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:
MarcelineVQ
2026-07-27 21:47:55 -07:00
parent f500147fc7
commit b12fc820ef
42 changed files with 5563 additions and 727 deletions
+87
View File
@@ -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.
+1 -1
View File
@@ -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();
+340
View File
@@ -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();
}