Add module control API, bigcursor with fractional scaling, shared mutex
Module control API: - Three cdecl exports: WeirdUtils_IsModuleActive, WeirdUtils_DisableModule, WeirdUtils_DisableAll - C header include/weirdutils_api.h for runtime DLL discovery - All modules gain pub module_name, isActive(), shared mutex via src/mutex.zig - build.zig refactored to module_list array of ModuleDesc Bigcursor: - D3D9 vtable hooks on SetCursorProperties/ShowCursor - Scale2x/Scale3x pixel-art upscalers in pure Zig (replaces 12K-line hqx C) - Bilinear resampler for fractional scales (1.0-4.0, default 1.2) - Win32 cursor via CreateIconIndirect bypasses D3D9 32x32 limit - FNV-1a hash cache for ~16 cursor bitmaps - Lua API: SetCursorScale(n) / GetCursorScale() - CVar cursorScale for Config.wtf persistence (tenths) Other: - Add dpslog module stub - Docs and README updates
This commit is contained in:
+64
-2
@@ -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_<name>_<PID>` (e.g. `Local\WeirdUtils_framecrash_12345`). The exception is transmogfix, which uses `Local\TransmogCoalesceHook_<PID>` 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-4
@@ -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.
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
+13
-13
@@ -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)
|
||||
|
||||
+24
-24
@@ -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
|
||||
|
||||
|
||||
@@ -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/`
|
||||
|
||||
+13
-13
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 <windows.h>
|
||||
|
||||
#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 */
|
||||
+669
-27
@@ -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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+52
-52
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+18
-29
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! - Session continuation: reuses files modified < 60 min ago
|
||||
//! - Session marker: writes `COMBATLOG_SESSION: <char> <realm>` 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 `<prefix>_*.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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+2
-3
@@ -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 {
|
||||
|
||||
+130
-43
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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) |
|
||||
|
||||
+116
-116
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+48
-64
@@ -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;
|
||||
}
|
||||
|
||||
+32
-32
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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_<module_name>_<PID>`
|
||||
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\<legacy_prefix>_<PID>`
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -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
|
||||
|
||||
+13
-31
@@ -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;
|
||||
}
|
||||
|
||||
+143
-114
@@ -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
|
||||
|
||||
+10
-10
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
-7
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
||||
+6
-9
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user