Maelstrom: Don't join games with a different version or sprite pack

From 25ca067ec3a32f56f82f1fa4c95763df6e37b8ce Mon Sep 17 00:00:00 2001
From: Sam Lantinga <[EMAIL REDACTED]>
Date: Mon, 6 Apr 2026 10:04:29 -0700
Subject: [PATCH] Don't join games with a different version or sprite pack

Also fixes a few edge cases in joining/kicking in the multiplayer lobby.
---
 game/gameinfo.cpp | 68 +++++++++++++++++++++++++++++++++++++++++++----
 game/gameinfo.h   |  5 ++++
 game/lobby.cpp    | 39 ++++++++++++++-------------
 game/replay.cpp   | 13 ++-------
 4 files changed, 91 insertions(+), 34 deletions(-)

diff --git a/game/gameinfo.cpp b/game/gameinfo.cpp
index 13ece0a3..16eae493 100644
--- a/game/gameinfo.cpp
+++ b/game/gameinfo.cpp
@@ -45,6 +45,8 @@ GameInfo::Reset()
 	numNodes = 0;
 	SDL_zero(nodes);
 	SDL_zero(players);
+	replayVersion = REPLAY_VERSION;
+	spriteCRC = gSpriteCRC;
 }
 
 void
@@ -88,6 +90,12 @@ GameInfo::SetPlayerSlot(int slot, const char *name, Uint8 controlMask)
 	}
 	player->controlMask = controlMask;
 
+	if (controlMask == CONTROL_NETWORK) {
+		player->available = true;
+	} else {
+		player->available = false;
+	}
+
 	UpdateUI(player);
 }
 
@@ -118,8 +126,7 @@ GameInfo::AddNetworkPlayer(Uint32 nodeID, const IPaddress &address, const char *
 	++numNodes;
 
 	for (slot = 0; slot < MAX_PLAYERS; ++slot) {
-		if (!players[slot].nodeID &&
-		    players[slot].controlMask == CONTROL_NETWORK) {
+		if (players[slot].available) {
 			break;
 		}
 	}
@@ -128,6 +135,7 @@ GameInfo::AddNetworkPlayer(Uint32 nodeID, const IPaddress &address, const char *
 	GameInfoPlayer *player = &players[slot];
 	player->nodeID = nodeID;
 	SDL_strlcpy(player->name, name, sizeof(player->name));
+	player->available = false;
 
 	UpdateUI(player);
 
@@ -145,6 +153,8 @@ GameInfo::CopyFrom(const GameInfo &rhs)
 	lives = rhs.lives;
 	turbo = rhs.turbo;
 	gameMode = rhs.gameMode;
+	replayVersion = rhs.replayVersion;
+	spriteCRC = rhs.spriteCRC;
 
 	for (i = 0; i < MAX_NODES; ++i) {
 		const GameInfoNode *node = rhs.GetNode(i);
@@ -182,6 +192,7 @@ GameInfo::CopyFrom(const GameInfo &rhs)
 		} else {
 			players[i].controlMask = CONTROL_NONE;
 		}
+		players[i].available = player->available;
 	}
 
 	UpdateUI();
@@ -246,6 +257,23 @@ GameInfo::ReadFromPacket(DynamicPacket &packet)
 		nodes[HOST_NODE].address = packet.address;
 	}
 
+	if (!packet.Read(replayVersion)) {
+		// Older version
+		replayVersion = 0;
+	}
+
+	if (!packet.Read(spriteCRC)) {
+		// Older version
+		spriteCRC = 0;
+	}
+
+	for (i = 0; i < MAX_PLAYERS; ++i) {
+		if (!packet.Read(players[i].available)) {
+			// Older version
+			players[i].available = false;
+		}
+	}
+
 	return true;
 }
 
@@ -272,6 +300,13 @@ GameInfo::WriteToPacket(DynamicPacket &packet)
 		packet.Write(players[i].nodeID);
 		packet.Write(players[i].name);
 	}
+
+	packet.Write(replayVersion);
+	packet.Write(spriteCRC);
+
+	for (i = 0; i < MAX_PLAYERS; ++i) {
+		packet.Write(players[i].available);
+	}
 }
 
 void
@@ -325,6 +360,7 @@ GameInfo::RemoveNode(Uint32 nodeID)
 			for (int j = i; j < (GetNumNodes() - 1); ++j) {
 				nodes[j] = nodes[j + 1];
 			}
+			SDL_zero(nodes[GetNumNodes() - 1]);
 			--numNodes;
 		} else {
 			++i;
@@ -345,6 +381,7 @@ GameInfo::RemovePlayer(int index)
 	player->nodeID = 0;
 	SDL_zero(player->name);
 	player->controlMask = CONTROL_NETWORK;
+	player->available = true;
 
 	UpdateUI(player);
 }
@@ -436,8 +473,7 @@ bool
 GameInfo::IsFull() const
 {
 	for (int i = 0; i < MAX_PLAYERS; ++i) {
-		if (!players[i].nodeID &&
-		    players[i].controlMask == CONTROL_NETWORK) {
+		if (players[i].available) {
 			return false;
 		}
 	}
@@ -508,6 +544,7 @@ GameInfo::BindPlayerToUI(int index, UIElement *element)
 	}
 
 	player->UI.element = element;
+	player->UI.join = element->GetElement<UIElement>("join");
 	player->UI.desc = element->GetElement<UIElement>("desc");
 	player->UI.name = element->GetElement<UIElement>("name");
 	player->UI.host = element->GetElement<UIElement>("host");
@@ -562,6 +599,8 @@ GameInfo::UpdateUI(GameInfoPlayer *player)
 		return;
 	}
 
+	bool enableJoin = true;
+
 	if (player->UI.name && player->UI.host) {
 		const GameInfoNode *node = GetNodeByID(player->nodeID);
 		if (!node || node->nodeID == localID) {
@@ -571,7 +610,26 @@ GameInfo::UpdateUI(GameInfoPlayer *player)
 			player->UI.name->Show();
 			player->UI.name->SetText(player->name);
 			player->UI.host->Show();
-			player->UI.host->SetText(NET_GetAddressString(node->address.host));
+			if (replayVersion != REPLAY_VERSION) {
+				player->UI.host->SetText("(different version)");
+				enableJoin = false;
+			} else if (spriteCRC != gSpriteCRC) {
+				player->UI.host->SetText("(different sprites)");
+				enableJoin = false;
+			} else if (IsFull() && !HasLocalControl()) {
+				player->UI.host->SetText("(no ships available)");
+				enableJoin = false;
+			} else {
+				player->UI.host->SetText(NET_GetAddressString(node->address.host));
+			}
+		}
+	}
+
+	if (player->UI.join) {
+		if (enableJoin) {
+			player->UI.join->SetDisabled(false);
+		} else {
+			player->UI.join->SetDisabled(true);
 		}
 	}
 
diff --git a/game/gameinfo.h b/game/gameinfo.h
index 1e8193bd..e63456ba 100644
--- a/game/gameinfo.h
+++ b/game/gameinfo.h
@@ -102,9 +102,11 @@ struct GameInfoPlayer
 	Uint32 nodeID;
 	char name[MAX_NAMELEN+1];
 	Uint8 controlMask;
+	Uint8 available;
 
 	struct {
 		UIElement *element;
+		UIElement *join;
 		UIElement *desc;
 		UIElement *name;
 		UIElement *host;
@@ -239,6 +241,9 @@ class GameInfo
 	Uint8 turbo;
 	Uint8 gameMode;
 
+	Uint32 replayVersion;
+	Uint32 spriteCRC;
+
 	Uint32 localID;
 
 	Uint8 paused;
diff --git a/game/lobby.cpp b/game/lobby.cpp
index e0e28a7a..7fdf3050 100644
--- a/game/lobby.cpp
+++ b/game/lobby.cpp
@@ -52,12 +52,10 @@ class SelectControlCallback : public UIClickCallback
 	virtual void operator()() {
 		if (m_game.IsHosting()) {
 			// Kick any player that was connected
-			if (m_controlType != CONTROL_NETWORK) {
-				const GameInfoPlayer* player = m_game.GetPlayer(m_index);
-				int nodeIndex = m_game.GetNodeIndex(player->nodeID);
-				if (nodeIndex >= 0) {
-					m_lobby->SendKick(nodeIndex);
-				}
+			const GameInfoPlayer* player = m_game.GetPlayer(m_index);
+			int nodeIndex = m_game.GetNodeIndex(player->nodeID);
+			if (nodeIndex >= 0) {
+				m_lobby->SendKick(nodeIndex);
 			}
 
 			if (m_controlType != CONTROL_NONE && m_controlType != CONTROL_NETWORK) {
@@ -504,8 +502,7 @@ LobbyDialogDelegate::SetState(LOBBY_STATE state)
 			for (i = 0; i < m_game.GetNumNodes(); ++i) {
 				SendKick(i);
 			}
-		} else if (m_state == STATE_JOINING ||
-			   m_state == STATE_JOINED) {
+		} else if (m_state == STATE_JOINING || m_state == STATE_JOINED) {
 			// Notify the host that we're gone
 			SendLeaveRequest();
 		}
@@ -1045,17 +1042,23 @@ LobbyDialogDelegate::ProcessRequestJoin(DynamicPacket &packet)
 		return;
 	}
 
-	m_game.AddNetworkPlayer(nodeID, packet.address, name);
-
-	// Let everybody know!
-	m_reply.StartLobbyMessage(LOBBY_GAME_INFO);
-	m_reply.Write((Uint32)0);
-	m_game.WriteToPacket(m_reply);
-	for (int i = 0; i < m_game.GetNumNodes(); ++i) {
-		if (m_game.IsNetworkNode(i)) {
-			IPaddress address = m_game.GetNode(i)->address;
-			NET_SendDatagram(gSocket, address.host, address.port, m_reply.data, m_reply.len);
+	if (m_game.AddNetworkPlayer(nodeID, packet.address, name)) {
+		// Let everybody know!
+		m_reply.StartLobbyMessage(LOBBY_GAME_INFO);
+		m_reply.Write((Uint32)0);
+		m_game.WriteToPacket(m_reply);
+		for (int i = 0; i < m_game.GetNumNodes(); ++i) {
+			if (m_game.IsNetworkNode(i)) {
+				IPaddress address = m_game.GetNode(i)->address;
+				NET_SendDatagram(gSocket, address.host, address.port, m_reply.data, m_reply.len);
+			}
 		}
+	} else {
+		m_reply.StartLobbyMessage(LOBBY_KICK);
+		m_reply.Write(m_game.gameID);
+		m_reply.Write(nodeID);
+
+		NET_SendDatagram(gSocket, packet.address.host, packet.address.port, m_reply.data, m_reply.len);
 	}
 }
 
diff --git a/game/replay.cpp b/game/replay.cpp
index 96d36a85..8b737757 100644
--- a/game/replay.cpp
+++ b/game/replay.cpp
@@ -121,18 +121,12 @@ Replay::Load(const char *file, bool headerOnly)
 	}
 
 	if (!headerOnly) {
-		if (!SDL_ReadIO(fp, &version, 1)) {
-			SDL_Log("Couldn't read data: %s", SDL_GetError());
-			goto done;
-		}
-		if (version != REPLAY_VERSION) {
+		if (m_game.replayVersion != REPLAY_VERSION) {
 			SDL_Log("Unsupported data version %d, expected %d", version, REPLAY_VERSION);
 			goto done;
 		}
 
-		Uint32 spriteCRC = 0;
-		SDL_ReadU32LE(fp, &spriteCRC);
-		if (spriteCRC != gSpriteCRC) {
+		if (m_game.spriteCRC != gSpriteCRC) {
 			SDL_Log("Game uses a different sprite pack, ignoring");
 			goto done;
 		}
@@ -197,9 +191,6 @@ Replay::Save(const char *file)
 		goto done;
 	}
 
-	SDL_WriteU8(fp, REPLAY_VERSION);
-	SDL_WriteU32LE(fp, gSpriteCRC);
-
 	destLen = compressBound(m_data.Size());
 	data.Reset();
 	data.Grow(destLen);