How multiplayer works
The Dauntless executable is a client-only build. On its own it cannot host a game. Even so, Undaunted runs a shared Ramsgate and multi-player hunts on the genuine 1.4.4 client. It does this by loading a DLL into extra copies of the same exe, which turns each copy into a game server. This page explains why that works and how the pieces fit together. Everything here about Undaunted’s code applies to 1.4.4 only. For the evidence that the build is client-only, see Client internals.
Correction. After disassembling the 2.1.1 client, we once wrote that multiplayer was impossible with the shipped files. The facts behind that were right: the server entry points really are compiled out. The conclusion was wrong. The network layer beneath those entry points is intact, and an injected DLL can drive it. The last section below explains what we got wrong.
Contents
What was stripped, and what was not
A WITH_SERVER_CODE=0 build loses the functions that make a process become a server. It keeps most of what a server needs once it is one.
| Piece | State | Where (1.4.4 RVA unless noted) |
|---|---|---|
UWorld::Listen | Stripped: a “return false” stub | 0x789370 (2.1.1: 0x140bfde50) |
UWorld::InternalGetNetMode | Always reports NM_Client when a net driver exists | 0x378BDA0 (2.1.1: 0x1453e4ef0) |
GIsServer / GIsClient | Forced to “client” in FEngineLoop::PreInit | globals at 0x5E4BC3A / 0x5E4BC39 |
UEngine::CreateNamedNetDriver | Present | 0x371A5E0 |
UIpNetDriver::InitListen | Intact. It binds a UDP socket. | 0x806F80 (2.1.1: 0x140c6d200) |
Control-message handling (Hello, Login, Join) in UWorld::NotifyControlMessage | Intact | 2.1.1: 0x1453e5d40, with a full NMT_* jump table |
Game-mode login (PreLogin, PostLogin, spawning the player) | Intact | AArchonGameMode::PostLogin at 0x14B7460 |
Actor channels and ReplicateActor | Intact | CreateChannelByName 0x3449E10, ReplicateActor 0x327E860 |
| The engine’s own server replication loop | Not used by Undaunted | Its ServerReplicateActors_* helper names are absent from both exes. We read this as stripped too, but missing names are weak evidence (not proven). |
So the missing parts are the call that opens a listening net driver, the net-mode answer, the two global flags and, as far as we can tell, the server replication loop. Everything else is compiled in, but the stock client never calls it.
Undaunted’s design
| Component | Language | Role |
|---|---|---|
UndauntedLauncher | Electron | Checks the exe’s SHA-256, copies the two DLLs into Archon\Binaries\Win64 before every launch, and starts the client with the player’s account key |
UndauntedMetagame | TypeScript, SQLite (Drizzle) | The backend: login, characters, inventory, loadouts, progression, the matchmaking queue, an admin API |
UndauntedDeployServer | TypeScript (Express) | Starts and supervises game-server processes |
UndauntedInternalServer.dll | C++ (MinHook, a Dumper-7 SDK) | Injected into every game process, the client included. Runs in server mode or client mode. |
dxgi.dll | prebuilt proxy | Loads the DLL into the process |
Loading the DLL: the dxgi.dll proxy
dxgi.dllis a static import of the 1.4.4 exe. Adxgi.dllplaced next to the exe is therefore loaded when the process starts, beforeFEngineLoop::PreInit, even with-nullrhi. That timing matters, because theGIsServer/GIsClientpatches must be in place before the engine initialises.- The proxy ships prebuilt (11,264 bytes), and its source has not been published. How it loads the server DLL is our inference: the DLL exports a
DummyLinkFunc, which points to a link by import. Writing our own proxy is on our roadmap, partly because of the AGPL. - The DLL uses fixed RVAs and does no signature scanning. It has no version check of its own either. The launcher’s hash check is the only guard. Loaded into any other exe, the DLL would write into the wrong code. The 1.4.4 exe it targets has SHA-256
d3d41e614908d2befd518b27046d9822d6130ef12ba3504babbdb786bef9cff4, the value pinned in Undaunted’s launcher. None of the offsets match 2.1.1.
One DLL, two modes
The DLL picks its mode with one test: does the raw process command line contain -server? The test is a case-sensitive substring match on the whole line, the exe path included. There is no environment variable and no config file.
Server mode
The deploy server starts each game server like this. It passes positional arguments first, then three switches:
Dauntless-Win64-Shipping.exe <gameserver key> <port> <map path>
<behemoth class | NO_BEHEMOTH> <matchmaker hunt id | NO_MM_HUNTID>
<uid:huntid,uid:huntid,... | NO_EXPECTED_PLAYERS> <ip:port>
-EpicPortal -server -nullrhi
It needs at least eight arguments after the exe name. Otherwise it shows INVALID GAMESERVER ARGS and exits. The <ip:port> argument is parsed but never used. The DLL then does the following:
- Makes
Listensucceed. It does not replaceUWorld::Listen. InsideUEngine::LoadMap, at RVA0x372E746, it overwrites the 5-byte call to the stub withmov al, 1plus threenops.LoadMapthen continues as if listening had worked. - Sets the global flags.
DllMainwritesGIsServer = 1andGIsClient = 0. Five byte patches inFEngineLoop::PreInitstop the engine from overwriting them later. An earlier version rewrote the flags in a loop. The upstream commit “Don’t hog a core writing to GIsServer/GIsClient” replaced that loop with these patches. - Forces the net mode. It hooks
InternalGetNetModeto always returnNM_DedicatedServer(1). The upstream commit that added this is titled “Force netmode, fixes arrivals and basically ALL weapon bugs”. - Controls the command line. It hooks
FCommandLine::Get, so the engine sees a fixed-server -unattended -nullrhi -nosound -EpicPortal -RepDriverDisable. The engine never sees the positional arguments. Because the hook replaces whatFCommandLine::Getreturns, it also gets past the command-line allow list described in Client internals. - Boots straight into the map. It hooks
UGameMapsSettings::GetGameDefaultMapto return<map>?MonsterClass=<behemoth>?HuntId=<id>?PlayerHuntIds=<uid:huntid,...>, leaving out everyNO_*part. A game mode override arrives as?game=inside the map argument. - Opens the net driver. Three seconds after a world exists, a hook on
UGameEngine::TickcallsCreateNamedNetDriver("GameNetDriver"). The 1.4.4 config maps that name toIpNetDriver. The hook then callsSetWorld, and then the intactUIpNetDriver::InitListenwith the port from the command line. - Routes join traffic to the stock handlers.
InitListenis given the wrong notify pointer. A hook onUIpNetDriver::TickDispatchputs&World->NetworkNotifyinto the driver’s notify field on every tick. That sends incoming Hello, Login and Join control messages to the stockUWorldhandlers. The DLL has noPreLoginorLoginhooks and does not touch player identity. The handshake and the game mode’s login are Phoenix’s own code. - Replicates actors itself.
SetReplicationDriveris forced to null, and the engine’s replication loop is not used. On every tick the DLL builds a list of relevant actors and callsCallPreReplication. For each open connection, it creates an actor channel where one is missing (CreateChannelByName("Actor"),SetChannelActor) and callsReplicateActor. For the connection’s own player controller it also calls what we believe isSendClientAdjustment. Two helper hooks make every actor count as level-initialized for every connection, and no connection count as saturated. (The two hooks’ names are swapped in the source.)
Other server-side hooks:
- Player-start selection (fixed in source on 1 October 2026, live confirmation pending). Upstream’s hook replaced Phoenix’s native
GetPlayerStartForPlayerwith the firstAPlayerStartin the global UObject table. Reverse engineering the original 1.4.4 function at RVA0x1368660shows that Phoenix filtersAArchonPlayerStart::GroupNameand chooses among the matching starts withAArchonPlayerState::PlayerStartSlot. Ignoring both can select the wrong start/world; it is the leading cause of the observed bug where a player who falls off the map respawns underneath it. Our source now calls Phoenix’s original selector first, and only if it returns null falls back to anAArchonPlayerStartin the sameUWorld, preferring the requested group and the same slot rule. A VS2022 x64 candidate DLL builds successfully; it still needs the fall/respawn case verified in game before replacing the pinned DLL for everyone. - Ability activation RPCs are handled directly, and then also passed to the original handler.
- Stamina is ticked for every player pawn.
HasFinishedLoadingis forced true.- A byte patch labelled “Fixup Ramsgate Crash” makes one ability-input array read as empty.
How a server talks to the backend. Server mode adds an x-undaunted-gameserver-apikey header to every HTTP request the process makes. The metagame accepts that header, and if the request also carries a player’s bearer token, it attributes the request to that player. Server mode does not redirect any endpoint URLs, and the upstream code does not show how Undaunted’s servers reach the metagame. On our host, the server processes run under the same Windows account as the client. They read the same user Game.ini, which has quoted overrides for all 167 endpoint keys pointing at the metagame. See Client internals for why the quotes are required.
Self-shutdown. A hunt server calls exit(0) once it has spent a total of 50 seconds with no player connected. The counter is never reset, so it also counts loading time before the first player joins. The check is hard-coded off for ports 8776 and above. With PORT_RANGE_END=8777, as in our setup, those are the Training Dojo and Ramsgate ports. Upstream’s repository does not state its own value; this check suggests it is 8777 there too.
Client mode
Hunt loading investigation (1 October 2026). Live deploy logs show repeated native hunt exits with 3221225477 (0xC0000005) and 3221226505 (0xC0000409); saved game crash reports independently contain access violations. This is evidence of crashed game processes, not proof that every long load has the same cause. A source review also shows hunt allocation returning after process creation, without a game-ready acknowledgement. A missing destination can therefore leave a client trying to travel. A proper fix needs crash diagnosis and bounded, authenticated readiness/liveness handling; do not pretend that spawning a process means its map is ready, disable memory admission, or restart occupied worlds as a workaround. No live server/DLL change was made during this investigation.
The separate IGameClient popup is from the client’s EAC initialization. The launcher now passes the pinned client’s -NoEAC switch (0.1.11); that does not repair a crashed hunt or alter matchmaking. Player-side confirmation is still needed. The public-airship backfill and respawn candidate DLL tests remain open.
The player’s client loads the same DLL. In client mode, the first argument is the metagame’s host:port, with no scheme.
- Endpoints. A hook on
FConfigCacheIni::GetStringrewrites 167 endpoint keys tohttp://<metagame>/.... In every config section whose name containsMcp, it also setsProtocoltohttpandDomain/RedirectUrlto the metagame. That sends Epic account and OAuth traffic to the metagame too. - Gameplay helpers.
HasFinishedLoadingis forced true, Arena and Escalation hunts are unlocked, and aUConsoleis created on F2. - Joining needs no help. The client travels to the game server with its own, stock networking code.
The deploy server
The deploy server is a small Express app. Upstream has a single endpoint, POST /api/matchmaker/handle-matchmaking-for-player, which has no authentication, and listens on all interfaces. Our fork binds it, and the metagame, to 127.0.0.1 by default. It adds a second, read-only route, GET /gameservers, which lists the running game servers for the metagame’s ServerStatus. Both routes are still unauthenticated, and both answer 403 to any caller that is not on loopback or that came through a proxy. HTTP API has the details.
| Instance | Upstream | Our fork |
|---|---|---|
| Ramsgate | One shared, persistent process on PORT_RANGE_END, started at boot | Same, and started again when a player travels there while it is dead |
| Training Dojo | One persistent process on PORT_RANGE_END - 1, started at boot | Started the first time someone is matchmade into it. ENABLE_DOJO=1 restores the upstream behaviour. |
| Hunts | One process per matched group, on a port from the pool PORT_RANGE_BEGIN to PORT_RANGE_END - 2 | Same |
- Routing. Game mode
CITYgoes to Ramsgate.SHAREDwith the Training Dojo hunt id goes to the Dojo.ISLANDstarts a new hunt server. Anything else falls back to Ramsgate. - Hunts. A player hunt maps to a random matchmaker hunt. That supplies the behemoth and a random map from the hunt’s map list, plus a
?game=override where the hunt defines one. Trials pick a random row from the Hard or Elite table and always use thearena_ramsgate_00map. - Launching. Server launches are queued one after another, with a configurable delay between them.
- Supervision. Once a minute, a watchdog checks each process with
process.kill(pid, 0). If the Ramsgate or Dojo process has disappeared, it starts a new one. It never kills anything, because hunt servers shut themselves down. - A dead Ramsgate on demand (our fork, 23 September 2026, from Harmonic’s fork). Before it hands out Ramsgate or the Dojo (a
CITYtrip, the Dojo, or the fallback to Ramsgate), the deploy server checks the same way that the process is still alive, and starts a dead one first (PERSISTENT_WORLD_LIVENESS, on by default;0leaves it to the watchdog, as before). Before, a player could be sent to a dead Ramsgate for up to a minute. The boot, the watchdog and players’ requests share one launch per world, so there is never a second process on UDP 8777 or 8776, and the restarted process is stored as the world’s record (upstream kept the old, dead one). A restart on demand waits in the launch queue like a hunt does, about 10 seconds with the kit. - Crashes of the deploy server itself. Every game-server process has
errorandexitlisteners: a wrongGAMESERVER_BINARY_PATHis logged (“Game server on port N failed: …”) and the hunt’s port goes back to the pool, where it used to end the deploy server with an unhandled error. Every process end is one “exited with code” line. A failed start at boot is one fatal line and exit code 1 when the process later ends; the deploy server keeps serving, and the next trip to Ramsgate tries again. A matchmaking request whose game server cannot be started (no free port, a spawn failure) answers 500{"error": "no_game_server"}, and the metagame answers the players’ status polls withFAILED. - The reply. The deploy server returns
{host, port}as soon as it has spawned the process. It does not wait for the server to be ready. A new server only starts listening about three seconds after its world exists. Harmonic’s fork waits for the game’s UDP port first; we left that out for now, because it would hold the player’s travel request open for the whole server boot, the client’s time limit for that request is unknown, and a 60-second deadline could kill a Ramsgate that boots slowly (roadmap 4.6). The fork’s game-server log files were left out too: the server DLL gives the engine a fixed command line and redirects its output, so the files would stay nearly empty.
Our port layout on the host:
| Service | Address |
|---|---|
| Metagame | TCP 127.0.0.1:61000 |
| Deploy server | TCP 127.0.0.1:61001 |
| Game servers | UDP 8770–8777. Ramsgate on 8777, Dojo on 8776, hunts on 8770–8775. |
We moved the metagame off Undaunted’s development default of 60000 because another application on our host already uses that port.
The matchmaking handoff
client metagame deploy server game server
| | | |
|-- POST /candidate/join -->| | |
| | hunts: wait for 4 players, | |
| | or 20 s after the last join | |
| |-- POST /api/matchmaker/... -->| |
| | |-- spawn exe + DLL --->|
| |<------- {host, port} ---------| |
|-- GET /candidate/status ->| | |
|<-- MATCHING --------------| | |
|-- GET /candidate/status ->| | |
|<-- IN_PROGRESS + serverInfo {host, port} | |
| |
|== UDP: Hello / Login / Join (stock engine handshake) ============================>|
- The status reply. Undaunted’s
/candidate/statusreturnsMATCHINGuntil the server is known, thenIN_PROGRESSwithserverInfo {buildId, gameSessionId, host, port}. It asks the client to poll every 10 seconds (candidateStatusPeriodMillis: 10000). Our fork answersFAILED(withstatusReason: nulland noserverInfo) when the deploy server could not give a server: a 500 or 503, a reply without a host, a quoted port, a body that is not JSON or a dropped connection, each tested on the Ramsgate, tutorial and queued-hunt paths (the failure cases come from Harmonic’s tests). Upstream sent the group to host""and port 0 instead. - Grouping. Hunts that need matchmaking are grouped by player hunt id. A group is sent to the deploy server when it reaches 4 players, or 20 seconds after the last player joined. The 20-second check only runs when a client polls
/candidate/status. Hunt ids containingRamsgateorDojo, and empty hunt ids, skip the queue and go to the deploy server at once. - One queued join per player. The hunt server waits for every player it was told to expect. A player’s new join therefore replaces any older join of theirs that is still waiting, in any queue, and so does a join for Ramsgate, the Dojo or a party’s hunt. The expected-player list names each account once. Before 22 September 2026 a join that was never matched stayed in the queue, the same player’s next join was added next to it, and a two-player hunt expected three players: the airship countdown froze with both real players ready. A party’s members are still expected together, once each.
- The client’s side (2.1.1). We mapped the client’s handling of this reply in detail, but only on 2.1.1:
- The HTTP status must be 200–206.
portmust be a JSON number. A quoted port leaves it at 0, and travel then fails silently.hostmust not be empty, or the client keeps polling forever.CANCELEDandFAILEDstop matchmaking.- The client builds its travel URL as
%s:%d?ticket=%s?gameSessionId=%s?EncryptionToken=%s, with?between every option.
We have not re-derived these rules for 1.4.4, though Undaunted’s replies fit them.
Measured resource use
These figures are for 1.4.4 on our host, a desktop with an 8-core CPU and 32 GB of RAM, with one player:
| Process | RAM | CPU |
|---|---|---|
Ramsgate server (-nullrhi) | about 1.1 GB | about 0.2 of a core |
Tutorial hunt server (-nullrhi) | about 0.9 GB | not measured |
| Player’s client | 1.5–1.8 GB | not measured |
- We have not yet measured a 4-player hunt.
- Upstream’s history shows performance fixes (“Emergency optimizations”, “Small optimizations to replication loop”).
- The deploy server starts game processes with no memory limit. A per-server memory guard is on our roadmap.
- The same risk showed up on 2.1.1. An uncapped client loading the city reached 9 GB there. Capping the texture streaming pool and lowering the scalability settings held it to about 2.8 GB.
Why we once said it was impossible
Our 2.1.1 analysis proved, down to single instructions, that UWorld::Listen is a stub, that GetNetMode can only answer “client”, that GIsClient is forced on, and that the exe has no WindowsServer platform name. From that we concluded that no command line, config or patch could make the client host, and that real multiplayer would need a server build Phoenix never released.
The evidence was right. The conclusion was not. We treated “the stock code path cannot reach this code” as “this code cannot run”. What changed our minds was the report that other people were matchmaking into Ramsgate with the same files. Undaunted shows the core of what is needed on 1.4.4: one patched call, five byte patches, one net-mode hook, a direct call into the intact InitListen, a fix for the net driver’s notify pointer, and a hand-written replication loop. Its other hooks cover the command line, the boot map and gameplay details.
We have not tried the same on 2.1.1. None of Undaunted’s offsets carry over. The equivalent 2.1.1 functions we found (the Listen stub at 0x140bfde50, GetNetMode at 0x1453e4ef0, the GIsClient write at 0x140c0f683, InitListen at 0x140c6d200) suggest the same approach would apply there. That is unverified.
Open questions
- The proxy’s source. The
dxgi.dllproxy’s source is unpublished. We plan to write our own. - Prebuilt binaries. We have not confirmed that the prebuilt
UndauntedInternalServer.dllwas built from the published source. We pin the hashes of the prebuilt DLLs we run and plan to build from source. - Double ability activation. The server’s RPC hook activates an ability directly and then also calls the original RPC. Whether that activates abilities twice is untested.
- Four players. Normal and 4-player hunts are reported working in Undaunted’s history, but we have only run the tutorial hunt and Ramsgate ourselves.