Compare commits
9
Commits
5bccb0734e
...
3978060954
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3978060954 | ||
|
|
0ef3cf4bbd | ||
|
|
c4aacbcdd2 | ||
|
|
db87e276bf | ||
|
|
34a119433a | ||
|
|
732d9c2abb | ||
|
|
feebe72ab7 | ||
|
|
e0d8d0ce7e | ||
|
|
618288e310 |
No files matched your search
+2
-1
@@ -67,12 +67,13 @@ add_library(sacredK SHARED
|
||||
src/mods/runetrade.cpp
|
||||
src/mods/teleportpotion.cpp
|
||||
src/mods/guards.cpp
|
||||
src/mods/charexport.cpp
|
||||
src/mods/options.cpp
|
||||
src/mods/lootfilter.cpp
|
||||
src/mods/userfolders.cpp
|
||||
src/mods/charfile.cpp
|
||||
src/mods/screen.cpp
|
||||
src/mods/characters.cpp
|
||||
src/mods/mainmenu.cpp
|
||||
)
|
||||
target_include_directories(sacredK PRIVATE src)
|
||||
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
|
||||
|
||||
@@ -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
@@ -78,8 +78,11 @@ Decided (2026-10-19, follow-up):
|
||||
0x425160) +0x5704, one byte counter per difficulty (6 entries).
|
||||
`0x424bc0` (k) increments, `0x424be0` (k) reads, `0x424c00` (k, value) sets.
|
||||
The script opcode at 0x4aed26 increments counter[`g_gameLevel`]: the story
|
||||
end. The counters travel in the save header (loader 0x61bb19..) and in the
|
||||
hero file (loadHero 0x606632.., saveHero 0x60444c..). The console cheats
|
||||
end. The counters travel in the save header (dwords at +0x88.., loader
|
||||
0x61bb19..) and in the hero header chunk 0xc3 (bytes at +0xed.., written by
|
||||
0x604360, read by loadHero 0x606632..; +0xec is `g_gameLevel`). Checked in
|
||||
a character file (2026-10-20): Silber and Gold completed, played on Platin =
|
||||
[0, 1, 1, 0, 0] in the hero and the Platin world. The console cheats
|
||||
`bronze` / `silber` / ... (0x615d00..) increment them too.
|
||||
- Vanilla offers a difficulty by **hero level**, not by completion:
|
||||
thresholds `0xad2dc4..0xad2dd4` (5 dwords, parsed from the balance data key
|
||||
@@ -123,6 +126,28 @@ Decided (2026-10-19, follow-up):
|
||||
without a world starts a new game with the shared hero.
|
||||
- The save key and the Esc menu ("Spielstände" becomes "Speichern") save
|
||||
directly, without the save window.
|
||||
- Main menu: "Kampagne Ancaria", "Kampagne Underworld" and "Spielstände"
|
||||
become one "Spielen": the characters, loaded or started in either campaign
|
||||
on any difficulty; new characters from there too. A character file holds a
|
||||
world per campaign and difficulty; the format must take more difficulties
|
||||
later. Decided: no level requirement for the Underworld (vanilla: hero level
|
||||
25, 0x70c48b), story completions open the next difficulty in both campaigns,
|
||||
"Neuer Charakter" is a button in the Spielen window.
|
||||
|
||||
### Campaigns (measured / read 2026-10-20)
|
||||
|
||||
- `g_campaign` 0x182cdc8 (u16): 0 Ancaria, 1 Underworld. The main menu's
|
||||
event handler (0x711fa0) sets it by entry name before an entry handles an
|
||||
event (UI_LABEL_NEWGAME 0, UI_LABEL_NEWADDON 1); both entries show
|
||||
UI_CHARACTER. `cEngine::Save` writes it to the save header +0x24 (2
|
||||
Ancaria, 3 Underworld, 0x61a5c5), `cEngine::Load` sets it from there
|
||||
(0x61bafd). The load window lists only saves of the running campaign
|
||||
(0x71e2b9).
|
||||
- Story completions are counted per difficulty only (the same counter for
|
||||
both campaigns).
|
||||
- Main menu (cUI_MainMenu, entries built by 0x712f30): cUI_StaticText64FX
|
||||
entries (constructor 0x753d70), name +0x30, rect +0x24 (0x20 apart), text
|
||||
id +0x78, command +0x88; main page vector +0x158, children +0x90.
|
||||
|
||||
## Implementation (`src/mods/characters.cpp`, `charfile.{h,cpp}`)
|
||||
|
||||
@@ -130,33 +155,97 @@ Decided (2026-10-19, follow-up):
|
||||
|
||||
- `Documents\Sacred\save\characters\CharacterNN.sacredK`: "SKCH", version 1,
|
||||
section count, then `{name[16], offset, size}` entries. Sections: `meta`
|
||||
(JSON: format, name UTF-8, class, level, lastDifficulty, migratedFrom,
|
||||
heroFrom), `hero` (AMH as `cEngine::SaveHero` writes it), `world<d>` (AMS as
|
||||
`cEngine::Save` writes it). Written to a temp file, then moved over.
|
||||
- `CharacterNN.autosave.sacredK`: same layout, one world (`meta.difficulty`).
|
||||
(JSON: format, name UTF-8, class, level, lastCampaign, lastDifficulty,
|
||||
migratedFrom, heroFrom, heroFromCampaign), `hero` (AMH as
|
||||
`cEngine::SaveHero` writes it), one world per campaign and difficulty,
|
||||
`ancaria<d>` / `underworld<d>` with d = `g_gameLevel` (AMS as
|
||||
`cEngine::Save` writes it). Sections are named, not slotted: a higher
|
||||
difficulty (or another campaign) is one more section. Files of the first
|
||||
format named Ancaria's worlds `world<d>`; they are renamed when read
|
||||
(charfile.cpp) and written under the new name at the next save. Written to
|
||||
a temp file, then moved over.
|
||||
- `CharacterNN.autosave.sacredK`: same layout, one world (`meta.campaign`,
|
||||
`meta.difficulty`).
|
||||
- The game's save folder is redirected to `characters\_work` (userfolders);
|
||||
the game reads and writes `world.PAK` / `hero.pax` there.
|
||||
- Deleted characters move to `characters\deleted\`.
|
||||
|
||||
### Load window (cUI_Savegame)
|
||||
### Main menu (`src/mods/mainmenu.cpp`)
|
||||
|
||||
- Before each scan (0x71e330) one preview `GAMEnn.PAK` per character is
|
||||
written into `_work` (header, thumbnail, world time, description
|
||||
"Name - Klasse"); row nn = character nn. The row's second line (call
|
||||
0x71db2a to SetText 0x736f00) shows "Stufe N - difficulty".
|
||||
- Laden (action 4 / 0xb) opens the difficulty dialog when there is more than
|
||||
one choice: worlds, newer autosaves "(Autosave)", and "(neu)" for every
|
||||
difficulty up to max(highest world, Silber) without a world (needs the
|
||||
shared hero; not in multiplayer, byte 0x182cb6c).
|
||||
- "Umbenennen" (below Löschen; not for the character being played) asks for
|
||||
a name; keys go to the field through the input key capture. The name is
|
||||
only in the hero header chunk 0xc3 (+8, UTF-16, 0x40 chars): rewritten in
|
||||
the hero, every world and the autosave, plus `meta.name`. Confirmed: the
|
||||
game loads renamed characters without complaint.
|
||||
- Dialog buttons are cUI_Button2 copies of the load button
|
||||
(`game::NewButtonLike`); their captions are our own text ids with
|
||||
overrides (texts.cpp checks overrides for our ids too).
|
||||
After the build (0x712f30) the NEWGAME and NEWADDON entries are removed (out
|
||||
of both vectors, deleted) and UI_LABEL_LOADGAME ("Spielstände", shows
|
||||
UI_WND_SAVEGAME) moves to the top with the text "Spielen"; the others move up,
|
||||
keeping the gap before "Beenden".
|
||||
|
||||
### Spielen screen (cUI_Savegame)
|
||||
|
||||
Request 2026-10-20: redesign the load window, 16:9, using the space (layout
|
||||
agreed: characters left, the character in the middle, worlds right, buttons
|
||||
below; our own background). Built on the shared screen kit
|
||||
(docs/screens.md); the window's own drawing and mouse handling are not used.
|
||||
|
||||
- The window still scans: before each scan (0x71e330) one preview
|
||||
`GAMEnn.PAK` per character is written into `_work`; row nn = character nn.
|
||||
Its entries are only needed for the load.
|
||||
- Left: the characters, newest save first (name, class, level; "im Spiel" for
|
||||
the one being played). Click selects, double click plays.
|
||||
- Middle: name, class and level, the world played last with its date, and
|
||||
the hero in 3D with its equipment (2026-10-20): the game's cUI_NetGranny
|
||||
(game/ui/granny.h), the control the export screen and the multiplayer
|
||||
lobby showed heroes with. The character's hero file goes to
|
||||
`_workpreview.pax`, LoadHeroFile (path, 0, info) reads its hero info
|
||||
(class, equipment looks) without touching the player hero. Drawn without
|
||||
its name label, with the white light set, size factor 3.8 (game 2.5); its
|
||||
x is moved by (1365 - 1024) / 2, as it centres the model as in a 1024
|
||||
layout. Migrated characters without the shared hero show none until
|
||||
played.
|
||||
- Right: a grid campaigns x difficulties. A cell shows the save's date,
|
||||
"+ Autosave" when the autosave is newer, "Story abgeschlossen", "Neu" for a
|
||||
difficulty that is open but has no world, "–" when closed. Open: Bronze and
|
||||
Silber, every difficulty up to the highest with a world in either campaign,
|
||||
and the one after each difficulty whose story the shared hero completed
|
||||
(hero header +0xed..); new worlds need the shared hero, not in multiplayer
|
||||
(byte 0x182cb6c). Click selects, double click plays. With a newer autosave
|
||||
"Laden von:" picks the save or the autosave (default: the autosave).
|
||||
"Welt zurücksetzen" for a saved world (not the character being played).
|
||||
- Buttons: Neuer Charakter (main menu only), Umbenennen, Löschen (not for the
|
||||
character being played), Zurück (action 0xc), Spielen.
|
||||
- Spielen: a saved world loads through the window's load action (4, with its
|
||||
"really load?" confirmation in a game): the window's selection is set to
|
||||
its first entry and `g_loadRow` names the row, which `Engine_Load` takes
|
||||
instead of the entry's. A new world starts a game (below; in a game after
|
||||
stopping the running one). The window's own load / default actions (Enter)
|
||||
run Spielen too; its delete actions (7, 8) are ignored.
|
||||
- Dialogs over the screen: rename (keys go to the field through the input
|
||||
key capture; the name is only in the hero header chunk 0xc3 (+8, UTF-16,
|
||||
0x40 chars): rewritten in the hero, every world and the autosave, plus
|
||||
`meta.name`), campaign of a new character, confirmations of reset and
|
||||
delete. Buttons are cUI_Button2 copies of the load button
|
||||
(`game::NewButtonLike`) with our own text ids and overrides.
|
||||
- Delete moves the character file and its autosave to `charactersdeleted`.
|
||||
- Neuer Charakter: sets `g_campaign`, hides the window and shows UI_CHARACTER
|
||||
(the game's character creation, as the main menu's campaign entries did).
|
||||
The first save makes the character file.
|
||||
- Reset (decided 2026-10-20) removes the world section (a copy goes to
|
||||
`charactersdeletedCharacterNN.sacredK.<section>.PAK`) and an autosave of
|
||||
that world, and clears the shared hero's story completion of that
|
||||
difficulty (hero header +0xed + d); the game counts it per difficulty, so
|
||||
the next difficulty closes in both campaigns unless a world keeps it open.
|
||||
|
||||
### Character window (cUI_Character)
|
||||
|
||||
- Vanilla made no new heroes for the Underworld: `cUI_Character::Show`
|
||||
(vf24 0x70e2a0) switches to mode 7 in single player with `g_campaign` 1
|
||||
(0x70e3e8), the import of an exported hero of level 25+ (0x70eedc); with no
|
||||
exported heroes its slots were empty, and slots below level 25 never lit
|
||||
up (render 0x70f4df / 0x70f57c) (reported 2026-10-20). All eight campaign
|
||||
checks of the window (0x70c473, 0x70d199, 0x70e3e8, 0x70eade, 0x70eeb2,
|
||||
0x70f4df, 0x70f57c, 0x70f6aa) read a constant Ancaria instead: creation
|
||||
with all classes in both campaigns; the start (ExecuteAction 0x70d0f0,
|
||||
action 1) uses `g_campaign` as set.
|
||||
- Class selection without "Importieren" (request 2026-10-20): button +0x3a0
|
||||
(caption UI_IMPORT in modes 0/1); its modes-0/1 draw (0x70f67e) is removed,
|
||||
its click (0x70eab4 / 0x70eac7, SetMode(7)) jumps to the handler's end.
|
||||
### Load
|
||||
|
||||
`cEngine::Load` (0x61b790) of a listed row: the chosen world and the hero are
|
||||
@@ -185,13 +274,35 @@ game the character's.
|
||||
number). Story autosaves (engine event 0xf subtype 2 with the flag at +0x18,
|
||||
noted in `cEngine::ReceiveEvent`) go into the autosave file.
|
||||
|
||||
Silent autosave: 10 minutes after the last save of any kind (or the game's
|
||||
start), `SaveOnTick` does the direct save marked as an autosave, so it goes
|
||||
into the autosave file. While a window is open or a save runs it waits for
|
||||
the next tick. A character never saved yet gets its character file from it.
|
||||
Interval: options window, *Allgemein*, *Autosave (Min.)* (docs/options.md); kept in
|
||||
`sacredK.ini` `[characters] AutosaveMinutes` (default 10, 0 off).
|
||||
|
||||
Direct save (K key, Esc menu "Speichern"): on the next engine tick, as the
|
||||
quicksave does (0x618ebb): `cUI_Manager::CanOpenWindow` (0x75a690),
|
||||
`cEngine::CaptureScreen` (0x613710, 1), description +0x6a, row +0x68 = 0,
|
||||
flag +0x54 |= 0x40. The Esc menu line keeps its action (Show
|
||||
`UI_WND_SAVEGAME`); `cUI_Manager::ReceiveEvent` (0x757120) turns that into the
|
||||
direct save while a game runs. Its text is patched at 0x6bc1f7 (id 0x41b is
|
||||
used by other windows too). 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
|
||||
|
||||
@@ -202,9 +313,6 @@ kept, the newest file overall is the shared hero.
|
||||
|
||||
## Open
|
||||
|
||||
- Unlocking by story completion (counters at save header +0x88..).
|
||||
- Reset a difficulty.
|
||||
- Remove the export / import UI.
|
||||
- Silent autosave every 10 minutes.
|
||||
- Quickload (row 0) does not know characters.
|
||||
- Quickload (row 0) does not know characters (not needed, user 2026-10-20).
|
||||
- Spielen screen: scrolling the list beyond 9 characters.
|
||||
- Multiplayer (see `todo.md`).
|
||||
@@ -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
|
||||
auto-pickup frame sets a mode per category. Its frame is made like the key
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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`,
|
||||
item classes 5, 6, 8, 0xd, 0x11..0x19, 0x1b, 0x1d) is not repeated.
|
||||
- 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).
|
||||
@@ -32,3 +32,11 @@ one of them.
|
||||
- The potion is used up (flag, RemoveItem, DestroyObject as UseItem does; a
|
||||
stack loses one) only when cCreature::Teleport is called from the portal
|
||||
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
@@ -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;
|
||||
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
|
||||
travel in the save header (+0x88..+0x98) and the hero file. Today every
|
||||
difficulty up to the highest played one is offered (and Silber).
|
||||
- **Reset a difficulty.** Remove one world from a character (the shared hero
|
||||
stays); a button / dialog entry in the load window.
|
||||
- **Remove the export / import UI.** The Esc menu's "Exportieren" and the
|
||||
character screen's import (cUI_Character mode 5, `docs/char-export.md`):
|
||||
characters replace exported heroes.
|
||||
- **Silent autosave every 10 minutes** (a setting later) into the
|
||||
autosave file, without the busy dialog, like the direct save
|
||||
(`SaveOnTick`) but marked as an autosave.
|
||||
- **Quickload (row 0) does not know characters.** Ctrl+Z saves into the
|
||||
current character, but the quickload event (0x116, engine +0x68 = row,
|
||||
flag 0x200) loads `GAME00.PAK`, which is not listed; make it reload the
|
||||
current character's world (or its newer autosave).
|
||||
travel in the save header (+0x88..+0x98) and the hero file. Done
|
||||
2026-10-20 (docs/characters.md).
|
||||
- **Autosave interval in the options window.** Done 2026-10-20: Allgemein
|
||||
page, "Spielstände" frame (docs/options.md).
|
||||
- Quickload (row 0, `GAME00.PAK`) does not know characters; not needed
|
||||
(user, 2026-10-20).
|
||||
|
||||
## Two saves in a row (2026-10-20, not reproduced)
|
||||
|
||||
Once two real saves ("saved", not "autosaved") went into the same character
|
||||
1.8 s apart, about 77 s after loading, with the timed autosave off. Save
|
||||
flag 0x40 is set in cEngine::ReceiveEvent at 0x618876 (quicksave),
|
||||
0x618f08 and 0x6190c5 (save event 0xf: +0xc row, +0x18 autosave flag). A
|
||||
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
@@ -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 |
|
||||
| `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
|
||||
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
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ constexpr uint32_t WorldToScreen = 0x00623c40;
|
||||
// --- globals -----------------------------------------------------------------
|
||||
constexpr uint32_t g_dxDriver = 0x00cda99c; // DxDriver7*
|
||||
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)
|
||||
|
||||
// --- read-only constants -----------------------------------------------------
|
||||
|
||||
@@ -2,13 +2,54 @@
|
||||
// cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still
|
||||
// renders but handles no events).
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace game {
|
||||
|
||||
constexpr size_t kControlFlagsOffset = 0x10;
|
||||
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) {
|
||||
return (static_cast<const uint8_t*>(control)[kControlFlagsOffset] & kControlFlagVisible) != 0;
|
||||
|
||||
+68
-6
@@ -10,6 +10,12 @@ namespace game {
|
||||
|
||||
constexpr int kDeviceDrawPrimitive = 25; // IDirect3DDevice7 vtable slots
|
||||
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 kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1
|
||||
|
||||
@@ -19,18 +25,74 @@ struct ScreenVertex {
|
||||
float u, v;
|
||||
};
|
||||
|
||||
// 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) {
|
||||
// Draws (x0, y0)-(x1, y1) with `surface` (a texture's Direct3D surface,
|
||||
// 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);
|
||||
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
|
||||
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
|
||||
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
|
||||
ScreenVertex quad[4] = {{x0, y0, 0, 1, color, 0, 0, 0},
|
||||
{x1, y0, 0, 1, color, 0, 1, 0},
|
||||
{x0, y1, 0, 1, color, 0, 0, 1},
|
||||
{x1, y1, 0, 1, color, 0, 1, 1}};
|
||||
ScreenVertex quad[4] = {{x0, y0, 0, 1, topLeft, 0, 0, 0},
|
||||
{x1, y0, 0, 1, topRight, 0, 1, 0},
|
||||
{x0, y1, 0, 1, bottomLeft, 0, 0, 1},
|
||||
{x1, y1, 0, 1, bottomRight, 0, 1, 1}};
|
||||
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture,
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -138,6 +138,7 @@ POINT ToGame(POINT p) {
|
||||
POINT result;
|
||||
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.x -= mods::hud::MenuFrameOffsetX(); // the main menu's centred frame
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+1102
-325
File diff suppressed because it is too large.
Load diff
@@ -7,4 +7,9 @@ namespace mods::characters {
|
||||
// save window (on the next engine tick).
|
||||
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
|
||||
@@ -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
@@ -1,5 +1,6 @@
|
||||
#include "mods/charfile.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
@@ -25,8 +26,11 @@ constexpr size_t kHeroLevel = 0x0;
|
||||
constexpr size_t kHeroClass = 0x4;
|
||||
constexpr size_t kHeroName = 0x8;
|
||||
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 kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords)
|
||||
constexpr size_t kWorldDifficulty = 0x84;
|
||||
@@ -86,10 +90,13 @@ std::optional<SaveInfo> ReadSaveInfo(const Bytes& file) {
|
||||
std::memcpy(&info.heroClass, header + kHeroClass, 4);
|
||||
const auto* name = reinterpret_cast<const wchar_t*>(header + kHeroName);
|
||||
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';
|
||||
if (info.world) {
|
||||
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;
|
||||
SYSTEMTIME time{};
|
||||
WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek,
|
||||
@@ -116,9 +123,24 @@ bool SetHeroName(Bytes& file, const std::wstring& name) {
|
||||
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));
|
||||
preview.resize(kChunkData);
|
||||
PutU32(preview, kWorldCampaign, CampaignSaveMode(campaign));
|
||||
std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize);
|
||||
size_t entry = kChunkTable;
|
||||
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;
|
||||
}
|
||||
|
||||
std::string WorldSection(int difficulty) {
|
||||
return "world" + std::to_string(difficulty);
|
||||
std::string WorldSection(int campaign, int 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() {
|
||||
sections_.clear();
|
||||
std::ifstream file(path_, std::ios::binary);
|
||||
@@ -181,7 +220,7 @@ bool CharacterFile::Open() {
|
||||
}
|
||||
Section section;
|
||||
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(§ion.offset, entry + kSectionNameSize, 4);
|
||||
std::memcpy(§ion.size, entry + kSectionNameSize + 4, 4);
|
||||
sections_.push_back(std::move(section));
|
||||
|
||||
+25
-4
@@ -2,6 +2,7 @@
|
||||
// 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.
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -16,6 +17,11 @@ using Bytes = std::vector<uint8_t>;
|
||||
// Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4).
|
||||
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
|
||||
// their hero: header 0x100 bytes, then a table of 64 chunks {type, offset,
|
||||
// size}; chunk 0xc3 is the hero header.
|
||||
@@ -23,9 +29,12 @@ struct SaveInfo {
|
||||
std::wstring name; // hero header +8, UTF-16
|
||||
uint32_t heroClass = 0; // hero header +4: 1 Seraphim .. 9 Daemonin
|
||||
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
|
||||
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
|
||||
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;
|
||||
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
|
||||
// date, its thumbnail and world time, and `description` (ANSI, up to 63
|
||||
// characters) as the save's name.
|
||||
Bytes BuildPreview(const Bytes& world, const std::string& description);
|
||||
// characters) as the save's name. The load window lists only saves of the
|
||||
// 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).
|
||||
std::optional<Bytes> ReadFile(const std::string& path);
|
||||
@@ -77,6 +96,8 @@ private:
|
||||
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
|
||||
+159
-17
@@ -21,6 +21,7 @@
|
||||
// Enabled=1
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
@@ -30,6 +31,8 @@
|
||||
#include "game/addresses.h"
|
||||
#include "game/render/dx_driver7.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "mods/hud.h"
|
||||
#include "mods/screen.h"
|
||||
#include "mods/widescreen.h"
|
||||
#include "re/config.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).
|
||||
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) {
|
||||
static const bool enabled = re::ConfigInt("hud", "Enabled", 1) != 0;
|
||||
const int width = mods::widescreen::RenderWidth();
|
||||
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() ||
|
||||
mods::widescreen::MenuIsShowing() || anchor == Anchor::Screen) {
|
||||
if (anchor == Anchor::Canvas) {
|
||||
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 anchor == Anchor::Center ? (width - kLayoutWidth) / 2 : width - kLayoutWidth;
|
||||
@@ -263,6 +277,29 @@ private:
|
||||
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
|
||||
// their count (0: no mouse event).
|
||||
int MouseEventXOffsets(void* event, int (&offsets)[2]) {
|
||||
@@ -341,6 +378,9 @@ size_t VertexSize(DWORD fvf) {
|
||||
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.
|
||||
// Primitives covering the whole 1024x768 layout (e.g. the dimming behind
|
||||
// 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]);
|
||||
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;
|
||||
for (DWORD i = 0; i < count; ++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;
|
||||
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,
|
||||
DWORD flags) {
|
||||
if (BoundTextureHidden()) {
|
||||
return S_OK;
|
||||
}
|
||||
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,
|
||||
WORD* indices, DWORD indexCount, DWORD flags) {
|
||||
if (BoundTextureHidden()) {
|
||||
return S_OK;
|
||||
}
|
||||
return g_drawIndexedPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, indices,
|
||||
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);
|
||||
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
|
||||
// 1024 frame with a picture: the render target left and right of the frame is
|
||||
// cleared black, and the window draws with a viewport on the frame. The
|
||||
// RAII for full-screen windows (options in a game) that fill the 1024 frame
|
||||
// with a picture: the menu screens' background (mods/screen.h) goes under
|
||||
// it, and the window draws with a viewport on the frame. The
|
||||
// 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
|
||||
// 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 {
|
||||
public:
|
||||
FrameScope(void* window, void* device, int dx) {
|
||||
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;
|
||||
}
|
||||
auto getViewport = reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]);
|
||||
if (FAILED(getViewport(device, &previous_))) {
|
||||
return;
|
||||
}
|
||||
const LONG height = static_cast<LONG>(previous_.height);
|
||||
Rect7 sides[] = {{0, 0, dx, height},
|
||||
{dx + kLayoutWidth, 0, mods::widescreen::RenderWidth(), height}};
|
||||
g_clear(device, 2, sides, kClearTarget, 0, 1.0f, 0);
|
||||
mods::screen::DrawBackground(device);
|
||||
Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous_.minZ, previous_.maxZ};
|
||||
device_ = device;
|
||||
g_setViewport(device, &frame);
|
||||
++g_hidingPictures;
|
||||
}
|
||||
~FrameScope() {
|
||||
if (device_) {
|
||||
--g_hidingPictures;
|
||||
g_setViewport(device_, &previous_);
|
||||
}
|
||||
}
|
||||
@@ -504,6 +587,10 @@ void OnDevice(void* device) {
|
||||
g_drawIndexedPrimitive = reinterpret_cast<DrawIndexedPrimitiveFn>(vtable[26]);
|
||||
re::AttachRuntimeHook("D3D7 DrawIndexedPrimitive", reinterpret_cast<void**>(&g_drawIndexedPrimitive),
|
||||
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.
|
||||
g_clear = reinterpret_cast<ClearFn>(vtable[10]);
|
||||
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);
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
// The item carried by the mouse cursor (0x6555e0, drawn by the render loop
|
||||
// after the UI) is placed like UI items: relative to x = 512 of the layout.
|
||||
// Items are only picked up from centred windows.
|
||||
// The cursor and the item it carries (0x6555e0, drawn by the render loop
|
||||
// after the UI) are placed like UI items: relative to x = 512 of the layout.
|
||||
// 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);
|
||||
RE_HOOK(0x006555e0, MouseCursor_DrawItem);
|
||||
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));
|
||||
return MouseCursor_DrawItem_original(mods::hud::CursorFor(self), edx, a, b);
|
||||
}
|
||||
|
||||
+25
-2
@@ -26,8 +26,31 @@ int VisibleDialogTooltips(void** out, int max);
|
||||
// shifted) or event handler (mouse position shifted by the window's offset).
|
||||
int RenderCentred(void* window, 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
|
||||
// with a viewport on it (as the save game and export screens).
|
||||
// RenderCentred for full-screen windows: the menu screens' background beside
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
@@ -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
@@ -1,5 +1,6 @@
|
||||
// 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
|
||||
// mods/input.cpp, which had only been settable in sacredK.ini.
|
||||
//
|
||||
@@ -32,9 +33,11 @@
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/draw.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "game/ui/frame.h"
|
||||
#include "game/ui/tabs.h"
|
||||
#include "game/ui/text.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "mods/characters.h"
|
||||
#include "mods/hud.h"
|
||||
#include "mods/input.h"
|
||||
#include "mods/lootfilter.h"
|
||||
@@ -55,21 +58,15 @@ constexpr size_t kOkButton = 0x15c;
|
||||
constexpr size_t kPickupRadios[] = {0x16c, 0x170, 0x174};
|
||||
constexpr const char* kPickupControlNames[] = {"UI_LABEL_COLLECT", "UI_FRAME_SEP_AUTOCOLLECT"};
|
||||
constexpr size_t kControlName = 0x30; // char[32], set by 0x732090
|
||||
// cUI_Frame (vtable 0x8955ac): its render (0x73a540) doesn't test the visible
|
||||
// flag but +0xdc (set to 1 by its constructors, read nowhere else).
|
||||
constexpr uint32_t kFrameVtable = 0x008955ac;
|
||||
constexpr size_t kFrameDrawn = 0xdc;
|
||||
// cUI_Frame (game/ui/frame.h) draws by +0xdc, not by the visible flag.
|
||||
// The key bindings page gets a translucent backdrop and a frame per column,
|
||||
// made as 0x7184e0 makes the others: cUI_Window2 0x724ed0 (rect*, colour;
|
||||
// ret 8) adds a backdrop (cUI_Static) to +0x90; frames are cUI_Frame
|
||||
// (0xe0 bytes, constructor 0x739f90 (name, type 1, rect*, 1), ret 0x10)
|
||||
// ret 8) adds a backdrop (cUI_Static) to +0x90; frames (game/ui/frame.h)
|
||||
// pushed onto +0x90 (vector push_back 0x6bcf50, (&pointer), ret 4).
|
||||
// (Drawing the existing ones at other rects did not fit: a frame's border
|
||||
// pieces keep the place they got when it was made.)
|
||||
constexpr uint32_t kBackdropColour = 0x9f000000;
|
||||
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
|
||||
// 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) {
|
||||
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):
|
||||
@@ -224,7 +221,7 @@ void ShowPage(Window& w, uint16_t page) {
|
||||
c.hiddenByUs = false;
|
||||
}
|
||||
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)};
|
||||
using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t);
|
||||
reinterpret_cast<AddBackdropFn>(0x00724ed0)(window, nullptr, &backdrop, kBackdropColour);
|
||||
if (void* memory = game::GameNew(kFrameSize)) {
|
||||
using FrameFn = void*(__fastcall*)(void*, void*, const char*, uint32_t, game::UiRect*, uint32_t);
|
||||
PushControl(window, reinterpret_cast<FrameFn>(0x00739f90)(memory, nullptr, name, kMainFrame, &frame, 1));
|
||||
if (uint8_t* control = game::NewFrame(name, frame)) {
|
||||
PushControl(window, control);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---------------------------------------------------------------------
|
||||
|
||||
struct RenderCall {
|
||||
@@ -588,6 +705,7 @@ int RenderBody(void* context) {
|
||||
if (visible && !w->wasVisible) {
|
||||
LoadPending();
|
||||
LoadLootPending();
|
||||
g_autosavePending = mods::characters::AutosaveMinutes();
|
||||
}
|
||||
w->wasVisible = visible;
|
||||
const uint16_t page = game::ActiveTab(w->sheet);
|
||||
@@ -600,6 +718,9 @@ int RenderBody(void* context) {
|
||||
if (!visible || page != kPageLoot) {
|
||||
g_lootHover = -1;
|
||||
}
|
||||
if (!visible || page != kPageCommon) {
|
||||
g_savesHover = -1;
|
||||
}
|
||||
if (!visible) {
|
||||
return result;
|
||||
}
|
||||
@@ -608,6 +729,8 @@ int RenderBody(void* context) {
|
||||
DrawKeysPage(*w, call->device);
|
||||
} else if (page == kPageLoot && g_pendingLoaded) {
|
||||
DrawLootPage(*w, call->device);
|
||||
} else if (page == kPageCommon && g_pendingLoaded) {
|
||||
DrawSaves(*w, call->device);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -649,6 +772,10 @@ int EventBody(void* context) {
|
||||
g_pendingLoaded && HandleLootEvent(*w, call->event)) {
|
||||
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 &&
|
||||
g_pendingLoaded) {
|
||||
switch (game::MouseEventKind(call->event)) {
|
||||
@@ -704,6 +831,7 @@ void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, Ui
|
||||
Classify(w);
|
||||
CreateKeyControls(w);
|
||||
CreateLootControls(w);
|
||||
CreateSavesControls(w);
|
||||
CreateTabs(w);
|
||||
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) {
|
||||
mods::input::SetBindingKeys(g_pending);
|
||||
mods::lootfilter::SetModes(g_lootPending);
|
||||
mods::characters::SetAutosaveMinutes(g_autosavePending);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -166,9 +166,17 @@ void Load() {
|
||||
}
|
||||
|
||||
// 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() {
|
||||
g_loaded = false;
|
||||
g_loadPending = true;
|
||||
if (g_constructed) {
|
||||
ClearGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// The chest grid's place and size, as the cUI_Chest constructor sets them for
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// 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
|
||||
// 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,
|
||||
// b), ret 0xc). Choosing a portal sends the engine an event whose case
|
||||
// (0x619671, "EE_USEPORTAL") teleports the hero with cCreature::Teleport
|
||||
@@ -26,11 +27,13 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/addresses.h"
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/items.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "mods/charfile.h"
|
||||
#include "mods/skillslots.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
@@ -55,7 +58,8 @@ constexpr uint32_t kUiEventBusyDialog = 0x39;
|
||||
constexpr uintptr_t kBusyDialogName = 0x0094d700; // "DEFAULT_BUSYDLG"
|
||||
constexpr uintptr_t kNoSender = 0x00aa24bc; // the game's empty name
|
||||
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 kUiEventString = 0x68;
|
||||
|
||||
@@ -65,6 +69,13 @@ constexpr uintptr_t kPortalTeleportReturn = 0x006197c8;
|
||||
constexpr size_t kControlFlags = 0x10;
|
||||
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
|
||||
// closes; and the dialog, once it showed for it.
|
||||
uint32_t g_pending = 0;
|
||||
@@ -77,7 +88,7 @@ void OpenPortalWindow(uint32_t knownPortals) {
|
||||
uint32_t, uint32_t, uint32_t);
|
||||
reinterpret_cast<CtorFn>(0x00553080)(event, nullptr, kUiEventBusyDialog, reinterpret_cast<const char*>(kBusyDialogName),
|
||||
reinterpret_cast<const char*>(kNoSender),
|
||||
kBusyDialogSetMode, kBusyModePortals, knownPortals, 0, 0, 0);
|
||||
kBusyDialogSetMode, PortalMode(), knownPortals, 0, 0, 0);
|
||||
game::PostEvent(event);
|
||||
using FreeFn = void(__fastcall*)(void*, void*);
|
||||
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;
|
||||
if (g_pending) {
|
||||
g_dialog = static_cast<uint8_t*>(self);
|
||||
if (mode != kBusyModePortals) {
|
||||
if (mode != kBusyModePortals && mode != kBusyModeUnderworldPortals) {
|
||||
g_pending = 0;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-13
@@ -63,12 +63,13 @@ bool g_pushesPatched = false;
|
||||
int g_renderWidth = 0;
|
||||
int g_renderHeight = 0;
|
||||
|
||||
// Frames from the main menu thread and the loading screen are laid out for
|
||||
// 1024x768 ("classic"); everything else is the widened in-game view.
|
||||
// The loading screen is laid out for 1024x768 ("classic"); the main menu
|
||||
// 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;
|
||||
// Whether the most recent scene (on any thread) was a classic one: the main
|
||||
// menu is showing. UI input is processed outside the menu thread.
|
||||
std::atomic<bool> g_lastSceneClassic = false;
|
||||
// Whether the most recent scene (on any thread) was the main menu's or the
|
||||
// loading screen's. UI input is processed outside the menu thread.
|
||||
std::atomic<bool> g_lastSceneMenu = false;
|
||||
thread_local int g_loadingScreenDepth = 0;
|
||||
|
||||
// D3DVIEWPORT7 (d3d.h is not in current Windows SDKs).
|
||||
@@ -339,14 +340,15 @@ int RenderWidth() {
|
||||
}
|
||||
|
||||
bool MenuIsShowing() {
|
||||
return g_lastSceneClassic;
|
||||
return g_lastSceneMenu;
|
||||
}
|
||||
|
||||
bool IsClassicFrame() {
|
||||
if (g_renderWidth == 0) {
|
||||
return false;
|
||||
return g_renderWidth != 0 && g_loadingScreenDepth > 0;
|
||||
}
|
||||
return g_loadingScreenDepth > 0 || GetCurrentThreadId() == g_menuThread.load();
|
||||
|
||||
bool IsMenuThread() {
|
||||
return GetCurrentThreadId() == g_menuThread.load();
|
||||
}
|
||||
|
||||
} // namespace mods::widescreen
|
||||
@@ -371,13 +373,13 @@ void __fastcall DxDriver7_LoadingScreen(DxDriver7* self, void* edx, uint32_t pro
|
||||
--g_loadingScreenDepth;
|
||||
}
|
||||
|
||||
// Menus draw their 3D parts for a 1024x768 screen: give them a 1024x768
|
||||
// viewport (the borderless mod then presents just that area, scaled), and the
|
||||
// world the full render size.
|
||||
// The loading screen draws for a 1024x768 screen: give it a 1024x768
|
||||
// viewport (the borderless mod then presents just that area, scaled), and
|
||||
// everything else the full render size.
|
||||
int __fastcall DxDriver7_BeginScene(DxDriver7* self, void* edx) {
|
||||
mods::hud::OnDevice(self->device);
|
||||
if (self->sceneDepth == 0) {
|
||||
g_lastSceneClassic = IsClassicFrame();
|
||||
g_lastSceneMenu = IsClassicFrame() || IsMenuThread();
|
||||
}
|
||||
if (g_renderWidth != 0 && self->sceneDepth == 0 && self->device) {
|
||||
const bool classic = IsClassicFrame();
|
||||
|
||||
@@ -11,15 +11,18 @@ namespace mods::widescreen {
|
||||
// the world view to match. No-op when disabled.
|
||||
void ApplyRenderSize(game::DxDisplayMode& mode, HWND window);
|
||||
|
||||
// True while the current thread renders a frame laid out for 1024x768 (main
|
||||
// menu, loading screen); the borderless mod then presents only that area.
|
||||
// True while the current thread renders a frame laid out for 1024x768 (the
|
||||
// loading screen); the borderless mod then presents only that area.
|
||||
bool IsClassicFrame();
|
||||
|
||||
// Applied render width (0 while the original 1024x768 is in use).
|
||||
int RenderWidth();
|
||||
|
||||
// True while the main menu / loading screen (classic layout) is on screen,
|
||||
// regardless of the calling thread.
|
||||
// True while the main menu or the loading screen is on screen, regardless of
|
||||
// the calling thread.
|
||||
bool MenuIsShowing();
|
||||
|
||||
// True on the main menu's render thread (cUI_Manager's thread loop).
|
||||
bool IsMenuThread();
|
||||
|
||||
} // namespace mods::widescreen
|
||||
+28
-1
@@ -302,7 +302,7 @@
|
||||
0x006e7730 cUI_Tooltip::Layout # measures the text, width = longest line + 8 unless flag 0x100
|
||||
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)
|
||||
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)
|
||||
@@ -334,13 +334,40 @@
|
||||
0x0061b790 cEngine::Load # (path, quiet), ret 8: reads a GAME.PAK
|
||||
0x00619c90 cEngine::Save # (path), ret 4; render thread, engine +0x54 & 0x40
|
||||
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
|
||||
0x0071e330 cUI_Savegame::Scan # (dir, multiplayer), ret 8: "./SAVE/*.PAK"
|
||||
0x0071be90 cUI_Savegame::SendSaveLoad # (kind, row): engine event 0xf
|
||||
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
|
||||
0x007d7cf0 Session::SetHeroClass # (class) -> +0x44, ret 4
|
||||
0x00417e50 GameSetup_Instance # 0x12e6c bytes; +0 set by both character-screen starts
|
||||
0x006bb870 cUI_EscMenu::BuildLines # line actions; "Spielstaende" text 0x41b at 0x6bc1f7
|
||||
0x00810af0 BringWindowToFront # (hwnd, timeout), ret 8; loops forever with timeout -1
|
||||
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)
|
||||
Reference in new issue
Block a user