Back to blog

Reverse Engineering Kenshi: DLL Injection for Multiplayer

How I implemented multiplayer functionality in a single-player game through DLL injection and memory hooking, without modifying game binaries.

#Game Dev#Reverse Engineering#C++#Win32

Kenshi is a unique sandbox RPG known for its brutal difficulty and open-ended gameplay. As a single-player game, it naturally lacks multiplayer support. This project explores whether we can add cooperative multiplayer through external code injection—without touching the game's original binaries or MOD system.

The Challenge

Adding multiplayer to a game that was never designed for it presents several challenges:

  1. No network code exists — Everything must be built from scratch
  2. No source code — We work with compiled binaries only
  3. Anti-mod architecture — The game's MOD system doesn't support gameplay modification
  4. Complex game state — Hundreds of entities with positions, stats, inventory, etc.

Approach: External DLL Injection

Instead of modifying game files, we inject a DLL into the running process:

┌─────────────────────────────────────────────────────────┐
│                    Kenshi.exe                           │
│  ┌─────────────────────────────────────────────────┐  │
│  │              Game Memory Space                   │  │
│  │  ┌─────────────┐  ┌──────────────────────────┐  │  │
│  │  │ Game Code   │  │ Injected KenshiMP.dll    │  │  │
│  │  │ (Original)  │  │ • Memory hooks          │  │  │
│  │  │             │  │ • Network sync          │  │  │
│  │  │             │  │ • State management      │  │  │
│  │  └─────────────┘  └──────────────────────────┘  │  │
│  └─────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘

The Injection Process

// Launcher: KenshiMultiplayer.exe
bool InjectDLL(HANDLE hProcess, const char* dllPath) {
    // Allocate memory in target process for DLL path
    LPVOID pRemotePath = VirtualAllocEx(
        hProcess,
        nullptr,
        strlen(dllPath) + 1,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_READWRITE
    );
 
    if (!pRemotePath) return false;
 
    // Write DLL path to target process
    WriteProcessMemory(hProcess, pRemotePath, dllPath, strlen(dllPath) + 1, nullptr);
 
    // Get LoadLibraryA address
    HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
    FARPROC pLoadLibrary = GetProcAddress(hKernel32, "LoadLibraryA");
 
    // Create remote thread to load our DLL
    HANDLE hThread = CreateRemoteThread(
        hProcess,
        nullptr,
        0,
        (LPTHREAD_START_ROUTINE)pLoadLibrary,
        pRemotePath,
        0,
        nullptr
    );
 
    WaitForSingleObject(hThread, INFINITE);
 
    // Cleanup
    VirtualFreeEx(hProcess, pRemotePath, 0, MEM_RELEASE);
    CloseHandle(hThread);
 
    return true;
}

Memory Hooking: Finding the Right Addresses

The core of the multiplayer system is hooking game functions to intercept and synchronize state. We need to find memory addresses for:

1. Character Position

// Pattern scanning for character position structure
void* FindCharacterBase() {
    // Known pattern from reverse engineering
    // mov rax, [CharacterPool + offset]
    // Pattern: 48 8B 05 ?? ?? ?? ?? 48 85 C0
    const char pattern[] = "\x48\x8B\x05\x00\x00\x00\x00\x48\x85\xC0";
    const char mask[] = "xxx???xxx";
 
    return ScanPattern(GetModuleHandle(nullptr), pattern, mask, 10);
}

2. Hooking Character Updates

// Trampoline hook for character position updates
void __fastcall HookedUpdatePosition(Character* character, float x, float y, float z) {
    // Store original position
    Vector3 originalPos = {character->x, character->y, character->z};
 
    // Call original function
    OriginalUpdatePosition(character, x, y, z);
 
    // Broadcast to network if this is the selected character
    if (character == GetSelectedCharacter()) {
        NetworkManager::BroadcastPosition({
            .id = character->id,
            .x = character->x,
            .y = character->y,
            .z = character->z
        });
    }
}

State Synchronization

Network Architecture

┌────────────┐                    ┌────────────┐
│   Host     │◄────── TCP ──────►│   Client   │
│            │                    │            │
│ • Authoritative                  │ • Receive state
│ • Broadcasts   │                    │ • Apply locally
│ • MOD verify│                    │ • Predict
└────────────┘                    └────────────┘
      │                                  │
      ▼                                  ▼
┌─────────────────────────────────────────────┐
│              Synced State                    │
│ • Selected character position/HP            │
│ • Gold amount                               │
│ • World time                                │
└─────────────────────────────────────────────┘

Position Sync Protocol

#pragma pack(push, 1)
struct PositionPacket {
    uint8_t type;        // PACKET_POSITION
    uint32_t characterId;
    float x, y, z;
    uint32_t timestamp;
};
#pragma pack(pop)
 
void PositionSync::Update() {
    // Host: broadcast selected character position
    if (IsHost()) {
        Character* selected = GetSelectedCharacter();
        if (selected && selected->positionChanged) {
            PositionPacket pkt = {
                .type = PACKET_POSITION,
                .characterId = selected->id,
                .x = selected->x,
                .y = selected->y,
                .z = selected->z,
                .timestamp = GetTickCount()
            };
            Broadcast(&pkt, sizeof(pkt));
            selected->positionChanged = false;
        }
    }
    // Client: apply received positions
    else {
        ApplyPendingPositions();
    }
}

MOD Verification

To ensure all players have the same game state, we verify MOD lists before connecting:

bool VerifyMODList(const std::vector<std::string>& clientMods) {
    auto hostMods = GetInstalledMODs();
 
    if (hostMods.size() != clientMods.size()) {
        return false;
    }
 
    for (size_t i = 0; i < hostMods.size(); i++) {
        if (hostMods[i].name != clientMods[i].name ||
            hostMods[i].hash != clientMods[i].hash) {
            return false;
        }
    }
 
    return true;
}

Crash Protection

Game crashes can happen unexpectedly. We implement SEH (Structured Exception Handling) to capture crash dumps:

__declspec(noinline) LONG WINAPI CrashHandler(EXCEPTION_POINTERS* pException) {
    // Create minidump
    HANDLE hFile = CreateFileA(
        "kenshi_mp_crash.dmp",
        GENERIC_WRITE,
        0,
        nullptr,
        CREATE_ALWAYS,
        FILE_ATTRIBUTE_NORMAL,
        nullptr
    );
 
    if (hFile != INVALID_HANDLE_VALUE) {
        MINIDUMP_EXCEPTION_INFORMATION mei = {
            .ThreadId = GetCurrentThreadId(),
            .ExceptionPointers = pException,
            .ClientPointers = FALSE
        };
 
        MiniDumpWriteDump(
            GetCurrentProcess(),
            GetCurrentProcessId(),
            hFile,
            MiniDumpWithDataSegs,
            &mei,
            nullptr,
            nullptr
        );
 
        CloseHandle(hFile);
    }
 
    return EXCEPTION_CONTINUE_SEARCH;
}
 
// Install crash handler
SetUnhandledExceptionFilter(CrashHandler);

Results and Limitations

What Works

  • Character position synchronization
  • Health point display
  • Gold amount sync
  • World time sync
  • Reconnection within 90 seconds

Current Limitations

  • Only selected character synced (not full world state)
  • No NPC synchronization
  • No combat state sync
  • Offset-dependent (requires updates for game patches)

Ethical Considerations

This project is for educational and research purposes only. Key principles:

  1. No game modification — Original binaries remain untouched
  2. No cheat functionality — Multiplayer only, no gameplay advantages
  3. MOD compatibility — Works within existing MOD ecosystem
  4. Open source — Code is auditable under AGPL-3.0

References


This article covers the technical implementation. Game modification tools should be used responsibly and in accordance with the game's terms of service.