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.

In-match first-person view: two players facing each other, with minimap, compass, score header, round clock and weapon HUD all live.
Two players in the same round, both driven by the rebuilt server. Minimap, compass, the score and K/D/A header, the round clock, perks, killstreak slots and a live ammo count. The other player is a real client connected over the network, which is the part that needs every layer underneath to be correct at once.

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:

OffsetSizeValue / meaning
0x0008magic IWff0100
0x0084version u32 = 423 (0x1A7); the client rejects anything else
0x00C1flag u8 = 0x01
0x00D8two u32 = 0
0x015256RSA-2048 PKCS#1 v1.5 encrypted key blob
0x115N × 0x2000encrypted 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.

Hex-Rays pseudocode of the client's asynchronous archive read, showing the 0x40000 block cap, the overlapped read setup and the branch into the decrypting stream.
The client reading its own archives, decompiled. Names and types here are mine; the addresses in the trailing comments are the real ones. The 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.

symptomstate being zeroed
crouch/prone abruptps+352/356/360, netfields 92/89/81
mantle sticksthe mantle state
sprint anim flickersthe 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_ParseSnapshot and 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

End of a free-for-all match showing final placements for three players and the match reward.
The end of a match, which is the part that needs everything else to work first. Final placements for three real players, the round-over state and the match reward, all published by the server and drawn by retail's own end-of-round UI.
Post-match Action Report screen showing XP awarded, rank progression and crate progress.
The post-match Action Report. 35 XP, matching the match reward on the previous screen, plus rank progression and crate progress. Getting this screen to appear at all meant driving the whole end-of-match sequence the way retail drove it, rather than just ending the round.

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.