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:
MarcelineVQ
2026-03-09 20:58:56 -07:00
parent db8178f08f
commit 85682f186b
2 changed files with 604 additions and 0 deletions
+436
View File
@@ -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.
+168
View File
@@ -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 {}