From 25ed0b3780309e50b903fab8fb27671d684e2558 Mon Sep 17 00:00:00 2001 From: MarcelineVQ Date: Tue, 24 Feb 2026 00:51:33 -0800 Subject: [PATCH] Add outline rendering system ported from C++ to Zig Port the model outline hook system (CM2SceneRenderDraw, ManageRenderListNode, DrawBatchProjected) from the C++ idris DLL to pure Zig. Includes D3D9 vtable hooks for stencil-based outline rendering with per-category colors and thickness. Fix GetObjectByGUID calling convention: was using fastcall (ECX/EDX) but Ghidra confirms it's __stdcall with stack params and RET 8. Fix lua_pushcclosure address from 0x6F3B80 (mid-body of another function) to 0x6F3920. Guard resolveModelOwner in DrawBatchProj with hasTargets() check and cache unit model status during ManageRenderListNode to avoid raw pointer chasing at render time. Add IsBadReadPtr validation in resolveModelOwner to match C++ IsValidReadPtr pattern for page-level memory safety. --- CLAUDE_PURE_ZIG_OUTLINE_PLAN.md | 91 +++++ docs/calling-conventions.md | 108 ++++++ docs/outline-port-notes.md | 83 ++++ docs/outline-validation.md | 84 ++++ src/addon/WeirdUtils.lua | 11 + src/main.zig | 6 +- src/outline/api.zig | 71 ++++ src/outline/d3d9_hook.zig | 662 ++++++++++++++++++++++++++++++++ src/outline/model_hook.zig | 238 ++++++++++++ src/outline/offsets.zig | 119 ++++++ src/outline/tracker.zig | 260 +++++++++++++ src/outline/types.zig | 259 +++++++++++++ src/outline/wow.zig | 225 +++++++++++ 13 files changed, 2216 insertions(+), 1 deletion(-) create mode 100644 CLAUDE_PURE_ZIG_OUTLINE_PLAN.md create mode 100644 docs/calling-conventions.md create mode 100644 docs/outline-port-notes.md create mode 100644 docs/outline-validation.md create mode 100644 src/outline/api.zig create mode 100644 src/outline/d3d9_hook.zig create mode 100644 src/outline/model_hook.zig create mode 100644 src/outline/offsets.zig create mode 100644 src/outline/tracker.zig create mode 100644 src/outline/types.zig create mode 100644 src/outline/wow.zig diff --git a/CLAUDE_PURE_ZIG_OUTLINE_PLAN.md b/CLAUDE_PURE_ZIG_OUTLINE_PLAN.md new file mode 100644 index 0000000..347d7e5 --- /dev/null +++ b/CLAUDE_PURE_ZIG_OUTLINE_PLAN.md @@ -0,0 +1,91 @@ +# Task: Pure Zig Unit Outline Reimplementation for weirdutils + +## Mission +Reimplement the WoW unit outlining system in **pure Zig** inside `zig/weirdutils`, with cleanly separated source files and no runtime dependency on the Idris C/C++ outline implementation. + +## Explicit Tooling Permission +You are explicitly authorized to use **Code Executor MCP freely**, including controlling **Ghidra** for reverse engineering support (function signatures, prologues, calling conventions, offsets, call sites). + +Use this freedom proactively to verify: +- Function prologues and patch-safe lengths +- rel32 fixup needs for call/jmp in overwritten prologues +- Calling convention correctness (__thiscall/__fastcall/__stdcall) +- Data structure offsets relevant to model tracking/render hooks + +## Repos / Paths +- Target implementation: `/media/storage/projects/zig/weirdutils` +- Reference implementation: `/media/storage/projects/idris/dlls` +- Existing Zig hook library reference: `/media/storage/projects/zig/hook` + +## Hard Constraints +1. **Pure Zig runtime path for outlines** (C/C++ outline files from Idris are reference-only). +2. Keep existing weirdutils features working (screenshot/interact/addon load). +3. Split code into focused modules; avoid giant monolith file growth. +4. Maintain x86 WoW 1.12.1 calling-convention correctness. +5. Preserve hook-chain safety (do not break existing detours). + +## Desired Source Layout (implement this or a clearly better equivalent) +Create these Zig modules under `src/outline/`: +- `offsets.zig` – constants for addresses/offsets +- `types.zig` – enums/structs (object types, vectors, draw categories) +- `wow.zig` – game memory access wrappers +- `tracker.zig` – per-frame model/category tracking state +- `model_hook.zig` – WoW render pipeline hooks for model classification +- `d3d9_hook.zig` – D3D9 DIP/EndScene hook logic for outline rendering +- `shader.zig` – optional shader helpers/constants (if used) +- `api.zig` – public init/reset/config functions exposed to main + +Also update: +- `src/main.zig` (initialize/cleanup outline subsystem) +- `build.zig` (if needed for new modules) +- `src/addon/WeirdUtils.lua` (outline command UX/status) + +## Behavior Requirements +Implement category-based outlines: +- Dead friendly players/corpses: through-wall style visibility +- Raid-marked units: clear colored outlines +- Current target (enemy NPC): emphasized outline + +Implement robust per-frame reset + repopulation logic to avoid stale tracking. + +## Implementation Phases +### Phase 1 – Mapping + verification +- Use Ghidra + references to verify prologues and hook patch sizes. +- Produce a concise mapping note in `docs/outline-port-notes.md`. + +### Phase 2 – Core Zig architecture +- Create modular outline subsystem under `src/outline/`. +- Port tracking/state logic and category assignment. + +### Phase 3 – Hook integration +- Implement render hooks with safe trampoline usage using existing Zig hook patterns. +- Integrate into weirdutils startup/shutdown flow. + +### Phase 4 – User controls + defaults +- Add `/wu outline` command family: + - `/wu outline on` + - `/wu outline off` + - `/wu outline status` + +### Phase 5 – Validation +- Ensure weirdutils still builds for x86 windows msvc. +- Smoke-check no regressions in interact/screenshot command registration paths. +- Add a brief test checklist in `docs/outline-validation.md`. + +## Deliverables +1. Pure Zig outline subsystem in separated files. +2. Updated integration in main/addon. +3. `docs/outline-port-notes.md` with verified hook/prologue decisions. +4. `docs/outline-validation.md` with run/test checklist. +5. Final summary with changed files and known limitations. + +## Guardrails +- Do not run destructive git operations. +- Do not remove existing features. +- If a prologue/callconv is uncertain, verify in Ghidra before patching. + +## Completion Signal +When fully done, run: +`openclaw system event --text "Done: pure Zig outline port implemented in weirdutils" --mode now` + +Then print a short completion summary. \ No newline at end of file diff --git a/docs/calling-conventions.md b/docs/calling-conventions.md new file mode 100644 index 0000000..1c69883 --- /dev/null +++ b/docs/calling-conventions.md @@ -0,0 +1,108 @@ +# WoW 1.12.1 Calling Conventions — Ghidra Verified + +All conventions verified against WoW.exe 1.12.1 build 5875 via Ghidra decompilation and raw byte analysis. + +## Model Pipeline Hooks (outline/model_hook.zig) + +| # | Address | Function | Convention | Params | Prologue | RET | Status | +|---|---------|----------|------------|--------|----------|-----|--------| +| 1 | `0x0070b360` | CM2SceneRenderDraw | `__thiscall` | ECX=this, stack: viewMatrix, batchData, batchIndices, batchCount | `55 8B EC 81 EC 80 00 00 00` (9B) | — | CORRECT | +| 2 | `0x00710b90` | CM2Model_ManageRenderListNode | `__thiscall` | ECX=model, stack: addToList | `55 8B EC 8B 45 08` (6B) | — | CORRECT | +| 3 | `0x0070cb30` | CM2Scene_DrawBatchProjected | `__fastcall` | ECX=renderContext | `55 8B EC 83 EC 10` (6B) | — | CORRECT | + +## Game Function Wrappers (outline/wow.zig) + +| # | Address | Function | Convention | Params | Prologue | RET | Status | +|---|---------|----------|------------|--------|----------|-----|--------| +| 4 | `0x00515970` | Script_UnitGUID | `__fastcall` | ECX=unitIdStr → EAX:EDX (64-bit) | `55 8B EC 51 56 68 90 00 00 00` | — | CORRECT | +| 5 | `0x00464870` | GetObjectByGUID | **`__stdcall`** | **stack: guidLow, guidHigh → EAX** | `55 8B EC 8B 45 08 8B 4D 0C` | **RET 8** | **FIXED** — was incorrectly using `hook.fastcall` | +| 6 | `0x006061E0` | CGUnit_C::UnitReaction | `__thiscall` | ECX=localPlayer, stack: unit → EAX (reaction int) | `53 8B DC 83 EC 08 83 E4 F8` | — | CORRECT | + +### GetObjectByGUID Detail + +Disassembly at `0x464870`: +``` +55 PUSH EBP +8B EC MOV EBP, ESP +8B 45 08 MOV EAX, [EBP+8] ; guidLow from STACK (not ECX!) +8B 4D 0C MOV ECX, [EBP+C] ; guidHigh from STACK (not EDX!) +8B D0 MOV EDX, EAX +0B D1 OR EDX, ECX ; test if guid == 0 +74 0B JZ return_zero +51 PUSH ECX ; push guidHigh for inner call +50 PUSH EAX ; push guidLow for inner call +E8 ... CALL FindObjectByGUID +5D POP EBP +C2 08 00 RET 8 ; callee cleans 8 bytes +``` + +The C++ reference declared this as `__fastcall(uint64_t)`. Under MSVC, `uint64_t` (8 bytes) is too large for a single 32-bit register, so `__fastcall` passes it on the stack — making it behave like `__stdcall`. The Zig code split it into two `u32` args and passed them in ECX/EDX via `hook.fastcall`, which was wrong. + +The transmog addon (`transmogfix/src/main.zig:134`) and interact module (`weirdutils/src/interact.zig:50`) already had the correct push-to-stack implementation. + +## Dead Overlay Functions (not yet ported — for future reference) + +| # | Address | Function | Convention | Params | Status | +|---|---------|----------|------------|--------|--------| +| 7 | `0x00483EE0` | WorldProjection_WorldToScreenCoords | `__thiscall` | ECX=WorldFrame, stack: float* worldXYZ, float* screenXYZ → uint (bool) | VERIFIED | +| 8 | `0x0041ADE0` | DDCToNDC | `__fastcall` | ECX=float* outX, EDX=float* outY, stack: float inX, float inY | VERIFIED | +| 9 | `0x00609210` | CGUnit_C::GetUnitName | `__thiscall` | ECX=unit, stack: uint** param → char* | VERIFIED | +| 10 | `0x006264E0` | GetObjectName | `__fastcall` | ECX=uint64_t* guidPtr → char* | VERIFIED | + +## Lua API (main.zig) + +All WoW 1.12.1 Lua C API functions use `__fastcall` with L (lua_State*) in ECX. + +| # | Address | Function | Convention | Params | RET | Status | +|---|---------|----------|------------|--------|-----|--------| +| 11 | `0x00704120` | FrameScript::Register | `__fastcall` | ECX=name, EDX=funcAddr | — | CORRECT | +| 12 | `0x006F3070` | lua_gettop | `__fastcall` | ECX=L → int | RET | CORRECT | +| 13 | `0x006F3080` | lua_settop | `__fastcall` | ECX=L, EDX=index | — | CORRECT | +| 14 | `0x006F3350` | lua_pushvalue | `__fastcall` | ECX=L, EDX=index | — | CORRECT | +| 15 | `0x006F3400` | lua_type | `__fastcall` | ECX=L, EDX=index → int | RET | CORRECT | +| 16 | `0x006F3510` | lua_isstring | `__fastcall` | ECX=L, EDX=index → int | RET | CORRECT | +| 17 | `0x006F3690` | lua_tostring | `__fastcall` | ECX=L, EDX=index → char* | — | CORRECT | +| 18 | `0x006F39F0` | lua_pushboolean | `__fastcall` | ECX=L, EDX=bool | — | CORRECT | +| 19 | `0x006F3890` | lua_pushstring | `__fastcall` | ECX=L, EDX=string | — | CORRECT | +| 20 | `0x006F3810` | lua_pushnumber | `__fastcall` | ECX=L, stack: f64 (8 bytes) | RET 8 | CORRECT | +| 21 | `0x006F3920` | lua_pushcclosure | `__fastcall` | ECX=L, EDX=func, stack: nupvalues | — | **FIXED** — was 0x6F3B80 (wrong addr) | +| 22 | `0x006F4940` | luaL_error | `__cdecl` | stack: L, fmt, ... | — | CORRECT | +| 23 | `0x006F4DC0` | luaL_openlib | `__fastcall` | ECX=L, EDX=libname, stack: funcs, nup | — | CORRECT | + +### lua_pushcclosure Detail + +Ghidra search found `lua_pushcclosure @ 006f3920`. No function exists at the old address `0x6F3B80` — it falls mid-body of another function. The wrapper was unused (never called from current code) so no crash occurred. + +### lua_pushnumber Detail + +Takes a `double` (8 bytes) which is too large for EDX, so it goes on the stack per `__fastcall` rules. Callee cleans with `RET 8`. The inline asm workaround in `weirdUtilsVersion` correctly does `SUB ESP,8; FSTPL (ESP); CALL` and relies on `RET 8` to rebalance. + +## File/Addon Hooks (main.zig) + +| # | Address | Function | Convention | Params | Prologue | RET | Status | +|---|---------|----------|------------|--------|----------|-----|--------| +| 24 | `0x0042a320` | ValidateFunctionPointer | `__fastcall` | ECX=addr | `55 8B EC 83 EC 40` (6B) | — | CORRECT (empty detour) | +| 25 | `0x00648620` | LoadFileWithTextureResourceFallback | `__stdcall` | 7 stack params | `55 8B EC 8B 4D 1C` (6B) | RET 0x1C | CORRECT | +| 26 | `0x00490250` | FrameScript_RegisterAllSystemCommands | `void(void)` | none | `56 E8 ...` (6B) | — | CORRECT (fixup at offset 1) | +| 27 | `0x0051F600` | LoadAddonsRecursively | `__fastcall` | ECX=error_handler | `53 8B 1D ...` (7B) | — | CORRECT | +| 28 | `0x006EDB90` | loadFileListWithIncludes | `__fastcall` | ECX=path, EDX=md5ctx, stack: error_handler | `55 8B EC 6A FF ...` | RET 4 | CORRECT | +| 29 | `0x004B6F70` | LoadUIBindingsFromFile | `__thiscall` | ECX=binding_mgr, stack: path, md5ctx, callback | `55 8B EC 81 EC 1C 04 00 00` | RET 0x0C | CORRECT | +| 30 | `0x0046a400` | GameEngine_MainInitialize | `void(void)` | none | `55 8B EC 83 EC 28` (6B) | — | CORRECT | +| 31 | `0x00490BD0` | World_HandlePlayerLogin | `void(void)` | none | `56 E8 ...` (6B) | — | CORRECT (fixup at offset 1) | + +### Note on #31 + +Ghidra names this `World_HandlePlayerLogin`, not `CGGameUI_Shutdown`. The Zig hook's detour just calls the original with no extra logic, so the naming discrepancy has no functional impact. + +## Utility Functions + +| # | Address | Function | Convention | Params | RET | Status | +|---|---------|----------|------------|--------|-----|--------| +| 32 | `0x006462E0` | M2_AllocateModelBuffer | `__stdcall` | stack: size, source_file, line, flags | RET 0x10 | CORRECT | +| 33 | `0x007040D0` | FrameScript::GetContext | `void(void)` | none → lua_State* in EAX | RET | CORRECT (trivial: `MOV EAX,[global]; RET`) | + +## Bugs Fixed (2026-02-24) + +1. **GetObjectByGUID** (`outline/wow.zig`): Changed from `hook.fastcall(u32, 0x464870, lo, hi)` to inline asm `push hi; push lo; call`. The function reads params from stack `[EBP+8]`/`[EBP+C]` and does `RET 8`. + +2. **lua_pushcclosure** (`main.zig`): Changed address from `0x6F3B80` to `0x6F3920`. The old address pointed into the middle of another function's body. diff --git a/docs/outline-port-notes.md b/docs/outline-port-notes.md new file mode 100644 index 0000000..1a671a8 --- /dev/null +++ b/docs/outline-port-notes.md @@ -0,0 +1,83 @@ +# Outline Port Notes + +Pure Zig reimplementation of the WoW 1.12.1 unit outline system, ported from the Idris C/C++ reference at `/media/storage/projects/idris/dlls`. + +## Hooked Functions + +### Model pipeline (inline hooks via `libs/hook`) + +| Address | Function | Convention | Prologue | Fixups | Notes | +|-------------|-----------------------------------|-------------|----------|--------|-------| +| `0x0070b360` | `CM2SceneRenderDraw` | `__thiscall` | 9 bytes | none | Batch reordering for outline priority | +| `0x00710b90` | `CM2Model_ManageRenderListNode` | `__thiscall` | 6 bytes | none | Classifies models on render-list add | +| `0x0070cb30` | `CM2Scene_DrawBatchProjected` | `__fastcall` | 6 bytes | none | Flags DIP hook for outline rendering | + +**Prologue verification** (Ghidra, WoW.exe 1.12.1 build 5875): + +- `0x0070b360`: `55 8B EC 81 EC 80 00 00 00` — `PUSH EBP; MOV EBP,ESP; SUB ESP,0x80`. Boundaries at +1, +3, +9. The `SUB ESP,0x80` is a 6-byte instruction (81 EC + imm32) spanning offset +3..+9, so 6-byte overwrite is **unsafe** — changed to 9. +- `0x00710b90`: `55 8B EC 8B 45 08` — `PUSH EBP; MOV EBP,ESP; MOV EAX,[EBP+8]`. Boundaries at +1, +3, +6. Clean 6-byte boundary. +- `0x0070cb30`: `55 8B EC 83 EC 10` — `PUSH EBP; MOV EBP,ESP; SUB ESP,0x10`. Boundaries at +1, +3, +6. Clean 6-byte boundary. + +All three use `buildFastcallToCdeclThunk` to bridge to `callconv(.c)` detour functions (since `__thiscall` is `__fastcall` with unused EDX). + +### D3D9 (vtable patching via dummy device) + +| VTable Index | Method | Purpose | +|-------------|---------------------------|---------| +| 42 | `EndScene` | Per-frame object scan, stencil clear | +| 82 | `DrawIndexedPrimitive` | Three-pass stencil outline rendering | +| 16 | `Reset` | Force D24S8 depth/stencil format | + +Vtable obtained by creating a temporary `IDirect3DDevice9` via `Direct3DCreate9` → `CreateDevice` with a hidden window. All D3D9 devices share the same vtable, so patching affects the game's device. + +## Outline Rendering (DIP hook) + +Three-pass stencil approach per outline model: + +1. **Pass 1 — Mark body**: Draw original geometry to stencil buffer (bit 0), no colour write. +2. **Pass 2 — Draw outline**: Screen-space vertex shader expands vertices along normals. Stencil test rejects body pixels. Write outline bit 1. Dead players disable depth test (through-wall); targets/raid marks respect depth. +3. **Pass 3 — Normal draw**: Restore all state, draw model normally on top. + +## Vertex Shader + +Compiled at runtime via `D3DXAssembleShader` from `d3dx9_43.dll` (loaded dynamically). Falls back to no outlines if the DLL isn't present. + +Format: `vs_2_0`, uses WoW's bone matrix constants (`c[idx+31..33]`), view-projection at `c2-c5`, custom constants at `c251` (bone scale) and `c252` (pixel thickness). + +Pixel shader: `ps_3_0`, outputs solid colour from `c0`. + +## Key Offsets + +| Address/Offset | Purpose | +|---------------|---------| +| `0x00B41414` | Object Manager pointer | +| `+0xAC` | First object in linked list | +| `+0xA4` | Base for next-object traversal | +| `+0xC0` | Local player GUID (from ObjMgr) | +| `0x00B71368` | Raid target GUID array (8 × 8 bytes) | +| `0x515970` | `UnitGUID(__fastcall, string_ECX→EAX:EDX)` | +| `0x464870` | `GetObjectByGUID(__stdcall, lo_stack, hi_stack→EAX)` — NOT fastcall! | +| `0x6061E0` | `UnitReaction(__thiscall, player_ECX, unit_stack→int)` | +| model+`0x28` | Direct owner object pointer | +| model+`0x3C0` | Callback owner object pointer | +| ctx+`0x3310` | Model pointer in render context | + +## Category Priority + +1. **Target** (golden amber `#FFC800`) — current target, 2.25px outline +2. **Raid-marked** (per-icon colour) — units with raid icons 1-8, 1.5px +3. **Dead player** (cyan `#00FFFF`) — deceased friendly players, 2.5px, through walls + +## Per-frame Flow + +1. `EndScene` fires → `tracker.scanObjects()` rebuilds GUID tracking → stencil cleared +2. Next frame: `ManageRenderListNode` classifies models → `DrawBatchProj` sets flags → `DIP` does three-pass rendering +3. One-frame latency for tracking updates (imperceptible) + +## Differences from Reference + +- No `std::unordered_set`/`std::unordered_map` — fixed arrays with linear search (max 64 dead GUIDs, 8 raid marks, 256 outline models). +- No `CriticalSection` — all hooks run on the main WoW thread; no synchronisation needed. +- No MinHook — uses the project's existing `libs/hook` inline hook library. +- Object scanning is frame-based (EndScene), not event-driven (no separate Idris runtime thread). +- Shader loading uses dynamic `d3dx9_43.dll` lookup; gracefully disabled if absent. diff --git a/docs/outline-validation.md b/docs/outline-validation.md new file mode 100644 index 0000000..8b9e506 --- /dev/null +++ b/docs/outline-validation.md @@ -0,0 +1,84 @@ +# Outline Validation Checklist + +## Build + +- [ ] `zig build` succeeds with no errors (x86 windows-msvc target) +- [ ] Output DLL present at `zig-out/lib/weirdutils.dll` + +## Existing Feature Regression + +- [ ] DLL loads without crash (inject into WoW 1.12.1) +- [ ] `/wu version` responds correctly +- [ ] `/wu test` calls C function and returns string +- [ ] Screenshot hook works (`/wu ss`, PNG output) +- [ ] Interact/loot bindings work (InteractNearest, LootAllCorpses) +- [ ] Addon loads on login (green "WeirdUtils loaded" message) + +## Outline Subsystem + +### Commands +- [ ] `/wu outline` shows ON/OFF status +- [ ] `/wu outline on` enables outlines +- [ ] `/wu outline off` disables outlines +- [ ] `/wu` help text includes outline commands + +### Dead Player Outlines +- [ ] Dead friendly players in party/raid show cyan outline +- [ ] Outline is visible through walls (no depth test) +- [ ] Outline disappears when player is resurrected +- [ ] Skeleton corpses are NOT outlined + +### Raid Mark Outlines +- [ ] Units with raid icons (Star through Skull) show coloured outlines +- [ ] Each icon has a distinct colour +- [ ] Outlines update when raid marks change +- [ ] Outlines respect terrain occlusion (depth test enabled) + +### Target Outline +- [ ] Current target shows golden amber outline +- [ ] Outline changes when target changes +- [ ] Outline clears when target is deselected +- [ ] Outline respects terrain occlusion + +### Rendering Quality +- [ ] Outlines are clean (no jagged edges or flicker) +- [ ] Outline thickness is consistent across distances +- [ ] Normal model renders correctly on top of outline +- [ ] No visible render state leakage to other models +- [ ] Non-outlined units don't cover outline pixels (stencil test) + +### Performance +- [ ] No noticeable FPS drop in normal gameplay +- [ ] No FPS drop in 40-player raid with multiple outlines +- [ ] Frame rate stable when toggling outlines on/off + +### Edge Cases +- [ ] D3DX DLL not present → outlines gracefully disabled (no crash) +- [ ] No targets tracked → zero overhead (fast path) +- [ ] `/wu outline off` → DIP hook passes through immediately +- [ ] Device Reset (resolution change) → shaders recreated properly + +## Hook Prologue Verification (Ghidra) + +Verified 2026-02-23 using Ghidra MCP against WoW.exe 1.12.1 (build 5875, 4,907,008 bytes). + +### Method + +Raw bytes read from each hook target address via `get_bytes`. Instruction boundaries decoded to confirm the `prologue_size` parameter passed to `hook.prepare()` lands on a clean instruction boundary. A mid-instruction cut would corrupt the trampoline — the copied bytes would decode as a different instruction when followed by the trampoline's JMP. + +### Results + +| Address | Function | Prologue bytes | Decoded | Boundary | Safe | Fixups | +|---------|----------|---------------|---------|----------|------|--------| +| `0x0070b360` | `CM2SceneRenderDraw` | `55 8B EC 81 EC 80 00 00 00` | `PUSH EBP; MOV EBP,ESP; SUB ESP,0x80` | +1,+3,**+9** | 9B | none | +| `0x00710b90` | `CM2Model_ManageRenderListNode` | `55 8B EC 8B 45 08` | `PUSH EBP; MOV EBP,ESP; MOV EAX,[EBP+8]` | +1,+3,**+6** | 6B | none | +| `0x0070cb30` | `CM2Scene_DrawBatchProjected` | `55 8B EC 83 EC 10` | `PUSH EBP; MOV EBP,ESP; SUB ESP,0x10` | +1,+3,**+6** | 6B | none | + +### Fix applied + +`CM2SceneRenderDraw` prologue changed from 6 to **9** bytes in `src/outline/model_hook.zig`. The original 6-byte value would have split the `SUB ESP, 0x80` instruction (opcode `81 EC` + 4-byte immediate, spanning offset +3 to +9). The trampoline would have executed `81 EC 80 E9 xx xx` as `SUB ESP, `, leading to stack corruption and a crash. + +### No other changes needed + +- All three prologues contain only register/memory instructions (no `E8 CALL` or `E9 JMP`), so `rel32_fixups` remains `&.{}`. +- The 4-byte NOP padding (bytes 5-8) at `0x0070b360` after the 5-byte `E9 JMP` is harmless — it is never executed (execution jumps to the detour thunk). diff --git a/src/addon/WeirdUtils.lua b/src/addon/WeirdUtils.lua index 4de6d2f..e0f71c9 100644 --- a/src/addon/WeirdUtils.lua +++ b/src/addon/WeirdUtils.lua @@ -42,6 +42,15 @@ SlashCmdList["WEIRDUTILS"] = function(msg) DEFAULT_CHAT_FRAME:AddMessage("Interact: InteractNearest() and LootAllCorpses() available") DEFAULT_CHAT_FRAME:AddMessage(" Bind in Key Bindings > Interact, or use /run InteractNearest(0)") DEFAULT_CHAT_FRAME:AddMessage(" /run LootAllCorpses() - Loot all nearby corpses") + elseif msg == "outline" or msg == "outline status" then + local on = OutlineCommand() + DEFAULT_CHAT_FRAME:AddMessage("Outlines: " .. (on and "|cff00ff00ON|r" or "|cffff0000OFF|r")) + elseif msg == "outline on" then + OutlineCommand("on") + DEFAULT_CHAT_FRAME:AddMessage("Outlines |cff00ff00enabled|r") + elseif msg == "outline off" then + OutlineCommand("off") + DEFAULT_CHAT_FRAME:AddMessage("Outlines |cffff0000disabled|r") else DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00WeirdUtils|r commands:") DEFAULT_CHAT_FRAME:AddMessage(" /wu version - Show version") @@ -50,5 +59,7 @@ SlashCmdList["WEIRDUTILS"] = function(msg) DEFAULT_CHAT_FRAME:AddMessage(" /wu ss on|off - Enable/disable PNG screenshots") DEFAULT_CHAT_FRAME:AddMessage(" /wu ss 0-9 - Set compression level") DEFAULT_CHAT_FRAME:AddMessage(" /wu interact - Interact/loot info") + DEFAULT_CHAT_FRAME:AddMessage(" /wu outline - Outline status") + DEFAULT_CHAT_FRAME:AddMessage(" /wu outline on|off - Enable/disable outlines") end end diff --git a/src/main.zig b/src/main.zig index e0dd933..e66172b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,6 +2,7 @@ const std = @import("std"); const hook = @import("hook"); const screenshot = @import("screenshot.zig"); const interact = @import("interact.zig"); +const outline = @import("outline/api.zig"); const WINAPI = std.builtin.CallingConvention.winapi; const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} }; @@ -99,7 +100,7 @@ pub const lua = struct { } pub fn pushcclosure(L: State, func: usize, n: i32) void { - const f: *const fn (State, usize, i32) callconv(fc) void = @ptrFromInt(0x6F3B80); + const f: *const fn (State, usize, i32) callconv(fc) void = @ptrFromInt(0x6F3920); f(L, func, n); } @@ -239,6 +240,7 @@ fn registerLuaFunctions() void { registerFunction("WeirdUtilsScreenshot", @intFromPtr(&screenshot.screenshotCommand)); registerFunction("InteractNearest", @intFromPtr(&interact.interactNearest)); registerFunction("LootAllCorpses", @intFromPtr(&interact.lootAllCorpses)); + registerFunction("OutlineCommand", @intFromPtr(&outline.outlineCommand)); } // ============================================================================= @@ -452,6 +454,7 @@ fn engineInitDetour() callconv(sc) void { const orig = engine_init_hook.getTrampoline(*const fn () callconv(sc) void); orig(); screenshot.installHook(); + _ = outline.init(); } // ============================================================================= @@ -503,6 +506,7 @@ fn install() void { fn uninstall() void { shutdown_hook.remove(); engine_init_hook.remove(); + outline.cleanup(); screenshot.removeHook(); interact.removeHooks(); load_addons_hook.remove(); diff --git a/src/outline/api.zig b/src/outline/api.zig new file mode 100644 index 0000000..36809d2 --- /dev/null +++ b/src/outline/api.zig @@ -0,0 +1,71 @@ +//! Public API for the outline subsystem. +//! +//! Exposes init/cleanup/config functions called from main.zig, +//! and a Lua C callback for `/wu outline` commands. + +const hook = @import("hook"); +const tracker = @import("tracker.zig"); +const model_hook = @import("model_hook.zig"); +const d3d9_hook = @import("d3d9_hook.zig"); + +/// Install all outline hooks (model pipeline + D3D9 vtable). +/// Called from main.zig during DLL_PROCESS_ATTACH or engine init. +pub fn init() bool { + if (!model_hook.installHooks()) return false; + if (!d3d9_hook.installHooks()) { + model_hook.removeHooks(); + return false; + } + return true; +} + +/// Remove all outline hooks. Called during DLL_PROCESS_DETACH. +pub fn cleanup() void { + d3d9_hook.removeHooks(); + model_hook.removeHooks(); +} + +/// Enable or disable outline rendering. +pub fn setEnabled(on: bool) void { + tracker.enabled = on; +} + +/// Check if outlines are enabled. +pub fn isEnabled() bool { + return tracker.enabled; +} + +/// Lua C callback for outline commands. +/// +/// Usage from Lua: +/// OutlineCommand() → returns (enabled: bool) +/// OutlineCommand("on") → enable outlines +/// OutlineCommand("off") → disable outlines +pub fn outlineCommand(L: *anyopaque) callconv(.c) u32 { + const nargs = hook.fastcall(i32, 0x6F3070, @intFromPtr(L), @as(u32, 0)); // lua_gettop + + if (nargs >= 1) { + // Check if first arg is a string + const is_str = hook.fastcall(u32, 0x6F3510, @intFromPtr(L), @as(u32, 1)); // lua_isstring + if (is_str != 0) { + const str_ptr = hook.fastcall(u32, 0x6F3690, @intFromPtr(L), @as(u32, 1)); // lua_tostring + if (str_ptr != 0) { + const s: [*:0]const u8 = @ptrFromInt(str_ptr); + const span = @import("std").mem.span(s); + if (eql(span, "on") or eql(span, "enable")) { + setEnabled(true); + } else if (eql(span, "off") or eql(span, "disable")) { + setEnabled(false); + } + } + } + } + + // Push current state as boolean + hook.fastcall(void, 0x6F39F0, @intFromPtr(L), @as(u32, if (isEnabled()) 1 else 0)); // lua_pushboolean + return 1; +} + +fn eql(a: []const u8, b: []const u8) bool { + return @import("std").mem.eql(u8, a, b); +} diff --git a/src/outline/d3d9_hook.zig b/src/outline/d3d9_hook.zig new file mode 100644 index 0000000..6060801 --- /dev/null +++ b/src/outline/d3d9_hook.zig @@ -0,0 +1,662 @@ +//! D3D9 vtable hooks for outline rendering. +//! +//! Patches IDirect3DDevice9 vtable entries for EndScene, DrawIndexedPrimitive, +//! and Reset via the dummy-device technique (shared vtable across all devices). +//! +//! - **EndScene**: per-frame object scan, stencil clear, shader creation. +//! - **DIP**: three-pass stencil-based outline when flagged by DrawBatchProj. +//! - **Reset**: forces D24S8 depth/stencil format for 8 stencil bits. + +const std = @import("std"); +const hook = @import("hook"); +const types = @import("types.zig"); +const tracker = @import("tracker.zig"); +const model_hook = @import("model_hook.zig"); + +const WINAPI = std.builtin.CallingConvention.winapi; +const sc: std.builtin.CallingConvention = .{ .x86_stdcall = .{} }; + +// ============================================================================= +// Windows / D3D9 externs +// ============================================================================= + +extern "kernel32" fn LoadLibraryA(name: [*:0]const u8) callconv(WINAPI) ?*anyopaque; +extern "kernel32" fn GetProcAddress(module: *anyopaque, name: [*:0]const u8) callconv(WINAPI) ?*anyopaque; +extern "kernel32" fn GetModuleHandleA(name: ?[*:0]const u8) callconv(WINAPI) ?*anyopaque; +extern "user32" fn RegisterClassExA(wc: *const types.WNDCLASSEXA) callconv(WINAPI) u16; +extern "user32" fn CreateWindowExA( + exStyle: u32, + cls: [*:0]const u8, + title: [*:0]const u8, + style: u32, + x: i32, + y: i32, + w: i32, + h: i32, + parent: ?*anyopaque, + menu: ?*anyopaque, + inst: ?*anyopaque, + param: ?*anyopaque, +) callconv(WINAPI) ?*anyopaque; +extern "user32" fn DestroyWindow(hwnd: *anyopaque) callconv(WINAPI) i32; +extern "user32" fn UnregisterClassA(name: [*:0]const u8, inst: ?*anyopaque) callconv(WINAPI) i32; +extern "user32" fn DefWindowProcA(hwnd: ?*anyopaque, msg: u32, wp: usize, lp: usize) callconv(WINAPI) usize; + +// ============================================================================= +// COM helper: read vtable pointer, call method by index +// ============================================================================= + +inline fn vt(obj: *anyopaque) [*]usize { + return @ptrFromInt(hook.readMem(u32, @intFromPtr(obj))); +} + +/// Call a COM method that takes only (self) and returns HRESULT. +fn comCall0(obj: *anyopaque, idx: usize) i32 { + const f: *const fn (*anyopaque) callconv(sc) i32 = @ptrFromInt(vt(obj)[idx]); + return f(obj); +} + +/// Release a COM object. +fn comRelease(obj: *anyopaque) void { + _ = comCall0(obj, types.VT.Release); +} + +// ============================================================================= +// Saved original vtable function pointers +// ============================================================================= + +var orig_endscene: usize = 0; +var orig_dip: usize = 0; +var orig_reset: usize = 0; +var d3d9_vtable: ?[*]usize = null; +var hooks_installed: bool = false; + +// ============================================================================= +// Outline shader resources (created on first use) +// ============================================================================= + +var outline_vs: ?*anyopaque = null; // IDirect3DVertexShader9* +var outline_ps: ?*anyopaque = null; // IDirect3DPixelShader9* +var outline_decl: ?*anyopaque = null; // IDirect3DVertexDeclaration9* +var shaders_attempted: bool = false; + +// D3DXAssembleShader function pointer (loaded dynamically) +const D3DXAssembleShaderFn = *const fn ( + [*]const u8, // pSrcData + u32, // SrcDataLen + ?*anyopaque, // pDefines + ?*anyopaque, // pInclude + u32, // Flags + *?*anyopaque, // ppShader (ID3DXBuffer**) + *?*anyopaque, // ppErrorMsgs (ID3DXBuffer**) +) callconv(sc) i32; + +// ============================================================================= +// EndScene hook +// ============================================================================= + +fn hkEndScene(device: *anyopaque) callconv(sc) i32 { + // Per-frame: scan objects for outline tracking + tracker.scanObjects(); + + // Clear stencil for next frame + if (tracker.enabled and tracker.hasTargets()) { + // IDirect3DDevice9::Clear(0, NULL, D3DCLEAR_STENCIL, 0, 1.0, 0) + deviceClear(device, types.D3DCLEAR_STENCIL); + } + + // Reset per-frame flags + model_hook.test_outline_stencil = false; + model_hook.rendering_unit = false; + model_hook.rendering_outline = false; + model_hook.current_model = 0; + + // Call original EndScene + const f: *const fn (*anyopaque) callconv(sc) i32 = @ptrFromInt(orig_endscene); + return f(device); +} + +// ============================================================================= +// Reset hook — force D24S8 depth/stencil format +// ============================================================================= + +fn hkReset(device: *anyopaque, pp: *types.D3DPRESENT_PARAMETERS) callconv(sc) i32 { + // If auto depth-stencil is enabled, force D24S8 format + if (pp.EnableAutoDepthStencil != 0) { + const fmt = pp.AutoDepthStencilFormat; + if (fmt != types.D3DFMT_D24S8 and fmt != types.D3DFMT_D24FS8 and + fmt != types.D3DFMT_D24X4S4 and fmt != types.D3DFMT_D15S1) + { + pp.AutoDepthStencilFormat = types.D3DFMT_D24S8; + } + } + + // Release shaders (device state lost on reset) + releaseShaders(); + + const f: *const fn (*anyopaque, *types.D3DPRESENT_PARAMETERS) callconv(sc) i32 = @ptrFromInt(orig_reset); + return f(device, pp); +} + +// ============================================================================= +// DrawIndexedPrimitive hook — three-pass outline rendering +// ============================================================================= + +fn hkDIP( + device: *anyopaque, + prim_type: u32, + base_vtx: i32, + min_vtx: u32, + num_verts: u32, + start_idx: u32, + prim_count: u32, +) callconv(sc) i32 { + const OrigDIP = *const fn (*anyopaque, u32, i32, u32, u32, u32, u32) callconv(sc) i32; + const origFn: OrigDIP = @ptrFromInt(orig_dip); + + // ---- Outline rendering path ---- + if (model_hook.rendering_outline) { + const model_ptr = model_hook.current_model; + const color = tracker.getModelColor(model_ptr) orelse + return origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count); + const category = tracker.getModelCategory(model_ptr); + + // Try to ensure outline shaders exist + if (!shaders_attempted) ensureShaders(device); + + // If shaders are not available, render normally + if (outline_vs == null or outline_ps == null) { + return origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count); + } + + // --- Save render state --- + const saved_vs = deviceGetPtr(device, types.VT.GetVertexShader); + const saved_ps = deviceGetPtr(device, types.VT.GetPixelShader); + const saved_decl = deviceGetPtr(device, types.VT.GetVertexDeclaration); + const saved_cull = deviceGetRS(device, types.D3DRS.CULLMODE); + const saved_ablend = deviceGetRS(device, types.D3DRS.ALPHABLENDENABLE); + const saved_zenable = deviceGetRS(device, types.D3DRS.ZENABLE); + const saved_zfunc = deviceGetRS(device, types.D3DRS.ZFUNC); + const saved_colorwr = deviceGetRS(device, types.D3DRS.COLORWRITEENABLE); + const saved_stencil_en = deviceGetRS(device, types.D3DRS.STENCILENABLE); + const saved_stencil_fn = deviceGetRS(device, types.D3DRS.STENCILFUNC); + const saved_stencil_ref = deviceGetRS(device, types.D3DRS.STENCILREF); + const saved_stencil_mask = deviceGetRS(device, types.D3DRS.STENCILMASK); + const saved_stencil_wmask = deviceGetRS(device, types.D3DRS.STENCILWRITEMASK); + const saved_stencil_pass = deviceGetRS(device, types.D3DRS.STENCILPASS); + + // Save VS constants c251-c252 (we overwrite these) + var saved_c251: [4]f32 = undefined; + var saved_c252: [4]f32 = undefined; + deviceGetVSConstF(device, 251, &saved_c251); + deviceGetVSConstF(device, 252, &saved_c252); + + // Save PS constant c0 (we overwrite for outline color) + var saved_ps_c0: [4]f32 = undefined; + deviceGetPSConstF(device, 0, &saved_ps_c0); + + // ========== PASS 1: Mark body in stencil (no color write) ========== + deviceSetRS(device, types.D3DRS.STENCILENABLE, 1); + deviceSetRS(device, types.D3DRS.STENCILFUNC, types.D3DCMP_ALWAYS); + deviceSetRS(device, types.D3DRS.STENCILREF, types.STENCIL_BIT_BODY); + deviceSetRS(device, types.D3DRS.STENCILMASK, 0xFF); + deviceSetRS(device, types.D3DRS.STENCILWRITEMASK, types.STENCIL_BIT_BODY); + deviceSetRS(device, types.D3DRS.STENCILPASS, types.D3DSTENCILOP_REPLACE); + deviceSetRS(device, types.D3DRS.STENCILFAIL, types.D3DSTENCILOP_KEEP); + deviceSetRS(device, types.D3DRS.STENCILZFAIL, types.D3DSTENCILOP_KEEP); + deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, 0); // stencil only + _ = origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count); + deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, 0x0F); + + // ========== PASS 2: Draw enlarged outline ========== + // Set outline shaders + deviceSetPtr(device, types.VT.SetVertexShader, outline_vs.?); + if (outline_decl) |d| deviceSetPtr(device, types.VT.SetVertexDeclaration, d); + deviceSetPtr(device, types.VT.SetPixelShader, outline_ps.?); + + // Cull front faces → only back faces of the enlarged model visible + deviceSetRS(device, types.D3DRS.CULLMODE, types.D3DCULL_CCW); + deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 0); + + // Stencil: draw only where body bit is NOT set, write outline bit + deviceSetRS(device, types.D3DRS.STENCILENABLE, 1); + deviceSetRS(device, types.D3DRS.STENCILFUNC, types.D3DCMP_EQUAL); + deviceSetRS(device, types.D3DRS.STENCILREF, types.STENCIL_BIT_OUTLINE); + deviceSetRS(device, types.D3DRS.STENCILMASK, types.STENCIL_BIT_BODY); + deviceSetRS(device, types.D3DRS.STENCILWRITEMASK, types.STENCIL_BIT_OUTLINE); + deviceSetRS(device, types.D3DRS.STENCILPASS, types.D3DSTENCILOP_REPLACE); + deviceSetRS(device, types.D3DRS.STENCILFAIL, types.D3DSTENCILOP_KEEP); + deviceSetRS(device, types.D3DRS.STENCILZFAIL, types.D3DSTENCILOP_KEEP); + + // Depth: dead players through walls (no Z), others respect depth + if (category == .dead_player) { + deviceSetRS(device, types.D3DRS.ZENABLE, types.D3DZB_FALSE); + } else { + deviceSetRS(device, types.D3DRS.ZENABLE, types.D3DZB_TRUE); + deviceSetRS(device, types.D3DRS.ZFUNC, types.D3DCMP_LESSEQUAL); + } + + // Set VS constants: c251 = (765, 1, 0, 0), c252 = pixel scale + const c251 = [4]f32{ 765.0, 1.0, 0.0, 0.0 }; + deviceSetVSConstF(device, 251, &c251); + + var vp: types.D3DVIEWPORT9 = .{}; + deviceGetViewport(device, &vp); + const pixels = tracker.getOutlinePixels(category); + const c252 = [4]f32{ + pixels * 2.0 / @as(f32, @floatFromInt(@max(vp.Width, 1))), + pixels * 2.0 / @as(f32, @floatFromInt(@max(vp.Height, 1))), + 0.0, + 0.0, + }; + deviceSetVSConstF(device, 252, &c252); + + // PS c0 = outline colour (ARGB → float4 RGBA) + const ps_color = [4]f32{ + @as(f32, @floatFromInt((color >> 16) & 0xFF)) / 255.0, + @as(f32, @floatFromInt((color >> 8) & 0xFF)) / 255.0, + @as(f32, @floatFromInt(color & 0xFF)) / 255.0, + @as(f32, @floatFromInt((color >> 24) & 0xFF)) / 255.0, + }; + deviceSetPSConstF(device, 0, &ps_color); + + _ = origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count); + + // ========== PASS 3: Restore state, draw normal model on top ========== + deviceSetPtrOrNull(device, types.VT.SetVertexShader, saved_vs); + deviceSetPtrOrNull(device, types.VT.SetVertexDeclaration, saved_decl); + deviceSetPtrOrNull(device, types.VT.SetPixelShader, saved_ps); + deviceSetRS(device, types.D3DRS.CULLMODE, saved_cull); + deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, saved_ablend); + deviceSetRS(device, types.D3DRS.ZENABLE, saved_zenable); + deviceSetRS(device, types.D3DRS.ZFUNC, saved_zfunc); + deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, saved_colorwr); + deviceSetRS(device, types.D3DRS.STENCILENABLE, saved_stencil_en); + deviceSetRS(device, types.D3DRS.STENCILFUNC, saved_stencil_fn); + deviceSetRS(device, types.D3DRS.STENCILREF, saved_stencil_ref); + deviceSetRS(device, types.D3DRS.STENCILMASK, saved_stencil_mask); + deviceSetRS(device, types.D3DRS.STENCILWRITEMASK, saved_stencil_wmask); + deviceSetRS(device, types.D3DRS.STENCILPASS, saved_stencil_pass); + deviceSetVSConstF(device, 251, &saved_c251); + deviceSetVSConstF(device, 252, &saved_c252); + deviceSetPSConstF(device, 0, &saved_ps_c0); + + if (saved_vs) |p| comRelease(p); + if (saved_ps) |p| comRelease(p); + if (saved_decl) |p| comRelease(p); + + return origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count); + } + + // ---- Stencil-test path for non-outline units (prevent covering outlines) ---- + if (model_hook.test_outline_stencil and model_hook.rendering_unit) { + const saved_en = deviceGetRS(device, types.D3DRS.STENCILENABLE); + const saved_fn2 = deviceGetRS(device, types.D3DRS.STENCILFUNC); + const saved_ref2 = deviceGetRS(device, types.D3DRS.STENCILREF); + const saved_mask2 = deviceGetRS(device, types.D3DRS.STENCILMASK); + const saved_pass2 = deviceGetRS(device, types.D3DRS.STENCILPASS); + + deviceSetRS(device, types.D3DRS.STENCILENABLE, 1); + deviceSetRS(device, types.D3DRS.STENCILFUNC, types.D3DCMP_EQUAL); + deviceSetRS(device, types.D3DRS.STENCILREF, 0x00); + deviceSetRS(device, types.D3DRS.STENCILMASK, types.STENCIL_BIT_OUTLINE); + deviceSetRS(device, types.D3DRS.STENCILPASS, types.D3DSTENCILOP_KEEP); + deviceSetRS(device, types.D3DRS.STENCILFAIL, types.D3DSTENCILOP_KEEP); + deviceSetRS(device, types.D3DRS.STENCILZFAIL, types.D3DSTENCILOP_KEEP); + + const hr = origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count); + + deviceSetRS(device, types.D3DRS.STENCILENABLE, saved_en); + deviceSetRS(device, types.D3DRS.STENCILFUNC, saved_fn2); + deviceSetRS(device, types.D3DRS.STENCILREF, saved_ref2); + deviceSetRS(device, types.D3DRS.STENCILMASK, saved_mask2); + deviceSetRS(device, types.D3DRS.STENCILPASS, saved_pass2); + return hr; + } + + // ---- Normal path ---- + return origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count); +} + +// ============================================================================= +// Device helper wrappers (stdcall COM vtable calls) +// ============================================================================= + +fn deviceSetRS(dev: *anyopaque, state: u32, value: u32) void { + const f: *const fn (*anyopaque, u32, u32) callconv(sc) i32 = @ptrFromInt(vt(dev)[types.VT.SetRenderState]); + _ = f(dev, state, value); +} + +fn deviceGetRS(dev: *anyopaque, state: u32) u32 { + var val: u32 = 0; + const f: *const fn (*anyopaque, u32, *u32) callconv(sc) i32 = @ptrFromInt(vt(dev)[types.VT.GetRenderState]); + _ = f(dev, state, &val); + return val; +} + +fn deviceSetPtr(dev: *anyopaque, idx: usize, ptr: *anyopaque) void { + const f: *const fn (*anyopaque, *anyopaque) callconv(sc) i32 = @ptrFromInt(vt(dev)[idx]); + _ = f(dev, ptr); +} + +fn deviceGetPtr(dev: *anyopaque, idx: usize) ?*anyopaque { + var ptr: ?*anyopaque = null; + const f: *const fn (*anyopaque, *?*anyopaque) callconv(sc) i32 = @ptrFromInt(vt(dev)[idx]); + _ = f(dev, &ptr); + return ptr; +} + +/// Set a COM pointer, handling the null case by passing 0 via raw write. +fn deviceSetPtrOrNull(dev: *anyopaque, idx: usize, ptr: ?*anyopaque) void { + if (ptr) |p| { + deviceSetPtr(dev, idx, p); + } else { + // COM method expects a pointer param; pass NULL (0) via asm to avoid + // Zig's "no null in *anyopaque" constraint. + const func_addr = vt(dev)[idx]; + asm volatile ( + \\push $0 + \\push %[self] + \\call *%[func] + : + : [self] "r" (@intFromPtr(dev)), + [func] "r" (func_addr), + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } + ); + } +} + +fn deviceSetVSConstF(dev: *anyopaque, start: u32, data: *const [4]f32) void { + const f: *const fn (*anyopaque, u32, *const [4]f32, u32) callconv(sc) i32 = + @ptrFromInt(vt(dev)[types.VT.SetVertexShaderConstantF]); + _ = f(dev, start, data, 1); +} + +fn deviceGetVSConstF(dev: *anyopaque, start: u32, data: *[4]f32) void { + const f: *const fn (*anyopaque, u32, *[4]f32, u32) callconv(sc) i32 = + @ptrFromInt(vt(dev)[types.VT.GetVertexShaderConstantF]); + _ = f(dev, start, data, 1); +} + +fn deviceSetPSConstF(dev: *anyopaque, start: u32, data: *const [4]f32) void { + const f: *const fn (*anyopaque, u32, *const [4]f32, u32) callconv(sc) i32 = + @ptrFromInt(vt(dev)[types.VT.SetPixelShaderConstantF]); + _ = f(dev, start, data, 1); +} + +fn deviceGetPSConstF(dev: *anyopaque, start: u32, data: *[4]f32) void { + const f: *const fn (*anyopaque, u32, *[4]f32, u32) callconv(sc) i32 = + @ptrFromInt(vt(dev)[types.VT.GetPixelShaderConstantF]); + _ = f(dev, start, data, 1); +} + +fn deviceGetViewport(dev: *anyopaque, vp: *types.D3DVIEWPORT9) void { + const f: *const fn (*anyopaque, *types.D3DVIEWPORT9) callconv(sc) i32 = + @ptrFromInt(vt(dev)[types.VT.GetViewport]); + _ = f(dev, vp); +} + +fn deviceClear(dev: *anyopaque, flags: u32) void { + // Clear(count, rects, flags, color, z, stencil) + const f: *const fn (*anyopaque, u32, ?*anyopaque, u32, u32, f32, u32) callconv(sc) i32 = + @ptrFromInt(vt(dev)[types.VT.Clear]); + _ = f(dev, 0, null, flags, 0, 1.0, 0); +} + +// ============================================================================= +// Shader creation (loaded dynamically from d3dx9_43.dll) +// ============================================================================= + +fn ensureShaders(device: *anyopaque) void { + shaders_attempted = true; + + // Load D3DX9 for shader assembly + const d3dx = LoadLibraryA("d3dx9_43.dll") orelse + LoadLibraryA("d3dx9_42.dll") orelse + LoadLibraryA("d3dx9_41.dll") orelse return; + const assemble_ptr = GetProcAddress(d3dx, "D3DXAssembleShader") orelse return; + const assemble: D3DXAssembleShaderFn = @ptrCast(assemble_ptr); + + // --- Vertex shader: screen-space normal extrusion with bone matrices --- + const vs_src = + "vs_2_0\n" ++ + "dcl_position v0\n" ++ + "dcl_blendweight v2\n" ++ + "dcl_blendindices v3\n" ++ + "dcl_normal v1\n" ++ + "mul r0.xyz, v3.zyxw, c251.x\n" ++ + "mova a0.xyz, r0\n" ++ + "mul r0, v2.y, c[a0.y + 31]\n" ++ + "mad r0, c[a0.x + 31], v2.z, r0\n" ++ + "mad r0, c[a0.z + 31], v2.x, r0\n" ++ + "dp3 r3.x, r0, v1\n" ++ + "dp4 r4.x, r0, v0\n" ++ + "mul r1, v2.y, c[a0.y + 32]\n" ++ + "mad r1, c[a0.x + 32], v2.z, r1\n" ++ + "mad r1, c[a0.z + 32], v2.x, r1\n" ++ + "dp3 r3.y, r1, v1\n" ++ + "dp4 r4.y, r1, v0\n" ++ + "mul r2, v2.y, c[a0.y + 33]\n" ++ + "mad r2, c[a0.x + 33], v2.z, r2\n" ++ + "mad r2, c[a0.z + 33], v2.x, r2\n" ++ + "dp3 r3.z, r2, v1\n" ++ + "dp4 r4.z, r2, v0\n" ++ + "mov r4.w, c251.y\n" ++ + "nrm r5.xyz, r3\n" ++ + "dp4 r6.x, c2, r4\n" ++ + "dp4 r6.y, c3, r4\n" ++ + "dp4 r6.z, c4, r4\n" ++ + "dp4 r6.w, c5, r4\n" ++ + "mov r5.w, c251.z\n" ++ + "dp4 r7.x, c2, r5\n" ++ + "dp4 r7.y, c3, r5\n" ++ + "mul r8.x, r7.x, r7.x\n" ++ + "mad r8.x, r7.y, r7.y, r8.x\n" ++ + "rsq r8.x, r8.x\n" ++ + "mul r7.xy, r7.xy, r8.xx\n" ++ + "mul r8.xy, r7.xy, c252.xy\n" ++ + "mul r8.xy, r8.xy, r6.ww\n" ++ + "add r6.xy, r6.xy, r8.xy\n" ++ + "mov oPos, r6\n"; + + var vs_code: ?*anyopaque = null; + var vs_err: ?*anyopaque = null; + if (assemble(vs_src, vs_src.len, null, null, 0, &vs_code, &vs_err) < 0 or vs_code == null) { + if (vs_err) |e| comRelease(e); + return; + } + defer comRelease(vs_code.?); + if (vs_err) |e| comRelease(e); + + // Create vertex shader from compiled bytecode + { + // ID3DXBuffer::GetBufferPointer is vtable[3], GetBufferSize is vtable[4] + const buf_ptr: *anyopaque = @ptrFromInt( + @as(*const fn (*anyopaque) callconv(sc) usize, @ptrFromInt(vt(vs_code.?)[3]))(vs_code.?), + ); + // CreateVertexShader(pFunction, ppShader) + const create: *const fn (*anyopaque, *anyopaque, *?*anyopaque) callconv(sc) i32 = + @ptrFromInt(vt(device)[types.VT.CreateVertexShader]); + if (create(device, buf_ptr, &outline_vs) < 0) return; + } + + // --- Pixel shader: solid color from PS constant c0 --- + const ps_src = "ps_3_0\nmov oC0, c0\n"; + var ps_code: ?*anyopaque = null; + var ps_err: ?*anyopaque = null; + if (assemble(ps_src, ps_src.len, null, null, 0, &ps_code, &ps_err) < 0 or ps_code == null) { + if (ps_err) |e| comRelease(e); + releaseShaders(); + return; + } + defer comRelease(ps_code.?); + if (ps_err) |e| comRelease(e); + + { + const buf_ptr: *anyopaque = @ptrFromInt( + @as(*const fn (*anyopaque) callconv(sc) usize, @ptrFromInt(vt(ps_code.?)[3]))(ps_code.?), + ); + const create: *const fn (*anyopaque, *anyopaque, *?*anyopaque) callconv(sc) i32 = + @ptrFromInt(vt(device)[types.VT.CreatePixelShader]); + if (create(device, buf_ptr, &outline_ps) < 0) { + releaseShaders(); + return; + } + } + + // --- Vertex declaration matching WoW M2 format --- + // Position (float3) @ 0, BlendWeight (D3DCOLOR) @ 12, BlendIndices (D3DCOLOR) @ 16, Normal (float3) @ 20 + const D3DDECL_END = [_]u8{ 0xFF, 0, 0, 0, 0, 0, 0, 0 }; + const decl_bytes = [_]u8{ + // stream=0, offset=0, type=2 (FLOAT3), method=0, usage=0 (POSITION), usageIndex=0 + 0, 0, 0, 0, 2, 0, 0, 0, + // stream=0, offset=12, type=4 (D3DCOLOR), method=0, usage=1 (BLENDWEIGHT), usageIndex=0 + 0, 0, 12, 0, 4, 0, 1, 0, + // stream=0, offset=16, type=4 (D3DCOLOR), method=0, usage=2 (BLENDINDICES), usageIndex=0 + 0, 0, 16, 0, 4, 0, 2, 0, + // stream=0, offset=20, type=2 (FLOAT3), method=0, usage=3 (NORMAL), usageIndex=0 + 0, 0, 20, 0, 2, 0, 3, 0, + } ++ D3DDECL_END; + + const create_decl: *const fn (*anyopaque, *const anyopaque, *?*anyopaque) callconv(sc) i32 = + @ptrFromInt(vt(device)[types.VT.CreateVertexDeclaration]); + if (create_decl(device, @ptrCast(&decl_bytes), &outline_decl) < 0) { + releaseShaders(); + return; + } +} + +fn releaseShaders() void { + if (outline_vs) |p| { + comRelease(p); + outline_vs = null; + } + if (outline_ps) |p| { + comRelease(p); + outline_ps = null; + } + if (outline_decl) |p| { + comRelease(p); + outline_decl = null; + } + shaders_attempted = false; +} + +// ============================================================================= +// Vtable patching helpers +// ============================================================================= + +extern "kernel32" fn VirtualProtect( + addr: *anyopaque, + size: usize, + new_prot: u32, + old_prot: *u32, +) callconv(WINAPI) i32; + +fn patchVtableEntry(vtable_ptr: [*]usize, idx: usize, new_fn: usize, old_fn: *usize) bool { + old_fn.* = vtable_ptr[idx]; + var old_prot: u32 = 0; + const addr: *anyopaque = @ptrFromInt(@intFromPtr(&vtable_ptr[idx])); + if (VirtualProtect(addr, @sizeOf(usize), 0x40, &old_prot) == 0) + return false; + vtable_ptr[idx] = new_fn; + _ = VirtualProtect(addr, @sizeOf(usize), old_prot, &old_prot); + return true; +} + +fn restoreVtableEntry(vtable_ptr: [*]usize, idx: usize, old_fn: usize) void { + var old_prot: u32 = 0; + const addr: *anyopaque = @ptrFromInt(@intFromPtr(&vtable_ptr[idx])); + if (VirtualProtect(addr, @sizeOf(usize), 0x40, &old_prot) == 0) + return; + vtable_ptr[idx] = old_fn; + _ = VirtualProtect(addr, @sizeOf(usize), old_prot, &old_prot); +} + +// ============================================================================= +// D3D9 vtable discovery via dummy device +// ============================================================================= + +fn getD3D9VTable() ?[*]usize { + const d3d9_mod = LoadLibraryA("d3d9.dll") orelse return null; + const create9_raw = GetProcAddress(d3d9_mod, "Direct3DCreate9") orelse return null; + + // Direct3DCreate9(D3D_SDK_VERSION=32) → IDirect3D9* + const Direct3DCreate9: *const fn (u32) callconv(sc) ?*anyopaque = @ptrCast(create9_raw); + const pD3D = Direct3DCreate9(32) orelse return null; + + // Register dummy window class + const inst = GetModuleHandleA(null); + var wc = types.WNDCLASSEXA{}; + wc.lpfnWndProc = &DefWindowProcA; + wc.hInstance = inst; + wc.lpszClassName = "WU_DummyD3D9"; + _ = RegisterClassExA(&wc); + + const hwnd = CreateWindowExA(0, "WU_DummyD3D9", "D", 0, 0, 0, 100, 100, null, null, inst, null) orelse { + comRelease(pD3D); + return null; + }; + + // IDirect3D9::CreateDevice — vtable[16] + // (self, Adapter, DeviceType, hFocusWindow, BehaviorFlags, pPP, ppDevice) + var pp = types.D3DPRESENT_PARAMETERS{}; + pp.Windowed = 1; + pp.SwapEffect = 1; // D3DSWAPEFFECT_DISCARD + pp.hDeviceWindow = @intFromPtr(hwnd); + + var pDevice: ?*anyopaque = null; + const createDev: *const fn (*anyopaque, u32, u32, *anyopaque, u32, *types.D3DPRESENT_PARAMETERS, *?*anyopaque) callconv(sc) i32 = + @ptrFromInt(vt(pD3D)[16]); + const hr = createDev(pD3D, 0, 1, hwnd, 0x20, &pp, &pDevice); // HAL, SOFTWARE_VERTEXPROCESSING + + if (hr < 0 or pDevice == null) { + comRelease(pD3D); + _ = DestroyWindow(hwnd); + _ = UnregisterClassA("WU_DummyD3D9", inst); + return null; + } + + // Read vtable — shared across all IDirect3DDevice9 instances + const vtable_ptr: [*]usize = @ptrFromInt(hook.readMem(u32, @intFromPtr(pDevice.?))); + + // Cleanup dummy objects + comRelease(pDevice.?); + comRelease(pD3D); + _ = DestroyWindow(hwnd); + _ = UnregisterClassA("WU_DummyD3D9", inst); + + return vtable_ptr; +} + +// ============================================================================= +// Install / Remove +// ============================================================================= + +pub fn installHooks() bool { + if (hooks_installed) return true; + + const vtable_ptr = getD3D9VTable() orelse return false; + d3d9_vtable = vtable_ptr; + + if (!patchVtableEntry(vtable_ptr, types.VT.EndScene, @intFromPtr(&hkEndScene), &orig_endscene)) return false; + if (!patchVtableEntry(vtable_ptr, types.VT.DrawIndexedPrimitive, @intFromPtr(&hkDIP), &orig_dip)) return false; + if (!patchVtableEntry(vtable_ptr, types.VT.Reset, @intFromPtr(&hkReset), &orig_reset)) return false; + + hooks_installed = true; + return true; +} + +pub fn removeHooks() void { + if (!hooks_installed) return; + + releaseShaders(); + + if (d3d9_vtable) |vtbl| { + if (orig_reset != 0) restoreVtableEntry(vtbl, types.VT.Reset, orig_reset); + if (orig_dip != 0) restoreVtableEntry(vtbl, types.VT.DrawIndexedPrimitive, orig_dip); + if (orig_endscene != 0) restoreVtableEntry(vtbl, types.VT.EndScene, orig_endscene); + } + + hooks_installed = false; +} diff --git a/src/outline/model_hook.zig b/src/outline/model_hook.zig new file mode 100644 index 0000000..e810432 --- /dev/null +++ b/src/outline/model_hook.zig @@ -0,0 +1,238 @@ +//! WoW model rendering pipeline hooks. +//! +//! Hooks three WoW functions to integrate outline rendering: +//! - CM2SceneRenderDraw — reorders batches so outline targets render first. +//! - CM2Model_ManageRenderListNode — classifies models on render-list add. +//! - CM2Scene_DrawBatchProjected — flags the DIP hook for outline rendering. + +const hook = @import("hook"); +const o = @import("offsets.zig"); +const types = @import("types.zig"); +const tracker = @import("tracker.zig"); +const wow = @import("wow.zig"); + +// ============================================================================= +// Hook state +// ============================================================================= + +var render_draw_hook: hook.Hook = .{}; +var manage_render_hook: hook.Hook = .{}; +var draw_batch_hook: hook.Hook = .{}; + +// ============================================================================= +// Volatile flags shared with d3d9_hook (read by DIP hook) +// ============================================================================= + +/// True while DrawBatchProj is rendering an outline-target model. +pub var rendering_outline: bool = false; + +/// Model pointer currently being rendered (for colour lookup in DIP). +pub var current_model: u32 = 0; + +/// Set after outline targets render; tells DIP to apply stencil test on +/// subsequent unit batches so outlines aren't covered by nearby players. +pub var test_outline_stencil: bool = false; + +/// True while the current DrawBatchProj batch is a unit (player/NPC). +pub var rendering_unit: bool = false; + +// ============================================================================= +// Batch reordering limits +// ============================================================================= + +const MAX_REORDER = 1024; +var reordered_indices: [MAX_REORDER]i32 = undefined; + +// ============================================================================= +// CM2SceneRenderDraw hook +// ============================================================================= +// __thiscall(this, viewMatrix, batchData, batchIndices, batchCount) +// Thunked to __cdecl(ecx_this, edx_unused, viewMatrix, batchData, batchIndices, batchCount) + +fn renderDrawDetour(this: u32, _edx: u32, view_matrix: u32, batch_data: u32, batch_indices: u32, batch_count: u32) callconv(.c) void { + _ = _edx; + + // If outlines disabled or nothing tracked, fast-path to original + if (!tracker.enabled or !tracker.hasTargets() or batch_count == 0 or + batch_data == 0 or batch_indices == 0 or batch_count > MAX_REORDER) + { + callOrigRenderDraw(this, view_matrix, batch_data, batch_indices, batch_count); + return; + } + + // Single pass: partition batches into outline-targets vs normal, + // keeping relative order within each group. + var outline_buf: [MAX_REORDER]i32 = undefined; + var normal_buf: [MAX_REORDER]i32 = undefined; + var o_count: usize = 0; + var n_count: usize = 0; + + const indices: [*]const i32 = @ptrFromInt(batch_indices); + for (0..batch_count) |i| { + const idx = indices[i]; + const batch_ptr = batch_data +% @as(u32, @bitCast(idx)) *% 0x40; + const model_ptr = hook.readMem(u32, batch_ptr + 4); + + if (model_ptr != 0 and tracker.findOutlineEntry(model_ptr) != null) { + outline_buf[o_count] = idx; + o_count += 1; + } else { + normal_buf[n_count] = idx; + n_count += 1; + } + } + + if (o_count == 0) { + // Nothing to reorder + callOrigRenderDraw(this, view_matrix, batch_data, batch_indices, batch_count); + return; + } + + // Build reordered array: outline targets FIRST (populate stencil), then normals + var total: usize = 0; + for (outline_buf[0..o_count]) |v| { + reordered_indices[total] = v; + total += 1; + } + for (normal_buf[0..n_count]) |v| { + reordered_indices[total] = v; + total += 1; + } + + callOrigRenderDraw(this, view_matrix, batch_data, @intFromPtr(&reordered_indices), @intCast(total)); +} + +fn callOrigRenderDraw(this: u32, view_matrix: u32, batch_data: u32, batch_indices: u32, batch_count: u32) void { + // __thiscall: ECX = this, stack = viewMatrix, batchData, batchIndices, batchCount + // Callee cleans 16 bytes (4 stack params). + // Pack args into a struct so we only need one "r" register to address them. + const args = [4]u32{ view_matrix, batch_data, batch_indices, batch_count }; + asm volatile ( + \\push 12(%[a]) + \\push 8(%[a]) + \\push 4(%[a]) + \\push (%[a]) + \\call *%[func] + : + : [_] "{ecx}" (this), + [a] "r" (&args), + [func] "r" (render_draw_hook.trampoline), + : .{ .eax = true, .edx = true, .memory = true, .cc = true } + ); +} + +// ============================================================================= +// CM2Model_ManageRenderListNode hook +// ============================================================================= +// __thiscall(model_ECX, addToList_stack) +// Thunked to __cdecl(ecx_model, edx_unused, addToList) + +fn manageRenderDetour(model: u32, _edx: u32, add_to_list: u32) callconv(.c) void { + _ = _edx; + + // Classify the model when it's being ADDED to the render list + if (add_to_list == 1 and model != 0 and tracker.enabled and tracker.hasTargets()) { + tracker.classifyModel(model); + } + + // Call original: __thiscall(model_ECX, addToList_stack) + asm volatile ( + \\push %[add] + \\call *%[func] + : + : [_] "{ecx}" (model), + [add] "r" (add_to_list), + [func] "r" (manage_render_hook.trampoline), + : .{ .eax = true, .edx = true, .memory = true, .cc = true } + ); +} + +// ============================================================================= +// CM2Scene_DrawBatchProjected hook +// ============================================================================= +// __fastcall(renderContext_ECX) +// Thunked to __cdecl(ecx_ctx, edx_unused) + +fn drawBatchProjDetour(ctx: u32, _edx: u32) callconv(.c) void { + _ = _edx; + + const model_ptr = if (wow.isValidPtr(ctx +% @as(u32, @intCast(o.RENDER_CONTEXT_MODEL_OFFSET)))) + hook.readMem(u32, ctx + o.RENDER_CONTEXT_MODEL_OFFSET) + else + 0; + + const entry = if (model_ptr != 0) tracker.findOutlineEntry(model_ptr) else null; + + if (entry != null) { + // This batch is an outline target — signal the DIP hook + rendering_outline = true; + current_model = model_ptr; + + callOrigDrawBatch(ctx); + + rendering_outline = false; + current_model = 0; + + // After outline targets render, enable stencil test for subsequent units + test_outline_stencil = true; + } else { + // Normal rendering. Determine if this is a unit for stencil testing. + rendering_outline = false; + current_model = 0; + rendering_unit = false; + + if (tracker.hasTargets() and model_ptr != 0) { + rendering_unit = tracker.isUnitModel(model_ptr); + } + + callOrigDrawBatch(ctx); + + rendering_unit = false; + } +} + +fn callOrigDrawBatch(ctx: u32) void { + asm volatile ("call *%[func]" + : + : [_] "{ecx}" (ctx), + [func] "r" (draw_batch_hook.trampoline), + : .{ .eax = true, .edx = true, .memory = true, .cc = true } + ); +} + +// ============================================================================= +// Install / Remove +// ============================================================================= + +pub fn installHooks() bool { + // CM2SceneRenderDraw — __thiscall, 4 stack args → cdecl thunk + // Prologue is 9 bytes: PUSH EBP (1) + MOV EBP,ESP (2) + SUB ESP,0x80 (6). + // 6 bytes would cut SUB ESP,0x80 mid-instruction. + if (render_draw_hook.prepare(o.FN_CM2SCENE_RENDER_DRAW, 9, &.{})) { + const thunk = render_draw_hook.mem.? + 32; + _ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&renderDrawDetour), 4); + render_draw_hook.activate(@intFromPtr(thunk)); + } else return false; + + // ManageRenderListNode — __thiscall, 1 stack arg → cdecl thunk + if (manage_render_hook.prepare(o.FN_CM2MODEL_MANAGE_RENDER_LIST, 6, &.{})) { + const thunk = manage_render_hook.mem.? + 32; + _ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&manageRenderDetour), 1); + manage_render_hook.activate(@intFromPtr(thunk)); + } else return false; + + // DrawBatchProj — __fastcall, 0 stack args → cdecl thunk + if (draw_batch_hook.prepare(o.FN_DRAW_BATCH_PROJ, 6, &.{})) { + const thunk = draw_batch_hook.mem.? + 32; + _ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&drawBatchProjDetour), 0); + draw_batch_hook.activate(@intFromPtr(thunk)); + } else return false; + + return true; +} + +pub fn removeHooks() void { + draw_batch_hook.remove(); + manage_render_hook.remove(); + render_draw_hook.remove(); +} diff --git a/src/outline/offsets.zig b/src/outline/offsets.zig new file mode 100644 index 0000000..b3b3308 --- /dev/null +++ b/src/outline/offsets.zig @@ -0,0 +1,119 @@ +//! WoW 1.12.1 (build 5875) memory addresses and struct offsets for the outline system. +//! +//! Sources: Ghidra analysis, UnitXP_SP3, Idris DLL reference implementation. + +// ============================================================================= +// Object Manager +// ============================================================================= + +/// Pointer to the Object Manager base. Dereference once to get the ObjMgr struct. +pub const OBJECT_MANAGER_PTR: usize = 0x00B41414; + +/// ObjMgr + this → first object in the linked list. +pub const OBJECT_LIST_OFFSET: usize = 0xAC; + +/// ObjMgr + this → base pointer for the next-object traversal table. +pub const OBJECT_NEXT_OFFSET: usize = 0xA4; + +/// ObjMgr + this → local player GUID (8 bytes). +pub const LOCAL_PLAYER_GUID_OFFSET: usize = 0xC0; + +// ============================================================================= +// Object fields (from object base pointer) +// ============================================================================= + +pub const OBJECT_TYPE_OFFSET: usize = 0x14; +pub const OBJECT_GUID_OFFSET: usize = 0x30; + +// ============================================================================= +// Unit / Player descriptor fields +// ============================================================================= + +/// object + this → pointer to descriptor block. +pub const UNIT_DESCRIPTOR_OFFSET: usize = 0x110; + +/// Descriptor + this → current HP (int32). +pub const UNIT_HP_OFFSET: usize = 0x40; + +/// Descriptor + this → unit flags (uint32). Test with UNIT_FLAG_DEAD. +pub const UNIT_FLAGS_OFFSET: usize = 0x224; + +/// Bit mask: unit is dead. +pub const UNIT_FLAG_DEAD: u32 = 0x20; + +// ============================================================================= +// Corpse descriptor fields +// ============================================================================= + +/// Corpse object + this → pointer to corpse descriptor. +pub const CORPSE_DESCRIPTOR_OFFSET: usize = 0x8; + +/// Corpse descriptor + this → owner GUID (8 bytes). +pub const CORPSE_FIELD_OWNER: usize = 0x18; + +/// Corpse descriptor + this → corpse flags (uint32). +pub const CORPSE_FIELD_FLAGS: usize = 0x8C; + +/// Bit mask: corpse is a skeleton (not resurrectable). +pub const CORPSE_FLAG_BONE: u32 = 0x01; + +// ============================================================================= +// Raid targets +// ============================================================================= + +/// Static array of 8 GUIDs (64 bytes total). Index 0 = Star, 7 = Skull. +pub const RAID_TARGET_ARRAY: usize = 0x00B71368; + +// ============================================================================= +// Model ownership (offsets from model pointer) +// ============================================================================= + +/// model + this → direct owner object pointer (set by InitializeModelWithParameters). +pub const MODEL_OWNER_DIRECT: usize = 0x28; + +/// model + this → render callback owner pointer (set by SetRenderCallbacks). +pub const MODEL_OWNER_CALLBACK: usize = 0x3C0; + +// ============================================================================= +// Render context +// ============================================================================= + +/// renderContext + this → current model pointer being rendered. +pub const RENDER_CONTEXT_MODEL_OFFSET: usize = 0x3310; + +// ============================================================================= +// Game state +// ============================================================================= + +/// Non-zero when the player is logged in and in the world. +pub const IS_IN_WORLD: usize = 0xB4B424; + +// ============================================================================= +// Function addresses +// ============================================================================= + +/// __stdcall(guidLo_stack, guidHi_stack) → object pointer (EAX). Returns 0 on miss. +/// Callee cleans 8 bytes (RET 8). NOT __fastcall — params on stack, not registers. +pub const FN_GET_OBJECT_BY_GUID: usize = 0x464870; + +/// __fastcall(unitIdStr_ECX) → GUID in EAX:EDX. Accepts "player", "target", etc. +pub const FN_UNIT_GUID: usize = 0x515970; + +/// __thiscall(localPlayer_ECX, targetUnit_stack) → reaction int (0-7). >=4 = friendly. +pub const FN_UNIT_REACTION: usize = 0x6061E0; + +// ============================================================================= +// Hooked function addresses (model rendering pipeline) +// ============================================================================= + +/// CM2SceneRenderDraw — main batch rendering entry point. +/// __thiscall(this, viewMatrix, batchData, batchIndices, batchCount) +pub const FN_CM2SCENE_RENDER_DRAW: usize = 0x0070b360; + +/// CM2Model_ManageRenderListNode — called for every model added/removed from render list. +/// __thiscall(model_ECX, addToList_stack) +pub const FN_CM2MODEL_MANAGE_RENDER_LIST: usize = 0x00710b90; + +/// CM2Scene_DrawModelBatchProjected — called per batch (type 0) during rendering. +/// __fastcall(renderContext_ECX) +pub const FN_DRAW_BATCH_PROJ: usize = 0x0070cb30; diff --git a/src/outline/tracker.zig b/src/outline/tracker.zig new file mode 100644 index 0000000..d1fd845 --- /dev/null +++ b/src/outline/tracker.zig @@ -0,0 +1,260 @@ +//! Per-frame model tracking for the outline system. +//! +//! Maintains fixed-size arrays for dead-player GUIDs, raid-marked GUIDs, +//! the current target, and the per-frame set of outline model entries. +//! All state is single-threaded (main WoW thread) — no synchronisation needed. + +const hook = @import("hook"); +const wow = @import("wow.zig"); +const o = @import("offsets.zig"); +const types = @import("types.zig"); + +// ============================================================================= +// Tracking limits +// ============================================================================= + +const MAX_DEAD_GUIDS = 64; +const MAX_RAID_MARKS = 8; +const MAX_OUTLINE_MODELS = 256; +const MAX_UNIT_MODELS = 512; + +// ============================================================================= +// Persistent tracking (survives across frames until scan refresh) +// ============================================================================= + +var dead_guids: [MAX_DEAD_GUIDS]u64 = .{0} ** MAX_DEAD_GUIDS; +var dead_guid_count: usize = 0; + +var raid_mark_guids: [MAX_RAID_MARKS]u64 = .{0} ** MAX_RAID_MARKS; +var raid_mark_indices: [MAX_RAID_MARKS]u8 = .{0} ** MAX_RAID_MARKS; +var raid_mark_count: usize = 0; + +var target_guid_val: u64 = 0; + +// ============================================================================= +// Per-frame outline model set (populated by ManageRenderListNode hook) +// ============================================================================= + +var frame_outlines: [MAX_OUTLINE_MODELS]types.OutlineEntry = undefined; +var frame_outline_count: usize = 0; + +// ============================================================================= +// Per-frame unit model cache (populated by ManageRenderListNode hook) +// ============================================================================= +// Caches which model pointers belong to units (players/NPCs), so DrawBatchProj +// can check unit status without raw pointer chasing through model structs. +// This mirrors the C++ g_modelToOwner hashmap approach. + +var frame_unit_models: [MAX_UNIT_MODELS]u32 = .{0} ** MAX_UNIT_MODELS; +var frame_unit_model_count: usize = 0; + +// ============================================================================= +// Global enable flag +// ============================================================================= + +pub var enabled: bool = true; + +// ============================================================================= +// Public query API +// ============================================================================= + +/// Look up a model pointer in the per-frame outline set. +pub fn findOutlineEntry(model_ptr: u32) ?*const types.OutlineEntry { + for (frame_outlines[0..frame_outline_count]) |*entry| { + if (entry.model_ptr == model_ptr) return entry; + } + return null; +} + +/// Check if any outline targets are tracked this frame. +pub fn hasTargets() bool { + return dead_guid_count > 0 or raid_mark_count > 0 or target_guid_val != 0; +} + +/// Get the outline colour for a model, or null if not tracked. +pub fn getModelColor(model_ptr: u32) ?u32 { + const entry = findOutlineEntry(model_ptr) orelse return null; + return switch (entry.category) { + .target => types.COLOR_TARGET, + .raid_marked => if (entry.raid_mark > 0 and entry.raid_mark <= 8) + types.RAID_MARK_COLORS[entry.raid_mark] + else + types.COLOR_DEAD_PLAYER, + .dead_player => types.COLOR_DEAD_PLAYER, + .none => null, + }; +} + +/// Get the outline category for a model. +pub fn getModelCategory(model_ptr: u32) types.ModelCategory { + const entry = findOutlineEntry(model_ptr) orelse return .none; + return entry.category; +} + +/// Get screen-space outline thickness in pixels for a category. +pub fn getOutlinePixels(cat: types.ModelCategory) f32 { + return switch (cat) { + .target => types.OUTLINE_PIXELS_TARGET, + .raid_marked => types.OUTLINE_PIXELS_RAID_MARK, + .dead_player => types.OUTLINE_PIXELS_DEAD_PLAYER, + .none => 0, + }; +} + +/// Check if a model was classified as a unit (player/NPC) this frame. +/// Used by DrawBatchProj to decide stencil testing without raw pointer chasing. +pub fn isUnitModel(model_ptr: u32) bool { + if (model_ptr == 0) return false; + for (frame_unit_models[0..frame_unit_model_count]) |m| { + if (m == model_ptr) return true; + } + return false; +} + +// ============================================================================= +// Per-frame model registration (called from ManageRenderListNode hook) +// ============================================================================= + +/// Try to classify a model and add it to the per-frame outline set. +/// Also caches unit status for stencil occlusion in DrawBatchProj. +pub fn classifyModel(model_ptr: u32) void { + if (model_ptr == 0 or !enabled) return; + + // Try to resolve the owning game object + const owner = wow.resolveModelOwner(model_ptr); + if (owner == 0) return; + + // Cache unit status for stencil occlusion (regardless of outline status) + const obj_type = wow.getObjectType(owner); + if (obj_type == .unit or obj_type == .player) { + addUnitModel(model_ptr); + } + + // Already tracked as outline this frame? + if (frame_outline_count >= MAX_OUTLINE_MODELS) return; + if (findOutlineEntry(model_ptr) != null) return; + + const guid = wow.getObjectGUID(owner); + if (guid == 0) return; + + // Priority 1: current target + if (guid == target_guid_val and target_guid_val != 0) { + addEntry(model_ptr, .target, 0); + return; + } + + // Priority 2: raid mark + for (raid_mark_guids[0..raid_mark_count], raid_mark_indices[0..raid_mark_count]) |rg, ri| { + if (rg == guid) { + addEntry(model_ptr, .raid_marked, ri); + return; + } + } + + // Priority 3: dead friendly player + for (dead_guids[0..dead_guid_count]) |dg| { + if (dg == guid) { + addEntry(model_ptr, .dead_player, 0); + return; + } + } +} + +fn addUnitModel(model_ptr: u32) void { + if (frame_unit_model_count >= MAX_UNIT_MODELS) return; + // Deduplicate + for (frame_unit_models[0..frame_unit_model_count]) |m| { + if (m == model_ptr) return; + } + frame_unit_models[frame_unit_model_count] = model_ptr; + frame_unit_model_count += 1; +} + +fn addEntry(model_ptr: u32, cat: types.ModelCategory, mark: u8) void { + if (frame_outline_count >= MAX_OUTLINE_MODELS) return; + frame_outlines[frame_outline_count] = .{ + .model_ptr = model_ptr, + .category = cat, + .raid_mark = mark, + }; + frame_outline_count += 1; +} + +// ============================================================================= +// Per-frame scan (called from EndScene) +// ============================================================================= + +/// Scan all visible objects and rebuild tracking lists. +pub fn scanObjects() void { + // Clear per-frame model sets + frame_outline_count = 0; + frame_unit_model_count = 0; + + // Clear persistent tracking (rebuilt every frame from scan) + dead_guid_count = 0; + raid_mark_count = 0; + target_guid_val = 0; + + if (!wow.isInGame()) return; + const local_player = wow.getLocalPlayer(); + if (local_player == 0) return; + + // Cache raid target GUIDs + wow.cacheRaidTargets(); + + // Read target GUID + target_guid_val = wow.getTargetGUID(); + + // Iterate all visible objects + var obj = wow.objectFirst(); + while (obj != 0) : (obj = wow.objectNext(obj)) { + const obj_type = wow.getObjectType(obj); + const guid = wow.getObjectGUID(obj); + if (guid == 0) continue; + + switch (obj_type) { + .player => { + // Dead friendly players → through-wall outline + if (wow.isUnitDead(obj) and wow.isUnitFriendly(obj, local_player)) { + addDeadGUID(guid); + } + // Raid marks + addRaidMarkIfMarked(guid); + }, + .unit => { + // Raid marks on NPCs + addRaidMarkIfMarked(guid); + }, + .corpse => { + // Non-skeleton corpses owned by players + if (!wow.isSkeletonCorpse(obj)) { + const owner_guid = wow.getCorpseOwnerGUID(obj); + if (owner_guid != 0) addDeadGUID(owner_guid); + } + }, + else => {}, + } + } +} + +fn addDeadGUID(guid: u64) void { + if (guid == 0 or dead_guid_count >= MAX_DEAD_GUIDS) return; + // Deduplicate + for (dead_guids[0..dead_guid_count]) |dg| { + if (dg == guid) return; + } + dead_guids[dead_guid_count] = guid; + dead_guid_count += 1; +} + +fn addRaidMarkIfMarked(guid: u64) void { + const mark = wow.getRaidMarkForGUID(guid); + if (mark == 0 or raid_mark_count >= MAX_RAID_MARKS) return; + // Deduplicate + for (raid_mark_guids[0..raid_mark_count]) |rg| { + if (rg == guid) return; + } + raid_mark_guids[raid_mark_count] = guid; + raid_mark_indices[raid_mark_count] = mark; + raid_mark_count += 1; +} diff --git a/src/outline/types.zig b/src/outline/types.zig new file mode 100644 index 0000000..160fec5 --- /dev/null +++ b/src/outline/types.zig @@ -0,0 +1,259 @@ +//! Shared types, enums, and D3D9 constants for the outline subsystem. + +const std = @import("std"); + +// ============================================================================= +// Model categories +// ============================================================================= + +pub const ModelCategory = enum(u8) { + none = 0, + target = 1, + raid_marked = 2, + dead_player = 3, +}; + +// ============================================================================= +// WoW object types +// ============================================================================= + +pub const ObjectType = enum(u32) { + null_obj = 0, + item = 1, + container = 2, + unit = 3, + player = 4, + game_object = 5, + dynamic_object = 6, + corpse = 7, + _, +}; + +// ============================================================================= +// Outline model entry (per-frame tracking) +// ============================================================================= + +pub const OutlineEntry = struct { + model_ptr: u32, + category: ModelCategory, + /// 1-8 for raid marks, 0 otherwise. + raid_mark: u8, +}; + +// ============================================================================= +// Outline colours — D3DCOLOR ARGB format (0xAARRGGBB) +// ============================================================================= + +/// Dead player / corpse outline (cyan). +pub const COLOR_DEAD_PLAYER: u32 = 0xFF00FFFF; + +/// Current target outline (golden amber). +pub const COLOR_TARGET: u32 = 0xFFFFC800; + +/// Raid mark colours, indexed 0-8. Index 0 = fallback cyan. +pub const RAID_MARK_COLORS = [9]u32{ + 0xFF00FFFF, // 0: fallback + 0xFFFFFF00, // 1: Star — Yellow + 0xFFFF8000, // 2: Circle — Orange + 0xFFCC44FF, // 3: Diamond — Purple + 0xFF00FF00, // 4: Triangle — Green + 0xFFC0C0FF, // 5: Moon — Silver/Pale Blue + 0xFF4040FF, // 6: Square — Blue + 0xFFFF2828, // 7: Cross — Soft Red + 0xFFFFF5DC, // 8: Skull — Bone White +}; + +// ============================================================================= +// Screen-space outline thickness (pixels) +// ============================================================================= + +pub const OUTLINE_PIXELS_TARGET: f32 = 2.25; +pub const OUTLINE_PIXELS_RAID_MARK: f32 = 1.5; +pub const OUTLINE_PIXELS_DEAD_PLAYER: f32 = 2.5; + +// ============================================================================= +// Stencil bit definitions +// ============================================================================= + +pub const STENCIL_BIT_BODY: u32 = 0x01; +pub const STENCIL_BIT_OUTLINE: u32 = 0x02; + +// ============================================================================= +// D3D9 Render State IDs (D3DRENDERSTATETYPE) +// ============================================================================= + +pub const D3DRS = struct { + pub const ZENABLE: u32 = 7; + pub const FILLMODE: u32 = 8; + pub const ZWRITEENABLE: u32 = 14; + pub const SRCBLEND: u32 = 19; + pub const DESTBLEND: u32 = 20; + pub const CULLMODE: u32 = 22; + pub const ZFUNC: u32 = 23; + pub const ALPHABLENDENABLE: u32 = 27; + pub const STENCILENABLE: u32 = 52; + pub const STENCILFAIL: u32 = 53; + pub const STENCILZFAIL: u32 = 54; + pub const STENCILPASS: u32 = 55; + pub const STENCILFUNC: u32 = 56; + pub const STENCILREF: u32 = 57; + pub const STENCILMASK: u32 = 58; + pub const STENCILWRITEMASK: u32 = 59; + pub const TEXTUREFACTOR: u32 = 60; + pub const COLORWRITEENABLE: u32 = 168; + pub const DEPTHBIAS: u32 = 195; +}; + +// ============================================================================= +// D3D9 comparison / stencil-op / cull / depth constants +// ============================================================================= + +pub const D3DCMP_ALWAYS: u32 = 8; +pub const D3DCMP_EQUAL: u32 = 3; +pub const D3DCMP_LESSEQUAL: u32 = 4; + +pub const D3DSTENCILOP_KEEP: u32 = 1; +pub const D3DSTENCILOP_REPLACE: u32 = 3; + +pub const D3DCULL_CW: u32 = 2; +pub const D3DCULL_CCW: u32 = 3; + +pub const D3DZB_FALSE: u32 = 0; +pub const D3DZB_TRUE: u32 = 1; + +pub const D3DCLEAR_STENCIL: u32 = 4; + +// ============================================================================= +// D3D9 texture stage state IDs +// ============================================================================= + +pub const D3DTSS = struct { + pub const COLOROP: u32 = 1; + pub const COLORARG1: u32 = 2; + pub const ALPHAOP: u32 = 4; + pub const ALPHAARG1: u32 = 5; +}; + +pub const D3DTOP_SELECTARG1: u32 = 2; +pub const D3DTA_TFACTOR: u32 = 3; + +// ============================================================================= +// D3D9 depth/stencil formats +// ============================================================================= + +pub const D3DFMT_D24S8: u32 = 75; +pub const D3DFMT_D24X4S4: u32 = 79; +pub const D3DFMT_D15S1: u32 = 73; +pub const D3DFMT_D24FS8: u32 = 83; + +// ============================================================================= +// IDirect3DDevice9 vtable indices +// ============================================================================= + +pub const VT = struct { + pub const Release: usize = 2; + pub const Reset: usize = 16; + pub const GetSwapChain: usize = 14; + pub const CreateDepthStencilSurface: usize = 29; + pub const GetRenderTarget: usize = 38; + pub const SetDepthStencilSurface: usize = 39; + pub const GetDepthStencilSurface: usize = 40; + pub const EndScene: usize = 42; + pub const Clear: usize = 43; + pub const SetViewport: usize = 47; + pub const GetViewport: usize = 48; + pub const SetRenderState: usize = 57; + pub const GetRenderState: usize = 58; + pub const SetTexture: usize = 65; + pub const SetTextureStageState: usize = 67; + pub const DrawIndexedPrimitive: usize = 82; + pub const CreateVertexDeclaration: usize = 86; + pub const SetVertexDeclaration: usize = 87; + pub const GetVertexDeclaration: usize = 88; + pub const CreateVertexShader: usize = 91; + pub const SetVertexShader: usize = 92; + pub const GetVertexShader: usize = 93; + pub const SetVertexShaderConstantF: usize = 94; + pub const GetVertexShaderConstantF: usize = 95; + pub const SetStreamSource: usize = 100; + pub const GetStreamSource: usize = 101; + pub const SetIndices: usize = 104; + pub const GetIndices: usize = 105; + pub const CreatePixelShader: usize = 106; + pub const SetPixelShader: usize = 107; + pub const GetPixelShader: usize = 108; + pub const SetPixelShaderConstantF: usize = 109; + pub const GetPixelShaderConstantF: usize = 110; +}; + +// ============================================================================= +// D3D9 present parameters (for dummy device creation) +// ============================================================================= + +pub const D3DPRESENT_PARAMETERS = extern struct { + BackBufferWidth: u32 = 0, + BackBufferHeight: u32 = 0, + BackBufferFormat: u32 = 0, + BackBufferCount: u32 = 0, + MultiSampleType: u32 = 0, + MultiSampleQuality: u32 = 0, + SwapEffect: u32 = 0, + hDeviceWindow: u32 = 0, + Windowed: u32 = 0, + EnableAutoDepthStencil: u32 = 0, + AutoDepthStencilFormat: u32 = 0, + Flags: u32 = 0, + FullScreen_RefreshRateInHz: u32 = 0, + PresentationInterval: u32 = 0, +}; + +// ============================================================================= +// D3D9 viewport +// ============================================================================= + +pub const D3DVIEWPORT9 = extern struct { + X: u32 = 0, + Y: u32 = 0, + Width: u32 = 0, + Height: u32 = 0, + MinZ: f32 = 0, + MaxZ: f32 = 0, +}; + +// ============================================================================= +// D3D9 surface desc (subset) +// ============================================================================= + +pub const D3DSURFACE_DESC = extern struct { + Format: u32 = 0, + Type: u32 = 0, + Usage: u32 = 0, + Pool: u32 = 0, + MultiSampleType: u32 = 0, + MultiSampleQuality: u32 = 0, + Width: u32 = 0, + Height: u32 = 0, +}; + +// ============================================================================= +// Windows structures for dummy device creation +// ============================================================================= + +pub const WndProc = *const fn (?*anyopaque, u32, usize, usize) callconv(WINAPI) usize; + +pub const WNDCLASSEXA = extern struct { + cbSize: u32 = @sizeOf(WNDCLASSEXA), + style: u32 = 0, + lpfnWndProc: ?WndProc = null, + cbClsExtra: i32 = 0, + cbWndExtra: i32 = 0, + hInstance: ?*anyopaque = null, + hIcon: ?*anyopaque = null, + hCursor: ?*anyopaque = null, + hbrBackground: ?*anyopaque = null, + lpszMenuName: ?[*:0]const u8 = null, + lpszClassName: ?[*:0]const u8 = null, + hIconSm: ?*anyopaque = null, +}; + +const WINAPI = std.builtin.CallingConvention.winapi; diff --git a/src/outline/wow.zig b/src/outline/wow.zig new file mode 100644 index 0000000..d7f6632 --- /dev/null +++ b/src/outline/wow.zig @@ -0,0 +1,225 @@ +//! Game memory access wrappers for WoW 1.12.1. +//! +//! Provides safe read helpers for the object manager, unit descriptors, +//! corpse fields, raid targets, and calling-convention wrappers for +//! game functions (UnitGUID, GetObjectByGUID, UnitReaction). + +const std = @import("std"); +const hook = @import("hook"); +const o = @import("offsets.zig"); +const types = @import("types.zig"); + +// ============================================================================= +// Pointer validation +// ============================================================================= + +const WINAPI = std.builtin.CallingConvention.winapi; + +extern "kernel32" fn IsBadReadPtr( + lp: ?*const anyopaque, + ucb: usize, +) callconv(WINAPI) i32; + +/// Quick sanity check — reject null, low-address, and kernel-space pointers. +pub fn isValidPtr(addr: u32) bool { + return addr >= 0x10000 and addr < 0x7F000000; +} + +/// Check if a pointer is readable using the Windows API (matches C++ IsValidReadPtr). +/// This does an actual page-level check, not just a range check. +fn isReadablePtr(addr: u32, size: usize) bool { + if (addr < 0x10000 or addr >= 0x7F000000) return false; + return IsBadReadPtr(@ptrFromInt(addr), size) == 0; +} + +// ============================================================================= +// Object manager traversal +// ============================================================================= + +pub fn isInGame() bool { + return hook.readMem(u32, o.IS_IN_WORLD) != 0; +} + +pub fn objectFirst() u32 { + const obj_mgr = hook.readMem(u32, o.OBJECT_MANAGER_PTR); + if (obj_mgr == 0) return 0; + return hook.readMem(u32, obj_mgr + o.OBJECT_LIST_OFFSET); +} + +pub fn objectNext(current: u32) u32 { + if (current == 0 or (current & 1) != 0) return 0; + const obj_mgr = hook.readMem(u32, o.OBJECT_MANAGER_PTR); + if (obj_mgr == 0) return 0; + const base = hook.readMem(u32, obj_mgr + o.OBJECT_NEXT_OFFSET); + const next = hook.readMem(u32, base + current + 4); + if (next == 0 or (next & 1) != 0) return 0; + return next; +} + +// ============================================================================= +// Object field reads +// ============================================================================= + +pub fn getObjectType(obj: u32) types.ObjectType { + if (!isValidPtr(obj)) return .null_obj; + return @enumFromInt(hook.readMem(u32, obj + o.OBJECT_TYPE_OFFSET)); +} + +pub fn readGUID(addr: u32) u64 { + const lo = hook.readMem(u32, addr); + const hi = hook.readMem(u32, addr + 4); + return (@as(u64, hi) << 32) | lo; +} + +pub fn getObjectGUID(obj: u32) u64 { + if (!isValidPtr(obj)) return 0; + return readGUID(obj + o.OBJECT_GUID_OFFSET); +} + +// ============================================================================= +// Unit helpers +// ============================================================================= + +pub fn isUnitDead(unit: u32) bool { + if (!isValidPtr(unit)) return false; + const desc = hook.readMem(u32, unit + o.UNIT_DESCRIPTOR_OFFSET); + if (!isValidPtr(desc)) return false; + const flags = hook.readMem(u32, desc + o.UNIT_FLAGS_OFFSET); + return (flags & o.UNIT_FLAG_DEAD) != 0; +} + +// ============================================================================= +// Corpse helpers +// ============================================================================= + +pub fn isSkeletonCorpse(obj: u32) bool { + if (!isValidPtr(obj)) return false; + const desc = hook.readMem(u32, obj + o.CORPSE_DESCRIPTOR_OFFSET); + if (!isValidPtr(desc)) return false; + const flags = hook.readMem(u32, desc + o.CORPSE_FIELD_FLAGS); + return (flags & o.CORPSE_FLAG_BONE) != 0; +} + +pub fn getCorpseOwnerGUID(obj: u32) u64 { + if (!isValidPtr(obj)) return 0; + const desc = hook.readMem(u32, obj + o.CORPSE_DESCRIPTOR_OFFSET); + if (!isValidPtr(desc)) return 0; + return readGUID(desc + o.CORPSE_FIELD_OWNER); +} + +// ============================================================================= +// Model owner resolution +// ============================================================================= + +/// Try to resolve the game object that owns a render model. +/// Tries callback owner (model+0x3C0) first, then direct owner (model+0x28). +/// Uses IsBadReadPtr for page-level validation, matching the C++ IsValidReadPtr pattern. +pub fn resolveModelOwner(model: u32) u32 { + // Need to read up to model+0x3C0+4 + if (!isReadablePtr(model, o.MODEL_OWNER_CALLBACK + 4)) return 0; + + // Try callback owner first — more reliable for units + const candidate_cb = hook.readMem(u32, model + o.MODEL_OWNER_CALLBACK); + if (candidate_cb != 0 and isReadablePtr(candidate_cb, 0x40)) { + const guid_lo = hook.readMem(u32, candidate_cb + o.OBJECT_GUID_OFFSET); + if (guid_lo != 0 and guid_lo < 0x10000000) return candidate_cb; + } + + // Fallback to direct owner (already validated model is readable past 0x28) + const candidate_dir = hook.readMem(u32, model + o.MODEL_OWNER_DIRECT); + if (candidate_dir != 0 and isReadablePtr(candidate_dir, 0x40)) { + const guid_lo = hook.readMem(u32, candidate_dir + o.OBJECT_GUID_OFFSET); + if (guid_lo != 0 and guid_lo < 0x10000000) return candidate_dir; + } + + return 0; +} + +// ============================================================================= +// Game function wrappers (calling-convention bridges) +// ============================================================================= + +/// UnitGUID("player") / UnitGUID("target") → 64-bit GUID. +/// __fastcall(unitIdStr_ECX) → EAX:EDX. +pub fn unitGUID(unit_id: [*:0]const u8) u64 { + var lo: u32 = undefined; + var hi: u32 = undefined; + asm volatile ("call *%[func]" + : [_] "={eax}" (lo), + [_] "={edx}" (hi), + : [_] "{ecx}" (@intFromPtr(unit_id)), + [func] "r" (@as(u32, o.FN_UNIT_GUID)), + : .{ .memory = true, .cc = true } + ); + return (@as(u64, hi) << 32) | lo; +} + +/// Resolve a GUID → object pointer via the object manager hash table. +/// Ghidra-verified: __stdcall(guidLow, guidHigh) with RET 8. +/// NOT __fastcall — params are read from stack, not registers. +pub fn getObjectByGUID(guid: u64) u32 { + if (guid == 0) return 0; + const lo: u32 = @truncate(guid); + const hi: u32 = @truncate(guid >> 32); + return asm volatile ( + \\push %[hi] + \\push %[lo] + \\call *%[func] + : [ret] "={eax}" (-> u32), + : [lo] "r" (lo), + [hi] "r" (hi), + [func] "r" (@as(u32, o.FN_GET_OBJECT_BY_GUID)), + : .{ .ecx = true, .edx = true, .memory = true, .cc = true } + ); +} + +/// Get the local player's object pointer. +pub fn getLocalPlayer() u32 { + const guid = unitGUID("player"); + if (guid == 0) return 0; + return getObjectByGUID(guid); +} + +/// Get the current target's GUID. +pub fn getTargetGUID() u64 { + return unitGUID("target"); +} + +/// Check if a unit is friendly to the local player. +/// Uses UnitReaction(__thiscall): ECX = localPlayer, stack = unit → int reaction. +/// Reaction >= 4 means friendly. +pub fn isUnitFriendly(unit: u32, local_player: u32) bool { + if (unit == 0 or local_player == 0) return false; + const reaction: i32 = asm volatile ( + \\push %[unit] + \\call *%[func] + : [ret] "={eax}" (-> i32), + : [_] "{ecx}" (local_player), + [unit] "r" (unit), + [func] "r" (@as(u32, o.FN_UNIT_REACTION)), + : .{ .edx = true, .memory = true, .cc = true } + ); + return reaction >= 4; +} + +// ============================================================================= +// Raid target cache +// ============================================================================= + +var cached_raid_targets: [8]u64 = .{0} ** 8; + +/// Read the 8 raid target GUIDs from WoW's static array into a local cache. +pub fn cacheRaidTargets() void { + for (0..8) |i| { + cached_raid_targets[i] = readGUID(@intCast(o.RAID_TARGET_ARRAY + i * 8)); + } +} + +/// Return the raid mark index (1-8) for a given GUID, or 0 if not marked. +pub fn getRaidMarkForGUID(guid: u64) u8 { + if (guid == 0) return 0; + for (cached_raid_targets, 0..) |rt, i| { + if (rt == guid) return @intCast(i + 1); + } + return 0; +}