From 6c467e22c4068bed68a9d427cdb02900616f220e Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Tue, 20 Oct 2026 00:14:36 +0200 Subject: [PATCH] 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) --- CMakeLists.txt | 1 + docs/shared-chest.md | 3 +- docs/user-folders.md | 55 ++++++++ src/mods/sharedchest.cpp | 10 +- src/mods/sharedchest.h | 2 +- src/mods/userfolders.cpp | 264 +++++++++++++++++++++++++++++++++++++++ src/mods/userfolders.h | 11 ++ 7 files changed, 337 insertions(+), 9 deletions(-) create mode 100644 docs/user-folders.md create mode 100644 src/mods/userfolders.cpp create mode 100644 src/mods/userfolders.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e34dcf4..7328f60 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -70,6 +70,7 @@ add_library(sacredK SHARED src/mods/charexport.cpp src/mods/options.cpp src/mods/lootfilter.cpp + src/mods/userfolders.cpp ) target_include_directories(sacredK PRIVATE src) target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS}) diff --git a/docs/shared-chest.md b/docs/shared-chest.md index 6085f6f..4ce191d 100644 --- a/docs/shared-chest.md +++ b/docs/shared-chest.md @@ -4,7 +4,8 @@ The chest gets a second tab that belongs to no single hero: a chest shared by all characters. Decided: stored in one file in the save folder -(`save\shared_chest.json`), two tabs ("Truhe" / "Gemeinsam"), same size as the +(`shared_chest.json` in the save folder, `Documents\Sacred\save`, see +`user-folders.md`), two tabs ("Truhe" / "Gemeinsam"), same size as the chest (20 x 10). Potions stack in it like in every player inventory (docs/item-stacks.md). Follow-up (same day): text tabs were barely visible; use the inventory's Rucksack / Kampfkünste / Combos header look with its diff --git a/docs/user-folders.md b/docs/user-folders.md new file mode 100644 index 0000000..df73220 --- /dev/null +++ b/docs/user-folders.md @@ -0,0 +1,55 @@ +# User folders (Documents\Sacred) + +Request (2026-10-19): the game keeps its save games and settings in the +user's Documents folder instead of the game folder: + +| Folder | Contents | +|---|---| +| `Documents\Sacred\save\` | `GAMEnn.PAK` (save rows), `Heronn.pax` (exported heroes), `shared_chest.json` (sacredK) | +| `Documents\Sacred\config\` | `Settings.cfg` (the game's options) | + +`sacredK.ini` (key bindings, loot filter, game folder) stays next to the DLL. + +## Measured + +Vanilla keeps everything inside the game folder: no AppData, Documents or +registry use (the `Ascaron Entertainment` folders there belong to Sacred 2). +`sacred.exe` names the files with paths relative to the working directory +(the game folder), from 37 code sites: + +| String | Address | Users | +|---|---|---| +| `./SAVE/GAME%.2d.PAK` | 0x95c530 | 0x60b58b, 0x60c0d3, 0x60e38f, 0x60e645, 0x71cda7 | +| `SAVE/GAME%.2d.PAK` | 0x95c884 | 0x60e35a, 0x60e60a, 0x619c38 | +| `./SAVE/GAMEF%d.PAK` | 0x95c870 | 0x60e3b9, 0x60e674 | +| `./SAVE/SAVE02.PAK` | 0x95c85c | 0x60dc2c | +| `Save\Hero%.2d.pax` | 0x95c4ec | 0x60bf16, 0x614ceb, 0x70d96c, 0x70dd8d, 0x70df1b, 0x70e091 | +| `.\Save`, `Save` | 0x95d5f4, 0x95d5ec | 0x619cd4/ec, 0x61cdc0/d1 | +| `SAVE\` | 0x9e15a0 | 0x70bf07 | +| `Settings.cfg` | 0xa18dc4 | 0x7ed6a2..0x7eeab3 (13), WinMain 0x816dc0..0x816dd6 | + +All file access goes through kernel32's ANSI functions: `CreateFileA`, +`DeleteFileA`, `FindFirstFileA`, `GetFileAttributes(Ex)A`, +`SetFileAttributesA`, `CreateDirectoryA`, `CopyFileA` (no `MoveFile*`, no +`SetCurrentDirectory`). + +## How the mod works (`src/mods/userfolders.cpp`) + +- Those eight functions are hooked (`RE_HOOK_API`). A path is rewritten when, + after an optional game-folder prefix and `.\`/`./`, its first component is + `save` (any case) or it is exactly `Settings.cfg`; every other path passes + unchanged. One choke point instead of 37 patched operands, and no long + absolute path in the game's fixed-size buffers. +- The Documents folder comes from `SHGetKnownFolderPath(FOLDERID_Documents)` + (follows OneDrive/redirected Documents), looked up on the first redirected + path: the hooks are installed under the loader lock, where shell calls must + not run. If it is unusable (lookup fails, or a character outside the ANSI + code page), the game folder stays in use. +- Migration: when `save\` or `config\` does not exist yet, the game folder's + save files / `Settings.cfg` are copied there once with the wide file + functions (not hooked). The originals stay in the game folder. +- `mods::userfolders::SaveDir()` gives sacredK's own files the same folder + (shared chest). + +Not covered: `Config.exe` and `gameserver.exe` (`GameServer.cfg`) are +separate programs without the DLL and still use the game folder. diff --git a/src/mods/sharedchest.cpp b/src/mods/sharedchest.cpp index 9ae8fe3..23fd51c 100644 --- a/src/mods/sharedchest.cpp +++ b/src/mods/sharedchest.cpp @@ -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 ------------------------------------------------------------ diff --git a/src/mods/sharedchest.h b/src/mods/sharedchest.h index ee604f7..9c7ad7f 100644 --- a/src/mods/sharedchest.h +++ b/src/mods/sharedchest.h @@ -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 diff --git a/src/mods/userfolders.cpp b/src/mods/userfolders.cpp new file mode 100644 index 0000000..858ad8b --- /dev/null +++ b/src/mods/userfolders.cpp @@ -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 +#include + +#include +#include + +#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(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 diff --git a/src/mods/userfolders.h b/src/mods/userfolders.h new file mode 100644 index 0000000..ae98c62 --- /dev/null +++ b/src/mods/userfolders.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +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