The subject is a Raven Software first-person shooter, built on Infinity Ward's IW4 engine and operated in China by Tencent as a region-locked free-to-play service. It ran for most of a decade, and then the service was shut down. What's left is a client that boots, tries to authenticate against machines that aren't there anymore, and sits in a menu forever.
Getting that client to play again has been a goal of mine for a long time, and over about three months I got it there. This post is how.
I am never releasing the server publicly. This was a game I spent a lot of my childhood in, it is gone now, and I wanted to know whether I could bring it back for myself. That is the whole reason it exists. What follows is the interesting part of doing it.
I went in assuming the hard parts would be the crypto and the protocol. They weren't. The hard part was figuring out which reference to trust, and then being willing to throw out conclusions I'd already written down once a better reference showed up.
What was my goal?
Not a reimplementation. Writing a server that is approximately right is a well understood project and it would have taught me nothing.
What I wanted was 1:1 with retail. Not "close enough to play." Identical where it can be measured, and honest in the source about where it can't. If the real server sent a netfield, we send that netfield, with that encoding, in that order. Where I can't prove what the real server sent, there's a comment at the point of the guess saying so.
That standard is most of why the project was hard, and it decided every architectural question below.
How did I pick an approach?
There are three ways to give a dead game a server, and I tried two of them before the one that worked.
Option 1, bridge an existing server to the client. We pointed a Treyarch-engine dedicated server at the client. On paper they're cousins. In practice they're two engines
whose structs agree only by coincidence, and every place they disagree turns into a silent
gameplay bug instead of an error. You don't get a crash. You get a player who can't crouch and
no idea why. The offset table further down is the post-mortem on this:
playerState origin is at 0x1C on IW4 and 0x24 on Treyarch's
T5, and T5's usercmd buttons aren't even the same type.
Option 2, write the server from scratch. Then every behaviour has to be re-derived and re-implemented, and there's nothing to check the implementation against. In one session I had four separate defects that stayed invisible until the client's own retained functions were being driven correctly. A from-scratch server wouldn't have had those functions sitting there to be wrong about.
Option 3, make the client be the server. This is the one that won, and everything else in the project follows from it:
The game server is the real client, running as a server.
out.exe gets launched with -qa and
OLMOD_PROCESS_ROLE=dedicated, our plugin is injected into it, and the plugin calls
the game's own retained functions through validated adapters. PmoveSingle,
BG_UpdatePlayerDObj, BG_PlayerAnimation, DObjCalcSkel,
SV_LinkEntity, the trace, the weapon registry. We only implement the boundaries
the shutdown took away: auth, lobby, matchmaking, snapshot transport. Everything else is the
shipping game doing what it always did.
Those first three are not the game's own invention. Online services here run on Demonware, the publisher's backend middleware, and the client talks to it through the bdSDK: a login ticket, a lobby connection, then storage, marketplace, friends and matchmaking, each with its own task IDs and reply shapes. So half the job was never about the engine at all. It was standing up something a bdSDK client finds convincing enough to proceed past, in the right order, because it will not reach the part I actually cared about until every one of those services has answered.
The useful consequence is that when something is wrong, the answer is already in the engine's code somewhere, and the fix is almost always to drive the retained function the way the engine drove it. You get correctness you could never have re-derived.
Which build to attack is the other half of the question, and the two builds are targeted for different reasons. 0.0.2.2 alpha gets the clean-room C# stack because it's the only build that shipped with no anti-cheat, which means its memory can be read and patched while it's running. That turns testing a hypothesis from a deploy cycle into about five seconds, and it's the single biggest reason that stack moved as fast as it did. The latest version of the game gets the injected plugin instead, because it is the build the surviving demos were recorded against, and the demos are the specification.
Which reference do I trust?
Most of reverse engineering a dead online game is deciding which reference to ask. Getting that ranking wrong is expensive, and asking one source another source's question is how the kill feed cost me hours.
Here's the ranking that held up:
1 THE DEMO the only source exact to the SHIPPING build. The specification.
2 BUILD 2.3 / 3.3.3.3 earlier builds of the same codebase; structural matches
3 LIVE RETAIL VIDEO presentation and user flow, no packet bytes
4 IW4 (+ IW5/T5/…) the engine this title was built FROM, a different title
The demo outranks everything because it's the same build. The title's own demo format preserves what
a real retail server actually sent, frame by frame, including configstrings, netfield tables,
event streams and the exact ordering of a round's lifecycle. 85 retail demos survive. I wrote a
query engine over the corpus so that "what did the real server send here, and when" has an
answer instead of an argument, and its gap report prints both directions: what retail asserts
that we never send, and what we send that retail never asserted. It over-reports. It flagged
predictableEvents[0..3] as missing when they're actually written, because the
source spells them differently, so every row gets checked against the source before anybody
acts on it.
Earlier builds outrank IW4 because they're the same codebase. They are also not
symbolised, and I had to retract a claim in my own notes that said they were. The 2.3 IDB has
41 named functions out of 32,531, and no SV_ or MSG_ names at all.
Names established by earlier reverse engineering are worth having, but their presence in an IDB
doesn't make the image symbolised, and treating it as though it does will eventually cost you a
day. Related: the debug symbol file everyone assumed shipped doesn't exist. It's a build path baked
into the exe and nothing more. I've watched people go looking
for that file.
IW4 answers rules, not addresses. The title derives from Infinity Ward's IW4, so concepts, constants and GSC logic
transfer over fine, but an IW4 address means nothing in this image. The announcer and
battlechatter are a good example of what that's good for. Both of them got settled by reading
IW4's _globallogic and _battlechatter_mp GSC rather than by guessing
at packets: the 5000 ms lead-change floor, the 3-second dialog queue, the timesup
line at 30 s, the getTeamVoicePrefix(team) + "1mc_" naming. The engine function
tells you what a thing is called. The script tells you when it fires. You need both.
So, the short version of the ranking: the demo answers what is sent and when, the older builds answer what it's called and what it does, and IW4 answers what the rule is.
The failure mode all of this is guarding against has a particular shape, and it took me a while to learn to catch it in myself. A reference answers a nearby question very convincingly, and you take it. That's how IW4 did it is not an answer to how this build did it.
The four-packet freeze
This is my favourite bug in the whole project, because the symptom pointed in completely the wrong direction and I spent a good while convinced it was a snapshot content problem, which it could never have been.
The client joins, renders a frame, and freezes. messageAcknowledge sticks at
whatever low number it happened to reach, 4 or 8, and never moves again. Delta compression
collapses to FULL forever. Input and camera get more and more delayed. Eventually the session
drops with EXE_TIMEDOUT behind a flood of "client sent framing we do not
consume".
From the server side it looks like a completely healthy session, and that's the trap. The
client's rejection is silent. It sets msg->overflowed,
CL_PacketEvent rolls clc->serverMessageSequence straight back at
0x4676E7, nothing gets logged, and the packet just evaporates.
The cause was one empty string.
Netchan_BuildServerPacket(pkt, sizeof(pkt), s_sequence, 0,
body, len, s_challenge, ""); // <-- lastClientCommand
That argument isn't decoration. It gets stirred into the netchan XOR key, one character per byte:
key ^= (unsigned char)(c << ((i + encodeStart) & 1));
buffer[i] ^= key;
While the client hasn't sent any reliable commands yet, "" is correct. Every
byte contributes 0 << anything, so the key never advances and the packet
decodes fine. That's why the first few snapshots always get accepted. Then the client issues
its first reliable command, the two sides start keying differently, and every packet after that
decodes to garbage, including the svc opcode at offset 8.
CL_ParseServerMessage dispatches nothing at all and drops the message.
It's also the reason bisecting snapshot content can never find this. playerState fields, trailing sections, clientState, all of it gets parsed long after the byte that's already corrupt. I could have stared at payloads for a week.
What actually found it was an injected client-side trace DLL hooking
CL_ParseServerMessage, CL_ParseSnapshot and every section handler,
because a silent rejection has to be made loud before you can debug it. Broken session:
[pkt 1] clcSeq=427 clsState=10 outer rc=8 cursize=135 split=0 ovf=0
=> REJECT outer ovf=1 rc=8 cursize=8 CL_PacketEvent will roll serverMessageSequence back
The packet arrives intact, 135 bytes, exactly what the server sent, and not one handler
fires. CL_ParseSnapshot isn't even entered. A working session shows
[snap N], PREDICT: OK and per-section probes with ovf=0,
so the comparison is unambiguous once you can see both.
The fix went into the C# relay, since the relay is the only side that knows the reliable
command stream. Netchan.Code is a pure keystream, meaning the key evolves from the
seed and the command string and never from the buffer contents, so it's an involution. Run it
once to undo, once more to redo:
var seed = _bridgeChallenge ^ seq;
var body = reply.AsSpan(Netchan.ServerEncodeStart);
Netchan.Code(body, seed, string.Empty); // undo the C++ side's ""
Netchan.Code(body, seed, _bridgeLastCmdString); // redo it the way the client will decode
No bridge protocol change and no C++ rebuild. It ends up sitting right next to the
reliableAcknowledge patch that was already there for the same reason, since bytes
[4..8) are plaintext because the XOR starts at
ServerEncodeStart = 8.
BEFORE: snapshot 100 sent (body 127B, framed 135B) seq=101 ack=4 delta=FULL
AFTER: snapshot 500 sent (body 65B, framed 73B) seq=501 ack=500 delta=yes
ack tracks the sequence 1:1 now. The part that actually matters is that
delta=yes stays, because deltas only work if the client genuinely stored the
previous frame. In game, the input lag is gone and sessions stop dropping.
The general thing I took from it is that a system which throws away bad input without logging it will look healthy from the other side indefinitely. I didn't need a better hypothesis, I needed the instrument that made the invisible event visible.
Fastfiles, where the encryption was never the problem
Raven's title ships its assets as encrypted IWff0100 fastfiles and I expected this to
be the wall. There was a note in my own handoff that said offline extraction wasn't an option,
reasoning from the fact that the payload has no zlib magic, so Tencent must be encrypting them
the way they encrypt IFS. The observation was right. The conclusion was wrong, and I retracted
it.
The container turned out to be plain:
| Offset | Size | Value / meaning |
|---|---|---|
0x000 | 8 | magic IWff0100 |
0x008 | 4 | version u32 = 423 (0x1A7); the client rejects anything else |
0x00C | 1 | flag u8 = 0x01 |
0x00D | 8 | two u32 = 0 |
0x015 | 256 | RSA-2048 PKCS#1 v1.5 encrypted key blob |
0x115 | N × 0x2000 | encrypted payload blocks |
That's 21 plaintext bytes plus a 256-byte wrapped key, so 277 total, which is why every one
of the 238 files satisfies (filesize - 277) % 8192 == 0. Underneath it's standard
LibTomCrypt AES-192-CTR, and every piece of key material is embedded in the shipped client exe,
including the RSA private key that unwraps the per-file AES key. No server-side secret, no
hardware root, nothing to brute force. I confirmed the offsets twice, once measured against the
files and once read back out of the client's own parser at sub_4847E0 and
sub_4843F0, mostly because a result that easy made me suspicious of myself.
The map archive decrypts and inflates end to end, 2,788 blocks with every one
SHA-256 verified, into a 47,091,535-byte zone stream with real asset names and a fully
plaintext mapents.
0x40000 cap
and the branch into a decrypting stream are what told me the format was block-structured and
that the key material had to be resolved client-side, before I had any of the header layout.
And then the caveat that's the actual reason I'm writing this section down. The decrypted zone is an IW5 asset image at version 423, and the server we were briefly planning to load it into is a Treyarch T5 build at version 473. Decryption was never the hard part of getting the map archive into that server. The asset-struct translation is, and it's a much bigger job.
So the verdict on that spike was trivial, and it doesn't buy you what you wanted. I'd call that a successful spike anyway. Two days to find it out beats two months.
There's a second layer worth mentioning. Fastfile decryption and the IFS runtime dependency
are separate problems, and nobody had separated them. Once they were separated, all 974 archive
packages could be materialized and 107,546 external resource wrappers decoded into the ordinary
loose files the backend already expects, which means client and dedicated startup both run with
enable_iips=0 and no IIPS host process at all, while the secure .ff
files stay untouched. In-game map validation under that runtime is still outstanding, because
the night I set it up my desktop locked before the client could queue, and I haven't gone back
to it.
Zeroes, twenty times a second
Three movement defects that would not die. Crouch and prone transitions snapped instead of blending, mantles stuck, and the sprint anim flickered. I spent a long time treating those as three problems, which is exactly why they lasted as long as they did.
They were all one fact. We populate 33 of the 235 playerState netfields, and the engine's delta encoder baselines against our own previous snapshot. So every field we never set wasn't just absent, it was being actively pinned to zero on the client, 20 times a second. That's harmless for fields nobody reads and fatal for client-side state machines, which were having their state wiped and restarted twenty times a second.
| symptom | state being zeroed |
|---|---|
| crouch/prone abrupt | ps+352/356/360, netfields 92/89/81 |
| mantle sticks | the mantle state |
| sprint anim flickers | the weapState array |
Not one of those is fixable by adding a field, because the values that are missing are timers the client owns and we never receive. The only thing that computes them is running the movement model server-side, which is where the IW3 pmove port came from, and it's the clearest argument I have for why Option 3 won.
Picking IW3 as the port base started out as an assumption. It verified, and two of the claims got stronger once I actually went and looked:
IW4 IW3 T5
playerState origin 0x1C 0x1C exact 0x24 no
playerState velocity 0x28 0x28 exact 0x30 no
usercmd buttons 32-bit int32_t bitarray<51> no
prone button bit 8 0x100 exact needs translation
crouch button bit 9 0x200 exact needs translation
Origin and velocity I re-derived by counting bytes through IW3's
playerState_s, and they land exactly on 0x1C and 0x28.
The button bits were the new finding: IW3's PM_CheckDuck reads 0x100
for prone and 0x200 for crouch, which are precisely this engine's bits 8 and 9. That
table is also the retroactive post-mortem on Option 1, since the T5 column is the reason
bridging a Treyarch server was never going to hold. It's the comparison I'd want to run before
committing to an integration rather than after.
The movement bug I like most needed no reverse engineering at all, just reading a keybind
properly. Sprint never engaged. the stock bind is +breath_sprint, and that one
key sets both bit 1 for sprint and bit 13 for hold-breath. We were forwarding both of them
faithfully, hold-breath is an interfering button for sprint start, and so the sprint was getting
cancelled at the exact moment it began.
All five of the movement bugs were found by measuring, and in every single case my first two or three hypotheses were wrong.
How do I know any of this is right?
The alpha stack is where I got strictest about this, because it's a full reimplementation with a Python reference and a C# port, and a reimplementation will happily lie to you.
The gate is byte-identical output rather than a round trip. A round-trip suite can agree with itself while both halves are wrong, and that's exactly how a broken 16-bit field made it through two live sessions and two client crashes. So instead both encoders emit a canonical dump over a fixed set of scripted sessions, and the dumps get diffed. 394 artifacts on the game server, 43 on the lobby, and both diffs have to come back empty.
They're scripted sessions rather than independent calls, which matters, because state drift in either implementation then shows up as differing hex under a matching name. The Python tree is kept deliberately, since it's the thing the port gets checked against, so it has to keep working. Two encoders that were written independently and agree on every byte can't both be wrong the same way.
Under that there are 1430 + 63 self-test assertions that need no socket and no client,
including a type-2 boundary sweep of 186 single-field snapshots, the six captured usercmd
fixtures with four ablations each, and buffer-layout assertions that are checked against the
bytes rather than through a reader I wrote. Then there's a startup self-test that drives a
synthetic client through the real handlers, so NAT, INIT, COOKIE-ECHO with a live secp224r1
key, DATA and netchan, plus two genuine captured packets. Each server runs that before anything
else and refuses to start if any of it faults. A spawn of exactly (0,0,0) is the
not-found fallback for map data and it's treated as fatal, because a default that looks
plausible is worse than a crash.
Every wire constant in the C# carries a comment naming where it came from, whether that's a function address in the client, a named IW4 symbol or a specific measured capture, and anything that's still a hypothesis says so right there. That's not bureaucracy. It's the only reason I could retract my own wrong claims about symbols, fastfiles and netfields without unravelling everything that had been built on top of them.
The alpha's live gate, for the record. Full C# stack, no Python running anywhere. The client
reached connstate 9 ACTIVE, and its netchan incomingSequence of 1198
equalled our serverMessageSequence of 1198, meaning every message we sent was
accepted. 2873 usercmds across 400+ snapshots. It spawned at z=2062, fell to the
ground, and then walked, turned and jumped.
One more rule from this that I'd keep anywhere. 3.3.3.3, the latest version, 2.3 and the alpha get
separate projects, not one project with if (build == …) in it. Every wire constant
differs per build, and that's the most expensive bug class in the entire project. The port to the latest version
hit nine per-build constants in a single session and each one cost a live test cycle. Two
projects that look nearly identical is correct here. One project with a build switch is a bug
factory.
What I had to build to get here
Almost none of the above was reachable with off-the-shelf tooling, so most of the project by volume is instrumentation. The parts that mattered:
- A query engine over the retail demo corpus, which asks 85 surviving demos what the real server sent and when. Phases, configstrings, entity and playerState census, per-field diffs, gap analysis in both directions.
- An injected client-side trace DLL that hooks
CL_ParseServerMessage,CL_ParseSnapshotand every snapshot section handler, so a silent client-side rejection turns into a line I can read. This is the only reason the netchan bug was findable. - A standalone fastfile decryptor doing decryption and inflation with per-block SHA-256 verification, with one crypto dependency and nothing else.
- A zone walker and asset indexer covering 144,985 assets across 695 fastfiles, indexed with SHA-1 and path, plus extractors for clipmaps, string tables, models and GFX.
- The byte-identical dump harness described above, on both the game server and the lobby.
- Roughly 150 live probes and patch scripts. Read
connstate, watch snapflags, sweep camera lag, diff playerState between frames, freeze timescale, dump the weapon registry, read the entity baseline. The alpha having no anti-cheat is what makes that style of live measurement possible, and it's exactly why that build got picked as the clean-room target.
If I had to pick, I'd say the instrumentation is the real result here and the servers are what it produced.
What does not work
The honest column, and in a project like this it's the more informative one.
On the alpha stack there are no entities yet, so players can't see each other. The 66-field entityState tables are dumped and all six codecs are solved, so it's the same shape of work as the playerState encoder that's already done, it just isn't written. There's also no collision on the server, because there's no BSP. The floor is a height field sampled from the map's own spawn entities, so you can walk through walls. Each client still collides locally against the real map it loaded, so the cost is drift near geometry rather than the thing being unplayable, and that one is deferred on purpose rather than overlooked. No shooting, damage or scoring either.
On the latest version, FFA runs and most of the rest of the mode list doesn't.
One constraint I'd call a win rather than a gap: no game content is a build dependency. The zone dump is 1.6 GB and the servers need a few kilobytes out of it, so a 161 KB spawn table gets precomputed once and the servers read that. A clean checkout builds, self-tests and starts with no zone directory present at all, and the startup self-test asserts that instead of assuming it.
Summary
Three months or so of recorded work, 500+ commits across the two repositories, both of which started from existing community projects and neither of which did any of the above, and around 150 purpose-built diagnostic tools. A dead region-locked shooter now runs a real match against a real player at retail's tick rate, and a second build runs on a clean-room C# stack that's verified byte for byte against an independent implementation.
If there's one thing worth taking from all of it, it's to make the dead game's own code do the work, and to rank your references before trusting any of them. Driving retained engine functions beats reimplementing them and it beats bridging a different engine by a mile, and most of the hours I lost went into asking a reference a question it was never going to be able to answer.
A few smaller ones:
- A client that drops a packet without logging it will look like a healthy session from the server side forever. Build the instrument before the hypothesis.
- Two independent implementations compared byte for byte, not a round trip. A round trip lets both halves be wrong together.
- A spike that ends in "this doesn't get us what we wanted" is still a successful spike, as long as that's how you report it.
- Retract loudly. Four of the biggest unlocks here came from overturning something my own notes already asserted, so write notes you can be wrong in.