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.
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 cursor0x19(OBJECT|UNIT|PLAYER): same handler --UpdateTargetInteractionCursor0x21(OBJECT|GAMEOBJECT):UpdateCursorForObject-- GO-specific cursor0x81(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, thenCanTargetEntity - 0x19 (player): allowed if
flags & 0x10, thenCanTargetEntity - 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
- Use ManageRenderListNode hook (already exists in outline) to identify models owned by target GOs (resolve model+0x28 -> game object -> check type/entry)
- Locate model data via
*(*(node+0x30) + 0x130)or equivalent from model ptr - Enlarge bounding sphere radius at model_data+0xcc (and/or submesh radius at submesh+0x3c) to extend the clickable volume above/around units
- 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
- Hook ManageRenderListNode (already hooked by outline module)
- When a model is added to the render list, resolve its owner (model+0x28)
- 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) - 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:
- Pre-render hook (e.g. WorldFrameUpdate or SceneEnd): clear a "seen this frame" flag on all tracked modified models.
- 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".
- 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) -> hitTypeWorldIntersectionTest: 0x480DF0(rayStart, rayEnd, flags, *hitResult) -> hitTypePerformRaycast: 0x480A50__thiscall(this, start, end, flags, *hitDist, hitPos) -> GUIDCheckObjectTypePermissions: 0x480780(objectData, permFlags) -> boolIsValidInteractionTarget: 0x480C90__fastcall(obj) -> boolraycastPickObjects: 0x7089C0__thiscall(renderer, start, end, *dist, hitPos) -> node*GetGameStateFlags: 0x481050() -> uintSearchNodeRecursive: 0x480D90setPositionAndListStatus: 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: 0x4813B0CWorld_Intersect: 0x672170Input_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:
- ManageRenderListNode (0x710B90) — render phase, fires first
- CGObjectIsDisabled (0x614EA0) — fires AFTER ManageRenderListNode
- 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.