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.
| |
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:
| |
and left the log running.
It answered
| |
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.