Publish source: Unlicense, public README, repo hygiene
The remote was previously a distribution-only point for pre-built DLLs. This opens the source. - LICENSE: Unlicense, with a GPL-3.0 carve-out for src/dpslog/WeirdDPSMate (a DPSMate fork that keeps its own license) - README.md replaces the stale internal one with the user-facing docs from DLL_README.md, swapping the 'Why No Source Code?' section for build and layout notes. DLL_README.md is dropped; one README now serves both. - RELEASING.md: drop the trim-the-README-per-release dance and the remote/WeirdUtils/ distribution clone, both obsolete now - gitignore agent/editor scratch, build caches, the vendored WSBT addon, and the WeirdThreat/uwu-logs checkouts (separate upstream repos) - Commit outstanding module work: superweirdo, clickthrough portal visuals, transform44 decompiles, worldmarkers demo presets, tools/
This commit is contained in:
+33
-1
@@ -7,7 +7,39 @@ ideas/
|
||||
wiki/
|
||||
src/**/assets_backup/
|
||||
.kangentic/
|
||||
.claude/settings.local.json
|
||||
.claude/
|
||||
kangentic.local.json
|
||||
|
||||
remote/
|
||||
|
||||
# agent/editor scratch
|
||||
.memsearch/
|
||||
.sisyphus/
|
||||
.vscode/
|
||||
AGENTS.md
|
||||
src/**/AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# build artifacts
|
||||
logsession.zip
|
||||
src/transform44/cycles.out
|
||||
|
||||
# vendored third-party addon, not ours to relicense
|
||||
src/dpslog/WSBT/*
|
||||
!src/dpslog/WSBT/WSBT_CLEUAdapter.lua
|
||||
|
||||
# separate upstream repos, checked out here for reference only
|
||||
# WeirdThreat -> github.com/MarcelineVQ/TWThreat
|
||||
# uwu-logs -> github.com/Ridepad/uwu-logs
|
||||
src/dpslog/WeirdThreat/
|
||||
src/dpslog/uwu-logs/
|
||||
|
||||
# internal planning notes
|
||||
SHARING_AUDIT.md
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# fetched at runtime by tools/stormlib.py
|
||||
tools/libstorm.so
|
||||
tools/lib/
|
||||
|
||||
-355
@@ -1,355 +0,0 @@
|
||||
# WeirdUtils
|
||||
|
||||
This package provides many pre-built DLLs for enhancing the vanilla 1.12 client WoW gameplay experience, aimed in particular at ease of use and accessibility but also bug fixes.
|
||||
|
||||
You may get all features by installing `weirdutils.dll`, or choose any selection of features via individual DLLs.
|
||||
On Turtle WoW, place your chosen DLLs next to your `WoW.exe` and add them to your `dlls.txt`. For other versions you will need some sort of DLL loader.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### World Markers
|
||||
|
||||
Place up to 5 animated colored markers (Cataclysm style) at any position in the world, useful for raid positioning, pull planning, or route marking. Requires party/raid leader or raid assist.
|
||||
|
||||
- `/worldmarker 1` through `/worldmarker 5` (or `/wm 1`) -- place a marker where your cursor is pointing
|
||||
- `/worldmarker 1 target` -- place a marker on a unit (player, target, mouseover, etc.)
|
||||
- `/clearworldmarker` (or `/cwm`) -- remove all markers
|
||||
- `/clearworldmarker 2` -- remove a specific marker
|
||||
|
||||
Keybindings for placing each marker and clearing all markers are available in the Key Bindings menu.
|
||||
|
||||
Markers automatically sync with group members who also have WeirdUtils installed. When a leader/assist places or clears a marker, all group members see it. Markers persist across zone transitions and respawn when you return to the area.
|
||||
|
||||
Lua API for addon developers:
|
||||
|
||||
- `WorldMarker(index)` -- place marker at cursor (returns x,y,z,areaId on success, nil if no permission, -1 on failure)
|
||||
- `WorldMarker(index, "unit")` -- place marker at a unit's position
|
||||
- `WorldMarker(index, x, y, z)` -- place marker at world coordinates
|
||||
- `ClearWorldMarker(index)` / `ClearWorldMarker()` -- remove one or all markers (returns 1 on success, nil if no permission)
|
||||
- `GetWorldMarker(index)` -- returns x,y,z,areaId for an active marker, nil if empty
|
||||
- `CanSetWorldMarker()` -- returns 1 if the local player is party/raid leader or raid assist, nil otherwise
|
||||
|
||||
**DLL:** `worldmarkers.dll`
|
||||
|
||||
---
|
||||
|
||||
### Outlines
|
||||
|
||||
Renders glowing colored outlines around units, improving visibility in crowded encounters.
|
||||
|
||||
- `/outlines` or `/ol` -- toggle outlines on or off
|
||||
|
||||
A keybinding is available in the Key Bindings menu.
|
||||
|
||||
**DLL:** `outline.dll`
|
||||
|
||||
---
|
||||
|
||||
### Interact
|
||||
|
||||
Smart interaction helpers for faster farming and dungeon runs:
|
||||
|
||||
- **Interact Nearest** -- right-clicks the closest interactable NPC or object within 5 yards
|
||||
- **Loot All Corpses** -- bulk loots all nearby corpses in sequence
|
||||
|
||||
Best used via keybindings (available in the Key Bindings menu) or macros:
|
||||
```
|
||||
/run InteractNearest(1)
|
||||
/run LootAllCorpses()
|
||||
```
|
||||
|
||||
**DLL:** `interact.dll`
|
||||
|
||||
---
|
||||
|
||||
### PNG Screenshots
|
||||
|
||||
Saves screenshots as compressed PNG files instead of the default uncompressed TGA format. Runs on a background thread with no frame drops.
|
||||
|
||||
Controlled via the `screenshotQuality` CVar (saved to config.wtf):
|
||||
|
||||
- `/script SetCVar("screenshotQuality", "6")` -- set compression level (1 = fast, 9 = smallest, default 6)
|
||||
- `/script SetCVar("screenshotQuality", "0")` -- disable PNG, use original TGA format
|
||||
|
||||
**DLL:** `pngscreenshots.dll`
|
||||
|
||||
---
|
||||
|
||||
### Crash Fix
|
||||
|
||||
Prevents a class of crashes caused by stale UI frame anchor pointers. No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `framecrash.dll`
|
||||
|
||||
---
|
||||
|
||||
### Transmog Fix
|
||||
|
||||
Eliminates FPS drops caused by rapid equipment visual updates when transmogged items lose durability. No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `transmogfix.dll`
|
||||
|
||||
---
|
||||
|
||||
### Custom Data/ Assets
|
||||
|
||||
Enables loading loose game asset files (models, textures, etc.) from the `Data/` directory without repacking MPQ archives. Place files in `Data/` mirroring the game's internal paths (e.g. `Data/Character/Troll/Female/TrollFemale.m2`) and they will be used instead of the MPQ version.
|
||||
|
||||
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.
|
||||
|
||||
No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `customassets.dll`
|
||||
|
||||
---
|
||||
|
||||
### Utility Minimap Trackings
|
||||
|
||||
Adds TBC/WotLK-style minimap tracking icons for NPC types, game objects, and quest givers.
|
||||
Replaces the native tracking dropdown with a combined menu showing both spell tracking and NPC category tracking.
|
||||
Can be disabled from the normal AddOn menu. Preferences saved per-character.
|
||||
|
||||
- Click the minimap tracking icon to open the dropdown
|
||||
- Check/uncheck NPC categories to toggle their minimap icons
|
||||
- Spell tracking (Hunter tracking, Find Herbs, etc.) remains available alongside NPC tracking
|
||||
- "Hide in Cities" toggle suppresses NPC icons in capital cities
|
||||
|
||||
Tracks various npc types and useful objects like Oranges and Brainwasher and Mailbox.
|
||||
|
||||
**DLL:** `minimapicons.dll`
|
||||
|
||||
---
|
||||
|
||||
### Clickthrough
|
||||
|
||||
Smart cursor targeting that prioritizes useful interactions. Instead of always selecting the nearest object under the cursor, the module finds the most useful target along the ray in priority order.
|
||||
|
||||
- Lootable corpses first, then interactable game objects/portals, then interactable NPCs, then normal selection
|
||||
- Dead non-lootable corpses can still be selected when nothing more useful is behind them
|
||||
- Disabled inside battlegrounds to prevent targeting objectives through enemy players
|
||||
|
||||
Can replace SuperWoW's `Clickthrough()` toggle with always-on smart targeting that doesn't require a manual toggle and preserves the ability to select dead bodies when needed. Disable corpse-clickthrough in SuperAPI if you want this.
|
||||
|
||||
No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `clickthrough.dll`
|
||||
|
||||
---
|
||||
|
||||
### Log Sessions
|
||||
|
||||
Organizes the combat, raw combat, and chat logs into per-character directories with timestamped filenames:
|
||||
|
||||
```
|
||||
Logs\<Realm>\<Character>\WoWChatLog_YYYYMMDD_HHMMSS.txt
|
||||
Logs\<Realm>\<Character>\WoWCombatLog_YYYYMMDD_HHMMSS.txt
|
||||
Logs\<Realm>\<Character>\WoWRawCombatLog_YYYYMMDD_HHMMSS.txt (superwow only)
|
||||
```
|
||||
|
||||
Every character login begins with a marker line (`COMBATLOG_SESSION` or `CHAT_SESSION`) identifying the character and realm.
|
||||
If a log file for the same character was written to within the last 60 minutes, the same logfile will be used instead of creating a new one.
|
||||
|
||||
Lua API for addon developers:
|
||||
|
||||
- `GetCombatLogPath()` -- returns the current combat log file path
|
||||
- `GetChatLogPath()` -- returns the current chat log file path
|
||||
|
||||
No other configuration needed, install and forget.
|
||||
|
||||
**DLL:** `logsessions.dll`
|
||||
|
||||
---
|
||||
|
||||
### DPSLog (Combat Log Events)
|
||||
|
||||
Provides WotLK 3.3.5-style `COMBAT_LOG_EVENT_UNFILTERED` for the vanilla client. Fires a single unified event with structured arguments instead of vanilla's fragmented localized text events. Enables modern DPS meter addons without expensive string parsing.
|
||||
|
||||
37 subevents covering all combat interactions: damage (spell, melee, periodic, environmental, damage shield, damage split), healing (direct, periodic, overheal tracking), misses (all types), auras (applied, removed, refreshed, broken, dose changes), casts (start, success, failed, interrupted), power (energize, drain, leech), dispels, extra attacks, deaths, and kills.
|
||||
|
||||
Each event includes source/destination GUIDs, names, unit flags, raid flags, spell info, and all WotLK-standard suffix fields. Booleans (critical, glancing, crushing) use WotLK semantics: `nil` for false, `"1"` for true. Names are `nil` when the client can't resolve the unit (despawned, out-of-range). Null GUIDs use `0x80000000` flags.
|
||||
|
||||
When `/combatlog` is active, also writes structured WotLK-style CSV to `Logs\WeirdCombatLog.txt` (replaces vanilla's combat log file with parseable data).
|
||||
|
||||
Also provides:
|
||||
|
||||
- `CombatLogGetCurrentEventInfo()` -- WotLK-style lazy arg retrieval (call from event handler):
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
|
||||
f:SetScript("OnEvent", function()
|
||||
local sub, srcGUID, srcName, srcFlags, srcRaidFlags,
|
||||
dstGUID, dstName, dstFlags, dstRaidFlags = CombatLogGetCurrentEventInfo()
|
||||
-- suffix args follow (spellId, spellName, etc.) -- see wiki for full layout
|
||||
end)
|
||||
```
|
||||
|
||||
- `GetSpellInfo(spellId)` -- the TBC/WotLK spell lookup API:
|
||||
|
||||
```lua
|
||||
local name, rank, icon, castTime, minRange, maxRange, spellId = GetSpellInfo(133)
|
||||
```
|
||||
|
||||
- `UnitCastingInfo("unit")` -- TBC/WotLK cast bar query (works on any visible unit):
|
||||
|
||||
```lua
|
||||
local name, rank, text, icon, startTime, endTime, isTradeSkill, castID, notInterruptible = UnitCastingInfo("target")
|
||||
if name then
|
||||
-- startTime/endTime are in milliseconds (compare with GetTime()*1000)
|
||||
end
|
||||
```
|
||||
|
||||
- `UnitChannelInfo("unit")` -- TBC/WotLK channel bar query:
|
||||
|
||||
```lua
|
||||
local name, rank, text, icon, startTime, endTime, isTradeSkill, notInterruptible = UnitChannelInfo("target")
|
||||
```
|
||||
|
||||
See the [DPSLog wiki page](https://codeberg.org/gwenael/WeirdUtils/wiki/DPSLog) for full event reference and addon developer guide.
|
||||
|
||||
**DLL:** `dpslog.dll`
|
||||
|
||||
---
|
||||
|
||||
### SuperWoW Heal Text Fix
|
||||
|
||||
Fixes duplicate floating heal numbers caused by SuperWoW 1.5. Only relevant if you use SuperWoW. No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `healtextfix.dll`
|
||||
|
||||
---
|
||||
|
||||
### Big Cursor
|
||||
|
||||
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.
|
||||
|
||||
Lua API for addon developers:
|
||||
|
||||
- `SetCursorScale(n)` -- set scale factor (1.0-4.0), takes effect on next cursor change
|
||||
- `GetCursorScale()` -- returns current scale factor
|
||||
|
||||
**DLL:** `bigcursor.dll`
|
||||
|
||||
---
|
||||
|
||||
### Performance
|
||||
|
||||
Engine-level optimizations that reduce CPU time on math, rendering helpers, file lookups, and data decompression.
|
||||
|
||||
- **SIMD Math** -- replaces 20+ internal math functions with SSE/AVX equivalents covering skeletal animation, particle rendering, frustum culling, collision detection, text glyph caching, and float-to-integer conversion
|
||||
- **Data Decompression** -- swaps the game's 2004-era zlib with a modern library (2.2x faster). Loading screen times reduced by at least 13%
|
||||
- **MPQ File Cache** -- caches archive file lookups so repeat file opens skip the archive chain walk. Saving 50-160ms every 15 seconds during heavy gameplay
|
||||
- **Timer Calibration** -- recalibrates the OS performance counter for accurate animation timing. Ported from [VanillaFixes](https://github.com/hannesmann/vanillafixes)
|
||||
- **Lua Runtime** -- custom slab allocator (O(1) free/realloc), incremental/generational GC (turns the ~5s stop-the-world freeze into ~9ms chunks), faster string interning (~40% on `luaS_newlstr`), and a literal prefilter on `string.find`/`gfind`/`gsub` that kills the O(n²) backtracking addon combat log parsers inflict on every chat message
|
||||
|
||||
Most noticeable in cities, raids, during zone transitions, and in addon-heavy setups.
|
||||
|
||||
**DLL:** `weirdperformance.dll`
|
||||
|
||||
---
|
||||
|
||||
## Why No Source Code?
|
||||
|
||||
This project is distributed as pre-built DLLs only. The source code is not and will not be made publicly available.
|
||||
|
||||
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 in case other devs find their dll's in conflict.
|
||||
|
||||
#### 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 released dll names:
|
||||
|
||||
`customassets`, `framecrash`, `logsessions`, `transmogfix`, `minimapicons`, `healtextfix`, `bigcursor`, `worldmarkers`, `interact`, `outline`, `pngscreenshots`, `clickthrough`, `dpslog`, `weirdperformance`
|
||||
|
||||
There is no re-enable API.
|
||||
|
||||
#### 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");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Version Query API
|
||||
|
||||
WeirdUtils registers a Lua global table and query function for addon developers to detect which modules are loaded and their versions. Available from the login screen onward.
|
||||
|
||||
#### `GetWeirdUtilsVersion()`
|
||||
|
||||
Returns the `WeirdUtils` table containing all enabled modules and their version strings:
|
||||
|
||||
```lua
|
||||
local modules = GetWeirdUtilsVersion()
|
||||
for name, version in pairs(modules) do
|
||||
print(name .. " v" .. version) -- e.g. "dpslog v1.0"
|
||||
end
|
||||
```
|
||||
|
||||
#### `GetWeirdUtilsVersion("modulename")`
|
||||
|
||||
Returns the version string for a specific module, or `nil` if not loaded:
|
||||
|
||||
```lua
|
||||
if GetWeirdUtilsVersion("dpslog") then
|
||||
-- DPSLog is available, register for COMBAT_LOG_EVENT_UNFILTERED
|
||||
end
|
||||
|
||||
local ver = GetWeirdUtilsVersion("minimapicons") -- "1.0" or nil
|
||||
```
|
||||
|
||||
The `WeirdUtils` table is additive -- if multiple independent DLLs are loaded (e.g. `dpslog.dll` and `minimapicons.dll` separately), each adds its own modules to the shared table.
|
||||
|
||||
---
|
||||
|
||||
### Module Mutexes
|
||||
|
||||
Each module also 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 - and can use the Runtime Module Control API to disable it. If you don't see it, the module isn't active and you're free to hook those functions yourself.
|
||||
@@ -25,8 +25,31 @@ Goals:
|
||||
- Prevent clicking GOs in states 2 and 3 (clickthrough module, GO filter pass)
|
||||
- Render states 2/3 in greyscale or desaturated to visually distinguish from usable ones
|
||||
|
||||
Click blocking fits in clickthrough's existing cascade filter (checkObjTypeDetour).
|
||||
Greyscale rendering needs research into the client's GO model draw path -- may need
|
||||
to intercept material/texture setup or set a per-object color tint before the draw call.
|
||||
Click blocking: DONE (portal_filter.zig in clickthrough module).
|
||||
|
||||
Module: clickthrough (own file, e.g. portal_filter.zig)
|
||||
Greyscale rendering research:
|
||||
- `SetModelAlpha` (0x710da0): writes float to sceneObject+0x1C4 (__thiscall). Alpha only.
|
||||
- `SetAlphaValue` (0x76ac50): writes float to entity+0xBC, calls through vtable+0x8C.
|
||||
- `SetModelAlpha_2` (0x76d120): reads byte at entity+0xC8, propagates to entity+0x318.
|
||||
- Fade system (0x672ef0): writes alpha to entity[0x22] (entity+0x88) via SetMemoryPointer.
|
||||
- D3D9 render states: outline module (d3d9_hook.zig) has full pattern for per-object
|
||||
D3D state manipulation via stencil buffer.
|
||||
- Greyscale options:
|
||||
1. D3DRS_TEXTUREFACTOR + D3DTOP_MODULATE per draw call (set grey color before object draws)
|
||||
2. Pixel shader override (D3D9 SetPixelShader to a desaturation shader)
|
||||
3. Find an existing color tint field on the model instance (not found yet)
|
||||
- `adjustColorSaturation` (0x74d794): client has a desaturation function built in.
|
||||
Takes (outputRGBA*, inputRGBA*, saturationFactor). 0.0 = greyscale, 1.0 = full color.
|
||||
Uses standard luminance weights at 0x818878/187c/1880. No xrefs found (may be unused).
|
||||
- `GetModelDiffuseColor` / `SetModelDiffuseColor`: reads/writes sceneObject+0x184 (3 floats RGB).
|
||||
`GetModelAmbientColor` / `SetModelAmbientColor`: sceneObject+0x190 (3 floats RGB).
|
||||
`SetModelAlpha`: sceneObject+0x1C4 (1 float). These are data accessors, not code functions.
|
||||
- M2 model rendering does NOT go through simple GxDevice SetTexture wrappers -- uses a
|
||||
different submission path. Only RenderTextureQuads (UI) calls GxDevice::SetTexture.
|
||||
- Viable approaches:
|
||||
1. Write grey diffuse color (e.g. 0.3, 0.3, 0.3) to sceneObject+0x184 per frame
|
||||
for targeted GOs. Need to find the GO -> scene object pointer chain.
|
||||
2. Hook D3D9 DrawIndexedPrimitive (like outline module) and set D3DRS_TEXTUREFACTOR
|
||||
to grey for targeted objects. Requires identifying which DIP calls belong to which GO.
|
||||
3. Set alpha to 0.5 via sceneObject+0x1C4 for a simpler "faded" visual instead of greyscale.
|
||||
- Next: find the GO -> entity -> scene object pointer chain so we can access +0x184.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
||||
|
||||
---
|
||||
|
||||
EXCEPTION: src/dpslog/WeirdDPSMate/ is a fork of DPSMate by Shino
|
||||
<Synced> and is licensed under the GNU General Public License v3.
|
||||
See src/dpslog/WeirdDPSMate/LICENSE. The dedication above does not
|
||||
apply to that directory.
|
||||
@@ -1,178 +1,391 @@
|
||||
# WeirdUtils
|
||||
|
||||
All-in-one WoW 1.12.1 (build 5875) utility DLL. Injected as a 32-bit DLL into the
|
||||
game process via Wine/DXVK on Linux. Provides screen-space outlines, screenshots,
|
||||
interaction helpers, and an embedded addon with Lua API + keybindings.
|
||||
This package provides many pre-built DLLs for enhancing the vanilla 1.12 client WoW gameplay experience, aimed in particular at ease of use and accessibility but also bug fixes.
|
||||
|
||||
## Current Features
|
||||
You may get all features by installing `weirdutils.dll`, or choose any selection of features via individual DLLs.
|
||||
On Turtle WoW, place your chosen DLLs next to your `WoW.exe` and add them to your `dlls.txt`. For other versions you will need some sort of DLL loader.
|
||||
|
||||
| Module | Description |
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### World Markers
|
||||
|
||||
Place up to 5 animated colored markers (Cataclysm style) at any position in the world, useful for raid positioning, pull planning, or route marking. Requires party/raid leader or raid assist.
|
||||
|
||||
- `/worldmarker 1` through `/worldmarker 5` (or `/wm 1`) -- place a marker where your cursor is pointing
|
||||
- `/worldmarker 1 target` -- place a marker on a unit (player, target, mouseover, etc.)
|
||||
- `/clearworldmarker` (or `/cwm`) -- remove all markers
|
||||
- `/clearworldmarker 2` -- remove a specific marker
|
||||
|
||||
Keybindings for placing each marker and clearing all markers are available in the Key Bindings menu.
|
||||
|
||||
Markers automatically sync with group members who also have WeirdUtils installed. When a leader/assist places or clears a marker, all group members see it. Markers persist across zone transitions and respawn when you return to the area.
|
||||
|
||||
Lua API for addon developers:
|
||||
|
||||
- `WorldMarker(index)` -- place marker at cursor (returns x,y,z,areaId on success, nil if no permission, -1 on failure)
|
||||
- `WorldMarker(index, "unit")` -- place marker at a unit's position
|
||||
- `WorldMarker(index, x, y, z)` -- place marker at world coordinates
|
||||
- `ClearWorldMarker(index)` / `ClearWorldMarker()` -- remove one or all markers (returns 1 on success, nil if no permission)
|
||||
- `GetWorldMarker(index)` -- returns x,y,z,areaId for an active marker, nil if empty
|
||||
- `CanSetWorldMarker()` -- returns 1 if the local player is party/raid leader or raid assist, nil otherwise
|
||||
|
||||
**DLL:** `worldmarkers.dll`
|
||||
|
||||
---
|
||||
|
||||
### Outlines
|
||||
|
||||
Renders glowing colored outlines around units, improving visibility in crowded encounters.
|
||||
|
||||
- `/outlines` or `/ol` -- toggle outlines on or off
|
||||
|
||||
A keybinding is available in the Key Bindings menu.
|
||||
|
||||
**DLL:** `outline.dll`
|
||||
|
||||
---
|
||||
|
||||
### Interact
|
||||
|
||||
Smart interaction helpers for faster farming and dungeon runs:
|
||||
|
||||
- **Interact Nearest** -- right-clicks the closest interactable NPC or object within 5 yards
|
||||
- **Loot All Corpses** -- bulk loots all nearby corpses in sequence
|
||||
|
||||
Best used via keybindings (available in the Key Bindings menu) or macros:
|
||||
```
|
||||
/run InteractNearest(1)
|
||||
/run LootAllCorpses()
|
||||
```
|
||||
|
||||
**DLL:** `interact.dll`
|
||||
|
||||
---
|
||||
|
||||
### PNG Screenshots
|
||||
|
||||
Saves screenshots as compressed PNG files instead of the default uncompressed TGA format. Runs on a background thread with no frame drops.
|
||||
|
||||
Controlled via the `screenshotQuality` CVar (saved to config.wtf):
|
||||
|
||||
- `/script SetCVar("screenshotQuality", "6")` -- set compression level (1 = fast, 9 = smallest, default 6)
|
||||
- `/script SetCVar("screenshotQuality", "0")` -- disable PNG, use original TGA format
|
||||
|
||||
**DLL:** `pngscreenshots.dll`
|
||||
|
||||
---
|
||||
|
||||
### Crash Fix
|
||||
|
||||
Prevents a class of crashes caused by stale UI frame anchor pointers. No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `framecrash.dll`
|
||||
|
||||
---
|
||||
|
||||
### Transmog Fix
|
||||
|
||||
Eliminates FPS drops caused by rapid equipment visual updates when transmogged items lose durability. No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `transmogfix.dll`
|
||||
|
||||
---
|
||||
|
||||
### Custom Data/ Assets
|
||||
|
||||
Enables loading loose game asset files (models, textures, etc.) from the `Data/` directory without repacking MPQ archives. Place files in `Data/` mirroring the game's internal paths (e.g. `Data/Character/Troll/Female/TrollFemale.m2`) and they will be used instead of the MPQ version.
|
||||
|
||||
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.
|
||||
|
||||
No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `customassets.dll`
|
||||
|
||||
---
|
||||
|
||||
### Utility Minimap Trackings
|
||||
|
||||
Adds TBC/WotLK-style minimap tracking icons for NPC types, game objects, and quest givers.
|
||||
Replaces the native tracking dropdown with a combined menu showing both spell tracking and NPC category tracking.
|
||||
Can be disabled from the normal AddOn menu. Preferences saved per-character.
|
||||
|
||||
- Click the minimap tracking icon to open the dropdown
|
||||
- Check/uncheck NPC categories to toggle their minimap icons
|
||||
- Spell tracking (Hunter tracking, Find Herbs, etc.) remains available alongside NPC tracking
|
||||
- "Hide in Cities" toggle suppresses NPC icons in capital cities
|
||||
|
||||
Tracks various npc types and useful objects like Oranges and Brainwasher and Mailbox.
|
||||
|
||||
**DLL:** `minimapicons.dll`
|
||||
|
||||
---
|
||||
|
||||
### Clickthrough
|
||||
|
||||
Smart cursor targeting that prioritizes useful interactions. Instead of always selecting the nearest object under the cursor, the module finds the most useful target along the ray in priority order.
|
||||
|
||||
- Lootable corpses first, then interactable game objects/portals, then interactable NPCs, then normal selection
|
||||
- Dead non-lootable corpses can still be selected when nothing more useful is behind them
|
||||
- Disabled inside battlegrounds to prevent targeting objectives through enemy players
|
||||
|
||||
Can replace SuperWoW's `Clickthrough()` toggle with always-on smart targeting that doesn't require a manual toggle and preserves the ability to select dead bodies when needed. Disable corpse-clickthrough in SuperAPI if you want this.
|
||||
|
||||
No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `clickthrough.dll`
|
||||
|
||||
---
|
||||
|
||||
### Log Sessions
|
||||
|
||||
Organizes the combat, raw combat, and chat logs into per-character, per-day files:
|
||||
|
||||
```
|
||||
Logs\<Realm>\<Character>\WoWChatLog_YYYY_MM_DD.txt
|
||||
Logs\<Realm>\<Character>\WoWCombatLog_YYYY_MM_DD.txt
|
||||
Logs\<Realm>\<Character>\WoWRawCombatLog_YYYY_MM_DD.txt (superwow only)
|
||||
```
|
||||
|
||||
Every character login begins with a marker line (`COMBATLOG_SESSION` or `CHAT_SESSION`) identifying the character and realm.
|
||||
If today's log file for that character already exists, it is appended to instead of creating a new one, so a day of play stays in one file even across multiple logins or `/reload`s.
|
||||
|
||||
Lua API for addon developers:
|
||||
|
||||
- `GetCombatLogPath()` -- returns the current combat log file path
|
||||
- `GetChatLogPath()` -- returns the current chat log file path
|
||||
|
||||
No other configuration needed, install and forget.
|
||||
|
||||
**DLL:** `logsessions.dll`
|
||||
|
||||
---
|
||||
|
||||
### DPSLog (Combat Log Events)
|
||||
|
||||
Provides WotLK 3.3.5-style `COMBAT_LOG_EVENT_UNFILTERED` for the vanilla client. Fires a single unified event with structured arguments instead of vanilla's fragmented localized text events. Enables modern DPS meter addons without expensive string parsing.
|
||||
|
||||
37 subevents covering all combat interactions: damage (spell, melee, periodic, environmental, damage shield, damage split), healing (direct, periodic, overheal tracking), misses (all types), auras (applied, removed, refreshed, broken, dose changes), casts (start, success, failed, interrupted), power (energize, drain, leech), dispels, extra attacks, deaths, and kills.
|
||||
|
||||
Each event includes source/destination GUIDs, names, unit flags, raid flags, spell info, and all WotLK-standard suffix fields. Booleans (critical, glancing, crushing) use WotLK semantics: `nil` for false, `"1"` for true. Names are `nil` when the client can't resolve the unit (despawned, out-of-range). Null GUIDs use `0x80000000` flags.
|
||||
|
||||
When `/combatlog` is active, also writes structured WotLK-style CSV to `Logs\WeirdCombatLog.txt` (replaces vanilla's combat log file with parseable data).
|
||||
|
||||
Also provides:
|
||||
|
||||
- `CombatLogGetCurrentEventInfo()` -- WotLK-style lazy arg retrieval (call from event handler):
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
|
||||
f:SetScript("OnEvent", function()
|
||||
local sub, srcGUID, srcName, srcFlags, srcRaidFlags,
|
||||
dstGUID, dstName, dstFlags, dstRaidFlags = CombatLogGetCurrentEventInfo()
|
||||
-- suffix args follow (spellId, spellName, etc.) -- see wiki for full layout
|
||||
end)
|
||||
```
|
||||
|
||||
- `GetSpellInfo(spellId)` -- the TBC/WotLK spell lookup API:
|
||||
|
||||
```lua
|
||||
local name, rank, icon, castTime, minRange, maxRange, spellId = GetSpellInfo(133)
|
||||
```
|
||||
|
||||
- `UnitCastingInfo("unit")` -- TBC/WotLK cast bar query (works on any visible unit):
|
||||
|
||||
```lua
|
||||
local name, rank, text, icon, startTime, endTime, isTradeSkill, castID, notInterruptible = UnitCastingInfo("target")
|
||||
if name then
|
||||
-- startTime/endTime are in milliseconds (compare with GetTime()*1000)
|
||||
end
|
||||
```
|
||||
|
||||
- `UnitChannelInfo("unit")` -- TBC/WotLK channel bar query:
|
||||
|
||||
```lua
|
||||
local name, rank, text, icon, startTime, endTime, isTradeSkill, notInterruptible = UnitChannelInfo("target")
|
||||
```
|
||||
|
||||
See the [DPSLog wiki page](https://codeberg.org/gwenael/WeirdUtils/wiki/DPSLog) for full event reference and addon developer guide.
|
||||
|
||||
**DLL:** `dpslog.dll`
|
||||
|
||||
---
|
||||
|
||||
### SuperWoW Heal Text Fix
|
||||
|
||||
Fixes duplicate floating heal numbers caused by SuperWoW 1.5. Only relevant if you use SuperWoW. No configuration needed, install and forget.
|
||||
|
||||
**DLL:** `healtextfix.dll`
|
||||
|
||||
---
|
||||
|
||||
### Big Cursor
|
||||
|
||||
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.
|
||||
|
||||
Lua API for addon developers:
|
||||
|
||||
- `SetCursorScale(n)` -- set scale factor (1.0-4.0), takes effect on next cursor change
|
||||
- `GetCursorScale()` -- returns current scale factor
|
||||
|
||||
**DLL:** `bigcursor.dll`
|
||||
|
||||
---
|
||||
|
||||
### Performance
|
||||
|
||||
Engine-level optimizations that reduce CPU time on math, rendering helpers, file lookups, and data decompression.
|
||||
|
||||
- **SIMD Math** -- replaces 20+ internal math functions with SSE/AVX equivalents covering skeletal animation, particle rendering, frustum culling, collision detection, text glyph caching, and float-to-integer conversion
|
||||
- **Data Decompression** -- swaps the game's 2004-era zlib with a modern library (2.2x faster). Loading screen times reduced by at least 13%
|
||||
- **MPQ File Cache** -- caches archive file lookups so repeat file opens skip the archive chain walk. Saving 50-160ms every 15 seconds during heavy gameplay
|
||||
- **Timer Calibration** -- recalibrates the client's RDTSC against the OS high-resolution counter for accurate animation timing, and raises the OS timer resolution to 0.5ms. Ported from [VanillaFixes](https://github.com/hannesmann/vanillafixes)
|
||||
- **Lua Runtime** -- custom slab allocator (O(1) free/realloc), incremental/generational GC (turns the ~1s stop-the-world freeze into ~9ms chunks), faster string interning (~40% on `luaS_newlstr`), and a literal prefilter on `string.find`/`gfind`/`gsub` that kills the O(n²) backtracking addon combat log parsers inflict on every chat message
|
||||
|
||||
Most noticeable in cities, raids, during zone transitions, and in addon-heavy setups.
|
||||
|
||||
**DLL:** `weirdperformance.dll`
|
||||
|
||||
---
|
||||
|
||||
## Source Code
|
||||
|
||||
This project is no longer actively developed. The full source is now published here,
|
||||
in the public domain (see `LICENSE`), so it can be forked, salvaged, or learned from
|
||||
rather than bit-rotting on a private disk.
|
||||
|
||||
Earlier releases shipped as pre-built DLLs only. That is no longer the case - the
|
||||
binaries on the releases page and the source in this repo are the same project.
|
||||
|
||||
Fair warning to anyone building on this: these DLLs hook deeply into the client's
|
||||
internals - memory layout, function addresses, rendering pipeline, input handling.
|
||||
Much of it is specific to 1.12.1 build 5875 and will not survive a different client
|
||||
build. Some modules (`interact`, `clickthrough`) intercept input and hit-testing;
|
||||
what you do with them on someone else's server is between you and that server's admins.
|
||||
|
||||
### Building
|
||||
|
||||
Requires Zig 0.16 and a checkout of [zhook](https://codeberg.org/marcelinevq/zhook)
|
||||
as a sibling directory - `build.zig.zon` refers to it by relative path:
|
||||
|
||||
```sh
|
||||
git clone https://codeberg.org/marcelinevq/zhook
|
||||
git clone https://codeberg.org/MarcelineVQ/WeirdUtils
|
||||
cd WeirdUtils
|
||||
zig build # zig-out/bin/weirdutils.dll
|
||||
zig build all-variants -Doptimize=ReleaseSmall # + one DLL per module
|
||||
```
|
||||
|
||||
Target is `x86-windows-msvc` (32-bit DLL); it is developed on Linux with the game
|
||||
running under Wine/DXVK. Per-module build flags are listed by `zig build --help`.
|
||||
|
||||
### Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| **Outline** | JFA-based screen-space outlines for targets, raid marks, dead players. See [src/outline/README.md](src/outline/README.md). |
|
||||
| **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. |
|
||||
| **DPSLog** | WotLK-style `COMBAT_LOG_EVENT` for vanilla 1.12.1. Fires structured events with full WotLK 3.3.5 CLEU parity: spell prefix (spellId, spellName, spellSchool), source/dest names, overkill, overheal, all damage/heal/miss/aura/cast subevents. Includes `GetSpellInfo(spellId)` Lua API. Embedded tracker addon. See [wiki/DPSLog.md](wiki/DPSLog.md). |
|
||||
| **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. |
|
||||
| **Lua Protection Bypass** | Stubs the Lua callback address validator to allow C function registration. |
|
||||
| `src/main.zig` | DLL entry, module init, Lua API registration |
|
||||
| `src/<module>/` | One directory per feature module; embedded addons live in `addon/` subdirs |
|
||||
| `src/offsets.zig`, `src/wow.zig` | Client addresses and memory access wrappers |
|
||||
| `build.zig` | Module list, versions, per-module build flags, variant DLL generation |
|
||||
| `docs/`, `src/*/RESEARCH.md` | Reverse-engineering notes for the subsystems being hooked |
|
||||
|
||||
## Consolidation Plan
|
||||
`src/dpslog/WeirdDPSMate/` is a fork of DPSMate and stays under GPL-3.0 - see its
|
||||
own `LICENSE`. Everything else is unlicensed/public domain.
|
||||
|
||||
WeirdUtils replaces the standalone utility DLLs in the parent directory. All
|
||||
development happens here - shared code, shared hooking infrastructure, one build
|
||||
system. The standalone DLLs are being retired.
|
||||
---
|
||||
|
||||
| Standalone DLL | Purpose | Integration Status |
|
||||
## Developer Notes
|
||||
### Runtime Module Control API
|
||||
|
||||
WeirdUtils exports three functions for querying and disabling modules at runtime in case other devs find their dll's in conflict.
|
||||
|
||||
#### Exported Functions
|
||||
|
||||
| Function | Signature | Description |
|
||||
|---|---|---|
|
||||
| `../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 |
|
||||
| `../interact` | Nearest interact + bulk loot | Partially integrated |
|
||||
| `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 |
|
||||
|
||||
### Compile-Time Feature Gating
|
||||
Module names are case-insensitive and match the released dll names:
|
||||
|
||||
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.
|
||||
Users can pick the full package or grab only the features they want.
|
||||
`customassets`, `framecrash`, `logsessions`, `transmogfix`, `minimapicons`, `healtextfix`, `bigcursor`, `worldmarkers`, `interact`, `outline`, `pngscreenshots`, `clickthrough`, `dpslog`, `weirdperformance`
|
||||
|
||||
```zig
|
||||
// build.zig options (planned)
|
||||
const enable_customassets = b.option(bool, "customassets", "Enable loose file loading & permissive patch glob") orelse true;
|
||||
const enable_transmogfix = b.option(bool, "transmogfix", "Enable transmog coalesce fix") orelse true;
|
||||
const enable_interact = b.option(bool, "interact", "Enable interact helpers") orelse true;
|
||||
const enable_outline = b.option(bool, "outline", "Enable outline rendering") orelse true;
|
||||
There is no re-enable API.
|
||||
|
||||
#### 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");
|
||||
```
|
||||
|
||||
```sh
|
||||
# Full build - all features in one DLL
|
||||
zig build
|
||||
The header tries all known DLL names (`weirdutils.dll`, `worldmarkers.dll`, etc.) via `GetModuleHandleA`, so it works regardless of which DLL variant is loaded.
|
||||
|
||||
# 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.
|
||||
```
|
||||
#### Raw GetProcAddress
|
||||
|
||||
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
|
||||
If you prefer not to use the header:
|
||||
|
||||
All built from this repo, all sharing the same hook library and codebase.
|
||||
|
||||
### Per-Feature Named Mutex
|
||||
|
||||
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
|
||||
it's already held, that module skips hook installation. This way any combination
|
||||
of DLLs coexists safely with no duplicate hooks.
|
||||
|
||||
```zig
|
||||
// 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
|
||||
CloseHandle(mutex);
|
||||
return;
|
||||
```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");
|
||||
}
|
||||
}
|
||||
// First to load wins - install hooks
|
||||
```
|
||||
|
||||
This is per-feature, not per-DLL. The full DLL claims one mutex per enabled
|
||||
feature. A single-feature DLL claims one mutex. Whichever loads first owns the
|
||||
hooks; the duplicate gracefully becomes a no-op.
|
||||
### Version Query API
|
||||
|
||||
## Planned: Ground-Projected Markers
|
||||
WeirdUtils registers a Lua global table and query function for addon developers to detect which modules are loaded and their versions. Available from the login screen onward.
|
||||
|
||||
World-space markers projected onto terrain, similar to raid markers but driven
|
||||
programmatically. Use cases:
|
||||
#### `GetWeirdUtilsVersion()`
|
||||
|
||||
- Visual range indicators (spell range circles, aggro radius)
|
||||
- Waypoint markers for navigation
|
||||
- Area-of-effect visualization
|
||||
- Custom raid positioning markers
|
||||
Returns the `WeirdUtils` table containing all enabled modules and their version strings:
|
||||
|
||||
Implementation will require:
|
||||
- Projecting screen-space or world-space coordinates onto the terrain mesh
|
||||
- Rendering textured quads or circles that conform to terrain height
|
||||
- Integration with the D3D9 hook pipeline (rendered during EndScene or as
|
||||
additional geometry injected into the scene)
|
||||
|
||||
## Distribution
|
||||
|
||||
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.
|
||||
|
||||
- **This repo** (private): all source, development, docs
|
||||
- **Public repo** (e.g. `WeirdUtils`): README with feature descriptions +
|
||||
GitHub Releases with DLL downloads
|
||||
|
||||
Release workflow:
|
||||
```sh
|
||||
# Build all variants from this repo
|
||||
zig build # weirdutils.dll (full)
|
||||
zig build -Doutline=true -Deverything-else=false # outline.dll
|
||||
# ... etc for each single-feature build
|
||||
|
||||
# Publish to the public repo
|
||||
gh release create v1.0 --repo YourName/WeirdUtils \
|
||||
--title "v1.0" --notes "Release notes" \
|
||||
./zig-out/lib/weirdutils.dll \
|
||||
./builds/outline.dll \
|
||||
./builds/customassets.dll
|
||||
```lua
|
||||
local modules = GetWeirdUtilsVersion()
|
||||
for name, version in pairs(modules) do
|
||||
print(name .. " v" .. version) -- e.g. "dpslog v1.0"
|
||||
end
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
#### `GetWeirdUtilsVersion("modulename")`
|
||||
|
||||
```
|
||||
weirdutils/
|
||||
build.zig Build configuration
|
||||
src/
|
||||
main.zig DLL entry, Lua API, file I/O hook, embedded addon
|
||||
screenshot.zig Screenshot capture hook
|
||||
interact.zig Interact + loot helpers
|
||||
png.zig PNG encoding for screenshots
|
||||
outline/ Outline subsystem (see src/outline/README.md)
|
||||
api.zig Public API, Lua command handler
|
||||
d3d9_hook.zig D3D9 vtable hooks, JFA pipeline, shaders
|
||||
model_hook.zig M2 batch reordering, rendering_outline flag
|
||||
tracker.zig Per-frame object/model tracking
|
||||
types.zig D3D9 constants, outline colors, categories
|
||||
offsets.zig WoW memory addresses and struct offsets
|
||||
wow.zig Game memory access wrappers
|
||||
addon/ Embedded addon files (.toc, .lua, .xml)
|
||||
libs/
|
||||
hook/ Shared x86 inline hooking library (trampoline, fastcall thunks)
|
||||
docs/ Design docs, research notes, shader analysis
|
||||
reference/ C reference implementations
|
||||
Returns the version string for a specific module, or `nil` if not loaded:
|
||||
|
||||
```lua
|
||||
if GetWeirdUtilsVersion("dpslog") then
|
||||
-- DPSLog is available, register for COMBAT_LOG_EVENT_UNFILTERED
|
||||
end
|
||||
|
||||
local ver = GetWeirdUtilsVersion("minimapicons") -- "1.0" or nil
|
||||
```
|
||||
|
||||
## Build
|
||||
The `WeirdUtils` table is additive -- if multiple independent DLLs are loaded (e.g. `dpslog.dll` and `minimapicons.dll` separately), each adds its own modules to the shared table.
|
||||
|
||||
```sh
|
||||
cd /media/storage/projects/zig/weirdutils
|
||||
zig build
|
||||
```
|
||||
---
|
||||
|
||||
Target: x86-windows-msvc (32-bit DLL), Zig 0.16 (patched: fastcall inreg fix).
|
||||
Host: Linux (Arch), game runs via Wine/DXVK.
|
||||
### Module Mutexes
|
||||
|
||||
## Hook Installation Order
|
||||
Each module also 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.
|
||||
|
||||
Hooks are installed in a specific sequence to handle dependencies:
|
||||
|
||||
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)
|
||||
|
||||
D3D9 hooks are deferred because creating a dummy device during engine init
|
||||
corrupts the d3d9 proxy's state.
|
||||
If you see the mutex, the module is loaded - and can use the Runtime Module Control API to disable it. If you don't see it, the module isn't active and you're free to hook those functions yourself.
|
||||
|
||||
@@ -36,111 +36,3 @@ Track notable changes here between releases. Clear this file when cutting a new
|
||||
minimap. Reads the per-player quest status cached on unit objects by the client's
|
||||
SMSG_QUESTGIVER_STATUS handler. Enabled by default in the tracking dropdown, togglable
|
||||
like other NPC tracking categories.
|
||||
|
||||
## Enhancements
|
||||
|
||||
- **Clickthrough**: Lootable corpses now click-through over players. Previously only
|
||||
interactable NPCs (vendors, quest givers) and game objects would win over a blocking
|
||||
player. Now dead units with the lootable flag are also prioritized, so you can loot
|
||||
corpses through the player crowd without having to reposition.
|
||||
|
||||
- **World Markers**: Disabled in battlegrounds via Map.dbc mapType check. Markers cannot
|
||||
be placed while in a battleground instance.
|
||||
|
||||
- **Clickthrough**: Reduced log noise -- logging changed from file to console-only.
|
||||
Also ignores assets_backup directories in custom asset scanning.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **World Markers**: Fixed marker placement over game objects -- re-raycast now uses
|
||||
terrain-only flags to find the ground position beneath a GO, instead of placing the
|
||||
marker at the GO's collision point.
|
||||
|
||||
- **DPSLog**: Fixed dynamic event slot search and environmental damage parameter ordering.
|
||||
|
||||
- **Addon System**: Fixed addon files not loading -- switched from @hasField to @hasDecl
|
||||
for compile-time module introspection.
|
||||
|
||||
## Internal / Performance
|
||||
|
||||
- **Transform44 Profiling** - render pipeline profiling module with 39 hotspot hooks,
|
||||
A/B testing framework for comparing baseline vs optimized code paths, SSE replacements
|
||||
for ClipPolygonToSinglePlane (4x), BuildTrianglePlanes (10x), rotateMatrixByAxisAngle
|
||||
(4.3x), RayTriangleIntersection (1.3x), multiplyMatrix4x4 (4.3x).
|
||||
|
||||
- **VanillaFixes Math Polyfill** - 17 UnitXP x87 FPU replacement hooks verified via
|
||||
Ghidra prologue/epilogue disassembly and benchmarked against original WoW.exe bytes.
|
||||
Hooks install in lateInit() to clobber UnitXP's hooks with correct calling conventions
|
||||
(thiscall vs fastcall verified from assembly). A/B tested via bitmask toggle.
|
||||
|
||||
Micro-benchmark results (x86 Linux harness, original x87 bytes mmap'd executable):
|
||||
```
|
||||
Winners (SSE faster): Neutral (~1.0x): Losers (x87 faster):
|
||||
rotMat3x3: 2.1x (158->72) matMulVec3: 1.0x dotProduct: 0.4x (5->11)
|
||||
rotMat4x4: 2.1x (162->76) multiply3x3: 1.0x evaluatePolynomial: 0.6x (11->16)
|
||||
planeNormal: 1.7x (58->33) crossProduct: 1.0x squaredMagnitude: 0.7x (7->9)
|
||||
transformAABox: 1.2x (89->69) applyTranslation: 0.9x vec3MulScalar: 0.8x
|
||||
vecMulMat4: 1.1x scaleByVec: 0.9x vec3MulAssign: 0.8x
|
||||
scaleByScalar: 1.1x (some runs) quatMulMat4: 0.8x
|
||||
```
|
||||
Losers are at function call overhead floor (original x87 is 5-11 cycles, close to
|
||||
bare CALL/RET cost). Future direction: patch original bytes in-place at load time
|
||||
to eliminate call overhead entirely.
|
||||
|
||||
Also includes CriticalSection SpinCount=4000 optimization (from UnitXP) and
|
||||
blit_hub memcpy fast paths for matching pixel formats.
|
||||
|
||||
- **Math SSE Benchmark Harness** (`zig build bench` / `zig build run-bench`) - standalone
|
||||
x86 Linux micro-benchmark that extracts original x87 function bytes from WoW.exe via
|
||||
Ghidra, mmaps them executable, and profiles against our SSE replacements. Fresh data
|
||||
each iteration to avoid overflow/denormal artifacts. Correctness validation included.
|
||||
|
||||
- **Glyph Shadow Cache** - direct-mapped O(1) bypass for the game's 4-bucket hash table
|
||||
in GetOrCreateCharacterGlyph. Reduces glyph lookup from ~3.65% frame time.
|
||||
|
||||
- **MPQ File Cache (filecache)** - 2-way set-associative cache for File_FindInArchive
|
||||
(0x6549a0). Skips the MPQ chain walk and per-archive hash table probe on repeat file
|
||||
opens. Cache hits cost ~1000 cycles vs ~30000 cycles for full search. 60-90% hit rate
|
||||
during gameplay, saving 6-160ms per 15s reporting period depending on scene load.
|
||||
Validated via game's own FindAndIncrementResourceReference to handle archive lifecycle.
|
||||
|
||||
- **Timer Fix (VanillaFixes port)** - TSC calibration, OS timer resolution (0.5ms via
|
||||
NtSetTimerResolution), and Windows 11 power throttling disable. Ported from
|
||||
hannesmann/vanillafixes. Primarily benefits native Windows; no measurable impact on
|
||||
Wine/Linux but applied unconditionally.
|
||||
|
||||
- **MPQ File Cache** - hooks File_FindInArchive (0x6549a0) to cache archive lookup
|
||||
results. First open does full MPQ chain walk (~60K cycles), subsequent opens hit
|
||||
direct-mapped cache with filename verification (~300 cycles). 80% hit rate in testing.
|
||||
Moved from standalone file_perf module to transform44 sub-module (file_cache.zig).
|
||||
|
||||
- **Logging Module** - centralized logging with auto-prefix, file and/or console routing.
|
||||
All modules now route output through the shared Logger.
|
||||
|
||||
- **Addon System Refactor** - module list now derived from build.zig, inactive addon
|
||||
prefixes pruned at runtime. Fully data-driven.
|
||||
|
||||
- **Shared Offsets** - consolidated shared game offsets and accessor functions into
|
||||
offsets.zig and wow.zig, eliminating per-module duplication.
|
||||
|
||||
- **Build System** - default optimize changed from Debug to ReleaseFast (works around
|
||||
Zig fastcall inreg LLVM bug). Logging available in all modes except ReleaseSmall.
|
||||
|
||||
## To Explore
|
||||
|
||||
- **Driver env vars on load** - set environment variables like `RADV_TEX_ANISO=16` from
|
||||
the DLL at load time, allowing driver-level anisotropic filtering while setting the
|
||||
in-game option to off/low. Avoids the double-filtering performance hit of game AF
|
||||
stacked on top of driver AF. Same approach could apply to other Mesa/RADV/DXVK knobs.
|
||||
|
||||
## DLL_README Gaps
|
||||
|
||||
Features documented in DLL_README but never included in a release:
|
||||
- World Markers
|
||||
- Outlines
|
||||
- Interact
|
||||
- Crash Fix (framecrash)
|
||||
|
||||
Features implemented but not yet documented in DLL_README:
|
||||
- Clickthrough lootable corpse priority
|
||||
- World Markers battleground restriction
|
||||
|
||||
+33
-30
@@ -4,15 +4,25 @@
|
||||
|
||||
## 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.
|
||||
`main` on the remote holds the full source, the user-facing `README.md`,
|
||||
`include/weirdutils_api.h`, and issue templates under `.gitea/`. Releases
|
||||
attach pre-built DLLs to a tag on `main`.
|
||||
|
||||
The remote `main` branch contains `README.md` (built from the local
|
||||
`DLL_README.md`), `weirdutils_api.h`, and issue templates under `.gitea/`.
|
||||
This was previously a distribution-only remote - source lived locally and only
|
||||
a trimmed README plus binaries were pushed. That is no longer the case; develop
|
||||
against `main` and push directly.
|
||||
|
||||
A local clone of the remote repo lives at `remote/WeirdUtils/`. The wiki
|
||||
lives at `remote/wiki/`. Use these for all remote operations - no tmp clones
|
||||
needed.
|
||||
The wiki clone lives at `remote/wiki/`. `remote/WeirdUtils/` is the old
|
||||
distribution-only clone and is obsolete - it can be deleted.
|
||||
|
||||
## 0. Sync tags
|
||||
|
||||
`tea` creates tags on Codeberg, so fetch them before comparing against the
|
||||
previous release:
|
||||
|
||||
```sh
|
||||
git fetch --tags
|
||||
```
|
||||
|
||||
## 1. Bump module versions
|
||||
|
||||
@@ -63,28 +73,20 @@ Verify:
|
||||
ls -lh zig-out/bin/weirdutils.dll zig-out/variants/*.dll
|
||||
```
|
||||
|
||||
## 3. Update the remote README
|
||||
## 3. Update the 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 -
|
||||
keep the header, install instructions, and included feature sections exactly
|
||||
as they are. Do NOT remove trailing spaces in `DLL_README.md` - they are
|
||||
intentional Markdown line breaks.
|
||||
`README.md` documents every module, not just the released subset - the source
|
||||
is public, so there is nothing to trim. Add a section for any new module.
|
||||
|
||||
The module name list in the Developer Notes section must also only list
|
||||
released module names.
|
||||
|
||||
```sh
|
||||
cd remote/WeirdUtils
|
||||
# edit README.md: remove sections for modules not in this release
|
||||
git add README.md
|
||||
git commit -m "Update README for vX.Y.Z"
|
||||
git push origin main
|
||||
cd ../..
|
||||
```
|
||||
Do NOT remove trailing spaces in `README.md` - they are intentional Markdown
|
||||
line breaks.
|
||||
|
||||
## 4. Write the release notes
|
||||
|
||||
Write the release notes to `/tmp/release-notes-vX.Y.Z.md` - NOT inside the
|
||||
project directory. They are a transient artifact consumed by `tea --note-file`
|
||||
and should not pollute the repo root or end up in git status.
|
||||
|
||||
Use this template - fill in the sections that apply, delete the rest.
|
||||
Use `-` (not em dash) anywhere a dash would be used.
|
||||
|
||||
@@ -147,11 +149,12 @@ tea release assets delete --repo MarcelineVQ/WeirdUtils -y v0.4.0 minimapicons.d
|
||||
tea release assets create --repo MarcelineVQ/WeirdUtils v0.4.0 zig-out/variants/minimapicons.dll
|
||||
```
|
||||
|
||||
## 6. Hide source archives
|
||||
## 6. Source archives
|
||||
|
||||
Codeberg attaches empty source tar/zip by default. Hide them via API:
|
||||
Codeberg attaches source tar/zip to each release automatically. Now that the
|
||||
repo contains the source, leave them visible - they are a useful artifact.
|
||||
|
||||
Get your token from the tea config:
|
||||
To hide them anyway, get your token from the tea config:
|
||||
|
||||
```sh
|
||||
grep 'token:' ~/.config/tea/config.yml | head -1 | awk '{print $2}'
|
||||
@@ -185,10 +188,10 @@ print(r[0]['id']) if r else print('not found')
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Remote clone tags synced (`cd remote/WeirdUtils && git fetch --tags`)
|
||||
- [ ] Module versions bumped in `build.zig` for changed modules
|
||||
- [ ] Check `RELEASE_NOTES.md` for unreleased changes — move into release notes
|
||||
- [ ] Built with `ReleaseSmall` (both default and `all-variants`)
|
||||
- [ ] Remote README updated — no unreleased module sections or names
|
||||
- [ ] `weirdutils_api.h` trimmed to released DLL names only and uploaded
|
||||
- [ ] `README.md` has a section for any new module
|
||||
- [ ] `include/weirdutils_api.h` lists all module DLL names
|
||||
- [ ] Release created and DLLs uploaded via `tea`
|
||||
- [ ] Source archives hidden
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
- [x] Setup Atelier #atelier
|
||||
|
||||
Atelier helps you manage AI coding tasks, plans, and TODOs in a visual Kanban board.
|
||||
|
||||
**Getting Started:**
|
||||
- Click **Setup** to configure Atelier with recommended defaults
|
||||
- Or click **Customize** to choose your own settings
|
||||
- Drag items between columns to update their status
|
||||
- Click an item to view and edit its content
|
||||
[Full documentation](https://atelier.dev)
|
||||
[Setup](vscode://Atelier.atelier/customize?agent=claude\&defaults=true) [Customize](vscode://Atelier.atelier/customize?agent=claude)
|
||||
- [ ] [add scaling to minimapicons](/home/august/.atelier/projects/-media-storage-projects-zig-weirdutils/plans/minimap-icon-scaling.md)
|
||||
- [ ] [Investigate the functions behind wow.exe lua string.find and find out how to optimize them as they seem to be extremely expensive for the work they do, at least when the game is ran via wine](/home/august/.atelier/projects/-media-storage-projects-zig-weirdutils/plans/lua-string-pattern-optimization.md)
|
||||
- [ ] allow dead player-pets to be selectable in clikthrough
|
||||
- [x] adjust our basic settings for atelier to use opus for work as the larger context window is useful
|
||||
@@ -66,6 +66,12 @@ pub fn build(b: *std.Build) void {
|
||||
|
||||
const build_options = b.addOptions();
|
||||
addModuleOptionsFromArray(b, build_options, &module_enabled);
|
||||
// transform_capture: records game-x87 transformMatrix4x4 state to disk for
|
||||
// offline bench parity. Mutually exclusive with bone_sse64 (capture needs
|
||||
// the real game output; bone_sse64 replaces it).
|
||||
const transform_capture_enabled = b.option(bool, "transform_capture",
|
||||
"Record transformMatrix4x4 inputs/outputs to disk (disables bone_sse64)") orelse false;
|
||||
build_options.addOption(bool, "transform_capture", transform_capture_enabled);
|
||||
const build_options_module = build_options.createModule();
|
||||
|
||||
const zhook_dep = b.dependency("zhook", .{
|
||||
@@ -281,7 +287,10 @@ pub fn build(b: *std.Build) void {
|
||||
});
|
||||
bench.root_module.addObject(bench_math_sse);
|
||||
bench.root_module.addObject(bench_silicon_sse);
|
||||
bench.root_module.addObject(bench_bone_sse);
|
||||
// bench_bone_sse64 imports bone_sse, so it provides both transformImpl_SSE
|
||||
// and transformImpl_SSE64 symbols. Linking bench_bone_sse too would cause
|
||||
// duplicate-symbol errors.
|
||||
_ = bench_bone_sse;
|
||||
bench.root_module.addObject(bench_bone_sse64);
|
||||
bench.root_module.addObject(bench_bone_baseline);
|
||||
bench.root_module.addObject(bench_particle_sse);
|
||||
|
||||
+31
-11
@@ -128,14 +128,25 @@ fn mapWowSections() bool {
|
||||
// Map additional pages for runtime constants that live outside .rdata:
|
||||
// 0x80C000-0x813000 covers 0x80C5C8 (billboard epsilon) and 0x811610 (SHORT_TO_FLOAT)
|
||||
// 0xCF0000-0xCF1000 covers 0xCF04C4 (boneKeyframe init flag) and 0xCF043C (pivot constants)
|
||||
// 0x876000-0x877000 covers 0x876504 (matrix-multiply dispatch table — statically
|
||||
// holds 0x74A7AD initializer; we write 0x74A7C6 directly to skip CPUID init)
|
||||
_ = mapZeroed(0x80C000, 0x8000); // covers 0x80C000-0x814000
|
||||
_ = mapZeroed(0xCF0000, 0x1000); // covers 0xCF0000-0xCF1000
|
||||
_ = mapZeroed(0x876000, 0x1000); // covers 0x876504 dispatch table (real-game-bytes bench)
|
||||
// Write runtime constant values
|
||||
@as(*align(1) u32, @ptrFromInt(0x811610)).* = 0x38000100; // SHORT_TO_FLOAT ~1/32767
|
||||
@as(*align(1) u32, @ptrFromInt(0x8029D4)).* = 0x34800000; // billboard epsilon
|
||||
@as(*align(1) u32, @ptrFromInt(0x80C5C8)).* = 0x35800000; // billboard sq epsilon
|
||||
@as(*align(1) u32, @ptrFromInt(0x80297C)).* = 0x40400000; // 3.0
|
||||
@as(*align(1) u32, @ptrFromInt(0x802990)).* = 0x40C00000; // 6.0
|
||||
// NOTE: benching against the real game x87 function at 0x714260 requires
|
||||
// initialising several dispatch tables in .data (0x876504, 0x876594, ...)
|
||||
// that select x87 vs SSE math implementations based on CPUID. Running the
|
||||
// game's init at 0x74FB49 pulls in heap/init dependencies we don't satisfy.
|
||||
// For now, the bench uses the Zig reimpl baseline (transformImpl_BASELINE).
|
||||
// Real-game-bytes parity is better done in-game via a hook diff.
|
||||
@as(*align(1) u32, @ptrFromInt(0x876504)).* = 0x0074A7C6;
|
||||
print(" [debug] *(0x876504) = 0x{x}\n", .{@as(*align(1) u32, @ptrFromInt(0x876504)).*});
|
||||
sections_mapped = true;
|
||||
return true;
|
||||
}
|
||||
@@ -977,6 +988,8 @@ pub fn main() void {
|
||||
report("__ftol", t_best, s_best, mismatches == 0);
|
||||
}
|
||||
|
||||
} // close outer disabled block so transform44 runs standalone
|
||||
|
||||
// =========================================================================
|
||||
// transform44: SSE implementation benchmark — comprehensive fixture
|
||||
// Exercises: bone loop (rot/trans/scale/static/billboard), texAnim,
|
||||
@@ -1663,7 +1676,11 @@ pub fn main() void {
|
||||
const sb: u32 = @bitCast(@as(f32, 1.0));
|
||||
const transformImpl_SSE = @extern(*const fn (u32, u32, u32, u32, u32) callconv(.{ .x86_thiscall = .{} }) void, .{ .name = "transformImpl_SSE" });
|
||||
const transformImpl_SSE64 = @extern(*const fn (u32, u32, u32, u32, u32) callconv(.{ .x86_thiscall = .{} }) void, .{ .name = "transformImpl_SSE64" });
|
||||
const transformImpl_BASELINE = @extern(*const fn (u32, u32, u32, u32, u32) callconv(.c) void, .{ .name = "transformImpl_BASELINE" });
|
||||
// Baseline: Zig reimplementation (bit-identical to bone_sse by construction).
|
||||
// Attempting to call the real game function at 0x714260 requires running
|
||||
// game init code (CPUID-based dispatch population, possibly heap setup)
|
||||
// that isn't feasible from this isolated bench fixture. See mapWowSections.
|
||||
const transformImpl_BASELINE = @extern(*const fn (u32, u32, u32, u32, u32) callconv(.{ .x86_thiscall = .{} }) void, .{ .name = "transformImpl_BASELINE" });
|
||||
|
||||
// Pre-set boneKeyframe init flag so we skip the atexit call (Windows CRT, can't run on Linux)
|
||||
@as(*u8, @ptrFromInt(0xCF04C4)).* = 1;
|
||||
@@ -1687,7 +1704,7 @@ pub fn main() void {
|
||||
|
||||
// --- Benchmark both BASELINE and SSE ---
|
||||
const run_bench_fn = struct {
|
||||
fn run(func: *const fn (u32, u32, u32, u32, u32) callconv(.c) void, so2: u32, pm: u32, pp: u32, po: u32, sb2: u32, scene: *[0x400]u8, actx: *[0x20]u8, iters: u32) u64 {
|
||||
fn run(func: *const fn (u32, u32, u32, u32, u32) callconv(.{ .x86_thiscall = .{} }) void, so2: u32, pm: u32, pp: u32, po: u32, sb2: u32, scene: *[0x400]u8, actx: *[0x20]u8, iters: u32) u64 {
|
||||
var best_inner: u64 = std.math.maxInt(u64);
|
||||
for (0..5) |_| {
|
||||
const t = rdtsc();
|
||||
@@ -1714,6 +1731,9 @@ pub fn main() void {
|
||||
const pp = @intFromPtr(&pos);
|
||||
const po = @intFromPtr(&ofs);
|
||||
|
||||
const best_base = run_bench_fn(transformImpl_BASELINE, so, pm, pp, po, sb, &scene_obj, &anim_ctx_mem, T44_ITERS);
|
||||
const avg_base = best_base / T44_ITERS;
|
||||
|
||||
const best_sse = run_bench_fn(transformImpl_SSE, so, pm, pp, po, sb, &scene_obj, &anim_ctx_mem, T44_ITERS);
|
||||
const avg_sse = best_sse / T44_ITERS;
|
||||
|
||||
@@ -1731,7 +1751,7 @@ pub fn main() void {
|
||||
const best_sse64 = run_bench_fn(transformImpl_SSE64, so, pm, pp, po, sb, &scene_obj, &anim_ctx_mem, T44_ITERS);
|
||||
const avg_sse64 = best_sse64 / T44_ITERS;
|
||||
|
||||
print(" BASELINE: {d} cycles/call (frozen)\n", .{BASELINE_CYCLES});
|
||||
print(" BASELINE: {d} cycles/call (frozen ref {d})\n", .{ avg_base, BASELINE_CYCLES });
|
||||
print(" SSE: {d} cycles/call", .{avg_sse});
|
||||
if (avg_sse < BASELINE_CYCLES) {
|
||||
const pct = (BASELINE_CYCLES - avg_sse) * 100 / BASELINE_CYCLES;
|
||||
@@ -1782,7 +1802,7 @@ pub fn main() void {
|
||||
};
|
||||
|
||||
const reset_and_run = struct {
|
||||
fn go(func: *const fn (u32, u32, u32, u32, u32) callconv(.c) void, so3: u32, pm3: u32, pp3: u32, po3: u32, sb3: u32, scene3: *[0x400]u8, actx3: *[0x20]u8, brt3: [*]u8, bc: usize) void {
|
||||
fn go(func: *const fn (u32, u32, u32, u32, u32) callconv(.{ .x86_thiscall = .{} }) void, so3: u32, pm3: u32, pp3: u32, po3: u32, sb3: u32, scene3: *[0x400]u8, actx3: *[0x20]u8, brt3: [*]u8, bc: usize) void {
|
||||
wu(u32, actx3[0x0C..0x10], 500, .little);
|
||||
wu(u32, scene3[0x40..0x44], 0, .little);
|
||||
// Re-init bone_rt anim_slot/sec_slot fields
|
||||
@@ -1849,14 +1869,14 @@ pub fn main() void {
|
||||
// calcColorValues_SSE -- disabled: no standalone SSE export yet
|
||||
// bench_calcColorValues();
|
||||
|
||||
// si_frustumCullBBox -- fastcall(bbox_ECX, flags_EDX, radius_stack) -> u32
|
||||
bench_frustumCullBBox();
|
||||
if (false) { // disabled: pulls in silicon_sse exports not linked into bench
|
||||
// si_frustumCullBBox -- fastcall(bbox_ECX, flags_EDX, radius_stack) -> u32
|
||||
bench_frustumCullBBox();
|
||||
|
||||
// si_processLinkedListCollision -- fastcall(listHead_ECX, queryBox_EDX, resultBuf_stack, flags_stack) -> u32
|
||||
// Builds a fake linked list with 8 nodes to benchmark AABB overlap test.
|
||||
bench_processLinkedListCollision();
|
||||
|
||||
} // end disabled block
|
||||
// si_processLinkedListCollision -- fastcall(listHead_ECX, queryBox_EDX, resultBuf_stack, flags_stack) -> u32
|
||||
// Builds a fake linked list with 8 nodes to benchmark AABB overlap test.
|
||||
bench_processLinkedListCollision();
|
||||
}
|
||||
|
||||
print("\n", .{});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# SuperWoWhook + Timber UnitBuff Crash
|
||||
|
||||
## Bug
|
||||
|
||||
SuperWoWhook.dll crashes at DLL offset 0x1688 (`MOVZX ESI, word [ESI+EAX*2]`)
|
||||
when UnitBuff is called for another player who has no buff in the queried slot.
|
||||
Not a WeirdUtils or Timber bug -- root cause is a data error in SuperWoWhook's
|
||||
inline patching template.
|
||||
|
||||
## Root Cause
|
||||
|
||||
SuperWoWhook's `SuperWoW_BuildUnitBuffHook` (0x10002460) patches UnitBuff's
|
||||
return epilogues with CALL instructions that redirect to DLL formatter blocks.
|
||||
Each CALL site has a paired return-landing patch that rewrites the bytes after
|
||||
the CALL with proper register-restore code (POP EDI; POP ESI; POP EBX; ...).
|
||||
|
||||
Template entries 88-103 contain 8 CALL+landing pairs. All 7 first pairs follow
|
||||
the pattern `landing = CALL_addr + 5`:
|
||||
|
||||
| Pair | CALL addr | Landing | OK? |
|
||||
|------|------------|------------|----------------|
|
||||
| 0 | 0x519AF1 | 0x519AF6 | +5, correct |
|
||||
| 1 | 0x519B19 | 0x519B1E | +5, correct |
|
||||
| 2 | 0x519C48 | 0x519C4D | +5, correct |
|
||||
| 3 | 0x519C6D | 0x519C72 | +5, correct |
|
||||
| 4 | 0x519708 | 0x51970D | +5, correct |
|
||||
| 5 | 0x519729 | 0x51972E | +5, correct |
|
||||
| 6 | 0x51981D | 0x519822 | +5, correct |
|
||||
| **7**| **0x51983B** | **0x519830** | **-16, WRONG** |
|
||||
|
||||
Pair 7's landing is at 0x519830 (should be 0x519840). The template dword at
|
||||
`0x1001B240 + 103*4 = 0x1001B3DC` contains `0x00519830` instead of `0x00519840`.
|
||||
|
||||
### What happens
|
||||
|
||||
The "other player no buff" path (JE at 0x519803 taken -> 0x519829):
|
||||
|
||||
1. `lua_pushnil` called at 0x51982B, returns to 0x519830
|
||||
2. Code at 0x519830 was patched with `5F 5E 5B 8B` (meant for 0x519840)
|
||||
3. This creates: `POP EDI; POP ESI; POP EBX; MOV EBP,[EDX+0]` -- the `8B` from
|
||||
the patch combines with `6A 00` from the original code to form `MOV EBP,[EDX]`
|
||||
4. EBP is overwritten with whatever EDX points to (addon string data: "AltA")
|
||||
5. `lua_pushnumber` called at 0x519836 with shifted stack
|
||||
6. CALL at 0x51983B jumps to formatter `UnitBuff_ReturnFieldU16_3vals` (0x10001680)
|
||||
7. Formatter reads `[EBP-0x10]` with EBP = 0x616C7441 -> ACCESS_VIOLATION
|
||||
|
||||
Meanwhile, 0x519840 (the actual CALL return address) is never patched and has
|
||||
`00 00 5B 8B E5 5D C3` -- `ADD [EAX],AL` would crash even if the formatter survived.
|
||||
|
||||
### Why intermittent
|
||||
|
||||
The crash only triggers when:
|
||||
- Querying buffs on another player (not self) -- self uses Timber's TW path
|
||||
- That player has no buff in the queried slot -- triggers the "no buff" JE path
|
||||
- EDX happens to point to readable memory -- if [EDX] faults, different crash site
|
||||
|
||||
### Why Timber-specific reports
|
||||
|
||||
- On vanilla, the same bug exists but is less visible: the "other player" path is
|
||||
rarely exercised by addons compared to the "self" path
|
||||
- On Timber, the "self" path is redirected through TW code (bypassing SuperWoWhook),
|
||||
so addons calling UnitBuff("player", N) never hit SuperWoWhook's patches -- only
|
||||
UnitBuff("target", N) etc. can trigger it
|
||||
|
||||
## Fix
|
||||
|
||||
Single byte fix in SuperWoWhook.dll: change the dword at file offset corresponding
|
||||
to VA `0x1001B3DC` from `0x00519830` to `0x00519840`.
|
||||
|
||||
## Crash Signature
|
||||
|
||||
- ACCESS_VIOLATION at SuperWoWhook offset 0x1688
|
||||
- `MOVZX ESI, word [ESI + EAX*2]` with invalid address
|
||||
- EBP = ASCII text (e.g. 0x616C7441 = "AltA") -- corrupted by MOV EBP,[EDX]
|
||||
- Stack contains 0x00519840 and addon strings ("DBG:AceEvent20Frame")
|
||||
|
||||
## Ghidra Labels
|
||||
|
||||
SuperWoWhook.dll:
|
||||
- `SuperWoW_BuildUnitBuffHook` (0x10002460)
|
||||
- `UnitBuff_ReturnFieldU16_3vals` (0x10001680) -- crash site
|
||||
- Template at 0x1001B240 (197 dwords), replacement data at local_328
|
||||
|
||||
WoW.exe (Timber):
|
||||
- UnitBuff other-player path: 0x519780-0x519846
|
||||
- JE at 0x519803: "no buff found" branch to 0x519829
|
||||
- Two epilogues: 0x51981D (buff found, pair 6 OK), 0x51983B (no buff, pair 7 BROKEN)
|
||||
@@ -0,0 +1,285 @@
|
||||
# SuperWoWhook vs Timber: UnitBuff/UnitDebuff Patch Conflict
|
||||
|
||||
## Overview
|
||||
|
||||
Both Timber (modified WoW.exe) and SuperWoWhook.dll extend `Lua_UnitBuff` and
|
||||
`Lua_UnitDebuff` to return extra values (spell ID, dispel type). SuperWoWhook
|
||||
patches vanilla return sites that Timber has already moved or replaced, causing
|
||||
stack corruption and crashes.
|
||||
|
||||
## What Each System Does
|
||||
|
||||
### Vanilla WoW (unmodified)
|
||||
- `UnitBuff(unit, index)` returns 2 values: texture, count
|
||||
- `UnitDebuff(unit, index)` returns 3 values: texture, count, dispelType
|
||||
|
||||
### Timber (TW_ extensions at 0xD06xxx)
|
||||
- `UnitBuff` returns 3 values: texture, count, **spellID**
|
||||
- `UnitDebuff` returns 4 values: texture, count, dispelType, **spellID**
|
||||
- Implemented via JMP patches into TW_ code at 0xD06xxx
|
||||
|
||||
### SuperWoWhook (runtime code injection)
|
||||
- Extends both functions to add similar extra return values
|
||||
- Built for vanilla byte layout -- patches specific return epilogues
|
||||
- Uses WriteProcessMemory at runtime to overwrite code
|
||||
|
||||
## UnitBuff Conflict (0x519500)
|
||||
|
||||
### Vanilla return path (found match, has icon):
|
||||
```
|
||||
519703: CALL 0x6F3810 ; lua_pushnumber(count)
|
||||
519708: POP EDI ; epilogue
|
||||
519709: POP ESI ; <-- SuperWoWhook patches 4 bytes here
|
||||
51970A: MOV EAX, 0x2 ; (replaces return count + epilogue)
|
||||
51970F: POP EBX
|
||||
519710: MOV ESP, EBP
|
||||
519712: POP EBP
|
||||
519713: RET ; returns 2 values
|
||||
```
|
||||
|
||||
### Timber replaces this with:
|
||||
```
|
||||
519703: JMP 0x00D06585 ; -> TW_UnitBuff_PushDuration
|
||||
(0x519708-0x519713 is now dead code, never reached)
|
||||
519714: MOV ESI, [EBP-0x4] ; (nil icon path continues here)
|
||||
519717: MOV ECX, ESI
|
||||
...
|
||||
```
|
||||
|
||||
### What TW_UnitBuff_PushDuration does (0xD06585):
|
||||
```
|
||||
D06585: CALL 0x6F3810 ; lua_pushnumber(count) -- was on FPU stack
|
||||
D0658A: SUB ESP, 0x8
|
||||
D0658D: MOV ECX, ESI
|
||||
D0658F: MOV EAX, [EBP-0x24] ; load saved spell ID (stashed by TW_Lua_UnitBuff_Extended)
|
||||
D06594: MOV dword [EBP-0x18], EAX
|
||||
D06597: FILD dword [EBP-0x18] ; convert to float
|
||||
D0659A: FSTP qword [ESP]
|
||||
D0659D: CALL 0x6F3810 ; lua_pushnumber(spellID)
|
||||
D065A2: JMP 0xD066AF ; -> ReturnConstant3_b (return 3)
|
||||
```
|
||||
|
||||
**Conflict**: SuperWoWhook writes 4 bytes at 0x519709 -- dead code in Timber
|
||||
(JMP at 0x519703 bypasses it). This specific patch is **harmless** since it
|
||||
never executes.
|
||||
|
||||
## UnitDebuff Conflict (0x519860) -- THE CRASH
|
||||
|
||||
### Vanilla return path 1 (has dispel type string):
|
||||
```
|
||||
519AEC: CALL 0x6F3890 ; lua_pushstring(dispelType)
|
||||
519AF1: POP EDI ; epilogue
|
||||
519AF2: POP ESI ; <-- SuperWoWhook patches 4 bytes here
|
||||
519AF3: MOV EAX, 0x3 ; (replaces return count + epilogue)
|
||||
519AF8: POP EBX
|
||||
519AF9: MOV ESP, EBP
|
||||
519AFB: POP EBP
|
||||
519AFC: RET ; returns 3 values
|
||||
```
|
||||
|
||||
### Timber replaces this with:
|
||||
```
|
||||
519AEC: JMP 0x00D065B4 ; -> TW_UnitDebuff_PushDispelType
|
||||
(0x519AF1-0x519AFC is dead code, never reached by original path)
|
||||
519AFD: MOV ESI, [EBP-0xC] ; (nil path starts here -- DIFFERENT CODE)
|
||||
519B00: MOV ECX, ESI ; <-- This is where 0x519AF2+0x0E lands!
|
||||
519B02: CALL 0x6F37F0 ; lua_pushnil
|
||||
...
|
||||
```
|
||||
|
||||
**Conflict**: SuperWoWhook writes 4 bytes at **0x519AF2**. In vanilla this was
|
||||
`POP ESI; MOV EAX, 0x3` (the return-3 epilogue). In Timber this is dead code
|
||||
between the JMP at 0x519AEC and the nil path at 0x519AFD. SuperWoWhook writes
|
||||
a relative jump here. The bytes are technically dead on the Timber happy path,
|
||||
BUT if SuperWoWhook's other patches redirect execution INTO this dead zone,
|
||||
the corrupted bytes execute and crash.
|
||||
|
||||
### Vanilla return path 2 (nil dispel, fallback):
|
||||
```
|
||||
519B14: CALL 0x6F37F0 ; lua_pushnil
|
||||
519B19: POP EDI ; epilogue
|
||||
519B1A: POP ESI ; <-- SuperWoWhook patches 4 bytes here
|
||||
519B1B: MOV EAX, 0x3
|
||||
519B20: POP EBX
|
||||
519B21: MOV ESP, EBP
|
||||
519B23: POP EBP
|
||||
519B24: RET ; returns 3 values
|
||||
```
|
||||
|
||||
### Timber replaces this with:
|
||||
```
|
||||
519B14: JMP 0x00D065D4 ; -> TW_UnitDebuff_PushNilFallback
|
||||
(0x519B19-0x519B24 is dead code)
|
||||
519B25: LEA ECX, [EBP-0x20] ; (next function or unrelated code)
|
||||
519B28: CALL 0x496400
|
||||
...
|
||||
```
|
||||
|
||||
**Conflict**: SuperWoWhook writes 4 bytes at **0x519B1A**. In Timber this is
|
||||
dead code after the JMP at 0x519B14. Timber's disassembler doesn't even show
|
||||
instructions at 0x519B1A. SuperWoWhook writes corrupted jump bytes into this
|
||||
dead zone. Same risk as above.
|
||||
|
||||
### UnitDebuff return paths that are IDENTICAL (safe):
|
||||
```
|
||||
519C49: POP ESI ; <-- SuperWoWhook patches (SAME in both)
|
||||
519C4A: MOV EAX, 0x3
|
||||
|
||||
519C6E: POP ESI ; <-- SuperWoWhook patches (SAME in both)
|
||||
519C6F: MOV EAX, 0x3
|
||||
```
|
||||
These paths were NOT modified by Timber. SuperWoWhook's patches here are safe.
|
||||
|
||||
## Crash Mechanism
|
||||
|
||||
### Execution trace
|
||||
|
||||
The crash stack has return address `0x51983B` (UnitBuff epilogue: POP EDI, POP ESI,
|
||||
MOV EAX 2, ... RET). This is a UnitBuff return path that Timber did NOT modify --
|
||||
the "no match" or error path returning 2 values. From this path, execution entered
|
||||
SuperWoWhook's return-value formatter (offset 0x1688) via one of the 27 rel32 JMP
|
||||
patches.
|
||||
|
||||
The crash block at 0x10001688 reads `[EBP-0x10]` (aura data ptr) and `[EBP-0x8]`
|
||||
(aura index) from UnitBuff's stack frame. These are locals set up by UnitBuff's
|
||||
prologue. If EBP is valid, this works.
|
||||
|
||||
### The corruption
|
||||
|
||||
EBP = `0x616C7441` = ASCII "AltA". This is string data, not a stack address. The
|
||||
saved EBP was overwritten BEFORE SuperWoWhook's code runs -- SuperWoWhook's code
|
||||
is the victim, not the cause of the corruption.
|
||||
|
||||
The string "AltA" likely comes from `ChatFrameEditBox:SetAltArrowKeyMode(false)` --
|
||||
called by pfUI or shaguTweaks during an AceEvent OnUpdate handler. The stack also
|
||||
contains "DBG:AceEvent20Frame".
|
||||
|
||||
### Possible causes
|
||||
|
||||
1. **Lua addon buffer overflow**: a Lua addon (pfUI, shaguTweaks) called from an
|
||||
AceEvent OnUpdate handler overflows a fixed-size buffer in the C call chain,
|
||||
writing "AltArrowKeyMode" string data over the saved EBP on the stack. This
|
||||
happens on a re-entrant Lua call from within UnitBuff processing.
|
||||
|
||||
2. **SuperWoWhook code cave stack collision**: SuperWoWhook's code cave may use
|
||||
stack space that overlaps with Timber's larger UnitBuff frame (Timber adds
|
||||
`[EBP-0x24]` for spell ID). If SuperWoWhook's code cave uses the same stack
|
||||
offsets for different purposes, the frames collide.
|
||||
|
||||
3. **Re-entrant UnitBuff call**: UnitBuff -> SuperWoWhook -> fires Lua event
|
||||
(UNIT_CASTEVENT) -> addon handler calls UnitBuff again -> second invocation
|
||||
corrupts the first's stack frame.
|
||||
|
||||
### What SuperWoWhook patches are harmless on Timber
|
||||
|
||||
All 5 UnitBuff/UnitDebuff return-epilogue patches (0x519709, 0x519AF2, 0x519B1A,
|
||||
0x519C49, 0x519C6E) land on dead code or unmodified code. These patches alone
|
||||
don't cause the crash -- they redirect to SuperWoWhook's formatters which work
|
||||
fine IF EBP is valid.
|
||||
|
||||
### Confirmed: Stack Frame Size Differs
|
||||
|
||||
**Vanilla UnitBuff prologue:**
|
||||
```
|
||||
519503: SUB ESP, 0x20 ; 32 bytes of locals
|
||||
```
|
||||
|
||||
**Timber UnitBuff prologue:**
|
||||
```
|
||||
519503: SUB ESP, 0x24 ; 36 bytes of locals (+4 for [EBP-0x24] spell ID)
|
||||
```
|
||||
|
||||
Timber enlarged the stack frame by 4 bytes to store the spell ID at `[EBP-0x24]`.
|
||||
However, this alone does NOT explain the crash:
|
||||
|
||||
- The formatter blocks use EBP-relative addressing (`[EBP-0x10]`, `[EBP-0x8]`)
|
||||
which is unaffected by the frame size change
|
||||
- The formatters end with `RET`, bypassing the original epilogue entirely --
|
||||
they never do `POP EBP` so the shifted saved-register positions don't matter
|
||||
- If this were the sole cause, UnitBuff would crash on EVERY call, not intermittently
|
||||
|
||||
### Root Cause: Formatter Uses Wrong Locals on Other-Player Path
|
||||
|
||||
**Confirmed via Unicorn x86 emulation.**
|
||||
|
||||
SuperWoWhook's formatter at offset 0x1680 does:
|
||||
```
|
||||
MOV ESI, [EBP-0x10] ; assumes: unit data pointer
|
||||
MOV EAX, [EBP-0x8] ; assumes: aura slot index
|
||||
MOVZX ESI, word [ESI+EAX*2] ; reads u16 from aura array
|
||||
```
|
||||
|
||||
This is correct for the **local player** aura iteration path (0x51960F+),
|
||||
where `[EBP-0x10]` = unit object and `[EBP-0x8]` = aura iteration index.
|
||||
|
||||
But SuperWoWhook patches the **other player** return sites at 0x51981D and
|
||||
0x51983B with E9 JMPs to this same formatter. On the other-player path:
|
||||
|
||||
- `[EBP-0x10]` = unit object pointer (from ClntObjMgrObjectPtr)
|
||||
- `[EBP-0x8]` = NOT an aura index -- it's the Lua buff index from lua_tonumber
|
||||
|
||||
The formatter computes `unit_obj + lua_buff_index * 2` and reads a u16.
|
||||
This is NOT a valid aura array access. Depending on the unit pointer and
|
||||
buff index values:
|
||||
|
||||
- Usually: reads from valid heap -> returns garbage data (wrong but no crash)
|
||||
- Sometimes: reads from unmapped memory or guard page -> ACCESS_VIOLATION
|
||||
|
||||
The EBP="AltA" corruption in the crash reports is a RED HERRING. The real
|
||||
crash is the formatter reading `[ESI+EAX*2]` with ESI=unit_ptr and EAX=buff_index
|
||||
on a code path where those locals hold different data than expected. When
|
||||
the computed address (unit_ptr + buff_index*2) happens to land on unmapped
|
||||
memory, it crashes. When it lands on mapped memory, it silently returns
|
||||
wrong data.
|
||||
|
||||
### Emulation Evidence
|
||||
|
||||
Unicorn x86 emulation of Timber+SuperWoWhook patched UnitBuff:
|
||||
|
||||
**Test 1** (no-icon other-player path -> 0x51983B -> formatter):
|
||||
```
|
||||
STEP 11: SW PATCH (nil-icon other) EBP=0x0010effc
|
||||
STEP 12: *** SW FORMATTER *** EBP=0x0010effc ESI=0x00000000
|
||||
MEM ERR: [0x01000000] at EIP=0x10001688 (MOVZX ESI, [ESI+EAX*2])
|
||||
```
|
||||
|
||||
**Test 2** (has-icon other-player path -> 0x51981D -> formatter):
|
||||
```
|
||||
STEP 13: *** SW FORMATTER *** EBP=0x0010effc ESI=0x02000000
|
||||
MEM ERR: [0x01000000] at EIP=0x10001688 (MOVZX ESI, [ESI+EAX*2])
|
||||
```
|
||||
|
||||
Both crash at the same instruction. The formatter works on the local-player
|
||||
path (tested separately, returns correctly), but crashes on the other-player
|
||||
path because the locals at [EBP-0x10] and [EBP-0x8] mean different things.
|
||||
|
||||
### Why It's Intermittent
|
||||
|
||||
The crash only occurs when `unit_ptr + buff_index*2` points to unmapped
|
||||
memory. Most unit object pointers are in the heap (0x1xxxxxxx-0x3xxxxxxx
|
||||
range) and buff indices are small (0-31), so `unit_ptr + 0..62` usually
|
||||
lands in mapped heap. The crash happens when:
|
||||
- The unit was recently freed (dangling pointer)
|
||||
- The unit is at a high heap address where +index*2 crosses a page boundary
|
||||
- Memory pressure causes the page to be unmapped
|
||||
|
||||
### Fix
|
||||
|
||||
SuperWoWhook should use **different formatters** for the local-player and
|
||||
other-player return paths, since the stack frame locals differ between them.
|
||||
Or it should not patch the other-player return sites (0x51981D, 0x51983B)
|
||||
at all, since Timber doesn't extend those paths with spell ID anyway.
|
||||
|
||||
## Fix Options
|
||||
|
||||
1. **SuperWoWhook detects Timber**: check if 0x519703 is a JMP (byte 0xE9)
|
||||
before patching. If so, skip UnitBuff/UnitDebuff patches since Timber
|
||||
already provides spell ID.
|
||||
|
||||
2. **Timber provides a flag**: export a marker (global variable or named
|
||||
mutex) that SuperWoWhook checks before patching.
|
||||
|
||||
3. **Users disable SuperWoWhook**: since Timber already provides the extra
|
||||
return values, SuperWoWhook's UnitBuff extension is redundant on Timber.
|
||||
|
||||
Not a WeirdUtils issue. WeirdUtils is not loaded in either crash.
|
||||
@@ -196,7 +196,7 @@ pub fn installHooks() void {
|
||||
g_is_hook_owner = result.is_owner;
|
||||
if (!g_is_hook_owner) return;
|
||||
|
||||
log = logging.Logger.open(module_name, .both);
|
||||
log = logging.Logger.open(module_name, .console);
|
||||
_ = cotp_hook.attach(ADDR_CheckObjectTypePermissions, &checkObjTypeDetour);
|
||||
_ = wit_hook.attach(ADDR_WorldIntersectionTest, &worldIntersectDetour);
|
||||
// _ = portal_visual.install();
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! portal_visual -- grey out unusable player-summoned portals/rituals.
|
||||
//!
|
||||
//! Hooks the M2 render pipeline to desaturate GO types 18 (ritual) and 22
|
||||
//! (portal) whose creator is a player not in the local player's group.
|
||||
//!
|
||||
//! Three hooks:
|
||||
//! ManageRenderListNode (0x710B90) -- tags portal models when added to render list
|
||||
//! DrawBatchProjected (0x70CB30) -- sets rendering_portal flag around batch draw
|
||||
//! DrawIndexedPrimitive (D3D9 vtable[82]) -- swaps pixel shader to desaturate
|
||||
|
||||
const hook = @import("zhook");
|
||||
const wow = @import("../wow.zig");
|
||||
const offsets = @import("../offsets.zig");
|
||||
const portal_filter = @import("portal_filter.zig");
|
||||
const logging = @import("../logging.zig");
|
||||
|
||||
var log: logging.Logger = .{};
|
||||
|
||||
const WINAPI = @import("std").builtin.CallingConvention.winapi;
|
||||
extern "kernel32" fn IsBadReadPtr(lp: u32, ucb: u32) callconv(WINAPI) i32;
|
||||
extern "kernel32" fn VirtualProtect(addr: *anyopaque, size: usize, new: u32, old: *u32) callconv(WINAPI) i32;
|
||||
|
||||
const MODEL_OWNER: usize = 0x28;
|
||||
const RENDER_CTX_MODEL: usize = 0x3310;
|
||||
|
||||
// Tagged portal model set -- direct-mapped cache
|
||||
const TAG_SIZE: u32 = 256;
|
||||
const TAG_MASK: u32 = TAG_SIZE - 1;
|
||||
var tagged_models: [TAG_SIZE]u32 = .{0} ** TAG_SIZE;
|
||||
|
||||
// Stores the GO object pointer alongside the model tag so we can access entity
|
||||
const TagEntry = struct { model: u32 = 0, owner: u32 = 0 };
|
||||
var tagged_entries: [TAG_SIZE]TagEntry = .{TagEntry{}} ** TAG_SIZE;
|
||||
|
||||
// Track entities we've already triggered fade on (one-shot test)
|
||||
var fade_triggered: [TAG_SIZE]u32 = .{0} ** TAG_SIZE;
|
||||
|
||||
// CreateFadeEffect: __thiscall(entity_ECX, fadeTime_f32_stack)
|
||||
const ENTITY_OFFSET: usize = 0x88;
|
||||
const CreateFadeEffectFn = fn (u32, f32) callconv(hook.cc.thiscall) void;
|
||||
const createFadeEffect: *const CreateFadeEffectFn = @ptrFromInt(0x672DF0);
|
||||
|
||||
// Deferred fade queue -- CreateFadeEffect is NOT safe to call during render list
|
||||
// traversal (ManageRenderListNode). Queue owner ptrs and process next frame.
|
||||
const FADE_QUEUE_SIZE: u32 = 16;
|
||||
var fade_queue: [FADE_QUEUE_SIZE]u32 = .{0} ** FADE_QUEUE_SIZE;
|
||||
var fade_queue_count: u32 = 0;
|
||||
|
||||
fn queueFade(owner: u32) void {
|
||||
if (fade_queue_count < FADE_QUEUE_SIZE) {
|
||||
fade_queue[fade_queue_count] = owner;
|
||||
fade_queue_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn processFadeQueue() void {
|
||||
var i: u32 = 0;
|
||||
while (i < fade_queue_count) : (i += 1) {
|
||||
triggerFade(fade_queue[i]);
|
||||
fade_queue[i] = 0;
|
||||
}
|
||||
fade_queue_count = 0;
|
||||
}
|
||||
|
||||
fn triggerFade(owner: u32) void {
|
||||
if (IsBadReadPtr(owner, 0x90) != 0) return;
|
||||
const entity = hook.readMem(u32, owner + ENTITY_OFFSET);
|
||||
if (entity == 0) return;
|
||||
if (IsBadReadPtr(entity, 0xC0) != 0) return;
|
||||
const scene_obj = hook.readMem(u32, entity + 0x88);
|
||||
if (scene_obj == 0) return;
|
||||
log.fmt("triggerFade: owner=0x{x} entity=0x{x} scene=0x{x}\n", .{ owner, entity, scene_obj });
|
||||
createFadeEffect(entity, 1.0);
|
||||
}
|
||||
|
||||
fn classifyModel(model: u32) void {
|
||||
const owner = hook.readMem(u32, model + MODEL_OWNER);
|
||||
if (owner == 0) return;
|
||||
if (IsBadReadPtr(owner, 0x20) != 0) return;
|
||||
const obj_type = hook.readMem(u32, owner + 0x14);
|
||||
if (obj_type != 5) {
|
||||
if (tagged_models[model & TAG_MASK] == model) {
|
||||
tagged_models[model & TAG_MASK] = 0;
|
||||
tagged_entries[model & TAG_MASK] = .{};
|
||||
}
|
||||
return;
|
||||
}
|
||||
const desc = wow.getDescriptor(owner);
|
||||
if (!wow.isValidPtr(desc)) return;
|
||||
const go_type = hook.readMem(u32, desc + offsets.DESC_GO_TYPE);
|
||||
if ((go_type == 18 or go_type == 22) and portal_filter.shouldFilter(desc)) {
|
||||
tagged_models[model & TAG_MASK] = model;
|
||||
tagged_entries[model & TAG_MASK] = .{ .model = model, .owner = owner };
|
||||
} else {
|
||||
if (tagged_models[model & TAG_MASK] == model) {
|
||||
tagged_models[model & TAG_MASK] = 0;
|
||||
tagged_entries[model & TAG_MASK] = .{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn isTagged(model: u32) bool {
|
||||
return tagged_models[model & TAG_MASK] == model;
|
||||
}
|
||||
|
||||
var rendering_portal: bool = false;
|
||||
var log_count: u32 = 0;
|
||||
|
||||
// =============================================================================
|
||||
// Hook: ManageRenderListNode (0x710B90)
|
||||
// =============================================================================
|
||||
|
||||
const ManageRenderFn = fn (u32, u32) callconv(hook.cc.thiscall) void;
|
||||
var manage_hook: hook.Detour(ManageRenderFn) = .{};
|
||||
|
||||
fn manageRenderDetour(model: u32, add_to_list: u32) callconv(hook.cc.thiscall) void {
|
||||
if (model != 0 and add_to_list == 1) {
|
||||
classifyModel(model);
|
||||
if (isTagged(model)) {
|
||||
const entry = tagged_entries[model & TAG_MASK];
|
||||
// One-shot: trigger fade test on first detection
|
||||
if (entry.owner != 0 and fade_triggered[model & TAG_MASK] != model) {
|
||||
fade_triggered[model & TAG_MASK] = model;
|
||||
triggerFadeTest(entry.owner);
|
||||
}
|
||||
}
|
||||
} else if (model != 0) {
|
||||
if (tagged_models[model & TAG_MASK] == model) {
|
||||
tagged_models[model & TAG_MASK] = 0;
|
||||
tagged_entries[model & TAG_MASK] = .{};
|
||||
}
|
||||
}
|
||||
manage_hook.callOriginal(.{ model, add_to_list });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook: DrawBatchProjected (0x70CB30)
|
||||
// =============================================================================
|
||||
|
||||
const DrawBatchFn = fn (u32) callconv(hook.cc.thiscall) void;
|
||||
var draw_batch_hook: hook.Detour(DrawBatchFn) = .{};
|
||||
|
||||
const MODEL_ALPHA: usize = 0x180; // written by fade system via SetMemoryPointer
|
||||
const DIM_ALPHA: u32 = @bitCast(@as(f32, 0.35));
|
||||
const FULL_ALPHA: u32 = @bitCast(@as(f32, 1.0));
|
||||
|
||||
fn drawBatchDetour(ctx: u32) callconv(hook.cc.thiscall) void {
|
||||
const model_ptr = if (wow.isValidPtr(ctx +% @as(u32, @intCast(RENDER_CTX_MODEL))))
|
||||
hook.readMem(u32, ctx + RENDER_CTX_MODEL)
|
||||
else
|
||||
0;
|
||||
|
||||
if (model_ptr != 0 and isTagged(model_ptr)) {
|
||||
// Write dim alpha to the model's opacity field before batch draws
|
||||
const saved = hook.readMem(u32, model_ptr + MODEL_ALPHA);
|
||||
const dest: *u32 = @ptrFromInt(model_ptr + MODEL_ALPHA);
|
||||
dest.* = DIM_ALPHA;
|
||||
|
||||
rendering_portal = true;
|
||||
draw_batch_hook.callOriginal(.{ctx});
|
||||
rendering_portal = false;
|
||||
|
||||
dest.* = saved;
|
||||
} else {
|
||||
draw_batch_hook.callOriginal(.{ctx});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// D3D9 DIP hook + desaturation pixel shader
|
||||
// =============================================================================
|
||||
|
||||
inline fn vt(obj: *anyopaque) [*]usize {
|
||||
return @ptrFromInt(hook.readMem(u32, @intFromPtr(obj)));
|
||||
}
|
||||
|
||||
const VT_DIP: usize = 82;
|
||||
const VT_CreatePixelShader: usize = 106;
|
||||
const VT_SetPixelShader: usize = 107;
|
||||
const VT_GetPixelShader: usize = 108;
|
||||
const VT_SetPSConstantF: usize = 109;
|
||||
|
||||
var orig_dip: usize = 0;
|
||||
var d3d9_vtable: ?[*]usize = null;
|
||||
var desat_shader: ?*anyopaque = null;
|
||||
|
||||
// ps_2_0 desaturation shader: samples texture, converts to greyscale via luminance.
|
||||
// c0 = luminance weights (0.299, 0.587, 0.114, 0.0)
|
||||
//
|
||||
// ps_2_0
|
||||
// dcl t0.xy
|
||||
// dcl_2d s0
|
||||
// texld r0, t0, s0 ; sample texture
|
||||
// dp3 r1.x, r0, c0 ; grey = dot(rgb, luma)
|
||||
// mov r1.y, r1.x ; replicate
|
||||
// mov r1.z, r1.x
|
||||
// mov r1.w, r0.w ; preserve alpha
|
||||
// mov oC0, r1
|
||||
//
|
||||
// Assembled from the D3D shader token spec (ps_2_0 format):
|
||||
const desat_shader_bytecode = [_]u32{
|
||||
0xFFFF0200, // ps_2_0
|
||||
// dcl t0.xy
|
||||
0x0200001F, 0x80000000, 0xB0030000,
|
||||
// dcl_2d s0
|
||||
0x0200001F, 0x90000000, 0xA00F0800,
|
||||
// texld r0, t0, s0
|
||||
0x03000042, 0x800F0000, 0xB0E40000, 0xA0E40800,
|
||||
// dp3 r1.x, r0, c0
|
||||
0x03000008, 0x80010001, 0x80E40000, 0xA0E40000,
|
||||
// mov r1.y, r1.x
|
||||
0x02000001, 0x80020001, 0x80000001,
|
||||
// mov r1.z, r1.x
|
||||
0x02000001, 0x80040001, 0x80000001,
|
||||
// mov r1.w, r0.w
|
||||
0x02000001, 0x80080001, 0x80FF0000,
|
||||
// mov oC0, r1
|
||||
0x02000001, 0x800F0800, 0x80E40001,
|
||||
// end
|
||||
0x0000FFFF,
|
||||
};
|
||||
|
||||
const luma_weights = [4]f32{ 0.299, 0.587, 0.114, 0.0 };
|
||||
|
||||
fn createShader(device: *anyopaque) bool {
|
||||
const createFn: *const fn (*anyopaque, [*]const u32, **anyopaque) callconv(hook.cc.stdcall) i32 =
|
||||
@ptrFromInt(vt(device)[VT_CreatePixelShader]);
|
||||
var shader: ?*anyopaque = null;
|
||||
const hr = createFn(device, &desat_shader_bytecode, @ptrCast(&shader));
|
||||
if (hr >= 0 and shader != null) {
|
||||
desat_shader = shader;
|
||||
return true;
|
||||
}
|
||||
log.fmt("CreatePixelShader failed: hr=0x{x}\n", .{@as(u32, @bitCast(hr))});
|
||||
return false;
|
||||
}
|
||||
|
||||
fn hkDIP(
|
||||
device: *anyopaque,
|
||||
prim_type: u32,
|
||||
base_vtx: i32,
|
||||
min_vtx: u32,
|
||||
num_verts: u32,
|
||||
start_idx: u32,
|
||||
prim_count: u32,
|
||||
) callconv(hook.cc.stdcall) i32 {
|
||||
const origFn: *const fn (*anyopaque, u32, i32, u32, u32, u32, u32) callconv(hook.cc.stdcall) i32 =
|
||||
@ptrFromInt(orig_dip);
|
||||
|
||||
if (rendering_portal and desat_shader != null) {
|
||||
// Save current pixel shader
|
||||
var saved_ps: ?*anyopaque = null;
|
||||
const getFn: *const fn (*anyopaque, *?*anyopaque) callconv(hook.cc.stdcall) i32 =
|
||||
@ptrFromInt(vt(device)[VT_GetPixelShader]);
|
||||
_ = getFn(device, &saved_ps);
|
||||
|
||||
// Set desaturation shader + luminance weights
|
||||
const setFn: *const fn (*anyopaque, ?*anyopaque) callconv(hook.cc.stdcall) i32 =
|
||||
@ptrFromInt(vt(device)[VT_SetPixelShader]);
|
||||
_ = setFn(device, desat_shader);
|
||||
|
||||
const setConstFn: *const fn (*anyopaque, u32, [*]const f32, u32) callconv(hook.cc.stdcall) i32 =
|
||||
@ptrFromInt(vt(device)[VT_SetPSConstantF]);
|
||||
_ = setConstFn(device, 0, &luma_weights, 1);
|
||||
|
||||
const result = origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count);
|
||||
|
||||
// Restore pixel shader
|
||||
_ = setFn(device, saved_ps);
|
||||
if (saved_ps) |ps| {
|
||||
const relFn: *const fn (*anyopaque) callconv(hook.cc.stdcall) u32 = @ptrFromInt(vt(ps)[2]);
|
||||
_ = relFn(ps);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count);
|
||||
}
|
||||
|
||||
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 = hook.readMem(u32, offsets.GX_DEVICE_PTR);
|
||||
if (gx == 0) return null;
|
||||
const dev = hook.readMem(u32, gx + offsets.GX_DEVICE_D3D_OFFSET);
|
||||
if (dev == 0) return null;
|
||||
const vtable_addr = hook.readMem(u32, dev);
|
||||
if (vtable_addr == 0) return null;
|
||||
return @ptrFromInt(vtable_addr);
|
||||
}
|
||||
|
||||
var d3d9_initialized: bool = false;
|
||||
|
||||
fn initD3D9() void {
|
||||
// DIP shader hook disabled -- testing model+0x180 alpha approach
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Install / Remove
|
||||
// =============================================================================
|
||||
|
||||
pub fn install() bool {
|
||||
log = logging.Logger.open("portal_visual", .both);
|
||||
if (manage_hook.attach(0x710B90, &manageRenderDetour) != .ok) return false;
|
||||
if (draw_batch_hook.attach(0x70CB30, &drawBatchDetour) != .ok) {
|
||||
manage_hook.detach();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deferred D3D9 init -- call from lateInit when device exists.
|
||||
pub fn lateInit() void {
|
||||
initD3D9();
|
||||
}
|
||||
|
||||
pub fn remove() void {
|
||||
if (d3d9_vtable) |vtbl| {
|
||||
if (orig_dip != 0) restoreVtableEntry(vtbl, VT_DIP, orig_dip);
|
||||
}
|
||||
draw_batch_hook.detach();
|
||||
manage_hook.detach();
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
# Handoff: COMBAT_LOG_EVENT Filtering (CLEU vs CLEUF)
|
||||
|
||||
## Goal
|
||||
|
||||
Implement Blizzard's TBC/WotLK event filtering system so we provide both:
|
||||
- `COMBAT_LOG_EVENT` -- filtered version (only events relevant to the player's group)
|
||||
- `COMBAT_LOG_EVENT_UNFILTERED` -- all events in range (everything the client sees)
|
||||
|
||||
This matches how TBC and WotLK work: addons register for whichever they need. DPS meters
|
||||
use UNFILTERED for complete data, while UI elements use the filtered version to reduce noise.
|
||||
|
||||
## Background
|
||||
|
||||
### How Blizzard Did It
|
||||
|
||||
In TBC (2.4.0), Blizzard introduced `COMBAT_LOG_EVENT_UNFILTERED` which fires for ALL
|
||||
combat events the client receives. They also provided `COMBAT_LOG_EVENT` which fires only
|
||||
for events passing a set of filters.
|
||||
|
||||
The filtering is controlled by:
|
||||
- `CombatLogClearEntries()` -- clears the combat log
|
||||
- `CombatLogAddFilter(...)` -- adds a filter rule
|
||||
- `CombatLogResetFilter()` -- resets to default filters
|
||||
- `CombatLogGetCurrentEntry(...)` -- reads the current event (deprecated by WotLK)
|
||||
- `CombatLogGetNumEntries()` -- count of buffered events
|
||||
|
||||
Default filters include:
|
||||
- Source or dest is the player
|
||||
- Source or dest is in the player's party/raid
|
||||
- Source or dest is the player's pet/guardian
|
||||
- Events within a certain range
|
||||
|
||||
### What We Currently Have
|
||||
|
||||
We fire a single `COMBAT_LOG_EVENT` at slot 549 via SignalEventParam. Every hook fires
|
||||
every event it sees -- no filtering. This is effectively UNFILTERED behavior under the
|
||||
FILTERED event name.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Register Both Events
|
||||
|
||||
Add a second event slot for `COMBAT_LOG_EVENT_UNFILTERED`:
|
||||
- `COMBAT_LOG_EVENT` at slot 549 (existing) -- will become the filtered version
|
||||
- `COMBAT_LOG_EVENT_UNFILTERED` at slot 550 -- fires everything (current behavior)
|
||||
|
||||
In `createEventsDetour`, bump the event count by 2 instead of 1 and register both names.
|
||||
Update all fire functions to fire both events (or fire UNFILTERED always and FILTERED
|
||||
conditionally).
|
||||
|
||||
**IMPORTANT**: Slot 550 was previously identified as a float global (0x3E800000 = 0.25).
|
||||
Need to verify this is still the case or find a different slot. May need to search for
|
||||
two consecutive free slots.
|
||||
|
||||
### Phase 2: Implement Filtering Logic
|
||||
|
||||
The filter checks whether source or dest is "interesting" to the local player:
|
||||
|
||||
```zig
|
||||
fn shouldFilter(src_guid: u64, dst_guid: u64) bool {
|
||||
// Always pass if source or dest is the local player
|
||||
if (src_guid == getPlayerGUID() or dst_guid == getPlayerGUID()) return false;
|
||||
// Always pass if source or dest is in the player's group
|
||||
if (isGroupMember(src_guid) or isGroupMember(dst_guid)) return false;
|
||||
// Always pass if source or dest is a pet owned by a group member
|
||||
if (isPetOfGroupMember(src_guid) or isPetOfGroupMember(dst_guid)) return false;
|
||||
// Filter out (don't fire COMBAT_LOG_EVENT)
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
Requires:
|
||||
- `getPlayerGUID()` -- already have this (0x468550)
|
||||
- `isGroupMember(guid)` -- check party (0xBC6F48) and raid (0xB712A8) GUID arrays
|
||||
- `isPetOfGroupMember(guid)` -- check UNIT_FIELD_SUMMONEDBY descriptor, resolve owner
|
||||
|
||||
### Phase 3: Fire Functions
|
||||
|
||||
Two approaches:
|
||||
|
||||
**Option A: Double-fire**
|
||||
Every fire function fires the event twice -- once for UNFILTERED (always), once for
|
||||
FILTERED (if passes filter). Simple but doubles the SignalEventParam calls.
|
||||
|
||||
**Option B: Conditional fire with shared push**
|
||||
Push args to Lua stack once, then call SignalEvent for each registered event that should
|
||||
receive it. Requires understanding SignalEventParam internals more deeply.
|
||||
|
||||
Option A is simpler and the performance cost of an extra SignalEventParam call per event
|
||||
is negligible compared to the Lua handler execution.
|
||||
|
||||
### Phase 4: sourceFlags / destFlags
|
||||
|
||||
With filtering in place, we can also construct the COMBATLOG_OBJECT_* bitfield that
|
||||
WotLK addons use for their own filtering:
|
||||
|
||||
```
|
||||
COMBATLOG_OBJECT_AFFILIATION_MINE = 0x0001
|
||||
COMBATLOG_OBJECT_AFFILIATION_PARTY = 0x0002
|
||||
COMBATLOG_OBJECT_AFFILIATION_RAID = 0x0004
|
||||
COMBATLOG_OBJECT_AFFILIATION_OUTSIDER = 0x0008
|
||||
COMBATLOG_OBJECT_REACTION_FRIENDLY = 0x0010
|
||||
COMBATLOG_OBJECT_REACTION_NEUTRAL = 0x0020
|
||||
COMBATLOG_OBJECT_REACTION_HOSTILE = 0x0040
|
||||
COMBATLOG_OBJECT_CONTROL_PLAYER = 0x0100
|
||||
COMBATLOG_OBJECT_CONTROL_NPC = 0x0200
|
||||
COMBATLOG_OBJECT_TYPE_PLAYER = 0x0400
|
||||
COMBATLOG_OBJECT_TYPE_NPC = 0x0800
|
||||
COMBATLOG_OBJECT_TYPE_PET = 0x1000
|
||||
COMBATLOG_OBJECT_TYPE_GUARDIAN = 0x2000
|
||||
COMBATLOG_OBJECT_TYPE_OBJECT = 0x4000
|
||||
```
|
||||
|
||||
These can be constructed from:
|
||||
- GUID type bits (high nibble of GUID encodes player/creature/pet/gameobject)
|
||||
- Group membership (party/raid roster arrays)
|
||||
- Reaction (UnitReaction at 0x6061E0)
|
||||
- Ownership (UNIT_FIELD_SUMMONEDBY descriptor)
|
||||
|
||||
This is the same data needed for filtering, so it comes naturally after Phase 2.
|
||||
|
||||
### Phase 5: Lua API (Optional)
|
||||
|
||||
Provide the filter configuration API for full parity:
|
||||
- `CombatLogAddFilter(srcFlags, dstFlags, eventType)`
|
||||
- `CombatLogResetFilter()`
|
||||
- `CombatLogGetNumEntries()`
|
||||
|
||||
Most addons don't use these -- they just register for UNFILTERED and do their own
|
||||
filtering. Low priority.
|
||||
|
||||
## Key Addresses
|
||||
|
||||
- Event slot 549: COMBAT_LOG_EVENT (existing)
|
||||
- Event slot 550: needs verification (was float 0.25 in earlier research)
|
||||
- Player GUID: 0x468550 (ClntObjMgrGetActivePlayer)
|
||||
- Party GUIDs: 0xBC6F48 (array of 4 u64 GUIDs)
|
||||
- Raid roster: 0xB712A8 (array of ptrs), count at 0xB713E0
|
||||
- UnitReaction: 0x6061E0
|
||||
- UNIT_FIELD_SUMMONEDBY: descriptor offset 0x30 (index 0x0C)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Phase 1 can start immediately
|
||||
- Phase 2 needs isGroupMember which uses existing known addresses
|
||||
- Phase 3 trivial once Phase 1+2 done
|
||||
- Phase 4 is independent enhancement, can parallelize with Phase 2
|
||||
|
||||
## Risk
|
||||
|
||||
- Slot 550 conflict with float global -- may need to find a safe slot
|
||||
- Double-firing events may interact poorly with addons that register for both
|
||||
(they'd see every event twice) -- need to ensure the events have distinct IDs
|
||||
- Performance of filter check per event should be negligible (few memory reads)
|
||||
@@ -599,6 +599,20 @@ All in the 0x51xxxx addon/UI system. Zeroing/overwriting these corrupts game sta
|
||||
SMSG_RESURRECT_FAILED (0x252).
|
||||
- [ ] **UNIT_POSITION**: Periodic or event-driven unit position data in combat log events.
|
||||
Useful for replay/analysis tools.
|
||||
- [ ] **SPELL_CAST dest coordinates**: SMSG_SPELL_START/GO include destination XYZ for
|
||||
area-targeted spells via TARGET_FLAG_DEST_LOCATION (0x40) in SpellCastTargets.
|
||||
- Target data format in packet: uint16 targetMask, then if 0x02: packed GUID (unit),
|
||||
if 0x20: 3x float (source XYZ), if 0x40: 3x float (dest XYZ)
|
||||
- Player ground AoE (Blizzard, Flamestrike, Rain of Fire, Volley): client sends clicked
|
||||
position, server echoes in both SPELL_START and SPELL_GO
|
||||
- Mob ground AoE (Ignite Earth, mob Flamestrike): AI script calls CastSpell(x,y,z,...),
|
||||
server sets 0x40 via setDestination(), coords appear in SPELL_GO
|
||||
- Self-centered AoE (Arcane Explosion, Hellfire): no 0x40, no dest coords
|
||||
- Triggered spells: dest overridden to caster position regardless of original flags
|
||||
- Already hooked at 0x6E7640 (spellStartDetour) -- just need to parse target mask and
|
||||
extract floats when 0x40 is set, emit as extra CLEU params on SPELL_CAST_START/SUCCESS
|
||||
- Server ref: SpellCastTargets::write() in tortoise-wow Spell.cpp lines 223-268
|
||||
- Enables boss mod positional warnings (e.g. Karazhan 40-man Ignite Earth ground zones)
|
||||
|
||||
### Completed reference
|
||||
- [x] RANGE_MISSED: Detected via spell ID check (75/5019) in ProcessSpellCombatResult hook.
|
||||
@@ -1010,3 +1024,91 @@ ProcessSpellDamageWithLocalization (0x629d30)
|
||||
| FrameScript_CreateEvents | FrameScript_CreateEvents | 0x703D90 |
|
||||
| FrameScript_RegisterFunction | FrameScript_RegisterFunction | 0x704120 |
|
||||
| GetSpellNameById | (SpellDb at 0xC0D780) | 0x6264b0 |
|
||||
|
||||
---
|
||||
|
||||
## Missing WotLK CLEU Subevents
|
||||
|
||||
Events present in WotLK 3.3.5 COMBAT_LOG_EVENT_UNFILTERED but not yet implemented.
|
||||
|
||||
### Loot Events (novel -- never existed in any WoW combat log)
|
||||
|
||||
LOOT was never a COMBAT_LOG_EVENT subevent in any WoW expansion. Loot tracking addons
|
||||
use separate Lua events (LOOT_OPENED, CHAT_MSG_LOOT, etc.), not the combat log.
|
||||
Adding loot to our combat log would be a novel extension, not WotLK parity.
|
||||
|
||||
Relevant vanilla packets if we ever want to add this:
|
||||
|
||||
| Packet | Opcode | Format |
|
||||
|--------|--------|--------|
|
||||
| SMSG_ITEM_PUSH_RESULT | 0x166 | playerGUID(8), received(4), created(4), showInChat(4), bagSlot(1), itemSlot(4), itemID(4), suffix(4), randomProp(4), count(4) |
|
||||
| SMSG_LOOT_ROLL | 0x2A2 | targetGUID(8), slot(4), rollerGUID(8), itemID(4), suffix(4), randomProp(4), rollNum(1), rollType(1) |
|
||||
| SMSG_LOOT_ROLL_WON | 0x29F | targetGUID(8), slot(4), itemID(4), suffix(4), randomProp(4), winnerGUID(8), rollNum(1), rollType(1) |
|
||||
| SMSG_LOOT_ALL_PASSED | 0x29E | targetGUID(8), slot(4), itemID(4), randomProp(4), suffix(4) |
|
||||
| SMSG_LOOT_MONEY_NOTIFY | 0x163 | amount(4) |
|
||||
|
||||
### Enchant Events (verified in WotLK/Cata logs)
|
||||
|
||||
Confirmed present in real WotLK combat logs. No vanilla packet equivalent -- would need
|
||||
to hook the client enchant application/removal functions.
|
||||
|
||||
| Subevent | Description |
|
||||
|----------|-------------|
|
||||
| ENCHANT_APPLIED | Item enchanted (temp or permanent) |
|
||||
| ENCHANT_REMOVED | Enchant removed/expired |
|
||||
|
||||
Verified WotLK format:
|
||||
```
|
||||
ENCHANT_APPLIED,srcGUID,srcName,srcFlags,srcRaidFlags,dstGUID,dstName,dstFlags,dstRaidFlags,"enchantName",itemID,"itemName"
|
||||
ENCHANT_REMOVED,0x0000000000000000,nil,0x80000000,0x80000000,dstGUID,dstName,dstFlags,dstRaidFlags,"enchantName",itemID,"itemName"
|
||||
```
|
||||
|
||||
Note: ENCHANT_REMOVED source is always null GUID (no "remover").
|
||||
|
||||
### SPELL_CREATE (verified in WotLK/Cata logs)
|
||||
|
||||
Fires when a player creates a game object (trap, totem, feast, cauldron).
|
||||
Uses standard spell prefix. Dest is the created GO's GUID.
|
||||
|
||||
```
|
||||
SPELL_CREATE,srcGUID,srcName,srcFlags,srcRaidFlags,goGUID,"goName",goFlags,goRaidFlags,spellID,"spellName",spellSchool
|
||||
```
|
||||
|
||||
### Combat Events (low priority)
|
||||
|
||||
| Subevent | Description | Notes |
|
||||
|----------|-------------|-------|
|
||||
| UNIT_DISSIPATES | Pet/totem/guardian despawn | Variant of UNIT_DIED, not in WotLK sample but in retail |
|
||||
| SPELL_ABSORBED | Damage absorbed by shield | Present in Shadowlands+, not in WotLK sample |
|
||||
| SPELL_HEAL_ABSORBED | Healing absorbed by anti-heal | Shadowlands+, no vanilla mechanic |
|
||||
| SWING_DAMAGE_LANDED | Melee hit after absorb | Shadowlands+, not in WotLK |
|
||||
| SPELL_BUILDING_* | Structure damage prefix | WotLK siege content, N/A in vanilla |
|
||||
| *_DURABILITY_DAMAGE | Equipment durability loss | Rarely useful |
|
||||
|
||||
### Metadata Events (Shadowlands+ only, NOT in WotLK)
|
||||
|
||||
These do NOT appear in WotLK/Cata combat logs. They were added in later expansions
|
||||
(combat log version 16+). Listed for reference only.
|
||||
|
||||
| Subevent | Description | First seen |
|
||||
|----------|-------------|------------|
|
||||
| ENCOUNTER_START | Boss encounter begins | Shadowlands+ |
|
||||
| ENCOUNTER_END | Boss encounter ends | Shadowlands+ |
|
||||
| ZONE_CHANGE | Player changes zone | Shadowlands+ |
|
||||
| COMBATANT_INFO | Gear/talent snapshot | Shadowlands+ |
|
||||
| CHALLENGE_MODE_START/END | M+ key start/end | Shadowlands+ |
|
||||
| EMOTE | Boss emote text | Shadowlands+ |
|
||||
| MAP_CHANGE | Map transition | Shadowlands+ |
|
||||
| ARENA_MATCH_START/END | Arena match | Shadowlands+ |
|
||||
|
||||
### Current Implementation Status
|
||||
|
||||
37 subevents implemented across 23 hooks (Phases 1-5 complete):
|
||||
- Damage: SWING/RANGE/SPELL/PERIODIC/SHIELD/SPLIT/ENVIRONMENTAL (7)
|
||||
- Missed: SWING/RANGE/SPELL/PERIODIC/SHIELD (5)
|
||||
- Heal: SPELL/PERIODIC (2)
|
||||
- Power: ENERGIZE/DRAIN/PERIODIC_ENERGIZE/PERIODIC_DRAIN/PERIODIC_LEECH (5)
|
||||
- Aura: APPLIED/REMOVED/DOSE(x2)/REFRESH/BROKEN/BROKEN_SPELL (7)
|
||||
- Cast: START/SUCCESS/FAILED (3)
|
||||
- Misc: INTERRUPT/DISPEL/DISPEL_FAILED/STOLEN/EXTRA_ATTACKS/SUMMON/RESURRECT/INSTAKILL (8)
|
||||
- Death: UNIT_DIED/UNIT_DESTROYED/PARTY_KILL (3)
|
||||
|
||||
@@ -486,7 +486,7 @@ local function profReport(label, tbl)
|
||||
end
|
||||
end
|
||||
|
||||
local benchActive = true
|
||||
local benchActive = false
|
||||
local benchFrame = CreateFrame("Frame")
|
||||
|
||||
local function benchCombatStart()
|
||||
|
||||
@@ -145,6 +145,14 @@ end
|
||||
local profiling = false
|
||||
local profCLEU = { events = 0, totalMs = 0, gcStart = 0 }
|
||||
local profOrig = { events = 0, totalMs = 0, gcStart = 0 }
|
||||
|
||||
-- Fine-grained timing buckets (microseconds, accumulated)
|
||||
local profDetail = { getInfo = 0, dbCalls = 0, dispatch = 0, total = 0, count = 0 }
|
||||
-- Per-DB-function timing (microseconds, accumulated)
|
||||
local profDB = {
|
||||
DamageDone = 0, DamageTaken = 0, EnemyDamage = 0, DeathHistory = 0,
|
||||
Healing = 0, HealingTaken = 0, BuildBuffs = 0, Other = 0, count = 0
|
||||
}
|
||||
local profCurrent = nil
|
||||
|
||||
-- Hook the parser's OnEvent to measure original mode
|
||||
@@ -204,6 +212,32 @@ local function profReport(label, tbl)
|
||||
"|cff00ff00[%s]|r %d events, %.1fms total, %.1f us/event, %+.1f KB gc",
|
||||
label, tbl.events, totalMs, avgUs, gcDelta))
|
||||
|
||||
-- Detail breakdown for CLEU
|
||||
if label == "CLEU" and profDetail.count > 0 then
|
||||
local avgGetInfo = profDetail.getInfo / profDetail.count
|
||||
local avgDB = profDetail.dbCalls / profDetail.count
|
||||
local avgTotal = profDetail.total / profDetail.count
|
||||
DEFAULT_CHAT_FRAME:AddMessage(string.format(
|
||||
"|cff00ff00[CLEU detail]|r getInfo=%.1f us, db+logic=%.1f us, total=%.1f us (%d events)",
|
||||
avgGetInfo, avgDB, avgTotal, profDetail.count))
|
||||
-- Per-DB-function breakdown
|
||||
if profDB.count > 0 then
|
||||
local n = profDB.count
|
||||
DEFAULT_CHAT_FRAME:AddMessage(string.format(
|
||||
"|cff00ff00[DB funcs]|r DD=%.0f DT=%.0f ED=%.0f DH=%.0f H=%.0f HT=%.0f Buf=%.0f Oth=%.0f us/evt",
|
||||
profDB.DamageDone/n, profDB.DamageTaken/n, profDB.EnemyDamage/n,
|
||||
profDB.DeathHistory/n, profDB.Healing/n, profDB.HealingTaken/n,
|
||||
profDB.BuildBuffs/n, profDB.Other/n))
|
||||
end
|
||||
profDetail.getInfo = 0
|
||||
profDetail.dbCalls = 0
|
||||
profDetail.total = 0
|
||||
profDetail.count = 0
|
||||
profDB.DamageDone = 0; profDB.DamageTaken = 0; profDB.EnemyDamage = 0
|
||||
profDB.DeathHistory = 0; profDB.Healing = 0; profDB.HealingTaken = 0
|
||||
profDB.BuildBuffs = 0; profDB.Other = 0; profDB.count = 0
|
||||
end
|
||||
|
||||
-- If we have both measurements, show comparison
|
||||
if lastCLEUAvg and lastOrigAvg and lastOrigAvg > 0 then
|
||||
local pct = ((lastCLEUAvg - lastOrigAvg) / lastOrigAvg) * 100
|
||||
@@ -216,38 +250,42 @@ local function profReport(label, tbl)
|
||||
end
|
||||
end
|
||||
|
||||
-- /dpsbench -- enables per-combat A/B profiling. Each combat: measure, report, flip.
|
||||
local benchActive = true
|
||||
-- Always-on CLEU profiling: report stats at every combat end.
|
||||
-- /dpsbench toggles A/B mode (alternates CLEU/ORIGINAL each combat for comparison).
|
||||
local benchActive = false
|
||||
|
||||
local benchFrame = CreateFrame("Frame")
|
||||
|
||||
local function benchCombatStart()
|
||||
if not benchActive then return end
|
||||
local function combatStart()
|
||||
profCurrent = cleuActive and profCLEU or profOrig
|
||||
profReset(profCurrent)
|
||||
debugprofilestart() -- start the monotonic clock for this combat
|
||||
debugprofilestart()
|
||||
profiling = true
|
||||
local label = cleuActive and "CLEU" or "ORIGINAL"
|
||||
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[DPS Bench]|r combat started, measuring %s", label))
|
||||
if benchActive then
|
||||
local label = cleuActive and "CLEU" or "ORIGINAL"
|
||||
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[DPS Bench]|r measuring %s", label))
|
||||
end
|
||||
end
|
||||
|
||||
local function benchCombatEnd()
|
||||
if not benchActive or not profiling then return end
|
||||
local function combatEnd()
|
||||
if not profiling then return end
|
||||
profiling = false
|
||||
local label = cleuActive and "CLEU" or "ORIGINAL"
|
||||
profReport(label, profCurrent)
|
||||
toggle()
|
||||
DEFAULT_CHAT_FRAME:AddMessage(string.format(
|
||||
"|cff00ff00[DPS Bench]|r next combat will use: %s", cleuActive and "CLEU" or "ORIGINAL"))
|
||||
if benchActive then
|
||||
toggle()
|
||||
DEFAULT_CHAT_FRAME:AddMessage(string.format(
|
||||
"|cff00ff00[DPS Bench]|r next combat: %s", cleuActive and "CLEU" or "ORIGINAL"))
|
||||
end
|
||||
end
|
||||
|
||||
benchFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
|
||||
benchFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
|
||||
benchFrame:SetScript("OnEvent", function()
|
||||
if event == "PLAYER_REGEN_DISABLED" then
|
||||
benchCombatStart()
|
||||
combatStart()
|
||||
elseif event == "PLAYER_REGEN_ENABLED" then
|
||||
benchCombatEnd()
|
||||
combatEnd()
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -256,11 +294,10 @@ SlashCmdList["DPSBENCH"] = function()
|
||||
benchActive = not benchActive
|
||||
if benchActive then
|
||||
DEFAULT_CHAT_FRAME:AddMessage(string.format(
|
||||
"|cff00ff00[DPS Bench]|r enabled. Current mode: %s. Enter combat to begin.",
|
||||
"|cff00ff00[DPS Bench]|r A/B enabled. Current mode: %s. Enter combat to begin.",
|
||||
cleuActive and "CLEU" or "ORIGINAL"))
|
||||
else
|
||||
profiling = false
|
||||
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[DPS Bench]|r disabled.")
|
||||
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[DPS Bench]|r A/B disabled (stats still reported).")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -280,16 +317,17 @@ local FailDB = DPSMate.Parser.FailDB
|
||||
|
||||
cleuHandler = function()
|
||||
if not CombatLogGetCurrentEventInfo then return end
|
||||
-- Single call — positions differ by prefix type:
|
||||
-- Swing: p1=amount, p2=overkill, p3=school, ...
|
||||
-- Spell: p1=spellId, p2=spellName, p3=spellSchool, p4=amount, ...
|
||||
-- Env: p1=envType, p2=amount, ...
|
||||
|
||||
local t0 = profiling and debugprofilestop()
|
||||
|
||||
local sub, srcGUID, srcName, srcFlags, srcRaidFlags,
|
||||
dstGUID, dstName, dstFlags, dstRaidFlags,
|
||||
p1, p2, p3, p4, p5, p6, p7, p8, p9,
|
||||
p10, p11, p12 = CombatLogGetCurrentEventInfo()
|
||||
if not sub then return end
|
||||
|
||||
local t1 = profiling and debugprofilestop()
|
||||
|
||||
if not srcName or srcName == "" then srcName = "Unknown" end
|
||||
if not dstName or dstName == "" then dstName = "Unknown" end
|
||||
|
||||
@@ -306,11 +344,17 @@ cleuHandler = function()
|
||||
local crush = crushing and 1 or 0
|
||||
local hit = (crit == 0 and glanc == 0 and crush == 0) and 1 or 0
|
||||
|
||||
local ta, tb
|
||||
if profiling then ta = debugprofilestop() end
|
||||
DB:DamageDone(srcName, AAttack, hit, crit, 0, 0, 0, 0, amount, glanc, 0)
|
||||
if profiling then tb = debugprofilestop(); profDB.DamageDone = profDB.DamageDone + (tb - ta); ta = tb end
|
||||
DB:DamageTaken(dstName, AAttack, hit, crit, 0, 0, 0, 0, amount, srcName, crush, 0)
|
||||
if profiling then tb = debugprofilestop(); profDB.DamageTaken = profDB.DamageTaken + (tb - ta); ta = tb end
|
||||
DB:EnemyDamage(1, DPSMateEDT, dstName, AAttack, hit, crit, 0, 0, 0, 0, amount, srcName, 0, crush)
|
||||
DB:EnemyDamage(2, DPSMateEDD, srcName, AAttack, hit, crit, 0, 0, 0, 0, amount, dstName, 0, 0)
|
||||
if profiling then tb = debugprofilestop(); profDB.EnemyDamage = profDB.EnemyDamage + (tb - ta); ta = tb end
|
||||
DB:DeathHistory(dstName, srcName, AAttack, amount, hit, crit, "hit", crush)
|
||||
if profiling then tb = debugprofilestop(); profDB.DeathHistory = profDB.DeathHistory + (tb - ta); profDB.count = profDB.count + 1 end
|
||||
if absorbed > 0 then
|
||||
DB:SetUnregisterVariables(absorbed, AAttack, srcName)
|
||||
DB:Absorb(AAttack, dstName, srcName)
|
||||
@@ -346,11 +390,17 @@ cleuHandler = function()
|
||||
local hit = (crit == 0 and glanc == 0 and crush == 0) and 1 or 0
|
||||
local abilityName = (sub == "SPELL_PERIODIC_DAMAGE") and (spellName .. "(Periodic)") or spellName
|
||||
|
||||
local ta, tb
|
||||
if profiling then ta = debugprofilestop() end
|
||||
DB:DamageDone(srcName, abilityName, hit, crit, 0, 0, 0, 0, amount, glanc, 0)
|
||||
if profiling then tb = debugprofilestop(); profDB.DamageDone = profDB.DamageDone + (tb - ta); ta = tb end
|
||||
DB:DamageTaken(dstName, abilityName, hit, crit, 0, 0, 0, 0, amount, srcName, crush, 0)
|
||||
if profiling then tb = debugprofilestop(); profDB.DamageTaken = profDB.DamageTaken + (tb - ta); ta = tb end
|
||||
DB:EnemyDamage(1, DPSMateEDT, dstName, abilityName, hit, crit, 0, 0, 0, 0, amount, srcName, 0, crush)
|
||||
DB:EnemyDamage(2, DPSMateEDD, srcName, abilityName, hit, crit, 0, 0, 0, 0, amount, dstName, 0, 0)
|
||||
if profiling then tb = debugprofilestop(); profDB.EnemyDamage = profDB.EnemyDamage + (tb - ta); ta = tb end
|
||||
DB:DeathHistory(dstName, srcName, abilityName, amount, hit, crit, "hit", crush)
|
||||
if profiling then tb = debugprofilestop(); profDB.DeathHistory = profDB.DeathHistory + (tb - ta); profDB.count = profDB.count + 1 end
|
||||
if spellSchool then DB:AddSpellSchool(abilityName, spellSchool) end
|
||||
if absorbed > 0 then
|
||||
DB:SetUnregisterVariables(absorbed, abilityName, srcName)
|
||||
@@ -396,10 +446,15 @@ cleuHandler = function()
|
||||
local effective = amount - overheal
|
||||
if effective < 0 then effective = 0 end
|
||||
|
||||
local ta, tb
|
||||
if profiling then ta = debugprofilestop() end
|
||||
DB:Healing(1, DPSMateHealingTaken, srcName, spellName, hit, crit, effective)
|
||||
DB:Healing(2, DPSMateOverhealing, srcName, spellName, hit, crit, overheal)
|
||||
if profiling then tb = debugprofilestop(); profDB.Healing = profDB.Healing + (tb - ta); ta = tb end
|
||||
DB:HealingTaken(1, DPSMateHealingTaken, srcName, spellName, hit, crit, effective, dstName)
|
||||
if profiling then tb = debugprofilestop(); profDB.HealingTaken = profDB.HealingTaken + (tb - ta); ta = tb end
|
||||
DB:DeathHistory(dstName, srcName, spellName, effective, hit, crit, "heal", 0)
|
||||
if profiling then tb = debugprofilestop(); profDB.DeathHistory = profDB.DeathHistory + (tb - ta); profDB.count = profDB.count + 1 end
|
||||
|
||||
-- ========================================================================
|
||||
-- AURA events (+ absorb shield lifecycle)
|
||||
@@ -481,6 +536,14 @@ cleuHandler = function()
|
||||
if not Parser.petToOwnerMap[dstName] then Parser.petToOwnerMap[dstName] = {} end
|
||||
Parser.petToOwnerMap[dstName][srcName] = true
|
||||
end
|
||||
|
||||
if profiling and t0 then
|
||||
local t2 = debugprofilestop()
|
||||
profDetail.getInfo = profDetail.getInfo + (t1 - t0)
|
||||
profDetail.dbCalls = profDetail.dbCalls + (t2 - t1)
|
||||
profDetail.total = profDetail.total + (t2 - t0)
|
||||
profDetail.count = profDetail.count + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Set the measured wrapper as the actual handler
|
||||
|
||||
@@ -875,6 +875,7 @@ function DPSMate.DB:OnGroupUpdate()
|
||||
local num = GetNumRaidMembers()
|
||||
DPSMate.Parser.TargetParty = {}
|
||||
DPSMate.Parser.petToOwnerMap = {}
|
||||
petQualifiedCache = {}
|
||||
if num<=0 then
|
||||
type = "party"
|
||||
num = GetNumPartyMembers()
|
||||
@@ -993,18 +994,36 @@ function DPSMate.DB:OnGroupUpdate()
|
||||
DPSMate.Parser:AssociateShaman("None", "None", true)
|
||||
end
|
||||
|
||||
-- Cache: raw pet name -> qualified "PetName (OwnerName)" to avoid repeated pairs() iteration
|
||||
local petQualifiedCache = {}
|
||||
|
||||
function DPSMate.DB:BuildUser(Dname, Dclass)
|
||||
if not Dname then Dname = "?!NIL Name?!" end
|
||||
-- Auto-qualify raw pet/totem names via petToOwnerMap (single-owner only)
|
||||
local pom = DPSMate.Parser.petToOwnerMap
|
||||
if pom and pom[Dname] then
|
||||
local count, singleOwner = 0, nil
|
||||
for o, _ in pairs(pom[Dname]) do
|
||||
count = count + 1
|
||||
singleOwner = o
|
||||
end
|
||||
if count == 1 then
|
||||
Dname = Dname .. " (" .. singleOwner .. ")"
|
||||
-- Fast path: already-known non-pet name — skip regex + petToOwnerMap entirely
|
||||
local u = DPSUser[Dname]
|
||||
if u then return u[1] end
|
||||
-- Check pet name cache (avoids repeated pairs() iteration on petToOwnerMap)
|
||||
local cached = petQualifiedCache[Dname]
|
||||
if cached then
|
||||
u = DPSUser[cached]
|
||||
if u then return u[1] end
|
||||
Dname = cached
|
||||
else
|
||||
-- Auto-qualify raw pet/totem names via petToOwnerMap (single-owner only)
|
||||
local pom = DPSMate.Parser.petToOwnerMap
|
||||
if pom and pom[Dname] then
|
||||
local count, singleOwner = 0, nil
|
||||
for o, _ in pairs(pom[Dname]) do
|
||||
count = count + 1
|
||||
singleOwner = o
|
||||
end
|
||||
if count == 1 then
|
||||
local qualified = Dname .. " (" .. singleOwner .. ")"
|
||||
petQualifiedCache[Dname] = qualified
|
||||
u = DPSUser[qualified]
|
||||
if u then return u[1] end
|
||||
Dname = qualified
|
||||
end
|
||||
end
|
||||
end
|
||||
local _,_, pet,owner = strfind(Dname,"(.+)%s%((.+)%)")
|
||||
@@ -1102,7 +1121,8 @@ function DPSMate.DB:Threat(cause, spellname, target, value, amount)
|
||||
target = self:BuildUser(target)
|
||||
cause = self:BuildUser(cause)
|
||||
spellname = self:BuildAbility(spellname)
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do
|
||||
local val = tablemodes[cat]
|
||||
if not DPSThreat[cat] then DPSThreat[cat] = {} end
|
||||
if not DPSThreat[cat][cause] then
|
||||
DPSThreat[cat][cause] = {}
|
||||
@@ -1743,7 +1763,7 @@ function DPSMate.DB:RegisterAbsorb(owner, ability, abilityTarget)
|
||||
owner = self:BuildUser(owner)
|
||||
abilityTarget = self:BuildUser(abilityTarget)
|
||||
ability = self:BuildAbility(ability)
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do local val = tablemodes[cat]
|
||||
if not DPSAbsorb[cat] then DPSAbsorb[cat] = {} end
|
||||
if not DPSAbsorb[cat][abilityTarget] then
|
||||
DPSAbsorb[cat][abilityTarget] = {}
|
||||
@@ -1777,7 +1797,7 @@ function DPSMate.DB:UnregisterAbsorb(ability, abilityTarget)
|
||||
ability = self:BuildAbility(ability)
|
||||
abilityTarget = self:BuildUser(abilityTarget)
|
||||
local AbsorbingAbility
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do local val = tablemodes[cat]
|
||||
AbsorbingAbility = self:GetActiveAbsorbAbilityByPlayer(ability, abilityTarget, cat)
|
||||
if AbsorbingAbility[1] then
|
||||
path = DPSAbsorb[cat][abilityTarget][AbsorbingAbility[1]][AbsorbingAbility[2]][AbsorbingAbility[3]]["i"]
|
||||
@@ -1889,7 +1909,7 @@ function DPSMate.DB:Absorb(ability, abilityTarget, incTarget)
|
||||
abilityTarget = self:BuildUser(abilityTarget)
|
||||
ability = self:BuildAbility(ability)
|
||||
local AbsorbingAbility
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do local val = tablemodes[cat]
|
||||
local abilityName = DPSMate:GetAbilityById(ability)
|
||||
local abilityEntry = abilityName and DPSMateAbility[abilityName]
|
||||
if not abilityEntry then break end
|
||||
@@ -2094,7 +2114,7 @@ function DPSMate.DB:Dispels(cause, Dname, target, ability)
|
||||
target = self:BuildUser(target)
|
||||
Dname = self:BuildAbility(Dname)
|
||||
ability = self:BuildAbility(ability)
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do local val = tablemodes[cat]
|
||||
if not DPSDispel[cat] then DPSDispel[cat] = {} end
|
||||
if not DPSDispel[cat][cause] then
|
||||
DPSDispel[cat][cause] = {
|
||||
@@ -2130,13 +2150,13 @@ function DPSMate.DB:UnregisterDeath(target)
|
||||
if strfind(target, "%s") then return end
|
||||
target = self:BuildUser(target)
|
||||
local p
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do
|
||||
if DPSDeath[cat] and DPSDeath[cat][target] and DPSDeath[cat][target][1] then
|
||||
DPSDeath[cat][target][1]["i"][1]=1
|
||||
DPSDeath[cat][target][1]["i"][2]=GameTime_GT()
|
||||
if cat==1 and DPSMate.Parser.TargetParty[DPSMate:GetUserById(target)] and DPSDeath[cat][target][1][1] then
|
||||
p = DPSDeath[cat][target][1][1]
|
||||
DPSMate:Broadcast(4, DPSMate:GetUserById(target), DPSMate:GetUserById(p[1]), DPSMate:GetAbilityById(p[2]), p[3])
|
||||
DPSMate:Broadcast(4, DPSMate:GetUserById(target), DPSMate:GetUserById(p[1]), DPSMate:GetAbilityById(p[2]), p[3])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2148,7 +2168,8 @@ function DPSMate.DB:DeathHistory(target, cause, ability, amount, hit, crit, type
|
||||
cause = self:BuildUser(cause)
|
||||
ability = self:BuildAbility(ability)
|
||||
local hitCritCrush
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do
|
||||
local val = tablemodes[cat]
|
||||
if not DPSDeath[cat] then DPSDeath[cat] = {} end
|
||||
if not DPSDeath[cat][target] then
|
||||
DPSDeath[cat][target] = {}
|
||||
@@ -2245,7 +2266,7 @@ function DPSMate.DB:Kick(cause, target, causeAbility, targetAbility)
|
||||
cause = self:BuildUser(cause)
|
||||
causeAbility = self:BuildAbility(causeAbility)
|
||||
targetAbility = self:BuildAbility(targetAbility)
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do local val = tablemodes[cat]
|
||||
if not DPSInterrupt[cat] then DPSInterrupt[cat] = {} end
|
||||
if not DPSInterrupt[cat][cause] then
|
||||
DPSInterrupt[cat][cause] = {
|
||||
@@ -2310,7 +2331,7 @@ function DPSMate.DB:BuildBuffs(cause, target, ability, bool)
|
||||
target = self:BuildUser(target)
|
||||
cause = self:BuildUser(cause)
|
||||
ability = self:BuildAbility(ability)
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do local val = tablemodes[cat]
|
||||
if not DPSAurasGained[cat] then DPSAurasGained[cat] = {} end
|
||||
if not DPSAurasGained[cat][target] then
|
||||
DPSAurasGained[cat][target] = {}
|
||||
@@ -2347,7 +2368,7 @@ function DPSMate.DB:DestroyBuffs(target, ability)
|
||||
target = self:BuildUser(target)
|
||||
ability = self:BuildAbility(ability)
|
||||
local TL
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do local val = tablemodes[cat]
|
||||
if not DPSAurasGained[cat] then DPSAurasGained[cat] = {} end
|
||||
if not DPSAurasGained[cat][target] then
|
||||
DPSAurasGained[cat][target] = {}
|
||||
@@ -2557,7 +2578,7 @@ function DPSMate.DB:BuildFail(type, user, cause, ability, amount)
|
||||
cause = self:BuildUser(cause)
|
||||
ability = self:BuildAbility(ability)
|
||||
local time = GameTime_GT()
|
||||
for cat, val in pairs(tablemodes) do
|
||||
for cat = 1, 2 do local val = tablemodes[cat]
|
||||
if not DPSFail[cat] then DPSFail[cat] = {} end
|
||||
if not DPSFail[cat][cause] then
|
||||
DPSFail[cat][cause] = {}
|
||||
|
||||
@@ -836,3 +836,43 @@ Called from:
|
||||
### Module currently DISABLED by default
|
||||
|
||||
Build flag changed to `orelse false` in build.zig. Enable with `-Dframecrash=true`.
|
||||
|
||||
---
|
||||
|
||||
## Uninvestigated: NULL this in PrepareModelForRender (0x710450)
|
||||
|
||||
Crash observed 2026-03-25:
|
||||
```
|
||||
0x0071045A: MOV EAX, [ESI + 0x10] -- ESI=ECX=0 (NULL this pointer)
|
||||
ACCESS_VIOLATION reading 0x00000010
|
||||
```
|
||||
|
||||
Call chain:
|
||||
```
|
||||
CM2Scene_ExecuteRenderPass (0x708900)
|
||||
-> RenderBatches (0x70B371)
|
||||
-> DrawCallback (0x70D960)
|
||||
-> ValidateClientObjectAndProcessWithManagerLookup (0x6C7780)
|
||||
-> PrepareModelForRender (0x710450) -- CRASH
|
||||
```
|
||||
|
||||
Root cause: at 0x6C77A8, `MOV EAX, [EAX+0xD8]` reads a field that can be NULL. The
|
||||
parent object is NULL-checked but the +0xD8 field is not. Then `MOV ECX, EAX` passes
|
||||
NULL as this to PrepareModelForRender.
|
||||
|
||||
```asm
|
||||
0x6C77A4: TEST EAX, EAX ; parent object null check
|
||||
0x6C77A6: JZ skip ; OK
|
||||
0x6C77A8: MOV EAX, [EAX+0xD8] ; read child -- CAN BE NULL
|
||||
0x6C77AE: PUSH 0
|
||||
0x6C77B0: PUSH 0
|
||||
0x6C77B2: MOV ECX, EAX ; this = possibly NULL
|
||||
0x6C77B4: CALL PrepareModelForRender ; CRASH if ECX=0
|
||||
```
|
||||
|
||||
PrepareModelForRender: thiscall(ECX=this, stack: 2 params), RET 0x8.
|
||||
|
||||
Fix options:
|
||||
1. Hook PrepareModelForRender with NULL-this guard (protects all callers)
|
||||
2. Patch call site at 0x6C77AE to add TEST EAX,EAX + JZ (needs code cave, only 11 bytes available for 15 bytes of patched code)
|
||||
3. Binary patch PrepareModelForRender prologue (needs to preserve RET 0x8 stack cleanup)
|
||||
|
||||
@@ -3,5 +3,4 @@
|
||||
## Notes: Session-based combat and chat log management
|
||||
## Version: 1.0
|
||||
|
||||
|
||||
LogSessions.lua
|
||||
|
||||
@@ -550,14 +550,14 @@ fn worldPosToMinimapCoords(
|
||||
});
|
||||
}
|
||||
|
||||
fn getFrameUnkScale(info: u32) f32 {
|
||||
fn getFrameUnkScale(info: u32) ?f32 {
|
||||
const frame = hook.readMem(u32, info + ADDR.MI_FRAME);
|
||||
if (!isValidPtr(frame)) return 1.0;
|
||||
if (!isValidPtr(frame)) return null;
|
||||
const fsp = frame + ADDR.FRAME_SCRIPT_PART;
|
||||
const vtable = hook.readMem(u32, fsp);
|
||||
if (!isValidPtr(vtable)) return 1.0;
|
||||
if (!isValidPtr(vtable)) return null;
|
||||
const fn_addr = hook.readMem(u32, vtable + 7 * 4);
|
||||
if (!isValidPtr(fn_addr)) return 1.0;
|
||||
if (!isValidPtr(fn_addr)) return null;
|
||||
// __thiscall(fsp_ECX) → f32 on FPU ST(0)
|
||||
return hook.call(fn (u32) callconv(hook.cc.thiscall) f32, fn_addr, .{fsp});
|
||||
}
|
||||
@@ -837,6 +837,10 @@ fn trackObject(info: u32, guid_lo: u32, guid_hi: u32, blip: Blip) void {
|
||||
|
||||
// Cache minimap info per frame — same for all objects in one enumeration cycle
|
||||
if (!g_minimap_info.valid) {
|
||||
// If the minimap frame scale can't be resolved (transient state during
|
||||
// zone transitions), skip all blips this cycle rather than projecting
|
||||
// with a wrong scale that pushes blips outside the circle.
|
||||
const unk_scale = getFrameUnkScale(info) orelse return;
|
||||
g_minimap_info = .{
|
||||
.cur = .{
|
||||
.x = hook.readMem(f32, info + ADDR.MI_POS),
|
||||
@@ -845,7 +849,7 @@ fn trackObject(info: u32, guid_lo: u32, guid_hi: u32, blip: Blip) void {
|
||||
},
|
||||
.radius = hook.readMem(f32, info + ADDR.MI_RADIUS),
|
||||
.layout_scale = hook.readMem(f32, info + ADDR.MI_LAYOUT_SCALE),
|
||||
.unk_scale = getFrameUnkScale(info),
|
||||
.unk_scale = unk_scale,
|
||||
.valid = true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! SuperWeirdo -- GO loot sparkle for interactable objects
|
||||
//!
|
||||
//! Replicates the SuperWoW loot sparkle feature via two patches and three
|
||||
//! cleanup hooks:
|
||||
//!
|
||||
//! Patches:
|
||||
//! 1. UpdateUnitStatusFlags (0x60c520) status flags initializer:
|
||||
//! 0x60c560: imm32 0x00000000 -> 0x00002000
|
||||
//! Sets bit 13 in status flags, enabling sparkle visual on qualifying objects.
|
||||
//!
|
||||
//! 2. AttachSpellVisualToUnit (0x61fc00) object type mask:
|
||||
//! 0x61fc1c: MOV ECX,0x8 -> MOV ECX,0x1
|
||||
//! Changes type filter from Unit-only (0x8) to Object base (0x1),
|
||||
//! allowing visual attachments on GOs.
|
||||
//!
|
||||
//! 3. Data constant at 0x838f7c: 0x14 -> 0x0D
|
||||
//! Adjusts sparkle-related rendering parameter.
|
||||
//!
|
||||
//! Cleanup hooks (prevent leaked render nodes on GO lifecycle changes):
|
||||
//! Vanilla GOs never had visual attachments, so their destroy/update paths
|
||||
//! don't clean them up. We hook three GO lifecycle functions and walk the
|
||||
//! render node list (obj+0xB4) to unlink+release any sparkle nodes
|
||||
//! (identified by node+0x2C & 0x8).
|
||||
|
||||
const std = @import("std");
|
||||
const hook = @import("zhook");
|
||||
const logging = @import("../logging.zig");
|
||||
const mod_mutex = @import("../mutex.zig");
|
||||
|
||||
pub const module_name: [*:0]const u8 = "superweirdo";
|
||||
|
||||
// -- Sparkle cleanup logic ----------------------------------------------------
|
||||
|
||||
/// Walk obj+0xB4 render node linked list. Find the first node with flag 0x8
|
||||
/// set at node+0x2C (visual attachment marker), unlink and release it.
|
||||
fn cleanupSparkleNodes(obj: u32) void {
|
||||
var node: u32 = hook.readMem(u32, obj + 0xB4);
|
||||
while (node != 0) {
|
||||
const flags: u8 = hook.readMem(u8, node + 0x2C);
|
||||
if (flags & 0x8 != 0) {
|
||||
// UnlinkObjectFromList: __thiscall(ECX=node)
|
||||
hook.call(fn (u32) callconv(hook.cc.thiscall) void, 0x6203a0, .{node});
|
||||
// DecrementRefCountAndCleanup: __thiscall(ECX=node)
|
||||
hook.call(fn (u32) callconv(hook.cc.thiscall) void, 0x6210e0, .{node});
|
||||
return;
|
||||
}
|
||||
node = hook.readMem(u32, node + 0x8C);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Detour hooks -------------------------------------------------------------
|
||||
|
||||
// Hook 1: UpdateGameObjectAnimationAndDB (0x5f7c40)
|
||||
// __thiscall(ECX=this), 3 stack params, RET 0xC
|
||||
const GoAnimFn = fn (u32, u32, u32, u32) callconv(hook.cc.thiscall) void;
|
||||
var go_anim_hook: hook.Detour(GoAnimFn) = .{};
|
||||
|
||||
fn goAnimDetour(this: u32, p1: u32, p2: u32, p3: u32) callconv(hook.cc.thiscall) void {
|
||||
go_anim_hook.callOriginal(.{ this, p1, p2, p3 });
|
||||
cleanupSparkleNodes(this);
|
||||
}
|
||||
|
||||
// Hook 2: GO update function (0x5f7ed0)
|
||||
// __thiscall(ECX=this), no stack params, RET
|
||||
const GoThiscallFn = fn (u32) callconv(hook.cc.thiscall) void;
|
||||
var go_update_hook: hook.Detour(GoThiscallFn) = .{};
|
||||
|
||||
fn goUpdateDetour(this: u32) callconv(hook.cc.thiscall) void {
|
||||
go_update_hook.callOriginal(.{this});
|
||||
cleanupSparkleNodes(this);
|
||||
}
|
||||
|
||||
// Hook 3: GameObjectCacheCallback (0x5f7e40)
|
||||
// __thiscall(ECX=this), no stack params, RET
|
||||
var go_cache_hook: hook.Detour(GoThiscallFn) = .{};
|
||||
|
||||
fn goCacheDetour(this: u32) callconv(hook.cc.thiscall) void {
|
||||
cleanupSparkleNodes(this);
|
||||
go_cache_hook.callOriginal(.{this});
|
||||
}
|
||||
|
||||
// -- Byte patches -------------------------------------------------------------
|
||||
|
||||
const Patch = struct {
|
||||
addr: usize,
|
||||
old: []const u8,
|
||||
new: []const u8,
|
||||
desc: []const u8,
|
||||
};
|
||||
|
||||
const byte_patches = [_]Patch{
|
||||
// UpdateUnitStatusFlags: init flags to 0x2000 (bit 13) instead of 0
|
||||
.{
|
||||
.addr = 0x0060c560,
|
||||
.old = &.{ 0x00, 0x00, 0x00, 0x00 },
|
||||
.new = &.{ 0x00, 0x20, 0x00, 0x00 },
|
||||
.desc = "status flags init 0x2000",
|
||||
},
|
||||
// AttachSpellVisualToUnit: type mask 0x8 (Unit) -> 0x1 (Object base)
|
||||
.{
|
||||
.addr = 0x0061fc1c,
|
||||
.old = &.{ 0xB9, 0x08, 0x00, 0x00 },
|
||||
.new = &.{ 0xB9, 0x01, 0x00, 0x00 },
|
||||
.desc = "visual attach type mask",
|
||||
},
|
||||
// Data value at 0x838f7c: 0x14 -> 0x0D
|
||||
.{
|
||||
.addr = 0x00838f7c,
|
||||
.old = &.{ 0x14, 0x00, 0x00, 0x00 },
|
||||
.new = &.{ 0x0D, 0x00, 0x00, 0x00 },
|
||||
.desc = "sparkle data constant",
|
||||
},
|
||||
};
|
||||
|
||||
// -- Module interface ---------------------------------------------------------
|
||||
|
||||
var g_mutex: ?*anyopaque = null;
|
||||
var g_is_hook_owner: bool = false;
|
||||
var g_patches_applied: [byte_patches.len]bool = .{false} ** byte_patches.len;
|
||||
var log: logging.Logger = .{};
|
||||
|
||||
pub fn isActive() bool {
|
||||
return g_is_hook_owner;
|
||||
}
|
||||
|
||||
pub fn installHooks() void {
|
||||
const result = mod_mutex.acquire(module_name);
|
||||
g_mutex = result.handle;
|
||||
g_is_hook_owner = result.is_owner;
|
||||
if (!g_is_hook_owner) return;
|
||||
|
||||
log = logging.Logger.open(module_name, .console);
|
||||
|
||||
// Byte patches
|
||||
inline for (byte_patches, 0..) |p, i| {
|
||||
const current = hook.readMem([p.old.len]u8, p.addr);
|
||||
if (std.mem.eql(u8, ¤t, p.old)) {
|
||||
hook.writeProtected(p.addr, p.new);
|
||||
g_patches_applied[i] = true;
|
||||
log.fmt("{s}: patched\n", .{p.desc});
|
||||
} else if (std.mem.eql(u8, ¤t, p.new)) {
|
||||
g_patches_applied[i] = true;
|
||||
log.fmt("{s}: already active\n", .{p.desc});
|
||||
} else {
|
||||
log.fmt("{s}: unexpected bytes at 0x{x}, skipping\n", .{ p.desc, p.addr });
|
||||
}
|
||||
}
|
||||
|
||||
// Detour hooks for GO sparkle cleanup
|
||||
if (go_anim_hook.attach(0x5f7c40, &goAnimDetour) != .ok) {
|
||||
log.print("WARN: failed to attach goAnimDetour\n");
|
||||
}
|
||||
if (go_update_hook.attach(0x5f7ed0, &goUpdateDetour) != .ok) {
|
||||
log.print("WARN: failed to attach goUpdateDetour\n");
|
||||
}
|
||||
if (go_cache_hook.attach(0x5f7e40, &goCacheDetour) != .ok) {
|
||||
log.print("WARN: failed to attach goCacheDetour\n");
|
||||
}
|
||||
log.print("GO cleanup hooks installed\n");
|
||||
}
|
||||
|
||||
pub fn removeHooks() void {
|
||||
if (!g_is_hook_owner) return;
|
||||
|
||||
go_cache_hook.detach();
|
||||
go_update_hook.detach();
|
||||
go_anim_hook.detach();
|
||||
|
||||
inline for (byte_patches, 0..) |p, i| {
|
||||
if (g_patches_applied[i]) {
|
||||
hook.writeProtected(p.addr, p.old);
|
||||
g_patches_applied[i] = false;
|
||||
log.fmt("{s}: restored\n", .{p.desc});
|
||||
}
|
||||
}
|
||||
|
||||
log.close();
|
||||
mod_mutex.release(&g_mutex);
|
||||
g_is_hook_owner = false;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# WoW 1.12.1 CPU Profiling Analysis
|
||||
|
||||
Source: `perf.data.perfparser` (July 2025 recording via hotspot)
|
||||
Exported: `cycles.out` (stack-collapsed format)
|
||||
|
||||
## Full CPU Time Breakdown
|
||||
|
||||
| % | Category | Notes |
|
||||
|---|---|---|
|
||||
| 33.18% | **Hooked WoW functions** | 38 hooks in transform44 module |
|
||||
| 19.19% | GPU/Driver | d3d9.dll (DXVK) + amdvlk32.so -- untouchable |
|
||||
| 18.74% | WoW long tail | ~2000+ functions each <0.15% -- not worth hooking |
|
||||
| 13.15% | WoW mid-tier | 50 functions at 0.15-0.39% -- hookable but diminishing returns |
|
||||
| 8.43% | Lua VM | lua_vm_execute, luaS_newlstr, etc. -- interpreter overhead |
|
||||
| 5.38% | Unresolved | WoW.exe code not in Ghidra symbol map |
|
||||
| 1.93% | Wine/System | ntdll, kernel32, wine internals |
|
||||
|
||||
## Hooked Functions (38 total, by self-time %)
|
||||
|
||||
### Already existed (6 hooks)
|
||||
| % | Address | Name | Convention |
|
||||
|---|---|---|---|
|
||||
| 2.80% | 0x714260 | transformMatrix4x4 | thiscall RET 0x10 |
|
||||
| 2.06% | 0x713d50 | findInterpolationIndices | thiscall RET 0x10 |
|
||||
| 0.73% | 0x707680 | renderFrame | thiscall RET 0x4 |
|
||||
| 0.44% | 0x713ea0 | interpolateAnimationKeyframes | fastcall RET 0x8 |
|
||||
| - | 0x708900 | executeSceneRenderPass | thiscall RET 0x4 |
|
||||
| - | 0x76FB00 | RenderTextureQuads | fastcall RET |
|
||||
| - | 0x616620 | CMovement::Process | thiscall RET 0x8 |
|
||||
|
||||
### New perf-identified hotspots (28 hooks)
|
||||
| % | Address | Name | Convention |
|
||||
|---|---|---|---|
|
||||
| 3.95% | 0x6318c0 | ClipPolygonToSinglePlane | stdcall RET 0x4 |
|
||||
| 3.65% | 0x5ca2d0 | GetOrCreateCharacterGlyph | stdcall RET 0x8 |
|
||||
| 1.73% | 0x7b2a50 | RenderParticleSprites | thiscall RET 0x8 |
|
||||
| 1.57% | 0x6abc40 | processLinkedListCollision | thiscall RET 0x8 |
|
||||
| 1.18% | 0x6afad0 | UpdateEntityAndChunksPositions | thiscall RET |
|
||||
| 1.15% | 0x765650 | renderAllFrameLayers | thiscall RET 0x4 |
|
||||
| 1.07% | 0x5ccbe0 | RenderTextToVertexBuffer | thiscall RET 0x18 |
|
||||
| 1.02% | 0x58a3d0 | RenderComplexGeometry | stdcall RET 0x24 |
|
||||
| 0.93% | 0x6c1f70 | updateEntitiesInBounds | thiscall RET 0x4 |
|
||||
| 0.88% | 0x5cdf40 | updateTextFrameCounter | thiscall RET |
|
||||
| 0.66% | 0x6816f0 | AddToSpatialGrid | thiscall RET |
|
||||
| 0.65% | 0x7c29f0 | ray_tri_intersect_idx_ushort | stdcall RET 0x10 |
|
||||
| 0.64% | 0x710b90 | ManageLinkedListNode | thiscall RET 0x4 |
|
||||
| 0.63% | 0x7b9b10 | calculateColorValues | thiscall RET 0x18 |
|
||||
| 0.61% | 0x686640 | SetVector3 | thiscall RET |
|
||||
| 0.59% | 0x6b8c60 | PerformSpatialCulling | thiscall RET 0x8 |
|
||||
| 0.59% | 0x6b88e0 | performCollisionDetection | thiscall RET 0x8 |
|
||||
| 0.55% | 0x7b5a10 | ProcessActiveParticles | stdcall RET 0x8 |
|
||||
| 0.53% | 0x404130 | CallbackIterator | stdcall RET 0x10 |
|
||||
| 0.50% | 0x464890 | FindObjectByGUID | stdcall RET 0x8 |
|
||||
| 0.49% | 0x632700 | RayTriangleIntersection | thiscall RET 0x20 |
|
||||
| 0.48% | 0x70cb30 | DrawBatchProj | thiscall RET |
|
||||
| 0.47% | 0x702000 | FindLuaFunction | stdcall RET 0x4 |
|
||||
| 0.44% | 0x5a0f50 | RenderSpriteQuads | thiscall RET 0xc |
|
||||
| 0.44% | 0x718960 | renderSceneNode | thiscall RET |
|
||||
| 0.44% | 0x6cffc0 | generateTerrainChunk | thiscall RET |
|
||||
| 0.43% | 0x593840 | D3D_SetTexture | thiscall RET 0x8 |
|
||||
| 0.42% | 0x6b8b70 | checkBoundingBoxIntersection | stdcall RET 0x8 |
|
||||
|
||||
### Unresolved-callee hooks (4 hooks)
|
||||
| % | Address | Name | Convention |
|
||||
|---|---|---|---|
|
||||
| ~0.5% | 0x7bdd60 | rotateMatrixByAxisAngle | thiscall RET 0xc |
|
||||
| ~0.1% | 0x632460 | BuildTrianglePlanes | thiscall RET 0xc |
|
||||
| ~0.2% | 0x7b3d20 | SetupParticleRendering | thiscall RET 0x4 |
|
||||
| ~0.2% | 0x5ce0c0 | renderTextLine | thiscall RET 0x10 |
|
||||
|
||||
## Unhooked Mid-Tier (50 functions, 13.15% total)
|
||||
|
||||
Not worth individual hooks -- too small or too high-frequency (hook overhead would distort):
|
||||
|
||||
| % | Name | Why not hook |
|
||||
|---|---|---|
|
||||
| 0.39% | compareRenderItemsExtended | Sort comparator, millions of calls |
|
||||
| 0.39% | GetCachedData | Cache accessor, extremely hot path |
|
||||
| 0.39% | raycastPickObjects | Moderate frequency |
|
||||
| 0.35% | inflateDecodeLiteralsAndLengths | Decompression, bursty |
|
||||
| 0.34% | UpdateParticlePhysics | Per-particle, very hot |
|
||||
| 0.34% | updateAnimationSystem | Could be interesting entry point |
|
||||
| 0.32% | ClntObjMgrObjectPtr | Object lookup, called everywhere |
|
||||
| 0.29% | multiplyMatrix4x4 | Tiny function, massive call count |
|
||||
| 0.26% | quickSortArray | Sort impl, millions of comparisons |
|
||||
| ... | (40 more at 0.15-0.31%) | |
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. **GPU/Driver is 19%** -- nothing we can do about d3d9.dll/amdvlk overhead
|
||||
2. **Lua VM is 8.4%** -- addon code execution, not optimizable from DLL side
|
||||
3. **Frustum clipping (ClipPolygonToSinglePlane) is the #1 WoW hotspot at 3.95%** -- pure math, SSE candidate via binary patch (no hook overhead)
|
||||
4. **Font rendering (GetOrCreateCharacterGlyph) is #2 at 3.65%** -- potential cache optimization
|
||||
5. **Bone pipeline (t44 + findInterp + interpKf) totals ~5.3%** -- SSE interp hook overhead negates savings; binary patch or full t44 rewrite needed
|
||||
6. **Hook overhead matters** -- for functions called >10k/frame (lerp, matrix multiply, sort comparators), detour trampoline cost (~30 cycles) exceeds any savings
|
||||
|
||||
## Files
|
||||
|
||||
- `cycles.out` -- raw stack-collapsed perf data
|
||||
- `perf.data.perfparser` -- hotspot binary cache (5.3GB)
|
||||
- Ghidra symbols: `/media/faststore/tmp/Dis/symbols.nm`
|
||||
@@ -1007,7 +1007,7 @@ fn calcScaledInverse(this_mat: u32, out: u32, scale: f32) void {
|
||||
// mat3(offset_vec3*), mat4(scale_float_bits)
|
||||
// =============================================================================
|
||||
|
||||
export fn transformImpl_BASELINE(this: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u32) callconv(.c) void {
|
||||
export fn transformImpl_BASELINE(this: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u32) callconv(.{ .x86_thiscall = .{} }) void {
|
||||
|
||||
@setEvalBranchQuota(50000);
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// RTQ (RenderTextureQuads) full rendering pipeline
|
||||
// All functions in the per-item draw call chain
|
||||
//
|
||||
// Call chain per item:
|
||||
// RenderTextureQuads (0x76FB00) -- the outer loop
|
||||
// -> InitializeRenderingPipeline (0x58A2A0) -- stores vertCount, calls RenderComplexGeometry
|
||||
// -> RenderComplexGeometry (0x58A3D0) -- format detect, VB create/fill, DrawPrimitive
|
||||
// -> CreateVertexBuffer (0x58A140 -> 0x594500) -- pool VB allocation
|
||||
// -> LockVertexBuffer (0x58A080) -- vtable call to CGxDevice+0xA8
|
||||
// -> [per-vertex interleave loop]
|
||||
// -> UnlockVertexBuffer (0x58A0A0) -- vtable call to CGxDevice+0xAC
|
||||
// -> DrawPrimitive (0x58A7C0) -- state update + SetRenderingCommand
|
||||
// -> RenderVertexBuffer (0x58A2E0) -- creates INDEX VB, issues DrawIndexedPrimitive
|
||||
// -> CreateAndBindVertexBuffer (0x58A750) -- index VB (bufferType=1)
|
||||
// -> CallGfxDeviceMethod_Wrapper (0x58A830) -- actual D3D9 DrawIndexedPrimitive
|
||||
// -> EmptyRenderFunction (0x58A340) -- RET (no-op)
|
||||
|
||||
// === InitializeRenderingPipeline (0x58A2A0) -- 54 bytes ===
|
||||
// __fastcall(ECX=vertCount, EDX=xyzPtr, 11 stack params)
|
||||
// Stores vertCount to global g_currentPrimitiveType and tail-calls RenderComplexGeometry.
|
||||
void __fastcall InitializeRenderingPipeline(int vertCount, void** xyzPtr, /* ...11 stack params */)
|
||||
{
|
||||
g_currentPrimitiveType = (void*)vertCount; // stored at some global
|
||||
RenderComplexGeometry(vertCount, xyzPtr, /* forward all params */);
|
||||
}
|
||||
|
||||
// === CreateVertexBuffer wrapper (0x58A140) -- 24 bytes ===
|
||||
// __fastcall(ECX=bufferType, EDX=vertexSize, stack: vertexCount)
|
||||
// Thin wrapper: loads CGxDevice from global, calls D3D_CreateVertexBuffer.
|
||||
void __fastcall CreateVertexBuffer(int bufferType, int vertexSize, int vertexCount)
|
||||
{
|
||||
D3D_CreateVertexBuffer(CGxDeviceD3d__device, bufferType, vertexSize, vertexCount);
|
||||
}
|
||||
|
||||
// === D3D_CreateVertexBuffer (0x594500) -- 75 bytes ===
|
||||
// __thiscall(ECX=CGxDevice, stack: bufferType, vertexSize, vertexCount)
|
||||
// Returns a pool handle. The pool is indexed by bufferType at CGxDevice+0x26CC.
|
||||
// Reuses existing D3D9 VB unless the requested size exceeds current allocation.
|
||||
int __thiscall D3D_CreateVertexBuffer(void* this, int bufferType, int vertexSize, int vertexCount)
|
||||
{
|
||||
int poolHandle = *(int*)((int)this + bufferType * 4 + 0x26CC);
|
||||
int d3dVB = *(int*)(poolHandle + 8);
|
||||
if (d3dVB != 0 && *(uint*)(d3dVB + 0x10) < (uint)(vertexSize * vertexCount)) {
|
||||
// Existing VB too small -- resize via vtable call
|
||||
(*(code**)(*this + 0xA0))(d3dVB, vertexSize * vertexCount);
|
||||
}
|
||||
SetDimensionsAndSize(poolHandle, vertexSize, vertexCount);
|
||||
return poolHandle;
|
||||
}
|
||||
|
||||
// === SetDimensionsAndSize (0x5946F0) -- 32 bytes ===
|
||||
void SetDimensionsAndSize(int handle, int vertexSize, int vertexCount)
|
||||
{
|
||||
*(int*)(handle + 0x0C) = vertexSize;
|
||||
*(int*)(handle + 0x10) = vertexCount;
|
||||
*(int*)(handle + 0x14) = vertexSize * vertexCount;
|
||||
*(char*)(handle + 0x1C) = 0; // clear dirty flag
|
||||
}
|
||||
|
||||
// === LockVertexBuffer (0x58A080) -- 18 bytes ===
|
||||
// __fastcall(ECX=poolHandle)
|
||||
// Calls CGxDevice vtable[0xA8/4 = 42] to lock the D3D9 VB.
|
||||
// Returns pointer to locked VB memory.
|
||||
void* __fastcall LockVertexBuffer(void* poolHandle)
|
||||
{
|
||||
return (*(code**)(*(int*)CGxDeviceD3d__device + 0xA8))(poolHandle);
|
||||
}
|
||||
|
||||
// === UnlockVertexBuffer (0x58A0A0) -- 27 bytes ===
|
||||
// __fastcall(ECX=poolHandle, EDX=byteCount)
|
||||
// Calls CGxDevice vtable[0xAC/4 = 43] to unlock, then marks pool handle active.
|
||||
void __fastcall UnlockVertexBuffer(int poolHandle, int byteCount)
|
||||
{
|
||||
(*(code**)(*(int*)CGxDeviceD3d__device + 0xAC))(poolHandle, byteCount);
|
||||
SetObjectActiveFlag(poolHandle);
|
||||
}
|
||||
|
||||
// === UpdateBufferData (0x58A0C0) -- 57 bytes ===
|
||||
// __fastcall(ECX=poolHandle, EDX=srcData, stack: byteCount, unused)
|
||||
// Used by CreateAndBindVertexBuffer for INDEX buffer filling.
|
||||
// If byteCount==0, auto-computes from handle's vertexSize*vertexCount.
|
||||
void __fastcall UpdateBufferData(int poolHandle, void* srcData, int byteCount, void* unused)
|
||||
{
|
||||
if (byteCount == 0) {
|
||||
byteCount = *(int*)(poolHandle + 0x10) * *(int*)(poolHandle + 0x0C);
|
||||
}
|
||||
(*(code**)(*(int*)CGxDeviceD3d__device + 0xB0))(poolHandle, srcData, byteCount, unused);
|
||||
SetObjectActiveFlag(poolHandle);
|
||||
}
|
||||
|
||||
// === DrawPrimitive / GxDevice dispatch (0x58A7C0) -- 54 bytes ===
|
||||
// __fastcall(ECX=poolHandle, EDX=formatCode)
|
||||
// Reads per-format state from table at 0x809C00 (16 bytes per entry):
|
||||
// +0x00: ptr to state array (GxDevice render state descriptors)
|
||||
// +0x04: state array element count
|
||||
// +0x08: primitive type mapping
|
||||
// +0x0C: dirty flags bitmask
|
||||
// Applies state, marks dirty, stores draw command in CGxDevice for D3D9 submission.
|
||||
void __fastcall DrawPrimitive(int poolHandle, int formatCode)
|
||||
{
|
||||
int* stateTable = (int*)(0x809C00 + formatCode * 0x10);
|
||||
UpdateGfxStateArray(poolHandle, stateTable[0], stateTable[1]);
|
||||
MarkStateDirty(stateTable[3]);
|
||||
SetRenderingCommand(CGxDeviceD3d__device, poolHandle, formatCode);
|
||||
}
|
||||
|
||||
// === SetRenderingCommand (0x592AA0) -- 40 bytes ===
|
||||
// __thiscall(ECX=CGxDevice, stack: poolHandle, formatCode)
|
||||
// Stores the draw command into CGxDevice for later D3D9 submission.
|
||||
void __thiscall SetRenderingCommand(void* this, void* poolHandle, int formatCode)
|
||||
{
|
||||
*(int*)((int)this + 0x27E0) = formatCode;
|
||||
*(void**)((int)this + 0x27E4) = poolHandle;
|
||||
*(int*)((int)this + 0x27E8) = *(int*)(0x809C08 + formatCode * 0x10);
|
||||
}
|
||||
|
||||
// === RenderVertexBuffer (0x58A2E0) -- 82 bytes ===
|
||||
// __fastcall(ECX=primType, EDX=vertCount, stack: indexPtr)
|
||||
// Creates index VB (bufferType=1), binds it, issues DrawIndexedPrimitive.
|
||||
void __fastcall RenderVertexBuffer(int primType, int vertCount, void* indexPtr)
|
||||
{
|
||||
if (g_currentPrimitiveType != NULL) {
|
||||
CreateAndBindVertexBuffer(vertCount, indexPtr); // index VB
|
||||
short adjustedPrimType = (short)g_currentPrimitiveType - 1;
|
||||
// Build draw call struct on stack
|
||||
struct { void* primPtr; void* unused; short vertCount; short flags; } call;
|
||||
call.primPtr = primType;
|
||||
call.unused = NULL;
|
||||
call.vertCount = vertCount;
|
||||
call.flags = 0;
|
||||
CallGfxDeviceMethod_Wrapper(&call, 1); // -> D3D9 DrawIndexedPrimitive
|
||||
}
|
||||
}
|
||||
|
||||
// === CreateAndBindVertexBuffer (0x58A750) -- 44 bytes ===
|
||||
// __fastcall(ECX=vertCount, EDX=dataPtr)
|
||||
// Used for INDEX buffer (bufferType=1, vertexSize=2 = sizeof(u16)).
|
||||
void __fastcall CreateAndBindVertexBuffer(int vertCount, void* dataPtr)
|
||||
{
|
||||
int handle = CreateVertexBuffer(1, 2, vertCount); // pool slot 1, 2 bytes/index
|
||||
UpdateBufferData(handle, dataPtr, 0, NULL);
|
||||
SetStreamSource(handle); // bind as index stream
|
||||
}
|
||||
|
||||
// === Vertex format tables (from game memory) ===
|
||||
//
|
||||
// Stride table at 0x85A7A8 (indexed by format code):
|
||||
// fmt 0: stride=12 xyz only
|
||||
// fmt 1: stride=24 xyz + defaultTC
|
||||
// fmt 2: stride=28 xyz + defaultTC + additional(4)
|
||||
// fmt 3: stride=32 xyz + defaultTC + texcoord1(8)
|
||||
// fmt 4: stride=36 xyz + defaultTC + additional(4) + texcoord1(8)
|
||||
// fmt 5: stride=40 xyz + defaultTC + texcoord1(8) + texcoord2(8)
|
||||
// fmt 6: stride=44 xyz + defaultTC + additional(4) + texcoord1(8) + texcoord2(8)
|
||||
// fmt 7: stride=16 xyz + additional(4)
|
||||
// fmt 8: stride=24 xyz + additional(4) + texcoord1(8)
|
||||
// fmt 9: stride=32 xyz + additional(4) + texcoord1(8) + texcoord2(8)
|
||||
// fmt 10: stride=20 xyz + texcoord1(8)
|
||||
// fmt 11: stride=28 xyz + texcoord1(8) + texcoord2(8)
|
||||
//
|
||||
// Element offset table at 0x8097A8 (indexed by format*13 + element):
|
||||
// Element 0 = xyz position (always at offset 0)
|
||||
// Element 3 = defaultTexCoord
|
||||
// Element 4 = additionalData (color)
|
||||
// Element 5 = texcoord1
|
||||
// Element 6 = texcoord2
|
||||
// Value -1 = element not present in this format
|
||||
@@ -0,0 +1,166 @@
|
||||
// RenderComplexGeometry (0x58A3D0) -- 886 bytes
|
||||
// __fastcall(ECX=vertCount, EDX=xyzPtr, 11 stack params)
|
||||
//
|
||||
// Called from InitializeRenderingPipeline (0x58A2A0) which just stores
|
||||
// vertCount to global [0xC0ED2C] then tail-calls this function.
|
||||
//
|
||||
// Purpose: Determine vertex format from which input pointers are non-null,
|
||||
// create/reuse a pool VB, lock it, interleave all input arrays into the VB
|
||||
// at the computed stride, unlock, then issue a DrawPrimitive.
|
||||
//
|
||||
// Parameters (after fastcall mapping):
|
||||
// param_1 = vertCount (ECX)
|
||||
// param_2 = xyzPtr (EDX) -- 3 floats per vert, stride in param_3
|
||||
// param_3 = xyzStride (stack) -- typically 0x0C (12 bytes)
|
||||
// param_4 = defaultTexCoordPtr (stack) -- game constant at 0xCF4CF4, 3 floats/vert
|
||||
// param_5 = defaultTexCoordStride (stack) -- 0 means use param_4 as single value
|
||||
// param_6 = additionalDataPtr (stack) -- per-vert color (DWORD), or NULL
|
||||
// param_7 = additionalDataStride (stack)
|
||||
// param_8 = texCoord1Ptr (stack) -- unused in RTQ path
|
||||
// param_9 = texCoord1Stride (stack)
|
||||
// param_10 = texCoord2Ptr (stack) -- UV coords, 2 floats/vert
|
||||
// param_11 = texCoord2Stride (stack) -- typically 8
|
||||
//
|
||||
// Vertex format table:
|
||||
// Format codes 0-11, determined by which of param_4/6/8/10 are non-null.
|
||||
// Stride table at 0x85A7A8: [12,24,28,32,36,40,44,16,24,32,20,28]
|
||||
// Element offset table at 0x8097A8: indexed by (format*13 + element)*4
|
||||
// Elements: 0=xyz, 3=defaultTC, 4=additional, 5=texcoord1, 6=texcoord2
|
||||
//
|
||||
// For RTQ with additionalData (format 4, stride 36):
|
||||
// [0-11] xyz (12 bytes, 3 floats)
|
||||
// [12-23] defaultTC (12 bytes, 3 floats from 0xCF4CF4)
|
||||
// [24-27] additional (4 bytes, DWORD color)
|
||||
// [28-35] texcoord2 (8 bytes, 2 floats UV)
|
||||
//
|
||||
// For RTQ without additionalData (format 1, stride 24):
|
||||
// [0-11] xyz (12 bytes)
|
||||
// [12-23] defaultTC (12 bytes)
|
||||
//
|
||||
// Per-vertex loop:
|
||||
// 1. Copy xyz (3 dwords) at element offset 0
|
||||
// 2. Copy defaultTC (3 dwords) at element offset 3 (12 bytes)
|
||||
// 3. Check lighting flag at CGxDevice+0x258:
|
||||
// - If flag == 1: byte-swap color (BGRA -> RGBA or similar)
|
||||
// - Else: copy color as-is
|
||||
// 4. Copy additional data (1 dword) at element offset 4
|
||||
// 5. Copy texcoord1 (2 dwords) at element offset 5
|
||||
// 6. Copy texcoord2 (2 dwords) at element offset 6
|
||||
// Each pointer advances by its respective stride per vertex.
|
||||
// VB write pointer advances by the interleaved stride per vertex.
|
||||
//
|
||||
// After loop: UnlockVertexBuffer, then DrawPrimitive(poolHandle, formatCode)
|
||||
//
|
||||
// DrawPrimitive (0x58A7C0):
|
||||
// Reads from a per-format-code table at 0x809C00 (stride 0x10):
|
||||
// +0x00: state array ptr
|
||||
// +0x04: state array count
|
||||
// +0x08: D3D primitive type table index
|
||||
// +0x0C: dirty flags mask
|
||||
// Calls UpdateGfxStateArray to apply GxDevice state changes,
|
||||
// then MarkStateDirty, then SetRenderingCommand which stores
|
||||
// the draw command into CGxDevice+0x27E0..0x27E8 for later
|
||||
// submission to D3D9.
|
||||
//
|
||||
// Pool VB system (D3D_CreateVertexBuffer at 0x594500):
|
||||
// CGxDevice has pool slots at +0x26CC indexed by bufferType.
|
||||
// RenderComplexGeometry uses bufferType=0.
|
||||
// Pool handle struct:
|
||||
// +0x08: D3D9 IDirect3DVertexBuffer9*
|
||||
// +0x0C: vertexSize (bytes per vertex for this format)
|
||||
// +0x10: vertexCount
|
||||
// +0x14: total bytes (vertexSize * vertexCount)
|
||||
// +0x1C: dirty flag (cleared by SetDimensionsAndSize)
|
||||
// The pool VB is reused across calls. Only recreated if the
|
||||
// requested size (vertexSize * vertexCount) exceeds the current
|
||||
// allocation at +0x14. This means no D3D9 CreateVertexBuffer
|
||||
// overhead on normal frames.
|
||||
//
|
||||
// RenderVertexBuffer (0x58A2E0):
|
||||
// Called AFTER RenderComplexGeometry returns. Uses a DIFFERENT pool
|
||||
// (bufferType=1) for index data. Creates/binds an index VB with
|
||||
// the quad indices {0,1,2,0,2,3}, then calls DrawIndexedPrimitive
|
||||
// via CallGfxDeviceMethod_Wrapper.
|
||||
|
||||
void __fastcall
|
||||
RenderComplexGeometry(
|
||||
int vertCount, // ECX
|
||||
void** xyzPtr, // EDX
|
||||
int xyzStride, // [ebp+0x08]
|
||||
void** defaultTCPtr, // [ebp+0x0C]
|
||||
int defaultTCStride, // [ebp+0x10]
|
||||
void** additionalPtr, // [ebp+0x14]
|
||||
int additionalStride, // [ebp+0x18]
|
||||
void** texCoord1Ptr, // [ebp+0x1C]
|
||||
int texCoord1Stride, // [ebp+0x20]
|
||||
void** texCoord2Ptr, // [ebp+0x24]
|
||||
int texCoord2Stride) // [ebp+0x28]
|
||||
{
|
||||
// Step 1: Format detection -- determine which of the 12 interleaved
|
||||
// vertex formats to use based on which input pointers are non-null.
|
||||
int formatCode = 1; // default: xyz + defaultTC
|
||||
// Complex nested-if tree mapping (defaultTC, additional, tc1, tc2)
|
||||
// presence to format codes 0-11. See format table above.
|
||||
|
||||
// Step 2: Get stride and allocate pool VB
|
||||
int stride = GetDataPointerByIndex(formatCode); // stride table lookup
|
||||
void* poolHandle = CreateVertexBuffer(0, stride, vertCount);
|
||||
char* vbData = LockVertexBuffer(poolHandle);
|
||||
|
||||
// Step 3: Compute write pointers for each element within the VB
|
||||
char* xyzDst = vbData + GetMatrixElementPointer(formatCode, 0);
|
||||
char* tcDst = (defaultTCPtr) ? vbData + GetMatrixElementPointer(formatCode, 3) : &dummy;
|
||||
char* addDst = (additionalPtr) ? vbData + GetMatrixElementPointer(formatCode, 4) : &dummy;
|
||||
char* tc1Dst = (texCoord1Ptr) ? vbData + GetMatrixElementPointer(formatCode, 5) : &dummy;
|
||||
char* tc2Dst = (texCoord2Ptr) ? vbData + GetMatrixElementPointer(formatCode, 6) : &dummy;
|
||||
|
||||
// Per-element advance: stride if present, 0 if writing to dummy
|
||||
int tcAdv = defaultTCPtr ? stride : 0;
|
||||
int addAdv = additionalPtr ? stride : 0;
|
||||
int tc1Adv = texCoord1Ptr ? stride : 0;
|
||||
int tc2Adv = texCoord2Ptr ? stride : 0;
|
||||
|
||||
// Step 4: Per-vertex interleave loop
|
||||
for (int v = 0; v < vertCount; v++) {
|
||||
// XYZ: always 12 bytes (3 floats)
|
||||
*(int*)(xyzDst + 0) = *(int*)(xyzPtr + 0);
|
||||
*(int*)(xyzDst + 4) = *(int*)(xyzPtr + 4);
|
||||
*(int*)(xyzDst + 8) = *(int*)(xyzPtr + 8);
|
||||
xyzDst += stride;
|
||||
xyzPtr += xyzStride;
|
||||
|
||||
// DefaultTC: 12 bytes (3 floats)
|
||||
*(int*)(tcDst + 0) = *(int*)(defaultTCPtr + 0);
|
||||
*(int*)(tcDst + 4) = *(int*)(defaultTCPtr + 4);
|
||||
*(int*)(tcDst + 8) = *(int*)(defaultTCPtr + 8);
|
||||
tcDst += tcAdv;
|
||||
defaultTCPtr += defaultTCStride;
|
||||
|
||||
// Additional (color): conditional byte-swap based on lighting flag
|
||||
int lightingInfo = UpdateLightingOffset(); // returns CGxDevice + 0x23C
|
||||
if (*(int*)(lightingInfo + 0x1C) == 1) {
|
||||
// Byte swap: BGRA -> RGBA (swap bytes 0 and 2)
|
||||
*(int*)(addDst) = CONCAT(byte3, byte0, byte1, byte2);
|
||||
} else {
|
||||
*(int*)(addDst) = *(int*)(additionalPtr);
|
||||
}
|
||||
addDst += addAdv;
|
||||
additionalPtr += additionalStride;
|
||||
|
||||
// TexCoord1: 8 bytes (2 floats)
|
||||
*(int*)(tc1Dst + 0) = *(int*)(texCoord1Ptr + 0);
|
||||
*(int*)(tc1Dst + 4) = *(int*)(texCoord1Ptr + 4);
|
||||
tc1Dst += tc1Adv;
|
||||
texCoord1Ptr += texCoord1Stride;
|
||||
|
||||
// TexCoord2: 8 bytes (2 floats)
|
||||
*(int*)(tc2Dst + 0) = *(int*)(texCoord2Ptr + 0);
|
||||
*(int*)(tc2Dst + 4) = *(int*)(texCoord2Ptr + 4);
|
||||
tc2Dst += tc2Adv;
|
||||
texCoord2Ptr += texCoord2Stride;
|
||||
}
|
||||
|
||||
// Step 5: Finalize
|
||||
UnlockVertexBuffer(poolHandle, stride * vertCount);
|
||||
DrawPrimitive(poolHandle, formatCode);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
// =============================================================================
|
||||
// processLinkedListCollision @ 0x006abc40
|
||||
// Function range: 0x006abc40 -- 0x006abd88 (329 bytes)
|
||||
// Generated by Ghidra headless analysis
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// SECTION 1: DECOMPILATION
|
||||
// =============================================================================
|
||||
|
||||
|
||||
undefined * __fastcall
|
||||
processLinkedListCollision(int *param_1,float *param_2,uint *param_3,uint param_4)
|
||||
|
||||
{
|
||||
int iVar1;
|
||||
uint uVar2;
|
||||
int iVar3;
|
||||
undefined *puVar4;
|
||||
undefined4 *puVar5;
|
||||
undefined **ppuVar6;
|
||||
undefined *local_28;
|
||||
undefined *local_24;
|
||||
undefined *local_20;
|
||||
undefined *local_1c;
|
||||
undefined *local_18;
|
||||
undefined *local_14;
|
||||
undefined *local_10;
|
||||
undefined *local_c;
|
||||
undefined *local_8;
|
||||
|
||||
if ((param_4 & 0xf0000f) != 0) {
|
||||
local_8 = (undefined *)param_1[2];
|
||||
local_10 = (undefined *)param_1;
|
||||
local_c = (undefined *)param_2;
|
||||
if ((((uint)local_8 & 1) != 0) || (local_8 == (undefined *)0x0)) {
|
||||
local_8 = (undefined *)0x0;
|
||||
}
|
||||
while ((puVar4 = local_8, ((uint)local_8 & 1) == 0 && (local_8 != (undefined *)0x0))) {
|
||||
iVar1 = *(int *)(local_8 + 4);
|
||||
if (((*(ushort *)(iVar1 + 0xc) & 0x100) == 0) &&
|
||||
((*(undefined **)(iVar1 + 0x8c) != PTR_00c89f20 && (*(int *)(iVar1 + 0x88) != 0)))) {
|
||||
if (*(int *)(iVar1 + 0x180) == 0 && *(int *)(iVar1 + 0x184) == 0) {
|
||||
uVar2 = param_4 & 0xf;
|
||||
}
|
||||
else {
|
||||
uVar2 = param_4 & 0xf00000;
|
||||
}
|
||||
if (uVar2 != 0) {
|
||||
if (-1 < (char)*(ushort *)(iVar1 + 0xc)) {
|
||||
return (undefined *)0x0;
|
||||
}
|
||||
puVar5 = (undefined4 *)(iVar1 + 0x14c);
|
||||
ppuVar6 = &local_28;
|
||||
for (iVar3 = 6; iVar3 != 0; iVar3 = iVar3 + -1) {
|
||||
*ppuVar6 = (undefined *)*puVar5;
|
||||
puVar5 = puVar5 + 1;
|
||||
ppuVar6 = ppuVar6 + 1;
|
||||
}
|
||||
if (((((float)local_28 < *(float *)(local_c + 0xc) !=
|
||||
((float)local_28 == *(float *)(local_c + 0xc))) &&
|
||||
((float)local_24 < *(float *)(local_c + 0x10) !=
|
||||
((float)local_24 == *(float *)(local_c + 0x10)))) &&
|
||||
((float)local_20 < *(float *)(local_c + 0x14) !=
|
||||
((float)local_20 == *(float *)(local_c + 0x14)))) &&
|
||||
(((*(float *)local_c <= (float)local_1c && (*(float *)(local_c + 4) <= (float)local_18)
|
||||
) && (*(float *)(local_c + 8) <= (float)local_14)))) {
|
||||
addGeometryToBuffer((float *)local_c,iVar1,param_3);
|
||||
puVar4 = local_8;
|
||||
}
|
||||
*(undefined **)(iVar1 + 0x8c) = PTR_00c89f20;
|
||||
}
|
||||
}
|
||||
local_8 = *(undefined **)(puVar4 + *(int *)local_10 + 4);
|
||||
}
|
||||
}
|
||||
return (undefined *)0x1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// SECTION 2: FULL DISASSEMBLY
|
||||
// =============================================================================
|
||||
//
|
||||
// 0x006abc40 55 PUSH EBP
|
||||
// 0x006abc41 8b ec MOV EBP,ESP
|
||||
// 0x006abc43 83 ec 24 SUB ESP,0x24
|
||||
// 0x006abc46 53 PUSH EBX
|
||||
// 0x006abc47 8b c1 MOV EAX,ECX
|
||||
// 0x006abc49 8b 4d 0c MOV ECX,dword ptr [EBP + 0xc]
|
||||
// 0x006abc4c f7 c1 0f 00 f0 00 TEST ECX,0xf0000f
|
||||
// 0x006abc52 56 PUSH ESI
|
||||
// 0x006abc53 57 PUSH EDI
|
||||
// 0x006abc54 89 55 f8 MOV dword ptr [EBP + -0x8],EDX
|
||||
// 0x006abc57 89 45 f4 MOV dword ptr [EBP + -0xc],EAX
|
||||
// 0x006abc5a 0f 84 1b 01 00 00 JZ 0x006abd7b
|
||||
// 0x006abc60 8b 40 08 MOV EAX,dword ptr [EAX + 0x8]
|
||||
// 0x006abc63 a8 01 TEST AL,0x1
|
||||
// 0x006abc65 75 04 JNZ 0x006abc6b
|
||||
// 0x006abc67 85 c0 TEST EAX,EAX
|
||||
// 0x006abc69 75 07 JNZ 0x006abc72
|
||||
// 0x006abc6b 33 d2 XOR EDX,EDX
|
||||
// 0x006abc6d 89 55 fc MOV dword ptr [EBP + -0x4],EDX
|
||||
// 0x006abc70 eb 06 JMP 0x006abc78
|
||||
// 0x006abc72 89 45 fc MOV dword ptr [EBP + -0x4],EAX
|
||||
// 0x006abc75 8b 55 fc MOV EDX,dword ptr [EBP + -0x4]
|
||||
// 0x006abc78 f6 c2 01 TEST DL,0x1
|
||||
// 0x006abc7b 0f 85 fa 00 00 00 JNZ 0x006abd7b
|
||||
// 0x006abc81 85 d2 TEST EDX,EDX
|
||||
// 0x006abc83 0f 84 f2 00 00 00 JZ 0x006abd7b
|
||||
// 0x006abc89 8b 5a 04 MOV EBX,dword ptr [EDX + 0x4]
|
||||
// 0x006abc8c 66 8b 43 0c MOV AX,word ptr [EBX + 0xc]
|
||||
// 0x006abc90 f6 c4 01 TEST AH,0x1
|
||||
// 0x006abc93 0f 85 c1 00 00 00 JNZ 0x006abd5a
|
||||
// 0x006abc99 8b 35 20 9f c8 00 MOV ESI,dword ptr [0x00c89f20]
|
||||
// 0x006abc9f 39 b3 8c 00 00 00 CMP dword ptr [EBX + 0x8c],ESI
|
||||
// 0x006abca5 0f 84 af 00 00 00 JZ 0x006abd5a
|
||||
// 0x006abcab 8b b3 88 00 00 00 MOV ESI,dword ptr [EBX + 0x88]
|
||||
// 0x006abcb1 85 f6 TEST ESI,ESI
|
||||
// 0x006abcb3 0f 84 a1 00 00 00 JZ 0x006abd5a
|
||||
// 0x006abcb9 8b b3 80 01 00 00 MOV ESI,dword ptr [EBX + 0x180]
|
||||
// 0x006abcbf 0b b3 84 01 00 00 OR ESI,dword ptr [EBX + 0x184]
|
||||
// 0x006abcc5 74 08 JZ 0x006abccf
|
||||
// 0x006abcc7 f7 c1 00 00 f0 00 TEST ECX,0xf00000
|
||||
// 0x006abccd eb 03 JMP 0x006abcd2
|
||||
// 0x006abccf f6 c1 0f TEST CL,0xf
|
||||
// 0x006abcd2 0f 84 82 00 00 00 JZ 0x006abd5a
|
||||
// 0x006abcd8 84 c0 TEST AL,AL
|
||||
// 0x006abcda 0f 89 90 00 00 00 JNS 0x006abd70
|
||||
// 0x006abce0 8d b3 4c 01 00 00 LEA ESI,[EBX + 0x14c]
|
||||
// 0x006abce6 b9 06 00 00 00 MOV ECX,0x6
|
||||
// 0x006abceb 8d 7d dc LEA EDI,[EBP + -0x24]
|
||||
// 0x006abcee f3 a5 MOVSD.REP ES:EDI,ESI
|
||||
// 0x006abcf0 d9 45 dc FLD float ptr [EBP + -0x24]
|
||||
// 0x006abcf3 8b 4d f8 MOV ECX,dword ptr [EBP + -0x8]
|
||||
// 0x006abcf6 d8 59 0c FCOMP float ptr [ECX + 0xc]
|
||||
// 0x006abcf9 df e0 FNSTSW AX
|
||||
// 0x006abcfb f6 c4 41 TEST AH,0x41
|
||||
// 0x006abcfe 7a 4e JP 0x006abd4e
|
||||
// 0x006abd00 d9 45 e0 FLD float ptr [EBP + -0x20]
|
||||
// 0x006abd03 d8 59 10 FCOMP float ptr [ECX + 0x10]
|
||||
// 0x006abd06 df e0 FNSTSW AX
|
||||
// 0x006abd08 f6 c4 41 TEST AH,0x41
|
||||
// 0x006abd0b 7a 41 JP 0x006abd4e
|
||||
// 0x006abd0d d9 45 e4 FLD float ptr [EBP + -0x1c]
|
||||
// 0x006abd10 d8 59 14 FCOMP float ptr [ECX + 0x14]
|
||||
// 0x006abd13 df e0 FNSTSW AX
|
||||
// 0x006abd15 f6 c4 41 TEST AH,0x41
|
||||
// 0x006abd18 7a 34 JP 0x006abd4e
|
||||
// 0x006abd1a d9 45 e8 FLD float ptr [EBP + -0x18]
|
||||
// 0x006abd1d d8 19 FCOMP float ptr [ECX]
|
||||
// 0x006abd1f df e0 FNSTSW AX
|
||||
// 0x006abd21 f6 c4 01 TEST AH,0x1
|
||||
// 0x006abd24 75 28 JNZ 0x006abd4e
|
||||
// 0x006abd26 d9 45 ec FLD float ptr [EBP + -0x14]
|
||||
// 0x006abd29 d8 59 04 FCOMP float ptr [ECX + 0x4]
|
||||
// 0x006abd2c df e0 FNSTSW AX
|
||||
// 0x006abd2e f6 c4 01 TEST AH,0x1
|
||||
// 0x006abd31 75 1b JNZ 0x006abd4e
|
||||
// 0x006abd33 d9 45 f0 FLD float ptr [EBP + -0x10]
|
||||
// 0x006abd36 d8 59 08 FCOMP float ptr [ECX + 0x8]
|
||||
// 0x006abd39 df e0 FNSTSW AX
|
||||
// 0x006abd3b f6 c4 01 TEST AH,0x1
|
||||
// 0x006abd3e 75 0e JNZ 0x006abd4e
|
||||
// 0x006abd40 8b 45 08 MOV EAX,dword ptr [EBP + 0x8]
|
||||
// 0x006abd43 50 PUSH EAX
|
||||
// 0x006abd44 8b d3 MOV EDX,EBX
|
||||
// 0x006abd46 e8 45 00 00 00 CALL 0x006abd90
|
||||
// 0x006abd4b 8b 55 fc MOV EDX,dword ptr [EBP + -0x4]
|
||||
// 0x006abd4e 8b 0d 20 9f c8 00 MOV ECX,dword ptr [0x00c89f20]
|
||||
// 0x006abd54 89 8b 8c 00 00 00 MOV dword ptr [EBX + 0x8c],ECX
|
||||
// 0x006abd5a 8b 45 f4 MOV EAX,dword ptr [EBP + -0xc]
|
||||
// 0x006abd5d 8b 00 MOV EAX,dword ptr [EAX]
|
||||
// 0x006abd5f 8b 4c 10 04 MOV ECX,dword ptr [EAX + EDX*0x1 + 0x4]
|
||||
// 0x006abd63 03 c2 ADD EAX,EDX
|
||||
// 0x006abd65 89 4d fc MOV dword ptr [EBP + -0x4],ECX
|
||||
// 0x006abd68 8b 4d 0c MOV ECX,dword ptr [EBP + 0xc]
|
||||
// 0x006abd6b e9 05 ff ff ff JMP 0x006abc75
|
||||
// 0x006abd70 5f POP EDI
|
||||
// 0x006abd71 5e POP ESI
|
||||
// 0x006abd72 33 c0 XOR EAX,EAX
|
||||
// 0x006abd74 5b POP EBX
|
||||
// 0x006abd75 8b e5 MOV ESP,EBP
|
||||
// 0x006abd77 5d POP EBP
|
||||
// 0x006abd78 c2 08 00 RET 0x8
|
||||
// 0x006abd7b 5f POP EDI
|
||||
// 0x006abd7c 5e POP ESI
|
||||
// 0x006abd7d b8 01 00 00 00 MOV EAX,0x1
|
||||
// 0x006abd82 5b POP EBX
|
||||
// 0x006abd83 8b e5 MOV ESP,EBP
|
||||
// 0x006abd85 5d POP EBP
|
||||
// 0x006abd86 c2 08 00 RET 0x8
|
||||
//
|
||||
// Total instructions: 108
|
||||
|
||||
// =============================================================================
|
||||
// SECTION 3: PROLOGUE (first 30 instructions)
|
||||
// =============================================================================
|
||||
//
|
||||
// 0x006abc40 55 PUSH EBP
|
||||
// 0x006abc41 8b ec MOV EBP,ESP
|
||||
// 0x006abc43 83 ec 24 SUB ESP,0x24
|
||||
// 0x006abc46 53 PUSH EBX
|
||||
// 0x006abc47 8b c1 MOV EAX,ECX
|
||||
// 0x006abc49 8b 4d 0c MOV ECX,dword ptr [EBP + 0xc]
|
||||
// 0x006abc4c f7 c1 0f 00 f0 00 TEST ECX,0xf0000f
|
||||
// 0x006abc52 56 PUSH ESI
|
||||
// 0x006abc53 57 PUSH EDI
|
||||
// 0x006abc54 89 55 f8 MOV dword ptr [EBP + -0x8],EDX
|
||||
// 0x006abc57 89 45 f4 MOV dword ptr [EBP + -0xc],EAX
|
||||
// 0x006abc5a 0f 84 1b 01 00 00 JZ 0x006abd7b
|
||||
// 0x006abc60 8b 40 08 MOV EAX,dword ptr [EAX + 0x8]
|
||||
// 0x006abc63 a8 01 TEST AL,0x1
|
||||
// 0x006abc65 75 04 JNZ 0x006abc6b
|
||||
// 0x006abc67 85 c0 TEST EAX,EAX
|
||||
// 0x006abc69 75 07 JNZ 0x006abc72
|
||||
// 0x006abc6b 33 d2 XOR EDX,EDX
|
||||
// 0x006abc6d 89 55 fc MOV dword ptr [EBP + -0x4],EDX
|
||||
// 0x006abc70 eb 06 JMP 0x006abc78
|
||||
// 0x006abc72 89 45 fc MOV dword ptr [EBP + -0x4],EAX
|
||||
// 0x006abc75 8b 55 fc MOV EDX,dword ptr [EBP + -0x4]
|
||||
// 0x006abc78 f6 c2 01 TEST DL,0x1
|
||||
// 0x006abc7b 0f 85 fa 00 00 00 JNZ 0x006abd7b
|
||||
// 0x006abc81 85 d2 TEST EDX,EDX
|
||||
// 0x006abc83 0f 84 f2 00 00 00 JZ 0x006abd7b
|
||||
// 0x006abc89 8b 5a 04 MOV EBX,dword ptr [EDX + 0x4]
|
||||
// 0x006abc8c 66 8b 43 0c MOV AX,word ptr [EBX + 0xc]
|
||||
// 0x006abc90 f6 c4 01 TEST AH,0x1
|
||||
// 0x006abc93 0f 85 c1 00 00 00 JNZ 0x006abd5a
|
||||
|
||||
// =============================================================================
|
||||
// SECTION 4: ALL RET INSTRUCTIONS (stack cleanup indicator)
|
||||
// =============================================================================
|
||||
//
|
||||
// 0x006abd78 c2 08 00 RET 0x8
|
||||
// 0x006abd86 c2 08 00 RET 0x8
|
||||
|
||||
// =============================================================================
|
||||
// SECTION 5: XREFS TO 0x006abc40 (callers)
|
||||
// =============================================================================
|
||||
//
|
||||
// 0x006aad4c [UNCONDITIONAL_CALL] in processGeometryBoundsCheck (0x006aaab0)
|
||||
// 0x006ab4a1 [UNCONDITIONAL_CALL] in processTerrainChunkMeshGeneration (0x006aadc0)
|
||||
//
|
||||
// Total callers: 2
|
||||
|
||||
// =============================================================================
|
||||
// SECTION 6: XREFS FROM 0x006abc40 (callees)
|
||||
// =============================================================================
|
||||
//
|
||||
// 0x006abd90 addGeometryToBuffer
|
||||
//
|
||||
// Total callees: 1
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
// =============================================================================
|
||||
// renderTextToBuffer @ 0x005CDC20
|
||||
// Decompiled from WoW.exe 1.12.1 (build 5875) via Ghidra 11.4.2
|
||||
// Single caller of RenderTextToVertexBuffer (0x5CCBE0)
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CALLING CONVENTION (verified from assembly)
|
||||
// -----------------------------------------------------------------------------
|
||||
// __thiscall: ECX = this (text object ptr), saved to EBX immediately
|
||||
// 0x005cdc20 PUSH EBP
|
||||
// 0x005cdc21 MOV EBP,ESP
|
||||
// 0x005cdc23 SUB ESP,0x74
|
||||
// 0x005cdc26 PUSH EBX
|
||||
// 0x005cdc27 PUSH ESI
|
||||
// 0x005cdc28 MOV EBX,ECX <-- this ptr saved to EBX
|
||||
// ...
|
||||
// 0x005cdee8 RET <-- single RET, no stack cleanup = __thiscall (0 stack params)
|
||||
//
|
||||
// Signature: void __thiscall renderTextToBuffer(TextObject* this)
|
||||
// No stack parameters. Single RET (no RET N).
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// XREFS TO (callers)
|
||||
// -----------------------------------------------------------------------------
|
||||
// 0x005cd6aa from validateAndPrepareText (UNCONDITIONAL_CALL)
|
||||
// 0x005cd426 from GetVertexBufferData (UNCONDITIONAL_CALL)
|
||||
//
|
||||
// Two callers:
|
||||
// 1. validateAndPrepareText (0x5cd6aa) -- validation/preparation path
|
||||
// 2. GetVertexBufferData (0x5cd426) -- vertex buffer retrieval path
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// XREFS FROM (callees)
|
||||
// -----------------------------------------------------------------------------
|
||||
// 0x005c6fa0 ConvertPixelsToScreen
|
||||
// 0x0040a2b0 __ftol (float-to-long)
|
||||
// 0x005c2810 ParseTextFormatCodes
|
||||
// 0x005c7260 WrapTextToWidth
|
||||
// 0x005c7010 ConvertPixelsToScreenAlt
|
||||
// 0x005ccbe0 RenderTextToVertexBuffer <<<< the target
|
||||
// 0x005cd310 AddRectangleToBuffer
|
||||
// 0x005cdf70 finalizeTextLayout
|
||||
// 0x005cd4d0 renderFadeEffect
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// TEXT OBJECT FIELD MAP (offsets from this/EBX)
|
||||
// -----------------------------------------------------------------------------
|
||||
// +0x1c float lineHeight (or spacing-related metric)
|
||||
// +0x28 float xOffset (used when bit7 of flags is clear)
|
||||
// +0x2c float color/style data (passed to RenderTextToVertexBuffer)
|
||||
// +0x34 float indentOrShadow (used when flags bit0 is set)
|
||||
// +0x3c float maxWidth (passed to WrapTextToWidth)
|
||||
// +0x40 float maxHeight (vertical overflow check)
|
||||
// +0x44 void* fontObject (passed to WrapTextToWidth as ECX)
|
||||
// +0x48 char* textString (the actual text to render)
|
||||
// +0x54 int alignment (0=left, 1=center, 2=right)
|
||||
// +0x58 float some metric (initial value for local_c / line step)
|
||||
// +0x5c uint flags bitfield:
|
||||
// bit0: has indent/shadow
|
||||
// bit1 (0x02): single-line mode (breaks loop after first line)
|
||||
// bit5 (0x20): has fade effect
|
||||
// bit7 (0x80): pixel mode vs screen mode
|
||||
// +0x60 uint resultFlags (OR'd with per-line flags from RenderTextToVertexBuffer)
|
||||
// +0x68 int fadeStart (passed to renderFadeEffect)
|
||||
// +0x6c int fadeEnd (passed to renderFadeEffect)
|
||||
// +0x90 uint lineCountOutput (zeroed at start, not the dirty flag)
|
||||
// +0x9c int LINE COUNTER / DIRTY FLAG -- THE KEY FIELD
|
||||
// - Checked FIRST: if (this+0x9c != 0) return immediately
|
||||
// - Incremented after each line rendered
|
||||
// - Also incremented for format-code-only lines (ParseTextFormatCodes returns 2)
|
||||
// - Acts as both "already rendered" guard AND line counter
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// DIRTY/VALID FLAG ANALYSIS
|
||||
// -----------------------------------------------------------------------------
|
||||
//
|
||||
// The field at +0x9c is the critical gate. The function's VERY FIRST check is:
|
||||
//
|
||||
// if (this->field_0x9c != 0) return; // already rendered, skip
|
||||
// if (this->textString == NULL) return; // no text
|
||||
// if (*this->textString == '\0') return; // empty text
|
||||
//
|
||||
// Assembly proof:
|
||||
// 0x005cdc2a MOV EAX,dword ptr [EBX + 0x9c]
|
||||
// 0x005cdc33 CMP EAX,EDI ; EDI = 0
|
||||
// 0x005cdc35 JNZ 0x005cdee2 ; bail if non-zero
|
||||
// 0x005cdc3b MOV EAX,dword ptr [EBX + 0x48]
|
||||
// 0x005cdc3e CMP EAX,EDI
|
||||
// 0x005cdc40 JZ 0x005cdee2 ; bail if text ptr is NULL
|
||||
// 0x005cdc46 CMP byte ptr [EAX],0x0
|
||||
// 0x005cdc49 JZ 0x005cdee2 ; bail if text is empty
|
||||
//
|
||||
// Then +0x9c is INCREMENTED after each rendered line:
|
||||
// 0x005cde55 MOV ECX,dword ptr [EBX + 0x9c]
|
||||
// 0x005cde5e INC ECX
|
||||
// 0x005cde62 MOV dword ptr [EBX + 0x9c],ECX
|
||||
//
|
||||
// And also incremented for format-code lines (ParseTextFormatCodes == 2):
|
||||
// 0x005cdd81 MOV EAX,dword ptr [EBX + 0x9c]
|
||||
// 0x005cdd8c INC EAX
|
||||
// 0x005cdd90 MOV dword ptr [EBX + 0x9c],EAX
|
||||
//
|
||||
// CONCLUSION: +0x9c serves as BOTH:
|
||||
// 1. A "dirty/needs-render" flag (0 = needs render, non-zero = already done)
|
||||
// 2. A line counter (counts lines processed during rendering)
|
||||
//
|
||||
// To force re-render: set this->field_0x9c = 0
|
||||
// To prevent render: set this->field_0x9c = non-zero
|
||||
//
|
||||
// The callers (validateAndPrepareText, GetVertexBufferData) presumably
|
||||
// reset +0x9c to 0 when text changes, triggering re-render on next call.
|
||||
|
||||
// +0x90 is zeroed at entry:
|
||||
// 0x005cdc4f MOV dword ptr [EBX + 0x90],EDI ; = 0
|
||||
// This appears to be a separate output counter, not the dirty flag.
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// FLOW SUMMARY
|
||||
// -----------------------------------------------------------------------------
|
||||
//
|
||||
// 1. Guard: if +0x9c != 0 || textString is null/empty -> return
|
||||
// 2. Zero +0x90 (line output counter?)
|
||||
// 3. Compute vertical layout params from +0x1c, +0x58, flags
|
||||
// 4. Loop over text lines:
|
||||
// a. Check vertical overflow (accumulated height vs +0x40 maxHeight)
|
||||
// b. ParseTextFormatCodes -- handle color/format escapes (returns 2 = consumed)
|
||||
// c. WrapTextToWidth -- break text at word boundaries for +0x3c width
|
||||
// d. Handle alignment (+0x54): left(0), center(1), right(2) via ConvertPixelsToScreenAlt
|
||||
// e. CALL RenderTextToVertexBuffer(this, textPtr, charCount, &color, &offset, &flags, &state)
|
||||
// f. Increment +0x9c (line counter)
|
||||
// g. If state==2, call AddRectangleToBuffer (highlight/selection rect)
|
||||
// h. OR per-line flags into +0x60
|
||||
// i. If flags bit1 (single-line mode), break
|
||||
// j. Advance to next line
|
||||
// 5. Call finalizeTextLayout(this)
|
||||
// 6. If flags bit5 (fade), call renderFadeEffect(this, fadeStart, fadeEnd)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// RenderTextToVertexBuffer CALL SITE DETAIL (at 0x5CDE50)
|
||||
// -----------------------------------------------------------------------------
|
||||
// __thiscall: ECX = this (text object)
|
||||
// Stack args (6, pushed right-to-left):
|
||||
// push &state (EBP-0x74, local_74 area -- tracks render state, value 2 = highlight)
|
||||
// push &flags (EBP-0x24, output flags OR'd into +0x60)
|
||||
// push charCount (EBP-0x18, from WrapTextToWidth output)
|
||||
// push &offset (EBP-0x48, vertical/horizontal position floats)
|
||||
// push &color (EBP-0x30, color/style data)
|
||||
// push textPtr (ESI, current position in text string)
|
||||
//
|
||||
// Assembly at call site:
|
||||
// 0x005cde39 LEA EAX,[EBP + -0x74]
|
||||
// 0x005cde3c PUSH EAX ; &state
|
||||
// 0x005cde3d LEA ECX,[EBP + -0x24]
|
||||
// 0x005cde40 PUSH ECX ; &flags
|
||||
// 0x005cde41 MOV ECX,dword ptr [EBP + -0x18]
|
||||
// 0x005cde44 LEA EDX,[EBP + -0x48]
|
||||
// 0x005cde47 PUSH EDX ; &offset
|
||||
// 0x005cde48 LEA EAX,[EBP + -0x30]
|
||||
// 0x005cde4b PUSH EAX ; &color
|
||||
// 0x005cde4c PUSH ECX ; charCount
|
||||
// 0x005cde4d PUSH ESI ; textPtr
|
||||
// 0x005cde4e MOV ECX,EBX ; this
|
||||
// 0x005cde50 CALL 0x005ccbe0 ; RenderTextToVertexBuffer
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// DECOMPILED C (Ghidra raw output, lightly annotated)
|
||||
// =============================================================================
|
||||
|
||||
/* WARNING: Variable defined which should be unmapped: local_88 */
|
||||
void __fastcall renderTextToBuffer(void *param_1) // ECX = this
|
||||
{
|
||||
byte bVar1;
|
||||
uint uVar2;
|
||||
undefined *puVar3;
|
||||
int iVar4;
|
||||
undefined **ppuVar5;
|
||||
byte *pbVar6;
|
||||
float unaff_EDI;
|
||||
undefined4 *puVar7;
|
||||
float10 extraout_ST0;
|
||||
float10 fVar8;
|
||||
float10 extraout_ST0_00;
|
||||
float10 extraout_ST0_01;
|
||||
float10 extraout_ST0_02;
|
||||
ulonglong uVar9;
|
||||
float10 *pfVar10;
|
||||
float fVar11;
|
||||
undefined *local_88;
|
||||
undefined *local_78;
|
||||
undefined *local_74;
|
||||
undefined *local_70;
|
||||
undefined *local_6c;
|
||||
undefined *local_68;
|
||||
undefined *local_4c;
|
||||
undefined *local_48;
|
||||
undefined *local_44;
|
||||
undefined *local_40;
|
||||
uint uStack_3c;
|
||||
undefined *local_38;
|
||||
undefined *local_34;
|
||||
undefined *local_30;
|
||||
undefined *local_2c;
|
||||
undefined *local_28;
|
||||
undefined *local_24;
|
||||
undefined *local_20;
|
||||
undefined *local_1c;
|
||||
undefined *local_18;
|
||||
undefined *local_14;
|
||||
undefined *local_10;
|
||||
undefined *local_c;
|
||||
byte local_5;
|
||||
|
||||
// --- GUARD: dirty/valid check ---
|
||||
if (((*(int *)((int)param_1 + 0x9c) != 0) || // already rendered?
|
||||
(*(char **)((int)param_1 + 0x48) == (char *)0x0)) || // no text ptr?
|
||||
(**(char **)((int)param_1 + 0x48) == '\0')) { // empty text?
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Reset line output counter ---
|
||||
*(undefined4 *)((int)param_1 + 0x90) = 0;
|
||||
|
||||
// --- Read fade params ---
|
||||
local_2c = *(undefined **)((int)param_1 + 0x6c); // fadeEnd
|
||||
local_38 = *(undefined **)((int)param_1 + 0x68); // fadeStart
|
||||
|
||||
// --- Convert line height to screen coords ---
|
||||
ConvertPixelsToScreen(
|
||||
(void *)(*(uint *)((int)param_1 + 0x5c) >> 7 & 1),
|
||||
(float10 *)-*(float *)((int)param_1 + 0x1c),
|
||||
unaff_EDI);
|
||||
|
||||
uVar2 = *(uint *)((int)param_1 + 0x5c) & 0x80; // pixel mode flag
|
||||
local_4c = (undefined *)0x0;
|
||||
|
||||
if (uVar2 == 0) {
|
||||
local_44 = *(undefined **)((int)param_1 + 0x28); // xOffset
|
||||
local_48 = (undefined *)(float)extraout_ST0;
|
||||
} else {
|
||||
local_48 = (undefined *)0x0;
|
||||
local_44 = (undefined *)0x0;
|
||||
}
|
||||
|
||||
local_34 = *(undefined **)((int)param_1 + 0x2c); // color/style
|
||||
local_10 = *(undefined **)((int)param_1 + 0x58); // line step metric
|
||||
pbVar6 = *(byte **)((int)param_1 + 0x48); // text string ptr
|
||||
|
||||
// Init state vars
|
||||
local_78 = (undefined *)0x0;
|
||||
local_70 = (undefined *)0x0;
|
||||
local_68 = (undefined *)0x0;
|
||||
local_20 = (undefined *)0x0;
|
||||
local_1c = (undefined *)0x0;
|
||||
local_14 = (undefined *)0x0;
|
||||
|
||||
// Compute line height (pixel mode vs screen mode)
|
||||
if (uVar2 == 0) {
|
||||
// Screen mode: round to integer pixels, then back to screen coords
|
||||
local_40 = PTR_00c2b9a0;
|
||||
uStack_3c = 0;
|
||||
uVar9 = __ftol();
|
||||
local_40 = (undefined *)uVar9;
|
||||
uStack_3c = 0;
|
||||
local_18 = (undefined *)(float)(uVar9 & 0xffffffff);
|
||||
local_10 = (undefined *)(float)((float10)(uVar9 & 0xffffffff) / extraout_ST0_00);
|
||||
ConvertPixelsToScreen((void *)0x0, *(float10 **)((int)param_1 + 0x1c), unaff_EDI);
|
||||
fVar8 = extraout_ST0_01 + (float10)(float)local_18;
|
||||
} else {
|
||||
// Pixel mode: just add
|
||||
fVar8 = (float10)(float)local_10 + (float10)*(float *)((int)param_1 + 0x1c);
|
||||
}
|
||||
|
||||
local_c = (undefined *)(float)fVar8; // total line step (height + spacing)
|
||||
local_5 = 1; // first line flag
|
||||
local_74 = (undefined *)((float)local_c + (float)local_48); // current Y position
|
||||
|
||||
bVar1 = *pbVar6;
|
||||
local_18 = (undefined *)0x0; // accumulated vertical height
|
||||
local_6c = local_48;
|
||||
|
||||
// --- MAIN RENDERING LOOP: iterate over lines ---
|
||||
do {
|
||||
// Check: end of text OR vertical overflow
|
||||
if (((bVar1 == 0) ||
|
||||
(*(float *)((int)param_1 + 0x40) <= (float)local_18)) ||
|
||||
(local_18 = (undefined *)((float)local_18 + *(float *)((int)param_1 + 0x1c) + (float)local_10),
|
||||
bVar1 == 0))
|
||||
goto LAB_005cdebc; // done
|
||||
|
||||
// Parse format/color codes
|
||||
puVar3 = ParseTextFormatCodes(
|
||||
pbVar6, &local_30, (uint *)0x0,
|
||||
*(uint *)((int)param_1 + 0x5c), &uStack_3c);
|
||||
|
||||
local_14 = (undefined *)0x0;
|
||||
|
||||
if (puVar3 == (undefined *)0x2) {
|
||||
// Format code consumed entire segment -- adjust position, skip render
|
||||
local_48 = (undefined *)((float)local_48 - (float)local_c);
|
||||
*(int *)((int)param_1 + 0x9c) = *(int *)((int)param_1 + 0x9c) + 1; // increment line counter
|
||||
pbVar6 = pbVar6 + (int)local_30;
|
||||
} else {
|
||||
// Check word wrap flag
|
||||
if (((uint)*(float10 **)((int)param_1 + 0x5c) & 1) == 0) {
|
||||
local_24 = (undefined *)0x0;
|
||||
} else {
|
||||
local_24 = *(undefined **)((int)param_1 + 0x34); // indent
|
||||
}
|
||||
|
||||
pfVar10 = *(float10 **)((int)param_1 + 0x1c);
|
||||
|
||||
// Word-wrap the text to fit maxWidth
|
||||
WrapTextToWidth(
|
||||
*(float10 **)((int)param_1 + 0x44), // font
|
||||
pbVar6, // text
|
||||
pfVar10, // line height
|
||||
*(float *)((int)param_1 + 0x3c), // maxWidth
|
||||
(int *)&local_1c, // out: charCount
|
||||
(float *)&local_14, // out: line width
|
||||
&local_20, // out: next line ptr
|
||||
(float10 *)local_24, // indent
|
||||
*(float10 **)((int)param_1 + 0x5c), // flags
|
||||
&local_5); // first line flag
|
||||
|
||||
// Check if wrapping produced valid output
|
||||
if (((local_20 == pbVar6) || (local_20 == (undefined *)0x0)) ||
|
||||
((local_1c == (undefined *)0x0 && (*local_20 == '\0')))) {
|
||||
LAB_005cdebc:
|
||||
// --- FINALIZE ---
|
||||
finalizeTextLayout((int)param_1);
|
||||
if ((*(byte *)((int)param_1 + 0x5c) & 0x20) == 0) {
|
||||
return;
|
||||
}
|
||||
// Fade effect
|
||||
if ((local_38 == (undefined *)0xffffffff) &&
|
||||
(local_2c == (undefined *)0xffffffff)) {
|
||||
return;
|
||||
}
|
||||
renderFadeEffect(param_1, (int)local_38, (int)local_2c);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- ALIGNMENT ---
|
||||
puVar3 = local_14;
|
||||
if (*(int *)((int)param_1 + 0x54) == 2) {
|
||||
// Right-aligned
|
||||
LAB_005cde14:
|
||||
ConvertPixelsToScreenAlt(
|
||||
(void *)(*(uint *)((int)param_1 + 0x5c) >> 7 & 1),
|
||||
(float10 *)-(float)puVar3, unaff_EDI);
|
||||
local_4c = (undefined *)(float)extraout_ST0_02;
|
||||
} else if (*(int *)((int)param_1 + 0x54) == 1) {
|
||||
// Center-aligned
|
||||
puVar3 = (undefined *)((float)local_14 * StaticFloat0_5);
|
||||
goto LAB_005cde14;
|
||||
}
|
||||
// else: left-aligned, local_4c stays 0
|
||||
|
||||
local_28 = (undefined *)0x0;
|
||||
if (local_78 != (undefined *)0x0) {
|
||||
local_70 = local_4c;
|
||||
}
|
||||
|
||||
fVar11 = 8.528623e-39; // junk / uninitialized
|
||||
puVar3 = local_1c;
|
||||
|
||||
// --- THE CALL: render this line's glyphs to vertex buffer ---
|
||||
RenderTextToVertexBuffer(
|
||||
param_1, // this (ECX)
|
||||
pbVar6, // textPtr (current line start)
|
||||
(int)local_1c, // charCount
|
||||
(uint *)&local_34, // &color/style
|
||||
(float *)&local_4c, // &position offset
|
||||
(uint *)&local_28, // &output flags
|
||||
(int *)&local_78); // &render state
|
||||
|
||||
// Increment line counter (+0x9c)
|
||||
*(int *)((int)param_1 + 0x9c) = *(int *)((int)param_1 + 0x9c) + 1;
|
||||
|
||||
// If state == 2, add highlight/selection rectangle
|
||||
if (local_78 == (undefined *)0x2) {
|
||||
ppuVar5 = &local_74;
|
||||
puVar7 = (undefined4 *)&stack0xffffff5c;
|
||||
for (iVar4 = 8; iVar4 != 0; iVar4 = iVar4 + -1) {
|
||||
*puVar7 = *ppuVar5;
|
||||
ppuVar5 = ppuVar5 + 1;
|
||||
puVar7 = puVar7 + 1;
|
||||
}
|
||||
AddRectangleToBuffer(param_1, (float)pfVar10, fVar11, (float)pbVar6, (float)puVar3);
|
||||
}
|
||||
|
||||
// Advance vertical position
|
||||
local_48 = (undefined *)((float)local_48 - (float)local_c);
|
||||
|
||||
// Accumulate result flags
|
||||
*(uint *)((int)param_1 + 0x60) = *(uint *)((int)param_1 + 0x60) | (uint)local_28;
|
||||
|
||||
// Move to next line
|
||||
pbVar6 = local_20;
|
||||
|
||||
// Single-line mode check: if bit1 set, done after first line
|
||||
if ((*(byte *)((int)param_1 + 0x5c) & 2) != 0)
|
||||
goto LAB_005cdebc;
|
||||
}
|
||||
|
||||
bVar1 = *pbVar6;
|
||||
local_6c = (undefined *)((float)local_6c - (float)local_c);
|
||||
local_74 = (undefined *)((float)local_74 - (float)local_c);
|
||||
} while (true);
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// FULL DISASSEMBLY LISTING
|
||||
// =============================================================================
|
||||
//
|
||||
// 0x005cdc20 PUSH EBP
|
||||
// 0x005cdc21 MOV EBP,ESP
|
||||
// 0x005cdc23 SUB ESP,0x74
|
||||
// 0x005cdc26 PUSH EBX
|
||||
// 0x005cdc27 PUSH ESI
|
||||
// 0x005cdc28 MOV EBX,ECX
|
||||
// 0x005cdc2a MOV EAX,dword ptr [EBX + 0x9c]
|
||||
// 0x005cdc30 PUSH EDI
|
||||
// 0x005cdc31 XOR EDI,EDI
|
||||
// 0x005cdc33 CMP EAX,EDI
|
||||
// 0x005cdc35 JNZ 0x005cdee2
|
||||
// 0x005cdc3b MOV EAX,dword ptr [EBX + 0x48]
|
||||
// 0x005cdc3e CMP EAX,EDI
|
||||
// 0x005cdc40 JZ 0x005cdee2
|
||||
// 0x005cdc46 CMP byte ptr [EAX],0x0
|
||||
// 0x005cdc49 JZ 0x005cdee2
|
||||
// 0x005cdc4f MOV dword ptr [EBX + 0x90],EDI
|
||||
// 0x005cdc55 FLD float ptr [EBX + 0x1c]
|
||||
// 0x005cdc58 MOV ECX,dword ptr [EBX + 0x6c]
|
||||
// 0x005cdc5b FCHS
|
||||
// 0x005cdc5d MOV EAX,dword ptr [EBX + 0x68]
|
||||
// 0x005cdc60 PUSH ECX
|
||||
// 0x005cdc61 MOV dword ptr [EBP + -0x28],ECX
|
||||
// 0x005cdc64 FSTP float ptr [ESP]
|
||||
// 0x005cdc67 MOV ECX,dword ptr [EBX + 0x5c]
|
||||
// 0x005cdc6a SHR ECX,0x7
|
||||
// 0x005cdc6d AND ECX,0x1
|
||||
// 0x005cdc70 MOV dword ptr [EBP + -0x34],EAX
|
||||
// 0x005cdc73 CALL 0x005c6fa0
|
||||
// 0x005cdc78 MOV EAX,dword ptr [EBX + 0x5c]
|
||||
// 0x005cdc7b AND EAX,0x80
|
||||
// 0x005cdc80 MOV dword ptr [EBP + -0x48],EDI
|
||||
// 0x005cdc83 JZ 0x005cdc8f
|
||||
// 0x005cdc85 FSTP ST0
|
||||
// 0x005cdc87 MOV dword ptr [EBP + -0x44],EDI
|
||||
// 0x005cdc8a MOV dword ptr [EBP + -0x40],EDI
|
||||
// 0x005cdc8d JMP 0x005cdc98
|
||||
// 0x005cdc8f MOV EDX,dword ptr [EBX + 0x28]
|
||||
// 0x005cdc92 FSTP float ptr [EBP + -0x44]
|
||||
// 0x005cdc95 MOV dword ptr [EBP + -0x40],EDX
|
||||
// 0x005cdc98 CMP EAX,EDI
|
||||
// 0x005cdc9a MOV ECX,dword ptr [EBX + 0x2c]
|
||||
// 0x005cdc9d MOV EDX,dword ptr [EBX + 0x58]
|
||||
// 0x005cdca0 MOV ESI,dword ptr [EBX + 0x48]
|
||||
// 0x005cdca3 MOV dword ptr [EBP + -0x30],ECX
|
||||
// 0x005cdca6 MOV dword ptr [EBP + -0x74],EDI
|
||||
// 0x005cdca9 MOV dword ptr [EBP + -0x6c],0x0
|
||||
// 0x005cdcb0 MOV dword ptr [EBP + -0x64],0x0
|
||||
// 0x005cdcb7 MOV dword ptr [EBP + -0x1c],EDI
|
||||
// 0x005cdcba MOV dword ptr [EBP + -0x18],EDI
|
||||
// 0x005cdcbd MOV dword ptr [EBP + -0x10],0x0
|
||||
// 0x005cdcc4 MOV dword ptr [EBP + -0xc],EDX
|
||||
// 0x005cdcc7 JZ 0x005cdcd1
|
||||
// 0x005cdcc9 FLD float ptr [EBP + -0xc]
|
||||
// 0x005cdccc FADD float ptr [EBX + 0x1c]
|
||||
// 0x005cdccf JMP 0x005cdd10
|
||||
// 0x005cdcd1 MOV EAX,[0x00c2b9a0]
|
||||
// 0x005cdcd6 MOV dword ptr [EBP + -0x3c],EAX
|
||||
// 0x005cdcd9 MOV dword ptr [EBP + -0x38],EDI
|
||||
// 0x005cdcdc FILD qword ptr [EBP + -0x3c]
|
||||
// 0x005cdcdf FLD float ptr [EBP + -0xc]
|
||||
// 0x005cdce2 FMUL ST1
|
||||
// 0x005cdce4 FADD float ptr [0x00808120]
|
||||
// 0x005cdcea CALL 0x0040a2b0
|
||||
// 0x005cdcef MOV dword ptr [EBP + -0x3c],EAX
|
||||
// 0x005cdcf2 MOV dword ptr [EBP + -0x38],EDI
|
||||
// 0x005cdcf5 FILD qword ptr [EBP + -0x3c]
|
||||
// 0x005cdcf8 MOV ECX,dword ptr [EBX + 0x1c]
|
||||
// 0x005cdcfb PUSH ECX
|
||||
// 0x005cdcfc FST float ptr [EBP + -0x14]
|
||||
// 0x005cdcff XOR ECX,ECX
|
||||
// 0x005cdd01 FDIV ST0,ST1
|
||||
// 0x005cdd03 FSTP float ptr [EBP + -0xc]
|
||||
// 0x005cdd06 FSTP ST0
|
||||
// 0x005cdd08 CALL 0x005c6fa0
|
||||
// 0x005cdd0d FADD float ptr [EBP + -0x14]
|
||||
// 0x005cdd10 MOV EDX,dword ptr [EBP + -0x44]
|
||||
// 0x005cdd13 FSTP float ptr [EBP + -0x8]
|
||||
// 0x005cdd16 FLD float ptr [EBP + -0x8]
|
||||
// 0x005cdd19 MOV byte ptr [EBP + -0x1],0x1
|
||||
// 0x005cdd1d FADD float ptr [EBP + -0x44]
|
||||
// 0x005cdd20 MOV CL,byte ptr [ESI]
|
||||
// 0x005cdd22 TEST CL,CL
|
||||
// 0x005cdd24 MOV dword ptr [EBP + -0x68],EDX
|
||||
// 0x005cdd27 FSTP float ptr [EBP + -0x70]
|
||||
// 0x005cdd2a MOV dword ptr [EBP + -0x14],0x0
|
||||
// 0x005cdd31 JZ 0x005cdebc
|
||||
// 0x005cdd37 FLD float ptr [EBP + -0x14]
|
||||
// 0x005cdd3a FCOMP float ptr [EBX + 0x40]
|
||||
// 0x005cdd3d FNSTSW AX
|
||||
// 0x005cdd3f TEST AH,0x5
|
||||
// 0x005cdd42 JP 0x005cdebc
|
||||
// 0x005cdd48 TEST CL,CL
|
||||
// 0x005cdd4a FLD float ptr [EBP + -0x14]
|
||||
// 0x005cdd4d FADD float ptr [EBX + 0x1c]
|
||||
// 0x005cdd50 FADD float ptr [EBP + -0xc]
|
||||
// 0x005cdd53 FSTP float ptr [EBP + -0x14]
|
||||
// 0x005cdd56 JZ 0x005cdebc
|
||||
// 0x005cdd5c MOV ECX,dword ptr [EBX + 0x5c]
|
||||
// 0x005cdd5f LEA EAX,[EBP + -0x38]
|
||||
// 0x005cdd62 PUSH EAX
|
||||
// 0x005cdd63 PUSH ECX
|
||||
// 0x005cdd64 PUSH EDI
|
||||
// 0x005cdd65 LEA EDX,[EBP + -0x2c]
|
||||
// 0x005cdd68 MOV ECX,ESI
|
||||
// 0x005cdd6a CALL 0x005c2810
|
||||
// 0x005cdd6f CMP EAX,0x2
|
||||
// 0x005cdd72 MOV dword ptr [EBP + -0x10],0x0
|
||||
// 0x005cdd79 JNZ 0x005cdd9b
|
||||
// 0x005cdd7b FLD float ptr [EBP + -0x44]
|
||||
// 0x005cdd7e MOV ECX,dword ptr [EBP + -0x2c]
|
||||
// 0x005cdd81 MOV EAX,dword ptr [EBX + 0x9c]
|
||||
// 0x005cdd87 FSUB float ptr [EBP + -0x8]
|
||||
// 0x005cdd8a ADD ESI,ECX
|
||||
// 0x005cdd8c INC EAX
|
||||
// 0x005cdd8d FSTP float ptr [EBP + -0x44]
|
||||
// 0x005cdd90 MOV dword ptr [EBX + 0x9c],EAX
|
||||
// 0x005cdd96 JMP 0x005cdea0
|
||||
// 0x005cdd9b MOV EAX,dword ptr [EBX + 0x5c]
|
||||
// 0x005cdd9e TEST AL,0x1
|
||||
// 0x005cdda0 JZ 0x005cddaa
|
||||
// 0x005cdda2 MOV EDX,dword ptr [EBX + 0x34]
|
||||
// 0x005cdda5 MOV dword ptr [EBP + -0x20],EDX
|
||||
// 0x005cdda8 JMP 0x005cddb1
|
||||
// 0x005cddaa MOV dword ptr [EBP + -0x20],0x0
|
||||
// 0x005cddb1 MOV EDX,dword ptr [EBP + -0x20]
|
||||
// 0x005cddb4 LEA ECX,[EBP + -0x1]
|
||||
// 0x005cddb7 PUSH ECX
|
||||
// 0x005cddb8 PUSH EAX
|
||||
// 0x005cddb9 PUSH EDX
|
||||
// 0x005cddba LEA EAX,[EBP + -0x1c]
|
||||
// 0x005cddbd PUSH EAX
|
||||
// 0x005cddbe MOV EAX,dword ptr [EBX + 0x3c]
|
||||
// 0x005cddc1 LEA ECX,[EBP + -0x10]
|
||||
// 0x005cddc4 PUSH ECX
|
||||
// 0x005cddc5 MOV ECX,dword ptr [EBX + 0x1c]
|
||||
// 0x005cddc8 LEA EDX,[EBP + -0x18]
|
||||
// 0x005cddcb PUSH EDX
|
||||
// 0x005cddcc PUSH EAX
|
||||
// 0x005cddcd PUSH ECX
|
||||
// 0x005cddce MOV ECX,dword ptr [EBX + 0x44]
|
||||
// 0x005cddd1 MOV EDX,ESI
|
||||
// 0x005cddd3 CALL 0x005c7260
|
||||
// 0x005cddd8 MOV EAX,dword ptr [EBP + -0x1c]
|
||||
// 0x005cdddb CMP EAX,ESI
|
||||
// 0x005cdddd JZ 0x005cdebc
|
||||
// 0x005cdde3 CMP EAX,EDI
|
||||
// 0x005cdde5 JZ 0x005cdebc
|
||||
// 0x005cddeb CMP dword ptr [EBP + -0x18],EDI
|
||||
// 0x005cddee JNZ 0x005cddf9
|
||||
// 0x005cddf0 CMP byte ptr [EAX],0x0
|
||||
// 0x005cddf3 JZ 0x005cdebc
|
||||
// 0x005cddf9 MOV EAX,dword ptr [EBX + 0x54]
|
||||
// 0x005cddfc CMP EAX,0x2
|
||||
// 0x005cddff JNZ 0x005cde06
|
||||
// 0x005cde01 FLD float ptr [EBP + -0x10]
|
||||
// 0x005cde04 JMP 0x005cde14
|
||||
// 0x005cde06 CMP EAX,0x1
|
||||
// 0x005cde09 JNZ 0x005cde2b
|
||||
// 0x005cde0b FLD float ptr [EBP + -0x10]
|
||||
// 0x005cde0e FMUL float ptr [0x007ffa24]
|
||||
// 0x005cde14 PUSH ECX
|
||||
// 0x005cde15 FCHS
|
||||
// 0x005cde17 MOV ECX,dword ptr [EBX + 0x5c]
|
||||
// 0x005cde1a FSTP float ptr [ESP]
|
||||
// 0x005cde1d SHR ECX,0x7
|
||||
// 0x005cde20 AND ECX,0x1
|
||||
// 0x005cde23 CALL 0x005c7010
|
||||
// 0x005cde28 FSTP float ptr [EBP + -0x48]
|
||||
// 0x005cde2b CMP dword ptr [EBP + -0x74],EDI
|
||||
// 0x005cde2e MOV dword ptr [EBP + -0x24],EDI
|
||||
// 0x005cde31 JZ 0x005cde39
|
||||
// 0x005cde33 MOV EDX,dword ptr [EBP + -0x48]
|
||||
// 0x005cde36 MOV dword ptr [EBP + -0x6c],EDX
|
||||
// 0x005cde39 LEA EAX,[EBP + -0x74]
|
||||
// 0x005cde3c PUSH EAX
|
||||
// 0x005cde3d LEA ECX,[EBP + -0x24]
|
||||
// 0x005cde40 PUSH ECX
|
||||
// 0x005cde41 MOV ECX,dword ptr [EBP + -0x18]
|
||||
// 0x005cde44 LEA EDX,[EBP + -0x48]
|
||||
// 0x005cde47 PUSH EDX
|
||||
// 0x005cde48 LEA EAX,[EBP + -0x30]
|
||||
// 0x005cde4b PUSH EAX
|
||||
// 0x005cde4c PUSH ECX
|
||||
// 0x005cde4d PUSH ESI
|
||||
// 0x005cde4e MOV ECX,EBX
|
||||
// 0x005cde50 CALL 0x005ccbe0
|
||||
// 0x005cde55 MOV ECX,dword ptr [EBX + 0x9c]
|
||||
// 0x005cde5b MOV EAX,dword ptr [EBP + -0x74]
|
||||
// 0x005cde5e INC ECX
|
||||
// 0x005cde5f CMP EAX,0x2
|
||||
// 0x005cde62 MOV dword ptr [EBX + 0x9c],ECX
|
||||
// 0x005cde68 JNZ 0x005cde82
|
||||
// 0x005cde6a SUB ESP,0x20
|
||||
// 0x005cde6d MOV EDI,ESP
|
||||
// 0x005cde6f MOV ECX,0x8
|
||||
// 0x005cde74 LEA ESI,[EBP + -0x70]
|
||||
// 0x005cde77 MOVSD.REP ES:EDI,ESI
|
||||
// 0x005cde79 MOV ECX,EBX
|
||||
// 0x005cde7b CALL 0x005cd310
|
||||
// 0x005cde80 XOR EDI,EDI
|
||||
// 0x005cde82 FLD float ptr [EBP + -0x44]
|
||||
// 0x005cde85 MOV ECX,dword ptr [EBX + 0x60]
|
||||
// 0x005cde88 MOV EDX,dword ptr [EBP + -0x24]
|
||||
// 0x005cde8b FSUB float ptr [EBP + -0x8]
|
||||
// 0x005cde8e MOV AL,byte ptr [EBX + 0x5c]
|
||||
// 0x005cde91 MOV ESI,dword ptr [EBP + -0x1c]
|
||||
// 0x005cde94 OR ECX,EDX
|
||||
// 0x005cde96 FSTP float ptr [EBP + -0x44]
|
||||
// 0x005cde99 TEST AL,0x2
|
||||
// 0x005cde9b MOV dword ptr [EBX + 0x60],ECX
|
||||
// 0x005cde9e JNZ 0x005cdebc
|
||||
// 0x005cdea0 FLD float ptr [EBP + -0x68]
|
||||
// 0x005cdea3 MOV CL,byte ptr [ESI]
|
||||
// 0x005cdea5 TEST CL,CL
|
||||
// 0x005cdea7 FSUB float ptr [EBP + -0x8]
|
||||
// 0x005cdeaa FSTP float ptr [EBP + -0x68]
|
||||
// 0x005cdead FLD float ptr [EBP + -0x70]
|
||||
// 0x005cdeb0 FSUB float ptr [EBP + -0x8]
|
||||
// 0x005cdeb3 FSTP float ptr [EBP + -0x70]
|
||||
// 0x005cdeb6 JNZ 0x005cdd37
|
||||
// 0x005cdebc MOV ECX,EBX
|
||||
// 0x005cdebe CALL 0x005cdf70
|
||||
// 0x005cdec3 TEST byte ptr [EBX + 0x5c],0x20
|
||||
// 0x005cdec7 JZ 0x005cdee2
|
||||
// 0x005cdec9 MOV EAX,dword ptr [EBP + -0x34]
|
||||
// 0x005cdecc CMP EAX,-0x1
|
||||
// 0x005cdecf JNZ 0x005cded6
|
||||
// 0x005cded1 CMP dword ptr [EBP + -0x28],EAX
|
||||
// 0x005cded4 JZ 0x005cdee2
|
||||
// 0x005cded6 MOV ECX,dword ptr [EBP + -0x28]
|
||||
// 0x005cded9 PUSH ECX
|
||||
// 0x005cdeda PUSH EAX
|
||||
// 0x005cdedb MOV ECX,EBX
|
||||
// 0x005cdedd CALL 0x005cd4d0
|
||||
// 0x005cdee2 POP EDI
|
||||
// 0x005cdee3 POP ESI
|
||||
// 0x005cdee4 POP EBX
|
||||
// 0x005cdee5 MOV ESP,EBP
|
||||
// 0x005cdee7 POP EBP
|
||||
// 0x005cdee8 RET
|
||||
@@ -108,7 +108,7 @@ const OtherPending = struct {
|
||||
var g_other_pending: [OTHER_PENDING_SIZE]OtherPending = [1]OtherPending{.{}} ** OTHER_PENDING_SIZE;
|
||||
var g_other_pending_count: i32 = 0;
|
||||
|
||||
var g_cached_visible_item: [19]u32 = .{0} ** 19;
|
||||
|
||||
|
||||
const UNIT_CACHE_SIZE: u32 = 64;
|
||||
|
||||
@@ -193,8 +193,6 @@ fn cachePlayerState() bool {
|
||||
|
||||
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (slot * VISIBLE_ITEM_STRIDE);
|
||||
g_cache.visible_items[i] = hook.readMem(u32, g_cache.player_desc + field_index * 4);
|
||||
|
||||
g_cached_visible_item[i] = g_cache.visible_items[i];
|
||||
}
|
||||
|
||||
g_cache.valid = true;
|
||||
@@ -488,20 +486,23 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32
|
||||
if (!g_cache.valid or g_cache.player_obj != obj) {
|
||||
_ = cachePlayerState();
|
||||
}
|
||||
if (val == 0 and g_cached_visible_item[slot] != 0) {
|
||||
// Read live descriptor — g_cached_visible_item can be stale if
|
||||
// another DLL (e.g. VanillaHelpers) writes directly to the
|
||||
// descriptor, bypassing SetBlock and our hook.
|
||||
const current_visible = readUnitVisibleItem(obj, slot);
|
||||
if (val == 0 and current_visible != 0) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Start transmog pattern tracking
|
||||
if (!g_local_pending[slot].active) g_local_pending_count += 1;
|
||||
g_local_pending[slot].original_visible_item = g_cached_visible_item[slot];
|
||||
g_local_pending[slot].original_visible_item = current_visible;
|
||||
g_local_pending[slot].timestamp = now;
|
||||
g_local_pending[slot].active = true;
|
||||
g_local_pending[slot].has_durability = false;
|
||||
log.fmt("BLOCK clear slot={d:2} item=0x{X:0>8}\n", .{ slot, g_cached_visible_item[slot] });
|
||||
log.fmt("BLOCK clear slot={d:2} item=0x{X:0>8}\n", .{ slot, current_visible });
|
||||
return 1; // Block the clear
|
||||
} else if (val != 0 and g_local_pending[slot].active) {
|
||||
if (val == g_local_pending[slot].original_visible_item) {
|
||||
@@ -537,10 +538,6 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache for non-blocked writes
|
||||
if (val != 0) {
|
||||
g_cached_visible_item[slot] = val;
|
||||
}
|
||||
} else if (g_enabled) {
|
||||
// =========== OTHER PLAYERS ===========
|
||||
const guid = getUnitGuid(obj);
|
||||
@@ -628,7 +625,6 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32
|
||||
const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(es)) * VISIBLE_ITEM_STRIDE);
|
||||
_ = callOriginalSetBlock(obj, field_index, 0);
|
||||
|
||||
g_cached_visible_item[es] = 0;
|
||||
g_local_pending[es].active = false;
|
||||
g_local_pending[es].has_durability = false;
|
||||
g_local_pending_count -= 1;
|
||||
@@ -780,7 +776,6 @@ pub fn installHooks() void {
|
||||
g_other_pending_count = 0;
|
||||
g_cache = .{};
|
||||
g_unit_cache = [1]UnitVisualState{.{}} ** UNIT_CACHE_SIZE;
|
||||
g_cached_visible_item = .{0} ** 19;
|
||||
|
||||
// Hook 1: SetBlock
|
||||
if (set_block_hook.attach(ADDR_SetBlock, &hookSetBlock) != .ok) return;
|
||||
|
||||
@@ -1098,7 +1098,7 @@ pub fn calcScaledInverse(this_mat: u32, out: u32, scale: f32) void {
|
||||
// mat3(offset_vec3*), mat4(scale_float_bits)
|
||||
// =============================================================================
|
||||
|
||||
pub fn transformImpl_SSE(this: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u32) callconv(.{ .x86_thiscall = .{} }) void {
|
||||
pub export fn transformImpl_SSE(this: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u32) callconv(.{ .x86_thiscall = .{} }) void {
|
||||
|
||||
@setEvalBranchQuota(50000);
|
||||
// =========================================================================
|
||||
|
||||
@@ -284,7 +284,7 @@ fn attachmentRecursion64(this: u32, model_hdr: u32, bone_out_base: u32, frame_ct
|
||||
// scene objects stay on the f64 pipeline.
|
||||
// =============================================================================
|
||||
|
||||
pub fn transformImpl_SSE64(this: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u32) callconv(.{ .x86_thiscall = .{} }) void {
|
||||
pub export fn transformImpl_SSE64(this: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u32) callconv(.{ .x86_thiscall = .{} }) void {
|
||||
@setEvalBranchQuota(50000);
|
||||
|
||||
// Section 1: Entry checks
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
//! transform_capture — snapshot transformMatrix4x4 inputs + outputs for offline
|
||||
//! bench/parity replay. Enabled via a feature flag in weirdperformance.zig.
|
||||
//!
|
||||
//! Design:
|
||||
//! - Hook transformMatrix4x4 (0x714260).
|
||||
//! - On entry: compute a path hash from SceneObject flags + model_hdr counts.
|
||||
//! Skip if already seen.
|
||||
//! - Walk reachable pointer graph; record each referenced memory page
|
||||
//! (4KB granularity, keyed by page address). Save INPUT snapshot.
|
||||
//! - Call the game original via callOriginal.
|
||||
//! - Save OUTPUT snapshot of all pages we know get written.
|
||||
//! - Write to disk: one .trxcap file per unique path hash.
|
||||
//!
|
||||
//! Replay (bench):
|
||||
//! - Read .trxcap file.
|
||||
//! - For each page in the input snapshot, MAP_FIXED at its VA, copy data.
|
||||
//! - Call transformImpl_SSE64 (or SSE or game's x87).
|
||||
//! - Diff memory against the saved output snapshot.
|
||||
//!
|
||||
//! This captures real game state and gives us true parity coverage, but has a
|
||||
//! state-pollution avoidance problem in-game: we can't run *both* game x87 and
|
||||
//! our SSE impls on the same live SceneObject without state bleeding between
|
||||
//! calls. The solution is to replay offline with each impl starting from a
|
||||
//! fresh copy of the captured input snapshot.
|
||||
|
||||
const std = @import("std");
|
||||
const hook = @import("zhook");
|
||||
const logging = @import("../logging.zig");
|
||||
|
||||
var log: logging.Logger = .{};
|
||||
|
||||
// Win32 file/dir APIs — we run inside a Windows DLL, no libc filesystem.
|
||||
const HANDLE = *anyopaque;
|
||||
const INVALID_HANDLE: HANDLE = @ptrFromInt(std.math.maxInt(usize));
|
||||
extern "kernel32" fn CreateFileA(
|
||||
name: [*:0]const u8,
|
||||
desiredAccess: u32,
|
||||
shareMode: u32,
|
||||
security: ?*anyopaque,
|
||||
creationDisposition: u32,
|
||||
flags: u32,
|
||||
template: ?HANDLE,
|
||||
) callconv(.{ .x86_stdcall = .{} }) HANDLE;
|
||||
extern "kernel32" fn WriteFile(
|
||||
h: HANDLE,
|
||||
buf: [*]const u8,
|
||||
bytes: u32,
|
||||
written: *u32,
|
||||
ov: ?*anyopaque,
|
||||
) callconv(.{ .x86_stdcall = .{} }) i32;
|
||||
extern "kernel32" fn CloseHandle(h: HANDLE) callconv(.{ .x86_stdcall = .{} }) i32;
|
||||
extern "kernel32" fn CreateDirectoryA(name: [*:0]const u8, security: ?*anyopaque) callconv(.{ .x86_stdcall = .{} }) i32;
|
||||
extern "kernel32" fn SetFilePointer(h: HANDLE, lo: i32, hi: ?*i32, method: u32) callconv(.{ .x86_stdcall = .{} }) u32;
|
||||
|
||||
const GENERIC_WRITE: u32 = 0x40000000;
|
||||
const FILE_SHARE_READ: u32 = 0x1;
|
||||
const CREATE_ALWAYS: u32 = 2;
|
||||
const OPEN_ALWAYS: u32 = 4;
|
||||
const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
|
||||
const FILE_END: u32 = 2;
|
||||
|
||||
fn writeAll(h: HANDLE, data: []const u8) bool {
|
||||
var written: u32 = 0;
|
||||
const ok = WriteFile(h, data.ptr, @intCast(data.len), &written, null);
|
||||
return ok != 0 and written == data.len;
|
||||
}
|
||||
|
||||
pub const module_name: [*:0]const u8 = "transform_capture";
|
||||
|
||||
// Hook into game transformMatrix4x4
|
||||
const TransformFn = fn (u32, u32, u32, u32, u32) callconv(.{ .x86_thiscall = .{} }) void;
|
||||
var transform_hook: hook.Detour(TransformFn) = .{};
|
||||
|
||||
// =============================================================================
|
||||
// SceneObject offsets (same as bone_sse, duplicated here so we don't couple)
|
||||
// =============================================================================
|
||||
|
||||
const SO_MODEL_DATA_PTR: u32 = 0x010;
|
||||
const SO_ANIM_CTX_PTR: u32 = 0x02C;
|
||||
const SO_MODEL_CTR_PTR: u32 = 0x030;
|
||||
const SO_SYNC_VALUE: u32 = 0x040;
|
||||
const SO_BONE_RT_BASE: u32 = 0x090;
|
||||
const SO_BONE_OUT_PTR: u32 = 0x094;
|
||||
const SO_TEX_ANIM_OUT: u32 = 0x0A0;
|
||||
const SO_COLOR_ANIM_OUT: u32 = 0x0A8;
|
||||
const SO_SCALE1: u32 = 0x0AC;
|
||||
const SO_SCALE2: u32 = 0x0B0;
|
||||
const SO_SCALE3: u32 = 0x0B4;
|
||||
const SO_HIERARCHY_PTR: u32 = 0x1C8;
|
||||
const SO_HIERARCHY_IDX: u32 = 0x1DC;
|
||||
const SO_FIELD_200: u32 = 0x200;
|
||||
const SO_PARTICLE1: u32 = 0x3C4;
|
||||
const SO_PARTICLE2: u32 = 0x3C8;
|
||||
const SO_PARTICLE3: u32 = 0x3D0;
|
||||
const SO_PARTICLE4: u32 = 0x3D4;
|
||||
|
||||
// =============================================================================
|
||||
// Memory helpers
|
||||
// =============================================================================
|
||||
|
||||
inline fn ru32(addr: u32) u32 {
|
||||
return @as(*const u32, @ptrFromInt(addr)).*;
|
||||
}
|
||||
inline fn ru16(addr: u32) u16 {
|
||||
return @as(*align(1) const u16, @ptrFromInt(addr)).*;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page capture infrastructure
|
||||
// =============================================================================
|
||||
|
||||
const PAGE_SIZE: u32 = 0x1000;
|
||||
const PAGE_MASK: u32 = 0xFFFFF000;
|
||||
const MAX_CAPTURES: usize = 64; // cap on unique-hash scenarios we record
|
||||
const MAX_PAGES_PER_CAPTURE: usize = 512; // each capture can touch up to 2MB
|
||||
|
||||
/// One captured page's raw bytes plus its VA.
|
||||
const PageBlock = extern struct {
|
||||
va: u32,
|
||||
data: [PAGE_SIZE]u8,
|
||||
};
|
||||
|
||||
/// One captured scenario: input pages + output pages + entry args.
|
||||
const Capture = struct {
|
||||
path_hash: u32 = 0,
|
||||
this: u32 = 0,
|
||||
mat1: u32 = 0,
|
||||
mat2: u32 = 0,
|
||||
mat3: u32 = 0,
|
||||
mat4: u32 = 0,
|
||||
|
||||
input_page_count: u32 = 0,
|
||||
output_page_count: u32 = 0,
|
||||
input_pages: [*]PageBlock = undefined,
|
||||
output_pages: [*]PageBlock = undefined,
|
||||
};
|
||||
|
||||
// One scratch slot — we write captures to disk immediately, so only one in-
|
||||
// flight capture needs memory at a time.
|
||||
var scratch: Capture = .{};
|
||||
var scratch_input_pages: [MAX_PAGES_PER_CAPTURE]PageBlock align(16) = undefined;
|
||||
var scratch_output_pages: [MAX_PAGES_PER_CAPTURE]PageBlock align(16) = undefined;
|
||||
|
||||
// Deduplication: array of hashes we've already dumped.
|
||||
var seen_hashes: [MAX_CAPTURES]u32 = [_]u32{0} ** MAX_CAPTURES;
|
||||
var seen_hash_count: usize = 0;
|
||||
|
||||
// Re-entry guard (transformMatrix4x4 recurses into itself for attachments).
|
||||
var in_capture: bool = false;
|
||||
|
||||
// Total captures written (== seen_hash_count, kept separate for clarity)
|
||||
var capture_count: usize = 0;
|
||||
|
||||
// Total hook calls seen (for coverage statistics)
|
||||
var total_calls: u64 = 0;
|
||||
|
||||
// Captures-written-per-log-slot so we can track distribution of hash values
|
||||
var call_counts_per_hash: [MAX_CAPTURES]u32 = [_]u32{0} ** MAX_CAPTURES;
|
||||
|
||||
// How many consecutive calls have yielded no new hash (for "coverage saturated" heuristic).
|
||||
var calls_since_new_hash: u64 = 0;
|
||||
|
||||
// Output directory — set at install time. Default = current working dir.
|
||||
var capture_dir: []const u8 = ".";
|
||||
|
||||
// Per-capture scratch: pages we've already saved this iteration.
|
||||
var seen_pages: [MAX_PAGES_PER_CAPTURE]u32 = [_]u32{0} ** MAX_PAGES_PER_CAPTURE;
|
||||
var seen_count: usize = 0;
|
||||
|
||||
fn seenAdd(page_va: u32) bool {
|
||||
// Linear scan; MAX_PAGES_PER_CAPTURE is small enough that this is cheap.
|
||||
for (seen_pages[0..seen_count]) |p| if (p == page_va) return false;
|
||||
if (seen_count >= MAX_PAGES_PER_CAPTURE) return false;
|
||||
seen_pages[seen_count] = page_va;
|
||||
seen_count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
fn seenReset() void {
|
||||
seen_count = 0;
|
||||
}
|
||||
|
||||
/// Copy one 4KB page from game memory into the destination array.
|
||||
/// Returns true if the page was new (added to seen list).
|
||||
fn captureOnePage(addr: u32, dst: [*]PageBlock, idx: *u32) bool {
|
||||
const page_va = addr & PAGE_MASK;
|
||||
if (!seenAdd(page_va)) return false;
|
||||
if (idx.* >= MAX_PAGES_PER_CAPTURE) return false;
|
||||
dst[idx.*].va = page_va;
|
||||
@memcpy(&dst[idx.*].data, @as([*]const u8, @ptrFromInt(page_va))[0..PAGE_SIZE]);
|
||||
idx.* += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Capture a memory range (rounded to pages).
|
||||
fn capturePagesCovering(addr: u32, size: u32, dst: [*]PageBlock, idx: *u32) void {
|
||||
if (addr == 0 or size == 0) return;
|
||||
const start_page = addr & PAGE_MASK;
|
||||
const end_addr = addr +% size;
|
||||
var cur = start_page;
|
||||
while (cur < end_addr) : (cur +%= PAGE_SIZE) {
|
||||
_ = captureOnePage(cur, dst, idx);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Graph walk — identify all pages reachable from the SceneObject that
|
||||
// transformMatrix4x4 might read.
|
||||
// =============================================================================
|
||||
|
||||
fn walkAndCapture(this: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u32, dst: [*]PageBlock, idx: *u32) void {
|
||||
_ = mat4;
|
||||
|
||||
// Direct pages
|
||||
capturePagesCovering(this, 0x400, dst, idx);
|
||||
capturePagesCovering(mat1, 0x40, dst, idx);
|
||||
capturePagesCovering(mat2, 0x0C, dst, idx);
|
||||
capturePagesCovering(mat3, 0x0C, dst, idx);
|
||||
|
||||
// Following pointers
|
||||
const anim_ctx = ru32(this + SO_ANIM_CTX_PTR);
|
||||
capturePagesCovering(anim_ctx, 0x20, dst, idx);
|
||||
|
||||
const model_ctr = ru32(this + SO_MODEL_CTR_PTR);
|
||||
capturePagesCovering(model_ctr, 0x140, dst, idx);
|
||||
if (model_ctr == 0) return;
|
||||
|
||||
const model_hdr = ru32(model_ctr + 0x130);
|
||||
capturePagesCovering(model_hdr, 0x200, dst, idx);
|
||||
if (model_hdr == 0) return;
|
||||
|
||||
// model_hdr reference arrays
|
||||
const gs_durations = ru32(model_hdr + 0x18);
|
||||
const gs_count = ru32(model_hdr + 0x14);
|
||||
capturePagesCovering(gs_durations, gs_count * 4, dst, idx);
|
||||
|
||||
const anim_lookup = ru32(model_hdr + 0x20);
|
||||
capturePagesCovering(anim_lookup, 0x1000, dst, idx); // conservative; actual size unknown
|
||||
|
||||
const bone_count = ru32(model_hdr + 0x34);
|
||||
const bone_defs = ru32(model_hdr + 0x38);
|
||||
capturePagesCovering(bone_defs, bone_count * 0x6C, dst, idx);
|
||||
|
||||
// bone_rt / bone_out
|
||||
const bone_rt = ru32(this + SO_BONE_RT_BASE);
|
||||
capturePagesCovering(bone_rt, bone_count * 0x118, dst, idx);
|
||||
const bone_out = ru32(this + SO_BONE_OUT_PTR);
|
||||
capturePagesCovering(bone_out, bone_count * 0x40, dst, idx);
|
||||
|
||||
// AnimData inside bone_defs references keyframe arrays
|
||||
var bi: u32 = 0;
|
||||
var bd = bone_defs;
|
||||
while (bi < bone_count) : ({
|
||||
bi += 1;
|
||||
bd += 0x6C;
|
||||
}) {
|
||||
// Three animation tracks per bone (trans/rot/scale)
|
||||
inline for ([_]u32{ 0x0C, 0x28, 0x44 }) |track_off| {
|
||||
const ad = bd + track_off;
|
||||
const ranges = ru32(ad + 0x08);
|
||||
const ranges_count = ru32(ad + 0x04); // nRanges
|
||||
capturePagesCovering(ranges, ranges_count * 8, dst, idx);
|
||||
const ts = ru32(ad + 0x10);
|
||||
const kf_count = ru32(ad + 0x0C);
|
||||
capturePagesCovering(ts, kf_count * 4, dst, idx);
|
||||
const kf_base = ru32(ad + 0x18);
|
||||
// Stride varies by track — trans/scale=12, rot=16, but spline modes
|
||||
// use 36. Conservative: use 36 × kf_count per track.
|
||||
capturePagesCovering(kf_base, kf_count * 36, dst, idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Output-buffer pages (will be WRITTEN but we need to capture input state
|
||||
// too in case the game reads before writing)
|
||||
capturePagesCovering(ru32(this + SO_TEX_ANIM_OUT), 0x200, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_COLOR_ANIM_OUT), 0x200, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_SCALE1), 0x200, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_SCALE2), 0x800, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_SCALE3), 0x800, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_HIERARCHY_PTR), 0x800, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_FIELD_200), 0x800, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_PARTICLE1), 0x800, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_PARTICLE2), 0x800, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_PARTICLE3), 0x800, dst, idx);
|
||||
capturePagesCovering(ru32(this + SO_PARTICLE4), 0x400, dst, idx);
|
||||
|
||||
// Attachment data
|
||||
const attach_count = ru32(model_hdr + 0x104);
|
||||
const attach_data = ru32(model_hdr + 0x108);
|
||||
capturePagesCovering(attach_data, attach_count * 0x30, dst, idx);
|
||||
|
||||
// Texture/color/word anim data
|
||||
inline for ([_]struct { cnt: u32, dat: u32, stride: u32 }{
|
||||
.{ .cnt = 0x54, .dat = 0x58, .stride = 0x34 }, // tex anim
|
||||
.{ .cnt = 0x64, .dat = 0x68, .stride = 0x28 }, // color anim
|
||||
.{ .cnt = 0x74, .dat = 0x78, .stride = 0x1C }, // word anim
|
||||
.{ .cnt = 0xAC, .dat = 0xB0, .stride = 0x54 }, // bone keyframe
|
||||
.{ .cnt = 0x11C, .dat = 0x120, .stride = 0xD4 }, // ribbon emitter
|
||||
.{ .cnt = 0x124, .dat = 0x128, .stride = 0x7C }, // particle emitter
|
||||
.{ .cnt = 0x134, .dat = 0x138, .stride = 0xDC }, // partsec
|
||||
.{ .cnt = 0x13C, .dat = 0x140, .stride = 0x1F8 }, // partlarge
|
||||
}) |sec| {
|
||||
const c = ru32(model_hdr + sec.cnt);
|
||||
const d = ru32(model_hdr + sec.dat);
|
||||
capturePagesCovering(d, c * sec.stride, dst, idx);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Path hash — distinguishes different code paths through transformMatrix4x4.
|
||||
// Cheap to compute; aim to hit every major branch.
|
||||
// =============================================================================
|
||||
|
||||
fn pathHash(this: u32) u32 {
|
||||
var h: u32 = 0x811C9DC5;
|
||||
|
||||
const model_ctr = ru32(this + SO_MODEL_CTR_PTR);
|
||||
if (model_ctr == 0) return h;
|
||||
const model_hdr = ru32(model_ctr + 0x130);
|
||||
if (model_hdr == 0) return h;
|
||||
|
||||
const bone_count = ru32(model_hdr + 0x34);
|
||||
h = (h ^ bone_count) *% 0x01000193;
|
||||
|
||||
const bone_defs = ru32(model_hdr + 0x38);
|
||||
if (bone_count > 0 and bone_defs != 0) {
|
||||
// Flags of first few bones (encodes billboard types, etc.)
|
||||
const max_bones = @min(bone_count, 6);
|
||||
var bi: u32 = 0;
|
||||
while (bi < max_bones) : (bi += 1) {
|
||||
const flags = ru32(bone_defs + bi * 0x6C + 0x04);
|
||||
h = (h ^ flags) *% 0x01000193;
|
||||
}
|
||||
}
|
||||
|
||||
// Which post-bone-loop sections are populated?
|
||||
inline for ([_]u32{ 0x14, 0x54, 0x64, 0x74, 0xAC, 0x104, 0x11C, 0x124, 0x134, 0x13C }) |off| {
|
||||
const v = ru32(model_hdr + off);
|
||||
h = (h ^ (if (v == 0) @as(u32, 0) else 1)) *% 0x01000193;
|
||||
}
|
||||
|
||||
// emitter_ctx presence
|
||||
const emitter_ctx = ru32(this + 0x1CC);
|
||||
h = (h ^ (if (emitter_ctx == 0) @as(u32, 0) else 1)) *% 0x01000193;
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Detour
|
||||
// =============================================================================
|
||||
|
||||
fn transformDetour(this: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u32) callconv(.{ .x86_thiscall = .{} }) void {
|
||||
// Re-entry guard: transformMatrix4x4 recurses for attachment children.
|
||||
if (in_capture) {
|
||||
transform_hook.callOriginal(.{ this, mat1, mat2, mat3, mat4 });
|
||||
return;
|
||||
}
|
||||
|
||||
total_calls +%= 1;
|
||||
|
||||
if (capture_count < MAX_CAPTURES) {
|
||||
const h = pathHash(this);
|
||||
var known_idx: ?usize = null;
|
||||
for (seen_hashes[0..seen_hash_count], 0..) |sh, i| if (sh == h) {
|
||||
known_idx = i;
|
||||
break;
|
||||
};
|
||||
if (known_idx) |idx| {
|
||||
call_counts_per_hash[idx] +%= 1;
|
||||
calls_since_new_hash +%= 1;
|
||||
} else {
|
||||
in_capture = true;
|
||||
defer in_capture = false;
|
||||
|
||||
scratch.path_hash = h;
|
||||
scratch.this = this;
|
||||
scratch.mat1 = mat1;
|
||||
scratch.mat2 = mat2;
|
||||
scratch.mat3 = mat3;
|
||||
scratch.mat4 = mat4;
|
||||
scratch.input_page_count = 0;
|
||||
scratch.output_page_count = 0;
|
||||
|
||||
seenReset();
|
||||
walkAndCapture(this, mat1, mat2, mat3, mat4, &scratch_input_pages, &scratch.input_page_count);
|
||||
|
||||
transform_hook.callOriginal(.{ this, mat1, mat2, mat3, mat4 });
|
||||
|
||||
var oi: u32 = 0;
|
||||
var pi: usize = 0;
|
||||
while (pi < scratch.input_page_count) : (pi += 1) {
|
||||
if (oi >= MAX_PAGES_PER_CAPTURE) break;
|
||||
scratch_output_pages[oi].va = scratch_input_pages[pi].va;
|
||||
@memcpy(&scratch_output_pages[oi].data, @as([*]const u8, @ptrFromInt(scratch_input_pages[pi].va))[0..PAGE_SIZE]);
|
||||
oi += 1;
|
||||
}
|
||||
scratch.output_page_count = oi;
|
||||
scratch.input_pages = &scratch_input_pages;
|
||||
scratch.output_pages = &scratch_output_pages;
|
||||
|
||||
const dump_err = dumpOneToDir(capture_dir, scratch);
|
||||
const cov_err = appendCoverageLog(h, scratch.input_page_count);
|
||||
log.fmt("capture: hash=0x{x:08} pages={d} dump={any} cov={any}\n", .{
|
||||
h, scratch.input_page_count, dump_err, cov_err,
|
||||
});
|
||||
|
||||
if (seen_hash_count < MAX_CAPTURES) {
|
||||
seen_hashes[seen_hash_count] = h;
|
||||
call_counts_per_hash[seen_hash_count] = 1;
|
||||
seen_hash_count += 1;
|
||||
}
|
||||
capture_count += 1;
|
||||
calls_since_new_hash = 0;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
calls_since_new_hash +%= 1;
|
||||
}
|
||||
|
||||
transform_hook.callOriginal(.{ this, mat1, mat2, mat3, mat4 });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// File format
|
||||
// =============================================================================
|
||||
|
||||
const MAGIC: [4]u8 = .{ 'T', 'R', 'X', 'C' };
|
||||
const VERSION: u32 = 1;
|
||||
|
||||
const FileHeader = extern struct {
|
||||
magic: [4]u8 = MAGIC,
|
||||
version: u32 = VERSION,
|
||||
path_hash: u32,
|
||||
this: u32,
|
||||
mat1: u32,
|
||||
mat2: u32,
|
||||
mat3: u32,
|
||||
mat4: u32,
|
||||
input_page_count: u32,
|
||||
output_page_count: u32,
|
||||
};
|
||||
|
||||
/// Build a null-terminated path: "<dir>/<filename>".
|
||||
fn buildPath(buf: []u8, dir: []const u8, filename: []const u8) ![:0]const u8 {
|
||||
const total = dir.len + 1 + filename.len + 1;
|
||||
if (total > buf.len) return error.NoSpaceLeft;
|
||||
@memcpy(buf[0..dir.len], dir);
|
||||
buf[dir.len] = '/';
|
||||
@memcpy(buf[dir.len + 1 ..][0..filename.len], filename);
|
||||
buf[dir.len + 1 + filename.len] = 0;
|
||||
return buf[0 .. dir.len + 1 + filename.len :0];
|
||||
}
|
||||
|
||||
fn buildDirZ(buf: []u8, dir: []const u8) ![:0]const u8 {
|
||||
if (dir.len + 1 > buf.len) return error.NoSpaceLeft;
|
||||
@memcpy(buf[0..dir.len], dir);
|
||||
buf[dir.len] = 0;
|
||||
return buf[0..dir.len :0];
|
||||
}
|
||||
|
||||
fn dumpOneToDir(dir_path: []const u8, c: Capture) !void {
|
||||
var dir_buf: [256]u8 = undefined;
|
||||
const dir_z = try buildDirZ(&dir_buf, dir_path);
|
||||
_ = CreateDirectoryA(dir_z.ptr, null); // ignore result: already-exists is fine
|
||||
|
||||
var name_buf: [64]u8 = undefined;
|
||||
const fname = try std.fmt.bufPrint(&name_buf, "{x:08}.trxcap", .{c.path_hash});
|
||||
|
||||
var path_buf: [512]u8 = undefined;
|
||||
const path = try buildPath(&path_buf, dir_path, fname);
|
||||
|
||||
const h = CreateFileA(path.ptr, GENERIC_WRITE, FILE_SHARE_READ, null, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, null);
|
||||
if (h == INVALID_HANDLE) return error.CreateFailed;
|
||||
defer _ = CloseHandle(h);
|
||||
|
||||
const hdr = FileHeader{
|
||||
.path_hash = c.path_hash,
|
||||
.this = c.this,
|
||||
.mat1 = c.mat1,
|
||||
.mat2 = c.mat2,
|
||||
.mat3 = c.mat3,
|
||||
.mat4 = c.mat4,
|
||||
.input_page_count = c.input_page_count,
|
||||
.output_page_count = c.output_page_count,
|
||||
};
|
||||
if (!writeAll(h, std.mem.asBytes(&hdr))) return error.WriteFailed;
|
||||
if (!writeAll(h, @as([*]const u8, @ptrCast(c.input_pages))[0 .. c.input_page_count * @sizeOf(PageBlock)])) return error.WriteFailed;
|
||||
if (!writeAll(h, @as([*]const u8, @ptrCast(c.output_pages))[0 .. c.output_page_count * @sizeOf(PageBlock)])) return error.WriteFailed;
|
||||
}
|
||||
|
||||
/// Append a line to coverage.log whenever we see a new path hash.
|
||||
fn appendCoverageLog(new_hash: u32, page_count: u32) !void {
|
||||
var dir_buf: [256]u8 = undefined;
|
||||
const dir_z = try buildDirZ(&dir_buf, capture_dir);
|
||||
_ = CreateDirectoryA(dir_z.ptr, null);
|
||||
|
||||
var path_buf: [512]u8 = undefined;
|
||||
const path = try buildPath(&path_buf, capture_dir, "coverage.log");
|
||||
|
||||
const h = CreateFileA(path.ptr, GENERIC_WRITE, FILE_SHARE_READ, null, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, null);
|
||||
if (h == INVALID_HANDLE) return error.CreateFailed;
|
||||
defer _ = CloseHandle(h);
|
||||
_ = SetFilePointer(h, 0, null, FILE_END);
|
||||
|
||||
var line_buf: [256]u8 = undefined;
|
||||
const line = try std.fmt.bufPrint(&line_buf, "[#{d}] new hash 0x{x:08} (total_calls={d}, pages={d})\n", .{
|
||||
seen_hash_count + 1, new_hash, total_calls, page_count,
|
||||
});
|
||||
if (!writeAll(h, line)) return error.WriteFailed;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Install / Remove
|
||||
// =============================================================================
|
||||
|
||||
/// Install with the given output directory (relative to WoW's current working
|
||||
/// directory, usually the game install folder). Captures + coverage.log will
|
||||
/// land there. Pass "." to write them next to WoW.exe.
|
||||
pub fn install() bool {
|
||||
return installTo("transform_captures");
|
||||
}
|
||||
|
||||
pub fn installTo(dir: []const u8) bool {
|
||||
log = logging.Logger.open("transform_capture", .both);
|
||||
capture_dir = dir;
|
||||
const ok = transform_hook.attach(0x714260, &transformDetour) == .ok;
|
||||
log.fmt("install: hook_attach={any} dir={s}\n", .{ ok, dir });
|
||||
|
||||
// Smoke-test the write path so we know if file I/O is broken before any
|
||||
// transform calls arrive.
|
||||
var dir_buf: [256]u8 = undefined;
|
||||
const dir_z = buildDirZ(&dir_buf, capture_dir) catch return ok;
|
||||
const mkdir_ok = CreateDirectoryA(dir_z.ptr, null);
|
||||
log.fmt("install: mkdir returned {d}\n", .{mkdir_ok});
|
||||
|
||||
var path_buf: [512]u8 = undefined;
|
||||
const path = buildPath(&path_buf, capture_dir, "install_test.txt") catch return ok;
|
||||
const h = CreateFileA(path.ptr, GENERIC_WRITE, FILE_SHARE_READ, null, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, null);
|
||||
if (h == INVALID_HANDLE) {
|
||||
log.fmt("install: CreateFileA FAILED for {s}\n", .{path});
|
||||
} else {
|
||||
_ = writeAll(h, "transform_capture install test\n");
|
||||
_ = CloseHandle(h);
|
||||
log.fmt("install: wrote install_test.txt ok\n", .{});
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
pub fn remove() void {
|
||||
transform_hook.detach();
|
||||
}
|
||||
|
||||
pub fn getCaptureCount() usize {
|
||||
return capture_count;
|
||||
}
|
||||
|
||||
pub fn getTotalCalls() u64 {
|
||||
return total_calls;
|
||||
}
|
||||
|
||||
pub fn getCallsSinceNewHash() u64 {
|
||||
return calls_since_new_hash;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const mod_mutex = @import("../mutex.zig");
|
||||
const inflate_hook = @import("inflate_hook.zig");
|
||||
const timer_fix = @import("timer_fix.zig");
|
||||
const filecache = @import("filecache.zig");
|
||||
const transform_capture = @import("transform_capture.zig");
|
||||
|
||||
pub const module_name: [*:0]const u8 = "weirdperformance";
|
||||
|
||||
@@ -364,8 +365,17 @@ pub fn installHooks() void {
|
||||
log = logging.Logger.open(module_name, .both);
|
||||
var installed: u32 = 0;
|
||||
|
||||
// Bone transform SSE
|
||||
if (transform_hook.attach(0x714260, &bone_sse64.transformImpl_SSE64) == .ok) installed += 1;
|
||||
// Bone transform: either the f64 SSE impl for normal operation, OR the
|
||||
// transform_capture hook for recording game-x87 state to disk.
|
||||
// Compile-time flag picks one: the two are mutually exclusive because
|
||||
// capture requires the game's x87 output (bone_sse64 would replace it).
|
||||
const build_options = @import("build_options");
|
||||
const capture_mode = @hasDecl(build_options, "transform_capture") and build_options.transform_capture;
|
||||
if (capture_mode) {
|
||||
if (transform_capture.install()) installed += 1;
|
||||
} else {
|
||||
if (transform_hook.attach(0x714260, &bone_sse64.transformImpl_SSE64) == .ok) installed += 1;
|
||||
}
|
||||
|
||||
// Frustum clip SSE (1.9x speedup)
|
||||
if (clip_hook.attach(0x6318C0, &clip_sse.clipPolygonToSinglePlane) == .ok) installed += 1;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# MPQ Load Order — WoW 1.12.1
|
||||
|
||||
`MPQ_InitializeArchives` (0x403740) loads in two phases.
|
||||
|
||||
## Phase 1 — Base archives (loaded by index)
|
||||
|
||||
Filename table at `0x82e12c`:
|
||||
|
||||
| Index | Archive |
|
||||
|-------|---------|
|
||||
| 0 | model.MPQ |
|
||||
| 1 | texture.MPQ |
|
||||
| 2 | terrain.MPQ |
|
||||
| 3 | wmo.MPQ |
|
||||
| 4 | sound.MPQ |
|
||||
| 5 | misc.MPQ |
|
||||
| 6 | interface.MPQ |
|
||||
| 7 | fonts.MPQ |
|
||||
| 8 | speech.MPQ |
|
||||
| 9 | dbc.MPQ |
|
||||
| 10 | speech2.MPQ |
|
||||
|
||||
Each opened via `OpenMPQArchiveWithPaths` (0x403b00) which tries `Data\name` then `..\Data\name`.
|
||||
|
||||
## Phase 2 — Patch archives
|
||||
|
||||
- `MPQArchiveEnumerator` (0x4039b0) discovers `patch-?.MPQ` files via glob
|
||||
- Then opens `patch.MPQ` and any discovered `patch-X.MPQ` archives
|
||||
|
||||
## Search order at file lookup time
|
||||
|
||||
`File_FindInArchive` (0x6549a0) searches the global archive array at `0x8826b4` recursively — later-registered archives (patches) are checked first. The array is searched from `count-1` down to `0`, so patches override base content.
|
||||
|
||||
## Global archive array
|
||||
|
||||
```
|
||||
Struct at 0x8826b4:
|
||||
+0x00 [0x8826b4]: capacity
|
||||
+0x04 [0x8826b8]: count
|
||||
+0x08 [0x8826bc]: array_ptr (SArchive**)
|
||||
+0x0C [0x8826c0]: growth_incr
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
-- MarkerPresets: save and restore world marker layouts.
|
||||
-- Demonstrates the WorldMarker / GetWorldMarker / ClearWorldMarker API.
|
||||
--
|
||||
-- /mp save <name> — snapshot all current marker positions
|
||||
-- /mp place <name> — restore a saved layout (places all markers at once)
|
||||
-- /mp clear — clear all markers
|
||||
-- /mp list — list saved presets
|
||||
-- /mp delete <name> — delete a preset
|
||||
|
||||
local NUM_MARKERS = 5
|
||||
local PREFIX = "|cff00ccff[Markers]|r "
|
||||
|
||||
local function msg(text)
|
||||
DEFAULT_CHAT_FRAME:AddMessage(PREFIX .. text)
|
||||
end
|
||||
|
||||
local function savePreset(name)
|
||||
if not name or name == "" then
|
||||
msg("Usage: /mp save <name>")
|
||||
return
|
||||
end
|
||||
|
||||
local preset = {}
|
||||
local count = 0
|
||||
for i = 1, NUM_MARKERS do
|
||||
local x, y, z, area = GetWorldMarker(i)
|
||||
if x then
|
||||
preset[i] = { x = x, y = y, z = z, area = area }
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
|
||||
if count == 0 then
|
||||
msg("No markers to save.")
|
||||
return
|
||||
end
|
||||
|
||||
MarkerPresets_Saved[name] = preset
|
||||
msg("Saved |cffffffff" .. name .. "|r (" .. count .. " marker" .. (count > 1 and "s" or "") .. ")")
|
||||
end
|
||||
|
||||
local function placePreset(name)
|
||||
if not name or name == "" then
|
||||
msg("Usage: /mp place <name>")
|
||||
return
|
||||
end
|
||||
|
||||
local preset = MarkerPresets_Saved[name]
|
||||
if not preset then
|
||||
msg("No preset named |cffffffff" .. name .. "|r")
|
||||
return
|
||||
end
|
||||
|
||||
if not CanSetWorldMarkers() then
|
||||
msg("|cffff4444No permission|r (need leader or assist)")
|
||||
return
|
||||
end
|
||||
|
||||
-- Clear existing markers, then place the saved ones
|
||||
ClearWorldMarker()
|
||||
|
||||
local count = 0
|
||||
for i = 1, NUM_MARKERS do
|
||||
local m = preset[i]
|
||||
if m then
|
||||
WorldMarker(i, m.x, m.y, m.z)
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
|
||||
msg("Placed |cffffffff" .. name .. "|r (" .. count .. " marker" .. (count > 1 and "s" or "") .. ")")
|
||||
end
|
||||
|
||||
local function clearMarkers()
|
||||
if not CanSetWorldMarkers() then
|
||||
msg("|cffff4444No permission|r (need leader or assist)")
|
||||
return
|
||||
end
|
||||
ClearWorldMarker()
|
||||
msg("Cleared all markers.")
|
||||
end
|
||||
|
||||
local function listPresets()
|
||||
local any = false
|
||||
for name, preset in pairs(MarkerPresets_Saved) do
|
||||
local count = 0
|
||||
for i = 1, NUM_MARKERS do
|
||||
if preset[i] then count = count + 1 end
|
||||
end
|
||||
msg(" |cffffffff" .. name .. "|r — " .. count .. " marker" .. (count > 1 and "s" or ""))
|
||||
any = true
|
||||
end
|
||||
if not any then
|
||||
msg("No saved presets.")
|
||||
end
|
||||
end
|
||||
|
||||
local function deletePreset(name)
|
||||
if not name or name == "" then
|
||||
msg("Usage: /mp delete <name>")
|
||||
return
|
||||
end
|
||||
if not MarkerPresets_Saved[name] then
|
||||
msg("No preset named |cffffffff" .. name .. "|r")
|
||||
return
|
||||
end
|
||||
MarkerPresets_Saved[name] = nil
|
||||
msg("Deleted |cffffffff" .. name .. "|r")
|
||||
end
|
||||
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("VARIABLES_LOADED")
|
||||
f:SetScript("OnEvent", function()
|
||||
MarkerPresets_Saved = MarkerPresets_Saved or {}
|
||||
end)
|
||||
|
||||
SLASH_MARKERPRESETS1 = "/mp"
|
||||
SLASH_MARKERPRESETS2 = "/markerpreset"
|
||||
SlashCmdList["MARKERPRESETS"] = function(input)
|
||||
local cmd, rest = string.match(input, "^(%S+)%s*(.*)")
|
||||
if not cmd then
|
||||
msg("Commands: save | place | clear | list | delete")
|
||||
return
|
||||
end
|
||||
|
||||
cmd = string.lower(cmd)
|
||||
local name = rest ~= "" and rest or nil
|
||||
|
||||
if cmd == "save" then
|
||||
savePreset(name)
|
||||
elseif cmd == "place" or cmd == "load" then
|
||||
placePreset(name)
|
||||
elseif cmd == "clear" then
|
||||
clearMarkers()
|
||||
elseif cmd == "list" or cmd == "ls" then
|
||||
listPresets()
|
||||
elseif cmd == "delete" or cmd == "del" or cmd == "rm" then
|
||||
deletePreset(name)
|
||||
else
|
||||
msg("Unknown command: " .. cmd)
|
||||
msg("Commands: save | place | clear | list | delete")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
## Interface: 11200
|
||||
## Title: [WeirdUtils] Marker Presets
|
||||
## Notes: Save and restore world marker positions. Requires WeirdUtils World Markers.
|
||||
## Version: 1.0
|
||||
## SavedVariablesPerCharacter: MarkerPresetsDB
|
||||
|
||||
MarkerPresets.lua
|
||||
@@ -22,7 +22,6 @@ const std = @import("std");
|
||||
const hook = @import("zhook");
|
||||
const lua = @import("../lua.zig");
|
||||
const o = @import("offsets.zig");
|
||||
const offsets = @import("../offsets.zig");
|
||||
const wow = @import("../wow.zig");
|
||||
const logging = @import("../logging.zig");
|
||||
|
||||
@@ -356,7 +355,7 @@ fn placeMarker(index: usize, pos: Vec3) bool {
|
||||
// Store persistent definition
|
||||
marker_defs[index] = .{
|
||||
.pos = pos,
|
||||
.area_id = hook.readMem(u32, offsets.ZONE_AREA_ID),
|
||||
.area_id = wow.getMapId(),
|
||||
.active = true,
|
||||
};
|
||||
|
||||
@@ -590,7 +589,7 @@ fn tickAnimations() void {
|
||||
if (player != 0) {
|
||||
const player_pos = getUnitPosition(player);
|
||||
if (player_pos.x != 0 or player_pos.y != 0 or player_pos.z != 0) {
|
||||
const current_area = hook.readMem(u32, offsets.ZONE_AREA_ID);
|
||||
const current_area = wow.getMapId();
|
||||
for (0..NUM_MARKERS) |i| {
|
||||
if (!marker_defs[i].active) continue;
|
||||
if (marker_entities[i] != null) continue; // entity alive, skip
|
||||
|
||||
@@ -189,6 +189,14 @@ pub fn isInBattleground() bool {
|
||||
return map_type == o.MAP_TYPE_BATTLEGROUND;
|
||||
}
|
||||
|
||||
/// Current map ID from the object manager (ObjMgr+0xCC).
|
||||
/// Returns 0xFFFFFFFF if the object manager is not available.
|
||||
pub fn getMapId() u32 {
|
||||
const obj_mgr = hook.readMem(u32, o.OBJECT_MANAGER_PTR);
|
||||
if (obj_mgr == 0) return 0xFFFFFFFF;
|
||||
return hook.readMem(u32, obj_mgr + o.OBJMGR_MAP_ID_OFFSET);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Game function wrappers
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert PNG to BLP2 (DXT3 compressed, with mipmaps)."""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def rgb_to_565(r, g, b):
|
||||
return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
|
||||
|
||||
|
||||
def color_565_to_rgb(c):
|
||||
r = ((c >> 11) & 0x1F) << 3
|
||||
g = ((c >> 5) & 0x3F) << 2
|
||||
b = (c & 0x1F) << 3
|
||||
return r, g, b
|
||||
|
||||
|
||||
def color_distance(r0, g0, b0, r1, g1, b1):
|
||||
return (r0 - r1) ** 2 + (g0 - g1) ** 2 + (b0 - b1) ** 2
|
||||
|
||||
|
||||
def encode_dxt1_block(pixels):
|
||||
"""Encode a 4x4 block of (r,g,b) tuples into 8-byte DXT1 color block.
|
||||
pixels: list of 16 (r,g,b) tuples in row-major order.
|
||||
"""
|
||||
# Find min/max colors by luminance
|
||||
min_c = min(pixels, key=lambda c: c[0] * 299 + c[1] * 587 + c[2] * 114)
|
||||
max_c = max(pixels, key=lambda c: c[0] * 299 + c[1] * 587 + c[2] * 114)
|
||||
|
||||
color0 = rgb_to_565(*max_c)
|
||||
color1 = rgb_to_565(*min_c)
|
||||
|
||||
# Ensure color0 > color1 for 4-color mode
|
||||
if color0 == color1:
|
||||
# All same color, indices all 0
|
||||
return struct.pack('<HHI', color0, color1, 0)
|
||||
|
||||
if color0 < color1:
|
||||
color0, color1 = color1, color0
|
||||
max_c, min_c = min_c, max_c
|
||||
|
||||
# Reconstruct actual RGB values after 565 quantization
|
||||
r0, g0, b0 = color_565_to_rgb(color0)
|
||||
r1, g1, b1 = color_565_to_rgb(color1)
|
||||
|
||||
# Build 4-color palette
|
||||
palette = [
|
||||
(r0, g0, b0),
|
||||
(r1, g1, b1),
|
||||
((2 * r0 + r1) // 3, (2 * g0 + g1) // 3, (2 * b0 + b1) // 3),
|
||||
((r0 + 2 * r1) // 3, (g0 + 2 * g1) // 3, (b0 + 2 * b1) // 3),
|
||||
]
|
||||
|
||||
# Find best index for each pixel
|
||||
indices = 0
|
||||
for i, (r, g, b) in enumerate(pixels):
|
||||
best_idx = 0
|
||||
best_dist = float('inf')
|
||||
for j, (pr, pg, pb) in enumerate(palette):
|
||||
d = color_distance(r, g, b, pr, pg, pb)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
best_idx = j
|
||||
indices |= best_idx << (i * 2)
|
||||
|
||||
return struct.pack('<HHI', color0, color1, indices)
|
||||
|
||||
|
||||
def encode_dxt3_block(pixels):
|
||||
"""Encode a 4x4 block of (r,g,b,a) tuples into 16-byte DXT3 block.
|
||||
pixels: list of 16 (r,g,b,a) tuples in row-major order.
|
||||
"""
|
||||
# Alpha: 4 bits per pixel, 64 bits total
|
||||
alpha_bits = 0
|
||||
for i, (_, _, _, a) in enumerate(pixels):
|
||||
alpha_bits |= (a >> 4) << (i * 4)
|
||||
|
||||
alpha_data = struct.pack('<Q', alpha_bits)
|
||||
|
||||
# Color: DXT1 block (ignore alpha)
|
||||
rgb_pixels = [(r, g, b) for r, g, b, _ in pixels]
|
||||
color_data = encode_dxt1_block(rgb_pixels)
|
||||
|
||||
return alpha_data + color_data
|
||||
|
||||
|
||||
def get_block_pixels(img_data, width, height, bx, by):
|
||||
"""Extract 4x4 pixel block from RGBA image data."""
|
||||
pixels = []
|
||||
for py in range(4):
|
||||
for px in range(4):
|
||||
x = bx * 4 + px
|
||||
y = by * 4 + py
|
||||
if x < width and y < height:
|
||||
idx = (y * width + x) * 4
|
||||
pixels.append(tuple(img_data[idx:idx + 4]))
|
||||
else:
|
||||
pixels.append((0, 0, 0, 0))
|
||||
return pixels
|
||||
|
||||
|
||||
def encode_dxt3_image(img_data, width, height):
|
||||
"""Encode full RGBA image as DXT3."""
|
||||
blocks_wide = (width + 3) // 4
|
||||
blocks_high = (height + 3) // 4
|
||||
result = bytearray()
|
||||
|
||||
for by in range(blocks_high):
|
||||
for bx in range(blocks_wide):
|
||||
pixels = get_block_pixels(img_data, width, height, bx, by)
|
||||
result.extend(encode_dxt3_block(pixels))
|
||||
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def generate_mipmaps(img):
|
||||
"""Generate mipmap chain from PIL Image. Returns list of (width, height, rgba_bytes)."""
|
||||
levels = [img]
|
||||
w, h = img.size
|
||||
while w > 1 or h > 1:
|
||||
w = max(1, w // 2)
|
||||
h = max(1, h // 2)
|
||||
levels.append(img.resize((w, h), Image.LANCZOS))
|
||||
return [(l.size[0], l.size[1], l.tobytes()) for l in levels]
|
||||
|
||||
|
||||
def png_to_blp2(png_path, blp_path):
|
||||
img = Image.open(png_path).convert('RGBA')
|
||||
width, height = img.size
|
||||
|
||||
mipmaps = generate_mipmaps(img)
|
||||
num_mipmaps = min(len(mipmaps), 16)
|
||||
|
||||
# Encode each mipmap level as DXT3
|
||||
encoded = []
|
||||
for w, h, data in mipmaps[:num_mipmaps]:
|
||||
encoded.append(encode_dxt3_image(data, w, h))
|
||||
|
||||
# BLP2 header: 1172 bytes
|
||||
# 4 (magic) + 4 (type) + 1 (compression) + 1 (alpha_depth) + 1 (alpha_type)
|
||||
# + 1 (has_mips) + 4 (width) + 4 (height) + 64 (offsets) + 64 (lengths)
|
||||
# + 1024 (palette) = 1172
|
||||
header_size = 4 + 4 + 1 + 1 + 1 + 1 + 4 + 4 + 64 + 64 + 1024
|
||||
|
||||
# Calculate offsets
|
||||
offsets = [0] * 16
|
||||
lengths = [0] * 16
|
||||
offset = header_size
|
||||
for i, data in enumerate(encoded):
|
||||
offsets[i] = offset
|
||||
lengths[i] = len(data)
|
||||
offset += len(data)
|
||||
|
||||
# Build header
|
||||
header = bytearray()
|
||||
header.extend(b'BLP2')
|
||||
header.extend(struct.pack('<I', 1)) # type = 1 (BLP/DXTC/Uncompressed)
|
||||
header.extend(struct.pack('<B', 2)) # compression = 2 (DXTC)
|
||||
header.extend(struct.pack('<B', 8)) # alpha_depth = 8
|
||||
header.extend(struct.pack('<B', 1)) # alpha_type = 1 (DXT3)
|
||||
header.extend(struct.pack('<B', 1)) # has_mips = 1
|
||||
header.extend(struct.pack('<I', width))
|
||||
header.extend(struct.pack('<I', height))
|
||||
for o in offsets:
|
||||
header.extend(struct.pack('<I', o))
|
||||
for l in lengths:
|
||||
header.extend(struct.pack('<I', l))
|
||||
header.extend(b'\x00' * 1024) # palette (unused for DXTC)
|
||||
|
||||
assert len(header) == header_size
|
||||
|
||||
with open(blp_path, 'wb') as f:
|
||||
f.write(header)
|
||||
for data in encoded:
|
||||
f.write(data)
|
||||
|
||||
print(f'{png_path} -> {blp_path}')
|
||||
print(f' {width}x{height}, {num_mipmaps} mipmaps, DXT3')
|
||||
print(f' {offset} bytes total')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
print(f'Usage: {sys.argv[0]} input.png output.blp')
|
||||
sys.exit(1)
|
||||
png_to_blp2(sys.argv[1], sys.argv[2])
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
StormLib ctypes wrapper for MPQ archive operations.
|
||||
Auto-downloads prebuilt StormLib from GitHub releases if not found locally.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
from ctypes import c_bool, c_char_p, c_uint32, c_uint64, c_void_p, byref, POINTER
|
||||
from pathlib import Path
|
||||
import platform
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
LIB_DIR = SCRIPT_DIR / "lib"
|
||||
|
||||
STORMLIB_VERSION = "v9.31"
|
||||
STORMLIB_RELEASE_URL = f"https://github.com/ladislav-zezula/StormLib/releases/download/{STORMLIB_VERSION}"
|
||||
|
||||
|
||||
def _download_stormlib():
|
||||
"""Download prebuilt StormLib for the current platform."""
|
||||
import urllib.request
|
||||
import ssl
|
||||
import tempfile
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
LIB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if system == "linux" and machine in ("x86_64", "amd64"):
|
||||
lib_name = "libstorm.so"
|
||||
deb_name = f"libstorm-dev_{STORMLIB_VERSION}_amd64.deb"
|
||||
url = f"{STORMLIB_RELEASE_URL}/{deb_name}"
|
||||
lib_path = LIB_DIR / lib_name
|
||||
|
||||
print(f"Downloading StormLib {STORMLIB_VERSION}...")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
pkg_path = tmpdir / "stormlib.deb"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "M2Editor/1.0"})
|
||||
# Try default SSL first, fall back to unverified if certs missing
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
resp_obj = urllib.request.urlopen(req, timeout=60, context=ctx)
|
||||
except ssl.SSLCertVerificationError:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
resp_obj = urllib.request.urlopen(req, timeout=60, context=ctx)
|
||||
with resp_obj as resp:
|
||||
pkg_path.write_bytes(resp.read())
|
||||
subprocess.run(["ar", "x", str(pkg_path)], cwd=tmpdir, check=True, capture_output=True)
|
||||
for data_tar in tmpdir.glob("data.tar.*"):
|
||||
subprocess.run(["tar", "xf", str(data_tar)], cwd=tmpdir, check=True, capture_output=True)
|
||||
break
|
||||
for so_file in tmpdir.rglob("libstorm.so*"):
|
||||
if so_file.is_file() and not so_file.is_symlink():
|
||||
shutil.copy2(so_file, lib_path)
|
||||
return lib_path
|
||||
|
||||
elif system == "windows" and machine in ("x86_64", "amd64"):
|
||||
lib_name = "storm.dll"
|
||||
url = f"{STORMLIB_RELEASE_URL}/stormlib_dll.zip"
|
||||
lib_path = LIB_DIR / lib_name
|
||||
|
||||
print(f"Downloading StormLib {STORMLIB_VERSION}...")
|
||||
import zipfile
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
pkg_path = tmpdir / "stormlib.zip"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "M2Editor/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
pkg_path.write_bytes(resp.read())
|
||||
with zipfile.ZipFile(pkg_path) as zf:
|
||||
zf.extractall(tmpdir)
|
||||
for dll in tmpdir.rglob("storm.dll"):
|
||||
if "x64" in str(dll.parent).lower() or "64" in str(dll.parent):
|
||||
shutil.copy2(dll, lib_path)
|
||||
return lib_path
|
||||
for dll in tmpdir.rglob("storm.dll"):
|
||||
shutil.copy2(dll, lib_path)
|
||||
return lib_path
|
||||
|
||||
raise ImportError(f"No prebuilt StormLib for {platform.system()} {platform.machine()}")
|
||||
|
||||
|
||||
def _load_stormlib():
|
||||
"""Find and load the StormLib shared library."""
|
||||
system = platform.system().lower()
|
||||
lib_name = "libstorm.so" if system == "linux" else "storm.dll"
|
||||
|
||||
search_paths = [
|
||||
LIB_DIR / lib_name,
|
||||
Path("/usr/local/lib") / lib_name,
|
||||
Path("/usr/lib") / lib_name,
|
||||
]
|
||||
|
||||
for path in search_paths:
|
||||
if path.exists():
|
||||
try:
|
||||
return ctypes.CDLL(str(path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
lib_path = _download_stormlib()
|
||||
return ctypes.CDLL(str(lib_path))
|
||||
|
||||
|
||||
_lib = _load_stormlib()
|
||||
|
||||
# Type aliases
|
||||
HANDLE = c_void_p
|
||||
DWORD = c_uint32
|
||||
LCID = c_uint32
|
||||
ULONGLONG = c_uint64
|
||||
TCHAR = c_char_p
|
||||
|
||||
# MPQ constants
|
||||
MPQ_CREATE_LISTFILE = 0x00100000
|
||||
MPQ_CREATE_ATTRIBUTES = 0x00200000
|
||||
MPQ_CREATE_ARCHIVE_V1 = 0x00000000
|
||||
|
||||
MPQ_FILE_COMPRESS = 0x00000200
|
||||
MPQ_FILE_REPLACEEXISTING = 0x80000000
|
||||
|
||||
MPQ_COMPRESSION_ZLIB = 0x02
|
||||
MPQ_OPEN_READ_ONLY = 0x00000100
|
||||
SFILE_OPEN_FROM_MPQ = 0x00000000
|
||||
|
||||
# Function signatures
|
||||
_lib.SFileCreateArchive.argtypes = [TCHAR, DWORD, DWORD, POINTER(HANDLE)]
|
||||
_lib.SFileCreateArchive.restype = c_bool
|
||||
|
||||
_lib.SFileOpenArchive.argtypes = [TCHAR, DWORD, DWORD, POINTER(HANDLE)]
|
||||
_lib.SFileOpenArchive.restype = c_bool
|
||||
|
||||
_lib.SFileCloseArchive.argtypes = [HANDLE]
|
||||
_lib.SFileCloseArchive.restype = c_bool
|
||||
|
||||
_lib.SFileFlushArchive.argtypes = [HANDLE]
|
||||
_lib.SFileFlushArchive.restype = c_bool
|
||||
|
||||
_lib.SFileAddFileEx.argtypes = [HANDLE, TCHAR, c_char_p, DWORD, DWORD, DWORD]
|
||||
_lib.SFileAddFileEx.restype = c_bool
|
||||
|
||||
_lib.SFileCreateFile.argtypes = [HANDLE, c_char_p, ULONGLONG, DWORD, LCID, DWORD, POINTER(HANDLE)]
|
||||
_lib.SFileCreateFile.restype = c_bool
|
||||
|
||||
_lib.SFileWriteFile.argtypes = [HANDLE, c_void_p, DWORD, DWORD]
|
||||
_lib.SFileWriteFile.restype = c_bool
|
||||
|
||||
_lib.SFileFinishFile.argtypes = [HANDLE]
|
||||
_lib.SFileFinishFile.restype = c_bool
|
||||
|
||||
_lib.SFileCloseFile.argtypes = [HANDLE]
|
||||
_lib.SFileCloseFile.restype = c_bool
|
||||
|
||||
_lib.SFileHasFile.argtypes = [HANDLE, c_char_p]
|
||||
_lib.SFileHasFile.restype = c_bool
|
||||
|
||||
_lib.SFileCompactArchive.argtypes = [HANDLE, TCHAR, c_bool]
|
||||
_lib.SFileCompactArchive.restype = c_bool
|
||||
|
||||
_lib.SFileOpenFileEx.argtypes = [HANDLE, c_char_p, DWORD, POINTER(HANDLE)]
|
||||
_lib.SFileOpenFileEx.restype = c_bool
|
||||
|
||||
_lib.SFileGetFileSize.argtypes = [HANDLE, POINTER(DWORD)]
|
||||
_lib.SFileGetFileSize.restype = DWORD
|
||||
|
||||
_lib.SFileReadFile.argtypes = [HANDLE, c_void_p, DWORD, POINTER(DWORD), c_void_p]
|
||||
_lib.SFileReadFile.restype = c_bool
|
||||
|
||||
_lib.SFileRemoveFile.argtypes = [HANDLE, c_char_p, DWORD]
|
||||
_lib.SFileRemoveFile.restype = c_bool
|
||||
|
||||
class SFILE_FIND_DATA(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cFileName", ctypes.c_char * 1024),
|
||||
("szPlainName", ctypes.c_char_p),
|
||||
("dwHashIndex", DWORD),
|
||||
("dwBlockIndex", DWORD),
|
||||
("dwFileSize", DWORD),
|
||||
("dwFileFlags", DWORD),
|
||||
("dwCompSize", DWORD),
|
||||
("dwFileTimeLo", DWORD),
|
||||
("dwFileTimeHi", DWORD),
|
||||
("lcLocale", LCID),
|
||||
]
|
||||
|
||||
_lib.SFileFindFirstFile.argtypes = [HANDLE, c_char_p, POINTER(SFILE_FIND_DATA), c_char_p]
|
||||
_lib.SFileFindFirstFile.restype = HANDLE
|
||||
|
||||
_lib.SFileFindNextFile.argtypes = [HANDLE, POINTER(SFILE_FIND_DATA)]
|
||||
_lib.SFileFindNextFile.restype = c_bool
|
||||
|
||||
_lib.SFileFindClose.argtypes = [HANDLE]
|
||||
_lib.SFileFindClose.restype = c_bool
|
||||
|
||||
|
||||
class StormLibError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MPQArchive:
|
||||
"""Context manager for MPQ archive operations."""
|
||||
|
||||
def __init__(self, path, mode='r', max_files=0):
|
||||
self.path = Path(path)
|
||||
self.mode = mode
|
||||
self.handle = HANDLE()
|
||||
self._closed = False
|
||||
|
||||
path_bytes = str(self.path).encode('utf-8')
|
||||
|
||||
if mode == 'r':
|
||||
if not _lib.SFileOpenArchive(path_bytes, 0, MPQ_OPEN_READ_ONLY, byref(self.handle)):
|
||||
raise StormLibError(f"Failed to open archive: {path}")
|
||||
elif mode == 'w':
|
||||
if max_files == 0:
|
||||
max_files = 4096
|
||||
flags = MPQ_CREATE_LISTFILE | MPQ_CREATE_ATTRIBUTES | MPQ_CREATE_ARCHIVE_V1
|
||||
if not _lib.SFileCreateArchive(path_bytes, flags, max_files, byref(self.handle)):
|
||||
raise StormLibError(f"Failed to create archive: {path}")
|
||||
elif mode == 'a':
|
||||
if not _lib.SFileOpenArchive(path_bytes, 0, 0, byref(self.handle)):
|
||||
raise StormLibError(f"Failed to open archive for writing: {path}")
|
||||
else:
|
||||
raise ValueError(f"Invalid mode: {mode}")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
if not self._closed and self.handle:
|
||||
_lib.SFileFlushArchive(self.handle)
|
||||
_lib.SFileCloseArchive(self.handle)
|
||||
self._closed = True
|
||||
|
||||
def add_file(self, source_path, archive_name,
|
||||
compression=MPQ_COMPRESSION_ZLIB,
|
||||
flags=MPQ_FILE_COMPRESS | MPQ_FILE_REPLACEEXISTING):
|
||||
"""Add a file from disk to the archive."""
|
||||
if self.mode == 'r':
|
||||
raise StormLibError("Archive opened in read-only mode")
|
||||
source_bytes = str(source_path).encode('utf-8')
|
||||
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
|
||||
if not _lib.SFileAddFileEx(self.handle, source_bytes, archive_bytes,
|
||||
flags, compression, compression):
|
||||
raise StormLibError(f"Failed to add file: {source_path} -> {archive_name}")
|
||||
|
||||
def add_data(self, data, archive_name,
|
||||
compression=MPQ_COMPRESSION_ZLIB,
|
||||
flags=MPQ_FILE_COMPRESS | MPQ_FILE_REPLACEEXISTING):
|
||||
"""Add raw bytes as a file in the archive."""
|
||||
if self.mode == 'r':
|
||||
raise StormLibError("Archive opened in read-only mode")
|
||||
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
|
||||
file_handle = HANDLE()
|
||||
if not _lib.SFileCreateFile(self.handle, archive_bytes, 0, len(data), 0,
|
||||
flags, byref(file_handle)):
|
||||
raise StormLibError(f"Failed to create file in archive: {archive_name}")
|
||||
try:
|
||||
if not _lib.SFileWriteFile(file_handle, data, len(data), compression):
|
||||
raise StormLibError(f"Failed to write file data: {archive_name}")
|
||||
if not _lib.SFileFinishFile(file_handle):
|
||||
raise StormLibError(f"Failed to finish file: {archive_name}")
|
||||
except:
|
||||
_lib.SFileCloseFile(file_handle)
|
||||
raise
|
||||
|
||||
def has_file(self, archive_name):
|
||||
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
|
||||
return _lib.SFileHasFile(self.handle, archive_bytes)
|
||||
|
||||
def read_file(self, archive_name):
|
||||
"""Read a file from the archive."""
|
||||
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
|
||||
file_handle = HANDLE()
|
||||
if not _lib.SFileOpenFileEx(self.handle, archive_bytes, SFILE_OPEN_FROM_MPQ,
|
||||
byref(file_handle)):
|
||||
raise StormLibError(f"Failed to open file: {archive_name}")
|
||||
try:
|
||||
high_size = DWORD()
|
||||
size = _lib.SFileGetFileSize(file_handle, byref(high_size))
|
||||
if size == 0xFFFFFFFF:
|
||||
raise StormLibError(f"Failed to get file size: {archive_name}")
|
||||
buffer = ctypes.create_string_buffer(size)
|
||||
read_size = DWORD()
|
||||
if not _lib.SFileReadFile(file_handle, buffer, size, byref(read_size), None):
|
||||
raise StormLibError(f"Failed to read file: {archive_name}")
|
||||
return buffer.raw[:read_size.value]
|
||||
finally:
|
||||
_lib.SFileCloseFile(file_handle)
|
||||
|
||||
def remove_file(self, archive_name):
|
||||
if self.mode == 'r':
|
||||
raise StormLibError("Archive opened in read-only mode")
|
||||
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
|
||||
if not _lib.SFileRemoveFile(self.handle, archive_bytes, 0):
|
||||
raise StormLibError(f"Failed to remove file: {archive_name}")
|
||||
|
||||
def compact(self):
|
||||
if self.mode == 'r':
|
||||
raise StormLibError("Archive opened in read-only mode")
|
||||
if not _lib.SFileCompactArchive(self.handle, None, False):
|
||||
raise StormLibError("Failed to compact archive")
|
||||
|
||||
def list_files(self, pattern="*"):
|
||||
"""List all files in the archive matching a pattern."""
|
||||
files = []
|
||||
find_data = SFILE_FIND_DATA()
|
||||
find_handle = _lib.SFileFindFirstFile(self.handle, pattern.encode('utf-8'),
|
||||
byref(find_data), None)
|
||||
if not find_handle:
|
||||
return files
|
||||
try:
|
||||
while True:
|
||||
filename = find_data.cFileName.decode('utf-8', errors='replace')
|
||||
if filename and not filename.startswith('('):
|
||||
files.append(filename)
|
||||
if not _lib.SFileFindNextFile(find_handle, byref(find_data)):
|
||||
break
|
||||
finally:
|
||||
_lib.SFileFindClose(find_handle)
|
||||
return files
|
||||
Reference in New Issue
Block a user