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; +}