Compare commits

...
10 Commits
Author SHA1 Message Date
khallaandClaude Opus 5.5 70673dc301 Rename project to sacredK
sacred_re.dll/.exe/.ini/.log/_crash files are now sacredK.*; inventory saves
with the old "sacred_re.inventory" format id still load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 22:07:14 +02:00
khallaandClaude Opus 5.5 462ebf8c0b Docs: hard-coded game keys left after the key bindings page
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 00:40:03 +02:00
khallaandClaude Opus 5.5 838b1aa491 Options: tabs Allgemein / Beute / Tastenbelegung with key bindings
- Options window in three tabs; the auto-pickup settings move to Beute;
  sliders move with their page; black sides like the save game screen.
- Tastenbelegung: every binding settable (click, press key; Esc cancels,
  Backspace unbinds), "Alle zurücksetzen" as a game button, saved on OK.
- Potions bindable (default unbound), named by the game's potion texts; the
  game's own potion hotkeys are off; bar tooltips show the bound key.
- Map (M), overview map (Tab, held) and labels (Alt, held) bindable; the
  game's own M / Tab / Alt handling is switched off.
- Save game key calls the game's save window code instead of a posted key.
- Teleportationstrank: fixed text replacement from startup (also the
  Ausdauertrank items, potion kind 5 as well).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 00:38:34 +02:00
khallaandClaude Opus 5.5 72525cd5ba Export screen: widescreen frame, black sides, slots can be cleared
- cUI_Character (export) and cUI_Savegame draw inside the centred 1024x768
  frame (viewport) with the render target beside it cleared black; the
  export screen's 3D heroes were stretched across the wide viewport, away
  from their labels and hit areas.
- Export screen shows the game's erase button for an occupied slot and
  reloads the heroes after a delete. Overwriting was already the game's
  own path once clicks hit the right slot.
- game/ui/control.h: shared show/hide for cUI_Control2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 23:26:53 +02:00
khallaandClaude Opus 5.5 c875d7d6b9 Art tooltips: "Verbessert durch" lists what improves each art
Arts are objects (game::Art) built from the game's definitions: name,
launch type, damage source, regeneration group and the skills' art lists.
The tooltip gets one line per effect of a skill or attribute, sorted by
size; unlearned class skills are greyed out at 0%. Weapon arts list only
the damage types the held weapon deals and the held weapon's skill.
Tooltip line wrap ignores colour codes; art tooltips wrap wider.
Blitzschlag's tooltip rewrite becomes a tooltip filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 23:04:22 +02:00
khallaandClaude Opus 5.5 5d80a33e9b Blitzschlag: chains of one cast take separate routes
The chains of a cast share the enemies they have claimed and prefer
unclaimed ones. Each chain keeps its heading and ranks candidates by
distance and turn; a chain's first jump turns away from the directions
the other chains set out in, so they keep fanning out. Jumps reach 75 %
of the cast range.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 03:44:16 +02:00
khallaandClaude Opus 5.5 4018e23e6b Docs: how the survival bonus is calculated and stored
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 03:26:10 +02:00
khallaandClaude Opus 5.5 84c50d8c33 WASD: direction movement steers the walk path directly
The keys rewrite the hero's path every cCreature::Update: one segment
along the held direction, the facing set to it, sliding along blocked
cells. Starting from idle runs the game's own Think steps at once
(StartPendingWalk, WalkAction) instead of waiting up to 110 ms, so
weapon switches no longer stop the walk. Stops wait 50 ms after the
keys are released, and a stop in progress is taken back when keys are
held again. Names for movement, stats and text-key functions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 03:18:51 +02:00
khallaandClaude Opus 5.5 d296e2850b WASD: resume the walk right after a weapon set switch (untested in-game)
Switching between weapon classes resets the hero's animation state inside
cUI_Taskbar2::SelectWeaponSet, which dropped the walk for a tick. The hook
sends the WASD walk again at once. Checkpoint before the movement rework.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 01:41:13 +02:00
khallaandClaude Opus 5.5 184c7058fd Auto-attack: bow shots chain at full speed, arts start by their cooldown; hang watchdog
- The shot end's button check (0x4def52) answers "held" for the auto-attack
  target, like the melee swing end.
- An art order counts as started when the art's cooldown rises, so bow arts
  and arts ordered mid-swing hand over to auto-attack.
- re/crash.cpp: a watchdog writes sacred_re_hang.dmp when the engine tick
  stops for 10 s while the game is in front.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 01:29:29 +02:00
72 changed files with 3482 additions and 405 deletions

No files matched your search

+1 -1
View File
@@ -3,6 +3,6 @@
/.idea/ /.idea/
/cmake-build-*/ /cmake-build-*/
*.rea-snapshot *.rea-snapshot
sacred_re.ini sacredK.ini
/ghidra/ /ghidra/
/screenshots/ /screenshots/
+3 -3
View File
@@ -3,10 +3,10 @@
"configurations": [ "configurations": [
{ {
// Builds, then starts the game through the launcher (no debugger in the game). // Builds, then starts the game through the launcher (no debugger in the game).
"name": "Run Sacred (sacred_re)", "name": "Run Sacred (sacredK)",
"type": "cppvsdbg", "type": "cppvsdbg",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/build/x86-debug/sacred_re.exe", "program": "${workspaceFolder}/build/x86-debug/sacredK.exe",
"args": ["--game-dir", "D:\\Gaming\\Sacred Gold", "log"], "args": ["--game-dir", "D:\\Gaming\\Sacred Gold", "log"],
"cwd": "${workspaceFolder}/build/x86-debug", "cwd": "${workspaceFolder}/build/x86-debug",
"preLaunchTask": "CMake: build", "preLaunchTask": "CMake: build",
@@ -14,7 +14,7 @@
}, },
{ {
// Debug our DLL inside the game: set [debug] WaitForDebugger=1 in // Debug our DLL inside the game: set [debug] WaitForDebugger=1 in
// build/<preset>/sacred_re.ini, start "Run Sacred", then run this and // build/<preset>/sacredK.ini, start "Run Sacred", then run this and
// pick sacred.exe. The DLL waits (up to 60 s) before installing hooks. // pick sacred.exe. The DLL waits (up to 60 s) before installing hooks.
"name": "Attach to sacred.exe", "name": "Attach to sacred.exe",
"type": "cppvsdbg", "type": "cppvsdbg",
+6 -6
View File
@@ -6,8 +6,8 @@ project, autotest) first; this file adds how we work and what we learned.
## What this is ## What this is
`sacred_re.dll` is injected into the original **Sacred Gold 2.28** `sacredK.dll` is injected into the original **Sacred Gold 2.28**
`sacred.exe` (2006, 32-bit, MSVC 6) by the launcher `sacred_re.exe`. It `sacred.exe` (2006, 32-bit, MSVC 6) by the launcher `sacredK.exe`. It
hooks and patches the running game: some functions are rewritten, most of hooks and patches the running game: some functions are rewritten, most of
the work so far is **mods** — behaviour changes on top of the original the work so far is **mods** — behaviour changes on top of the original
(widescreen, HUD layout, 7 weapon sets / art slots, key bindings, WASD, (widescreen, HUD layout, 7 weapon sets / art slots, key bindings, WASD,
@@ -15,7 +15,7 @@ auto-attack, skill reworks, respec, map fixes, ...). The game stays playable
at every step; the user plays it to test. at every step; the user plays it to test.
- Target binary: the original `sacred.exe` in `D:\Gaming\Sacred Gold\` - Target binary: the original `sacred.exe` in `D:\Gaming\Sacred Gold\`
(path in `build\x86-debug\sacred_re.ini`, `[paths] GameDir`). Ignore the (path in `build\x86-debug\sacredK.ini`, `[paths] GameDir`). Ignore the
`SacredReborn*` files in that folder. `SacredReborn*` files in that folder.
- Game texts are German (Fertigkeiten, Attribute, Blitzschlag, ...); keep the - Game texts are German (Fertigkeiten, Attribute, Blitzschlag, ...); keep the
game's names when talking about features. game's names when talking about features.
@@ -65,12 +65,12 @@ at every step; the user plays it to test.
```powershell ```powershell
.\build.ps1 # x86-debug -> build\x86-debug\ .\build.ps1 # x86-debug -> build\x86-debug\
build\x86-debug\sacred_re.exe log # start the game with the DLL build\x86-debug\sacredK.exe log # start the game with the DLL
``` ```
New source files must be added to `CMakeLists.txt`. Output to check: New source files must be added to `CMakeLists.txt`. Output to check:
`build\x86-debug\sacred_re.log` (written anew each start; our lines plus the `build\x86-debug\sacredK.log` (written anew each start; our lines plus the
game's own log). Crashes: `sacred_re_crash.txt` / `.dmp` next to the DLL game's own log). Crashes: `sacredK_crash.txt` / `.dmp` next to the DLL
(exception, module+offset, ebp chain); Windows also records the faulting (exception, module+offset, ebp chain); Windows also records the faulting
offset in the Application event log (`Get-WinEvent`, provider "Application offset in the Application event log (`Get-WinEvent`, provider "Application
Error"). The debug build's run-time checks turn calling-convention mistakes Error"). The debug build's run-time checks turn calling-convention mistakes
+20 -16
View File
@@ -1,12 +1,12 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(sacred_re LANGUAGES C CXX) project(sacredK LANGUAGES C CXX)
# sacred.exe is a 32-bit process; everything we inject into it must be 32-bit too. # sacred.exe is a 32-bit process; everything we inject into it must be 32-bit too.
if(NOT CMAKE_SIZEOF_VOID_P EQUAL 4) if(NOT CMAKE_SIZEOF_VOID_P EQUAL 4)
message(FATAL_ERROR "sacred_re must be built for 32-bit x86 (use the 'x86-*' presets / an x86 MSVC environment).") message(FATAL_ERROR "sacredK must be built for 32-bit x86 (use the 'x86-*' presets / an x86 MSVC environment).")
endif() endif()
if(NOT MSVC) if(NOT MSVC)
message(FATAL_ERROR "sacred_re requires MSVC (the game's C++ ABI is MSVC's).") message(FATAL_ERROR "sacredK requires MSVC (the game's C++ ABI is MSVC's).")
endif() endif()
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
@@ -19,17 +19,17 @@ add_compile_definitions(WIN32_LEAN_AND_MEAN NOMINMAX _CRT_SECURE_NO_WARNINGS)
include(cmake/Detours.cmake) include(cmake/Detours.cmake)
include(cmake/Json.cmake) include(cmake/Json.cmake)
set(SACRED_RE_CXX_FLAGS /W4 /permissive- /utf-8) set(SACREDK_CXX_FLAGS /W4 /permissive- /utf-8)
# --- sacred_re.dll: injected into sacred.exe, installs the hooks ------------- # --- sacredK.dll: injected into sacred.exe, installs the hooks -------------
add_library(sacred_re SHARED add_library(sacredK SHARED
src/re/dllmain.cpp src/re/dllmain.cpp
src/re/hook.cpp src/re/hook.cpp
src/re/log.cpp src/re/log.cpp
src/re/config.cpp src/re/config.cpp
src/re/patch.cpp src/re/patch.cpp
src/re/crash.cpp src/re/crash.cpp
src/re/sacred_re.def src/re/sacredK.def
src/game/engine/log.cpp src/game/engine/log.cpp
src/game/engine/cmdline.cpp src/game/engine/cmdline.cpp
src/game/engine/events.cpp src/game/engine/events.cpp
@@ -38,6 +38,7 @@ add_library(sacred_re SHARED
src/game/engine/items.cpp src/game/engine/items.cpp
src/game/engine/stream.cpp src/game/engine/stream.cpp
src/game/ui/texts.cpp src/game/ui/texts.cpp
src/game/engine/art.cpp
src/mods/borderless.cpp src/mods/borderless.cpp
src/mods/widescreen.cpp src/mods/widescreen.cpp
src/mods/hud.cpp src/mods/hud.cpp
@@ -58,6 +59,7 @@ add_library(sacred_re SHARED
src/mods/combos.cpp src/mods/combos.cpp
src/mods/aurahits.cpp src/mods/aurahits.cpp
src/mods/chainlightning.cpp src/mods/chainlightning.cpp
src/mods/arttooltip.cpp
src/mods/escape.cpp src/mods/escape.cpp
src/mods/recordcache.cpp src/mods/recordcache.cpp
src/mods/megamap.cpp src/mods/megamap.cpp
@@ -65,14 +67,16 @@ add_library(sacred_re SHARED
src/mods/runetrade.cpp src/mods/runetrade.cpp
src/mods/teleportpotion.cpp src/mods/teleportpotion.cpp
src/mods/guards.cpp src/mods/guards.cpp
src/mods/charexport.cpp
src/mods/options.cpp
) )
target_include_directories(sacred_re PRIVATE src) target_include_directories(sacredK PRIVATE src)
target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS}) target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
target_link_libraries(sacred_re PRIVATE detours nlohmann_json::nlohmann_json dbghelp) target_link_libraries(sacredK PRIVATE detours nlohmann_json::nlohmann_json dbghelp)
# --- sacred_re.exe: launches sacred.exe with sacred_re.dll injected ---------- # --- sacredK.exe: launches sacred.exe with sacredK.dll injected ----------
add_executable(sacred_re_launcher WIN32 src/launcher/main.cpp) add_executable(sacredK_launcher WIN32 src/launcher/main.cpp)
set_target_properties(sacred_re_launcher PROPERTIES OUTPUT_NAME sacred_re) set_target_properties(sacredK_launcher PROPERTIES OUTPUT_NAME sacredK)
target_compile_options(sacred_re_launcher PRIVATE ${SACRED_RE_CXX_FLAGS}) target_compile_options(sacredK_launcher PRIVATE ${SACREDK_CXX_FLAGS})
target_link_libraries(sacred_re_launcher PRIVATE detours) target_link_libraries(sacredK_launcher PRIVATE detours)
add_dependencies(sacred_re_launcher sacred_re) add_dependencies(sacredK_launcher sacredK)
+7 -7
View File
@@ -1,8 +1,8 @@
# sacred_re # sacredK
An incremental reimplementation of **Sacred Gold 2.28** (`sacred.exe`, 2006). An incremental reimplementation of **Sacred Gold 2.28** (`sacred.exe`, 2006).
`sacred_re.dll` is injected into the original game at startup and replaces game `sacredK.dll` is injected into the original game at startup and replaces game
functions with rewritten C++ one by one. Everything not yet rewritten keeps functions with rewritten C++ one by one. Everything not yet rewritten keeps
running as original machine code, so the game stays playable at every step and running as original machine code, so the game stays playable at every step and
each rewrite can be tested in-game immediately. Once enough is rewritten, the each rewrite can be tested in-game immediately. Once enough is rewritten, the
@@ -42,14 +42,14 @@ the CMake presets.
## Running ## Running
```powershell ```powershell
build\x86-debug\sacred_re.exe --game-dir "D:\Gaming\Sacred Gold" # first run; remembered in sacred_re.ini build\x86-debug\sacredK.exe --game-dir "D:\Gaming\Sacred Gold" # first run; remembered in sacredK.ini
build\x86-debug\sacred_re.exe log # later runs; extra args go to the game build\x86-debug\sacredK.exe log # later runs; extra args go to the game
``` ```
`sacred_re.log` (next to the DLL) records what was hooked plus every message the `sacredK.log` (next to the DLL) records what was hooked plus every message the
game sends to its internal logger, even when the game's own logging is off. game sends to its internal logger, even when the game's own logging is off.
Hooks can be switched off individually in `sacred_re.ini` (next to the DLL) Hooks can be switched off individually in `sacredK.ini` (next to the DLL)
to compare a rewrite against the original: to compare a rewrite against the original:
```ini ```ini
@@ -61,7 +61,7 @@ CmdLineHasFlag=0
``` ```
src/re/ hook framework, injection-side startup, logging, config src/re/ hook framework, injection-side startup, logging, config
src/launcher/ sacred_re.exe — starts sacred.exe with the DLL injected src/launcher/ sacredK.exe — starts sacred.exe with the DLL injected
src/game/ rewritten game code and original-layout structs, by subsystem src/game/ rewritten game code and original-layout structs, by subsystem
src/mods/ behaviour changes on top of the original (e.g. borderless.cpp) src/mods/ behaviour changes on top of the original (e.g. borderless.cpp)
docs/ reverse-engineering notes; function-map.md tracks progress docs/ reverse-engineering notes; function-map.md tracks progress
+118
View File
@@ -0,0 +1,118 @@
# What improves an art (mods/arttooltip.cpp)
Request of 2026-10-18: "edit ALL skill tooltips, to show which attributes and
abilities improve them in which way".
Follow-ups the same day: arts as objects carrying their effects, name and
tooltip, no art-id checks in code; a dense block, one line per Fertigkeit or
attribute ("Stärke -> Schaden +12%"); only skills and attributes that act on
the art; unlearned skills (of the hero's class) with 0% and greyed out;
lines sorted by impact. Then: only "Regenerationszeit" (faster
regeneration rates are shown as the time they save), no attribute flavor
texts; one line per effect (an attribute with two effects gets two lines,
each sorted by its own size); a wider tooltip so the lines don't wrap.
## Design
- `game::Art` (game/engine/art.{h,cpp}): built on demand by `FindArt(id)`
from the game's definitions (name text SpellDef / CombatMoveDef +4, launch
type, spell or weapon damage values, regeneration group).
`Improvements(creature)` gives every (skill or attribute, stat, percent)
acting on the art for that hero; `Tooltip` = the game's text, the mods'
filters (`AddTooltipFilter`, e.g. Blitzschlag's values), then the block.
- Art ids appear only in data: the game's per-skill art lists and three
exceptions (Energieblitze regenerates as magic; 0x3fa and 0x38 take weapon
damage factors), both in art.cpp.
- Weapon skills count only with their weapon in hand (the game's checks
0x54a3e0 / 0x54a220 / 0x54a2f0 / 0x54a2e0 / 0x54a110, weapon class); only
the skills of the weapon in hand are listed. Their second curve (SP) is the attack speed
(+0x3c; the skill text labels it with text 0x44c, the same "speed" text the
magic schools use for +0x3e).
- mods/arttooltip.cpp hooks ArtTooltip and hands the text to the art.
- Width: tooltips with flag 0x80 wrap in cUI_Tooltip::FormatText
(`0x6e6630`): the first space after 45 characters of a line becomes a line
break (`cmp ecx, 5Ah` on the line's bytes at `0x6e687d`); the layout
(`0x6e7730`) then sizes the window to the longest line + 8. Colour codes
(`\cAARRGGBB`, 10 characters) counted there; the mod replaces the check so
they don't, and texts containing "Verbessert durch:" wrap after 56.
## Sources
Texts: `scripts/us/global.res` holds the German texts (despite the folder):
`"SZ"`, then 16-byte entries {key hash, offset, 0, length}, the UTF-16 text at
offset + 4. Skill names are game text `0x24b7 + skill id` (character panel
`0x6abc8d`); attribute names `UI_ATTRIB_STRENGTH`, `_RESISTANCE`,
`_GESCHICK`, `_PHYREG`, `_MAGREG`, `_CHARISMA`.
Skill ids (0x565f30's switch, names in file order): 1 Himmelsmagie,
2 Waffenkunde, 3 Stangenwaffen, 4 Schwertkunde, 5 Axtkampf,
6 Zwei-Waffen-Kampf, 7 Fernkampf, 8 Wendigkeit, 9 Parade, 0xa Konstitution,
0xb Rüstung, 0xc Meditation, 0xd Klingenkampf, 0xe Magiekunde, 0xf
Feuermagie, 0x10 Wassermagie, 0x11 Erdmagie, 0x12 Luftmagie, 0x13 Mondmagie,
0x14 Vampirismus, 0x15 Handel, 0x16 Reiten, 0x17 Entwaffnung, 0x18
Faustkampf, 0x19 Konzentration, 0x1a Ballistik, 0x1b Fallenkunst, 0x1c
Blutrausch, 0x1d Waffentechnik, 0x1e Zweihandwaffen, 0x1f Zwergenkunde, 0x20
Höllenmacht, 0x21 Schmiedekunst.
Balance values (globals `0xad2400..`, parsed as `key=value` around `0x5eb4xx`;
no Balance.txt ships, so the defaults stand). Skill curves are 12-byte
{start `<k>off`, half `<k>_s`, limit `<k>_w`} at `0xad2450 + 12 n`: WK
Waffenkunde, MK Magiekunde, FK/SK/AK/KK/STK/BK/FEK weapon skills (AW / SP),
HM/FM/EM/WM/LM/MM schools, BA, BL, KO Konzentration, Med Meditation, W, HR,
HP, P, R (resistances), then the price tables; HOM `0xad368c`, WT `0xad3698`.
Value(level) = 0 below 1, else start + 2 (limit − start)(1 − 1/((level − 1)/half + 1))
percent (DiminishingCurve `0x565c30`).
## How the game applies them
cCreatureArts::Recalculate (`0x579560`), then ApplySkill (`0x565f30`) per
skill row with level = bought + item levels:
- Attributes (+0x10 u16[6]) = invested (+0x1c) + base (+0x4a) + base ×
(level − 1) / 10, plus items. Hero level +0x56.
- Damage factors, 4 types (physical, fire, magic, poison):
weapon / combat arts +0x96 + +0xa6, spells +0x96 + +0xb6 (ArtTooltip
multiplies spell values by these; arts 0x3fa and 0x38 by the combat ones).
+0x96 = 1 + items. Attribute shares with F = (156 − BalStatOff) × level / 156
+ BalStatOff + 9 and invested points p, base b, item points q:
- +0xa6 physical: (b F/10 + p + q) of Stärke, or Geschick when
HoldsRangedWeapon (`0x54a110`), / BalCharPD
- +0xaa fire: (2 p + b F/10 + q) of Stärke and Geschick / BalCharFD
- +0xae magic: (b F/10 + p + q) of Mentale Regeneration / BalCharMD
- +0xb2 poison: (2 p + b F/10 + q) of Widerstand and Charisma / BalCharGD
- +0xb6..+0xc2 spells: (b F/10 + p) of Mentale Regeneration / BalCharMD
- Waffenkunde adds its % to +0xa6..+0xb2, Magiekunde to +0xb6..+0xc2.
- Regeneration rates: +0xee magic = (1 + items + Meditation %) × (1 +
Mentale Regeneration / 100), +0xf2 combat = (1 + items) × (1 + Physische
Regeneration / 100). Creature type 8 (Zwerg) gets +0xee = +0xf2.
- Regeneration time: ApplySkillToArts (`0x565c80`) divides each listed art's
cooldown total by 1 + value / 100. With the attack flag it also adds curve
FEK..SP (`0xad2504`) to +0x3e while the art is the selected art slot. Lists
(art ids) per skill:
- Himmelsmagie (HM, attack): d f 11 12 13 14 15 16 3fb
- Feuermagie (FM, attack): 5 b 17 18; Wassermagie (WM): 1b 4 22 23;
Erdmagie (EM): 2 10 19 1a; Luftmagie (LM): 1 1c 1d 21;
Mondmagie (MM): 3 7 24..2a
- Vampirismus (KO): 3ff 401 403 405 40e..411 408 2b 406 407 409..40d
- Konzentration (KO): 3e8..3fa 3fc 421..434 41e 3fe 400 402 404 412..416
443 446 444 449 44e 44a 44d 44b 44f 44c 436 437 438 43a 43b 43c 441
- Ballistik (BA, attack) and Fallenkunst (KO): 417..41d
- Blutrausch (BL, attack): 3fe 2b 408 406 407 409..40d
- Waffentechnik (WT, attack) and Zwergenkunde (KO): 43f 43e 43d 37..3b
- Höllenmacht (HOM): 3c..43
- The weapon skills (Stangenwaffen, Schwertkunde, Axtkampf, ...) raise the
attack value (+0xe6, AW curve) and attack speed (+0x3c, SP curve) while
their weapon is held; listed for weapon arts with that weapon in hand.
- The held weapon's damage per type (physical, fire, magic, poison) is at
cCreatureArts +0x76 + 4 t (max) and +0x86 + 4 t (min); the damage range
functions (0x563e90 .. 0x564160) multiply them by +0x96 + +0xa6 + 4 t. The
attributes' damage lines are listed only for the types the weapon deals.
Measured 2026-10-18 (diag log): +0xa6..+0xb2 and +0xb6 equal the sums of the
listed shares exactly (e.g. 5.422 = Waffenkunde 300.96% + Stärke 241.21%),
Physische Regeneration matches +0xf2 / (1 + items).
Art definitions: ArtSpellDef (arts.h) as ArtTooltip finds the spell values
(own SpellDef, or the one CombatMoveDef::LinkedSpell `0x4d1c50` names);
damage values 4 × (base, per level) s16 at SpellDef +0x42.
+25
View File
@@ -85,3 +85,28 @@ Rules (`autoattack.cpp`):
nearest enemy close by. Ranged: the same with a ranged weapon; with a melee nearest enemy close by. Ranged: the same with a ranged weapon; with a melee
weapon only an enemy already in close range (the art's target if it is, weapon only an enemy already in close range (the art's target if it is,
else the nearest), otherwise auto-attack stops. Until the art starts, no attack orders are sent. else the nearest), otherwise auto-attack stops. Until the art starts, no attack orders are sent.
## Bow speed and arts that start differently (2026-10-18)
User: auto-attack after spells not fully reliable; with a ranged weapon it
does not reach full speed; bow arts (Spinnenpfeil) do not start it.
Measured (order log + a log of every `cMouseCursor::IsButtonHeld` caller):
- A shot with a bow is action 5. Before the next shot the game checks the
button at `0x4def52` (shooter esi, target creature edi): held and the target
hostile -> it shoots again. A held button fired 2 s without new orders;
auto-attack sent one order per 0.67 s. `0x4def89` right after retargets to
the creature under the cursor when held.
- Several arts (0x42f, 0x3ee) never showed action 4 or 8, so the cast watch
waited its 5 s and auto-attack stopped. Arts ordered during a swing or cast
were taken as started by that action.
Now:
- `0x4def52` is redirected like the melee swing end (`SwingContinues`):
"held" for the auto-attack target. `0x4def89` is left alone.
- An art counts as started when its cooldown rises (`GetArtCooldown`
remaining above the value at the order, which follows regeneration). Arts
without a cooldown entry: the hero went into a new action other than
walking or idle.
+22
View File
@@ -53,6 +53,24 @@ ends (a lone enemy is hit once). Two enemies: it bounces between them.
are created at the first jump targets of the other chains (copies of the are created at the first jump targets of the other chains (copies of the
event with [14] = N - 1); their first hop has no arc. event with [14] = N - 1); their first hop has no arc.
Separate routes (2026-10-18). Request: the chains should not all follow the
same route. Before, each chain only avoided the enemies it had hit itself, and
only the extra chains' first targets differed. After that, parallel chains
picked the same nearest enemies and could swap onto each other's route. Now
the chains of one cast share a list of claimed enemies (the first target, and
every target as soon as a chain picks it). A jump prefers an enemy no chain
of the cast has claimed, then one this chain has not hit, then any other,
then the current one.
Follow-up (2026-10-18): "only on the first spread, then they most times merge
together again". The nearest free enemy of two chains often lies in the same
group. Each chain now keeps a heading (caster -> first target for the main
chain, then the direction of its last jump), and the candidates of a tier are
ranked by distance × (1 + 2 × turn), with turn = 0 straight ahead and 1
straight back. A chain's first jump has no heading yet; there the turn is the
largest closeness to the directions the cast's chains set out in, so the
branches fan away from the main chain and from each other.
First version (bolts copied for every jump, not using the game's chain) First version (bolts copied for every jump, not using the game's chain)
replaced the same day: it doubled the jumps. replaced the same day: it doubled the jumps.
@@ -91,3 +109,7 @@ step of the ray point).
Follow-up (2026-10-17): jumps at 900 felt too far. The cast keeps 900; jumps Follow-up (2026-10-17): jumps at 900 felt too far. The cast keeps 900; jumps
use `JumpRange()` = half the cast range (450). The tooltip shows both use `JumpRange()` = half the cast range (450). The tooltip shows both
("Reichweite", "Sprungweite"). ("Reichweite", "Sprungweite").
Follow-up (2026-10-18): with several chains, half the cast range left too
few separate targets ("maybe chain range needs to increase to 75% of cast
range"). Jumps now reach 75 % of the cast range (675).
+48
View File
@@ -0,0 +1,48 @@
# 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.
+16
View File
@@ -126,3 +126,19 @@ offset, that reset reached past the render target, `SetViewport` failed and
the small viewport stayed set: the cursor (drawn after the UI) was only the small viewport stayed set: the cursor (drawn after the UI) was only
visible inside it. A viewport at x 0 across the whole render width is now visible inside it. A viewport at x 0 across the whole render width is now
passed unshifted. passed unshifted.
## Full-screen windows: frame viewport and black sides (2026-10-18)
The character export screen (Esc menu "Exportieren", cUI_Character) and the
save game window (cUI_Savegame) cover the 1024 frame with a picture. Request:
black instead of the world beside it, and the export screen adapted to the
wide view. Measured on a screenshot: the export screen's 3D scene (heroes on
their stones) is projected for a 1024x768 viewport; across the widened one
it was stretched 1.8x wide, away from the slot labels and their hit areas.
Their render hooks (`HUD_RENDER_FRAMED`, `HUD_RENDER3_FRAMED`) run the window
inside `FrameScope`: while the window is visible, the render target left and
right of the centred frame is cleared black, then the viewport is set to the
frame (dx, 0, 1024x768) and restored afterwards. `Clear` clips to the
viewport, so the sides are cleared first. Both calls go to the unhooked
device methods (`IDirect3DDevice7` 10 Clear, 13 SetViewport, 15 GetViewport).
+111
View File
@@ -0,0 +1,111 @@
# Options window: tabs and key bindings
Request (2026-10-18): the settings screen gets three tabs: *Allgemein*,
*Beute* (loot filter; first iteration: the game's loot options moved there
from the common page) and *Tastenbelegung* (key bindings, so far only
settable in `sacredK.ini`). Code: `src/mods/options.cpp`; bindings in
`src/mods/input.cpp`.
## The window (cUI_Options)
- vtable `0x89505c`, constructor `0x716570` (name, rect, parent, flags;
`ret 0x18`), destructor `0x7165f0`; controls built by `0x7184e0`.
- vf44 `0x716640` (renderer, ?, ?; `ret 0xc`): window render (`0x727040`),
then drives the sliders while the mouse is held. vf10 `0x716b30`: events.
- `0x717ea0` loads the settings into the controls when the window is shown;
`0x717460` (thiscall, plain `ret`) writes them back — called for OK.
- Controls: vector at `+0x90` (begin) / `+0x94` (end); the 16 background tiles
are in the window's own list `+0x78`. Buttons: `+0x158` Cancel, `+0x15c` OK.
Pickup: radio buttons `+0x16c/+0x170/+0x174` (`PICKUPAUTO` 0/1/2),
checkbox `+0x178` (`PICKUPANIM`).
- Layout (1024x768): frames at x `0x40` / `0x220`, y `0x40` / `0x120` /
`0x200`, borders `0x14` outside, labels above (y `0x20` / `0x100` /
`0x1e0`). Left: graphics, auto-pickup (*AUTOCOLLECT*), general; right: sound
volume, 3D sound, internet. OK / Cancel at y `0x284`.
- cUI_Frame (vtable `0x8955ac`): its render `0x73a540` does not test the
visible flag, only `+0xdc` (set to 1 by its constructors).
## How the mod works
- After the constructor: every control in `+0x90` is put on a page by its
position — left column middle row → *Beute*, everything else → *Allgemein*,
OK / Cancel on all pages. The auto-pickup group moves up one row (to the top
of its page), the general group below it moves up into its place.
- A cUI_TabSheet (`game/ui/tabs.h`) at the top frames' border; its headers
(default row layout) sit above the frame labels.
- Each frame (vf44) the controls not on the active page are hidden (visible
flag, frames also `+0xdc`); controls hidden by the mod are shown again on
their page. The game re-shows controls when the window opens, so this runs
every frame.
- *Tastenbelegung* is drawn by the mod: rows per binding, grouped
(Kampfkünste, Waffensets, Bewegung, Aktionen), a key box per row. A click on
a box captures the next key press (`input::SetKeyCapture`, swallowed before
the game sees it): Esc cancels, Backspace unbinds, a bindable key is taken
(and removed from any other action). The page works on a copy loaded when
the window opens; OK (`0x717460`) saves it to `[keys]` in `sacredK.ini`,
Cancel drops it.
- Movement keys (W/A/S/D) are bindings now (`MoveUp/Left/Down/Right`);
`mods/wasd.cpp` polls the bound keys.
## Follow-ups (2026-10-18)
- Pages moved down 24 px (`kContentShift`) for room below the tab headers.
- *Alle zurücksetzen* (left of OK / Cancel) sets every binding back to its
default; saved with OK like any other change.
- Potion bindings *Trank 1–5* and *Trank 1 für Gefährten* (default: no key).
They call `cEngine::UseActionSlot` (`0x613040`, thiscall (kind, on ally),
`ret 8`) on the engine tick. The game's own potion hotkeys are off: its
engine hotkey switch (`cEngine::ReceiveEvent`, index bytes `0x619adc` per
upper-case character − 0x20, targets `0x619ac0`) had Space → kind 1 (Shift:
5), B → kind 1 on the weakest ally, Q 2, W 3, E 4, R 5; those six entries
now point at index 6 (no hotkey). `ActionSlot3` (W's action on G) is gone.
- The window is drawn like the save game / export screens: black beside the
1024 frame (`hud::RenderFramed`).
- Key bindings page: a backdrop (`0x724ed0`) and a frame (`0x739f90`) per
column, created with the window's controls and shown only on that page.
(Drawing the window's existing ones at other rects did not fit: a frame's
border pieces keep the place they were made at.)
- Pages move down 24 px under the tab headers. A slider (cUI_Scrollbar,
vtable `0x895664`) moves with its parts: minus, plus, track and knob are
embedded controls (+0x94 / +0x110 / +0x18c / +0x208) with rects in window
coordinates, and the knob is placed from +0x78 / +0x7c each frame.
- Key page layout (2026-10-19): the two frames span the other pages' frame
area (top row's top to the bottom of *Internet-Optionen*); the hints sit
in the bottom row next to *Alle zurücksetzen*, since they hold for the
whole page.
- Potion kinds are named by the bottom bar's tooltip texts:
`PotionTooltipText` `0x43fa90` (kind) → text id by key, e.g.
`<color=FFFFFF80>Heiltrank <colorw=FFFFFFFF>[SPC]` (kinds 1–5: Heiltrank,
Sog der Hölle, Trank des Mentors, Antidot der Viper, Trank der
Konzentration / Teleportationstrank; read in `scripts/de/SRglbl.res`).
Shown, the tags are colour codes (backslash, letter, 8 hex digits:
`\dFFFFFF80Heiltrank \wFFFFFFFF[SPC]`); `game::PotionKindName` strips them and the key; the tooltips get the bound key
in the brackets, or none (`game::SetTextOverride`, mods/potioncounts.cpp).
- No posted game keys any more: *Spiel speichern* calls what the UI manager's
S case (`0x757775`) does: save window built (manager slot `0x2b`),
`CanOpenWindow` `0x75a690`, engine event (vtable `0x88eb38`, 0x13, 5, 1),
then window command *show* (0x11) for `UI_WND_SAVEGAME`
(`game/ui/windows.h`, shared with escape.cpp's *hide* 0x12).
## Karte, Minikarte, Beschriftungen (2026-10-19)
Request: M (map), Tab (minimap) and Alt (text overlay) bindable too.
- M: the UI manager's letter switch (`cUI_Manager::ReceiveEvent` 0x757120,
upper-case character − 'C', index bytes 0x75868c, targets 0x758660) shows
`UI_WND_MEGAMAP` (slot 0x25) unless a blocking window is open; while the map
is open its own branch (cmp 'M' at 0x757f86) hides it. Switched off (M →
index 10, the compare → -1); the *Karte* binding does the same checks and
sends the window command.
- Tab: the in-game window procedure 0x8110d0 shows `UI_WND_OVERVIEWMAP` on
key down (switch on vk − 9: index bytes 0x812124, case 0x81146d) and hides
it on key up (0x812270, case 0x8117f8). Switched off (Tab → the "other key"
indices 4 / 3); *Minikarte (halten)* shows / hides it on the bound key's
down / up.
- Alt: no message, the game polls `GetAsyncKeyState(VK_MENU)` for its name
overlay in seven places (DrawObjectLabels 0x627260, PickObjectAt 0x626d30,
cEngine 0x60f1d0 / 0x611f10 / 0x617360, cCreature 0x599880, the map's debug
teleport 0x6c7ec0). Their `call [GetAsyncKeyState]` read our pointer
instead: `LabelsKeyState` returns the bound key's state.
- Alt and F10 come as WM_SYSKEYDOWN: the key capture takes those too.
- Key page: 20 lines per column (line 25 px) to fit the three rows.
+1 -1
View File
@@ -55,7 +55,7 @@ was empty after loading another character (in-game saves don't call saveHero).
the chest drew at the wrong spot after some loads. Both tabs' grids are put the chest drew at the wrong spot after some loads. Both tabs' grids are put
back to the constructor's values every tick and on each tab switch. back to the constructor's values every tick and on each tab switch.
- `MoveItem` from/to 0x11 while the shared tab is open moves from/to it. - `MoveItem` from/to 0x11 while the shared tab is open moves from/to it.
- File: `{"format": "sacred_re.shared_chest", "version": 1, "items": [{cell, - File: `{"format": "sacredK.shared_chest", "version": 1, "items": [{cell,
w, h, type, data (hex of vf1c), count}]}`; written after each successful w, h, type, data (hex of vf1c), count}]}`; written after each successful
`saveHero` and after each save-game write (in-game saves don't call saveHero; `saveHero` and after each save-game write (in-game saves don't call saveHero;
via a temp file), read on the first engine tick with a hero after a session via a temp file), read on the first engine tick with a hero after a session
+50
View File
@@ -0,0 +1,50 @@
# Survival bonus (Überlebensbonus)
Request (2026-10-18): "how is survival bonus calculated? please reset the
death counter on battlemage savegame and restore the survivability bonus".
## Where it shows
The tooltip of the Waffe / Kampfkunst / Resistenz block in the character
window (`cUI_StatisticsChar::Render` 0x6a9be0), only while the bonus is above
0. Text key "1159" (id 0x4407730). Text keys are numbers or names hashed by
`TextKeyHash` 0x80e010: `h = (h * 113 + toupper(c)) % 999999991`, then
`& 0x7fffffff`. `TextManager::GetTextByKey` 0x672fd0 looks them up.
## Formula
`cStatsManager` (one record at 0xaa3518, `cStatsManager::Instance` 0x425160)
keeps the time survived in ms at +0x570c. With m = whole minutes survived:
bonus % = 0 for m < 1
= 100 * (1 - 1 / ((m - 1) / 120 + 1)) otherwise
That is `DiminishingCurve` 0x565c30 (min 0, half 120, max 50, doubled). It
gives 50 % after 2 h and 75 % after 6 h, approaching 100 %. The experience for
a kill uses the same curve (0x534454), scaled by a global factor at 0xad362c.
The window adds 10 × a difficulty factor (1.34 / 1.67 / 2 / 2.34) when a hero
byte (+0x4f8 array, index 0x1c) is at least 6; what that byte means is not
known yet.
Measured: the survival time only grows in combat. `cStatsManager::AddTime`
(0x424930, (float s, alive), ret 8) is called from the engine tick (0x60e732)
with alive = hero +0x200 bit 0x8000. 0x547ce6 sets that bit while 0xad1c64 is
below 350 (or 1000), and 0x547ed8 clears it otherwise. 0xad1c64 is most
likely the distance to the nearest enemy. The play time (+0x56f8 h, +0x56fc
min, +0x5700 ms) always grows.
A death (`cStatsManager::OnHeroDeath` 0x4245a0, called from
`cCreature::ReceiveOrder`) counts one at +0x56f4 and zeroes the survival time.
## In the save game (GAME0x.PAK, uncompressed)
- Header ("AMS", version 0x1b) at the start: survival time in seconds at
0x44, loaded by `cEngine::load` 0x61b790 through
`cStatsManager::SetSurvivalSeconds` 0x424540.
- The statistics block (`cStatsManager::Serialize` 0x424ce0) ends with the
magic 0xF00DF00D. The deaths are 26 bytes before the magic, the play
hours 22, the minutes 18 and the ms 14.
- `ItemDataMgr::saveHero` 0x6046f0 appends a 0x22c-byte hero header (name in
UTF-16 at +0x48) and a 0x40-byte block (deaths, survival seconds, spawn
counter). The block is all zeros in single-player saves and is not where
the survival time is loaded from.
+7 -4
View File
@@ -19,10 +19,13 @@ one of them.
## Mod (src/mods/teleportpotion.cpp) ## Mod (src/mods/teleportpotion.cpp)
- Every item type of potion kind 5 counts. Their names (text 0x428700 of each - Every item type of potion kind 5 counts. Their name, "Trank der
type) are replaced in every game text (`game::ReplaceInTexts`): the item Konzentration", is replaced in every game text from the DLL's start
names and the bottom bar's quick-select tooltip ("Trank der Konzentration (`game::ReplaceInTexts`, applied where the game fetches a text): the item
[R]", its own text id). The shortest name is replaced, so stage words stay. names (stage words in front stay) and the bottom bar's quick-select tooltip
("Trank der Konzentration [R]", its own text id). Before (until
2026-10-19) the names were collected from the item types at game start, so
the main menu (options' key bindings) still had the old name.
- cCreature::UseItem (hooked in itemstack.cpp) asks `teleportpotion::UseItem` - cCreature::UseItem (hooked in itemstack.cpp) asks `teleportpotion::UseItem`
first: for the hero's Teleportationstrank it posts the teleporter's event first: for the hero's Teleportationstrank it posts the teleporter's event
with the hero's mask instead of the drink. with the hero's mask instead of the drink.
+30 -5
View File
@@ -93,7 +93,7 @@ mouse/keyboard like `tools/autotest-load.ps1`:
2. play scripted input through the existing hooks (keys, casting arts, weapon 2. play scripted input through the existing hooks (keys, casting arts, weapon
set switches, save + reload); set switches, save + reload);
3. take back-buffer screenshots at checkpoints and log checks to 3. take back-buffer screenshots at checkpoints and log checks to
`sacred_re.log` (aura effect present after reload, slot 6-7 inventories `sacredK.log` (aura effect present after reload, slot 6-7 inventories
survive a save, ...); survive a save, ...);
4. exit the game. 4. exit the game.
@@ -116,15 +116,40 @@ W, A and S were game hotkeys. With WASD movement (`src/mods/wasd.cpp`) their
actions moved to other keys for now; choose final bindings (and update the actions moved to other keys for now; choose final bindings (and update the
in-game texts/tooltips that name the old keys): in-game texts/tooltips that name the old keys):
| Key | Action | Temporary key (sacred_re.ini) | | Key | Action | Temporary key (sacredK.ini) |
|---|---|---| |---|---|---|
| W | action slot 3 (`cEngine::UseActionSlot`, like Q/E/R/B/Space) | G (`[keys] ActionSlot3`) | | W | potion kind 3 (`cEngine::UseActionSlot`, like Q/E/R/B/Space) | game potion keys switched off; `[keys] Potion1-5` (docs/options.md) |
| A | pick up items nearby (creature event 0x113) | Q (`[keys] PickUp`, 2026-10-17; was V) | | A | pick up items nearby (creature event 0x113) | Q (`[keys] PickUp`, 2026-10-17; was V) |
| S | save game window (`UI_WND_SAVEGAME`) | K (`[keys] SaveGame`) | | S | save game window (`UI_WND_SAVEGAME`) | K (`[keys] SaveGame`) |
| D | unbound | - | | D | unbound | - |
Globally bound letters (engine `0x618130`, UI manager `0x757120`): A B C E F ## Hard-coded game keys left (2026-10-19)
H I L M N O P Q R S T W, Space, ^, digits. Individual windows may use more.
Noted after the key bindings page (docs/options.md): these game keys are
still hard-coded, not on the page. Each would go the way M / Tab / Alt went:
switch the game's key off at its source (switch index byte or compare), add
a binding that calls the same game code.
| Key | Where | What (as read, not all measured) |
|---|---|---|
| I / F / C | UI manager letter switch (`0x757120`, cases at index bytes `0x75868c` + char − 'C') | inventory window with tab 1 / 2 / 3 (`cCommand_onInvOpen`; again: close) |
| L | same | quest book `UI_WND_QUESTBOOK` toggle |
| H | same | toggles UI manager flag 0x200 (unmeasured) |
| T | same | toggles UI manager flag 0x1000 (unmeasured) |
| N | same | network info `UI_WND_NETINFO` (multiplayer only) |
| O | same, only while the options window is open | closes options (applies them) |
| S | same (`0x757775`) | save game window; works only while S is not bound (it is: *Nach unten*). *Spiel speichern* already calls this code. |
| A | engine `cEngine::ReceiveEvent` (`0x618130`, compare at `0x6188e7`) | pick up items nearby; same situation as S (A is *Nach links*); *Gegenstände aufheben* calls the code |
| P | engine (compare at `0x6188f0`) | pause, single player only |
| `` ` `` / ^ / `\` (key codes 0x60, 0x5e, 0xdc) | engine (`0x6188cf`), UI manager (`0x75758c`, `0x75759c`) | console |
| Esc | engine, UI manager `HandleEsc` | closes windows / ESC menu (escape.cpp builds on it) |
| Shift + Space | engine potion table (off with the potion keys) | - |
Digits 0-9 are taken by the skill keys (input.cpp `Reserved`). Bound keys are
swallowed before the game sees them, so a game key only acts while it is not
bound to something else: rebinding *Nach links* away from A brings back the
game's A (pickup), same for S. Individual windows may have more keys of
their own (not surveyed).
## Potion stacks ## Potion stacks
+99 -8
View File
@@ -34,15 +34,87 @@ Mouse, measured with an order log:
## What the mod does ## What the mod does
Every `cEngine::Tick`, while W/A/S/D are held (and no text field has the ### Direction movement (2026-10-18)
focus, no mouse button is held): a plain walk order to hero position +
direction x 300 world units (about a cursor's distance). Screen directions in
world axes: up (-1,-1), down (1,1), left (-1,1), right (1,-1); combinations are
summed and normalised. All keys released: the target-less stop order.
Dead ends, for the record: re-aim orders (+0x20 = 1) on their own stop the Request: "lets go all in with directional vector movement". The keys steer
hero after two orders; far targets (2000 units) are rejected or followed the hero directly instead of sending walk orders 10 times per second, which
badly; targets near the stepwise-updated position make it arrive and stop. stuttered and stopped on weapon switches.
Measured (`Place` hook, watchpoints on the hero's x and on
`g_creatureNewPosition`):
- The only writer of a creature's position is `cObjectManager::Place`
(`0x5fcd10`, thiscall (id, position*), ret 8). For walking it is called from
`cCreature::Update` (`0x594110`, thiscall (u8), ret 4) at `0x59653a`, about
40 times per second on the engine thread.
- `Update` steps the creature along its path:
- waypoints {x, y} at +0x2ed + i * 8, segment index +0x36d, count +0x371,
sub-unit remainder +0x37d / +0x381;
- speed +0x375 (337 for the battlemage) times the elapsed time.
- The step goes into `g_creatureNewPosition` (`0xad2368`, {u16 area, x, y,
u8 level}; written at `0x5953da` and `0x5955f7`). It then passes through
`cCreature::CanStepTo` (`0x593c50`, thiscall (x, y, position*), ret 0xc),
which refuses a step into another cell that `cCreature::IsCellWalkable`
(`0x4f7b20`, thiscall (cell*), ret 4, read-only) rejects. On refusal the new
position is reset to the current one (`0x59631a`). Cells are 53.66563 world
units.
- Walking straight into a fence, 27 of 28 steps were refused: the hero ran on
the spot at 2 units per update.
Now (`src/mods/wasd.cpp`, hook on `cCreature::Update` for the controlled
creature, before the original):
- Held keys give a unit direction in world axes: up (-1,-1), down (1,1),
left (-1,1), right (1,-1); combinations are summed and normalised.
- Walking: the path is replaced by one segment from the hero's position,
1000 units along the direction (index 0, count 2). The game's own step moves
the hero at its own speed and animation, and it never arrives.
- Obstacles: the way 1.25 cells ahead is probed at quarter cells with
`IsCellWalkable`. If it is blocked, the direction turned by 30, 60 and 90
degrees is tried, the side last slid to first, so the hero slides along
walls. If all are blocked: stop order.
- The facing (+0x70 / +0x74, float unit vector in world axes) is set to the
direction each update. The game turns it towards the path gradually; on a
reversal that took about 300 ms and swung through the side, so the hero
walked backwards for a moment (user report: "direction change east/west is
not instant").
- Idle (start, after an attack, cast or weapon switch): a walk order 300
units ahead, and right after it the two steps `cCreature::Think` (vf04,
`0x537430`, thiscall (float), ret 4) would take on its next run:
`cCreature::StartPendingWalk` (`0x4ede70`: pending action +0xfc 2 with the
parameters at +0x100 becomes action 1 with +0x154) and
`cCreature::WalkAction` (`0x4f45b0`, phase 0 → 1: path, walk animation).
Measured: Think runs only about every 110 ms per creature, so the walk
started 16..125 ms after the order. Now it starts in the same update. If a
status effect holds the hero, the order is retried at most every 150 ms.
- Other actions (attack, cast, shot): left alone until they end.
- Rapid key changes (user report: "not 100% smooth when rapidly changing
directions"). Measured: a moment without any key (30 ms in the test)
sent the stop order. The hero then went through the game's stop: walk
phase 0x64, action 2 with the stop animation, idle about 110 ms later,
and only then started again (about 125 ms standing per change). Now:
- the stop order waits until the keys have been released for 50 ms;
- a stop requested but not begun (walk, phase 0x64) goes back to phase 1
when keys are held again;
- a running stop (action 2) restarts like standing idle.
Mouse walking has the same uneven steps (4/6 units per update) and paired
updates, so those are the game's own rhythm.
- Keys released: the target-less walk order (stops in place), unless a mouse
button is held.
Autotest (battlemage, each key 1.5 s): about 250 units per second and almost
no refused steps. The hero faces the key direction and plays the walk
animation.
Superseded: the walk order per `cEngine::Tick` (10 Hz). Dead ends, for the
record:
- re-aim orders (+0x20 = 1) on their own stop the hero after two orders;
- far targets (2000 units) are rejected or followed badly;
- targets near the stepwise-updated position make the hero arrive and stop;
- setting the walk mode +0xfc to 2 (as while a mouse button is held) does
not change the speed.
## Hotkeys ## Hotkeys
@@ -79,3 +151,22 @@ Now (`src/mods/input.cpp`, `FocusedTextField`): keys pass to the game while the
focused control is a writable edit control that `cUI_EditControl::Render` focused control is a writable edit control that `cUI_EditControl::Render`
(0x737ae0, thiscall (renderer), ret 4, returns at once while hidden) drew in (0x737ae0, thiscall (renderer), ret 4, returns at once while hidden) drew in
the last 250 ms. The render hook only notes the focused control. the last 250 ms. The render hook only notes the focused control.
## Weapon set switch while walking (2026-10-18)
Request: the hero stops for a moment when switching weapon sets (F-keys)
while walking with WASD.
Measured (state per tick + orders): `cUI_Taskbar2::SelectWeaponSet`
(`0x6e1440`, ret 8) sends order 0x114 (set, items) and 0x107; between melee
sets the walk goes on, between a melee and a bow set the hero drops to idle
for one tick: the weapon class changed, and the switch resets the hero's
animation state (`0x54e540(1, 0)`). The next WASD tick restarts the walk.
Measured again with a watchpoint on the action word: on a weapon class
change `cCreature::ResetToIdle` (`0x54e540`) plays the idle animation and
sets action 0, phase 1, pending action 0. A user report ("still little
stops, weapon to no weapon and back") came after the restart through a walk
order, which waited for the next Think (up to 110 ms). Now direction movement
starts the walk in the same update (see above). The `SelectWeaponSet` hook
with `ResumeWalk` was removed.
+339
View File
@@ -0,0 +1,339 @@
#include "game/engine/art.h"
#include <algorithm>
#include <cmath>
#include <map>
#include <memory>
#include "game/engine/orders.h"
#include "game/engine/skills.h"
#include "game/ui/texts.h"
namespace game {
namespace {
// The game's per-skill art lists (cCreatureArts::ApplySkill 0x565f30 passes
// them to ApplySkillToArts 0x565c80): the regeneration time of each listed art
// is divided by 1 + curve / 100; skills with the speed flag also add curve
// FEK..SP to the execution speed +0x3e of the selected art. Ballistik raises
// the damage of its traps too (ArtTooltip's damage factor for them).
struct LinkedStat {
ArtStat stat;
uint32_t curve;
};
struct SkillArts {
uint8_t skill;
std::vector<LinkedStat> stats;
std::vector<uint16_t> arts;
};
const std::vector<SkillArts>& SkillArtLists() {
using S = ArtStat;
auto school = [](uint32_t curve) {
return std::vector<LinkedStat>{{S::RegenerationTime, curve}, {S::ExecutionSpeed, kCurveExecutionSpeed}};
};
static const std::vector<SkillArts> lists = {
{kHimmelsmagie, school(kCurveHimmelsmagie), {0x0d, 0x0f, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x3fb}},
{kFeuermagie, school(kCurveFeuermagie), {0x05, 0x0b, 0x17, 0x18}},
{kWassermagie, school(kCurveWassermagie), {0x1b, 0x04, 0x22, 0x23}},
{kErdmagie, school(kCurveErdmagie), {0x02, 0x10, 0x19, 0x1a}},
{kLuftmagie, school(kCurveLuftmagie), {0x01, 0x1c, 0x1d, 0x21}},
{kMondmagie, school(kCurveMondmagie), {0x03, 0x07, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a}},
{kVampirismus,
{{S::RegenerationTime, kCurveKonzentration}},
{0x3ff, 0x401, 0x403, 0x405, 0x40e, 0x40f, 0x410, 0x411, 0x408, 0x2b, 0x406, 0x407, 0x409, 0x40a, 0x40b,
0x40c, 0x40d}},
{kKonzentration,
{{S::RegenerationTime, kCurveKonzentration}},
{0x3e8, 0x3e9, 0x3ea, 0x3eb, 0x3ec, 0x3ed, 0x3ee, 0x3ef, 0x3f0, 0x3f1, 0x3f2, 0x3f3, 0x3f4, 0x3f5, 0x3f6,
0x3f7, 0x3f8, 0x3f9, 0x3fa, 0x3fc, 0x421, 0x422, 0x423, 0x424, 0x425, 0x426, 0x427, 0x428, 0x429, 0x42a,
0x42b, 0x42c, 0x42d, 0x42e, 0x42f, 0x430, 0x431, 0x432, 0x433, 0x434, 0x41e, 0x3fe, 0x400, 0x402, 0x404,
0x412, 0x413, 0x414, 0x415, 0x416, 0x443, 0x446, 0x444, 0x449, 0x44e, 0x44a, 0x44d, 0x44b, 0x44f, 0x44c,
0x436, 0x437, 0x438, 0x43a, 0x43b, 0x43c, 0x441}},
{kBallistik,
{{S::RegenerationTime, kCurveBallistik},
{S::ExecutionSpeed, kCurveExecutionSpeed},
{S::Damage, kCurveBallistik}},
{0x417, 0x418, 0x419, 0x41a, 0x41b, 0x41c, 0x41d}},
{kFallenkunst, {{S::RegenerationTime, kCurveKonzentration}}, {0x417, 0x418, 0x419, 0x41a, 0x41b, 0x41c, 0x41d}},
{kBlutrausch, school(kCurveBlutrausch), {0x3fe, 0x2b, 0x408, 0x406, 0x407, 0x409, 0x40a, 0x40b, 0x40c, 0x40d}},
{kWaffentechnik, school(kCurveWaffentechnik), {0x43f, 0x43e, 0x43d, 0x37, 0x38, 0x39, 0x3a, 0x3b}},
{kZwergenkunde,
{{S::RegenerationTime, kCurveKonzentration}},
{0x43f, 0x43e, 0x43d, 0x37, 0x38, 0x39, 0x3a, 0x3b}},
{kHoellenmacht, {{S::RegenerationTime, kCurveHoellenmacht}}, {0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, 0x43}},
};
return lists;
}
// Arts the game treats unlike their definitions say: Energieblitze counts down
// at the magic rate (+0xee) although it is a combat move (0x562998,
// ArtTooltip); 0x3fa and 0x38 take the weapon's damage factors although they
// have spell values (ArtTooltip).
constexpr uint16_t kMagicRegenerationMoves[] = {kArtEnergieblitze};
constexpr uint16_t kWeaponDamageSpells[] = {0x3fa, 0x38};
template <size_t N>
bool Contains(const uint16_t (&ids)[N], uint16_t id) {
return std::find(std::begin(ids), std::end(ids), id) != std::end(ids);
}
// cCreature weapon checks (plain thiscall, as ApplySkill calls them).
bool CreatureCheck(void* creature, uint32_t address) {
using Fn = bool(__fastcall*)(void*, void*);
return reinterpret_cast<Fn>(address)(creature, nullptr);
}
bool HoldsTwoWeapons(void* creature) {
return CreatureCheck(creature, 0x0054a2e0); // weapon class 8
}
constexpr size_t kCreatureSecondHand = 0x1d8;
constexpr int kWeaponClassBareHands = 0;
constexpr int kWeaponClassBlade = 10;
// The weapon skills: attack value (+0xe6 += AW curve %) and attack speed
// (+0x3c += SP curve, the next curve) while the matching weapon is held
// (ApplySkill cases 3..7, 0xd, 0x18).
struct WeaponSkill {
uint8_t skill;
uint32_t curve;
bool (*applies)(void* creature);
};
const WeaponSkill kWeaponSkills[] = {
{kStangenwaffen, kCurveStangenwaffenValue,
[](void* c) { return CreatureCheck(c, 0x0054a3e0) && !HoldsTwoWeapons(c); }},
{kSchwertkunde, kCurveSchwertkundeValue,
[](void* c) { return CreatureCheck(c, 0x0054a220) && !HoldsTwoWeapons(c); }},
{kAxtkampf, kCurveAxtkampfValue, [](void* c) { return CreatureCheck(c, 0x0054a2f0) && !HoldsTwoWeapons(c); }},
{kZweiWaffenKampf, kCurveZweiWaffenValue,
[](void* c) {
return HoldsTwoWeapons(c) ||
(WeaponClass(c) == kWeaponClassBareHands &&
*reinterpret_cast<const uint32_t*>(static_cast<uint8_t*>(c) + kCreatureSecondHand) != 0);
}},
{kFernkampf, kCurveFernkampfValue, [](void* c) { return HoldsRangedWeapon(c); }},
// The game also counts a blade in the second hand; not covered here.
{kKlingenkampf, kCurveKlingenkampfValue, [](void* c) { return WeaponClass(c) == kWeaponClassBlade; }},
{kFaustkampf, kCurveFaustkampfValue, [](void* c) { return WeaponClass(c) == kWeaponClassBareHands; }},
};
// The held weapon's damage per type (physical, fire, magic, poison):
// cCreatureArts +0x76 + 4 t max, +0x86 + 4 t min (floats; the damage range
// functions 0x563e90 .. 0x564160 multiply them by +0x96 + +0xa6 + 4 t).
constexpr size_t kArtsWeaponDamageMax = 0x76;
constexpr size_t kArtsWeaponDamageMin = 0x86;
bool WeaponDealsDamage(const uint8_t* arts, int damageType) {
auto value = [&](size_t offset) { return *reinterpret_cast<const float*>(arts + offset + 4 * damageType); };
return value(kArtsWeaponDamageMax) > 0.0f || value(kArtsWeaponDamageMin) > 0.0f;
}
// Recalculate (0x579560) gives creature type 8 (Zwerg) the combat rate for
// magic arts too.
constexpr size_t kCreatureType = 0x10;
constexpr uint32_t kTypeZwerg = 8;
// The attributes' shares of the damage factors (Recalculate), in percent:
// (base * F / 10 + invested * w [+ item points]) / BalChar*D with F = (156 -
// BalStatOff) * level / 156 + BalStatOff + 9.
double AttributeDamage(const uint8_t* arts, int attribute, int damageType, double investedWeight, bool items) {
const float off = BalanceValue(kBalStatOff);
const double f = (156.0 - off) * HeroLevel(arts) / 156.0 + off + 9.0;
double value = AttributeBase(arts, attribute) * f * 0.1 + investedWeight * arts[kArtsInvested + attribute];
if (items) {
value += AttributeFromItems(arts, attribute);
}
return 100.0 * value / BalanceValue(kBalCharDamage[damageType]);
}
double RegenerationTimePercent(float bonus) {
return -100.0 * (1.0 - 1.0 / (1.0 + bonus / 100.0));
}
bool Offensive(LaunchType launch) {
return launch != LaunchType::SelfOrAlly && launch != LaunchType::Corpse && launch != LaunchType::Unknown;
}
// Damage values of a spell definition: 4 types x (base, per level), s16 at +0x42.
bool HasDamageValues(const uint8_t* spell) {
const auto* values = reinterpret_cast<const int16_t*>(spell + 0x42);
return std::any_of(values, values + 8, [](int16_t v) { return v != 0; });
}
const wchar_t* StatName(ArtStat stat) {
switch (stat) {
case ArtStat::Damage:
return L"Schaden";
case ArtStat::FireDamage:
return L"Feuerschaden";
case ArtStat::MagicDamage:
return L"Magieschaden";
case ArtStat::PoisonDamage:
return L"Giftschaden";
case ArtStat::AttackValue:
return L"Angriffswert";
case ArtStat::AttackSpeed:
return L"Angriffstempo";
case ArtStat::ExecutionSpeed:
return L"Ausführungstempo";
case ArtStat::RegenerationTime:
return L"Regenerationszeit";
}
return L"";
}
// Tooltip colours (ARGB, "\c" codes as the game's tooltips use them).
constexpr const wchar_t* kHeaderColour = L"\\cFFFFFF80";
constexpr const wchar_t* kTextColour = L"\\cFFFFFFFF";
constexpr const wchar_t* kInactiveColour = L"\\cFFAFAFAF";
std::wstring ImproverName(const Improver& by) {
return by.kind == Improver::Skill ? SkillName(by.id) : AttributeName(by.id);
}
std::map<uint16_t, std::vector<Art::TooltipFilter>>& Filters() {
static std::map<uint16_t, std::vector<Art::TooltipFilter>> filters;
return filters;
}
} // namespace
Art::Art(uint16_t id) : id_(id) {
const uint8_t* spell = FindSpellDef(id);
const uint8_t* def = spell ? spell : FindCombatMoveDef(id);
nameText_ = def ? *reinterpret_cast<const uint32_t*>(def + 4) : 0; // SpellDef / CombatMoveDef +4 (0x4d24d0, 0x4d1b40)
launch_ = ArtLaunchType(id);
const uint8_t* values = ArtSpellDef(id);
spellValues_ = values && !Contains(kWeaponDamageSpells, id);
magicRegeneration_ = spell || Contains(kMagicRegenerationMoves, id);
damage_ = Offensive(launch_) && (!values || HasDamageValues(values));
for (const SkillArts& list : SkillArtLists()) {
if (std::find(list.arts.begin(), list.arts.end(), id) == list.arts.end()) {
continue;
}
for (const LinkedStat& linked : list.stats) {
if (linked.stat != ArtStat::Damage || damage_) {
links_.push_back({list.skill, linked.curve, linked.stat});
}
}
}
}
std::wstring Art::Name() const {
return nameText_ ? GameText(nameText_) : std::wstring();
}
std::vector<Improvement> Art::Improvements(void* creature) const {
auto* arts = static_cast<uint8_t*>(creature) + kCreatureArts;
const uint8_t* classSkills = ClassSkills(arts);
std::vector<Improvement> out;
auto add = [&](Improver by, ArtStat stat, double percent) {
out.push_back({by, stat, static_cast<float>(percent)});
};
auto skill = [&](uint8_t id, uint32_t curve, ArtStat stat) {
const unsigned level = SkillLevel(arts, id);
const bool ofClass =
level > 0 || (classSkills && std::find(classSkills, classSkills + kClassSkills, id) != classSkills + kClassSkills);
if (!ofClass) {
return;
}
const float value = CurveValue(curve, level);
add({Improver::Skill, id}, stat, stat == ArtStat::RegenerationTime ? RegenerationTimePercent(value) : value);
};
// A faster regeneration rate (+x %) as the time it saves.
auto rate = [&](Improver by, double percent) {
add(by, ArtStat::RegenerationTime, RegenerationTimePercent(static_cast<float>(percent)));
};
auto attribute = [&](uint8_t id, ArtStat stat, double percent) { add({Improver::Attribute, id}, stat, percent); };
if (damage_ && spellValues_) {
// Spell values times +0x96 + +0xb6: Magiekunde, Mentale Regeneration.
skill(kMagiekunde, kCurveMagiekunde, ArtStat::Damage);
attribute(kMentaleRegeneration, ArtStat::Damage, AttributeDamage(arts, kMentaleRegeneration, 2, 1.0, false));
} else if (damage_) {
// Weapon damage times +0x96 + +0xa6: Waffenkunde for all types; the
// attributes for the types the held weapon deals.
skill(kWaffenkunde, kCurveWaffenkunde, ArtStat::Damage);
if (WeaponDealsDamage(arts, 0)) {
const uint8_t physical = HoldsRangedWeapon(creature) ? kGeschick : kStaerke;
attribute(physical, ArtStat::Damage, AttributeDamage(arts, physical, 0, 1.0, true));
}
if (WeaponDealsDamage(arts, 1)) {
attribute(kStaerke, ArtStat::FireDamage, AttributeDamage(arts, kStaerke, 1, 2.0, true));
attribute(kGeschick, ArtStat::FireDamage, AttributeDamage(arts, kGeschick, 1, 2.0, true));
}
if (WeaponDealsDamage(arts, 2)) {
attribute(kMentaleRegeneration, ArtStat::MagicDamage,
AttributeDamage(arts, kMentaleRegeneration, 2, 1.0, true));
}
if (WeaponDealsDamage(arts, 3)) {
attribute(kWiderstand, ArtStat::PoisonDamage, AttributeDamage(arts, kWiderstand, 3, 2.0, true));
attribute(kCharisma, ArtStat::PoisonDamage, AttributeDamage(arts, kCharisma, 3, 2.0, true));
}
// Only the skills of the weapon in hand.
for (const WeaponSkill& weapon : kWeaponSkills) {
if (weapon.applies(creature)) {
skill(weapon.skill, weapon.curve, ArtStat::AttackValue);
skill(weapon.skill, weapon.curve + kCurveSpeedOffset, ArtStat::AttackSpeed);
}
}
skill(kWendigkeit, kCurveWendigkeitValue, ArtStat::AttackValue);
}
for (const SkillLink& link : links_) {
skill(link.skill, link.curve, link.stat);
}
// Rates: +0xee = (1 + Meditation %) * (1 + Mentale Regeneration / 100),
// +0xf2 = 1 + Physische Regeneration / 100.
const bool zwerg = *reinterpret_cast<const uint32_t*>(static_cast<uint8_t*>(creature) + kCreatureType) == kTypeZwerg;
if (magicRegeneration_ && !zwerg) {
skill(kMeditation, kCurveMeditation, ArtStat::RegenerationTime); // a rate, divides the time too
rate({Improver::Attribute, kMentaleRegeneration}, AttributeValue(arts, kMentaleRegeneration));
} else {
rate({Improver::Attribute, kPhysischeRegeneration}, AttributeValue(arts, kPhysischeRegeneration));
}
return out;
}
std::wstring Art::Tooltip(void* creature, std::wstring gameText) const {
const auto filters = Filters().find(id_);
if (filters != Filters().end()) {
for (TooltipFilter filter : filters->second) {
filter(*this, creature, gameText);
}
}
std::vector<Improvement> improvements = Improvements(creature);
if (improvements.empty()) {
return gameText;
}
// One line per effect, biggest first; an improver with several effects
// appears once per effect.
std::stable_sort(improvements.begin(), improvements.end(), [](const Improvement& a, const Improvement& b) {
return std::fabs(a.percent) > std::fabs(b.percent);
});
std::wstring text = gameText + L"\n\n" + kHeaderColour + kImprovedByHeader;
for (const Improvement& i : improvements) {
// Greyed out while it changes nothing (not learned).
const long value = std::lround(i.percent);
text += L"\n";
text += value != 0 ? kTextColour : kInactiveColour;
text += ImproverName(i.by) + L" -> " + StatName(i.stat);
text += (value < 0 ? L" " : L" +") + std::to_wstring(value) + L"%";
}
return text + kTextColour;
}
const Art* FindArt(uint16_t id) {
static std::map<uint16_t, std::unique_ptr<Art>> arts;
auto it = arts.find(id);
if (it == arts.end()) {
std::unique_ptr<Art> art;
if (FindSpellDef(id) || FindCombatMoveDef(id)) {
art.reset(new Art(id));
}
it = arts.emplace(id, std::move(art)).first;
}
return it->second.get();
}
void AddTooltipFilter(uint16_t id, Art::TooltipFilter filter) {
Filters()[id].push_back(filter);
}
} // namespace game
+91
View File
@@ -0,0 +1,91 @@
#pragma once
// Combat arts as objects: name, what improves them and by how much, and their
// tooltip (docs/art-tooltip.md).
#include <cstdint>
#include <string>
#include <vector>
#include "game/engine/arts.h"
namespace game {
// The heading of the block Art::Tooltip adds; also marks a text as an art tooltip.
constexpr const wchar_t* kImprovedByHeader = L"Verbessert durch:";
// What about an art can be improved.
enum class ArtStat : uint8_t {
Damage, // all damage of a spell; the physical part of a weapon hit
FireDamage,
MagicDamage,
PoisonDamage,
AttackValue,
AttackSpeed,
ExecutionSpeed, // cast / execution speed while the art is selected (+0x3e)
RegenerationTime, // also faster regeneration rates, as the shorter time
};
// A skill or attribute that improves an art.
struct Improver {
enum Kind : uint8_t { Skill, Attribute } kind;
uint8_t id; // game::Skill / game::Attribute
bool operator==(const Improver& other) const { return kind == other.kind && id == other.id; }
};
// One stat an improver changes, in percent (regeneration time: negative); 0
// for a skill of the hero's class that it has not learned.
struct Improvement {
Improver by;
ArtStat stat;
float percent;
};
class Art {
public:
// Changes a mod makes to an art's tooltip text (the game's description and
// values), e.g. values the mod changed.
using TooltipFilter = void (*)(const Art& art, void* creature, std::wstring& text);
uint16_t Id() const { return id_; }
std::wstring Name() const;
LaunchType Launch() const { return launch_; }
bool DealsDamage() const { return damage_; }
// What improves the art for `creature`: the attributes and the skills of
// its class that act on this art, with their current effect.
std::vector<Improvement> Improvements(void* creature) const;
// The full tooltip: `gameText` (cCreatureArts::ArtTooltip's) with the
// mods' filters applied, then kImprovedByHeader with one line per effect
// of a skill or attribute, biggest first (greyed out while it changes
// nothing).
std::wstring Tooltip(void* creature, std::wstring gameText) const;
private:
friend const Art* FindArt(uint16_t id);
explicit Art(uint16_t id);
// A skill that improves this art beyond the general rules (the game's
// per-skill art lists): its curve and the stat.
struct SkillLink {
uint8_t skill;
uint32_t curve;
ArtStat stat;
};
uint16_t id_;
uint32_t nameText_ = 0;
LaunchType launch_ = LaunchType::Unknown;
bool spellValues_ = false; // damage from spell values (+0xb6 factors), else the weapon's (+0xa6)
bool magicRegeneration_ = false; // counts down at the magic rate (+0xee)
bool damage_ = false;
std::vector<SkillLink> links_;
};
// The art `id`, or null if the game has no definition for it.
const Art* FindArt(uint16_t id);
// Registers a mod's tooltip change for art `id`.
void AddTooltipFilter(uint16_t id, Art::TooltipFilter filter);
} // namespace game
+24 -3
View File
@@ -40,6 +40,22 @@ inline LaunchType ArtLaunchType(uint16_t id) {
return LaunchType::Unknown; return LaunchType::Unknown;
} }
// The spell definition an art's damage values come from: its own, else the
// spell its combat move names (0x4d1c50 on the CombatMoveDef, plain ret), as
// cCreatureArts::ArtTooltip looks it up. Null for pure combat moves.
inline const uint8_t* ArtSpellDef(uint16_t id) {
if (const uint8_t* spell = FindSpellDef(id)) {
return spell;
}
const uint8_t* move = FindCombatMoveDef(id);
if (!move) {
return nullptr;
}
using LinkFn = uint16_t(__fastcall*)(const void*, void*);
const uint16_t linked = reinterpret_cast<LinkFn>(0x004d1c50)(move, nullptr);
return linked ? FindSpellDef(linked) : nullptr;
}
// cCreature::ResolveArtId (0x5521e0, (ref, 0) as the taskbar calls it): the // cCreature::ResolveArtId (0x5521e0, (ref, 0) as the taskbar calls it): the
// art id of an art slot's reference (cInventoryEntry::GetArtSlotRef) for this // art id of an art slot's reference (cInventoryEntry::GetArtSlotRef) for this
// creature. // creature.
@@ -137,6 +153,13 @@ inline uint8_t* FindCooldownEntry(uint8_t* arts, uint16_t art) {
return begin + index * kCooldownSize; return begin + index * kCooldownSize;
} }
// Whether cooldown entry `entry` counts down at the magic rate (+0xee): magic
// group, or Energieblitze.
inline bool EntryAtMagicRate(const uint8_t* entry) {
return *reinterpret_cast<const uint32_t*>(entry + kCooldownGroup) == kRegenGroupMagic ||
*reinterpret_cast<const uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
}
struct ArtCooldown { struct ArtCooldown {
float remaining = 0.0f; // in the game's units float remaining = 0.0f; // in the game's units
float total = 0.0f; float total = 0.0f;
@@ -148,10 +171,8 @@ inline ArtCooldown GetArtCooldown(void* creature, uint16_t art) {
if (!entry) { if (!entry) {
return {}; return {};
} }
const bool magic = *reinterpret_cast<uint32_t*>(entry + kCooldownGroup) == kRegenGroupMagic ||
*reinterpret_cast<uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
const float perSecond = const float perSecond =
Float(entry, kCooldownSpeed) * Float(arts, magic ? kArtsRegenRateMagic : kArtsRegenRateCombat); Float(entry, kCooldownSpeed) * Float(arts, EntryAtMagicRate(entry) ? kArtsRegenRateMagic : kArtsRegenRateCombat);
const float remaining = Float(entry, kCooldownRemaining); const float remaining = Float(entry, kCooldownRemaining);
return {remaining, Float(entry, kCooldownTotal), perSecond > 0.0f ? remaining / perSecond : 0.0f}; return {remaining, Float(entry, kCooldownTotal), perSecond > 0.0f ? remaining / perSecond : 0.0f};
} }
+3
View File
@@ -2,6 +2,7 @@
#include <vector> #include <vector>
#include "re/crash.h"
#include "re/hook.h" #include "re/hook.h"
namespace game { namespace game {
@@ -57,6 +58,7 @@ void __fastcall Engine_Tick(void* self, void* edx, void* event);
RE_HOOK(0x0060f1d0, Engine_Tick); RE_HOOK(0x0060f1d0, Engine_Tick);
void __fastcall Engine_Tick(void* self, void* edx, void* event) { void __fastcall Engine_Tick(void* self, void* edx, void* event) {
Engine_Tick_original(self, edx, event); Engine_Tick_original(self, edx, event);
re::EngineHeartbeat(true);
if (self) { if (self) {
for (EngineTickFn callback : TickCallbacks()) { for (EngineTickFn callback : TickCallbacks()) {
callback(static_cast<uint8_t*>(self)); callback(static_cast<uint8_t*>(self));
@@ -80,6 +82,7 @@ void* __fastcall Engine_Construct(void* self, void* edx, uint32_t arg) {
void __fastcall Engine_Destruct(void* self, void* edx); void __fastcall Engine_Destruct(void* self, void* edx);
RE_HOOK(0x0060d5e0, Engine_Destruct); RE_HOOK(0x0060d5e0, Engine_Destruct);
void __fastcall Engine_Destruct(void* self, void* edx) { void __fastcall Engine_Destruct(void* self, void* edx) {
re::EngineHeartbeat(false);
for (GameEndFn callback : GameEndCallbacks()) { for (GameEndFn callback : GameEndCallbacks()) {
callback(); callback();
} }
+22
View File
@@ -1,6 +1,7 @@
#include "game/engine/items.h" #include "game/engine/items.h"
#include "game/engine/stream.h" #include "game/engine/stream.h"
#include "game/ui/texts.h"
namespace game { namespace game {
namespace { namespace {
@@ -48,4 +49,25 @@ uint32_t CloneItem(uint32_t id) {
return item ? CreateItem(ItemType(item), SerializeItem(item), item[kItemOwner]) : 0; return item ? CreateItem(ItemType(item), SerializeItem(item), item[kItemOwner]) : 0;
} }
std::wstring PotionKindName(int kind) {
// Shown as "\dFFFFFF80Heiltrank \wFFFFFFFF[SPC]" (the resource's
// <color=..> tags become a backslash, a letter and 8 hex digits): without
// the colour codes and the key.
constexpr size_t kColourCodeLength = 10;
const std::wstring text = ShownText(PotionTooltipText(kind));
std::wstring name;
for (size_t i = 0; i < text.size(); ++i) {
if (text[i] == L'\\') {
i += kColourCodeLength - 1;
} else if (text[i] == L'[') {
break;
} else {
name += text[i];
}
}
const size_t first = name.find_first_not_of(L' ');
const size_t last = name.find_last_not_of(L' ');
return first == std::wstring::npos ? std::wstring() : name.substr(first, last - first + 1);
}
} // namespace game } // namespace game
+20
View File
@@ -128,3 +128,23 @@ uint32_t CreateItem(uint32_t type, const std::vector<uint8_t>& data, uint32_t ow
uint32_t CloneItem(uint32_t id); uint32_t CloneItem(uint32_t id);
} // namespace game } // namespace game
#include <string>
namespace game {
// 0x43fa90 (ret 4; ecx unused, it asks the text manager): the text id of the
// bottom bar's tooltip for potion kind `kind` (1..kPotionKinds), by text key
// (0x94a8d0 ...), e.g. "<color=FFFFFF80>Heiltrank <colorw=FFFFFFFF>[SPC]";
// 0 for other kinds. Kinds: Heiltrank, Sog der Hölle, Trank des Mentors,
// Antidot der Viper, Trank der Konzentration (Teleportationstrank here).
inline uint32_t PotionTooltipText(int kind) {
using Fn = uint32_t(__fastcall*)(void*, void*, int);
return reinterpret_cast<Fn>(0x0043fa90)(*reinterpret_cast<void**>(kItemTypeTable), nullptr, kind);
}
// The name of potion kind `kind` as the bar's tooltip shows it (with our text
// changes), without its colour codes and key.
std::wstring PotionKindName(int kind);
} // namespace game
+1 -1
View File
@@ -7,7 +7,7 @@
// optional callback (0x017e5ee4) and to either OutputDebugString or the // optional callback (0x017e5ee4) and to either OutputDebugString or the
// in-game console object (0x017e5edc). // in-game console object (0x017e5edc).
// //
// Status: hooked, not rewritten. Every message is mirrored into sacred_re.log // Status: hooked, not rewritten. Every message is mirrored into sacredK.log
// whether or not the game's own logging is enabled, then forwarded. // whether or not the game's own logging is enabled, then forwarded.
#include "game/engine/log.h" #include "game/engine/log.h"
+5 -3
View File
@@ -73,11 +73,13 @@ inline void* GetPlayerHero() {
} }
// cKernel::PostEvent (0x808930) on Kernel_Instance() (0x808490): the kernel // cKernel::PostEvent (0x808930) on Kernel_Instance() (0x808490): the kernel
// copies the event and dispatches it (key events right away). // copies the event and dispatches it (key events right away). `a`: 1 as the
inline void PostEvent(void* event) { // window procedure posts key events, 0 as the UI manager's S hotkey posts its
// engine event.
inline void PostEvent(void* event, int a = 1) {
using InstanceFn = void*(__cdecl*)(); using InstanceFn = void*(__cdecl*)();
using PostFn = void(__fastcall*)(void* kernel, void* edx, void* event, int a, int b); using PostFn = void(__fastcall*)(void* kernel, void* edx, void* event, int a, int b);
reinterpret_cast<PostFn>(0x00808930)(reinterpret_cast<InstanceFn>(0x00808490)(), nullptr, event, 1, 0); reinterpret_cast<PostFn>(0x00808930)(reinterpret_cast<InstanceFn>(0x00808490)(), nullptr, event, a, 0);
} }
} // namespace game } // namespace game
+87 -1
View File
@@ -67,20 +67,72 @@ void OnCreatureOrder(CreatureOrderFn callback);
constexpr size_t kCreatureId = 0xc; constexpr size_t kCreatureId = 0xc;
constexpr size_t kCreatureX = 0x1c; constexpr size_t kCreatureX = 0x1c;
constexpr size_t kCreatureY = 0x20; constexpr size_t kCreatureY = 0x20;
// The walk path (measured 2026-10-18): waypoints {x, y} at +0x2ed + i * 8,
// the current segment's start index at +0x36d, the count at +0x371, and the
// step's sub-unit remainder (x, y, 1/1024 units) at +0x37d / +0x381.
// cCreature::Update (0x594110, thiscall (u8), ret 4; about 40 times per second
// on the engine thread) moves the creature its step length along the
// segments into g_creatureNewPosition (0xad2368, {u16, x, y, u8 level}),
// writes the reached point back as the segment start, and commits it with
// cObjectManager::Place (0x5fcd10), which refuses blocked spots (the
// creature then stays where it was). At the last waypoint the walk ends.
constexpr size_t kCreaturePath = 0x2ed;
constexpr size_t kPathPointSize = 8;
constexpr size_t kCreaturePathIndex = 0x36d;
constexpr size_t kCreaturePathCount = 0x371;
// Facing: a float unit vector in world axes ({0.707, -0.707} facing right on
// screen). Walking turns it towards the path gradually (measured 2026-10-18:
// about 300 ms for a reversal, swinging through the side).
constexpr size_t kCreatureFacing = 0x70;
// +0xfc: the pending action (u16, the order received but not yet started;
// 2 a walk), +0xfe its phase, +0x100..+0x13f its parameters (walk target at
// +0x104 / +0x108). cCreature::Think (vf04, 0x537430, thiscall (float),
// ret 4; about every 110 ms per creature) starts it and then runs the current
// action's handler.
constexpr size_t kCreaturePendingAction = 0xfc;
constexpr uint16_t kPendingWalk = 2;
// cCreature::StartPendingWalk (0x4ede70, thiscall (), ret): the Think step
// for a pending walk: unless a status effect (+0x1f4) holds the creature or
// the target is over 2000 units away, the pending parameters become the
// action's (+0x154), action 1, phase 0 unless already walking.
inline void StartPendingWalk(void* creature) {
using Fn = void(__fastcall*)(void*, void*);
reinterpret_cast<Fn>(0x004ede70)(creature, nullptr);
}
// cCreature::WalkAction (0x4f45b0, thiscall (), ret): the Think step of the
// walk action; phase 0 plans the path, sets the facing, starts the walk
// animation and goes to phase 1.
inline void WalkAction(void* creature) {
using Fn = void(__fastcall*)(void*, void*);
reinterpret_cast<Fn>(0x004f45b0)(creature, nullptr);
}
// +0x150: low word the current action, high word its phase; 0x10000 when // +0x150: low word the current action, high word its phase; 0x10000 when
// standing idle. Measured on the hero: 1 walking (also up to a target), // standing idle. Measured on the hero: 1 walking (also up to a target),
// 2 stopping (2026-10-18: the target-less walk order sets the walk's phase
// to 0x64 at 0x52f03a, WalkAction then switches to 2 with the stop
// animation at 0x4f6f8b, idle about 110 ms later at 0x546513),
// 3 weapon attack, 4 casting a spell (2026-10-16), 6 dying, 8 an art seen // 3 weapon attack, 4 casting a spell (2026-10-16), 6 dying, 8 an art seen
// after walking up (2026-10-15, presumably a combat move). // after walking up (2026-10-15, presumably a combat move), 5 a shot with a
// projectile weapon (bow, 2026-10-18).
constexpr size_t kCreatureAction = 0x150; constexpr size_t kCreatureAction = 0x150;
enum CreatureAction : int32_t { enum CreatureAction : int32_t {
kActionIdle = 0, kActionIdle = 0,
kActionWalk = 1, kActionWalk = 1,
kActionStop = 2,
kActionAttack = 3, kActionAttack = 3,
kActionCastSpell = 4, kActionCastSpell = 4,
kActionShoot = 5,
kActionDying = 6, kActionDying = 6,
kActionCombatMove = 8, kActionCombatMove = 8,
}; };
inline int32_t CurrentAction(const void* creature); inline int32_t CurrentAction(const void* creature);
constexpr size_t kCreatureActionPhase = 0x152; // u16
constexpr uint16_t kPhaseStopping = 0x64; // walk: stop requested
// cCreature::IsAlive (0x548ef0): not dying (+0x150 == 6), not dead (+0xfc == // cCreature::IsAlive (0x548ef0): not dying (+0x150 == 6), not dead (+0xfc ==
// 9), health (+0x4d8) left. // 9), health (+0x4d8) left.
@@ -111,6 +163,40 @@ inline int32_t CurrentAction(const void* creature) {
return CreatureField(creature, kCreatureAction) & 0xffff; return CreatureField(creature, kCreatureAction) & 0xffff;
} }
// A map position as objects keep it (object +0x18): area, x, y, level. In
// world units, or in map cells (world / kMapCellSize, truncated; 0x6225c0
// converts an object's position).
struct MapPosition {
uint16_t area = 0;
int32_t x = 0;
int32_t y = 0;
uint8_t level = 0;
};
static_assert(offsetof(MapPosition, x) == 4 && offsetof(MapPosition, level) == 0xc);
constexpr size_t kObjectPosition = 0x18;
constexpr size_t kObjectLevel = 0x24;
constexpr float kMapCellSize = 53.66563f; // 0x88f318 / 0x890044
// The map cell of world point (x, y) on the creature's area and level.
inline MapPosition CellAt(const void* creature, int x, int y) {
const auto* c = static_cast<const uint8_t*>(creature);
MapPosition cell;
cell.area = *reinterpret_cast<const uint16_t*>(c + kObjectPosition);
cell.x = static_cast<int32_t>(x / kMapCellSize);
cell.y = static_cast<int32_t>(y / kMapCellSize);
cell.level = c[kObjectLevel];
return cell;
}
// cCreature::IsCellWalkable (0x4f7b20, thiscall (cell*), ret 4): whether the
// creature may enter map cell `cell` (map sector lookups and its walk area
// +0x2c; reads only). The step test cCreature::CanStepTo (0x593c50) refuses a
// step into another cell that is not walkable.
inline bool IsCellWalkable(void* creature, MapPosition& cell) {
using Fn = char(__fastcall*)(void*, void*, MapPosition*);
return reinterpret_cast<Fn>(0x004f7b20)(creature, nullptr, &cell) != 0;
}
// The distance at which two creatures touch (0x4dbaa0 on one, (the other)): // The distance at which two creatures touch (0x4dbaa0 on one, (the other)):
// the mean of their body radii by object type (heroes 110 with a weapon, 80 // the mean of their body radii by object type (heroes 110 with a weapon, 80
// without; monsters 70..480). A melee attacker swings from about there // without; monsters 70..480). A melee attacker swings from about there
+191
View File
@@ -0,0 +1,191 @@
#pragma once
// A creature's attributes and skills (Fertigkeiten) in cCreatureArts (creature
// +0x3a8). See docs/art-tooltip.md for what each of them improves.
#include <cstddef>
#include <cstdint>
#include <string>
#include "game/ui/texts.h"
namespace game {
// Attributes, in the character panel's order.
enum Attribute : uint8_t {
kStaerke = 0,
kWiderstand = 1,
kGeschick = 2,
kPhysischeRegeneration = 3,
kMentaleRegeneration = 4,
kCharisma = 5,
};
constexpr int kAttributeCount = 6;
// The attribute's bare name: the texts with key hashes 0x440470a.. in
// global.res ("Stärke" .. "Charisma"), looked up by hash as GetTextByKey does
// (bit 31). The UI_ATTRIB_* key texts also explain the attribute; their first
// line is the fallback.
constexpr uint32_t kAttributeNameHash = 0x0440470a;
inline std::wstring AttributeName(uint8_t attribute) {
static constexpr const char* kTextKeys[kAttributeCount] = {
"UI_ATTRIB_STRENGTH", "UI_ATTRIB_RESISTANCE", "UI_ATTRIB_GESCHICK",
"UI_ATTRIB_PHYREG", "UI_ATTRIB_MAGREG", "UI_ATTRIB_CHARISMA",
};
if (attribute >= kAttributeCount) {
return {};
}
std::wstring name = GameText(0x80000000 | (kAttributeNameHash + attribute));
if (name.empty()) {
name = GameTextByKey(kTextKeys[attribute]);
name = name.substr(0, name.find_first_of(L"\n:"));
}
return name;
}
// cCreatureArts fields. cCreatureArts::Recalculate (0x579560) sets each shown
// attribute to invested + base + base * (level - 1) / 10, then adds items.
constexpr size_t kArtsAttributes = 0x10; // u16[6]: shown values
constexpr size_t kArtsInvested = 0x1c; // u8[6]: attribute points invested
constexpr int kFreeAttributesIndex = 7; // u8 +0x23: free points
constexpr size_t kArtsSkillIds = 0x24; // u8[8]: skill id by row (0: empty)
constexpr size_t kArtsSkillLevels = 0x2c; // u8[8]: bought levels
constexpr size_t kArtsSkillBonus = 0x34; // u8[8]: levels from items
constexpr size_t kArtsSkillChoices = 0x44; // u16: new skills still to choose
constexpr size_t kArtsCreatureType = 0x8; // u32: the creature (hero class) type
constexpr size_t kArtsAttributeBase = 0x4a; // u16[6]: the class's base values
constexpr size_t kArtsLevel = 0x56; // u16: hero level
constexpr int kSkillRows = 8;
inline uint16_t AttributeValue(const uint8_t* arts, int attribute) {
return reinterpret_cast<const uint16_t*>(arts + kArtsAttributes)[attribute];
}
inline uint16_t AttributeBase(const uint8_t* arts, int attribute) {
return reinterpret_cast<const uint16_t*>(arts + kArtsAttributeBase)[attribute];
}
inline uint16_t HeroLevel(const uint8_t* arts) {
return *reinterpret_cast<const uint16_t*>(arts + kArtsLevel);
}
// The part of an attribute's shown value that comes from items.
inline int AttributeFromItems(const uint8_t* arts, int attribute) {
const int base = AttributeBase(arts, attribute);
const int grown = arts[kArtsInvested + attribute] + base + base * (HeroLevel(arts) - 1) / 10;
return AttributeValue(arts, attribute) - grown;
}
// Skill ids (cCreatureArts::ApplySkill 0x565f30 switches on them).
enum Skill : uint8_t {
kHimmelsmagie = 0x01,
kWaffenkunde = 0x02,
kStangenwaffen = 0x03,
kSchwertkunde = 0x04,
kAxtkampf = 0x05,
kZweiWaffenKampf = 0x06,
kFernkampf = 0x07,
kWendigkeit = 0x08,
kMeditation = 0x0c,
kKlingenkampf = 0x0d,
kMagiekunde = 0x0e,
kFeuermagie = 0x0f,
kWassermagie = 0x10,
kErdmagie = 0x11,
kLuftmagie = 0x12,
kMondmagie = 0x13,
kVampirismus = 0x14,
kFaustkampf = 0x18,
kKonzentration = 0x19,
kBallistik = 0x1a,
kFallenkunst = 0x1b,
kBlutrausch = 0x1c,
kWaffentechnik = 0x1d,
kZwergenkunde = 0x1f,
kHoellenmacht = 0x20,
};
// The level of skill `skill` (bought + from items, as ApplySkill uses it), 0
// if the creature has not learned it.
inline unsigned SkillLevel(const uint8_t* arts, uint8_t skill) {
for (int row = 0; row < kSkillRows; ++row) {
if (arts[kArtsSkillIds + row] == skill) {
return arts[kArtsSkillLevels + row] + arts[kArtsSkillBonus + row];
}
}
return 0;
}
// The name the character panel shows (game text 0x24b7 + id, 0x6abc8d).
inline std::wstring SkillName(uint8_t skill) {
return GameText(0x24b7 + skill);
}
// The skills of the creature's class: 16 ids at +0x16 of its type record
// (0x43ac50 on the type table *0xaa9564, (type), ret 4; read by
// cCreatureArts::ChoosableSkills 0x580f30). Null without a record.
constexpr int kClassSkills = 16;
inline const uint8_t* ClassSkills(const uint8_t* arts) {
using Fn = const uint8_t*(__fastcall*)(void*, void*, uint32_t);
void* table = *reinterpret_cast<void**>(0x00aa9564);
const uint8_t* record =
table ? reinterpret_cast<Fn>(0x0043ac50)(table, nullptr, *reinterpret_cast<const uint32_t*>(arts + kArtsCreatureType))
: nullptr;
return record ? record + 0x16 : nullptr;
}
// A skill curve of the balance settings (globals parsed from "key=value"
// around 0x5eb4xx: "<k>off" start, "<k>_s" half, "<k>_w" limit): 0 below level
// 1, else start + 2 (limit - start) (1 - 1 / ((level - 1) / half + 1)), in
// percent (DiminishingCurve 0x565c30).
struct SkillCurve {
float start;
float half;
float limit;
};
inline float CurveValue(uint32_t curveAddress, unsigned level) {
const auto& curve = *reinterpret_cast<const SkillCurve*>(curveAddress);
if (level < 1) {
return 0.0f;
}
const float t = 1.0f - 1.0f / ((static_cast<float>(level) - 1.0f) / curve.half + 1.0f);
return 2.0f * (curve.limit - curve.start) * t + curve.start;
}
constexpr uint32_t kCurveWaffenkunde = 0x00ad2450; // WK
constexpr uint32_t kCurveMagiekunde = 0x00ad245c; // MK
constexpr uint32_t kCurveFaustkampfValue = 0x00ad2468; // FK..AW, then ..SP
constexpr uint32_t kCurveSchwertkundeValue = 0x00ad2480; // SK
constexpr uint32_t kCurveAxtkampfValue = 0x00ad2498; // AK
constexpr uint32_t kCurveKlingenkampfValue = 0x00ad24b0; // KK
constexpr uint32_t kCurveStangenwaffenValue = 0x00ad24c8; // STK
constexpr uint32_t kCurveZweiWaffenValue = 0x00ad24e0; // BK
constexpr uint32_t kCurveFernkampfValue = 0x00ad24f8; // FEK
constexpr uint32_t kCurveExecutionSpeed = 0x00ad2504; // FEK..SP: also the magic schools' speed bonus
constexpr uint32_t kCurveHimmelsmagie = 0x00ad2510; // HM
constexpr uint32_t kCurveFeuermagie = 0x00ad251c; // FM
constexpr uint32_t kCurveErdmagie = 0x00ad2528; // EM
constexpr uint32_t kCurveWassermagie = 0x00ad2534; // WM
constexpr uint32_t kCurveLuftmagie = 0x00ad2540; // LM
constexpr uint32_t kCurveMondmagie = 0x00ad254c; // MM
constexpr uint32_t kCurveBallistik = 0x00ad2558; // BA
constexpr uint32_t kCurveBlutrausch = 0x00ad2564; // BL
constexpr uint32_t kCurveKonzentration = 0x00ad2570; // KO
constexpr uint32_t kCurveMeditation = 0x00ad257c; // Med
constexpr uint32_t kCurveWendigkeitValue = 0x00ad2588; // W..AW
constexpr uint32_t kCurveHoellenmacht = 0x00ad368c; // HOM
constexpr uint32_t kCurveWaffentechnik = 0x00ad3698; // WT
// Weapon skills have two curves in a row: attack value (AW), then speed (SP).
constexpr uint32_t kCurveSpeedOffset = 0xc;
// Balance settings for the attributes' share of the damage factors
// (Recalculate): BalStatOff and the divisors BalCharPD / FD / MD / GD.
constexpr uint32_t kBalStatOff = 0x00ad242c;
constexpr uint32_t kBalCharDamage[4] = {0x00ad2430, 0x00ad2434, 0x00ad2438, 0x00ad243c};
inline float BalanceValue(uint32_t address) {
return *reinterpret_cast<const float*>(address);
}
// cCreature::HoldsRangedWeapon (0x54a110, plain ret): Recalculate takes
// Geschick instead of Stärke for the physical damage then.
inline bool HoldsRangedWeapon(void* creature) {
using Fn = bool(__fastcall*)(void*, void*);
return reinterpret_cast<Fn>(0x0054a110)(creature, nullptr);
}
} // namespace game
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still
// renders but handles no events).
#include <cstddef>
#include <cstdint>
namespace game {
constexpr size_t kControlFlagsOffset = 0x10;
constexpr uint32_t kControlFlagVisible = 1;
inline bool ControlShown(const void* control) {
return (static_cast<const uint8_t*>(control)[kControlFlagsOffset] & kControlFlagVisible) != 0;
}
// cUI_Control2::SetFlags (0x731e70) / ClearFlags (0x731ee0), thiscall (flags),
// ret 4; setting the visible flag calls the control's vf24 (show).
inline void SetControlShown(void* control, bool shown) {
using FlagFn = void(__fastcall*)(void*, void*, uint32_t);
reinterpret_cast<FlagFn>(shown ? 0x00731e70 : 0x00731ee0)(control, nullptr, kControlFlagVisible);
}
} // namespace game
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// Untextured quads on the UI's Direct3D 7 device (the argument of the UI's
// render methods, vf14).
#include <cstdint>
#include <windows.h>
namespace game {
constexpr int kDeviceDrawPrimitive = 25; // IDirect3DDevice7 vtable slots
constexpr int kDeviceSetTexture = 35;
constexpr DWORD kTriangleStrip = 5;
constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1
struct ScreenVertex {
float x, y, z, rhw;
uint32_t diffuse, specular;
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) {
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}};
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture,
quad, 4, 0);
}
} // namespace game
+7
View File
@@ -57,6 +57,13 @@ inline uint8_t* TabControl(void* sheet) {
return *reinterpret_cast<uint8_t**>(static_cast<uint8_t*>(sheet) + kTabSheetControl); return *reinterpret_cast<uint8_t**>(static_cast<uint8_t*>(sheet) + kTabSheetControl);
} }
constexpr size_t kTabControlActive = 0x84; // u16
inline uint16_t ActiveTab(void* sheet) {
const uint8_t* control = TabControl(sheet);
return control ? *reinterpret_cast<const uint16_t*>(control + kTabControlActive) : 0;
}
// The tab entries of a cUI_TabControl. // The tab entries of a cUI_TabControl.
inline uint8_t* TabEntries(uint8_t* control) { inline uint8_t* TabEntries(uint8_t* control) {
return *reinterpret_cast<uint8_t**>(control + kTabControlTabs); return *reinterpret_cast<uint8_t**>(control + kTabControlTabs);
+3
View File
@@ -3,6 +3,9 @@
#include <cstdint> #include <cstdint>
// First, so its DrawText macro renames game::DrawText the same way everywhere.
#include <windows.h>
namespace game { namespace game {
// Screen rect as the UI uses it (cUI_Control2::GetScreenRect 0x731c70, // Screen rect as the UI uses it (cUI_Control2::GetScreenRect 0x731c70,
+56
View File
@@ -4,6 +4,7 @@
#include <cwchar> #include <cwchar>
#include <cwctype> #include <cwctype>
#include <memory>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
@@ -57,6 +58,15 @@ std::vector<std::pair<std::wstring, std::wstring>>& Replacements() {
// By id; nodes stay put, so the strings handed out stay valid. // By id; nodes stay put, so the strings handed out stay valid.
std::unordered_map<uint32_t, Shown> g_shown; std::unordered_map<uint32_t, Shown> g_shown;
// SetTextOverride: by id. A replaced text is kept (the game may still hold
// the string it got); overrides change rarely (key bindings).
struct Override {
std::wstring text;
GameString string;
};
std::unordered_map<uint32_t, std::unique_ptr<Override>> g_overrides;
std::vector<std::unique_ptr<Override>> g_retiredOverrides;
std::wstring Capitalize(std::wstring text) { std::wstring Capitalize(std::wstring text) {
bool wordStart = true; bool wordStart = true;
for (wchar_t& c : text) { for (wchar_t& c : text) {
@@ -126,6 +136,49 @@ std::wstring GameText(uint32_t id) {
: std::wstring(); : std::wstring();
} }
std::wstring ShownText(uint32_t id) {
std::lock_guard guard(g_lock);
return Transform(id, GameText(id));
}
void SetTextOverride(uint32_t id, const std::wstring& text) {
std::lock_guard guard(g_lock);
std::unique_ptr<Override>& entry = g_overrides[id];
if (entry && entry->text == text) {
return;
}
if (entry) {
g_retiredOverrides.push_back(std::move(entry));
}
entry = std::make_unique<Override>();
entry->text = text;
const wchar_t* begin = entry->text.c_str();
entry->string = {begin, begin + entry->text.size(), begin + entry->text.size()};
}
std::wstring GameTextByKey(const char* key) {
// The key's hash (0x80e010, cdecl), looked up "by key" as GetTextByKey does.
using HashFn = uint32_t(__cdecl*)(const char*);
const uint32_t hash = reinterpret_cast<HashFn>(0x0080e010)(key);
return hash ? GameText(hash | 0x80000000) : std::wstring();
}
std::wstring WideStringText(const void* string) {
const auto* s = static_cast<const GameString*>(string);
return s && s->begin && s->end > s->begin ? std::wstring(s->begin, s->end) : std::wstring();
}
void SetWideString(void* string, const std::wstring& value) {
auto* s = static_cast<GameString*>(string);
if (s->begin) {
s->end = s->begin;
*const_cast<wchar_t*>(s->begin) = L'\0';
}
// The game's wide string append (0x592ef0, thiscall (first, last, unused), ret 0xc).
using AppendFn = void*(__fastcall*)(void*, void*, const wchar_t*, const wchar_t*, uint32_t);
reinterpret_cast<AppendFn>(0x00592ef0)(string, nullptr, value.data(), value.data() + value.size(), 0);
}
const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) { const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) {
if (id >= kFirstOwnText && id < 0x80000000) { if (id >= kFirstOwnText && id < 0x80000000) {
std::lock_guard guard(g_lock); std::lock_guard guard(g_lock);
@@ -136,6 +189,9 @@ const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) {
} }
const void* original = TextManager_GetText_original(self, edx, id); const void* original = TextManager_GetText_original(self, edx, id);
std::lock_guard guard(g_lock); std::lock_guard guard(g_lock);
if (const auto found = g_overrides.find(id); found != g_overrides.end()) {
return &found->second->string;
}
if (!original || (Replacements().empty() && !CapitalizedIds().count(id))) { if (!original || (Replacements().empty() && !CapitalizedIds().count(id))) {
return original; return original;
} }
+16
View File
@@ -23,4 +23,20 @@ void ReplaceInTexts(const std::wstring& from, const std::wstring& to);
// The game's text `id` without our changes (empty if there is none). // The game's text `id` without our changes (empty if there is none).
std::wstring GameText(uint32_t id); std::wstring GameText(uint32_t id);
// Text `id` as the game shows it, with our changes (without overrides).
std::wstring ShownText(uint32_t id);
// Shows `text` for the game's text `id` from now on (e.g. a tooltip naming a
// key binding); setting the same text again costs nothing.
void SetTextOverride(uint32_t id, const std::wstring& text);
// The game's text under key `key` (TextManager::GetTextByKey 0x672fd0), e.g.
// "UI_ATTRIB_STRENGTH"; empty if there is none.
std::wstring GameTextByKey(const char* key);
// A game wide string ({begin, end, capacity end}, e.g. a tooltip's text) as
// text, and replaced by `value` (grown with the game's allocator).
std::wstring WideStringText(const void* string);
void SetWideString(void* string, const std::wstring& value);
} // namespace game } // namespace game
+51
View File
@@ -0,0 +1,51 @@
#pragma once
// Opening and closing the UI manager's windows by name, as the game's own
// hotkeys and buttons do.
#include <cstddef>
#include <cstdint>
namespace game {
constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager*
inline uint32_t* UiManager() {
return *reinterpret_cast<uint32_t**>(kUiManager);
}
// cEventUI2 window commands (code at +4, subtype 5 at +8, names at +0x28): the
// UI manager's event handler (vf00, 0x757120) routes them by window name.
// Measured: HandleEsc hides with 0x12 (docs/escape.md); the S hotkey
// (0x757775) shows the save game window with 0x11. (Sent to the window's own
// handler a command was not understood the same way: the trader changed its
// stock instead of closing.)
enum class WindowCommand : uint32_t { Show = 0x11, Hide = 0x12 };
inline void SendWindowCommand(uint32_t* manager, const char* name, WindowCommand command) {
constexpr uint32_t kEventUiVtable = 0x0088edd0;
constexpr uint32_t kWindowSubtype = 5;
constexpr size_t kEventUiSize = 0x80;
// Names, written by 0x5904e0 (sender at +0, window at +0x20 of this).
constexpr size_t kEventUiNames = 0x28;
constexpr size_t kEventUiText = 0x68; // a string, 0x423920 / 0x416660
alignas(4) uint8_t event[kEventUiSize] = {};
*reinterpret_cast<uint32_t*>(event) = kEventUiVtable;
*reinterpret_cast<uint32_t*>(event + 4) = static_cast<uint32_t>(command);
*reinterpret_cast<uint32_t*>(event + 8) = kWindowSubtype;
using SetNamesFn = void(__fastcall*)(void*, void*, const char* window, const char* sender);
reinterpret_cast<SetNamesFn>(0x005904e0)(event + kEventUiNames, nullptr, name, "");
using StringFn = void(__fastcall*)(void*, void*);
reinterpret_cast<StringFn>(0x00423920)(event + kEventUiText, nullptr); // construct
using HandleFn = int(__fastcall*)(void*, void*, void*);
reinterpret_cast<HandleFn>((*reinterpret_cast<void***>(manager))[0])(manager, nullptr, event);
reinterpret_cast<StringFn>(0x00416660)(event + kEventUiText, nullptr); // destroy
}
// cUI_Manager 0x75a690 (thiscall (1), ret 4): false while a window that
// blocks others is open (the S hotkey asks it first).
inline bool UiCanOpenWindow(uint32_t* manager) {
using Fn = bool(__fastcall*)(void*, void*, int);
return reinterpret_cast<Fn>(0x0075a690)(manager, nullptr, 1);
}
} // namespace game
+9 -9
View File
@@ -1,11 +1,11 @@
// sacred_re.exe: starts sacred.exe with sacred_re.dll injected. // sacredK.exe: starts sacred.exe with sacredK.dll injected.
// //
// sacred_re.exe [--game-dir <dir>] [game arguments...] // sacredK.exe [--game-dir <dir>] [game arguments...]
// //
// The game directory comes from --game-dir (which is then remembered in // The game directory comes from --game-dir (which is then remembered in
// sacred_re.ini), else from [paths] GameDir in sacred_re.ini, else the // sacredK.ini), else from [paths] GameDir in sacredK.ini, else the
// launcher's own directory. Remaining arguments are passed to the game // launcher's own directory. Remaining arguments are passed to the game
// unchanged, e.g. `sacred_re.exe log` enables the game's DEBUG.LOG. // unchanged, e.g. `sacredK.exe log` enables the game's DEBUG.LOG.
#include <string> #include <string>
@@ -35,7 +35,7 @@ std::wstring WithTrailingSlash(std::wstring dir) {
} }
int Fail(const std::wstring& message) { int Fail(const std::wstring& message) {
MessageBoxW(nullptr, message.c_str(), L"sacred_re", MB_OK | MB_ICONERROR); MessageBoxW(nullptr, message.c_str(), L"sacredK", MB_OK | MB_ICONERROR);
return 1; return 1;
} }
@@ -43,7 +43,7 @@ int Fail(const std::wstring& message) {
int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) { int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) {
const std::wstring launcherDir = ExeDir(); const std::wstring launcherDir = ExeDir();
const std::wstring ini = launcherDir + L"sacred_re.ini"; const std::wstring ini = launcherDir + L"sacredK.ini";
int argc = 0; int argc = 0;
wchar_t** argv = CommandLineToArgvW(GetCommandLineW(), &argc); wchar_t** argv = CommandLineToArgvW(GetCommandLineW(), &argc);
@@ -71,12 +71,12 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) {
const std::wstring gameExe = gameDir + L"sacred.exe"; const std::wstring gameExe = gameDir + L"sacred.exe";
if (!FileExists(gameExe)) { if (!FileExists(gameExe)) {
return Fail(L"sacred.exe not found in\n" + gameDir + return Fail(L"sacred.exe not found in\n" + gameDir +
L"\n\nRun once with: sacred_re.exe --game-dir \"<Sacred Gold folder>\""); L"\n\nRun once with: sacredK.exe --game-dir \"<Sacred Gold folder>\"");
} }
const std::wstring dllPath = launcherDir + L"sacred_re.dll"; const std::wstring dllPath = launcherDir + L"sacredK.dll";
if (!FileExists(dllPath)) { if (!FileExists(dllPath)) {
return Fail(L"sacred_re.dll not found next to the launcher:\n" + dllPath); return Fail(L"sacredK.dll not found next to the launcher:\n" + dllPath);
} }
// Detours takes the DLL path as ANSI. // Detours takes the DLL path as ANSI.
char dllPathA[MAX_PATH]; char dllPathA[MAX_PATH];
+110
View File
@@ -0,0 +1,110 @@
// Art tooltips name what improves the art (request of 2026-10-18: "edit ALL
// skill tooltips, to show which attributes and abilities improve them in
// which way"; then: arts as objects with their effects, name and tooltip,
// lines like "Damage improved by [Axe Lore, Strength, ...]").
//
// cCreatureArts::ArtTooltip (0x589270, thiscall on creature +0x3a8, (art,
// text, flag), ret 0xc) writes the game's description and values into a wide
// string; game::Art::Tooltip (game/engine/art.cpp) applies the mods' changes
// and adds "Verbessert durch:" with one line per skill or attribute acting on
// the art, one line per effect sorted by size, e.g. "Stärke -> Schaden +12%"
// (unlearned skills greyed out with 0%). Combos (0x200..0x204) get no text
// from the game. Art tooltips wrap at a wider line (below).
#include <cstdint>
#include <cstring>
#include <string>
#include <string_view>
#include "game/engine/art.h"
#include "game/engine/arts.h"
#include "game/ui/texts.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::arttooltip {
namespace {
void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag);
RE_HOOK(0x00589270, Arts_Tooltip);
void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag) {
Arts_Tooltip_original(self, edx, art, text, flag);
const std::wstring gameText = game::WideStringText(text);
const game::Art* object = game::FindArt(static_cast<uint16_t>(art));
if (gameText.empty() || !object) {
return;
}
void* creature = static_cast<uint8_t*>(self) - game::kCreatureArts;
game::SetWideString(text, object->Tooltip(creature, gameText));
}
// Line wrap. With flag 0x80 (+0x154) cUI_Tooltip::FormatText (0x6e6630,
// thiscall (char), ret 4; called by SetText 0x6e6ae0) copies the text
// (+0x115c begin, +0x1160 end) and turns the first space after 45 characters
// of a line into a line break (the check `cmp ecx, 5Ah` on the line's bytes at
// 0x6e687d); the width then follows the longest line. Colour codes counted as
// characters there. Now they don't, and art tooltips wrap later, so the
// "Verbessert durch" lines stay whole.
constexpr int kGameWrapChars = 45;
constexpr int kArtWrapChars = 56;
constexpr size_t kTooltipText = 0x115c;
constexpr size_t kColourCodeLength = 10; // "\cAARRGGBB"
int g_wrapChars = kGameWrapChars;
void __fastcall Tooltip_FormatText(void* self, void* edx, char flag);
RE_HOOK(0x006e6630, Tooltip_FormatText);
void __fastcall Tooltip_FormatText(void* self, void* edx, char flag) {
auto* const* text = reinterpret_cast<const wchar_t* const*>(static_cast<uint8_t*>(self) + kTooltipText);
const bool art = text[0] && std::wstring_view(text[0], text[1] - text[0]).find(game::kImprovedByHeader) !=
std::wstring_view::npos;
g_wrapChars = art ? kArtWrapChars : kGameWrapChars;
Tooltip_FormatText_original(self, edx, flag);
g_wrapChars = kGameWrapChars;
}
// Characters of the line [start, current) beyond the wrap width, colour codes not counted.
int __cdecl WrapExcess(const wchar_t* start, const wchar_t* current) {
int chars = 0;
for (const wchar_t* c = start; c < current; ++c, ++chars) {
if (c[0] == L'\\' && c[1] == L'c') {
c += kColourCodeLength - 1;
--chars;
}
}
return chars - g_wrapChars;
}
// Replaces `mov ecx, edi; sub ecx, esi; and ecx, -2; cmp ecx, 5Ah` (line start
// in esi, current character in edi, the character in ax); the `jle` after it
// keeps the line going.
constexpr uint32_t kWrapCheck = 0x006e6876;
__declspec(naked) void WrapCheckThunk() {
__asm {
push eax
push edx
push edi
push esi
call WrapExcess
add esp, 8
mov ecx, eax
pop edx
pop eax
cmp ecx, 0
ret
}
}
void Startup() {
const int32_t rel = static_cast<int32_t>(reinterpret_cast<uintptr_t>(&WrapCheckThunk) - (kWrapCheck + 5));
uint8_t to[10] = {0xe8, 0, 0, 0, 0, 0x90, 0x90, 0x90, 0x90, 0x90};
std::memcpy(to + 1, &rel, 4);
const uint8_t from[10] = {0x8b, 0xcf, 0x2b, 0xce, 0x83, 0xe1, 0xfe, 0x83, 0xf9, 0x5a};
const bool ok = re::ReplaceCode(kWrapCheck, from, to, sizeof(to));
re::Log("arttooltip: tooltip wrap ignores colour codes, art tooltips %d characters wide%s", kArtWrapChars,
ok ? "" : " (patch FAILED)");
}
RE_STARTUP(ArtTooltipStartup, Startup);
} // namespace
} // namespace mods::arttooltip
+38 -11
View File
@@ -58,6 +58,8 @@ struct CastWatch {
uint32_t target = 0; // an enemy, or 0 uint32_t target = 0; // an enemy, or 0
bool walked = false; bool walked = false;
DWORD since = 0; DWORD since = 0;
float cooldown = 0.0f; // the art's remaining cooldown when ordered
int32_t action = 0; // the hero's action when ordered (and while it lasts)
}; };
CastWatch g_cast; CastWatch g_cast;
std::unordered_map<uint16_t, bool> g_artRanged; std::unordered_map<uint16_t, bool> g_artRanged;
@@ -118,17 +120,24 @@ void WatchCast(void* hero) {
g_cast = {}; g_cast = {};
return; return;
} }
switch (game::CurrentAction(hero)) { // Started: the art's cooldown began (spells, combat moves and bow arts
case game::kActionWalk: // alike), or for an art without one, the hero went into a new action
g_cast.walked = true; // other than walking (arts ordered during an attack or a cast were taken
break; // as started by that one, measured 2026-10-18).
case game::kActionCastSpell: const int32_t action = game::CurrentAction(hero);
case game::kActionCombatMove: const game::ArtCooldown cooldown = game::GetArtCooldown(hero, g_cast.art);
if (cooldown.remaining > g_cast.cooldown) {
OnCastStarted(hero); OnCastStarted(hero);
break; return;
default:
break;
} }
g_cast.cooldown = cooldown.remaining; // regenerating meanwhile
if (action == game::kActionWalk) {
g_cast.walked = true;
} else if (action != g_cast.action && action != game::kActionIdle && cooldown.total <= 0.0f) {
OnCastStarted(hero);
return;
}
g_cast.action = action;
} }
void OnTick(uint8_t* engine) { void OnTick(uint8_t* engine) {
@@ -233,8 +242,26 @@ char __fastcall AttackHeld(void* cursor, void*) {
return target && game::IsAlive(target) ? 1 : 0; return target && game::IsAlive(target) ? 1 : 0;
} }
// A shot with a projectile weapon (action 5) checks the button the same way
// before the next shot (call at 0x4def52; shooter in esi, target creature in
// edi): held -> the next shot at a hostile target follows right away
// (measured: a held button fired for 2 s without new orders, auto-attack
// orders one shot per 0.67 s). The check after it (0x4def89) retargets to the
// creature under the cursor and is left alone.
constexpr uint32_t kShotEndButtonCheck = 0x004def52;
__declspec(naked) void ShotContinuesThunk() {
__asm {
push edi
push esi
call SwingContinues
ret
}
}
void Startup() { void Startup() {
re::RedirectCall(kSwingEndButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&SwingContinuesThunk)); re::RedirectCall(kSwingEndButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&SwingContinuesThunk));
re::RedirectCall(kShotEndButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&ShotContinuesThunk));
re::RedirectCall(kSwingButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&AttackHeld)); re::RedirectCall(kSwingButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&AttackHeld));
} }
RE_STARTUP(AutoAttackStartup, Startup); RE_STARTUP(AutoAttackStartup, Startup);
@@ -265,7 +292,8 @@ void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
break; break;
} }
g_cast = {static_cast<uint16_t>(order.art), IsEnemy(hero, order.target) ? order.target : 0u, false, g_cast = {static_cast<uint16_t>(order.art), IsEnemy(hero, order.target) ? order.target : 0u, false,
GetTickCount()}; GetTickCount(), game::GetArtCooldown(hero, static_cast<uint16_t>(order.art)).remaining,
game::CurrentAction(hero)};
break; break;
} }
} }
@@ -278,4 +306,3 @@ struct OrderRegistration {
} // namespace } // namespace
} // namespace mods::autoattack } // namespace mods::autoattack
+1 -1
View File
@@ -9,7 +9,7 @@
// Mouse positions are mapped back from that rectangle to game pixels, both in // Mouse positions are mapped back from that rectangle to game pixels, both in
// GetMouseClientPos (polled by the game) and in mouse window messages. // GetMouseClientPos (polled by the game) and in mouse window messages.
// //
// sacred_re.ini: // sacredK.ini:
// [borderless] // [borderless]
// Enabled=1 ; 0 = original behaviour // Enabled=1 ; 0 = original behaviour
// IntegerScale=0 ; 1 = scale by whole multiples only (sharper, larger bars) // IntegerScale=0 ; 1 = scale by whole multiples only (sharper, larger bars)
+120 -51
View File
@@ -11,14 +11,20 @@
// //
// This mod keeps all of that and decides: // This mod keeps all of that and decides:
// - hits per chain N = 3 + level / 5: the first bolt's [14]; // - hits per chain N = 3 + level / 5: the first bolt's [14];
// - the next target: the nearest enemy within half Blitzschlag's cast range (see // - the next target: an enemy within 75 % of Blitzschlag's cast range (see
// below; the game's own jump search used about 316) that this // below; the game's own jump search used about 316) that no chain of this
// chain has not hit yet; else another enemy; else the current target again // cast has claimed yet, so parallel chains take separate routes; else one
// (a lone enemy takes all N hits); // this chain has not hit yet; else another enemy; else the current target
// again (a lone enemy takes all N hits). Among those, near and straight
// ahead wins: each chain keeps its heading, and a chain's first jump turns
// away from the directions the other chains set out in, so the chains keep
// fanning out instead of merging again;
// - chains B = 1 + level / 10: B - 1 more chains start at the first target, // - chains B = 1 + level / 10: B - 1 more chains start at the first target,
// each with a bolt at its own first jump target (created while the first // each with a bolt at its own first jump target (created while the first
// bolt is set up, as copies of its event; no arc for that first hop). // bolt is set up, as copies of its event; no arc for that first hop).
#include <algorithm>
#include <cmath>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <map> #include <map>
@@ -28,6 +34,7 @@
#include <windows.h> #include <windows.h>
#include "game/engine/art.h"
#include "game/engine/arts.h" #include "game/engine/arts.h"
#include "game/engine/fx.h" #include "game/engine/fx.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
@@ -60,10 +67,10 @@ constexpr int kRaySegments = 20;
double CastRange() { double CastRange() {
return static_cast<double>(kRaySegmentLength) * kRaySegments; return static_cast<double>(kRaySegmentLength) * kRaySegments;
} }
// A jump between enemies reaches half as far as the cast (request of // A jump between enemies reaches 75 % of the cast range (requests: 2026-10-17
// 2026-10-17: 900 felt too far for jumps). // 900 felt too far, half; 2026-10-18 more room for separate routes, 75 %).
double JumpRange() { double JumpRange() {
return CastRange() / 2; return CastRange() * 0.75;
} }
constexpr uint32_t kConstant30 = 0x0088ef18; // float 30.0, shared by 72 instructions constexpr uint32_t kConstant30 = 0x0088ef18; // float 30.0, shared by 72 instructions
constexpr uint32_t kRayRemainingMinusSegment = 0x00526db6; // fsub [30.0] constexpr uint32_t kRayRemainingMinusSegment = 0x00526db6; // fsub [30.0]
@@ -75,8 +82,37 @@ constexpr size_t kCreaturePosition = 0x18; // 4 dwords, as effects are placed
constexpr size_t kEventHitsLeft = 14; constexpr size_t kEventHitsLeft = 14;
constexpr DWORD kChainLifetimeMs = 10000; constexpr DWORD kChainLifetimeMs = 10000;
// A direction on the ground (unit vector, world axes).
struct Direction {
double x = 0.0;
double y = 0.0;
};
Direction DirectionBetween(const void* from, const void* to) {
const double dx = game::CreatureField(to, game::kCreatureX) - game::CreatureField(from, game::kCreatureX);
const double dy = game::CreatureField(to, game::kCreatureY) - game::CreatureField(from, game::kCreatureY);
const double length = std::sqrt(dx * dx + dy * dy);
return length > 0.0 ? Direction{dx / length, dy / length} : Direction{};
}
double Dot(Direction a, Direction b) {
return a.x * b.x + a.y * b.y;
}
// The chains of one cast: the enemies they have hit or are jumping to, and the
// directions the chains set out in, so each chain takes its own route
// (2026-10-18: "make sure that those multiple chains dont just all follow the
// same route"; reported in play: with the nearest free enemy only, chains
// spread at the first jump and then mostly merged again).
struct Cast {
std::vector<uint32_t> claimed;
std::vector<Direction> starts;
};
struct Chain { struct Chain {
std::shared_ptr<Cast> cast;
std::vector<uint32_t> hit; // targets in order std::vector<uint32_t> hit; // targets in order
Direction heading; // the last jump's direction (0, 0 before the first)
DWORD started = 0; DWORD started = 0;
}; };
@@ -118,10 +154,67 @@ unsigned ArtLevel(void* creature, uint16_t art) {
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0; return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
} }
// The nearest living enemy of `caster` within the jump range of `from`, not bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
// `current`; one not in `avoid` if there is any. return std::find(ids.begin(), ids.end(), id) != ids.end();
uint32_t Nearest(void* caster, void* from, uint32_t current, const std::vector<uint32_t>& avoid) { }
return game::NearestHostile(caster, from, JumpRange(), current, avoid.data(), avoid.size());
// How strongly a jump keeps its direction: an enemy straight behind counts as
// three times as far as one straight ahead.
constexpr double kTurnWeight = 2.0;
// The next target of `chain` from `current` (the creature `from`), among the
// living enemies of `caster` within the jump range:
// - first enemies no chain of the cast has claimed, then ones this chain has
// not hit, then any other;
// - among those the cheapest: distance * (1 + kTurnWeight * turn), turn 0 for
// straight ahead along the chain's heading, 1 for straight back; a chain's
// first jump (no heading yet) turns away from the directions the cast's
// other chains set out in.
// Claims it for the cast and turns the chain's heading towards it; 0 if none.
uint32_t NextOf(Chain& chain, void* caster, void* from, uint32_t current) {
const bool first = chain.heading.x == 0.0 && chain.heading.y == 0.0;
uint32_t best = 0;
int bestTier = 3;
double bestCost = 0.0;
Direction bestDirection;
const int count = game::NearbyCreatureCount();
for (int i = 0; i < count; ++i) {
const uint32_t id = game::NearbyCreatureId(i);
void* creature = game::GetCreature(id);
if (id == current || !creature || game::IsPlayerHeroId(id) || !game::IsHostileTo(caster, creature)) {
continue;
}
const double distance = game::DistanceTo(from, id);
if (distance > JumpRange()) {
continue;
}
const int tier = !Contains(chain.cast->claimed, id) ? 0 : !Contains(chain.hit, id) ? 1 : 2;
const Direction direction = DirectionBetween(from, creature);
double turn = 0.0;
if (!first) {
turn = (1.0 - Dot(chain.heading, direction)) / 2.0;
} else {
for (const Direction start : chain.cast->starts) {
turn = std::max(turn, (1.0 + Dot(start, direction)) / 2.0);
}
}
const double cost = distance * (1.0 + kTurnWeight * turn);
if (best && (tier > bestTier || (tier == bestTier && cost >= bestCost))) {
continue;
}
best = id;
bestTier = tier;
bestCost = cost;
bestDirection = direction;
}
if (best) {
chain.cast->claimed.push_back(best);
if (first) {
chain.cast->starts.push_back(bestDirection);
}
chain.heading = bestDirection;
}
return best;
} }
void Forget() { void Forget() {
@@ -187,19 +280,23 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
const uint32_t hits = Hits(level); const uint32_t hits = Hits(level);
const unsigned chains = Chains(level); const unsigned chains = Chains(level);
event[kEventHitsLeft] = hits; event[kEventHitsLeft] = hits;
auto cast = std::make_shared<Cast>();
cast->claimed.push_back(target);
auto chain = std::make_shared<Chain>(); auto chain = std::make_shared<Chain>();
chain->cast = cast;
chain->hit.push_back(target); chain->hit.push_back(target);
chain->heading = DirectionBetween(caster, first);
cast->starts.push_back(chain->heading); // the other chains fan away from it
chain->started = GetTickCount(); chain->started = GetTickCount();
g_bolts[id] = {chain, hits}; g_bolts[id] = {chain, hits};
// More chains: each starts with a bolt at its own first jump target. // More chains: each starts with a bolt at its own first jump target.
std::vector<uint32_t> taken{target};
for (unsigned c = 1; c < chains && hits > 1; ++c) { for (unsigned c = 1; c < chains && hits > 1; ++c) {
const uint32_t next = Nearest(caster, first, target, taken);
const uint32_t branchTarget = next ? next : target;
taken.push_back(branchTarget);
auto branch = std::make_shared<Chain>(); auto branch = std::make_shared<Chain>();
branch->cast = cast;
branch->hit.push_back(target); branch->hit.push_back(target);
branch->started = chain->started; branch->started = chain->started;
const uint32_t next = NextOf(*branch, caster, first, target);
const uint32_t branchTarget = next ? next : target;
g_pending.push_back({branch, branchTarget}); g_pending.push_back({branch, branchTarget});
SpawnBolt(fx, event, branchTarget, hits - 1); SpawnBolt(fx, event, branchTarget, hits - 1);
} }
@@ -264,7 +361,7 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui
return 0; return 0;
} }
const std::shared_ptr<Chain> chain = it->second.chain; const std::shared_ptr<Chain> chain = it->second.chain;
uint32_t next = Nearest(casterCreature, from, current, chain->hit); uint32_t next = NextOf(*chain, casterCreature, from, current);
if (!next) { if (!next) {
next = current; // nobody else: hit the same enemy again next = current; // nobody else: hit the same enemy again
} }
@@ -277,8 +374,7 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui
// The tooltip: the game's "Anzahl: X" line (its hit count) becomes the mod's // The tooltip: the game's "Anzahl: X" line (its hit count) becomes the mod's
// two numbers, hits per chain and chains, in both the current level's section // two numbers, hits per chain and chains, in both the current level's section
// and the next level's (skill book). cCreatureArts::ArtTooltip (0x589270, // and the next level's (skill book). cCreatureArts::ArtTooltip (0x589270,
// thiscall on creature +0x3a8, (art, text, flag), ret 0xc) builds the text // mods/arttooltip.cpp, game::Art::Tooltip) builds the text; sections start with the level
// into a wide string {begin, end, capacity}; sections start with the level
// line, values are "Name: value" lines separated by '\n' (measured for art // line, values are "Name: value" lines separated by '\n' (measured for art
// 0x21 at level 5: "...|Aktuelle Stufe: 5|Regeneration: 3.5s|Schaden: ...| // 0x21 at level 5: "...|Aktuelle Stufe: 5|Regeneration: 3.5s|Schaden: ...|
// Anzahl: 7", with flag 1 also "Nächste erlernte Stufe: 6" and its values). // Anzahl: 7", with flag 1 also "Nächste erlernte Stufe: 6" and its values).
@@ -290,22 +386,6 @@ constexpr const wchar_t* kCurrentLevelLine = L"Aktuelle Stufe:";
constexpr const wchar_t* kNextLevelLine = L"Nächste erlernte Stufe:"; constexpr const wchar_t* kNextLevelLine = L"Nächste erlernte Stufe:";
constexpr const wchar_t* kCountLine = L"Anzahl:"; constexpr const wchar_t* kCountLine = L"Anzahl:";
// The game's wide string append (0x592ef0, thiscall (first, last, unused),
// ret 0xc); grows the buffer with the game's allocator.
void AppendText(void* text, const wchar_t* first, const wchar_t* last) {
using Fn = void*(__fastcall*)(void*, void*, const wchar_t*, const wchar_t*, uint32_t);
reinterpret_cast<Fn>(0x00592ef0)(text, nullptr, first, last, 0);
}
void SetText(void* text, const std::wstring& value) {
auto** string = static_cast<wchar_t**>(text);
if (string[0]) {
string[1] = string[0];
*string[0] = L'\0';
}
AppendText(text, value.data(), value.data() + value.size());
}
std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) { std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) {
std::wstring out; std::wstring out;
unsigned level = currentLevel; unsigned level = currentLevel;
@@ -335,24 +415,13 @@ std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) {
return out; return out;
} }
void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag); void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
RE_HOOK(0x00589270, Arts_Tooltip); text = Rewrite(text, ArtLevel(creature, art.Id()));
void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag) {
Arts_Tooltip_original(self, edx, art, text, flag);
if (!text || static_cast<uint16_t>(art) != kArtBlitzschlag) {
return;
}
auto* const* string = static_cast<wchar_t* const*>(text);
if (!string[0] || string[1] <= string[0]) {
return;
}
void* creature = static_cast<uint8_t*>(self) - game::kCreatureArts;
const std::wstring before(string[0], string[1]);
const std::wstring after = Rewrite(before, ArtLevel(creature, kArtBlitzschlag));
if (after != before) {
SetText(text, after);
}
} }
struct TooltipRegistration {
TooltipRegistration() { game::AddTooltipFilter(kArtBlitzschlag, &RewriteTooltip); }
} g_tooltipRegistration;
} // namespace } // namespace
} // namespace mods::chainlightning } // namespace mods::chainlightning
+104
View File
@@ -0,0 +1,104 @@
// 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
+4 -33
View File
@@ -12,6 +12,8 @@
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include "game/ui/control.h"
#include "game/ui/windows.h"
#include "mods/hud.h" #include "mods/hud.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -39,15 +41,13 @@ constexpr int kEscMenuSlot = 0x2c;
// ends the dialog's input lock, and passes answers below 100000 to the quest // ends the dialog's input lock, and passes answers below 100000 to the quest
// script (0x46bd80). Esc does the same with an answer of 100000 or more: the // script (0x46bd80). Esc does the same with an answer of 100000 or more: the
// dialog closes and the heroes are free, nothing is chosen. // dialog closes and the heroes are free, nothing is chosen.
constexpr uint32_t kHideVisible = 1;
constexpr uint32_t kDialogManager = 0x00aaaf00; constexpr uint32_t kDialogManager = 0x00aaaf00;
constexpr size_t kTooltipDialogOwner = 0x21cc; constexpr size_t kTooltipDialogOwner = 0x21cc;
constexpr uint32_t kNoAnswer = 0xffffffff; // >= 100000: no script callback constexpr uint32_t kNoAnswer = 0xffffffff; // >= 100000: no script callback
constexpr uint32_t kDialogPending = 0x00ab5314; constexpr uint32_t kDialogPending = 0x00ab5314;
void CloseDialog(uint8_t* tooltip) { void CloseDialog(uint8_t* tooltip) {
using HideFn = void(__fastcall*)(void*, void*, uint32_t); game::SetControlShown(tooltip, false);
reinterpret_cast<HideFn>(0x00731ee0)(tooltip, nullptr, kHideVisible);
using AnswerFn = void(__fastcall*)(void*, void*, uint32_t owner, uint32_t answer); using AnswerFn = void(__fastcall*)(void*, void*, uint32_t owner, uint32_t answer);
reinterpret_cast<AnswerFn>(0x0049a090)(reinterpret_cast<void*>(kDialogManager), nullptr, reinterpret_cast<AnswerFn>(0x0049a090)(reinterpret_cast<void*>(kDialogManager), nullptr,
*reinterpret_cast<uint32_t*>(tooltip + kTooltipDialogOwner), kNoAnswer); *reinterpret_cast<uint32_t*>(tooltip + kTooltipDialogOwner), kNoAnswer);
@@ -55,39 +55,10 @@ void CloseDialog(uint8_t* tooltip) {
} }
constexpr size_t kControlFlags = 0x10; // bit 0: visible constexpr size_t kControlFlags = 0x10; // bit 0: visible
// cEventUI2 as HandleEsc builds it (event at [esp+10h] there).
constexpr uint32_t kEventUiVtable = 0x0088edd0;
constexpr uint32_t kEventUiWindowCommand = 0x12;
constexpr uint32_t kWindowCommandHide = 5;
constexpr size_t kEventUiSize = 0x80;
// Names, written by 0x5904e0 (sender at +0, window at +0x20 of this): measured
// in the UI commands the game sends when a window is closed with its button or
// hotkey (window name at +0x48).
constexpr size_t kEventUiNames = 0x28;
constexpr size_t kEventUiText = 0x68; // a string, 0x423920 / 0x416660
bool Visible(const uint8_t* window) { bool Visible(const uint8_t* window) {
return window && (window[kControlFlags] & 1) != 0; return window && (window[kControlFlags] & 1) != 0;
} }
// The window command goes to the UI manager's event handler (vf00,
// 0x757120), which routes it by name, as HandleEsc does for the inventory.
// (Sent to the window's own handler it was not understood the same way: the
// trader changed its stock instead of closing.)
void Hide(uint32_t* manager, const char* name) {
alignas(4) uint8_t event[kEventUiSize] = {};
*reinterpret_cast<uint32_t*>(event) = kEventUiVtable;
*reinterpret_cast<uint32_t*>(event + 4) = kEventUiWindowCommand;
*reinterpret_cast<uint32_t*>(event + 8) = kWindowCommandHide;
using SetNamesFn = void(__fastcall*)(void*, void*, const char* window, const char* sender);
reinterpret_cast<SetNamesFn>(0x005904e0)(event + kEventUiNames, nullptr, name, "");
using StringFn = void(__fastcall*)(void*, void*);
reinterpret_cast<StringFn>(0x00423920)(event + kEventUiText, nullptr); // construct
using HandleFn = int(__fastcall*)(void*, void*, void*);
reinterpret_cast<HandleFn>((*reinterpret_cast<void***>(manager))[0])(manager, nullptr, event);
reinterpret_cast<StringFn>(0x00416660)(event + kEventUiText, nullptr); // destroy
}
} // namespace } // namespace
// True if Esc closed windows (the game's handling is skipped). // True if Esc closed windows (the game's handling is skipped).
@@ -109,7 +80,7 @@ bool CloseWindows(uint32_t* manager) {
for (const ClosableWindow& w : kClosable) { for (const ClosableWindow& w : kClosable) {
auto* window = reinterpret_cast<uint8_t*>(manager[w.slot]); auto* window = reinterpret_cast<uint8_t*>(manager[w.slot]);
if (Visible(window)) { if (Visible(window)) {
Hide(manager, w.name); game::SendWindowCommand(manager, w.name, game::WindowCommand::Hide);
closed = true; closed = true;
} }
} }
+1 -1
View File
@@ -11,7 +11,7 @@ namespace game {
// cParticleSystem_burningBone::Render (vf04 0x774e10, vtable 0x895b84): // cParticleSystem_burningBone::Render (vf04 0x774e10, vtable 0x895b84):
// crashed reading the device it was given, which was null (2026-10-16, Esc // crashed reading the device it was given, which was null (2026-10-16, Esc
// pressed while walking; Windows reported sacred.exe+0x374e39). Who renders // pressed while walking; Windows reported sacred.exe+0x374e39). Who renders
// it without a device is not known yet (sacred_re_crash.txt will tell). With // it without a device is not known yet (sacredK_crash.txt will tell). With
// no device there is nothing to draw. // no device there is nothing to draw.
// (device, a 4x4 matrix by value; ret 0x44). // (device, a 4x4 matrix by value; ret 0x44).
struct Matrix4 { struct Matrix4 {
+75 -5
View File
@@ -16,7 +16,7 @@
// inside both. // inside both.
// The world never sees these offsets: they are only active inside the hooks. // The world never sees these offsets: they are only active inside the hooks.
// //
// sacred_re.ini: // sacredK.ini:
// [hud] // [hud]
// Enabled=1 // Enabled=1
@@ -430,6 +430,49 @@ HRESULT __stdcall ClearShifted(void* device, DWORD count, Rect7* rects, DWORD fl
return g_clear(device, count, shifted.data(), flags, color, z, stencil); return g_clear(device, count, shifted.data(), flags, color, z, stencil);
} }
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
// 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) {
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);
Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous_.minZ, previous_.maxZ};
device_ = device;
g_setViewport(device, &frame);
}
~FrameScope() {
if (device_) {
g_setViewport(device_, &previous_);
}
}
FrameScope(const FrameScope&) = delete;
FrameScope& operator=(const FrameScope&) = delete;
private:
void* device_ = nullptr;
Viewport7 previous_ = {};
};
} // namespace } // namespace
int CursorOffsetX() { int CursorOffsetX() {
@@ -571,6 +614,27 @@ int TooltipOffset(void* tooltip) {
return name##_original(self, edx, device, a, b); \ return name##_original(self, edx, device, a, b); \
} }
// Centred full-screen windows: black beside the frame, viewport on the frame.
#define HUD_RENDER_FRAMED(address, name) \
int __fastcall name(void* self, void* edx, void* device); \
RE_HOOK(address, name); \
int __fastcall name(void* self, void* edx, void* device) { \
const int dx = mods::hud::OffsetFor(mods::hud::Anchor::Center); \
mods::hud::FrameScope frame(self, device, dx); \
mods::hud::DrawOffset offset(self, dx); \
return name##_original(self, edx, device); \
}
#define HUD_RENDER3_FRAMED(address, name) \
int __fastcall name(void* self, void* edx, void* device, int a, int b); \
RE_HOOK(address, name); \
int __fastcall name(void* self, void* edx, void* device, int a, int b) { \
const int dx = mods::hud::OffsetFor(mods::hud::Anchor::Center); \
mods::hud::FrameScope frame(self, device, dx); \
mods::hud::DrawOffset offset(self, dx); \
return name##_original(self, edx, device, a, b); \
}
namespace game { namespace game {
using mods::hud::Anchor; using mods::hud::Anchor;
@@ -629,11 +693,10 @@ HUD_RENDER(0x006ea750, UiTrade_Render, Anchor::Center)
HUD_EVENT(0x006f8960, UiNetworkInfo_HandleEvent, Anchor::Center) HUD_EVENT(0x006f8960, UiNetworkInfo_HandleEvent, Anchor::Center)
HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center) HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center)
HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center) HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center)
HUD_RENDER3(0x0070f160, UiCharacter_Render, Anchor::Center) HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game
HUD_EVENT(0x00716b30, UiOptions_HandleEvent, Anchor::Center) // cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp.
HUD_RENDER3(0x00716640, UiOptions_Render, Anchor::Center)
HUD_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center) HUD_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center)
HUD_RENDER(0x0071dd80, UiSavegame_Render, Anchor::Center) HUD_RENDER_FRAMED(0x0071dd80, UiSavegame_Render)
HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center) HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center)
HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center) HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
@@ -714,6 +777,13 @@ int RenderCentred(void* window, int (*body)(void* context), void* context) {
return body(context); return body(context);
} }
int RenderFramed(void* window, void* device, int (*body)(void* context), void* context) {
const int dx = OffsetFor(Anchor::Center);
FrameScope frame(window, device, dx);
DrawOffset offset(window, dx);
return body(context);
}
int HandleCentred(void* event, int (*body)(void* context), void* context) { int HandleCentred(void* event, int (*body)(void* context), void* context) {
EventOffset offset(event, OffsetFor(Anchor::Center)); EventOffset offset(event, OffsetFor(Anchor::Center));
return body(context); return body(context);
+3
View File
@@ -26,5 +26,8 @@ int VisibleDialogTooltips(void** out, int max);
// shifted) or event handler (mouse position shifted by the window's offset). // shifted) or event handler (mouse position shifted by the window's offset).
int RenderCentred(void* window, int (*body)(void* context), void* context); int RenderCentred(void* window, int (*body)(void* context), void* context);
int HandleCentred(void* event, int (*body)(void* context), void* context); int HandleCentred(void* event, int (*body)(void* context), void* context);
// RenderCentred for full-screen windows: black beside the 1024 frame, drawn
// with a viewport on it (as the save game and export screens).
int RenderFramed(void* window, void* device, int (*body)(void* context), void* context);
} // namespace mods::hud } // namespace mods::hud
+383 -69
View File
@@ -6,22 +6,30 @@
// 0x6e1970). This mod reads the raw key messages instead (virtual keys, // 0x6e1970). This mod reads the raw key messages instead (virtual keys,
// independent of the keyboard layout): a bound key's messages are swallowed // independent of the keyboard layout): a bound key's messages are swallowed
// and its action runs. Skill keys cast their slot (src/mods/skillkeys.cpp), // and its action runs. Skill keys cast their slot (src/mods/skillkeys.cpp),
// weapon set keys select it on the taskbar; keys standing in for an original // weapon set keys select it on the taskbar; the other actions call the game
// hotkey post that hotkey's key event to the game's event kernel, the way the window // function its hotkey ran. The game's own hotkeys for bound actions are
// procedure does (the kernel dispatches it right away, on the window thread). // switched off (potions) or kept from it (the keys are swallowed).
// //
// While a text field has the keyboard, keys pass through unchanged. (Keys // While a text field has the keyboard, keys pass through unchanged. (Keys
// with Ctrl / Alt held used to pass too; GetKeyState reports Alt as held after // with Ctrl / Alt held used to pass too; GetKeyState reports Alt as held after
// Alt+Tab until it is pressed again in the game, which switched the bindings // Alt+Tab until it is pressed again in the game, which switched the bindings
// off. Alt+key arrives as WM_SYSKEYDOWN and is not handled here anyway.) // off. Alt+key arrives as WM_SYSKEYDOWN and is not handled here anyway.)
// //
// sacred_re.ini (key names: A-Z, 0-9, F1-F24, Space, None): // The bindings are set on the options window's key bindings tab
// (mods/options.cpp) and kept in sacredK.ini (key names: kKeyNames below,
// None):
// [keys] // [keys]
// SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art) // SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art)
// WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set // WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set
// ActionSlot3=G ; what W did (W is movement) // MoveUp=W, MoveLeft=A, MoveDown=S, MoveRight=D ; direction movement (mods/wasd.cpp polls them)
// Potion1=None ... Potion5=None ; drink a potion of kind 1-5
// AllyPotion=None ; kind 1 on the weakest ally (the game's own potion keys
// ; Space/B/Q/W/E/R are switched off)
// PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up) // PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up)
// SaveGame=K ; what S did (save game window) // SaveGame=K ; what S did (save game window)
// Map=M ; open / close the map (the game's M is switched off)
// OverviewMap=Tab ; the overview map while held (the game's Tab is switched off)
// Labels=Alt ; names of items and creatures while held (the game's Alt is switched off)
#include "mods/input.h" #include "mods/input.h"
@@ -29,14 +37,16 @@
#include <cctype> #include <cctype>
#include <cstdint> #include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <iterator>
#include <string> #include <string>
#include "game/engine/events.h" #include "game/engine/events.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/items.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "game/ui/windows.h"
#include "mods/skillbar.h" #include "mods/skillbar.h"
#include "mods/skillkeys.h" #include "mods/skillkeys.h"
#include "mods/wasd.h"
#include "re/config.h" #include "re/config.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -45,69 +55,109 @@
namespace mods::input { namespace mods::input {
namespace { namespace {
// --- sacred.exe ---------------------------------------------------------------
constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager*
constexpr uint32_t kKeyModifiers = 0x0182cdec; // u16, as the in-game window procedure keeps it
constexpr uint32_t kEventKeyVtable = 0x00895568;
// cEvent_key: +4 1 = pressed, 0 = released; +6 / +8 the key (character when
// pressed, virtual key when released); +0xa modifiers.
struct KeyEvent {
uint32_t vtable;
uint16_t pressed;
uint16_t raw;
uint16_t key;
uint16_t modifiers;
};
static_assert(sizeof(KeyEvent) == 0xc);
// Posts the key event the window procedure would post for `key` (an original
// hotkey letter or digit).
void PostGameKey(char key, bool pressed) {
KeyEvent event = {};
event.vtable = kEventKeyVtable;
event.pressed = pressed ? 1 : 0;
// Pressed: the character (lower case, as without Shift); released: the
// virtual key (upper case).
const auto code = static_cast<uint16_t>(pressed ? tolower(key) : toupper(key));
event.raw = code;
event.key = code;
event.modifiers = *reinterpret_cast<uint16_t*>(kKeyModifiers);
game::PostEvent(&event);
}
// --- bindings ------------------------------------------------------------------ // --- bindings ------------------------------------------------------------------
enum class Kind { SkillSlot, WeaponSet, GameKey, PickUp }; // Move: a held key polled by mods/wasd.cpp; its messages are only swallowed.
// Labels: a held key the game polls (LabelsKeyState); its messages are only
// swallowed.
enum class Kind { SkillSlot, WeaponSet, Move, Potion, PickUp, SaveGame, Map, OverviewMap, Labels };
// Potion index: the potion kind, with this flag on the weakest ally.
constexpr int kAllyPotion = 0x100;
struct Binding { struct Binding {
const char* name; const char* name;
const char* fallback; const char* fallback;
const wchar_t* label;
Kind kind; Kind kind;
int index; // slot / set (0-based) int index; // slot / set (0-based), Move direction
char gameKey; // GameKey: the original hotkey
int vk = 0; int vk = 0;
}; };
// Display order (the key bindings page shows them in groups by kind).
Binding g_bindings[] = { Binding g_bindings[] = {
{"SkillSlot0", "0", Kind::SkillSlot, 0, 0}, {"SkillSlot0", "0", L"Kampfkunst 0 (Rechtsklick)", Kind::SkillSlot, 0},
{"SkillSlot1", "1", Kind::SkillSlot, 1, 0}, {"SkillSlot1", "1", L"Kampfkunst 1", Kind::SkillSlot, 1},
{"SkillSlot2", "2", Kind::SkillSlot, 2, 0}, {"SkillSlot2", "2", L"Kampfkunst 2", Kind::SkillSlot, 2},
{"SkillSlot3", "3", Kind::SkillSlot, 3, 0}, {"SkillSlot3", "3", L"Kampfkunst 3", Kind::SkillSlot, 3},
{"SkillSlot4", "4", Kind::SkillSlot, 4, 0}, {"SkillSlot4", "4", L"Kampfkunst 4", Kind::SkillSlot, 4},
{"SkillSlot5", "5", Kind::SkillSlot, 5, 0}, {"SkillSlot5", "5", L"Kampfkunst 5", Kind::SkillSlot, 5},
{"SkillSlot6", "6", Kind::SkillSlot, 6, 0}, {"SkillSlot6", "6", L"Kampfkunst 6", Kind::SkillSlot, 6},
{"WeaponSet1", "F1", Kind::WeaponSet, 0, 0}, {"WeaponSet1", "F1", L"Waffenset 1", Kind::WeaponSet, 0},
{"WeaponSet2", "F2", Kind::WeaponSet, 1, 0}, {"WeaponSet2", "F2", L"Waffenset 2", Kind::WeaponSet, 1},
{"WeaponSet3", "F3", Kind::WeaponSet, 2, 0}, {"WeaponSet3", "F3", L"Waffenset 3", Kind::WeaponSet, 2},
{"WeaponSet4", "F4", Kind::WeaponSet, 3, 0}, {"WeaponSet4", "F4", L"Waffenset 4", Kind::WeaponSet, 3},
{"WeaponSet5", "F5", Kind::WeaponSet, 4, 0}, {"WeaponSet5", "F5", L"Waffenset 5", Kind::WeaponSet, 4},
{"WeaponSet6", "F6", Kind::WeaponSet, 5, 0}, {"WeaponSet6", "F6", L"Waffenset 6", Kind::WeaponSet, 5},
{"WeaponSet7", "F7", Kind::WeaponSet, 6, 0}, {"WeaponSet7", "F7", L"Waffenset 7", Kind::WeaponSet, 6},
{"ActionSlot3", "G", Kind::GameKey, 0, 'W'}, {"MoveUp", "W", L"Nach oben", Kind::Move, static_cast<int>(Move::Up)},
{"PickUp", "Q", Kind::PickUp, 0, 0}, {"MoveLeft", "A", L"Nach links", Kind::Move, static_cast<int>(Move::Left)},
{"SaveGame", "K", Kind::GameKey, 0, 'S'}, {"MoveDown", "S", L"Nach unten", Kind::Move, static_cast<int>(Move::Down)},
{"MoveRight", "D", L"Nach rechts", Kind::Move, static_cast<int>(Move::Right)},
{"Potion1", "None", L"Trank 1", Kind::Potion, 1},
{"Potion2", "None", L"Trank 2", Kind::Potion, 2},
{"Potion3", "None", L"Trank 3", Kind::Potion, 3},
{"Potion4", "None", L"Trank 4", Kind::Potion, 4},
{"Potion5", "None", L"Trank 5", Kind::Potion, 5},
{"AllyPotion", "None", L"Trank 1", Kind::Potion, 1 | kAllyPotion},
{"PickUp", "Q", L"Gegenstände aufheben", Kind::PickUp, 0},
{"SaveGame", "K", L"Spiel speichern", Kind::SaveGame, 0},
{"Map", "M", L"Karte", Kind::Map, 0},
{"OverviewMap", "Tab", L"Minikarte (halten)", Kind::OverviewMap, 0},
{"Labels", "Alt", L"Beschriftungen (halten)", Kind::Labels, 0},
}; };
constexpr int kBindingCount = static_cast<int>(std::size(g_bindings));
// Keys that can be bound besides A-Z, 0-9 and F1-F24 (named by their letter /
// number): ini name, shown name. Esc and Backspace stay free for the key
// bindings page (cancel / unbind), Enter for the game's chat.
struct NamedKey {
int vk;
const char* ini;
const wchar_t* label;
};
constexpr NamedKey kKeyNames[] = {
{VK_SPACE, "Space", L"Leertaste"}, {VK_TAB, "Tab", L"Tab"},
{VK_MENU, "Alt", L"Alt"},
{VK_INSERT, "Insert", L"Einfg"}, {VK_DELETE, "Delete", L"Entf"},
{VK_HOME, "Home", L"Pos1"}, {VK_END, "End", L"Ende"},
{VK_PRIOR, "PageUp", L"Bild auf"}, {VK_NEXT, "PageDown", L"Bild ab"},
{VK_UP, "Up", L"Pfeil hoch"}, {VK_DOWN, "Down", L"Pfeil runter"},
{VK_LEFT, "Left", L"Pfeil links"}, {VK_RIGHT, "Right", L"Pfeil rechts"},
{VK_NUMPAD0, "Num0", L"Num 0"}, {VK_NUMPAD1, "Num1", L"Num 1"},
{VK_NUMPAD2, "Num2", L"Num 2"}, {VK_NUMPAD3, "Num3", L"Num 3"},
{VK_NUMPAD4, "Num4", L"Num 4"}, {VK_NUMPAD5, "Num5", L"Num 5"},
{VK_NUMPAD6, "Num6", L"Num 6"}, {VK_NUMPAD7, "Num7", L"Num 7"},
{VK_NUMPAD8, "Num8", L"Num 8"}, {VK_NUMPAD9, "Num9", L"Num 9"},
};
bool LetterOrDigit(int vk) {
return (vk >= 'A' && vk <= 'Z') || (vk >= '0' && vk <= '9');
}
bool FunctionKey(int vk) {
return vk >= VK_F1 && vk <= VK_F24;
}
const NamedKey* NamedKeyFor(int vk) {
for (const NamedKey& key : kKeyNames) {
if (key.vk == vk) {
return &key;
}
}
return nullptr;
}
// The ini name of a bindable key ("None" for 0).
std::string IniName(int vk) {
if (LetterOrDigit(vk)) {
return std::string(1, static_cast<char>(vk));
}
if (FunctionKey(vk)) {
return "F" + std::to_string(vk - VK_F1 + 1);
}
const NamedKey* key = NamedKeyFor(vk);
return key ? key->ini : "None";
}
int ParseKey(const std::string& text) { int ParseKey(const std::string& text) {
if (text.size() == 1 && isalnum(static_cast<unsigned char>(text[0]))) { if (text.size() == 1 && isalnum(static_cast<unsigned char>(text[0]))) {
@@ -119,8 +169,10 @@ int ParseKey(const std::string& text) {
return VK_F1 + n - 1; return VK_F1 + n - 1;
} }
} }
if (_stricmp(text.c_str(), "Space") == 0) { for (const NamedKey& key : kKeyNames) {
return VK_SPACE; if (_stricmp(text.c_str(), key.ini) == 0) {
return key.vk;
}
} }
return 0; // None / unknown return 0; // None / unknown
} }
@@ -145,11 +197,14 @@ const Binding* BindingFor(int vk) {
} }
// Keys taken away from the game although nothing is bound to them: the digit // Keys taken away from the game although nothing is bound to them: the digit
// row (the game's slot keys) and the movement keys. // row (the game's slot keys).
bool Reserved(int vk) { bool Reserved(int vk) {
return (vk >= '0' && vk <= '9') || mods::wasd::IsMovementKey(vk); return vk >= '0' && vk <= '9';
} }
// The key bindings page's key capture.
void (*g_capture)(int vk) = nullptr;
// Pick up items nearby: the order the engine's A hotkey sends (creature order // Pick up items nearby: the order the engine's A hotkey sends (creature order
// 0x113, cEngine::ReceiveEvent 0x618c41; the hero collects, mode +0xfc 0xc), // 0x113, cEngine::ReceiveEvent 0x618c41; the hero collects, mode +0xfc 0xc),
// given to the controlled creature on the next engine tick. (Posting the A // given to the controlled creature on the next engine tick. (Posting the A
@@ -163,8 +218,30 @@ constexpr uint32_t kCollectRingLimit = 0x004ec8c6;
constexpr uint8_t kCollectRingsOriginal = 0xec; // -20 constexpr uint8_t kCollectRingsOriginal = 0xec; // -20
constexpr uint8_t kCollectRings = 0xd8; // -40 constexpr uint8_t kCollectRings = 0xd8; // -40
// Drink a potion: cEngine::UseActionSlot (0x613040, thiscall (potion kind,
// on an ally), ret 8), as the engine's potion hotkeys call it, on the next
// engine tick. Those hotkeys (cEngine::ReceiveEvent, a jump table on the
// upper-case character - 0x20: index bytes at 0x619adc, targets at 0x619ac0)
// are switched off: Space kind 1 (Shift: 5), B kind 1 on the weakest ally,
// Q 2, W 3, E 4, R 5 now go to index 6, the table's "no hotkey".
constexpr uint32_t kHotkeyIndices = 0x00619adc;
constexpr uint8_t kNoHotkey = 6;
constexpr re::BytePatch kPotionHotkeys[] = {
{kHotkeyIndices + ' ' - 0x20, 0, 0}, {kHotkeyIndices + 'B' - 0x20, 0, 1},
{kHotkeyIndices + 'E' - 0x20, 0, 2}, {kHotkeyIndices + 'Q' - 0x20, 0, 3},
{kHotkeyIndices + 'R' - 0x20, 0, 4}, {kHotkeyIndices + 'W' - 0x20, 0, 5},
};
std::atomic<int> g_potion{0};
void UseActionSlot(uint8_t* engine, int kind, bool ally) {
using UseFn = int(__fastcall*)(void*, void*, int, int);
reinterpret_cast<UseFn>(0x00613040)(engine, nullptr, kind, ally ? 1 : 0);
}
void OnTick(uint8_t* engine) { void OnTick(uint8_t* engine) {
if (const int potion = g_potion.exchange(0)) {
UseActionSlot(engine, potion & 0xff, (potion & kAllyPotion) != 0);
}
if (!g_pickUp.exchange(false)) { if (!g_pickUp.exchange(false)) {
return; return;
} }
@@ -175,6 +252,107 @@ void OnTick(uint8_t* engine) {
} }
} }
// The save game window, as the UI manager's S hotkey opens it (0x757775):
// only with the window built (manager slot 0x2b) and no blocking window open;
// it posts an engine event (code 0x13, 5, 1; with 0 for the kernel's second
// argument) and shows UI_WND_SAVEGAME.
constexpr int kSaveGameSlot = 0x2b;
constexpr uint32_t kEngineEventVtable = 0x0088eb38;
constexpr uint32_t kEngineEventSaveGame = 0x13;
void OpenSaveGame() {
uint32_t* ui = game::UiManager();
if (!ui || !ui[kSaveGameSlot] || !game::UiCanOpenWindow(ui)) {
return;
}
uint32_t event[9] = {kEngineEventVtable, kEngineEventSaveGame, 5, 1};
game::PostEvent(event, 0);
game::SendWindowCommand(ui, "UI_WND_SAVEGAME", game::WindowCommand::Show);
}
// The map (cUI_Megamap, manager slot 0x25), as the UI manager's M hotkey
// toggles it (cUI_Manager::ReceiveEvent 0x757120): open, M closes it; else
// nothing while one of the windows it checks first is open (slots 0x2b save
// game, 0x2c, 0x2f options, or the flag 0x17e82c4); it opens unless the flag
// 0x17e8264 is set or slot 0x36 is open. That hotkey is switched off: its
// letter switch (upper-case character - 'C': index bytes at 0x75868c, targets
// at 0x758660) sends M to index 10, "no hotkey", and the closing branch
// compares with -1 instead of 'M' (cmp eax, 4Dh at 0x757f86).
constexpr int kMegamapSlot = 0x25;
constexpr int kMapBlockingSlots[] = {0x2b, 0x2c, 0x2f};
constexpr uint32_t kMapBlockedFlag = 0x017e82c4;
constexpr uint32_t kMapDisabledFlag = 0x017e8264;
constexpr int kMapDisabledSlot = 0x36;
constexpr uint32_t kUiLetterIndices = 0x0075868c;
constexpr uint8_t kUiLetterMap = 5;
constexpr uint8_t kUiNoHotkey = 10;
constexpr uint32_t kUiMapCloseCompare = 0x00757f86;
constexpr size_t kControlFlagsOffset = 0x10; // bit 0: visible
bool WindowShown(const uint32_t* ui, int slot) {
const auto* window = reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(ui[slot]));
return window && (*reinterpret_cast<const uint32_t*>(window + kControlFlagsOffset) & 1) != 0;
}
bool Flag(uint32_t address) {
return *reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(address)) != 0;
}
void ToggleMap() {
uint32_t* ui = game::UiManager();
if (!ui || !ui[kMegamapSlot]) {
return;
}
if (WindowShown(ui, kMegamapSlot)) {
game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Hide);
return;
}
for (const int slot : kMapBlockingSlots) {
if (WindowShown(ui, slot)) {
return;
}
}
if (!Flag(kMapBlockedFlag) && !Flag(kMapDisabledFlag) && !WindowShown(ui, kMapDisabledSlot)) {
game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Show);
}
}
// The overview map (cUI_OverviewMap) while the key is held, as the game's
// window procedure (0x8110d0) shows it on Tab down (0x81146d) and hides it on
// Tab up (0x8117f8). Both are switched off: their switches on the virtual key
// - 9 (down: index bytes at 0x812124, targets at 0x812110; up: 0x812270 /
// 0x812260) send Tab to their "other key" index.
constexpr uint32_t kKeyDownIndices = 0x00812124;
constexpr uint32_t kKeyUpIndices = 0x00812270;
constexpr uint8_t kKeyDownOther = 4;
constexpr uint8_t kKeyUpOther = 3;
void ShowOverviewMap(bool show) {
if (uint32_t* ui = game::UiManager()) {
game::SendWindowCommand(ui, "UI_WND_OVERVIEWMAP",
show ? game::WindowCommand::Show : game::WindowCommand::Hide);
}
}
// Labels: the game asks GetAsyncKeyState(VK_MENU) for its name overlay
// (cWorldView0::DrawObjectLabels 0x627260 and PickObjectAt 0x626d30, cEngine
// 0x60f1d0 / 0x611f10 / 0x617360, cCreature 0x599880; and the map's debug
// teleport, cUI_Megamap 0x6c7ec0); those calls (call [import]) ask
// LabelsKeyState instead.
constexpr uint32_t kGetAsyncKeyStateImport = 0x0088e2b0;
constexpr uint32_t kLabelsKeyCalls[] = {0x00599ebf, 0x0060f227, 0x00612015, 0x006176ec,
0x00626ffe, 0x00627299, 0x006c80b0};
SHORT WINAPI LabelsKeyState(int) {
for (const Binding& binding : g_bindings) {
if (binding.kind == Kind::Labels) {
return binding.vk ? GetAsyncKeyState(binding.vk) : 0;
}
}
return 0;
}
SHORT(WINAPI* const g_labelsKeyState)(int) = &LabelsKeyState;
struct Registration { struct Registration {
Registration() { Registration() {
game::OnEngineTick(&OnTick); game::OnEngineTick(&OnTick);
@@ -190,12 +368,24 @@ void Press(const Binding& binding, int vk) {
case Kind::WeaponSet: case Kind::WeaponSet:
mods::skillbar::SelectWeaponSet(slot); mods::skillbar::SelectWeaponSet(slot);
break; break;
case Kind::GameKey: case Kind::SaveGame:
PostGameKey(binding.gameKey, true); OpenSaveGame();
break; break;
case Kind::PickUp: case Kind::PickUp:
g_pickUp = true; g_pickUp = true;
break; break;
case Kind::Potion:
g_potion = binding.index;
break;
case Kind::Map:
ToggleMap();
break;
case Kind::OverviewMap:
ShowOverviewMap(true);
break;
case Kind::Move:
case Kind::Labels:
break;
} }
} }
@@ -204,11 +394,16 @@ void Release(const Binding& binding, int vk) {
case Kind::SkillSlot: case Kind::SkillSlot:
mods::skillkeys::KeyReleased(vk); mods::skillkeys::KeyReleased(vk);
break; break;
case Kind::WeaponSet: case Kind::OverviewMap:
case Kind::PickUp: ShowOverviewMap(false);
break; break;
case Kind::GameKey: case Kind::WeaponSet:
PostGameKey(binding.gameKey, false); case Kind::Move:
case Kind::Potion:
case Kind::PickUp:
case Kind::SaveGame:
case Kind::Map:
case Kind::Labels:
break; break;
} }
} }
@@ -222,6 +417,17 @@ bool g_swallowChar = false;
void Startup() { void Startup() {
LoadBindings(); LoadBindings();
if (re::PatchAll(kPotionHotkeys, kNoHotkey) != 0) {
re::Log("input: potion hotkey patch FAILED");
}
if (!re::PatchInstructionByte(kUiLetterIndices + 'M' - 'C', 0, kUiLetterMap, kUiNoHotkey) ||
!re::PatchInstructionByte(kUiMapCloseCompare, 2, 'M', 0xff) ||
!re::PatchInstructionByte(kKeyDownIndices + VK_TAB - 9, 0, 0, kKeyDownOther) ||
!re::PatchInstructionByte(kKeyUpIndices + VK_TAB - 9, 0, 0, kKeyUpOther) ||
re::PatchAll(kLabelsKeyCalls, kGetAsyncKeyStateImport,
static_cast<uint32_t>(reinterpret_cast<uintptr_t>(&g_labelsKeyState))) != 0) {
re::Log("input: map / overview map / labels key patch FAILED");
}
if (!re::PatchInstructionByte(kCollectRingLimit, 2, kCollectRingsOriginal, kCollectRings)) { if (!re::PatchInstructionByte(kCollectRingLimit, 2, kCollectRingsOriginal, kCollectRings)) {
re::Log("input: pickup range patch FAILED"); re::Log("input: pickup range patch FAILED");
} }
@@ -252,7 +458,7 @@ uint32_t ControlFlags(const uint8_t* control) {
} }
uint8_t* FocusedTextField() { uint8_t* FocusedTextField() {
auto* ui = *reinterpret_cast<uint8_t**>(kUiManager); auto* ui = reinterpret_cast<uint8_t*>(game::UiManager());
void* focus = ui ? *reinterpret_cast<void**>(ui + kUiFocus) : nullptr; void* focus = ui ? *reinterpret_cast<void**>(ui + kUiFocus) : nullptr;
if (!focus || focus != g_drawnEdit.load() || GetTickCount() - g_drawnEditTick.load() > kDrawnRecentlyMs) { if (!focus || focus != g_drawnEdit.load() || GetTickCount() - g_drawnEditTick.load() > kDrawnRecentlyMs) {
return nullptr; return nullptr;
@@ -269,7 +475,7 @@ uint8_t* FocusedTextField() {
// Only the focused control counts: a window draws several edit controls per // Only the focused control counts: a window draws several edit controls per
// frame (measured: the save window), the last one drawn is not the focus. // frame (measured: the save window), the last one drawn is not the focus.
void NoteEditDrawn(void* edit) { void NoteEditDrawn(void* edit) {
auto* ui = *reinterpret_cast<uint8_t**>(kUiManager); auto* ui = reinterpret_cast<uint8_t*>(game::UiManager());
if (ui && edit == *reinterpret_cast<void**>(ui + kUiFocus) && if (ui && edit == *reinterpret_cast<void**>(ui + kUiFocus) &&
(ControlFlags(static_cast<uint8_t*>(edit)) & kControlVisible) != 0) { (ControlFlags(static_cast<uint8_t*>(edit)) & kControlVisible) != 0) {
g_drawnEdit = edit; g_drawnEdit = edit;
@@ -281,11 +487,119 @@ bool TypingIntoTextField() {
return FocusedTextField() != nullptr; return FocusedTextField() != nullptr;
} }
int MovementKey(Move direction) {
for (const Binding& binding : g_bindings) {
if (binding.kind == Kind::Move && binding.index == static_cast<int>(direction)) {
return binding.vk;
}
}
return 0;
}
int BindingCount() {
return kBindingCount;
}
std::wstring BindingLabel(int index) {
const Binding& binding = g_bindings[index];
if (binding.kind != Kind::Potion) {
return binding.label;
}
// The potion kind's name as the game shows it ("Trank N" until known).
std::wstring name = game::PotionKindName(binding.index & 0xff);
if (name.empty()) {
name = binding.label;
}
return (binding.index & kAllyPotion) ? name + L" für Gefährten" : name;
}
int PotionKey(int kind) {
for (const Binding& binding : g_bindings) {
if (binding.kind == Kind::Potion && binding.index == kind) {
return binding.vk;
}
}
return 0;
}
const wchar_t* BindingGroup(int index) {
switch (g_bindings[index].kind) {
case Kind::SkillSlot:
return L"Kampfkünste";
case Kind::WeaponSet:
return L"Waffensets";
case Kind::Move:
return L"Bewegung";
case Kind::Potion:
return L"Tränke";
case Kind::PickUp:
case Kind::SaveGame:
case Kind::Map:
case Kind::OverviewMap:
case Kind::Labels:
break;
}
return L"Aktionen";
}
int BindingKey(int index) {
return g_bindings[index].vk;
}
int DefaultBindingKey(int index) {
return ParseKey(g_bindings[index].fallback);
}
void SetBindingKeys(const int* keys) {
for (int i = 0; i < kBindingCount; ++i) {
Binding& binding = g_bindings[i];
binding.vk = Bindable(keys[i]) ? keys[i] : 0;
re::SetConfigString("keys", binding.name, IniName(binding.vk).c_str());
}
re::Log("input: key bindings saved");
}
bool Bindable(int vk) {
return LetterOrDigit(vk) || FunctionKey(vk) || NamedKeyFor(vk);
}
std::wstring KeyLabel(int vk) {
if (LetterOrDigit(vk)) {
return std::wstring(1, static_cast<wchar_t>(vk));
}
if (FunctionKey(vk)) {
return L"F" + std::to_wstring(vk - VK_F1 + 1);
}
const NamedKey* key = NamedKeyFor(vk);
return key ? key->label : L"";
}
void SetKeyCapture(void (*handler)(int vk)) {
g_capture = handler;
}
bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) { bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) {
switch (message) { switch (message) {
case WM_SYSKEYDOWN:
// Alt and F10 arrive as system keys: only the key capture takes them.
if (g_capture) {
if ((lParam & (1 << 30)) == 0) {
g_capture(static_cast<int>(wParam & 0xff));
}
return true;
}
return false;
case WM_KEYDOWN: { case WM_KEYDOWN: {
const int vk = static_cast<int>(wParam & 0xff); const int vk = static_cast<int>(wParam & 0xff);
const bool repeat = (lParam & (1 << 30)) != 0; const bool repeat = (lParam & (1 << 30)) != 0;
if (g_capture) {
g_taken[vk] = true;
g_swallowChar = true;
if (!repeat) {
g_capture(vk);
}
return true;
}
if (!repeat) { if (!repeat) {
g_taken[vk] = false; g_taken[vk] = false;
if (FocusedTextField()) { if (FocusedTextField()) {
+30
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <string>
#include <windows.h> #include <windows.h>
namespace mods::input { namespace mods::input {
@@ -14,4 +16,32 @@ void NoteEditDrawn(void* edit);
// handled and must not reach the game. // handled and must not reach the game.
bool HandleWindowMessage(HWND window, UINT message, WPARAM wParam, LPARAM lParam); bool HandleWindowMessage(HWND window, UINT message, WPARAM wParam, LPARAM lParam);
// The movement keys (polled by mods/wasd.cpp); 0 when unbound.
enum class Move { Up, Left, Down, Right };
int MovementKey(Move direction);
// The key bound to drinking potion kind `kind` (1..5); 0 when unbound.
int PotionKey(int kind);
// --- key bindings page (mods/options.cpp) --------------------------------------
// The bindable actions in display order, grouped (a group's actions follow
// each other).
int BindingCount();
std::wstring BindingLabel(int index); // potions: the game's name of the kind
const wchar_t* BindingGroup(int index);
int BindingKey(int index); // virtual key, 0 = none
int DefaultBindingKey(int index);
// Sets all bindings (`keys[BindingCount()]`) and writes them to sacredK.ini.
void SetBindingKeys(const int* keys);
// Whether `vk` can be bound, and its name as shown ("Leertaste", "F1", ...).
bool Bindable(int vk);
std::wstring KeyLabel(int vk);
// While set, every key press goes to `handler` (on the window thread) instead
// of the game or the bindings; its key messages are swallowed.
void SetKeyCapture(void (*handler)(int vk));
} // namespace mods::input } // namespace mods::input
+4 -2
View File
@@ -193,7 +193,7 @@ void PutArtSlot(const json& variants, uint8_t* slot) {
json ToJson(const std::vector<uint8_t>& block, const uint8_t* shadow) { json ToJson(const std::vector<uint8_t>& block, const uint8_t* shadow) {
Reader r(block); Reader r(block);
json j; json j;
j["format"] = "sacred_re.inventory"; j["format"] = "sacredK.inventory";
j["version"] = kVersion; j["version"] = kVersion;
j["player"] = r.U16(); j["player"] = r.U16();
j["flags"] = r.U16(); j["flags"] = r.U16();
@@ -262,7 +262,9 @@ json ToJson(const std::vector<uint8_t>& block, const uint8_t* shadow) {
// The original block for slots 1-5; slots 6-7 are applied afterwards. // The original block for slots 1-5; slots 6-7 are applied afterwards.
std::vector<uint8_t> ToBlock(const json& j) { std::vector<uint8_t> ToBlock(const json& j) {
if (j.at("format") != "sacred_re.inventory" || j.at("version").get<int>() > kVersion) { // "sacred_re.inventory": saves from before the project was renamed to sacredK.
const auto format = j.at("format").get<std::string>();
if ((format != "sacredK.inventory" && format != "sacred_re.inventory") || j.at("version").get<int>() > kVersion) {
throw std::runtime_error("unknown inventory format / version"); throw std::runtime_error("unknown inventory format / version");
} }
Writer w; Writer w;
+616
View File
@@ -0,0 +1,616 @@
// Options window in three tabs: Allgemein, Beute, Tastenbelegung (request of
// 2026-10-18). 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.
//
// The window (cUI_Options, vtable 0x89505c, constructor 0x716570 building its
// controls in 0x7184e0) is laid out for 1024x768: six frames in two columns
// (frame x 0x40 / 0x220, rows y 0x40 / 0x120 / 0x200, a frame label above
// each), OK / Cancel at the bottom right. All its controls are in the vector
// at +0x90. A cUI_TabSheet of the game's (the inventory's header look) above
// the frames switches between pages: each control belongs to the page of the
// frame it sits in (by position), hidden controls draw nothing and take no
// events (frames have their own draw flag, see kFrameDrawn). The auto-pickup
// frame (left column, middle row) moves to the top row of the Beute page, the
// general frame below it moves up into its place.
//
// The key bindings page is drawn here: a row per binding with its key in a
// box; a click on the box waits for a key (Esc: cancel, Backspace: no key),
// a key bound to another action is taken from it. OK saves the bindings
// (cUI_Options apply 0x717460), Cancel drops the changes. See docs/options.md.
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <string>
#include <vector>
#include <windows.h>
#include "game/ui/control.h"
#include "game/ui/draw.h"
#include "game/ui/events.h"
#include "game/ui/tabs.h"
#include "game/ui/text.h"
#include "game/ui/texts.h"
#include "mods/hud.h"
#include "mods/input.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::options {
namespace {
// --- cUI_Options -----------------------------------------------------------------
constexpr size_t kWindowControls = 0x90; // vector of its controls: begin, +4 end
constexpr size_t kCancelButton = 0x158;
constexpr size_t kOkButton = 0x15c;
// 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;
// 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)
// 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.
constexpr int kColumnSplit = 0x200; // left column frames end at x 0x1d4
constexpr int kMiddleRowTop = 0xf8; // auto-pickup label y 0x100, frame from y 0x10c
constexpr int kBottomRowTop = 0x1d8; // general label y 0x1e0, frame from y 0x1ec
constexpr int kRowStep = 0x120 - 0x40; // middle row frame y - top row frame y
// The tab headers sit 0x14 + their height above the sheet (cUI_TabSheet::Layout
// 0x729210); with the sheet at the top frames' border they end at y 0x18,
// 8 pixels above the frame labels. The pages move down for some room.
constexpr game::UiRect kSheetRect = {0x2c, 0x2c, 0, 0};
constexpr int kContentShift = 24;
enum Page : uint16_t { kPageCommon, kPageLoot, kPageKeys };
enum class Group { Always, Common, Loot, Keys };
struct Control {
uint8_t* control;
Group group;
bool hiddenByUs = false;
};
struct Window {
uint8_t* window = nullptr;
void* sheet = nullptr;
uint8_t* resetButton = nullptr;
std::vector<Control> controls;
bool wasVisible = false;
};
std::vector<Window> g_windows;
Window* Find(void* window) {
for (Window& w : g_windows) {
if (w.window == window) {
return &w;
}
}
return nullptr;
}
int32_t& ControlX(uint8_t* control) {
return *reinterpret_cast<int32_t*>(control + game::kControlRect);
}
int32_t& ControlY(uint8_t* control) {
return *reinterpret_cast<int32_t*>(control + game::kControlRect + 4);
}
bool IsFrame(const uint8_t* control) {
return *reinterpret_cast<const uint32_t*>(control) == kFrameVtable;
}
// cUI_Scrollbar (vtable 0x895664, the volume and map transparency sliders):
// its parts are embedded controls with rects in the window's coordinates,
// placed once when it is made; its render (0x7527c0) puts the knob at
// +0x78 / +0x7c (track start) plus the value each frame.
constexpr uint32_t kScrollbarVtable = 0x00895664;
constexpr size_t kScrollbarParts[] = {0x94, 0x110, 0x18c, 0x208}; // minus, plus, track, knob
constexpr size_t kScrollbarKnobY = 0x7c;
void MoveDown(uint8_t* control, int dy) {
ControlY(control) += dy;
if (*reinterpret_cast<const uint32_t*>(control) == kScrollbarVtable) {
for (const size_t part : kScrollbarParts) {
ControlY(control + part) += dy;
}
*reinterpret_cast<int32_t*>(control + kScrollbarKnobY) += dy;
}
}
uint8_t* Field(uint8_t* window, size_t offset) {
return *reinterpret_cast<uint8_t**>(window + offset);
}
// Sorts the controls into pages, moves the auto-pickup and general frames.
void Classify(Window& w) {
auto** begin = *reinterpret_cast<uint8_t***>(w.window + kWindowControls);
auto** end = *reinterpret_cast<uint8_t***>(w.window + kWindowControls + 4);
const uint8_t* ok = Field(w.window, kOkButton);
const uint8_t* cancel = Field(w.window, kCancelButton);
for (auto** it = begin; it && it != end; ++it) {
uint8_t* control = *it;
if (!control) {
continue;
}
const int x = ControlX(control);
const int y = ControlY(control);
Group group = Group::Common;
if (control == ok || control == cancel) {
group = Group::Always;
} else if (x < kColumnSplit && y >= kMiddleRowTop && y < kBottomRowTop) {
group = Group::Loot;
MoveDown(control, -kRowStep);
} else if (x < kColumnSplit && y >= kBottomRowTop) {
MoveDown(control, -kRowStep);
}
if (group != Group::Always) {
MoveDown(control, kContentShift);
}
w.controls.push_back({control, group});
}
}
void CreateTabs(Window& w) {
static const uint32_t kNames[3] = {game::RegisterText(L"Allgemein"), game::RegisterText(L"Beute"),
game::RegisterText(L"Tastenbelegung")};
static const char* const kPageNames[3] = {"UI_TABSHEET_OPTIONS_COMMON", "UI_TABSHEET_OPTIONS_LOOT",
"UI_TABSHEET_OPTIONS_KEYS"};
w.sheet = game::NewTabSheet("UI_TABSHEET_OPTIONS", kSheetRect, w.window);
if (!w.sheet) {
return;
}
// Pages: empty controls; the window's own controls are shown and hidden.
for (int page = 0; page < 3; ++page) {
game::AddTab(w.sheet, kNames[page], game::NewTabSheet(kPageNames[page], {0, 0, 0, 0}, w.sheet));
}
game::SetActiveTab(w.sheet, kPageCommon);
game::AddWindowControl(w.window, w.sheet);
}
bool OnPage(Group group, uint16_t page) {
return group == Group::Always || (group == Group::Common && page == kPageCommon) ||
(group == Group::Loot && page == kPageLoot) || (group == Group::Keys && page == kPageKeys);
}
// Every frame: the game shows its controls again when the window opens.
void ShowPage(Window& w, uint16_t page) {
for (Control& c : w.controls) {
const bool show = OnPage(c.group, page);
if (!show && game::ControlShown(c.control)) {
game::SetControlShown(c.control, false);
c.hiddenByUs = true;
} else if (show && c.hiddenByUs) {
game::SetControlShown(c.control, true);
c.hiddenByUs = false;
}
if (IsFrame(c.control)) {
c.control[kFrameDrawn] = show ? 1 : 0;
}
}
}
// --- key bindings page ---------------------------------------------------------
constexpr int kMaxBindings = 64;
int g_pending[kMaxBindings] = {}; // the page's keys until OK
bool g_pendingLoaded = false;
std::atomic<int> g_capturing{-1}; // binding waiting for a key
int g_hover = -1; // binding whose box is under the mouse
constexpr uint16_t kFont = 1;
constexpr uint32_t kTextColour = 0xffe6d6aa; // as the tab headers' normal text (cUI_TabControl::Render)
constexpr uint32_t kHeadingColour = 0xfff0c060;
constexpr uint32_t kUnboundColour = 0xffb4b4b4;
constexpr uint32_t kBoxColour = 0xc0141414;
constexpr uint32_t kBoxHoverColour = 0xc0343434;
constexpr uint32_t kBoxCaptureColour = 0xc0604818;
// Two framed columns, groups kept together (window coordinates). The frames
// line up with the other pages': from the top row's frame top to the bottom
// of the lowest frame (Internet-Optionen, y 0x200, height 0x40).
constexpr int kFrameX[2] = {0x40, 0x220};
constexpr int kFrameWidth = 0x180;
constexpr int kFrameTop = 0x40 + kContentShift;
constexpr int kFrameHeight = 0x200 + 0x40 + kContentShift - kFrameTop;
constexpr int kTextInset = 0x10; // as the game's labels in its frames
constexpr int kLine = 25;
constexpr int kLinesPerColumn = 20;
constexpr int kBoxHeight = 22;
constexpr int kTop = kFrameTop + (kFrameHeight - (kLinesPerColumn * kLine - (kLine - kBoxHeight))) / 2;
constexpr int kBoxWidth = 0x80;
constexpr int kBoxX = kFrameWidth - 2 * kTextInset - kBoxWidth; // from the column
constexpr int kLabelWidth = kBoxX - 8;
// The bottom row: the hints (they hold for the whole page), then "Alle
// zurücksetzen", a button like Cancel (x 0x25c, y 0x284) and OK next to them.
// The buttons' ornaments reach kButtonOrnament beyond their rects (measured
// in a screenshot).
constexpr int kBottomY = 0x284;
constexpr int kBottomHeight = 37;
constexpr int kCancelX = 0x25c;
constexpr int kButtonOrnament = 0x21;
constexpr int kResetWidth = 140; // fits its caption
constexpr int kResetX = kCancelX - 2 * kButtonOrnament - 12 - kResetWidth;
// The hints box: from the frames backdrop edge, a little taller than the row.
constexpr int kHintX = 0x40 - kFrameBorder;
constexpr int kHintMargin = 4;
constexpr game::UiRect kHintRect = {kHintX, kBottomY - kHintMargin, kResetX - kButtonOrnament - 8 - kHintX,
kBottomHeight + 2 * kHintMargin};
const wchar_t* const kHints[2] = {L"Feld anklicken, dann Taste drücken.", L"Esc: abbrechen, Rücktaste: leeren."};
enum class LineKind { Heading, Binding };
struct Line {
LineKind kind;
int binding; // Binding: its index
std::wstring text;
int column;
int line;
};
std::vector<Line> Layout() {
std::vector<Line> lines;
const int count = std::min(input::BindingCount(), kMaxBindings);
int column = 0;
int line = 0;
for (int i = 0; i < count;) {
const wchar_t* group = input::BindingGroup(i);
int size = 0;
while (i + size < count && input::BindingGroup(i + size) == group) {
++size;
}
if (column == 0 && line > 0 && line + 1 + size > kLinesPerColumn) {
column = 1;
line = 0;
}
lines.push_back({LineKind::Heading, -1, group, column, line++});
for (int k = 0; k < size; ++k) {
lines.push_back({LineKind::Binding, i + k, input::BindingLabel(i + k), column, line++});
}
++line; // gap between groups
i += size;
}
return lines;
}
game::UiRect LineRect(void* window, const Line& line, int dx, int width) {
const game::UiRect origin = game::ControlScreenRect(window);
return {origin.x + kFrameX[line.column] + kTextInset + dx, origin.y + kTop + line.line * kLine,
static_cast<int16_t>(width), static_cast<int16_t>(kBoxHeight)};
}
game::UiRect BoxRect(void* window, const Line& line) {
return LineRect(window, line, kBoxX, kBoxWidth);
}
game::UiRect OnWindow(void* window, const game::UiRect& r) {
const game::UiRect origin = game::ControlScreenRect(window);
return {origin.x + r.x, origin.y + r.y, r.w, r.h};
}
bool Inside(const game::UiRect& r, int x, int y) {
return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
}
void FillBox(void* device, const game::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 StopCapture() {
g_capturing = -1;
input::SetKeyCapture(nullptr);
}
// The key pressed while a box waits (window thread).
void OnCapturedKey(int vk) {
const int row = g_capturing;
if (row < 0) {
StopCapture();
return;
}
if (vk == VK_ESCAPE) {
StopCapture();
} else if (vk == VK_BACK) {
g_pending[row] = 0;
StopCapture();
} else if (input::Bindable(vk)) {
for (int& key : g_pending) {
if (key == vk) {
key = 0;
}
}
g_pending[row] = vk;
StopCapture();
}
}
void LoadPending() {
const int count = std::min(input::BindingCount(), kMaxBindings);
for (int i = 0; i < count; ++i) {
g_pending[i] = input::BindingKey(i);
}
g_pendingLoaded = true;
}
void DrawKeysPage(const Window& w, void* device) {
void* window = w.window;
for (const Line& line : Layout()) {
if (line.kind != LineKind::Binding) {
const game::UiRect rect = LineRect(window, line, 0, kBoxX + kBoxWidth);
game::DrawText(device, kFont, game::CenterLineVertically(kFont, rect), line.text.c_str(), kHeadingColour,
0);
continue;
}
const game::UiRect label = LineRect(window, line, 0, kLabelWidth);
game::DrawText(device, kFont, game::CenterLineVertically(kFont, label), line.text.c_str(), kTextColour, 0);
const game::UiRect box = BoxRect(window, line);
const bool capturing = g_capturing == line.binding;
const uint32_t fill = capturing ? kBoxCaptureColour : g_hover == line.binding ? kBoxHoverColour : kBoxColour;
FillBox(device, box, fill);
const int vk = g_pending[line.binding];
const std::wstring key = capturing ? L"Taste drücken" : vk ? input::KeyLabel(vk) : L"–";
game::DrawText(device, kFont, game::CenterLineVertically(kFont, box), key.c_str(),
vk || capturing ? kTextColour : kUnboundColour, game::kTextCenter);
}
const game::UiRect hints = OnWindow(window, kHintRect);
FillBox(device, hints, kBoxColour);
const int lineHeight = game::FontLineHeight(kFont);
game::UiRect hint = {hints.x, hints.y + (hints.h - 2 * lineHeight) / 2, hints.w, static_cast<int16_t>(lineHeight)};
for (const wchar_t* text : kHints) {
game::DrawText(device, kFont, hint, text, kTextColour, game::kTextCenter);
hint.y += lineHeight;
}
}
// "Alle zurücksetzen": every binding back to its default (saved with OK).
void ResetPending() {
const int count = std::min(input::BindingCount(), kMaxBindings);
for (int i = 0; i < count; ++i) {
g_pending[i] = input::DefaultBindingKey(i);
}
}
// The binding whose box is at (x, y), -1 if none.
int BoxAt(void* window, int x, int y) {
for (const Line& line : Layout()) {
if (line.kind == LineKind::Binding && Inside(BoxRect(window, line), x, y)) {
return line.binding;
}
}
return -1;
}
// cUI_Button2 (vtable 0x895308, 0xbc bytes, constructor 0x72a180, plain
// ret). 0x7184e0 sets up Cancel and OK the same way: images (+0x54..+0x87),
// sounds (0x72acf0 -> +0x90..+0x9f), caption (0x72ad60 (text id, font 1) ->
// +0xa4 / +0xa0), caption offset (0x72ad40 -> +0xb0), name (0x732090).
constexpr size_t kButtonSize = 0xbc;
constexpr size_t kButtonLook = 0x54;
constexpr size_t kButtonLookEnd = 0x88;
constexpr size_t kButtonSounds = 0x90;
constexpr size_t kButtonSoundsEnd = 0xa0;
constexpr size_t kButtonCaptionOffset = 0xb0; // x, y (int16)
constexpr uint32_t kButtonFlags = 5; // 0x731e70, as 0x7184e0 passes
void PushControl(uint8_t* window, void* control) {
using PushFn = void(__fastcall*)(void*, void*, void**);
reinterpret_cast<PushFn>(0x006bcf50)(window + kWindowControls, nullptr, &control);
}
// "Alle zurücksetzen": a button with Cancel's look.
uint8_t* CreateResetButton(uint8_t* window) {
static const uint32_t kCaption = game::RegisterText(L"Alle zurücksetzen");
uint8_t* cancel = Field(window, kCancelButton);
void* memory = cancel ? game::GameNew(kButtonSize) : nullptr;
if (!memory) {
return nullptr;
}
using ConstructFn = uint8_t*(__fastcall*)(void*, void*);
uint8_t* button = reinterpret_cast<ConstructFn>(0x0072a180)(memory, nullptr);
using FlagsFn = void(__fastcall*)(void*, void*, uint32_t);
reinterpret_cast<FlagsFn>(0x00731e70)(button, nullptr, kButtonFlags);
std::copy(cancel + kButtonLook, cancel + kButtonLookEnd, button + kButtonLook);
std::copy(cancel + kButtonSounds, cancel + kButtonSoundsEnd, button + kButtonSounds);
std::copy(cancel + kButtonCaptionOffset, cancel + kButtonCaptionOffset + 4, button + kButtonCaptionOffset);
auto* rect = reinterpret_cast<game::UiRect*>(button + game::kControlRect);
*rect = *reinterpret_cast<const game::UiRect*>(cancel + game::kControlRect);
rect->x = kResetX;
rect->w = kResetWidth;
using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t);
reinterpret_cast<CaptionFn>(0x0072ad60)(button, nullptr, kCaption, 1);
using NameFn = void(__fastcall*)(void*, void*, const char*);
reinterpret_cast<NameFn>(0x00732090)(button, nullptr, "UI_BTN_KEYS_RESET");
PushControl(window, button);
return button;
}
// The key bindings page's own controls, shown only there: a backdrop and
// frame around each column, the reset button.
void CreateKeyControls(Window& w) {
static const char* const kFrameNames[2] = {"UI_FRAME_KEYS_LEFT", "UI_FRAME_KEYS_RIGHT"};
auto* controls = w.window + kWindowControls;
const auto existing = static_cast<size_t>(*reinterpret_cast<uint8_t***>(controls + 4) -
*reinterpret_cast<uint8_t***>(controls));
for (int column = 0; column < 2; ++column) {
game::UiRect frame = {kFrameX[column], kFrameTop, kFrameWidth, kFrameHeight};
game::UiRect backdrop = {frame.x - kFrameBorder, frame.y - kFrameBorder,
static_cast<int16_t>(frame.w + 2 * kFrameBorder),
static_cast<int16_t>(frame.h + 2 * kFrameBorder)};
using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t);
reinterpret_cast<AddBackdropFn>(0x00724ed0)(w.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(w.window, reinterpret_cast<FrameFn>(0x00739f90)(memory, nullptr, kFrameNames[column],
kMainFrame, &frame, 1));
}
}
w.resetButton = CreateResetButton(w.window);
// Everything after the window's own controls is ours.
auto** begin = *reinterpret_cast<uint8_t***>(controls);
auto** end = *reinterpret_cast<uint8_t***>(controls + 4);
for (auto** it = begin + existing; it < end; ++it) {
if (*it) {
w.controls.push_back({*it, Group::Keys});
}
}
}
// --- hooks ---------------------------------------------------------------------
struct RenderCall {
uint8_t* window;
void* device;
int a;
int b;
int(__fastcall* original)(void*, void*, void*, int, int);
};
int RenderBody(void* context) {
auto* call = static_cast<RenderCall*>(context);
const int result = call->original(call->window, nullptr, call->device, call->a, call->b);
Window* w = Find(call->window);
if (!w || !w->sheet) {
return result;
}
const bool visible = game::ControlShown(call->window);
if (visible && !w->wasVisible) {
LoadPending();
}
w->wasVisible = visible;
const uint16_t page = game::ActiveTab(w->sheet);
if (!visible || page != kPageKeys) {
if (g_capturing >= 0) {
StopCapture();
}
g_hover = -1;
}
if (!visible) {
return result;
}
ShowPage(*w, page);
if (page == kPageKeys && g_pendingLoaded) {
DrawKeysPage(*w, call->device);
}
return result;
}
struct EventCall {
uint8_t* window;
void* event;
int(__fastcall* original)(void*, void*, void*);
};
int EventBody(void* context) {
auto* call = static_cast<EventCall*>(context);
Window* w = Find(call->window);
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys &&
g_pendingLoaded) {
switch (game::MouseEventKind(call->event)) {
case game::MouseEvent::Move: {
const int x = game::EventField(call->event, 0xc);
const int y = game::EventField(call->event, 0x10);
g_hover = BoxAt(call->window, x, y);
break;
}
case game::MouseEvent::ButtonDown: {
const int x = game::EventField(call->event, game::kMouseButtonX);
const int y = game::EventField(call->event, game::kMouseButtonY);
const bool left = game::EventField(call->event, game::kMouseButton) == game::kLeftButton;
if (g_capturing >= 0) {
StopCapture();
}
if (left && w->resetButton && Inside(game::ControlScreenRect(w->resetButton), x, y)) {
ResetPending(); // the button still gets the click (pressed look, sound)
break;
}
const int box = BoxAt(call->window, x, y);
if (left && box >= 0) {
g_capturing = box;
input::SetKeyCapture(&OnCapturedKey);
return 1;
}
break;
}
default:
break;
}
}
return call->original(call->window, nullptr, call->event);
}
} // namespace
} // namespace mods::options
namespace game {
using namespace mods::options;
// cUI_Options::cUI_Options (0x716570, ret 0x18): (name, rect, parent, flags).
void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent,
uint32_t flags);
RE_HOOK(0x00716570, UiOptions_Construct);
void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent,
uint32_t flags) {
void* window = UiOptions_Construct_original(self, edx, name, rect, parent, flags);
if (window) {
g_windows.push_back({static_cast<uint8_t*>(window)});
Window& w = g_windows.back();
Classify(w);
CreateKeyControls(w);
CreateTabs(w);
re::Log("options: tabs created (%u controls)", static_cast<unsigned>(w.controls.size()));
}
return window;
}
// cUI_Options destructor (0x7165f0, thiscall, plain ret).
void __fastcall UiOptions_Destruct(void* self, void* edx);
RE_HOOK(0x007165f0, UiOptions_Destruct);
void __fastcall UiOptions_Destruct(void* self, void* edx) {
std::erase_if(g_windows, [self](const Window& w) { return w.window == self; });
UiOptions_Destruct_original(self, edx);
}
// cUI_Options::vf44 (0x716640, ret 0xc): renders the window (cUI_Window2
// render first), drives the sliders; black beside the frame like the
// save game window.
int __fastcall UiOptions_Render(void* self, void* edx, void* device, int a, int b);
RE_HOOK(0x00716640, UiOptions_Render);
int __fastcall UiOptions_Render(void* self, void*, void* device, int a, int b) {
RenderCall call{static_cast<uint8_t*>(self), device, a, b, UiOptions_Render_original};
return mods::hud::RenderFramed(self, device, &RenderBody, &call);
}
// cUI_Options::HandleEvent (0x716b30, ret 4); centred like the other windows.
int __fastcall UiOptions_HandleEvent(void* self, void* edx, void* event);
RE_HOOK(0x00716b30, UiOptions_HandleEvent);
int __fastcall UiOptions_HandleEvent(void* self, void*, void* event) {
EventCall call{static_cast<uint8_t*>(self), event, UiOptions_HandleEvent_original};
return mods::hud::HandleCentred(event, &EventBody, &call);
}
// cUI_Options apply (0x717460, thiscall, plain ret): OK writes the settings.
void __fastcall UiOptions_Apply(void* self, void* edx);
RE_HOOK(0x00717460, UiOptions_Apply);
void __fastcall UiOptions_Apply(void* self, void* edx) {
UiOptions_Apply_original(self, edx);
if (g_pendingLoaded) {
mods::input::SetBindingKeys(g_pending);
}
}
} // namespace game
+29
View File
@@ -9,13 +9,21 @@
// control behind it (+0x3ac). GetPotionRef walks the inventory grid and asks // control behind it (+0x3ac). GetPotionRef walks the inventory grid and asks
// ItemPotionKind for each item's kind; the count here does the same and adds // ItemPotionKind for each item's kind; the count here does the same and adds
// up the potion stacks (itemstack.cpp). // up the potion stacks (itemstack.cpp).
//
// Their tooltips (game::PotionTooltipText, set on the slots by the taskbar,
// 0x6e6060; shown as "\dFFFFFF80Heiltrank \wFFFFFFFF[SPC]") name the key
// bound to the kind (mods/input.cpp) instead of the game's own, which is
// switched off; no key part while unbound.
#include <cstdint> #include <cstdint>
#include <cwchar> #include <cwchar>
#include <string>
#include "game/engine/items.h" #include "game/engine/items.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/ui/text.h" #include "game/ui/text.h"
#include "game/ui/texts.h"
#include "mods/input.h"
#include "mods/itemstack.h" #include "mods/itemstack.h"
#include "mods/skillslots.h" #include "mods/skillslots.h"
#include "re/hook.h" #include "re/hook.h"
@@ -59,6 +67,26 @@ void RenderCounts(uint8_t* taskbar, void* renderer) {
} }
} }
// The game's tooltip with its "[key]" end replaced by the bound key's.
std::wstring TooltipWithKey(uint32_t text, int vk) {
std::wstring tooltip = game::ShownText(text);
if (const size_t key = tooltip.rfind(L'['); key != std::wstring::npos && tooltip.back() == L']') {
tooltip.erase(key);
}
if (vk) {
tooltip += L"[" + input::KeyLabel(vk) + L"]";
}
return tooltip;
}
void UpdateTooltips() {
for (int kind = 1; kind <= game::kPotionKinds; ++kind) {
if (const uint32_t text = game::PotionTooltipText(kind)) {
game::SetTextOverride(text, TooltipWithKey(text, input::PotionKey(kind)));
}
}
}
} // namespace } // namespace
} // namespace mods::potioncounts } // namespace mods::potioncounts
@@ -69,5 +97,6 @@ void __fastcall UiTaskbar_RenderPotionSlots(void* self, void* edx, void* rendere
UiTaskbar_RenderPotionSlots_original(self, edx, renderer); UiTaskbar_RenderPotionSlots_original(self, edx, renderer);
if (self) { if (self) {
mods::potioncounts::RenderCounts(static_cast<uint8_t*>(self), renderer); mods::potioncounts::RenderCounts(static_cast<uint8_t*>(self), renderer);
mods::potioncounts::UpdateTooltips();
} }
} }
+12 -33
View File
@@ -29,6 +29,8 @@
#include "game/engine/arts.h" #include "game/engine/arts.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/skills.h"
#include "game/ui/draw.h"
#include "game/ui/events.h" #include "game/ui/events.h"
#include "game/ui/text.h" #include "game/ui/text.h"
#include "mods/hud.h" #include "mods/hud.h"
@@ -46,7 +48,7 @@ constexpr size_t kPlusButtons = 0x2cc;
constexpr size_t kMinusButtons = 0x8ac; // unused by the game constexpr size_t kMinusButtons = 0x8ac; // unused by the game
constexpr size_t kButtonSize = 0xbc; constexpr size_t kButtonSize = 0xbc;
constexpr int kRows = 8; constexpr int kRows = 8;
constexpr int kAttributeRows = 6; constexpr int kAttributeRows = game::kAttributeCount;
// cUI_Button (vtable 0x895308). // cUI_Button (vtable 0x895308).
constexpr size_t kControlFlags = 0x10; constexpr size_t kControlFlags = 0x10;
@@ -85,13 +87,13 @@ constexpr float kIconSize = 16.0f;
// The − sits left of the value column (values end at the + at x 232). // The − sits left of the value column (values end at the + at x 232).
constexpr int kMinusX = 184; constexpr int kMinusX = 184;
// cCreatureArts fields (see the top). // cCreatureArts fields (see the top, game/engine/skills.h).
constexpr size_t kAttributes = 0x1c; using game::kFreeAttributesIndex;
constexpr int kFreeAttributesIndex = 7; constexpr size_t kAttributes = game::kArtsInvested;
constexpr size_t kSkillIds = 0x24; constexpr size_t kSkillIds = game::kArtsSkillIds;
constexpr size_t kSkillLevels = 0x2c; constexpr size_t kSkillLevels = game::kArtsSkillLevels;
constexpr size_t kSkillBonus = 0x34; constexpr size_t kSkillBonus = game::kArtsSkillBonus;
constexpr size_t kSkillChoices = 0x44; constexpr size_t kSkillChoices = game::kArtsSkillChoices;
using AddAttributeFn = void(__fastcall*)(void* arts, void*, int index, int delta); using AddAttributeFn = void(__fastcall*)(void* arts, void*, int index, int delta);
using AddSkillLevelsFn = void(__fastcall*)(void* arts, void*, int row, int delta, int spendPoints); using AddSkillLevelsFn = void(__fastcall*)(void* arts, void*, int row, int delta, int spendPoints);
@@ -181,37 +183,14 @@ void Revert(uint8_t* panel, uint8_t* arts, int row) {
re::Log("respec: skill %x removed, %u to choose", skill, *reinterpret_cast<uint16_t*>(arts + kSkillChoices)); re::Log("respec: skill %x removed, %u to choose", skill, *reinterpret_cast<uint16_t*>(arts + kSkillChoices));
} }
// IDirect3DDevice7 as the UI's render methods get it (their vf14 argument).
constexpr int kDeviceDrawPrimitive = 25;
constexpr int kDeviceSetTexture = 35;
constexpr DWORD kTriangleStrip = 5;
constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1
struct Vertex {
float x, y, z, rhw;
uint32_t diffuse, specular;
float u, v;
};
// Turns the + that `minus` drew into a −. // Turns the + that `minus` drew into a −.
void CoverArm(void* device, uint8_t* minus) { void CoverArm(void* device, uint8_t* minus) {
const game::UiRect rect = game::ControlScreenRect(minus); const game::UiRect rect = game::ControlScreenRect(minus);
const float sx = rect.w / kIconSize; const float sx = rect.w / kIconSize;
const float sy = rect.h / kIconSize; const float sy = rect.h / kIconSize;
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);
for (const Patch& patch : kArmPatches) { for (const Patch& patch : kArmPatches) {
const float x0 = rect.x + patch.x0 * sx; game::FillRect(device, rect.x + patch.x0 * sx, rect.y + patch.y0 * sy, rect.x + patch.x1 * sx,
const float x1 = rect.x + patch.x1 * sx; rect.y + patch.y1 * sy, kDiscGrey);
const float y0 = rect.y + patch.y0 * sy;
const float y1 = rect.y + patch.y1 * sy;
Vertex quad[4] = {{x0, y0, 0, 1, kDiscGrey, 0, 0, 0},
{x1, y0, 0, 1, kDiscGrey, 0, 1, 0},
{x0, y1, 0, 1, kDiscGrey, 0, 0, 1},
{x1, y1, 0, 1, kDiscGrey, 0, 1, 1}};
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip,
kFvfScreenColourTexture, quad, 4, 0);
} }
} }
+3 -8
View File
@@ -37,6 +37,7 @@
#include "game/engine/items.h" #include "game/engine/items.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "game/ui/control.h"
#include "game/ui/events.h" #include "game/ui/events.h"
#include "game/ui/tabs.h" #include "game/ui/tabs.h"
#include "game/ui/text.h" #include "game/ui/text.h"
@@ -184,12 +185,6 @@ void Select(uint8_t* window, uint32_t mode) {
reinterpret_cast<SelectFn>(0x006d3fd0)(window, nullptr, HeroType(), mode); // doLottoSelect reinterpret_cast<SelectFn>(0x006d3fd0)(window, nullptr, HeroType(), mode); // doLottoSelect
} }
// Shows a control (0x731e70) / hides it (0x731ee0); flag 1 = visible.
void SetShown(uint8_t* control, bool shown) {
using FlagFn = void(__fastcall*)(void*, void*, uint32_t);
reinterpret_cast<FlagFn>(shown ? 0x00731e70 : 0x00731ee0)(control, nullptr, kControlVisible);
}
// Chooses the hero's last bought rune (steps through the choices). // Chooses the hero's last bought rune (steps through the choices).
void ChooseRemembered(uint8_t* window) { void ChooseRemembered(uint8_t* window) {
const int hero = HeroIndex(); const int hero = HeroIndex();
@@ -373,8 +368,8 @@ void UpdateChoice(uint8_t* window) {
return; return;
} }
updating = true; updating = true;
SetShown(window + kNextArrow, true); game::SetControlShown(window + kNextArrow, true);
SetShown(window + kPreviousArrow, true); game::SetControlShown(window + kPreviousArrow, true);
if (Field<uint32_t>(window, kChosenRune) == 0) { if (Field<uint32_t>(window, kChosenRune) == 0) {
Select(window, kSelectKeep); Select(window, kSelectKeep);
ChooseRemembered(window); ChooseRemembered(window);
+1 -1
View File
@@ -334,7 +334,7 @@ void Save() {
} }
} }
} }
const json j = {{"format", "sacred_re.shared_chest"}, {"version", kFileVersion}, {"items", items}}; const json j = {{"format", "sacredK.shared_chest"}, {"version", kFileVersion}, {"items", items}};
const std::string path = FilePath(); const std::string path = FilePath();
const std::string temp = path + ".tmp"; const std::string temp = path + ".tmp";
{ {
+11 -29
View File
@@ -34,6 +34,7 @@
#include "mods/skillslots.h" #include "mods/skillslots.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
#include "re/patch.h"
namespace mods::teleportpotion { namespace mods::teleportpotion {
namespace { namespace {
@@ -109,36 +110,18 @@ void OnTick(uint8_t*) {
} }
} }
// The yellow potions' names become kName in every text (names, the bottom // Every kind 5 potion is a Teleportationstrank in every text, from the start:
// bar's tooltip): the shortest name, so stage words in front ("Großer ...") // the yellow ones ("Trank der Konzentration", also the bottom bar's tooltip)
// stay, and any name that does not contain it. // and the stamina ones, kind 5 as well ("Schwacher / Starker / Kompletter
void OnGameStart() { // Ausdauertrank", measured 2026-10-19); stage words in front stay.
static bool renamed = false; constexpr const wchar_t* kOriginalNames[] = {L"Trank der Konzentration", L"Ausdauertrank"};
if (renamed) {
return; void Startup() {
for (const wchar_t* name : kOriginalNames) {
game::ReplaceInTexts(name, kName);
} }
std::vector<std::wstring> names;
for (uint32_t type = 1; type < game::kItemTypeCount; ++type) {
if (IsTeleportPotion(type)) {
std::wstring name = game::GameText(game::ItemNameText(type));
if (!name.empty() && std::find(names.begin(), names.end(), name) == names.end()) {
names.push_back(std::move(name));
}
}
}
std::sort(names.begin(), names.end(), [](const auto& a, const auto& b) { return a.size() < b.size(); });
std::vector<std::wstring> replaced;
for (const std::wstring& name : names) {
const bool covered = std::any_of(replaced.begin(), replaced.end(),
[&](const std::wstring& r) { return name.find(r) != std::wstring::npos; });
if (!covered) {
game::ReplaceInTexts(name, kName);
replaced.push_back(name);
}
}
renamed = !names.empty();
re::Log("teleportpotion: %zu potion names, %zu replaced", names.size(), replaced.size());
} }
RE_STARTUP(TeleportPotionStartup, Startup);
void OnGameEnd() { void OnGameEnd() {
g_pending = 0; g_pending = 0;
@@ -147,7 +130,6 @@ void OnGameEnd() {
struct Registration { struct Registration {
Registration() { Registration() {
game::OnGameStart(&OnGameStart);
game::OnGameEnd(&OnGameEnd); game::OnGameEnd(&OnGameEnd);
game::OnEngineTick(&OnTick); game::OnEngineTick(&OnTick);
} }
+228 -53
View File
@@ -1,20 +1,36 @@
// WASD movement for the player character. // WASD movement for the player character: the keys steer the hero directly
// (request of 2026-10-18: direction movement instead of walk orders).
// //
// While W, A, S or D is held, the controlled creature gets a plain walk order // The game walks a creature along a path of waypoints in cCreature::Update
// (cEvent_creature 4 to a ground point) every engine tick, aimed a cursor's // (0x594110, about 40 times per second; path layout in game/engine/orders.h).
// distance from it in the pressed screen direction. That is what the mouse // While W, A, S or D is held, each update of the controlled creature first
// does while its button is held: every mouse event re-sends a walk to the point // replaces its path with one straight segment from where it stands, far into
// under the cursor (cEngine::HandleMouseButton), which keeps the creature // the pressed screen direction: the game's own step then moves it at its own
// walking smoothly. (The "held" re-aim orders cEngine::Tick sends as well stop // speed, with its walk animation, and its step test refuses blocked cells. No
// it when sent on their own.) Releasing the keys sends the target-less walk // pathfinding, no arrival, and a new direction counts from the next update.
// order a released mouse button sends, which stops it in place.
// //
// W, A and S are game hotkeys; the key bindings (src/mods/input.cpp) keep // Obstacles: the cell one step ahead is probed with cCreature::IsCellWalkable
// W/A/S/D away from the game and move those actions to other keys. Typing into // (measured: walking straight into a fence, 27 of 28 steps were refused).
// a text field (chat, console, save names) is left alone. See docs/wasd.md. // When it is blocked, the direction turned by up to 90 degrees is tried,
// the side the hero last slid to first, so it slides along walls instead of
#include "mods/wasd.h" // running on the spot; with everything blocked it stands.
//
// The facing is set to the direction too: the game turns it towards the path
// over about 300 ms, which made reversals look like walking backwards.
//
// The path only drives a creature that walks. Standing idle (at the start, or
// after an attack, a cast, a weapon switch, which resets the hero to idle) it
// gets a walk order, and the two steps of cCreature::Think that would start it
// up to 110 ms later run at once: the walk goes on in the same update.
// Releasing the keys sends the target-less walk order a released mouse button
// sends, which stops it in place.
//
// The movement keys are key bindings (default W/A/S/D, src/mods/input.cpp,
// which keeps them away from the game; the game's W/A/S actions moved to
// other keys). Typing into a text field (chat, console, save names) is left
// alone. See docs/wasd.md.
#include <atomic>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
@@ -33,12 +49,31 @@ using game::ControlledCreature;
using game::CreatureOrder; using game::CreatureOrder;
using game::kEngineFlags; using game::kEngineFlags;
// Walk orders aim this far (world units) from the creature, about where the // The walk order that starts walking aims this far (world units).
// cursor would be. constexpr double kStartDistance = 300.0;
constexpr double kTargetDistance = 300.0; // Between two start orders while the walk has not begun yet.
constexpr DWORD kStartOrderMs = 150;
// Keys released this long before the hero stops: changing keys often leaves
// a moment without any (measured: a stop order there cost about 125 ms
// standing).
constexpr DWORD kStopGraceMs = 50;
// The steered segment's length: farther than any step, so it never arrives.
constexpr double kSegmentLength = 1000.0;
// How far ahead a direction is probed: a bit more than one map cell.
constexpr double kProbeDistance = game::kMapCellSize * 1.25;
constexpr double kProbeStep = game::kMapCellSize * 0.25;
// Directions tried around a blocked one (degrees, both sides).
constexpr double kSlideAngles[] = {30.0, 60.0, 90.0};
// Engine flags (+0x54): a mouse button is held / input is blocked.
constexpr uint32_t kEngineMouseHeld = 0x10;
constexpr uint32_t kEngineInputBlocked = 0x4000;
using mods::input::Move;
using mods::input::MovementKey;
bool KeyDown(int key) { bool KeyDown(int key) {
return (GetAsyncKeyState(key) & 0x8000) != 0; return key != 0 && (GetAsyncKeyState(key) & 0x8000) != 0;
} }
void SendWalk(uint8_t* engine, void* creature, int x, int y) { void SendWalk(uint8_t* engine, void* creature, int x, int y) {
@@ -58,55 +93,195 @@ void SendStop(uint8_t* engine, void* creature) {
game::SendCreatureOrder(engine, creature, order); game::SendCreatureOrder(engine, creature, order);
} }
struct MoveState { std::atomic<uint8_t*> g_engine{nullptr};
bool moving = false; bool g_moving = false;
DWORD g_releasedAt = 0; // when the keys were released (0: held)
DWORD g_startOrderAt = 0;
int g_slideSide = 1; // +1 / -1: the side the hero last turned to
struct Direction {
double x;
double y;
}; };
MoveState g_move;
} // namespace // The held movement keys as a unit screen direction in world axes: (-1,-1)
// up, (1,1) down, (-1,1) left, (1,-1) right. False when none (or the keys
bool IsMovementKey(int vk) { // belong to the mouse, blocked input or a text field).
return (vk == 'W' || vk == 'A' || vk == 'S' || vk == 'D'); bool HeldDirection(const uint8_t* engine, Direction& dir) {
int x = 0;
int y = 0;
const uint32_t flags = *reinterpret_cast<const uint32_t*>(engine + kEngineFlags);
if ((flags & (kEngineMouseHeld | kEngineInputBlocked)) == 0 && !mods::input::TypingIntoTextField()) {
if (KeyDown(MovementKey(Move::Up))) { x -= 1; y -= 1; }
if (KeyDown(MovementKey(Move::Down))) { x += 1; y += 1; }
if (KeyDown(MovementKey(Move::Left))) { x -= 1; y += 1; }
if (KeyDown(MovementKey(Move::Right))) { x += 1; y -= 1; }
}
if (x == 0 && y == 0) {
return false;
}
const double length = std::sqrt(static_cast<double>(x * x + y * y));
dir = {x / length, y / length};
return true;
} }
// Called once per engine tick (10 Hz), after the game's own input handling. Direction Rotated(Direction dir, double degrees) {
void Update(uint8_t* engine) { const double a = degrees * 3.14159265358979 / 180.0;
// World axes on screen: (-1,-1) up, (1,1) down, (-1,1) left, (1,-1) right. return {dir.x * std::cos(a) - dir.y * std::sin(a), dir.x * std::sin(a) + dir.y * std::cos(a)};
int dx = 0; }
int dy = 0;
const uint32_t flags = *reinterpret_cast<uint32_t*>(engine + kEngineFlags); // Whether the creature can go a probe distance along `dir`: every cell the
const bool mouseMoving = (flags & 0x10) != 0; // way crosses (sampled at quarter cells, so none is skipped) is walkable.
if (!mouseMoving && (flags & 0x4000) == 0 && !mods::input::TypingIntoTextField()) { bool Open(uint8_t* creature, Direction dir) {
if (KeyDown('W')) { dx -= 1; dy -= 1; } const int x = game::CreatureField(creature, game::kCreatureX);
if (KeyDown('S')) { dx += 1; dy += 1; } const int y = game::CreatureField(creature, game::kCreatureY);
if (KeyDown('A')) { dx -= 1; dy += 1; } game::MapPosition last = game::CellAt(creature, x, y);
if (KeyDown('D')) { dx += 1; dy -= 1; } for (double d = kProbeStep; d <= kProbeDistance; d += kProbeStep) {
game::MapPosition cell =
game::CellAt(creature, x + static_cast<int>(dir.x * d), y + static_cast<int>(dir.y * d));
if (cell.x == last.x && cell.y == last.y) {
continue;
}
if (!game::IsCellWalkable(creature, cell)) {
return false;
}
last = cell;
} }
if (dx == 0 && dy == 0) { return true;
if (g_move.moving && !mouseMoving) { }
if (void* creature = ControlledCreature(engine)) {
SendStop(engine, creature); // The direction to go for keys pointing along `dir`: itself, or the nearest
// open one within 90 degrees (the last slide side first). False if blocked.
bool OpenDirection(uint8_t* creature, Direction dir, Direction& out) {
if (Open(creature, dir)) {
out = dir;
return true;
}
const int sides[] = {g_slideSide, -g_slideSide};
for (const double angle : kSlideAngles) {
for (const int side : sides) {
const Direction turned = Rotated(dir, side * angle);
if (Open(creature, turned)) {
g_slideSide = side;
out = turned;
return true;
} }
} }
g_move.moving = false;
return;
} }
void* creature = ControlledCreature(engine); return false;
if (!creature) { }
return;
// One straight segment from the creature's position along `dir`.
void Steer(uint8_t* creature, Direction dir) {
const int x = game::CreatureField(creature, game::kCreatureX);
const int y = game::CreatureField(creature, game::kCreatureY);
auto* path = reinterpret_cast<int32_t*>(creature + game::kCreaturePath);
path[0] = x;
path[1] = y;
path[2] = x + static_cast<int>(dir.x * kSegmentLength);
path[3] = y + static_cast<int>(dir.y * kSegmentLength);
*reinterpret_cast<int32_t*>(creature + game::kCreaturePathIndex) = 0;
*reinterpret_cast<int32_t*>(creature + game::kCreaturePathCount) = 2;
auto* facing = reinterpret_cast<float*>(creature + game::kCreatureFacing);
facing[0] = static_cast<float>(dir.x);
facing[1] = static_cast<float>(dir.y);
}
// Starts walking along `dir` now: the walk order, then the two Think steps
// that would otherwise follow up to 110 ms later (start the pending walk, the
// walk action's first phase). True if the creature walks.
bool StartWalk(uint8_t* engine, uint8_t* creature, Direction dir) {
SendWalk(engine, creature, game::CreatureField(creature, game::kCreatureX) + static_cast<int>(dir.x * kStartDistance),
game::CreatureField(creature, game::kCreatureY) + static_cast<int>(dir.y * kStartDistance));
if (*reinterpret_cast<uint16_t*>(creature + game::kCreaturePendingAction) == game::kPendingWalk) {
game::StartPendingWalk(creature);
} }
const double length = std::sqrt(static_cast<double>(dx * dx + dy * dy)); if (game::CurrentAction(creature) != game::kActionWalk) {
const int x = game::CreatureField(creature, game::kCreatureX) + static_cast<int>(dx / length * kTargetDistance); return false; // held by a status effect: Think decides later
const int y = game::CreatureField(creature, game::kCreatureY) + static_cast<int>(dy / length * kTargetDistance); }
SendWalk(engine, creature, x, y); if (game::CreatureField(creature, game::kCreatureAction) >> 16 == 0) {
g_move.moving = true; game::WalkAction(creature);
}
return true;
}
void OnTick(uint8_t* engine) {
g_engine = engine;
} }
namespace {
struct Registration { struct Registration {
Registration() { game::OnEngineTick(&Update); } Registration() { game::OnEngineTick(&OnTick); }
} g_registration; } g_registration;
} // namespace } // namespace
// Before each update of a creature: steer the controlled one.
void BeforeCreatureUpdate(uint8_t* creature) {
uint8_t* engine = g_engine.load();
if (!engine || creature != ControlledCreature(engine)) {
return;
}
Direction keys;
if (!HeldDirection(engine, keys)) {
const DWORD now = GetTickCount();
if (g_moving && !g_releasedAt) {
g_releasedAt = now;
}
if (g_moving && now - g_releasedAt >= kStopGraceMs) {
if ((*reinterpret_cast<uint32_t*>(engine + kEngineFlags) & kEngineMouseHeld) == 0) {
SendStop(engine, creature);
}
g_moving = false;
}
return;
}
g_moving = true;
g_releasedAt = 0;
int32_t action = game::CurrentAction(creature);
if (action == game::kActionStop) {
action = game::kActionIdle; // stopping: start again like from standing
}
if (action != game::kActionWalk && action != game::kActionIdle) {
return; // attacking, casting, dying: the action finishes first
}
Direction dir;
const bool open = OpenDirection(creature, keys, dir);
if (action == game::kActionWalk) {
auto* phase = reinterpret_cast<uint16_t*>(creature + game::kCreatureActionPhase);
if (open) {
if (*phase == game::kPhaseStopping) {
*phase = 1; // a stop requested but not begun: walk on
}
Steer(creature, dir);
} else if (*phase != game::kPhaseStopping) {
SendStop(engine, creature); // stand instead of running on the spot
}
return;
}
const DWORD now = GetTickCount();
if (open && now - g_startOrderAt >= kStartOrderMs) {
g_startOrderAt = now;
if (StartWalk(engine, creature, dir)) {
g_startOrderAt = 0;
Steer(creature, dir);
}
}
}
} // namespace mods::wasd } // namespace mods::wasd
namespace game {
// cCreature::Update (0x594110, thiscall (u8), ret 4): moves the creature
// along its path, among others.
void __fastcall Creature_Update(void* self, void* edx, uint32_t arg);
RE_HOOK(0x00594110, Creature_Update);
void __fastcall Creature_Update(void* self, void* edx, uint32_t arg) {
if (self) {
mods::wasd::BeforeCreatureUpdate(static_cast<uint8_t*>(self));
}
Creature_Update_original(self, edx, arg);
}
} // namespace game
-8
View File
@@ -1,8 +0,0 @@
#pragma once
namespace mods::wasd {
// Whether `vk` is one of the movement keys W/A/S/D.
bool IsMovementKey(int vk);
} // namespace mods::wasd
+1 -1
View File
@@ -8,7 +8,7 @@
// 1024x768 layout, and set the variables from the render size. // 1024x768 layout, and set the variables from the render size.
// See docs/widescreen.md. // See docs/widescreen.md.
// //
// sacred_re.ini: // sacredK.ini:
// [widescreen] // [widescreen]
// Enabled=1 // Enabled=1
// Height=768 ; world pixels visible vertically (768 = original look); // Height=768 ; world pixels visible vertically (768 = original look);
+9
View File
@@ -47,4 +47,13 @@ std::string ConfigString(const char* section, const char* key, const char* fallb
return narrow; return narrow;
} }
void SetConfigString(const char* section, const char* key, const char* value) {
if (g_path.empty()) {
return;
}
wchar_t wide[256];
MultiByteToWideChar(CP_UTF8, 0, value, -1, wide, static_cast<int>(std::size(wide)));
WritePrivateProfileStringW(Widen(section).c_str(), Widen(key).c_str(), wide, g_path.c_str());
}
} // namespace re } // namespace re
+4 -2
View File
@@ -1,5 +1,5 @@
#pragma once #pragma once
// sacred_re.ini next to sacred_re.dll / sacred_re.exe. // sacredK.ini next to sacredK.dll / sacredK.exe.
#include <string> #include <string>
@@ -7,9 +7,11 @@ namespace re {
void SetConfigPath(const std::wstring& path); void SetConfigPath(const std::wstring& path);
const std::wstring& ConfigPath(); const std::wstring& ConfigPath();
// Folder of sacred_re.dll (with trailing separator), derived from the config path. // Folder of sacredK.dll (with trailing separator), derived from the config path.
std::wstring ModuleDirectory(); std::wstring ModuleDirectory();
int ConfigInt(const char* section, const char* key, int fallback); int ConfigInt(const char* section, const char* key, int fallback);
std::string ConfigString(const char* section, const char* key, const char* fallback); std::string ConfigString(const char* section, const char* key, const char* fallback);
// Writes `key=value` into the ini (value as UTF-8).
void SetConfigString(const char* section, const char* key, const char* value);
} // namespace re } // namespace re
+55 -4
View File
@@ -1,8 +1,13 @@
// Crash report: on an unhandled exception, writes sacred_re_crash.txt (code, // Crash report: on an unhandled exception, writes sacredK_crash.txt (code,
// address as module + offset, registers, return addresses found on the stack) // address as module + offset, registers, return addresses found on the stack)
// and sacred_re_crash.dmp next to the DLL. The game may install its own // and sacredK_crash.dmp next to the DLL. The game may install its own
// filter later; SetUnhandledExceptionFilter is hooked so ours stays first and // filter later; SetUnhandledExceptionFilter is hooked so ours stays first and
// passes the crash on to the game's afterwards. // passes the crash on to the game's afterwards.
//
// Hangs: a watchdog thread writes sacredK_hang.dmp (all threads) when the
// engine tick (re::EngineHeartbeat) has stopped for kHangMs while the game
// window is in front; once per hang. The game freezing on F2 while walking
// (2026-10-18) left no report.
#include "re/crash.h" #include "re/crash.h"
@@ -47,7 +52,7 @@ bool IsCode(uintptr_t address) {
} }
void WriteReport(EXCEPTION_POINTERS* info) { void WriteReport(EXCEPTION_POINTERS* info) {
FILE* file = _wfopen((g_dir + L"sacred_re_crash.txt").c_str(), L"w"); FILE* file = _wfopen((g_dir + L"sacredK_crash.txt").c_str(), L"w");
if (!file) { if (!file) {
return; return;
} }
@@ -95,7 +100,7 @@ void WriteReport(EXCEPTION_POINTERS* info) {
} }
fclose(file); fclose(file);
HANDLE dump = CreateFileW((g_dir + L"sacred_re_crash.dmp").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, HANDLE dump = CreateFileW((g_dir + L"sacredK_crash.dmp").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr); FILE_ATTRIBUTE_NORMAL, nullptr);
if (dump != INVALID_HANDLE_VALUE) { if (dump != INVALID_HANDLE_VALUE) {
MINIDUMP_EXCEPTION_INFORMATION exception = {GetCurrentThreadId(), info, FALSE}; MINIDUMP_EXCEPTION_INFORMATION exception = {GetCurrentThreadId(), info, FALSE};
@@ -105,6 +110,45 @@ void WriteReport(EXCEPTION_POINTERS* info) {
} }
} }
constexpr DWORD kHangMs = 10000;
volatile LONG g_lastBeat = 0; // GetTickCount of the last engine tick, 0: no game running
bool GameInFront() {
DWORD process = 0;
GetWindowThreadProcessId(GetForegroundWindow(), &process);
return process == GetCurrentProcessId();
}
void WriteHangDump() {
HANDLE dump = CreateFileW((g_dir + L"sacredK_hang.dmp").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (dump != INVALID_HANDLE_VALUE) {
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dump,
static_cast<MINIDUMP_TYPE>(MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo),
nullptr, nullptr, nullptr);
CloseHandle(dump);
}
}
DWORD WINAPI Watchdog(void*) {
bool reported = false;
for (;;) {
Sleep(1000);
const DWORD beat = static_cast<DWORD>(g_lastBeat);
// Signed: a tick may land between reading the beat and the clock.
if (beat == 0 || static_cast<LONG>(GetTickCount() - beat) < static_cast<LONG>(kHangMs)) {
reported = false;
continue;
}
if (!reported && GameInFront()) {
reported = true;
Log("hang: no engine tick for %lu ms, writing sacredK_hang.dmp", GetTickCount() - beat);
WriteHangDump();
Log("hang: dump written");
}
}
}
LONG WINAPI Filter(EXCEPTION_POINTERS* info) { LONG WINAPI Filter(EXCEPTION_POINTERS* info) {
if (InterlockedExchange(&g_reported, 1) == 0) { if (InterlockedExchange(&g_reported, 1) == 0) {
WriteReport(info); WriteReport(info);
@@ -117,6 +161,13 @@ LONG WINAPI Filter(EXCEPTION_POINTERS* info) {
void InstallCrashHandler(const std::wstring& dir) { void InstallCrashHandler(const std::wstring& dir) {
g_dir = dir; g_dir = dir;
g_gameFilter = SetUnhandledExceptionFilter(&Filter); g_gameFilter = SetUnhandledExceptionFilter(&Filter);
if (HANDLE thread = CreateThread(nullptr, 0, &Watchdog, nullptr, 0, nullptr)) {
CloseHandle(thread);
}
}
void EngineHeartbeat(bool running) {
InterlockedExchange(&g_lastBeat, running ? static_cast<LONG>(GetTickCount() | 1) : 0);
} }
} // namespace re } // namespace re
+5 -1
View File
@@ -4,7 +4,11 @@
namespace re { namespace re {
// Writes sacred_re_crash.txt / .dmp into `dir` when the game crashes. // Writes sacredK_crash.txt / .dmp into `dir` when the game crashes, and
// sacredK_hang.dmp when the engine stops ticking (see EngineHeartbeat).
void InstallCrashHandler(const std::wstring& dir); void InstallCrashHandler(const std::wstring& dir);
// The engine ticked (running), or the game ended (not running).
void EngineHeartbeat(bool running);
} // namespace re } // namespace re
+6 -6
View File
@@ -1,4 +1,4 @@
// sacred_re.dll entry point. Loaded into sacred.exe by the launcher before the // sacredK.dll entry point. Loaded into sacred.exe by the launcher before the
// game's own entry point runs, so every hook is in place from the first // game's own entry point runs, so every hook is in place from the first
// instruction of game code. // instruction of game code.
@@ -40,17 +40,17 @@ bool IsSupportedGameBuild() {
void Startup(HMODULE module) { void Startup(HMODULE module) {
std::wstring dir = ModuleDir(module); std::wstring dir = ModuleDir(module);
re::OpenLog((dir + L"sacred_re.log").c_str()); re::OpenLog((dir + L"sacredK.log").c_str());
re::SetConfigPath(dir + L"sacred_re.ini"); re::SetConfigPath(dir + L"sacredK.ini");
re::Log("sacred_re loaded"); re::Log("sacredK loaded");
re::InstallCrashHandler(dir); re::InstallCrashHandler(dir);
if (!IsSupportedGameBuild()) { if (!IsSupportedGameBuild()) {
re::Log("unsupported sacred.exe build, no hooks installed"); re::Log("unsupported sacred.exe build, no hooks installed");
MessageBoxW(nullptr, MessageBoxW(nullptr,
L"This sacred.exe is not the supported Sacred Gold 2.28 build.\n" L"This sacred.exe is not the supported Sacred Gold 2.28 build.\n"
L"sacred_re will not patch it; the game runs unmodified.", L"sacredK will not patch it; the game runs unmodified.",
L"sacred_re", MB_OK | MB_ICONWARNING); L"sacredK", MB_OK | MB_ICONWARNING);
return; return;
} }
if (re::ConfigInt("debug", "WaitForDebugger", 0)) { if (re::ConfigInt("debug", "WaitForDebugger", 0)) {
+2 -2
View File
@@ -13,7 +13,7 @@
// //
// The signature, including the calling convention, must match the original // The signature, including the calling convention, must match the original
// exactly (`__cdecl`, `__stdcall`, `__thiscall` / `__fastcall` stand-in, ...). // exactly (`__cdecl`, `__stdcall`, `__thiscall` / `__fastcall` stand-in, ...).
// Hooks can be disabled individually at runtime in sacred_re.ini: // Hooks can be disabled individually at runtime in sacredK.ini:
// //
// [hooks] // [hooks]
// Log=0 // Log=0
@@ -40,7 +40,7 @@ bool AttachRuntimeHook(const char* name, void** original, void* replacement);
// Hooks a Windows API function for the whole process (game and DLLs alike). // Hooks a Windows API function for the whole process (game and DLLs alike).
// `replacement` must have the API's exact signature; defines // `replacement` must have the API's exact signature; defines
// `replacement##_original`. Disabled in sacred_re.ini under the API's name. // `replacement##_original`. Disabled in sacredK.ini under the API's name.
#define RE_HOOK_API(api, replacement) \ #define RE_HOOK_API(api, replacement) \
static decltype(&replacement) replacement##_original = &api; \ static decltype(&replacement) replacement##_original = &api; \
static ::re::HookRegistration replacement##_registration( \ static ::re::HookRegistration replacement##_registration( \
+1 -1
View File
@@ -1,5 +1,5 @@
#pragma once #pragma once
// sacred_re's own log: sacred_re.log next to sacred_re.dll, plus OutputDebugString. // sacredK's own log: sacredK.log next to sacredK.dll, plus OutputDebugString.
namespace re { namespace re {
+1 -1
View File
@@ -1,4 +1,4 @@
LIBRARY sacred_re LIBRARY sacredK
EXPORTS EXPORTS
; Detours requires ordinal 1 on any DLL injected via DetourCreateProcessWithDll. ; Detours requires ordinal 1 on any DLL injected via DetourCreateProcessWithDll.
DetourFinishHelperProcess @1 NONAME DetourFinishHelperProcess @1 NONAME
+1 -1
View File
@@ -30,7 +30,7 @@ $env:GHIDRA_HEADLESS_MAXMEM = "8G"
`autotest-load.ps1` starts the game through the launcher, clicks `autotest-load.ps1` starts the game through the launcher, clicks
"Spiel laden" -> "Laden" -> "Ok" (first save game), takes screenshots in-game "Spiel laden" -> "Laden" -> "Ok" (first save game), takes screenshots in-game
and with the ESC menu open, and stops the game. `-Ini` appends temporary and with the ESC menu open, and stops the game. `-Ini` appends temporary
settings to `build\x86-debug\sacred_re.ini` (restored afterwards), e.g. settings to `build\x86-debug\sacredK.ini` (restored afterwards), e.g.
```powershell ```powershell
.\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0" .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
+3 -3
View File
@@ -2,10 +2,10 @@
# the list, 1 = the first, selected by default), take # the list, 1 = the first, selected by default), take
# screenshots in-game and with the ESC menu open, then stop the game. # screenshots in-game and with the ESC menu open, then stop the game.
# #
# .\tools\autotest-load.ps1 -Name ws_on # current sacred_re.ini # .\tools\autotest-load.ps1 -Name ws_on # current sacredK.ini
# .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0" # .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
# #
# -Ini temporarily appends settings to build\x86-debug\sacred_re.ini. # -Ini temporarily appends settings to build\x86-debug\sacredK.ini.
# Click positions are for the classic (menu) layout scaled onto a 3440x1440 # Click positions are for the classic (menu) layout scaled onto a 3440x1440
# monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs. # monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs.
param( param(
@@ -21,7 +21,7 @@ $ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "game-input.ps1") . (Join-Path $PSScriptRoot "game-input.ps1")
$root = Split-Path $PSScriptRoot $root = Split-Path $PSScriptRoot
$iniPath = Join-Path $root "build\x86-debug\sacred_re.ini" $iniPath = Join-Path $root "build\x86-debug\sacredK.ini"
$iniBackup = if (Test-Path $iniPath) { Get-Content $iniPath -Raw } else { "" } $iniBackup = if (Test-Path $iniPath) { Get-Content $iniPath -Raw } else { "" }
if ($Ini) { Set-Content $iniPath ($iniBackup + "`r`n" + $Ini + "`r`n") -Encoding ASCII } if ($Ini) { Set-Content $iniPath ($iniBackup + "`r`n" + $Ini + "`r`n") -Encoding ASCII }
+4 -4
View File
@@ -31,9 +31,9 @@ function Focus-Sacred($Game) {
[SacredRe.Input]::SetForegroundWindow($Game.MainWindowHandle) [SacredRe.Input]::SetForegroundWindow($Game.MainWindowHandle)
} }
$script:LogPath = Join-Path $script:Root "build\x86-debug\sacred_re.log" $script:LogPath = Join-Path $script:Root "build\x86-debug\sacredK.log"
# Waits until sacred_re.log (written anew at every start) has at least $Count # Waits until sacredK.log (written anew at every start) has at least $Count
# lines containing $Pattern, focusing the game meanwhile; then waits $AfterMs. # lines containing $Pattern, focusing the game meanwhile; then waits $AfterMs.
function Wait-Log([string]$Pattern, [int]$Count = 1, [int]$TimeoutSeconds = 60, [int]$AfterMs = 500, $Game = $null) { function Wait-Log([string]$Pattern, [int]$Count = 1, [int]$TimeoutSeconds = 60, [int]$AfterMs = 500, $Game = $null) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds) $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
@@ -46,7 +46,7 @@ function Wait-Log([string]$Pattern, [int]$Count = 1, [int]$TimeoutSeconds = 60,
if ($found -ge $Count) { Start-Sleep -Milliseconds $AfterMs; return } if ($found -ge $Count) { Start-Sleep -Milliseconds $AfterMs; return }
Start-Sleep -Milliseconds 200 Start-Sleep -Milliseconds 200
} }
throw "timeout waiting for '$Pattern' in sacred_re.log" throw "timeout waiting for '$Pattern' in sacredK.log"
} }
# Starts the game and returns once its main menu is up (the UI manager # Starts the game and returns once its main menu is up (the UI manager
@@ -54,7 +54,7 @@ function Wait-Log([string]$Pattern, [int]$Count = 1, [int]$TimeoutSeconds = 60,
function Start-Sacred([string]$Arguments = "log") { function Start-Sacred([string]$Arguments = "log") {
if (Get-Process sacred -ErrorAction SilentlyContinue) { throw "sacred.exe is already running" } if (Get-Process sacred -ErrorAction SilentlyContinue) { throw "sacred.exe is already running" }
if (Test-Path $script:LogPath) { Remove-Item $script:LogPath -ErrorAction SilentlyContinue } if (Test-Path $script:LogPath) { Remove-Item $script:LogPath -ErrorAction SilentlyContinue }
$null = Start-Process (Join-Path $script:Root "build\x86-debug\sacred_re.exe") -ArgumentList $Arguments $null = Start-Process (Join-Path $script:Root "build\x86-debug\sacredK.exe") -ArgumentList $Arguments
$game = $null $game = $null
for ($i = 0; $i -lt 80 -and -not $game; $i++) { for ($i = 0; $i -lt 80 -and -not $game; $i++) {
Start-Sleep -Milliseconds 250 Start-Sleep -Milliseconds 250
+1 -1
View File
@@ -6,7 +6,7 @@
// //
// Ghidra's own RecoverClassesFromRTTIScript finds nothing in this binary. // Ghidra's own RecoverClassesFromRTTIScript finds nothing in this binary.
// Run once, headless, without -readOnly (see tools/README.md). // Run once, headless, without -readOnly (see tools/README.md).
// @category sacred_re // @category sacredK
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
+1 -1
View File
@@ -6,7 +6,7 @@
// //
// Addresses without a function (e.g. methods only reachable via a vtable) get // Addresses without a function (e.g. methods only reachable via a vtable) get
// one created first; run without -readOnly to keep those in the project. // one created first; run without -readOnly to keep those in the project.
// @category sacred_re // @category sacredK
import java.io.File; import java.io.File;
import java.io.PrintWriter; import java.io.PrintWriter;
+1 -1
View File
@@ -9,7 +9,7 @@
// into a class namespace; "#" starts a comment. Functions are created where // into a class namespace; "#" starts a comment. Functions are created where
// none starts at the address yet. Names starting with "g_" label data // none starts at the address yet. Names starting with "g_" label data
// (globals, tables) instead of functions. // (globals, tables) instead of functions.
// @category sacred_re // @category sacredK
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
+62
View File
@@ -258,3 +258,65 @@
0x005819a0 cCreatureArts::FindCombo # (id) -> index, 4 if none 0x005819a0 cCreatureArts::FindCombo # (id) -> index, 4 if none
0x00581410 cCreatureArts::ComboTooltip # (index, text, mode) 0x00581410 cCreatureArts::ComboTooltip # (index, text, mode)
0x004d2970 FindComboDef # (0x200..0x204) -> 0x30-byte entry at 0x953348 0x004d2970 FindComboDef # (0x200..0x204) -> 0x30-byte entry at 0x953348
# --- movement (WASD direction movement, 2026-10-18) ---------------------------
0x00594110 cCreature::Update # (u8), ret 4; ~40/s: walks the path (+0x2ed) into g_creatureNewPosition, Place
0x00593c50 cCreature::CanStepTo # (x, y, position*), ret 0xc; refuses an unwalkable other cell; side effects
0x004f7b20 cCreature::IsCellWalkable # (cell position*), ret 4; read-only
0x00593290 cCreature::BlendToAnimation # (anim, duration, speed, scale): Granny cross-fade
0x0054e540 cCreature::ResetToIdle # (notify, any level): idle animation, action 0 phase 1, pending 0 (weapon class change)
0x00622570 MapPosition_Init # {u16 area, x, y, u8 level}
0x00622590 MapPosition_Set # from (area, x, y, level)
0x006225c0 MapPosition_ToCell # world units -> cells (/ 53.66563)
0x00ad2368 g_creatureNewPosition # MapPosition the walk step computes before Place
0x0065f010 cTextureCache::Get # (id): loads on demand, touches the LRU
# --- UI edit control ---------------------------------------------------------
0x00737ae0 cUI_EditControl::Render # vf14, ret 4; returns at once while hidden
0x007372b0 cUI_EditControl::HandleEvent # vf10
0x00546df0 cCreature::WeaponClass # (0) -> motion set index
0x004dbaa0 cCreature::ContactDistance # (other): mean of the body radii
0x00537430 cCreature::Think # vf04 (float elapsed), ret 4; ~every 110 ms: pending action (+0xfc), then the action's handler
0x004ede70 cCreature::StartPendingWalk # (): pending walk (+0xfc 2) -> action 1, params +0x100 -> +0x154
0x004f45b0 cCreature::WalkAction # (): walk action handler; phase 0 plans the path, facing, animation
# --- statistics / survival bonus (2026-10-18) ----------------------------------
0x00425160 cStatsManager::Instance # cdecl (player id, ignored) -> record at 0xaa3518
0x00424930 cStatsManager::AddTime # (float s, alive): play time (+0x56f8 h, +0x56fc min, +0x5700 ms), survival +0x570c ms
0x004245a0 cStatsManager::OnHeroDeath # deaths +0x56f4 += 1, survival time = 0
0x004245c0 cStatsManager::Deaths
0x00424520 cStatsManager::SurvivalSeconds # +0x570c / 1000
0x00424540 cStatsManager::SetSurvivalSeconds # (s), ret 4
0x00424560 cStatsManager::CountSpawn # +0x5710 u16 (random spawns near the hero, 0x45ff50)
0x00424ce0 cStatsManager::Serialize # (stream, write): ends with magic 0xF00DF00D; survival time not included
0x00565c30 DiminishingCurve # (min, x, half, max): 0 below 1, else min + 2(max-min)(1 - 1/((x-1)/half + 1))
0x006046f0 ItemDataMgr::saveHero # 0x22c header + 0x40 block (+0 deaths, +8 survival s, +0xc spawns)
0x00605900 ItemDataMgr::loadHero
0x00672fd0 TextManager::GetTextByKey # (key string): id = hash 0x80e010
0x0080e010 TextKeyHash # h = (h*113 + toupper(c)) % 999999991, & 0x7fffffff
0x00565f30 cCreatureArts::ApplySkill # (skill, level, describe out, ...), ret 0x18: skill effects, or their text
0x00565c80 cCreatureArts::ApplySkillToArts # (arts[], start, level, half, limit, attack, out, nameText), ret 0x20
0x0054a110 cCreature::HoldsRangedWeapon # Geschick instead of Stärke for physical damage
0x004d1c50 CombatMoveDef::LinkedSpell # spell id whose values the combat move uses, 0 if none
0x0043ac50 TypeTable::CreatureRecord # (type), ret 4: class record (+0x16: 16 skill ids)
0x006e6630 cUI_Tooltip::FormatText # (char), ret 4: copies +0x115c to +0x2168, wraps at 45 chars with flag 0x80
0x006e6ae0 cUI_Tooltip::SetText # (textId, text*, fixedWidth), ret 0xc
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)
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)
0x0070e650 cUI_Character::OnConfirmButton # the right-hand button (Exportieren, ...)
0x007f01a0 HeroSlots_DeleteHero # (slot + 1), thiscall on 0x7d7c40(), ret 4: deletes Save\Hero%02d.pax
0x00731e70 cUI_Control2::SetFlags # (flags), ret 4: bit 0 = visible (show)
0x00716570 cUI_Options::cUI_Options # (name, rect, parent, flags) ret 0x18
0x007165f0 cUI_Options::~cUI_Options
0x007184e0 cUI_Options::CreateControls # frames, labels, buttons, sliders (vector +0x90)
0x00717ea0 cUI_Options::LoadSettings # settings -> controls, on show
0x00717460 cUI_Options::Apply # controls -> settings (OK)
0x00739f90 cUI_Frame::cUI_Frame
0x0043fa90 PotionTooltipText # (kind 1..5), ret 4: bar tooltip text id by key ("<color>Heiltrank <colorw>[SPC]")
0x00724ed0 cUI_Window2::AddBackdrop # (rect*, ARGB), ret 8: translucent cUI_Static into +0x90
0x0075a690 cUI_Manager::CanOpenWindow # (1), ret 4: false while a blocking window is open
0x007320c0 cUI_Control2::SetTooltipText # (text id) -> +0x20, ret 4
0x007320d0 cUI_Control2::TooltipText # +0x20 once hovered long enough, else 0
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"name": "sacred-re-tools", "name": "sacredk-tools",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sacred-re-tools", "name": "sacredk-tools",
"dependencies": { "dependencies": {
"iced-x86": "1.21.0" "iced-x86": "1.21.0"
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "sacred-re-tools", "name": "sacredk-tools",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {