diff --git a/code/ccfile.cpp b/code/ccfile.cpp
index d7951750..36c09a5e 100644
--- a/code/ccfile.cpp
+++ b/code/ccfile.cpp
@@ -378,6 +378,25 @@ void CCFileClass::Close(void)
}
+///
+/// Assigns a name to this file object and opens it in one operation.
+///
+/// The name of the file to open.
+/// The access rights to open the file with.
+/// int; Was the file opened successfully?
+int CCFileClass::Open(char const * filename, int rights)
+{
+ // A file being written is looked for in the current directory alone, and never in a
+ // mixfile, since nothing can be written into one.
+ if ((rights & WRITE) != 0) {
+ return(CDFileClass::Open(filename, rights));
+ }
+
+ Set_Name(filename);
+ return(Open(rights));
+}
+
+
/***********************************************************************************************
* CCFileClass::Open -- Opens a file from either the mixfile system or the rawfile system. *
* *
diff --git a/code/ccfile.h b/code/ccfile.h
index 579308cc..cb9c8b4f 100644
--- a/code/ccfile.h
+++ b/code/ccfile.h
@@ -57,7 +57,7 @@ class CCFileClass : public CDFileClass
bool Is_Resident(void) const {return(Data.Get_Buffer() != NULL);}
virtual bool Is_Available(int forced=false) override;
virtual bool Is_Open(void) const override;
- virtual int Open(char const * filename, int rights=READ) override {Set_Name(filename);return(Open(rights));};
+ virtual int Open(char const * filename, int rights=READ) override;
virtual int Open(int rights=READ) override;
virtual int Read(void * buffer, int size) override;
virtual int Seek(int pos, int dir=SEEK_CUR) override;
diff --git a/code/cdfile.cpp b/code/cdfile.cpp
index d69ce1c4..11ef777a 100644
--- a/code/cdfile.cpp
+++ b/code/cdfile.cpp
@@ -41,20 +41,27 @@
#include "cdfile.h"
+#include
+
/*
** Pointer to the first search path record.
*/
CDFileClass::SearchDriveType * CDFileClass::First = NULL;
+// Where this player's own files are kept, ending in a separator, or empty when the player
+// has no directory of their own.
+static std::string UserPath;
+
///
/// Constructs a CD file object for the file specified.
-/// The name is searched in the current directory and every configured path, so the object
-/// refers to the first matching local file.
+/// The name is searched for in the player's own directory, the current directory and every
+/// configured path, so the object refers to the first matching local file.
///
/// The name of the file this object should refer to.
CDFileClass::CDFileClass(char const *filename) :
- IsDisabled(false)
+ IsDisabled(false),
+ RequestedName(NULL)
{
CDFileClass::Set_Name(filename);
// memset (RawPath, 0, sizeof(RawPath));
@@ -66,11 +73,18 @@ CDFileClass::CDFileClass(char const *filename) :
/// Use Set_Name to give the object a file to work with before trying to open it.
///
CDFileClass::CDFileClass(void) :
- IsDisabled(false)
+ IsDisabled(false),
+ RequestedName(NULL)
{
}
+CDFileClass::~CDFileClass(void)
+{
+ Capture_Name(NULL);
+}
+
+
/***********************************************************************************************
* CDFileClass::Open -- Opens the file object -- with path search. *
* *
@@ -89,66 +103,14 @@ CDFileClass::CDFileClass(void) :
*=============================================================================================*/
int CDFileClass::Open(int rights)
{
- return(BASECLASS::Open(rights));
-}
-
-
-///
-/// Adds a list of directories to search when a file is not in the current directory.
-/// The list is written the way DOS wrote a PATH -- entries separated by semicolons, with
-/// or without a trailing backslash. Each entry is appended to the search chain in the
-/// order given, so the first directory named is the first one tried.
-///
-/// The semicolon separated list of directories to add.
-/// int; Zero if at least one directory was added, or 1 if the list held none.
-int CDFileClass::Set_Search_Drives(char * pathlist)
-{
- bool found = false;
-
- /*
- ** If there is no pathlist to add, then just return.
- */
- if (!pathlist) return(0);
-
- char *copy_pathlist = strdup(pathlist);
-
- char const * ptr = strtok(copy_pathlist, ";");
- while (ptr != NULL) {
- if (strlen(ptr) > 0) {
-
- char path[MAX_PATH]; // Working path buffer.
-
- /*
- ** Fixup the path to be legal. Legal is defined as all that is necessary to
- ** create a pathname is to append the actual filename submitted to the
- ** file system. This means that it must have either a trailing ':' or '\'
- ** character.
- */
- strcpy(path, ptr);
- switch (path[strlen(path)-1]) {
- case ':':
- case '\\':
- break;
-
- default:
- strcat(path, "\\");
- break;
- }
-
- found = true;
- Add_Search_Drive(path);
- }
-
- /*
- ** Find the next path string and resubmit.
- */
- ptr = strtok(NULL, ";");
+ // A file being written belongs to the player, so it is opened where the player's own
+ // files are kept rather than wherever a copy happened to be found. What a deployment
+ // ships is read from and never written over.
+ if ((rights & WRITE) != 0) {
+ Point_At_Own_Copy();
}
- free(copy_pathlist);
-
- if (!found) return(1);
- return(0);
+ return(BASECLASS::Open(rights));
}
@@ -166,7 +128,7 @@ int CDFileClass::Set_Search_Drives(char * pathlist)
* HISTORY: *
* 5/22/96 10:12AM ST : Created *
*=============================================================================================*/
-void CDFileClass::Add_Search_Drive(char *path)
+void CDFileClass::Add_Search_Drive(char const * path)
{
SearchDriveType *srch; // Working pointer to path object.
/*
@@ -196,10 +158,135 @@ void CDFileClass::Add_Search_Drive(char *path)
}
+///
+/// Records where this player's own files are kept.
+/// A file the game writes, creates or deletes goes here, and a file it reads is looked for
+/// here before anywhere else. Passing nothing puts the game back to keeping everything
+/// together in the directory it is run from.
+///
+/// The directory to keep the player's own files in.
+void CDFileClass::Set_User_Path(char const * path)
+{
+ UserPath.clear();
+
+ if (path == NULL || *path == '\0') return;
+
+ UserPath = path;
+
+ switch (UserPath[UserPath.length()-1]) {
+ case ':':
+ case '/':
+ case '\\':
+ break;
+
+ default:
+ UserPath += '\\';
+ break;
+ }
+}
+
+
+char const * CDFileClass::User_Path(void)
+{
+ return(UserPath.empty() ? NULL : UserPath.c_str());
+}
+
+
+///
+/// Reports whether a name already carries a directory of its own.
+/// Such a name has said where it goes, so neither the search nor the player's own directory
+/// touches it. The characters are the ones a directory is allowed to end with.
+///
+/// The name to examine.
+/// bool; Does the name carry a directory?
+bool CDFileClass::Has_Directory(char const * filename)
+{
+ return(filename != NULL && strpbrk(filename, "\\/:") != NULL);
+}
+
+
+///
+/// Works out where a file belongs once it is the player's own.
+///
+/// The name the game asked for.
+/// Receives the pathname when one can be built.
+/// The size of that buffer.
+/// bool; Was a pathname built? It fails when the player has no directory of their
+/// own, when the caller has already named one, or when the two will not make one pathname.
+bool CDFileClass::User_Path_For(char const * filename, char * buffer, int size)
+{
+ if (UserPath.empty() || filename == NULL) return(false);
+ if (Has_Directory(filename)) return(false);
+ if ((int)(UserPath.length() + strlen(filename)) >= size) return(false);
+
+ strcpy(buffer, UserPath.c_str());
+ strcat(buffer, filename);
+ return(true);
+}
+
+
+///
+/// Keeps a copy of the name the game asked for.
+/// The copy is this object's own, so that the name survives the object being pointed at a
+/// copy found elsewhere, and survives a mixfile lookup writing over the name in place.
+///
+/// The name to keep, or NULL to let go of the one kept.
+/// The kept copy, which lasts until the next name is kept.
+char const * CDFileClass::Capture_Name(char const * filename)
+{
+ char * captured = (filename != NULL) ? strdup(filename) : NULL;
+
+ if (RequestedName != NULL) {
+ free((char *)RequestedName);
+ }
+ RequestedName = captured;
+
+ return(RequestedName);
+}
+
+
+///
+/// Points the object at the file this player's own game owns, where a write and a delete
+/// both belong. Worked out from the name that was asked for rather than from the one the
+/// object carries, so that it lands in the same place however often it is done.
+///
+void CDFileClass::Point_At_Own_Copy(void)
+{
+ if (IsDisabled || RequestedName == NULL) return;
+
+ char path[_MAX_PATH];
+
+ if (User_Path_For(RequestedName, path, sizeof(path))) {
+ BASECLASS::Set_Name(path);
+ } else {
+ BASECLASS::Set_Name(RequestedName);
+ }
+}
+
+
+///
+/// Reports the search path at a position in the chain, counting from zero in the order the
+/// paths are tried. This is how a scan covers the same folders a file open would.
+///
+/// The position in the search chain.
+/// The path at that position, or NULL once the end of the chain is passed.
+char const * CDFileClass::Search_Path(int index)
+{
+ SearchDriveType const * srch = First;
+
+ while (srch != NULL && index > 0) {
+ srch = (SearchDriveType const *)srch->Next;
+ index--;
+ }
+
+ return(srch != NULL ? srch->Path : NULL);
+}
+
+
/***********************************************************************************************
* CDFileClass::Clear_Search_Drives -- Removes all record of a search path. *
* *
- * Use this routine to clear out any previous path(s) set with Set_Search_Drives() *
+ * Use this routine to clear out any previous path(s) set with Add_Search_Drive() *
* function. *
* *
* INPUT: none *
@@ -239,13 +326,30 @@ void CDFileClass::Clear_Search_Drives(void)
/// The selected file name, including a configured path when one supplies the match.
char const * CDFileClass::Set_Name(char const *filename)
{
+ // Kept before anything else, because the name the object ends up carrying records where
+ // a copy was found, and a write has to go back to what was asked for.
+ filename = Capture_Name(filename);
+
+ // A file this player's own game wrote is the one that answers, whatever a deployment
+ // ships under the same name.
+ if (!IsDisabled) {
+ char path[_MAX_PATH];
+
+ if (User_Path_For(filename, path, sizeof(path))) {
+ BASECLASS::Set_Name(path);
+ if (BASECLASS::Is_Available()) {
+ return(File_Name());
+ }
+ }
+ }
+
/*
** Try to find the file in the current directory first. If it can be found, then
** just return with the normal file name setting process. Do the same if there is
** no multi-drive search path.
*/
BASECLASS::Set_Name(filename);
- if (IsDisabled || !First || BASECLASS::Is_Available()) return(File_Name());
+ if (IsDisabled || !First || filename == NULL || BASECLASS::Is_Available()) return(File_Name());
/*
** Attempt to find the file first. Check the current directory. If not found there, then
@@ -257,17 +361,22 @@ char const * CDFileClass::Set_Name(char const *filename)
while (srch) {
char path[_MAX_PATH];
- /*
- ** Build a pathname to search for.
- */
- strcpy(path, srch->Path);
- strcat(path, filename);
+ // A directory and a name that will not make one pathname between them are passed
+ // over rather than truncated into a different name.
+ if (strlen(srch->Path) + strlen(filename) < sizeof(path)) {
- // Check this path. Is_Available returns false when the file cannot be opened,
- // allowing the search to continue with the next configured path.
- BASECLASS::Set_Name(path);
- if (BASECLASS::Is_Available()) {
- return(File_Name());
+ /*
+ ** Build a pathname to search for.
+ */
+ strcpy(path, srch->Path);
+ strcat(path, filename);
+
+ // Check this path. Is_Available returns false when the file cannot be opened,
+ // allowing the search to continue with the next configured path.
+ BASECLASS::Set_Name(path);
+ if (BASECLASS::Is_Available()) {
+ return(File_Name());
+ }
}
/*
@@ -323,10 +432,10 @@ int CDFileClass::Open(char const *filename, int rights)
/*
** If writing is requested, then multiple drive searching is not performed.
*/
- if (IsDisabled || rights == WRITE) {
+ if (IsDisabled || (rights & WRITE) != 0) {
- BASECLASS::Set_Name( filename );
- return( BASECLASS::Open( rights ) );
+ BASECLASS::Set_Name( Capture_Name(filename) );
+ return( CDFileClass::Open( rights ) );
}
/*
@@ -338,6 +447,20 @@ int CDFileClass::Open(char const *filename, int rights)
}
+///
+/// Deletes this player's own copy of the file.
+/// What a deployment ships is read from and never removed, so a file thrown away here falls
+/// back to the copy it shipped with rather than disappearing altogether.
+///
+/// int; Was a file deleted?
+int CDFileClass::Delete(void)
+{
+ Point_At_Own_Copy();
+
+ return(BASECLASS::Delete());
+}
+
+
HANDLE FindFileHandle = INVALID_HANDLE_VALUE;
///
diff --git a/code/cdfile.h b/code/cdfile.h
index 9c64d85d..9f1b34a1 100644
--- a/code/cdfile.h
+++ b/code/cdfile.h
@@ -36,12 +36,14 @@
/*
* This class is derived from the BufferIOFileClass, and adds the ability to search across
- * several directories for a file. The current directory is examined first, and if the file
- * is not there then every directory in the search list is tried in turn. A file being
- * opened for writing is only ever looked for in the current directory.
+ * several directories for a file. A file this player's own game wrote is found first, then
+ * the current directory, then every directory in the search list in turn.
*
- * The search order is whatever order the directories were handed to Set_Search_Drives(),
- * which takes them in the same semicolon separated form the DOS PATH variable used.
+ * A file opened for writing, created or deleted is not searched for at all. It resolves to
+ * the player's own directory, so that what a deployment ships is read from and never written
+ * over. A name that already carries a directory of its own is left exactly as it was given.
+ *
+ * The search order is whatever order the directories were handed to Add_Search_Drive().
*/
class CDFileClass : public BufferIOFileClass
{
@@ -50,17 +52,25 @@ class CDFileClass : public BufferIOFileClass
public:
CDFileClass(char const *filename);
CDFileClass(void);
- virtual ~CDFileClass(void) override {};
+ virtual ~CDFileClass(void) override;
+
+ // A file object owns the name it captured, so it is not copied.
+ CDFileClass(CDFileClass const & file) = delete;
+ CDFileClass & operator = (CDFileClass const & file) = delete;
virtual char const * Set_Name(char const *filename) override;
virtual int Open(char const *filename, int rights=READ) override;
virtual int Open(int rights=READ) override;
+ virtual int Delete(void) override;
void Searching(int on) {IsDisabled = !on;};
- static int Set_Search_Drives(char * pathlist);
- static void Add_Search_Drive(char *path);
+ static void Add_Search_Drive(char const * path);
static void Clear_Search_Drives(void);
+ static char const * Search_Path(int index);
+
+ static void Set_User_Path(char const * path);
+ static char const * User_Path(void);
static bool Find_First_File(char *buffer);
static bool Find_Next_File(char *buffer);
@@ -68,11 +78,22 @@ class CDFileClass : public BufferIOFileClass
private:
+ char const * Capture_Name(char const * filename);
+ void Point_At_Own_Copy(void);
+
+ static bool Has_Directory(char const * filename);
+ static bool User_Path_For(char const * filename, char * buffer, int size);
+
/*
** Is multi-drive searching disabled for this file object?
*/
bool IsDisabled;
+ // The name the game asked for. Every later decision is made from this rather than
+ // from the name the object carries, because that one records where a copy was
+ // found and answers a different question.
+ char const * RequestedName;
+
/*
** This is the control record for each of the drives specified in the search
** path. There can be many such search paths available.
diff --git a/code/conquer.cpp b/code/conquer.cpp
index af89e70f..03b90bca 100644
--- a/code/conquer.cpp
+++ b/code/conquer.cpp
@@ -80,6 +80,7 @@
#include "data.h"
#include "dbgprint.h"
#include "dsaudio.h"
+#include "gamedirs.h"
#include "gamedlg.h"
#include "globals.h"
#include "houstype.h"
@@ -1170,6 +1171,13 @@ unsigned int Disk_Space_Available(void)
DebugString("Checking available disk space\n");
+ /*
+ * Measured where the game's saved games will actually go, which is not the current
+ * directory once a player has one of their own.
+ */
+ std::string const user_directory = User_File_Write_Name("");
+ LPCTSTR const disk = user_directory.empty() ? NULL : user_directory.c_str();
+
// Get the free disk space on the drive.
// NOTE IML: For Win'95, must query for support for GetDiskFreeSpaceEx before using it - otherwise use GetDiskFreeSpace().
HINSTANCE kernel = GetModuleHandle("KERNEL32.DLL");
@@ -1180,7 +1188,7 @@ unsigned int Disk_Space_Available(void)
DebugString("Using GetDiskFreeSpaceEx\n");
// NOTE: This function uses GetDiskFreeSpaceEx() and therefore assumes Win '95 OSR2 or greater.
- if (!getfreediskspaceex(NULL, &freebytecount, &totalbytecount, &totalfreebytecount)) {
+ if (!getfreediskspaceex(disk, &freebytecount, &totalbytecount, &totalfreebytecount)) {
DWORD const error = GetLastError();
DebugString("GetDiskFreeSpaceEx failed with error code %d - %s\n", error, Last_Error_Text(error));
} else {
@@ -1203,7 +1211,7 @@ unsigned int Disk_Space_Available(void)
// QUESTION: SDK docs say that values returned by this function are erroneous if partition > 2Gb.
// Does that mean that the partition is guaranteed to be <= 2Gb if Ex is not available?
- if (GetDiskFreeSpace(NULL, §orspercluster, &bytespersector, &freeclustercount, &totalclustercount)) {
+ if (GetDiskFreeSpace(disk, §orspercluster, &bytespersector, &freeclustercount, &totalclustercount)) {
diskspace = ((sectorspercluster * bytespersector) / 1024) * freeclustercount;
DebugString("Free disk space is %d Mb\n", diskspace / 1024);
return(diskspace);
diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp
new file mode 100644
index 00000000..ae3aeaf2
--- /dev/null
+++ b/code/gamedirs.cpp
@@ -0,0 +1,387 @@
+/*******************************************************************************
+ * 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 "gamedirs.h"
+
+#include "cdfile.h"
+#include "dbgprint.h"
+#include "ini.h"
+#include "rawfile.h"
+
+#include
+
+/*
+ * The directories the command line named. Empty means the game's own directory, so an
+ * installation that names neither resolves every file exactly as it always did.
+ */
+static std::string DataDirectory;
+static std::string UserDirectory;
+
+/*
+ * The folders a deployment's files are looked for in when no configuration names any. A
+ * configuration's list replaces this rather than adding to it.
+ */
+static char const * const DefaultSearchFolders = "INI,MIX,Maps";
+
+static char const * const ConfigName = "OPENTS.INI";
+
+/*
+ * The folders the configuration itself is looked for in, relative to the data directory.
+ */
+static char const * const ConfigProbes[] = {"", "INI\\", "MIX\\"};
+
+
+static std::string Trim_Path(std::string const & path)
+{
+ std::string::size_type first = path.find_first_not_of(" \t");
+ if (first == std::string::npos) {
+ return(std::string());
+ }
+
+ std::string::size_type last = path.find_last_not_of(" \t");
+ return(path.substr(first, last - first + 1));
+}
+
+
+///
+/// Puts a directory in the form a file name can simply be appended to, which is what the
+/// search chain has always expected of one.
+///
+/// The directory to terminate.
+/// The directory, ending in a separator.
+static std::string Terminate_Path(std::string const & path)
+{
+ if (path.empty()) {
+ return(path);
+ }
+
+ switch (path[path.length() - 1]) {
+ case '\\':
+ case '/':
+ case ':':
+ return(path);
+
+ default:
+ return(path + '\\');
+ }
+}
+
+
+static bool Is_Same_Path(std::string const & left, std::string const & right)
+{
+ return(_stricmp(left.c_str(), right.c_str()) == 0);
+}
+
+
+static bool Is_Registered(std::string const & path)
+{
+ for (int index = 0; ; index++) {
+ char const * registered = CDFileClass::Search_Path(index);
+ if (registered == NULL) {
+ return(false);
+ }
+
+ if (Is_Same_Path(registered, path)) {
+ return(true);
+ }
+ }
+}
+
+
+///
+/// Reports where a deployment's files are, which is the data directory when one is named
+/// and the game's own directory otherwise. Everything a configuration names is relative
+/// to it.
+///
+/// The directory a deployment's files are kept in.
+static std::string Data_Home(void)
+{
+ return(DataDirectory);
+}
+
+
+/*
+ * What went wrong with the directories, kept for whoever is in a position to tell the
+ * player. Reporting it is not this module's business, since it has no window to report in.
+ */
+static std::string DirectoryError;
+
+
+static void Report_Directory_Error(char const * what, std::string const & path)
+{
+ char message[MAX_PATH + 128];
+
+ sprintf(message, "The %s directory cannot be used:\n\n%s", what, path.c_str());
+ DirectoryError = message;
+
+ DebugString("[GameDirs] %s directory unusable: %s.\n", what, path.c_str());
+ printf("The %s directory cannot be used: %s\n", what, path.c_str());
+}
+
+
+char const * Game_Directory_Error(void)
+{
+ return(DirectoryError.c_str());
+}
+
+
+static bool Is_Directory(std::string const & path)
+{
+ DWORD attributes = GetFileAttributes(path.c_str());
+
+ return(attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0);
+}
+
+
+void Set_Data_Directory(char const * path)
+{
+ DataDirectory = Terminate_Path(Trim_Path(path != NULL ? path : ""));
+}
+
+
+void Set_User_Directory(char const * path)
+{
+ UserDirectory = Terminate_Path(Trim_Path(path != NULL ? path : ""));
+
+ // The file layer places and finds the player's own files; this is the only thing that
+ // tells it where they go.
+ CDFileClass::Set_User_Path(UserDirectory.c_str());
+}
+
+
+///
+/// Splits a configured folder list into the folders it names.
+/// Folders are separated by commas, since a semicolon opens a comment in the file the list
+/// is written in. They are returned in the order written, with the whitespace around them
+/// dropped and a trailing separator supplied, and a folder named twice is kept once.
+///
+/// The comma separated list of folders.
+/// The folders named, in the order they were written.
+std::vector Parse_Search_Folders(char const * list)
+{
+ std::vector folders;
+
+ if (list == NULL) {
+ return(folders);
+ }
+
+ std::string const text = list;
+ std::string::size_type start = 0;
+
+ while (start <= text.length()) {
+ std::string::size_type end = text.find(',', start);
+ if (end == std::string::npos) {
+ end = text.length();
+ }
+
+ /*
+ * Comparing the folders only once they are in the form they will be searched in
+ * keeps the same folder written two ways from being searched twice.
+ */
+ std::string const folder = Terminate_Path(Trim_Path(text.substr(start, end - start)));
+
+ /*
+ * The game's own directory is examined before any of these, so naming it adds
+ * nothing. Naming only it is how a deployment asks for no other folder, an entry
+ * with nothing after the equals sign being one an INI file cannot carry.
+ */
+ if (folder == ".\\" || folder == "./") {
+ if (end == text.length()) {
+ break;
+ }
+ start = end + 1;
+ continue;
+ }
+
+ if (!folder.empty()) {
+ bool present = false;
+ for (std::string const & existing : folders) {
+ if (Is_Same_Path(existing, folder)) {
+ present = true;
+ break;
+ }
+ }
+
+ if (!present) {
+ folders.push_back(folder);
+ }
+ }
+
+ if (end == text.length()) {
+ break;
+ }
+ start = end + 1;
+ }
+
+ return(folders);
+}
+
+
+///
+/// Makes the directories the command line named usable.
+/// The user directory is created when it is not there yet, because it is the game's own to
+/// write. A named data directory must already exist, a missing one being reported here
+/// rather than as the missing files it would become later.
+///
+/// bool; Can the game run with the directories it was given?
+bool Apply_Game_Directories(void)
+{
+ if (!UserDirectory.empty()) {
+ if (!Is_Directory(UserDirectory) && !CreateDirectory(UserDirectory.c_str(), NULL)) {
+ Report_Directory_Error("user", UserDirectory);
+ return(false);
+ }
+
+ DebugString("[GameDirs] User directory is %s.\n", UserDirectory.c_str());
+ }
+
+ if (!DataDirectory.empty()) {
+ if (!Is_Directory(DataDirectory)) {
+ Report_Directory_Error("data", DataDirectory);
+ return(false);
+ }
+
+ CDFileClass::Add_Search_Drive(DataDirectory.c_str());
+ DebugString("[GameDirs] Data directory is %s.\n", DataDirectory.c_str());
+ }
+
+ return(true);
+}
+
+
+///
+/// Reads the deployment's configuration and installs the folders it searches.
+/// The file is read from the disk rather than through the game's file system, so a
+/// deployment cannot hide the description of its own layout inside an archive.
+///
+void Init_Search_Folders(void)
+{
+ std::string const home = Data_Home();
+ std::string list = DefaultSearchFolders;
+
+ for (char const * probe : ConfigProbes) {
+ std::string const name = home + probe + ConfigName;
+ RawFileClass file(name.c_str());
+
+ if (!file.Is_Available()) {
+ continue;
+ }
+
+ INIClass ini;
+ ini.Load(file);
+
+ if (ini.Is_Present("Paths", "SearchPaths")) {
+ char buffer[2048];
+
+ int length = ini.Get_String("Paths", "SearchPaths", "", buffer, sizeof(buffer));
+ if (length >= (int)sizeof(buffer) - 1) {
+ DebugString("[GameDirs] %s names more folders than %s can hold.\n", name.c_str(), "SearchPaths");
+ }
+
+ list = buffer;
+ }
+
+ DebugString("[GameDirs] Read %s.\n", name.c_str());
+ break;
+ }
+
+ for (std::string const & folder : Parse_Search_Folders(list.c_str())) {
+ std::string const path = home + folder;
+
+ if (!Is_Registered(path)) {
+ CDFileClass::Add_Search_Drive(path.c_str());
+ DebugString("[GameDirs] Searching %s.\n", path.c_str());
+ }
+ }
+}
+
+
+std::string User_File_Write_Name(char const * filename)
+{
+ if (UserDirectory.empty()) {
+ return(filename);
+ }
+
+ return(UserDirectory + filename);
+}
+
+
+static void Scan_Folder(char const * prefix, char const * pattern, std::vector & names)
+{
+ std::string const search = std::string(prefix) + pattern;
+
+ WIN32_FIND_DATA block;
+ HANDLE handle = FindFirstFile(search.c_str(), &block);
+ if (handle == INVALID_HANDLE_VALUE) {
+ return;
+ }
+
+ do {
+ if ((block.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_HIDDEN|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_TEMPORARY)) != 0) {
+ continue;
+ }
+
+ bool present = false;
+ for (std::string const & existing : names) {
+ if (Is_Same_Path(existing, block.cFileName)) {
+ present = true;
+ break;
+ }
+ }
+
+ if (!present) {
+ names.push_back(block.cFileName);
+ }
+ } while (FindNextFile(handle, &block));
+
+ FindClose(handle);
+}
+
+
+///
+/// Finds the files matching a pattern in every directory the game reads from.
+/// A name held by more than one directory is reported once, and opening that name afterwards
+/// lands on the same file this scan saw, because both walk the directories in the same order.
+/// The names come back sorted, so what the game makes of them does not depend on the order
+/// a file system happened to hand them over in.
+///
+/// The wildcard pattern to match, with no directory attached.
+/// The matching file names, without the directory they were found in.
+std::vector Search_Files(char const * pattern)
+{
+ std::vector names;
+
+ /*
+ * Asked of the file layer rather than kept here, so that a scan and an open are reading
+ * the very same directory.
+ */
+ char const * user = CDFileClass::User_Path();
+ if (user != NULL) {
+ Scan_Folder(user, pattern, names);
+ }
+
+ Scan_Folder("", pattern, names);
+
+ for (int index = 0; ; index++) {
+ char const * path = CDFileClass::Search_Path(index);
+ if (path == NULL) {
+ break;
+ }
+
+ Scan_Folder(path, pattern, names);
+ }
+
+ std::sort(names.begin(), names.end(), [](std::string const & left, std::string const & right) {
+ return(_stricmp(left.c_str(), right.c_str()) < 0);
+ });
+
+ return(names);
+}
diff --git a/code/gamedirs.h b/code/gamedirs.h
new file mode 100644
index 00000000..40068f9b
--- /dev/null
+++ b/code/gamedirs.h
@@ -0,0 +1,41 @@
+/*******************************************************************************
+ * 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
+#include
+
+/*
+ * The directories the game keeps its files in. The data directory holds what a deployment
+ * ships and is never written to. The user directory holds what a player's game writes.
+ * Either one unnamed means the game's own directory, which is where everything lived when
+ * a game was one directory belonging to one person.
+ */
+
+void Set_Data_Directory(char const * path);
+void Set_User_Directory(char const * path);
+
+bool Apply_Game_Directories(void);
+void Init_Search_Folders(void);
+
+/*
+ * What stopped the directories being used, for whoever has a window to say it in.
+ */
+char const * Game_Directory_Error(void);
+
+/*
+ * Where a file the game itself writes belongs. The file classes place their own files, so
+ * this is for the few things that never reach them: structured storage, and the directory
+ * searches and disk queries the game makes of Windows directly.
+ */
+std::string User_File_Write_Name(char const * filename);
+
+std::vector Parse_Search_Folders(char const * list);
+std::vector Search_Files(char const * pattern);
diff --git a/code/init.cpp b/code/init.cpp
index ed66b0c6..cdca24b8 100644
--- a/code/init.cpp
+++ b/code/init.cpp
@@ -109,6 +109,7 @@
#include "expand.h"
#include "factory.h"
#include "fog.h"
+#include "gamedirs.h"
#include "gamedlg.h"
#include "getcpu.h"
#include "globals.h"
@@ -605,36 +606,18 @@ static BOOL CALLBACK Rules_Choice_Dialog_Proc(HWND window, UINT message, WPARAM
void Init_Campaigns(void)
{
bool found = false;
- WIN32_FIND_DATA fd;
- HANDLE handle = FindFirstFile("BATTLE*.INI", &fd);
- while (handle != INVALID_HANDLE_VALUE) {
- if ((fd.dwFileAttributes & (FILE_ATTRIBUTE_HIDDEN|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_TEMPORARY)) == false) {
-
- const char * name = &fd.cAlternateFileName[0];
- if (strlen(name) == 0) {
- name = &fd.cFileName[0];
- }
-
- CCFileClass file(name);
- CCINIClass * ini = new CCINIClass;
- ini->Load(file, false);
-
- if (stricmp(name, "BATTLE.INI") == 0) {
- found = true;
- }
+ for (std::string const & name : Search_Files("BATTLE*.INI")) {
+ CCFileClass file(name.c_str());
+ CCINIClass * ini = new CCINIClass;
+ ini->Load(file, false);
- Read_Battle_INI(*ini);
- delete ini;
+ if (stricmp(name.c_str(), "BATTLE.INI") == 0) {
+ found = true;
}
- if (FindNextFile(handle, &fd) == 0) {
- break;
- }
- }
-
- if (handle != INVALID_HANDLE_VALUE) {
- FindClose(handle);
+ Read_Battle_INI(*ini);
+ delete ini;
}
if (!found) {
@@ -851,39 +834,21 @@ static bool Init_Rules(void)
DynamicVectorClass Rules;
bool found = false;
- WIN32_FIND_DATA fd;
- HANDLE handle = FindFirstFile("RULE*.INI", &fd);
-
- while (handle != INVALID_HANDLE_VALUE) {
- if ((fd.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_HIDDEN|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_TEMPORARY)) == 0) {
- const char * name = &fd.cAlternateFileName[0];
-
- if (*name == '\0') {
- name = &fd.cFileName[0];
- }
-
- CCFileClass file(name);
- CCINIClass * rule = new CCINIClass;
- rule->Load(file, false);
+ for (std::string const & name : Search_Files("RULE*.INI")) {
+ CCFileClass file(name.c_str());
+ CCINIClass * rule = new CCINIClass;
- if (stricmp(name, "RULES.INI") == 0) {
- found = true;
- Rules.Add_Head(rule);
- } else {
- Rules.Add(rule);
- }
- }
+ rule->Load(file, false);
- if (FindNextFile(handle, &fd) == 0) {
- break;
+ if (stricmp(name.c_str(), "RULES.INI") == 0) {
+ found = true;
+ Rules.Add_Head(rule);
+ } else {
+ Rules.Add(rule);
}
}
- if (handle != INVALID_HANDLE_VALUE) {
- FindClose(handle);
- }
-
if (!found) {
CCFileClass file("RULES.INI");
CCINIClass * rule = new CCINIClass;
@@ -1731,6 +1696,11 @@ bool Parse_Command_Line(int argc, char * argv[])
}
}
*dest = '\0';
+
+ // Matching is done on an upper case copy, so that an option carrying a directory
+ // can still take it in the case it was written.
+ char original[512];
+ strcpy(original, arg_string);
strupr(string);
/*
@@ -1794,11 +1764,13 @@ bool Parse_Command_Line(int argc, char * argv[])
#endif
- /*
- ** File search path override.
- */
- if (strstr(string, "-CD")) {
- CCFileClass::Set_Search_Drives(&string[3]);
+ if (strnicmp(string, "-DATADIR=", strlen("-DATADIR=")) == 0) {
+ Set_Data_Directory(&original[strlen("-DATADIR=")]);
+ continue;
+ }
+
+ if (strnicmp(string, "-USERDIR=", strlen("-USERDIR=")) == 0) {
+ Set_User_Directory(&original[strlen("-USERDIR=")]);
continue;
}
@@ -2391,7 +2363,9 @@ static void Init_Expand_Mixfiles(void)
for (index = 99; index >= 0; index--) {
sprintf(name, "EXPAND%02d.MIX", index);
- if (RawFileClass(name).Is_Available()) {
+ // Searched for as a loose file wherever the game's files are kept, but never
+ // inside another archive.
+ if (CDFileClass(name).Is_Available()) {
expand = new MFCD(name, &FastKey);
assert(expand != NULL);
@@ -2427,7 +2401,9 @@ static void Init_Patch_Mixfiles(void)
{
MFCD * expand;
- if (RawFileClass("PATCH.MIX").Is_Available()) {
+ // As with the expansion archives, found loose in any of the game's folders but never
+ // inside another archive.
+ if (CDFileClass("PATCH.MIX").Is_Available()) {
expand = new MFCD("PATCH.MIX", &FastKey);
assert(expand != NULL);
@@ -2534,8 +2510,6 @@ static bool Init_Bootstrap_Mixfiles(void)
*=============================================================================================*/
static bool Init_Secondary_Mixfiles(void)
{
- char name[_MAX_PATH];
-
/*
** Inform the file system of the various MIX files.
*/
@@ -2552,26 +2526,29 @@ static bool Init_Secondary_Mixfiles(void)
MFCD * mix;
- strcpy(name, "MAPS*.MIX");
+ {
+ std::vector const maps = Search_Files("MAPS*.MIX");
- if (CDFileClass::Find_First_File(name) == true) {
+ for (unsigned int index = 0; index < maps.size(); index++) {
+ char const * found = maps[index].c_str();
+ DebugStringNoPrefix(" %s", found);
- DebugStringNoPrefix(" %s", name);
- MapsMix = new MFCD(name, &FastKey);
- assert(MapsMix != NULL);
+ // The first archive found is the game's own; the rest are whatever else is
+ // installed alongside it.
+ if (index == 0) {
+ MapsMix = new MFCD(found, &FastKey);
+ assert(MapsMix != NULL);
+ continue;
+ }
- while (CDFileClass::Find_Next_File(name) == true) {
- DebugStringNoPrefix(" %s", name);
- mix = new MFCD(name, &FastKey);
- assert(mix != NULL);
+ mix = new MFCD(found, &FastKey);
+ assert(mix != NULL);
- if (mix != NULL) {
- MapsMixLocal.Add(mix);
- }
+ if (mix != NULL) {
+ MapsMixLocal.Add(mix);
}
-
- CDFileClass::Find_Close();
}
+ }
#ifndef _DEMO
@@ -2640,26 +2617,27 @@ static bool Init_Secondary_Mixfiles(void)
ScoresPresent = true;
Theme.Scan();
- strcpy(name, "MOVIES*.MIX");
+ {
+ std::vector const movies = Search_Files("MOVIES*.MIX");
- if (CDFileClass::Find_First_File(name) == true) {
+ for (unsigned int index = 0; index < movies.size(); index++) {
+ char const * found = movies[index].c_str();
+ DebugStringNoPrefix(" %s", found);
- DebugStringNoPrefix(" %s", name);
- MoviesMix = new MFCD(name, &FastKey);
- assert(MoviesMix != NULL);
+ if (index == 0) {
+ MoviesMix = new MFCD(found, &FastKey);
+ assert(MoviesMix != NULL);
+ continue;
+ }
- while (CDFileClass::Find_Next_File(name) == true) {
- DebugStringNoPrefix(" %s", name);
- mix = new MFCD(name, &FastKey);
- assert(mix != NULL);
+ mix = new MFCD(found, &FastKey);
+ assert(mix != NULL);
- if (mix != NULL) {
- MoviesMixLocal.Add(mix);
- }
+ if (mix != NULL) {
+ MoviesMixLocal.Add(mix);
}
-
- CDFileClass::Find_Close();
}
+ }
if (MoviesMix == NULL) {
return(false);
diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp
index 4e0bd607..9988e7d8 100644
--- a/code/loaddlg.cpp
+++ b/code/loaddlg.cpp
@@ -44,6 +44,7 @@
#include "campaign.h"
#include "conquer.h"
#include "data.h"
+#include "gamedirs.h"
#include "globals.h"
#include "houstype.h"
#include "init.h"
@@ -507,7 +508,7 @@ bool LoadOptionsClass::Dialog(void)
}
if (filename != NULL) {
- bool exists = RawFileClass(filename).Is_Available() == true;
+ bool exists = CDFileClass(filename).Is_Available() == true;
if (exists && WWMessageBox()._Process(TXT_CONFIRM_SAVE, 1, TXT_YES, TXT_NO, TXT_NONE))
State = STATE_PENDING;
else {
@@ -605,6 +606,25 @@ 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 *
* *
@@ -663,38 +683,28 @@ void LoadOptionsClass::Fill_List(HWND window)
/*
** Find all savegame files
*/
- HANDLE hFind = FindFirstFile(buffer, &ff);
fdata = NULL;
- if (hFind != INVALID_HANDLE_VALUE) {
- while (true) {
- if ((ff.dwFileAttributes & (FILE_ATTRIBUTE_TEMPORARY|FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_HIDDEN)) == 0) {
- 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;
- }
- }
+ for (std::string const & name : Search_Files(buffer)) {
+ if (!Find_Saved_Game(name.c_str(), &ff)) {
+ continue;
+ }
- /*
- ** Find the next file
- */
- if (!FindNextFile(hFind, &ff)) {
- break;
- }
+ if (fdata == NULL) {
+ fdata = new FileEntryClass;
}
- if (fdata != NULL) {
- delete fdata;
+ /*
+ ** get the game's info; if success, add it to the list
+ */
+ if (Read_File(fdata, &ff) == true) {
+ Files.Add(fdata);
+ fdata = NULL;
}
+ }
- FindClose(hFind);
+ if (fdata != NULL) {
+ delete fdata;
}
if (Files.Count() > 0) {
@@ -776,30 +786,22 @@ bool LoadOptionsClass::Files_Present(void)
sprintf(pattern, "*.%3s", Extension);
WIN32_FIND_DATAA find_data;
- HANDLE hFind = FindFirstFile(pattern, &find_data);
-
- if (hFind != INVALID_HANDLE_VALUE) {
- while (true) {
- if ((find_data.dwFileAttributes & (FILE_ATTRIBUTE_TEMPORARY|FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_HIDDEN)) == 0) {
- if (_stricmp(find_data.cFileName, NET_SAVE_FILE_NAME) != 0) {
- FileEntryClass entry;
- if (Read_File(&entry, &find_data) == true) {
- files_found = true;
- break;
- }
- }
- }
- /*
- ** Find the next file
- */
- if (!FindNextFile(hFind, &find_data)) {
- break;
- }
+ for (std::string const & name : Search_Files(pattern)) {
+ if (_stricmp(name.c_str(), NET_SAVE_FILE_NAME) == 0) {
+ continue;
+ }
+
+ if (!Find_Saved_Game(name.c_str(), &find_data)) {
+ continue;
}
- }
- FindClose(hFind);
+ FileEntryClass entry;
+ if (Read_File(&entry, &find_data) == true) {
+ files_found = true;
+ break;
+ }
+ }
return(files_found);
}
@@ -881,7 +883,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(file_name) == TRUE) {
+ if (DeleteFile(User_File_Write_Name(file_name).c_str()) == TRUE) {
return(true);
}
return(false);
diff --git a/code/netshare.cpp b/code/netshare.cpp
index 837319ed..4a4de038 100644
--- a/code/netshare.cpp
+++ b/code/netshare.cpp
@@ -1483,7 +1483,7 @@ void Receive_Random_Map_Preview(void)
}
DebugString("Loading the compressed preview image\n");
- RawFileClass file(preview_name);
+ CDFileClass file(preview_name);
int size = file.Size();
char * buffer = new char[size];
file.Read(buffer, size);
@@ -1632,7 +1632,7 @@ void Send_Preview_To_Guests(void)
DebugString("Compressed preview image is %d bytes\n", comp_size);
- RawFileClass file("Preview.bin");
+ CDFileClass file("Preview.bin");
if (file.Is_Available()) {
file.Delete();
}
diff --git a/code/obscure.cpp b/code/obscure.cpp
index cab40774..392a1d5b 100644
--- a/code/obscure.cpp
+++ b/code/obscure.cpp
@@ -108,6 +108,13 @@ int Obfuscate(char const * string)
if (((length+3) & 0x00FC) > maxlen) {
maxlen = ((length+3) & 0x00FC);
}
+
+ // Rounding a phrase that fills the buffer up to the next multiple of four would
+ // put its terminator one position past the end.
+ if (maxlen > (int)sizeof(buffer)-1) {
+ maxlen = (int)sizeof(buffer)-1;
+ }
+
int index;
for (index = length; index < maxlen; index++) {
buffer[index] = (char)('A' + ((('?' ^ buffer[index-length]) + index) % 26));
diff --git a/code/options.cpp b/code/options.cpp
index b1e51aa4..de479fdd 100644
--- a/code/options.cpp
+++ b/code/options.cpp
@@ -614,7 +614,7 @@ BOOL CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARA
ini.Put_Int("Hotkey", cmd->Get_Unique_Name(), key);
}
- RawFileClass file("Keyboard.ini");
+ CDFileClass file("Keyboard.ini");
ini.Save(file, false);
*retval = IDOK;
return(TRUE);
@@ -667,6 +667,8 @@ BOOL CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARA
if (HIWORD(wparam) == BN_CLICKED) {
if (WWMessageBox()._Process(TXT_RESET_HOTKEYS, IDOK, TXT_YES, TXT_NO, TXT_NONE, false) == 0) {
DebugString("Deleting users KEYBOARD.INI\n");
+ // Only the player's own file is discarded; the defaults a
+ // deployment ships are what the reset falls back on.
CCFileClass file("KEYBOARD.INI");
file.Delete();
Init_Hotkeys();
diff --git a/code/saveload.cpp b/code/saveload.cpp
index 4f940ecd..51c1bcc0 100644
--- a/code/saveload.cpp
+++ b/code/saveload.cpp
@@ -73,6 +73,7 @@
#include "enviro.h"
#include "factory.h"
#include "fog.h"
+#include "gamedirs.h"
#include "globals.h"
#include "goptions.h"
#include "houstype.h"
@@ -923,11 +924,11 @@ static bool Get_All(IStream *stream, bool save_net)
*=========================================================================*/
static bool Save_Game(const char *file_name, char const * descr)
{
- WCHAR name[64];
+ WCHAR name[MAX_PATH];
DebugString("\nSAVING GAME [%s - %s]\n", file_name, descr);
- MultiByteToWideChar(0,0, file_name, -1, name, sizeof(name)/sizeof(WCHAR));
+ MultiByteToWideChar(0,0, User_File_Write_Name(file_name).c_str(), -1, name, sizeof(name)/sizeof(WCHAR));
/*
** Open the file
@@ -1151,7 +1152,7 @@ bool Is_Multiplayer_Saving_Allowed(void)
*=========================================================================*/
bool Load_Game(const char *file_name)
{
- WCHAR name[64];
+ WCHAR name[MAX_PATH];
DebugString("\nLOADING GAME [%s]\n", file_name);
@@ -1179,7 +1180,9 @@ bool Load_Game(const char *file_name)
** Open the file
*/
IStoragePtr storage;
- MultiByteToWideChar(0,0,file_name, -1, name, (sizeof(name)/sizeof(WCHAR)));
+
+ // 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)));
if (FAILED(StgOpenStorage(name, 0, STGM_SHARE_DENY_WRITE, 0, 0, &storage))) {
return(false);
@@ -1323,9 +1326,10 @@ int Load_Misc_Values(IStream * stream)
bool Get_Savefile_Info(char const * name, SaveVersionInfo * info)
{
IStoragePtr storage;
- WCHAR wname[64];
+ WCHAR wname[MAX_PATH];
- MultiByteToWideChar(0, 0, name, -1, wname, sizeof(wname) / sizeof(WCHAR));
+ // 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));
HRESULT result = StgOpenStorage(wname, NULL, STGM_SHARE_EXCLUSIVE|STGM_READWRITE, NULL, 0, &storage);
if (FAILED(result)) {
diff --git a/code/sendfile.cpp b/code/sendfile.cpp
index e4f0a9d6..a8d65664 100644
--- a/code/sendfile.cpp
+++ b/code/sendfile.cpp
@@ -39,6 +39,7 @@
#include "always.h"
#include "conquer.h"
+#include "cdfile.h"
#include "dbgprint.h"
#include "globals.h"
#include "ini.h"
@@ -194,7 +195,7 @@ bool Receive_Remote_File ( char *file_name, unsigned int file_length, bool show_
DebugString("Receiving download of file %s\n", (const char *)file_name);
- RawFileClass save_file (file_name);
+ CDFileClass save_file (file_name);
/*
** If the file already exists then delete it and re-create it.
diff --git a/code/session.cpp b/code/session.cpp
index 550f16f3..f9d51198 100644
--- a/code/session.cpp
+++ b/code/session.cpp
@@ -56,6 +56,7 @@
#include "conquer.h"
#include "data.h"
#include "dbgprint.h"
+#include "gamedirs.h" // for Search_Files.
#include "globals.h"
#include "ipxmgr.h"
#include "language\language.h"
@@ -463,9 +464,6 @@ void SessionClass::Read_MultiPlayer_Settings(void)
Rule->Do_HouseTypes(*RuleINI);
- // Create filename and read the file.
- CCFileClass file(CONFIG_FILE_NAME);
-
// Get the player's last-used Handle
ConfigINI.Get_String("MultiPlayer", "Handle", Fetch_String(TXT_NONAME), Handle, sizeof(Handle));
@@ -604,7 +602,7 @@ bool SessionClass::Log_To_File(FILE *out)
*=========================================================================*/
void SessionClass::Write_MultiPlayer_Settings(void)
{
- RawFileClass file(CONFIG_FILE_NAME);
+ CDFileClass file(CONFIG_FILE_NAME);
{
// Save the player's last-used Handle & Color
ConfigINI.Put_Int("MultiPlayer", "Color", (int)PrefColor);
@@ -700,38 +698,20 @@ void SessionClass::Read_Scenario_Descriptions(void)
}
}
- WIN32_FIND_DATA block;
- HANDLE handle = FindFirstFile("*.PKT", &block);
- while (handle != INVALID_HANDLE_VALUE) {
- if ((block.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_HIDDEN|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_TEMPORARY)) == 0) {
- char const * name = &block.cAlternateFileName[0];
- if (strlen(name) == 0) name = &block.cFileName[0];
-//Mono_Printf("Found file '%s'.\n", block.cAlternateFileName);
-//Mono_Printf("Found file '%s'.\n", block.cFileName);
-//DebugString("Found file '%s'.\n", block.cAlternateFileName);
-//DebugString("Found file '%s'.\n", block.cFileName);
-//DebugString( "Found alternate PKT file.\n" );
-
- if (stricmp(name, "MISSIONS.PKT")) {
- file.Close();
- file.Set_Name(name);
- ini.Clear();
- ini.Load(file);
+ for (std::string const & name : Search_Files("*.PKT")) {
+ if (stricmp(name.c_str(), "MISSIONS.PKT")) {
+ file.Close();
+ file.Set_Name(name.c_str());
+ ini.Clear();
+ ini.Load(file);
- int count = ini.Entry_Count("MultiMaps");
- for (int index = 0; index < count; index++) {
- if (ini.Get_String("MultiMaps", ini.Get_Entry("MultiMaps", index), "", name_buffer, sizeof(name_buffer))) {
- Scenarios.Add(new MultiMission(ini, name_buffer));
- }
+ int count = ini.Entry_Count("MultiMaps");
+ for (int index = 0; index < count; index++) {
+ if (ini.Get_String("MultiMaps", ini.Get_Entry("MultiMaps", index), "", name_buffer, sizeof(name_buffer))) {
+ Scenarios.Add(new MultiMission(ini, name_buffer));
}
}
}
-
- if (FindNextFile(handle, &block) == 0) break;
- }
-
- if (handle != INVALID_HANDLE_VALUE) {
- FindClose(handle);
}
ini.Clear();
@@ -742,28 +722,15 @@ void SessionClass::Read_Scenario_Descriptions(void)
** Scan the current directory for any loose .MPR files and build the appropriate entries
** into the scenario list list
*/
- char const * file_name;
char digest_buffer[32];
- handle = FindFirstFile( "*.MPR" , &block );
- while (handle != INVALID_HANDLE_VALUE) {
- if ((block.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_HIDDEN|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_TEMPORARY)) == 0) {
- file_name = &block.cAlternateFileName[0];
- if (strlen(file_name) == 0) file_name = &block.cFileName[0];
-//DebugString( "Found MPR '%s'\n", file_name );
- file.Set_Name(file_name);
- ini.Load(file);
-
- ini.Get_String("Basic", "Name", "No Name", name_buffer, sizeof(name_buffer) );
- ini.Get_String("Digest", "1", "No Digest", digest_buffer, sizeof(digest_buffer) );
- Scenarios.Add(new MultiMission(file_name, name_buffer, digest_buffer,ini.Get_Bool("Basic", "Official", false)));
- }
-
- if (FindNextFile(handle, &block) == 0) break;
- }
+ for (std::string const & file_name : Search_Files("*.MPR")) {
+ file.Set_Name(file_name.c_str());
+ ini.Load(file);
- if (handle != INVALID_HANDLE_VALUE) {
- FindClose(handle);
+ ini.Get_String("Basic", "Name", "No Name", name_buffer, sizeof(name_buffer) );
+ ini.Get_String("Digest", "1", "No Digest", digest_buffer, sizeof(digest_buffer) );
+ Scenarios.Add(new MultiMission(file_name.c_str(), name_buffer, digest_buffer,ini.Get_Bool("Basic", "Official", false)));
}
Options.ScenarioIndex = 0;
diff --git a/code/startup.cpp b/code/startup.cpp
index b4671501..9e76aaa9 100644
--- a/code/startup.cpp
+++ b/code/startup.cpp
@@ -77,6 +77,7 @@
#include "factory.h"
#include "fly.h"
#include "fog.h"
+#include "gamedirs.h"
#include "goptions.h"
#include "house.h"
#include "houstype.h"
@@ -155,9 +156,13 @@
#include "wwmouse.h"
#include "zbuffer.h"
+#include
+
#include
#include
#include
+#include
+#include
extern HINSTANCE LanguageResources;
@@ -351,6 +356,50 @@ static bool RegisterClasses(void)
}
+///
+/// Builds the argument list the game parses from the command line the shell handed over.
+/// The shell's own quoting decides where one argument ends and the next begins, so a
+/// directory whose name holds spaces arrives as the single argument it was written as.
+///
+/// Full path to the running executable, which becomes the first
+/// argument the way a DOS program received it.
+/// Receives the argument array, which lasts as long as the process.
+/// The number of arguments, which is never less than one.
+static int Build_Arguments(char const * path_to_exe, char ** & argv)
+{
+ static std::vector arguments;
+ static std::vector pointers;
+
+ arguments.clear();
+ pointers.clear();
+ arguments.push_back(path_to_exe);
+
+ int wide_count = 0;
+ LPWSTR * wide_argv = CommandLineToArgvW(GetCommandLineW(), &wide_count);
+
+ if (wide_argv != NULL) {
+ // Index zero names the executable, which the caller has already established.
+ for (int index = 1; index < wide_count; index++) {
+ int length = WideCharToMultiByte(CP_ACP, 0, wide_argv[index], -1, NULL, 0, NULL, NULL);
+ if (length <= 1) continue;
+
+ std::string argument(length - 1, '\0');
+ WideCharToMultiByte(CP_ACP, 0, wide_argv[index], -1, argument.data(), length, NULL, NULL);
+ arguments.push_back(argument);
+ }
+
+ LocalFree(wide_argv);
+ }
+
+ for (std::string & argument : arguments) {
+ pointers.push_back(argument.data());
+ }
+
+ argv = pointers.data();
+ return((int)pointers.size());
+}
+
+
/***********************************************************************************************
* main -- Initial startup routine (preps library systems). *
* *
@@ -368,11 +417,11 @@ static bool RegisterClasses(void)
* HISTORY: *
* 03/20/1995 JLB : Created. *
*=============================================================================================*/
-int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * command_line , int command_show )
+int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_show )
{
int argc; //Command line argument count
- char * argv[20]; //Pointers to command line arguments
- char path_to_exe[132];
+ char ** argv; //Pointers to command line arguments
+ char path_to_exe[MAX_PATH];
char buffer[512];
#ifdef STEVES_NEW_CATCHER
@@ -483,22 +532,11 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * command_line , in
*/
GetModuleFileName (instance, &path_to_exe[0], sizeof(path_to_exe));
- /*
- ** First argument is supposed to be a pointer to the .EXE that is running
- **
- */
- argc=1; //Set argument count to 1
- argv[0]=&path_to_exe[0]; //Set 1st command line argument to point to full path
-
/*
** Get pointers to command line arguments just like if we were in DOS
**
*/
- char *token = strtok(command_line, " ");
- while (argc < ARRAY_SIZE(argv) && token != NULL) {
- argv[argc++] = strtrim(token);
- token = strtok(NULL, " ");
- }
+ argc = Build_Arguments(path_to_exe, argv);
/*
** Change directory to the where the executable is located. Handle the
@@ -513,11 +551,21 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * command_line , in
int error_code = EXIT_FAILURE;
- if (Parse_Command_Line(argc, argv)) {
+ if (Parse_Command_Line(argc, argv) && Apply_Game_Directories()) {
Exception_Run_Immediate_Test();
- RawFileClass *cfile = new RawFileClass(CONFIG_FILE_NAME);
+ /*
+ * Before anything is read, so that every file the game goes on to open is looked
+ * for where this deployment actually keeps it.
+ */
+ Init_Search_Folders();
+
+ // The recording's name was settled during static initialization, before there was
+ // anywhere for a player's files to go. Naming it again settles it where it belongs.
+ Session.RecordFile.Set_Name("RECORD.BIN");
+
+ CDFileClass *cfile = new CDFileClass(CONFIG_FILE_NAME);
ConfigINI.Load(*cfile, false);
Options.ScreenWidth = ConfigINI.Get_Int("Video", "ScreenWidth", Options.ScreenWidth);
@@ -622,8 +670,9 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * command_line , in
*/
if (Special.IsFromInstall == true) {
ConfigINI.Put_Bool("Intro", "PlayIntro", false);
+
+ // Left closed, so that saving opens it for writing itself.
cfile->Close();
- cfile->Open();
ConfigINI.Save(*cfile, false);
}
@@ -661,6 +710,14 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * command_line , in
} else {
+ /*
+ * A startup this early has no window of its own, and may have been given no console
+ * either, so a directory the game cannot use is reported where it will be seen.
+ */
+ if (*Game_Directory_Error() != '\0') {
+ MessageBox(NULL, Game_Directory_Error(), Fetch_String(TXT_SHORT_TITLE), MB_ICONEXCLAMATION|MB_OK);
+ }
+
// The help and the invalid option message are of no use if the console closes with
// the process a moment later.
Debug_Console_Hold();
diff --git a/code/wonline.cpp b/code/wonline.cpp
index 2e8ee5b3..63fbf08c 100644
--- a/code/wonline.cpp
+++ b/code/wonline.cpp
@@ -746,7 +746,7 @@ void Read_WOL_Settings(void)
///
void Write_WOL_Settings(void)
{
- RawFileClass file(CONFIG_FILE_NAME);
+ CDFileClass file(CONFIG_FILE_NAME);
ConfigINI.Put_Int("WOnline", "AllowPage", g_AllowPage);
ConfigINI.Put_Int("WOnline", "AllowFind", g_AllowFind);
ConfigINI.Put_Int("WOnline", "LangFilter", g_LangFilter);
diff --git a/manual/changes/command-line-spaces.md b/manual/changes/command-line-spaces.md
new file mode 100644
index 00000000..0eb6fff2
--- /dev/null
+++ b/manual/changes/command-line-spaces.md
@@ -0,0 +1,23 @@
+---
+title: Accept launch options carrying a long or quoted path
+category: fix
+release: 0.2.0
+targets: []
+credit: [ZivDero]
+---
+
+A launch option carrying a path whose name contains spaces is now read as the
+single argument it was written as. The command line was split on every space
+before any quoting was considered, so a quoted path arrived as several
+arguments and the option was not recognized. The shell's own quoting rules now
+decide where one argument ends and the next begins.
+
+An argument of about 125 characters or more no longer crashes the game as it
+starts. Every argument is put through a transformation that recognizes the
+options carrying no leading dash, and one long enough to fill that routine's
+working buffer wrote its terminator one position past the end of it.
+
+The number of arguments a launch can carry is no longer capped at nineteen.
+
+A directory too long to have a file name appended to it is passed over by the
+file search rather than truncated into a different one.
diff --git a/manual/changes/deployment-search-folders.md b/manual/changes/deployment-search-folders.md
new file mode 100644
index 00000000..c20353d2
--- /dev/null
+++ b/manual/changes/deployment-search-folders.md
@@ -0,0 +1,40 @@
+---
+title: Search the folders a deployment keeps its files in
+category: feature
+release: 0.2.0
+breaking: true
+migration:
+- Rename an `INI`, `MIX` or `Maps` directory beside the game whose files are not meant to be loaded, or ship an `OPENTS.INI` naming only the game's own directory as `SearchPaths=.`.
+- Check a `Maps` directory in particular, since the maps it holds now appear in the game's own lists.
+targets:
+- type: format
+ id: opents-ini
+ effect: added
+credit: [ZivDero]
+---
+
+A distribution can sort its files into folders and name them in an `OPENTS.INI`
+beside its game data. With no such file the game searches `INI`, `MIX` and
+`Maps`, so a deployment can sort its files and ship no configuration at all.
+
+Wildcard searches now cover every folder the game searches rather than stopping
+at the first one holding a match. Rules files, battle files, map packets, loose
+maps, and the map and movie archives are all found across the folders. Names are
+gathered in a fixed order, so which copy of a repeated name is used no longer
+depends on the order a file system reported it in.
+
+The loose `PATCH.MIX` and `EXPAND??.MIX` archives are now looked for in every
+searched folder instead of the game's own directory alone. They are still
+required to be loose files, so an expansion archive cannot be hidden inside
+another archive.
+
+Files the game writes are never written into a searched folder, and a file it
+deletes is never one of theirs. The settings file, the hotkey file, the hall of
+fame and a saved random map were opened through the search before being written,
+so a copy a deployment had shipped in one of these folders could be overwritten,
+and the hotkey reset could delete it. Both now settle on the game's own copy
+before writing.
+
+The settings written when the intro is first shown are saved again. The file was
+reopened for reading beforehand, which left the save with a file it could not
+write through.
diff --git a/manual/changes/game-data-directory.md b/manual/changes/game-data-directory.md
new file mode 100644
index 00000000..601622d6
--- /dev/null
+++ b/manual/changes/game-data-directory.md
@@ -0,0 +1,19 @@
+---
+title: Read the game's data from a directory named on the command line
+category: feature
+release: 0.2.0
+targets:
+- type: command
+ id: launch:data-directory
+ effect: added
+credit: [ZivDero]
+---
+
+`-DATADIR=` tells the game where its data is kept. Everything the game
+reads is looked for there as well as beside the executable, and the deployment
+file describing how that directory is sorted is read from it.
+
+The game never writes to the directory, so one copy of the data can be shared
+between the people using a machine, and can be installed somewhere they are not
+allowed to write to. A directory that is not there stops startup with a message
+rather than leaving the game to fail later over a missing file.
diff --git a/manual/changes/retire-cd-path.md b/manual/changes/retire-cd-path.md
new file mode 100644
index 00000000..9f14f1de
--- /dev/null
+++ b/manual/changes/retire-cd-path.md
@@ -0,0 +1,25 @@
+---
+title: Retire the -CD launch option
+category: feature
+release: 0.2.0
+breaking: true
+migration:
+- Replace `-CD` with `-DATADIR=` where the path holds the game's data.
+- Replace it with `-USERDIR=` where the path held files of your own that stood in for the game's. That directory is read before any other, so what it holds still answers first, and it is where the game writes.
+- Name the folder in the deployment's `OPENTS.INI` `SearchPaths` where it is one of several the game should always search.
+targets:
+- type: command
+ id: launch:cd-path
+ effect: removed
+credit: [ZivDero]
+---
+
+`-CD` no longer adds a local file-search path; an argument beginning with
+it is ignored like any other the game does not recognize. Everything it was used
+for is now said another way: the game data directory points the game at its data,
+the user data directory holds the files a player puts ahead of it, and a
+deployment's own `OPENTS.INI` names the folders its data is sorted into.
+
+With it goes the last of its disc-era plumbing: the semicolon-separated list it
+accepted, and the upper-casing its path could not escape while every other
+directory option keeps the case it was written in.
diff --git a/manual/changes/user-data-directory.md b/manual/changes/user-data-directory.md
new file mode 100644
index 00000000..542fd7dc
--- /dev/null
+++ b/manual/changes/user-data-directory.md
@@ -0,0 +1,28 @@
+---
+title: Keep a player's own files in a directory named on the command line
+category: feature
+release: 0.2.0
+targets:
+- type: command
+ id: launch:user-directory
+ effect: added
+credit: [ZivDero]
+---
+
+`-USERDIR=` tells the game where to keep what it writes. Every file the
+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.
+
+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
+already has beside the executable are still read until their own copy exists, so
+pointing an existing installation at a user directory carries them forward.
+
+A file the game throws away is its own copy. Resetting the hotkeys discards the
+player's and falls back to the ones a deployment shipped, rather than removing
+what everyone shares.
+
+Without the option every one of these files stays where it has always been,
+beside the executable.
diff --git a/manual/content/formats/opents-ini.md b/manual/content/formats/opents-ini.md
new file mode 100644
index 00000000..a5ac33db
--- /dev/null
+++ b/manual/content/formats/opents-ini.md
@@ -0,0 +1,53 @@
+---
+format_id: opents-ini
+title: OPENTS.INI
+summary: Names the folders a deployment keeps its game files sorted into.
+kind: file
+source_files:
+ - code/gamedirs.cpp
+filenames:
+ - OPENTS.INI
+related:
+ - type: command
+ id: launch:data-directory
+ - type: command
+ id: launch:user-directory
+ - type: using
+ id: game-data
+---
+
+A distribution ships this file beside its game data to say where that data is kept. It is the deployment's own file, as against `SUN.INI`, which the game writes a player's settings back to.
+
+```ini title="OPENTS.INI"
+[Paths]
+SearchPaths=INI,MIX,Maps,Addons
+```
+
+`SearchPaths` names folders separated by commas, which the game searches in the order written. The whitespace around a name is dropped, a trailing separator is supplied if the name lacks one, and a folder named twice is searched once. Commas separate the entries because a semicolon opens a comment on the line it appears in.
+
+Without the file, and without the key, the game behaves as though `SearchPaths=INI,MIX,Maps` were written: a distribution can sort its files into `INI`, `MIX` and `Maps` folders and ship no configuration at all. A written list **replaces** that default rather than adding to it, so a deployment that wants the default folders as well as its own names them again.
+
+The game's own directory is examined before any listed folder, so naming it adds nothing. Naming only it, as `SearchPaths=.`, is how a deployment asks for no other folder to be searched — an entry with nothing after the equals sign is passed over by the file reader and would leave the default in force.
+
+## Where the file is looked for
+
+The file is read from the disk rather than through the game's file layer, so a deployment cannot describe its own layout from inside an archive. It is looked for in the game data directory, then in that directory's `INI` and `MIX` folders, and the first copy found is the one read.
+
+The game data directory is what [`-DATADIR`](/using/command-line/data-directory/) names, and the game's own directory when nothing names one. Every folder `SearchPaths` lists is relative to it.
+
+## The order files are searched for in
+
+1. the user data directory, when [`-USERDIR`](/using/command-line/user-directory/) names one;
+2. the game's own directory;
+3. the game data directory, when [`-DATADIR`](/using/command-line/data-directory/) names one;
+4. the folders `SearchPaths` lists, in the order written.
+
+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.
+
+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.
+
+:::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/using/game-data.md b/manual/content/using/game-data.md
index 62bda164..3e27476b 100644
--- a/manual/content/using/game-data.md
+++ b/manual/content/using/game-data.md
@@ -11,6 +11,12 @@ related:
id: build-and-run
- type: using
id: configuration-files
+ - type: format
+ id: opents-ini
+ - type: command
+ id: launch:data-directory
+ - type: command
+ id: launch:user-directory
---
The repository contains engine source and build inputs. It does not contain maps, movies, audio, or other proprietary game assets.
@@ -18,3 +24,9 @@ The repository contains engine source and build inputs. It does not contain maps
Place data from a legitimate copy of Tiberian Sun under `Run/`. The tracked `Run/place_steam_build_here` marker identifies this local run tree; the directory's populated contents are ignored by Git.
Do not place game data in the CMake build directory. The build copies OpenTS executables and `Language.dll` into `Run/`, alongside the locally supplied game files.
+
+## Keeping the data somewhere else
+
+`-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.
+
+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 78c1cbe8..aa54443b 100644
--- a/manual/data/command-adapters.yaml
+++ b/manual/data/command-adapters.yaml
@@ -466,14 +466,22 @@ launch_options:
availability: *debug
sites:
- { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-CHECKMAP', guard: _DEBUG }
- - id: launch:cd-path
- title: Local data path
- syntax: -CD
- description: Adds the path following -CD to the local file-search list.
- audience: developer
+ - id: launch:data-directory
+ title: Game data directory
+ syntax: -DATADIR=
+ description: Reads the game's data from the directory named, which the game never writes to.
+ audience: player
+ availability: *all
+ sites:
+ - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-DATADIR=' }
+ - id: launch:user-directory
+ title: User data directory
+ syntax: -USERDIR=
+ description: Keeps the settings, saved games and other files the game writes in the directory named, creating it when it is not there.
+ audience: player
availability: *all
sites:
- - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-CD' }
+ - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-USERDIR=' }
- id: launch:tournament-time
title: Tournament time limit
syntax: -TIME=
diff --git a/manual/data/commands.yaml b/manual/data/commands.yaml
index e3cb9916..b672f421 100644
--- a/manual/data/commands.yaml
+++ b/manual/data/commands.yaml
@@ -1764,14 +1764,26 @@ launch_options:
_provenance:
source: code/init.cpp
guard: _DEBUG
-- id: launch:cd-path
- route_id: cd-path
+- id: launch:data-directory
+ route_id: data-directory
kind: launch
- title: Local data path
- description: Adds the path following -CD to the local file-search list.
- audience: developer
+ title: Game data directory
+ description: Reads the game's data from the directory named, which the game never writes to.
+ audience: player
+ availability: *id001
+ syntax: -DATADIR=
+ _provenance:
+ source: code/init.cpp
+ guard: null
+- id: launch:user-directory
+ route_id: user-directory
+ kind: launch
+ title: User data directory
+ description: Keeps the settings, saved games and other files the game writes in the directory named,
+ creating it when it is not there.
+ audience: player
availability: *id001
- syntax: -CD
+ syntax: -USERDIR=
_provenance:
source: code/init.cpp
guard: null
diff --git a/manual/data/ini-read-exclusions.yaml b/manual/data/ini-read-exclusions.yaml
index 4467c34d..5fc8b8dd 100644
--- a/manual/data/ini-read-exclusions.yaml
+++ b/manual/data/ini-read-exclusions.yaml
@@ -140,3 +140,9 @@ site_exclusions:
keys: [Name]
classification: excluded
reason: This dynamic region lookup reads a legacy online-service endpoint record, not a mod setting.
+
+ - path: code/gamedirs.cpp
+ function: Init_Search_Folders
+ 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.
diff --git a/manual/data/tombstones.yaml b/manual/data/tombstones.yaml
index 1f7d6509..2cd0479c 100644
--- a/manual/data/tombstones.yaml
+++ b/manual/data/tombstones.yaml
@@ -6,6 +6,16 @@
search_aliases:
- Cancel modem operation
summary: Cancelled a dial, answer, or modem-command wait. Removed with modem and null-modem play.
+- type: command
+ id: launch:cd-path
+ route: /using/command-line/cd-path/
+ search_aliases:
+ - Local data path
+ - -CD
+ summary: Added the path following -CD to the local file-search list. The directories the game searches are now named by the data and user directory options and by the deployment's own search folders.
+ replacement:
+ type: format
+ id: opents-ini
- type: key
id: ModemName
route: /keys/modemname/
diff --git a/manual/tools/tests/test_lifecycle_expansion.py b/manual/tools/tests/test_lifecycle_expansion.py
index b6dd6129..4aef28bb 100644
--- a/manual/tools/tests/test_lifecycle_expansion.py
+++ b/manual/tools/tests/test_lifecycle_expansion.py
@@ -287,6 +287,85 @@ def test_system_and_command_targets_tombstones_and_replacements_resolve(self):
errors, manual, {}, {}, {}, {}, entities)
self.assertEqual(errors, [])
+ def test_history_may_cite_an_entity_removed_later(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ manual = Path(temporary)
+ (manual / "changes").mkdir()
+ (manual / "changes" / "old-behavior.md").write_text(
+ "---\n"
+ "title: Change the old command\n"
+ "category: feature\n"
+ "release: 1.0.0\n"
+ "targets:\n"
+ " - type: command\n"
+ " id: OldCommand\n"
+ " effect: changed\n"
+ "credit: [Programmer]\n"
+ "---\n",
+ encoding="utf-8",
+ )
+ (manual / "changes" / "old-removal.md").write_text(
+ "---\n"
+ "title: Remove the old command\n"
+ "category: feature\n"
+ "release: 2.0.0\n"
+ "targets:\n"
+ " - type: command\n"
+ " id: OldCommand\n"
+ " effect: removed\n"
+ "credit: [Programmer]\n"
+ "---\n",
+ encoding="utf-8",
+ )
+ registry = {
+ "development": "2.0.0",
+ "by_version": {
+ "1.0.0": {"version": "1.0.0", "status": "released"},
+ "2.0.0": {"version": "2.0.0", "status": "development"},
+ },
+ }
+ tombstones = [{
+ "type": "command",
+ "id": "OldCommand",
+ "route": "/commands/oldcommand/",
+ "search_aliases": [],
+ "summary": "Removed command.",
+ }]
+ errors = []
+ versioning.validate_changes(
+ errors, manual, registry, {}, {}, {}, tombstones,
+ entities={"command": {}})
+ self.assertEqual(errors, [])
+
+ # A tombstone answers only for the entity itself, never for a scope of one.
+ (manual / "changes" / "old-behavior.md").write_text(
+ "---\n"
+ "title: Change the old key\n"
+ "category: feature\n"
+ "release: 1.0.0\n"
+ "targets:\n"
+ " - type: key\n"
+ " id: OldKey\n"
+ " effect: changed\n"
+ " scope: campaign\n"
+ "credit: [Programmer]\n"
+ "---\n",
+ encoding="utf-8",
+ )
+ tombstones.append({
+ "type": "key",
+ "id": "OldKey",
+ "route": "/keys/oldkey/",
+ "search_aliases": [],
+ "summary": "Removed key.",
+ })
+ errors = []
+ versioning.validate_changes(
+ errors, manual, registry, {}, {}, {}, tombstones,
+ entities={"command": {}})
+ self.assertTrue(
+ any("unknown active entity" in error for error in errors))
+
def test_command_deltas_ignore_provenance_and_require_lifecycle(self):
base = {
"registered_commands": [
diff --git a/manual/tools/versioning.py b/manual/tools/versioning.py
index 5ad1c7e7..6f8aad61 100644
--- a/manual/tools/versioning.py
+++ b/manual/tools/versioning.py
@@ -584,7 +584,11 @@ def validate_changes(
elif target["scope"] in scope_ids(keys[target["id"]]):
errors.append(
f"{target_context}: removed key scope is still active")
- elif not active_entity(target, keys, scripts, formats, enums, entities):
+ elif (not active_entity(target, keys, scripts, formats, enums, entities)
+ and (target["scope"] is not None
+ or (target["type"], target["id"]) not in tombstone_map)):
+ # History may cite an entity that was removed later; the lifecycle
+ # ordering check keeps such events ahead of the removal.
errors.append(
f"{target_context}: unknown active entity "
f"{target_description(target)}")
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 7e8311bd..d179e302 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1 +1,2 @@
+add_subdirectory(gamedirs)
add_subdirectory(logstress)
diff --git a/tests/gamedirs/CMakeLists.txt b/tests/gamedirs/CMakeLists.txt
new file mode 100644
index 00000000..97e40385
--- /dev/null
+++ b/tests/gamedirs/CMakeLists.txt
@@ -0,0 +1,52 @@
+# The directory handling is compiled straight into the harness, along with the file classes
+# and the INI reader it stands on. It lives outside code/ so that the recursive glob building
+# OpenTS cannot pick this target's entry point up.
+add_executable(GameDirs
+ "${CMAKE_CURRENT_SOURCE_DIR}/gamedirscontract.cpp"
+ "${CMAKE_SOURCE_DIR}/code/gamedirs.cpp"
+ "${CMAKE_SOURCE_DIR}/code/cdfile.cpp"
+ "${CMAKE_SOURCE_DIR}/code/bfiofile.cpp"
+ "${CMAKE_SOURCE_DIR}/code/rawfile.cpp"
+ "${CMAKE_SOURCE_DIR}/code/dbgprint.cpp"
+ "${CMAKE_SOURCE_DIR}/code/ini.cpp"
+ "${CMAKE_SOURCE_DIR}/code/b64pipe.cpp"
+ "${CMAKE_SOURCE_DIR}/code/b64straw.cpp"
+ "${CMAKE_SOURCE_DIR}/code/base64.cpp"
+ "${CMAKE_SOURCE_DIR}/code/buff.cpp"
+ "${CMAKE_SOURCE_DIR}/code/crc.cpp"
+ "${CMAKE_SOURCE_DIR}/code/cstraw.cpp"
+ "${CMAKE_SOURCE_DIR}/code/int.cpp"
+ "${CMAKE_SOURCE_DIR}/code/mpmath.cpp"
+ "${CMAKE_SOURCE_DIR}/code/pipe.cpp"
+ "${CMAKE_SOURCE_DIR}/code/pk.cpp"
+ "${CMAKE_SOURCE_DIR}/code/readline.cpp"
+ "${CMAKE_SOURCE_DIR}/code/straw.cpp"
+ "${CMAKE_SOURCE_DIR}/code/trim.cpp"
+ "${CMAKE_SOURCE_DIR}/code/xpipe.cpp"
+ "${CMAKE_SOURCE_DIR}/code/xstraw.cpp"
+)
+
+target_compile_features(GameDirs PRIVATE cxx_std_20)
+
+# The generated build stamp comes with dbgprint.cpp, so this target needs it too.
+target_include_directories(GameDirs PRIVATE
+ "${CMAKE_SOURCE_DIR}/code"
+ "${OPENTS_GENERATED_DIR}"
+)
+
+add_dependencies(GameDirs OpenTSBuildStamp)
+
+target_compile_definitions(GameDirs PRIVATE WIN32 _WINDOWS _MBCS)
+
+target_compile_options(GameDirs PRIVATE
+ $<$:/MTd /EHsc /Zc:__cplusplus>
+ $<$:/MT /EHsc /Zc:__cplusplus>
+)
+
+target_link_libraries(GameDirs PRIVATE kernel32 user32 shell32)
+
+set_target_properties(GameDirs PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
+)
+
+add_test(NAME gamedirs COMMAND GameDirs)
diff --git a/tests/gamedirs/gamedirscontract.cpp b/tests/gamedirs/gamedirscontract.cpp
new file mode 100644
index 00000000..d2f9a6a0
--- /dev/null
+++ b/tests/gamedirs/gamedirscontract.cpp
@@ -0,0 +1,575 @@
+/*******************************************************************************
+ * 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.
+ ******************************************************************************/
+
+// Exercises the game directories without the engine or any game data: the folder list a
+// deployment configures, the scan that covers every folder, and where a player's own files
+// are read from and written to. Every file this uses is one the harness makes itself.
+
+#include
+
+#include
+#include
+#include
+
+#include "cdfile.h"
+#include "gamedirs.h"
+#include "rawfile.h"
+
+namespace {
+
+int Failures = 0;
+
+std::string Root;
+char OriginalDirectory[MAX_PATH];
+
+
+void Check(bool condition, char const * what)
+{
+ std::printf("%-62s %s\n", what, condition ? "ok" : "FAILED");
+
+ if (!condition) {
+ Failures++;
+ }
+}
+
+
+void Check_List(std::vector const & actual, std::vector const & expected, char const * what)
+{
+ bool same = actual.size() == expected.size();
+
+ for (unsigned int index = 0; same && index < actual.size(); index++) {
+ same = actual[index] == expected[index];
+ }
+
+ Check(same, what);
+
+ if (!same) {
+ std::printf(" got:");
+ for (std::string const & entry : actual) {
+ std::printf(" [%s]", entry.c_str());
+ }
+ std::printf("\n expected:");
+ for (std::string const & entry : expected) {
+ std::printf(" [%s]", entry.c_str());
+ }
+ std::printf("\n");
+ }
+}
+
+
+// The file object keeps the name pointer it is handed rather than copying it, so the string
+// it points into has to outlive it.
+void Write_File(std::string const & path, char const * contents)
+{
+ RawFileClass file(path.c_str());
+
+ file.Open(FileClass::WRITE);
+ file.Write(contents, (int)strlen(contents));
+ file.Close();
+}
+
+
+std::string Read_File(std::string const & path)
+{
+ RawFileClass file(path.c_str());
+
+ if (!file.Is_Available()) {
+ return(std::string());
+ }
+
+ int const size = file.Size();
+ std::string contents(size, '\0');
+
+ file.Open(FileClass::READ);
+ file.Read(contents.data(), size);
+ file.Close();
+
+ return(contents);
+}
+
+
+bool File_Exists(std::string const & path)
+{
+ return(GetFileAttributes(path.c_str()) != INVALID_FILE_ATTRIBUTES);
+}
+
+
+void Make_Directory(std::string const & path)
+{
+ CreateDirectory(path.c_str(), NULL);
+}
+
+
+/*
+ * Every case starts from the same empty tree, with no folders configured and the current
+ * directory back at the root, so that one case cannot decide another's outcome.
+ */
+void Reset(void)
+{
+ CDFileClass::Clear_Search_Drives();
+ Set_Data_Directory("");
+ Set_User_Directory("");
+ SetCurrentDirectory(Root.c_str());
+}
+
+
+void Test_Parsing(void)
+{
+ Check_List(Parse_Search_Folders("INI,MIX"), {"INI\\", "MIX\\"}, "a plain list keeps its order");
+ Check_List(Parse_Search_Folders(" INI ,\tMIX "), {"INI\\", "MIX\\"}, "surrounding whitespace is dropped");
+ Check_List(Parse_Search_Folders("INI\\,MIX/"), {"INI\\", "MIX/"}, "a separator already written is kept");
+ Check_List(Parse_Search_Folders("INI,ini\\,INI"), {"INI\\"}, "the same folder written differently is one folder");
+ Check_List(Parse_Search_Folders("INI,,MIX"), {"INI\\", "MIX\\"}, "an empty entry is passed over");
+ Check_List(Parse_Search_Folders(""), {}, "an empty list names no folders");
+ Check_List(Parse_Search_Folders(" "), {}, "a list of whitespace names no folders");
+ Check_List(Parse_Search_Folders(NULL), {}, "no list at all names no folders");
+ Check_List(Parse_Search_Folders("D:"), {"D:"}, "a bare drive is left as it is");
+ Check_List(Parse_Search_Folders("."), {}, "naming only the game's own directory adds no folder");
+ Check_List(Parse_Search_Folders(".,Extra"), {"Extra\\"}, "the game's own directory is passed over in a longer list");
+}
+
+
+void Test_Defaults(void)
+{
+ Reset();
+ Init_Search_Folders();
+
+ Check(CDFileClass::Search_Path(0) != NULL && std::string(CDFileClass::Search_Path(0)) == "INI\\",
+ "with no configuration the INI folder is searched");
+ Check(CDFileClass::Search_Path(1) != NULL && std::string(CDFileClass::Search_Path(1)) == "MIX\\",
+ "with no configuration the MIX folder is searched");
+ Check(CDFileClass::Search_Path(2) != NULL && std::string(CDFileClass::Search_Path(2)) == "Maps\\",
+ "with no configuration the Maps folder is searched");
+ Check(CDFileClass::Search_Path(3) == NULL, "nothing else is searched");
+}
+
+
+void Test_Configured_Folders(void)
+{
+ Reset();
+ Write_File(Root + "\\OPENTS.INI", "[Paths]\nSearchPaths=Data,More\n");
+ Init_Search_Folders();
+
+ Check(CDFileClass::Search_Path(0) != NULL && std::string(CDFileClass::Search_Path(0)) == "Data\\",
+ "a configured folder is searched");
+ Check(CDFileClass::Search_Path(1) != NULL && std::string(CDFileClass::Search_Path(1)) == "More\\",
+ "configured folders keep the order they are written in");
+ Check(CDFileClass::Search_Path(2) == NULL, "a configured list replaces the default folders");
+
+ /*
+ * A file cannot carry an entry with nothing after the equals sign -- the reader passes
+ * such a line over -- so naming the game's own directory is how a deployment asks for
+ * no other folder.
+ */
+ Reset();
+ Write_File(Root + "\\OPENTS.INI", "[Paths]\nSearchPaths=.\n");
+ Init_Search_Folders();
+
+ Check(CDFileClass::Search_Path(0) == NULL, "naming only the game's own directory turns the default folders off");
+
+ DeleteFile((Root + "\\OPENTS.INI").c_str());
+}
+
+
+void Test_Configuration_In_A_Folder(void)
+{
+ Reset();
+ Write_File(Root + "\\INI\\OPENTS.INI", "[Paths]\nSearchPaths=FromIni\n");
+ Init_Search_Folders();
+
+ Check(CDFileClass::Search_Path(0) != NULL && std::string(CDFileClass::Search_Path(0)) == "FromIni\\",
+ "the configuration is found in a sorted deployment's own INI folder");
+
+ DeleteFile((Root + "\\INI\\OPENTS.INI").c_str());
+}
+
+
+void Test_Data_Directory(void)
+{
+ Reset();
+ Set_Data_Directory((Root + "\\Data").c_str());
+ Write_File(Root + "\\Data\\OPENTS.INI", "[Paths]\nSearchPaths=Sorted\n");
+
+
+ Check(Apply_Game_Directories(), "a data directory that exists is accepted");
+ Init_Search_Folders();
+
+ std::string const expected_data = Root + "\\Data\\";
+ std::string const expected_sorted = expected_data + "Sorted\\";
+
+ Check(CDFileClass::Search_Path(0) != NULL && std::string(CDFileClass::Search_Path(0)) == expected_data,
+ "the data directory itself is searched");
+ Check(CDFileClass::Search_Path(1) != NULL && std::string(CDFileClass::Search_Path(1)) == expected_sorted,
+ "a folder it configures is searched inside it");
+
+ DeleteFile((Root + "\\Data\\OPENTS.INI").c_str());
+
+ Reset();
+ Set_Data_Directory((Root + "\\Missing").c_str());
+ Check(!Apply_Game_Directories(), "a data directory that is not there is refused");
+}
+
+
+void Test_User_Directory(void)
+{
+ Reset();
+ Set_User_Directory((Root + "\\User\\Fresh").c_str());
+
+ Check(Apply_Game_Directories(), "a user directory is created when it is not there yet");
+ Check(GetFileAttributes((Root + "\\User\\Fresh").c_str()) != INVALID_FILE_ATTRIBUTES,
+ "the created user directory is on the disk");
+
+ std::string const expected_user = Root + "\\User\\Fresh\\";
+ Check(CDFileClass::User_Path() != NULL && std::string(CDFileClass::User_Path()) == expected_user,
+ "the file layer is told where the player's own files go");
+ Check(CDFileClass::Search_Path(0) == NULL,
+ "the user directory is not one of the searched folders");
+
+ Check(User_File_Write_Name("SUN.INI") == expected_user + "SUN.INI",
+ "a file the game writes goes to the user directory");
+
+ Reset();
+ Check(CDFileClass::User_Path() == NULL,
+ "with no user directory the file layer has none either");
+ Check(User_File_Write_Name("SUN.INI") == "SUN.INI",
+ "with no user directory a written file keeps its plain name");
+}
+
+
+void Test_Search_Files(void)
+{
+ Reset();
+
+ Write_File(Root + "\\ALPHA.MPR", "");
+ Write_File(Root + "\\INI\\BRAVO.MPR", "");
+ Write_File(Root + "\\INI\\ALPHA.MPR", "");
+ Write_File(Root + "\\MIX\\CHARLIE.MPR", "");
+
+ Init_Search_Folders();
+
+ Check_List(Search_Files("*.MPR"), {"ALPHA.MPR", "BRAVO.MPR", "CHARLIE.MPR"},
+ "a scan covers every folder, reports a name once, and sorts it");
+
+ /*
+ * The scan and an ordinary open have to agree, or the game would list one file and load
+ * another. Both walk the folders in the same order, so the game's own copy wins.
+ */
+ CDFileClass found("ALPHA.MPR");
+ Check(std::string(found.File_Name()) == "ALPHA.MPR",
+ "opening a name the scan reported lands on the copy the scan saw");
+
+ CDFileClass sorted("CHARLIE.MPR");
+ Check(std::string(sorted.File_Name()) == "MIX\\CHARLIE.MPR",
+ "a name held only by a searched folder opens from that folder");
+}
+
+
+void Test_Writes_Do_Not_Search(void)
+{
+ Reset();
+ Write_File(Root + "\\MIX\\WRITTEN.DAT", "shipped");
+ Init_Search_Folders();
+
+ /*
+ * A file opened for writing must never be looked for anywhere but the current directory:
+ * a deployment's folders are read from, not written to.
+ */
+ CDFileClass file;
+ file.Open("WRITTEN.DAT", FileClass::READ|FileClass::WRITE);
+ std::string const written = file.File_Name();
+ file.Close();
+
+ Check(written == "WRITTEN.DAT", "a read-write open does not settle on a searched folder");
+
+ Check(GetFileAttributes((Root + "\\WRITTEN.DAT").c_str()) != INVALID_FILE_ATTRIBUTES,
+ "the written file is in the current directory");
+
+ WIN32_FILE_ATTRIBUTE_DATA shipped;
+ GetFileAttributesEx((Root + "\\MIX\\WRITTEN.DAT").c_str(), GetFileExInfoStandard, &shipped);
+ Check(shipped.nFileSizeLow == 7, "the copy in the searched folder is untouched");
+}
+
+
+/*
+ * A file named with a directory of its own, looked up while folders are searched, makes the
+ * search build a pathname out of two full paths. That pair does not have to fit in one, and
+ * once it does not the search has to pass it over rather than build it anyway.
+ */
+void Test_Long_Names(void)
+{
+ Reset();
+
+ std::string long_folder = Root + "\\";
+ while (long_folder.length() < 150) {
+ long_folder += "x";
+ }
+ long_folder += "\\";
+
+ CDFileClass::Add_Search_Drive(long_folder.c_str());
+
+ std::string const absolute = Root + "\\ALPHA.MPR";
+ Write_File(absolute, "");
+
+ CDFileClass file(absolute.c_str());
+ Check(std::string(file.File_Name()) == absolute,
+ "a file named with its own directory is found while long folders are searched");
+
+ CDFileClass missing((Root + "\\NOTHERE.MPR").c_str());
+ Check(std::string(missing.File_Name()) == Root + "\\NOTHERE.MPR",
+ "a name that no folder holds comes back as it was given");
+
+ Check_List(Search_Files("*.MPR"), {"ALPHA.MPR"}, "a scan passes over a folder it cannot build a name in");
+}
+
+
+/*
+ * The file layer places what the game writes and finds what it reads. These are the rules a
+ * caller never states, so they are checked here rather than at any one of them.
+ */
+void Test_The_File_Layer_Places_Written_Files(void)
+{
+ Reset();
+ Set_User_Directory((Root + "\\User\\Own").c_str());
+ Apply_Game_Directories();
+ Init_Search_Folders();
+
+ std::string const own = Root + "\\User\\Own\\";
+
+ CDFileClass written("OWN.DAT");
+ written.Open(FileClass::WRITE);
+ written.Write("mine", 4);
+ written.Close();
+
+ Check(std::string(written.File_Name()) == own + "OWN.DAT", "a written file is named in the user directory");
+ Check(File_Exists(own + "OWN.DAT"), "a written file is in the user directory");
+ Check(!File_Exists(Root + "\\OWN.DAT"), "a written file is not beside the game");
+
+ /*
+ * A second object, made once both copies exist, so that what answers is the search and
+ * not the object that did the writing.
+ */
+ Write_File(Root + "\\MIX\\SHARED.DAT", "shipped");
+ Write_File(own + "SHARED.DAT", "own");
+
+ CDFileClass shared("SHARED.DAT");
+ Check(Read_File(shared.File_Name()) == "own", "a read prefers the player's own copy");
+
+ // A created file is read back from where it was created, which is what a game storing
+ // its progress does every time it starts.
+ CDFileClass progress("PROGRESS.INI");
+ Check(!progress.Is_Available(), "a file the player has never had is not there yet");
+ progress.Create();
+ progress.Close();
+
+ CDFileClass reopened("PROGRESS.INI");
+ Check(reopened.Is_Available(), "a created file is found again");
+ Check(std::string(reopened.File_Name()) == own + "PROGRESS.INI", "a created file is found in the user directory");
+}
+
+
+void Test_The_File_Layer_Deletes_Only_The_Player_Copy(void)
+{
+ Reset();
+ Set_User_Directory((Root + "\\User\\Own").c_str());
+ Apply_Game_Directories();
+ Init_Search_Folders();
+
+ std::string const own = Root + "\\User\\Own\\";
+
+ Write_File(Root + "\\MIX\\GONE.DAT", "shipped");
+ Write_File(own + "GONE.DAT", "own");
+
+ CDFileClass discard("GONE.DAT");
+ discard.Delete();
+
+ Check(!File_Exists(own + "GONE.DAT"), "the player's own copy is thrown away");
+ Check(File_Exists(Root + "\\MIX\\GONE.DAT"), "the copy a deployment ships is left alone");
+
+ CDFileClass again("GONE.DAT");
+ Check(Read_File(again.File_Name()) == "shipped", "what a deployment ships answers once the player's copy is gone");
+}
+
+
+/*
+ * A deployment ships default hotkeys in a folder it searches, and the player saves their own
+ * over the top. Throwing the player's away has to leave the deployment's alone, or the reset
+ * takes with it the very defaults it is meant to fall back on.
+ */
+void Test_Resetting_Keeps_The_Shipped_Default(void)
+{
+ Reset();
+ Set_User_Directory((Root + "\\User\\Own").c_str());
+ Apply_Game_Directories();
+ Init_Search_Folders();
+
+ Write_File(Root + "\\INI\\KEYBOARD.INI", "shipped");
+
+ // A player who has never saved their own asks for the defaults back.
+ CDFileClass untouched("KEYBOARD.INI");
+ Check(std::string(untouched.File_Name()) == "INI\\KEYBOARD.INI",
+ "a player with none of their own reads the shipped default");
+ untouched.Delete();
+ Check(File_Exists(Root + "\\INI\\KEYBOARD.INI"),
+ "a reset with nothing of the player's own leaves the shipped default");
+
+ // And now one who has.
+ Write_File(Root + "\\User\\Own\\KEYBOARD.INI", "mine");
+
+ CDFileClass owned("KEYBOARD.INI");
+ Check(Read_File(owned.File_Name()) == "mine", "the player's own hotkeys are the ones read");
+ owned.Delete();
+
+ Check(!File_Exists(Root + "\\User\\Own\\KEYBOARD.INI"), "a reset throws the player's own away");
+ Check(File_Exists(Root + "\\INI\\KEYBOARD.INI"), "a reset leaves the shipped default");
+
+ CDFileClass fallback("KEYBOARD.INI");
+ Check(Read_File(fallback.File_Name()) == "shipped", "the shipped default answers again after a reset");
+
+ DeleteFile((Root + "\\INI\\KEYBOARD.INI").c_str());
+}
+
+
+void Test_A_Name_With_A_Directory_Is_Left_Alone(void)
+{
+ Reset();
+ Set_User_Directory((Root + "\\User\\Own").c_str());
+ Apply_Game_Directories();
+
+ CDFileClass rooted("MIX\\ROOTED.DAT");
+ rooted.Open(FileClass::WRITE);
+ rooted.Write("here", 4);
+ rooted.Close();
+
+ Check(std::string(rooted.File_Name()) == "MIX\\ROOTED.DAT", "a name with a directory keeps it");
+ Check(File_Exists(Root + "\\MIX\\ROOTED.DAT"), "a name with a directory is written where it says");
+ Check(!File_Exists(Root + "\\User\\Own\\ROOTED.DAT"), "a name with a directory is not moved");
+}
+
+
+void Test_Placing_A_File_Is_Repeatable(void)
+{
+ Reset();
+ Set_User_Directory((Root + "\\User\\Own").c_str());
+ Apply_Game_Directories();
+
+ CDFileClass file("AGAIN.DAT");
+ file.Open(FileClass::WRITE);
+ file.Close();
+ std::string const once = file.File_Name();
+
+ file.Open(FileClass::WRITE);
+ file.Close();
+
+ Check(std::string(file.File_Name()) == once, "opening a file for writing twice names it the same place");
+
+ // The buffered path opens the file a second time itself, with read access added.
+ CDFileClass buffered("BUFFERED.DAT");
+ buffered.Cache(1024);
+ buffered.Open(FileClass::WRITE);
+ buffered.Write("cached", 6);
+ buffered.Close();
+
+ Check(File_Exists(Root + "\\User\\Own\\BUFFERED.DAT"), "a buffered write lands in the user directory");
+}
+
+
+void Test_Without_A_User_Directory_Nothing_Moves(void)
+{
+ Reset();
+ Init_Search_Folders();
+
+ Write_File(Root + "\\MIX\\STILL.DAT", "shipped");
+
+ CDFileClass written("STILL.DAT");
+ written.Open(FileClass::WRITE);
+ written.Write("here", 4);
+ written.Close();
+
+ Check(std::string(written.File_Name()) == "STILL.DAT", "a written file keeps its plain name");
+ Check(File_Exists(Root + "\\STILL.DAT"), "a written file lands beside the game");
+ Check(Read_File(Root + "\\MIX\\STILL.DAT") == "shipped", "a searched folder's copy is untouched");
+
+ CDFileClass discard("STILL.DAT");
+ discard.Delete();
+
+ Check(!File_Exists(Root + "\\STILL.DAT"), "a delete takes the copy beside the game");
+ Check(File_Exists(Root + "\\MIX\\STILL.DAT"), "a delete leaves the searched folder's copy");
+
+ CDFileClass shipped("STILL.DAT");
+ Check(std::string(shipped.File_Name()) == "MIX\\STILL.DAT", "a read still falls through to the searched folders");
+}
+
+
+bool Make_Root(void)
+{
+ char temp[MAX_PATH];
+ if (GetTempPath(sizeof(temp), temp) == 0) {
+ return(false);
+ }
+
+ char name[MAX_PATH];
+ std::snprintf(name, sizeof(name), "%sopents-gamedirs-%lu", temp, GetCurrentProcessId());
+ Root = name;
+
+ Make_Directory(Root);
+ Make_Directory(Root + "\\INI");
+ Make_Directory(Root + "\\MIX");
+ Make_Directory(Root + "\\Data");
+ Make_Directory(Root + "\\User");
+
+ return(SetCurrentDirectory(Root.c_str()) != 0);
+}
+
+
+void Remove_Root(void)
+{
+ SetCurrentDirectory(OriginalDirectory);
+
+ // The tree is shallow and entirely this harness's own, so it is removed by name.
+ char command[MAX_PATH + 32];
+ std::snprintf(command, sizeof(command), "cmd /c rd /s /q \"%s\"", Root.c_str());
+ system(command);
+}
+
+}
+
+
+int main(void)
+{
+ GetCurrentDirectory(sizeof(OriginalDirectory), OriginalDirectory);
+
+ if (!Make_Root()) {
+ std::printf("could not create the working directory\n");
+ return(1);
+ }
+
+ std::printf("Working in %s\n\n", Root.c_str());
+
+ Test_Parsing();
+ Test_Defaults();
+ Test_Configured_Folders();
+ Test_Configuration_In_A_Folder();
+ Test_Data_Directory();
+ Test_User_Directory();
+ Test_Search_Files();
+ Test_Writes_Do_Not_Search();
+ Test_Long_Names();
+ Test_The_File_Layer_Places_Written_Files();
+ Test_The_File_Layer_Deletes_Only_The_Player_Copy();
+ Test_Resetting_Keeps_The_Shipped_Default();
+ Test_A_Name_With_A_Directory_Is_Left_Alone();
+ Test_Placing_A_File_Is_Repeatable();
+ Test_Without_A_User_Directory_Nothing_Moves();
+
+ Reset();
+ Remove_Root();
+
+ std::printf("\n%s\n", Failures == 0 ? "All checks passed." : "There were failures.");
+ return(Failures == 0 ? 0 : 1);
+}