Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bccb0734e | ||
|
|
5df7efc198 | ||
|
|
6c467e22c4 |
No files matched your search
Vendored
+4
-2
@@ -3,13 +3,15 @@
|
||||
"configurations": [
|
||||
{
|
||||
// Builds, then starts the game through the launcher (no debugger in the game).
|
||||
// The game folder comes from build/x86-debug/sacredK.ini: run the
|
||||
// launcher once with --game-dir "<Sacred Gold folder>" (README.md).
|
||||
"name": "Run Sacred (sacredK)",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/build/x86-debug/sacredK.exe",
|
||||
"args": ["--game-dir", "D:\\Gaming\\Sacred Gold", "log"],
|
||||
"args": ["log"],
|
||||
"cwd": "${workspaceFolder}/build/x86-debug",
|
||||
"preLaunchTask": "CMake: build",
|
||||
"preLaunchTask": "Build x86-debug",
|
||||
"console": "internalConsole"
|
||||
},
|
||||
{
|
||||
|
||||
Vendored
+7
-4
@@ -2,10 +2,13 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "CMake: build",
|
||||
"type": "cmake",
|
||||
"command": "build",
|
||||
"targets": ["all"],
|
||||
// build.ps1 enters the MSVC x86 environment and builds the x86-debug
|
||||
// preset; unlike a CMake Tools task it needs no preset picked in VS Code.
|
||||
"label": "Build x86-debug",
|
||||
"type": "process",
|
||||
"command": "powershell",
|
||||
"args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "${workspaceFolder}/build.ps1", "x86-debug"],
|
||||
"options": { "cwd": "${workspaceFolder}" },
|
||||
"group": { "kind": "build", "isDefault": true },
|
||||
"problemMatcher": ["$msCompile"]
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ the work so far is **mods** — behaviour changes on top of the original
|
||||
auto-attack, skill reworks, respec, map fixes, ...). The game stays playable
|
||||
at every step; the user plays it to test.
|
||||
|
||||
- Target binary: the original `sacred.exe` in `D:\Gaming\Sacred Gold\`
|
||||
(path in `build\x86-debug\sacredK.ini`, `[paths] GameDir`). Ignore the
|
||||
`SacredReborn*` files in that folder.
|
||||
- Target binary: the original `sacred.exe` of a local Sacred Gold 2.28
|
||||
installation (see `README.md`, Supported build). The game is not in the
|
||||
repo; its folder is given to the launcher once (`--game-dir`) and kept in
|
||||
`build\<preset>\sacredK.ini`, `[paths] GameDir`. Read it from there; never
|
||||
hard-code a local path in code, docs or configs.
|
||||
- Game texts are German (Fertigkeiten, Attribute, Blitzschlag, ...); keep the
|
||||
game's names when talking about features.
|
||||
- The user's monitor is 3440x1440; the game renders about 1835x768 scaled up.
|
||||
|
||||
@@ -70,6 +70,9 @@ add_library(sacredK SHARED
|
||||
src/mods/charexport.cpp
|
||||
src/mods/options.cpp
|
||||
src/mods/lootfilter.cpp
|
||||
src/mods/userfolders.cpp
|
||||
src/mods/charfile.cpp
|
||||
src/mods/characters.cpp
|
||||
)
|
||||
target_include_directories(sacredK PRIVATE src)
|
||||
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
|
||||
|
||||
@@ -41,11 +41,24 @@ the CMake presets.
|
||||
|
||||
## Running
|
||||
|
||||
The game is not part of this repository. You need a local installation of
|
||||
Sacred Gold 2.28 with the `sacred.exe` listed under [Supported build](#supported-build)
|
||||
(e.g. the Steam or GOG release), and you tell the launcher its folder once:
|
||||
|
||||
```powershell
|
||||
build\x86-debug\sacredK.exe --game-dir "D:\Gaming\Sacred Gold" # first run; remembered in sacredK.ini
|
||||
build\x86-debug\sacredK.exe log # later runs; extra args go to the game
|
||||
build\x86-debug\sacredK.exe --game-dir "<Sacred Gold folder>" # first run; remembered in sacredK.ini
|
||||
build\x86-debug\sacredK.exe log # later runs; extra args go to the game
|
||||
```
|
||||
|
||||
The folder is stored as `[paths] GameDir` in `sacredK.ini` next to the
|
||||
launcher (one per build preset, not tracked by git). Without it, the launcher
|
||||
looks for `sacred.exe` in its own folder and otherwise asks for `--game-dir`.
|
||||
The VS Code launch configuration relies on this stored path too.
|
||||
|
||||
Save games and the game's `Settings.cfg` live in `Documents\Sacred\save` and
|
||||
`Documents\Sacred\config`; on the first start they are copied there from the
|
||||
game folder (see `docs/user-folders.md`).
|
||||
|
||||
`sacredK.log` (next to the DLL) records what was hooked plus every message the
|
||||
game sends to its internal logger, even when the game's own logging is off.
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Characters and difficulties (one file per character)
|
||||
|
||||
## Request (2026-10-19)
|
||||
|
||||
Get rid of exporting characters. One character = one save file; inside it one
|
||||
world state per difficulty. The hero (gear, level, attributes, skills, chest,
|
||||
everything) is shared between the difficulties; only the unlocked portals are
|
||||
per difficulty (they have to be found again on each level).
|
||||
|
||||
- Unlocking: Bronze and Silver are open from the start; each following
|
||||
difficulty opens by completing the story on the one before it. More
|
||||
difficulties shall be addable later.
|
||||
- The export / import screen goes away. The load window lists characters;
|
||||
loading asks for the difficulty.
|
||||
- "Spiel speichern" saves the current character into its current difficulty.
|
||||
No "switch difficulty" button: save, end the game, load on another
|
||||
difficulty. No save on quit.
|
||||
- A silent autosave every few minutes into a separate slot, so the player
|
||||
still decides when the real save is updated.
|
||||
- Existing saves are migrated.
|
||||
- Delete a character (everything of it); reset a single difficulty.
|
||||
- Multiplayer: later (see `todo.md`).
|
||||
|
||||
Decided (2026-10-19, follow-up):
|
||||
- Unlocking by story completion only; Bronze and Silver always open (maybe
|
||||
only Bronze after a later rebalance). No level ranges: enemies always scale
|
||||
with the player and the difficulty.
|
||||
- Autosave every 10 minutes for now (a game setting later); the load window
|
||||
offers it per character when it is newer than the real save.
|
||||
- The game's own autosaves on story steps also go into the autosave slot,
|
||||
silently.
|
||||
- Migration as proposed (group by hero name + class), a backup of all saves
|
||||
first. Done by hand once:
|
||||
`Documents\Sacred\backup\2026-10-19_before_characters` (Documents save
|
||||
folder and game save folder); the migration code makes its own backup too.
|
||||
|
||||
## Measured / read so far
|
||||
|
||||
### Files
|
||||
|
||||
- Save games `GAMEnn.PAK` ("AMS", version 0x1b): a full world dump; the hero
|
||||
is one object in it (`* Objects`, ref 1, family 3) and its items are in
|
||||
`* Inventory`. Chunks are listed by the loader's log (`* Engine`, `* world`,
|
||||
`* calendar`, `* Blood`, `* RegionInfo`, `* Team/AllianceInfo`,
|
||||
`* Objects`, `* Inventory`, `* Particles`, `* Views`, `* Weather`,
|
||||
`* Scripts`).
|
||||
- Hero files `Heronn.pax` ("AMH"): written by `ItemDataMgr::saveHero`
|
||||
`0x6046f0`; header 0x22c bytes + chunks (items, inventories, chest, stats
|
||||
block). The hero's **portals** and world position are part of it (log
|
||||
`saveHero ... wpos[..] portals[%x]`; portal bits on the hero at +0x578).
|
||||
|
||||
### Functions
|
||||
|
||||
| Address | What |
|
||||
|---|---|
|
||||
| 0x60ac70 | `cEngine::cEngine`: new game or save-game load |
|
||||
| 0x61b790 | `cEngine::Load(path, quiet)`: reads a `GAME.PAK` (version check 0x15..0x1b), chunk loop, logs `---LOADING SAVEGAME.PAK` |
|
||||
| 0x619c90 | `cEngine::Save(path)`: called from the render thread when engine +0x54 has 0x40/0x80; row = engine +0x68 |
|
||||
| 0x60dc00 | `cEngine::InitGame`: new game (campaign start, start equipment) |
|
||||
| 0x61cda0 | `cEngine::SaveHero(path)`: hero export (`saveHero` into a RAM file, then to disk) |
|
||||
| 0x7d7c40 | session singleton (0x3ad0 bytes, `g_session` 0x182cb68) |
|
||||
| 0x7efd90 | `Session::SelectHeroSlot(slot 1..8)`, ret 4: loads `Save\Hero%02d.pax` into the session's RAM file +0x3a3c, sets +0x49 = 1 (hero from file), +0x37b8 = slot, reads the hero header into +0x16a (0x22c bytes) |
|
||||
| 0x755ac0 | `cCommand_armaPlay::execute`: posts 0x8002 (mode 1 load row, 4 load multi, else init) to the main window |
|
||||
| 0x706b90 | difficulty list for a hero level (Bronze .. Niob) |
|
||||
|
||||
- With session +0x49 set, the engine constructor loads the hero from the
|
||||
session's file into the **loaded save game** (`loadHero` at 0x60ba65) and
|
||||
destroys the old hero's items afterwards. That is the vanilla mechanism for
|
||||
"this world, that hero" and the base of the shared hero.
|
||||
- In-game save / load: engine +0x54 flags 0xc0 (save row +0x68 via
|
||||
`cEngine::Save`), 0x200 (load row via `cEngine::Load`).
|
||||
|
||||
### Difficulty
|
||||
|
||||
- `g_gameLevel` 0x182cdc4: current difficulty, from the save header (log
|
||||
`GameLevel[n]`). Index mapping still to confirm (0 = Bronze ..).
|
||||
- Story completion: `cStatsManager` (`cStatsManager::Instance(player)`
|
||||
0x425160) +0x5704, one byte counter per difficulty (6 entries).
|
||||
`0x424bc0` (k) increments, `0x424be0` (k) reads, `0x424c00` (k, value) sets.
|
||||
The script opcode at 0x4aed26 increments counter[`g_gameLevel`]: the story
|
||||
end. The counters travel in the save header (loader 0x61bb19..) and in the
|
||||
hero file (loadHero 0x606632.., saveHero 0x60444c..). The console cheats
|
||||
`bronze` / `silber` / ... (0x615d00..) increment them too.
|
||||
- Vanilla offers a difficulty by **hero level**, not by completion:
|
||||
thresholds `0xad2dc4..0xad2dd4` (5 dwords, parsed from the balance data key
|
||||
`StartLevMulti` at 0x5ef12c). Silver for levels [dc4, dcc], Gold
|
||||
[dc8, dd0], Platinum [dcc, dd4], Niob >= dd0, Bronze <= dc8. The same
|
||||
thresholds reduce experience when the hero is far above the difficulty
|
||||
(0x57dd60). Values: to be logged at runtime.
|
||||
|
||||
### Runtime log (2026-10-19, vanilla flows)
|
||||
|
||||
| Flow | What happened |
|
||||
|---|---|
|
||||
| load row 2 | `armaPlay load [2]` -> `cEngine(0x00000002)` -> `cEngine::Load("./SAVE/GAME02.PAK", 1)`; engine +0x68 = row |
|
||||
| in-game save | `cEngine::Save("SAVE/GAME02.PAK")`, engine +0x54 = 0x444 |
|
||||
| new game, exported hero, Silver | `cUI_Character::executeAction(IMPORT_DO) difficulty[1]`, `cEngine(0x80080000)`: bit 31 new game, bits 16..23 = hero slot + 1; `InitGame` creates a start hero of class session +0x44 (9) with start equipment, then `LoadHeroFile("Save\Hero07.pax", 1, 0)` (`0x61d190`, cdecl) loads the exported hero onto it |
|
||||
| new game, new hero, Bronze | `armaPlay init`, `cEngine(0x80000000)`, class +0x44 = 6, no hero file |
|
||||
|
||||
- `g_gameLevel`: 0 Bronze, 1 Silver (2 Gold, 3 Platinum, 4 Niob). The
|
||||
battlemage row is Silver.
|
||||
- Level thresholds at runtime: 20, 60, 100, 140, 180.
|
||||
- `Session::SelectHeroSlot` and session +0x49 are not used in single player
|
||||
(multiplayer). The hero swap block after it (0x60ba1c..) is not on the
|
||||
single-player load path.
|
||||
- `LoadHeroFile(path, 1, 0)` puts a hero file onto the existing player hero,
|
||||
start items and all: the same operation the shared hero needs after a
|
||||
world load.
|
||||
- Confirmed in-game (2026-10-19/20): `LoadHeroFile(hero, 1, 0)` called right
|
||||
after `cEngine::Load` returns puts the hero onto the loaded world's hero.
|
||||
An exported battlemage loaded onto another character's world came out
|
||||
exact: level, attributes, skills, gear, backpack, chest; nothing
|
||||
duplicated or left behind. It even replaced the class (dwarf hero onto a
|
||||
battlemage world). The result survives an in-game save and reload, with no
|
||||
cheat-check message.
|
||||
|
||||
## Follow-up requests (2026-10-20)
|
||||
|
||||
- Numbered files (`CharacterNN.sacredK`), one list row per character; the
|
||||
difficulty is chosen when loading, in a dialog (or a newer autosave, marked).
|
||||
- Rename a character from the load window.
|
||||
- Every difficulty below the highest played one is always available; one
|
||||
without a world starts a new game with the shared hero.
|
||||
- The save key and the Esc menu ("Spielstände" becomes "Speichern") save
|
||||
directly, without the save window.
|
||||
|
||||
## Implementation (`src/mods/characters.cpp`, `charfile.{h,cpp}`)
|
||||
|
||||
### Files
|
||||
|
||||
- `Documents\Sacred\save\characters\CharacterNN.sacredK`: "SKCH", version 1,
|
||||
section count, then `{name[16], offset, size}` entries. Sections: `meta`
|
||||
(JSON: format, name UTF-8, class, level, lastDifficulty, migratedFrom,
|
||||
heroFrom), `hero` (AMH as `cEngine::SaveHero` writes it), `world<d>` (AMS as
|
||||
`cEngine::Save` writes it). Written to a temp file, then moved over.
|
||||
- `CharacterNN.autosave.sacredK`: same layout, one world (`meta.difficulty`).
|
||||
- The game's save folder is redirected to `characters\_work` (userfolders);
|
||||
the game reads and writes `world.PAK` / `hero.pax` there.
|
||||
- Deleted characters move to `characters\deleted\`.
|
||||
|
||||
### Load window (cUI_Savegame)
|
||||
|
||||
- Before each scan (0x71e330) one preview `GAMEnn.PAK` per character is
|
||||
written into `_work` (header, thumbnail, world time, description
|
||||
"Name - Klasse"); row nn = character nn. The row's second line (call
|
||||
0x71db2a to SetText 0x736f00) shows "Stufe N - difficulty".
|
||||
- Laden (action 4 / 0xb) opens the difficulty dialog when there is more than
|
||||
one choice: worlds, newer autosaves "(Autosave)", and "(neu)" for every
|
||||
difficulty up to max(highest world, Silber) without a world (needs the
|
||||
shared hero; not in multiplayer, byte 0x182cb6c).
|
||||
- "Umbenennen" (below Löschen; not for the character being played) asks for
|
||||
a name; keys go to the field through the input key capture. The name is
|
||||
only in the hero header chunk 0xc3 (+8, UTF-16, 0x40 chars): rewritten in
|
||||
the hero, every world and the autosave, plus `meta.name`. Confirmed: the
|
||||
game loads renamed characters without complaint.
|
||||
- Dialog buttons are cUI_Button2 copies of the load button
|
||||
(`game::NewButtonLike`); their captions are our own text ids with
|
||||
overrides (texts.cpp checks overrides for our ids too).
|
||||
|
||||
### Load
|
||||
|
||||
`cEngine::Load` (0x61b790) of a listed row: the chosen world and the hero are
|
||||
unpacked to `_work`, the original loads `world.PAK`, then `LoadHeroFile`
|
||||
(0x61d190) puts the shared hero on. The world's portals (hero +0x578) and
|
||||
position (+0x18..+0x28) are put back afterwards: the hero file holds the
|
||||
whole hero object and `loadHero` deserialises it over the hero, position
|
||||
included (measured: the hero stood where it was saved on another
|
||||
difficulty; the hero stays the same object). A migrated character without a
|
||||
shared hero takes it from the first loaded world (`heroFrom`).
|
||||
|
||||
### New world on a difficulty
|
||||
|
||||
As `cUI_Character::executeAction(IMPORT_DO)` (0x70d637): session (0x7d7c40)
|
||||
+0x14 = 1, +0x44 = class, GameSetup (0x417e50) +0 = 1, `g_gameLevel` = d,
|
||||
then cCommand_armaPlay (vtable 0x894ff4, execute 0x755ac0) mode 1, argument
|
||||
`0x80000000 | 1 << 16`. cEngine's constructor runs InitGame and then
|
||||
`LoadHeroFile("Save\Hero00.pax")` (0x60bf2c); our hook loads the character's
|
||||
hero instead, keeps the start position, clears the portals and makes the
|
||||
game the character's.
|
||||
|
||||
### Save
|
||||
|
||||
`cEngine::Save` (0x619c90) writes `world.PAK`, then `SaveHero` writes
|
||||
`hero.pax`; both go into the current character (a new one gets the next free
|
||||
number). Story autosaves (engine event 0xf subtype 2 with the flag at +0x18,
|
||||
noted in `cEngine::ReceiveEvent`) go into the autosave file.
|
||||
|
||||
Direct save (K key, Esc menu "Speichern"): on the next engine tick, as the
|
||||
quicksave does (0x618ebb): `cUI_Manager::CanOpenWindow` (0x75a690),
|
||||
`cEngine::CaptureScreen` (0x613710, 1), description +0x6a, row +0x68 = 0,
|
||||
flag +0x54 |= 0x40. The Esc menu line keeps its action (Show
|
||||
`UI_WND_SAVEGAME`); `cUI_Manager::ReceiveEvent` (0x757120) turns that into the
|
||||
direct save while a game runs. Its text is patched at 0x6bc1f7 (id 0x41b is
|
||||
used by other windows too). Loading is from the main menu only.
|
||||
|
||||
### Migration
|
||||
|
||||
On first start (no `characters\` folder): after a backup in
|
||||
`Documents\Sacred\backup\<stamp>_characters_migration`, `GAME*.PAK` and
|
||||
`Hero*.pax` are grouped by (name, class); the newest world per difficulty is
|
||||
kept, the newest file overall is the shared hero.
|
||||
|
||||
## Open
|
||||
|
||||
- Unlocking by story completion (counters at save header +0x88..).
|
||||
- Reset a difficulty.
|
||||
- Remove the export / import UI.
|
||||
- Silent autosave every 10 minutes.
|
||||
- Quickload (row 0) does not know characters.
|
||||
- Multiplayer (see `todo.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
|
||||
|
||||
@@ -166,3 +166,33 @@ The hero sometimes plays a walking animation after a save game load while
|
||||
standing still. Not reproduced yet: a load log (hero mode, action, animation
|
||||
fields, orders) showed nothing unusual; right after a load the game itself
|
||||
sends one walk order (0x4) while the hero is idle. Measure again when it shows.
|
||||
|
||||
## Multiplayer with characters (2026-10-19)
|
||||
|
||||
Characters became one file each with a world per difficulty
|
||||
(`docs/characters.md`); vanilla multiplayer picks exported `Heronn.pax`
|
||||
heroes, which no longer exist. Later: give multiplayer the shared hero of a
|
||||
character file (the session's hero file +0x3a3c, as `SelectHeroSlot` does),
|
||||
and decide what a multiplayer game does to the character's progress.
|
||||
|
||||
## Characters: open parts of the rework (2026-10-20)
|
||||
|
||||
From the characters rework (`docs/characters.md`, request of 2026-10-19):
|
||||
|
||||
- **Unlock difficulties by story completion.** Bronze and Silber stay open;
|
||||
difficulty d >= 2 opens when the story on d - 1 is completed. The counters
|
||||
are one byte per difficulty in cStatsManager +0x5704 (read 0x424be0) and
|
||||
travel in the save header (+0x88..+0x98) and the hero file. Today every
|
||||
difficulty up to the highest played one is offered (and Silber).
|
||||
- **Reset a difficulty.** Remove one world from a character (the shared hero
|
||||
stays); a button / dialog entry in the load window.
|
||||
- **Remove the export / import UI.** The Esc menu's "Exportieren" and the
|
||||
character screen's import (cUI_Character mode 5, `docs/char-export.md`):
|
||||
characters replace exported heroes.
|
||||
- **Silent autosave every 10 minutes** (a setting later) into the
|
||||
autosave file, without the busy dialog, like the direct save
|
||||
(`SaveOnTick`) but marked as an autosave.
|
||||
- **Quickload (row 0) does not know characters.** Ctrl+Z saves into the
|
||||
current character, but the quickload event (0x116, engine +0x68 = row,
|
||||
flag 0x200) loads `GAME00.PAK`, which is not listed; make it reload the
|
||||
current character's world (or its newer autosave).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
// cUI_Button2 (vtable 0x895308, 0xbc bytes, constructor 0x72a180, plain ret).
|
||||
// Windows set their buttons up as cUI_Options 0x7184e0 does: images
|
||||
// (+0x54..+0x87), sounds (0x72acf0 -> +0x90..+0x9f), caption (0x72ad60 (text
|
||||
// id, font 1) -> +0xa4 / +0xa0), caption offset (0x72ad40 -> +0xb0), name
|
||||
// (0x732090).
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/ui/tabs.h"
|
||||
#include "game/ui/text.h"
|
||||
|
||||
namespace game {
|
||||
|
||||
constexpr size_t kButtonSize = 0xbc;
|
||||
constexpr size_t kButtonLook = 0x54;
|
||||
constexpr size_t kButtonLookEnd = 0x88;
|
||||
constexpr size_t kButtonSounds = 0x90;
|
||||
constexpr size_t kButtonSoundsEnd = 0xa0;
|
||||
constexpr size_t kButtonCaptionOffset = 0xb0; // x, y (int16)
|
||||
constexpr uint32_t kButtonFlags = 5; // 0x731e70, as 0x7184e0 passes
|
||||
|
||||
// Control vtable entries: vf10 handles an event (ret 4), vf14 renders (ret 4).
|
||||
constexpr size_t kVfHandleEvent = 0x10 / 4;
|
||||
constexpr size_t kVfRender = 0x14 / 4;
|
||||
|
||||
// A new button with `model`'s look and sounds at `rect` (relative to the
|
||||
// parent it is added to, or the screen frame without one), captioned with text
|
||||
// id `caption`.
|
||||
inline uint8_t* NewButtonLike(const uint8_t* model, const UiRect& rect, uint32_t caption, const char* name) {
|
||||
void* memory = GameNew(kButtonSize);
|
||||
if (!memory) {
|
||||
return nullptr;
|
||||
}
|
||||
using ConstructFn = uint8_t*(__fastcall*)(void*, void*);
|
||||
uint8_t* button = reinterpret_cast<ConstructFn>(0x0072a180)(memory, nullptr);
|
||||
using FlagsFn = void(__fastcall*)(void*, void*, uint32_t);
|
||||
reinterpret_cast<FlagsFn>(0x00731e70)(button, nullptr, kButtonFlags);
|
||||
std::copy(model + kButtonLook, model + kButtonLookEnd, button + kButtonLook);
|
||||
std::copy(model + kButtonSounds, model + kButtonSoundsEnd, button + kButtonSounds);
|
||||
std::copy(model + kButtonCaptionOffset, model + kButtonCaptionOffset + 4, button + kButtonCaptionOffset);
|
||||
*reinterpret_cast<UiRect*>(button + kControlRect) = rect;
|
||||
using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t);
|
||||
reinterpret_cast<CaptionFn>(0x0072ad60)(button, nullptr, caption, 1);
|
||||
using NameFn = void(__fastcall*)(void*, void*, const char*);
|
||||
reinterpret_cast<NameFn>(0x00732090)(button, nullptr, name);
|
||||
return button;
|
||||
}
|
||||
|
||||
inline int ControlHandleEvent(void* control, void* event) {
|
||||
using Fn = int(__fastcall*)(void*, void*, void*);
|
||||
return reinterpret_cast<Fn>((*static_cast<void***>(control))[kVfHandleEvent])(control, nullptr, event);
|
||||
}
|
||||
|
||||
inline int ControlRender(void* control, void* device) {
|
||||
using Fn = int(__fastcall*)(void*, void*, void*);
|
||||
return reinterpret_cast<Fn>((*static_cast<void***>(control))[kVfRender])(control, nullptr, device);
|
||||
}
|
||||
|
||||
inline bool Inside(const UiRect& rect, int x, int y) {
|
||||
return x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h;
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -182,6 +182,9 @@ void SetWideString(void* string, const std::wstring& value) {
|
||||
const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) {
|
||||
if (id >= kFirstOwnText && id < 0x80000000) {
|
||||
std::lock_guard guard(g_lock);
|
||||
if (const auto found = g_overrides.find(id); found != g_overrides.end()) {
|
||||
return &found->second->string;
|
||||
}
|
||||
const size_t index = id - kFirstOwnText;
|
||||
if (index < Texts().size()) {
|
||||
return &Texts()[index];
|
||||
|
||||
@@ -256,3 +256,41 @@ SHORT WINAPI GetAsyncKeyStateWhenFocused(int key) {
|
||||
}
|
||||
return GetAsyncKeyStateWhenFocused_original(key);
|
||||
}
|
||||
|
||||
// The game's "bring the window to the front" (0x810af0, thiscall (window,
|
||||
// timeout ms), ret 8; WinMain calls it three times): ShowWindow, then
|
||||
// SetForegroundWindow until the window is in front and SetFocus until it has
|
||||
// the focus. WinMain passes 0xffffffff, which never times out. Windows refuses
|
||||
// the foreground to a process when the user clicked or typed elsewhere while
|
||||
// it started, so the game hung at startup (2026-10-20, four times; main
|
||||
// thread in this loop, log ending at WM_ACTIVATEAPP(1, 0)). Now it asks for a
|
||||
// limited time, with the input of the window in front attached (which Windows
|
||||
// allows), and carries on in the background otherwise; a click brings it up.
|
||||
constexpr DWORD kForegroundWaitMs = 2000;
|
||||
|
||||
bool __fastcall BringWindowToFront(void* self, void* edx, HWND window, DWORD timeout);
|
||||
RE_HOOK(0x00810af0, BringWindowToFront);
|
||||
bool __fastcall BringWindowToFront(void*, void*, HWND window, DWORD timeout) {
|
||||
ShowWindow(window, SW_SHOW);
|
||||
const DWORD wait = std::min(timeout, kForegroundWaitMs);
|
||||
const DWORD start = GetTickCount();
|
||||
while (GetForegroundWindow() != window && GetTickCount() - start < wait) {
|
||||
const DWORD front = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
|
||||
const DWORD self = GetCurrentThreadId();
|
||||
const bool attached = front && front != self && AttachThreadInput(front, self, TRUE);
|
||||
BringWindowToTop(window);
|
||||
SetForegroundWindow(window);
|
||||
if (attached) {
|
||||
AttachThreadInput(front, self, FALSE);
|
||||
}
|
||||
Sleep(10);
|
||||
}
|
||||
const bool inFront = GetForegroundWindow() == window;
|
||||
if (inFront) {
|
||||
SetFocus(window);
|
||||
} else {
|
||||
re::Log("borderless: the game window did not get to the front, starting in the background");
|
||||
}
|
||||
UpdateWindow(window);
|
||||
return inFront;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
// Characters and difficulties (characters.cpp, docs/characters.md).
|
||||
|
||||
namespace mods::characters {
|
||||
|
||||
// Saves the running game into the current character at once, without the
|
||||
// save window (on the next engine tick).
|
||||
void RequestSave();
|
||||
|
||||
} // namespace mods::characters
|
||||
@@ -0,0 +1,282 @@
|
||||
#include "mods/charfile.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace mods::characters {
|
||||
namespace {
|
||||
|
||||
// Save files (AMS / AMH): header, chunk table at 0x100 with 64 entries.
|
||||
constexpr size_t kChunkTable = 0x100;
|
||||
constexpr size_t kChunkEntrySize = 12;
|
||||
constexpr size_t kChunkCount = 64;
|
||||
constexpr size_t kChunkData = kChunkTable + kChunkCount * kChunkEntrySize; // 0x400
|
||||
constexpr uint32_t kChunkHero = 0xc3;
|
||||
constexpr uint32_t kChunkWorldTime = 0x95; // "Date[..] Time[..]", UTF-16
|
||||
constexpr uint32_t kChunkDescription = 0x83; // the save's name, 0x40 bytes ANSI
|
||||
constexpr uint32_t kChunkThumbnail = 0x93;
|
||||
constexpr size_t kDescriptionSize = 0x40;
|
||||
// The load window reads the header and 0xc00 table bytes (0x71de90).
|
||||
constexpr size_t kPreviewMinimumSize = 0xd00;
|
||||
|
||||
constexpr size_t kHeroLevel = 0x0;
|
||||
constexpr size_t kHeroClass = 0x4;
|
||||
constexpr size_t kHeroName = 0x8;
|
||||
constexpr size_t kHeroNameChars = 0x40;
|
||||
|
||||
constexpr size_t kWorldMode = 0x24;
|
||||
constexpr size_t kWorldMultiplayer = 0x2c;
|
||||
constexpr size_t kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords)
|
||||
constexpr size_t kWorldDifficulty = 0x84;
|
||||
|
||||
// Character file: "SKCH", version, section count, then {name[16], offset, size}.
|
||||
constexpr char kMagic[4] = {'S', 'K', 'C', 'H'};
|
||||
constexpr uint32_t kVersion = 1;
|
||||
constexpr size_t kSectionNameSize = 16;
|
||||
constexpr size_t kSectionEntrySize = kSectionNameSize + 8;
|
||||
constexpr size_t kFileHeaderSize = 12;
|
||||
|
||||
uint32_t U32(const Bytes& bytes, size_t offset) {
|
||||
uint32_t value = 0;
|
||||
if (offset + 4 <= bytes.size()) {
|
||||
std::memcpy(&value, bytes.data() + offset, 4);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
void PutU32(Bytes& bytes, size_t offset, uint32_t value) {
|
||||
std::memcpy(bytes.data() + offset, &value, 4);
|
||||
}
|
||||
|
||||
struct Chunk {
|
||||
uint32_t type = 0;
|
||||
uint32_t offset = 0;
|
||||
uint32_t size = 0;
|
||||
};
|
||||
|
||||
std::optional<Chunk> FindChunk(const Bytes& file, uint32_t type) {
|
||||
if (file.size() < kChunkData) {
|
||||
return std::nullopt;
|
||||
}
|
||||
for (size_t i = 0; i < kChunkCount; ++i) {
|
||||
const size_t entry = kChunkTable + i * kChunkEntrySize;
|
||||
Chunk chunk{U32(file, entry), U32(file, entry + 4), U32(file, entry + 8)};
|
||||
if (chunk.type == type && chunk.offset + uint64_t(chunk.size) <= file.size()) {
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<SaveInfo> ReadSaveInfo(const Bytes& file) {
|
||||
if (file.size() < kChunkData || (std::memcmp(file.data(), "AMS", 3) != 0 && std::memcmp(file.data(), "AMH", 3) != 0)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::optional<Chunk> hero = FindChunk(file, kChunkHero);
|
||||
if (!hero || hero->size < kHeroName + kHeroNameChars * 2) {
|
||||
return std::nullopt;
|
||||
}
|
||||
SaveInfo info;
|
||||
const uint8_t* header = file.data() + hero->offset;
|
||||
std::memcpy(&info.level, header + kHeroLevel, 4);
|
||||
std::memcpy(&info.heroClass, header + kHeroClass, 4);
|
||||
const auto* name = reinterpret_cast<const wchar_t*>(header + kHeroName);
|
||||
info.name.assign(name, wcsnlen(name, kHeroNameChars));
|
||||
info.world = file[2] == 'S';
|
||||
if (info.world) {
|
||||
info.difficulty = static_cast<int>(U32(file, kWorldDifficulty));
|
||||
info.mode = U32(file, kWorldMode);
|
||||
info.multiplayer = U32(file, kWorldMultiplayer) != 0;
|
||||
SYSTEMTIME time{};
|
||||
WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek,
|
||||
&time.wHour, &time.wMinute, &time.wSecond, &time.wMilliseconds};
|
||||
for (size_t i = 0; i < std::size(fields); ++i) {
|
||||
*fields[i] = static_cast<WORD>(U32(file, kWorldSavedAt + i * 4));
|
||||
}
|
||||
FILETIME stamp{};
|
||||
if (SystemTimeToFileTime(&time, &stamp)) {
|
||||
info.savedAt = (uint64_t(stamp.dwHighDateTime) << 32) | stamp.dwLowDateTime;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
bool SetHeroName(Bytes& file, const std::wstring& name) {
|
||||
const std::optional<Chunk> hero = FindChunk(file, kChunkHero);
|
||||
if (!hero || hero->size < kHeroName + kHeroNameChars * 2 || name.empty() || name.size() > kHeroNameMaxChars) {
|
||||
return false;
|
||||
}
|
||||
uint8_t* field = file.data() + hero->offset + kHeroName;
|
||||
std::memset(field, 0, kHeroNameChars * 2);
|
||||
std::memcpy(field, name.data(), name.size() * 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
Bytes BuildPreview(const Bytes& world, const std::string& description) {
|
||||
Bytes preview(world.begin(), world.begin() + std::min(world.size(), kChunkData));
|
||||
preview.resize(kChunkData);
|
||||
std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize);
|
||||
size_t entry = kChunkTable;
|
||||
auto append = [&](uint32_t type, const uint8_t* data, uint32_t size) {
|
||||
PutU32(preview, entry, type);
|
||||
PutU32(preview, entry + 4, static_cast<uint32_t>(preview.size()));
|
||||
PutU32(preview, entry + 8, size);
|
||||
entry += kChunkEntrySize;
|
||||
preview.insert(preview.end(), data, data + size);
|
||||
};
|
||||
for (uint32_t type : {kChunkWorldTime, kChunkThumbnail}) {
|
||||
if (const std::optional<Chunk> chunk = FindChunk(world, type)) {
|
||||
append(type, world.data() + chunk->offset, chunk->size);
|
||||
}
|
||||
}
|
||||
uint8_t text[kDescriptionSize] = {};
|
||||
std::memcpy(text, description.data(), std::min(description.size(), kDescriptionSize - 1));
|
||||
append(kChunkDescription, text, kDescriptionSize);
|
||||
if (preview.size() < kPreviewMinimumSize) {
|
||||
preview.resize(kPreviewMinimumSize);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
std::optional<Bytes> ReadFile(const std::string& path) {
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return Bytes(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
bool WriteFileReplacing(const std::string& path, const Bytes& bytes) {
|
||||
const std::string temp = path + ".tmp";
|
||||
{
|
||||
std::ofstream file(temp, std::ios::binary | std::ios::trunc);
|
||||
if (!file.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
|
||||
}
|
||||
|
||||
std::string WorldSection(int difficulty) {
|
||||
return "world" + std::to_string(difficulty);
|
||||
}
|
||||
|
||||
bool CharacterFile::Open() {
|
||||
sections_.clear();
|
||||
std::ifstream file(path_, std::ios::binary);
|
||||
uint8_t header[kFileHeaderSize];
|
||||
if (!file.read(reinterpret_cast<char*>(header), sizeof(header)) || std::memcmp(header, kMagic, 4) != 0) {
|
||||
return false;
|
||||
}
|
||||
uint32_t count = 0;
|
||||
std::memcpy(&count, header + 8, 4);
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
uint8_t entry[kSectionEntrySize];
|
||||
if (!file.read(reinterpret_cast<char*>(entry), sizeof(entry))) {
|
||||
return false;
|
||||
}
|
||||
Section section;
|
||||
const auto* name = reinterpret_cast<const char*>(entry);
|
||||
section.name.assign(name, strnlen(name, kSectionNameSize));
|
||||
std::memcpy(§ion.offset, entry + kSectionNameSize, 4);
|
||||
std::memcpy(§ion.size, entry + kSectionNameSize + 4, 4);
|
||||
sections_.push_back(std::move(section));
|
||||
}
|
||||
meta = nlohmann::json::object();
|
||||
if (std::optional<Bytes> text = Read("meta")) {
|
||||
meta = nlohmann::json::parse(text->begin(), text->end(), nullptr, false);
|
||||
if (meta.is_discarded() || !meta.is_object()) {
|
||||
meta = nlohmann::json::object();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CharacterFile::Has(const std::string& name) const {
|
||||
for (const Section& section : sections_) {
|
||||
if (section.name == name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<Bytes> CharacterFile::Read(const std::string& name) const {
|
||||
for (const Section& section : sections_) {
|
||||
if (section.name != name) {
|
||||
continue;
|
||||
}
|
||||
if (section.data) {
|
||||
return section.data;
|
||||
}
|
||||
std::ifstream file(path_, std::ios::binary);
|
||||
Bytes bytes(section.size);
|
||||
if (!file.seekg(section.offset) ||
|
||||
!file.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void CharacterFile::Set(const std::string& name, Bytes bytes) {
|
||||
for (Section& section : sections_) {
|
||||
if (section.name == name) {
|
||||
section.size = static_cast<uint32_t>(bytes.size());
|
||||
section.data = std::move(bytes);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Section section;
|
||||
section.name = name;
|
||||
section.size = static_cast<uint32_t>(bytes.size());
|
||||
section.data = std::move(bytes);
|
||||
sections_.push_back(std::move(section));
|
||||
}
|
||||
|
||||
void CharacterFile::Remove(const std::string& name) {
|
||||
std::erase_if(sections_, [&](const Section& section) { return section.name == name; });
|
||||
}
|
||||
|
||||
bool CharacterFile::Save() {
|
||||
const std::string text = meta.dump(2);
|
||||
Set("meta", Bytes(text.begin(), text.end()));
|
||||
|
||||
std::vector<Bytes> contents;
|
||||
for (const Section& section : sections_) {
|
||||
std::optional<Bytes> bytes = Read(section.name);
|
||||
if (!bytes) {
|
||||
return false;
|
||||
}
|
||||
contents.push_back(std::move(*bytes));
|
||||
}
|
||||
Bytes out(kFileHeaderSize + sections_.size() * kSectionEntrySize);
|
||||
std::memcpy(out.data(), kMagic, 4);
|
||||
PutU32(out, 4, kVersion);
|
||||
PutU32(out, 8, static_cast<uint32_t>(sections_.size()));
|
||||
for (size_t i = 0; i < sections_.size(); ++i) {
|
||||
const size_t entry = kFileHeaderSize + i * kSectionEntrySize;
|
||||
std::memcpy(out.data() + entry, sections_[i].name.data(), std::min(sections_[i].name.size(), kSectionNameSize - 1));
|
||||
PutU32(out, entry + kSectionNameSize, static_cast<uint32_t>(out.size()));
|
||||
PutU32(out, entry + kSectionNameSize + 4, static_cast<uint32_t>(contents[i].size()));
|
||||
out.insert(out.end(), contents[i].begin(), contents[i].end());
|
||||
}
|
||||
if (!WriteFileReplacing(path_, out)) {
|
||||
return false;
|
||||
}
|
||||
// The file now holds every section; offsets as just written.
|
||||
for (size_t i = 0; i < sections_.size(); ++i) {
|
||||
const size_t entry = kFileHeaderSize + i * kSectionEntrySize;
|
||||
sections_[i].offset = U32(out, entry + kSectionNameSize);
|
||||
sections_[i].size = U32(out, entry + kSectionNameSize + 4);
|
||||
sections_[i].data.reset();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mods::characters
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
// Character files (docs/characters.md): one file per character with its shared
|
||||
// hero and one world per difficulty, plus reading the game's save files.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace mods::characters {
|
||||
|
||||
using Bytes = std::vector<uint8_t>;
|
||||
|
||||
// Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4).
|
||||
constexpr int kDifficultyCount = 5;
|
||||
|
||||
// What the game's save files (GAME.PAK "AMS", hero files .pax "AMH") say about
|
||||
// their hero: header 0x100 bytes, then a table of 64 chunks {type, offset,
|
||||
// size}; chunk 0xc3 is the hero header.
|
||||
struct SaveInfo {
|
||||
std::wstring name; // hero header +8, UTF-16
|
||||
uint32_t heroClass = 0; // hero header +4: 1 Seraphim .. 9 Daemonin
|
||||
uint32_t level = 0; // hero header +0
|
||||
bool world = false; // a save game (AMS), not a hero file
|
||||
int difficulty = 0; // AMS header +0x84 (g_gameLevel)
|
||||
uint32_t mode = 0; // AMS header +0x24: 2 normal, 3 hardcore
|
||||
bool multiplayer = false; // AMS header +0x2c
|
||||
uint64_t savedAt = 0; // AMS header +0x5c SYSTEMTIME, as FILETIME
|
||||
};
|
||||
std::optional<SaveInfo> ReadSaveInfo(const Bytes& file);
|
||||
|
||||
// Writes `name` as the hero's name (hero header +8, 0x40 UTF-16 characters
|
||||
// with the terminator), the only place the name is stored; false if the file
|
||||
// has no hero header or the name is empty or too long.
|
||||
constexpr size_t kHeroNameMaxChars = 63;
|
||||
bool SetHeroName(Bytes& file, const std::wstring& name);
|
||||
|
||||
// A small save game the game's load window can list: `world`'s header with its
|
||||
// date, its thumbnail and world time, and `description` (ANSI, up to 63
|
||||
// characters) as the save's name.
|
||||
Bytes BuildPreview(const Bytes& world, const std::string& description);
|
||||
|
||||
// Reading and writing whole files (paths in the game's code page).
|
||||
std::optional<Bytes> ReadFile(const std::string& path);
|
||||
bool WriteFileReplacing(const std::string& path, const Bytes& bytes);
|
||||
|
||||
// A character file: named sections ("meta" JSON, "hero" .pax, "world<d>"
|
||||
// GAME.PAK). Sections are read from disk on demand; Save writes all of them
|
||||
// to a temporary file and replaces the old one.
|
||||
class CharacterFile {
|
||||
public:
|
||||
explicit CharacterFile(std::string path) : path_(std::move(path)) {}
|
||||
|
||||
// Reads the section table and the meta; false if the file is missing or
|
||||
// not a character file.
|
||||
bool Open();
|
||||
|
||||
const std::string& Path() const { return path_; }
|
||||
bool Has(const std::string& section) const;
|
||||
std::optional<Bytes> Read(const std::string& section) const;
|
||||
void Set(const std::string& section, Bytes bytes);
|
||||
void Remove(const std::string& section);
|
||||
bool Save();
|
||||
|
||||
nlohmann::json meta = nlohmann::json::object();
|
||||
|
||||
private:
|
||||
struct Section {
|
||||
std::string name;
|
||||
uint32_t offset = 0; // in the file; unused once `data` is set
|
||||
uint32_t size = 0;
|
||||
std::optional<Bytes> data; // set or changed since Open
|
||||
};
|
||||
std::string path_;
|
||||
std::vector<Section> sections_;
|
||||
};
|
||||
|
||||
std::string WorldSection(int difficulty);
|
||||
|
||||
} // namespace mods::characters
|
||||
+1
-2
@@ -695,8 +695,7 @@ HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center)
|
||||
HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game
|
||||
// cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp.
|
||||
HUD_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER_FRAMED(0x0071dd80, UiSavegame_Render)
|
||||
// cUI_Savegame (0x71d130 / 0x71dd80): hooked by mods/characters.cpp.
|
||||
HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
|
||||
|
||||
|
||||
+3
-20
@@ -26,7 +26,7 @@
|
||||
// AllyPotion=None ; kind 1 on the weakest ally (the game's own potion keys
|
||||
// ; Space/B/Q/W/E/R are switched off)
|
||||
// PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up)
|
||||
// SaveGame=K ; what S did (save game window)
|
||||
// SaveGame=K ; save the game at once (S opened the save window)
|
||||
// Map=M ; open / close the map (the game's M is switched off)
|
||||
// OverviewMap=Tab ; the overview map while held (the game's Tab is switched off)
|
||||
// Labels=Alt ; names of items and creatures while held (the game's Alt is switched off)
|
||||
@@ -45,6 +45,7 @@
|
||||
#include "game/engine/items.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/ui/windows.h"
|
||||
#include "mods/characters.h"
|
||||
#include "mods/skillbar.h"
|
||||
#include "mods/skillkeys.h"
|
||||
#include "re/config.h"
|
||||
@@ -252,24 +253,6 @@ void OnTick(uint8_t* engine) {
|
||||
}
|
||||
}
|
||||
|
||||
// The save game window, as the UI manager's S hotkey opens it (0x757775):
|
||||
// only with the window built (manager slot 0x2b) and no blocking window open;
|
||||
// it posts an engine event (code 0x13, 5, 1; with 0 for the kernel's second
|
||||
// argument) and shows UI_WND_SAVEGAME.
|
||||
constexpr int kSaveGameSlot = 0x2b;
|
||||
constexpr uint32_t kEngineEventVtable = 0x0088eb38;
|
||||
constexpr uint32_t kEngineEventSaveGame = 0x13;
|
||||
|
||||
void OpenSaveGame() {
|
||||
uint32_t* ui = game::UiManager();
|
||||
if (!ui || !ui[kSaveGameSlot] || !game::UiCanOpenWindow(ui)) {
|
||||
return;
|
||||
}
|
||||
uint32_t event[9] = {kEngineEventVtable, kEngineEventSaveGame, 5, 1};
|
||||
game::PostEvent(event, 0);
|
||||
game::SendWindowCommand(ui, "UI_WND_SAVEGAME", game::WindowCommand::Show);
|
||||
}
|
||||
|
||||
// The map (cUI_Megamap, manager slot 0x25), as the UI manager's M hotkey
|
||||
// toggles it (cUI_Manager::ReceiveEvent 0x757120): open, M closes it; else
|
||||
// nothing while one of the windows it checks first is open (slots 0x2b save
|
||||
@@ -376,7 +359,7 @@ void Press(const Binding& binding, int vk) {
|
||||
mods::skillbar::SelectWeaponSet(slot);
|
||||
break;
|
||||
case Kind::SaveGame:
|
||||
OpenSaveGame();
|
||||
characters::RequestSave(); // at once, no save window
|
||||
break;
|
||||
case Kind::PickUp:
|
||||
g_pickUp = true;
|
||||
|
||||
+13
-38
@@ -28,6 +28,7 @@
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "game/ui/button.h"
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/draw.h"
|
||||
#include "game/ui/events.h"
|
||||
@@ -326,10 +327,6 @@ game::UiRect OnWindow(void* window, const game::UiRect& r) {
|
||||
return {origin.x + r.x, origin.y + r.y, r.w, r.h};
|
||||
}
|
||||
|
||||
bool Inside(const game::UiRect& r, int x, int y) {
|
||||
return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
|
||||
}
|
||||
|
||||
void FillBox(void* device, const game::UiRect& r, uint32_t colour) {
|
||||
game::FillRect(device, static_cast<float>(r.x), static_cast<float>(r.y), static_cast<float>(r.x + r.w),
|
||||
static_cast<float>(r.y + r.h), colour);
|
||||
@@ -412,25 +409,13 @@ void ResetPending() {
|
||||
// The binding whose box is at (x, y), -1 if none.
|
||||
int BoxAt(void* window, int x, int y) {
|
||||
for (const Line& line : Layout()) {
|
||||
if (line.kind == LineKind::Binding && Inside(BoxRect(window, line), x, y)) {
|
||||
if (line.kind == LineKind::Binding && game::Inside(BoxRect(window, line), x, y)) {
|
||||
return line.binding;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// cUI_Button2 (vtable 0x895308, 0xbc bytes, constructor 0x72a180, plain
|
||||
// ret). 0x7184e0 sets up Cancel and OK the same way: images (+0x54..+0x87),
|
||||
// sounds (0x72acf0 -> +0x90..+0x9f), caption (0x72ad60 (text id, font 1) ->
|
||||
// +0xa4 / +0xa0), caption offset (0x72ad40 -> +0xb0), name (0x732090).
|
||||
constexpr size_t kButtonSize = 0xbc;
|
||||
constexpr size_t kButtonLook = 0x54;
|
||||
constexpr size_t kButtonLookEnd = 0x88;
|
||||
constexpr size_t kButtonSounds = 0x90;
|
||||
constexpr size_t kButtonSoundsEnd = 0xa0;
|
||||
constexpr size_t kButtonCaptionOffset = 0xb0; // x, y (int16)
|
||||
constexpr uint32_t kButtonFlags = 5; // 0x731e70, as 0x7184e0 passes
|
||||
|
||||
void PushControl(uint8_t* window, void* control) {
|
||||
using PushFn = void(__fastcall*)(void*, void*, void**);
|
||||
reinterpret_cast<PushFn>(0x006bcf50)(window + kWindowControls, nullptr, &control);
|
||||
@@ -439,27 +424,17 @@ void PushControl(uint8_t* window, void* control) {
|
||||
// "Alle zurücksetzen": a button with Cancel's look.
|
||||
uint8_t* CreateResetButton(uint8_t* window) {
|
||||
static const uint32_t kCaption = game::RegisterText(L"Alle zurücksetzen");
|
||||
uint8_t* cancel = Field(window, kCancelButton);
|
||||
void* memory = cancel ? game::GameNew(kButtonSize) : nullptr;
|
||||
if (!memory) {
|
||||
const uint8_t* cancel = Field(window, kCancelButton);
|
||||
if (!cancel) {
|
||||
return nullptr;
|
||||
}
|
||||
using ConstructFn = uint8_t*(__fastcall*)(void*, void*);
|
||||
uint8_t* button = reinterpret_cast<ConstructFn>(0x0072a180)(memory, nullptr);
|
||||
using FlagsFn = void(__fastcall*)(void*, void*, uint32_t);
|
||||
reinterpret_cast<FlagsFn>(0x00731e70)(button, nullptr, kButtonFlags);
|
||||
std::copy(cancel + kButtonLook, cancel + kButtonLookEnd, button + kButtonLook);
|
||||
std::copy(cancel + kButtonSounds, cancel + kButtonSoundsEnd, button + kButtonSounds);
|
||||
std::copy(cancel + kButtonCaptionOffset, cancel + kButtonCaptionOffset + 4, button + kButtonCaptionOffset);
|
||||
auto* rect = reinterpret_cast<game::UiRect*>(button + game::kControlRect);
|
||||
*rect = *reinterpret_cast<const game::UiRect*>(cancel + game::kControlRect);
|
||||
rect->x = kResetX;
|
||||
rect->w = kResetWidth;
|
||||
using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t);
|
||||
reinterpret_cast<CaptionFn>(0x0072ad60)(button, nullptr, kCaption, 1);
|
||||
using NameFn = void(__fastcall*)(void*, void*, const char*);
|
||||
reinterpret_cast<NameFn>(0x00732090)(button, nullptr, "UI_BTN_KEYS_RESET");
|
||||
PushControl(window, button);
|
||||
game::UiRect rect = *reinterpret_cast<const game::UiRect*>(cancel + game::kControlRect);
|
||||
rect.x = kResetX;
|
||||
rect.w = kResetWidth;
|
||||
uint8_t* button = game::NewButtonLike(cancel, rect, kCaption, "UI_BTN_KEYS_RESET");
|
||||
if (button) {
|
||||
PushControl(window, button);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -551,7 +526,7 @@ game::UiRect LootBoxRect(void* window, int row, int mode) {
|
||||
int LootBoxAt(void* window, int x, int y) {
|
||||
for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) {
|
||||
for (int mode = 0; mode < mods::lootfilter::kModeCount; ++mode) {
|
||||
if (Inside(LootBoxRect(window, category + 1, mode), x, y)) {
|
||||
if (game::Inside(LootBoxRect(window, category + 1, mode), x, y)) {
|
||||
return category * mods::lootfilter::kModeCount + mode;
|
||||
}
|
||||
}
|
||||
@@ -690,7 +665,7 @@ int EventBody(void* context) {
|
||||
if (g_capturing >= 0) {
|
||||
StopCapture();
|
||||
}
|
||||
if (left && w->resetButton && Inside(game::ControlScreenRect(w->resetButton), x, y)) {
|
||||
if (left && w->resetButton && game::Inside(game::ControlScreenRect(w->resetButton), x, y)) {
|
||||
ResetPending(); // the button still gets the click (pressed look, sound)
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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,282 @@
|
||||
// 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.
|
||||
//
|
||||
// The game's own view of the save folder can be moved into a subfolder
|
||||
// (SetGameSaveSubfolder): the characters mod (characters.cpp) keeps the game's
|
||||
// files in a work folder and its own files beside it.
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
// Below the save folder, with leading separator ("\characters\_work"); empty:
|
||||
// the save folder itself.
|
||||
std::string& GameSaveSubfolder() {
|
||||
static std::string subfolder;
|
||||
return subfolder;
|
||||
}
|
||||
|
||||
// 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 (save) {
|
||||
if (folders.save.empty() && GameSaveSubfolder().empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
buffer = (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + GameSaveSubfolder();
|
||||
for (char c : rest.substr(first.size())) {
|
||||
buffer += c == '/' ? '\\' : c;
|
||||
}
|
||||
} else {
|
||||
if (folders.config.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
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
|
||||
|
||||
void SetGameSaveSubfolder(const std::string& subfolder) {
|
||||
GameSaveSubfolder() = subfolder;
|
||||
}
|
||||
|
||||
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,16 @@
|
||||
#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();
|
||||
|
||||
// The game's save folder becomes SaveDir() + `subfolder` (leading separator,
|
||||
// e.g. "\characters\_work"); SaveDir() itself stays for sacredK's files.
|
||||
// Set during static initialisation, before the game touches a file.
|
||||
void SetGameSaveSubfolder(const std::string& subfolder);
|
||||
|
||||
} // namespace mods::userfolders
|
||||
@@ -329,3 +329,18 @@
|
||||
0x006116c0 cEngine::dropItem # the player's drop into the world
|
||||
0x005fc910 cObjectManager::RemoveFromMap # (id), ret 4
|
||||
0x00819580 Options_PickupAuto # PICKUPAUTO from the config (0 all, 1 valuable, 2 gold)
|
||||
0x0061d190 LoadHeroFile # cdecl (path, 1, 0): a hero file onto the player hero (whole object, position too)
|
||||
0x0061cda0 cEngine::SaveHero # cdecl (path): the player hero as a hero file
|
||||
0x0061b790 cEngine::Load # (path, quiet), ret 8: reads a GAME.PAK
|
||||
0x00619c90 cEngine::Save # (path), ret 4; render thread, engine +0x54 & 0x40
|
||||
0x0060dc00 cEngine::InitGame # (int), ret 4: new game, start hero
|
||||
0x0071c520 cUI_Savegame::Action # (action), ret 4: 2 save, 4 load, 8 delete, 10 rescan, 0xb default
|
||||
0x0071e330 cUI_Savegame::Scan # (dir, multiplayer), ret 8: "./SAVE/*.PAK"
|
||||
0x0071be90 cUI_Savegame::SendSaveLoad # (kind, row): engine event 0xf
|
||||
0x00755ac0 cCommand_armaPlay::execute # posts 0x8002 (mode, argument) to the main window
|
||||
0x007d7c40 Session_Instance
|
||||
0x007d7cf0 Session::SetHeroClass # (class) -> +0x44, ret 4
|
||||
0x00417e50 GameSetup_Instance # 0x12e6c bytes; +0 set by both character-screen starts
|
||||
0x006bb870 cUI_EscMenu::BuildLines # line actions; "Spielstaende" text 0x41b at 0x6bc1f7
|
||||
0x00810af0 BringWindowToFront # (hwnd, timeout), ret 8; loops forever with timeout -1
|
||||
0x0072ad60 cUI_Button2::SetCaption # (text id, font), ret 8 -> +0xa4 / +0xa0
|
||||
Reference in new issue
Block a user