No DLL. No code of ours running inside the game. No hooks into the game process.
Verified, not claimed: Diablo III loads 120 modules with this running — the same count as before it existed. The only non-Windows, non-game modules present are Defender's AMSI provider and YoloMouse, both of which were already there.
What it uses, and nothing more:
OpenProcessa handle with read/write access ReadProcessMemorywalks a pointer chain from outside WriteProcessMemorywrites 4 bytes — one float — nothing else PostMessageone synthetic Zpress while locating the fieldExplicitly not used:
CreateRemoteThread,LoadLibrary, remoteSetWindowsHookEx, thread hijacking, IAT/inline patching, or any modification of the game's code.The one hook that exists is for YOUR HOTKEYS ONLY, and it is not in the game.
WH_KEYBOARD_LLis the single hook type Windows does not inject — it is called back inside ZoomHUD's own process, unlikeWH_KEYBOARDorWH_GETMESSAGEwhich map a DLL into every process. It exists solely so a bare-can be a hotkey without being swallowed system-wide. It never touches Diablo III.Practical consequence: our code cannot crash your game, because none of it runs there.
Zooms the Diablo III camera further out than the game allows, via hotkeys.
Defaults — single keys, no modifier needed:
- zoom further out
+ zoom back in
NumPad 0 reset to default
Ctrl+Alt+R reload (re-find the zoom field)
Ctrl+Alt+End quit
There is also a reload button on the window. Use it after a game reload or character swap if the zoom stops responding: the tool re-finds the field automatically when the value stops reading plausibly, but the old address can sometimes still hold a believable number, in which case that check does not trip. Reload forces it. No restart, no recalibration.
Run ZoomHUD.bat (or ZoomHUD.exe) any time — before or after the game. It
waits for Diablo III, finds the zoom field on its own, and re-finds it whenever
the game rebuilds it.
Edit ZoomHUD.ini, created next to the exe on first run:
zoom_out = -
zoom_in = +
zoom_reset = num0
reload = ctrl+alt+R
quit = ctrl+alt+End
step = 0.5 # change per keypress
min = -6.0 # clamp; past about -3 the camera clips terrain
max = 0.5
swallow = 1 # 1 = the game never sees the keypress
only_in_game = 1 # 1 = only react while Diablo III is focusedNames: - + [ ] , . num- num+ num0 F1–F12 PgUp PgDn
Home End Insert Delete A–Z D0–D9, optionally prefixed with
ctrl+, alt+, shift+.
Single keys are safe here. Input is read through a low-level keyboard hook
rather than RegisterHotKey, which matters: a bare hotkey registered the normal
way is swallowed system-wide, so binding - would stop you typing a minus
sign in any other application. The hook only reacts while Diablo III is the
focused window (only_in_game = 1) and passes every other keystroke straight
through. swallow = 1 additionally hides the key from the game itself, so a
binding that clashes with a game control causes no double action.
Rebuild from source with powershell -ExecutionPolicy Bypass -File Build.ps1.
Requires only the .NET Framework, which is already on every Windows machine.
A single 32-bit float inside a per-character object on the heap.
| value | meaning |
|---|---|
0.0 |
default view (what you see normally) |
~0.5 |
the zoomed-in view the Z key toggles to |
| negative | further out than the game can reach |
Two consequences follow, and both are easy to get backwards:
0.5is not "half zoomed out." Values between0.0and0.5sit between the two views the game already gives you. Anything in that range moves the camera in, not out.- Going further out means going below
0.0. The parameter extrapolates cleanly past its normal range:-1.0puts the camera roughly twice as far beyond default as0.5puts it in the other direction. Useful values run from about-0.5to-3.0; beyond that the camera can clip through terrain.
Z does not scale a number — it switches the camera between two states, and
this float is the parameter that selects between them. Writing a value the
keybind can never produce is what puts the camera somewhere the game has no
control for.
The value is not saved anywhere. It is not in D3Prefs.txt (which has no
zoom key at all) and does not survive a client restart. It is pure runtime
state, which is why a resident tool is needed rather than a config edit.
The field cannot be found by scanning for values, and understanding why is what makes the working method reliable.
Scanning for a value that changes when you press Z yields millions of
hits, and narrowing it produces the wrong survivors. A running game mutates
enormous amounts of memory every frame, and among the survivors are matrix
elements, animation timers, and UI fade alphas. Several of those are also
floats that sit at exactly 0.0 and 1.0, alternate in lockstep with any UI
transition, and are indistinguishable from the zoom by value alone.
Two specific traps worth naming, because both look like success:
- Identity-matrix elements. A 4×4 transform ends
0,0,0,1. That trailing1matches a "0 or 1 float" filter perfectly and rides every toggle, because the matrix is cleared and rebuilt during transitions. It is not a zoom value and writing to it does nothing. - UI alphas. Interface fades are normalised
0.0 → 1.0floats that animate on exactly the same schedule as the camera transition.
Anything found by value also goes stale. The object lives on a heap the game recycles. Addresses die on client restart, on character swap, on zone change — and in practice within minutes of ordinary play. A remembered address is worthless; it must be re-derived every time.
Two filters, applied in order. Neither depends on any address.
The field is reached by a fixed chain of pointers rooted in the game module's static writable data:
objMgr = *(an 8-byte-aligned pointer in the module's writable .data)
unk = *(objMgr + BASE_OFF)
node = *(unk + 0x48)
zoom = (float)(node + 0x18)
Walking that shape is a far stronger filter than any value test, and the search
space is tiny — the module's writable .data is about 4.6 MB, versus the
2.2 GB of heap a value scan has to cover. The whole walk takes ~1.5 seconds.
BASE_OFF is the only part that moves between game versions (the containing
structure grows as fields are added), so it is swept over 0x800…0xE00 in
8-byte steps rather than hardcoded. The +0x48 and +0x18 hops have been
stable and are used directly.
The structural filter still returns thousands of chains. The one that matters is identified by a property nothing else has:
Press
Zfive times, reading every candidate each time. Keep only addresses that return to the exact same value every time a zoom state repeats, and hold a different exact value in the other state.
This is decisive because it tests reproducibility rather than change. Heap churn and animation can change on cue by coincidence; they cannot reproduce a specific value on command, repeatedly, in alternation. A free-running counter fails immediately — it never returns to a previous value.
The surviving pair should read 0.0 in one state and a small positive value in
the other. That is the zoom.
When even that is ambiguous (or after a patch, when the offsets above may be wrong), there is a filter that depends on nothing version-specific:
Write an extrapolated value, screenshot the game window, and compare against the frame-to-frame noise measured while idle. Only the real zoom changes essentially every pixel.
Ambient animation — monsters, spell effects, torchlight — moves a few percent of the frame. A camera move changes all of it. Measuring the idle noise floor first and requiring ~2.5× that separates them cleanly. In practice the zoom scores around 48 against a noise floor near 8.
This is what makes the tool patch-proof in principle: the test is literally "did the picture change", which no game update can invalidate.
When a patch breaks it, the failure will be BASE_OFF moving. In order:
- Widen the sweep.
0x600…0x1200instead of0x800…0xE00. Cheap; usually enough, since the structure only grows. - Sweep the other hops. If that fails,
+0x48and+0x18have moved too. Sweep+0x00…0x90and+0x00…0x40respectively. Slower, still bounded. - Drop the chain entirely. Use the value-alternation filter across all of memory to get candidates, then confirm each with the screen test. Slower (minutes rather than seconds) but assumes nothing — no offsets, no addresses, no expected values. This is the fallback that always works.
- Read the new offsets back out. Once step 3 finds the address, walk
pointers backwards to recover the new
BASE_OFFand restore the fast path.
The two assumptions that would genuinely break the design: the zoom ceasing to be a 4-byte float, or ceasing to have two discrete states. Neither has any reason to change.
Everything here is derived at runtime — nothing below is compiled in as an absolute address.
module Diablo III64.exe base resolved at runtime (ASLR)
BASE_OFF swept 0x800..0xE00 the offset that moves between patches
hop 1 +0x48 stable so far
hop 2 +0x18 stable so far
default state 0.0
toggled state ~0.5
useful range -0.5 .. -3.0 beyond that the camera clips terrain
verified against 2.8.0.99920
Nothing is injected into Diablo III. The tool reads and writes the game's
memory from outside via ReadProcessMemory / WriteProcessMemory. No DLL is
loaded into the game, and its module list is unchanged while this runs. Beyond
being less conspicuous, it means a bug here crashes this tool rather than the
game.
The address is never remembered. It is re-derived on every launch and again whenever the current one stops reading a plausible value. That is not a fallback path — it is the normal mode of operation, because the object is rebuilt constantly.
The value usually sticks once written. On this build the game does not continuously reset the field, so a single write holds. The tool re-checks every 2 seconds anyway and rewrites if something has reset it, which also covers the case where the object was rebuilt underneath.
Locating presses Z a few times. That is the verification step toggling the
zoom to observe both states. It restores the level afterwards. If the camera
flicks in and out for a few seconds on startup, that is the tool identifying
itself, not a fault.
ZoomHUD\
ZoomHUD.exe the app
ZoomHUD.bat launcher
ZoomHUD.ini your key bindings (created on first run, fully commented)
README.md this file
Build.ps1 rebuilds the exe from source
Clean.ps1 removes run artefacts; -Scrub also redacts identifiers
src\
ZoomHUD.cs complete source, ~700 lines, single file
tools\ PowerShell versions and the research instruments
Zoom.ps1 locate, apply, hold
Zoom-Auto.ps1 version-independent fallback: no hardcoded offsets
Zoom-Daemon.ps1 background watcher, survives restarts
Scan-Chain.ps1 the pointer-chain walk on its own
Screen-Probe.ps1 the screenshot confirmation test
Scan-Value.ps1 general differential memory scanner
Input.ps1 sends keys to the game window
Freeze-Many.ps1 hold a set of addresses at a value
Find-Camera.ps1 locate camera structs by signature
Context.ps1 classify an address by what surrounds it
Find-Strings.ps1 search the module for strings
Scan-Rdata.ps1 search read-only constants
Peek.ps1 read/write/watch fixed addresses
Watch-Attach.ps1 which processes hold handles to the game
Trace-Write.ps1 hardware write-breakpoint via cdb
docs\
AUDIT.md full investigation record
ARCHITECTURE.md the two builds compared: stacks, footprint, hook latency
APPROACH.md hardcoded vs derived: why nothing here stores an address
GR-TOOLS.md the TurboHUD automation plugins: how they act, what breaks them
Everything in tools\ must stay in the same directory — the scripts invoke
each other by relative path.
cd tools
.\Zoom-Auto.ps1 # assumes nothing; finds it from scratchThat path hardcodes no offsets and no values — it learns the states by observation and confirms by screenshot. Slower (minutes rather than seconds), but it is the one that keeps working when the fast path breaks. Once it reports an address, the new offsets can be read back out to repair the fast path.
docs\AUDIT.md records ten phases of investigation, most of it negative
results — the approaches that looked right and were not. Worth reading before
changing the method, since it is built specifically to avoid repeating them.