Reviving Sega's forgotten golf MMO after 14 years

Wherein I recover enough of SEGA SPLASH! GOLF to make the single-player Practice Mode work, enable the shops and character customisation, and even fix a couple of bugs along the way.


Let's recap for a moment. In part 1, "They Made A Golf MMO With Sonic In it (Real!) (Not Clickbait!) (Only A Bit)"...

We need to go further. We have the technology.

But first, I'd like to tell you about our spons-

[an anvil falls from the ceiling and non-destructively squashes me like a cartoon character]

Going Back in Time

Until now, I've been working with just game version 0,956,4,12, which is a client that was given to me by biggestsonicfan - it came from their personal archives, since they actually played the game back when it was live. It turns out they had one more useful thing up their sleeves though!

Closed Beta

The game was released in April 2008, but there was actually a closed beta phase before that, and they gave me an archived copy of the client. It's missing features, but it does have some interesting differences...

This version of the game isn't packed with ASProtect, which makes it easier to analyse. The code for debug logging also looks different.

In the build I'm trying to revive, each debug call is expanded by the compiler into a bunch of function calls:

Aren't those empty strings suspicious...? Well, in the closed beta, they actually represent the function name and the line number. I'd wager that the original code looks something like this:

#ifdef BETA
#define DEBUG_LOG(...) \
    logger->writeTimestamp(); \
    logger->writeTextW(L" "); \
    logger->writeTextW(__FUNCTION__); \
    logger->writeFormat(L"(%4d)", __line__); \
    logger->writeTextW(L" "); \
    logger->writeFormat(__VA_ARGS__); \
    logger->writeTextW(L"\n");
#else
#define DEBUG_LOG(...) \
    logger->writeTimestamp(); \
    logger->writeTextW(L" "); \
    logger->writeTextW(L""); \
    logger->writeFormat(L""); \
    logger->writeTextW(L" "); \
    logger->writeFormat(__VA_ARGS__); \
    logger->writeTextW(L"\n");
#endif

So, for any log message that exists in this build of the game, I know the class name and function name that generated it. All of the packet handlers have names like Circuit::func_ACK_CHG_MODE and Circuit::func_SEND_TITLES.

The function I showed in Part 1 for handling the RoomStat structure is called PartyInfo::ConvertFrom... which is actually kinda funny, because there's several different PartyInfo methods that all accept a structure and read some data from it, and they're all called PartyInfo::ConvertFrom. Shoutout to function overloading.

Anyway, these logs are how I was able to confirm the names of all the Task virtual methods, as well as some other things I mentioned in Part 1 like the name of the cGUIMan class and the CTopTask::InitColorDialog() method. All of these small bits of info add up to give me a better understanding of the big picture.

Closed Beta Logs

The other useful thing present in biggestsonicfan's client archive was a set of logfiles from November 2007, during the closed beta phase. It looks like these were automatically turned on for that version of the client, and this is invaluable for helping us understand how Sega's original server behaved!

For example, here's a subset from one of the logs where it looks like they entered a lobby:

11/10/07 19:10:40 CTopTask::Execute( 862) Move[14]
11/10/07 19:10:40 cLobbySelectTask::ReceiveMess( 266) LobbySelectTask Mode VS
11/10/07 19:10:40 CTopTask::Execute( 873) LobbySelect On Mode VS
11/10/07 19:10:40 Circuit::Send(1001) ????:packet=8, pid=27
11/10/07 19:10:40 Circuit::func_ACK_CHG_MODE(1437) ACK_CHG_MODE(1) received.
11/10/07 19:10:40 Circuit::Send(1001) ????:packet=10, pid=28
11/10/07 19:10:40 Circuit::func_SEND_LOBBY_NUM(1457) SEND_LOBBY_NUM(4) received.
11/10/07 19:10:40 Circuit::Send(1001) ????:packet=12, pid=29
11/10/07 19:10:40 Circuit::func_SEND_LOBBY_DATA(1478) SEND_LOBBY_DATA(0:[?????]) received.
11/10/07 19:10:40 cLoadLobbyDataTask::GetLobbyData( 521) LobbyData Not Complete.
11/10/07 19:10:40 cLoadLobbyDataTask::GetLobbyData( 522) VSLobbyCount[1/4]
11/10/07 19:10:40 cLoadLobbyDataTask::GetLobbyData( 523) CompeLobbyCount[0/0]
11/10/07 19:10:40 cLoadLobbyDataTask::ReqLobbyData( 749) GetLobby[1] : Mode[1]
11/10/07 19:10:40 Circuit::Send(1001) ????:packet=12, pid=30
11/10/07 19:10:41 Circuit::func_SEND_LOBBY_DATA(1478) SEND_LOBBY_DATA(1:[????!??]) received.
11/10/07 19:10:41 cLoadLobbyDataTask::GetLobbyData( 521) LobbyData Not Complete.
11/10/07 19:10:41 cLoadLobbyDataTask::GetLobbyData( 522) VSLobbyCount[2/4]
11/10/07 19:10:41 cLoadLobbyDataTask::GetLobbyData( 523) CompeLobbyCount[0/0]
11/10/07 19:10:41 cLoadLobbyDataTask::ReqLobbyData( 749) GetLobby[2] : Mode[1]
11/10/07 19:10:41 Circuit::Send(1001) ????:packet=12, pid=31
11/10/07 19:10:41 Circuit::func_SEND_LOBBY_DATA(1478) SEND_LOBBY_DATA(2:[?????????]) received.
11/10/07 19:10:41 cLoadLobbyDataTask::GetLobbyData( 521) LobbyData Not Complete.
11/10/07 19:10:41 cLoadLobbyDataTask::GetLobbyData( 522) VSLobbyCount[3/4]
11/10/07 19:10:41 cLoadLobbyDataTask::GetLobbyData( 523) CompeLobbyCount[0/0]
11/10/07 19:10:41 cLoadLobbyDataTask::ReqLobbyData( 749) GetLobby[3] : Mode[1]
11/10/07 19:10:41 Circuit::Send(1001) ????:packet=12, pid=32
11/10/07 19:10:41 Circuit::func_SEND_LOBBY_DATA(1478) SEND_LOBBY_DATA(3:[??????]) received.
11/10/07 19:10:41 cLoadLobbyDataTask::GetLobbyData( 514) LobbyData Complete!
11/10/07 19:10:41 cLoadLobbyDataTask::GetLobbyData( 515) VSLobbyCount[4/4]
11/10/07 19:10:41 cLoadLobbyDataTask::GetLobbyData( 516) CompeLobbyCount[0/0]
11/10/07 19:10:41 cLobby_ServerSelectBaseTask::ReceiveMess( 960) BaseTask Receive _MESID05_LOBBYSELECT_GETDATA_END_
11/10/07 19:10:41 Circuit::Send(1001) ????:packet=14, pid=33
11/10/07 19:10:42 Circuit::func_ACK_ENTER_LOBBY(1518) ACK_ENTER_LOBBY(0) received.
11/10/07 19:10:42 cLobbySelectTask::ReceiveMess( 340) Connect VSLobby[0] : name = ?????

I'll put this into (mostly) plain English using my understanding of the game:

Why are there so many question marks? Character sets strike again - these logs were converted to the system's non-Unicode character set for saving. On a non-Japanese system, that means all the Japanese characters get unceremoniously replaced.

I'll be coming back to these logs later on, but there's one more thing I want to mine before I talk about characters: the website.

The Wayback Machine Saves Us

I struggled to find any information online about this game, especially in English. I realised after a bit that I could use the Wayback Machine to try and dig up the official website. Lots of the pages are archived, but this doesn't include any of the CSS or images.

The captured pages are a little sad. The homepage from December 2007 boasts that the game will launch in Spring 2008. "SEGA's first! An online golf game that can be played for free is now available!"

In February 2008, the tagline changes to "It's starting! 2/20 Open Beta Trial!" The next capture, from March 2008, finally lets you download the game and sign up.

I want to share this beautiful snippet from the intro, as mangled by Safari's machine translation:

SEGA Splash! What is golf?

POINT 1 Free at any time!
The play fee is always free! Anyone can feel free to enjoy online golf games.

POINT 2 Anyone can be crispy! Easy operation
Easy operation for beginners! You can play with just a mouse. When you get used to it, let's operate it together with the keyboard.

POINT 3 Sonic and others participate as caddy
Sonic and the others appear as caddies! He is very active as a reassuring support role. Let's aim for the cooperative technique "special shot" with the caddy.

POINT 4 Fashionable is fun
It's also fun to be fashionable! More than 100 kinds of clothes and accessories are available! Depending on the combination, a special effect may occur!

I don't know about you, but I'd absolutely download a game if it gave me the ability to be crispy. Oh, and it seems that if you played the open beta, you got a Sonic golf club as a reward. Wait, what the hell? Let me boot up the game and see what that's like...

The Sonic club set is just a set of clubs in the colours of Sonic, Knuckles, Tails and Eggman.

Okay, that's kinda boring. I mean, this game also contains FISH CLUBS.

A set of clubs in the shape of different kinds of frozen fish. They're so big that they disrespectfully clip through each other in the preview interface.

Anyway I digress. In April, they announced the full launch of the game for Wednesday 23rd April 2008. This news post promises a large update with new characters, caddies and courses, the introduction of the "Sega Coin" microtransaction currency, and that all progress from the open beta will be carried over.

Five months after launch, they gave up and posted Notice of the end of the "Golf" service. On the 12th November 2008, they announced that the game would shut down on 12th February 2009. All Sega Coin items would be reduced to half price, and at the end of January, they would stop selling Sega Coins and refund players.


Viewing the archived "Game Flow" page in Safari is fruitless, it just contains several images that don't load, with the unhelpful alt text of "1. Login & server selection" and "2. Mode selection"

You know what? That's all just a distraction. The Game Guide is what we really want; an explanation of the game so that we can hopefully reconstruct more of it.

Some of the pages, like "Game Flow", are basically pointless without the assets - but others are quite helpful, going into detail on game mechanics and into all the different modes.

"My Room" is a catch-all area where you can customise your character's loadout and parameters, view player rankings, "browse commemorative photos", manage mail, manage friends and blocked players, and so on. "Practice Mode" is the single-player mode that will probably be easiest to enable, by a long shot.

But what if I told you that this was not the only Splash Golf website?

Hangame also saves us...?

I spotted some oddities in the game client's strings. Some parts of the game just open up a web page. Take a look at this decompiled pseudocode, featuring function and field names I had to make up:

void BankTask::doAction() {
    if (this->clickedButton == 1) {
        // Closes the Bank window
        DispatchMessage(0x5006, 0x0005, 0x1017, 0);
    } else if (this->clickedButton == 0 && this->okButtonEnabled) {
        if (BigObj->isHanGame) {
            ShellExecute(BigObj->hwnd, NULL, L"http://splashgolf.hangame.co.jp/shop.nhn?m=point", NULL, NULL, SW_SHOWNORMAL);
        } else {
            // Do a lot of COM/OLE fluff using ATL classes
            // I've simplified the code here a bit for readability, getting rid of the error handling
            IWebBrowser2 *browser = createBrowser(CLSID_InternetExplorer);
            browser->PutVisible(true);

            OLEString url = L"https://charge.isao.net/profile_oem/OEMUsrPrfMenu_std.cgi";
            OLEString headers = "Content-type: application/x-www-form-urlencoded\r\n";
            char buffer[512];
            sprintf(buffer,
                    "login_id=%s@splashgolf.isao.net&login_password=%s&ctg_disc_flg=1&size=40&maxlength=40&product_name=splashgolf",
                    BigObj->globalInfo.nonHGName, BigObj->globalInfo.nonHGPass);
            PostData postData(buffer);
            browser->Navigate2(url, null, null, postData, headers);
        }
    }
}

Hey, let's go back to the page about the Splash Golf shutdown. A footnote says:

※1 The customer's personal information registered with ISAO Co., Ltd. through this service will be strictly managed and deleted when the user support at our company is completed, taking into account various procedures.

It looks like ISAO was a company that used to run Sega's online services, so that explains one branch - the microtransaction system was obviously managed by them. But what about Hangame?

The archived Hangame Splash Golf homepage in Safari, partially translated; there's a login form, game screenshots, news updates, a download button, and quaint mentions of Internet Explorer 6

Hangame is a Korean game portal that also operates in Japan - it still exists today, but they're now called "Hange" in Japan. Crucially for our purposes though, we can try and find their version of the Splash Golf website. Luckily, the Wayback Machine has it at splashgolf.hangame.co.jp, and it's got most of its assets intact - the Flash menus even load using Ruffle.

This is a great little time capsule. They've even got part of the game guide, in its full glory - the more detailed sections all redirect to the Sega website, but the introduction is fully intact.

Fun tip: If you're on an Apple device, the translation features built into Safari will use OCR to recognise text in images and translate it. It's not perfect but it's pretty good.

Machine-translated guide screenshot. The top part describes how after setting the power of your shot, you must click the "impact area" at the right moment to ensure that the shot is accurate. There's a game screenshot showing the right point in the bar that gets you a "Nice Shot!", and then an illustrated conversation where Rusk tells Miel that you can start over a limited amount of times.

Check out the "Let's play a round" page for an example, which shows the process of joining and playing a competition round. There's even illustrated comics where the playable characters talk about the game mechanics.

This page also sheds more light on the game flow, at least for this mode. You select a lobby and then get a list of tournaments, and you can pick one - I think these correspond to "rooms", but I could be wrong. After joining, you enter the tournament lounge, where you can see all the other participants and chat with them.

Once you're ready to play, you click the "Ready" button and enter the actual game where you get to do the actual golfing.

💭 Don't you hate it when you get so caught up in side details like fish-shaped clubs and competing Japanese game portals that you forget about the actual point of the damn game? Yeah... we've all been there.

Let's Fix Single Mode!

It'd be pretty cool if we actually got to play a round, wouldn't it? Let's recap where we left off in Part 1:

So here's what happens if I try to enter Single Mode now:

I guess I need to do something with 274. As per the usual convention, 275 is probably the reply packet it's expecting - what does that do? There's no helpful log messages, so I'll have to use the info I learned from other parts of the game to figure out what it's doing.


Packet 275's payload contains a variable-length list of integers, and nothing else. It's passed into a function that looks something like this:

void handlePacket275_SingleModeInit(Packet275 *pkt) {
    if (BigObj->globalInfo.mode == 5 && PartyInfo->curPlayerInfo) {
        for (int i = 0; i < pkt->count; i++) {
            PartyInfo->curPlayerInfo->golfBag[i] = pkt->data[i] >> 10;
        }
        for (int i = 0; i < 8; i++) {
            cCourceEnv->singleModeItems[i] = 0;
        }
        for (int i = 0; i < pkt->count; i++) {
            cCourceEnv->singleModeItems[i] = pkt->data[i];
        }
        cCourceEnv->singleModeItemsCount = pkt->count;
    }
}

In hindsight, this makes total sense to me, but when I first tried to make Single Mode work, I didn't know what these values were actually used for - so I tried to wing it. I've got helpful names for them now, but that wasn't the case when I first encountered this code!

What happens to that data after it gets copied into cCourceEnv? There's an easy way to try and track that down, which may be obvious to those who are familiar with reversing code, but I figure it may be interesting to explain anyway.

How Structures Work

Here's how I defined the structure for packet 275:

struct Packet275 {
    PacketHeader hdr; // 4 bytes at offset 0
    int count; // 4 bytes at offset 4
    int data[8]; // 32 bytes at offset 8
};

If you've got a variable that points to a Packet275 instance and code that accesses the count field, which is at offset 4, then chances are pretty high it's going to generate an instruction like mov eax, [edx+4] - meaning read a 4-byte value from the address you get when you add 4 to edx.

So I can just search for every instruction that contains 4, right? Well, sure, but there's going to be hundreds or thousands of accesses like this.

However, cCourceEnv is a really big class, and the array it's copying into is located at offset 0xCE7F8. This means there's a good chance that if I just search for that constant (using Search > Immediate value in IDA, or Scalar search in Ghidra) I'll easily find other code that accesses that array.

This isn't a hard and fast rule, and there are situations where this trick may not work. The offset could be dynamically computed (this tends to happen for arrays), or the field could be part of a nested structure, where some accesses are relative to the inner one and some accesses are relative to the outer one. But still, it's so easy that it's usually worth trying, just in case it works out.

Processing Packet 275

Before I got distracted, I was explaining what happens with this array of 8 elements. It gets copied into another array which is located at offset 0xCE7F8 inside cCourceEnv.

If I search for that giant number, there's one other result, and it's the function that I know to be called cRoundMain::Initialize. There's a block of code that only runs if the mode is set to 5, which is Single Mode. It backs up three chunks of player data before then zeroing out one of them and copying the data from Packet 275 over the other two.

Once again, with the benefit of hindsight I can tell you exactly what this does, along with a sprinkling of badly machine-translated info from the game guide.

The Practice Mode page explains the limitations of this mode; most notably:

Carry item: You can use 8 types of specific items one by one. There is no handover.

Hold item: I don't consume it.

The Items / Ornaments page is what helped me finally put things together. "Carry Items" are items you can use mid-round, which apply to a particular shot. "Hold Items" are items that apply to an entire round. And the 8 IDs in Packet 275 are the "specific items" that the text refers to!

So, the steps in this mystery code in cRoundMain::Initialize are as follows:


I'll be honest, though. That's what I know now, not what I knew when I first tried to get into Single Mode. I knew that all of these arrays represented items in some form or other, but I didn't know how they were used, or what I needed to send to the game.

I did see one clue: at the end of the Initialize method, there's some debugging(?) code which adds 100 instances to your inventory of 8 different types, but only if g_pCirc (the Connection singleton) is null. I decided to purloin these as a safe initial bet.

            elif packet_type == 274:
                # GAME SERVER: Request items with counts (someItemIDs)
                packet = struct.pack(
                    '<i8i',
                    8,
                    (0x5001 << 10) | 100,
                    (0x5003 << 10) | 100,
                    (0x5005 << 10) | 100,
                    (0x5007 << 10) | 100,
                    (0x7001 << 10) | 100,
                    (0x7002 << 10) | 100,
                    (0x7003 << 10) | 100,
                    (0x7004 << 10) | 100,
                )
                write_packet(conn, 275, packet)

Can we get in-game now? Nope, but we do get a little further; now it sends me packet 31 and waits for another reply.

Starting the Game

Our mission is a lot clearer this time round. 31 is almost certainly the precursor to packet 32, which we know as ORD_GAMESTART - and this one is an absolute unit, containing lots of data. This seems daunting, but it's actually refreshingly easy thanks to all the debug messages!

Check out this PartyInfo method:

int PartyInfo::usePkt32Data(Pkt32Data *data) {
    this->x4D60 = 1;

    if (this->curMode == 2) {
        // TL: "★☆Party member list☆★ (room name: %s)"
        debug(L"★☆パーティ参加メンバー一覧☆★(ルーム名:%s)", this->room_name);
        for (int i = 0; i < 50; i++) {
            if (this->playerInfos[i] != nullptr)
                debug(L"%3d : %s", i + 1, this->playerInfos[i]->name);
        }
        // TL: "★☆List of party participants☆★ (so far)"
        debug(L"★☆パーティ参加メンバー一覧☆★(ここまで)");
    }

    // [... some code that copies all the data into class fields ... ]

    this->sub_5235B0(); // sets some player flags
    this->createScores(-1); // creates PlayerScore objects for each player

    debug(L"▼GAMESTART(mode:%d rule:%d time:%d member(%d/%d) [course:%d season:%d] holes:%d) received.");

    wchar_t str_index[1024], str_HoleNo[1024], str_WindDir[1024], str_WindPow[1024];
    wchar_t str_Weather[1024], str_CupPos[1024], tmp[1024];

    for (int i = 0; i < 18; i++) {
        swprintf(tmp, L" [%2d]", i);
        wcscat(str_index, tmp);
        swprintf(tmp, L" %4d", data->HoleNo[i]);
        wcscat(str_HoleNo, tmp);
        swprintf(tmp, L" %4d", data->WindDir[i]);
        wcscat(str_WindDir, tmp);
        swprintf(tmp, L" %4d", data->WindPow[i]);
        wcscat(str_WindPow, tmp);
        swprintf(tmp, L" %4d", data->Weather[i]);
        wcscat(str_Weather, tmp);
        swprintf(tmp, L" %4d", data->CupPos[i]);
        wcscat(str_CupPos, tmp);

        if (i == data->holes) {
            wcscat(str_index, L"  || ");
            wcscat(str_HoleNo, L"  || ");
            wcscat(str_WindDir, L"  || ");
            wcscat(str_WindPow, L"  || ");
            wcscat(str_Weather, L"  || ");
            wcscat(str_CupPos, L"  || ");
        }
    }

    debug(str_index);
    debug(str_HoleNo);
    debug(str_WindDir);
    debug(str_WindPow);
    debug(str_Weather);
    debug(str_CupPos);

    // TL: "◇Unprocessed USTAT: %d pieces"
    debug(L"◇未処理の USTAT : %d 個", this->m_nUstatReceiveCount);
    // TL: "■List of caddies/balls/hold items equipped by each player"
    debug(L"■各プレイヤーの装備しているキャディ/ボール/ホールドアイテム一覧");

    for (int i = 0; i < 50; i++) {
        if (this->playerInfos[i] != nullptr) {
            swprintf(buffer, L"[%2d](uid:%6d cid:%5d) caddie:%d ä¿¡é ¼:%5d ball:%2d [name:%s]",
                     i, this->playerInfos[i]->uid, this->playerInfos[i]->cid, this->playerInfos[i]->caddie,
                     this->playerInfos[i]->caddieReliance, this->playerInfos[i]->ball, this->playerInfos[i]->name);

            for (int j = 0; j < 8; j++) {
                swprintf(tmp, L" [%d:%d]", j, this->playerInfos[i]->holdBox[j]);
            }
            debug(buffer);
        }
    }

    cCourseEnv::instance->setThingsFromPartyInfo(this);
    if (this->curMode == 2)
        sub_7291A0(this->room_TimeLimit * 60.0, cTimer::instance->x58);
    return 0;
}

Yes, that's a lot of code, but it basically documents the struct for us. Thank you Sega, couldn't have done it without you 🙏 xxx

Now it's time for another rabbit hole, wherein we discover how to generate all of these fields. Sit down.

Haha Just Kidding :3

I'm pretty sure this packet is used for every game mode, not just Practice/Single Mode. That means we get to cheat! Remember the Closed Beta logfiles? These logs are present there too! So I took the config from a random round from 2007...

            elif packet_type == 31:
                # GAME SERVER: Game start
                packet = struct.pack(
                    '<bbBbbbbb 18b 18b 18b 18b 18b 50i 50h 50i 50i 8i 1568x',
                    5, # mode
                    0, # rule
                    20, # time
                    1, # member
                    1, # member_max
                    1, # course
                    1, # season
                    9, # holes
                    0,1,2,3,4,5,6,7,8,-1,-1,-1,-1,-1,-1,-1,-1,-1, # HoleNo
                    109,-36,127,-126,-53,-115,7,7,13,0,0,0,0,0,0,0,0,0, # WindDir
                    5,7,7,6,5,3,3,7,6,0,0,0,0,0,0,0,0,0, # WindPow
                    1,1,2,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0, # Weather
                    3,3,1,0,1,2,3,1,4,0,0,0,0,0,0,0,0,0, # CupPos
                    500,*([-1] * 49), # cid
                    *([0] * 50), # caddieArray
                    *([0] * 50), # caddieRelianceArray
                    *([0] * 50), # ballArray
                    0,0,0,0,0,0,0,0, # holdBox
                )
                write_packet(conn, 32, packet)

I set the mode to 5 to match Single Mode, and I set the cid (I think this stands for 'connection ID' - more on this later) to 500 to match the other lies that my fake server tells. Then I just left everything else as zero, to see what would happen.

... The game shows an error dialog box, then immediately transitions to a loading screen, then fades out and exits. Oh no. Logfiles? Help?

Characters!

Comparing my log against the 2007 log, there's one obvious and suspicious difference. During the loading process, GolfCharacter::SetupCharacter spits out details about the character's specs - type, ID, clothes, accessories, etc. In my logfile, all of these values are 0 or -1, and right after there's a line that says ErrCode:2016.

What's even more suspicious is that if I go to My Room, the game also crashes with the same issue. The solution is to give myself a character.

... What's a character?

We'll cover that in more depth later on, but for now we can handwave most of it.

Packet 101 is called SEND_CHRDATA, and that's probably a response to packet 100, if the usual pattern holds. One of the packets we receive on login is 100, and the payload includes the character ID that I told the client to use. Case closed, right?

The structure is pretty easy to reconstruct from debug messages, although I just left some fields as zeroes because I didn't fully understand their meaning. The highlight is the Character Appearance struct, which is awkward to deal with because it uses C bitfields to pack its values together - but I'm ok with that, I've seen bitfields before.

I used the same tactic again where I just stole the character parameters from 2007's finest log files, and duly sent the game a SEND_CHRDATA packet when it asked for one. Oh, and there's also a SEND_APPEAR packet which asks for an Appearance struct, so I guess I should implement that too.

The My Room window is now open, showing the character in a plain default outfit standing in a room alongside a chat window

This is a promising start, we can now open My Room without crashing! I must be on the right track. Can I start a game too??

The game is stuck on a loading screen featuring chibi versions of the characters, with an almost-full progress bar

...alright, now this game is just taunting me. The progress bar moves up to 95% and then stops.

Ranking and Whatnot

The game sends a few different packets to request stuff, but the one it really cares about is packet 43, SEND_CRECORD, which comes in reply to packet 42. There's a dizzying amount of different structures for keeping track of all sorts of stats and achievements.

This particular one contains info about the players that achieved certain milestones on a particular course and with a particular hole count. We can just lie to the client and tell it that nobody's gotten anything, it's fine.

            elif packet_type == 42:
                # GAME SERVER: Request course record
                uid, course, season, hole_idx = struct.unpack_from('<4xibbb', packet)
                logger.info(f'GetCourseRecord: {uid=}, {course=}, {season=}, {hole_idx=}', extra=d)
                packet = struct.pack(
                    '<ibbb 52x b',
                    uid,
                    course,
                    season,
                    hole_idx,
                    # skip all the unknown stuff here
                    0, # successful result
                )
                write_packet(conn, 43, packet)

And what do you know? We're in.

The little guy's standing on a low-poly island with unrealistically green glass, preparing to hit the ball into the rough

That's it, our mission's done, you can go home now. Or... is it?

A Very Mysterious Crash

This was my first time ever playing this game, and I was terrible at it. I tried to play through a round, but after a while, the game would just unceremoniously quit. The log file didn't tell me anything, it just went through the game's normal shutdown process.

Was I so bad at golf that I was reaching the stroke limit and then being kicked out? Was my server failing to respond to something appropriately, and triggering a really really slow timeout? Was the game just getting upset at being run on ARM Windows in a virtual machine? Did I miss one of the GameGuard liveness checks?

I made it to the second hole a couple of times, but I'd almost never get past my first shot on that hole. I ruled out timeouts by leaving the game running for 30 minutes with no issues. Whether I sped through the round or took my time, the outcome was always the same.

Exasperated, I broke out a debugger. I started up the game under x64dbg x32dbg and played a round, the same way I'd been doing all along.

The debugger has paused with an access violation, mid-game

Oh shit, this has gotta be our bug. But how do I track it down...?

I followed the callstack to see where this was coming from, and it was a part of the game's code that I'd not touched yet at all. It traced back to one of the massive functions in cRoundMain (which is one of these "god objects" that controls lots of things), and I didn't understand that class well enough to have a clue, so I looked at functions closer to the crash.

It occurs in a class called DxSound. I spent some time mapping out the different functions so I could build my mental picture of what was going on.

Another useful RE tip: Closely related functions will often be right next to each other in an executable, because if they're in the same .c/.cpp file, they'll be linked at the same time. This is not always the case because certain optimisations can result in code ending up in odd places, but it still often holds.

The actual crash occurs when the game tries to set the volume of a sound buffer, but gets passed an invalid reference. This code seems solid.

Going up one more level in the chain of abstractions, I come to a general sound management class that I don't know the name of. There's a method that accepts an index (which must be 6 or lower) and a floating-point value (which is clamped between 0.0 and 1.0), and it looks up a DxSound instance in an array.

The index parameter is -1. Uhhh. That can't be right.


Let's go up another level. We come to a class called ChrMotionSE, which follows a fairly common pattern in this game. It loads constant data from CSV files and then uses it to do fun game-y things.

It took me an embarrassing amount of time to unravel how it works, because it parses the data and then stores it into a nested maze of C++ STL data structures. I'm talking about std::vector<std::map<int, std::map<int, MotionData>>>. I ended up staring at the Visual C++ STL headers for a good while, trying to match up all the helper functions so I could eventually establish that yes, this unholy chunk of code is really just blah = map[key] or whatever.

Rather than rambling on in depth about that, I'll cut straight to the chase and explain how this subsystem actually works.

How To Play Footstep Sounds When The Little Dude Puts His Foot On The Ground

ChrMotionSE allows sound effects (SE) to be tied to specific character animations, so that they play at the right time during the animation and the Gamer's feeling of immersion can be maintained. There are 7 tables, one for each playable character, stored in a CSV each.

Screenshot of the original table opened in a spreadsheet app; there are helpful descriptive names on each column in Japanese, and each entry also has a name. Lots of the rows are just placeholder entries with a description and no actual data.

Here's the table c001.csv, which corresponds to Rusk, one of the two starting characters.

The left three columns are x, Motion ID, and Motion Name - the name is purely for the developers' use, and ignored by the game. I'll explain x in a moment.

The next three columns define which sound to play and when to play it: Frame, Category ID and Sound ID.

The final three are just parameters for how to play it: Effective range, Attenuation slope and Playback time (in frames).

Many of the rows are just placeholders where they note that an animation exists, but they don't attach any sounds to that animation. These are marked by the x character in the leftmost column, and the parser will skip these rows.

Also, some of the motions are lucky and get to have multiple sounds! Motion ID 8 is described as "Deciding motion", and it plays sound IDs 29, 30 and 15 at frames 47, 55 and 75 respectively.

Alright... so how does all this lead to our crash? It's trying to set the volume. The 'index' parameter is the Category ID from the sheet, minus one. This is weird, surely it wouldn't contain a row that just made the game crash?

Definitely Not Foreshadowing

The current motion ID is 206, so I looked for the corresponding rows in the sheet. It all looks sound (haha). At this point I wondered if there was some sort of memory corruption going on that would overwrite entries after they'd been loaded, or even just a logic bug in the state machine that queues sounds or something like that.

What's the name for 206, anyway?

JP: (アイアン) 足移動。その場で旋回。(右)

Machine translation: (Iron) Foot movement. Turn on the spot. (right)

There are eight of these, 105/106, 205/206, 305/306 and 405/406 for the driver, iron, wedge and putter respectively. Wait, does that mean...

I restarted the game, entered practice mode, groaned at the loading time, and then I was on the course. I selected an iron and turned my character. The game froze up for a couple of seconds, and then closed out in a most familiar fashion.

... ... oh no           (no i'm not making another comic this time, sorry)

This explained everything, and yet nothing at all. Now I know why it tends to happen on the 2nd hole - if the game decides I'm at iron-worthy distance to the hole, it picks an iron, and then I innocently rotate my little guy, and everything goes to shit.

But I still don't know how we got into this situation in the first place!

I was genuinely stumped. I manually looked through the nodes in the map through x64dbg's memory view, and confirmed that yes, motion 206 had a spurious empty sound. But why, Sega, why?

An Unfortunate CSV Event

I did not figure this out until I painstakingly stepped through the parser code in a debugger, and it's ridiculous. Take a look at the rows in question.

Screenshot of the rows for motion 206. It has two sounds assigned to it, and directly underneath them is a motion that has a name but no ID, and which has been turned off by using the "x" signal in the leftmost column.

You don't see anything weird? It's fine, I didn't either. Anyway, here's my best attempt at turning the parser into something readable...

bool ChrMotionSE::loadCSV(const wchar_t *path) {
    bool result = false;

    cCsvRead *csv = new cCsvRead();
    csv->alloc(1024, 1024);
    int numRows = csv->LoadCSV(path);

    if (numRows) {
        std::map<int, std::map<int, MotionData>> outerMap;
        int row = 0;

        do {
            if (wcscmp(csv->getStr(0, row), L"x") != 0) {
                // allow all parsing to be terminated early
                if (wcscmp(csv, getStr(0, row), L"xx") == 0)
                    break;

                std::map<int, MotionData> innerMap;
                int motionID = csv->getLong(1, row);

                if (motionID != -32768 && motionID != 0) {
                    while (row < 32768) {
                        int newMotionID = csv->getLong(1, row);
                        if (motionID != newMotionID && newMotionID != 0 || newMotionID == -32768) {
                            // this row marks the beginning of a new group, so leave it there
                            // for the next iteration of the outer loop to grab
                            row--;
                            break;
                        }

                        MotionData data;
                        data.uniqueID = nextUniqueID++;
                        data.categoryID = csv->getLong(4, row) - 1;
                        data.soundID = csv->getLong(5, row);
                        data.effectiveRange = csv->getFloat(6, row);
                        data.attenuationGradient = csv->getFloat(7, row);
                        data.playbackTime = csv->getLong(8, row);

                        // key = frame number
                        innerMap.insert(csv->getLong(3, row), data);
                        row++;
                    }

                    outerMap.insert(motionID, innerMap);
                    innerMap.clear();
                }
            }
        } while (++curRow < numRows);

        this->vector.push_back(outerMap);
    } else {
        result = true;
    }

    delete csv;
    return result;
}

There are two nested loops, building up two nested maps. The outer loop looks for a row that has a valid motion ID and that isn't disabled by "x". Once it's found one, the inner loop grabs as many entries as it can, only stopping once it comes across a row that has a different motion ID.

That seems sensible, but there's an issue hiding in plain sight here.

What does the parser do when it gets to the rows for Motion ID 206, our seemingly-cursed iron-only turn action?

On the first row, it sees a valid Motion ID, so it enters the inner loop and adds the first sound: ID 15, frame 24.

On the second row, there's no Motion ID, so it correctly assumes that this is a second sound: ID 15, frame 49.

The third row is disabled, right? It's got the silly little "x" and everything. Hold up there--

Dramatic photo with a tilt shift and vignette effect where I point an accusing finger at the empty Motion ID cell on the third row

The inner loop does not give a fuck about the "x". It only stops when it sees a different Motion ID. This placeholder row does not have one... so it gets treated as another sound for Motion 206.

All the fields are empty, so this leads to the invalid sound entry. Our mystery is finally over. Mostly.

Infrequently Asked Questions

Q: Does it affect any of the other characters or clubs?

No. It only affects Rusk, and only when wielding an iron.

Q: How did this bug make it past QA?

I don't know. Maybe this is part of why the game flopped. We may never find out.

Q: Are you sure you didn't cause this somehow?

Pretty sure. Everything adds up, and the fact that this mistake only exists in one of the seven character CSV files makes me more confident that it's just a bug.

Q: I worked for Sega in 2008 and I know why it happened.

That's not a question. But seriously, can you tell me more?? I want to know the story.

Q: How did you fix it?

I'm glad you asked, I was just about to get to that.

More Patches

I had two avenues: I could patch the game executable to fix the bug, or I could modify the CSV file to stop it from triggering this bug. I decided to take the former, since I'd already made some edits (to disable GameGuard and fix the text encoding issue).

If I wanted to make more substantial changes to the game's behaviour, I'd be better off compiling a DLL and loading it in, but I didn't want to faff about with that at this point. So it was back to one of my favourite hacky techniques: writing assembly directly in x64dbg, and then copying it back to the EXE using the File > Patch file option.

I needed some space to fit my extra code, so I repurposed one of the functions that deals with launching GameGuard, as I knew that would never be executed - I'd already patched out the call to it.

The final approach I took was to add a bit of extra code to the parser's inner loop. It checks the row's leftmost cell for "x" (which previously only occurred in the outer loop), and if it sees that, then it breaks out of the loop. Simple but effective.

With that patch in place, I could now successfully complete a practice round. No more turning issues! But we're not done fixing the game yet--

Security Is A Concept

I'd written my slapdash game server in Python using the built-in ssl module, and it ran fine when I tested it. However, when somebody else tried it on their machine, it failed.

What gives? Well, Splash Golf uses OpenSSL 0.9.8e from February 2007. It does SSLv2, SSLv3 and TLS 1.0, that's it. Newer libraries are straight up incompatible. For most scenarios, that's the behaviour you want, but it's actually really inconvenient when we just want to make an ancient game work.

I was looking to rebuild my code in Rust, so I had a look at potential options and I came across a promising one: the blaze-ssl-async crate, which aims to solve a similar issue - it's a barebones implementation of SSLv3 atop Tokio, for other ancient online games. I gave it a shot, but I was struggling with my very limited async Rust experience, and decided I'd be better off going with something more well-known.

Thus, my other option was to make the game speak modern SSL.

Dynamic Linking Saves Us

Splash Golf pulls in OpenSSL dynamically, as ssleay32.dll and libeay32.dll. In theory, we can swap those DLLs out for a newer version, but that's not actually true - these versions are 16 years old, and the new ones aren't API-compatible, even though Splash Golf only uses a tiny part of the API surface.

Firstly, the process of establishing a SSL context begins with a SSL_METHOD object. OpenSSL provides functions that create these for you, but there's a catch.

Splash calls the SSLv23_client_method function. This sorta exists in the latest OpenSSL release, but not really. As per the linked manual page:

These functions do not exist anymore, they have been renamed to TLS_method(), TLS_server_method() and TLS_client_method() respectively. Currently, the old function calls are renamed to the corresponding new ones by preprocessor macros, to ensure that existing code which uses the old function names still compiles. However, using the old function names is deprecated and new code should call the new functions instead.

That little trick would only work if we recompiled Splash Golf, and we can't do that.

The other issue is that the two OpenSSL DLLs have been renamed to more sensible names in more recent releases; libeay32 is now libcrypto, and ssleay32 is now libssl.


I could have tried to modify the Splash Golf import table to change the DLL names and to change the SSLv23_client_method import, but this seemed finicky, and I wasn't yet sure if I'd need to make any other changes to the game's behaviour. So I decided to instead write a pair of shim DLLs.

// dllmain.cpp : Defines the entry point for the DLL application.
#include "pch.h"

#pragma comment(lib, "libssl")
#include "openssl\ssl.h"

__declspec(dllexport) void my_SSL_CTX_free(SSL_CTX *ctx) {
#pragma comment(linker, "/EXPORT:SSL_CTX_free=" __FUNCDNAME__)
    SSL_CTX_free(ctx);
}

__declspec(dllexport) SSL_CTX *my_SSL_CTX_new(SSL_METHOD *method) {
#pragma comment(linker, "/EXPORT:SSL_CTX_new=" __FUNCDNAME__)
    return SSL_CTX_new(method);
}

__declspec(dllexport) void my_SSL_CTX_set_quiet_shutdown(SSL_CTX *ctx, int mode) {
#pragma comment(linker, "/EXPORT:SSL_CTX_set_quiet_shutdown=" __FUNCDNAME__)
    SSL_CTX_set_quiet_shutdown(ctx, mode);
}

__declspec(dllexport) int my_SSL_connect(SSL *ssl) {
#pragma comment(linker, "/EXPORT:SSL_connect=" __FUNCDNAME__)
    return SSL_connect(ssl);
}

__declspec(dllexport) void my_SSL_free(SSL *ssl) {
#pragma comment(linker, "/EXPORT:SSL_free=" __FUNCDNAME__)
    SSL_free(ssl);
}

__declspec(dllexport) int my_SSL_get_error(const SSL *ssl, int ret) {
#pragma comment(linker, "/EXPORT:SSL_get_error=" __FUNCDNAME__)
    return SSL_get_error(ssl, ret);
}

__declspec(dllexport) int my_SSL_library_init() {
#pragma comment(linker, "/EXPORT:SSL_library_init=" __FUNCDNAME__)
    return SSL_library_init();
}

__declspec(dllexport) SSL *my_SSL_new(SSL_CTX *ctx) {
#pragma comment(linker, "/EXPORT:SSL_new=" __FUNCDNAME__)
    return SSL_new(ctx);
}

__declspec(dllexport) int my_SSL_pending(const SSL* ssl) {
#pragma comment(linker, "/EXPORT:SSL_pending=" __FUNCDNAME__)
    return SSL_pending(ssl);
}

__declspec(dllexport) int my_SSL_read(SSL* ssl, void* buf, int num) {
#pragma comment(linker, "/EXPORT:SSL_read=" __FUNCDNAME__)
    return SSL_read(ssl, buf, num);
}

__declspec(dllexport) void my_SSL_set_bio(SSL* ssl, BIO *rbio, BIO *wbio) {
#pragma comment(linker, "/EXPORT:SSL_set_bio=" __FUNCDNAME__)
    SSL_set_bio(ssl, rbio, wbio);
}

__declspec(dllexport) int my_SSL_shutdown(SSL* ssl) {
#pragma comment(linker, "/EXPORT:SSL_shutdown=" __FUNCDNAME__)
    return SSL_shutdown(ssl);
}

__declspec(dllexport) int my_SSL_write(SSL* ssl, const void* buf, int num) {
#pragma comment(linker, "/EXPORT:SSL_write=" __FUNCDNAME__)
    return SSL_write(ssl, buf, num);
}

__declspec(dllexport) SSL_METHOD *my_SSLv23_client_method(SSL* ssl) {
#pragma comment(linker, "/EXPORT:SSLv23_client_method=" __FUNCDNAME__)
    return (SSL_METHOD *) SSLv23_client_method();
}

BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

I'm sure there's a better way to do this, but it worked, so I stuck with it.

Note that I couldn't define functions with the same exact names as the OpenSSL ones, as those were taken by the real copy of OpenSSL 3.1.2 that I built against. I add a my_ prefix and then I use the /EXPORT: linker option to ensure that my new DLL exports these with the name that Splash Golf expects to see.

With that sorted, I now have four DLLs: my fake libeay32.dll and ssleay32.dll, which replace Sega's versions, and the real libcrypto-3.dll and libssl-3.dll, which do the actual work. Splash Golf now supports the latest TLS version and will happily talk to any modern TLS libraries.

... Now what?

I've met my initial goal of making Practice Mode work... but there's still so much stuff in this game that's inaccessible. After all, it's a very limited mode that only lets you play one course, with no options. It'd be cool to go further, and it'd be cool to recreate more of the game.

Let's do it.

Characters (For Real This Time)

Up to now, I've been supplying the client with the bare minimum info it needs to get past roadblocks - made-up constant IDs, hardcoded stats, and so on. If I want to go further, I'll need to develop my understanding of all these features, and try to get closer to what Sega's official server would have done.

What's an ID?

I wasn't clear on how the different types of IDs tied together (like cid, uid and chr_uid), so I looked through the Closed Beta logfiles to see if I could glean any clues.

The CIDs mentioned in these files range from 614 to 999, and they appear to be consistent within a given play session, but not across different sessions. My educated guess is that CID stands for Connection ID, and this identifies a particular player within the context of a server instance.

The UIDs are all 5 digits, ranging from 10018 to 11000. These do match across different sessions, and the protocol supports the idea that they're persistent user identifiers - the course record structure from SEND_CRECORD includes the UIDs of the players that hold a particular record.

The ChrUID is the weirdest one, and it seems to identify a particular character, but even now I'm still not 100% on whether these are truly a separate ID space or not. All the ChrUIDs seen in the logs are between 4300 and 5100.

... What's a character?

I guess I've finally got to address this. Splash Golf has a few RPG-like aspects in that you can level up your characters and assign skill points to them. The key thing, however, is that you get to have multiple characters.

There are seven in all: Rusk, Miel, Rose, Chocola, Shelly, Gouda and Sect. On your first play, you get to pick between Rusk and Miel, and you can purchase other characters from the "AP Company" outlet in the Shop section. Each character has its own experience and its own set of equippable items.

I spent a couple of weeks mapping out different aspects of the game client so that I could learn as much as possible about the different flows and pieces of data involved. I don't have 100% conclusive answers, but I have what seems to be a pretty good guess.

The First-Time User Flow

In Part 1, I made up a fake response containing the structure I called "StuffF9" (I think the actual name is something like "UData") which tells the client all the initial info it needs to know about the player: name, CID, UID, ChrUID, and some info about progression/ranking.

The function that parses this structure has a curious check: if the chrUID specified is -1, it sets the current character appearance's 'character type' to -100, a very invalid value. I originally saw this and dismissed it as a weird debug mode-related quirk or maybe overzealous error handling, but it's actually an important part of the game - it means that you're a first-time player!

The main menu is handled by CTopTask, and one of the first things it does is decide how this flow should be handled, with three possible outcomes.

The mystery parameter to CharacterEditTask controls what kind of flow it presents.

Type 0 (character type -100)

This suggests that packet 95 creates your initial character, and I'd expect that to assign the ChrUID - but curiously, packet 96 does not contain one. Instead, the client assumes that your account's UID is going to be the ChrUID. More on this later.

Type 1 (player name is the login ID with an underscore)

This is just a "pick a new name" flow - I assume this is something that Sega/ISAO staff could trigger, if they came across a user with a problematic name that hadn't been caught by the filter.

Let's try it out

I modified my server to set the character UID to -1. True to form, upon logging in, I get the welcome message and I get to pick a name.

Screenshot of the name entry box, with Japanese explanatory text and an input field that has an IME selection button next to it. I typed "avg_sega_enjoyer" into it.

Oh, okay, I can't do that. The "don't put Sega in your name" filter is.. uh.. client-side??? I hope they did this on the server side too...

An error has popped up, scolding me for entering a name that contains "_sega"

I picked a different name, and the client now sends packet 105 as expected. I hastily added a bit of code to send packet 106 with the 'success' flag, and now I get this glorious character creation window.

The "Character Edit" window has a big 3D preview of the character, flavour text in Japanese, buttons to cycle between character options, five colour pickers for Hair, Skin, Tops, Bottoms and Shoes, and finally "Reset" and "OK" buttons.

What next?

Ideally, it'd be cool to do more with characters. However... one of the main tasks we could do is equip clothes and items and such, and we don't have any right now. Let's try and fix that.

Shops and Shop Accessories

Since I send the client a SEND_MODECTRL packet where every flag is active, every shop is accessible, through this cutesy interface that looks like a shopping centre map.

The shop menu looks like a map of a shopping centre, complete with icons for escalators, lifts and emergency exits. There are buttons to switch between the different floors and to enter different shops.

At first glance, these are in a very dire state. Every shop is empty, and entering Renaissance simply hangs the game while it waits for a server response.

The contents of the shops is, of course, defined by the server, and we can fill them up by responding to some other types of packets. To do that, we'll need to understand the item system.

How Items Work

Every item has an identifier which is formed out of 22 bits. In certain contexts, like the player's inventory, these also have a quantity attached; in this case, the identifier is shifted left by 10 bits, leaving the lower 10 as a quantity from 0 to 1023.

Mask Shift Length Purpose
003E0000 17 5 bits Character ID
0001F000 12 5 bits Category
00000800 11 1 bit Capacity flag (0: maximum is 50; 1: maximum is 5)
000007FF 0 11 bits Number of the item

Character ID ranges from 1 to 7 for character-specific items (like clothing and hair styles), and is 0 for other items. Annoyingly, the category field is dependent on this, and some of the values overlap.

The capacity flag defines the maximum quantity that you can possess of this item. I don't understand why this is a per-item property, because the game client even contains a function that determines what it should be for a given Character ID and Category. ¯\_(ツ)_/¯ We may never know.

Category (if Character ID is zero)

ID Name Purpose
0 Club Set Set of clubs you can equip in-game
3 Ball Ball you can use in-game
4 Carry (Parameter) Boosts your stats for one shot
5 Carry (Environment) Modifies weather and/or wind for one shot
6 Carry (GroundRes) Reduces the effects of certain terrain types like roughs and bunkers for one shot
7 Carry (PowerGauge) Boosts your maximum power for one shot
8 Carry (Caddy) Allows you to swap caddies mid-round or use a caddy's special ability
9 Hold (Point) Boosts the amount of points you get at the end of a round
12 Hold (Event) Currency you can use to play minigames in Game Center
13 Hold (Ticket) Currency you can use for different game features
14 Hold (Humor) Improves the character's "mood" (?) and allows you to get extra bonus GP
16 Hold (Support) Boosts your stats over an entire round
31 Caddy Caddy you can rent to help you in a round

All of these have a maximum quantity of 50, except for the Club Set and Caddy, where it's set to 5.

Category (if Character ID is non-zero)

ID Name Purpose
1 Head Headwear like hats and masks
2 Glasses Facewear like glasses and ... beards??
3 Tops Shirts and such
4 Bottoms Shorts and such
5 Shoes Footwear like shoes, boots and flippers
6 Gloves Gloves and such
7 Wing Backpacks, wings, etc
15 Hair Style self-explanatory
16 Hair Colour self-explanatory
17 Skin Colour self-explanatory
18 Face Paint self-explanatory
19 Eye Colour self-explanatory
20 Hair Style Ticket Can be exchanged for a Hair Style at Glim
21 Hair Colour Ticket Can be exchanged for a Hair Colour at Glim
22 Skin Colour Ticket Can be exchanged for a Skin Colour at Sunny Trick
23 Face Paint Ticket Can be exchanged for a Face Paint at Sunny Trick
24 Eye Colour Ticket Can be exchanged for an Eye Colour at Honey Eye
31 Character A purchaseable player character

The tickets have a maximum quantity of 50, and the others all have a maximum quantity of 5.

Collecting the Item Data

Everything that the game knows about items is held by a class called cItemData, but the different sources for its contents are scattered in very awkward ways.

Tables in CSV Files

All the non-character-specific items (from the first table), and the "Salon Items" (hair style/colour, skin colour, face paint and eye colour) are initially defined by these CSVs. The exact fields vary throughout the different files, but in general, this provides:

There are also some fields which are never used by the game, and which seem to only be there for the developers' references, or perhaps for the server's use. For example, CarryItem_Parameter.csv contains these four ignored columns:

Item Info Files

Clothing (tops, bottoms, shoes) and accessories (head, glasses, gloves, wing) are all character-specific, and stored in a set of binary files with the .ii01 extension.

I don't fully understand every field present in these, as there's no names or comments to rely on (as there are in the CSV), but these are some of the ones I've identified so far:

SVItemData

The server can supply a list of entries in Packet 309 which will overwrite some of the parameters for specific items, including:

Sell Items

Finally, the last source comes from a trifecta of packets: 90 (SEND_SELLITEMLIST), 167 (SEND_SALON_ITEM_LIST) and 148 (SEND_SELL_CADDIE_LIST).

These do dual billing - they define which items are available in the shops at any given time, and also set their prices.

This means that we have no frame of reference for how much a particular item was supposed to cost. Some of the CSV files include a "Price" value but they only specify a range (low, medium or high) and not an actual number.

Packets 90 and 167 include a variable-length list of structures, with the following info awkwardly packed into various bitfields:

Field Description
Item Code The 22-bit code that uniquely identifies this item
Price Ranging from 0 to 0xFFFFF (1,048,575)
Currency Sets whether the item is sold for GP, for SC, or only for tickets (in which case the price is not used)
Marketing Type Allows an item to be designated as "New!", "Hot!" or "Sale!", which affects its sorting and presentation in the shop
SP Price Sets the price of this item in Goldrush Fashion/Goldrush Goods

Packet 148 changes this up a bit for caddies:

Field Description
Item Code The 22-bit code that uniquely identifies this item
Price (3h) The cost to rent this caddy for 3 hours
Price (3d) The cost to rent this caddy for 3 days
Price (30d) The cost to rent this caddy for 30 days
Infinite Rental If set, the caddy can be permanently rented
Currency Sets whether the item is sold for GP, for SC, or only for tickets (in which case the price is not used)
Marketing Type Allows an item to be designated as "New!", "Hot!" or "Sale!", which affects its sorting and presentation in the shop

With this knowledge, I'm almost ready to put items into the shop, but I'll also need some money.

Currencies

There are three main currencies in SEGA SPLASH! GOLF... I think. Not counting tickets.

Packet 93 requests your amount of money, and the server replies with packet 94, REP_MONEY, which just contains your current amount of GP and of SC. These are shown in the user interface while you're on the main menu, in My Room, and in Shop.

GP (Golf Points) are what you earn from playing the game, and SC (Sega Coins) are what you can buy using microtransactions. The real curiosity here is... what is "SP"?

Packet 204 requests it, and the server replies with your SP balance in a packet called SEND_NP. It's used by the "Goldrush Goods" and "Goldrush Fashion" shops exclusively.

The archived game guide doesn't mention these! There's a GP and SC page that explains the first two. The Shop page talks about the other shops on 4F, but doesn't mention the Goldrush shops.

None of the images in the guide were archived, so we can't even tell if they were present at the time - maybe they just never got released? We know that they could enable/disable each shop remotely through ModeCtrl, after all.

Can Hangame save us again?

... not really. We can see Hangame's version of the Shop guide complete with all its images, but we can't learn much new.

One of the first screenshots confirms that the "Simple shirt" was on sale for 1,000 GP. Some of the other screenshots show other item prices, but the image is too small for any of them to be readable.

Hangame's guide is for an older version of the game where none of the facilities on 4F were available.

Let's get some items in

I added some code to my server that would generate item lists containing everything in the game, with highly arbitrary prices, just to see what would happen. And it works: I can browse everything, including items they never even completed or released!

For the clothing and salon outlets, there's a dropdown that lets you pick which character you are browsing items for. The characters you don't own are dimmed out. You can buy clothing for a character you don't own, but the game will warn you of this fact and ask you to confirm that you're sure.

The shop interface shows items on the left, a 3D preview of the character in a dressing room on the right, and category selection tabs at the top. A popup dialog with mangled Japanese text contains an error for Category 4, ItemId 69

There's a number of items that are obviously incomplete. Some have no icon (and just show a placeholder "Now Printing" image), some have no model, and others have neither. Some broken items will just fail quietly while others will pop up an error.

The weird custom scrollbars make it kinda annoying to scroll through all the items. That said, this might have been less of a problem originally - I have no evidence to support this, but based on what other games do (and the server-side listing), I'm assuming that the shop would only sell a subset of the catalogue at any given time.

Let's buy some items

We've got currency, we've got stock in the shops. It's time to partake in the charade of capitalism!

The Buy Window shows a scrollable list of the items in my basket (with controls to adjust the quantity), a reset button, a summary of how it'll affect my balances, and finally Buy/Cancel buttons.

Clicking Buy gets me this nice popup, which shows everything I'm going to get.

This is in the clothing shop, so the only valid quantity is 1, but if I'm buying consumable items, I can adjust that.

The "ticket" indicator lights up if I'm using a ticket to purchase the item.

Below the list of items, there's a bit of Japanese text asking me to confirm that I want to buy these items, and then three GP and SC amounts:

There are six possible request packets for the different types of interactions - three for when you're buying an item with GP or SC, and three for when you're using a ticket. These are divided into one for inventory items (clothing and consumables), one for salon items, and one for renting a caddy.

I first tackled buying inventory items using GP/SC. The client sends packet 91, containing an item code and quantity. The server responds with a result code.

The valid results are all helpfully enumerated by debug messages: OK, BALANCE, NO_ITEM, INVALID_COUNT, INVALID_ITEM_TYPE, ERROR and NO_TICKET. Thank you again Sega!

After a successful purchase, the client will update its local copy of the player inventory to contain the new item, but it doesn't seem to update the balance or re-request it from the server. Therefore, my server sends an updated REP_MONEY packet immediately. I have no evidence for Sega's official server doing this, but it seems to make sense - why would the player want to see stale data?

I implemented this, and also hooked up packet 131/132 (the request/response pair for getting the player's inventory), and purchased some items. And wouldn't you know it - My Room is way more useful now!

Investigating My Room

Now that we've got some interesting stuff to look at here, let's do that.

Golf Setting

I've selected the "Carry Item" tab in the Golf Setting section. There's a list of items on the right, a 3D preview of a potion in the top left, and eight slots representing my Golf Bag's contents at the bottom left.

I showed off the Closet in Part 1, so let's delve into Golf Setting first. This one lets you pick your club set, ball, caddy (...but I haven't implemented rentals yet), select Carry Items for your Golf Bag, and equip Hold Items.

I don't need to do much for this on the server-side, but I do need to understand what happens when I select some items. As it turns out, the game sends various packets depending on what I've changed:

A naive implementation of this would be very straightforward; I'd just be taking the data supplied by the client and storing it.

A proper implementation should do some checks on it, to make sure that you're only wearing clothes that you own and equipping items that you're allowed to use. But for my purposes, I'll quietly pretend that's not an issue.

Closet

The closet interface shows a large preview of my character on the left, and a tabbed list of items on the right. He's wearing floppy dog ears, a bright red collar, a pair of shorts that has a curly tail attached to the back and paw boots.

In much the same vein as the Golf Setting page, making this work just involves properly implementing REQ_CHG_APPEAR.

I'm very entertained by the dogboy outfit that Rusk can wear, as shown in this screenshot. The tail wags at a surprisingly fast rate. For some reason, the ears and paw boots require a class of G-2, but the top and shorts can be worn with G-4 (which is the starting class).

Furry bait aside, though, there's one thing accessible through the Closet that I want to draw attention to, because it's pretty important.

Parameters

What gives this game its RPG aspect is the fact that you can level up your characters and allocate skill points to them. The curious "Param Edit" button at the bottom left corner of the Closet pops up this complex dialog.

A popup window titled "Parameter". The top row shows your class/rank, MP, and a widget to select which class you want to edit. There's a bar showing that you have 24 out of 24 class points. Then, there's four separate sections for Power, Control, Impact and Spin, which allow you to allocate points to each category, while displaying your level in that category.

In order to make any sense of this, we really need the original documentation. You can read it here on the Sega website if you speak Japanese or use a translator, but I'll try and summarise what I learned.

MP, Exp and Lv

At the end of each round, you accrue both Master Points (MP) and experience. Your account has one MP counter, and each of your characters has four experience counters - for power, control, impact and spin.

Your amount of MP determines your class/rank. Classes are split up into G, F, E, D, C, B, A, S, and each of these is split up by 4, so the lowest possible rank is G-4 and the highest is S-1.

Your character's amount of experience determines their level (Lv) in each of the four skills. You gain exp based on what kind of club you use. If you gain levels in a skill, then you can assign class points towards it.

Clothing/accessory boosts act as extra points towards a particular skill; you can see this occurring in the above screenshot. Every skill is Lv. 00, so I'm unable to use any of my class points, but I still get some boosts from clothing, and these are marked in orange as "Assist".

Also, you can't assign more than half of your class points to any skill, even if your level in that skill would otherwise allow it. This is the green "Class Limit" line - none of the parameters are allowed to go over 12.

Class Options

Still with me? Great. There's a bit of extra complexity. See the "Class Edit" toggle at the top, that allows you to pick a class?

If you've gained enough MP to make it to F-4 or beyond, then you can temporarily downgrade any of your characters to a lower class. This allows you to participate in tournaments that have a maximum class limit. The game also stores your skill point assignments separately for each class.

Alright, glad we could clear that up.

Ranking

This one's kinda boring. You can search the leaderboards and see who did the best (or worst) across the whole game, on a ton of different metrics. What am I going to do, make up fake data? Ehhh, I've done enough of that already... maybe later.

My Album

Caddies will automatically take photos (screenshots) to commemorate certain events, and they show up in this section. I've not actually gotten round to making caddies work yet, though, because they don't work in Practice Mode and multiplayer is a whole different kettle of fish.

This one is actually kinda cursed, because the viewer is a local webpage that loads in an embedded Internet Explorer. The game generates some JavaScript that contains details on what images to show and then writes it to a file.

Mail Box and My List

I can't really do these until I've got a more robust multiplayer server, as they both require the existence of other players.

Mail Box implements a rudimentary messaging system. My List pops up a window where you can view the players in your current lobby (if you're in one), your circle (which is apparently a feature they never finished), your friends list and your blocklist.

Status

Popup window that displays my name, player title and various stats alongside a small 3D preview of my character. There are tabs for Records, Winnings, Caddie and Equip, and a greyed-out Event tab

This is a pretty straightforward window that just displays info about myself. I think I'm supposed to have a player title, but I haven't implemented the machinery to let you get one yet.

There's also a version of this window that lets me view info about other players but I won't be able to try that out until I've gotten proper multiplayer support into my custom server.

"Records" shows all my stats, with sub-tabs for picking between general records, course-specific records and tournament records. "Winnings" shows 3D models of awards, but I don't have any yet.

"Caddie" shows details about my caddy, but I've not implemented that yet, so it's just blank right now. And finally, "Equip" shows my current loadout, with sub-tabs to pick between my clothing and my golfing equipment.

Trash Box

The Trash Box interface shows the contents of my inventory on the left side, and a list of the items I've selected on the right side.

This is the last part of My Room; it's pretty predictable but I figure I may as well show a screenshot here too.

You pick items from the left side, select a quantity, and then get a confirmation popup before they get added to the "Trash Item" list on the right.

Once you've picked everything you want to get rid of, you click OK, get a confirmation popup, then get a second confirmation popup, and then it sends a single packet to the server containing the item IDs and quantities.

I've not implemented this in my server yet, but it seems straightforward, so I'll do it at some point.

... Now what?

I've gotten as far as I can get without proper multiplayer support, but I can't keep putting that off.

Asynchronous Uncertainty (free name idea for your next album, if you have a band)

My server is currently in a state of flux as I try to decide what I want to do with it. The first proof-of-concept was a Python script, but then I rewrote it in Rust™, using Tokio. Tokio is pretty cool, but it's taken me a while to adjust to its mental model (and to async Rust in general).

I've worked on game servers before, but it's always been old-fashioned single-threaded code with blocking calls everywhere and whatnot. This makes certain things way easier (e.g. letting players query info about other players that are online) but also has obvious performance implications.

I wanted to use this project as an excuse to get more experience with doing things The Right Way... but I'm also very aware that I've already sunk a lot of time into this; more than I ever planned on. I started digging into this game on the 8th August, and tomorrow will mark 4 weeks since then.

Am I capable of doing it? I think so. Do I have the motivation to do it? Not really.


At this point, I've done so much research that it would be a shame to just burn myself out and stop here, before I ever release anything. So with that in mind, I think I'm going to rip out the server core, rebuild it using a simpler approach, and hopefully publish a nice working server later this week.

It may not be as glamorous - but I shouldn't let perfect be the enemy of the good. So many of my projects have fallen to that fate already...

Next Time...

With this post, I've now documented everything I've done so far - it's new territory from here onwards. Thank you for following my silly adventures 🥺

I'm hoping to assemble a server that actually lets you play with somebody else, and then release that later this week along with a third, probably shorter, writeup. No promises yet, but I'd love to wrap it up and properly restore this game's glory!

If you enjoyed this, you might also like some of my other posts, like the time I wrote a Psion PDA emulator or the 8-part saga wherein I bricked, unbricked and then fixed a firmware bug in my mouse.


Previous Post: They Made A Golf MMO With Sonic In it (Real!) (Not Clickbait!) (Only A Bit)
Next Post: Splash Golf Revival, Part 3: The Final Splash