- Spielen hero panel shows the selected hero in 3D (cUI_NetGranny, game/ui/granny.h). - All menu screens draw on the options' rock wall; the main menu's 1024 frame is drawn centred over it, mouse and cursor shifted to match. - The main menu's and options' own pictures and the main menu's 3D scene are left out (textures hidden by surface, scene call redirected). - Logo top left: FX_SACREDLOGO.TGA, drawn added. - Game textures by name (game/ui/texture.h), textured and added quads. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
319 lines
18 KiB
Markdown
319 lines
18 KiB
Markdown
# 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 (dwords at +0x88.., loader
|
||
0x61bb19..) and in the hero header chunk 0xc3 (bytes at +0xed.., written by
|
||
0x604360, read by loadHero 0x606632..; +0xec is `g_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 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.
|
||
- 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_campaign` 0x182cdc8 (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::Save` writes it to the save header +0x24 (2
|
||
Ancaria, 3 Underworld, 0x61a5c5), `cEngine::Load` sets 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 as
|
||
`cEngine::SaveHero` writes it), one world per campaign and difficulty,
|
||
`ancaria<d>` / `underworld<d>` with d = `g_gameLevel` (AMS as
|
||
`cEngine::Save` writes it). Sections are named, not slotted: a higher
|
||
difficulty (or another campaign) is one more section. Files of the first
|
||
format named Ancaria's worlds `world<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 writes `world.PAK` / `hero.pax` there.
|
||
- 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".
|
||
|
||
### Spielen screen (cUI_Savegame)
|
||
|
||
Request 2026-10-20: redesign the load window, 16:9, using the space (layout
|
||
agreed: characters left, the character in the middle, worlds right, buttons
|
||
below; our own background). Built on the shared screen kit
|
||
(docs/screens.md); the window's own drawing and mouse handling are not used.
|
||
|
||
- The window still scans: before each scan (0x71e330) one preview
|
||
`GAMEnn.PAK` per character is written into `_work`; row nn = character nn.
|
||
Its entries are only needed for the load.
|
||
- Left: the characters, newest save first (name, class, level; "im Spiel" for
|
||
the one being played). Click selects, double click plays.
|
||
- Middle: name, class and level, the world played last with its date, and
|
||
the hero in 3D with its equipment (2026-10-20): the game's cUI_NetGranny
|
||
(game/ui/granny.h), the control the export screen and the multiplayer
|
||
lobby showed heroes with. The character's hero file goes to
|
||
`_workpreview.pax`, LoadHeroFile (path, 0, info) reads its hero info
|
||
(class, equipment looks) without touching the player hero. Drawn without
|
||
its name label, with the white light set, size factor 3.8 (game 2.5); its
|
||
x is moved by (1365 - 1024) / 2, as it centres the model as in a 1024
|
||
layout. Migrated characters without the shared hero show none until
|
||
played.
|
||
- Right: a grid campaigns x difficulties. A cell shows the save's date,
|
||
"+ Autosave" when the autosave is newer, "Story abgeschlossen", "Neu" for a
|
||
difficulty that is open but has no world, "–" when closed. 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..); new worlds need the shared hero, not in multiplayer
|
||
(byte 0x182cb6c). Click selects, double click plays. With a newer autosave
|
||
"Laden von:" picks the save or the autosave (default: the autosave).
|
||
"Welt zurücksetzen" for a saved world (not the character being played).
|
||
- Buttons: Neuer Charakter (main menu only), Umbenennen, Löschen (not for the
|
||
character being played), Zurück (action 0xc), Spielen.
|
||
- Spielen: a saved world loads through the window's load action (4, with its
|
||
"really load?" confirmation in a game): the window's selection is set to
|
||
its first entry and `g_loadRow` names the row, which `Engine_Load` takes
|
||
instead of the entry's. A new world starts a game (below; in a game after
|
||
stopping the running one). The window's own load / default actions (Enter)
|
||
run Spielen too; its delete actions (7, 8) are ignored.
|
||
- Dialogs over the screen: rename (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`), campaign of a new character, confirmations of reset and
|
||
delete. Buttons are cUI_Button2 copies of the load button
|
||
(`game::NewButtonLike`) with our own text ids and overrides.
|
||
- Delete moves the character file and its autosave to `charactersdeleted`.
|
||
- Neuer Charakter: 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.
|
||
- 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.
|
||
|
||
### Character window (cUI_Character)
|
||
|
||
- Vanilla made no new heroes for the Underworld: `cUI_Character::Show`
|
||
(vf24 0x70e2a0) switches to mode 7 in single player with `g_campaign` 1
|
||
(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) uses `g_campaign` as 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
|
||
|
||
`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.
|
||
|
||
Silent autosave: 10 minutes after the last save of any kind (or the game's
|
||
start), `SaveOnTick` does the direct save marked as an autosave, so it goes
|
||
into the autosave file. While a window is open or a save runs it waits for
|
||
the next tick. A character never saved yet gets its character file from it.
|
||
Interval: options window, *Allgemein*, *Autosave (Min.)* (docs/options.md); kept in
|
||
`sacredK.ini` `[characters] AutosaveMinutes` (default 10, 0 off).
|
||
|
||
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).
|
||
|
||
The Esc menu: "Exportieren" became "Laden" (requests 2026-10-20). The line
|
||
(text id 0x419 pushed at 0x6bc272, now "Laden") keeps its command, which shows
|
||
UI_CHARACTER (the export screen) and hides the menu; in a game
|
||
`UiManager_ReceiveEvent` turns that into showing the load window. The load
|
||
window always opens in load mode (`cUI_Savegame::Show` vf24 0x71cfb0 picks
|
||
save mode in a game; `SetMode` 0x71c4f0 (2) after it). In a game every
|
||
world can be loaded, the current one too (a reload from its last save; the
|
||
game's in-game load, engine flag 0x200). A "(neu)" world needs a new engine:
|
||
the running game stops as "Beenden" stops it (`cCommand_armaStop`, execute
|
||
0x755b30, vtable 0x88ee2c: posts 0x8001 to the main window, whose handler
|
||
deletes the engine), and when the engine is gone (`OnGameEnd`) the new game's
|
||
start command follows (0x8002), without the main menu. The character being
|
||
played cannot be deleted. 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
|
||
|
||
- Quickload (row 0) does not know characters (not needed, user 2026-10-20).
|
||
- Spielen screen: scrolling the list beyond 9 characters.
|
||
- Multiplayer (see `todo.md`).
|