Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb103e1fde | |||
| d6e5b530ef | |||
| 8c46065ada | |||
| 9408d887ba | |||
| 80cb08422b | |||
| 0773de5977 | |||
| b697a896e8 | |||
| 6117e5282e | |||
| 53d84ccc8a | |||
| 7e35de7f61 | |||
| ba9f89691d | |||
| b58bf2caa6 | |||
| 52ea639d64 | |||
| a023d76eea | |||
| 7ece2abea2 | |||
| 10c5d4cc12 | |||
| e52f703da5 | |||
| 91f0724ce2 | |||
| 650d584ff0 | |||
| 643850c8ee | |||
| ad8fc41ace | |||
| d0f74f840b | |||
| 5b10288ff7 | |||
| e887c3bad5 | |||
| 0c24f96ae1 | |||
| ad0532925a | |||
| 9c3f7018fb | |||
| a100cede46 | |||
| 2466fd67d3 | |||
| 3d528e3bbf | |||
| a629bedccf | |||
| 4f566b1957 | |||
| d9c847c557 | |||
| 1bf5b526be | |||
| 9275fbe04f | |||
| eb122d52a9 | |||
| e819eeb35b | |||
| 8776a6b362 | |||
| c3094c9413 | |||
| 7d5d13f702 | |||
| 742c415885 | |||
| 8c71e46872 | |||
| ad820699bc | |||
| 97bb53f6cd | |||
| 68e6f60d90 | |||
| 02dec23c2a | |||
| 8c1cf9be36 | |||
| f5bb64e6c7 | |||
| 0ce3cae208 | |||
| a45d521567 | |||
| 5206c42cf0 |
@@ -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,82 @@ $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.
|
||||
|
||||
## 2026-07-15 StreamingTargetPlanner Extraction
|
||||
|
||||
- Runtime and editor wanted/retained ADT set calculation now lives in the
|
||||
scene-free `StreamingTargetPlanner`.
|
||||
- `StreamingTargetPolicy` preserves existing chunk/tile radii, warm/prewarm/
|
||||
retain margins and the clamped boundary-prefetch threshold.
|
||||
- `StreamingTargetPlan` freezes its focus/wanted/retained result; the streamer
|
||||
still owns available paths, load-queue distance sorting, loaded-tile LOD/detail
|
||||
state, async work, caches, nodes and RIDs.
|
||||
- Asset-free fixtures cover center, edge/corner prefetch, catalog filtering,
|
||||
clamp and editor behavior plus bounded High-like iteration timing.
|
||||
- Cache versions, quality profiles, queue priority and visible rendering rules
|
||||
are unchanged by the extraction.
|
||||
|
||||
## 2026-07-16 RenderBudgetScheduler Extraction
|
||||
|
||||
- The scene-free `RenderBudgetScheduler` now owns the per-frame counters for 16
|
||||
named renderer operation lanes. It owns no queue, task, cache, Node or RID.
|
||||
- `StreamingWorldLoader` snapshots its existing exported limits once per process
|
||||
frame and asks for a boolean permit immediately before each historically
|
||||
budgeted operation.
|
||||
- Chunk removals and creations intentionally share one `chunk_geometry` lane;
|
||||
the existing removal-first drain order and combined limit are preserved.
|
||||
- Loader teardown cancels permit issuance before waiting for asynchronous work.
|
||||
Existing worker/result staleness checks and resource cleanup remain unchanged.
|
||||
- Asset-free contracts cover exact bounds, independent/shared lanes, frame reset,
|
||||
invalid inputs, terminal cancellation, detached diagnostics and hot-path timing.
|
||||
- Defaults, quality presets, queue order, cache versions and visible rendering
|
||||
rules are unchanged. Asset-backed p95/p99 comparison remains required.
|
||||
|
||||
## 2026-07-16 Streamer Internal Access Gate
|
||||
|
||||
- `verify_renderer_internal_access.gd` derives private queue, task, cache and
|
||||
tile-state field names from `StreamingWorldLoader` declarations and rejects
|
||||
external member/reflection access in gameplay and Godot `EditorPlugin` package sources.
|
||||
- The gate follows the implementation inventory instead of duplicating a manual
|
||||
list, so a newly named private renderer field is covered automatically when it
|
||||
matches those ownership categories.
|
||||
- Scene composition may still reference the loader script and the facade's
|
||||
implementation path; those are composition details, not mutable queue access.
|
||||
- Renderer diagnostic probes are added explicitly as their facade contracts land.
|
||||
- This is a source-only boundary check and changes no renderer behavior or fidelity.
|
||||
|
||||
## 2026-07-16 Rendered Ground Query Facade
|
||||
|
||||
- `WorldRenderFacade.sample_ground_height()` accepts `GodotWorldPosition` and
|
||||
returns renderer-owned immutable `RenderedGroundSample` from already loaded
|
||||
quality/tile-LOD render meshes, avoiding a render-to-gameplay dependency.
|
||||
- Its factories load the predeclared Script path instead of relying on a warm
|
||||
global-class cache; a dedicated cold-start verifier covers this bootstrap path.
|
||||
- `renderer_ground_query_snapshot()` preserves the terrain probe's tile readiness,
|
||||
queue position, mesh bounds and nearby seam-fallback diagnostics as a detached
|
||||
Dictionary; callers receive no queue, tile-state, Mesh or Node reference.
|
||||
- `probe_render_terrain_height.gd` now uses this facade snapshot and no longer reads
|
||||
`_tile_states`, `_tile_load_queue`, `_tile_loading_tasks` or `_available_tiles`.
|
||||
- Gameplay continues using the independently composed M02 `TerrainQuery`/
|
||||
`AdtTerrainQuery`. Loaded render meshes are diagnostic and are not authoritative
|
||||
movement collision.
|
||||
- The ray height, 3x3 tile search and `2/5/10/20/40`-unit nearby fallback are
|
||||
preserved. No terrain geometry, cache, streaming or visual behavior changed.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,119 @@
|
||||
# M02-GMP-TERRAIN-QUERY-001 — Player terrain query boundary
|
||||
|
||||
<!-- OPENWC_CLAIM:M02-GMP-TERRAIN-QUERY-001:sindo-main-codex:2026-07-16 -->
|
||||
<!-- OPENWC_HANDOFF:READY:M02-GMP-TERRAIN-QUERY-001:a45d521 -->
|
||||
<!-- OPENWC_INTEGRATION:ACCEPTED:M02-GMP-TERRAIN-QUERY-001:f5bb64e -->
|
||||
|
||||
## Ownership
|
||||
|
||||
- Target: M02
|
||||
- Program: GMP/RND
|
||||
- Owner/Agent ID: sindo-main-codex
|
||||
- Branch: `work/sindo-main-codex/m02-terrain-query`
|
||||
- Lease expires UTC: 2026-07-16
|
||||
- Integrator: M02 milestone integrator
|
||||
|
||||
## Outcome
|
||||
|
||||
Introduce a typed `TerrainQuery` boundary and move ADT loading, caching, chunk
|
||||
selection and height interpolation out of `ThirdPersonWowController` without
|
||||
changing current spawn/ground-snap behavior.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Change ADT topology, coordinate formulas, terrain mesh or renderer caches.
|
||||
- Add physics collision, slopes, water, jump/fall/swim or server movement.
|
||||
- Reuse the streaming renderer cache before its public terrain-query contract exists.
|
||||
- Extract camera or character presentation.
|
||||
- Mark M02 complete or edit its checklist/Evidence.
|
||||
|
||||
## Paths
|
||||
|
||||
- Exclusive: `src/gameplay/terrain/`, `src/tools/verify_terrain_query.gd`,
|
||||
`docs/modules/terrain-query.md`, this claim
|
||||
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`,
|
||||
`src/tools/verify_player_input.gd`,
|
||||
`src/tools/verify_coordinate_conversion_boundaries.gd`,
|
||||
`docs/modules/local-player-movement.md`,
|
||||
`docs/modules/README.md`
|
||||
- Generated/ignored: local `.godot`, native DLL, generated ADT resources and renderer corpus
|
||||
|
||||
## Contracts and data
|
||||
|
||||
- Public API: `TerrainQuery.sample_ground_height(GodotWorldPosition)` returning
|
||||
immutable `TerrainGroundSample`
|
||||
- Production adapter: `AdtTerrainQuery` with owned per-tile parse cache
|
||||
- Test/data-source seam: optional ADT tile loader callable at construction
|
||||
- Coordinate contract version, ADT format, renderer cache and persistence: unchanged
|
||||
- Consumers: current player scene; future movement/collision policies
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Requires: merged movement controller on master `5206c42`
|
||||
- Blocks: camera/presentation extraction from the player hotspot
|
||||
- External state: production uses existing native `ADTLoader`; synthetic tests need no proprietary data
|
||||
|
||||
## Verification
|
||||
|
||||
- Commands: synthetic height/cache/failure verifier, player input/movement
|
||||
regressions, asset-free scene, coordinate/StreamingFocus gates, renderer dry-run
|
||||
and repository gates
|
||||
- Fixtures: synthetic 145-height chunk payload and injected flat terrain query
|
||||
- Fidelity evidence: preserves existing outer-grid bilinear interpolation,
|
||||
spawn ground offset and per-tick snap formula only
|
||||
- Performance budget: one cached tile lookup and local interpolation per ground query;
|
||||
no new I/O after first tile load
|
||||
|
||||
## Documentation deliverables
|
||||
|
||||
- Inline public API docs for query, sample and ADT adapter
|
||||
- Terrain-query module spec with inputs/outputs, data-flow, cache lifecycle and sequence diagrams
|
||||
- Updated movement/coordinate consumer boundaries, source maps and module registry
|
||||
|
||||
## Simplicity and naming
|
||||
|
||||
- Important names: `TerrainQuery`, `TerrainGroundSample`, `AdtTerrainQuery`
|
||||
- Simplest approach: one narrow query contract, one typed result and one existing-format adapter
|
||||
- Rejected complexity: general collision framework, terrain service locator and renderer-cache coupling
|
||||
- Unavoidable complexity: ADT 145-height layout and chunk lookup stay isolated in adapter
|
||||
- Measured optimization evidence: existing per-tile cache retained; no new optimization
|
||||
|
||||
## Status
|
||||
|
||||
- State: ready-for-review
|
||||
- Done: typed query/sample contract, cached ADT adapter, player migration,
|
||||
synthetic interpolation/cache/failure tests, injected scene regression and documentation
|
||||
- Next: M02 integrator reviews and merges before camera-rig extraction
|
||||
- Blocked by:
|
||||
|
||||
## Handoff
|
||||
|
||||
- Commit: `a45d521`
|
||||
- Branch: `work/sindo-main-codex/m02-terrain-query`
|
||||
- Outcome: `ThirdPersonWowController` no longer loads ADTs, caches tile
|
||||
dictionaries, selects chunks or interpolates height grids. It consumes a
|
||||
replaceable typed `TerrainQuery` and retains only existing offset/snap policy.
|
||||
- Public contracts: additive immutable `TerrainGroundSample`, replaceable
|
||||
`TerrainQuery`, cached `AdtTerrainQuery` and player `set_terrain_query`.
|
||||
Coordinate contract version, ADT/native format and renderer caches are unchanged.
|
||||
- Verification: `TERRAIN_QUERY PASS contract=4 interpolation=1 cache=1
|
||||
failures=2 player_injection=1`; player-input and local-movement regressions
|
||||
passed; asset-free scene exited `0`; coordinate boundary passed with five
|
||||
required consumers and StreamingFocus passed; renderer dry-run passed eight
|
||||
pre-capture gates and seven checkpoint plans; coordination/documentation/diff gates passed.
|
||||
- Fidelity: synthetic fractional-grid fixture locks the inherited 145-height
|
||||
outer-grid bilinear result within `0.002` Godot units; injected scene locks
|
||||
spawn offset `0.05` and physics snap. This is sandbox-regression evidence,
|
||||
not build-12340 triangle/hole/collision parity.
|
||||
- Local verification inputs: isolated worktree reused the existing ignored
|
||||
native DLL and generated ADT resource scripts. Proprietary extracted data and
|
||||
character GLB were absent, so renderer dry-run logged expected missing-corpus
|
||||
diagnostics while all contract steps returned success.
|
||||
- Rebuild/migration: none; caches are transient and existing formats remain unchanged.
|
||||
- Remaining risks: first ADT parse remains synchronous as before; unavailable
|
||||
tiles cache until query replacement; player still owns snap policy; exact
|
||||
ADT triangle choice, holes, slopes, liquids and collision remain open.
|
||||
- Documentation: inline public APIs plus `docs/modules/terrain-query.md` with
|
||||
inputs/outputs, data-flow, cache state and cross-boundary sequence diagrams,
|
||||
ownership, failure/recovery, capability status and source map. Coordinate and
|
||||
movement consumer specifications were updated.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,115 @@
|
||||
# M03-RND-FACADE-GROUND-QUERY-001 — Rendered ground query facade
|
||||
|
||||
<!-- OPENWC_CLAIM:M03-RND-FACADE-GROUND-QUERY-001:sindo-main-codex:2026-07-18 -->
|
||||
|
||||
## Ownership
|
||||
|
||||
- Target: M03
|
||||
- Program: RND
|
||||
- Owner/Agent ID: sindo-main-codex
|
||||
- Branch: `work/sindo-main-codex/m03-facade-ground-query`
|
||||
- Lease expires UTC: 2026-07-18
|
||||
- Integrator: M03 milestone integrator
|
||||
|
||||
## Outcome
|
||||
|
||||
Expose typed ground-height sampling and a detached readiness diagnostic through
|
||||
`WorldRenderFacade`, using already loaded render terrain meshes, and migrate the
|
||||
terrain-height probe away from direct streamer state/queue access.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replace the M02 gameplay-owned `TerrainQuery`/`AdtTerrainQuery` composition.
|
||||
- Make rendered mesh sampling authoritative for movement, collision or server state.
|
||||
- Add slopes, holes, liquid, WMO/M2 collision or async query callbacks.
|
||||
- Change terrain meshes, streaming policy, queue order, cache formats or visuals.
|
||||
- Complete environment/entity-visual facade contracts or mark M03 complete.
|
||||
|
||||
## Paths
|
||||
|
||||
- Exclusive: this claim
|
||||
- Shared/hotspots: `src/render/world_render_facade.gd`,
|
||||
`src/scenes/streaming/streaming_world_loader.gd`,
|
||||
`src/tools/probe_render_terrain_height.gd`,
|
||||
`src/tools/verify_world_render_facade.gd`,
|
||||
`src/tools/verify_renderer_internal_access.gd`, `docs/modules/world-renderer.md`,
|
||||
`docs/modules/terrain-query.md`, `RENDER.md`, `targets/03-renderer-facade.md`
|
||||
- Generated/ignored: `.godot`, native DLL, generated ADT resource scripts,
|
||||
extracted renderer corpus and probe reports
|
||||
|
||||
## Contracts and data
|
||||
|
||||
- Input: immutable `GodotWorldPosition` in Godot world units
|
||||
- Output: renderer-owned immutable `RenderedGroundSample` plus optional detached diagnostic Dictionary
|
||||
- Sampling source: loaded quality or tile-LOD Mesh and its live tile transform
|
||||
- M02 ADT query cache, renderer cache versions and coordinate contracts: unchanged
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Requires: M02 terrain query contract and integrated M03 facade/access gate `53d84cc`
|
||||
- Blocks: removal of renderer diagnostic internal-access exception and later terrain service extraction
|
||||
- External state: asset-backed probe is optional; contract tests use a facade double
|
||||
|
||||
## Verification
|
||||
|
||||
- Commands: facade query/snapshot isolation contract, internal-access gate,
|
||||
terrain-query regression, focus/coordinate/manifest/shutdown regressions and repository gates
|
||||
- Fixtures: available/unavailable sample, detached diagnostic, probe source migration
|
||||
- Fidelity evidence: mesh ray/nearest fallback algorithm moves unchanged from the probe
|
||||
- Performance budget: on-demand diagnostic query only; no per-frame query added
|
||||
|
||||
## Documentation deliverables
|
||||
|
||||
- Inline facade/streamer API docs
|
||||
- World-renderer API/data-flow/sequence/ownership/recovery/source-map updates
|
||||
- Terrain-query backend status/ownership clarification and renderer implementation note
|
||||
|
||||
## Simplicity and naming
|
||||
|
||||
- Important names: `sample_ground_height`, `renderer_ground_query_snapshot`
|
||||
- Simplest approach: one typed facade query over one existing diagnostic read model
|
||||
- Rejected complexity: duplicate ADT cache, physics service, callback bus, query node,
|
||||
generic spatial interface or new dependency
|
||||
- Unavoidable complexity: triangle-mesh ray query retains the existing bounded
|
||||
nearby fallback for seam/edge diagnostic behavior
|
||||
|
||||
## Status
|
||||
|
||||
- State: ready-for-review
|
||||
- Done: added renderer-owned typed sample, facade query/snapshot, migrated terrain
|
||||
probe, expanded internal-access/coordinate gates and updated module docs
|
||||
- Next: M03 integrator reviews and merges `b697a89` plus cold-start fix `9408d88`
|
||||
- Blocked by:
|
||||
|
||||
<!-- OPENWC_HANDOFF:READY:M03-RND-FACADE-GROUND-QUERY-001:9408d88 -->
|
||||
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-FACADE-GROUND-QUERY-001:d6e5b53 -->
|
||||
|
||||
## Handoff
|
||||
|
||||
- Commits: implementation `b697a89`; cold-start fix `9408d88`
|
||||
- Results: `WorldRenderFacade` delegates loaded-mesh ground sampling and returns
|
||||
renderer-owned `RenderedGroundSample`; detached diagnostic snapshots preserve
|
||||
the probe report while hiding queue/tile/Mesh/Node references. Gameplay remains
|
||||
on the independent M02 `TerrainQuery` composition.
|
||||
- Verification: facade `delegation=5 ground_contract=2 runtime_scenes=2 tools=3`;
|
||||
cold rendered-ground value `contract=3` from an empty Godot class cache;
|
||||
internal access `private_symbols=43 gameplay=7 editor_sources=9 renderer_tools=1`;
|
||||
terrain query contracts passed; coordinate boundary `files=113 consumers=5`;
|
||||
renderer manifest `7/7`; shutdown ownership, scheduler/planner, coordination,
|
||||
documentation and diff gates passed.
|
||||
- Fidelity: the probe's 5000-unit ray height, 3x3 tile search, highest-hit choice,
|
||||
readiness fields and `2/5/10/20/40` nearby fallback moved unchanged. No terrain
|
||||
geometry, streaming, cache, material, placement or visual rule changed. No
|
||||
build-12340 parity claim is added.
|
||||
- External/local inputs: ignored native DLL and generated ADT resource scripts
|
||||
were copied from master only for editor/streamer parse and then removed.
|
||||
Proprietary extracted world data was unavailable, producing expected degraded diagnostics.
|
||||
- Remaining risks: triangle-mesh generation is on-demand/main-thread and diagnostic;
|
||||
it is unsuitable for per-physics-frame gameplay; exact asset-backed probe output
|
||||
still depends on local corpus readiness; environment/entity facade contracts remain.
|
||||
- Documentation: inline facade/loader/value API; world-renderer API/data-flow/
|
||||
sequence/ownership/recovery/capability/source map; terrain-query authority
|
||||
clarification and renderer implementation notes updated.
|
||||
- Integration: implementation accepted in master merge `80cb084`; cold-start fix
|
||||
accepted in merge `d6e5b53`. Post-merge cold/facade/access/terrain/coordinate/
|
||||
manifest/shutdown/scheduler/planner/focus and repository gates passed.
|
||||
@@ -0,0 +1,106 @@
|
||||
# M03-RND-INTERNAL-ACCESS-GATE-001 — Streamer internal access gate
|
||||
|
||||
<!-- OPENWC_CLAIM:M03-RND-INTERNAL-ACCESS-GATE-001:sindo-main-codex:2026-07-18 -->
|
||||
|
||||
## Ownership
|
||||
|
||||
- Target: M03
|
||||
- Program: RND
|
||||
- Owner/Agent ID: sindo-main-codex
|
||||
- Branch: `work/sindo-main-codex/m03-streamer-internal-access-gate`
|
||||
- Lease expires UTC: 2026-07-18
|
||||
- Integrator: M03 milestone integrator
|
||||
|
||||
## Outcome
|
||||
|
||||
Add an automated repository boundary gate proving gameplay code and Godot
|
||||
`EditorPlugin` implementations do not access `StreamingWorldLoader` private
|
||||
queue, task, cache or tile-state fields.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Change renderer behavior, queues, jobs, cache formats or facade contracts.
|
||||
- Ban scene composition references to the loader script or facade adapter path.
|
||||
- Migrate renderer-only diagnostic probes that inspect internal readiness/terrain;
|
||||
those require the later metrics/ground-query facade contract.
|
||||
- Mark M03 complete.
|
||||
|
||||
## Paths
|
||||
|
||||
- Exclusive: `src/tools/verify_renderer_internal_access.gd`, this claim
|
||||
- Shared/hotspots: `docs/modules/world-renderer.md`, `RENDER.md`,
|
||||
`targets/03-renderer-facade.md`
|
||||
- Read-only scan scope: `src/gameplay/**/*.gd`, `src/editor/**/*.gd`, Godot
|
||||
`EditorPlugin` package scripts under `src/**/*.gd` and `addons/**/*.gd`, and
|
||||
the streamer field declarations
|
||||
|
||||
## Contracts and data
|
||||
|
||||
- Input: repository GDScript source and actual private streamer declarations
|
||||
- Output: deterministic pass/fail diagnostics with consumer path and symbol
|
||||
- Runtime, scene, cache, coordinate and asset contracts: unchanged
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Requires: integrated facade and scheduler master `a023d76`
|
||||
- Blocks: safe service extraction and M03 dependency regression acceptance
|
||||
- External state: none
|
||||
|
||||
## Verification
|
||||
|
||||
- Commands: dedicated internal-access gate, facade regression, coordination,
|
||||
documentation and diff gates
|
||||
- Fixtures: gameplay source discovery, EditorPlugin discovery and synthetic
|
||||
forbidden symbol detection
|
||||
- Fidelity evidence: source-only enforcement; no runtime or visual code changes
|
||||
- Performance budget: one bounded repository source scan in a headless test process
|
||||
|
||||
## Documentation deliverables
|
||||
|
||||
- World-renderer boundary/verification/known-gap/source-map updates
|
||||
- Renderer implementation note documenting diagnostic-tool exception
|
||||
- No new module spec because this package adds a verifier to the existing renderer module
|
||||
|
||||
## Simplicity and naming
|
||||
|
||||
- Important name: `verify_renderer_internal_access.gd`
|
||||
- Simplest approach: derive forbidden fields from the current streamer instead
|
||||
of maintaining a second hand-written queue list
|
||||
- Rejected complexity: language plugin, custom linter framework or runtime proxy
|
||||
- Unavoidable complexity: recursive source discovery expands each actual
|
||||
`EditorPlugin` root to its package scripts while excluding reference mirrors
|
||||
|
||||
## Status
|
||||
|
||||
- State: ready-for-review
|
||||
- Done: derived 43 private renderer symbols, scanned seven gameplay and nine
|
||||
EditorPlugin-package sources, added synthetic positive/negative fixtures and docs
|
||||
- Next: M03 integrator reviews and merges `b58bf2c`
|
||||
- Blocked by:
|
||||
|
||||
<!-- OPENWC_HANDOFF:READY:M03-RND-INTERNAL-ACCESS-GATE-001:b58bf2c -->
|
||||
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-INTERNAL-ACCESS-GATE-001:7e35de7 -->
|
||||
|
||||
## Handoff
|
||||
|
||||
- Commit: `b58bf2c`
|
||||
- Results: repository gate derives private queue/task/cache/state declarations
|
||||
from the streamer and rejects external member/reflection access in gameplay and
|
||||
EditorPlugin package sources. Current scope passes with `43/7/9` inventory counts.
|
||||
- Verification: internal access, facade and budget scheduler headless contracts;
|
||||
Godot editor scan with local ignored dependencies; coordination, documentation
|
||||
and diff gates passed.
|
||||
- Fidelity: source-only enforcement changes no runtime configuration, queue order,
|
||||
cache version, placement, material, animation or visibility behavior. No
|
||||
build-12340 parity claim is added.
|
||||
- External/local inputs: ignored native DLL and two generated ADT resource scripts
|
||||
were copied from master only for facade/editor smoke and then removed. Proprietary
|
||||
extracted world data was unavailable, producing expected degraded-data diagnostics.
|
||||
- Remaining risks: renderer diagnostic terrain probe remains an explicit exception;
|
||||
whitespace-obfuscated dynamic reflection is outside the lightweight source gate;
|
||||
future non-EditorPlugin authoring code is already prohibited by repository policy.
|
||||
- Documentation: world-renderer boundary/verification/capability/known-gap/source
|
||||
map and `RENDER.md` implementation note updated; no new module was introduced.
|
||||
- Integration: accepted in master merge `7e35de7`; post-merge access gate
|
||||
`private_symbols=43 gameplay=7 editor_sources=9`, facade/scheduler/planner and
|
||||
repository gates passed.
|
||||
@@ -0,0 +1,116 @@
|
||||
# M03-RND-SCHEDULER-001 — Bounded render budget scheduler
|
||||
|
||||
<!-- OPENWC_CLAIM:M03-RND-SCHEDULER-001:sindo-main-codex:2026-07-18 -->
|
||||
|
||||
## Ownership
|
||||
|
||||
- Target: M03
|
||||
- Program: RND
|
||||
- Owner/Agent ID: sindo-main-codex
|
||||
- Branch: `work/sindo-main-codex/m03-render-budget-scheduler`
|
||||
- Lease expires UTC: 2026-07-18
|
||||
- Integrator: M03 milestone integrator
|
||||
|
||||
## Outcome
|
||||
|
||||
Extract the streamer's per-frame main-thread operation quotas into a scene-free
|
||||
`RenderBudgetScheduler`, preserving existing queue priority and quota values and
|
||||
stopping permit issuance after renderer teardown cancellation.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Move queue, worker-task, cache, Node, Resource or RID ownership out of the streamer.
|
||||
- Change queue ordering, concurrency limits, exported defaults or visual behavior.
|
||||
- Add a generic job framework, priority graph, time-based adaptive budgeting or dependency.
|
||||
- Change cache formats, renderer profiles or target milestone status.
|
||||
|
||||
## Paths
|
||||
|
||||
- Exclusive: `src/render/streaming/render_budget_scheduler.gd`,
|
||||
`src/tools/verify_render_budget_scheduler.gd`,
|
||||
`docs/modules/render-budget-scheduler.md`, this claim
|
||||
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
|
||||
`docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`,
|
||||
`targets/03-renderer-facade.md`
|
||||
- Generated/ignored: `.godot`, native DLL, generated cache resources and
|
||||
proprietary renderer corpus
|
||||
|
||||
## Contracts and data
|
||||
|
||||
- Input: immutable per-frame map from documented operation lane IDs to
|
||||
non-negative operation limits
|
||||
- Output: boolean permits and remaining/consumed diagnostic snapshots
|
||||
- Lifecycle: `begin_frame` resets permits; `cancel` permanently rejects permits
|
||||
for the current scheduler instance
|
||||
- Queue contents, ordering, task concurrency and schema/cache versions: unchanged
|
||||
- Consumer: `StreamingWorldLoader` main-thread queue/finalize drains
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Requires: integrated M03 facade and streaming planner on master `650d584`
|
||||
- Blocks: renderer performance regression gate and per-service extraction
|
||||
- External state: none; tests use synthetic lane limits and no world assets
|
||||
|
||||
## Verification
|
||||
|
||||
- Commands: dedicated scheduler verifier, facade/planner/focus/coordinate
|
||||
regressions, renderer manifest/dry-run and repository gates
|
||||
- Fixtures: shared lane priority, independent lanes, zero/negative limits,
|
||||
frame reset, unknown lane and cancellation
|
||||
- Fidelity evidence: operation defaults and loop order are captured before
|
||||
migration; all seven renderer checkpoint plans remain valid
|
||||
- Performance budget: permit checks are constant-time dictionary operations and
|
||||
create no Node, Resource, worker task, cache entry or RID
|
||||
|
||||
## Documentation deliverables
|
||||
|
||||
- Inline API docs for scheduler lifecycle, permits and diagnostics
|
||||
- New module specification with input/output table, data-flow, sequence and
|
||||
lifecycle diagrams
|
||||
- Updated world-renderer flow/ownership/source map, module registry and renderer notes
|
||||
|
||||
## Simplicity and naming
|
||||
|
||||
- Important name: `RenderBudgetScheduler`
|
||||
- Simplest approach: one small main-thread service with named quota lanes
|
||||
- Rejected complexity: generic job graph, callbacks, queue ownership, adaptive
|
||||
timing controller, service container or third-party dependency
|
||||
- Unavoidable complexity: chunk removal and creation intentionally share one
|
||||
lane so removal retains priority while consuming the same historical budget
|
||||
- Measured optimization evidence: no optimization claim; exact bounded extraction
|
||||
|
||||
## Status
|
||||
|
||||
- State: ready-for-review
|
||||
- Done: extracted all 16 historical operation quotas, migrated ordered drains,
|
||||
added terminal teardown cancellation, asset-free verification and module docs
|
||||
- Next: M03 integrator reviews and merges `e52f703`
|
||||
- Blocked by:
|
||||
|
||||
<!-- OPENWC_HANDOFF:READY:M03-RND-SCHEDULER-001:e52f703 -->
|
||||
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-SCHEDULER-001:7ece2ab -->
|
||||
|
||||
## Handoff
|
||||
|
||||
- Commit: `e52f703`
|
||||
- Results: `RenderBudgetScheduler` owns only per-frame lane counters and terminal
|
||||
cancellation; `StreamingWorldLoader` retains queue order, task/cache/node/RID
|
||||
ownership and operation execution. Chunk removal/create share one historical budget.
|
||||
- Verification: scheduler `cases=6 iterations=20000 elapsed_ms=7.495`; planner
|
||||
`cases=5 average_ms=1.879`; facade/focus contracts passed; coordinate boundary
|
||||
`files=111 consumers=6`; renderer manifest `checkpoints=7 coverage=7 caches=7`;
|
||||
shutdown ownership, documentation, coordination and diff gates passed.
|
||||
- Fidelity: exported defaults, quality presets, queue order, operation sites,
|
||||
cache versions and visible rendering rules are unchanged. Seven checkpoint
|
||||
plans remain valid. No build-12340 parity claim is added.
|
||||
- External/local inputs: ignored native DLL and two generated ADT resource scripts
|
||||
were copied from master only for the worktree parse/smoke and then removed.
|
||||
Proprietary extracted ADT data was unavailable, producing expected degraded-data diagnostics.
|
||||
- Remaining risks: queue storage length and worker concurrency remain streamer-owned;
|
||||
cancellation stops permit issuance but does not interrupt in-flight work; exact
|
||||
p95/p99 impact still requires an asset-backed baseline.
|
||||
- Documentation: new scheduler module spec with API, input/output, data-flow,
|
||||
sequence, lifecycle, ownership, cancellation, performance and source-map sections;
|
||||
world renderer, module registry and renderer implementation notes updated.
|
||||
- Integration: accepted in master merge `7ece2ab`; post-merge scheduler timing
|
||||
`cases=6 iterations=20000 elapsed_ms=10.216`, all listed regressions and gates passed.
|
||||
@@ -0,0 +1,120 @@
|
||||
# M03-RND-STREAMING-PLANNER-001 — Pure streaming target planner
|
||||
|
||||
<!-- OPENWC_CLAIM:M03-RND-STREAMING-PLANNER-001:sindo-main-codex:2026-07-17 -->
|
||||
|
||||
## Ownership
|
||||
|
||||
- Target: M03
|
||||
- Program: RND
|
||||
- Owner/Agent ID: sindo-main-codex
|
||||
- Branch: `work/sindo-main-codex/m03-streaming-target-planner`
|
||||
- Lease expires UTC: 2026-07-17
|
||||
- Integrator: M03 milestone integrator
|
||||
|
||||
## Outcome
|
||||
|
||||
Extract runtime/editor ADT wanted/retained-set calculation and boundary prefetch
|
||||
selection from `StreamingWorldLoader` into a scene-free, deterministic
|
||||
`StreamingTargetPlanner` with typed focus input and asset-free contract tests.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Change load/retain radii, boundary thresholds, queue priority or LOD policy.
|
||||
- Move queue mutation, async jobs, cache ownership or GPU finalization.
|
||||
- Extract `RenderBudgetScheduler` or asset services in this package.
|
||||
- Change cache formats, renderer profiles or target milestone status.
|
||||
|
||||
## Paths
|
||||
|
||||
- Exclusive: `src/render/streaming/streaming_target_planner.gd`,
|
||||
`src/render/streaming/streaming_target_policy.gd`,
|
||||
`src/render/streaming/streaming_target_plan.gd`,
|
||||
`src/tools/verify_streaming_target_planner.gd`,
|
||||
`docs/modules/streaming-target-planner.md`, this claim
|
||||
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
|
||||
`src/tools/verify_coordinate_conversion_boundaries.gd`,
|
||||
`docs/modules/world-renderer.md`, `docs/modules/coordinate-mapping.md`,
|
||||
`docs/modules/README.md`, `RENDER.md`
|
||||
- Generated/ignored: `.godot`, native DLL, generated cache resources and
|
||||
proprietary renderer corpus
|
||||
|
||||
## Contracts and data
|
||||
|
||||
- Input: immutable `GodotWorldPosition`, available-tile key set and immutable
|
||||
`StreamingTargetPolicy`
|
||||
- Output: immutable `StreamingTargetPlan` with focus tile plus detached
|
||||
wanted/retained tile-key sets
|
||||
- Tile-key representation remains the streamer's existing `<x>_<y>` internal key
|
||||
- Schema/cache/coordinate versions: unchanged
|
||||
- Consumers: `StreamingWorldLoader` runtime/editor refresh paths
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Requires: M01 typed coordinates/focus and integrated facade package `a100ced`
|
||||
- Blocks: budget scheduler extraction and planner performance regression gate
|
||||
- External state: none; tests use synthetic tile catalogs
|
||||
|
||||
## Verification
|
||||
|
||||
- Commands: dedicated planner verifier, facade/focus/coordinate regressions,
|
||||
renderer manifest/dry-run and repository gates
|
||||
- Fixtures: center, edge, corner, unavailable-tile, clamped-radius and editor cases
|
||||
- Fidelity evidence: expected tile sets are captured from current formulas; full
|
||||
renderer dry-run retains all seven checkpoint plans
|
||||
- Performance budget: planner work remains bounded by square radii and creates no
|
||||
Node, Resource, task, cache or RID
|
||||
|
||||
## Documentation deliverables
|
||||
|
||||
- Inline API docs for planner/policy/plan
|
||||
- New module specification with input/output table and data-flow diagram
|
||||
- Updated world-renderer data-flow/sequence/ownership/source map
|
||||
- Updated module registry and renderer notes
|
||||
|
||||
## Simplicity and naming
|
||||
|
||||
- Important names: `StreamingTargetPlanner`, `StreamingTargetPolicy`,
|
||||
`StreamingTargetPlan`
|
||||
- Simplest approach: one pure calculator plus two immutable value objects
|
||||
- Rejected complexity: generic graph planner, priority framework, service
|
||||
container, job ownership or duplicate available-tile catalog
|
||||
- Unavoidable complexity: boundary-prefetch cross product must preserve corner
|
||||
behavior and existing string tile keys
|
||||
- Measured optimization evidence: no new optimization; exact extraction
|
||||
|
||||
## Status
|
||||
|
||||
- State: ready-for-review
|
||||
- Done: extracted immutable policy/plan and pure planner, migrated runtime/editor
|
||||
target refresh, added behavior/dependency/performance coverage and module docs
|
||||
- Next: integrated in master; next M03 package extracts the budget scheduler
|
||||
- Blocked by:
|
||||
|
||||
<!-- OPENWC_HANDOFF:READY:M03-RND-STREAMING-PLANNER-001:ad8fc41 -->
|
||||
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-STREAMING-PLANNER-001:643850c -->
|
||||
|
||||
## Handoff
|
||||
|
||||
- Commit: `e887c3b`; cold-start fix `ad8fc41`
|
||||
- Integration: accepted in master merges `d0f74f8` and `643850c`
|
||||
- Results: runtime/editor wanted and retained ADT sets plus boundary-prefetch
|
||||
centers are calculated by scene-free `StreamingTargetPlanner`; streamer keeps
|
||||
queue ordering, LOD/detail state, jobs, caches, nodes and RIDs.
|
||||
- Verification: planner `cases=5 iterations=250 average_ms=1.765`; facade
|
||||
`delegation=3 runtime_scenes=2 tools=3`; focus `contract=1 runtime_scenes=2
|
||||
capture_tools=3`; coordinate boundary `files=109 consumers=6`; renderer
|
||||
manifest `checkpoints=7 coverage=7 caches=7`; shutdown ownership passed;
|
||||
documentation, coordination and diff gates passed.
|
||||
- Fidelity: center/corner/catalog/clamp/editor fixtures preserve the extracted
|
||||
formulas; seven-checkpoint dry-run retained all plans. No radius, threshold,
|
||||
load priority, cache version, placement, material, animation or visibility
|
||||
rule changed. No build-12340 parity claim is added.
|
||||
- External/local inputs: ignored native DLL and generated ADT resource scripts
|
||||
were copied from master for worktree smoke only. Proprietary ADT and character
|
||||
assets were unavailable, so the dry-run emitted expected degraded-data diagnostics.
|
||||
- Remaining risks: exact p95/p99 impact still needs an asset-backed baseline;
|
||||
pathological custom radii are not benchmarked; tile-key strings remain the
|
||||
existing internal catalog representation; queue/LOD application remains monolithic.
|
||||
- Documentation updated: new planner module spec with API/input/output/data-flow/
|
||||
sequence/ownership/recovery/performance/source-map sections; world renderer,
|
||||
coordinate mapping, module registry and renderer implementation notes updated.
|
||||
@@ -11,7 +11,12 @@
|
||||
| Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) |
|
||||
| Player input | Partial | [`player-input.md`](player-input.md) |
|
||||
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) |
|
||||
| Terrain query | Implemented | [`terrain-query.md`](terrain-query.md) |
|
||||
| Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) |
|
||||
| Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) |
|
||||
| Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) |
|
||||
| Streaming target planner | Implemented | [`streaming-target-planner.md`](streaming-target-planner.md) |
|
||||
| Render budget scheduler | Implemented | [`render-budget-scheduler.md`](render-budget-scheduler.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) |
|
||||
| UI/Lua/audio | Prototype/Planned | [`../../targets/roadmap/05-ui-lua-audio.md`](../../targets/roadmap/05-ui-lua-audio.md) |
|
||||
|
||||
@@ -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`.
|
||||
@@ -5,9 +5,9 @@
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Status | Implemented |
|
||||
| Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002`, `M01-QAR-COORD-FIXTURES-001`, `M01-FND-COORD-BOUNDARY-GATE-001`, `M01-QAR-SERVER-SPAWN-RENDERER-001` |
|
||||
| Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002`, `M01-QAR-COORD-FIXTURES-001`, `M01-FND-COORD-BOUNDARY-GATE-001`, `M01-QAR-SERVER-SPAWN-RENDERER-001`, `M02-GMP-TERRAIN-QUERY-001` consumer migration |
|
||||
| Owners | Foundation/domain |
|
||||
| Last verified | Worktree `work/sindo-main-codex/m01-server-spawn-renderer`, 2026-07-13 |
|
||||
| Last verified | `a45d521`, 2026-07-14 |
|
||||
| Profiles/capabilities | All profiles; contract version 2 |
|
||||
|
||||
## Purpose
|
||||
@@ -86,7 +86,7 @@ Forbidden dependencies:
|
||||
| Input | `AdtPlacementPosition` | Future ADT adapter | `CoordinateMapper` | Immutable caller value | Any thread, call duration |
|
||||
| Input | `CanonicalWowWorldPosition`, `CanonicalWowWorldYaw` | Gameplay/content/domain | `CoordinateMapper` | Immutable caller value | Any thread, call duration |
|
||||
| Output | `CanonicalWowWorldPosition` | `CoordinateMapper` | Gameplay/content/adapters | New immutable value | Caller-owned reference |
|
||||
| Output | `AdtTileCoordinate`, `AdtTileLocalPosition`, `AdtChunkCoordinate` | `CoordinateMapper` | Streaming/content adapters | New immutable values | Caller-owned references |
|
||||
| Output | `AdtTileCoordinate`, `AdtTileLocalPosition`, `AdtChunkCoordinate` | `CoordinateMapper` | Streaming/content/terrain-query adapters | New immutable values | Caller-owned references |
|
||||
| Output | `GodotWorldPosition`, `GodotWorldYaw` | `CoordinateMapper` | Future render/scene adapter | New immutable values | Caller-owned references |
|
||||
| Test input | `coordinate_golden_points.json` schema 1 | Pinned public server row plus private numeric observations | Headless fixture verifier | Versioned repository fixture | Test-process lifetime |
|
||||
|
||||
@@ -115,6 +115,13 @@ flowchart LR
|
||||
Canonical -->|half extent - Y; Z; half extent - X| Godot[GodotWorldPosition]
|
||||
Godot -->|typed direct adapter| Tile
|
||||
Godot -->|typed direct adapter| Local
|
||||
Godot --> TerrainQuery[AdtTerrainQuery consumer]
|
||||
Godot --> TargetPlanner[StreamingTargetPlanner consumer]
|
||||
TerrainQuery --> Tile
|
||||
TerrainQuery --> Local
|
||||
TerrainQuery --> Chunk
|
||||
TargetPlanner --> Tile
|
||||
TargetPlanner --> Local
|
||||
Tile -->|tile plus local| Godot
|
||||
ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw]
|
||||
CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw]
|
||||
@@ -185,7 +192,8 @@ raw three-number arrays are not an accepted new persistence contract.
|
||||
- Boundary enforcement: `src/tools/verify_coordinate_conversion_boundaries.gd`.
|
||||
- Server-spawn renderer contract: `src/tools/verify_server_spawn_renderer.gd`
|
||||
validates the dedicated manifest against the same pinned fixture before GUI capture.
|
||||
- Integration/E2E: sky, player, streamer and terrain probe use mapper boundaries;
|
||||
- Integration/E2E: sky, player spawn, streamer, terrain probe and the extracted
|
||||
`AdtTerrainQuery` use mapper boundaries;
|
||||
`StreamingFocus` provides typed renderer focus, and the pinned AzerothCore
|
||||
Human Warrior spawn is captured in the Eastern Kingdoms renderer at ADT
|
||||
`(32,48)`, chunk `(3,12)`.
|
||||
@@ -243,6 +251,8 @@ raw three-number arrays are not an accepted new persistence contract.
|
||||
| `src/tests/fixtures/coordinate_golden_points.json` | Versioned cross-source values, provenance and tolerances |
|
||||
| `src/tools/verify_coordinate_golden_fixtures.gd` | Cross-source fixture schema and mapper validation |
|
||||
| `src/tools/verify_coordinate_conversion_boundaries.gd` | Repository-wide manual conversion and required-consumer gate |
|
||||
| `src/gameplay/terrain/adt_terrain_query.gd` | Typed Godot-position consumer for ADT tile/local/chunk lookup |
|
||||
| `src/render/streaming/streaming_target_planner.gd` | Typed Godot-position consumer for ADT focus tile and boundary-local lookup |
|
||||
| `src/tools/server_spawn_render_manifest.json` | Pinned server-spawn camera, player, marker and expected ADT ownership |
|
||||
| `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer manifest contract verifier |
|
||||
| `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision |
|
||||
|
||||
@@ -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 | `435e1c9`, 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[Ground snap adapter]
|
||||
Velocity --> Presentation[Visual facing and animation adapter]
|
||||
Scene --> Terrain[TerrainQuery and ground snap 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,7 +179,9 @@ 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 |
|
||||
| Missing terrain data | Outside module | Scene skips existing ground snap | Existing terrain diagnostics | TerrainQuery extraction will own policy |
|
||||
| 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
|
||||
controller resets velocity and flight state deterministically.
|
||||
@@ -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,18 +247,25 @@ 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 |
|
||||
| Terrain/collision movement policy | Planned | Outside module by design | Extract `TerrainQuery` next |
|
||||
| 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 |
|
||||
| Prediction/reconciliation | Planned | M08/M09 roadmap | Requires movement snapshot/network contract |
|
||||
|
||||
## Known gaps and risks
|
||||
|
||||
- Numeric defaults preserve the existing sandbox but are not original-client evidence.
|
||||
- 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 until `TerrainQuery` extraction.
|
||||
- Sprint and free flight still lack a typed sandbox capability gate.
|
||||
- Ground snap remains in the player scene, but its ADT data access is now behind
|
||||
a replaceable `TerrainQuery`.
|
||||
- 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.
|
||||
|
||||
@@ -230,10 +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/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain/presentation adapters |
|
||||
| `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_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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Render Budget Scheduler
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Status | Implemented |
|
||||
| Target/work package | `M03-RND-SCHEDULER-001` |
|
||||
| Owners | Renderer workstream / M03 integrator |
|
||||
| Last verified | Worktree `work/sindo-main-codex/m03-render-budget-scheduler`, 2026-07-16 |
|
||||
| Profiles/capabilities | Runtime and Editor main-thread operation quotas; profile values supplied by streamer composition |
|
||||
|
||||
## Purpose
|
||||
|
||||
Issue a bounded number of permits for each renderer operation lane during one
|
||||
process frame. The scheduler centralizes quota accounting and teardown
|
||||
cancellation without taking ownership of queues or render work.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Own, sort, drain or cap the stored length of a renderer queue.
|
||||
- Submit, cancel or await worker and `ResourceLoader` tasks.
|
||||
- Execute callbacks or mutate the SceneTree, resources, caches or RIDs.
|
||||
- Adapt limits from elapsed time or create a general job/dependency framework.
|
||||
- Select renderer quality or compatibility profiles.
|
||||
|
||||
## Context and boundaries
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Config[Streamer exported per-frame limits] --> Frame[operation limit snapshot]
|
||||
Frame --> Scheduler[RenderBudgetScheduler]
|
||||
Queues[StreamingWorldLoader owned queues] --> Drains[Ordered queue drains]
|
||||
Scheduler -->|boolean permits| Drains
|
||||
Drains --> Render[Main-thread finalize/attach/evict]
|
||||
Shutdown[StreamingWorldLoader exit] -->|cancel| Scheduler
|
||||
Scheduler -. does not own .-> Queues
|
||||
```
|
||||
|
||||
Allowed dependencies are Godot value containers and `RefCounted`. The module
|
||||
must not depend on `Node`, `WorkerThreadPool`, `ResourceLoader`, `RenderingServer`,
|
||||
mutexes, renderer assets or streamer queue fields.
|
||||
|
||||
## Public API
|
||||
|
||||
| Symbol | Kind | Purpose | Thread/lifetime | Failure behavior |
|
||||
|---|---|---|---|---|
|
||||
| Lane constants | `StringName` constants | Stable operation categories used in the frame limit map | Process lifetime | Unknown lanes have zero permits |
|
||||
| `begin_frame` | Method | Replaces all limits and clears consumption diagnostics for one frame | Main thread; once before drains | Negative limits clamp to zero; cannot revive a cancelled instance |
|
||||
| `has_remaining_permit` | Query | Tests a lane without consuming | Main thread; current frame | Unknown/exhausted/cancelled returns `false` |
|
||||
| `try_consume_permit` | Mutation | Atomically checks and consumes one caller operation permit | Main thread; current frame | Unknown/exhausted/cancelled returns `false` |
|
||||
| `remaining_permits` | Query | Returns a lane's current unconsumed count | Main thread; current frame | Unknown/cancelled returns zero |
|
||||
| `consumed_permits_snapshot` | Query | Returns detached per-lane diagnostics | Caller owns returned dictionary | Mutation cannot affect scheduler state |
|
||||
| `cancel` | Lifecycle method | Permanently stops new permits and clears remaining limits | Main thread; renderer teardown | Idempotent; does not interrupt in-flight caller work |
|
||||
| `is_cancelled` | Query | Reports terminal lifecycle state | Scheduler lifetime | No failure |
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
||||
|---|---|---|---|---|---|
|
||||
| Input | `Dictionary[StringName, int]` operation limits | `StreamingWorldLoader` configuration snapshot | Scheduler | Copied by scheduler | One process frame |
|
||||
| Input | Lane ID | Ordered streamer drain | Scheduler | Constant value | One permit check |
|
||||
| Input | Cancellation | Streamer `_exit_tree` | Scheduler | Terminal flag | Renderer instance lifetime |
|
||||
| Output | Permit boolean | Scheduler | Streamer drain loop | Scalar | One attempted operation |
|
||||
| Output | Remaining count | Scheduler | Streamer/tests | Scalar | Current frame |
|
||||
| Output | Consumed snapshot | Scheduler | Diagnostics/tests | Detached caller-owned copy | Current frame snapshot |
|
||||
|
||||
The scheduler has no I/O, logging, SceneTree, task, cache or GPU side effects.
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Exports[Exported operation limits] --> Limits[_render_operation_limits_for_frame]
|
||||
Limits --> Begin[begin_frame]
|
||||
Begin --> Remaining[remaining permits by lane]
|
||||
Queue[Non-empty caller-owned queue] --> Check[try_consume_permit]
|
||||
Remaining --> Check
|
||||
Check -->|true| Execute[Caller performs one operation]
|
||||
Check -->|false| Defer[Operation remains queued]
|
||||
Execute --> Consumed[consumed permits diagnostics]
|
||||
```
|
||||
|
||||
## Lifecycle and state
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Ready
|
||||
Ready --> FrameActive: begin_frame
|
||||
FrameActive --> FrameActive: consume/check
|
||||
FrameActive --> FrameActive: begin_frame resets limits
|
||||
Ready --> Cancelled: cancel
|
||||
FrameActive --> Cancelled: cancel
|
||||
Cancelled --> Cancelled: begin_frame/check/cancel
|
||||
Cancelled --> [*]
|
||||
```
|
||||
|
||||
Cancellation is intentionally terminal. A new renderer scene creates a new
|
||||
scheduler instance; an old instance cannot accidentally resume after teardown.
|
||||
|
||||
## Main sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Stream as StreamingWorldLoader
|
||||
participant Budget as RenderBudgetScheduler
|
||||
participant Queue as Loader-owned queues
|
||||
participant Render as SceneTree/RenderingServer
|
||||
Stream->>Budget: begin_frame(operation limits)
|
||||
loop existing fixed drain order
|
||||
Stream->>Queue: inspect next operation
|
||||
Stream->>Budget: try_consume_permit(lane)
|
||||
Budget-->>Stream: true or false
|
||||
Stream->>Render: execute one operation when true
|
||||
end
|
||||
Stream->>Budget: cancel() during _exit_tree
|
||||
Budget-->>Stream: later permits rejected
|
||||
Stream->>Stream: await tasks and release owned resources
|
||||
```
|
||||
|
||||
## Ownership, threading and resources
|
||||
|
||||
- `StreamingWorldLoader` owns every queue, task registry, cache, Node, Resource and RID.
|
||||
- The scheduler owns two small per-frame counter dictionaries and one terminal flag.
|
||||
- The streamer preserves operation order. In particular, chunk removals and
|
||||
creations share `CHUNK_GEOMETRY`, with removals drained first.
|
||||
- All current scheduler calls occur on the Godot main thread. The service has no
|
||||
mutex and is not safe for concurrent mutation.
|
||||
- A scheduler instance lives exactly as long as its loader instance.
|
||||
|
||||
## Errors, cancellation and recovery
|
||||
|
||||
| Condition | Detection | Behavior | Diagnostic | Recovery |
|
||||
|---|---|---|---|---|
|
||||
| Negative/zero limit | `begin_frame` clamp | Lane issues no permits | Contract verifier | Correct configuration or accept disabled lane |
|
||||
| Unknown lane | Permit lookup miss | Returns `false`/zero | Contract verifier | Add the lane to the frame snapshot |
|
||||
| Exhausted lane | Remaining count is zero | Work remains in caller queue for a later frame | Queue-depth/hitch metrics | Next `begin_frame` replenishes permits |
|
||||
| Renderer teardown | Streamer calls `cancel` before task waits | New permits stop immediately | Shutdown ownership regression | New scene creates a new scheduler |
|
||||
| In-flight operation at cancel | Caller already consumed permit | Scheduler does not interrupt it | Ownership docs | Caller completes/cleans it through existing shutdown path |
|
||||
|
||||
This cancellation scope is permit issuance, not worker cancellation. Existing
|
||||
task/result staleness and shutdown waits remain streamer responsibilities.
|
||||
|
||||
## Configuration and capabilities
|
||||
|
||||
The streamer's existing exported values populate 16 lanes: tile finalization,
|
||||
terrain upgrade/control-splat/splat-cache/splat-build, water finalization, shared
|
||||
chunk geometry, tile-load starts, tile LOD create/remove, M2 animation/mesh
|
||||
finalization and build, WMO instance/group build, and detail synchronization.
|
||||
Defaults and quality-preset assignments are unchanged. `tile_finalize` retains
|
||||
its historical minimum of one; other negative values behave as zero.
|
||||
|
||||
## Persistence, cache and migration
|
||||
|
||||
The module is runtime-only and serializes nothing. It changes no cache, asset,
|
||||
scene, protocol or database format and requires no version bump or migration.
|
||||
|
||||
## Diagnostics and observability
|
||||
|
||||
- `consumed_permits_snapshot` exposes detached per-frame counts for tests and
|
||||
future facade metrics without exposing mutable scheduler state.
|
||||
- Existing queue depths, named hitch sections and `HITCH`/`PERF` logs remain in
|
||||
`StreamingWorldLoader`.
|
||||
- No scheduler logging occurs on the hot permit path.
|
||||
|
||||
## Verification
|
||||
|
||||
- `src/tools/verify_render_budget_scheduler.gd` covers exact exhaustion,
|
||||
independent lanes, shared removal/create priority, frame reset, invalid/unknown
|
||||
lanes, detached diagnostics, terminal cancellation and source boundaries.
|
||||
- The verifier performs 20,000 permit checks with a generous 150 ms regression
|
||||
ceiling on the headless runner; this is a guard, not a renderer p95 claim.
|
||||
- Integration regressions must retain the seven renderer checkpoint plans and
|
||||
existing focus/planner/facade contracts.
|
||||
- Fidelity evidence is behavior-preserving: exported values, process order,
|
||||
cache versions and visual operations are unchanged. No build-12340 parity
|
||||
claim follows from this extraction.
|
||||
|
||||
## Extension points
|
||||
|
||||
- Add facade consumption metrics using detached snapshots.
|
||||
- Move a service's queue drain behind its own boundary while retaining these lane IDs.
|
||||
- Add measured time-aware policy only after asset-backed p95/p99 evidence proves
|
||||
fixed operation counts insufficient; do not embed callbacks in this scheduler.
|
||||
|
||||
## Capability status
|
||||
|
||||
| Capability | Status | Evidence | Gap/next step |
|
||||
|---|---|---|---|
|
||||
| Per-lane frame bounds | Implemented | Exact exhaustion and independent-lane contracts | Asset-backed p95/p99 baseline remains |
|
||||
| Shared removal/create priority | Implemented | Synthetic shared `CHUNK_GEOMETRY` contract | Other service priorities remain streamer-owned |
|
||||
| Teardown permit cancellation | Implemented | Terminal cancellation contract and loader source gate | Worker stale-result cancellation remains streamer-owned |
|
||||
| Detached consumption diagnostics | Implemented | Snapshot isolation contract | Not yet exposed through facade metrics |
|
||||
|
||||
## Known gaps and risks
|
||||
|
||||
- Queue storage length is not capped; only per-frame draining is bounded.
|
||||
- Worker concurrency and stale-result cancellation remain existing streamer logic.
|
||||
- Exact p95/p99 impact requires local extracted assets and paired baseline runs.
|
||||
- Lane IDs are dynamic dictionary keys; the verifier guards the current contract.
|
||||
|
||||
## Source map
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `src/render/streaming/render_budget_scheduler.gd` | Scene-free lane counters, permits, diagnostics and cancellation |
|
||||
| `src/scenes/streaming/streaming_world_loader.gd` | Limit composition, fixed drain order, queue/resource ownership and operation execution |
|
||||
| `src/tools/verify_render_budget_scheduler.gd` | Asset-free behavior, dependency and bounded timing regression |
|
||||
|
||||
## Related decisions and references
|
||||
|
||||
- [`world-renderer.md`](world-renderer.md)
|
||||
- [`streaming-target-planner.md`](streaming-target-planner.md)
|
||||
- [`../../targets/03-renderer-facade.md`](../../targets/03-renderer-facade.md)
|
||||
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
|
||||
- [`../CODING_STANDARD.md`](../CODING_STANDARD.md)
|
||||
@@ -0,0 +1,239 @@
|
||||
# Streaming Target Planner
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Status | Implemented |
|
||||
| Target/work package | `M03-RND-STREAMING-PLANNER-001` |
|
||||
| Owners | Renderer workstream / M03 integrator |
|
||||
| Last verified | `e887c3b`, 2026-07-15 |
|
||||
| Profiles/capabilities | Runtime and Editor preview ADT target selection; profile values supplied by caller |
|
||||
|
||||
## Purpose
|
||||
|
||||
Calculate which available ADT tiles are wanted and retained for one typed focus
|
||||
without accessing the SceneTree, worker tasks, renderer queues, caches or GPU
|
||||
resources. Runtime planning preserves prewarm, hysteresis and boundary-prefetch
|
||||
behavior; editor planning preserves the square preview radius.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Mutate load/finalize/eviction queues.
|
||||
- Prioritize queued tile paths by world distance.
|
||||
- Calculate chunk or tile LOD levels after a tile is loaded.
|
||||
- Own the available WDT tile catalog or cache paths.
|
||||
- Create, cancel or await jobs.
|
||||
- Implement M2, WMO, liquid or terrain attachment.
|
||||
|
||||
## Context and boundaries
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Focus[GodotWorldPosition] --> Planner[StreamingTargetPlanner]
|
||||
Policy[StreamingTargetPolicy] --> Planner
|
||||
Catalog[Available tile keys from WDT/directory] --> Planner
|
||||
Planner --> Plan[StreamingTargetPlan]
|
||||
Plan --> Streamer[StreamingWorldLoader queue/state apply]
|
||||
Streamer --> Queue[Tile load queue]
|
||||
```
|
||||
|
||||
Allowed dependencies:
|
||||
|
||||
- typed coordinate values and `CoordinateMapper`;
|
||||
- immutable planner policy/result contracts;
|
||||
- read-only available-tile key membership.
|
||||
|
||||
Forbidden dependencies:
|
||||
|
||||
- `Node`, SceneTree and viewport/camera discovery;
|
||||
- `WorkerThreadPool`, `ResourceLoader` and filesystem access;
|
||||
- `RenderingServer`, meshes, materials, nodes and RIDs;
|
||||
- gameplay, network, UI, editor UI or server adapters;
|
||||
- streamer queues, tile states and cache dictionaries.
|
||||
|
||||
## Public API
|
||||
|
||||
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|
||||
|---|---|---|---|---|
|
||||
| `StreamingTargetPlanner.plan_runtime` | Pure method | Builds wanted/retained sets with edge/corner prefetch | Any thread if caller does not mutate catalog concurrently; call-scoped | Empty catalog produces empty sets |
|
||||
| `StreamingTargetPlanner.plan_editor` | Pure method | Builds identical editor wanted/retained square sets | Same as runtime planning | Negative radius clamps to zero |
|
||||
| `StreamingTargetPolicy` | Immutable value | Captures radius margins and boundary threshold for one plan | Caller-owned, shareable | Values retain scene input; effective margins/threshold are clamped |
|
||||
| `visible_tile_radius` | Pure query | Resolves chunk-radius versus tile-radius visibility | Any thread/value lifetime | No failure |
|
||||
| `load_tile_radius` | Pure query | Adds non-negative prewarm margin | Any thread/value lifetime | No failure |
|
||||
| `retained_tile_radius` | Pure query | Adds non-negative hysteresis margin | Any thread/value lifetime | No failure |
|
||||
| `StreamingTargetPlan` | Immutable value | Holds focus tile and read-only wanted/retained key sets | Caller-owned plan lifetime | No failure |
|
||||
| `wanted_tile_keys` | Read-only query | Returns immutable wanted set | Plan lifetime | Mutation is rejected by Godot read-only Dictionary |
|
||||
| `retained_tile_keys` | Read-only query | Returns immutable retained set | Plan lifetime | Mutation is rejected by Godot read-only Dictionary |
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
||||
|---|---|---|---|---|---|
|
||||
| Input | `GodotWorldPosition` | `WorldRenderFacade`/streamer focus adapter | Planner | Immutable caller value | One plan |
|
||||
| Input | Available `<tile_x>_<tile_y>` keys | `StreamingWorldLoader` WDT/directory catalog | Planner membership checks | Loader-owned, read-only during call | Map session |
|
||||
| Input | `StreamingTargetPolicy` | Streamer configuration snapshot | Planner | Immutable caller value | One or more plans |
|
||||
| Input | Editor preview radius | Editor scene configuration | Planner | Scalar copy | One plan |
|
||||
| Output | `StreamingTargetPlan.focus_tile` | Planner/`CoordinateMapper` | Streamer diagnostics | Immutable typed coordinate | Plan lifetime |
|
||||
| Output | Wanted tile-key set | Planner | Streamer queue/state apply | Plan-owned read-only Dictionary | Plan lifetime |
|
||||
| Output | Retained tile-key set | Planner | Streamer hysteresis/state apply | Plan-owned read-only Dictionary | Plan lifetime |
|
||||
|
||||
Side effects:
|
||||
|
||||
- none;
|
||||
- no input mutation;
|
||||
- no logging, I/O, task submission, cache access, SceneTree or GPU work.
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Position[GodotWorldPosition] --> Tile[CoordinateMapper.godot_to_adt_tile]
|
||||
Position --> Local[CoordinateMapper.godot_to_adt_tile_local]
|
||||
Policy[StreamingTargetPolicy] --> Radii[load and retained radii]
|
||||
Policy --> Threshold[clamped boundary threshold]
|
||||
Tile --> Centers[base prefetch center]
|
||||
Local --> Centers
|
||||
Threshold --> Centers
|
||||
Centers --> Wanted[available keys within load radius]
|
||||
Centers --> Retained[available keys within retained radius]
|
||||
Catalog[Available tile keys] --> Wanted
|
||||
Catalog --> Retained
|
||||
Wanted --> Plan[StreamingTargetPlan]
|
||||
Retained --> Plan
|
||||
Tile --> Plan
|
||||
```
|
||||
|
||||
## Lifecycle/state
|
||||
|
||||
The planner is stateless. A call creates local center/set dictionaries, freezes
|
||||
the result dictionaries in `StreamingTargetPlan`, returns the plan and retains
|
||||
no reference to inputs or output. There is no retry, cancellation or shutdown
|
||||
state machine.
|
||||
|
||||
## Main sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Stream as StreamingWorldLoader
|
||||
participant Planner as StreamingTargetPlanner
|
||||
participant Mapper as CoordinateMapper
|
||||
participant Plan as StreamingTargetPlan
|
||||
Stream->>Planner: plan_runtime(catalog, typed focus, policy)
|
||||
Planner->>Mapper: focus tile and tile-local position
|
||||
Mapper-->>Planner: typed coordinates
|
||||
Planner->>Planner: prefetch centers and available key sets
|
||||
Planner->>Plan: freeze focus/wanted/retained result
|
||||
Plan-->>Stream: immutable target plan
|
||||
Stream->>Stream: apply queues, LOD and detail state
|
||||
```
|
||||
|
||||
## Ownership, threading and resources
|
||||
|
||||
- `StreamingWorldLoader` owns the available catalog and must not mutate it during a planning call.
|
||||
- `StreamingTargetPolicy` and typed focus remain caller-owned immutable values.
|
||||
- The planner owns temporary center/set values only until plan construction.
|
||||
- `StreamingTargetPlan` owns its read-only result dictionaries.
|
||||
- The planner creates no Node, Resource, RID, thread, mutex or task.
|
||||
- Current runtime calls occur on the main thread; scene-free contracts permit a
|
||||
future worker call only after catalog publication/ownership is explicit.
|
||||
|
||||
## Errors, cancellation and recovery
|
||||
|
||||
| Failure | Detection | Behavior | Diagnostic | Recovery |
|
||||
|---|---|---|---|---|
|
||||
| Empty/unavailable catalog | No membership matches | Valid plan with empty sets | Streamer retains existing missing-data diagnostics | Load a valid WDT/directory catalog and replan |
|
||||
| Focus outside catalog/grid | Typed coordinate does not match keys | Valid focus tile with filtered empty/partial sets | Caller diagnostic if required | Supply valid focus/map or catalog |
|
||||
| Negative prewarm/retain margin | Policy effective-radius query | Margin clamps to zero | Contract test | Correct configuration if negative value was unintended |
|
||||
| Boundary threshold outside range | Policy query | Clamp to `0.0..0.49` | Contract test | Correct configuration or accept safe clamp |
|
||||
| Plan no longer current | New focus/config/catalog revision | Caller discards ephemeral plan | Streamer refresh diagnostics | Replan from current inputs |
|
||||
|
||||
Cancellation is not applicable: planning is synchronous, bounded and owns no
|
||||
external work. A returned stale plan is simply not retained by the planner.
|
||||
|
||||
## Configuration and capabilities
|
||||
|
||||
| Setting/capability | Default source | Profile | Runtime mutable | Effect |
|
||||
|---|---|---|---|---|
|
||||
| `lod2_radius_chunks` | Streamer scene/profile | Renderer | Yes at composition/debug boundary | Converts chunk coverage to minimum ADT radius |
|
||||
| `lod2_tile_radius` | Streamer scene/profile | Renderer | Yes | Minimum visible ADT radius |
|
||||
| `warm_tile_margin` | Streamer scene/profile | Renderer | Yes | Extends chunk-derived visible radius |
|
||||
| `prewarm_tile_margin` | Streamer scene/profile | Renderer | Yes | Extends wanted/load radius |
|
||||
| `retain_tile_margin` | Streamer scene/profile | Renderer | Yes | Extends retained hysteresis radius |
|
||||
| `boundary_prefetch_threshold` | Streamer scene/profile | Renderer | Yes | Adds adjacent/corner prefetch centers near tile boundaries |
|
||||
| Editor preview radius | Editor scene | Authoring preview | Yes | Square wanted/retained preview set |
|
||||
|
||||
The planner does not select `Blizzlike335` or `Enhanced`; it applies the values
|
||||
already selected by renderer composition.
|
||||
|
||||
## Persistence, cache and migration
|
||||
|
||||
- Planner contracts are runtime-only and are not serialized.
|
||||
- Existing tile keys and cache paths are unchanged.
|
||||
- No cache/resource/schema version bump or migration is required.
|
||||
|
||||
## Diagnostics and observability
|
||||
|
||||
- Logs remain emitted by `StreamingWorldLoader` after applying a plan.
|
||||
- `StreamingTargetPlan` exposes focus/wanted/retained data for deterministic tests.
|
||||
- Dedicated verifier reports case count, iteration count, total time and average plan time.
|
||||
- No correlation ID is required for synchronous ephemeral plans.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit/contract: `src/tools/verify_streaming_target_planner.gd` covers center
|
||||
radii, edge/corner cross product, catalog filtering, clamps, editor behavior,
|
||||
input/result ownership and scene-free dependencies.
|
||||
- Integration: streamer source gate confirms both runtime/editor delegation and
|
||||
removal of `_predictive_focus_tiles` from the monolith.
|
||||
- Fidelity evidence: expected sets encode the pre-extraction formulas; renderer
|
||||
checkpoint dry-run must retain all seven checkpoint plans.
|
||||
- Performance budget: 250 synthetic High-like corner plans average at most
|
||||
`4.0 ms` each on the headless regression runner; measured result is recorded
|
||||
in test output.
|
||||
- Manual diagnostics: unchanged `StreamingWorld` focus/wanted/queued log.
|
||||
|
||||
## Extension points
|
||||
|
||||
- Direction/velocity/frustum priority can extend policy/input only with fixtures
|
||||
and a compatibility-safe default reproducing the current position-only plan.
|
||||
- Multi-focus planning may union additional typed centers without giving the
|
||||
planner ownership of queues or consumers.
|
||||
- Queue ordering remains a separate scheduler/application responsibility.
|
||||
|
||||
## Capability status
|
||||
|
||||
| Capability | Status | Evidence | Gap/next step |
|
||||
|---|---|---|---|
|
||||
| Runtime wanted/retained sets | Implemented | Center/corner/catalog fixtures | Add paired traversal performance evidence |
|
||||
| Boundary prefetch centers | Implemented | Edge/corner cross-product fixture | Direction/velocity prediction not implemented |
|
||||
| Editor preview set | Implemented | Negative/radius-one fixtures | Editor viewport integration remains existing adapter |
|
||||
| Scene-free deterministic execution | Implemented | Dependency/source gate and 250-iteration run | Worker execution is unnecessary today |
|
||||
| Queue priority/LOD | Not in module | Preserved in streamer | Separate extraction packages |
|
||||
|
||||
## Known gaps and risks
|
||||
|
||||
- Tile-key sets preserve the streamer's internal string representation; a typed
|
||||
catalog migration is deferred until it can remove, rather than duplicate, that source of truth.
|
||||
- Planner time scales with the union of square radii/prefetch centers. The
|
||||
current bounded regression covers High-like corner input but not pathological custom radii.
|
||||
- Exact p95/p99 frame impact still requires renderer baseline runs with local assets.
|
||||
- Direction, velocity and camera frustum do not influence target selection yet.
|
||||
|
||||
## Source map
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `src/render/streaming/streaming_target_planner.gd` | Pure runtime/editor set calculation and boundary centers |
|
||||
| `src/render/streaming/streaming_target_policy.gd` | Immutable radius/threshold snapshot and effective clamps |
|
||||
| `src/render/streaming/streaming_target_plan.gd` | Immutable typed focus and read-only result sets |
|
||||
| `src/scenes/streaming/streaming_world_loader.gd` | Planner composition and plan application to queues/state |
|
||||
| `src/tools/verify_streaming_target_planner.gd` | Asset-free behavior/dependency/performance regression |
|
||||
|
||||
## Related decisions and references
|
||||
|
||||
- [`world-renderer.md`](world-renderer.md)
|
||||
- [`../../targets/03-renderer-facade.md`](../../targets/03-renderer-facade.md)
|
||||
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
|
||||
- [`../GODOT_BEST_PRACTICES.md`](../GODOT_BEST_PRACTICES.md)
|
||||
- [`../CODING_STANDARD.md`](../CODING_STANDARD.md)
|
||||
@@ -0,0 +1,259 @@
|
||||
# Terrain Query
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Status | Implemented |
|
||||
| Target/work package | M02 / M02-GMP-TERRAIN-QUERY-001; M03 renderer consumer `M03-RND-FACADE-GROUND-QUERY-001` |
|
||||
| Owners | Gameplay terrain-query contract; ADT adapter owns parsed tile cache |
|
||||
| Last verified | `a45d521`, 2026-07-14 |
|
||||
| Profiles/capabilities | Current sandbox ADT ground height; loaded-render-mesh diagnostic query; authoritative physics backend planned |
|
||||
|
||||
## Purpose
|
||||
|
||||
Provide a replaceable, scene-free ground-height query for player movement.
|
||||
Isolate native ADT loading, per-tile caching, chunk selection and legacy outer
|
||||
height-grid interpolation from the player scene behind a typed
|
||||
`GodotWorldPosition → TerrainGroundSample` contract.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Own player transforms, movement velocity or ground-snap policy.
|
||||
- Provide collision normals, slopes, holes, liquids, WMO/M2 collision or ray queries.
|
||||
- Change ADT topology, coordinate conversion, renderer caches or native parser format.
|
||||
- Claim original-client terrain-contact parity from height regression alone.
|
||||
- Couple gameplay to the monolithic streaming renderer before a public backend exists.
|
||||
|
||||
## Context and boundaries
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Scene[ThirdPersonWowController adapter] --> Position[GodotWorldPosition]
|
||||
Position --> Contract[TerrainQuery]
|
||||
Contract --> Adapter[AdtTerrainQuery]
|
||||
Adapter --> Mapper[CoordinateMapper]
|
||||
Adapter --> Native[ADTLoader]
|
||||
Native --> Tile[ADT tile Dictionary]
|
||||
Tile --> Cache[Owned tile cache]
|
||||
Cache --> Sample[TerrainGroundSample]
|
||||
Sample --> Scene
|
||||
Scene --> Snap[Existing ground-snap policy]
|
||||
```
|
||||
|
||||
Allowed dependencies:
|
||||
|
||||
- Typed `GodotWorldPosition` and `CoordinateMapper` contracts.
|
||||
- Existing native `ADTLoader` at the ADT adapter boundary.
|
||||
- Dynamic dictionaries only inside the native-format adapter.
|
||||
- Player scene composition may inject any `TerrainQuery` implementation.
|
||||
- Renderer diagnostics reuse `GodotWorldPosition` through `WorldRenderFacade`
|
||||
but return renderer-owned `RenderedGroundSample`, preserving the render/gameplay boundary.
|
||||
|
||||
Forbidden dependencies:
|
||||
|
||||
- `TerrainQuery` contract depending on Nodes, cameras, input or renderer state.
|
||||
- Player scene loading/parsing ADT data or owning a parsed tile cache.
|
||||
- Manual WoW/Godot/ADT world-coordinate conversion outside `CoordinateMapper`.
|
||||
- Terrain query mutating transforms, gameplay movement state or visual meshes.
|
||||
|
||||
## Public API
|
||||
|
||||
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|
||||
|---|---|---|---|---|
|
||||
| `TerrainGroundSample.available(height_units)` | Immutable value factory | Creates a finite Godot-world ground height | Any thread; caller-held value | Non-finite height becomes unavailable |
|
||||
| `TerrainGroundSample.unavailable(failure_code)` | Immutable value factory | Creates explicit unavailable result | Any thread; caller-held value | Empty code normalizes to `terrain_unavailable` |
|
||||
| `TerrainQuery.sample_ground_height(position)` | Replaceable boundary | Samples ground at typed Godot world position | Implementation-defined; current adapter main-thread owned | Base reports `terrain_query_not_implemented` |
|
||||
| `AdtTerrainQuery.new(extracted_directory, map_directory_name, loader_override)` | Adapter constructor | Configures one map and optional test/data-source loader | Query lifetime | Production validates native loader and file presence lazily |
|
||||
| `AdtTerrainQuery.sample_ground_height(position)` | Adapter method | Loads/caches tile and interpolates ADT outer heights | Main thread; cache lives with query | Returns stable failure code |
|
||||
| `ThirdPersonWowController.set_terrain_query(query)` | Composition method | Replaces terrain backend independently of movement/presentation | Call before scene ready or on main thread | Null is rejected and current query retained |
|
||||
| `WorldRenderFacade.sample_ground_height(position)` | Separate renderer diagnostic API | Samples already loaded terrain Mesh and returns `RenderedGroundSample` without exposing streamer state | Main thread; immutable sample | Stable `render_terrain_*` unavailable code; not injected into player by default |
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
||||
|---|---|---|---|---|---|
|
||||
| Input | `GodotWorldPosition` | Player scene adapter | `TerrainQuery` | Immutable caller-held value | One query |
|
||||
| Input | Extracted directory/map name | Scene composition | `AdtTerrainQuery` | Copied strings | Query lifetime |
|
||||
| Input | ADT file/dictionary | Filesystem/native loader or injected loader | `AdtTerrainQuery` | Adapter-owned cached dictionary | Query lifetime |
|
||||
| Output | `TerrainGroundSample` | Terrain query | Player ground-snap adapter | Immutable caller-held value | One query |
|
||||
| Output | Failure code | Terrain query | Diagnostics/tests | Immutable `StringName` | Sample lifetime |
|
||||
|
||||
Side effects:
|
||||
|
||||
- Production adapter performs lazy file existence check and native ADT parse on first tile query.
|
||||
- Adapter mutates only its per-query tile/failure caches.
|
||||
- No scene tree, transform, renderer RID, network, database or persisted setting mutation occurs.
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Position[GodotWorldPosition] --> TileCoord[CoordinateMapper tile/local/chunk]
|
||||
TileCoord --> Lookup{Tile cached?}
|
||||
Lookup -->|no| Load[ADTLoader or injected loader]
|
||||
Load --> Cache[Cache Dictionary or unavailable result]
|
||||
Lookup -->|yes| Cache
|
||||
Cache --> Chunk[Select MCNK by origin]
|
||||
Chunk --> Heights[Validate 145 heights]
|
||||
Heights --> Bilinear[Interpolate 9x9 outer grid]
|
||||
Bilinear --> Available[TerrainGroundSample available]
|
||||
Cache -->|missing/invalid| Unavailable[TerrainGroundSample unavailable]
|
||||
```
|
||||
|
||||
## Lifecycle/state
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> TileUnknown
|
||||
TileUnknown --> TileAvailable: first query / successful load
|
||||
TileUnknown --> TileUnavailable: first query / missing or failed
|
||||
TileAvailable --> TileAvailable: cached queries
|
||||
TileUnavailable --> TileUnavailable: cached queries
|
||||
TileAvailable --> [*]: query released
|
||||
TileUnavailable --> [*]: query released
|
||||
```
|
||||
|
||||
Unavailable tiles are cached intentionally to avoid repeated filesystem/native
|
||||
work each physics tick. Recovery after files appear requires replacing the query
|
||||
instance; hot-reload invalidation is outside this M02 package.
|
||||
|
||||
## Main sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Player as ThirdPersonWowController
|
||||
participant Query as TerrainQuery
|
||||
participant ADT as AdtTerrainQuery
|
||||
participant Mapper as CoordinateMapper
|
||||
participant Native as ADTLoader
|
||||
Player->>Query: sample_ground_height(GodotWorldPosition)
|
||||
Query->>ADT: implementation dispatch
|
||||
ADT->>Mapper: tile/local/chunk coordinates
|
||||
alt tile not cached
|
||||
ADT->>Native: load_adt(absolute path)
|
||||
Native-->>ADT: ADT Dictionary
|
||||
end
|
||||
ADT->>ADT: select chunk and interpolate heights
|
||||
ADT-->>Player: TerrainGroundSample
|
||||
Player->>Player: apply existing offset/snap if available
|
||||
```
|
||||
|
||||
## Ownership, threading and resources
|
||||
|
||||
- Player scene owns the active `TerrainQuery` reference.
|
||||
- `AdtTerrainQuery` exclusively owns tile/failure dictionaries until released.
|
||||
- Current production calls and cache mutations occur on the main physics thread.
|
||||
- Native loader result dictionaries do not cross the adapter boundary.
|
||||
- Samples and typed positions are immutable caller-held values.
|
||||
- The module creates no Nodes, Resources, RIDs or background jobs.
|
||||
- Future worker-backed queries require an explicit async/result contract rather
|
||||
than concurrent mutation of the current dictionaries.
|
||||
|
||||
## Errors, cancellation and recovery
|
||||
|
||||
| Failure | Detection | Behavior | Diagnostic | Recovery |
|
||||
|---|---|---|---|---|
|
||||
| Base contract used directly | Base method | Unavailable sample | `terrain_query_not_implemented` | Inject implementation |
|
||||
| Native loader absent | ClassDB check | Cache unavailable tile | `adt_loader_unavailable` | Load extension and replace query |
|
||||
| ADT file absent | File check | Cache unavailable tile | `adt_source_missing` | Restore corpus and replace query |
|
||||
| Native parse empty/fails | Result validation | Cache unavailable tile | `adt_load_failed` | Inspect source/parser; replace query |
|
||||
| Injected loader returns wrong type | Type check | Cache unavailable tile | `adt_loader_result_invalid` | Fix adapter |
|
||||
| Chunk absent | Chunk lookup | Unavailable sample | `adt_chunk_missing` | Validate ADT dictionary |
|
||||
| Height array shorter than 145 | Size check | Unavailable sample | `adt_chunk_heights_invalid` | Fix parser/source |
|
||||
| Non-finite interpolated height | Sample invariant | Unavailable sample | `terrain_height_not_finite` | Inspect source values |
|
||||
| Null query injection | Scene guard | Existing query retained | Player composition error | Pass valid query |
|
||||
|
||||
There are no asynchronous operations to cancel. Replacing/releasing the query
|
||||
is the deterministic cache-reset and recovery path.
|
||||
|
||||
## Configuration and capabilities
|
||||
|
||||
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|
||||
|---|---|---|---|---|
|
||||
| Extracted directory | Player `extracted_dir` | Current sandbox | Captured at creation | ADT source root |
|
||||
| Map directory name | Player `map_name` | Current sandbox | Captured at creation | ADT filename/path |
|
||||
| Loader override | Invalid callable | Test/custom data source | Constructor only | Bypasses production ClassDB/file loading |
|
||||
| Tile cache | Enabled | All current uses | Cleared by replacing query | Avoids repeated parse/failure work |
|
||||
| Height policy | 9x9 outer-grid bilinear | Current sandbox | Fixed | Preserves pre-extraction behavior |
|
||||
|
||||
## Persistence, cache and migration
|
||||
|
||||
The cache is in-memory and unversioned because it holds native loader output for
|
||||
one query lifetime. No disk cache, schema or migration changes are introduced.
|
||||
Existing ADT/native/cache format versions remain unchanged.
|
||||
|
||||
## Diagnostics and observability
|
||||
|
||||
- Logs: dedicated verifier emits `TERRAIN_QUERY PASS` or named failures.
|
||||
- Structured status: every unavailable sample has a stable `failure_code`.
|
||||
- Metrics: synthetic verifier counts loader calls to prove one load per tile.
|
||||
- Debug views: none.
|
||||
- Correlation IDs: not applicable to synchronous local query.
|
||||
|
||||
## Verification
|
||||
|
||||
- Contract: available/unavailable/base/non-finite result invariants.
|
||||
- Pure synthetic adapter: typed coordinate mapping, exact chunk selection,
|
||||
145-height layout and bilinear interpolation within `0.002` units (Vector3
|
||||
source precision), plus cached second query.
|
||||
- Failures: empty tile and invalid height array produce stable codes.
|
||||
- Integration: asset-free player scene accepts injected flat query and preserves
|
||||
spawn offset `0.05` plus physics ground snap.
|
||||
- Boundary: source regression rejects ADT loader/cache/chunk helpers in player.
|
||||
- Fidelity evidence: existing interpolation and snap behavior only; no
|
||||
original-client or collision parity claim.
|
||||
- Performance budget: one dictionary cache lookup and scalar interpolation per
|
||||
cached query; native parse only on first tile access.
|
||||
|
||||
## Extension points
|
||||
|
||||
- Streaming renderer or physics backend may implement `TerrainQuery` without changing player/movement.
|
||||
- The current facade intentionally uses a separate renderer-owned result and is
|
||||
not a `TerrainQuery` implementation. A future adapter must explicitly translate
|
||||
readiness/results and define gameplay authority before composition.
|
||||
- Future result may grow separate slope, surface, liquid or collision contracts;
|
||||
incompatible additions require a versioned contract rather than Dictionary fields.
|
||||
- Test/authoring data sources may use constructor loader override.
|
||||
- Async streaming requires a separate availability/update model.
|
||||
|
||||
## Capability status
|
||||
|
||||
| Capability | Status | Evidence | Gap/next step |
|
||||
|---|---|---|---|
|
||||
| Typed replaceable ground-height query | Implemented | Contract and injected player regression | Integrate target checklist after review |
|
||||
| Existing ADT height interpolation | Implemented | Synthetic fractional-grid fixture | Compare terrain contact with build 12340 |
|
||||
| Per-tile success/failure cache | Implemented | Loader call-count regression | Add explicit invalidation only with authoring/hot reload |
|
||||
| Renderer loaded-mesh diagnostic backend | Implemented | M03 facade typed sample and detached snapshot contract | Not composed into gameplay; triangle Mesh ray is diagnostic only |
|
||||
| Authoritative renderer/physics backend | Planned | Boundary permits replacement | Define holes/slopes/collision/readiness semantics before gameplay composition |
|
||||
| Holes/slopes/collision | Planned | Outside height-only contract | Later movement/physics package |
|
||||
| Liquid/swim query | Planned | Outside contract | M09/M12 world gameplay |
|
||||
|
||||
## Known gaps and risks
|
||||
|
||||
- Bilinear outer-grid height is the inherited sandbox approximation; exact ADT
|
||||
triangle choice, holes and collision are not represented.
|
||||
- Query is synchronous; first access may parse an ADT on the main thread exactly
|
||||
as before extraction. A streaming/backend replacement is future work.
|
||||
- Unavailable results remain cached until query replacement.
|
||||
- Player still owns ground offset/snap policy; only data access is extracted.
|
||||
- Existing native dictionaries remain dynamic inside the adapter.
|
||||
|
||||
## Source map
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `src/gameplay/terrain/terrain_ground_sample.gd` | Immutable available/unavailable result |
|
||||
| `src/gameplay/terrain/terrain_query.gd` | Replaceable typed query boundary |
|
||||
| `src/gameplay/terrain/adt_terrain_query.gd` | Native ADT adapter, cache, chunk selection and interpolation |
|
||||
| `src/scenes/player/third_person_wow_controller.gd` | Query composition and existing ground-snap consumer |
|
||||
| `src/render/world_render_facade.gd` | Read-only loaded-render-mesh diagnostic consumer of typed position/sample contracts |
|
||||
| `src/tools/verify_terrain_query.gd` | Synthetic contract/cache/failure/injection regression |
|
||||
| `src/tests/scenes/player_input_regression.tscn` | Asset-free player composition fixture |
|
||||
|
||||
## Related decisions and references
|
||||
|
||||
- ADR: none; no coordinate, format or authority decision changed.
|
||||
- Upstream/reference: `targets/02-player-decomposition.md`,
|
||||
`targets/roadmap/02-rendering-and-graphics.md`,
|
||||
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
|
||||
@@ -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`.
|
||||
+109
-28
@@ -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`; `M03-RND-STREAMING-PLANNER-001`; `M03-RND-SCHEDULER-001`; `M03-RND-INTERNAL-ACCESS-GATE-001`; `M03-RND-FACADE-GROUND-QUERY-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-render-budget-scheduler`, 2026-07-16 |
|
||||
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
|
||||
|
||||
## Purpose
|
||||
@@ -32,7 +32,12 @@ 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]
|
||||
Loader --> Planner[StreamingTargetPlanner]
|
||||
Planner --> TargetPlan[StreamingTargetPlan]
|
||||
TargetPlan --> Loader
|
||||
Loader --> Budget[RenderBudgetScheduler]
|
||||
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 +65,52 @@ Forbidden dependencies:
|
||||
|
||||
## Public API
|
||||
|
||||
Текущая система ещё не имеет стабильного facade. Фактический integration surface — `StreamingWorldLoader` Node3D и exported properties. M03 должен заменить этот surface на `WorldRenderFacade`.
|
||||
Runtime, capture and probe callers use `WorldRenderFacade` for focus, metrics and
|
||||
rendered-ground diagnostics. `StreamingWorldLoader` remains the internal scene
|
||||
implementation while later M03 packages add environment and entity-visual contracts.
|
||||
Gameplay modules and Godot `EditorPlugin` package sources are repository-gated
|
||||
from externally reading/writing loader-private queue, task, cache and tile-state fields.
|
||||
|
||||
| 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.sample_ground_height` | Public read-only query | Samples already loaded render terrain at a typed Godot world position | Main thread; immutable caller-held result | Stable unavailable code for missing mesh/intersection/facade; not gameplay authority |
|
||||
| `WorldRenderFacade.renderer_ground_query_snapshot` | Public diagnostic query | Returns ground height plus detached tile/readiness context without exposing mutable state | Main thread; caller-owned deep copy | Missing/invalid renderer 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 | Ground-query `GodotWorldPosition` | Renderer probe/future adapter | `WorldRenderFacade` | Immutable caller-held value | One main-thread query |
|
||||
| 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 | `StreamingTargetPlan` | `StreamingTargetPlanner` | Streamer queue/state apply | Immutable ephemeral value | One refresh |
|
||||
| Internal control | Per-frame lane limits and boolean permits | Streamer configuration / `RenderBudgetScheduler` | Ordered streamer drains | Scheduler-owned counters | Main thread/frame |
|
||||
| Output | Desired tile/detail operations | Streamer plan application | 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 | `RenderedGroundSample` | `WorldRenderFacade`/loaded terrain | Renderer diagnostics | Immutable caller-held value | One query |
|
||||
| Output | Detached rendered-ground snapshot | `WorldRenderFacade` | Terrain-height probe | Caller-owned deep copy | One query |
|
||||
| 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 +125,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,23 +133,27 @@ flowchart TD
|
||||
E[Editor viewport] --> EA[Editor adapter]
|
||||
A --> F[StreamingFocus]
|
||||
EA --> F
|
||||
F --> T[Compute desired ADT tiles]
|
||||
WDT[WDT tile catalog] --> T
|
||||
F --> RF[WorldRenderFacade]
|
||||
RF --> Planner[StreamingTargetPlanner]
|
||||
Planner --> T[StreamingTargetPlan apply]
|
||||
WDT[WDT tile catalog] --> Planner
|
||||
T --> Q[Tile load queue]
|
||||
Q --> P[Worker parse/cache load]
|
||||
P --> R[Result queues]
|
||||
R --> B[Per-frame budget scheduler]
|
||||
Q --> Parse[Worker parse/cache load]
|
||||
Parse --> R[Result queues]
|
||||
R --> B[RenderBudgetScheduler permits]
|
||||
B --> Terrain[Terrain attach/upgrade]
|
||||
B --> M2[M2 group/MultiMesh attach]
|
||||
B --> WMO[WMO instance/group attach]
|
||||
B --> Liquid[Liquid attach]
|
||||
Loaded[Loaded terrain Mesh and Transform] --> Ground[Facade ground query]
|
||||
Ground --> GroundSample[RenderedGroundSample / detached diagnostics]
|
||||
Terrain --> World[Godot world]
|
||||
M2 --> World
|
||||
WMO --> World
|
||||
Liquid --> World
|
||||
Marker --> World
|
||||
F --> E[Eviction/retention decisions]
|
||||
E --> World
|
||||
F --> Eviction[Eviction/retention decisions]
|
||||
Eviction --> World
|
||||
```
|
||||
|
||||
## Lifecycle/state
|
||||
@@ -155,18 +180,29 @@ 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 Planner as StreamingTargetPlanner
|
||||
participant Worker as Worker task
|
||||
participant Budget as Main-thread budget
|
||||
participant Budget as RenderBudgetScheduler
|
||||
participant Render as SceneTree/RenderingServer
|
||||
Source->>Focus: sample explicit Godot position
|
||||
Focus->>Stream: set/refresh typed focus
|
||||
Stream->>Stream: desired tiles and priorities
|
||||
Focus->>Facade: set/refresh typed focus
|
||||
Facade->>Stream: delegate focus and refresh
|
||||
Stream->>Planner: typed focus, catalog and target policy
|
||||
Planner-->>Stream: immutable wanted/retained plan
|
||||
Stream->>Stream: queue priority and loaded-tile LOD/detail state
|
||||
Stream->>Worker: parse/load tile and detail data
|
||||
Worker-->>Stream: immutable result
|
||||
Stream->>Budget: enqueue finalize operations
|
||||
Budget->>Render: attach bounded terrain/M2/WMO/liquid work
|
||||
Stream->>Budget: begin_frame(operation limits)
|
||||
Stream->>Budget: try_consume_permit(lane)
|
||||
Budget-->>Stream: true while lane remains bounded
|
||||
Stream->>Render: attach permitted terrain/M2/WMO/liquid work
|
||||
Stream->>Render: evict outside retention range
|
||||
Source->>Facade: sample_ground_height(typed position)
|
||||
Facade->>Stream: sample loaded render terrain
|
||||
Stream-->>Facade: RenderedGroundSample / diagnostic snapshot
|
||||
Stream->>Budget: shutdown: cancel permit issuance
|
||||
Stream->>Worker: shutdown: wait for WorkerThreadPool tasks
|
||||
Stream->>Stream: shutdown: finish registered ResourceLoader requests
|
||||
Stream->>Render: clear queues and owned tree nodes/RIDs
|
||||
@@ -176,6 +212,16 @@ 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.
|
||||
- Rendered-ground query results and diagnostic snapshots are caller-owned values;
|
||||
the facade never returns Mesh, Node, tile-state or queue references.
|
||||
- Loaded-mesh ground sampling is renderer diagnostics, not authoritative gameplay
|
||||
collision. M02 `TerrainQuery` composition and ADT cache ownership remain unchanged.
|
||||
- `StreamingTargetPlanner` is stateless and owns only call-local calculations;
|
||||
its immutable plan is consumed synchronously by the streamer.
|
||||
- `RenderBudgetScheduler` owns only per-frame lane counters and a terminal
|
||||
cancellation flag. The streamer retains queue ordering and all operation side effects.
|
||||
- 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,10 +238,13 @@ 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 |
|
||||
| Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path |
|
||||
| Render terrain unavailable | Facade ground query | Immutable unavailable sample | Stable `render_terrain_*` failure code and optional snapshot | Wait for streaming or use gameplay-owned query backend |
|
||||
| D3D12 descriptor exhaustion | Rendering backend error | Render failure/fallback backend | Godot error + baseline report | Dedup resources/fix settings |
|
||||
| Teleport/map change | Focus/session transition | Old jobs become stale | Target/session generation | Cancel/drop stale results |
|
||||
| Shutdown leak | Godot leak/RID diagnostics | Resource retained | Verbose shutdown report + cache shutdown verifier | Drain requests, preserve tree ownership, then free detached prototypes and clear resource caches |
|
||||
@@ -225,6 +274,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.
|
||||
@@ -232,6 +283,18 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
||||
## Verification
|
||||
|
||||
- Unit/contract tests: streaming-focus contract/wiring, material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan.
|
||||
- Pure planner contract: center/corner/catalog/clamp/editor cases plus bounded
|
||||
High-like iteration timing without loading a world scene.
|
||||
- Budget scheduler contract: exact lane exhaustion, shared chunk removal/create
|
||||
priority, independent lanes, frame reset, invalid limits, terminal cancellation,
|
||||
dependency boundaries and bounded permit timing without loading a world scene.
|
||||
- Internal-access contract: derives the current streamer's private queue/task/
|
||||
cache/state field inventory and rejects external member/reflection access from
|
||||
gameplay or EditorPlugin package code.
|
||||
- Ground-query facade contract: available/unavailable typed result delegation,
|
||||
detached nested diagnostics and terrain-probe source migration without private access.
|
||||
- Rendered-ground value contract: available/non-finite/unavailable invariants pass
|
||||
from a cold Godot class cache before editor import.
|
||||
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00
|
||||
cold/warm checkpoints and one dedicated mapped server-spawn checkpoint.
|
||||
- Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe.
|
||||
@@ -257,7 +320,11 @@ 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 | Facade contracts cover focus, metrics and loaded-mesh ground query | Environment and entity visuals remain |
|
||||
| Pure streaming target planner | Implemented | `M03-RND-STREAMING-PLANNER-001` asset-free contract/performance regression | Queue scheduling and loaded-tile LOD application remain in streamer |
|
||||
| Bounded render budget scheduler | Implemented | `M03-RND-SCHEDULER-001` asset-free permit/cancellation/performance regression | Queue storage, worker concurrency and operation execution remain in streamer/services |
|
||||
| Gameplay/editor/tool internal-access boundary | Implemented | Access gate scans gameplay, EditorPlugin packages and terrain probe against actual private streamer declarations | Add future renderer diagnostic tools explicitly to the protected list |
|
||||
| Rendered terrain ground query | Implemented | `M03-RND-FACADE-GROUND-QUERY-001` typed result/snapshot isolation and probe migration | Diagnostic Mesh ray only; not movement/collision authority |
|
||||
|
||||
## Known gaps and risks
|
||||
|
||||
@@ -278,14 +345,28 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
||||
- D3D12 descriptor issues remain. Explicit shutdown ownership removes the GUI capture's Node/resource/RID leaks in focused and seven-checkpoint runs; a timing-dependent ObjectDB warning for anonymous zero-reference `RefCounted` objects remains as a separate engine-teardown diagnostic.
|
||||
- M2/WMO/material/particle/ribbon/portal parity incomplete.
|
||||
- Public API is mostly exported configuration rather than stable contracts.
|
||||
- Rendered-ground sampling generates a triangle mesh on demand and retains the
|
||||
historical nearby seam fallback. It is appropriate for diagnostics, not a
|
||||
per-physics-frame gameplay query or a claim of exact terrain collision parity.
|
||||
|
||||
## Source map
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `src/render/world_render_facade.gd` | Stable caller boundary for typed focus, detached metrics and rendered-ground queries |
|
||||
| `src/render/terrain/rendered_ground_sample.gd` | Immutable renderer-owned available/unavailable ground query result |
|
||||
| `src/render/streaming/streaming_target_planner.gd` | Scene-free wanted/retained ADT target calculation |
|
||||
| `src/render/streaming/streaming_target_policy.gd` | Immutable renderer radius/prefetch policy |
|
||||
| `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets |
|
||||
| `src/render/streaming/render_budget_scheduler.gd` | Scene-free per-frame operation permits and terminal cancellation |
|
||||
| `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` | Focus/metrics/ground delegation, snapshot isolation and consumer wiring regression |
|
||||
| `src/tools/verify_rendered_ground_sample.gd` | Cold-start renderer ground result invariant regression |
|
||||
| `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression |
|
||||
| `src/tools/verify_render_budget_scheduler.gd` | Scheduler bounds, shared-lane priority, cancellation and timing regression |
|
||||
| `src/tools/verify_renderer_internal_access.gd` | Gameplay/EditorPlugin/registered renderer-tool boundary gate derived from private streamer fields |
|
||||
| `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 |
|
||||
@@ -301,7 +382,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
||||
| `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer checkpoint contract verification |
|
||||
| `tools/sweep_render_checkpoint_camera_pose.ps1` | Offline yaw/pitch capture grid and paired-error ranking |
|
||||
| `src/tools/verify_render_coordinate_calibration.gd` | Build 12340 camera-coordinate golden point round-trip diagnostic |
|
||||
| `src/tools/probe_render_terrain_height.gd` | Offline active-mesh terrain height and camera-clearance report |
|
||||
| `src/tools/probe_render_terrain_height.gd` | Offline facade-backed active-mesh terrain height and camera-clearance report |
|
||||
| `src/tools/probe_render_camera_occluders.gd` | Scene-tree placement containment and camera-to-target AABB intersection report |
|
||||
|
||||
## Related decisions and references
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Reference projects are research inputs, not OpenWC runtime resources.
|
||||
@@ -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.
|
||||
|
||||
+1
-1
Submodule reference/open-realm updated: dfe316d3b0...c7ed0545c8
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,166 @@
|
||||
class_name AdtTerrainQuery
|
||||
extends TerrainQuery
|
||||
|
||||
## TerrainQuery adapter for existing native ADT dictionaries.
|
||||
## Parsed tiles, including unavailable results, are cached for this query's
|
||||
## lifetime. The adapter is main-thread owned and creates no scene resources.
|
||||
|
||||
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
|
||||
|
||||
const CHUNK_SIZE_UNITS := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
|
||||
const OUTER_GRID_UNIT_SIZE := CHUNK_SIZE_UNITS / 8.0
|
||||
|
||||
var _extracted_directory: String
|
||||
var _map_directory_name: String
|
||||
var _adt_tile_loader_override: Callable
|
||||
var _adt_tile_cache: Dictionary = {}
|
||||
var _failure_code_by_tile_key: Dictionary = {}
|
||||
|
||||
|
||||
## Creates an ADT query for one map. `adt_tile_loader_override` is an optional
|
||||
## test/data-source seam receiving the absolute ADT path and returning a Dictionary.
|
||||
func _init(
|
||||
extracted_directory: String,
|
||||
map_directory_name: String,
|
||||
adt_tile_loader_override: Callable = Callable()
|
||||
) -> void:
|
||||
_extracted_directory = extracted_directory
|
||||
_map_directory_name = map_directory_name
|
||||
_adt_tile_loader_override = adt_tile_loader_override
|
||||
|
||||
|
||||
## Samples the existing 9x9 outer-height grid with bilinear interpolation.
|
||||
func sample_ground_height(godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
|
||||
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(godot_world_position)
|
||||
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(godot_world_position)
|
||||
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(
|
||||
tile_coordinate,
|
||||
tile_local_position
|
||||
)
|
||||
var tile_key := _tile_key(tile_coordinate.tile_x, tile_coordinate.tile_y)
|
||||
var adt_tile_data := _load_adt_tile(tile_coordinate.tile_x, tile_coordinate.tile_y)
|
||||
if adt_tile_data.is_empty():
|
||||
return TerrainGroundSample.unavailable(
|
||||
_failure_code_by_tile_key.get(tile_key, &"adt_tile_unavailable")
|
||||
)
|
||||
|
||||
var tile_origin := _find_tile_origin(adt_tile_data)
|
||||
var chunk := _find_chunk(
|
||||
adt_tile_data,
|
||||
clampi(chunk_coordinate.chunk_x, 0, 15),
|
||||
clampi(chunk_coordinate.chunk_y, 0, 15),
|
||||
tile_origin
|
||||
)
|
||||
if chunk.is_empty():
|
||||
return TerrainGroundSample.unavailable(&"adt_chunk_missing")
|
||||
|
||||
var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
|
||||
if heights.size() < 145:
|
||||
return TerrainGroundSample.unavailable(&"adt_chunk_heights_invalid")
|
||||
|
||||
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
|
||||
var chunk_local_x := godot_world_position.x_units - chunk_origin.x
|
||||
var chunk_local_z := godot_world_position.z_units - chunk_origin.z
|
||||
var grid_x := clampf(chunk_local_x / OUTER_GRID_UNIT_SIZE, 0.0, 8.0)
|
||||
var grid_z := clampf(chunk_local_z / OUTER_GRID_UNIT_SIZE, 0.0, 8.0)
|
||||
var x0 := clampi(int(floor(grid_x)), 0, 8)
|
||||
var z0 := clampi(int(floor(grid_z)), 0, 8)
|
||||
var x1 := mini(x0 + 1, 8)
|
||||
var z1 := mini(z0 + 1, 8)
|
||||
var fraction_x := grid_x - float(x0)
|
||||
var fraction_z := grid_z - float(z0)
|
||||
|
||||
var height_x0 := lerpf(
|
||||
heights[_outer_height_index(z0, x0)],
|
||||
heights[_outer_height_index(z0, x1)],
|
||||
fraction_x
|
||||
)
|
||||
var height_x1 := lerpf(
|
||||
heights[_outer_height_index(z1, x0)],
|
||||
heights[_outer_height_index(z1, x1)],
|
||||
fraction_x
|
||||
)
|
||||
return TerrainGroundSample.available(
|
||||
chunk_origin.y + lerpf(height_x0, height_x1, fraction_z)
|
||||
)
|
||||
|
||||
|
||||
func _load_adt_tile(tile_x: int, tile_y: int) -> Dictionary:
|
||||
var tile_key := _tile_key(tile_x, tile_y)
|
||||
if _adt_tile_cache.has(tile_key):
|
||||
return _adt_tile_cache[tile_key]
|
||||
|
||||
var resource_path := _extracted_directory.path_join(
|
||||
"World/Maps/%s/%s_%d_%d.adt" % [_map_directory_name, _map_directory_name, tile_x, tile_y]
|
||||
)
|
||||
var absolute_path := ProjectSettings.globalize_path(resource_path)
|
||||
var adt_tile_data: Dictionary = {}
|
||||
var failure_code: StringName = &""
|
||||
if _adt_tile_loader_override.is_valid():
|
||||
var override_result = _adt_tile_loader_override.call(absolute_path)
|
||||
if override_result is Dictionary:
|
||||
adt_tile_data = override_result
|
||||
else:
|
||||
failure_code = &"adt_loader_result_invalid"
|
||||
elif not ClassDB.class_exists("ADTLoader"):
|
||||
failure_code = &"adt_loader_unavailable"
|
||||
elif not FileAccess.file_exists(absolute_path):
|
||||
failure_code = &"adt_source_missing"
|
||||
else:
|
||||
var loader = ClassDB.instantiate("ADTLoader")
|
||||
if loader != null:
|
||||
var load_result = loader.call("load_adt", absolute_path)
|
||||
if load_result is Dictionary:
|
||||
adt_tile_data = load_result
|
||||
if adt_tile_data.is_empty():
|
||||
failure_code = &"adt_load_failed"
|
||||
|
||||
if adt_tile_data.is_empty() and failure_code.is_empty():
|
||||
failure_code = &"adt_tile_unavailable"
|
||||
_adt_tile_cache[tile_key] = adt_tile_data
|
||||
_failure_code_by_tile_key[tile_key] = failure_code
|
||||
return adt_tile_data
|
||||
|
||||
|
||||
func _find_chunk(
|
||||
adt_tile_data: Dictionary,
|
||||
chunk_x: int,
|
||||
chunk_y: int,
|
||||
tile_origin: Vector3
|
||||
) -> Dictionary:
|
||||
for chunk in adt_tile_data.get("chunks", []) as Array:
|
||||
if chunk.is_empty():
|
||||
continue
|
||||
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
|
||||
var local_origin := chunk_origin - tile_origin
|
||||
var candidate_chunk_x := clampi(int(round(local_origin.x / CHUNK_SIZE_UNITS)), 0, 15)
|
||||
var candidate_chunk_y := clampi(int(round(local_origin.z / CHUNK_SIZE_UNITS)), 0, 15)
|
||||
if candidate_chunk_x == chunk_x and candidate_chunk_y == chunk_y:
|
||||
return chunk
|
||||
return {}
|
||||
|
||||
|
||||
func _find_tile_origin(adt_tile_data: Dictionary) -> Vector3:
|
||||
var has_origin := false
|
||||
var minimum_x := 0.0
|
||||
var minimum_z := 0.0
|
||||
for chunk in adt_tile_data.get("chunks", []) as Array:
|
||||
if chunk.is_empty():
|
||||
continue
|
||||
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
|
||||
if not has_origin:
|
||||
minimum_x = chunk_origin.x
|
||||
minimum_z = chunk_origin.z
|
||||
has_origin = true
|
||||
else:
|
||||
minimum_x = minf(minimum_x, chunk_origin.x)
|
||||
minimum_z = minf(minimum_z, chunk_origin.z)
|
||||
return Vector3(minimum_x, 0.0, minimum_z) if has_origin else Vector3.ZERO
|
||||
|
||||
|
||||
func _tile_key(tile_x: int, tile_y: int) -> String:
|
||||
return "%d_%d" % [tile_x, tile_y]
|
||||
|
||||
|
||||
func _outer_height_index(row: int, column: int) -> int:
|
||||
return row * 17 + column
|
||||
@@ -0,0 +1 @@
|
||||
uid://cq7scvxqw6ffm
|
||||
@@ -0,0 +1,49 @@
|
||||
class_name TerrainGroundSample
|
||||
extends RefCounted
|
||||
|
||||
## Immutable result of a terrain ground-height query in Godot world units.
|
||||
## An unavailable sample carries a stable failure code instead of overloading a
|
||||
## numeric height with `NAN` at the public boundary.
|
||||
|
||||
var is_available: bool:
|
||||
get:
|
||||
return _is_available
|
||||
|
||||
var height_units: float:
|
||||
get:
|
||||
return _height_units
|
||||
|
||||
var failure_code: StringName:
|
||||
get:
|
||||
return _failure_code
|
||||
|
||||
var _is_available: bool
|
||||
var _height_units: float
|
||||
var _failure_code: StringName
|
||||
|
||||
|
||||
## Creates an available ground sample in Godot world units.
|
||||
static func available(height_units_value: float) -> TerrainGroundSample:
|
||||
return TerrainGroundSample.new(true, height_units_value, &"")
|
||||
|
||||
|
||||
## Creates an unavailable sample with a stable diagnostic code.
|
||||
static func unavailable(failure_code_value: StringName) -> TerrainGroundSample:
|
||||
return TerrainGroundSample.new(false, 0.0, failure_code_value)
|
||||
|
||||
|
||||
## Normalizes factory inputs so available samples are finite and unavailable
|
||||
## samples always carry a non-empty failure code.
|
||||
func _init(
|
||||
is_available_value: bool,
|
||||
height_units_value: float,
|
||||
failure_code_value: StringName
|
||||
) -> void:
|
||||
_is_available = is_available_value and is_finite(height_units_value)
|
||||
_height_units = height_units_value if _is_available else 0.0
|
||||
if _is_available:
|
||||
_failure_code = &""
|
||||
elif not is_finite(height_units_value):
|
||||
_failure_code = &"terrain_height_not_finite"
|
||||
else:
|
||||
_failure_code = failure_code_value if not failure_code_value.is_empty() else &"terrain_unavailable"
|
||||
@@ -0,0 +1 @@
|
||||
uid://boe5qkip6ffku
|
||||
@@ -0,0 +1,12 @@
|
||||
class_name TerrainQuery
|
||||
extends RefCounted
|
||||
|
||||
## Replaceable scene-free boundary for gameplay terrain height sampling.
|
||||
## Implementations receive a typed Godot world position and must return an
|
||||
## explicit available/unavailable result without mutating the caller.
|
||||
|
||||
|
||||
## Samples terrain ground height at a typed Godot world position.
|
||||
## The base implementation reports `terrain_query_not_implemented`.
|
||||
func sample_ground_height(_godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
|
||||
return TerrainGroundSample.unavailable(&"terrain_query_not_implemented")
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3f2hetuo342q
|
||||
@@ -0,0 +1,84 @@
|
||||
## Issues bounded, per-frame permits for renderer main-thread operation lanes.
|
||||
##
|
||||
## The scheduler owns counters only. Queue contents, operation ordering and the
|
||||
## side effects performed after a permit remain owned by the caller. One instance
|
||||
## belongs to one renderer lifecycle and must only be used on the main thread.
|
||||
class_name RenderBudgetScheduler
|
||||
extends RefCounted
|
||||
|
||||
const TILE_FINALIZE: StringName = &"tile_finalize"
|
||||
const TERRAIN_UPGRADE_FINALIZE: StringName = &"terrain_upgrade_finalize"
|
||||
const TERRAIN_CONTROL_SPLAT_FINALIZE: StringName = &"terrain_control_splat_finalize"
|
||||
const TERRAIN_SPLAT_CACHE_FINALIZE: StringName = &"terrain_splat_cache_finalize"
|
||||
const TERRAIN_SPLAT_BUILD: StringName = &"terrain_splat_build"
|
||||
const WATER_FINALIZE: StringName = &"water_finalize"
|
||||
const CHUNK_GEOMETRY: StringName = &"chunk_geometry"
|
||||
const TILE_LOAD_START: StringName = &"tile_load_start"
|
||||
const TILE_LOD_CREATE: StringName = &"tile_lod_create"
|
||||
const TILE_LOD_REMOVE: StringName = &"tile_lod_remove"
|
||||
const M2_ANIMATION_FINALIZE: StringName = &"m2_animation_finalize"
|
||||
const M2_MESH_FINALIZE: StringName = &"m2_mesh_finalize"
|
||||
const M2_BUILD: StringName = &"m2_build"
|
||||
const WMO_BUILD: StringName = &"wmo_build"
|
||||
const WMO_RENDER_GROUP_BUILD: StringName = &"wmo_render_group_build"
|
||||
const DETAIL_ASSET_SYNC: StringName = &"detail_asset_sync"
|
||||
|
||||
var _remaining_permits_by_lane: Dictionary = {}
|
||||
var _consumed_permits_by_lane: Dictionary = {}
|
||||
var _is_cancelled := false
|
||||
|
||||
|
||||
## Replaces all lane limits for a new frame. Negative limits are treated as zero.
|
||||
## Calling this after [method cancel] leaves the scheduler cancelled.
|
||||
func begin_frame(operation_limits_by_lane: Dictionary) -> void:
|
||||
_remaining_permits_by_lane.clear()
|
||||
_consumed_permits_by_lane.clear()
|
||||
if _is_cancelled:
|
||||
return
|
||||
|
||||
for lane_variant in operation_limits_by_lane:
|
||||
var lane := StringName(lane_variant)
|
||||
_remaining_permits_by_lane[lane] = maxi(0, int(operation_limits_by_lane[lane_variant]))
|
||||
_consumed_permits_by_lane[lane] = 0
|
||||
|
||||
|
||||
## Returns whether a lane can issue another permit without consuming it.
|
||||
## Unknown lanes and cancelled schedulers return false.
|
||||
func has_remaining_permit(lane: StringName) -> bool:
|
||||
if _is_cancelled:
|
||||
return false
|
||||
return int(_remaining_permits_by_lane.get(lane, 0)) > 0
|
||||
|
||||
|
||||
## Consumes one permit from [param lane] and returns true when it was granted.
|
||||
## Unknown, exhausted or cancelled lanes return false without changing state.
|
||||
func try_consume_permit(lane: StringName) -> bool:
|
||||
if not has_remaining_permit(lane):
|
||||
return false
|
||||
_remaining_permits_by_lane[lane] = int(_remaining_permits_by_lane[lane]) - 1
|
||||
_consumed_permits_by_lane[lane] = int(_consumed_permits_by_lane.get(lane, 0)) + 1
|
||||
return true
|
||||
|
||||
|
||||
## Returns the unconsumed permit count for one lane.
|
||||
func remaining_permits(lane: StringName) -> int:
|
||||
if _is_cancelled:
|
||||
return 0
|
||||
return int(_remaining_permits_by_lane.get(lane, 0))
|
||||
|
||||
|
||||
## Returns a detached diagnostic snapshot of permits consumed in this frame.
|
||||
func consumed_permits_snapshot() -> Dictionary:
|
||||
return _consumed_permits_by_lane.duplicate()
|
||||
|
||||
|
||||
## Permanently rejects new permits for this scheduler instance and clears limits.
|
||||
## In-flight work remains the caller's responsibility and is not interrupted.
|
||||
func cancel() -> void:
|
||||
_is_cancelled = true
|
||||
_remaining_permits_by_lane.clear()
|
||||
|
||||
|
||||
## Returns whether [method cancel] ended this scheduler lifecycle.
|
||||
func is_cancelled() -> bool:
|
||||
return _is_cancelled
|
||||
@@ -0,0 +1 @@
|
||||
uid://byaii7lec82i6
|
||||
@@ -0,0 +1,37 @@
|
||||
class_name StreamingTargetPlan
|
||||
extends RefCounted
|
||||
|
||||
## Immutable result of one runtime or editor target-planning operation.
|
||||
## Tile-key sets use the streamer's existing `<tile_x>_<tile_y>` representation.
|
||||
|
||||
var focus_tile: AdtTileCoordinate:
|
||||
get:
|
||||
return _focus_tile
|
||||
|
||||
var _focus_tile: AdtTileCoordinate
|
||||
var _wanted_tile_keys: Dictionary
|
||||
var _retained_tile_keys: Dictionary
|
||||
|
||||
|
||||
## Takes ownership of planner-created sets and freezes them. The planner creates
|
||||
## these dictionaries independently from its catalog input, so no copy is needed.
|
||||
func _init(
|
||||
focus_tile_value: AdtTileCoordinate,
|
||||
wanted_tile_keys_value: Dictionary,
|
||||
retained_tile_keys_value: Dictionary
|
||||
) -> void:
|
||||
_focus_tile = focus_tile_value
|
||||
_wanted_tile_keys = wanted_tile_keys_value
|
||||
_retained_tile_keys = retained_tile_keys_value
|
||||
_wanted_tile_keys.make_read_only()
|
||||
_retained_tile_keys.make_read_only()
|
||||
|
||||
|
||||
## Returns the immutable wanted tile-key set.
|
||||
func wanted_tile_keys() -> Dictionary:
|
||||
return _wanted_tile_keys
|
||||
|
||||
|
||||
## Returns the immutable retained tile-key set.
|
||||
func retained_tile_keys() -> Dictionary:
|
||||
return _retained_tile_keys
|
||||
@@ -0,0 +1 @@
|
||||
uid://fq1ax8tdju2q
|
||||
@@ -0,0 +1,104 @@
|
||||
class_name StreamingTargetPlanner
|
||||
extends RefCounted
|
||||
|
||||
## Scene-free deterministic calculator for runtime/editor ADT target sets.
|
||||
## It creates no tasks or renderer resources and never mutates its inputs.
|
||||
|
||||
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
|
||||
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
|
||||
const TARGET_PLAN_SCRIPT := preload("res://src/render/streaming/streaming_target_plan.gd")
|
||||
|
||||
|
||||
## Builds runtime wanted and retained sets, including the existing edge/corner
|
||||
## prefetch centers. `available_tile_paths_by_key` is read only for membership.
|
||||
func plan_runtime(
|
||||
available_tile_paths_by_key: Dictionary,
|
||||
focus_position: GodotWorldPosition,
|
||||
policy
|
||||
):
|
||||
var focus_tile = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(focus_position)
|
||||
var prefetch_centers := _prefetch_centers(focus_position, focus_tile, policy)
|
||||
var wanted_tile_keys := _available_keys_within_radius(
|
||||
available_tile_paths_by_key,
|
||||
prefetch_centers,
|
||||
policy.load_tile_radius()
|
||||
)
|
||||
var retained_tile_keys := _available_keys_within_radius(
|
||||
available_tile_paths_by_key,
|
||||
prefetch_centers,
|
||||
policy.retained_tile_radius()
|
||||
)
|
||||
return TARGET_PLAN_SCRIPT.new(focus_tile, wanted_tile_keys, retained_tile_keys)
|
||||
|
||||
|
||||
## Builds the editor square preview set. Editor wanted and retained sets are
|
||||
## identical and a negative radius is clamped to the focus tile.
|
||||
func plan_editor(
|
||||
available_tile_paths_by_key: Dictionary,
|
||||
focus_position: GodotWorldPosition,
|
||||
editor_preview_tile_radius: int
|
||||
):
|
||||
var focus_tile = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(focus_position)
|
||||
var centers: Array[AdtTileCoordinate] = [focus_tile]
|
||||
var wanted_tile_keys := _available_keys_within_radius(
|
||||
available_tile_paths_by_key,
|
||||
centers,
|
||||
maxi(0, editor_preview_tile_radius)
|
||||
)
|
||||
return TARGET_PLAN_SCRIPT.new(focus_tile, wanted_tile_keys, wanted_tile_keys)
|
||||
|
||||
|
||||
func _prefetch_centers(
|
||||
focus_position: GodotWorldPosition,
|
||||
focus_tile: AdtTileCoordinate,
|
||||
policy
|
||||
) -> Array[AdtTileCoordinate]:
|
||||
var centers: Array[AdtTileCoordinate] = [focus_tile]
|
||||
var threshold: float = policy.clamped_boundary_prefetch_threshold()
|
||||
if threshold <= 0.0:
|
||||
return centers
|
||||
|
||||
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(focus_position)
|
||||
var local_x_fraction: float = tile_local_position.east_yards / COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
|
||||
var local_z_fraction: float = tile_local_position.south_yards / COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
|
||||
var tile_x_offsets: Array[int] = [0]
|
||||
var tile_y_offsets: Array[int] = [0]
|
||||
|
||||
if local_x_fraction <= threshold:
|
||||
tile_x_offsets.append(-1)
|
||||
elif local_x_fraction >= 1.0 - threshold:
|
||||
tile_x_offsets.append(1)
|
||||
|
||||
if local_z_fraction <= threshold:
|
||||
tile_y_offsets.append(-1)
|
||||
elif local_z_fraction >= 1.0 - threshold:
|
||||
tile_y_offsets.append(1)
|
||||
|
||||
for tile_x_offset in tile_x_offsets:
|
||||
for tile_y_offset in tile_y_offsets:
|
||||
if tile_x_offset == 0 and tile_y_offset == 0:
|
||||
continue
|
||||
centers.append(ADT_TILE_COORDINATE_SCRIPT.new(
|
||||
focus_tile.tile_x + tile_x_offset,
|
||||
focus_tile.tile_y + tile_y_offset
|
||||
))
|
||||
return centers
|
||||
|
||||
|
||||
func _available_keys_within_radius(
|
||||
available_tile_paths_by_key: Dictionary,
|
||||
centers: Array[AdtTileCoordinate],
|
||||
tile_radius: int
|
||||
) -> Dictionary:
|
||||
var available_tile_keys: Dictionary = {}
|
||||
for center in centers:
|
||||
for tile_y in range(center.tile_y - tile_radius, center.tile_y + tile_radius + 1):
|
||||
for tile_x in range(center.tile_x - tile_radius, center.tile_x + tile_radius + 1):
|
||||
var tile_key := _tile_key(tile_x, tile_y)
|
||||
if available_tile_paths_by_key.has(tile_key):
|
||||
available_tile_keys[tile_key] = true
|
||||
return available_tile_keys
|
||||
|
||||
|
||||
func _tile_key(tile_x: int, tile_y: int) -> String:
|
||||
return "%d_%d" % [tile_x, tile_y]
|
||||
@@ -0,0 +1 @@
|
||||
uid://crqjbgoe3f6ea
|
||||
@@ -0,0 +1,108 @@
|
||||
class_name StreamingTargetPolicy
|
||||
extends RefCounted
|
||||
|
||||
## Immutable runtime tile-radius and boundary-prefetch policy consumed by
|
||||
## [StreamingTargetPlanner]. Values preserve scene configuration exactly;
|
||||
## effective radii and thresholds apply the existing renderer clamps.
|
||||
|
||||
const CHUNKS_PER_ADT_TILE := 16
|
||||
|
||||
var lod2_radius_chunks: int:
|
||||
get:
|
||||
return _lod2_radius_chunks
|
||||
|
||||
var lod2_tile_radius: int:
|
||||
get:
|
||||
return _lod2_tile_radius
|
||||
|
||||
var warm_tile_margin: int:
|
||||
get:
|
||||
return _warm_tile_margin
|
||||
|
||||
var prewarm_tile_margin: int:
|
||||
get:
|
||||
return _prewarm_tile_margin
|
||||
|
||||
var retain_tile_margin: int:
|
||||
get:
|
||||
return _retain_tile_margin
|
||||
|
||||
var boundary_prefetch_threshold: float:
|
||||
get:
|
||||
return _boundary_prefetch_threshold
|
||||
|
||||
var _lod2_radius_chunks: int
|
||||
var _lod2_tile_radius: int
|
||||
var _warm_tile_margin: int
|
||||
var _prewarm_tile_margin: int
|
||||
var _retain_tile_margin: int
|
||||
var _boundary_prefetch_threshold: float
|
||||
|
||||
|
||||
## Captures one renderer configuration snapshot for a deterministic plan.
|
||||
func _init(
|
||||
lod2_radius_chunks_value: int,
|
||||
lod2_tile_radius_value: int,
|
||||
warm_tile_margin_value: int,
|
||||
prewarm_tile_margin_value: int,
|
||||
retain_tile_margin_value: int,
|
||||
boundary_prefetch_threshold_value: float
|
||||
) -> void:
|
||||
_lod2_radius_chunks = lod2_radius_chunks_value
|
||||
_lod2_tile_radius = lod2_tile_radius_value
|
||||
_warm_tile_margin = warm_tile_margin_value
|
||||
_prewarm_tile_margin = prewarm_tile_margin_value
|
||||
_retain_tile_margin = retain_tile_margin_value
|
||||
_boundary_prefetch_threshold = boundary_prefetch_threshold_value
|
||||
|
||||
|
||||
## Returns the current visible ADT radius derived from chunk and tile settings.
|
||||
func visible_tile_radius() -> int:
|
||||
return calculate_visible_tile_radius(_lod2_radius_chunks, _lod2_tile_radius, _warm_tile_margin)
|
||||
|
||||
|
||||
## Returns the visible radius plus the non-negative prewarm margin.
|
||||
func load_tile_radius() -> int:
|
||||
return calculate_load_tile_radius(
|
||||
_lod2_radius_chunks,
|
||||
_lod2_tile_radius,
|
||||
_warm_tile_margin,
|
||||
_prewarm_tile_margin
|
||||
)
|
||||
|
||||
|
||||
## Returns the load radius plus the non-negative hysteresis margin.
|
||||
func retained_tile_radius() -> int:
|
||||
return load_tile_radius() + maxi(0, _retain_tile_margin)
|
||||
|
||||
|
||||
## Returns the edge fraction accepted by the existing prefetch policy.
|
||||
func clamped_boundary_prefetch_threshold() -> float:
|
||||
return clampf(_boundary_prefetch_threshold, 0.0, 0.49)
|
||||
|
||||
|
||||
## Calculates the visible radius without allocating a policy. The streamer uses
|
||||
## this hot-path form while finalizing tiles between planning refreshes.
|
||||
static func calculate_visible_tile_radius(
|
||||
lod2_radius_chunks_value: int,
|
||||
lod2_tile_radius_value: int,
|
||||
warm_tile_margin_value: int
|
||||
) -> int:
|
||||
var chunk_tile_radius := int(ceil(
|
||||
float(lod2_radius_chunks_value) / float(CHUNKS_PER_ADT_TILE)
|
||||
)) + warm_tile_margin_value
|
||||
return maxi(chunk_tile_radius, lod2_tile_radius_value)
|
||||
|
||||
|
||||
## Calculates the load radius without allocating a policy.
|
||||
static func calculate_load_tile_radius(
|
||||
lod2_radius_chunks_value: int,
|
||||
lod2_tile_radius_value: int,
|
||||
warm_tile_margin_value: int,
|
||||
prewarm_tile_margin_value: int
|
||||
) -> int:
|
||||
return calculate_visible_tile_radius(
|
||||
lod2_radius_chunks_value,
|
||||
lod2_tile_radius_value,
|
||||
warm_tile_margin_value
|
||||
) + maxi(0, prewarm_tile_margin_value)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfrad5wr668w5
|
||||
@@ -0,0 +1,52 @@
|
||||
class_name RenderedGroundSample
|
||||
extends RefCounted
|
||||
|
||||
## Immutable result of sampling currently loaded render terrain in Godot world units.
|
||||
## This diagnostic value is renderer-owned and is not authoritative gameplay collision.
|
||||
|
||||
var is_available: bool:
|
||||
get:
|
||||
return _is_available
|
||||
|
||||
var height_units: float:
|
||||
get:
|
||||
return _height_units
|
||||
|
||||
var failure_code: StringName:
|
||||
get:
|
||||
return _failure_code
|
||||
|
||||
var _is_available: bool
|
||||
var _height_units: float
|
||||
var _failure_code: StringName
|
||||
|
||||
const SCRIPT_PATH := "res://src/render/terrain/rendered_ground_sample.gd"
|
||||
|
||||
|
||||
## Creates an available finite rendered-ground sample in Godot world units.
|
||||
static func available(height_units_value: float) -> RefCounted:
|
||||
return load(SCRIPT_PATH).new(true, height_units_value, &"")
|
||||
|
||||
|
||||
## Creates an unavailable rendered-ground sample with a stable failure code.
|
||||
static func unavailable(failure_code_value: StringName) -> RefCounted:
|
||||
return load(SCRIPT_PATH).new(false, 0.0, failure_code_value)
|
||||
|
||||
|
||||
func _init(
|
||||
is_available_value: bool,
|
||||
height_units_value: float,
|
||||
failure_code_value: StringName
|
||||
) -> void:
|
||||
_is_available = is_available_value and is_finite(height_units_value)
|
||||
_height_units = height_units_value if _is_available else 0.0
|
||||
if _is_available:
|
||||
_failure_code = &""
|
||||
elif not is_finite(height_units_value):
|
||||
_failure_code = &"render_terrain_height_not_finite"
|
||||
else:
|
||||
_failure_code = (
|
||||
failure_code_value
|
||||
if not failure_code_value.is_empty()
|
||||
else &"render_terrain_unavailable"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c744ldnnn87a8
|
||||
@@ -0,0 +1,140 @@
|
||||
@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")
|
||||
const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.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)
|
||||
|
||||
|
||||
## Samples the currently loaded render terrain at a typed Godot world position.
|
||||
## This read-only renderer view is diagnostic and does not replace authoritative
|
||||
## gameplay collision or the independently composed [TerrainQuery].
|
||||
func sample_ground_height(godot_world_position: GodotWorldPosition) -> RefCounted:
|
||||
var loader := _resolve_streaming_world_loader()
|
||||
if loader == null:
|
||||
return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_facade_unavailable")
|
||||
var sample_variant = loader.call("sample_rendered_ground_height", godot_world_position)
|
||||
if sample_variant is RefCounted and sample_variant.get_script() == RENDERED_GROUND_SAMPLE_SCRIPT:
|
||||
return sample_variant
|
||||
push_error("WorldRenderFacade received an invalid rendered ground sample.")
|
||||
return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_ground_sample_invalid")
|
||||
|
||||
|
||||
## Returns a detached diagnostic snapshot for one rendered-ground query.
|
||||
## Mutating the result cannot mutate streamer queues, tile state or resources.
|
||||
func renderer_ground_query_snapshot(godot_world_position: GodotWorldPosition) -> Dictionary:
|
||||
var loader := _resolve_streaming_world_loader()
|
||||
if loader == null:
|
||||
return {}
|
||||
var snapshot_variant = loader.call("render_ground_query_snapshot", godot_world_position)
|
||||
if not (snapshot_variant is Dictionary):
|
||||
push_error("WorldRenderFacade received a non-Dictionary ground query snapshot.")
|
||||
return {}
|
||||
return (snapshot_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",
|
||||
&"sample_rendered_ground_height",
|
||||
&"render_ground_query_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
|
||||
@@ -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
|
||||
@@ -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,9 +7,8 @@ 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 CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
|
||||
const UNIT_SIZE := CHUNK_SIZE / 8.0
|
||||
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"
|
||||
@export var map_name: String = "Azeroth"
|
||||
@@ -26,367 +22,121 @@ const UNIT_SIZE := CHUNK_SIZE / 8.0
|
||||
@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 _adt_cache: Dictionary = {}
|
||||
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
|
||||
|
||||
|
||||
## Replaces the ground-height backend without changing player movement or presentation.
|
||||
## Passing null is a composition error and leaves the current query unchanged.
|
||||
func set_terrain_query(terrain_query: TerrainQuery) -> void:
|
||||
if terrain_query == null:
|
||||
push_error("ThirdPersonWowController: terrain query cannot be null")
|
||||
return
|
||||
_terrain_query = terrain_query
|
||||
|
||||
|
||||
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(
|
||||
run_speed,
|
||||
backward_speed,
|
||||
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 ground := _sample_ground_height(global_position)
|
||||
if is_finite(ground):
|
||||
global_position.y = ground + ground_offset
|
||||
_yaw = rotation.y
|
||||
_apply_camera_transform()
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
var ground := _sample_ground_height(global_position)
|
||||
if is_finite(ground):
|
||||
var target_y := ground + ground_offset
|
||||
var ground_sample := _sample_ground_height(global_position)
|
||||
if ground_sample.is_available:
|
||||
var target_y := ground_sample.height_units + ground_offset
|
||||
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
|
||||
|
||||
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 _on_character_appearance_ready(character_root: Node3D) -> void:
|
||||
if _animation_presenter != null:
|
||||
_animation_presenter.bind_character_root(character_root)
|
||||
|
||||
|
||||
func _apply_character_starter_outfit(compositor: Node) -> void:
|
||||
if _character_root == null or compositor == null:
|
||||
return
|
||||
var resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
|
||||
if not resolver.call("load_dbcs", extracted_dir):
|
||||
return
|
||||
var inferred: Dictionary = resolver.call("infer_race_gender_from_model_path", character_model_path)
|
||||
var race := String(inferred.get("race", "Human"))
|
||||
var gender := String(inferred.get("gender", "Male"))
|
||||
var outfit: Dictionary = resolver.call("resolve_start_outfit", race, gender, character_class_id)
|
||||
if outfit.is_empty():
|
||||
return
|
||||
if compositor.has_method("set_equipment_components"):
|
||||
compositor.call("set_equipment_components", outfit.get("textures", PackedStringArray()))
|
||||
if _character_root.has_method("apply_outfit_defaults"):
|
||||
_character_root.call("apply_outfit_defaults", outfit)
|
||||
|
||||
|
||||
func _update_character_animation(is_moving: bool) -> void:
|
||||
if _animation_player == null:
|
||||
return
|
||||
var wanted := _choose_animation(["Run", "Walk"]) if is_moving else _choose_animation(["Stand", "Idle"])
|
||||
if wanted.is_empty() or wanted == _active_animation:
|
||||
return
|
||||
_active_animation = wanted
|
||||
_animation_player.play(wanted, animation_blend_time)
|
||||
|
||||
|
||||
func _prepare_animation_player(player: AnimationPlayer) -> void:
|
||||
player.playback_default_blend_time = animation_blend_time
|
||||
for animation_name in player.get_animation_list():
|
||||
var lower := String(animation_name).to_lower()
|
||||
if lower == "stand" or lower == "idle" or lower == "run" or lower == "walk" or lower.contains("stand") or lower.contains("run") or lower.contains("walk"):
|
||||
var animation := player.get_animation(animation_name)
|
||||
if animation:
|
||||
animation.loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
|
||||
func _choose_animation(candidates: Array[String]) -> String:
|
||||
if _animation_player == null:
|
||||
return ""
|
||||
for candidate in candidates:
|
||||
if _animation_player.has_animation(candidate):
|
||||
return candidate
|
||||
var list := _animation_player.get_animation_list()
|
||||
for candidate in candidates:
|
||||
var lower := candidate.to_lower()
|
||||
for animation_name in list:
|
||||
if String(animation_name).to_lower().contains(lower):
|
||||
return String(animation_name)
|
||||
return ""
|
||||
|
||||
|
||||
func _find_animation_player(root: Node) -> AnimationPlayer:
|
||||
if root is AnimationPlayer:
|
||||
return root
|
||||
for child in root.get_children():
|
||||
var found := _find_animation_player(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _calculate_aabb(root: Node) -> AABB:
|
||||
var result := AABB()
|
||||
var has_aabb := false
|
||||
for mesh_inst in _iter_mesh_instances(root):
|
||||
if mesh_inst.mesh == null:
|
||||
continue
|
||||
var local_aabb := mesh_inst.mesh.get_aabb()
|
||||
var global_aabb := mesh_inst.global_transform * local_aabb
|
||||
if not has_aabb:
|
||||
result = global_aabb
|
||||
has_aabb = true
|
||||
else:
|
||||
result = result.merge(global_aabb)
|
||||
return result if has_aabb else AABB()
|
||||
|
||||
|
||||
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
|
||||
var result: Array[MeshInstance3D] = []
|
||||
_collect_mesh_instances(root, result)
|
||||
return result
|
||||
|
||||
|
||||
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
|
||||
if node is MeshInstance3D:
|
||||
result.append(node)
|
||||
for child in node.get_children():
|
||||
_collect_mesh_instances(child, result)
|
||||
|
||||
|
||||
func _character_textures_dir(model_path: String) -> String:
|
||||
return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
|
||||
|
||||
|
||||
func _apply_camera_transform() -> void:
|
||||
if _camera_pivot:
|
||||
_camera_pivot.position = Vector3(0.0, camera_height, 0.0)
|
||||
_camera_pivot.rotation.x = _pitch
|
||||
if _camera:
|
||||
_camera.position = Vector3(0.0, 0.0, camera_distance)
|
||||
_camera.rotation = Vector3.ZERO
|
||||
|
||||
|
||||
func _sample_ground_height(world_pos: Vector3) -> float:
|
||||
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z)
|
||||
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
|
||||
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
|
||||
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
|
||||
var data := _load_adt(tile_coordinate.tile_x, tile_coordinate.tile_y)
|
||||
if data.is_empty():
|
||||
return NAN
|
||||
|
||||
var tile_origin := _get_tile_origin(data)
|
||||
var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15)
|
||||
var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15)
|
||||
var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin)
|
||||
if chunk.is_empty():
|
||||
return NAN
|
||||
|
||||
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
|
||||
var chunk_local := world_pos - origin
|
||||
var gx := clampf(chunk_local.x / UNIT_SIZE, 0.0, 8.0)
|
||||
var gz := clampf(chunk_local.z / UNIT_SIZE, 0.0, 8.0)
|
||||
var x0 := clampi(int(floor(gx)), 0, 8)
|
||||
var z0 := clampi(int(floor(gz)), 0, 8)
|
||||
var x1 := mini(x0 + 1, 8)
|
||||
var z1 := mini(z0 + 1, 8)
|
||||
var fx := gx - float(x0)
|
||||
var fz := gz - float(z0)
|
||||
var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
|
||||
if heights.size() < 145:
|
||||
return NAN
|
||||
|
||||
var h00 := heights[_outer(z0, x0)]
|
||||
var h10 := heights[_outer(z0, x1)]
|
||||
var h01 := heights[_outer(z1, x0)]
|
||||
var h11 := heights[_outer(z1, x1)]
|
||||
var hx0 := lerpf(h00, h10, fx)
|
||||
var hx1 := lerpf(h01, h11, fx)
|
||||
return origin.y + lerpf(hx0, hx1, fz)
|
||||
|
||||
|
||||
func _load_adt(tx: int, ty: int) -> Dictionary:
|
||||
var key := "%d_%d" % [tx, ty]
|
||||
if _adt_cache.has(key):
|
||||
return _adt_cache[key]
|
||||
if not ClassDB.class_exists("ADTLoader"):
|
||||
_adt_cache[key] = {}
|
||||
return {}
|
||||
|
||||
var res_path := extracted_dir.path_join("World/Maps/%s/%s_%d_%d.adt" % [map_name, map_name, tx, ty])
|
||||
var abs_path := ProjectSettings.globalize_path(res_path)
|
||||
if not FileAccess.file_exists(abs_path):
|
||||
_adt_cache[key] = {}
|
||||
return {}
|
||||
|
||||
var loader = ClassDB.instantiate("ADTLoader")
|
||||
var data: Dictionary = loader.call("load_adt", abs_path) if loader else {}
|
||||
_adt_cache[key] = data
|
||||
return data
|
||||
|
||||
|
||||
func _find_chunk(data: Dictionary, chunk_x: int, chunk_y: int, tile_origin: Vector3) -> Dictionary:
|
||||
for chunk in data.get("chunks", []) as Array:
|
||||
if chunk.is_empty():
|
||||
continue
|
||||
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
|
||||
var local := origin - tile_origin
|
||||
var cx := clampi(int(round(local.x / CHUNK_SIZE)), 0, 15)
|
||||
var cy := clampi(int(round(local.z / CHUNK_SIZE)), 0, 15)
|
||||
if cx == chunk_x and cy == chunk_y:
|
||||
return chunk
|
||||
return {}
|
||||
|
||||
|
||||
func _get_tile_origin(data: Dictionary) -> Vector3:
|
||||
var found := false
|
||||
var min_x := 0.0
|
||||
var min_z := 0.0
|
||||
for chunk in data.get("chunks", []) as Array:
|
||||
if chunk.is_empty():
|
||||
continue
|
||||
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
|
||||
if not found:
|
||||
min_x = origin.x
|
||||
min_z = origin.z
|
||||
found = true
|
||||
else:
|
||||
min_x = minf(min_x, origin.x)
|
||||
min_z = minf(min_z, origin.z)
|
||||
return Vector3(min_x, 0.0, min_z) if found else Vector3.ZERO
|
||||
|
||||
|
||||
func _outer(row: int, col: int) -> int:
|
||||
return row * 17 + col
|
||||
func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
|
||||
return _terrain_query.sample_ground_height(
|
||||
GODOT_WORLD_POSITION_SCRIPT.new(
|
||||
godot_world_position.x,
|
||||
godot_world_position.y,
|
||||
godot_world_position.z
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,11 +17,16 @@ const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot
|
||||
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
|
||||
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
|
||||
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
|
||||
const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd")
|
||||
const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd")
|
||||
const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd")
|
||||
const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
|
||||
const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
|
||||
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
|
||||
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
|
||||
const M2_MATERIAL_REFRESH_VERSION := 2
|
||||
const WMO_MATERIAL_REFRESH_VERSION := 10
|
||||
const RENDER_GROUND_QUERY_RAY_HEIGHT_UNITS := 5000.0
|
||||
|
||||
const TILE_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
|
||||
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
|
||||
@@ -174,6 +179,7 @@ const QUALITY_HIGH := "High"
|
||||
@export var hitch_profiler_threshold_ms: float = 20.0
|
||||
|
||||
var _builder
|
||||
var _streaming_target_planner := STREAMING_TARGET_PLANNER_SCRIPT.new()
|
||||
var _map_dir := ""
|
||||
var _abs_map_dir := ""
|
||||
var _wdt_info: Dictionary = {}
|
||||
@@ -230,6 +236,7 @@ var _dbg_last_report := 0.0
|
||||
var _runtime_stats_accum := 0.0
|
||||
var _terrain_quality_log_accum := 0.0
|
||||
var _shutting_down := false
|
||||
var _render_budget_scheduler := RENDER_BUDGET_SCHEDULER_SCRIPT.new()
|
||||
var _last_refresh_focus_pos := Vector3.ZERO
|
||||
var _has_refresh_focus := false
|
||||
var _tile_mesh_cache: Dictionary = {}
|
||||
@@ -313,6 +320,7 @@ func _ready() -> void:
|
||||
func _process(delta: float) -> void:
|
||||
if _available_tiles.is_empty():
|
||||
return
|
||||
_render_budget_scheduler.begin_frame(_render_operation_limits_for_frame())
|
||||
|
||||
var profile_enabled := hitch_profiler_enabled and not Engine.is_editor_hint()
|
||||
var profile_start := Time.get_ticks_usec() if profile_enabled else 0
|
||||
@@ -384,11 +392,33 @@ func _process(delta: float) -> void:
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_shutting_down = true
|
||||
_render_budget_scheduler.cancel()
|
||||
_wait_for_tile_tasks()
|
||||
_clear_streamed_world()
|
||||
_release_runtime_caches_for_shutdown()
|
||||
|
||||
|
||||
func _render_operation_limits_for_frame() -> Dictionary:
|
||||
return {
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE: maxi(1, tile_finalize_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE: maxi(0, terrain_upgrade_finalize_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE: maxi(0, terrain_control_splat_cache_finalize_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE: maxi(0, terrain_splat_cache_finalize_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD: maxi(0, terrain_splat_builds_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE: maxi(0, water_finalize_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY: maxi(0, chunk_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOAD_START: maxi(0, tiles_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_CREATE: maxi(0, tile_lod_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_REMOVE: maxi(0, tile_lod_remove_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE: maxi(0, m2_animation_finalize_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE: maxi(0, m2_mesh_finalize_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD: maxi(0, m2_build_groups_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD: maxi(0, wmo_build_instances_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD: maxi(0, wmo_render_group_ops_per_tick),
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC: maxi(0, detail_asset_ops_per_tick),
|
||||
}
|
||||
|
||||
|
||||
## Frees detached prototype nodes and releases render resources owned by this
|
||||
## loader after all asynchronous work has completed. Map refreshes retain these
|
||||
## caches; only scene shutdown destroys them.
|
||||
@@ -720,57 +750,47 @@ func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> vo
|
||||
if not force and not _should_refresh_focus(focus_pos):
|
||||
return
|
||||
|
||||
var focus_tile := _world_to_tile(focus_pos)
|
||||
var total_tile_radius: int = maxi(0, editor_preview_tile_radius)
|
||||
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
|
||||
var target_plan = _streaming_target_planner.plan_editor(
|
||||
_available_tiles,
|
||||
typed_focus_position,
|
||||
editor_preview_tile_radius
|
||||
)
|
||||
var wanted_tiles: Dictionary = target_plan.wanted_tile_keys()
|
||||
|
||||
var wanted_tiles: Dictionary = {}
|
||||
for ty in range(focus_tile.y - total_tile_radius, focus_tile.y + total_tile_radius + 1):
|
||||
for tx in range(focus_tile.x - total_tile_radius, focus_tile.x + total_tile_radius + 1):
|
||||
var key := _tile_key(tx, ty)
|
||||
if _available_tiles.has(key):
|
||||
wanted_tiles[key] = true
|
||||
|
||||
_apply_streaming_target(wanted_tiles, wanted_tiles, focus_pos)
|
||||
_apply_streaming_target(wanted_tiles, target_plan.retained_tile_keys(), focus_pos)
|
||||
_note_refresh_focus(focus_pos)
|
||||
|
||||
if force or debug_streaming:
|
||||
print("StreamingWorld(editor): focus_tile=%s wanted=%d queued=%d" % [
|
||||
focus_tile, wanted_tiles.size(), _tile_load_queue.size()])
|
||||
Vector2i(target_plan.focus_tile.tile_x, target_plan.focus_tile.tile_y),
|
||||
wanted_tiles.size(),
|
||||
_tile_load_queue.size(),
|
||||
])
|
||||
|
||||
|
||||
func _refresh_streaming_targets_at(focus_pos: Vector3, force: bool) -> void:
|
||||
if not force and not _should_refresh_focus(focus_pos):
|
||||
return
|
||||
|
||||
var focus_tile := _world_to_tile(focus_pos)
|
||||
|
||||
# Chunk-LOD radius → tile radius (ceiling), plus margin for pre-warming.
|
||||
var focus_tiles := _predictive_focus_tiles(focus_pos)
|
||||
var total_tile_radius: int = _runtime_load_tile_radius()
|
||||
var retain_tile_radius: int = _runtime_retain_tile_radius()
|
||||
|
||||
var retained_tiles: Dictionary = {}
|
||||
for center in focus_tiles:
|
||||
for ty in range(center.y - retain_tile_radius, center.y + retain_tile_radius + 1):
|
||||
for tx in range(center.x - retain_tile_radius, center.x + retain_tile_radius + 1):
|
||||
var retained_key := _tile_key(tx, ty)
|
||||
if _available_tiles.has(retained_key):
|
||||
retained_tiles[retained_key] = true
|
||||
|
||||
var wanted_tiles: Dictionary = {}
|
||||
for center in focus_tiles:
|
||||
for ty in range(center.y - total_tile_radius, center.y + total_tile_radius + 1):
|
||||
for tx in range(center.x - total_tile_radius, center.x + total_tile_radius + 1):
|
||||
var key := _tile_key(tx, ty)
|
||||
if _available_tiles.has(key):
|
||||
wanted_tiles[key] = true
|
||||
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
|
||||
var target_plan = _streaming_target_planner.plan_runtime(
|
||||
_available_tiles,
|
||||
typed_focus_position,
|
||||
_streaming_target_policy()
|
||||
)
|
||||
var wanted_tiles: Dictionary = target_plan.wanted_tile_keys()
|
||||
var retained_tiles: Dictionary = target_plan.retained_tile_keys()
|
||||
|
||||
_apply_streaming_target(wanted_tiles, retained_tiles, focus_pos)
|
||||
_note_refresh_focus(focus_pos)
|
||||
|
||||
if force or debug_streaming:
|
||||
print("StreamingWorld: focus_tile=%s wanted=%d queued=%d" % [
|
||||
focus_tile, wanted_tiles.size(), _tile_load_queue.size()])
|
||||
Vector2i(target_plan.focus_tile.tile_x, target_plan.focus_tile.tile_y),
|
||||
wanted_tiles.size(),
|
||||
_tile_load_queue.size(),
|
||||
])
|
||||
|
||||
|
||||
|
||||
@@ -916,33 +936,31 @@ func _rebuild_chunk_queues() -> void:
|
||||
|
||||
|
||||
func _process_queues() -> void:
|
||||
var ops_left: int = chunk_ops_per_tick
|
||||
var tile_lod_create_left: int = tile_lod_ops_per_tick
|
||||
var tile_lod_remove_left: int = tile_lod_remove_ops_per_tick
|
||||
|
||||
# Free stale terrain first so GPU descriptors/memory are reclaimed before new creates.
|
||||
while ops_left > 0 and not _chunk_remove_queue.is_empty():
|
||||
while not _chunk_remove_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
|
||||
_remove_chunk(_chunk_remove_queue.pop_back())
|
||||
ops_left -= 1
|
||||
|
||||
while tile_lod_remove_left > 0 and not _tile_lod_remove_queue.is_empty():
|
||||
while not _tile_lod_remove_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_REMOVE):
|
||||
_remove_tile_lod(_tile_lod_remove_queue.pop_back())
|
||||
tile_lod_remove_left -= 1
|
||||
|
||||
# Parse N ADT files this tick — the main throttle knob
|
||||
var loads_left: int = tiles_per_tick
|
||||
while loads_left > 0 and not _tile_load_queue.is_empty() and _tile_loading_tasks.size() < max_concurrent_tile_tasks:
|
||||
while (
|
||||
not _tile_load_queue.is_empty()
|
||||
and _tile_loading_tasks.size() < max_concurrent_tile_tasks
|
||||
and _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOAD_START)
|
||||
):
|
||||
_start_tile_load_async(_tile_load_queue.pop_front())
|
||||
loads_left -= 1
|
||||
|
||||
while tile_lod_create_left > 0 and not _tile_lod_create_queue.is_empty():
|
||||
while not _tile_lod_create_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_LOD_CREATE):
|
||||
_create_tile_lod(_tile_lod_create_queue.pop_front())
|
||||
tile_lod_create_left -= 1
|
||||
|
||||
# Create chunk meshes — sorted nearest-first so visible detail appears first
|
||||
while ops_left > 0 and not _chunk_create_queue.is_empty():
|
||||
while not _chunk_create_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
|
||||
_create_chunk(_chunk_create_queue.pop_front())
|
||||
ops_left -= 1
|
||||
|
||||
# Debug summary every 2 seconds
|
||||
_dbg_last_report += get_process_delta_time()
|
||||
@@ -1022,6 +1040,173 @@ func render_baseline_snapshot() -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
## Samples already loaded render terrain at one typed Godot world position.
|
||||
## This diagnostic rendering view is not authoritative gameplay collision.
|
||||
func sample_rendered_ground_height(godot_world_position: GodotWorldPosition) -> RefCounted:
|
||||
var ground_query_snapshot := render_ground_query_snapshot(godot_world_position)
|
||||
if ground_query_snapshot.has("terrain_height"):
|
||||
return RENDERED_GROUND_SAMPLE_SCRIPT.available(float(ground_query_snapshot["terrain_height"]))
|
||||
var query_status := StringName(ground_query_snapshot.get("status", "render_terrain_unavailable"))
|
||||
match query_status:
|
||||
&"mesh_not_ready":
|
||||
return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_terrain_mesh_not_ready")
|
||||
&"no_intersection":
|
||||
return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_terrain_no_intersection")
|
||||
_:
|
||||
return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_terrain_unavailable")
|
||||
|
||||
|
||||
## Returns a detached diagnostic read model for the loaded terrain around one
|
||||
## typed position. Queue/cache ownership and mutable tile state stay internal.
|
||||
func render_ground_query_snapshot(godot_world_position: GodotWorldPosition) -> Dictionary:
|
||||
var world_position := Vector3(
|
||||
godot_world_position.x_units,
|
||||
godot_world_position.y_units,
|
||||
godot_world_position.z_units
|
||||
)
|
||||
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(godot_world_position)
|
||||
var highest_terrain_height := -INF
|
||||
var intersected_tile_key := ""
|
||||
var ready_mesh_count := 0
|
||||
for tile_y in range(tile_coordinate.tile_y - 1, tile_coordinate.tile_y + 2):
|
||||
for tile_x in range(tile_coordinate.tile_x - 1, tile_coordinate.tile_x + 2):
|
||||
var tile_key := _tile_key(tile_x, tile_y)
|
||||
if not _tile_states.has(tile_key):
|
||||
continue
|
||||
var terrain_state: Dictionary = _tile_states[tile_key]
|
||||
var terrain_height_variant = _intersect_rendered_terrain_state(terrain_state, world_position)
|
||||
if terrain_height_variant == null:
|
||||
continue
|
||||
ready_mesh_count += 1
|
||||
var terrain_height := float(terrain_height_variant)
|
||||
if terrain_height > highest_terrain_height:
|
||||
highest_terrain_height = terrain_height
|
||||
intersected_tile_key = tile_key
|
||||
|
||||
if is_finite(highest_terrain_height):
|
||||
return {
|
||||
"status": "sampled",
|
||||
"tile": intersected_tile_key,
|
||||
"terrain_height": highest_terrain_height,
|
||||
}
|
||||
|
||||
var focus_tile_key := _tile_key(tile_coordinate.tile_x, tile_coordinate.tile_y)
|
||||
var missing_snapshot := {
|
||||
"status": "no_intersection" if ready_mesh_count > 0 else "mesh_not_ready",
|
||||
"tile": focus_tile_key,
|
||||
}
|
||||
missing_snapshot.merge(
|
||||
_render_ground_tile_diagnostic(focus_tile_key, world_position),
|
||||
true
|
||||
)
|
||||
if bool(missing_snapshot.get("quality_mesh_present", false)) or bool(
|
||||
missing_snapshot.get("tile_lod_mesh_present", false)):
|
||||
missing_snapshot["status"] = "no_intersection"
|
||||
missing_snapshot.merge(_nearest_rendered_terrain_sample(world_position), true)
|
||||
if missing_snapshot.get("nearest_sample_distance", null) != null:
|
||||
missing_snapshot["status"] = "sampled_nearby"
|
||||
missing_snapshot["terrain_height"] = float(missing_snapshot["nearest_sample_height"])
|
||||
return missing_snapshot
|
||||
|
||||
|
||||
func _intersect_rendered_terrain_state(terrain_state: Dictionary, world_position: Vector3):
|
||||
var terrain_mesh: Mesh = terrain_state.get("quality_terrain_mesh", null)
|
||||
if terrain_mesh == null:
|
||||
terrain_mesh = terrain_state.get("tile_lod_mesh", null)
|
||||
if terrain_mesh == null:
|
||||
return null
|
||||
var triangle_mesh := terrain_mesh.generate_triangle_mesh()
|
||||
if triangle_mesh == null:
|
||||
return null
|
||||
var tile_root := terrain_state.get("root", null) as Node3D
|
||||
if tile_root == null or not is_instance_valid(tile_root):
|
||||
return null
|
||||
var inverse_transform := tile_root.global_transform.affine_inverse()
|
||||
var local_ray_origin := inverse_transform * Vector3(
|
||||
world_position.x,
|
||||
RENDER_GROUND_QUERY_RAY_HEIGHT_UNITS,
|
||||
world_position.z
|
||||
)
|
||||
var local_ray_direction := inverse_transform.basis * Vector3.DOWN
|
||||
var intersection: Dictionary = triangle_mesh.intersect_ray(local_ray_origin, local_ray_direction)
|
||||
if intersection.is_empty():
|
||||
return null
|
||||
var local_hit: Vector3 = intersection.get("position", Vector3.ZERO)
|
||||
return (tile_root.global_transform * local_hit).y
|
||||
|
||||
|
||||
func _render_ground_tile_diagnostic(tile_key: String, world_position: Vector3) -> Dictionary:
|
||||
var queued_index := -1
|
||||
for request_index in _tile_load_queue.size():
|
||||
var tile_request: Dictionary = _tile_load_queue[request_index]
|
||||
if String(tile_request.get("key", "")) == tile_key:
|
||||
queued_index = request_index
|
||||
break
|
||||
var diagnostic_snapshot := {
|
||||
"available": _available_tiles.has(tile_key),
|
||||
"queued_index": queued_index,
|
||||
"loading": _tile_loading_tasks.has(tile_key),
|
||||
"state_present": _tile_states.has(tile_key),
|
||||
"load_queue_size": _tile_load_queue.size(),
|
||||
}
|
||||
if not _tile_states.has(tile_key):
|
||||
return diagnostic_snapshot
|
||||
|
||||
var terrain_state: Dictionary = _tile_states[tile_key]
|
||||
var quality_mesh: Mesh = terrain_state.get("quality_terrain_mesh", null)
|
||||
var tile_lod_mesh: Mesh = terrain_state.get("tile_lod_mesh", null)
|
||||
diagnostic_snapshot["quality_mesh_present"] = quality_mesh != null
|
||||
diagnostic_snapshot["tile_lod_mesh_present"] = tile_lod_mesh != null
|
||||
diagnostic_snapshot["quality_source"] = String(terrain_state.get("quality_terrain_source", ""))
|
||||
diagnostic_snapshot["tile_lod"] = int(terrain_state.get("tile_lod", -1))
|
||||
var tile_root := terrain_state.get("root", null) as Node3D
|
||||
if tile_root != null and is_instance_valid(tile_root):
|
||||
var local_position := tile_root.global_transform.affine_inverse() * world_position
|
||||
diagnostic_snapshot["tile_root_position"] = _render_ground_vector3_array(tile_root.global_position)
|
||||
diagnostic_snapshot["probe_local_position"] = _render_ground_vector3_array(local_position)
|
||||
var diagnostic_mesh := quality_mesh if quality_mesh != null else tile_lod_mesh
|
||||
if diagnostic_mesh != null:
|
||||
var mesh_bounds := diagnostic_mesh.get_aabb()
|
||||
diagnostic_snapshot["mesh_aabb_position"] = _render_ground_vector3_array(mesh_bounds.position)
|
||||
diagnostic_snapshot["mesh_aabb_size"] = _render_ground_vector3_array(mesh_bounds.size)
|
||||
return diagnostic_snapshot
|
||||
|
||||
|
||||
func _nearest_rendered_terrain_sample(world_position: Vector3) -> Dictionary:
|
||||
for radius_units in [2.0, 5.0, 10.0, 20.0, 40.0]:
|
||||
for offset in [
|
||||
Vector2(radius_units, 0.0),
|
||||
Vector2(-radius_units, 0.0),
|
||||
Vector2(0.0, radius_units),
|
||||
Vector2(0.0, -radius_units),
|
||||
]:
|
||||
var sample_position := world_position + Vector3(offset.x, 0.0, offset.y)
|
||||
var typed_sample_position = GODOT_WORLD_POSITION_SCRIPT.new(
|
||||
sample_position.x,
|
||||
sample_position.y,
|
||||
sample_position.z
|
||||
)
|
||||
var sample_tile = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_sample_position)
|
||||
var sample_tile_key := _tile_key(sample_tile.tile_x, sample_tile.tile_y)
|
||||
if not _tile_states.has(sample_tile_key):
|
||||
continue
|
||||
var terrain_height_variant = _intersect_rendered_terrain_state(
|
||||
_tile_states[sample_tile_key],
|
||||
sample_position
|
||||
)
|
||||
if terrain_height_variant != null:
|
||||
return {
|
||||
"nearest_sample_distance": radius_units,
|
||||
"nearest_sample_height": float(terrain_height_variant),
|
||||
"nearest_sample_tile": sample_tile_key,
|
||||
}
|
||||
return {"nearest_sample_distance": null}
|
||||
|
||||
|
||||
func _render_ground_vector3_array(vector: Vector3) -> Array[float]:
|
||||
return [vector.x, vector.y, vector.z]
|
||||
|
||||
|
||||
func _tick_runtime_stats(delta: float) -> void:
|
||||
if not runtime_stats_enabled or Engine.is_editor_hint():
|
||||
return
|
||||
@@ -1293,11 +1478,10 @@ func _load_tile_task(request: Dictionary) -> void:
|
||||
|
||||
func _drain_tile_load_results() -> void:
|
||||
var results: Array = []
|
||||
var finalize_budget: int = maxi(1, tile_finalize_ops_per_tick)
|
||||
_tile_result_mutex.lock()
|
||||
while finalize_budget > 0 and not _tile_result_queue.is_empty():
|
||||
while not _tile_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
|
||||
results.append(_tile_result_queue.pop_front())
|
||||
finalize_budget -= 1
|
||||
_tile_result_mutex.unlock()
|
||||
|
||||
for result in results:
|
||||
@@ -1321,10 +1505,10 @@ func _drain_tile_load_results() -> void:
|
||||
_finalize_loaded_tile(request, result["data"])
|
||||
|
||||
var baked_ready: Array = []
|
||||
if finalize_budget <= 0:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
|
||||
return
|
||||
for key in _tile_loading_tasks.keys():
|
||||
if baked_ready.size() >= finalize_budget:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
|
||||
break
|
||||
var pending: Dictionary = _tile_loading_tasks[key]
|
||||
if pending.get("mode", "raw") != "baked" and pending.get("mode", "raw") != "stream":
|
||||
@@ -1332,7 +1516,8 @@ func _drain_tile_load_results() -> void:
|
||||
|
||||
var status := ResourceLoader.load_threaded_get_status(pending["path"])
|
||||
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
|
||||
baked_ready.append(key)
|
||||
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TILE_FINALIZE):
|
||||
baked_ready.append(key)
|
||||
|
||||
for key in baked_ready:
|
||||
if not _tile_loading_tasks.has(key):
|
||||
@@ -1750,18 +1935,18 @@ func _request_terrain_splat_cache(state: Dictionary) -> bool:
|
||||
|
||||
func _drain_terrain_upgrade_results() -> void:
|
||||
var ready: Array[String] = []
|
||||
var budget := maxi(0, terrain_upgrade_finalize_ops_per_tick)
|
||||
if budget <= 0:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
|
||||
return
|
||||
|
||||
for key_variant in _terrain_upgrade_tasks.keys():
|
||||
if ready.size() >= budget:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
|
||||
break
|
||||
var key := String(key_variant)
|
||||
var pending: Dictionary = _terrain_upgrade_tasks[key]
|
||||
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
|
||||
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
|
||||
ready.append(key)
|
||||
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_UPGRADE_FINALIZE):
|
||||
ready.append(key)
|
||||
|
||||
var changed := false
|
||||
for key in ready:
|
||||
@@ -1809,18 +1994,18 @@ func _drain_terrain_upgrade_results() -> void:
|
||||
|
||||
func _drain_terrain_control_splat_cache_results() -> void:
|
||||
var ready: Array[String] = []
|
||||
var budget := maxi(0, terrain_control_splat_cache_finalize_ops_per_tick)
|
||||
if budget <= 0:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
|
||||
return
|
||||
|
||||
for key_variant in _terrain_control_splat_cache_tasks.keys():
|
||||
if ready.size() >= budget:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
|
||||
break
|
||||
var key := String(key_variant)
|
||||
var pending: Dictionary = _terrain_control_splat_cache_tasks[key]
|
||||
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
|
||||
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
|
||||
ready.append(key)
|
||||
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_CONTROL_SPLAT_FINALIZE):
|
||||
ready.append(key)
|
||||
|
||||
var changed := false
|
||||
for key in ready:
|
||||
@@ -1875,18 +2060,18 @@ func _drain_terrain_control_splat_cache_results() -> void:
|
||||
|
||||
func _drain_terrain_splat_cache_results() -> void:
|
||||
var ready: Array[String] = []
|
||||
var budget := maxi(0, terrain_splat_cache_finalize_ops_per_tick)
|
||||
if budget <= 0:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
|
||||
return
|
||||
|
||||
for key_variant in _terrain_splat_cache_tasks.keys():
|
||||
if ready.size() >= budget:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
|
||||
break
|
||||
var key := String(key_variant)
|
||||
var pending: Dictionary = _terrain_splat_cache_tasks[key]
|
||||
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
|
||||
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
|
||||
ready.append(key)
|
||||
if _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_CACHE_FINALIZE):
|
||||
ready.append(key)
|
||||
|
||||
var changed := false
|
||||
for key in ready:
|
||||
@@ -1963,14 +2148,13 @@ func _load_terrain_splat_task(key: String, adt_path: String) -> void:
|
||||
|
||||
func _drain_terrain_splat_results() -> void:
|
||||
var results: Array = []
|
||||
var budget := maxi(0, terrain_splat_builds_per_tick)
|
||||
if budget <= 0:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD):
|
||||
return
|
||||
|
||||
_terrain_splat_result_mutex.lock()
|
||||
while budget > 0 and not _terrain_splat_result_queue.is_empty():
|
||||
while not _terrain_splat_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.TERRAIN_SPLAT_BUILD):
|
||||
results.append(_terrain_splat_result_queue.pop_front())
|
||||
budget -= 1
|
||||
_terrain_splat_result_mutex.unlock()
|
||||
|
||||
var changed := false
|
||||
@@ -2074,14 +2258,13 @@ func _load_tile_water_task(key: String, adt_path: String) -> void:
|
||||
|
||||
func _drain_water_load_results() -> void:
|
||||
var results: Array = []
|
||||
var budget := maxi(0, water_finalize_ops_per_tick)
|
||||
if budget <= 0:
|
||||
if not _render_budget_scheduler.has_remaining_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
|
||||
return
|
||||
|
||||
_water_result_mutex.lock()
|
||||
while budget > 0 and not _water_result_queue.is_empty():
|
||||
while not _water_result_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.WATER_FINALIZE):
|
||||
results.append(_water_result_queue.pop_front())
|
||||
budget -= 1
|
||||
_water_result_mutex.unlock()
|
||||
|
||||
for result in results:
|
||||
@@ -2891,50 +3074,24 @@ func _compute_desired_lods(state: Dictionary, cam_pos: Vector3) -> Dictionary:
|
||||
return desired
|
||||
|
||||
|
||||
func _runtime_visible_tile_radius() -> int:
|
||||
var chunk_tile_radius: int = int(ceil(float(lod2_radius_chunks) / 16.0)) + warm_tile_margin
|
||||
return maxi(chunk_tile_radius, lod2_tile_radius)
|
||||
|
||||
|
||||
func _runtime_load_tile_radius() -> int:
|
||||
return _runtime_visible_tile_radius() + maxi(0, prewarm_tile_margin)
|
||||
return STREAMING_TARGET_POLICY_SCRIPT.calculate_load_tile_radius(
|
||||
lod2_radius_chunks,
|
||||
lod2_tile_radius,
|
||||
warm_tile_margin,
|
||||
prewarm_tile_margin
|
||||
)
|
||||
|
||||
|
||||
func _runtime_retain_tile_radius() -> int:
|
||||
return _runtime_load_tile_radius() + maxi(0, retain_tile_margin)
|
||||
|
||||
|
||||
func _predictive_focus_tiles(focus_pos: Vector3) -> Array[Vector2i]:
|
||||
var base := _world_to_tile(focus_pos)
|
||||
var result: Array[Vector2i] = [base]
|
||||
var threshold: float = clampf(boundary_prefetch_threshold, 0.0, 0.49)
|
||||
if threshold <= 0.0:
|
||||
return result
|
||||
|
||||
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
|
||||
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_focus_position)
|
||||
var local_x: float = tile_local_position.east_yards / TILE_SIZE
|
||||
var local_z: float = tile_local_position.south_yards / TILE_SIZE
|
||||
var x_offsets: Array[int] = [0]
|
||||
var z_offsets: Array[int] = [0]
|
||||
|
||||
if local_x <= threshold:
|
||||
x_offsets.append(-1)
|
||||
elif local_x >= 1.0 - threshold:
|
||||
x_offsets.append(1)
|
||||
|
||||
if local_z <= threshold:
|
||||
z_offsets.append(-1)
|
||||
elif local_z >= 1.0 - threshold:
|
||||
z_offsets.append(1)
|
||||
|
||||
for ox in x_offsets:
|
||||
for oz in z_offsets:
|
||||
if ox == 0 and oz == 0:
|
||||
continue
|
||||
result.append(Vector2i(base.x + ox, base.y + oz))
|
||||
|
||||
return result
|
||||
func _streaming_target_policy():
|
||||
return STREAMING_TARGET_POLICY_SCRIPT.new(
|
||||
lod2_radius_chunks,
|
||||
lod2_tile_radius,
|
||||
warm_tile_margin,
|
||||
prewarm_tile_margin,
|
||||
retain_tile_margin,
|
||||
boundary_prefetch_threshold
|
||||
)
|
||||
|
||||
|
||||
func _compute_desired_tile_lod(state: Dictionary, cam_pos: Vector3) -> int:
|
||||
@@ -3285,12 +3442,11 @@ func _enqueue_detail_asset_sync(key: String) -> void:
|
||||
|
||||
|
||||
func _process_detail_asset_queue() -> void:
|
||||
var ops_left: int = maxi(0, detail_asset_ops_per_tick)
|
||||
while ops_left > 0 and not _detail_asset_queue.is_empty():
|
||||
while not _detail_asset_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC):
|
||||
var key: String = String(_detail_asset_queue.pop_front())
|
||||
_detail_asset_queued.erase(key)
|
||||
_process_detail_asset_key(key)
|
||||
ops_left -= 1
|
||||
|
||||
|
||||
func _process_detail_asset_key(key: String) -> void:
|
||||
@@ -3374,8 +3530,8 @@ func _register_tile_wmos(state: Dictionary, wmo_names: PackedStringArray, wmo_pl
|
||||
func _process_wmo_build_jobs() -> void:
|
||||
_drain_wmo_render_loads()
|
||||
_drain_wmo_scene_loads()
|
||||
var ops_left: int = maxi(0, wmo_build_instances_per_tick)
|
||||
while ops_left > 0 and not _wmo_build_queue.is_empty():
|
||||
while _render_budget_scheduler.has_remaining_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD) and not _wmo_build_queue.is_empty():
|
||||
var tile_key: String = String(_wmo_build_queue.front())
|
||||
if not _wmo_build_jobs.has(tile_key):
|
||||
_wmo_build_queue.pop_front()
|
||||
@@ -3472,7 +3628,7 @@ func _process_wmo_build_jobs() -> void:
|
||||
_tile_states[tile_key] = state
|
||||
_wmo_build_queue.pop_front()
|
||||
_wmo_build_queue.append(tile_key)
|
||||
ops_left -= 1
|
||||
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD)
|
||||
continue
|
||||
|
||||
job["index"] = index + 1
|
||||
@@ -3480,7 +3636,7 @@ func _process_wmo_build_jobs() -> void:
|
||||
state["wmo_refs"] = refs
|
||||
state["wmo_building"] = true
|
||||
_tile_states[tile_key] = state
|
||||
ops_left -= 1
|
||||
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_BUILD)
|
||||
|
||||
if int(job["index"]) >= wmo_placements.size():
|
||||
state["wmo_refs"] = refs
|
||||
@@ -3622,8 +3778,8 @@ func _start_wmo_render_build(unique_key: String, root: Node3D, render_resource:
|
||||
|
||||
|
||||
func _process_wmo_render_build_jobs() -> void:
|
||||
var ops_left: int = maxi(0, wmo_render_group_ops_per_tick)
|
||||
while ops_left > 0 and not _wmo_render_build_queue.is_empty():
|
||||
while _render_budget_scheduler.has_remaining_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD) and not _wmo_render_build_queue.is_empty():
|
||||
var unique_key := String(_wmo_render_build_queue.front())
|
||||
if not _wmo_render_build_jobs.has(unique_key):
|
||||
_wmo_render_build_queue.pop_front()
|
||||
@@ -3665,7 +3821,7 @@ func _process_wmo_render_build_jobs() -> void:
|
||||
_set_editor_owner_recursive(mesh_instance)
|
||||
job["mesh_index"] = mesh_index + 1
|
||||
_wmo_render_build_jobs[unique_key] = job
|
||||
ops_left -= 1
|
||||
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
|
||||
continue
|
||||
|
||||
var multimeshes: Array = render_resource.get("multimeshes")
|
||||
@@ -3697,7 +3853,7 @@ func _process_wmo_render_build_jobs() -> void:
|
||||
_set_editor_owner_recursive(multimesh_instance)
|
||||
job["multimesh_index"] = multimesh_index + 1
|
||||
_wmo_render_build_jobs[unique_key] = job
|
||||
ops_left -= 1
|
||||
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD)
|
||||
continue
|
||||
|
||||
_cancel_wmo_render_build_job(unique_key)
|
||||
@@ -4209,16 +4365,14 @@ func _drain_m2_animation_loads() -> void:
|
||||
_m2_animation_load_requests.erase(normalized_rel)
|
||||
_m2_animation_finalize_queue.append(pending)
|
||||
|
||||
var ops_left: int = maxi(0, m2_animation_finalize_ops_per_tick)
|
||||
while ops_left > 0 and not _m2_animation_finalize_queue.is_empty():
|
||||
while not _m2_animation_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE):
|
||||
var pending: Dictionary = _m2_animation_finalize_queue.pop_front()
|
||||
var normalized_rel := String(pending.get("normalized", ""))
|
||||
if normalized_rel.is_empty() or _m2_animated_scene_cache.has(normalized_rel) or _m2_static_animation_cache.has(normalized_rel):
|
||||
ops_left -= 1
|
||||
continue
|
||||
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
|
||||
_m2_static_animation_cache[normalized_rel] = true
|
||||
ops_left -= 1
|
||||
continue
|
||||
|
||||
var path := String(pending.get("path", ""))
|
||||
@@ -4232,11 +4386,9 @@ func _drain_m2_animation_loads() -> void:
|
||||
_m2_animated_scene_cache[normalized_rel] = node as Node3D
|
||||
if debug_streaming:
|
||||
print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()])
|
||||
ops_left -= 1
|
||||
continue
|
||||
node.free()
|
||||
_m2_static_animation_cache[normalized_rel] = true
|
||||
ops_left -= 1
|
||||
|
||||
|
||||
func _drain_m2_mesh_loads() -> void:
|
||||
@@ -4255,16 +4407,14 @@ func _drain_m2_mesh_loads() -> void:
|
||||
_m2_mesh_load_requests.erase(normalized_rel)
|
||||
_m2_mesh_finalize_queue.append(pending)
|
||||
|
||||
var ops_left: int = maxi(0, m2_mesh_finalize_ops_per_tick)
|
||||
while ops_left > 0 and not _m2_mesh_finalize_queue.is_empty():
|
||||
while not _m2_mesh_finalize_queue.is_empty() and _render_budget_scheduler.try_consume_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE):
|
||||
var pending: Dictionary = _m2_mesh_finalize_queue.pop_front()
|
||||
var normalized_rel := String(pending.get("normalized", ""))
|
||||
if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel):
|
||||
ops_left -= 1
|
||||
continue
|
||||
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
|
||||
_m2_missing_cache[normalized_rel] = true
|
||||
ops_left -= 1
|
||||
continue
|
||||
|
||||
var path := String(pending.get("path", ""))
|
||||
@@ -4274,12 +4424,11 @@ func _drain_m2_mesh_loads() -> void:
|
||||
_m2_mesh_cache[normalized_rel] = _prepare_m2_mesh_for_runtime(normalized_rel, mesh)
|
||||
else:
|
||||
_m2_missing_cache[normalized_rel] = true
|
||||
ops_left -= 1
|
||||
|
||||
|
||||
func _process_m2_build_jobs() -> void:
|
||||
var ops_left: int = maxi(0, m2_build_groups_per_tick)
|
||||
while ops_left > 0 and not _m2_build_queue.is_empty():
|
||||
while _render_budget_scheduler.has_remaining_permit(
|
||||
RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) and not _m2_build_queue.is_empty():
|
||||
var key: String = String(_m2_build_queue.front())
|
||||
if not _m2_build_jobs.has(key):
|
||||
_m2_build_queue.pop_front()
|
||||
@@ -4325,7 +4474,7 @@ func _process_m2_build_jobs() -> void:
|
||||
if enable_m2_animated_instances and _m2_animation_load_requests.has(normalized_rel):
|
||||
_m2_build_queue.pop_front()
|
||||
_m2_build_queue.append(key)
|
||||
ops_left -= 1
|
||||
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
|
||||
continue
|
||||
var batch_size: int = (
|
||||
maxi(1, m2_animated_instances_per_tick)
|
||||
@@ -4344,7 +4493,7 @@ func _process_m2_build_jobs() -> void:
|
||||
if not _m2_missing_cache.has(normalized_rel):
|
||||
_m2_build_queue.pop_front()
|
||||
_m2_build_queue.append(key)
|
||||
ops_left -= 1
|
||||
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
|
||||
continue
|
||||
else:
|
||||
_materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial)
|
||||
@@ -4355,7 +4504,7 @@ func _process_m2_build_jobs() -> void:
|
||||
else:
|
||||
job["offset"] = offset + consumed
|
||||
_m2_build_jobs[key] = job
|
||||
ops_left -= 1
|
||||
_render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD)
|
||||
|
||||
if int(job["index"]) >= group_keys.size():
|
||||
_finish_m2_build_job(key)
|
||||
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -5,9 +5,7 @@ extends SceneTree
|
||||
## [--wait 3.0] [--output user://render_terrain_height/report.json]
|
||||
|
||||
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
|
||||
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
|
||||
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
|
||||
const RAY_HEIGHT := 5000.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
@@ -37,7 +35,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,10 +57,17 @@ 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)
|
||||
var typed_camera_position = GODOT_WORLD_POSITION_SCRIPT.new(
|
||||
camera_position.x,
|
||||
camera_position.y,
|
||||
camera_position.z
|
||||
)
|
||||
var terrain_sample: Dictionary = render_facade.call(
|
||||
"renderer_ground_query_snapshot",
|
||||
typed_camera_position
|
||||
)
|
||||
terrain_sample["name"] = checkpoint.get("name", "checkpoint")
|
||||
terrain_sample["camera_y"] = camera_position.y
|
||||
if terrain_sample.has("terrain_height"):
|
||||
@@ -90,137 +100,6 @@ func _run_async() -> void:
|
||||
quit(1 if failed else 0)
|
||||
|
||||
|
||||
func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary:
|
||||
var tile_coordinate := _adt_tile_vector2i(world_position)
|
||||
var tile_states: Dictionary = world.get("_tile_states")
|
||||
var highest_terrain_height := -INF
|
||||
var intersected_tile_key := ""
|
||||
var ready_mesh_count := 0
|
||||
for tile_y in range(tile_coordinate.y - 1, tile_coordinate.y + 2):
|
||||
for tile_x in range(tile_coordinate.x - 1, tile_coordinate.x + 2):
|
||||
var tile_key := "%d_%d" % [tile_x, tile_y]
|
||||
if not tile_states.has(tile_key):
|
||||
continue
|
||||
var state: Dictionary = tile_states[tile_key]
|
||||
var terrain_height_variant = _intersect_terrain_state(state, world_position)
|
||||
if terrain_height_variant == null:
|
||||
continue
|
||||
ready_mesh_count += 1
|
||||
var terrain_height := float(terrain_height_variant)
|
||||
if terrain_height > highest_terrain_height:
|
||||
highest_terrain_height = terrain_height
|
||||
intersected_tile_key = tile_key
|
||||
if not is_finite(highest_terrain_height):
|
||||
var missing_result := {
|
||||
"status": "no_intersection" if ready_mesh_count > 0 else "mesh_not_ready",
|
||||
"tile": "%d_%d" % [tile_coordinate.x, tile_coordinate.y],
|
||||
}
|
||||
missing_result.merge(_tile_runtime_diagnostic(world, String(missing_result.tile), world_position), true)
|
||||
if bool(missing_result.get("quality_mesh_present", false)) or bool(missing_result.get("tile_lod_mesh_present", false)):
|
||||
missing_result["status"] = "no_intersection"
|
||||
missing_result.merge(_nearest_terrain_sample(world, world_position), true)
|
||||
if missing_result.get("nearest_sample_distance", null) != null:
|
||||
missing_result["status"] = "sampled_nearby"
|
||||
missing_result["terrain_height"] = float(missing_result.nearest_sample_height)
|
||||
return missing_result
|
||||
return {
|
||||
"status": "sampled",
|
||||
"tile": intersected_tile_key,
|
||||
"terrain_height": highest_terrain_height,
|
||||
}
|
||||
|
||||
|
||||
func _intersect_terrain_state(state: Dictionary, world_position: Vector3):
|
||||
var terrain_mesh: Mesh = state.get("quality_terrain_mesh", null)
|
||||
if terrain_mesh == null:
|
||||
terrain_mesh = state.get("tile_lod_mesh", null)
|
||||
if terrain_mesh == null:
|
||||
return null
|
||||
var triangle_mesh := terrain_mesh.generate_triangle_mesh()
|
||||
if triangle_mesh == null:
|
||||
return null
|
||||
var tile_root := state.get("root", null) as Node3D
|
||||
if tile_root == null:
|
||||
return null
|
||||
var inverse_transform := tile_root.global_transform.affine_inverse()
|
||||
var local_ray_origin := inverse_transform * Vector3(world_position.x, RAY_HEIGHT, world_position.z)
|
||||
var local_ray_direction := inverse_transform.basis * Vector3.DOWN
|
||||
var intersection: Dictionary = triangle_mesh.intersect_ray(local_ray_origin, local_ray_direction)
|
||||
if intersection.is_empty():
|
||||
return null
|
||||
var local_hit: Vector3 = intersection.get("position", Vector3.ZERO)
|
||||
var world_hit := tile_root.global_transform * local_hit
|
||||
return world_hit.y
|
||||
|
||||
|
||||
func _tile_runtime_diagnostic(world: Node3D, tile_key: String, world_position: Vector3) -> Dictionary:
|
||||
var available_tiles: Dictionary = world.get("_available_tiles")
|
||||
var loading_tasks: Dictionary = world.get("_tile_loading_tasks")
|
||||
var tile_states: Dictionary = world.get("_tile_states")
|
||||
var load_queue: Array = world.get("_tile_load_queue")
|
||||
var queued_index := -1
|
||||
for index in load_queue.size():
|
||||
var request: Dictionary = load_queue[index]
|
||||
if String(request.get("key", "")) == tile_key:
|
||||
queued_index = index
|
||||
break
|
||||
var diagnostic := {
|
||||
"available": available_tiles.has(tile_key),
|
||||
"queued_index": queued_index,
|
||||
"loading": loading_tasks.has(tile_key),
|
||||
"state_present": tile_states.has(tile_key),
|
||||
"load_queue_size": load_queue.size(),
|
||||
}
|
||||
if tile_states.has(tile_key):
|
||||
var state: Dictionary = tile_states[tile_key]
|
||||
var quality_mesh: Mesh = state.get("quality_terrain_mesh", null)
|
||||
var tile_lod_mesh: Mesh = state.get("tile_lod_mesh", null)
|
||||
diagnostic["quality_mesh_present"] = quality_mesh != null
|
||||
diagnostic["tile_lod_mesh_present"] = tile_lod_mesh != null
|
||||
diagnostic["quality_source"] = String(state.get("quality_terrain_source", ""))
|
||||
diagnostic["tile_lod"] = int(state.get("tile_lod", -1))
|
||||
var tile_root := state.get("root", null) as Node3D
|
||||
if tile_root != null:
|
||||
var local_position := tile_root.global_transform.affine_inverse() * world_position
|
||||
diagnostic["tile_root_position"] = _vector3_array(tile_root.global_position)
|
||||
diagnostic["probe_local_position"] = _vector3_array(local_position)
|
||||
var diagnostic_mesh := quality_mesh if quality_mesh != null else tile_lod_mesh
|
||||
if diagnostic_mesh != null:
|
||||
var mesh_aabb := diagnostic_mesh.get_aabb()
|
||||
diagnostic["mesh_aabb_position"] = _vector3_array(mesh_aabb.position)
|
||||
diagnostic["mesh_aabb_size"] = _vector3_array(mesh_aabb.size)
|
||||
return diagnostic
|
||||
|
||||
|
||||
func _nearest_terrain_sample(world: Node3D, world_position: Vector3) -> Dictionary:
|
||||
var tile_states: Dictionary = world.get("_tile_states")
|
||||
for radius in [2.0, 5.0, 10.0, 20.0, 40.0]:
|
||||
for offset in [Vector2(radius, 0.0), Vector2(-radius, 0.0), Vector2(0.0, radius), Vector2(0.0, -radius)]:
|
||||
var sample_position := world_position + Vector3(offset.x, 0.0, offset.y)
|
||||
var sample_tile := _adt_tile_vector2i(sample_position)
|
||||
var sample_key := "%d_%d" % [sample_tile.x, sample_tile.y]
|
||||
if not tile_states.has(sample_key):
|
||||
continue
|
||||
var height_variant = _intersect_terrain_state(tile_states[sample_key], sample_position)
|
||||
if height_variant != null:
|
||||
return {
|
||||
"nearest_sample_distance": radius,
|
||||
"nearest_sample_height": float(height_variant),
|
||||
"nearest_sample_tile": sample_key,
|
||||
}
|
||||
return {"nearest_sample_distance": null}
|
||||
|
||||
|
||||
func _adt_tile_vector2i(world_position: Vector3) -> Vector2i:
|
||||
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_position.x, world_position.y, world_position.z)
|
||||
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
|
||||
return Vector2i(tile_coordinate.tile_x, tile_coordinate.tile_y)
|
||||
|
||||
|
||||
func _vector3_array(value: Vector3) -> Array[float]:
|
||||
return [value.x, value.y, value.z]
|
||||
|
||||
|
||||
func _vector3(value_variant) -> Vector3:
|
||||
if not (value_variant is Array) or value_variant.size() != 3:
|
||||
return Vector3.ZERO
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dctxeqgyptl8l
|
||||
@@ -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
|
||||
@@ -19,9 +19,10 @@ const ALLOWED_LEGACY_NAME_PATHS: Array[String] = [
|
||||
]
|
||||
const REQUIRED_MAPPER_CALLS := {
|
||||
"res://src/scenes/sky/wow_sky_controller.gd": ["godot_to_canonical", "godot_to_adt_tile", "godot_to_adt_tile_local"],
|
||||
"res://src/scenes/streaming/streaming_world_loader.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_godot"],
|
||||
"res://src/scenes/player/third_person_wow_controller.gd": ["godot_to_adt_tile", "adt_tile_local_to_godot"],
|
||||
"res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"],
|
||||
"res://src/scenes/streaming/streaming_world_loader.gd": ["godot_to_adt_tile", "adt_tile_local_to_godot"],
|
||||
"res://src/render/streaming/streaming_target_planner.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local"],
|
||||
"res://src/scenes/player/third_person_wow_controller.gd": ["adt_tile_local_to_godot"],
|
||||
"res://src/gameplay/terrain/adt_terrain_query.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_chunk"],
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +60,7 @@ func _initialize() -> void:
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=4 classifier_cases=6" % source_paths.size())
|
||||
print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=5 classifier_cases=6" % source_paths.size())
|
||||
quit(0)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,175 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free M03 regression for bounded renderer operation permits and cancellation.
|
||||
|
||||
const SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
|
||||
const SCHEDULER_PATH := "res://src/render/streaming/render_budget_scheduler.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const PERFORMANCE_ITERATIONS := 20000
|
||||
const MAXIMUM_PERMIT_CHECK_MILLISECONDS := 150.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_bounded_lane_and_diagnostics(failures)
|
||||
_verify_shared_lane_priority(failures)
|
||||
_verify_independent_lanes_and_frame_reset(failures)
|
||||
_verify_invalid_limits_and_unknown_lane(failures)
|
||||
_verify_cancellation(failures)
|
||||
_verify_scene_free_extraction(failures)
|
||||
var elapsed_milliseconds := _measure_permit_checks(failures)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("RENDER_BUDGET_SCHEDULER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("RENDER_BUDGET_SCHEDULER PASS cases=6 iterations=%d elapsed_ms=%.3f" % [
|
||||
PERFORMANCE_ITERATIONS,
|
||||
elapsed_milliseconds,
|
||||
])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_bounded_lane_and_diagnostics(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.TILE_FINALIZE: 2})
|
||||
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "first permit", failures)
|
||||
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "second permit", failures)
|
||||
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_FINALIZE), "exhausted permit", failures)
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_FINALIZE), 0, "remaining permits", failures)
|
||||
var consumed_snapshot: Dictionary = scheduler.consumed_permits_snapshot()
|
||||
_expect_equal(int(consumed_snapshot[SCHEDULER_SCRIPT.TILE_FINALIZE]), 2, "consumed permits", failures)
|
||||
consumed_snapshot[SCHEDULER_SCRIPT.TILE_FINALIZE] = 99
|
||||
_expect_equal(
|
||||
int(scheduler.consumed_permits_snapshot()[SCHEDULER_SCRIPT.TILE_FINALIZE]),
|
||||
2,
|
||||
"diagnostic snapshot is detached",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_shared_lane_priority(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.CHUNK_GEOMETRY: 3})
|
||||
var removals := 0
|
||||
while removals < 2 and scheduler.try_consume_permit(SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
|
||||
removals += 1
|
||||
var creates := 0
|
||||
while scheduler.try_consume_permit(SCHEDULER_SCRIPT.CHUNK_GEOMETRY):
|
||||
creates += 1
|
||||
_expect_equal(removals, 2, "shared lane removals consume first", failures)
|
||||
_expect_equal(creates, 1, "shared lane leaves one create", failures)
|
||||
|
||||
|
||||
func _verify_independent_lanes_and_frame_reset(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({
|
||||
SCHEDULER_SCRIPT.TILE_LOD_CREATE: 1,
|
||||
SCHEDULER_SCRIPT.TILE_LOD_REMOVE: 2,
|
||||
})
|
||||
_expect_true(scheduler.try_consume_permit(SCHEDULER_SCRIPT.TILE_LOD_CREATE), "create lane permit", failures)
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_REMOVE), 2, "remove lane independent", failures)
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.TILE_LOD_CREATE: 3})
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_CREATE), 3, "new frame resets lane", failures)
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.TILE_LOD_REMOVE), 0, "new frame removes absent lane", failures)
|
||||
_expect_equal(int(scheduler.consumed_permits_snapshot()[SCHEDULER_SCRIPT.TILE_LOD_CREATE]), 0, "new frame resets diagnostics", failures)
|
||||
|
||||
|
||||
func _verify_invalid_limits_and_unknown_lane(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({
|
||||
SCHEDULER_SCRIPT.WATER_FINALIZE: 0,
|
||||
SCHEDULER_SCRIPT.M2_BUILD: -4,
|
||||
})
|
||||
_expect_false(scheduler.has_remaining_permit(SCHEDULER_SCRIPT.WATER_FINALIZE), "zero limit", failures)
|
||||
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.M2_BUILD), "negative limit clamps", failures)
|
||||
_expect_false(scheduler.try_consume_permit(&"unknown_lane"), "unknown lane", failures)
|
||||
|
||||
|
||||
func _verify_cancellation(failures: Array[String]) -> void:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.WMO_BUILD: 2})
|
||||
scheduler.cancel()
|
||||
_expect_true(scheduler.is_cancelled(), "cancelled state", failures)
|
||||
_expect_false(scheduler.try_consume_permit(SCHEDULER_SCRIPT.WMO_BUILD), "cancel rejects permit", failures)
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.WMO_BUILD: 5})
|
||||
_expect_equal(scheduler.remaining_permits(SCHEDULER_SCRIPT.WMO_BUILD), 0, "begin frame cannot revive cancelled lifecycle", failures)
|
||||
|
||||
|
||||
func _verify_scene_free_extraction(failures: Array[String]) -> void:
|
||||
var scheduler_source := _read_text(SCHEDULER_PATH, failures)
|
||||
_expect_true(scheduler_source.contains("extends RefCounted"), "scheduler is RefCounted", failures)
|
||||
for forbidden_text in ["extends Node", "WorkerThreadPool", "ResourceLoader", "RenderingServer", "Mutex"]:
|
||||
_expect_false(scheduler_source.contains(forbidden_text), "scheduler omits %s" % forbidden_text, failures)
|
||||
|
||||
var loader_source := _read_text(LOADER_PATH, failures)
|
||||
_expect_true(loader_source.contains("begin_frame(_render_operation_limits_for_frame())"), "loader begins one budget frame", failures)
|
||||
_expect_true(loader_source.contains("_render_budget_scheduler.cancel()"), "loader cancels scheduler on teardown", failures)
|
||||
_expect_false(loader_source.contains("var ops_left"), "loader has no local operation counters", failures)
|
||||
_expect_false(loader_source.contains("var finalize_budget"), "loader has no local finalize counter", failures)
|
||||
for exported_limit_name in [
|
||||
"tile_finalize_ops_per_tick",
|
||||
"terrain_upgrade_finalize_ops_per_tick",
|
||||
"terrain_control_splat_cache_finalize_ops_per_tick",
|
||||
"terrain_splat_cache_finalize_ops_per_tick",
|
||||
"terrain_splat_builds_per_tick",
|
||||
"water_finalize_ops_per_tick",
|
||||
"chunk_ops_per_tick",
|
||||
"tiles_per_tick",
|
||||
"tile_lod_ops_per_tick",
|
||||
"tile_lod_remove_ops_per_tick",
|
||||
"m2_animation_finalize_ops_per_tick",
|
||||
"m2_mesh_finalize_ops_per_tick",
|
||||
"m2_build_groups_per_tick",
|
||||
"wmo_build_instances_per_tick",
|
||||
"wmo_render_group_ops_per_tick",
|
||||
"detail_asset_ops_per_tick",
|
||||
]:
|
||||
_expect_true(
|
||||
loader_source.contains(": maxi(") and loader_source.contains(exported_limit_name),
|
||||
"frame snapshot includes %s" % exported_limit_name,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _measure_permit_checks(failures: Array[String]) -> float:
|
||||
var scheduler = SCHEDULER_SCRIPT.new()
|
||||
scheduler.begin_frame({SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC: PERFORMANCE_ITERATIONS})
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for _iteration in PERFORMANCE_ITERATIONS:
|
||||
scheduler.try_consume_permit(SCHEDULER_SCRIPT.DETAIL_ASSET_SYNC)
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_true(
|
||||
elapsed_milliseconds <= MAXIMUM_PERMIT_CHECK_MILLISECONDS,
|
||||
"permit checks stay within %.1f ms (actual %.3f)" % [
|
||||
MAXIMUM_PERMIT_CHECK_MILLISECONDS,
|
||||
elapsed_milliseconds,
|
||||
],
|
||||
failures
|
||||
)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _read_text(path: String, failures: Array[String]) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("cannot open %s" % path)
|
||||
return ""
|
||||
return file.get_as_text()
|
||||
|
||||
|
||||
func _expect_equal(actual_value: int, expected_value: int, label: String, failures: Array[String]) -> void:
|
||||
if actual_value != expected_value:
|
||||
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||
if not actual_value:
|
||||
failures.append("%s expected true" % label)
|
||||
|
||||
|
||||
func _expect_false(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||
if actual_value:
|
||||
failures.append("%s expected false" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b27nncxfog7cv
|
||||
@@ -0,0 +1 @@
|
||||
uid://cojh1u6igoocj
|
||||
@@ -0,0 +1 @@
|
||||
uid://cy5sbwxmjyue8
|
||||
@@ -0,0 +1,45 @@
|
||||
extends SceneTree
|
||||
|
||||
## Cold-start contract for the renderer-owned ground query result value.
|
||||
|
||||
const SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd")
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
var available_sample: RefCounted = SAMPLE_SCRIPT.available(42.25)
|
||||
_expect_true(bool(available_sample.get("is_available")), "available sample", failures)
|
||||
_expect_near(float(available_sample.get("height_units")), 42.25, "available height", failures)
|
||||
|
||||
var non_finite_sample: RefCounted = SAMPLE_SCRIPT.available(INF)
|
||||
_expect_true(not bool(non_finite_sample.get("is_available")), "non-finite unavailable", failures)
|
||||
_expect_true(
|
||||
StringName(non_finite_sample.get("failure_code")) == &"render_terrain_height_not_finite",
|
||||
"non-finite failure code",
|
||||
failures
|
||||
)
|
||||
|
||||
var unavailable_sample: RefCounted = SAMPLE_SCRIPT.unavailable(&"")
|
||||
_expect_true(
|
||||
StringName(unavailable_sample.get("failure_code")) == &"render_terrain_unavailable",
|
||||
"empty failure code normalization",
|
||||
failures
|
||||
)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("RENDERED_GROUND_SAMPLE: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print("RENDERED_GROUND_SAMPLE PASS contract=3")
|
||||
quit(0)
|
||||
|
||||
|
||||
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://b8juyikyvx1xw
|
||||
@@ -0,0 +1,170 @@
|
||||
extends SceneTree
|
||||
|
||||
## M03 repository gate preventing gameplay/EditorPlugin coupling to streamer internals.
|
||||
|
||||
const STREAMING_WORLD_LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const GAMEPLAY_ROOT := "res://src/gameplay"
|
||||
const SOURCE_ROOTS: Array[String] = ["res://src", "res://addons"]
|
||||
const RENDERER_DIAGNOSTIC_PATHS: Array[String] = [
|
||||
"res://src/tools/probe_render_terrain_height.gd",
|
||||
]
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
var loader_source := _read_text(STREAMING_WORLD_LOADER_PATH, failures)
|
||||
var private_renderer_symbols := _extract_private_renderer_symbols(loader_source, failures)
|
||||
_verify_synthetic_detection(private_renderer_symbols, failures)
|
||||
|
||||
var gameplay_paths := _collect_gdscript_paths(GAMEPLAY_ROOT)
|
||||
var editor_source_paths := _discover_editor_source_paths(failures)
|
||||
var consumer_paths_by_path: Dictionary = {}
|
||||
for gameplay_path in gameplay_paths:
|
||||
consumer_paths_by_path[gameplay_path] = true
|
||||
for editor_source_path in editor_source_paths:
|
||||
consumer_paths_by_path[editor_source_path] = true
|
||||
for renderer_diagnostic_path in RENDERER_DIAGNOSTIC_PATHS:
|
||||
consumer_paths_by_path[renderer_diagnostic_path] = true
|
||||
|
||||
for consumer_path_variant in consumer_paths_by_path:
|
||||
var consumer_path := String(consumer_path_variant)
|
||||
var consumer_source := _read_text(consumer_path, failures)
|
||||
for forbidden_symbol in _find_forbidden_symbols(consumer_source, private_renderer_symbols):
|
||||
failures.append("%s accesses private renderer symbol %s" % [consumer_path, forbidden_symbol])
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("RENDERER_INTERNAL_ACCESS: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("RENDERER_INTERNAL_ACCESS PASS private_symbols=%d gameplay=%d editor_sources=%d renderer_tools=%d" % [
|
||||
private_renderer_symbols.size(),
|
||||
gameplay_paths.size(),
|
||||
editor_source_paths.size(),
|
||||
RENDERER_DIAGNOSTIC_PATHS.size(),
|
||||
])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _extract_private_renderer_symbols(loader_source: String, failures: Array[String]) -> Array[String]:
|
||||
var declaration_pattern := RegEx.new()
|
||||
var compile_error := declaration_pattern.compile(
|
||||
"(?m)^var\\s+(_[A-Za-z0-9_]*(?:queue|tasks|cache|states)[A-Za-z0-9_]*)"
|
||||
)
|
||||
if compile_error != OK:
|
||||
failures.append("cannot compile private renderer symbol pattern")
|
||||
return []
|
||||
|
||||
var symbols_by_name: Dictionary = {}
|
||||
for regex_match in declaration_pattern.search_all(loader_source):
|
||||
symbols_by_name[regex_match.get_string(1)] = true
|
||||
var symbols: Array[String] = []
|
||||
for symbol_variant in symbols_by_name:
|
||||
symbols.append(String(symbol_variant))
|
||||
symbols.sort()
|
||||
if symbols.is_empty():
|
||||
failures.append("streamer private symbol inventory is empty")
|
||||
return symbols
|
||||
|
||||
|
||||
func _discover_editor_source_paths(failures: Array[String]) -> Array[String]:
|
||||
var all_source_paths: Array[String] = []
|
||||
for source_root in SOURCE_ROOTS:
|
||||
all_source_paths.append_array(_collect_gdscript_paths(source_root))
|
||||
|
||||
var editor_roots_by_path: Dictionary = {}
|
||||
var editor_source_paths_by_path: Dictionary = {}
|
||||
for source_path in all_source_paths:
|
||||
var source := _read_text(source_path, failures)
|
||||
if source_path.contains("/editor/"):
|
||||
editor_source_paths_by_path[source_path] = true
|
||||
if source.contains("extends " + "EditorPlugin") or source_path.ends_with("/plugin.gd"):
|
||||
editor_roots_by_path[source_path.get_base_dir()] = true
|
||||
|
||||
for source_path in all_source_paths:
|
||||
for editor_root_variant in editor_roots_by_path:
|
||||
var editor_root := String(editor_root_variant)
|
||||
if source_path.begins_with(editor_root + "/"):
|
||||
editor_source_paths_by_path[source_path] = true
|
||||
|
||||
var editor_source_paths: Array[String] = []
|
||||
for editor_source_path_variant in editor_source_paths_by_path:
|
||||
editor_source_paths.append(String(editor_source_path_variant))
|
||||
editor_source_paths.sort()
|
||||
return editor_source_paths
|
||||
|
||||
|
||||
func _collect_gdscript_paths(root_path: String) -> Array[String]:
|
||||
var paths: Array[String] = []
|
||||
var directory := DirAccess.open(root_path)
|
||||
if directory == null:
|
||||
return paths
|
||||
|
||||
directory.list_dir_begin()
|
||||
var entry_name := directory.get_next()
|
||||
while not entry_name.is_empty():
|
||||
if entry_name.begins_with(".") or entry_name == "reference":
|
||||
entry_name = directory.get_next()
|
||||
continue
|
||||
var entry_path := root_path.path_join(entry_name)
|
||||
if directory.current_is_dir():
|
||||
paths.append_array(_collect_gdscript_paths(entry_path))
|
||||
elif entry_name.ends_with(".gd"):
|
||||
paths.append(entry_path)
|
||||
entry_name = directory.get_next()
|
||||
directory.list_dir_end()
|
||||
paths.sort()
|
||||
return paths
|
||||
|
||||
|
||||
func _find_forbidden_symbols(source: String, private_renderer_symbols: Array[String]) -> Array[String]:
|
||||
var found_symbols: Array[String] = []
|
||||
for private_renderer_symbol in private_renderer_symbols:
|
||||
if _contains_external_symbol_access(source, private_renderer_symbol):
|
||||
found_symbols.append(private_renderer_symbol)
|
||||
return found_symbols
|
||||
|
||||
|
||||
func _contains_external_symbol_access(source: String, private_renderer_symbol: String) -> bool:
|
||||
for access_text in [
|
||||
"." + private_renderer_symbol,
|
||||
'.get("%s"' % private_renderer_symbol,
|
||||
".get('%s'" % private_renderer_symbol,
|
||||
'.set("%s"' % private_renderer_symbol,
|
||||
".set('%s'" % private_renderer_symbol,
|
||||
'.call("%s"' % private_renderer_symbol,
|
||||
".call('%s'" % private_renderer_symbol,
|
||||
'["%s"]' % private_renderer_symbol,
|
||||
"['%s']" % private_renderer_symbol,
|
||||
]:
|
||||
if source.contains(access_text):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _verify_synthetic_detection(private_renderer_symbols: Array[String], failures: Array[String]) -> void:
|
||||
if not private_renderer_symbols.has("_tile_states"):
|
||||
failures.append("streamer inventory does not contain _tile_states fixture")
|
||||
return
|
||||
var forbidden_fixture := 'var tile_states = world.get("_tile_states")'
|
||||
var detected_symbols := _find_forbidden_symbols(forbidden_fixture, private_renderer_symbols)
|
||||
if detected_symbols != ["_tile_states"]:
|
||||
failures.append("synthetic private access was not detected exactly")
|
||||
var direct_member_fixture := "var tile_states = world._tile_states"
|
||||
if _find_forbidden_symbols(direct_member_fixture, private_renderer_symbols) != ["_tile_states"]:
|
||||
failures.append("synthetic direct member access was not detected exactly")
|
||||
var unrelated_owner_fixture := "var _tile_states: Dictionary = {}"
|
||||
if not _find_forbidden_symbols(unrelated_owner_fixture, private_renderer_symbols).is_empty():
|
||||
failures.append("unrelated private declaration was rejected")
|
||||
var safe_fixture := "var metrics = render_facade.renderer_metrics_snapshot()"
|
||||
if not _find_forbidden_symbols(safe_fixture, private_renderer_symbols).is_empty():
|
||||
failures.append("facade metrics fixture was rejected")
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://y11r5k0ve0md
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free M03 regression for pure runtime/editor ADT target planning.
|
||||
|
||||
const PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd")
|
||||
const POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd")
|
||||
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
|
||||
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
|
||||
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
|
||||
|
||||
const PLANNER_PATH := "res://src/render/streaming/streaming_target_planner.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const PERFORMANCE_ITERATIONS := 250
|
||||
const MAXIMUM_AVERAGE_PLAN_MILLISECONDS := 4.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
var planner = PLANNER_SCRIPT.new()
|
||||
_verify_center_radii_and_isolation(planner, failures)
|
||||
_verify_corner_prefetch_and_catalog_filter(planner, failures)
|
||||
_verify_policy_clamps(planner, failures)
|
||||
_verify_editor_plan(planner, failures)
|
||||
_verify_scene_free_extraction(failures)
|
||||
var elapsed_milliseconds := _measure_planner_iterations(planner, failures)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("STREAMING_TARGET_PLANNER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("STREAMING_TARGET_PLANNER PASS cases=5 iterations=%d elapsed_ms=%.3f average_ms=%.3f" % [
|
||||
PERFORMANCE_ITERATIONS,
|
||||
elapsed_milliseconds,
|
||||
elapsed_milliseconds / float(PERFORMANCE_ITERATIONS),
|
||||
])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_center_radii_and_isolation(planner, failures: Array[String]) -> void:
|
||||
var available_tiles := _tile_catalog(28, 36, 44, 52)
|
||||
var focus_position = _tile_position(32, 48, 0.5, 0.5)
|
||||
var policy = POLICY_SCRIPT.new(0, 1, 0, 1, 1, 0.35)
|
||||
var plan = planner.plan_runtime(available_tiles, focus_position, policy)
|
||||
var wanted_tile_keys: Dictionary = plan.wanted_tile_keys()
|
||||
var retained_tile_keys: Dictionary = plan.retained_tile_keys()
|
||||
|
||||
_expect_equal(plan.focus_tile.tile_x, 32, "center focus tile X", failures)
|
||||
_expect_equal(plan.focus_tile.tile_y, 48, "center focus tile Y", failures)
|
||||
_expect_equal(wanted_tile_keys.size(), 25, "center wanted 5x5", failures)
|
||||
_expect_equal(retained_tile_keys.size(), 49, "center retained 7x7", failures)
|
||||
_expect_true(wanted_tile_keys.has("30_46"), "wanted includes north-west radius edge", failures)
|
||||
_expect_true(wanted_tile_keys.has("34_50"), "wanted includes south-east radius edge", failures)
|
||||
_expect_true(retained_tile_keys.has("29_45"), "retained includes hysteresis edge", failures)
|
||||
|
||||
_expect_true(wanted_tile_keys.is_read_only(), "wanted result is immutable", failures)
|
||||
_expect_true(retained_tile_keys.is_read_only(), "retained result is immutable", failures)
|
||||
_expect_equal(available_tiles.size(), 81, "available catalog remains unchanged", failures)
|
||||
|
||||
|
||||
func _verify_corner_prefetch_and_catalog_filter(planner, failures: Array[String]) -> void:
|
||||
var available_tiles := {
|
||||
"32_48": "tile-a",
|
||||
"33_48": "tile-b",
|
||||
"32_49": "tile-c",
|
||||
"33_49": "tile-d",
|
||||
"31_48": "outside-prefetch",
|
||||
}
|
||||
var focus_position = _tile_position(32, 48, 0.99, 0.99)
|
||||
var policy = POLICY_SCRIPT.new(0, 0, 0, 0, 0, 0.35)
|
||||
var plan = planner.plan_runtime(available_tiles, focus_position, policy)
|
||||
_expect_set_equal(
|
||||
plan.wanted_tile_keys(),
|
||||
["32_48", "33_48", "32_49", "33_49"],
|
||||
"corner prefetch cross product",
|
||||
failures
|
||||
)
|
||||
_expect_set_equal(
|
||||
plan.retained_tile_keys(),
|
||||
["32_48", "33_48", "32_49", "33_49"],
|
||||
"corner retained cross product",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_policy_clamps(planner, failures: Array[String]) -> void:
|
||||
var policy = POLICY_SCRIPT.new(16, 2, -5, -3, -4, 0.9)
|
||||
_expect_equal(policy.visible_tile_radius(), 2, "visible radius keeps tile minimum", failures)
|
||||
_expect_equal(policy.load_tile_radius(), 2, "negative prewarm clamps to zero", failures)
|
||||
_expect_equal(policy.retained_tile_radius(), 2, "negative retain clamps to zero", failures)
|
||||
_expect_near(policy.clamped_boundary_prefetch_threshold(), 0.49, "threshold upper clamp", failures)
|
||||
|
||||
var plan = planner.plan_runtime(
|
||||
_tile_catalog(30, 34, 46, 50),
|
||||
_tile_position(32, 48, 0.5, 0.5),
|
||||
policy
|
||||
)
|
||||
_expect_equal(plan.wanted_tile_keys().size(), 25, "clamped policy wanted radius", failures)
|
||||
_expect_equal(plan.retained_tile_keys().size(), 25, "clamped policy retained radius", failures)
|
||||
|
||||
|
||||
func _verify_editor_plan(planner, failures: Array[String]) -> void:
|
||||
var available_tiles := _tile_catalog(31, 33, 47, 49)
|
||||
var focus_position = _tile_position(32, 48, 0.5, 0.5)
|
||||
var negative_radius_plan = planner.plan_editor(available_tiles, focus_position, -4)
|
||||
_expect_set_equal(
|
||||
negative_radius_plan.wanted_tile_keys(),
|
||||
["32_48"],
|
||||
"editor negative radius clamps to focus",
|
||||
failures
|
||||
)
|
||||
_expect_set_equal(
|
||||
negative_radius_plan.retained_tile_keys(),
|
||||
["32_48"],
|
||||
"editor retained matches wanted",
|
||||
failures
|
||||
)
|
||||
|
||||
var radius_one_plan = planner.plan_editor(available_tiles, focus_position, 1)
|
||||
_expect_equal(radius_one_plan.wanted_tile_keys().size(), 9, "editor radius one 3x3", failures)
|
||||
_expect_set_equal(
|
||||
radius_one_plan.retained_tile_keys(),
|
||||
radius_one_plan.wanted_tile_keys().keys(),
|
||||
"editor radius retained matches wanted",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_scene_free_extraction(failures: Array[String]) -> void:
|
||||
var planner_source := _read_text(PLANNER_PATH, failures)
|
||||
_expect_true(planner_source.contains("extends RefCounted"), "planner is RefCounted", failures)
|
||||
_expect_true(not planner_source.contains("extends Node"), "planner does not inherit Node", failures)
|
||||
_expect_true(not planner_source.contains("Vector3"), "planner keeps typed position boundary", failures)
|
||||
for forbidden_text in ["WorkerThreadPool", "RenderingServer", "ResourceLoader", "_tile_load_queue"]:
|
||||
_expect_true(not planner_source.contains(forbidden_text), "planner omits %s" % forbidden_text, failures)
|
||||
|
||||
var loader_source := _read_text(LOADER_PATH, failures)
|
||||
_expect_true(loader_source.contains("_streaming_target_planner.plan_runtime"), "loader delegates runtime planning", failures)
|
||||
_expect_true(loader_source.contains("_streaming_target_planner.plan_editor"), "loader delegates editor planning", failures)
|
||||
_expect_true(not loader_source.contains("func _predictive_focus_tiles"), "loader no longer owns prefetch planner", failures)
|
||||
|
||||
|
||||
func _measure_planner_iterations(planner, failures: Array[String]) -> float:
|
||||
var available_tiles := _tile_catalog(24, 40, 40, 56)
|
||||
var focus_position = _tile_position(32, 48, 0.99, 0.99)
|
||||
var policy = POLICY_SCRIPT.new(40, 5, 1, 3, 4, 0.42)
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for _iteration in PERFORMANCE_ITERATIONS:
|
||||
planner.plan_runtime(available_tiles, focus_position, policy)
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
var average_plan_milliseconds := elapsed_milliseconds / float(PERFORMANCE_ITERATIONS)
|
||||
_expect_true(
|
||||
average_plan_milliseconds <= MAXIMUM_AVERAGE_PLAN_MILLISECONDS,
|
||||
"average plan stays within %.1f ms (actual %.3f)" % [
|
||||
MAXIMUM_AVERAGE_PLAN_MILLISECONDS,
|
||||
average_plan_milliseconds,
|
||||
],
|
||||
failures
|
||||
)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _tile_catalog(minimum_tile_x: int, maximum_tile_x: int, minimum_tile_y: int, maximum_tile_y: int) -> Dictionary:
|
||||
var available_tiles: Dictionary = {}
|
||||
for tile_y in range(minimum_tile_y, maximum_tile_y + 1):
|
||||
for tile_x in range(minimum_tile_x, maximum_tile_x + 1):
|
||||
available_tiles["%d_%d" % [tile_x, tile_y]] = "synthetic"
|
||||
return available_tiles
|
||||
|
||||
|
||||
func _tile_position(tile_x: int, tile_y: int, east_fraction: float, south_fraction: float) -> GodotWorldPosition:
|
||||
var tile_coordinate = ADT_TILE_COORDINATE_SCRIPT.new(tile_x, tile_y)
|
||||
var tile_local_position = ADT_TILE_LOCAL_POSITION_SCRIPT.new(
|
||||
east_fraction * COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS,
|
||||
0.0,
|
||||
south_fraction * COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
|
||||
)
|
||||
return COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(tile_coordinate, tile_local_position)
|
||||
|
||||
|
||||
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_set_equal(actual_set: Dictionary, expected_keys: Array, label: String, failures: Array[String]) -> void:
|
||||
var actual_keys := actual_set.keys()
|
||||
actual_keys.sort()
|
||||
var sorted_expected_keys := expected_keys.duplicate()
|
||||
sorted_expected_keys.sort()
|
||||
if actual_keys != sorted_expected_keys:
|
||||
failures.append("%s expected %s, got %s" % [label, sorted_expected_keys, actual_keys])
|
||||
|
||||
|
||||
func _expect_equal(actual_value: int, expected_value: int, label: String, failures: Array[String]) -> void:
|
||||
if actual_value != expected_value:
|
||||
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
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://b5unt23vi8krk
|
||||
@@ -0,0 +1,198 @@
|
||||
extends SceneTree
|
||||
|
||||
## Headless M02 contract, interpolation, cache and player-injection regression.
|
||||
|
||||
const TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/terrain_query.gd"
|
||||
const ADT_TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/adt_terrain_query.gd"
|
||||
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
|
||||
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
|
||||
|
||||
var _synthetic_adt_tile_data: Dictionary = {}
|
||||
var _synthetic_loader_call_count := 0
|
||||
|
||||
|
||||
class FlatTerrainQuery extends TerrainQuery:
|
||||
var _height_units: float
|
||||
|
||||
func _init(height_units: float) -> void:
|
||||
_height_units = height_units
|
||||
|
||||
func sample_ground_height(_godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
|
||||
return TerrainGroundSample.available(_height_units)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run_verification.call_deferred()
|
||||
|
||||
|
||||
func _run_verification() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_typed_result_contract(failures)
|
||||
_verify_synthetic_adt_interpolation_and_cache(failures)
|
||||
_verify_unavailable_tile(failures)
|
||||
_verify_player_injection(failures)
|
||||
_verify_source_boundaries(failures)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("TERRAIN_QUERY: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("TERRAIN_QUERY PASS contract=4 interpolation=1 cache=1 failures=2 player_injection=1")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_typed_result_contract(failures: Array[String]) -> void:
|
||||
var available_sample := TerrainGroundSample.available(123.5)
|
||||
_expect_true(available_sample.is_available, "available sample state", failures)
|
||||
_expect_near(available_sample.height_units, 123.5, "available sample height", failures)
|
||||
_expect_true(available_sample.failure_code.is_empty(), "available sample has no failure", failures)
|
||||
|
||||
var unavailable_sample := TerrainGroundSample.unavailable(&"synthetic_failure")
|
||||
_expect_true(not unavailable_sample.is_available, "unavailable sample state", failures)
|
||||
_expect_equal(unavailable_sample.failure_code, &"synthetic_failure", "unavailable failure code", failures)
|
||||
|
||||
var base_sample := TerrainQuery.new().sample_ground_height(GodotWorldPosition.new(0.0, 0.0, 0.0))
|
||||
_expect_equal(base_sample.failure_code, &"terrain_query_not_implemented", "base query failure", failures)
|
||||
|
||||
var non_finite_sample := TerrainGroundSample.available(NAN)
|
||||
_expect_true(not non_finite_sample.is_available, "non-finite sample unavailable", failures)
|
||||
_expect_equal(non_finite_sample.failure_code, &"terrain_height_not_finite", "non-finite failure code", failures)
|
||||
|
||||
|
||||
func _verify_synthetic_adt_interpolation_and_cache(failures: Array[String]) -> void:
|
||||
var query_position := GodotWorldPosition.new(17000.0, 999.0, 26525.0)
|
||||
_synthetic_adt_tile_data = _build_synthetic_adt_tile(query_position)
|
||||
_synthetic_loader_call_count = 0
|
||||
var terrain_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", _load_synthetic_adt_tile)
|
||||
|
||||
var first_sample := terrain_query.sample_ground_height(query_position)
|
||||
_expect_true(first_sample.is_available, "synthetic ADT sample available", failures)
|
||||
_expect_near_with_tolerance(first_sample.height_units, 137.0, 0.002, "synthetic bilinear height", failures)
|
||||
var second_sample := terrain_query.sample_ground_height(query_position)
|
||||
_expect_near_with_tolerance(second_sample.height_units, 137.0, 0.002, "cached synthetic height", failures)
|
||||
_expect_equal(_synthetic_loader_call_count, 1, "ADT tile loaded once", failures)
|
||||
|
||||
|
||||
func _verify_unavailable_tile(failures: Array[String]) -> void:
|
||||
var query_position := GodotWorldPosition.new(17000.0, 0.0, 26525.0)
|
||||
var empty_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", func(_path: String) -> Dictionary: return {})
|
||||
var empty_sample := empty_query.sample_ground_height(query_position)
|
||||
_expect_true(not empty_sample.is_available, "empty ADT unavailable", failures)
|
||||
_expect_equal(empty_sample.failure_code, &"adt_tile_unavailable", "empty ADT failure", failures)
|
||||
|
||||
var invalid_heights_data := _build_synthetic_adt_tile(query_position)
|
||||
var invalid_chunks: Array = invalid_heights_data.get("chunks", [])
|
||||
(invalid_chunks[invalid_chunks.size() - 1] as Dictionary)["heights"] = PackedFloat32Array()
|
||||
var invalid_query := AdtTerrainQuery.new(
|
||||
"res://synthetic",
|
||||
"Azeroth",
|
||||
func(_path: String) -> Dictionary: return invalid_heights_data
|
||||
)
|
||||
var invalid_sample := invalid_query.sample_ground_height(query_position)
|
||||
_expect_equal(invalid_sample.failure_code, &"adt_chunk_heights_invalid", "invalid heights failure", failures)
|
||||
|
||||
|
||||
func _verify_player_injection(failures: Array[String]) -> void:
|
||||
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
|
||||
_expect_true(packed_scene != null, "regression scene loads", failures)
|
||||
if packed_scene == null:
|
||||
return
|
||||
var player := packed_scene.instantiate() as CharacterBody3D
|
||||
player.set_terrain_query(FlatTerrainQuery.new(20.0))
|
||||
root.add_child(player)
|
||||
player.set_physics_process(false)
|
||||
_expect_near(player.position.y, 20.05, "injected spawn ground height", failures)
|
||||
player.position.y = 30.0
|
||||
player.call("_physics_process", 1.0)
|
||||
_expect_near(player.position.y, 20.05, "injected physics ground snap", failures)
|
||||
player.free()
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var terrain_contract_source := _read_text(TERRAIN_QUERY_PATH, failures)
|
||||
_expect_true(not terrain_contract_source.contains("extends Node"), "TerrainQuery omits Node", failures)
|
||||
_expect_true(not terrain_contract_source.contains("Vector3"), "TerrainQuery omits untyped Vector3 position", failures)
|
||||
|
||||
var adt_query_source := _read_text(ADT_TERRAIN_QUERY_PATH, failures)
|
||||
_expect_true(adt_query_source.contains("ADTLoader"), "ADT adapter owns native loader", failures)
|
||||
_expect_true(adt_query_source.contains("_adt_tile_cache"), "ADT adapter owns tile cache", failures)
|
||||
|
||||
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
|
||||
for forbidden_text in ["ADTLoader", "load_adt", "_adt_cache", "_find_chunk", "_outer_height_index"]:
|
||||
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
|
||||
_expect_true(player_source.contains("set_terrain_query"), "player exposes terrain query injection", failures)
|
||||
_expect_true(player_source.contains("_terrain_query.sample_ground_height"), "player delegates ground sampling", failures)
|
||||
|
||||
|
||||
func _build_synthetic_adt_tile(query_position: GodotWorldPosition) -> Dictionary:
|
||||
var tile_coordinate := CoordinateMapper.godot_to_adt_tile(query_position)
|
||||
var tile_local_position := CoordinateMapper.godot_to_adt_tile_local(query_position)
|
||||
var chunk_coordinate := CoordinateMapper.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
|
||||
var chunk_size := CoordinateMapper.ADT_CHUNK_SIZE_YARDS
|
||||
var outer_grid_unit_size := chunk_size / 8.0
|
||||
var target_chunk_origin := Vector3(
|
||||
query_position.x_units - outer_grid_unit_size * 4.5,
|
||||
100.0,
|
||||
query_position.z_units - outer_grid_unit_size * 3.25
|
||||
)
|
||||
var tile_origin := target_chunk_origin - Vector3(
|
||||
chunk_coordinate.chunk_x * chunk_size,
|
||||
0.0,
|
||||
chunk_coordinate.chunk_y * chunk_size
|
||||
)
|
||||
|
||||
var heights := PackedFloat32Array()
|
||||
heights.resize(145)
|
||||
for row in range(9):
|
||||
for column in range(9):
|
||||
heights[row * 17 + column] = float(row * 10 + column)
|
||||
var target_chunk := {"origin": target_chunk_origin, "heights": heights}
|
||||
if chunk_coordinate.chunk_x == 0 and chunk_coordinate.chunk_y == 0:
|
||||
return {"chunks": [target_chunk]}
|
||||
return {
|
||||
"chunks": [
|
||||
{"origin": tile_origin, "heights": PackedFloat32Array()},
|
||||
target_chunk,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
func _load_synthetic_adt_tile(_absolute_path: String) -> Dictionary:
|
||||
_synthetic_loader_call_count += 1
|
||||
return _synthetic_adt_tile_data
|
||||
|
||||
|
||||
func _read_text(path: String, failures: Array[String]) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("cannot open %s" % path)
|
||||
return ""
|
||||
return file.get_as_text()
|
||||
|
||||
|
||||
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
|
||||
if absf(actual_value - expected_value) > 0.000001:
|
||||
failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_near_with_tolerance(
|
||||
actual_value: float,
|
||||
expected_value: float,
|
||||
tolerance: float,
|
||||
label: String,
|
||||
failures: Array[String]
|
||||
) -> void:
|
||||
if absf(actual_value - expected_value) > tolerance:
|
||||
failures.append("%s expected %.6f ± %.6f, got %.6f" % [label, expected_value, tolerance, actual_value])
|
||||
|
||||
|
||||
func _expect_equal(actual_value: Variant, expected_value: Variant, label: String, failures: Array[String]) -> void:
|
||||
if actual_value != expected_value:
|
||||
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||
if not actual_value:
|
||||
failures.append("%s expected true" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://caberesg37dmj
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user