- One world per campaign and difficulty in a character file (sections ancaria<d> / underworld<d>; old world<d> renamed when read). Difficulty unlocks by the shared hero's story completions (hero header +0xed..), shared by both campaigns. - Main menu: "Kampagne Ancaria", "Kampagne Underworld" and "Spielstaende" become "Spielen" (mods/mainmenu.cpp). The load window lists all characters, the world dialog offers both campaigns, "Neuer Charakter" asks for the campaign and opens character creation. - Character window: no Underworld import mode (new Underworld heroes, all classes, slots light up), no "Importieren"; load window without "Speichern". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
15 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).
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
- Reset a difficulty.
- Remove the export / import UI.
- Silent autosave every 10 minutes.
- Quickload (row 0) does not know characters.
- Multiplayer (see
todo.md).