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-
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!
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.
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:
SEND_LOBBY_NUM
) with a count of 4SEND_LOBBY_DATA
) to fetch info about that lobbyACK_ENTER_LOBBY
)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.
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...
Okay, that's kinda boring. I mean, this game also contains FISH CLUBS.
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.
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?
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?
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.
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.
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:
SEND_IDPASS_G
with ACK_IDPASS_G
(containing a placeholder user record) and SEND_MODECTRL
(containing an array of 1 bits) gets me to the Mode Select screenORD_COLOR_ELEMENT
unlocks the controls so I can click on stuffREQ_CHG_CRCHRUID
(which selects your current character) and REQ_CHG_MODE
(which selects a mode)So here's what happens if I try to enter Single Mode now:
SEND_USTAT
, one of the few bidirectional packetsI 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.
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.
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.
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.
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?
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.
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.
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??
...alright, now this game is just taunting me. The progress bar moves up to 95% and then stops.
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.
That's it, our mission's done, you can go home now. Or... is it?
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.
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.
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.
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?
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?
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.
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--
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.
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.
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--
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.
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.
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.
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.
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.
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.
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.
"_" + login_id
?
The mystery parameter to CharacterEditTask controls what kind of flow it presents.
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.
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.
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.
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...
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.
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.
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.
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.
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.
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.
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.
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.
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:
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:
The server can supply a list of entries in Packet 309 which will overwrite some of the parameters for specific items, including:
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.
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.
... 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.
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.
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.
We've got currency, we've got stock in the shops. It's time to partake in the charade of capitalism!
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!
Now that we've got some interesting stuff to look at here, let's do that.
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:
REQ_CHG_CHR_PARAM
, containing my character's class, parameters, club, ball and caddyREQ_CHG_APPEAR
, containing my character's appearanceA 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
I've gotten as far as I can get without proper multiplayer support, but I can't keep putting that off.
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...
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