Compare commits

..
9 Commits
Author SHA1 Message Date
khallaandClaude Opus 5.5 3978060954 Teleportationstrank: the running campaign's portal list
In the Underworld the potion opens the Underworld portals (busy dialog mode
6, as the teleporters there do); g_campaign moved to game/addresses.h.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:42:55 +02:00
khallaandClaude Opus 5.5 0ef3cf4bbd Shared chest, menu after a game, dialogs over 16:9 screens
- Shared chest: emptied and not saved from the moment a save game is read
  until it reloads; a hero saved inside a load wrote the new world's
  objects (stale ids) as the chest's items.
- Main menu after a game: shown 16:9 screens are tracked on the menu thread
  only and expire after 2 frames; a destroyed in-game window kept the menu
  unframed (old pictures, frame at the left).
- Dialogs over a 16:9 screen in the main menu are centred, their dimming
  covers the screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:41:12 +02:00
khallaandClaude Opus 5.5 c4aacbcdd2 Hide menu pictures only inside the menu frames
The hidden-texture list stayed active in a game; a game texture made at the
address of a freed menu picture was skipped (rendering glitches in-game).
Hiding now applies only while the main menu's frame or the in-game options
draw over the menu background.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:10:30 +02:00
khallaandClaude Opus 5.5 db87e276bf Menu screens: rock background, centred frame, logo, 3D hero
- 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>
2026-10-21 00:05:55 +02:00
khallaandClaude Opus 5.5 34a119433a Characters: 16:9 "Spielen" screen, autosave interval option
- Shared screen kit (mods/screen, docs/screens.md): a 1365x768 canvas
  centred in the render width on our own dark background, the game's
  frames and buttons; the main menu renders full width while it is shown.
- The load window (cUI_Savegame) is replaced by the Spielen screen:
  character list, hero panel, campaign x difficulty grid, save/autosave
  choice, rename/delete/reset/new character dialogs.
- Migrated characters without a shared hero load their hero's world
  first, then start the new world.
- Options, Allgemein: autosave interval (Aus/5/10/15/30 minutes).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 23:20:45 +02:00
khallaandClaude Opus 5.5 732d9c2abb Characters: Laden in the Esc menu, timed autosave
- Esc menu: "Exportieren" became "Laden"; it opens the load window, always
  in load mode. In a game every world loads (the current one reloads); a
  "(neu)" world stops the game (cCommand_armaStop) and starts the new one
  without the main menu. The character being played cannot be deleted.
- Silent autosave every sacredK.ini [characters] AutosaveMinutes (default
  10, 0 = off) into the character's autosave file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 22:41:20 +02:00
khallaandClaude Opus 5.5 feebe72ab7 Characters: reset a difficulty
"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>
2026-10-20 22:04:41 +02:00
khallaandClaude Opus 5.5 e0d8d0ce7e Esc menu without "Exportieren"; export screen mod removed
- cUI_EscMenu::BuildLines hook: the UI_ESC_EXPORT line is removed, the
  lines below move up, the window shrinks by one line, centred.
- game/ui/control.h: control vector helpers (ControlsIn, RemoveControl,
  SetControlAt, DeleteControl, ControlNamed), used by the main menu too.
- The export screen is unreachable now: charexport.cpp and
  docs/char-export.md deleted. Spielen screen redesign noted in todo.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 21:56:11 +02:00
khallaandClaude Opus 5.5 618288e310 Characters: campaigns, "Spielen" main menu, story unlocks
- 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>
2026-10-20 21:48:47 +02:00
32 changed files with 2664 additions and 609 deletions

No files matched your search

+2 -1
View File
@@ -67,12 +67,13 @@ add_library(sacredK SHARED
src/mods/runetrade.cpp src/mods/runetrade.cpp
src/mods/teleportpotion.cpp src/mods/teleportpotion.cpp
src/mods/guards.cpp src/mods/guards.cpp
src/mods/charexport.cpp
src/mods/options.cpp src/mods/options.cpp
src/mods/lootfilter.cpp src/mods/lootfilter.cpp
src/mods/userfolders.cpp src/mods/userfolders.cpp
src/mods/charfile.cpp src/mods/charfile.cpp
src/mods/screen.cpp
src/mods/characters.cpp src/mods/characters.cpp
src/mods/mainmenu.cpp
) )
target_include_directories(sacredK PRIVATE src) target_include_directories(sacredK PRIVATE src)
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS}) target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
-48
View File
@@ -1,48 +0,0 @@
# Character export screen (`src/mods/charexport.cpp`)
Request (2026-10-18): the export screen (Esc menu "Exportieren") was not
adapted to widescreen (layout and clicks shifted), slots could not be
cleared or overwritten; the screen and the save game window need black
beside the background picture.
## Layout
See `hud.md`, "Full-screen windows": the 3D scene was stretched across the
wide viewport; the window now draws with a viewport on the centred frame.
## cUI_Character
One window for the main menu's character selection / import and the export
screen (vtable 0x894fa8, render vf44 0x70f160, events vf10 0x70e860).
| Field | Meaning |
|---|---|
| +0x390 | mode: 4 selection, 5 export, 6 overwrite confirmation (7 import) |
| +0x394 | previous mode |
| +0x1b4 | 8 slot controls; hero id at +0x2f8 (0: empty) |
| +0x1d6 | selected slot (char, -1 none) |
| +0x3b4 | erase button ("UI_CHAR_ERASE_TOOLTIP") |
| Address | Function |
|---|---|
| 0x70c760 | SetMode (mode), ret 4; entering mode 5 reloads the heroes |
| 0x70c440 | SelectSlot (char slot), ret 4; shows the erase button in modes 4 / 7 only |
| 0x70d0f0 | ExecuteAction (action, slot, extra), ret 0xc: 6 export into slot (`Save\Hero%.2d.pax`), 9 export with confirmation if occupied |
| 0x70e650 | right-hand button: in mode 5 action 6 (empty slot) or 9 (occupied) |
| 0x7f01a0 | HeroSlots_DeleteHero (slot + 1), thiscall on 0x7d7c40(), ret 4 |
## Overwrite
Measured with a log: exporting to an occupied slot was already the game's own
path (action 9 -> mode 6 confirmation -> action 6). It only failed because
clicks hit the wrong place before the layout fix.
## Clear
The erase button is created for every mode, and its click handler (vf10)
does not check the mode: it opens the game's confirmation
(UI_CHAR_ERASE_CONFIRM, cUI_BusyDlg), whose answer (0x72087b) calls
DeleteHero. The mod shows the button in export mode whenever an occupied slot
is selected (after SetMode / SelectSlot). After DeleteHero, if the screen is
in export mode, it enters export mode again (reloads the heroes) and clears
the selection; otherwise the erased hero stayed until the next export.
+137 -29
View File
@@ -78,8 +78,11 @@ Decided (2026-10-19, follow-up):
0x425160) +0x5704, one byte counter per difficulty (6 entries). 0x425160) +0x5704, one byte counter per difficulty (6 entries).
`0x424bc0` (k) increments, `0x424be0` (k) reads, `0x424c00` (k, value) sets. `0x424bc0` (k) increments, `0x424be0` (k) reads, `0x424c00` (k, value) sets.
The script opcode at 0x4aed26 increments counter[`g_gameLevel`]: the story The script opcode at 0x4aed26 increments counter[`g_gameLevel`]: the story
end. The counters travel in the save header (loader 0x61bb19..) and in the end. The counters travel in the save header (dwords at +0x88.., loader
hero file (loadHero 0x606632.., saveHero 0x60444c..). The console cheats 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. `bronze` / `silber` / ... (0x615d00..) increment them too.
- Vanilla offers a difficulty by **hero level**, not by completion: - Vanilla offers a difficulty by **hero level**, not by completion:
thresholds `0xad2dc4..0xad2dd4` (5 dwords, parsed from the balance data key thresholds `0xad2dc4..0xad2dd4` (5 dwords, parsed from the balance data key
@@ -123,6 +126,28 @@ Decided (2026-10-19, follow-up):
without a world starts a new game with the shared hero. without a world starts a new game with the shared hero.
- The save key and the Esc menu ("Spielstände" becomes "Speichern") save - The save key and the Esc menu ("Spielstände" becomes "Speichern") save
directly, without the save window. 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}`) ## Implementation (`src/mods/characters.cpp`, `charfile.{h,cpp}`)
@@ -130,33 +155,97 @@ Decided (2026-10-19, follow-up):
- `Documents\Sacred\save\characters\CharacterNN.sacredK`: "SKCH", version 1, - `Documents\Sacred\save\characters\CharacterNN.sacredK`: "SKCH", version 1,
section count, then `{name[16], offset, size}` entries. Sections: `meta` section count, then `{name[16], offset, size}` entries. Sections: `meta`
(JSON: format, name UTF-8, class, level, lastDifficulty, migratedFrom, (JSON: format, name UTF-8, class, level, lastCampaign, lastDifficulty,
heroFrom), `hero` (AMH as `cEngine::SaveHero` writes it), `world<d>` (AMS as migratedFrom, heroFrom, heroFromCampaign), `hero` (AMH as
`cEngine::Save` writes it). Written to a temp file, then moved over. `cEngine::SaveHero` writes it), one world per campaign and difficulty,
- `CharacterNN.autosave.sacredK`: same layout, one world (`meta.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's save folder is redirected to `characters\_work` (userfolders);
the game reads and writes `world.PAK` / `hero.pax` there. the game reads and writes `world.PAK` / `hero.pax` there.
- Deleted characters move to `characters\deleted\`. - Deleted characters move to `characters\deleted\`.
### Load window (cUI_Savegame) ### Main menu (`src/mods/mainmenu.cpp`)
- Before each scan (0x71e330) one preview `GAMEnn.PAK` per character is After the build (0x712f30) the NEWGAME and NEWADDON entries are removed (out
written into `_work` (header, thumbnail, world time, description of both vectors, deleted) and UI_LABEL_LOADGAME ("Spielstände", shows
"Name - Klasse"); row nn = character nn. The row's second line (call UI_WND_SAVEGAME) moves to the top with the text "Spielen"; the others move up,
0x71db2a to SetText 0x736f00) shows "Stufe N - difficulty". keeping the gap before "Beenden".
- 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).
### 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 ### Load
`cEngine::Load` (0x61b790) of a listed row: the chosen world and the hero are `cEngine::Load` (0x61b790) of a listed row: the chosen world and the hero are
@@ -185,13 +274,35 @@ game the character's.
number). Story autosaves (engine event 0xf subtype 2 with the flag at +0x18, number). Story autosaves (engine event 0xf subtype 2 with the flag at +0x18,
noted in `cEngine::ReceiveEvent`) go into the autosave file. 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 Direct save (K key, Esc menu "Speichern"): on the next engine tick, as the
quicksave does (0x618ebb): `cUI_Manager::CanOpenWindow` (0x75a690), quicksave does (0x618ebb): `cUI_Manager::CanOpenWindow` (0x75a690),
`cEngine::CaptureScreen` (0x613710, 1), description +0x6a, row +0x68 = 0, `cEngine::CaptureScreen` (0x613710, 1), description +0x6a, row +0x68 = 0,
flag +0x54 |= 0x40. The Esc menu line keeps its action (Show flag +0x54 |= 0x40. The Esc menu line keeps its action (Show
`UI_WND_SAVEGAME`); `cUI_Manager::ReceiveEvent` (0x757120) turns that into the `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 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. 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 ### Migration
@@ -202,9 +313,6 @@ kept, the newest file overall is the shared hero.
## Open ## Open
- Unlocking by story completion (counters at save header +0x88..). - Quickload (row 0) does not know characters (not needed, user 2026-10-20).
- Reset a difficulty. - Spielen screen: scrolling the list beyond 9 characters.
- Remove the export / import UI.
- Silent autosave every 10 minutes.
- Quickload (row 0) does not know characters.
- Multiplayer (see `todo.md`). - Multiplayer (see `todo.md`).
+15
View File
@@ -117,3 +117,18 @@ choice (PICKUPAUTO radio buttons, their labels, the separator; matched by
pointer and control name at +0x30) is never shown, a framed table below the pointer and control name at +0x30) is never shown, a framed table below the
auto-pickup frame sets a mode per category. Its frame is made like the key auto-pickup frame sets a mode per category. Its frame is made like the key
page's (`AddFrame`). page's (`AddFrame`).
## Allgemein: autosave interval (2026-10-20)
Request: the silent autosave's interval (docs/characters.md, Save) settable
in the options, not only in `sacredK.ini`. The general frame moved up into
the auto-pickup frame's place; it is taller than the frames beside it, so the
new frame goes below the lowest frame of the left column. It copies the
right column's lowest frame (Internet-Optionen): its height and the gap the
right column leaves between its frames, measured from the window's frames
when it is made (a lower frame broke its side ornaments, a smaller gap joined
the borders: screenshots 2026-10-20). One row *Autosave (Min.)*: Aus / 5 /
10 / 15 / 30 (the key page's box colour is not seen on the dark backdrop: a
lighter one). A click selects, OK writes
`[characters] AutosaveMinutes` (`characters::SetAutosaveMinutes`, the timer
uses it at once), Cancel drops it.
+90
View File
@@ -0,0 +1,90 @@
# 16:9 menu screens
Request (2026-10-20): the game's menu screens are laid out for 1024x768; we
design our own UIs for them. There is no 16:9 artwork for the game, so the
background is our own, and the approach is shared by all menu screens. The
background (user, 2026-10-20: "textured dark", the flat gradient looked
black): the options window's rock wall, `GUI_OPTION_BACKGROUND<row><column>.TGA`
(4 x 3 tiles of 256), scaled to cover the render target, tinted down,
shaded towards the bottom and the sides. Game textures by name:
`game/ui/texture.h`, drawn with `game::DrawTextured` (game/ui/draw.h). Target: 16:9, 1920x1080, which is
1365x768 in UI units; wider screens only get more background at the sides
(not designed for ultrawide).
## How a screen works
- `mods/screen.{h,cpp}`: a screen replaces a full-screen window's drawing and
mouse handling. Its render hook calls `screen::Render(window, device, body,
context)`: the background across the whole render target, then the body in
canvas coordinates (1365x768, centred: `hud::RenderCanvas`, the HUD
layer's offset machinery with `Anchor::Canvas`; drawing, the cursor and the
window's hit tests shifted alike). Mouse events go through `screen::Handle`
(`hud::HandleCanvas`). Keys stay the window's.
- The main menu thread renders the full width (no 4:3 frame any more).
- Parts: `screen::Panel` (dark backdrop and the game's frame, `cUI_Frame`,
game/ui/frame.h, made anew when its rect changes), `screen::Button` (a
button of the game's with the look of one of the window's,
`game::NewButtonLike`), `screen::Text`, `screen::Fill`,
`screen::DoubleClick`. Spacing constants from measurements: frames reach
0x20 beyond their rects and need a height of about 0x40, buttons reach 0x21.
Frames are built of 64-unit pieces: widths and heights in steps of 64
(`screen::kFrameStep`), other sizes leave gaps (screenshot 2026-10-20).
- Fonts (logged 2026-10-20): font 0 is another class (no line height field);
the TrueType fonts 1..7 have line heights 20, 19, 25, 31, 25, 28, 13.
Screens use font 1; 4 or 6 would suit headings.
- Inside the canvas scope the HUD layer does not stretch "full 1024 layout"
quads (`g_canvasScope`).
## Main menu frame (2026-10-20)
Request: the background on all screens, the main menu's and the options
window's own pictures removed.
- cUI_Manager's UI render (`0x7587b0`, hooked in mods/screen.cpp) on the
menu thread: the background first, then `hud::RenderMenuFrame` runs the
render with drawing shifted by (render width - 1024) / 2 and a viewport on
the frame (3D parts). Windows inside draw in frame coordinates (their own
HUD scopes are nested, so they do nothing).
- Mouse positions are given to the game in frame coordinates: the
borderless mod's `ToGame` subtracts `hud::MenuFrameOffsetX`. The cursor
sprite (`cMouseCursor::DrawItem` 0x6555e0, drawn after the UI) is shifted
by drawing only.
- While a 16:9 screen is shown the frame is not shifted (offset 0): the
screen draws itself across the width.
- Full-screen windows in a game (options, `hud::RenderFramed`) get the
background beside the frame instead of black.
- The windows' pictures are left out by texture: the HUD layer tracks the
texture bound to stage 0 (`SetTexture`, device vtable 35) and skips quads
drawn with a hidden one (`hud::SetHiddenTextures`). Logged on the menu
thread (texture ids requested from the cache, by caller; names by trying
the names in the texture pak): main menu GUI_UW_OPTION1..12 + UI_MAIN_SKY,
options UW_OPTION_1_01..12. This install uses the Underworld versions; the
Ancaria ones (GUI_OPTION1..12, ...) were not seen and are not listed.
- The main menu's 3D scene (sky UI_UW_MAIN_CENTER, water with the SACRED
logo UI_MAIN_REF; render 0x75e620 on the scene object 0x75df10) is called
from cUI_MainMenu::Render (0x7122c0) at 0x712bd9; that call is redirected
to an empty function. cUI_Character (0x70f41b) still draws it.
- Logo top left (2026-10-21), 48 from the edges, 124 high: FX_SACREDLOGO.TGA,
the emblem (horns around a ring of runes) glowing blue on black without
alpha, drawn added (blend ONE/ONE, `game::DrawTexturedAdded`) as the
game's effects draw it. Other candidates: GUI_MAIN_LOGO (plain golden
lettering), SACREDSYMBOL01/02 (model atlases), the emblem on the loading
pictures (PAK\loading*.bmp, not in the paks). Screens may overlap it.
## Screens
- Spielen (the load window, cUI_Savegame): docs/characters.md, "Spielen
screen".
## Fixes (2026-10-21)
- The main menu came back unframed (old pictures, frame at the left) after a
game: the set of shown 16:9 screens kept a window of the in-game UI that
was destroyed while shown. Now only screens drawn on the menu thread count,
and an entry expires after 2 menu frames without being drawn
(`screen::BeginMenuFrame`).
- Dialogs over a 16:9 screen in the main menu (e.g. "Spielstand laden?") sat
at the left with the dimming 1024 wide: `hud::OffsetFor` gave every window
offset 0 while the menu showed. Now only while the menu frame is drawn
centred (it shifts the windows itself); otherwise dialogs are centred and
their full-layout dimming stretched, as in a game.
+15
View File
@@ -70,3 +70,18 @@ was empty after loading another character (in-game saves don't call saveHero).
- loadHero's extra fix-up for some item classes (`0x5beaa0` / `0x5d3a20`, - loadHero's extra fix-up for some item classes (`0x5beaa0` / `0x5d3a20`,
item classes 5, 6, 8, 0xd, 0x11..0x19, 0x1b, 0x1d) is not repeated. item classes 5, 6, 8, 0xd, 0x11..0x19, 0x1b, 0x1d) is not repeated.
- Multiplayer: not supported. - Multiplayer: not supported.
## Items replaced by a world's (fixed 2026-10-21)
Reported: the shared chest's items were gone, other items (potions, gear) in
their cells. The grid holds object ids. A save game loaded into the running
engine (Esc "Laden", cEngine::Load from the render loop 0x60e68d) replaces
all objects, so until the chest reloads on the next tick its ids name the new
world's objects. A hero saved inside that load (characters.cpp Engine_Load: a
migrated character's first load makes its world hero the shared hero with
SaveHero) wrote the shared chest, i.e. those objects, into
shared_chest.json; the next tick loaded them as the chest. Now the chest is
emptied and marked unloaded as soon as a save game is read or a game starts
(RequestLoad), so no save writes it before it is loaded again. The user's
chest was restored from the 2026-10-19 backup (the broken file kept as
backup/shared_chest.broken-2026-10-21.json).
+8
View File
@@ -32,3 +32,11 @@ one of them.
- The potion is used up (flag, RemoveItem, DestroyObject as UseItem does; a - The potion is used up (flag, RemoveItem, DestroyObject as UseItem does; a
stack loses one) only when cCreature::Teleport is called from the portal stack loses one) only when cCreature::Teleport is called from the portal
case while it is pending; closing the window keeps it. case while it is pending; closing the window keeps it.
## Campaign (2026-10-21)
The potion showed Ancaria's portals in the Underworld. cInterpretSQW::Teleporter
(0x4ae330) chooses the busy dialog's mode by the portal touched: index > 0xd
(Underworld) mode 6, else mode 5, both with the hero's known-portal mask
(+0x578). The potion now chooses by the running campaign, g_campaign
(0x182cdc8, u16: 0 Ancaria, 1 Underworld): mode 6 in the Underworld.
+41 -14
View File
@@ -182,17 +182,44 @@ From the characters rework (`docs/characters.md`, request of 2026-10-19):
- **Unlock difficulties by story completion.** Bronze and Silber stay open; - **Unlock difficulties by story completion.** Bronze and Silber stay open;
difficulty d >= 2 opens when the story on d - 1 is completed. The counters difficulty d >= 2 opens when the story on d - 1 is completed. The counters
are one byte per difficulty in cStatsManager +0x5704 (read 0x424be0) and are one byte per difficulty in cStatsManager +0x5704 (read 0x424be0) and
travel in the save header (+0x88..+0x98) and the hero file. Today every travel in the save header (+0x88..+0x98) and the hero file. Done
difficulty up to the highest played one is offered (and Silber). 2026-10-20 (docs/characters.md).
- **Reset a difficulty.** Remove one world from a character (the shared hero - **Autosave interval in the options window.** Done 2026-10-20: Allgemein
stays); a button / dialog entry in the load window. page, "Spielstände" frame (docs/options.md).
- **Remove the export / import UI.** The Esc menu's "Exportieren" and the - Quickload (row 0, `GAME00.PAK`) does not know characters; not needed
character screen's import (cUI_Character mode 5, `docs/char-export.md`): (user, 2026-10-20).
characters replace exported heroes.
- **Silent autosave every 10 minutes** (a setting later) into the ## Two saves in a row (2026-10-20, not reproduced)
autosave file, without the busy dialog, like the direct save
(`SaveOnTick`) but marked as an autosave. Once two real saves ("saved", not "autosaved") went into the same character
- **Quickload (row 0) does not know characters.** Ctrl+Z saves into the 1.8 s apart, about 77 s after loading, with the timed autosave off. Save
current character, but the quickload event (0x116, engine +0x68 = row, flag 0x40 is set in cEngine::ReceiveEvent at 0x618876 (quicksave),
flag 0x200) loads `GAME00.PAK`, which is not listed; make it reload the 0x618f08 and 0x6190c5 (save event 0xf: +0xc row, +0x18 autosave flag). A
current character's world (or its newer autosave). log of the event that sets the flag (dwords 0..9) did not catch it again;
add it back if it shows.
Once (2026-10-20, not reproduced) an autosave seemed to fire right after a
new world started through the hero chain (Character08: hero world loaded,
then the new world). The log of that run had no save at all; the timed
autosave counts from the game start (OnGameStart).
## Savegames screen redesign ("Spielen")
Asked 2026-10-20: redesign the load window (cUI_Savegame, the "Spielen"
screen, characters.cpp): show a render of the selected character, use the
space far better. No 16:9 artwork exists for the game (user, 2026-10-20):
we design our own UIs, using the width beyond the 1024 frame (now drawn in
a centred 1024x768 viewport with black sides, hud.cpp FrameScope). Design
for 16:9 (1920x1080, 1365x768 UI units), not ultrawide (user). Screen done
2026-10-20 (docs/characters.md, "Spielen screen"; docs/screens.md). Hero
in 3D done 2026-10-20. Open: list scrolling beyond 9 characters, larger
heading font.
## Fine-tuning the Spielen and options screens (2026-10-21)
The user is very satisfied with the menu rework for now (rock background on
all menu screens, centred menu frame, logo, Spielen screen with the 3D
hero), but both the Spielen screen and the options window still need a lot
of fine-tuning. Details to be collected when we get to it; known so far:
list scrolling beyond 9 characters, larger heading font, the options
window's layout in the width beyond its 1024 frame.
+4 -2
View File
@@ -67,9 +67,11 @@ Elsewhere (unknown / UI, leave alone for now): `0x40694a`, `0x4ec036`,
| `0x006469d0` | loading screen: GDI BitBlt of bitmap + progress bar + text into the back buffer (fixed 1024x768) | render thread | | `0x006469d0` | loading screen: GDI BitBlt of bitmap + progress bar + text into the back buffer (fixed 1024x768) | render thread |
| `0x006a0c60` | video/intro playback (unexamined) | | | `0x006a0c60` | video/intro playback (unexamined) | |
The widescreen mod gives these frames a 1024x768 D3D viewport (set at The widescreen mod gives the loading screen a 1024x768 D3D viewport (set at
`BeginScene` when the scene depth `+0x1d8` is 0) and the borderless mod `BeginScene` when the scene depth `+0x1d8` is 0) and the borderless mod
presents only that area, scaled to the monitor. presents only that area, scaled to the monitor. The main menu thread renders
the full width since 2026-10-20: its 1024x768 frame is drawn centred on the
menu screens' background (docs/screens.md, "Main menu frame").
## Direct3D 7 render target limit ## Direct3D 7 render target limit
+1
View File
@@ -28,6 +28,7 @@ constexpr uint32_t WorldToScreen = 0x00623c40;
// --- globals ----------------------------------------------------------------- // --- globals -----------------------------------------------------------------
constexpr uint32_t g_dxDriver = 0x00cda99c; // DxDriver7* constexpr uint32_t g_dxDriver = 0x00cda99c; // DxDriver7*
constexpr uint32_t g_appFlags = 0x0182cdb8; // uint32_t, 0x20000 = inactive constexpr uint32_t g_appFlags = 0x0182cdb8; // uint32_t, 0x20000 = inactive
constexpr uint32_t g_campaign = 0x0182cdc8; // uint16_t, the running game's campaign: 0 Ancaria, 1 Underworld
constexpr uint32_t g_mouseCursor = 0x00cdbadc; // mouse cursor object*: x +4, y +8 (render pixels) constexpr uint32_t g_mouseCursor = 0x00cdbadc; // mouse cursor object*: x +4, y +8 (render pixels)
// --- read-only constants ----------------------------------------------------- // --- read-only constants -----------------------------------------------------
+41
View File
@@ -2,13 +2,54 @@
// cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still // cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still
// renders but handles no events). // renders but handles no events).
#include <algorithm>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <vector>
namespace game { namespace game {
constexpr size_t kControlFlagsOffset = 0x10; constexpr size_t kControlFlagsOffset = 0x10;
constexpr uint32_t kControlFlagVisible = 1; constexpr uint32_t kControlFlagVisible = 1;
constexpr size_t kControlName = 0x30; // char[], set by 0x732090 (e.g. "UI_LABEL_NEWGAME")
inline bool ControlNamed(const void* control, const char* name) {
return std::strcmp(static_cast<const char*>(control) + kControlName, name) == 0;
}
// Windows keep their lines / entries in game vectors of control pointers
// {begin, end, capacity end} at `offset` of the window.
inline std::vector<uint8_t*> ControlsIn(void* window, size_t offset) {
auto* field = static_cast<uint8_t*>(window) + offset;
auto** begin = *reinterpret_cast<uint8_t***>(field);
auto** end = *reinterpret_cast<uint8_t***>(field + 4);
return std::vector<uint8_t*>(begin, end);
}
// Takes `control` out of the vector (the order of the others stays).
inline void RemoveControl(void* window, size_t offset, const void* control) {
auto* field = static_cast<uint8_t*>(window) + offset;
auto** begin = *reinterpret_cast<uint8_t***>(field);
auto**& end = *reinterpret_cast<uint8_t***>(field + 4);
auto** found = std::find(begin, end, control);
if (found != end) {
std::memmove(found, found + 1, (end - found - 1) * sizeof(*found));
--end;
}
}
// Replaces the vector's entry `index`.
inline void SetControlAt(void* window, size_t offset, size_t index, uint8_t* control) {
(*reinterpret_cast<uint8_t***>(static_cast<uint8_t*>(window) + offset))[index] = control;
}
// Destroys a control made with the game's allocator (vf00, the scalar deleting
// destructor, e.g. cUI_StaticText64 0x754680); out of every list first.
inline void DeleteControl(void* control) {
using DeleteFn = void*(__fastcall*)(void*, void*, int);
reinterpret_cast<DeleteFn>((*static_cast<void***>(control))[0])(control, nullptr, 1);
}
inline bool ControlShown(const void* control) { inline bool ControlShown(const void* control) {
return (static_cast<const uint8_t*>(control)[kControlFlagsOffset] & kControlFlagVisible) != 0; return (static_cast<const uint8_t*>(control)[kControlFlagsOffset] & kControlFlagVisible) != 0;
+68 -6
View File
@@ -10,6 +10,12 @@ namespace game {
constexpr int kDeviceDrawPrimitive = 25; // IDirect3DDevice7 vtable slots constexpr int kDeviceDrawPrimitive = 25; // IDirect3DDevice7 vtable slots
constexpr int kDeviceSetTexture = 35; constexpr int kDeviceSetTexture = 35;
constexpr int kDeviceSetRenderState = 20;
constexpr int kDeviceGetRenderState = 21;
constexpr DWORD kStateSourceBlend = 19; // D3DRENDERSTATETYPE
constexpr DWORD kStateDestinationBlend = 20;
constexpr DWORD kStateAlphaBlendEnable = 27;
constexpr DWORD kBlendOne = 2; // D3DBLEND
constexpr DWORD kTriangleStrip = 5; constexpr DWORD kTriangleStrip = 5;
constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1 constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1
@@ -19,18 +25,74 @@ struct ScreenVertex {
float u, v; float u, v;
}; };
// Fills (x0, y0)-(x1, y1) with the ARGB colour `color`. // Draws (x0, y0)-(x1, y1) with `surface` (a texture's Direct3D surface,
inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) { // game/ui/texture.h) from (u0, v0) to (u1, v1), tinted by `colour`.
inline void DrawTextured(void* device, void* surface, float x0, float y0, float x1, float y1, float u0, float v0,
float u1, float v1, uint32_t colour = 0xffffffff) {
void** vtable = *static_cast<void***>(device);
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, surface);
ScreenVertex quad[4] = {{x0, y0, 0, 1, colour, 0, u0, v0},
{x1, y0, 0, 1, colour, 0, u1, v0},
{x0, y1, 0, 1, colour, 0, u0, v1},
{x1, y1, 0, 1, colour, 0, u1, v1}};
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture,
quad, 4, 0);
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
}
// DrawTextured added onto what is drawn (source and destination blend ONE):
// for the effect textures, drawn on black without alpha.
inline void DrawTexturedAdded(void* device, void* surface, float x0, float y0, float x1, float y1, float u0,
float v0, float u1, float v1) {
void** vtable = *static_cast<void***>(device);
using SetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD);
using GetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD*);
const auto set = reinterpret_cast<SetRenderStateFn>(vtable[kDeviceSetRenderState]);
const auto get = reinterpret_cast<GetRenderStateFn>(vtable[kDeviceGetRenderState]);
DWORD blending = 0, source = 0, destination = 0;
get(device, kStateAlphaBlendEnable, &blending);
get(device, kStateSourceBlend, &source);
get(device, kStateDestinationBlend, &destination);
set(device, kStateAlphaBlendEnable, TRUE);
set(device, kStateSourceBlend, kBlendOne);
set(device, kStateDestinationBlend, kBlendOne);
DrawTextured(device, surface, x0, y0, x1, y1, u0, v0, u1, v1);
set(device, kStateAlphaBlendEnable, blending);
set(device, kStateSourceBlend, source);
set(device, kStateDestinationBlend, destination);
}
// Fills (x0, y0)-(x1, y1), ARGB colours per corner: top left, top right,
// bottom left, bottom right (blended between them).
inline void FillCorners(void* device, float x0, float y0, float x1, float y1, uint32_t topLeft, uint32_t topRight,
uint32_t bottomLeft, uint32_t bottomRight) {
void** vtable = *static_cast<void***>(device); void** vtable = *static_cast<void***>(device);
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*); using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD); using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr); reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
ScreenVertex quad[4] = {{x0, y0, 0, 1, color, 0, 0, 0}, ScreenVertex quad[4] = {{x0, y0, 0, 1, topLeft, 0, 0, 0},
{x1, y0, 0, 1, color, 0, 1, 0}, {x1, y0, 0, 1, topRight, 0, 1, 0},
{x0, y1, 0, 1, color, 0, 0, 1}, {x0, y1, 0, 1, bottomLeft, 0, 0, 1},
{x1, y1, 0, 1, color, 0, 1, 1}}; {x1, y1, 0, 1, bottomRight, 0, 1, 1}};
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture, reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture,
quad, 4, 0); quad, 4, 0);
} }
// Fills (x0, y0)-(x1, y1) with the ARGB colour `color`.
inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) {
FillCorners(device, x0, y0, x1, y1, color, color, color, color);
}
// Top to bottom from `top` to `bottom`.
inline void FillVertical(void* device, float x0, float y0, float x1, float y1, uint32_t top, uint32_t bottom) {
FillCorners(device, x0, y0, x1, y1, top, top, bottom, bottom);
}
// Left to right from `left` to `right`.
inline void FillHorizontal(void* device, float x0, float y0, float x1, float y1, uint32_t left, uint32_t right) {
FillCorners(device, x0, y0, x1, y1, left, right, left, right);
}
} // namespace game } // namespace game
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// cUI_Frame (vtable 0x8955ac, 0xe0 bytes): the game's ornate frame. Its
// constructor (0x739f90, thiscall (name, type, rect*, 1), ret 0x10) places the
// border pieces once, around `rect`: a frame is not moved afterwards, a new one
// is made instead. Its render (0x73a540) draws while +0xdc is set (1 from the
// constructor), not by the visible flag. Type 1 is the windows' main frame
// (cUI_Options 0x7184e0). Its ornaments reach about 0x20 beyond the rect, and
// it needs a height of about 0x40 for its side pieces to join (measured on the
// options window, 2026-10-20).
#include <cstddef>
#include <cstdint>
#include "game/ui/tabs.h"
#include "game/ui/text.h"
namespace game {
constexpr uint32_t kFrameVtable = 0x008955ac;
constexpr size_t kFrameSize = 0xe0;
constexpr size_t kFrameDrawn = 0xdc;
constexpr uint32_t kFrameMain = 1;
constexpr int kFrameMinHeight = 0x40;
// A new frame around `rect` (relative to the parent it is added to, or the
// screen without one); null if out of memory.
inline uint8_t* NewFrame(const char* name, UiRect rect, uint32_t type = kFrameMain) {
void* memory = GameNew(kFrameSize);
if (!memory) {
return nullptr;
}
using FrameFn = uint8_t*(__fastcall*)(void*, void*, const char*, uint32_t, UiRect*, uint32_t);
return reinterpret_cast<FrameFn>(0x00739f90)(memory, nullptr, name, type, &rect, 1);
}
} // namespace game
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// cUI_NetGranny (vtable 0x894c8c, 0x634 bytes, window name UI_WND_GRANNY): the
// game's 3D view of a hero with its equipment, as the export screen's slots
// (cUI_Character +0x1b4, 0x710f31) and the multiplayer lobby showed them.
//
// The model comes from a hero info (0x22c bytes: class at +4, the equipment's
// looks), which LoadHeroFile (0x61d190, cdecl (path, 0, info)) reads from a
// hero file without touching the player hero (0x70bf2c).
//
// Render (vf14 -> vf44 0x6f17f0 (device, 0, 1), only while visible; the last
// argument adds the name and level below the model, 0x6f1260). With +0x53d
// clear the model stands at the rect's horizontal centre, its feet at
// rect.y + h * k, projected with the creatures' ortho camera (x -512..512
// around the screen centre, widened by the widescreen mod); size factor
// +0x630 (2.5 from the constructor). Two lights are made on the first render
// (0x6f0f10): a dim blue set (the lobby) with +0x53f clear, a white one with
// it set. With +0x53d set (cUI_Character) the model is placed in a
// perspective scene instead.
#include <cstddef>
#include <cstdint>
#include "game/ui/tabs.h"
#include "game/ui/text.h"
namespace game {
constexpr size_t kNetGrannySize = 0x634;
constexpr size_t kNetGrannyInScene = 0x53d; // u8, see above
constexpr size_t kNetGrannyWhiteLight = 0x53f; // u8, before the first render
constexpr size_t kNetGrannyScale = 0x630; // float
constexpr size_t kHeroInfoSize = 0x22c;
// cUI_NetGranny::cUI_NetGranny (0x6f06f0, thiscall (rect by value), ret 0xc).
inline uint8_t* NewNetGranny(UiRect rect) {
void* memory = GameNew(kNetGrannySize);
using ConstructFn = uint8_t*(__fastcall*)(void*, void*, UiRect);
return memory ? reinterpret_cast<ConstructFn>(0x006f06f0)(memory, nullptr, rect) : nullptr;
}
// The model alone, without its name and level (vf44, thiscall (device,
// 0, label), ret 0xc).
inline void RenderNetGranny(uint8_t* granny, void* device) {
using RenderFn = void(__fastcall*)(void*, void*, void*, uint32_t, uint8_t);
reinterpret_cast<RenderFn>((*reinterpret_cast<void***>(granny))[0x44 / 4])(granny, nullptr, device, 0, 0);
}
// Shows the hero of `info` (0x6f4b20, thiscall (info, x, y, z, turn), ret
// 0x14; the four floats place it in the perspective scene only).
inline void SetNetGrannyHero(uint8_t* granny, const void* info) {
using SetFn = void(__fastcall*)(void*, void*, const void*, float, float, float, float);
reinterpret_cast<SetFn>(0x006f4b20)(granny, nullptr, info, 0.0f, 0.0f, 0.0f, 0.0f);
}
// No hero (0x6f4ae0, thiscall ()).
inline void ClearNetGrannyHero(uint8_t* granny) {
reinterpret_cast<void(__fastcall*)(void*, void*)>(0x006f4ae0)(granny, nullptr);
}
// The hero info of a hero file (a path as the game's file functions take it);
// false if it cannot be read. `info` holds kHeroInfoSize bytes.
inline bool ReadHeroInfo(const char* path, void* info) {
using LoadFn = uint8_t(__cdecl*)(const char*, uint32_t, void*);
return reinterpret_cast<LoadFn>(0x0061d190)(path, 0, info) != 0;
}
} // namespace game
+34
View File
@@ -0,0 +1,34 @@
#pragma once
// The game's textures by name (a .TGA in the texture paks), as cGranny's
// effects load them (0x401232): the texture manager (0x656c80, cdecl (),
// created on first use, kept at 0x13e57b4) gives the id of a name (0x657620,
// thiscall (name), ret 4); the texture cache (0x13e57b8) loads it on demand
// (0x65f010, thiscall (id, 0), ret 8), its Direct3D surface at +0x14 (as
// cUI_Button's render binds it, 0x72afb7).
#include <cstddef>
#include <cstdint>
namespace game {
constexpr uintptr_t kTextureCache = 0x013e57b8;
constexpr size_t kTextureSurface = 0x14;
inline uint32_t TextureId(const char* name) {
void* manager = reinterpret_cast<void*(__cdecl*)()>(0x00656c80)();
using IdFn = uint32_t(__fastcall*)(void*, void*, const char*);
return manager ? reinterpret_cast<IdFn>(0x00657620)(manager, nullptr, name) : 0;
}
// The Direct3D surface of texture `id`, loaded if needed; null if none.
inline void* TextureSurface(uint32_t id) {
void* cache = *reinterpret_cast<void**>(kTextureCache);
if (!cache || !id) {
return nullptr;
}
using GetFn = uint8_t*(__fastcall*)(void*, void*, uint32_t, uint32_t);
uint8_t* texture = reinterpret_cast<GetFn>(0x0065f010)(cache, nullptr, id, 0);
return texture ? *reinterpret_cast<void**>(texture + kTextureSurface) : nullptr;
}
} // namespace game
+1
View File
@@ -138,6 +138,7 @@ POINT ToGame(POINT p) {
POINT result; POINT result;
result.x = std::clamp(MulDiv(p.x - layout.client.left, layout.gameWidth, width), 0, layout.gameWidth - 1); result.x = std::clamp(MulDiv(p.x - layout.client.left, layout.gameWidth, width), 0, layout.gameWidth - 1);
result.y = std::clamp(MulDiv(p.y - layout.client.top, layout.gameHeight, height), 0, layout.gameHeight - 1); result.y = std::clamp(MulDiv(p.y - layout.client.top, layout.gameHeight, height), 0, layout.gameHeight - 1);
result.x -= mods::hud::MenuFrameOffsetX(); // the main menu's centred frame
return result; return result;
} }
+1115 -338
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -7,4 +7,9 @@ namespace mods::characters {
// save window (on the next engine tick). // save window (on the next engine tick).
void RequestSave(); void RequestSave();
// Minutes between silent autosaves (0: off); sacredK.ini [characters]
// AutosaveMinutes, set on the options window's Allgemein page.
int AutosaveMinutes();
void SetAutosaveMinutes(int minutes);
} // namespace mods::characters } // namespace mods::characters
-104
View File
@@ -1,104 +0,0 @@
// Character export (Esc menu "Exportieren"): slots can be cleared.
//
// Request (2026-10-18): the export screen was not adapted to widescreen, and
// slots could not be cleared or overwritten. The layout is fixed in hud.cpp
// (FrameScope); overwriting turned out to be the game's own path once clicks
// hit the right slot (measured: action 9 on an occupied slot -> mode 6
// confirmation -> action 6 exports into it).
//
// cUI_Character serves the main menu's character selection and the export
// screen: mode at +0x390 (4 selection, 5 export, 6 overwrite confirmation),
// selected slot (char, -1 none) at +0x1d6, slot controls at +0x1b4 (8, hero
// id at +0x2f8, 0: empty). Its erase button (+0x3b4, "UI_CHAR_ERASE_TOOLTIP")
// is created for both, but SelectSlot (0x70c440) shows it only in modes 4 / 7
// and SetMode (0x70c760) hides it. The click handler (vf10 0x70e860) does not
// check the mode: it opens the game's confirmation (UI_CHAR_ERASE_CONFIRM, a
// cUI_BusyDlg), whose answer deletes the hero (0x7f01a0, slot + 1). So in
// export mode the button is shown whenever an occupied slot is selected.
//
// The export screen reloads its heroes when it enters export mode (SetMode 5,
// also after an export); after a delete nothing did, so the erased hero stayed
// until the next export (user, 2026-10-18). After a delete from the export
// screen it enters export mode again and the selection is cleared.
#include <cstdint>
#include "game/ui/control.h"
#include "re/hook.h"
namespace mods::charexport {
namespace {
constexpr size_t kMode = 0x390;
constexpr size_t kSelectedSlot = 0x1d6;
constexpr size_t kSlots = 0x1b4;
constexpr size_t kSlotHeroId = 0x2f8;
constexpr size_t kEraseButton = 0x3b4;
constexpr int kSlotCount = 8;
constexpr int kModeExport = 5;
constexpr int kNoSlot = -1;
// The cUI_Character instance (one, owned by the UI manager), noted when its
// mode or selection changes.
void* g_window = nullptr;
template <typename T>
T& Field(void* window, size_t offset) {
return *reinterpret_cast<T*>(static_cast<uint8_t*>(window) + offset);
}
bool SelectedSlotOccupied(void* window) {
const int slot = Field<int8_t>(window, kSelectedSlot);
if (slot < 0 || slot >= kSlotCount) {
return false;
}
void* control = Field<void*>(window, kSlots + slot * sizeof(void*));
return control && Field<uint32_t>(control, kSlotHeroId) != 0;
}
void UpdateEraseButton(void* window) {
void* button = Field<void*>(window, kEraseButton);
if (button && Field<int>(window, kMode) == kModeExport) {
game::SetControlShown(button, SelectedSlotOccupied(window));
}
}
} // namespace
} // namespace mods::charexport
namespace game {
// cUI_Character::SetMode (0x70c760, thiscall (mode), ret 4).
void __fastcall UiCharacter_SetMode(void* self, void* edx, int mode);
RE_HOOK(0x0070c760, UiCharacter_SetMode);
void __fastcall UiCharacter_SetMode(void* self, void* edx, int mode) {
mods::charexport::g_window = self;
UiCharacter_SetMode_original(self, edx, mode);
mods::charexport::UpdateEraseButton(self);
}
// cUI_Character::SelectSlot (0x70c440, thiscall (char slot, -1 none), ret 4).
void __fastcall UiCharacter_SelectSlot(void* self, void* edx, int slot);
RE_HOOK(0x0070c440, UiCharacter_SelectSlot);
void __fastcall UiCharacter_SelectSlot(void* self, void* edx, int slot) {
mods::charexport::g_window = self;
UiCharacter_SelectSlot_original(self, edx, slot);
mods::charexport::UpdateEraseButton(self);
}
// HeroSlots_DeleteHero (0x7f01a0, thiscall on 0x7d7c40() (slot + 1), ret 4;
// returns a bool in al), called by the erase confirmation's answer.
int __fastcall HeroSlots_DeleteHero(void* self, void* edx, int slotPlusOne);
RE_HOOK(0x007f01a0, HeroSlots_DeleteHero);
int __fastcall HeroSlots_DeleteHero(void* self, void* edx, int slotPlusOne) {
const int result = HeroSlots_DeleteHero_original(self, edx, slotPlusOne);
void* window = mods::charexport::g_window;
using mods::charexport::Field;
if (window && Field<int>(window, mods::charexport::kMode) == mods::charexport::kModeExport) {
UiCharacter_SetMode(window, nullptr, mods::charexport::kModeExport);
UiCharacter_SelectSlot(window, nullptr, mods::charexport::kNoSlot);
}
return result;
}
} // namespace game
+45 -6
View File
@@ -1,5 +1,6 @@
#include "mods/charfile.h" #include "mods/charfile.h"
#include <algorithm>
#include <cstring> #include <cstring>
#include <fstream> #include <fstream>
@@ -25,8 +26,11 @@ constexpr size_t kHeroLevel = 0x0;
constexpr size_t kHeroClass = 0x4; constexpr size_t kHeroClass = 0x4;
constexpr size_t kHeroName = 0x8; constexpr size_t kHeroName = 0x8;
constexpr size_t kHeroNameChars = 0x40; constexpr size_t kHeroNameChars = 0x40;
constexpr size_t kHeroStoryCompleted = 0xed; // one byte per difficulty (written at 0x604442..)
constexpr size_t kWorldMode = 0x24; constexpr size_t kWorldCampaign = 0x24;
constexpr uint32_t kSaveModeAncaria = 2;
constexpr uint32_t kSaveModeUnderworld = 3;
constexpr size_t kWorldMultiplayer = 0x2c; constexpr size_t kWorldMultiplayer = 0x2c;
constexpr size_t kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords) constexpr size_t kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords)
constexpr size_t kWorldDifficulty = 0x84; constexpr size_t kWorldDifficulty = 0x84;
@@ -86,10 +90,13 @@ std::optional<SaveInfo> ReadSaveInfo(const Bytes& file) {
std::memcpy(&info.heroClass, header + kHeroClass, 4); std::memcpy(&info.heroClass, header + kHeroClass, 4);
const auto* name = reinterpret_cast<const wchar_t*>(header + kHeroName); const auto* name = reinterpret_cast<const wchar_t*>(header + kHeroName);
info.name.assign(name, wcsnlen(name, kHeroNameChars)); info.name.assign(name, wcsnlen(name, kHeroNameChars));
if (hero->size >= kHeroStoryCompleted + info.storyCompleted.size()) {
std::memcpy(info.storyCompleted.data(), header + kHeroStoryCompleted, info.storyCompleted.size());
}
info.world = file[2] == 'S'; info.world = file[2] == 'S';
if (info.world) { if (info.world) {
info.difficulty = static_cast<int>(U32(file, kWorldDifficulty)); info.difficulty = static_cast<int>(U32(file, kWorldDifficulty));
info.mode = U32(file, kWorldMode); info.campaign = U32(file, kWorldCampaign) == kSaveModeUnderworld ? kCampaignUnderworld : kCampaignAncaria;
info.multiplayer = U32(file, kWorldMultiplayer) != 0; info.multiplayer = U32(file, kWorldMultiplayer) != 0;
SYSTEMTIME time{}; SYSTEMTIME time{};
WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek, WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek,
@@ -116,9 +123,24 @@ bool SetHeroName(Bytes& file, const std::wstring& name) {
return true; return true;
} }
Bytes BuildPreview(const Bytes& world, const std::string& description) { bool SetStoryCompleted(Bytes& file, int difficulty, uint8_t count) {
const std::optional<Chunk> hero = FindChunk(file, kChunkHero);
if (!hero || difficulty < 0 || difficulty >= kDifficultyCount ||
hero->size < kHeroStoryCompleted + kDifficultyCount) {
return false;
}
file[hero->offset + kHeroStoryCompleted + difficulty] = count;
return true;
}
uint32_t CampaignSaveMode(int campaign) {
return campaign == kCampaignUnderworld ? kSaveModeUnderworld : kSaveModeAncaria;
}
Bytes BuildPreview(const Bytes& world, const std::string& description, int campaign) {
Bytes preview(world.begin(), world.begin() + std::min(world.size(), kChunkData)); Bytes preview(world.begin(), world.begin() + std::min(world.size(), kChunkData));
preview.resize(kChunkData); preview.resize(kChunkData);
PutU32(preview, kWorldCampaign, CampaignSaveMode(campaign));
std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize); std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize);
size_t entry = kChunkTable; size_t entry = kChunkTable;
auto append = [&](uint32_t type, const uint8_t* data, uint32_t size) { auto append = [&](uint32_t type, const uint8_t* data, uint32_t size) {
@@ -161,10 +183,27 @@ bool WriteFileReplacing(const std::string& path, const Bytes& bytes) {
return MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; return MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
} }
std::string WorldSection(int difficulty) { std::string WorldSection(int campaign, int difficulty) {
return "world" + std::to_string(difficulty); return (campaign == kCampaignUnderworld ? "underworld" : "ancaria") + std::to_string(difficulty);
} }
namespace {
// Format of 2026-10-20 (before campaigns): "world<d>" were Ancaria's worlds;
// renamed when read, written under the new name at the next save.
constexpr char kOldWorldPrefix[] = "world";
std::string CurrentSectionName(const std::string& name) {
constexpr size_t length = sizeof(kOldWorldPrefix) - 1;
if (name.size() > length && name.compare(0, length, kOldWorldPrefix) == 0 &&
std::all_of(name.begin() + length, name.end(), [](char c) { return c >= '0' && c <= '9'; })) {
return WorldSection(kCampaignAncaria, std::stoi(name.substr(length)));
}
return name;
}
} // namespace
bool CharacterFile::Open() { bool CharacterFile::Open() {
sections_.clear(); sections_.clear();
std::ifstream file(path_, std::ios::binary); std::ifstream file(path_, std::ios::binary);
@@ -181,7 +220,7 @@ bool CharacterFile::Open() {
} }
Section section; Section section;
const auto* name = reinterpret_cast<const char*>(entry); const auto* name = reinterpret_cast<const char*>(entry);
section.name.assign(name, strnlen(name, kSectionNameSize)); section.name = CurrentSectionName(std::string(name, strnlen(name, kSectionNameSize)));
std::memcpy(&section.offset, entry + kSectionNameSize, 4); std::memcpy(&section.offset, entry + kSectionNameSize, 4);
std::memcpy(&section.size, entry + kSectionNameSize + 4, 4); std::memcpy(&section.size, entry + kSectionNameSize + 4, 4);
sections_.push_back(std::move(section)); sections_.push_back(std::move(section));
+25 -4
View File
@@ -2,6 +2,7 @@
// Character files (docs/characters.md): one file per character with its shared // Character files (docs/characters.md): one file per character with its shared
// hero and one world per difficulty, plus reading the game's save files. // hero and one world per difficulty, plus reading the game's save files.
#include <array>
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
#include <string> #include <string>
@@ -16,6 +17,11 @@ using Bytes = std::vector<uint8_t>;
// Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4). // Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4).
constexpr int kDifficultyCount = 5; constexpr int kDifficultyCount = 5;
// The campaigns (g_campaign 0x182cdc8): Ancaria 0, Underworld 1.
constexpr int kCampaignAncaria = 0;
constexpr int kCampaignUnderworld = 1;
constexpr int kCampaignCount = 2;
// What the game's save files (GAME.PAK "AMS", hero files .pax "AMH") say about // What the game's save files (GAME.PAK "AMS", hero files .pax "AMH") say about
// their hero: header 0x100 bytes, then a table of 64 chunks {type, offset, // their hero: header 0x100 bytes, then a table of 64 chunks {type, offset,
// size}; chunk 0xc3 is the hero header. // size}; chunk 0xc3 is the hero header.
@@ -23,9 +29,12 @@ struct SaveInfo {
std::wstring name; // hero header +8, UTF-16 std::wstring name; // hero header +8, UTF-16
uint32_t heroClass = 0; // hero header +4: 1 Seraphim .. 9 Daemonin uint32_t heroClass = 0; // hero header +4: 1 Seraphim .. 9 Daemonin
uint32_t level = 0; // hero header +0 uint32_t level = 0; // hero header +0
// Story completions per difficulty (hero header +0xed.., cStatsManager
// +0x5704; the hero file's are the hero's, loadHero sets them).
std::array<uint8_t, kDifficultyCount> storyCompleted{};
bool world = false; // a save game (AMS), not a hero file bool world = false; // a save game (AMS), not a hero file
int difficulty = 0; // AMS header +0x84 (g_gameLevel) int difficulty = 0; // AMS header +0x84 (g_gameLevel)
uint32_t mode = 0; // AMS header +0x24: 2 normal, 3 hardcore int campaign = kCampaignAncaria; // AMS header +0x24 (CampaignSaveMode)
bool multiplayer = false; // AMS header +0x2c bool multiplayer = false; // AMS header +0x2c
uint64_t savedAt = 0; // AMS header +0x5c SYSTEMTIME, as FILETIME uint64_t savedAt = 0; // AMS header +0x5c SYSTEMTIME, as FILETIME
}; };
@@ -37,10 +46,20 @@ std::optional<SaveInfo> ReadSaveInfo(const Bytes& file);
constexpr size_t kHeroNameMaxChars = 63; constexpr size_t kHeroNameMaxChars = 63;
bool SetHeroName(Bytes& file, const std::wstring& name); bool SetHeroName(Bytes& file, const std::wstring& name);
// Sets the hero header's story completions of `difficulty` (+0xed..; in a hero
// file they are the hero's, loadHero puts them into cStatsManager); false if
// the file has no hero header.
bool SetStoryCompleted(Bytes& file, int difficulty, uint8_t count);
// The save header's campaign field (+0x24): 2 Ancaria, 3 Underworld (written
// at 0x61a5c5, read back by cEngine::Load 0x61bafd).
uint32_t CampaignSaveMode(int campaign);
// A small save game the game's load window can list: `world`'s header with its // A small save game the game's load window can list: `world`'s header with its
// date, its thumbnail and world time, and `description` (ANSI, up to 63 // date, its thumbnail and world time, and `description` (ANSI, up to 63
// characters) as the save's name. // characters) as the save's name. The load window lists only saves of the
Bytes BuildPreview(const Bytes& world, const std::string& description); // running campaign (0x71e2b9): the preview names `campaign`.
Bytes BuildPreview(const Bytes& world, const std::string& description, int campaign);
// Reading and writing whole files (paths in the game's code page). // Reading and writing whole files (paths in the game's code page).
std::optional<Bytes> ReadFile(const std::string& path); std::optional<Bytes> ReadFile(const std::string& path);
@@ -77,6 +96,8 @@ private:
std::vector<Section> sections_; std::vector<Section> sections_;
}; };
std::string WorldSection(int difficulty); // A world's section: "ancaria<d>" / "underworld<d>", d = g_gameLevel. Sections
// are named, not slotted: more difficulties (or campaigns) are more sections.
std::string WorldSection(int campaign, int difficulty);
} // namespace mods::characters } // namespace mods::characters
+159 -17
View File
@@ -21,6 +21,7 @@
// Enabled=1 // Enabled=1
#include <algorithm> #include <algorithm>
#include <atomic>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <vector> #include <vector>
@@ -30,6 +31,8 @@
#include "game/addresses.h" #include "game/addresses.h"
#include "game/render/dx_driver7.h" #include "game/render/dx_driver7.h"
#include "game/ui/events.h" #include "game/ui/events.h"
#include "mods/hud.h"
#include "mods/screen.h"
#include "mods/widescreen.h" #include "mods/widescreen.h"
#include "re/config.h" #include "re/config.h"
#include "re/hook.h" #include "re/hook.h"
@@ -47,13 +50,24 @@ thread_local bool g_eventShifted = false;
// Screen: laid out across the whole render width by its mod (drawn unshifted). // Screen: laid out across the whole render width by its mod (drawn unshifted).
enum class Anchor { Center, Right, Screen }; // Canvas: a 16:9 screen (kCanvasWidth), centred.
enum class Anchor { Center, Right, Screen, Canvas };
// Offset of the main menu's 1024 frame while it is drawn centred (set each
// menu frame by RenderMenuFrame; 0 while a 16:9 screen draws itself).
std::atomic<int> g_menuFrameDx = 0;
int OffsetFor(Anchor anchor) { int OffsetFor(Anchor anchor) {
static const bool enabled = re::ConfigInt("hud", "Enabled", 1) != 0; static const bool enabled = re::ConfigInt("hud", "Enabled", 1) != 0;
const int width = mods::widescreen::RenderWidth(); const int width = mods::widescreen::RenderWidth();
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() || if (anchor == Anchor::Canvas) {
mods::widescreen::MenuIsShowing() || anchor == Anchor::Screen) { return width > kCanvasWidth ? (width - kCanvasWidth) / 2 : 0;
}
// The main menu's frame shifts the windows in it; over a 16:9 screen
// (no frame) dialogs are centred like in a game (user, 2026-10-21).
const bool inMenuFrame = mods::widescreen::MenuIsShowing() && g_menuFrameDx != 0;
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() || inMenuFrame ||
anchor == Anchor::Screen) {
return 0; return 0;
} }
return anchor == Anchor::Center ? (width - kLayoutWidth) / 2 : width - kLayoutWidth; return anchor == Anchor::Center ? (width - kLayoutWidth) / 2 : width - kLayoutWidth;
@@ -263,6 +277,29 @@ private:
CursorOffset cursor_; CursorOffset cursor_;
}; };
// RAII: shift drawing only (the cursor is not seen shifted), for things
// already in the shifted layout's coordinates (the main menu's cursor).
class DrawShift {
public:
explicit DrawShift(int dx) : active_(!g_drawScoped && dx != 0) {
if (active_) {
g_drawScoped = true;
g_offsetX = dx;
}
}
~DrawShift() {
if (active_) {
g_drawScoped = false;
g_offsetX = 0;
}
}
DrawShift(const DrawShift&) = delete;
DrawShift& operator=(const DrawShift&) = delete;
private:
bool active_;
};
// Stores the offsets of a mouse event's x fields (game/ui/events.h); returns // Stores the offsets of a mouse event's x fields (game/ui/events.h); returns
// their count (0: no mouse event). // their count (0: no mouse event).
int MouseEventXOffsets(void* event, int (&offsets)[2]) { int MouseEventXOffsets(void* event, int (&offsets)[2]) {
@@ -341,6 +378,9 @@ size_t VertexSize(DWORD fvf) {
return size; return size;
} }
// A 16:9 screen draws (RenderCanvas): no 1024 layout to stretch.
thread_local bool g_canvasScope = false;
// Returns vertices shifted by g_offsetX, or the input when nothing to do. // Returns vertices shifted by g_offsetX, or the input when nothing to do.
// Primitives covering the whole 1024x768 layout (e.g. the dimming behind // Primitives covering the whole 1024x768 layout (e.g. the dimming behind
// modal dialogs) are stretched across the render width instead. // modal dialogs) are stretched across the render width instead.
@@ -359,7 +399,7 @@ void* ShiftVertices(DWORD fvf, void* vertices, DWORD count) {
minY = std::min(minY, position[1]); minY = std::min(minY, position[1]);
maxY = std::max(maxY, position[1]); maxY = std::max(maxY, position[1]);
} }
const bool fullLayout = minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f; const bool fullLayout = !g_canvasScope && minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f;
const float scale = static_cast<float>(mods::widescreen::RenderWidth()) / kLayoutWidth; const float scale = static_cast<float>(mods::widescreen::RenderWidth()) / kLayoutWidth;
for (DWORD i = 0; i < count; ++i) { for (DWORD i = 0; i < count; ++i) {
auto* x = reinterpret_cast<float*>(buffer.data() + stride * i); auto* x = reinterpret_cast<float*>(buffer.data() + stride * i);
@@ -375,13 +415,59 @@ using DrawIndexedPrimitiveFn = HRESULT(__stdcall*)(void* device, DWORD type, DWO
DrawPrimitiveFn g_drawPrimitive = nullptr; DrawPrimitiveFn g_drawPrimitive = nullptr;
DrawIndexedPrimitiveFn g_drawIndexedPrimitive = nullptr; DrawIndexedPrimitiveFn g_drawIndexedPrimitive = nullptr;
// Textures not drawn (SetHiddenTextures): the surface bound to stage 0 is
// tracked per thread (each thread draws with its own device calls). Only
// inside a frame drawn over the menu background (g_hidingPictures: the main
// menu's, the options' in a game): the list keeps surfaces the cache may
// have freed since, and a game texture made at the same address was skipped
// in the world view (user, 2026-10-21: "rendering glitches ingame").
constexpr int kHiddenTextureMax = 64;
std::atomic<void*> g_hiddenTextures[kHiddenTextureMax] = {};
thread_local void* g_boundTexture = nullptr;
thread_local int g_hidingPictures = 0;
struct HidingPictures {
HidingPictures() { ++g_hidingPictures; }
~HidingPictures() { --g_hidingPictures; }
HidingPictures(const HidingPictures&) = delete;
HidingPictures& operator=(const HidingPictures&) = delete;
};
using SetTextureFn = HRESULT(__stdcall*)(void* device, DWORD stage, void* surface);
SetTextureFn g_setTexture = nullptr;
HRESULT __stdcall SetTextureTracked(void* device, DWORD stage, void* surface) {
if (stage == 0) {
g_boundTexture = surface;
}
return g_setTexture(device, stage, surface);
}
bool BoundTextureHidden() {
if (!g_boundTexture || g_hidingPictures == 0) {
return false;
}
for (const auto& hidden : g_hiddenTextures) {
if (hidden.load(std::memory_order_relaxed) == g_boundTexture) {
return true;
}
}
return false;
}
HRESULT __stdcall DrawPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count, HRESULT __stdcall DrawPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count,
DWORD flags) { DWORD flags) {
if (BoundTextureHidden()) {
return S_OK;
}
return g_drawPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, flags); return g_drawPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, flags);
} }
HRESULT __stdcall DrawIndexedPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count, HRESULT __stdcall DrawIndexedPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count,
WORD* indices, DWORD indexCount, DWORD flags) { WORD* indices, DWORD indexCount, DWORD flags) {
if (BoundTextureHidden()) {
return S_OK;
}
return g_drawIndexedPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, indices, return g_drawIndexedPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, indices,
indexCount, flags); indexCount, flags);
} }
@@ -432,36 +518,33 @@ HRESULT __stdcall ClearShifted(void* device, DWORD count, Rect7* rects, DWORD fl
using GetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport); using GetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport);
constexpr int kVtableGetViewport = 15; // IDirect3DDevice7 (13 SetViewport, 14 MultiplyTransform) constexpr int kVtableGetViewport = 15; // IDirect3DDevice7 (13 SetViewport, 14 MultiplyTransform)
constexpr DWORD kClearTarget = 1; // D3DCLEAR_TARGET
// RAII for full-screen windows (character export, save games) that fill the // RAII for full-screen windows (options in a game) that fill the 1024 frame
// 1024 frame with a picture: the render target left and right of the frame is // with a picture: the menu screens' background (mods/screen.h) goes under
// cleared black, and the window draws with a viewport on the frame. The // it, and the window draws with a viewport on the frame. The
// export screen's 3D scene (heroes, background) uses a projection for a // export screen's 3D scene (heroes, background) uses a projection for a
// 1024x768 viewport; across the widened one it was stretched 1.8x wide, away // 1024x768 viewport; across the widened one it was stretched 1.8x wide, away
// from the slot labels and hit areas (measured on a screenshot, 2026-10-18). // from the slot labels and hit areas (measured on a screenshot, 2026-10-18).
// Clear clips to the viewport, so the sides are cleared before it is set.
class FrameScope { class FrameScope {
public: public:
FrameScope(void* window, void* device, int dx) { FrameScope(void* window, void* device, int dx) {
const auto* w = static_cast<const uint8_t*>(window); const auto* w = static_cast<const uint8_t*>(window);
if (dx == 0 || g_drawScoped || !device || !g_setViewport || !g_clear || (w[kControlFlags] & 1) == 0) { if (dx == 0 || g_drawScoped || !device || !g_setViewport || (w[kControlFlags] & 1) == 0) {
return; return;
} }
auto getViewport = reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]); auto getViewport = reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]);
if (FAILED(getViewport(device, &previous_))) { if (FAILED(getViewport(device, &previous_))) {
return; return;
} }
const LONG height = static_cast<LONG>(previous_.height); mods::screen::DrawBackground(device);
Rect7 sides[] = {{0, 0, dx, height},
{dx + kLayoutWidth, 0, mods::widescreen::RenderWidth(), height}};
g_clear(device, 2, sides, kClearTarget, 0, 1.0f, 0);
Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous_.minZ, previous_.maxZ}; Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous_.minZ, previous_.maxZ};
device_ = device; device_ = device;
g_setViewport(device, &frame); g_setViewport(device, &frame);
++g_hidingPictures;
} }
~FrameScope() { ~FrameScope() {
if (device_) { if (device_) {
--g_hidingPictures;
g_setViewport(device_, &previous_); g_setViewport(device_, &previous_);
} }
} }
@@ -504,6 +587,10 @@ void OnDevice(void* device) {
g_drawIndexedPrimitive = reinterpret_cast<DrawIndexedPrimitiveFn>(vtable[26]); g_drawIndexedPrimitive = reinterpret_cast<DrawIndexedPrimitiveFn>(vtable[26]);
re::AttachRuntimeHook("D3D7 DrawIndexedPrimitive", reinterpret_cast<void**>(&g_drawIndexedPrimitive), re::AttachRuntimeHook("D3D7 DrawIndexedPrimitive", reinterpret_cast<void**>(&g_drawIndexedPrimitive),
reinterpret_cast<void*>(&DrawIndexedPrimitiveShifted)); reinterpret_cast<void*>(&DrawIndexedPrimitiveShifted));
// SetTexture 35.
g_setTexture = reinterpret_cast<SetTextureFn>(vtable[35]);
re::AttachRuntimeHook("D3D7 SetTexture", reinterpret_cast<void**>(&g_setTexture),
reinterpret_cast<void*>(&SetTextureTracked));
// Clear 10, SetViewport 13. // Clear 10, SetViewport 13.
g_clear = reinterpret_cast<ClearFn>(vtable[10]); g_clear = reinterpret_cast<ClearFn>(vtable[10]);
re::AttachRuntimeHook("D3D7 Clear", reinterpret_cast<void**>(&g_clear), reinterpret_cast<void*>(&ClearShifted)); re::AttachRuntimeHook("D3D7 Clear", reinterpret_cast<void**>(&g_clear), reinterpret_cast<void*>(&ClearShifted));
@@ -788,6 +875,56 @@ int HandleCentred(void* event, int (*body)(void* context), void* context) {
return body(context); return body(context);
} }
void SetHiddenTextures(void* const* surfaces, int count) {
for (int i = 0; i < kHiddenTextureMax; ++i) {
g_hiddenTextures[i].store(i < count ? surfaces[i] : nullptr, std::memory_order_relaxed);
}
}
int MenuFrameOffsetX() {
return mods::widescreen::MenuIsShowing() ? g_menuFrameDx.load() : 0;
}
int RenderMenuFrame(void* device, bool framed, int (*body)(void* context), void* context) {
const int width = mods::widescreen::RenderWidth();
const int dx = framed && width > kLayoutWidth ? (width - kLayoutWidth) / 2 : 0;
g_menuFrameDx = dx;
HidingPictures hiding;
Viewport7 previous;
auto getViewport =
device ? reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]) : nullptr;
if (dx == 0 || g_drawScoped || !g_setViewport || !getViewport || FAILED(getViewport(device, &previous))) {
return body(context);
}
Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous.minZ, previous.maxZ};
g_setViewport(device, &frame);
g_drawScoped = true; // the windows inside draw in the frame's coordinates
g_offsetX = dx;
const int result = body(context);
g_offsetX = 0;
g_drawScoped = false;
g_setViewport(device, &previous);
return result;
}
int CanvasOffsetX() {
return OffsetFor(Anchor::Canvas);
}
int RenderCanvas(void* window, int (*body)(void* context), void* context) {
DrawOffset offset(window, OffsetFor(Anchor::Canvas));
const bool outer = !g_canvasScope;
g_canvasScope = true;
const int result = body(context);
g_canvasScope = !outer;
return result;
}
int HandleCanvas(void* event, int (*body)(void* context), void* context) {
EventOffset offset(event, OffsetFor(Anchor::Canvas));
return body(context);
}
} // namespace mods::hud } // namespace mods::hud
namespace game { namespace game {
@@ -804,12 +941,17 @@ int __fastcall UiTaskbar_HitTest(void* self, void* edx, int x, int y) {
return UiTaskbar_HitTest_original(self, edx, mods::hud::HitTestX(self, x), y); return UiTaskbar_HitTest_original(self, edx, mods::hud::HitTestX(self, x), y);
} }
// The item carried by the mouse cursor (0x6555e0, drawn by the render loop // The cursor and the item it carries (0x6555e0, drawn by the render loop
// after the UI) is placed like UI items: relative to x = 512 of the layout. // after the UI) are placed like UI items: relative to x = 512 of the layout.
// Items are only picked up from centred windows. // Items are only picked up from centred windows. In the main menu the cursor
// is in the centred frame's coordinates (MenuFrameOffsetX).
int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b); int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b);
RE_HOOK(0x006555e0, MouseCursor_DrawItem); RE_HOOK(0x006555e0, MouseCursor_DrawItem);
int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b) { int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b) {
if (mods::widescreen::MenuIsShowing()) {
mods::hud::DrawShift shift(mods::hud::MenuFrameOffsetX());
return MouseCursor_DrawItem_original(self, edx, a, b);
}
mods::hud::DrawOffset offset(nullptr, mods::hud::OffsetFor(Anchor::Center)); mods::hud::DrawOffset offset(nullptr, mods::hud::OffsetFor(Anchor::Center));
return MouseCursor_DrawItem_original(mods::hud::CursorFor(self), edx, a, b); return MouseCursor_DrawItem_original(mods::hud::CursorFor(self), edx, a, b);
} }
+25 -2
View File
@@ -26,8 +26,31 @@ int VisibleDialogTooltips(void** out, int max);
// shifted) or event handler (mouse position shifted by the window's offset). // shifted) or event handler (mouse position shifted by the window's offset).
int RenderCentred(void* window, int (*body)(void* context), void* context); int RenderCentred(void* window, int (*body)(void* context), void* context);
int HandleCentred(void* event, int (*body)(void* context), void* context); int HandleCentred(void* event, int (*body)(void* context), void* context);
// RenderCentred for full-screen windows: black beside the 1024 frame, drawn // RenderCentred for full-screen windows: the menu screens' background beside
// with a viewport on it (as the save game and export screens). // the 1024 frame, drawn with a viewport on it (as the options in a game).
int RenderFramed(void* window, void* device, int (*body)(void* context), void* context); int RenderFramed(void* window, void* device, int (*body)(void* context), void* context);
// 16:9 screens (mods/screen.h): laid out on a 1365x768 canvas, centred in
// the render width. RenderCanvas / HandleCanvas run body(context) with drawing,
// cursor and mouse events in canvas coordinates; CanvasOffsetX is the canvas'
// left edge in the render target.
constexpr int kCanvasWidth = 1365;
constexpr int kCanvasHeight = 768;
int CanvasOffsetX();
int RenderCanvas(void* window, int (*body)(void* context), void* context);
int HandleCanvas(void* event, int (*body)(void* context), void* context);
// The main menu (mods/screen.cpp): with `framed`, body(context) draws the
// menu's 1024x768 frame centred in the render width (shifted, with a
// viewport on it); mouse positions are then given in frame coordinates
// (MenuFrameOffsetX, applied by the borderless mod). Without, the menu draws
// unshifted (a 16:9 screen is shown).
int RenderMenuFrame(void* device, bool framed, int (*body)(void* context), void* context);
int MenuFrameOffsetX();
// Quads drawn with one of these textures (Direct3D surfaces) are left out
// inside the frames drawn over the menu background (RenderMenuFrame, the
// in-game options), until the list changes (at most 64).
void SetHiddenTextures(void* const* surfaces, int count);
} // namespace mods::hud } // namespace mods::hud
+93
View File
@@ -0,0 +1,93 @@
// Main menu "Spielen" (request of 2026-10-20, docs/characters.md): the entries
// "Kampagne Ancaria", "Kampagne Underworld" and "Spielstände" become one
// "Spielen", which opens the load window: there the characters of both
// campaigns are loaded or started on any campaign and difficulty, and new
// characters are made (characters.cpp).
//
// cUI_MainMenu builds its entries in 0x712f30 (thiscall, plain ret): one
// cUI_StaticText64FX (constructor 0x753d70) per entry with its name at +0x30
// (UI_LABEL_NEWGAME, ...), its rect at +0x24 (relative, 0x20 apart), its text
// id at +0x78 and its command at +0x88 (UI_LABEL_LOADGAME: show
// UI_WND_SAVEGAME). The main page's entries are in the vector at +0x158, all
// children in the one at +0x90. The event handler (vf10 0x711fa0) sets
// g_campaign 0x182cdc8 by entry name before an entry handles an event:
// NEWGAME 0, NEWADDON 1. After the build the campaign entries are removed and
// "Spielstände" moves to the top as "Spielen"; the others move up.
#include <algorithm>
#include <cstdint>
#include <vector>
#include "game/ui/control.h"
#include "game/ui/tabs.h"
#include "game/ui/text.h"
#include "game/ui/texts.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::mainmenu {
namespace {
constexpr size_t kMenuChildren = 0x90; // vector of all child controls
constexpr size_t kMenuMainPage = 0x158; // vector of the main page's entries
constexpr size_t kEntryTextId = 0x78;
constexpr int kEntryPitch = 0x20; // as 0x712f30 places them
constexpr const char* kRemoved[] = {"UI_LABEL_NEWGAME", "UI_LABEL_NEWADDON"};
constexpr char kPlayEntry[] = "UI_LABEL_LOADGAME";
int32_t& EntryY(uint8_t* entry) {
return reinterpret_cast<game::UiRect*>(entry + game::kControlRect)->y;
}
bool IsRemoved(const uint8_t* entry) {
return std::any_of(std::begin(kRemoved), std::end(kRemoved),
[&](const char* name) { return game::ControlNamed(entry, name); });
}
void __fastcall MainMenu_Build(uint8_t* self, void* edx);
RE_HOOK(0x00712f30, MainMenu_Build);
void __fastcall MainMenu_Build(uint8_t* self, void* edx) {
MainMenu_Build_original(self, edx);
const std::vector<uint8_t*> original = game::ControlsIn(self, kMenuMainPage);
auto play = std::find_if(original.begin(), original.end(),
[](const uint8_t* entry) { return game::ControlNamed(entry, kPlayEntry); });
if (original.empty() || play == original.end()) {
re::Log("mainmenu: no %s entry, menu left as it is", kPlayEntry);
return;
}
// New order: "Spielen", then the rest as they were; each keeps the extra
// space it had above it (the gap before "Beenden").
std::vector<int32_t> extraGap(original.size(), 0);
for (size_t i = 1; i < original.size(); ++i) {
extraGap[i] = std::max(0, EntryY(original[i]) - EntryY(original[i - 1]) - kEntryPitch);
}
int32_t y = EntryY(original.front());
std::vector<uint8_t*> kept = {*play};
std::vector<int32_t> keptGap = {0};
for (size_t i = 0; i < original.size(); ++i) {
if (original[i] != *play && !IsRemoved(original[i])) {
kept.push_back(original[i]);
keptGap.push_back(extraGap[i]);
}
}
for (uint8_t* entry : original) {
if (IsRemoved(entry)) {
game::RemoveControl(self, kMenuMainPage, entry);
game::RemoveControl(self, kMenuChildren, entry);
game::DeleteControl(entry);
}
}
for (size_t i = 0; i < kept.size(); ++i) {
game::SetControlAt(self, kMenuMainPage, i, kept[i]);
y += keptGap[i];
EntryY(kept[i]) = y;
y += kEntryPitch;
}
static const uint32_t kPlayText = game::RegisterText(L"Spielen");
*reinterpret_cast<uint32_t*>(*play + kEntryTextId) = kPlayText;
re::Log("mainmenu: %zu entries, \"Spielen\" first", kept.size());
}
} // namespace
} // namespace mods::mainmenu
+143 -14
View File
@@ -1,5 +1,6 @@
// Options window in three tabs: Allgemein, Beute, Tastenbelegung (request of // Options window in three tabs: Allgemein, Beute, Tastenbelegung (request of
// 2026-10-18). Beute holds the game's auto-pickup settings (first iteration: // 2026-10-18). Allgemein also gets the autosave interval (characters.cpp);
// Beute holds the game's auto-pickup settings (first iteration:
// moved there from the common page); Tastenbelegung sets the key bindings of // moved there from the common page); Tastenbelegung sets the key bindings of
// mods/input.cpp, which had only been settable in sacredK.ini. // mods/input.cpp, which had only been settable in sacredK.ini.
// //
@@ -32,9 +33,11 @@
#include "game/ui/control.h" #include "game/ui/control.h"
#include "game/ui/draw.h" #include "game/ui/draw.h"
#include "game/ui/events.h" #include "game/ui/events.h"
#include "game/ui/frame.h"
#include "game/ui/tabs.h" #include "game/ui/tabs.h"
#include "game/ui/text.h" #include "game/ui/text.h"
#include "game/ui/texts.h" #include "game/ui/texts.h"
#include "mods/characters.h"
#include "mods/hud.h" #include "mods/hud.h"
#include "mods/input.h" #include "mods/input.h"
#include "mods/lootfilter.h" #include "mods/lootfilter.h"
@@ -55,21 +58,15 @@ constexpr size_t kOkButton = 0x15c;
constexpr size_t kPickupRadios[] = {0x16c, 0x170, 0x174}; constexpr size_t kPickupRadios[] = {0x16c, 0x170, 0x174};
constexpr const char* kPickupControlNames[] = {"UI_LABEL_COLLECT", "UI_FRAME_SEP_AUTOCOLLECT"}; constexpr const char* kPickupControlNames[] = {"UI_LABEL_COLLECT", "UI_FRAME_SEP_AUTOCOLLECT"};
constexpr size_t kControlName = 0x30; // char[32], set by 0x732090 constexpr size_t kControlName = 0x30; // char[32], set by 0x732090
// cUI_Frame (vtable 0x8955ac): its render (0x73a540) doesn't test the visible // cUI_Frame (game/ui/frame.h) draws by +0xdc, not by the visible flag.
// flag but +0xdc (set to 1 by its constructors, read nowhere else).
constexpr uint32_t kFrameVtable = 0x008955ac;
constexpr size_t kFrameDrawn = 0xdc;
// The key bindings page gets a translucent backdrop and a frame per column, // The key bindings page gets a translucent backdrop and a frame per column,
// made as 0x7184e0 makes the others: cUI_Window2 0x724ed0 (rect*, colour; // made as 0x7184e0 makes the others: cUI_Window2 0x724ed0 (rect*, colour;
// ret 8) adds a backdrop (cUI_Static) to +0x90; frames are cUI_Frame // ret 8) adds a backdrop (cUI_Static) to +0x90; frames (game/ui/frame.h)
// (0xe0 bytes, constructor 0x739f90 (name, type 1, rect*, 1), ret 0x10)
// pushed onto +0x90 (vector push_back 0x6bcf50, (&pointer), ret 4). // pushed onto +0x90 (vector push_back 0x6bcf50, (&pointer), ret 4).
// (Drawing the existing ones at other rects did not fit: a frame's border // (Drawing the existing ones at other rects did not fit: a frame's border
// pieces keep the place they got when it was made.) // pieces keep the place they got when it was made.)
constexpr uint32_t kBackdropColour = 0x9f000000; constexpr uint32_t kBackdropColour = 0x9f000000;
constexpr int kFrameBorder = 0x14; // backdrop beyond the frame on each side constexpr int kFrameBorder = 0x14; // backdrop beyond the frame on each side
constexpr size_t kFrameSize = 0xe0;
constexpr uint32_t kMainFrame = 1;
// Layout of 0x7184e0 (window coordinates): the frames' borders begin 0x14 // Layout of 0x7184e0 (window coordinates): the frames' borders begin 0x14
// left of / above the frame positions, their labels at y 0x20 / 0x100 / 0x1e0. // left of / above the frame positions, their labels at y 0x20 / 0x100 / 0x1e0.
@@ -120,7 +117,7 @@ int32_t& ControlY(uint8_t* control) {
} }
bool IsFrame(const uint8_t* control) { bool IsFrame(const uint8_t* control) {
return *reinterpret_cast<const uint32_t*>(control) == kFrameVtable; return *reinterpret_cast<const uint32_t*>(control) == game::kFrameVtable;
} }
// cUI_Scrollbar (vtable 0x895664, the volume and map transparency sliders): // cUI_Scrollbar (vtable 0x895664, the volume and map transparency sliders):
@@ -224,7 +221,7 @@ void ShowPage(Window& w, uint16_t page) {
c.hiddenByUs = false; c.hiddenByUs = false;
} }
if (IsFrame(c.control)) { if (IsFrame(c.control)) {
c.control[kFrameDrawn] = show ? 1 : 0; c.control[game::kFrameDrawn] = show ? 1 : 0;
} }
} }
} }
@@ -445,9 +442,8 @@ void AddFrame(uint8_t* window, game::UiRect frame, const char* name) {
static_cast<int16_t>(frame.h + 2 * kFrameBorder)}; static_cast<int16_t>(frame.h + 2 * kFrameBorder)};
using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t); using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t);
reinterpret_cast<AddBackdropFn>(0x00724ed0)(window, nullptr, &backdrop, kBackdropColour); reinterpret_cast<AddBackdropFn>(0x00724ed0)(window, nullptr, &backdrop, kBackdropColour);
if (void* memory = game::GameNew(kFrameSize)) { if (uint8_t* control = game::NewFrame(name, frame)) {
using FrameFn = void*(__fastcall*)(void*, void*, const char*, uint32_t, game::UiRect*, uint32_t); PushControl(window, control);
PushControl(window, reinterpret_cast<FrameFn>(0x00739f90)(memory, nullptr, name, kMainFrame, &frame, 1));
} }
} }
@@ -567,6 +563,127 @@ void DrawLootPage(const Window& w, void* device) {
} }
} }
// --- saves (Allgemein page) ------------------------------------------------------
// The autosave interval (request of 2026-10-20), in the left column below
// the general frame (moved up, it is taller than the frames beside it): a
// frame of its own, as the right column's lowest frame (Internet-Optionen):
// its height, and the gap the right column has between its frames, both
// measured when the window is made. (A frame lower than that breaks its
// side ornaments; screenshots 2026-10-20.)
constexpr int kSavesFallbackGap = 0x50;
constexpr int16_t kSavesFallbackHeight = 0x40;
constexpr int kSavesLabelWidth = 0x90;
constexpr int kSavesBoxGap = 8;
constexpr int kAutosaveChoices[] = {0, 5, 10, 15, 30}; // minutes, 0: off
constexpr int kAutosaveChoiceCount = static_cast<int>(std::size(kAutosaveChoices));
constexpr int kSavesBoxWidth =
(kFrameWidth - 2 * kTextInset - kSavesLabelWidth - (kAutosaveChoiceCount - 1) * kSavesBoxGap) /
kAutosaveChoiceCount;
// Lighter than kBoxColour: on the frame's dark backdrop that one is not seen.
constexpr uint32_t kSavesBoxColour = 0xc0303030;
constexpr uint32_t kSavesBoxHoverColour = 0xc0484848;
game::UiRect g_savesFrame = {kFrameX[0], 0, kFrameWidth, kSavesFallbackHeight};
int g_autosavePending = 0; // minutes until OK
int g_savesHover = -1; // choice under the mouse
void CreateSavesControls(Window& w) {
int bottom = 0;
std::vector<game::UiRect> right;
for (const Control& c : w.controls) {
if (c.group != Group::Common || !IsFrame(c.control)) {
continue;
}
const game::UiRect rect = *reinterpret_cast<const game::UiRect*>(c.control + game::kControlRect);
if (ControlX(c.control) < kColumnSplit) {
bottom = std::max(bottom, rect.y + rect.h);
} else if (rect.w == kFrameWidth) {
right.push_back(rect); // the main frames, not their label plates (200 wide)
}
}
// The lowest frame and the nearest one ending above it. (Measured: the
// right column's frames are 384 wide at y 88, 312, 536, 128 / 128 / 64
// high: gaps of 96. Their label plates are frames too, 200 wide.)
std::sort(right.begin(), right.end(), [](const game::UiRect& a, const game::UiRect& b) { return a.y < b.y; });
int gap = kSavesFallbackGap;
if (!right.empty()) {
const game::UiRect& lowest = right.back();
int aboveBottom = -1;
for (const game::UiRect& r : right) {
if (r.y + r.h <= lowest.y) {
aboveBottom = std::max(aboveBottom, r.y + r.h);
}
}
if (aboveBottom >= 0) {
gap = lowest.y - aboveBottom;
}
g_savesFrame.h = lowest.h;
}
g_savesFrame.y = bottom + gap;
const size_t existing = ControlCount(w.window);
AddFrame(w.window, g_savesFrame, "UI_FRAME_SAVES");
AssignFrom(w, existing, Group::Common);
}
game::UiRect SavesRowRect(void* window, int dx, int width) {
const game::UiRect origin = game::ControlScreenRect(window);
return {origin.x + g_savesFrame.x + kTextInset + dx, origin.y + g_savesFrame.y + (g_savesFrame.h - kBoxHeight) / 2,
static_cast<int16_t>(width), static_cast<int16_t>(kBoxHeight)};
}
game::UiRect SavesBoxRect(void* window, int choice) {
return SavesRowRect(window, kSavesLabelWidth + choice * (kSavesBoxWidth + kSavesBoxGap), kSavesBoxWidth);
}
int SavesBoxAt(void* window, int x, int y) {
for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) {
if (game::Inside(SavesBoxRect(window, choice), x, y)) {
return choice;
}
}
return -1;
}
void DrawSaves(const Window& w, void* device) {
void* window = w.window;
game::DrawText(device, kFont, game::CenterLineVertically(kFont, SavesRowRect(window, 0, kSavesLabelWidth)),
L"Autosave (Min.)", kTextColour, 0);
for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) {
const game::UiRect box = SavesBoxRect(window, choice);
const int minutes = kAutosaveChoices[choice];
FillBox(device, box,
g_autosavePending == minutes ? kBoxSelectedColour
: g_savesHover == choice ? kSavesBoxHoverColour
: kSavesBoxColour);
const std::wstring text = minutes ? std::to_wstring(minutes) : L"Aus";
game::DrawText(device, kFont, game::CenterLineVertically(kFont, box), text.c_str(), kTextColour,
game::kTextCenter);
}
}
bool HandleSavesEvent(Window& w, void* event) {
switch (game::MouseEventKind(event)) {
case game::MouseEvent::Move:
g_savesHover = SavesBoxAt(w.window, game::EventField(event, 0xc), game::EventField(event, 0x10));
return false;
case game::MouseEvent::ButtonDown: {
if (game::EventField(event, game::kMouseButton) != game::kLeftButton) {
return false;
}
const int box = SavesBoxAt(w.window, game::EventField(event, game::kMouseButtonX),
game::EventField(event, game::kMouseButtonY));
if (box < 0) {
return false;
}
g_autosavePending = kAutosaveChoices[box];
return true;
}
default:
return false;
}
}
// --- hooks --------------------------------------------------------------------- // --- hooks ---------------------------------------------------------------------
struct RenderCall { struct RenderCall {
@@ -588,6 +705,7 @@ int RenderBody(void* context) {
if (visible && !w->wasVisible) { if (visible && !w->wasVisible) {
LoadPending(); LoadPending();
LoadLootPending(); LoadLootPending();
g_autosavePending = mods::characters::AutosaveMinutes();
} }
w->wasVisible = visible; w->wasVisible = visible;
const uint16_t page = game::ActiveTab(w->sheet); const uint16_t page = game::ActiveTab(w->sheet);
@@ -600,6 +718,9 @@ int RenderBody(void* context) {
if (!visible || page != kPageLoot) { if (!visible || page != kPageLoot) {
g_lootHover = -1; g_lootHover = -1;
} }
if (!visible || page != kPageCommon) {
g_savesHover = -1;
}
if (!visible) { if (!visible) {
return result; return result;
} }
@@ -608,6 +729,8 @@ int RenderBody(void* context) {
DrawKeysPage(*w, call->device); DrawKeysPage(*w, call->device);
} else if (page == kPageLoot && g_pendingLoaded) { } else if (page == kPageLoot && g_pendingLoaded) {
DrawLootPage(*w, call->device); DrawLootPage(*w, call->device);
} else if (page == kPageCommon && g_pendingLoaded) {
DrawSaves(*w, call->device);
} }
return result; return result;
} }
@@ -649,6 +772,10 @@ int EventBody(void* context) {
g_pendingLoaded && HandleLootEvent(*w, call->event)) { g_pendingLoaded && HandleLootEvent(*w, call->event)) {
return 1; return 1;
} }
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageCommon &&
g_pendingLoaded && HandleSavesEvent(*w, call->event)) {
return 1;
}
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys && if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys &&
g_pendingLoaded) { g_pendingLoaded) {
switch (game::MouseEventKind(call->event)) { switch (game::MouseEventKind(call->event)) {
@@ -704,6 +831,7 @@ void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, Ui
Classify(w); Classify(w);
CreateKeyControls(w); CreateKeyControls(w);
CreateLootControls(w); CreateLootControls(w);
CreateSavesControls(w);
CreateTabs(w); CreateTabs(w);
re::Log("options: tabs created (%u controls)", static_cast<unsigned>(w.controls.size())); re::Log("options: tabs created (%u controls)", static_cast<unsigned>(w.controls.size()));
} }
@@ -744,6 +872,7 @@ void __fastcall UiOptions_Apply(void* self, void* edx) {
if (g_pendingLoaded) { if (g_pendingLoaded) {
mods::input::SetBindingKeys(g_pending); mods::input::SetBindingKeys(g_pending);
mods::lootfilter::SetModes(g_lootPending); mods::lootfilter::SetModes(g_lootPending);
mods::characters::SetAutosaveMinutes(g_autosavePending);
} }
} }
+302
View File
@@ -0,0 +1,302 @@
// 16:9 menu screens (mods/screen.h, docs/screens.md).
#include "mods/screen.h"
#include <algorithm>
#include <atomic>
#include <map>
#include <vector>
#include <windows.h>
#include "game/addresses.h"
#include "game/render/dx_driver7.h"
#include "game/ui/button.h"
#include "game/ui/control.h"
#include "game/ui/draw.h"
#include "game/ui/events.h"
#include "game/ui/frame.h"
#include "game/ui/texture.h"
#include "game/ui/texts.h"
#include "mods/widescreen.h"
#include "re/hook.h"
#include "re/patch.h"
namespace mods::screen {
namespace {
// Background (user, 2026-10-20: "textured dark", then "doesn't need to be that
// dark, but apply it to all screens"): the options window's rock wall,
// GUI_OPTION_BACKGROUND<row><column>.TGA (4 x 3 tiles of 256, a 1024x768
// picture), scaled to cover the render target, a little darkened, darker
// towards the bottom and the sides.
constexpr int kBackgroundColumns = 4;
constexpr int kBackgroundRows = 3;
constexpr float kBackgroundTile = 256.0f;
constexpr uint32_t kBackgroundTint = 0xffa0a0a0;
constexpr uint32_t kShadeTop = 0x10000000;
constexpr uint32_t kShadeBottom = 0x70000000;
constexpr uint32_t kVignette = 0x70000000;
constexpr int kVignetteWidth = 240;
constexpr uint32_t kPanelBackdrop = 0xb4000000;
constexpr int kHeadingHeight = 30;
// Frames drawn so far, to tell a button drawn this frame (events come between
// frames).
std::atomic<unsigned> g_frame{1};
// The windows whose screen is shown on the menu thread, with the menu frame
// they were last drawn in: the main menu's frame is not drawn centred while
// any is (the screen draws itself across the width). Entries not drawn for
// kShownFrames frames are dropped: the UI is rebuilt when a game starts or
// ends, and a window destroyed while shown kept the menu from being framed
// (user, 2026-10-21: "the old main menu is rendering again"). Menu thread only.
constexpr unsigned kShownFrames = 2;
unsigned g_menuFrame = 0;
std::map<const void*, unsigned> g_shown;
void NoteShown(const void* window, bool shown) {
if (!widescreen::IsMenuThread()) {
return;
}
if (shown) {
g_shown[window] = g_menuFrame;
} else {
g_shown.erase(window);
}
}
// The windows' own pictures, replaced by the background (user, 2026-10-20:
// "the previous backgrounds from settings and mainMenu can be removed"), 12
// tiles each (logged: the textures the menu thread asks for, by name): the
// main menu GUI_UW_OPTION<1..12>.TGA with the sky above it UI_MAIN_SKY.TGA,
// the options window UW_OPTION_1_<01..12>.TGA. The main menu's 3D scene
// behind its picture is not drawn either (game::RemoveMenuScene).
std::vector<uint32_t> PictureTextureIds() {
std::vector<uint32_t> ids = {game::TextureId("UI_MAIN_SKY.TGA")};
char name[40];
for (int i = 1; i <= 12; ++i) {
wsprintfA(name, "GUI_UW_OPTION%d.TGA", i);
ids.push_back(game::TextureId(name));
wsprintfA(name, "UW_OPTION_1_%02d.TGA", i);
ids.push_back(game::TextureId(name));
}
return ids;
}
// Their surfaces (loaded now, as the window is about to draw them; the cache
// may have made them anew) go to the HUD layer's hidden list.
void HidePictures() {
static const std::vector<uint32_t> ids = PictureTextureIds();
std::vector<void*> surfaces;
for (uint32_t id : ids) {
if (void* surface = game::TextureSurface(id)) {
surfaces.push_back(surface);
}
}
hud::SetHiddenTextures(surfaces.data(), static_cast<int>(surfaces.size()));
}
// The game's logo top left (user, 2026-10-21: "the bright blueish one"),
// kLogoMargin from the edges: FX_SACREDLOGO.TGA, the emblem (horns around a
// ring of runes) glowing blue on black, no alpha, added onto the background as
// the game's effects draw it; the glow within u kLogoU0..U1 of the square
// texture, the full height (measured on a screenshot of it drawn whole).
constexpr char kLogoTexture[] = "FX_SACREDLOGO.TGA";
constexpr float kLogoU0 = 0.13f, kLogoU1 = 0.87f;
static_assert(kLogoWidth == static_cast<int>(kLogoHeight * (kLogoU1 - kLogoU0)));
void DrawLogo(void* device) {
if (void* logo = game::TextureSurface(game::TextureId(kLogoTexture))) {
const float edge = static_cast<float>(kLogoMargin);
game::DrawTexturedAdded(device, logo, edge, edge, edge + kLogoWidth, edge + kLogoHeight, kLogoU0, 0, kLogoU1,
1);
}
}
void DrawRockWall(void* device, float width, float height) {
const float scale = std::max(width / (kBackgroundColumns * kBackgroundTile),
height / (kBackgroundRows * kBackgroundTile));
const float tile = kBackgroundTile * scale;
const float left = (width - kBackgroundColumns * tile) / 2;
const float top = (height - kBackgroundRows * tile) / 2;
for (int row = 0; row < kBackgroundRows; ++row) {
for (int column = 0; column < kBackgroundColumns; ++column) {
char name[40];
wsprintfA(name, "GUI_OPTION_BACKGROUND%d%d.TGA", row, column);
if (void* surface = game::TextureSurface(game::TextureId(name))) {
const float x = left + column * tile, y = top + row * tile;
game::DrawTextured(device, surface, x, y, x + tile, y + tile, 0, 0, 1, 1, kBackgroundTint);
}
}
}
}
} // namespace
bool BeginMenuFrame() {
++g_menuFrame;
std::erase_if(g_shown, [](const auto& entry) { return g_menuFrame - entry.second > kShownFrames; });
return !g_shown.empty();
}
void DrawBackground(void* device) {
const float width = static_cast<float>(std::max(widescreen::RenderWidth(), kWidth));
const float height = static_cast<float>(kHeight);
HidePictures();
game::FillRect(device, 0, 0, width, height, 0xff000000);
DrawRockWall(device, width, height);
game::FillVertical(device, 0, 0, width, height, kShadeTop, kShadeBottom);
game::FillHorizontal(device, 0, 0, kVignetteWidth, height, kVignette, 0);
game::FillHorizontal(device, width - kVignetteWidth, 0, width, height, 0, kVignette);
DrawLogo(device);
}
int Render(void* window, void* device, int (*body)(void* context), void* context) {
const bool shown = game::ControlShown(window);
NoteShown(window, shown);
if (!shown || !device) {
return 0;
}
++g_frame;
DrawBackground(device);
return hud::RenderCanvas(window, body, context);
}
int Handle(void* event, int (*body)(void* context), void* context) {
return hud::HandleCanvas(event, body, context);
}
void Fill(void* device, const UiRect& r, uint32_t colour) {
game::FillRect(device, static_cast<float>(r.x), static_cast<float>(r.y), static_cast<float>(r.x + r.w),
static_cast<float>(r.y + r.h), colour);
}
void Text(void* device, const UiRect& rect, const std::wstring& text, uint32_t colour, uint32_t flags) {
game::DrawText(device, kFont, game::CenterLineVertically(kFont, rect), text.c_str(), colour, flags);
}
UiRect Inset(const UiRect& r, int by) {
return {r.x + by, r.y + by, static_cast<int16_t>(r.w - 2 * by), static_cast<int16_t>(r.h - 2 * by)};
}
UiRect Panel::Draw(void* device, const UiRect& rect, const std::wstring& heading) {
if (!frame_ || rect.x != rect_.x || rect.y != rect_.y || rect.w != rect_.w || rect.h != rect_.h) {
if (frame_) {
game::DeleteControl(frame_);
}
frame_ = game::NewFrame(name_, rect);
rect_ = rect;
}
Fill(device, rect, kPanelBackdrop);
if (frame_) {
game::ControlRender(frame_, device);
}
UiRect content = Inset(rect, kPanelPadding);
if (!heading.empty()) {
Text(device, {content.x, content.y, content.w, kHeadingHeight}, heading, kHeadingColour);
content.y += kHeadingHeight;
content.h = static_cast<int16_t>(content.h - kHeadingHeight);
}
return content;
}
void Button::Draw(void* device, const uint8_t* model, const UiRect& rect) {
if (!control_) {
captionId_ = game::RegisterText(caption_.c_str());
control_ = game::NewButtonLike(model, rect, captionId_, name_);
if (!control_) {
return;
}
}
rect_ = rect;
*reinterpret_cast<UiRect*>(control_ + game::kControlRect) = rect;
game::ControlRender(control_, device);
drawnFrame_ = g_frame;
}
bool Button::Clicked(void* event) {
if (!control_ || drawnFrame_ != g_frame) {
return false;
}
game::ControlHandleEvent(control_, event);
return game::MouseEventKind(event) == game::MouseEvent::ButtonDown &&
game::EventField(event, game::kMouseButton) == game::kLeftButton &&
game::Inside(rect_, game::EventField(event, game::kMouseButtonX),
game::EventField(event, game::kMouseButtonY));
}
void Button::SetCaption(const std::wstring& caption) {
caption_ = caption;
if (captionId_) {
game::SetTextOverride(captionId_, caption_);
}
}
int16_t ButtonHeight(const uint8_t* model) {
return reinterpret_cast<const UiRect*>(model + game::kControlRect)->h;
}
bool DoubleClick::Click(int target) {
const unsigned long now = GetTickCount();
const bool twice = target == last_ && now - at_ <= GetDoubleClickTime();
last_ = twice ? -1 : target;
at_ = now;
return twice;
}
} // namespace mods::screen
// The main menu (cUI_Manager's UI render 0x7587b0, thiscall (1 argument), ret
// 4, from the menu thread loop 0x756aa1 and the in-game render 0x60ea74): on
// the menu thread the background goes first, then the menu's 1024x768 frame
// is drawn centred over it (hud::RenderMenuFrame); a 16:9 screen draws its own.
namespace game {
uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument);
RE_HOOK(0x007587b0, UiManager_Render);
uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument) {
using namespace mods;
if (!widescreen::IsMenuThread() || widescreen::IsClassicFrame() || widescreen::RenderWidth() == 0) {
return UiManager_Render_original(self, edx, argument);
}
auto* driver = *reinterpret_cast<DxDriver7**>(addr::g_dxDriver);
void* device = driver ? driver->device : nullptr;
const bool framed = !screen::BeginMenuFrame();
if (framed && device) {
screen::DrawBackground(device);
}
struct Call {
void* self;
void* edx;
uint32_t argument;
uint32_t result;
} call{self, edx, argument, 0};
hud::RenderMenuFrame(
device, framed,
[](void* context) {
auto* c = static_cast<Call*>(context);
c->result = UiManager_Render_original(c->self, c->edx, c->argument);
return 0;
},
&call);
return call.result;
}
// cUI_MainMenu::Render (vf14 0x7122c0) draws a 3D scene (sky UI_UW_MAIN_CENTER,
// water UI_MAIN_REF; 0x75e620, thiscall (1 argument), ret 4, on the scene
// object 0x75df10) behind its picture: the call at 0x712bd9 goes nowhere.
constexpr uint32_t kMainMenuSceneCall = 0x00712bd9;
constexpr uint32_t kRenderMenuScene = 0x0075e620;
void __fastcall SkipMenuScene(void*, void*, uint32_t) {}
void RemoveMenuScene() {
re::RedirectCall(kMainMenuSceneCall, kRenderMenuScene, reinterpret_cast<const void*>(&SkipMenuScene));
}
RE_STARTUP(MenuSceneStartup, RemoveMenuScene);
} // namespace game
+117
View File
@@ -0,0 +1,117 @@
#pragma once
// 16:9 menu screens (request of 2026-10-20, docs/screens.md): a screen of our
// own replaces a full-screen window of the game's (its render and mouse
// handling), laid out on a 1365x768 canvas (1920x1080 in UI units), centred in
// the render width; wider screens only get more background at the sides. The
// look is the game's: its frames and buttons on a dark background of ours.
//
// A screen hooks its window's render and event methods and calls Render /
// Handle with a body that draws / handles in canvas coordinates with the
// helpers below.
#include <cstdint>
#include <string>
#include "game/ui/text.h"
#include "mods/hud.h"
namespace mods::screen {
using game::UiRect;
constexpr int kWidth = hud::kCanvasWidth;
constexpr int kHeight = hud::kCanvasHeight;
// Spacing: the game's frames reach about 0x20 beyond their rects, buttons
// about 0x21 (measured on the options window).
constexpr int kFrameOrnament = 0x20;
// The frames are made of pieces of 64: other sizes leave gaps (the game's
// frames are 384x128, 384x64; screenshot 2026-10-20).
constexpr int kFrameStep = 64;
constexpr int kButtonOrnament = 0x21;
constexpr int kMargin = kFrameOrnament + 16; // canvas edge to a panel's rect
constexpr int kPanelGap = 96; // between two panels' rects, as the options window's columns
constexpr int kPanelPadding = 16; // panel rect to its content
// The logo in the background's top left corner (render target coordinates;
// screens may overlap it).
constexpr int kLogoMargin = 48;
constexpr int kLogoHeight = 124;
constexpr int kLogoWidth = kLogoHeight * 74 / 100; // the emblem's aspect (screen.cpp)
constexpr uint16_t kFont = 1;
constexpr uint32_t kTextColour = 0xffe6d6aa; // as the options' texts
constexpr uint32_t kHeadingColour = 0xfff0c060; // as the options' headings
constexpr uint32_t kDimColour = 0xff8c8474;
constexpr uint32_t kRowColour = 0x40302418;
constexpr uint32_t kRowHoverColour = 0x80403020;
constexpr uint32_t kRowSelectedColour = 0xc0604818;
// The background of the menu screens across the whole render target (also
// beside the main menu's centred frame and full-screen windows in a game).
void DrawBackground(void* device);
// Called once per main menu frame (menu thread): whether a screen is shown
// (the main menu then does not draw centred).
bool BeginMenuFrame();
// Draws the screen of `window`: the background across the whole render
// target, then body(context) in canvas coordinates.
int Render(void* window, void* device, int (*body)(void* context), void* context);
// Runs body(context) with the event's mouse position in canvas coordinates.
int Handle(void* event, int (*body)(void* context), void* context);
void Fill(void* device, const UiRect& rect, uint32_t colour);
// One line of text in `rect`, centred vertically (game::kTextCenter /
// kTextRight for horizontal alignment).
void Text(void* device, const UiRect& rect, const std::wstring& text, uint32_t colour, uint32_t flags = 0);
UiRect Inset(const UiRect& rect, int by);
// A panel: a dark backdrop with the game's frame around `rect` (made anew when
// the rect changes) and an optional heading at its top.
class Panel {
public:
explicit Panel(const char* name) : name_(name) {}
// The content area below the heading.
UiRect Draw(void* device, const UiRect& rect, const std::wstring& heading = {});
private:
const char* name_;
UiRect rect_ = {};
uint8_t* frame_ = nullptr;
};
// A button of the game's (look and sounds of `model`, a button of the
// window), made on first use.
class Button {
public:
Button(const char* name, std::wstring caption) : name_(name), caption_(std::move(caption)) {}
void Draw(void* device, const uint8_t* model, const UiRect& rect);
// Passes the event to the button (pressed look, sound); true for a left
// click on it. Only for buttons drawn this frame.
bool Clicked(void* event);
void SetCaption(const std::wstring& caption);
private:
const char* name_;
std::wstring caption_;
uint32_t captionId_ = 0;
uint8_t* control_ = nullptr;
UiRect rect_ = {};
unsigned drawnFrame_ = 0;
};
// Height of the model's buttons (as the window made them).
int16_t ButtonHeight(const uint8_t* model);
// Two clicks on the same thing within the system's double-click time.
class DoubleClick {
public:
bool Click(int target);
private:
int last_ = -1;
unsigned long at_ = 0;
};
} // namespace mods::screen
+9 -1
View File
@@ -166,9 +166,17 @@ void Load() {
} }
// A session started or a save game was loaded into it (its objects replace // A session started or a save game was loaded into it (its objects replace
// the old ones): (re)load on the next tick with a hero. // the old ones): (re)load on the next tick with a hero. Until then the grid's
// ids are stale (they name the new world's objects) and nothing is saved: a
// hero saved inside the load (characters.cpp, Engine_Load, a migrated
// character's first load) wrote them into the file, the chest's items
// replaced by the world's (user, 2026-10-21).
void RequestLoad() { void RequestLoad() {
g_loaded = false;
g_loadPending = true; g_loadPending = true;
if (g_constructed) {
ClearGrid();
}
} }
// The chest grid's place and size, as the cUI_Chest constructor sets them for // The chest grid's place and size, as the cUI_Chest constructor sets them for
+15 -4
View File
@@ -8,7 +8,8 @@
// whose tooltip names the potion too). Touching a teleporter runs // whose tooltip names the potion too). Touching a teleporter runs
// cInterpretSQW::Teleporter (0x4ae330): it adds the portal to the hero's known ones (bit mask at hero +0x578) and posts // cInterpretSQW::Teleporter (0x4ae330): it adds the portal to the hero's known ones (bit mask at hero +0x578) and posts
// a UI event (type 0x39) to DEFAULT_BUSYDLG with +8 = 2, +0xc = 5 (portal // a UI event (type 0x39) to DEFAULT_BUSYDLG with +8 = 2, +0xc = 5 (portal
// mode; 6 for the underworld portals above 0xd) and +0x10 = the mask; // mode; 6 for the underworld portals above 0xd, the same mask) and +0x10 =
// the mask;
// cUI_BusyDlg::HandleEvent (0x720c10) then calls SetMode (0x720180, (mode, a, // cUI_BusyDlg::HandleEvent (0x720c10) then calls SetMode (0x720180, (mode, a,
// b), ret 0xc). Choosing a portal sends the engine an event whose case // b), ret 0xc). Choosing a portal sends the engine an event whose case
// (0x619671, "EE_USEPORTAL") teleports the hero with cCreature::Teleport // (0x619671, "EE_USEPORTAL") teleports the hero with cCreature::Teleport
@@ -26,11 +27,13 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "game/addresses.h"
#include "game/engine/events.h" #include "game/engine/events.h"
#include "game/engine/items.h" #include "game/engine/items.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "game/ui/texts.h" #include "game/ui/texts.h"
#include "mods/charfile.h"
#include "mods/skillslots.h" #include "mods/skillslots.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -55,7 +58,8 @@ constexpr uint32_t kUiEventBusyDialog = 0x39;
constexpr uintptr_t kBusyDialogName = 0x0094d700; // "DEFAULT_BUSYDLG" constexpr uintptr_t kBusyDialogName = 0x0094d700; // "DEFAULT_BUSYDLG"
constexpr uintptr_t kNoSender = 0x00aa24bc; // the game's empty name constexpr uintptr_t kNoSender = 0x00aa24bc; // the game's empty name
constexpr uint32_t kBusyDialogSetMode = 2; constexpr uint32_t kBusyDialogSetMode = 2;
constexpr uint32_t kBusyModePortals = 5; constexpr uint32_t kBusyModePortals = 5; // Ancaria's portals
constexpr uint32_t kBusyModeUnderworldPortals = 6;
constexpr size_t kUiEventSize = 0x80; constexpr size_t kUiEventSize = 0x80;
constexpr size_t kUiEventString = 0x68; constexpr size_t kUiEventString = 0x68;
@@ -65,6 +69,13 @@ constexpr uintptr_t kPortalTeleportReturn = 0x006197c8;
constexpr size_t kControlFlags = 0x10; constexpr size_t kControlFlags = 0x10;
constexpr uint32_t kControlVisible = 1; constexpr uint32_t kControlVisible = 1;
// The portal list of the running campaign (user, 2026-10-21: the potion
// showed Ancaria's portals in the Underworld), as the teleporters choose it.
uint32_t PortalMode() {
const uint16_t campaign = *reinterpret_cast<const uint16_t*>(game::addr::g_campaign);
return campaign == characters::kCampaignUnderworld ? kBusyModeUnderworldPortals : kBusyModePortals;
}
// The potion that opened the portal window, until it is used or the window // The potion that opened the portal window, until it is used or the window
// closes; and the dialog, once it showed for it. // closes; and the dialog, once it showed for it.
uint32_t g_pending = 0; uint32_t g_pending = 0;
@@ -77,7 +88,7 @@ void OpenPortalWindow(uint32_t knownPortals) {
uint32_t, uint32_t, uint32_t); uint32_t, uint32_t, uint32_t);
reinterpret_cast<CtorFn>(0x00553080)(event, nullptr, kUiEventBusyDialog, reinterpret_cast<const char*>(kBusyDialogName), reinterpret_cast<CtorFn>(0x00553080)(event, nullptr, kUiEventBusyDialog, reinterpret_cast<const char*>(kBusyDialogName),
reinterpret_cast<const char*>(kNoSender), reinterpret_cast<const char*>(kNoSender),
kBusyDialogSetMode, kBusyModePortals, knownPortals, 0, 0, 0); kBusyDialogSetMode, PortalMode(), knownPortals, 0, 0, 0);
game::PostEvent(event); game::PostEvent(event);
using FreeFn = void(__fastcall*)(void*, void*); using FreeFn = void(__fastcall*)(void*, void*);
reinterpret_cast<FreeFn>(0x006e85d0)(event + kUiEventString, nullptr); reinterpret_cast<FreeFn>(0x006e85d0)(event + kUiEventString, nullptr);
@@ -162,7 +173,7 @@ void __fastcall BusyDlg_SetMode(void* self, void* edx, uint32_t mode, uint32_t a
using namespace mods::teleportpotion; using namespace mods::teleportpotion;
if (g_pending) { if (g_pending) {
g_dialog = static_cast<uint8_t*>(self); g_dialog = static_cast<uint8_t*>(self);
if (mode != kBusyModePortals) { if (mode != kBusyModePortals && mode != kBusyModeUnderworldPortals) {
g_pending = 0; g_pending = 0;
} }
} }
+16 -14
View File
@@ -63,12 +63,13 @@ bool g_pushesPatched = false;
int g_renderWidth = 0; int g_renderWidth = 0;
int g_renderHeight = 0; int g_renderHeight = 0;
// Frames from the main menu thread and the loading screen are laid out for // The loading screen is laid out for 1024x768 ("classic"); the main menu
// 1024x768 ("classic"); everything else is the widened in-game view. // thread renders the full width too (its windows drawn centred by
// mods/screen.cpp), the in-game view is widened.
std::atomic<DWORD> g_menuThread = 0; std::atomic<DWORD> g_menuThread = 0;
// Whether the most recent scene (on any thread) was a classic one: the main // Whether the most recent scene (on any thread) was the main menu's or the
// menu is showing. UI input is processed outside the menu thread. // loading screen's. UI input is processed outside the menu thread.
std::atomic<bool> g_lastSceneClassic = false; std::atomic<bool> g_lastSceneMenu = false;
thread_local int g_loadingScreenDepth = 0; thread_local int g_loadingScreenDepth = 0;
// D3DVIEWPORT7 (d3d.h is not in current Windows SDKs). // D3DVIEWPORT7 (d3d.h is not in current Windows SDKs).
@@ -339,14 +340,15 @@ int RenderWidth() {
} }
bool MenuIsShowing() { bool MenuIsShowing() {
return g_lastSceneClassic; return g_lastSceneMenu;
} }
bool IsClassicFrame() { bool IsClassicFrame() {
if (g_renderWidth == 0) { return g_renderWidth != 0 && g_loadingScreenDepth > 0;
return false; }
}
return g_loadingScreenDepth > 0 || GetCurrentThreadId() == g_menuThread.load(); bool IsMenuThread() {
return GetCurrentThreadId() == g_menuThread.load();
} }
} // namespace mods::widescreen } // namespace mods::widescreen
@@ -371,13 +373,13 @@ void __fastcall DxDriver7_LoadingScreen(DxDriver7* self, void* edx, uint32_t pro
--g_loadingScreenDepth; --g_loadingScreenDepth;
} }
// Menus draw their 3D parts for a 1024x768 screen: give them a 1024x768 // The loading screen draws for a 1024x768 screen: give it a 1024x768
// viewport (the borderless mod then presents just that area, scaled), and the // viewport (the borderless mod then presents just that area, scaled), and
// world the full render size. // everything else the full render size.
int __fastcall DxDriver7_BeginScene(DxDriver7* self, void* edx) { int __fastcall DxDriver7_BeginScene(DxDriver7* self, void* edx) {
mods::hud::OnDevice(self->device); mods::hud::OnDevice(self->device);
if (self->sceneDepth == 0) { if (self->sceneDepth == 0) {
g_lastSceneClassic = IsClassicFrame(); g_lastSceneMenu = IsClassicFrame() || IsMenuThread();
} }
if (g_renderWidth != 0 && self->sceneDepth == 0 && self->device) { if (g_renderWidth != 0 && self->sceneDepth == 0 && self->device) {
const bool classic = IsClassicFrame(); const bool classic = IsClassicFrame();
+7 -4
View File
@@ -11,15 +11,18 @@ namespace mods::widescreen {
// the world view to match. No-op when disabled. // the world view to match. No-op when disabled.
void ApplyRenderSize(game::DxDisplayMode& mode, HWND window); void ApplyRenderSize(game::DxDisplayMode& mode, HWND window);
// True while the current thread renders a frame laid out for 1024x768 (main // True while the current thread renders a frame laid out for 1024x768 (the
// menu, loading screen); the borderless mod then presents only that area. // loading screen); the borderless mod then presents only that area.
bool IsClassicFrame(); bool IsClassicFrame();
// Applied render width (0 while the original 1024x768 is in use). // Applied render width (0 while the original 1024x768 is in use).
int RenderWidth(); int RenderWidth();
// True while the main menu / loading screen (classic layout) is on screen, // True while the main menu or the loading screen is on screen, regardless of
// regardless of the calling thread. // the calling thread.
bool MenuIsShowing(); bool MenuIsShowing();
// True on the main menu's render thread (cUI_Manager's thread loop).
bool IsMenuThread();
} // namespace mods::widescreen } // namespace mods::widescreen
+28 -1
View File
@@ -302,7 +302,7 @@
0x006e7730 cUI_Tooltip::Layout # measures the text, width = longest line + 8 unless flag 0x100 0x006e7730 cUI_Tooltip::Layout # measures the text, width = longest line + 8 unless flag 0x100
0x0075ac70 cUI_Manager::ShowTooltip # (x, y, text, ?, flags, show) 0x0075ac70 cUI_Manager::ShowTooltip # (x, y, text, ?, flags, show)
# Character selection / export screen (src/mods/charexport.cpp) # Character selection / export screen (cUI_Character)
0x0070c760 cUI_Character::SetMode # (mode), ret 4: +0x390 (4 selection, 5 export, 6 overwrite confirm) 0x0070c760 cUI_Character::SetMode # (mode), ret 4: +0x390 (4 selection, 5 export, 6 overwrite confirm)
0x0070c440 cUI_Character::SelectSlot # (char slot, -1 none), ret 4 0x0070c440 cUI_Character::SelectSlot # (char slot, -1 none), ret 4
0x0070d0f0 cUI_Character::ExecuteAction # (action, slot, extra), ret 0xc: 6 export into slot, 9 export (confirm if occupied) 0x0070d0f0 cUI_Character::ExecuteAction # (action, slot, extra), ret 0xc: 6 export into slot, 9 export (confirm if occupied)
@@ -334,13 +334,40 @@
0x0061b790 cEngine::Load # (path, quiet), ret 8: reads a GAME.PAK 0x0061b790 cEngine::Load # (path, quiet), ret 8: reads a GAME.PAK
0x00619c90 cEngine::Save # (path), ret 4; render thread, engine +0x54 & 0x40 0x00619c90 cEngine::Save # (path), ret 4; render thread, engine +0x54 & 0x40
0x0060dc00 cEngine::InitGame # (int), ret 4: new game, start hero 0x0060dc00 cEngine::InitGame # (int), ret 4: new game, start hero
0x0071c4f0 cUI_Savegame::SetMode # (mode), ret 4: 1 save, 2 load (+0x75c)
0x0071cfb0 cUI_Savegame::Show # vf24 (bool), ret 4: picks save mode in a game
0x0071c520 cUI_Savegame::Action # (action), ret 4: 2 save, 4 load, 8 delete, 10 rescan, 0xb default 0x0071c520 cUI_Savegame::Action # (action), ret 4: 2 save, 4 load, 8 delete, 10 rescan, 0xb default
0x0071e330 cUI_Savegame::Scan # (dir, multiplayer), ret 8: "./SAVE/*.PAK" 0x0071e330 cUI_Savegame::Scan # (dir, multiplayer), ret 8: "./SAVE/*.PAK"
0x0071be90 cUI_Savegame::SendSaveLoad # (kind, row): engine event 0xf 0x0071be90 cUI_Savegame::SendSaveLoad # (kind, row): engine event 0xf
0x00755ac0 cCommand_armaPlay::execute # posts 0x8002 (mode, argument) to the main window 0x00755ac0 cCommand_armaPlay::execute # posts 0x8002 (mode, argument) to the main window
0x00755b30 cCommand_armaStop::execute # vf04, plain ret: ends the game, posts 0x8001 to the main window (engine deleted, main menu)
0x007d7c40 Session_Instance 0x007d7c40 Session_Instance
0x007d7cf0 Session::SetHeroClass # (class) -> +0x44, ret 4 0x007d7cf0 Session::SetHeroClass # (class) -> +0x44, ret 4
0x00417e50 GameSetup_Instance # 0x12e6c bytes; +0 set by both character-screen starts 0x00417e50 GameSetup_Instance # 0x12e6c bytes; +0 set by both character-screen starts
0x006bb870 cUI_EscMenu::BuildLines # line actions; "Spielstaende" text 0x41b at 0x6bc1f7 0x006bb870 cUI_EscMenu::BuildLines # line actions; "Spielstaende" text 0x41b at 0x6bc1f7
0x00810af0 BringWindowToFront # (hwnd, timeout), ret 8; loops forever with timeout -1 0x00810af0 BringWindowToFront # (hwnd, timeout), ret 8; loops forever with timeout -1
0x0072ad60 cUI_Button2::SetCaption # (text id, font), ret 8 -> +0xa4 / +0xa0 0x0072ad60 cUI_Button2::SetCaption # (text id, font), ret 8 -> +0xa4 / +0xa0
0x00712f30 cUI_MainMenu::BuildEntries # thiscall, plain ret: the menu entries (cUI_StaticText64FX); main page vector +0x158
0x00711fa0 cUI_MainMenu::HandleEvent # vf10: sets g_campaign 0x182cdc8 by entry name (NEWGAME 0, NEWADDON 1)
0x00753d70 cUI_StaticText64FX::cUI_StaticText64FX # (name, rect, parent, ?, text id, command, flags)
0x0070e2a0 cUI_Character::Show # vf24 (shown), ret 4: single player + g_campaign 1 -> mode 7 (Underworld import, level 25+)
# cUI_NetGranny: 3D hero view (game/ui/granny.h)
0x006f06f0 cUI_NetGranny::cUI_NetGranny # (rect by value), ret 0xc
0x006f17f0 cUI_NetGranny::Render # vf44 (device, 0, label), ret 0xc
0x006f4b20 cUI_NetGranny::SetHero # (hero info, x, y, z, turn), ret 0x14
0x006f4ae0 cUI_NetGranny::ClearHero
0x006f0f10 cUI_NetGranny::SetLights # (on): +0x53f white set
0x006f4d70 cUI_NetGranny::InitCamera
0x0070beb0 cUI_Character::LoadExportedHeroes # Hero00..07.pax -> slot grannies +0x1b4
# Textures by name (game/ui/texture.h)
0x00656c80 TextureManager_Instance # cdecl (), kept at 0x13e57b4
0x00657620 TextureManager::IdOf # (name), ret 4
0x0065f010 TextureCache::Get # (id, 0), ret 8; cache at 0x13e57b8, D3D surface +0x14
# Main menu (mods/screen.cpp)
0x007122c0 cUI_MainMenu::Render # vf14
0x0075df10 MenuScene_Instance # 0x1a84 bytes, kept at 0x17ecb40
0x0075e620 MenuScene::Render # (1 argument), ret 4: sky + water (UI_UW_MAIN_CENTER, UI_MAIN_REF)
0x0075e820 MenuScene::Update # (seconds)