- 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>
11 KiB
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 byItemDataMgr::saveHero0x6046f0; header 0x22c bytes + chunks (items, inventories, chest, stats block). The hero's portals and world position are part of it (logsaveHero ... 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 (
loadHeroat 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 viacEngine::Load).
Difficulty
g_gameLevel0x182cdc4: current difficulty, from the save header (logGameLevel[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 cheatsbronze/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 keyStartLevMultiat 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::SelectHeroSlotand 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 aftercEngine::Loadreturns 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 ascEngine::SaveHerowrites it),world<d>(AMS ascEngine::Savewrites 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 writesworld.PAK/hero.paxthere. - Deleted characters move to
characters\deleted\.
Load window (cUI_Savegame)
- Before each scan (0x71e330) one preview
GAMEnn.PAKper 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).