Where I left off

The server could listen. It caught the client’s opening line, HELLO P=1 REVISION=0 MINVER=2327 VER=2797, parsed it, checked the version numbers, printed “yep, understood that.” And then did nothing, because replying meant solving a problem I’d been carefully writing “open question” next to for two posts.

This post: it replies. And the client walks three steps further into the handshake without me touching it.

What was actually in the way

When the client sends HELLO, that text doesn’t just go on the wire naked. It’s wrapped. Unreal Engine 3 packs every message into a little structure called a bunch: think of it as an envelope with a header. The header says things like: is this a reliable message (must arrive) or throwaway? Which channel does it belong to? What’s its sequence number? How many bits of payload follow?

I could read the client’s HELLO bunch because my decoder just skipped past the header and hunted for the ASCII. But to send a bunch, I have to build that header myself, bit for bit, in exactly the layout this specific 2008 build expects. One bit in the wrong place and the client throws the whole packet away without a word.

And UE3’s bunch header format isn’t one fixed thing. It drifted across the engine’s life. The write-ups online mostly describe the later versions. Fury is early, April 2008, and it’s a licensee build, so Auran could have tweaked it. I didn’t want to copy a layout from a blog post and hope.

Not guessing: let the client show me

Here’s the move I’m happy with. I already had a perfectly-formed bunch header, captured from a real run: the client’s own HELLO. So instead of looking up the format, I reverse-engineered it out of those 53 bytes.

I sat down and bit-decoded the packet by hand, well, with a scratch script, but one field at a time, writing down “bit 14 is this, the next 10 bits are that.” Out came a candidate layout: a couple of flag bits, a 10-bit channel number, a 10-bit sequence number, a marker bit, a 12-bit length, then the text.

Then the test that makes it real. I wrote the encoder, the code that builds a bunch from scratch, and pointed it at the client’s HELLO parameters. If my understanding of the layout was right, it should spit out the exact 53 bytes the client sent.

1
2
3
want (53B): 0080052080c0850a000000521113d313085...dce0d40
got  (53B): 0080052080c0850a000000521113d313085...dce0d40
SELFTEST PASS: encoder reproduces the client's HELLO byte-for-byte.

Byte for byte. That’s not “probably right,” that’s “the bytes are identical.” This check now runs as server/ --selftest so it can’t quietly rot.

(The one field I still can’t fully explain is a single 1 bit that sits between the sequence number and the length. UE3 calls it bHasPackageMap. The client sets it on every control message, so I set it too, and everything works. I’ve written its name down and moved on.)

The experiment: say something and watch

With an encoder I trust, the plan was simple and a bit cheeky: send the client a plausible next message and see what it does. In the old Unreal handshake, the server’s answer to HELLO is CHALLENGE: normally “here’s a random number, prove you know the secret.” Fury’s HELLO had no random number in it anywhere, which was a hint. So I sent:

1
server -> CHALLENGE NONCE=0 VER=2797 MINVER=2327

and left the log running.

It answered

1
2
3
4
5
client -> HELLO P=1 REVISION=0 MINVER=2327 VER=2797
server -> CHALLENGE NONCE=0 VER=2797 MINVER=2327
client -> NETSPEED 15000
client -> LOGIN RESPONSE=-1812032818 URL=entry.fmap
client -> [ACK]  ... acknowledging the server's packet

Three new messages, none of which I had to prompt for individually. NETSPEED is the client telling the server how much bandwidth to send it. LOGIN is the client saying “okay, logging in now, here’s my challenge response, and I’m coming from the map entry.fmap” (which is the little loading-screen level it sits on before a match, exactly where Phase 1 said it’d be).

And that last line, the ACK, is the client acknowledging my packet. UE3 only acks a bunch it accepted as well-formed. The client didn’t just tolerate my message, it filed it as valid engine traffic. That’s the encoder confirmed from the other side.

For eighteen years this program has sent HELLO into nothing and timed out. Give it one sentence back and it just… continues. The whole rest of the conversation is still in there, waiting.

Two things that got easier

The challenge response is fake. RESPONSE=-1812032818 looked like a hash: the client proving it knows a shared secret. So I tried again with NONCE=12345. Same response. Tried with no nonce at all. Same response: -1812032818, every time. Whatever this build computes, it isn’t derived from anything the server controls. So my server doesn’t need to validate it; it can just accept LOGIN and move on. That’s a whole crypto dance I don’t have to build.

No session key. The client’s LOGIN carries a challenge response and a map name and nothing else. Back in Phase 2 I found a login gate that expects a SessionKey, a token the matchmaking server hands out as a reservation. It’s not here. That fits the theory that the gate is either handled in native C++ or switched off in this build, though I’m not calling it proven yet.

Next

The server’s next line is WELCOME, the message that tells the client “your match is on level EL1_Mortem, go load it.” That kicks off the client actually travelling to the arena map, reconnecting on the other side, and running the login path far enough to get a player slot. Somewhere past that, a character stands up in a level. That’s the whole game of Phase 3.

But today the server talks, and the client talks back. After a month of reading, that’s a real conversation.