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.
437 lines
19 KiB
Markdown
437 lines
19 KiB
Markdown
# 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.
|