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.
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,168 @@
|
||||
//! GO click-through module.
|
||||
//!
|
||||
//! Makes game objects (mailboxes, soulwells, etc.) clickable through players
|
||||
//! and units by hooking WorldIntersectionTest (0x480DF0). When the normal
|
||||
//! raycast hits a player/unit, we re-call the original with GO-only flags.
|
||||
//! If a GO is found, we return that result instead.
|
||||
//!
|
||||
//! Hooks:
|
||||
//! WorldIntersectionTest (0x480DF0) -- sole raycast entry, called from HitTestPoint
|
||||
|
||||
const std = @import("std");
|
||||
const hook = @import("zhook");
|
||||
const logging = @import("../logging.zig");
|
||||
const mod_mutex = @import("../mutex.zig");
|
||||
|
||||
pub const module_name: [*:0]const u8 = "clickthrough";
|
||||
|
||||
// =============================================================================
|
||||
// WoW addresses
|
||||
// =============================================================================
|
||||
|
||||
const ADDR_WorldIntersectionTest: usize = 0x480DF0;
|
||||
const ADDR_GetObjectByGUID: usize = 0x464870;
|
||||
|
||||
// =============================================================================
|
||||
// HitTestResult layout
|
||||
// =============================================================================
|
||||
|
||||
const HIT_GUID_LO: usize = 0x00;
|
||||
const HIT_GUID_HI: usize = 0x04;
|
||||
const HIT_RESULT_SIZE: usize = 0x34;
|
||||
|
||||
// Object struct offsets
|
||||
const OBJ_DESCRIPTOR: usize = 0x08;
|
||||
const OBJ_TYPE_MASK_OFFSET: usize = 0x08; // at *(*(obj+8)+8)
|
||||
|
||||
// Type masks
|
||||
const TYPE_UNIT: u32 = 0x09;
|
||||
const TYPE_PLAYER: u32 = 0x19;
|
||||
|
||||
// Raycast flags
|
||||
const FLAG_GO: u32 = 0x04;
|
||||
|
||||
// =============================================================================
|
||||
// Target GO filtering
|
||||
// =============================================================================
|
||||
|
||||
const OBJ_TYPE: usize = 0x14;
|
||||
const OBJ_TYPE_GO: u32 = 5;
|
||||
const ADDR_CallSpellCastHandler: usize = 0x5F8800;
|
||||
|
||||
fn getObjectByGUID(guid_lo: u32, guid_hi: u32) u32 {
|
||||
if (guid_lo == 0 and guid_hi == 0) return 0;
|
||||
return hook.call(fn (u32, u32) callconv(hook.cc.stdcall) u32, ADDR_GetObjectByGUID, .{ guid_lo, guid_hi });
|
||||
}
|
||||
|
||||
/// Check if the GUID refers to an interactable GO.
|
||||
fn isInteractableGO(guid_lo: u32, guid_hi: u32) bool {
|
||||
const obj = getObjectByGUID(guid_lo, guid_hi);
|
||||
if (obj == 0) return false;
|
||||
const obj_type = hook.readMem(u32, obj + OBJ_TYPE);
|
||||
if (obj_type != OBJ_TYPE_GO) return false;
|
||||
// CallSpellCastHandler: __fastcall(obj_ECX) -> bool
|
||||
return hook.call(fn (u32) callconv(hook.cc.fastcall) u8, ADDR_CallSpellCastHandler, .{obj}) != 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook state
|
||||
// =============================================================================
|
||||
|
||||
var g_mutex: ?*anyopaque = null;
|
||||
var g_is_hook_owner: bool = false;
|
||||
|
||||
// WorldIntersectionTest: __thiscall(ECX=worldFrame, rayStart*, rayEnd*, flags, hitResult*) -> hitType
|
||||
// RET 0x10 (4 stack args)
|
||||
const WorldIntersectFn = fn (u32, u32, u32, u32, u32) callconv(hook.cc.thiscall) u32;
|
||||
var wit_hook: hook.Detour(WorldIntersectFn) = .{};
|
||||
|
||||
var log: logging.Logger = .{};
|
||||
var g_log_counter: u32 = 0;
|
||||
|
||||
// =============================================================================
|
||||
// Hook: WorldIntersectionTest (0x480DF0)
|
||||
// Called from HitTestPoint with the ray and flags. We call original, check
|
||||
// the result, and if it's a unit/player, re-call with GO-only flags.
|
||||
// =============================================================================
|
||||
|
||||
fn worldIntersectDetour(world_frame: u32, ray_start: u32, ray_end: u32, flags: u32, hit_result: u32) callconv(hook.cc.thiscall) u32 {
|
||||
// Call original with caller's flags
|
||||
const hit_type = wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, flags, hit_result });
|
||||
|
||||
if (!g_is_hook_owner or hit_result == 0 or hit_type != 2) return hit_type;
|
||||
|
||||
// hitType 2 = object hit. Check if it's a unit/player.
|
||||
const buf_lo = hook.readMem(u32, hit_result + HIT_GUID_LO);
|
||||
const buf_hi = hook.readMem(u32, hit_result + HIT_GUID_HI);
|
||||
|
||||
if (buf_lo == 0 and buf_hi == 0) return hit_type;
|
||||
|
||||
const obj = getObjectByGUID(buf_lo, buf_hi);
|
||||
if (obj == 0) return hit_type;
|
||||
|
||||
const desc_ptr = hook.readMem(u32, obj + OBJ_DESCRIPTOR);
|
||||
if (desc_ptr < 0x10000 or desc_ptr >= 0x7F000000) return hit_type;
|
||||
|
||||
const type_mask = hook.readMem(u32, desc_ptr + OBJ_TYPE_MASK_OFFSET);
|
||||
if (type_mask != TYPE_UNIT and type_mask != TYPE_PLAYER) return hit_type;
|
||||
|
||||
// Hit a unit/player. Re-call original with unit/player flags stripped.
|
||||
const go_flags: u32 = 0x04; // GOs only
|
||||
var go_result = [_]u8{0} ** HIT_RESULT_SIZE;
|
||||
const go_hit_type = wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, go_flags, @intFromPtr(&go_result) });
|
||||
|
||||
g_log_counter +%= 1;
|
||||
if (g_log_counter % 60 == 0) {
|
||||
const go_lo = std.mem.readInt(u32, go_result[HIT_GUID_LO..][0..4], .little);
|
||||
const go_hi = std.mem.readInt(u32, go_result[HIT_GUID_HI..][0..4], .little);
|
||||
log.fmt("[ct] unit/player mask=0x{x}, GO re-raycast: type={d} guid=0x{x}:{x}\n", .{ type_mask, go_hit_type, go_hi, go_lo });
|
||||
}
|
||||
|
||||
if (go_hit_type < 2) return hit_type;
|
||||
|
||||
const go_guid_lo = std.mem.readInt(u32, go_result[HIT_GUID_LO..][0..4], .little);
|
||||
const go_guid_hi = std.mem.readInt(u32, go_result[HIT_GUID_HI..][0..4], .little);
|
||||
|
||||
if (!isInteractableGO(go_guid_lo, go_guid_hi)) return hit_type;
|
||||
|
||||
log.fmt("[ct] GO click-through: 0x{x}:{x} replaces unit/player 0x{x}:{x}\n", .{ go_guid_hi, go_guid_lo, buf_hi, buf_lo });
|
||||
|
||||
// Replace the caller's hitResult with the GO result
|
||||
const dst: [*]u8 = @ptrFromInt(hit_result);
|
||||
@memcpy(dst[0..HIT_RESULT_SIZE], &go_result);
|
||||
|
||||
return go_hit_type;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Module lifecycle
|
||||
// =============================================================================
|
||||
|
||||
pub fn isActive() bool {
|
||||
return g_is_hook_owner;
|
||||
}
|
||||
|
||||
pub fn installHooks() void {
|
||||
logging.print("[clickthrough] Module loaded\n");
|
||||
|
||||
const result = mod_mutex.acquire(module_name);
|
||||
g_mutex = result.handle;
|
||||
g_is_hook_owner = result.is_owner;
|
||||
if (!g_is_hook_owner) return;
|
||||
|
||||
log = logging.Logger.open(module_name, .both);
|
||||
_ = wit_hook.attach(ADDR_WorldIntersectionTest, &worldIntersectDetour);
|
||||
|
||||
log.fmt("[clickthrough] WorldIntersectionTest hooked at 0x{x}\n", .{ADDR_WorldIntersectionTest});
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {
|
||||
if (g_is_hook_owner) {
|
||||
wit_hook.detach();
|
||||
log.close();
|
||||
mod_mutex.release(&g_mutex);
|
||||
}
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
|
||||
pub fn onShutdown() void {}
|
||||
Reference in New Issue
Block a user