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:
MarcelineVQ
2026-03-06 13:27:35 -08:00
parent 0783f3b0dd
commit 2aaa089c76
44 changed files with 1904 additions and 1022 deletions
+39 -39
View File
@@ -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
View File
@@ -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 |
+8 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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;