Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: exe-standalone
path: src/D2BotNG/bin/Release/net10.0-windows/win-x86/publish/D2BotNG.exe
path: src/D2BotNG/bin/Release/net10.0-windows/win-x64/publish/D2BotNG.exe
retention-days: 1

publish-framework:
Expand All @@ -235,7 +235,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: exe-framework
path: src/D2BotNG/bin/Release/net10.0-windows/win-x86/publish/D2BotNG.exe
path: src/D2BotNG/bin/Release/net10.0-windows/win-x64/publish/D2BotNG.exe
retention-days: 1

release:
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Modern Diablo II bot manager. Manages D2 game instances, handles CD key rotation
```
protos/ # Protobuf definitions (source of truth for all services)
src/
D2BotNG/ # .NET backend (x86 Windows)
D2BotNG/ # .NET backend (x64 Windows)
Services/ # gRPC implementations (*ServiceImpl.cs)
Engine/ # Profile lifecycle (ProfileEngine), scheduling (ScheduleEngine)
Windows/ # Win32 interop: GameLauncher, ProcessManager, Patcher, MessageWindow
Expand Down Expand Up @@ -70,7 +70,7 @@ npm run generate-grpc # Regenerate protobuf types from protos/
cd src/D2BotNG
dotnet publish -c Release --self-contained # Bundles .NET runtime (~60-80MB)
dotnet publish -c Release --no-self-contained # Requires .NET 10 runtime (~15-25MB)
# Output: bin/Release/net10.0-windows/win-x86/publish/D2BotNG.exe
# Output: bin/Release/net10.0-windows/win-x64/publish/D2BotNG.exe
```

## gRPC Services
Expand Down Expand Up @@ -112,8 +112,9 @@ Frontend uses a single gRPC server-stream for all real-time state:

### Windows Layer
- **GameLauncher** (`Windows/GameLauncher.cs`) - 12-step launch pipeline: clear cache, build CLI args, create suspended process, patch memory, resume, inject D2BS.dll, set title
- **ProcessManager** (`Windows/ProcessManager.cs`) - DLL injection via LoadLibraryA remote thread, process creation, graceful shutdown (WM_CLOSE + force kill), job object for auto-killing child processes on crash
- **ProcessManager** (`Windows/ProcessManager.cs`) - DLL injection via LoadLibraryW remote thread, process creation, graceful shutdown (WM_CLOSE + force kill), job object for auto-killing child processes on crash
- **Patcher** (`Windows/Patcher.cs`) - Binary memory patches via VirtualProtectEx + WriteProcessMemory
- **RemoteModule** (`Windows/RemoteModule.cs`) - Resolves a target's kernel32 `LoadLibrary` address for cross-bitness injection (x64 manager → 32-bit game) and reads target module bases, via a PE export walk over ReadProcessMemory
- **MessageWindow** (`Windows/MessageWindow.cs`) - WM_COPYDATA receiver, parses JSON from D2BS, queues to Channel<D2BSMessage>
- **DaclOverwriter** - Changes DACL for elevated process access

Expand Down Expand Up @@ -252,7 +253,7 @@ Item/mule data lives in `d2bs/kolbot/mules/` (*.txt files, watched by FileSystem

## Notes

- **x86 required** - D2BS compatibility (32-bit DLL injection into game process)
- **x64 build (forced)** - `Platforms`/`PlatformTarget`/`RuntimeIdentifier` in `D2BotNG.csproj` pin x64. Still injects the 32-bit D2BS into a 32-bit game cross-bitness via `RemoteModule` (see Windows Layer)
- **Windows-only** - WinForms, WebView2, Win32 APIs, P/Invoke throughout
- **Dual-mode** - GUI (WebView2 desktop) or headless (server-only with message-only window)
- **Frontend embedded** - Production UI builds to `wwwroot/`, served by Kestrel
Expand Down
8 changes: 0 additions & 8 deletions D2BotNG.sln
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,22 @@ Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|x64.ActiveCfg = Debug|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|x64.Build.0 = Debug|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|x86.ActiveCfg = Debug|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Debug|x86.Build.0 = Debug|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|Any CPU.Build.0 = Release|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|x64.ActiveCfg = Release|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|x64.Build.0 = Release|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|x86.ActiveCfg = Release|Any CPU
{43AFF715-A5B8-4882-8BB1-8864CBD788F3}.Release|x86.Build.0 = Release|Any CPU
{074DA5D1-201F-45C9-9A23-5666D2A401D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{074DA5D1-201F-45C9-9A23-5666D2A401D0}.Debug|x64.ActiveCfg = Debug|Any CPU
{074DA5D1-201F-45C9-9A23-5666D2A401D0}.Debug|x86.ActiveCfg = Debug|Any CPU
{074DA5D1-201F-45C9-9A23-5666D2A401D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{074DA5D1-201F-45C9-9A23-5666D2A401D0}.Release|x64.ActiveCfg = Release|Any CPU
{074DA5D1-201F-45C9-9A23-5666D2A401D0}.Release|x86.ActiveCfg = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ Download the latest release from the [Releases](../../releases) page. Two versio

| Version | Size | Requirements |
|---------|------|-------------|
| **Self-contained** | Larger | Windows 10/11 (x86) — just run, no prerequisites |
| **Framework-dependent** | Smaller | Windows 10/11 (x86) + [.NET 10 Runtime](https://dotnet.microsoft.com/download/dotnet/10.0) |
| **Self-contained** | Larger | Windows 10/11 (x64) — just run, no prerequisites |
| **Framework-dependent** | Smaller | Windows 10/11 (x64) + [.NET 10 Runtime](https://dotnet.microsoft.com/download/dotnet/10.0) |

To get started:

Expand Down Expand Up @@ -154,16 +154,18 @@ Full compatibility with D2BS:

- Same command-line arguments (`-d2c`, `-d2x`, `-handle`, `-profile`)
- WM_COPYDATA IPC messaging
- DLL injection (LoadLibraryA remote thread)
- DLL injection (LoadLibraryW remote thread)
- Memory patches for various D2 versions
- d2bs.ini generation with game paths and CD keys
- All D2BS message types supported (heartbeat, status, console, items, key rotation, etc.)

> **Note:** Built for x64, but still injects the 32-bit D2BS into the 32-bit game (it resolves the game's `LoadLibrary` address across the bitness boundary).

## Development

### Requirements

- Windows 10/11 (x86 build for D2BS compatibility)
- Windows 10/11 (x64)
- .NET 10 SDK
- Node.js 20+

Expand Down Expand Up @@ -210,7 +212,7 @@ dotnet publish -c Release --self-contained
dotnet publish -c Release --no-self-contained
```

Output: `bin/Release/net10.0-windows/win-x86/publish/D2BotNG.exe`
Output: `bin/Release/net10.0-windows/win-x64/publish/D2BotNG.exe`

### Architecture

Expand Down
5 changes: 3 additions & 2 deletions src/D2BotNG/D2BotNG.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>default</LangVersion>
<PlatformTarget>x86</PlatformTarget>
<PlatformTarget>x64</PlatformTarget>
<Platforms>x64</Platforms>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>app.ico</ApplicationIcon>
Expand Down Expand Up @@ -89,7 +90,7 @@

<!-- Publish: dotnet publish -c Release (with self-contained or no-self-contained flag) -->
<PropertyGroup>
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- Clean publish output: no IIS web.config, no XML docs, embed PDB -->
Expand Down
43 changes: 35 additions & 8 deletions src/D2BotNG/Windows/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,33 +32,50 @@ public static extern bool DuplicateHandle(
public static extern nint GetCurrentProcess();


// dwSize is SIZE_T (pointer-sized: 64-bit on x64), so it must be nuint, not uint.
[DllImport("kernel32.dll", SetLastError = true)]
public static extern nint VirtualAllocEx(nint hProcess, nint lpAddress, uint dwSize, uint flAllocationType, uint flProtect);
public static extern nint VirtualAllocEx(nint hProcess, nint lpAddress, nuint dwSize, uint flAllocationType, uint flProtect);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool VirtualFreeEx(nint hProcess, nint lpAddress, uint dwSize, uint dwFreeType);
public static extern bool VirtualFreeEx(nint hProcess, nint lpAddress, nuint dwSize, uint dwFreeType);

// lpflOldProtect is PDWORD (always 32-bit), so out uint is correct; only dwSize (SIZE_T) is pointer-sized.
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool VirtualProtectEx(nint hProcess, nint lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect);
public static extern bool VirtualProtectEx(nint hProcess, nint lpAddress, nuint dwSize, uint flNewProtect, out uint lpflOldProtect);

// nSize and *lpNumberOfBytesWritten are both SIZE_T. The out param is critical: on x64 the
// kernel writes 8 bytes here, so a 4-byte (int) target would be a stack/heap overrun.
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool WriteProcessMemory(nint hProcess, nint lpBaseAddress, byte[] lpBuffer, uint nSize, out int lpNumberOfBytesWritten);
public static extern bool WriteProcessMemory(nint hProcess, nint lpBaseAddress, byte[] lpBuffer, nuint nSize, out nuint lpNumberOfBytesWritten);

// nSize and *lpNumberOfBytesRead are both SIZE_T — pointer-sized on x64. Used to read a target
// process's PE headers/export table when resolving function addresses inside that process.
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool ReadProcessMemory(nint hProcess, nint lpBaseAddress, byte[] lpBuffer, nuint nSize, out nuint lpNumberOfBytesRead);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern nint GetModuleHandle(string? lpModuleName);

// Resolves an export in *our own* loaded module — used as the same-bitness fast path, where the
// address is also valid in a same-flavour target (shared system-DLL base per boot).
[DllImport("kernel32.dll", SetLastError = true)]
public static extern nint GetProcAddress(nint hModule, string lpProcName);

[DllImport("kernel32.dll")]
public static extern nint LocalFree(nint hMem);

// Detects a target's bitness: on an x64 OS a WOW64 process is 32-bit. The manager is built x64,
// so a WOW64 target means cross-bitness injection.
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool IsWow64Process(nint hProcess, out bool wow64Process);

#endregion

#region kernel32.dll - Thread

// dwStackSize is SIZE_T (pointer-sized); lpThreadId is LPDWORD (always 32-bit).
[DllImport("kernel32.dll", SetLastError = true)]
public static extern nint CreateRemoteThread(nint hProcess, nint lpThreadAttributes, uint dwStackSize, nint lpStartAddress, nint lpParameter, uint dwCreationFlags, out uint lpThreadId);
public static extern nint CreateRemoteThread(nint hProcess, nint lpThreadAttributes, nuint dwStackSize, nint lpStartAddress, nint lpParameter, uint dwCreationFlags, out uint lpThreadId);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern nint OpenThread(uint dwDesiredAccess, bool bInheritHandle, uint dwThreadId);
Expand All @@ -69,9 +86,6 @@ public static extern bool DuplicateHandle(
[DllImport("kernel32.dll", SetLastError = true)]
public static extern uint WaitForSingleObject(nint hHandle, uint dwMilliseconds);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool GetExitCodeThread(nint hThread, out uint lpExitCode);

#endregion

#region kernel32.dll - Process Creation
Expand Down Expand Up @@ -217,6 +231,19 @@ public static extern uint SetSecurityInfo(

#endregion

#region psapi.dll - Module Enumeration

// Reads a remote module's base address from the loader's module list. The base is the HMODULE
// value (pointer-width), so it must be captured as nint — on x64 a 32-bit thread exit code
// (the old approach) would truncate it.
[DllImport("psapi.dll", SetLastError = true)]
public static extern bool EnumProcessModulesEx(nint hProcess, [Out] nint[] lphModule, uint cb, out uint lpcbNeeded, uint dwFilterFlag);

[DllImport("psapi.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern uint GetModuleFileNameExW(nint hProcess, nint hModule, System.Text.StringBuilder lpFilename, uint nSize);

#endregion

/// <summary>
/// Async polling wrapper around WaitForSingleObject.
/// Polls instead of blocking so the calling thread is released between checks.
Expand Down
4 changes: 4 additions & 0 deletions src/D2BotNG/Windows/NativeTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ public enum SE_OBJECT_TYPE
// Special window handles
public static readonly nint HWND_MESSAGE = -3;

// EnumProcessModulesEx filter: return 32-bit, 64-bit, and all other modules. LIST_MODULES_ALL
// is required so a 64-bit manager can see a 32-bit (WOW64) game's modules.
public const uint LIST_MODULES_ALL = 0x03;

// Job Object constants
public const int JobObjectExtendedLimitInformation = 9;
public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
Expand Down
37 changes: 17 additions & 20 deletions src/D2BotNG/Windows/Patcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public async Task<bool> ApplyPatchAsync(Process process, string module, Patch pa
var targetAddress = moduleBase + patch.Offset;

// Change memory protection
if (!VirtualProtectEx(hProcess, targetAddress, (uint)patch.Data.Length, PAGE_EXECUTE_READWRITE, out uint oldProtection))
if (!VirtualProtectEx(hProcess, targetAddress, (nuint)patch.Data.Length, PAGE_EXECUTE_READWRITE, out uint oldProtection))
{
_logger.LogError("Failed to change memory protection at {Address:X}", targetAddress);
return false;
Expand All @@ -54,7 +54,7 @@ public async Task<bool> ApplyPatchAsync(Process process, string module, Patch pa
try
{
// Write the patch bytes
if (!WriteProcessMemory(hProcess, targetAddress, patch.Data.ToByteArray(), (uint)patch.Data.Length, out _))
if (!WriteProcessMemory(hProcess, targetAddress, patch.Data.ToByteArray(), (nuint)patch.Data.Length, out _))
{
_logger.LogError("Failed to write patch at {Address:X}", targetAddress);
return false;
Expand All @@ -65,7 +65,7 @@ public async Task<bool> ApplyPatchAsync(Process process, string module, Patch pa
}
finally
{
VirtualProtectEx(hProcess, targetAddress, (uint)patch.Data.Length, oldProtection, out _);
VirtualProtectEx(hProcess, targetAddress, (nuint)patch.Data.Length, oldProtection, out _);
}
}
catch (Exception ex)
Expand All @@ -85,7 +85,7 @@ private async Task<nint> LoadModuleRemotelyAsync(nint processHandle, string modu
var pathBytes = Encoding.Unicode.GetBytes(modulePath + '\0');

// Allocate memory in target process for the DLL path string
var remoteMemory = VirtualAllocEx(processHandle, 0, (uint)pathBytes.Length, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
var remoteMemory = VirtualAllocEx(processHandle, 0, (nuint)pathBytes.Length, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (remoteMemory == 0)
{
_logger.LogError("Failed to allocate memory for module path in target process");
Expand All @@ -95,18 +95,18 @@ private async Task<nint> LoadModuleRemotelyAsync(nint processHandle, string modu
try
{
// Write the DLL path into the allocated memory
if (!WriteProcessMemory(processHandle, remoteMemory, pathBytes, (uint)pathBytes.Length, out _))
if (!WriteProcessMemory(processHandle, remoteMemory, pathBytes, (nuint)pathBytes.Length, out _))
{
_logger.LogError("Failed to write module path to target process");
return 0;
}

// Get LoadLibraryW address from our own kernel32 (same address in target due to ASLR shared base)
var kernel32 = GetModuleHandle("kernel32.dll");
var loadLibraryAddr = GetProcAddress(kernel32, "LoadLibraryW");
// Resolve LoadLibraryW for the target. Same-bitness targets use our own kernel32 (shared
// base); a 32-bit target gets the 32-bit kernel32 address from a peer WOW64 process.
var loadLibraryAddr = RemoteModule.ResolveExportForTarget(processHandle, "kernel32.dll", "LoadLibraryW");
if (loadLibraryAddr == 0)
{
_logger.LogError("Failed to get LoadLibraryW address");
_logger.LogError("Failed to resolve LoadLibraryW for target process");
return 0;
}

Expand All @@ -127,26 +127,23 @@ private async Task<nint> LoadModuleRemotelyAsync(nint processHandle, string modu
return 0;
}

// LoadLibrary's return value is the module base address (or 0 on failure)
// Retrieved via the thread's exit code
if (!GetExitCodeThread(threadHandle.DangerousGetHandle(), out var moduleBase))
{
_logger.LogError("Failed to get exit code from LoadLibraryW thread");
return 0;
}

// The remote thread's exit code can't carry the module base on x64: it's a 32-bit
// DWORD, while a 64-bit HMODULE needs the full pointer width. Read the base from the
// loader's module list instead — the LoadLibraryW above both forces the load and (as
// the first thread to run in the suspended process) initializes the loader data, so
// the module is now enumerable.
var moduleBase = RemoteModule.GetModuleBase(processHandle, Path.GetFileName(modulePath));
if (moduleBase == 0)
{
_logger.LogError("LoadLibraryW failed in target process for {ModulePath}", modulePath);
_logger.LogError("Module {ModulePath} not loaded in target process after LoadLibraryW", modulePath);
return 0;
}

return (nint)moduleBase;
return moduleBase;
}
finally
{
VirtualFreeEx(processHandle, remoteMemory, 0, MEM_RELEASE);
}
}

}
Loading
Loading