User folders: save games and Settings.cfg in Documents\Sacred
The game's ANSI file API calls are hooked; paths under save\ and Settings.cfg are redirected to Documents\Sacred\save and Documents\Sacred\config, copied there once from the game folder. The shared chest uses the same save folder. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
198c21dbfa
commit
6c467e22c4
7 files changed
+337
-9
No files matched your search
@@ -12,7 +12,7 @@
|
||||
// serializer (vf1c(stream, 1, 0)); loadHero recreates them with
|
||||
// cObjectManager::Create(type, 0, 0, 0), vf1c(stream, 0, file version) and
|
||||
// gives them their new id. The shared chest does the same into
|
||||
// save\shared_chest.json: written whenever a hero is saved, read at the start
|
||||
// shared_chest.json in the save folder (mods/userfolders.cpp): written whenever a hero is saved, read at the start
|
||||
// of each game session; cleared when the session ends (the ids die with the
|
||||
// object manager).
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
#include "mods/itemstack.h"
|
||||
#include "mods/sharedchest.h"
|
||||
#include "mods/skillslots.h"
|
||||
#include "re/config.h"
|
||||
#include "mods/userfolders.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
@@ -102,11 +102,7 @@ bool SharedOpen() {
|
||||
}
|
||||
|
||||
std::string FilePath() {
|
||||
std::string dir = re::ConfigString("paths", "GameDir", "");
|
||||
if (!dir.empty() && dir.back() != '\\' && dir.back() != '/') {
|
||||
dir += '\\';
|
||||
}
|
||||
return dir + "save\\shared_chest.json";
|
||||
return mods::userfolders::SaveDir() + "shared_chest.json";
|
||||
}
|
||||
|
||||
// --- items <-> JSON ------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace mods::sharedchest {
|
||||
|
||||
// Writes the shared chest (save\shared_chest.json); called after a hero is saved.
|
||||
// Writes the shared chest (shared_chest.json in the save folder); called after a hero is saved.
|
||||
void Save();
|
||||
|
||||
} // namespace mods::sharedchest
|
||||
@@ -0,0 +1,264 @@
|
||||
// User folders (request of 2026-10-19): save games and the game's settings
|
||||
// live in the user's Documents instead of the game folder:
|
||||
// Documents\Sacred\save\ GAMEnn.PAK, Heronn.pax, shared_chest.json
|
||||
// Documents\Sacred\config\ Settings.cfg
|
||||
//
|
||||
// Measured: sacred.exe names these files with paths relative to the game
|
||||
// folder (the working directory), from 37 code sites: "./SAVE/GAME%.2d.PAK",
|
||||
// "SAVE/GAME%.2d.PAK", "./SAVE/GAMEF%d.PAK", "./SAVE/SAVE02.PAK",
|
||||
// "Save\Hero%.2d.pax", ".\Save", "Save", "SAVE\" and "Settings.cfg"
|
||||
// (0x95c4ec..0x95d5f4, 0x9e15a0, 0xa18dc4). It reaches files only through
|
||||
// kernel32's ANSI file functions, so those are hooked: a path whose first
|
||||
// component is "save", or which is "Settings.cfg", is rewritten into the user
|
||||
// folder; every other path passes unchanged. One choke point instead of 37
|
||||
// patched operands, and no long absolute path in the game's fixed buffers.
|
||||
//
|
||||
// Migration: the first time a folder is used and does not exist yet, the game
|
||||
// folder's files are copied into it (the originals stay where they were).
|
||||
// Config.exe and gameserver.exe are separate programs and still use the game
|
||||
// folder.
|
||||
|
||||
#include "mods/userfolders.h"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <windows.h>
|
||||
#include <shlobj.h>
|
||||
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::userfolders {
|
||||
namespace {
|
||||
|
||||
constexpr char kSaveFolder[] = "save";
|
||||
constexpr char kSettingsFile[] = "Settings.cfg";
|
||||
constexpr wchar_t kUserFolder[] = L"Sacred";
|
||||
constexpr wchar_t kUserSaveFolder[] = L"save";
|
||||
constexpr wchar_t kUserConfigFolder[] = L"config";
|
||||
|
||||
bool IsSeparator(char c) {
|
||||
return c == '\\' || c == '/';
|
||||
}
|
||||
|
||||
bool EqualsNoCase(std::string_view a, std::string_view b) {
|
||||
return a.size() == b.size() && _strnicmp(a.data(), b.data(), a.size()) == 0;
|
||||
}
|
||||
|
||||
// The game folder (sacred.exe's), with trailing separator.
|
||||
const std::string& GameDir() {
|
||||
static const std::string dir = [] {
|
||||
char path[MAX_PATH];
|
||||
DWORD length = GetModuleFileNameA(nullptr, path, MAX_PATH);
|
||||
std::string result(path, length);
|
||||
return result.substr(0, result.find_last_of("\\/") + 1);
|
||||
}();
|
||||
return dir;
|
||||
}
|
||||
|
||||
std::wstring Widen(const std::string& text) {
|
||||
std::wstring result(MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, nullptr, 0), L'\0');
|
||||
MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, result.data(), static_cast<int>(result.size()));
|
||||
result.pop_back();
|
||||
return result;
|
||||
}
|
||||
|
||||
// `path` in the game's code page; empty when a character has no ANSI form.
|
||||
std::string Narrow(const std::wstring& path) {
|
||||
char buffer[MAX_PATH];
|
||||
BOOL lossy = FALSE;
|
||||
int length = WideCharToMultiByte(CP_ACP, WC_NO_BEST_FIT_CHARS, path.c_str(), -1, buffer, MAX_PATH, nullptr,
|
||||
&lossy);
|
||||
return length > 0 && !lossy ? std::string(buffer) : std::string();
|
||||
}
|
||||
|
||||
bool Exists(const std::wstring& path) {
|
||||
return GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES;
|
||||
}
|
||||
|
||||
// Copies the files of `from` into `to` (not recursive; the save folder is flat).
|
||||
void CopyFiles(const std::wstring& from, const std::wstring& to) {
|
||||
WIN32_FIND_DATAW found;
|
||||
HANDLE find = FindFirstFileW((from + L"\\*").c_str(), &found);
|
||||
if (find == INVALID_HANDLE_VALUE) {
|
||||
return;
|
||||
}
|
||||
int copied = 0;
|
||||
do {
|
||||
if (!(found.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) &&
|
||||
CopyFileW((from + L"\\" + found.cFileName).c_str(), (to + L"\\" + found.cFileName).c_str(), TRUE)) {
|
||||
++copied;
|
||||
}
|
||||
} while (FindNextFileW(find, &found));
|
||||
FindClose(find);
|
||||
re::Log("user folders: copied %d files from the game's save folder", copied);
|
||||
}
|
||||
|
||||
// The user folders without trailing separator, in the game's code page; both
|
||||
// empty when the Documents folder can't be used (the game folder stays).
|
||||
struct Folders {
|
||||
std::string save;
|
||||
std::string config;
|
||||
};
|
||||
|
||||
// Wide file functions only: the hooked ANSI ones would map the game folder's
|
||||
// files onto themselves.
|
||||
Folders FindFolders() {
|
||||
PWSTR documents = nullptr;
|
||||
HRESULT result = SHGetKnownFolderPath(FOLDERID_Documents, 0, nullptr, &documents);
|
||||
std::wstring root = SUCCEEDED(result) ? std::wstring(documents) + L"\\" + kUserFolder : std::wstring();
|
||||
CoTaskMemFree(documents);
|
||||
|
||||
Folders folders;
|
||||
if (root.empty() || Narrow(root).empty()) {
|
||||
re::Log("user folders: Documents folder unusable (0x%08lx), files stay in the game folder", result);
|
||||
return folders;
|
||||
}
|
||||
const std::wstring save = root + L"\\" + kUserSaveFolder;
|
||||
const std::wstring config = root + L"\\" + kUserConfigFolder;
|
||||
const std::wstring gameDir = Widen(GameDir());
|
||||
CreateDirectoryW(root.c_str(), nullptr);
|
||||
if (!Exists(save)) {
|
||||
CreateDirectoryW(save.c_str(), nullptr);
|
||||
CopyFiles(gameDir + Widen(kSaveFolder), save);
|
||||
}
|
||||
if (!Exists(config)) {
|
||||
CreateDirectoryW(config.c_str(), nullptr);
|
||||
if (CopyFileW((gameDir + Widen(kSettingsFile)).c_str(), (config + L"\\" + Widen(kSettingsFile)).c_str(),
|
||||
TRUE)) {
|
||||
re::Log("user folders: copied the game's %s", kSettingsFile);
|
||||
}
|
||||
}
|
||||
folders.save = Narrow(save);
|
||||
folders.config = Narrow(config);
|
||||
re::Log("user folders: save games in %s, settings in %s", folders.save.c_str(), folders.config.c_str());
|
||||
return folders;
|
||||
}
|
||||
|
||||
// Looked up on first use, not at startup: the hooks are installed under the
|
||||
// loader lock, where the shell's folder lookup must not run.
|
||||
const Folders& UserFolders() {
|
||||
static const Folders folders = FindFolders();
|
||||
return folders;
|
||||
}
|
||||
|
||||
// `path` as the game means it, redirected into the user folders; nullptr when
|
||||
// it stays as it is. `buffer` holds the result.
|
||||
const char* Redirect(const char* path, std::string& buffer) {
|
||||
if (!path) {
|
||||
return nullptr;
|
||||
}
|
||||
std::string_view rest(path);
|
||||
const std::string& gameDir = GameDir();
|
||||
if (rest.size() >= gameDir.size() && EqualsNoCase(rest.substr(0, gameDir.size()), gameDir)) {
|
||||
rest.remove_prefix(gameDir.size());
|
||||
} else if ((rest.size() >= 2 && rest[1] == ':') || (!rest.empty() && IsSeparator(rest[0]))) {
|
||||
return nullptr; // absolute, elsewhere
|
||||
}
|
||||
while (rest.size() >= 2 && rest[0] == '.' && IsSeparator(rest[1])) {
|
||||
rest.remove_prefix(2);
|
||||
}
|
||||
const size_t end = rest.find_first_of("\\/");
|
||||
const std::string_view first = rest.substr(0, end);
|
||||
const bool save = EqualsNoCase(first, kSaveFolder);
|
||||
if (!save && !(end == std::string_view::npos && EqualsNoCase(first, kSettingsFile))) {
|
||||
return nullptr;
|
||||
}
|
||||
const Folders& folders = UserFolders();
|
||||
if (folders.save.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
if (save) {
|
||||
buffer = folders.save;
|
||||
for (char c : rest.substr(first.size())) {
|
||||
buffer += c == '/' ? '\\' : c;
|
||||
}
|
||||
} else {
|
||||
buffer = folders.config + "\\" + kSettingsFile;
|
||||
}
|
||||
return buffer.c_str();
|
||||
}
|
||||
|
||||
// The path to hand to the original function.
|
||||
const char* Mapped(const char* path, std::string& buffer) {
|
||||
const char* redirected = Redirect(path, buffer);
|
||||
return redirected ? redirected : path;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string SaveDir() {
|
||||
const Folders& folders = UserFolders();
|
||||
return (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + "\\";
|
||||
}
|
||||
|
||||
} // namespace mods::userfolders
|
||||
|
||||
namespace {
|
||||
|
||||
using mods::userfolders::Mapped;
|
||||
|
||||
HANDLE WINAPI CreateFileInUserFolders(LPCSTR path, DWORD access, DWORD share, LPSECURITY_ATTRIBUTES security,
|
||||
DWORD disposition, DWORD flags, HANDLE templateFile);
|
||||
BOOL WINAPI DeleteFileInUserFolders(LPCSTR path);
|
||||
HANDLE WINAPI FindFirstFileInUserFolders(LPCSTR pattern, LPWIN32_FIND_DATAA found);
|
||||
DWORD WINAPI GetFileAttributesInUserFolders(LPCSTR path);
|
||||
BOOL WINAPI GetFileAttributesExInUserFolders(LPCSTR path, GET_FILEEX_INFO_LEVELS level, LPVOID info);
|
||||
BOOL WINAPI SetFileAttributesInUserFolders(LPCSTR path, DWORD attributes);
|
||||
BOOL WINAPI CreateDirectoryInUserFolders(LPCSTR path, LPSECURITY_ATTRIBUTES security);
|
||||
BOOL WINAPI CopyFileInUserFolders(LPCSTR from, LPCSTR to, BOOL failIfExists);
|
||||
|
||||
RE_HOOK_API(CreateFileA, CreateFileInUserFolders);
|
||||
RE_HOOK_API(DeleteFileA, DeleteFileInUserFolders);
|
||||
RE_HOOK_API(FindFirstFileA, FindFirstFileInUserFolders);
|
||||
RE_HOOK_API(GetFileAttributesA, GetFileAttributesInUserFolders);
|
||||
RE_HOOK_API(GetFileAttributesExA, GetFileAttributesExInUserFolders);
|
||||
RE_HOOK_API(SetFileAttributesA, SetFileAttributesInUserFolders);
|
||||
RE_HOOK_API(CreateDirectoryA, CreateDirectoryInUserFolders);
|
||||
RE_HOOK_API(CopyFileA, CopyFileInUserFolders);
|
||||
|
||||
HANDLE WINAPI CreateFileInUserFolders(LPCSTR path, DWORD access, DWORD share, LPSECURITY_ATTRIBUTES security,
|
||||
DWORD disposition, DWORD flags, HANDLE templateFile) {
|
||||
std::string buffer;
|
||||
return CreateFileInUserFolders_original(Mapped(path, buffer), access, share, security, disposition, flags,
|
||||
templateFile);
|
||||
}
|
||||
|
||||
BOOL WINAPI DeleteFileInUserFolders(LPCSTR path) {
|
||||
std::string buffer;
|
||||
return DeleteFileInUserFolders_original(Mapped(path, buffer));
|
||||
}
|
||||
|
||||
HANDLE WINAPI FindFirstFileInUserFolders(LPCSTR pattern, LPWIN32_FIND_DATAA found) {
|
||||
std::string buffer;
|
||||
return FindFirstFileInUserFolders_original(Mapped(pattern, buffer), found);
|
||||
}
|
||||
|
||||
DWORD WINAPI GetFileAttributesInUserFolders(LPCSTR path) {
|
||||
std::string buffer;
|
||||
return GetFileAttributesInUserFolders_original(Mapped(path, buffer));
|
||||
}
|
||||
|
||||
BOOL WINAPI GetFileAttributesExInUserFolders(LPCSTR path, GET_FILEEX_INFO_LEVELS level, LPVOID info) {
|
||||
std::string buffer;
|
||||
return GetFileAttributesExInUserFolders_original(Mapped(path, buffer), level, info);
|
||||
}
|
||||
|
||||
BOOL WINAPI SetFileAttributesInUserFolders(LPCSTR path, DWORD attributes) {
|
||||
std::string buffer;
|
||||
return SetFileAttributesInUserFolders_original(Mapped(path, buffer), attributes);
|
||||
}
|
||||
|
||||
BOOL WINAPI CreateDirectoryInUserFolders(LPCSTR path, LPSECURITY_ATTRIBUTES security) {
|
||||
std::string buffer;
|
||||
return CreateDirectoryInUserFolders_original(Mapped(path, buffer), security);
|
||||
}
|
||||
|
||||
BOOL WINAPI CopyFileInUserFolders(LPCSTR from, LPCSTR to, BOOL failIfExists) {
|
||||
std::string fromBuffer;
|
||||
std::string toBuffer;
|
||||
return CopyFileInUserFolders_original(Mapped(from, fromBuffer), Mapped(to, toBuffer), failIfExists);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace mods::userfolders {
|
||||
|
||||
// Folder of the save games, with trailing separator: Documents\Sacred\save\,
|
||||
// or the game's own save\ when the Documents folder is unusable.
|
||||
std::string SaveDir();
|
||||
|
||||
} // namespace mods::userfolders
|
||||
Reference in new issue
Block a user