Skip to content

vmap: dynamic GameObject collision (doors) — cmangos parity, spec first #103

Closed

Description

@Mathih13

Part of #39 (design: the vmap decision record §10, private working origin).

Dynamic GameObject collision, cmangos parity (mangos-classic ships GameObjectModel + DynamicTree wired into Map LoS — verified against their tree 2026-08-10): server-spawned GOs with model collision contribute to rays while spawned/closed and stop when opened/despawned. Doors first (Deadmines).

Design needed before implementation — that's why this is not agent-safe yet:

  • Resolving GameObjectDisplayInfo → model file → collision triangles at import.
  • A dynamic triangle source in the module keyed by GO state — the static per-cell store won't fit; likely a small per-GO-instance table consulted by the same ray walkers.
  • Which GO types get collision (doors/gates first; NOT chests/props).

Done when: a spec comment lands here and is split into implementable sub-issues — or, implemented directly: a closed Deadmines door blocks the LoS ray and the open door doesn't (headless probe).


Migrated 2026-08-11 from the private tracker (spacetime-core#528).

Activity

  1. Mathih13 commented on Aug 11, 2026

    @Mathih13
    ContributorAuthor

    Spec — dynamic GameObject collision (doors), cmangos parity

    Grounded against the shipped vmap slices. What exists today:

    • Static store: game_vmap_chunk (per-terrain-cell packed triangle shards, module/src/vmap.rs) + the two ray wrappers los_ray/collision_ray over lyracore_shared::vmap::cast_ray (DDA cell walk, global-min t). LoS = WMO-class only, collision = WMO+M2 (RayFlavor, crates/lyracore-shared/src/vmap.rs).
    • GO state: game_gameobject.state — 0 = ready/closed, 1 = open (DOOR_OPEN_STATE, module/src/encounter.rs:196); DOOR/BUTTON toggle via apply_use_gameobject, choreography via encounter::open_door(entry, instance_id) (instance 0 targets the static rows). Despawn = row delete. Dungeon doors are per-instance copies (instance.rs create_instance, GO_COPY_BAND guids, same x/y/z/rotation as the instance-0 source; reap deletes them). Spawn quaternion is on the row (rotation_0..3, feat(importer): extract Deadmines geometry from ADT tiles #515; all-zero ⇒ derive yaw-only from orientation — the gateway's build_gameobject_rotation_values rule).
    • Importer: game_gameobject_template.display_id is imported but never resolved to a model — no GameObjectDisplayInfo read anywhere in importer/src. The pieces all exist though: wow_dbc 0.3 ships vanilla_tables::game_object_display_info (display_id → model_name string), collision::open_geometry_chain opens the model MPQs, and nav::m2_tris already loads an M2's bounding (collision) mesh in model-local coords. The dump's gameobject_template.size (scale) is parsed past today, not stored.

    1. Import side: --go-models (display → model → local triangles)

    New importer mode, used together with --dump (it needs both inputs): parse gameobject_template, keep DOOR/BUTTON rows (classify_go_type types 0/1), resolve display_id via GameObjectDisplayInfo.dbc (--go-models <client Data/ dir> opens the DBC + geometry chain), swap .mdx/.mdl → .m2 (the collision.rs::read_m2_bounding_tris rule), and extract the M2 bounding mesh in model-local space via nav::m2_tris. Pre-apply the MDDF axis shuffle ([-v[2], -v[0], v[1]] — what apply_full does for placed doodads) at extract, so the module-side transform is pure quaternion+scale+translate; this mirrors cmangos, whose vmap extractor writes already-converted vertices that GameObjectModel then transforms by the DB quaternion.

    Local space, not world space, because the world transform is per spawn row and per instance copy — position/quaternion/scale live on data the module already holds, and baking world triangles per spawn would break for instance copies (no source-guid backref on GO_COPY_BAND rows) and for debug_spawn_gameobject.

    Output: one row per door template entry into a new module-private table, batched via new reducers (import_go_models / _append, the import_vmap_chunks hex-blob convention):

    game_go_model  (PK entry: u32)
      scale: f32          -- dump gameobject_template.size (avoids a GameObjectTemplate column + gateway binding hand-sync)
      radius: f32         -- local bounding-sphere radius of the shuffled mesh
      blob: Vec<u8>       -- lyracore_shared::vmap codec, local-space tris (class tag irrelevant here — see §2)
    

    Door models are tiny (tens of bounding tris); one row per entry stays far under MAX_ROW_TRI_BYTES — error out if one doesn't, no sharding. Dry-run report mirrors --vmap: per-template model name + tri count, warnings for WMO-model displays (skip, see open questions) and parse failures. Same licensing firewall: in-memory only.

    2. Module side: per-GO collider table + state-gated ray merge

    The static per-cell store can't carry state-keyed triangles (re-binning per toggle). Instead, the DynamicTree analogue the issue sketches — a small registry consulted by the same ray entry points:

    game_go_collider  (PK go_guid: u64 -> game_gameobject.guid)
      map_id: u32, instance_id: u64      -- btree index (map_id, instance_id)
      entry: u32                          -- -> game_go_model
      x, y, z: f32                        -- world position (GOs are static; copied at registration)
      radius_world: f32                   -- model radius × scale, the cheap segment reject
    

    Registration (derived data, idempotent clear+rebuild like arm_pool):

    • rebuild_go_colliders (operator reducer): scan game_gameobject × template type DOOR/BUTTON × game_go_model row exists → insert registry rows. The import pipeline calls it after import_gameobjects + import_go_models (slots next to arm_all_pools; Run a fresh-provision parity check for the Rust lyracore import subcommands #104's import vmaps verb grows this step).
    • create_instance's GO copy loop registers each door copy (clone the source's registry row, new guid + instance_id); the instance reap deletes them (it already iterates the copies at instance.rs:994).
    • Liveness is checked at ray time anyway (below), so a stale registry row is a harmless orphan — the GameObjectUnlocked posture.

    Consult path — inside vmap::los_ray / vmap::collision_ray (so every existing caller — has_los, nav_step's collision gate, Blink's clamp — gets doors with zero call-site changes), behind the same vmap_enabled gate:

    1. Static cast_ray as today → candidate hit t_static.
    2. Dynamic pass: btree-probe game_go_collider by (map_id, instance_id); per row: segment-vs-sphere reject (pure fn) → game_gameobject.guid().find() — skip if missing (despawned) or state != 0 (open) → decode game_go_model.blob → inverse-transform the segment into model space (quaternion from the GO row's rotation_0..3, all-zero ⇒ yaw-only from orientation, the feat(importer): extract Deadmines geometry from ADT tiles #515 rule ported to a shared pure fn; then ÷scale, −position) → Möller–Trumbore (reuse segment_tri_hit) → t_go.
    3. Return the global min of t_static and every t_go. The codec's class tags never gate the dynamic pass — participation is decided per GO row, so no TriClass addition, no VMAP_VERSION bump.

    Both ray flavors. cmangos wires GameObjectModel/DynamicTree into Map LoS (verified against their tree 2026-08-10, per this issue) and into the movement/hit-pos queries; #39's WMO-only LoS rule exists to keep static M2 doodads (forests) transparent — a door is server-state geometry, not vegetation; and this issue's done-when explicitly requires the closed door to block the LoS ray. So: closed door blocks LoS (aggro/casts/swing) and collision (Blink, nav_step gate). area_info/cast_ray_area stays static-only (doors carry no WMO group/indoor semantics — matches cmangos getAreaInfo). floor_z inherits the dynamic pass via collision_ray; accepted (vertical doors are near-invisible to a down-probe, and cmangos' dyn tree feeds height queries too).

    State transitions, exhaustively: spawned+closed (state == 0) → contributes; opened (state == 1, toggle or open_door) → stops at the next ray, no table churn; re-closed → contributes again; despawned (row deleted — instance reap, pool reroll can't hit doors but the liveness check covers it) → stops.

    Instance correctness: a caller inside instance I probes (map, I) only — its own door copies, never the unreachable instance-0 sources; open-world callers probe (map, 0). This needs instance_id threaded into los_ray/collision_ray and their nav::has_los/nav_step wrappers (~20 mechanical call sites; every caller already holds the mover's entity). Deferred to the last sub-issue — the headless Deadmines probe works against the instance-0 static rows first (open_door(entry, 0) flips them).

    Perf: the dynamic pass adds one btree probe + N sphere rejects per ray (N = registered doors on the map — tens on the canonical box), guid-find + decode only past the reject. nav_step's gate fires per movement tick, so re-run debug_bench_collision_gate (and debug_bench_los exact=true) on the canonical box before calling it done.

    3. Scope

    DOOR (0) and BUTTON (1) templates only — the rebuild_go_colliders type gate is the single enforcement point. Explicitly NOT chests, gather nodes, goobers, or props (carts stay collision-less until a later type-widening decision; they were #39's static doodad leak, not this issue's). First target: the Deadmines doors (map 36, imported via --include-map 36 — "the Deadmines doors/cannon/chests ride this", importer/src/main.rs:3883), concretely the Rhahk'Zor Factory Door open_door already choreographs.

    Acceptance probe (headless, dev DB, vmap_enabled=true — note map 36 has no static vmap chunks, which is fine: the dynamic pass is independent of game_vmap_chunk and the missing-chunk contract keeps everything else clear):

    debug_vmap_ray 36 <segment across the closed Factory Door>   → los=Some(hit), collision=Some(hit)
    debug_encounter_open_door <factory door entry> 0
    debug_vmap_ray 36 <same segment>                             → los=None, collision=None
    

    4. Sub-issue split

    1. Run the --go-models importer dry run against approved client data for Deadmines doors #112 — Importer: --go-models extraction + game_go_model table/reducers (§1). Agent-safe for the code+tests; the live dry-run against the client Data/ is the operator's half.
    2. Run the canonical-box benchmark comparison for the door collider ray merge #113 — Module: game_go_collider registry + state-gated ray merge (§2, instance 0 semantics). Blocked by Run the --go-models importer dry run against approved client data for Deadmines doors #112 (needs the table + a fixture row path). Headless synthetic done-when — no client data needed.
    3. Accept the Deadmines Factory Door against imported geometry and a real client #114 — Instance-aware ray surface + Deadmines acceptance (§2 instance threading + §3 probe). Blocked by Run the --go-models importer dry run against approved client data for Deadmines doors #112+Run the canonical-box benchmark comparison for the door collider ray merge #113. The live probe needs the dev DB + operator client, so operator-assisted like Run a fresh-provision parity check for the Rust lyracore import subcommands #104.

    This lands the "spec comment + split into implementable sub-issues" half of this issue's done-when; #114 closes the implemented half.

    Open questions (not verified in code)

    • Axis convention for DB-quaternion GO placement. The MDDF shuffle + quaternion synthesis above is a reasoned parity guess, same posture as vmap.rs::apply_full's pitch/roll note — not verified against a live door. The Deadmines probe IS the calibration check; if the hit plane lands wrong, calibrate like nav::calibrate did.
    • WMO-model door displays. Some display ids may name a .wmo, not an M2. Slice 1 warns + skips; widen only if a needed door turns out to be one (the Deadmines doors are expected M2 — unverified until the dry-run report).
    • Factory Door numeric entry. Resolve by name from the imported game_gameobject_template at probe time rather than hardcoding (believed 13965 in cmangos-classic, unverified here).
  2. Mathih13 commented on Oct 8, 2026

    @Mathih13
    ContributorAuthor

    The spec is on this issue and split into #112, #113 and #114. The code landed in #477. Those three issues track the remaining checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2normalenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions