Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70673dc301 | ||
|
|
462ebf8c0b | ||
|
|
838b1aa491 | ||
|
|
72525cd5ba | ||
|
|
c875d7d6b9 | ||
|
|
5d80a33e9b | ||
|
|
4018e23e6b | ||
|
|
84c50d8c33 | ||
|
|
d296e2850b | ||
|
|
184c7058fd |
No files matched your search
+1
-1
@@ -3,6 +3,6 @@
|
||||
/.idea/
|
||||
/cmake-build-*/
|
||||
*.rea-snapshot
|
||||
sacred_re.ini
|
||||
sacredK.ini
|
||||
/ghidra/
|
||||
/screenshots/
|
||||
Vendored
+3
-3
@@ -3,10 +3,10 @@
|
||||
"configurations": [
|
||||
{
|
||||
// Builds, then starts the game through the launcher (no debugger in the game).
|
||||
"name": "Run Sacred (sacred_re)",
|
||||
"name": "Run Sacred (sacredK)",
|
||||
"type": "cppvsdbg",
|
||||
"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"],
|
||||
"cwd": "${workspaceFolder}/build/x86-debug",
|
||||
"preLaunchTask": "CMake: build",
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
{
|
||||
// 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.
|
||||
"name": "Attach to sacred.exe",
|
||||
"type": "cppvsdbg",
|
||||
|
||||
@@ -6,8 +6,8 @@ project, autotest) first; this file adds how we work and what we learned.
|
||||
|
||||
## What this is
|
||||
|
||||
`sacred_re.dll` is injected into the original **Sacred Gold 2.28**
|
||||
`sacred.exe` (2006, 32-bit, MSVC 6) by the launcher `sacred_re.exe`. It
|
||||
`sacredK.dll` is injected into the original **Sacred Gold 2.28**
|
||||
`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
|
||||
the work so far is **mods** — behaviour changes on top of the original
|
||||
(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.
|
||||
|
||||
- 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.
|
||||
- Game texts are German (Fertigkeiten, Attribute, Blitzschlag, ...); keep the
|
||||
game's names when talking about features.
|
||||
@@ -65,12 +65,12 @@ at every step; the user plays it to test.
|
||||
|
||||
```powershell
|
||||
.\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:
|
||||
`build\x86-debug\sacred_re.log` (written anew each start; our lines plus the
|
||||
game's own log). Crashes: `sacred_re_crash.txt` / `.dmp` next to the DLL
|
||||
`build\x86-debug\sacredK.log` (written anew each start; our lines plus the
|
||||
game's own log). Crashes: `sacredK_crash.txt` / `.dmp` next to the DLL
|
||||
(exception, module+offset, ebp chain); Windows also records the faulting
|
||||
offset in the Application event log (`Get-WinEvent`, provider "Application
|
||||
Error"). The debug build's run-time checks turn calling-convention mistakes
|
||||
|
||||
+20
-16
@@ -1,12 +1,12 @@
|
||||
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.
|
||||
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()
|
||||
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()
|
||||
|
||||
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/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 -------------
|
||||
add_library(sacred_re SHARED
|
||||
# --- sacredK.dll: injected into sacred.exe, installs the hooks -------------
|
||||
add_library(sacredK SHARED
|
||||
src/re/dllmain.cpp
|
||||
src/re/hook.cpp
|
||||
src/re/log.cpp
|
||||
src/re/config.cpp
|
||||
src/re/patch.cpp
|
||||
src/re/crash.cpp
|
||||
src/re/sacred_re.def
|
||||
src/re/sacredK.def
|
||||
src/game/engine/log.cpp
|
||||
src/game/engine/cmdline.cpp
|
||||
src/game/engine/events.cpp
|
||||
@@ -38,6 +38,7 @@ add_library(sacred_re SHARED
|
||||
src/game/engine/items.cpp
|
||||
src/game/engine/stream.cpp
|
||||
src/game/ui/texts.cpp
|
||||
src/game/engine/art.cpp
|
||||
src/mods/borderless.cpp
|
||||
src/mods/widescreen.cpp
|
||||
src/mods/hud.cpp
|
||||
@@ -58,6 +59,7 @@ add_library(sacred_re SHARED
|
||||
src/mods/combos.cpp
|
||||
src/mods/aurahits.cpp
|
||||
src/mods/chainlightning.cpp
|
||||
src/mods/arttooltip.cpp
|
||||
src/mods/escape.cpp
|
||||
src/mods/recordcache.cpp
|
||||
src/mods/megamap.cpp
|
||||
@@ -65,14 +67,16 @@ add_library(sacred_re SHARED
|
||||
src/mods/runetrade.cpp
|
||||
src/mods/teleportpotion.cpp
|
||||
src/mods/guards.cpp
|
||||
src/mods/charexport.cpp
|
||||
src/mods/options.cpp
|
||||
)
|
||||
target_include_directories(sacred_re PRIVATE src)
|
||||
target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS})
|
||||
target_link_libraries(sacred_re PRIVATE detours nlohmann_json::nlohmann_json dbghelp)
|
||||
target_include_directories(sacredK PRIVATE src)
|
||||
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
|
||||
target_link_libraries(sacredK PRIVATE detours nlohmann_json::nlohmann_json dbghelp)
|
||||
|
||||
# --- sacred_re.exe: launches sacred.exe with sacred_re.dll injected ----------
|
||||
add_executable(sacred_re_launcher WIN32 src/launcher/main.cpp)
|
||||
set_target_properties(sacred_re_launcher PROPERTIES OUTPUT_NAME sacred_re)
|
||||
target_compile_options(sacred_re_launcher PRIVATE ${SACRED_RE_CXX_FLAGS})
|
||||
target_link_libraries(sacred_re_launcher PRIVATE detours)
|
||||
add_dependencies(sacred_re_launcher sacred_re)
|
||||
# --- sacredK.exe: launches sacred.exe with sacredK.dll injected ----------
|
||||
add_executable(sacredK_launcher WIN32 src/launcher/main.cpp)
|
||||
set_target_properties(sacredK_launcher PROPERTIES OUTPUT_NAME sacredK)
|
||||
target_compile_options(sacredK_launcher PRIVATE ${SACREDK_CXX_FLAGS})
|
||||
target_link_libraries(sacredK_launcher PRIVATE detours)
|
||||
add_dependencies(sacredK_launcher sacredK)
|
||||
@@ -1,8 +1,8 @@
|
||||
# sacred_re
|
||||
# sacredK
|
||||
|
||||
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
|
||||
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
|
||||
@@ -42,14 +42,14 @@ the CMake presets.
|
||||
## Running
|
||||
|
||||
```powershell
|
||||
build\x86-debug\sacred_re.exe --game-dir "D:\Gaming\Sacred Gold" # first run; remembered in sacred_re.ini
|
||||
build\x86-debug\sacred_re.exe log # later runs; extra args go to the game
|
||||
build\x86-debug\sacredK.exe --game-dir "D:\Gaming\Sacred Gold" # first run; remembered in sacredK.ini
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```ini
|
||||
@@ -61,7 +61,7 @@ CmdLineHasFlag=0
|
||||
|
||||
```
|
||||
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/mods/ behaviour changes on top of the original (e.g. borderless.cpp)
|
||||
docs/ reverse-engineering notes; function-map.md tracks progress
|
||||
|
||||
@@ -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.
|
||||
@@ -85,3 +85,28 @@ Rules (`autoattack.cpp`):
|
||||
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,
|
||||
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.
|
||||
@@ -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
|
||||
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)
|
||||
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
|
||||
use `JumpRange()` = half the cast range (450). The tooltip shows both
|
||||
("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).
|
||||
@@ -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
@@ -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
|
||||
visible inside it. A viewport at x 0 across the whole render width is now
|
||||
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
@@ -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.
|
||||
@@ -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
|
||||
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.
|
||||
- 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
|
||||
`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
|
||||
|
||||
@@ -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.
|
||||
@@ -19,10 +19,13 @@ one of them.
|
||||
|
||||
## Mod (src/mods/teleportpotion.cpp)
|
||||
|
||||
- Every item type of potion kind 5 counts. Their names (text 0x428700 of each
|
||||
type) are replaced in every game text (`game::ReplaceInTexts`): the item
|
||||
names and the bottom bar's quick-select tooltip ("Trank der Konzentration
|
||||
[R]", its own text id). The shortest name is replaced, so stage words stay.
|
||||
- Every item type of potion kind 5 counts. Their name, "Trank der
|
||||
Konzentration", is replaced in every game text from the DLL's start
|
||||
(`game::ReplaceInTexts`, applied where the game fetches a text): the item
|
||||
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`
|
||||
first: for the hero's Teleportationstrank it posts the teleporter's event
|
||||
with the hero's mask instead of the drink.
|
||||
|
||||
+30
-5
@@ -93,7 +93,7 @@ mouse/keyboard like `tools/autotest-load.ps1`:
|
||||
2. play scripted input through the existing hooks (keys, casting arts, weapon
|
||||
set switches, save + reload);
|
||||
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, ...);
|
||||
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
|
||||
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) |
|
||||
| S | save game window (`UI_WND_SAVEGAME`) | K (`[keys] SaveGame`) |
|
||||
| D | unbound | - |
|
||||
|
||||
Globally bound letters (engine `0x618130`, UI manager `0x757120`): A B C E F
|
||||
H I L M N O P Q R S T W, Space, ^, digits. Individual windows may use more.
|
||||
## Hard-coded game keys left (2026-10-19)
|
||||
|
||||
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
|
||||
|
||||
|
||||
+99
-8
@@ -34,15 +34,87 @@ Mouse, measured with an order log:
|
||||
|
||||
## What the mod does
|
||||
|
||||
Every `cEngine::Tick`, while W/A/S/D are held (and no text field has the
|
||||
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.
|
||||
### Direction movement (2026-10-18)
|
||||
|
||||
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 it arrive and stop.
|
||||
Request: "lets go all in with directional vector movement". The keys steer
|
||||
the hero directly instead of sending walk orders 10 times per second, which
|
||||
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
|
||||
|
||||
@@ -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`
|
||||
(0x737ae0, thiscall (renderer), ret 4, returns at once while hidden) drew in
|
||||
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.
|
||||
@@ -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
|
||||
@@ -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
@@ -40,6 +40,22 @@ inline LaunchType ArtLaunchType(uint16_t id) {
|
||||
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
|
||||
// art id of an art slot's reference (cInventoryEntry::GetArtSlotRef) for this
|
||||
// creature.
|
||||
@@ -137,6 +153,13 @@ inline uint8_t* FindCooldownEntry(uint8_t* arts, uint16_t art) {
|
||||
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 {
|
||||
float remaining = 0.0f; // in the game's units
|
||||
float total = 0.0f;
|
||||
@@ -148,10 +171,8 @@ inline ArtCooldown GetArtCooldown(void* creature, uint16_t art) {
|
||||
if (!entry) {
|
||||
return {};
|
||||
}
|
||||
const bool magic = *reinterpret_cast<uint32_t*>(entry + kCooldownGroup) == kRegenGroupMagic ||
|
||||
*reinterpret_cast<uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
|
||||
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);
|
||||
return {remaining, Float(entry, kCooldownTotal), perSecond > 0.0f ? remaining / perSecond : 0.0f};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "re/crash.h"
|
||||
#include "re/hook.h"
|
||||
|
||||
namespace game {
|
||||
@@ -57,6 +58,7 @@ void __fastcall Engine_Tick(void* self, void* edx, void* event);
|
||||
RE_HOOK(0x0060f1d0, Engine_Tick);
|
||||
void __fastcall Engine_Tick(void* self, void* edx, void* event) {
|
||||
Engine_Tick_original(self, edx, event);
|
||||
re::EngineHeartbeat(true);
|
||||
if (self) {
|
||||
for (EngineTickFn callback : TickCallbacks()) {
|
||||
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);
|
||||
RE_HOOK(0x0060d5e0, Engine_Destruct);
|
||||
void __fastcall Engine_Destruct(void* self, void* edx) {
|
||||
re::EngineHeartbeat(false);
|
||||
for (GameEndFn callback : GameEndCallbacks()) {
|
||||
callback();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "game/engine/items.h"
|
||||
|
||||
#include "game/engine/stream.h"
|
||||
#include "game/ui/texts.h"
|
||||
|
||||
namespace game {
|
||||
namespace {
|
||||
@@ -48,4 +49,25 @@ uint32_t CloneItem(uint32_t id) {
|
||||
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
|
||||
@@ -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);
|
||||
|
||||
} // 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
|
||||
@@ -7,7 +7,7 @@
|
||||
// optional callback (0x017e5ee4) and to either OutputDebugString or the
|
||||
// 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.
|
||||
|
||||
#include "game/engine/log.h"
|
||||
|
||||
@@ -73,11 +73,13 @@ inline void* GetPlayerHero() {
|
||||
}
|
||||
|
||||
// cKernel::PostEvent (0x808930) on Kernel_Instance() (0x808490): the kernel
|
||||
// copies the event and dispatches it (key events right away).
|
||||
inline void PostEvent(void* event) {
|
||||
// copies the event and dispatches it (key events right away). `a`: 1 as the
|
||||
// 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 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
|
||||
@@ -67,20 +67,72 @@ void OnCreatureOrder(CreatureOrderFn callback);
|
||||
constexpr size_t kCreatureId = 0xc;
|
||||
constexpr size_t kCreatureX = 0x1c;
|
||||
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
|
||||
// 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
|
||||
// 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;
|
||||
enum CreatureAction : int32_t {
|
||||
kActionIdle = 0,
|
||||
kActionWalk = 1,
|
||||
kActionStop = 2,
|
||||
kActionAttack = 3,
|
||||
kActionCastSpell = 4,
|
||||
kActionShoot = 5,
|
||||
kActionDying = 6,
|
||||
kActionCombatMove = 8,
|
||||
};
|
||||
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 ==
|
||||
// 9), health (+0x4d8) left.
|
||||
@@ -111,6 +163,40 @@ inline int32_t CurrentAction(const void* creature) {
|
||||
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 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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -57,6 +57,13 @@ inline uint8_t* TabControl(void* sheet) {
|
||||
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.
|
||||
inline uint8_t* TabEntries(uint8_t* control) {
|
||||
return *reinterpret_cast<uint8_t**>(control + kTabControlTabs);
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// First, so its DrawText macro renames game::DrawText the same way everywhere.
|
||||
#include <windows.h>
|
||||
|
||||
namespace game {
|
||||
|
||||
// Screen rect as the UI uses it (cUI_Control2::GetScreenRect 0x731c70,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <cwchar>
|
||||
#include <cwctype>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#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.
|
||||
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) {
|
||||
bool wordStart = true;
|
||||
for (wchar_t& c : text) {
|
||||
@@ -126,6 +136,49 @@ std::wstring GameText(uint32_t id) {
|
||||
: 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) {
|
||||
if (id >= kFirstOwnText && id < 0x80000000) {
|
||||
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);
|
||||
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))) {
|
||||
return original;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
// 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
|
||||
// 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>
|
||||
|
||||
@@ -35,7 +35,7 @@ std::wstring WithTrailingSlash(std::wstring dir) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ int Fail(const std::wstring& message) {
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) {
|
||||
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;
|
||||
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";
|
||||
if (!FileExists(gameExe)) {
|
||||
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)) {
|
||||
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.
|
||||
char dllPathA[MAX_PATH];
|
||||
|
||||
@@ -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
@@ -58,6 +58,8 @@ struct CastWatch {
|
||||
uint32_t target = 0; // an enemy, or 0
|
||||
bool walked = false;
|
||||
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;
|
||||
std::unordered_map<uint16_t, bool> g_artRanged;
|
||||
@@ -118,17 +120,24 @@ void WatchCast(void* hero) {
|
||||
g_cast = {};
|
||||
return;
|
||||
}
|
||||
switch (game::CurrentAction(hero)) {
|
||||
case game::kActionWalk:
|
||||
g_cast.walked = true;
|
||||
break;
|
||||
case game::kActionCastSpell:
|
||||
case game::kActionCombatMove:
|
||||
// Started: the art's cooldown began (spells, combat moves and bow arts
|
||||
// alike), or for an art without one, the hero went into a new action
|
||||
// other than walking (arts ordered during an attack or a cast were taken
|
||||
// as started by that one, measured 2026-10-18).
|
||||
const int32_t action = game::CurrentAction(hero);
|
||||
const game::ArtCooldown cooldown = game::GetArtCooldown(hero, g_cast.art);
|
||||
if (cooldown.remaining > g_cast.cooldown) {
|
||||
OnCastStarted(hero);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
@@ -233,8 +242,26 @@ char __fastcall AttackHeld(void* cursor, void*) {
|
||||
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() {
|
||||
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_STARTUP(AutoAttackStartup, Startup);
|
||||
@@ -265,7 +292,8 @@ void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -278,4 +306,3 @@ struct OrderRegistration {
|
||||
} // namespace
|
||||
|
||||
} // namespace mods::autoattack
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// Mouse positions are mapped back from that rectangle to game pixels, both in
|
||||
// GetMouseClientPos (polled by the game) and in mouse window messages.
|
||||
//
|
||||
// sacred_re.ini:
|
||||
// sacredK.ini:
|
||||
// [borderless]
|
||||
// Enabled=1 ; 0 = original behaviour
|
||||
// IntegerScale=0 ; 1 = scale by whole multiples only (sharper, larger bars)
|
||||
|
||||
+120
-51
@@ -11,14 +11,20 @@
|
||||
//
|
||||
// This mod keeps all of that and decides:
|
||||
// - 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
|
||||
// below; the game's own jump search used about 316) that this
|
||||
// chain has not hit yet; else another enemy; else the current target again
|
||||
// (a lone enemy takes all N hits);
|
||||
// - the next target: an enemy within 75 % of Blitzschlag's cast range (see
|
||||
// below; the game's own jump search used about 316) that no chain of this
|
||||
// cast has claimed yet, so parallel chains take separate routes; else one
|
||||
// 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,
|
||||
// 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).
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
@@ -28,6 +34,7 @@
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "game/engine/art.h"
|
||||
#include "game/engine/arts.h"
|
||||
#include "game/engine/fx.h"
|
||||
#include "game/engine/objects.h"
|
||||
@@ -60,10 +67,10 @@ constexpr int kRaySegments = 20;
|
||||
double CastRange() {
|
||||
return static_cast<double>(kRaySegmentLength) * kRaySegments;
|
||||
}
|
||||
// A jump between enemies reaches half as far as the cast (request of
|
||||
// 2026-10-17: 900 felt too far for jumps).
|
||||
// A jump between enemies reaches 75 % of the cast range (requests: 2026-10-17
|
||||
// 900 felt too far, half; 2026-10-18 more room for separate routes, 75 %).
|
||||
double JumpRange() {
|
||||
return CastRange() / 2;
|
||||
return CastRange() * 0.75;
|
||||
}
|
||||
constexpr uint32_t kConstant30 = 0x0088ef18; // float 30.0, shared by 72 instructions
|
||||
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 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 {
|
||||
std::shared_ptr<Cast> cast;
|
||||
std::vector<uint32_t> hit; // targets in order
|
||||
Direction heading; // the last jump's direction (0, 0 before the first)
|
||||
DWORD started = 0;
|
||||
};
|
||||
|
||||
@@ -118,10 +154,67 @@ unsigned ArtLevel(void* creature, uint16_t art) {
|
||||
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
|
||||
}
|
||||
|
||||
// The nearest living enemy of `caster` within the jump range of `from`, not
|
||||
// `current`; one not in `avoid` if there is any.
|
||||
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());
|
||||
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
|
||||
return std::find(ids.begin(), ids.end(), id) != ids.end();
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -187,19 +280,23 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
|
||||
const uint32_t hits = Hits(level);
|
||||
const unsigned chains = Chains(level);
|
||||
event[kEventHitsLeft] = hits;
|
||||
auto cast = std::make_shared<Cast>();
|
||||
cast->claimed.push_back(target);
|
||||
auto chain = std::make_shared<Chain>();
|
||||
chain->cast = cast;
|
||||
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();
|
||||
g_bolts[id] = {chain, hits};
|
||||
// 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) {
|
||||
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>();
|
||||
branch->cast = cast;
|
||||
branch->hit.push_back(target);
|
||||
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});
|
||||
SpawnBolt(fx, event, branchTarget, hits - 1);
|
||||
}
|
||||
@@ -264,7 +361,7 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui
|
||||
return 0;
|
||||
}
|
||||
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) {
|
||||
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
|
||||
// two numbers, hits per chain and chains, in both the current level's section
|
||||
// and the next level's (skill book). cCreatureArts::ArtTooltip (0x589270,
|
||||
// thiscall on creature +0x3a8, (art, text, flag), ret 0xc) builds the text
|
||||
// into a wide string {begin, end, capacity}; sections start with the level
|
||||
// mods/arttooltip.cpp, game::Art::Tooltip) builds the text; sections start with the level
|
||||
// line, values are "Name: value" lines separated by '\n' (measured for art
|
||||
// 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).
|
||||
@@ -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* 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 out;
|
||||
unsigned level = currentLevel;
|
||||
@@ -335,24 +415,13 @@ std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) {
|
||||
return out;
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
|
||||
text = Rewrite(text, ArtLevel(creature, art.Id()));
|
||||
}
|
||||
|
||||
struct TooltipRegistration {
|
||||
TooltipRegistration() { game::AddTooltipFilter(kArtBlitzschlag, &RewriteTooltip); }
|
||||
} g_tooltipRegistration;
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::chainlightning
|
||||
@@ -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
@@ -12,6 +12,8 @@
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/windows.h"
|
||||
#include "mods/hud.h"
|
||||
#include "re/hook.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
|
||||
// script (0x46bd80). Esc does the same with an answer of 100000 or more: the
|
||||
// dialog closes and the heroes are free, nothing is chosen.
|
||||
constexpr uint32_t kHideVisible = 1;
|
||||
constexpr uint32_t kDialogManager = 0x00aaaf00;
|
||||
constexpr size_t kTooltipDialogOwner = 0x21cc;
|
||||
constexpr uint32_t kNoAnswer = 0xffffffff; // >= 100000: no script callback
|
||||
constexpr uint32_t kDialogPending = 0x00ab5314;
|
||||
|
||||
void CloseDialog(uint8_t* tooltip) {
|
||||
using HideFn = void(__fastcall*)(void*, void*, uint32_t);
|
||||
reinterpret_cast<HideFn>(0x00731ee0)(tooltip, nullptr, kHideVisible);
|
||||
game::SetControlShown(tooltip, false);
|
||||
using AnswerFn = void(__fastcall*)(void*, void*, uint32_t owner, uint32_t answer);
|
||||
reinterpret_cast<AnswerFn>(0x0049a090)(reinterpret_cast<void*>(kDialogManager), nullptr,
|
||||
*reinterpret_cast<uint32_t*>(tooltip + kTooltipDialogOwner), kNoAnswer);
|
||||
@@ -55,39 +55,10 @@ void CloseDialog(uint8_t* tooltip) {
|
||||
}
|
||||
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) {
|
||||
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
|
||||
|
||||
// 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) {
|
||||
auto* window = reinterpret_cast<uint8_t*>(manager[w.slot]);
|
||||
if (Visible(window)) {
|
||||
Hide(manager, w.name);
|
||||
game::SendWindowCommand(manager, w.name, game::WindowCommand::Hide);
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ namespace game {
|
||||
// cParticleSystem_burningBone::Render (vf04 0x774e10, vtable 0x895b84):
|
||||
// crashed reading the device it was given, which was null (2026-10-16, Esc
|
||||
// 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.
|
||||
// (device, a 4x4 matrix by value; ret 0x44).
|
||||
struct Matrix4 {
|
||||
|
||||
+75
-5
@@ -16,7 +16,7 @@
|
||||
// inside both.
|
||||
// The world never sees these offsets: they are only active inside the hooks.
|
||||
//
|
||||
// sacred_re.ini:
|
||||
// sacredK.ini:
|
||||
// [hud]
|
||||
// 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);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
int CursorOffsetX() {
|
||||
@@ -571,6 +614,27 @@ int TooltipOffset(void* tooltip) {
|
||||
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 {
|
||||
|
||||
using mods::hud::Anchor;
|
||||
@@ -629,11 +693,10 @@ HUD_RENDER(0x006ea750, UiTrade_Render, Anchor::Center)
|
||||
HUD_EVENT(0x006f8960, UiNetworkInfo_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center)
|
||||
HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER3(0x0070f160, UiCharacter_Render, Anchor::Center)
|
||||
HUD_EVENT(0x00716b30, UiOptions_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER3(0x00716640, UiOptions_Render, Anchor::Center)
|
||||
HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game
|
||||
// cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp.
|
||||
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_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
|
||||
|
||||
@@ -714,6 +777,13 @@ int RenderCentred(void* window, int (*body)(void* context), void* 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) {
|
||||
EventOffset offset(event, OffsetFor(Anchor::Center));
|
||||
return body(context);
|
||||
|
||||
@@ -26,5 +26,8 @@ int VisibleDialogTooltips(void** out, int max);
|
||||
// shifted) or event handler (mouse position shifted by the window's offset).
|
||||
int RenderCentred(void* window, int (*body)(void* context), void* context);
|
||||
int HandleCentred(void* event, int (*body)(void* context), void* context);
|
||||
// RenderCentred for full-screen windows: black beside the 1024 frame, drawn
|
||||
// with a viewport on it (as the save game and export screens).
|
||||
int RenderFramed(void* window, void* device, int (*body)(void* context), void* context);
|
||||
|
||||
} // namespace mods::hud
|
||||
+383
-69
@@ -6,22 +6,30 @@
|
||||
// 0x6e1970). This mod reads the raw key messages instead (virtual keys,
|
||||
// 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),
|
||||
// weapon set keys select it on the taskbar; keys standing in for an original
|
||||
// hotkey post that hotkey's key event to the game's event kernel, the way the window
|
||||
// procedure does (the kernel dispatches it right away, on the window thread).
|
||||
// weapon set keys select it on the taskbar; the other actions call the game
|
||||
// function its hotkey ran. The game's own hotkeys for bound actions are
|
||||
// switched off (potions) or kept from it (the keys are swallowed).
|
||||
//
|
||||
// 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
|
||||
// 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.)
|
||||
//
|
||||
// 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]
|
||||
// SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art)
|
||||
// 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)
|
||||
// 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"
|
||||
|
||||
@@ -29,14 +37,16 @@
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/items.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/ui/windows.h"
|
||||
#include "mods/skillbar.h"
|
||||
#include "mods/skillkeys.h"
|
||||
#include "mods/wasd.h"
|
||||
#include "re/config.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
@@ -45,69 +55,109 @@
|
||||
namespace mods::input {
|
||||
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 ------------------------------------------------------------------
|
||||
|
||||
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 {
|
||||
const char* name;
|
||||
const char* fallback;
|
||||
const wchar_t* label;
|
||||
Kind kind;
|
||||
int index; // slot / set (0-based)
|
||||
char gameKey; // GameKey: the original hotkey
|
||||
int index; // slot / set (0-based), Move direction
|
||||
int vk = 0;
|
||||
};
|
||||
|
||||
// Display order (the key bindings page shows them in groups by kind).
|
||||
Binding g_bindings[] = {
|
||||
{"SkillSlot0", "0", Kind::SkillSlot, 0, 0},
|
||||
{"SkillSlot1", "1", Kind::SkillSlot, 1, 0},
|
||||
{"SkillSlot2", "2", Kind::SkillSlot, 2, 0},
|
||||
{"SkillSlot3", "3", Kind::SkillSlot, 3, 0},
|
||||
{"SkillSlot4", "4", Kind::SkillSlot, 4, 0},
|
||||
{"SkillSlot5", "5", Kind::SkillSlot, 5, 0},
|
||||
{"SkillSlot6", "6", Kind::SkillSlot, 6, 0},
|
||||
{"WeaponSet1", "F1", Kind::WeaponSet, 0, 0},
|
||||
{"WeaponSet2", "F2", Kind::WeaponSet, 1, 0},
|
||||
{"WeaponSet3", "F3", Kind::WeaponSet, 2, 0},
|
||||
{"WeaponSet4", "F4", Kind::WeaponSet, 3, 0},
|
||||
{"WeaponSet5", "F5", Kind::WeaponSet, 4, 0},
|
||||
{"WeaponSet6", "F6", Kind::WeaponSet, 5, 0},
|
||||
{"WeaponSet7", "F7", Kind::WeaponSet, 6, 0},
|
||||
{"ActionSlot3", "G", Kind::GameKey, 0, 'W'},
|
||||
{"PickUp", "Q", Kind::PickUp, 0, 0},
|
||||
{"SaveGame", "K", Kind::GameKey, 0, 'S'},
|
||||
{"SkillSlot0", "0", L"Kampfkunst 0 (Rechtsklick)", Kind::SkillSlot, 0},
|
||||
{"SkillSlot1", "1", L"Kampfkunst 1", Kind::SkillSlot, 1},
|
||||
{"SkillSlot2", "2", L"Kampfkunst 2", Kind::SkillSlot, 2},
|
||||
{"SkillSlot3", "3", L"Kampfkunst 3", Kind::SkillSlot, 3},
|
||||
{"SkillSlot4", "4", L"Kampfkunst 4", Kind::SkillSlot, 4},
|
||||
{"SkillSlot5", "5", L"Kampfkunst 5", Kind::SkillSlot, 5},
|
||||
{"SkillSlot6", "6", L"Kampfkunst 6", Kind::SkillSlot, 6},
|
||||
{"WeaponSet1", "F1", L"Waffenset 1", Kind::WeaponSet, 0},
|
||||
{"WeaponSet2", "F2", L"Waffenset 2", Kind::WeaponSet, 1},
|
||||
{"WeaponSet3", "F3", L"Waffenset 3", Kind::WeaponSet, 2},
|
||||
{"WeaponSet4", "F4", L"Waffenset 4", Kind::WeaponSet, 3},
|
||||
{"WeaponSet5", "F5", L"Waffenset 5", Kind::WeaponSet, 4},
|
||||
{"WeaponSet6", "F6", L"Waffenset 6", Kind::WeaponSet, 5},
|
||||
{"WeaponSet7", "F7", L"Waffenset 7", Kind::WeaponSet, 6},
|
||||
{"MoveUp", "W", L"Nach oben", Kind::Move, static_cast<int>(Move::Up)},
|
||||
{"MoveLeft", "A", L"Nach links", Kind::Move, static_cast<int>(Move::Left)},
|
||||
{"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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (_stricmp(text.c_str(), "Space") == 0) {
|
||||
return VK_SPACE;
|
||||
for (const NamedKey& key : kKeyNames) {
|
||||
if (_stricmp(text.c_str(), key.ini) == 0) {
|
||||
return key.vk;
|
||||
}
|
||||
}
|
||||
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
|
||||
// row (the game's slot keys) and the movement keys.
|
||||
// row (the game's slot keys).
|
||||
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
|
||||
// 0x113, cEngine::ReceiveEvent 0x618c41; the hero collects, mode +0xfc 0xc),
|
||||
// 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 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) {
|
||||
if (const int potion = g_potion.exchange(0)) {
|
||||
UseActionSlot(engine, potion & 0xff, (potion & kAllyPotion) != 0);
|
||||
}
|
||||
if (!g_pickUp.exchange(false)) {
|
||||
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 {
|
||||
Registration() {
|
||||
game::OnEngineTick(&OnTick);
|
||||
@@ -190,12 +368,24 @@ void Press(const Binding& binding, int vk) {
|
||||
case Kind::WeaponSet:
|
||||
mods::skillbar::SelectWeaponSet(slot);
|
||||
break;
|
||||
case Kind::GameKey:
|
||||
PostGameKey(binding.gameKey, true);
|
||||
case Kind::SaveGame:
|
||||
OpenSaveGame();
|
||||
break;
|
||||
case Kind::PickUp:
|
||||
g_pickUp = true;
|
||||
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:
|
||||
mods::skillkeys::KeyReleased(vk);
|
||||
break;
|
||||
case Kind::WeaponSet:
|
||||
case Kind::PickUp:
|
||||
case Kind::OverviewMap:
|
||||
ShowOverviewMap(false);
|
||||
break;
|
||||
case Kind::GameKey:
|
||||
PostGameKey(binding.gameKey, false);
|
||||
case Kind::WeaponSet:
|
||||
case Kind::Move:
|
||||
case Kind::Potion:
|
||||
case Kind::PickUp:
|
||||
case Kind::SaveGame:
|
||||
case Kind::Map:
|
||||
case Kind::Labels:
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -222,6 +417,17 @@ bool g_swallowChar = false;
|
||||
|
||||
void Startup() {
|
||||
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)) {
|
||||
re::Log("input: pickup range patch FAILED");
|
||||
}
|
||||
@@ -252,7 +458,7 @@ uint32_t ControlFlags(const uint8_t* control) {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!focus || focus != g_drawnEdit.load() || GetTickCount() - g_drawnEditTick.load() > kDrawnRecentlyMs) {
|
||||
return nullptr;
|
||||
@@ -269,7 +475,7 @@ uint8_t* FocusedTextField() {
|
||||
// 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.
|
||||
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) &&
|
||||
(ControlFlags(static_cast<uint8_t*>(edit)) & kControlVisible) != 0) {
|
||||
g_drawnEdit = edit;
|
||||
@@ -281,11 +487,119 @@ bool TypingIntoTextField() {
|
||||
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) {
|
||||
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: {
|
||||
const int vk = static_cast<int>(wParam & 0xff);
|
||||
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) {
|
||||
g_taken[vk] = false;
|
||||
if (FocusedTextField()) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace mods::input {
|
||||
@@ -14,4 +16,32 @@ void NoteEditDrawn(void* edit);
|
||||
// handled and must not reach the game.
|
||||
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
|
||||
@@ -193,7 +193,7 @@ void PutArtSlot(const json& variants, uint8_t* slot) {
|
||||
json ToJson(const std::vector<uint8_t>& block, const uint8_t* shadow) {
|
||||
Reader r(block);
|
||||
json j;
|
||||
j["format"] = "sacred_re.inventory";
|
||||
j["format"] = "sacredK.inventory";
|
||||
j["version"] = kVersion;
|
||||
j["player"] = 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.
|
||||
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");
|
||||
}
|
||||
Writer w;
|
||||
|
||||
@@ -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
|
||||
@@ -9,13 +9,21 @@
|
||||
// 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
|
||||
// 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 <cwchar>
|
||||
#include <string>
|
||||
|
||||
#include "game/engine/items.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/ui/text.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "mods/input.h"
|
||||
#include "mods/itemstack.h"
|
||||
#include "mods/skillslots.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 mods::potioncounts
|
||||
|
||||
@@ -69,5 +97,6 @@ void __fastcall UiTaskbar_RenderPotionSlots(void* self, void* edx, void* rendere
|
||||
UiTaskbar_RenderPotionSlots_original(self, edx, renderer);
|
||||
if (self) {
|
||||
mods::potioncounts::RenderCounts(static_cast<uint8_t*>(self), renderer);
|
||||
mods::potioncounts::UpdateTooltips();
|
||||
}
|
||||
}
|
||||
+12
-33
@@ -29,6 +29,8 @@
|
||||
|
||||
#include "game/engine/arts.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/skills.h"
|
||||
#include "game/ui/draw.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "game/ui/text.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 kButtonSize = 0xbc;
|
||||
constexpr int kRows = 8;
|
||||
constexpr int kAttributeRows = 6;
|
||||
constexpr int kAttributeRows = game::kAttributeCount;
|
||||
|
||||
// cUI_Button (vtable 0x895308).
|
||||
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).
|
||||
constexpr int kMinusX = 184;
|
||||
|
||||
// cCreatureArts fields (see the top).
|
||||
constexpr size_t kAttributes = 0x1c;
|
||||
constexpr int kFreeAttributesIndex = 7;
|
||||
constexpr size_t kSkillIds = 0x24;
|
||||
constexpr size_t kSkillLevels = 0x2c;
|
||||
constexpr size_t kSkillBonus = 0x34;
|
||||
constexpr size_t kSkillChoices = 0x44;
|
||||
// cCreatureArts fields (see the top, game/engine/skills.h).
|
||||
using game::kFreeAttributesIndex;
|
||||
constexpr size_t kAttributes = game::kArtsInvested;
|
||||
constexpr size_t kSkillIds = game::kArtsSkillIds;
|
||||
constexpr size_t kSkillLevels = game::kArtsSkillLevels;
|
||||
constexpr size_t kSkillBonus = game::kArtsSkillBonus;
|
||||
constexpr size_t kSkillChoices = game::kArtsSkillChoices;
|
||||
|
||||
using AddAttributeFn = void(__fastcall*)(void* arts, void*, int index, int delta);
|
||||
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));
|
||||
}
|
||||
|
||||
// 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 −.
|
||||
void CoverArm(void* device, uint8_t* minus) {
|
||||
const game::UiRect rect = game::ControlScreenRect(minus);
|
||||
const float sx = rect.w / 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) {
|
||||
const float x0 = rect.x + patch.x0 * sx;
|
||||
const float x1 = rect.x + patch.x1 * sx;
|
||||
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);
|
||||
game::FillRect(device, rect.x + patch.x0 * sx, rect.y + patch.y0 * sy, rect.x + patch.x1 * sx,
|
||||
rect.y + patch.y1 * sy, kDiscGrey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
#include "game/engine/items.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "game/ui/tabs.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
|
||||
}
|
||||
|
||||
// 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).
|
||||
void ChooseRemembered(uint8_t* window) {
|
||||
const int hero = HeroIndex();
|
||||
@@ -373,8 +368,8 @@ void UpdateChoice(uint8_t* window) {
|
||||
return;
|
||||
}
|
||||
updating = true;
|
||||
SetShown(window + kNextArrow, true);
|
||||
SetShown(window + kPreviousArrow, true);
|
||||
game::SetControlShown(window + kNextArrow, true);
|
||||
game::SetControlShown(window + kPreviousArrow, true);
|
||||
if (Field<uint32_t>(window, kChosenRune) == 0) {
|
||||
Select(window, kSelectKeep);
|
||||
ChooseRemembered(window);
|
||||
|
||||
@@ -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 temp = path + ".tmp";
|
||||
{
|
||||
|
||||
+11
-29
@@ -34,6 +34,7 @@
|
||||
#include "mods/skillslots.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::teleportpotion {
|
||||
namespace {
|
||||
@@ -109,36 +110,18 @@ void OnTick(uint8_t*) {
|
||||
}
|
||||
}
|
||||
|
||||
// The yellow potions' names become kName in every text (names, the bottom
|
||||
// bar's tooltip): the shortest name, so stage words in front ("Großer ...")
|
||||
// stay, and any name that does not contain it.
|
||||
void OnGameStart() {
|
||||
static bool renamed = false;
|
||||
if (renamed) {
|
||||
return;
|
||||
// Every kind 5 potion is a Teleportationstrank in every text, from the start:
|
||||
// the yellow ones ("Trank der Konzentration", also the bottom bar's tooltip)
|
||||
// and the stamina ones, kind 5 as well ("Schwacher / Starker / Kompletter
|
||||
// Ausdauertrank", measured 2026-10-19); stage words in front stay.
|
||||
constexpr const wchar_t* kOriginalNames[] = {L"Trank der Konzentration", L"Ausdauertrank"};
|
||||
|
||||
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() {
|
||||
g_pending = 0;
|
||||
@@ -147,7 +130,6 @@ void OnGameEnd() {
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
game::OnGameStart(&OnGameStart);
|
||||
game::OnGameEnd(&OnGameEnd);
|
||||
game::OnEngineTick(&OnTick);
|
||||
}
|
||||
|
||||
+228
-53
@@ -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
|
||||
// (cEvent_creature 4 to a ground point) every engine tick, aimed a cursor's
|
||||
// distance from it in the pressed screen direction. That is what the mouse
|
||||
// does while its button is held: every mouse event re-sends a walk to the point
|
||||
// under the cursor (cEngine::HandleMouseButton), which keeps the creature
|
||||
// walking smoothly. (The "held" re-aim orders cEngine::Tick sends as well stop
|
||||
// it when sent on their own.) Releasing the keys sends the target-less walk
|
||||
// order a released mouse button sends, which stops it in place.
|
||||
// The game walks a creature along a path of waypoints in cCreature::Update
|
||||
// (0x594110, about 40 times per second; path layout in game/engine/orders.h).
|
||||
// While W, A, S or D is held, each update of the controlled creature first
|
||||
// replaces its path with one straight segment from where it stands, far into
|
||||
// the pressed screen direction: the game's own step then moves it at its own
|
||||
// speed, with its walk animation, and its step test refuses blocked cells. No
|
||||
// pathfinding, no arrival, and a new direction counts from the next update.
|
||||
//
|
||||
// W, A and S are game hotkeys; the key bindings (src/mods/input.cpp) keep
|
||||
// W/A/S/D away from the game and move those actions to other keys. Typing into
|
||||
// a text field (chat, console, save names) is left alone. See docs/wasd.md.
|
||||
|
||||
#include "mods/wasd.h"
|
||||
// Obstacles: the cell one step ahead is probed with cCreature::IsCellWalkable
|
||||
// (measured: walking straight into a fence, 27 of 28 steps were refused).
|
||||
// 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
|
||||
// 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 <cstdint>
|
||||
|
||||
@@ -33,12 +49,31 @@ using game::ControlledCreature;
|
||||
using game::CreatureOrder;
|
||||
using game::kEngineFlags;
|
||||
|
||||
// Walk orders aim this far (world units) from the creature, about where the
|
||||
// cursor would be.
|
||||
constexpr double kTargetDistance = 300.0;
|
||||
// The walk order that starts walking aims this far (world units).
|
||||
constexpr double kStartDistance = 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) {
|
||||
return (GetAsyncKeyState(key) & 0x8000) != 0;
|
||||
return key != 0 && (GetAsyncKeyState(key) & 0x8000) != 0;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
struct MoveState {
|
||||
bool moving = false;
|
||||
std::atomic<uint8_t*> g_engine{nullptr};
|
||||
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
|
||||
|
||||
bool IsMovementKey(int vk) {
|
||||
return (vk == 'W' || vk == 'A' || vk == 'S' || vk == 'D');
|
||||
// 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
|
||||
// belong to the mouse, blocked input or a text field).
|
||||
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.
|
||||
void Update(uint8_t* engine) {
|
||||
// World axes on screen: (-1,-1) up, (1,1) down, (-1,1) left, (1,-1) right.
|
||||
int dx = 0;
|
||||
int dy = 0;
|
||||
const uint32_t flags = *reinterpret_cast<uint32_t*>(engine + kEngineFlags);
|
||||
const bool mouseMoving = (flags & 0x10) != 0;
|
||||
if (!mouseMoving && (flags & 0x4000) == 0 && !mods::input::TypingIntoTextField()) {
|
||||
if (KeyDown('W')) { dx -= 1; dy -= 1; }
|
||||
if (KeyDown('S')) { dx += 1; dy += 1; }
|
||||
if (KeyDown('A')) { dx -= 1; dy += 1; }
|
||||
if (KeyDown('D')) { dx += 1; dy -= 1; }
|
||||
Direction Rotated(Direction dir, double degrees) {
|
||||
const double a = degrees * 3.14159265358979 / 180.0;
|
||||
return {dir.x * std::cos(a) - dir.y * std::sin(a), dir.x * std::sin(a) + dir.y * std::cos(a)};
|
||||
}
|
||||
|
||||
// Whether the creature can go a probe distance along `dir`: every cell the
|
||||
// way crosses (sampled at quarter cells, so none is skipped) is walkable.
|
||||
bool Open(uint8_t* creature, Direction dir) {
|
||||
const int x = game::CreatureField(creature, game::kCreatureX);
|
||||
const int y = game::CreatureField(creature, game::kCreatureY);
|
||||
game::MapPosition last = game::CellAt(creature, x, y);
|
||||
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) {
|
||||
if (g_move.moving && !mouseMoving) {
|
||||
if (void* creature = ControlledCreature(engine)) {
|
||||
SendStop(engine, creature);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (!creature) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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));
|
||||
const int x = game::CreatureField(creature, game::kCreatureX) + static_cast<int>(dx / length * kTargetDistance);
|
||||
const int y = game::CreatureField(creature, game::kCreatureY) + static_cast<int>(dy / length * kTargetDistance);
|
||||
SendWalk(engine, creature, x, y);
|
||||
g_move.moving = true;
|
||||
if (game::CurrentAction(creature) != game::kActionWalk) {
|
||||
return false; // held by a status effect: Think decides later
|
||||
}
|
||||
if (game::CreatureField(creature, game::kCreatureAction) >> 16 == 0) {
|
||||
game::WalkAction(creature);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void OnTick(uint8_t* engine) {
|
||||
g_engine = engine;
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct Registration {
|
||||
Registration() { game::OnEngineTick(&Update); }
|
||||
Registration() { game::OnEngineTick(&OnTick); }
|
||||
} g_registration;
|
||||
|
||||
} // 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 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
|
||||
@@ -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
|
||||
@@ -8,7 +8,7 @@
|
||||
// 1024x768 layout, and set the variables from the render size.
|
||||
// See docs/widescreen.md.
|
||||
//
|
||||
// sacred_re.ini:
|
||||
// sacredK.ini:
|
||||
// [widescreen]
|
||||
// Enabled=1
|
||||
// Height=768 ; world pixels visible vertically (768 = original look);
|
||||
|
||||
@@ -47,4 +47,13 @@ std::string ConfigString(const char* section, const char* key, const char* fallb
|
||||
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
|
||||
+4
-2
@@ -1,5 +1,5 @@
|
||||
#pragma once
|
||||
// sacred_re.ini next to sacred_re.dll / sacred_re.exe.
|
||||
// sacredK.ini next to sacredK.dll / sacredK.exe.
|
||||
|
||||
#include <string>
|
||||
|
||||
@@ -7,9 +7,11 @@ namespace re {
|
||||
|
||||
void SetConfigPath(const std::wstring& path);
|
||||
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();
|
||||
int ConfigInt(const char* section, const char* key, int 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
|
||||
+55
-4
@@ -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)
|
||||
// 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
|
||||
// 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"
|
||||
|
||||
@@ -47,7 +52,7 @@ bool IsCode(uintptr_t address) {
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -95,7 +100,7 @@ void WriteReport(EXCEPTION_POINTERS* info) {
|
||||
}
|
||||
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);
|
||||
if (dump != INVALID_HANDLE_VALUE) {
|
||||
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) {
|
||||
if (InterlockedExchange(&g_reported, 1) == 0) {
|
||||
WriteReport(info);
|
||||
@@ -117,6 +161,13 @@ LONG WINAPI Filter(EXCEPTION_POINTERS* info) {
|
||||
void InstallCrashHandler(const std::wstring& dir) {
|
||||
g_dir = dir;
|
||||
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
|
||||
|
||||
+5
-1
@@ -4,7 +4,11 @@
|
||||
|
||||
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);
|
||||
|
||||
// The engine ticked (running), or the game ended (not running).
|
||||
void EngineHeartbeat(bool running);
|
||||
|
||||
} // namespace re
|
||||
+6
-6
@@ -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
|
||||
// instruction of game code.
|
||||
|
||||
@@ -40,17 +40,17 @@ bool IsSupportedGameBuild() {
|
||||
|
||||
void Startup(HMODULE module) {
|
||||
std::wstring dir = ModuleDir(module);
|
||||
re::OpenLog((dir + L"sacred_re.log").c_str());
|
||||
re::SetConfigPath(dir + L"sacred_re.ini");
|
||||
re::Log("sacred_re loaded");
|
||||
re::OpenLog((dir + L"sacredK.log").c_str());
|
||||
re::SetConfigPath(dir + L"sacredK.ini");
|
||||
re::Log("sacredK loaded");
|
||||
re::InstallCrashHandler(dir);
|
||||
|
||||
if (!IsSupportedGameBuild()) {
|
||||
re::Log("unsupported sacred.exe build, no hooks installed");
|
||||
MessageBoxW(nullptr,
|
||||
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"sacred_re", MB_OK | MB_ICONWARNING);
|
||||
L"sacredK will not patch it; the game runs unmodified.",
|
||||
L"sacredK", MB_OK | MB_ICONWARNING);
|
||||
return;
|
||||
}
|
||||
if (re::ConfigInt("debug", "WaitForDebugger", 0)) {
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@
|
||||
//
|
||||
// The signature, including the calling convention, must match the original
|
||||
// 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]
|
||||
// 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).
|
||||
// `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) \
|
||||
static decltype(&replacement) replacement##_original = &api; \
|
||||
static ::re::HookRegistration replacement##_registration( \
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#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 {
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
LIBRARY sacred_re
|
||||
LIBRARY sacredK
|
||||
EXPORTS
|
||||
; Detours requires ordinal 1 on any DLL injected via DetourCreateProcessWithDll.
|
||||
DetourFinishHelperProcess @1 NONAME
|
||||
+1
-1
@@ -30,7 +30,7 @@ $env:GHIDRA_HEADLESS_MAXMEM = "8G"
|
||||
`autotest-load.ps1` starts the game through the launcher, clicks
|
||||
"Spiel laden" -> "Laden" -> "Ok" (first save game), takes screenshots in-game
|
||||
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
|
||||
.\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# the list, 1 = the first, selected by default), take
|
||||
# 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"
|
||||
#
|
||||
# -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
|
||||
# monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs.
|
||||
param(
|
||||
@@ -21,7 +21,7 @@ $ErrorActionPreference = "Stop"
|
||||
. (Join-Path $PSScriptRoot "game-input.ps1")
|
||||
|
||||
$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 { "" }
|
||||
if ($Ini) { Set-Content $iniPath ($iniBackup + "`r`n" + $Ini + "`r`n") -Encoding ASCII }
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ function Focus-Sacred($Game) {
|
||||
[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.
|
||||
function Wait-Log([string]$Pattern, [int]$Count = 1, [int]$TimeoutSeconds = 60, [int]$AfterMs = 500, $Game = $null) {
|
||||
$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 }
|
||||
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
|
||||
@@ -54,7 +54,7 @@ function Wait-Log([string]$Pattern, [int]$Count = 1, [int]$TimeoutSeconds = 60,
|
||||
function Start-Sacred([string]$Arguments = "log") {
|
||||
if (Get-Process sacred -ErrorAction SilentlyContinue) { throw "sacred.exe is already running" }
|
||||
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
|
||||
for ($i = 0; $i -lt 80 -and -not $game; $i++) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//
|
||||
// Ghidra's own RecoverClassesFromRTTIScript finds nothing in this binary.
|
||||
// Run once, headless, without -readOnly (see tools/README.md).
|
||||
// @category sacred_re
|
||||
// @category sacredK
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//
|
||||
// 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.
|
||||
// @category sacred_re
|
||||
// @category sacredK
|
||||
|
||||
import java.io.File;
|
||||
import java.io.PrintWriter;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// into a class namespace; "#" starts a comment. Functions are created where
|
||||
// none starts at the address yet. Names starting with "g_" label data
|
||||
// (globals, tables) instead of functions.
|
||||
// @category sacred_re
|
||||
// @category sacredK
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
|
||||
@@ -258,3 +258,65 @@
|
||||
0x005819a0 cCreatureArts::FindCombo # (id) -> index, 4 if none
|
||||
0x00581410 cCreatureArts::ComboTooltip # (index, text, mode)
|
||||
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
|
||||
Generated
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "sacred-re-tools",
|
||||
"name": "sacredk-tools",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sacred-re-tools",
|
||||
"name": "sacredk-tools",
|
||||
"dependencies": {
|
||||
"iced-x86": "1.21.0"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "sacred-re-tools",
|
||||
"name": "sacredk-tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Reference in new issue
Block a user