Diablo 2: Resurrected | Building a Memory Trainer on Linux from Scratch (Part 2)

Part 2 of 3 — Part 1 covered pointer chains and the building blocks. This is where the plan meets reality.
Part 1 ended with a fairly clean setup. That is, find the process, find the module base, walk a pointer chain, read or write the value. On Windows, that’s a well-established approach. Cheat Engine has built-in pointer scanning, there are community-maintained offset tables, and the technique has been documented for decades.
Then I tried to run it under Wine and my perfect setup blew up immediately.
Pointer Chains Don’t Work Under Wine#
The pointer chain approach from Part 1 relies on one key assumption: you can find the game’s executable in memory and use it as a fixed starting point. On Windows, this is easy enough; D2R.exe shows up by filename in the process memory map. Unfortunately, on Linux under Wine/Proton (GE-Proton10-34 via Lutris), that assumption was proven to be incorrect.
The PE Binary Is Invisible#
The first thing the trainer does is look for D2R.exe in /proc/pid/maps to find the module base. On Windows, you’d see something like:
7f0010000000-7f0012800000 r-xp ... /path/to/D2R.exe
But what I saw when D2R.exe was running in Wine was this:
4610000-6e98000 rw-s ... /memfd:wine-mapping (deleted)
No D2R.exe anywhere. This is because Wine doesn’t map PE binaries the way a native Windows loader does. It copies them into anonymous memory regions labeled memfd:wine-mapping. There’s no filename to search for, so the find_module_base() function from Part 1 returns nothing.
To work around this, you’d have to identify and scan every memfd:wine-mapping region looking for a valid PE header; checking for the “MZ” magic bytes at the start, then following the DOS header to verify the “PE\0\0” signature:
for (auto& reg : candidates) {
// Every Windows executable starts with the bytes "MZ" (the DOS header,
// dating back to the 1980s). If a region doesn't start with MZ, it's
// not a PE binary.
uint8_t mz[2] = {0};
if (!peek(pid, reg.start, mz, 2)) continue;
if (mz[0] != 'M' || mz[1] != 'Z') continue;
// The DOS header at offset 0x3C contains a pointer to the PE header.
// We read that pointer, then check if the PE signature ("PE\0\0") is
// actually there. This two-step check confirms it's a real PE image
// and not just random bytes that happen to start with "MZ".
uint32_t pe_off = 0;
peek(pid, reg.start + 0x3C, &pe_off, 4);
uint32_t pe_sig = 0;
peek(pid, reg.start + pe_off, &pe_sig, 4);
if (pe_sig == 0x00004550) // "PE\0\0" as a 32-bit integer
return reg.start;
}
This actually works. You can find the PE image this way. But as it turns out, finding it is only the first problem.
The 0x140000000 Lie#
While scanning /proc/pid/maps, I noticed the address 0x140000000 in the map. On Windows, that’s the standard PE base address for 64-bit executables and exactly what you’d expect for D2R.exe. For a brief moment it looked like the answer.
Then I looked at the permissions column: ---p. No read, no write, no execute. It’s a reserved placeholder that Wine sets up to maintain compatibility with Windows address space expectations, but no actual code or data lives there. The real loaded image was eventually found by scanning memfd:wine-mapping regions for valid MZ/PE headers with the code above. Our target process finally turned up at 0x4610000, a completely different address that changes at every launch.
Deep Heap Indirection#
Even after finding the actual PE base, the pointer chain approach still wasn’t working. I tried walking community-sourced offset chains from the PE base and they either hit null pointers or landed on garbage. To understand why, I went back to scanmem (the tool from Part 1) and looked at where it actually found the stat values. They were found sitting in anonymous heap regions, far from the PE image in the address space.
The PlayerUnit struct (the data structure that holds all the player’s stats) lives deep in Wine’s heap, behind multiple layers of indirection. The path looks something like: PE → some internal manager → an allocator → a heap chunk → PlayerUnit. On native Windows with Cheat Engine, you can trace this with automated pointer scans that brute-force every possible path. Under Wine, the intermediate heap structures are laid out differently enough from native Windows that community-sourced offsets don’t apply, and manually tracing through all that indirection is impractical.
All of this left me with three problems, all pointing the same direction:
- The PE is basically invisible — Wine maps it as anonymous
memfd:wine-mappingregions with no filename to search for - The familiar base address is a lie —
0x140000000exists but is an empty placeholder with no permissions - The data we actually want is unreachable from the PE — stat values live deep in heap allocations that don’t connect back to the binary through any reliable pointer chain
Pointer chains are a Windows tool for a Windows problem. Under Wine, we needed a fundamentally different approach.
Pattern Scanning#
So if we can’t navigate from a known base address through a chain of pointers, what’s left? The answer is to flip the problem around entirely. Instead of figuring out where the data is by tracing from the executable, we search for the data itself. To do this, we walk through the entire process memory looking for a known pattern (the stat array) and when we find it, we know where everything is.
From what I can tell, this is actually how most modern trainers work. Tools like CheatHappens and WeMod don’t ship with fragile pointer chains that break every patch. They use pattern scanning (sometimes called “signature scanning”) to locate data structures by their content. The approach has a nice property in that it doesn’t care where the game allocates its data. It can be in the PE, on the heap, in a Wine memfd region, or another location because you’re searching by what the data looks like, not where it lives.
So then the question becomes: what does D2R’s stat data look like in memory?
How D2R Stores Stats#
Very fortunately, I didn’t have to reverse engineer this from scratch. The D2R modding community has already done that work. Projects like blacha/diablo2 and others on GitHub have documented the internal data structures. By referencing these sources, I was able to find that D2R keeps player stats as a flat array of 8-byte entries, where each entry contains a stat code (identifying what the stat is) and its value:
┌──────────┬──────────┬───────────────┐
│ u16 unk │ u16 code │ u32 value │
├──────────┼──────────┼───────────────┤
│ ???? │ 0x0000 │ 48 │ ← Strength = 48
│ ???? │ 0x0001 │ 10 │ ← Energy = 10
│ ???? │ 0x0002 │ 20 │ ← Dexterity = 20
│ ???? │ 0x0003 │ 27 │ ← Vitality = 27
│ ???? │ 0x0006 │ 0x00731200 │ ← HP (shifted by 8)
│ ... │ ... │ ... │
│ ???? │ 0x000E │ 999999 │ ← Gold
└──────────┴──────────┴───────────────┘
Each entry is 8 bytes: 2 bytes of unknown/padding, 2 bytes for the stat code, and 4 bytes for the value. The stat codes are sequential and consistent:
| Code | Stat | Encoding |
|---|---|---|
| 0 | Strength | Direct |
| 1 | Energy | Direct |
| 2 | Dexterity | Direct |
| 3 | Vitality | Direct |
| 4 | Stat Points | Direct |
| 5 | Skill Points | Direct |
| 6 | Current HP | display_value << 8 |
| 7 | Max HP | display_value << 8 |
| 8 | Current Mana | display_value << 8 |
| 9 | Max Mana | display_value << 8 |
| 10 | Current Stamina | display_value << 8 |
| 11 | Max Stamina | display_value << 8 |
| 12 | Level | Direct |
| 13 | Experience | Direct |
| 14 | Gold (inventory) | Direct |
| 15 | Gold (stash) | Direct |
As you can see in the table above, most stats store their value directly. That is, a Strength value of 48 is actually stored as 48. But HP, Mana, and Stamina use something different: the value is the display number shifted left by 8 bits.
Bit shifting just means moving all the bits in a number to the left or right by some number of positions. Shifting left by 8 is the same as multiplying by 256 (2⁸). So if your HP is 100, the game stores 100 × 256 = 25,600. To get the display value back from the raw memory, you shift right by 8 (divide by 256): 25,600 ÷ 256 = 100.
The lower 8 bits act as fractional precision. The game avoids floating point math by scaling values up internally. For example, 1.5 damage might be stored as 384 (i.e., 1.5x256). This caught me off guard initially; I was searching for my HP value in memory and couldn’t find it because I was looking for the wrong number.
The Scanner#
With the stat layout known, we need to identify a good search pattern. Something that would be unique enough to identify the stat array without false positives across the hundreds of megabytes of writable memory in the process.
From the table above, we know that the first four stats are always Strength, Energy, Dexterity, and Vitality, in that order, with codes 0, 1, 2, 3. Every D2R character has these, they always appear at the start of the array, and the combination of four consecutive 8-byte entries with those exact codes is unique enough that we never hit a false match. Just to be sure, we also sanity-check the values (must be between 1 and 9999) to filter out coincidental byte patterns. It should be pretty obvious that no legitimate character has 0 or 10,000 Strength:
// Each stat entry is exactly 8 bytes: 2 unknown, 2 stat code, 4 value.
// We define a struct so we can work with named fields instead of raw offsets.
struct RawStatEntry {
uint16_t unk;
uint16_t code;
uint32_t value;
};
// Walk through the memory buffer 8 bytes at a time (one stat entry per step).
// We need 4 consecutive entries, so we stop 32 bytes before the end.
for (size_t off = 0; off + 32 <= buf_size; off += 8) {
RawStatEntry e0, e1, e2, e3;
// memcpy instead of casting — avoids alignment issues on some platforms
memcpy(&e0, buf + off, 8);
memcpy(&e1, buf + off + 8, 8);
memcpy(&e2, buf + off + 16, 8);
memcpy(&e3, buf + off + 24, 8);
// Pattern match: codes must be exactly 0,1,2,3 in order (Str, Ene, Dex, Vit).
// The value sanity check (1-9999) filters out coincidental byte matches —
// no legit character has 0 or 10,000 Strength.
if (e0.code == 0 && e1.code == 1 &&
e2.code == 2 && e3.code == 3 &&
e0.value > 0 && e0.value < 10000 &&
e1.value > 0 && e1.value < 10000 &&
e2.value > 0 && e2.value < 10000 &&
e3.value > 0 && e3.value < 10000) {
// Found it — stat array starts here
}
}
The pattern matching handles what to look for. But we also need to be careful about where we look, too.
Which Memory Regions to Scan#
As I found, you can’t just blindly read every region listed in /proc/pid/maps. Some regions are mapped from files on disk (shared libraries, font files, locale data), some are reserved ranges with no actual content, and some belong to Wine’s internal bookkeeping. Reading the wrong region at best returns garbage and at worst can crash the game by triggering a page fault in protected memory (ask me how I know!).
The stat array is player data that lives on the heap, so we know a few things about where to start looking:
- It has to be in a writable region (
rw-permissions) — the game writes to it constantly - It’s either anonymous (no file backing) or in a
memfd:wine-mappingregion (Wine’s equivalent) - It’s going to be in a region between roughly 128 bytes and 200 MB — smaller than that is too small to hold the array, larger is probably a graphics buffer
File-backed regions (.dll, .nls, .idx), anything without write permission, and tiny/enormous regions all get skipped. This type of filtering ended up being the difference between a scanner that worked and one that often crashed the game.
Why Pattern Scanning Beats Pointer Chains#
For our use case, running under Wine where the PE binary is invisible and heap layout is different, pattern scanning ended up solving every problem pointer chains created:
| Pointer Chains | Pattern Scanning | |
|---|---|---|
| Survives game patches | ❌ Offsets change | ✅ Pattern is stable |
| Works under Wine/Proton | ❌ PE invisible, heap opaque | ✅ Scans raw memory |
| Needs config/offsets | ❌ Yes | ✅ Self-contained |
| Setup required | ❌ Manual scanmem tracing | ✅ None |
The tradeoff here is speed. Scanning hundreds of megabytes is far slower than walking a pointer chain. But in practice, the scan completes in under a second, and we only need to do it once per session (with occasional re-scans when the array moves, more on that below).
Memory Access on Linux#
Part 1 covered the process_vm_readv and process_vm_writev syscalls for reading and writing another process’s memory. The pattern scanner uses the same functions — the only difference for this iteration is that we’re reading large chunks (entire memory regions at a time) instead of individual 4-byte values.
One thing worth noting that I discovered during development: the other common approach on Linux (opening /proc/pid/mem as a file, seeking to an address, and reading/writing) partially works. Reads succeed, but writes silently fail. They return success, but nothing changes in the target process’s memory. I burned some time debugging that before switching to process_vm_writev, which actually works.
Freezing Stats#
At this point the trainer can find stats and write to them. You can set gold to 999999 and it sticks. Nothing recalculates gold, so a single write is permanent (at least until you save and reload, but we’ll get to that).
HP and mana are a different story though. The game is constantly updating them due to damage, regeneration, buff ticks, poison effects, etc. so a single write gets overwritten almost immediately. The solution is a background thread that continuously re-applies frozen values, overwriting the game’s updates faster than the player can notice them.
The Catch: Stats Move#
Unfortunately, there was a complication I didn’t anticipate. The stat array isn’t actually pinned to a fixed address; it moves! Whenever you level up, change zones, or load a save, D2R reallocates the data structure and the old address goes stale. I found this out the hard way. The trainer would work perfectly, then the player would take a waypoint to a new area and suddenly all the frozen values stopped updating. The address we were writing to was now some random piece of deallocated memory.
The fix: the freeze thread re-runs the pattern scanner on every tick to track the array’s current location. This sounds expensive, but in practice the scan is fast enough (we know which regions to search) that it doesn’t noticeably impact performance:
// Lambda captures the shared state by reference. The thread runs until
// the `running` flag goes false (set by the main thread on quit).
std::thread freeze_thread([&]() {
while (running) {
{
// Lock the mutex before touching the frozen list or stats.
// The main thread also modifies these (when you type freeze/
// unfreeze commands), so without the lock you get races.
std::lock_guard<std::mutex> lock(freeze_mutex);
if (!frozen.empty()) {
// Re-scan = re-run the pattern scanner to find current
// stat array location. This is the key to surviving
// zone transitions and level-ups.
resolve_stats(pid, stats);
for (auto& f : frozen) {
if (f.index < stats.size() && stats[f.index].addr != 0) {
write_memory(pid, stats[f.index].addr,
&f.value, sizeof(f.value));
}
}
}
}
// 100ms = 10 writes per second. Fast enough that you don't notice
// a gap in frozen values, slow enough to not waste CPU.
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
At 100ms intervals the re-scan is fast enough that you don’t notice the gap and HP stays pinned through zone transitions. (Spoiler from Part 3: 100ms turns out to be not fast enough to survive sustained combat damage. That’s a different problem.)
Online vs. Offline#
Before going further, a quick note about where this works and where it doesn’t.
D2R online is server-authoritative. This means that the server tracks the real values for HP, gold, transactions; everything that matters. Our memory edits only change what the local client displays. In practice:
- HP freeze: Health bar looks full, but the server knows your real HP. You die “out of nowhere” when server-side HP hits zero.
- Gold edit: Display shows 999999, server rejects purchases because it knows you’re broke.
- Stat edits: Purely cosmetic. Damage calculations happen server-side.
Offline characters are fully client-side; the game trusts whatever is in memory and on disk. All edits work as expected. Thus, everything in this series targets offline play.
Save File Editing#
At this point the trainer now handles live memory — finding stats, reading them, writing them, freezing them. But there’s a problem: memory edits are ephemeral. Set gold to 999999 in memory, save the game, reload, and gold goes back to whatever the save file says. The save file is the source of truth, and our memory edits don’t touch it.
For permanent changes, we need to edit the .d2s save file directly. I also expected this to be pretty straightforward, too; open the file, find the gold value at some byte offset, write a new number. Instead, I spent more time on save file parsing than any other part of the trainer, because Blizzard decided to store stats as a variable-length bitstream instead of at fixed byte offsets.
D2S Stat Section Format#
The .d2s format has been documented by the modding community (the krisives/d2s-format and WalterCouto/D2CE repos on GitHub were invaluable here). Stats are NOT at fixed byte offsets. Instead, they’re packed as a bitfield-encoded section where each stat is encoded as a 9-bit ID followed by a variable-length value.
If you haven’t encountered bitstreams before, here’s the idea. Normally, file formats work in whole bytes — a 4-byte integer here, an 8-byte string there, everything aligned to neat byte boundaries. A bitstream throws that out. Instead of thinking in bytes (8 bits), you think in individual bits. Each piece of data can be any number of bits long — 9 bits for a stat ID, 10 bits for Strength, 25 bits for Gold — and they’re packed end-to-end with no padding between them. A value that starts at bit 19 and is 25 bits long will span across parts of 4 different bytes. Nothing lines up to byte boundaries, which makes reading and writing significantly more annoying than a normal file format.
“Variable-length” means each stat consumes a different number of bits depending on what it is. You can’t jump to “the gold value” without reading every stat before it first, because you don’t know where gold starts until you’ve counted all the preceding bits. It’s like a tape you have to play from the beginning — there’s no table of contents.
Based on information found on github about the .d2s file format, we know that the stats section starts with the marker bytes gf (0x67 0x66). After that marker, each stat is encoded as a 9-bit ID followed by a variable-length value like so:
┌─────────────┬──────────────────────┐
│ 9-bit ID │ N-bit value │
├─────────────┼──────────────────────┤
│ 000000000 │ 10 bits (Strength) │
│ 100000000 │ 10 bits (Energy) │
│ 010000000 │ 10 bits (Dexterity) │
│ ... │ ... │
│ 111111111 │ (terminator: 0x1FF) │
└─────────────┴──────────────────────┘
The bit widths vary by stat — this is why you can’t just seek to a fixed offset:
| Stat | Bits |
|---|---|
| Str / Energy / Dex / Vit / Stat Pts | 10 |
| Skill Points | 8 |
| HP / Max HP / Mana / Max Mana / Stamina / Max Stamina | 21 |
| Level | 7 |
| Experience | 32 |
| Gold / Gold Stash | 25 |
The section terminates when you hit a 9-bit ID of 0x1FF (all bits set).
Bit Ordering#
Here’s where it gets a bit tedious. There’s one more wrinkle to reading these bitfields: the bits within each byte are stored LSB-first — least significant bit first. If you’re not used to bit-level work, here’s what that means.
Normally when you write a number like 5 in binary, you write it as 101 — the biggest place value (4) is on the left, the smallest (1) is on the right. That’s “most significant bit first,” and it’s how humans tend to think about binary. LSB-first flips that: the smallest bit comes first. So 5 would be stored as 101 but read starting from the right side of each byte.
Why does this matter? Because our values span byte boundaries. A 25-bit gold value doesn’t fit neatly into 3 bytes (24 bits) or 4 bytes (32 bits). It starts partway through one byte and ends partway through another. To read it, you have to pull out individual bits from across those bytes and reassemble them in the right order.
The approach: walk through the bits one at a time, figure out which byte each bit lives in and which position within that byte, extract it, and place it into the result:
uint32_t read_bits(const uint8_t* data, size_t bit_offset, int count) {
uint32_t result = 0;
for (int i = 0; i < count; i++) {
// Divide by 8 to find which byte this bit lives in
size_t byte_idx = (bit_offset + i) / 8;
// Modulo 8 to find the position within that byte (0 = least significant)
int bit_idx = (bit_offset + i) % 8;
// Shift the byte right to put our target bit in the lowest position,
// then mask with & 1 to isolate just that single bit
int bit = (data[byte_idx] >> bit_idx) & 1;
// Place the extracted bit into the correct position in our result
result |= (bit << i);
}
return result;
}
To make this concrete: a 25-bit gold value of 999999 starts at, say, bit offset 47 in the stat section. That’s partway through byte 5 (bit 47 ÷ 8 = byte 5, bit position 7). The value spans from there across bytes 6, 7, 8, and into byte 9. The function walks all 25 bits, plucking each one from the right byte and position, and assembles the final number.
The write function is the mirror image; decompose your value into individual bits and poke each one into the right position in the file. It’s not complicated once you understand the pattern, but it’s the kind of thing where off-by-one errors are very easy to make and very annoying to debug.
The Checksum#
Unfortunately, you can’t just edit the save file and call it done, either. D2S files have a 32-bit checksum at byte offset 12. Edit anything — even one bit in the stat section — without recalculating it, and the game will silently reject the file and load the old save instead. No error message, no warning, nothing, it just doesn’t load your changes.
The good thing is that the checksum algorithm is documented in the same community format specs we used for the stat parsing. It’s fairly straightforward once you know it: zero out the checksum field, then walk every byte in the file doing a rotate-left-1-and-add:
void fix_checksum(std::vector<uint8_t>& data) {
// Step 1: Zero out the checksum field itself. The checksum is calculated
// over the entire file, but with its own field set to zero (otherwise
// you'd have a circular dependency — the checksum depends on itself).
memset(data.data() + 12, 0, 4);
// Step 2: Accumulate. The algorithm is "rotate left by 1 bit, then add
// the next byte." The rotate is the key — without it, the order of bytes
// wouldn't matter and you could rearrange the file without detection.
uint32_t sum = 0;
for (size_t i = 0; i < data.size(); i++) {
sum = ((sum << 1) | (sum >> 31)) + data[i];
}
// Step 3: Write the new checksum back to offset 12.
memcpy(data.data() + 12, &sum, 4);
}
This is a common pattern in game save formats, essentially a simple integrity check to catch file corruption (or edits); just enough to verify the file hasn’t been accidentally damaged. Or in our case, to verify we didn’t mess up our bit manipulation.
Finding the gf Marker#
So now we can parse the bitstream and fix the checksum, but there’s still the question of where the stat section actually begins in the file. The D2S format docs say it starts at byte 765. However, in practice, newer D2R versions have it at byte 833 or elsewhere. The offset drifts across patches as Blizzard adds or modifies header fields. This is because hardcoding an offset would break every time the game updates. Sound familiar? It’s the same problem as pointer chains. Same solution, too, as it turns out. Search for the known signature instead of assuming a fixed position:
// The "gf" marker (0x67 0x66) always precedes the stat bitstream.
// Start searching around byte 700 since it's always in that neighborhood.
for (size_t i = 700; i < data.size() - 1; i++) {
if (data[i] == 'g' && data[i + 1] == 'f') {
stats_offset = i;
break;
}
}
Value Limits#
Now that we can find the stats and parse the bitstream, there’s one more thing to be aware of before writing values back: the bit widths themselves cap what you can store. As found in the community format specs, Gold is a 25-bit unsigned integer: max 33,554,431 (2²⁵ - 1). HP and Mana are 21 bits, maxing at 2,097,151 raw — but remember the << 8 encoding from the memory section? The display max is actually 2,097,151 ÷ 256 = 8,191. Try to write a larger value and you’ll either overflow the bit field (corrupting the next stat in the stream) or the game will just ignore it.
Auto-Save Integration#
With all of these pieces in place — pattern scanning for memory, bitstream parsing for save files, and checksum repair, the last step is wiring them together so edits actually stick.
The trainer ties memory editing and save file editing together so you don’t have to think about persistence. When you set gold via the trainer (s15 999999), it:
- Writes the value to process memory (immediate in-game effect)
- Reads the
.d2sfile, walks the bitstream to the gold stat, overwrites the bits - Recalculates the checksum
- Writes the file back (persists through save/reload)
- Creates a
.bakbackup the first time (because corrupting a save file with a bitfield bug is still a real possibility, and losing a character to a trainer mistake would be adding insult to injury)
Save file location under Wine/Lutris:
~/Games/battlenet/drive_c/users/steamuser/Saved Games/Diablo II Resurrected/
As a side note, this path threw me off initially.. Here I was looking for saves inside the D2R install directory, however, under Wine, the game writes to the emulated Windows user profile, which ends up nested several layers deep inside your Lutris prefix.
Final Project Structure#
At the end of Part 2, here’s what our project now looks like. Note that each source file has a clear responsibility, whether that be process discovery, memory operations, or save file manipulation. This keeps the main file focused on the CLI interface and game logic:
d2r-trainer/
├── CMakeLists.txt
├── src/
│ ├── main.cpp # CLI, freeze thread, god mode, save integration
│ ├── process.h/cpp # PID finder, PE base scanner for Wine/Proton
│ ├── memory.h/cpp # read/write, pointer walker, pattern scanner
│ └── savefile.h/cpp # D2S bitfield parser, stat editor, checksum
└── build/
└── d2r-trainer # run with sudo
Building#
The build process is the same as Part 1; CMake and make:
cd d2r-trainer
mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make -j4
Dependencies: cmake, readline (for the interactive CLI), pthread (for freeze thread).
Running#
Same as before; root access is required to read and write another process’s memory:
# Start D2R through Lutris, load into a game with an offline character, then:
sudo ./d2r-trainer
The sudo is necessary for process_vm_readv/process_vm_writev — without root (or CAP_SYS_PTRACE), the kernel won’t let you touch another process’s memory.
Commands#
Part 2 adds save file commands alongside the memory editing from Part 1 as shown here:
s<N> <value> Set stat #N to value (e.g. s7 99999999 for HP)
f<N> <value> Freeze stat #N at value
u<N> Unfreeze stat #N
g Toggle god mode (freeze HP + Mana at max)
w Write all current stats to .d2s save file
r Refresh / re-read all values
R Full re-scan (find stat array again)
q Quit
Things That Will Bite You#
Before we wrap up, here are a few of the issues run into during development of the trainer. Some of these cost hours of debugging, so hopefully this saves you the trouble:
Wine/Proton#
memfd:wine-mappingis how Wine maps PE binaries. If you’re looking forD2R.exeby filename in/proc/pid/maps, you won’t find it.- Don’t read file-backed regions (
.dll,.nls, etc.); accessing some of them crashes the game. /proc/pid/memwrites silently fail. Useprocess_vm_writevinstead.- The
0x140000000address shows up in maps as a---pplaceholder. It’s not the loaded image.
D2R-Specific#
- Pressing G (graphics toggle) crashes the game under Wine. It may work sometimes, but it will just as often crash on my system.
- Changing resolution or window mode in settings also causes crashes under Wine.
- After memory edits or save file changes, the game would sometimes reset from fullscreen to a stuck windowed mode. The window was pinned to the upper-left corner with no way to move or resize it. I had to reset display settings from outside the game each time.
- Battle.net launcher closes each time D2R starts: fix this in Battle.net Settings → App → “Keep Battle.net open.”
- Stats with value 0 (like skills) may not appear in the memory array at all; they’re simply absent, not stored as zero.
- The stat array relocates on level-up and zone transitions. If your trainer stops working after a zone change, you need to re-scan.
General#
- Online games are server-authoritative. Memory edits are cosmetic only. Stick to offline characters.
- Memory edits don’t persist across saves — that’s what save file editing is for.
- Scanning the wrong memory regions crashes games. Filter to
rw-anonymous/memfd regions only. - The freeze thread and UI thread both touch the stat table. Without a mutex (a lock that ensures only one thread accesses shared data at a time), you’ll get race conditions that manifest as intermittent garbage values. These are the kinds of bugs that appear randomly and are maddening to reproduce.
What’s Next?#
At this point the trainer is working. We had stat reading, editing, freezing, and save file manipulation. For gold and most stats, it worked great. The one thing it couldn’t do reliably yet was to keep the player alive under sustained damage.
The freeze thread at 100ms was writing max HP back 10 times a second, and that sounds fast, but the character was still dying under sustained damage when surrounded by groups of enemies. The damage function was clearly running faster than our thread, likely every frame, and the death check was happening before our next write could land.
That’s the problem Part 3 sets out to solve, and the journey to figure it out covers some interesting territory: reverse engineering the damage function, code patching, anti-tamper detection, DLL injection, and eventually a solution that was simpler than all of them.
Resources#
- blacha/diablo2 — D2R struct layouts and stat codes
- WalterCouto/D2CE — D2S file format documentation
- krisives/d2s-format — D2S format spec with bitfield details
- hectorgimenez/d2go — Go library with D2R struct definitions
- scanmem/scanmem — still the starting point for discovering patterns