Compare commits

...

23 Commits

Author SHA1 Message Date
sindoring ad0532925a qar(M03): exclude reference projects from Godot imports
Work-Package: M03-QAR-REFERENCE-IMPORTS-001
Agent: sindo-main-codex
Tests: headless Godot rescan; nested reference repositories remain clean
Fidelity: reference-only tooling change; runtime and renderer behavior unchanged
2026-07-15 23:56:15 +04:00
sindoring 9c3f7018fb docs(M03): accept renderer facade integration
Work-Package: M03-RND-FACADE-001
Agent: sindo-main-codex
Tests: post-merge verification follows
Fidelity: no runtime change
2026-07-15 23:49:59 +04:00
sindoring a100cede46 Merge work package M03-RND-FACADE-001
Work-Package: M03-RND-FACADE-001
Agent: sindo-main-codex
Tests: facade/focus/coordinate/manifest contracts, dry-run, coordination and documentation gates
Fidelity: no renderer target, budget, cache or visual behavior change
2026-07-15 23:49:49 +04:00
sindoring 2466fd67d3 docs(M03): publish renderer facade handoff
Work-Package: M03-RND-FACADE-001
Agent: sindo-main-codex
Tests: facade/focus contracts and repository gates pass
Fidelity: no visual or streaming-policy change
2026-07-15 23:49:21 +04:00
sindoring 3d528e3bbf rnd(M03): introduce renderer facade seam
Work-Package: M03-RND-FACADE-001
Agent: sindo-main-codex
Tests: facade/focus/coordinate/manifest contracts, renderer dry-run, coordination and documentation gates
Fidelity: focus targets, budgets, queues, caches and visible renderer behavior remain unchanged
2026-07-15 23:48:53 +04:00
sindoring a629bedccf rnd(M03): claim renderer facade seam
Work-Package: M03-RND-FACADE-001
Agent: sindo-main-codex
Tests: not run (coordination-only commit)
Fidelity: no runtime change
2026-07-15 23:43:50 +04:00
sindoring 4f566b1957 Merge pull request 'qar(M02): close player decomposition milestone' (#28) from work/sindo-main-codex/m02-integrator-closeout into master
Reviewed-on: #28
2026-07-15 22:38:42 +03:00
sindoring d9c847c557 docs(M02): hand off milestone closeout
Work-Package: M02-QAR-INTEGRATOR-CLOSEOUT-001
Agent: sindo-main-codex
Tests: records complete post-merge and renderer verification
Fidelity: records direct, reference-only and open-gap classifications
2026-07-15 23:35:22 +04:00
sindoring 1bf5b526be qar(M02): close player decomposition milestone
Work-Package: M02-QAR-INTEGRATOR-CLOSEOUT-001
Agent: sindo-main-codex
Tests: all M02 contracts, renderer dry-run, documentation and coordination gates passed
Fidelity: direct binding evidence and reference-only timing/routing are separated; remaining locomotion/camera gaps stay explicit
2026-07-15 23:35:00 +04:00
sindoring 9275fbe04f reference, scenes 2026-07-15 23:32:50 +04:00
sindoring eb122d52a9 Merge pull request 'gmp(M02): add 3.3.5 input profile' (#27) from work/sindo-main-codex/m02-input-profile-335 into master
Reviewed-on: #27
2026-07-14 23:20:22 +03:00
sindoring e819eeb35b docs(M02): hand off 3.3.5 input profile
Work-Package: M02-GMP-INPUT-PROFILE-335-001
Agent: sindo-main-codex
Tests: records complete package and renderer verification
Fidelity: records private-source hashes, public reference revisions and remaining gaps
2026-07-15 00:18:03 +04:00
sindoring 8776a6b362 gmp(M02): add 3.3.5 input profile
Work-Package: M02-GMP-INPUT-PROFILE-335-001
Agent: sindo-main-codex
Tests: player input/movement/capability/terrain/camera/presentation, coordinate/streaming, docs/coordination and renderer dry-run passed
Fidelity: build-12340 binding hashes and selected defaults pinned; TrinityCore turn rate and WoWee mouse routing are compatibility references with explicit limits
2026-07-15 00:17:34 +04:00
sindoring c3094c9413 Merge pull request 'gmp(M02): gate debug movement capabilities' (#26) from work/sindo-main-codex/m02-sandbox-capabilities into master
Reviewed-on: #26
2026-07-14 22:58:54 +03:00
sindoring 7d5d13f702 docs(M02): publish movement capability handoff
Work-Package: M02-GMP-SANDBOX-CAPABILITIES-001

Agent: sindo-main-codex
2026-07-14 23:56:00 +04:00
sindoring 742c415885 gmp(M02): gate debug movement capabilities
Work-Package: M02-GMP-SANDBOX-CAPABILITIES-001

Agent: sindo-main-codex

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

Fidelity: proves debug sprint/free-flight exclusion in Blizzlike335; no positive movement parity claim
2026-07-14 23:55:27 +04:00
sindoring 8c71e46872 Merge pull request 'rnd(M02): extract character presenters' (#25) from work/sindo-main-codex/m02-character-presentation into master
Reviewed-on: #25
2026-07-14 22:44:11 +03:00
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
71 changed files with 3215 additions and 365 deletions
+17 -1
View File
@@ -25,7 +25,8 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g
## Основные файлы
- `src/scenes/streaming/streaming_world_loader.gd` - главный runtime streamer мира.
- `src/render/world_render_facade.gd` - стабильная граница runtime/tools для focus и metrics.
- `src/scenes/streaming/streaming_world_loader.gd` - внутренний runtime streamer мира.
- `src/scenes/streaming/eastern_kingdoms_streaming.tscn` - текущая сцена для проверки Azeroth/Eastern Kingdoms.
- `addons/mpq_extractor/loaders/adt_builder.gd` - сборка ADT terrain, control splat материалов и MH2O liquids.
- `addons/mpq_extractor/loaders/m2_builder.gd` - сборка статического M2 mesh/material.
@@ -873,6 +874,21 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- This closes a coordinate/renderer integration criterion only. It does not
claim TrinityCore row equivalence or visual parity with the original client.
## 2026-07-15 WorldRenderFacade First Seam
- Runtime streaming scenes, checkpoint capture and renderer probes now submit
typed focus through `WorldRenderFacade`; those callers no longer call the
monolithic streamer's focus methods or configure its focus source directly.
- Baseline capture reads queue/cache/activity counts through
`renderer_metrics_snapshot()`. The facade returns a deep copy, so diagnostics
cannot mutate renderer-owned state.
- `StreamingWorldLoader` remains the internal implementation and still owns all
queues, tasks, caches, nodes and RIDs. The facade adds no parallel state or
replacement scheduler.
- This package does not alter targets, budgets, cache versions, placement,
visibility, materials or animation. Environment, entity visuals and ground
query remain explicit follow-up facade contracts in M03.
## 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.
+1
View File
@@ -2,6 +2,7 @@
<!-- OPENWC_CLAIM:M02-GMP-INPUT-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M02-GMP-INPUT-001:6bd2e84 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M02-GMP-INPUT-001:14dead1 -->
## Ownership
@@ -0,0 +1,136 @@
# M02-GMP-INPUT-PROFILE-335-001 — WoW 3.3.5a input profile
<!-- OPENWC_CLAIM:M02-GMP-INPUT-PROFILE-335-001:sindo-main-codex:2026-07-17 -->
## Ownership
- Target: M02
- Program: GMP/QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-input-profile-335`
- Lease expires UTC: 2026-07-17
- Integrator: M02 milestone integrator
## Outcome
Add a profile-aware, remappable `Blizzlike335` input path whose default movement
bindings match the extracted build-12340 binding table while preserving the
existing `RenderSandbox` controls. Add deterministic turn intent and keyboard
turn consumption at the server-contract default turn rate.
## Non-goals
- Implement jump/fall/swim, network prediction or server reconciliation.
- Claim complete movement or camera parity from binding and turn-rate evidence.
- Persist user keybindings or build keybinding UI.
- Commit proprietary extracted client files.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/tests/fixtures/player_input_profile_335.json`, this claim
- Shared/hotspots: `project.godot`, `src/domain/input/move_intent.gd`,
`src/gameplay/input/player_input_actions.gd`,
`src/gameplay/input/player_input_source.gd`,
`src/gameplay/movement/local_player_movement_controller.gd`,
`src/scenes/player/third_person_wow_controller.gd`,
`src/scenes/player/third_person_camera_rig.gd`, player/movement verifiers
and regression scene, `docs/modules/player-input.md`,
`docs/modules/local-player-movement.md`, `docs/modules/third-person-camera.md`
- Generated/ignored: local `.godot`, native DLL and proprietary extracted corpus
## Contracts and data
- `MoveIntent` gains an immutable, clamped signed turn axis.
- `PlayerInputSource` selects explicit sandbox or compatibility action sets at
construction and routes compatibility A/D to strafe while camera rotation is held.
- `LocalPlayerMovementController` converts turn intent into a deterministic yaw
delta without reading engine input or owning a scene transform.
- A sanitized JSON fixture records selected binding facts, source hashes and the
pinned TrinityCore 3.3.5 server-contract commit.
## Dependencies
- Requires: merged sandbox capability package on master `c3094c9`.
- Blocks: final M02 integration audit and milestone closeout.
- External state: local extracted build-12340 binding files are evidence inputs
only; committed tests are reproducible without them.
## Verification
- Fixture schema/provenance and selected default-binding regression.
- Pure profile composition, A/D turn, Q/E strafe and camera-held A/D strafe cases.
- Deterministic turn-rate and controller integration regression.
- Existing M02 and repository gates remain required.
## Documentation deliverables
- Updated input/movement public API, profile matrix, data flow, sequence,
failure/recovery, fidelity provenance and source maps.
## Simplicity and naming
- Important names: `turn_axis`, `BLIZZLIKE_STRAFE_LEFT`, `TURN_LEFT`.
- Simplest approach: one profile selection at composition plus one scalar intent.
- Rejected complexity: input-context framework, command bus and persisted binding store.
- Unavoidable complexity: compatibility A/D behavior depends on camera-rotate state.
- Measured optimization evidence: not applicable.
## Status
- State: ready-for-review
- Done: added distinct remappable profiles, signed turn intent/controller path,
camera-yaw synchronization, sanitized provenance fixture, regression coverage
and updated module specifications
- Next: milestone integrator reviews and merges `8776a6b`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M02-GMP-INPUT-PROFILE-335-001:8776a6b -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M02-GMP-INPUT-PROFILE-335-001:eb122d5 -->
## Handoff
- Branch/commit: `work/sindo-main-codex/m02-input-profile-335` at `8776a6b`.
- Outcome: `RenderSandbox` retains A/D strafe and debug E/Q/Space/Shift behavior;
`Blizzlike335` now samples W/S and Up/Down forward/back, Q/E strafe, A/D and
Left/Right turn, with A/D routed to strafe while right-mouse rotation is active.
- Public contracts: `MoveIntent.turn_axis`; profile-selecting
`PlayerInputSource.new(profile_id)`; pure compatibility composition;
`LocalPlayerMovementController.calculate_yaw_delta_radians(...)`; camera yaw
synchronization after keyboard turn. Unknown input profiles fail closed to
`Blizzlike335`.
- Project configuration: four additive compatibility actions; existing action
names and sandbox mappings remain valid. W/S also gain the original Up/Down
secondary defaults.
- Verification: player input `actions=16 intent_cases=9 profiles=3 fixture=1
controller=1 regression_scene=3`; movement `cases=15 state_transitions=2`;
capabilities `profiles=3 sprint=2 flight=2 scenes=2`; terrain
`contract=4 interpolation=1 cache=1 failures=2`; camera
`state_cases=12 policy_cases=3`; presentation `appearance_cases=9
animation_cases=10`; coordinate boundaries `files=101`; StreamingFocus
`runtime_scenes=2 capture_tools=3`.
- Renderer: unified dry-run passed project/material/dedupe/cache/manifest/
calibration/coordinate/server-spawn gates and planned all seven checkpoints;
coordinate gate reported `files=103`. Missing proprietary GLB/DBC/ADT inputs
produced the expected diagnostics only.
- Repository gates: documentation `module_specs=8`; coordination
`fallback_claims=31`, with the same five unrelated expired M00 warnings.
- Fidelity: local build-12340 `DefaultBindings.wtf` SHA-256
`35FFA0E4E2356A13A0633A848F9E57ABE7DF9D8A16C2245E15D33013AD2F56A5`
and `Bindings.xml` SHA-256
`2E01276AFB7462F1417710F13B4BF1A456341F7F28CC8AE0D6D9C7442D7F6793`
were rechecked and selected facts sanitized into the committed fixture.
TrinityCore revision `2853a621d6af91a803787a2b8a509f4ce3e0300d` supplies the
3.141594 rad/s player server-contract turn rate. WoWee revision
`626243e937fb93965fa583a6507ed5a1aa7dda4b` supports right-mouse A/D routing.
- Fidelity limits: server turn rate and public reference routing are not direct
original-client timing/behavior proof. Jump, autorun and left-button
camera/select defaults are recorded but not implemented. This package does
not claim complete input, movement or camera parity.
- Migration/cache: no persisted settings or cache schema. Future saved bindings
must migrate stable action names; current project users receive additive actions.
- Documentation: updated input, movement and camera APIs, inputs/outputs, data
flow, sequence, ownership, failure/recovery, configuration, fidelity and source maps.
- Recommended merge order: merge after sandbox capabilities (already on master),
then run the final M02 integration/evidence audit without marking the target
done solely from this package.
@@ -2,6 +2,7 @@
<!-- OPENWC_CLAIM:M02-GMP-MOVEMENT-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M02-GMP-MOVEMENT-001:435e1c9 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M02-GMP-MOVEMENT-001:5206c42 -->
## Ownership
@@ -0,0 +1,127 @@
# M02-GMP-SANDBOX-CAPABILITIES-001 — Debug movement capability gate
<!-- OPENWC_CLAIM:M02-GMP-SANDBOX-CAPABILITIES-001:sindo-main-codex:2026-07-16 -->
## Ownership
- Target: M02
- Program: GMP/FND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-sandbox-capabilities`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Introduce an immutable local-player movement capability contract and require it
at the movement boundary so debug sprint and free flight execute only in the
explicit `RenderSandbox` profile and are rejected by `Blizzlike335`.
## Non-goals
- Claim that current movement values or `Blizzlike335` locomotion are 1:1.
- Build a global application/profile framework before the application shell exists.
- Remove debug Input Map actions or change their current sandbox bindings.
- Implement jump, fall, swim, collision, server authority or reconciliation.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/gameplay/movement/player_movement_capabilities.gd`,
`src/tools/verify_player_movement_capabilities.gd`, this claim
- Shared/hotspots: `src/gameplay/movement/local_player_movement_controller.gd`,
`src/scenes/player/third_person_wow_controller.gd`, movement/input verifiers,
player regression scene, `docs/modules/local-player-movement.md`,
`docs/modules/player-input.md`
- Generated/ignored: local `.godot`, native DLL, generated resources and renderer corpus
## Contracts and data
- `PlayerMovementCapabilities` owns immutable profile ID plus explicit debug
sprint/flight booleans and safe named factories.
- `LocalPlayerMovementController` requires the capability value; missing input
falls back to `Blizzlike335`, never sandbox.
- Player scene maps its exported profile selection once at the composition boundary.
- Input actions and `MoveIntent` remain unchanged; rejected debug requests have no effect.
## Dependencies
- Requires: merged character presentation package on master `8c71e46`.
- Blocks: final M02 integration audit and milestone closeout.
- External state: none; verification is asset-free.
## Verification
- Pure profile factory/default and immutability regression.
- Sprint accepted in RenderSandbox and ignored in Blizzlike335.
- Flight toggle/vertical basis accepted in RenderSandbox and rejected in Blizzlike335.
- Real regression scene verifies both profile compositions.
- Existing input/movement/terrain/camera/presentation and renderer gates remain required.
- Fidelity evidence: negative proof that debug capabilities cannot affect the
compatibility profile; no positive movement parity claim.
## Documentation deliverables
- Inline public API and profile semantics on the capability/controller boundary.
- Updated movement and input specs: API, inputs/outputs, data flow, lifecycle,
failure/recovery, capability matrix and source map.
## Simplicity and naming
- Important name: `PlayerMovementCapabilities`.
- Simplest approach: one immutable value injected into the existing controller.
- Rejected complexity: autoload singleton, generic feature-flag service and global profile registry.
- Unavoidable complexity: one profile-to-capability mapping at scene composition.
- Measured optimization evidence: not applicable.
## Status
- State: ready-for-review
- Done: added immutable capability contract, gated sprint/free-flight, covered
pure and real-scene profiles and updated movement/input specifications
- Next: milestone integrator reviews and merges `742c415`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M02-GMP-SANDBOX-CAPABILITIES-001:742c415 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M02-GMP-SANDBOX-CAPABILITIES-001:c3094c9 -->
## Handoff
- Branch/commit: `work/sindo-main-codex/m02-sandbox-capabilities` at `742c415`.
- Outcome: debug sprint and free flight now require explicit
`PlayerMovementCapabilities.render_sandbox()`; `Blizzlike335`, unknown and
null capability inputs reject both effects.
- Public contract: immutable profile ID plus `allows_debug_sprint` and
`allows_debug_free_flight`; named factories map configuration once at the
composition boundary. The movement controller exposes its effective profile ID.
- Runtime composition: `ThirdPersonWowController.movement_profile_id` defaults
to `RenderSandbox`, preserving renderer-scene behavior. Selecting
`Blizzlike335` keeps ordinary movement while ignoring Shift/Space/E/Q effects.
- Verification: capability `profiles=3 sprint=2 flight=2 scenes=2 boundary=1`,
movement `cases=12 state_transitions=2`, input `actions=12 intent_cases=6`,
terrain `contract=4 interpolation=1 cache=1 failures=2`, camera
`state_cases=12 policy_cases=3`, presentation
`appearance_cases=9 animation_cases=10`, coordinate boundaries
`files=101 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; the combined
run reported coordinate `files=103`. Missing proprietary GLB/DBC/ADT inputs
produced the expected diagnostics only.
- Repository gates: documentation `module_specs=8`, coordination
`fallback_claims=30`; five unrelated expired M00 claim warnings remain.
- Fidelity: this package provides negative evidence that sandbox-only debug
movement cannot alter the `Blizzlike335` profile. It does not prove ordinary
movement, bindings, flight or timing match build 12340.
- Migration: direct `LocalPlayerMovementController.new(...)` callers that need
debug behavior must now pass `render_sandbox()`; omitted/null capability is
intentionally compatibility-safe. Existing repository scenes require no edit.
- Cache/persistence: no schema, cache, binding or stored-profile migration.
- Local ignored inputs: native extension DLL, UID cache and generated ADT
resource scripts supported renderer verification and are not committed.
- Risks: runtime renderer scenes still default to `RenderSandbox`; the future
application shell must explicitly choose its profile. `Blizzlike335` currently
guarantees only debug exclusion, not complete movement fidelity.
- Recommended merge order: merge after presentation (already on master), then
perform the final M02 integration/evidence audit before any target closeout.
- Documentation: updated movement/input API, inputs/outputs, data flow, state,
sequence, ownership, failure/recovery, capabilities, verification and source maps.
@@ -2,6 +2,7 @@
<!-- OPENWC_CLAIM:M02-GMP-TERRAIN-QUERY-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M02-GMP-TERRAIN-QUERY-001:a45d521 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M02-GMP-TERRAIN-QUERY-001:f5bb64e -->
## Ownership
@@ -0,0 +1,111 @@
# M02-QAR-INTEGRATOR-CLOSEOUT-001 — M02 milestone closeout
<!-- OPENWC_CLAIM:M02-QAR-INTEGRATOR-CLOSEOUT-001:sindo-main-codex:2026-07-17 -->
## Ownership
- Target: M02
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-integrator-closeout`
- Lease expires UTC: 2026-07-17
- Integrator: sindo-main-codex, continuing the user-directed milestone workflow on 2026-07-15
## Outcome
Audit every merged M02 package on current master, record final structural and
fidelity evidence, close M02 and activate M03 only when all target and repository
gates pass.
## Non-goals
- Change player, movement, terrain, camera, presentation or renderer behavior.
- Claim complete WoW 3.3.5a locomotion/camera parity.
- Implement recorded jump/fall/swim/fly, collision or timing gaps.
- Commit proprietary assets, generated UIDs, caches or user scene changes.
## Paths
- Exclusive: M02/M03 target status, M02 final Evidence, all M02 claim acceptance markers
- Shared/hotspots: `targets/README.md`, `targets/DEVELOPMENT_ROADMAP.md`, this claim
- Generated/ignored: local Godot imports, native DLL, renderer corpus and `user://` reports
## Contracts and data
- Runtime APIs, Input Map actions, fixture schemas and cache formats: unchanged
- Consumers: milestone sequence and future M03 renderer-facade packages
- Fidelity classification distinguishes direct build-12340 binding evidence,
server/reference compatibility evidence and explicitly unverified gaps
## Dependencies
- Requires: all seven M02 READY packages integrated through master `eb122d5`
- Blocks: M03 activation
- External state: private original-client files are hash/provenance inputs only
## Verification
- Commands: all M02 input/movement/capability/terrain/camera/presentation tests,
coordinate/streaming boundaries, unified renderer dry-run, documentation,
coordination and diff hygiene
- Fidelity evidence: build-12340 default binding hashes and selected facts;
pinned TrinityCore turn rate; public reference routing; explicit remaining gaps
- Performance: no runtime change; renderer baseline remains the performance guard
## Documentation deliverables
- M02 final target Evidence/checklist and limitation statement
- M02 claim integration-acceptance markers
- Target index and roadmap transition to M03
- Existing API/module specifications and diagrams audited without behavioral edits
## Simplicity and naming
- Important names introduced: none beyond the closeout work-package ID
- Simplest approach: evidence/status-only integrator patch
- Rejected complexity: behavioral fixes or a new closeout framework
- Unavoidable complexity: explicit evidence classification prevents false 1:1 claims
## Status
- State: ready-for-review
- Done: accepted seven M02 packages, reran post-merge gates, recorded complete
target Evidence, closed M02 and activated M03
- Next: merge closeout commit `1bf5b52` before starting an M03 implementation claim
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M02-QAR-INTEGRATOR-CLOSEOUT-001:1bf5b52 -->
## Handoff
- Commit: `1bf5b52`.
- Branch: `work/sindo-main-codex/m02-integrator-closeout`.
- Outcome: M02 is `DONE` with all eight structural checklist items and complete
classified Evidence; M03 is the only `ACTIVE` target and the target index/
roadmap point to renderer-facade extraction.
- Integration audit: all seven READY implementation commits are ancestors of
merged master `eb122d5`; their claims now carry the corresponding
`OPENWC_INTEGRATION:ACCEPTED` merge markers.
- Verification: input `16/9/3/1/3`, movement `15` cases and `2` transitions,
capability `3` profiles and `2` scenes, terrain `4/1/1/2`, camera `12/3`,
presentation `9/10/3`, coordinate boundaries `103` files and StreamingFocus
`2` runtime scenes/`3` capture tools all passed. Unified renderer dry-run
passed project/material/dedupe/shutdown gates, manifest `7/7/7`, calibration
maximum error `0.000015`, server-spawn renderer and seven checkpoint plans.
- Repository gates: documentation passed with `8` module specs and `7` required
files. Coordination passed with one active target; `13` expired M00 claim
warnings are historical and unrelated to M02.
- Fidelity: direct build-12340 binding hashes/facts are preserved in the
sanitized fixture. TrinityCore turn rate and WoWee mouse routing remain
explicitly reference-only. Translation speeds/camera values are sandbox
regressions, and jump/fall/swim/fly parity remains unimplemented/unverified.
Debug sprint/free flight is negatively proven absent from `Blizzlike335`.
- Remaining risks: target Evidence carries exact follow-up scope for M09/M10/M12:
movement/camera timing, jump/fall/swim/fly, autorun/LMB semantics, active
collision, terrain/liquid policy, persisted bindings, server authority and
prediction/reconciliation. No complete `1:1` claim is made.
- Runtime/cache/migration: no behavioral change, dependency, cache schema or
persisted settings migration in closeout.
- Documentation: M02 target Evidence/checklist, seven acceptance markers,
target index and roadmap state updated. Existing input, movement, terrain,
camera and presentation API/module specs and diagrams passed the documentation gate.
+119
View File
@@ -0,0 +1,119 @@
# 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 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M02-RND-CAMERA-001:68e6f60 -->
## 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,125 @@
# 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 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M02-RND-CHARACTER-PRESENTATION-001:8c71e46 -->
## 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.
+117
View File
@@ -0,0 +1,117 @@
# M03-RND-FACADE-001 — Renderer facade first seam
<!-- OPENWC_CLAIM:M03-RND-FACADE-001:sindo-main-codex:2026-07-17 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-renderer-facade`
- Lease expires UTC: 2026-07-17
- Integrator: M03 milestone integrator
## Outcome
Introduce `WorldRenderFacade` as the only runtime/tool caller surface for typed
streaming focus updates and read-only renderer metrics, while retaining
`StreamingWorldLoader` as the unchanged internal implementation.
## Non-goals
- Extract the pure target planner or budget scheduler in this package.
- Add environment or entity-presentation commands before their contracts exist.
- Replace the M02 `TerrainQuery` gameplay boundary in this package.
- Change streaming radii, queues, cache formats, visuals or quality presets.
- Mark M03 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/render/world_render_facade.gd`,
`src/tools/verify_world_render_facade.gd`, this claim
- Shared/hotspots: both runtime streaming scenes, three renderer capture/probe
tools, `src/tools/verify_streaming_focus.gd`, `docs/modules/world-renderer.md`,
`docs/modules/README.md`, `RENDER.md`
- Generated/ignored: local `.godot`, native build outputs, caches and proprietary
renderer corpus
## Contracts and data
- Public API: `set_streaming_focus`, `refresh_streaming_focus`,
`renderer_metrics_snapshot`
- Scene adapter: explicit `streaming_focus_source_path` owned by the facade
- Internal implementation seam: `streaming_world_loader_path`; callers must not
read streamer queues or call its methods directly
- Schema/cache/coordinate contracts: unchanged
- Consumers: runtime scenes, capture tool and renderer probes
## Dependencies
- Requires: M01 `StreamingFocus` and merged M02 closeout on master `4f566b1`
- Blocks: environment/entity/ground facade contracts and planner extraction
- External state: none; contract verification uses a test double
## Verification
- Commands: dedicated facade verifier, streaming-focus regression, renderer
baseline manifest/dry-run, coordinate boundary and repository gates
- Fixtures: typed focus, source Node3D and read-only metrics test double
- Fidelity evidence: no renderer configuration, planner, queue, placement,
visibility, material, animation or cache behavior changes
- Performance budget: one source position sample per process frame; target
recomputation remains throttled inside the existing streamer
## Documentation deliverables
- Inline public API docs for facade properties and methods
- Updated world-renderer module specification
- Updated data-flow, sequence and dependency diagrams
- Updated source map/status and renderer notes
## Simplicity and naming
- Important names: `WorldRenderFacade`, `renderer_metrics_snapshot`
- Simplest approach: one small Node adapter delegating to the existing streamer
- Rejected complexity: service container, generic command bus, streamer rewrite
or duplicate queue/cache state
- Unavoidable complexity: scene-relative source and implementation paths are
resolved on the main thread with explicit diagnostics
- Measured optimization evidence: not applicable; behavior-preserving seam
## Status
- State: ready-for-review
- Done: introduced the facade, migrated two runtime scenes and three renderer
tools, added regression coverage and updated renderer documentation
- Next: M03 integrator reviews and merges `3d528e3`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-FACADE-001:3d528e3 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-FACADE-001:a100ced -->
## Handoff
- Commit: `3d528e3`
- Integration: accepted in master merge `a100ced`
- Results: `WorldRenderFacade` is the runtime/tool surface for typed focus and
detached metrics; the existing streamer remains the sole owner of queues,
caches, tasks, nodes and RIDs.
- Verification: facade `delegation=3 runtime_scenes=2 tools=3`; streaming focus
`contract=1 runtime_scenes=2 capture_tools=3`; coordinate boundary
`files=105 native_boundary=1 oracle=1 consumers=5 classifier_cases=6`;
render manifest `checkpoints=7 coverage=7 caches=7`; seven-checkpoint headless
dry-run; coordination/documentation/diff gates passed.
- Fidelity: no changes to renderer target calculation, budgets, queue order,
cache versions, placement, visibility, materials or animation. The dry-run
retained all seven checkpoint plans. No build-12340 parity claim is added.
- External/local inputs: ignored native DLL and generated ADT resource scripts
were copied from master only to run the worktree smoke. Proprietary extracted
ADT and character GLB data were unavailable, producing the expected dry-run
degraded-data diagnostics.
- Remaining risks: environment, entity visuals and ground query are not yet
facade contracts; streamer exported configuration remains transitional scene
composition; focus-source sampling adds one small main-thread adapter step per
process frame while target refresh remains streamer-throttled.
- Documentation updated: inline facade API, `docs/modules/world-renderer.md`
public API/inputs/outputs/data-flow/sequence/ownership/recovery/status/source
map, and `RENDER.md` file map plus implementation note.
+2
View File
@@ -12,6 +12,8 @@
| 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) |
| 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) |
+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`.
+67 -20
View File
@@ -5,17 +5,18 @@
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M02 / M02-GMP-MOVEMENT-001 |
| Target/work package | M02 / M02-GMP-MOVEMENT-001, M02-GMP-SANDBOX-CAPABILITIES-001, M02-GMP-INPUT-PROFILE-335-001 and presentation consumers |
| Owners | Gameplay local movement state; scene composition owns the instance |
| Last verified | `a45d521`, 2026-07-14 |
| Profiles/capabilities | Current render sandbox; production/debug profile gate pending |
| Last verified | M02-GMP-INPUT-PROFILE-335-001 worktree, 2026-07-15 |
| Profiles/capabilities | `RenderSandbox` debug movement; `Blizzlike335` selected bindings and keyboard turn with debug sprint/flight rejected |
## 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.
Godot-world movement basis into deterministic per-tick displacement and convert
signed turn intent into a yaw delta without querying terrain, cameras, input
devices or visual assets.
## Non-goals
@@ -32,13 +33,16 @@ flowchart LR
Input[MoveIntent] --> Movement[LocalPlayerMovementController]
Basis[Explicit local movement Basis] --> Movement
Delta[Physics delta seconds] --> Movement
Profile[Scene profile ID] --> Capabilities[PlayerMovementCapabilities]
Capabilities --> Movement
Movement --> Displacement[Godot-world displacement]
Movement --> Yaw[Godot yaw delta radians]
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 animation adapter]
Velocity --> Presentation[Visual facing and CharacterAnimationPresenter adapter]
```
Allowed dependencies:
@@ -57,11 +61,16 @@ Forbidden dependencies:
| 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 |
| `PlayerMovementCapabilities.render_sandbox()` | Factory | Creates the only debug sprint/free-flight capability set | Immutable caller value | None |
| `PlayerMovementCapabilities.blizzlike_335()` | Factory | Creates the compatibility-safe set with debug movement disabled | Immutable caller value | Name is intent, not a parity claim |
| `PlayerMovementCapabilities.for_profile_id(...)` | Factory | Maps composition configuration once; unknown IDs fail closed | Immutable caller value | Unknown maps to `Blizzlike335` |
| `LocalPlayerMovementController.new(...)` | Constructor | Sets explicit speeds, acceleration, sprint multiplier and movement capabilities | Created by composition root; retained for player scene lifetime | Null capability defaults to `Blizzlike335` |
| `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 |
| `movement_profile_id` | Read-only state | Effective capability profile ID | Controller lifetime | None |
| `toggle_sandbox_flight()` | Command | Toggles flight only when explicitly permitted and clears velocity | Owning physics/input thread | Rejected request returns `false` with state unchanged |
| `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 |
| `calculate_yaw_delta_radians(move_intent, delta_seconds)` | Pure query | Converts signed turn input to Godot yaw delta | Owning physics thread | Negative delta is treated as zero |
Constructor units:
@@ -73,17 +82,20 @@ Constructor units:
| `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 |
| `keyboard_turn_speed_radians_per_second` | Positive keyboard yaw speed; defaults to PI rad/s |
## 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 scene adapter | Movement controller | Value copy | One physics tick |
| Input | `PlayerMovementCapabilities` | Application/player scene composition | Movement controller | Immutable caller-held value | Controller lifetime |
| 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/animation adapter | Read-only value copy | Until next update |
| Output | Yaw delta radians | Movement controller | Player scene adapter | Scalar value | Applied before movement basis selection |
| 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:
@@ -97,9 +109,14 @@ Side effects:
```mermaid
flowchart LR
Intent[MoveIntent axes] --> Target[Calculate target velocity]
Intent --> Turn[Calculate signed yaw delta]
Turn --> SceneYaw[Scene applies character yaw]
Basis[Player or camera-pivot Basis] --> Directions[Forward/right directions]
Directions --> Target
Config[Speeds and sprint multiplier] --> Target
Profile[RenderSandbox or Blizzlike335] --> Capability[Immutable debug capabilities]
Capability -->|allow or reject sprint| Target
Capability -->|allow or reject flight toggle| Current
Previous[Owned velocity] --> Integrate[move_toward by acceleration × delta]
Target --> Integrate
Integrate --> Current[Updated velocity]
@@ -112,7 +129,8 @@ flowchart LR
```mermaid
stateDiagram-v2
[*] --> GroundedSandbox
GroundedSandbox --> FlyingSandbox: toggle / clear velocity
GroundedSandbox --> FlyingSandbox: toggle [debug flight allowed] / clear velocity
GroundedSandbox --> GroundedSandbox: toggle [debug flight rejected]
FlyingSandbox --> GroundedSandbox: toggle / clear velocity
GroundedSandbox --> GroundedSandbox: advance
FlyingSandbox --> FlyingSandbox: advance
@@ -128,8 +146,14 @@ sequenceDiagram
participant Input as PlayerInputSource
participant Scene as ThirdPersonWowController
participant Move as LocalPlayerMovementController
participant Present as Facing/Animation adapter
participant Cap as PlayerMovementCapabilities
participant Present as CharacterAnimationPresenter adapter
Input-->>Scene: MoveIntent
Scene->>Cap: map configured profile once
Scene->>Move: construct with immutable capabilities
Scene->>Move: calculate_yaw_delta_radians(intent, delta)
Move-->>Scene: yaw delta
Scene->>Scene: apply yaw and synchronize camera orbit yaw
Scene->>Move: advance(intent, selected basis, delta)
Move-->>Scene: displacement
Scene->>Scene: apply global position and ground snap
@@ -140,6 +164,8 @@ sequenceDiagram
## Ownership, threading and resources
- The player scene creates and exclusively owns one movement controller.
- The composition root creates one immutable capability value; the controller
retains it and exposes only its effective profile ID.
- 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.
@@ -153,6 +179,8 @@ sequenceDiagram
| Negative physics delta | `advance` guard | Returns zero displacement; velocity unchanged | Unit regression | Resume with valid non-negative delta |
| Missing scene basis owner | Scene composition defect | Controller cannot be called correctly | Scene/parser error or boundary test | Restore explicit scene dependency |
| Flight toggle during motion | Command path | Velocity clears before next step | State-transition regression | Continue from zero velocity |
| Flight/sprint request in `Blizzlike335` | Capability check | Request has no effect | Profile regression | Select explicit `RenderSandbox` only for tooling/debug scenes |
| Unknown/null profile capability | Factory/constructor safe default | Uses `Blizzlike335`; debug movement disabled | Capability regression | Correct composition profile ID |
| 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
@@ -169,6 +197,10 @@ controller resets velocity and flight state deterministically.
| 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 |
| Debug sprint permission | Enabled | `RenderSandbox` only | Immutable | Allows sprint request multiplier |
| Debug free-flight permission | Enabled | `RenderSandbox` only | Immutable | Allows toggle and vertical movement |
| Compatibility-safe debug permissions | Disabled | `Blizzlike335` | Immutable | Ignores sprint/toggle requests |
| Keyboard turn rate | `3.141594` rad/s | `Blizzlike335` composition | Fixed for controller lifetime | Server-contract default applied to signed turn intent |
## Persistence, cache and migration
@@ -187,13 +219,17 @@ separate versioned movement snapshot contract.
- 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.
pitched camera basis, state reset, signed keyboard turn and invalid delta.
- Capability: `verify_player_movement_capabilities.gd` covers three profile-ID
cases, safe defaults, sprint/flight allow/reject behavior and two real scene compositions.
- 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.
- Fidelity evidence: translation defaults remain regression-locked against the
pre-extraction sandbox. The compatibility keyboard turn default is pinned to
TrinityCore 3.3.5 `playerBaseMoveSpeed[MOVE_TURN_RATE]`; that server contract
does not alone prove original-client timing parity.
- Performance budget: constant-time vector math, no per-tick I/O or scene lookup;
`MoveIntent` allocation remains owned by the input package.
@@ -202,7 +238,8 @@ separate versioned movement snapshot contract.
- 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.
- A future application profile can map to this narrow capability value without
coupling the movement controller to the application shell.
## Capability status
@@ -210,7 +247,9 @@ separate versioned movement snapshot contract.
|---|---|---|---|
| Scene-free local velocity state | Implemented | Pure controller and source boundary regression | Integrate target checklist after review |
| Existing planar acceleration/speeds | Implemented | Exact numeric unit cases and real scene regression | Compare with build 12340 before fidelity claim |
| Existing camera-relative free flight | Implemented | Pitched-basis and toggle/reset cases | Restrict to sandbox profile |
| Compatibility keyboard turn | Implemented | Pinned server-core value, signed pure cases and real-scene profile regression | Direct original-client timing comparison remains required |
| Existing camera-relative free flight | Implemented in `RenderSandbox` | Pitched-basis, profile and toggle/reset cases | Original-client flight semantics remain out of scope |
| Typed sprint/free-flight exclusion | Implemented | Pure and real-scene profile regressions | Application shell must select profile explicitly later |
| 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 |
@@ -218,12 +257,15 @@ separate versioned movement snapshot contract.
## Known gaps and risks
- Numeric defaults preserve the existing sandbox but are not original-client evidence.
- Translation/acceleration defaults preserve the existing sandbox and 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.
- Runtime renderer scenes intentionally default to `RenderSandbox`; a future
game-client composition must explicitly select `Blizzlike335`.
- `Blizzlike335` currently means only debug movement exclusion and must not be
interpreted as evidence that remaining movement semantics are 1:1.
- The module uses Godot math value types; a future engine-independent gameplay
predictor may require scalar/domain movement contracts.
@@ -232,11 +274,16 @@ separate versioned movement snapshot contract.
| Path | Responsibility |
|---|---|
| `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration |
| `src/gameplay/movement/player_movement_capabilities.gd` | Immutable profile-to-debug-capability contract and safe defaults |
| `src/gameplay/terrain/terrain_query.gd` | Replaceable ground-height input boundary |
| `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain/presentation adapters |
| `src/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/tools/verify_player_movement_capabilities.gd` | Profile factory, allow/reject and real-scene regression |
| `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture |
| `src/tools/verify_player_input.gd` | Input-to-movement scene regression |
| `src/tests/fixtures/player_input_profile_335.json` | Turn-rate and selected binding provenance |
## Related decisions and references
+63 -35
View File
@@ -5,24 +5,25 @@
| Field | Value |
|---|---|
| Status | Partial |
| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 consumer update |
| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-SANDBOX-CAPABILITIES-001, M02-GMP-INPUT-PROFILE-335-001 |
| Owners | Gameplay input boundary; `sindo-main-codex` for current package |
| Last verified | `435e1c9`, 2026-07-14 |
| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified |
| Last verified | M02-GMP-INPUT-PROFILE-335-001 worktree, 2026-07-15 |
| Profiles/capabilities | Distinct RenderSandbox and Blizzlike335 action layouts; debug execution remains RenderSandbox-only |
## 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.
`MoveIntent` that can be consumed by the independently testable local movement
controller. Select either the preserved renderer-sandbox layout or the verified
build-12340 movement bindings at the scene composition boundary.
## 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.
- Implement jump, autorun, left-button select/move or persisted binding settings.
- Remove process-wide debug actions; Blizzlike335 sampling ignores them and the
movement capability boundary also rejects directly constructed debug requests.
## Context and boundaries
@@ -30,10 +31,12 @@ independently testable local movement controller.
flowchart LR
Devices[Keyboard and mouse] --> InputMap[Godot Input Map]
InputMap --> Source[PlayerInputSource]
Profile[RenderSandbox or Blizzlike335] --> Source
Source --> Intent[MoveIntent]
Intent --> Movement[LocalPlayerMovementController]
Profile[PlayerMovementCapabilities] --> Movement
Movement --> Controller[ThirdPersonWowController adapter]
Controller --> Camera[Current camera adapter]
Controller --> Camera[ThirdPersonCameraRig]
```
Allowed dependencies:
@@ -52,26 +55,30 @@ Forbidden dependencies:
| 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` | Immutable value | Carries normalized forward, strafe, vertical and turn 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.new(profile_id)` | Constructor | Selects sandbox or compatibility action layout once | Scene lifetime | Unknown IDs fail closed to `Blizzlike335` |
| `PlayerInputSource.sample_move_intent()` | Adapter method | Samples the selected action layout 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 |
| `PlayerInputSource.compose_blizzlike_335_move_intent(...)` | Pure factory | Composes Q/E strafe, A/D turn and right-mouse A/D strafe semantics | 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 | Current scene controller | Engine-owned event | Current input dispatch only |
| 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` | Current scene controller | Static constants | Process lifetime |
| Output | Debug sprint request/vertical axes | `PlayerInputSource` | Capability-gated movement controller | Immutable intent fields | One physics tick |
| Output | Signed turn axis | `PlayerInputSource` | `LocalPlayerMovementController` | Immutable scalar | 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.
- The existing controller still mutates player/camera transforms and mouse mode;
those responsibilities are intentionally not moved by this package.
- `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
@@ -81,8 +88,11 @@ flowchart LR
Project[project.godot action defaults] --> Map[InputMap]
Remap[User or editor remapping] --> Map
Map -->|movement strengths| Source[PlayerInputSource]
Profile[Selected input profile] --> Source
Map -->|camera events| Camera[ThirdPersonCameraRig]
Source -->|clamp cancel normalize| Intent[MoveIntent]
Intent --> Consumer[LocalPlayerMovementController]
Profile[PlayerMovementCapabilities] --> Consumer
Consumer --> Adapter[ThirdPersonWowController]
```
@@ -91,7 +101,9 @@ flowchart LR
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.
the movement controller and is not hidden in the input adapter. RenderSandbox
retains debug requests in its intent; Blizzlike335 does not sample them, and the
immutable movement capability also rejects directly constructed requests.
## Main sequence
@@ -105,7 +117,7 @@ sequenceDiagram
Engine->>Source: movement action strengths
Source->>Intent: compose normalized axes and requests
Source-->>Player: sample_move_intent()
Player->>Move: advance(intent, selected basis, delta)
Player->>Move: calculate yaw delta, then advance(intent, selected basis, delta)
Move-->>Player: displacement and velocity state
```
@@ -126,6 +138,7 @@ sequenceDiagram
| 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 |
| Debug request in `Blizzlike335` | Input profile and movement capability checks | Input adapter omits it; directly constructed request has no effect | Profile/capability regressions | Use `RenderSandbox` only when debug movement is intended |
There is no asynchronous operation to cancel and no persisted state to roll back.
@@ -133,12 +146,15 @@ There is no asynchronous operation to cancel and no persisted state to roll back
| 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 |
| Forward/backward | Physical W/S and Up/Down | Both | Yes, through Input Map | Produces signed forward axis |
| Strafe left/right | Physical A/D | `RenderSandbox` | Yes | Preserves renderer baseline |
| Strafe left/right | Physical Q/E | `Blizzlike335` | Yes | Matches selected build-12340 defaults |
| Keyboard turn | Physical A/D and Left/Right | `Blizzlike335` | Yes | Produces signed turn axis |
| Camera-held A/D | Physical A/D while right mouse is held | `Blizzlike335` | Yes | Produces strafe and suppresses keyboard turn |
| 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 |
| Debug sprint | Shift | Executable only in `RenderSandbox` | Yes | Requests multiplier; `Blizzlike335` ignores it |
| Debug flight up/down/toggle | E/Q/Space | Executable only in `RenderSandbox` | Yes | Requests free flight; `Blizzlike335` remains grounded |
## Persistence, cache and migration
@@ -156,13 +172,20 @@ provide a settings migration once user settings are persisted.
## Verification
- Unit/contract tests: `src/tools/verify_player_input.gd` validates all actions,
default bindings, axis cancellation/clamping/normalization and debug request flags.
both profile defaults, axis cancellation/clamping/normalization, turn routing,
safe profile fallback, fixture provenance 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.
the verifier, which drives sandbox forward/camera/flight plus compatibility
keyboard turn and right-mouse strafe through real scene controllers.
- Profile integration: `verify_player_movement_capabilities.gd` drives sprint
and flight actions through real `RenderSandbox` and `Blizzlike335` scene instances.
- Fidelity evidence: sanitized fixture `player_input_profile_335.json` pins the
private build-12340 `DefaultBindings.wtf`/`Bindings.xml` hashes and selected
movement/camera commands. TrinityCore 3.3.5 revision
`2853a621d6af91a803787a2b8a509f4ce3e0300d` pins the server-contract player
turn rate. WoWee revision `626243e937fb93965fa583a6507ed5a1aa7dda4b`
supports right-mouse A/D strafe routing. Both are compatibility references,
not direct original-client timing/conditional-behavior proof.
- 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
@@ -183,16 +206,16 @@ provide a settings migration once user settings are persisted.
|---|---|---|---|
| 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 |
| Selected 3.3.5a movement defaults | Implemented | Pinned private-source hashes, sanitized selected bindings and profile regressions | Jump, autorun and left-button behavior remain planned |
| Compatibility-profile exclusion of sprint/flight | Implemented | Typed capability and two real-scene regressions | Application shell selection remains future work |
| 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 input is action-mapped but camera state/collision remains in the monolithic controller.
- Only selected movement/camera binding facts are implemented; the fixture explicitly records unimplemented defaults.
- Input Map still exposes debug bindings process-wide; the movement consumer now
rejects their effects outside `RenderSandbox`.
- Camera state is extracted; active collision remains an explicit planned policy.
- There is no persisted user keybinding format or UI yet.
## Source map
@@ -204,12 +227,17 @@ provide a settings migration once user settings are persisted.
| `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_wow_controller.gd` | Composition and camera/input-event adapter |
| `src/gameplay/movement/player_movement_capabilities.gd` | Defense-in-depth execution gate for debug requests |
| `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/tests/fixtures/player_input_profile_335.json` | Sanitized build-12340 binding and server turn-rate provenance |
| `src/tools/verify_player_input.gd` | Headless contract/integration regression |
| `src/tools/verify_player_movement_capabilities.gd` | Sandbox versus compatibility-profile 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`.
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`, and the
pinned sources recorded in `player_input_profile_335.json`.
+257
View File
@@ -0,0 +1,257 @@
# Third-Person Camera
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M02 / M02-RND-CAMERA-001 and M02-GMP-INPUT-PROFILE-335-001 consumer update |
| Owners | Camera presentation state and CameraPivot/Camera3D lifecycle |
| Last verified | M02-GMP-INPUT-PROFILE-335-001 worktree, 2026-07-15 |
| 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 |
| `synchronize_yaw_from_character()` | Composition method | Refreshes orbit yaw after keyboard movement turns the character | Main physics thread | Missing character leaves yaw unchanged |
| `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 | Keyboard-turn synchronization | Player scene after yaw application | Camera rig | Synchronous notification | Current physics tick |
| 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.
- Input-profile integration verifies keyboard yaw synchronization, preventing a
later right-mouse orbit from restoring stale character yaw.
- 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`.
+37 -18
View File
@@ -5,9 +5,9 @@
| Field | Value |
|---|---|
| Status | Partial |
| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; декомпозиция M02–M03 |
| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; `M03-RND-FACADE-001` |
| Owners | Renderer workstream / milestone integrator |
| Last verified | Worktree `work/sindo-main-codex/m01-server-spawn-renderer`, 2026-07-13 |
| Last verified | Worktree `work/sindo-main-codex/m03-renderer-facade`, 2026-07-15 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose
@@ -32,7 +32,8 @@ flowchart LR
SpawnFixture[Pinned server spawn fixture] --> SpawnManifest[Spawn render manifest]
SpawnManifest --> Capture
Adapter --> Focus
Focus --> Loader[StreamingWorldLoader]
Focus --> Facade[WorldRenderFacade]
Facade --> Loader[StreamingWorldLoader internal]
Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders]
Cache[Baked terrain/M2/WMO caches] --> Loader
Native --> Parsed[Parsed tile/model data]
@@ -60,36 +61,43 @@ Forbidden dependencies:
## Public API
Текущая система ещё не имеет стабильного facade. Фактический integration surface — `StreamingWorldLoader` Node3D и exported properties. M03 должен заменить этот surface на `WorldRenderFacade`.
Runtime, capture and probe callers use `WorldRenderFacade` for focus and metrics.
`StreamingWorldLoader` remains the internal scene implementation while later M03
packages add environment, entity-visual and ground-query contracts.
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `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 |
| `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 |
| `WorldRenderFacade.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 |
| `WorldRenderFacade.set_streaming_focus` | Public method | Accepts focus from app/editor/tool composition | Main thread/session | Missing internal renderer is diagnosed; no state is duplicated |
| `WorldRenderFacade.refresh_streaming_focus` | Public method | Samples configured source and delegates existing refresh thresholds | Main thread/session | Returns `false` when dependency/focus is unavailable |
| `WorldRenderFacade.renderer_metrics_snapshot` | Public method | Returns a deep-detached queue/cache/activity snapshot | Main thread/caller-owned result | Missing/invalid implementation result returns empty snapshot with diagnostic |
| `WorldRenderFacade.streaming_world_loader_path` | Internal adapter property | Resolves the existing implementation without exposing it to consumers | Main thread, scene lifetime | Missing method/path produces one diagnostic until recovery |
| `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 |
| `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 |
Публичным contract не считаются внутренние dictionaries, queues, job records и generated node names.
Публичным contract не считаются `StreamingWorldLoader` methods/properties,
внутренние dictionaries, queues, job records и generated node names. Scene-owned
loader configuration remains transitional composition data, not a caller API.
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| 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 | `StreamingFocus` | Player/spectator/editor/capture adapter | `WorldRenderFacade` | Immutable caller value | Main thread/session |
| Input | Focus-source `NodePath` | Runtime/test scene composition | `WorldRenderFacade` 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 | 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 | 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 | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand |
| Output | Logs/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:
@@ -104,7 +112,7 @@ Side effects:
```mermaid
flowchart TD
P[Player/spectator Node3D] --> A[Explicit scene adapter]
PlayerSource[Player/spectator Node3D] --> A[Explicit scene adapter]
C[Capture camera] --> A
SF[Pinned server spawn] --> SM[Validated spawn manifest]
SM --> C
@@ -112,11 +120,12 @@ flowchart TD
E[Editor viewport] --> EA[Editor adapter]
A --> F[StreamingFocus]
EA --> F
F --> T[Compute desired ADT tiles]
F --> RF[WorldRenderFacade]
RF --> T[StreamingWorldLoader target refresh]
WDT[WDT tile catalog] --> T
T --> Q[Tile load queue]
Q --> P[Worker parse/cache load]
P --> R[Result queues]
Q --> Parse[Worker parse/cache load]
Parse --> R[Result queues]
R --> B[Per-frame budget scheduler]
B --> Terrain[Terrain attach/upgrade]
B --> M2[M2 group/MultiMesh attach]
@@ -155,12 +164,14 @@ stateDiagram-v2
sequenceDiagram
participant Source as Player/Editor/Capture source
participant Focus as StreamingFocus adapter
participant Stream as StreamingWorldLoader
participant Facade as WorldRenderFacade
participant Stream as StreamingWorldLoader internal
participant Worker as Worker task
participant Budget as Main-thread budget
participant Render as SceneTree/RenderingServer
Source->>Focus: sample explicit Godot position
Focus->>Stream: set/refresh typed focus
Focus->>Facade: set/refresh typed focus
Facade->>Stream: delegate focus and refresh
Stream->>Stream: desired tiles and priorities
Stream->>Worker: parse/load tile and detail data
Worker-->>Stream: immutable result
@@ -176,6 +187,8 @@ sequenceDiagram
## Ownership, threading and resources
- `StreamingWorldLoader` владеет tile states, queues, task registries, active world instances и render caches session scope.
- `WorldRenderFacade` owns only scene-relative adapter paths. It does not own the
streamer, focus source, returned metrics, queues, caches, nodes or RIDs.
- 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.
@@ -192,6 +205,8 @@ sequenceDiagram
|---|---|---|---|---|
| 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` |
| Missing renderer implementation | Facade path/method validation | Focus/metrics call fails without mutation | One facade diagnostic until recovery | Fix scene composition path/implementation |
| Invalid metrics result | Facade return-type validation | Return empty caller snapshot | Facade error | Restore `Dictionary` implementation contract |
| 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 |
| Worker failure | Task result/empty data | Skip/degrade affected asset | Queue/task warning | Retry/rebuild source |
@@ -225,6 +240,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Diagnostics and observability
- Logs: `HITCH`, `PERF`, `TERRAIN_QUALITY`, `SKY_LIGHT`, `SKYBOX_MODEL`.
- API snapshot: `WorldRenderFacade.renderer_metrics_snapshot()` returns detached
queue/cache/activity counts without exposing mutable renderer state.
- Metrics: queue depths, active tiles/assets, named finalize times, frame percentiles and max hitch.
- Debug views: render checkpoint scenes/captures; future streaming/LOD/portal overlays.
- Correlation IDs: currently mostly tile/path keys; target architecture adds session/job IDs.
@@ -257,7 +274,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| 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 | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain |
## Known gaps and risks
@@ -283,9 +300,11 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Path | Responsibility |
|---|---|
| `src/render/world_render_facade.gd` | Stable caller boundary for typed focus and detached renderer metrics |
| `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 |
| `src/tools/verify_world_render_facade.gd` | Facade delegation, snapshot isolation and consumer wiring regression |
| `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_native_animated_builder.gd` | Native animated M2 experiment |
+24
View File
@@ -24,11 +24,13 @@ enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg")
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)
, 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":4194320,"physical_keycode":0,"key_label":0,"unicode":0,"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)
, 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":4194322,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
openwc_player_strafe_left={
@@ -41,6 +43,28 @@ openwc_player_strafe_right={
"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_blizzlike_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":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
]
}
openwc_player_blizzlike_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":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
openwc_player_turn_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)
, 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":4194319,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
openwc_player_turn_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)
, 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":4194321,"physical_keycode":0,"key_label":0,"unicode":0,"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)
+1
View File
@@ -0,0 +1 @@
# Reference projects are research inputs, not OpenWC runtime resources.
+4
View File
@@ -2,6 +2,10 @@ Reference-only sources and tools.
This directory contains external projects used to compare WoW file-format behavior and editor pipelines while implementing OpenWC. Runtime code should not depend on anything in this directory.
Godot project scanning is disabled for this directory by `.gdignore`. Reference
assets remain available to Git, text search and external tooling, but Godot must
not generate `.import` sidecars inside nested reference repositories.
- `blender-wow-studio-3.4-1.1.0_Experimental/` - Blender add-on sources for M2/WMO import/export reference.
- `wow.export/` - wow.export source tree and Blender OBJ importer reference.
- `noggit-red/` - Noggit RED source tree from `https://gitlab.com/dirtbikercj/noggit-red`, used as a reference for WoW 3.3.5 map editing, ADT/WMO/M2 placement behavior, UID handling and editor workflows.
Submodule reference/open-realm updated: dfe316d3b0...c7ed0545c8
+9 -1
View File
@@ -21,10 +21,16 @@ var is_sprint_requested: bool:
get:
return _is_sprint_requested
## Signed keyboard turn request: left is negative and right is positive.
var turn_axis: float:
get:
return _turn_axis
var _forward_axis: float
var _strafe_axis: float
var _vertical_axis: float
var _is_sprint_requested: bool
var _turn_axis: float
## Creates one frame's movement request without retaining mutable input state.
@@ -32,12 +38,14 @@ func _init(
forward_axis_value: float = 0.0,
strafe_axis_value: float = 0.0,
vertical_axis_value: float = 0.0,
sprint_requested: bool = false
sprint_requested: bool = false,
turn_axis_value: float = 0.0
) -> 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
_turn_axis = clampf(turn_axis_value, -1.0, 1.0)
## Returns true when the intent requests planar or vertical translation.
+10 -2
View File
@@ -2,13 +2,17 @@ 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.
## Separate translation actions preserve the renderer sandbox while allowing
## Blizzlike335 defaults to match build-12340 movement bindings.
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 BLIZZLIKE_STRAFE_LEFT := &"openwc_player_blizzlike_strafe_left"
const BLIZZLIKE_STRAFE_RIGHT := &"openwc_player_blizzlike_strafe_right"
const TURN_LEFT := &"openwc_player_turn_left"
const TURN_RIGHT := &"openwc_player_turn_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"
@@ -23,6 +27,10 @@ const REQUIRED_ACTIONS: Array[StringName] = [
MOVE_BACKWARD,
STRAFE_LEFT,
STRAFE_RIGHT,
BLIZZLIKE_STRAFE_LEFT,
BLIZZLIKE_STRAFE_RIGHT,
TURN_LEFT,
TURN_RIGHT,
DEBUG_FLY_UP,
DEBUG_FLY_DOWN,
DEBUG_SPRINT,
+76 -11
View File
@@ -5,21 +5,86 @@ extends RefCounted
## This adapter owns no movement state and may be replaced independently of the
## local movement controller.
const RENDER_SANDBOX_PROFILE_ID := &"RenderSandbox"
const BLIZZLIKE_335_PROFILE_ID := &"Blizzlike335"
## 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)
var input_profile_id: StringName:
get:
return _input_profile_id
var _input_profile_id: StringName
## Selects one supported action layout. Unknown profiles fail closed to Blizzlike335.
func _init(requested_profile_id: StringName = BLIZZLIKE_335_PROFILE_ID) -> void:
_input_profile_id = (
RENDER_SANDBOX_PROFILE_ID
if requested_profile_id == RENDER_SANDBOX_PROFILE_ID
else BLIZZLIKE_335_PROFILE_ID
)
## Builds a normalized intent from action strengths for deterministic tests and
## Samples the current Input singleton state for one physics tick.
func sample_move_intent() -> MoveIntent:
if _input_profile_id == RENDER_SANDBOX_PROFILE_ID:
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)
)
return compose_blizzlike_335_move_intent(
Input.get_action_strength(PlayerInputActions.MOVE_FORWARD),
Input.get_action_strength(PlayerInputActions.MOVE_BACKWARD),
Input.get_action_strength(PlayerInputActions.BLIZZLIKE_STRAFE_LEFT),
Input.get_action_strength(PlayerInputActions.BLIZZLIKE_STRAFE_RIGHT),
Input.get_action_strength(PlayerInputActions.TURN_LEFT),
Input.get_action_strength(PlayerInputActions.TURN_RIGHT),
Input.is_action_pressed(PlayerInputActions.CAMERA_ROTATE)
)
## Builds the compatibility layout: Q/E always strafe; A/D turn unless
## right-mouse camera rotation is active, when A/D also strafe. Default bindings
## come from build 12340; conditional routing is reference-implementation evidence.
static func compose_blizzlike_335_move_intent(
forward_strength: float,
backward_strength: float,
strafe_left_strength: float,
strafe_right_strength: float,
turn_left_strength: float,
turn_right_strength: float,
camera_rotate_active: bool
) -> MoveIntent:
var effective_strafe_left := strafe_left_strength
var effective_strafe_right := strafe_right_strength
var turn_axis := clampf(turn_right_strength, 0.0, 1.0) - clampf(turn_left_strength, 0.0, 1.0)
if camera_rotate_active:
effective_strafe_left += turn_left_strength
effective_strafe_right += turn_right_strength
turn_axis = 0.0
var move_intent := compose_move_intent(
forward_strength,
backward_strength,
effective_strafe_left,
effective_strafe_right,
0.0,
0.0,
false
)
return MoveIntent.new(
move_intent.forward_axis,
move_intent.strafe_axis,
0.0,
false,
turn_axis
)
## Builds a normalized sandbox 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,
@@ -13,25 +13,34 @@ var is_flight_enabled: bool:
get:
return _is_flight_enabled
var movement_profile_id: StringName:
get:
return _movement_capabilities.profile_id
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 _keyboard_turn_speed_radians_per_second: float
var _movement_capabilities: PlayerMovementCapabilities
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.
## Creates a movement state with explicit Godot-unit speeds, acceleration and capabilities.
## The current compatibility renderer treats one Godot unit as one WoW yard.
## A null capability input fails closed to Blizzlike335 debug permissions.
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
sandbox_sprint_multiplier: float,
movement_capabilities: PlayerMovementCapabilities = null,
keyboard_turn_speed_radians_per_second: float = PI
) -> void:
_run_speed_units_per_second = run_speed_units_per_second
_backward_speed_units_per_second = backward_speed_units_per_second
@@ -39,11 +48,32 @@ func _init(
_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
_keyboard_turn_speed_radians_per_second = maxf(
keyboard_turn_speed_radians_per_second,
0.0
)
_movement_capabilities = (
movement_capabilities
if movement_capabilities != null
else PlayerMovementCapabilities.blizzlike_335()
)
## Toggles sandbox free flight and clears velocity exactly like the pre-M02 controller.
## Returns the new flight state.
## Returns the Godot yaw delta for one signed keyboard-turn request.
## Positive intent turns right, which is negative rotation around Godot +Y.
func calculate_yaw_delta_radians(move_intent: MoveIntent, delta_seconds: float) -> float:
return (
-move_intent.turn_axis
* _keyboard_turn_speed_radians_per_second
* maxf(delta_seconds, 0.0)
)
## Toggles sandbox free flight and clears velocity when the profile permits it.
## Returns the resulting state; rejected compatibility-profile requests return false.
func toggle_sandbox_flight() -> bool:
if not _movement_capabilities.allows_debug_free_flight:
return false
_is_flight_enabled = not _is_flight_enabled
_godot_world_velocity_units_per_second = Vector3.ZERO
return _is_flight_enabled
@@ -83,7 +113,11 @@ func _calculate_target_velocity(move_intent: MoveIntent, godot_world_movement_ba
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 speed_multiplier := (
_sandbox_sprint_multiplier
if move_intent.is_sprint_requested and _movement_capabilities.allows_debug_sprint
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
@@ -0,0 +1,53 @@
class_name PlayerMovementCapabilities
extends RefCounted
## Immutable local-player movement capability selection.
## Unknown or missing profile identifiers collapse to the safe Blizzlike335
## capability set; only RenderSandbox enables debug sprint and free flight.
const BLIZZLIKE_335_PROFILE_ID: StringName = &"Blizzlike335"
const RENDER_SANDBOX_PROFILE_ID: StringName = &"RenderSandbox"
var profile_id: StringName:
get:
return _profile_id
var allows_debug_sprint: bool:
get:
return _allows_debug_sprint
var allows_debug_free_flight: bool:
get:
return _allows_debug_free_flight
var _profile_id: StringName
var _allows_debug_sprint: bool
var _allows_debug_free_flight: bool
func _init(requested_profile_id: StringName = BLIZZLIKE_335_PROFILE_ID) -> void:
if requested_profile_id == RENDER_SANDBOX_PROFILE_ID:
_profile_id = RENDER_SANDBOX_PROFILE_ID
_allows_debug_sprint = true
_allows_debug_free_flight = true
return
_profile_id = BLIZZLIKE_335_PROFILE_ID
_allows_debug_sprint = false
_allows_debug_free_flight = false
## Returns the debug-enabled capability set used by current renderer scenes.
static func render_sandbox() -> PlayerMovementCapabilities:
return PlayerMovementCapabilities.new(RENDER_SANDBOX_PROFILE_ID)
## Returns the compatibility-safe capability set with debug movement disabled.
## This name identifies the intended compatibility profile, not proven 1:1 movement.
static func blizzlike_335() -> PlayerMovementCapabilities:
return PlayerMovementCapabilities.new(BLIZZLIKE_335_PROFILE_ID)
## Maps scene/application profile configuration to a known capability set.
## Unknown identifiers fail closed to Blizzlike335.
static func for_profile_id(requested_profile_id: StringName) -> PlayerMovementCapabilities:
return PlayerMovementCapabilities.new(requested_profile_id)
@@ -0,0 +1 @@
uid://cdnulu22drbdw
+110
View File
@@ -0,0 +1,110 @@
@tool
class_name WorldRenderFacade
extends Node
## Stable main-thread boundary between runtime/tool callers and the monolithic
## streaming renderer. The facade owns no queues, caches, nodes or RIDs.
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd")
## Internal renderer implementation resolved relative to this node.
## The referenced node remains owned by its scene parent.
@export var streaming_world_loader_path: NodePath = NodePath("..")
## Optional explicit Node3D source sampled in Godot world coordinates.
## Runtime scenes use the player; capture and probe tools replace it with their camera.
@export var streaming_focus_source_path: NodePath
var _streaming_world_loader: Node
var _missing_loader_reported := false
var _missing_focus_source_reported := false
func _ready() -> void:
_resolve_streaming_world_loader()
set_process(streaming_focus_source_path != NodePath())
if streaming_focus_source_path != NodePath():
# Child nodes become ready before their parent StreamingWorldLoader. Only
# publish the initial focus here; the loader applies it after initialization.
_capture_streaming_focus_from_source()
func _process(_delta_seconds: float) -> void:
_capture_streaming_focus_from_source()
## Replaces the renderer focus. The immutable value remains caller-owned; the
## internal streamer retains the reference for its next throttled refresh.
func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
var loader := _resolve_streaming_world_loader()
if loader == null:
return
loader.call("set_streaming_focus", streaming_focus)
## Samples the configured source and requests target refresh from the internal
## streamer. Returns false when either dependency or a valid focus is unavailable.
func refresh_streaming_focus(force: bool = false) -> bool:
_capture_streaming_focus_from_source()
var loader := _resolve_streaming_world_loader()
if loader == null:
return false
return bool(loader.call("refresh_streaming_focus", force))
## Returns a detached read-only metrics snapshot for diagnostics and baselines.
## Mutating the returned Dictionary cannot mutate renderer-owned state.
func renderer_metrics_snapshot() -> Dictionary:
var loader := _resolve_streaming_world_loader()
if loader == null:
return {}
var metrics_variant = loader.call("render_baseline_snapshot")
if not (metrics_variant is Dictionary):
push_error("WorldRenderFacade received a non-Dictionary renderer metrics snapshot.")
return {}
return (metrics_variant as Dictionary).duplicate(true)
func _capture_streaming_focus_from_source() -> void:
if streaming_focus_source_path == NodePath():
return
var focus_source := get_node_or_null(streaming_focus_source_path) as Node3D
if focus_source == null:
if not _missing_focus_source_reported:
push_warning("WorldRenderFacade focus source is missing or is not Node3D: %s" % streaming_focus_source_path)
_missing_focus_source_reported = true
return
_missing_focus_source_reported = false
var godot_position: Vector3 = focus_source.global_position
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(
godot_position.x,
godot_position.y,
godot_position.z
)
set_streaming_focus(STREAMING_FOCUS_SCRIPT.new(typed_world_position))
func _resolve_streaming_world_loader() -> Node:
if is_instance_valid(_streaming_world_loader):
return _streaming_world_loader
var candidate := get_node_or_null(streaming_world_loader_path)
if candidate == null:
if not _missing_loader_reported:
push_error("WorldRenderFacade cannot resolve StreamingWorldLoader at %s" % streaming_world_loader_path)
_missing_loader_reported = true
return null
for required_method in [
&"set_streaming_focus",
&"refresh_streaming_focus",
&"render_baseline_snapshot",
]:
if not candidate.has_method(required_method):
if not _missing_loader_reported:
push_error("WorldRenderFacade renderer implementation lacks %s" % required_method)
_missing_loader_reported = true
return null
_streaming_world_loader = candidate
_missing_loader_reported = false
return _streaming_world_loader
+1
View File
@@ -0,0 +1 @@
uid://sxn3ai2ic18r
@@ -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,175 @@
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()
## Synchronizes orbit yaw after keyboard turning changes the character transform.
func synchronize_yaw_from_character() -> void:
if _character_body != null:
_yaw_radians = _character_body.rotation.y
## 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
+48 -223
View File
@@ -1,8 +1,5 @@
extends CharacterBody3D
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")
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 ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
@@ -10,6 +7,7 @@ const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/ad
const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
const PLAYER_MOVEMENT_CAPABILITIES_SCRIPT := preload("res://src/gameplay/movement/player_movement_capabilities.gd")
const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrain_query.gd")
@export var extracted_dir: String = "res://data/extracted"
@@ -24,35 +22,21 @@ const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrai
@export var sprint_multiplier: float = 6.0
@export var flight_vertical_speed: float = 7.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
## TrinityCore 3.3.5 player MOVE_TURN_RATE default, in radians per second.
@export var keyboard_turn_speed_radians_per_second: float = 3.141594
## Local composition profile. RenderSandbox preserves debug sprint/free flight;
## Blizzlike335 rejects them but does not yet claim complete movement parity.
@export_enum("RenderSandbox", "Blizzlike335") var movement_profile_id: String = "RenderSandbox"
@export var ground_offset: float = 0.05
@export var ground_snap_speed: float = 24.0
@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 character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
@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
@export var animation_presenter_path: NodePath = NodePath("Visual/AnimationPresenter")
var _camera_pivot: Node3D
var _camera: Camera3D
var _visual: Node3D
var _character_root: Node3D
var _animation_player: AnimationPlayer
var _active_animation := ""
var _captured := false
var _yaw := 0.0
var _pitch := deg_to_rad(-18.0)
var _camera_rig: ThirdPersonCameraRig
var _appearance_presenter: CharacterAppearancePresenter
var _animation_presenter: CharacterAnimationPresenter
var _player_input_source: PlayerInputSource
var _local_movement_controller: LocalPlayerMovementController
var _terrain_query: TerrainQuery
@@ -68,7 +52,7 @@ func set_terrain_query(terrain_query: TerrainQuery) -> void:
func _ready() -> void:
_player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new()
_player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new(StringName(movement_profile_id))
if _terrain_query == null:
_terrain_query = ADT_TERRAIN_QUERY_SCRIPT.new(extracted_dir, map_name)
_local_movement_controller = LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT.new(
@@ -77,59 +61,55 @@ func _ready() -> void:
strafe_speed,
flight_vertical_speed,
acceleration,
sprint_multiplier
sprint_multiplier,
PLAYER_MOVEMENT_CAPABILITIES_SCRIPT.for_profile_id(StringName(movement_profile_id)),
keyboard_turn_speed_radians_per_second
)
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D
_camera = get_node_or_null(camera_path) as Camera3D
_visual = get_node_or_null(visual_path) as Node3D
if _camera:
_camera.current = true
_camera.far = 50000.0
_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:
var spawn_tile = ADT_TILE_COORDINATE_SCRIPT.new(spawn_tile_x, spawn_tile_y)
var half_tile_size := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS * 0.5
var spawn_local = ADT_TILE_LOCAL_POSITION_SCRIPT.new(half_tile_size, global_position.y, half_tile_size)
var spawn_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(spawn_tile, spawn_local)
global_position = Vector3(spawn_position.x_units, spawn_position.y_units, spawn_position.z_units)
_load_character_visual()
var spawn_ground_sample := _sample_ground_height(global_position)
if spawn_ground_sample.is_available:
global_position.y = spawn_ground_sample.height_units + ground_offset
_yaw = rotation.y
_apply_camera_transform()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE):
_captured = true
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
elif event.is_action_released(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE):
_captured = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_IN):
camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_OUT):
camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.RELEASE_CURSOR):
_captured = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
if _camera_rig != null:
_camera_rig.handle_camera_input(event)
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
_local_movement_controller.toggle_sandbox_flight()
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:
var move_intent := _player_input_source.sample_move_intent()
var yaw_delta_radians := _local_movement_controller.calculate_yaw_delta_radians(
move_intent,
delta
)
if not is_zero_approx(yaw_delta_radians):
rotation.y += yaw_delta_radians
if _camera_rig != null:
_camera_rig.synchronize_yaw_from_character()
var godot_world_movement_basis := global_basis
if _local_movement_controller.is_flight_enabled and _camera_pivot:
godot_world_movement_basis = _camera_pivot.global_basis
if _local_movement_controller.is_flight_enabled and _camera_rig != null:
godot_world_movement_basis = _camera_rig.godot_world_flight_movement_basis()
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
if not _local_movement_controller.is_flight_enabled:
@@ -140,171 +120,16 @@ func _physics_process(delta: float) -> void:
var movement_velocity := _local_movement_controller.godot_world_velocity_units_per_second
var horizontal_motion := Vector2(movement_velocity.x, movement_velocity.z)
if _visual and horizontal_motion.length_squared() > 0.01:
_visual.global_rotation.y = atan2(-movement_velocity.x, -movement_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)
_apply_camera_transform()
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)
if _animation_presenter != null:
_animation_presenter.present_locomotion(horizontal_motion.length_squared() > 0.04)
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 _on_character_appearance_ready(character_root: Node3D) -> void:
if _animation_presenter != null:
_animation_presenter.bind_character_root(character_root)
func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
@@ -3,6 +3,10 @@
[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://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_sky"]
[ext_resource type="Script" uid="uid://cq60al4uw0mgo" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
[ext_resource type="Script" uid="uid://bhqbo1ftrt3a3" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
[ext_resource type="Script" uid="uid://c2jnn0e3mmymc" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
[ext_resource type="Script" path="res://src/render/world_render_facade.gd" id="7_render_facade"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45
@@ -53,7 +57,6 @@ fog_depth_end = 5200.0
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_stream")
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
quality_preset = "High"
update_interval = 0.1
@@ -91,11 +94,17 @@ wmo_visibility_range = 3600.0
debug_streaming = true
hitch_profiler_enabled = true
[node name="WorldRenderFacade" type="Node" parent="."]
script = ExtResource("7_render_facade")
streaming_world_loader_path = NodePath("..")
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
script = ExtResource("2_player")
spawn_tile_x = 31
spawn_tile_y = 49
movement_profile_id = "Blizzlike335"
[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer" unique_id=1297880621]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
@@ -104,9 +113,14 @@ shape = SubResource("PlayerCapsuleShape_1")
[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)
mesh = SubResource("PlayerCapsuleMesh_1")
script = ExtResource("5_appearance")
[node name="AnimationPresenter" type="Node" parent="ThirdPersonPlayer/Visual" unique_id=2131488264]
script = ExtResource("6_animation")
[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)
script = ExtResource("4_camera_rig")
[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)
+14 -1
View File
@@ -3,6 +3,10 @@
[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://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"]
[ext_resource type="Script" path="res://src/render/world_render_facade.gd" id="7_render_facade"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45
@@ -54,7 +58,6 @@ fog_depth_end = 5200.0
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_sisqv")
map_name = "Kalimdor"
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
quality_preset = "High"
update_interval = 0.1
@@ -92,6 +95,11 @@ wmo_visibility_range = 3600.0
debug_streaming = true
hitch_profiler_enabled = true
[node name="WorldRenderFacade" type="Node" parent="."]
script = ExtResource("7_render_facade")
streaming_world_loader_path = NodePath("..")
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
script = ExtResource("2_puy8r")
@@ -105,9 +113,14 @@ shape = SubResource("PlayerCapsuleShape_1")
[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)
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]
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]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
+57
View File
@@ -0,0 +1,57 @@
{
"schema_version": 1,
"profile_id": "Blizzlike335",
"sources": {
"original_client_default_bindings": {
"status": "verified_private_metadata",
"build": 12340,
"path": "WTF/DefaultBindings.wtf",
"sha256": "35FFA0E4E2356A13A0633A848F9E57ABE7DF9D8A16C2245E15D33013AD2F56A5",
"distribution": "Selected binding facts only; proprietary file remains outside Git."
},
"original_client_binding_commands": {
"status": "verified_private_metadata",
"build": 12340,
"path": "Interface/FrameXML/Bindings.xml",
"sha256": "2E01276AFB7462F1417710F13B4BF1A456341F7F28CC8AE0D6D9C7442D7F6793",
"distribution": "Hash and selected command names only; proprietary file remains outside Git."
},
"trinitycore_player_turn_rate": {
"status": "verified_public_source",
"repository": "TrinityCore/TrinityCore",
"revision": "2853a621d6af91a803787a2b8a509f4ce3e0300d",
"path": "src/server/game/Entities/Unit/Unit.cpp",
"url": "https://raw.githubusercontent.com/TrinityCore/TrinityCore/2853a621d6af91a803787a2b8a509f4ce3e0300d/src/server/game/Entities/Unit/Unit.cpp",
"symbol": "playerBaseMoveSpeed[MOVE_TURN_RATE]",
"radians_per_second": 3.141594
},
"wowee_mouse_strafe_reference": {
"status": "verified_public_reference_only",
"repository": "Kelsidavis/WoWee",
"revision": "626243e937fb93965fa583a6507ed5a1aa7dda4b",
"path": "src/rendering/camera_controller.cpp",
"sha256": "54A41EFDA77629B2EAB432734A94D0F6F58223F3E4752338FF4C321E7835001B",
"observation": "A/D turn while right mouse is released and strafe while it is held; Q/E always strafe.",
"fidelity_limit": "Reference implementation behavior, not original-client proof."
}
},
"selected_default_bindings": [
{"command": "MOVEFORWARD", "keys": ["W", "UP"], "implemented_action": "openwc_player_move_forward"},
{"command": "MOVEBACKWARD", "keys": ["S", "DOWN"], "implemented_action": "openwc_player_move_backward"},
{"command": "TURNLEFT", "keys": ["A", "LEFT"], "implemented_action": "openwc_player_turn_left"},
{"command": "TURNRIGHT", "keys": ["D", "RIGHT"], "implemented_action": "openwc_player_turn_right"},
{"command": "STRAFELEFT", "keys": ["Q"], "implemented_action": "openwc_player_blizzlike_strafe_left"},
{"command": "STRAFERIGHT", "keys": ["E"], "implemented_action": "openwc_player_blizzlike_strafe_right"},
{"command": "JUMP", "keys": ["SPACE", "NUMPAD0"], "implemented_action": null},
{"command": "TOGGLEAUTORUN", "keys": ["NUMLOCK", "BUTTON4"], "implemented_action": null},
{"command": "CAMERAZOOMIN", "keys": ["MOUSEWHEELUP"], "implemented_action": "openwc_player_camera_zoom_in"},
{"command": "CAMERAZOOMOUT", "keys": ["MOUSEWHEELDOWN"], "implemented_action": "openwc_player_camera_zoom_out"},
{"command": "TURNORACTION", "keys": ["BUTTON2"], "implemented_action": "openwc_player_camera_rotate"},
{"command": "CAMERAORSELECTORMOVE", "keys": ["BUTTON1"], "implemented_action": null}
],
"known_gaps": [
"Jump, autorun and left-button camera/select behavior are recorded but not implemented by this package.",
"The server-core turn-rate default is compatibility evidence, not direct original-client timing proof.",
"Right-mouse A/D strafe routing is supported by a public reference implementation, not direct original-client capture."
]
}
@@ -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")
+10 -2
View File
@@ -1,14 +1,22 @@
[gd_scene load_steps=2 format=3]
[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
character_model_path = ""
[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"]
+11 -7
View File
@@ -64,7 +64,12 @@ func _capture_async() -> void:
camera.far = 50000.0
camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
(world as Node3D).add_child(camera)
world.set("streaming_focus_source_path", NodePath("CheckpointCamera"))
var render_facade := world.get_node_or_null("WorldRenderFacade")
if render_facade == null:
push_error("Streaming scene has no WorldRenderFacade")
quit(1)
return
render_facade.set("streaming_focus_source_path", NodePath("../CheckpointCamera"))
world.set("debug_streaming", true)
world.set("runtime_stats_enabled", true)
get_root().add_child(world)
@@ -126,8 +131,7 @@ func _capture_async() -> void:
if player != null:
player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0])))
_set_sky_time(world, float(checkpoint.get("time_hours", 13.0)))
if world.has_method("refresh_streaming_focus"):
world.call("refresh_streaming_focus", true)
render_facade.call("refresh_streaming_focus", true)
if dry_run:
print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [
@@ -148,7 +152,7 @@ func _capture_async() -> void:
var load_started := Time.get_ticks_usec()
await create_timer(maxf(wait_seconds, 0.0)).timeout
var load_time_ms := float(Time.get_ticks_usec() - load_started) / 1000.0
var metrics := await _measure_frames(maxf(measure_seconds, 0.1), world)
var metrics := await _measure_frames(maxf(measure_seconds, 0.1), render_facade)
await RenderingServer.frame_post_draw
var image := get_root().get_texture().get_image()
@@ -189,7 +193,7 @@ func _capture_async() -> void:
quit(0)
func _measure_frames(seconds: float, world: Node) -> Dictionary:
func _measure_frames(seconds: float, render_facade: Node) -> Dictionary:
var frame_times: Array[float] = []
var deadline := Time.get_ticks_usec() + int(seconds * 1000000.0)
var previous := Time.get_ticks_usec()
@@ -200,8 +204,8 @@ func _measure_frames(seconds: float, world: Node) -> Dictionary:
previous = now
frame_times.sort()
var snapshot := {}
if world.has_method("render_baseline_snapshot"):
snapshot = world.call("render_baseline_snapshot")
if render_facade.has_method("renderer_metrics_snapshot"):
snapshot = render_facade.call("renderer_metrics_snapshot")
return {
"frames": frame_times.size(),
"frame_ms_p50": _percentile(frame_times, 0.50),
@@ -0,0 +1 @@
uid://b5n7erahq8i46
+7 -3
View File
@@ -28,7 +28,12 @@ func _run_async() -> void:
camera.name = "OccluderProbeCamera"
camera.current = true
world.add_child(camera)
world.set("streaming_focus_source_path", NodePath(camera.name))
var render_facade := world.get_node_or_null("WorldRenderFacade")
if render_facade == null:
push_error("CAMERA_OCCLUDER_PROBE: streaming scene has no WorldRenderFacade")
quit(1)
return
render_facade.set("streaming_focus_source_path", NodePath("../%s" % camera.name))
world.set("debug_streaming", false)
get_root().add_child(world)
await process_frame
@@ -47,8 +52,7 @@ func _run_async() -> void:
var camera_position := _vector3(checkpoint.get("camera", []))
var target_position := _vector3(checkpoint.get("target", []))
camera.global_position = camera_position
if world.has_method("refresh_streaming_focus"):
world.call("refresh_streaming_focus", true)
render_facade.call("refresh_streaming_focus", true)
await create_timer(maxf(0.1, wait_seconds)).timeout
var geometry_nodes: Array[Node3D] = []
_collect_geometry_nodes(world, geometry_nodes)
@@ -0,0 +1 @@
uid://bpdv66fxxslbc
+7 -3
View File
@@ -37,7 +37,12 @@ func _run_async() -> void:
var camera := Camera3D.new()
camera.current = true
world.add_child(camera)
world.set("streaming_focus_source_path", NodePath(camera.name))
var render_facade := world.get_node_or_null("WorldRenderFacade")
if render_facade == null:
push_error("TERRAIN_HEIGHT_PROBE: streaming scene has no WorldRenderFacade")
quit(1)
return
render_facade.set("streaming_focus_source_path", NodePath("../%s" % camera.name))
world.set("debug_streaming", false)
get_root().add_child(world)
await process_frame
@@ -54,8 +59,7 @@ func _run_async() -> void:
continue
var camera_position := _vector3(checkpoint.get("camera", []))
camera.global_position = camera_position
if world.has_method("refresh_streaming_focus"):
world.call("refresh_streaming_focus", true)
render_facade.call("refresh_streaming_focus", true)
await create_timer(maxf(0.1, wait_seconds)).timeout
var terrain_sample := _sample_terrain(world, camera_position)
terrain_sample["name"] = checkpoint.get("name", "checkpoint")
@@ -0,0 +1 @@
uid://dctxeqgyptl8l
+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 @@
uid://buw0m8pjm5h02
+26 -4
View File
@@ -12,6 +12,7 @@ func _initialize() -> void:
_verify_acceleration_and_deceleration(failures)
_verify_sandbox_sprint(failures)
_verify_flight_state_and_basis(failures)
_verify_keyboard_turn(failures)
_verify_invalid_delta(failures)
_verify_scene_boundary(failures)
@@ -21,7 +22,7 @@ func _initialize() -> void:
quit(1)
return
print("LOCAL_PLAYER_MOVEMENT PASS cases=12 state_transitions=2 scene_boundary=1")
print("LOCAL_PLAYER_MOVEMENT PASS cases=15 state_transitions=2 scene_boundary=1")
quit(0)
@@ -88,6 +89,13 @@ func _verify_invalid_delta(failures: Array[String]) -> void:
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "negative delta state", failures)
func _verify_keyboard_turn(failures: Array[String]) -> void:
var controller := _new_controller()
_expect_near(controller.calculate_yaw_delta_radians(_intent(0.0, 0.0, 0.0, false, 1.0), 0.5), -PI * 0.5, "right turn yaw", failures)
_expect_near(controller.calculate_yaw_delta_radians(_intent(0.0, 0.0, 0.0, false, -1.0), 0.5), PI * 0.5, "left turn yaw", failures)
_expect_near(controller.calculate_yaw_delta_radians(_intent(0.0, 0.0, 0.0, false, 1.0), -1.0), 0.0, "negative turn delta", 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"]:
@@ -102,16 +110,25 @@ func _verify_scene_boundary(failures: Array[String]) -> void:
func _new_controller() -> LocalPlayerMovementController:
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
return LocalPlayerMovementController.new(
7.0,
4.5,
4.5,
7.0,
28.0,
6.0,
PlayerMovementCapabilities.render_sandbox()
)
func _intent(
forward_axis: float,
strafe_axis: float,
vertical_axis: float = 0.0,
sprint_requested: bool = false
sprint_requested: bool = false,
turn_axis: float = 0.0
) -> MoveIntent:
return MoveIntent.new(forward_axis, strafe_axis, vertical_axis, sprint_requested)
return MoveIntent.new(forward_axis, strafe_axis, vertical_axis, sprint_requested, turn_axis)
func _read_text(path: String, failures: Array[String]) -> String:
@@ -132,6 +149,11 @@ func _expect_vector_near(
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
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 @@
uid://qpkd87g1w31j
+85 -3
View File
@@ -6,6 +6,7 @@ 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"
const PROFILE_FIXTURE_PATH := "res://src/tests/fixtures/player_input_profile_335.json"
func _initialize() -> void:
@@ -15,6 +16,7 @@ func _initialize() -> void:
func _run_verification() -> void:
var failures: Array[String] = []
_verify_default_input_actions(failures)
_verify_profile_fixture(failures)
_verify_move_intent_composition(failures)
_verify_controller_boundary(failures)
_verify_regression_scene(failures)
@@ -25,7 +27,7 @@ func _run_verification() -> void:
quit(1)
return
print("PLAYER_INPUT PASS actions=%d intent_cases=6 controller=1 regression_scene=1" % PlayerInputActions.REQUIRED_ACTIONS.size())
print("PLAYER_INPUT PASS actions=%d intent_cases=9 profiles=3 fixture=1 controller=1 regression_scene=3" % PlayerInputActions.REQUIRED_ACTIONS.size())
quit(0)
@@ -34,9 +36,17 @@ func _verify_default_input_actions(failures: Array[String]) -> void:
_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_FORWARD, KEY_UP, false, failures)
_expect_key_binding(PlayerInputActions.MOVE_BACKWARD, KEY_S, true, failures)
_expect_key_binding(PlayerInputActions.MOVE_BACKWARD, KEY_DOWN, false, 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.BLIZZLIKE_STRAFE_LEFT, KEY_Q, true, failures)
_expect_key_binding(PlayerInputActions.BLIZZLIKE_STRAFE_RIGHT, KEY_E, true, failures)
_expect_key_binding(PlayerInputActions.TURN_LEFT, KEY_A, true, failures)
_expect_key_binding(PlayerInputActions.TURN_LEFT, KEY_LEFT, false, failures)
_expect_key_binding(PlayerInputActions.TURN_RIGHT, KEY_D, true, failures)
_expect_key_binding(PlayerInputActions.TURN_RIGHT, KEY_RIGHT, false, 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)
@@ -47,6 +57,40 @@ func _verify_default_input_actions(failures: Array[String]) -> void:
_expect_mouse_binding(PlayerInputActions.CAMERA_ZOOM_OUT, MOUSE_BUTTON_WHEEL_DOWN, failures)
func _verify_profile_fixture(failures: Array[String]) -> void:
var fixture_text := _read_text(PROFILE_FIXTURE_PATH, failures)
var parsed_fixture: Variant = JSON.parse_string(fixture_text)
_expect_true(parsed_fixture is Dictionary, "profile fixture parses", failures)
if not parsed_fixture is Dictionary:
return
var fixture := parsed_fixture as Dictionary
_expect_true(fixture.get("schema_version") == 1, "profile fixture schema", failures)
_expect_true(fixture.get("profile_id") == "Blizzlike335", "profile fixture identity", failures)
var sources := fixture.get("sources", {}) as Dictionary
var bindings_source := sources.get("original_client_default_bindings", {}) as Dictionary
_expect_true(bindings_source.get("build") == 12340, "fixture client build", failures)
_expect_true(
bindings_source.get("sha256") == "35FFA0E4E2356A13A0633A848F9E57ABE7DF9D8A16C2245E15D33013AD2F56A5",
"fixture binding source hash",
failures
)
var commands_source := sources.get("original_client_binding_commands", {}) as Dictionary
_expect_true(
commands_source.get("sha256") == "2E01276AFB7462F1417710F13B4BF1A456341F7F28CC8AE0D6D9C7442D7F6793",
"fixture command source hash",
failures
)
var turn_source := sources.get("trinitycore_player_turn_rate", {}) as Dictionary
_expect_near(float(turn_source.get("radians_per_second", 0.0)), 3.141594, "fixture turn rate", failures)
var mouse_strafe_source := sources.get("wowee_mouse_strafe_reference", {}) as Dictionary
_expect_true(
mouse_strafe_source.get("revision") == "626243e937fb93965fa583a6507ed5a1aa7dda4b",
"fixture mouse-strafe reference revision",
failures
)
_expect_true((fixture.get("selected_default_bindings", []) as Array).size() == 12, "fixture selected binding count", 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)
@@ -67,6 +111,18 @@ func _verify_move_intent_composition(failures: Array[String]) -> void:
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)
var blizzlike_strafe := PlayerInputSource.compose_blizzlike_335_move_intent(0.0, 0.0, 1.0, 0.0, 0.0, 0.0, false)
_expect_near(blizzlike_strafe.strafe_axis, -1.0, "blizzlike Q strafe", failures)
var blizzlike_turn := PlayerInputSource.compose_blizzlike_335_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, false)
_expect_near(blizzlike_turn.turn_axis, 1.0, "blizzlike D turn", failures)
var mouse_strafe := PlayerInputSource.compose_blizzlike_335_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, true)
_expect_near(mouse_strafe.strafe_axis, 1.0, "camera-held D strafe", failures)
_expect_near(mouse_strafe.turn_axis, 0.0, "camera-held D suppresses turn", failures)
_expect_true(PlayerInputSource.new(&"RenderSandbox").input_profile_id == &"RenderSandbox", "sandbox profile selected", failures)
_expect_true(PlayerInputSource.new(&"Blizzlike335").input_profile_id == &"Blizzlike335", "blizzlike profile selected", failures)
_expect_true(PlayerInputSource.new(&"Unknown").input_profile_id == &"Blizzlike335", "unknown profile fails closed", failures)
func _verify_controller_boundary(failures: Array[String]) -> void:
var controller_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
@@ -102,12 +158,13 @@ func _verify_regression_scene(failures: Array[String]) -> void:
Input.action_release(PlayerInputActions.MOVE_FORWARD)
_expect_near(player.position.z, -7.0, "forward action moves current sandbox controller", failures)
var camera_distance_before: float = player.get("camera_distance")
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(player.get("camera_distance"), camera_distance_before - 1.0, "zoom action updates camera distance", failures)
_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
@@ -117,6 +174,31 @@ func _verify_regression_scene(failures: Array[String]) -> void:
_expect_true(movement_controller.is_flight_enabled, "flight action preserves immediate sandbox toggle", failures)
player.free()
var blizzlike_player := packed_scene.instantiate() as CharacterBody3D
blizzlike_player.set("movement_profile_id", "Blizzlike335")
root.add_child(blizzlike_player)
blizzlike_player.set_physics_process(false)
Input.action_press(PlayerInputActions.TURN_RIGHT)
blizzlike_player.call("_physics_process", 0.5)
Input.action_release(PlayerInputActions.TURN_RIGHT)
_expect_near(blizzlike_player.rotation.y, -3.141594 * 0.5, "blizzlike D turns right", failures)
var blizzlike_camera := blizzlike_player.get_node("CameraPivot") as ThirdPersonCameraRig
_expect_near(blizzlike_camera.yaw_radians, blizzlike_player.rotation.y, "keyboard turn synchronizes camera yaw", failures)
blizzlike_player.free()
var mouse_strafe_player := packed_scene.instantiate() as CharacterBody3D
mouse_strafe_player.set("movement_profile_id", "Blizzlike335")
root.add_child(mouse_strafe_player)
mouse_strafe_player.set_physics_process(false)
Input.action_press(PlayerInputActions.CAMERA_ROTATE)
Input.action_press(PlayerInputActions.TURN_RIGHT)
mouse_strafe_player.call("_physics_process", 0.5)
Input.action_release(PlayerInputActions.TURN_RIGHT)
Input.action_release(PlayerInputActions.CAMERA_ROTATE)
_expect_near(mouse_strafe_player.rotation.y, 0.0, "camera-held D does not turn", failures)
_expect_near(mouse_strafe_player.position.x, 2.25, "camera-held D strafes right", failures)
mouse_strafe_player.free()
func _expect_key_binding(
action_name: StringName,
+1
View File
@@ -0,0 +1 @@
uid://dwx3m77yybv0h
@@ -0,0 +1,148 @@
extends SceneTree
## Headless M02 profile/capability gate regression for debug player movement.
const MOVEMENT_CAPABILITIES_PATH := "res://src/gameplay/movement/player_movement_capabilities.gd"
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"
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_capability_profiles(failures)
_verify_movement_gate(failures)
_verify_real_scene_profiles(failures)
_verify_source_boundary(failures)
if not failures.is_empty():
for failure in failures:
push_error("PLAYER_MOVEMENT_CAPABILITIES: %s" % failure)
quit(1)
return
print("PLAYER_MOVEMENT_CAPABILITIES PASS profiles=3 sprint=2 flight=2 scenes=2 boundary=1")
quit(0)
func _verify_capability_profiles(failures: Array[String]) -> void:
var render_sandbox := PlayerMovementCapabilities.render_sandbox()
_expect_true(render_sandbox.profile_id == PlayerMovementCapabilities.RENDER_SANDBOX_PROFILE_ID, "sandbox profile identity", failures)
_expect_true(render_sandbox.allows_debug_sprint, "sandbox sprint capability", failures)
_expect_true(render_sandbox.allows_debug_free_flight, "sandbox flight capability", failures)
var blizzlike := PlayerMovementCapabilities.blizzlike_335()
_expect_true(blizzlike.profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "blizzlike profile identity", failures)
_expect_true(not blizzlike.allows_debug_sprint, "blizzlike rejects sprint", failures)
_expect_true(not blizzlike.allows_debug_free_flight, "blizzlike rejects flight", failures)
var unknown_profile := PlayerMovementCapabilities.for_profile_id(&"UnknownProfile")
_expect_true(unknown_profile.profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "unknown profile fails closed", failures)
_expect_true(not unknown_profile.allows_debug_sprint, "unknown profile rejects sprint", failures)
_expect_true(not unknown_profile.allows_debug_free_flight, "unknown profile rejects flight", failures)
func _verify_movement_gate(failures: Array[String]) -> void:
var sprint_intent := MoveIntent.new(1.0, 0.0, 0.0, true)
var sandbox_controller := _new_controller(PlayerMovementCapabilities.render_sandbox())
sandbox_controller.advance(sprint_intent, Basis.IDENTITY, 2.0)
_expect_vector_near(sandbox_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox sprint applied", failures)
_expect_true(sandbox_controller.toggle_sandbox_flight(), "sandbox flight enabled", failures)
var blizzlike_controller := _new_controller(PlayerMovementCapabilities.blizzlike_335())
blizzlike_controller.advance(sprint_intent, Basis.IDENTITY, 2.0)
_expect_vector_near(blizzlike_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "blizzlike sprint ignored", failures)
_expect_true(not blizzlike_controller.toggle_sandbox_flight(), "blizzlike flight rejected", failures)
_expect_true(not blizzlike_controller.is_flight_enabled, "blizzlike remains grounded", failures)
var safe_default_controller := LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
_expect_true(safe_default_controller.movement_profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "missing capability defaults safe", failures)
func _verify_real_scene_profiles(failures: Array[String]) -> void:
var sandbox_player := _instantiate_player("RenderSandbox", failures)
if sandbox_player != null:
_press_sprint_forward_for_two_seconds(sandbox_player)
var sandbox_movement: LocalPlayerMovementController = sandbox_player.get("_local_movement_controller")
_expect_vector_near(sandbox_movement.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox scene sprint", failures)
sandbox_player.call("_unhandled_input", _action_event(PlayerInputActions.DEBUG_TOGGLE_FLIGHT))
_expect_true(sandbox_movement.is_flight_enabled, "sandbox scene flight", failures)
sandbox_player.free()
var blizzlike_player := _instantiate_player("Blizzlike335", failures)
if blizzlike_player != null:
_press_sprint_forward_for_two_seconds(blizzlike_player)
var blizzlike_movement: LocalPlayerMovementController = blizzlike_player.get("_local_movement_controller")
_expect_vector_near(blizzlike_movement.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "blizzlike scene sprint rejected", failures)
blizzlike_player.call("_unhandled_input", _action_event(PlayerInputActions.DEBUG_TOGGLE_FLIGHT))
_expect_true(not blizzlike_movement.is_flight_enabled, "blizzlike scene flight rejected", failures)
blizzlike_player.free()
func _instantiate_player(profile_id: String, failures: Array[String]) -> CharacterBody3D:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
if packed_scene == null:
failures.append("regression scene loads")
return null
var player := packed_scene.instantiate() as CharacterBody3D
if player == null:
failures.append("regression player instantiates")
return null
player.set("movement_profile_id", profile_id)
root.add_child(player)
player.set_physics_process(false)
return player
func _press_sprint_forward_for_two_seconds(player: CharacterBody3D) -> void:
Input.action_press(PlayerInputActions.MOVE_FORWARD)
Input.action_press(PlayerInputActions.DEBUG_SPRINT)
player.call("_physics_process", 2.0)
Input.action_release(PlayerInputActions.DEBUG_SPRINT)
Input.action_release(PlayerInputActions.MOVE_FORWARD)
func _action_event(action_name: StringName) -> InputEventAction:
var input_event := InputEventAction.new()
input_event.action = action_name
input_event.pressed = true
return input_event
func _verify_source_boundary(failures: Array[String]) -> void:
var capability_source := _read_text(MOVEMENT_CAPABILITIES_PATH, failures)
for forbidden_text in ["extends Node", "extends Resource", "Input.", "CharacterBody3D"]:
_expect_true(not capability_source.contains(forbidden_text), "capability omits %s" % forbidden_text, failures)
_expect_true(not capability_source.contains("\n\tset:"), "capability exposes no property setters", failures)
var movement_source := _read_text(MOVEMENT_CONTROLLER_PATH, failures)
_expect_true(movement_source.contains("allows_debug_sprint"), "movement gates sprint capability", failures)
_expect_true(movement_source.contains("allows_debug_free_flight"), "movement gates flight capability", failures)
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
_expect_true(player_source.contains("for_profile_id(StringName(movement_profile_id))"), "scene maps profile once", failures)
_expect_true(not player_source.contains('movement_profile_id == "'), "scene omits profile branches", failures)
func _new_controller(capabilities: PlayerMovementCapabilities) -> LocalPlayerMovementController:
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0, capabilities)
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)
@@ -0,0 +1 @@
uid://bcg11uv05uhcr
@@ -0,0 +1 @@
uid://bopeup4jipa35
@@ -0,0 +1 @@
uid://cojh1u6igoocj
@@ -0,0 +1 @@
uid://cy5sbwxmjyue8
+15 -3
View File
@@ -6,6 +6,7 @@ const StreamingFocusScript = preload("res://src/domain/streaming/streaming_focus
const GodotWorldPositionScript = preload("res://src/domain/coordinates/godot_world_position.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const FACADE_PATH := "res://src/render/world_render_facade.gd"
const RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn",
@@ -21,6 +22,7 @@ func _initialize() -> void:
var failures: Array[String] = []
_verify_scene_free_focus_value(failures)
_verify_loader_boundary(failures)
_verify_facade_boundary(failures)
_verify_runtime_scene_wiring(failures)
_verify_capture_tool_wiring(failures)
@@ -65,11 +67,21 @@ func _verify_loader_boundary(failures: Array[String]) -> void:
_expect_true(not loader_source.contains(forbidden_text), "loader omits %s" % forbidden_text, failures)
func _verify_facade_boundary(failures: Array[String]) -> void:
var facade_source := _read_text(FACADE_PATH, failures)
for required_text in [
"class_name WorldRenderFacade",
"func set_streaming_focus(streaming_focus: StreamingFocus) -> void:",
"func refresh_streaming_focus(force: bool = false) -> bool:",
]:
_expect_true(facade_source.contains(required_text), "facade contains %s" % required_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")'),
scene_source.contains('streaming_focus_source_path = NodePath("../ThirdPersonPlayer")'),
"%s uses player focus" % scene_path,
failures
)
@@ -78,8 +90,8 @@ func _verify_runtime_scene_wiring(failures: Array[String]) -> void:
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(tool_source.contains('render_facade.set("streaming_focus_source_path"'), "%s sets explicit focus source" % tool_path, failures)
_expect_true(tool_source.contains('render_facade.call("refresh_streaming_focus", true)'), "%s uses facade refresh" % tool_path, failures)
_expect_true(not tool_source.contains('world.call("_refresh_streaming_targets_at"'), "%s avoids private refresh" % tool_path, failures)
+1
View File
@@ -0,0 +1 @@
uid://caberesg37dmj
+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)
@@ -0,0 +1 @@
uid://c13xqbnau04i4
+117
View File
@@ -0,0 +1,117 @@
extends SceneTree
## Headless contract and wiring regression for the first M03 renderer facade seam.
const WORLD_RENDER_FACADE_SCRIPT := preload("res://src/render/world_render_facade.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd")
const RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn",
]
const 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",
]
class StreamingWorldLoaderDouble extends Node:
var current_focus: StreamingFocus
var refresh_count := 0
var metrics := {"tiles": 2, "queues": {"tile": 1}}
func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
current_focus = streaming_focus
func refresh_streaming_focus(_force: bool = false) -> bool:
refresh_count += 1
return current_focus != null
func render_baseline_snapshot() -> Dictionary:
return metrics
func _initialize() -> void:
var failures: Array[String] = []
await _verify_delegation_and_snapshot_isolation(failures)
_verify_repository_wiring(failures)
if not failures.is_empty():
for failure in failures:
push_error("WORLD_RENDER_FACADE: %s" % failure)
quit(1)
return
print("WORLD_RENDER_FACADE PASS delegation=3 runtime_scenes=2 tools=3")
quit(0)
func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void:
var test_root := Node.new()
var loader := StreamingWorldLoaderDouble.new()
loader.name = "StreamingWorldLoaderDouble"
var source := Node3D.new()
source.name = "FocusSource"
source.position = Vector3(10.0, 20.0, 30.0)
var facade = WORLD_RENDER_FACADE_SCRIPT.new()
facade.name = "WorldRenderFacade"
facade.streaming_world_loader_path = NodePath("../StreamingWorldLoaderDouble")
facade.streaming_focus_source_path = NodePath("../FocusSource")
test_root.add_child(loader)
test_root.add_child(source)
test_root.add_child(facade)
get_root().add_child(test_root)
await process_frame
_expect_true(loader.current_focus != null, "ready captures explicit source", failures)
if loader.current_focus != null:
_expect_near(loader.current_focus.world_position.x_units, 10.0, "source X", failures)
_expect_near(loader.current_focus.world_position.y_units, 20.0, "source Y", failures)
_expect_near(loader.current_focus.world_position.z_units, 30.0, "source Z", failures)
var typed_focus = STREAMING_FOCUS_SCRIPT.new(GODOT_WORLD_POSITION_SCRIPT.new(40.0, 50.0, 60.0))
facade.streaming_focus_source_path = NodePath()
facade.set_streaming_focus(typed_focus)
_expect_true(facade.refresh_streaming_focus(true), "typed focus refresh delegates", failures)
_expect_true(loader.current_focus == typed_focus, "typed focus reference delegates", failures)
var snapshot: Dictionary = facade.renderer_metrics_snapshot()
snapshot["tiles"] = 999
(snapshot["queues"] as Dictionary)["tile"] = 999
_expect_true(int(loader.metrics.tiles) == 2, "top-level metrics are detached", failures)
_expect_true(int(loader.metrics.queues.tile) == 1, "nested metrics are detached", failures)
test_root.queue_free()
func _verify_repository_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('path="res://src/render/world_render_facade.gd"'), "%s loads facade" % scene_path, failures)
_expect_true(scene_source.contains('[node name="WorldRenderFacade" type="Node" parent="."]'), "%s owns facade node" % scene_path, failures)
_expect_true(scene_source.contains('streaming_focus_source_path = NodePath("../ThirdPersonPlayer")'), "%s facade uses player focus" % scene_path, failures)
for tool_path in TOOL_PATHS:
var tool_source := _read_text(tool_path, failures)
_expect_true(tool_source.contains('get_node_or_null("WorldRenderFacade")'), "%s resolves facade" % tool_path, failures)
_expect_true(not tool_source.contains('world.call("refresh_streaming_focus"'), "%s avoids direct streamer refresh" % tool_path, failures)
_expect_true(not tool_source.contains('world.set("streaming_focus_source_path"'), "%s avoids direct streamer focus config" % 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 not is_equal_approx(actual_value, expected_value):
failures.append("%s expected %.3f, got %.3f" % [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://baoiewiesd02u
+54 -16
View File
@@ -1,6 +1,7 @@
# M02 — Player Decomposition
<!-- OPENWC_TARGET:ACTIVE -->
<!-- OPENWC_TARGET:DONE -->
<!-- OPENWC_TARGET_DONE:M02:work/sindo-main-codex/m02-integrator-closeout:2026-07-15 -->
## Outcome
@@ -8,14 +9,14 @@
## Steps
- [ ] Заменить hardcoded keys на remappable Input Map actions с профилем 3.3.5a.
- [ ] Выделить `PlayerInputSource → MoveIntent`.
- [ ] Выделить local movement state/controller.
- [ ] Выделить `TerrainQuery` и убрать прямой ADT parsing из player.
- [ ] Выделить third-person camera rig и camera collision policy.
- [ ] Выделить character appearance и animation presenters.
- [ ] Оставить sprint/flight debug-функции только в sandbox profile.
- [ ] Добавить movement/camera state tests и regression scene.
- [x] Заменить hardcoded keys на remappable Input Map actions с профилем 3.3.5a.
- [x] Выделить `PlayerInputSource → MoveIntent`.
- [x] Выделить local movement state/controller.
- [x] Выделить `TerrainQuery` и убрать прямой ADT parsing из player.
- [x] Выделить third-person camera rig и camera collision policy.
- [x] Выделить character appearance и animation presenters.
- [x] Оставить sprint/flight debug-функции только в sandbox profile.
- [x] Добавить movement/camera state tests и regression scene.
## Fidelity evidence
@@ -27,10 +28,47 @@
## Evidence
- Date:
- Revision/worktree:
- Commands:
- Results:
- Fidelity comparison:
- Changed files:
- Remaining risks:
- Date: 2026-07-15
- Revision/worktree: merged master `eb122d5`; closeout
`work/sindo-main-codex/m02-integrator-closeout`.
- Commands: `verify_player_input.gd`; `verify_local_player_movement.gd`;
`verify_player_movement_capabilities.gd`; `verify_terrain_query.gd`;
`verify_third_person_camera_rig.gd`; `verify_character_presentation.gd`;
`verify_coordinate_conversion_boundaries.gd`; `verify_streaming_focus.gd`;
`tools/run_render_baseline.ps1 -DryRun`; documentation, coordination and
diff-hygiene gates.
- Results: input PASS (`16` actions, `9` intent cases, `3` profiles, one
provenance fixture, `3` real-scene cases); movement PASS (`15` cases, `2`
state transitions); profile capabilities PASS (`3` profiles, sprint/flight
allow/reject and `2` scenes); terrain PASS (`4` contract, interpolation,
cache and `2` failure cases); camera PASS (`12` state and `3` policy cases);
presentation PASS (`9` appearance and `10` animation cases across `3`
scenes); coordinate boundary PASS (`103` files, `5` consumers); StreamingFocus
PASS (`2` runtime scenes, `3` capture tools). Renderer dry-run passed project,
material, dedupe and shutdown gates, manifest `7/7/7`, calibration maximum
error `0.000015`, server-spawn renderer and all seven planned checkpoints.
- Fidelity comparison: selected build-12340 default bindings are direct private
observations pinned by `DefaultBindings.wtf` SHA-256
`35FFA0E4E2356A13A0633A848F9E57ABE7DF9D8A16C2245E15D33013AD2F56A5`
and `Bindings.xml` SHA-256
`2E01276AFB7462F1417710F13B4BF1A456341F7F28CC8AE0D6D9C7442D7F6793`;
only sanitized selected facts are committed. `3.141594` rad/s keyboard turn
is pinned to TrinityCore 3.3.5 revision
`2853a621d6af91a803787a2b8a509f4ce3e0300d`; right-mouse A/D strafe routing
is supported by WoWee revision `626243e937fb93965fa583a6507ed5a1aa7dda4b`.
These two sources are compatibility references, not direct client timing/
conditional-behavior proof. Translation speeds and camera values remain
sandbox regression baselines rather than original-client parity evidence;
jump/fall/swim/fly gameplay is not implemented by this decomposition target.
Debug sprint/free flight is proven unable to execute in `Blizzlike335`.
- Changed files: typed input intent/actions/source, local movement/capabilities,
terrain query, camera rig/collision policy, character appearance/animation
presenters, player composition, Input Map configuration, asset-free regression
scenes, sanitized input fixture, M02 verifiers, module specifications and M02
coordination claims.
- Remaining risks: direct build-12340 timing evidence for movement/camera,
jump/fall/swim/fly, autorun, left-button camera/select behavior, active camera
collision, terrain slopes/holes/liquids, persisted keybindings, server authority,
prediction and reconciliation remain for M09/M10/M12. Runtime renderer scenes
continue to default to explicit `RenderSandbox` until an application shell
selects `Blizzlike335`. M02 makes no complete `1:1` compatibility claim.
+1 -1
View File
@@ -1,6 +1,6 @@
# M03 — Renderer Facade and Safe Extraction
<!-- OPENWC_TARGET:OPEN -->
<!-- OPENWC_TARGET:ACTIVE -->
## Outcome
+1 -1
View File
@@ -21,7 +21,7 @@ OpenWC должен предоставить четыре согласованн
- 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.
- 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.
- 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. Текущая работа M03 закрывает renderer стабильным facade и извлекает planner/scheduler без rewrite.
- Gameplay domain, network protocol, production UI/Lua, audio orchestration и server adapters в основном предстоит реализовать.
- Editor plugin пока решает extraction/preview задачи, но не является полноценной authoring platform.
+3 -3
View File
@@ -10,7 +10,7 @@
## Current target
`M02` — [02-player-decomposition.md](02-player-decomposition.md)
`M03` — [03-renderer-facade.md](03-renderer-facade.md)
Одновременно `ACTIVE` может быть только одна цель. Следующая цель становится `ACTIVE` после появления валидной `OPENWC_TARGET_DONE` метки у предыдущей.
@@ -35,8 +35,8 @@
|---|---|---|---|
| M00 | [Renderer baseline](00-render-baseline.md) | — | DONE |
| M01 | [Coordinates and architecture seams](01-coordinates-and-seams.md) | M00 | DONE |
| M02 | [Player decomposition](02-player-decomposition.md) | M01 | ACTIVE |
| M03 | [Renderer facade and extraction](03-renderer-facade.md) | M02 | OPEN |
| M02 | [Player decomposition](02-player-decomposition.md) | M01 | DONE |
| M03 | [Renderer facade and extraction](03-renderer-facade.md) | M02 | ACTIVE |
| M04 | [Godot Editor shell](04-editor-shell.md) | M03 | OPEN |
| M05 | [Content Project](05-content-project.md) | M04 | OPEN |
| M06 | [Server database adapters](06-server-adapters.md) | M05 | OPEN |