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

  • Part 1 of 3 — scanmem, pointer chains, and the building blocks of a trainer.

If you’ve ever tried to create your own game cheats, you probably started with memory scanning. By far, it’s the most direct path: find a value, narrow it down, change it, done.

I know how to use scanmem or CheatEngine to do that. But every time your game restarts, the addresses are gone and you have to do your scanning all over again. It works, but it’s tedious, and it’s clearly not how real trainer makers do it. Tools created from groups like CheatHappens and WeMod don’t ask you to scan for gold every launch. They somehow just know how to find it.

I wanted to understand how that works. How do you build a trainer that reliably locates game data across restarts, without manual scanning, on a game running under Wine on Linux? That question first led to pointer chains, then pattern scanning, then reverse engineering the damage function, then DLL injection, and finally to a solution that was arguably dumber than all of them.

This is the starting point: pointer chains. The classic Windows approach; basically walk from a fixed offset in the game executable through a series of pointers until you land on the live data. The offsets are baked into the compiled binary. They don’t change between runs, only between patches.

Here’s what a chain looks like:

  graph TD
    A["D2R.exe + 0x2028E60"] -->|"+0x10"| B["[addr]"]
    B -->|"+0x48"| C["[addr]"]
    C -->|"+0x00"| D["Gold Value"]
    style A fill:#8b0000,color:#fff
    style D fill:#228b22,color:#fff

The base address of D2R.exe changes each launch, but you can always find it. The offsets are constant. Your trainer reads the base, walks the chain, and arrives at the live value every time. Simple enough in theory.


Finding Pointer Chains with scanmem#

The process starts with the scanmem work you’d expect. Narrow down where your target value lives, then trace backwards to figure out what points to it.

Attach to D2R#

# fish shell
set PID (pgrep -f D2R.exe | head -n1)
sudo scanmem $PID
# bash/zsh
PID=$(pgrep -f D2R.exe | head -n1)
sudo scanmem $PID

Find the Value#

Finding the value you’re looking for uses the standard scanmem workflow. For example, to change gold in-game, you search for your existing amount, change it, then search the new value and repeat until you’ve got one or two addresses left:

> 12345          # search for current gold amount
> 12400          # change gold in-game, search new value
> 12400          # repeat until one or two addresses remain
> list            # show the found address(es)

Say you find gold sitting at address 0x7F12345678. That’s a heap address; it changes every time the game restarts. To build a repeatable trainer, you need to figure out how the game itself finds that address. Somewhere in the binary there’s a chain of pointers that starts at a fixed (static) location and follows a series of offsets to arrive at your gold value. The first step is figuring out where the chain starts.

Check Memory Regions#

Use lregions within scanmem to see all mapped memory regions — equivalent to what you’d find in /proc/<pid>/maps:

> lregions

Look for the region mapped from D2R.exe. The start address of that region is your module base; that is, the fixed reference point that survives restarts (on Windows, at least). Every pointer chain has to start from here or another loaded module.

Trace Pointers Backwards#

This is the core of pointer chain discovery. You have the address of your gold value, but that address is on the heap, allocated at runtime and different every launch. The game finds it by following a chain: a static pointer in the binary points to a struct, which has a field that points to another struct, which eventually points to the memory holding your gold. You need to reconstruct that chain in reverse.

The idea is that if something points to 0x7F12345678, then that 8-byte value is stored somewhere in memory. Find where, and you’ve found one link in the chain. Repeat until you land within the D2R.exe module’s address range. That’s the static starting point.

  1. Search the entire process memory for your gold address, stored as a little-endian byte sequence (x86 stores the least significant byte first):

    > reset
    > option scan_data_type bytearray
    > 78 56 34 12 7F 00 00 00
    
  2. Say you find it stored at 0x7F00AABB48. That means some data structure in memory has a pointer to your gold value at byte offset 0x48 from its base. Think of it like a struct; that is, a bundle of related data laid out contiguously in memory where the field at position 0x48 says “gold is over here.” Once found, record the offset: +0x48.

  3. Now repeat the process. Search for 0x7F00AABB00 (the approximate base of that struct) and then find what points to that address.

  4. Keep going until the address you find falls within the D2R.exe region (check against lregions). Subtract the module base to get your static offset — the one number that survives restarts.

Record the Chain#

After enough rounds of tracing, you end up with a complete chain from the static base to your value:

  graph TD
    A["D2R.exe\n+ 0x2028E60"] -->|"read pointer, add 0x10"| B["Struct A"]
    B -->|"read pointer, add 0x48"| C["Struct B"]
    C -->|"read pointer, add 0x00"| D["Gold Value"]
    style A fill:#8b0000,color:#fff
    style D fill:#228b22,color:#fff

The red node is static; it’s a fixed offset from the module base. The green node is the actual gold value in memory. Everything in between is heap-allocated and changes every launch, but the offsets between them don’t.

Validate#

In order to validate your offset once you’ve found it, restart the game entirely. Re-attach scanmem, find the new D2R.exe base from lregions, and manually walk the chain. If you arrive at the correct current gold value, the chain is valid.


Building the C++ Trainer#

At this point you could stop, fire up scanmem every time, re-derive the addresses, manually poke values. But the whole point of figuring out pointer chains here is to automate the process. The trainer needs to find D2R’s process, locate the module in memory, walk the chain, and read or write the value all without human intervention.

The project I created splits naturally along those responsibilities:

Project Structure#

d2r-trainer/
├── CMakeLists.txt
├── config.json          # pointer chains (editable without recompiling)
├── src/
│   ├── main.cpp         # CLI interface
│   ├── process.cpp      # find Wine PID, parse /proc/maps
│   ├── process.h
│   ├── memory.cpp       # read/write /proc/pid/mem, walk pointer chains
│   ├── memory.h
│   ├── config.cpp       # load pointer chains from JSON
│   └── config.h
└── README.md

Process discovery, memory access, and chain definitions each get their own files. The config itself is separate from the code because pointer chains break whenever the game patches. You want to be able to update offsets without recompiling.

Storing Chains in Config#

The chains live in JSON so they’re easy to edit:

{
  "process_name": "D2R.exe",
  "module": "D2R.exe",
  "cheats": [
    {
      "name": "Gold",
      "base_offset": "0x2028E60",
      "offsets": ["0x10", "0x48", "0x00"],
      "type": "int32"
    },
    {
      "name": "Health",
      "base_offset": "0x2028E60",
      "offsets": ["0x10", "0x88"],
      "type": "int32"
    }
  ]
}

Note: The offsets above are examples. You’ll need to discover the real ones for your game version using the scanmem process.

Step 1: Finding the Process#

Before you can read anything, you need the correct process ID (PID). On Windows this would be a CreateToolhelp32Snapshot call. On Linux, Wine processes are just regular processes so everything lives in /proc. Walk the directory, read each process’s command line, and match on D2R.exe:

#include <filesystem>
#include <fstream>
#include <string>

namespace fs = std::filesystem;

pid_t find_d2r_pid() {
    // /proc contains a numbered directory for every running process
    for (auto& entry : fs::directory_iterator("/proc")) {
        if (!entry.is_directory()) continue;

        // Filter to numeric directories (PIDs) — skip /proc/sys, /proc/net, etc.
        std::string pid_str = entry.path().filename().string();
        if (!std::all_of(pid_str.begin(), pid_str.end(), ::isdigit)) continue;

        // Each process has a "cmdline" file containing the command that launched it.
        // For Wine processes, this includes the Windows executable name.
        std::ifstream cmdline(entry.path() / "cmdline");
        std::string cmd;
        std::getline(cmdline, cmd, '\0');  // cmdline is null-delimited, not newline

        if (cmd.find("D2R.exe") != std::string::npos) {
            return std::stoi(pid_str);
        }
    }
    return -1;
}

This is the Linux equivalent of what Cheat Engine does automatically when you attach to a process. The nice thing about Wine is that you don’t need any special API to do this, it’s just /proc.

Step 2: Finding the Module Base#

With the PID in hand, you need to know where D2R.exe is loaded in memory. ASLR (Address Space Layout Randomization) randomizes this every launch, but the kernel exposes the full memory map in /proc/<pid>/maps. Each line in that file describes one memory region — its address range, permissions, and what file (if any) is mapped there. We look for the line mentioning our module and grab the start address:

#include <cstdint>
#include <fstream>
#include <sstream>
#include <string>

uintptr_t find_module_base(pid_t pid, const std::string& module_name) {
    // /proc/<pid>/maps lists every memory region in the process.
    // Each line looks like: "7f0010000000-7f0012800000 r-xp 00000000 ... D2R.exe"
    std::string maps_path = "/proc/" + std::to_string(pid) + "/maps";
    std::ifstream maps(maps_path);
    std::string line;

    while (std::getline(maps, line)) {
        if (line.find(module_name) != std::string::npos) {
            // The address range is the first field: "start-end"
            std::string addr_range = line.substr(0, line.find(' '));
            std::string start_addr = addr_range.substr(0, addr_range.find('-'));
            // Parse the hex string into an actual address
            return std::stoull(start_addr, nullptr, 16);
        }
    }
    return 0;
}

This gives you the anchor point; the base address that your pointer chain offsets are relative to. Without it, the chain has nowhere to start.

Step 3: Reading and Writing Process Memory#

Now you need to actually reach into D2R’s address space. Linux gives you process_vm_readv and process_vm_writev. These are kernel syscalls (a kind of gateway between user programs and the OS) for direct cross-process memory access. They’re fast (single syscall, no context switch into the target), and because they operate at the kernel level, Wine has absolutely no idea they’re happening:

#include <cstdint>
#include <cstring>
#include <sys/uio.h>

bool read_memory(pid_t pid, uintptr_t addr, void* buf, size_t size) {
    // iovec describes a chunk of memory: where it starts and how big it is.
    // "local" is our buffer (where to put the data we read).
    // "remote" is the target process address (where to read from).
    struct iovec local  = { buf, size };
    struct iovec remote = { (void*)addr, size };
    // process_vm_readv copies memory directly from the remote process into
    // our buffer. Returns the number of bytes read, or -1 on failure.
    return process_vm_readv(pid, &local, 1, &remote, 1, 0) == (ssize_t)size;
}

bool write_memory(pid_t pid, uintptr_t addr, const void* buf, size_t size) {
    // Same idea in reverse — copy from our buffer into the remote process.
    struct iovec local  = { (void*)buf, size };
    struct iovec remote = { (void*)addr, size };
    return process_vm_writev(pid, &local, 1, &remote, 1, 0) == (ssize_t)size;
}

The iovec struct might look unfamiliar, but it’s just a {pointer, length} pair that tells the kernel where to read from and where to write to. One describes your local buffer, the other describes the address in the target process. The kernel handles the rest.

Requires root or CAP_SYS_PTRACE the same privilege level as scanmem. This is the foundation everything else builds on.

Step 4: Walking the Chain#

With process discovery, module location, and memory access in place, the actual chain walk is straightforward. Start at the base, add the first offset, read the pointer stored there, add the next offset, read again and repeat until you’ve consumed all the offsets and arrived at the final address where the value lives:

uintptr_t walk_pointer_chain(pid_t pid, uintptr_t base,
                              const std::vector<uintptr_t>& offsets) {
    uintptr_t addr = base;

    // Walk every offset except the last one. Each intermediate offset
    // points to ANOTHER pointer that we need to dereference (read the
    // address stored at that location and jump to it).
    for (size_t i = 0; i < offsets.size() - 1; i++) {
        addr += offsets[i];

        // Read the pointer at this address — it tells us where to go next
        uintptr_t next = 0;
        if (!read_memory(pid, addr, &next, sizeof(next))) {
            return 0;  // couldn't read — process died or bad address
        }
        if (next == 0) {
            return 0;  // null pointer — chain is broken (player not in game?)
        }
        addr = next;  // follow the pointer
    }

    // The last offset is different — it's not a pointer to dereference,
    // it's the final offset within the struct where the actual value lives.
    addr += offsets.back();
    return addr;
}

If any pointer in the chain is null or unreadable, the chain is broken. This means that the player probably isn’t in a game yet, or the game just relocated its data structures.

Putting It Together#

All four pieces we’ve previously discussed connect in a handful of lines:

pid_t pid = find_d2r_pid();
uintptr_t base = find_module_base(pid, "D2R.exe");

uintptr_t start = base + 0x2028E60;
std::vector<uintptr_t> offsets = {0x10, 0x48, 0x00};

uintptr_t gold_addr = walk_pointer_chain(pid, start, offsets);

int32_t gold = 0;
read_memory(pid, gold_addr, &gold, sizeof(gold));
std::cout << "Current gold: " << gold << std::endl;

// Set gold to 999999
int32_t new_gold = 999999;
write_memory(pid, gold_addr, &new_gold, sizeof(new_gold));

Essentially, find the process, find the module, walk the chain, read or write. That’s the entire trainer loop.

Building the Trainer#

With all the source files in place, we need a build system. CMake is the standard choice for C++ projects. It handles compiler flags, dependencies, and platform differences so you don’t have to write raw Makefiles:

cmake_minimum_required(VERSION 3.16)
project(d2r-trainer LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)

find_package(nlohmann_json 3.0 REQUIRED)

add_executable(d2r-trainer
    src/main.cpp
    src/process.cpp
    src/memory.cpp
    src/config.cpp
)

target_link_libraries(d2r-trainer PRIVATE nlohmann_json::nlohmann_json)

The nlohmann_json dependency is for parsing the config file — it’s the de facto JSON library for C++. On Arch/Garuda you can install it with pacman -S nlohmann-json. Then the standard CMake build:

mkdir build && cd build
cmake ..
make

Running the Trainer#

The trainer needs to read and write another process’s memory, which the kernel won’t allow without elevated privileges. To address this we’ve got two options: sudo for quick and dirty, or setcap to grant just the specific capability the binary needs:

# Needs root or CAP_SYS_PTRACE
sudo ./d2r-trainer

# Or grant the capability permanently:
sudo setcap cap_sys_ptrace=eip ./d2r-trainer
./d2r-trainer

With the trainer built and running, here’s what it actually looks like in action — nothing fancy, just enough to be useful:

D2R Trainer v0.1
─────────────────
Process: D2R.exe (PID: 12345)
Module base: 0x7F0010000000

[1] Gold:    12345  (set: s1 <value>)
[2] Health:  420    (set: s2 <value>)
[3] Mana:    180    (set: s3 <value>)

[r] Refresh values
[q] Quit

At this point you’ll notice that setting gold seems to work fine as a one-shot write because nothing recalculates it. However, health and mana are different. In practice, it looks as if the game constantly updates them; damage, regeneration, buff ticks, etc. so that a single write gets overwritten almost immediately. For those stats, a freeze mode that continuously rewrites those values on a short timer keeps them pinned where you want them to be.


Gotchas#

Wine Specifics#

  • D2R.exe runs under Wine, so it’s a regular Linux process — /proc access works normally.
  • Module names in /proc/maps show the full Wine path. Match on just D2R.exe.
  • Wine may load the PE binary in a non-standard way; the base address in maps corresponds to the PE image base.

Chain Stability#

  • Chains are stable within a game version. When D2R patches, offsets change.
  • Keeping chains in config.json means you never have to recompile.

Anti-Debug#

  • D2R has anti-debugging protections that cause crashes on Windows. Under Wine, these are largely ineffective; the anti-debug calls target Windows APIs that Wine doesn’t fully implement.
  • process_vm_readv is a Linux kernel syscall. Completely invisible to the game.

Resources#

  • OwnedCore forums — community-sourced D2R offsets
  • d2go (GitHub: hectorgimenez/d2go) — Go library with D2R struct definitions
  • blacha/diablo2 (GitHub) — TypeScript/memory package with struct layouts
  • scanmem — still useful for discovering new patterns

Putting It All Together#

StepWhatTool
1Find value addressscanmem
2Check memory regionsscanmem lregions
3Trace pointers backwardsscanmem (search for address as value)
4Repeat until you hit D2R.exe rangescanmem + lregions
5Record offsets into config.jsonText editor
6Validate after restartscanmem or the trainer itself
7Build and runC++ / CMake

This is the foundation. It works on Windows with Cheat Engine, and in theory it works on Linux too. In practice, Wine had other plans. This is what Part 2 will be about.