Compare commits

..

34 Commits

Author SHA1 Message Date
sindoring ad820699bc docs(M02): publish character presentation handoff
Work-Package: M02-RND-CHARACTER-PRESENTATION-001

Agent: sindo-main-codex
2026-07-14 23:42:42 +04:00
sindoring 97bb53f6cd rnd(M02): extract character presenters
Work-Package: M02-RND-CHARACTER-PRESENTATION-001

Agent: sindo-main-codex

Tests: presentation/input/movement/terrain/camera regressions; coordinate/streaming/docs/coordination gates; renderer dry-run 7/7

Fidelity: preserves current sandbox model/outfit and Stand/Idle/Run/Walk behavior; build-12340 parity remains unverified
2026-07-14 23:42:12 +04:00
sindoring 68e6f60d90 Merge pull request 'rnd(M02): extract third-person camera rig' (#24) from work/sindo-main-codex/m02-camera-rig into master
Reviewed-on: #24
2026-07-14 22:30:33 +03:00
sindoring 02dec23c2a docs(M02): publish camera rig handoff
Work-Package: M02-RND-CAMERA-001
2026-07-14 23:27:41 +04:00
sindoring 8c1cf9be36 rnd(M02): extract third-person camera rig
Work-Package: M02-RND-CAMERA-001
2026-07-14 23:27:03 +04:00
sindoring f5bb64e6c7 Merge pull request 'gmp(M02): extract player terrain query' (#23) from work/sindo-main-codex/m02-terrain-query into master
Reviewed-on: #23
2026-07-14 22:17:12 +03:00
sindoring 0ce3cae208 docs(M02): publish terrain query handoff
Work-Package: M02-GMP-TERRAIN-QUERY-001
Agent: sindo-main-codex
Tests: coordination and documentation gates; diff check
Fidelity: documents inherited height/snap regression and remaining collision evidence gap
2026-07-14 23:11:23 +04:00
sindoring a45d521567 gmp(M02): extract player terrain query
Work-Package: M02-GMP-TERRAIN-QUERY-001
Agent: sindo-main-codex
Tests: terrain query, player input and movement regressions; asset-free scene; renderer dry-run; coordinate, streaming, documentation and coordination gates
Fidelity: preserves existing ADT outer-grid interpolation and player ground snap; build-12340 collision parity remains unverified
2026-07-14 23:10:55 +04:00
sindoring 5206c42cf0 Merge pull request 'gmp(M02): extract local player movement' (#22) from work/sindo-main-codex/m02-local-movement into master
Reviewed-on: #22
2026-07-14 22:01:21 +03:00
sindoring d2d6df3655 docs(M02): publish local movement handoff
Work-Package: M02-GMP-MOVEMENT-001
Agent: sindo-main-codex
Tests: coordination and documentation gates; diff check
Fidelity: documents preserved sandbox behavior and remaining build-12340 evidence gap
2026-07-14 22:59:59 +04:00
sindoring 435e1c95d2 gmp(M02): extract local player movement
Work-Package: M02-GMP-MOVEMENT-001
Agent: sindo-main-codex
Tests: local movement and player input regressions; asset-free scene; renderer dry-run; coordinate, streaming, documentation and coordination gates
Fidelity: preserves current sandbox speed, acceleration, sprint and camera-relative flight behavior; build-12340 parity remains unverified
2026-07-14 22:59:33 +04:00
sindoring 14dead194b Merge pull request 'gmp(M02): add player input intent seam' (#21) from work/sindo-main-codex/m02-player-input into master
Reviewed-on: #21
2026-07-14 21:52:26 +03:00
sindoring e794764269 docs(M02): publish player input handoff
Work-Package: M02-GMP-INPUT-001
Agent: sindo-main-codex
Tests: coordination and documentation gates; diff check
Fidelity: documents regression evidence and unverified build-12340 semantics
2026-07-14 22:45:14 +04:00
sindoring 6bd2e84048 gmp(M02): add player input intent seam
Work-Package: M02-GMP-INPUT-001
Agent: sindo-main-codex
Tests: verify_player_input; renderer baseline dry-run; coordinate, streaming, documentation and coordination gates
Fidelity: preserves current sandbox bindings and movement/camera behavior; exact build-12340 semantics remain unverified
2026-07-14 22:44:44 +04:00
sindoring 62eece991c Merge pull request 'docs(M01): claim milestone closeout' (#20) from work/sindo-main-codex/m01-integrator-closeout into master
Reviewed-on: #20
2026-07-14 12:55:09 +03:00
sindoring fc81059dfa docs(M01): mark milestone closeout ready
Work-Package: M01-QAR-INTEGRATOR-CLOSEOUT-001

Agent: sindo-main-codex
2026-07-14 12:14:39 +04:00
sindoring dd412e6d0d test(M01): close coordinates and seams milestone
Work-Package: M01-QAR-INTEGRATOR-CLOSEOUT-001

Agent: sindo-main-codex

Tests: all M01 domain gates, unified renderer dry-run, coordination and documentation gates

Fidelity: five build-12340 points, raw ADT and mapped AzerothCore spawn evidence; no 1:1 claim
2026-07-14 12:13:43 +04:00
sindoring 07f08044f4 docs(M01): claim milestone closeout
Work-Package: M01-QAR-INTEGRATOR-CLOSEOUT-001

Agent: sindo-main-codex
2026-07-14 12:01:49 +04:00
sindoring c8e99f28d6 Merge pull request 'chore(M01): remove local spawn capture logs' (#19) from work/sindo-main-codex/m01-server-spawn-renderer into master
Reviewed-on: #19
2026-07-14 10:58:37 +03:00
sindoring 48cc976ba0 docs(M01): mark server spawn renderer ready
Work-Package: M01-QAR-SERVER-SPAWN-RENDERER-001

Agent: sindo-main-codex

Tests: server spawn GUI cold/warm, unified renderer dry-run, domain and documentation gates
2026-07-14 11:56:39 +04:00
sindoring 318e6ae757 chore(M01): remove local spawn capture logs
Work-Package: M01-QAR-SERVER-SPAWN-RENDERER-001

Agent: sindo-main-codex

Tests: renderer dry-run, coordinate/domain gates, GUI cold/warm inspection
2026-07-14 11:55:05 +04:00
sindoring 747fae8e2e Merge pull request 'координаты' (#18) from work/sindo-main-codex/m01-server-spawn-renderer into master
Reviewed-on: #18
2026-07-14 10:48:53 +03:00
sindoring 24ddc99a7b координаты 2026-07-14 11:48:14 +04:00
sindoring 52b6fa2ebb Merge pull request 'docs(M01): claim server spawn renderer evidence' (#17) from work/sindo-main-codex/m01-server-spawn-renderer into master
Reviewed-on: #17
2026-07-14 10:42:14 +03:00
sindoring 3933dbfc47 docs(M01): claim server spawn renderer evidence
Work-Package: M01-QAR-SERVER-SPAWN-RENDERER-001

Agent: sindo-main-codex
2026-07-13 17:41:02 +04:00
sindoring a07a487368 docs(M01): mark coordinate boundary gate ready
Work-Package: M01-FND-COORD-BOUNDARY-GATE-001

Agent: sindo-main-codex
2026-07-13 16:40:07 +04:00
sindoring 47dec6c32c feat(M01): enforce coordinate conversion boundaries
Work-Package: M01-FND-COORD-BOUNDARY-GATE-001

Agent: sindo-main-codex
2026-07-13 16:37:29 +04:00
sindoring c8e62acf4c docs(M01): claim coordinate boundary gate
Work-Package: M01-FND-COORD-BOUNDARY-GATE-001

Agent: sindo-main-codex
2026-07-13 16:15:18 +04:00
sindoring 7c64e8d60a docs(M01): mark domain identities ready
Work-Package: M01-FND-DOMAIN-IDENTITIES-001

Agent: sindo-main-codex
2026-07-13 16:11:35 +04:00
sindoring c1dc07c705 feat(M01): add typed domain identities
Work-Package: M01-FND-DOMAIN-IDENTITIES-001

Agent: sindo-main-codex
2026-07-13 16:11:00 +04:00
sindoring d68786355c docs(M01): claim domain identity contracts
Work-Package: M01-FND-DOMAIN-IDENTITIES-001

Agent: sindo-main-codex
2026-07-13 16:05:55 +04:00
sindoring 77cd17a5bc docs(M01): mark streaming focus ready
Work-Package: M01-RND-STREAMING-FOCUS-001

Agent: sindo-main-codex
2026-07-13 15:45:32 +04:00
sindoring 7815385b3b feat(M01): add explicit streaming focus
Work-Package: M01-RND-STREAMING-FOCUS-001

Agent: sindo-main-codex
2026-07-13 15:41:16 +04:00
sindoring fbef131bcb docs(M01): claim streaming focus contract
Work-Package: M01-RND-STREAMING-FOCUS-001

Agent: sindo-main-codex
2026-07-13 15:35:45 +04:00
95 changed files with 5891 additions and 522 deletions
+4
View File
@@ -35,3 +35,7 @@
# CMake build директория # CMake build директория
/src/native/build/ /src/native/build/
# Local root-level diagnostic capture logs
/*.stdout.log
/*.stderr.log
+43
View File
@@ -830,6 +830,49 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- A focused 8-second GUI checkpoint and a seven-checkpoint single-pass GUI run finish without Node, renderer resource or RID leak diagnostics. A timing-dependent anonymous zero-reference `RefCounted` ObjectDB warning can remain at engine teardown; it is tracked separately and is not suppressed. - A focused 8-second GUI checkpoint and a seven-checkpoint single-pass GUI run finish without Node, renderer resource or RID leak diagnostics. A timing-dependent anonymous zero-reference `RefCounted` ObjectDB warning can remain at engine teardown; it is tracked separately and is not suppressed.
- This is shutdown-only ownership behavior and does not alter checkpoint rendering, cache formats or the `Blizzlike335` compatibility profile. - This is shutdown-only ownership behavior and does not alter checkpoint rendering, cache formats or the `Blizzlike335` compatibility profile.
## 2026-07-13 Explicit Streaming Focus
- `StreamingWorldLoader` consumes an immutable `StreamingFocus` containing a
typed `GodotWorldPosition`; runtime streaming no longer discovers the active
`Camera3D` from the viewport or child nodes.
- Eastern Kingdoms and Kalimdor runtime scenes explicitly use the player as the
streaming source. Checkpoint capture and terrain/occluder probes explicitly
use their dedicated camera through the same public refresh path.
- The editor viewport camera remains behind a dedicated editor-only adapter.
`camera_path` is retained solely for optional automatic overview positioning.
- Streaming radii, LOD decisions, cache formats and renderer quality profiles
are unchanged by this migration.
## 2026-07-13 Coordinate Conversion Boundary Gate
- Sky/area lookup, streaming tile ownership, player spawn/terrain sampling and
terrain diagnostics now cross world-coordinate spaces through typed
`CoordinateMapper` methods.
- Renderer distance, LOD, ray and model-local M2/WMO calculations remain in
their existing Godot/local spaces; they are not axis conversions.
- The repository gate rejects new manual world-center, WoW/Godot and
Godot-position-to-ADT formulas. The unified renderer baseline runs this gate.
- Native ADT/WDT parsers keep one shared world conversion in
`wow_chunk_reader.h`; the independent M00 calibration formula remains a test oracle.
- Coordinate contract version 2, renderer radii, cache formats and visual
quality profiles are unchanged.
## 2026-07-13 Golden Server Spawn Renderer Evidence
- The pinned AzerothCore Human Warrior start at server position
`(-8949.95, -132.493, 83.5312)` maps through `CoordinateMapper` to Godot
`(17199.159666667, 83.5312, 26016.616666667)`, ADT `(32,48)` and MCNK `(3,12)`.
- `server_spawn_render_manifest.json` drives the ordinary Eastern Kingdoms
checkpoint capture pipeline at that location; no live database or production
spawn entity is introduced.
- A renderer-native emissive marker starts at the exact mapped origin and keeps
depth testing enabled so GUI evidence exposes its relationship to terrain.
- `verify_server_spawn_renderer.gd` rejects drift between the pinned fixture,
manifest target/player coordinates and expected ADT ownership. The unified
renderer baseline runs this contract check before capture.
- This closes a coordinate/renderer integration criterion only. It does not
claim TrinityCore row equivalence or visual parity with the original client.
## Practical Rule For Future Work ## Practical Rule For Future Work
If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it. If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it.
@@ -0,0 +1,131 @@
# M01-FND-COORD-BOUNDARY-GATE-001 — Coordinate conversion boundary gate
<!-- OPENWC_CLAIM:M01-FND-COORD-BOUNDARY-GATE-001:sindo-main-codex:2026-07-15 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M01-FND-COORD-BOUNDARY-GATE-001:c8e99f2 -->
## Ownership
- Target: M01
- Program: FND/QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m01-coordinate-boundary-gate`
- Lease expires UTC: 2026-07-15
- Integrator: M01 milestone integrator
## Outcome
Route remaining GDScript world/tile axis conversions through `CoordinateMapper`
and add a repository-wide gate that rejects new manual world-center, axis-swap
or world-to-ADT tile formulas outside explicit contract/native-oracle boundaries.
## Non-goals
- Rewriting model-local M2/WMO vertex or placement orientation transforms.
- Moving renderer distance/LOD geometry out of Godot space.
- Replacing the native parser boundary with GDScript calls from worker threads.
- Changing coordinate contract semantics, renderer radii or cache formats.
## Paths
- Exclusive: `src/tools/verify_coordinate_conversion_boundaries.gd`
- Shared/hotspots: `src/domain/coordinates/coordinate_mapper.gd`, coordinate
tests/docs/ADR, streamer/player/sky/terrain-probe consumers,
`docs/CODING_STANDARD.md`, this claim
- Generated/ignored: Godot import metadata, extracted assets and renderer caches
## Contracts and data
- Additive mapper methods: Godot position to ADT tile/local and ADT tile/local
to Godot position
- Native exception: one formula definition in `src/native/src/wow_chunk_reader.h`;
ADT/WDT loaders may call it but may not define copies
- Oracle exception: `verify_render_coordinate_calibration.gd` retains one
independent M00 formula to detect common-mode mapper regressions
- Model-local transforms are outside the world-coordinate gate
## Dependencies
- Requires: coordinate contract v2 and M01 seams through `7c64e8d`
- Blocks: M01 coordinate-centralization exit criterion and milestone evidence audit
- External state: no dependency or proprietary input
## Verification
- Commands: conversion-boundary gate, mapper/golden fixtures, streaming focus,
identities, renderer calibration/manifest and repository gates
- Fixtures: Godot/ADT tile center, local offsets and exact boundaries
- Fidelity evidence: existing build-12340 points and raw ADT/server fixture remain unchanged
- Performance budget: mapper allocations occur only at adapter/update boundaries;
renderer distance/LOD math remains in Godot scalars
## Documentation deliverables
- Inline public API docs: new mapper methods
- Module specification/ADR: boundary APIs, allowed exceptions and gate behavior
- Coding standard: mandatory review rule for cross-space conversions
- Data-flow diagram: existing coordinate module diagram updated for direct adapters
- State/sequence diagrams: unchanged; pure synchronous mapping
## Simplicity and naming
- Important names introduced: `godot_to_adt_tile`, `godot_to_adt_tile_local`,
`adt_tile_local_to_godot`, `verify_coordinate_conversion_boundaries`
- Simplest considered solution: extend the existing mapper and scan source text
for narrow high-signal formula signatures
- Rejected complexity/abstractions: generic coordinate algebra framework,
native-to-GDScript calls on parser workers and a broad numeric-literal ban
- Unavoidable complexity and justification: one native boundary and one
independent test oracle must remain explicit to avoid worker coupling and common-mode tests
- Measured optimization evidence: existing renderer distance/LOD scalar math is unchanged
## Status
- State: ready-for-review
- Done: mapper extensions, consumer migration, repository gate, review rule,
integrated renderer gate, regression fixtures and documentation
- Next: integrator review, merge and M01 completion-evidence audit
- Blocked by:
## Handoff
- Commit: `47dec6c`
- Branch: `work/sindo-main-codex/m01-coordinate-boundary-gate`
- Outcome: all audited GDScript world/tile conversions in sky, streaming,
player spawn/ground sampling and terrain probes route through typed
`CoordinateMapper` boundaries. A repository scan rejects new manual
world-center, legacy axis-conversion, world-to-tile, tile-local and
tile-center formulas.
- Contract/schema: additive `godot_to_adt_tile`,
`godot_to_adt_tile_local` and `adt_tile_local_to_godot` APIs; no persisted
format, cache version or renderer radius changes.
- Verification: CoordinateConversionBoundaries PASS (`83` files, one native
boundary, one independent oracle, four consumers, six classifier guards);
CoordinateMapper PASS (`5` golden points, `8` tile boundaries, `2` raw MCNK,
`5` yaw cases); renderer calibration PASS (`5` points, maximum error
`0.000015`); renderer manifest PASS (`7/7/7`); unified render baseline dry-run,
headless project load, runtime cache shutdown, coordination, documentation
and whitespace gates passed.
- GUI regression: final ADT-boundary capture compared `2/2`, missing `0`,
failed `0` (cold mean `0.0002324`, changed ratio `0.0013741`; warm mean
`0.0000226`, changed ratio `0.0001285`). Earlier full capture produced all
`14` images; `11/14` met the strict changed-pixel threshold. The three
threshold-only differences retained low mean error (`<= 0.00622`) and were
visually classified as cold M2 readiness and liquid animation phase, with
unchanged camera, geometry alignment, area and streaming focus.
- Fidelity: existing build-12340 calibrated points, raw ADT fixture and server
spawn fixture remain unchanged. Native parser workers retain exactly one
formula in `wow_chunk_reader.h`; the calibration verifier retains exactly one
independent formula so mapper and oracle cannot share a common-mode defect.
- Rebuild: none. Local ignored extracted assets/native binary/cache junctions
were used for runtime checks and are not part of the commit.
- Remaining risks: source scanning intentionally recognizes narrow high-signal
formula signatures rather than parsing arbitrary algebra; native formula
parity still relies on calibration/raw fixtures. Model-local M2/WMO basis
transforms and Godot-space LOD distance math are intentionally outside scope.
- Recommended merge order: after `7c64e8d`; this package closes the remaining
M01 manual axis-conversion checklist item, but only the milestone integrator
may update target Evidence/status or set `OPENWC_TARGET_DONE`.
- Documentation: coding review rule, coordinate-mapping module API/input-output/
data-flow/capability/risk/source-map sections, ADR 0001 and `RENDER.md` were
updated. Existing pure synchronous data-flow diagram remains authoritative;
no state/sequence diagram changed.
@@ -1,7 +1,7 @@
# M01-FND-COORD-TILE-AXIS-002 — ADT filename axis correction # M01-FND-COORD-TILE-AXIS-002 — ADT filename axis correction
<!-- OPENWC_CLAIM:M01-FND-COORD-TILE-AXIS-002:sindo-main-codex:2026-07-15 --> <!-- OPENWC_CLAIM:M01-FND-COORD-TILE-AXIS-002:sindo-main-codex:2026-07-15 -->
<!-- OPENWC_HANDOFF:READY:M01-FND-COORD-TILE-AXIS-002:a7072e3 --> <!-- OPENWC_INTEGRATION:ACCEPTED:M01-FND-COORD-TILE-AXIS-002:c8e99f2 -->
## Ownership ## Ownership
+1 -1
View File
@@ -1,7 +1,7 @@
# M01-FND-COORDS-001 — Canonical coordinate contracts # M01-FND-COORDS-001 — Canonical coordinate contracts
<!-- OPENWC_CLAIM:M01-FND-COORDS-001:sindo-main-codex:2026-07-15 --> <!-- OPENWC_CLAIM:M01-FND-COORDS-001:sindo-main-codex:2026-07-15 -->
<!-- OPENWC_HANDOFF:READY:M01-FND-COORDS-001:f45695c --> <!-- OPENWC_INTEGRATION:ACCEPTED:M01-FND-COORDS-001:c8e99f2 -->
## Ownership ## Ownership
@@ -0,0 +1,112 @@
# M01-FND-DOMAIN-IDENTITIES-001 — Stable domain identity namespaces
<!-- OPENWC_CLAIM:M01-FND-DOMAIN-IDENTITIES-001:sindo-main-codex:2026-07-15 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M01-FND-DOMAIN-IDENTITIES-001:c8e99f2 -->
## Ownership
- Target: M01
- Program: FND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m01-domain-identities`
- Lease expires UTC: 2026-07-15
- Integrator: M01 milestone integrator
## Outcome
Add scene-free, immutable identity values for stable authored content,
session-local entities, opaque WoW wire GUIDs and namespaced server entries so
callers cannot silently interchange their numeric/string representations.
## Non-goals
- Allocating Content IDs, Entity IDs or server entries.
- Decoding WoW GUID high types before the build-12340 protocol package.
- Defining map/spell/item/quest/display/sound IDs in this package.
- Migrating gameplay, network or Content Project consumers that do not exist yet.
## Paths
- Exclusive: `src/domain/identity/`, `src/tools/verify_domain_identities.gd`,
`docs/modules/domain-identities.md`, `docs/adr/0002-domain-identity-namespaces.md`
- Shared/hotspots: `docs/ARCHITECTURE.md`, this claim
- Generated/ignored: Godot import metadata
## Contracts and data
- `ContentId`: canonical lowercase UUID text persisted with schema-owned content
- `EntityId`: positive session-local integer, never a persisted content/server identity
- `WowGuid`: opaque high/low unsigned 32-bit words preserving wire bits
- `ServerEntryId`: positive integer plus non-empty adapter/table namespace
- Migration: additive types only; no existing persisted data changes
## Dependencies
- Requires: M01 coordinate/focus seams through `77cd17a`
- Blocks: Content Project schemas, server adapters, network entity mapping and gameplay registry
- External state: no dependency or proprietary input
## Verification
- Commands: identity contract/boundary verifier, dependency search,
coordination/documentation gates and headless project load
- Fixtures: valid/invalid UUIDs, session IDs, full unsigned GUID words and
duplicate numeric server entries in different namespaces
- Fidelity evidence: GUID words remain opaque and lossless until build-12340
packet fixtures establish semantic decomposition
- Performance budget: constant-time scalar/string validation outside frame loops
## Documentation deliverables
- Inline public API docs: every value and validation/equality method
- Module specification: persistence, ownership, namespace mapping and recovery
- Data-flow diagram: authored/runtime/wire/server identities into adapters
- State/sequence diagrams: lifecycle diagram for content/session/wire/server identity
- ADR: namespace separation and UUID persistence decision
## Simplicity and naming
- Important names introduced: `ContentId`, `EntityId`, `WowGuid`, `ServerEntryId`
- Simplest considered solution: four independent values with explicit fields
- Rejected complexity/abstractions: generic ID base class, registry service,
UUID generator and speculative GUID high-type enum
- Unavoidable complexity and justification: server entry namespace and split GUID
words prevent collisions and signed 64-bit loss
- Measured optimization evidence: not applicable
## Status
- State: ready-for-review
- Done: four identity values, boundary verifier, ADR, module specification,
architecture mapping and repository gates
- Next: integrator review and merge before the manual axis-conversion gate
- Blocked by:
## Handoff
- Commit: `c1dc07c`
- Branch: `work/sindo-main-codex/m01-domain-identities`
- Outcome: authored content, runtime entities, WoW wire GUIDs and namespaced
server entries now have separate immutable scene-free value objects
- Contract/schema: identity contract version 1; `ContentId` uses canonical
lowercase UUID text. No existing schema/cache is migrated; containing future
schemas still require their own version and duplicate/reference policy
- Verification: DomainIdentities PASS (`5` ContentId, `5` EntityId, `6` WowGuid,
`5` ServerEntryId cases); StreamingFocus, coordinate golden fixtures and
CoordinateMapper PASS; M00 calibration PASS with maximum error `0.000015`;
renderer manifest PASS (`7` checkpoints); headless project load exit `0`;
coordination, documentation, naming/dependency and whitespace checks passed
- Fidelity: `WowGuid` preserves two complete unsigned 32-bit wire words and
explicitly does not claim build-12340 high-type semantics without packet
fixtures; zero GUID remains an uninterpreted observable sentinel
- Rebuild: none; no dependency or proprietary input. Clean-worktree project
load reports expected missing ignored extracted assets/character resources
- Remaining risks: no ID allocator, mapping registry or server namespace
vocabulary exists yet; UUID shape does not enforce generator version/variant;
GDScript callers must use domain `equals` or canonical keys rather than
reference `==`
- Recommended merge order: after StreamingFocus at `77cd17a`, before the M01
manual axis-conversion rule/search gate
- Documentation: ADR 0002, domain-identities module API/input-output/data-flow/
lifecycle/persistence/recovery/capability/source-map sections, module registry
and architecture Domain mapping added or updated
@@ -1,7 +1,7 @@
# M01-QAR-COORD-FIXTURES-001 — Coordinate golden fixtures # M01-QAR-COORD-FIXTURES-001 — Coordinate golden fixtures
<!-- OPENWC_CLAIM:M01-QAR-COORD-FIXTURES-001:sindo-main-codex:2026-07-15 --> <!-- OPENWC_CLAIM:M01-QAR-COORD-FIXTURES-001:sindo-main-codex:2026-07-15 -->
<!-- OPENWC_HANDOFF:READY:M01-QAR-COORD-FIXTURES-001:ce4f7ec --> <!-- OPENWC_INTEGRATION:ACCEPTED:M01-QAR-COORD-FIXTURES-001:c8e99f2 -->
## Ownership ## Ownership
@@ -0,0 +1,101 @@
# M01-QAR-INTEGRATOR-CLOSEOUT-001 — M01 milestone closeout
<!-- OPENWC_CLAIM:M01-QAR-INTEGRATOR-CLOSEOUT-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M01-QAR-INTEGRATOR-CLOSEOUT-001:dd412e6 -->
## Ownership
- Target: M01
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m01-integrator-closeout`
- Lease expires UTC: 2026-07-16
- Integrator: sindo-main-codex, continuing the user-directed milestone workflow on 2026-07-14
## Outcome
Audit every merged M01 work package on current `master`, record final evidence,
close M01 and activate M02 only if all target verification and documentation
criteria pass.
## Non-goals
- Change coordinate, identity, streaming or renderer behavior.
- Start M02 implementation.
- Add new compatibility or visual-parity claims.
- Commit proprietary assets, generated UIDs, caches or capture output.
## Paths
- Exclusive: M01/M02 target status, M01 final Evidence and M01 claim acceptance markers
- Shared/hotspots: `targets/README.md`, `targets/DEVELOPMENT_ROADMAP.md`, this claim
- Generated/ignored: local Godot imports, renderer corpus and `user://` reports
## Contracts and data
- Public APIs, schemas, cache formats and migrations: unchanged
- Coordinate contract remains version 2
- Fixture schema remains version 1
- Consumers: milestone sequence and M02 work packages
## Dependencies
- Requires: all seven M01 READY commits integrated through master `c8e99f2`
- Blocks: M02 activation
- External state: server-spawn GUI hashes and original-client numeric evidence are recorded; proprietary files remain outside Git
## Verification
- Commands: all M01 domain/fixture/boundary/focus/identity/spawn gates,
unified renderer dry-run, coordination/documentation checks and diff hygiene
- Fidelity evidence: five build-12340 points, raw ADT metadata, pinned
AzerothCore spawn and cache-complete GUI marker capture
- Performance: no runtime change; preserve recorded renderer cold/warm evidence
## Documentation deliverables
- M01 final target Evidence and checklist
- M01 claim integration-acceptance markers
- Target index and roadmap transition to M02
- API/module specifications and diagrams are audited, not changed unless a gap is found
## Simplicity and naming
- Important names introduced: none beyond the closeout work-package ID
- Simplest approach: evidence/status-only integrator patch
- Rejected complexity: new one-off closeout framework or behavioral changes
- Unavoidable complexity: none
## Status
- State: ready-for-review
- Done: accepted seven M01 packages, reran post-merge gates, recorded complete
target Evidence, closed M01 and activated M02
- Next: merge closeout commit before starting an M02 implementation claim
- Blocked by:
## Handoff
- Commit: `dd412e6`
- Branch: `work/sindo-main-codex/m01-integrator-closeout`
- Outcome: M01 is `DONE` with all seven checklist items and complete Evidence;
M02 is the only `ACTIVE` target and `targets/README.md` points to it.
- Integration audit: all seven M01 READY commits are ancestors of master
`c8e99f2`; their claims now carry `OPENWC_INTEGRATION:ACCEPTED` markers.
- Verification: unified renderer dry-run passed project load, material/dedupe/
shutdown gates, manifest `7/7/7`, calibration maximum error `0.000015`,
coordinate boundary (`84` files) and server-spawn renderer `(32,48)/(3,12)`;
coordinate mapper, golden fixtures, StreamingFocus and domain identity gates
passed. Coordination and documentation gates passed; coordination reports
only five already-expired M00 claim warnings.
- Fidelity: closeout preserves five build-12340 coordinate observations, raw ADT
evidence, pinned AzerothCore spawn mapping and cache-complete Northshire GUI
evidence without claiming original-client visual parity or TrinityCore row
equivalence.
- Rebuild/migration: none. Coordinate contract remains version 2; fixture schema
remains version 1; renderer cache formats are unchanged.
- Remaining risks: recorded in M01 Evidence—TrinityCore populated spawn,
placement Euler parity, native parser boundary and broader renderer parity.
- Documentation: M01 target Evidence/checklist, target index and roadmap current
state updated. Existing coordinate, identity and renderer module specs/API/
diagrams were audited and passed the documentation gate.
@@ -0,0 +1,135 @@
# M01-QAR-SERVER-SPAWN-RENDERER-001 — Golden server spawn renderer evidence
<!-- OPENWC_CLAIM:M01-QAR-SERVER-SPAWN-RENDERER-001:sindo-main-codex:2026-07-15 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M01-QAR-SERVER-SPAWN-RENDERER-001:c8e99f2 -->
## Ownership
- Target: M01
- Program: QAR/RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m01-server-spawn-renderer`
- Lease expires UTC: 2026-07-15
- Integrator: M01 milestone integrator
## Outcome
Route the pinned AzerothCore Human Warrior spawn fixture through
`CoordinateMapper` into the live Eastern Kingdoms renderer and produce
repeatable headless contract checks plus GUI screenshot evidence at the mapped
ADT tile/chunk.
## Non-goals
- Connecting to a live AzerothCore or TrinityCore database.
- Claiming TrinityCore spawn equivalence without a pinned populated TDB row.
- Changing coordinate formulas, streaming radii, terrain, player movement or
production character presentation.
- Adding proprietary assets or screenshots to Git.
## Paths
- Exclusive: `src/tools/server_spawn_render_manifest.json`,
`src/tools/verify_server_spawn_renderer.gd`
- Shared/hotspots: `src/tools/capture_render_checkpoints.gd`, renderer runner,
coordinate/world-renderer module documentation, `RENDER.md`, this claim
- Generated/ignored: Godot UIDs/import metadata and `user://` PNG/JSON evidence
## Contracts and data
- Fixture schema and coordinate contract version remain unchanged.
- Additive diagnostic checkpoint metadata may request a renderer-native marker;
existing M00 checkpoints remain byte-for-byte compatible.
- The spawn remains sourced from pinned AzerothCore revision
`68fcf0098b16388093989627f37be530fc91f07d`.
## Dependencies
- Requires: merged M01 packages through `a07a487`
- Blocks: M01 golden server-spawn renderer criterion and milestone closeout
- External state: GUI capture requires the existing local extracted/cached
renderer corpus; headless contract verification remains non-proprietary
## Verification
- Commands: dedicated spawn verifier, coordinate fixture/mapper/boundary gates,
renderer baseline dry-run, GUI checkpoint capture and human inspection
- Fixture: AzerothCore race `1`, class `1`, map `0`, server position
`(-8949.95, -132.493, 83.5312)`
- Expected renderer result: Godot position
`(17199.159666667, 83.5312, 26016.616666667)`, ADT `(32,48)`, MCNK `(3,12)`
- Fidelity classification: server/coordinate integration evidence only; it does
not establish full original-client visual parity
## Documentation deliverables
- Inline API/tool documentation for new verifier and marker metadata
- Coordinate-mapping module capability/evidence update
- World-renderer checkpoint input/output/source-map update when marker support is added
- Existing synchronous coordinate data-flow diagram updated only if flow changes
## Simplicity and naming
- Important names: `server_spawn_render_manifest`,
`verify_server_spawn_renderer`, `diagnostic_spawn_marker`
- Simplest approach: reuse the existing checkpoint capture pipeline with one
dedicated manifest and a renderer-native diagnostic marker
- Rejected complexity: live DB adapter, new scene-test framework and production
spawn entity implementation before the network/gameplay milestones
- Performance: one diagnostic checkpoint outside production runtime
## Status
- State: ready-for-review
- Done: fixture-to-renderer manifest/verifier, renderer-native marker, unified
baseline integration, cold/warm GUI evidence, documentation and log cleanup
- Next: integrator review, merge cleanup/handoff and M01 closeout audit
- Blocked by:
## Handoff
- Commits: implementation `24ddc99`; required cleanup `318e6ae`
- Branch: `work/sindo-main-codex/m01-server-spawn-renderer`
- Outcome: the pinned AzerothCore Human Warrior start is routed through
`CoordinateMapper` into the ordinary Eastern Kingdoms capture scene. A
depth-tested magenta marker begins at the exact mapped spawn origin.
- Contract/schema: additive diagnostic manifest schema 1 and marker metadata;
coordinate contract version 2, production renderer APIs, cache formats and
`Blizzlike335` behavior are unchanged.
- Verification: `SERVER_SPAWN_RENDERER PASS spawn=1 checkpoint=1 tile=32_48
chunk=3_12 profile=Blizzlike335`; unified renderer dry-run passed project,
materials, M2 dedupe, shutdown, `7/7/7` manifest, five-point calibration,
coordinate-boundary and server-spawn steps; coordinate golden fixtures,
mapper, StreamingFocus, domain identities, documentation and whitespace gates
passed. Coordination passed with five expired M00-claim warnings only.
- GUI evidence: two `1280x900` cold/warm PNGs captured under
`user://server_spawn_renderer_m01_gui_run2`; focus tile `(32,48)`, terrain
quality `100/100`, missing cache `0`, queues drained, report results `2`.
Human inspection confirms the marker is on rendered terrain beside the road
at Northshire Abbey rather than below terrain or in an adjacent tile. PNG
hashes: cold `c08c43bcb75e5b3277105afb0d4e5a76caae9db91a7db64488770ffa03e32589`,
warm `1b75237a090ea163836372a8e39e778c909773f4508063adc3f70a3e12a85a62`.
- Fidelity: source remains AzerothCore revision
`68fcf0098b16388093989627f37be530fc91f07d`; server
`(-8949.95,-132.493,83.5312)` maps to Godot
`(17199.159666667,83.5312,26016.616666667)`, ADT `(32,48)`, MCNK `(3,12)`.
This proves coordinate/renderer integration, not original-client visual parity
or TrinityCore populated-row equivalence.
- Runtime notes: D3D12 descriptor-heap initialization fell back to Vulkan;
optional ignored character/ambient GLB imports were unavailable. Neither
affected terrain/marker evidence. The known anonymous ObjectDB teardown
warning remained; no Node, renderer resource or RID leak diagnostic occurred
in the cache-complete run.
- Repository hygiene: four accidentally committed local GUI logs are removed by
`318e6ae`; root `*.stdout.log`/`*.stderr.log` files are now ignored. Generated
PNG/JSON evidence and proprietary corpus remain outside Git.
- Remaining risks: the diagnostic marker is not a production network entity;
live server adapters belong to M06/M08. Exact original-client character spawn
presentation and TrinityCore database equivalence remain unverified.
- Documentation: `RENDER.md`, renderer-baseline procedure, coordinate-mapping
and world-renderer module API/input-output/data-flow/capability/source-map
sections updated. No lifecycle/state diagram changed because the verifier and
mapper path are synchronous; capture reuses the documented renderer lifecycle.
- Recommended merge order: `318e6ae` after master merge `747fae8`. Then the M01
milestone integrator may accept all M01 claims, update target Evidence/checks
and evaluate `OPENWC_TARGET_DONE`.
@@ -0,0 +1,120 @@
# M01-RND-STREAMING-FOCUS-001 — Explicit streaming focus contract
<!-- OPENWC_CLAIM:M01-RND-STREAMING-FOCUS-001:sindo-main-codex:2026-07-15 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M01-RND-STREAMING-FOCUS-001:c8e99f2 -->
## Ownership
- Target: M01
- Program: RND/FND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m01-streaming-focus`
- Lease expires UTC: 2026-07-15
- Integrator: M01 milestone integrator
## Outcome
Introduce a scene-free `StreamingFocus` value and make runtime/test streaming
consume it through an explicit source or public setter instead of discovering
the active `Camera3D` from a viewport.
## Non-goals
- Extracting `WorldRenderFacade` or `StreamingTargetPlanner` from the monolithic loader.
- Adding direction, velocity, frustum or multi-focus planning.
- Decomposing the player controller before M02.
- Changing terrain, M2, WMO, liquid, cache or LOD formulas.
## Paths
- Exclusive: `src/domain/streaming/`, `src/tools/verify_streaming_focus.gd`
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
`src/scenes/streaming/eastern_kingdoms_streaming.tscn`,
`src/scenes/streaming/kalimdor_streaming.tscn`, renderer diagnostic tools,
`docs/modules/world-renderer.md`, this claim
- Generated/ignored: local extracted assets, renderer caches and Godot import metadata
## Contracts and data
- Public API: immutable `StreamingFocus`; loader `set_streaming_focus` and
`refresh_streaming_focus`
- Source adapter: explicit `streaming_focus_source_path` may reference any `Node3D`
- Migration: runtime scenes use player focus; capture/probe tools explicitly use
their dedicated camera as the source
- Compatibility: streaming radii/LOD/cache behavior and coordinate contract v2 unchanged
## Dependencies
- Requires: coordinate contract version 2 and golden fixtures at `52e1b2b`
- Blocks: M01 renderer/test focus exit criterion and later renderer facade extraction
- External state: no new dependency or proprietary input
## Verification
- Commands: streaming focus contract/integration verifier, renderer baseline
manifest/calibration, headless scene smoke, coordination/documentation gates
- Fixtures: Node3D player-style source and explicit capture-style setter
- Fidelity evidence: existing seven renderer checkpoints and build-12340 coordinate
calibration must remain unchanged
- Performance budget: one scalar focus snapshot per configured streaming interval;
no new per-tile or per-chunk work
## Documentation deliverables
- Inline public API docs: focus value and loader public methods
- Module specification: inputs/outputs, ownership, lifecycle, failure/recovery and source map
- Data-flow diagram: player/editor/capture source to focus contract to loader
- State/sequence diagram: focus sampling and refresh threshold sequence
## Simplicity and naming
- Important names introduced: `StreamingFocus`, `streaming_focus_source_path`,
`set_streaming_focus`, `refresh_streaming_focus`
- Simplest considered solution: one immutable value plus one explicit scene adapter path
- Rejected complexity/abstractions: provider interface hierarchy, service locator,
multi-focus registry and predictive motion fields
- Unavoidable complexity and justification: editor viewport remains a separate
adapter because its camera is owned by `EditorInterface`
- Measured optimization evidence: not applicable
## Status
- State: ready-for-review
- Done: contract, runtime/editor/capture migration, headless regression,
module diagrams, renderer documentation and cold/warm GUI capture
- Next: integrator review and merge before typed identity work
- Blocked by:
## Handoff
- Commit: `7815385`
- Branch: `work/sindo-main-codex/m01-streaming-focus`
- Outcome: runtime streaming consumes immutable `StreamingFocus`; Eastern
Kingdoms and Kalimdor scenes explicitly follow the player, while capture and
terrain/occluder probes explicitly follow their dedicated camera
- Contract/API: new scene-free `StreamingFocus(GodotWorldPosition)` plus loader
`set_streaming_focus`, `refresh_streaming_focus` and
`streaming_focus_source_path`; `camera_path` remains only for optional overview positioning
- Verification: StreamingFocus PASS (`1` contract, `2` runtime scenes, `3`
capture tools); coordinate golden fixtures and CoordinateMapper PASS; M00
calibration PASS with maximum error `0.000015`; manifest PASS (`7`
checkpoints); runtime-cache shutdown PASS; headless runtime scene exit `0`;
coordination, documentation, dependency and whitespace checks passed
- Renderer checkpoint: standard 8-second GUI capture completed `14` images
(`7` cold + `7` warm) and wrote schema-1 `report.json`; focus tiles changed
through the public contract; no Node/resource/RID leak diagnostic appeared
- Fidelity: checkpoint camera positions, coordinate contract v2, streaming radii,
LOD formulas, cache formats and quality profiles are unchanged; this is
regression evidence, not a new `1:1` parity claim
- Rebuild: none; GUI verification used local ignored extracted assets/cache
junctions. Missing worktree `.godot/imported` copies for several incidental M2
GLBs produced existing cache-load fallback diagnostics only
- Remaining risks: the monolithic loader still owns planning/jobs/presentation;
direction, velocity, frustum and multiple foci remain M03 planner work. The
known timing-dependent zero-reference `ObjectDB` shutdown warning occurred,
without Node/resource/RID leak diagnostics
- Recommended merge order: after coordinate fixtures at `52e1b2b`, before typed
identity and renderer-facade extraction
- Documentation: `world-renderer.md` inputs/outputs, public API, lifecycle,
data-flow and sequence diagrams, ownership, recovery, verification,
capability matrix, risks and source map updated; `RENDER.md` updated
+112
View File
@@ -0,0 +1,112 @@
# M02-GMP-INPUT-001 — Player input intent seam
<!-- OPENWC_CLAIM:M02-GMP-INPUT-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M02-GMP-INPUT-001:6bd2e84 -->
## Ownership
- Target: M02
- Program: GMP/FND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-player-input`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Replace direct keyboard polling in the runtime player with remappable Input Map
actions and an immutable `PlayerInputSource → MoveIntent` boundary while
preserving the current render-sandbox movement behavior.
## Non-goals
- Extract local movement, terrain query, camera rig or character presenters.
- Claim exact build-12340 movement/keybinding parity before original-client fixtures.
- Change movement speeds, camera geometry, terrain sampling or renderer behavior.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/domain/input/`, `src/gameplay/input/`,
`src/tests/scenes/player_input_regression.tscn`,
`src/tools/verify_player_input.gd`, `docs/modules/player-input.md`, this claim
- Shared/hotspots: `project.godot`,
`src/scenes/player/third_person_wow_controller.gd`, `docs/modules/README.md`
- Generated/ignored: Godot import metadata, `.uid` files and local renderer corpus
## Contracts and data
- Public API: immutable `MoveIntent`; `PlayerInputSource.sample_move_intent`
- Configuration: additive `openwc_player_*` Input Map actions
- Persistence/schema/cache: unchanged
- Consumers: current sandbox player controller; later local movement controller
## Dependencies
- Requires: merged M01 coordinate/streaming-focus contracts on master `62eece9`
- Blocks: later M02 movement-controller extraction
- External state: none; tests use synthetic Input Map events
## Verification
- Commands: dedicated player-input verifier, headless project load, coordinate
boundary gate, coordination/documentation gates and diff hygiene
- Fixtures: synthetic action strengths and configured default bindings
- Fidelity evidence: current W/S/A/D, Shift, E/Q and Space sandbox behavior remains
mapped through actions; exact 3.3.5a semantics remain unverified
- Performance budget: one immutable intent allocation per physics tick; no I/O
## Documentation deliverables
- Inline API documentation for `MoveIntent`, `PlayerInputActions` and
`PlayerInputSource`
- Player-input module specification with inputs/outputs, data-flow and sequence diagrams
- Module registry/source map and honest capability status
## Simplicity and naming
- Important names: `MoveIntent`, `PlayerInputSource`, `PlayerInputActions`
- Simplest approach: immutable value, one input adapter and one controller consumer
- Rejected complexity: input provider hierarchy, event bus and generic command framework
- Unavoidable complexity: none
- Measured optimization evidence: not applicable
## Status
- State: ready-for-review
- Done: twelve remappable actions, immutable `MoveIntent`, stateless
`PlayerInputSource`, controller migration, asset-free regression scene,
dedicated verifier and module specification
- Next: M02 integrator reviews and merges before local movement extraction
- Blocked by:
## Handoff
- Commit: `6bd2e84`
- Branch: `work/sindo-main-codex/m02-player-input`
- Outcome: the runtime player no longer polls physical keyboard keys. Movement
action strengths cross an immutable `MoveIntent` seam; camera and immediate
sandbox-flight events use the same remappable action catalog.
- Public contract: additive `MoveIntent`, `PlayerInputActions` and
`PlayerInputSource`; no schema, cache, coordinate or renderer contract change.
- Verification: `PLAYER_INPUT PASS actions=12 intent_cases=6 controller=1
regression_scene=1`; asset-free scene smoke exited `0`; coordinate boundary
(`88` files) and StreamingFocus gates passed; unified renderer dry-run passed
all eight pre-capture gates and produced seven dry-run checkpoint results;
coordination/documentation/diff gates passed.
- Fidelity: W/S/A/D, Shift, E/Q, Space, right mouse, wheel and Escape remain the
default sandbox bindings. Regression drives forward movement, camera zoom and
immediate flight toggle through the real controller. This does not establish
exact build-12340 default bindings, turn/strafe policy or gameplay movement parity.
- Local verification inputs: the isolated worktree reused the existing ignored
native DLL and generated ADT resource scripts. Proprietary extracted data and
the character GLB were absent, so the renderer dry-run logged the expected
missing-corpus diagnostics while all contract steps returned success.
- Rebuild/migration: none. Action names are additive; persisted user-binding
migration does not exist yet and is documented as a gap.
- Remaining risks: production profile gating for sprint/free flight, exact
3.3.5a bindings, local movement/terrain/camera/presentation extraction and
broader movement/camera state tests remain M02 work.
- Documentation: inline API comments plus `docs/modules/player-input.md` with
inputs/outputs, data-flow and sequence diagrams, ownership, recovery,
capability status, known gaps and source map.
+117
View File
@@ -0,0 +1,117 @@
# M02-GMP-MOVEMENT-001 — Local movement state/controller
<!-- OPENWC_CLAIM:M02-GMP-MOVEMENT-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M02-GMP-MOVEMENT-001:435e1c9 -->
## Ownership
- Target: M02
- Program: GMP
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-local-movement`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Move local velocity, sandbox flight state, acceleration and directional speed
calculation from `ThirdPersonWowController` into a scene-free,
independently-testable local movement controller without changing current
sandbox motion.
## Non-goals
- Extract terrain queries, camera rig, character presentation or animation.
- Add jump/fall/swim, server prediction or reconciliation.
- Change current movement speeds, acceleration, camera-relative flight or ground snap.
- Gate debug movement by build profile; that remains a separate M02 package.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/gameplay/movement/`,
`src/tools/verify_local_player_movement.gd`,
`docs/modules/local-player-movement.md`, this claim
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`,
`src/tools/verify_player_input.gd`, `docs/modules/player-input.md`,
`docs/modules/README.md`
- Generated/ignored: local `.godot`, native DLL, generated ADT resources and renderer corpus
## Contracts and data
- Public API: stateful `LocalPlayerMovementController`
- Inputs: immutable `MoveIntent`, local Godot movement basis and physics delta seconds
- Outputs: local Godot displacement and read-only velocity/flight state
- Schema/cache/coordinate contracts: unchanged
- Consumer: current sandbox player scene; later gameplay composition root
## Dependencies
- Requires: merged input seam on master `14dead1`
- Blocks: terrain query and camera/presentation extraction from the player hotspot
- External state: none; unit tests use synthetic intents and bases
## Verification
- Commands: dedicated pure movement verifier, player-input regression, asset-free
scene smoke, coordinate/StreamingFocus gates, renderer dry-run and repository gates
- Fixtures: identity/rotated/pitched bases, acceleration steps, sprint and flight transitions
- Fidelity evidence: exact current 7.0/4.5/4.5 speeds, 28.0 acceleration,
6x sandbox sprint and camera-relative free flight are regression-locked only
- Performance budget: one small state update per physics tick; no allocation, I/O or scene lookup
## Documentation deliverables
- Inline public API docs for movement state/configuration and update methods
- Local movement module specification with data-flow, state and sequence diagrams
- Updated player-input consumer diagram/status and module registry
## Simplicity and naming
- Important name: `LocalPlayerMovementController`
- Simplest approach: one stateful RefCounted owning velocity and flight toggle
- Rejected complexity: generic locomotion framework, ECS component hierarchy and event bus
- Unavoidable complexity: movement basis remains an explicit local-space input
so the pure controller does not discover player/camera Nodes
- Measured optimization evidence: not applicable
## Status
- State: ready-for-review
- Done: scene-free movement state/controller, player scene migration, twelve
numeric cases, two flight transitions, integrated regression and module documentation
- Next: M02 integrator reviews and merges before TerrainQuery extraction
- Blocked by:
## Handoff
- Commit: `435e1c9`
- Branch: `work/sindo-main-codex/m02-local-movement`
- Outcome: `ThirdPersonWowController` no longer owns velocity, flight state,
acceleration or directional speed calculation. It selects an explicit
Godot-world basis, applies returned displacement, and retains terrain and
presentation adapters for later M02 packages.
- Public contract: additive `LocalPlayerMovementController` with read-only
Godot-world velocity/flight state, `toggle_sandbox_flight` and `advance`.
Input, coordinate, cache and renderer contracts remain unchanged.
- Verification: `LOCAL_PLAYER_MOVEMENT PASS cases=12 state_transitions=2
scene_boundary=1`; `PLAYER_INPUT PASS actions=12 intent_cases=6 controller=1
regression_scene=1`; asset-free scene smoke exited `0`; coordinate boundary
and StreamingFocus passed; renderer dry-run passed all eight pre-capture gates
and seven checkpoint plans; coordination/documentation/diff gates passed.
- Fidelity: exact existing defaults are regression-locked—forward `7.0`,
backward/strafe `4.5`, acceleration `28.0`, sandbox sprint `6x`, vertical
flight `7.0`, velocity reset on toggle and camera-relative pitched flight.
This preserves the sandbox baseline but is not original-client evidence.
- Local verification inputs: the isolated worktree reused the existing ignored
native DLL and generated ADT resource scripts. Proprietary extracted data and
character GLB were absent, so renderer dry-run logged expected missing-corpus
diagnostics while every contract step returned success.
- Rebuild/migration: none; transient movement state is not persisted.
- Remaining risks: exported speed changes after `_ready` do not reconfigure the
controller; exact build-12340 movement semantics, terrain/collision state,
jump/fall/swim, server prediction and debug profile gating remain open.
- Documentation: inline public API plus `docs/modules/local-player-movement.md`
with inputs/outputs, data-flow, state and sequence diagrams, ownership,
recovery, capability status, risks and source map; player-input consumer
diagrams/status were updated.
@@ -0,0 +1,118 @@
# M02-GMP-TERRAIN-QUERY-001 — Player terrain query boundary
<!-- OPENWC_CLAIM:M02-GMP-TERRAIN-QUERY-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M02-GMP-TERRAIN-QUERY-001:a45d521 -->
## Ownership
- Target: M02
- Program: GMP/RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-terrain-query`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Introduce a typed `TerrainQuery` boundary and move ADT loading, caching, chunk
selection and height interpolation out of `ThirdPersonWowController` without
changing current spawn/ground-snap behavior.
## Non-goals
- Change ADT topology, coordinate formulas, terrain mesh or renderer caches.
- Add physics collision, slopes, water, jump/fall/swim or server movement.
- Reuse the streaming renderer cache before its public terrain-query contract exists.
- Extract camera or character presentation.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/gameplay/terrain/`, `src/tools/verify_terrain_query.gd`,
`docs/modules/terrain-query.md`, this claim
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`,
`src/tools/verify_player_input.gd`,
`src/tools/verify_coordinate_conversion_boundaries.gd`,
`docs/modules/local-player-movement.md`,
`docs/modules/README.md`
- Generated/ignored: local `.godot`, native DLL, generated ADT resources and renderer corpus
## Contracts and data
- Public API: `TerrainQuery.sample_ground_height(GodotWorldPosition)` returning
immutable `TerrainGroundSample`
- Production adapter: `AdtTerrainQuery` with owned per-tile parse cache
- Test/data-source seam: optional ADT tile loader callable at construction
- Coordinate contract version, ADT format, renderer cache and persistence: unchanged
- Consumers: current player scene; future movement/collision policies
## Dependencies
- Requires: merged movement controller on master `5206c42`
- Blocks: camera/presentation extraction from the player hotspot
- External state: production uses existing native `ADTLoader`; synthetic tests need no proprietary data
## Verification
- Commands: synthetic height/cache/failure verifier, player input/movement
regressions, asset-free scene, coordinate/StreamingFocus gates, renderer dry-run
and repository gates
- Fixtures: synthetic 145-height chunk payload and injected flat terrain query
- Fidelity evidence: preserves existing outer-grid bilinear interpolation,
spawn ground offset and per-tick snap formula only
- Performance budget: one cached tile lookup and local interpolation per ground query;
no new I/O after first tile load
## Documentation deliverables
- Inline public API docs for query, sample and ADT adapter
- Terrain-query module spec with inputs/outputs, data-flow, cache lifecycle and sequence diagrams
- Updated movement/coordinate consumer boundaries, source maps and module registry
## Simplicity and naming
- Important names: `TerrainQuery`, `TerrainGroundSample`, `AdtTerrainQuery`
- Simplest approach: one narrow query contract, one typed result and one existing-format adapter
- Rejected complexity: general collision framework, terrain service locator and renderer-cache coupling
- Unavoidable complexity: ADT 145-height layout and chunk lookup stay isolated in adapter
- Measured optimization evidence: existing per-tile cache retained; no new optimization
## Status
- State: ready-for-review
- Done: typed query/sample contract, cached ADT adapter, player migration,
synthetic interpolation/cache/failure tests, injected scene regression and documentation
- Next: M02 integrator reviews and merges before camera-rig extraction
- Blocked by:
## Handoff
- Commit: `a45d521`
- Branch: `work/sindo-main-codex/m02-terrain-query`
- Outcome: `ThirdPersonWowController` no longer loads ADTs, caches tile
dictionaries, selects chunks or interpolates height grids. It consumes a
replaceable typed `TerrainQuery` and retains only existing offset/snap policy.
- Public contracts: additive immutable `TerrainGroundSample`, replaceable
`TerrainQuery`, cached `AdtTerrainQuery` and player `set_terrain_query`.
Coordinate contract version, ADT/native format and renderer caches are unchanged.
- Verification: `TERRAIN_QUERY PASS contract=4 interpolation=1 cache=1
failures=2 player_injection=1`; player-input and local-movement regressions
passed; asset-free scene exited `0`; coordinate boundary passed with five
required consumers and StreamingFocus passed; renderer dry-run passed eight
pre-capture gates and seven checkpoint plans; coordination/documentation/diff gates passed.
- Fidelity: synthetic fractional-grid fixture locks the inherited 145-height
outer-grid bilinear result within `0.002` Godot units; injected scene locks
spawn offset `0.05` and physics snap. This is sandbox-regression evidence,
not build-12340 triangle/hole/collision parity.
- Local verification inputs: isolated worktree reused the existing ignored
native DLL and generated ADT resource scripts. Proprietary extracted data and
character GLB were absent, so renderer dry-run logged expected missing-corpus
diagnostics while all contract steps returned success.
- Rebuild/migration: none; caches are transient and existing formats remain unchanged.
- Remaining risks: first ADT parse remains synchronous as before; unavailable
tiles cache until query replacement; player still owns snap policy; exact
ADT triangle choice, holes, slopes, liquids and collision remain open.
- Documentation: inline public APIs plus `docs/modules/terrain-query.md` with
inputs/outputs, data-flow, cache state and cross-boundary sequence diagrams,
ownership, failure/recovery, capability status and source map. Coordinate and
movement consumer specifications were updated.
+118
View File
@@ -0,0 +1,118 @@
# M02-RND-CAMERA-001 — Third-person camera rig boundary
<!-- OPENWC_CLAIM:M02-RND-CAMERA-001:sindo-main-codex:2026-07-16 -->
## Ownership
- Target: M02
- Program: RND/GMP
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-camera-rig`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Move mouse capture, orbit yaw/pitch, zoom state and Camera3D transforms from
`ThirdPersonWowController` into a reusable `ThirdPersonCameraRig`, with an
explicit replaceable camera-collision policy that preserves the current
no-collision sandbox baseline.
## Non-goals
- Enable a new collision/raycast behavior before build-12340 comparison.
- Change initial yaw/pitch, zoom limits, sensitivity, FOV, far plane or camera path.
- Implement first-person, follow lag, shoulder offsets or cinematic cameras.
- Change movement, terrain query, character presentation or renderer streaming focus.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/scenes/player/third_person_camera_rig.gd`,
`src/scenes/player/camera_collision_policy.gd`,
`src/scenes/player/no_camera_collision_policy.gd`,
`src/tools/verify_third_person_camera_rig.gd`,
`docs/modules/third-person-camera.md`, this claim
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`,
runtime streaming scenes, player regression scene,
`src/tools/verify_player_input.gd`, `docs/modules/player-input.md`,
`docs/modules/local-player-movement.md`, `docs/modules/README.md`
- Generated/ignored: local `.godot`, native DLL, generated resources and renderer corpus
## Contracts and data
- Public API: `ThirdPersonCameraRig.initialize_for_character`,
`handle_camera_input`, `godot_world_flight_movement_basis`, state getters and
`set_collision_policy`
- Policy: `CameraCollisionPolicy.resolve_camera_distance`; default
`NoCameraCollisionPolicy` returns requested distance unchanged
- Scene paths and renderer camera path remain unchanged
- Schema/cache/coordinate contracts: unchanged
- Consumers: player composition, sandbox flight movement and renderer viewport
## Dependencies
- Requires: merged TerrainQuery package on master `f5bb64e`
- Blocks: character appearance/animation extraction from remaining player hotspot
- External state: none; tests use asset-free camera/player scene
## Verification
- Commands: dedicated orbit/zoom/clamp/policy verifier, input/movement/terrain
regressions, asset-free scene, coordinate/StreamingFocus gates, renderer dry-run
and repository gates
- Fixtures: initial state, remappable action events, mouse deltas and fixed-distance policy
- Fidelity evidence: preserve current `-18°` pitch, `8` distance, `1` zoom step,
`2..18` clamp, `0.003` sensitivity and no-collision output
- Performance budget: constant-time state/transform update; no raycast in baseline policy
## Documentation deliverables
- Inline API/export documentation for rig and collision policies
- Camera module spec with inputs/outputs, data-flow, state and sequence diagrams
- Updated input/movement consumer diagrams/source maps and module registry
## Simplicity and naming
- Important names: `ThirdPersonCameraRig`, `CameraCollisionPolicy`,
`NoCameraCollisionPolicy`
- Simplest approach: one Node3D rig on existing CameraPivot and one explicit identity policy
- Rejected complexity: generic camera framework, spring arm migration and hidden physics raycast
- Unavoidable complexity: rig mutates Camera3D/character Node transforms on main thread
- Measured optimization evidence: not applicable
## Status
- State: ready-for-review
- Done: extracted rig and collision policy, migrated all three scene compositions,
added regressions and completed required module documentation
- Next: milestone integrator reviews and merges `8c1cf9b`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M02-RND-CAMERA-001:8c1cf9b -->
## Handoff
- Outcome: `ThirdPersonCameraRig` now owns capture, orbit, zoom and camera
transforms; `ThirdPersonWowController` only dispatches camera events and
consumes the rig basis for sandbox flight.
- Public contracts: `initialize_for_character`, `handle_camera_input`,
`godot_world_flight_movement_basis`, `set_collision_policy`, read-only state,
and `CameraCollisionPolicy.resolve_camera_distance`.
- Verification: camera `state_cases=12 policy_cases=3 scenes=3`, input
`actions=12 intent_cases=6`, movement `cases=12 state_transitions=2`, terrain
`contract=4 interpolation=1 cache=1 failures=2`, coordinate boundary
`files=98 consumers=5`; documentation, coordination and diff checks passed.
- Renderer dry-run: seven checkpoint plans and report completed; expected
diagnostics only for unavailable proprietary corpus/character GLB.
- Fidelity: preserves the sandbox defaults (`-18 degrees`, distance `8`, zoom
`2..18` step `1`, sensitivity `0.003`, pivot `1.7`, far `50000`) and the prior
no-collision behavior. No build-12340 parity claim is made.
- Local ignored inputs: native extension DLL and generated ADT resource scripts
were used for the renderer dry-run and are not part of the commit.
- Migration: none; `ThirdPersonPlayer/CameraPivot/Camera3D` remains stable.
- Risks: active collision and exact original-client camera semantics remain
unverified; exported initial values are captured at ready; mouse mode is
process-global.
- Documentation: added the camera API/module spec plus data-flow, state and
sequence diagrams; updated player-input and local-movement consumer maps.
@@ -0,0 +1,124 @@
# M02-RND-CHARACTER-PRESENTATION-001 — Character presentation boundary
<!-- OPENWC_CLAIM:M02-RND-CHARACTER-PRESENTATION-001:sindo-main-codex:2026-07-16 -->
## Ownership
- Target: M02
- Program: RND/GMP
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-character-presentation`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Move character GLB composition, geoset/texture/outfit setup and locomotion
animation selection from `ThirdPersonWowController` into independently
replaceable `CharacterAppearancePresenter` and `CharacterAnimationPresenter`
components without changing the current sandbox presentation.
## Non-goals
- Add new model, equipment, animation or build-12340 behavior.
- Change character asset formats, DBC parsing, texture composition or geoset rules.
- Add network-driven appearance/movement snapshots.
- Change input, movement, terrain, camera or renderer streaming behavior.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/scenes/character/character_appearance_presenter.gd`,
`src/scenes/character/character_animation_presenter.gd`,
`src/tools/verify_character_presentation.gd`,
`docs/modules/character-presentation.md`, this claim
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`, runtime
streaming scenes, player regression scene, `src/tools/verify_player_input.gd`,
`docs/modules/local-player-movement.md`, `docs/modules/README.md`
- Reused unchanged: geoset controller, texture compositor and outfit resolver
- Generated/ignored: local `.godot`, native DLL, generated resources and renderer corpus
## Contracts and data
- Appearance API loads/replaces one character root under the existing `Visual` node.
- Animation API binds an `AnimationPlayer` from that root and accepts a typed
moving/stationary presentation state.
- Scene paths, model defaults, DBC/cache/coordinate contracts remain unchanged.
- Consumers: player scene composition and renderer viewport only.
## Dependencies
- Requires: merged camera package on master `68e6f60`.
- Blocks: final M02 sandbox-profile gate and target integration audit.
- External state: asset-free tests use a synthetic mesh and AnimationPlayer.
## Verification
- Dedicated appearance composition, replacement, grounding and failure regression.
- Dedicated animation discovery, fallback, looping, blend and state regression.
- Player source-boundary and three-scene wiring checks.
- Existing input/movement/terrain/camera regressions, coordinate gates and renderer dry-run.
- Fidelity evidence: preserve current sandbox model path, scale, yaw, outfit,
Stand/Idle and Run/Walk selection only; no original-client parity claim.
## Documentation deliverables
- Inline public API/export documentation for both presenters.
- Module specification with inputs/outputs, data-flow, lifecycle/state and sequence diagrams.
- Updated movement consumer/source map and module registry.
## Simplicity and naming
- Important names: `CharacterAppearancePresenter`, `CharacterAnimationPresenter`.
- Simplest approach: two small scene components on the existing `Visual` subtree.
- Rejected complexity: generic presenter framework, async asset scheduler and duplicated character systems.
- Unavoidable complexity: scene-tree composition and AnimationPlayer discovery run on the main thread.
- Measured optimization evidence: not applicable.
## Status
- State: ready-for-review
- Done: extracted both presenters, migrated all runtime/regression scenes,
added asset-free regressions and completed required module documentation
- Next: milestone integrator reviews and merges `97bb53f`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M02-RND-CHARACTER-PRESENTATION-001:97bb53f -->
## Handoff
- Branch/commit: `work/sindo-main-codex/m02-character-presentation` at `97bb53f`.
- Outcome: `CharacterAppearancePresenter` owns model/geoset/compositor/outfit
composition and `CharacterAnimationPresenter` owns AnimationPlayer discovery,
loop preparation and moving/stationary playback. The player is now only the
composition and horizontal-velocity adapter.
- Changed composition: both runtime scenes and the player regression scene keep
`ThirdPersonPlayer/Visual`; they add `Visual/AnimationPresenter`.
- Public contracts: appearance load/read-root/ready signal and animation
bind/present/read-state APIs documented in the module specification.
- Verification: presentation `appearance_cases=9 animation_cases=10 scenes=3`,
input `actions=12 intent_cases=6`, movement `cases=12 state_transitions=2`,
terrain `contract=4 interpolation=1 cache=1 failures=2`, camera
`state_cases=12 policy_cases=3`, coordinate boundaries `files=99 consumers=5`,
and StreamingFocus `runtime_scenes=2 capture_tools=3` passed.
- Renderer: dry-run passed project/material/dedupe/cache/manifest/calibration/
coordinate/server-spawn gates and planned all seven checkpoints. Missing
proprietary HumanMale GLB, DBC and ADT corpus produced expected diagnostics.
- Repository gates: documentation `module_specs=8`, coordination
`fallback_claims=29`; five unrelated expired M00 claim warnings remain.
- Fidelity: current model path, scale `1`, yaw `90 degrees`, class `1`, blend
`0.15 seconds`, starter outfit and Stand/Idle/Run/Walk selection are preserved.
No build-12340 appearance or animation parity claim is made.
- Migration: repository scenes are migrated. External/custom scenes must move
model/class/scale/yaw exports from the player node to the scripted `Visual`
node and add `Visual/AnimationPresenter`. No cache/database rebuild is needed.
- Local ignored inputs: native extension DLL, UID cache and generated ADT
resource scripts supported the dry-run and are not committed.
- Risks: synchronous model load may hitch; first descendant AnimationPlayer wins;
moving state is a sandbox threshold boolean; complete equipment/animation
fidelity remains open.
- Recommended merge order: merge this package after camera (already on master),
then implement the sandbox profile gate and perform the final M02 integration audit.
- Documentation: added character-presentation API, inputs/outputs, data-flow,
lifecycle/state, sequence, ownership/failure and source-map sections; updated
the module registry and movement presentation consumer map.
+6 -1
View File
@@ -36,7 +36,12 @@ TrinityCore/AzerothCore ◄── Network Adapter ◄── Runtime Client
### Domain ### Domain
Содержит идентификаторы, value objects, команды и события: `EntityId`, `WowGuid`, `MapPosition`, `SpellId`, `MoveIntent`, `EntityMoved`. Не содержит side effects. Содержит идентификаторы, value objects, команды и события: stable authored
`ContentId`, session-local `EntityId`, opaque wire `WowGuid`, namespaced
`ServerEntryId`, typed coordinates, `SpellId`, `MoveIntent`, `EntityMoved`.
Эти identity namespaces не конвертируются неявно и не содержат side effects;
их lifecycle и mapping определены в
[`modules/domain-identities.md`](modules/domain-identities.md).
### Gameplay ### Gameplay
+19
View File
@@ -237,6 +237,23 @@ TODO/FIXME обязан содержать target/work-package ID или diagnos
- Медленный понятный reference implementation полезен как oracle для optimized implementation. - Медленный понятный reference implementation полезен как oracle для optimized implementation.
- Сложная оптимизация удаляется или упрощается, если она не даёт измеримого результата. - Сложная оптимизация удаляется или упрощается, если она не даёт измеримого результата.
## Coordinate conversion boundary
- Cross-space world conversions выполняются только через typed API
`CoordinateMapper`; ручные world-center offsets, swaps осей и вычисление ADT
filename indices из Godot position запрещены.
- Scene/render adapter МОЖЕТ создать `Vector3` только после получения
`GodotWorldPosition` или перевести engine vector в typed scalar value на своей границе.
- Единственная native world-position формула находится в
`src/native/src/wow_chunk_reader.h`, поскольку parser workers не вызывают
GDScript. Новая native копия требует отдельного boundary decision и parity fixture.
- `verify_render_coordinate_calibration.gd` остаётся независимым test oracle;
production code не копирует его формулы.
- Model-local M2/WMO vertex basis и placement orientation не являются world
coordinate conversion. Они документируются и тестируются своим format adapter.
- `verify_coordinate_conversion_boundaries.gd` обязателен при изменении
coordinates, native parsers, sky, player, streaming или renderer diagnostics.
## Review checklist ## Review checklist
- Можно ли понять назначение сущности по имени? - Можно ли понять назначение сущности по имени?
@@ -245,6 +262,8 @@ TODO/FIXME обязан содержать target/work-package ID или diagnos
- Можно ли убрать abstraction без потери correctness/boundary? - Можно ли убрать abstraction без потери correctness/boundary?
- Есть ли скрытый mutable state или side effect? - Есть ли скрытый mutable state или side effect?
- Не появился ли второй источник истины? - Не появился ли второй источник истины?
- Все ли cross-space world coordinates проходят через `CoordinateMapper` или
документированную native boundary?
- Измерена ли сложная optimization? - Измерена ли сложная optimization?
- Изолирована и документирована ли неизбежная сложность? - Изолирована и документирована ли неизбежная сложность?
- Соответствуют ли API docs и diagrams фактическому коду? - Соответствуют ли API docs и diagrams фактическому коду?
+19 -1
View File
@@ -10,7 +10,10 @@
.\tools\run_render_baseline.ps1 .\tools\run_render_baseline.ps1
``` ```
Скрипт последовательно проверяет headless-загрузку проекта, renderer materials, M2 unique-id dedupe, regression manifest и затем снимает checkpoint-ы. Для CI или быстрой проверки контракта без PNG: Скрипт последовательно проверяет headless-загрузку проекта, renderer materials,
M2 unique-id dedupe, regression manifest, coordinate boundaries и dedicated
server-spawn renderer contract, затем снимает checkpoint-ы. Для CI или быстрой
проверки контракта без PNG:
```powershell ```powershell
.\tools\run_render_baseline.ps1 -DryRun -WaitSeconds 0.1 -MeasureSeconds 0.1 .\tools\run_render_baseline.ps1 -DryRun -WaitSeconds 0.1 -MeasureSeconds 0.1
@@ -18,6 +21,21 @@
Если `godot` отсутствует в `PATH`, скрипт использует локальный executable, указанный в `RENDER.md`. Другой executable задаётся через `-GodotPath`. Если `godot` отсутствует в `PATH`, скрипт использует локальный executable, указанный в `RENDER.md`. Другой executable задаётся через `-GodotPath`.
Отдельный M01 server-spawn checkpoint использует тот же capture pipeline, но не
расширяет семичастный M00 visual baseline:
```powershell
godot --headless --path . --script res://src/tools/verify_server_spawn_renderer.gd
godot --path . --script res://src/tools/capture_render_checkpoints.gd -- `
--manifest res://src/tools/server_spawn_render_manifest.json `
--output user://server_spawn_renderer
```
Manifest связывает pinned AzerothCore Human Warrior spawn с Godot position,
ADT tile/chunk и renderer-native diagnostic marker. Маркер начинается точно в
spawn origin и проходит обычный depth test, поэтому screenshot показывает его
отношение к terrain; это integration evidence, а не заявление visual parity.
Результат по умолчанию находится в `user://render_baseline`: Результат по умолчанию находится в `user://render_baseline`:
- `report.json` — Godot, OS, CPU, GPU/backend, viewport, revision, cache inventory, cache versions, параметры checkpoint-ов и результаты; - `report.json` — Godot, OS, CPU, GPU/backend, viewport, revision, cache inventory, cache versions, параметры checkpoint-ов и результаты;
+23 -4
View File
@@ -108,6 +108,21 @@ show `indexX` decreasing world Y (east) and `indexY` decreasing world X (south).
OpenRealm independently calculates filename X from world Y and filename Y from OpenRealm independently calculates filename X from world Y and filename Y from
world X. Contract version 2 corrects the formulas before any consumer migration. world X. Contract version 2 corrects the formulas before any consumer migration.
### Enforced conversion boundaries
GDScript production consumers use `CoordinateMapper` for canonical/Godot and
Godot/ADT tile-local conversion. `StreamingWorldLoader`, player spawn/terrain
sampling, sky/area lookup and terrain diagnostics may construct `Vector3` only
at their scene/tool boundary.
The native ADT/WDT parser path retains one equivalent formula definition in
`src/native/src/wow_chunk_reader.h`; worker parsers cannot call GDScript. The
M00 renderer calibration keeps one independent formula as a test oracle so a
shared mapper defect cannot make both implementation and evidence agree.
Repository verification rejects additional world-center, world/tile or named
WoW/Godot formulas outside those explicit exceptions. Model-local M2/WMO basis
transforms are separate format contracts and are not classified as world-space mapping.
## Alternatives considered ## Alternatives considered
### Make renderer coordinates canonical ### Make renderer coordinates canonical
@@ -138,10 +153,12 @@ grid ownership rules.
- Renderer consumers must explicitly map and then create Godot vectors at their - Renderer consumers must explicitly map and then create Godot vectors at their
own boundary. own boundary.
- ADT filename indices can no longer be confused with renderer X/Z tile names. - ADT filename indices can no longer be confused with renderer X/Z tile names.
- Existing native/parser and renderer conversions remain unchanged until - GDScript world/tile consumers use mapper APIs; renderer distance and LOD math
separate consumer migration packages merge after this contract. remains ordinary Godot-space geometry.
- Manual axis conversions become review/test violations once migration is - Native ADT/WDT loaders share one boundary formula and the M00 calibration
complete; this ADR alone does not delete compatibility paths. keeps one independent non-production oracle.
- New manual axis conversions are review/test violations enforced by the
repository-wide source gate.
- Persisted schemas are unchanged. Future serialized positions must include an - Persisted schemas are unchanged. Future serialized positions must include an
explicit coordinate-space/version discriminator. explicit coordinate-space/version discriminator.
@@ -154,6 +171,8 @@ grid ownership rules.
- The observed Elwynn position resolves to the real `Azeroth_31_49.adt` file; - The observed Elwynn position resolves to the real `Azeroth_31_49.adt` file;
MCNK `(0,0)` to `(15,15)` origins confirm X-east and Y-south chunk growth. MCNK `(0,0)` to `(15,15)` origins confirm X-east and Y-south chunk growth.
- World-yaw normalization and round-trip cases cover cardinal angles and wrap. - World-yaw normalization and round-trip cases cover cardinal angles and wrap.
- Direct Godot/ADT tile-local conversions round-trip and production consumers
are checked by `verify_coordinate_conversion_boundaries.gd`.
## References ## References
+122
View File
@@ -0,0 +1,122 @@
# ADR 0002: Separate domain identity namespaces
- Status: Accepted for M01 contract implementation
- Date: 2026-07-13
- Work package: `M01-FND-DOMAIN-IDENTITIES-001`
- Decision owners: M01 foundation/integration owners
## Context
OpenWC must relate authored content, runtime objects, WoW protocol objects and
TrinityCore/AzerothCore rows. All four domains can expose an integer or string,
but their identity lifetimes and collision rules are different. A raw `int`, a
generic `id` field or one universal ID class would allow an entity sequence to
be persisted as content identity, a database entry to be sent as a wire GUID,
or equal entry numbers from different tables to collide.
The Content Project requires stable references before server entries are
allocated. Runtime entities require cheap session-local identity even when no
server object exists. WoW 3.3.5a GUIDs carry opaque 64 wire bits whose semantic
decomposition must be established by protocol fixtures. Server templates use
numeric entries scoped by an adapter/table namespace.
## Decision
### ContentId
`ContentId` stores canonical lowercase UUID text in the `8-4-4-4-12` shape.
It is the only identity in this decision intended for stable authored-content
references and serialization. Construction normalizes whitespace and letter
case; `is_valid()` reports malformed input without crashing an import.
The value validates text shape, not UUID version or variant bits. A future
Content Project allocator chooses the UUID generation policy and owns duplicate
detection. Every persisted schema that contains a `ContentId` still declares
its own schema version and field migration.
### EntityId
`EntityId` stores a positive integer allocated by one runtime session owner.
It is valid only inside that session and must not be persisted as content,
protocol or server identity. Sequence reuse policy belongs to the future entity
registry, not the value object.
### WowGuid
`WowGuid` stores opaque `high_32_bits` and `low_32_bits` integers. Each word is
validated against `0..0xffffffff`, preserving every unsigned wire bit without
combining it into GDScript's signed 64-bit integer. The all-zero value remains
an observable protocol sentinel and receives no gameplay interpretation here.
High-type masks, packed-GUID codecs and object-kind extraction are explicitly
deferred to the build-12340 protocol package and require packet fixtures.
### ServerEntryId
`ServerEntryId` stores a positive numeric entry together with a normalized,
non-empty adapter/table namespace. Equal numbers in different namespaces are
different identities. Mapping a server entry to a `ContentId` is explicit
adapter/snapshot data; neither value derives the other.
### Equality and keys
The types expose domain-named `equals` methods because `RefCounted` `==`
compares object references. Stable keys are available only where their lifetime
allows them: `ContentId.to_key()`, `WowGuid.to_hex_string()` and
`ServerEntryId.to_key()`. `EntityId` exposes only `to_debug_key()` to discourage
persistence.
## Alternatives considered
### One generic `Id<T>` abstraction
Rejected because GDScript has no compile-time generic type distinction and the
shared API would obscure different validation, lifetime and persistence rules.
### Use strings everywhere
Rejected because prefixes are a convention rather than a type boundary and
would still permit accidental cross-layer assignment.
### Store WowGuid in one integer
Rejected because unsigned 64-bit GUIDs can exceed GDScript's positive signed
integer range. Split words preserve the wire representation losslessly.
### Use server entries as ContentId
Rejected because entries are allocated by a particular backend/table, can
collide between namespaces and do not exist while authoring offline content.
### Generate UUIDs inside ContentId
Rejected because randomness/allocation is a side effect requiring duplicate,
fixture and deterministic-build policy. This value object only normalizes and
validates an allocator-provided value.
## Consequences
- Cross-layer contracts must name the identity type they accept.
- Content references remain stable before and after server deployment.
- Runtime entity registries need explicit mappings to wire GUIDs/content IDs.
- Server adapters must supply a namespace and maintain entry-to-content maps.
- Malformed external values remain representable long enough for adapters to
produce diagnostics, but consumers must call the relevant validation method.
- Additional typed IDs such as map, spell, item and quest remain separate
follow-up contracts rather than aliases of these four values.
## Verification
- UUID normalization, shape validation and value equality are fixture-tested.
- Zero/positive session sequences establish `EntityId` validity boundaries.
- Maximum unsigned GUID words round-trip to exactly sixteen hexadecimal digits;
overflow is rejected and zero remains detectable.
- Equal server entry numbers in creature/gameobject namespaces do not compare equal.
- Dependency search verifies that identity values do not inherit Node/Resource
or expose `Vector3`.
## References
- [RFC 9562: Universally Unique IDentifiers](https://www.rfc-editor.org/rfc/rfc9562.html)
- [`../ARCHITECTURE.md`](../ARCHITECTURE.md)
- [`../../targets/roadmap/01-foundation-and-data.md`](../../targets/roadmap/01-foundation-and-data.md)
+6
View File
@@ -8,6 +8,12 @@
|---|---|---| |---|---|---|
| Foundation/data | Planned/Partial | [`../../targets/roadmap/01-foundation-and-data.md`](../../targets/roadmap/01-foundation-and-data.md) | | Foundation/data | Planned/Partial | [`../../targets/roadmap/01-foundation-and-data.md`](../../targets/roadmap/01-foundation-and-data.md) |
| Coordinate mapping | Implemented | [`coordinate-mapping.md`](coordinate-mapping.md) | | Coordinate mapping | Implemented | [`coordinate-mapping.md`](coordinate-mapping.md) |
| Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) |
| Player input | Partial | [`player-input.md`](player-input.md) |
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) |
| Terrain query | Implemented | [`terrain-query.md`](terrain-query.md) |
| Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) |
| Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) |
| Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) |
| Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) | | Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) |
| Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) | | Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) |
+273
View File
@@ -0,0 +1,273 @@
# Character Presentation
## Metadata
| Field | Value |
|---|---|
| Status | Implemented boundary / Partial fidelity |
| Target/work package | M02 / M02-RND-CHARACTER-PRESENTATION-001 |
| Owners | Character appearance composition and locomotion animation presentation |
| Last verified | `97bb53f`, 2026-07-14 |
| Profiles/capabilities | Current render sandbox character experiment |
## Purpose
Compose the current sandbox character GLB, geosets, skin textures and starter
outfit below the existing `Visual` node, then independently present its
stationary or moving animation. Keep visual asset loading and AnimationPlayer
state out of gameplay movement and the player scene composition root.
## Non-goals
- Define authoritative character identity, equipment or movement state.
- Change the existing GLB, geoset, texture-compositor, DBC or outfit formats.
- Implement full build-12340 animation selection, blending or equipment visuals.
- Load world assets, query terrain, process input or control the camera.
- Introduce asynchronous streaming, background scene mutation or persistence.
## Context and boundaries
```mermaid
flowchart LR
Scene[ThirdPersonWowController composition] --> Appearance[CharacterAppearancePresenter]
Model[Configured PackedScene] --> Appearance
DBC[Extracted DBC/item textures] --> Appearance
Appearance --> Root[CharacterModel root]
Root --> Geosets[Existing geoset controller]
Root --> Compositor[Existing texture compositor]
Appearance -->|ready root| Scene
Scene --> Animation[CharacterAnimationPresenter]
Velocity[Movement read model] -->|moving boolean| Animation
Animation --> Player[AnimationPlayer below root]
```
Allowed dependencies:
- configured Godot `PackedScene` character asset;
- existing `CharacterGeosetController`, `CharacterTextureCompositor` and
`WowCharacterOutfitResolver` presentation components;
- `AnimationPlayer` and animation resources below the composed character root;
- main-thread player scene composition and movement velocity read model.
Forbidden dependencies:
- `MoveIntent`, input devices or movement integration;
- `TerrainQuery`, ADT parsing, coordinate conversion or streaming decisions;
- cameras, network packets, server databases or gameplay reducers;
- direct production database writes or cache-format ownership.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `CharacterAppearancePresenter.load_character_appearance(content_root_override)` | Command | Replaces the owned character root and schedules final grounding/outfit composition | Main thread; may be called again during scene lifetime | Returns `false` for empty/invalid model; warning for invalid resource |
| `CharacterAppearancePresenter.character_root` | Read-only state | Current owned model root or null | Stable until next load | None |
| `character_appearance_ready(character_root)` | Signal | Publishes a fully attached, grounded root | Deferred main-thread completion | Not emitted for invalid/empty model |
| `CharacterAnimationPresenter.bind_character_root(character_root)` | Command | Discovers and prepares the first descendant AnimationPlayer | Main thread; binding lifetime | Returns `false` and clears state when absent |
| `CharacterAnimationPresenter.present_locomotion(is_moving)` | Command | Selects current stationary/moving candidate and starts it once | Main/physics thread | Returns `false` when unavailable, missing or unchanged |
| `active_animation_name` | Read-only state | Selected animation name | Stable until bind/state transition | Empty when unbound |
| `has_animation_player` | Read-only state | Whether the presenter has a valid bound player | Stable until bind | None |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Model resource path, class, scale and yaw | Runtime scene composition | Appearance presenter | Exported configuration | Captured at load |
| Input | Extracted content root | Player scene configuration | Appearance presenter | Copied string | Captured at load |
| Input | Packed character scene | Godot resource loader | Appearance presenter | Shared resource; presenter owns instance | Scene lifetime |
| Output | Composed `CharacterModel` root | Appearance presenter | Scene/animation presenter | Appearance presenter owns node | Until replacement |
| Output | Ready signal | Appearance presenter | Player composition | Synchronous signal after deferred completion | One per successful load |
| Input | Moving/stationary boolean | Player adapter from horizontal velocity | Animation presenter | Value copy | One physics tick |
| Output | AnimationPlayer playback mutation | Animation presenter | Rendered character | Bound root owns player/resources | Until next transition |
Side effects:
- loads and instantiates the configured model on the main thread;
- adds/removes one owned `CharacterModel` subtree;
- composes the existing texture/outfit components and reads their extracted inputs;
- changes model transform for scale, yaw and ground alignment;
- configures locomotion loop modes and starts AnimationPlayer playback;
- does not write files, caches, databases or network state.
## Data flow
```mermaid
flowchart TD
Config[Model/class/scale/yaw/content root] --> Load[Load PackedScene]
Load --> Replace[Replace owned CharacterModel]
Replace --> Attach[Attach model and compositor]
Attach --> Ground[Fit mesh bounds to visual ground]
DBC[Existing outfit resolver] --> Outfit[Apply starter outfit]
Ground --> Ready[Emit ready root]
Outfit --> Ready
Ready --> Bind[Discover AnimationPlayer]
Velocity[Horizontal movement velocity] --> State[Moving or stationary]
State --> Select[Run/Walk or Stand/Idle selection]
Bind --> Select
Select --> Playback[Loop and play with blend]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> Empty
Empty --> Loading: valid load command
Loading --> Ready: attach + deferred composition
Loading --> Empty: invalid resource
Ready --> Loading: replacement load
Ready --> Empty: empty model command
state Animation {
[*] --> Unbound
Unbound --> Stationary: bind root with animations
Stationary --> Moving: moving=true
Moving --> Stationary: moving=false
Stationary --> Unbound: bind missing player
Moving --> Unbound: bind missing player
}
```
Appearance replacement removes the old root from the scene immediately and
queues it for deletion. Deferred completion checks root identity, so a stale
completion cannot publish a replaced model.
## Main sequence
```mermaid
sequenceDiagram
participant Scene as ThirdPersonWowController
participant Appearance as CharacterAppearancePresenter
participant Existing as Geoset/Compositor/Outfit
participant Animation as CharacterAnimationPresenter
participant Player as AnimationPlayer
Scene->>Appearance: load_character_appearance(content root)
Appearance->>Appearance: load, instantiate and attach root
Appearance->>Existing: compose textures and starter outfit
Appearance-->>Scene: character_appearance_ready(root)
Scene->>Animation: bind_character_root(root)
Animation->>Player: prepare loops and play Stand/Idle
loop Physics ticks
Scene->>Animation: present_locomotion(is moving)
Animation->>Player: play only on state/name change
end
```
## Ownership, threading and resources
- The runtime scene owns one appearance presenter on `Visual` and one animation
presenter below it.
- The appearance presenter exclusively owns the generated `CharacterModel`
subtree; imported shared resources remain Godot-owned.
- The animation presenter borrows an AnimationPlayer reference until rebind or
subtree replacement; the player scene orders rebind through the ready signal.
- Loading, node mutation, bounds calculation, outfit setup and playback occur
on the main thread. There are no workers, locks, RIDs or cancellation tokens.
- The player controller owns neither model resources nor animation state.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Empty model path | Load guard | Clears old root and returns `false` | Contract regression | Configure a path and retry |
| Invalid model resource | Failed PackedScene cast | Remains empty | Named warning with path | Correct/extract asset and retry |
| Replaced deferred completion | Root identity check | Stale completion ignored | Replacement regression | No action required |
| Missing DBC/outfit inputs | Existing resolver returns false/empty | Base model remains visible | Existing resolver diagnostics | Restore extracted inputs and reload |
| Missing AnimationPlayer | Recursive discovery returns null | Presenter becomes unbound | Contract return/read state | Bind a root with animations |
| Missing candidate animation | Candidate selection returns empty | Current playback unchanged | Verifier/source diagnostics | Extend verified candidate policy later |
There is no background load cancellation. Replacement is the recovery path and
invalid model state is explicit through the return value and null read model.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Character model | Human male sandbox GLB | Current sandbox | Before reload | Model scene input |
| Character class ID | `1` | Current sandbox | Before reload | Starter outfit lookup |
| Uniform scale | `1.0` | Current sandbox | Before reload | Character root scale |
| Yaw offset | `90` degrees | Current sandbox | Before reload | Imported model correction |
| Extracted content root | `res://data/extracted` | Local content | Per load override | DBC/item texture input |
| Animation blend | `0.15` seconds | Current sandbox | Before bind/transition | Playback cross-fade/default blend |
| Stationary candidates | `Stand`, then `Idle` | Current sandbox | Fixed | Exact then substring selection |
| Moving candidates | `Run`, then `Walk` | Current sandbox | Fixed | Exact then substring selection |
## Persistence, cache and migration
Presenter state is transient. No saved settings, user profile, database schema
or cache format changes. Existing scene paths remain
`ThirdPersonPlayer/Visual` and add only `Visual/AnimationPresenter`; model and
extracted-data paths retain their defaults. Moving the model exports from the
player node to `Visual` is a scene-authoring ownership change, not persisted
runtime data migration.
## Diagnostics and observability
- Logs: invalid model path warns with the responsible presenter and path.
- Read models: current character root, animation binding and active name.
- Tests: dedicated verifier reports appearance, animation, scene and player boundaries.
- Metrics/correlation IDs: not applicable to this synchronous sandbox presenter.
## Verification
- Unit/contract: synthetic model fixture verifies load, scale, yaw, grounding,
compositor composition, ready signal, replacement and empty-model clearing.
- Animation: synthetic libraries verify nested discovery, exact and substring
candidate selection, loop policy, blend configuration and duplicate suppression.
- Boundary: player source contains no model loading, outfit, bounds or
AnimationPlayer logic; appearance and animation dependencies remain separate.
- Integration: Eastern Kingdoms, Kalimdor and player regression scenes compose
both presenters; existing input/movement/terrain/camera tests remain required.
- Fidelity evidence: only preservation of the current sandbox defaults and
selection rules. No build-12340 appearance or animation parity is claimed.
- Performance: one synchronous model composition per load and constant-time
locomotion calls except on state transition; asset streaming remains future work.
## Extension points
- Replace starter outfit inputs with a versioned appearance/equipment snapshot.
- Replace boolean locomotion input with a typed presentation state after
movement/network contracts define walk, run, jump, fall, swim, mount and emotes.
- Add asynchronous/preloaded model providers behind the appearance command
without moving resource loading back into gameplay.
- Add verified animation IDs/variation policy after build-12340 fixtures exist.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Independent appearance composition | Implemented | Synthetic fixture and three scene wirings | Integrator target update |
| Independent locomotion animation | Implemented | Exact/fallback/loop/blend regression | Add typed presentation state later |
| Current starter outfit | Partial | Existing resolver reused unchanged | Needs extracted DBC fixture and client comparison |
| Current skin/geoset composition | Partial | Existing components reused | Full equipment/customization fidelity incomplete |
| Build-12340 animation semantics | Planned | No original-client fixture | Capture animation IDs/transitions/timing |
| Runtime equipment/network updates | Planned | No snapshot contract | M08/M09/M12 work |
## Known gaps and risks
- Model loading remains synchronous and can hitch when not preloaded.
- Exact appearance, equipment, animation choice, playback speed and blending are unverified.
- The moving input is a sandbox threshold boolean, not authoritative locomotion state.
- The first descendant AnimationPlayer wins; multi-player imported scenes need a
more explicit binding contract if they appear.
- Existing geoset/compositor/outfit implementations retain their own fidelity gaps.
## Source map
| Path | Responsibility |
|---|---|
| `src/scenes/character/character_appearance_presenter.gd` | Model-root ownership, composition, ground fit and outfit setup |
| `src/scenes/character/character_animation_presenter.gd` | AnimationPlayer binding and locomotion playback state |
| `src/scenes/character/character_geoset_controller.gd` | Existing geoset visibility and outfit defaults |
| `src/scenes/character/character_texture_compositor.gd` | Existing skin/equipment texture composition |
| `src/scenes/character/wow_character_outfit_resolver.gd` | Existing DBC starter outfit lookup |
| `src/scenes/player/third_person_wow_controller.gd` | Scene composition and moving-state adapter |
| `src/tests/scenes/character_presentation_model_fixture.tscn` | Asset-free model/grounding fixture |
| `src/tools/verify_character_presentation.gd` | Presenter, scene and dependency regression |
## Related decisions and references
- ADR: none; current behavior and resource formats are preserved.
- Upstream/reference: `targets/02-player-decomposition.md`,
`targets/roadmap/02-rendering-and-graphics.md`,
`targets/roadmap/04-gameplay-systems.md`, `RENDER.md`.
+35 -9
View File
@@ -5,9 +5,9 @@
| Field | Value | | Field | Value |
|---|---| |---|---|
| Status | Implemented | | Status | Implemented |
| Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002`, `M01-QAR-COORD-FIXTURES-001` | | Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002`, `M01-QAR-COORD-FIXTURES-001`, `M01-FND-COORD-BOUNDARY-GATE-001`, `M01-QAR-SERVER-SPAWN-RENDERER-001`, `M02-GMP-TERRAIN-QUERY-001` consumer migration |
| Owners | Foundation/domain | | Owners | Foundation/domain |
| Last verified | Worktree `work/sindo-main-codex/m01-coordinate-fixtures`, 2026-07-13 | | Last verified | `a45d521`, 2026-07-14 |
| Profiles/capabilities | All profiles; contract version 2 | | Profiles/capabilities | All profiles; contract version 2 |
## Purpose ## Purpose
@@ -69,6 +69,9 @@ Forbidden dependencies:
| `GodotWorldYaw` | Immutable value | Godot Y-axis world yaw in radians | Any thread; caller-owned reference | Constructor does not normalize | | `GodotWorldYaw` | Immutable value | Godot Y-axis world yaw in radians | Any thread; caller-owned reference | Constructor does not normalize |
| `CoordinateMapper.server_to_canonical` / inverse | Pure static methods | Preserve server X/Y/Z as canonical | Synchronous; new result value | Null input is a caller contract violation | | `CoordinateMapper.server_to_canonical` / inverse | Pure static methods | Preserve server X/Y/Z as canonical | Synchronous; new result value | Null input is a caller contract violation |
| `CoordinateMapper.canonical_to_godot` / inverse | Pure static methods | Apply Y-up renderer basis and map offset | Synchronous; new result value | Non-finite scalars propagate | | `CoordinateMapper.canonical_to_godot` / inverse | Pure static methods | Apply Y-up renderer basis and map offset | Synchronous; new result value | Non-finite scalars propagate |
| `CoordinateMapper.godot_to_adt_tile` | Pure static method | Resolve filename indices from typed renderer position | Synchronous; new result value | Result remains unclamped |
| `CoordinateMapper.godot_to_adt_tile_local` | Pure static method | Resolve east/height/south offsets from typed renderer position | Synchronous; new result value | Caller validates ownership/range |
| `CoordinateMapper.adt_tile_local_to_godot` | Pure static method | Compose typed renderer position from tile/local values | Synchronous; new result value | Non-finite scalars propagate |
| `CoordinateMapper.canonical_to_adt_placement` / inverse | Pure static methods | Convert ADT placement field basis | Synchronous; new result value | Non-finite scalars propagate | | `CoordinateMapper.canonical_to_adt_placement` / inverse | Pure static methods | Convert ADT placement field basis | Synchronous; new result value | Non-finite scalars propagate |
| `CoordinateMapper.canonical_to_adt_tile` | Pure static method | Compute unclamped filename indices | Synchronous; new result value | Out-of-map result remains invalid | | `CoordinateMapper.canonical_to_adt_tile` | Pure static method | Compute unclamped filename indices | Synchronous; new result value | Out-of-map result remains invalid |
| `CoordinateMapper.canonical_to_adt_tile_local` / inverse | Pure static methods | Split/reconstruct tile-local position | Synchronous; new result value | Caller validates tile ownership | | `CoordinateMapper.canonical_to_adt_tile_local` / inverse | Pure static methods | Split/reconstruct tile-local position | Synchronous; new result value | Caller validates tile ownership |
@@ -83,7 +86,7 @@ Forbidden dependencies:
| Input | `AdtPlacementPosition` | Future ADT adapter | `CoordinateMapper` | Immutable caller value | Any thread, call duration | | Input | `AdtPlacementPosition` | Future ADT adapter | `CoordinateMapper` | Immutable caller value | Any thread, call duration |
| Input | `CanonicalWowWorldPosition`, `CanonicalWowWorldYaw` | Gameplay/content/domain | `CoordinateMapper` | Immutable caller value | Any thread, call duration | | Input | `CanonicalWowWorldPosition`, `CanonicalWowWorldYaw` | Gameplay/content/domain | `CoordinateMapper` | Immutable caller value | Any thread, call duration |
| Output | `CanonicalWowWorldPosition` | `CoordinateMapper` | Gameplay/content/adapters | New immutable value | Caller-owned reference | | Output | `CanonicalWowWorldPosition` | `CoordinateMapper` | Gameplay/content/adapters | New immutable value | Caller-owned reference |
| Output | `AdtTileCoordinate`, `AdtTileLocalPosition`, `AdtChunkCoordinate` | `CoordinateMapper` | Streaming/content adapters | New immutable values | Caller-owned references | | Output | `AdtTileCoordinate`, `AdtTileLocalPosition`, `AdtChunkCoordinate` | `CoordinateMapper` | Streaming/content/terrain-query adapters | New immutable values | Caller-owned references |
| Output | `GodotWorldPosition`, `GodotWorldYaw` | `CoordinateMapper` | Future render/scene adapter | New immutable values | Caller-owned references | | Output | `GodotWorldPosition`, `GodotWorldYaw` | `CoordinateMapper` | Future render/scene adapter | New immutable values | Caller-owned references |
| Test input | `coordinate_golden_points.json` schema 1 | Pinned public server row plus private numeric observations | Headless fixture verifier | Versioned repository fixture | Test-process lifetime | | Test input | `coordinate_golden_points.json` schema 1 | Pinned public server row plus private numeric observations | Headless fixture verifier | Versioned repository fixture | Test-process lifetime |
@@ -100,12 +103,23 @@ flowchart LR
RawADTFixture[Raw ADT numeric metadata] --> FixtureVerifier RawADTFixture[Raw ADT numeric metadata] --> FixtureVerifier
ClientFixture[Build 12340 observations] --> FixtureVerifier ClientFixture[Build 12340 observations] --> FixtureVerifier
FixtureVerifier --> Mapper FixtureVerifier --> Mapper
ServerFixture --> SpawnManifest[Server spawn render manifest]
SpawnManifest --> SpawnVerifier[Renderer checkpoint verifier]
SpawnVerifier --> Capture[Live renderer capture]
Capture --> Marker[Diagnostic marker at mapped spawn]
Server[ServerWorldPosition X,Y,Z] -->|identity components| Canonical[CanonicalWowWorldPosition] Server[ServerWorldPosition X,Y,Z] -->|identity components| Canonical[CanonicalWowWorldPosition]
ADT[AdtPlacementPosition X,height,Z] -->|half extent - horizontal| Canonical ADT[AdtPlacementPosition X,height,Z] -->|half extent - horizontal| Canonical
Canonical -->|tileX from Y; tileY from X| Tile[AdtTileCoordinate] Canonical -->|tileX from Y; tileY from X| Tile[AdtTileCoordinate]
Canonical -->|north-west edge offsets| Local[AdtTileLocalPosition] Canonical -->|north-west edge offsets| Local[AdtTileLocalPosition]
Local -->|floor offset / chunk size| Chunk[AdtChunkCoordinate] Local -->|floor offset / chunk size| Chunk[AdtChunkCoordinate]
Canonical -->|half extent - Y; Z; half extent - X| Godot[GodotWorldPosition] Canonical -->|half extent - Y; Z; half extent - X| Godot[GodotWorldPosition]
Godot -->|typed direct adapter| Tile
Godot -->|typed direct adapter| Local
Godot --> TerrainQuery[AdtTerrainQuery consumer]
TerrainQuery --> Tile
TerrainQuery --> Local
TerrainQuery --> Chunk
Tile -->|tile plus local| Godot
ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw] ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw]
CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw] CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw]
``` ```
@@ -172,7 +186,14 @@ raw three-number arrays are not an accepted new persistence contract.
- Unit/contract tests: `src/tools/verify_coordinate_mapper.gd`. - Unit/contract tests: `src/tools/verify_coordinate_mapper.gd`.
- Cross-source golden test: `src/tools/verify_coordinate_golden_fixtures.gd`. - Cross-source golden test: `src/tools/verify_coordinate_golden_fixtures.gd`.
- Integration/E2E: consumer migration and `StreamingFocus` packages remain pending. - Boundary enforcement: `src/tools/verify_coordinate_conversion_boundaries.gd`.
- Server-spawn renderer contract: `src/tools/verify_server_spawn_renderer.gd`
validates the dedicated manifest against the same pinned fixture before GUI capture.
- Integration/E2E: sky, player spawn, streamer, terrain probe and the extracted
`AdtTerrainQuery` use mapper boundaries;
`StreamingFocus` provides typed renderer focus, and the pinned AzerothCore
Human Warrior spawn is captured in the Eastern Kingdoms renderer at ADT
`(32,48)`, chunk `(3,12)`.
- Fidelity evidence: five build-12340 camera positions from the M00 manifest map - Fidelity evidence: five build-12340 camera positions from the M00 manifest map
within `0.002` yard; a pinned AzerothCore human-warrior spawn and one raw MCNK within `0.002` yard; a pinned AzerothCore human-warrior spawn and one raw MCNK
header observation independently confirm tile `(32,48)` and chunk `(3,12)`; header observation independently confirm tile `(32,48)` and chunk `(3,12)`;
@@ -192,18 +213,19 @@ raw three-number arrays are not an accepted new persistence contract.
| Capability | Status | Evidence | Gap/next step | | Capability | Status | Evidence | Gap/next step |
|---|---|---|---| |---|---|---|---|
| Canonical/server/Godot position mapping | Implemented | Headless round-trip and five M00 golden points | Migrate producers/consumers | | Canonical/server/Godot position mapping | Implemented | Headless round-trip, five M00 golden points and migrated GDScript consumers | Network/server adapters remain future consumers |
| ADT placement mapping | Implemented | Synthetic/direct round-trip fixtures | Compare native parsed placements during migration | | ADT placement mapping | Implemented | Synthetic/direct round-trip fixtures | Compare native parsed placements during migration |
| ADT tile/local/chunk ownership | Implemented | Center/extent boundaries plus raw `Azeroth_31_49.adt` filename and MCNK direction evidence | Migrate streaming consumers | | ADT tile/local/chunk ownership | Implemented | Center/extent boundaries, raw `Azeroth_31_49.adt` evidence and direct Godot adapter round-trip | Native parser remains explicit boundary |
| World-facing yaw mapping | Implemented | Cardinal/wrap round-trip fixtures | Placement Euler remains separate | | World-facing yaw mapping | Implemented | Cardinal/wrap round-trip fixtures | Placement Euler remains separate |
| Scene-free production contract | Implemented | Source boundary and headless test | Add dependency search gate later in M01 | | Scene-free production contract | Implemented | Source boundary, headless tests and repository-wide conversion gate | Maintain gate with new consumers |
| Cross-source coordinate fixture | Implemented | Pinned AzerothCore spawn, raw ADT MCNK metadata and five build-12340 points pass one headless verifier | Add live adapter/renderer integration | | Cross-source coordinate fixture | Implemented | Pinned AzerothCore spawn, raw ADT MCNK metadata and five build-12340 points pass one headless verifier | Add live adapter/renderer integration |
| TrinityCore populated spawn equivalence | Not verified | The base development schema has no pinned populated row used by this fixture | Pin and verify a TDB snapshot before claiming parity | | TrinityCore populated spawn equivalence | Not verified | The base development schema has no pinned populated row used by this fixture | Pin and verify a TDB snapshot before claiming parity |
| Visual/server-spawn integration | Planned | M01 target criteria | Route the fixture through migrated adapters and renderer | | Visual/server-spawn integration | Implemented | Dedicated manifest/verifier plus cold/warm GUI capture of the mapped AzerothCore spawn | This is coordinate/renderer integration evidence, not original-client visual parity |
## Known gaps and risks ## Known gaps and risks
- Existing native loaders and renderer scripts still contain manual conversions. - Native ADT/WDT parsing retains one shared boundary formula because worker
parsers cannot call GDScript; the gate prevents additional definitions.
- Contract version 1 briefly transposed ADT filename/chunk axes; version 2 fixes - Contract version 1 briefly transposed ADT filename/chunk axes; version 2 fixes
the defect before any consumer migration. the defect before any consumer migration.
- MDDF/MODF rotation comments and implementations disagree across existing paths; - MDDF/MODF rotation comments and implementations disagree across existing paths;
@@ -225,6 +247,10 @@ raw three-number arrays are not an accepted new persistence contract.
| `src/tools/verify_coordinate_mapper.gd` | Headless fixtures and boundary regressions | | `src/tools/verify_coordinate_mapper.gd` | Headless fixtures and boundary regressions |
| `src/tests/fixtures/coordinate_golden_points.json` | Versioned cross-source values, provenance and tolerances | | `src/tests/fixtures/coordinate_golden_points.json` | Versioned cross-source values, provenance and tolerances |
| `src/tools/verify_coordinate_golden_fixtures.gd` | Cross-source fixture schema and mapper validation | | `src/tools/verify_coordinate_golden_fixtures.gd` | Cross-source fixture schema and mapper validation |
| `src/tools/verify_coordinate_conversion_boundaries.gd` | Repository-wide manual conversion and required-consumer gate |
| `src/gameplay/terrain/adt_terrain_query.gd` | Typed Godot-position consumer for ADT tile/local/chunk lookup |
| `src/tools/server_spawn_render_manifest.json` | Pinned server-spawn camera, player, marker and expected ADT ownership |
| `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer manifest contract verifier |
| `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision | | `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision |
## Related decisions and references ## Related decisions and references
+232
View File
@@ -0,0 +1,232 @@
# Domain Identities
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | `M01-FND-DOMAIN-IDENTITIES-001` |
| Owners | Foundation/domain |
| Last verified | Worktree `work/sindo-main-codex/m01-domain-identities`, 2026-07-13 |
| Profiles/capabilities | All profiles; identity contract version 1 |
## Purpose
Provide non-interchangeable, scene-free identity values for authored content,
runtime entities, WoW wire objects and namespaced server entries. Each value
states its lifetime and serialization rules instead of exposing a generic `id`.
## Non-goals
- Allocating, reserving or globally registering IDs.
- Parsing packet bytes or server database rows.
- Mapping GUID high types or server entries to gameplay object kinds.
- Defining Content Project, snapshot or replay schemas.
- Replacing future typed map/spell/item/quest/display/sound IDs.
## Context and boundaries
```mermaid
flowchart LR
Authoring[Content Project allocator] --> ContentId[ContentId]
Session[Entity registry] --> EntityId[EntityId]
Wire[Build 12340 packet adapter] --> WowGuid[WowGuid]
Server[Core snapshot adapter] --> ServerEntry[ServerEntryId]
ContentId --> Mapping[Explicit identity mapping records]
EntityId --> Mapping
WowGuid --> Mapping
ServerEntry --> Mapping
Mapping --> Consumers[Gameplay / network / authoring consumers]
Raw[Raw int or string] -. forbidden across boundary .-> Consumers
```
Allowed dependencies:
- GDScript scalar/string operations and `RefCounted` lifetime;
- callers in content, gameplay, network and server adapter layers.
Forbidden dependencies:
- Node, Resource, SceneTree, renderer and UI state;
- packet buffers, SQL connections or database table implementations;
- implicit conversion between identity namespaces;
- random/global allocation inside the value objects.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `ContentId` | Immutable value | Stable authored identity as canonical lowercase UUID text | Any thread; persisted content lifetime | `is_valid()` reports malformed shape |
| `ContentId.equals` | Pure method | Value equality across references | Call duration | Null compares false |
| `ContentId.to_key` | Pure method | Canonical serialization/dictionary key | Stable across schema lifetime | Caller must validate first |
| `EntityId` | Immutable value | Positive session-local runtime sequence | One runtime session | `is_valid()` rejects zero/negative |
| `EntityId.equals` | Pure method | Equality within an explicitly shared session | Call duration | Cross-session comparison is a caller error |
| `EntityId.to_debug_key` | Pure method | Non-persistent diagnostic label | Session/log lifetime | Must not be serialized as content |
| `WowGuid` | Immutable value | Opaque unsigned high/low wire words | Packet/replay/entity mapping lifetime | `has_valid_word_ranges()` reports overflow |
| `WowGuid.is_empty` | Pure method | Detect all-zero wire sentinel | Call duration | Does not assign semantic meaning |
| `WowGuid.to_hex_string` | Pure method | Sixteen-digit diagnostic/replay key | Wire/replay lifetime | Caller validates ranges first |
| `ServerEntryId` | Immutable value | Adapter/table namespace plus positive entry | Snapshot/deployment mapping lifetime | `is_valid()` rejects missing namespace/non-positive entry |
| `ServerEntryId.to_key` | Pure method | Namespaced mapping key | Snapshot/schema lifetime | Caller validates first |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | UUID text | Future Content Project allocator/parser | `ContentId` | Copied normalized string | Build/content lifetime |
| Input | Session sequence | Future entity registry | `EntityId` | Copied integer | One runtime session |
| Input | Two unsigned 32-bit words | Future build-12340 packet codec | `WowGuid` | Copied integers | Packet/replay/entity lifetime |
| Input | Namespace and numeric entry | Future core snapshot adapter | `ServerEntryId` | Copied normalized string/integer | Snapshot/deployment lifetime |
| Output | Validation/equality/key results | Identity values | Adapters/registries/schemas | Returned scalar/string | Call duration |
Side effects:
- none;
- no allocation registry, random generation, file/cache write, packet read,
database query, scene mutation or logging.
## Data flow
```mermaid
flowchart TD
ContentSource[Authored document] -->|UUID text| ContentId
ServerRow[Backend table row] -->|adapter + table + entry| ServerEntryId
Packet[World packet] -->|high/low wire words| WowGuid
Registry[Session entity allocator] -->|positive sequence| EntityId
ServerEntryId --> DeployMap[Explicit deployment mapping]
ContentId --> DeployMap
WowGuid --> RuntimeMap[Explicit runtime mapping]
EntityId --> RuntimeMap
DeployMap --> Snapshot[Versioned snapshot/change set]
RuntimeMap --> WorldState[Session world state]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
state ContentIdentity {
[*] --> Parsed
Parsed --> Persisted: valid ContentId
Persisted --> Referenced
}
state RuntimeIdentity {
[*] --> Allocated
Allocated --> Active
Active --> Released: session owner removes entity
Released --> [*]
}
state ExternalIdentity {
[*] --> Observed
Observed --> Mapped: WowGuid or ServerEntryId
Mapped --> Stale: disconnect or snapshot replacement
Stale --> [*]
}
```
The values themselves are stateless and immutable. Allocation, release,
mapping and stale-state ownership belong to future registries/adapters.
## Main sequence
Identity construction and validation are synchronous. An external adapter reads
its format, constructs the matching value, validates it, then publishes an
explicit mapping or typed domain event. No value object performs I/O or retries.
## Ownership, threading and resources
- Values own only copied strings/integers and are caller-owned `RefCounted` references.
- Public methods do not mutate values or shared state and are worker-safe.
- Content/session/wire/server mapping records are owned by their future layer,
not by these values.
- No Node, RID, file, socket, database, lock or cache resource is acquired.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Malformed ContentId | `is_valid()` | Invalid value remains inspectable | Source field/path supplied by parser | Regenerate or repair authored ID |
| Invalid EntityId sequence | `is_valid()` | Registry must reject publication | Session/correlation context | Fix allocator/session mapping |
| GUID word overflow | `has_valid_word_ranges()` | Codec must reject packet/replay value | Packet offset/opcode context | Reject malformed input; do not truncate |
| Empty GUID | `is_empty()` | Preserved as protocol sentinel | Adapter decides whether field permits empty | Apply opcode/field-specific rule |
| Missing server namespace/entry | `is_valid()` | Snapshot adapter rejects mapping | Backend/table/row context | Correct adapter schema mapping |
| Unknown/stale mapping | Registry/snapshot lookup | No implicit numeric fallback | Typed IDs in diagnostic | Refresh snapshot/session or reject event |
There is no cancellation inside constant-time value operations.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Identity contract version | 1 | All | No | Four separate namespaces and validation rules |
| ContentId text form | Lowercase `8-4-4-4-12` UUID | Authoring/runtime/tools | No | Stable persisted key |
| GUID decomposition | Opaque two-word storage | `wotlk_335a_build_12340` | No | Prevents unverified high-type assumptions |
## Persistence, cache and migration
- `ContentId.uuid_text` is the only stable persisted identity introduced here.
- Containing schemas must store their own schema version and duplicate/reference policy.
- `EntityId` must never be persisted across sessions.
- `WowGuid` may appear in versioned packet replays as explicit high/low words or
the exact sixteen-digit hex form; a replay schema owns that choice.
- `ServerEntryId` may appear in snapshots only with both namespace and entry.
- This package is additive and migrates no existing data or cache.
## Diagnostics and observability
- Stable diagnostics use `ContentId.to_key`, `WowGuid.to_hex_string` and
`ServerEntryId.to_key`.
- Runtime diagnostics may use `EntityId.to_debug_key` with a session correlation ID.
- Production metrics/logging belong to registries and adapters; values emit none.
## Verification
- Contract test: `src/tools/verify_domain_identities.gd`.
- Fixtures cover UUID normalization/malformed shape, positive/zero session IDs,
maximum/overflow GUID words, zero GUID and cross-table server entry collisions.
- Dependency gate confirms no Node/Resource/Vector3 exposure.
- Fidelity boundary: GUID bit meaning remains unclaimed until build-12340 packet fixtures.
- Performance: constant-time integer checks and a bounded 36-character UUID scan.
## Extension points
- Content Project allocator and duplicate/reference validation.
- Session-owned entity registry and explicit `WowGuid ↔ EntityId` mappings.
- Backend capability adapters that define server entry namespaces.
- Separate typed map/spell/item/quest/display/sound values.
- Fixture-backed build-12340 GUID high-type decoding in the protocol layer.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Stable authored ContentId | Implemented contract | UUID normalization/validation fixtures | Allocator and schema arrive with Content Project |
| Session-local EntityId | Implemented contract | Positive/zero/equality fixtures | Registry lifecycle arrives with gameplay world state |
| Lossless opaque WowGuid | Implemented contract | Full unsigned words and hex fixture | Packet codec/high-type semantics need build-12340 fixtures |
| Namespaced ServerEntryId | Implemented contract | Cross-table collision fixture | Backend namespace capability mapping arrives with server adapters |
| Cross-namespace mappings | Planned | Architecture/ADR boundary | Implement only in owning registries/adapters |
## Known gaps and risks
- No allocator or duplicate registry exists yet.
- GDScript `==` compares references; consumers must use domain `equals` methods
or canonical keys until a registry owns keying.
- UUID shape validation deliberately does not enforce generation version/variant.
- Server namespace vocabulary is adapter-owned and not yet centrally catalogued.
- GUID high-type/object-kind semantics remain unverified rather than guessed.
## Source map
| Path | Responsibility |
|---|---|
| `src/domain/identity/content_id.gd` | Stable canonical UUID content identity |
| `src/domain/identity/entity_id.gd` | Session-local runtime identity |
| `src/domain/identity/wow_guid.gd` | Opaque unsigned WoW wire identity |
| `src/domain/identity/server_entry_id.gd` | Namespaced backend entry identity |
| `src/tools/verify_domain_identities.gd` | Headless value/boundary regression |
| `docs/adr/0002-domain-identity-namespaces.md` | Normative namespace/lifetime decision |
## Related decisions and references
- ADR: [`../adr/0002-domain-identity-namespaces.md`](../adr/0002-domain-identity-namespaces.md)
- Architecture: [`../ARCHITECTURE.md`](../ARCHITECTURE.md)
- Foundation plan: [`../../targets/roadmap/01-foundation-and-data.md`](../../targets/roadmap/01-foundation-and-data.md)
+247
View File
@@ -0,0 +1,247 @@
# Local Player Movement
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M02 / M02-GMP-MOVEMENT-001, camera basis and character-presentation consumer updates |
| Owners | Gameplay local movement state; scene composition owns the instance |
| Last verified | `97bb53f`, 2026-07-14 |
| Profiles/capabilities | Current render sandbox; production/debug profile gate pending |
## Purpose
Own current local velocity, acceleration and sandbox flight state independently
of the player scene. Convert an immutable `MoveIntent` plus an explicit
Godot-world movement basis into deterministic per-tick displacement without
querying terrain, cameras, input devices or visual assets.
## Non-goals
- Apply world transforms or own a `CharacterBody3D`.
- Query terrain height, collision, water or slopes.
- Implement jump, fall, swim, server prediction or reconciliation.
- Select the player basis versus camera-pivot basis; the scene adapter owns that decision.
- Claim build-12340 movement fidelity from sandbox regression alone.
## Context and boundaries
```mermaid
flowchart LR
Input[MoveIntent] --> Movement[LocalPlayerMovementController]
Basis[Explicit local movement Basis] --> Movement
Delta[Physics delta seconds] --> Movement
Movement --> Displacement[Godot-world displacement]
Movement --> Velocity[Read-only velocity state]
Scene[ThirdPersonWowController] --> Basis
Displacement --> Scene
Scene --> Transform[Player world transform]
Scene --> Terrain[TerrainQuery and ground snap adapter]
Velocity --> Presentation[Visual facing and CharacterAnimationPresenter adapter]
```
Allowed dependencies:
- `MoveIntent` from the gameplay input boundary.
- Godot value types `Basis` and `Vector3` for renderer world-space direction math.
- Main-thread scene adapter for basis selection and displacement application.
Forbidden dependencies:
- `Node`, `Resource`, `Input`, `Camera3D`, `ADTLoader` or scene lookup.
- World-coordinate conversion or manual ADT/tile formulas.
- Character assets, animation players, materials, packets or server state.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `LocalPlayerMovementController.new(...)` | Constructor | Sets explicit speeds, acceleration and sandbox sprint multiplier | Created by composition root; retained for player scene lifetime | Caller owns configuration validation |
| `godot_world_velocity_units_per_second` | Read-only state | Current Godot-world velocity after the last update | Stable until next mutation on owning thread | None |
| `is_flight_enabled` | Read-only state | Current sandbox free-flight mode | Stable until toggle | None |
| `toggle_sandbox_flight()` | Command | Toggles flight and clears velocity | Owning physics/input thread | Returns new flight state |
| `advance(move_intent, godot_world_movement_basis, delta_seconds)` | Stateful update | Accelerates toward requested velocity and returns displacement for the tick | Owning physics thread; one call per tick | Negative delta is treated as zero |
Constructor units:
| Parameter | Unit/meaning |
|---|---|
| `run_speed_units_per_second` | Forward Godot units per second |
| `backward_speed_units_per_second` | Backward Godot units per second |
| `strafe_speed_units_per_second` | Lateral Godot units per second |
| `flight_vertical_speed_units_per_second` | Sandbox vertical Godot units per second |
| `acceleration_units_per_second_squared` | Velocity change per second |
| `sandbox_sprint_multiplier` | Dimensionless debug multiplier |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `MoveIntent` | `PlayerInputSource` | Movement controller | Immutable caller-held value | One physics tick |
| Input | Godot-world `Basis` | Player or `ThirdPersonCameraRig` adapter | Movement controller | Value copy | One physics tick |
| Input | Delta seconds | Godot physics loop | Movement controller | Scalar value | One physics tick |
| Input | Flight toggle command | Remappable sandbox action via scene adapter | Movement controller | Synchronous command | Input event dispatch |
| Output | Displacement `Vector3` | Movement controller | Player scene adapter | Value copy | Applied in same tick |
| Output | Velocity `Vector3` | Movement controller | Facing and `CharacterAnimationPresenter` scene adapter | Read-only value copy | Until next update |
| Output | Flight state | Movement controller | Basis/terrain scene adapter | Read-only boolean | Until next toggle |
Side effects:
- Mutates only controller-owned velocity and flight state.
- Does not mutate scene tree, transforms, resources, filesystem, network or cache.
- The scene adapter remains responsible for transform mutation and ground snap.
## Data flow
```mermaid
flowchart LR
Intent[MoveIntent axes] --> Target[Calculate target velocity]
Basis[Player or camera-pivot Basis] --> Directions[Forward/right directions]
Directions --> Target
Config[Speeds and sprint multiplier] --> Target
Previous[Owned velocity] --> Integrate[move_toward by acceleration × delta]
Target --> Integrate
Integrate --> Current[Updated velocity]
Current --> Step[velocity × delta]
Step --> Displacement[Scene-applied displacement]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> GroundedSandbox
GroundedSandbox --> FlyingSandbox: toggle / clear velocity
FlyingSandbox --> GroundedSandbox: toggle / clear velocity
GroundedSandbox --> GroundedSandbox: advance
FlyingSandbox --> FlyingSandbox: advance
```
The names describe the existing sandbox mode only. They are not authoritative
gameplay movement states and do not imply terrain contact or server permission.
## Main sequence
```mermaid
sequenceDiagram
participant Input as PlayerInputSource
participant Scene as ThirdPersonWowController
participant Move as LocalPlayerMovementController
participant Present as CharacterAnimationPresenter adapter
Input-->>Scene: MoveIntent
Scene->>Move: advance(intent, selected basis, delta)
Move-->>Scene: displacement
Scene->>Scene: apply global position and ground snap
Scene->>Move: godot_world_velocity_units_per_second
Scene->>Present: horizontal motion
```
## Ownership, threading and resources
- The player scene creates and exclusively owns one movement controller.
- Velocity and flight state have the same lifetime as that controller.
- All mutations currently occur on the main input/physics thread.
- Read-only value returns are copies; consumers cannot mutate owned state.
- No Nodes, Resources, RIDs, files, jobs or worker threads are created.
- The supplied `Basis` is a Godot-world direction basis, not a typed world-position contract or coordinate conversion.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Negative physics delta | `advance` guard | Returns zero displacement; velocity unchanged | Unit regression | Resume with valid non-negative delta |
| Missing scene basis owner | Scene composition defect | Controller cannot be called correctly | Scene/parser error or boundary test | Restore explicit scene dependency |
| Flight toggle during motion | Command path | Velocity clears before next step | State-transition regression | Continue from zero velocity |
| Missing terrain data | `TerrainGroundSample.is_available == false` | Scene skips existing ground snap | Stable terrain failure code | Replace/recover query backend |
There is no asynchronous cancellation, retry or rollback. Recreating the
controller resets velocity and flight state deterministically.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Forward speed | `7.0` units/s | Current sandbox | Fixed for controller lifetime | Target forward velocity |
| Backward speed | `4.5` units/s | Current sandbox | Fixed for controller lifetime | Target backward velocity |
| Strafe speed | `4.5` units/s | Current sandbox | Fixed for controller lifetime | Target lateral velocity |
| Acceleration | `28.0` units/s² | Current sandbox | Fixed for controller lifetime | Acceleration and deceleration |
| Flight vertical speed | `7.0` units/s | Sandbox debug | Fixed for controller lifetime | Vertical free-flight target |
| Sprint multiplier | `6.0` | Sandbox debug | Fixed for controller lifetime | Multiplies planar and vertical targets |
| Camera-relative basis | Selected only while flight is enabled | Sandbox debug | Per tick | Preserves existing pitched flight direction |
## Persistence, cache and migration
Movement state is transient and not serialized. No schema, cache, migration or
saved settings change is introduced. Future reconnect/replay state requires a
separate versioned movement snapshot contract.
## Diagnostics and observability
- Logs: verifier emits `LOCAL_PLAYER_MOVEMENT PASS` or named invariant failures.
- Metrics: none yet; update is constant-time vector math.
- Debug views: current character facing and animation remain observable scene outputs.
- Correlation IDs: not applicable to synchronous local sandbox state.
## Verification
- Unit/contract: `src/tools/verify_local_player_movement.gd` covers forward,
backward, strafe, rotated basis, acceleration, deceleration, sprint, flight,
pitched camera basis, state reset and invalid delta.
- Boundary: verifier rejects Node/Input/Camera/ADT dependencies and movement
integration/state remaining in the player scene.
- Scene integration: `verify_player_input.gd` drives the asset-free player
regression scene through forward motion and immediate flight toggle.
- Fidelity evidence: default values and formulas are regression-locked against
the pre-extraction sandbox. No original-client comparison is claimed.
- Performance budget: constant-time vector math, no per-tick I/O or scene lookup;
`MoveIntent` allocation remains owned by the input package.
## Extension points
- Terrain/collision policy can consume displacement without changing input or velocity ownership.
- A future server-aware predictor may replace this controller behind the scene composition boundary.
- A typed movement snapshot may expose deterministic replay state when M08/M09 require it.
- Sandbox debug capability gating can reject sprint/flight before commands reach this controller.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Scene-free local velocity state | Implemented | Pure controller and source boundary regression | Integrate target checklist after review |
| Existing planar acceleration/speeds | Implemented | Exact numeric unit cases and real scene regression | Compare with build 12340 before fidelity claim |
| Existing camera-relative free flight | Implemented | Pitched-basis and toggle/reset cases | Restrict to sandbox profile |
| Terrain height query | Implemented | Typed `TerrainQuery` and injected player regression | Ground-snap policy remains scene-owned |
| Terrain collision movement policy | Planned | Height-only query does not model collision | Add slopes/holes/collision later |
| Jump/fall/swim | Planned | M02/M09 roadmap | Requires terrain/liquid and server contracts |
| Prediction/reconciliation | Planned | M08/M09 roadmap | Requires movement snapshot/network contract |
## Known gaps and risks
- Numeric defaults preserve the existing sandbox but are not original-client evidence.
- Configuration is captured at scene `_ready`; runtime mutation of exported speed
properties does not reconfigure an existing controller.
- Ground snap remains in the player scene, but its ADT data access is now behind
a replaceable `TerrainQuery`.
- Sprint and free flight still lack a typed sandbox capability gate.
- The module uses Godot math value types; a future engine-independent gameplay
predictor may require scalar/domain movement contracts.
## Source map
| Path | Responsibility |
|---|---|
| `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration |
| `src/gameplay/terrain/terrain_query.gd` | Replaceable ground-height input boundary |
| `src/scenes/player/third_person_camera_rig.gd` | Camera-relative sandbox flight basis producer |
| `src/scenes/character/character_animation_presenter.gd` | Locomotion presentation consumer behind the scene adapter |
| `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain and presentation-state adapters |
| `src/tools/verify_local_player_movement.gd` | Pure numeric, state-transition and dependency regression |
| `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture |
| `src/tools/verify_player_input.gd` | Input-to-movement scene regression |
## Related decisions and references
- ADR: none; this follows the existing M02 decomposition boundary.
- Upstream/reference: `targets/02-player-decomposition.md`,
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
+217
View File
@@ -0,0 +1,217 @@
# Player Input
## Metadata
| Field | Value |
|---|---|
| Status | Partial |
| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 and M02-RND-CAMERA-001 consumer updates |
| Owners | Gameplay input boundary; `sindo-main-codex` for current package |
| Last verified | `8c1cf9b`, 2026-07-14 |
| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified |
## Purpose
Convert remappable Godot Input Map actions into an immutable, scene-free
`MoveIntent` that can be consumed by the current player controller and a later
independently testable local movement controller.
## Non-goals
- Define authoritative server movement, prediction or reconciliation.
- Own character transform, terrain queries, camera state or animation state.
- Claim that the current W/S/A/D sandbox mapping exactly matches build 12340.
- Make sprint or free flight available to the production compatibility profile;
their profile gate remains a later M02 package.
## Context and boundaries
```mermaid
flowchart LR
Devices[Keyboard and mouse] --> InputMap[Godot Input Map]
InputMap --> Source[PlayerInputSource]
Source --> Intent[MoveIntent]
Intent --> Movement[LocalPlayerMovementController]
Movement --> Controller[ThirdPersonWowController adapter]
Controller --> Camera[ThirdPersonCameraRig]
```
Allowed dependencies:
- `PlayerInputSource` may read Godot's process-wide `Input` adapter.
- The scene controller may consume `MoveIntent` and camera-specific action names.
- `MoveIntent` may contain scalar axes and request flags only.
Forbidden dependencies:
- `MoveIntent` must not read `Input`, inherit `Node`/`Resource`, or contain world coordinates.
- The input source must not mutate player transforms, camera nodes or animation state.
- Renderer, terrain parsers and network codecs must not read Input Map actions.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `MoveIntent` | Immutable value | Carries normalized forward, strafe and vertical axes plus the sandbox sprint request | Created and consumed on the main physics tick; caller retains reference | Constructor clamps axes; no I/O failure |
| `MoveIntent.has_translation()` | Query | Reports whether any translation axis is non-zero | Any thread while value remains immutable | None |
| `PlayerInputActions` | Constants | Provides stable action names shared by configuration and adapters | Process lifetime | Missing project actions are detected by verification |
| `PlayerInputSource.sample_move_intent()` | Adapter method | Samples configured actions for one physics tick | Main thread; returned intent is independent of adapter lifetime | Missing actions resolve to zero strength and are a configuration defect |
| `PlayerInputSource.compose_move_intent(...)` | Pure factory | Clamps strengths, cancels opposites and normalizes diagonal planar input | Any thread with scalar inputs | None |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Movement action strengths | Godot `InputMap`/`Input` | `PlayerInputSource` | Engine-owned snapshot | Sampled on main physics tick |
| Input | Mouse action events | Godot `_unhandled_input` dispatch | `ThirdPersonCameraRig` through player dispatch | Engine-owned event | Current input dispatch only |
| Output | `MoveIntent` | `PlayerInputSource` | `LocalPlayerMovementController` through scene composition | Immutable caller-held value | One physics tick |
| Output | Camera action names | `PlayerInputActions` | `ThirdPersonCameraRig` | Static constants | Process lifetime |
Side effects:
- `PlayerInputSource` has no side effects beyond reading current engine input.
- `ThirdPersonCameraRig` owns mouse mode, orbit state and camera transforms;
player remains only the event-dispatch composition boundary.
- No filesystem, cache, network, database or renderer-resource mutation occurs.
## Data flow
```mermaid
flowchart LR
Project[project.godot action defaults] --> Map[InputMap]
Remap[User or editor remapping] --> Map
Map -->|movement strengths| Source[PlayerInputSource]
Map -->|camera events| Camera[ThirdPersonCameraRig]
Source -->|clamp cancel normalize| Intent[MoveIntent]
Intent --> Consumer[LocalPlayerMovementController]
Consumer --> Adapter[ThirdPersonWowController]
```
## Lifecycle/state
The adapter is stateless. The scene composition root creates one
`PlayerInputSource` and `LocalPlayerMovementController` in `_ready`; each physics
tick produces a new immutable intent. Flight enabled/disabled state is owned by
the movement controller and is not hidden in the input adapter.
## Main sequence
```mermaid
sequenceDiagram
participant Engine as Godot Input
participant Source as PlayerInputSource
participant Intent as MoveIntent
participant Move as LocalPlayerMovementController
participant Player as ThirdPersonWowController adapter
Engine->>Source: movement action strengths
Source->>Intent: compose normalized axes and requests
Source-->>Player: sample_move_intent()
Player->>Move: advance(intent, selected basis, delta)
Move-->>Player: displacement and velocity state
```
## Ownership, threading and resources
- The controller owns the input-source reference for its scene lifetime.
- Each `MoveIntent` is immutable and retained only by its consumer.
- Sampling and controller mutation occur on the main physics thread.
- The module creates no Nodes, Resources, RIDs, workers or background jobs.
- Input Map definitions are project configuration; runtime remapping remains
engine-owned and does not mutate the domain value.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Required action missing | Headless contract verifier | Godot returns zero input for that action | `PLAYER_INPUT` failure naming action | Restore action or user mapping |
| Conflicting opposite actions | Pure composition | Strengths cancel deterministically | Covered by contract test | Release/remap one action |
| Strength outside range | Pure composition | Value clamps to `[0, 1]` before axis calculation | Covered by contract test | Input adapter may be corrected independently |
| Controller disabled/unloaded | Scene lifecycle | Sampling stops with physics processing | Existing scene lifecycle | Re-enable or recreate scene |
There is no asynchronous operation to cancel and no persisted state to roll back.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Forward/backward | Physical W/S | Current render sandbox | Yes, through Input Map | Produces signed forward axis |
| Strafe left/right | Physical A/D | Current render sandbox | Yes | Produces signed right axis |
| Camera rotate | Right mouse button | Current render sandbox | Yes | Captures/releases mouse for orbit |
| Camera zoom | Mouse wheel | Current render sandbox | Yes | Adjusts current camera distance |
| Debug sprint | Shift | Sandbox debug only by intent; profile gate pending | Yes | Requests existing speed multiplier |
| Debug flight up/down/toggle | E/Q/Space | Sandbox debug only by intent; profile gate pending | Yes | Requests existing free-flight controls |
## Persistence, cache and migration
The action names are additive project configuration. No saved binding schema,
cache or migration exists yet. Renaming an action is a contract change and must
provide a settings migration once user settings are persisted.
## Diagnostics and observability
- Logs: dedicated verifier emits `PLAYER_INPUT PASS` or one failure per invariant.
- Metrics: none; sampling cost is one small value allocation per physics tick.
- Debug views: none.
- Correlation IDs: not applicable to local synchronous input.
## Verification
- Unit/contract tests: `src/tools/verify_player_input.gd` validates all actions,
default bindings, axis cancellation/clamping/normalization and debug request flags.
- Integration/E2E: `src/tests/scenes/player_input_regression.tscn` is loaded by
the verifier, which drives forward, camera-zoom and immediate sandbox-flight
actions through the real scene controller and also rejects direct
physical-key polling.
- Fidelity evidence: current sandbox defaults retain W/S/A/D, E/Q, Shift, Space,
right mouse and wheel bindings. This is observable-regression coverage only.
- Performance budgets: no I/O, jobs or scene lookup per sample; one immutable
intent allocation per physics tick.
- Manual diagnostics: run the Eastern Kingdoms scene and remap one movement
action in Project Settings before checking movement.
## Extension points
- A gamepad/accessibility adapter may call `compose_move_intent` without changing
movement consumers.
- A later input-context owner may suppress gameplay actions while preserving the
same intent contract.
- A future server-aware movement predictor may replace the current local
movement consumer without changing `PlayerInputSource`.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Remappable current sandbox controls | Implemented | `verify_player_input.gd` action/default checks | Add persisted user binding settings later |
| `PlayerInputSource → MoveIntent` seam | Implemented | Pure composition and controller boundary checks | Local movement consumer is now extracted |
| Exact 3.3.5a default semantics | Unknown | No original-client binding fixture in this package | Capture build-12340 defaults and mouse-turn/strafe policy |
| Production profile exclusion of sprint/flight | Partial | Debug actions are named explicitly | Add typed profile/capability gate in M02 |
| Jump/fall/swim/fly gameplay semantics | Planned | M02 target and gameplay roadmap | Implement after terrain/movement state extraction |
## Known gaps and risks
- The defaults preserve the pre-M02 sandbox behavior, not verified original-client semantics.
- Sprint and free flight are still executable by the sandbox controller until a
typed build-profile gate is introduced.
- Camera state is extracted; active collision remains an explicit planned policy.
- There is no persisted user keybinding format or UI yet.
## Source map
| Path | Responsibility |
|---|---|
| `project.godot` | Additive default Input Map bindings |
| `src/domain/input/move_intent.gd` | Immutable locomotion request contract |
| `src/gameplay/input/player_input_actions.gd` | Stable project action names |
| `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition |
| `src/gameplay/movement/local_player_movement_controller.gd` | Scene-free movement consumer |
| `src/scenes/player/third_person_camera_rig.gd` | Camera-action consumer and orbit/zoom state |
| `src/scenes/player/third_person_wow_controller.gd` | Composition and input-event dispatcher |
| `src/tests/scenes/player_input_regression.tscn` | Asset-free controller movement/camera regression fixture |
| `src/tools/verify_player_input.gd` | Headless contract/integration regression |
## Related decisions and references
- ADR: none; this implements the existing architecture and M02 seam.
- Upstream/reference: `targets/02-player-decomposition.md`,
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
+251
View File
@@ -0,0 +1,251 @@
# Terrain Query
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M02 / M02-GMP-TERRAIN-QUERY-001 |
| Owners | Gameplay terrain-query contract; ADT adapter owns parsed tile cache |
| Last verified | `a45d521`, 2026-07-14 |
| Profiles/capabilities | Current sandbox ADT ground height; renderer/physics backend planned |
## Purpose
Provide a replaceable, scene-free ground-height query for player movement.
Isolate native ADT loading, per-tile caching, chunk selection and legacy outer
height-grid interpolation from the player scene behind a typed
`GodotWorldPosition → TerrainGroundSample` contract.
## Non-goals
- Own player transforms, movement velocity or ground-snap policy.
- Provide collision normals, slopes, holes, liquids, WMO/M2 collision or ray queries.
- Change ADT topology, coordinate conversion, renderer caches or native parser format.
- Claim original-client terrain-contact parity from height regression alone.
- Couple gameplay to the monolithic streaming renderer before a public backend exists.
## Context and boundaries
```mermaid
flowchart LR
Scene[ThirdPersonWowController adapter] --> Position[GodotWorldPosition]
Position --> Contract[TerrainQuery]
Contract --> Adapter[AdtTerrainQuery]
Adapter --> Mapper[CoordinateMapper]
Adapter --> Native[ADTLoader]
Native --> Tile[ADT tile Dictionary]
Tile --> Cache[Owned tile cache]
Cache --> Sample[TerrainGroundSample]
Sample --> Scene
Scene --> Snap[Existing ground-snap policy]
```
Allowed dependencies:
- Typed `GodotWorldPosition` and `CoordinateMapper` contracts.
- Existing native `ADTLoader` at the ADT adapter boundary.
- Dynamic dictionaries only inside the native-format adapter.
- Player scene composition may inject any `TerrainQuery` implementation.
Forbidden dependencies:
- `TerrainQuery` contract depending on Nodes, cameras, input or renderer state.
- Player scene loading/parsing ADT data or owning a parsed tile cache.
- Manual WoW/Godot/ADT world-coordinate conversion outside `CoordinateMapper`.
- Terrain query mutating transforms, gameplay movement state or visual meshes.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `TerrainGroundSample.available(height_units)` | Immutable value factory | Creates a finite Godot-world ground height | Any thread; caller-held value | Non-finite height becomes unavailable |
| `TerrainGroundSample.unavailable(failure_code)` | Immutable value factory | Creates explicit unavailable result | Any thread; caller-held value | Empty code normalizes to `terrain_unavailable` |
| `TerrainQuery.sample_ground_height(position)` | Replaceable boundary | Samples ground at typed Godot world position | Implementation-defined; current adapter main-thread owned | Base reports `terrain_query_not_implemented` |
| `AdtTerrainQuery.new(extracted_directory, map_directory_name, loader_override)` | Adapter constructor | Configures one map and optional test/data-source loader | Query lifetime | Production validates native loader and file presence lazily |
| `AdtTerrainQuery.sample_ground_height(position)` | Adapter method | Loads/caches tile and interpolates ADT outer heights | Main thread; cache lives with query | Returns stable failure code |
| `ThirdPersonWowController.set_terrain_query(query)` | Composition method | Replaces terrain backend independently of movement/presentation | Call before scene ready or on main thread | Null is rejected and current query retained |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `GodotWorldPosition` | Player scene adapter | `TerrainQuery` | Immutable caller-held value | One query |
| Input | Extracted directory/map name | Scene composition | `AdtTerrainQuery` | Copied strings | Query lifetime |
| Input | ADT file/dictionary | Filesystem/native loader or injected loader | `AdtTerrainQuery` | Adapter-owned cached dictionary | Query lifetime |
| Output | `TerrainGroundSample` | Terrain query | Player ground-snap adapter | Immutable caller-held value | One query |
| Output | Failure code | Terrain query | Diagnostics/tests | Immutable `StringName` | Sample lifetime |
Side effects:
- Production adapter performs lazy file existence check and native ADT parse on first tile query.
- Adapter mutates only its per-query tile/failure caches.
- No scene tree, transform, renderer RID, network, database or persisted setting mutation occurs.
## Data flow
```mermaid
flowchart LR
Position[GodotWorldPosition] --> TileCoord[CoordinateMapper tile/local/chunk]
TileCoord --> Lookup{Tile cached?}
Lookup -->|no| Load[ADTLoader or injected loader]
Load --> Cache[Cache Dictionary or unavailable result]
Lookup -->|yes| Cache
Cache --> Chunk[Select MCNK by origin]
Chunk --> Heights[Validate 145 heights]
Heights --> Bilinear[Interpolate 9x9 outer grid]
Bilinear --> Available[TerrainGroundSample available]
Cache -->|missing/invalid| Unavailable[TerrainGroundSample unavailable]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> TileUnknown
TileUnknown --> TileAvailable: first query / successful load
TileUnknown --> TileUnavailable: first query / missing or failed
TileAvailable --> TileAvailable: cached queries
TileUnavailable --> TileUnavailable: cached queries
TileAvailable --> [*]: query released
TileUnavailable --> [*]: query released
```
Unavailable tiles are cached intentionally to avoid repeated filesystem/native
work each physics tick. Recovery after files appear requires replacing the query
instance; hot-reload invalidation is outside this M02 package.
## Main sequence
```mermaid
sequenceDiagram
participant Player as ThirdPersonWowController
participant Query as TerrainQuery
participant ADT as AdtTerrainQuery
participant Mapper as CoordinateMapper
participant Native as ADTLoader
Player->>Query: sample_ground_height(GodotWorldPosition)
Query->>ADT: implementation dispatch
ADT->>Mapper: tile/local/chunk coordinates
alt tile not cached
ADT->>Native: load_adt(absolute path)
Native-->>ADT: ADT Dictionary
end
ADT->>ADT: select chunk and interpolate heights
ADT-->>Player: TerrainGroundSample
Player->>Player: apply existing offset/snap if available
```
## Ownership, threading and resources
- Player scene owns the active `TerrainQuery` reference.
- `AdtTerrainQuery` exclusively owns tile/failure dictionaries until released.
- Current production calls and cache mutations occur on the main physics thread.
- Native loader result dictionaries do not cross the adapter boundary.
- Samples and typed positions are immutable caller-held values.
- The module creates no Nodes, Resources, RIDs or background jobs.
- Future worker-backed queries require an explicit async/result contract rather
than concurrent mutation of the current dictionaries.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Base contract used directly | Base method | Unavailable sample | `terrain_query_not_implemented` | Inject implementation |
| Native loader absent | ClassDB check | Cache unavailable tile | `adt_loader_unavailable` | Load extension and replace query |
| ADT file absent | File check | Cache unavailable tile | `adt_source_missing` | Restore corpus and replace query |
| Native parse empty/fails | Result validation | Cache unavailable tile | `adt_load_failed` | Inspect source/parser; replace query |
| Injected loader returns wrong type | Type check | Cache unavailable tile | `adt_loader_result_invalid` | Fix adapter |
| Chunk absent | Chunk lookup | Unavailable sample | `adt_chunk_missing` | Validate ADT dictionary |
| Height array shorter than 145 | Size check | Unavailable sample | `adt_chunk_heights_invalid` | Fix parser/source |
| Non-finite interpolated height | Sample invariant | Unavailable sample | `terrain_height_not_finite` | Inspect source values |
| Null query injection | Scene guard | Existing query retained | Player composition error | Pass valid query |
There are no asynchronous operations to cancel. Replacing/releasing the query
is the deterministic cache-reset and recovery path.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Extracted directory | Player `extracted_dir` | Current sandbox | Captured at creation | ADT source root |
| Map directory name | Player `map_name` | Current sandbox | Captured at creation | ADT filename/path |
| Loader override | Invalid callable | Test/custom data source | Constructor only | Bypasses production ClassDB/file loading |
| Tile cache | Enabled | All current uses | Cleared by replacing query | Avoids repeated parse/failure work |
| Height policy | 9x9 outer-grid bilinear | Current sandbox | Fixed | Preserves pre-extraction behavior |
## Persistence, cache and migration
The cache is in-memory and unversioned because it holds native loader output for
one query lifetime. No disk cache, schema or migration changes are introduced.
Existing ADT/native/cache format versions remain unchanged.
## Diagnostics and observability
- Logs: dedicated verifier emits `TERRAIN_QUERY PASS` or named failures.
- Structured status: every unavailable sample has a stable `failure_code`.
- Metrics: synthetic verifier counts loader calls to prove one load per tile.
- Debug views: none.
- Correlation IDs: not applicable to synchronous local query.
## Verification
- Contract: available/unavailable/base/non-finite result invariants.
- Pure synthetic adapter: typed coordinate mapping, exact chunk selection,
145-height layout and bilinear interpolation within `0.002` units (Vector3
source precision), plus cached second query.
- Failures: empty tile and invalid height array produce stable codes.
- Integration: asset-free player scene accepts injected flat query and preserves
spawn offset `0.05` plus physics ground snap.
- Boundary: source regression rejects ADT loader/cache/chunk helpers in player.
- Fidelity evidence: existing interpolation and snap behavior only; no
original-client or collision parity claim.
- Performance budget: one dictionary cache lookup and scalar interpolation per
cached query; native parse only on first tile access.
## Extension points
- Streaming renderer or physics backend may implement `TerrainQuery` without changing player/movement.
- Future result may grow separate slope, surface, liquid or collision contracts;
incompatible additions require a versioned contract rather than Dictionary fields.
- Test/authoring data sources may use constructor loader override.
- Async streaming requires a separate availability/update model.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Typed replaceable ground-height query | Implemented | Contract and injected player regression | Integrate target checklist after review |
| Existing ADT height interpolation | Implemented | Synthetic fractional-grid fixture | Compare terrain contact with build 12340 |
| Per-tile success/failure cache | Implemented | Loader call-count regression | Add explicit invalidation only with authoring/hot reload |
| Renderer/streaming cache backend | Planned | Boundary permits replacement | M03 renderer facade/public terrain backend |
| Holes/slopes/collision | Planned | Outside height-only contract | Later movement/physics package |
| Liquid/swim query | Planned | Outside contract | M09/M12 world gameplay |
## Known gaps and risks
- Bilinear outer-grid height is the inherited sandbox approximation; exact ADT
triangle choice, holes and collision are not represented.
- Query is synchronous; first access may parse an ADT on the main thread exactly
as before extraction. A streaming/backend replacement is future work.
- Unavailable results remain cached until query replacement.
- Player still owns ground offset/snap policy; only data access is extracted.
- Existing native dictionaries remain dynamic inside the adapter.
## Source map
| Path | Responsibility |
|---|---|
| `src/gameplay/terrain/terrain_ground_sample.gd` | Immutable available/unavailable result |
| `src/gameplay/terrain/terrain_query.gd` | Replaceable typed query boundary |
| `src/gameplay/terrain/adt_terrain_query.gd` | Native ADT adapter, cache, chunk selection and interpolation |
| `src/scenes/player/third_person_wow_controller.gd` | Query composition and existing ground-snap consumer |
| `src/tools/verify_terrain_query.gd` | Synthetic contract/cache/failure/injection regression |
| `src/tests/scenes/player_input_regression.tscn` | Asset-free player composition fixture |
## Related decisions and references
- ADR: none; no coordinate, format or authority decision changed.
- Upstream/reference: `targets/02-player-decomposition.md`,
`targets/roadmap/02-rendering-and-graphics.md`,
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
+253
View File
@@ -0,0 +1,253 @@
# Third-Person Camera
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M02 / M02-RND-CAMERA-001 |
| Owners | Camera presentation state and CameraPivot/Camera3D lifecycle |
| Last verified | `8c1cf9b`, 2026-07-14 |
| Profiles/capabilities | Current sandbox orbit/zoom; identity collision policy |
## Purpose
Own third-person mouse capture, orbit yaw/pitch, zoom state and Camera3D local
transforms on the existing `CameraPivot` node. Expose an explicit replaceable
camera-distance collision policy while preserving the current no-collision
sandbox baseline.
## Non-goals
- Implement an unverified physics raycast or claim build-12340 collision parity.
- Own player movement, terrain queries, streaming focus or character visuals.
- Implement first-person, follow lag, shoulder camera, cinematics or view modes.
- Change current sensitivity, pitch/zoom limits, FOV or far plane.
## Context and boundaries
```mermaid
flowchart LR
InputMap[Remappable camera actions] --> Player[ThirdPersonWowController dispatch]
Player --> Rig[ThirdPersonCameraRig]
Mouse[Mouse motion] --> Player
Rig --> Character[Character yaw transform]
Rig --> Pivot[CameraPivot local transform]
Rig --> Policy[CameraCollisionPolicy]
Policy --> Distance[Resolved distance]
Distance --> Camera[Camera3D local transform]
Rig --> FlightBasis[Godot-world pivot basis]
FlightBasis --> Movement[LocalPlayerMovementController adapter]
```
Allowed dependencies:
- Existing `PlayerInputActions` names and Godot input events.
- Explicit character, pivot and Camera3D Node references on the main thread.
- Replaceable `CameraCollisionPolicy` selected by composition/tests.
- Movement adapter may read the rig's Godot-world basis during sandbox flight.
Forbidden dependencies:
- ADT/terrain parsing, movement velocity, gameplay state or animation ownership.
- Camera rig discovering renderer/network/server services.
- Default collision policy performing hidden raycasts or changing baseline distance.
- Collision policy mutating rig/camera/character nodes.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `ThirdPersonCameraRig` | `Node3D` component | Owns orbit/zoom state on CameraPivot | Main thread; scene-owned | Reports missing Camera3D |
| `initialize_for_character(character_body)` | Composition method | Attaches yaw target and captures initial character yaw | Main thread after scene ready | Null leaves yaw target absent |
| `handle_camera_input(event)` | Input adapter | Applies capture/release/zoom/orbit actions | Main input thread | Returns whether camera consumed state |
| `set_collision_policy(policy)` | Composition method | Replaces distance resolution policy | Main thread | Null rejected; old policy retained |
| `refresh_camera_transform()` | Presentation update | Applies pivot pose and resolved camera distance | Main/physics thread | Missing camera leaves state but no Camera3D mutation |
| `godot_world_flight_movement_basis()` | Read model | Returns pivot global basis for sandbox flight | Main thread | None |
| `CameraCollisionPolicy.resolve_camera_distance(pivot, requested)` | Policy boundary | Resolves camera distance in Godot units | Main thread in current implementation | Rig clamps output to `[0, requested]` |
| `NoCameraCollisionPolicy` | Identity policy | Preserves requested distance with no physics query | Any main-thread call | None |
Read-only rig state: `yaw_radians`, `pitch_radians`,
`requested_distance_units`, `resolved_distance_units` and `is_mouse_captured`.
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Camera action `InputEvent` | Godot/player dispatch | Camera rig | Engine-owned event | One dispatch |
| Input | Mouse relative pixels | Godot input | Camera rig | Engine-owned event | One dispatch |
| Input | Character `Node3D` | Player composition | Camera rig | Scene-owned reference | Rig scene lifetime |
| Input | Collision policy | Composition/test | Camera rig | Rig-held RefCounted | Until replacement |
| Output | Character yaw | Camera rig | Player transform | Scene mutation | Orbit event |
| Output | Pivot pitch/height | Camera rig | CameraPivot | Scene mutation | Refresh |
| Output | Requested/resolved distance | Camera rig/policy | Camera3D | Rig-owned scalar/read model | Until next input/refresh |
| Output | Godot-world `Basis` | Camera rig | Flight movement adapter | Value copy | Physics tick |
Side effects:
- Mutates mouse mode, character yaw, CameraPivot local transform and Camera3D local transform.
- Marks Camera3D current and sets its far plane once at ready.
- Releases captured mouse on rig removal.
- Creates no files, caches, RIDs, workers, network messages or gameplay state.
## Data flow
```mermaid
flowchart LR
Action[Camera action or mouse delta] --> State[Yaw pitch requested distance capture]
State --> Clamp[Pitch/zoom clamps]
Clamp --> Pivot[Pivot height and pitch]
Clamp --> Request[Requested distance]
Request --> Policy[CameraCollisionPolicy]
Policy --> Safe[Clamp 0..requested]
Safe --> Camera[Camera3D Z distance]
State --> Character[Character yaw]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> MouseVisible
MouseVisible --> MouseCaptured: camera rotate pressed
MouseCaptured --> MouseVisible: camera rotate released
MouseCaptured --> MouseVisible: release cursor
MouseCaptured --> MouseCaptured: mouse motion / orbit and clamp
MouseVisible --> MouseVisible: zoom and clamp
MouseCaptured --> [*]: rig removed / release mouse
MouseVisible --> [*]: rig removed
```
Zoom and collision distance are orthogonal to capture state. The requested zoom
persists while a policy may temporarily choose a shorter resolved distance.
## Main sequence
```mermaid
sequenceDiagram
participant Engine as Godot Input
participant Player as ThirdPersonWowController
participant Rig as ThirdPersonCameraRig
participant Policy as CameraCollisionPolicy
participant Camera as Camera3D
Engine->>Player: InputEvent
Player->>Rig: handle_camera_input(event)
Rig->>Rig: update capture/orbit/zoom state
Rig->>Policy: resolve_camera_distance(pivot, requested)
Policy-->>Rig: resolved distance
Rig->>Camera: apply local position/rotation
opt captured mouse motion
Rig->>Player: mutate character yaw transform
end
```
## Ownership, threading and resources
- Parent player scene owns the CameraPivot rig and Camera3D child.
- Rig holds non-owning scene references to character and camera.
- Rig owns scalar camera state and the RefCounted collision policy.
- All input/state/scene mutations occur on the main thread.
- Policy output is a scalar; policies must not mutate supplied nodes.
- No background tasks, resources, RIDs or caches are created.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Camera3D missing | `_ready` lookup | State continues; transform application skipped | `ThirdPersonCameraRig` error with path | Restore child/path and recreate scene |
| Character missing | Initialization | Orbit state updates without character yaw mutation | Composition/test failure | Call initializer with player node |
| Null collision policy | Setter guard | Current policy retained | Rig error | Pass valid policy |
| Policy returns negative/too large | Rig clamp | Distance clamped to `[0, requested]` | Policy regression tests | Fix/replace policy |
| Rig removed while captured | `_exit_tree` | Mouse made visible | Lifecycle regression | Recreate rig normally |
There is no asynchronous cancellation or persisted rollback. Recreating the rig
restores configured defaults.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Initial pitch | `-18°` | Current sandbox | State-owned after ready | Initial orbit elevation |
| Pitch limits | `-65°..35°` | Current sandbox | Exported | Mouse clamp |
| Mouse sensitivity | `0.003` rad/pixel | Current sandbox | Exported | Orbit delta |
| Pivot height | `1.7` units | Current sandbox | Exported | Camera eye origin |
| Initial distance | `8` units | Current sandbox | Exported before ready | Requested zoom |
| Zoom limits/step | `2..18`, step `1` | Current sandbox | Exported | Requested distance clamp |
| Far plane | `50000` units | Renderer sandbox | Exported | Camera far plane |
| Collision policy | `NoCameraCollisionPolicy` | Current sandbox | Replaceable | Identity distance/no raycast |
## Persistence, cache and migration
Camera state and policy are transient. No SavedVariables/settings schema, cache
or migration is introduced. Scene paths remain
`ThirdPersonPlayer/CameraPivot/Camera3D`, so renderer diagnostics and capture
tools require no path migration.
## Diagnostics and observability
- Logs: missing camera/null policy emit explicit component errors.
- Read models: yaw, pitch, requested/resolved distance and capture state.
- Tests: dedicated verifier reports state/policy/scene counts.
- Metrics/correlation IDs: not applicable to constant-time local presentation.
## Verification
- State regression: initial yaw/pitch/distance/height/far plane, capture/release,
mouse orbit, character yaw, pitch clamps and zoom clamps.
- Policy regression: fixed-distance test policy changes Camera3D Z; identity
policy restores requested distance; baseline policy contains no physics query.
- Scene integration: Eastern Kingdoms, Kalimdor and asset-free regression scenes
attach the rig to the existing CameraPivot.
- Boundary: player no longer owns capture/yaw/pitch/distance or camera transform helper.
- Existing input/movement/terrain regressions and renderer dry-run remain required.
- Fidelity evidence: current numeric defaults and identity collision behavior
only; original-client orbit/collision comparison remains open.
- Performance: constant-time scalar/transform work once per physics tick and
input event; no default raycast.
## Extension points
- Add an opt-in raycast collision policy after reference fixtures define mask,
margin, recovery smoothing and first-person limit.
- Add follow/shoulder/cinematic rigs as separate components, not branches inside this rig.
- Persist user camera settings through a future versioned settings module.
- Input contexts may suppress camera actions before dispatch without changing rig state.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Independent third-person rig | Implemented | Three scene wirings and player boundary regression | Integrate target checklist after review |
| Existing orbit/zoom behavior | Implemented | Numeric state/transform regression | Compare against build 12340 |
| Camera-relative sandbox flight basis | Implemented | Basis and movement regressions | Restrict flight to sandbox profile |
| Replaceable collision policy | Implemented | Fixed and identity policy tests | Implement verified collision policy |
| Active camera collision | Planned | Identity policy intentionally preserves baseline | Capture original-client occluder fixtures first |
| First-person/follow policies | Planned | Outside current sandbox baseline | Later completeness work |
## Known gaps and risks
- Default collision policy performs no occluder avoidance, matching the previous sandbox.
- Exact WoW camera orbit direction, pitch limits, zoom curve, collision margin and
recovery smoothing are unverified.
- Exported initial values are captured at ready; runtime mutation may require an
explicit reconfiguration API later.
- Mouse mode remains process-global Godot state and must be coordinated with future UI contexts.
## Source map
| Path | Responsibility |
|---|---|
| `src/scenes/player/third_person_camera_rig.gd` | CameraPivot state, input and transforms |
| `src/scenes/player/camera_collision_policy.gd` | Replaceable distance-policy boundary |
| `src/scenes/player/no_camera_collision_policy.gd` | Identity baseline policy |
| `src/scenes/player/third_person_wow_controller.gd` | Camera event dispatch and movement-basis consumer |
| `src/scenes/streaming/*_streaming.tscn` | Runtime rig composition |
| `src/tests/scenes/player_input_regression.tscn` | Asset-free rig/player composition |
| `src/tools/verify_third_person_camera_rig.gd` | State, policy, scene and boundary regression |
## Related decisions and references
- ADR: none; current behavior and scene paths are preserved.
- Upstream/reference: `targets/02-player-decomposition.md`,
`targets/roadmap/02-rendering-and-graphics.md`,
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
+56 -15
View File
@@ -5,9 +5,9 @@
| Field | Value | | Field | Value |
|---|---| |---|---|
| Status | Partial | | Status | Partial |
| Target/work package | M00 active; декомпозиция M01M03 | | Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; декомпозиция M02M03 |
| Owners | Renderer workstream / milestone integrator | | Owners | Renderer workstream / milestone integrator |
| Last verified | M00 visual-diff worktree, 2026-07-11 | | Last verified | Worktree `work/sindo-main-codex/m01-server-spawn-renderer`, 2026-07-13 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose ## Purpose
@@ -26,7 +26,13 @@
```mermaid ```mermaid
flowchart LR flowchart LR
Focus[Camera/player/editor focus] --> Loader[StreamingWorldLoader] Player[Player or spectator Node3D] --> Adapter[Explicit source adapter]
Editor[Editor viewport adapter] --> Focus[StreamingFocus]
Capture[Capture tool] --> Adapter
SpawnFixture[Pinned server spawn fixture] --> SpawnManifest[Spawn render manifest]
SpawnManifest --> Capture
Adapter --> Focus
Focus --> Loader[StreamingWorldLoader]
Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders] Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders]
Cache[Baked terrain/M2/WMO caches] --> Loader Cache[Baked terrain/M2/WMO caches] --> Loader
Native --> Parsed[Parsed tile/model data] Native --> Parsed[Parsed tile/model data]
@@ -60,7 +66,11 @@ Forbidden dependencies:
|---|---|---|---|---| |---|---|---|---|---|
| `map_name` | Exported property | WDT/map directory name | Set before world load | Missing WDT produces diagnostic/empty world | | `map_name` | Exported property | WDT/map directory name | Set before world load | Missing WDT produces diagnostic/empty world |
| `extracted_dir` | Exported property | Root of legally extracted client data | Session lifetime | Missing files reported per loader path | | `extracted_dir` | Exported property | Root of legally extracted client data | Session lifetime | Missing files reported per loader path |
| `camera_path` | Exported property | Current streaming focus compatibility path | Main thread, scene lifetime | Missing camera prevents normal focus update | | `StreamingFocus` | Immutable value | Typed Godot-basis position without Node/Camera dependency | Any thread; caller-owned reference | Null position is a caller contract violation |
| `streaming_focus_source_path` | Exported adapter property | Samples any explicit `Node3D` source into `StreamingFocus` | Main thread, scene lifetime | Missing/wrong node warns once and retains prior focus |
| `set_streaming_focus` | Public method | Accepts focus from app/editor/tool composition | Main thread/session | Null focus is ignored by refresh until replaced |
| `refresh_streaming_focus` | Public method | Samples configured source and applies refresh thresholds | Main thread/session | Returns `false` when no valid focus exists |
| `camera_path` | Exported property | Camera used only by optional automatic overview positioning | Main thread, scene lifetime | Missing camera skips automatic positioning |
| `quality_preset` | Exported property | Applies renderer budgets/radii | Before/at runtime depending property | Invalid combination should be diagnosed | | `quality_preset` | Exported property | Applies renderer budgets/radii | Before/at runtime depending property | Invalid combination should be diagnosed |
| `runtime_stats_enabled` | Exported property | Enables periodic performance snapshot | Runtime mutable | Logging overhead only | | `runtime_stats_enabled` | Exported property | Enables periodic performance snapshot | Runtime mutable | Logging overhead only |
| `hitch_profiler_enabled` | Exported property | Enables named hitch sections | Runtime mutable | Logging overhead only | | `hitch_profiler_enabled` | Exported property | Enables named hitch sections | Runtime mutable | Logging overhead only |
@@ -71,12 +81,16 @@ Forbidden dependencies:
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | | Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Input | Map/focus/configuration | Scene/app/editor | `StreamingWorldLoader` | Caller config, copied/read | Main thread/session | | Input | `StreamingFocus` | Player/spectator/editor/capture adapter | `StreamingWorldLoader` | Immutable caller value | Main thread/session |
| Input | Focus-source `NodePath` | Runtime/test scene composition | Loader source adapter | Scene-owned node, sampled position | Main thread/scene lifetime |
| Input | Map/configuration | Scene/app/editor | `StreamingWorldLoader` | Caller config, copied/read | Main thread/session |
| Input | WDT/ADT/M2/WMO/BLP bytes | Extracted asset repository | Native loaders/builders | Loader result owns parsed data | Worker or controlled load | | Input | WDT/ADT/M2/WMO/BLP bytes | Extracted asset repository | Native loaders/builders | Loader result owns parsed data | Worker or controlled load |
| Input | Baked resources | Cache build tools | Streamer/builders | Shared immutable resource/cache entry | Session/cache lifetime | | Input | Baked resources | Cache build tools | Streamer/builders | Shared immutable resource/cache entry | Session/cache lifetime |
| Test input | Server-spawn render manifest | Coordinate fixture/verifier | Checkpoint capture tool | Repository-owned diagnostic data | Test-process lifetime |
| Output | Desired tile/detail operations | Streaming planner inside loader | Finalize queues | Loader-owned | Cross-frame | | Output | Desired tile/detail operations | Streaming planner inside loader | Finalize queues | Loader-owned | Cross-frame |
| Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach | | Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach |
| Output | Metrics/diagnostics | Hitch/stat instrumentation | Logs/baseline tools | Immutable records | Periodic/frame | | Output | Metrics/diagnostics | Hitch/stat instrumentation | Logs/baseline tools | Immutable records | Periodic/frame |
| Test output | Diagnostic spawn marker and cold/warm PNGs | Checkpoint capture tool | Human/integrator review | SceneTree marker; `user://` files | One capture process |
Side effects: Side effects:
@@ -90,7 +104,15 @@ Side effects:
```mermaid ```mermaid
flowchart TD flowchart TD
F[Streaming focus] --> T[Compute desired ADT tiles] P[Player/spectator Node3D] --> A[Explicit scene adapter]
C[Capture camera] --> A
SF[Pinned server spawn] --> SM[Validated spawn manifest]
SM --> C
SM --> Marker[Renderer-native origin marker]
E[Editor viewport] --> EA[Editor adapter]
A --> F[StreamingFocus]
EA --> F
F --> T[Compute desired ADT tiles]
WDT[WDT tile catalog] --> T WDT[WDT tile catalog] --> T
T --> Q[Tile load queue] T --> Q[Tile load queue]
Q --> P[Worker parse/cache load] Q --> P[Worker parse/cache load]
@@ -104,6 +126,7 @@ flowchart TD
M2 --> World M2 --> World
WMO --> World WMO --> World
Liquid --> World Liquid --> World
Marker --> World
F --> E[Eviction/retention decisions] F --> E[Eviction/retention decisions]
E --> World E --> World
``` ```
@@ -113,13 +136,14 @@ flowchart TD
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> Initializing [*] --> Initializing
Initializing --> Ready: WDT/catalog/config ready Initializing --> WaitingForFocus: WDT/catalog/config ready
Initializing --> Degraded: missing loaders/data/cache Initializing --> Degraded: missing loaders/data/cache
Ready --> Streaming: focus update WaitingForFocus --> Streaming: valid StreamingFocus
Streaming --> Streaming: load/finalize/evict ticks Streaming --> Streaming: load/finalize/evict ticks
Streaming --> WaitingForFocus: world reset without replacement focus
Streaming --> SwitchingMap: map/focus reset Streaming --> SwitchingMap: map/focus reset
SwitchingMap --> Initializing SwitchingMap --> Initializing
Ready --> ShuttingDown WaitingForFocus --> ShuttingDown
Degraded --> ShuttingDown Degraded --> ShuttingDown
Streaming --> ShuttingDown Streaming --> ShuttingDown
ShuttingDown --> [*] ShuttingDown --> [*]
@@ -129,12 +153,14 @@ stateDiagram-v2
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Focus as Camera/Player/Editor participant Source as Player/Editor/Capture source
participant Focus as StreamingFocus adapter
participant Stream as StreamingWorldLoader participant Stream as StreamingWorldLoader
participant Worker as Worker task participant Worker as Worker task
participant Budget as Main-thread budget participant Budget as Main-thread budget
participant Render as SceneTree/RenderingServer participant Render as SceneTree/RenderingServer
Focus->>Stream: focus position changed Source->>Focus: sample explicit Godot position
Focus->>Stream: set/refresh typed focus
Stream->>Stream: desired tiles and priorities Stream->>Stream: desired tiles and priorities
Stream->>Worker: parse/load tile and detail data Stream->>Worker: parse/load tile and detail data
Worker-->>Stream: immutable result Worker-->>Stream: immutable result
@@ -150,6 +176,8 @@ sequenceDiagram
## Ownership, threading and resources ## Ownership, threading and resources
- `StreamingWorldLoader` владеет tile states, queues, task registries, active world instances и render caches session scope. - `StreamingWorldLoader` владеет tile states, queues, task registries, active world instances и render caches session scope.
- Focus producers own the immutable `StreamingFocus` reference; the loader retains
the latest reference and converts it to `Vector3` only inside the render boundary.
- Worker tasks не должны менять SceneTree и shared Resource concurrently. - Worker tasks не должны менять SceneTree и shared Resource concurrently.
- Parsed/grouped results передаются обратно через guarded result queues. - Parsed/grouped results передаются обратно через guarded result queues.
- Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets. - Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets.
@@ -163,6 +191,8 @@ sequenceDiagram
| Failure | Detection | Behavior | Diagnostic | Recovery | | Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---| |---|---|---|---|---|
| Missing WDT/ADT | File/catalog check | Tile/map unavailable | Loader warning/error | Fix extraction/profile, reload | | Missing WDT/ADT | File/catalog check | Tile/map unavailable | Loader warning/error | Fix extraction/profile, reload |
| Missing focus source | Explicit NodePath resolution | Retain current streamed content and prior valid focus | One warning until source recovers | Fix composition path or call `set_streaming_focus` |
| No focus supplied | Refresh contract | No target recomputation; queues continue draining | `refresh_streaming_focus()` returns `false` | Supply player/editor/capture focus |
| Cache version mismatch | Required format constant | Reject stale cache/fallback | Version diagnostic | Rebuild matching cache | | Cache version mismatch | Required format constant | Reject stale cache/fallback | Version diagnostic | Rebuild matching cache |
| Worker failure | Task result/empty data | Skip/degrade affected asset | Queue/task warning | Retry/rebuild source | | Worker failure | Task result/empty data | Skip/degrade affected asset | Queue/task warning | Retry/rebuild source |
| Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path | | Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path |
@@ -184,6 +214,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Persistence, cache and migration ## Persistence, cache and migration
- `StreamingFocus` is runtime-only and is not serialized. Scene migration adds
`streaming_focus_source_path`; existing `camera_path` remains valid only for
optional automatic overview positioning.
- Renderer reads caches under `data/cache`; caches are not committed. - Renderer reads caches under `data/cache`; caches are not committed.
- Format changes require `FORMAT_VERSION`/required-version bump and rebuild instructions. - Format changes require `FORMAT_VERSION`/required-version bump and rebuild instructions.
- Atomic cache publication and unified manifest are target-state work from FND/M03. - Atomic cache publication and unified manifest are target-state work from FND/M03.
@@ -198,8 +231,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, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan. - Unit/contract tests: streaming-focus contract/wiring, 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. - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00
cold/warm checkpoints and one dedicated mapped server-spawn checkpoint.
- Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe. - Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe.
- 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).
@@ -207,7 +241,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Extension points ## Extension points
- Target `WorldRenderFacade` for runtime/editor/capture callers. - Target `WorldRenderFacade` for runtime/editor/capture callers.
- Target `StreamingFocus` independent of Camera3D. - Extend `StreamingFocus` with measured direction/velocity needs when the pure planner is extracted.
- Extracted planner, budget scheduler, terrain, M2, WMO, liquid, sky and character services. - Extracted planner, budget scheduler, terrain, M2, WMO, liquid, sky and character services.
- Separate Blizzlike/Enhanced material and quality profiles. - Separate Blizzlike/Enhanced material and quality profiles.
@@ -221,12 +255,15 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity | | Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity |
| Sky/light | Partial | DBC controller/logs | Zone transition paired validation | | Sky/light | Partial | DBC controller/logs | Zone transition paired validation |
| Character presentation | Prototype | Character creator/player experiment | Full customization/equipment states | | Character presentation | Prototype | Character creator/player experiment | Full customization/equipment states |
| Camera-independent streaming focus | Implemented | Typed contract, two player-focused runtime scenes and three explicitly camera-focused diagnostic tools | Multi-focus/direction/velocity belong to planner extraction |
| Golden server-spawn visualization | Implemented | Pinned AzerothCore fixture, validated renderer manifest and diagnostic origin-marker capture at ADT `(32,48)`/MCNK `(3,12)` | No TrinityCore row or original-client visual-parity claim |
| Stable renderer facade | Planned | M03 | Current caller uses monolithic Node API | | Stable renderer facade | Planned | M03 | Current caller uses monolithic Node API |
## Known gaps and risks ## Known gaps and risks
- Monolithic streamer mixes planning, jobs, caches and presentation. - Monolithic streamer mixes planning, jobs, caches and presentation.
- Direct camera path remains until M01/M03. - `camera_path` remains only for optional automatic overview positioning; it no
longer drives runtime streaming or discovers the active viewport camera.
- 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. - Первый 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. - Terrain-height probe исключил under-terrain состояние для всех пяти точек; waterfall exact-XZ miss классифицирован как TriangleMesh seam/edge и подтверждён nearby sample в 2 units.
@@ -247,6 +284,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Path | Responsibility | | Path | Responsibility |
|---|---| |---|---|
| `src/scenes/streaming/streaming_world_loader.gd` | Current runtime orchestration, queues, caches and attachment | | `src/scenes/streaming/streaming_world_loader.gd` | Current runtime orchestration, queues, caches and attachment |
| `src/domain/streaming/streaming_focus.gd` | Immutable camera-independent streaming position contract |
| `src/tools/verify_streaming_focus.gd` | Headless contract, dependency and runtime/tool wiring regression |
| `addons/mpq_extractor/loaders/adt_builder.gd` | Terrain/material/liquid construction | | `addons/mpq_extractor/loaders/adt_builder.gd` | Terrain/material/liquid construction |
| `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material construction | | `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material construction |
| `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment | | `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment |
@@ -258,6 +297,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| `src/tools/compare_render_checkpoints.gd` | Offline JPG/PNG paired-image perceptual metrics and JSON pass/fail report | | `src/tools/compare_render_checkpoints.gd` | Offline JPG/PNG paired-image perceptual metrics and JSON pass/fail report |
| `src/tools/verify_render_runtime_cache_shutdown.gd` | Headless ownership regression for detached runtime prototypes, resource caches and empty liquid roots | | `src/tools/verify_render_runtime_cache_shutdown.gd` | Headless ownership regression for detached runtime prototypes, resource caches and empty liquid roots |
| `src/tools/capture_render_checkpoints.gd` | Deterministic no-roll checkpoint camera, performance and visual capture | | `src/tools/capture_render_checkpoints.gd` | Deterministic no-roll checkpoint camera, performance and visual capture |
| `src/tools/server_spawn_render_manifest.json` | Dedicated mapped AzerothCore spawn checkpoint and diagnostic marker configuration |
| `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer checkpoint contract verification |
| `tools/sweep_render_checkpoint_camera_pose.ps1` | Offline yaw/pitch capture grid and paired-error ranking | | `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/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_terrain_height.gd` | Offline active-mesh terrain height and camera-clearance report |
+63
View File
@@ -19,6 +19,69 @@ config/icon="res://icon.svg"
enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg") enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg")
[input]
openwc_player_move_forward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
]
}
openwc_player_move_backward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
openwc_player_strafe_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
openwc_player_strafe_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
openwc_player_debug_fly_up={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
openwc_player_debug_fly_down={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
]
}
openwc_player_debug_sprint={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194325,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
openwc_player_debug_toggle_flight={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":32,"key_label":32,"unicode":32,"location":0,"echo":false,"script":null)
]
}
openwc_player_camera_rotate={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
openwc_player_camera_zoom_in={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
openwc_player_camera_zoom_out={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
openwc_player_release_cursor={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics] [physics]
3d/physics_engine="Jolt Physics" 3d/physics_engine="Jolt Physics"
@@ -48,6 +48,36 @@ static func godot_to_canonical(godot_position: GodotWorldPosition) -> CanonicalW
) )
## Returns ADT filename indices directly from a Godot renderer position.
static func godot_to_adt_tile(godot_position: GodotWorldPosition) -> AdtTileCoordinate:
return AdtTileCoordinate.new(
int(floor(godot_position.x_units / ADT_TILE_SIZE_YARDS)),
int(floor(godot_position.z_units / ADT_TILE_SIZE_YARDS))
)
## Returns east/height/south offsets inside the owning ADT tile.
static func godot_to_adt_tile_local(godot_position: GodotWorldPosition) -> AdtTileLocalPosition:
var tile_coordinate := godot_to_adt_tile(godot_position)
return AdtTileLocalPosition.new(
godot_position.x_units - float(tile_coordinate.tile_x) * ADT_TILE_SIZE_YARDS,
godot_position.y_units,
godot_position.z_units - float(tile_coordinate.tile_y) * ADT_TILE_SIZE_YARDS
)
## Converts an ADT tile/local pair directly to the Godot renderer basis.
static func adt_tile_local_to_godot(
tile_coordinate: AdtTileCoordinate,
local_position: AdtTileLocalPosition
) -> GodotWorldPosition:
return GodotWorldPosition.new(
float(tile_coordinate.tile_x) * ADT_TILE_SIZE_YARDS + local_position.east_yards,
local_position.height_yards,
float(tile_coordinate.tile_y) * ADT_TILE_SIZE_YARDS + local_position.south_yards
)
## Converts canonical WoW coordinates to MDDF/MODF X/height/Z file fields. ## Converts canonical WoW coordinates to MDDF/MODF X/height/Z file fields.
static func canonical_to_adt_placement(canonical_position: CanonicalWowWorldPosition) -> AdtPlacementPosition: static func canonical_to_adt_placement(canonical_position: CanonicalWowWorldPosition) -> AdtPlacementPosition:
return AdtPlacementPosition.new( return AdtPlacementPosition.new(
+43
View File
@@ -0,0 +1,43 @@
class_name ContentId
extends RefCounted
## Stable authored-content identity serialized as canonical lowercase UUID text.
## It is independent of runtime entities, server database entries and wire GUIDs.
var uuid_text: String:
get:
return _uuid_text
var _uuid_text: String
## Normalizes surrounding whitespace and hexadecimal letter case.
func _init(uuid_text_value: String) -> void:
_uuid_text = uuid_text_value.strip_edges().to_lower()
## Reports whether the value has the canonical 8-4-4-4-12 UUID text shape.
func is_valid() -> bool:
if _uuid_text.length() != 36:
return false
for character_index in range(_uuid_text.length()):
if character_index in [8, 13, 18, 23]:
if _uuid_text[character_index] != "-":
return false
elif not _is_lowercase_hexadecimal_character(_uuid_text[character_index]):
return false
return true
## Compares stable content identity by normalized UUID text.
func equals(other_content_id: ContentId) -> bool:
return other_content_id != null and _uuid_text == other_content_id.uuid_text
## Returns the canonical dictionary/serialization key.
func to_key() -> String:
return _uuid_text
func _is_lowercase_hexadecimal_character(character: String) -> bool:
return (character >= "0" and character <= "9") or (character >= "a" and character <= "f")
+1
View File
@@ -0,0 +1 @@
uid://dk6splyui2an1
+31
View File
@@ -0,0 +1,31 @@
class_name EntityId
extends RefCounted
## Session-local runtime entity identity.
## This sequence is never a stable content ID, server entry or WoW wire GUID.
var session_sequence: int:
get:
return _session_sequence
var _session_sequence: int
## Stores an allocator-issued session sequence without persistence semantics.
func _init(session_sequence_value: int) -> void:
_session_sequence = session_sequence_value
## Reports whether this is a positive allocator-issued sequence.
func is_valid() -> bool:
return _session_sequence > 0
## Compares identity inside the same explicitly owned runtime session.
func equals(other_entity_id: EntityId) -> bool:
return other_entity_id != null and _session_sequence == other_entity_id.session_sequence
## Returns a diagnostic key; it must not be persisted across sessions.
func to_debug_key() -> String:
return "entity:%d" % _session_sequence
+1
View File
@@ -0,0 +1 @@
uid://bqdd8el4me5ng
+41
View File
@@ -0,0 +1,41 @@
class_name ServerEntryId
extends RefCounted
## Adapter-namespaced numeric entry from a server database or canonical snapshot.
## Equal numbers in different template tables are intentionally different IDs.
var entry_namespace: String:
get:
return _entry_namespace
var entry_value: int:
get:
return _entry_value
var _entry_namespace: String
var _entry_value: int
## Normalizes a table/adapter namespace and preserves its positive entry value.
func _init(entry_namespace_value: String, entry_value_value: int) -> void:
_entry_namespace = entry_namespace_value.strip_edges().to_lower()
_entry_value = entry_value_value
## Reports whether both collision-prevention fields are present.
func is_valid() -> bool:
return not _entry_namespace.is_empty() and _entry_value > 0
## Compares both namespace and numeric entry.
func equals(other_server_entry_id: ServerEntryId) -> bool:
return (
other_server_entry_id != null
and _entry_namespace == other_server_entry_id.entry_namespace
and _entry_value == other_server_entry_id.entry_value
)
## Returns the stable adapter key without implying a ContentId mapping.
func to_key() -> String:
return "%s:%d" % [_entry_namespace, _entry_value]
@@ -0,0 +1 @@
uid://cg3mht5bhgsya
+53
View File
@@ -0,0 +1,53 @@
class_name WowGuid
extends RefCounted
## Opaque 64-bit WoW wire GUID stored as two lossless unsigned 32-bit words.
## Build-specific high-type decoding belongs to the protocol adapter and must be
## fixture-backed before becoming a domain contract.
const MAXIMUM_UNSIGNED_32_BIT_VALUE := 0xffffffff
var high_32_bits: int:
get:
return _high_32_bits
var low_32_bits: int:
get:
return _low_32_bits
var _high_32_bits: int
var _low_32_bits: int
## Preserves both wire words without signed 64-bit conversion.
func _init(high_32_bits_value: int, low_32_bits_value: int) -> void:
_high_32_bits = high_32_bits_value
_low_32_bits = low_32_bits_value
## Reports whether both words fit their unsigned wire ranges.
func has_valid_word_ranges() -> bool:
return _is_unsigned_32_bit_word(_high_32_bits) and _is_unsigned_32_bit_word(_low_32_bits)
## Reports the all-zero protocol sentinel without assigning gameplay meaning.
func is_empty() -> bool:
return _high_32_bits == 0 and _low_32_bits == 0
## Compares all 64 opaque wire bits.
func equals(other_wow_guid: WowGuid) -> bool:
return (
other_wow_guid != null
and _high_32_bits == other_wow_guid.high_32_bits
and _low_32_bits == other_wow_guid.low_32_bits
)
## Returns exactly sixteen lowercase hexadecimal digits for diagnostics/replays.
func to_hex_string() -> String:
return "%08x%08x" % [_high_32_bits, _low_32_bits]
func _is_unsigned_32_bit_word(word_value: int) -> bool:
return word_value >= 0 and word_value <= MAXIMUM_UNSIGNED_32_BIT_VALUE
+1
View File
@@ -0,0 +1 @@
uid://bmwlyacooq2lc
+47
View File
@@ -0,0 +1,47 @@
class_name MoveIntent
extends RefCounted
## Immutable player locomotion request expressed in character-local axes.
## Axis values are normalized to `[-1, 1]`: forward/right/up are positive.
## The intent contains no world coordinates, engine input events or presentation state.
var forward_axis: float:
get:
return _forward_axis
var strafe_axis: float:
get:
return _strafe_axis
var vertical_axis: float:
get:
return _vertical_axis
var is_sprint_requested: bool:
get:
return _is_sprint_requested
var _forward_axis: float
var _strafe_axis: float
var _vertical_axis: float
var _is_sprint_requested: bool
## Creates one frame's movement request without retaining mutable input state.
func _init(
forward_axis_value: float = 0.0,
strafe_axis_value: float = 0.0,
vertical_axis_value: float = 0.0,
sprint_requested: bool = false
) -> void:
_forward_axis = clampf(forward_axis_value, -1.0, 1.0)
_strafe_axis = clampf(strafe_axis_value, -1.0, 1.0)
_vertical_axis = clampf(vertical_axis_value, -1.0, 1.0)
_is_sprint_requested = sprint_requested
## Returns true when the intent requests planar or vertical translation.
func has_translation() -> bool:
return not is_zero_approx(_forward_axis) \
or not is_zero_approx(_strafe_axis) \
or not is_zero_approx(_vertical_axis)
+1
View File
@@ -0,0 +1 @@
uid://btk7brcavbeec
+17
View File
@@ -0,0 +1,17 @@
class_name StreamingFocus
extends RefCounted
## Immutable world-streaming point independent of cameras and the scene tree.
## Renderer adapters may obtain this position from a player, spectator, editor
## viewport or capture tool, but streaming consumers receive only this value.
var world_position: GodotWorldPosition:
get:
return _world_position
var _world_position: GodotWorldPosition
## Creates a focus at an explicit position in the Godot renderer basis.
func _init(world_position_value: GodotWorldPosition) -> void:
_world_position = world_position_value
@@ -0,0 +1 @@
uid://ehv78jvge0q2
@@ -0,0 +1,34 @@
class_name PlayerInputActions
extends RefCounted
## Stable Input Map action names consumed by the runtime player input adapter.
## Defaults preserve the current render-sandbox controls; users may remap every
## action through Godot's Input Map without changing gameplay code.
const MOVE_FORWARD := &"openwc_player_move_forward"
const MOVE_BACKWARD := &"openwc_player_move_backward"
const STRAFE_LEFT := &"openwc_player_strafe_left"
const STRAFE_RIGHT := &"openwc_player_strafe_right"
const DEBUG_FLY_UP := &"openwc_player_debug_fly_up"
const DEBUG_FLY_DOWN := &"openwc_player_debug_fly_down"
const DEBUG_SPRINT := &"openwc_player_debug_sprint"
const DEBUG_TOGGLE_FLIGHT := &"openwc_player_debug_toggle_flight"
const CAMERA_ROTATE := &"openwc_player_camera_rotate"
const CAMERA_ZOOM_IN := &"openwc_player_camera_zoom_in"
const CAMERA_ZOOM_OUT := &"openwc_player_camera_zoom_out"
const RELEASE_CURSOR := &"openwc_player_release_cursor"
const REQUIRED_ACTIONS: Array[StringName] = [
MOVE_FORWARD,
MOVE_BACKWARD,
STRAFE_LEFT,
STRAFE_RIGHT,
DEBUG_FLY_UP,
DEBUG_FLY_DOWN,
DEBUG_SPRINT,
DEBUG_TOGGLE_FLIGHT,
CAMERA_ROTATE,
CAMERA_ZOOM_IN,
CAMERA_ZOOM_OUT,
RELEASE_CURSOR,
]
@@ -0,0 +1 @@
uid://bbhtyxm428bkm
+45
View File
@@ -0,0 +1,45 @@
class_name PlayerInputSource
extends RefCounted
## Converts remappable Godot Input Map actions into immutable [MoveIntent] values.
## This adapter owns no movement state and may be replaced independently of the
## local movement controller.
## Samples the current Input singleton state for one physics tick.
func sample_move_intent() -> MoveIntent:
return compose_move_intent(
Input.get_action_strength(PlayerInputActions.MOVE_FORWARD),
Input.get_action_strength(PlayerInputActions.MOVE_BACKWARD),
Input.get_action_strength(PlayerInputActions.STRAFE_LEFT),
Input.get_action_strength(PlayerInputActions.STRAFE_RIGHT),
Input.get_action_strength(PlayerInputActions.DEBUG_FLY_UP),
Input.get_action_strength(PlayerInputActions.DEBUG_FLY_DOWN),
Input.is_action_pressed(PlayerInputActions.DEBUG_SPRINT)
)
## Builds a normalized intent from action strengths for deterministic tests and
## alternative input adapters. Strengths outside `[0, 1]` are clamped.
static func compose_move_intent(
forward_strength: float,
backward_strength: float,
strafe_left_strength: float,
strafe_right_strength: float,
fly_up_strength: float,
fly_down_strength: float,
sprint_requested: bool
) -> MoveIntent:
var planar_axis := Vector2(
clampf(strafe_right_strength, 0.0, 1.0) - clampf(strafe_left_strength, 0.0, 1.0),
clampf(forward_strength, 0.0, 1.0) - clampf(backward_strength, 0.0, 1.0)
)
if planar_axis.length_squared() > 1.0:
planar_axis = planar_axis.normalized()
var vertical_axis := clampf(fly_up_strength, 0.0, 1.0) - clampf(fly_down_strength, 0.0, 1.0)
return MoveIntent.new(
planar_axis.y,
planar_axis.x,
vertical_axis,
sprint_requested
)
@@ -0,0 +1 @@
uid://b050i2lnei8qq
@@ -0,0 +1,97 @@
class_name LocalPlayerMovementController
extends RefCounted
## Scene-free state and velocity integrator for the current local sandbox player.
## Direction inputs use a Godot-world movement basis selected by the scene
## adapter. This class does not discover Nodes, query terrain or mutate transforms.
var godot_world_velocity_units_per_second: Vector3:
get:
return _godot_world_velocity_units_per_second
var is_flight_enabled: bool:
get:
return _is_flight_enabled
var _run_speed_units_per_second: float
var _backward_speed_units_per_second: float
var _strafe_speed_units_per_second: float
var _flight_vertical_speed_units_per_second: float
var _acceleration_units_per_second_squared: float
var _sandbox_sprint_multiplier: float
var _godot_world_velocity_units_per_second := Vector3.ZERO
var _is_flight_enabled := false
## Creates a movement state with explicit Godot-unit speeds and acceleration.
## The current compatibility renderer treats one Godot unit as one WoW yard.
func _init(
run_speed_units_per_second: float,
backward_speed_units_per_second: float,
strafe_speed_units_per_second: float,
flight_vertical_speed_units_per_second: float,
acceleration_units_per_second_squared: float,
sandbox_sprint_multiplier: float
) -> void:
_run_speed_units_per_second = run_speed_units_per_second
_backward_speed_units_per_second = backward_speed_units_per_second
_strafe_speed_units_per_second = strafe_speed_units_per_second
_flight_vertical_speed_units_per_second = flight_vertical_speed_units_per_second
_acceleration_units_per_second_squared = acceleration_units_per_second_squared
_sandbox_sprint_multiplier = sandbox_sprint_multiplier
## Toggles sandbox free flight and clears velocity exactly like the pre-M02 controller.
## Returns the new flight state.
func toggle_sandbox_flight() -> bool:
_is_flight_enabled = not _is_flight_enabled
_godot_world_velocity_units_per_second = Vector3.ZERO
return _is_flight_enabled
## Advances velocity by one physics step and returns local-world displacement.
## `godot_world_movement_basis` is the player basis on ground and camera-pivot basis in flight.
## Negative delta values are treated as zero and do not mutate velocity.
func advance(
move_intent: MoveIntent,
godot_world_movement_basis: Basis,
delta_seconds: float
) -> Vector3:
var safe_delta_seconds := maxf(delta_seconds, 0.0)
if is_zero_approx(safe_delta_seconds):
return Vector3.ZERO
var target_velocity := _calculate_target_velocity(move_intent, godot_world_movement_basis)
_godot_world_velocity_units_per_second = _godot_world_velocity_units_per_second.move_toward(
target_velocity,
_acceleration_units_per_second_squared * safe_delta_seconds
)
return _godot_world_velocity_units_per_second * safe_delta_seconds
func _calculate_target_velocity(move_intent: MoveIntent, godot_world_movement_basis: Basis) -> Vector3:
var forward_direction := -godot_world_movement_basis.z
var right_direction := godot_world_movement_basis.x
if not _is_flight_enabled:
forward_direction.y = 0.0
right_direction.y = 0.0
forward_direction = forward_direction.normalized()
right_direction = right_direction.normalized()
var forward_speed := (
_run_speed_units_per_second
if move_intent.forward_axis > 0.0
else _backward_speed_units_per_second
)
var speed_multiplier := _sandbox_sprint_multiplier if move_intent.is_sprint_requested else 1.0
var target_velocity := (
forward_direction * move_intent.forward_axis * forward_speed
+ right_direction * move_intent.strafe_axis * _strafe_speed_units_per_second
) * speed_multiplier
if _is_flight_enabled:
target_velocity.y += (
move_intent.vertical_axis
* _flight_vertical_speed_units_per_second
* speed_multiplier
)
return target_velocity
@@ -0,0 +1 @@
uid://dxlx5kloh7p5c
+166
View File
@@ -0,0 +1,166 @@
class_name AdtTerrainQuery
extends TerrainQuery
## TerrainQuery adapter for existing native ADT dictionaries.
## Parsed tiles, including unavailable results, are cached for this query's
## lifetime. The adapter is main-thread owned and creates no scene resources.
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const CHUNK_SIZE_UNITS := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const OUTER_GRID_UNIT_SIZE := CHUNK_SIZE_UNITS / 8.0
var _extracted_directory: String
var _map_directory_name: String
var _adt_tile_loader_override: Callable
var _adt_tile_cache: Dictionary = {}
var _failure_code_by_tile_key: Dictionary = {}
## Creates an ADT query for one map. `adt_tile_loader_override` is an optional
## test/data-source seam receiving the absolute ADT path and returning a Dictionary.
func _init(
extracted_directory: String,
map_directory_name: String,
adt_tile_loader_override: Callable = Callable()
) -> void:
_extracted_directory = extracted_directory
_map_directory_name = map_directory_name
_adt_tile_loader_override = adt_tile_loader_override
## Samples the existing 9x9 outer-height grid with bilinear interpolation.
func sample_ground_height(godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(godot_world_position)
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(godot_world_position)
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(
tile_coordinate,
tile_local_position
)
var tile_key := _tile_key(tile_coordinate.tile_x, tile_coordinate.tile_y)
var adt_tile_data := _load_adt_tile(tile_coordinate.tile_x, tile_coordinate.tile_y)
if adt_tile_data.is_empty():
return TerrainGroundSample.unavailable(
_failure_code_by_tile_key.get(tile_key, &"adt_tile_unavailable")
)
var tile_origin := _find_tile_origin(adt_tile_data)
var chunk := _find_chunk(
adt_tile_data,
clampi(chunk_coordinate.chunk_x, 0, 15),
clampi(chunk_coordinate.chunk_y, 0, 15),
tile_origin
)
if chunk.is_empty():
return TerrainGroundSample.unavailable(&"adt_chunk_missing")
var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
if heights.size() < 145:
return TerrainGroundSample.unavailable(&"adt_chunk_heights_invalid")
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var chunk_local_x := godot_world_position.x_units - chunk_origin.x
var chunk_local_z := godot_world_position.z_units - chunk_origin.z
var grid_x := clampf(chunk_local_x / OUTER_GRID_UNIT_SIZE, 0.0, 8.0)
var grid_z := clampf(chunk_local_z / OUTER_GRID_UNIT_SIZE, 0.0, 8.0)
var x0 := clampi(int(floor(grid_x)), 0, 8)
var z0 := clampi(int(floor(grid_z)), 0, 8)
var x1 := mini(x0 + 1, 8)
var z1 := mini(z0 + 1, 8)
var fraction_x := grid_x - float(x0)
var fraction_z := grid_z - float(z0)
var height_x0 := lerpf(
heights[_outer_height_index(z0, x0)],
heights[_outer_height_index(z0, x1)],
fraction_x
)
var height_x1 := lerpf(
heights[_outer_height_index(z1, x0)],
heights[_outer_height_index(z1, x1)],
fraction_x
)
return TerrainGroundSample.available(
chunk_origin.y + lerpf(height_x0, height_x1, fraction_z)
)
func _load_adt_tile(tile_x: int, tile_y: int) -> Dictionary:
var tile_key := _tile_key(tile_x, tile_y)
if _adt_tile_cache.has(tile_key):
return _adt_tile_cache[tile_key]
var resource_path := _extracted_directory.path_join(
"World/Maps/%s/%s_%d_%d.adt" % [_map_directory_name, _map_directory_name, tile_x, tile_y]
)
var absolute_path := ProjectSettings.globalize_path(resource_path)
var adt_tile_data: Dictionary = {}
var failure_code: StringName = &""
if _adt_tile_loader_override.is_valid():
var override_result = _adt_tile_loader_override.call(absolute_path)
if override_result is Dictionary:
adt_tile_data = override_result
else:
failure_code = &"adt_loader_result_invalid"
elif not ClassDB.class_exists("ADTLoader"):
failure_code = &"adt_loader_unavailable"
elif not FileAccess.file_exists(absolute_path):
failure_code = &"adt_source_missing"
else:
var loader = ClassDB.instantiate("ADTLoader")
if loader != null:
var load_result = loader.call("load_adt", absolute_path)
if load_result is Dictionary:
adt_tile_data = load_result
if adt_tile_data.is_empty():
failure_code = &"adt_load_failed"
if adt_tile_data.is_empty() and failure_code.is_empty():
failure_code = &"adt_tile_unavailable"
_adt_tile_cache[tile_key] = adt_tile_data
_failure_code_by_tile_key[tile_key] = failure_code
return adt_tile_data
func _find_chunk(
adt_tile_data: Dictionary,
chunk_x: int,
chunk_y: int,
tile_origin: Vector3
) -> Dictionary:
for chunk in adt_tile_data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var local_origin := chunk_origin - tile_origin
var candidate_chunk_x := clampi(int(round(local_origin.x / CHUNK_SIZE_UNITS)), 0, 15)
var candidate_chunk_y := clampi(int(round(local_origin.z / CHUNK_SIZE_UNITS)), 0, 15)
if candidate_chunk_x == chunk_x and candidate_chunk_y == chunk_y:
return chunk
return {}
func _find_tile_origin(adt_tile_data: Dictionary) -> Vector3:
var has_origin := false
var minimum_x := 0.0
var minimum_z := 0.0
for chunk in adt_tile_data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
if not has_origin:
minimum_x = chunk_origin.x
minimum_z = chunk_origin.z
has_origin = true
else:
minimum_x = minf(minimum_x, chunk_origin.x)
minimum_z = minf(minimum_z, chunk_origin.z)
return Vector3(minimum_x, 0.0, minimum_z) if has_origin else Vector3.ZERO
func _tile_key(tile_x: int, tile_y: int) -> String:
return "%d_%d" % [tile_x, tile_y]
func _outer_height_index(row: int, column: int) -> int:
return row * 17 + column
@@ -0,0 +1 @@
uid://cq7scvxqw6ffm
@@ -0,0 +1,49 @@
class_name TerrainGroundSample
extends RefCounted
## Immutable result of a terrain ground-height query in Godot world units.
## An unavailable sample carries a stable failure code instead of overloading a
## numeric height with `NAN` at the public boundary.
var is_available: bool:
get:
return _is_available
var height_units: float:
get:
return _height_units
var failure_code: StringName:
get:
return _failure_code
var _is_available: bool
var _height_units: float
var _failure_code: StringName
## Creates an available ground sample in Godot world units.
static func available(height_units_value: float) -> TerrainGroundSample:
return TerrainGroundSample.new(true, height_units_value, &"")
## Creates an unavailable sample with a stable diagnostic code.
static func unavailable(failure_code_value: StringName) -> TerrainGroundSample:
return TerrainGroundSample.new(false, 0.0, failure_code_value)
## Normalizes factory inputs so available samples are finite and unavailable
## samples always carry a non-empty failure code.
func _init(
is_available_value: bool,
height_units_value: float,
failure_code_value: StringName
) -> void:
_is_available = is_available_value and is_finite(height_units_value)
_height_units = height_units_value if _is_available else 0.0
if _is_available:
_failure_code = &""
elif not is_finite(height_units_value):
_failure_code = &"terrain_height_not_finite"
else:
_failure_code = failure_code_value if not failure_code_value.is_empty() else &"terrain_unavailable"
@@ -0,0 +1 @@
uid://boe5qkip6ffku
+12
View File
@@ -0,0 +1,12 @@
class_name TerrainQuery
extends RefCounted
## Replaceable scene-free boundary for gameplay terrain height sampling.
## Implementations receive a typed Godot world position and must return an
## explicit available/unavailable result without mutating the caller.
## Samples terrain ground height at a typed Godot world position.
## The base implementation reports `terrain_query_not_implemented`.
func sample_ground_height(_godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
return TerrainGroundSample.unavailable(&"terrain_query_not_implemented")
@@ -0,0 +1 @@
uid://d3f2hetuo342q
@@ -0,0 +1,95 @@
class_name CharacterAnimationPresenter
extends Node
## Selects and plays the current sandbox idle or moving animation.
## Animation import, movement simulation and model composition remain external.
const MOVING_ANIMATION_CANDIDATES: Array[String] = ["Run", "Walk"]
const STATIONARY_ANIMATION_CANDIDATES: Array[String] = ["Stand", "Idle"]
## Cross-fade time retained from the current sandbox controller.
@export var animation_blend_time_seconds: float = 0.15
var active_animation_name: String:
get:
return _active_animation_name
var has_animation_player: bool:
get:
return _animation_player != null and is_instance_valid(_animation_player)
var _animation_player: AnimationPlayer
var _active_animation_name := ""
## Finds the first AnimationPlayer below a newly composed character root.
## Binding null or a root without animations clears the current presentation.
func bind_character_root(character_root: Node) -> bool:
_animation_player = (
_find_animation_player(character_root)
if character_root != null and is_instance_valid(character_root)
else null
)
_active_animation_name = ""
if _animation_player == null:
return false
_prepare_animation_player(_animation_player)
present_locomotion(false)
return true
## Presents the current moving/stationary locomotion state.
## Returns true only when a different animation starts playing.
func present_locomotion(is_moving: bool) -> bool:
if _animation_player == null or not is_instance_valid(_animation_player):
_animation_player = null
_active_animation_name = ""
return false
var candidates := MOVING_ANIMATION_CANDIDATES if is_moving else STATIONARY_ANIMATION_CANDIDATES
var selected_animation := _choose_animation(candidates)
if selected_animation.is_empty() or selected_animation == _active_animation_name:
return false
_active_animation_name = selected_animation
_animation_player.play(selected_animation, animation_blend_time_seconds)
return true
func _prepare_animation_player(animation_player: AnimationPlayer) -> void:
animation_player.playback_default_blend_time = animation_blend_time_seconds
for animation_name in animation_player.get_animation_list():
var lowercase_name := String(animation_name).to_lower()
if (
lowercase_name == "stand"
or lowercase_name == "idle"
or lowercase_name == "run"
or lowercase_name == "walk"
or lowercase_name.contains("stand")
or lowercase_name.contains("run")
or lowercase_name.contains("walk")
):
var animation := animation_player.get_animation(animation_name)
if animation != null:
animation.loop_mode = Animation.LOOP_LINEAR
func _choose_animation(candidates: Array[String]) -> String:
for candidate in candidates:
if _animation_player.has_animation(candidate):
return candidate
var animation_names := _animation_player.get_animation_list()
for candidate in candidates:
var lowercase_candidate := candidate.to_lower()
for animation_name in animation_names:
if String(animation_name).to_lower().contains(lowercase_candidate):
return String(animation_name)
return ""
func _find_animation_player(root: Node) -> AnimationPlayer:
if root is AnimationPlayer:
return root
for child in root.get_children():
var animation_player := _find_animation_player(child)
if animation_player != null:
return animation_player
return null
@@ -0,0 +1 @@
uid://c2jnn0e3mmymc
@@ -0,0 +1,150 @@
class_name CharacterAppearancePresenter
extends Node3D
## Composes one sandbox character model, texture compositor and starter outfit.
## The presenter owns only nodes below the existing Visual scene boundary.
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd")
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd")
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd")
signal character_appearance_ready(character_root: Node3D)
## Packed character scene used by the current render sandbox.
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
## Character class used when resolving the current starter outfit.
@export var character_class_id: int = 1
## Uniform scale applied to the composed character root.
@export var character_scale: float = 1.0
## Model-space yaw correction retained from the current sandbox.
@export var character_yaw_offset_degrees: float = 90.0
## Root containing extracted DBC and item texture inputs.
@export var extracted_data_directory: String = "res://data/extracted"
var character_root: Node3D:
get:
return _character_root
var _character_root: Node3D
## Loads and composes the configured character model below this Visual node.
## A non-empty directory argument keeps the player's terrain/content root aligned.
## Returns false immediately when no model is configured or the resource is invalid.
func load_character_appearance(content_root_override: String = "") -> bool:
if not content_root_override.is_empty():
extracted_data_directory = content_root_override
_clear_character_root()
if character_model_path.is_empty():
return false
var character_scene := load(character_model_path) as PackedScene
if character_scene == null:
push_warning("CharacterAppearancePresenter: cannot load character model: %s" % character_model_path)
return false
if is_class("MeshInstance3D"):
set("mesh", null)
position = Vector3.ZERO
_character_root = Node3D.new()
_character_root.name = "CharacterModel"
_character_root.set_script(GEOSET_CONTROLLER_SCRIPT)
_character_root.scale = Vector3.ONE * maxf(character_scale, 0.001)
_character_root.rotation_degrees.y = character_yaw_offset_degrees
var character_instance := character_scene.instantiate()
character_instance.name = character_model_path.get_file().get_basename()
_character_root.add_child(character_instance)
var texture_compositor := Node.new()
texture_compositor.name = "CharacterTextureCompositor"
texture_compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
texture_compositor.set("textures_dir", _character_textures_directory(character_model_path))
texture_compositor.set("extracted_dir", extracted_data_directory)
_character_root.add_child(texture_compositor)
add_child(_character_root)
_complete_character_composition.call_deferred(_character_root, texture_compositor)
return true
func _complete_character_composition(expected_character_root: Node3D, texture_compositor: Node) -> void:
if expected_character_root != _character_root or not is_instance_valid(expected_character_root):
return
_fit_character_to_ground(expected_character_root)
_apply_character_starter_outfit(expected_character_root, texture_compositor)
character_appearance_ready.emit(expected_character_root)
func _clear_character_root() -> void:
if _character_root == null or not is_instance_valid(_character_root):
_character_root = null
return
remove_child(_character_root)
_character_root.queue_free()
_character_root = null
func _fit_character_to_ground(root: Node3D) -> void:
var character_bounds := _calculate_global_bounds(root)
if character_bounds.size == Vector3.ZERO:
return
var local_minimum_y := character_bounds.position.y - root.global_position.y
root.position.y -= local_minimum_y
func _apply_character_starter_outfit(root: Node3D, texture_compositor: Node) -> void:
if texture_compositor == null:
return
var outfit_resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
if not outfit_resolver.call("load_dbcs", extracted_data_directory):
return
var inferred_identity: Dictionary = outfit_resolver.call(
"infer_race_gender_from_model_path",
character_model_path
)
var race_name := String(inferred_identity.get("race", "Human"))
var gender_name := String(inferred_identity.get("gender", "Male"))
var starter_outfit: Dictionary = outfit_resolver.call(
"resolve_start_outfit",
race_name,
gender_name,
character_class_id
)
if starter_outfit.is_empty():
return
if texture_compositor.has_method("set_equipment_components"):
texture_compositor.call(
"set_equipment_components",
starter_outfit.get("textures", PackedStringArray())
)
if root.has_method("apply_outfit_defaults"):
root.call("apply_outfit_defaults", starter_outfit)
func _calculate_global_bounds(root: Node) -> AABB:
var combined_bounds := AABB()
var has_bounds := false
for mesh_instance in _mesh_instances_below(root):
if mesh_instance.mesh == null:
continue
var global_bounds := mesh_instance.global_transform * mesh_instance.mesh.get_aabb()
combined_bounds = global_bounds if not has_bounds else combined_bounds.merge(global_bounds)
has_bounds = true
return combined_bounds if has_bounds else AABB()
func _mesh_instances_below(root: Node) -> Array[MeshInstance3D]:
var mesh_instances: Array[MeshInstance3D] = []
_collect_mesh_instances(root, mesh_instances)
return mesh_instances
func _collect_mesh_instances(node: Node, mesh_instances: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
mesh_instances.append(node)
for child in node.get_children():
_collect_mesh_instances(child, mesh_instances)
func _character_textures_directory(model_path: String) -> String:
return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
@@ -0,0 +1 @@
uid://bhqbo1ftrt3a3
@@ -0,0 +1,15 @@
class_name CameraCollisionPolicy
extends RefCounted
## Replaceable third-person camera-distance policy.
## Implementations may inspect the explicit pivot/world boundary but must return
## a distance in Godot units without mutating the rig or camera nodes.
## Resolves the camera distance for the current pivot and requested zoom.
## The base policy preserves the request unchanged.
func resolve_camera_distance(
_camera_pivot: Node3D,
requested_distance_units: float
) -> float:
return requested_distance_units
@@ -0,0 +1 @@
uid://bgd41bb3hvbl1
@@ -0,0 +1,12 @@
class_name NoCameraCollisionPolicy
extends CameraCollisionPolicy
## Identity collision policy preserving the pre-M02 sandbox camera behavior.
## Returns the requested distance without physics queries or allocations.
func resolve_camera_distance(
_camera_pivot: Node3D,
requested_distance_units: float
) -> float:
return requested_distance_units
@@ -0,0 +1 @@
uid://csts45n5rnxro
@@ -0,0 +1,169 @@
class_name ThirdPersonCameraRig
extends Node3D
## Third-person orbit/zoom state and Camera3D presentation adapter.
## The rig owns camera input state and local transforms. Character movement,
## terrain, rendering and collision-policy decisions remain external boundaries.
const NO_CAMERA_COLLISION_POLICY_SCRIPT := preload("res://src/scenes/player/no_camera_collision_policy.gd")
## Relative Camera3D child used by this rig.
@export var camera_path: NodePath = NodePath("Camera3D")
## Mouse radians applied per input pixel.
@export var mouse_sensitivity_radians_per_pixel: float = 0.003
## Minimum orbit pitch in radians.
@export var minimum_pitch_radians: float = deg_to_rad(-65.0)
## Maximum orbit pitch in radians.
@export var maximum_pitch_radians: float = deg_to_rad(35.0)
## Pivot height above the character origin in Godot units.
@export var pivot_height_units: float = 1.7
## Initial requested camera distance in Godot units.
@export var initial_distance_units: float = 8.0
## Minimum user-requested zoom distance in Godot units.
@export var minimum_distance_units: float = 2.0
## Maximum user-requested zoom distance in Godot units.
@export var maximum_distance_units: float = 18.0
## Distance change per zoom action in Godot units.
@export var zoom_step_units: float = 1.0
## Camera far plane retained from the renderer sandbox baseline.
@export var camera_far_plane_units: float = 50000.0
var yaw_radians: float:
get:
return _yaw_radians
var pitch_radians: float:
get:
return _pitch_radians
var requested_distance_units: float:
get:
return _requested_distance_units
var resolved_distance_units: float:
get:
return _resolved_distance_units
var is_mouse_captured: bool:
get:
return _is_mouse_captured
var _character_body: Node3D
var _camera: Camera3D
var _collision_policy: CameraCollisionPolicy
var _yaw_radians := 0.0
var _pitch_radians := deg_to_rad(-18.0)
var _requested_distance_units := 8.0
var _resolved_distance_units := 8.0
var _is_mouse_captured := false
func _ready() -> void:
_camera = get_node_or_null(camera_path) as Camera3D
if _camera == null:
push_error("ThirdPersonCameraRig: Camera3D not found at %s" % camera_path)
if _collision_policy == null:
_collision_policy = NO_CAMERA_COLLISION_POLICY_SCRIPT.new()
_pitch_radians = clampf(_pitch_radians, minimum_pitch_radians, maximum_pitch_radians)
_requested_distance_units = clampf(
initial_distance_units,
minimum_distance_units,
maximum_distance_units
)
if _camera != null:
_camera.current = true
_camera.far = camera_far_plane_units
refresh_camera_transform()
func _physics_process(_delta_seconds: float) -> void:
refresh_camera_transform()
func _exit_tree() -> void:
if _is_mouse_captured:
_is_mouse_captured = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
## Attaches the character transform rotated by orbit input and captures its initial yaw.
func initialize_for_character(character_body: Node3D) -> void:
_character_body = character_body
if _character_body != null:
_yaw_radians = _character_body.rotation.y
refresh_camera_transform()
## Replaces camera-distance collision behavior and immediately refreshes the camera.
## Passing null is rejected and preserves the current policy.
func set_collision_policy(collision_policy: CameraCollisionPolicy) -> void:
if collision_policy == null:
push_error("ThirdPersonCameraRig: collision policy cannot be null")
return
_collision_policy = collision_policy
refresh_camera_transform()
## Applies remappable camera actions and orbit mouse motion.
## Returns true when the event changed or consumed camera state.
func handle_camera_input(event: InputEvent) -> bool:
if event.is_action_pressed(PlayerInputActions.CAMERA_ROTATE):
_is_mouse_captured = true
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
return true
if event.is_action_released(PlayerInputActions.CAMERA_ROTATE):
_is_mouse_captured = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
return true
if event.is_action_pressed(PlayerInputActions.CAMERA_ZOOM_IN):
_requested_distance_units = clampf(
_requested_distance_units - zoom_step_units,
minimum_distance_units,
maximum_distance_units
)
refresh_camera_transform()
return true
if event.is_action_pressed(PlayerInputActions.CAMERA_ZOOM_OUT):
_requested_distance_units = clampf(
_requested_distance_units + zoom_step_units,
minimum_distance_units,
maximum_distance_units
)
refresh_camera_transform()
return true
if event.is_action_pressed(PlayerInputActions.RELEASE_CURSOR):
_is_mouse_captured = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
return true
if _is_mouse_captured and event is InputEventMouseMotion:
_yaw_radians -= event.relative.x * mouse_sensitivity_radians_per_pixel
_pitch_radians = clampf(
_pitch_radians - event.relative.y * mouse_sensitivity_radians_per_pixel,
minimum_pitch_radians,
maximum_pitch_radians
)
if _character_body != null:
_character_body.rotation.y = _yaw_radians
refresh_camera_transform()
return true
return false
## Returns the pivot's Godot-world basis for camera-relative sandbox flight.
func godot_world_flight_movement_basis() -> Basis:
return global_basis
## Applies pivot and Camera3D local transforms using the active collision policy.
func refresh_camera_transform() -> void:
position = Vector3(0.0, pivot_height_units, 0.0)
rotation.x = _pitch_radians
if _camera == null:
return
var policy_distance := _collision_policy.resolve_camera_distance(
self,
_requested_distance_units
) if _collision_policy != null else _requested_distance_units
_resolved_distance_units = clampf(policy_distance, 0.0, _requested_distance_units)
_camera.position = Vector3(0.0, 0.0, _resolved_distance_units)
_camera.rotation = Vector3.ZERO
@@ -0,0 +1 @@
uid://cq60al4uw0mgo
+87 -377
View File
@@ -1,12 +1,13 @@
extends CharacterBody3D extends CharacterBody3D
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd") const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd") const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd") const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const TILE_SIZE := 533.33333 const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
const CHUNK_SIZE := TILE_SIZE / 16.0 const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
const UNIT_SIZE := CHUNK_SIZE / 8.0 const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrain_query.gd")
@export var extracted_dir: String = "res://data/extracted" @export var extracted_dir: String = "res://data/extracted"
@export var map_name: String = "Azeroth" @export var map_name: String = "Azeroth"
@@ -20,397 +21,106 @@ const UNIT_SIZE := CHUNK_SIZE / 8.0
@export var sprint_multiplier: float = 6.0 @export var sprint_multiplier: float = 6.0
@export var flight_vertical_speed: float = 7.0 @export var flight_vertical_speed: float = 7.0
@export var acceleration: float = 28.0 @export var acceleration: float = 28.0
@export var mouse_sensitivity: float = 0.003
@export var camera_pitch_min: float = deg_to_rad(-65.0)
@export var camera_pitch_max: float = deg_to_rad(35.0)
@export var camera_height: float = 1.7
@export var camera_distance: float = 8.0
@export var camera_min_distance: float = 2.0
@export var camera_max_distance: float = 18.0
@export var camera_zoom_step: float = 1.0
@export var ground_offset: float = 0.05 @export var ground_offset: float = 0.05
@export var ground_snap_speed: float = 24.0 @export var ground_snap_speed: float = 24.0
@export var camera_pivot_path: NodePath = NodePath("CameraPivot") @export var camera_pivot_path: NodePath = NodePath("CameraPivot")
@export var camera_path: NodePath = NodePath("CameraPivot/Camera3D")
@export var visual_path: NodePath = NodePath("Visual") @export var visual_path: NodePath = NodePath("Visual")
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb" @export var animation_presenter_path: NodePath = NodePath("Visual/AnimationPresenter")
@export var character_class_id: int = 1
@export var character_scale: float = 1.0
@export var character_yaw_offset_degrees: float = 90.0
@export var animation_blend_time: float = 0.15
var _camera_pivot: Node3D var _camera_rig: ThirdPersonCameraRig
var _camera: Camera3D var _appearance_presenter: CharacterAppearancePresenter
var _visual: Node3D var _animation_presenter: CharacterAnimationPresenter
var _character_root: Node3D var _player_input_source: PlayerInputSource
var _animation_player: AnimationPlayer var _local_movement_controller: LocalPlayerMovementController
var _active_animation := "" var _terrain_query: TerrainQuery
var _captured := false
var _yaw := 0.0
var _pitch := deg_to_rad(-18.0) ## Replaces the ground-height backend without changing player movement or presentation.
var _horizontal_velocity := Vector3.ZERO ## Passing null is a composition error and leaves the current query unchanged.
var _flight_enabled := false func set_terrain_query(terrain_query: TerrainQuery) -> void:
var _adt_cache: Dictionary = {} if terrain_query == null:
push_error("ThirdPersonWowController: terrain query cannot be null")
return
_terrain_query = terrain_query
func _ready() -> void: func _ready() -> void:
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D _player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new()
_camera = get_node_or_null(camera_path) as Camera3D if _terrain_query == null:
_visual = get_node_or_null(visual_path) as Node3D _terrain_query = ADT_TERRAIN_QUERY_SCRIPT.new(extracted_dir, map_name)
if _camera: _local_movement_controller = LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT.new(
_camera.current = true run_speed,
_camera.far = 50000.0 backward_speed,
strafe_speed,
flight_vertical_speed,
acceleration,
sprint_multiplier
)
_camera_rig = get_node_or_null(camera_pivot_path) as ThirdPersonCameraRig
_appearance_presenter = get_node_or_null(visual_path) as CharacterAppearancePresenter
_animation_presenter = get_node_or_null(animation_presenter_path) as CharacterAnimationPresenter
if _camera_rig == null:
push_error("ThirdPersonWowController: ThirdPersonCameraRig not found at %s" % camera_pivot_path)
else:
_camera_rig.initialize_for_character(self)
if _appearance_presenter == null:
push_error("ThirdPersonWowController: CharacterAppearancePresenter not found at %s" % visual_path)
else:
_appearance_presenter.character_appearance_ready.connect(_on_character_appearance_ready)
_appearance_presenter.load_character_appearance(extracted_dir)
if _animation_presenter == null:
push_error("ThirdPersonWowController: CharacterAnimationPresenter not found at %s" % animation_presenter_path)
if spawn_at_tile_center: if spawn_at_tile_center:
global_position.x = (float(spawn_tile_x) + 0.5) * TILE_SIZE var spawn_tile = ADT_TILE_COORDINATE_SCRIPT.new(spawn_tile_x, spawn_tile_y)
global_position.z = (float(spawn_tile_y) + 0.5) * TILE_SIZE var half_tile_size := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS * 0.5
_load_character_visual() var spawn_local = ADT_TILE_LOCAL_POSITION_SCRIPT.new(half_tile_size, global_position.y, half_tile_size)
var ground := _sample_ground_height(global_position) var spawn_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(spawn_tile, spawn_local)
if is_finite(ground): global_position = Vector3(spawn_position.x_units, spawn_position.y_units, spawn_position.z_units)
global_position.y = ground + ground_offset var spawn_ground_sample := _sample_ground_height(global_position)
_yaw = rotation.y if spawn_ground_sample.is_available:
_apply_camera_transform() global_position.y = spawn_ground_sample.height_units + ground_offset
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT: if _camera_rig != null:
_captured = event.pressed _camera_rig.handle_camera_input(event)
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP: _local_movement_controller.toggle_sandbox_flight()
camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_captured = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_SPACE:
_flight_enabled = not _flight_enabled
_horizontal_velocity = Vector3.ZERO
if _captured and event is InputEventMouseMotion:
_yaw -= event.relative.x * mouse_sensitivity
_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, camera_pitch_min, camera_pitch_max)
rotation.y = _yaw
_apply_camera_transform()
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
var input_dir := _get_input_dir() var move_intent := _player_input_source.sample_move_intent()
var target_velocity := Vector3.ZERO var godot_world_movement_basis := global_basis
if input_dir.length_squared() > 0.0: if _local_movement_controller.is_flight_enabled and _camera_rig != null:
target_velocity = _movement_vector(input_dir) godot_world_movement_basis = _camera_rig.godot_world_flight_movement_basis()
if _flight_enabled: global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
target_velocity.y += _flight_vertical_velocity()
_horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta) if not _local_movement_controller.is_flight_enabled:
global_position += _horizontal_velocity * delta var ground_sample := _sample_ground_height(global_position)
if ground_sample.is_available:
if not _flight_enabled: var target_y := ground_sample.height_units + ground_offset
var ground := _sample_ground_height(global_position)
if is_finite(ground):
var target_y := ground + ground_offset
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0)) global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
var horizontal_motion := Vector2(_horizontal_velocity.x, _horizontal_velocity.z) var movement_velocity := _local_movement_controller.godot_world_velocity_units_per_second
if _visual and horizontal_motion.length_squared() > 0.01: var horizontal_motion := Vector2(movement_velocity.x, movement_velocity.z)
_visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z) if _appearance_presenter != null and horizontal_motion.length_squared() > 0.01:
_appearance_presenter.global_rotation.y = atan2(-movement_velocity.x, -movement_velocity.z)
_update_character_animation(horizontal_motion.length_squared() > 0.04) if _animation_presenter != null:
_animation_presenter.present_locomotion(horizontal_motion.length_squared() > 0.04)
_apply_camera_transform()
func _get_input_dir() -> Vector2: func _on_character_appearance_ready(character_root: Node3D) -> void:
var dir := Vector2.ZERO if _animation_presenter != null:
if Input.is_key_pressed(KEY_W): _animation_presenter.bind_character_root(character_root)
dir.y -= 1.0
if Input.is_key_pressed(KEY_S):
dir.y += 1.0
if Input.is_key_pressed(KEY_A):
dir.x -= 1.0
if Input.is_key_pressed(KEY_D):
dir.x += 1.0
return dir.normalized()
func _movement_vector(input_dir: Vector2) -> Vector3: func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
var forward := -global_basis.z return _terrain_query.sample_ground_height(
var right := global_basis.x GODOT_WORLD_POSITION_SCRIPT.new(
if _flight_enabled and _camera_pivot: godot_world_position.x,
forward = -_camera_pivot.global_basis.z godot_world_position.y,
right = _camera_pivot.global_basis.x godot_world_position.z
else: )
forward.y = 0.0 )
right.y = 0.0
forward = forward.normalized()
right = right.normalized()
var speed_z := run_speed if input_dir.y < 0.0 else backward_speed
var speed_x := strafe_speed
var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0
return (forward * -input_dir.y * speed_z + right * input_dir.x * speed_x) * sprint
func _flight_vertical_velocity() -> float:
var dir := 0.0
if Input.is_key_pressed(KEY_E):
dir += 1.0
if Input.is_key_pressed(KEY_Q):
dir -= 1.0
var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0
return dir * flight_vertical_speed * sprint
func _load_character_visual() -> void:
if character_model_path.is_empty() or _visual == null:
return
if _visual is MeshInstance3D:
(_visual as MeshInstance3D).mesh = null
_visual.position = Vector3.ZERO
if _character_root and is_instance_valid(_character_root):
_character_root.queue_free()
_character_root = null
_animation_player = null
_active_animation = ""
var scene: PackedScene = load(character_model_path)
if scene == null:
push_warning("ThirdPersonWowController: cannot load character model: %s" % character_model_path)
return
_character_root = Node3D.new()
_character_root.name = "CharacterModel"
_character_root.set_script(GEOSET_CONTROLLER_SCRIPT)
_character_root.scale = Vector3.ONE * maxf(character_scale, 0.001)
_character_root.rotation_degrees.y = character_yaw_offset_degrees
var instance := scene.instantiate()
instance.name = character_model_path.get_file().get_basename()
_character_root.add_child(instance)
var compositor := Node.new()
compositor.name = "CharacterTextureCompositor"
compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
compositor.set("textures_dir", _character_textures_dir(character_model_path))
compositor.set("extracted_dir", extracted_dir)
_character_root.add_child(compositor)
_visual.add_child(_character_root)
await get_tree().process_frame
_fit_character_to_ground()
_apply_character_starter_outfit(compositor)
_animation_player = _find_animation_player(_character_root)
if _animation_player:
_prepare_animation_player(_animation_player)
_update_character_animation(false)
func _fit_character_to_ground() -> void:
if _character_root == null:
return
var aabb := _calculate_aabb(_character_root)
if aabb.size == Vector3.ZERO:
return
var local_min_y := aabb.position.y - _character_root.global_position.y
_character_root.position.y -= local_min_y
func _apply_character_starter_outfit(compositor: Node) -> void:
if _character_root == null or compositor == null:
return
var resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
if not resolver.call("load_dbcs", extracted_dir):
return
var inferred: Dictionary = resolver.call("infer_race_gender_from_model_path", character_model_path)
var race := String(inferred.get("race", "Human"))
var gender := String(inferred.get("gender", "Male"))
var outfit: Dictionary = resolver.call("resolve_start_outfit", race, gender, character_class_id)
if outfit.is_empty():
return
if compositor.has_method("set_equipment_components"):
compositor.call("set_equipment_components", outfit.get("textures", PackedStringArray()))
if _character_root.has_method("apply_outfit_defaults"):
_character_root.call("apply_outfit_defaults", outfit)
func _update_character_animation(is_moving: bool) -> void:
if _animation_player == null:
return
var wanted := _choose_animation(["Run", "Walk"]) if is_moving else _choose_animation(["Stand", "Idle"])
if wanted.is_empty() or wanted == _active_animation:
return
_active_animation = wanted
_animation_player.play(wanted, animation_blend_time)
func _prepare_animation_player(player: AnimationPlayer) -> void:
player.playback_default_blend_time = animation_blend_time
for animation_name in player.get_animation_list():
var lower := String(animation_name).to_lower()
if lower == "stand" or lower == "idle" or lower == "run" or lower == "walk" or lower.contains("stand") or lower.contains("run") or lower.contains("walk"):
var animation := player.get_animation(animation_name)
if animation:
animation.loop_mode = Animation.LOOP_LINEAR
func _choose_animation(candidates: Array[String]) -> String:
if _animation_player == null:
return ""
for candidate in candidates:
if _animation_player.has_animation(candidate):
return candidate
var list := _animation_player.get_animation_list()
for candidate in candidates:
var lower := candidate.to_lower()
for animation_name in list:
if String(animation_name).to_lower().contains(lower):
return String(animation_name)
return ""
func _find_animation_player(root: Node) -> AnimationPlayer:
if root is AnimationPlayer:
return root
for child in root.get_children():
var found := _find_animation_player(child)
if found:
return found
return null
func _calculate_aabb(root: Node) -> AABB:
var result := AABB()
var has_aabb := false
for mesh_inst in _iter_mesh_instances(root):
if mesh_inst.mesh == null:
continue
var local_aabb := mesh_inst.mesh.get_aabb()
var global_aabb := mesh_inst.global_transform * local_aabb
if not has_aabb:
result = global_aabb
has_aabb = true
else:
result = result.merge(global_aabb)
return result if has_aabb else AABB()
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = []
_collect_mesh_instances(root, result)
return result
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node)
for child in node.get_children():
_collect_mesh_instances(child, result)
func _character_textures_dir(model_path: String) -> String:
return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
func _apply_camera_transform() -> void:
if _camera_pivot:
_camera_pivot.position = Vector3(0.0, camera_height, 0.0)
_camera_pivot.rotation.x = _pitch
if _camera:
_camera.position = Vector3(0.0, 0.0, camera_distance)
_camera.rotation = Vector3.ZERO
func _sample_ground_height(world_pos: Vector3) -> float:
var tx := int(floor(world_pos.x / TILE_SIZE))
var ty := int(floor(world_pos.z / TILE_SIZE))
var data := _load_adt(tx, ty)
if data.is_empty():
return NAN
var tile_origin := _get_tile_origin(data)
var local := world_pos - tile_origin
var chunk_x := clampi(int(floor(local.x / CHUNK_SIZE)), 0, 15)
var chunk_y := clampi(int(floor(local.z / CHUNK_SIZE)), 0, 15)
var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin)
if chunk.is_empty():
return NAN
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var chunk_local := world_pos - origin
var gx := clampf(chunk_local.x / UNIT_SIZE, 0.0, 8.0)
var gz := clampf(chunk_local.z / UNIT_SIZE, 0.0, 8.0)
var x0 := clampi(int(floor(gx)), 0, 8)
var z0 := clampi(int(floor(gz)), 0, 8)
var x1 := mini(x0 + 1, 8)
var z1 := mini(z0 + 1, 8)
var fx := gx - float(x0)
var fz := gz - float(z0)
var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
if heights.size() < 145:
return NAN
var h00 := heights[_outer(z0, x0)]
var h10 := heights[_outer(z0, x1)]
var h01 := heights[_outer(z1, x0)]
var h11 := heights[_outer(z1, x1)]
var hx0 := lerpf(h00, h10, fx)
var hx1 := lerpf(h01, h11, fx)
return origin.y + lerpf(hx0, hx1, fz)
func _load_adt(tx: int, ty: int) -> Dictionary:
var key := "%d_%d" % [tx, ty]
if _adt_cache.has(key):
return _adt_cache[key]
if not ClassDB.class_exists("ADTLoader"):
_adt_cache[key] = {}
return {}
var res_path := extracted_dir.path_join("World/Maps/%s/%s_%d_%d.adt" % [map_name, map_name, tx, ty])
var abs_path := ProjectSettings.globalize_path(res_path)
if not FileAccess.file_exists(abs_path):
_adt_cache[key] = {}
return {}
var loader = ClassDB.instantiate("ADTLoader")
var data: Dictionary = loader.call("load_adt", abs_path) if loader else {}
_adt_cache[key] = data
return data
func _find_chunk(data: Dictionary, chunk_x: int, chunk_y: int, tile_origin: Vector3) -> Dictionary:
for chunk in data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var local := origin - tile_origin
var cx := clampi(int(round(local.x / CHUNK_SIZE)), 0, 15)
var cy := clampi(int(round(local.z / CHUNK_SIZE)), 0, 15)
if cx == chunk_x and cy == chunk_y:
return chunk
return {}
func _get_tile_origin(data: Dictionary) -> Vector3:
var found := false
var min_x := 0.0
var min_z := 0.0
for chunk in data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
if not found:
min_x = origin.x
min_z = origin.z
found = true
else:
min_x = minf(min_x, origin.x)
min_z = minf(min_z, origin.z)
return Vector3(min_x, 0.0, min_z) if found else Vector3.ZERO
func _outer(row: int, col: int) -> int:
return row * 17 + col
+15 -13
View File
@@ -2,9 +2,9 @@
extends Node extends Node
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const TILE_SIZE := 533.33333
const WOW_WORLD_CENTER := 17066.666
const LIGHT_COORD_SCALE := 36.0 const LIGHT_COORD_SCALE := 36.0
const HALF_MINUTES_PER_DAY := 2880 const HALF_MINUTES_PER_DAY := 2880
@@ -701,28 +701,30 @@ func _get_target_wow_position() -> Vector3:
var world_pos := Vector3.ZERO var world_pos := Vector3.ZERO
if _target: if _target:
world_pos = _target.global_position world_pos = _target.global_position
return Vector3( var canonical_position = COORDINATE_MAPPER_SCRIPT.godot_to_canonical(_typed_godot_position(world_pos))
WOW_WORLD_CENTER - world_pos.z, return Vector3(canonical_position.x_yards, canonical_position.y_yards, canonical_position.z_yards)
WOW_WORLD_CENTER - world_pos.x,
world_pos.y)
func _get_target_area_id() -> int: func _get_target_area_id() -> int:
if not _target or map_name.is_empty(): if not _target or map_name.is_empty():
return 0 return 0
var world_pos := _target.global_position var world_pos := _target.global_position
var tile_x := int(floor(world_pos.x / TILE_SIZE)) var typed_world_position = _typed_godot_position(world_pos)
var tile_y := int(floor(world_pos.z / TILE_SIZE)) var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
var local_x := world_pos.x - float(tile_x) * TILE_SIZE var local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
var local_y := world_pos.z - float(tile_y) * TILE_SIZE var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, local_position)
var chunk_x := clampi(int(floor(local_x / (TILE_SIZE / 16.0))), 0, 15) var areas := _load_adt_area_grid(tile_coordinate.tile_x, tile_coordinate.tile_y)
var chunk_y := clampi(int(floor(local_y / (TILE_SIZE / 16.0))), 0, 15)
var areas := _load_adt_area_grid(tile_x, tile_y)
if areas.is_empty(): if areas.is_empty():
return 0 return 0
var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15)
var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15)
return int(areas[chunk_y * 16 + chunk_x]) return int(areas[chunk_y * 16 + chunk_x])
func _typed_godot_position(world_position: Vector3):
return GODOT_WORLD_POSITION_SCRIPT.new(world_position.x, world_position.y, world_position.z)
func _load_adt_area_grid(tile_x: int, tile_y: int) -> PackedInt32Array: func _load_adt_area_grid(tile_x: int, tile_y: int) -> PackedInt32Array:
var key := "%d_%d" % [tile_x, tile_y] var key := "%d_%d" % [tile_x, tile_y]
if _adt_area_cache.has(key): if _adt_area_cache.has(key):
@@ -3,6 +3,9 @@
[ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_stream"] [ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_stream"]
[ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"] [ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"]
[ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_sky"] [ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_sky"]
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] [sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45 radius = 0.45
@@ -53,6 +56,7 @@ fog_depth_end = 5200.0
[node name="StreamingWorld" type="Node3D" unique_id=1063159974] [node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_stream") script = ExtResource("1_stream")
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D") camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
quality_preset = "High" quality_preset = "High"
update_interval = 0.1 update_interval = 0.1
@@ -103,9 +107,14 @@ shape = SubResource("PlayerCapsuleShape_1")
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492] [node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
mesh = SubResource("PlayerCapsuleMesh_1") mesh = SubResource("PlayerCapsuleMesh_1")
script = ExtResource("5_appearance")
[node name="AnimationPresenter" type="Node" parent="ThirdPersonPlayer/Visual"]
script = ExtResource("6_animation")
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249] [node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
script = ExtResource("4_camera_rig")
[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971] [node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
@@ -3,6 +3,9 @@
[ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_sisqv"] [ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_sisqv"]
[ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_puy8r"] [ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_puy8r"]
[ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_43ha7"] [ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_43ha7"]
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] [sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45 radius = 0.45
@@ -54,6 +57,7 @@ fog_depth_end = 5200.0
[node name="StreamingWorld" type="Node3D" unique_id=1063159974] [node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_sisqv") script = ExtResource("1_sisqv")
map_name = "Kalimdor" map_name = "Kalimdor"
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D") camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
quality_preset = "High" quality_preset = "High"
update_interval = 0.1 update_interval = 0.1
@@ -104,9 +108,14 @@ shape = SubResource("PlayerCapsuleShape_1")
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492] [node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
mesh = SubResource("PlayerCapsuleMesh_1") mesh = SubResource("PlayerCapsuleMesh_1")
script = ExtResource("5_appearance")
[node name="AnimationPresenter" type="Node" parent="ThirdPersonPlayer/Visual"]
script = ExtResource("6_animation")
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249] [node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
script = ExtResource("4_camera_rig")
[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971] [node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
+101 -62
View File
@@ -1,5 +1,5 @@
@tool @tool
## Streams Azeroth terrain around the active camera with chunk-based LODs. ## Streams Azeroth terrain around an explicit [StreamingFocus] with chunk-based LODs.
extends Node3D extends Node3D
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd") const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
@@ -12,14 +12,19 @@ const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_buil
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd") const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd")
const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd")
const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5 const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
const M2_MATERIAL_REFRESH_VERSION := 2 const M2_MATERIAL_REFRESH_VERSION := 2
const WMO_MATERIAL_REFRESH_VERSION := 10 const WMO_MATERIAL_REFRESH_VERSION := 10
const TILE_SIZE := 533.33333 const TILE_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
const CHUNK_SIZE := TILE_SIZE / 16.0 const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const ADT_CLIFFROCK_WORLD_YAW_OFFSET := PI const ADT_CLIFFROCK_WORLD_YAW_OFFSET := PI
const ADT_WATERFALL_WORLD_YAW_OFFSET := PI * 0.5 const ADT_WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
const QUALITY_CUSTOM := "Custom" const QUALITY_CUSTOM := "Custom"
@@ -29,6 +34,9 @@ const QUALITY_HIGH := "High"
@export var extracted_dir: String = "res://data/extracted" @export var extracted_dir: String = "res://data/extracted"
@export var map_name: String = "Azeroth" @export var map_name: String = "Azeroth"
## Optional scene adapter source. Any Node3D may provide the runtime focus.
@export var streaming_focus_source_path: NodePath
## Camera used only by the optional automatic overview positioning feature.
@export var camera_path: NodePath @export var camera_path: NodePath
@export_enum("Custom", "Performance", "Balanced", "High") var quality_preset: String = QUALITY_CUSTOM @export_enum("Custom", "Performance", "Balanced", "High") var quality_preset: String = QUALITY_CUSTOM
@@ -213,6 +221,8 @@ var _tile_min := Vector2i(63, 63)
var _tile_max := Vector2i(0, 0) var _tile_max := Vector2i(0, 0)
var _camera_initialized := false var _camera_initialized := false
var _editor_signature := "" var _editor_signature := ""
var _streaming_focus: StreamingFocus
var _missing_focus_source_reported := false
var _last_focus_pos := Vector3.ZERO var _last_focus_pos := Vector3.ZERO
var _dbg_chunks_created := 0 var _dbg_chunks_created := 0
var _dbg_chunks_null := 0 var _dbg_chunks_null := 0
@@ -293,6 +303,8 @@ func _ready() -> void:
_tick_editor_streaming() _tick_editor_streaming()
elif auto_position_camera: elif auto_position_camera:
_position_camera_over_world() _position_camera_over_world()
if not Engine.is_editor_hint():
_capture_streaming_focus_from_source()
set_process(true) set_process(true)
call_deferred("_refresh_streaming_targets_after_ready") call_deferred("_refresh_streaming_targets_after_ready")
@@ -364,7 +376,7 @@ func _process(delta: float) -> void:
if Engine.is_editor_hint(): if Engine.is_editor_hint():
_tick_editor_streaming() _tick_editor_streaming()
else: else:
_refresh_streaming_targets(false) refresh_streaming_focus(false)
did_refresh = true did_refresh = true
_profile_section(timings, "refresh", section_start, profile_enabled) _profile_section(timings, "refresh", section_start, profile_enabled)
_log_hitch_profile(profile_start, timings, did_refresh, profile_enabled) _log_hitch_profile(profile_start, timings, did_refresh, profile_enabled)
@@ -590,10 +602,11 @@ func _tick_editor_streaming() -> void:
var focus_world := _tile_center_to_world(editor_preview_center_x, editor_preview_center_y) var focus_world := _tile_center_to_world(editor_preview_center_x, editor_preview_center_y)
if editor_follow_view_camera: if editor_follow_view_camera:
var editor_camera := _get_stream_camera() var editor_camera := _get_editor_view_camera()
if editor_camera: if editor_camera:
focus_world += editor_camera.global_position focus_world += editor_camera.global_position
_refresh_editor_streaming_targets_at(focus_world, false) _set_streaming_focus_from_vector3(focus_world)
_refresh_editor_streaming_targets_at(_streaming_focus_to_vector3(), false)
func _scan_available_tiles() -> void: func _scan_available_tiles() -> void:
@@ -681,24 +694,28 @@ func _load_tiles_from_directory() -> void:
_tile_max.y = max(_tile_max.y, ty) _tile_max.y = max(_tile_max.y, ty)
func _refresh_streaming_targets(force: bool) -> void: ## Replaces the current streaming point without requiring a Node or Camera3D.
var camera := _get_stream_camera() func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
if camera == null: _streaming_focus = streaming_focus
return
## Samples the configured scene source, then refreshes streaming from the typed focus.
## Returns false when no valid focus is available; existing streamed content is retained.
func refresh_streaming_focus(force: bool = false) -> bool:
_capture_streaming_focus_from_source()
if _streaming_focus == null or _streaming_focus.world_position == null:
return false
_terrain_root.position = Vector3.ZERO _terrain_root.position = Vector3.ZERO
_refresh_streaming_targets_at(camera.global_position, force) _refresh_streaming_targets_at(_streaming_focus_to_vector3(), force)
return true
func _refresh_streaming_targets_after_ready() -> void: func _refresh_streaming_targets_after_ready() -> void:
var camera := _get_stream_camera()
if camera == null:
return
_terrain_root.position = Vector3.ZERO
_has_refresh_focus = false _has_refresh_focus = false
_refresh_streaming_targets_at(camera.global_position, false) refresh_streaming_focus(false)
## Core streaming update — works for both game (camera pos) and editor (preview center pos). ## Core streaming update for runtime and editor focus positions.
func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> void: func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> void:
if not force and not _should_refresh_focus(focus_pos): if not force and not _should_refresh_focus(focus_pos):
return return
@@ -772,10 +789,9 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar
var parts: PackedStringArray = key.split("_") var parts: PackedStringArray = key.split("_")
var tx: int = int(parts[0]) var tx: int = int(parts[0])
var ty: int = int(parts[1]) var ty: int = int(parts[1])
var tcx: float = (tx + 0.5) * TILE_SIZE var tile_center := _tile_center_to_world(tx, ty)
var tcz: float = (ty + 0.5) * TILE_SIZE var dx: float = tile_center.x - focus_pos.x
var dx: float = tcx - focus_pos.x var dz: float = tile_center.z - focus_pos.z
var dz: float = tcz - focus_pos.z
_tile_load_queue.append({ _tile_load_queue.append({
"key": key, "key": key,
"tx": tx, "tx": tx,
@@ -2895,8 +2911,10 @@ func _predictive_focus_tiles(focus_pos: Vector3) -> Array[Vector2i]:
if threshold <= 0.0: if threshold <= 0.0:
return result return result
var local_x := fposmod(focus_pos.x, TILE_SIZE) / TILE_SIZE var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
var local_z := fposmod(focus_pos.z, TILE_SIZE) / TILE_SIZE var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_focus_position)
var local_x: float = tile_local_position.east_yards / TILE_SIZE
var local_z: float = tile_local_position.south_yards / TILE_SIZE
var x_offsets: Array[int] = [0] var x_offsets: Array[int] = [0]
var z_offsets: Array[int] = [0] var z_offsets: Array[int] = [0]
@@ -3019,10 +3037,17 @@ func _is_tile_chunk_set_ready(state: Dictionary) -> bool:
func _tile_dist_sq(state: Dictionary, cam_pos: Vector3) -> float: func _tile_dist_sq(state: Dictionary, cam_pos: Vector3) -> float:
var min_x: float = float(state["tx"]) * TILE_SIZE var tile_coordinate = ADT_TILE_COORDINATE_SCRIPT.new(int(state["tx"]), int(state["ty"]))
var max_x: float = min_x + TILE_SIZE var north_west_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(
var min_z: float = float(state["ty"]) * TILE_SIZE tile_coordinate,
var max_z: float = min_z + TILE_SIZE ADT_TILE_LOCAL_POSITION_SCRIPT.new(0.0, 0.0, 0.0))
var south_east_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(
tile_coordinate,
ADT_TILE_LOCAL_POSITION_SCRIPT.new(TILE_SIZE, 0.0, TILE_SIZE))
var min_x: float = north_west_position.x_units
var max_x: float = south_east_position.x_units
var min_z: float = north_west_position.z_units
var max_z: float = south_east_position.z_units
var dx := 0.0 var dx := 0.0
if cam_pos.x < min_x: if cam_pos.x < min_x:
@@ -3048,32 +3073,46 @@ func _is_tile_queued(key: String) -> bool:
return false return false
func _get_stream_camera() -> Camera3D: func _capture_streaming_focus_from_source() -> void:
if Engine.is_editor_hint(): if streaming_focus_source_path == NodePath():
# The 3D editor viewport camera lives in EditorInterface, not get_viewport(). return
# get_viewport().get_camera_3d() from a @tool script returns whichever Camera3D var focus_source := get_node_or_null(streaming_focus_source_path) as Node3D
# belongs to the edited scene (often null), so editor-view movement is invisible if focus_source == null:
# unless we reach into EditorInterface. if not _missing_focus_source_reported:
var vp := EditorInterface.get_editor_viewport_3d(0) push_warning("Streaming focus source is missing or is not Node3D: %s" % streaming_focus_source_path)
if vp != null: _missing_focus_source_reported = true
var cam := vp.get_camera_3d() return
if cam: _missing_focus_source_reported = false
return cam _set_streaming_focus_from_vector3(focus_source.global_position)
if camera_path != NodePath():
var from_path := get_node_or_null(camera_path)
if from_path is Camera3D:
return from_path
var viewport_camera := get_viewport().get_camera_3d() func _set_streaming_focus_from_vector3(world_position: Vector3) -> void:
if viewport_camera: var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(
return viewport_camera world_position.x,
world_position.y,
world_position.z
)
set_streaming_focus(STREAMING_FOCUS_SCRIPT.new(typed_world_position))
for child in get_children():
if child is Camera3D and child.current:
return child
func _streaming_focus_to_vector3() -> Vector3:
var world_position: GodotWorldPosition = _streaming_focus.world_position
return Vector3(world_position.x_units, world_position.y_units, world_position.z_units)
func _get_editor_view_camera() -> Camera3D:
# EditorInterface owns this camera. This method is the explicit editor adapter;
# runtime streaming never discovers a viewport camera.
var editor_viewport := EditorInterface.get_editor_viewport_3d(0)
if editor_viewport == null:
return null return null
return editor_viewport.get_camera_3d()
func _get_auto_position_camera() -> Camera3D:
if camera_path == NodePath():
return null
return get_node_or_null(camera_path) as Camera3D
func _can_use_baked_tile_cache() -> bool: func _can_use_baked_tile_cache() -> bool:
@@ -5365,13 +5404,13 @@ func _position_camera_over_world() -> void:
if _camera_initialized: if _camera_initialized:
return return
var camera := _get_stream_camera() var camera := _get_auto_position_camera()
if camera == null: if camera == null:
return return
var center_tx: float = (_tile_min.x + _tile_max.x) * 0.5 var minimum_tile_center := _tile_center_to_world(_tile_min.x, _tile_min.y)
var center_ty: float = (_tile_min.y + _tile_max.y) * 0.5 var maximum_tile_center := _tile_center_to_world(_tile_max.x, _tile_max.y)
var center := _tile_center_to_world(center_tx, center_ty) var center := (minimum_tile_center + maximum_tile_center) * 0.5
var tile_span: int = int(max(_tile_max.x - _tile_min.x + 1, _tile_max.y - _tile_min.y + 1)) var tile_span: int = int(max(_tile_max.x - _tile_min.x + 1, _tile_max.y - _tile_min.y + 1))
var height: float = max(2000.0, float(tile_span) * TILE_SIZE * 0.18) var height: float = max(2000.0, float(tile_span) * TILE_SIZE * 0.18)
@@ -5380,19 +5419,19 @@ func _position_camera_over_world() -> void:
_camera_initialized = true _camera_initialized = true
## Tile (tx, ty) NW corner sits at Godot (tx*TILE_SIZE, 0, ty*TILE_SIZE). func _tile_center_to_world(tile_x: int, tile_y: int) -> Vector3:
## This matches wow_to_godot output: chunk origins ≈ (TX*TILE_SIZE, h, TY*TILE_SIZE). var tile_coordinate = ADT_TILE_COORDINATE_SCRIPT.new(tile_x, tile_y)
func _tile_center_to_world(tile_x: float, tile_y: float) -> Vector3: var half_tile_size := TILE_SIZE * 0.5
return Vector3( var tile_center_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(
(tile_x + 0.5) * TILE_SIZE, tile_coordinate,
0.0, ADT_TILE_LOCAL_POSITION_SCRIPT.new(half_tile_size, 0.0, half_tile_size))
(tile_y + 0.5) * TILE_SIZE) return Vector3(tile_center_position.x_units, tile_center_position.y_units, tile_center_position.z_units)
func _world_to_tile(world_pos: Vector3) -> Vector2i: func _world_to_tile(world_pos: Vector3) -> Vector2i:
return Vector2i( var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z)
int(floor(world_pos.x / TILE_SIZE)), var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
int(floor(world_pos.z / TILE_SIZE))) return Vector2i(tile_coordinate.tile_x, tile_coordinate.tile_y)
func _tile_key(tx: int, ty: int) -> String: func _tile_key(tx: int, ty: int) -> String:
@@ -0,0 +1,10 @@
[gd_scene load_steps=2 format=3]
[sub_resource type="BoxMesh" id="BoxMesh_character"]
size = Vector3(1, 2, 1)
[node name="SyntheticCharacter" type="Node3D"]
[node name="Body" type="MeshInstance3D" parent="."]
position = Vector3(0, 2, 0)
mesh = SubResource("BoxMesh_character")
@@ -0,0 +1,22 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="1_player"]
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="2_camera_rig"]
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="3_appearance"]
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="4_animation"]
[node name="PlayerInputRegression" type="CharacterBody3D"]
script = ExtResource("1_player")
spawn_at_tile_center = false
[node name="Visual" type="Node3D" parent="."]
script = ExtResource("3_appearance")
character_model_path = ""
[node name="AnimationPresenter" type="Node" parent="Visual"]
script = ExtResource("4_animation")
[node name="CameraPivot" type="Node3D" parent="."]
script = ExtResource("2_camera_rig")
[node name="Camera3D" type="Camera3D" parent="CameraPivot"]
+42 -3
View File
@@ -64,7 +64,7 @@ func _capture_async() -> void:
camera.far = 50000.0 camera.far = 50000.0
camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0])) camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
(world as Node3D).add_child(camera) (world as Node3D).add_child(camera)
world.set("camera_path", NodePath("CheckpointCamera")) world.set("streaming_focus_source_path", NodePath("CheckpointCamera"))
world.set("debug_streaming", true) world.set("debug_streaming", true)
world.set("runtime_stats_enabled", true) world.set("runtime_stats_enabled", true)
get_root().add_child(world) get_root().add_child(world)
@@ -126,8 +126,8 @@ func _capture_async() -> void:
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)))
if world.has_method("_refresh_streaming_targets_at"): if world.has_method("refresh_streaming_focus"):
world.call("_refresh_streaming_targets_at", camera.global_position, true) world.call("refresh_streaming_focus", true)
if dry_run: if dry_run:
print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [ print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [
@@ -246,6 +246,8 @@ func _result_record(checkpoint: Dictionary, pass_name: String, load_time_ms: flo
func _create_probe(checkpoint: Dictionary, dry_run: bool) -> Node3D: func _create_probe(checkpoint: Dictionary, dry_run: bool) -> Node3D:
if bool(checkpoint.get("diagnostic_spawn_marker", false)):
return null if dry_run else _create_diagnostic_spawn_marker(checkpoint)
var rel_path := String(checkpoint.get("probe_model", "")) var rel_path := String(checkpoint.get("probe_model", ""))
if rel_path.is_empty() or dry_run: if rel_path.is_empty() or dry_run:
return null return null
@@ -265,6 +267,43 @@ func _create_probe(checkpoint: Dictionary, dry_run: bool) -> Node3D:
return M2_NATIVE_ANIMATED_BUILDER.build(data, extracted_dir) return M2_NATIVE_ANIMATED_BUILDER.build(data, extracted_dir)
## Creates a renderer-native marker whose origin is the exact mapped server
## spawn. The vertical mast grows upward from that origin, so terrain occlusion
## remains visible instead of being hidden by an always-on-top overlay.
func _create_diagnostic_spawn_marker(checkpoint: Dictionary) -> Node3D:
var marker_root := Node3D.new()
marker_root.name = "DiagnosticServerSpawnMarker"
var marker_height := maxf(float(checkpoint.get("diagnostic_marker_height", 6.0)), 0.5)
var marker_radius := maxf(float(checkpoint.get("diagnostic_marker_radius", 0.35)), 0.05)
var marker_material := StandardMaterial3D.new()
marker_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
marker_material.albedo_color = Color(1.0, 0.05, 0.7, 1.0)
marker_material.emission_enabled = true
marker_material.emission = Color(1.0, 0.01, 0.35, 1.0)
var mast_mesh := CylinderMesh.new()
mast_mesh.height = marker_height
mast_mesh.top_radius = marker_radius
mast_mesh.bottom_radius = marker_radius
mast_mesh.material = marker_material
var mast := MeshInstance3D.new()
mast.name = "SpawnOriginMast"
mast.mesh = mast_mesh
mast.position.y = marker_height * 0.5
marker_root.add_child(mast)
var origin_mesh := SphereMesh.new()
origin_mesh.radius = marker_radius * 1.8
origin_mesh.height = marker_radius * 3.6
origin_mesh.material = marker_material
var origin := MeshInstance3D.new()
origin.name = "SpawnOrigin"
origin.mesh = origin_mesh
marker_root.add_child(origin)
return marker_root
func _set_sky_time(world: Node, time_hours: float) -> void: func _set_sky_time(world: Node, time_hours: float) -> void:
var sky := world.get_node_or_null("WowSkyController") var sky := world.get_node_or_null("WowSkyController")
if sky != null: if sky != null:
+3 -3
View File
@@ -28,7 +28,7 @@ func _run_async() -> void:
camera.name = "OccluderProbeCamera" camera.name = "OccluderProbeCamera"
camera.current = true camera.current = true
world.add_child(camera) world.add_child(camera)
world.set("camera_path", NodePath(camera.name)) world.set("streaming_focus_source_path", NodePath(camera.name))
world.set("debug_streaming", false) world.set("debug_streaming", false)
get_root().add_child(world) get_root().add_child(world)
await process_frame await process_frame
@@ -47,8 +47,8 @@ func _run_async() -> void:
var camera_position := _vector3(checkpoint.get("camera", [])) var camera_position := _vector3(checkpoint.get("camera", []))
var target_position := _vector3(checkpoint.get("target", [])) var target_position := _vector3(checkpoint.get("target", []))
camera.global_position = camera_position camera.global_position = camera_position
if world.has_method("_refresh_streaming_targets_at"): if world.has_method("refresh_streaming_focus"):
world.call("_refresh_streaming_targets_at", camera_position, true) world.call("refresh_streaming_focus", true)
await create_timer(maxf(0.1, wait_seconds)).timeout await create_timer(maxf(0.1, wait_seconds)).timeout
var geometry_nodes: Array[Node3D] = [] var geometry_nodes: Array[Node3D] = []
_collect_geometry_nodes(world, geometry_nodes) _collect_geometry_nodes(world, geometry_nodes)
+13 -10
View File
@@ -5,7 +5,8 @@ extends SceneTree
## [--wait 3.0] [--output user://render_terrain_height/report.json] ## [--wait 3.0] [--output user://render_terrain_height/report.json]
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json" const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
const TILE_SIZE := 533.33333 const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const RAY_HEIGHT := 5000.0 const RAY_HEIGHT := 5000.0
@@ -36,7 +37,7 @@ func _run_async() -> void:
var camera := Camera3D.new() var camera := Camera3D.new()
camera.current = true camera.current = true
world.add_child(camera) world.add_child(camera)
world.set("camera_path", NodePath(camera.name)) world.set("streaming_focus_source_path", NodePath(camera.name))
world.set("debug_streaming", false) world.set("debug_streaming", false)
get_root().add_child(world) get_root().add_child(world)
await process_frame await process_frame
@@ -53,8 +54,8 @@ func _run_async() -> void:
continue continue
var camera_position := _vector3(checkpoint.get("camera", [])) var camera_position := _vector3(checkpoint.get("camera", []))
camera.global_position = camera_position camera.global_position = camera_position
if world.has_method("_refresh_streaming_targets_at"): if world.has_method("refresh_streaming_focus"):
world.call("_refresh_streaming_targets_at", camera_position, true) world.call("refresh_streaming_focus", true)
await create_timer(maxf(0.1, wait_seconds)).timeout await create_timer(maxf(0.1, wait_seconds)).timeout
var terrain_sample := _sample_terrain(world, camera_position) var terrain_sample := _sample_terrain(world, camera_position)
terrain_sample["name"] = checkpoint.get("name", "checkpoint") terrain_sample["name"] = checkpoint.get("name", "checkpoint")
@@ -90,9 +91,7 @@ func _run_async() -> void:
func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary: func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary:
var tile_coordinate := Vector2i( var tile_coordinate := _adt_tile_vector2i(world_position)
int(floor(world_position.x / TILE_SIZE)),
int(floor(world_position.z / TILE_SIZE)))
var tile_states: Dictionary = world.get("_tile_states") var tile_states: Dictionary = world.get("_tile_states")
var highest_terrain_height := -INF var highest_terrain_height := -INF
var intersected_tile_key := "" var intersected_tile_key := ""
@@ -198,9 +197,7 @@ func _nearest_terrain_sample(world: Node3D, world_position: Vector3) -> Dictiona
for radius in [2.0, 5.0, 10.0, 20.0, 40.0]: 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)]: 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_position := world_position + Vector3(offset.x, 0.0, offset.y)
var sample_tile := Vector2i( var sample_tile := _adt_tile_vector2i(sample_position)
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] var sample_key := "%d_%d" % [sample_tile.x, sample_tile.y]
if not tile_states.has(sample_key): if not tile_states.has(sample_key):
continue continue
@@ -214,6 +211,12 @@ func _nearest_terrain_sample(world: Node3D, world_position: Vector3) -> Dictiona
return {"nearest_sample_distance": null} return {"nearest_sample_distance": null}
func _adt_tile_vector2i(world_position: Vector3) -> Vector2i:
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_position.x, world_position.y, world_position.z)
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
return Vector2i(tile_coordinate.tile_x, tile_coordinate.tile_y)
func _vector3_array(value: Vector3) -> Array[float]: func _vector3_array(value: Vector3) -> Array[float]:
return [value.x, value.y, value.z] return [value.x, value.y, value.z]
@@ -0,0 +1,29 @@
{
"schema_version": 1,
"profile": "Blizzlike335",
"scene": "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"map": "Azeroth",
"quality_preset": "High",
"viewport": [1280, 900],
"camera_fov": 62.0,
"default_wait_seconds": 10.0,
"default_measure_seconds": 1.0,
"source_fixture": "res://src/tests/fixtures/coordinate_golden_points.json",
"source_spawn_name": "azerothcore_human_warrior_start",
"required_coverage": ["server_spawn"],
"checkpoints": [
{
"name": "azerothcore_human_warrior_start",
"coverage": ["server_spawn"],
"camera": [17168.0, 105.0, 25984.0],
"target": [17199.159666667, 83.5312, 26016.616666667],
"player": [17199.159666667, 83.5312, 26016.616666667],
"time_hours": 13.0,
"diagnostic_spawn_marker": true,
"diagnostic_marker_height": 6.0,
"diagnostic_marker_radius": 0.35,
"expected_adt_tile": [32, 48],
"expected_adt_chunk": [3, 12]
}
]
}
+164
View File
@@ -0,0 +1,164 @@
extends SceneTree
## Asset-free M02 appearance, animation and player-boundary regression.
const MODEL_FIXTURE_PATH := "res://src/tests/scenes/character_presentation_model_fixture.tscn"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const APPEARANCE_PRESENTER_PATH := "res://src/scenes/character/character_appearance_presenter.gd"
const ANIMATION_PRESENTER_PATH := "res://src/scenes/character/character_animation_presenter.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
const RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn",
]
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
await _verify_appearance_presenter(failures)
_verify_animation_presenter(failures)
_verify_source_boundaries(failures)
_verify_scene_wiring(failures)
if not failures.is_empty():
for failure in failures:
push_error("CHARACTER_PRESENTATION: %s" % failure)
quit(1)
return
print("CHARACTER_PRESENTATION PASS appearance_cases=9 animation_cases=10 scenes=3 player_boundary=1")
quit(0)
func _verify_appearance_presenter(failures: Array[String]) -> void:
var presenter := CharacterAppearancePresenter.new()
presenter.character_model_path = MODEL_FIXTURE_PATH
presenter.character_scale = 2.0
presenter.character_yaw_offset_degrees = 45.0
root.add_child(presenter)
var ready_roots: Array[Node3D] = []
presenter.character_appearance_ready.connect(func(character_root: Node3D) -> void: ready_roots.append(character_root))
_expect_true(presenter.load_character_appearance("res://missing-extracted-fixture"), "fixture load starts", failures)
await process_frame
var character_root := presenter.character_root
_expect_true(character_root != null, "character root composed", failures)
if character_root != null:
_expect_near(character_root.scale.x, 2.0, "uniform character scale", failures)
_expect_near(character_root.rotation_degrees.y, 45.0, "character yaw offset", failures)
_expect_near(character_root.position.y, -2.0, "character grounded after scale", failures)
_expect_true(character_root.has_node("CharacterTextureCompositor"), "texture compositor composed", failures)
_expect_true(ready_roots.size() == 1, "appearance ready emitted", failures)
_expect_true(presenter.extracted_data_directory == "res://missing-extracted-fixture", "content root override retained", failures)
var previous_root := character_root
_expect_true(presenter.load_character_appearance(), "replacement load starts", failures)
_expect_true(presenter.character_root != previous_root, "replacement swaps owned root", failures)
await process_frame
_expect_true(ready_roots.size() == 2, "replacement ready emitted", failures)
presenter.character_model_path = ""
_expect_true(not presenter.load_character_appearance(), "empty model rejected", failures)
_expect_true(presenter.character_root == null, "empty model clears old root", failures)
await process_frame
presenter.free()
func _verify_animation_presenter(failures: Array[String]) -> void:
var animation_root := Node.new()
var nested_node := Node.new()
var animation_player := AnimationPlayer.new()
animation_root.add_child(nested_node)
nested_node.add_child(animation_player)
var animation_library := AnimationLibrary.new()
for animation_name in ["Stand", "Run", "Emote"]:
animation_library.add_animation(animation_name, Animation.new())
animation_player.add_animation_library("", animation_library)
var presenter := CharacterAnimationPresenter.new()
presenter.animation_blend_time_seconds = 0.25
_expect_true(presenter.bind_character_root(animation_root), "nested animation player bound", failures)
_expect_true(presenter.has_animation_player, "animation player state exposed", failures)
_expect_true(presenter.active_animation_name == "Stand", "stationary animation selected", failures)
_expect_true(animation_player.current_animation == "Stand", "stationary animation playing", failures)
_expect_near(animation_player.playback_default_blend_time, 0.25, "default blend configured", failures)
_expect_true(animation_player.get_animation("Stand").loop_mode == Animation.LOOP_LINEAR, "stand loops", failures)
_expect_true(animation_player.get_animation("Run").loop_mode == Animation.LOOP_LINEAR, "run loops", failures)
_expect_true(animation_player.get_animation("Emote").loop_mode == Animation.LOOP_NONE, "unrelated animation unchanged", failures)
_expect_true(presenter.present_locomotion(true), "moving transition starts", failures)
_expect_true(presenter.active_animation_name == "Run", "moving animation selected", failures)
_expect_true(not presenter.present_locomotion(true), "duplicate moving state ignored", failures)
var fallback_root := Node.new()
var fallback_player := AnimationPlayer.new()
fallback_root.add_child(fallback_player)
var fallback_library := AnimationLibrary.new()
fallback_library.add_animation("CharacterStandLoop", Animation.new())
fallback_library.add_animation("FastRunCycle", Animation.new())
fallback_player.add_animation_library("", fallback_library)
_expect_true(presenter.bind_character_root(fallback_root), "fallback animation player bound", failures)
_expect_true(presenter.active_animation_name == "CharacterStandLoop", "substring stationary fallback", failures)
presenter.present_locomotion(true)
_expect_true(presenter.active_animation_name == "FastRunCycle", "substring moving fallback", failures)
fallback_root.free()
_expect_true(not presenter.present_locomotion(false), "freed animation root rejected", failures)
_expect_true(not presenter.has_animation_player, "freed animation binding cleared", failures)
var missing_animation_root := Node.new()
_expect_true(not presenter.bind_character_root(missing_animation_root), "missing animation player rejected", failures)
_expect_true(not presenter.has_animation_player, "missing binding clears state", failures)
presenter.free()
animation_root.free()
missing_animation_root.free()
func _verify_source_boundaries(failures: Array[String]) -> void:
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
for forbidden_text in [
"GEOSET_CONTROLLER_SCRIPT",
"TEXTURE_COMPOSITOR_SCRIPT",
"OUTFIT_RESOLVER_SCRIPT",
"func _load_character_visual",
"func _find_animation_player",
"func _calculate_aabb",
"_animation_player",
]:
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
_expect_true(player_source.contains("_appearance_presenter.load_character_appearance"), "player delegates appearance", failures)
_expect_true(player_source.contains("_animation_presenter.present_locomotion"), "player delegates animation", failures)
var appearance_source := _read_text(APPEARANCE_PRESENTER_PATH, failures)
for forbidden_text in ["MoveIntent", "TerrainQuery", "ThirdPersonCameraRig", "AnimationPlayer"]:
_expect_true(not appearance_source.contains(forbidden_text), "appearance omits %s" % forbidden_text, failures)
var animation_source := _read_text(ANIMATION_PRESENTER_PATH, failures)
for forbidden_text in ["load(character_model_path)", "TerrainQuery", "MoveIntent", "Camera3D"]:
_expect_true(not animation_source.contains(forbidden_text), "animation omits %s" % forbidden_text, failures)
func _verify_scene_wiring(failures: Array[String]) -> void:
for scene_path in RUNTIME_SCENE_PATHS + [REGRESSION_SCENE_PATH]:
var scene_source := _read_text(scene_path, failures)
_expect_true(scene_source.contains("character_appearance_presenter.gd"), "%s references appearance presenter" % scene_path, failures)
_expect_true(scene_source.contains("character_animation_presenter.gd"), "%s references animation presenter" % scene_path, failures)
_expect_true(scene_source.contains('name="AnimationPresenter"'), "%s composes animation presenter" % scene_path, failures)
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if absf(actual_value - expected_value) > 0.000001:
failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1,141 @@
extends SceneTree
## Repository-wide M01 gate against manual cross-space world coordinate formulas.
## Model-local M2/WMO basis transforms and ordinary Godot-space geometry are not
## world-space conversions and are intentionally outside these signatures.
const SCAN_ROOTS: Array[String] = ["res://src", "res://addons"]
const SOURCE_EXTENSIONS: Array[String] = ["gd", "cpp", "h", "hpp"]
const EXCLUDED_DIRECTORY_PREFIXES: Array[String] = ["res://src/native/build/"]
const GATE_PATH := "res://src/tools/verify_coordinate_conversion_boundaries.gd"
const COORDINATE_MAPPER_PATH := "res://src/domain/coordinates/coordinate_mapper.gd"
const CALIBRATION_ORACLE_PATH := "res://src/tools/verify_render_coordinate_calibration.gd"
const NATIVE_BOUNDARY_PATH := "res://src/native/src/wow_chunk_reader.h"
const ALLOWED_LEGACY_NAME_PATHS: Array[String] = [
NATIVE_BOUNDARY_PATH,
"res://src/native/src/adt_loader.cpp",
"res://src/native/src/wdt_loader.cpp",
CALIBRATION_ORACLE_PATH,
]
const REQUIRED_MAPPER_CALLS := {
"res://src/scenes/sky/wow_sky_controller.gd": ["godot_to_canonical", "godot_to_adt_tile", "godot_to_adt_tile_local"],
"res://src/scenes/streaming/streaming_world_loader.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_godot"],
"res://src/scenes/player/third_person_wow_controller.gd": ["adt_tile_local_to_godot"],
"res://src/gameplay/terrain/adt_terrain_query.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_chunk"],
"res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"],
}
func _initialize() -> void:
var failures: Array[String] = []
var source_paths: Array[String] = []
for scan_root in SCAN_ROOTS:
_collect_source_paths(scan_root, source_paths, failures)
var native_boundary_definition_count := 0
var independent_oracle_definition_count := 0
for source_path in source_paths:
if source_path == GATE_PATH:
continue
var source := _read_text(source_path, failures)
var source_lines := source.split("\n")
for line_index in range(source_lines.size()):
var source_line := String(source_lines[line_index])
var violation := _coordinate_violation(source_path, source_line)
if not violation.is_empty():
failures.append("%s:%d %s" % [source_path, line_index + 1, violation])
if source_path == NATIVE_BOUNDARY_PATH and source_line.contains("inline void wow_to_godot"):
native_boundary_definition_count += 1
if source_path == CALIBRATION_ORACLE_PATH and source_line.contains("func _wow_to_godot"):
independent_oracle_definition_count += 1
_expect_equal(native_boundary_definition_count, 1, "native world conversion definition count", failures)
_expect_equal(independent_oracle_definition_count, 1, "independent calibration oracle count", failures)
_verify_required_mapper_calls(failures)
_verify_classifier_guards(failures)
if not failures.is_empty():
for failure in failures:
push_error("COORDINATE_CONVERSION_BOUNDARIES: %s" % failure)
quit(1)
return
print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=5 classifier_cases=6" % source_paths.size())
quit(0)
func _coordinate_violation(source_path: String, source_line: String) -> String:
var stripped_line := source_line.strip_edges()
if stripped_line.is_empty():
return ""
if (source_line.contains("wow_to_godot") or source_line.contains("godot_to_wow")) and source_path not in ALLOWED_LEGACY_NAME_PATHS:
return "manual WoW/Godot conversion name outside mapper boundary"
if (source_line.contains("17066.666") or source_line.contains("WOW_WORLD_CENTER")) and source_path not in [NATIVE_BOUNDARY_PATH, CALIBRATION_ORACLE_PATH]:
return "manual WoW world-center formula outside native boundary/oracle"
if source_line.contains("1600.0 / 3.0") and source_path != COORDINATE_MAPPER_PATH:
return "duplicated ADT tile-size formula outside CoordinateMapper"
if source_path != COORDINATE_MAPPER_PATH and source_line.contains("floor(") and source_line.contains("TILE_SIZE") and (source_line.contains(".x") or source_line.contains(".z")):
return "manual Godot world-position to ADT tile formula"
if source_path != COORDINATE_MAPPER_PATH and source_line.contains("fposmod(") and source_line.contains("TILE_SIZE"):
return "manual Godot world-position to ADT tile-local formula"
if source_path != COORDINATE_MAPPER_PATH and source_line.contains("+ 0.5) * TILE_SIZE"):
return "manual ADT tile-center to Godot world-position formula"
return ""
func _verify_required_mapper_calls(failures: Array[String]) -> void:
for source_path in REQUIRED_MAPPER_CALLS:
var source := _read_text(source_path, failures)
for required_call in REQUIRED_MAPPER_CALLS[source_path]:
if not source.contains(String(required_call)):
failures.append("%s must call CoordinateMapper.%s" % [source_path, required_call])
func _verify_classifier_guards(failures: Array[String]) -> void:
_expect_true(not _coordinate_violation("res://src/example.gd", "const CENTER := 17066.666").is_empty(), "world-center classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "func wow_to_godot():").is_empty(), "conversion-name classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "floor(world_position.x / TILE_SIZE)").is_empty(), "world-to-tile classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "fposmod(world_position.z, TILE_SIZE)").is_empty(), "world-to-tile-local classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "(tile_x + 0.5) * TILE_SIZE").is_empty(), "tile-center classifier", failures)
_expect_true(_coordinate_violation(NATIVE_BOUNDARY_PATH, "inline void wow_to_godot(float wx) { gx = -(wx - 17066.666f); }").is_empty(), "native boundary exception", failures)
func _collect_source_paths(directory_path: String, source_paths: Array[String], failures: Array[String]) -> void:
var directory := DirAccess.open(directory_path)
if directory == null:
failures.append("cannot scan directory %s" % directory_path)
return
for entry_name in directory.get_files():
var source_path := directory_path.path_join(entry_name)
if entry_name.get_extension().to_lower() in SOURCE_EXTENSIONS:
source_paths.append(source_path)
for child_directory_name in directory.get_directories():
var child_directory_path := directory_path.path_join(child_directory_name) + "/"
if _has_excluded_prefix(child_directory_path):
continue
_collect_source_paths(child_directory_path.trim_suffix("/"), source_paths, failures)
func _has_excluded_prefix(path: String) -> bool:
for excluded_prefix in EXCLUDED_DIRECTORY_PREFIXES:
if path.begins_with(excluded_prefix):
return true
return false
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_equal(actual_value, expected_value, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://bnj844sd2n6ln
+12
View File
@@ -119,6 +119,18 @@ func _verify_tile_and_chunk_boundaries(failures: Array[String]) -> void:
_expect_true(local_position.east_yards >= 0.0 and local_position.east_yards < tile_size, "tile-local east offset is half-open", failures) _expect_true(local_position.east_yards >= 0.0 and local_position.east_yards < tile_size, "tile-local east offset is half-open", failures)
_expect_true(local_position.south_yards >= 0.0 and local_position.south_yards < tile_size, "tile-local south offset is half-open", failures) _expect_true(local_position.south_yards >= 0.0 and local_position.south_yards < tile_size, "tile-local south offset is half-open", failures)
var godot_position = CoordinateMapperScript.canonical_to_godot(CanonicalWowWorldPositionScript.new(-9153.334, 386.666, 180.0))
var direct_godot_tile = CoordinateMapperScript.godot_to_adt_tile(godot_position)
var direct_godot_local = CoordinateMapperScript.godot_to_adt_tile_local(godot_position)
var direct_godot_round_trip = CoordinateMapperScript.adt_tile_local_to_godot(direct_godot_tile, direct_godot_local)
_expect_equal(direct_godot_tile.tile_x, 31, "direct Godot ADT tile X", failures)
_expect_equal(direct_godot_tile.tile_y, 49, "direct Godot ADT tile Y", failures)
_expect_near(direct_godot_local.east_yards, 146.66677, GOLDEN_POSITION_TOLERANCE_YARDS, "direct Godot tile-local east", failures)
_expect_near(direct_godot_local.south_yards, 86.66677, GOLDEN_POSITION_TOLERANCE_YARDS, "direct Godot tile-local south", failures)
_expect_near(direct_godot_round_trip.x_units, godot_position.x_units, ROUND_TRIP_TOLERANCE_YARDS, "direct Godot tile-local X round trip", failures)
_expect_near(direct_godot_round_trip.y_units, godot_position.y_units, ROUND_TRIP_TOLERANCE_YARDS, "direct Godot tile-local Y round trip", failures)
_expect_near(direct_godot_round_trip.z_units, godot_position.z_units, ROUND_TRIP_TOLERANCE_YARDS, "direct Godot tile-local Z round trip", failures)
var chunk_local_position = AdtTileLocalPositionScript.new( var chunk_local_position = AdtTileLocalPositionScript.new(
CoordinateMapperScript.ADT_CHUNK_SIZE_YARDS * 2.0, CoordinateMapperScript.ADT_CHUNK_SIZE_YARDS * 2.0,
180.0, 180.0,
+87
View File
@@ -0,0 +1,87 @@
extends SceneTree
## Headless M01 contract verification for non-interchangeable identity values.
const ContentIdScript = preload("res://src/domain/identity/content_id.gd")
const EntityIdScript = preload("res://src/domain/identity/entity_id.gd")
const WowGuidScript = preload("res://src/domain/identity/wow_guid.gd")
const ServerEntryIdScript = preload("res://src/domain/identity/server_entry_id.gd")
func _initialize() -> void:
var failures: Array[String] = []
_verify_content_id(failures)
_verify_entity_id(failures)
_verify_wow_guid(failures)
_verify_server_entry_id(failures)
_verify_scene_free_sources(failures)
if not failures.is_empty():
for failure in failures:
push_error("DOMAIN_IDENTITIES: %s" % failure)
quit(1)
return
print("DOMAIN_IDENTITIES PASS content=5 entity=5 wow_guid=6 server_entry=5")
quit(0)
func _verify_content_id(failures: Array[String]) -> void:
var normalized = ContentIdScript.new(" 123E4567-E89B-42D3-A456-426614174000 ")
_expect_true(normalized.is_valid(), "normalized ContentId is valid", failures)
_expect_equal(normalized.to_key(), "123e4567-e89b-42d3-a456-426614174000", "ContentId canonical key", failures)
_expect_true(normalized.equals(ContentIdScript.new("123e4567-e89b-42d3-a456-426614174000")), "equal ContentIds", failures)
_expect_true(not normalized.equals(ContentIdScript.new("123e4567-e89b-42d3-a456-426614174001")), "different ContentIds", failures)
_expect_true(not ContentIdScript.new("123e4567e89b42d3a456426614174000").is_valid(), "malformed ContentId", failures)
func _verify_entity_id(failures: Array[String]) -> void:
var entity_id = EntityIdScript.new(42)
_expect_true(entity_id.is_valid(), "positive EntityId", failures)
_expect_true(not EntityIdScript.new(0).is_valid(), "zero EntityId", failures)
_expect_true(not EntityIdScript.new(-1).is_valid(), "negative EntityId", failures)
_expect_true(entity_id.equals(EntityIdScript.new(42)), "equal EntityIds", failures)
_expect_equal(entity_id.to_debug_key(), "entity:42", "EntityId diagnostic key", failures)
func _verify_wow_guid(failures: Array[String]) -> void:
var guid = WowGuidScript.new(0xf1300000, 0xffffffff)
_expect_true(guid.has_valid_word_ranges(), "maximum GUID low word", failures)
_expect_equal(guid.to_hex_string(), "f1300000ffffffff", "GUID hexadecimal form", failures)
_expect_true(guid.equals(WowGuidScript.new(0xf1300000, 0xffffffff)), "equal GUID words", failures)
_expect_true(WowGuidScript.new(0, 0).is_empty(), "zero GUID sentinel", failures)
_expect_true(not WowGuidScript.new(-1, 0).has_valid_word_ranges(), "negative GUID word", failures)
_expect_true(not WowGuidScript.new(0x100000000, 0).has_valid_word_ranges(), "overflow GUID word", failures)
func _verify_server_entry_id(failures: Array[String]) -> void:
var creature_entry = ServerEntryIdScript.new(" Creature_Template ", 123)
var gameobject_entry = ServerEntryIdScript.new("gameobject_template", 123)
_expect_true(creature_entry.is_valid(), "namespaced server entry", failures)
_expect_equal(creature_entry.to_key(), "creature_template:123", "server entry key", failures)
_expect_true(not creature_entry.equals(gameobject_entry), "cross-table entry collision prevented", failures)
_expect_true(not ServerEntryIdScript.new("", 123).is_valid(), "missing entry namespace", failures)
_expect_true(not ServerEntryIdScript.new("creature_template", 0).is_valid(), "zero server entry", failures)
func _verify_scene_free_sources(failures: Array[String]) -> void:
for file_name in ["content_id.gd", "entity_id.gd", "wow_guid.gd", "server_entry_id.gd"]:
var path := "res://src/domain/identity/%s" % file_name
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
continue
var source := file.get_as_text()
_expect_true(not source.contains("extends Node"), "%s does not inherit Node" % file_name, failures)
_expect_true(not source.contains("extends Resource"), "%s does not inherit Resource" % file_name, failures)
_expect_true(not source.contains("Vector3"), "%s does not expose Vector3" % file_name, failures)
func _expect_equal(actual_value, expected_value, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://bmig1rwaur44r
+137
View File
@@ -0,0 +1,137 @@
extends SceneTree
## Headless M02 unit and boundary regression for local player movement state.
const MOVEMENT_CONTROLLER_PATH := "res://src/gameplay/movement/local_player_movement_controller.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_directional_speeds(failures)
_verify_acceleration_and_deceleration(failures)
_verify_sandbox_sprint(failures)
_verify_flight_state_and_basis(failures)
_verify_invalid_delta(failures)
_verify_scene_boundary(failures)
if not failures.is_empty():
for failure in failures:
push_error("LOCAL_PLAYER_MOVEMENT: %s" % failure)
quit(1)
return
print("LOCAL_PLAYER_MOVEMENT PASS cases=12 state_transitions=2 scene_boundary=1")
quit(0)
func _verify_directional_speeds(failures: Array[String]) -> void:
var forward_controller := _new_controller()
var forward_displacement := forward_controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, 1.0)
_expect_vector_near(forward_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "forward velocity", failures)
_expect_vector_near(forward_displacement, Vector3(0.0, 0.0, -7.0), "forward displacement", failures)
var backward_controller := _new_controller()
backward_controller.advance(_intent(-1.0, 0.0), Basis.IDENTITY, 1.0)
_expect_vector_near(backward_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, 4.5), "backward velocity", failures)
var strafe_controller := _new_controller()
strafe_controller.advance(_intent(0.0, 1.0), Basis.IDENTITY, 1.0)
_expect_vector_near(strafe_controller.godot_world_velocity_units_per_second, Vector3(4.5, 0.0, 0.0), "strafe velocity", failures)
var rotated_controller := _new_controller()
var rotated_basis := Basis(Vector3.UP, deg_to_rad(90.0))
rotated_controller.advance(_intent(1.0, 0.0), rotated_basis, 1.0)
_expect_vector_near(
rotated_controller.godot_world_velocity_units_per_second,
(-rotated_basis.z).normalized() * 7.0,
"rotated character basis",
failures
)
func _verify_acceleration_and_deceleration(failures: Array[String]) -> void:
var controller := _new_controller()
var forward_intent := _intent(1.0, 0.0)
_expect_vector_near(controller.advance(forward_intent, Basis.IDENTITY, 0.1), Vector3(0.0, 0.0, -0.28), "first acceleration displacement", failures)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -2.8), "first acceleration velocity", failures)
controller.advance(forward_intent, Basis.IDENTITY, 0.1)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -5.6), "second acceleration velocity", failures)
controller.advance(_intent(0.0, 0.0), Basis.IDENTITY, 0.1)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -2.8), "deceleration velocity", failures)
func _verify_sandbox_sprint(failures: Array[String]) -> void:
var controller := _new_controller()
controller.advance(_intent(1.0, 0.0, 0.0, true), Basis.IDENTITY, 2.0)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox sprint velocity", failures)
func _verify_flight_state_and_basis(failures: Array[String]) -> void:
var controller := _new_controller()
controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, 1.0)
_expect_true(controller.toggle_sandbox_flight(), "flight toggles on", failures)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "flight toggle clears velocity", failures)
var pitched_basis := Basis(Vector3.RIGHT, deg_to_rad(30.0))
controller.advance(_intent(1.0, 0.0, 1.0), pitched_basis, 1.0)
var expected_flight_velocity := (-pitched_basis.z).normalized() * 7.0 + Vector3.UP * 7.0
_expect_vector_near(controller.godot_world_velocity_units_per_second, expected_flight_velocity, "camera-relative flight velocity", failures)
_expect_true(not controller.toggle_sandbox_flight(), "flight toggles off", failures)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "landing toggle clears velocity", failures)
func _verify_invalid_delta(failures: Array[String]) -> void:
var controller := _new_controller()
var displacement := controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, -1.0)
_expect_vector_near(displacement, Vector3.ZERO, "negative delta displacement", failures)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "negative delta state", failures)
func _verify_scene_boundary(failures: Array[String]) -> void:
var movement_source := _read_text(MOVEMENT_CONTROLLER_PATH, failures)
for forbidden_text in ["extends Node", "extends Resource", "Input.", "Camera3D", "ADTLoader", "global_position"]:
_expect_true(not movement_source.contains(forbidden_text), "movement controller omits %s" % forbidden_text, failures)
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
_expect_true(player_source.contains("_local_movement_controller.advance"), "scene delegates movement update", failures)
_expect_true(not player_source.contains("move_toward(target_velocity"), "scene omits velocity integration", failures)
_expect_true(not player_source.contains("func _movement_vector"), "scene omits movement-vector calculation", failures)
_expect_true(not player_source.contains("_horizontal_velocity"), "scene omits movement velocity state", failures)
_expect_true(not player_source.contains("var _flight_enabled"), "scene omits flight state", failures)
func _new_controller() -> LocalPlayerMovementController:
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
func _intent(
forward_axis: float,
strafe_axis: float,
vertical_axis: float = 0.0,
sprint_requested: bool = false
) -> MoveIntent:
return MoveIntent.new(forward_axis, strafe_axis, vertical_axis, sprint_requested)
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_vector_near(
actual_value: Vector3,
expected_value: Vector3,
label: String,
failures: Array[String]
) -> void:
if not actual_value.is_equal_approx(expected_value):
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
+163
View File
@@ -0,0 +1,163 @@
extends SceneTree
## Headless M02 contract regression for remappable actions and MoveIntent composition.
const MOVE_INTENT_PATH := "res://src/domain/input/move_intent.gd"
const PLAYER_INPUT_SOURCE_PATH := "res://src/gameplay/input/player_input_source.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
_verify_default_input_actions(failures)
_verify_move_intent_composition(failures)
_verify_controller_boundary(failures)
_verify_regression_scene(failures)
if not failures.is_empty():
for failure in failures:
push_error("PLAYER_INPUT: %s" % failure)
quit(1)
return
print("PLAYER_INPUT PASS actions=%d intent_cases=6 controller=1 regression_scene=1" % PlayerInputActions.REQUIRED_ACTIONS.size())
quit(0)
func _verify_default_input_actions(failures: Array[String]) -> void:
for action_name in PlayerInputActions.REQUIRED_ACTIONS:
_expect_true(InputMap.has_action(action_name), "Input Map contains %s" % action_name, failures)
_expect_key_binding(PlayerInputActions.MOVE_FORWARD, KEY_W, true, failures)
_expect_key_binding(PlayerInputActions.MOVE_BACKWARD, KEY_S, true, failures)
_expect_key_binding(PlayerInputActions.STRAFE_LEFT, KEY_A, true, failures)
_expect_key_binding(PlayerInputActions.STRAFE_RIGHT, KEY_D, true, failures)
_expect_key_binding(PlayerInputActions.DEBUG_FLY_UP, KEY_E, true, failures)
_expect_key_binding(PlayerInputActions.DEBUG_FLY_DOWN, KEY_Q, true, failures)
_expect_key_binding(PlayerInputActions.DEBUG_SPRINT, KEY_SHIFT, false, failures)
_expect_key_binding(PlayerInputActions.DEBUG_TOGGLE_FLIGHT, KEY_SPACE, true, failures)
_expect_key_binding(PlayerInputActions.RELEASE_CURSOR, KEY_ESCAPE, false, failures)
_expect_mouse_binding(PlayerInputActions.CAMERA_ROTATE, MOUSE_BUTTON_RIGHT, failures)
_expect_mouse_binding(PlayerInputActions.CAMERA_ZOOM_IN, MOUSE_BUTTON_WHEEL_UP, failures)
_expect_mouse_binding(PlayerInputActions.CAMERA_ZOOM_OUT, MOUSE_BUTTON_WHEEL_DOWN, failures)
func _verify_move_intent_composition(failures: Array[String]) -> void:
var neutral := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false)
_expect_near(neutral.forward_axis, 0.0, "neutral forward", failures)
_expect_true(not neutral.has_translation(), "neutral has no translation", failures)
var forward := PlayerInputSource.compose_move_intent(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, false)
_expect_near(forward.forward_axis, 1.0, "forward axis", failures)
var canceled := PlayerInputSource.compose_move_intent(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, false)
_expect_true(not canceled.has_translation(), "opposite actions cancel", failures)
var diagonal := PlayerInputSource.compose_move_intent(1.0, 0.0, 0.0, 1.0, 0.0, 0.0, false)
_expect_near(Vector2(diagonal.strafe_axis, diagonal.forward_axis).length(), 1.0, "diagonal normalized", failures)
var vertical := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 1.5, -0.5, false)
_expect_near(vertical.vertical_axis, 1.0, "vertical strengths clamped", failures)
var debug_requests := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, true)
_expect_true(debug_requests.is_sprint_requested, "sprint request retained", failures)
func _verify_controller_boundary(failures: Array[String]) -> void:
var controller_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
_expect_true(controller_source.contains("sample_move_intent()"), "controller samples PlayerInputSource", failures)
_expect_true(not controller_source.contains("Input.is_key_pressed"), "controller omits direct keyboard polling", failures)
_expect_true(not controller_source.contains("KEY_"), "controller omits hardcoded key constants", failures)
var move_intent_source := _read_text(MOVE_INTENT_PATH, failures)
_expect_true(not move_intent_source.contains("extends Node"), "MoveIntent does not inherit Node", failures)
_expect_true(not move_intent_source.contains("extends Resource"), "MoveIntent does not inherit Resource", failures)
_expect_true(not move_intent_source.contains("Input."), "MoveIntent does not read engine input", failures)
var input_source := _read_text(PLAYER_INPUT_SOURCE_PATH, failures)
_expect_true(input_source.contains("Input.get_action_strength"), "input source reads action strengths", failures)
_expect_true(not input_source.contains("is_key_pressed"), "input source omits physical-key polling", failures)
func _verify_regression_scene(failures: Array[String]) -> void:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
_expect_true(packed_scene != null, "regression scene loads", failures)
if packed_scene == null:
return
var player := packed_scene.instantiate() as CharacterBody3D
_expect_true(player != null, "regression player instantiates", failures)
if player == null:
return
root.add_child(player)
player.set_physics_process(false)
Input.action_press(PlayerInputActions.MOVE_FORWARD)
player.call("_physics_process", 1.0)
Input.action_release(PlayerInputActions.MOVE_FORWARD)
_expect_near(player.position.z, -7.0, "forward action moves current sandbox controller", failures)
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
var camera_distance_before := camera_rig.requested_distance_units
var zoom_event := InputEventMouseButton.new()
zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP
zoom_event.pressed = true
player.call("_unhandled_input", zoom_event)
_expect_near(camera_rig.requested_distance_units, camera_distance_before - 1.0, "zoom action updates camera distance", failures)
var flight_toggle_event := InputEventAction.new()
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
flight_toggle_event.pressed = true
player.call("_unhandled_input", flight_toggle_event)
var movement_controller: LocalPlayerMovementController = player.get("_local_movement_controller")
_expect_true(movement_controller.is_flight_enabled, "flight action preserves immediate sandbox toggle", failures)
player.free()
func _expect_key_binding(
action_name: StringName,
expected_keycode: Key,
uses_physical_keycode: bool,
failures: Array[String]
) -> void:
for input_event in InputMap.action_get_events(action_name):
if input_event is InputEventKey:
var key_event := input_event as InputEventKey
var configured_keycode := key_event.physical_keycode if uses_physical_keycode else key_event.keycode
if configured_keycode == expected_keycode:
return
failures.append("%s missing expected key binding %s" % [action_name, expected_keycode])
func _expect_mouse_binding(
action_name: StringName,
expected_button: MouseButton,
failures: Array[String]
) -> void:
for input_event in InputMap.action_get_events(action_name):
if input_event is InputEventMouseButton and input_event.button_index == expected_button:
return
failures.append("%s missing expected mouse binding %s" % [action_name, expected_button])
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if absf(actual_value - expected_value) > 0.000001:
failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
+138
View File
@@ -0,0 +1,138 @@
extends SceneTree
## Verifies that the pinned AzerothCore spawn is routed through the coordinate
## contract into an executable renderer checkpoint. GUI capture is performed by
## capture_render_checkpoints.gd with the validated dedicated manifest.
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const SERVER_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/server_world_position.gd")
const MANIFEST_PATH := "res://src/tools/server_spawn_render_manifest.json"
const EXPECTED_FIXTURE_PATH := "res://src/tests/fixtures/coordinate_golden_points.json"
const EXPECTED_SPAWN_NAME := "azerothcore_human_warrior_start"
const EXPECTED_PROFILE := "Blizzlike335"
const POSITION_TOLERANCE_YARDS := 0.002
func _initialize() -> void:
var failures: Array[String] = []
var manifest := _load_json_object(MANIFEST_PATH, failures)
var fixture_path := String(manifest.get("source_fixture", ""))
_expect_equal(fixture_path, EXPECTED_FIXTURE_PATH, "source fixture path", failures)
var fixture := _load_json_object(fixture_path, failures) if not fixture_path.is_empty() else {}
if manifest.is_empty() or fixture.is_empty():
_report_and_quit(failures)
return
_expect_equal(int(manifest.get("schema_version", -1)), 1, "manifest schema version", failures)
_expect_equal(String(manifest.get("profile", "")), EXPECTED_PROFILE, "renderer profile", failures)
_expect_equal(String(manifest.get("map", "")), "Azeroth", "renderer map", failures)
_expect_true(ResourceLoader.exists(String(manifest.get("scene", ""))), "renderer scene exists", failures)
_expect_equal(String(manifest.get("source_spawn_name", "")), EXPECTED_SPAWN_NAME, "source spawn name", failures)
var spawn := _find_named_dictionary(fixture.get("server_spawns", []), EXPECTED_SPAWN_NAME)
_expect_true(not spawn.is_empty(), "pinned server spawn exists", failures)
var checkpoints: Array = manifest.get("checkpoints", [])
_expect_equal(checkpoints.size(), 1, "renderer checkpoint count", failures)
if spawn.is_empty() or checkpoints.size() != 1 or not (checkpoints[0] is Dictionary):
_report_and_quit(failures)
return
var checkpoint: Dictionary = checkpoints[0]
var server_values := _three_floats(spawn.get("server_position", null), "server position", failures)
if server_values.is_empty():
_report_and_quit(failures)
return
var server_position = SERVER_WORLD_POSITION_SCRIPT.new(server_values[0], server_values[1], server_values[2])
var canonical_position = COORDINATE_MAPPER_SCRIPT.server_to_canonical(server_position)
var godot_position = COORDINATE_MAPPER_SCRIPT.canonical_to_godot(canonical_position)
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.canonical_to_adt_tile(canonical_position)
var tile_local_position = COORDINATE_MAPPER_SCRIPT.canonical_to_adt_tile_local(canonical_position)
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
var mapped_position := [godot_position.x_units, godot_position.y_units, godot_position.z_units]
_expect_equal(String(checkpoint.get("name", "")), EXPECTED_SPAWN_NAME, "checkpoint name", failures)
_expect_true("server_spawn" in checkpoint.get("coverage", []), "server-spawn coverage", failures)
_expect_true(bool(checkpoint.get("diagnostic_spawn_marker", false)), "diagnostic spawn marker enabled", failures)
_expect_three_near(checkpoint.get("target", null), mapped_position, "checkpoint target", failures)
_expect_three_near(checkpoint.get("player", null), mapped_position, "checkpoint player", failures)
_expect_pair(checkpoint.get("expected_adt_tile", null), [tile_coordinate.tile_x, tile_coordinate.tile_y], "ADT tile", failures)
_expect_pair(checkpoint.get("expected_adt_chunk", null), [chunk_coordinate.chunk_x, chunk_coordinate.chunk_y], "ADT chunk", failures)
_expect_three_near(spawn.get("expected_godot_position", null), mapped_position, "fixture Godot position", failures)
var camera_values := _three_floats(checkpoint.get("camera", null), "checkpoint camera", failures)
if not camera_values.is_empty():
_expect_true(camera_values[1] > godot_position.y_units, "checkpoint camera is above spawn", failures)
_expect_true(Vector3(camera_values[0], camera_values[1], camera_values[2]).distance_to(Vector3(mapped_position[0], mapped_position[1], mapped_position[2])) > 1.0, "checkpoint camera is distinct from spawn", failures)
if not failures.is_empty():
_report_and_quit(failures)
return
print("SERVER_SPAWN_RENDERER PASS spawn=1 checkpoint=1 tile=%d_%d chunk=%d_%d profile=%s" % [
tile_coordinate.tile_x,
tile_coordinate.tile_y,
chunk_coordinate.chunk_x,
chunk_coordinate.chunk_y,
EXPECTED_PROFILE,
])
quit(0)
func _find_named_dictionary(entries: Array, expected_name: String) -> Dictionary:
for entry_variant in entries:
if entry_variant is Dictionary and String(entry_variant.get("name", "")) == expected_name:
return entry_variant
return {}
func _load_json_object(path: String, failures: Array[String]) -> Dictionary:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open JSON: %s" % path)
return {}
var parsed = JSON.parse_string(file.get_as_text())
if not (parsed is Dictionary):
failures.append("JSON is not an object: %s" % path)
return {}
return parsed
func _three_floats(value_variant, label: String, failures: Array[String]) -> Array[float]:
if not (value_variant is Array) or value_variant.size() != 3:
failures.append("%s must contain three numbers" % label)
return []
return [float(value_variant[0]), float(value_variant[1]), float(value_variant[2])]
func _expect_three_near(actual_variant, expected_values: Array, label: String, failures: Array[String]) -> void:
var actual_values := _three_floats(actual_variant, label, failures)
if actual_values.is_empty() or expected_values.size() != 3:
return
for component_index in range(3):
if absf(actual_values[component_index] - float(expected_values[component_index])) > POSITION_TOLERANCE_YARDS:
failures.append("%s[%d] expected %.9f, got %.9f" % [label, component_index, expected_values[component_index], actual_values[component_index]])
func _expect_pair(actual_variant, expected_values: Array, label: String, failures: Array[String]) -> void:
if not (actual_variant is Array) or actual_variant.size() != 2:
failures.append("%s must contain two integers" % label)
return
for component_index in range(2):
_expect_equal(int(actual_variant[component_index]), int(expected_values[component_index]), "%s[%d]" % [label, component_index], failures)
func _expect_equal(actual_value, expected_value, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
func _report_and_quit(failures: Array[String]) -> void:
if failures.is_empty():
failures.append("verification stopped without a diagnostic")
for failure in failures:
push_error("SERVER_SPAWN_RENDERER: %s" % failure)
quit(1)
@@ -0,0 +1 @@
uid://c7f2rpfey0vrs
+101
View File
@@ -0,0 +1,101 @@
extends SceneTree
## Headless M01 contract and wiring regression for camera-independent streaming.
const StreamingFocusScript = preload("res://src/domain/streaming/streaming_focus.gd")
const GodotWorldPositionScript = preload("res://src/domain/coordinates/godot_world_position.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn",
]
const CAPTURE_TOOL_PATHS: Array[String] = [
"res://src/tools/capture_render_checkpoints.gd",
"res://src/tools/probe_render_camera_occluders.gd",
"res://src/tools/probe_render_terrain_height.gd",
]
func _initialize() -> void:
var failures: Array[String] = []
_verify_scene_free_focus_value(failures)
_verify_loader_boundary(failures)
_verify_runtime_scene_wiring(failures)
_verify_capture_tool_wiring(failures)
if not failures.is_empty():
for failure in failures:
push_error("STREAMING_FOCUS: %s" % failure)
quit(1)
return
print("STREAMING_FOCUS PASS contract=1 runtime_scenes=2 capture_tools=3")
quit(0)
func _verify_scene_free_focus_value(failures: Array[String]) -> void:
var position = GodotWorldPositionScript.new(17199.159666667, 83.5312, 26016.616666667)
var focus = StreamingFocusScript.new(position)
_expect_true(focus.world_position == position, "focus retains typed position", failures)
_expect_near(focus.world_position.x_units, 17199.159666667, "focus X", failures)
_expect_near(focus.world_position.y_units, 83.5312, "focus Y", failures)
_expect_near(focus.world_position.z_units, 26016.616666667, "focus Z", failures)
var focus_source := _read_text("res://src/domain/streaming/streaming_focus.gd", failures)
_expect_true(not focus_source.contains("extends Node"), "focus does not inherit Node", failures)
_expect_true(not focus_source.contains("extends Resource"), "focus does not inherit Resource", failures)
_expect_true(not focus_source.contains("Vector3"), "focus does not expose Vector3", failures)
func _verify_loader_boundary(failures: Array[String]) -> void:
var loader_source := _read_text(LOADER_PATH, failures)
for required_text in [
"@export var streaming_focus_source_path: NodePath",
"func set_streaming_focus(streaming_focus: StreamingFocus) -> void:",
"func refresh_streaming_focus(force: bool = false) -> bool:",
]:
_expect_true(loader_source.contains(required_text), "loader contains %s" % required_text, failures)
for forbidden_text in [
"get_viewport().get_camera_3d()",
"child is Camera3D and child.current",
"func _get_stream_camera()",
]:
_expect_true(not loader_source.contains(forbidden_text), "loader omits %s" % forbidden_text, failures)
func _verify_runtime_scene_wiring(failures: Array[String]) -> void:
for scene_path in RUNTIME_SCENE_PATHS:
var scene_source := _read_text(scene_path, failures)
_expect_true(
scene_source.contains('streaming_focus_source_path = NodePath("ThirdPersonPlayer")'),
"%s uses player focus" % scene_path,
failures
)
func _verify_capture_tool_wiring(failures: Array[String]) -> void:
for tool_path in CAPTURE_TOOL_PATHS:
var tool_source := _read_text(tool_path, failures)
_expect_true(tool_source.contains('world.set("streaming_focus_source_path"'), "%s sets explicit focus source" % tool_path, failures)
_expect_true(tool_source.contains('world.call("refresh_streaming_focus", true)'), "%s uses public refresh" % tool_path, failures)
_expect_true(not tool_source.contains('world.call("_refresh_streaming_targets_at"'), "%s avoids private refresh" % tool_path, failures)
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if absf(actual_value - expected_value) > 0.000001:
failures.append("%s expected %.9f, got %.9f" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
+1
View File
@@ -0,0 +1 @@
uid://b7ayw5a0bn1lq
+198
View File
@@ -0,0 +1,198 @@
extends SceneTree
## Headless M02 contract, interpolation, cache and player-injection regression.
const TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/terrain_query.gd"
const ADT_TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/adt_terrain_query.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
var _synthetic_adt_tile_data: Dictionary = {}
var _synthetic_loader_call_count := 0
class FlatTerrainQuery extends TerrainQuery:
var _height_units: float
func _init(height_units: float) -> void:
_height_units = height_units
func sample_ground_height(_godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
return TerrainGroundSample.available(_height_units)
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
_verify_typed_result_contract(failures)
_verify_synthetic_adt_interpolation_and_cache(failures)
_verify_unavailable_tile(failures)
_verify_player_injection(failures)
_verify_source_boundaries(failures)
if not failures.is_empty():
for failure in failures:
push_error("TERRAIN_QUERY: %s" % failure)
quit(1)
return
print("TERRAIN_QUERY PASS contract=4 interpolation=1 cache=1 failures=2 player_injection=1")
quit(0)
func _verify_typed_result_contract(failures: Array[String]) -> void:
var available_sample := TerrainGroundSample.available(123.5)
_expect_true(available_sample.is_available, "available sample state", failures)
_expect_near(available_sample.height_units, 123.5, "available sample height", failures)
_expect_true(available_sample.failure_code.is_empty(), "available sample has no failure", failures)
var unavailable_sample := TerrainGroundSample.unavailable(&"synthetic_failure")
_expect_true(not unavailable_sample.is_available, "unavailable sample state", failures)
_expect_equal(unavailable_sample.failure_code, &"synthetic_failure", "unavailable failure code", failures)
var base_sample := TerrainQuery.new().sample_ground_height(GodotWorldPosition.new(0.0, 0.0, 0.0))
_expect_equal(base_sample.failure_code, &"terrain_query_not_implemented", "base query failure", failures)
var non_finite_sample := TerrainGroundSample.available(NAN)
_expect_true(not non_finite_sample.is_available, "non-finite sample unavailable", failures)
_expect_equal(non_finite_sample.failure_code, &"terrain_height_not_finite", "non-finite failure code", failures)
func _verify_synthetic_adt_interpolation_and_cache(failures: Array[String]) -> void:
var query_position := GodotWorldPosition.new(17000.0, 999.0, 26525.0)
_synthetic_adt_tile_data = _build_synthetic_adt_tile(query_position)
_synthetic_loader_call_count = 0
var terrain_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", _load_synthetic_adt_tile)
var first_sample := terrain_query.sample_ground_height(query_position)
_expect_true(first_sample.is_available, "synthetic ADT sample available", failures)
_expect_near_with_tolerance(first_sample.height_units, 137.0, 0.002, "synthetic bilinear height", failures)
var second_sample := terrain_query.sample_ground_height(query_position)
_expect_near_with_tolerance(second_sample.height_units, 137.0, 0.002, "cached synthetic height", failures)
_expect_equal(_synthetic_loader_call_count, 1, "ADT tile loaded once", failures)
func _verify_unavailable_tile(failures: Array[String]) -> void:
var query_position := GodotWorldPosition.new(17000.0, 0.0, 26525.0)
var empty_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", func(_path: String) -> Dictionary: return {})
var empty_sample := empty_query.sample_ground_height(query_position)
_expect_true(not empty_sample.is_available, "empty ADT unavailable", failures)
_expect_equal(empty_sample.failure_code, &"adt_tile_unavailable", "empty ADT failure", failures)
var invalid_heights_data := _build_synthetic_adt_tile(query_position)
var invalid_chunks: Array = invalid_heights_data.get("chunks", [])
(invalid_chunks[invalid_chunks.size() - 1] as Dictionary)["heights"] = PackedFloat32Array()
var invalid_query := AdtTerrainQuery.new(
"res://synthetic",
"Azeroth",
func(_path: String) -> Dictionary: return invalid_heights_data
)
var invalid_sample := invalid_query.sample_ground_height(query_position)
_expect_equal(invalid_sample.failure_code, &"adt_chunk_heights_invalid", "invalid heights failure", failures)
func _verify_player_injection(failures: Array[String]) -> void:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
_expect_true(packed_scene != null, "regression scene loads", failures)
if packed_scene == null:
return
var player := packed_scene.instantiate() as CharacterBody3D
player.set_terrain_query(FlatTerrainQuery.new(20.0))
root.add_child(player)
player.set_physics_process(false)
_expect_near(player.position.y, 20.05, "injected spawn ground height", failures)
player.position.y = 30.0
player.call("_physics_process", 1.0)
_expect_near(player.position.y, 20.05, "injected physics ground snap", failures)
player.free()
func _verify_source_boundaries(failures: Array[String]) -> void:
var terrain_contract_source := _read_text(TERRAIN_QUERY_PATH, failures)
_expect_true(not terrain_contract_source.contains("extends Node"), "TerrainQuery omits Node", failures)
_expect_true(not terrain_contract_source.contains("Vector3"), "TerrainQuery omits untyped Vector3 position", failures)
var adt_query_source := _read_text(ADT_TERRAIN_QUERY_PATH, failures)
_expect_true(adt_query_source.contains("ADTLoader"), "ADT adapter owns native loader", failures)
_expect_true(adt_query_source.contains("_adt_tile_cache"), "ADT adapter owns tile cache", failures)
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
for forbidden_text in ["ADTLoader", "load_adt", "_adt_cache", "_find_chunk", "_outer_height_index"]:
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
_expect_true(player_source.contains("set_terrain_query"), "player exposes terrain query injection", failures)
_expect_true(player_source.contains("_terrain_query.sample_ground_height"), "player delegates ground sampling", failures)
func _build_synthetic_adt_tile(query_position: GodotWorldPosition) -> Dictionary:
var tile_coordinate := CoordinateMapper.godot_to_adt_tile(query_position)
var tile_local_position := CoordinateMapper.godot_to_adt_tile_local(query_position)
var chunk_coordinate := CoordinateMapper.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
var chunk_size := CoordinateMapper.ADT_CHUNK_SIZE_YARDS
var outer_grid_unit_size := chunk_size / 8.0
var target_chunk_origin := Vector3(
query_position.x_units - outer_grid_unit_size * 4.5,
100.0,
query_position.z_units - outer_grid_unit_size * 3.25
)
var tile_origin := target_chunk_origin - Vector3(
chunk_coordinate.chunk_x * chunk_size,
0.0,
chunk_coordinate.chunk_y * chunk_size
)
var heights := PackedFloat32Array()
heights.resize(145)
for row in range(9):
for column in range(9):
heights[row * 17 + column] = float(row * 10 + column)
var target_chunk := {"origin": target_chunk_origin, "heights": heights}
if chunk_coordinate.chunk_x == 0 and chunk_coordinate.chunk_y == 0:
return {"chunks": [target_chunk]}
return {
"chunks": [
{"origin": tile_origin, "heights": PackedFloat32Array()},
target_chunk,
]
}
func _load_synthetic_adt_tile(_absolute_path: String) -> Dictionary:
_synthetic_loader_call_count += 1
return _synthetic_adt_tile_data
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if absf(actual_value - expected_value) > 0.000001:
failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value])
func _expect_near_with_tolerance(
actual_value: float,
expected_value: float,
tolerance: float,
label: String,
failures: Array[String]
) -> void:
if absf(actual_value - expected_value) > tolerance:
failures.append("%s expected %.6f ± %.6f, got %.6f" % [label, expected_value, tolerance, actual_value])
func _expect_equal(actual_value: Variant, expected_value: Variant, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
+174
View File
@@ -0,0 +1,174 @@
extends SceneTree
## Headless M02 camera state, scene wiring and collision-policy regression.
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const CAMERA_RIG_PATH := "res://src/scenes/player/third_person_camera_rig.gd"
const NO_COLLISION_POLICY_PATH := "res://src/scenes/player/no_camera_collision_policy.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
const RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn",
]
class FixedDistanceCameraCollisionPolicy extends CameraCollisionPolicy:
var _fixed_distance_units: float
func _init(fixed_distance_units: float) -> void:
_fixed_distance_units = fixed_distance_units
func resolve_camera_distance(
_camera_pivot: Node3D,
_requested_distance_units: float
) -> float:
return _fixed_distance_units
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
var player := _instantiate_regression_player(failures)
if player != null:
_verify_initial_state(player, failures)
_verify_zoom_clamps(player, failures)
_verify_orbit_and_capture(player, failures)
_verify_collision_policy(player, failures)
player.free()
_verify_source_boundaries(failures)
_verify_scene_wiring(failures)
if not failures.is_empty():
for failure in failures:
push_error("THIRD_PERSON_CAMERA: %s" % failure)
quit(1)
return
print("THIRD_PERSON_CAMERA PASS state_cases=12 policy_cases=3 scenes=3 player_boundary=1")
quit(0)
func _instantiate_regression_player(failures: Array[String]) -> CharacterBody3D:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
_expect_true(packed_scene != null, "regression scene loads", failures)
if packed_scene == null:
return null
var player := packed_scene.instantiate() as CharacterBody3D
_expect_true(player != null, "regression player instantiates", failures)
if player == null:
return null
root.add_child(player)
player.set_physics_process(false)
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
camera_rig.set_physics_process(false)
return player
func _verify_initial_state(player: CharacterBody3D, failures: Array[String]) -> void:
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
var camera := camera_rig.get_node("Camera3D") as Camera3D
_expect_near(camera_rig.yaw_radians, 0.0, "initial yaw", failures)
_expect_near(camera_rig.pitch_radians, deg_to_rad(-18.0), "initial pitch", failures)
_expect_near(camera_rig.requested_distance_units, 8.0, "initial requested distance", failures)
_expect_near(camera_rig.resolved_distance_units, 8.0, "initial resolved distance", failures)
_expect_near(camera_rig.position.y, 1.7, "initial pivot height", failures)
_expect_near(camera.position.z, 8.0, "initial camera local Z", failures)
_expect_near(camera.far, 50000.0, "camera far plane", failures)
_expect_true(camera_rig.godot_world_flight_movement_basis().is_equal_approx(camera_rig.global_basis), "flight basis is pivot basis", failures)
func _verify_zoom_clamps(player: CharacterBody3D, failures: Array[String]) -> void:
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
var zoom_in_event := _action_event(PlayerInputActions.CAMERA_ZOOM_IN, true)
for _step in range(20):
camera_rig.handle_camera_input(zoom_in_event)
_expect_near(camera_rig.requested_distance_units, 2.0, "minimum zoom clamp", failures)
for _step in range(30):
camera_rig.handle_camera_input(_action_event(PlayerInputActions.CAMERA_ZOOM_OUT, true))
_expect_near(camera_rig.requested_distance_units, 18.0, "maximum zoom clamp", failures)
func _verify_orbit_and_capture(player: CharacterBody3D, failures: Array[String]) -> void:
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
_expect_true(camera_rig.handle_camera_input(_action_event(PlayerInputActions.CAMERA_ROTATE, true)), "capture action consumed", failures)
_expect_true(camera_rig.is_mouse_captured, "mouse capture state", failures)
var mouse_motion := InputEventMouseMotion.new()
mouse_motion.relative = Vector2(100.0, -50.0)
_expect_true(camera_rig.handle_camera_input(mouse_motion), "captured mouse motion consumed", failures)
_expect_near(camera_rig.yaw_radians, -0.3, "orbit yaw", failures)
_expect_near(camera_rig.pitch_radians, deg_to_rad(-18.0) + 0.15, "orbit pitch", failures)
_expect_near(player.rotation.y, -0.3, "character follows orbit yaw", failures)
var clamp_up_motion := InputEventMouseMotion.new()
clamp_up_motion.relative = Vector2(0.0, -100000.0)
camera_rig.handle_camera_input(clamp_up_motion)
_expect_near(camera_rig.pitch_radians, deg_to_rad(35.0), "maximum pitch clamp", failures)
var clamp_down_motion := InputEventMouseMotion.new()
clamp_down_motion.relative = Vector2(0.0, 100000.0)
camera_rig.handle_camera_input(clamp_down_motion)
_expect_near(camera_rig.pitch_radians, deg_to_rad(-65.0), "minimum pitch clamp", failures)
camera_rig.handle_camera_input(_action_event(PlayerInputActions.CAMERA_ROTATE, false))
_expect_true(not camera_rig.is_mouse_captured, "mouse release state", failures)
func _verify_collision_policy(player: CharacterBody3D, failures: Array[String]) -> void:
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
var camera := camera_rig.get_node("Camera3D") as Camera3D
camera_rig.set_collision_policy(FixedDistanceCameraCollisionPolicy.new(3.0))
_expect_near(camera_rig.resolved_distance_units, 3.0, "fixed policy distance", failures)
_expect_near(camera.position.z, 3.0, "fixed policy camera transform", failures)
camera_rig.set_collision_policy(NoCameraCollisionPolicy.new())
_expect_near(camera_rig.resolved_distance_units, 18.0, "identity policy restores request", failures)
func _verify_source_boundaries(failures: Array[String]) -> void:
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
for forbidden_text in ["var _captured", "var _yaw", "var _pitch", "func _apply_camera_transform", "camera_distance"]:
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
_expect_true(player_source.contains("_camera_rig.handle_camera_input"), "player delegates camera input", failures)
var rig_source := _read_text(CAMERA_RIG_PATH, failures)
_expect_true(rig_source.contains("extends Node3D"), "camera rig owns pivot Node3D", failures)
for forbidden_text in ["ADTLoader", "TerrainQuery", "MoveIntent", "AnimationPlayer"]:
_expect_true(not rig_source.contains(forbidden_text), "camera rig omits %s" % forbidden_text, failures)
var no_collision_source := _read_text(NO_COLLISION_POLICY_PATH, failures)
for forbidden_text in ["intersect_ray", "direct_space_state", "PhysicsRayQueryParameters3D"]:
_expect_true(not no_collision_source.contains(forbidden_text), "baseline policy omits %s" % forbidden_text, failures)
func _verify_scene_wiring(failures: Array[String]) -> void:
for scene_path in RUNTIME_SCENE_PATHS + [REGRESSION_SCENE_PATH]:
var scene_source := _read_text(scene_path, failures)
_expect_true(scene_source.contains("third_person_camera_rig.gd"), "%s references camera rig" % scene_path, failures)
_expect_true(scene_source.contains('script = ExtResource("4_camera_rig")') or scene_source.contains('script = ExtResource("2_camera_rig")'), "%s wires CameraPivot script" % scene_path, failures)
func _action_event(action_name: StringName, is_pressed: bool) -> InputEventAction:
var input_event := InputEventAction.new()
input_event.action = action_name
input_event.pressed = is_pressed
return input_event
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if absf(actual_value - expected_value) > 0.000001:
failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
+41 -15
View File
@@ -1,6 +1,7 @@
# M01 — Coordinates and Architecture Seams # M01 — Coordinates and Architecture Seams
<!-- OPENWC_TARGET:ACTIVE --> <!-- OPENWC_TARGET:DONE -->
<!-- OPENWC_TARGET_DONE:M01:work/sindo-main-codex/m01-integrator-closeout:2026-07-14 -->
## Outcome ## Outcome
@@ -8,13 +9,13 @@
## Steps ## Steps
- [ ] Создать domain/value-object слой без `Node`/`Resource` dependencies. - [x] Создать domain/value-object слой без `Node`/`Resource` dependencies.
- [ ] Определить canonical WoW, Godot, ADT tile/chunk и local coordinate types. - [x] Определить canonical WoW, Godot, ADT tile/chunk и local coordinate types.
- [ ] Реализовать единственный `CoordinateMapper` для position и orientation. - [x] Реализовать единственный `CoordinateMapper` для position и orientation.
- [ ] Собрать golden points из ADT, server spawn и оригинального клиента. - [x] Собрать golden points из ADT, server spawn и оригинального клиента.
- [ ] Ввести `StreamingFocus` вместо неявной зависимости от active Camera3D. - [x] Ввести `StreamingFocus` вместо неявной зависимости от active Camera3D.
- [ ] Ввести stable `ContentId`, `EntityId` и отличить их от WoW GUID/server entry. - [x] Ввести stable `ContentId`, `EntityId` и отличить их от WoW GUID/server entry.
- [ ] Запретить ручные axis conversion через code review rule/test search. - [x] Запретить ручные axis conversion через code review rule/test search.
## Verification ## Verification
@@ -28,10 +29,35 @@ Renderer/test scene получают focus через контракт; коор
## Evidence ## Evidence
- Date: - Date: 2026-07-14
- Revision/worktree: - Revision/worktree: merged master `c8e99f2`; closeout
- Commands: `work/sindo-main-codex/m01-integrator-closeout`
- Results: - Commands: `tools/run_render_baseline.ps1 -DryRun -WaitSeconds 0.1
- Fidelity comparison: -MeasureSeconds 0.1`; `verify_coordinate_mapper.gd`;
- Changed files: `verify_coordinate_golden_fixtures.gd`; `verify_streaming_focus.gd`;
- Remaining risks: `verify_domain_identities.gd`; coordination/documentation/diff gates.
- Results: CoordinateMapper PASS (`5` golden points, `8` tile boundaries,
`2` raw MCNK, `5` yaw); golden fixtures PASS (`1` server spawn, `1` raw ADT,
`5` original-client points); StreamingFocus PASS (`1` contract, `2` runtime
scenes, `3` capture tools); identities PASS (`5/5/6/5`); conversion boundary
PASS (`84` files, one native boundary, one oracle, four consumers, six
classifiers); server-spawn renderer PASS at ADT `(32,48)`, MCNK `(3,12)`;
renderer manifest PASS (`7/7/7`) and calibration maximum error `0.000015`.
- Fidelity comparison: five build-12340 camera observations, pinned AzerothCore
Human Warrior spawn and private raw ADT metadata agree through coordinate
contract version 2. Cache-complete cold/warm GUI capture places the mapped
spawn marker on rendered Northshire terrain with focus `(32,48)`, quality
`100/100`, missing cache `0`; PNG SHA-256 values are recorded in the server
spawn handoff. The final ADT-boundary regression passed `2/2`; the full M01
regression captured `14/14`, with `11` strict passes and three low-mean
timing/animation-phase differences rather than coordinate drift.
- Changed files: `src/domain/coordinates/`, `src/domain/identity/`,
`src/domain/streaming/`, migrated sky/player/streaming/terrain consumers,
coordinate/renderer fixtures and tools, runtime scenes, ADR 0001/0002,
coordinate/domain/renderer module specs, coding/render documentation and
M01 coordination claims.
- Remaining risks: TrinityCore populated spawn equivalence is not verified;
native parser conversion remains one separately calibrated boundary; MDDF/
MODF placement Euler parity is outside world-yaw scope; renderer visual parity
gaps and the known anonymous ObjectDB teardown warning continue. No `1:1`
compatibility claim is made by M01.
+1 -1
View File
@@ -1,6 +1,6 @@
# M02 — Player Decomposition # M02 — Player Decomposition
<!-- OPENWC_TARGET:OPEN --> <!-- OPENWC_TARGET:ACTIVE -->
## Outcome ## Outcome
+1 -1
View File
@@ -21,7 +21,7 @@ OpenWC должен предоставить четыре согласованн
- MPQ/BLP/ADT/WDT/M2/WMO native parsing и renderer vertical slice уже существуют. - MPQ/BLP/ADT/WDT/M2/WMO native parsing и renderer vertical slice уже существуют.
- Runtime streaming, terrain quality, MultiMesh M2, WMO caches, liquids, sky и character experiments находятся в рабочем состоянии, но сосредоточены в крупных orchestration scripts. - Runtime streaming, terrain quality, MultiMesh M2, WMO caches, liquids, sky и character experiments находятся в рабочем состоянии, но сосредоточены в крупных orchestration scripts.
- Renderer baseline M00 завершён: paired comparison с оригинальным клиентом записывает измеренные gaps без заявления parity, а обязательные diagnostic gaps закрыты или явно классифицированы. Текущая работа M01 вводит coordinate и architecture seams. - Renderer baseline M00 завершён: paired comparison с оригинальным клиентом записывает измеренные gaps без заявления parity, а обязательные diagnostic gaps закрыты или явно классифицированы. M01 завершил coordinate, identity и streaming-focus seams; текущая работа M02 декомпозирует player input, movement, terrain query, camera и presentation без observable regression.
- Gameplay domain, network protocol, production UI/Lua, audio orchestration и server adapters в основном предстоит реализовать. - Gameplay domain, network protocol, production UI/Lua, audio orchestration и server adapters в основном предстоит реализовать.
- Editor plugin пока решает extraction/preview задачи, но не является полноценной authoring platform. - Editor plugin пока решает extraction/preview задачи, но не является полноценной authoring platform.
+3 -3
View File
@@ -10,7 +10,7 @@
## Current target ## Current target
`M01` — [01-coordinates-and-seams.md](01-coordinates-and-seams.md) `M02` — [02-player-decomposition.md](02-player-decomposition.md)
Одновременно `ACTIVE` может быть только одна цель. Следующая цель становится `ACTIVE` после появления валидной `OPENWC_TARGET_DONE` метки у предыдущей. Одновременно `ACTIVE` может быть только одна цель. Следующая цель становится `ACTIVE` после появления валидной `OPENWC_TARGET_DONE` метки у предыдущей.
@@ -34,8 +34,8 @@
| ID | Цель | Зависит от | Статус | | ID | Цель | Зависит от | Статус |
|---|---|---|---| |---|---|---|---|
| M00 | [Renderer baseline](00-render-baseline.md) | — | DONE | | M00 | [Renderer baseline](00-render-baseline.md) | — | DONE |
| M01 | [Coordinates and architecture seams](01-coordinates-and-seams.md) | M00 | ACTIVE | | M01 | [Coordinates and architecture seams](01-coordinates-and-seams.md) | M00 | DONE |
| M02 | [Player decomposition](02-player-decomposition.md) | M01 | OPEN | | M02 | [Player decomposition](02-player-decomposition.md) | M01 | ACTIVE |
| M03 | [Renderer facade and extraction](03-renderer-facade.md) | M02 | OPEN | | M03 | [Renderer facade and extraction](03-renderer-facade.md) | M02 | OPEN |
| M04 | [Godot Editor shell](04-editor-shell.md) | M03 | OPEN | | M04 | [Godot Editor shell](04-editor-shell.md) | M03 | OPEN |
| M05 | [Content Project](05-content-project.md) | M04 | OPEN | | M05 | [Content Project](05-content-project.md) | M04 | OPEN |
+2
View File
@@ -57,6 +57,8 @@ try {
Invoke-GodotStep "runtime-cache-shutdown" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_runtime_cache_shutdown.gd") Invoke-GodotStep "runtime-cache-shutdown" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_runtime_cache_shutdown.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") Invoke-GodotStep "coordinate-calibration" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_coordinate_calibration.gd")
Invoke-GodotStep "coordinate-boundaries" @("--headless", "--path", ".", "--script", "res://src/tools/verify_coordinate_conversion_boundaries.gd")
Invoke-GodotStep "server-spawn-renderer" @("--headless", "--path", ".", "--script", "res://src/tools/verify_server_spawn_renderer.gd")
$captureArgs = @( $captureArgs = @(
"--path", ".", "--path", ".",