From 85682f186b673ad2b5abcd846e32cf568211ff61 Mon Sep 17 00:00:00 2001 From: MarcelineVQ Date: Mon, 9 Mar 2026 20:58:56 -0700 Subject: [PATCH] Add clickthrough module: GO click-through via dual WorldIntersectionTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/clickthrough/RESEARCH.md | 436 ++++++++++++++++++++++++++++++ src/clickthrough/clickthrough.zig | 168 ++++++++++++ 2 files changed, 604 insertions(+) create mode 100644 src/clickthrough/RESEARCH.md create mode 100644 src/clickthrough/clickthrough.zig diff --git a/src/clickthrough/RESEARCH.md b/src/clickthrough/RESEARCH.md new file mode 100644 index 0000000..ccb34db --- /dev/null +++ b/src/clickthrough/RESEARCH.md @@ -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. diff --git a/src/clickthrough/clickthrough.zig b/src/clickthrough/clickthrough.zig new file mode 100644 index 0000000..3e30b2c --- /dev/null +++ b/src/clickthrough/clickthrough.zig @@ -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 {}