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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 01:41:42 +02:00

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 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).