From 0c0dee6d7d53607637550d3bdda1b2be014e8cac Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 19:36:38 +0300 Subject: [PATCH 01/25] Stop a long launch argument overrunning the obfuscation buffer --- code/obscure.cpp | 7 +++++++ 1 file changed, 7 insertions(+) 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)); From 98dc4f46110637efe56827d49a812b2ee529c204 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 19:36:45 +0300 Subject: [PATCH 02/25] Keep a file opened for writing out of the search folders --- code/ccfile.cpp | 21 ++++++++++++++ code/ccfile.h | 2 +- code/cdfile.cpp | 73 ++++++++++++++++++++++++++++++++++++++++--------- code/cdfile.h | 4 ++- 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/code/ccfile.cpp b/code/ccfile.cpp index d7951750..07830324 100644 --- a/code/ccfile.cpp +++ b/code/ccfile.cpp @@ -378,6 +378,27 @@ 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..c1b7247e 100644 --- a/code/cdfile.cpp +++ b/code/cdfile.cpp @@ -118,6 +118,13 @@ int CDFileClass::Set_Search_Drives(char * pathlist) char path[MAX_PATH]; // Working path buffer. + // A directory with no room left for a trailing separator cannot become half + // of a pathname, so it is passed over. + if (strlen(ptr) + 1 >= sizeof(path)) { + ptr = strtok(NULL, ";"); + continue; + } + /* ** 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 @@ -166,7 +173,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,6 +203,41 @@ void CDFileClass::Add_Search_Drive(char *path) } +/// +/// Adds a path to the front of the search chain, so that it is tried before every path +/// already added. The current directory is still examined first. +/// +/// The path to search before all the others. +void CDFileClass::Add_Search_Drive_Front(char const * path) +{ + SearchDriveType * srch = new SearchDriveType; + + srch->Path = strdup(path); + srch->Next = First; + + First = srch; +} + + +/// +/// 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. * * * @@ -257,17 +299,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); - - // 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()); + // 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)) { + + /* + ** 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,7 +370,7 @@ 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 ) ); diff --git a/code/cdfile.h b/code/cdfile.h index 9c64d85d..62a85e5f 100644 --- a/code/cdfile.h +++ b/code/cdfile.h @@ -59,8 +59,10 @@ class CDFileClass : public BufferIOFileClass 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 Add_Search_Drive_Front(char const * path); static void Clear_Search_Drives(void); + static char const * Search_Path(int index); static bool Find_First_File(char *buffer); static bool Find_Next_File(char *buffer); From 20dc791f4ec7c06a7a5b6fece4a292b8d3286b94 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 19:36:52 +0300 Subject: [PATCH 03/25] Add the deployment's game data and user directories --- code/gamedirs.cpp | 394 ++++++++++++++++++++++++++++++++++++++++++++++ code/gamedirs.h | 45 ++++++ 2 files changed, 439 insertions(+) create mode 100644 code/gamedirs.cpp create mode 100644 code/gamedirs.h diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp new file mode 100644 index 00000000..c6cd8175 --- /dev/null +++ b/code/gamedirs.cpp @@ -0,0 +1,394 @@ +/******************************************************************************* + * 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"; + +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)); +} + + +/* + * A directory is kept in the form a file name can simply be appended to, which is what the + * search chain has always expected of one. + */ +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); + } + } +} + + +/* + * 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. + */ +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 : "")); +} + + +/// +/// 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); +} + + +/// +/// Installs the directories the command line named. +/// 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); + } + + /* + * Ahead of everything the command line and a deployment supply, so that a file a + * player's own game acquired is the one found. + */ + CDFileClass::Add_Search_Drive_Front(UserDirectory.c_str()); + 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); +} + + +std::string User_File_Read_Name(char const * filename) +{ + if (UserDirectory.empty()) { + return(filename); + } + + std::string const path = UserDirectory + filename; + if (RawFileClass(path.c_str()).Is_Available()) { + return(path); + } + + /* + * A game only just pointed at a user directory still finds what it wrote beside itself, + * so a player keeps the settings and the saved games they already had. + */ + return(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 the game's own directory and every folder searched. +/// A name held by more than one folder is reported once, and opening that name afterwards +/// lands on the same file this scan saw, because both walk the folders 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; + + 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..ce404b17 --- /dev/null +++ b/code/gamedirs.h @@ -0,0 +1,45 @@ +/******************************************************************************* + * 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, and where one should be read from. The two + * differ while a player still has settings and saved games beside the executable: those + * are read where they are, and written where they now belong. + * + * A file object built from one of these keeps the pointer it is given rather than copying + * the name, so hold the string for as long as the file object lives. + */ +std::string User_File_Read_Name(char const * filename); +std::string User_File_Write_Name(char const * filename); + +std::vector Parse_Search_Folders(char const * list); +std::vector Search_Files(char const * pattern); From 66efd038ded5e486f07b103bd959c8340c44e7e8 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 19:36:59 +0300 Subject: [PATCH 04/25] Read the game's files through the configured directories --- code/init.cpp | 160 +++++++++++++++++++++-------------------------- code/session.cpp | 70 ++++++--------------- code/startup.cpp | 100 +++++++++++++++++++++++------ 3 files changed, 173 insertions(+), 157 deletions(-) diff --git a/code/init.cpp b/code/init.cpp index ed66b0c6..9af2d300 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); /* @@ -1797,8 +1767,18 @@ bool Parse_Command_Line(int argc, char * argv[]) /* ** File search path override. */ - if (strstr(string, "-CD")) { - CCFileClass::Set_Search_Drives(&string[3]); + if (strnicmp(string, "-CD", strlen("-CD")) == 0) { + CCFileClass::Set_Search_Drives(&original[strlen("-CD")]); + continue; + } + + 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 +2371,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 +2409,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 +2518,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 +2534,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 +2625,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/session.cpp b/code/session.cpp index 550f16f3..2511c739 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" #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,8 @@ bool SessionClass::Log_To_File(FILE *out) *=========================================================================*/ void SessionClass::Write_MultiPlayer_Settings(void) { - RawFileClass file(CONFIG_FILE_NAME); + std::string const path = User_File_Write_Name(CONFIG_FILE_NAME); + RawFileClass file(path.c_str()); { // Save the player's last-used Handle & Color ConfigINI.Put_Int("MultiPlayer", "Color", (int)PrefColor); @@ -700,38 +699,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 +723,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..c9c3b4f0 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,23 @@ 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. It is not searched for. + Session.RecordFile.Searching(false); + Session.RecordFile.Set_Name(User_File_Write_Name("RECORD.BIN").c_str()); + + std::string const config_path = User_File_Read_Name(CONFIG_FILE_NAME); + RawFileClass *cfile = new RawFileClass(config_path.c_str()); ConfigINI.Load(*cfile, false); Options.ScreenWidth = ConfigINI.Get_Int("Video", "ScreenWidth", Options.ScreenWidth); @@ -623,8 +673,12 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * command_line , in if (Special.IsFromInstall == true) { ConfigINI.Put_Bool("Intro", "PlayIntro", false); cfile->Close(); - cfile->Open(); - ConfigINI.Save(*cfile, false); + + // Written where the player's files belong, which is not necessarily where + // they were read from. + std::string const settings_path = User_File_Write_Name(CONFIG_FILE_NAME); + RawFileClass settings(settings_path.c_str()); + ConfigINI.Save(settings, false); } cfile->Close(); @@ -661,6 +715,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(); From 49cc4fa75735666e983adbad1f09d22aff21fa95 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 19:37:06 +0300 Subject: [PATCH 05/25] Write the player's own files to the user directory --- code/conquer.cpp | 12 ++++++++++-- code/loaddlg.cpp | 9 +++++---- code/mapgen.cpp | 4 +++- code/netshare.cpp | 7 +++++-- code/options.cpp | 12 +++++++++--- code/saveload.cpp | 13 +++++++------ code/score.cpp | 20 +++++++++++++------- code/sendfile.cpp | 4 +++- code/wonline.cpp | 4 +++- 9 files changed, 58 insertions(+), 27 deletions(-) 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/loaddlg.cpp b/code/loaddlg.cpp index 4e0bd607..08f2bd57 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" @@ -661,9 +662,9 @@ void LoadOptionsClass::Fill_List(HWND window) sprintf(buffer, "*.%3s", Extension); /* - ** Find all savegame files + ** Find all savegame files, where this player's own files are kept. */ - HANDLE hFind = FindFirstFile(buffer, &ff); + HANDLE hFind = FindFirstFile(User_File_Write_Name(buffer).c_str(), &ff); fdata = NULL; if (hFind != INVALID_HANDLE_VALUE) { @@ -776,7 +777,7 @@ bool LoadOptionsClass::Files_Present(void) sprintf(pattern, "*.%3s", Extension); WIN32_FIND_DATAA find_data; - HANDLE hFind = FindFirstFile(pattern, &find_data); + HANDLE hFind = FindFirstFile(User_File_Write_Name(pattern).c_str(), &find_data); if (hFind != INVALID_HANDLE_VALUE) { while (true) { @@ -881,7 +882,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/mapgen.cpp b/code/mapgen.cpp index 349b8846..a5f6ee16 100644 --- a/code/mapgen.cpp +++ b/code/mapgen.cpp @@ -29,6 +29,7 @@ #include "coord.h" #include "data.h" #include "dbgprint.h" +#include "gamedirs.h" #include "house.h" #include "houstype.h" #include "incdec.h" @@ -4379,7 +4380,8 @@ bool MapSeedClass::Save_File(const char * file_name, const char * descr) { if (file_name != NULL) { DebugString("Saving random map: %s - %s\n", file_name, descr); - CCFileClass file(file_name); + std::string const path = User_File_Write_Name(file_name); + RawFileClass file(path.c_str()); INIClass ini; ini.Put_String("RandomMap", "Description", descr); ini.Put_Int("RandomMap", "Width", Width, 0); diff --git a/code/netshare.cpp b/code/netshare.cpp index 837319ed..c48da343 100644 --- a/code/netshare.cpp +++ b/code/netshare.cpp @@ -15,6 +15,7 @@ #include "conquer.h" #include "data.h" #include "dbgprint.h" +#include "gamedirs.h" #include "globals.h" #include "goptions.h" #include "ipxmgr.h" @@ -1483,7 +1484,8 @@ void Receive_Random_Map_Preview(void) } DebugString("Loading the compressed preview image\n"); - RawFileClass file(preview_name); + std::string const preview_path = User_File_Read_Name(preview_name); + RawFileClass file(preview_path.c_str()); int size = file.Size(); char * buffer = new char[size]; file.Read(buffer, size); @@ -1632,7 +1634,8 @@ void Send_Preview_To_Guests(void) DebugString("Compressed preview image is %d bytes\n", comp_size); - RawFileClass file("Preview.bin"); + std::string const preview_path = User_File_Write_Name("Preview.bin"); + RawFileClass file(preview_path.c_str()); if (file.Is_Available()) { file.Delete(); } diff --git a/code/options.cpp b/code/options.cpp index b1e51aa4..0047b1d5 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -68,6 +68,7 @@ #include "command.h" #include "dbgprint.h" #include "dsurface.h" +#include "gamedirs.h" #include "globals.h" #include "init.h" #include "ipxmgr.h" @@ -441,7 +442,8 @@ void OptionsClass::Load_Settings(void) *=============================================================================================*/ void OptionsClass::Save_Settings (void) { - CCFileClass file(CONFIG_FILE_NAME); + std::string const path = User_File_Write_Name(CONFIG_FILE_NAME); + RawFileClass file(path.c_str()); DebugString("Saving game settings\n"); @@ -614,7 +616,8 @@ 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"); + std::string const path = User_File_Write_Name("Keyboard.ini"); + RawFileClass file(path.c_str()); ini.Save(file, false); *retval = IDOK; return(TRUE); @@ -667,7 +670,10 @@ 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"); - CCFileClass file("KEYBOARD.INI"); + // Only the player's own file is discarded; the defaults a + // deployment ships are what the reset falls back on. + std::string const path = User_File_Write_Name("KEYBOARD.INI"); + RawFileClass file(path.c_str()); file.Delete(); Init_Hotkeys(); SendMessage(window, HKD_REINIT, 0, 0); diff --git a/code/saveload.cpp b/code/saveload.cpp index 4f940ecd..2bcaad6d 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,7 @@ bool Load_Game(const char *file_name) ** Open the file */ IStoragePtr storage; - MultiByteToWideChar(0,0,file_name, -1, name, (sizeof(name)/sizeof(WCHAR))); + MultiByteToWideChar(0,0,User_File_Read_Name(file_name).c_str(), -1, name, (sizeof(name)/sizeof(WCHAR))); if (FAILED(StgOpenStorage(name, 0, STGM_SHARE_DENY_WRITE, 0, 0, &storage))) { return(false); @@ -1323,9 +1324,9 @@ 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)); + MultiByteToWideChar(0, 0, User_File_Read_Name(name).c_str(), -1, wname, sizeof(wname) / sizeof(WCHAR)); HRESULT result = StgOpenStorage(wname, NULL, STGM_SHARE_EXCLUSIVE|STGM_READWRITE, NULL, 0, &storage); if (FAILED(result)) { diff --git a/code/score.cpp b/code/score.cpp index 701bb4ef..914b6fb6 100644 --- a/code/score.cpp +++ b/code/score.cpp @@ -58,6 +58,7 @@ #include "draw.h" #include "dsaudio.h" #include "dsurface.h" +#include "gamedirs.h" #include "goptions.h" #include "houstype.h" #include "keyboard.h" @@ -66,6 +67,7 @@ #include "mixfile.h" #include "movie.h" #include "msgloop.h" +#include "rawfile.h" #include "scenario.h" #include "session.h" #include "shapeset.h" @@ -382,12 +384,16 @@ void ScoreClass::Presentation(void) x = XPos - FullFont->String_Width(str) / 2 + 84; Alloc_Object(obj = new ScorePrintClass(str, x, YPos + 217, FullFont, false)); + // The hall of fame is the player's own, so it is not looked for in the folders the + // shared file object searches. memset(hallfame, 0, sizeof(hallfame)); file.Close(); - file.Set_Name(FAME_FILE_NAME); - if (file.Is_Available() == true) { - file.Read(hallfame, sizeof(hallfame)); - file.Close(); + + std::string const fame_path = User_File_Read_Name(FAME_FILE_NAME); + RawFileClass fame(fame_path.c_str()); + if (fame.Is_Available() == true) { + fame.Read(hallfame, sizeof(hallfame)); + fame.Close(); } /* @@ -448,9 +454,9 @@ void ScoreClass::Presentation(void) Keyboard->Clear(); - if (file.Open(FAME_FILE_NAME, FileClass::WRITE)) { - file.Write(hallfame, sizeof(hallfame)); - file.Close(); + if (fame.Open(User_File_Write_Name(FAME_FILE_NAME).c_str(), FileClass::WRITE)) { + fame.Write(hallfame, sizeof(hallfame)); + fame.Close(); } Theme.Stop(true); diff --git a/code/sendfile.cpp b/code/sendfile.cpp index e4f0a9d6..b9b74d64 100644 --- a/code/sendfile.cpp +++ b/code/sendfile.cpp @@ -40,6 +40,7 @@ #include "conquer.h" #include "dbgprint.h" +#include "gamedirs.h" #include "globals.h" #include "ini.h" #include "ipxmgr.h" @@ -194,7 +195,8 @@ 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); + std::string const save_path = User_File_Write_Name(file_name); + RawFileClass save_file (save_path.c_str()); /* ** If the file already exists then delete it and re-create it. diff --git a/code/wonline.cpp b/code/wonline.cpp index 2e8ee5b3..228ea8e5 100644 --- a/code/wonline.cpp +++ b/code/wonline.cpp @@ -26,6 +26,7 @@ #include "data.h" #include "dbgprint.h" #include "dict.h" +#include "gamedirs.h" #include "globals.h" #include "goptions.h" #include "houstype.h" @@ -746,7 +747,8 @@ void Read_WOL_Settings(void) /// void Write_WOL_Settings(void) { - RawFileClass file(CONFIG_FILE_NAME); + std::string const path = User_File_Write_Name(CONFIG_FILE_NAME); + RawFileClass file(path.c_str()); ConfigINI.Put_Int("WOnline", "AllowPage", g_AllowPage); ConfigINI.Put_Int("WOnline", "AllowFind", g_AllowFind); ConfigINI.Put_Int("WOnline", "LangFilter", g_LangFilter); From 6210c43e8b4a8716794a27b6971330af7d1a7ec6 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 19:37:13 +0300 Subject: [PATCH 06/25] Cover the game directories with a contract test --- tests/CMakeLists.txt | 1 + tests/gamedirs/CMakeLists.txt | 52 ++++ tests/gamedirs/gamedirscontract.cpp | 368 ++++++++++++++++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 tests/gamedirs/CMakeLists.txt create mode 100644 tests/gamedirs/gamedirscontract.cpp 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..95f558b0 --- /dev/null +++ b/tests/gamedirs/gamedirscontract.cpp @@ -0,0 +1,368 @@ +/******************************************************************************* + * 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(); +} + + +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, "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::Search_Path(0) != NULL && std::string(CDFileClass::Search_Path(0)) == expected_user, + "the user directory is searched ahead of everything else"); + + Check(User_File_Write_Name("SUN.INI") == expected_user + "SUN.INI", + "a file the game writes goes to the user directory"); + Check(User_File_Read_Name("SUN.INI") == "SUN.INI", + "a file with no copy in the user directory is still read where it was"); + + Write_File(expected_user + "SUN.INI", "[Options]\n"); + Check(User_File_Read_Name("SUN.INI") == expected_user + "SUN.INI", + "once written, the user's own copy is the one read"); + + Reset(); + Check(User_File_Write_Name("SUN.INI") == "SUN.INI", + "with no user directory a written file keeps its plain name"); + Check(User_File_Read_Name("SUN.INI") == "SUN.INI", + "with no user directory a read 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"); +} + + +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(); + + Reset(); + Remove_Root(); + + std::printf("\n%s\n", Failures == 0 ? "All checks passed." : "There were failures."); + return(Failures == 0 ? 0 : 1); +} From 667de5e7ac05dde8d4203d3b035e86ff3e46717e Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 19:37:19 +0300 Subject: [PATCH 07/25] Document the directory options and the deployment file --- manual/changes/command-line-spaces.md | 26 ++++++++++ manual/changes/deployment-search-folders.md | 37 ++++++++++++++ manual/changes/game-data-directory.md | 19 ++++++++ manual/changes/user-data-directory.md | 23 +++++++++ manual/content/formats/opents-ini.md | 54 +++++++++++++++++++++ manual/content/using/game-data.md | 12 +++++ manual/data/command-adapters.yaml | 16 ++++++ manual/data/commands.yaml | 23 +++++++++ manual/data/ini-read-exclusions.yaml | 6 +++ 9 files changed, 216 insertions(+) create mode 100644 manual/changes/command-line-spaces.md create mode 100644 manual/changes/deployment-search-folders.md create mode 100644 manual/changes/game-data-directory.md create mode 100644 manual/changes/user-data-directory.md create mode 100644 manual/content/formats/opents-ini.md diff --git a/manual/changes/command-line-spaces.md b/manual/changes/command-line-spaces.md new file mode 100644 index 00000000..1759edc2 --- /dev/null +++ b/manual/changes/command-line-spaces.md @@ -0,0 +1,26 @@ +--- +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. + +`-CD` is now matched at the start of an argument rather than anywhere within +one, and keeps the case its path was written in, so an unrelated argument that +happens to contain those letters no longer adds a search path built from the +wrong part of it. A directory too long to have a file name appended to it is +passed over by the 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..e3f460a1 --- /dev/null +++ b/manual/changes/deployment-search-folders.md @@ -0,0 +1,37 @@ +--- +title: Search the folders a deployment keeps its files in +category: feature +release: 0.2.0 +breaking: true +migration: +- Rename an `INI` or `MIX` 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=.`. +targets: +- type: format + id: opents-ini + effect: added +- type: command + id: launch:cd-path + effect: changed +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` and `MIX`, +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, which +also means the folders a `-CD` argument adds now contribute to them. 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. 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, or deleted by the hotkey reset. 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/user-data-directory.md b/manual/changes/user-data-directory.md new file mode 100644 index 00000000..806b321a --- /dev/null +++ b/manual/changes/user-data-directory.md @@ -0,0 +1,23 @@ +--- +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: the settings +file, hotkeys, saved games, the hall of fame, recordings, saved random maps and +the files a multiplayer game downloads. The directory is created when it is not +there yet, and it is searched ahead of the game's own folders, so a map a player +received is found before a copy the deployment shipped. + +Settings and saved games a player already has beside the executable are still +read from there until the game writes them to the new directory, so pointing an +existing installation at one carries them forward. + +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..b05b3385 --- /dev/null +++ b/manual/content/formats/opents-ini.md @@ -0,0 +1,54 @@ +--- +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: command + id: launch:cd-path + - 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,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` were written: a distribution can sort its files into `INI` and `MIX` 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 game's own directory, always examined first; +2. the user data directory, when [`-USERDIR`](/using/command-line/user-directory/) names one; +3. the folders [`-CD`](/using/command-line/cd-path/) added, in the order given; +4. the game data directory, when `-DATADIR` names one; +5. the folders `SearchPaths` lists, in the order written. + +Everything the game opens through its file layer 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. + +Wildcard searches — for rules, battle files, map packs, map archives and movie archives — cover every folder in the list rather than stopping at the first that holds a match. A name held by more than one folder is used once, from the folder 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 other files the game writes are never written into a searched folder. They belong to the player, and go to the user data directory, or to the game's own directory when there is none. A folder listed here is only ever read from. +::: diff --git a/manual/content/using/game-data.md b/manual/content/using/game-data.md index 62bda164..6c6370c3 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 read-only copy of the data serve several people, each writing only to their own directory. + +The data may be sorted into folders rather than left in one directory. Without any configuration the game also searches `INI` and `MIX`; [`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..7b723d60 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -474,6 +474,22 @@ launch_options: availability: *all sites: - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-CD' } + - 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:-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..4800bf01 100644 --- a/manual/data/commands.yaml +++ b/manual/data/commands.yaml @@ -1775,6 +1775,29 @@ launch_options: _provenance: source: code/init.cpp guard: null +- id: launch:data-directory + route_id: data-directory + kind: launch + 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: -USERDIR= + _provenance: + source: code/init.cpp + guard: null - id: launch:tournament-time route_id: tournament-time kind: launch diff --git a/manual/data/ini-read-exclusions.yaml b/manual/data/ini-read-exclusions.yaml index 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. From 398671e8934262760934e32b028b51163d827214 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 19:42:06 +0300 Subject: [PATCH 08/25] Carry new file-layer prose in ordinary comment form --- code/ccfile.cpp | 6 ++---- code/loaddlg.cpp | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/code/ccfile.cpp b/code/ccfile.cpp index 07830324..36c09a5e 100644 --- a/code/ccfile.cpp +++ b/code/ccfile.cpp @@ -386,10 +386,8 @@ void CCFileClass::Close(void) /// 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. - */ + // 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)); } diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp index 08f2bd57..e0e78818 100644 --- a/code/loaddlg.cpp +++ b/code/loaddlg.cpp @@ -662,7 +662,7 @@ void LoadOptionsClass::Fill_List(HWND window) sprintf(buffer, "*.%3s", Extension); /* - ** Find all savegame files, where this player's own files are kept. + ** Find all savegame files */ HANDLE hFind = FindFirstFile(User_File_Write_Name(buffer).c_str(), &ff); fdata = NULL; From 17ea7f78ca12c5757ac4c7e1700fd3d7762ffe5b Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 21:36:25 +0300 Subject: [PATCH 09/25] Resolve a written or deleted file to the user directory --- code/cdfile.cpp | 164 +++++++++++++++++++++++++++++++++++++++++++++--- code/cdfile.h | 38 +++++++++-- 2 files changed, 191 insertions(+), 11 deletions(-) diff --git a/code/cdfile.cpp b/code/cdfile.cpp index c1b7247e..6cdf79e8 100644 --- a/code/cdfile.cpp +++ b/code/cdfile.cpp @@ -46,15 +46,21 @@ */ CDFileClass::SearchDriveType * CDFileClass::First = NULL; +/* +** Where this player's own files are kept. +*/ +char const * CDFileClass::UserPath = NULL; + /// /// 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 +72,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,6 +102,15 @@ CDFileClass::CDFileClass(void) : *=============================================================================================*/ int CDFileClass::Open(int rights) { + /* + ** 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(); + } + return(BASECLASS::Open(rights)); } @@ -219,6 +241,114 @@ void CDFileClass::Add_Search_Drive_Front(char const * 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) +{ + if (UserPath != NULL) { + free((char *)UserPath); + UserPath = NULL; + } + + if (path == NULL || *path == '\0') return; + + char terminated[MAX_PATH]; + + // A directory with no room left for a trailing separator cannot become half of a + // pathname, so it is refused rather than kept in a form nothing can be appended to. + if (strlen(path) + 1 >= sizeof(terminated)) return; + + strcpy(terminated, path); + switch (terminated[strlen(terminated)-1]) { + case ':': + case '/': + case '\\': + break; + + default: + strcat(terminated, "\\"); + break; + } + + UserPath = strdup(terminated); +} + + +char const * CDFileClass::User_Path(void) +{ + return(UserPath); +} + + +/* +** A name that names a directory of its own has already 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. +*/ +bool CDFileClass::Has_Directory(char const * filename) +{ + return(filename != NULL && strpbrk(filename, "\\/:") != NULL); +} + + +/* +** Where a file belongs once it is the player's own. Fails when there is no such directory, +** 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 == NULL || filename == NULL) return(false); + if (Has_Directory(filename)) return(false); + if ((int)(strlen(UserPath) + strlen(filename)) >= size) return(false); + + strcpy(buffer, UserPath); + 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. +*/ +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, which is 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. @@ -281,13 +411,19 @@ 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); + /* ** 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 @@ -372,8 +508,8 @@ int CDFileClass::Open(char const *filename, int rights) */ if (IsDisabled || (rights & WRITE) != 0) { - BASECLASS::Set_Name( filename ); - return( BASECLASS::Open( rights ) ); + BASECLASS::Set_Name( Capture_Name(filename) ); + return( CDFileClass::Open( rights ) ); } /* @@ -385,6 +521,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 62a85e5f..605b07e1 100644 --- a/code/cdfile.h +++ b/code/cdfile.h @@ -36,9 +36,12 @@ /* * 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. + * + * 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 Set_Search_Drives(), * which takes them in the same semicolon separated form the DOS PATH variable used. @@ -50,11 +53,12 @@ class CDFileClass : public BufferIOFileClass public: CDFileClass(char const *filename); CDFileClass(void); - virtual ~CDFileClass(void) override {}; + virtual ~CDFileClass(void) override; 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;}; @@ -64,17 +68,33 @@ class CDFileClass : public BufferIOFileClass 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); static void Find_Close(void); 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. @@ -88,4 +108,14 @@ class CDFileClass : public BufferIOFileClass ** This points to the first path record. */ static SearchDriveType * First; + + /* + ** Where this player's own files are kept, ending in a separator. NULL when the + ** player has no directory of their own and everything belongs together. + */ + static char const * UserPath; + + // A file object owns the name it captured, so it is not copied. + CDFileClass(CDFileClass const & file); + CDFileClass & operator = (CDFileClass const & file); }; From 55a5946da7867044062f971fb4bc3c513b559cfc Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 21:37:13 +0300 Subject: [PATCH 10/25] Read a player's own copy before the game's own directory --- code/cdfile.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/code/cdfile.cpp b/code/cdfile.cpp index 6cdf79e8..9ef49af6 100644 --- a/code/cdfile.cpp +++ b/code/cdfile.cpp @@ -417,6 +417,21 @@ char const * CDFileClass::Set_Name(char const *filename) */ 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 From a4922569c2a0d568f59d28c4e4d09b5e28f24109 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 21:39:13 +0300 Subject: [PATCH 11/25] Install the user directory in the file layer --- code/cdfile.cpp | 16 ---------------- code/cdfile.h | 1 - code/gamedirs.cpp | 26 +++++++++++++++++--------- tests/gamedirs/gamedirscontract.cpp | 16 ++++++---------- 4 files changed, 23 insertions(+), 36 deletions(-) diff --git a/code/cdfile.cpp b/code/cdfile.cpp index 9ef49af6..18e08b43 100644 --- a/code/cdfile.cpp +++ b/code/cdfile.cpp @@ -225,22 +225,6 @@ void CDFileClass::Add_Search_Drive(char const * path) } -/// -/// Adds a path to the front of the search chain, so that it is tried before every path -/// already added. The current directory is still examined first. -/// -/// The path to search before all the others. -void CDFileClass::Add_Search_Drive_Front(char const * path) -{ - SearchDriveType * srch = new SearchDriveType; - - srch->Path = strdup(path); - srch->Next = First; - - First = srch; -} - - /// /// 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 diff --git a/code/cdfile.h b/code/cdfile.h index 605b07e1..da56c170 100644 --- a/code/cdfile.h +++ b/code/cdfile.h @@ -64,7 +64,6 @@ class CDFileClass : public BufferIOFileClass static int Set_Search_Drives(char * pathlist); static void Add_Search_Drive(char const * path); - static void Add_Search_Drive_Front(char const * path); static void Clear_Search_Drives(void); static char const * Search_Path(int index); diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp index c6cd8175..2e5a7835 100644 --- a/code/gamedirs.cpp +++ b/code/gamedirs.cpp @@ -146,6 +146,10 @@ void Set_Data_Directory(char const * 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()); } @@ -218,7 +222,7 @@ std::vector Parse_Search_Folders(char const * list) /// -/// Installs the directories the command line named. +/// 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. @@ -232,11 +236,6 @@ bool Apply_Game_Directories(void) return(false); } - /* - * Ahead of everything the command line and a deployment supply, so that a file a - * player's own game acquired is the one found. - */ - CDFileClass::Add_Search_Drive_Front(UserDirectory.c_str()); DebugString("[GameDirs] User directory is %s.\n", UserDirectory.c_str()); } @@ -363,9 +362,9 @@ static void Scan_Folder(char const * prefix, char const * pattern, std::vector -/// Finds the files matching a pattern in the game's own directory and every folder searched. -/// A name held by more than one folder is reported once, and opening that name afterwards -/// lands on the same file this scan saw, because both walk the folders in the same order. +/// 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. /// @@ -375,6 +374,15 @@ 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++) { diff --git a/tests/gamedirs/gamedirscontract.cpp b/tests/gamedirs/gamedirscontract.cpp index 95f558b0..23e28634 100644 --- a/tests/gamedirs/gamedirscontract.cpp +++ b/tests/gamedirs/gamedirscontract.cpp @@ -199,23 +199,19 @@ void Test_User_Directory(void) "the created user directory is on the disk"); std::string const expected_user = Root + "\\User\\Fresh\\"; - Check(CDFileClass::Search_Path(0) != NULL && std::string(CDFileClass::Search_Path(0)) == expected_user, - "the user directory is searched ahead of everything else"); + 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"); - Check(User_File_Read_Name("SUN.INI") == "SUN.INI", - "a file with no copy in the user directory is still read where it was"); - - Write_File(expected_user + "SUN.INI", "[Options]\n"); - Check(User_File_Read_Name("SUN.INI") == expected_user + "SUN.INI", - "once written, the user's own copy is the one read"); 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"); - Check(User_File_Read_Name("SUN.INI") == "SUN.INI", - "with no user directory a read file keeps its plain name"); } From f64879b1970a57da0567bbab011479787a00fedd Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 21:42:41 +0300 Subject: [PATCH 12/25] Let the file classes place the player's own files --- code/gamedirs.cpp | 19 ------------------- code/gamedirs.h | 10 +++------- code/mapgen.cpp | 4 +--- code/netshare.cpp | 7 ++----- code/options.cpp | 10 +++------- code/saveload.cpp | 6 ++++-- code/score.cpp | 20 +++++++------------- code/sendfile.cpp | 5 ++--- code/session.cpp | 5 ++--- code/startup.cpp | 18 +++++++----------- code/wonline.cpp | 4 +--- 11 files changed, 32 insertions(+), 76 deletions(-) diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp index 2e5a7835..9bd20a95 100644 --- a/code/gamedirs.cpp +++ b/code/gamedirs.cpp @@ -310,25 +310,6 @@ std::string User_File_Write_Name(char const * filename) } -std::string User_File_Read_Name(char const * filename) -{ - if (UserDirectory.empty()) { - return(filename); - } - - std::string const path = UserDirectory + filename; - if (RawFileClass(path.c_str()).Is_Available()) { - return(path); - } - - /* - * A game only just pointed at a user directory still finds what it wrote beside itself, - * so a player keeps the settings and the saved games they already had. - */ - return(filename); -} - - static void Scan_Folder(char const * prefix, char const * pattern, std::vector & names) { std::string const search = std::string(prefix) + pattern; diff --git a/code/gamedirs.h b/code/gamedirs.h index ce404b17..40068f9b 100644 --- a/code/gamedirs.h +++ b/code/gamedirs.h @@ -31,14 +31,10 @@ void Init_Search_Folders(void); char const * Game_Directory_Error(void); /* - * Where a file the game itself writes belongs, and where one should be read from. The two - * differ while a player still has settings and saved games beside the executable: those - * are read where they are, and written where they now belong. - * - * A file object built from one of these keeps the pointer it is given rather than copying - * the name, so hold the string for as long as the file object lives. + * 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_Read_Name(char const * filename); std::string User_File_Write_Name(char const * filename); std::vector Parse_Search_Folders(char const * list); diff --git a/code/mapgen.cpp b/code/mapgen.cpp index a5f6ee16..349b8846 100644 --- a/code/mapgen.cpp +++ b/code/mapgen.cpp @@ -29,7 +29,6 @@ #include "coord.h" #include "data.h" #include "dbgprint.h" -#include "gamedirs.h" #include "house.h" #include "houstype.h" #include "incdec.h" @@ -4380,8 +4379,7 @@ bool MapSeedClass::Save_File(const char * file_name, const char * descr) { if (file_name != NULL) { DebugString("Saving random map: %s - %s\n", file_name, descr); - std::string const path = User_File_Write_Name(file_name); - RawFileClass file(path.c_str()); + CCFileClass file(file_name); INIClass ini; ini.Put_String("RandomMap", "Description", descr); ini.Put_Int("RandomMap", "Width", Width, 0); diff --git a/code/netshare.cpp b/code/netshare.cpp index c48da343..4a4de038 100644 --- a/code/netshare.cpp +++ b/code/netshare.cpp @@ -15,7 +15,6 @@ #include "conquer.h" #include "data.h" #include "dbgprint.h" -#include "gamedirs.h" #include "globals.h" #include "goptions.h" #include "ipxmgr.h" @@ -1484,8 +1483,7 @@ void Receive_Random_Map_Preview(void) } DebugString("Loading the compressed preview image\n"); - std::string const preview_path = User_File_Read_Name(preview_name); - RawFileClass file(preview_path.c_str()); + CDFileClass file(preview_name); int size = file.Size(); char * buffer = new char[size]; file.Read(buffer, size); @@ -1634,8 +1632,7 @@ void Send_Preview_To_Guests(void) DebugString("Compressed preview image is %d bytes\n", comp_size); - std::string const preview_path = User_File_Write_Name("Preview.bin"); - RawFileClass file(preview_path.c_str()); + CDFileClass file("Preview.bin"); if (file.Is_Available()) { file.Delete(); } diff --git a/code/options.cpp b/code/options.cpp index 0047b1d5..de479fdd 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -68,7 +68,6 @@ #include "command.h" #include "dbgprint.h" #include "dsurface.h" -#include "gamedirs.h" #include "globals.h" #include "init.h" #include "ipxmgr.h" @@ -442,8 +441,7 @@ void OptionsClass::Load_Settings(void) *=============================================================================================*/ void OptionsClass::Save_Settings (void) { - std::string const path = User_File_Write_Name(CONFIG_FILE_NAME); - RawFileClass file(path.c_str()); + CCFileClass file(CONFIG_FILE_NAME); DebugString("Saving game settings\n"); @@ -616,8 +614,7 @@ BOOL CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARA ini.Put_Int("Hotkey", cmd->Get_Unique_Name(), key); } - std::string const path = User_File_Write_Name("Keyboard.ini"); - RawFileClass file(path.c_str()); + CDFileClass file("Keyboard.ini"); ini.Save(file, false); *retval = IDOK; return(TRUE); @@ -672,8 +669,7 @@ BOOL CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARA 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. - std::string const path = User_File_Write_Name("KEYBOARD.INI"); - RawFileClass file(path.c_str()); + CCFileClass file("KEYBOARD.INI"); file.Delete(); Init_Hotkeys(); SendMessage(window, HKD_REINIT, 0, 0); diff --git a/code/saveload.cpp b/code/saveload.cpp index 2bcaad6d..1b6ee1da 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -1179,8 +1179,10 @@ bool Load_Game(const char *file_name) /* ** Open the file */ + // Structured storage does not go through the game's file system, so the file layer is + // asked where the save is before the name is handed over. IStoragePtr storage; - MultiByteToWideChar(0,0,User_File_Read_Name(file_name).c_str(), -1, name, (sizeof(name)/sizeof(WCHAR))); + 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); @@ -1326,7 +1328,7 @@ bool Get_Savefile_Info(char const * name, SaveVersionInfo * info) IStoragePtr storage; WCHAR wname[MAX_PATH]; - MultiByteToWideChar(0, 0, User_File_Read_Name(name).c_str(), -1, wname, sizeof(wname) / sizeof(WCHAR)); + 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/score.cpp b/code/score.cpp index 914b6fb6..701bb4ef 100644 --- a/code/score.cpp +++ b/code/score.cpp @@ -58,7 +58,6 @@ #include "draw.h" #include "dsaudio.h" #include "dsurface.h" -#include "gamedirs.h" #include "goptions.h" #include "houstype.h" #include "keyboard.h" @@ -67,7 +66,6 @@ #include "mixfile.h" #include "movie.h" #include "msgloop.h" -#include "rawfile.h" #include "scenario.h" #include "session.h" #include "shapeset.h" @@ -384,16 +382,12 @@ void ScoreClass::Presentation(void) x = XPos - FullFont->String_Width(str) / 2 + 84; Alloc_Object(obj = new ScorePrintClass(str, x, YPos + 217, FullFont, false)); - // The hall of fame is the player's own, so it is not looked for in the folders the - // shared file object searches. memset(hallfame, 0, sizeof(hallfame)); file.Close(); - - std::string const fame_path = User_File_Read_Name(FAME_FILE_NAME); - RawFileClass fame(fame_path.c_str()); - if (fame.Is_Available() == true) { - fame.Read(hallfame, sizeof(hallfame)); - fame.Close(); + file.Set_Name(FAME_FILE_NAME); + if (file.Is_Available() == true) { + file.Read(hallfame, sizeof(hallfame)); + file.Close(); } /* @@ -454,9 +448,9 @@ void ScoreClass::Presentation(void) Keyboard->Clear(); - if (fame.Open(User_File_Write_Name(FAME_FILE_NAME).c_str(), FileClass::WRITE)) { - fame.Write(hallfame, sizeof(hallfame)); - fame.Close(); + if (file.Open(FAME_FILE_NAME, FileClass::WRITE)) { + file.Write(hallfame, sizeof(hallfame)); + file.Close(); } Theme.Stop(true); diff --git a/code/sendfile.cpp b/code/sendfile.cpp index b9b74d64..a8d65664 100644 --- a/code/sendfile.cpp +++ b/code/sendfile.cpp @@ -39,8 +39,8 @@ #include "always.h" #include "conquer.h" +#include "cdfile.h" #include "dbgprint.h" -#include "gamedirs.h" #include "globals.h" #include "ini.h" #include "ipxmgr.h" @@ -195,8 +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); - std::string const save_path = User_File_Write_Name(file_name); - RawFileClass save_file (save_path.c_str()); + 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 2511c739..f9d51198 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -56,7 +56,7 @@ #include "conquer.h" #include "data.h" #include "dbgprint.h" -#include "gamedirs.h" +#include "gamedirs.h" // for Search_Files. #include "globals.h" #include "ipxmgr.h" #include "language\language.h" @@ -602,8 +602,7 @@ bool SessionClass::Log_To_File(FILE *out) *=========================================================================*/ void SessionClass::Write_MultiPlayer_Settings(void) { - std::string const path = User_File_Write_Name(CONFIG_FILE_NAME); - RawFileClass file(path.c_str()); + CDFileClass file(CONFIG_FILE_NAME); { // Save the player's last-used Handle & Color ConfigINI.Put_Int("MultiPlayer", "Color", (int)PrefColor); diff --git a/code/startup.cpp b/code/startup.cpp index c9c3b4f0..1f5851ab 100644 --- a/code/startup.cpp +++ b/code/startup.cpp @@ -562,12 +562,10 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho Init_Search_Folders(); // The recording's name was settled during static initialization, before there was - // anywhere for a player's files to go. It is not searched for. - Session.RecordFile.Searching(false); - Session.RecordFile.Set_Name(User_File_Write_Name("RECORD.BIN").c_str()); + // anywhere for a player's files to go. Naming it again settles it where it belongs. + Session.RecordFile.Set_Name("RECORD.BIN"); - std::string const config_path = User_File_Read_Name(CONFIG_FILE_NAME); - RawFileClass *cfile = new RawFileClass(config_path.c_str()); + CDFileClass *cfile = new CDFileClass(CONFIG_FILE_NAME); ConfigINI.Load(*cfile, false); Options.ScreenWidth = ConfigINI.Get_Int("Video", "ScreenWidth", Options.ScreenWidth); @@ -672,13 +670,11 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho */ if (Special.IsFromInstall == true) { ConfigINI.Put_Bool("Intro", "PlayIntro", false); - cfile->Close(); - // Written where the player's files belong, which is not necessarily where - // they were read from. - std::string const settings_path = User_File_Write_Name(CONFIG_FILE_NAME); - RawFileClass settings(settings_path.c_str()); - ConfigINI.Save(settings, false); + // Left closed, so that saving opens it for writing. Reopening it here for + // reading gave the save a handle it could not write through. + cfile->Close(); + ConfigINI.Save(*cfile, false); } cfile->Close(); diff --git a/code/wonline.cpp b/code/wonline.cpp index 228ea8e5..63fbf08c 100644 --- a/code/wonline.cpp +++ b/code/wonline.cpp @@ -26,7 +26,6 @@ #include "data.h" #include "dbgprint.h" #include "dict.h" -#include "gamedirs.h" #include "globals.h" #include "goptions.h" #include "houstype.h" @@ -747,8 +746,7 @@ void Read_WOL_Settings(void) /// void Write_WOL_Settings(void) { - std::string const path = User_File_Write_Name(CONFIG_FILE_NAME); - RawFileClass file(path.c_str()); + 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); From 20c5c9804960214891dcd6a6b86578185267ae87 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 21:44:14 +0300 Subject: [PATCH 13/25] Scan for saved games where the game reads and writes them --- code/loaddlg.cpp | 97 ++++++++++++++++++++++++------------------------ 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp index e0e78818..0f2e5ea6 100644 --- a/code/loaddlg.cpp +++ b/code/loaddlg.cpp @@ -508,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 { @@ -606,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 * * * @@ -662,40 +681,30 @@ void LoadOptionsClass::Fill_List(HWND window) sprintf(buffer, "*.%3s", Extension); /* - ** Find all savegame files + ** Find all savegame files, wherever the game reads and writes them. */ - HANDLE hFind = FindFirstFile(User_File_Write_Name(buffer).c_str(), &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) { @@ -777,30 +786,22 @@ bool LoadOptionsClass::Files_Present(void) sprintf(pattern, "*.%3s", Extension); WIN32_FIND_DATAA find_data; - HANDLE hFind = FindFirstFile(User_File_Write_Name(pattern).c_str(), &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); } From 7daf1b117ea3451332770f687738611e09d8584d Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 21:45:21 +0300 Subject: [PATCH 14/25] Cover the file layer's directory rules with the contract test --- tests/gamedirs/gamedirscontract.cpp | 169 ++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/tests/gamedirs/gamedirscontract.cpp b/tests/gamedirs/gamedirscontract.cpp index 23e28634..8188ad15 100644 --- a/tests/gamedirs/gamedirscontract.cpp +++ b/tests/gamedirs/gamedirscontract.cpp @@ -75,6 +75,31 @@ void Write_File(std::string const & path, char const * contents) } +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); @@ -301,6 +326,145 @@ void Test_Long_Names(void) } +/* + * 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"); +} + + +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]; @@ -355,6 +519,11 @@ int main(void) 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_A_Name_With_A_Directory_Is_Left_Alone(); + Test_Placing_A_File_Is_Repeatable(); + Test_Without_A_User_Directory_Nothing_Moves(); Reset(); Remove_Root(); From 7dd45915b9f418aa86598ca50b2750ad0ffc7af5 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 21:49:47 +0300 Subject: [PATCH 15/25] Record where the file layer keeps a player's own files --- manual/changes/deployment-search-folders.md | 14 ++++++++++---- manual/changes/user-data-directory.md | 21 +++++++++++++-------- manual/content/formats/opents-ini.md | 12 +++++++----- manual/content/using/game-data.md | 2 +- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/manual/changes/deployment-search-folders.md b/manual/changes/deployment-search-folders.md index e3f460a1..2bba5a2c 100644 --- a/manual/changes/deployment-search-folders.md +++ b/manual/changes/deployment-search-folders.md @@ -31,7 +31,13 @@ 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. 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, or deleted by the hotkey reset. +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/user-data-directory.md b/manual/changes/user-data-directory.md index 806b321a..542fd7dc 100644 --- a/manual/changes/user-data-directory.md +++ b/manual/changes/user-data-directory.md @@ -9,15 +9,20 @@ targets: credit: [ZivDero] --- -`-USERDIR=` tells the game where to keep what it writes: the settings -file, hotkeys, saved games, the hall of fame, recordings, saved random maps and -the files a multiplayer game downloads. The directory is created when it is not -there yet, and it is searched ahead of the game's own folders, so a map a player -received is found before a copy the deployment shipped. +`-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. -Settings and saved games a player already has beside the executable are still -read from there until the game writes them to the new directory, so pointing an -existing installation at one carries them forward. +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 index b05b3385..82022d51 100644 --- a/manual/content/formats/opents-ini.md +++ b/manual/content/formats/opents-ini.md @@ -39,16 +39,18 @@ The game data directory is what [`-DATADIR`](/using/command-line/data-directory/ ## The order files are searched for in -1. the game's own directory, always examined first; -2. the user data directory, when [`-USERDIR`](/using/command-line/user-directory/) names one; +1. the user data directory, when [`-USERDIR`](/using/command-line/user-directory/) names one; +2. the game's own directory; 3. the folders [`-CD`](/using/command-line/cd-path/) added, in the order given; 4. the game data directory, when `-DATADIR` names one; 5. the folders `SearchPaths` lists, in the order written. -Everything the game opens through its file layer 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. +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. -Wildcard searches — for rules, battle files, map packs, map archives and movie archives — cover every folder in the list rather than stopping at the first that holds a match. A name held by more than one folder is used once, from the folder that comes first, which is the same copy an ordinary open of that name would land on. +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 other files the game writes are never written into a searched folder. They belong to the player, and go to the user data directory, or to the game's own directory when there is none. A folder listed here is only ever read from. +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 6c6370c3..a4adbfb7 100644 --- a/manual/content/using/game-data.md +++ b/manual/content/using/game-data.md @@ -27,6 +27,6 @@ Do not place game data in the CMake build directory. The build copies OpenTS exe ## 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 read-only copy of the data serve several people, each writing only to their own directory. +`-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` and `MIX`; [`OPENTS.INI`](/formats/opents-ini/) names other folders and the order they are searched in. From 854917ef1e9195c3180832522de955d779d4e7ee Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 22:33:50 +0300 Subject: [PATCH 16/25] Prove a hotkey reset keeps the shipped default --- tests/gamedirs/gamedirscontract.cpp | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/gamedirs/gamedirscontract.cpp b/tests/gamedirs/gamedirscontract.cpp index 8188ad15..d49c04b9 100644 --- a/tests/gamedirs/gamedirscontract.cpp +++ b/tests/gamedirs/gamedirscontract.cpp @@ -394,6 +394,45 @@ void Test_The_File_Layer_Deletes_Only_The_Player_Copy(void) } +/* + * 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(); @@ -521,6 +560,7 @@ int main(void) 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(); From 691a31ba0e05a41a34671a044786be2c7d1d2043 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 23:00:50 +0300 Subject: [PATCH 17/25] Retire the -CD launch option --- code/cdfile.cpp | 68 +--------------- code/cdfile.h | 4 +- code/init.cpp | 8 -- manual/changes/command-line-spaces.md | 7 +- manual/changes/deployment-search-folders.md | 6 +- manual/changes/retire-cd-path.md | 23 ++++++ manual/content/formats/opents-ini.md | 7 +- manual/data/command-adapters.yaml | 8 -- manual/data/commands.yaml | 11 --- manual/data/tombstones.yaml | 10 +++ .../tools/tests/test_lifecycle_expansion.py | 79 +++++++++++++++++++ manual/tools/versioning.py | 6 +- 12 files changed, 124 insertions(+), 113 deletions(-) create mode 100644 manual/changes/retire-cd-path.md diff --git a/code/cdfile.cpp b/code/cdfile.cpp index 18e08b43..4f53b62b 100644 --- a/code/cdfile.cpp +++ b/code/cdfile.cpp @@ -115,72 +115,6 @@ int CDFileClass::Open(int 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. - - // A directory with no room left for a trailing separator cannot become half - // of a pathname, so it is passed over. - if (strlen(ptr) + 1 >= sizeof(path)) { - ptr = strtok(NULL, ";"); - continue; - } - - /* - ** 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, ";"); - } - - free(copy_pathlist); - - if (!found) return(1); - return(0); -} - - /*********************************************************************************************** * CDFC::Add_Search_Drive -- Add a new path to the search path list * * * @@ -355,7 +289,7 @@ char const * CDFileClass::Search_Path(int index) /*********************************************************************************************** * 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 * diff --git a/code/cdfile.h b/code/cdfile.h index da56c170..47e323e5 100644 --- a/code/cdfile.h +++ b/code/cdfile.h @@ -43,8 +43,7 @@ * 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 Set_Search_Drives(), - * which takes them in the same semicolon separated form the DOS PATH variable used. + * The search order is whatever order the directories were handed to Add_Search_Drive(). */ class CDFileClass : public BufferIOFileClass { @@ -62,7 +61,6 @@ class CDFileClass : public BufferIOFileClass void Searching(int on) {IsDisabled = !on;}; - static int Set_Search_Drives(char * pathlist); static void Add_Search_Drive(char const * path); static void Clear_Search_Drives(void); static char const * Search_Path(int index); diff --git a/code/init.cpp b/code/init.cpp index 9af2d300..cdca24b8 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -1764,14 +1764,6 @@ bool Parse_Command_Line(int argc, char * argv[]) #endif - /* - ** File search path override. - */ - if (strnicmp(string, "-CD", strlen("-CD")) == 0) { - CCFileClass::Set_Search_Drives(&original[strlen("-CD")]); - continue; - } - if (strnicmp(string, "-DATADIR=", strlen("-DATADIR=")) == 0) { Set_Data_Directory(&original[strlen("-DATADIR=")]); continue; diff --git a/manual/changes/command-line-spaces.md b/manual/changes/command-line-spaces.md index 1759edc2..0eb6fff2 100644 --- a/manual/changes/command-line-spaces.md +++ b/manual/changes/command-line-spaces.md @@ -19,8 +19,5 @@ 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. -`-CD` is now matched at the start of an argument rather than anywhere within -one, and keeps the case its path was written in, so an unrelated argument that -happens to contain those letters no longer adds a search path built from the -wrong part of it. A directory too long to have a file name appended to it is -passed over by the search rather than truncated into a different one. +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 index 2bba5a2c..350ef2dc 100644 --- a/manual/changes/deployment-search-folders.md +++ b/manual/changes/deployment-search-folders.md @@ -9,9 +9,6 @@ targets: - type: format id: opents-ini effect: added -- type: command - id: launch:cd-path - effect: changed credit: [ZivDero] --- @@ -21,8 +18,7 @@ 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, which -also means the folders a `-CD` argument adds now contribute to them. Names are +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. diff --git a/manual/changes/retire-cd-path.md b/manual/changes/retire-cd-path.md new file mode 100644 index 00000000..54d2492b --- /dev/null +++ b/manual/changes/retire-cd-path.md @@ -0,0 +1,23 @@ +--- +title: Retire the -CD launch option +category: feature +release: 0.2.0 +breaking: true +migration: +- Replace `-CD` in a shortcut or launcher with `-DATADIR=` when the path holds the game's data, or name the folder in the deployment's `OPENTS.INI` `SearchPaths`. +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. The game data +directory covers pointing the game at its data, and a deployment's own +`OPENTS.INI` names the folders that data is sorted into, so the option had +become a second, narrower way of saying either. + +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/content/formats/opents-ini.md b/manual/content/formats/opents-ini.md index 82022d51..a99add52 100644 --- a/manual/content/formats/opents-ini.md +++ b/manual/content/formats/opents-ini.md @@ -12,8 +12,6 @@ related: id: launch:data-directory - type: command id: launch:user-directory - - type: command - id: launch:cd-path - type: using id: game-data --- @@ -41,9 +39,8 @@ The game data directory is what [`-DATADIR`](/using/command-line/data-directory/ 1. the user data directory, when [`-USERDIR`](/using/command-line/user-directory/) names one; 2. the game's own directory; -3. the folders [`-CD`](/using/command-line/cd-path/) added, in the order given; -4. the game data directory, when `-DATADIR` names one; -5. the folders `SearchPaths` lists, in the order written. +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. diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index 7b723d60..aa54443b 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -466,14 +466,6 @@ 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 - availability: *all - sites: - - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-CD' } - id: launch:data-directory title: Game data directory syntax: -DATADIR= diff --git a/manual/data/commands.yaml b/manual/data/commands.yaml index 4800bf01..b672f421 100644 --- a/manual/data/commands.yaml +++ b/manual/data/commands.yaml @@ -1764,17 +1764,6 @@ launch_options: _provenance: source: code/init.cpp guard: _DEBUG -- id: launch:cd-path - route_id: cd-path - kind: launch - title: Local data path - description: Adds the path following -CD to the local file-search list. - audience: developer - availability: *id001 - syntax: -CD - _provenance: - source: code/init.cpp - guard: null - id: launch:data-directory route_id: data-directory kind: launch diff --git a/manual/data/tombstones.yaml b/manual/data/tombstones.yaml index 1f7d6509..30d0beae 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. Replaced by the game data directory and the deployment's own search folders. + replacement: + type: command + id: launch:data-directory - 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)}") From 84144b9b4343222f42033cde32e94a0036fbace5 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 23:10:20 +0300 Subject: [PATCH 18/25] Carry the file layer's new prose in ordinary comment form --- code/cdfile.cpp | 54 +++++++++++++++++------------------------------- code/cdfile.h | 14 +++++-------- code/loaddlg.cpp | 2 +- 3 files changed, 25 insertions(+), 45 deletions(-) diff --git a/code/cdfile.cpp b/code/cdfile.cpp index 4f53b62b..dab465ed 100644 --- a/code/cdfile.cpp +++ b/code/cdfile.cpp @@ -46,9 +46,7 @@ */ CDFileClass::SearchDriveType * CDFileClass::First = NULL; -/* -** Where this player's own files are kept. -*/ +// Where this player's own files are kept. char const * CDFileClass::UserPath = NULL; @@ -102,11 +100,9 @@ CDFileClass::~CDFileClass(void) *=============================================================================================*/ int CDFileClass::Open(int rights) { - /* - ** 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. - */ + // 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(); } @@ -203,21 +199,17 @@ char const * CDFileClass::User_Path(void) } -/* -** A name that names a directory of its own has already 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. -*/ +// A name that names a directory of its own has already 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. bool CDFileClass::Has_Directory(char const * filename) { return(filename != NULL && strpbrk(filename, "\\/:") != NULL); } -/* -** Where a file belongs once it is the player's own. Fails when there is no such directory, -** when the caller has already named one, or when the two will not make one pathname. -*/ +// Where a file belongs once it is the player's own. Fails when there is no such directory, +// 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 == NULL || filename == NULL) return(false); @@ -230,11 +222,9 @@ bool CDFileClass::User_Path_For(char const * filename, char * buffer, int size) } -/* -** 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. -*/ +// 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. char const * CDFileClass::Capture_Name(char const * filename) { char * captured = (filename != NULL) ? strdup(filename) : NULL; @@ -248,11 +238,9 @@ char const * CDFileClass::Capture_Name(char const * filename) } -/* -** Points the object at the file this player's own game owns, which is 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. -*/ +// Points the object at the file this player's own game owns, which is 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; @@ -329,16 +317,12 @@ 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. - */ + // 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. - */ + // 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]; diff --git a/code/cdfile.h b/code/cdfile.h index 47e323e5..c48a16fa 100644 --- a/code/cdfile.h +++ b/code/cdfile.h @@ -85,11 +85,9 @@ class CDFileClass : public BufferIOFileClass */ 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. - */ + // 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; /* @@ -106,10 +104,8 @@ class CDFileClass : public BufferIOFileClass */ static SearchDriveType * First; - /* - ** Where this player's own files are kept, ending in a separator. NULL when the - ** player has no directory of their own and everything belongs together. - */ + // Where this player's own files are kept, ending in a separator. NULL when the + // player has no directory of their own and everything belongs together. static char const * UserPath; // A file object owns the name it captured, so it is not copied. diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp index 0f2e5ea6..9988e7d8 100644 --- a/code/loaddlg.cpp +++ b/code/loaddlg.cpp @@ -681,7 +681,7 @@ void LoadOptionsClass::Fill_List(HWND window) sprintf(buffer, "*.%3s", Extension); /* - ** Find all savegame files, wherever the game reads and writes them. + ** Find all savegame files */ fdata = NULL; From 5c298f6644ad2a122012634b7318cb8c6aad0b58 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 23:13:25 +0300 Subject: [PATCH 19/25] Document the new file layer functions as XML documentation --- code/cdfile.cpp | 38 +++++++++++++++++++++++++++----------- code/gamedirs.cpp | 20 ++++++++++++-------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/code/cdfile.cpp b/code/cdfile.cpp index dab465ed..8557f92b 100644 --- a/code/cdfile.cpp +++ b/code/cdfile.cpp @@ -199,17 +199,27 @@ char const * CDFileClass::User_Path(void) } -// A name that names a directory of its own has already 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. +/// +/// 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); } -// Where a file belongs once it is the player's own. Fails when there is no such directory, -// when the caller has already named one, or when the two will not make one pathname. +/// +/// 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 == NULL || filename == NULL) return(false); @@ -222,9 +232,13 @@ bool CDFileClass::User_Path_For(char const * filename, char * buffer, int size) } -// 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. +/// +/// 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; @@ -238,9 +252,11 @@ char const * CDFileClass::Capture_Name(char const * filename) } -// Points the object at the file this player's own game owns, which is 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. +/// +/// 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; diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp index 9bd20a95..18d27bbe 100644 --- a/code/gamedirs.cpp +++ b/code/gamedirs.cpp @@ -51,10 +51,12 @@ static std::string Trim_Path(std::string const & path) } -/* - * A directory is kept in the form a file name can simply be appended to, which is what the - * search chain has always expected of one. - */ +/// +/// 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()) { @@ -94,10 +96,12 @@ static bool Is_Registered(std::string const & path) } -/* - * 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. - */ +/// +/// 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); From 8152a56adb78b55a480cbc7fa4172424b8e9450e Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 00:27:33 +0300 Subject: [PATCH 20/25] Name every path the retired -CD option is replaced by --- manual/changes/retire-cd-path.md | 12 +++++++----- manual/data/tombstones.yaml | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/manual/changes/retire-cd-path.md b/manual/changes/retire-cd-path.md index 54d2492b..9f14f1de 100644 --- a/manual/changes/retire-cd-path.md +++ b/manual/changes/retire-cd-path.md @@ -4,7 +4,9 @@ category: feature release: 0.2.0 breaking: true migration: -- Replace `-CD` in a shortcut or launcher with `-DATADIR=` when the path holds the game's data, or name the folder in the deployment's `OPENTS.INI` `SearchPaths`. +- 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 @@ -13,10 +15,10 @@ 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. The game data -directory covers pointing the game at its data, and a deployment's own -`OPENTS.INI` names the folders that data is sorted into, so the option had -become a second, narrower way of saying either. +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 diff --git a/manual/data/tombstones.yaml b/manual/data/tombstones.yaml index 30d0beae..2cd0479c 100644 --- a/manual/data/tombstones.yaml +++ b/manual/data/tombstones.yaml @@ -12,10 +12,10 @@ search_aliases: - Local data path - -CD - summary: Added the path following -CD to the local file-search list. Replaced by the game data directory and the deployment's own search folders. + 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: command - id: launch:data-directory + type: format + id: opents-ini - type: key id: ModemName route: /keys/modemname/ From 5696af9064bfcd686d97bafce231a86d0aefc3d7 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 01:02:45 +0300 Subject: [PATCH 21/25] Own the user directory with a string instead of by hand --- code/cdfile.cpp | 34 ++++++++++++++-------------------- code/cdfile.h | 4 ---- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/code/cdfile.cpp b/code/cdfile.cpp index 8557f92b..a65ea183 100644 --- a/code/cdfile.cpp +++ b/code/cdfile.cpp @@ -41,13 +41,17 @@ #include "cdfile.h" +#include + /* ** Pointer to the first search path record. */ CDFileClass::SearchDriveType * CDFileClass::First = NULL; -// Where this player's own files are kept. -char const * CDFileClass::UserPath = NULL; +// Where this player's own files are kept, ending in a separator, or empty when the player +// has no directory of their own. It is kept here rather than in the class so that the file +// classes, which nearly every part of the game includes, need no standard library header. +static std::string UserPath; /// @@ -164,38 +168,28 @@ void CDFileClass::Add_Search_Drive(char const * path) /// The directory to keep the player's own files in. void CDFileClass::Set_User_Path(char const * path) { - if (UserPath != NULL) { - free((char *)UserPath); - UserPath = NULL; - } + UserPath.clear(); if (path == NULL || *path == '\0') return; - char terminated[MAX_PATH]; + UserPath = path; - // A directory with no room left for a trailing separator cannot become half of a - // pathname, so it is refused rather than kept in a form nothing can be appended to. - if (strlen(path) + 1 >= sizeof(terminated)) return; - - strcpy(terminated, path); - switch (terminated[strlen(terminated)-1]) { + switch (UserPath[UserPath.length()-1]) { case ':': case '/': case '\\': break; default: - strcat(terminated, "\\"); + UserPath += '\\'; break; } - - UserPath = strdup(terminated); } char const * CDFileClass::User_Path(void) { - return(UserPath); + return(UserPath.empty() ? NULL : UserPath.c_str()); } @@ -222,11 +216,11 @@ bool CDFileClass::Has_Directory(char const * filename) /// 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 == NULL || filename == NULL) return(false); + if (UserPath.empty() || filename == NULL) return(false); if (Has_Directory(filename)) return(false); - if ((int)(strlen(UserPath) + strlen(filename)) >= size) return(false); + if ((int)(UserPath.length() + strlen(filename)) >= size) return(false); - strcpy(buffer, UserPath); + strcpy(buffer, UserPath.c_str()); strcat(buffer, filename); return(true); } diff --git a/code/cdfile.h b/code/cdfile.h index c48a16fa..2629161e 100644 --- a/code/cdfile.h +++ b/code/cdfile.h @@ -104,10 +104,6 @@ class CDFileClass : public BufferIOFileClass */ static SearchDriveType * First; - // Where this player's own files are kept, ending in a separator. NULL when the - // player has no directory of their own and everything belongs together. - static char const * UserPath; - // A file object owns the name it captured, so it is not copied. CDFileClass(CDFileClass const & file); CDFileClass & operator = (CDFileClass const & file); From cf125354698828230205c9d40a59eb40814949a9 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 01:05:22 +0300 Subject: [PATCH 22/25] State what the file layer does, not what it stopped doing --- code/cdfile.cpp | 3 +-- code/startup.cpp | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/code/cdfile.cpp b/code/cdfile.cpp index a65ea183..11ef777a 100644 --- a/code/cdfile.cpp +++ b/code/cdfile.cpp @@ -49,8 +49,7 @@ 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. It is kept here rather than in the class so that the file -// classes, which nearly every part of the game includes, need no standard library header. +// has no directory of their own. static std::string UserPath; diff --git a/code/startup.cpp b/code/startup.cpp index 1f5851ab..9e76aaa9 100644 --- a/code/startup.cpp +++ b/code/startup.cpp @@ -671,8 +671,7 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho if (Special.IsFromInstall == true) { ConfigINI.Put_Bool("Intro", "PlayIntro", false); - // Left closed, so that saving opens it for writing. Reopening it here for - // reading gave the save a handle it could not write through. + // Left closed, so that saving opens it for writing itself. cfile->Close(); ConfigINI.Save(*cfile, false); } From e657b2eb917e5858e88d5b71e295f2aba4854f22 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 01:15:53 +0300 Subject: [PATCH 23/25] Search a Maps folder without configuration --- code/gamedirs.cpp | 2 +- manual/changes/deployment-search-folders.md | 7 ++++--- manual/content/formats/opents-ini.md | 4 ++-- manual/content/using/game-data.md | 2 +- tests/gamedirs/gamedirscontract.cpp | 4 +++- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp index 18d27bbe..ae3aeaf2 100644 --- a/code/gamedirs.cpp +++ b/code/gamedirs.cpp @@ -29,7 +29,7 @@ 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"; +static char const * const DefaultSearchFolders = "INI,MIX,Maps"; static char const * const ConfigName = "OPENTS.INI"; diff --git a/manual/changes/deployment-search-folders.md b/manual/changes/deployment-search-folders.md index 350ef2dc..c20353d2 100644 --- a/manual/changes/deployment-search-folders.md +++ b/manual/changes/deployment-search-folders.md @@ -4,7 +4,8 @@ category: feature release: 0.2.0 breaking: true migration: -- Rename an `INI` or `MIX` 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=.`. +- 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 @@ -13,8 +14,8 @@ 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` and `MIX`, -so a deployment can sort its files and ship no configuration at all. +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 diff --git a/manual/content/formats/opents-ini.md b/manual/content/formats/opents-ini.md index a99add52..a5ac33db 100644 --- a/manual/content/formats/opents-ini.md +++ b/manual/content/formats/opents-ini.md @@ -20,12 +20,12 @@ A distribution ships this file beside its game data to say where that data is ke ```ini title="OPENTS.INI" [Paths] -SearchPaths=INI,MIX,Addons +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` were written: a distribution can sort its files into `INI` and `MIX` 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. +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. diff --git a/manual/content/using/game-data.md b/manual/content/using/game-data.md index a4adbfb7..3e27476b 100644 --- a/manual/content/using/game-data.md +++ b/manual/content/using/game-data.md @@ -29,4 +29,4 @@ Do not place game data in the CMake build directory. The build copies OpenTS exe `-DATADIR=` reads the game's data from the directory named instead of requiring it beside the executable, and `-USERDIR=` keeps what the game writes — settings, saved games, recordings and downloaded maps — in a directory of its own. Together they let one copy of the data serve several people, each writing only to their own directory and reading their own files ahead of the shared ones. -The data may be sorted into folders rather than left in one directory. Without any configuration the game also searches `INI` and `MIX`; [`OPENTS.INI`](/formats/opents-ini/) names other folders and the order they are searched in. +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/tests/gamedirs/gamedirscontract.cpp b/tests/gamedirs/gamedirscontract.cpp index d49c04b9..d2f9a6a0 100644 --- a/tests/gamedirs/gamedirscontract.cpp +++ b/tests/gamedirs/gamedirscontract.cpp @@ -144,7 +144,9 @@ void Test_Defaults(void) "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, "nothing else 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"); } From ebd3c0f93e3a7d2ee646c6a39293dab711fc9953 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 01:19:58 +0300 Subject: [PATCH 24/25] Delete the file object's copy operations outright --- code/cdfile.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/code/cdfile.h b/code/cdfile.h index 2629161e..9f1b34a1 100644 --- a/code/cdfile.h +++ b/code/cdfile.h @@ -54,6 +54,10 @@ class CDFileClass : public BufferIOFileClass CDFileClass(void); 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; @@ -103,8 +107,4 @@ class CDFileClass : public BufferIOFileClass ** This points to the first path record. */ static SearchDriveType * First; - - // A file object owns the name it captured, so it is not copied. - CDFileClass(CDFileClass const & file); - CDFileClass & operator = (CDFileClass const & file); }; From c1fe596d24ee76498b6c470353d6a87b12673743 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 01:23:02 +0300 Subject: [PATCH 25/25] Attach the storage note to the line it explains --- code/saveload.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/code/saveload.cpp b/code/saveload.cpp index 1b6ee1da..51c1bcc0 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -1179,9 +1179,9 @@ bool Load_Game(const char *file_name) /* ** Open the file */ - // Structured storage does not go through the game's file system, so the file layer is - // asked where the save is before the name is handed over. IStoragePtr storage; + + // Structured storage goes straight to Windows, so the file layer locates the save first. MultiByteToWideChar(0,0,CDFileClass(file_name).File_Name(), -1, name, (sizeof(name)/sizeof(WCHAR))); if (FAILED(StgOpenStorage(name, 0, STGM_SHARE_DENY_WRITE, 0, 0, &storage))) { @@ -1328,6 +1328,7 @@ bool Get_Savefile_Info(char const * name, SaveVersionInfo * info) IStoragePtr storage; WCHAR wname[MAX_PATH]; + // Structured storage goes straight to Windows, so the file layer locates the save first. MultiByteToWideChar(0, 0, CDFileClass(name).File_Name(), -1, wname, sizeof(wname) / sizeof(WCHAR)); HRESULT result = StgOpenStorage(wname, NULL, STGM_SHARE_EXCLUSIVE|STGM_READWRITE, NULL, 0, &storage);