Compare commits

..
3 Commits
Author SHA1 Message Date
khallaandClaude Opus 5.5 5bccb0734e Characters: one file per character with a world per difficulty
- CharacterNN.sacredK holds the shared hero and one world per difficulty,
  plus an autosave file; the game works in save\characters\_work
- Load window: one row per character, a difficulty dialog on Laden
  (worlds, newer autosaves, "(neu)" starts a new game with the shared
  hero), Umbenennen, delete moves the character to characters\deleted
- The world keeps its own portals and hero position when the shared hero
  is put on
- K and the Esc menu's "Speichern" save directly (quicksave path)
- One-time migration of the old save rows and exported heroes, after a
  backup
- Startup no longer hangs in the game's foreground loop (0x810af0)
- Text overrides apply to our own text ids too; shared cUI_Button2
  helpers in game/ui/button.h

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 01:41:42 +02:00
khallaandClaude Opus 5.5 5df7efc198 Docs: local Sacred Gold install required, no hard-coded game path
README explains --game-dir and the stored GameDir; the VS Code launch
uses the stored path and builds through build.ps1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 00:14:36 +02:00
khallaandClaude Opus 5.5 6c467e22c4 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>
2026-10-20 00:14:36 +02:00
24 changed files with 2416 additions and 80 deletions

No files matched your search

+4 -2
View File
@@ -3,13 +3,15 @@
"configurations": [ "configurations": [
{ {
// Builds, then starts the game through the launcher (no debugger in the game). // 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)", "name": "Run Sacred (sacredK)",
"type": "cppvsdbg", "type": "cppvsdbg",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/build/x86-debug/sacredK.exe", "program": "${workspaceFolder}/build/x86-debug/sacredK.exe",
"args": ["--game-dir", "D:\\Gaming\\Sacred Gold", "log"], "args": ["log"],
"cwd": "${workspaceFolder}/build/x86-debug", "cwd": "${workspaceFolder}/build/x86-debug",
"preLaunchTask": "CMake: build", "preLaunchTask": "Build x86-debug",
"console": "internalConsole" "console": "internalConsole"
}, },
{ {
+7 -4
View File
@@ -2,10 +2,13 @@
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "CMake: build", // build.ps1 enters the MSVC x86 environment and builds the x86-debug
"type": "cmake", // preset; unlike a CMake Tools task it needs no preset picked in VS Code.
"command": "build", "label": "Build x86-debug",
"targets": ["all"], "type": "process",
"command": "powershell",
"args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "${workspaceFolder}/build.ps1", "x86-debug"],
"options": { "cwd": "${workspaceFolder}" },
"group": { "kind": "build", "isDefault": true }, "group": { "kind": "build", "isDefault": true },
"problemMatcher": ["$msCompile"] "problemMatcher": ["$msCompile"]
} }
+5 -3
View File
@@ -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 auto-attack, skill reworks, respec, map fixes, ...). The game stays playable
at every step; the user plays it to test. at every step; the user plays it to test.
- Target binary: the original `sacred.exe` in `D:\Gaming\Sacred Gold\` - Target binary: the original `sacred.exe` of a local Sacred Gold 2.28
(path in `build\x86-debug\sacredK.ini`, `[paths] GameDir`). Ignore the installation (see `README.md`, Supported build). The game is not in the
`SacredReborn*` files in that folder. 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 texts are German (Fertigkeiten, Attribute, Blitzschlag, ...); keep the
game's names when talking about features. game's names when talking about features.
- The user's monitor is 3440x1440; the game renders about 1835x768 scaled up. - The user's monitor is 3440x1440; the game renders about 1835x768 scaled up.
+3
View File
@@ -70,6 +70,9 @@ add_library(sacredK SHARED
src/mods/charexport.cpp src/mods/charexport.cpp
src/mods/options.cpp src/mods/options.cpp
src/mods/lootfilter.cpp src/mods/lootfilter.cpp
src/mods/userfolders.cpp
src/mods/charfile.cpp
src/mods/characters.cpp
) )
target_include_directories(sacredK PRIVATE src) target_include_directories(sacredK PRIVATE src)
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS}) target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
+15 -2
View File
@@ -41,11 +41,24 @@ the CMake presets.
## Running ## 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 ```powershell
build\x86-debug\sacredK.exe --game-dir "D:\Gaming\Sacred Gold" # first run; remembered in sacredK.ini 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 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 `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. game sends to its internal logger, even when the game's own logging is off.
+210
View File
@@ -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`).
+2 -1
View File
@@ -4,7 +4,8 @@
The chest gets a second tab that belongs to no single hero: a chest shared by 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 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 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; (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 use the inventory's Rucksack / Kampfkünste / Combos header look with its
+30
View File
@@ -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 standing still. Not reproduced yet: a load log (hero mode, action, animation
fields, orders) showed nothing unusual; right after a load the game itself 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. 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).
+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.
+66
View File
@@ -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
+3
View File
@@ -182,6 +182,9 @@ void SetWideString(void* string, const std::wstring& value) {
const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) { const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) {
if (id >= kFirstOwnText && id < 0x80000000) { if (id >= kFirstOwnText && id < 0x80000000) {
std::lock_guard guard(g_lock); 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; const size_t index = id - kFirstOwnText;
if (index < Texts().size()) { if (index < Texts().size()) {
return &Texts()[index]; return &Texts()[index];
+38
View File
@@ -256,3 +256,41 @@ SHORT WINAPI GetAsyncKeyStateWhenFocused(int key) {
} }
return GetAsyncKeyStateWhenFocused_original(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
+10
View File
@@ -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
+282
View File
@@ -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(&section.offset, entry + kSectionNameSize, 4);
std::memcpy(&section.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
+82
View File
@@ -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
View File
@@ -695,8 +695,7 @@ HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center)
HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center) HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center)
HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game
// cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp. // cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp.
HUD_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center) // cUI_Savegame (0x71d130 / 0x71dd80): hooked by mods/characters.cpp.
HUD_RENDER_FRAMED(0x0071dd80, UiSavegame_Render)
HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center) HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center)
HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center) HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
+3 -20
View File
@@ -26,7 +26,7 @@
// AllyPotion=None ; kind 1 on the weakest ally (the game's own potion keys // AllyPotion=None ; kind 1 on the weakest ally (the game's own potion keys
// ; Space/B/Q/W/E/R are switched off) // ; 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) // 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) // 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) // 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) // 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/items.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "game/ui/windows.h" #include "game/ui/windows.h"
#include "mods/characters.h"
#include "mods/skillbar.h" #include "mods/skillbar.h"
#include "mods/skillkeys.h" #include "mods/skillkeys.h"
#include "re/config.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 // 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 // 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 // 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); mods::skillbar::SelectWeaponSet(slot);
break; break;
case Kind::SaveGame: case Kind::SaveGame:
OpenSaveGame(); characters::RequestSave(); // at once, no save window
break; break;
case Kind::PickUp: case Kind::PickUp:
g_pickUp = true; g_pickUp = true;
+13 -38
View File
@@ -28,6 +28,7 @@
#include <windows.h> #include <windows.h>
#include "game/ui/button.h"
#include "game/ui/control.h" #include "game/ui/control.h"
#include "game/ui/draw.h" #include "game/ui/draw.h"
#include "game/ui/events.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}; 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) { 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), 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); 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. // The binding whose box is at (x, y), -1 if none.
int BoxAt(void* window, int x, int y) { int BoxAt(void* window, int x, int y) {
for (const Line& line : Layout()) { 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 line.binding;
} }
} }
return -1; 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) { void PushControl(uint8_t* window, void* control) {
using PushFn = void(__fastcall*)(void*, void*, void**); using PushFn = void(__fastcall*)(void*, void*, void**);
reinterpret_cast<PushFn>(0x006bcf50)(window + kWindowControls, nullptr, &control); 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. // "Alle zurücksetzen": a button with Cancel's look.
uint8_t* CreateResetButton(uint8_t* window) { uint8_t* CreateResetButton(uint8_t* window) {
static const uint32_t kCaption = game::RegisterText(L"Alle zurücksetzen"); static const uint32_t kCaption = game::RegisterText(L"Alle zurücksetzen");
uint8_t* cancel = Field(window, kCancelButton); const uint8_t* cancel = Field(window, kCancelButton);
void* memory = cancel ? game::GameNew(kButtonSize) : nullptr; if (!cancel) {
if (!memory) {
return nullptr; return nullptr;
} }
using ConstructFn = uint8_t*(__fastcall*)(void*, void*); game::UiRect rect = *reinterpret_cast<const game::UiRect*>(cancel + game::kControlRect);
uint8_t* button = reinterpret_cast<ConstructFn>(0x0072a180)(memory, nullptr); rect.x = kResetX;
using FlagsFn = void(__fastcall*)(void*, void*, uint32_t); rect.w = kResetWidth;
reinterpret_cast<FlagsFn>(0x00731e70)(button, nullptr, kButtonFlags); uint8_t* button = game::NewButtonLike(cancel, rect, kCaption, "UI_BTN_KEYS_RESET");
std::copy(cancel + kButtonLook, cancel + kButtonLookEnd, button + kButtonLook); if (button) {
std::copy(cancel + kButtonSounds, cancel + kButtonSoundsEnd, button + kButtonSounds); PushControl(window, button);
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);
return button; return button;
} }
@@ -551,7 +526,7 @@ game::UiRect LootBoxRect(void* window, int row, int mode) {
int LootBoxAt(void* window, int x, int y) { int LootBoxAt(void* window, int x, int y) {
for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) { for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) {
for (int mode = 0; mode < mods::lootfilter::kModeCount; ++mode) { 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; return category * mods::lootfilter::kModeCount + mode;
} }
} }
@@ -690,7 +665,7 @@ int EventBody(void* context) {
if (g_capturing >= 0) { if (g_capturing >= 0) {
StopCapture(); 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) ResetPending(); // the button still gets the click (pressed look, sound)
break; break;
} }
+3 -7
View File
@@ -12,7 +12,7 @@
// serializer (vf1c(stream, 1, 0)); loadHero recreates them with // serializer (vf1c(stream, 1, 0)); loadHero recreates them with
// cObjectManager::Create(type, 0, 0, 0), vf1c(stream, 0, file version) and // cObjectManager::Create(type, 0, 0, 0), vf1c(stream, 0, file version) and
// gives them their new id. The shared chest does the same into // 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 // of each game session; cleared when the session ends (the ids die with the
// object manager). // object manager).
@@ -38,7 +38,7 @@
#include "mods/itemstack.h" #include "mods/itemstack.h"
#include "mods/sharedchest.h" #include "mods/sharedchest.h"
#include "mods/skillslots.h" #include "mods/skillslots.h"
#include "re/config.h" #include "mods/userfolders.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -102,11 +102,7 @@ bool SharedOpen() {
} }
std::string FilePath() { std::string FilePath() {
std::string dir = re::ConfigString("paths", "GameDir", ""); return mods::userfolders::SaveDir() + "shared_chest.json";
if (!dir.empty() && dir.back() != '\\' && dir.back() != '/') {
dir += '\\';
}
return dir + "save\\shared_chest.json";
} }
// --- items <-> JSON ------------------------------------------------------------ // --- items <-> JSON ------------------------------------------------------------
+1 -1
View File
@@ -2,7 +2,7 @@
namespace mods::sharedchest { 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(); void Save();
} // namespace mods::sharedchest } // namespace mods::sharedchest
+282
View File
@@ -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
+16
View File
@@ -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
+15
View File
@@ -329,3 +329,18 @@
0x006116c0 cEngine::dropItem # the player's drop into the world 0x006116c0 cEngine::dropItem # the player's drop into the world
0x005fc910 cObjectManager::RemoveFromMap # (id), ret 4 0x005fc910 cObjectManager::RemoveFromMap # (id), ret 4
0x00819580 Options_PickupAuto # PICKUPAUTO from the config (0 all, 1 valuable, 2 gold) 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