diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp
index ae3aeaf2..9d097463 100644
--- a/code/gamedirs.cpp
+++ b/code/gamedirs.cpp
@@ -33,6 +33,11 @@ static char const * const DefaultSearchFolders = "INI,MIX,Maps";
static char const * const ConfigName = "OPENTS.INI";
+/*
+ * The folder saved games are kept in, under whichever directory the player's own files go.
+ */
+static char const * const SavedGamesFolder = "Saved Games";
+
/*
* The folders the configuration itself is looked for in, relative to the data directory.
*/
@@ -314,6 +319,22 @@ std::string User_File_Write_Name(char const * filename)
}
+///
+/// Names a saved game inside the folder they are kept in. The folder is not one of the
+/// searched ones and is created here, so a launcher can browse it before the first save is
+/// written.
+///
+/// The name to open, delete or scan for.
+std::string Saved_Game_Name(char const * filename)
+{
+ std::string const folder = UserDirectory + SavedGamesFolder;
+
+ CreateDirectory(folder.c_str(), NULL);
+
+ return(folder + '\\' + filename);
+}
+
+
static void Scan_Folder(char const * prefix, char const * pattern, std::vector & names)
{
std::string const search = std::string(prefix) + pattern;
diff --git a/code/gamedirs.h b/code/gamedirs.h
index 40068f9b..7adefc06 100644
--- a/code/gamedirs.h
+++ b/code/gamedirs.h
@@ -37,5 +37,11 @@ char const * Game_Directory_Error(void);
*/
std::string User_File_Write_Name(char const * filename);
+/*
+ * Where the player's saved games are. They are never searched for: the folder is named
+ * outright wherever a saved game is opened, listed or removed.
+ */
+std::string Saved_Game_Name(char const * filename);
+
std::vector Parse_Search_Folders(char const * list);
std::vector Search_Files(char const * pattern);
diff --git a/code/house.cpp b/code/house.cpp
index 9f259989..f10f5de6 100644
--- a/code/house.cpp
+++ b/code/house.cpp
@@ -311,6 +311,7 @@ HouseClass::HouseClass(HouseTypeClass const * type) :
BuildingsLost(0),
WhoLastHurtMe(HOUSE_NONE),
Center(0,0,0),
+ SpawnWaypoint(-1),
Radius(0),
LATime(0),
LAEnemy(HOUSE_NONE),
@@ -6491,6 +6492,7 @@ void HouseClass::Serialize(SaveStreamClass & stream)
stream.Serialize(EnemyAirForcePrediction);
stream.Serialize(EnemyInfantryForcePrediction);
stream.Serialize(PowerSurplus);
+ stream.Serialize(SpawnWaypoint);
}
diff --git a/code/house.h b/code/house.h
index 604f5f20..bb2bb797 100644
--- a/code/house.h
+++ b/code/house.h
@@ -83,8 +83,6 @@ class ObjectTypeClass;
class SaveStreamClass;
template class DynamicVectorClass;
-#define HOUSE_NAME_MAX 20
-
/****************************************************************************
** Certain aspects of the house "country" are initially set by the scenario
@@ -565,6 +563,7 @@ class HouseClass : public AbstractClass
** the base.
*/
Coord Center; // Center of the base.
+ int SpawnWaypoint; // starting waypoint this house was placed at; -1 = never placed
int Radius; // Average building distance from center (leptons).
struct {
int AirDefense;
diff --git a/code/house.hh b/code/house.hh
index 73bdac43..a0df86a4 100644
--- a/code/house.hh
+++ b/code/house.hh
@@ -14,6 +14,8 @@
#pragma once
+#define HOUSE_NAME_MAX 20
+
/**********************************************************************
** The houses that can be played are listed here. Each has their own
** personality and strengths.
diff --git a/code/init.cpp b/code/init.cpp
index 476d5dc3..cbc5aa63 100644
--- a/code/init.cpp
+++ b/code/init.cpp
@@ -154,6 +154,7 @@
#include "scheme.h"
#include "script.h"
#include "session.h"
+#include "spawner.h"
#include "side.h"
#include "skirmish.h"
#include "smudtype.h"
@@ -413,18 +414,20 @@ int Init_Game(int , char * [])
/*
** Play the startup animation.
*/
- if (Special.IsFromInstall == true) {
- DebugString("Playing first time intro sequence.\n");
- Play_Movie("EVA.VQA", THEME_NONE, false);
- }
+ if (!Spawner_Is_Requested()) {
+ if (Special.IsFromInstall == true) {
+ DebugString("Playing first time intro sequence.\n");
+ Play_Movie("EVA.VQA", THEME_NONE, false);
+ }
- DebugString("Playing startup movies.\n");
- Play_Movie("WWLOGO.VQA", THEME_NONE);
- if (!Get_New_Menu()->MixFile) {
- if (CCFileClass("FS_TITLE.VQA").Is_Available() == true) {
- Play_Movie("FS_TITLE.VQA", THEME_NONE, false);
- } else {
- Play_Movie("STARTUP.VQA", THEME_NONE, false);
+ DebugString("Playing startup movies.\n");
+ Play_Movie("WWLOGO.VQA", THEME_NONE);
+ if (!Get_New_Menu()->MixFile) {
+ if (CCFileClass("FS_TITLE.VQA").Is_Available() == true) {
+ Play_Movie("FS_TITLE.VQA", THEME_NONE, false);
+ } else {
+ Play_Movie("STARTUP.VQA", THEME_NONE, false);
+ }
}
}
@@ -648,6 +651,21 @@ void Init_Campaigns(void)
}
+///
+/// Reads the countries and the sides they belong to from the rules, so a house's side is
+/// known before anything asks for it.
+///
+void Prepare_Side_Roster(void)
+{
+ Rule->Do_HouseTypes(*RuleINI);
+ Rule->Do_Sides(*RuleINI);
+
+ for (int index = 0; index < HouseTypes.Count(); index++) {
+ HouseTypes[index]->Read_INI(*RuleINI);
+ }
+}
+
+
///
/// Can this campaign be played with the addons that are enabled?
/// A base game campaign is offered only when no addon is running, and an addon's own
@@ -1072,6 +1090,18 @@ bool Select_Game(bool )
}
}
+ /*
+ * A launch replaces the menu once. An ended match or a refusal answers false, so the
+ * process exits and the client sees it go.
+ */
+ if (Spawner_Is_Requested()) {
+ if (!Spawner_Prepare(gameloaded)) {
+ return(false);
+ }
+ process = false;
+ Theme.Stop(true);
+ }
+
while (process) {
/*
@@ -1118,33 +1148,6 @@ bool Select_Game(bool )
break;
}
- switch (Options.Difficulty) {
- case 0:
- Scen->CDifficulty = DIFF_HARD;
- Scen->Difficulty = DIFF_EASY;
- break;
-
- case 1:
- Scen->CDifficulty = DIFF_HARD;
- Scen->Difficulty = DIFF_NORMAL;
- break;
-
- case 2:
- Scen->CDifficulty = DIFF_NORMAL;
- Scen->Difficulty = DIFF_NORMAL;
- break;
-
- case 3:
- Scen->CDifficulty = DIFF_EASY;
- Scen->Difficulty = DIFF_NORMAL;
- break;
-
- case 4:
- Scen->CDifficulty = DIFF_EASY;
- Scen->Difficulty = DIFF_HARD;
- break;
- }
-
Theme.Stop(true);
int timeout = (TickCount + 5 * TIMER_SECOND);
@@ -1387,8 +1390,14 @@ bool Select_Game(bool )
Session.PlayerIsGDI = stricmp(HouseTypes[Session.Players[0]->Player.House]->Name(), "GDI") == 0;
}
- if (Session.Type != GAME_NORMAL || Debug_ForceScenario || Session.Play) {
- if (!Start_Scenario(Scen->ScenarioName, true, CAMPAIGN_NONE)) {
+ // The menu sets the difficulty pair on every path but a client launch, which chose it.
+ if (!Spawner_Is_Active()) {
+ Session.CampaignDifficulty = (DiffType)Options.Difficulty;
+ Session.CampaignCDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty);
+ }
+
+ if (Session.Type != GAME_NORMAL || Debug_ForceScenario || Session.Play || Spawner_Is_Active()) {
+ if (!Start_Scenario(Scen->ScenarioName, true, Spawner_Is_Active() ? Scen->Campaign : CAMPAIGN_NONE)) {
if (Debug_Map) {
return(false);
} else {
@@ -1405,6 +1414,13 @@ bool Select_Game(bool )
}
}
+ // The mission read clears these, so a launch file's carried-over flags are set after it.
+ if (Spawner_Is_Active() && Session.Type == GAME_NORMAL) {
+ for (int index = 0; index < ARRAY_SIZE(Environment.Globals); index++) {
+ Scen->Set_Global_To(index, Environment.Globals[index]);
+ }
+ }
+
/*
** Save initialization values if we're recording this game.
*/
@@ -1656,6 +1672,12 @@ bool Parse_Command_Line(int argc, char * argv[])
continue;
}
+ // A client asking the game to launch what SPAWN.INI describes.
+ if (stricmp(string, "-SPAWN") == 0) {
+ Spawner_Request();
+ continue;
+ }
+
if (memcmp(string, "-TIME=", 6) == 0) {
sscanf(&string[6], "%d", &TournamentTime);
}
@@ -1831,7 +1853,11 @@ void Init_Random(void)
** a recording; the random number generator is initialized by loading
** the game.
*/
- if (Session.LoadGame || Session.Play) {
+ if (Session.LoadGame) {
+ return;
+ }
+
+ if (Session.Play) {
Scen->RandomNumber = Seed;
NonCriticalRandomNumber = Seed;
DebugString("Seed is %08x\n", Seed);
diff --git a/code/init.h b/code/init.h
index 5d57aba1..7b80b5ff 100644
--- a/code/init.h
+++ b/code/init.h
@@ -42,6 +42,8 @@ void Title_Screen_Restore(bool force=false);
void Init_Campaigns(void);
+void Prepare_Side_Roster(void);
+
void Delete_All_Objects(void);
void Init_Theater(TheaterType theater);
diff --git a/code/language/language.rc b/code/language/language.rc
index 4b237f1d..1e0d0433 100644
--- a/code/language/language.rc
+++ b/code/language/language.rc
@@ -1426,11 +1426,13 @@ STYLE WS_CHILD
FONT 8, "MS Sans Serif"
BEGIN
CONTROL "Resume Mission",IDC_RESUME_MISSION,"Button",
- BS_OWNERDRAW,120,48,99,14
+ BS_OWNERDRAW,120,59,99,14
CONTROL "Game Controls",IDC_GAME_CONTROLS,"Button",BS_OWNERDRAW,
- 120,12,99,14
+ 120,8,99,14
+ CONTROL "Save Game",IDC_SAVE_GAME,"Button",BS_OWNERDRAW,
+ 120,25,99,14
CONTROL "Abort Mission",IDC_ABORT_MISSION,"Button",BS_OWNERDRAW,
- 120,30,99,14
+ 120,42,99,14
CONTROL "Slider1",IDC_GAME_SPEED_SLIDER,"msctls_trackbar32",
TBS_BOTH | TBS_NOTICKS,95,115,148,13
LTEXT "Game Speed",-1,39,115,58,13,SS_CENTERIMAGE | NOT
diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp
index 9988e7d8..a3306cc3 100644
--- a/code/loaddlg.cpp
+++ b/code/loaddlg.cpp
@@ -358,6 +358,16 @@ LRESULT CALLBACK LoadOptionsClass::Delete_Dialog_Proc(HWND window, UINT message,
}
+///
+/// Is a saved game of this name already there? Asked before one is written, since a name the
+/// folder holds is written over rather than added to.
+///
+static bool Saved_Game_Exists(char const * name)
+{
+ return(GetFileAttributes(Saved_Game_Name(name).c_str()) != INVALID_FILE_ATTRIBUTES);
+}
+
+
/***********************************************************************************************
* LoadOptionsClass::Process -- main processing routine *
* *
@@ -490,25 +500,17 @@ bool LoadOptionsClass::Dialog(void)
}
const char * filename = NULL;
+ char test_filename[256];
if (entry && entry->Valid) {
filename = entry->Filename;
} else {
- char test_filename[256];
-
- { /// the scope is important to make it match - temp_file nedes to be destroyed before assigning the string
- CCFileClass temp_file;
- do {
- sprintf(test_filename, "SAVE%04lX.%3s", rand(), Extension);
- temp_file.Set_Name(test_filename);
- } while (temp_file.Is_Available() == true);
- }
-
+ Pick_Filename(test_filename);
filename = test_filename;
}
if (filename != NULL) {
- bool exists = CDFileClass(filename).Is_Available() == true;
+ bool exists = Saved_Game_Exists(filename);
if (exists && WWMessageBox()._Process(TXT_CONFIRM_SAVE, 1, TXT_YES, TXT_NO, TXT_NONE))
State = STATE_PENDING;
else {
@@ -569,11 +571,9 @@ bool LoadOptionsClass::Dialog(void)
/// Be sure the buffer is big enough to hold a complete filename.
void LoadOptionsClass::Pick_Filename(char *name)
{
- CCFileClass file;
do {
sprintf(name, "SAVE%04lX.%3s", rand(), Extension);
- file.Set_Name(name);
- } while (file.Is_Available() == true);
+ } while (Saved_Game_Exists(name));
}
@@ -606,25 +606,6 @@ void LoadOptionsClass::Clear_List(void)
}
-/*
- * Recovers the directory entry for a saved game the scan turned up. The scan reports bare
- * names, so the file is located the way an open would locate it and then asked about by the
- * name it actually has. The entry names the file alone, without the directory it sits in.
- */
-static bool Find_Saved_Game(char const * name, WIN32_FIND_DATAA * entry)
-{
- CDFileClass located(name);
-
- HANDLE handle = FindFirstFile(located.File_Name(), entry);
- if (handle == INVALID_HANDLE_VALUE) {
- return(false);
- }
-
- FindClose(handle);
- return(true);
-}
-
-
/***********************************************************************************************
* LoadOptionsClass::Fill_List -- fills the list box & GameNum arrays *
* *
@@ -685,22 +666,28 @@ void LoadOptionsClass::Fill_List(HWND window)
*/
fdata = NULL;
- for (std::string const & name : Search_Files(buffer)) {
- if (!Find_Saved_Game(name.c_str(), &ff)) {
- continue;
- }
+ HANDLE hFind = FindFirstFile(Saved_Game_Name(buffer).c_str(), &ff);
- if (fdata == NULL) {
- fdata = new FileEntryClass;
- }
+ if (hFind != INVALID_HANDLE_VALUE) {
+ do {
+ if ((ff.dwFileAttributes & (FILE_ATTRIBUTE_TEMPORARY|FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_HIDDEN)) != 0) {
+ continue;
+ }
- /*
- ** get the game's info; if success, add it to the list
- */
- if (Read_File(fdata, &ff) == true) {
- Files.Add(fdata);
- fdata = NULL;
- }
+ if (fdata == NULL) {
+ fdata = new FileEntryClass;
+ }
+
+ /*
+ ** get the game's info; if success, add it to the list
+ */
+ if (Read_File(fdata, &ff) == true) {
+ Files.Add(fdata);
+ fdata = NULL;
+ }
+ } while (FindNextFile(hFind, &ff));
+
+ FindClose(hFind);
}
if (fdata != NULL) {
@@ -786,21 +773,26 @@ bool LoadOptionsClass::Files_Present(void)
sprintf(pattern, "*.%3s", Extension);
WIN32_FIND_DATAA find_data;
+ HANDLE hFind = FindFirstFile(Saved_Game_Name(pattern).c_str(), &find_data);
- for (std::string const & name : Search_Files(pattern)) {
- if (_stricmp(name.c_str(), NET_SAVE_FILE_NAME) == 0) {
- continue;
- }
+ if (hFind != INVALID_HANDLE_VALUE) {
+ do {
+ if ((find_data.dwFileAttributes & (FILE_ATTRIBUTE_TEMPORARY|FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_HIDDEN)) != 0) {
+ continue;
+ }
- if (!Find_Saved_Game(name.c_str(), &find_data)) {
- continue;
- }
+ if (_stricmp(find_data.cFileName, NET_SAVE_FILE_NAME) == 0) {
+ continue;
+ }
- FileEntryClass entry;
- if (Read_File(&entry, &find_data) == true) {
- files_found = true;
- break;
- }
+ FileEntryClass entry;
+ if (Read_File(&entry, &find_data) == true) {
+ files_found = true;
+ break;
+ }
+ } while (FindNextFile(hFind, &find_data));
+
+ FindClose(hFind);
}
return(files_found);
@@ -883,7 +875,7 @@ bool LoadOptionsClass::Save_File(const char * file_name, const char * descr)
/// bool; Was the file deleted?
bool LoadOptionsClass::Delete_File(const char * file_name)
{
- if (DeleteFile(User_File_Write_Name(file_name).c_str()) == TRUE) {
+ if (DeleteFile(Saved_Game_Name(file_name).c_str()) == TRUE) {
return(true);
}
return(false);
diff --git a/code/mapgen.cpp b/code/mapgen.cpp
index 349b8846..d5ed5610 100644
--- a/code/mapgen.cpp
+++ b/code/mapgen.cpp
@@ -29,6 +29,7 @@
#include "coord.h"
#include "data.h"
#include "dbgprint.h"
+#include "gamedirs.h"
#include "house.h"
#include "houstype.h"
#include "incdec.h"
@@ -4366,6 +4367,16 @@ bool MapSeedClass::Save(const char * name)
}
+///
+/// Is this the generator's own map rather than settings a player saved? The map travels to
+/// the other machines with the match, so it is kept where the game's own files are.
+///
+static bool Is_Shared_Map_File(char const * file_name)
+{
+ return(stricmp(file_name, RANDOM_MAP_FILE_NAME) == 0);
+}
+
+
///
/// Writes the map generator settings to a file.
/// This routine records everything the random map dialog offers, so that loading the file
@@ -4379,7 +4390,9 @@ bool MapSeedClass::Save_File(const char * file_name, const char * descr)
{
if (file_name != NULL) {
DebugString("Saving random map: %s - %s\n", file_name, descr);
- CCFileClass file(file_name);
+ CCFileClass shared(file_name);
+ RawFileClass owned(Saved_Game_Name(file_name).c_str());
+ FileClass & file = Is_Shared_Map_File(file_name) ? (FileClass &)shared : (FileClass &)owned;
INIClass ini;
ini.Put_String("RandomMap", "Description", descr);
ini.Put_Int("RandomMap", "Width", Width, 0);
@@ -4439,7 +4452,9 @@ bool MapSeedClass::Load_File(const char * file_name)
{
if (file_name != NULL) {
DebugString("Loading random map: %s\n", file_name);
- CCFileClass file(file_name);
+ CCFileClass shared(file_name);
+ RawFileClass owned(Saved_Game_Name(file_name).c_str());
+ FileClass & file = Is_Shared_Map_File(file_name) ? (FileClass &)shared : (FileClass &)owned;
INIClass ini;
if (ini.Load(file)) {
@@ -4511,7 +4526,7 @@ bool MapSeedClass::Read_File(FileEntryClass * entry, WIN32_FIND_DATAA * ff)
if (entry != NULL && ff != NULL) {
if (stricmp(ff->cFileName, RANDOM_MAP_FILE_NAME)) {
- CCFileClass file(ff->cFileName);
+ RawFileClass file(Saved_Game_Name(ff->cFileName).c_str());
INIClass ini;
if (ini.Load(file)) {
if (ini.Get_String("RandomMap", "Description", 0, buffer, sizeof(buffer)) > 0 )
diff --git a/code/netdlg2.cpp b/code/netdlg2.cpp
index 65b91c17..65fd2f56 100644
--- a/code/netdlg2.cpp
+++ b/code/netdlg2.cpp
@@ -851,7 +851,6 @@ bool Net2Remote_Connect(void)
// Add myself to the list, and to the Players vector.
//------------------------------------------------------------------------
NodeNameType * who = new NodeNameType;
- memset(who, 0, sizeof(*who));
strcpy(who->Name, Session.Handle);
strcpy(who->Player.Serial, SerialNumber);
who->Player.House = Session.House;
@@ -2473,7 +2472,6 @@ static void Get_Join_Responses(void)
// Create & add a node to the Vector
//..................................................................
who = new NodeNameType;
- memset(who, 0, sizeof(*who));
strcpy(who->Name, Session.GPacket.Name);
strcpy(who->Player.Serial, Session.GPacket.Serial);
who->Address = Session.GAddress;
@@ -2530,7 +2528,6 @@ static void Get_Join_Responses(void)
Clear_Vector(&Session.Players);
who = new NodeNameType;
- memset(who, 0, sizeof(*who));
strcpy(who->Name, Session.Handle);
who->Player.House = Session.House;
who->Player.Color = Session.ColorIdx;
@@ -3104,7 +3101,6 @@ static void Get_Join_Responses(void)
// Add node to the Vector list
//..................................................................
who = new NodeNameType;
- memset(who, 0, sizeof(*who));
strcpy(who->Name, Session.GPacket.Name);
who->Address = Session.GAddress;
who->Player.House = Session.GPacket.PlayerInfo.House;
diff --git a/code/netshare.cpp b/code/netshare.cpp
index 5909278d..797a7ef3 100644
--- a/code/netshare.cpp
+++ b/code/netshare.cpp
@@ -1261,6 +1261,20 @@ int CALLBACK Scenario_DlgProc(HWND window, UINT message, WPARAM wparam, LPARAM l
}
+///
+/// Puts the options every machine agreed on into the globals the simulation reads, so a match
+/// against other machines is played under one set of rules however it was set up.
+///
+void Commit_Session_Specials(void)
+{
+ Special.IsHarvesterImmune = Session.Options.HarvTruce;
+ Special.IsDestroyBridges = Session.Options.BridgeDestruction;
+ Special.IsTGrowth = true;
+ Special.IsTSpread = true;
+ Special.Apply_To_Game();
+}
+
+
///
/// Performs the last setup step before a multiplayer game begins.
/// This routine copies the agreed session options into the globals the game logic actually
@@ -1274,11 +1288,7 @@ void PregameSetup(void)
DebugString("Pregame setup for %d players.\n", Session.NumPlayers);
Options.GameSpeed = Session.Options.GameSpeed;
Session.CommProtocol = DEFAULT_COMM_PROTOCOL;
- Special.IsHarvesterImmune = Session.Options.HarvTruce;
- Special.IsDestroyBridges = Session.Options.BridgeDestruction;
- Special.IsTGrowth = true;
- Special.IsTSpread = true;
- Special.Apply_To_Game();
+ Commit_Session_Specials();
}
diff --git a/code/netshare.h b/code/netshare.h
index 27200fc6..3d18e8eb 100644
--- a/code/netshare.h
+++ b/code/netshare.h
@@ -20,6 +20,7 @@ int ODMessageBox(const char *text, int type, bool (*callback)(void), bool large
int CALLBACK ODMessageBox_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam);
bool Set_Scenario_Info_From_Index(int index);
+void Commit_Session_Specials(void);
void PregameSetup(void);
void Update_Network_Dialog_Preview(HWND win);
void Receive_Random_Map_Preview(void);
diff --git a/code/options.cpp b/code/options.cpp
index de479fdd..f69efe1c 100644
--- a/code/options.cpp
+++ b/code/options.cpp
@@ -359,7 +359,7 @@ void OptionsClass::Load_Settings(void)
DebugString("GameSpeed = %d\n", GameSpeed);
Difficulty = ConfigINI.Get_Int("Options", "Difficulty", Difficulty);
- Difficulty = std::min(Difficulty, 4);
+ Difficulty = std::min(Difficulty, (int)DIFF_COUNT - 1);
Difficulty = std::max(Difficulty, 0);
DebugString("Difficulty = %d\n", Difficulty);
diff --git a/code/saveload.cpp b/code/saveload.cpp
index 51c1bcc0..e7e9bf58 100644
--- a/code/saveload.cpp
+++ b/code/saveload.cpp
@@ -149,8 +149,6 @@ static bool MultiplayerSavePending = false;
static std::string PendingSaveFileName;
static std::string PendingSaveDescription;
-static int Reconcile_Players(void);
-
_COM_SMARTPTR_TYPEDEF(ILinkStream, __uuidof(ILinkStream));
@@ -620,8 +618,12 @@ static bool Put_All(IStream *stream, int save_net)
return(false);
}
- if (Session.Type == GAME_SKIRMISH) {
- DebugString("Writing Skirmish Session.Options\n");
+ /*
+ * A campaign takes its options from the mission. Every other kind is given them at setup, so
+ * the save is the only place a resume can find them.
+ */
+ if (Session.Type != GAME_NORMAL) {
+ DebugString("Writing Session.Options\n");
if (!Session.Options.Save(stream)) {
DebugString("\t***** FAILED!\n");
return(false);
@@ -870,8 +872,8 @@ static bool Get_All(IStream *stream, bool save_net)
return(false);
}
- if (Session.Type == GAME_SKIRMISH) {
- DebugString("Reading Skirmish Session.Options\n");
+ if (Session.Type != GAME_NORMAL) {
+ DebugString("Reading Session.Options\n");
if (!Session.Options.Load(stream)) {
DebugString("\t***** FAILED!\n");
return(false);
@@ -928,7 +930,7 @@ static bool Save_Game(const char *file_name, char const * descr)
DebugString("\nSAVING GAME [%s - %s]\n", file_name, descr);
- MultiByteToWideChar(0,0, User_File_Write_Name(file_name).c_str(), -1, name, sizeof(name)/sizeof(WCHAR));
+ MultiByteToWideChar(0,0, Saved_Game_Name(file_name).c_str(), -1, name, sizeof(name)/sizeof(WCHAR));
/*
** Open the file
@@ -1181,8 +1183,8 @@ bool Load_Game(const char *file_name)
*/
IStoragePtr storage;
- // Structured storage goes straight to Windows, so the file layer locates the save first.
- MultiByteToWideChar(0,0,CDFileClass(file_name).File_Name(), -1, name, (sizeof(name)/sizeof(WCHAR)));
+ // Structured storage goes straight to Windows, so the saved game is named in full first.
+ MultiByteToWideChar(0,0,Saved_Game_Name(file_name).c_str(), -1, name, (sizeof(name)/sizeof(WCHAR)));
if (FAILED(StgOpenStorage(name, 0, STGM_SHARE_DENY_WRITE, 0, 0, &storage))) {
return(false);
@@ -1217,6 +1219,10 @@ bool Load_Game(const char *file_name)
*/
Post_Load_Game();
+ // The next mission of a resumed campaign is played at the pair the save carries.
+ Session.CampaignDifficulty = Scen->Difficulty;
+ Session.CampaignCDifficulty = Scen->CDifficulty;
+
Map.Init_IO();
Map.Activate(1);
Map.Reposition_Sidebar();
@@ -1328,8 +1334,8 @@ bool Get_Savefile_Info(char const * name, SaveVersionInfo * info)
IStoragePtr storage;
WCHAR wname[MAX_PATH];
- // Structured storage goes straight to Windows, so the file layer locates the save first.
- MultiByteToWideChar(0, 0, CDFileClass(name).File_Name(), -1, wname, sizeof(wname) / sizeof(WCHAR));
+ // Structured storage goes straight to Windows, so the saved game is named in full first.
+ MultiByteToWideChar(0, 0, Saved_Game_Name(name).c_str(), -1, wname, sizeof(wname) / sizeof(WCHAR));
HRESULT result = StgOpenStorage(wname, NULL, STGM_SHARE_EXCLUSIVE|STGM_READWRITE, NULL, 0, &storage);
if (FAILED(result)) {
@@ -1345,134 +1351,63 @@ bool Get_Savefile_Info(char const * name, SaveVersionInfo * info)
}
-/***************************************************************************
- * Reconcile_Players -- Reconciles loaded data with the 'Players' vector *
- * *
- * This function is for supporting loading a saved multiplayer game. *
- * When the game is loaded, we have to figure out which house goes with *
- * which entry in the Players vector. We also have to figure out if *
- * everyone who was originally in the game is still with us, and if not, *
- * turn their stuff over to the computer. *
- * *
- * So, this function does the following: *
- * - For every name in 'Players', makes sure that name is in the House *
- * array; if not, it's a fatal error. *
- * - For every human-controlled house, makes sure there's a player *
- * with that name; if not, it turns that house over to the computer. *
- * - Fills in the Player's house ID *
- * *
- * This assumes that each player MUST keep their name the same as it was *
- * when the game was saved! It's also assumed that the network *
- * connections have not been formed yet, since Player[i]->Player.ID will *
- * be invalid until this routine has been called. *
- * *
- * INPUT: *
- * none. *
- * *
- * OUTPUT: *
- * true = OK, false = error *
- * *
- * WARNINGS: *
- * none. *
- * *
- * HISTORY: *
- * 09/29/1995 BRR : Created. *
- *=========================================================================*/
-static int Reconcile_Players(void)
+///
+/// Gives each seated player the restored house carrying its name, so the connections formed
+/// afterwards reach the right houses.
+///
+/// bool; Do the seats and the saved houses agree?
+bool Reconcile_Players(void)
{
- #if 0
- int i;
- int found;
- HousesType house;
- HouseClass * housep;
-
- /*
- ** If there are no players, there's nothing to do.
- */
- if (Session.Players.Count()==0)
+ if (Session.Players.Count() == 0) {
return(true);
+ }
- /*
- ** Make sure every name we're connected to can be found in a House
- */
- for (i = 0; i < Session.Players.Count(); i++) {
- found = 0;
- for (house = HOUSE_MULTI1; house < HOUSE_MULTI1 +
- Session.MaxPlayers; house++) {
-
- housep = Houses[house];
- if (!housep) {
- continue;
- }
+ for (int i = 0; i < Session.Players.Count(); i++) {
+ HouseClass * found = NULL;
- if (!stricmp(Session.Players[i]->Name, housep->IniName)) {
- found = 1;
+ for (int house = 0; house < Houses.Count(); house++) {
+ if (Houses[house]->IsHuman && stricmp(Session.Players[i]->Name, Houses[house]->IniName) == 0) {
+ found = Houses[house];
break;
}
}
- if (!found)
+
+ if (found == NULL) {
return(false);
+ }
+
+ Session.Players[i]->Player.ID = found->HeapID;
}
- //
- // Loop through all Houses; if we find a human-owned house that we're
- // not connected to, turn it over to the computer.
- //
- for (house = HOUSE_MULTI1; house < HOUSE_MULTI1 +
- Session.MaxPlayers; house++) {
- housep = Houses[house];
- if (!housep) {
- continue;
- }
+ // The first seat is this machine, and PlayerPtr the house that wrote the save.
+ if (Houses[Session.Players[0]->Player.ID] != PlayerPtr) {
+ return(false);
+ }
- //
- // Skip this house if it wasn't human to start with.
- //
+ for (int house = 0; house < Houses.Count(); house++) {
+ HouseClass * housep = Houses[house];
if (!housep->IsHuman) {
continue;
}
- //
- // Try to find this name in the Players vector; if it's found, set
- // its ID to this house.
- //
- found = 0;
- for (i = 0; i < Session.Players.Count(); i++) {
- if (!stricmp(Session.Players[i]->Name, housep->IniName)) {
- found = 1;
- Session.Players[i]->Player.ID = house;
+ bool seated = false;
+ for (int i = 0; i < Session.Players.Count(); i++) {
+ if (Session.Players[i]->Player.ID == housep->HeapID) {
+ seated = true;
break;
}
}
- /*
- ** If this name wasn't found, remove it
- */
- if (!found) {
-
- /*
- ** Turn the player's house over to the computer's AI
- */
+ // A player who did not return leaves their house fighting on under the computer.
+ if (!seated) {
housep->IsHuman = false;
housep->IsStarted = true;
-// housep->Smartness = IQ_MENSA;
housep->IQ = Rule->MaxIQ;
- housep->IniName = Text_String(TXT_COMPUTER);
-
- Session.NumPlayers--;
+ housep->IniName = Fetch_String(TXT_COMPUTER);
}
}
- //
- // If all went well, our Session.NumPlayers value should now equal the value
- // from the saved game, minus any players we removed.
- //
- if (Session.NumPlayers == Session.Players.Count()) {
- return(true);
- } else {
- return(false);
- }
- #endif
+ return(true);
}
diff --git a/code/saveload.h b/code/saveload.h
index 67ee5d92..2d6bb8fc 100644
--- a/code/saveload.h
+++ b/code/saveload.h
@@ -23,6 +23,7 @@ int Load_Misc_Values(IStream * stream);
int Save_Misc_Values(IStream * stream);
bool Get_Savefile_Info(char const * name, SaveVersionInfo * info);
bool Load_Game(const char *file_name);
+bool Reconcile_Players(void);
bool Request_Save_Game(char const * file_name, char const * descr);
void Process_Pending_Save_Game(void);
void Reset_Multiplayer_Save_State(void);
diff --git a/code/scenario.cpp b/code/scenario.cpp
index d65bfc79..3feb6540 100644
--- a/code/scenario.cpp
+++ b/code/scenario.cpp
@@ -1430,6 +1430,11 @@ char const * Pick_Load_Background_Name(Point2D & pos)
player = Session.Players[player]->Player.House;
}
+ // Only two sides have loading art, so any other house is shown the first side's.
+ if (player < 0 || player > 1) {
+ player = 0;
+ }
+
int choice = (player << 1) + Random_Pick(0, 1);
if (VisibleRect.Width == 640) {
@@ -1583,8 +1588,8 @@ bool Read_Scenario_INI(CCINIClass const & ini, bool is_mapgen)
Clear_Scenario();
if (Session.Type == GAME_NORMAL) {
- Scen->Difficulty = (DiffType)Options.Difficulty;
- Scen->CDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty);
+ Scen->Difficulty = Session.CampaignDifficulty;
+ Scen->CDifficulty = Session.CampaignCDifficulty;
Scen->Special.IsFogOfWar = false;
Special.IsFogOfWar = false;
} else {
@@ -2063,6 +2068,29 @@ void Write_Scenario_INI(char const * fname, bool mplayer)
}
+///
+/// Fetches the node describing one seat of the match. The player list is in each machine's
+/// own order, so a seat is found by the house it was assigned, not by position.
+///
+/// The node describing that seat, or NULL if the match does not hold it.
+static NodeNameType * Seated_Node(int seat)
+{
+ for (int i = 0; i < Session.Players.Count(); i++) {
+ if (Session.Players[i]->Player.ID == seat) {
+ return(Session.Players[i]);
+ }
+ }
+
+ for (int i = 0; i < Session.Computers.Count(); i++) {
+ if (Session.Computers[i]->Player.ID == seat) {
+ return(Session.Computers[i]);
+ }
+ }
+
+ return(NULL);
+}
+
+
/***********************************************************************************************
* Assign_Houses -- Assigns multiplayer houses to various players *
* *
@@ -2161,6 +2189,8 @@ void Assign_Houses(void)
housep->Assign_Handicap(DIFF_NORMAL);
+ housep->SpawnWaypoint = player->Player.SpawnChoice;
+
//.....................................................................
// Record where we placed this player
//.....................................................................
@@ -2173,7 +2203,18 @@ void Assign_Houses(void)
// Now assign computer players to the remaining houses.
//------------------------------------------------------------------------
for (i = Session.Players.Count(); i < Session.Players.Count() + Session.Options.AIPlayers; i++) {
+
+ /*
+ * A launch file may have seated this computer player. Anything it left unnamed the game
+ * picks, as it does for a game set up from the menu.
+ */
+ int seatnum = i - Session.Players.Count();
+ NodeNameType * seat = seatnum < Session.Computers.Count() ? Session.Computers[seatnum] : NULL;
+
pref_house = (HousesType)Random_Pick(0, 1);
+ if (seat != NULL && seat->Player.House != -1) {
+ pref_house = (HousesType)seat->Player.House;
+ }
// Pick a color for this house; keep looping until we find one.
int color = -1;
@@ -2183,6 +2224,13 @@ void Assign_Houses(void)
break;
}
}
+
+ /*
+ * A seated color is taken as written, repeats included: a cooperative team shares one.
+ */
+ if (seat != NULL && seat->Player.Color != -1) {
+ color = seat->Player.Color;
+ }
color_used[color] = true;
/*
@@ -2196,7 +2244,7 @@ void Assign_Houses(void)
housep->Init_Data(color, pref_house, Session.Options.Credits);
housep->Scheme = Session.Color_Index_To_Scheme(color);
housep->Initialize_Radar_Color();
- housep->IniName = Fetch_String(TXT_COMPUTER);
+ housep->IniName = (seat != NULL && seat->Name[0] != '\0') ? seat->Name : Fetch_String(TXT_COMPUTER);
if (Session.Type != GAME_NORMAL) {
housep->IQ = Rule->MaxIQ;
@@ -2206,7 +2254,35 @@ void Assign_Houses(void)
if (Session.Players.Count() > 1 && Rule->IsCompEasyBonus && difficulty > DIFF_EASY) {
difficulty = (DiffType)(difficulty - 1);
}
+ if (seat != NULL && seat->Player.Handicap >= 0) {
+ difficulty = (DiffType)seat->Player.Handicap;
+ }
housep->Assign_Handicap(difficulty);
+
+ if (seat != NULL) {
+ housep->SpawnWaypoint = seat->Player.SpawnChoice;
+ seat->Player.ID = housep->HeapID;
+ }
+ }
+
+ // A seat's mask names other seats, not the houses they became.
+ int seated = Session.Players.Count() + Session.Computers.Count();
+ for (int seatnum = 0; seatnum < seated; seatnum++) {
+ NodeNameType * node = Seated_Node(seatnum);
+ if (node == NULL || node->Player.AlliesMask == 0) {
+ continue;
+ }
+
+ for (int target = 0; target < seated; target++) {
+ if (target == seatnum || (node->Player.AlliesMask & (1u << target)) == 0) {
+ continue;
+ }
+
+ NodeNameType * other = Seated_Node(target);
+ if (other != NULL) {
+ Houses[node->Player.ID]->Make_Ally(Houses[other->Player.ID]);
+ }
+ }
}
HouseClass * neutral_house = new HouseClass(HouseTypes[HouseTypeClass::From_Name("Neutral")]);
@@ -2250,18 +2326,67 @@ static void Remove_AI_Players(void)
///
-/// Fetches the starting locations available to a multiplayer game.
-/// The scenario's own waypoints are preferred, but a map that does not supply enough of
-/// them for everyone playing has the shortfall made up with random spots on open ground.
+/// Makes up a shortfall of starting locations with open ground, since a map need not declare
+/// a start position for everybody playing.
+///
+/// The list of starting locations to append to.
+/// How many of the locations may actually be started from; raised as
+/// spots are appended.
+/// How many are needed.
+static void Append_Open_Start_Positions(DynamicVectorClass & waypts, int & usable, int wanted)
+{
+ if (usable >= wanted) {
+ return;
+ }
+
+ DebugString("Multiplayer start waypoint deficiency - looking for more start positions\n");
+
+ while (usable < wanted) {
+ Cell trycell = Cell(Map.MapRect.X + Random_Pick(10, Map.MapRect.Width - 10), Map.MapRect.Y + 10 + Random_Pick(0, Map.MapRect.Height - 10));
+
+ trycell = Map.Nearby_Location(trycell, SPEED_TRACK, -1, MZONE_NORMAL, false, Point2D(8, 8));
+ if (trycell != CELL_NONE) {
+ waypts.Add(trycell);
+ usable++;
+ DebugString("Random multiplayer start waypoint added at cell %d,%d\n", trycell.X, trycell.Y);
+ }
+ }
+}
+
+
+///
+/// Fetches the starting locations a multiplayer game may use, making up any shortfall with
+/// open ground. When identity is kept, each entry is numbered by its waypoint and undeclared
+/// ones are left as holes.
///
/// Is this one of the maps that shipped with the game?
+/// Must an entry's place in the list be its waypoint number?
/// Returns with the list of cells that players may be started from.
-static DynamicVectorClass Build_Start_Waypoint_List(bool official)
+static DynamicVectorClass Build_Start_Waypoint_List(bool official, bool keep_identity)
{
DynamicVectorClass waypts;
+ if (keep_identity) {
+ int usable = 0;
+ for (int waycount = 0; waycount < MAX_PLAYERS; waycount++) {
+ bool declared = Scen->Is_Valid_Waypoint(waycount);
+ waypts.Add(declared ? Scen->Get_Waypoint_Cell(waycount) : CELL_NONE);
+ if (declared) {
+ usable++;
+ }
+ }
+
+ /*
+ * Spots making up a shortfall are appended past the numbered ones, so no number comes to
+ * mean a place the map never declared.
+ */
+ Append_Open_Start_Positions(waypts, usable, Session.Players.Count() + Session.Options.AIPlayers);
+
+ return(waypts);
+ }
+
int num_waypts = 0;
- for (int i = 0; i < 8; i++) {
+ for (int i = 0; i < MAX_PLAYERS; i++) {
if (Scen->Is_Valid_Waypoint(i)) {
num_waypts++;
} else {
@@ -2277,7 +2402,7 @@ static DynamicVectorClass Build_Start_Waypoint_List(bool official)
*/
int look_for = std::max(num_waypts, Session.Players.Count()+Session.Options.AIPlayers);
if (!official) {
- look_for = 8;
+ look_for = MAX_PLAYERS;
}
for (int waycount = 0; waycount < look_for; waycount++) {
@@ -2287,24 +2412,8 @@ static DynamicVectorClass Build_Start_Waypoint_List(bool official)
}
}
- /*
- ** If there are insufficient waypoints to account for all players, then randomly assign
- ** starting points until there is enough.
- */
- int deficiency = look_for - waypts.Count();
- if (deficiency > 0) {
- DebugString("Multiplayer start waypoint deficiency - looking for more start positions\n");
-
- while (waypts.Count() < look_for) {
- Cell trycell = Cell(Map.MapRect.X + Random_Pick(10, Map.MapRect.Width - 10), Map.MapRect.Y + 10 + Random_Pick(0, Map.MapRect.Height - 10));
-
- trycell = Map.Nearby_Location(trycell, SPEED_TRACK, -1, MZONE_NORMAL, false, Point2D(8, 8));
- if (trycell != CELL_NONE) {
- waypts.Add(trycell);
- DebugString("Random multiplayer start waypoint added at cell %d,%d\n", trycell.X, trycell.Y);
- }
- }
- }
+ int usable = waypts.Count();
+ Append_Open_Start_Positions(waypts, usable, look_for);
return(waypts);
}
@@ -2367,16 +2476,52 @@ static void Create_Units(bool official)
int average_cost = total_cost / total_objs;
int max_value = unit_count * average_cost;
+ /*
+ * A house only asks for a position by number when a launch file chose one for it, which is
+ * what decides whether the numbers must keep their identity.
+ */
+ bool choices = false;
+ for (int index = 0; index < Houses.Count(); index++) {
+ if (Houses[index] != NULL && Houses[index]->SpawnWaypoint >= 0) {
+ choices = true;
+ break;
+ }
+ }
+
/*
** Build a list of the valid waypoints. This normally shouldn't be
** necessary because the scenario level designer should have assigned
** valid locations to the first N waypoints, but just in case, this
** loop verifies that.
*/
- DynamicVectorClass waypts = Build_Start_Waypoint_List(official);
- bool taken[16];
+ DynamicVectorClass waypts = Build_Start_Waypoint_List(official, choices);
+ bool taken[MAX_PLAYERS * 2];
for (int index = 0; index < ARRAY_SIZE(taken); index++) {
- taken[index] = false;
+ taken[index] = choices && index < waypts.Count() && waypts[index] == CELL_NONE;
+ }
+
+ /*
+ * A house that named a position holds it before anybody draws, so a house that named none
+ * cannot take it. When two name the same position, the first keeps it.
+ */
+ int reserved[MAX_PLAYERS * 2];
+ for (int index = 0; index < ARRAY_SIZE(reserved); index++) {
+ reserved[index] = -1;
+ }
+
+ if (choices) {
+ for (int index = 0; index < Houses.Count(); index++) {
+ HouseClass * housep = Houses[index];
+ if (housep == NULL || housep->Class->IsMultiplayPassive) {
+ continue;
+ }
+
+ int spot = housep->SpawnWaypoint;
+ if (spot >= 0 && spot < waypts.Count() && !taken[spot]) {
+ reserved[spot] = index;
+ taken[spot] = true;
+ }
+ }
}
/*
@@ -2426,10 +2571,18 @@ static void Create_Units(bool official)
** one of the valid locations at random. The other houses pick the furthest
** wapoint from the existing houses.
*/
- if (numtaken == 0) {
- int pick = Random_Pick(0, waypts.Count() - 1);
+ if (choices && hptr->SpawnWaypoint >= 0 && hptr->SpawnWaypoint < waypts.Count() &&
+ reserved[hptr->SpawnWaypoint] == (int)house) {
+ centroid = waypts[hptr->SpawnWaypoint];
+ numtaken++;
+ } else if (numtaken == 0) {
+ int pick;
+ do {
+ pick = Random_Pick(0, waypts.Count() - 1);
+ } while (taken[pick]);
centroid = waypts[pick];
taken[pick] = true;
+ hptr->SpawnWaypoint = pick;
numtaken++;
} else {
@@ -2454,7 +2607,7 @@ static void Create_Units(bool official)
if (!taken[index]) {
for (int trypoint = 0; trypoint < waypts.Count(); trypoint++) {
- if (taken[trypoint]) {
+ if (taken[trypoint] && waypts[trypoint] != CELL_NONE) {
score[index] += Distance(waypts[index], waypts[trypoint]);
}
}
@@ -2468,6 +2621,9 @@ static void Create_Units(bool official)
int best = 0;
int bestvalue = 0;
for (int searchindex = 0; searchindex < waypts.Count(); searchindex++) {
+ if (waypts[searchindex] == CELL_NONE) {
+ continue;
+ }
if (score[searchindex] > bestvalue || bestvalue == 0) {
bestvalue = score[searchindex];
best = searchindex;
@@ -2479,6 +2635,7 @@ static void Create_Units(bool official)
*/
centroid = waypts[best];
taken[best] = true;
+ hptr->SpawnWaypoint = best;
numtaken++;
}
diff --git a/code/session.cpp b/code/session.cpp
index da274270..a266f617 100644
--- a/code/session.cpp
+++ b/code/session.cpp
@@ -66,6 +66,7 @@
#include "rules.h"
#include "savestream.h"
#include "scenario.h"
+#include "spawner.h"
#include "special.h"
#include "stats.h"
#include "xstraw.h"
@@ -174,6 +175,8 @@ SessionClass::SessionClass(void)
ObiWan = 0;
Solo = 0;
+ CampaignDifficulty = DIFF_NORMAL;
+ CampaignCDifficulty = DIFF_NORMAL;
MasterPlayerID = -1;
memset(MasterPlayerName, 0, sizeof(MasterPlayerName));
@@ -272,7 +275,11 @@ SessionClass::~SessionClass(void)
void SessionClass::One_Time(void)
{
//Read_MultiPlayer_Settings();
- Read_Scenario_Descriptions();
+
+ // A client-launched game names its scenario outright and never shows the map list.
+ if (!Spawner_Is_Requested()) {
+ Read_Scenario_Descriptions();
+ }
UniqueID = Compute_Unique_ID();
DebugString("Session one time init. UniqueID is %08x\n", UniqueID);
diff --git a/code/session.h b/code/session.h
index a6b2928a..6846fcae 100644
--- a/code/session.h
+++ b/code/session.h
@@ -44,6 +44,8 @@
#include "version.h"
#include "win.h"
+#include
+
#include "dialog.hh"
#include "diff.hh"
@@ -217,6 +219,9 @@ struct NodeNameType {
int ProcessTime; // Length of time to process players main loop
int Status; //
int SquadID; //
+ int SpawnChoice; // starting waypoint asked for; -1 = the engine picks
+ int Handicap; // difficulty asked for; -1 = the session default
+ unsigned AlliesMask; // seats allied with, one bit per seat index
} Player;
struct {
unsigned int LastTime; // last time we heard from this guy
@@ -224,6 +229,17 @@ struct NodeNameType {
int Color; // chat player's color
} Chat;
};
+
+ // A new node asks for nothing, leaving the start position and difficulty to the game.
+ NodeNameType(void)
+ {
+ memset(this, 0, sizeof(*this));
+ Player.SpawnChoice = -1;
+ Player.Handicap = -1;
+
+ // The memset above wipes the broadcast address the default constructor supplies.
+ Address = IPXAddressClass();
+ }
};
@@ -510,6 +526,13 @@ class SessionClass
int ObiWan; // 1 = player can see all
int Solo; // 1 = player can play alone
+ /*
+ * The pair a campaign mission is played at. It lives here because the scenario's own
+ * copy is wiped before each mission, while a restart or the next one must keep it.
+ */
+ DiffType CampaignDifficulty;
+ DiffType CampaignCDifficulty;
+
/*
* If the local player is playing a GDI house, then this flag will be true. A starting
* multiplayer scenario takes its side and its speech set from it.
@@ -683,6 +706,9 @@ class SessionClass
DynamicVectorClass Games; // list of games
DynamicVectorClass Players; // list of players
DynamicVectorClass Chat; // list of chat nodes
+
+ // The computer players a launch file seated, after the humans; the menu leaves this empty.
+ DynamicVectorClass Computers;
int Suspended;
/*
diff --git a/code/skirmish.cpp b/code/skirmish.cpp
index 1f3d5f7e..83073897 100644
--- a/code/skirmish.cpp
+++ b/code/skirmish.cpp
@@ -220,11 +220,7 @@ bool Skirmish_Mode_Dialog(void)
{
int rc = -1;
- Rule->Do_HouseTypes(*RuleINI);
- Rule->Do_Sides(*RuleINI);
- for (int i = 0; i < HouseTypes.Count(); i++) {
- HouseTypes[i]->Read_INI(*RuleINI);
- }
+ Prepare_Side_Roster();
Hide_Mouse();
Draw_Menu_Background();
@@ -417,6 +413,7 @@ BOOL Skirmish_On_WM_INITDIALOG(HWND window, WPARAM wparam, LPARAM lparam)
Session.Options.ScenarioIndex = 0;
SendDlgItemMessage(window, IDC_SCENARIONAME, WM_SETTEXT, 0, (LPARAM)Session.Options.ScenarioDescription);
Clear_Vector(&Session.Players);
+ Clear_Vector(&Session.Computers);
handle = GetDlgItem(window, IDC_SKIRMISH_BASES);
if (handle) Button_SetCheck(handle, Session.Options.Bases ? BST_CHECKED : BST_UNCHECKED);
diff --git a/code/spawner.cpp b/code/spawner.cpp
new file mode 100644
index 00000000..d664b1dc
--- /dev/null
+++ b/code/spawner.cpp
@@ -0,0 +1,539 @@
+/*******************************************************************************
+ * O P E N T S
+ *******************************************************************************
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * Copyright 2026 OpenTS contributors
+ *
+ * See LICENSE.md for applicable additional terms and warranty disclaimers.
+ ******************************************************************************/
+
+
+#include "always.h"
+
+#include "spawner.h"
+
+#include "spawnerconfig.h"
+
+#include "addon.h"
+#include "campaign.h"
+#include "ccfile.h"
+#include "ccini.h"
+#include "dbgprint.h"
+#include "enviro.h"
+#include "globals.h"
+#include "goptions.h"
+#include "houstype.h"
+#include "init.h"
+#include "ipxmgr.h"
+#include "language\language.h"
+#include "loaddlg.h"
+#include "mplayer.h"
+#include "netshare.h"
+#include "msgbox.h"
+#include "saveload.h"
+#include "savever.h"
+#include "scenario.h"
+#include "session.h"
+
+#include
+#include
+#include
+#include
+#include
+
+
+static_assert(HOUSE_NAME_MAX == MPLAYER_NAME_MAX,
+ "a seat is judged and ordered by the name the session carries");
+
+/*
+ * Only the first launch runs. A later call answers false, so the process exits rather than
+ * falling into the menu.
+ */
+static bool SpawnRequested = false;
+static bool SpawnConsumed = false;
+static SpawnerConfigClass SpawnConfig;
+
+
+///
+/// Refuses the launch, telling the player why and leaving the reason in the log.
+///
+/// A printf style description of the fault.
+/// false, so a caller can refuse and return in one statement.
+static bool Spawner_Refuse(char const * fault, ...)
+{
+ char buffer[256];
+
+ va_list args;
+ va_start(args, fault);
+ std::vsnprintf(buffer, sizeof(buffer), fault, args);
+ va_end(args);
+
+ DebugString("[Spawner] Refusing to launch: %s\n", buffer);
+ WWMessageBox().Process(buffer, TXT_OK);
+
+ return(false);
+}
+
+
+///
+/// Converts a seat's alliance list into the bit mask a node carries.
+///
+/// One bit set per seat this one is allied with.
+static unsigned Spawner_Allies_Mask(SpawnerConfigClass::SlotType const & seat)
+{
+ unsigned mask = 0;
+
+ for (int ally : seat.Alliances) {
+ if (ally >= 0 && ally < SpawnerConfigClass::SLOT_COUNT) {
+ mask |= 1u << ally;
+ }
+ }
+
+ return(mask);
+}
+
+
+///
+/// The difficulty a seat is played at, logged when it differs from the one asked for.
+///
+/// The difficulty to play the seat at, or -1 for the session default.
+static int Spawner_Seat_Handicap(int index, int asked)
+{
+ int played = SpawnerConfigClass::Playable_Handicap(asked);
+
+ if (played != asked) {
+ DebugString("[Spawner] Seat %d asked for difficulty %d and is played at %d.\n",
+ index + 1, asked, played);
+ }
+
+ return(played);
+}
+
+
+///
+/// Tells the session who is playing at this machine.
+///
+static void Spawner_Seat_Local(void)
+{
+ SpawnerConfigClass::SlotType const & local = SpawnConfig.Slots[SpawnConfig.LocalSlot];
+
+ std::snprintf(Session.Handle, sizeof(Session.Handle), "%s",
+ local.Name.empty() ? "Player" : local.Name.c_str());
+ Session.House = local.Country;
+ Session.ColorIdx = local.Color;
+ Session.PrefColor = Session.ColorIdx;
+}
+
+
+///
+/// Adds one human seat to the list the houses are created from.
+///
+static void Spawner_Seat_Human(int index)
+{
+ SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index];
+
+ NodeNameType * node = new NodeNameType;
+ std::snprintf(node->Name, sizeof(node->Name), "%s",
+ seat.Name.empty() ? "Player" : seat.Name.c_str());
+ node->Player.House = seat.Country;
+ node->Player.Color = seat.Color;
+ node->Player.ProcessTime = -1;
+ node->Player.SpawnChoice = seat.StartingPosition;
+ node->Player.AlliesMask = Spawner_Allies_Mask(seat);
+
+ // Through a tunnel a machine is addressed by its tunnel number, written where the port goes.
+ if (SpawnConfig.TunnelPort != 0) {
+ node->Address.Set_Address(0, htons((unsigned short)seat.Port));
+ } else if (seat.Port > 0) {
+ node->Address.Set_Address(inet_addr(seat.Address.c_str()), htons((unsigned short)seat.Port));
+ }
+
+ Session.Players.Add(node);
+}
+
+
+///
+/// Adds the human seats, this machine's first, because the game takes the first entry to be
+/// the local player.
+///
+static void Spawner_Seat_Humans(void)
+{
+ Spawner_Seat_Human(SpawnConfig.LocalSlot);
+
+ for (int index = 0; index < SpawnConfig.HumanCount; index++) {
+ if (index != SpawnConfig.LocalSlot) {
+ Spawner_Seat_Human(index);
+ }
+ }
+
+ Session.NumPlayers = SpawnConfig.HumanCount;
+}
+
+
+///
+/// Adds the computer seats the client named to the list the houses are created from.
+///
+static void Spawner_Seat_Computers(void)
+{
+ // Handicap 0 is the rules' easy table, which makes the hardest opponent.
+ static char const * const _ai_names[DIFF_COUNT] = { "Hard AI", "Medium AI", "Easy AI" };
+
+ for (int index = SpawnConfig.HumanCount; index < SpawnerConfigClass::SLOT_COUNT; index++) {
+ SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index];
+ if (seat.Occupancy != SpawnerConfigClass::OccupancyType::Computer) {
+ continue;
+ }
+
+ NodeNameType * node = new NodeNameType;
+ node->Player.House = seat.Country;
+ node->Player.Color = seat.Color;
+ node->Player.Handicap = Spawner_Seat_Handicap(index, seat.Handicap);
+ node->Player.SpawnChoice = seat.StartingPosition;
+ node->Player.AlliesMask = Spawner_Allies_Mask(seat);
+
+ // Session difficulty is numbered the opposite way round from a seat's handicap.
+ if (SpawnConfig.AINamesByDifficulty) {
+ int played = node->Player.Handicap >= 0
+ ? node->Player.Handicap
+ : (DIFF_COUNT - 1 - SpawnConfig.AIDifficulty);
+ std::snprintf(node->Name, sizeof(node->Name), "%s",
+ _ai_names[std::clamp(played, 0, DIFF_COUNT - 1)]);
+ }
+
+ Session.Computers.Add(node);
+ }
+}
+
+
+///
+/// Copies the launch file's match options into the session and the game options.
+///
+static void Spawner_Bind_Options(void)
+{
+ Session.Options.Bases = SpawnConfig.Bases;
+ Session.Options.Credits = SpawnConfig.Credits;
+ Session.Options.BridgeDestruction = SpawnConfig.BridgeDestroy;
+ Session.Options.Goodies = SpawnConfig.Crates;
+ Session.Options.ShortGame = SpawnConfig.ShortGame;
+ Session.Options.GameSpeed = SpawnConfig.GameSpeed;
+ Session.Options.CrapEngineers = SpawnConfig.MultiEngineer;
+ Session.Options.UnitCount = SpawnConfig.UnitCount;
+ Session.Options.AIPlayers = SpawnConfig.AIPlayers;
+ Session.Options.AIDifficulty = (DiffType)SpawnConfig.AIDifficulty;
+ Session.Options.AlliesAllowed = SpawnConfig.AlliesAllowed;
+ Session.Options.FogOfWar = SpawnConfig.FogOfWar;
+ Session.Options.MCVRedeploy = SpawnConfig.MCVRedeploy;
+
+ /*
+ * Only a match against other machines commits this to the simulation; a skirmish never does.
+ */
+ Session.Options.HarvTruce = SpawnConfig.HarvesterTruce;
+
+ // These two live outside the session's option block.
+ Options.GameSpeed = SpawnConfig.GameSpeed;
+ BuildLevel = SpawnConfig.TechLevel;
+
+ // Init_Random uses this for a game played alone, and draws its own seed when it is zero.
+ CustomSeed = SpawnConfig.Seed;
+
+ /*
+ * Read, not honored. Every field the reader carries is bound above, consulted when a launch
+ * is refused, or listed here, so a new field forces a decision. A contract test enforces it.
+ *
+ * MapName - shown while loading; bound with the scenario below.
+ * IsCampaign, LoadSaveGame,
+ * SaveGameName - read to decide the kind of launch and name the save.
+ * Slots[].IsSpectator - read to refuse a launch.
+ * IsHost - which machine hosts matters once one can leave.
+ * Tournament, GameID,
+ * WriteStatistics - naming a match and reporting how it went.
+ * AutoSaveInterval,
+ * NextCampaignAutoSave,
+ * NextSkirmishAutoSave - saving on a schedule.
+ * BuildOffAlly, AttackNeutralUnits,
+ * ScrapMetal, AutoSurrender,
+ * ContinueWithoutHumans - options the game has no setting of its own for yet.
+ * CoachMode - watching and advising rather than playing.
+ * QuickMatch, SkipScoreScreen,
+ * PlayMoviesInMultiplayer,
+ * CustomLoadScreen,
+ * CustomLoadScreenX,
+ * CustomLoadScreenY,
+ * DifficultyName - what a player is shown around the match.
+ */
+}
+
+
+///
+/// Names the scenario the session plays, in place of the menu's map list.
+///
+static void Spawner_Bind_Scenario(void)
+{
+ std::snprintf(Scen->ScenarioName, sizeof(Scen->ScenarioName), "%s", SpawnConfig.ScenarioName.c_str());
+ std::snprintf(Session.ScenarioFileName, sizeof(Session.ScenarioFileName), "%s", SpawnConfig.ScenarioName.c_str());
+ std::snprintf(Session.Options.ScenarioDescription, sizeof(Session.Options.ScenarioDescription),
+ "%s", SpawnConfig.MapName.c_str());
+
+ Session.Options.ScenarioIndex = -1;
+ Session.ScenarioFileLength = CCFileClass(Scen->ScenarioName).Size();
+ Session.ScenarioIsOfficial = false;
+ Session.ScenarioDigest[0] = '\0';
+}
+
+
+///
+/// Opens the network a match against other machines is played over, through the tunnel the
+/// file names or straight to the address each seat carries.
+///
+/// bool; Is the network ready to carry the match?
+static bool Spawner_Wire_Network(void)
+{
+ if (SpawnConfig.TunnelPort != 0) {
+ Ipx.Configure_Tunnel(htons((unsigned short)SpawnConfig.TunnelId),
+ inet_addr(SpawnConfig.TunnelAddress.c_str()), htons((unsigned short)SpawnConfig.TunnelPort));
+ } else {
+ Ipx.Configure_Direct_Peers((unsigned short)SpawnConfig.ListenPort);
+ }
+
+ // The local seat is first, so every seat after it is another machine.
+ for (int index = 1; index < Session.Players.Count(); index++) {
+ Ipx.Add_Peer(Session.Players[index]->Address);
+ }
+
+ if (!Ipx.Init()) {
+ return(Spawner_Refuse("The network could not be opened."));
+ }
+
+ return(true);
+}
+
+
+///
+/// Resumes the saved game a launch file names. The save carries the game and its houses,
+/// while a match against other machines takes its seats and their addresses from the file.
+///
+/// Set when the save loads, so the caller starts no scenario.
+/// bool; Is the saved game running?
+static bool Spawner_Resume(bool & gameloaded)
+{
+ if (SpawnConfig.SaveGameName.empty()) {
+ return(Spawner_Refuse("The file asks to resume a saved game without naming one."));
+ }
+
+ SaveVersionInfo info;
+ if (!Get_Savefile_Info(SpawnConfig.SaveGameName.c_str(), &info)) {
+ return(Spawner_Refuse("The saved game %s is missing or unreadable.", SpawnConfig.SaveGameName.c_str()));
+ }
+
+ if (info.Get_Internal_Version() != ExpectedGameVersion) {
+ return(Spawner_Refuse("The saved game was made by another version of the game."));
+ }
+
+ // A client never arranges a local network game, so no launch file describes one.
+ GameType type = (GameType)info.Get_Game_Type();
+ if (type == GAME_IPX) {
+ return(Spawner_Refuse("Resuming a game arranged over the local network is not supported."));
+ }
+
+ /*
+ * The file seats the same people again, so the network opens before the load and the queue
+ * synchronizes at the resumed frame.
+ */
+ if (type == GAME_INTERNET) {
+ std::string fault;
+ if (!SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)) {
+ return(Spawner_Refuse("%s", fault.c_str()));
+ }
+
+ Clear_Vector(&Session.Players);
+ Clear_Vector(&Session.Computers);
+
+ Spawner_Seat_Local();
+ Spawner_Seat_Humans();
+
+ if (!Spawner_Wire_Network()) {
+ return(false);
+ }
+
+ Session.LoadGame = true;
+ }
+
+ if (!LoadOptionsClass().Load_File(SpawnConfig.SaveGameName.c_str())) {
+ return(Spawner_Refuse("The saved game %s could not be loaded.", SpawnConfig.SaveGameName.c_str()));
+ }
+
+ if (type == GAME_INTERNET && !Reconcile_Players()) {
+ return(Spawner_Refuse("The saved game and the file do not agree on who is playing."));
+ }
+
+ /*
+ * A save carries the options it was played under, but game speed is the player's own.
+ */
+ Options.GameSpeed = SpawnConfig.GameSpeed;
+
+ gameloaded = true;
+
+ return(true);
+}
+
+
+///
+/// Assembles the campaign mission a launch asks for: the mission, its difficulty pair, and
+/// the scenario flags carried over from an earlier mission.
+///
+/// bool; Can the campaign the file describes be played?
+static bool Spawner_Setup_Campaign(void)
+{
+ if (SpawnConfig.CampaignDifficulty < 0 || SpawnConfig.CampaignDifficulty >= DIFF_COUNT ||
+ SpawnConfig.CampaignCDifficulty < 0 || SpawnConfig.CampaignCDifficulty >= DIFF_COUNT) {
+ return(Spawner_Refuse("A campaign is played at difficulty 0, 1 or 2, and the file says %d and %d.",
+ SpawnConfig.CampaignDifficulty, SpawnConfig.CampaignCDifficulty));
+ }
+
+ if (SpawnConfig.CampaignID < -1 || SpawnConfig.CampaignID >= Campaigns.Count()) {
+ return(Spawner_Refuse("The file names campaign %d, and there are %d.",
+ SpawnConfig.CampaignID, Campaigns.Count()));
+ }
+
+ Session.Type = GAME_NORMAL;
+ Options.GameSpeed = SpawnConfig.GameSpeed;
+ Session.CampaignDifficulty = (DiffType)SpawnConfig.CampaignDifficulty;
+ Session.CampaignCDifficulty = (DiffType)SpawnConfig.CampaignCDifficulty;
+ Scen->Campaign = (CampaignType)SpawnConfig.CampaignID;
+
+ // A fresh launch carries nothing over, so the file's flags replace an earlier mission's.
+ new (&Environment) EnvironmentClass;
+ for (int index = 0; index < SpawnerConfigClass::GLOBAL_FLAG_COUNT; index++) {
+ Environment.Globals[index] = SpawnConfig.GlobalFlags[index];
+ }
+
+ std::snprintf(Scen->ScenarioName, sizeof(Scen->ScenarioName), "%s", SpawnConfig.ScenarioName.c_str());
+
+ return(true);
+}
+
+
+///
+/// Assembles the session a launch asks for, in place of what a setup dialog commits.
+///
+static void Spawner_Setup_Session(void)
+{
+ Session.Type = SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Multiplayer
+ ? GAME_INTERNET : GAME_SKIRMISH;
+
+ // Every machine must draw alike, and no lobby is there to share a seed.
+ if (Session.Type == GAME_INTERNET) {
+ Seed = SpawnConfig.Seed;
+ }
+
+ Clear_Vector(&Session.Players);
+ Clear_Vector(&Session.Computers);
+
+ Spawner_Bind_Options();
+
+ if (Session.Type == GAME_INTERNET) {
+ Commit_Session_Specials();
+ }
+
+ Spawner_Seat_Local();
+ Spawner_Seat_Humans();
+ Spawner_Seat_Computers();
+ Spawner_Bind_Scenario();
+}
+
+
+///
+/// Records that a client asked the game to launch what SPAWN.INI describes.
+///
+void Spawner_Request(void)
+{
+ SpawnRequested = true;
+}
+
+
+///
+/// Did a client ask the game to launch what its file describes?
+///
+bool Spawner_Is_Requested(void)
+{
+ return(SpawnRequested);
+}
+
+
+///
+/// Is the running game the one a launch file described? Asked by code that must leave a
+/// client's choices alone.
+///
+bool Spawner_Is_Active(void)
+{
+ return(SpawnConsumed);
+}
+
+
+///
+/// Reads the launch file and assembles the game it describes, in place of the menu. Answers
+/// false once that game has ended, so the process exits rather than showing one.
+///
+/// Set when the launch resumed a saved game.
+/// bool; Is a game ready to start?
+bool Spawner_Prepare(bool & gameloaded)
+{
+ if (SpawnConsumed) {
+ return(false);
+ }
+
+ CCFileClass file("SPAWN.INI");
+ if (!file.Is_Available()) {
+ return(Spawner_Refuse("SPAWN.INI is missing, and it says what to launch."));
+ }
+
+ CCINIClass ini;
+ ini.Load(file, false);
+ SpawnConfig.Read_INI(ini);
+
+ SpawnConsumed = true;
+
+ /*
+ * Every kind of launch is played at this speed, so it is checked before the kinds part.
+ */
+ if (SpawnConfig.GameSpeed < 0 || SpawnConfig.GameSpeed >= OptionsClass::MAX_SPEED_SETTING) {
+ return(Spawner_Refuse("The file asks for game speed %d, and the game has 0 through %d.",
+ SpawnConfig.GameSpeed, OptionsClass::MAX_SPEED_SETTING - 1));
+ }
+
+ // A seat names its country by the rules' own numbering, so the roster is read first.
+ Prepare_Side_Roster();
+
+ if (SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Resume) {
+ return(Spawner_Resume(gameloaded));
+ }
+
+ Disable_Addon(ADDON_ANY);
+ if (SpawnConfig.Firestorm) {
+ Enable_Addon(ADDON_FIRESTORM);
+ Set_Required_Addon(ADDON_FIRESTORM);
+ }
+
+ if (SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Campaign) {
+ if (!Spawner_Setup_Campaign()) {
+ return(false);
+ }
+ } else {
+ std::string fault;
+ if (!SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)) {
+ return(Spawner_Refuse("%s", fault.c_str()));
+ }
+
+ Spawner_Setup_Session();
+ }
+
+ DebugString("[Spawner] Launching %s with session identity %08x.\n",
+ Scen->ScenarioName, SpawnConfig.Session_Identity_CRC());
+
+ // The network comes last, once the session it carries is fully assembled.
+ if (Session.Type == GAME_INTERNET && !Spawner_Wire_Network()) {
+ return(false);
+ }
+
+ return(true);
+}
diff --git a/code/spawner.h b/code/spawner.h
new file mode 100644
index 00000000..0dc9bcca
--- /dev/null
+++ b/code/spawner.h
@@ -0,0 +1,17 @@
+/*******************************************************************************
+ * O P E N T S
+ *******************************************************************************
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * Copyright 2026 OpenTS contributors
+ *
+ * See LICENSE.md for applicable additional terms and warranty disclaimers.
+ ******************************************************************************/
+
+
+#pragma once
+
+
+void Spawner_Request(void);
+bool Spawner_Is_Requested(void);
+bool Spawner_Is_Active(void);
+bool Spawner_Prepare(bool & gameloaded);
diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp
new file mode 100644
index 00000000..643d8c1d
--- /dev/null
+++ b/code/spawnerconfig.cpp
@@ -0,0 +1,509 @@
+/*******************************************************************************
+ * O P E N T S
+ *******************************************************************************
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * Copyright 2026 OpenTS contributors
+ *
+ * See LICENSE.md for applicable additional terms and warranty disclaimers.
+ ******************************************************************************/
+
+
+#include "spawnerconfig.h"
+
+#include "crc.h"
+#include "diff.hh"
+#include "ini.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+
+namespace {
+
+/*
+ * The section holding the match's settings. It describes the machine reading the file as
+ * well, so the first seat is read from here.
+ */
+char const * const SETTINGS = "Settings";
+
+
+///
+/// Reads a string entry.
+///
+/// The value written, or the fallback.
+std::string Read_Text(INIClass const & ini, char const * section, char const * entry, std::string const & fallback)
+{
+ char buffer[512];
+ if (ini.Get_String(section, entry, "", buffer, sizeof(buffer)) == 0) {
+ return(fallback);
+ }
+ return(buffer);
+}
+
+
+///
+/// Reads one of the eight numbered entries a section names its seats by.
+///
+/// The value written for that seat.
+int Read_Slot_Int(INIClass const & ini, char const * section, int slot, int fallback)
+{
+ std::string entry = "Multi" + std::to_string(slot + 1);
+ return(ini.Get_Int(section, entry.c_str(), fallback));
+}
+
+
+///
+/// Checks a dotted address, so a seat naming no real machine is refused with every other
+/// fault. The game's own resolver cannot be reached from here.
+///
+/// bool; Is this four numbers between 0 and 255?
+bool Is_Address(std::string const & text)
+{
+ unsigned quad[4] = {};
+ char tail = '\0';
+
+ if (std::sscanf(text.c_str(), "%u.%u.%u.%u%c", &quad[0], &quad[1], &quad[2], &quad[3], &tail) != 4) {
+ return(false);
+ }
+
+ for (unsigned part : quad) {
+ if (part > 255) {
+ return(false);
+ }
+ }
+
+ return(quad[0] != 0 || quad[1] != 0 || quad[2] != 0 || quad[3] != 0);
+}
+
+
+///
+/// Names the fault that refuses a launch.
+///
+/// A printf style description of the fault.
+/// false, so a caller can name a fault and refuse in one statement.
+bool Fault(std::string & fault, char const * format, ...)
+{
+ char buffer[256];
+
+ va_list args;
+ va_start(args, format);
+ std::vsnprintf(buffer, sizeof(buffer), format, args);
+ va_end(args);
+
+ fault = buffer;
+ return(false);
+}
+
+}
+
+
+///
+/// Reads the match's seats and sorts them into the order their houses are created in, which
+/// is the order everything naming a seat by position means. A seat is human because the file
+/// wrote a section for it.
+///
+void SpawnerConfigClass::Read_Slots(INIClass const & ini)
+{
+ std::array staging;
+
+ for (int index = 0; index < SLOT_COUNT; index++) {
+ std::string section = index == 0 ? SETTINGS : "Other" + std::to_string(index);
+
+ SlotType & slot = staging[index];
+ if (ini.Section_Present(section.c_str())) {
+ slot.Occupancy = OccupancyType::Human;
+ // A seat is judged and ordered by the name the game keeps, the same on every machine.
+ slot.Name = Read_Text(ini, section.c_str(), "Name", "").substr(0, HOUSE_NAME_MAX - 1);
+ slot.Color = ini.Get_Int(section.c_str(), "Color", -1);
+ slot.Country = ini.Get_Int(section.c_str(), "Side", -1);
+ slot.Address = Read_Text(ini, section.c_str(), "Ip", slot.Address);
+ slot.Port = ini.Get_Int(section.c_str(), "Port", -1);
+ } else {
+ slot.Color = Read_Slot_Int(ini, "HouseColors", index, -1);
+ slot.Country = Read_Slot_Int(ini, "HouseCountries", index, -1);
+ slot.Handicap = Read_Slot_Int(ini, "HouseHandicaps", index, -1);
+ }
+ }
+
+ /*
+ * Sorting by color makes a seat's index the house it becomes. Every machine writes its
+ * own file with itself first, so a name breaks a color tie rather than file order.
+ */
+ std::vector humans;
+ std::vector rest;
+ for (int index = 0; index < SLOT_COUNT; index++) {
+ (staging[index].Occupancy == OccupancyType::Human ? humans : rest).push_back(index);
+ }
+ std::stable_sort(humans.begin(), humans.end(), [&staging](int left, int right) {
+ if (staging[left].Color != staging[right].Color) {
+ return(staging[left].Color < staging[right].Color);
+ }
+ return(_stricmp(staging[left].Name.c_str(), staging[right].Name.c_str()) < 0);
+ });
+
+ HumanCount = (int)humans.size();
+ LocalSlot = 0;
+
+ int filled = 0;
+ for (int index : humans) {
+ if (index == 0) {
+ LocalSlot = filled;
+ }
+ Slots[filled++] = staging[index];
+ }
+
+ /*
+ * A seat no section claimed is a computer player, and the options say how many of those play.
+ */
+ for (int index : rest) {
+ SlotType & slot = Slots[filled];
+ slot = staging[index];
+ slot.Occupancy = (filled - HumanCount) < AIPlayers ? OccupancyType::Computer : OccupancyType::Empty;
+ filled++;
+ }
+
+ /*
+ * The alliance sections name seats by the sorted order, so they are read after the sort.
+ */
+ static char const * const _ordinals[SLOT_COUNT] = {
+ "HouseAllyOne", "HouseAllyTwo", "HouseAllyThree", "HouseAllyFour",
+ "HouseAllyFive", "HouseAllySix", "HouseAllySeven", "HouseAllyEight"
+ };
+
+ for (int index = 0; index < SLOT_COUNT; index++) {
+ SlotType & slot = Slots[index];
+
+ std::string entry = "Multi" + std::to_string(index + 1);
+ slot.IsSpectator = ini.Get_Bool("IsSpectator", entry.c_str(), false);
+ slot.StartingPosition = Read_Slot_Int(ini, "SpawnLocations", index, -1);
+
+ /*
+ * A start position outside the map's range is left to the game, as no position at all is.
+ */
+ if (slot.StartingPosition < -1 || slot.StartingPosition >= SLOT_COUNT) {
+ slot.StartingPosition = -1;
+ }
+
+ std::string section = "Multi" + std::to_string(index + 1) + "_Alliances";
+ if (!ini.Section_Present(section.c_str())) {
+ continue;
+ }
+
+ for (int ally = 0; ally < SLOT_COUNT; ally++) {
+ slot.Alliances[ally] = ini.Get_Int(section.c_str(), _ordinals[ally], -1);
+ }
+ }
+}
+
+
+///
+/// What kind of game this file asks for. A resume answers by itself, because the save carries
+/// the type, the options and the houses.
+///
+SpawnerConfigClass::LaunchType SpawnerConfigClass::Launch_Type(void) const
+{
+ if (LoadSaveGame) {
+ return(LaunchType::Resume);
+ }
+ if (IsCampaign) {
+ return(LaunchType::Campaign);
+ }
+ if (HumanCount > 1) {
+ return(LaunchType::Multiplayer);
+ }
+ return(LaunchType::Skirmish);
+}
+
+
+///
+/// The identity of the match this file asks for. It covers every value the course of the
+/// match depends on and nothing merely displayed, so two machines handed the same match
+/// agree. The version comes first: one file read two ways is not one match.
+///
+int SpawnerConfigClass::Session_Identity_CRC(void) const
+{
+ CRCEngine crc;
+
+ crc(SCHEMA_VERSION);
+
+ crc(ScenarioName.c_str());
+ crc(IsCampaign);
+ crc(CampaignID);
+ crc(CampaignDifficulty);
+ crc(CampaignCDifficulty);
+ crc(LoadSaveGame);
+ crc(SaveGameName.c_str());
+
+ crc(Bases);
+ crc(Credits);
+ crc(BridgeDestroy);
+ crc(Crates);
+ crc(ShortGame);
+ crc(BuildOffAlly);
+ crc(GameSpeed);
+ crc(MultiEngineer);
+ crc(UnitCount);
+ crc(AIPlayers);
+ crc(AIDifficulty);
+ crc(AlliesAllowed);
+ crc(HarvesterTruce);
+ crc(FogOfWar);
+ crc(MCVRedeploy);
+ crc(Seed);
+ crc(TechLevel);
+ crc(Firestorm);
+ crc(AttackNeutralUnits);
+ crc(ScrapMetal);
+
+ for (bool flag : GlobalFlags) {
+ crc(flag);
+ }
+
+ for (SlotType const & slot : Slots) {
+ crc(static_cast(slot.Occupancy));
+ crc(slot.Color);
+ crc(slot.Country);
+ crc(slot.Handicap);
+ crc(slot.IsSpectator);
+ crc(slot.StartingPosition);
+
+ for (int ally : slot.Alliances) {
+ crc(ally);
+ }
+ }
+
+ return(crc());
+}
+
+
+///
+/// The difficulty a seat is played at. A client may ask for an easier opponent than the game
+/// has, and any such request comes to the easiest one it does have.
+///
+/// The difficulty to play the seat at, or -1 for the session default.
+int SpawnerConfigClass::Playable_Handicap(int asked)
+{
+ if (asked < 0) {
+ return(-1);
+ }
+ /*
+ * The rules' hardest table makes the easiest opponent, so an easier request lands there.
+ */
+ if (asked > DIFF_HARD) {
+ return(DIFF_HARD);
+ }
+ return(asked);
+}
+
+
+///
+/// Judges whether this reading describes a game that can be played. The country and color
+/// counts are passed in because they come from the rules, which only a running game holds.
+///
+/// Where to leave the sentence describing the first fault found.
+/// bool; Can the game this file describes be played?
+bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const
+{
+ /*
+ * A resumed match against other machines is seated from the file like any other, so the same
+ * rules hold for it.
+ */
+ LaunchType kind = Launch_Type();
+ bool multiplayer = kind == LaunchType::Multiplayer ||
+ (kind == LaunchType::Resume && HumanCount > 1);
+
+ if (kind != LaunchType::Campaign && HumanCount == 0) {
+ return(Fault(fault, "The file seats nobody at this machine."));
+ }
+
+ if (AIDifficulty < 0 || AIDifficulty >= DIFF_COUNT) {
+ return(Fault(fault, "The file plays the computer at difficulty %d, and there are %d.",
+ AIDifficulty, DIFF_COUNT));
+ }
+
+ int free_seats = SLOT_COUNT - HumanCount;
+ if (AIPlayers < 0 || AIPlayers > free_seats) {
+ return(Fault(fault, "The file asks for %d computer players, and %d seats are left.",
+ AIPlayers, free_seats));
+ }
+
+ for (int index = 0; index < SLOT_COUNT; index++) {
+ SlotType const & slot = Slots[index];
+ if (slot.Occupancy == OccupancyType::Empty) {
+ continue;
+ }
+
+ bool human = slot.Occupancy == OccupancyType::Human;
+
+ // A computer seat may leave its country and color to the game; a person's seat names both.
+ if ((human || slot.Country != -1) && (slot.Country < 0 || slot.Country >= countries)) {
+ return(Fault(fault, "Seat %d is given country %d, and there are %d to choose from.",
+ index + 1, slot.Country, countries));
+ }
+
+ // A computer seat may leave its color to the game; only a color the rules lack refuses.
+ if ((human || slot.Color != -1) && (slot.Color < 0 || slot.Color >= colors)) {
+ return(Fault(fault, "Seat %d is given color %d, and there are %d to choose from.",
+ index + 1, slot.Color, colors));
+ }
+
+ if (slot.Handicap < -1 || slot.Handicap > 6) {
+ return(Fault(fault, "Seat %d is given difficulty %d, which names none.",
+ index + 1, slot.Handicap));
+ }
+
+ for (int ally : slot.Alliances) {
+ if (ally < -1 || ally >= SLOT_COUNT ||
+ (ally >= 0 && Slots[ally].Occupancy == OccupancyType::Empty)) {
+ return(Fault(fault, "Seat %d is allied to seat %d, which the match does not hold.",
+ index + 1, ally + 1));
+ }
+ }
+
+ if (slot.IsSpectator) {
+ return(Fault(fault, "Seat %d watches rather than plays, which this game cannot yet do.",
+ index + 1));
+ }
+
+ /*
+ * The client keys a seat by an order no other machine can rebuild, so two people sharing a
+ * name or a color would take each other's start position and alliances.
+ */
+ if (human && multiplayer) {
+ if (slot.Name.empty()) {
+ return(Fault(fault, "Seat %d is played by somebody the file does not name.", index + 1));
+ }
+
+ for (int other = 0; other < index; other++) {
+ if (Slots[other].Occupancy != OccupancyType::Human) {
+ continue;
+ }
+
+ if (_stricmp(Slots[other].Name.c_str(), slot.Name.c_str()) == 0) {
+ return(Fault(fault, "Seats %d and %d are both played by %s.",
+ other + 1, index + 1, slot.Name.c_str()));
+ }
+
+ if (Slots[other].Color == slot.Color) {
+ return(Fault(fault, "Seats %d and %d are both given color %d.",
+ other + 1, index + 1, slot.Color));
+ }
+ }
+
+ /*
+ * Through a tunnel the port carries the tunnel number, so every seat but this one needs
+ * it either way.
+ */
+ if (index != LocalSlot) {
+ if (slot.Port < 1 || slot.Port > 65535) {
+ return(Fault(fault, "Seat %d is reached on port %d, which names no machine.",
+ index + 1, slot.Port));
+ }
+
+ if (TunnelPort == 0 && !Is_Address(slot.Address)) {
+ return(Fault(fault, "Seat %d is reached at %s, which names no machine.",
+ index + 1, slot.Address.c_str()));
+ }
+ }
+ }
+ }
+
+ return(true);
+}
+
+
+///
+/// Reads what the client asked the game to launch. Reading cannot fail: an unwritten key has
+/// a settled meaning, an unusable value keeps it, and an unknown key is passed over.
+///
+void SpawnerConfigClass::Read_INI(INIClass const & ini)
+{
+ IsCampaign = ini.Get_Bool(SETTINGS, "IsSinglePlayer", IsCampaign);
+ IsHost = ini.Get_Bool(SETTINGS, "Host", IsHost);
+ CampaignID = ini.Get_Int(SETTINGS, "CampaignID", CampaignID);
+ Tournament = ini.Get_Int(SETTINGS, "Tournament", Tournament);
+ GameID = ini.Get_Int(SETTINGS, "GameID", GameID);
+
+ ScenarioName = Read_Text(ini, SETTINGS, "Scenario", ScenarioName);
+ MapName = Read_Text(ini, SETTINGS, "UIMapName", MapName);
+
+ LoadSaveGame = ini.Get_Bool(SETTINGS, "LoadSaveGame", LoadSaveGame);
+
+ /*
+ * A saved game is opened by name in the game's own folder, so a name written with a path is
+ * reduced to its last element.
+ */
+ SaveGameName = std::filesystem::path(Read_Text(ini, SETTINGS, "SaveGameName", SaveGameName)).filename().string();
+
+ AutoSaveInterval = ini.Get_Int(SETTINGS, "AutoSaveGame", AutoSaveInterval);
+
+ /*
+ * The client counts its automatic saves from one, while the game numbers them from zero.
+ */
+ NextCampaignAutoSave = ini.Get_Int(SETTINGS, "NextSPAutoSaveId", 1) - 1;
+ NextSkirmishAutoSave = ini.Get_Int(SETTINGS, "NextSkirmishAutoSaveId", 1) - 1;
+
+ Bases = ini.Get_Bool(SETTINGS, "Bases", Bases);
+ Credits = ini.Get_Int(SETTINGS, "Credits", Credits);
+ BridgeDestroy = ini.Get_Bool(SETTINGS, "BridgeDestroy", BridgeDestroy);
+ Crates = ini.Get_Bool(SETTINGS, "Crates", Crates);
+ ShortGame = ini.Get_Bool(SETTINGS, "ShortGame", ShortGame);
+ BuildOffAlly = ini.Get_Bool(SETTINGS, "BuildOffAlly", BuildOffAlly);
+ GameSpeed = ini.Get_Int(SETTINGS, "GameSpeed", GameSpeed);
+ MultiEngineer = ini.Get_Bool(SETTINGS, "MultiEngineer", MultiEngineer);
+ UnitCount = ini.Get_Int(SETTINGS, "UnitCount", UnitCount);
+ AIPlayers = ini.Get_Int(SETTINGS, "AIPlayers", AIPlayers);
+ AIDifficulty = ini.Get_Int(SETTINGS, "AIDifficulty", AIDifficulty);
+ AlliesAllowed = ini.Get_Bool(SETTINGS, "AlliesAllowed", AlliesAllowed);
+ HarvesterTruce = ini.Get_Bool(SETTINGS, "HarvesterTruce", HarvesterTruce);
+ FogOfWar = ini.Get_Bool(SETTINGS, "FogOfWar", FogOfWar);
+ MCVRedeploy = ini.Get_Bool(SETTINGS, "MCVRedeploy", MCVRedeploy);
+ Seed = ini.Get_Int(SETTINGS, "Seed", Seed);
+ TechLevel = ini.Get_Int(SETTINGS, "TechLevel", TechLevel);
+ Firestorm = ini.Get_Bool(SETTINGS, "Firestorm", Firestorm);
+ CampaignDifficulty = ini.Get_Int(SETTINGS, "DifficultyModeHuman", CampaignDifficulty);
+ CampaignCDifficulty = ini.Get_Int(SETTINGS, "DifficultyModeComputer", CampaignCDifficulty);
+
+ /*
+ * One key serves twice: the game listens on this port, and a tunnel names the machine by it.
+ * Absent, the tunnel number is zero and the listen port keeps its default.
+ */
+ TunnelId = ini.Get_Int(SETTINGS, "Port", TunnelId);
+ ListenPort = ini.Get_Int(SETTINGS, "Port", ListenPort);
+ TunnelAddress = Read_Text(ini, "Tunnel", "Ip", TunnelAddress);
+ TunnelPort = ini.Get_Int("Tunnel", "Port", TunnelPort);
+
+ QuickMatch = ini.Get_Bool(SETTINGS, "QuickMatch", QuickMatch);
+ SkipScoreScreen = ini.Get_Bool(SETTINGS, "SkipScoreScreen", SkipScoreScreen);
+ WriteStatistics = ini.Get_Bool(SETTINGS, "WriteStatistics", WriteStatistics);
+ AINamesByDifficulty = ini.Get_Bool(SETTINGS, "DifficultyBasedAINames", AINamesByDifficulty);
+ CoachMode = ini.Get_Bool(SETTINGS, "CoachMode", CoachMode);
+ AutoSurrender = ini.Get_Bool(SETTINGS, "AutoSurrender", AutoSurrender);
+ AttackNeutralUnits = ini.Get_Bool(SETTINGS, "AttackNeutralUnits", AttackNeutralUnits);
+ ScrapMetal = ini.Get_Bool(SETTINGS, "ScrapMetal", ScrapMetal);
+ ContinueWithoutHumans = ini.Get_Bool(SETTINGS, "ContinueWithoutHumans", ContinueWithoutHumans);
+ PlayMoviesInMultiplayer = ini.Get_Bool(SETTINGS, "PlayMoviesInMultiplayer", PlayMoviesInMultiplayer);
+ CustomLoadScreen = Read_Text(ini, SETTINGS, "CustomLoadScreen", CustomLoadScreen);
+ DifficultyName = Read_Text(ini, SETTINGS, "DifficultyName", DifficultyName);
+
+ std::string position = Read_Text(ini, SETTINGS, "CustomLoadScreenPos", "");
+ if (!position.empty()) {
+ int x = 0;
+ int y = 0;
+ if (std::sscanf(position.c_str(), "%d,%d", &x, &y) == 2) {
+ CustomLoadScreenX = x;
+ CustomLoadScreenY = y;
+ }
+ }
+
+ for (int index = 0; index < GLOBAL_FLAG_COUNT; index++) {
+ std::string entry = "GlobalFlag" + std::to_string(index);
+ GlobalFlags[index] = ini.Get_Bool("GlobalFlags", entry.c_str(), false);
+ }
+
+ Read_Slots(ini);
+}
diff --git a/code/spawnerconfig.h b/code/spawnerconfig.h
new file mode 100644
index 00000000..1bff4207
--- /dev/null
+++ b/code/spawnerconfig.h
@@ -0,0 +1,146 @@
+/*******************************************************************************
+ * O P E N T S
+ *******************************************************************************
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * Copyright 2026 OpenTS contributors
+ *
+ * See LICENSE.md for applicable additional terms and warranty disclaimers.
+ ******************************************************************************/
+
+
+#pragma once
+
+#include "house.hh"
+
+#include
+#include
+
+class INIClass;
+
+
+/*
+ * What a client asked the game to launch. The file's spelling, defaults and shape belong to
+ * the CnCNet client; reading cannot fail, and the result is judged at the launch.
+ */
+class SpawnerConfigClass
+{
+ public:
+
+ // Counts changes to what the game makes of a launch file, never the file's own vocabulary.
+ static constexpr int SCHEMA_VERSION = 1;
+
+ // One seat per house a match may hold, and the fifty scenario flags the engine keeps.
+ static constexpr int SLOT_COUNT = 8;
+ static constexpr int GLOBAL_FLAG_COUNT = 50;
+
+ // Resume overrides the rest: a saved game carries its own type, options and houses.
+ enum class LaunchType {
+ Skirmish,
+ Campaign,
+ Multiplayer,
+ Resume,
+ };
+
+ // A file marks a seat human by writing a section for it; an unwritten one is a computer.
+ enum class OccupancyType {
+ Empty,
+ Human,
+ Computer,
+ };
+
+ /*
+ * A seat is held in the order the houses are created in, so its index is the house it
+ * becomes, which is what alliances and start positions name.
+ */
+ struct SlotType {
+ OccupancyType Occupancy = OccupancyType::Empty;
+ std::string Name;
+ int Color = -1;
+ int Country = -1;
+ int Handicap = -1;
+ bool IsSpectator = false;
+ int StartingPosition = -1;
+ std::array Alliances = {-1, -1, -1, -1, -1, -1, -1, -1};
+ std::string Address = "0.0.0.0";
+ int Port = -1;
+ };
+
+ void Read_INI(INIClass const & ini);
+ LaunchType Launch_Type(void) const;
+ int Session_Identity_CRC(void) const;
+
+ // The rules' tables are handed in, so a reading can be judged without the game running.
+ bool Is_Playable(int countries, int colors, std::string & fault) const;
+
+ static int Playable_Handicap(int asked);
+
+ // What kind of game to start.
+ bool IsCampaign = false;
+ bool IsHost = false;
+ int CampaignID = -1;
+ int Tournament = 0;
+ int GameID = 0;
+
+ // The scenario and the saved game.
+ std::string ScenarioName = "spawnmap.ini";
+ std::string MapName;
+ bool LoadSaveGame = false;
+ std::string SaveGameName;
+ int AutoSaveInterval = 10800;
+ int NextCampaignAutoSave = 0;
+ int NextSkirmishAutoSave = 0;
+
+ // The options every house plays under.
+ bool Bases = true;
+ int Credits = 10000;
+ bool BridgeDestroy = true;
+ bool Crates = false;
+ bool ShortGame = false;
+ bool BuildOffAlly = false;
+ int GameSpeed = 0;
+ bool MultiEngineer = false;
+ int UnitCount = 0;
+ int AIPlayers = 0;
+ int AIDifficulty = 1;
+ bool AlliesAllowed = false;
+ bool HarvesterTruce = false;
+ bool FogOfWar = false;
+ bool MCVRedeploy = true;
+ int Seed = 0;
+ int TechLevel = 10;
+ bool Firestorm = true;
+ int CampaignDifficulty = 1;
+ int CampaignCDifficulty = 1;
+ std::array GlobalFlags = {};
+
+ // Where the machines reach one another, settled by whatever service arranged the match.
+ int TunnelId = 0;
+ int ListenPort = 1234;
+ std::string TunnelAddress = "0.0.0.0";
+ int TunnelPort = 0;
+
+ // What a player is shown.
+ bool QuickMatch = false;
+ bool SkipScoreScreen = false;
+ bool WriteStatistics = false;
+ bool AINamesByDifficulty = false;
+ bool CoachMode = false;
+ bool AutoSurrender = true;
+ bool AttackNeutralUnits = false;
+ bool ScrapMetal = false;
+ bool ContinueWithoutHumans = false;
+ bool PlayMoviesInMultiplayer = false;
+ std::string CustomLoadScreen;
+ int CustomLoadScreenX = 0;
+ int CustomLoadScreenY = 0;
+ std::string DifficultyName;
+
+ // The match's seats, and where in them the machine reading the file sits.
+ std::array Slots;
+ int HumanCount = 0;
+ int LocalSlot = 0;
+
+ private:
+
+ void Read_Slots(INIClass const & ini);
+};
diff --git a/code/startup.cpp b/code/startup.cpp
index 9e76aaa9..22199f90 100644
--- a/code/startup.cpp
+++ b/code/startup.cpp
@@ -115,6 +115,7 @@
#include "shapeset.h"
#include "side.h"
#include "sidebar.h"
+#include "spawner.h"
#include "smudge.h"
#include "smudtype.h"
#include "sun.h"
@@ -660,7 +661,7 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho
** Check for forced intro movie run disabling. If the conquer
** configuration file says "no", then don't run the intro.
*/
- if (!Special.IsFromInstall) {
+ if (!Special.IsFromInstall && !Spawner_Is_Requested()) {
Special.IsFromInstall = ConfigINI.Get_Bool("Intro", "PlayIntro", true);
}
@@ -668,7 +669,7 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho
** Regardless of whether we should run it or not, here we're
** gonna change it to say "no" in the future.
*/
- if (Special.IsFromInstall == true) {
+ if (Special.IsFromInstall == true && !Spawner_Is_Requested()) {
ConfigINI.Put_Bool("Intro", "PlayIntro", false);
// Left closed, so that saving opens it for writing itself.
diff --git a/code/theme.cpp b/code/theme.cpp
index 07c32ad5..a64e4966 100644
--- a/code/theme.cpp
+++ b/code/theme.cpp
@@ -316,10 +316,7 @@ ThemeType ThemeClass::Next_Song(ThemeType theme) const
{
int i;
- /*
- * A score that repeats is played again, but only while the game actually holds it. One
- * it does not would otherwise answer this forever, and nothing else would ever be picked.
- */
+ // A score the game does not hold would repeat forever, and nothing else would be picked.
if ((unsigned)theme >= (unsigned)Themes.Count() || !Themes[theme]->Available ||
(!Themes[theme]->Repeat && !IsRepeat)) {
if (IsShuffle == true) {
@@ -437,9 +434,8 @@ int ThemeClass::Play_Song(ThemeType theme)
Audio.StreamLowImpact = false;
/*
- * A score that would not start is not the one playing. Recording it as the
- * current one silences the game for good: stopping a score that never
- * started does nothing, so the one that failed would stay current.
+ * Stopping a score that never started does nothing, so recording one that
+ * failed to start as the current score would silence the game for good.
*/
if (Current == -1) {
DebugString("Theme::PlaySong(%d) - Unavailable\n", theme);
diff --git a/manual/changes/campaign-difficulty-range.md b/manual/changes/campaign-difficulty-range.md
new file mode 100644
index 00000000..7e22eadb
--- /dev/null
+++ b/manual/changes/campaign-difficulty-range.md
@@ -0,0 +1,13 @@
+---
+title: Hold the campaign difficulty setting to the settings it names
+category: fix
+release: 0.2.0
+targets: []
+credit: [ZivDero]
+---
+
+The campaign difficulty read from the settings file is now held to the three
+difficulties the game has, rather than to five. A file edited by hand to name a
+fourth or fifth could start a mission whose computer difficulty fell below the
+easiest one and read the difficulty table from outside itself. A difficulty
+chosen in the game is unaffected.
diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md
new file mode 100644
index 00000000..b893326a
--- /dev/null
+++ b/manual/changes/client-driven-launch.md
@@ -0,0 +1,25 @@
+---
+title: Launch and play a game from a client's launch file
+category: feature
+release: 0.2.0
+targets:
+- type: command
+ id: launch:spawn
+ effect: added
+- type: format
+ id: spawn-ini
+ effect: added
+- type: format
+ id: save-games
+ effect: changed
+credit: [ZivDero, Rampastring, dkeeton, FunkyFr3sh, CCHyper, Belonit, hifi, Iran]
+---
+
+Starting the game with `-SPAWN` now plays the match `SPAWN.INI` describes: a skirmish, a
+campaign mission, a game against other machines through a CnCNet tunnel or straight between
+them, or any of those resumed from a saved game. The startup movies and the menu are
+skipped, and the game exits when the match ends.
+
+A client-launched game against other machines can now be saved from its options dialog.
+
+The people credited here wrote the earlier spawners this one follows.
diff --git a/manual/changes/saved-games-folder.md b/manual/changes/saved-games-folder.md
new file mode 100644
index 00000000..b86fb53b
--- /dev/null
+++ b/manual/changes/saved-games-folder.md
@@ -0,0 +1,19 @@
+---
+title: Keep saved games in a folder of their own
+category: feature
+release: 0.2.0
+targets:
+- type: format
+ id: save-games
+ effect: changed
+credit: [ZivDero]
+---
+
+Saved games now live in a `Saved Games` folder, beside the game or inside the user data
+directory when one is named. Every save, load, listing and deletion names that folder, and
+the settings the random map generator saves keep to it too.
+
+Saves made by earlier builds sit beside the game, or in the user data directory when one is
+named, and are no longer listed; moving the files into `Saved Games` restores them.
+
+After a load, the campaign difficulty now comes from the save rather than the menu setting.
diff --git a/manual/changes/user-data-directory.md b/manual/changes/user-data-directory.md
index 542fd7dc..6edc42f8 100644
--- a/manual/changes/user-data-directory.md
+++ b/manual/changes/user-data-directory.md
@@ -13,7 +13,7 @@ credit: [ZivDero]
game writes, creates or deletes goes there — the settings file, hotkeys, saved
games, the hall of fame, recordings, saved random maps, screenshots and the
files a multiplayer game downloads — and the directory is created when it is not
-there yet.
+there yet. Saved games take a `Saved Games` folder of their own inside it.
It is read from before anywhere else, so a player's own copy of a file is the one
the game uses, whatever a deployment ships under the same name. Files a player
diff --git a/manual/content/formats/opents-ini.md b/manual/content/formats/opents-ini.md
index a5ac33db..902f521f 100644
--- a/manual/content/formats/opents-ini.md
+++ b/manual/content/formats/opents-ini.md
@@ -44,9 +44,11 @@ The game data directory is what [`-DATADIR`](/using/command-line/data-directory/
Everything the game opens follows that order: archives, rules, artwork, scenarios and launch files alike. A loose file still stands in for an archived one, so a copy found in any of these folders is used ahead of an archived copy of the same name.
-A player's own copy is therefore the one the game reads, whatever a deployment ships under the same name. That is what makes a shared installation work: the settings, hotkeys and saved games a player has are theirs, and the rest is read from the copy everyone shares.
+A player's own copy is therefore the one the game reads, whatever a deployment ships under the same name. That is what makes a shared installation work: the settings and hotkeys a player has are theirs, and the rest is read from the copy everyone shares.
-Wildcard searches — for rules, battle files, map packs, saved games, map archives and movie archives — cover every directory in the list rather than stopping at the first that holds a match. A name held by more than one is used once, from the one that comes first, which is the same copy an ordinary open of that name would land on.
+Wildcard searches — for rules, battle files, map packs, map archives and movie archives — cover every directory in the list rather than stopping at the first that holds a match. A name held by more than one is used once, from the one that comes first, which is the same copy an ordinary open of that name would land on.
+
+[Saved games](/formats/save-games/) are the exception to all of this. They keep to a `Saved Games` folder inside the user data directory, and are named there outright rather than searched for, so that a launcher browsing them finds them in one place.
:::caution[Files the game writes are not searched for]
Settings, saved games, recordings and everything else the game writes go to the user data directory, or to the game's own directory when there is none. A file the game deletes is its own copy, so throwing away a player's hotkeys falls back to the ones a deployment shipped rather than removing them. Nothing listed here is ever written to or deleted from.
diff --git a/manual/content/formats/save-games.md b/manual/content/formats/save-games.md
index 8ad78e4c..45489dc0 100644
--- a/manual/content/formats/save-games.md
+++ b/manual/content/formats/save-games.md
@@ -23,7 +23,11 @@ source_files:
The save dialog creates `.SAV` files. Each file is an OLE compound document: the listing details live in the document's own property set, and the game state goes into a single `CONTENTS` stream that is compressed as it is written.
-The dialog names a new save `SAVE` followed by four hexadecimal digits, drawing again until it finds a name no existing file answers to; saving over a listed game reuses that game's name. A multiplayer save is written under one fixed name instead and is never offered in the list.
+## Where the files are
+
+Saved games keep to a `Saved Games` folder of their own, beside the game or inside the [user directory](/using/game-data/) when one is named, created the first time the game asks for a saved game. Every save, load, listing and deletion names that folder outright: unlike the files the game reads, a saved game is never looked for anywhere else. A client that browses saved games therefore finds them in one place, whichever layout the game was installed in.
+
+The dialog names a new save `SAVE` followed by four hexadecimal digits, drawing again until it finds a name no existing file answers to; saving over a listed game reuses that game's name. A multiplayer save is written under one fixed name instead and is never offered in the list. The random map generator keeps its saved settings in the same folder, under names of its own; the map a host generates for a match is not one of them, and stays with the game's files so that it can travel to the other machines.
## When the file is written
@@ -41,10 +45,11 @@ The `CONTENTS` stream is a fixed sequence of records — the scenario, the envir
The project-version stamp decides whether a file is offered at all, and only
the running version's stamp is accepted. The load dialog reads the property set
-of every `.SAV` in the game directory and skips every file stamped by anything
-else, including the Tiberian Sun release and another OpenTS release-cycle
-version. A save that reaches the engine without passing through the dialog, as
-a network save does, is checked the same way and refused. Development snapshots
+of every `.SAV` in the saved-games folder and skips every file stamped by
+anything else, including the Tiberian Sun release and another OpenTS
+release-cycle version. A save that reaches the engine without passing through
+the dialog, as one resumed from a [launch file](/formats/spawn-ini/) does, is
+checked the same way and refused. Development snapshots
within one cycle share the stamp; that mechanical match is not a promise that
their save layouts or simulation state interoperate. A listed save that was not
made in a campaign is marked with a leading `*`.
diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md
new file mode 100644
index 00000000..f95af0a6
--- /dev/null
+++ b/manual/content/formats/spawn-ini.md
@@ -0,0 +1,184 @@
+---
+format_id: spawn-ini
+title: Client launch file
+summary: Describes the match a client asks the game to launch when it starts the game with -SPAWN.
+kind: file
+source_files:
+- code/spawnerconfig.cpp
+- code/spawnerconfig.h
+- code/spawner.cpp
+filenames:
+- SPAWN.INI
+related:
+- type: format
+ id: ini-syntax
+- type: command
+ id: launch:spawn
+---
+
+A client that sets up matches outside the game writes this file beside the game and starts
+the game with [`-SPAWN`](/using/command-line/spawn). The game then plays the match the file
+describes instead of showing its own menu, and exits when that match ends.
+
+The vocabulary below is the client's, not the game's: the spelling of every key, and what
+each means when it is left out, are settled by what clients already write. Reading the file
+never fails. A key the game does not know is passed over, a value it cannot make sense of
+keeps the meaning an absent key would have, and whether the result describes a game that
+can be played is judged once, when the launch is attempted.
+
+## What the file asks for
+
+The `[Settings]` section says what kind of game to start.
+
+| Key | Meaning |
+| --- | --- |
+| `Scenario` | The scenario file to play. Defaults to `spawnmap.ini`. |
+| `IsSinglePlayer` | Play a campaign mission rather than a match. |
+| `LoadSaveGame`, `SaveGameName` | Resume the named saved game. |
+
+A file that seats more than one person asks for a game against other machines.
+
+## Resuming a saved game
+
+`LoadSaveGame=yes` resumes the saved game `SaveGameName` names, and decides the kind of game
+on its own: a saved game carries the kind of game it was, the options it was played under
+and the houses that played it, so nothing else in the file decides those. A client resuming a
+campaign writes little more than the name of the save.
+
+The name is a file inside the game's saved-games folder, and a name written with a path of
+its own is reduced to its last part. A save the folder does not hold, or one made by
+another version of the game, is refused, and the reason is shown.
+
+A save from a game against other machines resumes as well. Every machine loads its own
+copy of the save — the synchronized in-game save writes one on each of them, named
+`SAVEGAME.NET` — while the file seats the same people again, with the addresses their
+machines answer on now. A player who does not return leaves their house fighting on under
+the computer, and before play resumes the machines compare the games they loaded, so
+mismatched saves are refused rather than drifting apart. The launch is refused when the
+seats and the save disagree on who is playing, or when the save came from a game the menu
+arranged over the local network.
+
+## A campaign mission
+
+`IsSinglePlayer=yes` plays the mission `Scenario` names. `CampaignID` says which campaign
+the mission belongs to, counted from zero in the order the battle files declare them, or
+`-1` for a mission outside any campaign. The campaign decides what the mission leads on to,
+which ending it plays, and which of the game's own introductions plays, exactly as when a
+campaign is chosen from the menu.
+
+`DifficultyModeHuman` and `DifficultyModeComputer` each name a difficulty from 0 to 2 — the
+player's houses and the computer's, applied independently, so all nine pairings can be
+played where the menu offers only its three. A restart or the next mission keeps the pair.
+
+`[GlobalFlags]` seeds the scenario flags a mission chain carries forward: entries
+`GlobalFlag0` through `GlobalFlag49` are set on the mission as it starts, so a mission
+launched partway through a chain begins in the state the missions before it left.
+
+The mission's own briefing and opening movies play as they do from the menu; only the
+game's startup movies are skipped.
+
+## The options every house plays under
+
+Read from `[Settings]`: `Bases`, `Credits`, `BridgeDestroy`, `Crates`, `ShortGame`,
+`GameSpeed`, `MultiEngineer`, `UnitCount`, `AIPlayers`, `AIDifficulty`, `AlliesAllowed`,
+`FogOfWar`, `MCVRedeploy`, `TechLevel`, `Firestorm`, and `Seed`.
+
+A written `Seed` makes a launch repeatable: the same file played twice places every house
+the same way. A seed of `0` leaves the placement to chance, which is also what an absent
+`Seed` means.
+
+`HarvesterTruce` applies in a game against other machines. A skirmish records it with the
+rest of the match's options but does not apply it, exactly as a skirmish set up from the
+menu does not.
+
+## Who is playing
+
+A seat is a person's because the file writes a section for it: `[Settings]` describes the
+player at this machine, and `[Other1]` through `[Other7]` describe the others. Each names
+`Name`, `Side` (the country), and `Color`.
+
+A seat no section claims is a computer player, described by position instead:
+
+| Section | Entry | Meaning |
+| --- | --- | --- |
+| `[HouseColors]` | `Multi1`–`Multi8` | The color that seat plays. |
+| `[HouseCountries]` | `Multi1`–`Multi8` | The country that seat plays. |
+| `[HouseHandicaps]` | `Multi1`–`Multi8` | The difficulty that seat plays at. |
+
+A computer seat may write `-1` for its country or color and leave the choice to the game,
+as a game set up from the menu does. A person's seat names both. `AIPlayers` says how many
+of the unclaimed seats are actually played by a computer.
+
+The seats are then ordered the way the game creates houses — the people first, by ascending
+color — and everything below that names a seat by number means that order.
+
+| Section | Entry | Meaning |
+| --- | --- | --- |
+| `[SpawnLocations]` | `Multi1`–`Multi8` | The map start position that seat begins at. |
+| `[Multi1_Alliances]`–`[Multi8_Alliances]` | `HouseAllyOne`–`HouseAllyEight` | The seats that seat is allied with. |
+
+A start position the map does not declare, or one another seat has already taken, is left
+to the game to choose, which is also what writing no position means. Alliances are made
+exactly as written, before the first frame: a match whose file forbids new alliances still
+starts with the ones it wrote.
+
+A computer player may share the color a person plays; in a game against other machines, two
+people may not. The client keys each seat by an order no other machine can rebuild, so two
+people of one color would take each other's start position and alliances.
+
+## A game against other machines
+
+Each machine writes its own file, with itself in `[Settings]` and everybody else in the
+`[OtherN]` sections. Those sections carry `Ip` and `Port` as well, naming the address a
+machine answers on. A `[Tunnel]` section with its own `Ip` and `Port` routes the match
+through a tunnel instead, and each machine is then named by the tunnel number its own `Port`
+key carries rather than by its address.
+
+Every person must be named, and no two may be named the same, whatever the letters' case.
+Each machine writes its own file with itself first, so the seats are ordered by color and
+name rather than by the order the file wrote them; without those names the machines would
+not seat the same match.
+
+The seed is taken exactly as written, the same on every machine — including `0`, which in a
+match against other machines is a seed like any other rather than a draw from chance.
+
+When a `[Tunnel]` section names a server, the match is played through it; otherwise each
+machine is reached straight at the address its section carries, while this machine listens
+on the port its own `Port` key names.
+
+## When something is wrong
+
+A file describing a game that cannot be played is refused: the reason is shown and written
+to the log, and the game exits rather than falling back to its menu. A launch is refused
+when it
+
+- seats nobody at this machine;
+- asks for more computer players than there are seats;
+- names a country or color the loaded rules do not have;
+- plays the computer at a difficulty the game does not have;
+- asks for a game speed the game does not have;
+- gives a seat a difficulty outside `-1` to `6`;
+- allies a seat with one the match does not hold;
+- asks for a seat that watches rather than plays.
+
+A match against other machines is refused as well when a person is left unnamed, when two
+are named the same or given one color, and when a machine other than this one is given no
+port to answer on or no address to answer at.
+
+A difficulty easier than the three the game has is not refused: the seat is played as the
+easiest opponent the game does have. The two run opposite ways: the easiest opponent plays
+at the hardest of the game's three settings.
+
+## What the game does not take from a launch file
+
+The timing keys are not read at all, `ReconnectTimeout` and `ConnTimeout` among them. How
+far ahead the machines run, how often they exchange their orders, and how long they wait for
+one that has gone quiet are set by the game, and no launch file changes them. `MapHash` is
+not read either: the machines compare the games they have loaded before play begins, which
+settles the same question for themselves.
+
+These keys are read but change nothing yet: `IsHost`, `Tournament`, `GameID`,
+`WriteStatistics`, the automatic-save scheduling keys, `BuildOffAlly`,
+`AttackNeutralUnits`, `ScrapMetal`, `AutoSurrender`, `ContinueWithoutHumans`, `CoachMode`,
+`QuickMatch`, `SkipScoreScreen`, `PlayMoviesInMultiplayer`, `CustomLoadScreen`,
+`CustomLoadScreenPos`, and `DifficultyName`.
diff --git a/manual/content/using/game-data.md b/manual/content/using/game-data.md
index 8c8a5e58..90e3c32a 100644
--- a/manual/content/using/game-data.md
+++ b/manual/content/using/game-data.md
@@ -32,4 +32,6 @@ Do not place game data in the CMake build directory. The build copies OpenTS exe
`-DATADIR=` reads the game's data from the directory named instead of requiring it beside the executable, and `-USERDIR=` keeps what the game writes — settings, saved games, recordings and downloaded maps — in a directory of its own. Together they let one copy of the data serve several people, each writing only to their own directory and reading their own files ahead of the shared ones.
+[Saved games](/formats/save-games/) go one step further, into a `Saved Games` folder of their own inside that directory. They are the one thing the game both writes and browses, so they are named there outright rather than looked for among the folders the game reads from.
+
The data may be sorted into folders rather than left in one directory. Without any configuration the game also searches `INI`, `MIX` and `Maps`; [`OPENTS.INI`](/formats/opents-ini/) names other folders and the order they are searched in.
diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml
index aa54443b..1dce919d 100644
--- a/manual/data/command-adapters.yaml
+++ b/manual/data/command-adapters.yaml
@@ -482,6 +482,14 @@ launch_options:
availability: *all
sites:
- { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-USERDIR=' }
+ - id: launch:spawn
+ title: Client launch
+ syntax: -SPAWN
+ description: Launches the game SPAWN.INI describes, in place of the startup movies and the menu.
+ audience: player
+ availability: *all
+ sites:
+ - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-SPAWN' }
- id: launch:tournament-time
title: Tournament time limit
syntax: -TIME=
diff --git a/manual/data/commands.yaml b/manual/data/commands.yaml
index 4d58e14a..18b1b31a 100644
--- a/manual/data/commands.yaml
+++ b/manual/data/commands.yaml
@@ -1772,6 +1772,17 @@ launch_options:
_provenance:
source: code/init.cpp
guard: null
+- id: launch:spawn
+ route_id: spawn
+ kind: launch
+ title: Client launch
+ description: Launches the game SPAWN.INI describes, in place of the startup movies and the menu.
+ audience: player
+ availability: *id001
+ syntax: -SPAWN
+ _provenance:
+ source: code/init.cpp
+ guard: null
- id: launch:tournament-time
route_id: tournament-time
kind: launch
diff --git a/manual/data/ini-read-exclusions.yaml b/manual/data/ini-read-exclusions.yaml
index e81cf513..753d06da 100644
--- a/manual/data/ini-read-exclusions.yaml
+++ b/manual/data/ini-read-exclusions.yaml
@@ -136,3 +136,14 @@ site_exclusions:
keys: [SearchPaths]
classification: excluded
reason: The folder list belongs to the deployment file that describes where a distribution keeps its own files, and is documented as part of that format rather than as game data.
+
+ - path: code/spawnerconfig.cpp
+ function: SpawnerConfigClass::Read_Slots
+ keys: [Color, Side, Port]
+ classification: excluded
+ reason: Each names one seat of a match inside a section the CnCNet client numbers per seat, not an authored game-data setting.
+ - path: code/spawnerconfig.cpp
+ function: SpawnerConfigClass::Read_INI
+ keys: [AIDifficulty, AIPlayers, AlliesAllowed, AttackNeutralUnits, AutoSaveGame, AutoSurrender, Bases, BridgeDestroy, BuildOffAlly, CampaignID, CoachMode, ContinueWithoutHumans, Crates, Credits, DifficultyBasedAINames, DifficultyModeComputer, DifficultyModeHuman, Firestorm, FogOfWar, GameID, GameSpeed, HarvesterTruce, Host, IsSinglePlayer, LoadSaveGame, MCVRedeploy, MultiEngineer, NextSPAutoSaveId, NextSkirmishAutoSaveId, PlayMoviesInMultiplayer, Port, QuickMatch, ScrapMetal, Seed, ShortGame, SkipScoreScreen, TechLevel, Tournament, UnitCount, WriteStatistics]
+ classification: excluded
+ reason: The CnCNet client writes these per launch to describe one match, so they are not part of the authored game-data key catalog.
diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs
index 374d7b37..106f9d2b 100644
--- a/manual/site/tests/documentation-source-contract.test.mjs
+++ b/manual/site/tests/documentation-source-contract.test.mjs
@@ -192,3 +192,264 @@ test('Building main-shape Image is additive to the inherited ObjectType Image re
], 'Building main-shape selection');
assert.doesNotMatch(fetchImage, /\bGraphicName\s*=/);
});
+
+test('Every field the launch file reader carries is bound or named as unhonored', () => {
+ const header = source('code/spawnerconfig.h');
+ const spawner = source('code/spawner.cpp');
+
+ assert.match(
+ spawner,
+ /Read, not honored/,
+ 'the binding step keeps its ledger of fields it deliberately leaves alone',
+ );
+
+ const fields = [];
+ for (const line of header.split('\n')) {
+ const declaration = /^\t{2,3}(?!static |enum |struct |\/)[A-Za-z_][^;(]*?[\s>*&]([A-Za-z_]\w*)\s*(?:=[^;]*)?;\s*$/.exec(line);
+ if (declaration) fields.push(declaration[1]);
+ }
+ assert.ok(fields.length > 30, `expected the reader to carry many fields, found ${fields.length}`);
+
+ for (const field of fields) {
+ assert.match(
+ spawner,
+ new RegExp(String.raw`\b${field}\b`),
+ `${field} is read from a launch file but code/spawner.cpp neither binds it nor names it in the "Read, not honored" ledger`,
+ );
+ }
+});
+
+test('A session node is left to its own constructor rather than zeroed by hand', () => {
+ assert.doesNotMatch(
+ source('code/netdlg2.cpp'),
+ /memset\(who, 0, sizeof\(\*who\)\)/,
+ 'zeroing a node by hand would wipe the defaults its constructor sets',
+ );
+});
+
+test('House assignment takes each seat as written before the neutral houses exist', () => {
+ const assign = functionBody(source('code/scenario.cpp'), 'void Assign_Houses(void)');
+
+ assertOrdered(assign, [
+ 'housep->SpawnWaypoint = player->Player.SpawnChoice;',
+ 'seat->Player.House != -1',
+ 'seat->Player.Color != -1',
+ 'seat->Player.Handicap >= 0',
+ 'housep->SpawnWaypoint = seat->Player.SpawnChoice;',
+ 'seat->Player.ID = housep->HeapID;',
+ ], 'a seated house takes its country, color, difficulty and start position');
+
+ assertOrdered(assign, [
+ 'Seated_Node(seatnum)',
+ 'Make_Ally',
+ 'HouseTypeClass::From_Name("Neutral")',
+ ], 'the alliance table names seats, so it is applied before any house that is not one');
+});
+
+test('A chosen start position keeps its number and is claimed before the game picks', () => {
+ const scenario = source('code/scenario.cpp');
+
+ const build = functionBody(
+ scenario,
+ 'static DynamicVectorClass| Build_Start_Waypoint_List(bool official, bool keep_identity)',
+ );
+ assertOrdered(build, [
+ 'if (keep_identity) {',
+ 'waypts.Add(declared ? Scen->Get_Waypoint_Cell(waycount) : CELL_NONE);',
+ 'Append_Open_Start_Positions(',
+ 'return(waypts);',
+ ], 'the numbered list keeps an undeclared position as a hole and appends any shortfall past it');
+
+ const create = functionBody(
+ scenario.slice(scenario.search(/static void Create_Units\(bool official\)\s*\{/)),
+ 'static void Create_Units(bool official)',
+ );
+ assertOrdered(create, [
+ 'Houses[index]->SpawnWaypoint >= 0',
+ 'Build_Start_Waypoint_List(official, choices)',
+ 'taken[index] = choices && index < waypts.Count() && waypts[index] == CELL_NONE;',
+ 'reserved[spot] = index;',
+ 'reserved[hptr->SpawnWaypoint] == (int)house',
+ '} else if (numtaken == 0) {',
+ ], 'every named position is held before the game picks for anybody who named none');
+});
+
+test('The campaign handicap pair lives on the session, and the mission reader never asks the spawner', () => {
+ const scenario = source('code/scenario.cpp');
+
+ assertOrdered(
+ functionBody(scenario, 'bool Read_Scenario_INI(CCINIClass const & ini, bool is_mapgen)'),
+ [
+ 'Scen->Difficulty = Session.CampaignDifficulty;',
+ 'Scen->CDifficulty = Session.CampaignCDifficulty;',
+ ],
+ 'the mission takes the pair the session carries',
+ );
+ assert.doesNotMatch(
+ scenario,
+ /#include "spawner\.h"/,
+ 'the mission reader has no line to the spawner',
+ );
+
+ assertOrdered(
+ functionBody(source('code/init.cpp'), 'bool Select_Game(bool )'),
+ [
+ 'Session.CampaignDifficulty = (DiffType)Options.Difficulty;',
+ 'Session.CampaignCDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty);',
+ ],
+ 'the menu derives the pair the mission reader used to compute, ahead of the start',
+ );
+});
+
+test('A campaign spawn writes the game its own state and nothing more', () => {
+ const spawner = source('code/spawner.cpp');
+
+ assertOrdered(functionBody(spawner, 'static bool Spawner_Setup_Campaign(void)'), [
+ 'Session.Type = GAME_NORMAL;',
+ 'Session.CampaignDifficulty = (DiffType)SpawnConfig.CampaignDifficulty;',
+ 'Session.CampaignCDifficulty = (DiffType)SpawnConfig.CampaignCDifficulty;',
+ 'Scen->Campaign = (CampaignType)SpawnConfig.CampaignID;',
+ 'new (&Environment) EnvironmentClass;',
+ 'Environment.Globals[index] = SpawnConfig.GlobalFlags[index];',
+ ], 'a campaign launch lands in the game’s own state');
+
+ assertOrdered(functionBody(source('code/init.cpp'), 'bool Select_Game(bool )'), [
+ 'Spawner_Is_Active() ? Scen->Campaign : CAMPAIGN_NONE',
+ 'Scen->Set_Global_To(index, Environment.Globals[index]);',
+ ], 'a spawned mission is named by the file and starts with the flags it carried');
+});
+
+test('A resume is judged before it is loaded, and the save answers for the rest', () => {
+ assertOrdered(functionBody(source('code/spawner.cpp'), 'static bool Spawner_Resume(bool & gameloaded)'), [
+ 'SpawnConfig.SaveGameName.empty()',
+ 'Get_Savefile_Info(SpawnConfig.SaveGameName.c_str(), &info)',
+ 'info.Get_Internal_Version() != ExpectedGameVersion',
+ 'type == GAME_IPX',
+ 'SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)',
+ 'Spawner_Seat_Humans();',
+ 'Spawner_Wire_Network()',
+ 'Session.LoadGame = true;',
+ 'LoadOptionsClass().Load_File(SpawnConfig.SaveGameName.c_str())',
+ 'Reconcile_Players()',
+ 'gameloaded = true;',
+ ], 'a network resume seats the players and opens the network before the save is read');
+
+ for (const dialog of ['IDD_OPT_CTRL_WOL']) {
+ const template = source('code/language/language.rc');
+ const body = template.slice(template.indexOf(dialog + ' DIALOG'));
+ assert.match(
+ body.slice(0, body.indexOf('END')),
+ /IDC_SAVE_GAME/,
+ `${dialog} offers the synchronized save the options handler has always known`,
+ );
+ }
+
+ assertOrdered(functionBody(source('code/saveload.cpp'), 'bool Reconcile_Players(void)'), [
+ 'stricmp(Session.Players[i]->Name, Houses[house]->IniName) == 0',
+ 'Session.Players[i]->Player.ID = found->HeapID;',
+ 'Houses[Session.Players[0]->Player.ID] != PlayerPtr',
+ 'housep->IsHuman = false;',
+ 'housep->IniName = Fetch_String(TXT_COMPUTER);',
+ ], 'every seat is matched and this machine identified before any house changes hands');
+
+ assertOrdered(functionBody(source('code/saveload.cpp'), 'bool Load_Game(const char *file_name)'), [
+ 'Session.Type = (GameType)info.Get_Game_Type();',
+ 'Post_Load_Game();',
+ 'Session.CampaignDifficulty = Scen->Difficulty;',
+ 'Session.CampaignCDifficulty = Scen->CDifficulty;',
+ ], 'a load takes the kind of game and the campaign pair from the save');
+});
+
+test('Saved games are named in one folder rather than searched for', () => {
+ const gamedirs = source('code/gamedirs.cpp');
+
+ assertOrdered(functionBody(gamedirs, 'std::string Saved_Game_Name(char const * filename)'), [
+ 'UserDirectory + SavedGamesFolder',
+ 'CreateDirectory(folder.c_str(), NULL);',
+ ], 'a saved game is named inside the user directory, and the folder is made on the way');
+
+ for (const [file, signature] of [
+ ['code/saveload.cpp', 'static bool Save_Game(const char *file_name, char const * descr)'],
+ ['code/saveload.cpp', 'bool Load_Game(const char *file_name)'],
+ ['code/saveload.cpp', 'bool Get_Savefile_Info(char const * name, SaveVersionInfo * info)'],
+ ['code/loaddlg.cpp', 'void LoadOptionsClass::Fill_List(HWND window)'],
+ ['code/loaddlg.cpp', 'bool LoadOptionsClass::Files_Present(void)'],
+ ['code/loaddlg.cpp', 'bool LoadOptionsClass::Delete_File(const char * file_name)'],
+ ]) {
+ assert.match(
+ functionBody(source(file), signature),
+ /Saved_Game_Name\(/,
+ `${signature} names the folder saved games are kept in`,
+ );
+ }
+
+ assert.doesNotMatch(
+ functionBody(source('code/loaddlg.cpp'), 'void LoadOptionsClass::Fill_List(HWND window)') +
+ functionBody(source('code/loaddlg.cpp'), 'bool LoadOptionsClass::Files_Present(void)'),
+ /Search_Files\(/,
+ 'the listing no longer scans the folders the game reads from',
+ );
+});
+
+test('A match against other machines is assembled whole and wired to its network last', () => {
+ const spawner = source('code/spawner.cpp');
+
+ assertOrdered(functionBody(spawner, 'bool Spawner_Prepare(bool & gameloaded)'), [
+ 'SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)',
+ 'Spawner_Setup_Session();',
+ 'SpawnConfig.Session_Identity_CRC()',
+ 'Session.Type == GAME_INTERNET && !Spawner_Wire_Network()',
+ ], 'the match is judged, assembled and named before its network is opened');
+
+ assertOrdered(functionBody(spawner, 'static bool Spawner_Wire_Network(void)'), [
+ 'Ipx.Configure_Tunnel(',
+ 'Ipx.Configure_Direct_Peers(',
+ 'Ipx.Add_Peer(Session.Players[index]->Address);',
+ 'if (!Ipx.Init()) {',
+ ], 'the transport is chosen, the peers named, and only then the network opened');
+
+ assertOrdered(functionBody(source('code/scenario.cpp'), 'static NodeNameType * Seated_Node(int seat)'), [
+ 'Session.Players[i]->Player.ID == seat',
+ 'Session.Computers[i]->Player.ID == seat',
+ ], 'a seat is found by the house it was assigned, not by its place in the list');
+
+
+ assert.match(
+ functionBody(spawner, 'static void Spawner_Setup_Session(void)'),
+ /LaunchType::Multiplayer\s*\n?\s*\?\s*GAME_INTERNET : GAME_SKIRMISH;/,
+ 'one assembly serves both kinds of match',
+ );
+
+ assertOrdered(functionBody(spawner, 'static void Spawner_Seat_Human(int index)'), [
+ 'if (SpawnConfig.TunnelPort != 0) {',
+ 'node->Address.Set_Address(0, htons((unsigned short)seat.Port));',
+ 'inet_addr(seat.Address.c_str())',
+ ], 'a tunnelled machine is named by its tunnel number before an address is read');
+
+ assertOrdered(functionBody(spawner, 'static void Spawner_Seat_Humans(void)'), [
+ 'Spawner_Seat_Human(SpawnConfig.LocalSlot);',
+ 'if (index != SpawnConfig.LocalSlot) {',
+ ], 'the local seat leads the player list the rest of the game reads');
+
+ assertOrdered(functionBody(spawner, 'static void Spawner_Setup_Session(void)'), [
+ 'GAME_INTERNET : GAME_SKIRMISH;',
+ 'Seed = SpawnConfig.Seed;',
+ ], 'one seed is taken as written, since no lobby hands one around');
+
+ assertOrdered(
+ functionBody(
+ source('code/spawnerconfig.cpp'),
+ 'bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const',
+ ),
+ [
+ 'kind == LaunchType::Multiplayer ||',
+ '(kind == LaunchType::Resume && HumanCount > 1)',
+ 'if (human && multiplayer) {',
+ 'slot.Name.empty()',
+ '_stricmp(Slots[other].Name.c_str(), slot.Name.c_str()) == 0',
+ 'Slots[other].Color == slot.Color',
+ 'slot.Port < 1 || slot.Port > 65535',
+ ],
+ 'the seat order the machines share is what the name and color rules are held for',
+ );
+});
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 5de329d7..de530aea 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,3 +1,4 @@
+add_subdirectory(cpudetect)
add_subdirectory(gamedirs)
add_subdirectory(logstress)
-add_subdirectory(cpudetect)
+add_subdirectory(spawner)
diff --git a/tests/gamedirs/gamedirscontract.cpp b/tests/gamedirs/gamedirscontract.cpp
index d2f9a6a0..e8f92452 100644
--- a/tests/gamedirs/gamedirscontract.cpp
+++ b/tests/gamedirs/gamedirscontract.cpp
@@ -506,6 +506,49 @@ void Test_Without_A_User_Directory_Nothing_Moves(void)
}
+/*
+ * Saved games are the one thing the game both writes and browses, so they keep to a folder of
+ * their own that is named outright rather than searched for.
+ */
+void Test_Saved_Games_Folder(void)
+{
+ Reset();
+ Init_Search_Folders();
+
+ Check(Saved_Game_Name("SAVE0001.SAV") == "Saved Games\\SAVE0001.SAV",
+ "a saved game is named inside the folder saved games are kept in");
+ Check(File_Exists(Root + "\\Saved Games"),
+ "asking for a saved game makes the folder to keep it in");
+
+ for (int index = 0; ; index++) {
+ char const * path = CDFileClass::Search_Path(index);
+ if (path == NULL) {
+ break;
+ }
+
+ Check(std::string(path).find("Saved Games") == std::string::npos,
+ "the folder saved games are kept in is not one of the searched folders");
+ }
+
+ Reset();
+ Set_User_Directory((Root + "\\User\\Saves").c_str());
+ Apply_Game_Directories();
+
+ std::string const expected = Root + "\\User\\Saves\\Saved Games";
+ Check(Saved_Game_Name("SAVE0002.SAV") == expected + "\\SAVE0002.SAV",
+ "a user directory takes the saved games with it");
+ Check(File_Exists(expected),
+ "the folder is made inside the user directory");
+
+ /*
+ * A pattern is named the same way a file is, since the listing scans the one folder rather
+ * than every folder the game reads from.
+ */
+ Check(Saved_Game_Name("*.SAV") == expected + "\\*.SAV",
+ "a pattern is named in the same folder the saved games are");
+}
+
+
bool Make_Root(void)
{
char temp[MAX_PATH];
@@ -566,6 +609,7 @@ int main(void)
Test_A_Name_With_A_Directory_Is_Left_Alone();
Test_Placing_A_File_Is_Repeatable();
Test_Without_A_User_Directory_Nothing_Moves();
+ Test_Saved_Games_Folder();
Reset();
Remove_Root();
diff --git a/tests/spawner/CMakeLists.txt b/tests/spawner/CMakeLists.txt
new file mode 100644
index 00000000..dc597a9c
--- /dev/null
+++ b/tests/spawner/CMakeLists.txt
@@ -0,0 +1,42 @@
+# The launch file reader is compiled straight into the harness along with the INI reader it
+# is written against. It lives outside code/ so that the recursive glob building the engine
+# cannot pick this target's entry point up.
+add_executable(SpawnContract
+ "${CMAKE_CURRENT_SOURCE_DIR}/spawncontract.cpp"
+ "${CMAKE_SOURCE_DIR}/code/spawnerconfig.cpp"
+ "${CMAKE_SOURCE_DIR}/code/ini.cpp"
+ "${CMAKE_SOURCE_DIR}/code/readline.cpp"
+ "${CMAKE_SOURCE_DIR}/code/trim.cpp"
+ "${CMAKE_SOURCE_DIR}/code/buff.cpp"
+ "${CMAKE_SOURCE_DIR}/code/straw.cpp"
+ "${CMAKE_SOURCE_DIR}/code/xstraw.cpp"
+ "${CMAKE_SOURCE_DIR}/code/cstraw.cpp"
+ "${CMAKE_SOURCE_DIR}/code/pipe.cpp"
+ "${CMAKE_SOURCE_DIR}/code/xpipe.cpp"
+ "${CMAKE_SOURCE_DIR}/code/b64straw.cpp"
+ "${CMAKE_SOURCE_DIR}/code/b64pipe.cpp"
+ "${CMAKE_SOURCE_DIR}/code/base64.cpp"
+ "${CMAKE_SOURCE_DIR}/code/crc.cpp"
+ "${CMAKE_SOURCE_DIR}/code/pk.cpp"
+ "${CMAKE_SOURCE_DIR}/code/int.cpp"
+ "${CMAKE_SOURCE_DIR}/code/mpmath.cpp"
+)
+
+target_compile_features(SpawnContract PRIVATE cxx_std_20)
+
+target_include_directories(SpawnContract PRIVATE "${CMAKE_SOURCE_DIR}/code")
+
+target_compile_definitions(SpawnContract PRIVATE WIN32 _WINDOWS _MBCS NOMINMAX)
+
+target_compile_options(SpawnContract PRIVATE
+ $<$:/MTd /EHsc /Zc:__cplusplus>
+ $<$:/MT /EHsc /Zc:__cplusplus>
+)
+
+target_link_libraries(SpawnContract PRIVATE kernel32 user32 shell32)
+
+set_target_properties(SpawnContract PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
+)
+
+add_test(NAME spawncontract COMMAND SpawnContract)
diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp
new file mode 100644
index 00000000..429427fd
--- /dev/null
+++ b/tests/spawner/spawncontract.cpp
@@ -0,0 +1,754 @@
+/*******************************************************************************
+ * O P E N T S
+ *******************************************************************************
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * Copyright 2026 OpenTS contributors
+ *
+ * See LICENSE.md for applicable additional terms and warranty disclaimers.
+ ******************************************************************************/
+
+// Pins the launch file the CnCNet client writes, with no engine and no game data: unwritten
+// keys, seat order, the reader's silent repairs, and the values two machines must agree on.
+
+#include
+#include
+#include
+
+#include "ini.h"
+#include "spawnerconfig.h"
+#include "xstraw.h"
+
+namespace {
+
+using LaunchType = SpawnerConfigClass::LaunchType;
+using OccupancyType = SpawnerConfigClass::OccupancyType;
+
+int Failures = 0;
+
+
+void Check(bool condition, char const * what)
+{
+ std::printf("%-64s %s\n", what, condition ? "ok" : "FAILED");
+
+ if (!condition) {
+ Failures++;
+ }
+}
+
+
+// What the client writes to resume a saved campaign. It names almost nothing: the saved game
+// carries the houses, the options and the kind of game they were played as.
+char const _Resume[] =
+ "[Settings]\n"
+ "Scenario=spawnmap.ini\n"
+ "SaveGameName=SAVEGAME.001\n"
+ "LoadSaveGame=Yes\n"
+ "SidebarHack=True\n"
+ "CustomLoadScreen=Resources/l600s01.pcx\n"
+ "Firestorm=No\n"
+ "GameSpeed=1\n";
+
+
+// What the client writes to start a campaign mission. It names no player and no color, since
+// the map says who is playing.
+char const _Campaign[] =
+ "[Settings]\n"
+ "Scenario=spawnmap.ini\n"
+ "CampaignID=-1\n"
+ "GameSpeed=1\n"
+ "Firestorm=False\n"
+ "CustomLoadScreen=Resources/l600s01.pcx\n"
+ "IsSinglePlayer=Yes\n"
+ "SidebarHack=True\n"
+ "Side=0\n"
+ "BuildOffAlly=False\n"
+ "DifficultyModeHuman=0\n"
+ "DifficultyModeComputer=2\n";
+
+
+// What the client writes to start a game against computer players. The player is described
+// in the settings, and the computer players are named by position.
+char const _Skirmish[] =
+ "[Settings]\n"
+ "Scenario=spawnmap.ini\n"
+ "Name=Commander\n"
+ "Side=1\n"
+ "Color=4\n"
+ "Port=1234\n"
+ "AIPlayers=2\n"
+ "Credits=5000\n"
+ "ShortGame=Yes\n"
+ "Protocol=0\n"
+ "NextSkirmishAutoSaveId=5\n"
+ "\n"
+ "[HouseColors]\n"
+ "Multi2=2\n"
+ "Multi3=7\n"
+ "\n"
+ "[HouseCountries]\n"
+ "Multi2=0\n"
+ "Multi3=1\n"
+ "\n"
+ "[HouseHandicaps]\n"
+ "Multi2=2\n"
+ "Multi3=0\n"
+ "\n"
+ "[SpawnLocations]\n"
+ "Multi1=3\n"
+ "Multi2=9\n"
+ "Multi3=-2\n"
+ "\n"
+ "[Multi1_Alliances]\n"
+ "HouseAllyOne=1\n";
+
+
+// What the client writes for a game against other machines. The player who wrote it holds the
+// higher color, so the sort puts that seat second.
+char const _Network[] =
+ "[Settings]\n"
+ "Scenario=spawnmap.ini\n"
+ "Name=Second\n"
+ "Side=1\n"
+ "Color=5\n"
+ "Port=50000\n"
+ "Host=No\n"
+ "\n"
+ "[Other1]\n"
+ "Name=First\n"
+ "Side=0\n"
+ "Color=1\n"
+ "Ip=10.0.0.7\n"
+ "Port=50001\n"
+ "\n"
+ "[Tunnel]\n"
+ "Ip=88.99.11.22\n"
+ "Port=50010\n"
+ "\n"
+ "[IsSpectator]\n"
+ "Multi1=Yes\n"
+ "\n"
+ "[Multi2_Alliances]\n"
+ "HouseAllyOne=0\n";
+
+
+SpawnerConfigClass Read(char const * text, int length)
+{
+ INIClass ini;
+ BufferStraw straw(text, length);
+ ini.Load(straw);
+
+ SpawnerConfigClass config;
+ config.Read_INI(ini);
+ return(config);
+}
+
+
+bool Judge(char const * text, int length, int countries, int colors, std::string & fault)
+{
+ SpawnerConfigClass config = Read(text, length);
+ fault.clear();
+ return(config.Is_Playable(countries, colors, fault));
+}
+
+}
+
+
+int main(void)
+{
+ /*
+ * A file naming nothing at all still describes a playable game, because every key the
+ * client leaves out has a settled meaning.
+ */
+ {
+ char const empty[] = "[Settings]\n";
+ SpawnerConfigClass config = Read(empty, sizeof(empty) - 1);
+
+ Check(config.Bases && config.Credits == 10000 && config.MCVRedeploy,
+ "the unwritten keys keep the meaning clients expect");
+ Check(config.TunnelId == 0 && config.ListenPort == 1234,
+ "one absent port names no tunnel and still listens");
+ Check(config.ScenarioName == "spawnmap.ini",
+ "the scenario a client always writes is also the default");
+ Check(config.NextCampaignAutoSave == 0 && config.NextSkirmishAutoSave == 0,
+ "the first automatic save is numbered from zero");
+
+ bool any = false;
+ for (bool flag : config.GlobalFlags) {
+ any = any || flag;
+ }
+ Check(!any, "no scenario flag is set unasked");
+ }
+
+ /*
+ * Resuming a saved game asks for almost nothing; the save answers for the rest, so the
+ * reading alone decides the kind of launch.
+ */
+ {
+ SpawnerConfigClass config = Read(_Resume, sizeof(_Resume) - 1);
+
+ Check(config.Launch_Type() == LaunchType::Resume, "resuming a save is what the file asks for");
+ Check(config.SaveGameName == "SAVEGAME.001", "the saved game is named");
+ Check(!config.Firestorm, "the expansion is left out when the file says so");
+ }
+
+ /*
+ * A saved game is opened by name in the game's own folder, so a name written with a path
+ * is reduced to the name without ceremony.
+ */
+ {
+ char const traversal[] =
+ "[Settings]\n"
+ "LoadSaveGame=Yes\n"
+ "SaveGameName=..\\..\\Windows\\SAVEGAME.001\n";
+ SpawnerConfigClass config = Read(traversal, sizeof(traversal) - 1);
+
+ Check(config.SaveGameName == "SAVEGAME.001", "only the name of the saved game is read");
+
+ char const forward[] =
+ "[Settings]\n"
+ "SaveGameName=saves/deep/SAVEGAME.002\n";
+ config = Read(forward, sizeof(forward) - 1);
+
+ Check(config.SaveGameName == "SAVEGAME.002", "a forward slash hides nothing either");
+
+ char const drive[] =
+ "[Settings]\n"
+ "SaveGameName=C:SAVEGAME.003\n";
+ config = Read(drive, sizeof(drive) - 1);
+
+ Check(config.SaveGameName == "SAVEGAME.003", "a drive letter is not part of the name");
+ }
+
+ /*
+ * A campaign takes its houses from the map, so a file naming no player is complete.
+ */
+ {
+ SpawnerConfigClass config = Read(_Campaign, sizeof(_Campaign) - 1);
+
+ Check(config.Launch_Type() == LaunchType::Campaign, "a single player game is a campaign");
+ Check(config.CampaignDifficulty == 0 && config.CampaignCDifficulty == 2,
+ "the two difficulties are read apart");
+ Check(config.CampaignID == -1, "a mission outside any campaign says so");
+ }
+
+ /*
+ * The seats end up in the order the houses are created in, which is what everything
+ * naming a seat by position afterwards means.
+ */
+ {
+ SpawnerConfigClass config = Read(_Skirmish, sizeof(_Skirmish) - 1);
+
+ Check(config.Launch_Type() == LaunchType::Skirmish, "one player and computers is a skirmish");
+ Check(config.HumanCount == 1 && config.LocalSlot == 0, "the only player holds the first seat");
+ Check(config.Slots[0].Occupancy == OccupancyType::Human &&
+ config.Slots[0].Name == "Commander" &&
+ config.Slots[0].Color == 4 && config.Slots[0].Country == 1,
+ "the player is read from the settings themselves");
+ Check(config.Slots[1].Occupancy == OccupancyType::Computer && config.Slots[1].Color == 2 &&
+ config.Slots[1].Country == 0 && config.Slots[1].Handicap == 2,
+ "a seat no section claimed is a computer player named by position");
+ Check(config.Slots[2].Occupancy == OccupancyType::Computer && config.Slots[2].Color == 7,
+ "the second computer player follows the first");
+ Check(config.Slots[3].Occupancy == OccupancyType::Empty, "no more seats are filled than are asked for");
+ Check(config.Slots[0].StartingPosition == 3, "a start position is read for the seat that asked");
+ Check(config.Slots[1].StartingPosition == -1,
+ "a start position the map cannot hold becomes the game's own choice");
+ Check(config.Slots[2].StartingPosition == -1,
+ "a position below the game's own choice is that choice");
+ Check(config.Slots[0].Alliances[0] == 1, "an alliance is read as the seat it names");
+ Check(config.NextSkirmishAutoSave == 4, "a client's save numbering is shifted to the game's");
+ }
+
+ /*
+ * The player who wrote the file is not always the first house, and the game has to know
+ * which seat is his once the sorting is done.
+ */
+ {
+ SpawnerConfigClass config = Read(_Network, sizeof(_Network) - 1);
+
+ Check(config.HumanCount == 2, "a written section is what makes a seat a player");
+ Check(config.Slots[0].Name == "First" && config.Slots[1].Name == "Second",
+ "the players are sorted into the order their houses are created in");
+ Check(config.LocalSlot == 1, "this machine knows which of the seats is its own");
+ Check(config.Slots[0].Address == "10.0.0.7" && config.Slots[0].Port == 50001,
+ "the other machine is read with the address it answers on");
+ Check(config.TunnelId == 50000 && config.ListenPort == 50000,
+ "one written port both listens and names this machine to a tunnel");
+ Check(config.TunnelAddress == "88.99.11.22" && config.TunnelPort == 50010,
+ "the tunnel is read from its own section");
+ Check(config.Launch_Type() == LaunchType::Multiplayer,
+ "two players is a game against another machine");
+ Check(config.Slots[0].IsSpectator && !config.Slots[1].IsSpectator,
+ "a watcher is named by the seat order the houses take");
+ Check(config.Slots[1].Alliances[0] == 0,
+ "an alliance section names the sorted seat too");
+ }
+
+ /*
+ * Every machine writes its own file with itself first, so a color tie must be broken by what
+ * the seats say rather than by file order.
+ */
+ {
+ char const view_a[] =
+ "[Settings]\n"
+ "Name=Alpha\n"
+ "Side=0\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Name=Bravo\n"
+ "Side=1\n"
+ "Color=3\n"
+ "Ip=10.0.0.9\n"
+ "Port=50002\n";
+ char const view_b[] =
+ "[Settings]\n"
+ "Name=Bravo\n"
+ "Side=1\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Name=Alpha\n"
+ "Side=0\n"
+ "Color=3\n"
+ "Ip=10.0.0.8\n"
+ "Port=50001\n";
+
+ SpawnerConfigClass a = Read(view_a, sizeof(view_a) - 1);
+ SpawnerConfigClass b = Read(view_b, sizeof(view_b) - 1);
+ Check(a.Slots[0].Name == "Alpha" && b.Slots[0].Name == "Alpha",
+ "a color tie seats the match identically on every machine");
+ Check(a.LocalSlot == 0 && b.LocalSlot == 1,
+ "each machine still knows which of the tied seats is its own");
+ Check(a.Session_Identity_CRC() == b.Session_Identity_CRC(),
+ "the tied match carries one identity on both machines");
+ }
+
+ /*
+ * Two machines handed the same match agree on its identity, and a difference in what
+ * either of them merely displays cannot move it.
+ */
+ {
+ SpawnerConfigClass one = Read(_Skirmish, sizeof(_Skirmish) - 1);
+ SpawnerConfigClass two = Read(_Skirmish, sizeof(_Skirmish) - 1);
+
+ Check(one.Session_Identity_CRC() == two.Session_Identity_CRC(),
+ "the same match is given the same identity twice");
+
+ two.MapName = "A Map By Another Name";
+ two.DifficultyName = "Gentle";
+ two.Slots[0].Name = "Somebody Else";
+ Check(one.Session_Identity_CRC() == two.Session_Identity_CRC(),
+ "what a player is shown is left out of the identity");
+
+ two.Credits = one.Credits + 1;
+ Check(one.Session_Identity_CRC() != two.Session_Identity_CRC(),
+ "a value the match is played by moves the identity");
+
+ SpawnerConfigClass three = Read(_Skirmish, sizeof(_Skirmish) - 1);
+ three.Slots[1].Country = one.Slots[1].Country + 1;
+ Check(one.Session_Identity_CRC() != three.Session_Identity_CRC(),
+ "a computer player's country moves the identity");
+
+ SpawnerConfigClass four = Read(_Skirmish, sizeof(_Skirmish) - 1);
+ four.GlobalFlags[49] = !four.GlobalFlags[49];
+ Check(one.Session_Identity_CRC() != four.Session_Identity_CRC(),
+ "a scenario flag moves the identity");
+
+ /*
+ * A resume is a match of its own: the saved game decides everything the fields above
+ * would otherwise have decided, so which save is being resumed is part of the identity.
+ */
+ SpawnerConfigClass five = Read(_Skirmish, sizeof(_Skirmish) - 1);
+ five.LoadSaveGame = !five.LoadSaveGame;
+ Check(one.Session_Identity_CRC() != five.Session_Identity_CRC(),
+ "resuming a save rather than starting one moves the identity");
+
+ SpawnerConfigClass six = Read(_Resume, sizeof(_Resume) - 1);
+ SpawnerConfigClass seven = Read(_Resume, sizeof(_Resume) - 1);
+ seven.SaveGameName = "SAVEGAME.002";
+ Check(six.Session_Identity_CRC() != seven.Session_Identity_CRC(),
+ "resuming another saved game moves the identity");
+
+ /*
+ * Where the machines reach one another carries the match rather than shaping it, and each
+ * machine writes its own view, so it is left out as well.
+ */
+ SpawnerConfigClass eight = Read(_Network, sizeof(_Network) - 1);
+ SpawnerConfigClass nine = Read(_Network, sizeof(_Network) - 1);
+ nine.TunnelAddress = "203.0.113.9";
+ nine.TunnelPort = 50010;
+ nine.ListenPort = 60000;
+ nine.Slots[0].Address = "10.0.0.8";
+ nine.Slots[0].Port = 50003;
+ Check(eight.Session_Identity_CRC() == nine.Session_Identity_CRC(),
+ "where the machines reach one another is left out of the identity");
+ }
+
+ /*
+ * The timing the machines keep is the game's own, and a key the game does not know is
+ * passed over, so neither can move a match's identity.
+ */
+ {
+ char const plain[] =
+ "[Settings]\n"
+ "Credits=7000\n"
+ "Seed=42\n";
+ char const noisy[] =
+ "[Settings]\n"
+ "Credits=7000\n"
+ "Seed=42\n"
+ "Protocol=2\n"
+ "FrameSendRate=3\n"
+ "MaxAhead=100\n"
+ "PreCalcMaxAhead=1\n"
+ "MaxLatencyLevel=2\n"
+ "SomeFutureClientKey=1\n";
+
+ SpawnerConfigClass a = Read(plain, sizeof(plain) - 1);
+ SpawnerConfigClass b = Read(noisy, sizeof(noisy) - 1);
+ Check(a.Session_Identity_CRC() == b.Session_Identity_CRC(),
+ "timing and unknown keys cannot move a match's identity");
+ }
+
+ /*
+ * The scenario flags are named by their number, and a load screen position is taken
+ * whole or not at all.
+ */
+ {
+ char const flags[] =
+ "[Settings]\n"
+ "CustomLoadScreenPos=317,401\n"
+ "\n"
+ "[GlobalFlags]\n"
+ "GlobalFlag0=yes\n"
+ "GlobalFlag49=yes\n";
+ SpawnerConfigClass config = Read(flags, sizeof(flags) - 1);
+
+ Check(config.GlobalFlags[0] && config.GlobalFlags[49],
+ "a scenario flag is read by its number");
+
+ bool between = false;
+ for (int index = 1; index < 49; index++) {
+ between = between || config.GlobalFlags[index];
+ }
+ Check(!between, "no flag is set by a neighbor's spelling");
+ Check(config.CustomLoadScreenX == 317 && config.CustomLoadScreenY == 401,
+ "the load screen position is read whole");
+
+ char const malformed[] =
+ "[Settings]\n"
+ "CustomLoadScreenPos=oops\n";
+ config = Read(malformed, sizeof(malformed) - 1);
+
+ Check(config.CustomLoadScreenX == 0 && config.CustomLoadScreenY == 0,
+ "a position the reader cannot make sense of is no position");
+
+ char const half[] =
+ "[Settings]\n"
+ "CustomLoadScreenPos=12\n";
+ config = Read(half, sizeof(half) - 1);
+
+ Check(config.CustomLoadScreenX == 0 && config.CustomLoadScreenY == 0,
+ "half a position is no position either");
+ }
+
+ /*
+ * Reading a launch file cannot fail, so whether what it describes can be played is
+ * judged separately, against the tables the game has loaded by the time it launches.
+ */
+ {
+ std::string fault;
+
+ Check(Judge(_Skirmish, sizeof(_Skirmish) - 1, 2, 8, fault),
+ "a match the loaded rules can hold is played");
+
+ char const crowded[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=0\n"
+ "AIPlayers=8\n";
+ Check(!Judge(crowded, sizeof(crowded) - 1, 2, 8, fault) &&
+ fault.find("8") != std::string::npos && fault.find("7") != std::string::npos,
+ "more computer players than seats names both counts");
+
+ char const negative[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=0\n"
+ "AIPlayers=-1\n";
+ Check(!Judge(negative, sizeof(negative) - 1, 2, 8, fault),
+ "fewer than no computer players is refused");
+
+ char const nameless_country[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Color=0\n";
+ Check(!Judge(nameless_country, sizeof(nameless_country) - 1, 2, 8, fault),
+ "a person's country is never the game's to draw");
+
+ char const nameless_color[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n";
+ Check(!Judge(nameless_color, sizeof(nameless_color) - 1, 2, 8, fault),
+ "a person's color is never the game's to draw either");
+
+ char const drawn_computer[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=0\n"
+ "AIPlayers=1\n"
+ "\n"
+ "[HouseColors]\n"
+ "Multi2=-1\n"
+ "\n"
+ "[HouseCountries]\n"
+ "Multi2=-1\n";
+ Check(Judge(drawn_computer, sizeof(drawn_computer) - 1, 2, 8, fault),
+ "a computer seat may leave its country and color to the game");
+
+ char const past_countries[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=2\n"
+ "Color=0\n";
+ Check(!Judge(past_countries, sizeof(past_countries) - 1, 2, 8, fault),
+ "a country the rules did not declare is refused");
+
+ char const past_colors[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=8\n";
+ Check(!Judge(past_colors, sizeof(past_colors) - 1, 2, 8, fault),
+ "a color the game has no scheme for is refused");
+
+ char const shared_color[] =
+ "[Settings]\n"
+ "Name=Alpha\n"
+ "Side=0\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Name=Bravo\n"
+ "Side=1\n"
+ "Color=3\n"
+ "Ip=10.0.0.9\n"
+ "Port=50002\n";
+ Check(!Judge(shared_color, sizeof(shared_color) - 1, 2, 8, fault),
+ "two people of one color are refused against other machines");
+
+ char const shared_with_computer[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=3\n"
+ "AIPlayers=1\n"
+ "\n"
+ "[HouseColors]\n"
+ "Multi2=3\n";
+ Check(Judge(shared_with_computer, sizeof(shared_with_computer) - 1, 2, 8, fault),
+ "a computer player may take the color its opponent plays");
+
+ char const past_difficulty[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=0\n"
+ "AIPlayers=1\n"
+ "\n"
+ "[HouseHandicaps]\n"
+ "Multi2=7\n";
+ Check(!Judge(past_difficulty, sizeof(past_difficulty) - 1, 2, 8, fault),
+ "a difficulty naming none is refused");
+
+ char const easy_difficulty[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=0\n"
+ "AIPlayers=1\n"
+ "\n"
+ "[HouseHandicaps]\n"
+ "Multi2=6\n";
+ Check(Judge(easy_difficulty, sizeof(easy_difficulty) - 1, 2, 8, fault),
+ "a difficulty easier than the game holds is played, not refused");
+
+ Check(SpawnerConfigClass::Playable_Handicap(-1) == -1 && SpawnerConfigClass::Playable_Handicap(0) == 0 &&
+ SpawnerConfigClass::Playable_Handicap(2) == 2 && SpawnerConfigClass::Playable_Handicap(3) == 2 &&
+ SpawnerConfigClass::Playable_Handicap(6) == 2,
+ "an easier opponent than the game has comes to the easiest opponent it has");
+
+ char const two_machines[] =
+ "[Settings]\n"
+ "Name=Alpha\n"
+ "Side=0\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Name=Bravo\n"
+ "Side=1\n"
+ "Color=5\n"
+ "Ip=10.0.0.9\n"
+ "Port=50002\n";
+ Check(Judge(two_machines, sizeof(two_machines) - 1, 2, 8, fault),
+ "a match against another machine with everybody named is played");
+
+ char const nameless_machine[] =
+ "[Settings]\n"
+ "Name=Alpha\n"
+ "Side=0\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Side=1\n"
+ "Color=5\n"
+ "Ip=10.0.0.9\n"
+ "Port=50002\n";
+ Check(!Judge(nameless_machine, sizeof(nameless_machine) - 1, 2, 8, fault),
+ "a person the file leaves unnamed is refused against other machines");
+
+ char const one_name[] =
+ "[Settings]\n"
+ "Name=Alpha\n"
+ "Side=0\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Name=alpha\n"
+ "Side=1\n"
+ "Color=5\n"
+ "Ip=10.0.0.9\n"
+ "Port=50002\n";
+ Check(!Judge(one_name, sizeof(one_name) - 1, 2, 8, fault) &&
+ fault.find("1") != std::string::npos && fault.find("2") != std::string::npos,
+ "two people under one name are refused however either is spelled");
+
+ char const alone[] =
+ "[Settings]\n"
+ "Side=0\n"
+ "Color=0\n";
+ Check(Judge(alone, sizeof(alone) - 1, 2, 8, fault),
+ "somebody playing alone need not be named");
+
+ char const nobody[] =
+ "[GlobalFlags]\n"
+ "GlobalFlag0=yes\n";
+ Check(!Judge(nobody, sizeof(nobody) - 1, 2, 8, fault),
+ "a file seating nobody at this machine is refused");
+
+ char const past_ai_difficulty[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=0\n"
+ "AIDifficulty=7\n";
+ Check(!Judge(past_ai_difficulty, sizeof(past_ai_difficulty) - 1, 2, 8, fault),
+ "a computer difficulty the game does not have is refused");
+
+ char const unreachable[] =
+ "[Settings]\n"
+ "Name=Alpha\n"
+ "Side=0\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Name=Bravo\n"
+ "Side=1\n"
+ "Color=5\n"
+ "Ip=10.0.0.9\n";
+ Check(!Judge(unreachable, sizeof(unreachable) - 1, 2, 8, fault),
+ "a machine the file gives no port is refused");
+
+ char const nowhere[] =
+ "[Settings]\n"
+ "Name=Alpha\n"
+ "Side=0\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Name=Bravo\n"
+ "Side=1\n"
+ "Color=5\n"
+ "Ip=10.0.0.\n"
+ "Port=50002\n";
+ Check(!Judge(nowhere, sizeof(nowhere) - 1, 2, 8, fault),
+ "an address naming no machine is refused");
+
+ char const tunnelled[] =
+ "[Settings]\n"
+ "Name=Alpha\n"
+ "Side=0\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Name=Bravo\n"
+ "Side=1\n"
+ "Color=5\n"
+ "Port=50002\n"
+ "\n"
+ "[Tunnel]\n"
+ "Ip=88.99.11.22\n"
+ "Port=50010\n";
+ Check(Judge(tunnelled, sizeof(tunnelled) - 1, 2, 8, fault),
+ "a tunnelled machine is named by its number rather than an address");
+
+ char const one_kept_name[] =
+ "[Settings]\n"
+ "Name=CommanderAlphaOmegaX\n"
+ "Side=0\n"
+ "Color=3\n"
+ "\n"
+ "[Other1]\n"
+ "Name=CommanderAlphaOmegaY\n"
+ "Side=1\n"
+ "Color=5\n"
+ "Ip=10.0.0.9\n"
+ "Port=50002\n";
+ Check(!Judge(one_kept_name, sizeof(one_kept_name) - 1, 2, 8, fault),
+ "two names the game keeps as one are refused");
+
+ char const unheld_ally[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=0\n"
+ "\n"
+ "[Multi1_Alliances]\n"
+ "HouseAllyOne=5\n";
+ Check(!Judge(unheld_ally, sizeof(unheld_ally) - 1, 2, 8, fault),
+ "an alliance with a seat nobody occupies is refused");
+
+ char const past_seats[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=0\n"
+ "\n"
+ "[Multi1_Alliances]\n"
+ "HouseAllyOne=8\n";
+ Check(!Judge(past_seats, sizeof(past_seats) - 1, 2, 8, fault),
+ "an alliance with a seat the match does not hold is refused");
+
+ char const watcher[] =
+ "[Settings]\n"
+ "Name=Commander\n"
+ "Side=0\n"
+ "Color=0\n"
+ "\n"
+ "[IsSpectator]\n"
+ "Multi1=Yes\n";
+ Check(!Judge(watcher, sizeof(watcher) - 1, 2, 8, fault),
+ "a seat that watches rather than plays is refused");
+
+ Check(!Judge(_Skirmish, sizeof(_Skirmish) - 1, 0, 8, fault),
+ "a match is refused rather than read against countries the rules never declared");
+ }
+
+ std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED");
+ return(Failures == 0 ? 0 : 1);
+}
| | | | | | | | |