From 1807c3a363eb3f1420c03a4d88902f992061e46a Mon Sep 17 00:00:00 2001 From: sindoring Date: Thu, 16 Jul 2026 01:11:58 +0400 Subject: [PATCH 1/2] feat(M03): add entity presentation facade --- RENDER.md | 15 ++ docs/modules/README.md | 1 + docs/modules/world-entity-presentation.md | 254 ++++++++++++++++++ docs/modules/world-renderer.md | 46 +++- .../entities/entity_presentation_snapshot.gd | 104 +++++++ .../entity_presentation_snapshot.gd.uid | 1 + src/render/entities/world_entity_presenter.gd | 130 +++++++++ .../entities/world_entity_presenter.gd.uid | 1 + src/render/world_render_facade.gd | 64 +++++ .../streaming/eastern_kingdoms_streaming.tscn | 5 + src/scenes/streaming/kalimdor_streaming.tscn | 5 + ...rld_entity_visual_replacement_fixture.tscn | 10 + src/tools/verify_world_entity_presentation.gd | 157 +++++++++++ .../verify_world_entity_presentation.gd.uid | 1 + src/tools/verify_world_render_facade.gd | 42 ++- 15 files changed, 829 insertions(+), 7 deletions(-) create mode 100644 docs/modules/world-entity-presentation.md create mode 100644 src/render/entities/entity_presentation_snapshot.gd create mode 100644 src/render/entities/entity_presentation_snapshot.gd.uid create mode 100644 src/render/entities/world_entity_presenter.gd create mode 100644 src/render/entities/world_entity_presenter.gd.uid create mode 100644 src/tests/scenes/world_entity_visual_replacement_fixture.tscn create mode 100644 src/tools/verify_world_entity_presentation.gd create mode 100644 src/tools/verify_world_entity_presentation.gd.uid diff --git a/RENDER.md b/RENDER.md index 3e053c4..114b5ad 100644 --- a/RENDER.md +++ b/RENDER.md @@ -964,6 +964,21 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' capture path. Light/Area/Skybox DBC lookup, interpolation, fog, sun and shader behavior remain unchanged; weather and indoor environment state are follow-up work. +## 2026-07-16 World Entity Presentation Facade + +- `EntityPresentationSnapshot` contract version 1 combines session `EntityId`, + typed Godot world position, visual PackedScene path, finite yaw/scale and visibility. +- `WorldRenderFacade.present_entity()` and `remove_entity()` delegate to an explicit + scene-owned `WorldEntityPresenter`; both runtime scenes compose that service. +- The presenter owns only visual subtrees. Same-path snapshots update transform/ + visibility, changed paths replace after successful instantiation, and removal + detaches plus queues the owned root for deletion. +- The local player, authoritative state, GUID mapping and asset/display selection + remain outside this service. Runtime emits no entity commands yet, so baseline + output is unchanged. +- PackedScene loading is currently synchronous on create/replace and is a documented + prototype limitation, not an accepted network-scale frame-critical path. + ## 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. diff --git a/docs/modules/README.md b/docs/modules/README.md index 00727c3..ca7d78d 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -15,6 +15,7 @@ | 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) | +| World entity presentation | Implemented boundary / Prototype visuals | [`world-entity-presentation.md`](world-entity-presentation.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) | diff --git a/docs/modules/world-entity-presentation.md b/docs/modules/world-entity-presentation.md new file mode 100644 index 0000000..638d6fc --- /dev/null +++ b/docs/modules/world-entity-presentation.md @@ -0,0 +1,254 @@ +# World Entity Presentation + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented boundary / Prototype visuals | +| Target/work package | M03 / `M03-RND-FACADE-ENTITY-001` | +| Owners | Renderer entity-presentation boundary and visual subtree lifecycle | +| Last verified | Worktree `work/sindo-main-codex/m03-facade-entity`, 2026-07-16 | +| Profiles/capabilities | Contract version 1; asset-free scene presentation only | + +## Purpose + +Accept immutable session-local entity visual snapshots through +`WorldRenderFacade`, create or update renderer-owned visual subtrees idempotently, +and remove them explicitly without making the renderer authoritative for entity, +gameplay, network or content state. + +## Non-goals + +- Allocate `EntityId` or map it to `WowGuid`, display ID or server entry. +- Present the separately composed local player through this service. +- Define build-12340 model, equipment, animation, effects or visibility rules. +- Own gameplay/world state, packet decoding, terrain or streaming decisions. +- Provide asynchronous loading, pooling, batching, persistence or cache formats. + +## Context and boundaries + +```mermaid +flowchart LR + WorldState[Future authoritative WorldState] --> Adapter[Entity presentation adapter] + Adapter --> Snapshot[EntityPresentationSnapshot v1] + Snapshot --> Facade[WorldRenderFacade] + Facade --> Presenter[WorldEntityPresenter] + Asset[PackedScene visual path] --> Presenter + Presenter --> Visual[Owned Node3D visual subtree] + LocalPlayer[Local player composition] -. separate boundary .-> Viewport[Rendered world] + Visual --> Viewport +``` + +Allowed dependencies: + +- immutable M01 `EntityId` and `GodotWorldPosition` values; +- renderer-facing Godot transform and scene APIs; +- an explicit repository/resource path selected outside the renderer; +- `WorldRenderFacade` and main-thread runtime scene composition. + +Forbidden dependencies: + +- gameplay reducers, intents, combat/movement authority or entity allocation; +- packet codecs, GUID interpretation, server DB access or Content Project writes; +- streamer queues/tasks/caches, terrain query or camera/input state; +- hidden autoload, SceneTree group service lookup or persisted session IDs. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `EntityPresentationSnapshot.CONTRACT_VERSION` | Contract constant | Identifies additive snapshot contract version `1` | Compile/runtime constant | Incompatible changes require migration/ADR | +| `EntityPresentationSnapshot.create(...)` | Immutable value factory | Captures identity, typed position, visual path, yaw, scale and visibility | Any thread to construct; caller-owned | Invalid value carries stable `entity_presentation_*` failure code | +| `WorldRenderFacade.present_entity(snapshot)` | Command | Idempotently creates/updates/replaces one world visual | Main thread; world session | Returns `false` for invalid snapshot, missing service or failed scene creation | +| `WorldRenderFacade.remove_entity(entity_id)` | Command | Removes one known session visual | Main thread; world session | Returns `false` for invalid or unknown identity | +| `WorldRenderFacade.entity_presentation_snapshot()` | Diagnostic query | Returns detached scalar entity inventory | Main thread; caller-owned copy | Missing/invalid service returns empty snapshot with diagnostic | +| `WorldEntityPresenter.present_entity(snapshot)` | Internal service command | Owns visual load, attach, transform update and replacement | Main thread; service lifetime | Failed replacement retains prior valid subtree | +| `WorldEntityPresenter.remove_entity(entity_id)` | Internal service command | Detaches and schedules owned subtree deletion | Main thread | Unknown identity is unchanged/false | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Valid session-local `EntityId` | Future entity registry/adapter | Snapshot/presenter index | Immutable caller reference | One runtime session | +| Input | `GodotWorldPosition` | World-state presentation adapter | Snapshot/presenter transform | Immutable caller reference | One snapshot | +| Input | PackedScene resource path | Asset-selection adapter | Presenter | Copied string; resource remains Godot-owned | Until visual replacement | +| Input | Finite yaw radians, positive scale, visibility | Presentation adapter | Presenter | Scalar copies | One snapshot | +| Output | Owned Node3D subtree | Presenter | SceneTree/viewport | Presenter child ownership | Until replacement/removal/world teardown | +| Output | Detached inventory dictionary | Presenter/facade | Diagnostics/tests | Caller-owned deep copy | One query | + +Side effects: + +- synchronously loads a PackedScene only for first presentation or visual-path change; +- instantiates, attaches, transforms, detaches and queues visual Node3D subtrees; +- does not mutate authoritative state, streamer queues, caches, files, DB or network; +- does not retain caller snapshots after an entity entry is replaced or removed. + +## Data flow + +```mermaid +flowchart TD + ReadModel[Entity visual read model] --> Validate[Validate typed immutable snapshot] + Validate -->|invalid| Reject[Return false; no scene mutation] + Validate -->|valid| Lookup[Lookup by EntityId debug key] + Lookup -->|same visual path| Transform[Update world transform and visibility] + Lookup -->|new or changed path| Instantiate[Load and instantiate Node3D] + Instantiate -->|failure| Retain[Retain previous valid visual] + Instantiate -->|success| Replace[Detach old, attach replacement] + Replace --> Transform + Transform --> Inventory[Detached diagnostics] + Remove[remove_entity] --> Detach[Detach and queue_free owned root] +``` + +## Lifecycle/state + +```mermaid +stateDiagram-v2 + [*] --> Absent + Absent --> Presented: valid present + successful instantiate + Absent --> Absent: invalid/missing visual + Presented --> Presented: same-path snapshot update + Presented --> Replacing: changed visual path + Replacing --> Presented: replacement succeeds + Replacing --> Presented: replacement fails; retain old + Presented --> Absent: remove_entity + Presented --> [*]: world/service teardown + Absent --> [*]: world/service teardown +``` + +## Main sequence + +```mermaid +sequenceDiagram + participant Adapter as World-state adapter + participant Facade as WorldRenderFacade + participant Presenter as WorldEntityPresenter + participant Loader as Godot ResourceLoader + participant Tree as SceneTree + Adapter->>Facade: present_entity(immutable snapshot) + Facade->>Presenter: present_entity(snapshot) + alt same identity and visual path + Presenter->>Tree: update transform/visibility + else new or changed visual + Presenter->>Loader: load PackedScene + Loader-->>Presenter: scene or failure + alt scene is valid Node3D + Presenter->>Tree: detach old, attach/configure new + else load/shape failure + Presenter-->>Facade: false; old visual retained + end + end + Adapter->>Facade: remove_entity(EntityId) + Facade->>Presenter: remove_entity(EntityId) + Presenter->>Tree: detach + queue_free owned root +``` + +## Ownership, threading and resources + +- `EntityPresentationSnapshot` owns copied scalars/strings and immutable references + to caller-owned identity/position values; it owns no Node, Resource or RID. +- `WorldEntityPresenter` owns its dictionary and every visual root attached below it. +- A dictionary entry retains the latest snapshot only while the entity is presented; + diagnostics expose no Node, Resource or snapshot reference. +- Scene load, instantiation, transform mutation, replacement and removal are main-thread only. +- Successful replacement instantiates first, then detaches the previous root, so an + invalid/missing new asset cannot erase the last valid presentation. +- Parent SceneTree teardown owns attached children; `_exit_tree` drops the index so + no stale lookup survives world unload. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Invalid identity/type | Snapshot/service validation | Reject without mutation | `entity_presentation_invalid_entity_id` | Supply valid session `EntityId` | +| Non-finite position/yaw | Snapshot validation | Reject without mutation | Stable position/yaw failure code | Repair adapter read model | +| Empty path or invalid scale | Snapshot validation | Reject without mutation | Stable path/scale failure code | Supply valid visual descriptor | +| Missing/non-PackedScene asset | Resource existence/load check | New visual absent; old replacement retained | Named presenter warning | Restore asset mapping and resend snapshot | +| Non-Node3D scene root | Instance type check | Free invalid instance; retain old | Named presenter warning | Fix visual scene root | +| Unknown remove | Dictionary lookup | No mutation; return `false` | Caller command result | Reconcile registry/presentation lifecycle | +| World teardown | Scene lifecycle | SceneTree frees children; index clears | Shutdown regression | Recreate service with next world | + +There is no background job to cancel. Synchronous asset load is an explicit +prototype limitation and must be replaced/budgeted before network-scale use. + +## Configuration and capabilities + +| Setting/capability | Default | Profile | Runtime mutable | Effect | +|---|---|---|---|---| +| Contract version | `1` | All | No | Identity/path/position/yaw/scale/visibility full snapshot | +| Present semantics | Idempotent by `EntityId` | Runtime/tools | No | Duplicate snapshots do not duplicate roots | +| Resource loading | Synchronous on create/replace | Prototype only | No | Not accepted as frame-critical production path | +| Local player | Separate composition | Current sandbox | No | Prevents facade service from owning gameplay player root | + +## Persistence, cache and migration + +- No snapshot, entity key or resource instance is persisted. +- `EntityId` remains session-local and must never enter content/cache schemas. +- Resource paths are current renderer adapter inputs, not stable content identity. +- Contract version 1 is additive. Changing identity lifetime, coordinate basis or + persisted representation requires a migration and ADR. + +## Diagnostics and observability + +- `entity_presentation_snapshot()` reports contract version, entity count and a + sorted list of `EntityId.to_debug_key`, visual path and visibility. +- The snapshot is deep-detached by the facade and contains no Node/Resource/RID. +- Load/type failures include the visual path but never proprietary bytes. +- Future performance telemetry should measure create/replace duration and active + entity/material/RID counts before introducing pooling or batching. + +## Verification + +- `src/tools/verify_world_entity_presentation.gd` covers eight value-contract and + fourteen lifecycle/ownership cases with repository-owned asset-free scenes. +- `src/tools/verify_world_render_facade.gd` covers command delegation, detached + diagnostics and both runtime scene service paths. +- Identity, coordinate, renderer-internal-access, shutdown, manifest, planner, + scheduler and documentation gates remain required. +- Fidelity boundary: runtime emits no entity commands yet, so existing seven + checkpoint scenes remain unchanged. No build-12340 display/animation parity is claimed. + +## Extension points + +- AssetRepository/display-ID adapter that produces validated visual descriptors. +- Async/budgeted load and main-thread finalize behind the same facade commands. +- Animation/equipment/effect snapshots after build-12340 fixtures define behavior. +- Spatial batching/MultiMesh only after profiling proves per-Node overhead. +- Entity registry adapter that maps `WowGuid` to session `EntityId` outside render. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Typed entity visual input | Implemented contract | Versioned immutable validation fixtures | Asset/display mapping not implemented | +| Present/update/remove lifecycle | Implemented boundary | Asset-free create/update/replace/remove tests | Network-scale async/budget path missing | +| Deterministic ownership | Implemented | One presenter-owned root per EntityId; teardown checks | RID/material budgets remain future work | +| WoW entity visual fidelity | Planned | No claim | Requires build-12340 display/equipment/animation fixtures | + +## Known gaps and risks + +- Synchronous PackedScene load may hitch and is not a production hot-path design. +- Resource path is renderer adapter data, not stable authored content identity. +- Full snapshots currently cover transform/visibility only, not animation/equipment/effects. +- One Node3D per entity is unprofiled at world scale; batching is deliberately deferred. +- The local player remains separately composed until gameplay/network architecture is ready. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/entities/entity_presentation_snapshot.gd` | Immutable versioned entity visual input and validation | +| `src/render/entities/world_entity_presenter.gd` | Main-thread entity visual ownership and lifecycle | +| `src/render/world_render_facade.gd` | Stable public present/remove/diagnostic commands | +| `src/tools/verify_world_entity_presentation.gd` | Snapshot and scene lifecycle regression | +| `src/tests/scenes/character_presentation_model_fixture.tscn` | Primary asset-free visual fixture | +| `src/tests/scenes/world_entity_visual_replacement_fixture.tscn` | Replacement asset-free visual fixture | +| `src/scenes/streaming/*_streaming.tscn` | Runtime facade/service composition | + +## Related decisions and references + +- [`world-renderer.md`](world-renderer.md) +- [`domain-identities.md`](domain-identities.md) +- [`coordinate-mapping.md`](coordinate-mapping.md) +- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md) +- [`../ARCHITECTURE.md`](../ARCHITECTURE.md) +- [`../GODOT_BEST_PRACTICES.md`](../GODOT_BEST_PRACTICES.md) diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 3247be0..6ecdf45 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -5,9 +5,9 @@ | Field | Value | |---|---| | Status | Partial | -| 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`; `M03-RND-FACADE-ENVIRONMENT-001` | +| 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`; `M03-RND-FACADE-ENVIRONMENT-001`; `M03-RND-FACADE-ENTITY-001` | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-facade-environment`, 2026-07-16 | +| Last verified | Worktree `work/sindo-main-codex/m03-facade-entity`, 2026-07-16 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -49,6 +49,10 @@ flowchart LR Facade --> SkyController[WowSkyController] Sky[DBC sky/light data] --> SkyController SkyController --> Scene + EntityState[Entity visual read model] --> EntitySnapshot[EntityPresentationSnapshot] + EntitySnapshot --> Facade + Facade --> EntityPresenter[WorldEntityPresenter] + EntityPresenter --> Scene ``` Allowed dependencies: @@ -69,9 +73,9 @@ Forbidden dependencies: ## Public API Runtime, capture and probe callers use `WorldRenderFacade` for focus, immutable -environment input, metrics and rendered-ground diagnostics. `StreamingWorldLoader` -and `WowSkyController` remain internal scene implementations while a later M03 -package adds entity-visual commands. +environment/entity presentation input, metrics and rendered-ground diagnostics. +`StreamingWorldLoader`, `WowSkyController` and `WorldEntityPresenter` remain +internal scene implementations. Gameplay modules and Godot `EditorPlugin` package sources are repository-gated from externally reading/writing loader-private queue, task, cache and tile-state fields. @@ -90,6 +94,11 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `WorldRenderFacade.apply_environment_snapshot` | Public command | Delegates exact world time to the scene-owned DBC sky controller | Main thread; snapshot remains caller-owned | Returns `false` for invalid input or missing/incompatible controller | | `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 | | `WorldRenderFacade.world_environment_controller_path` | Internal adapter property | Resolves the existing `WowSkyController` without exposing it to consumers | Main thread, scene lifetime | Missing method/path produces one diagnostic until recovery | +| `EntityPresentationSnapshot.create` | Immutable value factory | Validates contract-v1 identity, typed position, visual path, yaw, scale and visibility | Any thread; caller-owned | Stable invalid code; never mutates scene | +| `WorldRenderFacade.present_entity` | Public command | Idempotently creates/updates/replaces one session entity visual | Main thread/session | Returns false for invalid input, missing service or visual load/type failure | +| `WorldRenderFacade.remove_entity` | Public command | Removes one known session entity visual | Main thread/session | Invalid/unknown identity returns false | +| `WorldRenderFacade.entity_presentation_snapshot` | Public diagnostic query | Returns detached scalar entity inventory | Main thread/caller-owned copy | Missing/invalid service returns empty dictionary with diagnostic | +| `WorldRenderFacade.world_entity_presenter_path` | Internal adapter property | Resolves renderer-owned entity visual service | Main thread/scene lifetime | Missing methods/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 | @@ -107,6 +116,8 @@ loader configuration remains transitional composition data, not a caller API. | 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 | `WorldEnvironmentSnapshot` | World-state/capture adapter | `WorldRenderFacade` / `WowSkyController` | Immutable caller-held value; not retained | One main-thread update | +| Input | `EntityPresentationSnapshot` | Future world-state/preview adapter | `WorldRenderFacade` / entity presenter | Immutable caller-held full visual snapshot | One main-thread present/update | +| Input | `EntityId` removal command | Future entity registry/adapter | `WorldRenderFacade` / entity presenter | Immutable session-local value | One main-thread removal | | 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 | @@ -118,6 +129,8 @@ loader configuration remains transitional composition data, not a caller API. | 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 | Entity visual Node3D subtree | `WorldEntityPresenter` | SceneTree/viewport | Presenter-owned child | Until replace/remove/teardown | +| Output | Detached entity inventory | `WorldRenderFacade` | Diagnostics/tests | 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 | @@ -160,6 +173,11 @@ flowchart TD RF --> SkyController[WowSkyController] DBC[Light/Area/Skybox DBC] --> SkyController SkyController --> World + EntityReadModel[Entity visual read model] --> EntitySnapshot[EntityPresentationSnapshot] + EntitySnapshot --> RF + RF --> EntityPresenter[WorldEntityPresenter] + EntityAsset[PackedScene path] --> EntityPresenter + EntityPresenter --> World Terrain --> World[Godot world] M2 --> World WMO --> World @@ -218,6 +236,11 @@ sequenceDiagram Source->>Facade: apply_environment_snapshot(exact time) Facade->>Render: delegate to WowSkyController Render->>Render: freeze local clock; retain DBC sampling/apply rules + Source->>Facade: present_entity(full visual snapshot) + Facade->>Render: delegate to WorldEntityPresenter + Render->>Render: create/update/replace owned entity root + Source->>Facade: remove_entity(EntityId) + Facade->>Render: detach and queue_free owned root Stream->>Budget: shutdown: cancel permit issuance Stream->>Worker: shutdown: wait for WorkerThreadPool tasks Stream->>Stream: shutdown: finish registered ResourceLoader requests @@ -236,6 +259,10 @@ sequenceDiagram into its existing fixed-clock fields on the main thread. - `WowSkyController` continues to own DBC sampling state and mutations of its duplicated Godot `Environment`, sun, skybox node and world shader globals. +- `WorldEntityPresenter` owns one optional visual Node3D subtree per presented + `EntityId`; it owns no authoritative entity state and excludes the local player. +- Entity presentation snapshots remain caller-owned inputs. Detached diagnostics + expose only identity debug keys, paths and visibility, never Node/Resource/RID. - 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 @@ -264,6 +291,9 @@ sequenceDiagram | Invalid metrics result | Facade return-type validation | Return empty caller snapshot | Facade error | Restore `Dictionary` implementation contract | | Invalid environment time | Snapshot finite-value validation | Reject update; retain prior sky clock | `environment_time_not_finite` on invalid value | Supply a finite time snapshot | | Missing environment controller | Facade path/method validation | Reject environment update; other facade APIs remain available | One facade diagnostic until recovery | Fix scene composition path/implementation | +| Invalid entity visual snapshot | Contract validation | Reject without scene mutation | Stable `entity_presentation_*` failure code | Repair adapter read model and resend | +| Missing/invalid entity visual asset | Resource existence/load/root check | New entity absent or prior replacement retained | Named presenter warning | Restore asset mapping and resend snapshot | +| Missing entity presenter | Facade path/method validation | Reject entity commands; other facade APIs remain available | One facade diagnostic until recovery | Fix runtime scene composition | | 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 | @@ -344,8 +374,9 @@ 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 | Partial | Facade contracts cover focus, environment time, metrics and loaded-mesh ground query | Entity visuals remain | +| Stable renderer facade | Implemented boundary / Partial fidelity | Facade contracts cover focus, environment, entity visuals, metrics and loaded-mesh ground query | Production consumers and fidelity remain | | Environment snapshot input | Implemented boundary / Partial fidelity | Immutable time contract, facade/controller delegation and checkpoint migration | Weather/indoor state and paired zone-transition fidelity remain | +| Entity presentation commands | Implemented boundary / Prototype visuals | Versioned typed snapshot plus present/remove lifecycle and runtime service composition | Display/equipment/animation/effects and async scale path 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 | @@ -380,6 +411,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m |---|---| | `src/render/world_render_facade.gd` | Stable caller boundary for typed focus/environment, detached metrics and rendered-ground queries | | `src/render/environment/world_environment_snapshot.gd` | Immutable normalized world time input with explicit invalid state | +| `src/render/entities/entity_presentation_snapshot.gd` | Immutable contract-v1 entity visual input and validation | +| `src/render/entities/world_entity_presenter.gd` | Main-thread entity visual subtree ownership and lifecycle | | `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 | @@ -390,6 +423,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/verify_streaming_focus.gd` | Headless contract, dependency and runtime/tool wiring regression | | `src/tools/verify_world_render_facade.gd` | Focus/environment/metrics/ground delegation, snapshot isolation and consumer wiring regression | | `src/tools/verify_world_environment_snapshot.gd` | Cold-start environment value normalization and invalid-input regression | +| `src/tools/verify_world_entity_presentation.gd` | Entity snapshot, create/update/replace/remove and ownership 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 | diff --git a/src/render/entities/entity_presentation_snapshot.gd b/src/render/entities/entity_presentation_snapshot.gd new file mode 100644 index 0000000..e56ec58 --- /dev/null +++ b/src/render/entities/entity_presentation_snapshot.gd @@ -0,0 +1,104 @@ +class_name EntityPresentationSnapshot +extends RefCounted + +## Immutable renderer input for one session-local world entity visual. +## Gameplay authority, identity allocation and asset selection remain external. + +const CONTRACT_VERSION := 1 +const SCRIPT_PATH := "res://src/render/entities/entity_presentation_snapshot.gd" +const ENTITY_ID_SCRIPT := preload("res://src/domain/identity/entity_id.gd") +const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") + +var is_valid: bool: + get: + return _failure_code.is_empty() + +var failure_code: StringName: + get: + return _failure_code + +var entity_id: RefCounted: + get: + return _entity_id + +var world_position: RefCounted: + get: + return _world_position + +var visual_scene_path: String: + get: + return _visual_scene_path + +var yaw_radians: float: + get: + return _yaw_radians + +var uniform_scale: float: + get: + return _uniform_scale + +var is_visible: bool: + get: + return _is_visible + +var _failure_code: StringName +var _entity_id: RefCounted +var _world_position: RefCounted +var _visual_scene_path: String +var _yaw_radians: float +var _uniform_scale: float +var _is_visible: bool + + +## Creates a version-1 full visual snapshot. Invalid values remain inspectable and +## are rejected without scene mutation by the facade/presenter. +static func create( + entity_id_value: RefCounted, + world_position_value: RefCounted, + visual_scene_path_value: String, + yaw_radians_value: float = 0.0, + uniform_scale_value: float = 1.0, + is_visible_value: bool = true +) -> RefCounted: + return load(SCRIPT_PATH).new( + entity_id_value, + world_position_value, + visual_scene_path_value, + yaw_radians_value, + uniform_scale_value, + is_visible_value + ) + + +func _init( + entity_id_value: RefCounted, + world_position_value: RefCounted, + visual_scene_path_value: String, + yaw_radians_value: float, + uniform_scale_value: float, + is_visible_value: bool +) -> void: + _entity_id = entity_id_value + _world_position = world_position_value + _visual_scene_path = visual_scene_path_value.strip_edges() + _yaw_radians = yaw_radians_value + _uniform_scale = uniform_scale_value + _is_visible = is_visible_value + _failure_code = _validate() + + +func _validate() -> StringName: + if _entity_id == null or _entity_id.get_script() != ENTITY_ID_SCRIPT or not bool(_entity_id.call("is_valid")): + return &"entity_presentation_invalid_entity_id" + if _world_position == null or _world_position.get_script() != GODOT_WORLD_POSITION_SCRIPT: + return &"entity_presentation_invalid_world_position" + for coordinate_name in [&"x_units", &"y_units", &"z_units"]: + if not is_finite(float(_world_position.get(coordinate_name))): + return &"entity_presentation_position_not_finite" + if _visual_scene_path.is_empty(): + return &"entity_presentation_visual_path_empty" + if not is_finite(_yaw_radians): + return &"entity_presentation_yaw_not_finite" + if not is_finite(_uniform_scale) or _uniform_scale <= 0.0: + return &"entity_presentation_scale_invalid" + return &"" diff --git a/src/render/entities/entity_presentation_snapshot.gd.uid b/src/render/entities/entity_presentation_snapshot.gd.uid new file mode 100644 index 0000000..609e133 --- /dev/null +++ b/src/render/entities/entity_presentation_snapshot.gd.uid @@ -0,0 +1 @@ +uid://djvnorls0yf06 diff --git a/src/render/entities/world_entity_presenter.gd b/src/render/entities/world_entity_presenter.gd new file mode 100644 index 0000000..f4df353 --- /dev/null +++ b/src/render/entities/world_entity_presenter.gd @@ -0,0 +1,130 @@ +@tool +class_name WorldEntityPresenter +extends Node3D + +## Main-thread owner of visual subtrees for session-local world entities. +## The full immutable snapshot is reapplied idempotently by entity identity. + +const ENTITY_PRESENTATION_SNAPSHOT_SCRIPT := preload("res://src/render/entities/entity_presentation_snapshot.gd") +const ENTITY_ID_SCRIPT := preload("res://src/domain/identity/entity_id.gd") + +var _presented_entities: Dictionary = {} + + +## Creates or updates one entity visual. A changed visual path is instantiated +## successfully before the previous subtree is detached, so failed replacement +## retains the last valid presentation. +func present_entity(entity_presentation_snapshot: RefCounted) -> bool: + if not _is_valid_snapshot(entity_presentation_snapshot): + return false + var entity_id: RefCounted = entity_presentation_snapshot.get("entity_id") + var entity_key := _entity_key(entity_id) + var visual_scene_path := String(entity_presentation_snapshot.get("visual_scene_path")) + var current_entry: Dictionary = _presented_entities.get(entity_key, {}) + var visual_root := current_entry.get("visual_root") as Node3D + if visual_root == null or not is_instance_valid(visual_root) or String(current_entry.get("visual_scene_path", "")) != visual_scene_path: + var replacement_root := _instantiate_visual_root(entity_id, visual_scene_path) + if replacement_root == null: + return false + if visual_root != null and is_instance_valid(visual_root): + remove_child(visual_root) + add_child(replacement_root) + _apply_snapshot_transform(replacement_root, entity_presentation_snapshot) + if visual_root != null and is_instance_valid(visual_root): + visual_root.queue_free() + visual_root = replacement_root + else: + _apply_snapshot_transform(visual_root, entity_presentation_snapshot) + _presented_entities[entity_key] = { + "visual_root": visual_root, + "visual_scene_path": visual_scene_path, + "snapshot": entity_presentation_snapshot, + } + return true + + +## Removes one owned entity visual. Returns false for an invalid/unknown identity. +func remove_entity(entity_id: RefCounted) -> bool: + if entity_id == null or entity_id.get_script() != ENTITY_ID_SCRIPT or not bool(entity_id.call("is_valid")): + return false + var entity_key := _entity_key(entity_id) + if not _presented_entities.has(entity_key): + return false + var entry: Dictionary = _presented_entities[entity_key] + var visual_root := entry.get("visual_root") as Node3D + _presented_entities.erase(entity_key) + if visual_root != null and is_instance_valid(visual_root): + remove_child(visual_root) + visual_root.queue_free() + return true + + +## Returns detached scalar diagnostics without exposing owned Nodes or snapshots. +func entity_presentation_snapshot() -> Dictionary: + var entities: Array[Dictionary] = [] + var sorted_entity_keys: Array = _presented_entities.keys() + sorted_entity_keys.sort() + for entity_key_variant in sorted_entity_keys: + var entity_key := String(entity_key_variant) + var entry: Dictionary = _presented_entities[entity_key] + var snapshot: RefCounted = entry.get("snapshot") + entities.append({ + "entity_key": entity_key, + "visual_scene_path": String(entry.get("visual_scene_path", "")), + "visible": bool(snapshot.get("is_visible")), + }) + return { + "contract_version": ENTITY_PRESENTATION_SNAPSHOT_SCRIPT.CONTRACT_VERSION, + "entity_count": entities.size(), + "entities": entities, + } + + +func _exit_tree() -> void: + _presented_entities.clear() + + +func _is_valid_snapshot(entity_presentation_snapshot: RefCounted) -> bool: + return ( + entity_presentation_snapshot != null + and entity_presentation_snapshot.get_script() == ENTITY_PRESENTATION_SNAPSHOT_SCRIPT + and bool(entity_presentation_snapshot.get("is_valid")) + ) + + +func _entity_key(entity_id: RefCounted) -> String: + return String(entity_id.call("to_debug_key")) + + +func _instantiate_visual_root(entity_id: RefCounted, visual_scene_path: String) -> Node3D: + if not ResourceLoader.exists(visual_scene_path, "PackedScene"): + push_warning("WorldEntityPresenter cannot find visual scene: %s" % visual_scene_path) + return null + var visual_scene := load(visual_scene_path) as PackedScene + if visual_scene == null: + push_warning("WorldEntityPresenter cannot load visual scene: %s" % visual_scene_path) + return null + var visual_instance := visual_scene.instantiate() + if not (visual_instance is Node3D): + visual_instance.free() + push_warning("WorldEntityPresenter visual scene root is not Node3D: %s" % visual_scene_path) + return null + var visual_root := visual_instance as Node3D + visual_root.name = "EntityVisual_%020d" % int(entity_id.get("session_sequence")) + return visual_root + + +func _apply_snapshot_transform(visual_root: Node3D, entity_presentation_snapshot: RefCounted) -> void: + var world_position: RefCounted = entity_presentation_snapshot.get("world_position") + var position_units := Vector3( + float(world_position.get("x_units")), + float(world_position.get("y_units")), + float(world_position.get("z_units")) + ) + var uniform_scale := float(entity_presentation_snapshot.get("uniform_scale")) + var yaw_radians := float(entity_presentation_snapshot.get("yaw_radians")) + visual_root.global_transform = Transform3D( + Basis(Vector3.UP, yaw_radians).scaled(Vector3.ONE * uniform_scale), + position_units + ) + visual_root.visible = bool(entity_presentation_snapshot.get("is_visible")) diff --git a/src/render/entities/world_entity_presenter.gd.uid b/src/render/entities/world_entity_presenter.gd.uid new file mode 100644 index 0000000..fad668d --- /dev/null +++ b/src/render/entities/world_entity_presenter.gd.uid @@ -0,0 +1 @@ +uid://b0keiyi1wuu5i diff --git a/src/render/world_render_facade.gd b/src/render/world_render_facade.gd index 70c4f09..54e5295 100644 --- a/src/render/world_render_facade.gd +++ b/src/render/world_render_facade.gd @@ -9,6 +9,8 @@ const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot 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") const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd") +const ENTITY_PRESENTATION_SNAPSHOT_SCRIPT := preload("res://src/render/entities/entity_presentation_snapshot.gd") +const ENTITY_ID_SCRIPT := preload("res://src/domain/identity/entity_id.gd") ## Internal renderer implementation resolved relative to this node. ## The referenced node remains owned by its scene parent. @@ -18,14 +20,19 @@ const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment ## The facade delegates snapshots but owns neither the controller nor Environment. @export var world_environment_controller_path: NodePath = NodePath("../WowSkyController") +## Scene-owned service that creates and owns session-local world entity visuals. +@export var world_entity_presenter_path: NodePath = NodePath("../WorldEntityPresenter") + ## 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 _world_environment_controller: Node +var _world_entity_presenter: Node var _missing_loader_reported := false var _missing_environment_controller_reported := false +var _missing_entity_presenter_reported := false var _missing_focus_source_reported := false @@ -116,6 +123,43 @@ func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool: return bool(controller.call("apply_environment_snapshot", world_environment_snapshot)) +## Idempotently creates or updates one world entity visual through the renderer- +## owned presenter. The immutable full snapshot remains caller-owned. +func present_entity(entity_presentation_snapshot: RefCounted) -> bool: + if ( + entity_presentation_snapshot == null + or entity_presentation_snapshot.get_script() != ENTITY_PRESENTATION_SNAPSHOT_SCRIPT + or not bool(entity_presentation_snapshot.get("is_valid")) + ): + return false + var presenter := _resolve_world_entity_presenter() + if presenter == null: + return false + return bool(presenter.call("present_entity", entity_presentation_snapshot)) + + +## Removes one session-local entity visual without changing authoritative state. +func remove_entity(entity_id: RefCounted) -> bool: + if entity_id == null or entity_id.get_script() != ENTITY_ID_SCRIPT or not bool(entity_id.call("is_valid")): + return false + var presenter := _resolve_world_entity_presenter() + if presenter == null: + return false + return bool(presenter.call("remove_entity", entity_id)) + + +## Returns detached entity-presentation diagnostics with no Node references. +func entity_presentation_snapshot() -> Dictionary: + var presenter := _resolve_world_entity_presenter() + if presenter == null: + return {} + var snapshot_variant = presenter.call("entity_presentation_snapshot") + if not (snapshot_variant is Dictionary): + push_error("WorldRenderFacade received a non-Dictionary entity presentation snapshot.") + return {} + return (snapshot_variant as Dictionary).duplicate(true) + + func _capture_streaming_focus_from_source() -> void: if streaming_focus_source_path == NodePath(): return @@ -177,3 +221,23 @@ func _resolve_world_environment_controller() -> Node: _world_environment_controller = candidate _missing_environment_controller_reported = false return _world_environment_controller + + +func _resolve_world_entity_presenter() -> Node: + if is_instance_valid(_world_entity_presenter): + return _world_entity_presenter + var candidate := get_node_or_null(world_entity_presenter_path) + if candidate == null: + if not _missing_entity_presenter_reported: + push_error("WorldRenderFacade cannot resolve entity presenter at %s" % world_entity_presenter_path) + _missing_entity_presenter_reported = true + return null + for required_method in [&"present_entity", &"remove_entity", &"entity_presentation_snapshot"]: + if not candidate.has_method(required_method): + if not _missing_entity_presenter_reported: + push_error("WorldRenderFacade entity presenter lacks %s" % required_method) + _missing_entity_presenter_reported = true + return null + _world_entity_presenter = candidate + _missing_entity_presenter_reported = false + return _world_entity_presenter diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index 452ba52..44bda0d 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -7,6 +7,7 @@ [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"] +[ext_resource type="Script" path="res://src/render/entities/world_entity_presenter.gd" id="8_entity_presenter"] [sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] radius = 0.45 @@ -99,6 +100,10 @@ script = ExtResource("7_render_facade") streaming_world_loader_path = NodePath("..") streaming_focus_source_path = NodePath("../ThirdPersonPlayer") world_environment_controller_path = NodePath("../WowSkyController") +world_entity_presenter_path = NodePath("../WorldEntityPresenter") + +[node name="WorldEntityPresenter" type="Node3D" parent="."] +script = ExtResource("8_entity_presenter") [node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400) diff --git a/src/scenes/streaming/kalimdor_streaming.tscn b/src/scenes/streaming/kalimdor_streaming.tscn index eca6801..f02df94 100644 --- a/src/scenes/streaming/kalimdor_streaming.tscn +++ b/src/scenes/streaming/kalimdor_streaming.tscn @@ -7,6 +7,7 @@ [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"] +[ext_resource type="Script" path="res://src/render/entities/world_entity_presenter.gd" id="8_entity_presenter"] [sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] radius = 0.45 @@ -100,6 +101,10 @@ script = ExtResource("7_render_facade") streaming_world_loader_path = NodePath("..") streaming_focus_source_path = NodePath("../ThirdPersonPlayer") world_environment_controller_path = NodePath("../WowSkyController") +world_entity_presenter_path = NodePath("../WorldEntityPresenter") + +[node name="WorldEntityPresenter" type="Node3D" parent="."] +script = ExtResource("8_entity_presenter") [node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400) diff --git a/src/tests/scenes/world_entity_visual_replacement_fixture.tscn b/src/tests/scenes/world_entity_visual_replacement_fixture.tscn new file mode 100644 index 0000000..4f4a073 --- /dev/null +++ b/src/tests/scenes/world_entity_visual_replacement_fixture.tscn @@ -0,0 +1,10 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="SphereMesh" id="SphereMesh_replacement"] +radius = 0.5 +height = 1.0 + +[node name="ReplacementVisual" type="Node3D"] + +[node name="Marker" type="MeshInstance3D" parent="."] +mesh = SubResource("SphereMesh_replacement") diff --git a/src/tools/verify_world_entity_presentation.gd b/src/tools/verify_world_entity_presentation.gd new file mode 100644 index 0000000..cd3e29e --- /dev/null +++ b/src/tools/verify_world_entity_presentation.gd @@ -0,0 +1,157 @@ +extends SceneTree + +## Asset-free contract, lifecycle and ownership regression for world entity visuals. + +const SNAPSHOT_SCRIPT := preload("res://src/render/entities/entity_presentation_snapshot.gd") +const PRESENTER_SCRIPT := preload("res://src/render/entities/world_entity_presenter.gd") +const ENTITY_ID_SCRIPT := preload("res://src/domain/identity/entity_id.gd") +const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") + +const PRIMARY_VISUAL_PATH := "res://src/tests/scenes/character_presentation_model_fixture.tscn" +const REPLACEMENT_VISUAL_PATH := "res://src/tests/scenes/world_entity_visual_replacement_fixture.tscn" +const SNAPSHOT_SOURCE_PATH := "res://src/render/entities/entity_presentation_snapshot.gd" +const PRESENTER_SOURCE_PATH := "res://src/render/entities/world_entity_presenter.gd" +const FACADE_SOURCE_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", +] + + +func _initialize() -> void: + _run_verification.call_deferred() + + +func _run_verification() -> void: + var failures: Array[String] = [] + _verify_snapshot_contract(failures) + await _verify_presenter_lifecycle(failures) + _verify_source_boundaries(failures) + + if not failures.is_empty(): + for failure in failures: + push_error("WORLD_ENTITY_PRESENTATION: %s" % failure) + quit(1) + return + print("WORLD_ENTITY_PRESENTATION PASS snapshot_cases=8 lifecycle_cases=14 runtime_scenes=2") + quit(0) + + +func _verify_snapshot_contract(failures: Array[String]) -> void: + var entity_id: RefCounted = ENTITY_ID_SCRIPT.new(7) + var position: RefCounted = GODOT_WORLD_POSITION_SCRIPT.new(10.0, 20.0, 30.0) + var valid_snapshot := SNAPSHOT_SCRIPT.create(entity_id, position, PRIMARY_VISUAL_PATH, 0.5, 2.0, false) + _expect_true(bool(valid_snapshot.get("is_valid")), "valid snapshot", failures) + _expect_true(int(SNAPSHOT_SCRIPT.CONTRACT_VERSION) == 1, "contract version", failures) + _expect_failure(SNAPSHOT_SCRIPT.create(ENTITY_ID_SCRIPT.new(0), position, PRIMARY_VISUAL_PATH), &"entity_presentation_invalid_entity_id", "invalid entity", failures) + _expect_failure(SNAPSHOT_SCRIPT.create(entity_id, RefCounted.new(), PRIMARY_VISUAL_PATH), &"entity_presentation_invalid_world_position", "invalid position type", failures) + _expect_failure(SNAPSHOT_SCRIPT.create(entity_id, GODOT_WORLD_POSITION_SCRIPT.new(INF, 0.0, 0.0), PRIMARY_VISUAL_PATH), &"entity_presentation_position_not_finite", "non-finite position", failures) + _expect_failure(SNAPSHOT_SCRIPT.create(entity_id, position, ""), &"entity_presentation_visual_path_empty", "empty visual path", failures) + _expect_failure(SNAPSHOT_SCRIPT.create(entity_id, position, PRIMARY_VISUAL_PATH, NAN), &"entity_presentation_yaw_not_finite", "non-finite yaw", failures) + _expect_failure(SNAPSHOT_SCRIPT.create(entity_id, position, PRIMARY_VISUAL_PATH, 0.0, 0.0), &"entity_presentation_scale_invalid", "zero scale", failures) + + +func _verify_presenter_lifecycle(failures: Array[String]) -> void: + var presenter: Node3D = PRESENTER_SCRIPT.new() + presenter.name = "WorldEntityPresenter" + root.add_child(presenter) + var entity_id: RefCounted = ENTITY_ID_SCRIPT.new(7) + var initial_snapshot := SNAPSHOT_SCRIPT.create( + entity_id, + GODOT_WORLD_POSITION_SCRIPT.new(10.0, 20.0, 30.0), + PRIMARY_VISUAL_PATH, + 0.5, + 2.0, + false + ) + _expect_true(bool(presenter.call("present_entity", initial_snapshot)), "initial present", failures) + var visual_root := presenter.get_node_or_null("EntityVisual_00000000000000000007") as Node3D + _expect_true(visual_root != null, "owned visual attached", failures) + if visual_root != null: + _expect_vector_near(visual_root.global_position, Vector3(10.0, 20.0, 30.0), "initial position", failures) + _expect_near(visual_root.scale.x, 2.0, "initial scale", failures) + _expect_true(not visual_root.visible, "initial visibility", failures) + var original_instance_id := visual_root.get_instance_id() if visual_root != null else 0 + + var updated_snapshot := SNAPSHOT_SCRIPT.create( + entity_id, + GODOT_WORLD_POSITION_SCRIPT.new(40.0, 50.0, 60.0), + PRIMARY_VISUAL_PATH, + 1.0, + 1.5, + true + ) + _expect_true(bool(presenter.call("present_entity", updated_snapshot)), "idempotent update", failures) + visual_root = presenter.get_node_or_null("EntityVisual_00000000000000000007") as Node3D + _expect_true(visual_root != null and visual_root.get_instance_id() == original_instance_id, "same visual root retained", failures) + if visual_root != null: + _expect_vector_near(visual_root.global_position, Vector3(40.0, 50.0, 60.0), "updated position", failures) + _expect_true(visual_root.visible, "updated visibility", failures) + + var failed_replacement := SNAPSHOT_SCRIPT.create(entity_id, GODOT_WORLD_POSITION_SCRIPT.new(1.0, 2.0, 3.0), "res://missing/entity_visual.tscn") + _expect_true(not bool(presenter.call("present_entity", failed_replacement)), "missing replacement rejected", failures) + visual_root = presenter.get_node_or_null("EntityVisual_00000000000000000007") as Node3D + _expect_true(visual_root != null and visual_root.get_instance_id() == original_instance_id, "failed replacement retains visual", failures) + + var replacement_snapshot := SNAPSHOT_SCRIPT.create(entity_id, GODOT_WORLD_POSITION_SCRIPT.new(70.0, 80.0, 90.0), REPLACEMENT_VISUAL_PATH) + _expect_true(bool(presenter.call("present_entity", replacement_snapshot)), "valid replacement", failures) + visual_root = presenter.get_node_or_null("EntityVisual_00000000000000000007") as Node3D + _expect_true(visual_root != null and visual_root.get_instance_id() != original_instance_id, "replacement swaps root", failures) + + var diagnostic_snapshot: Dictionary = presenter.call("entity_presentation_snapshot") + _expect_true(int(diagnostic_snapshot.get("contract_version", 0)) == 1, "diagnostic version", failures) + _expect_true(int(diagnostic_snapshot.get("entity_count", 0)) == 1, "diagnostic entity count", failures) + (diagnostic_snapshot.get("entities") as Array).clear() + _expect_true(int((presenter.call("entity_presentation_snapshot") as Dictionary).get("entity_count", 0)) == 1, "diagnostic detached", failures) + + _expect_true(bool(presenter.call("remove_entity", entity_id)), "known entity removed", failures) + _expect_true(not bool(presenter.call("remove_entity", entity_id)), "duplicate removal rejected", failures) + _expect_true(presenter.get_child_count() == 0, "owned visual detached", failures) + presenter.free() + await process_frame + + +func _verify_source_boundaries(failures: Array[String]) -> void: + var snapshot_source := _read_text(SNAPSHOT_SOURCE_PATH, failures) + for forbidden_snapshot_text in ["extends Node", "Node3D", "PackedScene", "Dictionary", "Vector3"]: + _expect_true(not snapshot_source.contains(forbidden_snapshot_text), "snapshot omits %s" % forbidden_snapshot_text, failures) + var presenter_source := _read_text(PRESENTER_SOURCE_PATH, failures) + for forbidden_presenter_text in ["MoveIntent", "TerrainQuery", "WowGuid", "network", "server_data"]: + _expect_true(not presenter_source.contains(forbidden_presenter_text), "presenter omits %s" % forbidden_presenter_text, failures) + for required_presenter_text in ["ResourceLoader.exists", "remove_child(visual_root)", "visual_root.queue_free()"]: + _expect_true(presenter_source.contains(required_presenter_text), "presenter contains %s" % required_presenter_text, failures) + var facade_source := _read_text(FACADE_SOURCE_PATH, failures) + for required_facade_method in ["func present_entity(", "func remove_entity(", "func entity_presentation_snapshot()"]: + _expect_true(facade_source.contains(required_facade_method), "facade contains %s" % required_facade_method, failures) + for scene_path in RUNTIME_SCENE_PATHS: + var scene_source := _read_text(scene_path, failures) + _expect_true(scene_source.contains("world_entity_presenter.gd"), "%s loads presenter" % scene_path, failures) + _expect_true(scene_source.contains('world_entity_presenter_path = NodePath("../WorldEntityPresenter")'), "%s facade path" % 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_failure(snapshot: RefCounted, expected_code: StringName, label: String, failures: Array[String]) -> void: + _expect_true(not bool(snapshot.get("is_valid")), "%s invalid" % label, failures) + _expect_true(StringName(snapshot.get("failure_code")) == expected_code, "%s failure code" % label, failures) + + +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_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) diff --git a/src/tools/verify_world_entity_presentation.gd.uid b/src/tools/verify_world_entity_presentation.gd.uid new file mode 100644 index 0000000..44c9f69 --- /dev/null +++ b/src/tools/verify_world_entity_presentation.gd.uid @@ -0,0 +1 @@ +uid://dkkjbc8pcglcp diff --git a/src/tools/verify_world_render_facade.gd b/src/tools/verify_world_render_facade.gd index b9754ef..68a7fec 100644 --- a/src/tools/verify_world_render_facade.gd +++ b/src/tools/verify_world_render_facade.gd @@ -8,6 +8,8 @@ const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_fo const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd") const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd") const WOW_SKY_CONTROLLER_SCRIPT := preload("res://src/scenes/sky/wow_sky_controller.gd") +const ENTITY_PRESENTATION_SNAPSHOT_SCRIPT := preload("res://src/render/entities/entity_presentation_snapshot.gd") +const ENTITY_ID_SCRIPT := preload("res://src/domain/identity/entity_id.gd") const RUNTIME_SCENE_PATHS: Array[String] = [ "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn", @@ -57,6 +59,23 @@ class WorldEnvironmentControllerDouble extends Node: return true +class WorldEntityPresenterDouble extends Node: + var presented_snapshot: RefCounted + var removed_entity_id: RefCounted + var diagnostics := {"entity_count": 1, "entities": [{"entity_key": "entity:7"}]} + + func present_entity(entity_presentation_snapshot: RefCounted) -> bool: + presented_snapshot = entity_presentation_snapshot + return true + + func remove_entity(entity_id: RefCounted) -> bool: + removed_entity_id = entity_id + return true + + func entity_presentation_snapshot() -> Dictionary: + return diagnostics + + func _initialize() -> void: var failures: Array[String] = [] _verify_rendered_ground_sample_contract(failures) @@ -70,7 +89,7 @@ func _initialize() -> void: quit(1) return - print("WORLD_RENDER_FACADE PASS delegation=6 ground_contract=2 environment_contract=7 runtime_scenes=2 tools=3") + print("WORLD_RENDER_FACADE PASS delegation=9 ground_contract=2 environment_contract=7 entity_contract=6 runtime_scenes=2 tools=3") quit(0) @@ -116,14 +135,18 @@ func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void: source.position = Vector3(10.0, 20.0, 30.0) var environment_controller := WorldEnvironmentControllerDouble.new() environment_controller.name = "WorldEnvironmentControllerDouble" + var entity_presenter := WorldEntityPresenterDouble.new() + entity_presenter.name = "WorldEntityPresenterDouble" var facade = WORLD_RENDER_FACADE_SCRIPT.new() facade.name = "WorldRenderFacade" facade.streaming_world_loader_path = NodePath("../StreamingWorldLoaderDouble") facade.streaming_focus_source_path = NodePath("../FocusSource") facade.world_environment_controller_path = NodePath("../WorldEnvironmentControllerDouble") + facade.world_entity_presenter_path = NodePath("../WorldEntityPresenterDouble") test_root.add_child(loader) test_root.add_child(source) test_root.add_child(environment_controller) + test_root.add_child(entity_presenter) test_root.add_child(facade) get_root().add_child(test_root) await process_frame @@ -181,6 +204,21 @@ func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void: "invalid environment snapshot rejected", failures ) + + var entity_id: RefCounted = ENTITY_ID_SCRIPT.new(7) + var entity_snapshot: RefCounted = ENTITY_PRESENTATION_SNAPSHOT_SCRIPT.create( + entity_id, + GODOT_WORLD_POSITION_SCRIPT.new(1.0, 2.0, 3.0), + "res://src/tests/scenes/character_presentation_model_fixture.tscn" + ) + _expect_true(facade.present_entity(entity_snapshot), "entity presentation delegates", failures) + _expect_true(entity_presenter.presented_snapshot == entity_snapshot, "entity snapshot reference delegates", failures) + _expect_true(facade.remove_entity(entity_id), "entity removal delegates", failures) + _expect_true(entity_presenter.removed_entity_id == entity_id, "entity identity reference delegates", failures) + var entity_diagnostics: Dictionary = facade.entity_presentation_snapshot() + (entity_diagnostics.get("entities") as Array).clear() + _expect_true(entity_presenter.diagnostics.entities.size() == 1, "entity diagnostics detached", failures) + _expect_true(not facade.present_entity(ENTITY_PRESENTATION_SNAPSHOT_SCRIPT.create(ENTITY_ID_SCRIPT.new(0), GODOT_WORLD_POSITION_SCRIPT.new(0.0, 0.0, 0.0), "res://fixture.tscn")), "invalid entity snapshot rejected", failures) test_root.queue_free() @@ -191,6 +229,8 @@ func _verify_repository_wiring(failures: Array[String]) -> void: _expect_true(scene_source.contains('[node name="WorldRenderFacade" type="Node" parent="."]'), "%s owns facade node" % scene_path, failures) _expect_true(scene_source.contains('streaming_focus_source_path = NodePath("../ThirdPersonPlayer")'), "%s facade uses player focus" % scene_path, failures) _expect_true(scene_source.contains('world_environment_controller_path = NodePath("../WowSkyController")'), "%s facade uses sky controller" % scene_path, failures) + _expect_true(scene_source.contains('world_entity_presenter_path = NodePath("../WorldEntityPresenter")'), "%s facade uses entity presenter" % scene_path, failures) + _expect_true(scene_source.contains('[node name="WorldEntityPresenter" type="Node3D" parent="."]'), "%s owns entity presenter service" % scene_path, failures) for tool_path in TOOL_PATHS: var tool_source := _read_text(tool_path, failures) From b73b2e74a2fb1d358573d891078ee35075fffea1 Mon Sep 17 00:00:00 2001 From: sindoring Date: Thu, 16 Jul 2026 01:12:17 +0400 Subject: [PATCH 2/2] coord(M03): hand off entity presentation facade --- .../claims/M03-RND-FACADE-ENTITY-001.md | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/coordination/claims/M03-RND-FACADE-ENTITY-001.md b/coordination/claims/M03-RND-FACADE-ENTITY-001.md index fafc230..9edfec8 100644 --- a/coordination/claims/M03-RND-FACADE-ENTITY-001.md +++ b/coordination/claims/M03-RND-FACADE-ENTITY-001.md @@ -92,14 +92,36 @@ scene service with explicit entity subtree ownership. ## Status -- State: claimed -- Done: contract, ownership and non-goals published -- Next: implement snapshot, presenter, facade wiring, regressions and docs +- State: ready-for-review +- Done: immutable contract-v1 snapshot, presenter ownership lifecycle, facade + commands, runtime composition, asset-free regressions and module documentation +- Next: M03 integrator reviews and merges `1807c3a` - Blocked by: + + ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `1807c3a` +- Results: facade now exposes idempotent `present_entity`, explicit + `remove_entity` and detached diagnostics. Both runtime scenes compose a + `WorldEntityPresenter` that owns one replaceable visual subtree per valid + session `EntityId`; local player and authoritative state remain separate. +- Verification: entity `snapshot_cases=8 lifecycle_cases=14 runtime_scenes=2`; + facade `delegation=9 entity_contract=6`; identities `5/5/6/5`; character + presentation `9/10/3/1`; coordinate boundary `files=119`; internal access + `43/7/9/1`; environment/ground/terrain, manifest `7/7`, shutdown, scheduler + (`20000` iterations), planner (`250` iterations), focus, editor parse, dry-run, + coordination, documentation and diff gates passed. +- Fidelity: runtime produces no world-entity commands yet, so all seven baseline + plans and existing player/world output remain unchanged. No build-12340 + display, equipment, animation, effects or visibility parity claim is added. +- External/local inputs: ignored native DLL and generated ADT scripts supported + editor/dry-run only. Missing proprietary ADT/DBC/character corpus produced the + expected degraded diagnostics. +- Remaining risks: PackedScene create/replace loads synchronously and is not an + accepted network-scale hot path; resource paths are adapter inputs rather than + stable content IDs; animation/equipment/effects and scale profiling remain open. +- Documentation updated: new world-entity-presentation API/input-output/data-flow/ + lifecycle/sequence/ownership/failure/configuration/migration/diagnostic/source-map + spec; world renderer, module registry and `RENDER.md` updated.