You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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:
Static cast_ray as today → candidate hit t_static.
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.
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 Dooropen_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
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).
Activity
Mathih13 commentedon Aug 11, 2026
Spec — dynamic GameObject collision (doors), cmangos parity
Grounded against the shipped vmap slices. What exists today:
game_vmap_chunk(per-terrain-cell packed triangle shards,module/src/vmap.rs) + the two ray wrapperslos_ray/collision_rayoverlyracore_shared::vmap::cast_ray(DDA cell walk, global-mint). LoS = WMO-class only, collision = WMO+M2 (RayFlavor,crates/lyracore-shared/src/vmap.rs).game_gameobject.state— 0 = ready/closed, 1 = open (DOOR_OPEN_STATE,module/src/encounter.rs:196); DOOR/BUTTON toggle viaapply_use_gameobject, choreography viaencounter::open_door(entry, instance_id)(instance 0 targets the static rows). Despawn = row delete. Dungeon doors are per-instance copies (instance.rscreate_instance,GO_COPY_BANDguids, 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 fromorientation— the gateway'sbuild_gameobject_rotation_valuesrule).game_gameobject_template.display_idis imported but never resolved to a model — no GameObjectDisplayInfo read anywhere inimporter/src. The pieces all exist though:wow_dbc0.3 shipsvanilla_tables::game_object_display_info(display_id → model_namestring),collision::open_geometry_chainopens the model MPQs, andnav::m2_trisalready loads an M2's bounding (collision) mesh in model-local coords. The dump'sgameobject_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): parsegameobject_template, keep DOOR/BUTTON rows (classify_go_typetypes 0/1), resolvedisplay_idviaGameObjectDisplayInfo.dbc(--go-models <client Data/ dir>opens the DBC + geometry chain), swap.mdx/.mdl → .m2(thecollision.rs::read_m2_bounding_trisrule), and extract the M2 bounding mesh in model-local space vianav::m2_tris. Pre-apply the MDDF axis shuffle ([-v[2], -v[0], v[1]]— whatapply_fulldoes 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 thatGameObjectModelthen 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_BANDrows) and fordebug_spawn_gameobject.Output: one row per door template entry into a new module-private table, batched via new reducers (
import_go_models/_append, theimport_vmap_chunkshex-blob convention):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:
Registration (derived data, idempotent clear+rebuild like
arm_pool):rebuild_go_colliders(operator reducer): scangame_gameobject× template type DOOR/BUTTON ×game_go_modelrow exists → insert registry rows. The import pipeline calls it afterimport_gameobjects+import_go_models(slots next toarm_all_pools; Run a fresh-provision parity check for the Rust lyracore import subcommands #104'simport vmapsverb 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 atinstance.rs:994).GameObjectUnlockedposture.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 samevmap_enabledgate:cast_rayas today → candidate hitt_static.game_go_colliderby (map_id, instance_id); per row: segment-vs-sphere reject (pure fn) →game_gameobject.guid().find()— skip if missing (despawned) orstate != 0(open) → decodegame_go_model.blob→ inverse-transform the segment into model space (quaternion from the GO row'srotation_0..3, all-zero ⇒ yaw-only fromorientation, the feat(importer): extract Deadmines geometry from ADT tiles #515 rule ported to a shared pure fn; then ÷scale, −position) → Möller–Trumbore (reusesegment_tri_hit) →t_go.t_staticand everyt_go. The codec's class tags never gate the dynamic pass — participation is decided per GO row, so noTriClassaddition, noVMAP_VERSIONbump.Both ray flavors. cmangos wires
GameObjectModel/DynamicTreeinto 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_stepgate).area_info/cast_ray_areastays static-only (doors carry no WMO group/indoor semantics — matches cmangosgetAreaInfo).floor_zinherits the dynamic pass viacollision_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 oropen_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_idthreaded intolos_ray/collision_rayand theirnav::has_los/nav_stepwrappers (~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-rundebug_bench_collision_gate(anddebug_bench_los exact=true) on the canonical box before calling it done.3. Scope
DOOR (0) and BUTTON (1) templates only — the
rebuild_go_colliderstype 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 Dooropen_dooralready 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 ofgame_vmap_chunkand the missing-chunk contract keeps everything else clear):4. Sub-issue split
--go-modelsextraction +game_go_modeltable/reducers (§1). Agent-safe for the code+tests; the live dry-run against the client Data/ is the operator's half.game_go_colliderregistry + 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.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)
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 likenav::calibratedid..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).game_gameobject_templateat probe time rather than hardcoding (believed 13965 in cmangos-classic, unverified here).Mathih13 commentedon Oct 8, 2026
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.