Maelstrom: Added a mod selection dialog

From 8c464222449121682fe85e484e7534d8adfb7fcb Mon Sep 17 00:00:00 2001
From: Sam Lantinga <[EMAIL REDACTED]>
Date: Tue, 7 Apr 2026 08:18:41 -0700
Subject: [PATCH] Added a mod selection dialog

---
 CMakeLists.txt                            |   6 +-
 Data/Images/Maelstrom_Titles#103.png      | Bin 0 -> 3194 bytes
 Data/Images/mods.png                      | Bin 0 -> 766 bytes
 Data/UI/UITemplates.xml                   |  20 ++
 Data/UI/controls.xml                      |   2 +-
 Data/UI/main.xml                          |  10 +
 Data/UI/mods.xml                          |  48 ++++
 README.md                                 |  17 +-
 Xcode/Maelstrom.xcodeproj/project.pbxproj |  10 +
 android-project/app/assets/Data           |   1 +
 android-project/app/assets/mods           |   1 +
 android-project/app/build.gradle          |   2 +-
 external/SDL                              |   2 +-
 game/MaelstromUI.cpp                      |   3 +
 game/Maelstrom_Globals.h                  |   1 +
 game/init.cpp                             | 119 +++++++--
 game/init.h                               |   1 +
 game/main.cpp                             |   3 +-
 game/mods.cpp                             | 282 ++++++++++++++++++++++
 game/mods.h                               | 103 ++++++++
 utils/files.c                             |  88 ++++++-
 utils/files.h                             |   5 +-
 22 files changed, 679 insertions(+), 45 deletions(-)
 create mode 100644 Data/Images/Maelstrom_Titles#103.png
 create mode 100644 Data/Images/mods.png
 create mode 100644 Data/UI/mods.xml
 create mode 120000 android-project/app/assets/Data
 create mode 120000 android-project/app/assets/mods
 create mode 100644 game/mods.cpp
 create mode 100644 game/mods.h

diff --git a/CMakeLists.txt b/CMakeLists.txt
index a9e6d5ef..3377f913 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -75,6 +75,8 @@ set(MAELSTROM_SOURCES
 	game/main.h
 	game/make.cpp
 	game/make.h
+	game/mods.cpp
+	game/mods.h
 	game/myerror.cpp
 	game/myerror.h
 	game/netplay.cpp
@@ -223,7 +225,8 @@ if(EMSCRIPTEN)
     # on the web, we have to put the files inside of the webassembly
     # somewhat unintuitively, this is done via a linker argument.
     target_link_libraries(${TARGET_NAME} PRIVATE 
-        "--preload-file \"${CMAKE_CURRENT_LIST_DIR}/Data@/\""
+        "--preload-file \"${CMAKE_CURRENT_LIST_DIR}/Data@Data/\""
+        "--preload-file \"${CMAKE_CURRENT_LIST_DIR}/mods@mods/\""
     )
 else()
     option(STANDALONE_INSTALL "Build Maelstrom installed into a single directory" TRUE)
@@ -250,6 +253,7 @@ else()
         install(IMPORTED_RUNTIME_ARTIFACTS SteamworksSDK::steam_api)
     endif()
     install(DIRECTORY Data DESTINATION "${GAME_INSTALL_DATADIR}")
+    install(DIRECTORY mods DESTINATION "${GAME_INSTALL_DATADIR}")
 
     file(GLOB docs "Docs/*.txt" "README*")
     install(FILES ${docs} "COPYING" TYPE DOC)
diff --git a/Data/Images/Maelstrom_Titles#103.png b/Data/Images/Maelstrom_Titles#103.png
new file mode 100644
index 0000000000000000000000000000000000000000..e48ddcdf48aecc1aa4dac6c03773918b0a6b5d7c
GIT binary patch
literal 3194
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
U62ZR1`Wyh3rgkQE#-50O11i!W;{X5v

literal 0
HcmV?d00001

diff --git a/Data/Images/mods.png b/Data/Images/mods.png
new file mode 100644
index 0000000000000000000000000000000000000000..fcf8ecdaac76528b082a9e1e15c06cfb4effd7d6
GIT binary patch
literal 766
zcmV<a0s;MrP)<h;3K|Lk000e1NJLTq001BW001Be1^@s6b9#F800001b5ch_0olnc
ze*gdg1ZP1_K>z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF*
zm;eA5aGbhPJOBU!d`Uz>R9M69)?cXKa~KEk_vh@#+6v2RHaEDL3!|fjthDlHWL9cf
zp_ar@E+iM2a%yrelqe-}V@sBDL(&?NsbzL#{)FAwQ3@CSj6)8`%gwX=`q}Y2znw|y
z_tdBFx99Wxc|Xtdd7jTF8Pbr3G!McGESZmuHlV{!k~K-Lsx_KqCds}eyK3!kt<4-a
z09ck}ZITU1u1O-v>q%Zq@=B5ulU$MH^7?j?yqV<XB)eK`lY^#p0oGz1?_&n<VjI?A
z!9QgPmz3<j()}2ZV%=PwKkF~rjdSL4MlP!$;AcG6PvH@KhD-4<4pbC-u@$3z^cP|s
zp1>rg@DgstMfeWa&!&6>4);ck)36yY)c)7lj_Ys|PObm#_#WSM@Auf+h#zrwuk+DD
zJJu({MOcp~E5cv!d!_jyT!@iAHlD`>7Irf)#*RXF5Mx-`$Iw{Sb_Un>kv$9d;yp~`
zRjfLi;9`7IYr0PIU05+oxek=fVVu_^a|Z6I{on8dp2O092XO+MuoQP-PZ`*an{Z{7
zYXf%H;oOGLaee{bTcvpew_{nQ{(-r)&IC5~@Xo}2_^8r;ckzz^7{QBGj<;|pmLH||
ze*@5&)3|KyP?GT^<5<~RJJ_@T2R0{J(pvj+kXg|g`10s+-o>9vGBv2Zk^lNeoejW^
zSbdzX><%_2xnQmUZcFlQl0DTzH)HWJI6fT@CYem~c#<7So*u-&DcFJ!s)wJ#xjiyF
zv9*V{x<>Id-mZ~+!hF+s#OSZsU%(c1wc*Ld`>+e_s(<4NwjB545Wd0}_!K|Y{J3S%
wa<yXwGLq!#B<rh{pJ}bVGkhfv>3>Oo0+A&UeUqHEuK)l507*qoM6N<$f&*b|<^TWy

literal 0
HcmV?d00001

diff --git a/Data/UI/UITemplates.xml b/Data/UI/UITemplates.xml
index 35a0e995..d9462f6d 100644
--- a/Data/UI/UITemplates.xml
+++ b/Data/UI/UITemplates.xml
@@ -98,4 +98,24 @@
 		<Size w="170" h="20"/>
 		<Color r="0x00" g="0x00" b="0x00"/>
 	</Rectangle>
+
+	<Area templateName="ModBox">
+		<Size w="432" h="32"/>
+		<Elements>
+			<DialogLabel name="label">
+				<Anchor anchorFrom="LEFT" anchorTo="LEFT" x="8"/>
+			</DialogLabel>
+			<DialogLabel name="name">
+				<Anchor anchorFrom="TOPLEFT" anchorTo="TOPLEFT" x="8" y="6"/>
+			</DialogLabel>
+			<!-- 75 character maximum description -->
+			<Label name="desc" template="SmallBlack">
+				<Anchor anchorFrom="TOPLEFT" anchorTo="TOPLEFT" x="8" y="22"/>
+			</Label>
+			<DialogLabel name="invalid" text="(invalid)">
+				<Anchor anchorFrom="RIGHT" anchorTo="RIGHT" x="-4"/>
+				<Color r="0xA0" g="0x10" b="0x10"/>
+			</DialogLabel>
+		</Elements>
+	</Area>
 </UITemplates>
diff --git a/Data/UI/controls.xml b/Data/UI/controls.xml
index 8113765d..cb24a87d 100644
--- a/Data/UI/controls.xml
+++ b/Data/UI/controls.xml
@@ -87,7 +87,7 @@
 			<Anchor anchorFrom="TOPLEFT" anchorTo="TOPLEFT" x="291" y="265"/>
 		</DialogButton>
 		<DialogButton text="OK" id="1">
-			<Anchor anchorFrom="TOPLEFT" anchorTo="TOPRIGHT" anchor="cancelButton"x="25"/>
+			<Anchor anchorFrom="TOPLEFT" anchorTo="TOPRIGHT" anchor="cancelButton" x="25"/>
 		</DialogButton>
 	</Elements>
 </Dialog>
diff --git a/Data/UI/main.xml b/Data/UI/main.xml
index 367eaf7b..45598d85 100644
--- a/Data/UI/main.xml
+++ b/Data/UI/main.xml
@@ -282,6 +282,11 @@
 				<Checkbox name="KidMode" image="kidmode" checkedImage="kidmode-selected" bindValue="Cheat.KidMode" action="toggle_kidmode">
 					<Anchor anchorFrom="TOP" anchorTo="TOP" anchor="vertical_divider" x="196"/>
 				</Checkbox>
+
+				<!-- Mods -->
+				<Button name="Mods" image="mods" action="show_mods">
+					<Anchor anchorFrom="BOTTOMRIGHT" anchorTo="BOTTOMRIGHT" x="-68" y="-48"/>
+				</Button>
 			</Elements>
 		</Area>
 
@@ -476,6 +481,11 @@
 				<Checkbox name="KidMode" image="kidmode" checkedImage="kidmode-selected" bindValue="Cheat.KidMode" action="toggle_kidmode">
 					<Anchor anchorFrom="BOTTOMRIGHT" anchorTo="TOPRIGHT" anchor="hdivider" x="-8" y="-4"/>
 				</Checkbox>
+
+				<!-- Mods -->
+				<Button name="Mods" image="mods" action="show_mods">
+					<Anchor anchorFrom="RIGHT" anchorTo="LEFT" anchor="KidMode" x="-8"/>
+				</Button>
 			</Elements>
 		</Area>
 	</Elements>
diff --git a/Data/UI/mods.xml b/Data/UI/mods.xml
new file mode 100644
index 00000000..7530942e
--- /dev/null
+++ b/Data/UI/mods.xml
@@ -0,0 +1,48 @@
+<Dialog delegate="ModsDialog">
+	<Size w="474" h="292"/>
+	<Elements>
+		<Title id="103">
+			<Anchor anchorFrom="TOPLEFT" anchorTo="TOPLEFT" x="4" y="4"/>
+		</Icon>
+
+		<Area name="mod1" template="ModBox">
+			<Anchor anchorFrom="TOPLEFT" anchorTo="TOPLEFT" x="30" y="62"/>
+		</Area>
+		<Area name="mod2" template="ModBox">
+			<Anchor anchorFrom="TOPLEFT" anchorTo="BOTTOMLEFT" anchor="mod1" y="6"/>
+		</Area>
+		<Area name="mod3" template="ModBox">
+			<Anchor anchorFrom="TOPLEFT" anchorTo="BOTTOMLEFT" anchor="mod2" y="6"/>
+		</Area>
+		<Area name="mod4" template="ModBox">
+			<Anchor anchorFrom="TOPLEFT" anchorTo="BOTTOMLEFT" anchor="mod3" y="6"/>
+		</Area>
+		<Area name="mod5" template="ModBox">
+			<Anchor anchorFrom="TOPLEFT" anchorTo="BOTTOMLEFT" anchor="mod4" y="6"/>
+		</Area>
+
+		<DialogRadioGroup name="modsRadioGroup">
+			<Elements>
+				<DialogRadioButton name="pip1" id="1">
+					<Anchor anchorFrom="RIGHT" anchorTo="LEFT" anchor="mod1" x="20"/>
+				</DialogRadioButton>
+				<DialogRadioButton name="pip2" id="2">
+					<Anchor anchorFrom="RIGHT" anchorTo="LEFT" anchor="mod2" x="20"/>
+				</DialogRadioButton>
+				<DialogRadioButton name="pip3" id="3">
+					<Anchor anchorFrom="RIGHT" anchorTo="LEFT" anchor="mod3" x="20"/>
+				</DialogRadioButton>
+				<DialogRadioButton name="pip4" id="4">
+					<Anchor anchorFrom="RIGHT" anchorTo="LEFT" anchor="mod4" x="20"/>
+				</DialogRadioButton>
+				<DialogRadioButton name="pip5" id="5">
+					<Anchor anchorFrom="RIGHT" anchorTo="LEFT" anchor="mod5" x="20"/>
+				</DialogRadioButton>
+			</Elements>
+		</DialogRadioGroup>
+
+		<DialogButton text="OK" default="true">
+			<Anchor anchorFrom="BOTTOMRIGHT" anchorTo="BOTTOMRIGHT" x="-10" y="-11"/>
+		</DialogButton>
+	</Elements>
+</Dialog>
diff --git a/README.md b/README.md
index a01c3c22..0a32f546 100644
--- a/README.md
+++ b/README.md
@@ -47,18 +47,21 @@ You can click on the high score entries to watch that game again. This is especi
 
 The classic easter eggs from the original game are all there, and it's up to you to find them...
 
-### Addons
+### Mods
 
-The art and sounds for the game are in the Data directory and can be freely modified for your own use. If you create a directory "mod" next to the Data directory, files in there will override the base game.
+You can create mods for the game by copying the folders in Data to a new folder, modifying the files, and then zipping them up and dropping the archive into the top level "mods" folder. If you place a file README.txt at the  top level in your zip file, you can set the name and description field used by the game. You can look at Maelstrom_1980.zip in the mods folder for an example of this.
 
-If you have access to the original sound and sprite packs for Maelstrom, you can build Maelstrom from source and use the included tool `macres` to unpack them into the mod directory to change the art and sounds for the game:
+Once you've installed a mod, you can enable it by clicking on the box icon on the main menu and selecting it in that dialog.
+
+Note that if you play network multiplayer, all players must have the same sprite mods in order to play together.
+
+If you have access to the original sound and sprite packs for Maelstrom, you can build the macres tool included in the Maelstrom source code and use that to export the files in a form that can be zipped up as a mod for this game.
+
+The usage is:

-macres --export ‘%Maelstrom Sprites’ mod
-macres --export ‘%Maelstrom Sounds’ mod
+macres --export [file] [output_directory]


-If you play network multiplayer, all players must have the same set of sprites, otherwise the games will get out of sync.
-
---

Enjoy!
diff --git a/Xcode/Maelstrom.xcodeproj/project.pbxproj b/Xcode/Maelstrom.xcodeproj/project.pbxproj
index 3cd89247..c1447f5e 100644
--- a/Xcode/Maelstrom.xcodeproj/project.pbxproj
+++ b/Xcode/Maelstrom.xcodeproj/project.pbxproj
@@ -61,6 +61,8 @@
		AA929E852EDBA1900005200A /* Docs in Resources */ = {isa = PBXBuildFile; fileRef = AA929E842EDBA1900005200A /* Docs */; };
		AA929E872EDBA3B80005200A /* Data in Resources */ = {isa = PBXBuildFile; fileRef = AA929E862EDBA3B80005200A /* Data */; };
		F3281F432F66693200107D76 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F3281F422F66693200107D76 /* Assets.xcassets */; };
+		F384D6AC2F8646D400DC6EA7 /* mods in Resources */ = {isa = PBXBuildFile; fileRef = F384D6AB2F8646D400DC6EA7 /* mods */; };
+		F384D6AF2F8646FE00DC6EA7 /* mods.cpp in Sources */ = {isa = PBXBuildFile; fileRef = F384D6AE2F8646FE00DC6EA7 /* mods.cpp */; };
		F3A2C6E32F84C2DD0080C346 /* physfs_archiver_qpak.c in Sources */ = {isa = PBXBuildFile; fileRef = F3A2C6D32F84C2DD0080C346 /* physfs_archiver_qpak.c */; };
		F3A2C6E42F84C2DD0080C346 /* physfs_archiver_csm.c in Sources */ = {isa = PBXBuildFile; fileRef = F3A2C6CB2F84C2DD0080C346 /* physfs_archiver_csm.c */; };
		F3A2C6E52F84C2DD0080C346 /* physfs_archiver_zip.c in Sources */ = {isa = PBXBuildFile; fileRef = F3A2C6D92F84C2DD0080C346 /* physfs_archiver_zip.c */; };
@@ -220,6 +222,9 @@
		AA929E842EDBA1900005200A /* Docs */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Docs; path = ../Docs; sourceTree = "<group>"; };
		AA929E862EDBA3B80005200A /* Data */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Data; path = ../Data; sourceTree = "<group>"; };
		F3281F422F66693200107D76 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+		F384D6AB2F8646D400DC6EA7 /* mods */ = {isa = PBXFileReference; lastKnownFileType = folder; name = mods; path = ../mods; sourceTree = "<group>"; };
+		F384D6AD2F8646FE00DC6EA7 /* mods.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = mods.h; sourceTree = "<group>"; };
+		F384D6AE2F8646FE00DC6EA7 /* mods.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = mods.cpp; sourceTree = "<group>"; };
		F3A2C6C82F84C2DD0080C346 /* physfs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = physfs.h; sourceTree = "<group>"; };
		F3A2C6C92F84C2DD0080C346 /* physfs.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = physfs.c; sourceTree = "<group>"; };
		F3A2C6CA2F84C2DD0080C346 /* physfs_archiver_7z.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = physfs_archiver_7z.c; sourceTree = "<group>"; };
@@ -275,6 +280,7 @@
				AA9285942EDB9A3D0005200A /* SDL_net.xcodeproj */,
				AA929E862EDBA3B80005200A /* Data */,
				AA929E842EDBA1900005200A /* Docs */,
+				F384D6AB2F8646D400DC6EA7 /* mods */,
				AA928DB12EDB9C5F0005200A /* game */,
				AA928E4A2EDB9D540005200A /* maclib */,
				AA928E462EDB9D260005200A /* miniz */,
@@ -340,6 +346,8 @@
				AA928DCF2EDB9CA60005200A /* main.cpp */,
				AA928DD02EDB9CA60005200A /* make.h */,
				AA928DD12EDB9CA60005200A /* make.cpp */,
+				F384D6AD2F8646FE00DC6EA7 /* mods.h */,
+				F384D6AE2F8646FE00DC6EA7 /* mods.cpp */,
				AA928DD22EDB9CA60005200A /* myerror.h */,
				AA928DD32EDB9CA60005200A /* myerror.cpp */,
				AA928DD42EDB9CA60005200A /* netplay.h */,
@@ -596,6 +604,7 @@
			isa = PBXResourcesBuildPhase;
			buildActionMask = 2147483647;
			files = (
+				F384D6AC2F8646D400DC6EA7 /* mods in Resources */,
				AA929E872EDBA3B80005200A /* Data in Resources */,
				F3281F432F66693200107D76 /* Assets.xcassets in Resources */,
				AA929E852EDBA1900005200A /* Docs in Resources */,
@@ -675,6 +684,7 @@
				AA928E0B2EDB9CEA0005200A /* prefs.cpp in Sources */,
				AA928DF42EDB9CA60005200A /* fastrand.cpp in Sources */,
				AA928DF52EDB9CA60005200A /* replay.cpp in Sources */,
+				F384D6AF2F8646FE00DC6EA7 /* mods.cpp in Sources */,
				AA928DF62EDB9CA60005200A /* gameinfo.cpp in Sources */,
				AA928DF72EDB9CA60005200A /* make.cpp in Sources */,
				AA928DF82EDB9CA60005200A /* scores.cpp in Sources */,
diff --git a/android-project/app/assets/Data b/android-project/app/assets/Data
new file mode 120000
index 00000000..ec031b23
--- /dev/null
+++ b/android-project/app/assets/Data
@@ -0,0 +1 @@
+../../../Data
\ No newline at end of file
diff --git a/android-project/app/assets/mods b/android-project/app/assets/mods
new file mode 120000
index 00000000..18fba55e
--- /dev/null
+++ b/android-project/app/assets/mods
@@ -0,0 +1 @@
+../../../mods
\ No newline at end of file
diff --git a/android-project/app/build.gradle b/android-project/app/build.gradle
index 52c9d020..52625639 100644
--- a/android-project/app/build.gradle
+++ b/android-project/app/build.gradle
@@ -39,7 +39,7 @@ android {
    if (!project.hasProperty('EXCLUDE_NATIVE_LIBS')) {
        sourceSets.main {
            jniLibs.srcDir 'libs'
-            assets.srcDir '../../Data'
+            assets.srcDir 'assets'
        }
        externalNativeBuild {
            if (buildWithCMake) {
diff --git a/external/SDL b/external/SDL
index ce3cc80a..61ba96db 160000
--- a/external/SDL
+++ b/external/SDL
@@ -1 +1 @@
-Subproject commit ce3cc80aca6e2d7eab0865ba9e39d3ac5855794a
+Subproject commit 61ba96db363b264f507ed00a7850c3174aa32ce9
diff --git a/game/MaelstromUI.cpp b/game/MaelstromUI.cpp
index e7872e05..7f829b98 100644
--- a/game/MaelstromUI.cpp
+++ b/game/MaelstromUI.cpp
@@ -31,6 +31,7 @@
#include "gameover.h"
#include "player.h"
#include "lobby.h"
+#include "mods.h"
#include "MacDialog.h"
#include "../screenlib/UIContainer.h"
#include "../screenlib/UIElementButton.h"
@@ -397,6 +398,8 @@ MaelstromUI::CreatePanelDelegate(UIPanel *panel, const char *delegate)
		return new GameOverPanelDelegate(panel);
	} else if (SDL_strcasecmp(delegate, "ControlsDialog") == 0) {
		return new ControlsDialogDelegate(panel);
+	} else if (SDL_strcasecmp(delegate, "ModsDialog") == 0) {
+		return new ModsDialogDelegate(panel);
	}
	return UIManager::CreatePanelDelegate(panel, delegate);
}
diff --git a/game/Maelstrom_Globals.h b/game/Maelstrom_Globals.h
index 37051d99..90f65a12 100644
--- a/game/Maelstrom_Globals.h
+++ b/game/Maelstrom_Globals.h
@@ -51,6 +51,7 @@
#define PREFERENCES_MULTIPLAYER_FRAGS "Network.Frags"
#define PREFERENCES_KIDMODE "Cheat.KidMode"
#define PREFERENCES_CONTINUES "Cheat.Continues"
+#define PREFERENCES_MOD_FILE "ModFile"

// The Font Server :)
extern FontServ *fontserv;
diff --git a/game/init.cpp b/game/init.cpp
index 1d44560f..788e645e 100644
--- a/game/init.cpp
+++ b/game/init.cpp
@@ -112,6 +112,7 @@ enum LoadingStage
	LOAD_STAGE_COMPLETE
};
static int gLoadingStage = LOAD_STAGE_WAITING;
+static int gLoadBarStage = 1;

// Local functions used in this file.
static void DrawLoadBar();
@@ -198,7 +199,6 @@ static bool InitResolutions(int &w, int &h)

static void DrawLoadBar()
{
-	static int stage = 1;
	UIPanel *panel;
	UIElement *progress = NULL;
	int fact;
@@ -209,10 +209,10 @@ static void DrawLoadBar()
		progress = panel->GetElement<UIElement>("progress");
	}
	if (progress) {
-		fact = (FULL_WIDTH * stage) / MAX_BAR;
+		fact = (FULL_WIDTH * gLoadBarStage) / MAX_BAR;
		progress->SetWidth(fact);
	}
-	++stage;
+	++gLoadBarStage;
}	/* -- DrawLoadBar */


@@ -834,6 +834,26 @@ static bool LoadIcon(SDL_Surface **icon)
	return true;
}

+static void ShowLoadingPanel(int stage)
+{
+	gInitializing = true;
+	gLoadingStage = stage;
+	gLoadBarStage = 1;
+	gSpriteCRC = 0;
+
+	/* -- Load any mods */
+	if (!SetModFile(prefs->GetString(PREFERENCES_MOD_FILE, ""))) {
+		prefs->SetString(PREFERENCES_MOD_FILE, "");
+	}
+
+	/* -- Throw up our intro screen */
+	if (ui->GetPanelTransition() == PANEL_TRANSITION_FADE) {
+		screen->FadeOut();
+	}
+
+	ui->ShowPanel(PANEL_LOADING);
+}
+
/* ----------------------------------------------------------------- */
/* -- Perform some initializations and report failure if we choke */
bool StartInitialization(int window_width, int window_height, Uint32 window_flags)
@@ -924,12 +944,22 @@ bool StartInitialization(int window_width, int window_height, Uint32 window_flag
	ui->SetPanelTransition(PANEL_TRANSITION_FADE);
#endif

-	/* -- Throw up our intro screen */
-	if (ui->GetPanelTransition() == PANEL_TRANSITION_FADE) {
-		screen->FadeOut();
+	ShowLoadingPanel(LOAD_STAGE_WAITING);
+
+	return true;
+}
+
+bool RestartInitialization()
+{
+	/* Load the Sound Server and initialize sound */
+	delete sound;
+	sound = new Sound("Maelstrom Sounds", gSoundLevel);
+	if (sound->Error()) {
+		error("Fatal: %s\n", sound->Error());
+		return false;
	}

-	ui->ShowPanel(PANEL_LOADING);
+	ShowLoadingPanel(LOAD_STAGE_STARTING);

	return true;
}
@@ -1276,26 +1306,66 @@ static void BackwardsSprite(BlitPtr *theBlit, BlitPtr oldBlit)

static int LoadCICNS(void)
{
-	if ( (gAutoFireIcon = GetCIcon(screen, 128)) == NULL )
+	if ( gAutoFireIcon ) {
+		Free_Texture(screen, gAutoFireIcon);
+	}
+	if ( (gAutoFireIcon = GetCIcon(screen, 128)) == NULL ) {
		return(-1);
-	if ( (gAirBrakesIcon = GetCIcon(screen, 129)) == NULL )
+	}
+	if ( gAirBrakesIcon ) {
+		Free_Texture(screen, gAirBrakesIcon);
+	}
+	if ( (gAirBrakesIcon = GetCIcon(screen, 129)) == NULL ) {
		return(-1);
-	if ( (gMult2Icon = GetCIcon(screen, 130)) == NULL )
+	}
+	if ( gMult2Icon ) {
+		Free_Texture(screen, gMult2Icon);
+	}
+	if ( (gMult2Icon = GetCIcon(screen, 130)) == NULL ) {
		return(-1);
-	if ( (gMult3Icon = GetCIcon(screen, 131)) == NULL )
+	}
+	if ( gMult3Icon ) {
+		Free_Texture(screen, gMult3Icon);
+	}
+	if ( (gMult3Icon = GetCIcon(screen, 131)) == NULL ) {
		return(-1);
-	if ( (gMult4Icon = GetCIcon(screen, 132)) == NULL )
+	}
+	if ( gMult4Icon ) {
+		Free_Texture(screen, gMult4Icon);
+	}
+	if ( (gMult4Icon = GetCIcon(screen, 132)) == NULL ) {
		return(-1);
-	if ( (gMult5Icon = GetCIcon(screen, 134)) == NULL )
+	}
+	if ( gMult5Icon ) {
+		Free_Texture(screen, gMult5Icon);
+	}
+	if ( (gMult5Icon = GetCIcon(screen, 134)) == NULL ) {
		return(-1);
-	if ( (gLuckOfTheIrishIcon = GetCIcon(screen, 133)) == NULL )
+	}
+	if ( gLuckOfTheIrishIcon ) {
+		Free_Texture(screen, gLuckOfTheIrishIcon);
+	}
+	if ( (gLuckOfTheIrishIcon = GetCIcon(screen, 133)) == NULL ) {
		return(-1);
-	if ( (gTripleFireIcon = GetCIcon(screen, 135)) == NULL )
+	}
+	if ( gTripleFireIcon ) {
+		Free_Texture(screen, gTripleFireIcon);
+	}
+	if ( (gTripleFireIcon = GetCIcon(screen, 135)) == NULL ) {
		return(-1);
-	if ( (gLongFireIcon = GetCIcon(screen, 136)) == NULL )
+	}
+	if ( gLongFireIcon ) {
+		Free_Texture(screen, gLongFireIcon);
+	}
+	if ( (gLongFireIcon = GetCIcon(screen, 136)) == NULL ) {
		return(-1);
-	if ( (gShieldIcon = GetCIcon(screen, 137)) == NULL )
+	}
+	if ( gShieldIcon ) {
+		Free_Texture(screen, gShieldIcon);
+	}
+	if ( (gShieldIcon = GetCIcon(screen, 137)) == NULL ) {
		return(-1);
+	}
	return(0);
}	/* -- LoadCICNS */

@@ -1400,6 +1470,13 @@ static int LoadSprite(bool large, BlitPtr *theBlit, int baseID, int numFrames)

		SDL_DestroySurface(surface);
	}
+	if (*theBlit) {
+		for (index = 0; index < numFrames; index++) {
+			if ((*theBlit)->sprite[index]) {
+				Free_Texture(screen, (*theBlit)->sprite[index]);
+			}
+		}
+	}
	(*theBlit) = aBlit;
	return(0);
}	/* -- LoadSprite */
@@ -1425,8 +1502,13 @@ static int LoadSmallSprite(BlitPtr *theBlit, int baseID, int numFrames)
void LoadingPanelDelegate::OnShow()
{
#ifdef SDL_PLATFORM_EMSCRIPTEN
-	StartWaiting();
+	if (gLoadingStage == LOAD_STAGE_WAITING) {
+		StartWaiting();
+	} else {
+		StartLoading();
+	}
#else
+	// No waiting necessary, the user doesn't need to interact first
	StartLoading();
#endif
}
@@ -1459,7 +1541,6 @@ void LoadingPanelDelegate::StartWaiting()
			loading_label->Hide();
		}
	}
-	gLoadingStage = LOAD_STAGE_WAITING;
}

void LoadingPanelDelegate::StartLoading()
diff --git a/game/init.h b/game/init.h
index 393d986b..58ee35de 100644
--- a/game/init.h
+++ b/game/init.h
@@ -38,6 +38,7 @@ class LoadingPanelDelegate : public UIPanelDelegate
};

extern bool StartInitialization(int window_width, int window_height, Uint32 window_flags);
+extern bool RestartInitialization();
extern bool ContinueInitialization();
extern void CleanUp(void);

diff --git a/game/main.cpp b/game/main.cpp
index 55c89be2..687f6996 100644
--- a/game/main.cpp
+++ b/game/main.cpp
@@ -186,7 +186,8 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
	/* Initializing Steam can set up environment variables, so do this first */
	InitSteam();

-	if ( !InitFilesystem(MAELSTROM_ORGANIZATION, MAELSTROM_NAME) ) {
+	if ( !InitFilesystem(argv[0], MAELSTROM_ORGANIZATION, MAELSTROM_NAME) ) {
+		error("Couldn't initialize filesystem: %s", SDL_GetError());
		return SDL_APP_FAILURE;
	}

diff --git a/game/mods.cpp b/game/mods.cpp
new file mode 100644
index 00000000..82a7df16
--- /dev/null
+++ b/game/mods.cpp
@@ -0,0 +1,282 @@
+/*
+  Maelstrom: Open Source version of the classic game by Ambrosia Software
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+
+#include "Maelstrom_Globals.h"
+#include "mods.h"
+#include "physfs.h"
+#include "../external/physfs/extras/physfssdl3.h"
+#include "../utils/files.h"
+#include "../screenlib/UIElementRadio.h"
+#include "init.h"
+
+bool
+ModsDialogDelegate::OnLoad()
+{
+	m_radioGroup = m_panel->GetElement<UIElementRadioGroup>("modsRadioGroup");
+	if (!m_radioGroup) {
+		error("Warning: Couldn't find 'modsRadioGroup'\n");
+		return false;
+	}
+
+	ModBox box;
+	for (int i = 1; LoadModBox(i, &box); ++i) {
+		m_modBoxes.add(box);
+	}
+	return true;
+}
+
+void
+ModsDialogDelegate::OnShow()
+{
+	const char *current_mod = prefs->GetString(PREFERENCES_MOD_FILE, "");
+
+	// We can't have a mod open while we're enumerating mod archives
+	SetModFile("");
+	LoadMods();
+	SetModFile(current_mod);
+
+	for (unsigned int i = 0; i < m_modBoxes.length(); ++i) {
+		UIElementRadioButton *button = m_radioGroup->GetRadioButton(i + 1);
+
+		ModBox *box = &m_modBoxes[i];
+		if (i == 0) {
+			box->name->Hide();
+			box->desc->Hide();
+			box->label->SetText("Original art and sounds");
+			box->label->Show();
+			box->invalid->Hide();
+			box->box->Show();
+
+			if (button) {
+				button->SetDisabled(false);
+				if (SDL_strcmp(current_mod, "") == 0) {
+					button->SetChecked(true);
+				}
+				button->Show();
+			}
+		} else {
+			unsigned int mod_index = (i - 1);
+			if (mod_index < m_mods.length()) {
+				Mod *mod = m_mods[i - 1];
+				if (mod->desc) {
+					box->label->Hide();
+					box->name->SetText(mod->name);
+					box->name->Show();
+					box->desc->SetText(mod->desc);
+					box->desc->Show();
+				} else {
+					box->name->Hide();
+					box->desc->Hide();
+					box->label->SetText(mod->name);
+					box->label->Show();
+				}
+				if (mod->valid) {
+					box->invalid->Hide();
+				} else {
+					box->invalid->Show();
+				}
+				box->box->Show();
+
+				if (button) {
+					if (mod->valid) {
+						button->SetDisabled(false);
+					} else {
+						button->SetDisabled(true);
+					}
+					if (SDL_strcmp(current_mod, mod->path) == 0) {
+						button->SetChecked(true);
+					}
+					button->Show();
+				}
+			} else {
+				box->box->Hide();
+
+				if (button) {
+					button->Hide();
+				}
+			}
+		}
+	}
+}
+
+void
+ModsDialogDelegate::OnHide()
+{
+	int value = m_radioGroup->GetValue();
+	if (value > 0) {
+		const char *current_mod = prefs->GetString(PREFERENCES_MOD_FILE, "");
+		const char *selected_mod = "";
+
+		if (value > 1) {
+			selected_mod = m_mods[value - 2]->path;
+		}
+		if (SDL_strcmp(selected_mod, current_mod) != 0) {
+			prefs->SetString(PREFERENCES_MOD_FILE, selected_mod);
+			RestartInitialization();
+		}
+	}
+}
+
+bool
+ModsDialogDelegate::LoadModBox(int index, ModBox *box)
+{
+	SDL_zerop(box);
+
+	char name[128];
+	SDL_snprintf(name, sizeof(name), "mod%d", index);
+	box->box = m_panel->GetElement<UIE

(Patch may be truncated, please check the link at the top of this post.)