Files
WeirdUtils/src/clickthrough/RESEARCH.md
T
MarcelineVQ 85682f186b Add clickthrough module: GO click-through via dual WorldIntersectionTest
Hooks WorldIntersectionTest (0x480DF0) to detect when the raycast hits a
unit/player, then re-calls the original with GO-only flags (0x04). If an
interactable GO is behind the unit/player on the same ray, replaces the
hit result so the game targets the GO instead.

Uses CallSpellCastHandler (0x5F8800) for interactability filtering —
any usable GO (mailboxes, soulwells, portals, etc.) triggers click-through.
2026-03-09 20:58:56 -07:00

19 KiB

Clickthrough Module Research

Goal

Make game objects (mailboxes, refreshment tables, summon portals, etc.) clickable through units (players, pets) standing on top of them.


Hit-test / Raycast Pipeline (Ghidra-verified)

Full pipeline (every frame)

WorldFrameUpdate (0x481790) -- __thiscall(worldFrame, deltaTime)
  DispatchHeartbeatEvent()
  NameplateManager_UpdateAll()
  NDCToDDC(mouseNDC -> screenCoords)
  GetGameStateFlags() -> flag bits:
      0x04 = GOs, 0x08 = units, 0x10 = players, 0x40 = corpses
      Normal gameplay: 0x5c | terrain_bits
  HitTestPoint(worldFrame, screenX, screenY, &hitResult) -> hitType
    GetWorldPositionFromScreenCoords -> rayStart, rayEnd
    WorldIntersectionTest(rayStart, rayEnd, flags, &hitResult) -> hitType
      CWorld_Intersect(rayStart, rayEnd, 0, &hitPoint, &dist, queryFlags) -> terrain
      PerformRaycast(this, rayStart, rayEnd, flags, &hitDist, hitPos) -> GUID
        iterate renderable list at worldFrame+0x31c:
          CheckObjectTypePermissions(obj, flags) -- type bit filter
          IsValidInteractionTarget(obj) -- interactability check
          SearchNodeRecursive(node, interactable, obj) -- mark for spatial query
        raycastPickObjects(renderer, start, end, &dist, hitPos) -> closest hit
      closer-wins comparison: object dist vs terrain dist
      return 0 (nothing/terrain-normal), 1 (terrain-AoE), 2 (object)
  hitType dispatch:
    0: ResetCursor(), SetTargetGUID(0,0)
    1: HandleGroundTargeting (AoE spell reticle)
    2: HandleTargetSelection (0x4828D0) -- cursor + mouseover

HandleTargetSelection (0x4828D0) -- __thiscall(worldFrame, hitResult*)

Resolves hit GUID to object, switches on type mask *(*(obj+8)+8):

  • 0x09 (OBJECT|UNIT): SetCursorType(8) -- attack/interact cursor
  • 0x19 (OBJECT|UNIT|PLAYER): same handler -- UpdateTargetInteractionCursor
  • 0x21 (OBJECT|GAMEOBJECT): UpdateCursorForObject -- GO-specific cursor
  • 0x81 (OBJECT|CORPSE): HandleTargetInteraction -- loot cursor
  • default: ResetCursor() Then: SetTargetGUID(worldFrame, guidLo, guidHi) -- sets mouseover at worldFrame+0x340

UpdateHitTest (0x481F00) -- __fastcall(worldFrame)

Called on click events (not every frame). Runs HitTestPoint and stores result persistently at worldFrame+0x350 (hitType), +0x358 (GUID), +0x360 (position).

CheckObjectTypePermissions (0x480780)

Switches on object type mask, checks flag bits:

  • 0x09 (unit): allowed if flags & 0x08, then CanTargetEntity
  • 0x19 (player): allowed if flags & 0x10, then CanTargetEntity
  • 0x21 (GO): allowed if flags & 0x04, always true in normal mode
  • 0x81 (corpse): allowed if flags & 0x40

IsValidInteractionTarget (0x480C90)

  • Units (0x09/0x19): health > 0 OR connection state valid
  • GOs (0x21): CallSpellCastHandler(obj) -- same as minimapicons uses
  • Corpses (0x81): IsTargetInteractable(obj)

HitTestResult struct layout (from hitResult*)

Offset Type Field
+0x00 u64 GUID (lo + hi)
+0x08 Vec3 Hit position (X, Y, Z)
+0x14 f32 Hit distance
+0x18 Vec3 Ray origin
+0x24 Vec3 Ray end
+0x30 f32 Ray length

Bounding Sphere / Click Volume (Ghidra-verified)

How raycastPickObjects tests each object

raycastPickObjects (0x7089C0) iterates nodes linked via node+0x418. For each node, it reads bounding data and performs a bounding sphere test:

model_data = *(*(node+0x30) + 0x130)

if *(node+0x410) == 3:
    bounds_ptr = model_data + 0xd0       // collision bounds
else:
    geoset_idx = *(*(node+0x90) + 0xa4)
    array_base = *(model_data + 0x20)
    bounds_ptr = array_base + geoset_idx * 0x44 + 0x24   // submesh bounds

// bounds_ptr points to 7 floats: AABB min(3), AABB max(3), radius(1)
center = (min + max) * 0.5
radius = bounds_ptr[6]

// Fallback when radius == 0: use model-level bounds
if radius == 0:
    min = model_data+0xb4, max = model_data+0xc0
    radius = *(model_data+0xcc)

// Transform center by per-instance world matrix at node+0xFC
world_center = transform(center, node+0xFC)

// Scale radius by matrix scale
scale_sq = mat[0]^2 + mat[1]^2 + mat[2]^2
sphere_radius_sq = scale_sq * radius^2

// Standard ray-sphere intersection test
if perpendicular_distance^2 <= sphere_radius_sq:
    add to hit list with entry/exit distances

After all nodes tested: heapSort by distance, test detailed geometry for top hits.

Scene node structure (partial)

Offset Type Field
+0x10 u32 Some flags (checked != 0 as prerequisite)
+0x2c u32* Parent container pointer
+0x30 u32* Model/resource pointer (->+0x130 = model data)
+0x40 u32 Some ID (compared to renderer+0x10)
+0x90 u32* Geoset/animation state pointer (->+0xa4 = geoset index)
+0xFC mat4 Per-instance world transform matrix (16 floats)
+0x1cc u32 Additional flag for type==3 path
+0x1dc u32* Child list head (for SearchNodeRecursive)
+0x1e4 u32* Next sibling
+0x410 u32 Enable flag (1=normal, 3=collision bounds, set by setPositionAndListStatus)
+0x414 u32* Spatial query linked list prev
+0x418 u32* Spatial query linked list next
+0x41c u32* Parent render object pointer
+0x420 u32* Interactability flag pointer

Model data bounding offsets (at ((node+0x30) + 0x130))

Offset Type Field
+0x20 u32 Offset to submesh/geoset data array
+0xb4 Vec3 Model bounding box min (fallback)
+0xc0 Vec3 Model bounding box max (fallback)
+0xcc f32 Model bounding sphere radius (fallback)
+0xd0 7xf32 Collision bounds: AABB min, max, radius

Submesh entry (0x44 bytes each, at *(model_data+0x20))

Offset Type Field
+0x24 Vec3 Submesh AABB min
+0x30 Vec3 Submesh AABB max
+0x3c f32 Submesh bounding sphere radius

Key insight: bounds are per-MODEL, not per-instance

The bounding data comes from model data (shared by all instances of the same model). Per-instance differences are only in the world transform matrix at node+0xFC (position, rotation, scale).

For our target GOs (mailboxes, portals, refreshment tables), each GO type uses a unique model, so modifying the model bounding radius effectively targets only that GO type. No other objects share these models.

Modification strategy

  1. Use ManageRenderListNode hook (already exists in outline) to identify models owned by target GOs (resolve model+0x28 -> game object -> check type/entry)
  2. Locate model data via *(*(node+0x30) + 0x130) or equivalent from model ptr
  3. Enlarge bounding sphere radius at model_data+0xcc (and/or submesh radius at submesh+0x3c) to extend the clickable volume above/around units
  4. Only needs to be done once per model (cached), not every frame

Pros: No function hooks needed for the actual click path, purely data modification on model load. Uses existing ManageRenderListNode hook pattern. The raycast engine naturally picks the GO because its enlarged bounds overlap the unit's position.

Cons: Per-model (not per-instance), but target GO models are unique. Need to determine appropriate radius enlargement values. Visual rendering is NOT affected -- only the click/pick bounding sphere changes.


Approach 1: Hook RightClickUnit (REJECTED -- Warden risk)

Hook RightClickUnit (0x60BEA0) __thiscall(unit, autoloot). When a player right-clicks a friendly player or player-summoned unit, search for nearby interactable GOs and redirect to RightClickObject (0x5F8660) instead.

Pros: Simple, clean, only affects right-clicks on units Cons: Hooking RightClickUnit is a sensitive game function that Warden may monitor. Could cause false positives for server anti-cheat.

Note: interact.zig does NOT hook RightClickUnit -- it calls it directly from Lua-triggered functions (InteractNearest, LootAllCorpses). Those are player-initiated, not intercepting game click dispatch.


Approach 2: Hit-test post-processing (VIABLE -- cursor-level filtering)

Hook HandleTargetSelection (0x4828D0, per-frame) and UpdateHitTest (0x481F00, on-click) to post-process the raycast result. When the hit object is a unit/player, scan for a nearby interactable GO and swap the GUID.

HandleTargetSelection hook

  • Read GUID from hitResult[0..1]
  • Resolve to object, check type mask
  • If unit (0x09) or player (0x19): scan visible GOs near hit position
  • If interactable GO found within ~3-5 yards, write GO GUID into hitResult
  • Call original -- now sees the GO, sets GO cursor + mouseover

UpdateHitTest hook

  • Call original (stores result at worldFrame+0x350/0x358/0x360)
  • Read hitType from worldFrame+0x350; if type 2 (object):
  • Read GUID from worldFrame+0x358; resolve, check type
  • If unit/player, scan for nearby GO
  • If found, overwrite worldFrame+0x358 with GO GUID

GO scan

Iterate object manager for type 5 (game_object) within radius of hit position. Check interactability with CallSpellCastHandler (0x5f8800). Return closest.

Pros: UI-level hooks (not security-sensitive), handles both cursor and click, transparent to the player Cons: Two hooks, per-frame GO scan when hovering units (mitigated: only scans when cursor is over a unit, and the scan is a simple object list iteration)


Approach 3: Model bounding radius enlargement (PREFERRED)

Enlarge the bounding sphere radius in model data for specific GO models so the GO's clickable volume extends above/around units standing on it. The raycast engine naturally picks the GO because its enlarged bounds encompass the cursor position that would otherwise hit the unit.

Implementation

  1. Hook ManageRenderListNode (already hooked by outline module)
  2. When a model is added to the render list, resolve its owner (model+0x28)
  3. If owner is a GO of interest (mailbox, portal, refreshment table): a. Get model data: *(model + 0x130) (or via node+0x30 chain) b. Read current radius at model_data+0xcc c. If not already enlarged, multiply by enlargement factor (e.g. 3x-5x) d. Mark as modified (avoid re-enlarging)
  4. The GO is now clickable over a much larger area

What to enlarge

  • model_data+0xcc: model-level bounding sphere radius (fallback path)
  • Submesh bounds at *(model_data+0x20) + geoset*0x44 + 0x3c: per-geoset radius
  • Both should be enlarged for consistent behavior

Target GOs (unique models -- safe to modify)

  • Mailboxes (GO type 19): unique mailbox models
  • Refreshment Table (summoned by mage): unique table model
  • Meeting Stone / Summon Portal: unique portal model
  • Healthstone / Soulwell: unique cauldron model
  • Other player-summoned utility GOs

Open questions

  • What is the relationship between model ptr (from ManageRenderListNode) and the model data buffer? Is model+0x130 always the M2 loaded data? (Confirmed in main.zig loadModelAsyncDetour: model+0x130 = buffer ptr)
  • What enlargement factor is needed to reliably extend above a player model? Player bounding sphere is ~2 yards radius. GO might need 5-8 yard radius.
  • Does enlarging radius affect anything other than click picking? (Probably not -- the render pipeline uses its own bounds for culling, separate from the pick bounds in the spatial query system)

Pros: No hooks on click/interaction functions. Purely data modification. Uses existing ManageRenderListNode hook. Transparent to player. No per-frame scanning cost. Warden-safe (modifying model data, not game functions).

Cons: Per-model not per-instance (acceptable for unique GO models). Need to verify model data pointer chain from ManageRenderListNode context.

Lifecycle: per-frame flag/restore pattern

Models whose bounding radius we enlarge must be restored when the GO is no longer present (despawned, out of range, logout). The pattern:

  1. Pre-render hook (e.g. WorldFrameUpdate or SceneEnd): clear a "seen this frame" flag on all tracked modified models.
  2. Object enumeration (same style as minimapicons per-frame GO iteration): iterate visible objects, identify target GOs, resolve to model, set flag. If model radius not yet enlarged, enlarge it now. Mark model as "seen".
  3. Post-render hook (or end of same frame): iterate tracked models. Any model NOT flagged this frame has its GO gone -- restore original radius, remove from tracking.

This ensures:

  • Models are only enlarged while their GO is visible
  • Logout/map change/despawn automatically restores all models
  • No stale enlarged bounds persist across sessions
  • Similar to how minimapicons tracks blips per-frame with a fresh scan

Approach 4: Lua-only / Keybind approach (SAFE fallback)

Provide InteractNearestGO() Lua function (like InteractNearest but GO-only). Player binds it to a key. When pressed, finds nearest interactable GO and calls RightClickObject on it.

Pros: Zero Warden risk (same pattern as existing InteractNearest), player-initiated Cons: Requires keybind, not transparent click-through


Key Addresses

Raycast pipeline

  • WorldFrameUpdate: 0x481790 __thiscall(worldFrame, deltaTime)
  • HitTestPoint: 0x481190 __thiscall(worldFrame, screenX, screenY, *hitResult) -> hitType
  • WorldIntersectionTest: 0x480DF0 (rayStart, rayEnd, flags, *hitResult) -> hitType
  • PerformRaycast: 0x480A50 __thiscall(this, start, end, flags, *hitDist, hitPos) -> GUID
  • CheckObjectTypePermissions: 0x480780 (objectData, permFlags) -> bool
  • IsValidInteractionTarget: 0x480C90 __fastcall(obj) -> bool
  • raycastPickObjects: 0x7089C0 __thiscall(renderer, start, end, *dist, hitPos) -> node*
  • GetGameStateFlags: 0x481050 () -> uint
  • SearchNodeRecursive: 0x480D90
  • setPositionAndListStatus: 0x713CB0 __thiscall(node, enable, interactable, parent)

Cursor / mouseover

  • HandleTargetSelection: 0x4828D0 __thiscall(worldFrame, hitResult*)
  • UpdateHitTest: 0x481F00 __fastcall(worldFrame)
  • SetTargetGUID: 0x482090 __thiscall(worldFrame, guidLo, guidHi)
  • SetCursorType: 0x523C20 __fastcall(cursorId)
  • ResetCursor: 0x523D30

Interaction

  • RightClickUnit: 0x60BEA0 __thiscall(unit, autoloot)
  • RightClickObject: 0x5F8660 __thiscall(obj, autoloot)
  • CallSpellCastHandler: 0x5F8800 __fastcall(obj_ECX) -> char(bool)
  • GetObjectPointer: 0x464870 __stdcall(guidLo, guidHi) -> objPtr

WorldFrame data

  • WorldFrame ptr: *(u32*)0xB4B2BC
  • Mouseover GUID: worldFrame+0x340 (8 bytes)
  • Click hitType: worldFrame+0x350
  • Click GUID: worldFrame+0x358
  • Click hit pos: worldFrame+0x360 (Vec3)
  • Renderable list: worldFrame+0x31c

Object data

  • GetWorldPositionFromScreenCoords: 0x4813B0
  • CWorld_Intersect: 0x672170
  • Input_GetMouseScreenCoordinates: 0x42CDE0
  • Unit position: obj+0x9B8 (y), +0x9BC (x), +0x9C0 (z)
  • GO position: *(obj+0x110)+0x24 (y), +0x28 (x), +0x2C (z)
  • Object type at obj+0x14: 3=unit, 4=player, 5=game_object
  • Type mask at *(obj+0x8)+0x8: 0x09=unit, 0x19=player, 0x21=GO, 0x81=corpse
  • OBJECT_FIELD_SCALE_X: *(obj+0x8)+0x10 (float)
  • UNIT_FIELD_SUMMONEDBY: *(obj+0x8)+0x30 (u64 GUID)

Model / render pipeline (from outline module)

  • CM2Model_ManageRenderListNode: 0x710B90 __thiscall(model, addToList)
  • MODEL_OWNER_DIRECT: model+0x28
  • MODEL_OWNER_CALLBACK: model+0x3C0
  • Model data buffer: model+0x130 (ptr), model+0x134 (size)
  • Model bounding box: model_data+0xb4 (min), +0xc0 (max), +0xcc (radius)
  • Collision bounds: model_data+0xd0 (AABB min, max, radius -- 7 floats)
  • Submesh array: *(model_data+0x20), each entry 0x44 bytes
  • Submesh bounds: entry+0x24 (AABB min, max), entry+0x3c (radius)

Vanilla 1.12.1 DBC note

GameObjectDisplayInfo.dbc does NOT contain geo box/bounds in vanilla (added in WoTLK). Server uses custom gameobject_display_info_addon DB table. Client derives clickable bounds from the M2 model header bounding data.


GO Model Struct Investigation (2026-03-09)

Problem: model+0x130 is NOT the M2 data pointer for GO models

The raycast research established *(model + 0x130) as the M2 data buffer for unit models. However, runtime testing shows GO (mailbox) models have a float at +0x130, not a pointer. The struct layout differs between unit and GO models.

Hook architecture (working)

Three hooks, frame execution order:

  1. ManageRenderListNode (0x710B90) — render phase, fires first
  2. CGObjectIsDisabled (0x614EA0) — fires AFTER ManageRenderListNode
  3. SceneEnd (0x5A17A0) — end of frame

Because CGObjectIsDisabled fires after ManageRenderListNode, target GO pointers are collected one frame late. Solved with a deferred-clear flag: SceneEnd sets target_go_needs_clear, first CGObjectIsDisabled call of next frame clears the list. ManageRenderListNode sees previous frame's targets (GOs don't move).

Model back-pointer matching (confirmed working)

  • model+0x28 (MODEL_OWNER_DIRECT) matches GO object pointers from object manager
  • Value comparison only — no dereferencing of back-pointers needed
  • model+0x3C0 (MODEL_OWNER_CALLBACK) is garbage for GO models (unit-only field)

CGObjectIsDisabled (0x614EA0) — per-object visibility hook

  • __thiscall(object_ECX) -> u32 (0=render, 1=disabled)
  • Fires for every visible object (not just GOs — check obj+0x14 == 5)
  • ~420-500 calls per frame in a typical scene
  • ~280 of those are GOs
  • Gives object pointer directly — no GUID resolution needed

Matched GO model struct dump (mailbox, go_type=19, entry=173221)

Model at 0x34e46808, owner at +0x28 = GO object pointer (confirmed match).

+0x000: 00000002 00000000 34e47014 32a3d808   ; [0]=refcount/type?, [8]=nearby alloc, [C]=heap
+0x010: 00000001 00000001 00000001 00000000   ; flags
+0x020: 00000000 00000000 32a03a38 14692008   ; [28]=OWNER_DIRECT (GO ptr!), [2C]=heap
+0x030: 32948408 00000000 00000000 3b5b500c   ; [30]=heap
+0x040: 000000df 00000000 ...                 ; [40]=0xDF=223
+0x070: 005f7df0 ...                          ; [70]=code addr (CGGameObject method?)
+0x090: 3294500c 32944c10 32944808 32944818   ; cluster of heap ptrs (bone/submesh?)
+0x0a0: 00000000 32944828 329448c8 00000000
+0x0b0: ... 00000001 bf31d52a bf3826a5 ...    ; start of transform floats
+0x0c0: ... 3f3826a5 bf31d52a ...             ; rotation matrix
+0x0e0: 3f800000 00000000 44c9f28f c5893ccd   ; [E8]=world X≈1615.8, [EC]=Y≈-4391.6
+0x0f0: 4121ba5e ...                          ; [F0]=Z≈10.1
+0x0fc-0x13c: world transform matrix (identity-like with position)
+0x130: bd8bc000 41cdb480 3f800000 3f800000   ; [130]=FLOAT(-0.068), NOT a pointer!
+0x150-0x180: scale/blend floats (1.0f pattern)
+0x190: 3f0a8a8b 3ed8d8da 3f0a8a8b 3f800000   ; color/tint values
+0x1c0: ... 00000001 ...
+0x1f0: 00000000 005f7da0 ...                 ; [1F4]=code addr (CGGameObject vtable?)

Key observation: raycast uses SCENE NODE, not model directly

Re-reading the raycast code: raycastPickObjects iterates scene nodes linked via node+0x418. The model data path is:

model_data = *(*(node+0x30) + 0x130)

Where node+0x30 is a "model/resource pointer" — this is NOT the same as the CM2Model pointer from ManageRenderListNode ECX! The scene node at +0x30 points to a different struct (the resource/loaded model), and THAT struct has the M2 data at +0x130.

Next step: Use Ghidra to trace from the GO object → scene node → +0x30 resource pointer → +0x130 M2 data, to find the correct indirection chain from the CM2Model pointer we have in ManageRenderListNode.