Full Changelog: https://github.com/shefben/earthbreakers/commits/1.0
EarthBreakers multiplayer patch
EarthBreakers (Steam AppID 1319010, Grasshopper-Win64-Shipping.exe) shipped
with its multiplayer wired to a Steam matchmaking backend that no longer
exists for this build. This patch replaces that backend with a small
steam_api64.dll shim plus a standalone Python list server, so the game can
find and join servers again without Steam.
It does not modify the game's .exe or any .pak content. Everything
lives in a drop-in steam_api64.dll and a config file.
What it does
- Lets the client browse and join servers again (
ISteamMatchmakingServers
is intercepted and backed by an HTTP list server instead of Steam). - Lets a listen/dedicated server register itself on that list.
- Gives every player a unique Steam ID (the stock Steam emulator handed every
copy of the game the same ID, which capped a session at two players). - Fixes several server-side join/spawn bugs the game hits when running
without a real Steam backend (see Known issues and fixes below). - Ships pointed at a public list server by default —
the.yankees-suck.net— so installing the patch is enough to see
other players' games; you don't need to stand up your own list server
unless you want a private/LAN session.
How it works
+----------------+ HTTP/JSON +--------------------------+
| Game client | --- /servers --> | Python list server |
| (steam_api64 | <-------------- | list_server/server.py |
| shim DLL) | | default: the.yankees- |
+----------------+ | suck.net:8088 |
^ +--------------------------+
| ISteamMatchmakingServers
| vtable + P2P/session calls
+----------------+
| OS / UE Steam |
| OSS code paths |
+----------------+
The shim is a drop-in replacement for steam_api64.dll. It forwards every
Steam call it doesn't care about to the existing Steam emulator (renamed
steam_api64_orig.dll next to it), and intercepts the handful of calls that
matter for multiplayer:
SteamInternal_FindOrCreateUserInterface(..., "SteamMatchMakingServers002")
returns the shim's own matchmaking-servers object. Its
RequestInternetServerListdoes an HTTP GET against the list server's
/serversendpoint, parses the JSON, and feeds the results back through
the standardISteamMatchmakingServerListResponsecallbacks the SDK
normally drives — the game's own server browser UI doesn't know the
difference.EnableHeartbeats(true)(a listen/dedicated server going live) makes
the shimPOST /servers/registerand then heartbeat itself on the list
server, so it shows up for everyone else.- P2P session / networking calls are patched to work over real UDP
between arbitrary machines instead of loopback-only, with a SteamID →
endpoint peer table so reconnects and multiple simultaneous players work. ISteamUseridentity is patched so each installed copy derives a
unique SteamID from the machine name + Windows username instead of every
copy sharing one ID.- A set of targeted, server-side fixes (below) work around bugs the game's
own code hits once it's not talking to real Steam.
None of this needs the game's source or a recompiled .exe — it all happens
by being the DLL the game already loads at startup.
Known issues and fixes already in the shim
The interesting bugs here aren't in the shim's own networking — they're bugs
in the game's code that only surface once you're running a server without
real Steam infrastructure behind it (no dedicated-server code path was ever
shipped/tested for this build). Each was root-caused by reverse engineering
the shipping binary and is worked around from the shim:
- Players fall through the map. The ground is a UE Landscape in a
streaming sublevel that never loads on a headless server. The shim forces
that sublevel to stream in and recreates landscape collision; a flat-floor
fallback catches anyone who still spawns over a hole. - New pawns spawn with no weapon / no collision. Pawns spawn
"deferred" (construction never finishes) unless the shim explicitly
finishes construction, which is also required before the weapon-equip
logic has anything to attach to. - Joining players get kicked right after team select. PostLogin drives
the player through spectator → team-select →Server_SetTeam, and a
null-deref in cleanup code (hit only because the emulated Steam identity
doesn't look like the game expects) kicks them. Gated off / patched around
in the shim's join handling. - The buy menu is empty / the shop terminal has no sprite / the server
dies when the last player leaves. All trace back to the player's team
identity never being established server-side; the shim drives the
BP-level team/economy state (UTeamStateData,UUpgradegrants, currency
deduction) directly as the authoritative side, since there's no native
Server_Purchaseentry point to hook.
See docs/flow_*.md for the full reverse-engineered network/spawn/loadout
flow this is all built against.
Open issue: the AI harvester
Resource harvester units (AI-controlled, not player-driven) don't move on a
server run through this patch — root cause: the server's navmesh is empty
(never built/baked for a headless run), so the harvester's own Blueprint
MoveTo silently fails. Chasing this down through the possessed-controller
path was a dead end.
Current fix: the shim runs a direct autopilot for harvester pawns —
it re-issues movement commands straight at the pawn each tick and wakes its
PhysX body, bypassing the broken BP pathing entirely. Harvesters do move and
harvest with this in place.
Still unexplained: while chasing the throttle the harvester's vehicle
movement code reads, live RE turned up a real contradiction that's still
open — the AI does command full throttle (raw == 1.0) at the point the
value is read, but it reads back as 0 by the time UpdateState runs later
in the same tick. Seven separate theories (including a controller gate and
an early-return branch) have been tested and disproven against the live
process; nothing found so far explains the drop. This doesn't block anything
today because the autopilot workaround sidesteps the game's own throttle
path, but it means the stock harvester movement code is still broken
underneath, and would need this resolved before the autopilot patch could be
retired.
Layout
server_dev/
steam_shim/ # the shim: source of steam_api64.dll
src/shim.cpp all interception, patches, autopilot, etc.
src/steam_types.h Steam SDK type/struct definitions
CMakeLists.txt
ebrk/ # shared native watchdog/symbol-resolution lib
src/ crash-safe patching, logging, diagnostics
tools/gen_symbols.py regenerates generated/symbols.g.h from
symbols.def against the symbolized IDB
list_server/server.py # the HTTP list server (default port 8088)
release/ # source-of-truth for the distributable zip
game_files/ files copied onto a game install
install.ps1 / uninstall.ps1 non-destructive installer (backs up first)
docs/ # reverse-engineered flow documentation
flow_01_network_join.md .. flow_05_verification.md
ida_scripts/, findings/ # one-off RE scripts and their raw output
tools/ # ad hoc IDA/analysis scripts used during RE
Install (pre-built patch)
- Download the release archive and unzip it somewhere.
- Run
INSTALL.cmd, point it at your EarthBreakers install folder (the one
containingGrasshopper\andEngine\). It backs up every file it
replaces into_ebrk_backup\first — safe to re-run. - Join: run
join.cmdfrom the game folder. It points at
the.yankees-suck.netby default (seeGrasshopper\Binaries\Win64\ebrk.ini
to change it — same file lets you point at a friend's IP or127.0.0.1
for a same-machine test). - Host: run
host.cmd [MapName]. Your game registers itself with the
public list automatically; other players see it in the server browser.
Only runstart_list_server.cmdif you specifically want to self-host
the browser list instead (LAN parties, private testing). - Log file for troubleshooting:
%TEMP%\ebrk_shim.log.
To remove the patch: run UNINSTALL.cmd from the same package — it restores
everything from _ebrk_backup\.
Compile from source
Requires a C++17 toolchain with the Windows SDK (Visual Studio 2019+, the
build below uses the VS generator) and Python 3.8+ (only needed at build
time to verify ebrk/generated/symbols.g.h is still in sync with
ebrk/symbols.def; it does not regenerate it — that requires the symbolized
IDB, which isn't part of this repo).
cmake -S server_dev/steam_shim -B server_dev/steam_shim/build -G "Visual Studio 16 2019" -A x64
cmake --build server_dev/steam_shim/build --config ReleaseOutput: server_dev/steam_shim/build/Release/steam_api64.dll.
Install it over the existing steam_api64.dll next to the game's
steamclient64.dll, at:
<game>\Engine\Binaries\ThirdParty\Steamworks\Steamv146\Win64\steam_api64.dll
(not Grasshopper\Binaries\... — that path is ignored). Keep the
original next to it as steam_api64_orig.dll; the shim forwards to it. For
local dev, install_shim.ps1 in this directory automates that swap
(hardcoded to a local M:\earthbreakers checkout — the release/install.ps1
script is the portable one meant for other machines).
Building the release archive
release/ is unzipped as-is plus the proprietary Steamworks binaries pulled
from a working game install (steam_api64_orig.dll, steamclient64.dll,
SteamConfig.ini, steam_settings/) and a freshly built
steam_api64.dll. Those binaries are not committed to this repository —
only the packaging scripts and config templates under release/ are.
Run order for local development (no packaging)
- Build the shim (see above) and install it with
install_shim.ps1. - Start a list server:
python list_server\server.py(defaults to
0.0.0.0:8088). - Launch with
run_client.cmd/run_server.cmd, pointingebrk.inior
-ebrklist=at that list server (127.0.0.1for same-machine testing). - Watch
%TEMP%\ebrk_shim.logand the list server's stdout.
List server endpoints
| Method | Path | Notes |
|---|---|---|
| GET | /servers |
{"servers": [...]} |
| GET | /healthz |
{"ok": true} |
| GET | /debug |
HTML dump of the current list (for a browser) |
| POST | /servers/register |
registers a server, returns {key, lease} |
| POST | /servers/heartbeat |
{"key":..., "server":{...}} — refresh a lease |
| POST | /servers/unregister |
{"key":...} — drop a server |
Config reference (ebrk.ini)
Read once at startup, next to the game exe first, then next to the shim DLL.
Command line (-ebrklist=, -ebrkp2pport=, -ebrkpublicip=, -ebrkmap=,
...) and the EBRK_* environment variables both override it; see the
comments in release/game_files/Grasshopper/Binaries/Win64/ebrk.ini for the
full list of keys (list_host, list_port, public_ip, p2p_port,
steam_id, username, map, direct_host, join_passthrough).
Changing the map
The host's UGameMapsSettings::GetGameDefaultMap is always intercepted by
the shim and forced to a known-good ?listen URL — passing a map straight
on the exe's command line was found unreliable (UE's own Browse-to-map retry
loop, HandleBrowseToDefaultMapFailure, kept firing on some maps). So the
map is picked by the shim's config, not by the command-line URL by itself.
Set it with any one of:
ebrk.ini:map = Renegade_Map_02(persists across launches)- Environment:
EBRK_MAP=Renegade_Map_02 - Command line:
-ebrkmap=Renegade_Map_02 host.cmd <MapName>/run_server.cmd <MapName>— both scripts forward
the argument as-ebrkmap=for you.
Precedence is command line > environment > ebrk.ini > default
(Renegade_Map_03). Short map name only — no /Game/RTS/Levels/ prefix.
The GameMode stays fixed to FPS_Renegade_GM regardless of which map is
selected. Log line to confirm what actually loaded: OurGetGameDefaultMap: returning '...' in %TEMP%\ebrk_shim.log.
Available maps
Extracted from Grasshopper-WindowsNoEditor.pak's asset list
(/Game/RTS/Levels/*.umap). The client's main-menu background map
(/Game/FPS/Levels/Intro) and the _Landscape streaming sublevels (terrain
geometry loaded into Renegade_Map_02/03, not standalone maps) are not
listed — they aren't things you host.
| Map | Notes |
|---|---|
Renegade_Map_02 |
Real gameplay map, full GameMode/team/spawn wiring. |
Renegade_Map_03 |
Real gameplay map, full GameMode/team/spawn wiring. Shim default. |
Renegade_Map_04 |
Real gameplay map, full GameMode/team/spawn wiring. Also the game's own ServerDefaultMap in DefaultEngine.ini. |
Renegade_OnlyBuildingsTest |
Test map: buildings only, no tank/harvester/turret AI. Lighter on the game-thread tick than the populated test maps. |
Renegade_SmokeTest |
Test map, populated with Harvester/Turret/Tank actors — AI+anim tick load has been observed to stall the server's game thread. |
Renegade_SmokeTest_Empty |
Same as Renegade_SmokeTest but empty; lightest map shipped. |
DedicatedServerWaitingRoom |
The game's own lobby/staging map (not a combat map). |