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>
This commit is contained in:
1 parent
5df7efc198
commit
5bccb0734e
16 files changed
+2052
-64
No files matched your search
@@ -71,6 +71,8 @@ add_library(sacredK SHARED
|
|||||||
src/mods/options.cpp
|
src/mods/options.cpp
|
||||||
src/mods/lootfilter.cpp
|
src/mods/lootfilter.cpp
|
||||||
src/mods/userfolders.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})
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -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).
|
||||||
@@ -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) {
|
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];
|
||||||
|
|||||||
@@ -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
@@ -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_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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
// folder's files are copied into it (the originals stay where they were).
|
// 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
|
// Config.exe and gameserver.exe are separate programs and still use the game
|
||||||
// folder.
|
// 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 "mods/userfolders.h"
|
||||||
|
|
||||||
@@ -136,6 +140,13 @@ Folders FindFolders() {
|
|||||||
return folders;
|
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
|
// 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.
|
// loader lock, where the shell's folder lookup must not run.
|
||||||
const Folders& UserFolders() {
|
const Folders& UserFolders() {
|
||||||
@@ -166,15 +177,18 @@ const char* Redirect(const char* path, std::string& buffer) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
const Folders& folders = UserFolders();
|
const Folders& folders = UserFolders();
|
||||||
if (folders.save.empty()) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
if (save) {
|
if (save) {
|
||||||
buffer = folders.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())) {
|
for (char c : rest.substr(first.size())) {
|
||||||
buffer += c == '/' ? '\\' : c;
|
buffer += c == '/' ? '\\' : c;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (folders.config.empty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
buffer = folders.config + "\\" + kSettingsFile;
|
buffer = folders.config + "\\" + kSettingsFile;
|
||||||
}
|
}
|
||||||
return buffer.c_str();
|
return buffer.c_str();
|
||||||
@@ -188,6 +202,10 @@ const char* Mapped(const char* path, std::string& buffer) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
void SetGameSaveSubfolder(const std::string& subfolder) {
|
||||||
|
GameSaveSubfolder() = subfolder;
|
||||||
|
}
|
||||||
|
|
||||||
std::string SaveDir() {
|
std::string SaveDir() {
|
||||||
const Folders& folders = UserFolders();
|
const Folders& folders = UserFolders();
|
||||||
return (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + "\\";
|
return (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + "\\";
|
||||||
|
|||||||
@@ -8,4 +8,9 @@ namespace mods::userfolders {
|
|||||||
// or the game's own save\ when the Documents folder is unusable.
|
// or the game's own save\ when the Documents folder is unusable.
|
||||||
std::string SaveDir();
|
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
|
} // namespace mods::userfolders
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user