"Zuruecksetzen" in the load window lists the selected character's worlds and asks for confirmation. The world goes (a copy in characters\deleted), with an autosave of it, and the shared hero's story completion of that difficulty is cleared. The dialog is one generic list (title + entries). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
16 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 (dwords at +0x88.., loader 0x61bb19..) and in the hero header chunk 0xc3 (bytes at +0xed.., written by 0x604360, read by loadHero 0x606632..; +0xec isg_gameLevel). Checked in a character file (2026-10-20): Silber and Gold completed, played on Platin = [0, 1, 1, 0, 0] in the hero and the Platin world. 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.
- Main menu: "Kampagne Ancaria", "Kampagne Underworld" and "Spielstände" become one "Spielen": the characters, loaded or started in either campaign on any difficulty; new characters from there too. A character file holds a world per campaign and difficulty; the format must take more difficulties later. Decided: no level requirement for the Underworld (vanilla: hero level 25, 0x70c48b), story completions open the next difficulty in both campaigns, "Neuer Charakter" is a button in the Spielen window.
Campaigns (measured / read 2026-10-20)
g_campaign0x182cdc8 (u16): 0 Ancaria, 1 Underworld. The main menu's event handler (0x711fa0) sets it by entry name before an entry handles an event (UI_LABEL_NEWGAME 0, UI_LABEL_NEWADDON 1); both entries show UI_CHARACTER.cEngine::Savewrites it to the save header +0x24 (2 Ancaria, 3 Underworld, 0x61a5c5),cEngine::Loadsets it from there (0x61bafd). The load window lists only saves of the running campaign (0x71e2b9).- Story completions are counted per difficulty only (the same counter for both campaigns).
- Main menu (cUI_MainMenu, entries built by 0x712f30): cUI_StaticText64FX entries (constructor 0x753d70), name +0x30, rect +0x24 (0x20 apart), text id +0x78, command +0x88; main page vector +0x158, children +0x90.
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, lastCampaign, lastDifficulty, migratedFrom, heroFrom, heroFromCampaign),hero(AMH ascEngine::SaveHerowrites it), one world per campaign and difficulty,ancaria<d>/underworld<d>with d =g_gameLevel(AMS ascEngine::Savewrites it). Sections are named, not slotted: a higher difficulty (or another campaign) is one more section. Files of the first format named Ancaria's worldsworld<d>; they are renamed when read (charfile.cpp) and written under the new name at the next save. Written to a temp file, then moved over.CharacterNN.autosave.sacredK: same layout, one world (meta.campaign,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\.
Main menu (src/mods/mainmenu.cpp)
After the build (0x712f30) the NEWGAME and NEWADDON entries are removed (out of both vectors, deleted) and UI_LABEL_LOADGAME ("Spielstände", shows UI_WND_SAVEGAME) moves to the top with the text "Spielen"; the others move up, keeping the gap before "Beenden".
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 preview's campaign field is the running campaign, so the window lists every character. The row's second line (call 0x71db2a to SetText 0x736f00) shows "Stufe N - Campaign - difficulty". -
Laden (action 4 / 0xb) opens the world dialog when there is more than one choice: per campaign worlds, newer autosaves "(Autosave)", and "(neu)" for every difficulty without a world up to the highest open one (needs the shared hero; not in multiplayer, byte 0x182cb6c). Open: Bronze and Silber, every difficulty up to the highest with a world in either campaign, and the one after each difficulty whose story the shared hero completed (hero header +0xed..).
g_campaignis set to the chosen world's before loading or starting. -
"Neuer Charakter" (two places below Löschen; main menu only) asks for the campaign, sets
g_campaign, hides the window and shows UI_CHARACTER (the game's character creation, as the main menu's campaign entries did). The first save makes the character file. -
Vanilla made no new heroes for the Underworld:
cUI_Character::Show(vf24 0x70e2a0) switches to mode 7 in single player withg_campaign1 (0x70e3e8), the import of an exported hero of level 25+ (0x70eedc); with no exported heroes its slots were empty, and slots below level 25 never lit up (render 0x70f4df / 0x70f57c) (reported 2026-10-20). All eight campaign checks of the window (0x70c473, 0x70d199, 0x70e3e8, 0x70eade, 0x70eeb2, 0x70f4df, 0x70f57c, 0x70f6aa) read a constant Ancaria instead: creation with all classes in both campaigns; the start (ExecuteAction 0x70d0f0, action 1) usesg_campaignas set. -
Class selection without "Importieren" (request 2026-10-20): button +0x3a0 (caption UI_IMPORT in modes 0/1); its modes-0/1 draw (0x70f67e) is removed, its click (0x70eab4 / 0x70eac7, SetMode(7)) jumps to the handler's end.
-
Load window without "Speichern" (request 2026-10-20; saving is direct): button +0x820 (action 1, disabled in load mode, so its click falls through) is not drawn (jump over 0x71ddbc); Löschen (+0x8dc) takes its rect, our buttons follow below it.
-
"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). -
"Zurücksetzen" (below Umbenennen; a selected character with the shared hero and a world, not the one being played) lists the character's worlds, then asks " zurücksetzen?". A reset (decided 2026-10-20) removes the world section (a copy goes to
charactersdeletedCharacterNN.sacredK.<section>.PAK) and an autosave of that world, and clears the shared hero's story completion of that difficulty (hero header +0xed + d); the game counts it per difficulty, so the next difficulty closes in both campaigns unless a world keeps it open. The hero keeps everything else. -
The dialog is one generic list: each kind fills a title and entries (FillDialog), "Abbrechen" last.
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.
The Esc menu has no "Exportieren" (request 2026-10-20): cUI_EscMenu::BuildLines
(0x6bb870, called by the constructor and on an event) makes five
cUI_StaticText64 lines in the vector +0x158, 0x24 apart; each handles its own
events. After it the UI_ESC_EXPORT line is removed and deleted, the lines
below move up, and the window (0x108 x 0xc8) loses 0x24 of height, staying
centred. With it the export screen (cUI_Character mode 5) is unreachable;
its mod (charexport.cpp, an erase button for export slots) was deleted.
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
- Silent autosave every 10 minutes.
- Quickload (row 0) does not know characters.
- Multiplayer (see
todo.md).