Splash Golf Revival, Part 3: The Final Splash

Wherein I finally make the multiplayer mode in SEGA SPLASH! GOLF playable (more or less), and release the source code to my custom server.


Welcome back to the final instalment of this absurd series about reverse-engineering and reviving a long-dead MMO. A quick recap:

In part 1, "They Made A Golf MMO With Sonic In it (Real!) (Not Clickbait!) (Only A Bit)", I dug into the game for the first time and managed to get it to the main menu. In part 2, "Reviving Sega's forgotten golf MMO after 14 years", I got the single-player Practice Mode working and put the game's entire item catalogue into the shop.

As cool as this is, I think I've objectively done a pretty terrible job so far. "MMO" stands for Massively Multiplayer Online game. I've made the game connect to a server, so you can say it's online, but I've completely failed at the massively multiplayer bit. That's a 33% success rate. I'd like to get a passing grade for this project.

Furthermore, the Practice Mode is extremely limited and you can't experience most of the game's content that way. It'd be a real shame to cut it off here before I fix the other bits.

It's Multiplayer Time!

Oh, by the way, if you're not interested in reading the 39 pages of fluff and just want to see something cool, scroll to the bottom of the page and there's a nice demo video.

The Existing Game's Scope

First off - what do we need to implement? SEGA SPLASH! GOLF includes a number of cross-player online interactions which we can summarise as follows:

It'd be cool to eventually get all of these working, but we need to prioritise a little bit.

Our Server's Scope

As I mentioned at the end of Part 2, I want to actually get something usable out of this project before I lose steam and/or run out of free time, and that means ignoring my perfectionist streak and setting some boundaries.

I'm not aiming to write a production-quality game server that could handle hundreds or thousands of players. I'm aiming to reimplement enough of the game's interactions so that people can at least experience what it had to offer, and document them well enough that someone else could extend it if the need ever arises.

I just wanted to get this out of the way first because I know some of the design decisions I make in the next section will seem rather silly, and I'm fully aware of that. This is not meant to be Web Scaleβ„’; it's meant to be quick and dirty so that I can adapt to whatever requirements this game throws at me as I discover them.

You good with that? Let's move on.

Building a Prototype Server

My Splash server is a Rust project built around Tokio, an asynchronous runtime. All persistent state is stored in a SQLite database.

Networking Architecture

I wanted to use this project as an opportunity to make myself more familiar with async frameworks like Tokio, since most of my previous experience involved single-threaded servers that use things like select() or epoll to multiplex over sockets. It's not impossible by any means, but it is a bit of a paradigm shift - and game servers in particular can be a little trickier, because of state.

The Tokio tutorial begins by explaining how to write a basic server (in "Spawning") where each connection is a distinct task, but where there's no shared state. They follow up with "Shared state" which explains how to use mutexes to let multiple tasks safely access the same data, and then "Channels" which explains message passing.

I understood these basic concepts pretty well, but it took me a bit longer to figure out how to map these to the game programming concerns that I'm familiar with.

... How do I do in-game chat?

Conceptually, this is simple: When a user types in "hewwo! :3" and presses Enter, the client sends a SEND_MESSAGE packet to the server. The server then puts their user ID into the packet (so that they can't pretend to be other users!) and then sends it to the recipients - for room chat, that's the other players in the room, and for DMs, that's just the target user. But how do I map this into Tokio's models for shared state?

I could have an Arc<Mutex<Vec<String>>> that stores messages, and each user would be able to safely add to it by borrowing that mutex. That solves one half of the issue, but I still need to send the message to all of the recipients. They're not polling the server for messages, they're just expecting to see it appear.

Channels, or message passing, seem like the more sensible approach, right? I can give each user a channel. However, I still need a way to discover which channels exist, and, wait... this is going to get even weirder once I try to do things that involve game logic.

Serialisation (no, not the Serde kind)

In an async world, every task is fully independent, for better or for worse.

Consider, for example, a website that uses reCAPTCHA to try and prevent stop bots from signing up. When somebody signs up, the backend will need to send a request to reCAPTCHA to verify a challenge, wait for the reply, and then it'll either create an account or return an error. Crucially, the backend can keep on serving other users while it's waiting for reCAPTCHA to respond, and that's the beauty of async code.

However... for something like a game server, this behaviour might even be the antithesis of what we want. Let me explain.

Say you have a multiplayer game where 12 players are running around a fortress. Each of these players is a separate task, and it has the ability to send a packet to the other 11 players. So what happens when someone moves around, and I need to relay that? I could write some code like this:

impl PlayerTask {
  async fn relay_player_movement(&self, x: f32, y: f32, z: f32) -> Result<()> {
    for teammate in &self.teammates {
      teammate.send(Packet::Move { id: self.id, x, y, z }).await?;
    }
    Ok(())
  }
}

This looks solid, and maximises async-ness, but I've actually introduced a bit of uncertainty here. There are 12 players, so each player has 11 teammates, right? It's entirely possible for two players to move at the same time, and send out those move packets to their teammates inconsistently:

Some of the players have now received A's move followed by B's, and some of them have received B's move followed by A's.

For just player movement, this is probably fine, but there are many, many game scenarios where you would really want things to happen in a well-defined order. This is why serialisation matters!

Splitting things into Tasks

This is where we can really take advantage of message passing! If we want everything within this group of 12 players to happen in a predictable fashion, then we can create a task that reads messages from a channel and then acts on them in order before it reads another message.

struct Room {
  members: Vec<mpsc::Sender<Packet>>
}

enum RoomMessage {
  PlayerMove { id: usize, x: f32, y: f32, z: f32 }
  // ... and more
}

impl Room {
  async fn handle_message(&self, msg: RoomMessage) -> Result<()> {
    match msg {
      PlayerMove { id, x, y, z } => {
        for member in &self.members {
          member.send(Packet::Move { id, x, y, z })
        }
      }
    }
    Ok(())
  }

  fn start() -> mpsc::Sender<RoomMessage> {
    let (msg_tx, mut msg_rx) = mpsc::channel(1024);

    tokio::spawn(async move {
      let mut room = Room { members: Vec::new() };
      while let Some(msg) = msg_rx.recv().await {
        room.handle_message(msg).unwrap();
      }
    });

    msg_tx
  }
}

Okay, so I'm going to admit something here: I started off with this approach, and then decided to bail on it slightly, in the interests of time.

Recreating the Splash Golf server is very experimental, and I don't have a perfect mental model of all the interactions that the game requires. So I'm taking the slightly easier way out, and feeding all interactions through a GameServer task. This is absolutely not ideal from a performance standpoint, but this test server is never going to see enough players for it to matter.

Once I have all the game's features working, then I can look at refactoring it, but for now, this will do, even if it does hurt my pride a bit.

Storage Architecture

I've talked about how to store state while the game is running, but I'd really like to store things in a more permanent fashion. I don't want to go through the character creation flow every time I make a change and restart the server!

There are so many ways to do this, but once again, I'm prioritising code that I can write quickly and iterate on quickly. I'm using SQLite using the rusqlite crate. Mapping it into async isn't super easy, but it's doable - I decided to create a Tokio task for the database, so that both my login server and my game server (which run in the same process) can talk to it.

A Schema

What information do I actually need to store? We can actually glean some clues about the original game's schema from how the protocol works.

I'll create a SQLite table for each of these sections as I implement them.

Another Embarrassing Admission

In the interests of time, I'm doing something ridiculous, but hear me out. For most of this data, I'm just using Serde to encode a struct as JSON and then storing that into a SQLite text field.

Are there better ways to do this? Absolutely. But I've shaved enough yaks already. A quick-and-dirty solution will allow me to move on and focus on the actual gameplay, and it's all isolated into a task with a clean interface, so it's easy to replace later on.

Building some Game Logic

I've rambled enough; let's do something interesting and try to implement some multiplayer functionality. Our first target will be the VS Mode.

Step 1: Selecting a Lobby

When I select VS Mode, I get a blank lobby window and the game sends packet 10, GET_LOBBY_NUM. The flow here is pretty straightforward:

The lobby data structure is also simple: it just includes the current and maximum player counts, the name and 32 unused bytes. I quickly implemented this and got a lobby to appear!

The "Lobby Select" window has a list with one entry, which I named "Foo", and buttons to Reload and Close the list.

Step 2: Entering the Lobby

When I try to enter it, the game sends the REQ_ENTER_LOBBY packet, and waits for a reply. I need to acknowledge it with ACK_ENTER_LOBBY, at which point I receive two more packets - packet 18 requests the list of rooms, and packet 87 requests the list of players in the lobby.

Screenshot of the lobby itself. The left half of the screen is an empty list of rooms, and the right half is split between a tabbed player list and a tabbed chat box.

Getting to this point was pretty simple, but this raises another question: how do I deal with the state changes that occur after entering the lobby? As in: if a player joins or leaves, what happens?

If I leave the lobby, the game just sends a new REQ_CHG_MODE packet, so I'm assuming that the original server would kick you out of the room in this case. However, I still need to make sure this reflects for the other players.

When I enter the lobby, the client sends packet 87 to ask for the list of members, and the server sends one instance of packet 88 (SEND_ULIST_L) for each of those members. It's worth looking at exactly how the client handles that packet...

// Pseudocode with assumed type/method names, as always
void PlayerList::handleSendUListL(UListL *ulist) {
    // Find the existing index of this player, if any
    int currIndex = -1;
    for (int i = 0; i < this->lobbyMembers.size(); i++) {
        if (this->lobbyMembers[i].uid == ulist->uid) {
            currIndex = i;
            break;
        }
    }

    PlayerPlateData ppl;
    ppl.initFromUListL(ulist, -1);

    // Is this player in the same lobby as us?
    bool isPlayerInLobby = (ulist->mode == this->curMode) && (ulist->ln == this->curLobby);

    if (currIndex >= 0) {
        if (isPlayerInLobby)
            this->lobbyMembers[i] = ppl;
        else
            this->lobbyMembers.erase(this->lobbyMembers.begin() + currIndex);
    } else {
        if (isPlayerInLobby)
            this->lobbyMembers.push_back(ppl);
    }

    int sortType = this->playerSort.currentSelection;
    auto sortFunc = this->playerSort.isAscending() ? PlayerSorts[sortType].asc : PlayerSorts[sortType].desc;
    if (sortFunc)
        std::sort(this->playerSort.begin(), this->playerSort.end(), sortFunc);
}

If SEND_ULIST_L was only used for the use case of "I've entered the room and need to know who's in it, so that I can populate the list", then half of this logic would be unnecessary. However, it handles lots of cases, which implies to me that Sega's official server would send this packet to lobby members every time a player's state changes.

So let's enumerate the situations we care about:

These all check out, let's go for it.

Tangent: How do I test this?

I'm now at a point where I need to try and actually connect multiple clients to the server, and I don't want to faff about with multiple computers if I can avoid it. Luckily, there's a fix.

I opened two copies of the game in separate windows, which are both in the same lobby, showing up in both player lists as "Ninji" and "Ninji2"

Splash Golf uses a fairly common mechanism to stop you from running more than one instance at the same time; it tries to create a named mutex. I looked at the related code and thought about trying to patch it out, and then I realised that there was an even easier workaround: make a second copy of the EXE, and just... change the mutex name for that copy.

Don't overcomplicate things if you don't need to :3

With that sorted, I can test my assumptions about the user list handling code, and they seem to work out! If I leave the lobby, I immediately disappear from the other client's player list, and the moment I return, I appear again.

Step 3: Creating a Room

Things get more complex here. While lobbies appear to be pre-defined by the server, rooms can be created by players using an in-game interface. What's more, they have a number of parameters. The exact meaning of these parameters differs between Mode 1 (VS) and Mode 2 (Competition). I won't go into the details in depth for now, but here's a side-by-side comparison so you can get an idea.

Two "Create Room" windows side by side. Both of them have text fields for a room name and password, a large widget to pick a course (defaulting to "Random") and OK/Cancel buttons. The VS room window has 7 configurable parameters all labelled in Japanese, and the Competition room has 16 configurable parameters, some of which are greyed out by default.

Luckily, at this point, we don't have any reason to try and interpret these parameters - storing them in the room data and then regurgitating them to the client will suffice. So.. what happens when I try and create a room?

The client sends packet 16, containing what appears to be the exact same "RoomStat" structure that I mentioned in part 2, and then it just waits for the response- which, of course, is ACK_MAKE_ROOM.

After a room has been created, the client sends REQ_ULIST to fetch the list of users in the room. The server replies using one SEND_ULIST packet for each member, and ACK_ULIST_R at the end.

I wasn't sure if making a room would automatically join me to the room, or if I'd have to manually enter it afterwards, but there was only one way to find out: create one and see what happens.

The client abruptly jumps to a black screen for a second, and then returns to the lobby, with no feedback. Wait, what?

It turns out that as a safety check, the client looks through the user list to ensure that your user ID is present on it, and if it's not, then it just boots you back to the lobby. I modified my REQ_MAKE_ROOM handler to automatically add the room creator to the room, and we were in business.

My default character stands in a 3D-modelled room. Floating windows display the room info, the chat box and a player list (showing me with a crown icon and a G-rank icon), and the bottom of the screen has buttons to edit the room settings or leave as well as greyed-out buttons for "Team", "Gallery" and "Start".

This is the beautiful "VS Lounge", as rendered using the lowest graphical settings. You can right click on your character to select different poses, but they're all named using Japanese which I can't read.

πŸ’­ ...maybe I should switch to an x86 machine to take screenshots... but that'd mean moving to the other side of my room. Maybe later.

The crown icon next to my name in the player list means that I'm the Room Leader, and that means I get to wield the "Start" button as well as kick people out - but that's still a ways off for now. Regardless, we're making good progress! Can we get anybody else into the room?

Step 4: Entering a Room

The work I've done for lobbies serves me well here, as this is a pretty similar process. There are quite a few packets I needed to implement, but this is bringing together my knowledge of the game's internals in a rather satisfying way.

There's a lot of stuff, but half of these packets are not specific to this particular flow, and they're just general primitives - for example, REQ_UDATA is used in other parts of the game as it simply fetches a user object by their UID.

The same room interface now contains two players, both standing in the room and showing in the player list. For the new member, the bottom bar has a highlighted "Ready" button instead of "Start".

Once I've gotten all of that implemented, my second client can now join the room!

πŸ’­ ... I should probably reimplement item purchasing (which I ripped out during a major refactoring) so I can take screenshots with something other than the default outfit.

Step 4.5: Readying Up

My second player gets a "Ready" button; clicking on this sends SEND_USTAT to the server with flag 1 enabled.

I'm still a little fuzzy on how UStat works, as it includes a few different things using different bits. The lowest bit (| 1) signifies readiness, but this only matters within the context of a room. On the other hand, it also encodes bits for "am I in a round?" and the idle/busy statuses, and I'm pretty sure that these appear on player lists even outwith your room, e.g. in the lobby, or on your friends' friend lists.

For now, I'm handling SEND_USTAT naively and I just relay it to the other members of the lobby, but I can revisit this later on - I've not even implemented friend requests yet, so I can pretend that's not a concern for now.

impl GameServer {
    pub(super) async fn handle_send_ustat(&mut self, who: usize, cid: CID, uid: UID, stat: Stat) -> Result<()> {
        // Only allow this if it comes from the same user
        if self.conns[who].cid == cid && self.conns[who].uid == uid {
            let old_stat = self.conns[who].stat;
            self.conns[who].stat = stat;
            debug!("{} stat:{:X} -> {:X}", self.conns[who].name, old_stat, stat);

            // Notify everyone who might care
            let my_mode = self.conns[who].mode;
            let my_lobby = self.conns[who].cur_lobby;

            for conn in &self.conns {
                if conn.cid != cid {
                    if my_mode == conn.mode && my_lobby >= 0 && my_lobby == conn.cur_lobby {
                        conn.write(Packet::SEND_USTAT { cid, uid, stat }).await?;
                    }
                }
            }
        } else {
            warn!("{} tried to change someone else's ustat!", self.conns[who].cid);
        }
        Ok(())
    }
}

With this done, the room system is coming together. When my second player is Ready, the leader's greyed-out "Start" button becomes active, and "All Ready, Press Start" appears in the middle of the screen. We're so close πŸ‘‰πŸ‘ˆ

Step 5: Starting a Game

We now return to familiar territory. In Part 2, to get Practice Mode to work, I had to implement packet 31, which I'm calling REQ_GAMESTART because I'm like 95% sure that's what it's called, even though none of the debug strings mention the official name for it.

There is a lot of data in ORD_GAMESTART:

To properly implement all of this, I'll need to understand the room parameters, and I'll need to better understand all the random stuff - e.g. what are the valid values, and do any of the CSVs include info that could help me?

I'm going to hold off on that, and stay with my old trick where I pilfered all the parameters from a closed beta game in 2007. I fill the CID array with the connection IDs of the room's players, and I send my artisanal ORD_GAMESTART packet to all of them. This gets us to the loading screen, but it's not quite enough for the multiplayer mode, for a very good reason. Different players' machines will take different amounts of time to load the game data, so each client keeps track of how the others are doing.

This is easily implemented by relaying the SEND_LOADSTAT2 packet to everyone else in the room. And wouldn't you know it...

I'm in-game and one of the characters is preparing to make their shot. The left side of the screen now shows a head for each player in the room, and a "Shot" icon marks which one is active.

I'm probably too old to say this, but this is pretty poggers. Can I get a W in the chat?

...actually no, I can't, I haven't implemented chat yet. oops. Well, it doesn't matter, we're more interested in playing the game.

How To Become A Gamer

Can you believe it? We've made it through 2.5 blog posts, thousands and thousands of words, and ostensibly revived this multiplayer golf game, but you still can't actually, uh, play a multiplayer round of golf. We're about to fix this, after this message from our sponsor.

Squarespa-- just kidding

I took this opportunity to take a break from this writeup, and tie up some loose ends. I'd previously implemented a proof-of-concept Shop where you could buy items and they'd show up in your inventory, but that was before I rewrote half of the server to support crucial elements like a database and multiple connections.

With a bit more work, I brought this logic back, and now I could kit out my character.

In-game screenshot where my character is now getting ready to make their shot - using the dogboy costume and a non-default club

Okay, that's better. Let's play a round.

Figuring out the Netcode

I've already gotten over the first synchronisation hurdle by implementing SEND_LOADSTAT2, but what else awaits me? And a more crucial question is: how much work does the server need to do?

The different gameplay packets range from obvious (like SEND_DIRECTION) to inscrutable (like SEND_COMMAND). There's no way I can implement everything perfectly right away, but I can try and chip away at portions of the system, and see how far I can get into a round.

Teeing Up

The first packets I saw involved are SEND_CRCLUB and SEND_DIRECTION, which are used for sending your currently selected club (iron, wedge, putter, etc) and your direction to the other players - so that they can see what you're doing, and judge you for your choices.

I implemented these in a very naive fashion by just relaying the packet to every other player. So far, so good - can I keep using this tactic for the other packets?

Making a Shot

After I've positioned myself, I use the mouse or keyboard to pick the power and impact for my shot, as well as optionally applying a draw/fade. This info is relayed using the SEND_SHOT packet.

My client also sends SEND_BALLPOS with some data (X/Y/Z position, hole index, and 'stat'), followed by SEND_STOP_BALLPOS with updated versions of the same data.

    /// Sync the shot info to the other players in a room
    pub(super) async fn handle_shot_info(&self, who: usize, clock: u64, dir: f32, power: i16, impact: i16, hit_x: i8, hit_y: i8, club: i8) -> Result<()> {
        let packet = Packet::SEND_SHOT { clock, cid: self.conns[who].cid, dir, power, impact, hit_x, hit_y, club };
        self.send_packet_to_roommates(who, packet).await
    }

    /// Sync the ball position to the other players in a room
    pub(super) async fn handle_ballpos(&self, who: usize, hole: i8, stat: i8, x: f32, y: f32, z: f32) -> Result<()> {
        let packet = Packet::SEND_BALLPOS { cid: self.conns[who].cid, hole, stat, x, y, z };
        self.send_packet_to_roommates(who, packet).await
    }

Surely it can't be this simple... I restarted the server and logged in my two clients, got them into a round, and then made a shot. To my surprise, it all seemed to work perfectly! Both clients showed the shot, and they seemed to come up with the same outcome. Wow.

Commands

I saw a lot of SEND_COMMAND in the logs, so it seemed like I'd have to try and understand this packet... somehow. It has a simple structure: a numeric command ID, two arguments (p0 and p1), and a flag that I don't have a name for.

I initially tried doing the same thing that I did with all the other packets, and I just relayed it to all the other clients. That didn't work; the client got stuck, and it seemed like it was waiting to receive an echo of the command that it just sent. So then I modified my logic to add that, and it fixed that issue, but it created a different one: I kept on accruing infinite bonus GP, as the client would send command 50 in response to command 50.

This gave me an idea. The unknown flag was 0 for most commands, but for this specific one, it was 1 - maybe that means "send the packet to everyone other than me"? I tried that and it worked, mostly.

Desync Hell

At this point, I was over the moon, because I'd gotten simple multiplayer interactions to work... but when I was testing, I ran into a concerning issue. Sometimes, the two clients would get out of sync and soft-lock because it seemed like they couldn't agree on who should be playing.

I tried this out multiple times. I used OBS to record a video of both clients side-by-side and played until I hit the soft-lock, then I pored over the video and compared it to the clients' log files to try and figure out what the problem might be. Unfortunately, I couldn't glean much info from the logs without doing more research on how the gameplay mechanics were implemented inside the cRoundMain class.

I did notice that the two clients appeared to disagree on what the "ShotResult" should be, so first I set my attention towards figuring that out.

Shot Result What it means
Below 100 The GroundType of the terrain that the ball landed on
100 Ball entered the hole
102 OB (Out of Bounds)
104 Water hazard
106 Some other failure state

One of the clients thought the ball had gone into the hole, while the other client thought the ball was still on the green. This was a step in the right direction but I still didn't know why this occurred, so it was time to do more research.

State Tracking

cRoundMain has two different state machines; the former is referred to as the "phase", and the latter is referred to as the "state" (and only takes effect when the phase is 12). I'm 99% sure that they had enumerated names for each state, but none of the log files tell me what they are, so all I've got is numbers.

The states range from 0 to over 85, so there's a lot of them. I didn't dig through everything but I managed to glean some very useful info about the state management...

State 17 is the beginning of a shot; it loads the ball to display, and then picks which state to jump to (18 or 20) depending on whether you are the current player (in which case you get to shoot) or not (in which case you wait until shot info arrives from the server).

State 29 sends the shot info. State 38 sends SEND_STOP_BALLPOS (if you're not a spectator), and state 39 waits for that packet to arrive.

It also seems that command 4096 is used for the current player to tell the other players who should next take their shot.

Get Out Of My Way

Once I'd pieced together a rough map of how the states flowed, I was able to look at the log files again and figure out what was happening. Then it hit me: I'd assumed that each client would only send the server info about its own shot. I was wrong.

In state 38, each non-spectator client sends SEND_STOP_BALLPOS to the server, even if that client is not the current player. My naive implementation would simply relay this packet to every client in the room. This meant that in a 2-player game, I'd receive two SEND_STOP_BALLPOS packets for each shot. One of them would be processed, while the other one would simply hang around in cRoundMain's state and poison the next shot with stale data.

Of course this was causing desyncs! But what's the best way to fix this? I actually don't have an obvious answer for how Sega's server might have handled this.

I have a hunch that they might have used this to try and identify client bugs; perhaps the server would collate all the packets it received from all the players, and raise an alarm if they didn't match. I don't really care about doing that; at least, not at this stage in my silly little project. I do however need a solution.

"Yes I Will Trust You :)"

The first idea I had was to use command 4096 to try and keep track of the current player, and discard SEND_STOP_BALLPOS packets from non-current players. This seemed like a sensible course of action.

This worked like a dream, until I got to the second hole. The clients use a bunch of awkward logic to figure out who should tee up first, but none of them send command 4096, which is what I was relying on.

So, it was time to do something hacky and very cheatable (although really, that already describes most of this game's multiplayer functionality): I know that only the current player sends SEND_SHOT, so I can use this. I'll only accept SEND_STOP_BALLPOS packets from a player if they were the last one that sent a SEND_SHOT. Good enough? Perhaps.

I gave it a shot (haha) and it... seems to work! I successfully played a 3-hole round, and then the game got stuck because each client sent the server a SEND_SCORE packet and was waiting to hear back.

The next step is for the server to update the players' rankings, give them any due experience, and return a PAC_GROWPARAM packet.

Now What?

I am now at a weird little impasse. I'd set myself a personal deadline of releasing something, along with this post, no later than Sunday 17th September 2023, because after that, I'll have far less time to work on stuff like this. That day has come.

As it stands, I won't be able to release the more polished software I wanted - but I can at least clean up what I have, and try to document the next steps for anyone who might want to pick it up after me.

Client-Side Patching

Up to now, I've been using a hacked-up version of an unpacked version of the Splash Golf executable. I didn't particularly want to release this as-is. Unpacking it is a frustrating process, and publishing a modified version online is legally questionable (even though it's unlikely that anyone would really care about such an old game).

This led me to think: can I keep the game in its packed form, and make those changes in memory? The answer is yes - meet SplashHack.

I'd previously shoved a newer version of OpenSSL into the game by creating fake shim DLLs. SplashHack builds upon this approach by doing some funky things in DllMain, the code that executes when the DLL is attached to a process.

Since the OpenSSL DLLs are loaded by the game client's import table, we can't actually hook the game's code from DllMain - at this point, ASProtect is still busy unpacking the client, and trying to modify that code will cause it to get very upset. What we can do is lay a trap for later!

Before the game's WinMain function executes, but after unpacking is done, the Visual C++ runtime does a bit of initialisation. This includes a call to Windows's GetVersionExA function.

I use MinHook to hook GetVersionExA, wrapping it with a basic function that checks to see if a known string is present at a certain address. If I find that string, then I know unpacking is complete, so I go ahead and apply all my other game patches.

This gives me SplashHack, which rolls all the necessary improvements into four DLLs that you can just place into the game directory:

One more thing: the actual game EXE is hidden in Splash.bin, where they encrypt a tiny chunk of it so that you can't just run it directly. SplashHack includes a decryption tool that takes care of that, so you don't have to try and fix the Sega launcher.

Preparing the Server

You can find the source code for my experimental server here: github.com/Treeki/SplashSrv

I should reiterate that this is really, really rough software. Lots of features still don't work, or haven't even been tested. The server crashes if you try to enter a room where the players haven't disconnected cleanly.

It is, however, a start, and I'd love to see someone else work on it further. Both SplashHack and SplashSrv are released under the MIT license. The readme file includes a long list of possible improvements.

Demonstration and Conclusion

I recorded a full round, so you can see what it looks like to play this game in 2023. This doesn't show everything in the game (no items, no caddies, only one course) but it's still interesting.

This brings my involvement with SEGA SPLASH! GOLF to an end, at least for now. I hope you've enjoyed this saga, and not cringed too much at my software engineering crimes.

If you liked it and/or have feedback, feel free to @ me on the fediverse at @Ninji@wuffs.org, or Bluesky (sigh) at @snoot.zone. See you next time!


Previous Post: Reviving Sega's forgotten golf MMO after 14 years
Next Post: Free Software that you can't customise is not truly Free Software