diff --git a/DLL_README.md b/DLL_README.md index fe35ca0..24f8cf2 100644 --- a/DLL_README.md +++ b/DLL_README.md @@ -97,7 +97,7 @@ Enables loading loose game asset files (models, textures, etc.) from the `Data/` Also allows multi-character patch archive names (e.g. `patch-12.mpq`, `patch-jimbo.mpq`). -Patch archives are sorted case-insensitively by filename — last in the sort gets highest priority, and all patches override the base archives. +Patch archives are sorted case-insensitively by filename - last in the sort gets highest priority, and all patches override the base archives. No configuration needed, install and forget. @@ -139,7 +139,12 @@ Fixes duplicate floating heal numbers caused by SuperWoW 1.5. Only relevant if y ### Big Cursor -Increases the hardware cursor render size for improved visibility. No configuration needed, install and forget. +Upscales the hardware cursor for improved visibility without losing sharpness. Supports fractional scales from 1.0 (off) to 4.0. + +- `/script SetCursorScale(1.2)` -- set cursor scale (default 1.2x) +- `/script SetCursorScale(1)` -- disable (use original 32x32 cursor) + +This value is saved to the `cursorScale` CVar in tenths: `/script SetCVar("cursorScale", "15")` for 1.5x. **DLL:** `bigcursor.dll` @@ -152,3 +157,60 @@ This project is distributed as pre-built DLLs only. The source code is not and w These DLLs work by hooking deeply into the game client's internals: memory layout, function addresses, rendering pipeline, input handling, and more. While every feature here is built for legitimate quality-of-life use, the underlying techniques touch on too many core mechanisms that are trivially abusable. Publishing the source would be handing a candy store to bad actors: the same hooks and patterns used to render a raid marker or fix a crash can be repurposed for cheats, exploits, and in particular automation with minimal effort. + +--- + +## Developer Notes +### Runtime Module Control API + +WeirdUtils exports three functions for querying and disabling modules at runtime. This is the preferred way for other DLLs to check module state and take over functionality - no mutex tricks needed. + +#### Exported Functions + +| Function | Signature | Description | +|---|---|---| +| `WeirdUtils_IsModuleActive` | `int __cdecl (const char *name)` | Returns 1 if the module is compiled in and currently hooked, 0 otherwise | +| `WeirdUtils_DisableModule` | `int __cdecl (const char *name)` | Unhooks the named module. Returns 1 if found, 0 otherwise | +| `WeirdUtils_DisableAll` | `int __cdecl (void)` | Unhooks all modules and core hooks. Returns count of modules disabled | + +Module names are case-insensitive and match the build option names: + +`customassets`, `framecrash`, `logsessions`, `transmogfix`, `minimapicons`, `healtextfix`, `bigcursor`, `worldmarkers`, `interact`, `outline`, `screenshot` + +There is no re-enable API - re-hooking after unhook is unsafe. + +#### C/C++ Header + +A header-only `include/weirdutils_api.h` is provided that handles DLL discovery and runtime resolution automatically. No .lib file needed: + +```c +#include "weirdutils_api.h" + +// Returns 0 if WeirdUtils isn't loaded - safe to call unconditionally +if (WeirdUtils_IsModuleActive("transmogfix")) + WeirdUtils_DisableModule("transmogfix"); +``` + +The header tries all known DLL names (`weirdutils.dll`, `worldmarkers.dll`, etc.) via `GetModuleHandleA`, so it works regardless of which DLL variant is loaded. + +#### Raw GetProcAddress + +If you prefer not to use the header: + +```c +HMODULE hMod = GetModuleHandleA("weirdutils.dll"); +if (hMod) { + typedef int (__cdecl *IsActiveFn)(const char *); + IsActiveFn isActive = (IsActiveFn)GetProcAddress(hMod, "WeirdUtils_IsModuleActive"); + if (isActive && isActive("transmogfix")) { + typedef int (__cdecl *DisableFn)(const char *); + DisableFn disable = (DisableFn)GetProcAddress(hMod, "WeirdUtils_DisableModule"); + if (disable) disable("transmogfix"); + } +} + +### Module Mutexes + +Each module holds a named mutex while active: `Local\WeirdUtils__` (e.g. `Local\WeirdUtils_framecrash_12345`). The exception is transmogfix, which uses `Local\TransmogCoalesceHook_` for legacy reasons. + +If you see the mutex, the module is loaded - use the Runtime Module Control API below to disable it. If you don't see it, the module isn't active and you're free to hook those functions yourself. diff --git a/README.md b/README.md index b8ffac4..848f47c 100644 --- a/README.md +++ b/README.md @@ -12,31 +12,31 @@ interaction helpers, and an embedded addon with Lua API + keybindings. | **Screenshot** | Hooks CTgaFile::Write for screenshot capture. | | **Interact** | Nearest NPC/object interaction, bulk looting with queue processing. | | **Markers** | World-space raid markers (5 colors) using M2 model entities. Proximity respawn, group sync, animated spawn/despawn. Lua API + slash commands (`/wm`, `/cwm`). | -| **Framecrash** | Anchor vtable guards — prevents crashes from dangling relativeTo pointers and NULL frame refs. | +| **Framecrash** | Anchor vtable guards - prevents crashes from dangling relativeTo pointers and NULL frame refs. | | **Combatlog** | Combat log fixes. | | **Minimap Icons** | Minimap icon fixes. | | **Transmogfix** | Coalesces transmog durability update packets to prevent death frame drops. | | **Data Assets** | Loose file loading, permissive MPQ glob patterns, pre-indexed file hash set. | | **Healtextfix** | Heal text display fix. | -| **Embedded Addon** | Virtual addons loaded from DLL memory — .toc, .lua, .xml, .m2, .blp served via file I/O hooks (LoadFile + Storm layer). No on-disk addon folder needed. | +| **Embedded Addon** | Virtual addons loaded from DLL memory - .toc, .lua, .xml, .m2, .blp served via file I/O hooks (LoadFile + Storm layer). No on-disk addon folder needed. | | **Lua Protection Bypass** | Stubs the Lua callback address validator to allow C function registration. | ## Consolidation Plan WeirdUtils replaces the standalone utility DLLs in the parent directory. All -development happens here — shared code, shared hooking infrastructure, one build +development happens here - shared code, shared hooking infrastructure, one build system. The standalone DLLs are being retired. | Standalone DLL | Purpose | Integration Status | |---|---|---| | `../assetfix` | Loose file loading, permissive MPQ glob patterns, pre-indexed file hash set | Not started | -| `../transmogfix` | Death frame drop fix — coalesces transmog durability update packets | Not started | +| `../transmogfix` | Death frame drop fix - coalesces transmog durability update packets | Not started | | `../interact` | Nearest interact + bulk loot | Partially integrated | ### Compile-Time Feature Gating Each module is gated behind a build flag. The same codebase produces both the -all-in-one DLL and individual feature DLLs — just different compile flags. +all-in-one DLL and individual feature DLLs - just different compile flags. Users can pick the full package or grab only the features they want. ```zig @@ -48,21 +48,21 @@ const enable_outline = b.option(bool, "outline", "Enable outline rendering") ore ``` ```sh -# Full build — all features in one DLL +# Full build - all features in one DLL zig build -# Single-feature builds — one DLL per feature for individual distribution +# Single-feature builds - one DLL per feature for individual distribution zig build -Dcustomassets=true -Dtransmogfix=false -Dinteract=false -Doutline=false zig build -Dcustomassets=false -Dtransmogfix=true -Dinteract=false -Doutline=false # etc. ``` Release artifacts: -- `weirdutils.dll` — everything -- `customassets.dll` — just asset/MPQ fixes -- `transmogfix.dll` — just transmog coalesce -- `interact.dll` — just interact/loot helpers -- `outline.dll` — just outline rendering +- `weirdutils.dll` - everything +- `customassets.dll` - just asset/MPQ fixes +- `transmogfix.dll` - just transmog coalesce +- `interact.dll` - just interact/loot helpers +- `outline.dll` - just outline rendering All built from this repo, all sharing the same hook library and codebase. @@ -70,7 +70,7 @@ All built from this repo, all sharing the same hook library and codebase. A user might load the full DLL alongside one of the smaller single-feature DLLs (e.g. they use `weirdutils.dll` for everything but also have `customassets.dll` from -before they switched). Each feature module claims a **named mutex** on load — if +before they switched). Each feature module claims a **named mutex** on load - if it's already held, that module skips hook installation. This way any combination of DLLs coexists safely with no duplicate hooks. @@ -78,11 +78,11 @@ of DLLs coexists safely with no duplicate hooks. // Each module creates a process-specific named mutex on init const mutex = CreateMutexA(null, 1, "Local\\WeirdUtils_CustomAssetsHook_{pid}"); if (GetLastError() == ERROR_ALREADY_EXISTS) { - // Another DLL already owns this feature's hooks — skip + // Another DLL already owns this feature's hooks - skip CloseHandle(mutex); return; } -// First to load wins — install hooks +// First to load wins - install hooks ``` This is per-feature, not per-DLL. The full DLL claims one mutex per enabled @@ -109,7 +109,7 @@ Implementation will require: This repo is private (source not published to avoid empowering bad actors). Distribution uses a separate **public release repo** that contains only a -user-facing README and binary releases — no source code. +user-facing README and binary releases - no source code. - **This repo** (private): all source, development, docs - **Public repo** (e.g. `WeirdUtils`): README with feature descriptions + @@ -169,10 +169,10 @@ Host: Linux (Arch), game runs via Wine/DXVK. Hooks are installed in a specific sequence to handle dependencies: -1. **DLL_PROCESS_ATTACH** — Lua protection bypass, file I/O hook, LoadScriptFunctions, +1. **DLL_PROCESS_ATTACH** - Lua protection bypass, file I/O hook, LoadScriptFunctions, LoadAddonsRecursively, interact hooks, GameEngine_MainInitialize, CGGameUI_Shutdown -2. **GameEngine_MainInitialize** (one-shot) — screenshot hook, outline model hooks -3. **First model hook callback** (deferred) — D3D9 vtable hooks (EndScene, DIP, Reset) +2. **GameEngine_MainInitialize** (one-shot) - screenshot hook, outline model hooks +3. **First model hook callback** (deferred) - D3D9 vtable hooks (EndScene, DIP, Reset) D3D9 hooks are deferred because creating a dummy device during engine init corrupts the d3d9 proxy's state. diff --git a/RELEASING.md b/RELEASING.md index 2d995f4..f13a1e0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ ## How the remote repo works This project is developed entirely locally. The remote repo is **only** a -distribution point for releases — no source code is pushed. +distribution point for releases - no source code is pushed. The remote `main` branch contains a single file: `README.md` (built from the local `DLL_README.md`). This must be set up once when creating the repo: @@ -50,7 +50,7 @@ zig build --help 2>&1 | grep 'Enable' ### Combined DLL Build `weirdutils.dll` with only the modules for this release. Explicitly -disable everything not being included — defaults may enable modules you don't +disable everything not being included - defaults may enable modules you don't want: ```sh @@ -68,7 +68,7 @@ zig build -Doptimize=ReleaseSmall \ zig build all-variants -Doptimize=ReleaseSmall ``` -This builds all variants — you only attach the ones for this release. +This builds all variants - you only attach the ones for this release. ### Output locations @@ -86,7 +86,7 @@ ls -lh zig-out/bin/weirdutils.dll zig-out/variants/*.dll ## 2. Update the remote README The remote README should match the features in this release. Start from -`DLL_README.md` and remove the sections for modules not being released — +`DLL_README.md` and remove the sections for modules not being released - keep the header, install instructions, and included feature sections exactly as they are. diff --git a/build.zig b/build.zig index 701c5be..fd7b0c3 100644 --- a/build.zig +++ b/build.zig @@ -1,5 +1,28 @@ const std = @import("std"); +const ModuleDesc = struct { + name: []const u8, + desc: []const u8, + default: bool = true, +}; + +/// Single source of truth for all modules. Adding a module here is enough +/// to wire up the build option, build_options passthrough, and DLL variant. +const module_list = [_]ModuleDesc{ + .{ .name = "screenshot", .desc = "Enable screenshot module" }, + .{ .name = "interact", .desc = "Enable interact module" }, + .{ .name = "outline", .desc = "Enable outline module", .default = false }, + .{ .name = "worldmarkers", .desc = "Enable world markers module" }, + .{ .name = "framecrash", .desc = "Enable framecrash fix", .default = false }, + .{ .name = "logsessions", .desc = "Enable log session rotation" }, + .{ .name = "minimapicons", .desc = "Enable custom minimap icons" }, + .{ .name = "transmogfix", .desc = "Enable transmog update coalescing" }, + .{ .name = "customassets", .desc = "Enable loose file loading & permissive patch glob" }, + .{ .name = "healtextfix", .desc = "Enable SuperWoW heal text fix" }, + .{ .name = "bigcursor", .desc = "Enable big cursor module" }, + .{ .name = "dpslog", .desc = "Enable structured combat log events for addons", .default = false }, +}; + pub fn build(b: *std.Build) void { const target = b.resolveTargetQuery(.{ .cpu_arch = .x86, @@ -9,31 +32,10 @@ pub fn build(b: *std.Build) void { const optimize = b.standardOptimizeOption(.{}); // Build options for conditional module compilation - const enable_screenshot = b.option(bool, "screenshot", "Enable screenshot module") orelse true; - const enable_interact = b.option(bool, "interact", "Enable interact module") orelse true; - const enable_outline = b.option(bool, "outline", "Enable outline module") orelse true; - const enable_worldmarkers = b.option(bool, "worldmarkers", "Enable world markers module") orelse true; - const enable_framecrash = b.option(bool, "framecrash", "Enable framecrash fix") orelse false; - const enable_logsessions = b.option(bool, "logsessions", "Enable log session rotation") orelse true; - const enable_minimapicons = b.option(bool, "minimapicons", "Enable custom minimap icons") orelse true; - const enable_transmogfix = b.option(bool, "transmogfix", "Enable transmog update coalescing") orelse true; - const enable_customassets = b.option(bool, "customassets", "Enable loose file loading & permissive patch glob") orelse true; - const enable_healtextfix = b.option(bool, "healtextfix", "Enable SuperWoW heal text fix") orelse true; - const enable_bigcursor = b.option(bool, "bigcursor", "Enable big cursor module") orelse true; - - // Create build options module const build_options = b.addOptions(); - build_options.addOption(bool, "enable_screenshot", enable_screenshot); - build_options.addOption(bool, "enable_interact", enable_interact); - build_options.addOption(bool, "enable_outline", enable_outline); - build_options.addOption(bool, "enable_worldmarkers", enable_worldmarkers); - build_options.addOption(bool, "enable_framecrash", enable_framecrash); - build_options.addOption(bool, "enable_logsessions", enable_logsessions); - build_options.addOption(bool, "enable_minimapicons", enable_minimapicons); - build_options.addOption(bool, "enable_transmogfix", enable_transmogfix); - build_options.addOption(bool, "enable_customassets", enable_customassets); - build_options.addOption(bool, "enable_healtextfix", enable_healtextfix); - build_options.addOption(bool, "enable_bigcursor", enable_bigcursor); + inline for (module_list) |mod| { + build_options.addOption(bool, "enable_" ++ mod.name, b.option(bool, mod.name, mod.desc) orelse mod.default); + } const build_options_module = build_options.createModule(); const zhook_dep = b.dependency("zhook", .{ @@ -55,42 +57,19 @@ pub fn build(b: *std.Build) void { }, }), }); - b.installArtifact(lib); // Convenience step to build all single-module variants const build_all_step = b.step("all-variants", "Build all DLL variants"); - // Helper to create a single-module build - const Variant = struct { name: []const u8, screenshot: bool, interact: bool, outline: bool, worldmarkers: bool, framecrash: bool, logsessions: bool, minimapicons: bool, transmogfix: bool, customassets: bool, healtextfix: bool, bigcursor: bool }; - inline for (&[_]Variant{ - .{ .name = "screenshot", .screenshot = true, .interact = false, .outline = false, .worldmarkers = false, .framecrash = true, .logsessions = true, .minimapicons = false, .transmogfix = false, .customassets = false, .healtextfix = false, .bigcursor = false }, - .{ .name = "interact", .screenshot = false, .interact = true, .outline = false, .worldmarkers = false, .framecrash = true, .logsessions = true, .minimapicons = false, .transmogfix = false, .customassets = false, .healtextfix = false, .bigcursor = false }, - .{ .name = "outline", .screenshot = false, .interact = false, .outline = true, .worldmarkers = false, .framecrash = true, .logsessions = true, .minimapicons = false, .transmogfix = false, .customassets = false, .healtextfix = false, .bigcursor = false }, - .{ .name = "worldmarkers", .screenshot = false, .interact = false, .outline = false, .worldmarkers = true, .framecrash = true, .logsessions = true, .minimapicons = false, .transmogfix = false, .customassets = false, .healtextfix = false, .bigcursor = false }, - .{ .name = "framecrash", .screenshot = false, .interact = false, .outline = false, .worldmarkers = false, .framecrash = true, .logsessions = false, .minimapicons = false, .transmogfix = false, .customassets = false, .healtextfix = false, .bigcursor = false }, - .{ .name = "logsessions", .screenshot = false, .interact = false, .outline = false, .worldmarkers = false, .framecrash = false, .logsessions = true, .minimapicons = false, .transmogfix = false, .customassets = false, .healtextfix = false, .bigcursor = false }, - .{ .name = "minimapicons", .screenshot = false, .interact = false, .outline = false, .worldmarkers = false, .framecrash = true, .logsessions = false, .minimapicons = true, .transmogfix = false, .customassets = false, .healtextfix = false, .bigcursor = false }, - .{ .name = "transmogfix", .screenshot = false, .interact = false, .outline = false, .worldmarkers = false, .framecrash = false, .logsessions = false, .minimapicons = false, .transmogfix = true, .customassets = false, .healtextfix = false, .bigcursor = false }, - .{ .name = "customassets", .screenshot = false, .interact = false, .outline = false, .worldmarkers = false, .framecrash = false, .logsessions = false, .minimapicons = false, .transmogfix = false, .customassets = true, .healtextfix = false, .bigcursor = false }, - .{ .name = "healtextfix", .screenshot = false, .interact = false, .outline = false, .worldmarkers = false, .framecrash = false, .logsessions = false, .minimapicons = false, .transmogfix = false, .customassets = false, .healtextfix = true, .bigcursor = false }, - .{ .name = "bigcursor", .screenshot = false, .interact = false, .outline = false, .worldmarkers = false, .framecrash = false, .logsessions = false, .minimapicons = false, .transmogfix = false, .customassets = false, .healtextfix = false, .bigcursor = true }, - }) |variant| { + inline for (module_list) |variant_mod| { const opts = b.addOptions(); - opts.addOption(bool, "enable_screenshot", variant.screenshot); - opts.addOption(bool, "enable_interact", variant.interact); - opts.addOption(bool, "enable_outline", variant.outline); - opts.addOption(bool, "enable_worldmarkers", variant.worldmarkers); - opts.addOption(bool, "enable_framecrash", variant.framecrash); - opts.addOption(bool, "enable_logsessions", variant.logsessions); - opts.addOption(bool, "enable_minimapicons", variant.minimapicons); - opts.addOption(bool, "enable_transmogfix", variant.transmogfix); - opts.addOption(bool, "enable_customassets", variant.customassets); - opts.addOption(bool, "enable_healtextfix", variant.healtextfix); - opts.addOption(bool, "enable_bigcursor", variant.bigcursor); + inline for (module_list) |m| { + opts.addOption(bool, "enable_" ++ m.name, std.mem.eql(u8, m.name, variant_mod.name)); + } const variant_lib = b.addLibrary(.{ - .name = variant.name, + .name = variant_mod.name, .linkage = .dynamic, .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), diff --git a/docs/CLAUDE_PROPER_OBJECT_REGISTRATION_PLAN.md b/docs/CLAUDE_PROPER_OBJECT_REGISTRATION_PLAN.md index 811e411..667bb17 100644 --- a/docs/CLAUDE_PROPER_OBJECT_REGISTRATION_PLAN.md +++ b/docs/CLAUDE_PROPER_OBJECT_REGISTRATION_PLAN.md @@ -56,7 +56,7 @@ Also review the CLI Ghidra skill: ## Execution Plan -## Phase 1 — Identify canonical native registration path +## Phase 1 - Identify canonical native registration path ### Task 1.1: Recover true iterator and linkage semantics - Disassemble around `0x00683F80` (and nearby helpers) in detail. @@ -80,7 +80,7 @@ Also review the CLI Ghidra skill: --- -## Phase 2 — Build proper registration wrapper in markers module +## Phase 2 - Build proper registration wrapper in markers module ### Task 2.1: Implement native-path wrapper - Add a wrapper that uses discovered native creator/registration APIs. @@ -101,7 +101,7 @@ Also review the CLI Ghidra skill: --- -## Phase 3 — Validation protocol (must pass) +## Phase 3 - Validation protocol (must pass) Run and capture logs for: diff --git a/docs/MASK_OUTLINE_DESIGN.md b/docs/MASK_OUTLINE_DESIGN.md index fd4edda..c386d21 100644 --- a/docs/MASK_OUTLINE_DESIGN.md +++ b/docs/MASK_OUTLINE_DESIGN.md @@ -1,12 +1,12 @@ -# Outline Rendering Design — Requirements & Approach Analysis +# Outline Rendering Design - Requirements & Approach Analysis ## Date: 2026-02-24 ## Requirements (Exact) 1. **Walls, terrain, game objects (doors, pillars)**: MUST occlude outlines -2. **Players and NPCs**: MUST NOT occlude outlines — outlines are specifically for making enemies easier to see in combat, so they must always show over other units -3. **Dead friendly players**: Outlines visible through EVERYTHING (including walls) — for finding corpses to resurrect +2. **Players and NPCs**: MUST NOT occlude outlines - outlines are specifically for making enemies easier to see in combat, so they must always show over other units +3. **Dead friendly players**: Outlines visible through EVERYTHING (including walls) - for finding corpses to resurrect Any architectural approach is acceptable. The existing 3-pass stencil code is proof-of-concept, not a constraint. Efficiency and cleanliness matter more than preserving existing code. @@ -18,7 +18,7 @@ The depth buffer doesn't distinguish "wall pixel" from "player pixel." After a f - If you skip depth testing → players don't occlude (✓) but walls also don't occlude (✗) - If you composite in EndScene after all rendering → outlines are on top of everything, including walls (✗) -**You need depth information from BEFORE players/NPCs have rendered, but AFTER terrain/WMOs have rendered.** This is only available at a specific point during the frame — when M2 model batches begin processing. +**You need depth information from BEFORE players/NPCs have rendered, but AFTER terrain/WMOs have rendered.** This is only available at a specific point during the frame - when M2 model batches begin processing. ## Approach Analysis @@ -46,7 +46,7 @@ The depth buffer doesn't distinguish "wall pixel" from "player pixel." After a f - Without batch reordering, player depth may already be present → players occlude mask pixels - With batch reordering, the mask captures the right silhouette, but compositing in EndScene draws OVER walls that rendered later -The composite happens at the wrong time — after everything has rendered, including walls that should occlude. +The composite happens at the wrong time - after everything has rendered, including walls that should occlude. **Could work if combined with batch reordering** and a depth-aware composite, but this reintroduces the batch reordering requirement and adds render target + fullscreen quad overhead on top. @@ -61,16 +61,16 @@ Copy the depth buffer at the start of M2 rendering (after terrain/WMOs, before p Same core idea as Approach A but optimized: 1. **Pass 1 (stencil + outline):** Set stencil to mark body. Draw the expanded outline geometry with custom VS, using stencil to exclude body pixels. Write `STENCIL_BIT_OUTLINE` where outline draws. -2. **Pass 2 (normal):** Draw model normally (game's original state). This is the draw that would have happened anyway — just done after the outline. +2. **Pass 2 (normal):** Draw model normally (game's original state). This is the draw that would have happened anyway - just done after the outline. -Wait — this is still 3 DIP calls (stencil mark needs the normal geometry first). The passes can't easily be collapsed because the stencil mark (body silhouette) must exist before the outline can exclude it. +Wait - this is still 3 DIP calls (stencil mark needs the normal geometry first). The passes can't easily be collapsed because the stencil mark (body silhouette) must exist before the outline can exclude it. ### Approach E: Inverted Hull with Game's Depth (Refined Stencil) Same 3-pass stencil, but: - **Use the game's own VS for pass 1 and 3** (no custom shader needed for stencil mark + normal draw) -- **Custom VS only for pass 2** (outline expansion) — this is the only pass that needs modified geometry -- **Minimize state changes** — only save/restore what we actually modify +- **Custom VS only for pass 2** (outline expansion) - this is the only pass that needs modified geometry +- **Minimize state changes** - only save/restore what we actually modify - **Skip vertex declaration swap** if the game's declaration is compatible with our VS This is what the current code already does, just cleaned up. @@ -109,12 +109,12 @@ The Reset hook releases shaders. Ensure `shaders_attempted` is reset so they're ### 6. Consider vs_3_0 upgrade -The current VS uses vs_2_0. The game supports ps_3_0 (confirmed 0xFFFF0300), so vs_3_0 is available. Benefits: better precision for screen-space normal calculation, no instruction count limit. However vs_2_0 works and is simpler — this is optional. +The current VS uses vs_2_0. The game supports ps_3_0 (confirmed 0xFFFF0300), so vs_3_0 is available. Benefits: better precision for screen-space normal calculation, no instruction count limit. However vs_2_0 works and is simpler - this is optional. ## Implementation Order -1. Update `types.zig` — add any missing D3D9 constants -2. Update `d3d9_hook.zig` — add ZWRITEENABLE, DEPTHBIAS, stride check -3. Verify `model_hook.zig` — batch reordering and stencil flags are correct +1. Update `types.zig` - add any missing D3D9 constants +2. Update `d3d9_hook.zig` - add ZWRITEENABLE, DEPTHBIAS, stride check +3. Verify `model_hook.zig` - batch reordering and stencil flags are correct 4. Build and verify compilation 5. Test (login screen → in-game with live targets) diff --git a/docs/calling-conventions.md b/docs/calling-conventions.md index 1c69883..89b06a8 100644 --- a/docs/calling-conventions.md +++ b/docs/calling-conventions.md @@ -1,4 +1,4 @@ -# WoW 1.12.1 Calling Conventions — Ghidra Verified +# 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. @@ -6,17 +6,17 @@ All conventions verified against WoW.exe 1.12.1 build 5875 via Ghidra decompilat | # | 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 | +| 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 | +| 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 @@ -36,11 +36,11 @@ E8 ... CALL FindObjectByGUID 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 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) +## Dead Overlay Functions (not yet ported - for future reference) | # | Address | Function | Convention | Params | Status | |---|---------|----------|------------|--------|--------| @@ -55,23 +55,23 @@ 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 | +| 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 | +| 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 | +| 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 | +| 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. +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 @@ -81,14 +81,14 @@ Takes a `double` (8 bytes) which is too large for EDX, so it goes on the stack p | # | Address | Function | Convention | Params | Prologue | RET | Status | |---|---------|----------|------------|--------|----------|-----|--------| -| 24 | `0x0042a320` | ValidateFunctionPointer | `__fastcall` | ECX=addr | `55 8B EC 83 EC 40` (6B) | — | CORRECT (empty detour) | +| 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 | +| 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) | +| 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 diff --git a/docs/jfa-banding-investigation.md b/docs/jfa-banding-investigation.md index 8b00e54..240572d 100644 --- a/docs/jfa-banding-investigation.md +++ b/docs/jfa-banding-investigation.md @@ -8,7 +8,7 @@ Outlines rendered via the JFA pipeline have a "marching ants" pattern of missing ## What Was Ruled Out ### 1. Silhouette RT is clean (CONFIRMED) -Added `DEBUG_SHOW_SILHOUETTE` comptime flag in `d3d9_hook.zig` that skips JFA and composites the raw silhouette RT directly to the backbuffer. The silhouette was solid with no banding — the cached draw replay produces correct geometry. **Stale VB hypothesis is NOT the cause.** +Added `DEBUG_SHOW_SILHOUETTE` comptime flag in `d3d9_hook.zig` that skips JFA and composites the raw silhouette RT directly to the backbuffer. The silhouette was solid with no banding - the cached draw replay produces correct geometry. **Stale VB hypothesis is NOT the cause.** ### 2. JFA sentinel value (1.0, 1.0) → (-1.0, -1.0) (NO IMPROVEMENT) Changed the JFA init shader sentinel from `(1.0, 1.0)` to `(-1.0, -1.0)` so unflooded pixels can't act as false seeds near the right screen edge. This did NOT fix the marching ants pattern. The sentinel is currently set to `(-1.0, -1.0)` in the code (uncommitted). @@ -29,7 +29,7 @@ Batch reorder puts outline targets last so depth buffer has full scene geometry - `DEBUG_SHOW_SILHOUETTE` comptime flag added (currently `false`), with `debug_sil_ps` shader - Debug shader had a cmp operand inversion bug that was fixed (`c0.w, c0.x` not `c0.x, c0.w`) -## Remaining Investigation — JFA Pipeline Bug +## Remaining Investigation - JFA Pipeline Bug Since the silhouette is clean, the bug is in the JFA shaders (Phase 2). Possible causes: @@ -37,25 +37,25 @@ Since the silhouette is clean, the bug is in the JFA shaders (Phase 2). Possible The propagation shader uses `cmp` (ps_3_0) which tests `>= 0` vs `< 0`. When `new_dist² == best_dist²` (exactly equal), `cmp` picks the OLD seed (`>= 0` branch). This tie-breaking might cause systematic bias where seeds from certain directions are always preferred, creating directional artifacts. Worth testing: swap `cmp` operands to prefer new seed on ties, or add a small epsilon. ### B. dp2add precision on DXVK -`dp2add r2.z, r2, r2, c1.x` computes `r2.x*r2.x + r2.y*r2.y + 0.0`. DXVK translates this to Vulkan — there may be precision differences vs native D3D9 that affect distance comparisons, especially for pixels equidistant from multiple seeds. +`dp2add r2.z, r2, r2, c1.x` computes `r2.x*r2.x + r2.y*r2.y + 0.0`. DXVK translates this to Vulkan - there may be precision differences vs native D3D9 that affect distance comparisons, especially for pixels equidistant from multiple seeds. ### C. Neighbor sampling at texture edges When `mad r4.xy, offset, step_uv, v0.xy` goes outside [0,1], CLAMP addressing returns the edge texel. This could feed stale/wrong seed UVs into the comparison. Clamping the sample coordinate to valid range before comparison could help. ### D. The JFA algorithm itself may not suit this use case Consider alternative approaches: -- **Screen-space dilation** (iterative morphological expand of silhouette) — simpler, no distance field needed -- **Gaussian blur difference** — blur silhouette, subtract original, threshold +- **Screen-space dilation** (iterative morphological expand of silhouette) - simpler, no distance field needed +- **Gaussian blur difference** - blur silhouette, subtract original, threshold - **Sobel/edge detection** on the silhouette RT - Docs in `/media/storage/projects/zig/weirdutils/docs/` describe these alternatives - Reference articles: ameye.dev "5 ways to draw an outline", Ben Golus "Quest for Very Wide Outlines" ## Key Files -- `src/outline/d3d9_hook.zig` — D3D9 hooks, JFA pipeline, all shaders -- `src/outline/model_hook.zig` — batch reordering, rendering_outline flag -- `src/outline/tracker.zig` — per-frame model tracking -- `src/outline/types.zig` — D3D9 constants -- `reference/c_overlay/d3d9_hook.cpp` — C reference (uses 3-pass shell extrusion, not JFA) +- `src/outline/d3d9_hook.zig` - D3D9 hooks, JFA pipeline, all shaders +- `src/outline/model_hook.zig` - batch reordering, rendering_outline flag +- `src/outline/tracker.zig` - per-frame model tracking +- `src/outline/types.zig` - D3D9 constants +- `reference/c_overlay/d3d9_hook.cpp` - C reference (uses 3-pass shell extrusion, not JFA) ## Build / Environment - `zig build` from `/media/storage/projects/zig/weirdutils/` diff --git a/docs/outline-port-notes.md b/docs/outline-port-notes.md index 1a671a8..3ef1cc3 100644 --- a/docs/outline-port-notes.md +++ b/docs/outline-port-notes.md @@ -14,9 +14,9 @@ Pure Zig reimplementation of the WoW 1.12.1 unit outline system, ported from the **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. +- `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). @@ -34,9 +34,9 @@ Vtable obtained by creating a temporary `IDirect3DDevice9` via `Direct3DCreate9` 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. +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 @@ -56,7 +56,7 @@ Pixel shader: `ps_3_0`, outputs solid colour from `c0`. | `+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! | +| `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 | @@ -64,9 +64,9 @@ Pixel shader: `ps_3_0`, outputs solid colour from `c0`. ## 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 +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 @@ -76,8 +76,8 @@ Pixel shader: `ps_3_0`, outputs solid colour from `c0`. ## 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. +- 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-rendering-research.md b/docs/outline-rendering-research.md index 7a1938f..75d76e5 100644 --- a/docs/outline-rendering-research.md +++ b/docs/outline-rendering-research.md @@ -36,7 +36,7 @@ Uses **depth-buffer + Sobel edge detection** tightly integrated into the renderi - During the skinned mesh rendering pass, shaders write **scaled depth** to a secondary buffer via **Multiple Render Targets (MRT)**. - Outlines are produced by running a **Sobel filter** on that scaled depth buffer. The Sobel filter finds discontinuities in depth corresponding to silhouette edges. -- The detected edge is rendered back over the skinned mesh — done **per-mesh individually**, not as a single full-screen post-process. +- The detected edge is rendered back over the skinned mesh - done **per-mesh individually**, not as a single full-screen post-process. - For GPUs that do not support MRT, there is a **fallback using stencil buffers**. - Rendering order places outlines as a dedicated stage between skinned meshes and grass/water in a 13-stage pipeline. @@ -48,13 +48,13 @@ Sources: ### Valve Source Engine (Left 4 Dead / DOTA 2 / TF2) -Uses the **"L4D Glow Effect"** — a **stencil + render-to-texture + blur** approach. Used across Left 4 Dead, TF2, CS:GO, and DOTA 2 (pre-Source 2): +Uses the **"L4D Glow Effect"** - a **stencil + render-to-texture + blur** approach. Used across Left 4 Dead, TF2, CS:GO, and DOTA 2 (pre-Source 2): 1. **Stencil pass**: Draw the entity onto the Stencil Buffer. Creates a "cutout" mask of the entity's silhouette. 2. **Color pass**: Draw the entity with the desired glow color (flat/constant color) onto a separate Render Target ("GlowBuff1"). 3. **Blur + composite**: Blur GlowBuff1 (using a second RT "GlowBuff2" for ping-pong blur passes), then render the blurred result to the screen **while respecting the stencil buffer**. The stencil test ensures only the blurred pixels that extend beyond the entity's silhouette are visible, producing a halo/outline effect. -The stencil cutout is the key innovation — it prevents the glow color from appearing inside the character, so you only see the outline fringe. +The stencil cutout is the key innovation - it prevents the glow color from appearing inside the character, so you only see the outline fringe. Sources: - https://developer.valvesoftware.com/wiki/L4D_Glow_Effect @@ -94,10 +94,10 @@ Sources: **How it works:** -Render each target's silhouette as flat color to an offscreen render target (depth-tested against terrain for alive, no depth for dead). Then run a pixel shader that samples an NxN neighborhood — if any sample is "on", the pixel is outline. Subtract the original mask to get just the ring. Composite over backbuffer. +Render each target's silhouette as flat color to an offscreen render target (depth-tested against terrain for alive, no depth for dead). Then run a pixel shader that samples an NxN neighborhood - if any sample is "on", the pixel is outline. Subtract the original mask to get just the ring. Composite over backbuffer. ```hlsl -// SM3.0 pixel shader — fixed-size box dilation +// SM3.0 pixel shader - fixed-size box dilation sampler2D SilhouetteTex; float2 TexelSize; // (1.0/screenW, 1.0/screenH) @@ -131,7 +131,7 @@ if (length(float2(x, y)) <= OutlineRadius) { **Cons:** - Square corners at large radii (box kernel artifact) -- Cost grows as O(N^2) with outline width — impractical beyond ~8px +- Cost grows as O(N^2) with outline width - impractical beyond ~8px - Needs 2-3 render targets **Performance:** 49 texture samples per pixel at 1024x768 = ~38M samples. On modern hardware: effectively free (<1ms). On 2004-era hardware: 2-4ms. @@ -144,7 +144,7 @@ if (length(float2(x, y)) <= OutlineRadius) { The JFA (Rong & Tan, 2006) computes an approximate 2D distance transform on the GPU using O(log N) pixel shader passes. This is the foundation of high-quality screen-space outlines in modern games. -**Step 1 — Seed initialization:** +**Step 1 - Seed initialization:** Render unit silhouettes into a binary mask. An init shader reads this mask: "on" pixels output their own UV coordinates, "off" pixels get a sentinel value (e.g., `(9999, 9999)`). Output format: RG16F (two channels for x,y coordinates). ```hlsl @@ -159,7 +159,7 @@ float4 SeedInitPS(float2 uv : TEXCOORD0) : COLOR0 } ``` -**Step 2 — JFA propagation (iterative):** +**Step 2 - JFA propagation (iterative):** Execute `ceil(log2(maxOutlineRadius))` passes. For pass k, step size = `2^(N-k-1)` (starts large, halves each pass). Each pixel samples itself and 8 compass neighbors at the step offset (9 total samples in a 3x3 grid with large spacing). Keep the seed coordinate nearest to the current pixel. Ping-pong between two render targets. ```hlsl @@ -190,7 +190,7 @@ float4 JFAPassPS(float2 uv : TEXCOORD0) : COLOR0 } ``` -**Step 3 — Distance readout and outline generation:** +**Step 3 - Distance readout and outline generation:** After all passes, each texel holds the UV of the nearest seed. Convert to pixel-space distance and threshold: ```hlsl @@ -210,12 +210,12 @@ float4 OutlinePS(float2 uv : TEXCOORD0) : COLOR0 } ``` -**D3D9/SM3.0 compatibility:** Fully compatible. Each pass is a simple pixel shader with 9 texture samples and simple arithmetic. No gather, no integer bitops, no geometry/compute shaders required. Ping-pong between two textures is standard D3D9. The only requirement is that D3D9 does not allow reading and writing the same surface — alternate between two textures each pass. +**D3D9/SM3.0 compatibility:** Fully compatible. Each pass is a simple pixel shader with 9 texture samples and simple arithmetic. No gather, no integer bitops, no geometry/compute shaders required. Ping-pong between two textures is standard D3D9. The only requirement is that D3D9 does not allow reading and writing the same surface - alternate between two textures each pass. **Pros:** -- Exact circular distance field — perfectly round outlines at any width +- Exact circular distance field - perfectly round outlines at any width - Anti-aliasable (smoothstep on the distance) -- Cost is O(log2(N)) passes — a 32px outline costs only 5 passes +- Cost is O(log2(N)) passes - a 32px outline costs only 5 passes - Enables soft glow, pulsing, gradient effects for free (just change threshold function) - Nothing requires anything beyond SM2.0 @@ -227,7 +227,7 @@ float4 OutlinePS(float2 uv : TEXCOORD0) : COLOR0 **Performance:** 10 fullscreen passes at 9 samples each = 90M samples at 1024x768. On modern hardware: sub-millisecond. Can run at half resolution (512x384) to halve cost with minimal quality loss for outlines up to 5-6px. -**Occlusion:** Same as dilation — mask generation is independent of outline generation. +**Occlusion:** Same as dilation - mask generation is independent of outline generation. **JFA quality:** Approximation error bounded at sqrt(2)/2 pixels at jump step boundaries. For outlines up to ~20px, visually imperceptible. Results are smooth, rotationally symmetric, and anti-aliasable. @@ -247,7 +247,7 @@ Sources: **How it works:** -Render each target with a unique ID value into an R8 render target (depth-tested). Run a 3x3 Sobel filter — pixels where neighboring IDs differ are edges. +Render each target with a unique ID value into an R8 render target (depth-tested). Run a 3x3 Sobel filter - pixels where neighboring IDs differ are edges. ```hlsl float4 SobelEdgePS(float2 uv : TEXCOORD0) : COLOR0 @@ -276,7 +276,7 @@ float4 SobelEdgePS(float2 uv : TEXCOORD0) : COLOR0 - No variable-thickness artifacts **Cons:** -- Produces only 1-2px outlines — can't thicken without adding dilation anyway +- Produces only 1-2px outlines - can't thicken without adding dilation anyway - Detects unit-to-unit boundaries too (unwanted internal edges between overlapping characters) - Alone, not sufficient for controllable-width outlines @@ -291,7 +291,7 @@ float4 SobelEdgePS(float2 uv : TEXCOORD0) : COLOR0 Render silhouette to RT. Separable Gaussian blur (H pass + V pass). Subtract original from blurred → outline. Can downsample to 1/4 res for performance (like retail WoW does for bloom). ```hlsl -// Gaussian blur 5-tap (separable — run horizontal then vertical) +// Gaussian blur 5-tap (separable - run horizontal then vertical) float weights[5] = {0.0625, 0.25, 0.375, 0.25, 0.0625}; float4 GaussianBlurPS(float2 uv : TEXCOORD0) : COLOR0 @@ -320,13 +320,13 @@ float4 OutlineExtractPS(float2 uv : TEXCOORD0) : COLOR0 - Downsampling to 1/4 res makes a 4px kernel act like a 16px outline **Cons:** -- Soft/gradient edges, not crisp — looks like a glow, not a hard outline +- Soft/gradient edges, not crisp - looks like a glow, not a hard outline - Width control is imprecise (tied to blur sigma) - Can't produce a hard-edged outline without thresholding (which re-introduces aliasing) **Performance:** 2 fullscreen passes with 5 samples each = 10 samples total. Very cheap. -**Occlusion:** Same as dilation — mask generation is independent. +**Occlusion:** Same as dilation - mask generation is independent. ### 5. Normal Extrusion (Current Approach, Refined) @@ -371,12 +371,12 @@ This makes outline thickness uniform in screen pixels at any depth. All screen-space techniques (1-4) share a two-phase architecture that differs fundamentally from the current normal-extrusion approach: -**Phase A — Silhouette mask generation (in DIP hook, per-unit)** +**Phase A - Silhouette mask generation (in DIP hook, per-unit)** - For alive targets: render unit geometry with depth test ON against scene depth → writes to RT_Silhouette - For dead targets: render with depth test OFF → writes to RT_Dead - This is where the 3 occlusion requirements are enforced -**Phase B — Outline generation + composite (in EndScene, once per frame)** +**Phase B - Outline generation + composite (in EndScene, once per frame)** - Dilate/JFA/blur the mask → extract outline ring → alpha-blend over backbuffer - This is purely 2D, knows nothing about depth @@ -397,9 +397,9 @@ Requirement 2 (other units don't occlude outlines) is the hardest to satisfy. It **JFA with batch reordering for Req 2.** Rationale: -- Batch reordering already solves Req 2 without needing a secondary depth buffer — outline targets render when only terrain depth exists +- Batch reordering already solves Req 2 without needing a secondary depth buffer - outline targets render when only terrain depth exists - JFA gives the best outline quality (uniform, circular, anti-aliased, any width) at O(log2(N)) cost -- The outline width of 2-3px only needs ~2 JFA passes — nearly free +- The outline width of 2-3px only needs ~2 JFA passes - nearly free - Glow/pulse effects come for free if desired - Everything is SM2.0 compatible, let alone SM3.0 - The silhouette mask pass replaces the current pass 1+2 (stencil body + normal extrusion) with a simpler "render flat color to RT" @@ -409,9 +409,9 @@ Rationale: **Resources to create at device creation/reset:** ``` -RT_Silhouette: RGBA8, screen size — mask for all outline targets -RT_JFA_A: RG16F, screen size — JFA ping-pong buffer A -RT_JFA_B: RG16F, screen size — JFA ping-pong buffer B +RT_Silhouette: RGBA8, screen size - mask for all outline targets +RT_JFA_A: RG16F, screen size - JFA ping-pong buffer A +RT_JFA_B: RG16F, screen size - JFA ping-pong buffer B ``` **Hook intercept points:** @@ -454,15 +454,15 @@ Restore all after outline composite. ## Additional References -- "Inking the Cube" (GPU Gems 1, Chapter 11, Everitt) — screen-space dilation -- "Advanced Techniques in Real-Time Rendering" (GDC 2011, de Carpentier) — screen-space outlines -- "Post-Processing Effects in Games" (GDC 2013, Wihlidal) — Sobel ID-buffer approach +- "Inking the Cube" (GPU Gems 1, Chapter 11, Everitt) - screen-space dilation +- "Advanced Techniques in Real-Time Rendering" (GDC 2011, de Carpentier) - screen-space outlines +- "Post-Processing Effects in Games" (GDC 2013, Wihlidal) - Sobel ID-buffer approach - Unreal Engine 4 custom depth/stencil outline documentation -- https://ameye.dev/notes/rendering-outlines/ — "5 Ways to Draw an Outline" -- https://linework.ameye.dev/soft-outline/ — soft outline documentation +- https://ameye.dev/notes/rendering-outlines/ - "5 Ways to Draw an Outline" +- https://linework.ameye.dev/soft-outline/ - soft outline documentation - https://www.codeproject.com/Articles/128527/Stencil-Buffer-Glows-Part-1 - https://www.codeproject.com/Articles/156323/Stencil-Buffer-Glows-Part-2 - https://www.tomlooman.com/unreal-engine-soft-outline/ -- https://aras-p.info/texts/D3D9GPUHacks.html — D3D9 GPU hacks reference -- https://ameye.dev/notes/edge-detection-outlines/ — edge detection outlines -- https://www.videopoetics.com/tutorials/pixel-perfect-outline-shaders-unity/ — pixel-perfect outlines +- https://aras-p.info/texts/D3D9GPUHacks.html - D3D9 GPU hacks reference +- https://ameye.dev/notes/edge-detection-outlines/ - edge detection outlines +- https://www.videopoetics.com/tutorials/pixel-perfect-outline-shaders-unity/ - pixel-perfect outlines diff --git a/docs/outline-validation.md b/docs/outline-validation.md index 8b9e506..9496180 100644 --- a/docs/outline-validation.md +++ b/docs/outline-validation.md @@ -64,7 +64,7 @@ Verified 2026-02-23 using Ghidra MCP against WoW.exe 1.12.1 (build 5875, 4,907,0 ### 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. +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 @@ -81,4 +81,4 @@ Raw bytes read from each hook target address via `get_bytes`. Instruction bounda ### 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). +- 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/ideas/ground-projected-raid-markers.md b/ideas/ground-projected-raid-markers.md index 781fb81..dec23d3 100644 --- a/ideas/ground-projected-raid-markers.md +++ b/ideas/ground-projected-raid-markers.md @@ -18,8 +18,8 @@ ### Model Rendering Hooks - **File:** `src/outline/model_hook.zig` -- `CM2SceneRenderDraw` hook — batch reordering for depth control -- `CM2Scene_DrawBatchProjected` hook — per-batch interception +- `CM2SceneRenderDraw` hook - batch reordering for depth control +- `CM2Scene_DrawBatchProjected` hook - per-batch interception - Access to render context and model pointers - Batch reordering ensures outline targets render when only terrain+WMO depth exists @@ -40,10 +40,10 @@ ### UnitXP SP3 Reference - **Path:** `/media/storage/projects/UnitXP_SP3_Orig/UnitXP_SP3/` - **Key file:** `Vanilla1121_functions.h` -- `vanilla1121_worldToScreen(C3Vector& world)` — world → screen projection -- `CWorld_Intersect()` — raycast through world geometry -- `vanilla1121_unitPosition()` — unit world position -- `vanilla1121_getCameraPosition()` — camera world position +- `vanilla1121_worldToScreen(C3Vector& world)` - world → screen projection +- `CWorld_Intersect()` - raycast through world geometry +- `vanilla1121_unitPosition()` - unit world position +- `vanilla1121_getCameraPosition()` - camera world position ### WoWee Terrain Renderer Reference - **Path:** `/media/storage/projects/WoWee/src/rendering/terrain_renderer.cpp` diff --git a/include/weirdutils_api.h b/include/weirdutils_api.h new file mode 100644 index 0000000..a56a44c --- /dev/null +++ b/include/weirdutils_api.h @@ -0,0 +1,82 @@ +/* + * weirdutils_api.h - Runtime Module Control API for WeirdUtils + * + * Header-only, no .lib needed. Include this file and call the inline + * wrappers; they resolve the DLL exports at runtime via GetModuleHandleA + * and GetProcAddress. Returns 0 / no-ops if WeirdUtils is not loaded. + * + * Usage: + * #include "weirdutils_api.h" + * + * if (WeirdUtils_IsModuleActive("transmogfix")) + * WeirdUtils_DisableModule("transmogfix"); + */ + +#ifndef WEIRDUTILS_API_H +#define WEIRDUTILS_API_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- Function pointer typedefs ---- */ + +typedef int (__cdecl *WeirdUtils_IsModuleActiveFn)(const char *name); +typedef int (__cdecl *WeirdUtils_DisableModuleFn)(const char *name); +typedef int (__cdecl *WeirdUtils_DisableAllFn)(void); + +/* ---- DLL auto-discovery ---- */ + +static const char *const WeirdUtils__DllNames[] = { + "weirdutils.dll", + "worldmarkers.dll", + "outline.dll", + "interact.dll", + "screenshot.dll", + "framecrash.dll", + "transmogfix.dll", + "customassets.dll", + "logsessions.dll", + "healtextfix.dll", + "bigcursor.dll", + "minimapicons.dll", + "dpslog.dll", + NULL +}; + +static FARPROC WeirdUtils__Resolve(const char *func_name) { + int i; + for (i = 0; WeirdUtils__DllNames[i] != NULL; ++i) { + HMODULE hMod = GetModuleHandleA(WeirdUtils__DllNames[i]); + if (hMod) { + FARPROC proc = GetProcAddress(hMod, func_name); + if (proc) return proc; + } + } + return NULL; +} + +/* ---- Inline wrappers (no-op if DLL not loaded) ---- */ + +static int WeirdUtils_IsModuleActive(const char *name) { + WeirdUtils_IsModuleActiveFn fn = (WeirdUtils_IsModuleActiveFn)WeirdUtils__Resolve("WeirdUtils_IsModuleActive"); + return fn ? fn(name) : 0; +} + +static int WeirdUtils_DisableModule(const char *name) { + WeirdUtils_DisableModuleFn fn = (WeirdUtils_DisableModuleFn)WeirdUtils__Resolve("WeirdUtils_DisableModule"); + return fn ? fn(name) : 0; +} + +static int WeirdUtils_DisableAll(void) { + WeirdUtils_DisableAllFn fn = (WeirdUtils_DisableAllFn)WeirdUtils__Resolve("WeirdUtils_DisableAll"); + return fn ? fn() : 0; +} + +#ifdef __cplusplus +} +#endif + +#endif /* WEIRDUTILS_API_H */ diff --git a/src/bigcursor/bigcursor.zig b/src/bigcursor/bigcursor.zig index 7d7e690..b0905ea 100644 --- a/src/bigcursor/bigcursor.zig +++ b/src/bigcursor/bigcursor.zig @@ -1,51 +1,693 @@ //! Big cursor module. //! -//! Increases the hardware cursor render size for improved visibility. +//! Hooks IDirect3DDevice9::SetCursorProperties to upscale the hardware cursor +//! using the hqx pixel-art scaling algorithm, then sets an enlarged Win32 cursor +//! via CreateIconIndirect (bypassing D3D9's 32x32 limit). //! -//! TODO: Research cursor rendering pipeline and hook implementation. +//! Supports fractional scales (e.g. 1.5x): hqx to next integer, then bilinear +//! downsample to exact target size. const std = @import("std"); +const hook = @import("zhook"); const con = @import("../console.zig"); +const mod_mutex = @import("../mutex.zig"); const WINAPI = std.builtin.CallingConvention.winapi; -extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?*anyopaque; -extern "kernel32" fn ReleaseMutex(hMutex: *anyopaque) callconv(WINAPI) i32; -extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32; -extern "kernel32" fn GetLastError() callconv(WINAPI) u32; -extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32; -const ERROR_ALREADY_EXISTS: u32 = 183; +const sc: std.builtin.CallingConvention = .{ .x86_stdcall = .{} }; +const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} }; + +pub const module_name: [*:0]const u8 = "bigcursor"; var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; -pub fn installHooks() void { - con.print("[bigcursor] Module loaded (stub)\n"); +// ============================================================================= +// File logging (survives crashes — console closes too fast) +// ============================================================================= - // Multi-DLL safety: only one instance per process should hook - var mutex_name_buf: [64]u8 = undefined; - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_BigcursorHook_{d}", .{GetCurrentProcessId()}) catch return; - mutex_name_buf[mutex_name.len] = 0; +extern "kernel32" fn CreateFileA(name: [*:0]const u8, access: u32, share: u32, sa: ?*anyopaque, disp: u32, flags: u32, template: ?*anyopaque) callconv(WINAPI) ?*anyopaque; +extern "kernel32" fn WriteFile(handle: *anyopaque, buf: [*]const u8, len: u32, written: ?*u32, overlapped: ?*anyopaque) callconv(WINAPI) i32; +extern "kernel32" fn FlushFileBuffers(handle: *anyopaque) callconv(WINAPI) i32; +extern "kernel32" fn CloseHandle(handle: *anyopaque) callconv(WINAPI) i32; - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return; +const INVALID_HANDLE: usize = 0xFFFFFFFF; +var g_logfile: ?*anyopaque = null; - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; - con.print("[bigcursor] Another DLL owns hooks (mutex taken), skipping\n"); +fn logInit() void { + const h = CreateFileA("bigcursor_debug.log", 0x40000000, 1, null, 2, 0x80, null); // GENERIC_WRITE, FILE_SHARE_READ, CREATE_ALWAYS, NORMAL + if (h) |handle| { + if (@intFromPtr(handle) != INVALID_HANDLE) { + g_logfile = handle; + } + } +} + +fn logDeinit() void { + if (g_logfile) |h| { + _ = CloseHandle(h); + g_logfile = null; + } +} + +fn log(msg: []const u8) void { + con.print(msg); + if (g_logfile) |h| { + _ = WriteFile(h, msg.ptr, @intCast(msg.len), null, null); + _ = FlushFileBuffers(h); + } +} + +fn logFmt(comptime f: []const u8, args: anytype) void { + var buf: [512]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, f, args) catch return; + log(msg); +} + +// ============================================================================= +// Scale2x — edge-aware pixel-art 2x upscaler (EPX/AdvMAME2x) +// +// For each pixel P with neighbors A(up) B(right) C(left) D(down): +// E0 = (C==A && C!=D && A!=B) ? A : P E1 = (A==B && A!=C && B!=D) ? B : P +// E2 = (D==C && D!=B && C!=A) ? C : P E3 = (B==D && B!=A && D!=C) ? D : P +// ============================================================================= + +fn scale2x(src: [*]const u32, src_w: u32, src_h: u32, dst: [*]u32, dst_w: u32) void { + for (0..src_h) |jj| { + const j: u32 = @intCast(jj); + for (0..src_w) |ii| { + const i: u32 = @intCast(ii); + const p = src[j * src_w + i]; + const a = if (j > 0) src[(j - 1) * src_w + i] else p; + const b = if (i < src_w - 1) src[j * src_w + i + 1] else p; + const c = if (i > 0) src[j * src_w + i - 1] else p; + const d = if (j < src_h - 1) src[(j + 1) * src_w + i] else p; + + const oy = j * 2; + const ox = i * 2; + dst[oy * dst_w + ox] = if (c == a and c != d and a != b) a else p; + dst[oy * dst_w + ox + 1] = if (a == b and a != c and b != d) b else p; + dst[(oy + 1) * dst_w + ox] = if (d == c and d != b and c != a) c else p; + dst[(oy + 1) * dst_w + ox + 1] = if (b == d and b != a and d != c) d else p; + } + } +} + +// ============================================================================= +// Scale3x — edge-aware pixel-art 3x upscaler (AdvMAME3x) +// +// Neighbors: A(up) B(right) C(left) D(down) + diagonals +// Uses same edge detection as Scale2x, extended to 3x3 output block. +// ============================================================================= + +fn scale3x(src: [*]const u32, src_w: u32, src_h: u32, dst: [*]u32, dst_w: u32) void { + for (0..src_h) |jj| { + const j: u32 = @intCast(jj); + for (0..src_w) |ii| { + const i: u32 = @intCast(ii); + const idx = j * src_w + i; + const e = src[idx]; // center pixel (P) + const b = if (j > 0) src[idx - src_w] else e; // up + const h = if (j < src_h - 1) src[idx + src_w] else e; // down + const d = if (i > 0) src[idx - 1] else e; // left + const f = if (i < src_w - 1) src[idx + 1] else e; // right + const a = if (j > 0 and i > 0) src[idx - src_w - 1] else e; + const c = if (j > 0 and i < src_w - 1) src[idx - src_w + 1] else e; + const g = if (j < src_h - 1 and i > 0) src[idx + src_w - 1] else e; + const ii_ = if (j < src_h - 1 and i < src_w - 1) src[idx + src_w + 1] else e; + + const oy = j * 3; + const ox = i * 3; + + if (b != h and d != f) { + dst[oy * dst_w + ox] = if (d == b) d else e; + dst[oy * dst_w + ox + 1] = if (d == b and e != c or b == f and e != a) b else e; + dst[oy * dst_w + ox + 2] = if (b == f) f else e; + dst[(oy + 1) * dst_w + ox] = if (d == b and e != g or d == h and e != a) d else e; + dst[(oy + 1) * dst_w + ox + 1] = e; + dst[(oy + 1) * dst_w + ox + 2] = if (b == f and e != ii_ or h == f and e != c) f else e; + dst[(oy + 2) * dst_w + ox] = if (d == h) d else e; + dst[(oy + 2) * dst_w + ox + 1] = if (d == h and e != ii_ or h == f and e != g) h else e; + dst[(oy + 2) * dst_w + ox + 2] = if (h == f) f else e; + } else { + dst[oy * dst_w + ox] = e; + dst[oy * dst_w + ox + 1] = e; + dst[oy * dst_w + ox + 2] = e; + dst[(oy + 1) * dst_w + ox] = e; + dst[(oy + 1) * dst_w + ox + 1] = e; + dst[(oy + 1) * dst_w + ox + 2] = e; + dst[(oy + 2) * dst_w + ox] = e; + dst[(oy + 2) * dst_w + ox + 1] = e; + dst[(oy + 2) * dst_w + ox + 2] = e; + } + } + } +} + +// ============================================================================= +// Win32 externs +// ============================================================================= + +extern "kernel32" fn VirtualProtect(addr: *anyopaque, size: usize, new_prot: u32, old_prot: *u32) callconv(WINAPI) i32; + +extern "gdi32" fn CreateBitmap(w: i32, h: i32, planes: u32, bpp: u32, bits: ?*const anyopaque) callconv(WINAPI) ?*anyopaque; +extern "gdi32" fn DeleteObject(obj: *anyopaque) callconv(WINAPI) i32; + +extern "user32" fn CreateIconIndirect(info: *IconInfo) callconv(WINAPI) ?*anyopaque; +extern "user32" fn DestroyCursor(cursor: *anyopaque) callconv(WINAPI) i32; +extern "user32" fn SetCursor(cursor: ?*anyopaque) callconv(WINAPI) ?*anyopaque; + +const IconInfo = extern struct { + fIcon: i32 = 0, + xHotspot: u32 = 0, + yHotspot: u32 = 0, + hbmMask: ?*anyopaque = null, + hbmColor: ?*anyopaque = null, +}; + +// ============================================================================= +// WoW CVar API (1.12.1 build 5875) +// ============================================================================= + +const CVAR_LOOKUP: usize = 0x0063DEC0; + +// RegisterCVar: __fastcall(ECX=name, EDX=help, stack: unk1, default, callback, category, unk2, unk3) +const RegisterCVarFn = *const fn ([*:0]const u8, u32, u32, [*:0]const u8, u32, u32, u32, u32) callconv(fc) u32; +const registerCVar: RegisterCVarFn = @ptrFromInt(0x0063DB90); + +const CVAR_NAME = "cursorScale"; + +/// Read CVar integer value (tenths: 15 = 1.5x). Returns f32 scale. +fn readCVarScaleInit() f32 { + const cvar_ptr = hook.fastcall(u32, CVAR_LOOKUP, @intFromPtr(@as([*:0]const u8, CVAR_NAME)), @as(u32, 0)); + if (cvar_ptr == 0) return g_scale_f; + // CVar struct: integer value at offset +40 bytes (10 pointer-sized fields) + const val = hook.readMem(i32, cvar_ptr + 40); + if (val >= 10 and val <= 40) { + return @as(f32, @floatFromInt(val)) / 10.0; + } + return g_scale_f; +} + +// ============================================================================= +// D3D9 vtable indices +// ============================================================================= + +const VT_SetCursorProperties: usize = 10; +const VT_ShowCursor: usize = 12; + +// IDirect3DSurface9 vtable +const SVT_GetDesc: usize = 12; +const SVT_LockRect: usize = 13; +const SVT_UnlockRect: usize = 14; + +// D3D9 constants +const D3DFMT_A8R8G8B8: u32 = 21; + +// Game's GxDevice → IDirect3DDevice9 pointer chain +const GX_DEVICE_PTR: usize = 0xC0ED38; +const GX_DEVICE_D3D_OFFSET: usize = 0x38A8; + +// ============================================================================= +// COM helpers +// ============================================================================= + +inline fn vtbl(obj: *anyopaque) [*]usize { + return @ptrFromInt(hook.readMem(u32, @intFromPtr(obj))); +} + +// ============================================================================= +// State +// ============================================================================= + +var d3d9_vtable: ?[*]usize = null; +var orig_set_cursor_props: usize = 0; +var orig_show_cursor: usize = 0; +var hooks_installed: bool = false; + +var g_scale_f: f32 = 1.2; // fractional scale (1.0 = off, 1.2 = default, etc.) +var g_hcursor: ?*anyopaque = null; // current enlarged HCURSOR +var g_cursor_visible: bool = false; + +// Simple pixel hash cache for ~10 distinct WoW cursor bitmaps. +// Cache key includes scale so changing scale invalidates. +const MAX_CACHE = 16; +const CacheEntry = struct { + hash: u64 = 0, + hcursor: ?*anyopaque = null, + valid: bool = false, +}; +var g_cache: [MAX_CACHE]CacheEntry = [_]CacheEntry{.{}} ** MAX_CACHE; +var g_cache_count: u32 = 0; + +// ============================================================================= +// FNV-1a hash for cursor pixel data (includes scale in hash) +// ============================================================================= + +fn hashPixels(data: [*]const u8, len: usize, scale_bits: u32) u64 { + var h: u64 = 0xcbf29ce484222325; + // Mix scale into hash so different scales produce different cache keys + h ^= scale_bits; + h *%= 0x100000001b3; + for (data[0..len]) |byte| { + h ^= byte; + h *%= 0x100000001b3; + } + return h; +} + +// ============================================================================= +// Surface helpers +// ============================================================================= + +const D3DLOCKED_RECT = extern struct { + Pitch: i32 = 0, + pBits: ?[*]u8 = null, +}; + +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, +}; + +fn surfaceGetDesc(surface: *anyopaque, desc: *D3DSURFACE_DESC) i32 { + const f: *const fn (*anyopaque, *D3DSURFACE_DESC) callconv(sc) i32 = @ptrFromInt(vtbl(surface)[SVT_GetDesc]); + return f(surface, desc); +} + +fn surfaceLockRect(surface: *anyopaque, locked: *D3DLOCKED_RECT, rect: ?*anyopaque, flags: u32) i32 { + const f: *const fn (*anyopaque, *D3DLOCKED_RECT, ?*anyopaque, u32) callconv(sc) i32 = @ptrFromInt(vtbl(surface)[SVT_LockRect]); + return f(surface, locked, rect, flags); +} + +fn surfaceUnlockRect(surface: *anyopaque) i32 { + const f: *const fn (*anyopaque) callconv(sc) i32 = @ptrFromInt(vtbl(surface)[SVT_UnlockRect]); + return f(surface); +} + +// ============================================================================= +// Bilinear resampler (downsample hqx output to exact target size) +// ============================================================================= + +fn lerpChannel(a: u32, b: u32, c: u32, d: u32, fx: f32, fy: f32) u8 { + const fa: f32 = @floatFromInt(a); + const fb: f32 = @floatFromInt(b); + const fc_: f32 = @floatFromInt(c); + const fd: f32 = @floatFromInt(d); + const val = fa * (1.0 - fx) * (1.0 - fy) + fb * fx * (1.0 - fy) + fc_ * (1.0 - fx) * fy + fd * fx * fy; + return @intFromFloat(@min(@max(val, 0.0), 255.0)); +} + +fn bilinearResample( + src: [*]const u32, + src_w: u32, + src_h: u32, + dst: [*]u32, + dst_w: u32, + dst_h: u32, +) void { + const sw_f: f32 = @floatFromInt(src_w); + const sh_f: f32 = @floatFromInt(src_h); + const dw_f: f32 = @floatFromInt(dst_w); + const dh_f: f32 = @floatFromInt(dst_h); + + for (0..dst_h) |dy| { + const sy_f = (@as(f32, @floatFromInt(dy)) + 0.5) * sh_f / dh_f - 0.5; + const sy_floor = @max(sy_f, 0.0); + const sy0: u32 = @intFromFloat(sy_floor); + const sy1 = @min(sy0 + 1, src_h - 1); + const fy = sy_f - @as(f32, @floatFromInt(sy0)); + + for (0..dst_w) |dx| { + const sx_f = (@as(f32, @floatFromInt(dx)) + 0.5) * sw_f / dw_f - 0.5; + const sx_floor = @max(sx_f, 0.0); + const sx0: u32 = @intFromFloat(sx_floor); + const sx1 = @min(sx0 + 1, src_w - 1); + const fx = sx_f - @as(f32, @floatFromInt(sx0)); + + const c00 = src[sy0 * src_w + sx0]; + const c10 = src[sy0 * src_w + sx1]; + const c01 = src[sy1 * src_w + sx0]; + const c11 = src[sy1 * src_w + sx1]; + + const b = lerpChannel(c00 & 0xFF, c10 & 0xFF, c01 & 0xFF, c11 & 0xFF, fx, fy); + const g = lerpChannel((c00 >> 8) & 0xFF, (c10 >> 8) & 0xFF, (c01 >> 8) & 0xFF, (c11 >> 8) & 0xFF, fx, fy); + const r = lerpChannel((c00 >> 16) & 0xFF, (c10 >> 16) & 0xFF, (c01 >> 16) & 0xFF, (c11 >> 16) & 0xFF, fx, fy); + const a = lerpChannel((c00 >> 24) & 0xFF, (c10 >> 24) & 0xFF, (c01 >> 24) & 0xFF, (c11 >> 24) & 0xFF, fx, fy); + + dst[dy * dst_w + dx] = @as(u32, a) << 24 | @as(u32, r) << 16 | @as(u32, g) << 8 | @as(u32, b); + } + } +} + +// ============================================================================= +// Create enlarged Win32 cursor from BGRA pixel data +// ============================================================================= + +// Static buffers — single-threaded D3D9 calls, no contention. +var s_src_buf: [32 * 32]u32 = undefined; +var s_scaled_buf: [128 * 128]u32 = undefined; // Scale2x/3x output +var s_dst_buf: [128 * 128]u32 = undefined; // final output (after bilinear) +var s_mask_buf: [128 * 128 / 8]u8 = undefined; + +fn createEnlargedCursor( + src_pixels: [*]const u8, + src_pitch: u32, + src_w: u32, + src_h: u32, + hotspot_x: u32, + hotspot_y: u32, + scale_f: f32, +) ?*anyopaque { + // Compute final target dimensions + const dst_w: u32 = @intFromFloat(@as(f32, @floatFromInt(src_w)) * scale_f); + const dst_h: u32 = @intFromFloat(@as(f32, @floatFromInt(src_h)) * scale_f); + + if (dst_w > 128 or dst_h > 128 or dst_w < 1 or dst_h < 1) return null; + if (src_w > 32 or src_h > 32) return null; + + // Pick smallest scale factor that covers the target: 2x, 3x, or 2x+2x=4x + const int_factor: u32 = if (scale_f <= 2.0) 2 else if (scale_f <= 3.0) 3 else 4; + const scaled_w = src_w * int_factor; + const scaled_h = src_h * int_factor; + const needs_resample = (dst_w != scaled_w or dst_h != scaled_h); + + // Copy source pixels to contiguous buffer (surface pitch may differ from width*4) + const src_row_bytes = src_w * 4; + for (0..src_h) |y| { + const src_row = src_pixels + y * src_pitch; + const dst_row: [*]u8 = @ptrCast(&s_src_buf[y * src_w]); + @memcpy(dst_row[0..src_row_bytes], src_row[0..src_row_bytes]); + } + + // Run edge-aware pixel-art upscaler + switch (int_factor) { + 2 => scale2x(&s_src_buf, src_w, src_h, &s_scaled_buf, scaled_w), + 3 => scale3x(&s_src_buf, src_w, src_h, &s_scaled_buf, scaled_w), + 4 => { + // 4x = two passes of Scale2x + scale2x(&s_src_buf, src_w, src_h, &s_dst_buf, src_w * 2); + scale2x(&s_dst_buf, src_w * 2, src_h * 2, &s_scaled_buf, scaled_w); + }, + else => return null, + } + + // Bilinear resample to exact target size if needed + const final_pixels: [*]u32 = if (needs_resample) blk: { + bilinearResample(&s_scaled_buf, scaled_w, scaled_h, &s_dst_buf, dst_w, dst_h); + break :blk &s_dst_buf; + } else &s_scaled_buf; + + // Create Win32 cursor via CreateIconIndirect + const mask_bytes = dst_w * dst_h / 8; + @memset(s_mask_buf[0..mask_bytes], 0xFF); + + const hbm_mask = CreateBitmap(@intCast(dst_w), @intCast(dst_h), 1, 1, &s_mask_buf) orelse return null; + const hbm_color = CreateBitmap(@intCast(dst_w), @intCast(dst_h), 1, 32, final_pixels) orelse { + _ = DeleteObject(hbm_mask); + return null; + }; + + const hot_x: u32 = @intFromFloat(@as(f32, @floatFromInt(hotspot_x)) * scale_f); + const hot_y: u32 = @intFromFloat(@as(f32, @floatFromInt(hotspot_y)) * scale_f); + var info = IconInfo{ + .fIcon = 0, + .xHotspot = hot_x, + .yHotspot = hot_y, + .hbmMask = hbm_mask, + .hbmColor = hbm_color, + }; + + const hcursor = CreateIconIndirect(&info); + + _ = DeleteObject(hbm_mask); + _ = DeleteObject(hbm_color); + + return hcursor; +} + +// ============================================================================= +// Cache management +// ============================================================================= + +fn cacheLookup(pixel_hash: u64) ?*anyopaque { + for (&g_cache) |*entry| { + if (entry.valid and entry.hash == pixel_hash) + return entry.hcursor; + } + return null; +} + +fn cacheInsert(pixel_hash: u64, hcursor: *anyopaque) void { + var idx: u32 = g_cache_count; + if (idx >= MAX_CACHE) { + if (g_cache[0].hcursor) |old| _ = DestroyCursor(old); + for (0..MAX_CACHE - 1) |i| g_cache[i] = g_cache[i + 1]; + idx = MAX_CACHE - 1; + } else { + g_cache_count += 1; + } + g_cache[idx] = .{ .hash = pixel_hash, .hcursor = hcursor, .valid = true }; +} + +fn cacheClear() void { + for (&g_cache) |*entry| { + if (entry.hcursor) |old| _ = DestroyCursor(old); + entry.* = .{}; + } + g_cache_count = 0; +} + +// ============================================================================= +// Hook: IDirect3DDevice9::SetCursorProperties (VT slot 10) +// ============================================================================= + +fn hkSetCursorProperties(device: *anyopaque, x_hotspot: u32, y_hotspot: u32, cursor_surface: *anyopaque) callconv(sc) i32 { + const origFn: *const fn (*anyopaque, u32, u32, *anyopaque) callconv(sc) i32 = + @ptrFromInt(orig_set_cursor_props); + + if (g_scale_f <= 1.0) return origFn(device, x_hotspot, y_hotspot, cursor_surface); + + // Get surface dimensions and format + var desc: D3DSURFACE_DESC = .{}; + const hr_desc = surfaceGetDesc(cursor_surface, &desc); + if (hr_desc < 0) return origFn(device, x_hotspot, y_hotspot, cursor_surface); + + if (desc.Format != D3DFMT_A8R8G8B8 or desc.Width > 32 or desc.Height > 32) { + return origFn(device, x_hotspot, y_hotspot, cursor_surface); + } + + // Lock source surface to read pixels + var locked: D3DLOCKED_RECT = .{}; + const hr_lock = surfaceLockRect(cursor_surface, &locked, null, 0x10); + if (hr_lock < 0) return origFn(device, x_hotspot, y_hotspot, cursor_surface); + + const bits = locked.pBits orelse { + _ = surfaceUnlockRect(cursor_surface); + return origFn(device, x_hotspot, y_hotspot, cursor_surface); + }; + const pitch: u32 = if (locked.Pitch > 0) @intCast(locked.Pitch) else { + _ = surfaceUnlockRect(cursor_surface); + return origFn(device, x_hotspot, y_hotspot, cursor_surface); + }; + + // Hash pixel data + scale for cache key + const data_size = pitch * desc.Height; + const scale_bits: u32 = @bitCast(g_scale_f); + const pixel_hash = hashPixels(bits, data_size, scale_bits); + + // Check cache + if (cacheLookup(pixel_hash)) |cached_cursor| { + _ = surfaceUnlockRect(cursor_surface); + g_hcursor = cached_cursor; + const result = origFn(device, x_hotspot, y_hotspot, cursor_surface); + if (g_cursor_visible) _ = SetCursor(g_hcursor); + return result; + } + + // Run hqx upscale + bilinear downsample + if (createEnlargedCursor(bits, pitch, desc.Width, desc.Height, x_hotspot, y_hotspot, g_scale_f)) |new_cursor| { + cacheInsert(pixel_hash, new_cursor); + g_hcursor = new_cursor; + } + + _ = surfaceUnlockRect(cursor_surface); + + const result = origFn(device, x_hotspot, y_hotspot, cursor_surface); + + if (g_hcursor != null and g_cursor_visible) { + _ = SetCursor(g_hcursor); + } + + return result; +} + +// ============================================================================= +// Hook: IDirect3DDevice9::ShowCursor (VT slot 12) +// ============================================================================= + +fn hkShowCursor(device: *anyopaque, bShow: i32) callconv(sc) i32 { + const origFn: *const fn (*anyopaque, i32) callconv(sc) i32 = + @ptrFromInt(orig_show_cursor); + + g_cursor_visible = bShow != 0; + + if (g_hcursor != null and g_scale_f > 1.0) { + // Hide D3D9's 32x32 hardware cursor — we use a Win32 cursor instead + _ = origFn(device, 0); + if (g_cursor_visible) { + _ = SetCursor(g_hcursor); + } else { + _ = SetCursor(null); + } + return if (g_cursor_visible) 1 else 0; + } + + return origFn(device, bShow); +} + +// ============================================================================= +// Lua API: SetCursorScale(n) / GetCursorScale() +// ============================================================================= + +fn luaPushNumber(L_ptr: usize, n: f64) void { + const raw: [2]u32 = @bitCast(n); + asm volatile ( + \\push %[hi] + \\push %[lo] + \\call *%[func] + : + : [_] "{ecx}" (L_ptr), + [lo] "r" (raw[0]), + [hi] "r" (raw[1]), + [func] "r" (@as(u32, 0x6F3810)), + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); +} + +pub fn luaSetCursorScale(L: *anyopaque) callconv(.c) u32 { + const L_ptr = @intFromPtr(L); + const nargs = hook.fastcall(i32, 0x6F3070, L_ptr, @as(u32, 0)); // lua_gettop + if (nargs < 1) return 0; + + const val: f32 = @floatCast(hook.fastcall(f64, 0x6F3620, L_ptr, @as(i32, 1))); // lua_tonumber + if (val < 1.0 or val > 4.0) return 0; + + if (val != g_scale_f) { + g_scale_f = val; + cacheClear(); + g_hcursor = null; + logFmt("[bigcursor] scale set to {d:.2}\n", .{g_scale_f}); + } + return 0; +} + +pub fn luaGetCursorScale(L: *anyopaque) callconv(.c) u32 { + luaPushNumber(@intFromPtr(L), @floatCast(g_scale_f)); + return 1; +} + +// ============================================================================= +// Vtable patching +// ============================================================================= + +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); +} + +fn getD3D9VTable() ?[*]usize { + const gx_device = hook.readMem(u32, GX_DEVICE_PTR); + if (gx_device == 0) return null; + const d3d_device = hook.readMem(u32, gx_device + GX_DEVICE_D3D_OFFSET); + if (d3d_device == 0) return null; + const vtable_addr = hook.readMem(u32, d3d_device); + if (vtable_addr == 0) return null; + return @ptrFromInt(vtable_addr); +} + +// ============================================================================= +// Late init (called from engineInitDetour when D3D9 device exists) +// ============================================================================= + +pub fn lateInit() void { + if (!g_is_hook_owner) return; + if (hooks_installed) return; + + const vt = getD3D9VTable() orelse { + con.print("[bigcursor] D3D9 device not available yet\n"); + return; + }; + d3d9_vtable = vt; + + if (!patchVtableEntry(vt, VT_SetCursorProperties, @intFromPtr(&hkSetCursorProperties), &orig_set_cursor_props)) { + con.print("[bigcursor] failed to hook SetCursorProperties\n"); return; } - g_is_hook_owner = true; + if (!patchVtableEntry(vt, VT_ShowCursor, @intFromPtr(&hkShowCursor), &orig_show_cursor)) { + con.print("[bigcursor] failed to hook ShowCursor\n"); + restoreVtableEntry(vt, VT_SetCursorProperties, orig_set_cursor_props); + return; + } + + hooks_installed = true; + + // Register CVar for persistence (tenths: 15 = 1.5x, 20 = 2.0x) + _ = registerCVar(CVAR_NAME, 0, 0, "12", 0, 1, 0, 0); + g_scale_f = readCVarScaleInit(); + logFmt("[bigcursor] D3D9 hooks installed (scale={d:.1}x)\n", .{g_scale_f}); +} + +// ============================================================================= +// Module lifecycle +// ============================================================================= + +pub fn isActive() bool { + return g_is_hook_owner; +} + +pub fn installHooks() void { + logInit(); + log("[bigcursor] Module loaded\n"); + + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; } pub fn removeHooks() void { - if (g_is_hook_owner) { - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; + if (hooks_installed) { + if (d3d9_vtable) |vt| { + if (orig_show_cursor != 0) restoreVtableEntry(vt, VT_ShowCursor, orig_show_cursor); + if (orig_set_cursor_props != 0) restoreVtableEntry(vt, VT_SetCursorProperties, orig_set_cursor_props); } + hooks_installed = false; + } + + cacheClear(); + g_hcursor = null; + + if (g_is_hook_owner) { + mod_mutex.release(&g_mutex); } g_is_hook_owner = false; + logDeinit(); } diff --git a/src/console.zig b/src/console.zig index 9f7902c..0609a38 100644 --- a/src/console.zig +++ b/src/console.zig @@ -1,4 +1,4 @@ -//! Debug console — compiles out entirely in non-Debug builds. +//! Debug console - compiles out entirely in non-Debug builds. //! //! Usage from any module: //! const con = @import("../console.zig"); // or appropriate relative path diff --git a/src/customassets/customassets.zig b/src/customassets/customassets.zig index bbb4cf8..e631661 100644 --- a/src/customassets/customassets.zig +++ b/src/customassets/customassets.zig @@ -15,7 +15,7 @@ const hook = @import("zhook"); const con = @import("../console.zig"); // ============================================================================= -// Windows API (project-specific — not in hook lib) +// Windows API (project-specific - not in hook lib) // ============================================================================= const WINAPI = std.builtin.CallingConvention.winapi; @@ -276,29 +276,25 @@ fn revertLooseFilePatches() void { // Init / Cleanup // ============================================================================= +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "customassets"; + var installed: bool = false; var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; +pub fn isActive() bool { + return g_is_hook_owner; +} + pub fn installHooks() void { con.print("[customassets] Module loaded\n"); - // Multi-DLL safety: only one instance per process should hook - var mutex_name_buf: [64]u8 = undefined; - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_CustomAssetsHook_{d}", .{GetCurrentProcessId()}) catch return; - mutex_name_buf[mutex_name.len] = 0; - - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return; - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; - con.print("[customassets] Another DLL owns hooks (mutex taken), skipping\n"); - return; - } - g_is_hook_owner = true; + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; applyGlobPatch(); applyLooseFilePatches(); @@ -317,11 +313,7 @@ pub fn removeHooks() void { } if (g_is_hook_owner) { - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; - } + mod_mutex.release(&g_mutex); } g_is_hook_owner = false; } diff --git a/src/dpslog/dpslog.zig b/src/dpslog/dpslog.zig new file mode 100644 index 0000000..972f837 --- /dev/null +++ b/src/dpslog/dpslog.zig @@ -0,0 +1,34 @@ +//! DPS log module. +//! +//! Provides structured Lua objects for combat log events so addons can read +//! parsed fields directly instead of re-parsing the combat log string. +//! +//! TODO: Hook combat log event dispatch, build Lua tables per event type. + +const con = @import("../console.zig"); +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "dpslog"; + +var g_mutex: ?*anyopaque = null; +var g_is_hook_owner: bool = false; + +pub fn isActive() bool { + return g_is_hook_owner; +} + +pub fn installHooks() void { + con.print("[dpslog] Module loaded (stub)\n"); + + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; +} + +pub fn removeHooks() void { + if (g_is_hook_owner) { + mod_mutex.release(&g_mutex); + } + g_is_hook_owner = false; +} diff --git a/src/framecrash/RESEARCH.md b/src/framecrash/RESEARCH.md index be10e76..67b333f 100644 --- a/src/framecrash/RESEARCH.md +++ b/src/framecrash/RESEARCH.md @@ -30,7 +30,7 @@ ESI=03319888 EDI=303A4588 EBP=00F2FADC ESP=00F2FACC processGraphicsFrame (0x764330) → renderAllFrameLayers (0x765650) → processFrameUpdates - → DispatchHeartbeatEvent (0x76b2c0) — fires OnUpdate + → DispatchHeartbeatEvent (0x76b2c0) - fires OnUpdate → ExecuteLuaCallback (0x704f10) → luaD_pcall (0x6f6960) → luaCallFunction (0x6f6050) @@ -66,14 +66,14 @@ This is the Lua API `frame:GetPoint(index)`. It returns anchor point info: 0x007a2408: CMP EBX,0x9 ; 9 anchor point types max 0x007a240b: JL 0x007a23f8 -; === Found anchor — get relativeTo frame === +; === Found anchor - get relativeTo frame === 0x007a2416: MOV EDX,[EDI] ; anchor vtable 0x007a2418: MOV ECX,EDI ; this = anchor 0x007a241a: CALL [EDX + 0xc] ; vtable[3]() → GetRelativeTo → returns raw ptr 0x007a241d: TEST EAX,EAX 0x007a241f: JZ 0x007a24d7 ; NULL → safe "no relativeTo" path -; Second call to same vfunc — gets value for real this time +; Second call to same vfunc - gets value for real this time 0x007a2425: MOV EAX,[EDI] 0x007a2429: CALL [EAX + 0xc] ; vtable[3]() again 0x007a242c: TEST EAX,EAX @@ -106,19 +106,19 @@ the point name + x/y offsets, skipping the relativeTo frame entirely. ## Anchor Object Structure (0x14 = 20 bytes) -Discovered from `SetAnimationOrder` (0x00767c70) — the internal C++ `SetPoint`: +Discovered from `SetAnimationOrder` (0x00767c70) - the internal C++ `SetPoint`: ``` Offset Size Field +0x00 4 vtable pointer = PTR_GetAnimationOrder_0081c44c +0x04 4 x offset (float) +0x08 4 y offset (float) -+0x0C 4 relativeTo frame pointer (RAW — no refcount, no validation!) ++0x0C 4 relativeTo frame pointer (RAW - no refcount, no validation!) +0x10 4 relative point enum (uint) ``` **Vtable address**: `0x0081c44c` (in .rdata) -**vtable[3]** (at vtable+0xC = `0x0081c458`): GetRelativeTo — simply returns `this+0x0C` +**vtable[3]** (at vtable+0xC = `0x0081c458`): GetRelativeTo - simply returns `this+0x0C` ### Anchor Creation (in SetAnimationOrder) ```c @@ -164,7 +164,7 @@ invalidation mechanism.** When the relativeTo frame is destroyed: | 0x403f50 | `ValidateObjectPointer` | Calls `IsBadReadPtr` via `[0x007ff2b8]` | | 0x4c38a0 | `FrameScript_ValidateMemory` | Global memory validation | -### Type ID Globals (runtime values, .bss — not readable from Ghidra) +### Type ID Globals (runtime values, .bss - not readable from Ghidra) | Address | Name | |---------|------| | 0x00cf0c10 | `g_ParentFrameTypeID` | @@ -175,20 +175,20 @@ invalidation mechanism.** When the relativeTo frame is destroyed: ### Frame Lifecycle | Address | Name | |---------|------| -| 0x773240 | `DestroyFrame` — clears fields +0/+4, unlinks | -| 0x4c34a0 | `DestroyFrameScriptObject` — clears lua ref, unlinks from list, frees | +| 0x773240 | `DestroyFrame` - clears fields +0/+4, unlinks | +| 0x4c34a0 | `DestroyFrameScriptObject` - clears lua ref, unlinks from list, frees | | 0x4c3510 | `FrameScript_InsertIntoList` | | 0x4c3c10 | `FrameScript_UnlinkFromList` | -| 0x701bd0 | `RegisterFrameScriptReference` — creates Lua table + metatable for frame | +| 0x701bd0 | `RegisterFrameScriptReference` - creates Lua table + metatable for frame | ### Anchor Functions | Address | Name | |---------|------| -| 0x767c70 | `SetAnimationOrder` — internal SetPoint (creates anchor object) | -| 0x768010 | `IsAnimationPlaying` — recursive anchor dependency check | -| 0x7a2340 | `luaGetPoint` — Lua API, CRASH SITE | -| 0x7a2540 | `luaSetPoint` — Lua API | -| 0x7a2940 | `luaClearAllPoints` — Lua API | +| 0x767c70 | `SetAnimationOrder` - internal SetPoint (creates anchor object) | +| 0x768010 | `IsAnimationPlaying` - recursive anchor dependency check | +| 0x7a2340 | `luaGetPoint` - Lua API, CRASH SITE | +| 0x7a2540 | `luaSetPoint` - Lua API | +| 0x7a2940 | `luaClearAllPoints` - Lua API | ### Windows API | IAT Address | API | @@ -267,7 +267,7 @@ ESI=16D046A8 EDI=17077C2C EBP=00F2F7EC ESP=00F2F7D8 0x007A2FB8: F6 41 3C 02 TEST byte ptr [ECX+0x3C], 0x02 ; ECX=0 → reads 0x0000003C ``` -ECX is NULL — the frame/layout object pointer is missing. +ECX is NULL - the frame/layout object pointer is missing. **Note**: EDX=0x0081C44C (the anchor vtable) is just a leftover register value, NOT caused by our vtable hook. EDX is not used at the crash site. @@ -275,18 +275,18 @@ NOT caused by our vtable hook. EDX is not used at the crash site. ### Call Chain ``` GetAnimationSmoothing (0x768d20) - → calculate_negative... (0x7673d0) — pushes 0x0081C3D0, calls SetFrameHitTestMode - → SetFrameHitTestMode (0x7671a0) — iterates anchors, calls vtable[1] (+0x04) + → calculate_negative... (0x7673d0) - pushes 0x0081C3D0, calls SetFrameHitTestMode + → SetFrameHitTestMode (0x7671a0) - iterates anchors, calls vtable[1] (+0x04) → luaGetWidth (0x7a2f90) ← CRASH: ECX=NULL ``` -This crashes during **frame layout calculation at startup** — the frame layout +This crashes during **frame layout calculation at startup** - the frame layout system is computing dimensions, and a frame's layout dependency (parent or anchor target) is NULL. ### Key Disassembly (crash site in luaGetWidth) ```asm -; luaGetWidth prologue — sets up local vars +; luaGetWidth prologue - sets up local vars 0x007a2faa: MOV [EBP-8], 0x0 0x007a2fb1: MOV [EBP-4], 0x0 0x007a2fb8: TEST byte ptr [ECX+0x3C], 0x02 ← CRASH (ECX=NULL) @@ -339,7 +339,7 @@ anchor target) is NULL. and dereference it at `+0x3C` **without** a NULL check. The anchor exists, but its `relativeTo` field is NULL (no target frame set). -**`0x0081C3D0` is NOT a vtable** — it's a static array of 3 anchor-point indices: +**`0x0081C3D0` is NOT a vtable** - it's a static array of 3 anchor-point indices: `[0, 3, 6]`, used by the width layout pass. `SetFrameHitTestMode` iterates these indices to look up anchors from the frame's anchor array. @@ -356,7 +356,7 @@ but the actual functions take an extra stack parameter. ### Root Cause `SetFrameHitTestMode` (0x7671a0) calls `vtable[1]` with `PUSH EAX` before the -call — passing 1 stack argument. Since GetWidth/GetHeight are `__thiscall`, the +call - passing 1 stack argument. Since GetWidth/GetHeight are `__thiscall`, the callee must clean up this argument (RET 4). Our hooks with no stack parameter used RET 0, leaving 4 bytes on the stack after every call, causing misalignment and eventual crash. @@ -414,7 +414,7 @@ So given a CLayoutFrame pointer (e.g., relativeTo from an anchor): ## Destruction Path Analysis (Ghidra) -### cleanup_linked_list_structures (0x767720) — The Bottleneck +### cleanup_linked_list_structures (0x767720) - The Bottleneck All frame destruction goes through this function. Exactly 3 direct callers: @@ -424,11 +424,11 @@ All frame destruction goes through this function. Exactly 3 direct callers: | `CleanupRegion` | 0x76c560 | Region cleanup (called by WorldObjectBaseDestructor) | | `cleanupGraphicsResources` | 0x764390 | Graphics teardown | -### WorldObjectBaseDestructor (0x7693b0) — Common Base +### WorldObjectBaseDestructor (0x7693b0) - Common Base All frame-type destructors eventually call `WorldObjectBaseDestructor`, which: -1. Calls `cleanup_linked_list_structures(param_1 + 9)` — **our hook fires here** -2. Calls `CleanupRegion` — also calls `cleanup_linked_list_structures` +1. Calls `cleanup_linked_list_structures(param_1 + 9)` - **our hook fires here** +2. Calls `CleanupRegion` - also calls `cleanup_linked_list_structures` 3. Calls `FrameScript_Destructor` (base class cleanup, list unlinking) 16 callers of WorldObjectBaseDestructor (all are frame-type destructors): @@ -438,12 +438,12 @@ All frame-type destructors eventually call `WorldObjectBaseDestructor`, which: `statusBarCleanupResources`, `cleanupMessageFrameResources`, `cleanupScrollFrame`, `CleanupObjectManager`, `CleanupColorSelectFrame`, `CleanupMovieFrame`, `luaIsVisible` -### DestroyFrame (0x773240) — NOT a Destruction Path +### DestroyFrame (0x773240) - NOT a Destruction Path Only called from `InitializeFrameProperties` (0x7731d0). This is a **re-initialization** path, not frame destruction. No need to hook. -### DestroyFrameScriptObject (0x4c34a0) — Vtable Entry, Already Covered +### DestroyFrameScriptObject (0x4c34a0) - Vtable Entry, Already Covered Referenced only as DATA at `0x806cb8` (vtable slot). `FrameScript_Destructor` (0x4c3690) sets the vtable to this and does list unlinking / FreeMemory. It runs **after** @@ -454,10 +454,10 @@ called by the time this executes. No need to hook. **Our single hook on `cleanup_linked_list_structures` covers all frame destruction paths.** The vtable hooks (GetWidth/GetHeight/GetRelativeTo) remain as defense-in-depth but -are not expected to fire during normal operation — they would only catch bugs in +are not expected to fire during normal operation - they would only catch bugs in our cleanup logic or unknown destruction paths. -### Anchor Vtable (0x0081C44C) — Full Layout +### Anchor Vtable (0x0081C44C) - Full Layout ``` [0] +0x00 = 0x00767d80 → GetAnimationOrder (destructor) [1] +0x04 = 0x007a2f90 → luaGetWidth ← CRASH FUNCTION @@ -465,21 +465,21 @@ our cleanup logic or unknown destruction paths. [3] +0x0C = 0x00767d70 → GetRelativeTo (already hooked) ``` -### Fix Attempt: vtable[1]/[2] hook returning 0.0 — REVERTED +### Fix Attempt: vtable[1]/[2] hook returning 0.0 - REVERTED Hooked vtable[1] (GetWidth) and vtable[2] (GetHeight) with wrappers that returned 0.0 when relativeTo was NULL/dangling. **This broke UI layout** because: 1. 0.0 is not the sentinel value that `SetFrameHitTestMode` expects - (it compares via `FCOMP [0x00cf550c]` — a runtime .bss value) + (it compares via `FCOMP [0x00cf550c]` - a runtime .bss value) 2. The functions have side effects (IsAnimationDone, GetAnimationTarget calls) - that update layout state — skipping them entirely is wrong + that update layout state - skipping them entirely is wrong ### Relationship Between Crash 1 and Crash 2 The GetRelativeTo hook (crash 1 fix) self-heals by NULLing anchor+0x0C when it detects a dangling pointer. But GetWidth/GetHeight read anchor+0x0C **directly** (not through vtable[3]), so they see the now-NULL value and crash. -The two crashes are likely the same underlying issue — the GetRelativeTo hook +The two crashes are likely the same underlying issue - the GetRelativeTo hook is masking the dangling pointer but exposing it as a NULL pointer to other code. --- @@ -491,14 +491,14 @@ is masking the dangling pointer but exposing it as a NULL pointer to other code. WoW's frame system tracks anchor dependencies via `PauseAnimationGroup` / `ResumeAnimationGroup`: -**PauseAnimationGroup(relativeTo_frame, owner_frame, bitmask)** — 0x767ee0 +**PauseAnimationGroup(relativeTo_frame, owner_frame, bitmask)** - 0x767ee0 - Called by `SetAnimationOrder` (SetPoint) when creating an anchor - Maintains a linked list on `relativeTo_frame+0x30/0x34` - Each node is 0x10 bytes: `[link0, next_ptr(+4), owner_frame(+8), bitmask(+C)]` -- Bitmask = `1 << anchor_point_enum` — tracks which anchor slots reference this frame +- Bitmask = `1 << anchor_point_enum` - tracks which anchor slots reference this frame - If owner already in list, ORs in the new bitmask bits -**ResumeAnimationGroup(relativeTo_frame, owner_frame, bitmask)** — 0x767fa0 +**ResumeAnimationGroup(relativeTo_frame, owner_frame, bitmask)** - 0x767fa0 - Called when replacing/removing an anchor - Walks list at `relativeTo_frame+0x34`, finds matching owner - Clears bitmask bits: `node+0xC &= ~bitmask` @@ -506,7 +506,7 @@ WoW's frame system tracks anchor dependencies via `PauseAnimationGroup` / ### Proper Anchor Cleanup (exists but not called on destruction) -**cleanup_array_of_objects** (0x767620) — cleans up a frame's OWN anchors: +**cleanup_array_of_objects** (0x767620) - cleans up a frame's OWN anchors: ```c for i in 0..9: anchor = *(frame + i*4 + 4) // anchor slot @@ -519,13 +519,13 @@ for i in 0..9: ``` Called from: `SetAnimationOrigin` (0x768e20), `StartAnimationGroup` (0x767db0), -`GetAnimationEndDelay` (0x768430) — **never during frame destruction**. +`GetAnimationEndDelay` (0x768430) - **never during frame destruction**. ### Frame Destruction Chain ``` -destroy_object (0x7676f0) — thiscall(frame, free_flag) - -> cleanup_linked_list_structures (0x767720) — thiscall(frame) +destroy_object (0x7676f0) - thiscall(frame, free_flag) + -> cleanup_linked_list_structures (0x767720) - thiscall(frame) -> sets vtable to CLayoutFrame base (0x81c400) -> SetAnimationOrigin(frame) -> cleanup_array_of_objects(frame) ^ cleans up THIS frame's own anchors (forward direction) @@ -576,27 +576,27 @@ First 5 bytes (53 56 8B F1 57) can be replaced with JMP rel32 for a detour. --- -## Stale UIParent Pointer — The Persistent Unknown Destruction Path +## Stale UIParent Pointer - The Persistent Unknown Destruction Path ### Discovery One persistent stale pointer escapes ALL hooked destruction paths. Pattern: - Address always ends in `X008` (e.g., `0x17f20008`, `0x17fb4008`, `0x03bc0008`) -- CFrame base = addr - 0x24 = `XXXXffe4` — crosses page boundary +- CFrame base = addr - 0x24 = `XXXXffe4` - crosses page boundary - First page (containing CFrame base, vtable) is DECOMMITTED - Second page (containing CLayoutFrame inner at +0x24) survives - Frame name at CFrame+0x98 reads garbage (`"t%Ç"`) from residual second-page data -- NOT in destruction history ring buffer — never went through any hooked detour +- NOT in destruction history ring buffer - never went through any hooked detour ### Diagnostic Hooks Added -**PauseAnimationGroup (0x767ee0)** — dependency registration tracker: +**PauseAnimationGroup (0x767ee0)** - dependency registration tracker: - `__thiscall(ECX=relativeTo_frame, owner_frame, bitmask)`, RET 0x8 - Prologue: `55 8B EC 53 8B D9` (6 bytes) - Silently records every registration to a 2048-entry ring buffer - Queried by vtable hooks when stale pointer detected -**SetAnimationOrder (0x767c70)** — anchor creation validator: +**SetAnimationOrder (0x767c70)** - anchor creation validator: - `__thiscall(ECX=frame, point_enum, relativeTo, relPoint, xOfs, yOfs, param_6)`, RET 0x18 - Prologue: `55 8B EC 8B 45 0C` (6 bytes) - Validates relativeTo AND CFrame base (relativeTo - 0x24) with IsBadReadPtr @@ -627,12 +627,12 @@ SetAnimOrder with the dead relativeTo address: ### Race Condition Confirmed -- `DEP REGISTERED` — PauseAnimationGroup WAS called for the address (dependency existed) -- But name at registration time was `"t%Ç"` (garbage) — the frame was ALREADY DEAD +- `DEP REGISTERED` - PauseAnimationGroup WAS called for the address (dependency existed) +- But name at registration time was `"t%Ç"` (garbage) - the frame was ALREADY DEAD when PauseAnimationGroup ran - `SetAnimOrder` RACE check: `IsBadReadPtr(relativeTo - 0x24)` FAILS (first page decommitted), but `IsBadReadPtr(relativeTo)` passes (second page survives) -- The original game code doesn't validate relativeTo at all — just stores the raw pointer +- The original game code doesn't validate relativeTo at all - just stores the raw pointer ### Symptom: Black Screen @@ -644,9 +644,9 @@ but the UI is broken. UIParent is destroyed through an unknown path during a UI reload/transition (character select → world, or loading screen). The destruction does NOT go through: -- `cleanup_linked_list_structures` (0x767720) — hooked, not triggered -- `destroyUIElement` (0x7645a0) — hooked, not triggered -- `ProcessUIUpdateEvent` (0x772ec0) — hooked, not triggered +- `cleanup_linked_list_structures` (0x767720) - hooked, not triggered +- `destroyUIElement` (0x7645a0) - hooked, not triggered +- `ProcessUIUpdateEvent` (0x772ec0) - hooked, not triggered A new UIParent is created at a different address, but addon/Blizzard initialization code passes the OLD (now dead) address to SetPoint/SetAnimOrder. diff --git a/src/framecrash/framecrash.zig b/src/framecrash/framecrash.zig index 77bd159..cd06bea 100644 --- a/src/framecrash/framecrash.zig +++ b/src/framecrash/framecrash.zig @@ -35,23 +35,31 @@ extern "kernel32" fn GetLastError() callconv(WINAPI) u32; extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32; const ERROR_ALREADY_EXISTS: u32 = 183; +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "framecrash"; + var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; +pub fn isActive() bool { + return g_is_hook_owner; +} + // ============================================================================= // Anchor vtable layout (20-byte object allocated in SetPoint / SetAnimationOrder) // // +0x00 vtable ptr → 0x0081c44c (.rdata) // +0x04 x offset (float) // +0x08 y offset (float) -// +0x0C relativeTo (raw frame pointer — the dangerous one) +// +0x0C relativeTo (raw frame pointer - the dangerous one) // +0x10 relPoint (uint, anchor point enum on the relativeTo frame) // // vtable at 0x0081c44c: -// [0] +0x00 GetAnimationOrder (0x767d80) — destructor/cleanup -// [1] +0x04 luaGetWidth (0x7a2f90) — reads [this+0xC]+0x3C -// [2] +0x08 luaGetHeight (0x7a3070) — reads [this+0xC]+0x3C -// [3] +0x0C GetRelativeTo (0x767d70) — returns *(this+0x0C) +// [0] +0x00 GetAnimationOrder (0x767d80) - destructor/cleanup +// [1] +0x04 luaGetWidth (0x7a2f90) - reads [this+0xC]+0x3C +// [2] +0x08 luaGetHeight (0x7a3070) - reads [this+0xC]+0x3C +// [3] +0x0C GetRelativeTo (0x767d70) - returns *(this+0x0C) // ============================================================================= const ANCHOR_VTABLE_ADDR: usize = 0x0081c44c; @@ -93,7 +101,7 @@ var cleanup_hook: hook.Detour(CleanupFn) = .{}; // Second destruction path: destroyUIElement (0x7645a0) // // Called from cleanupGraphicsResources (UI teardown/reload) via the strata loop. -// Frees frames WITHOUT calling cleanup_linked_list_structures — just unlinks from +// Frees frames WITHOUT calling cleanup_linked_list_structures - just unlinks from // lists, cleans up sub-regions, and calls FreeMemory. The dependency list at // frame+0x34 is never walked, so other frames' anchors are left dangling. // @@ -121,12 +129,12 @@ const ProcessUIFn = fn (u32, u32) callconv(tc) u32; var process_ui_hook: hook.Detour(ProcessUIFn) = .{}; // ============================================================================= -// Priority 1: Hook PauseAnimationGroup (0x767ee0) — dependency registration +// Priority 1: Hook PauseAnimationGroup (0x767ee0) - dependency registration // // Records every dependency registration so we can later determine whether a // stale relativeTo pointer was ever registered through PauseAnimationGroup. // If PauseAnimationGroup was never called for an address, the dependency was -// never created — pointing to a race condition or unknown creation path. +// never created - pointing to a race condition or unknown creation path. // // Signature: void __thiscall PauseAnimationGroup(ECX=relativeTo_frame, owner_frame, bitmask) // Prologue: 55 8B EC 53 8B D9 (6 bytes, no rel32) @@ -139,7 +147,7 @@ const PauseAnimFn = fn (u32, u32, u32) callconv(tc) void; var pause_anim_hook: hook.Detour(PauseAnimFn) = .{}; // ============================================================================= -// Priority 2: Hook SetAnimationOrder (0x767c70) — anchor creation validation +// Priority 2: Hook SetAnimationOrder (0x767c70) - anchor creation validation // // Validates the relativeTo param with IsBadReadPtr BEFORE the original runs. // If relativeTo is already freed when the anchor is created, the dependency @@ -158,7 +166,7 @@ const SetAnimFn = fn (u32, u32, u32, u32, u32, u32, u32) callconv(tc) void; var set_anim_hook: hook.Detour(SetAnimFn) = .{}; // ============================================================================= -// Dependency registration ring buffer — track PauseAnimationGroup calls +// Dependency registration ring buffer - track PauseAnimationGroup calls // // When vtable hooks detect a stale pointer, we look up this buffer to answer: // "was PauseAnimationGroup ever called for this address?" @@ -227,7 +235,7 @@ fn getRegisteredName(relativeTo: u32) []const u8 { } // ============================================================================= -// Destruction history ring buffer — correlate stale pointers with frame names +// Destruction history ring buffer - correlate stale pointers with frame names // ============================================================================= const HISTORY_SIZE = 1024; @@ -273,7 +281,7 @@ fn fmtStaleInfo(relativeTo: u32) struct { name: []const u8, saw_destroy: bool } return .{ .name = fmtFrameName(relativeTo), .saw_destroy = false }; } -// logRegistrationStatus and dumpStaleContext removed — verbose diagnostic logging +// logRegistrationStatus and dumpStaleContext removed - verbose diagnostic logging // superseded by HEAL/FIX/RACE messages. Ring buffers still used by tryFixStaleRelativeTo. /// Detour for cleanup_linked_list_structures. Runs before the original to @@ -298,7 +306,7 @@ fn destroyUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 { return destroy_ui_hook.callOriginal(.{ frame, free_flag }); } -/// Detour for ProcessUIUpdateEvent — third destruction path, called via vtable. +/// Detour for ProcessUIUpdateEvent - third destruction path, called via vtable. fn processUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 { recordDestruction(frame); if (IsBadReadPtr(@ptrFromInt(frame + 0x24), 4) == 0) { @@ -309,18 +317,18 @@ fn processUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 { return process_ui_hook.callOriginal(.{ frame, free_flag }); } -/// Detour for PauseAnimationGroup — records every dependency registration. +/// Detour for PauseAnimationGroup - records every dependency registration. /// This tells us whether a stale relativeTo was ever registered through the /// normal dependency tracking system. /// Signature: void __thiscall PauseAnimationGroup(ECX=relativeTo_frame, owner_frame, bitmask) fn pauseAnimDetour(relativeTo_frame: u32, owner_frame: u32, bitmask: u32) callconv(tc) void { - // Silently record — queried later by vtable hooks via logRegistrationStatus() + // Silently record - queried later by vtable hooks via logRegistrationStatus() recordRegistration(relativeTo_frame, owner_frame, bitmask); pause_anim_hook.callOriginal(.{ relativeTo_frame, owner_frame, bitmask }); } -/// Detour for SetAnimationOrder — validates relativeTo param before anchor creation. +/// Detour for SetAnimationOrder - validates relativeTo param before anchor creation. /// Float params (xOfs, yOfs) are passed as raw u32 bit patterns on the stack. fn setAnimOrderDetour(frame: u32, point_enum: u32, relativeTo: u32, rel_point: u32, x_ofs: u32, y_ofs: u32, param_6: u32) callconv(tc) void { var fixed_relativeTo = relativeTo; @@ -373,7 +381,7 @@ fn countReverseDependencies(dying_frame: u32) u32 { /// Walk the PauseAnimationGroup dependency list on the dying frame and NULL out /// the relativeTo field in any anchors from other frames that reference it. /// -/// Safety: purely defensive — does NOT call destructors, free nodes, or modify +/// Safety: purely defensive - does NOT call destructors, free nodes, or modify /// the dying frame's list pointers. The original cleanup_linked_list_structures /// handles its own data structures. fn cleanupReverseDependencies(dying_frame: u32) void { @@ -411,12 +419,12 @@ fn cleanupReverseDependencies(dying_frame: u32) void { const relativeTo: u32 = readAligned(anchor + 0x0C); if (relativeTo != dying_frame) continue; - // Verify vtable matches the known anchor vtable — reject garbage objects + // Verify vtable matches the known anchor vtable - reject garbage objects const vtable: u32 = readAligned(anchor); if (vtable != ANCHOR_VTABLE_ADDR) continue; // NULL the relativeTo pointer so it can't dangle. - // Don't call destructors or free the anchor — that risks cascading + // Don't call destructors or free the anchor - that risks cascading // side effects and is unnecessary. A NULL relativeTo is handled // gracefully by all code paths (luaGetPoint, GetWidth, GetHeight). const field: *align(1) u32 = @ptrFromInt(anchor + 0x0C); @@ -457,7 +465,7 @@ fn getFrameName(layout_frame: u32) ?[*:0]const u8 { return @ptrFromInt(name_ptr); } -/// Format a frame name for logging — returns "FrameName" or "(unnamed)". +/// Format a frame name for logging - returns "FrameName" or "(unnamed)". fn fmtFrameName(layout_frame: u32) []const u8 { if (getFrameName(layout_frame)) |name| { return std.mem.span(name); @@ -509,13 +517,13 @@ fn tryFixStaleRelativeTo(anchor: u32, stale: u32) bool { // // Three vtable slots must be hooked because they independently read anchor+0x0C // (relativeTo) without going through each other: -// [1] GetWidth — reads [this+0xC]+0x3C, crashes if relativeTo is NULL/dangling -// [2] GetHeight — same pattern as GetWidth -// [3] GetRelativeTo — returns *(this+0x0C), caller dereferences it +// [1] GetWidth - reads [this+0xC]+0x3C, crashes if relativeTo is NULL/dangling +// [2] GetHeight - same pattern as GetWidth +// [3] GetRelativeTo - returns *(this+0x0C), caller dereferences it // // GetRelativeTo NULLs the pointer on detection (self-heal). GetWidth/GetHeight // must independently handle both NULL and dangling relativeTo by returning the -// sentinel value from [0x00cf550c] — the value SetFrameHitTestMode compares +// sentinel value from [0x00cf550c] - the value SetFrameHitTestMode compares // against to detect "no dimension available". // ============================================================================= @@ -550,10 +558,10 @@ fn getRelativeToHook(this: u32) callconv(tc) u32 { if (!isRelativeToValid(result)) { // Try to substitute live UIParent before NULLing if (tryFixStaleRelativeTo(this, result)) { - // Re-call original — it now reads the fixed pointer + // Re-call original - it now reads the fixed pointer return orig(this); } - // No substitute available — NULL it out + // No substitute available - NULL it out const field: *align(1) u32 = @ptrFromInt(this + 0x0C); field.* = 0; return 0; @@ -564,14 +572,14 @@ fn getRelativeToHook(this: u32) callconv(tc) u32 { /// Hook for vtable[1] GetWidth. Checks anchor+0x0C before calling original. /// Returns sentinel if relativeTo is NULL or dangling. -/// Signature: f32 __thiscall GetWidth(this, u32 param) — callee cleans 1 stack arg. +/// Signature: f32 __thiscall GetWidth(this, u32 param) - callee cleans 1 stack arg. fn getWidthHook(this: u32, param: u32) callconv(tc) f32 { const relativeTo: u32 = readAligned(this + 0x0C); if (!isRelativeToValid(relativeTo)) { if (relativeTo != 0) { // Try to substitute live UIParent instead of NULLing if (tryFixStaleRelativeTo(this, relativeTo)) { - // Fixed — call original with the healed pointer + // Fixed - call original with the healed pointer const orig: *const fn (u32, u32) callconv(tc) f32 = @ptrFromInt(orig_get_width); return orig(this, param); } @@ -586,7 +594,7 @@ fn getWidthHook(this: u32, param: u32) callconv(tc) f32 { } /// Hook for vtable[2] GetHeight. Same pattern as GetWidth. -/// Signature: f32 __thiscall GetHeight(this, u32 param) — callee cleans 1 stack arg. +/// Signature: f32 __thiscall GetHeight(this, u32 param) - callee cleans 1 stack arg. fn getHeightHook(this: u32, param: u32) callconv(tc) f32 { const relativeTo: u32 = readAligned(this + 0x0C); if (!isRelativeToValid(relativeTo)) { @@ -625,22 +633,10 @@ fn restoreVtableSlot(slot_addr: usize, saved: *usize) void { } pub fn installHooks() void { - // Multi-DLL safety: only one instance per process should hook - var mutex_name_buf: [64]u8 = undefined; - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_FramecrashHook_{d}", .{GetCurrentProcessId()}) catch return; - mutex_name_buf[mutex_name.len] = 0; - - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return; - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; - con.print("[framecrash] Another DLL owns hooks (mutex taken), skipping\n"); - return; - } - g_is_hook_owner = true; + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; // Root cause fix #1: detour cleanup_linked_list_structures to clean up // reverse anchor references before the frame is destroyed. @@ -650,7 +646,7 @@ pub fn installHooks() void { con.print("[framecrash] Frame cleanup detour installed\n"); } - // Root cause fix #2: detour destroyUIElement — the second destruction path + // Root cause fix #2: detour destroyUIElement - the second destruction path // used by cleanupGraphicsResources during UI teardown/reload. This path // frees frames without walking the dependency list. if (destroy_ui_hook.attach(DESTROY_UI_TARGET, &destroyUIDetour) != .ok) { @@ -659,7 +655,7 @@ pub fn installHooks() void { con.print("[framecrash] destroyUIElement detour installed\n"); } - // Root cause fix #3: detour ProcessUIUpdateEvent — virtual function that + // Root cause fix #3: detour ProcessUIUpdateEvent - virtual function that // calls CleanupUIElement + FreeMemory without layout cleanup. if (process_ui_hook.attach(PROCESS_UI_TARGET, &processUIDetour) != .ok) { con.print("[framecrash] ERROR: Failed to install ProcessUIUpdateEvent detour\n"); @@ -717,11 +713,7 @@ pub fn removeHooks() void { } if (g_is_hook_owner) { - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; - } + mod_mutex.release(&g_mutex); } g_is_hook_owner = false; } diff --git a/src/healtextfix/healtextfix.zig b/src/healtextfix/healtextfix.zig index bebf24b..046b3df 100644 --- a/src/healtextfix/healtextfix.zig +++ b/src/healtextfix/healtextfix.zig @@ -18,18 +18,18 @@ //! //! Patches applied to SuperWoWhook.dll in memory: //! -//! 1. 0x3006 (2 bytes) — Skip duplicate heal text in handler +//! 1. 0x3006 (2 bytes) - Skip duplicate heal text in handler //! The handler at RVA 0x3BF0 creates floating text, then calls through //! to the original wow.exe function (which also creates text = duplicate). //! Patch MOV ECX,[EDI] -> JMP +0x7A to skip to the call-through at 0x3C82. //! Old: 8B 0F //! New: EB 7A //! -//! 2. 0x306E (4 bytes) — Redirect HoT text handler pointer +//! 2. 0x306E (4 bytes) - Redirect HoT text handler pointer //! Old: 9C D8 C4 00 //! New: 06 7C 44 00 //! -//! 3. 0x3123 (4 bytes) — Redirect HoT text handler pointer (second site) +//! 3. 0x3123 (4 bytes) - Redirect HoT text handler pointer (second site) //! Old: 9C D8 C4 00 //! New: 06 7C 44 00 //! @@ -125,8 +125,18 @@ fn fileOffsetToVA(base: [*]const u8, file_offset: u32) ?[*]u8 { return null; } +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "healtextfix"; + +var g_mutex: ?*anyopaque = null; +var g_is_hook_owner: bool = false; var g_applied_set: ?*const PatchSet = null; +pub fn isActive() bool { + return g_is_hook_owner; +} + /// Scan the DLL's mapped memory for SUPERWOW_VERSION="..." and extract the version string. fn detectVersion(base: [*]const u8) ?[]const u8 { const scan_len = 0x20000; @@ -142,12 +152,17 @@ fn detectVersion(base: [*]const u8) ?[]const u8 { } pub fn installHooks() void { - con.print("[healtextfix] Module loaded (stub)\n"); + con.print("[healtextfix] Module loaded\n"); + + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; } -/// Called from engineInitDetour (GameEngine_MainInitialize hook) — late enough +/// Called from engineInitDetour (GameEngine_MainInitialize hook) - late enough /// that SuperWoWhook.dll should be loaded if present. pub fn lateInit() void { + if (!g_is_hook_owner) return; const superwow_base = GetModuleHandleA("SuperWoWhook.dll"); if (superwow_base == null) { con.print("[healtextfix] SuperWoWhook.dll not found, skipping\n"); @@ -223,7 +238,13 @@ pub fn lateInit() void { } pub fn removeHooks() void { - const set = g_applied_set orelse return; + if (!g_is_hook_owner) return; + + const set = g_applied_set orelse { + mod_mutex.release(&g_mutex); + g_is_hook_owner = false; + return; + }; const superwow_base = GetModuleHandleA("SuperWoWhook.dll"); if (superwow_base == null) return; @@ -251,5 +272,7 @@ pub fn removeHooks() void { } g_applied_set = null; + mod_mutex.release(&g_mutex); + g_is_hook_owner = false; con.print("[healtextfix] All patches restored\n"); } diff --git a/src/interact/interact.zig b/src/interact/interact.zig index 1ab0d2f..02dbd28 100644 --- a/src/interact/interact.zig +++ b/src/interact/interact.zig @@ -179,7 +179,7 @@ fn luaPrintError(L: *anyopaque, msg: [*:0]const u8) void { } // ============================================================================= -// InteractNearest — find closest interactable within 5 yards, right-click it +// InteractNearest - find closest interactable within 5 yards, right-click it // ============================================================================= pub fn interactNearest(L: *anyopaque) callconv(.c) u32 { @@ -269,14 +269,22 @@ pub fn interactNearest(L: *anyopaque) callconv(.c) u32 { } // ============================================================================= -// LootAllCorpses — queue nearby lootable corpses, loot them sequentially +// LootAllCorpses - queue nearby lootable corpses, loot them sequentially // ============================================================================= const MAX_LOOT_QUEUE: usize = 100; +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "interact"; + var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; +pub fn isActive() bool { + return g_is_hook_owner; +} + var loot_queue: [MAX_LOOT_QUEUE]u64 = .{0} ** MAX_LOOT_QUEUE; var loot_queue_count: usize = 0; var loot_queue_index: usize = 0; @@ -318,7 +326,7 @@ fn processLootQueue() void { const loot_guid_hi = hook.readMem(u32, Offsets.LOOT_GUID_HI); if (loot_guid_lo != 0 or loot_guid_hi != 0) { - // Loot window is open — wait for auto-loot to finish. + // Loot window is open - wait for auto-loot to finish. // If items remain after timeout (e.g. unique items already owned), // skip to next corpse. if (elapsed > 1500) { @@ -327,7 +335,7 @@ fn processLootQueue() void { return; } - // Loot GUID is zero — loot closed or server hasn't responded yet + // Loot GUID is zero - loot closed or server hasn't responded yet if (elapsed < loot_next_delay) return; interactNextCorpse(); @@ -404,38 +412,19 @@ fn hookSceneEnd(device: u32) callconv(tc) void { pub fn installHooks() void { con.print("[interact] Module loaded\n"); - // Multi-DLL safety: only one instance per process should hook - var mutex_name_buf: [64]u8 = undefined; - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_InteractHook_{d}", .{GetCurrentProcessId()}) catch return; - mutex_name_buf[mutex_name.len] = 0; + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return; - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; - con.print("[interact] Another DLL owns hooks (mutex taken), skipping\n"); - return; - } - g_is_hook_owner = true; - - // SceneEnd — per-frame loot queue processing + // SceneEnd - per-frame loot queue processing _ = scene_end_hook.attach(Offsets.ADDR_SceneEnd, &hookSceneEnd); } pub fn removeHooks() void { if (g_is_hook_owner) { scene_end_hook.detach(); - } - - if (g_is_hook_owner) { - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; - } + mod_mutex.release(&g_mutex); } g_is_hook_owner = false; } diff --git a/src/logsessions/RESEARCH.md b/src/logsessions/RESEARCH.md index 565e38d..0d8eafc 100644 --- a/src/logsessions/RESEARCH.md +++ b/src/logsessions/RESEARCH.md @@ -251,15 +251,15 @@ On DLL init, scan `Logs\` directory for `WoWCombatLog_*.txt` files: ## Resolved Questions - [x] Section at 0x00843610: `.data` (RW) -- no VirtualProtect needed -- [x] Player name: use `GetObjectName` (0x6264E0) — __fastcall(ECX=guid_ptr) → char*. - RetrieveNPCDataFromCache (0x55f080) does NOT return char* in EAX — it returns +- [x] Player name: use `GetObjectName` (0x6264E0) - __fastcall(ECX=guid_ptr) → char*. + RetrieveNPCDataFromCache (0x55f080) does NOT return char* in EAX - it returns name bytes. The actual pointer is written to the output buffer param. GetObjectName is simpler and verified by c_overlay reference. - [x] Original string too short for timestamped name -- must use pointer redirect - [x] **ESI clobber crash (ACCESS_VIOLATION at 0x6F61AF)**: luaCallFunction stores luaState in ESI and the C function pointer in EDI, dispatches via `CALL EDI`, then reads `[ESI+0x8]`. Our detour's compiled code (Debug build) did NOT push - ESI/EDI/EBX in its prologue — Zig only saves callee-saved registers it + ESI/EDI/EBX in its prologue - Zig only saves callee-saved registers it allocates directly, but subcalls (callOriginal wrapper, inline asm game calls) can clobber them without the compiler knowing. **All game functions verified to preserve ESI/EDI/EBX**: GetPlayerGUID (0x468550, doesn't touch them), diff --git a/src/logsessions/logsessions.zig b/src/logsessions/logsessions.zig index a955178..a9c0253 100644 --- a/src/logsessions/logsessions.zig +++ b/src/logsessions/logsessions.zig @@ -9,7 +9,7 @@ //! - Session continuation: reuses files modified < 60 min ago //! - Session marker: writes `COMBATLOG_SESSION: ` on first combat write //! -//! All DLL-side — no Lua addon needed. +//! All DLL-side - no Lua addon needed. const std = @import("std"); const hook = @import("zhook"); @@ -72,11 +72,19 @@ const WIN32_FIND_DATAA = extern struct { // Mutex state // ============================================================================= +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "logsessions"; + var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; +pub fn isActive() bool { + return g_is_hook_owner; +} + // ============================================================================= -// Session state — reset on logout +// Session state - reset on logout // ============================================================================= /// Static buffers for redirected paths (null-terminated). Must outlive the process. @@ -105,7 +113,7 @@ var g_combat_marker_written: bool = false; var g_chat_marker_written: bool = false; var g_raw_marker_written: bool = false; -/// Raw combat log handle address — captured from initLogDetour when SuperWoW +/// Raw combat log handle address - captured from initLogDetour when SuperWoW /// calls InitializeLogBuffer for WoWRawCombatLog. We don't know this address /// statically; SuperWoW passes it as handle_out. var g_raw_combat_handle_addr: u32 = 0; @@ -114,7 +122,6 @@ var g_raw_combat_handle_addr: u32 = 0; var g_original_combat_path_ptr: u32 = 0; var g_original_chat_path_ptr: u32 = 0; - // ============================================================================= // Character / realm identity // ============================================================================= @@ -189,7 +196,7 @@ fn setupSessionDir(realm: []const u8, char_name: []const u8) bool { } // ============================================================================= -// Session continuation — find recent file to reuse +// Session continuation - find recent file to reuse // ============================================================================= /// Scan directory for files matching `_*.txt`, return the newest if @@ -231,7 +238,7 @@ fn findRecentFile(prefix: []const u8, result_buf: *[260]u8) ?usize { if (newest_name_len == 0) return null; - // Compare against current time — both UTC FILETIME (100ns units) + // Compare against current time - both UTC FILETIME (100ns units) var current_ft: FILETIME = undefined; GetSystemTimeAsFileTime(¤t_ft); const current: u64 = @bitCast(current_ft); @@ -324,7 +331,7 @@ fn configureSession(char_span: []const u8, realm_span: []const u8) void { } // ============================================================================= -// HandleCharacterSelection hook — set up paths before world loading +// HandleCharacterSelection hook - set up paths before world loading // ============================================================================= var enter_world_hook: hook.Detour(fn () callconv(sc) void) = .{}; @@ -363,7 +370,7 @@ fn restorePathPointers() void { } // ============================================================================= -// InitializeLogBuffer hook — lazy setup + path redirect +// InitializeLogBuffer hook - lazy setup + path redirect // ============================================================================= var init_log_hook: hook.Detour(fn (u32, u32, u32) callconv(sc) u32) = .{}; @@ -395,7 +402,7 @@ fn initLogDetour(file_path: u32, flags: u32, handle_out: u32) callconv(sc) u32 { } // ============================================================================= -// WriteFormattedLogMessage hook — inject session markers on first write per log +// WriteFormattedLogMessage hook - inject session markers on first write per log // ============================================================================= var write_log_hook: hook.Detour(fn (u32, u32, u32) callconv(sc) void) = .{}; @@ -452,7 +459,7 @@ fn writeSessionMarker(handle: u32, fmt_str: [*:0]const u8) void { } // ============================================================================= -// Shutdown — reset session state for next login +// Shutdown - reset session state for next login // ============================================================================= /// Resets all session state so the next login gets fresh paths. @@ -477,7 +484,7 @@ pub fn onShutdown() void { } // ============================================================================= -// Lua API — log path accessors +// Lua API - log path accessors // ============================================================================= /// GetCombatLogPath() → string or nil @@ -507,24 +514,12 @@ pub fn luaGetChatLogPath(L: lua.State) callconv(.c) u32 { pub fn installHooks() void { con.print("[logsessions] Module loaded\n"); - // Multi-DLL safety: only one instance per process should hook - var mutex_name_buf: [64]u8 = undefined; - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_LogSessionsHook_{d}", .{GetCurrentProcessId()}) catch return; - mutex_name_buf[mutex_name.len] = 0; + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return; - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; - con.print("[logsessions] Another DLL owns hooks (mutex taken), skipping\n"); - return; - } - g_is_hook_owner = true; - - // Hook HandleCharacterSelection — sets up paths when player clicks Enter World, + // Hook HandleCharacterSelection - sets up paths when player clicks Enter World, // before the world loading sequence calls InitializeLogBuffer. if (enter_world_hook.attach(o.FN_HANDLE_CHAR_SELECT, &enterWorldDetour) != .ok) { con.print("[logsessions] FAILED to hook HandleCharacterSelection!\n"); @@ -551,12 +546,7 @@ pub fn removeHooks() void { init_log_hook.detach(); enter_world_hook.detach(); restorePathPointers(); - - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; - } + mod_mutex.release(&g_mutex); } g_is_hook_owner = false; } diff --git a/src/logsessions/offsets.zig b/src/logsessions/offsets.zig index 3671fd6..bf3b81d 100644 --- a/src/logsessions/offsets.zig +++ b/src/logsessions/offsets.zig @@ -4,12 +4,12 @@ // Combat log path and state // ============================================================================= -/// Path table pointer — .data section (RW), points to "Logs\WoWCombatLog.txt" string. +/// Path table pointer - .data section (RW), points to "Logs\WoWCombatLog.txt" string. /// Index 0 (0x0084360c) = chat log, index 1 (0x00843610) = combat log. /// Overwriting the u32 at this address redirects where the combat log file is created. pub const COMBAT_LOG_PATH_PTR: usize = 0x00843610; -/// Chat log path pointer — .data section (RW), points to "Logs\WoWChatLog.txt" string. +/// Chat log path pointer - .data section (RW), points to "Logs\WoWChatLog.txt" string. /// Index 0 in the path table at 0x0084360c. pub const CHAT_LOG_PATH_PTR: usize = 0x0084360c; @@ -31,13 +31,13 @@ pub const REALM_NAME_CVAR_BASE: usize = 0x00c28130; // Log writing // ============================================================================= -/// InitializeLogBuffer — __stdcall(filePath: [*:0]const u8, flags: u32, handleOut: *u32). +/// InitializeLogBuffer - __stdcall(filePath: [*:0]const u8, flags: u32, handleOut: *u32). /// Creates a log buffer context. Copies path into context struct (max 260 bytes). /// Returns nonzero on success. Callee cleans stack (RET 0xC). /// Called by EnableChatLogging (game) and CombatLogAdd (SuperWoW) with hardcoded paths. pub const FN_INIT_LOG_BUFFER: usize = 0x0065a0c0; -/// WriteFormattedLogMessage — __stdcall(handle: u32, fmt: [*:0]const u8, va_list: *anyopaque). +/// WriteFormattedLogMessage - __stdcall(handle: u32, fmt: [*:0]const u8, va_list: *anyopaque). /// Three fixed params, callee cleans stack (RET 0xC). Third arg is va_list pointer. /// Writes a timestamped, formatted line to the log buffer. Auto-flushes at 48KB. pub const FN_WRITE_FMT_LOG_MSG: usize = 0x0065ac20; @@ -46,7 +46,7 @@ pub const FN_WRITE_FMT_LOG_MSG: usize = 0x0065ac20; // Character select / enter world // ============================================================================= -/// HandleCharacterSelection — void(void), called by Lua EnterWorld(). +/// HandleCharacterSelection - void(void), called by Lua EnterWorld(). /// Fires when the player clicks "Enter World" on the character select screen, /// before world loading and log buffer initialization. pub const FN_HANDLE_CHAR_SELECT: usize = 0x0046b500; @@ -66,4 +66,3 @@ pub const CHAR_ENTRY_SIZE: usize = 0x120; /// Offset of character name within a character entry. pub const CHAR_NAME_OFFSET: usize = 0x08; - diff --git a/src/lua.zig b/src/lua.zig index bb7c340..1de8f5d 100644 --- a/src/lua.zig +++ b/src/lua.zig @@ -66,7 +66,7 @@ pub fn pushnil(L: State) void { pub fn pushnumber(L: State, n: f64) void { // __thiscall: ECX=L, f64 on stack [EBP+8]/[EBP+0xc], ret 8. - // .never_tail: callee-cleanup pops stack args — tail-call would corrupt the stack. + // .never_tail: callee-cleanup pops stack args - tail-call would corrupt the stack. const f: *const fn (State, f64) callconv(.{ .x86_thiscall = .{} }) void = @ptrFromInt(0x6F3810); @call(.never_tail, f, .{ L, n }); } @@ -136,8 +136,7 @@ pub fn luaError(L: State, msg: [*:0]const u8) void { : [L] "r" (@intFromPtr(L)), [msg] "r" (@intFromPtr(msg)), [func] "r" (@as(u32, 0x6F4940)), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } pub const LuaReg = extern struct { diff --git a/src/main.zig b/src/main.zig index 1913ec7..6a0bea4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -15,6 +15,7 @@ const build_opts = struct { const customassets = @import("build_options").enable_customassets; const healtextfix = @import("build_options").enable_healtextfix; const bigcursor = @import("build_options").enable_bigcursor; + const dpslog = @import("build_options").enable_dpslog; }; // Conditional module imports @@ -29,6 +30,7 @@ const transmogfix = if (build_opts.transmogfix) @import("transmogfix/transmogfix const customassets = if (build_opts.customassets) @import("customassets/customassets.zig") else struct {}; const healtextfix = if (build_opts.healtextfix) @import("healtextfix/healtextfix.zig") else struct {}; const bigcursor = if (build_opts.bigcursor) @import("bigcursor/bigcursor.zig") else struct {}; +const dpslog = if (build_opts.dpslog) @import("dpslog/dpslog.zig") else struct {}; const WINAPI = std.builtin.CallingConvention.winapi; const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} }; @@ -43,7 +45,7 @@ var protection_hook: hook.Detour(fn () callconv(sc) void) = .{}; fn luaProtectionDetour() callconv(sc) void {} // ============================================================================= -// Lua C API wrappers (WoW 1.12.1 — all __fastcall, L in ECX) +// Lua C API wrappers (WoW 1.12.1 - all __fastcall, L in ECX) // ============================================================================= pub const lua = @import("lua.zig"); @@ -67,8 +69,7 @@ fn allocateGameBuffer(size: u32) ?[*]u8 { : [size] "r" (size), [src] "r" (@intFromPtr(@as([*:0]const u8, "weirdutils"))), [func] "r" (@as(u32, 0x6462E0)), - : .{ .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .ecx = true, .edx = true, .memory = true, .cc = true }); } // ============================================================================= @@ -89,8 +90,7 @@ fn weirdUtilsVersion(L: lua.State) callconv(.c) u32 { : : [_] "{ecx}" (@intFromPtr(L)), [func] "r" (@as(u32, 0x6F3810)), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); return 1; } @@ -114,6 +114,10 @@ fn registerLuaFunctions() void { registerFunction("GetCombatLogPath", @intFromPtr(&logsessions.luaGetCombatLogPath)); registerFunction("GetChatLogPath", @intFromPtr(&logsessions.luaGetChatLogPath)); } + if (build_opts.bigcursor) { + registerFunction("SetCursorScale", @intFromPtr(&bigcursor.luaSetCursorScale)); + registerFunction("GetCursorScale", @intFromPtr(&bigcursor.luaGetCursorScale)); + } if (build_opts.worldmarkers and markers.isActive()) { // User-facing functions stay global registerFunction("WorldMarker", @intFromPtr(&markers.luaWorldMarker)); @@ -125,7 +129,6 @@ fn registerLuaFunctions() void { .{ .name = "SetMarkerDef", .func = @intFromPtr(&markers.luaSetMarkerDef) }, .{ .name = "ClearMarkerDef", .func = @intFromPtr(&markers.luaClearMarkerDef) }, .{ .name = "GetMarkerDef", .func = @intFromPtr(&markers.luaGetMarkerDef) }, - .{ .name = null, .func = 0 }, // sentinel }; lua.openlib(lua.getContext(), "WorldMarkers", &lib, 0); @@ -343,7 +346,7 @@ fn isFakeFileContext(ctx_addr: u32) bool { hook.readMem(u32, ctx_addr + 0x30) != 0; // embedded ptr set } -/// Call initializeFileContext (0x647290) — __thiscall(ECX=ctx, type) +/// Call initializeFileContext (0x647290) - __thiscall(ECX=ctx, type) fn callInitFileContext(ctx: [*]u8, file_type: u32) void { asm volatile ( \\push %[ftype] @@ -352,22 +355,20 @@ fn callInitFileContext(ctx: [*]u8, file_type: u32) void { : [_] "{ecx}" (@intFromPtr(ctx)), [ftype] "r" (file_type), [func] "r" (@as(u32, 0x647290)), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } -/// Call cleanupFileContext (0x6472d0) — __thiscall(ECX=ctx) +/// Call cleanupFileContext (0x6472d0) - __thiscall(ECX=ctx) fn callCleanupFileContext(ctx: [*]u8) void { asm volatile ( \\call *%[func] : : [_] "{ecx}" (@intFromPtr(ctx)), [func] "r" (@as(u32, 0x6472d0)), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } -/// Free a buffer via FreeMemory/SMemFree (0x646430) — __stdcall(ptr, src, flags) +/// Free a buffer via FreeMemory/SMemFree (0x646430) - __stdcall(ptr, src, flags) fn freeGameBuffer(ptr: [*]u8) void { asm volatile ( \\push $0xffffffff @@ -378,8 +379,7 @@ fn freeGameBuffer(ptr: [*]u8) void { : [ptr] "r" (@intFromPtr(ptr)), [src] "r" (@intFromPtr(@as([*:0]const u8, "weirdutils"))), [func] "r" (@as(u32, 0x646430)), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } // --- Hook 1: openFileWithOptions (0x6477c0) --- @@ -522,7 +522,7 @@ fn processAsyncDetour(param1: u32) callconv(fc) void { return; } - // Not our fake — call original + // Not our fake - call original process_async_hook.callOriginal(.{param1}); } @@ -540,7 +540,7 @@ fn cleanupFileHandleDetour(file_ctx: u32) callconv(sc) void { } } - // Always use original CleanupFileHandleResources — it handles fake contexts correctly + // Always use original CleanupFileHandleResources - it handles fake contexts correctly // (NULL-safe checks on +0x04/+0x3C/+0x40/+0x08, then cleanupFileContext + FreeMemory). cleanup_file_handle_hook.callOriginal(.{file_ctx}); @@ -552,7 +552,7 @@ fn cleanupFileHandleDetour(file_ctx: u32) callconv(sc) void { fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) callconv(tc) u32 { // file_handle IS the file context address directly (Ghidra shows pointer* but - // the assembly pushes it directly to GetFileSizeFromHandle — no dereference) + // the assembly pushes it directly to GetFileSizeFromHandle - no dereference) if (isFakeFileContext(file_handle)) { con.fmt("[file] loadModelAsync: model=0x{x} fh=0x{x} cb={d}\n", .{ model, file_handle, should_use_callback }); const data_ptr = hook.readMem(u32, file_handle + 0x30); @@ -570,7 +570,7 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) // Store size in model first (original does this before allocation) @as(*align(1) u32, @ptrFromInt(model + 0x134)).* = data_size; - // Allocate buffer via setCullMode (0x71f9a0) — same as original path + // Allocate buffer via setCullMode (0x71f9a0) - same as original path // setCullMode is __fastcall(ECX=size), returns buffer pointer const buffer_addr = hook.fastcall(u32, 0x71f9a0, data_size, 0); if (buffer_addr == 0) { @@ -588,7 +588,7 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) @memcpy(buffer[0..data_size], src[0..data_size]); con.print("[file] memcpy done\n"); - // No async task — set task pointer to NULL + // No async task - set task pointer to NULL @as(*align(1) u32, @ptrFromInt(model + 0x0c)).* = 0; con.print("[file] task=0 set\n"); @@ -611,13 +611,13 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) hook.readMem(u32, model + 0x138), }); - // Call processLoadedModelData directly — __fastcall(ECX=model) + // Call processLoadedModelData directly - __fastcall(ECX=model) con.fmt("[file] calling processLoadedModelData(0x{x})...\n", .{model}); const result = hook.fastcall(u32, 0x71d640, model, 0); con.print("[file] processLoadedModelData returned\n"); con.fmt("[file] result=0x{x}\n", .{result}); - // Dump model fields after processLoadedModelData — check if texture async task was created + // Dump model fields after processLoadedModelData - check if texture async task was created con.print("[file] POST dump:\n"); con.fmt("[file] POST model+0x0c=0x{x} +0x130=0x{x} +0x134=0x{x} +0x138=0x{x}\n", .{ hook.readMem(u32, model + 0x0c), @@ -631,7 +631,7 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) return 1; } - // Not our fake — call original + // Not our fake - call original return model_load_hook.callOriginal(.{ model, file_handle, should_use_callback }); } @@ -744,8 +744,7 @@ fn callLoadFileListWithIncludes(toc_path: [*:0]const u8, md5ctx: *[88]u8, error_ [_] "{edx}" (@intFromPtr(md5ctx)), [eh] "r" (error_handler), [func] "r" (@as(u32, 0x6EDB90)), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } fn callLoadUIBindingsFromFile(path: [*:0]const u8, md5ctx: *[88]u8, callback: u32) void { @@ -761,8 +760,7 @@ fn callLoadUIBindingsFromFile(path: [*:0]const u8, md5ctx: *[88]u8, callback: u3 [path] "r" (@intFromPtr(path)), [md5] "r" (@intFromPtr(md5ctx)), [cb] "r" (callback), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } // ============================================================================= @@ -783,11 +781,14 @@ fn engineInitDetour() callconv(sc) void { if (build_opts.healtextfix) { healtextfix.lateInit(); } + if (build_opts.bigcursor) { + bigcursor.lateInit(); + } } // ============================================================================= // Hook: World_HandleLogoutCleanup (0x491180) -// Fires on real character logout/disconnect only — NOT on /reload or map change. +// Fires on real character logout/disconnect only - NOT on /reload or map change. // ============================================================================= var logout_hook: hook.Detour(fn () callconv(sc) void) = .{}; @@ -795,11 +796,11 @@ var logout_hook: hook.Detour(fn () callconv(sc) void) = .{}; fn logoutDetour() callconv(sc) void { con.print("[weirdutils] World_HandleLogoutCleanup -- player logout\n"); - // Reset per-session state — only on real logout/disconnect, not /reload. + // Reset per-session state - only on real logout/disconnect, not /reload. if (build_opts.worldmarkers) markers.onShutdown(); if (build_opts.logsessions) logsessions.onShutdown(); - // Clean up world objects BEFORE game teardown — modules with + // Clean up world objects BEFORE game teardown - modules with // remove_on_shutdown must destroy while game systems are alive. comptime var i = modules.len; inline while (i > 0) { @@ -820,13 +821,15 @@ fn logoutDetour() callconv(sc) void { var shutdown_hook: hook.Detour(fn () callconv(sc) void) = .{}; // ============================================================================= -// Module lifecycle — single table drives install, shutdown, and uninstall. +// Module lifecycle - single table drives install, shutdown, and uninstall. // Adding a module here guarantees all three phases are handled. // ============================================================================= const ModuleHooks = struct { + name: ?[*:0]const u8 = null, install: ?*const fn () void = null, remove: ?*const fn () void = null, + is_active: ?*const fn () bool = null, /// If true, remove is also called during CGGameUI_Shutdown (before game /// teardown), not just during DLL unload. Use for modules that create /// world objects which must be destroyed while game systems are alive. @@ -836,23 +839,24 @@ const ModuleHooks = struct { /// Order matters: modules are installed top-to-bottom, removed bottom-to-top. /// Modules with remove_on_shutdown run their remove during shutdownDetour too. const modules = [_]ModuleHooks{ - if (build_opts.customassets) .{ .install = customassets.installHooks, .remove = customassets.removeHooks } else .{}, - if (build_opts.framecrash) .{ .install = framecrash.installHooks, .remove = framecrash.removeHooks } else .{}, - if (build_opts.logsessions) .{ .install = logsessions.installHooks, .remove = logsessions.removeHooks } else .{}, - if (build_opts.transmogfix) .{ .install = transmogfix.installHooks, .remove = transmogfix.removeHooks } else .{}, - if (build_opts.minimapicons) .{ .install = minimapicons.installHooks, .remove = minimapicons.removeHooks } else .{}, - if (build_opts.healtextfix) .{ .install = healtextfix.installHooks, .remove = healtextfix.removeHooks } else .{}, - if (build_opts.bigcursor) .{ .install = bigcursor.installHooks, .remove = bigcursor.removeHooks } else .{}, - if (build_opts.worldmarkers) .{ .install = markers.installHooks, .remove = markers.removeHooks } else .{}, - if (build_opts.interact) .{ .install = interact.installHooks, .remove = interact.removeHooks } else .{}, - if (build_opts.outline) .{ .remove = outline.cleanup } else .{}, - if (build_opts.screenshot) .{ .remove = screenshot.removeHook } else .{}, + if (build_opts.customassets) .{ .name = customassets.module_name, .install = customassets.installHooks, .remove = customassets.removeHooks, .is_active = customassets.isActive } else .{}, + if (build_opts.framecrash) .{ .name = framecrash.module_name, .install = framecrash.installHooks, .remove = framecrash.removeHooks, .is_active = framecrash.isActive } else .{}, + if (build_opts.logsessions) .{ .name = logsessions.module_name, .install = logsessions.installHooks, .remove = logsessions.removeHooks, .is_active = logsessions.isActive } else .{}, + if (build_opts.transmogfix) .{ .name = transmogfix.module_name, .install = transmogfix.installHooks, .remove = transmogfix.removeHooks, .is_active = transmogfix.isActive } else .{}, + if (build_opts.minimapicons) .{ .name = minimapicons.module_name, .install = minimapicons.installHooks, .remove = minimapicons.removeHooks, .is_active = minimapicons.isActive } else .{}, + if (build_opts.healtextfix) .{ .name = healtextfix.module_name, .install = healtextfix.installHooks, .remove = healtextfix.removeHooks, .is_active = healtextfix.isActive } else .{}, + if (build_opts.bigcursor) .{ .name = bigcursor.module_name, .install = bigcursor.installHooks, .remove = bigcursor.removeHooks, .is_active = bigcursor.isActive } else .{}, + if (build_opts.dpslog) .{ .name = dpslog.module_name, .install = dpslog.installHooks, .remove = dpslog.removeHooks, .is_active = dpslog.isActive } else .{}, + if (build_opts.worldmarkers) .{ .name = markers.module_name, .install = markers.installHooks, .remove = markers.removeHooks, .is_active = markers.isActive } else .{}, + if (build_opts.interact) .{ .name = interact.module_name, .install = interact.installHooks, .remove = interact.removeHooks, .is_active = interact.isActive } else .{}, + if (build_opts.outline) .{ .name = outline.module_name, .remove = outline.cleanup, .is_active = outline.isActive } else .{}, + if (build_opts.screenshot) .{ .name = screenshot.module_name, .remove = screenshot.removeHook, .is_active = screenshot.isActive } else .{}, }; fn shutdownDetour() callconv(sc) void { con.print("[weirdutils] CGGameUI_Shutdown\n"); // Per-session resets and remove_on_shutdown cleanup live in logoutDetour - // (World_HandleLogoutCleanup) — fires on real logout/disconnect only, not /reload. + // (World_HandleLogoutCleanup) - fires on real logout/disconnect only, not /reload. shutdown_hook.callOriginal(.{}); } @@ -898,6 +902,89 @@ fn uninstall() void { con.deinit(); } +// ============================================================================= +// Runtime Module Control API - exported for other DLLs +// ============================================================================= + +fn asciiEqlIgnoreCase(a: [*:0]const u8, b: [*:0]const u8) bool { + var i: usize = 0; + while (true) : (i += 1) { + const ca = a[i]; + const cb = b[i]; + if (ca == 0 and cb == 0) return true; + if (ca == 0 or cb == 0) return false; + const la = if (ca >= 'A' and ca <= 'Z') ca + 32 else ca; + const lb = if (cb >= 'A' and cb <= 'Z') cb + 32 else cb; + if (la != lb) return false; + } +} + +/// Returns 1 if the named module is compiled in AND currently active, 0 otherwise. +fn isModuleActive(name: [*:0]const u8) callconv(.c) i32 { + inline for (modules) |m| { + if (m.name) |mod_name| { + if (asciiEqlIgnoreCase(name, mod_name)) { + if (m.is_active) |active_fn| { + return if (active_fn()) 1 else 0; + } + return 0; + } + } + } + return 0; +} + +/// Disables the named module by calling its remove function. +/// Returns 1 if found and removed, 0 if not found or not compiled in. +fn disableModule(name: [*:0]const u8) callconv(.c) i32 { + inline for (modules) |m| { + if (m.name) |mod_name| { + if (asciiEqlIgnoreCase(name, mod_name)) { + if (m.remove) |rm| { + rm(); + return 1; + } + return 0; + } + } + } + return 0; +} + +/// Disables all modules in reverse order, then detaches core hooks. +/// Returns the number of modules that were disabled. +fn disableAll() callconv(.c) i32 { + var count: i32 = 0; + + // Remove modules in reverse order + comptime var i = modules.len; + inline while (i > 0) { + i -= 1; + if (modules[i].remove) |rm| { + rm(); + count += 1; + } + } + + // Detach core hooks + shutdown_hook.detach(); + logout_hook.detach(); + engine_init_hook.detach(); + load_addons_hook.detach(); + lsf_hook.detach(); + file_hook.detach(); + removeFileHooks(); + protection_hook.detach(); + + return count; +} + +comptime { + @export(&isModuleActive, .{ .name = "WeirdUtils_IsModuleActive" }); + @export(&disableModule, .{ .name = "WeirdUtils_DisableModule" }); + @export(&disableAll, .{ .name = "WeirdUtils_DisableAll" }); +} + // ============================================================================= // DLL entry point // ============================================================================= diff --git a/src/markers/MPQ_FILESYSTEM_RESEARCH.md b/src/markers/MPQ_FILESYSTEM_RESEARCH.md index 6f6ee87..e002d78 100644 --- a/src/markers/MPQ_FILESYSTEM_RESEARCH.md +++ b/src/markers/MPQ_FILESYSTEM_RESEARCH.md @@ -59,16 +59,16 @@ Archives are stored in a **dynamic array** managed as a struct: Global archive array struct at 0x8826b4: +0x00 [0x8826b4]: capacity (max slots) +0x04 [0x8826b8]: count (current number of archives) - +0x08 [0x8826bc]: array_ptr (SArchive** — pointer to array of SArchive pointers) + +0x08 [0x8826bc]: array_ptr (SArchive** - pointer to array of SArchive pointers) +0x0C [0x8826c0]: growth_incr (allocation growth increment) RTTI tag: ".PAVSArchive@@" at 0x82e248 ``` Managed by: -- `GrowArchiveArray` (0x4045a0) — __thiscall, resizes the array -- `ResizeArchiveArray` (0x4046f0) — sets initial capacity -- `MPQ_CleanupAllArchives` (0x403c70) — iterates count→0 calling Archive_Close +- `GrowArchiveArray` (0x4045a0) - __thiscall, resizes the array +- `ResizeArchiveArray` (0x4046f0) - sets initial capacity +- `MPQ_CleanupAllArchives` (0x403c70) - iterates count→0 calling Archive_Close ### SArchive Object (Minimal Wrapper) @@ -114,18 +114,18 @@ From `InitializeArchiveStructure` (0x655bf0), the full MPQ handle (pointed to by | +0x290 | `[0xa4]` | Attributes offset | | +0x294 | `[0xa5]` | Hash table allocated buffer | -The I/O vtable at `+0x140 [0x50]` is critical — it provides the read callbacks that Storm uses to access the archive data. Read calls go through `(*(code **)(*param_1[0x50] + 4))(...)`. +The I/O vtable at `+0x140 [0x50]` is critical - it provides the read callbacks that Storm uses to access the archive data. Read calls go through `(*(code **)(*param_1[0x50] + 4))(...)`. ### Archive Registration (How MPQs Are Opened) ``` MPQ_InitializeArchives (0x403740) │ -├─ ResizeArchiveArray(...) — allocate the global array +├─ ResizeArchiveArray(...) - allocate the global array │ ├─ For each base MPQ (model, texture, terrain, wmo, sound, misc, interface, fonts, dbc): │ └─ OpenMPQArchiveWithPaths(name, param2, index) [0x403b00] -│ ├─ FormatPath(buf, 0x104, pathIndex, name) — tries "Data\name" then "..\Data\name" +│ ├─ FormatPath(buf, 0x104, pathIndex, name) - tries "Data\name" then "..\Data\name" │ └─ Archive_OpenUnified(path, ..., &PTR_008826bc[index]) [0x648dd0] │ └─ OpenFileWithValidation(path, ..., &archive) [0x655690] │ └─ InitializeArchiveStructure(archive, ...) [0x655bf0] @@ -189,7 +189,7 @@ struct IOObject { // 0x118 bytes total [4] +0x10: 0x66e1c0 = IOManagerDestructor [5] +0x14: 0x66e1e0 = IOManagerCleanup [6] +0x18: 0x66e200 = (unknown) -[7] +0x1C: 0x7f800000 = (float NaN — padding/sentinel) +[7] +0x1C: 0x7f800000 = (float NaN - padding/sentinel) [8] +0x20: 0x66e2d0 = ValidateIOOperation [9] +0x24: 0x66e310 = GetIOResult [10]+0x28: 0x66e340 = CancelIOOperation @@ -235,7 +235,7 @@ The `SArchive` wrapper at the top level (8 bytes: `{type, handle}`) bridges thes Storm has no "register a provider" API. Every type code (0-4) is hardcoded to specific OS handle types. The I/O vtable exists but is deeply intertwined with async thread managers and buffer management (0x118 bytes of state). Creating a custom IO object is theoretically possible but requires replicating the async infrastructure. -The most practical "virtual filesystem" approaches use **real OS handles** that Storm can consume natively — either through temp files or through MPQ archives that Storm opens and manages itself. +The most practical "virtual filesystem" approaches use **real OS handles** that Storm can consume natively - either through temp files or through MPQ archives that Storm opens and manages itself. --- @@ -245,11 +245,11 @@ The most practical "virtual filesystem" approaches use **real OS handles** that Use Windows `CreateFile` with `FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE` to create **real OS file handles** backed primarily by the filesystem cache (RAM). Then hook only `openFileWithOptions` to redirect Storm to our temp files. -Windows `FILE_ATTRIBUTE_TEMPORARY` tells the cache manager to avoid flushing to disk if possible — the data lives in RAM. `FILE_FLAG_DELETE_ON_CLOSE` auto-deletes the file when the last handle closes, even on crash. +Windows `FILE_ATTRIBUTE_TEMPORARY` tells the cache manager to avoid flushing to disk if possible - the data lives in RAM. `FILE_FLAG_DELETE_ON_CLOSE` auto-deletes the file when the last handle closes, even on crash. ### Implementation -1. **DLL init** — for each embedded asset: +1. **DLL init** - for each embedded asset: ```c // Get temp directory GetTempPath(MAX_PATH, tempDir); @@ -271,7 +271,7 @@ Windows `FILE_ATTRIBUTE_TEMPORARY` tells the cache manager to avoid flushing to // Store mapping: "Spells\\WU_XYZ.m2" → tempPath ``` -2. **Hook `openFileWithOptions` (0x6477c0)** — single hook: +2. **Hook `openFileWithOptions` (0x6477c0)** - single hook: ``` openFileDetour(archive, path, flags, handle_out): if path matches our asset map: @@ -280,18 +280,18 @@ Windows `FILE_ATTRIBUTE_TEMPORARY` tells the cache manager to avoid flushing to return original(archive, path, flags, handle_out) ``` -3. **No other hooks needed** — Storm opens the temp file with `CreateFileA`, gets a real handle. All native I/O works: +3. **No other hooks needed** - Storm opens the temp file with `CreateFileA`, gets a real handle. All native I/O works: - `GetFileSizeFromHandle` → type-0 dispatch → `fstat` on real handle ✓ - `ReadFileFromMultipleSources` → type-0 dispatch → `fileReadWithLock` on real handle ✓ - `processAsyncFileOperation` → reads from real handle via worker thread ✓ - `CleanupFileHandleResources` → closes real handle ✓ - `loadModelFromFileAsync` → async task reads real handle ✓ -4. **Cleanup** — on DLL unload, close our original handles. `FILE_FLAG_DELETE_ON_CLOSE` handles the rest. If the process crashes, Windows still cleans up temp files. +4. **Cleanup** - on DLL unload, close our original handles. `FILE_FLAG_DELETE_ON_CLOSE` handles the rest. If the process crashes, Windows still cleans up temp files. ### Why This Works For Async -The entire reason our current approach needs 6 hooks is that fake type-0 contexts with NULL handles fail when the async executor calls `fileReadWithLock` directly. With real temp file handles, the async executor reads from a real OS handle — no hooks needed on the read path at all. +The entire reason our current approach needs 6 hooks is that fake type-0 contexts with NULL handles fail when the async executor calls `fileReadWithLock` directly. With real temp file handles, the async executor reads from a real OS handle - no hooks needed on the read path at all. ### Path Redirection Strategy @@ -325,7 +325,7 @@ Files should go in the system temp directory (`GetTempPath`), NOT the game direc `FILE_ATTRIBUTE_TEMPORARY` is a hint to the Windows cache manager: - Data is kept in the filesystem cache (RAM) and written to disk lazily - For small files (~20 assets, totaling a few MB), Windows will almost certainly keep everything in cache -- This is NOT a guarantee — under memory pressure, Windows may flush to disk +- This is NOT a guarantee - under memory pressure, Windows may flush to disk - But for our use case (~2-3 MB total), the data effectively stays in RAM ### Key Addresses @@ -343,10 +343,10 @@ OR: | `locateFileInDirectories` | 0x647e60 | __fastcall(6) | Return temp path as disk file | ### Pros -- **Reduces from 6 hooks to 1** — the biggest win -- **All async I/O works natively** — real OS handles, no fake contexts +- **Reduces from 6 hooks to 1** - the biggest win +- **All async I/O works natively** - real OS handles, no fake contexts - No fake file contexts, no critical section management, no refcount tracking -- No MPQ building required — files are individual temp files +- No MPQ building required - files are individual temp files - Auto-cleanup via `FILE_FLAG_DELETE_ON_CLOSE` - Temp files are invisible to the user (in system temp dir) - The `loadFileDetour` (0x648620) for addon files could remain unchanged @@ -356,7 +356,7 @@ OR: - Requires `CreateFile`/`WriteFile` Win32 calls from Zig (straightforward via `@import("std").os.windows`) - ~20 temp files created at init (small overhead, ~2-3 MB total) - Temp file creation adds a few ms to DLL init -- If DLL loads after `scanDirectoriesForFiles` (likely), the disk hash table won't have our files — must use path redirection hook rather than relying on native discovery +- If DLL loads after `scanDirectoriesForFiles` (likely), the disk hash table won't have our files - must use path redirection hook rather than relying on native discovery --- @@ -386,9 +386,9 @@ Build a real MPQ archive at compile time containing all embedded assets. Write i | Global array struct | 0x8826b4 | `{capacity, count, array_ptr, growth}` | ### Pros -- **Eliminates ALL 6 Storm I/O hooks** — game reads MPQ natively +- **Eliminates ALL 6 Storm I/O hooks** - game reads MPQ natively - Zero fake file contexts, zero async handling -- Proven approach — this is how patch.MPQ works +- Proven approach - this is how patch.MPQ works - The `loadFileDetour` (0x648620) for addon files could remain unchanged ### Cons @@ -434,10 +434,10 @@ For disk-based MPQs, this vtable points to functions that call `ReadFile`/`SetFi #### B1: Fake the File Backing (Simpler) 1. Embed MPQ in DLL via `@embedFile` -2. Hook `openFileWithOptions` to intercept when Storm opens `"WeirdUtils.mpq"` — create a fake file context pointing to the embedded MPQ blob +2. Hook `openFileWithOptions` to intercept when Storm opens `"WeirdUtils.mpq"` - create a fake file context pointing to the embedded MPQ blob 3. Add **seek support** to the fake context (current implementation only does flat memcpy from offset 0) -4. Call `Archive_OpenUnified("WeirdUtils.mpq", ...)` — Storm reads the MPQ header, hash table, block table through our hooked I/O -5. For subsequent reads FROM the archive (when loading files within the MPQ), Storm seeks within the same archive file handle — our hook serves the right bytes +4. Call `Archive_OpenUnified("WeirdUtils.mpq", ...)` - Storm reads the MPQ header, hash table, block table through our hooked I/O +5. For subsequent reads FROM the archive (when loading files within the MPQ), Storm seeks within the same archive file handle - our hook serves the right bytes **Key difference from current approach**: Instead of matching ~20 individual paths and creating ~20 fake contexts, we serve **one** fake file (the MPQ itself). Storm handles all per-file hash lookup, decompression, and I/O natively. @@ -448,8 +448,8 @@ For disk-based MPQs, this vtable points to functions that call `ReadFile`/`SetFi - File path matching reduces from ~20 paths to 1 **What we might drop**: -- `loadModelAsyncDetour` (0x71d4e0) — this hook exists because M2 async loading bypasses our read hook for type-0 fake contexts. But archive files go through Storm's type-4 read path, which reads from the archive handle. If the archive handle is our fake context with seek support, Storm's own async code should work. -- `processAsyncDetour` (0x647350) — same reasoning; the async executor reads from the archive file context, which goes through our hooked `readFileDetour`. +- `loadModelAsyncDetour` (0x71d4e0) - this hook exists because M2 async loading bypasses our read hook for type-0 fake contexts. But archive files go through Storm's type-4 read path, which reads from the archive handle. If the archive handle is our fake context with seek support, Storm's own async code should work. +- `processAsyncDetour` (0x647350) - same reasoning; the async executor reads from the archive file context, which goes through our hooked `readFileDetour`. **Risk**: The async executor might call `fileReadWithLock` directly on the archive handle, bypassing `ReadFileFromMultipleSources`. This is the exact problem that forced hooks 4 and 6 in the current approach. Needs verification. @@ -483,11 +483,11 @@ For disk-based MPQs, this vtable points to functions that call `ReadFile`/`SetFi ### Concept -Instead of hooking individual I/O functions, hook `locateFileInDirectories` (0x647e60) — the single function that decides WHERE a file comes from (disk, hash cache, or archive). Make it return type=0 (disk) with a path that resolves to our embedded data. +Instead of hooking individual I/O functions, hook `locateFileInDirectories` (0x647e60) - the single function that decides WHERE a file comes from (disk, hash cache, or archive). Make it return type=0 (disk) with a path that resolves to our embedded data. ### Why This Doesn't Quite Work -`locateFileInDirectories` only decides the file *location* — it doesn't serve data. After it returns type=0, `openFileWithOptions` calls `openFileHandle` to actually open the disk file. If there's no real file on disk, this fails. +`locateFileInDirectories` only decides the file *location* - it doesn't serve data. After it returns type=0, `openFileWithOptions` calls `openFileHandle` to actually open the disk file. If there's no real file on disk, this fails. However, this could work in combination with disk writes (see Approach A) or with a minimal I/O hook. @@ -548,7 +548,7 @@ This works for **disk-based** loose files but not for in-memory embedded data. H | Target | Address | Patch | |--------|---------|-------| -| `CheckFileExistence` | 0x654DD0 | Hook target — `__fastcall(ECX=filename, EDX=flags, [esp+4]=output)` | +| `CheckFileExistence` | 0x654DD0 | Hook target - `__fastcall(ECX=filename, EDX=flags, [esp+4]=output)` | | Gate 1 (JZ) | 0x654b5c | `74 25 → 90 90` (NOP) | | Gate 2 (JNZ) | 0x654b6a | `75 17 → 90 90` (NOP) | | Glob pattern byte | 0x82edc2 | `3F → 2A` ('?' → '*') for multi-char patch names | @@ -576,20 +576,20 @@ This works for **disk-based** loose files but not for in-memory embedded data. H ### Best Overall: Approach E (Windows Temp File Handles) **Reasoning**: -1. **Reduces from 6 hooks to 1** — the single biggest complexity reduction possible -2. **All async I/O works natively** — real OS handles eliminate the entire class of async-bypass bugs that forced hooks 4, 5, and 6 -3. **No MPQ building required** — avoids the entire hash table encryption / block table / header format complexity -4. **No new RE work** — we already know `openFileWithOptions` (0x6477c0) intimately -5. **Auto-cleanup** — `FILE_FLAG_DELETE_ON_CLOSE` handles cleanup even on crash -6. **Low risk** — we're giving Storm exactly what it expects (real disk files), just in a temp location -7. **Files live in RAM** — `FILE_ATTRIBUTE_TEMPORARY` keeps data in the filesystem cache for our small (~2-3 MB) asset set +1. **Reduces from 6 hooks to 1** - the single biggest complexity reduction possible +2. **All async I/O works natively** - real OS handles eliminate the entire class of async-bypass bugs that forced hooks 4, 5, and 6 +3. **No MPQ building required** - avoids the entire hash table encryption / block table / header format complexity +4. **No new RE work** - we already know `openFileWithOptions` (0x6477c0) intimately +5. **Auto-cleanup** - `FILE_FLAG_DELETE_ON_CLOSE` handles cleanup even on crash +6. **Low risk** - we're giving Storm exactly what it expects (real disk files), just in a temp location +7. **Files live in RAM** - `FILE_ATTRIBUTE_TEMPORARY` keeps data in the filesystem cache for our small (~2-3 MB) asset set **Next steps for Approach E**: 1. Write a prototype: create temp files from embedded data at DLL init 2. Hook `openFileWithOptions` to redirect matching paths to temp file paths 3. Verify M2 model loading works end-to-end (including textures via async path) 4. If successful, remove hooks 2-6 and the fake file context infrastructure -5. Keep `loadFileDetour` (0x648620) for addon files — these use a different pipeline +5. Keep `loadFileDetour` (0x648620) for addon files - these use a different pipeline ### Runner-Up: Approach A (Temp MPQ on Disk) @@ -680,7 +680,7 @@ The customassets project's `CheckFileExistence` hook and glob pattern patch coul |--------|---------|-------------| | Archive array capacity | 0x8826b4 | Max archive slots | | Archive array count | 0x8826b8 | Current archive count | -| Archive array pointer | 0x8826bc | `SArchive**` — array of pointers | +| Archive array pointer | 0x8826bc | `SArchive**` - array of pointers | | Archive array growth | 0x8826c0 | Growth increment | | Disk hash table base | 0xc521e8 | File path → disk path hash table | | Disk hash table mask | 0xc521f0 | Hash mask (0xffffffff = disabled) | diff --git a/src/markers/RESEARCH.md b/src/markers/RESEARCH.md index 6086df0..5da2f61 100644 --- a/src/markers/RESEARCH.md +++ b/src/markers/RESEARCH.md @@ -1,7 +1,7 @@ # M2 Model Loading Research ## Root Cause (Confirmed) -M2 model loading uses `openFileWithOptions` (0x6477c0) directly — it **never** goes through our hooked `LoadFileWithTextureResourceFallback` (0x648620). That's why embedded M2 files aren't served. +M2 model loading uses `openFileWithOptions` (0x6477c0) directly - it **never** goes through our hooked `LoadFileWithTextureResourceFallback` (0x648620). That's why embedded M2 files aren't served. ## M2 Loading Call Chain ``` @@ -30,17 +30,17 @@ createModelAttachment (0x707350) | `CleanupFileHandleResources` (0x648730) | `__stdcall` | 1 | `RET 0x04` | | `loadModelFromFileAsync` (0x71d4e0) | `__thiscall` | ECX=this, 2 stack | `RET 0x08` | -## GetFileSizeFromHandle (0x6487f0) — dispatches on context type -- Type 0: `fstatFileHandle(ctx[1]+0x10)` — needs real file handle -- Type 1: returns `ctx[5]` (value at +0x14) — **simplest, just returns a stored value** +## GetFileSizeFromHandle (0x6487f0) - dispatches on context type +- Type 0: `fstatFileHandle(ctx[1]+0x10)` - needs real file handle +- Type 1: returns `ctx[5]` (value at +0x14) - **simplest, just returns a stored value** - Types 2/3: `GetAudioStreamPosition(ctx[0xf])` - Type 4: `getFileSize(ctx[0x10])` -## ReadFileFromMultipleSources (0x648460) — dispatches on context type -- Type 0: `fileReadWithLock(buffer, 1, size, ctx[1])` — needs real file handle +## ReadFileFromMultipleSources (0x648460) - dispatches on context type +- Type 0: `fileReadWithLock(buffer, 1, size, ctx[1])` - needs real file handle - Other types: use respective handle fields -## initializeFileContext (0x647290) — __thiscall, ECX=ctx, 1 stack param (type) +## initializeFileContext (0x647290) - __thiscall, ECX=ctx, 1 stack param (type) ```c TraverseListNodes((LPCRITICAL_SECTION)(this + 0x24)); // init critical section *(uint *)this = param_1; // +0x00: type @@ -57,7 +57,7 @@ TraverseListNodes((LPCRITICAL_SECTION)(this + 0x24)); // init critical section *(uint *)(this + 0x1C) = 0; ``` -## CleanupFileHandleResources (0x648730) — __stdcall, 1 param, RET 0x04 +## CleanupFileHandleResources (0x648730) - __stdcall, 1 param, RET 0x04 ```c if (ctx + 0x04 != NULL) closeFileStreamSafely(ctx + 0x04); // disk file handle if (ctx + 0x3C != 0) Stream_CompareBuffers(ctx + 0x3C); // stream @@ -82,7 +82,7 @@ if (ctx + 0x40 != NULL) closeArchiveFile(ctx + 0x40); // archive ``` Queued via `AsyncTask_QueueForExecution` (0x443ae0). Executor reads from file handle into buffer, then calls completion callback. -## loadResourceByPath (0x706a50) — __thiscall, ECX=resourceMgr +## loadResourceByPath (0x706a50) - __thiscall, ECX=resourceMgr After `openFileWithOptions` succeeds: 1. Allocates 0x164-byte model object via `M2_AllocateModelBuffer` 2. `initializeModelObject(modelObj, resourceMgr)` @@ -90,7 +90,7 @@ After `openFileWithOptions` succeeds: 4. On success: copies normalized path into modelObj+0x20, sets up hash links 5. On failure: `CleanupFileHandleResources`, free model object, return NULL -## LoadFileWithTextureResourceFallback (0x648620) — our hooked function +## LoadFileWithTextureResourceFallback (0x648620) - our hooked function Calls `openFileWithOptions(param_1, path, async_flag, &handle_out)`, then: 1. `GetFileSizeFromHandle(handle, NULL)` → size 2. `M2_AllocateModelBuffer(size + extra_alloc)` → buffer @@ -119,7 +119,7 @@ Every file context type needs a **real file handle or archive handle** for the r ### Option 3: Hook openFileWithOptions only - Make it produce a context that works through existing read pipeline -- **Hardest** — requires understanding all read paths for the chosen type +- **Hardest** - requires understanding all read paths for the chosen type ## Implementation: In-Memory File Serving (chosen: Option 1 extended) @@ -136,21 +136,21 @@ Every file context type needs a **real file handle or archive handle** for the r ### Fake File Context Layout Allocated via `allocateGameBuffer(0x60)`, zero-filled, then: -- `initializeFileContext(ctx, 0)` — sets type=0, inits critsec at +0x24 +- `initializeFileContext(ctx, 0)` - sets type=0, inits critsec at +0x24 - `+0x0C`: duplicated path string (game-allocated) - `+0x30`: embedded data pointer (custom field, points into DLL .rdata) - `+0x34`: embedded data size (custom field) - **Detection**: `type==0 && handle(+0x04)==NULL && *(ctx+0x30)!=0` - Return value from openFileWithOptions: 2 (non-zero = success) -### processAsyncFileOperation (0x647350) — verified via Ghidra +### processAsyncFileOperation (0x647350) - verified via Ghidra - `__fastcall(ECX=request)`, plain `RET` (c3) - Request: `+0x08`=file_ctx, `+0x0C`=dest_buf, `+0x10`=read_size, `+0x14`=seek/event struct -- Event handle at `*(*(request+0x14)+4)` — signaled via `SetEvent` +- Event handle at `*(*(request+0x14)+4)` - signaled via `SetEvent` - Cleanup epilogue: decrement `*(ctx+0x5c)`, `LeaveCriticalSection(ctx+0x24)`, signal event, conditional `CleanupFileHandleResources` - Close-after-read flag at `*(ctx+0x58)` -### CleanupFileHandleResources (0x648730) — verified decompile +### CleanupFileHandleResources (0x648730) - verified decompile ```c void CleanupFileHandleResources(int ctx) { if (ctx == 0) return; @@ -160,21 +160,21 @@ void CleanupFileHandleResources(int ctx) { if (*(ctx+0x08)) { /* check+free sub-buffer */ FreeMemory(*(ctx+0x08)); } cleanupFileContext(ctx); // destroy critsec FreeMemory(ctx); // free 0x60 struct - // NOTE: does NOT free path at +0x0C — we must free it ourselves + // NOTE: does NOT free path at +0x0C - we must free it ourselves } ``` -FreeMemory (SMemFree) at **0x646430** — `__stdcall(ptr, src_str, flags)`. +FreeMemory (SMemFree) at **0x646430** - `__stdcall(ptr, src_str, flags)`. ### Crash: Hook Install Order Matters **Symptom**: Crash on game load in `loadFileDetour` calling `file_hook.getTrampoline()`. The trampoline memory (VirtualAlloc'd) contained zeros instead of the saved prologue. **Analysis**: -- Crash at `0x075D5E88` (trampoline memory) — bytes: `00 00 00 00` +- Crash at `0x075D5E88` (trampoline memory) - bytes: `00 00 00 00` - Return address `0x04AA1542` in weirdutils.dll = `CALL *%eax` in `loadFileDetour` - `getTrampoline` (compiled at DLL+0x3250) reads `file_hook.trampoline` field from `.data` section at `0x1015c0d8`, checks non-NULL, calls through it -**Root cause**: `LoadFileWithTextureResourceFallback` (0x648620) internally calls `openFileWithOptions` (0x6477c0). If `file_hook` is installed FIRST (copying the 0x648620 prologue to its trampoline), and THEN we patch 0x6477c0, the trampoline's execution context is disrupted. The `file_hook` trampoline runs the original 0x648620 prologue which eventually calls 0x6477c0 — but if 0x6477c0 was patched after the trampoline was built, there may be page-level or VirtualProtect interactions that corrupt the trampoline's allocated memory. +**Root cause**: `LoadFileWithTextureResourceFallback` (0x648620) internally calls `openFileWithOptions` (0x6477c0). If `file_hook` is installed FIRST (copying the 0x648620 prologue to its trampoline), and THEN we patch 0x6477c0, the trampoline's execution context is disrupted. The `file_hook` trampoline runs the original 0x648620 prologue which eventually calls 0x6477c0 - but if 0x6477c0 was patched after the trampoline was built, there may be page-level or VirtualProtect interactions that corrupt the trampoline's allocated memory. **Fix**: Install Storm I/O hooks (`installFileHooks`) BEFORE `file_hook` at 0x648620. Remove in reverse order. @@ -184,20 +184,20 @@ There are TWO independent async systems for file I/O: **1. High-level async task system** (used by M2 model + texture loading): - Queue: `AsyncTask_QueueForExecution` (0x443ae0) -- Worker: `AsyncTaskWorkerThread` (0x443360) — calls **ReadFileFromMultipleSources** (our hook 3!) -- Main thread: `ProcessAsyncTasksWithTimeLimit` (0x443E70) — calls completion callbacks +- Worker: `AsyncTaskWorkerThread` (0x443360) - calls **ReadFileFromMultipleSources** (our hook 3!) +- Main thread: `ProcessAsyncTasksWithTimeLimit` (0x443E70) - calls completion callbacks - Task structure: `[0]=file_ctx, [1]=buffer, [2]=size, [3]=callback_ctx, [4]=callback_fn` - For M2 models: callback = `onModelLoadComplete` (0x71d5e0), executor = `asyncFileReader` (0x71d610) - For textures: callback = `TextureLoadCallback` (0x44a500), queued from `LoadTextureFromPath` (0x44a310) **2. Low-level file async system** (NOT used for texture/M2 loading): -- `processAsyncFileOperation` (0x647350) — calls **fileReadWithLock** (0x740c97) directly +- `processAsyncFileOperation` (0x647350) - calls **fileReadWithLock** (0x740c97) directly - Request: `+0x08=file_ctx, +0x0C=dest_buf, +0x10=read_size, +0x14=seek/event` - Type-dispatched: type 0→fileReadWithLock, type 1→decompressFileData, type 2/3→stream, type 4→archive - Has close-after-read flag at ctx+0x58, refcount at ctx+0x5c -- NOT installed as a hook — not needed since texture loading uses the high-level system +- NOT installed as a hook - not needed since texture loading uses the high-level system -### onModelLoadComplete (0x71d5e0) — Verified Decompile +### onModelLoadComplete (0x71d5e0) - Verified Decompile ```c void __fastcall onModelLoadComplete(void *modelObject) { CleanupFileHandleResources(**(int **)(modelObject + 0xc)); // free file handle via task @@ -208,7 +208,7 @@ void __fastcall onModelLoadComplete(void *modelObject) { ``` **Critical**: cleanup file handle BEFORE processLoadedModelData. Our hook matches this order. -### cleanupFileContext (0x6472d0) — Verified Decompile +### cleanupFileContext (0x6472d0) - Verified Decompile ```c void __fastcall cleanupFileContext(int param_1) { if (*(param_1 + 0x1c)) { cleanupInflateContext(*(param_1+0x1c)); FreeMemory(*(param_1+0x1c)); } @@ -220,7 +220,7 @@ void __fastcall cleanupFileContext(int param_1) { ``` NOTE: cleanupFileContext DOES free +0x0C (path string). Do NOT free it manually. -### TextureLoadCallback (0x44a500) — Verified Decompile +### TextureLoadCallback (0x44a500) - Verified Decompile ```c void __fastcall TextureLoadCallback(int texture_obj) { puVar1 = ProcessTextureData(texture_obj); @@ -233,7 +233,7 @@ void __fastcall TextureLoadCallback(int texture_obj) { ``` Called by ProcessAsyncTasksWithTimeLimit on the main thread after worker completes. -### LoadTextureFromPath (0x44a310) — Texture Async Task Setup +### LoadTextureFromPath (0x44a310) - Texture Async Task Setup ```c puVar5 = openFileWithOptions(NULL, path, flags, &file_handle); // hook 1 creates fake puVar7 = AllocateAsyncTaskObject(); @@ -251,7 +251,7 @@ AsyncTask_QueueForExecution(task); - Crash at EIP=0x00000000 after processLoadedModelData returns 1 - Stack shows TextureLoadCallback (0x44A526 = after CALL CleanupFileHandleResources) - File context 0x36596088 on stack (was M2 ctx, freed, reused as BLP ctx) -- EDX=0x36596080 (ctx-8), EBP=04AA3212 (in weirdutils.dll — corrupted frame ptr) +- EDX=0x36596080 (ctx-8), EBP=04AA3212 (in weirdutils.dll - corrupted frame ptr) - Crash appears to be inside our cleanupFileHandleDetour during BLP context cleanup - BLP data IS served correctly (AsyncTaskWorkerThread → ReadFileFromMultipleSources → hook 3) - Investigation ongoing: possible stack corruption in callCleanupFileContext or freeGameBuffer @@ -274,7 +274,7 @@ WorldFrameUpdate(this, deltaTime): if hitType == 2: HandleTargetSelection(this, &localResult) // object hover ``` -### UpdateHitTest (0x481F00) — __fastcall(ECX=worldFrame) +### UpdateHitTest (0x481F00) - __fastcall(ECX=worldFrame) Called on **click events** (not every frame). Performs the same raycast but stores the result persistently at `worldFrame + 0x350`: ```c @@ -301,14 +301,14 @@ void __fastcall UpdateHitTest(void *worldFrame) { | +0x388 | 4 | f32 | Ray distance | ### AoE Targeting Reticle Globals (only during spell targeting) -- `0x00B4B3A0` Vec3 — terrain position under cursor (written by HandleGroundTargeting) -- `0x00B4B3B0` f32 — spell targeting radius -- `0x0083DC2C` u32 — validity: 0=valid, 1=out-of-range, 3=updating -- `0x00CECAC0` u16 — spell targeting state flags (0x20=terrain, 0x40=secondary) +- `0x00B4B3A0` Vec3 - terrain position under cursor (written by HandleGroundTargeting) +- `0x00B4B3B0` f32 - spell targeting radius +- `0x0083DC2C` u32 - validity: 0=valid, 1=out-of-range, 3=updating +- `0x00CECAC0` u16 - spell targeting state flags (0x20=terrain, 0x40=secondary) ### Click-to-Move Destination -- `0x00C4D890` Vec3 — destination (only when CTM initiated) -- `0x00C4D888` u32 — movement mode +- `0x00C4D890` Vec3 - destination (only when CTM initiated) +- `0x00C4D888` u32 - movement mode ### Key Functions | Address | Name | Convention | Params | @@ -326,10 +326,10 @@ void __fastcall UpdateHitTest(void *worldFrame) { ### Approach for Markers ### HitTestPoint / WorldIntersectionTest Return Values `WorldIntersectionTest(rayStart, rayEnd, gameStateFlags, result)` returns: -- `0` — no intersection (sky) — coords NOT written to result -- `gameStateFlags & 1` — terrain hit — coords written. Returns 1 only during +- `0` - no intersection (sky) - coords NOT written to result +- `gameStateFlags & 1` - terrain hit - coords written. Returns 1 only during AoE targeting (bit 0 set), otherwise returns 0 even on valid terrain hit -- `2` — object hit (closer than terrain) +- `2` - object hit (closer than terrain) So hitType=0 is ambiguous: either "terrain hit in normal mode" or "no hit at all". To distinguish: zero the result coords before calling, then check if they were written. @@ -337,7 +337,7 @@ To distinguish: zero the result coords before calling, then check if they were w ### Approach for Markers Call `UpdateHitTest(worldFrame)` to perform the raycast and store result at `worldFrame+0x350`. Zero intersection coords before the call, then check if -they were populated. This is safe from Lua callbacks — `HitTestPoint` +they were populated. This is safe from Lua callbacks - `HitTestPoint` saves/restores view matrices. The persistent result at `worldFrame+0x358` is normally only click-updated, but overwriting it is harmless. @@ -553,19 +553,19 @@ cause the visual glitch. Need to identify which bone tracks use global sequences --- -## World Teardown — Entity Cleanup Crash Investigation +## World Teardown - Entity Cleanup Crash Investigation ### Crash Details -- **Crash function**: 0x687220 — generic linked-list unlink operation - - First crash at 0x687243: `mov [edx], esi` — write to freed memory - - Second crash at 0x687221: `mov esi, [ecx]` — read from freed memory -- **When**: Logout to character select, map transitions — NOT during normal gameplay +- **Crash function**: 0x687220 - generic linked-list unlink operation + - First crash at 0x687243: `mov [edx], esi` - write to freed memory + - Second crash at 0x687221: `mov esi, [ecx]` - read from freed memory +- **When**: Logout to character select, map transitions - NOT during normal gameplay - **Thread**: Background/worker thread (very short stack: WoW.exe → kernel32 → ntdll) - **Root cause**: WDOODADDEF heap teardown iterates linked list, hits freed or corrupt node ### Decompiled Crash Function (0x687220) ```c -// Linked-list unlink — removes node from intrusive doubly-linked list +// Linked-list unlink - removes node from intrusive doubly-linked list void __fastcall UnlinkFromList(int *param_1) { int prev = *param_1; // param_1[0] = prev pointer if (prev != 0) { @@ -597,7 +597,7 @@ void __fastcall UnlinkFromList(int *param_1) { ### World Teardown Chain (Ghidra-verified) ``` -CleanupWorldAndEntities (0x66fc40) — void(), no params, __stdcall +CleanupWorldAndEntities (0x66fc40) - void(), no params, __stdcall ├── CleanupEntityList_ProcessAll() ← iterates UNKNOWN list └── CleanupWorldAndReleaseResources (0x697ac0) ├── ClearWorldObjectsAndResetState (0x6a6710) ← iterates linked list at PTR_00c96088 @@ -608,13 +608,13 @@ CleanupWorldAndEntities (0x66fc40) — void(), no params, __stdcall ``` ### Callers of CleanupWorldAndEntities (0x66fc40) -- `InitializeWorldScene` (0x401bc0) — **map change** (cleans old world before loading new) -- `ShutdownClientSystems` (0x401ee0) — **full game exit** +- `InitializeWorldScene` (0x401bc0) - **map change** (cleans old world before loading new) +- `ShutdownClientSystems` (0x401ee0) - **full game exit** ### Callers of ClearWorldObjectsAndResetState (0x6a6710) -- `LoadWorldMap` (0x6941f0) — map loading -- `UpdateWorldAndGameObjects` (0x698390) — periodic world update (chunk unloading?) -- `CleanupWorldAndReleaseResources` (0x697ac0) — full teardown +- `LoadWorldMap` (0x6941f0) - map loading +- `UpdateWorldAndGameObjects` (0x698390) - periodic world update (chunk unloading?) +- `CleanupWorldAndReleaseResources` (0x697ac0) - full teardown - `SimpleWorldUpdate` (0x694920) ### Entity Type Dispatch in CleanupEntity_ProcessAttachments (0x670d50) @@ -636,7 +636,7 @@ void __fastcall CleanupEntity_ProcessAttachments(entity) { ``` - M2 entities (CreateWorldUnit → WDOODADDEF heap) have flag 0x40 - WMO entities (CreateGameObject → WMAPOBJDEF heap) have flag 0x8 -- Ghidra names are misleading — `cleanupGameObject` handles M2/WDOODADDEF, `destroyWorldEnvironment` handles WMO/WMAPOBJDEF +- Ghidra names are misleading - `cleanupGameObject` handles M2/WDOODADDEF, `destroyWorldEnvironment` handles WMO/WMAPOBJDEF ### CleanupEntity_ProcessAttachments Callers (ONLY 3 in entire binary) - `processCinematicExit` (0x6e4940) @@ -645,10 +645,10 @@ void __fastcall CleanupEntity_ProcessAttachments(entity) { - **NOT called by CleanupEntityList_ProcessAll** or any teardown function ### WDOODADDEF Heap (0xCA7E20) References -- `AllocateRenderableObject` (0x6a07f7) — allocates from heap -- `CleanupVisualEffectAndRelease` (0x6a0916) — frees to heap -- `InitializeWorldSystem` (0x691f4f) — initializes heap -- `CleanupWorldSystem` (0x692241) — tears down heap +- `AllocateRenderableObject` (0x6a07f7) - allocates from heap +- `CleanupVisualEffectAndRelease` (0x6a0916) - frees to heap +- `InitializeWorldSystem` (0x691f4f) - initializes heap +- `CleanupWorldSystem` (0x692241) - tears down heap - Called by `ShutdownAllGameSystems` (0x66fb00) ### Other Key Addresses @@ -656,9 +656,9 @@ void __fastcall CleanupEntity_ProcessAttachments(entity) { - `CleanupWorldSystem` (0x6920c0) → called by ShutdownAllGameSystems - `cleanupSecondaryResources` (0x6a6c70) → called from CleanupWorldAndEntities + CleanupWorldSystem - This calls `cleanupGameObject` (0x6a67a0) and `destroyWorldEnvironment` (0x6a6870) on entities -- `gameQuit` (0x41f9b0) — fires on disconnect/quit (ref: UnitXP_SP3) +- `gameQuit` (0x41f9b0) - fires on disconnect/quit (ref: UnitXP_SP3) -### World Unit Hash Table (0xCA7DC0) — The Crash Structure +### World Unit Hash Table (0xCA7DC0) - The Crash Structure The crash occurs during teardown of a **hash table at 0xCA7DC0** that tracks all world units (doodads created via `CreateWorldUnit`). Our entities ARE in this table. @@ -682,7 +682,7 @@ The crash occurs during teardown of a **hash table at 0xCA7DC0** that tracks all - `FindOrCreateWorldUnit` (0x694e90): reads 0xCA7DC4, 0xCA7DDC - `complexListInitializerWithCleanup` (0x69f670): reads/writes ALL (init + teardown) -### CreateWorldUnit (0x694980) — Entity Registration (Decompiled) +### CreateWorldUnit (0x694980) - Entity Registration (Decompiled) `CreateWorldUnit` inserts entities into **three** linked lists: ```c @@ -722,7 +722,7 @@ int *CreateWorldUnit(char *modelPath, float *pos, float facing, int param4) { **Critical**: Entity is in 3 lists. All 3 must be unlinked during cleanup. -### CleanupVisualEffectAndRelease (0x6a0840) — What It Unlinks +### CleanupVisualEffectAndRelease (0x6a0840) - What It Unlinks `CleanupVisualEffectAndRelease` unlinks from up to **three** linked lists: ```c @@ -807,21 +807,21 @@ Only 3 CALLs visible in the function body: `FindSubstringInString`, `CreateGameO `ModelAttachment_CreateNode`. The M2 path (`CreateWorldUnit`) must be via tail-call or the decompiler inlined it. -### CleanupWorldAndEntities (0x66fc40) — Full Chain (Decompiled) +### CleanupWorldAndEntities (0x66fc40) - Full Chain (Decompiled) ```c void CleanupWorldAndEntities(void) { - CleanupEntityList_ProcessAll(); // 0x672c40 — iterates PTR_00c7b2dc (NOT hash table) + CleanupEntityList_ProcessAll(); // 0x672c40 - iterates PTR_00c7b2dc (NOT hash table) CleanupWorldAndReleaseResources(); // 0x697ac0 PTR_00c7b748 = 0; } ``` -### CleanupEntityList_ProcessAll (0x672c40) — Decompiled +### CleanupEntityList_ProcessAll (0x672c40) - Decompiled Iterates the linked list at `PTR_00c7b2dc` (NOT the hash table at 0xCA7DC0). For each entry, calls `CleanupObjectAttachments_FreeMemory` which ends with -`DestroyWorldObjectAndRelease`. Our entities are NOT in `PTR_00c7b2dc` — +`DestroyWorldObjectAndRelease`. Our entities are NOT in `PTR_00c7b2dc` - they're only registered in the hash table. So this function doesn't touch them. ```c @@ -838,24 +838,24 @@ void CleanupEntityList_ProcessAll(void) { ``` **PTR_00c7b2dc xrefs** (who manages this list): -- `CleanupEntityList_ProcessAll` (0x672c40) — reads/iterates -- `UpdateFadeEffects_ProcessTimers` (0x672efe) — reads -- `DestroyFileMapping` (0x66f460) — reads + writes (teardown) -- `SetFileAttributes` (0x66f440) — writes (initialization) -- `CreateFadeEffect_EntityManagement` (0x672e76) — reads (entity insertion?) +- `CleanupEntityList_ProcessAll` (0x672c40) - reads/iterates +- `UpdateFadeEffects_ProcessTimers` (0x672efe) - reads +- `DestroyFileMapping` (0x66f460) - reads + writes (teardown) +- `SetFileAttributes` (0x66f440) - writes (initialization) +- `CreateFadeEffect_EntityManagement` (0x672e76) - reads (entity insertion?) Our entities created via `CreateEntityInstance_WithAttachment` are NOT added to this list. The callers (`CastSpellByID_Extended`, `CreateGameObjectPathEffect`) probably add the returned entity to PTR_00c7b2dc themselves. We don't. -### Vtable at 0x0081089c — Hash Table Entry Destructors +### Vtable at 0x0081089c - Hash Table Entry Destructors ``` -[0] 0x006a1170 DestroyMapDoodadDefinition — WDOODADDEF destructor +[0] 0x006a1170 DestroyMapDoodadDefinition - WDOODADDEF destructor [1] 0x006a11a0 LoadAndAddMapDoodadToList [2] 0x006a14d0 MapDoodadDestructor [3] 0x006a1260 CleanupMapDoodadContainer -[4] 0x006a1320 DestroyMapObjectDefinition — WMAPOBJDEF destructor +[4] 0x006a1320 DestroyMapObjectDefinition - WMAPOBJDEF destructor [5] 0x006a1350 LoadAndAddMapObjectToList [6] 0x006a1590 MapObjectDestructor [7] 0x006a1410 CleanupMapObjectContainer @@ -869,9 +869,9 @@ void DestroyMapDoodadDefinition(undefined **param_1) { } ``` This is a simple destructor: calls entity vtable[0](0) then frees via SMemFree. -NOT the same as CleanupVisualEffectAndRelease — doesn't unlink from lists. +NOT the same as CleanupVisualEffectAndRelease - doesn't unlink from lists. -### hashTableTeardownLoop (0x69f740) — atexit Handler (Decompiled) +### hashTableTeardownLoop (0x69f740) - atexit Handler (Decompiled) Registered via `validateMemoryOperation` (atexit) at 0x69f730. Runs during process exit. ```c @@ -912,7 +912,7 @@ void hashTableTeardownLoop(void) { If an entity was freed (by our cleanup) but not unlinked from these lists, the teardown follows dangling pointers into freed heap memory. -### cleanupGameObject (0x6a67a0) — Decompiled +### cleanupGameObject (0x6a67a0) - Decompiled ```c void __fastcall cleanupGameObject(undefined **param_1) { @@ -939,7 +939,7 @@ void __fastcall cleanupGameObject(undefined **param_1) { } ``` -### CleanupVisualEffectAndRelease (0x6a0840) — What It Actually Unlinks +### CleanupVisualEffectAndRelease (0x6a0840) - What It Actually Unlinks ```c void __fastcall CleanupVisualEffectAndRelease(entity) { @@ -963,7 +963,7 @@ void __fastcall CleanupVisualEffectAndRelease(entity) { **CONFIRMED**: `CleanupVisualEffectAndRelease` unlinks entity[4]/[5] (global list) and conditionally entity[0x2e-0x31]. It does NOT unlink from the **hash bucket list**. -### ManageLinkedList (0x695ef0) — Intrusive List Insertion +### ManageLinkedList (0x695ef0) - Intrusive List Insertion ```c void __thiscall ManageLinkedList(void *this, int *entity, int mode, int insert_point) { @@ -1063,29 +1063,29 @@ So the 3 intrusive list node offsets within a WDOODADDEF entity are: After `ManageLinkedList` insertion, entity[0x2F] should always be non-zero (sentinel has bit 0 set = odd address). -### InitializeRenderableObject (0x6a7d00) — Entity Initialization +### InitializeRenderableObject (0x6a7d00) - Entity Initialization Called from `AllocateRenderableObject`. Zeroes most fields including: -- entity[0x2E] = 0, entity[0x2F] = 0 (bucket list node — zeroed before insertion) -- entity[0x30] = 0, entity[0x31] = 0 (global list node — zeroed before insertion) +- entity[0x2E] = 0, entity[0x2F] = 0 (bucket list node - zeroed before insertion) +- entity[0x30] = 0, entity[0x31] = 0 (global list node - zeroed before insertion) - entity[2] |= 0x40 (sets the M2/WDOODADDEF flag) - entity[0] = vtable PTR_DestroyRenderableObject_00810a74 -### Callers of CreateEntityInstance_WithAttachment — What They Do After +### Callers of CreateEntityInstance_WithAttachment - What They Do After Only 3 callers in entire binary: -1. **`CreateGameObjectPathEffect` (0x5f8030)** — `__thiscall` on a game object +1. **`CreateGameObjectPathEffect` (0x5f8030)** - `__thiscall` on a game object - Does NOT save the return value! Fire-and-forget. - - Passes `(path, pos, facing, 0, 0, param_1, param_2)` — param_6/7 are parent refs + - Passes `(path, pos, facing, 0, 0, param_1, param_2)` - param_6/7 are parent refs -2. **`CastSpellByID_Extended` (0x6e4b60)** — spell casting +2. **`CastSpellByID_Extended` (0x6e4b60)** - spell casting - Stores in global `PTR_00ceca8c` - Calls `SetEntityFlag_ToggleBit(entity, 0)` = sets `entity[0xD] |= 1` - Cleaned up by `processCinematicExit` → `CleanupEntity_ProcessAttachments(PTR_00ceca8c)` - - Passes `(path, pos, 0.0, 0, 0, 0, 0)` — update_now=0! + - Passes `(path, pos, 0.0, 0, 0, 0, 0)` - update_now=0! -3. **Unknown (0x6e5a6e)** — likely another spell effect +3. **Unknown (0x6e5a6e)** - likely another spell effect **Key differences from our call**: - Both native callers pass `update_now=0` (param_5). We pass `update_now=1`. @@ -1098,7 +1098,7 @@ Only 3 callers in entire binary: Map doodads use `FindOrCreateWorldUnit` (0x694e90) called from `AttachDoodadObjects` (0x695b1e). These are separate creation paths that both register in the hash table but through different code. -### CleanupWorldAndReleaseResources (0x697ac0) — Full Chain +### CleanupWorldAndReleaseResources (0x697ac0) - Full Chain ```c void CleanupWorldAndReleaseResources(void) { @@ -1119,7 +1119,7 @@ atexit teardown at 0x691830 (separate from hash table teardown). Base offset is Disabled ALL our cleanup (no CleanupEntity_ProcessAttachments, no world_cleanup_hook, no removeHooks cleanup). Created 5 markers, replaced with 5 more, closed game. -**Still crashed.** This means the crash is NOT caused by our cleanup — the game's +**Still crashed.** This means the crash is NOT caused by our cleanup - the game's own atexit handler can't handle our entities even when they're fully intact. The WDOODADDEF heap is destroyed by `CleanupWorldSystem` (called from @@ -1138,22 +1138,22 @@ and are NOT registered in any cleanup tracking list. They're cleaned up explicit map change/exit, it would have the same crash problem as our entities. The game avoids this because spells always end before map transitions. But we don't -have that guarantee — our markers persist across frames until explicitly cleared. +have that guarantee - our markers persist across frames until explicitly cleared. ### TODO -- [x] Decompile 0x672c40 (CleanupEntityList_ProcessAll) — iterates PTR_00c7b2dc, not hash table -- [x] Decompile hashTableTeardownLoop (0x69f740) — atexit handler, iterates all hash entries -- [x] Check vtable at 0x0081089c — DestroyMapDoodadDefinition, simple free -- [x] Decompile ContainerLookup (0x687960) — converts between list spaces -- [x] Decompile cleanupGameObject + CleanupVisualEffectAndRelease — handles all 3 lists IF entity[0x2F]!=0 -- [x] Decompile ManageLinkedList (0x695ef0) — intrusive list with base offset +- [x] Decompile 0x672c40 (CleanupEntityList_ProcessAll) - iterates PTR_00c7b2dc, not hash table +- [x] Decompile hashTableTeardownLoop (0x69f740) - atexit handler, iterates all hash entries +- [x] Check vtable at 0x0081089c - DestroyMapDoodadDefinition, simple free +- [x] Decompile ContainerLookup (0x687960) - converts between list spaces +- [x] Decompile cleanupGameObject + CleanupVisualEffectAndRelease - handles all 3 lists IF entity[0x2F]!=0 +- [x] Decompile ManageLinkedList (0x695ef0) - intrusive list with base offset - [x] Confirm bucket base offset = 0xB8 from InitializeHashTable -- [x] TEST: no-cleanup build still crashes — crash is NOT from our cleanup -- [x] Decompile native callers — neither registers in extra lists +- [x] TEST: no-cleanup build still crashes - crash is NOT from our cleanup +- [x] Decompile native callers - neither registers in extra lists - [x] **Investigate spell-spawned game objects (e.g. mailbox summon) as reference** - - Spell entities are NOT "persistent game objects" — they're client-side visual effects only + - Spell entities are NOT "persistent game objects" - they're client-side visual effects only - `processCinematicExit` (0x6e4940) explicitly cleans them: `CleanupEntity_ProcessAttachments(entity); entity = NULL;` - - Called before every new spell cast — spell entities NEVER survive to teardown + - Called before every new spell cast - spell entities NEVER survive to teardown - If a spell entity survived to atexit, it would crash too (same bug as ours) - [x] Check what `ClearWorldObjectsAndResetState` (PTR_00c96088) contains - PTR_00c96088 is a **terrain chunk list**, NOT a WDOODADDEF entity list @@ -1161,12 +1161,12 @@ have that guarantee — our markers persist across frames until explicitly clear - `LoadWorldTerrainChunk` writes to PTR_00c96084 (the list head) - Map doodads are attached to parent chunks via `ModelAttachment_CreateNode` in `AttachDoodadObjects` - `destroyPrimaryGameObject` (0x6a69f0) destroys the parent chunk, which walks attachment children - - We CANNOT participate in this list — it's for terrain chunks, not standalone entities + - We CANNOT participate in this list - it's for terrain chunks, not standalone entities - [x] Determine proper entity lifecycle for persistent world objects - **There is no native path for standalone persistent WDOODADDEF entities** - All native callers either: (a) attach to parent chunks, or (b) explicitly clean up before teardown - Correct approach: explicit cleanup via `CleanupEntity_ProcessAttachments` before teardown - - Hook point: `CleanupWorldAndEntities` (0x66fc40) PRE-hook — fires for exit, logout, AND map change + - Hook point: `CleanupWorldAndEntities` (0x66fc40) PRE-hook - fires for exit, logout, AND map change ## Entity Lifecycle Solution (Confirmed) @@ -1201,7 +1201,7 @@ Hook `CleanupWorldAndEntities` (0x66fc40). Before calling the original: 1. Call `CleanupEntity_ProcessAttachments` on all active marker entities 2. Call `CleanupEntity_ProcessAttachments` on all despawning entities 3. Null all entity pointers / reset state -4. Call original — hash table no longer contains our entries → no crash +4. Call original - hash table no longer contains our entries → no crash This handles ALL scenarios: game exit, logout, map change. @@ -1215,7 +1215,7 @@ void __fastcall destroyPrimaryGameObject(undefined **param_1) { } ``` -#### processCinematicExit (0x6e4940) — Spell Entity Cleanup +#### processCinematicExit (0x6e4940) - Spell Entity Cleanup ```c // After handling cinematic/targeting state... if (PTR_00ceca8c != NULL) { @@ -1224,7 +1224,7 @@ if (PTR_00ceca8c != NULL) { } ``` -#### CreateEntityInstance_WithAttachment (0x6707c0) — M2 Path +#### CreateEntityInstance_WithAttachment (0x6707c0) - M2 Path ```c int * __fastcall CreateEntityInstance_WithAttachment( char *modelPath, float *pos, float facing, int flags, int updateNow, int p6, int p7) { @@ -1242,18 +1242,18 @@ int * __fastcall CreateEntityInstance_WithAttachment( } ``` -#### hashTableTeardownLoop (0x69f740) — The Crash Site +#### hashTableTeardownLoop (0x69f740) - The Crash Site ```c void hashTableTeardownLoop(void) { if ((DAT_00ca7cf0 & 1) == 0) { DAT_00ca7cf0 |= 1; _DAT_00ca7dc0 = &PTR_DestroyMapDoodadDefinition_0081089c; - // Walk global list — crashes here if entries point to freed heap + // Walk global list - crashes here if entries point to freed heap while (PTR_00ca7dcc is valid) { piVar2 = ValidateLinkedList(&PTR_00ca7dc4, PTR_00ca7dcc); CalculateDistance3D(piVar2); // reads from freed entity memory } - // Walk each hash bucket — also crashes + // Walk each hash bucket - also crashes for each bucket in PTR_00ca7ddc { while (bucket entry is valid) { piVar2 = ValidateLinkedList(bucket, entry); @@ -1288,32 +1288,32 @@ void ClearWorldObjectsAndResetState(void) { **Every native M2 entity created via `CreateEntityInstance_WithAttachment` is explicitly cleaned up by a parent.** The atexit handler (hashTableTeardownLoop) is a safety net for the hash table data -structure — it should NEVER encounter live entities in normal operation. +structure - it should NEVER encounter live entities in normal operation. #### All 3 callers of CreateEntityInstance_WithAttachment: -1. **CastSpellByID_Extended (0x6e4b60)** — spell targeting reticle +1. **CastSpellByID_Extended (0x6e4b60)** - spell targeting reticle - Stores entity in global `PTR_00ceca8c` - Cleaned up by `processCinematicExit` before every new spell cast - Also cleaned up by `executeSpellOrItem` (0x6e54f0) -2. **CreateGameObjectPathEffect (0x5f8030)** — game object visual effect +2. **CreateGameObjectPathEffect (0x5f8030)** - game object visual effect - Called by `CreatePathObjectByUnitType` (0x5f4970) - Return value saved at parent+0x10 (despite Ghidra typing it as void) - Cleaned up by `DestroyPathObjectIfPresent` (0x5f4950) → `CleanupEntity_ProcessAttachments` - Parent is a spell effect object (SpellEffectWithPath class at ~0x5f4800) - `SpellEffectWithPathDestructor` (0x5f48d0) destroys parent during spell teardown -3. **Unknown caller (0x6e5a6e)** — likely in `executeSpellOrItem` (0x6e54f0) +3. **Unknown caller (0x6e5a6e)** - likely in `executeSpellOrItem` (0x6e54f0) - Same pattern as #1 #### All 3 callers of CleanupEntity_ProcessAttachments (0x670d50): -- `processCinematicExit` (0x6e4940) — spell cleanup -- `executeSpellOrItem` (0x6e54f0) — spell/item cleanup -- `DestroyPathObjectIfPresent` (0x5f4950) — path effect cleanup +- `processCinematicExit` (0x6e4940) - spell cleanup +- `executeSpellOrItem` (0x6e54f0) - spell/item cleanup +- `DestroyPathObjectIfPresent` (0x5f4950) - path effect cleanup #### CleanupVisualEffectAndRelease has only ONE caller: -- `cleanupGameObject` (0x6a67a0) — the M2/WDOODADDEF cleanup function +- `cleanupGameObject` (0x6a67a0) - the M2/WDOODADDEF cleanup function So the full cleanup chain is always: ``` @@ -1332,7 +1332,7 @@ Parent destroyed |------|--------|----------|-----------------|------| | PTR_00c96088 | 0xC | Terrain chunk wrappers → doodad parents | destroyPrimaryGameObject | various | | PTR_00c9e358 | via PTR_00c9e350 | WMO entity wrappers (from CreateEntityInstance_WithAttachment WMO path) | destroyWorldEnvironment | WMAPOBJDEF | -| PTR_00c962bc | 0xC | **Empty in 1.12.1** — no insertion code found, only init+atexit | cleanupGameObject (conditional) | WDOODADDEF | +| PTR_00c962bc | 0xC | **Empty in 1.12.1** - no insertion code found, only init+atexit | cleanupGameObject (conditional) | WDOODADDEF | | PTR_00ca8044 | via PTR_00ca803c | Map manager objects | CleanupAndReleaseMemoryBlock | PTR_00ca7e10 (4th heap) | | PTR_00c7b2dc | 0xC | WENTITY fade effect wrappers | CleanupObjectAttachments_FreeMemory → DestroyWorldObjectAndRelease | WENTITY | diff --git a/src/markers/addon/Markers.lua b/src/markers/addon/Markers.lua index edb3b5e..a396680 100644 --- a/src/markers/addon/Markers.lua +++ b/src/markers/addon/Markers.lua @@ -18,12 +18,12 @@ end -- Delimiter is ":" (pipe "|" is WoW's escape char for color codes) -- -- Messages: --- P:idx:x:y:z:area — live placement (everyone processes) --- C:idx — clear one marker --- CA — clear all markers --- SR — sync request (non-leader asking leader to send defs) --- LSR — leader sync request (leader asking anyone to send defs) --- SF:idx:x:y:z:area — sync fill (only processed by requester in sync mode) +-- P:idx:x:y:z:area - live placement (everyone processes) +-- C:idx - clear one marker +-- CA - clear all markers +-- SR - sync request (non-leader asking leader to send defs) +-- LSR - leader sync request (leader asking anyone to send defs) +-- SF:idx:x:y:z:area - sync fill (only processed by requester in sync mode) -- -- Permission model: ALL permission checks are enforced DLL-side. -- WorldMarker/ClearWorldMarker: DLL checks local player is leader/assist. @@ -265,7 +265,7 @@ local function onAddonMessage(prefix, message, channel, sender) return end - -- P, C, CA — DLL checks sender permission via SetMarkerDef/ClearMarkerDef + -- P, C, CA - DLL checks sender permission via SetMarkerDef/ClearMarkerDef if cmd == "P" then local idx, x, y, z, areaId = parseMarkerFields(parts) if idx then @@ -315,7 +315,7 @@ local function scheduleSyncRequest() end -- ============================================================================= --- Roster change tracking — retriggerable debounce timer +-- Roster change tracking - retriggerable debounce timer -- Each roster event that increases group size adds 1s to the timer (starts at -- 5s, caps at 10s). When the timer expires, one broadcast fires. This -- guarantees delivery after the storm of events settles. diff --git a/src/markers/markers.zig b/src/markers/markers.zig index ccea640..16825d1 100644 --- a/src/markers/markers.zig +++ b/src/markers/markers.zig @@ -6,17 +6,17 @@ //! Entities are respawned automatically when the player approaches within 200y. //! //! Lua API (globals): -//! WorldMarker(index, x, y, z) — place marker at coordinates -//! WorldMarker(index, "unit") — place marker at unit's position -//! WorldMarker(index) — place marker at cursor terrain position -//! ClearWorldMarker(index) — remove specific marker (1-5) -//! ClearWorldMarker() — remove all markers -//! CanSetWorldMarkers() — returns 1 if leader/assist, nil otherwise +//! WorldMarker(index, x, y, z) - place marker at coordinates +//! WorldMarker(index, "unit") - place marker at unit's position +//! WorldMarker(index) - place marker at cursor terrain position +//! ClearWorldMarker(index) - remove specific marker (1-5) +//! ClearWorldMarker() - remove all markers +//! CanSetWorldMarkers() - returns 1 if leader/assist, nil otherwise //! -//! Lua API (WorldMarkers table — internal, used by addon): +//! Lua API (WorldMarkers table - internal, used by addon): //! WorldMarkers.SetMarkerDef(i, x, y, z, area, sender) //! WorldMarkers.ClearMarkerDef([index,] sender) -//! WorldMarkers.GetMarkerDef(index) — returns x, y, z, areaId or nil +//! WorldMarkers.GetMarkerDef(index) - returns x, y, z, areaId or nil const std = @import("std"); const hook = @import("zhook"); @@ -33,6 +33,9 @@ extern "kernel32" fn GetLastError() callconv(WINAPI) u32; extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32; extern "kernel32" fn GetTickCount() callconv(WINAPI) u32; const ERROR_ALREADY_EXISTS: u32 = 183; +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "worldmarkers"; var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; @@ -77,7 +80,7 @@ pub const Vec3 = struct { // State // ============================================================================= -/// Persistent marker definition — survives zone transitions. +/// Persistent marker definition - survives zone transitions. const MarkerDef = struct { pos: Vec3, area_id: u32, @@ -88,7 +91,7 @@ const EMPTY_DEF: MarkerDef = .{ .pos = .{ .x = 0, .y = 0, .z = 0 }, .area_id = 0 /// Marker definitions persist across zone transitions (NOT cleared in worldCleanupDetour). var marker_defs: [NUM_MARKERS]MarkerDef = .{EMPTY_DEF} ** NUM_MARKERS; -/// Transient entity state — cleared on zone transition / teardown. +/// Transient entity state - cleared on zone transition / teardown. var marker_entities: [NUM_MARKERS]?*anyopaque = .{null} ** NUM_MARKERS; var marker_created_tick: [NUM_MARKERS]u32 = .{0} ** NUM_MARKERS; var hold_queued: [NUM_MARKERS]bool = .{false} ** NUM_MARKERS; @@ -115,7 +118,7 @@ const fc = std.builtin.CallingConvention{ .x86_fastcall = .{} }; const sc = std.builtin.CallingConvention{ .x86_stdcall = .{} }; // ============================================================================= -// Permission check — leader or raid officer required +// Permission check - leader or raid officer required // ============================================================================= /// Get local player GUID via GetPlayerGUID (0x468550). @@ -132,7 +135,7 @@ fn getPlayerGUID() u64 { } /// Look up a player name from the name cache by GUID. -/// Calls RetrieveNPCDataFromCache — __thiscall(ECX=cache), 6 stack params, RET 0x18. +/// Calls RetrieveNPCDataFromCache - __thiscall(ECX=cache), 6 stack params, RET 0x18. fn getNameFromGUID(guid_lo: u32, guid_hi: u32) ?[*:0]const u8 { if (guid_lo == 0 and guid_hi == 0) return null; var name_buf: [2]u32 = .{ 0, 0 }; @@ -140,7 +143,9 @@ fn getNameFromGUID(guid_lo: u32, guid_hi: u32) ?[*:0]const u8 { guid_lo, guid_hi, @intFromPtr(&name_buf), - 0, 0, 0, + 0, + 0, + 0, }; const result: u32 = asm volatile ( \\ push 20(%[a]) @@ -159,7 +164,7 @@ fn getNameFromGUID(guid_lo: u32, guid_hi: u32) ?[*:0]const u8 { } /// Check if the local player has permission to place/clear markers. -/// Uses direct memory reads — no Lua state required. +/// Uses direct memory reads - no Lua state required. /// Requires: party leader, raid leader, or raid officer (assist). fn canSetMarkers() bool { const player_guid = getPlayerGUID(); @@ -191,7 +196,7 @@ fn canSetMarkers() bool { } /// Check if a named sender has permission (leader or raid officer). -/// Used to authenticate incoming addon messages — sender name comes from +/// Used to authenticate incoming addon messages - sender name comes from /// CHAT_MSG_ADDON arg4 (server-verified, can't be spoofed). fn senderHasPermission(sender: [*:0]const u8) bool { const sender_span = std.mem.span(sender); @@ -224,7 +229,6 @@ fn senderHasPermission(sender: [*:0]const u8) bool { return std.mem.eql(u8, std.mem.span(leader_name), sender_span); } - // ============================================================================= // Position helpers // ============================================================================= @@ -261,14 +265,14 @@ fn getCursorTerrainPosition() ?Vec3 { if (world_frame == 0 or world_frame < 0x10000) return null; // Zero the intersection point before raycasting so we can detect "no hit" - // (HitTestPoint returns 0 for both "terrain hit" and "no hit" in normal mode — + // (HitTestPoint returns 0 for both "terrain hit" and "no hit" in normal mode - // WorldIntersectionTest returns gameStateFlags & 1, which is 0 outside AoE targeting. // On a real hit the coords are overwritten; on sky/no-hit they stay zeroed.) @as(*align(1) u32, @ptrFromInt(world_frame + o.WF_HIT_TERRAIN_X)).* = 0; @as(*align(1) u32, @ptrFromInt(world_frame + o.WF_HIT_TERRAIN_Y)).* = 0; @as(*align(1) u32, @ptrFromInt(world_frame + o.WF_HIT_TERRAIN_Z)).* = 0; - // UpdateHitTest — __fastcall(ECX=worldFrame) + // UpdateHitTest - __fastcall(ECX=worldFrame) hook.fastcall(void, o.FN_UPDATE_HIT_TEST, world_frame, 0); const x = hook.readMem(f32, world_frame + o.WF_HIT_TERRAIN_X); @@ -284,7 +288,7 @@ fn getCursorTerrainPosition() ?Vec3 { // Game function wrappers // ============================================================================= -/// CreateEntityInstance_WithAttachment — __fastcall, RET 0x14. +/// CreateEntityInstance_WithAttachment - __fastcall, RET 0x14. fn createEntityInstance(path: [*:0]const u8, pos: *[3]f32, facing: f32, flags: u32, update_now: u32) ?*anyopaque { const facing_bits: u32 = @bitCast(facing); const stack_args = [5]u32{ @@ -312,7 +316,7 @@ fn createEntityInstance(path: [*:0]const u8, pos: *[3]f32, facing: f32, flags: u return if (result != 0) @ptrFromInt(result) else null; } -/// CleanupEntity_ProcessAttachments — __fastcall(ECX=entity), no stack params. +/// CleanupEntity_ProcessAttachments - __fastcall(ECX=entity), no stack params. fn cleanupEntity(obj: *anyopaque) void { asm volatile ("call *%[func]" : @@ -326,7 +330,7 @@ fn cleanupEntity(obj: *anyopaque) void { // ============================================================================= /// Play an animation on an entity's M2 model render context (entity+0x88). -/// CM2Model__PlayBoneAnimation — __thiscall(ECX=model), RET 0x1c. +/// CM2Model__PlayBoneAnimation - __thiscall(ECX=model), RET 0x1c. fn playAnimation(entity: *anyopaque, anim_id: u32, queue: bool) void { const entity_addr = @intFromPtr(entity); const model = hook.readMem(u32, entity_addr + 0x88); @@ -393,7 +397,7 @@ fn beginDespawn(entity: *anyopaque) void { return; } } - // All slots full — force-cleanup the oldest and reuse slot 0 + // All slots full - force-cleanup the oldest and reuse slot 0 if (despawning[0]) |old| { cleanupEntity(old.entity); } @@ -410,7 +414,7 @@ fn beginDespawn(entity: *anyopaque) void { fn placeMarker(index: usize, pos: Vec3) bool { if (index >= NUM_MARKERS) return false; - // Clear existing entity in this slot (but not the def — we're about to overwrite it) + // Clear existing entity in this slot (but not the def - we're about to overwrite it) clearEntity(index); // Store persistent definition @@ -486,7 +490,7 @@ fn clearAllMarkers() void { pub fn luaWorldMarker(L: lua.State) callconv(.c) u32 { if (!canSetMarkers()) { con.print("[worldmarkers] WorldMarker: no permission\n"); - return 0; // nil — addon shows user message + return 0; // nil - addon shows user message } const nargs = lua.gettop(L); @@ -542,7 +546,7 @@ pub fn luaClearWorldMarker(L: lua.State) callconv(.c) u32 { const lua_type: *const fn (lua.State, i32) callconv(fc) i32 = @ptrFromInt(0x6F3400); if (nargs == 0 or lua_type(L, 1) == 0) { - // No args or nil — clear all + // No args or nil - clear all clearAllMarkers(); lua.pushnumber(L, 1.0); return 1; @@ -590,7 +594,7 @@ fn tickAnimations() void { hold_queued[i] = true; } - // Zombie detection — entity exists but refcount dropped to 1 (culled by game). + // Zombie detection - entity exists but refcount dropped to 1 (culled by game). // Check every 3 seconds to avoid per-frame overhead. if (now -% last_zombie_tick >= ZOMBIE_CHECK_INTERVAL_MS) { last_zombie_tick = now; @@ -607,7 +611,7 @@ fn tickAnimations() void { } } - // Proximity respawn — check every ~1 second + // Proximity respawn - check every ~1 second if (now -% last_respawn_tick >= RESPAWN_CHECK_INTERVAL_MS) { last_respawn_tick = now; @@ -638,7 +642,7 @@ fn tickAnimations() void { /// Lua: SetMarkerDef(index, x, y, z, areaId, senderName) /// Store a marker definition without immediately spawning. Used by the addon -/// when receiving remote marker data — proximity respawn handles entity creation. +/// when receiving remote marker data - proximity respawn handles entity creation. /// senderName is verified against the group roster for leader/officer permission. pub fn luaSetMarkerDef(L: lua.State) callconv(.c) u32 { const nargs = lua.gettop(L); @@ -673,15 +677,15 @@ pub fn luaSetMarkerDef(L: lua.State) callconv(.c) u32 { return 0; } -/// Lua: ClearMarkerDef(senderName) — clear all -/// Lua: ClearMarkerDef(index, senderName) — clear one +/// Lua: ClearMarkerDef(senderName) - clear all +/// Lua: ClearMarkerDef(index, senderName) - clear one /// senderName is verified against the group roster for leader/officer permission. pub fn luaClearMarkerDef(L: lua.State) callconv(.c) u32 { const nargs = lua.gettop(L); if (nargs < 1) return 0; if (lua.isstring(L, 1) and (nargs == 1 or !lua.isnumber(L, 1))) { - // ClearMarkerDef(senderName) — clear all + // ClearMarkerDef(senderName) - clear all const sender = lua.tostring(L, 1) orelse return 0; if (!senderHasPermission(sender)) { con.fmt("[worldmarkers] ClearMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)}); @@ -692,7 +696,7 @@ pub fn luaClearMarkerDef(L: lua.State) callconv(.c) u32 { } if (nargs >= 2 and lua.isnumber(L, 1) and lua.isstring(L, 2)) { - // ClearMarkerDef(index, senderName) — clear one + // ClearMarkerDef(index, senderName) - clear one const sender = lua.tostring(L, 2) orelse return 0; if (!senderHasPermission(sender)) { con.fmt("[worldmarkers] ClearMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)}); @@ -737,8 +741,6 @@ pub fn luaCanSetMarkers(L: lua.State) callconv(.c) u32 { return 0; } - - // ============================================================================= // World teardown hook // ============================================================================= @@ -746,7 +748,7 @@ pub fn luaCanSetMarkers(L: lua.State) callconv(.c) u32 { var world_cleanup_hook: hook.Detour(fn () callconv(sc) void) = .{}; var world_update_hook: hook.Detour(fn (u32) callconv(fc) void) = .{}; -/// OnWorldUpdate hook — per-frame tick while world is active. +/// OnWorldUpdate hook - per-frame tick while world is active. /// Drives animation state (Hold queue after Stand, despawn cleanup). fn worldUpdateDetour(frame: u32) callconv(fc) void { tickAnimations(); @@ -755,7 +757,7 @@ fn worldUpdateDetour(frame: u32) callconv(fc) void { /// Pre-hook on CleanupWorldAndEntities (0x66fc40). /// Destroys all our entities via CleanupEntity_ProcessAttachments before the -/// game's teardown runs — the same pattern every native caller uses (e.g. +/// game's teardown runs - the same pattern every native caller uses (e.g. /// processCinematicExit, DestroyPathObjectIfPresent). This unlinks them from /// the WDOODADDEF hash table so the atexit handler never touches freed memory. fn worldCleanupDetour() callconv(sc) void { @@ -765,8 +767,8 @@ fn worldCleanupDetour() callconv(sc) void { } /// Destroy all tracked entities (active markers + despawning). -/// Does NOT clear marker_defs — definitions persist for respawn. -/// Idempotent — safe to call multiple times. +/// Does NOT clear marker_defs - definitions persist for respawn. +/// Idempotent - safe to call multiple times. fn destroyAllEntities() void { var count: u32 = 0; for (&marker_entities, 0..) |*slot, i| { @@ -803,21 +805,10 @@ fn destroyAllEntities() void { pub fn installHooks() void { con.print("[worldmarkers] Module loaded\n"); - var mutex_name_buf: [64]u8 = undefined; - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_WorldMarkersHook_{d}", .{GetCurrentProcessId()}) catch return; - mutex_name_buf[mutex_name.len] = 0; - - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return; - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; - con.print("[worldmarkers] Another DLL owns world markers (mutex taken), skipping\n"); - return; - } - g_is_hook_owner = true; + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; // Hook OnWorldUpdate for per-frame animation tick (runs every frame while world is active). if (world_update_hook.attach(o.FN_ON_WORLD_UPDATE, &worldUpdateDetour) != .ok) { @@ -827,7 +818,7 @@ pub fn installHooks() void { } // Hook CleanupWorldAndEntities to destroy our entities before world teardown. - // This fires on map change, logout, AND exit — before heaps are destroyed. + // This fires on map change, logout, AND exit - before heaps are destroyed. if (world_cleanup_hook.attach(o.FN_CLEANUP_WORLD_AND_ENTITIES, &worldCleanupDetour) != .ok) { con.print("[worldmarkers] FAILED to hook CleanupWorldAndEntities!\n"); } else { @@ -835,7 +826,7 @@ pub fn installHooks() void { } } -/// Called from CGGameUI_Shutdown (logout/exit) — wipes marker definitions. +/// Called from CGGameUI_Shutdown (logout/exit) - wipes marker definitions. /// Does NOT touch hooks or mutex. Entities are destroyed separately by /// worldCleanupDetour which fires after shutdown. pub fn onShutdown() void { @@ -845,19 +836,12 @@ pub fn onShutdown() void { pub fn removeHooks() void { if (g_is_hook_owner) { - // destroyAllEntities is idempotent — if worldCleanupDetour already ran, + // destroyAllEntities is idempotent - if worldCleanupDetour already ran, // all slots are null and this is a no-op. destroyAllEntities(); world_update_hook.detach(); world_cleanup_hook.detach(); - } - - if (g_is_hook_owner) { - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; - } + mod_mutex.release(&g_mutex); } g_is_hook_owner = false; } diff --git a/src/markers/offsets.zig b/src/markers/offsets.zig index 51fe0b6..b8b4d8e 100644 --- a/src/markers/offsets.zig +++ b/src/markers/offsets.zig @@ -20,7 +20,7 @@ pub const MOVEMENT_POS_Z: usize = 0x18; // Entity creation (high-level API) // ============================================================================= -/// CreateEntityInstance_WithAttachment — __fastcall, RET 0x14 (5 stack params). +/// CreateEntityInstance_WithAttachment - __fastcall, RET 0x14 (5 stack params). /// ECX = modelPath (char*), EDX = position (float[3]*) /// Stack: facing (float), flags (int), updateNow (int), param6 (int), param7 (int) /// Returns: entity pointer (int*). @@ -37,7 +37,7 @@ pub const FN_CREATE_ENTITY_INSTANCE: usize = 0x006707c0; // World teardown (map unload / logout / exit) // ============================================================================= -/// CleanupWorldAndEntities — void(), no params, __stdcall. +/// CleanupWorldAndEntities - void(), no params, __stdcall. /// Top-level world teardown: calls CleanupEntityList_ProcessAll, then /// CleanupWorldAndReleaseResources (which iterates heaps and force-frees). /// Called from InitializeWorldScene (map change) and ShutdownClientSystems (exit). @@ -52,21 +52,21 @@ pub const FN_CLEANUP_WORLD_AND_ENTITIES: usize = 0x0066fc40; /// __fastcall returns void** pub const FN_ALLOCATE_WORLD_OBJECT: usize = 0x006a0930; -/// DestroyWorldObjectAndRelease(object) — __fastcall, ECX=obj, no stack params. +/// DestroyWorldObjectAndRelease(object) - __fastcall, ECX=obj, no stack params. /// Unlinks from world object list (+0x10/+0x14), calls virtual destructor, frees heap. /// ONLY for objects on WENTITY heap (from AllocateAndInitializeWorldObject). pub const FN_DESTROY_WORLD_OBJECT: usize = 0x006a0a70; -/// CleanupEntity_ProcessAttachments(entity) — __fastcall, ECX=entity, no stack params. +/// CleanupEntity_ProcessAttachments(entity) - __fastcall, ECX=entity, no stack params. /// High-level destructor counterpart to CreateEntityInstance_WithAttachment. /// Walks and frees attachment children, decrements refcount at +0x0E, then /// dispatches to type-specific destructor based on flags at +0x8: -/// flag 0x8 (M2): destroyWorldEnvironment (0x6a6870) — scene graph removal + free -/// flag 0x40 (WMO): cleanupGameObject (0x6a67a0) — render detach + spatial unlink + free +/// flag 0x8 (M2): destroyWorldEnvironment (0x6a6870) - scene graph removal + free +/// flag 0x40 (WMO): cleanupGameObject (0x6a67a0) - render detach + spatial unlink + free /// Only actually frees when refcount reaches 0. pub const FN_CLEANUP_ENTITY: usize = 0x00670d50; -/// DecrementReferenceCount(obj) — __fastcall, ECX=obj, no stack params. +/// DecrementReferenceCount(obj) - __fastcall, ECX=obj, no stack params. /// Decrements ref count; when it reaches 0, calls virtual destructor to free. pub const FN_DECREMENT_REFCOUNT: usize = 0x007103a0; @@ -74,10 +74,10 @@ pub const FN_DECREMENT_REFCOUNT: usize = 0x007103a0; // Cursor terrain position // ============================================================================= -/// WorldFrame global pointer — *(u32*)PTR = worldFrame object. +/// WorldFrame global pointer - *(u32*)PTR = worldFrame object. pub const PTR_WORLD_FRAME: usize = 0x00B4B2BC; -/// UpdateHitTest — __fastcall(ECX=worldFrame), no stack params. +/// UpdateHitTest - __fastcall(ECX=worldFrame), no stack params. /// Raycasts from camera through mouse cursor, stores result at worldFrame+0x350: /// +0x350: hit type (0=none, 1=terrain, 2=object) /// +0x360: terrain intersection X (f32) @@ -95,7 +95,7 @@ pub const WF_HIT_TERRAIN_Z: usize = 0x368; // Zone / area identification // ============================================================================= -/// Current zone area ID — numeric, locale-safe zone identifier. +/// Current zone area ID - numeric, locale-safe zone identifier. /// Updated by the game as the player moves between areas. pub const ZONE_AREA_ID: usize = 0x00B4E314; @@ -103,7 +103,7 @@ pub const ZONE_AREA_ID: usize = 0x00B4E314; // Per-frame world update // ============================================================================= -/// OnWorldUpdate — __fastcall(ECX=worldFrame), no stack params, void return. +/// OnWorldUpdate - __fastcall(ECX=worldFrame), no stack params, void return. /// Called every frame while the world is active (in-game, not login screen). /// Part of CGWorldFrame update pipeline. pub const FN_ON_WORLD_UPDATE: usize = 0x00482EA0; @@ -123,7 +123,7 @@ pub const FN_CM2_CREATE_FOR_MODEL_OBJECT: usize = 0x00695100; // Animation // ============================================================================= -/// CM2Model__PlayBoneAnimation — __thiscall(ECX=modelRenderCtx), RET 0x1c (7 stack params). +/// CM2Model__PlayBoneAnimation - __thiscall(ECX=modelRenderCtx), RET 0x1c (7 stack params). /// (boneIndex, animId, sequenceIndex, animData*, speed, blendMode, queueAnimation) /// boneIndex: 0xFFFFFFFF = all bones /// animId: M2 animation ID (0=Stand, 158=Hold, 159=Decay for Raid_UI_FX) @@ -138,75 +138,75 @@ pub const FN_PLAY_BONE_ANIMATION: usize = 0x007121a0; // Transform and position // ============================================================================= -/// UpdateObjectTransform_CalculateBounds — __fastcall, RET 0x0C +/// UpdateObjectTransform_CalculateBounds - __fastcall, RET 0x0C /// ECX = world object, EDX = 4x4 transform matrix (float[16]) /// Stack: bounds (float[6] min/max), halfExtents (float[3]), forceUpdate (int) pub const FN_UPDATE_OBJECT_TRANSFORM: usize = 0x006717d0; // ============================================================================= -// File I/O (Storm) — for in-memory file serving +// File I/O (Storm) - for in-memory file serving // ============================================================================= -/// openFileWithOptions — __stdcall(4), RET 0x10, prologue=9 +/// openFileWithOptions - __stdcall(4), RET 0x10, prologue=9 /// (archive_ptr, path, flags, handle_out) → type_code (0=fail, 1-4=success) pub const FN_OPEN_FILE_WITH_OPTIONS: usize = 0x006477c0; -/// GetFileSizeFromHandle — __stdcall(2), RET 0x08, prologue=6 +/// GetFileSizeFromHandle - __stdcall(2), RET 0x08, prologue=6 /// (file_context, high_size_out) → size pub const FN_GET_FILE_SIZE: usize = 0x006487f0; -/// ReadFileFromMultipleSources — __stdcall(6), RET 0x18, prologue=6 +/// ReadFileFromMultipleSources - __stdcall(6), RET 0x18, prologue=6 /// (context, buffer, size, bytes_read_out, async_ptr, param6) → bool /// async_ptr==NULL: synchronous read. Non-NULL: queues async operation. pub const FN_READ_FILE: usize = 0x00648460; -/// CleanupFileHandleResources — __stdcall(1), RET 0x04, prologue=7 +/// CleanupFileHandleResources - __stdcall(1), RET 0x04, prologue=7 /// (file_context) → 1 pub const FN_CLEANUP_FILE_HANDLE: usize = 0x00648730; -/// processAsyncFileOperation — __fastcall(ECX=request), plain RET, prologue=7 +/// processAsyncFileOperation - __fastcall(ECX=request), plain RET, prologue=7 /// Request structure: +0x08=file_ctx, +0x0C=dest_buf, +0x10=read_size, /// +0x14=seek/event_struct (*(+0x14)+4 = event handle) pub const FN_PROCESS_ASYNC_FILE_OP: usize = 0x00647350; -/// initializeFileContext — __thiscall(ECX=ctx, type) +/// initializeFileContext - __thiscall(ECX=ctx, type) /// Sets context type, initializes critical section at +0x24, zeroes fields. pub const FN_INIT_FILE_CONTEXT: usize = 0x00647290; -/// cleanupFileContext — __thiscall(ECX=ctx) +/// cleanupFileContext - __thiscall(ECX=ctx) /// Destroys critical section, cleanup companion to initializeFileContext. pub const FN_CLEANUP_FILE_CONTEXT: usize = 0x006472d0; -/// FreeMemory (SMemFree) — __stdcall(3): (ptr, src_str, flags) +/// FreeMemory (SMemFree) - __stdcall(3): (ptr, src_str, flags) pub const FN_FREE_MEMORY: usize = 0x00646430; // ============================================================================= // M2 model loading (async pipeline) // ============================================================================= -/// loadModelFromFileAsync — __thiscall(ECX=model_obj), 2 stack params, RET 0x08 +/// loadModelFromFileAsync - __thiscall(ECX=model_obj), 2 stack params, RET 0x08 /// (fileHandle: **ctx, shouldUseCallback: int) → 1 -/// Prologue: 55 8b ec 8b 55 0c 56 8b f1 — safe sizes: [6, 7, 9] +/// Prologue: 55 8b ec 8b 55 0c 56 8b f1 - safe sizes: [6, 7, 9] /// Allocates async task to read file and call onModelLoadComplete when done. /// The async executor at 0x71d610 calls fileReadWithLock directly, bypassing -/// our ReadFileFromMultipleSources hook — hence this hook fills the buffer +/// our ReadFileFromMultipleSources hook - hence this hook fills the buffer /// synchronously for fake file contexts. pub const FN_LOAD_MODEL_ASYNC: usize = 0x0071d4e0; -/// processLoadedModelData — __fastcall(ECX=model), no stack params, plain RET +/// processLoadedModelData - __fastcall(ECX=model), no stack params, plain RET /// Parses model header from buffer at model+0x130 (ptr) / model+0x134 (size), /// initializes model resources, sets bit 0 of model+8 when done. pub const FN_PROCESS_LOADED_MODEL_DATA: usize = 0x0071d640; // ============================================================================= -// Permission check — leader / raid officer +// Permission check - leader / raid officer // ============================================================================= /// Group leader GUID (64-bit): low u32 at +0, high u32 at +4. /// Valid for both party leader and raid leader. pub const LEADER_GUID: usize = 0x00bc75f8; -/// Raid roster — array of 40 pointers to roster entry structs. +/// Raid roster - array of 40 pointers to roster entry structs. /// Entry layout: +0x00/+0x04 = GUID (u64), +0x08 = subgroup, +0x0C = rank. /// Rank: 0 = member, 1 = assistant, 2 = leader. pub const RAID_ROSTER_ARRAY: usize = 0x00b712a8; @@ -217,18 +217,18 @@ pub const RAID_MEMBER_COUNT: usize = 0x00b713e0; /// Offset within a roster entry to the rank field (i32). pub const ROSTER_ENTRY_RANK: usize = 0x0C; -/// Party member GUIDs — 4 slots, 8 bytes each (lo/hi u32 pairs). +/// Party member GUIDs - 4 slots, 8 bytes each (lo/hi u32 pairs). /// Contains other party members (not local player). Stride = 8. pub const PARTY_MEMBER_GUIDS: usize = 0x00bc6f48; -/// GetPlayerGUID — __fastcall(), no params, returns EAX(low):EDX(high). +/// GetPlayerGUID - __fastcall(), no params, returns EAX(low):EDX(high). pub const FN_GET_PLAYER_GUID: usize = 0x00468550; -/// RetrieveNPCDataFromCache — __thiscall(ECX=cache_obj), 6 stack params, RET 0x18. +/// RetrieveNPCDataFromCache - __thiscall(ECX=cache_obj), 6 stack params, RET 0x18. /// (guid_low, guid_high, name_buf_ptr, 0, 0, 0) → char* name or NULL. /// Used by GetRaidRosterInfo to resolve player names from GUIDs. pub const FN_NAME_CACHE_LOOKUP: usize = 0x0055f080; -/// Name cache object — static instance at this address. Passed as ECX (this) +/// Name cache object - static instance at this address. Passed as ECX (this) /// to RetrieveNPCDataFromCache. pub const NAME_CACHE_OBJ: usize = 0x00c0e228; diff --git a/src/minimapicons/minimapicons.zig b/src/minimapicons/minimapicons.zig index 5a461ff..b774e20 100644 --- a/src/minimapicons/minimapicons.zig +++ b/src/minimapicons/minimapicons.zig @@ -8,48 +8,30 @@ //! //! TODO: Hook minimap icon rendering, classify NPCs by type, overlay custom icons. -const std = @import("std"); const con = @import("../console.zig"); +const mod_mutex = @import("../mutex.zig"); -const WINAPI = std.builtin.CallingConvention.winapi; -extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?*anyopaque; -extern "kernel32" fn ReleaseMutex(hMutex: *anyopaque) callconv(WINAPI) i32; -extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32; -extern "kernel32" fn GetLastError() callconv(WINAPI) u32; -extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32; -const ERROR_ALREADY_EXISTS: u32 = 183; +pub const module_name: [*:0]const u8 = "minimapicons"; var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; +pub fn isActive() bool { + return g_is_hook_owner; +} + pub fn installHooks() void { con.print("[minimapicons] Module loaded (stub)\n"); - // Multi-DLL safety: only one instance per process should hook - var mutex_name_buf: [64]u8 = undefined; - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_MinimapiconsHook_{d}", .{GetCurrentProcessId()}) catch return; - mutex_name_buf[mutex_name.len] = 0; - - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return; - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; - con.print("[minimapicons] Another DLL owns hooks (mutex taken), skipping\n"); - return; - } - g_is_hook_owner = true; + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; } pub fn removeHooks() void { if (g_is_hook_owner) { - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; - } + mod_mutex.release(&g_mutex); } g_is_hook_owner = false; } diff --git a/src/mutex.zig b/src/mutex.zig new file mode 100644 index 0000000..d4063e0 --- /dev/null +++ b/src/mutex.zig @@ -0,0 +1,57 @@ +const std = @import("std"); +const con = @import("console.zig"); + +const WINAPI = std.builtin.CallingConvention.winapi; +extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: ?[*:0]const u8) callconv(WINAPI) ?*anyopaque; +extern "kernel32" fn ReleaseMutex(hMutex: *anyopaque) callconv(WINAPI) i32; +extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32; +extern "kernel32" fn GetLastError() callconv(WINAPI) u32; +extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32; + +const ERROR_ALREADY_EXISTS: u32 = 183; + +pub const Result = struct { + handle: ?*anyopaque, + is_owner: bool, +}; + +/// Try to acquire the per-process module mutex. +/// Format: `Local\WeirdUtils__` +pub fn acquire(module_name: [*:0]const u8) Result { + var buf: [80]u8 = undefined; + const name_span = std.mem.span(module_name); + const formatted = std.fmt.bufPrint(&buf, "Local\\WeirdUtils_{s}_{d}", .{ name_span, GetCurrentProcessId() }) catch return .{ .handle = null, .is_owner = false }; + return doAcquire(&buf, formatted.len, name_span); +} + +/// Acquire with a legacy mutex name format: `Local\_` +/// Used by transmogfix which shipped before the naming convention was established. +pub fn acquireLegacy(legacy_prefix: [*:0]const u8, module_name: [*:0]const u8) Result { + var buf: [80]u8 = undefined; + const formatted = std.fmt.bufPrint(&buf, "Local\\{s}_{d}", .{ std.mem.span(legacy_prefix), GetCurrentProcessId() }) catch return .{ .handle = null, .is_owner = false }; + return doAcquire(&buf, formatted.len, std.mem.span(module_name)); +} + +fn doAcquire(buf: *[80]u8, len: usize, log_name: []const u8) Result { + buf[len] = 0; + + const mutex = CreateMutexA(null, 1, @ptrCast(buf[0..len :0])); + if (mutex == null) return .{ .handle = null, .is_owner = false }; + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + _ = CloseHandle(mutex.?); + con.fmt("[{s}] Another DLL owns hooks (mutex taken), skipping\n", .{log_name}); + return .{ .handle = null, .is_owner = false }; + } + + return .{ .handle = mutex, .is_owner = true }; +} + +/// Release and close a module mutex. +pub fn release(mutex: *?*anyopaque) void { + if (mutex.*) |m| { + _ = ReleaseMutex(m); + _ = CloseHandle(m); + mutex.* = null; + } +} diff --git a/src/outline/README.md b/src/outline/README.md index 15d8bd0..0678347 100644 --- a/src/outline/README.md +++ b/src/outline/README.md @@ -8,9 +8,9 @@ that runs entirely in EndScene, composited on top of the final backbuffer. The outline system has three main phases per frame: -1. **Object scan** (EndScene start) — identify which game objects should be outlined -2. **DIP hook** (during game rendering) — cache draw calls and write stencil marks -3. **JFA pipeline** (EndScene, after game rendering) — produce and composite outlines +1. **Object scan** (EndScene start) - identify which game objects should be outlined +2. **DIP hook** (during game rendering) - cache draw calls and write stencil marks +3. **JFA pipeline** (EndScene, after game rendering) - produce and composite outlines ## Files @@ -44,17 +44,17 @@ state to determine outline visibility. `CM2SceneRenderDraw` receives a flat array of M2 batch indices. The hook partitions them into 3 groups before calling the original function: -- **Group 1 — depth-priority models**: game object M2s and the local player's M2s. +- **Group 1 - depth-priority models**: game object M2s and the local player's M2s. These render first so their depth is in the buffer when stencil marks are written. The local player occludes outlines (they're the camera reference point). If the local player IS an outline target, their models go in group 2 instead (the outline check takes priority in the partition logic). -- **Group 2 — outline targets**: models belonging to tracked entities (current target, +- **Group 2 - outline targets**: models belonging to tracked entities (current target, raid-marked units, dead friendly players). The DIP hook intercepts these draws to cache parameters and write stencil=1 where they pass the depth test. -- **Group 3 — everything else**: other players, their gear, NPCs, creatures. +- **Group 3 - everything else**: other players, their gear, NPCs, creatures. These render last. Their depth is NOT in the buffer when stencil marks are written, so outlines show through them. The outline composites on top in EndScene regardless. @@ -73,14 +73,14 @@ them into 3 groups before calling the original function: The DIP hook writes stencil marks during outline target rendering (group 2): - `STENCILFUNC = ALWAYS`, `STENCILPASS = REPLACE`, `STENCILREF = 1` -- `STENCILZFAIL = KEEP` — pixels behind depth-tested geometry keep stencil=0 +- `STENCILZFAIL = KEEP` - pixels behind depth-tested geometry keep stencil=0 - After each outline DIP, `STENCILWRITEMASK` is set to 0 to protect marks from subsequent draws (group 3 models could otherwise overwrite them) - Exception: dead players skip stencil entirely (`STENCILENABLE = 0`) so their outlines are visible through walls for corpse finding EndScene Phase 1 uses `STENCILFUNC = EQUAL`, `STENCILREF = 1` to gate the -silhouette replay — only pixels marked as visible get silhouette color. +silhouette replay - only pixels marked as visible get silhouette color. Stencil is cleared to 0 after Phase 1 to avoid affecting the next frame. @@ -89,16 +89,16 @@ Stencil is cleared to 0 after Phase 1 to avoid affecting the next frame. After Phase 1 produces the silhouette RT (A8R8G8B8), the JFA pipeline generates outlines via distance field: -1. **JFA Init** — seed the distance field from the silhouette. Pixels with +1. **JFA Init** - seed the distance field from the silhouette. Pixels with silhouette content (alpha >= 0.002) output their own UV as a seed. Empty pixels output sentinel (-1, -1) which is outside UV space [0,1] so it never wins distance comparisons. -2. **JFA Propagation** — 4 passes at step sizes [8, 4, 2, 1], ping-ponging +2. **JFA Propagation** - 4 passes at step sizes [8, 4, 2, 1], ping-ponging between two G16R16F render targets. Each pass does a 9-tap sample (self + 8 neighbors at step distance) and keeps the nearest seed UV. -3. **JFA Decode + Composite** — compute pixel-space distance from each pixel +3. **JFA Decode + Composite** - compute pixel-space distance from each pixel to its nearest seed. If distance < outline width AND the pixel is outside the silhouette interior, output the outline color with alpha blending. @@ -121,7 +121,7 @@ Width is encoded as `alpha = pixels / 4.0` in the silhouette, decoded as | `rt_jfa_a_tex` | G16R16F | JFA ping buffer (seed UV coordinates) | | `rt_jfa_b_tex` | G16R16F | JFA pong buffer | -## DIP Hook — Draw Caching +## DIP Hook - Draw Caching The DIP hook does NOT draw silhouettes inline (that corrupts WoW's GxDevice internal render state). Instead it: @@ -147,7 +147,7 @@ Each frame, `scanObjects()` iterates the WoW object manager and collects: When `CM2Model_ManageRenderListNode` fires for each model being added to the render list, `classifyModel()` reads the model's owner back-pointers (`model+0x28` direct, `model+0x3C0` callback) and matches them against the -collected object pointers. No pointer dereferencing of unknown memory — just +collected object pointers. No pointer dereferencing of unknown memory - just value comparison against the validated set from the object manager. ## Outline Categories @@ -162,7 +162,7 @@ value comparison against the validated set from the object manager. 1. `api.init()` installs model hooks immediately (ManageRenderListNode, DrawBatchProjected, CM2SceneRenderDraw) -2. D3D9 hooks are **deferred** until the first model hook fires — creating a +2. D3D9 hooks are **deferred** until the first model hook fires - creating a dummy D3D9 device during engine init corrupts the proxy's state 3. `api.initD3D9Deferred()` patches the D3D9 vtable (EndScene, DIP, Reset) 4. Reset hook forces D24S8 depth/stencil format (8 stencil bits required) @@ -189,7 +189,7 @@ OutlineCommand("off") -- disable outlines - **Local player outline (planned)**: a Gaussian blur outline mode for the local player to improve visibility in combat when surrounded by mobs. Separate from - the JFA pipeline — will use blur difference (blur silhouette, subtract original, + the JFA pipeline - will use blur difference (blur silhouette, subtract original, threshold) for a softer glow effect and only apply to other players. - **Death tracking needs improvement**: currently uses `UNIT_FLAG_DEAD` which is @@ -197,10 +197,10 @@ OutlineCommand("off") -- disable outlines - Dead players should be outlined (works now) - Released bodies (corpse objects) should also be outlined (partially works via `.corpse` type, but needs verification that released-but-not-skeleton corpses are caught) - - Feign Death must NOT trigger the dead outline — feign death sets the dead flag + - Feign Death must NOT trigger the dead outline - feign death sets the dead flag but the player is alive. Need to check for the feign death aura/buff or use a more specific death condition - - The local player should never get a death outline on themselves — the purpose + - The local player should never get a death outline on themselves - the purpose of the death outline is to help the player find and resurrect others, not to highlight their own corpse - Marker outlines should not persist on units after they die diff --git a/src/outline/api.zig b/src/outline/api.zig index 56c8d19..cb1854f 100644 --- a/src/outline/api.zig +++ b/src/outline/api.zig @@ -11,16 +11,17 @@ const model_hook = @import("model_hook.zig"); const d3d9_hook = @import("d3d9_hook.zig"); const WINAPI = std.builtin.CallingConvention.winapi; -extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?*anyopaque; -extern "kernel32" fn ReleaseMutex(hMutex: *anyopaque) callconv(WINAPI) i32; -extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32; -extern "kernel32" fn GetLastError() callconv(WINAPI) u32; -extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32; -const ERROR_ALREADY_EXISTS: u32 = 183; +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "outline"; var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; +pub fn isActive() bool { + return g_is_hook_owner; +} + /// Install model hooks immediately. D3D9 hooks are deferred until the first /// model hook fires (i.e. the game is actively rendering), because creating a /// dummy D3D9 device during engine init corrupts the d3d9 proxy's state and @@ -28,29 +29,17 @@ var g_is_hook_owner: bool = false; pub fn init() bool { con.print("[outline] Module loaded\n"); - // Multi-DLL safety: only one instance per process should hook - var mutex_name_buf: [64]u8 = undefined; - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_OutlineHook_{d}", .{GetCurrentProcessId()}) catch return false; - mutex_name_buf[mutex_name.len] = 0; - - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return false; - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; - con.print("[outline] Another DLL owns hooks (mutex taken), skipping\n"); - return true; - } - g_is_hook_owner = true; + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return true; if (!model_hook.installHooks()) return false; return true; } /// Called from the first model hook callback, once rendering is active. -/// Safe to create the dummy D3D9 device now — the game's real device is +/// Safe to create the dummy D3D9 device now - the game's real device is /// fully initialised and the proxy's state is stable. pub fn initD3D9Deferred() void { _ = d3d9_hook.installHooks(); @@ -61,14 +50,7 @@ pub fn cleanup() void { if (g_is_hook_owner) { d3d9_hook.removeHooks(); model_hook.removeHooks(); - } - - if (g_is_hook_owner) { - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; - } + mod_mutex.release(&g_mutex); } g_is_hook_owner = false; } diff --git a/src/outline/d3d9_hook.zig b/src/outline/d3d9_hook.zig index 2d3759a..56bd82a 100644 --- a/src/outline/d3d9_hook.zig +++ b/src/outline/d3d9_hook.zig @@ -126,7 +126,7 @@ const CachedDraw = struct { num_verts: u32 = 0, start_idx: u32 = 0, prim_count: u32 = 0, - // GPU state (AddRef'd COM objects — released after replay) + // GPU state (AddRef'd COM objects - released after replay) vb: ?*anyopaque = null, vb_offset: u32 = 0, vb_stride: u32 = 0, @@ -136,7 +136,7 @@ const CachedDraw = struct { // Per-model outline info color: u32 = 0, category: types.ModelCategory = .none, - // VS constants (bone matrices, world/view/proj) — copied by value + // VS constants (bone matrices, world/view/proj) - copied by value vs_consts: [MAX_VS_CONST_REGS][4]f32 = undefined, }; @@ -197,8 +197,7 @@ fn deviceSetPtrOrNull(dev: *anyopaque, idx: usize, ptr: ?*anyopaque) void { : : [self] "r" (@intFromPtr(dev)), [func] "r" (func_addr), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } } @@ -251,8 +250,7 @@ fn deviceSetTexture(dev: *anyopaque, stage: u32, tex: ?*anyopaque) void { : [self] "r" (@intFromPtr(dev)), [stage] "r" (stage), [func] "r" (func_addr), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } } @@ -302,8 +300,7 @@ fn deviceSetStreamSource(dev: *anyopaque, stream: u32, vb: ?*anyopaque, offset: : [self] "r" (@intFromPtr(dev)), [a] "r" (&args), [func] "r" (func_addr), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } } @@ -337,8 +334,7 @@ fn deviceSetIndices(dev: *anyopaque, ib: ?*anyopaque) void { : : [self] "r" (@intFromPtr(dev)), [func] "r" (func_addr), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } } @@ -397,7 +393,7 @@ fn argbToFloat4(argb: u32) [4]f32 { // Terrain depth snapshot // ============================================================================= -// (Terrain DS snapshot removed — DXVK does not support StretchRect for +// (Terrain DS snapshot removed - DXVK does not support StretchRect for // depth-stencil surfaces. Terrain occlusion is achieved via stencil marks // written during the DIP hook, when the game's DS already has terrain depth.) @@ -420,38 +416,55 @@ fn ensureResources(device: *anyopaque) void { resource_height = vp.Height; // Silhouette RT (A8R8G8B8) - if (deviceCreateTexture(device, vp.Width, vp.Height, 1, - types.D3DUSAGE_RENDERTARGET, types.D3DFMT_A8R8G8B8, types.D3DPOOL_DEFAULT, - &rt_silhouette_tex) < 0) { releaseResources(); return; } + if (deviceCreateTexture(device, vp.Width, vp.Height, 1, types.D3DUSAGE_RENDERTARGET, types.D3DFMT_A8R8G8B8, types.D3DPOOL_DEFAULT, &rt_silhouette_tex) < 0) { + releaseResources(); + return; + } rt_silhouette_surf = textureGetSurfaceLevel(rt_silhouette_tex.?); - if (rt_silhouette_surf == null) { releaseResources(); return; } + if (rt_silhouette_surf == null) { + releaseResources(); + return; + } // JFA A RT (G16R16F) - if (deviceCreateTexture(device, vp.Width, vp.Height, 1, - types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT, - &rt_jfa_a_tex) < 0) { releaseResources(); return; } + if (deviceCreateTexture(device, vp.Width, vp.Height, 1, types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT, &rt_jfa_a_tex) < 0) { + releaseResources(); + return; + } rt_jfa_a_surf = textureGetSurfaceLevel(rt_jfa_a_tex.?); - if (rt_jfa_a_surf == null) { releaseResources(); return; } + if (rt_jfa_a_surf == null) { + releaseResources(); + return; + } // JFA B RT (G16R16F) - if (deviceCreateTexture(device, vp.Width, vp.Height, 1, - types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT, - &rt_jfa_b_tex) < 0) { releaseResources(); return; } + if (deviceCreateTexture(device, vp.Width, vp.Height, 1, types.D3DUSAGE_RENDERTARGET, types.D3DFMT_G16R16F, types.D3DPOOL_DEFAULT, &rt_jfa_b_tex) < 0) { + releaseResources(); + return; + } rt_jfa_b_surf = textureGetSurfaceLevel(rt_jfa_b_tex.?); - if (rt_jfa_b_surf == null) { releaseResources(); return; } - + if (rt_jfa_b_surf == null) { + releaseResources(); + return; + } } fn releaseResources() void { inline for (.{ &rt_silhouette_surf, &rt_jfa_a_surf, &rt_jfa_b_surf, }) |surf_ptr| { - if (surf_ptr.*) |s| { comRelease(s); surf_ptr.* = null; } + if (surf_ptr.*) |s| { + comRelease(s); + surf_ptr.* = null; + } } inline for (.{ &rt_silhouette_tex, &rt_jfa_a_tex, &rt_jfa_b_tex, }) |tex_ptr| { - if (tex_ptr.*) |t| { comRelease(t); tex_ptr.* = null; } + if (tex_ptr.*) |t| { + comRelease(t); + tex_ptr.* = null; + } } resource_width = 0; resource_height = 0; @@ -461,7 +474,7 @@ fn releaseResources() void { // Shader source strings // ============================================================================= -/// Flat colour pixel shader — outputs PS constant c0. +/// Flat colour pixel shader - outputs PS constant c0. const ps_flat_src = "ps_3_0\nmov oC0, c0\n"; /// JFA init: sample silhouette, output own UV as seed or sentinel (-1,-1). @@ -491,7 +504,7 @@ const jfa_prop_src = "def c9, 1.0, 1.0, 0.0, 0.0\n" ++ "dcl_2d s0\n" ++ "dcl_texcoord0 v0\n" ++ - // Self sample — initialize best seed and distance + // Self sample - initialize best seed and distance "texld r0, v0, s0\n" ++ "sub r2.xy, v0.xy, r0.xy\n" ++ "dp2add r9.x, r2, r2, c1.x\n" ++ // best dist² @@ -672,7 +685,10 @@ fn assemblePS(device: *anyopaque, assemble: D3DXAssembleShaderFn, src: [*]const fn releaseShaders() void { inline for (.{ &outline_ps, &jfa_init_ps, &jfa_prop_ps, &jfa_decode_ps, &debug_sil_ps }) |ps| { - if (ps.*) |p| { comRelease(p); ps.* = null; } + if (ps.*) |p| { + comRelease(p); + ps.* = null; + } } shaders_attempted = false; } @@ -731,7 +747,7 @@ fn hkEndScene(device: *anyopaque) callconv(sc) i32 { } // ============================================================================= -// Reset hook — force D24S8 depth/stencil format, release resources +// Reset hook - force D24S8 depth/stencil format, release resources // ============================================================================= fn hkReset(device: *anyopaque, pp: *types.D3DPRESENT_PARAMETERS) callconv(sc) i32 { @@ -754,7 +770,7 @@ fn hkReset(device: *anyopaque, pp: *types.D3DPRESENT_PARAMETERS) callconv(sc) i3 } // ============================================================================= -// DrawIndexedPrimitive hook — cache outline draws for EndScene replay +// DrawIndexedPrimitive hook - cache outline draws for EndScene replay // ============================================================================= // When an outline target is being drawn, we cache the draw parameters and // current GPU state (VB, IB, vertex decl, VS, VS constants) so they can be @@ -808,7 +824,7 @@ fn hkDIP( // Capture current GPU state (AddRef COM objects to keep them alive) deviceGetStreamSource(device, 0, &draw.vb, &draw.vb_offset, &draw.vb_stride); - // GetStreamSource AddRef's the VB — we keep the ref until replay + // GetStreamSource AddRef's the VB - we keep the ref until replay draw.ib = deviceGetIndices(device); // GetIndices AddRef's the IB draw.vertex_decl = deviceGetPtr(device, types.VT.GetVertexDeclaration); @@ -832,7 +848,7 @@ fn hkDIP( const s_enable = deviceGetRS(device, types.D3DRS.STENCILENABLE); const s_func = deviceGetRS(device, types.D3DRS.STENCILFUNC); const s_ref = deviceGetRS(device, types.D3DRS.STENCILREF); - // (STENCILWRITEMASK not saved — intentionally set to 0 on restore) + // (STENCILWRITEMASK not saved - intentionally set to 0 on restore) const s_pass = deviceGetRS(device, types.D3DRS.STENCILPASS); const s_fail = deviceGetRS(device, types.D3DRS.STENCILFAIL); const s_zfail = deviceGetRS(device, types.D3DRS.STENCILZFAIL); @@ -856,7 +872,7 @@ fn hkDIP( deviceSetRS(device, types.D3DRS.STENCILPASS, s_pass); deviceSetRS(device, types.D3DRS.STENCILFAIL, s_fail); deviceSetRS(device, types.D3DRS.STENCILZFAIL, s_zfail); - // Write mask 0 instead of restoring original — prevents any + // Write mask 0 instead of restoring original - prevents any // subsequent DIP from overwriting our stencil=1 marks. // Restored properly in EndScene before the JFA pipeline. deviceSetRS(device, types.D3DRS.STENCILWRITEMASK, 0); @@ -871,10 +887,22 @@ fn hkDIP( fn clearCachedDraws() void { for (0..cached_draw_count) |i| { var draw = &cached_draws[i]; - if (draw.vb) |obj| { comRelease(obj); draw.vb = null; } - if (draw.ib) |obj| { comRelease(obj); draw.ib = null; } - if (draw.vertex_decl) |obj| { comRelease(obj); draw.vertex_decl = null; } - if (draw.vertex_shader) |obj| { comRelease(obj); draw.vertex_shader = null; } + if (draw.vb) |obj| { + comRelease(obj); + draw.vb = null; + } + if (draw.ib) |obj| { + comRelease(obj); + draw.ib = null; + } + if (draw.vertex_decl) |obj| { + comRelease(obj); + draw.vertex_decl = null; + } + if (draw.vertex_shader) |obj| { + comRelease(obj); + draw.vertex_shader = null; + } } cached_draw_count = 0; } @@ -898,7 +926,7 @@ fn runJfaPipeline(device: *anyopaque) void { // Save ALL state that replay + JFA will modify (manual, no state blocks) // ===================================================================== - // COM objects (Get* AddRefs — must Release after restore) + // COM objects (Get* AddRefs - must Release after restore) const saved_rt0 = deviceGetRenderTarget(device, 0); const saved_ps = deviceGetPtr(device, types.VT.GetPixelShader); const saved_vs = deviceGetPtr(device, types.VT.GetVertexShader); @@ -969,7 +997,7 @@ fn runJfaPipeline(device: *anyopaque) void { deviceSetRenderTarget(device, 0, rt_silhouette_surf.?); clearRenderTarget(device, 0x00000000); - // Keep game's DS bound — it has stencil marks from DIP hook where + // Keep game's DS bound - it has stencil marks from DIP hook where // outline targets passed the terrain depth test (stencil=1 = visible). // Don't write depth or stencil during replay. deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0); @@ -1006,8 +1034,7 @@ fn runJfaPipeline(device: *anyopaque) void { deviceSetRS(device, types.D3DRS.STENCILPASS, types.D3DSTENCILOP_KEEP); } - _ = origFn(device, draw.prim_type, draw.base_vtx, draw.min_vtx, - draw.num_verts, draw.start_idx, draw.prim_count); + _ = origFn(device, draw.prim_type, draw.base_vtx, draw.min_vtx, draw.num_verts, draw.start_idx, draw.prim_count); } clearCachedDraws(); @@ -1048,89 +1075,88 @@ fn runJfaPipeline(device: *anyopaque) void { const quad = buildFullscreenQuad(vp.Width, vp.Height); deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), @sizeOf(QuadVertex)); } - // Skip JFA — jump straight to state restore + // Skip JFA - jump straight to state restore } else { - // ===================================================================== - // Phase 2: JFA pipeline (silhouette → outline composite) - // ===================================================================== + // ===================================================================== + // Phase 2: JFA pipeline (silhouette → outline composite) + // ===================================================================== - deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, null); - deviceSetPtrOrNull(device, types.VT.SetVertexShader, null); - deviceSetFVF(device, types.D3DFVF_XYZRHW | types.D3DFVF_TEX1); - deviceSetRS(device, types.D3DRS.ZENABLE, types.D3DZB_FALSE); - deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0); - deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 0); - deviceSetRS(device, types.D3DRS.CULLMODE, types.D3DCULL_NONE); - deviceSetRS(device, types.D3DRS.ALPHATESTENABLE, 0); - deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, 0x0F); + deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, null); + deviceSetPtrOrNull(device, types.VT.SetVertexShader, null); + deviceSetFVF(device, types.D3DFVF_XYZRHW | types.D3DFVF_TEX1); + deviceSetRS(device, types.D3DRS.ZENABLE, types.D3DZB_FALSE); + deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0); + deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 0); + deviceSetRS(device, types.D3DRS.CULLMODE, types.D3DCULL_NONE); + deviceSetRS(device, types.D3DRS.ALPHATESTENABLE, 0); + deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, 0x0F); - deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP); - deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP); - deviceSetSamplerState(device, 0, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT); - deviceSetSamplerState(device, 0, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT); - deviceSetSamplerState(device, 0, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE); - deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP); - deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP); - deviceSetSamplerState(device, 1, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT); - deviceSetSamplerState(device, 1, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT); - deviceSetSamplerState(device, 1, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE); + deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP); + deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP); + deviceSetSamplerState(device, 0, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT); + deviceSetSamplerState(device, 0, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT); + deviceSetSamplerState(device, 0, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE); + deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP); + deviceSetSamplerState(device, 1, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP); + deviceSetSamplerState(device, 1, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT); + deviceSetSamplerState(device, 1, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT); + deviceSetSamplerState(device, 1, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE); - const quad = buildFullscreenQuad(vp.Width, vp.Height); - const qstride: u32 = @sizeOf(QuadVertex); - const fw = @as(f32, @floatFromInt(@max(vp.Width, 1))); - const fh = @as(f32, @floatFromInt(@max(vp.Height, 1))); + const quad = buildFullscreenQuad(vp.Width, vp.Height); + const qstride: u32 = @sizeOf(QuadVertex); + const fw = @as(f32, @floatFromInt(@max(vp.Width, 1))); + const fh = @as(f32, @floatFromInt(@max(vp.Height, 1))); - // Pass 1: JFA Init (silhouette → JFA_A) - deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?); - deviceSetTexture(device, 0, rt_silhouette_tex); - deviceSetPtr(device, types.VT.SetPixelShader, jfa_init_ps.?); - deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); + // Pass 1: JFA Init (silhouette → JFA_A) + deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?); + deviceSetTexture(device, 0, rt_silhouette_tex); + deviceSetPtr(device, types.VT.SetPixelShader, jfa_init_ps.?); + deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); - // JFA Propagation: steps [8, 4, 2, 1] ping-ponging between A and B. - deviceSetPtr(device, types.VT.SetPixelShader, jfa_prop_ps.?); - var c0: [4]f32 = undefined; + // JFA Propagation: steps [8, 4, 2, 1] ping-ponging between A and B. + deviceSetPtr(device, types.VT.SetPixelShader, jfa_prop_ps.?); + var c0: [4]f32 = undefined; - // step=8 (JFA_A → JFA_B) - deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?); - deviceSetTexture(device, 0, rt_jfa_a_tex); - c0 = .{ 8.0 / fw, 8.0 / fh, 0.0, 0.0 }; - deviceSetPSConstF(device, 0, &c0); - deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); + // step=8 (JFA_A → JFA_B) + deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?); + deviceSetTexture(device, 0, rt_jfa_a_tex); + c0 = .{ 8.0 / fw, 8.0 / fh, 0.0, 0.0 }; + deviceSetPSConstF(device, 0, &c0); + deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); - // step=4 (JFA_B → JFA_A) - deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?); - deviceSetTexture(device, 0, rt_jfa_b_tex); - c0 = .{ 4.0 / fw, 4.0 / fh, 0.0, 0.0 }; - deviceSetPSConstF(device, 0, &c0); - deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); + // step=4 (JFA_B → JFA_A) + deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?); + deviceSetTexture(device, 0, rt_jfa_b_tex); + c0 = .{ 4.0 / fw, 4.0 / fh, 0.0, 0.0 }; + deviceSetPSConstF(device, 0, &c0); + deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); - // step=2 (JFA_A → JFA_B) - deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?); - deviceSetTexture(device, 0, rt_jfa_a_tex); - c0 = .{ 2.0 / fw, 2.0 / fh, 0.0, 0.0 }; - deviceSetPSConstF(device, 0, &c0); - deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); + // step=2 (JFA_A → JFA_B) + deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?); + deviceSetTexture(device, 0, rt_jfa_a_tex); + c0 = .{ 2.0 / fw, 2.0 / fh, 0.0, 0.0 }; + deviceSetPSConstF(device, 0, &c0); + deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); - // step=1 (JFA_B → JFA_A) - deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?); - deviceSetTexture(device, 0, rt_jfa_b_tex); - c0 = .{ 1.0 / fw, 1.0 / fh, 0.0, 0.0 }; - deviceSetPSConstF(device, 0, &c0); - deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); - - // Pass 4: Decode + Composite (JFA_A + silhouette → backbuffer) - if (saved_rt0) |rt| deviceSetRenderTarget(device, 0, rt); - deviceSetTexture(device, 0, rt_jfa_a_tex); - deviceSetTexture(device, 1, rt_silhouette_tex); - c0 = [4]f32{ fw, fh, 4.0, 0.0 }; - deviceSetPSConstF(device, 0, &c0); - deviceSetPtr(device, types.VT.SetPixelShader, jfa_decode_ps.?); - deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 1); - deviceSetRS(device, types.D3DRS.SRCBLEND, types.D3DBLEND_SRCALPHA); - deviceSetRS(device, types.D3DRS.DESTBLEND, types.D3DBLEND_INVSRCALPHA); - deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); + // step=1 (JFA_B → JFA_A) + deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?); + deviceSetTexture(device, 0, rt_jfa_b_tex); + c0 = .{ 1.0 / fw, 1.0 / fh, 0.0, 0.0 }; + deviceSetPSConstF(device, 0, &c0); + deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); + // Pass 4: Decode + Composite (JFA_A + silhouette → backbuffer) + if (saved_rt0) |rt| deviceSetRenderTarget(device, 0, rt); + deviceSetTexture(device, 0, rt_jfa_a_tex); + deviceSetTexture(device, 1, rt_silhouette_tex); + c0 = [4]f32{ fw, fh, 4.0, 0.0 }; + deviceSetPSConstF(device, 0, &c0); + deviceSetPtr(device, types.VT.SetPixelShader, jfa_decode_ps.?); + deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 1); + deviceSetRS(device, types.D3DRS.SRCBLEND, types.D3DBLEND_SRCALPHA); + deviceSetRS(device, types.D3DRS.DESTBLEND, types.D3DBLEND_INVSRCALPHA); + deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride); } // end else (normal JFA path) // ===================================================================== @@ -1174,7 +1200,10 @@ fn runJfaPipeline(device: *anyopaque) void { deviceSetVSConstF(device, 0, &saved_vs_consts, MAX_VS_CONST_REGS); // COM objects (restore binding then release our ref) - if (saved_rt0) |rt| { deviceSetRenderTarget(device, 0, rt); comRelease(rt); } // RT0 + if (saved_rt0) |rt| { + deviceSetRenderTarget(device, 0, rt); + comRelease(rt); + } // RT0 deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, saved_ds); // DS if (saved_ds) |ds| comRelease(ds); deviceSetPtrOrNull(device, types.VT.SetPixelShader, saved_ps); // PS diff --git a/src/outline/model_hook.zig b/src/outline/model_hook.zig index 704addb..0aa393e 100644 --- a/src/outline/model_hook.zig +++ b/src/outline/model_hook.zig @@ -1,12 +1,12 @@ //! WoW model rendering pipeline hooks. //! //! Hooks three WoW functions to integrate outline rendering: -//! - CM2SceneRenderDraw — reorders batches so outline targets render last. -//! - CM2Model_ManageRenderListNode — classifies models on render-list add. -//! - CM2Scene_DrawBatchProjected — flags the DIP hook for outline rendering. +//! - CM2SceneRenderDraw - reorders batches so outline targets render last. +//! - CM2Model_ManageRenderListNode - classifies models on render-list add. +//! - CM2Scene_DrawBatchProjected - flags the DIP hook for outline rendering. //! //! Calling conventions: -//! - RenderDraw & ManageRender use callconv(.x86_thiscall) — direct native detours. +//! - RenderDraw & ManageRender use callconv(.x86_thiscall) - direct native detours. //! - DrawBatchProj uses a callconv(.naked) entry point because Zig 0.15 has a //! codegen bug with callconv(.x86_fastcall) that generates wrong ret instructions //! for functions with ≤2 register params. The naked wrapper bridges to a cdecl @@ -64,12 +64,12 @@ var reordered_indices: [MAX_REORDER]i32 = undefined; // CM2SceneRenderDraw hook // ============================================================================= // __thiscall(this, viewMatrix, batchData, batchIndices, batchCount) -// Native thiscall detour — no thunk needed. +// Native thiscall detour - no thunk needed. // // Reorders batch indices into 3 groups: -// 1. Game objects/doodads — render first, write depth so outlines respect them -// 2. Outline targets — render second, DIP hook writes stencil against depth -// 3. Other players, gear, NPCs — render last, draw over targets normally +// 1. Game objects/doodads - render first, write depth so outlines respect them +// 2. Outline targets - render second, DIP hook writes stencil against depth +// 3. Other players, gear, NPCs - render last, draw over targets normally // // This gives outlines that are occluded by world/WMO/game objects but show // through other players and gear (since those aren't in depth when stencil @@ -145,7 +145,7 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices: // CM2Model_ManageRenderListNode hook // ============================================================================= // __thiscall(model_ECX, addToList_stack) -// Native thiscall detour — no thunk needed. +// Native thiscall detour - no thunk needed. fn manageRenderDetour(model: u32, add_to_list: u32) callconv(tc) void { // Classify the model when it's being ADDED to the render list @@ -180,7 +180,7 @@ fn drawBatchProjDetour(ctx: u32) callconv(tc) void { 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 + // This batch is an outline target - signal the DIP hook rendering_outline = true; current_model = model_ptr; diff --git a/src/outline/offsets.zig b/src/outline/offsets.zig index b3b3308..fa05d7f 100644 --- a/src/outline/offsets.zig +++ b/src/outline/offsets.zig @@ -93,7 +93,7 @@ pub const IS_IN_WORLD: usize = 0xB4B424; // ============================================================================= /// __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. +/// 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. @@ -106,14 +106,14 @@ pub const FN_UNIT_REACTION: usize = 0x6061E0; // Hooked function addresses (model rendering pipeline) // ============================================================================= -/// CM2SceneRenderDraw — main batch rendering entry point. +/// 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. +/// 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. +/// 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 index a86a381..ef2fbc2 100644 --- a/src/outline/tracker.zig +++ b/src/outline/tracker.zig @@ -2,12 +2,12 @@ //! //! Maintains fixed-size arrays of tracked object pointers (target, raid marks, //! dead players) and the per-frame set of outline model entries. -//! All state is single-threaded (main WoW thread) — no synchronisation needed. +//! All state is single-threaded (main WoW thread) - no synchronisation needed. //! //! Model classification uses forward mapping: scanObjects stores object pointers //! from the object manager, then classifyModel (ManageRenderListNode) reads the //! model's back-pointers (model+0x28, model+0x3C0) and compares them against -//! known object pointers. No dereferencing of unknown memory — just value +//! known object pointers. No dereferencing of unknown memory - just value //! comparison against the object manager's validated set. const std = @import("std"); @@ -27,7 +27,7 @@ const MAX_OUTLINE_MODELS = 256; // Tracked object set (populated by scanObjects from the object manager) // ============================================================================= // Stores obj_ptr + category for entities we want to outline. -// classifyModel matches model back-pointers against these — no pointer chasing. +// classifyModel matches model back-pointers against these - no pointer chasing. const TrackedObj = struct { obj_ptr: u32, @@ -46,7 +46,7 @@ var frame_outlines: [MAX_OUTLINE_MODELS]types.OutlineEntry = undefined; var frame_outline_count: usize = 0; // ============================================================================= -// Per-frame game object tracking (for render ordering — game objects first) +// Per-frame game object tracking (for render ordering - game objects first) // ============================================================================= // Game object M2 models need to render before outline targets so their depth // is in the buffer when stencil marks are written. Tracked separately from @@ -127,7 +127,7 @@ pub fn getOutlinePixels(cat: types.ModelCategory) f32 { /// Classify a model by comparing its back-pointers against known object pointers. /// Safe: only reads from the model struct (which WoW just handed us via the -/// ManageRenderListNode __thiscall), then compares values — never dereferences +/// ManageRenderListNode __thiscall), then compares values - never dereferences /// the back-pointer values as pointers. pub fn classifyModel(model_ptr: u32) void { if (model_ptr == 0 or !enabled) return; @@ -155,7 +155,10 @@ pub fn classifyModel(model_ptr: u32) void { // Deduplicate var found = false; for (game_obj_models[0..game_obj_model_count]) |m| { - if (m == model_ptr) { found = true; break; } + if (m == model_ptr) { + found = true; + break; + } } if (!found) { game_obj_models[game_obj_model_count] = model_ptr; @@ -214,7 +217,7 @@ pub fn scanObjects() void { // Cache raid target GUIDs wow.cacheRaidTargets(); - // Resolve target to object pointer (highest priority — added first) + // Resolve target to object pointer (highest priority - added first) const target_guid = wow.getTargetGUID(); if (target_guid != 0) { const target_obj = wow.getObjectByGUID(target_guid); diff --git a/src/outline/types.zig b/src/outline/types.zig index 21a9dfc..44d8558 100644 --- a/src/outline/types.zig +++ b/src/outline/types.zig @@ -41,7 +41,7 @@ pub const OutlineEntry = struct { }; // ============================================================================= -// Outline colours — D3DCOLOR ARGB format (0xAARRGGBB) +// Outline colours - D3DCOLOR ARGB format (0xAARRGGBB) // ============================================================================= /// Dead player / corpse outline (cyan). @@ -53,14 +53,14 @@ 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 + 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 }; // ============================================================================= diff --git a/src/outline/wow.zig b/src/outline/wow.zig index a4d8c1c..7f3d61d 100644 --- a/src/outline/wow.zig +++ b/src/outline/wow.zig @@ -20,7 +20,7 @@ extern "kernel32" fn IsBadReadPtr( ucb: usize, ) callconv(WINAPI) i32; -/// Quick sanity check — reject null, low-address, and kernel-space pointers. +/// Quick sanity check - reject null, low-address, and kernel-space pointers. pub fn isValidPtr(addr: u32) bool { return addr >= 0x10000 and addr < 0x7F000000; } @@ -118,7 +118,7 @@ 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 + // 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); @@ -149,14 +149,13 @@ pub fn unitGUID(unit_id: [*:0]const u8) u64 { [_] "={edx}" (hi), : [_] "{ecx}" (@intFromPtr(unit_id)), [func] "r" (@as(u32, o.FN_UNIT_GUID)), - : .{ .memory = true, .cc = true } - ); + : .{ .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. +/// 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); @@ -169,8 +168,7 @@ pub fn getObjectByGUID(guid: u64) 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 } - ); + : .{ .ecx = true, .edx = true, .memory = true, .cc = true }); } /// Get the local player's object pointer. @@ -197,8 +195,7 @@ pub fn isUnitFriendly(unit: u32, local_player: u32) bool { : [_] "{ecx}" (local_player), [unit] "r" (unit), [func] "r" (@as(u32, o.FN_UNIT_REACTION)), - : .{ .edx = true, .memory = true, .cc = true } - ); + : .{ .edx = true, .memory = true, .cc = true }); return reaction >= 4; } diff --git a/src/screenshot/png.zig b/src/screenshot/png.zig index 7fead2b..041c18c 100644 --- a/src/screenshot/png.zig +++ b/src/screenshot/png.zig @@ -1,5 +1,5 @@ //! Minimal PNG encoder with deflate compression. -//! No large struct literals or lookup tables — entire module adds ~2KB to .rdata. +//! No large struct literals or lookup tables - entire module adds ~2KB to .rdata. //! Supports store blocks (level 0) and fixed-Huffman encoding (levels 1-9). const std = @import("std"); @@ -72,7 +72,7 @@ pub fn encode( } // ============================================================================= -// Stream wrapper — tracks CRC and Adler inline +// Stream wrapper - tracks CRC and Adler inline // ============================================================================= fn Stream(comptime Ctx: type) type { @@ -140,7 +140,7 @@ fn Stream(comptime Ctx: type) type { } // ============================================================================= -// Store blocks (level 0) — no compression, byte-aligned +// Store blocks (level 0) - no compression, byte-aligned // ============================================================================= fn writeIdatStore(s: anytype, pixels: [*]const u8, w: u32, h: u32) void { @@ -204,7 +204,7 @@ fn writeIdatStore(s: anytype, pixels: [*]const u8, w: u32, h: u32) void { } // ============================================================================= -// Fixed Huffman + LZ77 — RFC 1951 §3.2.6 fixed codes with back-references +// Fixed Huffman + LZ77 - RFC 1951 §3.2.6 fixed codes with back-references // // Sub filter makes adjacent pixel differences small (often zero in flat areas). // LZ77 with hash-table matching finds repeated byte sequences within a 32KB @@ -339,7 +339,7 @@ fn writeIdatFixedHuffman(s: anytype, pixels: [*]const u8, w: u32, h: u32, level: }; defer std.heap.page_allocator.free(out_buf); - // Hash chains for LZ77 matching — chain depth = 2 * level (stb approach) + // Hash chains for LZ77 matching - chain depth = 2 * level (stb approach) const ZHASH: u32 = 16384; const chain_depth: u32 = @as(u32, level) * 2; const chain_mem = std.heap.page_allocator.alloc(u32, ZHASH * chain_depth) catch { @@ -425,7 +425,7 @@ fn writeIdatFixedHuffman(s: anytype, pixels: [*]const u8, w: u32, h: u32, level: const prev2 = chain_mem[base2 + j]; if ((i + 1) -% prev2 <= WINDOW) { if (countMatch(filtered[0..raw_size], prev2, i + 1) > best_len) { - best_dist = 0; // cancel — next position is better + best_dist = 0; // cancel - next position is better break; } } diff --git a/src/screenshot/screenshot.zig b/src/screenshot/screenshot.zig index 1a2764f..169a9b2 100644 --- a/src/screenshot/screenshot.zig +++ b/src/screenshot/screenshot.zig @@ -61,7 +61,7 @@ var tga_hook: hook.Detour(TgaWriteFn) = .{}; var screenshot_dir: [260]u8 = undefined; var screenshot_dir_len: usize = 0; var screenshot_counter: u8 = 0; -var last_screenshot_time: u64 = 0; // packed YMDHMS — resets counter on new second +var last_screenshot_time: u64 = 0; // packed YMDHMS - resets counter on new second // ============================================================================= // Ring buffer queue (max 8 pending screenshots) @@ -83,9 +83,17 @@ var queue_tail: usize = 0; var queue_count: usize = 0; var mutex: std.atomic.Mutex = .unlocked; var worker_running: bool = false; +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "screenshot"; + var g_mutex: ?HANDLE = null; var g_is_hook_owner: bool = false; +pub fn isActive() bool { + return g_is_hook_owner; +} + fn enqueue(shot: PendingScreenshot) bool { if (queue_count >= MAX_PENDING) return false; queue[queue_tail] = shot; @@ -103,7 +111,7 @@ fn dequeue() ?PendingScreenshot { } // ============================================================================= -// Directory extraction — capture path prefix from game's first TGA filename +// Directory extraction - capture path prefix from game's first TGA filename // ============================================================================= fn extractDir(filename_ptr: u32) void { @@ -120,7 +128,7 @@ fn extractDir(filename_ptr: u32) void { } // ============================================================================= -// Call original CTgaFile::Write — __thiscall(self_ECX, filename_stack) ret 4 +// Call original CTgaFile::Write - __thiscall(self_ECX, filename_stack) ret 4 // ============================================================================= fn callOriginal(self: u32, filename: u32) i32 { @@ -133,7 +141,6 @@ fn callOriginal(self: u32, filename: u32) i32 { // ============================================================================= fn tgaWriteDetour(self: u32, filename: u32) callconv(tc) i32 { - if (!enabled) return callOriginal(self, filename); // Validate TGA header fields @@ -182,7 +189,7 @@ fn tgaWriteDetour(self: u32, filename: u32) callconv(tc) i32 { } // ============================================================================= -// Worker thread — dequeues shots, converts BGR→RGB, writes PNG +// Worker thread - dequeues shots, converts BGR→RGB, writes PNG // ============================================================================= fn workerThread() void { @@ -220,7 +227,7 @@ fn processScreenshot(shot: PendingScreenshot) void { var st: SYSTEMTIME = undefined; GetLocalTime(&st); - // Pack timestamp into a single comparable value — reset counter on new second + // Pack timestamp into a single comparable value - reset counter on new second const now: u64 = @as(u64, st.wYear) << 32 | @as(u64, st.wMonth) << 24 | @as(u64, st.wDay) << 16 | @as(u64, st.wHour) << 10 | @as(u64, st.wMinute) << 4 | @as(u64, st.wSecond); @@ -288,8 +295,7 @@ fn luaPushNumber(L_ptr: usize, n: f64) void { [lo] "r" (raw[0]), [hi] "r" (raw[1]), [func] "r" (@as(u32, 0x6F3810)), - : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true } - ); + : .{ .eax = true, .ecx = true, .edx = true, .memory = true, .cc = true }); } // ============================================================================= @@ -303,7 +309,7 @@ fn luaPushNumber(L_ptr: usize, n: f64) void { pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 { const L_ptr = @intFromPtr(L); - // lua_gettop(L) — __fastcall(L_ECX), EDX unused + // lua_gettop(L) - __fastcall(L_ECX), EDX unused const nargs = hook.fastcall(i32, 0x6F3070, L_ptr, @as(u32, 0)); if (nargs == 0) { @@ -314,7 +320,7 @@ pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 { return 2; } - // lua_tostring(L, 1) — __fastcall(L_ECX, index_EDX) + // lua_tostring(L, 1) - __fastcall(L_ECX, index_EDX) const raw_str = hook.fastcall(usize, 0x6F3690, L_ptr, @as(i32, 1)); if (raw_str != 0) { const str: [*:0]const u8 = @ptrFromInt(raw_str); @@ -326,7 +332,7 @@ pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 { enabled = false; } else if (std.mem.eql(u8, arg, "quality")) { if (nargs >= 2) { - // lua_tonumber(L, 2) — __fastcall(L_ECX, index_EDX), returns f64 in ST(0) + // lua_tonumber(L, 2) - __fastcall(L_ECX, index_EDX), returns f64 in ST(0) const level = hook.fastcall(f64, 0x6F3620, L_ptr, @as(i32, 2)); compression_level = std.math.clamp(@as(i32, @intFromFloat(level)), 0, 9); } @@ -343,22 +349,10 @@ pub fn screenshotCommand(L: *anyopaque) callconv(.c) u32 { pub fn installHook() void { con.print("[screenshot] Module loaded\n"); - // Multi-DLL safety: only one instance per process should hook - var mutex_name_buf: [64]u8 = undefined; - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\WeirdUtils_ScreenshotHook_{d}", .{GetCurrentProcessId()}) catch return; - mutex_name_buf[mutex_name.len] = 0; - - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return; - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; - con.print("[screenshot] Another DLL owns hooks (mutex taken), skipping\n"); - return; - } - g_is_hook_owner = true; + const result = mod_mutex.acquire(module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) return; // CTgaFile::Write at 0x5a4810 // __thiscall(self, filename) ret 4 @@ -374,14 +368,7 @@ pub fn installHook() void { pub fn removeHook() void { if (g_is_hook_owner) { tga_hook.detach(); - } - - if (g_is_hook_owner) { - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; - } + mod_mutex.release(&g_mutex); } g_is_hook_owner = false; } diff --git a/src/transmogfix/transmogfix.zig b/src/transmogfix/transmogfix.zig index ffb8baa..799f25e 100644 --- a/src/transmogfix/transmogfix.zig +++ b/src/transmogfix/transmogfix.zig @@ -154,11 +154,19 @@ const CachedPlayerState = struct { var g_cache: CachedPlayerState = .{}; +const mod_mutex = @import("../mutex.zig"); + +pub const module_name: [*:0]const u8 = "transmogfix"; + var g_enabled: bool = true; var g_initialized: bool = false; var g_is_hook_owner: bool = false; var g_mutex: ?*anyopaque = null; +pub fn isActive() bool { + return g_is_hook_owner; +} + // ============================================================================= // Hooks // ============================================================================= @@ -409,7 +417,7 @@ fn processTimeouts(now: u32) void { found_count += 1; const elapsed = now -% g_other_pending[i].timestamp; if (elapsed >= OTHER_PLAYER_TIMEOUT_MS) { - // Re-resolve the GUID to a live object pointer — the cached + // Re-resolve the GUID to a live object pointer - the cached // unit_ptr may be stale if the player despawned since capture. const unit = getObjectByGUID(g_other_pending[i].guid); con.fmt("[other] TIMEOUT slot={d:2} guid=0x{X:0>16} {d}ms unit=0x{X:0>8}\n", .{ g_other_pending[i].slot, g_other_pending[i].guid, elapsed, unit }); @@ -458,7 +466,7 @@ fn processTimeouts(now: u32) void { if (display_table != 0) { const box_model_data = hook.readMem(u32, display_table + DISPLAY_ID_BOX * 4); if (box_model_data != 0) { - // Set cached ModelData to BOX model — forces ShouldUpdateDisplayInfo = true + // Set cached ModelData to BOX model - forces ShouldUpdateDisplayInfo = true const dest: *u32 = @ptrFromInt(unit + UNIT_CACHED_MODELDATA_OFFSET); dest.* = box_model_data; @@ -502,7 +510,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 { _ = cachePlayerState(); } if (val == 0 and g_cached_visible_item[slot] != 0) { - // CLEAR detected — check if INV_SLOT is already empty (real unequip) + // CLEAR detected - check if INV_SLOT is already empty (real unequip) if (g_cache.valid and g_cache.equipped_guids[slot] == 0) { g_cached_visible_item[slot] = 0; return callOriginalSetBlock(obj, index, value); @@ -518,7 +526,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 { return 1; // Block the clear } else if (val != 0 and g_local_pending[slot].active) { if (val == g_local_pending[slot].original_visible_item) { - // RESTORE with same value — transmog pattern confirmed + // RESTORE with same value - transmog pattern confirmed if (g_local_pending[slot].has_durability) { const dur = g_local_pending[slot].captured_durability; @@ -526,7 +534,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 { writeItemDurabilityDirect(slot, dur); con.fmt("[local] APPLY dur slot={d:2} dur={d}\n", .{ slot, dur }); } else { - // Don't block — broken items need visual update + // Don't block - broken items need visual update con.fmt("[local] PASS broken slot={d:2}\n", .{slot}); g_local_pending[slot].active = false; g_local_pending[slot].has_durability = false; @@ -543,7 +551,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 { con.fmt("[local] BLOCK restore slot={d:2} item=0x{X:0>8} -- coalesced!\n", .{ slot, val }); return 1; // Block the restore } else { - // Different item value — real gear change + // Different item value - real gear change g_local_pending[slot].active = false; g_local_pending[slot].has_durability = false; g_local_pending_count -= 1; @@ -581,7 +589,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 { } } else if (idx >= 0 and g_other_pending[@intCast(idx)].active) { const ui: u32 = @intCast(idx); - // Restore — check timeout and same item + // Restore - check timeout and same item const elapsed = now -% g_other_pending[ui].timestamp; const current_val = readUnitVisibleItem(obj, slot); if (elapsed < OTHER_PLAYER_TIMEOUT_MS and val == current_val) { @@ -599,7 +607,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 { } } - // DURABILITY writes — capture for pending local player slots + // DURABILITY writes - capture for pending local player slots if (g_enabled and index == ITEM_FIELD_DURABILITY) { const slot = findSlotForItemObject(obj); if (slot >= 0) { @@ -609,12 +617,12 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 { g_local_pending[s].has_durability = true; g_local_pending[s].timestamp = GetTickCount(); con.fmt("[local] CATCH dur slot={d:2} dur={d}\n", .{ s, val }); - return 1; // Block — captured + return 1; // Block - captured } } } - // INV_SLOT writes — detect gear changes and update cache + // INV_SLOT writes - detect gear changes and update cache if (index >= PLAYER_FIELD_INV_SLOT_HEAD and index < PLAYER_FIELD_INV_SLOT_HEAD + 48) { @@ -777,24 +785,14 @@ fn hookSceneEnd(device: u32) callconv(tc) void { pub fn installHooks() void { con.print("[transmogfix] Module loaded\n"); - // Multi-DLL safety: only one instance per process should hook - var mutex_name_buf: [64]u8 = undefined; - // muted name scheme not quite the same because this dll exists in the wild and we want to match it - const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\TransmogCoalesceHook_{d}", .{GetCurrentProcessId()}) catch return; - mutex_name_buf[mutex_name.len] = 0; - - g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0])); - if (g_mutex == null) return; - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - _ = CloseHandle(g_mutex.?); - g_mutex = null; - g_is_hook_owner = false; + // Legacy mutex name - this DLL existed in the wild before the naming convention + const result = mod_mutex.acquireLegacy("TransmogCoalesceHook", module_name); + g_mutex = result.handle; + g_is_hook_owner = result.is_owner; + if (!g_is_hook_owner) { g_initialized = true; - con.print("[transmogfix] Another DLL owns hooks (mutex taken), skipping\n"); return; } - g_is_hook_owner = true; // Initialize state (already zero-initialized by Zig defaults) g_local_pending = [1]LocalPending{.{}} ** 19; @@ -830,14 +828,7 @@ pub fn removeHooks() void { scene_end_hook.detach(); refresh_hook.detach(); set_block_hook.detach(); - } - - if (g_is_hook_owner) { - if (g_mutex) |m| { - _ = ReleaseMutex(m); - _ = CloseHandle(m); - g_mutex = null; - } + mod_mutex.release(&g_mutex); } g_initialized = false;