Where I left off
For weeks now the work has been reading. Decompiled script, call graphs, packet captures against a server that isn’t there. Every post ended with “and next I’ll trace…” or “and next I’ll survey…”.
Not this one. This post, I started typing the server.
It does almost nothing yet, I want to be upfront about that. It listens on a network port, catches the client’s opening line, and prints out that it understood it. No reply. If you squint it’s a very elaborate way to run a program that says “yep, heard that.” But it’s the first piece of this whole project that’s code I wrote doing the job the dead server used to do, and after a month of highlighters that feels like something.
Two smaller decisions got made first, so let me clear those out of the way.
Housekeeping: the to-do list and the map
The native to-do list. Last post I traced the path from “client connects” to “character can move” and promised to turn the messy trace into a clean checklist of the C++ pieces I have to rebuild. Did that. It’s not exciting reading, it’s a checklist, but the shape of it matters: one big item (the actual network code, which I have to write from scratch), and then a row of small stuff that’s either a fake-it stub or a few lines of reimplementation. The one genuinely heavy dependency, the database layer, is only needed on the “faithful” route from last post. The shortcut route doesn’t touch it at all. Good to know before I pick.
The map. A Fury match needs a level to happen in. My client ships 37 of them. I want the smallest, simplest one to test against, so I went digging through the game’s own map table, it lists every level with its game mode, player count, and physical size.
The pick is EL1_Mortem. EL1 is one-versus-one elimination, the smallest
game mode in Fury, two players, simplest possible rules. Mortem is the smallest
of the four 1v1 maps on disk (8.8 MB; the others run 10 to 23 MB). The game class
behind it is 54 lines of script. When you’re trying to get anything working,
“two players, one round, smallest file” is exactly the corner you want to start
in.
One wrinkle worth calling out because it changed how I think about this. The plan
said “confirm the map loads offline.” Back in Phase 1 I found that if you launch
the client pointed straight at a level, most of them just hang on the loading
screen forever, they need backend services that aren’t there. Only the
character-creation screen has a proper offline mode. So does that sink EL1_Mortem?
No, because my server never loads the map at all. My server is a C# program. It doesn’t run the Unreal engine. When a real match started, the client loaded the level, after the server told it to. So the question isn’t “can I load this map offline,” it’s “can the client load it when my server tells it to travel there”, and I can’t test that until my server can tell the client anything. Which is the next milestone, not this one. For now: the map file exists, I know its name, moving on.
What I actually wrote
The server is two small C# projects. One holds the low-level bit-twiddling stuff for reading network packets. The other is the actual program: open port 7777, sit in a loop, and for every blob of data that arrives, try to make sense of it.
“Make sense of it” means two things right now:
Read the packet number. Every packet Unreal sends starts with a 14-bit counter. Not 16 bits, not a byte, fourteen bits, and they’re stored little-end-first, so pulling the number out is a fiddly bit-by-bit thing rather than “read two bytes.” I know it’s exactly 14 bits and exactly that layout because I decoded it by hand back in the handshake capture post. The counter ticks up by one on every packet, including the tiny keep-alive ones, which is how you know all the packets belong to one single conversation.
Find the text and check it. Fury’s opening handshake, unusually, is plain readable text,
HELLO P=1 REVISION=0 MINVER=2327 VER=2797. My server digs that line out of the packet and checks the four values against what I captured the real client sending. If they match, it says so. That version tuple is the thing a real server would check to decide “is this client too old to let in,” so getting the server to read and verify it is a real, if tiny, piece of the job.
That’s it. That’s the milestone. About a hundred lines.
Proving it works, twice
I didn’t want to just eyeball the output and call it good, so I checked it two ways.
Against the recording. Back in the capture post I had a Python script that decoded those handshake packets. I saved all 104 raw packets from that session. So I pointed the new C# server at those saved files and had it decode them cold, and it produced the exact same bytes as the Python did, on all 104. Same packet numbers, same text, same mystery bytes in between. If the new code disagreed with the known-good decode anywhere, that’d tell me I’d got the bit-layout wrong. It didn’t.
Against the live client. Then the real test: I started the C# server, and launched the actual unmodified Fury client pointed at it. And there it was in my server’s log:
| |
The client says HELLO. My server understands it. The client waits, resends the
HELLO about once a second (it never gets an answer), and after ten seconds
gives up (FAILURE, timed out), and the client process exits cleanly. Which is
exactly what it did against the do-nothing sink in the capture post. The
difference is that back then a dumb pipe was writing the bytes to a file. This
time a program I wrote caught them, unpacked the packet number, pulled out the
text, and checked it.
Small thing. But that’s the wall this whole phase is about climbing, and this is one hand on it.
Two ways I got it wrong first
Because it’s never that clean. Getting the recording test to pass took two tries.
The bug both times was the same shape, and it’s a classic. To find the readable
text in a packet, I scan for a long run of “printable” characters, letters,
digits, spaces, punctuation. In my first version I converted the raw bytes to
text first, then scanned. Sounds harmless. It isn’t: when you convert raw bytes
to text and a byte isn’t a valid character, most conversions quietly replace it
with a ?. And ? is a printable character. So my scanner would sail
straight through a stretch of garbage bytes that had all been turned into
question marks, decide it had found a lovely long run of text, and lock onto the
wrong part of the packet.
It worked fine for the HELLO packet, that one really is clean text, which is
exactly the trap. The FAILURE packet has some binary framing bytes right before
the text, and there my scanner face-planted, “reading” the framing as
question-mark text and reporting gibberish.
Fix: scan the raw bytes for printable values first, and only convert the actual match to text at the end. Second try, all 104 packets decoded clean. The lesson, which I have now re-learned maybe five times in my life, is that the convenient “just turn it into a string” step is where the information leaks out.
What I still can’t see
Between the packet number and the text there’s a chunk of bytes, the “bunch
header,” Unreal’s framing for a chunk of channel data. Nine bytes, and I don’t
fully understand them yet. For every HELLO packet they’re identical:
16 80 00 02 17 2a 00 00 00. That 2a is 42, which is the length of the
HELLO text including a trailing zero byte, so at least part of that header is
“here comes 42 bytes.” The rest (which channel, whether it’s a reliable message,
the sequence number) is in there somewhere but I haven’t pinned exactly which
bits are which.
I could guess. I’m not going to. There’s a rule on this project that unknowns about the protocol don’t get guessed into the code; they get written down as unknowns until I can prove them. And I can prove this one soon: the next milestone is to send the client a reply, and the fastest way to learn how the framing works is to build a reply, send it, and watch whether the client accepts it or chokes. The client is a very precise oracle. It just answers slowly.
Next up
- Reply to
HELLO. Work out that framing well enough to write it, not just read it. Send the client something back on the control channel and watch what it does, which is also how I finally learn what the rest of the handshake conversation actually says, since none of it is in Fury’s own code. - Then start walking the login sequence: get the server to formally accept the connection, and stand up that “reservation” the character needs to check into.