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:
khallaandClaude Opus 5.5 committed 2026-10-20 00:14:36 +02:00
1 parent 198c21dbfa
commit 6c467e22c4
7 files changed
+337 -9

No files matched your search

+1
View File
@@ -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})
+2 -1
View File
@@ -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
+55
View File
@@ -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.
+3 -7
View File
@@ -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 ------------------------------------------------------------
+1 -1
View File
@@ -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
+264
View File
@@ -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
+11
View File
@@ -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