They Made A Golf MMO With Sonic In it (Real!) (Not Clickbait!) (Only A Bit)

I got nerdsniped into reviving SEGA SPLASH! GOLF, a long-forgotten MMO that you've probably never heard of - I certainly hadn't.


Background

Software preservation is a pretty hot topic these days. Everybody gets frustrated, understandably, when games get pulled from online storefronts or those storefronts close down. A recent report from the Video Game History Foundation estimates that "87% of classic games are not in release"; that's dire.

"I'll just download a ROM!", you might say. Sure. That's great for offline games... but what about anything that requires a server?

One of the earliest examples of this issue is Nintendo's Satellaview, an accessory for the SNES that received a bunch of exclusive downloadable games via a satellite link. Most of this content is lost to time, except for what the community has managed to recover from the cached data on memory packs.

However, MMOs might just be one of the hardest hit genres. Let's dive into one!

Introducing SEGA SPLASH! GOLF

The game's title screen, showing the window to select a server - the only option is "CoolServer", subtitled "Ninji, 2023"

The 2000s had a glut of free-to-play MMORPGs that all seemed to be pulled out of the same playbook:

I'd never heard of SEGA SPLASH! GOLF before, but I was encouraged to look into it by biggestsonicfan, who supplied me with a copy of the game client.

According to the page on Sega Retro, SSG was released in 2008 in Japan, and shut down in February 2009 after less than 10 months. I guess that explains why I'd never heard of it - that's also an impressively short amount of time to keep a game running.

Anyway, I've got the game... what next? Well, we'll need to figure out how it works, and that means getting past the various layers of security on the game. There's also the added challenge of figuring out how the protocol works, with no existing servers or packet captures to use as a guide.

Security Measures

You might ask: "How much faff can there be on a game from 15 years ago that didn't even make it through its first year?"

Online games have been rife with cheating forever, in many forms. Shooters get wallhacks (that allow you to e.g. see players behind walls) and aimbots. Fall Guys requires Easy Anti-Cheat so that you can't just use memory hacks to double your speed. Hell, even the high score leaderboards for the jump-rope minigame in Super Mario Odyssey are full of fake scores.

The ideal way to thwart this is by having the server be authoritative, but that's not always easy - especially when timing is concerned. Imagine a version of Fall Guys where when you press the jump button, the server has to check whether you're allowed to jump before you can do it. The latency involved would make it unplayable!

So the only alternative is for the server (and the other players) to take you at your word when you say that you've jumped. As a result, developers will try to make it REALLY difficult for people to mess with the client and its assets.

Our silly little golf game is no exception! There's a launcher which is responsible for decrypting the game executable (stored in Splash.bin), and that executable is packed. The actual game also uses GameGuard, which I'll explain more on later. All the assets (models, textures, etc.) are in mysterious archive files where the names have been obfuscated.

Decrypting the Executable

They didn't bother obfuscating the launcher, so it's pretty easy to figure this one out. I threw it into IDA and searched for references to the filename, and quickly found this code:

  if ( !wcscmp(*(const wchar_t **)(dword_4780D4 + 4), L"GAME") )
  {
    Sleep(0x7D0u);
    if ( dword_4780CC == 4 )
      swprintf(v2, (const size_t)L" %s %s", *(const wchar_t *const *)(dword_4780D4 + 8), *(_DWORD *)(dword_4780D4 + 12));
    sub_403880(L"Splash.bin", L"SplashGame.bin", 0x100u);
    SetFileAttributesW(L"SplashGame.bin", 2u);
    CreateProcessW(L"SplashGame.bin", v2, 0, 0, 0, 0, 0, 0, &StartupInfo, &ProcessInformation);
    WaitForSingleObject(ProcessInformation.hProcess, 0xFFFFFFFF);
    Sleep(0x3E8u);
    sub_408AD0(L"SplashGame.bin", 0);
    CloseHandle(ProcessInformation.hThread);
    CloseHandle(ProcessInformation.hProcess);
    return 0;
  }

This is executing a SplashGame.bin process, which I don't have - so the unknown sub_403880 function is probably what decrypts it. Looking into that function, I see code which uses Windows API functions to read the file into a buffer, then it calls sub_401FD0 on part of that buffer, and then writes bytes out in three chunks. This looks like our suspect!

It's a C++ method on a class that's accessed via a global variable, dword_475308. By looking at the references to that variable, I can find the code that creates it. It passes the weird string "luiabyhcljwdhlaijflnaivure" to another method, which absolutely looks like a weird encryption key... but it also copies it into another buffer. What's going on?

Reversing Encryption Algorithms

Trying to figure out an encryption algorithm from disassembled or decompiled code can be awkward at the best of times because it'll often end up being optimised by the compiler (e.g. a tight loop being unrolled). I have a go-to trick for this: a search engine.

The method that accepts the key initialises the encryption algorithm's state from a set of arrays. I threw the very first value into a search engine.

DuckDuckGo search results for "0x243f6a88" - the first result is a Stack Exchange cryptography question, "How does the Blowfish algorithm key initialization work"

OK, so chances are very high that it's Blowfish. I looked it up, found some pseudocode in the Wikipedia article, and it looks like what's going on here.


I compared the launcher's code against the Blowfish pseudocode to identify the different operations, gave the functions nice names, and then I was able to figure out the steps involved:

I put this together into a nice Python script using the blowfish module.

import blowfish

with open('Splash.bin', 'rb') as f:
    data = f.read()

cipher1 = blowfish.Cipher(b'luiabyhcljwdhlaijflnaivure', byte_order='little')
key = b''.join(cipher1.encrypt_ecb(b'luiabyhcljwdhlaijflnaivure\0\0\0\0\0\0'))
cipher2 = blowfish.Cipher(key, byte_order='little')

with open('SplashDecrypted.bin', 'wb') as f:
    f.write(b''.join(cipher2.decrypt_ecb(data[0x100:0x200])))
    f.write(data[:0x100])
    f.write(data[0x200:])

And that's the first layer defeated!

I originally tried using PyCryptodome here, but it didn't work. Turns out Blowfish cares about endianness, but PyCrypto (and ergo PyCryptodome) never exposed this and just always do big-endian computations - whereas Splash.bin is encoded using little-endian Blowfish. The more you know!

Unpacking the Executable

We still can't do much with the EXE in its current form; everything is unreadable. SSG is packed with ASProtect, which compresses and encrypts everything in it. The unpacking code is deliberately obfuscated to make it a pain to reverse-engineer, and it also includes other fun tricks like debugger detection.

Writing an actual unpacker for ASProtect is possible, but it's a lot of work for little reward. It's much easier to use a debugger like x64dbg or OllyDbg, along with a plugin like ScyllaHide that will get you past the debugger detection.


I didn't unpack the Splash Golf game executable myself, so I can't go into much detail on the exact procedure. However, the general steps are along the lines of:

I tried to reproduce it myself for the sake of writing this post, but I'm on an ARM MacBook and as it turns out... debugging obfuscated x86 code running in Windows's x86-on-ARM emulator is a very brittle process. So you'll have to take my word for it that it's possible. Please? 🥺👉👈

GameGuard

Last but not least... trying to launch the game, in either its packed or unpacked form, will immediately start GameGuard. If you're not familiar with it, it's an anti-cheat system that uses a host of techniques to try and ensure that you're not doing anything unsavoury with the game.

For a laugh, throw the name dump_wmimmc into your favourite search engine (that's the name of GameGuard's rootkit driver) and you'll get lots of results where people complain about GG crashing their systems, sometimes without even realising that it's GG's fault.


GG has two tricks up its sleeve. Firstly, it includes a system they call CS Auth. The game server sends encrypted challenge packets to the client, which the client passes along to GG. If GG is running and satisfied with the game's state, it will generate a response, which will then be passed back to the server. If the client doesn't provide a valid response, then something fishy must be going on.

Secondly, GG is packed with Themida, which is basically the 2000s version of Denuvo. Themida goes beyond what ASProtect does, because it also allows developers to mark specific code regions as sensitive - this in turn makes it do Fun Things™ to that code. All of the interesting logic in GG, including the stuff for CS Auth, takes advantage of this Themida feature that turns it into encrypted bytecode for a custom virtual machine.

It's nuts. Thankfully, for this project, we don't actually have to care. If we were connecting to the official Splash Golf servers then we'd need to run GG for the sake of CS Auth, but they're long gone, so we can just strip it out and nobody will know.


Neutering GameGuard requires three changes:

  1. Disable the function that initialises the GameGuard client-side API ("NPGameLib"), and the associated "quit if failed" check
  2. Disable the block in the game's main loop that checks every 10 seconds to see if GG is still running
  3. Disable the call that sends the player's user ID to GameGuard on login, and the associated "quit if failed" check

I'm not gonna say it's trivial, because figuring out where these checks are takes a bit of work - but once you know where to look, a few carefully-placed NOP instructions will do the trick.

Let's Fix The Game!

With GameGuard taken out, we can now get the game to launch, right?

... Right?

Nope - it crashes after a couple of seconds, before rendering anything. So much for that.

Debugging

When I looked at the decrypted game executable in IDA, I saw a ton of debug messages, which is always great to see when you're digging into an unknown executable. Can I get my game to spit them out?

The answer is yes, and it's hilariously easy. Splash Golf uses a plain text configuration file, splash2.cfg. Adding the line Eighty-One = 244 enables all debug logging.

=============================SplashGolf Started.============================
08/18/23 10:30:01  SGCore Started.
08/18/23 10:30:01  SEGA SPLASH! GOLF  version 0, 956, 4, 12
08/18/23 10:30:01  --- Version.h : CR_VERSION = 956
08/18/23 10:30:02  Window Initialize Successful.
08/18/23 10:30:02  ::SetWindowPos(rect(1062, 217, ww:806, wh:632)) winw:800 winh:600
08/18/23 10:30:03  dev:AvailableTextureMem(D3DMan::Init()時) -> -1
08/18/23 10:30:03  DirectD3D Initialize Successful.
08/18/23 10:30:03  DisplayMode[0]= 800: 600
08/18/23 10:30:03  DisplayMode[1]=1024: 768
08/18/23 10:30:03  DisplayMode[2]=1280: 960
08/18/23 10:30:03  DisplayMode[3]=1280:1024
08/18/23 10:30:03  DisplayMode[4]= 960: 600
08/18/23 10:30:03  DisplayMode[5]=1280: 720
08/18/23 10:30:03  DisplayMode[6]=1280: 800
08/18/23 10:30:03  CsvRead Load OK [data\sound\_text\se_list\config.csv]
08/18/23 10:30:03  CsvRead Load OK [data\sound\_text\se_list\loop_se.csv]
08/18/23 10:30:03  CsvRead Load OK [data\sound\_text\voice_list\config.csv]
08/18/23 10:30:03  CsvRead Load OK [data\sound\_text\bgm_list\bgm.csv]
08/18/23 10:30:03  CsvRead Load OK [data\sound\_text\bgm_list\bgm_env.csv]
08/18/23 10:30:03  CsvRead Load OK [data\sound\_text\bgm_list\bgm_env.csv]
08/18/23 10:30:03  CsvRead Load OK [data\sound\_text\bgm_list\bgm_env.csv]
08/18/23 10:30:03  CsvRead Load OK [data\sound\_text\se_list\se_all_round.csv]
08/18/23 10:30:04  CsvRead Load OK [data\sound\_text\se_list\se_all_round.csv]
08/18/23 10:30:05  CsvRead Load OK [data\sound\_text\se_list\se_all_round.csv]
08/18/23 10:30:06  CsvRead Load OK [data\sound\_text\se_list\se_all_round.csv]
08/18/23 10:30:06  CsvRead Load OK [data\sound\_text\se_list\jingle_all_round.csv]
08/18/23 10:30:07  CsvRead Load OK [data\sound\_text\se_list\se_cheer.csv]
08/18/23 10:30:07  CsvRead Load OK [data\sound\_text\se_list\se_applause.csv]
08/18/23 10:30:07  CsvRead Load OK [data\sound\_text\voice_list\system_voice.csv]
08/18/23 10:30:07  data\common\pattern\wood01.dds
08/18/23 10:30:07  data\common\window\w09s.dds
08/18/23 10:30:07  data\common\window\w09a.dds
08/18/23 10:30:07  CsvRead Load OK [data\common\emoji\emoji.csv]
08/18/23 10:30:07  DialogTask Started
08/18/23 10:30:07  DialogTask Started
08/18/23 10:30:07  CsvRead Load OK [data\console\Shop\ShopItem\CarryItem_Parameter.csv]
08/18/23 10:30:07  ReadCarryItem_Parameter() OK
[...]

Having logs was definitely useful, but at this point I didn't understand enough about the game to actually track down where the issue could be. Did it dislike something on my more modern system? Did I screw something up when patching out GameGuard? Sometimes it would just crash, and sometimes I'd get a "Buffer overrun" error in a message box.


I resorted to the trusty x64dbg to see what the game was doing when it crashed. It was loading a CSV file.

After carefully mapping out the game's CSV reading code in IDA, I realised what was going on... Splash Golf makes very heavy use of CSV files to store lots of things: UI layouts, game data, and in this case, a table mapping internal error codes to human-readable strings.

All the CSV files are stored using Windows codepage 932 (Microsoft's weird variation of Shift-JIS). The game calls the Windows function MultiByteToWideChar to get this data into Windows's UTF-16 encoding, but crucially, it does not specify an encoding.

A parody of the "oh no" comics, titled "a pre-unicode story". A pink blob happily says "Hello" in Japanese. The orange blob receives it in mangled Latin characters and puts their arms up in confusion. In the final panel, the two blobs are sideways and sharing the same "oh no" speech bubble.

(with my apologies to Alex Norris for defiling their comic concept)

Normally, this sort of mixup would mean that, say... instead of seeing "私はUnicodeが大好きです", we'd see something like "Ž„‚ÍUnicode‚ª‘å�D‚«‚Å‚·". Ugly and unreadable, but not damaging.

However, if we mis-decode "私は" (two characters) as "Ž„‚Í" (four characters), things can go pear-shaped quite fast. Splash Golf reserves a nice array on the stack of 128 characters, and this is enough for all of the messages in the game, but only if they're decoded properly.

I fixed this with a tiny patch so that MultiByteToWideChar was called with 932 as the first parameter (the ID of the Japanese codepage) rather than 0. Doing this allowed me to get to the login screen on my non-Japanese Windows installation!

Logging In

No matter what I enter into the login screen, it just times out. The game servers are, of course, long dead. Figuring out our next steps is going to require some more intensive reverse-engineering, so it's time to dive into IDA.

If Splash Golf were written in something like Unity or Java, then we'd have names for all the classes and methods (sometimes obfuscated, but you can still get an idea of the structure). But it's not, it's C++, so all we get is a pool of about 10,000 unnamed functions, and that's daunting as hell.


I like to approach RE projects in the same way that I might approach a Sudoku puzzle or a Picross puzzle - what are the easiest steps I can take at this point?

In a Sudoku puzzle, I'd look for the rows, columns and boxes that have the smallest amount of blank squares, and begin there. If there's a row where there's only one empty square, then I immediately know what to put in it, and hopefully that helps me solve something else. Repeat until you win.

In a Picross puzzle, if there's 15 rows and I see a column that just contains "13", then I know it's going to contain a contiguous block of 13 filled squares. This means I can confidently fill in the 11 middle squares, because those will be filled no matter what.

It might not seem like it, but you can apply similar tactics to reversing a large project.

Searching for Context Clues

On many platforms (including Windows), the C/C++ compilation process automatically strips out all the function and class names in non-debug builds, because you don't need them to actually run the compiled code. There's lots of other clues we can go off, though.

Imported functions from the OS and from other DLLs are an easy first step. Graphics-related code can be identified by looking for calls to the Direct3D functions. Splash Golf loads OpenSSL from a DLL, so references to functions like SSL_connect and SSL_write will let us find any network code that uses SSL.

If I want to find out how a file format works, then I can look for strings that refer to filenames. If I know that a particular file always begins with the magic bytes, say, 12 34 56 78, then I can search the executable for that constant and I might find the code that parses that file format.


I also mentioned above that Splash Golf includes a lot of debugging output, and this is an absolute goldmine for reverse-engineering.

Consider this pseudocode:

char sub_585850(void *a1, void *a2) {
    a2->dword_D1C = a1->dword_4;
    a2->dword_D20 = a1->dword_8;
    sub_5CDD30(a1);
    if (a1->dword_8 > 0) {
        char v1 = sub_58CCC0(a2, a1->dword_8, a1 + 0xCC, 0, a1->dword_4, -1);
    }
    SetEvent(a2->dword_10);
    sub_63A460(a1);
    return 1;
}

I can't learn much from this function, at least not by itself. It's calling the Windows function SetEvent, which implies that this code is involved in some multithreaded nonsense, but that's really all I know.

But... I lied, that's not the actual implementation of sub_585850. The actual code contains a bunch of debug messages. It actually looks like this:

char sub_585850(void *a1, void *a2) {
    a2->dword_D1C = a1->dword_4;
    a2->dword_D20 = a1->dword_8;
    debug(L"ACK_IDPASS_G[pid:%d][cid:%d][uid:%d]", a1->word_2, a1->dword_4, a1->dword_8);
    sub_5CDD30(a1);
    if (a1->dword_8 > 0) {
        debug(L"ACK_IDPASS_G AddUser[pid:%d][cid:%d][uid:%d][name:%s]", a1->word_2, a1->dword_4, a1->dword_8, a1 + 0xCC);
        char v1 = sub_58CCC0(a2, a1->dword_8, a1 + 0xCC, 0, a1->dword_4, -1);
        debug(L"ACK_IDPASS_G AddUser Result[%d]", v1);
    }
    SetEvent(a2->dword_10);
    sub_63A460(a1);
    debug(L"ACK_IDPASS_G Success");
    return 1;
}

Right away, I can draw a couple conclusions from just these messages:

The only reference to sub_585850 is a massive array of function pointers, and many of them contain similar messages. As it turns out, each of these functions corresponds to one packet that the game server can send. a1 is a pointer to the data within the packet, and a2 is a pointer to the class that handles a network connection.

I made a slight simplification above - there's no actual "debug" function in the compiled code. I think the original source code used a C preprocessor macro which got expanded out every time a debug message was sent... so each "debug" call is actually 15 lines of code which checks to see if a global logging object exists, enters a critical section, calls multiple methods on that object, and then leaves the critical section.

Writing a Login Server

I began by looking through the game's strings to find out where it connects to. There is a function that just returns the string sg-swsv.sp-golf.jp, directly above a function that just returns the value 2050, so I'm pretty sure these are the game's server and port.

All the functions surrounding these two are related to the game's configuration, as stored in splash2.cfg, but these two just return hardcoded values. I suppose that makes sense, the developers wouldn't have wanted to make it easy to connect to an unofficial server.

I wrote a tiny Python script which listened on port 2050 and printed out what it received, and I told the game to connect to it. I then tried to log in, and I was... very confused.

The game sent me some data, but it didn't match up with what I expected the format to look like. There was no readable username/password, and there was some nonsense that seemed to change every time I tried logging in.

Well, at this point, I hadn't realised that Splash Golf used SSL, so I was actually receiving a SSL handshake which I just didn't recognise. This is annoying, it means I'll have to create a certificate, and obviously I can't create a valid one for Sega's domain.

oh no

Oh wait, it's fine, they don't actually validate the server's certificate in any way, shape or form. I generated a self-signed certificate and the game accepts it just fine, and sends me a login packet. Nice.


Conveniently, the packets are arranged in a fairly sensible order, and by looking at the debug messages I was able to piece together the flow.

The client begins by sending packet 1 (or SEND_IDPASS), which contains the user ID, password and client version number. The login server replies with packet 2 (ACK_IDPASS), which simply contains a single byte representing success or failure.

The client then sends packet 3 (no name seen in the debug messages). The login server replies by sending one instance of packet 4 (SEND_GMSVDATA) for each game server, which contains a number, IP address, port, 'EncKey', name, comment, and current/maximum player counts. It then finishes up with packet 5 (ACK_GMSVLIST), which includes no data.

I wrote some basic code which would reply to SEND_IDPASS with a success packet, and to SEND_GMSVDATA with a couple of fake packets. I then connected to it and my fake server showed up - as shown in this screenshot from the beginning of the post!

The game's title screen, showing the window to select a server - the only option is "CoolServer", subtitled "Ninji, 2023"

One hurdle down. Next step: let's try and piece together how the rest of the game works.

Recovering the Game Flow

At first, I approached this in a bit of a silly fashion - the game itself didn't ship with any documentation, and I couldn't find any online (especially not in English), so I decided to just see if I could figure it out from the debug messages and code. Most importantly, there's an array that maps each packet type to a function that gets called when it arrives.

There are 339 types in all, but not all are used, and most of them only work in one direction. Type 1 is SEND_IDPASS which the client uses to send credentials to the login server - but if the client were to receive this packet, it would just output the message "Invalid Packet(%d type:%d) Received." to the log.

Packets!

Here's a table listing some of the more interesting server-to-client packets in this array, along with what I could learn from looking at them and their debugging strings.

ID Name Notes
7 ACK_IDPASS_G Contains cid, uid and name fields, in a big structure that gets copied to a global variable
9 ACK_CHG_MODE Contains a mode field; a negative mode value appears to indicate some kind of different (error?) state
11 SEND_LOBBY_NUM Contains a cnt field which is stored into the connection object
13 SEND_LOBBY_DATA Contains fields for num, a name and two member counts; gets copied into an array
15 ACK_ENTER_LOBBY Contains a ln field
17 ACK_MAKE_ROOM Contains a num field
19 (No name listed) Contains a structure; some of the fields are mode, lobby, room, flag, member (2x), watcher and a string
21 ACK_ENTER_ROOM Contains a structure; some of the fields are No., max and now
23 SEND_ULIST Contains a structure with fields cid, uid, stat, mode, ln, rn and a string
25 ACK_EXIT_ROOM Contains one value, and the debug log says OK or NG depending on what that value is
26 SEND_USTAT Contains just cid, uid and stat
27 SEND_MESSAGE Feeds its input into a method on a global object called "CHAT_TASK"
29 (No name listed) Just clears a couple of global variables
30 (No name listed) Feeds a bunch of its fields into the same data that Packet 19 sets
32 ORD_GAMESTART Contains a ton of data that I'll deal with later

This is not an exhaustive list, of course, but we can already begin to map out some of the game's workings - especially by combining this info with what we can learn from log messages in other parts of the game.

ACK is short for acknowledgement, which means that those packets form the server's response to a corresponding packet sent by the client. Conveniently, these always seem to have consecutive IDs - note the conspicuous gaps in the table above!


So there's the concept of "modes", "lobbies" and "rooms". ACK_CHG_MODE stores the mode into a field in the connection object, that makes sense.

Here's an example of one of these packet handling functions, annotated with the info I've built up about the structures.

char __cdecl HandlePacket_13_SEND_LOBBY_DATA(SendLobbyData *a1, Connection *a2)
{
  LobbyData *p_data; // ebx
  void (__stdcall *v3)(LPCRITICAL_SECTION); // esi
  void (__stdcall *v4)(LPCRITICAL_SECTION); // esi
  __int16 v5; // cx
  LobbyData **v6; // edi
  wchar_t Buffer[128]; // [esp+10h] [ebp-104h] BYREF
  void *v9; // [esp+110h] [ebp-4h]

  v9 = (void *)dword_91A0A4;
  p_data = &a1->data;
  swprintf(
    Buffer,
    L"SEND_LOBBY_DATA(pid:%d)(num:%d[%s][member %d/%d]) received.",
    a1->hdr.pid,
    a1->data.num,
    a1->data.name,
    a1->data.member,
    a1->data.member_max);
  if ( ErrorLogger_ptr )
  {
    v3 = EnterCriticalSection;
    EnterCriticalSection(&ErrorLogger_ptr->critSec);
    ErrorLogger_ptr->flags = 3;
    v3(&ErrorLogger_ptr->critSec);
    ErrorLogger_writeTimestamp(ErrorLogger_ptr);
    ErrorLogger_writeTextW(ErrorLogger_ptr, w_space);
    ErrorLogger_writeTextW(ErrorLogger_ptr, &w_null);
    ErrorLogger_writeFormat(ErrorLogger_ptr, &w_null);
    ErrorLogger_writeTextW(ErrorLogger_ptr, w_space);
    ErrorLogger_writeTextW(ErrorLogger_ptr, Buffer);
    ErrorLogger_writeTextW(ErrorLogger_ptr, w_newline);
    v4 = LeaveCriticalSection;
    LeaveCriticalSection(&ErrorLogger_ptr->critSec);
    v4(&ErrorLogger_ptr->critSec);
  }
  qmemcpy(PartyInfo_ptr, p_data, 0x48u);
  if ( a2->curMode == 1 )
  {
    v5 = 0;
  }
  else
  {
    if ( a2->curMode != 2 )
      goto LABEL_10;
    v5 = 1;
  }
  v6 = &a2->lobbyDataArray[v5][p_data->num];
  if ( !*v6 )
    *v6 = (LobbyData *)operator new(0x48u);
  qmemcpy(*v6, p_data, sizeof(LobbyData));
LABEL_10:
  SetEvent(a2->signals[4]);
  handle_SEND_LOBBY_DATA(a1);
  return 1;
}

The Connection object contains a field I called lobbyDataArray, which has space for two sets of 16. This code stores the received data into one of those, based on the mode and the num field.

This tells me two things. First, the game is prepared to receive multiple copies of this packet, so this is probably "here's all the lobbies that exist!" (in a similar fashion to SEND_GMSVDATA for the server selection menu) and not "this is the lobby that you're in". Secondly, lobbies only matter for modes 1 and 2, and there's distinct sets for each mode.


What about rooms? We don't have a helpful name for Packet 19, but it contains a structure with a bunch of data that gets passed to a method on the "PartyInfo" singleton class. This method's only debug message is partially in Japanese, but machine translation says that "ルーム情報" means "Room information", which seems pretty plausible.

Our unnamed packet 30 helps tie things together in a more meaningful way as it's got a whole bunch of debug messages. IDA's decompilation output is over 200 lines, so I'll instead present you with my own decompiled pseudocode:

bool PartyInfo::handlePkt30_RoomStat(RoomStat *roomStat) {
    RoomData roomData = this->lastPkt19Data;
    bool success = false;

    debug(L"■□■↓↓↓ROOMSTAT (room:%d[%d/%d] course:%d season:%d)",
          roomStat->room, roomStat->member, roomStat->memberMax, roomStat->course, roomStat->season);

    if (roomData.room != roomStat->room || roomData.room < 0)
        return false;

    if (roomStat->memberMax != 0 || BigObj->globalInfo.mode == 3) {
        roomData.flag = roomStat->flag;
        roomData.rules = roomStat->rules;
        // ... elided for brevity
        // all the fields from the roomStat packet are copied into the roomData structure
        success = this->handlePkt19Data(&roomData);
    } else {
        // TL: "●Room disbanded! (1)"
        debug(L"●ルーム解散!(1)");
        debug(L"  ROOMDATA: number[%d]", roomData.room);
        debug(L"  ROOMSTAT: number[%d]", roomStat->room);
        debug(L"  ROOMSTAT: member_max[%d]", roomStat->memberMax);
        debug(L"  PlayerParam.Mode: [%d]", BigObj->globalInfo.mode);
        // this is a method on PartyInfo, but they use the global pointer here anyway :p
        debug(L"  PartyInfo.Mode: [%d]", PartyInfo->curMode);

        cMyRoomTask::RoomBreakUp();
        this->isRoomDissolved = true;
        this->lastPkt19Data.room = -1;
        this->curRoom = -1;

        // TL: "Forced dissolution!"
        debug(L"強制解散!");
    }

    debug(L"■□■↑↑↑ROOMSTAT");
    return success;
}

Now I know that the structure supplied by Packet 19 (and by ACK_ENTER_ROOM) is called "RoomData" and the structure supplied by Packet 30 is called "RoomStat", and all its fields are a subset of RoomData.

Yeah, that makes sense. I'm getting ahead of myself though... can I figure out enough to get the game past the login screen? That's probably a more sensible first step.

Naming is Hard (see: "StuffF9")

Remember how the login server gives the client a list of game servers? For maximum ease, I just told it that the game server was at the same IP address and port. Doing this gets me packet 6, or SEND_IDPASS_G, which is conveniently in the same format as the login server's SEND_IDPASS - it's just the user ID, password and client version number.

Of course, it's not as simple to implement ACK_IDPASS_G, because it includes a whole-ass structure. I named it StuffF9 because it was 0xF9 bytes big and I didn't have a better name for it yet - trust me, it's not the worst name in my IDA database.

Sidenote about pid: this is a field in the packet header. For most packets, it's just a number that increases by 1 with each packet sent, wrapping around after 0x7FFF. However, there are a few packet types where the PID value is meaningful and is used by the client to distinguish which of the game's systems is expecting a specific reply.

// Another pseudocode decompilation from yours truly
bool HandlePacket_7_ACK_IDPASS_G(AckIDPassG *packet, Connection *conn) {
    conn->my_cid = packet->stuff.cid;
    conn->my_uid = packet->stuff.uid;
    debug(L"ACK_IDPASS_G[pid:%d][cid:%d][uid:%d]", packet->hdr.pid, packet->stuff.cid, packet->stuff.uid);

    // this function is near the code for ServerSelectTask, and just copies the StuffF9
    // object into a global variable
    HandleAckIDPassG_1(packet);

    if (packet->stuff.uid > 0) {
        debug(L"ACK_IDPASS_G AddUser[pid:%d][cid:%d][uid:%d][name:%s]",
              packet->hdr.pid, packet->stuff.cid, packet->stuff.uid, packet->stuff.name);
        bool result = conn->addOrUpdateUser(packet->stuff.uid, packet->stuff.name, 0, packet->stuff.cid, -1);
        debug(L"ACK_IDPASS_G AddUser Result[%d]", result);
    }

    SetEvent(conn->signals[1]);

    // this function does a bunch of shenanigans if the 'pid' is 252
    // this only occurs if the client is switching servers to join a room
    //  (I figured this out later on in the project - sorry to break the post's narrative like this)
    HandleAckIDPassG_2(packet);

    debug(L"ACK_IDPASS_G Success");
    return true;
}

I followed the flow of the data from the "StuffF9" structure and annotated the fields I could in order to put together some info - and then I tried sending the game a placeholder packet. I picked arbitrary numbers for the three IDs (cid 500, uid 1000, chr_uid 1234) and set the name to "Test", and left all the other fields as zero.

This causes the game to send me a bunch more packets with IDs 71, 73, 75, 117, 308, 311, 138 and 268, and then time out when I'm not replying to any of them. Oh boy, that's a lot of stuff...


At the time I had no idea what most of these were, but since I'm doing this writeup afterwards, I can just skip past all that work and spoil it:

ID Purpose
71 Requests the player's friend list
73 Requests the list of incoming friend requests
75 Requests the list of outgoing friend requests
117 Requests the player's blocklist
308 Requests "SVITEMDATA", which allows some items' parameters to be dynamically patched (not yet sure what this was used for)
311 Requests "CLUBDATA", which allows similar parameter updates for golf clubs
138 Sets the current character, using the chr_uid specified in StuffF9
268 Requests "ModeCtrl", which allows game features to be enabled/disabled server-side

The game keeps waiting until it receives packet 269 (SEND_MODECTRL) with an array of bitflags. I just set all of them on, and lo and behold... a menu screen!

The main Splash Golf menu, titled "Mode Select", features a stylised overhead view of a seaside town with clickable buildings for different modes and areas such as Quick Mode, Competition Mode, Single Mode, My Room, Shop, Bank and Game Center

Nothing is clickable. I can't even exit the game without killing the process or using Alt+F4. But hey, this might be the first time anyone's seen this screen since 2009, and that's pretty cool.

I tried implementing SEND_CRCHRUID (the reply to packet 138, which is apparently called REQ_CHG_CRCHRUID) and ACK_CHG_MODE as well, sending placeholder replies to see if that would pacify the client, but that wasn't good enough.

In order to proceed, I think I'll need a better understanding of the game logic - and to figure that out, I'll need to dig further into the game engine.

An Untitled Game Framework

Splash Golf's debug logging continues to be a goldmine. Whenever the game quits, it dumps out a log like this:

08/12/23 19:25:30  v begin to destruct TaskManager.
08/12/23 19:25:30  ■idx  pri:idx STS   group:taskID taskID   Label
08/12/23 19:25:30  [   0]   0:  0 RUN (0xf000:0x0003(     3)) GameFadeTask
08/12/23 19:25:30  [   1]   0:  1 RUN (0xf000:0x0001(     1)) ScrCaptureTask
08/12/23 19:25:30  [   2]  15:  0 REM (0xffff:0x0000(     0)) 
08/12/23 19:25:30  [   3]  15:  1 RUN (0x2202:0x0000(     0)) DIALOG_TASK
08/12/23 19:25:30  [   4]  15:  2 RUN (0x3005:0x0004(     4)) LOUNGE DIALOG
08/12/23 19:25:30  [   5]  15:  3 REM (0x4000:0x0004(     4)) 
08/12/23 19:25:30  [   6]  15:  4 RUN (0x4000:0x0003(     3)) 
08/12/23 19:25:30  [   7]  15:  5 RUN (0x5004:0x0003(     3)) TOP_TASK
08/12/23 19:25:30  [   8]  15:  6 RUN (0x500a:0x0009(     9)) QUICKSETTING_TASK
08/12/23 19:25:30  [   9]  15:  7 REM (0x6003:0x0002(     2)) LABEL_SCREEN_OPTION
08/12/23 19:25:30  [  10]  15:  8 REM (0x6010:0x0003(     3)) LABEL_SOUND_OPTION
08/12/23 19:25:30  [  11]  15:  9 REM (0x6011:0x0004(     4)) LABEL_GAME_OPTION
08/12/23 19:25:30  [  12]  15: 10 REM (0x6012:0x0005(     5)) CHAT_MACRO
08/12/23 19:25:30  [  13]  15: 11 RUN (0xf000:0x0000(     0)) LABEL_GAMEGUARD_TASK
08/12/23 19:25:30  [  14]  31:  0 RUN (0x3003:0x0002(     2)) ChrLoadManagerTask

This suggested that I should look at TaskManager, so I did.

Tasks and such

Pretty much everything that happens in Splash Golf is inside a Task. It sounds like a thread-related thing, but don't be fooled - every Task runs on the same thread, except for a select few that spin off their own (and that's not really handled by TaskManager).

Each Task has a group ID and task ID, as seen in the log above, and many (but not all) have a label. It takes the form of a C++ class where you override virtual methods.

Method Purpose
InitExecute Called once, right after a task is created
TermExecute Called once, right before a task is destroyed
Execute Called every tick if the task's status is "Run"
Draw Called every tick, while Direct3D drawing is active
ExecuteSleep Called every tick if the task's status is "Sleep"
ReleaseDXRes Direct3D resource bookkeeping
RestoreDXRes Direct3D resource bookkeeping
LostDXDev Called if the Direct3D device is 'lost'
ResetDXDev Called after the Direct3D device has been reset
ReceiveSignal Called when a signal is sent to the task
ReceiveMess Called when a message is sent to the task

Signals and messages are the two main ways that tasks communicate with each other, but messages seem to be far more commonly used. A signal is just an ID, but a message is formed of both an ID and a parameter.

One common pattern, for example, is for a UI task to store the Group ID and Task ID of another task that it will notify when an event occurs (like a button being clicked or a process being completed) by sending a message to it.

Phases

Many of the game's tasks use these as crude state machines - they're just variables that indicate which phase a task is in. Some tasks get fancy by having multiple phases, or by using a second variable (wow!) to store the previous phase so that they can spit out nice logging messages.

08/12/23 19:23:45  ☆ServerSelectTask Phase_Connect_Gmsv is Changed. [6] → [7]
08/12/23 19:23:45  ★ServerSelectTask Phase_Connect_Gmsv Passage[7]
08/12/23 19:23:45  送信成功:packet=268, pid=10
08/12/23 19:23:45  ■SEND_CRCHRUID(pid:6 cid:500 now_chr_uid:1234) received.
08/12/23 19:23:45  ○CRCHRUID(pid:6 cid:500 ChrUid:1234)
08/12/23 19:23:45  送信成功:packet=83, pid=-1
08/12/23 19:23:45  送信成功:packet=100, pid=-1
08/12/23 19:23:45  SEND_MODECTRL( pid:7 ) received
08/12/23 19:23:45  ☆ServerSelectTask Phase_Connect_Gmsv is Changed. [7] → [8]
08/12/23 19:23:45  ★ServerSelectTask Phase_Connect_Gmsv Passage[8]
08/12/23 19:23:45  ☆ServerSelectTask Phase_Connect_Gmsv is Changed. [8] → [9]
08/12/23 19:23:45  ★ServerSelectTask Phase_Connect_Gmsv Passage[9]
08/12/23 19:23:45  ☆ServerSelectTask Phase is Changed. [5] → [6]
08/12/23 19:23:48  ☆ServerSelectTask Phase is Changed. [6] → [7]
08/12/23 19:23:48  ☆ServerSelectTask Phase is Changed. [7] → [8]
08/12/23 19:23:48  Phase Change Wait: 7
08/12/23 19:23:48  LogIn_ServerSelect_BGTask Finished.

User Interfaces

Figuring out the game's UI system seems a bit out-of-place for the task of writing a custom server, doesn't it...?

While I'm not planning to modify any of the UIs, knowing how it works is really useful for learning more about the game. As useful as the debug messages are, they don't tell the full story. If I can follow where a particular field 'goes' and find out how the game uses it, then that's great.

I'm also likely to run into more roadblocks like the current menu issue, where I'll need to understand the UI code in order to know what causes a specific behaviour.


There's a class responsible for drawing UI, cGUIMan, which is defined using CSV files. Working with these is fun because quite a few of them have comments, but they're all in Japanese.

An interface is built out of 'primitives' which can be modified at runtime. There are some parts of the game which dynamically create these, but this is pretty uncommon; most of the UIs I've looked at have all their parts pre-defined in the CSV.

Here's an example file; this is "console/Lobby/roomlist/VSRoomPlate.csv".

BASEDIR,data\,,,,,,,,,,,,,,,
BASEPOS,0,0,,,,,,,,,,,,,,
// --変形マトリクス,,,,,,,,,,,,,,,,
// idx,prim,enable,,,,,,,,,,,,,,
0,MATRIX,1,,,,,,,,,,,,,,
// --背景,,,,,,,,,,,,,,,,
// idx,prim,enable,x,y,w,h,R,G,B,semi,texture file,u0,v0,u1,v1,
1,SPRITE,1,0,0,362,44,ff,ff,ff,1,console\lobby\roomlist\rlb.dds,0,0,0,0,// 非選択時
// --アイコン,,,,,,,,,,,,,,,,
// idx,prim,enable,x,y,w,h,R,G,B,semi,texture file,u0,v0,u1,v1,
2,SPRITE,1,30,7,0,0,ff,ff,ff,1,none,0,0,0,0,// コースロゴ
3,SPRITE,1,144,2,92,23,ff,ff,ff,1,common\icon\RoomDetailParts.dds,1,1,0,0,// ルール
4,SPRITE,0,5,9,25,25,ff,ff,ff,1,common\icon\RoomDetailParts.dds,1,226,0,0,// ラウンド中
5,SPRITE,0,319,23,16,16,ff,ff,ff,1,common\icon\RoomDetailParts.dds,50,226,0,0,// キー
6,SPRITE,0,337,23,16,16,ff,ff,ff,1,common\icon\RoomDetailParts.dds,68,226,0,0,// ブラックリスト
// --文字情報,,,,,,,,,,,,,,,,
// idx,prim,enable,x,y,w,h,sizeH,pitch,RGB,semi,weight(0-1000),font name,,,,,,,
7,CSTR,1,8,16,36,12,12,0,0,1,400,MS ゴシック,// 部屋番号,,,,,,
8,CSTR,1,256,7,36,12,12,0,0,1,400,MS ゴシック,// ホール数,,,,,,
9,CSTR,1,311,7,36,12,12,0,0,1,400,MS ゴシック,// 制限時間,,,,,,
10,CSTR,1,335,7,36,12,12,0,0,1,400,MS ゴシック,// プレイヤー最大人数,,,,,,
11,CSTR,1,329,7,36,12,12,0,0,1,400,MS ゴシック,// プレイヤー人数,,,,,,
12,CSTR,0,327,3,36,16,16,0,ff7800,1,400,MS ゴシック,// プレイヤー人数 赤文字16dot,,,,,,
13,CSTR,0,383,8,96,12,12,0,0,1,400,MS ゴシック,// ダミー,,,,,,
14,CSTR,1,150,26,144,12,12,0,0,1,400,MS ゴシック,// 部屋名,,,,,,
// --マスク,,,,,,,,,,,,,,,,,,,
// idx,prim,enable,x,y,w,h,R,G,B,semi,texture file,u0,v0,u1,v1,,,,
15,SPRITE,0,0,0,362,44,ff,ff,ff,1,console\lobby\roomlist\rlb.dds,0,46,0,0,,,,
// --カーソル,,,,,,,,,,,,,,,,,,,
// idx,prim,enable,x,y,w,h,button_tex,icon_tex,bu0,bv0,bu1,bv1,bu2,bv2,bu3,bv3,iu0,iv0,
16,BUTTON,0,0,0,362,44,console\lobby\roomlist\rlb.dds,none,0,92,0,140,0,188,0,0,0,0,// 名前

This is an excellent example. Each primitive type has helpful comments describing what the fields do. Each primitive also has a comment describing what it does (e.g. 制限時間, which translates to "time limit").

A variety of primitive types are supported:

Name Purpose
Matrix Modifies the rendering matrix for the following primitives
TexMatrix Modifies the texture matrix for the following primitives
Board Draws a rectangle using a flat colour
GlBoard Draws a rectangle with a gradient background
Sprite Draws a texture (or a portion of one)
WpSprite Draws a wood-grain texture
Button Clickable button that supports multiple states
FxStr A plain string
CStr A fancier string which can contain emoticons
LStr A list of strings
Scissor Configures a scissor rectangle for the Direct3D renderer (clips all drawing)
Frame Draws a hollow rectangle

These features all combine together to create some beautiful interfaces. I can't show you VSRoomPlate because I haven't implemented the server machinery for that yet, but here's an example of another one - this is the Closet.

The game's Closet interface. The left half shows character stats and a preview of my character wearing a tank top, dark grey trousers, orange flippers, a sword and one blue glove. The right half has a tabbed interface for picking different clothing items and accessories, showing their Japanese names, stat bonuses and class requirements.

Input Handling

Figuring out how cGUIMan does its rendering is only part of the story, as useful as it is. All of the UIs are usable with the keyboard, and probably also with a gamepad (although I haven't tried).

They didn't bother implementing a generic approach for this... instead, every single window has bespoke code for all UI interactions, including logic for toggling which elements are focused and so forth. It's painfully ugly, but it wasn't a deal breaker once I got used to reading the code.

I won't dwell on this too much - let's go back to the actual game.

Getting In-Game (for real)

The main menu, with its silly little town and clickable buildings, is orchestrated by a Task subclass called CTopTask.

According to the logs, the furthest we get is Phase 5, so we need to move on from there. Looking at CTopTask::Execute to see what happens in that phase, I was.. well, stuck for a bit, for a very silly reason.

Oh shit, another tangent

Based off the decompiled code, in phase 5, the client initialises the text input subsystem and then jumps directly to phase 7. So why didn't this work?

There is a global variable (whose name I don't know) which seems to control some form of internal testing mode. It's statically initialised to 1, but then gets set to 0 as soon as the game's login screen appears, and it never gets set to anything else.

It has some interesting effects, which I can enumerate by looking through the cross-references to that variable in IDA, including:

Anyway... Remember how this game is packed with ASProtect? I'd loaded an unpacked version into IDA, which mostly worked - but I guess one of the flags in the PE header was wrong, and IDA thought the data section was all read-only.

The code for phase 5 actually looks like this:

// Pseudocode for phase 5
if (mysteryTestingFlag) {
    this->nextPhase = 7;
} else if (didReceiveOrdColorResult) {
    this->nextPhase = 7;
    debug("ColorRes element : %d", OrdColorResult_data.element);
    if (OrdColorResult_data.element >= 0 && OrdColorResult_data.element <= 4) {
        this->nextPhase = 6;
        this->InitColorDialog();
        this->setLabelBalloonHelp();
    }
}

However, the Hex-Rays decompiler was too smart for its own good. Since it thought the data section was read-only, it assumed that the mystery testing flag would always keep its initial value of 1 - and obliterated the else branch. Fixing this, and following the references to these variables, made the solution obvious: I needed to send an ORD_COLOR_RESULT packet!

(End tangent)

I didn't know this at the time, but this packet is related to one of the game's online competition features.

According to awkward machine-translated docs from the game's website (the parts that the Wayback Machine has, at least), each account would be assigned a colour - blue, red, green, yellow or pink. Each colour would be pitted against the others. After server maintenance, the colour whose players performed best would win prizes, and then everybody would be randomly assigned a new colour.

Anyway, if the packet's element field is between 0 and 4, then the client displays a dialog box telling them that they are now in that colour - presumably this occurs on your first login after maintenance.

By sending an ORD_COLOR_RESULT packet, the game interface unlocks and we can interact with it... to an extent.

We can't join games, or do much else in this state. It's still excellent progress though, and I can go from here and try and make more functionality work, like the "Single Mode" single-player practice mode.

Next Time...

This post is now pretty long, so I'm going to split it up here. In the next instalment, I plan to feature:

I hope you enjoyed this adventure into software archaeology! I'm finally starting a new job in two weeks, so I'll have less time to work on shenanigans like this, but I'm hoping to at least finish up the second half and release some useful code before then.


Previous Post: Joining the NixOS Pyramid Scheme
Next Post: Reviving Sega's forgotten golf MMO after 14 years