Compare commits
46
Commits
432cac2ccf
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e13b82f040 | ||
|
|
7ec0fa2b8e | ||
|
|
471b8b13d5 | ||
|
|
198bbab6f9 | ||
|
|
55e48dd302 | ||
|
|
e539cab604 | ||
|
|
42b99526a4 | ||
|
|
91e7f844cc | ||
|
|
a4d5683403 | ||
|
|
32f953fc01 | ||
|
|
7d8089c299 | ||
|
|
2cab6c216d | ||
|
|
10c97f75e1 | ||
|
|
401e20b1f2 | ||
|
|
c0eedbce7f | ||
|
|
9bc557cd95 | ||
|
|
c7d54a6cfd | ||
|
|
1d16fc65c4 | ||
|
|
34cacefcfc | ||
|
|
b452558122 | ||
|
|
3f03228c29 | ||
|
|
91bbc52cf0 | ||
|
|
3978060954 | ||
|
|
0ef3cf4bbd | ||
|
|
c4aacbcdd2 | ||
|
|
db87e276bf | ||
|
|
34a119433a | ||
|
|
732d9c2abb | ||
|
|
feebe72ab7 | ||
|
|
e0d8d0ce7e | ||
|
|
618288e310 | ||
|
|
5bccb0734e | ||
|
|
5df7efc198 | ||
|
|
6c467e22c4 | ||
|
|
198c21dbfa | ||
|
|
ce3d46a568 | ||
|
|
70673dc301 | ||
|
|
462ebf8c0b | ||
|
|
838b1aa491 | ||
|
|
72525cd5ba | ||
|
|
c875d7d6b9 | ||
|
|
5d80a33e9b | ||
|
|
4018e23e6b | ||
|
|
84c50d8c33 | ||
|
|
d296e2850b | ||
|
|
184c7058fd |
No files matched your search
+2
-1
@@ -3,6 +3,7 @@
|
||||
/.idea/
|
||||
/cmake-build-*/
|
||||
*.rea-snapshot
|
||||
sacred_re.ini
|
||||
sacredK.ini
|
||||
/ghidra/
|
||||
/screenshots/
|
||||
*.stackdump
|
||||
Vendored
+7
-5
@@ -3,18 +3,20 @@
|
||||
"configurations": [
|
||||
{
|
||||
// Builds, then starts the game through the launcher (no debugger in the game).
|
||||
"name": "Run Sacred (sacred_re)",
|
||||
// The game folder comes from build/x86-debug/sacredK.ini: run the
|
||||
// launcher once with --game-dir "<Sacred Gold folder>" (README.md).
|
||||
"name": "Run Sacred (sacredK)",
|
||||
"type": "cppvsdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/build/x86-debug/sacred_re.exe",
|
||||
"args": ["--game-dir", "D:\\Gaming\\Sacred Gold", "log"],
|
||||
"program": "${workspaceFolder}/build/x86-debug/sacredK.exe",
|
||||
"args": ["log"],
|
||||
"cwd": "${workspaceFolder}/build/x86-debug",
|
||||
"preLaunchTask": "CMake: build",
|
||||
"preLaunchTask": "Build x86-debug",
|
||||
"console": "internalConsole"
|
||||
},
|
||||
{
|
||||
// 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",
|
||||
|
||||
Vendored
+7
-4
@@ -2,10 +2,13 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "CMake: build",
|
||||
"type": "cmake",
|
||||
"command": "build",
|
||||
"targets": ["all"],
|
||||
// build.ps1 enters the MSVC x86 environment and builds the x86-debug
|
||||
// preset; unlike a CMake Tools task it needs no preset picked in VS Code.
|
||||
"label": "Build x86-debug",
|
||||
"type": "process",
|
||||
"command": "powershell",
|
||||
"args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "${workspaceFolder}/build.ps1", "x86-debug"],
|
||||
"options": { "cwd": "${workspaceFolder}" },
|
||||
"group": { "kind": "build", "isDefault": true },
|
||||
"problemMatcher": ["$msCompile"]
|
||||
}
|
||||
|
||||
@@ -6,17 +6,19 @@ 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,
|
||||
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
|
||||
`SacredReborn*` files in that folder.
|
||||
- Target binary: the original `sacred.exe` of a local Sacred Gold 2.28
|
||||
installation (see `README.md`, Supported build). The game is not in the
|
||||
repo; its folder is given to the launcher once (`--game-dir`) and kept in
|
||||
`build\<preset>\sacredK.ini`, `[paths] GameDir`. Read it from there; never
|
||||
hard-code a local path in code, docs or configs.
|
||||
- Game texts are German (Fertigkeiten, Attribute, Blitzschlag, ...); keep the
|
||||
game's names when talking about features.
|
||||
- The user's monitor is 3440x1440; the game renders about 1835x768 scaled up.
|
||||
@@ -65,12 +67,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
|
||||
@@ -141,6 +143,6 @@ into a breakpoint exception (0x80000003, "ESP was not properly saved").
|
||||
## Memory
|
||||
|
||||
Project memory lives in
|
||||
`C:\Users\torha\.claude\projects\D--Projekte-sacred-decompile\memory\`
|
||||
`C:\Users\torha\.claude\projects\D--Projekte-sacred-k\memory\`
|
||||
(`project-status.md`: what is done and confirmed, what is open). Update it
|
||||
after commits and before long pauses.
|
||||
+38
-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,8 @@ 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/monitor.cpp
|
||||
src/mods/borderless.cpp
|
||||
src/mods/widescreen.cpp
|
||||
src/mods/hud.cpp
|
||||
@@ -58,6 +60,8 @@ add_library(sacred_re SHARED
|
||||
src/mods/combos.cpp
|
||||
src/mods/aurahits.cpp
|
||||
src/mods/chainlightning.cpp
|
||||
src/mods/dwarfarts.cpp
|
||||
src/mods/arttooltip.cpp
|
||||
src/mods/escape.cpp
|
||||
src/mods/recordcache.cpp
|
||||
src/mods/megamap.cpp
|
||||
@@ -65,14 +69,32 @@ add_library(sacred_re SHARED
|
||||
src/mods/runetrade.cpp
|
||||
src/mods/teleportpotion.cpp
|
||||
src/mods/guards.cpp
|
||||
src/mods/options.cpp
|
||||
src/mods/lootfilter.cpp
|
||||
src/mods/userfolders.cpp
|
||||
src/mods/charfile.cpp
|
||||
src/mods/screen.cpp
|
||||
src/mods/characters.cpp
|
||||
src/mods/mainmenu.cpp
|
||||
src/mods/mapborder.cpp
|
||||
src/mods/mapreveal.cpp
|
||||
src/mods/questbook.cpp
|
||||
src/mods/followers.cpp
|
||||
src/mods/questfail.cpp
|
||||
src/mods/questgiver.cpp
|
||||
src/mods/questitems.cpp
|
||||
src/mods/questnpc.cpp
|
||||
src/mods/questscript.cpp
|
||||
src/mods/anticheat.cpp
|
||||
src/mods/loadingscreen.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
|
||||
@@ -41,15 +41,28 @@ the CMake presets.
|
||||
|
||||
## Running
|
||||
|
||||
The game is not part of this repository. You need a local installation of
|
||||
Sacred Gold 2.28 with the `sacred.exe` listed under [Supported build](#supported-build)
|
||||
(e.g. the Steam or GOG release), and you tell the launcher its folder once:
|
||||
|
||||
```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 "<Sacred Gold folder>" # 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
|
||||
The folder is stored as `[paths] GameDir` in `sacredK.ini` next to the
|
||||
launcher (one per build preset, not tracked by git). Without it, the launcher
|
||||
looks for `sacred.exe` in its own folder and otherwise asks for `--game-dir`.
|
||||
The VS Code launch configuration relies on this stored path too.
|
||||
|
||||
Save games and the game's `Settings.cfg` live in `Documents\Sacred\save` and
|
||||
`Documents\Sacred\config`; on the first start they are copied there from the
|
||||
game folder (see `docs/user-folders.md`).
|
||||
|
||||
`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 +74,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,76 @@
|
||||
# Anti-cheat removed
|
||||
|
||||
## Request (2026-10-25)
|
||||
|
||||
The Gladiator (Character03, level 106) "randomly exploded" fighting an
|
||||
underleveled dragon on Silber. The user: the cheater detection is not
|
||||
necessary, remove it. After a second explosion: "i want all that weird
|
||||
cheater detection code gone".
|
||||
|
||||
## Measured
|
||||
|
||||
A temporary damage log showed the hero at full health (17200) going to 0 in
|
||||
one tick, with the game logging `cheat statBonus 0/3`, `cheat SkillLevel 0..7`,
|
||||
`Hase!` and `cObjectManager::destroy: tried to delete local hero #1!`; the hero
|
||||
came back with 1199 health and died to the next hit.
|
||||
|
||||
- The game mirrors the local hero's fields (pointers `0x182cddc`, on a
|
||||
protected page, and `0x182cde0`, a separate heap block: `HeapAlloc` 0x1000
|
||||
at `0x816541`): +0 hero +0x3b4, +4 hero +0x3ee (gold), +8 hero +0x3fe (u16),
|
||||
xor-ed with the hero's key byte (+0x38) repeated four times (the second
|
||||
copy: key << 1); +0xc a raw copy of hero +0x3b0..+0x49d (attributes, skill
|
||||
levels). Writer: `0x603d9c` and about 30 inline copies after legitimate
|
||||
changes, all starting with `VirtualProtect(page, 0x1000, PAGE_READWRITE)`
|
||||
and ending with `0x201`.
|
||||
- Checks: `cCreature::Think` about once in 101 ticks (`rand() % 101 == 10`,
|
||||
`0x5389b5`), `VerifyHeroMirror` `0x5905f0` (callers `0x57fc71`,
|
||||
`0x580001`, `0x580331`, `0x61ce22`), inline copies at `0x4659f6`,
|
||||
`0x564c6f`, `0x57e09c`, `0x585bd0`, `0x619d33`, and a trap in Think
|
||||
(`0x5385a6`: a hero whose level exceeds its experience's by more than 3
|
||||
gets +0x3b4 / +0x3ee / +0x3fe raised behind the mirrors' back). A mismatch
|
||||
resets the field (attributes 1, skill levels 0); any mismatch, or no hero at
|
||||
all, kills the hero ("*** Cheater detected! ***", health 0, action dying)
|
||||
and sets the cheater flag `0x182cdd0`, saved in the hero file (record +0xf3,
|
||||
`0x604409`) and read back by loadHero (`0x6066eb`) and the character
|
||||
selection (`0x70da99`, from the record's mark +0x3ef).
|
||||
- Stream checks in the quest manager's hero data (`0x465980`): a marker
|
||||
`0xfea4fea4` (`0x467782`) and three stored values compared with the hero
|
||||
(`0x46a60b`, `0x46a64e`, `0x46a696`).
|
||||
- In the failing log every skill level mismatched: the mirror was stale
|
||||
(changes outside the game's mirror writers, e.g. the shared hero loaded into
|
||||
another world).
|
||||
|
||||
## History
|
||||
|
||||
- First mod (confirmed): a `VirtualProtect` hook rewrote the mirror from the
|
||||
hero whenever a check opened its page, so checks compared the hero with
|
||||
itself.
|
||||
- The hero exploded again (`Hase!` twice, no `cheat statBonus` / `SkillLevel`
|
||||
line): the hook refreshed the second mirror only if it lay on the protected
|
||||
page, so never; the checks compare its three xor-ed fields after the first
|
||||
mirror (e.g. `0x585f83`) without a log line. Refreshing both fixed it (user:
|
||||
"i no longer explode").
|
||||
- Then the checks were removed entirely (below), the hook is gone.
|
||||
|
||||
## Mod (`src/mods/anticheat.cpp`): checks removed
|
||||
|
||||
Every check is skipped by a code patch (`re::ReplaceAll`): no field is reset,
|
||||
no hero killed, the cheater flag never set.
|
||||
|
||||
| Check | Patch |
|
||||
|---|---|
|
||||
| Think trap `0x5385a6` | `0x5385ca` `jle` -> `jmp` |
|
||||
| Think, 1 tick in 101 (`0x5389be`) | `0x5389b8` `jne` -> `jmp` |
|
||||
| `0x564c6f`, `0x57e09c`, `0x585bd0` | the "not the local hero" `jne` before each (`0x564c50`, `0x57e07d`, `0x585bb0`) -> `jmp`: the game's own path past the check |
|
||||
| quest manager hero data `0x4659f6` | `0x4659dc` `jmp 0x46606b` (the code there writes esi / edi before reading them; the stream keeps its clean marker 0xfeedf00d) |
|
||||
| `0x619d33` | `mov ebp, [esp+0x34]; jmp 0x61a2de`, the registers as the check leaves them (edi already 0x88e874) |
|
||||
| `VerifyHeroMirror` `0x5905f0` (callers test al) | `mov al, 1; ret` |
|
||||
| stream checks `0x467782`, `0x46a60b`, `0x46a64e`, `0x46a696` | jumps past the punishment unconditional |
|
||||
| loadHero `0x6066eb` | writes 0 to the cheater flag |
|
||||
| character selection `0x70da8c` | `setne cl` -> `xor cl, cl` |
|
||||
|
||||
All field resets (`mov [reg+0x3b4], 1` ...) lie inside these checks; all
|
||||
writes of the flag `0x182cdd0` (`0x465ebe`, `0x538e53`, `0x56508a`,
|
||||
`0x57e528`, `0x57fcfd`, `0x58008d`, `0x5803bd`, `0x586090`, `0x61a17e`,
|
||||
`0x61ceac`) are their punishments. The mirror writers stay: they only fill
|
||||
the now unread mirrors.
|
||||
@@ -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,318 @@
|
||||
# Characters and difficulties (one file per character)
|
||||
|
||||
## Request (2026-10-19)
|
||||
|
||||
Get rid of exporting characters. One character = one save file; inside it one
|
||||
world state per difficulty. The hero (gear, level, attributes, skills, chest,
|
||||
everything) is shared between the difficulties; only the unlocked portals are
|
||||
per difficulty (they have to be found again on each level).
|
||||
|
||||
- Unlocking: Bronze and Silver are open from the start; each following
|
||||
difficulty opens by completing the story on the one before it. More
|
||||
difficulties shall be addable later.
|
||||
- The export / import screen goes away. The load window lists characters;
|
||||
loading asks for the difficulty.
|
||||
- "Spiel speichern" saves the current character into its current difficulty.
|
||||
No "switch difficulty" button: save, end the game, load on another
|
||||
difficulty. No save on quit.
|
||||
- A silent autosave every few minutes into a separate slot, so the player
|
||||
still decides when the real save is updated.
|
||||
- Existing saves are migrated.
|
||||
- Delete a character (everything of it); reset a single difficulty.
|
||||
- Multiplayer: later (see `todo.md`).
|
||||
|
||||
Decided (2026-10-19, follow-up):
|
||||
- Unlocking by story completion only; Bronze and Silver always open (maybe
|
||||
only Bronze after a later rebalance). No level ranges: enemies always scale
|
||||
with the player and the difficulty.
|
||||
- Autosave every 10 minutes for now (a game setting later); the load window
|
||||
offers it per character when it is newer than the real save.
|
||||
- The game's own autosaves on story steps also go into the autosave slot,
|
||||
silently.
|
||||
- Migration as proposed (group by hero name + class), a backup of all saves
|
||||
first. Done by hand once:
|
||||
`Documents\Sacred\backup\2026-10-19_before_characters` (Documents save
|
||||
folder and game save folder); the migration code makes its own backup too.
|
||||
|
||||
## Measured / read so far
|
||||
|
||||
### Files
|
||||
|
||||
- Save games `GAMEnn.PAK` ("AMS", version 0x1b): a full world dump; the hero
|
||||
is one object in it (`* Objects`, ref 1, family 3) and its items are in
|
||||
`* Inventory`. Chunks are listed by the loader's log (`* Engine`, `* world`,
|
||||
`* calendar`, `* Blood`, `* RegionInfo`, `* Team/AllianceInfo`,
|
||||
`* Objects`, `* Inventory`, `* Particles`, `* Views`, `* Weather`,
|
||||
`* Scripts`).
|
||||
- Hero files `Heronn.pax` ("AMH"): written by `ItemDataMgr::saveHero`
|
||||
`0x6046f0`; header 0x22c bytes + chunks (items, inventories, chest, stats
|
||||
block). The hero's **portals** and world position are part of it (log
|
||||
`saveHero ... wpos[..] portals[%x]`; portal bits on the hero at +0x578).
|
||||
|
||||
### Functions
|
||||
|
||||
| Address | What |
|
||||
|---|---|
|
||||
| 0x60ac70 | `cEngine::cEngine`: new game or save-game load |
|
||||
| 0x61b790 | `cEngine::Load(path, quiet)`: reads a `GAME.PAK` (version check 0x15..0x1b), chunk loop, logs `---LOADING SAVEGAME.PAK` |
|
||||
| 0x619c90 | `cEngine::Save(path)`: called from the render thread when engine +0x54 has 0x40/0x80; row = engine +0x68 |
|
||||
| 0x60dc00 | `cEngine::InitGame`: new game (campaign start, start equipment) |
|
||||
| 0x61cda0 | `cEngine::SaveHero(path)`: hero export (`saveHero` into a RAM file, then to disk) |
|
||||
| 0x7d7c40 | session singleton (0x3ad0 bytes, `g_session` 0x182cb68) |
|
||||
| 0x7efd90 | `Session::SelectHeroSlot(slot 1..8)`, ret 4: loads `Save\Hero%02d.pax` into the session's RAM file +0x3a3c, sets +0x49 = 1 (hero from file), +0x37b8 = slot, reads the hero header into +0x16a (0x22c bytes) |
|
||||
| 0x755ac0 | `cCommand_armaPlay::execute`: posts 0x8002 (mode 1 load row, 4 load multi, else init) to the main window |
|
||||
| 0x706b90 | difficulty list for a hero level (Bronze .. Niob) |
|
||||
|
||||
- With session +0x49 set, the engine constructor loads the hero from the
|
||||
session's file into the **loaded save game** (`loadHero` at 0x60ba65) and
|
||||
destroys the old hero's items afterwards. That is the vanilla mechanism for
|
||||
"this world, that hero" and the base of the shared hero.
|
||||
- In-game save / load: engine +0x54 flags 0xc0 (save row +0x68 via
|
||||
`cEngine::Save`), 0x200 (load row via `cEngine::Load`).
|
||||
|
||||
### Difficulty
|
||||
|
||||
- `g_gameLevel` 0x182cdc4: current difficulty, from the save header (log
|
||||
`GameLevel[n]`). Index mapping still to confirm (0 = Bronze ..).
|
||||
- Story completion: `cStatsManager` (`cStatsManager::Instance(player)`
|
||||
0x425160) +0x5704, one byte counter per difficulty (6 entries).
|
||||
`0x424bc0` (k) increments, `0x424be0` (k) reads, `0x424c00` (k, value) sets.
|
||||
The script opcode at 0x4aed26 increments counter[`g_gameLevel`]: the story
|
||||
end. The counters travel in the save header (dwords at +0x88.., loader
|
||||
0x61bb19..) and in the hero header chunk 0xc3 (bytes at +0xed.., written by
|
||||
0x604360, read by loadHero 0x606632..; +0xec is `g_gameLevel`). Checked in
|
||||
a character file (2026-10-20): Silber and Gold completed, played on Platin =
|
||||
[0, 1, 1, 0, 0] in the hero and the Platin world. The console cheats
|
||||
`bronze` / `silber` / ... (0x615d00..) increment them too.
|
||||
- Vanilla offers a difficulty by **hero level**, not by completion:
|
||||
thresholds `0xad2dc4..0xad2dd4` (5 dwords, parsed from the balance data key
|
||||
`StartLevMulti` at 0x5ef12c). Silver for levels [dc4, dcc], Gold
|
||||
[dc8, dd0], Platinum [dcc, dd4], Niob >= dd0, Bronze <= dc8. The same
|
||||
thresholds reduce experience when the hero is far above the difficulty
|
||||
(0x57dd60). Values: to be logged at runtime.
|
||||
|
||||
### Runtime log (2026-10-19, vanilla flows)
|
||||
|
||||
| Flow | What happened |
|
||||
|---|---|
|
||||
| load row 2 | `armaPlay load [2]` -> `cEngine(0x00000002)` -> `cEngine::Load("./SAVE/GAME02.PAK", 1)`; engine +0x68 = row |
|
||||
| in-game save | `cEngine::Save("SAVE/GAME02.PAK")`, engine +0x54 = 0x444 |
|
||||
| new game, exported hero, Silver | `cUI_Character::executeAction(IMPORT_DO) difficulty[1]`, `cEngine(0x80080000)`: bit 31 new game, bits 16..23 = hero slot + 1; `InitGame` creates a start hero of class session +0x44 (9) with start equipment, then `LoadHeroFile("Save\Hero07.pax", 1, 0)` (`0x61d190`, cdecl) loads the exported hero onto it |
|
||||
| new game, new hero, Bronze | `armaPlay init`, `cEngine(0x80000000)`, class +0x44 = 6, no hero file |
|
||||
|
||||
- `g_gameLevel`: 0 Bronze, 1 Silver (2 Gold, 3 Platinum, 4 Niob). The
|
||||
battlemage row is Silver.
|
||||
- Level thresholds at runtime: 20, 60, 100, 140, 180.
|
||||
- `Session::SelectHeroSlot` and session +0x49 are not used in single player
|
||||
(multiplayer). The hero swap block after it (0x60ba1c..) is not on the
|
||||
single-player load path.
|
||||
- `LoadHeroFile(path, 1, 0)` puts a hero file onto the existing player hero,
|
||||
start items and all: the same operation the shared hero needs after a
|
||||
world load.
|
||||
- Confirmed in-game (2026-10-19/20): `LoadHeroFile(hero, 1, 0)` called right
|
||||
after `cEngine::Load` returns puts the hero onto the loaded world's hero.
|
||||
An exported battlemage loaded onto another character's world came out
|
||||
exact: level, attributes, skills, gear, backpack, chest; nothing
|
||||
duplicated or left behind. It even replaced the class (dwarf hero onto a
|
||||
battlemage world). The result survives an in-game save and reload, with no
|
||||
cheat-check message.
|
||||
|
||||
## Follow-up requests (2026-10-20)
|
||||
|
||||
- Numbered files (`CharacterNN.sacredK`), one list row per character; the
|
||||
difficulty is chosen when loading, in a dialog (or a newer autosave, marked).
|
||||
- Rename a character from the load window.
|
||||
- Every difficulty below the highest played one is always available; one
|
||||
without a world starts a new game with the shared hero.
|
||||
- The save key and the Esc menu ("Spielstände" becomes "Speichern") save
|
||||
directly, without the save window.
|
||||
- Main menu: "Kampagne Ancaria", "Kampagne Underworld" and "Spielstände"
|
||||
become one "Spielen": the characters, loaded or started in either campaign
|
||||
on any difficulty; new characters from there too. A character file holds a
|
||||
world per campaign and difficulty; the format must take more difficulties
|
||||
later. Decided: no level requirement for the Underworld (vanilla: hero level
|
||||
25, 0x70c48b), story completions open the next difficulty in both campaigns,
|
||||
"Neuer Charakter" is a button in the Spielen window.
|
||||
|
||||
### Campaigns (measured / read 2026-10-20)
|
||||
|
||||
- `g_campaign` 0x182cdc8 (u16): 0 Ancaria, 1 Underworld. The main menu's
|
||||
event handler (0x711fa0) sets it by entry name before an entry handles an
|
||||
event (UI_LABEL_NEWGAME 0, UI_LABEL_NEWADDON 1); both entries show
|
||||
UI_CHARACTER. `cEngine::Save` writes it to the save header +0x24 (2
|
||||
Ancaria, 3 Underworld, 0x61a5c5), `cEngine::Load` sets it from there
|
||||
(0x61bafd). The load window lists only saves of the running campaign
|
||||
(0x71e2b9).
|
||||
- Story completions are counted per difficulty only (the same counter for
|
||||
both campaigns).
|
||||
- Main menu (cUI_MainMenu, entries built by 0x712f30): cUI_StaticText64FX
|
||||
entries (constructor 0x753d70), name +0x30, rect +0x24 (0x20 apart), text
|
||||
id +0x78, command +0x88; main page vector +0x158, children +0x90.
|
||||
|
||||
## Implementation (`src/mods/characters.cpp`, `charfile.{h,cpp}`)
|
||||
|
||||
### Files
|
||||
|
||||
- `Documents\Sacred\save\characters\CharacterNN.sacredK`: "SKCH", version 1,
|
||||
section count, then `{name[16], offset, size}` entries. Sections: `meta`
|
||||
(JSON: format, name UTF-8, class, level, lastCampaign, lastDifficulty,
|
||||
migratedFrom, heroFrom, heroFromCampaign), `hero` (AMH as
|
||||
`cEngine::SaveHero` writes it), one world per campaign and difficulty,
|
||||
`ancaria<d>` / `underworld<d>` with d = `g_gameLevel` (AMS as
|
||||
`cEngine::Save` writes it). Sections are named, not slotted: a higher
|
||||
difficulty (or another campaign) is one more section. Files of the first
|
||||
format named Ancaria's worlds `world<d>`; they are renamed when read
|
||||
(charfile.cpp) and written under the new name at the next save. Written to
|
||||
a temp file, then moved over.
|
||||
- `CharacterNN.autosave.sacredK`: same layout, one world (`meta.campaign`,
|
||||
`meta.difficulty`).
|
||||
- The game's save folder is redirected to `characters\_work` (userfolders);
|
||||
the game reads and writes `world.PAK` / `hero.pax` there.
|
||||
- Deleted characters move to `characters\deleted\`.
|
||||
|
||||
### Main menu (`src/mods/mainmenu.cpp`)
|
||||
|
||||
After the build (0x712f30) the NEWGAME and NEWADDON entries are removed (out
|
||||
of both vectors, deleted) and UI_LABEL_LOADGAME ("Spielstände", shows
|
||||
UI_WND_SAVEGAME) moves to the top with the text "Spielen"; the others move up,
|
||||
keeping the gap before "Beenden".
|
||||
|
||||
### Spielen screen (cUI_Savegame)
|
||||
|
||||
Request 2026-10-20: redesign the load window, 16:9, using the space (layout
|
||||
agreed: characters left, the character in the middle, worlds right, buttons
|
||||
below; our own background). Built on the shared screen kit
|
||||
(docs/screens.md); the window's own drawing and mouse handling are not used.
|
||||
|
||||
- The window still scans: before each scan (0x71e330) one preview
|
||||
`GAMEnn.PAK` per character is written into `_work`; row nn = character nn.
|
||||
Its entries are only needed for the load.
|
||||
- Left: the characters, newest save first (name, class, level; "im Spiel" for
|
||||
the one being played). Click selects, double click plays.
|
||||
- Middle: name, class and level, the world played last with its date, and
|
||||
the hero in 3D with its equipment (2026-10-20): the game's cUI_NetGranny
|
||||
(game/ui/granny.h), the control the export screen and the multiplayer
|
||||
lobby showed heroes with. The character's hero file goes to
|
||||
`_workpreview.pax`, LoadHeroFile (path, 0, info) reads its hero info
|
||||
(class, equipment looks) without touching the player hero. Drawn without
|
||||
its name label, with the white light set, size factor 3.8 (game 2.5); its
|
||||
x is moved by (1365 - 1024) / 2, as it centres the model as in a 1024
|
||||
layout. Migrated characters without the shared hero show none until
|
||||
played.
|
||||
- Right: a grid campaigns x difficulties. A cell shows the save's date,
|
||||
"+ Autosave" when the autosave is newer, "Story abgeschlossen", "Neu" for a
|
||||
difficulty that is open but has no world, "–" when closed. Open: Bronze and
|
||||
Silber, every difficulty up to the highest with a world in either campaign,
|
||||
and the one after each difficulty whose story the shared hero completed
|
||||
(hero header +0xed..); new worlds need the shared hero, not in multiplayer
|
||||
(byte 0x182cb6c). Click selects, double click plays. With a newer autosave
|
||||
"Laden von:" picks the save or the autosave (default: the autosave).
|
||||
"Welt zurücksetzen" for a saved world (not the character being played).
|
||||
- Buttons: Neuer Charakter (main menu only), Umbenennen, Löschen (not for the
|
||||
character being played), Zurück (action 0xc), Spielen.
|
||||
- Spielen: a saved world loads through the window's load action (4, with its
|
||||
"really load?" confirmation in a game): the window's selection is set to
|
||||
its first entry and `g_loadRow` names the row, which `Engine_Load` takes
|
||||
instead of the entry's. A new world starts a game (below; in a game after
|
||||
stopping the running one). The window's own load / default actions (Enter)
|
||||
run Spielen too; its delete actions (7, 8) are ignored.
|
||||
- Dialogs over the screen: rename (keys go to the field through the input
|
||||
key capture; the name is only in the hero header chunk 0xc3 (+8, UTF-16,
|
||||
0x40 chars): rewritten in the hero, every world and the autosave, plus
|
||||
`meta.name`), campaign of a new character, confirmations of reset and
|
||||
delete. Buttons are cUI_Button2 copies of the load button
|
||||
(`game::NewButtonLike`) with our own text ids and overrides.
|
||||
- Delete moves the character file and its autosave to `charactersdeleted`.
|
||||
- Neuer Charakter: sets `g_campaign`, hides the window and shows UI_CHARACTER
|
||||
(the game's character creation, as the main menu's campaign entries did).
|
||||
The first save makes the character file.
|
||||
- Reset (decided 2026-10-20) removes the world section (a copy goes to
|
||||
`charactersdeletedCharacterNN.sacredK.<section>.PAK`) and an autosave of
|
||||
that world, and clears the shared hero's story completion of that
|
||||
difficulty (hero header +0xed + d); the game counts it per difficulty, so
|
||||
the next difficulty closes in both campaigns unless a world keeps it open.
|
||||
|
||||
### Character window (cUI_Character)
|
||||
|
||||
- Vanilla made no new heroes for the Underworld: `cUI_Character::Show`
|
||||
(vf24 0x70e2a0) switches to mode 7 in single player with `g_campaign` 1
|
||||
(0x70e3e8), the import of an exported hero of level 25+ (0x70eedc); with no
|
||||
exported heroes its slots were empty, and slots below level 25 never lit
|
||||
up (render 0x70f4df / 0x70f57c) (reported 2026-10-20). All eight campaign
|
||||
checks of the window (0x70c473, 0x70d199, 0x70e3e8, 0x70eade, 0x70eeb2,
|
||||
0x70f4df, 0x70f57c, 0x70f6aa) read a constant Ancaria instead: creation
|
||||
with all classes in both campaigns; the start (ExecuteAction 0x70d0f0,
|
||||
action 1) uses `g_campaign` as set.
|
||||
- Class selection without "Importieren" (request 2026-10-20): button +0x3a0
|
||||
(caption UI_IMPORT in modes 0/1); its modes-0/1 draw (0x70f67e) is removed,
|
||||
its click (0x70eab4 / 0x70eac7, SetMode(7)) jumps to the handler's end.
|
||||
### Load
|
||||
|
||||
`cEngine::Load` (0x61b790) of a listed row: the chosen world and the hero are
|
||||
unpacked to `_work`, the original loads `world.PAK`, then `LoadHeroFile`
|
||||
(0x61d190) puts the shared hero on. The world's portals (hero +0x578) and
|
||||
position (+0x18..+0x28) are put back afterwards: the hero file holds the
|
||||
whole hero object and `loadHero` deserialises it over the hero, position
|
||||
included (measured: the hero stood where it was saved on another
|
||||
difficulty; the hero stays the same object). A migrated character without a
|
||||
shared hero takes it from the first loaded world (`heroFrom`).
|
||||
|
||||
### New world on a difficulty
|
||||
|
||||
As `cUI_Character::executeAction(IMPORT_DO)` (0x70d637): session (0x7d7c40)
|
||||
+0x14 = 1, +0x44 = class, GameSetup (0x417e50) +0 = 1, `g_gameLevel` = d,
|
||||
then cCommand_armaPlay (vtable 0x894ff4, execute 0x755ac0) mode 1, argument
|
||||
`0x80000000 | 1 << 16`. cEngine's constructor runs InitGame and then
|
||||
`LoadHeroFile("Save\Hero00.pax")` (0x60bf2c); our hook loads the character's
|
||||
hero instead, keeps the start position, clears the portals and makes the
|
||||
game the character's.
|
||||
|
||||
### Save
|
||||
|
||||
`cEngine::Save` (0x619c90) writes `world.PAK`, then `SaveHero` writes
|
||||
`hero.pax`; both go into the current character (a new one gets the next free
|
||||
number). Story autosaves (engine event 0xf subtype 2 with the flag at +0x18,
|
||||
noted in `cEngine::ReceiveEvent`) go into the autosave file.
|
||||
|
||||
Silent autosave: 10 minutes after the last save of any kind (or the game's
|
||||
start), `SaveOnTick` does the direct save marked as an autosave, so it goes
|
||||
into the autosave file. While a window is open or a save runs it waits for
|
||||
the next tick. A character never saved yet gets its character file from it.
|
||||
Interval: options window, *Allgemein*, *Autosave (Min.)* (docs/options.md); kept in
|
||||
`sacredK.ini` `[characters] AutosaveMinutes` (default 10, 0 off).
|
||||
|
||||
Direct save (K key, Esc menu "Speichern"): on the next engine tick, as the
|
||||
quicksave does (0x618ebb): `cUI_Manager::CanOpenWindow` (0x75a690),
|
||||
`cEngine::CaptureScreen` (0x613710, 1), description +0x6a, row +0x68 = 0,
|
||||
flag +0x54 |= 0x40. The Esc menu line keeps its action (Show
|
||||
`UI_WND_SAVEGAME`); `cUI_Manager::ReceiveEvent` (0x757120) turns that into the
|
||||
direct save while a game runs. Its text is patched at 0x6bc1f7 (id 0x41b is
|
||||
used by other windows too).
|
||||
|
||||
The Esc menu: "Exportieren" became "Laden" (requests 2026-10-20). The line
|
||||
(text id 0x419 pushed at 0x6bc272, now "Laden") keeps its command, which shows
|
||||
UI_CHARACTER (the export screen) and hides the menu; in a game
|
||||
`UiManager_ReceiveEvent` turns that into showing the load window. The load
|
||||
window always opens in load mode (`cUI_Savegame::Show` vf24 0x71cfb0 picks
|
||||
save mode in a game; `SetMode` 0x71c4f0 (2) after it). In a game every
|
||||
world can be loaded, the current one too (a reload from its last save; the
|
||||
game's in-game load, engine flag 0x200). A "(neu)" world needs a new engine:
|
||||
the running game stops as "Beenden" stops it (`cCommand_armaStop`, execute
|
||||
0x755b30, vtable 0x88ee2c: posts 0x8001 to the main window, whose handler
|
||||
deletes the engine), and when the engine is gone (`OnGameEnd`) the new game's
|
||||
start command follows (0x8002), without the main menu. The character being
|
||||
played cannot be deleted. The export screen (cUI_Character
|
||||
mode 5) is unreachable; its mod (charexport.cpp, an erase button for export slots) was deleted.
|
||||
|
||||
### Migration
|
||||
|
||||
On first start (no `characters\` folder): after a backup in
|
||||
`Documents\Sacred\backup\<stamp>_characters_migration`, `GAME*.PAK` and
|
||||
`Hero*.pax` are grouped by (name, class); the newest world per difficulty is
|
||||
kept, the newest file overall is the shared hero.
|
||||
|
||||
## Open
|
||||
|
||||
- Quickload (row 0) does not know characters (not needed, user 2026-10-20).
|
||||
- Spielen screen: scrolling the list beyond 9 characters.
|
||||
- Multiplayer (see `todo.md`).
|
||||
@@ -0,0 +1,143 @@
|
||||
# Dwarf arts: Kanonenschuss, Mörsergranate, Eingraben
|
||||
|
||||
## Requests (2026-10-25 / 2026-10-26)
|
||||
|
||||
- Kanonenschuss (art 0x43f, `gui_spells_kanone`): "remove the knockback,
|
||||
instead it can be just a stun" (chosen: no push, only a stun); the range
|
||||
increased for the wider view; the stun particles not on targets that
|
||||
avoided the stun. The game's stun resistance stays (2026-10-26: "we dont
|
||||
ignore anything, the skill is fine for now").
|
||||
- Mörsergranate (art 0x43e, `gui_spells_moerser`): "the radius of the
|
||||
explosion should increase every 5 skill levels".
|
||||
|
||||
## Measured
|
||||
|
||||
- The cannon's code is the combat move function `0x5145d0`, case 0x43f. Its
|
||||
ray: `cmp edi,19h` at `0x517389`, 25 steps of 40 = 1000 range. Hostile
|
||||
targets get `cEvent_creature` 5 from `0x51768d`.
|
||||
- Stun chance `0x4fc810(level, creature)` = level × 5 / creature level × 100
|
||||
(`UI_STAT_STUNNCHANCE`). On a successful roll: `0x5936e0(MOTION_HIT_A 0xaf,
|
||||
3.0, 0.25)` at `0x517709` (pushes from `0x5176f8`), then the stun order
|
||||
`cEvent_creature` 3 with +0x14 = 100, sent by `call [eax+18h]` at
|
||||
`0x5177aa` (returns to `0x5177ad`).
|
||||
- The stun *is* the knockback. `cCreature::ReceiveOrder` `0x52e400` case 3:
|
||||
first the resistance `vf24(0x50)` (`0x55d0a0`: flag +0x4f0 & 0x1000 resists
|
||||
2 in 3, effect 0x35d a percentage); otherwise push left +0x24d = 100, flag
|
||||
+0x200 |= 0x20000, attacker +0x259, ResetToIdle. `cCreature::Update` branch
|
||||
`0x594c82` moves a creature with that flag away from the attacker and lowers
|
||||
the push left by each step (`0x594deb`), measured about 100 units in 0.3 s;
|
||||
at 0 the flag is cleared.
|
||||
- The impact effect `TYPE_FX_DWARF_SHOOT_IMPACT` (0x47f) is created on every
|
||||
hit target by `CreateAt` at `0x517855` (target in ebx), resisted or not.
|
||||
- Mörsergranate: the grenade (type 0x3fa) lands and creates
|
||||
`TYPE_FX_GRENADEIMPACT` (0x3fc), which gathers its targets with
|
||||
`cObjectManager 0x5ff820` (called from `0x5ab400` at `0x5ab63d`): a creature
|
||||
is hit when its distance is below the impact's size (150, type table
|
||||
`0x428bb0`) plus its own type's size.
|
||||
|
||||
## The mod (`src/mods/dwarfarts.cpp`)
|
||||
|
||||
- The HIT_A motion is jumped over (`0x5176f8`: `jmp` past the call).
|
||||
- The push step at `0x594deb` calls `PushStepThunk`: creatures the cannon
|
||||
stunned (order 3 from `0x5177ad`, kept with an end time) move by 0 and keep
|
||||
their push left above 0 for 1.5 s, so they stand still, then carry on.
|
||||
- The impact effect is created only on targets in the stun state.
|
||||
- The ray gets 45 steps (1800); confirmed to reach across the widened view.
|
||||
- The impact's gather call gets a radius of size × (1 + 0.1 × (level / 5)),
|
||||
with the owner's Mörsergranate level (`game::ArtLevel`). The growth 0.1 per
|
||||
step is our choice; not yet seen in the game (Borf's level is 2).
|
||||
|
||||
## Mörsergranate impact marker (asked 2026-10-25, done 2026-10-26)
|
||||
|
||||
"Show exactly where the grenade will land and its explosion radius, a red
|
||||
circle with low alpha on the ground."
|
||||
|
||||
Measured:
|
||||
- The landing point is decided at launch (`0x5145d0` case 0x43e): the
|
||||
target's position, or, closer than 200, a point 200 from the dwarf with a
|
||||
+-15 jitter. It goes to the new grenade (type 0x3fa) in cEventFX 0x96,
|
||||
constructed at `0x5169fa` (`cEventFX::Construct` 0x41e0a0, thiscall, ret
|
||||
0x44: code, owner id, target id, position area / x / y / level, ...).
|
||||
- The impact gathers its targets at exactly that point, on several frames.
|
||||
The grenade object stays about 30 s after the explosion.
|
||||
- Ground -> screen: `0x622700` (world units -> world pixels, the isometric
|
||||
plane), then (pixel - camera +0x14/+0x18) / zoom +0xb60 + screen centre
|
||||
(floating texts 0x41e546). Checked with the hero: the screen centre.
|
||||
- The world view draws the ground (`0x62d530`, ground quad list), then the
|
||||
objects.
|
||||
|
||||
Mod: the construct call notes grenade, owner, landing point and radius
|
||||
(`ImpactRadius`, shared with the hit check); after `0x62d530` a disc (alpha
|
||||
0x40) with a 2 px rim (alpha 0xa0) is drawn there, without depth test, until
|
||||
the impact of that owner's grenade at that point gathers its targets.
|
||||
|
||||
Tooltip (2026-10-26, "the skills tooltip does not show the current radius"):
|
||||
a game::Art tooltip filter adds "Explosionsradius: N" after the last value
|
||||
line of each level section (current level, and the next level's in the skill
|
||||
book), in that line's colour, from the same `ImpactRadiusAt(level)` as the
|
||||
hit check and the marker.
|
||||
|
||||
## Grenade impact crash (2026-10-26)
|
||||
|
||||
Crashes at sacred.exe+0x361b54 (2026-10-26 00:12 and 22:39) in the particle
|
||||
drawer `0x7613e0`, called by `cParticleSystem_grenadeimpact::Render` (vf04
|
||||
0x7b66b0): colour = table +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and a
|
||||
particle had a life of over 500000. Guard (`mods/guards.cpp`): live particles
|
||||
with life >= 256 or row >= 3 are switched off before drawing. Logged in the
|
||||
next session: 5 particles, all row 3, life 255, garbage sizes (e.g. 190555,
|
||||
4.6e-40): the explosion emits into a fourth row whose emitter parameters it
|
||||
never sets (vf10 0x7b6980 fills three rows at +0x30d0). No crash since.
|
||||
|
||||
## Eingraben: the dwarf's escape (request 2026-10-28)
|
||||
|
||||
"I want it to be the Dwarfs option to Teleport/Escape. The Skill will cast a
|
||||
protective barrier around the Dwarf (current visual effect), clear him of all
|
||||
movement impending effects and then teleport to the position marked by the
|
||||
cursor (if possible). The Teleport is visually shown by the dwarf going
|
||||
underground and coming out again at target position." Range: the visible
|
||||
area; the barrier is the skill's current defensive bonus for 3 s; digging in
|
||||
and out 1 s each; "he should be able to see the target location", compared
|
||||
to the battlemage's Phasenverschiebung; the original lock in place goes.
|
||||
|
||||
Found in the code:
|
||||
- Spells run in `cCreature::CastSpell` (0x5222c0, thiscall (cast), ret 4;
|
||||
cast +0x08 / +0x0c the ground point, +0x20 the spell id). Eingraben is
|
||||
spell 0x39 (`gui_moves_eingraben`, launch type 5 around the caster).
|
||||
- Case 0x39 creates object type 0x480 at the caster with cEventFX 0x9d (owner
|
||||
and target the caster) and plays sound 0x840: the barrier, which follows
|
||||
the dwarf.
|
||||
- Case 1 (Phasenverschiebung, launch type 3) walks from the caster towards the
|
||||
target in steps of 10 while the cell is passable (`cWorld::IsPassable`
|
||||
0x636b60) and keeps the last point a creature can stand on
|
||||
(`cWorld::CanStand` 0x636eb0); a wall or cliff in the way ends the walk.
|
||||
|
||||
Logged (2026-10-28): the barrier adds, all with art 0x39, kind 0x3a value 0
|
||||
from its particles (0x7b9602), the lock in place (the dwarf was stuck 2-3 s),
|
||||
and kind 2 value 0.5 plus kinds 7..0xa value 30 from its update (0x5b5862..),
|
||||
the defensive bonus (resistances up while it lasts).
|
||||
|
||||
Mod (`mods/dwarfarts.cpp`): Eingraben's definition +0x3c becomes 3 (a point);
|
||||
after the original case the dwarf is teleported to the end of
|
||||
Phasenverschiebung's walk towards the point (the cursor keeps it within the
|
||||
visible area). The effect hook (`aurarecast.cpp`, `KeepEffect`) drops kind
|
||||
0x3a for art 0x39. Confirmed 2026-10-28: aimed, stops before walls and
|
||||
cliffs, casts at once at the screen edge, the barrier moves with him, no lock.
|
||||
|
||||
Duration ("more like 8 seconds as described in the tooltip, i want that to go
|
||||
down to 4 seconds"): an art's duration is 0x4d24f0 on its definition,
|
||||
+0x76 + level x +0x78, at most +0x82 when set (Eingraben: 7 + level, at most
|
||||
15). The barrier's lifetime (shield +0x20a8), its bonus (the effect applier,
|
||||
0x5b56ab) and the tooltip all use it; the mod sets 4 / 0 / 0.
|
||||
|
||||
Digging in and out: lowering the dwarf's height (creature +0xf0) moved only
|
||||
his marker; lowering the terrain height the game asks for him (0x449b20)
|
||||
moved the camera with him, so he never looked sunk. Instead ("change the
|
||||
barrier animation to turn from transparent to solid and back"): the barrier
|
||||
is `cParticleSystem_dwarf_shield` (constructor 0x7b8fc0, update vf08
|
||||
0x7b91a0, draw vf04 0x7b92b0; models fx_shield_phase1..4.grn). Its draw
|
||||
passes the model with alpha pulse x 128 (pulse 0.4..1 at +0x20b8; __ftol at
|
||||
0x7b9483), then again additively. The mod redirects that __ftol: during a dig
|
||||
the alpha goes to 255 over 1 s, the dwarf is moved (engine tick), and it goes
|
||||
back over 1 s. Orders to the dwarf are refused meanwhile
|
||||
(`game::FilterCreatureOrders`). Confirmed 2026-10-28, barrier and bonus 4 s.
|
||||
Open: clearing slows, roots and stuns.
|
||||
+31
@@ -101,6 +101,8 @@ widened instead, when the render size is applied (`PatchLayout`):
|
||||
| view centre (zoom frame, centred image) | 0x6ca53f, 0x6ca637, 0x6ca645 | 0x200 | width / 2 |
|
||||
| scroll centred on the hero; zoomed view; its quest markers | `[0x88e6fc]` (512.0) at 0x6c7bfe, 0x6cb27a, 0x6ccb7f, 0x6cf782 | 512 | own float |
|
||||
| zoomed view: other markers at 768 − d (centre + 256) | `[0x890040]` (768.0) at 0x6ce156 | 768 | width / 2 + 256 |
|
||||
| zoomed view: heroes' markers, 6 px quad at d + 509 / 515 (2026-10-25) | `[0x894780]` (509.0) at 0x6cf1bd, `[0x894778]` (515.0) at 0x6cf1dd | 512 ± 3 | centre ± 3 |
|
||||
| zoomed view: other symbols (quad, icons, labels) as integers (2026-10-25) | mov / add reg, 200h at 0x6ced82, 0x6cedb0, 0x6cf47a, 0x6cf4ac, 0x6cf5e4 | 0x200 | centre, rewritten when it changes |
|
||||
| dark band, right outer / inner edge | vertex immediates 0x6ca774, 0x6ca808 / 0x6ca7bc, 0x6ca84c | 1024 / 954 | width / width − 70 |
|
||||
|
||||
The frame is child statics of the window (vector at +0x78..+0x7c; measured:
|
||||
@@ -116,6 +118,19 @@ game lays them out at creation only).
|
||||
A window anchored `Screen` still counts as the outermost scope for events, so
|
||||
its child buttons (hooked as standalone controls, `Center`) don't shift again.
|
||||
|
||||
### Zoom on the hero (2026-10-25)
|
||||
|
||||
Reported: the zoomed view did not start centred on the hero. Read: the map
|
||||
zooms on its zoom point +0xc0438 / +0xc043c (screen) plus the scroll
|
||||
+0xc016c / +0xc0170 (render 0x6ca4d6). Holding the mouse button on the map
|
||||
(+0xc06bc, 0x6c8274) moves the point with the mouse (outside the zoom
|
||||
controls, 0x6ca47b); the zoom button (flag 0x100 in +0xc0154, 0x6c7fac)
|
||||
never sets it, so it zoomed on the last held point, or 0,0 after the map was
|
||||
created. Now, while the button zoom is on and the mouse is not held, every
|
||||
engine tick puts the point on the hero: map pixel by 0x6cb130 (thiscall
|
||||
(out, world x/y/level floats), ret 8; as opening the map centres on the hero,
|
||||
0x6c7bad..) minus the scroll. The held zoom still follows the mouse.
|
||||
|
||||
## Viewports (cursor in the quest book, 2026-10-17)
|
||||
|
||||
`SetViewport` inside a moved window is shifted like its vertices. The quest
|
||||
@@ -126,3 +141,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).
|
||||
@@ -0,0 +1,80 @@
|
||||
# Loot filter (Beute page)
|
||||
|
||||
Request (2026-10-19): on the options window's *Beute* tab, per category —
|
||||
Gold, Tränke, and the item colours white / blue / yellow / brown / green —
|
||||
one of:
|
||||
|
||||
- **Löschen** (Void): the drop is deleted at once;
|
||||
- **Ignorieren** (Ignore): the pickup key leaves it;
|
||||
- **Standard** (Default): picked up, label with the labels key (Alt);
|
||||
- **Immer anzeigen** (Always Show): picked up, label without the key too.
|
||||
|
||||
Decided with the user: the filter replaces the game's PICKUPAUTO choice
|
||||
(all / valuable / gold only; its radio buttons are hidden, the animation
|
||||
checkbox stays); Void deletes loot only (monster drops, chests), not items
|
||||
the player puts down. Settings: `[loot]` in `sacredK.ini`
|
||||
(`Gold=Default`, keys Quest, Gold, Potions, White, Blue, Yellow, Brown, Green;
|
||||
values Void, Ignore, Default, AlwaysShow). Code: `src/mods/lootfilter.cpp`,
|
||||
page in `src/mods/options.cpp`.
|
||||
|
||||
## What decides the category (read from the game)
|
||||
|
||||
- Quest items (request 2026-10-25: "Quest Items are currently potentially
|
||||
being destroyed, it needs to be top priority, also Löschen is not an option
|
||||
for those"), checked first: the type's quest number (type table +0x1c) or
|
||||
the scripts' QuestItem / MainQuestItem marks (+0x61 / +0x62; the script
|
||||
parser 0x4584a9 turns those words into tokens 0x61 / 0x62), as the quest
|
||||
book reads them (0x43ad80). The game's always-collect test `0x428550`
|
||||
requires type flag 0x1000000 besides, so quest items without it fell into
|
||||
their colour's category and could be voided. Their row has no *Löschen*
|
||||
box; a `Void` in the ini is read as Default.
|
||||
- Gold: `0x43ae20` (thiscall on the item type table, (type)): one of four
|
||||
types listed at `0x947920`. The labels show its amount, the collect mode
|
||||
takes it without inventory space, the loot drop sets the amount after
|
||||
creating it.
|
||||
- Potions: item class 9 (type table +0x2e).
|
||||
- Item colour (`0x5c1330`, the items' vf24 case 2): set items green
|
||||
(type group record `0x433f00` +0x23); otherwise, with the game's
|
||||
`UNIQUE_COLOR` option (on by default), by the quality tier, bits 16..19 of
|
||||
the item's quality flags (+0x11c): up to 4 white `ffefefef`, 5..11 blue
|
||||
`ffa0a0ff`, 12..14 yellow `fff0e040`, 15 brown `ffc19a33`; set green
|
||||
`ff15ff25`. (Without UNIQUE_COLOR, items with special bits get other
|
||||
colours from `0x5c1260`; the filter uses the tier regardless.)
|
||||
- Not filtered: runes (class 0x1c) and objects with flag 0x800 (the game's
|
||||
CanCollectItem always takes those).
|
||||
|
||||
## Where it acts
|
||||
|
||||
- **Pickup**: `cCreature::CanCollectItem` (`0x4ec170`, (item), ret 4) is the
|
||||
filter of the collect mode (`0x4ec3a0`, the pickup key's order 0x113): its
|
||||
ring scan and the follow-up scan next to a picked item. A clicked item does
|
||||
not ask it. Original: refuses carried items (+0x14 bit 2), takes the
|
||||
always-collected ones, then filters by PICKUPAUTO (`0x819580`; 1 by value
|
||||
against the hero level, 2 gold only). Now: carried → no; else by mode.
|
||||
- **Void**: the loot drop `cCreature 0x559a30` ((forced type, ?, ?, killer),
|
||||
ret 0x10; logs `Drop from [%d] %s`) makes its items with
|
||||
`cObjectManager::CreateAt` (`0x5fbaa0`, ret 0x14) and tosses them
|
||||
(`0x44e0a0`). Chests keep their contents as ids at +0x1e4/+0x1e8;
|
||||
`cChest3D::dropItems` (`0x5c9b10`, ret 4) throws them out. Ids from both
|
||||
are queued and handled on the next engine tick (the callers use the item
|
||||
after creating it, e.g. `0x4d0f29` uses the returned object): still on the
|
||||
map (no carried / dead flag) and Void → `RemoveFromMap` (`0x5fc910`) and
|
||||
`DestroyObject(id, 0, 0, 0)`, as the loot drop itself replaces an item.
|
||||
Player drops go through `cEngine::dropItem` (`0x6116c0`), not touched.
|
||||
- **Always Show**: `cWorldView0::DrawObjectLabels` (`0x627260`, (renderer),
|
||||
ret 4) draws the labels list (+0x96b30, 0x1c per entry, +0 object id; an
|
||||
entry with the high bit set is skipped) only with the key held (it sets
|
||||
+0x10 bit 0x4000 from the key). Without the key, if an always-shown item
|
||||
is listed: all other entries are marked skipped, the key reads as held
|
||||
(`input::ForceLabels`), the original runs, the marks and bit 0x4000 go back.
|
||||
Clicking such a label without the key does not pick it up (PickObjectAt
|
||||
`0x626d30` uses its own list); clicking the item does.
|
||||
|
||||
## The page
|
||||
|
||||
The auto-pickup frame (now only *Animation*) stays at the top left; below it,
|
||||
across both columns, a framed table: a row per category (name in the game's
|
||||
colour), a box per mode, the chosen one highlighted; the title "Beutefilter"
|
||||
in the header row (above the frame it met the backdrop); hints at the bottom.
|
||||
Changes are kept until OK (`0x717460`), Cancel drops them, like the key
|
||||
bindings.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Map reveal (fog of war)
|
||||
|
||||
## Request (2026-10-25)
|
||||
|
||||
"Can we increase the base vision range (for map fog of war reveal) by x10 or
|
||||
so?"
|
||||
|
||||
## Measured
|
||||
|
||||
- Each sector (64x64 tiles) keeps a 32x32 bit mask at +0x180 (128 bytes, one
|
||||
bit per 2x2 tiles): `Sector_SetExplored` 0x622bf0, copied out by 0x622c60,
|
||||
loaded from the save by 0x622c80 (0x606ae0). The zoomed map view turns it
|
||||
into its fog texture (FOGOFWAR01.TGA, 0x639030).
|
||||
- `World_SetExplored` 0x638f00 (thiscall on `*0xad14e0`, (tile x, tile y,
|
||||
explored), ret 0xc) finds the tile's sector; tiles >= 0x2000 and sectors
|
||||
that aren't loaded are ignored.
|
||||
- `RevealAroundHero` 0x599730 (creature thiscall (tile x, tile y, sight), ret
|
||||
0xc), called from about ten places in the creature code: only for the local
|
||||
hero, only when its tile changed (last tile 0xad2384 / 0xad2388), it calls
|
||||
`World_SetExplored` for every tile of a stamp centred on the hero. Sight 0:
|
||||
the 29x29 circle at 0x88f5cc (radius 14 tiles). The callers choose 1 or 2
|
||||
from the weather object (0x417e50: +0x9e0c set -> 2, 0x41ba90 true -> 1);
|
||||
those use the 15x15 stamps at 0x88f328 + (sight - 1) * 225 (radius 7, 5;
|
||||
a third one, radius 3, is unused).
|
||||
|
||||
## Mod (`mods/mapreveal.cpp`)
|
||||
|
||||
After the original revealed at a new tile, the same stamp scaled up 10x is
|
||||
revealed too, one cell (2x2 tiles) at a time through `World_SetExplored`: by
|
||||
day a circle of about 140 tiles radius, at night / in fog 70 / 50. About 15000
|
||||
cells per tile step by day. Sectors beyond the loaded ones are not marked.
|
||||
|
||||
## Creatures on the map (2026-10-25)
|
||||
|
||||
"Enemies on the TAB map don't show unless in very short range." The zoomed
|
||||
map view (0x6cc950, large map and TAB map) draws a creature off explored
|
||||
ground, or a hostile one, only within a sight range of the hero: |dx| and |dy|
|
||||
(world units, about 53.7 per tile) against a float at [esp+14h], 650 by
|
||||
default (0x6ce677), 300 where 0x63d280 holds for the hero's area (0x6ce6b5),
|
||||
350 when the weather object's 0x41ba40 is false (0x6ce6cf), plus a skill
|
||||
(0x355, 0x570d90) and equipment bonuses. Out of range only the debug flag
|
||||
0x182cdb8 & 2 shows them. The mod scales the three base values by 10 as well
|
||||
(about 120 tiles by day). The view only lists creatures of the sectors around
|
||||
the hero (25 entries at [esp+1ech]).
|
||||
|
||||
## Border line on the large map (2026-10-26)
|
||||
|
||||
"The red outline on the mega map is choppy and feels weird."
|
||||
|
||||
Measured (disassembly): `cUI_Megamap::DrawVisitedBorder` 0x6c8580 drew a
|
||||
2x2 px red dot (0xffff0000) per bit of a 100x100 byte grid (world +0xb73c):
|
||||
unvisited sectors next to visited ones (sector flag 0x20000, set when the
|
||||
hero's sector becomes the centre of the active 3x3, 0x626603), one bit per
|
||||
visited neighbour, the dot 10 / 20 px from the sector origin towards it.
|
||||
Built on load by 0x633230 and per sector change by 0x6332d0 / 0x6334f0. So:
|
||||
dots in 64-tile steps, following the sectors stood in rather than the
|
||||
explored mask the fog shows.
|
||||
|
||||
Mod (`mods/mapborder.cpp`): the draw is replaced. The sides between explored
|
||||
and unexplored cells (2x2 tiles) of the 100x100 map sectors are chained into
|
||||
polylines (clockwise around explored cells; at a corner touch the chain turns
|
||||
right), projected with `WorldToMapPixel` (linear), simplified to 0.75 px
|
||||
(Douglas-Peucker) and drawn as 2 px red lines. Rebuilt while the map is open
|
||||
when the masks change (checked every 250 ms). Cells of missing sectors count
|
||||
as neither explored nor unexplored, so no line runs along the world's edge.
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# 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.
|
||||
|
||||
## Beute: loot filter (2026-10-19)
|
||||
|
||||
The page holds the loot filter (`docs/loot-filter.md`): the game's pickup
|
||||
choice (PICKUPAUTO radio buttons, their labels, the separator; matched by
|
||||
pointer and control name at +0x30) is never shown, a framed table below the
|
||||
auto-pickup frame sets a mode per category. Its frame is made like the key
|
||||
page's (`AddFrame`).
|
||||
|
||||
## Allgemein: autosave interval (2026-10-20)
|
||||
|
||||
Request: the silent autosave's interval (docs/characters.md, Save) settable
|
||||
in the options, not only in `sacredK.ini`. The general frame moved up into
|
||||
the auto-pickup frame's place; it is taller than the frames beside it, so the
|
||||
new frame goes below the lowest frame of the left column. It copies the
|
||||
right column's lowest frame (Internet-Optionen): its height and the gap the
|
||||
right column leaves between its frames, measured from the window's frames
|
||||
when it is made (a lower frame broke its side ornaments, a smaller gap joined
|
||||
the borders: screenshots 2026-10-20). One row *Autosave (Min.)*: Aus / 5 /
|
||||
10 / 15 / 30 (the key page's box colour is not seen on the dark backdrop: a
|
||||
lighter one). A click selects, OK writes
|
||||
`[characters] AutosaveMinutes` (`characters::SetAutosaveMinutes`, the timer
|
||||
uses it at once), Cancel drops it.
|
||||
|
||||
## Allgemein: preferred monitor (2026-10-25)
|
||||
|
||||
Request: a setting for the monitor the game starts on; with none set, or the
|
||||
preferred monitor not connected, the system's main monitor.
|
||||
|
||||
- `src/mods/monitor.cpp`: the monitors (`EnumDisplayMonitors`), named and
|
||||
told apart through the display configuration (`QueryDisplayConfig`,
|
||||
`DisplayConfigGetDeviceInfo`: the GDI name `\.\DISPLAYn` of each path's
|
||||
source, the monitor's EDID name and device path of its target). The device
|
||||
path is the id: it stays the same across restarts and when monitors are
|
||||
rearranged, unlike `\.\DISPLAYn`. Kept in `sacredK.ini`
|
||||
`[display] Monitor=` (empty: the main monitor).
|
||||
- `GameMonitorRect` is asked where the screen is decided at startup: the
|
||||
borderless window covers that monitor (`borderless.cpp`), and the render
|
||||
size follows its aspect ratio (`widescreen.cpp` `ApplyRenderSize`). The
|
||||
log line `monitor: <name> WxH at (x,y)` says which one was used and why.
|
||||
A change applies at the next start.
|
||||
- In the options (user's choice of place): the *Internet-Optionen* frame
|
||||
(Datendurchsatz, multiplayer only) becomes *Bildschirm*. Its controls
|
||||
(`UI_CFG_LANSPEED` label, DSL / ISDN radio buttons, everything inside
|
||||
`UI_FRAME_INTERNET`) and the title label `UI_LABEL_WAN` are hidden; the
|
||||
game keeps the saved Datendurchsatz. The title text (key `UI_CFG_WAN`) is
|
||||
overridden with "Bildschirm" before the window is made.
|
||||
- One row: *Monitor* and a box with the choice (Hauptmonitor, then each
|
||||
monitor as "name (WxH)", "Nicht verbunden" for a preferred monitor that is
|
||||
missing). A click opens the list upwards (the frame is the lowest one); a
|
||||
click on an entry picks it, any click closes the list. A changed choice
|
||||
shows "Ab dem nächsten Spielstart" below the box. OK saves, Cancel drops.
|
||||
@@ -0,0 +1,278 @@
|
||||
# Quest audit: how quests fail or get stuck (2026-10-30)
|
||||
|
||||
Request (2026-10-26, started 2026-10-30): "iterate through all quests in the
|
||||
game, and check their conditions for potential failing or stuck up points
|
||||
given the current implementation and our attempted reset". Done one quest at
|
||||
a time from the compiled scripts with `tools/questscript.mjs` (full dump:
|
||||
`<dir> <id>`; compact: `<dir> --facts <id>`).
|
||||
|
||||
Scripts: Ancaria per hero class `bin\TYPE_NPC_<class>\` (the game writes the
|
||||
one in use to `bin\sgf.bin`; this audit reads `TYPE_NPC_GLADIATOR`, the class
|
||||
variants differ in a few class quests), Underworld `bin\Addon\NetScriptCamp\`.
|
||||
Ancaria: 603 quests in the table (87 + 13 side quests, ~37 main quests, 352
|
||||
generic `DQ_` quests from 16 templates, multiplayer / unnamed sets);
|
||||
Underworld: 190.
|
||||
|
||||
## What our mods do (the "reset")
|
||||
|
||||
- **M1 failure restart** (`questfail.cpp`, `Quest_End` hook): after
|
||||
`LoseQuest` ran the quest's failure script (`QIS_OnLose<id>`), the flags go
|
||||
back to available (1), the book entry stays failed with its compass on the
|
||||
giver. Quest variables and NPC states are left as they are.
|
||||
- **M2 giver death** (`questgiver.cpp`): a label of a running quest run for
|
||||
its dead giver (the giver: the NPC spawned with a dialog that has the accept
|
||||
button) is skipped, unless the giver is hostile to the hero (2026-10-30);
|
||||
the quest restarts (M1 plus the variables only this quest writes: 0, the
|
||||
one named after the quest: 1) and the giver's spawn instruction runs again
|
||||
once the body is gone.
|
||||
- **M3 load repair** (`questgiver.cpp`): a running quest whose giver is not
|
||||
alive after loading gets M2, unless the quest morphs its giver.
|
||||
- **M4 followers** (`followers.cpp`): escorts back on the hero's follower
|
||||
list after loading; followers more than 40 cells away are teleported to the
|
||||
hero.
|
||||
|
||||
## General findings
|
||||
|
||||
- **G1 M1 keeps the quest variables.** Givers' dialogs choose their branch by
|
||||
bits of the quest variable (`IF 49(bit, "<id>")`); bits set while the quest
|
||||
ran stay set after a failure, so the giver shows a "still running" branch
|
||||
without the accept button and the quest can never be taken again. Example
|
||||
702: the girl's death runs `lose702`; variable 702 keeps bit 1, the giver
|
||||
answers "Qoffen01" with `ok702`. Fix: M1 resets the quest's own variables
|
||||
like M2.
|
||||
- **G2 generic quests (2600..3599)** are the game's random quest pool;
|
||||
`Quest_End` already copies them under a new id. M1 should leave them (and
|
||||
their runtime copies) to the game.
|
||||
- **G3 one-time world progress.** Many targets are spawned once by the start
|
||||
script or `Region<N>Init`, not by the quest. When M2/M3 reset the quest's
|
||||
variables after such a target died, the progress is lost and the target
|
||||
never comes back: stuck. Either keep the variables on M2/M3 or respawn the
|
||||
quest's one-time NPCs (see G6).
|
||||
- **G4 duplicates on re-accept.** `QIS_OnEnter` creates the quest's NPCs and
|
||||
objects again; after a failure the old ones may still be alive.
|
||||
- **G5 givers deleted by the script** (`DelNPC` on the giver): M3 restarts a
|
||||
quest whose giver is gone on purpose.
|
||||
- **G6 restart without the NPCs.** The restart is pointless when the NPCs the
|
||||
quest needs are one-time spawns and dead or deleted. Idea: respawn them from
|
||||
their spawn instructions like the giver.
|
||||
- **G7 quests without a giver** (trigger areas): M1 "restarts" them and erases
|
||||
the failed book entry.
|
||||
- **G8 givers turned hostile by `SetNPCState ... 08 12`** instead of `Morph`
|
||||
are not in `g_giverTurns`; M3 restarts those quests wrongly, M1 points the
|
||||
book at an enemy.
|
||||
- **G9 shared variables.** M2 doesn't reset quest variables another quest's
|
||||
label also writes, so the respawned giver shows a running branch.
|
||||
- **G10 giver killed after the quest ended** (by its own script): M2 doesn't
|
||||
act, the giver stays dead.
|
||||
- **G11 failure continues a chain.** Some failures are part of a quest chain
|
||||
(`LoseQuest` together with `SetUpQuest` of the next quest, giver's dialog
|
||||
moved on); M1 must not restart those.
|
||||
- **G12 state in dialogs (Underworld).** Underworld quests keep their state
|
||||
in the NPCs' dialogs; a failure sets a "done/lost" dialog (or none), and M1
|
||||
doesn't put the accept dialog back (7614, 10260).
|
||||
- **G13 named ids.** Spawns with named ids (`res:AUFTRAGGEBER_10254`,
|
||||
`res:Sam1_Knappe`) or the id after the type argument aren't read by
|
||||
`InstructionScriptId`; those givers aren't in `g_givers`.
|
||||
- **V1/V2 vanilla bugs:** 1101's time-out never ends the quest; Res:17163 is
|
||||
used by both 602 and 1202, and 602 deletes 1202's patient.
|
||||
|
||||
## Per quest
|
||||
|
||||
Columns: how it can fail (vanilla), what happens with our mods, verdict
|
||||
(ok / G<n> / finding).
|
||||
|
||||
| Quest | Fails by | With our mods | Verdict |
|
||||
|---|---|---|---|
|
||||
| 151 Tierischer Wilddieb | never (no LoseQuest); wolf Res:17081 (start script, death `stirb151`: bit 2, triggers the quest) | M2/M3 on giver death: variable 151 reset to 1; if the wolf is already dead, bit 2 is lost and the wolf never respawns: stuck | G3 |
|
||||
| 152 Botendienst | time limit (`SetTimer` 4320 -> `zeit152`: bit 4, `LoseQuest`) | M1: bits 1 + 4 stay, the giver answers "Qfail" / "Qpostfail" without the accept button: stuck. With G1 reset: offered again; re-accept spawns the parcel (Res:6027) and later the recipient Res:17077 again (the old recipient stays, dialog off) | G1, G4 |
|
||||
| 154 verschwundener Schmied | never | the script deletes the giver Res:17626 (`talkedbride154`, `take154`): after the next load M3 restarts the running quest and spawns the giver again | G5 |
|
||||
| 201 Vermisste Tochter nach Ausritt | daughter Res:18006 dies (`Tochtertot201`: bit 8, `LoseQuest`); she is spawned once (`Region2Init`) | M1: bit 8 stays, giver answers `okt201` then `ok201`: stuck. With G1: offered again, but the daughter stays dead: cannot be finished | G1, G6 |
|
||||
| 202 Eskorte für Händler | merchant Res:18012 (the giver) and bodyguard Res:18013 both dead, or the bodyguard alone at the bridge | the merchant's death is M2 (restart, respawn; his dialog has no bit tests); the bodyguard is not needed | ok |
|
||||
| 203 Schatzkarte | never | giver death -> M2; re-accept creates map + chest again (old chest stays) | ok (G4) |
|
||||
| 204 Die Bücher der Gelehrigkeit | never | giver death -> M2; the boss and the book come from `QIS_OnEnter204`, so a restart can be redone | ok |
|
||||
| 301 Orks auf Bauernhof | time limit (`SetTimer` 1440 -> `lose301`); farmer Res:17033 and daughter Res:17032 both dead | no giver (trigger area, deleted on start); `QIS_OnLose301` deletes both NPCs. M1 makes it available but nothing can start it again, and with no known giver its failed book entry is erased | G7 |
|
||||
| 302 Von Räubern und Rindern | six cattle dead (`302a` = 6, `LoseQuest`); the giver Res:17029 (escorted) dies (`tot1_302`) | giver death -> M2 (but `302a` is also set by `startcode`, so it is not "only this quest's" and keeps its count). Cattle failure -> M1: the dialog's `48(6,"302a")` branch sets bit 3 -> "ok" forever: stuck, also with G1 unless G1 counts start-script initialisation as the quest's own. Re-accept spawns the six cattle again | G1 (start-script variables), G4 |
|
||||
| 303 Bodycount Orks | never; ogre Res:17031 is a one-time spawn (`Region3Init`, death `tot303`: bit 2) | as 151: giver death resets bit 2, a dead ogre never returns: stuck | G3 |
|
||||
| 401 Jagd auf wildernde Wölfe | never; wolves one-time (`Region4Init`, death `stirb401`: bit 2) | as 151 | G3 |
|
||||
| 402 Kräuter für Kräuterhexe | never | herbs from a permanent `MouseEvent` (`Region4Init`), can be picked again after M2 | ok |
|
||||
| 403 Kühe vor Wölfen retten | all cows dead (bit 4), then the giver's answer runs `lose403` | M1: bits 4 + 5 stay, giver "ok403" forever: stuck. With G1: offered again, but the cows (`Region4Init`) are gone | G1, G6 |
|
||||
| 501 Geister auf Landsitz | never by `LoseQuest`; choice `change501` / `nochange501` | `postchange501` turns giver Res:17071 and Res:17070 hostile with `SetNPCState ... 08 12` (not Morph): M2 sees the hostility at death, but M3 after a load would restart the quest (only Morph is known as "turns") | G8 |
|
||||
| 502 Verstecktes Waffenlager | never | enemies and boss from `QIS_OnEnter502` (variant by `Globalchange1`): redoable after M2 | ok |
|
||||
| 503 Botschaft an Geliebte | never | giver Res:17105's death label `weg503` belongs to no quest (M2 does not react); not needed: the quest ends at the lover Res:17104 | ok |
|
||||
| 601 Kunstraub von Händler | never | thief Res:17135 gives the item by dialog; redoable after M2 | ok |
|
||||
| 602 Wegelagerer | never | the knight Res:17136 is beaten, not killed (`ritter602` sets his HP back); after M2 his dialog offers the fight again | ok |
|
||||
| 603 Entlaufener Bräutigam | never | the groom Res:17140 is beaten (`braut603` heals him), the fight can be repeated after M2 | ok |
|
||||
| 701 Vergifteter Brunnen | never (`lose701` is not called) | boss Res:17063 one-time (`Region7Init`, drops the items); M2 resets bit 20 (item taken): stuck if the boss is dead | G3 |
|
||||
| 702 Mädchen bei Ausritt verschwunden ("Familienbande") | the girl Res:17060 dies (`lose702`) | giver turned hostile and killed: handled (7ec0fa2). Girl's death -> M1: bit 1 stays, giver "Qoffen01": stuck; with G1 redoable (the girl comes from `QIS_OnEnter702`) | G1 |
|
||||
| 703 Objektschutz Wegbrücke | leaving the bridge area / region before the bandits are dead (`zeit703`: bit 4), then the giver's answer runs `lose703` | M1: bits 4 + 5 stay, giver "ok703": stuck. With G1: redoable (bandits not all dead, else the quest would have been won) | G1 |
|
||||
| 801 Holzfällerstreit mit den Elfen | the giver Res:17144 dies (`tod801` -> `lose801`) | giver death -> M2 (restart, respawn) instead of the failure | ok |
|
||||
| 802 Königliche Jäger Verschollen | never | hunters, herbs and amulet are one-time (`Region8Init`); M2 resets 802 (progress bits): stuck when they are used up | G3 |
|
||||
| 803 Kopfgeldjagd | never | targets one-time (`Region8Init`). On giver death M2 does not reset `803` (quest 801's elf dialog also sets a bit in it), so the respawned giver shows the running branch without accept and the flags say "available": stuck | G3, G9 |
|
||||
| 901 Soldaten verschleppen Frauen | time limit (`zeit901`, also kills quest 902's women); the women Res:17051..17054 die (`lose901`: bit 5), then the giver's answer runs `lose901` | M1: bit 6 stays, giver "ok901": stuck. With G1: redoable after the time limit; after the women died they are gone (`Region9Init`) | G1, G6 |
|
||||
| 902 Rache an Reichem Händler | never | boss Res:17049 one-time (`Region9Init`, death `tot902`: bit 2) | G3 |
|
||||
| 903 Robin Hood | never | Res:17045 is beaten and healed (`re903`), the choice can be made again after M2 | ok |
|
||||
| 1001 Übergriffe auf die Bevölkerung | never | boss Res:17041 one-time (`Region10Init`, drops the proof) | G3 |
|
||||
| 1002 Spionage | seen by the spy's group (tag 0x60 `lose1002` on the NPCs from `QIS_OnEnter1002`: bit 4, `LoseQuest`) | M1: giver sets bit 5, "ok" forever: stuck. With G1 redoable; the old group (now hostile) stays next to the new one | G1, G4 |
|
||||
| 1003 Schlägertypen | the giver Res:17034 dies (`verreckt1003`); seven villagers die (bit 4), then the giver's answer runs `lose1003` | giver death -> M2. Villagers -> M1: bit 5, "ok" forever: stuck. With G1 redoable (the attackers come from the accept trigger again) | G1 |
|
||||
| 1101 Sektenpriester opfern Jungfrauen | the virgin Res:17112 dies (`Verloren1101` -> `lose11a1`); time limit (`Zeitum1101`: demon instead of the virgin, bit 3) | death -> M1: giver sets bit 5, "ok": stuck; with G1 redoable (all from `QIS_OnEnter1101`, old priests stay). Time limit: vanilla never ends the quest (the giver's bit 3 branch has no `LoseQuest`): running forever | G1, V1 |
|
||||
| 1102 Kompromittierende Situatuion | time limit (`Zeitum1102`: bit 3, `LoseQuest`) | M1: bit 1 stays, giver "Ok1102": stuck; with G1 redoable (the lady and the letter come from `QIS_OnEnter1102`). After the time limit the lady's bit 3 answer kills the giver (`SetHP 0`); the quest is no longer running then, so M2 does not respawn him | G1, G10 |
|
||||
| 1103 Verhandelnder Räuber | never | the robber Res:17118 is beaten and healed (`tot1103`) | ok |
|
||||
| 1111 DeModreys Steuerpresser | the escorted Res:17121 dies (`lose1111` -> `geld1111`: bit 5, `LoseQuest`) | M1: giver sets bit 6, "ok": stuck. With G1: the boss Res:17123 is one-time (`Region111Init`) and `geld1111` deleted his proof Res:6051: cannot be finished | G1, G6 |
|
||||
| 1112 Animagus terrorisiert Bauernhof | the spared Animagus Res:17127 dies (`ex1112` with bit 4: `LoseQuest`) | M1: bit 4 stays, the giver's answer then runs `postchange1112` (turns the giver hostile) instead of an offer. With G1: the Animagus is one-time (`Region111Init`). `postchange1112` turns the giver hostile by `SetNPCState` (M3 after a load: G8) | G1, G6, G8 |
|
||||
| 1113 Elfischer Kunstraub | never | thief Res:17129 and the goal object Res:6052 one-time (`Region111Init`) | G3 |
|
||||
| 1201 Sekte beschwört kleine Dämonen | never | cult one-time (`Region13Init`, group dead: bit 2) | G3 |
|
||||
| 1202 Heilmittel für Händler | time limit (`zeit1202` kills the patient Res:17163, `LoseQuest`) | M1: giver sets bit 5, "ok": stuck. With G1 the patient (`Region13Init`) is dead: cannot be finished. Vanilla: quest 602's `ok1_602` / `ritterweg_602` walk away and delete `res:17163` too (the same id): finishing 602 removes 1202's patient | G1, G6, V2 |
|
||||
| 1203 Der Richter und sein Henker | never | judge Res:17166 and executioner Res:17167 one-time (`Region13Init`) | G3 |
|
||||
| 1301 Entlaufener Gladiatoren-Minotaurus | never | the giver's death label is the quest's end (`exit1301`): `attack1301` turns him hostile (M2 lets it run since 7ec0fa2), the quest ends at once (no M3). Minotaur from `QIS_OnEnter1301` | ok |
|
||||
| 1302 Das Soldaten-Tunier | timer → `lose1302` | after M1 bits 4/5 and `1302a` stay (G1); opponent Res:17173 is deleted by `attacke1302_1` and created only by region init (G6). Vanilla typos: `DelNPC res17173`/`res17174` (no colon), `attacke1302_2` attacks 17174 | G1 + G6 |
|
||||
| 1303 Händler - legaler Warentransport | timer → `lose1303` | after M1 bits 3/4 stay (G1); re-accept creates recipient Res:17177 again (G4) | G1 + G4 |
|
||||
| 1401 Händler - Warenschmuggel | the side chosen dies (`lose1401`) | after M1 bits 1-6 and `1401a`/`1401b` stay (G1); re-accept creates Res:17180/17181 again (G4). Vanilla: soldier's death label `attack1401` doesn't exist (`attacke1401`) | G1 + G4 |
|
||||
| 1402 Junge Dame vor Übergriffen eines Offiziers schützen | never | officer and soldiers from `QIS_OnEnter1402` | ok |
|
||||
| 1403 Eskortiere Händler zu geheimen Treffen | recipient after the escorted giver died (`losequest1403`) | giver death is caught by M2 first (restart, giver back); re-accept creates recipient Res:17187 again (G4). Vanilla typo `ok1024` | G4 |
|
||||
| 1404 Eskortiere Dame zu Treffen mit Geliebten | the lady dies (`lose1404`) | after M1 bits 1/4 stay (G1); lady Res:17189 and boss Res:17190 are one-time (region init), dead lady makes a restart pointless (G6) | G1 + G6 |
|
||||
| 1405 Sekte von Dämonenanbetern | never | sect leader Res:17195, cultists and the three captives are one-time (region init); M2 reset after the leader died loses the progress | G3 |
|
||||
| 1501 Menschlicher Wilderer | never | the giver follows; vanilla his death (`ex1501`) lets the quest go on, M2 restarts it and brings him back instead. Re-accept creates the poachers again (G4) | M2 changes vanilla + G4 |
|
||||
| 1502 Hetzjagd | a label deletes the giver Res:17015, then `losequest1502` | the giver is spawned once by trigger area `hetz1_1502` (deleted after use); after M1 the book points at a deleted giver and nothing spawns him again (G6). Vanilla: his death label `lose1502` doesn't exist | G6 |
|
||||
| 1503 Arak-Attack | never | spiders and the target from `QIS_OnEnter1503` | ok |
|
||||
| 1601 Kunstraub | never | thief from `QIS_OnEnter1601` | ok |
|
||||
| 1602 Attentäter abfangen | the target dies (`target1602`) | after M1 bit 4 stays (G1); target Res:17205 and assassin Res:17201 are one-time (region init), the target is dead (G6) | G1 + G6 |
|
||||
| 1603 Blockadebrecher | never | | ok |
|
||||
| 1701 Getrennte Liebende | the lover dies (`lose1701`); afterwards the giver turns hostile and fails it again (`ende1701`) | after M1 bit 6 stays, the giver's dialog runs `ende1701` (G1); lover Res:17209 one-time (G6); `ende1701` makes the giver hostile by `SetNPCState 08 12`, M1 then points at an enemy (G8) | G1 + G6 + G8 |
|
||||
| 1702 Korruption | never | official Res:17215 one-time (region init) | G3 |
|
||||
| 1703 Unschuldiger am Galgen | judge after the time-out (`lose1703`) | after M1 bits 6/7 stay (G1); victim Res:17218 and boss Res:17221 are deleted by `zeit1703`, the victim is one-time (G6). `lose1703` kills the giver by `SetHP 0` on purpose; M2 skips his death label `less1703` and brings him back | G1 + G6, M2 changes vanilla |
|
||||
| 1801 Monströsere Wilderer | never | poachers one-time (region init) | G3 |
|
||||
| 1802 Dunkle Priester beschwören Dämon | never | priests from `QIS_OnEnter1802` | ok |
|
||||
| 1803 Dunkelelfengift | time-out (`zeitum1803`) | after M1 bit 4 stays, the giver's dialog runs `losequest1803` again (G1); boss Res:17229 one-time (region init), once killed the poison can't drop again (G6) | G1 + G6 |
|
||||
| 1901 Rache an bösem Anführer in Dungeon | never | boss Res:17232 one-time (region init) | G3 |
|
||||
| 1902 Monster haben Item verschleppt in Dungeon | never | monster Res:17020 one-time (region init) | G3 |
|
||||
| 1903 Entlaufene 'Tochter' | the girl dies (`lose1903`) | after M1 bit 5 stays, the giver's dialog morphs him into the monster at once (G1); girl Res:17237 one-time (G6) | G1 + G6 |
|
||||
| 2001 Untote Kühe auf Wiesen | never | boss Res:17239 spawns on the 4th trigger area; `2001a` starts at 1 from the start script, a reset to 0 matches no branch of `move2001` and the boss never comes (G1 start-script variable); trigger bits in `2001` | G3 (start-script variable) |
|
||||
| 2002 Kämpfe gegen Wilde Monster | never | repeatable arena, waves from `monster2_2002` | ok |
|
||||
| 2003 Anführer der Dämonensekte | never | the giver is the cult leader: `QIS_OnEnter2003` makes him hostile by `SetNPCState 08 12` (G8, M3 would restart). His death runs (hostile, 7ec0fa2). Vanilla typo `res:174242` | G8 |
|
||||
| 2101 Hands of my Dragon | never | chain with 2102/2103 (and 2303's egg) on dragon Res:17245 (one-time); prince Res:17246 and his guards from `QIS_OnEnter2101`. Shared variables `riesendiamant`/`drachenei` are written by several of these quests (G9 if one is restarted) | ok |
|
||||
| 2102 Drache - Zahnstocher | never | prince is the giver; boss Res:17249 from `QIS_OnEnter2102` | ok |
|
||||
| 2103 Return for Refill | never | knights created one after the other by `ziel2103` | ok |
|
||||
| 2201 Kuss der Spinnefrau | the girl dies (`lose2201`) | after M1 bits stay (G1); girl Res:17283 one-time (G6); `postchange2201` turns the giver Res:17281 hostile by `SetNPCState 08 12` (G8) | G1 + G6 + G8 |
|
||||
| 2202 Narzisstische Zauberin | never | `change2202` turns the giver Res:17255 hostile by `SetNPCState 08 12` (G8) | G8 |
|
||||
| 2203 Hinterhältiger Giftmischer | never | poisoner Res:17260 and the six barrels (counter `2303a`) are one-time (region init) | G3 |
|
||||
| 2301 Besorge Reagentium | never | bush is a fixed MouseEvent, item re-created in OnEnter | ok |
|
||||
| 2302 Larbortiere | never | the four monsters are one-time (region init), counted in `2302b` | G3 |
|
||||
| 2401 Botschaft für Scouts | never | chain 2401-2404 on giver Res:17268 (his death label `mann2404` belongs to 2404); scouts one-time (region init), moved/morphed by the quest | G3 |
|
||||
| 2402 Verlaufene Sielder finden | the settler dies after joining (`losequest2402`) | the failure continues the chain (`SetUpQuest 2403`, giver's dialog set to 2403); M1 restarting 2402 is wrong (G11) | G11 |
|
||||
| 2403 Verschleppte Sielder befreien | all settlers die (`losequest2403`) | like 2402: the failure continues the chain with 2404 (G11) | G11 |
|
||||
| 2404 Verteidige Lager | never | vanilla the giver's death (`mann2404`) hands the reward to Res:17280; M2 restarts and brings him back instead | M2 changes vanilla |
|
||||
| 2411 Das Orkische Relikt | never | relic from `QIS_OnEnter2411` | ok |
|
||||
| 2412 Der Feige Hund | never | dog Res:17584 one-time (region init) | G3 |
|
||||
| 2413 Kleine Wuselorks | never | all from `QIS_OnEnter2413` | ok |
|
||||
| 2501 Finde Drachnschuppe für Rüstung | never | dragon Res:17292 one-time (region init); counts towards `finale` | G3 |
|
||||
| 2502 Eierdieb | the time-out, reported to the giver (`losequest2502`) | the failure counts towards the region's `finale` (bit 2 + `finalestart`) like a success (G11); after M1 bits 4/5 stay (G1) | G11 |
|
||||
| 2503 Drachentöter vertreiben | never | dragon slayers one-time (region init) | G3 |
|
||||
| 2504 Rette Jungfrau | the maiden (= giver) dies (`lose2504`) | M2 catches her death first: restart and respawn instead of failure + `finale` bit; redoable | M2 changes vanilla |
|
||||
| 9003 Versteckter Händler | `rg3_goodie1_lose` (merchant's death) | progress lives in the shared variable `goodies` (8 quests), M1/M2 can't reset it (G1 + G9): after M1 bit 1 stays and the dialog offers the reward branch (`ExitQuest`) without the quest running | G1 + G9 |
|
||||
| 9004 Vermisstes Kind | `rg1_goodie_lose` (child's death) | `goodies` bit 4 stays, the giver only answers "ok" (G1 + G9); child Res:17513 one-time (G6) | G1 + G6 + G9 |
|
||||
| 9005 Der Waldläufer | never | | ok |
|
||||
| 9006 Der unbekannte Krieger | never | progress in shared `goodies` | ok |
|
||||
| 9007 Jacquaire's Waffen | never | guards from `QIS_OnEnter9007` | ok |
|
||||
| 9008 Furchtbares Verbrechen | escorted giver Res:17539 dies (`losequest9008`) | M2 catches the death (restart, respawn); `QIS_OnEnter9008` sets the trigger area again | M2 changes vanilla |
|
||||
| 9009 Whiskeylieferung | never | | ok |
|
||||
| 9010 Vergrabene Sklaven | escorted giver Res:17575 dies (`lose9010`) | M2 restarts; but `9010` is a counter (0 = offer, needs key Res:17580 in the inventory), M2's reset to 1 shows the "follow" branch, and `QIS_OnEnter9010` deleted the key, so it can't be accepted again (G6) | G6 |
|
||||
| 9011 Jacquaires Vergangenheit | never | boss and target from `QIS_OnEnter9011` | ok |
|
||||
| main quests 1-81, 670 | never | started by trigger areas (`SelfTriggerQuest<N>`), no giver, so M1-M3 don't touch them; 31's giver is only set by `SetNPCState` (not in `g_givers`) | ok |
|
||||
| 65 Trockenlegung durch Zwergenstaudamm (main) | never | giver Res:18306 (Torfstecher, start script) is in `g_givers`. Vanilla his death leaves 65 unfinishable (61 is activated by `Exit65`); M2 brings him back, but resets `65` incl. bit 5 (pump switched by `SwitchPump65`); if the pump can't be used twice the quest is stuck (G3). Re-accept creates the rune Res:17569 again (G4) | G3 + G4 |
|
||||
| 210 Verschleppte Novizinnen in Räuberöhle | never | boss Res:17083 one-time (start script) | G3 |
|
||||
| 9000 Das Untotenlager | the shaman's drop is picked up while the quest runs | refusing makes the giver Res:17500 attackable by `SetNPCState 08 14` (not Morph, G8); killing him drops the item whose label opens the Khorad Nur gate (`ablehnensieg_goodie_wueste`). If M2 sees him as not hostile it skips `drop_goodie_wueste`: **no item, the gate (main quest 55's way) never opens**. Check `IsHostileTo` for `08 14` | G8, critical |
|
||||
| 5003 Opferritual | the hero refuses at the end (`nein5003`) | a story choice: the shaman Res:17699 and his tribe turn hostile (`SetNPCState 08 12`) and the quest fails. M1 restarts it with bit 6 kept, so the (now hostile) shaman would offer the choice again (G11 + G8) | G8 + G11 |
|
||||
| 5005 Pony Express | a time limit runs out (`losepe`) | `losepe` deletes the giver Res:17753 (spawned once by main quest label `NachOrkland55`) and all stations; after M1 the book points at nobody and the quest can't be taken again (G5/G6); `5005c` stays (G1) | G1 + G5 |
|
||||
| 6001 Die Invasion der Eiselfen | all elves of the camp die (`od_welfen6001`) | after M1 bits stay (G1); the elves (incl. giver Res:17706) are one-time, dead (G6); re-accept creates the invaders again (G4). Accept label starts with `SetNPCState`, so the giver isn't in `g_givers` (no M2/M3) | G1 + G4 + G6 |
|
||||
| 6002 Das Orklager | never | | ok |
|
||||
| 6003 Elfenbeschützer | the escorted elf Res:17750 dies (`lose6003`) | after M1 bit 3 stays (G1); the elf is one-time (G6); her dialog is set by `SetNPCState`, so she is probably not in `g_givers` (no M2) | G1 + G6 |
|
||||
| 2600-3599 generic quests (16 templates × 22 regions) | `DQ_RG<n>_..._LOSE_<id>` (and `_ABORT_` for escorts) | no giver (runtime-assigned NPC), so no M2/M3; M1 restarts them although `Quest_End` already copies them (G2) | G2 |
|
||||
|
||||
### Underworld (`bin\Addon\NetScriptCamp`)
|
||||
|
||||
Underworld quests keep their state in the NPCs' dialogs (`SetNPCState ...
|
||||
dialog"dlg_<npc>_<id>_<step>"`), rarely in variables, and most givers are
|
||||
spawned by `QIS_OnSetUp<id>` (run from the start script) without a dialog.
|
||||
|
||||
| Quest | Fails when | Finding | Class |
|
||||
|---|---|---|---|
|
||||
| 7612 Eskorte | escorted Nanoduk Res:19547 dies (`od_nanoduk`) | he is the giver and one-time (`QIS_OnSetUp7612`); after M1 nobody offers the quest (G6) | G6 |
|
||||
| 7614 Vermisstes Kind (Underworld) | the child Res:19549 dies (`od_erdriac`) | the giver's dialog is set to the "child is dead" step `_d` (only "ok"); M1 doesn't restore the accept dialog `_a`, so it can't be taken again (G12). The child comes from `QIS_OnEnter7614` | G12 |
|
||||
| 7617 Eskorte in den Süden | escorted giver Res:19586 dies (`od_7617`) | he is spawned with his accept dialog, so M2 catches his death: restart and respawn | M2 changes vanilla |
|
||||
| 7619 Tod den Toten | the giver Res:19545 dies (`od_threepwood_7619`) | his accept dialog is only set by `SetNPCState` in `QIS_OnSetUp7619` (not in `g_givers`); after M1 he is dead (G6) | G6 |
|
||||
| 9518 Unter der Herrschaft von Dämonen | the mother Res:19530 dies while following (`od_lose9518`) | after M1 bits 1/2/4 stay, the giver only answers "ok" (G1); re-accept creates mother and demons again (G4) | G1 + G4 |
|
||||
| 9520 Cookies Quest | the hero gives the cookies to Von Endlich, then the girl ends it (`btn_FinishLose9520`, gold for experience) | a story choice (G11); after M1 bit 4 stays, so the girl offers `btn_FinishLose9520` again and again (repeatable gold-for-experience trade) | G11 |
|
||||
| 10254 Der Hilferuf | the giver dies (`od_Auftraggeber_10254`) | the giver is spawned once when the letter is picked up (`ot_Brief_10254`); after M1 he's dead and the letter gone (G6). The 10250-10261 givers have named ids (`res:AUFTRAGGEBER_10254`, after the type argument), which `InstructionScriptId` doesn't read: none of them is in `g_givers` (G13) | G6 + G13 |
|
||||
| 10255 Panik vor Spinnen | never (its "lose" is a label name; the giver offers a restart himself) | | ok |
|
||||
| 10260 Das Rätsel in der Wüßte | the wrong chest is opened (`fillchest_chestQJ_0x`) | the giver's dialog is removed (`0a#0`); after M1 nobody offers it (G12) | G12 |
|
||||
| other Underworld quests (main 11-56, 7600-7630, 9511-9522, 9900, 11101-11110, 15001-15084) | never | main quests have no giver (untouched). Side quests: givers spawned with their accept dialog in `QIS_OnSetUp<id>` are in `g_givers` (7600, 7601, 7605, 7609, 7614, 7617, 7618, 7620, 7622-7624, 7626, 7630, 9511, 9513, 9515, 9518, 9520, 9521); a chain's later quests on the same giver (dialog by `SetNPCState`) are not. Their targets also come from `QIS_OnSetUp` (one-time), so M2 after a target died loses progress only where variables hold it (G3, rare here); named ids (`res:Sam1_Knappe`, `res:AUFTRAGGEBER_…`) are skipped (G13) | ok / G3 |
|
||||
|
||||
## Summary and proposed fixes
|
||||
|
||||
Counted over the side quests (Ancaria 151-2504, 9003-9011, 210, 65, the
|
||||
5003-6003 set and the Underworld quests that can fail): about a third are
|
||||
fine, the rest fall into the classes above. In order of impact:
|
||||
|
||||
1. **G8 critical, 9000** (Khorad Nur gate): verify by a log whether a creature
|
||||
set by `SetNPCState ... 08 14` is hostile to the hero (`IsHostileTo`). If
|
||||
not, M2 skips the shaman's death label and the gate never opens. Fix
|
||||
either way: treat a giver as "turned" when any of its quest's labels set
|
||||
`08 12`/`08 14` on it (extends `ScanGiverTurns` beyond Morph); that also
|
||||
fixes M3 for 1112, 1301, 1701, 2003, 2201, 2202, 501, 5003.
|
||||
2. **G1 reset on failure:** M1 resets the quest's own variables like M2
|
||||
(including the ones only start/region scripts initialise, with their
|
||||
initial values, e.g. 302a, 2001a = 1). Fixes most "giver only says ok"
|
||||
cases (152, 702, 703, 1002, 1003, 1302, 1303, 1401, 1404, 1602, 1803,
|
||||
1903, 9518, ...).
|
||||
3. **G11 failures that are part of a story or chain** (2402, 2403, 2502,
|
||||
5003, 9520): M1 must not restart a quest whose failure label also calls
|
||||
`SetUpQuest`/`finalestart`, or sets the giver hostile.
|
||||
4. **G2 generic quests:** M1 leaves 2600-3599 (and their runtime copies) to
|
||||
the game.
|
||||
5. **G6 restart without NPCs / G3 lost progress:** the bigger design
|
||||
question. Options: (a) on restart, respawn the quest's one-time NPCs from
|
||||
their spawn instructions in start/region scripts (like the giver), (b) keep
|
||||
the variables on M2/M3 (no progress loss, but the giver may answer with a
|
||||
running branch), (c) don't restart quests whose needed NPCs are gone and
|
||||
leave them failed. Recommendation: (a) for NPCs the quest's dialogs or
|
||||
death labels name, else (c).
|
||||
6. **G4 duplicates:** before re-running `QIS_OnEnter`, delete the quest's
|
||||
still-alive named NPCs/objects it is about to create.
|
||||
7. **G5/G7** (154, 301, 1502, 5005): M3 skips quests whose labels `DelNPC`
|
||||
their giver; M1 skips quests without a giver.
|
||||
8. **G9/G12/G13** (803, 9003/9004 `goodies`, 7614, 10260, named ids): lower
|
||||
priority; G12 needs the accept dialog put back (it is in the giver's spawn
|
||||
instruction or `QIS_OnSetUp`).
|
||||
|
||||
## Fixes (2026-10-30)
|
||||
|
||||
Request: "all, but leave 1, as i cant play for now" (2-8 above; G8 waits for a
|
||||
measurement in the game). Not yet tested in the game.
|
||||
|
||||
- `mods/questscript.cpp` (new) reads the script code once and keeps, per
|
||||
quest: the giver (spawned with the accept dialog, else given it by an NPC
|
||||
state instruction: the Underworld's `QIS_OnSetUp<id>`), its variables (set
|
||||
only by its labels, plus the one named after it: G9), what `QIS_OnEnter<id>`
|
||||
creates, the NPCs it needs (its labels set their state, don't create them),
|
||||
whether it morphs or deletes its giver, and the failure labels that end its
|
||||
story (`LoseQuest` with `SetUpQuest`/`ActivateQuest`, experience or gold, a
|
||||
call into another quest's label, or an NPC offering the quest turned hostile
|
||||
or set to 0 hit points: 1701 `ende1701`, 1703, 2402, 2403, 2502, 2504, 5003,
|
||||
9520). It also keeps the labels running (for `Quest_End`) and spawns NPCs
|
||||
once their bodies are gone.
|
||||
- `questfail.cpp`: at `Quest_Trigger` (first time) the quest's variables are
|
||||
kept ("questVariables" with the hero). A failed quest stays failed when it
|
||||
is a generic quest or a copy (G2), has no giver (G7), its failure ends the
|
||||
story (G11) or its giver / an NPC it needs is gone without a spawn
|
||||
instruction (G5/G6). Else the restart also: resets its variables to the kept
|
||||
values (G1; without them: 1 for the one named after the quest, 0 else),
|
||||
deletes what `QIS_OnEnter` created (G4), spawns dead needed NPCs and the giver
|
||||
again (G6, from region start labels, another quest's label or
|
||||
`QIS_OnSetUp`), and gives a living giver the accept dialog again (G12).
|
||||
After a load, failed quests restarted earlier get their variables reset
|
||||
(repairs saves stuck by G1).
|
||||
- `questgiver.cpp` uses the same scan; the load repair skips quests that
|
||||
delete their giver (G5); the giver comes back through `questfail::Restart`.
|
||||
- Not done: G8 (fix 1), G3 for progress held in one-time objects (2203's
|
||||
barrels), G10, G13 (named ids such as `res:AUFTRAGGEBER_10254`: the game's
|
||||
name table is unknown, those givers can't be checked or respawned).
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
# Quests
|
||||
|
||||
## Quest data (measured 2026-10-17 / 2026-10-25)
|
||||
|
||||
- Quest table `g_quests` (vector begin/end `0xaa9e98` / `0xaa9e9c`), 0x124
|
||||
bytes per quest: id +0, internal name +4 (ANSI), scripts +0x10c (run on
|
||||
trigger), +0x114 (one-time, cleared after it ran), +0x118 success end,
|
||||
+0x11c failure end (only quests with one can fail), flags +0x120:
|
||||
1 triggered, 2 done, 4 in progress, 8 busy (while the trigger runs its
|
||||
scripts). `game/engine/quests.h`.
|
||||
- Quest manager: static object `0xaaaf00` (script interpreter). Quest book
|
||||
vector +0x424..+0x428, plain 0x174-byte entries (serialized field by field):
|
||||
state +4 (2 active, 100 solved, 101 failed), quest id +8, map position
|
||||
+0x14..+0x20; per player (16) a selected entry index at +0x3a8 + player * 8.
|
||||
- Script opcodes (keyword table `0x452540`): LoseQuest `0x36` and ExitQuest
|
||||
`0x0f` -> `0x48f110` -> `Quest_End` `0x46ced0` (thiscall (index, ?,
|
||||
success), ret 0xc; LoseQuest success 0, ExitQuest 1). QuestDone `0x0a` tests
|
||||
flag 2, QuestInProgress `0x0c` flag 4.
|
||||
- `Quest_Trigger` `0x46de10` (cdecl (index, ?, ?, ?), plain ret): does
|
||||
nothing if flag 1 is set; else sets 1 | 8, runs +0x114 (then cleared) and
|
||||
+0x10c, clears 8. Talking to a giver triggered a quest (flags 0 -> 1, no
|
||||
book entry yet); accepting it set flag 4 and added a book entry, state 2.
|
||||
- `Quest_End`: if the quest is in progress and not done, runs the end script
|
||||
(success / failure) and sets done (flags 3); the book entry's state becomes
|
||||
100 / 101. Quests with ids 2600..3599 (the generic "Bringe NPC zu ...",
|
||||
"Eskortiere ...", ...) are copied to the end of the table under a new id
|
||||
(largest + 1, at least 10000): the game repeats those itself.
|
||||
- Compiled quest scripts: `bin\sgf.bin` (labels such as `LoseQuest_10255`,
|
||||
texts `res:...`; random side quests use `DQ_` names, time limits
|
||||
`DQ_ZEITFENSTER_TAGE`).
|
||||
- Example (Gladiator, Silber, 2026-10-25): failed Hetzjagd 1502, Pony Express
|
||||
5005, Rette Jungfrau 2504, Eskortiere Händler zu geheimen Treffen 1403, all
|
||||
flags 3, book state 101.
|
||||
|
||||
## Failed quests restartable (request 2026-10-17)
|
||||
|
||||
A failed quest is not final: talking to the quest giver again offers it
|
||||
again. `mods/questfail.cpp`: after `Quest_End` with success 0 (its failure
|
||||
script has run) the quest's flags 2/4/8 are cleared and flag 1 is kept
|
||||
(book entry: see below); quests failed before the mod are reset once
|
||||
after loading. Confirmed in the log (the four quests above reset, 2026-10-25);
|
||||
the giver offering them again is still to be tested in the game.
|
||||
|
||||
2026-10-26: the restart first cleared flag 1 as well. Measured: a quest's
|
||||
script labels, its giver's dialog too, run only while flag 1 is set
|
||||
(Script_RunLabel `0x46bf11`; "Dialog faild" in the game log otherwise), and
|
||||
accepting it (opcode 0x14, `0x48f320`) needs it too; the game sets it when the
|
||||
hero enters the quest's region (`Quest_Trigger`). A restarted quest is now
|
||||
"available": flags 1.
|
||||
|
||||
2026-10-30 (quest audit, `docs/quest-audit.md`): a restart also resets the
|
||||
quest's variables, deletes what its `QIS_OnEnter` created, brings back the
|
||||
giver and the NPCs it needs, and gives the giver the accept dialog again; some
|
||||
failures stay final (generic quests, no giver, story endings, NPCs gone for
|
||||
good). The script facts come from `mods/questscript.cpp`.
|
||||
|
||||
Marker back to the giver (request 2026-10-25: "quests that failed, should
|
||||
have the blue marker point back to the quest giver"):
|
||||
|
||||
- Measured: the maps draw a marker for every book entry at its compass cell
|
||||
+0x18/+0x1c, colour from `0x4975d0` (thiscall (entry, kind), ret 8): open
|
||||
side quest `ff006cff` (blue), main `ffffff80`, solved green, failed red.
|
||||
The compass target +0x10/+0x14 is set by the scripts' QuestkompassPos
|
||||
(`0x49a7a0`: a cell, radius +0x20 "rad:") and QuestkompassOBJ
|
||||
(`0x49af70`: 0xffffffff + object id, or 0xeeeeeeee + script NPC id);
|
||||
`0x4a6740` resolves it into +0x18/+0x1c. The game keeps no giver per quest.
|
||||
- A quest is accepted in a dialog answer (`cDialogManager::Answer` 0x49a090 ->
|
||||
quest script -> `QuestManager_AddBookEntry` 0x46c450). The mod keeps the map
|
||||
cell of the NPC nearest to the hero then (within 400) as the giver, saved
|
||||
with the hero data (`questGivers`: quest id -> cell, merged from save game
|
||||
and hero file; quest ids and givers are the same in every world).
|
||||
- On failure the book entry stays (state 101) with its compass on the giver
|
||||
and is drawn blue; without a known giver it is removed as before (all
|
||||
quests that failed before this change). Accepting the quest again removes
|
||||
the failed entry.
|
||||
|
||||
## Quest book (measured 2026-10-25)
|
||||
|
||||
- Book entries (quest manager +0x424, 0x174 bytes): kind +0 (3 main quest,
|
||||
4 side quest: ids 1..99 are main quests, 0x46c450), state +4, quest id +8,
|
||||
title / text ids +0x24 / +0x28, progress log text ids +0x2c.., map position
|
||||
+0x18 / +0x1c (0x497830), chapter +0x16c (u16). A new entry (0x46c450,
|
||||
thiscall (quest index, ?), ret 8, called by the script interpreter
|
||||
0x48f602) copies its chapter from the player's current main quest entry
|
||||
(+0x3a4 + slot * 8; selected entry +0x3a8 + slot * 8, read by 0x49dd40).
|
||||
- cUI_Book (one per book mode, 0x7a8[mode] of the book window; quest mode
|
||||
+0x166 = 0): list builder 0x6b04c0 (thiscall, from the rebuild 0x6b1670),
|
||||
page-full check 0x6b1a70 (thiscall (tab, page), ret 8), render 0x6b27f0
|
||||
(thiscall (renderer, bookmarks only), ret 8: the left page's rows, then the
|
||||
selected row's detail record on the right, 0x6b1cd0 thiscall (renderer,
|
||||
record, sub-page), ret 0xc), events 0x6b3620 (thiscall (event), ret 4).
|
||||
Current tab +0x15e, page +0x162; per tab pages +0x97c + tab * 0xc (0x1c
|
||||
bytes: title text +0, selected row +4, rows +0x10) and detail records
|
||||
+0x9d0 + tab * 0xc (0x40 bytes). Row (0x14 bytes): u16 flags (4 spacer,
|
||||
0x40 done, 0x80 entry flag 1), title +4, detail record +8, book entry +0xc,
|
||||
icon +0x10. Act tabs: chapter text table 0x9de7e4 (9000 "Pfade des
|
||||
Schicksals", 9001 "Unter blutigen Bannern", 9002 "Die rastlosen Toten",
|
||||
9003 "Zeitenwende"); bookmarks: 7 controls at +0x168, 0xbc apart.
|
||||
- The book's list and the quest scripts run on the same thread (logged).
|
||||
- Quest table (Gladiator, Silber): 28 main quests, 87 side quests in id
|
||||
blocks of 100 (1xx .. 25xx, 2 to 7 each; their accepted quests lie together
|
||||
on the map), about 120 other side quests (5xxx, 6xxx, 9xxx, 102xx, 15xxx),
|
||||
352 generic ones (2600..3599) and bookkeeping entries (0, 99 "MP-Start",
|
||||
5081..5089 "Region 1 befreit").
|
||||
|
||||
## Quest book as one list (request 2026-10-25)
|
||||
|
||||
"Show all quests, unknown ones as ???, so a player knows how many quests an
|
||||
area holds"; then: "get rid of the tabs ... a section for story, and then a
|
||||
section per region", the game's page turning kept. `mods/questbook.cpp`: the
|
||||
game's builder runs on a reordered copy of the book (story, then a section
|
||||
"<region> (known/all)" with "???" rows, other side quests joined to the
|
||||
nearest area, "Unentdeckt" counts), all in the first tab; rows are mapped
|
||||
back to the real entries afterwards (ours to none, which clicks ignore). The
|
||||
bookmarks are hidden, pages titled "Quests", each page selects its first
|
||||
quest, a click on a heading keeps the selection. Counted: side quests whose
|
||||
name is unique in the table.
|
||||
|
||||
Area names (measured 2026-10-25): an area is an id block (1xx .. 25xx); its
|
||||
name is the game's name of the region most of its known quests lie in, as the
|
||||
megamap names the hero's region: book cell +0x18/+0x1c (x 53.66563 = world
|
||||
units) -> `World_SectorAt` 0x634970 -> `Sector_Region` 0x622e30 ->
|
||||
`Region_NameKey` 0x425370 -> text (`game/engine/regions.h`). Several blocks
|
||||
lie in one named region (regions 1, 2, 3 are all `UI_REGION_SOUTHKERN`), so
|
||||
blocks with the same name make one section; blocks without a known position
|
||||
are "Gebiet N".
|
||||
|
||||
"Erledigte anzeigen" (request 2026-10-25: "some kind of 'Show completed'
|
||||
checkmark. If unchecked, completed quests are not in the list (as well as
|
||||
completed regions)"): the list's first row, an own entry whose state draws the
|
||||
solved mark (100) when on and no icon (0) when off; its row carries the marker
|
||||
entry 0xfffffffe. A click toggles it (`sacredK.ini` `[questbook]
|
||||
ShowCompleted`, default 1) and calls `cUI_Book::Rebuild` 0x6b1670 (thiscall,
|
||||
no arguments; keeps tab and page if the page still exists, else ours clamps
|
||||
it). Off leaves out solved entries (main and side) and areas whose quests are
|
||||
all known and solved; failed quests stay (restartable).
|
||||
|
||||
## Quest NPCs after the quest (request 2026-10-25)
|
||||
|
||||
Request: NPCs related to a quest are drawn white on the minimaps and turn
|
||||
towards the player; once their quest is finished they should be drawn blue
|
||||
and stop turning, like other NPCs. `src/mods/questnpc.cpp`.
|
||||
|
||||
Measured:
|
||||
|
||||
- The zoomed map view (0x6cc950, call at 0x6cec6b) draws a creature white
|
||||
when `cCreature::IsQuestNpc` (0x54fa30) is true: creature flags +0x200 bit
|
||||
0x100 / 0x200 (escorted NPCs, set by the quest manager 0x46ae54..), or a
|
||||
dialog +0x245 in 1..59999 whose type (`cDialogManager::DialogType`
|
||||
0x49a180, record +0x48 of the 0x50-byte records at manager 0xaaaf00
|
||||
+0x755c, name at record +4) is below 50.
|
||||
- Some quests move their giver on to dialog 0 at the end
|
||||
(`DLG_15074_START` type 10 → `_OFFEN` 126 → `_ZIEL` 11 → 0); many leave it
|
||||
on the quest's dialog: `auftrag10`, `auftrag2411`, `AuftragEnde202`,
|
||||
`auftrag303` kept their dialog with the quest done (flags 3, book entry
|
||||
solved).
|
||||
- The turning, found with a hardware write watch (DR0) on the facing +0x70:
|
||||
the idle action (`cCreature::IdleAction` 0x544700, Think's action 0
|
||||
handler) turns every creature with a dialog +0x245 in 1..99999 towards the
|
||||
local hero each Think step (0x546334..0x5463d5, `cCreature::SetDirection`
|
||||
0x44df60 → 0x44ddc0), unless its type is of kind 4 in the type table
|
||||
(`TypeTable_IsKind4` 0x4265c0, call at 0x546357) or object flag +0x14 bit 2
|
||||
is set. So every NPC with a dialog turns, not only quest NPCs.
|
||||
|
||||
- Script labels (vector 0xaa9688..0xaa968c, 0x54 bytes each; quest scripts
|
||||
and dialog answers are indices into it, run by `Script_RunLabel` 0x46bd80):
|
||||
name +0, the quest the label belongs to +0x48 (-1 none; the label only runs
|
||||
once that quest is triggered, 0x46bf11). Each dialog has a label
|
||||
`Dialog:<dialog name>` (logged 2026-10-25: `Dialog:rg1_goodie1_auftrag`
|
||||
9004 Vermisstes Kind, the Aufgeregter Bauer; `Dialog:auftrag_wueste` 9000;
|
||||
`Dialog:auftrag11a1` 1101; `Dialog:DLG_15003_ZIEL` 15003;
|
||||
`Dialog:geschlossen101` none).
|
||||
|
||||
The quest of a dialog is its label's quest (`game/engine/quests.h`, script
|
||||
labels), looked up by the dialog record's name; cached per dialog. A first
|
||||
version read the quest id from the numbers in the dialog's name; that missed
|
||||
dialogs without one (the Aufgeregter Bauer's `rg1_goodie1_auftrag`, user
|
||||
report 2026-10-25) and misread others (`auftrag11a1`). When that quest
|
||||
exists, is done (flag 0x2) and not in progress (0x4):
|
||||
|
||||
- the hook on `IsQuestNpc` returns false (blue on the minimap);
|
||||
- the type check call at 0x546357 is redirected to a thunk that also answers
|
||||
true, so the idle action skips the turn.
|
||||
|
||||
Dialogs without a quest (healers, closed shops: `geschlossen101`,
|
||||
`heilen_global`) stay as the game has them.
|
||||
|
||||
## Quest scripts (measured 2026-10-26)
|
||||
|
||||
- Code: `binNetScriptFunkCode.bin` (`binAddonNetScript` differs only past
|
||||
0x9a653), open as the quest manager's FILE* +4 (`0xaaaf04`). Instructions
|
||||
{u16 opcode, u16 size with the header, args}; args tagged: 0x01 string,
|
||||
0x0b int32, 0x09 dialog, 0x05 label (e.g. a death label), ... Jump table of
|
||||
the executor `0x475970` at `0x4787b0` (opcode * 4).
|
||||
- Labels: the script label table (`0xaa9688`, 0x54 bytes; from
|
||||
`Vectoren.bin`): name, +0x40 code offset, +0x44 code size, +0x48 quest.
|
||||
`Script_RunLabel` `0x46bd80` (cdecl (label, object, flag)) reads the code
|
||||
and runs it with `0x475970` (quest manager thiscall (code, &offset, quest,
|
||||
object, flag, 0), ret 0x18; any memory buffer with offset 0 works).
|
||||
- Opcodes seen: 1 spawn ("Res:<script id>", type, position, 0x09 dialog,
|
||||
0x05 death label), 3 NPC mode (0x09 dialog, 0x0a follow + death label),
|
||||
4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named
|
||||
position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if
|
||||
(0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f
|
||||
compass, 0x43 / 0x44 / 0x45 / 0x4b variables (see below), 0x48 move NPC,
|
||||
0x56 dialog state.
|
||||
- Script NPC names: "Res:<n>" is a script id (object +0x3c, `0x44a390`); the
|
||||
quest manager maps it to the object (`0x464ec0`, map +0x7668); a spawn sets
|
||||
the entry.
|
||||
- Example "Jagd auf die Wilderer" (1501): `Region15Init` spawns the elf
|
||||
(Res:17013, type 134, dialog auftrag1501); accepting (`trigger1501`: 0x14)
|
||||
runs `QIS_OnEnter1501`: poachers with death label `tot1501`, the elf follows
|
||||
with death label `ex1501` (variable "1501" = 1, then `tot1501`); `tot1501`
|
||||
once all poachers are dead: variable 0 -> back to the elf, 1 -> quest ended
|
||||
without the reward. "Hetzjagd" (1502): the daughter (Res:17015, the giver)
|
||||
is spawned only when the hero walks into trigger area `hetz1_1502`, with
|
||||
three cultists; her father (Res:17016) only asks for her.
|
||||
|
||||
## Escorts on the follower list (request 2026-10-26)
|
||||
|
||||
"Furchtbares Verbrechen" (9008) did not go on at its target point: the
|
||||
script's test there ("@npc", `0x498aa0` case 0x40) looks the NPC up in the
|
||||
hero's follower list (+0x39c, 9-byte entries), which was empty after a load,
|
||||
while the NPC still had its leader (+0x251) and follow bit (+0x1f4: 4 for the
|
||||
follow order, 0x100 for script escorts). `mods/followers.cpp` adds such NPCs
|
||||
back with `cCreature::AddFollower` `0x54b070` once after loading. Town NPCs
|
||||
that run after the hero for a few seconds (status 0x404) are left out.
|
||||
Confirmed 2026-10-26.
|
||||
|
||||
## Quest givers come back (request 2026-10-26)
|
||||
|
||||
"Change the death script to spawn her back to her starting point", and "on
|
||||
load we need a repair ... if not completed or correctly running".
|
||||
`mods/questgiver.cpp`: a quest's giver is the NPC whose spawn instruction
|
||||
carries a dialog with an answer that accepts the quest (85 givers). When a
|
||||
label of a running quest runs for its dead giver, it is skipped, the quest
|
||||
restarts (failed entry at the giver, the variables only its labels set back:
|
||||
0, the one named after the quest 1), and once the body is gone the spawn instruction runs again. After a load,
|
||||
an accepted quest (flag 4) without its giver gets the same. Not yet seen in
|
||||
the game (the escort of "Hetzjagd" arrived alive); see `docs/todo.md` for the
|
||||
audit of how quests can fail.
|
||||
|
||||
Lesson (2026-10-26): flag 1 means available, not running; a first version
|
||||
took it for running and reset 16 available quests and spawned their NPCs on
|
||||
Borf (repaired once, then saved).
|
||||
|
||||
Givers killed as enemies (2026-10-30): "Familienbande" (702, book title
|
||||
"Mädchen bei Ausritt verschwunden") was restarted twice when the hero killed
|
||||
its giver. The quest wants that: back at the giver with the girl
|
||||
(Res:17060), answer `change702` changes the creature type of the giver
|
||||
Res:17061 (668 -> 262) and two relatives (opcode 0x4f {"Res:<id>", 0x02
|
||||
type}), they attack, and their death label `tot702` sends the hero back to the
|
||||
girl (`ziel702`, `exit702` ends the quest). Now a giver hostile to the hero
|
||||
at death runs the label as the game has it, and the load repair skips quests
|
||||
whose labels change their giver's type (0x4f on the giver; 2 of the 85 known
|
||||
givers, 702 among them). Confirmed in game 2026-10-30.
|
||||
|
||||
## Quest variables are bit fields (measured 2026-10-28)
|
||||
|
||||
Found while repairing "Kräuter für Kräuterhexe" (402; the user knows it as
|
||||
Windgeflüster). Variables live in the quest manager's vector +0x7550 /
|
||||
+0x7554 (0x24-byte entries: name[0x20], value +0x20; `game::ScriptVariable`).
|
||||
Script writes: 0x43 sets the value, 0x44 sets bit n (0x49bb30: value |= 1 <<
|
||||
n), 0x45 clears bit n (0x49c450), 0x4b adds n (0x49cc20). Conditions
|
||||
(`Script_Condition` 0x498aa0): 0x49 bit n set, 0x4a bit n clear (logged "if
|
||||
IsNotVarBit"), 0x48 value == n; a missing variable is false. The variable
|
||||
named after a quest has bit 0 while the quest is available: all 87 quests not
|
||||
yet accepted in a dwarf save had value 1, and nothing in the quest's own labels
|
||||
sets it. Quest 402: accepting sets bit 1, the berries bit 2, the reward bit 3;
|
||||
the giver's dialog offers the quest on bit 0.
|
||||
|
||||
The quest giver restart first ran 0x44 with 0 ("variable = 0"), which set bit
|
||||
0 and cleared nothing: the giver kept saying "Zögerst du in deinen Taten?".
|
||||
A reset to 0 then matched no dialog branch at all. Now the value is written
|
||||
directly: 0, or 1 for the quest's own variable. Confirmed 2026-10-28.
|
||||
|
||||
## Main quest items refused (request 2026-10-28)
|
||||
|
||||
"I cannot finish the Quest Windgeflüster." The berries (type 6029) are an item
|
||||
`Region4Init` places once on the ground near the Goldbeerenbusch (op 0x5e,
|
||||
pickup label `take402`); picking it up logged "cInventoryEntry::addMainQuestItem()
|
||||
Already has mainQuestItem(6029)" and the item was gone.
|
||||
`cInventoryEntry::AddMainQuestItem` (0x5d8240, thiscall (type, script id,
|
||||
notify)) keeps {type, script id} pairs and refuses a new item when an entry
|
||||
has the same type or script id 0 (0x5d83c6); scripts that give an item
|
||||
without a "Res:" id add it with 0 (0x486bd8). The dwarf held {4609, 0}, so
|
||||
every later main quest item was refused (a bug of the original).
|
||||
`mods/questitems.cpp` removes the script id test. The save was repaired once
|
||||
by running `take402` for the hero after the load (quest accepted, bit 2
|
||||
missing); the quest then finished. Confirmed 2026-10-28.
|
||||
|
||||
## Escorts never left behind (2026-10-26)
|
||||
|
||||
"I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no
|
||||
longer following me" (Borf). Measured in the log: the husband (Res:17584,
|
||||
type 86; spawned in `startcode.bin` with death label `drop2412`) fell behind,
|
||||
the game's own catch-up teleport moved him once (type 86 line), then the hero
|
||||
walked on, the sectors behind were switched off, and the game removed him
|
||||
and ran `drop2412` (quest variable 5, a token item res:17585 dropped where he
|
||||
was; picking it up runs `take2412`, the "husband lost" branch). His object id
|
||||
was reused right after: removed, not dead. The teleport potion came 50 s
|
||||
later and was not involved; portals and the potion take followers along.
|
||||
|
||||
`mods/followers.cpp`: every 500 ms each follower of the local hero more than
|
||||
40 cells away (sectors are switched off at 65 and more) on the same level is
|
||||
teleported to the hero with `cCreature::Teleport` 0x54d840 (thiscall (cell x,
|
||||
cell y, 0, 1), ret 0x10). Borf's husband was respawned once by a temporary
|
||||
repair (his spawn instruction, variable 1, token removed, `follow2412`); the
|
||||
quest then finished with him alive.
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# 16:9 menu screens
|
||||
|
||||
Request (2026-10-20): the game's menu screens are laid out for 1024x768; we
|
||||
design our own UIs for them. There is no 16:9 artwork for the game, so the
|
||||
background is our own, and the approach is shared by all menu screens. The
|
||||
background (user, 2026-10-20: "textured dark", the flat gradient looked
|
||||
black): the options window's rock wall, `GUI_OPTION_BACKGROUND<row><column>.TGA`
|
||||
(4 x 3 tiles of 256), scaled to cover the render target, tinted down,
|
||||
shaded towards the bottom and the sides. Game textures by name:
|
||||
`game/ui/texture.h`, drawn with `game::DrawTextured` (game/ui/draw.h). Target: 16:9, 1920x1080, which is
|
||||
1365x768 in UI units; wider screens only get more background at the sides
|
||||
(not designed for ultrawide).
|
||||
|
||||
## How a screen works
|
||||
|
||||
- `mods/screen.{h,cpp}`: a screen replaces a full-screen window's drawing and
|
||||
mouse handling. Its render hook calls `screen::Render(window, device, body,
|
||||
context)`: the background across the whole render target, then the body in
|
||||
canvas coordinates (1365x768, centred: `hud::RenderCanvas`, the HUD
|
||||
layer's offset machinery with `Anchor::Canvas`; drawing, the cursor and the
|
||||
window's hit tests shifted alike). Mouse events go through `screen::Handle`
|
||||
(`hud::HandleCanvas`). Keys stay the window's.
|
||||
- The main menu thread renders the full width (no 4:3 frame any more).
|
||||
- Parts: `screen::Panel` (dark backdrop and the game's frame, `cUI_Frame`,
|
||||
game/ui/frame.h, made anew when its rect changes), `screen::Button` (a
|
||||
button of the game's with the look of one of the window's,
|
||||
`game::NewButtonLike`), `screen::Text`, `screen::Fill`,
|
||||
`screen::DoubleClick`. Spacing constants from measurements: frames reach
|
||||
0x20 beyond their rects and need a height of about 0x40, buttons reach 0x21.
|
||||
Frames are built of 64-unit pieces: widths and heights in steps of 64
|
||||
(`screen::kFrameStep`), other sizes leave gaps (screenshot 2026-10-20).
|
||||
- Fonts (logged 2026-10-20): font 0 is another class (no line height field);
|
||||
the TrueType fonts 1..7 have line heights 20, 19, 25, 31, 25, 28, 13.
|
||||
Screens use font 1; 4 or 6 would suit headings.
|
||||
- Inside the canvas scope the HUD layer does not stretch "full 1024 layout"
|
||||
quads (`g_canvasScope`).
|
||||
|
||||
## Main menu frame (2026-10-20)
|
||||
|
||||
Request: the background on all screens, the main menu's and the options
|
||||
window's own pictures removed.
|
||||
|
||||
- cUI_Manager's UI render (`0x7587b0`, hooked in mods/screen.cpp) on the
|
||||
menu thread: the background first, then `hud::RenderMenuFrame` runs the
|
||||
render with drawing shifted by (render width - 1024) / 2 and a viewport on
|
||||
the frame (3D parts). Windows inside draw in frame coordinates (their own
|
||||
HUD scopes are nested, so they do nothing).
|
||||
- Mouse positions are given to the game in frame coordinates: the
|
||||
borderless mod's `ToGame` subtracts `hud::MenuFrameOffsetX`. The cursor
|
||||
sprite (`cMouseCursor::DrawItem` 0x6555e0, drawn after the UI) is shifted
|
||||
by drawing only.
|
||||
- While a 16:9 screen is shown the frame is not shifted (offset 0): the
|
||||
screen draws itself across the width.
|
||||
- Full-screen windows in a game (options, `hud::RenderFramed`) get the
|
||||
background beside the frame instead of black.
|
||||
- The windows' pictures are left out by texture: the HUD layer tracks the
|
||||
texture bound to stage 0 (`SetTexture`, device vtable 35) and skips quads
|
||||
drawn with a hidden one (`hud::SetHiddenTextures`). Logged on the menu
|
||||
thread (texture ids requested from the cache, by caller; names by trying
|
||||
the names in the texture pak): main menu GUI_UW_OPTION1..12 + UI_MAIN_SKY,
|
||||
options UW_OPTION_1_01..12. This install uses the Underworld versions; the
|
||||
Ancaria ones (GUI_OPTION1..12, ...) were not seen and are not listed.
|
||||
- The main menu's 3D scene (sky UI_UW_MAIN_CENTER, water with the SACRED
|
||||
logo UI_MAIN_REF; render 0x75e620 on the scene object 0x75df10) is called
|
||||
from cUI_MainMenu::Render (0x7122c0) at 0x712bd9; that call is redirected
|
||||
to an empty function. cUI_Character (0x70f41b) still draws it.
|
||||
- Logo top left (2026-10-21), 48 from the edges, 124 high: FX_SACREDLOGO.TGA,
|
||||
the emblem (horns around a ring of runes) glowing blue on black without
|
||||
alpha, drawn added (blend ONE/ONE, `game::DrawTexturedAdded`) as the
|
||||
game's effects draw it. Other candidates: GUI_MAIN_LOGO (plain golden
|
||||
lettering), SACREDSYMBOL01/02 (model atlases), the emblem on the loading
|
||||
pictures (PAK\loading*.bmp, not in the paks). Screens may overlap it.
|
||||
|
||||
## Screens
|
||||
|
||||
- Spielen (the load window, cUI_Savegame): docs/characters.md, "Spielen
|
||||
screen".
|
||||
|
||||
## Fixes (2026-10-21)
|
||||
|
||||
- The main menu came back unframed (old pictures, frame at the left) after a
|
||||
game: the set of shown 16:9 screens kept a window of the in-game UI that
|
||||
was destroyed while shown. Now only screens drawn on the menu thread count,
|
||||
and an entry expires after 2 menu frames without being drawn
|
||||
(`screen::BeginMenuFrame`).
|
||||
- Dialogs over a 16:9 screen in the main menu (e.g. "Spielstand laden?") sat
|
||||
at the left with the dimming 1024 wide: `hud::OffsetFor` gave every window
|
||||
offset 0 while the menu showed. Now only while the menu frame is drawn
|
||||
centred (it shifts the windows itself); otherwise dialogs are centred and
|
||||
their full-layout dimming stretched, as in a game.
|
||||
|
||||
## Load / save progress screen (2026-10-25)
|
||||
|
||||
Request: the progress screen shown while loading / saving should use the
|
||||
menu screens' background. The game's screen (`DxDriver7::LoadingScreen`
|
||||
`0x6469d0`, thiscall (progress, text)) draws with GDI into the back buffer's
|
||||
DC (surface +0xbc, GetDC vf 0x44 / ReleaseDC vf 0x68): the 1024x768 picture
|
||||
(HBITMAP +0x200) at 0, 0, the bar (+0x204 / +0x208) and the text at y 700,
|
||||
then Flip; it redraws only when the bar moves. Until now it was presented as
|
||||
a 1024x768 "classic" frame scaled to the monitor.
|
||||
|
||||
`mods/loadingscreen.cpp`: before the game's drawing, `DrawBackground` across
|
||||
the whole render width in a scene of its own (`DxDriver7` BeginScene
|
||||
`0x646f50` / EndScene `0x646fd0`); the game's GDI drawing is moved to the
|
||||
centred 1024 frame with `SetViewportOrgEx` on the back buffer's DC, caught in
|
||||
`CreateCompatibleDC(hdc)` which the game calls right after getting it. Every
|
||||
frame is now presented at the full render width (borderless.cpp has one
|
||||
layout).
|
||||
+18
-2
@@ -4,7 +4,8 @@
|
||||
|
||||
The chest gets a second tab that belongs to no single hero: a chest shared by
|
||||
all characters. Decided: stored in one file in the save folder
|
||||
(`save\shared_chest.json`), two tabs ("Truhe" / "Gemeinsam"), same size as the
|
||||
(`shared_chest.json` in the save folder, `Documents\Sacred\save`, see
|
||||
`user-folders.md`), two tabs ("Truhe" / "Gemeinsam"), same size as the
|
||||
chest (20 x 10). Potions stack in it like in every player inventory
|
||||
(docs/item-stacks.md). Follow-up (same day): text tabs were barely visible;
|
||||
use the inventory's Rucksack / Kampfkünste / Combos header look with its
|
||||
@@ -55,7 +56,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
|
||||
@@ -69,3 +70,18 @@ was empty after loading another character (in-game saves don't call saveHero).
|
||||
- loadHero's extra fix-up for some item classes (`0x5beaa0` / `0x5d3a20`,
|
||||
item classes 5, 6, 8, 0xd, 0x11..0x19, 0x1b, 0x1d) is not repeated.
|
||||
- Multiplayer: not supported.
|
||||
|
||||
## Items replaced by a world's (fixed 2026-10-21)
|
||||
|
||||
Reported: the shared chest's items were gone, other items (potions, gear) in
|
||||
their cells. The grid holds object ids. A save game loaded into the running
|
||||
engine (Esc "Laden", cEngine::Load from the render loop 0x60e68d) replaces
|
||||
all objects, so until the chest reloads on the next tick its ids name the new
|
||||
world's objects. A hero saved inside that load (characters.cpp Engine_Load: a
|
||||
migrated character's first load makes its world hero the shared hero with
|
||||
SaveHero) wrote the shared chest, i.e. those objects, into
|
||||
shared_chest.json; the next tick loaded them as the chest. Now the chest is
|
||||
emptied and marked unloaded as soon as a save game is read or a game starts
|
||||
(RequestLoad), so no save writes it before it is loaded again. The user's
|
||||
chest was restored from the 2026-10-19 backup (the broken file kept as
|
||||
backup/shared_chest.broken-2026-10-21.json).
|
||||
@@ -189,3 +189,13 @@ only (or disabled in multiplayer).
|
||||
table for the extra controls), layout, rendering, hit tests, drag & drop.
|
||||
6. Keys: 0-6 cast art slots (since 2026-10-15; before: 1-7 select and
|
||||
cast), F1-F7 select weapon sets.
|
||||
|
||||
### Hero files: items of sets 6-7 (2026-10-25)
|
||||
|
||||
Reported: the Gladiator lost the weapons of sets 6/7 on Silber. saveHero
|
||||
collects the items it writes (chunk 0xc2) from the weapon sets with
|
||||
`GetWeaponSetItem` over sets 0..4 (`cmp esi, 5` at `0x605046`), so sets 6-7
|
||||
kept ids of items that were never in the hero file; a world that takes its
|
||||
hero from the hero file (Silber without its own hero inventory) had nothing
|
||||
there. The loop bound is patched to 7 (inventory_save.cpp); loadHero's id
|
||||
replacement already covers the shadow slots. Confirmed 2026-10-25.
|
||||
@@ -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.
|
||||
+15
-4
@@ -19,13 +19,24 @@ 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.
|
||||
- The potion is used up (flag, RemoveItem, DestroyObject as UseItem does; a
|
||||
stack loses one) only when cCreature::Teleport is called from the portal
|
||||
case while it is pending; closing the window keeps it.
|
||||
|
||||
## Campaign (2026-10-21)
|
||||
|
||||
The potion showed Ancaria's portals in the Underworld. cInterpretSQW::Teleporter
|
||||
(0x4ae330) chooses the busy dialog's mode by the portal touched: index > 0xd
|
||||
(Underworld) mode 6, else mode 5, both with the hero's known-portal mask
|
||||
(+0x578). The potion now chooses by the running campaign, g_campaign
|
||||
(0x182cdc8, u16: 0 Ancaria, 1 Underworld): mode 6 in the Underworld.
|
||||
+148
-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
|
||||
|
||||
@@ -141,3 +166,121 @@ The hero sometimes plays a walking animation after a save game load while
|
||||
standing still. Not reproduced yet: a load log (hero mode, action, animation
|
||||
fields, orders) showed nothing unusual; right after a load the game itself
|
||||
sends one walk order (0x4) while the hero is idle. Measure again when it shows.
|
||||
|
||||
## Multiplayer with characters (2026-10-19)
|
||||
|
||||
Characters became one file each with a world per difficulty
|
||||
(`docs/characters.md`); vanilla multiplayer picks exported `Heronn.pax`
|
||||
heroes, which no longer exist. Later: give multiplayer the shared hero of a
|
||||
character file (the session's hero file +0x3a3c, as `SelectHeroSlot` does),
|
||||
and decide what a multiplayer game does to the character's progress.
|
||||
|
||||
## Characters: open parts of the rework (2026-10-20)
|
||||
|
||||
From the characters rework (`docs/characters.md`, request of 2026-10-19):
|
||||
|
||||
- **Unlock difficulties by story completion.** Bronze and Silber stay open;
|
||||
difficulty d >= 2 opens when the story on d - 1 is completed. The counters
|
||||
are one byte per difficulty in cStatsManager +0x5704 (read 0x424be0) and
|
||||
travel in the save header (+0x88..+0x98) and the hero file. Done
|
||||
2026-10-20 (docs/characters.md).
|
||||
- **Autosave interval in the options window.** Done 2026-10-20: Allgemein
|
||||
page, "Spielstände" frame (docs/options.md).
|
||||
- Quickload (row 0, `GAME00.PAK`) does not know characters; not needed
|
||||
(user, 2026-10-20).
|
||||
|
||||
## Two saves in a row (2026-10-20, not reproduced)
|
||||
|
||||
Once two real saves ("saved", not "autosaved") went into the same character
|
||||
1.8 s apart, about 77 s after loading, with the timed autosave off. Save
|
||||
flag 0x40 is set in cEngine::ReceiveEvent at 0x618876 (quicksave),
|
||||
0x618f08 and 0x6190c5 (save event 0xf: +0xc row, +0x18 autosave flag). A
|
||||
log of the event that sets the flag (dwords 0..9) did not catch it again;
|
||||
add it back if it shows.
|
||||
|
||||
Once (2026-10-20, not reproduced) an autosave seemed to fire right after a
|
||||
new world started through the hero chain (Character08: hero world loaded,
|
||||
then the new world). The log of that run had no save at all; the timed
|
||||
autosave counts from the game start (OnGameStart).
|
||||
|
||||
## Savegames screen redesign ("Spielen")
|
||||
|
||||
Asked 2026-10-20: redesign the load window (cUI_Savegame, the "Spielen"
|
||||
screen, characters.cpp): show a render of the selected character, use the
|
||||
space far better. No 16:9 artwork exists for the game (user, 2026-10-20):
|
||||
we design our own UIs, using the width beyond the 1024 frame (now drawn in
|
||||
a centred 1024x768 viewport with black sides, hud.cpp FrameScope). Design
|
||||
for 16:9 (1920x1080, 1365x768 UI units), not ultrawide (user). Screen done
|
||||
2026-10-20 (docs/characters.md, "Spielen screen"; docs/screens.md). Hero
|
||||
in 3D done 2026-10-20. Open: list scrolling beyond 9 characters, larger
|
||||
heading font.
|
||||
|
||||
## Fine-tuning the Spielen and options screens (2026-10-21)
|
||||
|
||||
The user is very satisfied with the menu rework for now (rock background on
|
||||
all menu screens, centred menu frame, logo, Spielen screen with the 3D
|
||||
hero), but both the Spielen screen and the options window still need a lot
|
||||
of fine-tuning. Details to be collected when we get to it; known so far:
|
||||
list scrolling beyond 9 characters, larger heading font, the options
|
||||
window's layout in the width beyond its 1024 frame.
|
||||
|
||||
## Quest NPCs after the quest (2026-10-25)
|
||||
|
||||
Request: NPCs related to a quest are drawn white on the minimaps and always
|
||||
turn towards the player; once their quest is finished they should stop
|
||||
turning and be drawn blue (like other NPCs). Done 2026-10-25
|
||||
(docs/quests.md, "Quest NPCs after the quest").
|
||||
|
||||
## Audit how every quest can fail (2026-10-26)
|
||||
|
||||
Request: "at some point we need to check ALL the quests and how they possibly
|
||||
can fail". Context: "Jagd auf die Wilderer" (1501) - the giver's death label
|
||||
`ex1501` sets the quest variable and runs `tot1501`, which ends the quest as
|
||||
solved without the reward once the poachers are dead; her survival is not
|
||||
needed for the quest itself. `src/mods/questgiver.cpp` currently skips a dead
|
||||
giver's label, restarts the quest and respawns the giver. Plan: from
|
||||
FunkCode.bin (label table in Vectoren.bin, 0x54-byte records: name, +0x40
|
||||
code offset, +0x44 size, +0x48 quest; instructions {u16 op, u16 size, args})
|
||||
list per quest: death labels given to NPCs (spawn / follow instructions with
|
||||
a 0x05 label), the labels that end it (opcode 0x0f) or lose it, and which
|
||||
NPCs must survive (escorts). Then decide per kind: respawn the giver only,
|
||||
or restart the quest.
|
||||
|
||||
## Quest giver death rule untested (2026-10-26)
|
||||
|
||||
`src/mods/questgiver.cpp` restarts a running quest whose giver died and
|
||||
spawns the giver again at her start (docs/quests.md, "Quest givers come
|
||||
back"). Built and checked against the scripts, not yet seen in the game: test
|
||||
with an escort whose giver dies (e.g. "Jagd auf die Wilderer" on the
|
||||
Gladiator, or "Hetzjagd"). Borf's case (the quest ended without the reward)
|
||||
was repaired once by hand.
|
||||
|
||||
## Inventory drawn without its dark backgrounds (2026-10-25, intermittent)
|
||||
|
||||
Reported twice: on opening the inventory the item list had no dark
|
||||
background behind the grid, and the tab texts had an odd background colour.
|
||||
Looks like render state (texture / blend / colour) left behind by an earlier
|
||||
draw; not reproduced yet. To find out: what was open or shown just before.
|
||||
|
||||
Possibly related (2026-10-25, once): right after a game start the main menu
|
||||
texts were drawn as solid gold blocks (the gold text texture without the
|
||||
font's letter mask). Gone after a restart; Alt+Tab does not cause it. The
|
||||
log showed nothing unusual (all hooks and patches applied).
|
||||
|
||||
## Frame drops while walking (2026-10-27, deferred 2026-10-30)
|
||||
|
||||
Reported 2026-10-27. Measured 2026-10-29 (about 54 min, temporary timing in
|
||||
the Flip, engine tick and map border, since removed): 549 frames over 40 ms,
|
||||
mostly 0-1 per 10 s, worst 833 ms. Present (Flip) was slow only once; the
|
||||
time goes to the game's own engine tick (227 ticks of 18-70 ms). Our tick
|
||||
callbacks were slow only at the 10-min autosave. The map border is drawn only
|
||||
while the map is open, so it is not the cause. Next step if resumed: time the
|
||||
parts of the game tick (sector streaming while walking is the main suspect).
|
||||
Deferred by the user 2026-10-30.
|
||||
|
||||
## Eingraben clears slows, roots and stuns (deferred 2026-10-30)
|
||||
|
||||
Step 3 of Eingraben (docs/dwarf-arts.md): cast clears slowing, rooting and
|
||||
stunning effects on the hero. The effect kinds are unknown; plan: temporary
|
||||
log of the effects added to the hero while the user gets slowed or frozen,
|
||||
then remove those kinds on cast. Deferred by the user 2026-10-30.
|
||||
@@ -0,0 +1,55 @@
|
||||
# User folders (Documents\Sacred)
|
||||
|
||||
Request (2026-10-19): the game keeps its save games and settings in the
|
||||
user's Documents folder instead of the game folder:
|
||||
|
||||
| Folder | Contents |
|
||||
|---|---|
|
||||
| `Documents\Sacred\save\` | `GAMEnn.PAK` (save rows), `Heronn.pax` (exported heroes), `shared_chest.json` (sacredK) |
|
||||
| `Documents\Sacred\config\` | `Settings.cfg` (the game's options) |
|
||||
|
||||
`sacredK.ini` (key bindings, loot filter, game folder) stays next to the DLL.
|
||||
|
||||
## Measured
|
||||
|
||||
Vanilla keeps everything inside the game folder: no AppData, Documents or
|
||||
registry use (the `Ascaron Entertainment` folders there belong to Sacred 2).
|
||||
`sacred.exe` names the files with paths relative to the working directory
|
||||
(the game folder), from 37 code sites:
|
||||
|
||||
| String | Address | Users |
|
||||
|---|---|---|
|
||||
| `./SAVE/GAME%.2d.PAK` | 0x95c530 | 0x60b58b, 0x60c0d3, 0x60e38f, 0x60e645, 0x71cda7 |
|
||||
| `SAVE/GAME%.2d.PAK` | 0x95c884 | 0x60e35a, 0x60e60a, 0x619c38 |
|
||||
| `./SAVE/GAMEF%d.PAK` | 0x95c870 | 0x60e3b9, 0x60e674 |
|
||||
| `./SAVE/SAVE02.PAK` | 0x95c85c | 0x60dc2c |
|
||||
| `Save\Hero%.2d.pax` | 0x95c4ec | 0x60bf16, 0x614ceb, 0x70d96c, 0x70dd8d, 0x70df1b, 0x70e091 |
|
||||
| `.\Save`, `Save` | 0x95d5f4, 0x95d5ec | 0x619cd4/ec, 0x61cdc0/d1 |
|
||||
| `SAVE\` | 0x9e15a0 | 0x70bf07 |
|
||||
| `Settings.cfg` | 0xa18dc4 | 0x7ed6a2..0x7eeab3 (13), WinMain 0x816dc0..0x816dd6 |
|
||||
|
||||
All file access goes through kernel32's ANSI functions: `CreateFileA`,
|
||||
`DeleteFileA`, `FindFirstFileA`, `GetFileAttributes(Ex)A`,
|
||||
`SetFileAttributesA`, `CreateDirectoryA`, `CopyFileA` (no `MoveFile*`, no
|
||||
`SetCurrentDirectory`).
|
||||
|
||||
## How the mod works (`src/mods/userfolders.cpp`)
|
||||
|
||||
- Those eight functions are hooked (`RE_HOOK_API`). A path is rewritten when,
|
||||
after an optional game-folder prefix and `.\`/`./`, its first component is
|
||||
`save` (any case) or it is exactly `Settings.cfg`; every other path passes
|
||||
unchanged. One choke point instead of 37 patched operands, and no long
|
||||
absolute path in the game's fixed-size buffers.
|
||||
- The Documents folder comes from `SHGetKnownFolderPath(FOLDERID_Documents)`
|
||||
(follows OneDrive/redirected Documents), looked up on the first redirected
|
||||
path: the hooks are installed under the loader lock, where shell calls must
|
||||
not run. If it is unusable (lookup fails, or a character outside the ANSI
|
||||
code page), the game folder stays in use.
|
||||
- Migration: when `save\` or `config\` does not exist yet, the game folder's
|
||||
save files / `Settings.cfg` are copied there once with the wide file
|
||||
functions (not hooked). The originals stay in the game folder.
|
||||
- `mods::userfolders::SaveDir()` gives sacredK's own files the same folder
|
||||
(shared chest).
|
||||
|
||||
Not covered: `Config.exe` and `gameserver.exe` (`GameServer.cfg`) are
|
||||
separate programs without the DLL and still use the game folder.
|
||||
+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.
|
||||
+6
-3
@@ -67,9 +67,12 @@ Elsewhere (unknown / UI, leave alone for now): `0x40694a`, `0x4ec036`,
|
||||
| `0x006469d0` | loading screen: GDI BitBlt of bitmap + progress bar + text into the back buffer (fixed 1024x768) | render thread |
|
||||
| `0x006a0c60` | video/intro playback (unexamined) | |
|
||||
|
||||
The widescreen mod gives these frames a 1024x768 D3D viewport (set at
|
||||
`BeginScene` when the scene depth `+0x1d8` is 0) and the borderless mod
|
||||
presents only that area, scaled to the monitor.
|
||||
Since 2026-10-25 the loading screen uses the full render width too: the menu
|
||||
background is drawn across it and the game's GDI drawing is centred
|
||||
(mods/loadingscreen.cpp, docs/screens.md "Load / save progress screen"); the
|
||||
1024x768 "classic" viewport and present are gone. The main menu thread renders
|
||||
the full width since 2026-10-20: its 1024x768 frame is drawn centred on the
|
||||
menu screens' background (docs/screens.md, "Main menu frame").
|
||||
|
||||
## Direct3D 7 render target limit
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ constexpr uint32_t GetMouseClientPos = 0x0066e500;
|
||||
constexpr uint32_t MainWindowProc = 0x008122a0;
|
||||
constexpr uint32_t DxDriver7_SetDisplayMode = 0x00645390;
|
||||
constexpr uint32_t DxDriver7_Flip = 0x00645f70;
|
||||
constexpr uint32_t DxDriver7_BeginScene = 0x00646f50;
|
||||
constexpr uint32_t DxDriver7_BeginScene = 0x00646f50; // thiscall, plain ret; nests (+0x1d8)
|
||||
constexpr uint32_t DxDriver7_EndScene = 0x00646fd0; // thiscall, plain ret
|
||||
constexpr uint32_t DxDriver7_LoadingScreen = 0x006469d0; // GDI progress screen, 1024x768
|
||||
constexpr uint32_t UiManager_ThreadLoop = 0x00756940; // main menu render loop (own thread)
|
||||
constexpr uint32_t Engine_RenderThreadLoop = 0x0060e3f0; // in-game render loop
|
||||
@@ -28,6 +29,7 @@ constexpr uint32_t WorldToScreen = 0x00623c40;
|
||||
// --- globals -----------------------------------------------------------------
|
||||
constexpr uint32_t g_dxDriver = 0x00cda99c; // DxDriver7*
|
||||
constexpr uint32_t g_appFlags = 0x0182cdb8; // uint32_t, 0x20000 = inactive
|
||||
constexpr uint32_t g_campaign = 0x0182cdc8; // uint16_t, the running game's campaign: 0 Ancaria, 1 Underworld
|
||||
constexpr uint32_t g_mouseCursor = 0x00cdbadc; // mouse cursor object*: x +4, y +8 (render pixels)
|
||||
|
||||
// --- read-only constants -----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
+38
-6
@@ -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.
|
||||
@@ -70,8 +86,9 @@ constexpr uint16_t kArtHeldenmut = 0x3f7;
|
||||
constexpr uint16_t kArtKlingenblick = 0x3f8;
|
||||
constexpr uint16_t kArtEnergieblitze = 0x3fb;
|
||||
constexpr uint16_t kArtFlammenwerfer = 0x43d;
|
||||
constexpr uint16_t kArtMeteor = 0x43e;
|
||||
constexpr uint16_t kArtKanone = 0x43f;
|
||||
constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
|
||||
constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone)
|
||||
constexpr uint16_t kArtEingraben = 0x39; // spell (gui_moves_eingraben)
|
||||
|
||||
// cCreatureArts fields.
|
||||
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
|
||||
@@ -90,7 +107,7 @@ constexpr uint32_t kRegenGroupCombo = 3; // handled by the combo branch
|
||||
// Arts regenerating in the magic group whatever their entry says
|
||||
// (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze).
|
||||
inline bool InMagicRegenGroup(uint32_t group, uint16_t art) {
|
||||
return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMeteor ||
|
||||
return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMoerser ||
|
||||
art == kArtKanone;
|
||||
}
|
||||
|
||||
@@ -137,6 +154,23 @@ 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;
|
||||
}
|
||||
|
||||
// The level of art `art` in cCreatureArts `arts` (cooldown entry level + bonus
|
||||
// levels), 0 if it has none.
|
||||
inline unsigned ArtLevelIn(uint8_t* arts, uint16_t art) {
|
||||
const uint8_t* entry = FindCooldownEntry(arts, art);
|
||||
return entry ? entry[kCooldownLevel] + entry[kCooldownBonusLevels] : 0;
|
||||
}
|
||||
inline unsigned ArtLevel(void* creature, uint16_t art) {
|
||||
return ArtLevelIn(static_cast<uint8_t*>(creature) + kCreatureArts, art);
|
||||
}
|
||||
|
||||
struct ArtCooldown {
|
||||
float remaining = 0.0f; // in the game's units
|
||||
float total = 0.0f;
|
||||
@@ -148,10 +182,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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
// The explored map (fog of war on the large map and the TAB map), measured
|
||||
// 2026-10-25. docs/map-reveal.md.
|
||||
//
|
||||
// Each sector (64x64 tiles) holds a 32x32 bit mask at +0x180 (one bit per 2x2
|
||||
// tiles, 128 bytes; Sector_SetExplored 0x622bf0, copied out by 0x622c60 and
|
||||
// in from the save by 0x622c80). The zoomed map view builds its fog texture
|
||||
// (FOGOFWAR01.TGA) from it (0x639030).
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace game {
|
||||
|
||||
// The world's sector grid (ecx of World_SetExplored).
|
||||
inline void* World() { return *reinterpret_cast<void**>(0x00ad14e0); }
|
||||
|
||||
// The sector grid: 128x128 sectors of 64x64 tiles (tiles < 0x2000), a u16
|
||||
// index per sector at +0x284 (row-major, 0 = none) into the sector pointer
|
||||
// vector at +0x240 (World_SetExplored 0x638f00).
|
||||
constexpr int kSectorTiles = 64;
|
||||
constexpr int kSectorGrid = 128;
|
||||
constexpr int kExploredCellTiles = 2; // one mask bit per 2x2 tiles
|
||||
constexpr int kSectorCells = kSectorTiles / kExploredCellTiles; // 32
|
||||
constexpr size_t kSectorExploredMask = 0x180; // 32 rows of 4 bytes
|
||||
|
||||
inline const uint8_t* SectorAt(int sectorX, int sectorY) {
|
||||
const auto* world = static_cast<const uint8_t*>(World());
|
||||
if (!world || sectorX < 0 || sectorY < 0 || sectorX >= kSectorGrid || sectorY >= kSectorGrid) {
|
||||
return nullptr;
|
||||
}
|
||||
const uint16_t index = reinterpret_cast<const uint16_t*>(world + 0x284)[sectorY * kSectorGrid + sectorX];
|
||||
if (index == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
return reinterpret_cast<const uint8_t* const*>(*reinterpret_cast<const uintptr_t*>(world + 0x240))[index];
|
||||
}
|
||||
|
||||
// The sector's explored mask (128 bytes), or null without a sector.
|
||||
inline const uint8_t* SectorExploredMask(int sectorX, int sectorY) {
|
||||
const uint8_t* sector = SectorAt(sectorX, sectorY);
|
||||
return sector ? sector + kSectorExploredMask : nullptr;
|
||||
}
|
||||
|
||||
// Bit of cell (x, y) within its sector's mask (Sector_SetExplored 0x622bf0).
|
||||
inline bool MaskCellExplored(const uint8_t* mask, int cellX, int cellY) {
|
||||
return (mask[cellY * 4 + (cellX >> 3)] >> (cellX & 7)) & 1;
|
||||
}
|
||||
|
||||
// World_SetExplored (0x638f00, thiscall (tile x, tile y, explored), ret 0xc):
|
||||
// sets the bit of the tile's sector; tiles outside the world (>= 0x2000) or in
|
||||
// a sector that isn't loaded are ignored.
|
||||
inline void SetExplored(int tileX, int tileY) {
|
||||
using Fn = void(__fastcall*)(void*, void*, int, int, int);
|
||||
reinterpret_cast<Fn>(0x00638f00)(World(), nullptr, tileX, tileY, 1);
|
||||
}
|
||||
|
||||
// RevealAroundHero (0x599730, creature thiscall (tile x, tile y, sight), ret
|
||||
// 0xc): for the local hero, when its tile changed (last tile 0xad2384 /
|
||||
// 0xad2388), sets every tile of a stamp centred on it explored. Sight 0 (day):
|
||||
// a 29x29 circle (radius 14); sight 1..3 (night, weather; chosen by the
|
||||
// callers from the weather object 0x417e50) a 15x15 stamp, the smaller ones
|
||||
// (radius 7, 5, 3) at 0x88f328 + (sight - 1) * 225.
|
||||
constexpr uintptr_t kRevealAroundHero = 0x00599730;
|
||||
inline int32_t LastRevealTileX() { return *reinterpret_cast<const int32_t*>(0x00ad2384); }
|
||||
inline int32_t LastRevealTileY() { return *reinterpret_cast<const int32_t*>(0x00ad2388); }
|
||||
|
||||
struct RevealStamp {
|
||||
const uint8_t* cells; // size x size bytes, non-zero = revealed
|
||||
int size;
|
||||
};
|
||||
|
||||
inline RevealStamp RevealStampFor(int sight) {
|
||||
if (sight <= 0) {
|
||||
return {reinterpret_cast<const uint8_t*>(0x0088f5cc), 29};
|
||||
}
|
||||
return {reinterpret_cast<const uint8_t*>(0x0088f328) + (sight - 1) * 15 * 15, 15};
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -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
|
||||
@@ -60,6 +60,69 @@ inline uint32_t ItemType(const void* item) {
|
||||
return *reinterpret_cast<const uint32_t*>(static_cast<const uint8_t*>(item) + kItemType);
|
||||
}
|
||||
|
||||
// 0x43ae20 (thiscall on the type table, (type), ret 4): the type is a gold
|
||||
// pile (one of the four types at 0x947920; labels show its amount, the loot
|
||||
// drop sets it, the collect mode takes it without inventory space).
|
||||
inline bool ItemIsGold(uint32_t type) {
|
||||
using Fn = bool(__fastcall*)(void* table, void* edx, uint32_t type);
|
||||
void* table = *reinterpret_cast<void**>(kItemTypeTable);
|
||||
return table && reinterpret_cast<Fn>(0x0043ae20)(table, nullptr, type);
|
||||
}
|
||||
|
||||
// 0x433f00 (thiscall on the type table, (type), ret 4): the type's group
|
||||
// record (0x102 bytes), null if none. +0x23 set: a set item (the item colour
|
||||
// 0x5c1330 shows it green).
|
||||
inline bool ItemIsSetItem(uint32_t type) {
|
||||
using Fn = const uint8_t*(__fastcall*)(void* table, void* edx, uint32_t type);
|
||||
void* table = *reinterpret_cast<void**>(kItemTypeTable);
|
||||
const uint8_t* group = table ? reinterpret_cast<Fn>(0x00433f00)(table, nullptr, type) : nullptr;
|
||||
return group && group[0x23] != 0;
|
||||
}
|
||||
|
||||
// Quest items: the type's quest number (+0x1c) or the scripts' QuestItem /
|
||||
// MainQuestItem marks (+0x61 / +0x62; tokens of those script words, 0x4584a9),
|
||||
// as the quest book reads them (0x43ad80). The game's CanCollectItem
|
||||
// (0x428550) takes them always, but only with type flag 0x1000000 set too.
|
||||
constexpr size_t kItemTypeQuest = 0x1c;
|
||||
constexpr size_t kItemTypeQuestItem = 0x61;
|
||||
constexpr size_t kItemTypeMainQuestItem = 0x62;
|
||||
inline bool ItemIsQuestItem(uint32_t type) {
|
||||
const auto* table = *reinterpret_cast<const uint8_t* const*>(kItemTypeTable);
|
||||
if (!table || type == 0 || type >= kItemTypeCount) {
|
||||
return false;
|
||||
}
|
||||
const uint8_t* entry = table + type * kItemTypeEntrySize;
|
||||
return *reinterpret_cast<const uint32_t*>(entry + kItemTypeQuest) != 0 || entry[kItemTypeQuestItem] ||
|
||||
entry[kItemTypeMainQuestItem];
|
||||
}
|
||||
|
||||
// The item's quality block (+0x118, cArmor3D::vf24 case 2 hands it to the
|
||||
// item colour 0x5c1330): flags at +4, bits 16..19 the quality tier. With the
|
||||
// game's UNIQUE_COLOR option (on by default) the colour follows the tier:
|
||||
// up to 4 white, 5..11 blue, 12..14 yellow, 15 brown; set items green.
|
||||
constexpr size_t kItemQualityFlags = 0x118 + 4;
|
||||
inline unsigned ItemQualityTier(const void* item) {
|
||||
const uint32_t flags =
|
||||
*reinterpret_cast<const uint32_t*>(static_cast<const uint8_t*>(item) + kItemQualityFlags);
|
||||
return (flags >> 16) & 0xf;
|
||||
}
|
||||
constexpr unsigned kTierBlue = 5;
|
||||
constexpr unsigned kTierYellow = 12;
|
||||
constexpr unsigned kTierBrown = 15;
|
||||
|
||||
// The item colours of 0x5c1330 (ARGB).
|
||||
constexpr uint32_t kItemColourWhite = 0xffefefef;
|
||||
constexpr uint32_t kItemColourBlue = 0xffa0a0ff;
|
||||
constexpr uint32_t kItemColourYellow = 0xfff0e040;
|
||||
constexpr uint32_t kItemColourBrown = 0xffc19a33;
|
||||
constexpr uint32_t kItemColourGreen = 0xff15ff25;
|
||||
|
||||
// The item colour the game shows (vf24(2, 0): 0x5c1330).
|
||||
inline uint32_t ItemColour(void* item) {
|
||||
using Fn = uint32_t(__fastcall*)(void*, void*, int, int);
|
||||
return reinterpret_cast<Fn>((*static_cast<void***>(item))[0x24 / 4])(item, nullptr, 2, 0);
|
||||
}
|
||||
|
||||
// The potion kind of object `id`, 0 if it is no potion (or gone).
|
||||
inline int PotionKindOf(uint32_t id) {
|
||||
const void* item = id ? GetObjectById(id) : nullptr;
|
||||
@@ -128,3 +191,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"
|
||||
|
||||
@@ -16,6 +16,10 @@ inline bool IsPlayerHeroId(uint32_t id) {
|
||||
// manager (saving marks effect objects dead, cObjectManager::Place refuses them).
|
||||
constexpr size_t kObjectFlags = 0x14;
|
||||
constexpr uint32_t kObjectDead = 0x10;
|
||||
// Set while an item is carried (in an inventory), not on the map:
|
||||
// cCreature::CanCollectItem refuses it, cEngine::dropItem (0x6116c0) only
|
||||
// takes it off the map without it.
|
||||
constexpr uint32_t kObjectCarried = 0x2;
|
||||
|
||||
// The creatures near the player (g_nearbyCreatures 0xad1c60: {object id, ?}
|
||||
// x g_nearbyCreatureCount 0xad1b48), as the game uses them for aura targets.
|
||||
@@ -31,6 +35,7 @@ constexpr uint32_t kTypeObject = 0x008e9648;
|
||||
constexpr uint32_t kTypeCreature = 0x008e9660;
|
||||
constexpr uint32_t kTypeControl = 0x009de168;
|
||||
constexpr uint32_t kTypeEditControl = 0x009e3608;
|
||||
constexpr uint32_t kTypeItemBase = 0x0094cbc0;
|
||||
|
||||
// __RTDynamicCast (0x849701).
|
||||
inline void* DynamicCast(void* object, uint32_t sourceType, uint32_t targetType) {
|
||||
@@ -51,14 +56,37 @@ inline void* GetObjectById(uint32_t id) {
|
||||
}
|
||||
|
||||
// cObjectManager::DestroyObject (0x5fbe10), with the arguments cCreature::UseItem
|
||||
// destroys a drunk potion with (id, 1, 0, 0).
|
||||
inline void DestroyObject(uint32_t id) {
|
||||
// destroys a drunk potion with (id, 1, 0, 0); the loot drop (0x559a30) an
|
||||
// item it replaces with (id, 0, 0, 0), after RemoveFromMap.
|
||||
inline void DestroyObject(uint32_t id, uint32_t a = 1) {
|
||||
using Fn = uint32_t(__fastcall*)(void* manager, void* edx, uint32_t, uint32_t, uint32_t, uint32_t);
|
||||
if (void* manager = ObjectManager()) {
|
||||
reinterpret_cast<Fn>(0x005fbe10)(manager, nullptr, id, 1, 0, 0);
|
||||
reinterpret_cast<Fn>(0x005fbe10)(manager, nullptr, id, a, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// cObjectManager 0x5fc910 (thiscall (id), ret 4): takes object `id` off the
|
||||
// map. The loot drop (0x559a30) replaces an item it made with this and
|
||||
// DestroyObject.
|
||||
inline void RemoveFromMap(uint32_t id) {
|
||||
using Fn = void(__fastcall*)(void* manager, void* edx, uint32_t);
|
||||
if (void* manager = ObjectManager()) {
|
||||
reinterpret_cast<Fn>(0x005fc910)(manager, nullptr, id);
|
||||
}
|
||||
}
|
||||
|
||||
// The end of the object ids (0x5fe730: object vector +4..+8, 4 bytes each);
|
||||
// ids 1..end-1 can be passed to GetObjectById.
|
||||
inline uint32_t ObjectIdEnd() {
|
||||
const auto* manager = static_cast<const uint8_t*>(ObjectManager());
|
||||
if (!manager) {
|
||||
return 0;
|
||||
}
|
||||
const auto begin = *reinterpret_cast<const uintptr_t*>(manager + 4);
|
||||
const auto end = *reinterpret_cast<const uintptr_t*>(manager + 8);
|
||||
return static_cast<uint32_t>((end - begin) / 4);
|
||||
}
|
||||
|
||||
// The object `id` as a cCreature, or null.
|
||||
inline void* GetCreature(uint32_t id) {
|
||||
return DynamicCast(GetObjectById(id), kTypeObject, kTypeCreature);
|
||||
@@ -73,11 +101,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
|
||||
@@ -15,8 +15,17 @@ std::vector<CreatureOrderFn>& Callbacks() {
|
||||
return callbacks;
|
||||
}
|
||||
|
||||
std::vector<CreatureOrderFilter>& Filters() {
|
||||
static std::vector<CreatureOrderFilter> filters;
|
||||
return filters;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void FilterCreatureOrders(CreatureOrderFilter filter) {
|
||||
Filters().push_back(filter);
|
||||
}
|
||||
|
||||
void OnCreatureOrder(CreatureOrderFn callback) {
|
||||
Callbacks().push_back(callback);
|
||||
}
|
||||
@@ -26,6 +35,11 @@ void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* orde
|
||||
RE_HOOK(0x0052e400, Creature_ReceiveOrder);
|
||||
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) {
|
||||
if (self && order) {
|
||||
for (CreatureOrderFilter filter : Filters()) {
|
||||
if (!filter(self, *order)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
void* caller = _ReturnAddress();
|
||||
for (CreatureOrderFn callback : Callbacks()) {
|
||||
callback(self, *order, caller);
|
||||
|
||||
+146
-1
@@ -63,24 +63,135 @@ inline void* ControlledCreature(uint8_t* engine) {
|
||||
using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller);
|
||||
void OnCreatureOrder(CreatureOrderFn callback);
|
||||
|
||||
// Asked before the callbacks: an order any filter refuses (returns false) is
|
||||
// dropped, the creature never gets it.
|
||||
using CreatureOrderFilter = bool (*)(void* creature, const CreatureOrder& order);
|
||||
void FilterCreatureOrders(CreatureOrderFilter filter);
|
||||
|
||||
// Creature fields.
|
||||
constexpr size_t kCreatureId = 0xc;
|
||||
constexpr size_t kCreatureX = 0x1c;
|
||||
constexpr size_t kCreatureY = 0x20;
|
||||
|
||||
// Escorts (measured 2026-10-26, docs/quests.md): an NPC told to follow
|
||||
// (cCreature::ReceiveOrder 0x52e400, e.g. 0x52e64f) stores its leader id at
|
||||
// +0x251, sets bit 4 of +0x1f4 (escorts of quest scripts have 0x100 instead,
|
||||
// measured on "Jagd auf die Wilderer"; the leave order clears both) and is added to the leader's follower list
|
||||
// (+0x39c..+0x3a0, 9-byte entries {u32 id, u32, u8}) with AddFollower.
|
||||
// Quest scripts test "@npc" (0x498aa0 case 0x40) against that list.
|
||||
constexpr size_t kCreatureLeader = 0x251;
|
||||
constexpr size_t kCreatureStatus = 0x1f4;
|
||||
constexpr uint32_t kStatusFollowing = 0x104;
|
||||
constexpr size_t kCreatureFollowersBegin = 0x39c;
|
||||
constexpr size_t kCreatureFollowersEnd = 0x3a0;
|
||||
constexpr size_t kFollowerEntrySize = 9;
|
||||
|
||||
#pragma pack(push, 1)
|
||||
struct FollowerEntry {
|
||||
uint32_t id;
|
||||
uint32_t value; // 1 for escorts, as the follow order adds them
|
||||
uint8_t flag; // 1 for escorts
|
||||
};
|
||||
#pragma pack(pop)
|
||||
static_assert(sizeof(FollowerEntry) == kFollowerEntrySize);
|
||||
|
||||
// cCreature::AddFollower (0x54b070, thiscall (const FollowerEntry*), ret 4):
|
||||
// adds the entry to the leader's list unless its id is in it.
|
||||
inline void AddFollower(void* leader, const FollowerEntry& entry) {
|
||||
using Fn = void(__fastcall*)(void*, void*, const FollowerEntry*);
|
||||
reinterpret_cast<Fn>(0x0054b070)(leader, nullptr, &entry);
|
||||
}
|
||||
|
||||
// The ids on the leader's follower list.
|
||||
template <typename Fn>
|
||||
void ForEachFollower(const void* leader, Fn&& fn) {
|
||||
const auto* creature = static_cast<const uint8_t*>(leader);
|
||||
const auto* begin = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersBegin);
|
||||
const auto* end = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersEnd);
|
||||
for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) {
|
||||
fn(*reinterpret_cast<const uint32_t*>(entry));
|
||||
}
|
||||
}
|
||||
|
||||
inline bool HasFollower(const void* leader, uint32_t id) {
|
||||
bool found = false;
|
||||
ForEachFollower(leader, [&](uint32_t follower) { found = found || follower == id; });
|
||||
return found;
|
||||
}
|
||||
|
||||
// cCreature::Teleport (0x54d840, thiscall (cell x, cell y, 0, 1), ret 0x10):
|
||||
// as the portals move the hero (EE_USEPORTAL 0x6197c3); the game's log line
|
||||
// "Teleport (type): <type>, ... (x,y)".
|
||||
inline void Teleport(void* creature, int32_t cellX, int32_t cellY) {
|
||||
using Fn = void(__fastcall*)(void*, void*, int32_t, int32_t, int32_t, int32_t);
|
||||
reinterpret_cast<Fn>(0x0054d840)(creature, nullptr, cellX, cellY, 0, 1);
|
||||
}
|
||||
|
||||
// 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 +222,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,276 @@
|
||||
#pragma once
|
||||
// Quests: the quest table (one entry per quest, from the scripts) and the quest
|
||||
// manager's quest book (the hero's accepted quests). docs/quests.md.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
|
||||
namespace game {
|
||||
|
||||
// Quest table g_quests: vector begin/end (0xaa9e98 / 0xaa9e9c), 0x124 bytes per quest.
|
||||
constexpr uintptr_t kQuestsBegin = 0x00aa9e98;
|
||||
constexpr uintptr_t kQuestsEnd = 0x00aa9e9c;
|
||||
constexpr size_t kQuestSize = 0x124;
|
||||
constexpr size_t kQuestId = 0x00;
|
||||
constexpr size_t kQuestName = 0x04; // internal name, ANSI
|
||||
constexpr size_t kQuestTriggerScript = 0x10c; // run by Quest_Trigger 0x46de10
|
||||
constexpr size_t kQuestSuccessScript = 0x118; // run by Quest_End on success
|
||||
constexpr size_t kQuestFailureScript = 0x11c; // run by Quest_End on failure
|
||||
constexpr size_t kQuestFlags = 0x120;
|
||||
|
||||
// Quest flags (+0x120); QuestDone tests 2, QuestInProgress 4.
|
||||
constexpr uint32_t kQuestTriggered = 0x1;
|
||||
constexpr uint32_t kQuestDone = 0x2;
|
||||
constexpr uint32_t kQuestInProgress = 0x4;
|
||||
constexpr uint32_t kQuestBusy = 0x8; // while Quest_Trigger runs the scripts
|
||||
|
||||
inline uint8_t* QuestsBegin() { return *reinterpret_cast<uint8_t**>(kQuestsBegin); }
|
||||
inline uint8_t* QuestsEnd() { return *reinterpret_cast<uint8_t**>(kQuestsEnd); }
|
||||
inline size_t QuestCount() {
|
||||
uint8_t* begin = QuestsBegin();
|
||||
return begin && QuestsEnd() > begin ? (QuestsEnd() - begin) / kQuestSize : 0;
|
||||
}
|
||||
inline uint8_t* Quest(int index) { return QuestsBegin() + index * kQuestSize; }
|
||||
inline int32_t& QuestId(uint8_t* quest) { return *reinterpret_cast<int32_t*>(quest + kQuestId); }
|
||||
inline uint32_t& QuestFlags(uint8_t* quest) { return *reinterpret_cast<uint32_t*>(quest + kQuestFlags); }
|
||||
inline const char* QuestName(const uint8_t* quest) { return reinterpret_cast<const char*>(quest + kQuestName); }
|
||||
|
||||
// Table index of quest `id`, -1 if unknown.
|
||||
inline int QuestIndex(int32_t id) {
|
||||
for (size_t i = 0, n = QuestCount(); i < n; ++i) {
|
||||
if (QuestId(Quest(static_cast<int>(i))) == id) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Script labels (vector begin/end 0xaa9688 / 0xaa968c, 0x54 bytes each; the
|
||||
// quests' scripts are indices into it, run by 0x46bd80): name +0 (e.g.
|
||||
// "Dialog:auftrag2411", "rg1_goodie1_annehmen"), the quest the label belongs to
|
||||
// +0x48 (-1 for none; 0x46bf11: runs only once that quest is triggered).
|
||||
constexpr uintptr_t kScriptLabelsBegin = 0x00aa9688;
|
||||
constexpr uintptr_t kScriptLabelsEnd = 0x00aa968c;
|
||||
constexpr size_t kScriptLabelSize = 0x54;
|
||||
constexpr size_t kScriptLabelNameSize = 0x48;
|
||||
constexpr size_t kScriptLabelQuest = 0x48;
|
||||
constexpr size_t kScriptLabelCode = 0x40; // offset of its code in FunkCode.bin
|
||||
constexpr size_t kScriptLabelCodeSize = 0x44; // bytes
|
||||
|
||||
inline const uint8_t* ScriptLabelsBegin() { return *reinterpret_cast<const uint8_t* const*>(kScriptLabelsBegin); }
|
||||
inline const uint8_t* ScriptLabelsEnd() { return *reinterpret_cast<const uint8_t* const*>(kScriptLabelsEnd); }
|
||||
inline const char* ScriptLabelName(const uint8_t* label) { return reinterpret_cast<const char*>(label); }
|
||||
inline int32_t ScriptLabelQuest(const uint8_t* label) {
|
||||
return *reinterpret_cast<const int32_t*>(label + kScriptLabelQuest);
|
||||
}
|
||||
inline uint32_t ScriptLabelCode(const uint8_t* label) {
|
||||
return *reinterpret_cast<const uint32_t*>(label + kScriptLabelCode);
|
||||
}
|
||||
inline uint32_t ScriptLabelCodeSize(const uint8_t* label) {
|
||||
return *reinterpret_cast<const uint32_t*>(label + kScriptLabelCodeSize);
|
||||
}
|
||||
|
||||
// Script code (FunkCode.bin, open as the quest manager's FILE* +4; measured
|
||||
// 2026-10-26): instructions {u16 opcode, u16 size (with these 4 bytes), args};
|
||||
// args are tagged (0x01 a string, 0x0b an int32, ...). Script_RunLabel
|
||||
// (0x46bd80) reads a label's code and runs it instruction by instruction with
|
||||
// 0x475970. Opcodes used here: 1 spawns a creature ("Res:<script id>", type,
|
||||
// position, 0x09 + its dialog, ...; e.g. Region15Init). Script variables are
|
||||
// bit fields ({0x01 name, 0x0b n}; measured 2026-10-27): 0x43 sets the value,
|
||||
// 0x44 sets bit n (0x49bb30), 0x45 clears bit n (0x49c450), 0x4b adds n
|
||||
// (0x49cc20); conditions 0x49 / 0x4a test bit n set / clear, 0x48 the value
|
||||
// (0x498aa0; a missing variable is false).
|
||||
constexpr uint16_t kScriptSpawn = 0x01;
|
||||
// More opcodes (keyword table 0x452540, name i <-> opcode; docs/quest-audit.md):
|
||||
// 3 NPC state ("Res:<id>", 0x09 dialog, flag 0x08 with 0x12 hostile, ...),
|
||||
// 8 create an object, 0x0f / 0x36 end the quest (ExitQuest / LoseQuest), 0x11 /
|
||||
// 0x12 give experience / gold, 0x14 start quest #id, 0x15 set up quest #id,
|
||||
// 0x16 call a label, 0x34 set hit points ("Res:<id>", percent), 0x37 delete an
|
||||
// NPC or object, 0x3a / 0x42 if / else if, 0x3c dialog answer (text, label),
|
||||
// 0x4f change creature type, 0x75 activate quest #id.
|
||||
constexpr uint16_t kScriptSetNpcState = 0x03;
|
||||
constexpr uint16_t kScriptCreateObject = 0x08;
|
||||
constexpr uint16_t kScriptGiveExperience = 0x11;
|
||||
constexpr uint16_t kScriptGiveGold = 0x12;
|
||||
constexpr uint16_t kScriptStartQuest = 0x14;
|
||||
constexpr uint16_t kScriptSetUpQuest = 0x15;
|
||||
constexpr uint16_t kScriptCallLabel = 0x16;
|
||||
constexpr uint16_t kScriptSetHitPoints = 0x34;
|
||||
constexpr uint16_t kScriptLoseQuest = 0x36;
|
||||
constexpr uint16_t kScriptDelete = 0x37;
|
||||
constexpr uint16_t kScriptIf = 0x3a;
|
||||
constexpr uint16_t kScriptAnswer = 0x3c;
|
||||
constexpr uint16_t kScriptElseIf = 0x42;
|
||||
constexpr uint16_t kScriptChangeType = 0x4f;
|
||||
constexpr uint16_t kScriptActivateQuest = 0x75;
|
||||
constexpr uint16_t kScriptSetVarValue = 0x43;
|
||||
constexpr uint16_t kScriptSetVarBit = 0x44;
|
||||
constexpr uint16_t kScriptClearVarBit = 0x45;
|
||||
constexpr uint16_t kScriptAddToVar = 0x4b;
|
||||
inline bool IsScriptVariableWrite(uint16_t opcode) {
|
||||
return opcode == kScriptSetVarValue || opcode == kScriptSetVarBit || opcode == kScriptClearVarBit ||
|
||||
opcode == kScriptAddToVar;
|
||||
}
|
||||
constexpr uint8_t kScriptArgString = 0x01;
|
||||
constexpr uint8_t kScriptArgInt = 0x0b;
|
||||
constexpr uint8_t kScriptArgDialog = 0x09;
|
||||
constexpr uint8_t kScriptArgAttitude = 0x08; // flag, followed by 0x12 (hostile) or 0x14
|
||||
|
||||
// The generic quests (Quest_End copies them under a new id; docs/quests.md).
|
||||
constexpr int32_t kGenericQuestFirst = 2600;
|
||||
constexpr int32_t kGenericQuestLast = 3599;
|
||||
|
||||
// CRT fseek (0x84866d) / fread (0x848726) of the game, on its own FILE*.
|
||||
inline void* ScriptFile() { return *reinterpret_cast<void**>(0x00aaaf04); }
|
||||
inline size_t ReadScriptFile(uint32_t offset, void* out, size_t size) {
|
||||
using SeekFn = int(__cdecl*)(void*, long, int);
|
||||
using ReadFn = size_t(__cdecl*)(void*, size_t, size_t, void*);
|
||||
void* file = ScriptFile();
|
||||
if (!file || reinterpret_cast<SeekFn>(0x0084866d)(file, static_cast<long>(offset), 0) != 0) {
|
||||
return 0;
|
||||
}
|
||||
return reinterpret_cast<ReadFn>(0x00848726)(out, 1, size, file);
|
||||
}
|
||||
|
||||
// Runs the one instruction at `code` (0x475970 on the quest manager, thiscall
|
||||
// (code, &offset, quest, object, flag, 0), ret 0x18; with an offset other than
|
||||
// -1 it reads from memory, -1 from the script file). 0 on an error.
|
||||
inline int RunScriptInstruction(const uint8_t* code, int32_t quest = -1, void* object = nullptr) {
|
||||
using Fn = int(__fastcall*)(void*, void*, const uint8_t*, int32_t*, int32_t, void*, int32_t, int32_t);
|
||||
int32_t offset = 0;
|
||||
return reinterpret_cast<Fn>(0x00475970)(reinterpret_cast<void*>(0x00aaaf00), nullptr, code, &offset, quest,
|
||||
object, 0, 0);
|
||||
}
|
||||
|
||||
// The script id of an object (0x44a390, thiscall (), ret: +0x3c, or its
|
||||
// type's default), as "Res:<id>" names it.
|
||||
inline uint32_t ObjectScriptId(void* object) {
|
||||
using Fn = uint32_t(__fastcall*)(void*, void*);
|
||||
return reinterpret_cast<Fn>(0x0044a390)(object, nullptr);
|
||||
}
|
||||
|
||||
// The object id the scripts know as `scriptId` (0x464ec0 on the quest
|
||||
// manager, thiscall (script id, &object id), ret 8: its map +0x7668, which
|
||||
// may still hold a dead object's id, then all objects). 0 if none.
|
||||
// The quest manager's map script id -> object id (+0x7668, std::map;
|
||||
// operator[] 0x4b37b0, thiscall (&key), ret 4; spawning sets it, 0x4839fa).
|
||||
inline uint32_t& ScriptObjectSlot(uint32_t scriptId) {
|
||||
using Fn = uint32_t*(__fastcall*)(void*, void*, const uint32_t*);
|
||||
return *reinterpret_cast<Fn>(0x004b37b0)(reinterpret_cast<uint8_t*>(0x00aaaf00) + 0x7668, nullptr, &scriptId);
|
||||
}
|
||||
|
||||
inline uint32_t ScriptObjectId(uint32_t scriptId) {
|
||||
using Fn = char(__fastcall*)(void*, void*, uint32_t, uint32_t*);
|
||||
uint32_t id = 0;
|
||||
return reinterpret_cast<Fn>(0x00464ec0)(reinterpret_cast<void*>(0x00aaaf00), nullptr, scriptId, &id) ? id : 0;
|
||||
}
|
||||
|
||||
// The quest manager, a static object (script interpreter; ecx of 0x475970 and
|
||||
// of Quest_End 0x46ced0). Its quest book is a vector of plain 0x174-byte entries
|
||||
// (serialized field by field, Quest_End 0x46d9d7..); per player a selected
|
||||
// entry index at +0x3a8 + player * 8 (Quest_End sets it to the count = none).
|
||||
inline uint8_t* QuestManager() { return reinterpret_cast<uint8_t*>(0x00aaaf00); }
|
||||
constexpr size_t kBookBegin = 0x424;
|
||||
constexpr size_t kBookEnd = 0x428;
|
||||
constexpr size_t kBookEntrySize = 0x174;
|
||||
|
||||
// The script variables: a vector at quest manager +0x7550 / +0x7554 of 0x24-byte
|
||||
// entries {name[0x20], value} (read by the conditions, 0x498aa0 case 0x49).
|
||||
constexpr size_t kVariablesBegin = 0x7550;
|
||||
constexpr size_t kVariablesEnd = 0x7554;
|
||||
constexpr size_t kVariableSize = 0x24;
|
||||
constexpr size_t kVariableValue = 0x20;
|
||||
|
||||
// The value slot of script variable `name`, nullptr if it does not exist yet.
|
||||
inline int32_t* ScriptVariable(const char* name) {
|
||||
uint8_t* begin = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesBegin);
|
||||
uint8_t* end = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesEnd);
|
||||
for (uint8_t* entry = begin; entry && entry < end; entry += kVariableSize) {
|
||||
if (strncmp(reinterpret_cast<const char*>(entry), name, kVariableValue) == 0) {
|
||||
return reinterpret_cast<int32_t*>(entry + kVariableValue);
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
constexpr size_t kBookCapacity = 0x42c;
|
||||
constexpr size_t kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450)
|
||||
constexpr size_t kBookState = 0x04;
|
||||
constexpr size_t kBookQuestId = 0x08;
|
||||
constexpr size_t kBookTitle = 0x24; // text ids
|
||||
constexpr size_t kBookText = 0x28;
|
||||
// The quest compass (the quest's marker on the maps, script QuestkompassPos /
|
||||
// QuestkompassOBJ 0x49a7a0 / 0x49af70): target +0x10/+0x14 is a map cell
|
||||
// (x, y), or kCompassObject + object id, or kCompassScriptNpc + script NPC id
|
||||
// (resolved to an object later); radius +0x20 ("rad:"). The marker's cell
|
||||
// +0x18/+0x1c is resolved from the target by 0x4a6740 (thiscall (entry), ret 4).
|
||||
constexpr size_t kBookCompassX = 0x10;
|
||||
constexpr size_t kBookCompassY = 0x14;
|
||||
constexpr size_t kBookCompassRadius = 0x20;
|
||||
constexpr uint32_t kCompassObject = 0xffffffff;
|
||||
constexpr uint32_t kCompassScriptNpc = 0xeeeeeeee;
|
||||
constexpr size_t kBookX = 0x18; // marker cell (0x497830)
|
||||
constexpr size_t kBookY = 0x1c;
|
||||
constexpr size_t kBookChapter = 0x16c; // u16, book tab + 1 (0 = tab 0), copied from the current main quest
|
||||
constexpr size_t kBookMainIndex = 0x3a4; // per player: the current main quest's entry
|
||||
constexpr size_t kBookSelected = 0x3a8;
|
||||
constexpr size_t kBookSelectedStride = 8;
|
||||
constexpr int kBookPlayers = 16; // +0x3a4.. 16 pairs up to the book at +0x424
|
||||
|
||||
constexpr int32_t kBookMain = 3;
|
||||
constexpr int32_t kBookSide = 4;
|
||||
constexpr int kMainQuestLast = 99; // quest ids 1..99 are main quests (0x46c450)
|
||||
|
||||
// Book entry states (+4); Quest_End writes 100 / 101.
|
||||
constexpr int32_t kBookActive = 2;
|
||||
constexpr int32_t kBookSolved = 100;
|
||||
constexpr int32_t kBookFailed = 101;
|
||||
|
||||
inline uint8_t*& BookBegin() { return *reinterpret_cast<uint8_t**>(QuestManager() + kBookBegin); }
|
||||
inline uint8_t*& BookEnd() { return *reinterpret_cast<uint8_t**>(QuestManager() + kBookEnd); }
|
||||
inline uint8_t*& BookCapacityEnd() { return *reinterpret_cast<uint8_t**>(QuestManager() + kBookCapacity); }
|
||||
inline size_t BookCount() {
|
||||
return BookBegin() && BookEnd() > BookBegin() ? (BookEnd() - BookBegin()) / kBookEntrySize : 0;
|
||||
}
|
||||
inline uint8_t* BookEntry(size_t index) { return BookBegin() + index * kBookEntrySize; }
|
||||
inline int32_t BookState(const uint8_t* entry) { return *reinterpret_cast<const int32_t*>(entry + kBookState); }
|
||||
inline int32_t BookQuestId(const uint8_t* entry) { return *reinterpret_cast<const int32_t*>(entry + kBookQuestId); }
|
||||
inline int32_t BookKind(const uint8_t* entry) { return *reinterpret_cast<const int32_t*>(entry + kBookKind); }
|
||||
inline uint16_t BookChapter(const uint8_t* entry) { return *reinterpret_cast<const uint16_t*>(entry + kBookChapter); }
|
||||
|
||||
// The local player's slot in the per-player book fields (session +0x14 is the
|
||||
// player number 1..16; 0x46c450, 0x49dd40).
|
||||
inline int LocalPlayerSlot() {
|
||||
using InstanceFn = uint8_t*(__cdecl*)();
|
||||
const int player = *reinterpret_cast<int32_t*>(reinterpret_cast<InstanceFn>(0x007d7c40)() + 0x14);
|
||||
return player >= 1 && player <= kBookPlayers ? player - 1 : 0;
|
||||
}
|
||||
inline uint32_t& BookSelected(int slot) {
|
||||
return *reinterpret_cast<uint32_t*>(QuestManager() + kBookSelected + slot * kBookSelectedStride);
|
||||
}
|
||||
inline uint32_t& BookMainIndex(int slot) {
|
||||
return *reinterpret_cast<uint32_t*>(QuestManager() + kBookMainIndex + slot * kBookSelectedStride);
|
||||
}
|
||||
|
||||
// Removes book entry `index`; the players' selected indices are kept on their
|
||||
// entries (or "none" = the new count), and so are their main quest indices.
|
||||
inline void EraseBookEntry(size_t index) {
|
||||
const size_t count = BookCount();
|
||||
if (index >= count) {
|
||||
return;
|
||||
}
|
||||
std::memmove(BookEntry(index), BookEntry(index + 1), (count - index - 1) * kBookEntrySize);
|
||||
BookEnd() -= kBookEntrySize;
|
||||
for (int player = 0; player < kBookPlayers; ++player) {
|
||||
for (uint32_t* kept : {&BookSelected(player), &BookMainIndex(player)}) {
|
||||
if (*kept == index || *kept >= count) {
|
||||
*kept = static_cast<uint32_t>(count - 1);
|
||||
} else if (*kept > index) {
|
||||
--*kept;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
// World regions: each sector carries a region number; the region name table
|
||||
// (from scripts/regionsnamen) maps region numbers to name text keys. The
|
||||
// megamap names the hero's region so (0x6c6d73..): position -> sector
|
||||
// (0x634970) -> region (0x622e30) -> key (0x425370) -> text.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
|
||||
namespace game {
|
||||
|
||||
// The region number at map cell (x, y) (quest book positions +0x18/+0x1c are
|
||||
// cells); 0 if none. World_SectorAt 0x634970 (thiscall (position*), ret 4,
|
||||
// world units) on the object manager's world (manager +0), Sector_Region
|
||||
// 0x622e30 (thiscall, plain ret).
|
||||
inline uint8_t RegionAtCell(int32_t x, int32_t y) {
|
||||
void* manager = ObjectManager();
|
||||
void* world = manager ? *reinterpret_cast<void**>(manager) : nullptr;
|
||||
if (!world) {
|
||||
return 0;
|
||||
}
|
||||
MapPosition position;
|
||||
position.x = static_cast<int32_t>(x * kMapCellSize);
|
||||
position.y = static_cast<int32_t>(y * kMapCellSize);
|
||||
using SectorFn = void*(__fastcall*)(void* world, void* edx, MapPosition* position);
|
||||
using RegionFn = uint32_t(__fastcall*)(void* sector, void* edx);
|
||||
void* sector = reinterpret_cast<SectorFn>(0x00634970)(world, nullptr, &position);
|
||||
return sector ? static_cast<uint8_t>(reinterpret_cast<RegionFn>(0x00622e30)(sector, nullptr)) : 0;
|
||||
}
|
||||
|
||||
// The name text key of region `region` (Region_NameKey 0x425370, cdecl), or
|
||||
// null.
|
||||
inline const char* RegionNameKey(uint8_t region) {
|
||||
using Fn = const char*(__cdecl*)(char region);
|
||||
return region ? reinterpret_cast<Fn>(0x00425370)(static_cast<char>(region)) : nullptr;
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -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,48 @@
|
||||
#pragma once
|
||||
// The world view (cWorldView0, vtable 0x8900f8): camera and the projection of
|
||||
// world positions onto the screen. docs/widescreen.md.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/addresses.h"
|
||||
#include "game/render/dx_driver7.h"
|
||||
|
||||
namespace game {
|
||||
|
||||
constexpr size_t kViewCameraX = 0x14; // int32, world pixels
|
||||
constexpr size_t kViewCameraY = 0x18;
|
||||
constexpr size_t kViewZoom = 0xb60; // float, 0.5 .. 2.0
|
||||
constexpr float kViewHalfHeight = 384.0f; // the render height stays 768
|
||||
|
||||
struct Point2 {
|
||||
float x, y;
|
||||
};
|
||||
|
||||
// World units -> world pixels: the ground plane's isometric projection
|
||||
// (0x622700, cdecl (out, in), plain ret; matrix at 0xad5710; a 53.66563-unit
|
||||
// tile is a 96x48 pixel diamond). The large map uses it too.
|
||||
inline Point2 WorldToPlane(float x, float y) {
|
||||
float in[3] = {x, y, 0};
|
||||
float out[3] = {};
|
||||
using Fn = void(__cdecl*)(float*, const float*);
|
||||
reinterpret_cast<Fn>(0x00622700)(out, in);
|
||||
return {out[0], out[1]};
|
||||
}
|
||||
|
||||
// World pixels -> screen: (pixel - camera) / zoom + screen centre (the
|
||||
// floating texts 0x41e546, the object passes 0x62f925). `halfWidth`: half
|
||||
// the render width (the centre the widescreen mod gives the view).
|
||||
inline Point2 PlaneToScreen(const uint8_t* view, const Point2& plane, float halfWidth) {
|
||||
const float zoom = *reinterpret_cast<const float*>(view + kViewZoom);
|
||||
const auto cameraX = static_cast<float>(*reinterpret_cast<const int32_t*>(view + kViewCameraX));
|
||||
const auto cameraY = static_cast<float>(*reinterpret_cast<const int32_t*>(view + kViewCameraY));
|
||||
return {(plane.x - cameraX) / zoom + halfWidth, (plane.y - cameraY) / zoom + kViewHalfHeight};
|
||||
}
|
||||
|
||||
// The Direct3D 7 device the world is drawn with.
|
||||
inline void* RenderDevice() {
|
||||
const auto* driver = *reinterpret_cast<DxDriver7* const*>(addr::g_dxDriver);
|
||||
return driver ? driver->device : nullptr;
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
// cUI_Button2 (vtable 0x895308, 0xbc bytes, constructor 0x72a180, plain ret).
|
||||
// Windows set their buttons up as cUI_Options 0x7184e0 does: images
|
||||
// (+0x54..+0x87), sounds (0x72acf0 -> +0x90..+0x9f), caption (0x72ad60 (text
|
||||
// id, font 1) -> +0xa4 / +0xa0), caption offset (0x72ad40 -> +0xb0), name
|
||||
// (0x732090).
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/ui/tabs.h"
|
||||
#include "game/ui/text.h"
|
||||
|
||||
namespace game {
|
||||
|
||||
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
|
||||
|
||||
// Control vtable entries: vf10 handles an event (ret 4), vf14 renders (ret 4).
|
||||
constexpr size_t kVfHandleEvent = 0x10 / 4;
|
||||
constexpr size_t kVfRender = 0x14 / 4;
|
||||
|
||||
// A new button with `model`'s look and sounds at `rect` (relative to the
|
||||
// parent it is added to, or the screen frame without one), captioned with text
|
||||
// id `caption`.
|
||||
inline uint8_t* NewButtonLike(const uint8_t* model, const UiRect& rect, uint32_t caption, const char* name) {
|
||||
void* memory = GameNew(kButtonSize);
|
||||
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(model + kButtonLook, model + kButtonLookEnd, button + kButtonLook);
|
||||
std::copy(model + kButtonSounds, model + kButtonSoundsEnd, button + kButtonSounds);
|
||||
std::copy(model + kButtonCaptionOffset, model + kButtonCaptionOffset + 4, button + kButtonCaptionOffset);
|
||||
*reinterpret_cast<UiRect*>(button + kControlRect) = rect;
|
||||
using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t);
|
||||
reinterpret_cast<CaptionFn>(0x0072ad60)(button, nullptr, caption, 1);
|
||||
using NameFn = void(__fastcall*)(void*, void*, const char*);
|
||||
reinterpret_cast<NameFn>(0x00732090)(button, nullptr, name);
|
||||
return button;
|
||||
}
|
||||
|
||||
inline int ControlHandleEvent(void* control, void* event) {
|
||||
using Fn = int(__fastcall*)(void*, void*, void*);
|
||||
return reinterpret_cast<Fn>((*static_cast<void***>(control))[kVfHandleEvent])(control, nullptr, event);
|
||||
}
|
||||
|
||||
inline int ControlRender(void* control, void* device) {
|
||||
using Fn = int(__fastcall*)(void*, void*, void*);
|
||||
return reinterpret_cast<Fn>((*static_cast<void***>(control))[kVfRender])(control, nullptr, device);
|
||||
}
|
||||
|
||||
inline bool Inside(const UiRect& rect, int x, int y) {
|
||||
return x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h;
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
// cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still
|
||||
// renders but handles no events).
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace game {
|
||||
|
||||
constexpr size_t kControlFlagsOffset = 0x10;
|
||||
constexpr uint32_t kControlFlagVisible = 1;
|
||||
constexpr size_t kControlName = 0x30; // char[], set by 0x732090 (e.g. "UI_LABEL_NEWGAME")
|
||||
|
||||
inline bool ControlNamed(const void* control, const char* name) {
|
||||
return std::strcmp(static_cast<const char*>(control) + kControlName, name) == 0;
|
||||
}
|
||||
|
||||
// Windows keep their lines / entries in game vectors of control pointers
|
||||
// {begin, end, capacity end} at `offset` of the window.
|
||||
inline std::vector<uint8_t*> ControlsIn(void* window, size_t offset) {
|
||||
auto* field = static_cast<uint8_t*>(window) + offset;
|
||||
auto** begin = *reinterpret_cast<uint8_t***>(field);
|
||||
auto** end = *reinterpret_cast<uint8_t***>(field + 4);
|
||||
return std::vector<uint8_t*>(begin, end);
|
||||
}
|
||||
|
||||
// Takes `control` out of the vector (the order of the others stays).
|
||||
inline void RemoveControl(void* window, size_t offset, const void* control) {
|
||||
auto* field = static_cast<uint8_t*>(window) + offset;
|
||||
auto** begin = *reinterpret_cast<uint8_t***>(field);
|
||||
auto**& end = *reinterpret_cast<uint8_t***>(field + 4);
|
||||
auto** found = std::find(begin, end, control);
|
||||
if (found != end) {
|
||||
std::memmove(found, found + 1, (end - found - 1) * sizeof(*found));
|
||||
--end;
|
||||
}
|
||||
}
|
||||
|
||||
// Replaces the vector's entry `index`.
|
||||
inline void SetControlAt(void* window, size_t offset, size_t index, uint8_t* control) {
|
||||
(*reinterpret_cast<uint8_t***>(static_cast<uint8_t*>(window) + offset))[index] = control;
|
||||
}
|
||||
|
||||
// Destroys a control made with the game's allocator (vf00, the scalar deleting
|
||||
// destructor, e.g. cUI_StaticText64 0x754680); out of every list first.
|
||||
inline void DeleteControl(void* control) {
|
||||
using DeleteFn = void*(__fastcall*)(void*, void*, int);
|
||||
reinterpret_cast<DeleteFn>((*static_cast<void***>(control))[0])(control, nullptr, 1);
|
||||
}
|
||||
|
||||
inline bool ControlShown(const void* control) {
|
||||
return (static_cast<const uint8_t*>(control)[kControlFlagsOffset] & kControlFlagVisible) != 0;
|
||||
}
|
||||
|
||||
// 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,114 @@
|
||||
#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 int kDeviceSetRenderState = 20;
|
||||
constexpr int kDeviceGetRenderState = 21;
|
||||
constexpr DWORD kStateSourceBlend = 19; // D3DRENDERSTATETYPE
|
||||
constexpr DWORD kStateDestinationBlend = 20;
|
||||
constexpr DWORD kStateAlphaBlendEnable = 27;
|
||||
constexpr DWORD kBlendOne = 2; // D3DBLEND
|
||||
constexpr DWORD kTriangleStrip = 5;
|
||||
constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1
|
||||
|
||||
struct ScreenVertex {
|
||||
float x, y, z, rhw;
|
||||
uint32_t diffuse, specular;
|
||||
float u, v;
|
||||
};
|
||||
|
||||
// Draws (x0, y0)-(x1, y1) with `surface` (a texture's Direct3D surface,
|
||||
// game/ui/texture.h) from (u0, v0) to (u1, v1), tinted by `colour`.
|
||||
inline void DrawTextured(void* device, void* surface, float x0, float y0, float x1, float y1, float u0, float v0,
|
||||
float u1, float v1, uint32_t colour = 0xffffffff) {
|
||||
void** vtable = *static_cast<void***>(device);
|
||||
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
|
||||
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
|
||||
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, surface);
|
||||
ScreenVertex quad[4] = {{x0, y0, 0, 1, colour, 0, u0, v0},
|
||||
{x1, y0, 0, 1, colour, 0, u1, v0},
|
||||
{x0, y1, 0, 1, colour, 0, u0, v1},
|
||||
{x1, y1, 0, 1, colour, 0, u1, v1}};
|
||||
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture,
|
||||
quad, 4, 0);
|
||||
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
|
||||
}
|
||||
|
||||
// DrawTextured added onto what is drawn (source and destination blend ONE):
|
||||
// for the effect textures, drawn on black without alpha.
|
||||
inline void DrawTexturedAdded(void* device, void* surface, float x0, float y0, float x1, float y1, float u0,
|
||||
float v0, float u1, float v1) {
|
||||
void** vtable = *static_cast<void***>(device);
|
||||
using SetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD);
|
||||
using GetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD*);
|
||||
const auto set = reinterpret_cast<SetRenderStateFn>(vtable[kDeviceSetRenderState]);
|
||||
const auto get = reinterpret_cast<GetRenderStateFn>(vtable[kDeviceGetRenderState]);
|
||||
DWORD blending = 0, source = 0, destination = 0;
|
||||
get(device, kStateAlphaBlendEnable, &blending);
|
||||
get(device, kStateSourceBlend, &source);
|
||||
get(device, kStateDestinationBlend, &destination);
|
||||
set(device, kStateAlphaBlendEnable, TRUE);
|
||||
set(device, kStateSourceBlend, kBlendOne);
|
||||
set(device, kStateDestinationBlend, kBlendOne);
|
||||
DrawTextured(device, surface, x0, y0, x1, y1, u0, v0, u1, v1);
|
||||
set(device, kStateAlphaBlendEnable, blending);
|
||||
set(device, kStateSourceBlend, source);
|
||||
set(device, kStateDestinationBlend, destination);
|
||||
}
|
||||
|
||||
// Fills (x0, y0)-(x1, y1), ARGB colours per corner: top left, top right,
|
||||
// bottom left, bottom right (blended between them).
|
||||
inline void FillCorners(void* device, float x0, float y0, float x1, float y1, uint32_t topLeft, uint32_t topRight,
|
||||
uint32_t bottomLeft, uint32_t bottomRight) {
|
||||
void** vtable = *static_cast<void***>(device);
|
||||
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
|
||||
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
|
||||
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
|
||||
ScreenVertex quad[4] = {{x0, y0, 0, 1, topLeft, 0, 0, 0},
|
||||
{x1, y0, 0, 1, topRight, 0, 1, 0},
|
||||
{x0, y1, 0, 1, bottomLeft, 0, 0, 1},
|
||||
{x1, y1, 0, 1, bottomRight, 0, 1, 1}};
|
||||
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture,
|
||||
quad, 4, 0);
|
||||
}
|
||||
|
||||
// Untextured triangles, three vertices each (a triangle list), in batches the
|
||||
// device takes in one call.
|
||||
inline void FillTriangles(void* device, ScreenVertex* vertices, size_t count) {
|
||||
constexpr DWORD kTriangleList = 4;
|
||||
constexpr size_t kBatch = 3 * 10000;
|
||||
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 (size_t first = 0; first < count; first += kBatch) {
|
||||
const size_t batch = count - first < kBatch ? count - first : kBatch;
|
||||
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(
|
||||
device, kTriangleList, kFvfScreenColourTexture, vertices + first, static_cast<DWORD>(batch), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Fills (x0, y0)-(x1, y1) with the ARGB colour `color`.
|
||||
inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) {
|
||||
FillCorners(device, x0, y0, x1, y1, color, color, color, color);
|
||||
}
|
||||
|
||||
// Top to bottom from `top` to `bottom`.
|
||||
inline void FillVertical(void* device, float x0, float y0, float x1, float y1, uint32_t top, uint32_t bottom) {
|
||||
FillCorners(device, x0, y0, x1, y1, top, top, bottom, bottom);
|
||||
}
|
||||
|
||||
// Left to right from `left` to `right`.
|
||||
inline void FillHorizontal(void* device, float x0, float y0, float x1, float y1, uint32_t left, uint32_t right) {
|
||||
FillCorners(device, x0, y0, x1, y1, left, right, left, right);
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
// cUI_Frame (vtable 0x8955ac, 0xe0 bytes): the game's ornate frame. Its
|
||||
// constructor (0x739f90, thiscall (name, type, rect*, 1), ret 0x10) places the
|
||||
// border pieces once, around `rect`: a frame is not moved afterwards, a new one
|
||||
// is made instead. Its render (0x73a540) draws while +0xdc is set (1 from the
|
||||
// constructor), not by the visible flag. Type 1 is the windows' main frame
|
||||
// (cUI_Options 0x7184e0). Its ornaments reach about 0x20 beyond the rect, and
|
||||
// it needs a height of about 0x40 for its side pieces to join (measured on the
|
||||
// options window, 2026-10-20).
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/ui/tabs.h"
|
||||
#include "game/ui/text.h"
|
||||
|
||||
namespace game {
|
||||
|
||||
constexpr uint32_t kFrameVtable = 0x008955ac;
|
||||
constexpr size_t kFrameSize = 0xe0;
|
||||
constexpr size_t kFrameDrawn = 0xdc;
|
||||
constexpr uint32_t kFrameMain = 1;
|
||||
constexpr int kFrameMinHeight = 0x40;
|
||||
|
||||
// A new frame around `rect` (relative to the parent it is added to, or the
|
||||
// screen without one); null if out of memory.
|
||||
inline uint8_t* NewFrame(const char* name, UiRect rect, uint32_t type = kFrameMain) {
|
||||
void* memory = GameNew(kFrameSize);
|
||||
if (!memory) {
|
||||
return nullptr;
|
||||
}
|
||||
using FrameFn = uint8_t*(__fastcall*)(void*, void*, const char*, uint32_t, UiRect*, uint32_t);
|
||||
return reinterpret_cast<FrameFn>(0x00739f90)(memory, nullptr, name, type, &rect, 1);
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
// cUI_NetGranny (vtable 0x894c8c, 0x634 bytes, window name UI_WND_GRANNY): the
|
||||
// game's 3D view of a hero with its equipment, as the export screen's slots
|
||||
// (cUI_Character +0x1b4, 0x710f31) and the multiplayer lobby showed them.
|
||||
//
|
||||
// The model comes from a hero info (0x22c bytes: class at +4, the equipment's
|
||||
// looks), which LoadHeroFile (0x61d190, cdecl (path, 0, info)) reads from a
|
||||
// hero file without touching the player hero (0x70bf2c).
|
||||
//
|
||||
// Render (vf14 -> vf44 0x6f17f0 (device, 0, 1), only while visible; the last
|
||||
// argument adds the name and level below the model, 0x6f1260). With +0x53d
|
||||
// clear the model stands at the rect's horizontal centre, its feet at
|
||||
// rect.y + h * k, projected with the creatures' ortho camera (x -512..512
|
||||
// around the screen centre, widened by the widescreen mod); size factor
|
||||
// +0x630 (2.5 from the constructor). Two lights are made on the first render
|
||||
// (0x6f0f10): a dim blue set (the lobby) with +0x53f clear, a white one with
|
||||
// it set. With +0x53d set (cUI_Character) the model is placed in a
|
||||
// perspective scene instead.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/ui/tabs.h"
|
||||
#include "game/ui/text.h"
|
||||
|
||||
namespace game {
|
||||
|
||||
constexpr size_t kNetGrannySize = 0x634;
|
||||
constexpr size_t kNetGrannyInScene = 0x53d; // u8, see above
|
||||
constexpr size_t kNetGrannyWhiteLight = 0x53f; // u8, before the first render
|
||||
constexpr size_t kNetGrannyScale = 0x630; // float
|
||||
constexpr size_t kHeroInfoSize = 0x22c;
|
||||
|
||||
// cUI_NetGranny::cUI_NetGranny (0x6f06f0, thiscall (rect by value), ret 0xc).
|
||||
inline uint8_t* NewNetGranny(UiRect rect) {
|
||||
void* memory = GameNew(kNetGrannySize);
|
||||
using ConstructFn = uint8_t*(__fastcall*)(void*, void*, UiRect);
|
||||
return memory ? reinterpret_cast<ConstructFn>(0x006f06f0)(memory, nullptr, rect) : nullptr;
|
||||
}
|
||||
|
||||
// The model alone, without its name and level (vf44, thiscall (device,
|
||||
// 0, label), ret 0xc).
|
||||
inline void RenderNetGranny(uint8_t* granny, void* device) {
|
||||
using RenderFn = void(__fastcall*)(void*, void*, void*, uint32_t, uint8_t);
|
||||
reinterpret_cast<RenderFn>((*reinterpret_cast<void***>(granny))[0x44 / 4])(granny, nullptr, device, 0, 0);
|
||||
}
|
||||
|
||||
// Shows the hero of `info` (0x6f4b20, thiscall (info, x, y, z, turn), ret
|
||||
// 0x14; the four floats place it in the perspective scene only).
|
||||
inline void SetNetGrannyHero(uint8_t* granny, const void* info) {
|
||||
using SetFn = void(__fastcall*)(void*, void*, const void*, float, float, float, float);
|
||||
reinterpret_cast<SetFn>(0x006f4b20)(granny, nullptr, info, 0.0f, 0.0f, 0.0f, 0.0f);
|
||||
}
|
||||
|
||||
// No hero (0x6f4ae0, thiscall ()).
|
||||
inline void ClearNetGrannyHero(uint8_t* granny) {
|
||||
reinterpret_cast<void(__fastcall*)(void*, void*)>(0x006f4ae0)(granny, nullptr);
|
||||
}
|
||||
|
||||
// The hero info of a hero file (a path as the game's file functions take it);
|
||||
// false if it cannot be read. `info` holds kHeroInfoSize bytes.
|
||||
inline bool ReadHeroInfo(const char* path, void* info) {
|
||||
using LoadFn = uint8_t(__cdecl*)(const char*, uint32_t, void*);
|
||||
return reinterpret_cast<LoadFn>(0x0061d190)(path, 0, info) != 0;
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
// The large map (M, cUI_Megamap): a scrollable view of the 2048x2816 world
|
||||
// map image. docs/hud.md, mods/megamap.cpp.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace game {
|
||||
|
||||
// Scroll position (map pixel at the view's left / top edge).
|
||||
constexpr size_t kMegamapScrollX = 0xc016c;
|
||||
constexpr size_t kMegamapScrollY = 0xc0170;
|
||||
|
||||
inline int32_t MegamapScrollX(const uint8_t* megamap) {
|
||||
return *reinterpret_cast<const int32_t*>(megamap + kMegamapScrollX);
|
||||
}
|
||||
inline int32_t MegamapScrollY(const uint8_t* megamap) {
|
||||
return *reinterpret_cast<const int32_t*>(megamap + kMegamapScrollY);
|
||||
}
|
||||
|
||||
struct Vec3 {
|
||||
float x, y, z;
|
||||
};
|
||||
|
||||
// cUI_Megamap::WorldToMapPixel (0x6cb130, thiscall (out, world x / y / level),
|
||||
// ret 8): the map pixel of a world position (world units, 53.66563 per tile).
|
||||
// Linear: the world plane projection 0x622700, then x * 0.007446 + 1018.75,
|
||||
// y * 0.014914 - 186.95.
|
||||
inline Vec3 WorldToMapPixel(uint8_t* megamap, const Vec3& world) {
|
||||
Vec3 pixel = {};
|
||||
using Fn = void(__fastcall*)(uint8_t*, void*, Vec3*, const Vec3*);
|
||||
reinterpret_cast<Fn>(0x006cb130)(megamap, nullptr, &pixel, &world);
|
||||
return pixel;
|
||||
}
|
||||
|
||||
constexpr float kWorldUnitsPerTile = 53.66563f; // 0x8946b0
|
||||
|
||||
} // 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,9 +136,60 @@ 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()};
|
||||
}
|
||||
|
||||
uint32_t GameTextIdByKey(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 ? hash | 0x80000000 : 0;
|
||||
}
|
||||
|
||||
std::wstring GameTextByKey(const char* key) {
|
||||
const uint32_t id = GameTextIdByKey(key);
|
||||
return id ? GameText(id) : 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);
|
||||
if (const auto found = g_overrides.find(id); found != g_overrides.end()) {
|
||||
return &found->second->string;
|
||||
}
|
||||
const size_t index = id - kFirstOwnText;
|
||||
if (index < Texts().size()) {
|
||||
return &Texts()[index];
|
||||
@@ -136,6 +197,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,23 @@ 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);
|
||||
// The id of the game's text under key `key` (0 if the key has no hash), e.g.
|
||||
// for SetTextOverride.
|
||||
uint32_t GameTextIdByKey(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,34 @@
|
||||
#pragma once
|
||||
// The game's textures by name (a .TGA in the texture paks), as cGranny's
|
||||
// effects load them (0x401232): the texture manager (0x656c80, cdecl (),
|
||||
// created on first use, kept at 0x13e57b4) gives the id of a name (0x657620,
|
||||
// thiscall (name), ret 4); the texture cache (0x13e57b8) loads it on demand
|
||||
// (0x65f010, thiscall (id, 0), ret 8), its Direct3D surface at +0x14 (as
|
||||
// cUI_Button's render binds it, 0x72afb7).
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace game {
|
||||
|
||||
constexpr uintptr_t kTextureCache = 0x013e57b8;
|
||||
constexpr size_t kTextureSurface = 0x14;
|
||||
|
||||
inline uint32_t TextureId(const char* name) {
|
||||
void* manager = reinterpret_cast<void*(__cdecl*)()>(0x00656c80)();
|
||||
using IdFn = uint32_t(__fastcall*)(void*, void*, const char*);
|
||||
return manager ? reinterpret_cast<IdFn>(0x00657620)(manager, nullptr, name) : 0;
|
||||
}
|
||||
|
||||
// The Direct3D surface of texture `id`, loaded if needed; null if none.
|
||||
inline void* TextureSurface(uint32_t id) {
|
||||
void* cache = *reinterpret_cast<void**>(kTextureCache);
|
||||
if (!cache || !id) {
|
||||
return nullptr;
|
||||
}
|
||||
using GetFn = uint8_t*(__fastcall*)(void*, void*, uint32_t, uint32_t);
|
||||
uint8_t* texture = reinterpret_cast<GetFn>(0x0065f010)(cache, nullptr, id, 0);
|
||||
return texture ? *reinterpret_cast<void**>(texture + kTextureSurface) : nullptr;
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,65 @@
|
||||
#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);
|
||||
}
|
||||
|
||||
// The UI manager's window pointers by slot (dwords).
|
||||
constexpr int kUiSlotMegamap = 0x25; // cUI_Megamap, UI_WND_MEGAMAP
|
||||
|
||||
inline uint8_t* UiWindow(const uint32_t* manager, int slot) {
|
||||
return manager ? reinterpret_cast<uint8_t*>(static_cast<uintptr_t>(manager[slot])) : nullptr;
|
||||
}
|
||||
|
||||
// The window in `slot` exists and is visible (control flags +0x10 bit 0).
|
||||
inline bool UiWindowShown(const uint32_t* manager, int slot) {
|
||||
constexpr size_t kControlFlags = 0x10;
|
||||
const uint8_t* window = UiWindow(manager, slot);
|
||||
return window && (*reinterpret_cast<const uint32_t*>(window + kControlFlags) & 1) != 0;
|
||||
}
|
||||
|
||||
// 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,68 @@
|
||||
// The game's anti-cheat is gone (requests of 2026-10-25: the Gladiator
|
||||
// "randomly exploded" fighting a dragon on Silber; then "i want all that weird
|
||||
// cheater detection code gone"). docs/anticheat.md.
|
||||
//
|
||||
// Measured: the game keeps two xor-ed mirrors of the local hero's attribute /
|
||||
// skill / gold fields (0x182cddc on a protected page, 0x182cde0 a heap block),
|
||||
// written after every legitimate change. Checks compare the hero with them,
|
||||
// reset every mismatching field (attributes to 1, skill levels to 0) and on
|
||||
// any mismatch kill the hero ("*** Cheater detected! *** Hase!") and set the
|
||||
// cheater flag 0x182cdd0, which the hero file keeps. Any change the game does
|
||||
// not route through its mirror writers (e.g. loading the shared hero into
|
||||
// another world) made the checks fire.
|
||||
//
|
||||
// The mod: every check is skipped through the game's own path for "not the
|
||||
// local hero" (or straight to the code after the check, with the registers that
|
||||
// code reads), so no field is reset and no hero killed; the cheater flag is
|
||||
// never set. The mirror writers stay (they only fill the unused mirrors).
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::anticheat {
|
||||
namespace {
|
||||
|
||||
constexpr re::CodeReplacement kNoAntiCheat[] = {
|
||||
// cCreature::Think: the trap for a level more than 3 above the experience's
|
||||
// (raises fields behind the mirrors' back): `jle` past it -> `jmp`
|
||||
{0x005385ca, {0x0f, 0x8e, 0xd8, 0x03, 0x00, 0x00}, {0xe9, 0xd9, 0x03, 0x00, 0x00, 0x90}, 6},
|
||||
// cCreature::Think: the check in 1 of 101 ticks: `jne` past it -> `jmp`
|
||||
{0x005389b8, {0x0f, 0x85, 0x0d, 0x06, 0x00, 0x00}, {0xe9, 0x0e, 0x06, 0x00, 0x00, 0x90}, 6},
|
||||
// inline checks behind "is this the local hero": `jne` past them -> `jmp`
|
||||
{0x00564c50, {0x0f, 0x85, 0x89, 0x05, 0x00, 0x00}, {0xe9, 0x8a, 0x05, 0x00, 0x00, 0x90}, 6},
|
||||
{0x0057e07d, {0x0f, 0x85, 0xd8, 0x05, 0x00, 0x00}, {0xe9, 0xd9, 0x05, 0x00, 0x00, 0x90}, 6},
|
||||
{0x00585bb0, {0x0f, 0x85, 0x55, 0x06, 0x00, 0x00}, {0xe9, 0x56, 0x06, 0x00, 0x00, 0x90}, 6},
|
||||
// the quest manager's hero data writer: past the check to 0x46606b (it
|
||||
// writes registers before reading them; the stream keeps its clean marker)
|
||||
{0x004659dc, {0x8b, 0x0d, 0xc0, 0x3b, 0xad, 0x00}, {0xe9, 0x8a, 0x06, 0x00, 0x00, 0x90}, 6},
|
||||
// 0x619d33: past the check to 0x61a2de with ebp as the check leaves it
|
||||
// (`mov ebp, [esp+0x34]`; edi already holds 0x88e874)
|
||||
{0x00619d33, {0x8b, 0x0d, 0xdc, 0xcd, 0x82, 0x01}, {0x8b, 0x6c, 0x24, 0x34, 0x90, 0x90}, 6},
|
||||
{0x00619d39, {0x8d, 0x44, 0x24, 0x14, 0x50}, {0xe9, 0xa0, 0x05, 0x00, 0x00}, 5},
|
||||
// VerifyHeroMirror (0x5905f0, plain ret, 4 callers test al): `mov al, 1; ret`
|
||||
{0x005905f0, {0x51, 0x8b, 0x0d}, {0xb0, 0x01, 0xc3}, 3},
|
||||
// stream checks of the quest manager's hero data: the cheat marker
|
||||
// 0xfea4fea4 read (`jne` past the punishment -> `jmp`) and three stored
|
||||
// values compared with the hero (`je` -> `jmp`)
|
||||
{0x00467782, {0x0f, 0x85, 0xbd, 0x01, 0x00, 0x00}, {0xe9, 0xbe, 0x01, 0x00, 0x00, 0x90}, 6},
|
||||
{0x0046a60b, {0x74, 0x18}, {0xeb, 0x18}, 2},
|
||||
{0x0046a64e, {0x74, 0x18}, {0xeb, 0x18}, 2},
|
||||
{0x0046a696, {0x74, 0x18}, {0xeb, 0x18}, 2},
|
||||
// loadHero: the hero file's cheater flag (`mov byte [0x182cdd0], 1` -> 0)
|
||||
{0x006066eb, {0xc6, 0x05, 0xd0, 0xcd, 0x82, 0x01, 0x01}, {0xc6, 0x05, 0xd0, 0xcd, 0x82, 0x01, 0x00}, 7},
|
||||
// character selection: the flag from the hero record's mark (+0x3ef):
|
||||
// `setne cl` -> `xor cl, cl`
|
||||
{0x0070da8c, {0x0f, 0x95, 0xc1}, {0x32, 0xc9, 0x90}, 3},
|
||||
};
|
||||
|
||||
void Startup() {
|
||||
const int failed = re::ReplaceAll(kNoAntiCheat);
|
||||
re::Log("anticheat: removed (%d patches failed)", failed);
|
||||
}
|
||||
RE_STARTUP(AntiCheatStartup, Startup);
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::anticheat
|
||||
@@ -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
|
||||
@@ -19,6 +19,7 @@
|
||||
#include "game/engine/fx.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "mods/dwarfarts.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
@@ -126,6 +127,9 @@ uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_
|
||||
RE_HOOK(0x00582c10, Arts_AddEffect);
|
||||
uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_t a2, float value, float duration,
|
||||
uint32_t art, uint32_t a6, uint32_t a7, uint32_t a8) {
|
||||
if (!mods::dwarfarts::KeepEffect(kind, art)) {
|
||||
return 1;
|
||||
}
|
||||
if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+45
-17
@@ -3,13 +3,13 @@
|
||||
// The game renders at a fixed resolution (1024x768). Unmodified, windowed mode
|
||||
// shows that as a 1024x768 popup centred on the desktop; fullscreen mode
|
||||
// switches the display mode. With this mod the game always runs windowed, the
|
||||
// popup covers the whole monitor, and each frame is scaled up into the largest
|
||||
// popup covers the whole monitor (mods/monitor.h: the preferred one), and each frame is scaled up into the largest
|
||||
// centred rectangle with the game's aspect ratio (black bars elsewhere).
|
||||
//
|
||||
// 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)
|
||||
@@ -35,6 +35,7 @@
|
||||
#include "game/render/dx_driver7.h"
|
||||
#include "mods/hud.h"
|
||||
#include "mods/input.h"
|
||||
#include "mods/monitor.h"
|
||||
#include "mods/widescreen.h"
|
||||
#include "re/config.h"
|
||||
#include "game/addresses.h"
|
||||
@@ -56,14 +57,12 @@ struct Viewport {
|
||||
HWND window = nullptr;
|
||||
bool runInBackground = false;
|
||||
Layout full; // the whole back buffer
|
||||
Layout classic; // its top-left 1024x768 (menus, loading screen; widescreen mod)
|
||||
};
|
||||
|
||||
Viewport g_viewport;
|
||||
std::atomic<bool> g_classicShown = false; // which layout the last presented frame used
|
||||
|
||||
const Layout& CurrentLayout() {
|
||||
return g_classicShown ? g_viewport.classic : g_viewport.full;
|
||||
return g_viewport.full;
|
||||
}
|
||||
|
||||
bool Enabled() {
|
||||
@@ -92,9 +91,7 @@ RECT FitRect(int areaWidth, int areaHeight, int width, int height, bool integerS
|
||||
|
||||
void ApplyBorderless(game::DxDriver7* driver) {
|
||||
HWND window = driver->window;
|
||||
MONITORINFO monitor = {sizeof(monitor)};
|
||||
GetMonitorInfoW(MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST), &monitor);
|
||||
const RECT& area = monitor.rcMonitor;
|
||||
const RECT area = mods::monitor::GameMonitorRect();
|
||||
const int areaWidth = area.right - area.left;
|
||||
const int areaHeight = area.bottom - area.top;
|
||||
|
||||
@@ -116,10 +113,8 @@ void ApplyBorderless(game::DxDriver7* driver) {
|
||||
viewport.window = window;
|
||||
viewport.runInBackground = re::ConfigInt("borderless", "RunInBackground", 1) != 0;
|
||||
viewport.full = makeLayout(driver->mode.width, driver->mode.height);
|
||||
viewport.classic = makeLayout(std::min<int>(driver->mode.width, 1024), std::min<int>(driver->mode.height, 768));
|
||||
viewport.active = true;
|
||||
g_viewport = viewport;
|
||||
g_classicShown = false;
|
||||
|
||||
// Paint the bars: the window class background brush is black.
|
||||
InvalidateRect(window, nullptr, TRUE);
|
||||
@@ -138,6 +133,7 @@ POINT ToGame(POINT p) {
|
||||
POINT result;
|
||||
result.x = std::clamp(MulDiv(p.x - layout.client.left, layout.gameWidth, width), 0, layout.gameWidth - 1);
|
||||
result.y = std::clamp(MulDiv(p.y - layout.client.top, layout.gameHeight, height), 0, layout.gameHeight - 1);
|
||||
result.x -= mods::hud::MenuFrameOffsetX(); // the main menu's centred frame
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -174,7 +170,7 @@ int __fastcall DxDriver7_SetDisplayMode(DxDriver7* self, void* edx, void* device
|
||||
}
|
||||
DxDisplayMode windowed = *mode;
|
||||
windowed.fullscreen = 0;
|
||||
mods::widescreen::ApplyRenderSize(windowed, self->window);
|
||||
mods::widescreen::ApplyRenderSize(windowed);
|
||||
g_viewport.active = false;
|
||||
const int result = DxDriver7_SetDisplayMode_original(self, edx, deviceCaps, &windowed, unknown);
|
||||
if (result) {
|
||||
@@ -187,12 +183,6 @@ int __fastcall DxDriver7_Flip(DxDriver7* self, void* edx) {
|
||||
// The original computes the blit rectangles once, for a centred window of
|
||||
// exactly the game's size; override them on every present.
|
||||
if (g_viewport.active && self->window == g_viewport.window) {
|
||||
// Menus and the loading screen only fill the top-left 1024x768 of a
|
||||
// widened back buffer; show just that part, scaled like before.
|
||||
const bool classic = mods::widescreen::IsClassicFrame();
|
||||
if (g_classicShown.exchange(classic) != classic) {
|
||||
InvalidateRect(self->window, nullptr, TRUE); // repaint the bars for the new layout
|
||||
}
|
||||
const Layout& layout = CurrentLayout();
|
||||
self->blitSource = {0, 0, layout.gameWidth, layout.gameHeight};
|
||||
self->blitDest = layout.screen;
|
||||
@@ -256,3 +246,41 @@ SHORT WINAPI GetAsyncKeyStateWhenFocused(int key) {
|
||||
}
|
||||
return GetAsyncKeyStateWhenFocused_original(key);
|
||||
}
|
||||
|
||||
// The game's "bring the window to the front" (0x810af0, thiscall (window,
|
||||
// timeout ms), ret 8; WinMain calls it three times): ShowWindow, then
|
||||
// SetForegroundWindow until the window is in front and SetFocus until it has
|
||||
// the focus. WinMain passes 0xffffffff, which never times out. Windows refuses
|
||||
// the foreground to a process when the user clicked or typed elsewhere while
|
||||
// it started, so the game hung at startup (2026-10-20, four times; main
|
||||
// thread in this loop, log ending at WM_ACTIVATEAPP(1, 0)). Now it asks for a
|
||||
// limited time, with the input of the window in front attached (which Windows
|
||||
// allows), and carries on in the background otherwise; a click brings it up.
|
||||
constexpr DWORD kForegroundWaitMs = 2000;
|
||||
|
||||
bool __fastcall BringWindowToFront(void* self, void* edx, HWND window, DWORD timeout);
|
||||
RE_HOOK(0x00810af0, BringWindowToFront);
|
||||
bool __fastcall BringWindowToFront(void*, void*, HWND window, DWORD timeout) {
|
||||
ShowWindow(window, SW_SHOW);
|
||||
const DWORD wait = std::min(timeout, kForegroundWaitMs);
|
||||
const DWORD start = GetTickCount();
|
||||
while (GetForegroundWindow() != window && GetTickCount() - start < wait) {
|
||||
const DWORD front = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
|
||||
const DWORD self = GetCurrentThreadId();
|
||||
const bool attached = front && front != self && AttachThreadInput(front, self, TRUE);
|
||||
BringWindowToTop(window);
|
||||
SetForegroundWindow(window);
|
||||
if (attached) {
|
||||
AttachThreadInput(front, self, FALSE);
|
||||
}
|
||||
Sleep(10);
|
||||
}
|
||||
const bool inFront = GetForegroundWindow() == window;
|
||||
if (inFront) {
|
||||
SetFocus(window);
|
||||
} else {
|
||||
re::Log("borderless: the game window did not get to the front, starting in the background");
|
||||
}
|
||||
UpdateWindow(window);
|
||||
return inFront;
|
||||
}
|
||||
+119
-55
@@ -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;
|
||||
};
|
||||
|
||||
@@ -113,15 +149,67 @@ unsigned Chains(unsigned level) {
|
||||
return 1 + level / 10;
|
||||
}
|
||||
|
||||
unsigned ArtLevel(void* creature, uint16_t art) {
|
||||
uint8_t* entry = game::FindCooldownEntry(static_cast<uint8_t*>(creature) + game::kCreatureArts, art);
|
||||
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
|
||||
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
|
||||
return std::find(ids.begin(), ids.end(), id) != ids.end();
|
||||
}
|
||||
|
||||
// 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());
|
||||
// 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() {
|
||||
@@ -183,23 +271,27 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
|
||||
return;
|
||||
}
|
||||
Forget();
|
||||
const unsigned level = ArtLevel(caster, g_heroArt);
|
||||
const unsigned level = game::ArtLevel(caster, g_heroArt);
|
||||
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 +356,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 +369,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 +381,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 +410,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, game::ArtLevel(creature, art.Id()));
|
||||
}
|
||||
|
||||
struct TooltipRegistration {
|
||||
TooltipRegistration() { game::AddTooltipFilter(kArtBlitzschlag, &RewriteTooltip); }
|
||||
} g_tooltipRegistration;
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::chainlightning
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
// Characters and difficulties (characters.cpp, docs/characters.md).
|
||||
|
||||
namespace mods::characters {
|
||||
|
||||
// Saves the running game into the current character at once, without the
|
||||
// save window (on the next engine tick).
|
||||
void RequestSave();
|
||||
|
||||
// Minutes between silent autosaves (0: off); sacredK.ini [characters]
|
||||
// AutosaveMinutes, set on the options window's Allgemein page.
|
||||
int AutosaveMinutes();
|
||||
void SetAutosaveMinutes(int minutes);
|
||||
|
||||
} // namespace mods::characters
|
||||
@@ -0,0 +1,321 @@
|
||||
#include "mods/charfile.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace mods::characters {
|
||||
namespace {
|
||||
|
||||
// Save files (AMS / AMH): header, chunk table at 0x100 with 64 entries.
|
||||
constexpr size_t kChunkTable = 0x100;
|
||||
constexpr size_t kChunkEntrySize = 12;
|
||||
constexpr size_t kChunkCount = 64;
|
||||
constexpr size_t kChunkData = kChunkTable + kChunkCount * kChunkEntrySize; // 0x400
|
||||
constexpr uint32_t kChunkHero = 0xc3;
|
||||
constexpr uint32_t kChunkWorldTime = 0x95; // "Date[..] Time[..]", UTF-16
|
||||
constexpr uint32_t kChunkDescription = 0x83; // the save's name, 0x40 bytes ANSI
|
||||
constexpr uint32_t kChunkThumbnail = 0x93;
|
||||
constexpr size_t kDescriptionSize = 0x40;
|
||||
// The load window reads the header and 0xc00 table bytes (0x71de90).
|
||||
constexpr size_t kPreviewMinimumSize = 0xd00;
|
||||
|
||||
constexpr size_t kHeroLevel = 0x0;
|
||||
constexpr size_t kHeroClass = 0x4;
|
||||
constexpr size_t kHeroName = 0x8;
|
||||
constexpr size_t kHeroNameChars = 0x40;
|
||||
constexpr size_t kHeroStoryCompleted = 0xed; // one byte per difficulty (written at 0x604442..)
|
||||
|
||||
constexpr size_t kWorldCampaign = 0x24;
|
||||
constexpr uint32_t kSaveModeAncaria = 2;
|
||||
constexpr uint32_t kSaveModeUnderworld = 3;
|
||||
constexpr size_t kWorldMultiplayer = 0x2c;
|
||||
constexpr size_t kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords)
|
||||
constexpr size_t kWorldDifficulty = 0x84;
|
||||
|
||||
// Character file: "SKCH", version, section count, then {name[16], offset, size}.
|
||||
constexpr char kMagic[4] = {'S', 'K', 'C', 'H'};
|
||||
constexpr uint32_t kVersion = 1;
|
||||
constexpr size_t kSectionNameSize = 16;
|
||||
constexpr size_t kSectionEntrySize = kSectionNameSize + 8;
|
||||
constexpr size_t kFileHeaderSize = 12;
|
||||
|
||||
uint32_t U32(const Bytes& bytes, size_t offset) {
|
||||
uint32_t value = 0;
|
||||
if (offset + 4 <= bytes.size()) {
|
||||
std::memcpy(&value, bytes.data() + offset, 4);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
void PutU32(Bytes& bytes, size_t offset, uint32_t value) {
|
||||
std::memcpy(bytes.data() + offset, &value, 4);
|
||||
}
|
||||
|
||||
struct Chunk {
|
||||
uint32_t type = 0;
|
||||
uint32_t offset = 0;
|
||||
uint32_t size = 0;
|
||||
};
|
||||
|
||||
std::optional<Chunk> FindChunk(const Bytes& file, uint32_t type) {
|
||||
if (file.size() < kChunkData) {
|
||||
return std::nullopt;
|
||||
}
|
||||
for (size_t i = 0; i < kChunkCount; ++i) {
|
||||
const size_t entry = kChunkTable + i * kChunkEntrySize;
|
||||
Chunk chunk{U32(file, entry), U32(file, entry + 4), U32(file, entry + 8)};
|
||||
if (chunk.type == type && chunk.offset + uint64_t(chunk.size) <= file.size()) {
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<SaveInfo> ReadSaveInfo(const Bytes& file) {
|
||||
if (file.size() < kChunkData || (std::memcmp(file.data(), "AMS", 3) != 0 && std::memcmp(file.data(), "AMH", 3) != 0)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::optional<Chunk> hero = FindChunk(file, kChunkHero);
|
||||
if (!hero || hero->size < kHeroName + kHeroNameChars * 2) {
|
||||
return std::nullopt;
|
||||
}
|
||||
SaveInfo info;
|
||||
const uint8_t* header = file.data() + hero->offset;
|
||||
std::memcpy(&info.level, header + kHeroLevel, 4);
|
||||
std::memcpy(&info.heroClass, header + kHeroClass, 4);
|
||||
const auto* name = reinterpret_cast<const wchar_t*>(header + kHeroName);
|
||||
info.name.assign(name, wcsnlen(name, kHeroNameChars));
|
||||
if (hero->size >= kHeroStoryCompleted + info.storyCompleted.size()) {
|
||||
std::memcpy(info.storyCompleted.data(), header + kHeroStoryCompleted, info.storyCompleted.size());
|
||||
}
|
||||
info.world = file[2] == 'S';
|
||||
if (info.world) {
|
||||
info.difficulty = static_cast<int>(U32(file, kWorldDifficulty));
|
||||
info.campaign = U32(file, kWorldCampaign) == kSaveModeUnderworld ? kCampaignUnderworld : kCampaignAncaria;
|
||||
info.multiplayer = U32(file, kWorldMultiplayer) != 0;
|
||||
SYSTEMTIME time{};
|
||||
WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek,
|
||||
&time.wHour, &time.wMinute, &time.wSecond, &time.wMilliseconds};
|
||||
for (size_t i = 0; i < std::size(fields); ++i) {
|
||||
*fields[i] = static_cast<WORD>(U32(file, kWorldSavedAt + i * 4));
|
||||
}
|
||||
FILETIME stamp{};
|
||||
if (SystemTimeToFileTime(&time, &stamp)) {
|
||||
info.savedAt = (uint64_t(stamp.dwHighDateTime) << 32) | stamp.dwLowDateTime;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
bool SetHeroName(Bytes& file, const std::wstring& name) {
|
||||
const std::optional<Chunk> hero = FindChunk(file, kChunkHero);
|
||||
if (!hero || hero->size < kHeroName + kHeroNameChars * 2 || name.empty() || name.size() > kHeroNameMaxChars) {
|
||||
return false;
|
||||
}
|
||||
uint8_t* field = file.data() + hero->offset + kHeroName;
|
||||
std::memset(field, 0, kHeroNameChars * 2);
|
||||
std::memcpy(field, name.data(), name.size() * 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SetStoryCompleted(Bytes& file, int difficulty, uint8_t count) {
|
||||
const std::optional<Chunk> hero = FindChunk(file, kChunkHero);
|
||||
if (!hero || difficulty < 0 || difficulty >= kDifficultyCount ||
|
||||
hero->size < kHeroStoryCompleted + kDifficultyCount) {
|
||||
return false;
|
||||
}
|
||||
file[hero->offset + kHeroStoryCompleted + difficulty] = count;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t CampaignSaveMode(int campaign) {
|
||||
return campaign == kCampaignUnderworld ? kSaveModeUnderworld : kSaveModeAncaria;
|
||||
}
|
||||
|
||||
Bytes BuildPreview(const Bytes& world, const std::string& description, int campaign) {
|
||||
Bytes preview(world.begin(), world.begin() + std::min(world.size(), kChunkData));
|
||||
preview.resize(kChunkData);
|
||||
PutU32(preview, kWorldCampaign, CampaignSaveMode(campaign));
|
||||
std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize);
|
||||
size_t entry = kChunkTable;
|
||||
auto append = [&](uint32_t type, const uint8_t* data, uint32_t size) {
|
||||
PutU32(preview, entry, type);
|
||||
PutU32(preview, entry + 4, static_cast<uint32_t>(preview.size()));
|
||||
PutU32(preview, entry + 8, size);
|
||||
entry += kChunkEntrySize;
|
||||
preview.insert(preview.end(), data, data + size);
|
||||
};
|
||||
for (uint32_t type : {kChunkWorldTime, kChunkThumbnail}) {
|
||||
if (const std::optional<Chunk> chunk = FindChunk(world, type)) {
|
||||
append(type, world.data() + chunk->offset, chunk->size);
|
||||
}
|
||||
}
|
||||
uint8_t text[kDescriptionSize] = {};
|
||||
std::memcpy(text, description.data(), std::min(description.size(), kDescriptionSize - 1));
|
||||
append(kChunkDescription, text, kDescriptionSize);
|
||||
if (preview.size() < kPreviewMinimumSize) {
|
||||
preview.resize(kPreviewMinimumSize);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
std::optional<Bytes> ReadFile(const std::string& path) {
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return Bytes(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
bool WriteFileReplacing(const std::string& path, const Bytes& bytes) {
|
||||
const std::string temp = path + ".tmp";
|
||||
{
|
||||
std::ofstream file(temp, std::ios::binary | std::ios::trunc);
|
||||
if (!file.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
|
||||
}
|
||||
|
||||
std::string WorldSection(int campaign, int difficulty) {
|
||||
return (campaign == kCampaignUnderworld ? "underworld" : "ancaria") + std::to_string(difficulty);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Format of 2026-10-20 (before campaigns): "world<d>" were Ancaria's worlds;
|
||||
// renamed when read, written under the new name at the next save.
|
||||
constexpr char kOldWorldPrefix[] = "world";
|
||||
|
||||
std::string CurrentSectionName(const std::string& name) {
|
||||
constexpr size_t length = sizeof(kOldWorldPrefix) - 1;
|
||||
if (name.size() > length && name.compare(0, length, kOldWorldPrefix) == 0 &&
|
||||
std::all_of(name.begin() + length, name.end(), [](char c) { return c >= '0' && c <= '9'; })) {
|
||||
return WorldSection(kCampaignAncaria, std::stoi(name.substr(length)));
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool CharacterFile::Open() {
|
||||
sections_.clear();
|
||||
std::ifstream file(path_, std::ios::binary);
|
||||
uint8_t header[kFileHeaderSize];
|
||||
if (!file.read(reinterpret_cast<char*>(header), sizeof(header)) || std::memcmp(header, kMagic, 4) != 0) {
|
||||
return false;
|
||||
}
|
||||
uint32_t count = 0;
|
||||
std::memcpy(&count, header + 8, 4);
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
uint8_t entry[kSectionEntrySize];
|
||||
if (!file.read(reinterpret_cast<char*>(entry), sizeof(entry))) {
|
||||
return false;
|
||||
}
|
||||
Section section;
|
||||
const auto* name = reinterpret_cast<const char*>(entry);
|
||||
section.name = CurrentSectionName(std::string(name, strnlen(name, kSectionNameSize)));
|
||||
std::memcpy(§ion.offset, entry + kSectionNameSize, 4);
|
||||
std::memcpy(§ion.size, entry + kSectionNameSize + 4, 4);
|
||||
sections_.push_back(std::move(section));
|
||||
}
|
||||
meta = nlohmann::json::object();
|
||||
if (std::optional<Bytes> text = Read("meta")) {
|
||||
meta = nlohmann::json::parse(text->begin(), text->end(), nullptr, false);
|
||||
if (meta.is_discarded() || !meta.is_object()) {
|
||||
meta = nlohmann::json::object();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CharacterFile::Has(const std::string& name) const {
|
||||
for (const Section& section : sections_) {
|
||||
if (section.name == name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<Bytes> CharacterFile::Read(const std::string& name) const {
|
||||
for (const Section& section : sections_) {
|
||||
if (section.name != name) {
|
||||
continue;
|
||||
}
|
||||
if (section.data) {
|
||||
return section.data;
|
||||
}
|
||||
std::ifstream file(path_, std::ios::binary);
|
||||
Bytes bytes(section.size);
|
||||
if (!file.seekg(section.offset) ||
|
||||
!file.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void CharacterFile::Set(const std::string& name, Bytes bytes) {
|
||||
for (Section& section : sections_) {
|
||||
if (section.name == name) {
|
||||
section.size = static_cast<uint32_t>(bytes.size());
|
||||
section.data = std::move(bytes);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Section section;
|
||||
section.name = name;
|
||||
section.size = static_cast<uint32_t>(bytes.size());
|
||||
section.data = std::move(bytes);
|
||||
sections_.push_back(std::move(section));
|
||||
}
|
||||
|
||||
void CharacterFile::Remove(const std::string& name) {
|
||||
std::erase_if(sections_, [&](const Section& section) { return section.name == name; });
|
||||
}
|
||||
|
||||
bool CharacterFile::Save() {
|
||||
const std::string text = meta.dump(2);
|
||||
Set("meta", Bytes(text.begin(), text.end()));
|
||||
|
||||
std::vector<Bytes> contents;
|
||||
for (const Section& section : sections_) {
|
||||
std::optional<Bytes> bytes = Read(section.name);
|
||||
if (!bytes) {
|
||||
return false;
|
||||
}
|
||||
contents.push_back(std::move(*bytes));
|
||||
}
|
||||
Bytes out(kFileHeaderSize + sections_.size() * kSectionEntrySize);
|
||||
std::memcpy(out.data(), kMagic, 4);
|
||||
PutU32(out, 4, kVersion);
|
||||
PutU32(out, 8, static_cast<uint32_t>(sections_.size()));
|
||||
for (size_t i = 0; i < sections_.size(); ++i) {
|
||||
const size_t entry = kFileHeaderSize + i * kSectionEntrySize;
|
||||
std::memcpy(out.data() + entry, sections_[i].name.data(), std::min(sections_[i].name.size(), kSectionNameSize - 1));
|
||||
PutU32(out, entry + kSectionNameSize, static_cast<uint32_t>(out.size()));
|
||||
PutU32(out, entry + kSectionNameSize + 4, static_cast<uint32_t>(contents[i].size()));
|
||||
out.insert(out.end(), contents[i].begin(), contents[i].end());
|
||||
}
|
||||
if (!WriteFileReplacing(path_, out)) {
|
||||
return false;
|
||||
}
|
||||
// The file now holds every section; offsets as just written.
|
||||
for (size_t i = 0; i < sections_.size(); ++i) {
|
||||
const size_t entry = kFileHeaderSize + i * kSectionEntrySize;
|
||||
sections_[i].offset = U32(out, entry + kSectionNameSize);
|
||||
sections_[i].size = U32(out, entry + kSectionNameSize + 4);
|
||||
sections_[i].data.reset();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mods::characters
|
||||
@@ -0,0 +1,103 @@
|
||||
#pragma once
|
||||
// Character files (docs/characters.md): one file per character with its shared
|
||||
// hero and one world per difficulty, plus reading the game's save files.
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace mods::characters {
|
||||
|
||||
using Bytes = std::vector<uint8_t>;
|
||||
|
||||
// Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4).
|
||||
constexpr int kDifficultyCount = 5;
|
||||
|
||||
// The campaigns (g_campaign 0x182cdc8): Ancaria 0, Underworld 1.
|
||||
constexpr int kCampaignAncaria = 0;
|
||||
constexpr int kCampaignUnderworld = 1;
|
||||
constexpr int kCampaignCount = 2;
|
||||
|
||||
// What the game's save files (GAME.PAK "AMS", hero files .pax "AMH") say about
|
||||
// their hero: header 0x100 bytes, then a table of 64 chunks {type, offset,
|
||||
// size}; chunk 0xc3 is the hero header.
|
||||
struct SaveInfo {
|
||||
std::wstring name; // hero header +8, UTF-16
|
||||
uint32_t heroClass = 0; // hero header +4: 1 Seraphim .. 9 Daemonin
|
||||
uint32_t level = 0; // hero header +0
|
||||
// Story completions per difficulty (hero header +0xed.., cStatsManager
|
||||
// +0x5704; the hero file's are the hero's, loadHero sets them).
|
||||
std::array<uint8_t, kDifficultyCount> storyCompleted{};
|
||||
bool world = false; // a save game (AMS), not a hero file
|
||||
int difficulty = 0; // AMS header +0x84 (g_gameLevel)
|
||||
int campaign = kCampaignAncaria; // AMS header +0x24 (CampaignSaveMode)
|
||||
bool multiplayer = false; // AMS header +0x2c
|
||||
uint64_t savedAt = 0; // AMS header +0x5c SYSTEMTIME, as FILETIME
|
||||
};
|
||||
std::optional<SaveInfo> ReadSaveInfo(const Bytes& file);
|
||||
|
||||
// Writes `name` as the hero's name (hero header +8, 0x40 UTF-16 characters
|
||||
// with the terminator), the only place the name is stored; false if the file
|
||||
// has no hero header or the name is empty or too long.
|
||||
constexpr size_t kHeroNameMaxChars = 63;
|
||||
bool SetHeroName(Bytes& file, const std::wstring& name);
|
||||
|
||||
// Sets the hero header's story completions of `difficulty` (+0xed..; in a hero
|
||||
// file they are the hero's, loadHero puts them into cStatsManager); false if
|
||||
// the file has no hero header.
|
||||
bool SetStoryCompleted(Bytes& file, int difficulty, uint8_t count);
|
||||
|
||||
// The save header's campaign field (+0x24): 2 Ancaria, 3 Underworld (written
|
||||
// at 0x61a5c5, read back by cEngine::Load 0x61bafd).
|
||||
uint32_t CampaignSaveMode(int campaign);
|
||||
|
||||
// A small save game the game's load window can list: `world`'s header with its
|
||||
// date, its thumbnail and world time, and `description` (ANSI, up to 63
|
||||
// characters) as the save's name. The load window lists only saves of the
|
||||
// running campaign (0x71e2b9): the preview names `campaign`.
|
||||
Bytes BuildPreview(const Bytes& world, const std::string& description, int campaign);
|
||||
|
||||
// Reading and writing whole files (paths in the game's code page).
|
||||
std::optional<Bytes> ReadFile(const std::string& path);
|
||||
bool WriteFileReplacing(const std::string& path, const Bytes& bytes);
|
||||
|
||||
// A character file: named sections ("meta" JSON, "hero" .pax, "world<d>"
|
||||
// GAME.PAK). Sections are read from disk on demand; Save writes all of them
|
||||
// to a temporary file and replaces the old one.
|
||||
class CharacterFile {
|
||||
public:
|
||||
explicit CharacterFile(std::string path) : path_(std::move(path)) {}
|
||||
|
||||
// Reads the section table and the meta; false if the file is missing or
|
||||
// not a character file.
|
||||
bool Open();
|
||||
|
||||
const std::string& Path() const { return path_; }
|
||||
bool Has(const std::string& section) const;
|
||||
std::optional<Bytes> Read(const std::string& section) const;
|
||||
void Set(const std::string& section, Bytes bytes);
|
||||
void Remove(const std::string& section);
|
||||
bool Save();
|
||||
|
||||
nlohmann::json meta = nlohmann::json::object();
|
||||
|
||||
private:
|
||||
struct Section {
|
||||
std::string name;
|
||||
uint32_t offset = 0; // in the file; unused once `data` is set
|
||||
uint32_t size = 0;
|
||||
std::optional<Bytes> data; // set or changed since Open
|
||||
};
|
||||
std::string path_;
|
||||
std::vector<Section> sections_;
|
||||
};
|
||||
|
||||
// A world's section: "ancaria<d>" / "underworld<d>", d = g_gameLevel. Sections
|
||||
// are named, not slotted: more difficulties (or campaigns) are more sections.
|
||||
std::string WorldSection(int campaign, int difficulty);
|
||||
|
||||
} // namespace mods::characters
|
||||
+1
-2
@@ -33,8 +33,7 @@ using namespace game; // combo and cooldown layout (arts.h)
|
||||
|
||||
// The art's level now (cooldown entry level + bonus levels), 0 if unknown.
|
||||
uint16_t CurrentLevel(uint8_t* arts, uint32_t art) {
|
||||
uint8_t* entry = art ? FindCooldownEntry(arts, static_cast<uint16_t>(art)) : nullptr;
|
||||
return entry ? static_cast<uint16_t>(entry[kCooldownLevel] + entry[kCooldownBonusLevels]) : 0;
|
||||
return art ? static_cast<uint16_t>(ArtLevelIn(arts, static_cast<uint16_t>(art))) : 0;
|
||||
}
|
||||
|
||||
void SyncCombos(uint8_t* hero) {
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
// The dwarf's Kanonenschuss (0x43f) and Mörsergranate (0x43e) (request of
|
||||
// 2026-10-25, docs/dwarf-arts.md).
|
||||
//
|
||||
// Kanonenschuss: "remove the knockback, instead it can be just a stun". On a
|
||||
// successful stun roll the dwarf's combat move code (0x5145d0, case 0x43f)
|
||||
// plays MOTION_HIT_A on the target (0x5936e0 at 0x517709) and sends it the
|
||||
// stun order (cEvent_creature 3 from 0x5177aa, +0x14 = 100). The game's stun
|
||||
// is a push (measured: about 100 units away from the attacker in 0.3 s): the
|
||||
// order sets creature flag 0x20000, the push left (+0x24d) and the attacker
|
||||
// (+0x259); while the flag is set, cCreature::Update (branch 0x594c82) skips
|
||||
// everything else and moves the creature away from the attacker, lowering
|
||||
// the push left by each step, and clears the flag at 0. The mod skips the
|
||||
// motion, and for creatures the cannon stunned the push step (0x594deb)
|
||||
// moves them by 0 and keeps the push left above 0 for kStunMs: they stand
|
||||
// still, doing nothing, then carry on. The shot's ray (25 steps of 40 = 1000) gets 45 steps (1800),
|
||||
// for the widened view ("range needs to be increased to accommodate for the
|
||||
// increased viewport", 2026-10-26).
|
||||
//
|
||||
// Mörsergranate: "the radius of the explosion should increase every 5 skill
|
||||
// levels". The grenade (type 0x3fa) creates an impact object
|
||||
// (TYPE_FX_GRENADEIMPACT, 0x3fc) where it lands; that object gathers its
|
||||
// targets with cObjectManager 0x5ff820 (called from 0x5ab400 at 0x5ab63d): a
|
||||
// creature is hit when its distance is below the impact type's size (150)
|
||||
// plus the creature type's size (object type table 0x428bb0). The mod passes
|
||||
// that call a radius of size × (1 + kRadiusGrowth × (level / 5)), with the
|
||||
// level of the owner's Mörsergranate.
|
||||
//
|
||||
// Impact marker (asked 2026-10-25): where the grenade will land and its
|
||||
// explosion radius, a red circle with low alpha on the ground. The landing
|
||||
// point is decided at launch (0x5145d0 case 0x43e: the target's position, or
|
||||
// 200 units from the dwarf with +-15 jitter when closer) and sent to the new
|
||||
// grenade with cEventFX 0x96 (constructed at 0x5169fa: code, owner, 0,
|
||||
// position area / x / y / level). The construct call notes the grenade and
|
||||
// the point; the circle is drawn after the world view's ground (0x62d530,
|
||||
// before the objects) until the impact gathers its targets (the grenade
|
||||
// object itself stays about 30 s longer).
|
||||
//
|
||||
// Eingraben (spell 0x39), the dwarf's escape (request of 2026-10-28): "cast a
|
||||
// protective barrier around the Dwarf (current visual effect), clear him of
|
||||
// all movement impeding effects and then teleport to the position marked by
|
||||
// the cursor (if possible)", shown as the dwarf going underground and coming
|
||||
// out at the target, 1 s each; range the visible area; the barrier is the
|
||||
// skill's current defensive bonus for 3 s; "he should be able to see the
|
||||
// target location"; like the battlemage's Phasenverschiebung. The spells run
|
||||
// in cCreature::CastSpell (0x5222c0, thiscall (cast), ret 4; cast +0x08 /
|
||||
// +0x0c the ground point, +0x20 the spell). Case 0x39 creates object type
|
||||
// 0x480 at the caster with cEventFX 0x9d (owner and target the caster) and
|
||||
// plays sound 0x840: the barrier, which gives the bonus. Case 1
|
||||
// (Phasenverschiebung) walks from the caster towards the target in steps of
|
||||
// 10 while the cell is passable (world 0x636b60) and keeps the last point a
|
||||
// creature can stand on (0x636eb0); a wall in the way ends the walk, so the
|
||||
// target has to be in sight. The mod aims Eingraben at a point (definition
|
||||
// +0x3c: 5 around the caster -> 3 a point), runs the original case (the barrier
|
||||
// and its bonus last kBarrierSeconds) and moves the dwarf to the end of that
|
||||
// walk; the cursor limits it to the visible area. The move takes 2 s without
|
||||
// orders: the barrier turns solid, he moves inside it, it clears again
|
||||
// ("not instant ... from transparent to solid and back", 2026-10-28; sinking
|
||||
// him into the ground failed: the camera follows his height).
|
||||
|
||||
#include "mods/dwarfarts.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
#include "game/engine/art.h"
|
||||
#include "game/engine/arts.h"
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/engine/worldview.h"
|
||||
#include "game/ui/draw.h"
|
||||
#include "mods/widescreen.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::dwarfarts {
|
||||
|
||||
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
|
||||
uint32_t only, int32_t radius, const uint8_t* fx);
|
||||
void DrawMarkers(const uint8_t* view);
|
||||
|
||||
namespace {
|
||||
|
||||
// The stunned target's motion: push 3.0f, push 0.25f... (0x5176f8) up to the
|
||||
// call of 0x5936e0 (0x517709, 5 bytes): jumped over.
|
||||
constexpr uint32_t kCannonStunMotion = 0x005176f8;
|
||||
constexpr uint8_t kStunMotionPush[] = {0x68, 0x00}; // push 0x3e800000 (first two bytes)
|
||||
constexpr uint8_t kSkipStunMotion[] = {0xeb, 0x14}; // jmp 0x51770e, after the call
|
||||
|
||||
// The ray's step count: cmp edi, 19h (0x517389, imm8 at +2); steps of 40.
|
||||
constexpr uint32_t kCannonRaySteps = 0x00517389;
|
||||
constexpr uint8_t kRayStepsOriginal = 25;
|
||||
constexpr uint8_t kRaySteps = 45;
|
||||
|
||||
// The cannon's stun order: the return address of its call [eax+18h].
|
||||
constexpr uint32_t kCannonStunOrderReturn = 0x005177ad;
|
||||
constexpr uint32_t kOrderStun = 3;
|
||||
constexpr DWORD kStunMs = 1500;
|
||||
// The push step in cCreature::Update: lea edx,[ebp+18h]; mov edi,[esp+60h]
|
||||
// (y offset; x offset in ebx); mov [ebp+24dh],eax (push left).
|
||||
constexpr uint32_t kPushStep = 0x00594deb;
|
||||
constexpr uint8_t kPushStepCode[] = {0x8d, 0x55, 0x18, 0x8b, 0x7c, 0x24, 0x60, 0x89, 0x85, 0x4d, 0x02, 0x00, 0x00};
|
||||
|
||||
std::map<uint32_t, DWORD> g_stunEnds; // creature id -> GetTickCount at which its stun ends
|
||||
|
||||
constexpr uint32_t kGrenadeImpactType = 0x3fc; // TYPE_FX_GRENADEIMPACT
|
||||
constexpr uint32_t kImpactGatherCall = 0x005ab63d; // in 0x5ab400
|
||||
constexpr uint32_t kGatherInRange = 0x005ff820; // cObjectManager: objects within type sizes
|
||||
constexpr size_t kFxOwner = 0x54; // u32: the creature the effect hits for
|
||||
constexpr unsigned kLevelsPerStep = 5;
|
||||
constexpr float kRadiusGrowth = 0.1f; // of the impact's own size, per step
|
||||
|
||||
// The size of object type `type` (0x428bb0 on the type table *0xaa9564,
|
||||
// 50 when the table has none).
|
||||
int32_t TypeSize(uint32_t type) {
|
||||
using Fn = int32_t(__fastcall*)(void* table, void* edx, uint32_t type);
|
||||
return reinterpret_cast<Fn>(0x00428bb0)(*reinterpret_cast<void**>(0x00aa9564), nullptr, type);
|
||||
}
|
||||
|
||||
// The explosion radius of a grenade thrown by `owner` (world units; a
|
||||
// creature is hit when within it plus its own size).
|
||||
int32_t ImpactRadiusAt(unsigned level) {
|
||||
const int32_t size = TypeSize(kGrenadeImpactType);
|
||||
return static_cast<int32_t>(static_cast<float>(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep)));
|
||||
}
|
||||
|
||||
int32_t ImpactRadius(void* owner) {
|
||||
return ImpactRadiusAt(owner ? game::ArtLevel(owner, game::kArtMoerser) : 0);
|
||||
}
|
||||
|
||||
// The tooltip (game::Art::Tooltip filter, as for Kettenblitz in
|
||||
// mods/chainlightning.cpp): sections start with the level line ("Aktuelle
|
||||
// Stufe:", in the skill book also "Nächste erlernte Stufe:") followed by
|
||||
// "Name: value" lines; each section gets "Explosionsradius: N" after its last
|
||||
// value, in that line's colour.
|
||||
constexpr const wchar_t* kCurrentLevelLine = L"Aktuelle Stufe:";
|
||||
constexpr const wchar_t* kNextLevelLine = L"Nächste erlernte Stufe:";
|
||||
constexpr const wchar_t* kValueSeparator = L": ";
|
||||
constexpr size_t kColourCodeLength = 10; // "\cAARRGGBB"
|
||||
|
||||
std::wstring LeadingColourCodes(const std::wstring& line) {
|
||||
size_t end = 0;
|
||||
while (line.compare(end, 2, L"\\c") == 0 && end + kColourCodeLength <= line.size()) {
|
||||
end += kColourCodeLength;
|
||||
}
|
||||
return line.substr(0, end);
|
||||
}
|
||||
|
||||
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
|
||||
std::vector<std::wstring> lines;
|
||||
for (size_t start = 0; start < text.size();) {
|
||||
size_t end = text.find(L'\n', start);
|
||||
end = end == std::wstring::npos ? text.size() : end + 1;
|
||||
lines.push_back(text.substr(start, end - start));
|
||||
start = end;
|
||||
}
|
||||
const unsigned current = game::ArtLevel(creature, art.Id());
|
||||
std::vector<std::wstring> out;
|
||||
unsigned level = current;
|
||||
bool inSection = false;
|
||||
size_t lastValue = SIZE_MAX; // index in `out`
|
||||
const auto closeSection = [&] {
|
||||
if (!inSection || lastValue == SIZE_MAX) {
|
||||
return;
|
||||
}
|
||||
std::wstring& after = out[lastValue];
|
||||
const bool last = after.empty() || after.back() != L'\n';
|
||||
if (last) {
|
||||
after += L'\n';
|
||||
}
|
||||
out.insert(out.begin() + static_cast<ptrdiff_t>(lastValue) + 1,
|
||||
LeadingColourCodes(after) + L"Explosionsradius: " + std::to_wstring(ImpactRadiusAt(level)) +
|
||||
(last ? L"" : L"\n"));
|
||||
};
|
||||
for (const std::wstring& line : lines) {
|
||||
const bool currentHeader = line.find(kCurrentLevelLine) != std::wstring::npos;
|
||||
const bool nextHeader = line.find(kNextLevelLine) != std::wstring::npos;
|
||||
if (currentHeader || nextHeader) {
|
||||
closeSection();
|
||||
inSection = true;
|
||||
level = nextHeader ? current + 1 : current;
|
||||
lastValue = SIZE_MAX;
|
||||
} else if (inSection && line.find(kValueSeparator) != std::wstring::npos) {
|
||||
lastValue = out.size();
|
||||
}
|
||||
out.push_back(line);
|
||||
}
|
||||
closeSection();
|
||||
text.clear();
|
||||
for (const std::wstring& line : out) {
|
||||
text += line;
|
||||
}
|
||||
}
|
||||
|
||||
// 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius),
|
||||
// ret 0x14) with the effect object in ebx.
|
||||
__declspec(naked) void GatherTargetsThunk() {
|
||||
__asm {
|
||||
push ebx // the effect object
|
||||
push dword ptr [esp+18h] // radius (-1: the type's size)
|
||||
push dword ptr [esp+18h] // only objects of this type (0: any)
|
||||
push dword ptr [esp+18h] // type
|
||||
push dword ptr [esp+18h] // position
|
||||
push dword ptr [esp+18h] // out vector
|
||||
call GatherTargets
|
||||
ret 14h
|
||||
}
|
||||
}
|
||||
|
||||
void OnOrder(void* creature, const game::CreatureOrder& order, void* caller) {
|
||||
if (order.command == kOrderStun && reinterpret_cast<uint32_t>(caller) == kCannonStunOrderReturn) {
|
||||
g_stunEnds[*reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(creature) + game::kCreatureId)] =
|
||||
GetTickCount() + kStunMs;
|
||||
}
|
||||
}
|
||||
|
||||
// The push step of `creature` with `left` push left after it: the push left
|
||||
// to store (low dword) and whether to stay in place (high dword).
|
||||
uint64_t __cdecl PushStep(uint8_t* creature, int32_t left) {
|
||||
const auto it = g_stunEnds.find(*reinterpret_cast<uint32_t*>(creature + game::kCreatureId));
|
||||
if (it == g_stunEnds.end()) {
|
||||
return static_cast<uint32_t>(left);
|
||||
}
|
||||
const bool stunned = static_cast<int32_t>(GetTickCount() - it->second) < 0;
|
||||
if (!stunned) {
|
||||
g_stunEnds.erase(it);
|
||||
}
|
||||
return (uint64_t{1} << 32) | static_cast<uint32_t>(stunned ? 1 : 0);
|
||||
}
|
||||
|
||||
__declspec(naked) void PushStepThunk() {
|
||||
__asm {
|
||||
push eax // push left after this step
|
||||
push ebp // the creature
|
||||
call PushStep
|
||||
add esp, 8
|
||||
test edx, edx
|
||||
jz move
|
||||
xor ebx, ebx // x offset
|
||||
mov dword ptr [esp+64h], 0 // y offset (the caller's [esp+60h])
|
||||
move:
|
||||
mov edi, [esp+64h]
|
||||
mov [ebp+24dh], eax
|
||||
lea edx, [ebp+18h]
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
// The cannon creates its impact effect (TYPE_FX_DWARF_SHOOT_IMPACT 0x47f,
|
||||
// attached to the target with cEventFX 0x9c) on every target it hits; it stays
|
||||
// on targets that were not pushed. CreateAt (0x5fbaa0, thiscall (type,
|
||||
// position, link, 0, 0), ret 0x14) at 0x517855, the target in ebx: only for
|
||||
// targets in the stun state (the order was not resisted); 0 otherwise, which
|
||||
// the game skips (GetObject(0) is null).
|
||||
constexpr uint32_t kImpactCreate = 0x00517855;
|
||||
constexpr uint32_t kCreateAt = 0x005fbaa0;
|
||||
constexpr size_t kCreatureFlags = 0x200;
|
||||
constexpr uint32_t kCreaturePushed = 0x20000; // the stun's push state (cCreature::Update 0x594c82)
|
||||
|
||||
uint32_t __fastcall CreateImpact(void* manager, void*, uint32_t type, const void* position, uint32_t link, uint32_t a,
|
||||
uint32_t b, const uint8_t* target) {
|
||||
if (!target || !(*reinterpret_cast<const uint32_t*>(target + kCreatureFlags) & kCreaturePushed)) {
|
||||
return 0;
|
||||
}
|
||||
using Fn = uint32_t(__fastcall*)(void*, void*, uint32_t, const void*, uint32_t, uint32_t, uint32_t);
|
||||
return reinterpret_cast<Fn>(kCreateAt)(manager, nullptr, type, position, link, a, b);
|
||||
}
|
||||
|
||||
__declspec(naked) void CreateImpactThunk() {
|
||||
__asm {
|
||||
push ebx // the target
|
||||
push dword ptr [esp+18h]
|
||||
push dword ptr [esp+18h]
|
||||
push dword ptr [esp+18h]
|
||||
push dword ptr [esp+18h]
|
||||
push dword ptr [esp+18h]
|
||||
call CreateImpact
|
||||
ret 14h
|
||||
}
|
||||
}
|
||||
|
||||
// --- impact marker ---------------------------------------------------------
|
||||
|
||||
constexpr uint32_t kGrenadeEventConstruct = 0x005169fa; // call cEventFX::Construct
|
||||
constexpr uint32_t kFxEventConstruct = 0x0041e0a0; // thiscall, ret 0x44
|
||||
constexpr uint32_t kFxEventLand = 0x96;
|
||||
constexpr size_t kObjectId = 0xc;
|
||||
constexpr uint32_t kMarkerFill = 0x40ff0000; // red, low alpha
|
||||
constexpr uint32_t kMarkerRim = 0xa0ff2020;
|
||||
constexpr float kRimWidth = 2.0f; // screen pixels
|
||||
constexpr int kCircleSegments = 48;
|
||||
|
||||
// A grenade object outlives its explosion by about 30 s (measured
|
||||
// 2026-10-26), so its marker ends with the explosion: the impact gathering
|
||||
// its targets (GatherTargets; it does so on several frames, at exactly the
|
||||
// noted landing point).
|
||||
constexpr float kImpactMatchDistance = 100.0f; // world units; the landing jitter is +-15
|
||||
|
||||
struct Grenade {
|
||||
uint8_t* object;
|
||||
uint32_t id;
|
||||
uint32_t owner;
|
||||
float x, y; // landing point, world units
|
||||
float radius;
|
||||
};
|
||||
std::vector<Grenade> g_grenades;
|
||||
|
||||
// The explosion of `owner`'s grenade at `position` (world units): ends the
|
||||
// marker of that grenade, the owner's one closest to it.
|
||||
void OnImpact(uint32_t owner, float x, float y) {
|
||||
auto closest = g_grenades.end();
|
||||
float best = kImpactMatchDistance;
|
||||
for (auto it = g_grenades.begin(); it != g_grenades.end(); ++it) {
|
||||
const float distance = std::hypot(it->x - x, it->y - y);
|
||||
if (it->owner == owner && distance < best) {
|
||||
best = distance;
|
||||
closest = it;
|
||||
}
|
||||
}
|
||||
if (closest != g_grenades.end()) {
|
||||
g_grenades.erase(closest);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct's arguments: code, owner id, target id, position (area, x, y,
|
||||
// level), ...; the new grenade object is in esi.
|
||||
void __cdecl NoteGrenade(const uint32_t* args, uint8_t* grenade) {
|
||||
if (args[0] != kFxEventLand || !grenade) {
|
||||
return;
|
||||
}
|
||||
const auto x = static_cast<float>(static_cast<int32_t>(args[4]));
|
||||
const auto y = static_cast<float>(static_cast<int32_t>(args[5]));
|
||||
const auto radius = static_cast<float>(ImpactRadius(game::GetCreature(args[1])));
|
||||
g_grenades.push_back({grenade, *reinterpret_cast<uint32_t*>(grenade + kObjectId), args[1], x, y, radius});
|
||||
}
|
||||
|
||||
__declspec(naked) void GrenadeEventThunk() {
|
||||
__asm {
|
||||
pushad
|
||||
lea eax, [esp+24h] // the arguments (above pushad and the return address)
|
||||
push esi // the grenade
|
||||
push eax
|
||||
call NoteGrenade
|
||||
add esp, 8
|
||||
popad
|
||||
mov eax, kFxEventConstruct
|
||||
jmp eax
|
||||
}
|
||||
}
|
||||
|
||||
bool Alive(const Grenade& grenade) {
|
||||
return game::GetObjectById(grenade.id) == grenade.object;
|
||||
}
|
||||
|
||||
using SetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD);
|
||||
using GetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD*);
|
||||
|
||||
// Render states for a translucent overlay without depth, restored after.
|
||||
class OverlayStates {
|
||||
public:
|
||||
explicit OverlayStates(void* device) : device_(device) {
|
||||
void** vtable = *static_cast<void***>(device);
|
||||
set_ = reinterpret_cast<SetRenderStateFn>(vtable[game::kDeviceSetRenderState]);
|
||||
const auto get = reinterpret_cast<GetRenderStateFn>(vtable[game::kDeviceGetRenderState]);
|
||||
for (size_t i = 0; i < kCount; ++i) {
|
||||
get(device_, kStates[i], &saved_[i]);
|
||||
set_(device_, kStates[i], kValues[i]);
|
||||
}
|
||||
}
|
||||
~OverlayStates() {
|
||||
for (size_t i = 0; i < kCount; ++i) {
|
||||
set_(device_, kStates[i], saved_[i]);
|
||||
}
|
||||
}
|
||||
OverlayStates(const OverlayStates&) = delete;
|
||||
OverlayStates& operator=(const OverlayStates&) = delete;
|
||||
|
||||
private:
|
||||
// D3DRENDERSTATE ZENABLE, ZWRITEENABLE off; alpha blending SRCALPHA /
|
||||
// INVSRCALPHA.
|
||||
static constexpr size_t kCount = 5;
|
||||
static constexpr DWORD kStates[kCount] = {7, 14, game::kStateAlphaBlendEnable, game::kStateSourceBlend,
|
||||
game::kStateDestinationBlend};
|
||||
static constexpr DWORD kValues[kCount] = {FALSE, FALSE, TRUE, 5, 6};
|
||||
void* device_;
|
||||
SetRenderStateFn set_ = nullptr;
|
||||
DWORD saved_[kCount] = {};
|
||||
};
|
||||
|
||||
game::ScreenVertex Vertex(const game::Point2& p, uint32_t colour) {
|
||||
return {p.x, p.y, 0, 1, colour, 0, 0, 0};
|
||||
}
|
||||
|
||||
// The circle on the ground: a translucent disc and a brighter rim.
|
||||
void DrawMarker(const uint8_t* view, void* device, const Grenade& grenade) {
|
||||
const float halfWidth = static_cast<float>(mods::widescreen::RenderWidth()) / 2;
|
||||
const auto screen = [&](float x, float y) {
|
||||
return game::PlaneToScreen(view, game::WorldToPlane(x, y), halfWidth);
|
||||
};
|
||||
const game::Point2 centre = screen(grenade.x, grenade.y);
|
||||
game::Point2 rim[kCircleSegments + 1];
|
||||
for (int i = 0; i <= kCircleSegments; ++i) {
|
||||
const float angle = 6.2831853f * static_cast<float>(i % kCircleSegments) / kCircleSegments;
|
||||
rim[i] = screen(grenade.x + grenade.radius * std::cos(angle), grenade.y + grenade.radius * std::sin(angle));
|
||||
}
|
||||
// The rim band reaches inwards from the circle by kRimWidth.
|
||||
const auto inner = [&](const game::Point2& p) {
|
||||
const float dx = centre.x - p.x, dy = centre.y - p.y;
|
||||
const float length = std::hypot(dx, dy);
|
||||
return length > kRimWidth ? game::Point2{p.x + dx / length * kRimWidth, p.y + dy / length * kRimWidth}
|
||||
: centre;
|
||||
};
|
||||
static std::vector<game::ScreenVertex> vertices;
|
||||
vertices.clear();
|
||||
for (int i = 0; i < kCircleSegments; ++i) {
|
||||
vertices.insert(vertices.end(),
|
||||
{Vertex(centre, kMarkerFill), Vertex(rim[i], kMarkerFill), Vertex(rim[i + 1], kMarkerFill)});
|
||||
}
|
||||
for (int i = 0; i < kCircleSegments; ++i) {
|
||||
const game::Point2 a = rim[i], b = rim[i + 1], c = inner(rim[i]), d = inner(rim[i + 1]);
|
||||
vertices.insert(vertices.end(), {Vertex(a, kMarkerRim), Vertex(b, kMarkerRim), Vertex(c, kMarkerRim),
|
||||
Vertex(c, kMarkerRim), Vertex(b, kMarkerRim), Vertex(d, kMarkerRim)});
|
||||
}
|
||||
game::FillTriangles(device, vertices.data(), vertices.size());
|
||||
}
|
||||
|
||||
// --- Eingraben ----------------------------------------------------------------
|
||||
|
||||
// The art's duration (0x4d24f0: definition +0x76 + level × +0x78, at most
|
||||
// +0x82 when set; Eingraben 7 + level, at most 15), which the barrier, its
|
||||
// bonus and the tooltip use: a fixed kBarrierSeconds ("down to 4 seconds",
|
||||
// 2026-10-28).
|
||||
constexpr int16_t kBarrierSeconds = 4;
|
||||
constexpr size_t kSpellDurationBase = 0x76; // i16
|
||||
constexpr size_t kSpellDurationPerLevel = 0x78; // i16
|
||||
constexpr size_t kSpellDurationMax = 0x82; // u16, 0: none
|
||||
// The barrier's effects (logged 2026-10-28, all with art 0x39): kind 0x3a
|
||||
// value 0 from its particles (0x7b9602), which holds the dwarf in place, and
|
||||
// kind 2 value 0.5 plus kinds 7..0xa value 30 from its update (0x5b5862..),
|
||||
// the defensive bonus.
|
||||
constexpr uint32_t kEffectDugIn = 0x3a;
|
||||
constexpr int32_t kWalkStep = 10; // Phasenverschiebung's step (0x52288a: 10.0)
|
||||
constexpr size_t kSpellLaunchType = 0x3c;
|
||||
|
||||
// The world (cWorld, the object manager's first field), as the art code
|
||||
// passes it to its cell checks.
|
||||
void* World() {
|
||||
void* manager = game::ObjectManager();
|
||||
return manager ? *static_cast<void**>(manager) : nullptr;
|
||||
}
|
||||
|
||||
// cWorld 0x636b60 (thiscall (position), ret 4): the cell is passable.
|
||||
bool Passable(void* world, game::MapPosition& at) {
|
||||
using Fn = bool(__fastcall*)(void*, void*, game::MapPosition*);
|
||||
return reinterpret_cast<Fn>(0x00636b60)(world, nullptr, &at);
|
||||
}
|
||||
|
||||
// cWorld 0x636eb0 (thiscall (creature id, position), ret 8): the creature can
|
||||
// stand there (may correct the position's area).
|
||||
bool Standable(void* world, uint32_t creature, game::MapPosition& at) {
|
||||
using Fn = bool(__fastcall*)(void*, void*, uint32_t, game::MapPosition*);
|
||||
return reinterpret_cast<Fn>(0x00636eb0)(world, nullptr, creature, &at);
|
||||
}
|
||||
|
||||
// Phasenverschiebung's walk (0x5227a6..): from the creature towards (x, y),
|
||||
// the last point it can stand on before a cell that is not passable.
|
||||
game::MapPosition DigDestination(uint8_t* creature, int32_t x, int32_t y) {
|
||||
const auto& from = *reinterpret_cast<const game::MapPosition*>(creature + game::kObjectPosition);
|
||||
game::MapPosition best = from;
|
||||
void* world = World();
|
||||
const float dx = static_cast<float>(x - from.x), dy = static_cast<float>(y - from.y);
|
||||
const float length = std::sqrt(dx * dx + dy * dy);
|
||||
if (!world || length < kWalkStep) {
|
||||
return best;
|
||||
}
|
||||
const auto stepX = static_cast<int32_t>(dx / length * kWalkStep);
|
||||
const auto stepY = static_cast<int32_t>(dy / length * kWalkStep);
|
||||
const auto steps = static_cast<int32_t>(length / kWalkStep);
|
||||
const uint32_t id = *reinterpret_cast<const uint32_t*>(creature + game::kCreatureId);
|
||||
game::MapPosition at = from;
|
||||
for (int32_t i = 0; i <= steps && Passable(world, at); ++i) {
|
||||
game::MapPosition stand = at;
|
||||
if (Standable(world, id, stand)) {
|
||||
best = stand;
|
||||
}
|
||||
at.x += stepX;
|
||||
at.y += stepY;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// The dig takes kFadeMs twice: the barrier turns solid, the dwarf moves
|
||||
// inside it, and it turns transparent again; he takes no orders meanwhile.
|
||||
constexpr DWORD kFadeMs = 1000;
|
||||
|
||||
struct Digging {
|
||||
uint32_t id = 0; // 0: none
|
||||
DWORD start = 0; // GetTickCount of the cast, or of the move once moved
|
||||
game::MapPosition to;
|
||||
bool moved = false;
|
||||
};
|
||||
Digging g_dig;
|
||||
|
||||
void Dig(uint8_t* creature, int32_t x, int32_t y) {
|
||||
const auto& from = *reinterpret_cast<const game::MapPosition*>(creature + game::kObjectPosition);
|
||||
const game::MapPosition to = DigDestination(creature, x, y);
|
||||
re::Log("dwarfarts: Eingraben %d,%d towards %d,%d -> %d,%d", from.x, from.y, x, y, to.x, to.y);
|
||||
if (to.x == from.x && to.y == from.y) {
|
||||
return;
|
||||
}
|
||||
g_dig = {*reinterpret_cast<const uint32_t*>(creature + game::kCreatureId), GetTickCount(), to, false};
|
||||
}
|
||||
|
||||
// Engine tick: once the barrier is solid, the move; the dig's end.
|
||||
void OnTick(uint8_t*) {
|
||||
auto* creature = g_dig.id ? static_cast<uint8_t*>(game::GetCreature(g_dig.id)) : nullptr;
|
||||
if (!creature || GetTickCount() - g_dig.start < kFadeMs) {
|
||||
g_dig.id = creature ? g_dig.id : 0;
|
||||
return;
|
||||
}
|
||||
if (g_dig.moved) {
|
||||
g_dig.id = 0;
|
||||
return;
|
||||
}
|
||||
game::Teleport(creature, static_cast<int32_t>(g_dig.to.x / game::kMapCellSize),
|
||||
static_cast<int32_t>(g_dig.to.y / game::kMapCellSize));
|
||||
g_dig.moved = true;
|
||||
g_dig.start = GetTickCount();
|
||||
}
|
||||
|
||||
bool AcceptOrder(void* creature, const game::CreatureOrder&) {
|
||||
return !g_dig.id || *reinterpret_cast<const uint32_t*>(static_cast<uint8_t*>(creature) + game::kCreatureId) != g_dig.id;
|
||||
}
|
||||
|
||||
void OnGameEnd() {
|
||||
g_dig = {};
|
||||
}
|
||||
|
||||
// The barrier (cParticleSystem_dwarf_shield, draw 0x7b92b0) draws its model
|
||||
// with alpha pulse × 128 (pulse 0.4..1, +0x20b8; __ftol at 0x7b9483), then
|
||||
// again additively. While a dig runs that alpha goes to opaque and back.
|
||||
constexpr uint32_t kShieldAlphaToInt = 0x007b9483;
|
||||
constexpr uint32_t kFloatToInt = 0x00848f94; // __ftol
|
||||
constexpr int32_t kOpaque = 255;
|
||||
|
||||
int32_t __cdecl ShieldAlpha(int32_t alpha) {
|
||||
if (!g_dig.id) {
|
||||
return alpha;
|
||||
}
|
||||
const float part = std::fmin(static_cast<float>(GetTickCount() - g_dig.start) / kFadeMs, 1.0f);
|
||||
const float solid = g_dig.moved ? 1.0f - part : part;
|
||||
return alpha + static_cast<int32_t>(static_cast<float>(kOpaque - alpha) * solid);
|
||||
}
|
||||
|
||||
__declspec(naked) void ShieldAlphaThunk() {
|
||||
__asm {
|
||||
mov eax, kFloatToInt
|
||||
call eax
|
||||
push eax
|
||||
call ShieldAlpha
|
||||
add esp, 4
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
bool AimEingraben() {
|
||||
auto* def = const_cast<uint8_t*>(game::FindSpellDef(game::kArtEingraben));
|
||||
if (!def || def[kSpellLaunchType] != static_cast<uint8_t>(game::LaunchType::AroundSelf)) {
|
||||
return false;
|
||||
}
|
||||
def[kSpellLaunchType] = static_cast<uint8_t>(game::LaunchType::Ground);
|
||||
*reinterpret_cast<int16_t*>(def + kSpellDurationBase) = kBarrierSeconds;
|
||||
*reinterpret_cast<int16_t*>(def + kSpellDurationPerLevel) = 0;
|
||||
*reinterpret_cast<uint16_t*>(def + kSpellDurationMax) = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Startup() {
|
||||
const bool aim = AimEingraben();
|
||||
re::Log("dwarfarts: Eingraben aimed at a point, barrier %d s%s", kBarrierSeconds, aim ? "" : " (patch FAILED)");
|
||||
const bool fade = re::RedirectCall(kShieldAlphaToInt, kFloatToInt, reinterpret_cast<const void*>(&ShieldAlphaThunk));
|
||||
re::Log("dwarfarts: Eingraben barrier turns solid%s", fade ? "" : " (patch FAILED)");
|
||||
const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk));
|
||||
re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)");
|
||||
uint8_t call[sizeof(kPushStepCode)];
|
||||
std::memset(call, 0x90, sizeof(call));
|
||||
call[0] = 0xe8;
|
||||
const int32_t rel = reinterpret_cast<int32_t>(&PushStepThunk) - static_cast<int32_t>(kPushStep + 5);
|
||||
std::memcpy(call + 1, &rel, sizeof(rel));
|
||||
const bool hold = re::ReplaceCode(kPushStep, kPushStepCode, call, sizeof(call));
|
||||
re::Log("dwarfarts: Kanonenschuss stun holds in place%s", hold ? "" : " (patch FAILED)");
|
||||
const bool stun = re::ReplaceCode(kCannonStunMotion, kStunMotionPush, kSkipStunMotion, sizeof(kSkipStunMotion));
|
||||
re::Log("dwarfarts: Kanonenschuss without stun motion%s", stun ? "" : " (patch FAILED)");
|
||||
const bool range = re::PatchInstructionByte(kCannonRaySteps, 2, kRayStepsOriginal, kRaySteps);
|
||||
re::Log("dwarfarts: Kanonenschuss range %d%s", kRaySteps * 40, range ? "" : " (patch FAILED)");
|
||||
const bool radius =
|
||||
re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast<const void*>(&GatherTargetsThunk));
|
||||
re::Log("dwarfarts: Mörsergranate radius%s", radius ? "" : " (patch FAILED)");
|
||||
const bool marker = re::RedirectCall(kGrenadeEventConstruct, kFxEventConstruct,
|
||||
reinterpret_cast<const void*>(&GrenadeEventThunk));
|
||||
re::Log("dwarfarts: Mörsergranate impact marker%s", marker ? "" : " (patch FAILED)");
|
||||
}
|
||||
RE_STARTUP(DwarfArtsStartup, Startup);
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
game::OnCreatureOrder(&OnOrder);
|
||||
game::FilterCreatureOrders(&AcceptOrder);
|
||||
game::OnGameEnd(&OnGameEnd);
|
||||
game::OnEngineTick(&OnTick);
|
||||
game::AddTooltipFilter(game::kArtMoerser, &RewriteTooltip);
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
} // namespace
|
||||
|
||||
bool KeepEffect(uint32_t kind, uint32_t art) {
|
||||
return art != game::kArtEingraben || kind != kEffectDugIn;
|
||||
}
|
||||
|
||||
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
|
||||
uint32_t only, int32_t radius, const uint8_t* fx) {
|
||||
if (type == kGrenadeImpactType && radius < 0 && fx) {
|
||||
const uint32_t owner = *reinterpret_cast<const uint32_t*>(fx + kFxOwner);
|
||||
radius = ImpactRadius(game::GetCreature(owner));
|
||||
// The position: area (u16), x, y (int32, world units), level.
|
||||
const auto* at = static_cast<const uint8_t*>(position);
|
||||
OnImpact(owner, static_cast<float>(*reinterpret_cast<const int32_t*>(at + 4)),
|
||||
static_cast<float>(*reinterpret_cast<const int32_t*>(at + 8)));
|
||||
}
|
||||
using Fn = uint32_t*(__fastcall*)(void*, void*, uint32_t*, const void*, uint32_t, uint32_t, int32_t);
|
||||
return reinterpret_cast<Fn>(kGatherInRange)(manager, nullptr, out, position, type, only, radius);
|
||||
}
|
||||
|
||||
// The impact markers of the grenades in flight, on the ground of `view`.
|
||||
void DrawMarkers(const uint8_t* view) {
|
||||
g_grenades.erase(std::remove_if(g_grenades.begin(), g_grenades.end(), [](const Grenade& g) { return !Alive(g); }),
|
||||
g_grenades.end());
|
||||
void* device = game::RenderDevice();
|
||||
if (g_grenades.empty() || !device) {
|
||||
return;
|
||||
}
|
||||
OverlayStates states(device);
|
||||
for (const Grenade& grenade : g_grenades) {
|
||||
DrawMarker(view, device, grenade);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace mods::dwarfarts
|
||||
|
||||
namespace game {
|
||||
|
||||
// cWorldView0::DrawGroundQuads (0x62d530, thiscall (renderer), ret 4): the
|
||||
// ground; the objects are drawn after it.
|
||||
void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer);
|
||||
RE_HOOK(0x0062d530, WorldView_DrawGroundQuads);
|
||||
void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer) {
|
||||
WorldView_DrawGroundQuads_original(self, edx, renderer);
|
||||
if (self) {
|
||||
mods::dwarfarts::DrawMarkers(self);
|
||||
}
|
||||
}
|
||||
|
||||
// cCreature::CastSpell (0x5222c0, thiscall (cast), ret 4): the spells; after
|
||||
// Eingraben's own case the dwarf digs to the point (cast +0x08 / +0x0c).
|
||||
void __fastcall Creature_CastSpell(uint8_t* self, void* edx, uint32_t* cast);
|
||||
RE_HOOK(0x005222c0, Creature_CastSpell);
|
||||
void __fastcall Creature_CastSpell(uint8_t* self, void* edx, uint32_t* cast) {
|
||||
const bool dig = self && cast && cast[8] == kArtEingraben;
|
||||
const int32_t x = dig ? static_cast<int32_t>(cast[2]) : 0, y = dig ? static_cast<int32_t>(cast[3]) : 0;
|
||||
Creature_CastSpell_original(self, edx, cast);
|
||||
if (dig) {
|
||||
mods::dwarfarts::Dig(self, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
// The dwarf's arts (dwarfarts.cpp).
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace mods::dwarfarts {
|
||||
|
||||
// Whether cCreatureArts::AddEffect adds an effect of `kind` for art `art`:
|
||||
// Eingraben no longer holds the dwarf in place.
|
||||
bool KeepEffect(uint32_t kind, uint32_t art);
|
||||
|
||||
} // namespace mods::dwarfarts
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Escorted NPCs stay with the hero (2026-10-26, docs/quests.md).
|
||||
//
|
||||
// On the follower list: "Furchtbares Verbrechen" (quest 9008) did not go on at
|
||||
// its target point: the NPC walked after the hero, but the script's test
|
||||
// there ("@npc", 0x498aa0 case 0x40) looks the NPC up in the hero's follower
|
||||
// list (hero +0x39c), and that list was empty (measured on Borf's save: NPC
|
||||
// 0x1fa with leader 1 and the follow bit, no followers on the hero). The NPC
|
||||
// keeps its side of the escort (leader +0x251, status bit 4) across a load;
|
||||
// the hero's list does not come back. The mod adds every NPC that follows a
|
||||
// hero but is missing from its list back with the game's own AddFollower
|
||||
// (0x54b070), with the entry the follow order uses ({id, 1, 1}), once after
|
||||
// loading.
|
||||
//
|
||||
// Never left behind: in "Die Sklaven der Orks" (2412) the husband fell
|
||||
// behind (the game's own catch-up teleport moved him once), and when the hero
|
||||
// walked on, his sector was switched off: the game removed him and ran his
|
||||
// spawn's death label (drop2412: quest variable 5, a token item dropped where
|
||||
// he was), with him alive (measured 2026-10-26 in the log: his object id
|
||||
// reused right after). Sectors are switched off at 2 sectors (65 tiles and
|
||||
// more) from the hero's; the mod teleports every follower of the local hero
|
||||
// that is more than kLeftBehindCells away to the hero (cCreature::Teleport,
|
||||
// as the portals), checked every kCheckMs.
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::followers {
|
||||
namespace {
|
||||
|
||||
constexpr int kTicksAfterStart = 30;
|
||||
constexpr int32_t kLeftBehindCells = 40;
|
||||
constexpr DWORD kCheckMs = 500;
|
||||
|
||||
// Small town NPCs run after the hero for a few seconds (status 0x404, leader
|
||||
// the hero) without joining his list (measured 2026-10-26: types 0x2be,
|
||||
// 0x2c0); they are not escorts.
|
||||
constexpr int32_t kStatusTagAlong = 0x400;
|
||||
|
||||
int g_ticksToCheck = -1;
|
||||
DWORD g_lastCheck = 0;
|
||||
|
||||
int32_t Field(const void* creature, size_t offset) {
|
||||
return *reinterpret_cast<const int32_t*>(static_cast<const uint8_t*>(creature) + offset);
|
||||
}
|
||||
|
||||
const game::MapPosition& Position(void* object) {
|
||||
return *reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(object) + game::kObjectPosition);
|
||||
}
|
||||
|
||||
int32_t Cell(int32_t units) {
|
||||
return static_cast<int32_t>(static_cast<float>(units) / game::kMapCellSize);
|
||||
}
|
||||
|
||||
void RepairFollowers() {
|
||||
for (uint32_t id = 1; id < game::ObjectIdEnd(); ++id) {
|
||||
if (game::IsPlayerHeroId(id) || !game::GetObjectById(id)) {
|
||||
continue;
|
||||
}
|
||||
void* creature = game::GetCreature(id);
|
||||
const int32_t status = creature ? Field(creature, game::kCreatureStatus) : 0;
|
||||
if (!(status & game::kStatusFollowing) || (status & kStatusTagAlong)) {
|
||||
continue;
|
||||
}
|
||||
const auto leaderId = static_cast<uint32_t>(Field(creature, game::kCreatureLeader));
|
||||
void* leader = game::IsPlayerHeroId(leaderId) ? game::GetCreature(leaderId) : nullptr;
|
||||
if (!leader || game::HasFollower(leader, id)) {
|
||||
continue;
|
||||
}
|
||||
game::AddFollower(leader, {id, 1, 1});
|
||||
re::Log("followers: %x back on the follower list of hero %x", id, leaderId);
|
||||
}
|
||||
}
|
||||
|
||||
// Followers of the local hero too far behind are teleported to him.
|
||||
void BringAlong() {
|
||||
void* hero = game::GetPlayerHero();
|
||||
if (!hero) {
|
||||
return;
|
||||
}
|
||||
const game::MapPosition& at = Position(hero);
|
||||
const int32_t heroX = Cell(at.x), heroY = Cell(at.y);
|
||||
game::ForEachFollower(hero, [&](uint32_t id) {
|
||||
void* follower = game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
|
||||
if (!follower || !game::IsAlive(follower)) {
|
||||
return;
|
||||
}
|
||||
const game::MapPosition& position = Position(follower);
|
||||
const int32_t x = Cell(position.x), y = Cell(position.y);
|
||||
if (position.level != at.level ||
|
||||
(std::abs(x - heroX) <= kLeftBehindCells && std::abs(y - heroY) <= kLeftBehindCells)) {
|
||||
return;
|
||||
}
|
||||
re::Log("followers: %x left behind at %d,%d, brought to the hero at %d,%d", id, x, y, heroX, heroY);
|
||||
game::Teleport(follower, heroX + 1, heroY + 1);
|
||||
});
|
||||
}
|
||||
|
||||
void OnTick(uint8_t*) {
|
||||
if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) {
|
||||
RepairFollowers();
|
||||
}
|
||||
const DWORD now = GetTickCount();
|
||||
if (g_ticksToCheck <= 0 && now - g_lastCheck >= kCheckMs) {
|
||||
g_lastCheck = now;
|
||||
BringAlong();
|
||||
}
|
||||
}
|
||||
|
||||
void OnStart() {
|
||||
g_ticksToCheck = kTicksAfterStart;
|
||||
}
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
game::OnEngineTick(&OnTick);
|
||||
game::OnGameStart(&OnStart);
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::followers
|
||||
+49
-1
@@ -3,6 +3,9 @@
|
||||
|
||||
#include <intrin.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
@@ -11,7 +14,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 {
|
||||
@@ -31,4 +34,49 @@ void __fastcall BurningBone_Render(void* self, void* edx, void* device, Matrix4
|
||||
BurningBone_Render_original(self, edx, device, transform);
|
||||
}
|
||||
|
||||
// cParticleSystem_grenadeimpact::Render (vf04 0x7b66b0, vtable 0x896cdc;
|
||||
// (device, matrix by value), ret 0x44): Mörsergranate's explosion. Crashed
|
||||
// twice (2026-10-26 00:12 and 22:39, sacred.exe+0x361b54 in the particle
|
||||
// drawer 0x7613e0): a particle's colour is read from the table at
|
||||
// +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and the particle had a life of
|
||||
// over 500000 (it counts down from about 255, at 250..400 per second, by
|
||||
// row: cParticleSystem update 0x763ea0). Particles are 0x40 bytes in the
|
||||
// vector +0x68..+0x6c; alive while life (+0x2c) and size (+0x28) are above 0.
|
||||
// How a particle gets there is not known yet: such particles are switched
|
||||
// off (life 0, the game's dead state) and the first ones logged.
|
||||
constexpr size_t kParticlesBegin = 0x68;
|
||||
constexpr size_t kParticlesEnd = 0x6c;
|
||||
constexpr size_t kParticleSize = 0x40;
|
||||
constexpr size_t kParticleSizeValue = 0x28; // float
|
||||
constexpr size_t kParticleLife = 0x2c; // float, colour column 0..255
|
||||
constexpr size_t kParticleRow = 0x3d; // u8, colour row
|
||||
constexpr float kColourColumns = 256.0f;
|
||||
constexpr uint8_t kGrenadeImpactRows = 3; // emitter parameter rows at +0x30d0 (0x20 bytes each)
|
||||
constexpr size_t kGrenadeImpactPhase = 0x20a9; // u8: 0 burning, 1 waiting, 2 fading (vf08 0x7b64c0)
|
||||
|
||||
void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform);
|
||||
RE_HOOK(0x007b66b0, GrenadeImpact_Render);
|
||||
void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform) {
|
||||
static int logged = 0;
|
||||
uint8_t* begin = *reinterpret_cast<uint8_t**>(self + kParticlesBegin);
|
||||
uint8_t* end = *reinterpret_cast<uint8_t**>(self + kParticlesEnd);
|
||||
for (uint8_t* particle = begin; particle && particle < end; particle += kParticleSize) {
|
||||
float& life = *reinterpret_cast<float*>(particle + kParticleLife);
|
||||
const float size = *reinterpret_cast<const float*>(particle + kParticleSizeValue);
|
||||
const uint8_t row = particle[kParticleRow];
|
||||
if (!(life > 0.0f) || !(size > 0.0f) || (life < kColourColumns && row < kGrenadeImpactRows)) {
|
||||
continue;
|
||||
}
|
||||
if (logged < 5) {
|
||||
++logged;
|
||||
re::Log("guards: grenade impact particle %d of %d out of range (life %g, row %u, size %g, phase %u), "
|
||||
"switched off",
|
||||
static_cast<int>((particle - begin) / kParticleSize), static_cast<int>((end - begin) / kParticleSize),
|
||||
life, row, size, self[kGrenadeImpactPhase]);
|
||||
}
|
||||
life = 0.0f;
|
||||
}
|
||||
GrenadeImpact_Render_original(self, edx, device, transform);
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
+224
-13
@@ -16,11 +16,12 @@
|
||||
// inside both.
|
||||
// The world never sees these offsets: they are only active inside the hooks.
|
||||
//
|
||||
// sacred_re.ini:
|
||||
// sacredK.ini:
|
||||
// [hud]
|
||||
// Enabled=1
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
@@ -30,6 +31,8 @@
|
||||
#include "game/addresses.h"
|
||||
#include "game/render/dx_driver7.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "mods/hud.h"
|
||||
#include "mods/screen.h"
|
||||
#include "mods/widescreen.h"
|
||||
#include "re/config.h"
|
||||
#include "re/hook.h"
|
||||
@@ -47,13 +50,24 @@ thread_local bool g_eventShifted = false;
|
||||
|
||||
|
||||
// Screen: laid out across the whole render width by its mod (drawn unshifted).
|
||||
enum class Anchor { Center, Right, Screen };
|
||||
// Canvas: a 16:9 screen (kCanvasWidth), centred.
|
||||
enum class Anchor { Center, Right, Screen, Canvas };
|
||||
|
||||
// Offset of the main menu's 1024 frame while it is drawn centred (set each
|
||||
// menu frame by RenderMenuFrame; 0 while a 16:9 screen draws itself).
|
||||
std::atomic<int> g_menuFrameDx = 0;
|
||||
|
||||
int OffsetFor(Anchor anchor) {
|
||||
static const bool enabled = re::ConfigInt("hud", "Enabled", 1) != 0;
|
||||
const int width = mods::widescreen::RenderWidth();
|
||||
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() ||
|
||||
mods::widescreen::MenuIsShowing() || anchor == Anchor::Screen) {
|
||||
if (anchor == Anchor::Canvas) {
|
||||
return width > kCanvasWidth ? (width - kCanvasWidth) / 2 : 0;
|
||||
}
|
||||
// The main menu's frame shifts the windows in it; over a 16:9 screen
|
||||
// (no frame) dialogs are centred like in a game (user, 2026-10-21).
|
||||
const bool inMenuFrame = mods::widescreen::MenuIsShowing() && g_menuFrameDx != 0;
|
||||
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsLoadingScreen() || inMenuFrame ||
|
||||
anchor == Anchor::Screen) {
|
||||
return 0;
|
||||
}
|
||||
return anchor == Anchor::Center ? (width - kLayoutWidth) / 2 : width - kLayoutWidth;
|
||||
@@ -263,6 +277,29 @@ private:
|
||||
CursorOffset cursor_;
|
||||
};
|
||||
|
||||
// RAII: shift drawing only (the cursor is not seen shifted), for things
|
||||
// already in the shifted layout's coordinates (the main menu's cursor).
|
||||
class DrawShift {
|
||||
public:
|
||||
explicit DrawShift(int dx) : active_(!g_drawScoped && dx != 0) {
|
||||
if (active_) {
|
||||
g_drawScoped = true;
|
||||
g_offsetX = dx;
|
||||
}
|
||||
}
|
||||
~DrawShift() {
|
||||
if (active_) {
|
||||
g_drawScoped = false;
|
||||
g_offsetX = 0;
|
||||
}
|
||||
}
|
||||
DrawShift(const DrawShift&) = delete;
|
||||
DrawShift& operator=(const DrawShift&) = delete;
|
||||
|
||||
private:
|
||||
bool active_;
|
||||
};
|
||||
|
||||
// Stores the offsets of a mouse event's x fields (game/ui/events.h); returns
|
||||
// their count (0: no mouse event).
|
||||
int MouseEventXOffsets(void* event, int (&offsets)[2]) {
|
||||
@@ -341,6 +378,9 @@ size_t VertexSize(DWORD fvf) {
|
||||
return size;
|
||||
}
|
||||
|
||||
// A 16:9 screen draws (RenderCanvas): no 1024 layout to stretch.
|
||||
thread_local bool g_canvasScope = false;
|
||||
|
||||
// Returns vertices shifted by g_offsetX, or the input when nothing to do.
|
||||
// Primitives covering the whole 1024x768 layout (e.g. the dimming behind
|
||||
// modal dialogs) are stretched across the render width instead.
|
||||
@@ -359,7 +399,7 @@ void* ShiftVertices(DWORD fvf, void* vertices, DWORD count) {
|
||||
minY = std::min(minY, position[1]);
|
||||
maxY = std::max(maxY, position[1]);
|
||||
}
|
||||
const bool fullLayout = minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f;
|
||||
const bool fullLayout = !g_canvasScope && minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f;
|
||||
const float scale = static_cast<float>(mods::widescreen::RenderWidth()) / kLayoutWidth;
|
||||
for (DWORD i = 0; i < count; ++i) {
|
||||
auto* x = reinterpret_cast<float*>(buffer.data() + stride * i);
|
||||
@@ -375,13 +415,59 @@ using DrawIndexedPrimitiveFn = HRESULT(__stdcall*)(void* device, DWORD type, DWO
|
||||
DrawPrimitiveFn g_drawPrimitive = nullptr;
|
||||
DrawIndexedPrimitiveFn g_drawIndexedPrimitive = nullptr;
|
||||
|
||||
// Textures not drawn (SetHiddenTextures): the surface bound to stage 0 is
|
||||
// tracked per thread (each thread draws with its own device calls). Only
|
||||
// inside a frame drawn over the menu background (g_hidingPictures: the main
|
||||
// menu's, the options' in a game): the list keeps surfaces the cache may
|
||||
// have freed since, and a game texture made at the same address was skipped
|
||||
// in the world view (user, 2026-10-21: "rendering glitches ingame").
|
||||
constexpr int kHiddenTextureMax = 64;
|
||||
std::atomic<void*> g_hiddenTextures[kHiddenTextureMax] = {};
|
||||
thread_local void* g_boundTexture = nullptr;
|
||||
thread_local int g_hidingPictures = 0;
|
||||
|
||||
struct HidingPictures {
|
||||
HidingPictures() { ++g_hidingPictures; }
|
||||
~HidingPictures() { --g_hidingPictures; }
|
||||
HidingPictures(const HidingPictures&) = delete;
|
||||
HidingPictures& operator=(const HidingPictures&) = delete;
|
||||
};
|
||||
|
||||
using SetTextureFn = HRESULT(__stdcall*)(void* device, DWORD stage, void* surface);
|
||||
SetTextureFn g_setTexture = nullptr;
|
||||
|
||||
HRESULT __stdcall SetTextureTracked(void* device, DWORD stage, void* surface) {
|
||||
if (stage == 0) {
|
||||
g_boundTexture = surface;
|
||||
}
|
||||
return g_setTexture(device, stage, surface);
|
||||
}
|
||||
|
||||
bool BoundTextureHidden() {
|
||||
if (!g_boundTexture || g_hidingPictures == 0) {
|
||||
return false;
|
||||
}
|
||||
for (const auto& hidden : g_hiddenTextures) {
|
||||
if (hidden.load(std::memory_order_relaxed) == g_boundTexture) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
HRESULT __stdcall DrawPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count,
|
||||
DWORD flags) {
|
||||
if (BoundTextureHidden()) {
|
||||
return S_OK;
|
||||
}
|
||||
return g_drawPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, flags);
|
||||
}
|
||||
|
||||
HRESULT __stdcall DrawIndexedPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count,
|
||||
WORD* indices, DWORD indexCount, DWORD flags) {
|
||||
if (BoundTextureHidden()) {
|
||||
return S_OK;
|
||||
}
|
||||
return g_drawIndexedPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, indices,
|
||||
indexCount, flags);
|
||||
}
|
||||
@@ -430,6 +516,46 @@ 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)
|
||||
|
||||
// RAII for full-screen windows (options in a game) that fill the 1024 frame
|
||||
// with a picture: the menu screens' background (mods/screen.h) goes under
|
||||
// it, and the window draws with a viewport on the frame. The
|
||||
// export screen's 3D scene (heroes, background) uses a projection for a
|
||||
// 1024x768 viewport; across the widened one it was stretched 1.8x wide, away
|
||||
// from the slot labels and hit areas (measured on a screenshot, 2026-10-18).
|
||||
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 || (w[kControlFlags] & 1) == 0) {
|
||||
return;
|
||||
}
|
||||
auto getViewport = reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]);
|
||||
if (FAILED(getViewport(device, &previous_))) {
|
||||
return;
|
||||
}
|
||||
mods::screen::DrawBackground(device);
|
||||
Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous_.minZ, previous_.maxZ};
|
||||
device_ = device;
|
||||
g_setViewport(device, &frame);
|
||||
++g_hidingPictures;
|
||||
}
|
||||
~FrameScope() {
|
||||
if (device_) {
|
||||
--g_hidingPictures;
|
||||
g_setViewport(device_, &previous_);
|
||||
}
|
||||
}
|
||||
FrameScope(const FrameScope&) = delete;
|
||||
FrameScope& operator=(const FrameScope&) = delete;
|
||||
|
||||
private:
|
||||
void* device_ = nullptr;
|
||||
Viewport7 previous_ = {};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
int CursorOffsetX() {
|
||||
@@ -461,6 +587,10 @@ void OnDevice(void* device) {
|
||||
g_drawIndexedPrimitive = reinterpret_cast<DrawIndexedPrimitiveFn>(vtable[26]);
|
||||
re::AttachRuntimeHook("D3D7 DrawIndexedPrimitive", reinterpret_cast<void**>(&g_drawIndexedPrimitive),
|
||||
reinterpret_cast<void*>(&DrawIndexedPrimitiveShifted));
|
||||
// SetTexture 35.
|
||||
g_setTexture = reinterpret_cast<SetTextureFn>(vtable[35]);
|
||||
re::AttachRuntimeHook("D3D7 SetTexture", reinterpret_cast<void**>(&g_setTexture),
|
||||
reinterpret_cast<void*>(&SetTextureTracked));
|
||||
// Clear 10, SetViewport 13.
|
||||
g_clear = reinterpret_cast<ClearFn>(vtable[10]);
|
||||
re::AttachRuntimeHook("D3D7 Clear", reinterpret_cast<void**>(&g_clear), reinterpret_cast<void*>(&ClearShifted));
|
||||
@@ -571,6 +701,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 +780,9 @@ 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_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER(0x0071dd80, UiSavegame_Render, Anchor::Center)
|
||||
HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game
|
||||
// cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp.
|
||||
// cUI_Savegame (0x71d130 / 0x71dd80): hooked by mods/characters.cpp.
|
||||
HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center)
|
||||
HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
|
||||
|
||||
@@ -714,11 +863,68 @@ 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);
|
||||
}
|
||||
|
||||
void SetHiddenTextures(void* const* surfaces, int count) {
|
||||
for (int i = 0; i < kHiddenTextureMax; ++i) {
|
||||
g_hiddenTextures[i].store(i < count ? surfaces[i] : nullptr, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
int MenuFrameOffsetX() {
|
||||
return mods::widescreen::MenuIsShowing() ? g_menuFrameDx.load() : 0;
|
||||
}
|
||||
|
||||
int RenderMenuFrame(void* device, bool framed, int (*body)(void* context), void* context) {
|
||||
const int width = mods::widescreen::RenderWidth();
|
||||
const int dx = framed && width > kLayoutWidth ? (width - kLayoutWidth) / 2 : 0;
|
||||
g_menuFrameDx = dx;
|
||||
HidingPictures hiding;
|
||||
Viewport7 previous;
|
||||
auto getViewport =
|
||||
device ? reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]) : nullptr;
|
||||
if (dx == 0 || g_drawScoped || !g_setViewport || !getViewport || FAILED(getViewport(device, &previous))) {
|
||||
return body(context);
|
||||
}
|
||||
Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous.minZ, previous.maxZ};
|
||||
g_setViewport(device, &frame);
|
||||
g_drawScoped = true; // the windows inside draw in the frame's coordinates
|
||||
g_offsetX = dx;
|
||||
const int result = body(context);
|
||||
g_offsetX = 0;
|
||||
g_drawScoped = false;
|
||||
g_setViewport(device, &previous);
|
||||
return result;
|
||||
}
|
||||
|
||||
int CanvasOffsetX() {
|
||||
return OffsetFor(Anchor::Canvas);
|
||||
}
|
||||
|
||||
int RenderCanvas(void* window, int (*body)(void* context), void* context) {
|
||||
DrawOffset offset(window, OffsetFor(Anchor::Canvas));
|
||||
const bool outer = !g_canvasScope;
|
||||
g_canvasScope = true;
|
||||
const int result = body(context);
|
||||
g_canvasScope = !outer;
|
||||
return result;
|
||||
}
|
||||
|
||||
int HandleCanvas(void* event, int (*body)(void* context), void* context) {
|
||||
EventOffset offset(event, OffsetFor(Anchor::Canvas));
|
||||
return body(context);
|
||||
}
|
||||
|
||||
} // namespace mods::hud
|
||||
|
||||
namespace game {
|
||||
@@ -735,12 +941,17 @@ int __fastcall UiTaskbar_HitTest(void* self, void* edx, int x, int y) {
|
||||
return UiTaskbar_HitTest_original(self, edx, mods::hud::HitTestX(self, x), y);
|
||||
}
|
||||
|
||||
// The item carried by the mouse cursor (0x6555e0, drawn by the render loop
|
||||
// after the UI) is placed like UI items: relative to x = 512 of the layout.
|
||||
// Items are only picked up from centred windows.
|
||||
// The cursor and the item it carries (0x6555e0, drawn by the render loop
|
||||
// after the UI) are placed like UI items: relative to x = 512 of the layout.
|
||||
// Items are only picked up from centred windows. In the main menu the cursor
|
||||
// is in the centred frame's coordinates (MenuFrameOffsetX).
|
||||
int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b);
|
||||
RE_HOOK(0x006555e0, MouseCursor_DrawItem);
|
||||
int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b) {
|
||||
if (mods::widescreen::MenuIsShowing()) {
|
||||
mods::hud::DrawShift shift(mods::hud::MenuFrameOffsetX());
|
||||
return MouseCursor_DrawItem_original(self, edx, a, b);
|
||||
}
|
||||
mods::hud::DrawOffset offset(nullptr, mods::hud::OffsetFor(Anchor::Center));
|
||||
return MouseCursor_DrawItem_original(mods::hud::CursorFor(self), edx, a, b);
|
||||
}
|
||||
|
||||
@@ -26,5 +26,31 @@ int VisibleDialogTooltips(void** out, int max);
|
||||
// shifted) or event handler (mouse position shifted by the window's offset).
|
||||
int RenderCentred(void* window, int (*body)(void* context), void* context);
|
||||
int HandleCentred(void* event, int (*body)(void* context), void* context);
|
||||
// RenderCentred for full-screen windows: the menu screens' background beside
|
||||
// the 1024 frame, drawn with a viewport on it (as the options in a game).
|
||||
int RenderFramed(void* window, void* device, int (*body)(void* context), void* context);
|
||||
|
||||
// 16:9 screens (mods/screen.h): laid out on a 1365x768 canvas, centred in
|
||||
// the render width. RenderCanvas / HandleCanvas run body(context) with drawing,
|
||||
// cursor and mouse events in canvas coordinates; CanvasOffsetX is the canvas'
|
||||
// left edge in the render target.
|
||||
constexpr int kCanvasWidth = 1365;
|
||||
constexpr int kCanvasHeight = 768;
|
||||
int CanvasOffsetX();
|
||||
int RenderCanvas(void* window, int (*body)(void* context), void* context);
|
||||
int HandleCanvas(void* event, int (*body)(void* context), void* context);
|
||||
|
||||
// The main menu (mods/screen.cpp): with `framed`, body(context) draws the
|
||||
// menu's 1024x768 frame centred in the render width (shifted, with a
|
||||
// viewport on it); mouse positions are then given in frame coordinates
|
||||
// (MenuFrameOffsetX, applied by the borderless mod). Without, the menu draws
|
||||
// unshifted (a 16:9 screen is shown).
|
||||
int RenderMenuFrame(void* device, bool framed, int (*body)(void* context), void* context);
|
||||
int MenuFrameOffsetX();
|
||||
|
||||
// Quads drawn with one of these textures (Direct3D surfaces) are left out
|
||||
// inside the frames drawn over the menu background (RenderMenuFrame, the
|
||||
// in-game options), until the list changes (at most 64).
|
||||
void SetHiddenTextures(void* const* surfaces, int count);
|
||||
|
||||
} // namespace mods::hud
|
||||
+374
-70
@@ -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)
|
||||
// SaveGame=K ; save the game at once (S opened the save 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,17 @@
|
||||
#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/characters.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 +56,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 +170,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 +198,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 +219,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 +253,88 @@ void OnTick(uint8_t* engine) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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;
|
||||
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[game::kUiSlotMegamap]) {
|
||||
return;
|
||||
}
|
||||
if (game::UiWindowShown(ui, game::kUiSlotMegamap)) {
|
||||
game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Hide);
|
||||
return;
|
||||
}
|
||||
for (const int slot : kMapBlockingSlots) {
|
||||
if (game::UiWindowShown(ui, slot)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!Flag(kMapBlockedFlag) && !Flag(kMapDisabledFlag) && !game::UiWindowShown(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};
|
||||
|
||||
// Set by mods/lootfilter.cpp around DrawObjectLabels to draw the labels of
|
||||
// items shown without the key (game thread, like the polls).
|
||||
bool g_labelsForced = false;
|
||||
|
||||
SHORT WINAPI LabelsKeyState(int) {
|
||||
if (g_labelsForced) {
|
||||
return static_cast<SHORT>(0x8000);
|
||||
}
|
||||
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 +350,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:
|
||||
characters::RequestSave(); // at once, no save window
|
||||
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 +376,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 +399,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 +440,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 +457,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 +469,127 @@ bool TypingIntoTextField() {
|
||||
return FocusedTextField() != nullptr;
|
||||
}
|
||||
|
||||
bool LabelsKeyHeld() {
|
||||
return (LabelsKeyState(VK_MENU) & 0x8000) != 0;
|
||||
}
|
||||
|
||||
void ForceLabels(bool forced) {
|
||||
g_labelsForced = forced;
|
||||
}
|
||||
|
||||
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,37 @@ 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 labels key (Beschriftungen) is held. While forced, the game's polls of
|
||||
// it read it held (mods/lootfilter.cpp, around DrawObjectLabels).
|
||||
bool LabelsKeyHeld();
|
||||
void ForceLabels(bool forced);
|
||||
|
||||
// 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
|
||||
@@ -32,6 +32,7 @@
|
||||
#include "mods/sharedchest.h"
|
||||
#include "mods/skillslots.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/patch.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::inventory_save {
|
||||
@@ -193,7 +194,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 +263,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;
|
||||
@@ -325,6 +328,20 @@ void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn rea
|
||||
HeroDataList().push_back({key, write, read});
|
||||
}
|
||||
|
||||
// saveHero writes the hero's items (chunk 0xc2) from its inventories, equipment
|
||||
// and weapon sets; the weapon-set loop (GetWeaponSetItem over sets 0..4, `cmp
|
||||
// esi, 5`) runs over all 7 so the items of sets 6-7 are in hero files too. Without
|
||||
// them a world loading the hero from its hero file had sets 6-7 pointing at
|
||||
// items that did not exist (2026-10-25).
|
||||
constexpr uint32_t kSaveHeroWeaponSetLoop = 0x00605046;
|
||||
|
||||
void Startup() {
|
||||
if (!re::PatchInstructionByte(kSaveHeroWeaponSetLoop, 2, kGameSlots, kSlots)) {
|
||||
re::Log("inventory: saveHero weapon-set loop not patched");
|
||||
}
|
||||
}
|
||||
RE_STARTUP(InventorySaveStartup, Startup);
|
||||
|
||||
} // namespace mods::inventory_save
|
||||
|
||||
namespace game {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// The load / save progress screen on the menu screens' background (request of
|
||||
// 2026-10-25).
|
||||
//
|
||||
// The game's progress screen (DxDriver7::LoadingScreen 0x6469d0, thiscall
|
||||
// (progress, text)) draws with GDI into the back buffer's DC (surface +0xbc,
|
||||
// GetDC vf 0x44 / ReleaseDC vf 0x68): the 1024x768 picture (HBITMAP +0x200)
|
||||
// at 0, 0, the progress bar (+0x204 / +0x208) and the text at y 700, then
|
||||
// Flip. It only redraws when the bar moves.
|
||||
//
|
||||
// The mod: first the menu background across the whole render width in a
|
||||
// Direct3D scene (screen::DrawBackground), then the game's drawing with the
|
||||
// DC's origin moved to the centred 1024 frame. The game asks for a memory DC
|
||||
// compatible with the back buffer's DC right after getting it
|
||||
// (CreateCompatibleDC(hdc)), which is where the origin is set.
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/addresses.h"
|
||||
#include "game/render/dx_driver7.h"
|
||||
#include "mods/loadingscreen.h"
|
||||
#include "mods/screen.h"
|
||||
#include "mods/widescreen.h"
|
||||
#include "re/hook.h"
|
||||
|
||||
namespace mods::loadingscreen {
|
||||
namespace {
|
||||
|
||||
constexpr int kLayoutWidth = 1024;
|
||||
|
||||
using BeginSceneFn = char(__fastcall*)(game::DxDriver7* self, void* edx);
|
||||
using EndSceneFn = void(__fastcall*)(game::DxDriver7* self, void* edx);
|
||||
|
||||
} // namespace
|
||||
|
||||
// While the progress screen draws on this thread: its frame's x.
|
||||
thread_local int g_offsetX = 0;
|
||||
|
||||
void Begin(game::DxDriver7* driver) {
|
||||
g_offsetX = 0;
|
||||
const int width = widescreen::RenderWidth();
|
||||
if (width <= kLayoutWidth || !driver || !driver->device || !driver->backBuffer) {
|
||||
return;
|
||||
}
|
||||
if (reinterpret_cast<BeginSceneFn>(static_cast<uintptr_t>(game::addr::DxDriver7_BeginScene))(driver, nullptr)) {
|
||||
screen::DrawBackground(driver->device);
|
||||
reinterpret_cast<EndSceneFn>(static_cast<uintptr_t>(game::addr::DxDriver7_EndScene))(driver, nullptr);
|
||||
}
|
||||
g_offsetX = (width - kLayoutWidth) / 2;
|
||||
}
|
||||
|
||||
void End() {
|
||||
g_offsetX = 0;
|
||||
}
|
||||
|
||||
} // namespace mods::loadingscreen
|
||||
|
||||
namespace mods::loadingscreen {
|
||||
namespace {
|
||||
|
||||
constexpr int kLayoutHeight = 768;
|
||||
|
||||
// The game's pictures are artwork on black (PAK\loading0.bmp, loadgame.bmp,
|
||||
// ...). Blended instead of copied: each pixel's alpha is its brightest
|
||||
// channel, colours stay as they are (premultiplied), so black shows the
|
||||
// background and the glow fades into it.
|
||||
bool BlendPicture(HDC target, HDC source) {
|
||||
const int offsetX = g_offsetX;
|
||||
g_offsetX = 0; // our own GDI calls below pass the hooks unchanged
|
||||
BITMAPINFO info = {};
|
||||
info.bmiHeader.biSize = sizeof(info.bmiHeader);
|
||||
info.bmiHeader.biWidth = kLayoutWidth;
|
||||
info.bmiHeader.biHeight = -kLayoutHeight;
|
||||
info.bmiHeader.biPlanes = 1;
|
||||
info.bmiHeader.biBitCount = 32;
|
||||
info.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
HBITMAP picture = CreateDIBSection(source, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
if (!picture) {
|
||||
g_offsetX = offsetX;
|
||||
return false;
|
||||
}
|
||||
HDC copy = CreateCompatibleDC(source);
|
||||
HGDIOBJ previous = SelectObject(copy, picture);
|
||||
BitBlt(copy, 0, 0, kLayoutWidth, kLayoutHeight, source, 0, 0, SRCCOPY);
|
||||
GdiFlush();
|
||||
auto* pixel = static_cast<uint8_t*>(bits);
|
||||
for (int i = 0; i < kLayoutWidth * kLayoutHeight; ++i, pixel += 4) {
|
||||
pixel[3] = std::max({pixel[0], pixel[1], pixel[2]});
|
||||
}
|
||||
const BLENDFUNCTION blend = {AC_SRC_OVER, 0, 255, AC_SRC_ALPHA};
|
||||
const bool done =
|
||||
GdiAlphaBlend(target, 0, 0, kLayoutWidth, kLayoutHeight, copy, 0, 0, kLayoutWidth, kLayoutHeight, blend) != 0;
|
||||
SelectObject(copy, previous);
|
||||
DeleteDC(copy);
|
||||
DeleteObject(picture);
|
||||
g_offsetX = offsetX;
|
||||
return done;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::loadingscreen
|
||||
|
||||
// The picture: BitBlt(hdc, 0, 0, 1024, 768, picture DC, 0, 0, SRCCOPY), or
|
||||
// BLACKNESS without a picture (then nothing is drawn over the background).
|
||||
BOOL WINAPI BitBltForLoadingScreen(HDC target, int x, int y, int width, int height, HDC source, int sx, int sy,
|
||||
DWORD rop);
|
||||
RE_HOOK_API(BitBlt, BitBltForLoadingScreen);
|
||||
|
||||
BOOL WINAPI BitBltForLoadingScreen(HDC target, int x, int y, int width, int height, HDC source, int sx, int sy,
|
||||
DWORD rop) {
|
||||
using namespace mods::loadingscreen;
|
||||
if (g_offsetX != 0 && x == 0 && y == 0 && width == 1024 && height == 768) {
|
||||
if (rop == BLACKNESS) {
|
||||
return TRUE;
|
||||
}
|
||||
if (rop == SRCCOPY && source && BlendPicture(target, source)) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
return BitBltForLoadingScreen_original(target, x, y, width, height, source, sx, sy, rop);
|
||||
}
|
||||
|
||||
HDC WINAPI CreateCompatibleDCForLoadingScreen(HDC hdc);
|
||||
RE_HOOK_API(CreateCompatibleDC, CreateCompatibleDCForLoadingScreen);
|
||||
|
||||
HDC WINAPI CreateCompatibleDCForLoadingScreen(HDC hdc) {
|
||||
if (hdc && mods::loadingscreen::g_offsetX != 0) {
|
||||
SetViewportOrgEx(hdc, mods::loadingscreen::g_offsetX, 0, nullptr);
|
||||
}
|
||||
return CreateCompatibleDCForLoadingScreen_original(hdc);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
// The load / save progress screen on the menu background (loadingscreen.cpp).
|
||||
|
||||
#include "game/render/dx_driver7.h"
|
||||
|
||||
namespace mods::loadingscreen {
|
||||
|
||||
// Around DxDriver7::LoadingScreen (widescreen.cpp's hook): Begin draws the
|
||||
// background and centres the game's GDI drawing, End stops centring.
|
||||
void Begin(game::DxDriver7* driver);
|
||||
void End();
|
||||
|
||||
} // namespace mods::loadingscreen
|
||||
@@ -0,0 +1,327 @@
|
||||
// Loot filter (request of 2026-10-19), set on the options window's Beute page
|
||||
// (mods/options.cpp): per category - Questgegenstände, Gold, Tränke, and the
|
||||
// item colours white, blue, yellow, brown, green - one of
|
||||
// Void a drop is deleted at once (monster loot, chests; items the
|
||||
// player puts down stay),
|
||||
// Ignore the pickup key (Q, the collect mode) leaves it,
|
||||
// Default picked up, its label shows with the labels key,
|
||||
// AlwaysShow picked up, its label shows without the key too.
|
||||
// It replaces the game's PICKUPAUTO setting (all / valuable / gold only),
|
||||
// whose radio buttons are gone from the page.
|
||||
//
|
||||
// - Categories from the game's data: quest items first (request of
|
||||
// 2026-10-25: they were voided as their colour's category; "it needs to be
|
||||
// top priority, also Löschen is not an option for those"; type's quest
|
||||
// number or quest-item mark, game::ItemIsQuestItem), gold by type
|
||||
// (0x43ae20), potions by item class 9, set items green (0x433f00 +0x23),
|
||||
// otherwise the quality tier that picks the item colour (0x5c1330,
|
||||
// game/engine/items.h). Runes and objects flagged always-collected are not
|
||||
// filtered.
|
||||
// - Pickup: cCreature::CanCollectItem (0x4ec170) is the collect mode's filter
|
||||
// (both of its scans; a clicked item does not ask it).
|
||||
// - Void: items made by the loot drop (cCreature 0x559a30, via
|
||||
// cObjectManager::CreateAt) and the contents a chest throws out
|
||||
// (cChest3D::dropItems 0x5c9b10) are taken off the map and destroyed on the
|
||||
// next engine tick, when the callers are done with them.
|
||||
// - AlwaysShow: cWorldView0::DrawObjectLabels (0x627260) draws the labels
|
||||
// list (+0x96b30, 0x1c per entry, +0 object id, high bit: skip) when the
|
||||
// labels key is held. Without the key it runs with the key read as held and
|
||||
// every other entry marked skipped, then the marks and the world view's
|
||||
// labels flag (+0x10 bit 0x4000, which it sets from the key) go back.
|
||||
|
||||
#include "mods/lootfilter.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/items.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "mods/input.h"
|
||||
#include "re/config.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::lootfilter {
|
||||
namespace {
|
||||
|
||||
enum Category : int { kQuest, kGold, kPotions, kWhite, kBlue, kYellow, kBrown, kGreen, kNone = -1 };
|
||||
|
||||
struct CategoryInfo {
|
||||
const char* key; // in [loot]
|
||||
const wchar_t* label;
|
||||
uint32_t colour;
|
||||
};
|
||||
// Gold and potions as the game's labels show them: white.
|
||||
constexpr CategoryInfo kCategories[kCategoryCount] = {
|
||||
{"Quest", L"Questgegenstände", game::kItemColourWhite},
|
||||
{"Gold", L"Gold", game::kItemColourWhite}, {"Potions", L"Tränke", game::kItemColourWhite},
|
||||
{"White", L"Weiß", game::kItemColourWhite}, {"Blue", L"Blau", game::kItemColourBlue},
|
||||
{"Yellow", L"Gelb", game::kItemColourYellow}, {"Brown", L"Braun", game::kItemColourBrown},
|
||||
{"Green", L"Grün (Set)", game::kItemColourGreen},
|
||||
};
|
||||
|
||||
struct ModeInfo {
|
||||
const char* key;
|
||||
const wchar_t* label;
|
||||
};
|
||||
constexpr ModeInfo kModes[kModeCount] = {
|
||||
{"Void", L"Löschen"}, {"Ignore", L"Ignorieren"}, {"Default", L"Standard"}, {"AlwaysShow", L"Immer anzeigen"}};
|
||||
|
||||
Mode g_modes[kCategoryCount] = {Mode::Default, Mode::Default, Mode::Default, Mode::Default,
|
||||
Mode::Default, Mode::Default, Mode::Default, Mode::Default};
|
||||
|
||||
// cObject +0x14 bit 0x800: the game's CanCollectItem takes such objects
|
||||
// whatever the pickup setting.
|
||||
constexpr uint32_t kObjectAlwaysCollected = 0x800;
|
||||
|
||||
int CategoryOf(void* object) {
|
||||
// Types are one table for all objects; the quality block needs a cItemBase.
|
||||
if (!object) {
|
||||
return kNone;
|
||||
}
|
||||
const uint32_t type = game::ItemType(object);
|
||||
if (type == 0 || type >= game::kItemTypeCount) {
|
||||
return kNone;
|
||||
}
|
||||
if (game::ItemIsQuestItem(type)) {
|
||||
return kQuest;
|
||||
}
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t*>(static_cast<const uint8_t*>(object) + game::kObjectFlags);
|
||||
if (flags & kObjectAlwaysCollected) {
|
||||
return kNone;
|
||||
}
|
||||
if (game::ItemIsGold(type)) {
|
||||
return kGold;
|
||||
}
|
||||
const uint8_t itemClass = game::ItemClass(type);
|
||||
if (itemClass == game::kItemClassPotion) {
|
||||
return kPotions;
|
||||
}
|
||||
if (itemClass == game::kItemClassRune) {
|
||||
return kNone;
|
||||
}
|
||||
void* item = game::DynamicCast(object, game::kTypeObject, game::kTypeItemBase);
|
||||
if (!item) {
|
||||
return kNone;
|
||||
}
|
||||
if (game::ItemIsSetItem(type)) {
|
||||
return kGreen;
|
||||
}
|
||||
const unsigned tier = game::ItemQualityTier(item);
|
||||
return tier >= game::kTierBrown ? kBrown : tier >= game::kTierYellow ? kYellow : tier >= game::kTierBlue ? kBlue : kWhite;
|
||||
}
|
||||
|
||||
Mode ModeOf(void* object) {
|
||||
const int category = CategoryOf(object);
|
||||
return category == kNone ? Mode::Default : g_modes[category];
|
||||
}
|
||||
|
||||
bool AnyMode(Mode mode) {
|
||||
for (const Mode m : g_modes) {
|
||||
if (m == mode) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Load() {
|
||||
for (int i = 0; i < kCategoryCount; ++i) {
|
||||
const std::string text = re::ConfigString("loot", kCategories[i].key, kModes[int(Mode::Default)].key);
|
||||
for (int m = 0; m < kModeCount; ++m) {
|
||||
if (_stricmp(text.c_str(), kModes[m].key) == 0 && ModeAllowed(i, static_cast<Mode>(m))) {
|
||||
g_modes[i] = static_cast<Mode>(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RE_STARTUP(LootFilterStartup, Load);
|
||||
|
||||
// --- Void ------------------------------------------------------------------------
|
||||
|
||||
int g_dropDepth = 0; // inside the loot drop
|
||||
std::vector<uint32_t> g_dropped; // its items and thrown out chest contents
|
||||
|
||||
void OnTick(uint8_t*) {
|
||||
if (g_dropped.empty()) {
|
||||
return;
|
||||
}
|
||||
const std::vector<uint32_t> dropped = std::move(g_dropped);
|
||||
g_dropped.clear();
|
||||
for (const uint32_t id : dropped) {
|
||||
auto* object = static_cast<uint8_t*>(game::GetObjectById(id));
|
||||
if (!object) {
|
||||
continue;
|
||||
}
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t*>(object + game::kObjectFlags);
|
||||
if ((flags & (game::kObjectCarried | game::kObjectDead)) || ModeOf(object) != Mode::Void) {
|
||||
continue;
|
||||
}
|
||||
re::Log("lootfilter: drop %u (type %u) deleted", id, game::ItemType(object));
|
||||
game::RemoveFromMap(id);
|
||||
game::DestroyObject(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
game::OnEngineTick(&OnTick);
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
// --- labels --------------------------------------------------------------------
|
||||
|
||||
constexpr size_t kLabelsBegin = 0x96b30;
|
||||
constexpr size_t kLabelEntrySize = 0x1c;
|
||||
constexpr uint32_t kLabelSkipped = 0x80000000;
|
||||
constexpr size_t kViewFlags = 0x10; // cWorldView0
|
||||
constexpr uint32_t kLabelsShown = 0x4000;
|
||||
|
||||
uint32_t* LabelEntry(uint8_t* view, size_t index) {
|
||||
auto* begin = *reinterpret_cast<uint8_t**>(view + kLabelsBegin);
|
||||
return reinterpret_cast<uint32_t*>(begin + index * kLabelEntrySize);
|
||||
}
|
||||
|
||||
size_t LabelCount(const uint8_t* view) {
|
||||
const auto* begin = *reinterpret_cast<const uint8_t* const*>(view + kLabelsBegin);
|
||||
const auto* end = *reinterpret_cast<const uint8_t* const*>(view + kLabelsBegin + 4);
|
||||
return begin && end > begin ? static_cast<size_t>(end - begin) / kLabelEntrySize : 0;
|
||||
}
|
||||
|
||||
bool AlwaysShown(uint32_t entryId) {
|
||||
if (entryId & kLabelSkipped) {
|
||||
return false;
|
||||
}
|
||||
void* object = game::GetObjectById(entryId);
|
||||
const int category = object ? CategoryOf(object) : kNone;
|
||||
return category != kNone && g_modes[category] == Mode::AlwaysShow;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const wchar_t* CategoryLabel(int category) {
|
||||
return kCategories[category].label;
|
||||
}
|
||||
|
||||
uint32_t CategoryColour(int category) {
|
||||
return kCategories[category].colour;
|
||||
}
|
||||
|
||||
const wchar_t* ModeLabel(Mode mode) {
|
||||
return kModes[static_cast<int>(mode)].label;
|
||||
}
|
||||
|
||||
bool ModeAllowed(int category, Mode mode) {
|
||||
return category != kQuest || mode != Mode::Void;
|
||||
}
|
||||
|
||||
Mode GetMode(int category) {
|
||||
return g_modes[category];
|
||||
}
|
||||
|
||||
void SetModes(const Mode* modes) {
|
||||
for (int i = 0; i < kCategoryCount; ++i) {
|
||||
g_modes[i] = modes[i];
|
||||
re::SetConfigString("loot", kCategories[i].key, kModes[static_cast<int>(modes[i])].key);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace mods::lootfilter
|
||||
|
||||
namespace game {
|
||||
|
||||
using namespace mods::lootfilter;
|
||||
|
||||
// cCreature::CanCollectItem (0x4ec170, (item), ret 4): carried items never;
|
||||
// runes and anything not filtered always; else by the mode.
|
||||
bool __fastcall Creature_CanCollectItem(void* self, void* edx, uint8_t* item);
|
||||
RE_HOOK(0x004ec170, Creature_CanCollectItem);
|
||||
bool __fastcall Creature_CanCollectItem(void*, void*, uint8_t* item) {
|
||||
if (!item || (*reinterpret_cast<const uint32_t*>(item + kObjectFlags) & kObjectCarried)) {
|
||||
return false;
|
||||
}
|
||||
const Mode mode = ModeOf(item);
|
||||
return mode == Mode::Default || mode == Mode::AlwaysShow;
|
||||
}
|
||||
|
||||
// The loot drop (cCreature 0x559a30, (forced type, ?, ?, killer id), ret 0x10;
|
||||
// returns the last item made or null).
|
||||
void* __fastcall Creature_DropLoot(void* self, void* edx, uint32_t type, uint32_t a, uint32_t b, uint32_t killer);
|
||||
RE_HOOK(0x00559a30, Creature_DropLoot);
|
||||
void* __fastcall Creature_DropLoot(void* self, void* edx, uint32_t type, uint32_t a, uint32_t b, uint32_t killer) {
|
||||
++g_dropDepth;
|
||||
void* result = Creature_DropLoot_original(self, edx, type, a, b, killer);
|
||||
--g_dropDepth;
|
||||
return result;
|
||||
}
|
||||
|
||||
// cObjectManager::CreateAt (0x5fbaa0, (type, position, link, 0, 0), ret 0x14).
|
||||
uint32_t __fastcall ObjMgr_CreateAt(void* self, void* edx, uint32_t type, const void* position, uint32_t link,
|
||||
uint32_t a, uint32_t b);
|
||||
RE_HOOK(0x005fbaa0, ObjMgr_CreateAt);
|
||||
uint32_t __fastcall ObjMgr_CreateAt(void* self, void* edx, uint32_t type, const void* position, uint32_t link,
|
||||
uint32_t a, uint32_t b) {
|
||||
const uint32_t id = ObjMgr_CreateAt_original(self, edx, type, position, link, a, b);
|
||||
if (g_dropDepth > 0 && id && AnyMode(Mode::Void)) {
|
||||
g_dropped.push_back(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// cChest3D::dropItems (0x5c9b10, (?), ret 4): throws out its contents (ids at
|
||||
// +0x1e4 .. +0x1e8), unless already opened (+0x14 bit 0x4000).
|
||||
constexpr size_t kChestContents = 0x1e4;
|
||||
void __fastcall Chest_DropItems(void* self, void* edx, uint32_t a);
|
||||
RE_HOOK(0x005c9b10, Chest_DropItems);
|
||||
void __fastcall Chest_DropItems(void* self, void* edx, uint32_t a) {
|
||||
if (AnyMode(Mode::Void)) {
|
||||
auto* chest = static_cast<uint8_t*>(self);
|
||||
const auto* begin = *reinterpret_cast<const uint32_t* const*>(chest + kChestContents);
|
||||
const auto* end = *reinterpret_cast<const uint32_t* const*>(chest + kChestContents + 4);
|
||||
for (const uint32_t* it = begin; it && it < end; ++it) {
|
||||
g_dropped.push_back(*it);
|
||||
}
|
||||
}
|
||||
Chest_DropItems_original(self, edx, a);
|
||||
}
|
||||
|
||||
// cWorldView0::DrawObjectLabels (0x627260, (renderer), ret 4).
|
||||
void __fastcall WorldView_DrawObjectLabels(void* self, void* edx, void* renderer);
|
||||
RE_HOOK(0x00627260, WorldView_DrawObjectLabels);
|
||||
void __fastcall WorldView_DrawObjectLabels(void* self, void* edx, void* renderer) {
|
||||
auto* view = static_cast<uint8_t*>(self);
|
||||
if (mods::input::LabelsKeyHeld() || !AnyMode(Mode::AlwaysShow)) {
|
||||
WorldView_DrawObjectLabels_original(self, edx, renderer);
|
||||
return;
|
||||
}
|
||||
const size_t count = LabelCount(view);
|
||||
bool any = false;
|
||||
for (size_t i = 0; i < count && !any; ++i) {
|
||||
any = AlwaysShown(LabelEntry(view, i)[0]);
|
||||
}
|
||||
if (!any) {
|
||||
WorldView_DrawObjectLabels_original(self, edx, renderer);
|
||||
return;
|
||||
}
|
||||
std::vector<uint32_t*> marked;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint32_t* entry = LabelEntry(view, i);
|
||||
if (!(entry[0] & kLabelSkipped) && !AlwaysShown(entry[0])) {
|
||||
entry[0] |= kLabelSkipped;
|
||||
marked.push_back(entry);
|
||||
}
|
||||
}
|
||||
mods::input::ForceLabels(true);
|
||||
WorldView_DrawObjectLabels_original(self, edx, renderer);
|
||||
mods::input::ForceLabels(false);
|
||||
for (uint32_t* entry : marked) {
|
||||
entry[0] &= ~kLabelSkipped;
|
||||
}
|
||||
*reinterpret_cast<uint32_t*>(view + kViewFlags) &= ~kLabelsShown;
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
// Loot filter (mods/lootfilter.cpp): per item category what happens to loot.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace mods::lootfilter {
|
||||
|
||||
enum class Mode : int {
|
||||
Void, // a drop of the category is deleted at once
|
||||
Ignore, // not collected by the pickup key
|
||||
Default, // collected; label with the labels key
|
||||
AlwaysShow, // collected; label without the key too
|
||||
};
|
||||
constexpr int kModeCount = 4;
|
||||
|
||||
// Categories: Questgegenstände (first: checked before the others, never
|
||||
// Void), Gold, Tränke, then the item colours white, blue, yellow, brown, green.
|
||||
constexpr int kCategoryCount = 8;
|
||||
|
||||
const wchar_t* CategoryLabel(int category);
|
||||
uint32_t CategoryColour(int category); // as the game colours their names
|
||||
const wchar_t* ModeLabel(Mode mode);
|
||||
bool ModeAllowed(int category, Mode mode);
|
||||
|
||||
Mode GetMode(int category);
|
||||
// Sets all modes (`modes[kCategoryCount]`) and writes them to sacredK.ini.
|
||||
void SetModes(const Mode* modes);
|
||||
|
||||
} // namespace mods::lootfilter
|
||||
@@ -0,0 +1,93 @@
|
||||
// Main menu "Spielen" (request of 2026-10-20, docs/characters.md): the entries
|
||||
// "Kampagne Ancaria", "Kampagne Underworld" and "Spielstände" become one
|
||||
// "Spielen", which opens the load window: there the characters of both
|
||||
// campaigns are loaded or started on any campaign and difficulty, and new
|
||||
// characters are made (characters.cpp).
|
||||
//
|
||||
// cUI_MainMenu builds its entries in 0x712f30 (thiscall, plain ret): one
|
||||
// cUI_StaticText64FX (constructor 0x753d70) per entry with its name at +0x30
|
||||
// (UI_LABEL_NEWGAME, ...), its rect at +0x24 (relative, 0x20 apart), its text
|
||||
// id at +0x78 and its command at +0x88 (UI_LABEL_LOADGAME: show
|
||||
// UI_WND_SAVEGAME). The main page's entries are in the vector at +0x158, all
|
||||
// children in the one at +0x90. The event handler (vf10 0x711fa0) sets
|
||||
// g_campaign 0x182cdc8 by entry name before an entry handles an event:
|
||||
// NEWGAME 0, NEWADDON 1. After the build the campaign entries are removed and
|
||||
// "Spielstände" moves to the top as "Spielen"; the others move up.
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/tabs.h"
|
||||
#include "game/ui/text.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::mainmenu {
|
||||
namespace {
|
||||
|
||||
constexpr size_t kMenuChildren = 0x90; // vector of all child controls
|
||||
constexpr size_t kMenuMainPage = 0x158; // vector of the main page's entries
|
||||
constexpr size_t kEntryTextId = 0x78;
|
||||
constexpr int kEntryPitch = 0x20; // as 0x712f30 places them
|
||||
|
||||
constexpr const char* kRemoved[] = {"UI_LABEL_NEWGAME", "UI_LABEL_NEWADDON"};
|
||||
constexpr char kPlayEntry[] = "UI_LABEL_LOADGAME";
|
||||
|
||||
int32_t& EntryY(uint8_t* entry) {
|
||||
return reinterpret_cast<game::UiRect*>(entry + game::kControlRect)->y;
|
||||
}
|
||||
|
||||
bool IsRemoved(const uint8_t* entry) {
|
||||
return std::any_of(std::begin(kRemoved), std::end(kRemoved),
|
||||
[&](const char* name) { return game::ControlNamed(entry, name); });
|
||||
}
|
||||
|
||||
void __fastcall MainMenu_Build(uint8_t* self, void* edx);
|
||||
RE_HOOK(0x00712f30, MainMenu_Build);
|
||||
void __fastcall MainMenu_Build(uint8_t* self, void* edx) {
|
||||
MainMenu_Build_original(self, edx);
|
||||
const std::vector<uint8_t*> original = game::ControlsIn(self, kMenuMainPage);
|
||||
auto play = std::find_if(original.begin(), original.end(),
|
||||
[](const uint8_t* entry) { return game::ControlNamed(entry, kPlayEntry); });
|
||||
if (original.empty() || play == original.end()) {
|
||||
re::Log("mainmenu: no %s entry, menu left as it is", kPlayEntry);
|
||||
return;
|
||||
}
|
||||
// New order: "Spielen", then the rest as they were; each keeps the extra
|
||||
// space it had above it (the gap before "Beenden").
|
||||
std::vector<int32_t> extraGap(original.size(), 0);
|
||||
for (size_t i = 1; i < original.size(); ++i) {
|
||||
extraGap[i] = std::max(0, EntryY(original[i]) - EntryY(original[i - 1]) - kEntryPitch);
|
||||
}
|
||||
int32_t y = EntryY(original.front());
|
||||
std::vector<uint8_t*> kept = {*play};
|
||||
std::vector<int32_t> keptGap = {0};
|
||||
for (size_t i = 0; i < original.size(); ++i) {
|
||||
if (original[i] != *play && !IsRemoved(original[i])) {
|
||||
kept.push_back(original[i]);
|
||||
keptGap.push_back(extraGap[i]);
|
||||
}
|
||||
}
|
||||
for (uint8_t* entry : original) {
|
||||
if (IsRemoved(entry)) {
|
||||
game::RemoveControl(self, kMenuMainPage, entry);
|
||||
game::RemoveControl(self, kMenuChildren, entry);
|
||||
game::DeleteControl(entry);
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; i < kept.size(); ++i) {
|
||||
game::SetControlAt(self, kMenuMainPage, i, kept[i]);
|
||||
y += keptGap[i];
|
||||
EntryY(kept[i]) = y;
|
||||
y += kEntryPitch;
|
||||
}
|
||||
static const uint32_t kPlayText = game::RegisterText(L"Spielen");
|
||||
*reinterpret_cast<uint32_t*>(*play + kEntryTextId) = kPlayText;
|
||||
re::Log("mainmenu: %zu entries, \"Spielen\" first", kept.size());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::mainmenu
|
||||
@@ -0,0 +1,376 @@
|
||||
// The explored-area border on the large map (M) as a continuous line along
|
||||
// the fog's edge.
|
||||
//
|
||||
// Request (2026-10-26): "the red outline on the mega map is choppy and feels
|
||||
// weird"; replace it, following the explored area (option A).
|
||||
//
|
||||
// Measured (disassembly): cUI_Megamap::DrawVisitedBorder (0x6c8580, called by
|
||||
// the map's render) draws one 2x2 px red dot (0xffff0000) per bit of a 100x100
|
||||
// byte grid (world +0xb73c), at a fixed offset of 10 / 20 px from the sector's
|
||||
// origin towards each neighbour. The grid marks unvisited sectors next to
|
||||
// visited ones (sector flag 0x20000, set when the hero's sector becomes the
|
||||
// centre of the active 3x3, 0x626603; rebuilt on load by 0x633230 and per
|
||||
// sector change by 0x6332d0). So the line was dots in 64-tile steps, and it
|
||||
// followed the sectors stood in, not the explored mask the fog shows (since
|
||||
// the 10x map reveal, far inside the cleared area).
|
||||
//
|
||||
// The mod replaces the draw: the edges between explored and unexplored cells
|
||||
// (2x2 tiles, game/engine/explored.h) of the map's 100x100 sectors are chained
|
||||
// into polylines, simplified to within a pixel and drawn as 2 px lines in the
|
||||
// game's red. The lines are rebuilt while the map is open whenever the masks
|
||||
// changed (checked every 250 ms). Cells of a missing sector count as neither,
|
||||
// so no line runs along the edge of the world.
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "game/engine/explored.h"
|
||||
#include "game/ui/draw.h"
|
||||
#include "game/ui/megamap.h"
|
||||
#include "mods/widescreen.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::mapborder {
|
||||
namespace {
|
||||
|
||||
// The map's sector grid (World_RebuildVisitedBorder 0x633230: 100x100).
|
||||
constexpr int kMapSectors = 100;
|
||||
constexpr int kMapCells = kMapSectors * game::kSectorCells;
|
||||
constexpr uint32_t kColour = 0xffff0000; // the game's border colour (0x6c870e)
|
||||
constexpr float kLineWidth = 2.0f; // the game's dots: 2x2 px
|
||||
constexpr double kTolerance = 0.75; // map pixels a simplified line may deviate
|
||||
constexpr DWORD kCheckIntervalMs = 250;
|
||||
constexpr int kScreenHeight = 768;
|
||||
|
||||
enum class Cell : uint8_t { None, Unexplored, Explored };
|
||||
|
||||
struct Grid {
|
||||
const uint8_t* masks[kMapSectors][kMapSectors];
|
||||
|
||||
void Load() {
|
||||
for (int y = 0; y < kMapSectors; ++y) {
|
||||
for (int x = 0; x < kMapSectors; ++x) {
|
||||
masks[y][x] = game::SectorExploredMask(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cell At(int x, int y) const {
|
||||
if (x < 0 || y < 0 || x >= kMapCells || y >= kMapCells) {
|
||||
return Cell::None;
|
||||
}
|
||||
const uint8_t* mask = masks[y / game::kSectorCells][x / game::kSectorCells];
|
||||
if (!mask) {
|
||||
return Cell::None;
|
||||
}
|
||||
return game::MaskCellExplored(mask, x % game::kSectorCells, y % game::kSectorCells) ? Cell::Explored
|
||||
: Cell::Unexplored;
|
||||
}
|
||||
|
||||
// FNV-1a over the masks and which sectors exist.
|
||||
uint64_t Hash() const {
|
||||
uint64_t hash = 0xcbf29ce484222325ull;
|
||||
const auto add = [&](uint8_t byte) { hash = (hash ^ byte) * 0x100000001b3ull; };
|
||||
for (const auto& row : masks) {
|
||||
for (const uint8_t* mask : row) {
|
||||
add(mask != nullptr);
|
||||
for (size_t i = 0; mask && i < 128; ++i) {
|
||||
add(mask[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
};
|
||||
|
||||
struct Point {
|
||||
double x, y;
|
||||
};
|
||||
|
||||
// Cell corner -> map pixel: WorldToMapPixel is linear, so three points give
|
||||
// it for the whole grid.
|
||||
struct Projection {
|
||||
Point origin, stepX, stepY;
|
||||
|
||||
static Point Corner(uint8_t* megamap, int cellX, int cellY) {
|
||||
const float unitsPerCell = game::kExploredCellTiles * game::kWorldUnitsPerTile;
|
||||
const game::Vec3 pixel = game::WorldToMapPixel(megamap, {cellX * unitsPerCell, cellY * unitsPerCell, 0});
|
||||
return {pixel.x, pixel.y};
|
||||
}
|
||||
|
||||
explicit Projection(uint8_t* megamap) {
|
||||
origin = Corner(megamap, 0, 0);
|
||||
const Point right = Corner(megamap, kMapCells, 0);
|
||||
const Point down = Corner(megamap, 0, kMapCells);
|
||||
stepX = {(right.x - origin.x) / kMapCells, (right.y - origin.y) / kMapCells};
|
||||
stepY = {(down.x - origin.x) / kMapCells, (down.y - origin.y) / kMapCells};
|
||||
}
|
||||
|
||||
Point operator()(int cellX, int cellY) const {
|
||||
return {origin.x + cellX * stepX.x + cellY * stepY.x, origin.y + cellX * stepX.y + cellY * stepY.y};
|
||||
}
|
||||
};
|
||||
|
||||
// Corners on the cell grid, (kMapCells + 1)^2 of them.
|
||||
uint32_t Key(int x, int y) {
|
||||
return static_cast<uint32_t>(y) * (kMapCells + 1) + static_cast<uint32_t>(x);
|
||||
}
|
||||
|
||||
struct Edge {
|
||||
int x0, y0, x1, y1;
|
||||
};
|
||||
|
||||
// Every side between an explored and an unexplored cell, clockwise around
|
||||
// the explored cell (screen orientation, y down), so the edges of a border
|
||||
// chain head to tail.
|
||||
std::vector<Edge> BorderEdges(const Grid& grid) {
|
||||
std::vector<Edge> edges;
|
||||
for (int sy = 0; sy < kMapSectors; ++sy) {
|
||||
for (int sx = 0; sx < kMapSectors; ++sx) {
|
||||
const uint8_t* mask = grid.masks[sy][sx];
|
||||
bool any = false;
|
||||
for (int i = 0; mask && i < 128 && !any; ++i) {
|
||||
any = mask[i] != 0;
|
||||
}
|
||||
if (!any) {
|
||||
continue;
|
||||
}
|
||||
for (int ly = 0; ly < game::kSectorCells; ++ly) {
|
||||
for (int lx = 0; lx < game::kSectorCells; ++lx) {
|
||||
if (!game::MaskCellExplored(mask, lx, ly)) {
|
||||
continue;
|
||||
}
|
||||
const int x = sx * game::kSectorCells + lx;
|
||||
const int y = sy * game::kSectorCells + ly;
|
||||
if (grid.At(x, y - 1) == Cell::Unexplored) {
|
||||
edges.push_back({x, y, x + 1, y});
|
||||
}
|
||||
if (grid.At(x + 1, y) == Cell::Unexplored) {
|
||||
edges.push_back({x + 1, y, x + 1, y + 1});
|
||||
}
|
||||
if (grid.At(x, y + 1) == Cell::Unexplored) {
|
||||
edges.push_back({x + 1, y + 1, x, y + 1});
|
||||
}
|
||||
if (grid.At(x - 1, y) == Cell::Unexplored) {
|
||||
edges.push_back({x, y + 1, x, y});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
using Chain = std::vector<std::pair<int, int>>;
|
||||
|
||||
// Edges chained into polylines: open ones (from a corner no edge ends at,
|
||||
// at a missing sector) first, then the closed loops. Where two cells touch
|
||||
// only at a corner, the chain turns right, staying around its own cell.
|
||||
std::vector<Chain> ChainEdges(const std::vector<Edge>& edges) {
|
||||
std::unordered_map<uint32_t, std::vector<size_t>> starting;
|
||||
std::unordered_map<uint32_t, int> ending;
|
||||
starting.reserve(edges.size());
|
||||
ending.reserve(edges.size());
|
||||
for (size_t i = 0; i < edges.size(); ++i) {
|
||||
starting[Key(edges[i].x0, edges[i].y0)].push_back(i);
|
||||
++ending[Key(edges[i].x1, edges[i].y1)];
|
||||
}
|
||||
std::vector<bool> used(edges.size(), false);
|
||||
std::vector<Chain> chains;
|
||||
const auto follow = [&](size_t first) {
|
||||
Chain chain = {{edges[first].x0, edges[first].y0}};
|
||||
size_t current = first;
|
||||
for (;;) {
|
||||
used[current] = true;
|
||||
const Edge& e = edges[current];
|
||||
chain.emplace_back(e.x1, e.y1);
|
||||
const int dx = e.x1 - e.x0;
|
||||
const int dy = e.y1 - e.y0;
|
||||
size_t next = SIZE_MAX;
|
||||
int bestTurn = 3;
|
||||
for (size_t candidate : starting[Key(e.x1, e.y1)]) {
|
||||
if (used[candidate]) {
|
||||
continue;
|
||||
}
|
||||
const int cx = edges[candidate].x1 - edges[candidate].x0;
|
||||
const int cy = edges[candidate].y1 - edges[candidate].y0;
|
||||
// 0 right (-dy, dx), 1 straight, 2 left.
|
||||
const int turn = (cx == -dy && cy == dx) ? 0 : (cx == dx && cy == dy) ? 1 : 2;
|
||||
if (turn < bestTurn) {
|
||||
bestTurn = turn;
|
||||
next = candidate;
|
||||
}
|
||||
}
|
||||
if (next == SIZE_MAX) {
|
||||
break;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
chains.push_back(std::move(chain));
|
||||
};
|
||||
for (size_t i = 0; i < edges.size(); ++i) {
|
||||
if (!used[i] && ending.find(Key(edges[i].x0, edges[i].y0)) == ending.end()) {
|
||||
follow(i);
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; i < edges.size(); ++i) {
|
||||
if (!used[i]) {
|
||||
follow(i);
|
||||
}
|
||||
}
|
||||
return chains;
|
||||
}
|
||||
|
||||
double DistanceToSegment(const Point& p, const Point& a, const Point& b) {
|
||||
const double dx = b.x - a.x;
|
||||
const double dy = b.y - a.y;
|
||||
const double length2 = dx * dx + dy * dy;
|
||||
double t = length2 > 0 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / length2 : 0;
|
||||
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||||
return std::hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
||||
}
|
||||
|
||||
// Douglas-Peucker: keeps first and last, and every point needed to stay
|
||||
// within kTolerance of the original.
|
||||
void Simplify(const std::vector<Point>& points, size_t first, size_t last, std::vector<Point>& out) {
|
||||
double farthest = 0;
|
||||
size_t index = first;
|
||||
for (size_t i = first + 1; i < last; ++i) {
|
||||
const double d = DistanceToSegment(points[i], points[first], points[last]);
|
||||
if (d > farthest) {
|
||||
farthest = d;
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
if (farthest > kTolerance) {
|
||||
Simplify(points, first, index, out);
|
||||
Simplify(points, index, last, out);
|
||||
} else {
|
||||
out.push_back(points[last]);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Point> SimplifiedLine(const Chain& chain, const Projection& project) {
|
||||
std::vector<Point> points;
|
||||
points.reserve(chain.size());
|
||||
for (const auto& [x, y] : chain) {
|
||||
points.push_back(project(x, y));
|
||||
}
|
||||
std::vector<Point> out = {points.front()};
|
||||
if (points.size() < 2) {
|
||||
return out;
|
||||
}
|
||||
// A closed loop is split at its point farthest from the start, so both
|
||||
// halves have distinct ends.
|
||||
size_t split = points.size() - 1;
|
||||
if (chain.front() == chain.back()) {
|
||||
double farthest = -1;
|
||||
for (size_t i = 1; i + 1 < points.size(); ++i) {
|
||||
const double d = std::hypot(points[i].x - points[0].x, points[i].y - points[0].y);
|
||||
if (d > farthest) {
|
||||
farthest = d;
|
||||
split = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
Simplify(points, 0, split, out);
|
||||
if (split < points.size() - 1) {
|
||||
Simplify(points, split, points.size() - 1, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Map-pixel line segments of the current border.
|
||||
struct Segment {
|
||||
float x0, y0, x1, y1;
|
||||
};
|
||||
std::vector<Segment> g_segments;
|
||||
uint64_t g_hash = 0;
|
||||
DWORD g_lastCheck = 0;
|
||||
bool g_built = false;
|
||||
|
||||
void Rebuild(uint8_t* megamap) {
|
||||
static Grid grid;
|
||||
grid.Load();
|
||||
const uint64_t hash = grid.Hash();
|
||||
if (g_built && hash == g_hash) {
|
||||
return;
|
||||
}
|
||||
const DWORD start = GetTickCount();
|
||||
const Projection project(megamap);
|
||||
const std::vector<Edge> edges = BorderEdges(grid);
|
||||
const std::vector<Chain> chains = ChainEdges(edges);
|
||||
g_segments.clear();
|
||||
for (const Chain& chain : chains) {
|
||||
const std::vector<Point> line = SimplifiedLine(chain, project);
|
||||
for (size_t i = 1; i < line.size(); ++i) {
|
||||
g_segments.push_back({static_cast<float>(line[i - 1].x), static_cast<float>(line[i - 1].y),
|
||||
static_cast<float>(line[i].x), static_cast<float>(line[i].y)});
|
||||
}
|
||||
}
|
||||
if (!g_built) {
|
||||
re::Log("map border: %zu edges, %zu lines, %zu segments, %lu ms", edges.size(), chains.size(),
|
||||
g_segments.size(), GetTickCount() - start);
|
||||
}
|
||||
g_hash = hash;
|
||||
g_built = true;
|
||||
}
|
||||
|
||||
void Draw(uint8_t* megamap, void* device) {
|
||||
const DWORD now = GetTickCount();
|
||||
if (!g_built || now - g_lastCheck >= kCheckIntervalMs) {
|
||||
g_lastCheck = now;
|
||||
Rebuild(megamap);
|
||||
}
|
||||
const float scrollX = static_cast<float>(game::MegamapScrollX(megamap));
|
||||
const float scrollY = static_cast<float>(game::MegamapScrollY(megamap));
|
||||
const float width = static_cast<float>(mods::widescreen::RenderWidth());
|
||||
const float half = kLineWidth / 2;
|
||||
static std::vector<game::ScreenVertex> vertices;
|
||||
vertices.clear();
|
||||
for (const Segment& s : g_segments) {
|
||||
const float x0 = s.x0 - scrollX, y0 = s.y0 - scrollY;
|
||||
const float x1 = s.x1 - scrollX, y1 = s.y1 - scrollY;
|
||||
if ((x0 < -half && x1 < -half) || (x0 > width + half && x1 > width + half) ||
|
||||
(y0 < -half && y1 < -half) || (y0 > kScreenHeight + half && y1 > kScreenHeight + half)) {
|
||||
continue;
|
||||
}
|
||||
const float length = std::hypot(x1 - x0, y1 - y0);
|
||||
if (length <= 0) {
|
||||
continue;
|
||||
}
|
||||
// Along and across the segment, half the width each; the ends reach
|
||||
// half the width further, closing the joints.
|
||||
const float ax = (x1 - x0) / length * half, ay = (y1 - y0) / length * half;
|
||||
const float nx = -ay, ny = ax;
|
||||
const game::ScreenVertex a = {x0 - ax + nx, y0 - ay + ny, 0, 1, kColour, 0, 0, 0};
|
||||
const game::ScreenVertex b = {x0 - ax - nx, y0 - ay - ny, 0, 1, kColour, 0, 0, 0};
|
||||
const game::ScreenVertex c = {x1 + ax + nx, y1 + ay + ny, 0, 1, kColour, 0, 0, 0};
|
||||
const game::ScreenVertex d = {x1 + ax - nx, y1 + ay - ny, 0, 1, kColour, 0, 0, 0};
|
||||
vertices.insert(vertices.end(), {a, b, c, c, b, d});
|
||||
}
|
||||
if (!vertices.empty()) {
|
||||
game::FillTriangles(device, vertices.data(), vertices.size());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::mapborder
|
||||
|
||||
namespace game {
|
||||
|
||||
// cUI_Megamap::DrawVisitedBorder (0x6c8580, thiscall (device), ret 4).
|
||||
void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void* edx, void* device);
|
||||
RE_HOOK(0x006c8580, UiMegamap_DrawVisitedBorder);
|
||||
void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void*, void* device) {
|
||||
if (self && device) {
|
||||
mods::mapborder::Draw(self, device);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,108 @@
|
||||
// A larger vision range for the explored map (request of 2026-10-25: "increase
|
||||
// the base vision range for the map's fog of war reveal by x10 or so").
|
||||
//
|
||||
// Measured (game/engine/explored.h): RevealAroundHero stamps a fixed pattern
|
||||
// around the hero into the sectors' explored masks whenever the hero's tile
|
||||
// changes, a 14-tile circle by day, smaller ones at night.
|
||||
//
|
||||
// The mod: after the original revealed at a new tile, the same pattern scaled
|
||||
// up kScale times is revealed too (by day a 140-tile circle), one cell (2x2
|
||||
// tiles) at a time through the game's own World_SetExplored. Only loaded
|
||||
// sectors can be marked; they reach further than the scaled stamp.
|
||||
//
|
||||
// Creatures on the zoomed map views (large map, TAB map; 0x6cc950): one off
|
||||
// explored ground, or a hostile one, is only drawn within a sight range of the
|
||||
// hero (|dx| and |dy| in world units, about 53.7 per tile): 650 (about 12
|
||||
// tiles), 300 where 0x63d280 holds for the hero's area, 350 when the weather
|
||||
// object's 0x41ba40 is false; plus bonuses from a skill (0x355) and the hero's
|
||||
// equipment values. The three base values are scaled by kScale as well.
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include "game/engine/explored.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::mapreveal {
|
||||
namespace {
|
||||
|
||||
constexpr int kScale = 10;
|
||||
constexpr int kCellTiles = 2; // one mask bit per 2x2 tiles
|
||||
|
||||
// The stamp cell a tile offset falls into: cell c covers offsets
|
||||
// c * kScale - kScale / 2 .. c * kScale + kScale / 2 - 1.
|
||||
int StampOffset(int tiles) {
|
||||
const int shifted = tiles + kScale / 2;
|
||||
return (shifted >= 0 ? shifted : shifted - kScale + 1) / kScale;
|
||||
}
|
||||
|
||||
// mov dword ptr [esp+14h], <float>: the map's creature sight range.
|
||||
struct SightRange {
|
||||
uint32_t instruction;
|
||||
float value;
|
||||
};
|
||||
constexpr SightRange kCreatureSightRanges[] = {
|
||||
{0x006ce677, 650.0f},
|
||||
{0x006ce6b5, 300.0f},
|
||||
{0x006ce6cf, 350.0f},
|
||||
};
|
||||
|
||||
uint32_t FloatBits(float value) {
|
||||
uint32_t bits;
|
||||
std::memcpy(&bits, &value, sizeof bits);
|
||||
return bits;
|
||||
}
|
||||
|
||||
void Startup() {
|
||||
int failed = 0;
|
||||
for (const SightRange& range : kCreatureSightRanges) {
|
||||
failed += !re::PatchInstructionDword(range.instruction, FloatBits(range.value), FloatBits(range.value * kScale));
|
||||
}
|
||||
if (failed) {
|
||||
re::Log("mapreveal: %d creature sight range patches FAILED", failed);
|
||||
}
|
||||
}
|
||||
|
||||
void RevealScaled(int tileX, int tileY, int sight) {
|
||||
const game::RevealStamp stamp = game::RevealStampFor(sight);
|
||||
const int centre = stamp.size / 2;
|
||||
const int radius = centre * kScale + kScale / 2;
|
||||
// Cells start at even tiles (sector origins are multiples of 64).
|
||||
const int left = (tileX - radius) & ~(kCellTiles - 1);
|
||||
const int top = (tileY - radius) & ~(kCellTiles - 1);
|
||||
for (int y = top; y <= tileY + radius; y += kCellTiles) {
|
||||
const int row = centre + StampOffset(y + kCellTiles / 2 - tileY);
|
||||
if (row < 0 || row >= stamp.size) {
|
||||
continue;
|
||||
}
|
||||
for (int x = left; x <= tileX + radius; x += kCellTiles) {
|
||||
const int column = centre + StampOffset(x + kCellTiles / 2 - tileX);
|
||||
if (column >= 0 && column < stamp.size && stamp.cells[row * stamp.size + column]) {
|
||||
game::SetExplored(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RE_STARTUP(MapRevealStartup, Startup);
|
||||
|
||||
} // namespace mods::mapreveal
|
||||
|
||||
void __fastcall RevealAroundHero(void* creature, void* edx, int tileX, int tileY, int sight);
|
||||
RE_HOOK(game::kRevealAroundHero, RevealAroundHero);
|
||||
void __fastcall RevealAroundHero(void* creature, void* edx, int tileX, int tileY, int sight) {
|
||||
const int lastX = game::LastRevealTileX();
|
||||
const int lastY = game::LastRevealTileY();
|
||||
RevealAroundHero_original(creature, edx, tileX, tileY, sight);
|
||||
// The original revealed (local hero, new tile) iff it moved its last tile
|
||||
// here; it ignores tile 0, 0.
|
||||
const bool revealed = (tileX != 0 || tileY != 0) && (lastX != tileX || lastY != tileY) &&
|
||||
game::LastRevealTileX() == tileX && game::LastRevealTileY() == tileY;
|
||||
if (revealed && sight >= 0 && sight <= 3) {
|
||||
mods::mapreveal::RevealScaled(tileX, tileY, sight);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@
|
||||
// to the right screen edge and its top and bottom tiles stretch across, and
|
||||
// the zoom buttons at the bottom stay centred. The window is drawn and clicked
|
||||
// unshifted (mods/hud.cpp, Anchor::Screen). See docs/hud.md.
|
||||
//
|
||||
// Zoom (request of 2026-10-25: the zoomed view did not start centred on the
|
||||
// hero): held mouse button zooms on the mouse (the game's magnifier); the zoom
|
||||
// button now zooms on the hero and follows him (the game kept the last
|
||||
// magnifier point, or 0,0).
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
@@ -16,6 +21,11 @@
|
||||
#include <unordered_map>
|
||||
|
||||
#include "game/addresses.h"
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/ui/megamap.h"
|
||||
#include "game/ui/windows.h"
|
||||
#include "mods/hud.h"
|
||||
#include "mods/megamap.h"
|
||||
#include "mods/widescreen.h"
|
||||
@@ -51,15 +61,71 @@ constexpr uint32_t kZoomView = 0x006cc950;
|
||||
constexpr uint32_t kZoomCentreUses[] = {0x006ccb7f, 0x006cf782};
|
||||
constexpr uint32_t kZoomMarkerX = 0x006ce156;
|
||||
constexpr float kZoomMarkerFromCentre = 256.0f;
|
||||
// The heroes' markers in the zoomed view: a 6 px quad at x = d + 509 / 515
|
||||
// (fadd [509.0] 0x6cf1bd, fadd [515.0] 0x6cf1dd), i.e. the centre 512 +- 3
|
||||
// (2026-10-25: they stayed at the 1024 layout's centre).
|
||||
constexpr uint32_t kZoomHeroMarkerLeft = 0x006cf1bd;
|
||||
constexpr uint32_t kZoomHeroMarkerRight = 0x006cf1dd;
|
||||
constexpr uintptr_t kZoomHeroMarkerLeftF = 0x00894780; // 509.0
|
||||
constexpr uintptr_t kZoomHeroMarkerRightF = 0x00894778; // 515.0
|
||||
constexpr float kZoomHeroMarkerHalf = 3.0f;
|
||||
// The other symbols in the zoomed view take the centre as an integer
|
||||
// immediate 200h (mov reg / add reg, 200h): a quad x = 200h - w / 200h + w
|
||||
// (0x6ced82, 0x6cedb0), icons (0x6cf47a, 0x6cf4ac) and labels centred on
|
||||
// their x (0x6cf5e4) (2026-10-25: they stayed at the 1024 layout's centre).
|
||||
// Rewritten whenever the centre changes.
|
||||
constexpr uint32_t kZoomCentreImmediates[] = {0x006ced82, 0x006cedb0, 0x006cf47a, 0x006cf4ac, 0x006cf5e4};
|
||||
// The dark band around the view (10 vertices, mov [esp+..], imm32): its right
|
||||
// outer edge x 1024 and inner edge x 954.
|
||||
constexpr uint32_t kBandRightOuter[] = {0x006ca774, 0x006ca808};
|
||||
constexpr uint32_t kBandRightInner[] = {0x006ca7bc, 0x006ca84c};
|
||||
constexpr int kBandWidth = 70;
|
||||
|
||||
// The zoom (cUI_Megamap's render 0x6c9ce0, 0x6ca442..0x6ca5cf): flags +0xc0154
|
||||
// bit 0x100 set by the zoom button (0x6c7fac); +0xc06bc set while the mouse
|
||||
// button is held on the map (0x6c8274). The zoom point +0xc0438 / +0xc043c
|
||||
// (screen) follows the mouse only while held; the view zooms on point + scroll
|
||||
// (+0xc016c / +0xc0170), the map pixel.
|
||||
constexpr size_t kMapFlags = 0xc0154;
|
||||
constexpr uint32_t kZoomedByButton = 0x100;
|
||||
constexpr size_t kZoomHeld = 0xc06bc;
|
||||
constexpr size_t kZoomPointX = 0xc0438;
|
||||
constexpr size_t kZoomPointY = 0xc043c;
|
||||
|
||||
template <typename T>
|
||||
T& Field(uint8_t* p, size_t offset) {
|
||||
return *reinterpret_cast<T*>(p + offset);
|
||||
}
|
||||
|
||||
// Every tick while the zoom button's zoom is on: the zoom point on the hero.
|
||||
void ZoomOnHero(uint8_t*) {
|
||||
uint8_t* megamap = game::UiWindow(game::UiManager(), game::kUiSlotMegamap);
|
||||
if (!game::UiWindowShown(game::UiManager(), game::kUiSlotMegamap) ||
|
||||
!(Field<uint32_t>(megamap, kMapFlags) & kZoomedByButton) || Field<uint8_t>(megamap, kZoomHeld)) {
|
||||
return;
|
||||
}
|
||||
auto* hero = static_cast<uint8_t*>(game::GetPlayerHero());
|
||||
if (!hero) {
|
||||
return;
|
||||
}
|
||||
const game::Vec3 world = {static_cast<float>(game::CreatureField(hero, game::kCreatureX)),
|
||||
static_cast<float>(game::CreatureField(hero, game::kCreatureY)),
|
||||
static_cast<float>(hero[game::kObjectLevel])};
|
||||
const game::Vec3 pixel = game::WorldToMapPixel(megamap, world);
|
||||
Field<int32_t>(megamap, kZoomPointX) = static_cast<int32_t>(pixel.x) - game::MegamapScrollX(megamap);
|
||||
Field<int32_t>(megamap, kZoomPointY) = static_cast<int32_t>(pixel.y) - game::MegamapScrollY(megamap);
|
||||
}
|
||||
|
||||
struct Registration {
|
||||
Registration() { game::OnEngineTick(&ZoomOnHero); }
|
||||
} g_registration;
|
||||
|
||||
float g_halfWidth = 512.0f;
|
||||
float g_zoomCentre = 512.0f;
|
||||
float g_zoomMarkerX = 768.0f;
|
||||
float g_zoomHeroMarkerLeft = 509.0f;
|
||||
float g_zoomHeroMarkerRight = 515.0f;
|
||||
uint32_t g_zoomCentreImmediate = kLayoutWidth / 2;
|
||||
|
||||
uint32_t FloatBits(float value) {
|
||||
uint32_t bits;
|
||||
@@ -183,6 +249,8 @@ void PatchLayout(int width) {
|
||||
failed += !re::RedirectOperand(instruction, game::addr::kScreenHalfWidthF, &g_zoomCentre);
|
||||
}
|
||||
failed += !re::RedirectOperand(kZoomMarkerX, game::addr::kScreenHeightF, &g_zoomMarkerX);
|
||||
failed += !re::RedirectOperand(kZoomHeroMarkerLeft, kZoomHeroMarkerLeftF, &g_zoomHeroMarkerLeft);
|
||||
failed += !re::RedirectOperand(kZoomHeroMarkerRight, kZoomHeroMarkerRightF, &g_zoomHeroMarkerRight);
|
||||
failed += re::PatchAll(kBandRightOuter, FloatBits(1024.0f), FloatBits(static_cast<float>(width)));
|
||||
failed += re::PatchAll(kBandRightInner, FloatBits(1024.0f - kBandWidth),
|
||||
FloatBits(static_cast<float>(width - kBandWidth)));
|
||||
@@ -196,6 +264,13 @@ void __cdecl SetZoomCentre() {
|
||||
const int width = std::max(mods::widescreen::RenderWidth(), kLayoutWidth);
|
||||
g_zoomCentre = width / 2.0f - static_cast<float>(mods::hud::DrawOffsetX());
|
||||
g_zoomMarkerX = g_zoomCentre + kZoomMarkerFromCentre;
|
||||
g_zoomHeroMarkerLeft = g_zoomCentre - kZoomHeroMarkerHalf;
|
||||
g_zoomHeroMarkerRight = g_zoomCentre + kZoomHeroMarkerHalf;
|
||||
const auto immediate = static_cast<uint32_t>(width / 2 - mods::hud::DrawOffsetX());
|
||||
if (immediate != g_zoomCentreImmediate &&
|
||||
re::PatchAll(kZoomCentreImmediates, g_zoomCentreImmediate, immediate) == 0) {
|
||||
g_zoomCentreImmediate = immediate;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace mods::megamap
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Preferred monitor (request of 2026-10-25): a setting in the graphics options
|
||||
// picks the monitor the game starts on; with none set, or the preferred one
|
||||
// not connected, the system's main monitor.
|
||||
//
|
||||
// The game window and its render size are decided when the display mode is
|
||||
// set at startup (borderless.cpp, widescreen.cpp ApplyRenderSize): both ask
|
||||
// GameMonitorRect. A change applies at the next start.
|
||||
//
|
||||
// Monitors are told apart by their device path (DisplayConfigGetDeviceInfo,
|
||||
// target name: "\\?\DISPLAY#GSM5B7F#..."), which survives restarts, monitor
|
||||
// order changes and other ports' changes, unlike the GDI names \\.\DISPLAYn.
|
||||
//
|
||||
// sacredK.ini:
|
||||
// [display]
|
||||
// Monitor=<device path> ; empty: the main monitor
|
||||
|
||||
#include "mods/monitor.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "re/config.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::monitor {
|
||||
namespace {
|
||||
|
||||
constexpr const char* kSection = "display";
|
||||
constexpr const char* kKey = "Monitor";
|
||||
|
||||
std::string Utf8(const wchar_t* text) {
|
||||
const int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr);
|
||||
if (size <= 1) {
|
||||
return {};
|
||||
}
|
||||
std::string result(static_cast<size_t>(size - 1), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, text, -1, result.data(), size, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
struct Target {
|
||||
std::wstring gdiName; // \\.\DISPLAYn of the source showing it
|
||||
std::string id;
|
||||
std::wstring name;
|
||||
};
|
||||
|
||||
// The active display paths: which monitor shows which GDI display.
|
||||
std::vector<Target> Targets() {
|
||||
UINT32 pathCount = 0;
|
||||
UINT32 modeCount = 0;
|
||||
std::vector<DISPLAYCONFIG_PATH_INFO> paths;
|
||||
std::vector<DISPLAYCONFIG_MODE_INFO> modes;
|
||||
LONG result;
|
||||
do {
|
||||
if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount) != ERROR_SUCCESS) {
|
||||
return {};
|
||||
}
|
||||
paths.resize(pathCount);
|
||||
modes.resize(modeCount);
|
||||
result = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &pathCount, paths.data(), &modeCount, modes.data(),
|
||||
nullptr);
|
||||
} while (result == ERROR_INSUFFICIENT_BUFFER);
|
||||
if (result != ERROR_SUCCESS) {
|
||||
return {};
|
||||
}
|
||||
paths.resize(pathCount);
|
||||
std::vector<Target> targets;
|
||||
for (const DISPLAYCONFIG_PATH_INFO& path : paths) {
|
||||
DISPLAYCONFIG_SOURCE_DEVICE_NAME source = {};
|
||||
source.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
|
||||
source.header.size = sizeof(source);
|
||||
source.header.adapterId = path.sourceInfo.adapterId;
|
||||
source.header.id = path.sourceInfo.id;
|
||||
DISPLAYCONFIG_TARGET_DEVICE_NAME target = {};
|
||||
target.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
|
||||
target.header.size = sizeof(target);
|
||||
target.header.adapterId = path.targetInfo.adapterId;
|
||||
target.header.id = path.targetInfo.id;
|
||||
if (DisplayConfigGetDeviceInfo(&source.header) != ERROR_SUCCESS ||
|
||||
DisplayConfigGetDeviceInfo(&target.header) != ERROR_SUCCESS) {
|
||||
continue;
|
||||
}
|
||||
targets.push_back({source.viewGdiDeviceName, Utf8(target.monitorDevicePath), target.monitorFriendlyDeviceName});
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
BOOL CALLBACK AddMonitor(HMONITOR handle, HDC, LPRECT, LPARAM data) {
|
||||
auto* gdi = reinterpret_cast<std::vector<MONITORINFOEXW>*>(data);
|
||||
MONITORINFOEXW info = {};
|
||||
info.cbSize = sizeof(info);
|
||||
if (GetMonitorInfoW(handle, &info)) {
|
||||
gdi->push_back(info);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<Monitor> List() {
|
||||
std::vector<MONITORINFOEXW> gdi;
|
||||
EnumDisplayMonitors(nullptr, nullptr, &AddMonitor, reinterpret_cast<LPARAM>(&gdi));
|
||||
const std::vector<Target> targets = Targets();
|
||||
std::vector<Monitor> monitors;
|
||||
for (const MONITORINFOEXW& info : gdi) {
|
||||
Monitor monitor{"", L"", info.rcMonitor, (info.dwFlags & MONITORINFOF_PRIMARY) != 0};
|
||||
for (const Target& target : targets) {
|
||||
if (target.gdiName == info.szDevice) { // mirrored displays: the first monitor
|
||||
monitor.id = target.id;
|
||||
monitor.name = target.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (monitor.id.empty()) {
|
||||
monitor.id = Utf8(info.szDevice); // no display config (remote desktop, ...)
|
||||
}
|
||||
if (monitor.name.empty()) {
|
||||
monitor.name = L"Monitor " + std::to_wstring(monitors.size() + 1);
|
||||
}
|
||||
monitors.push_back(monitor);
|
||||
}
|
||||
std::stable_sort(monitors.begin(), monitors.end(),
|
||||
[](const Monitor& a, const Monitor& b) { return a.primary && !b.primary; });
|
||||
return monitors;
|
||||
}
|
||||
|
||||
std::string PreferredId() {
|
||||
return re::ConfigString(kSection, kKey, "");
|
||||
}
|
||||
|
||||
void SetPreferredId(const std::string& id) {
|
||||
re::SetConfigString(kSection, kKey, id.c_str());
|
||||
}
|
||||
|
||||
RECT GameMonitorRect() {
|
||||
const std::vector<Monitor> monitors = List();
|
||||
const std::string preferred = PreferredId();
|
||||
const Monitor* chosen = nullptr;
|
||||
for (const Monitor& monitor : monitors) {
|
||||
if ((!preferred.empty() && monitor.id == preferred) || (!chosen && monitor.primary)) {
|
||||
chosen = &monitor;
|
||||
}
|
||||
}
|
||||
if (!chosen) {
|
||||
// No monitor reported: the main monitor's rectangle as Windows has it.
|
||||
MONITORINFO info = {sizeof(info)};
|
||||
GetMonitorInfoW(MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY), &info);
|
||||
return info.rcMonitor;
|
||||
}
|
||||
const bool found = !preferred.empty() && chosen->id == preferred;
|
||||
static bool logged = false;
|
||||
if (!logged) {
|
||||
logged = true;
|
||||
re::Log("monitor: %ls %ldx%ld at (%ld,%ld)%s", chosen->name.c_str(), chosen->rect.right - chosen->rect.left,
|
||||
chosen->rect.bottom - chosen->rect.top, chosen->rect.left, chosen->rect.top,
|
||||
found ? "" : preferred.empty() ? ", main monitor" : ", preferred monitor not connected: main monitor");
|
||||
}
|
||||
return chosen->rect;
|
||||
}
|
||||
|
||||
} // namespace mods::monitor
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
// The monitor the game runs on (request of 2026-10-25): a preferred monitor
|
||||
// from the options, or the system's main monitor when none is set or the
|
||||
// preferred one is not connected. See monitor.cpp.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace mods::monitor {
|
||||
|
||||
struct Monitor {
|
||||
std::string id; // stable across restarts and reordering: the monitor's device path
|
||||
std::wstring name; // the monitor's own name (EDID), e.g. "LG ULTRAGEAR"
|
||||
RECT rect; // desktop coordinates
|
||||
bool primary;
|
||||
};
|
||||
|
||||
// The connected monitors, the main monitor first.
|
||||
std::vector<Monitor> List();
|
||||
|
||||
// The preferred monitor's id from sacredK.ini, empty for none.
|
||||
std::string PreferredId();
|
||||
void SetPreferredId(const std::string& id);
|
||||
|
||||
// The rectangle of the monitor to run on: the preferred one if connected,
|
||||
// else the main monitor.
|
||||
RECT GameMonitorRect();
|
||||
|
||||
} // namespace mods::monitor
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// The quest book as one list: the story, then the side quests by area,
|
||||
// unknown ones as "???" (requests of 2026-10-25: a player sees how many
|
||||
// quests an area holds without being told where they are; then "get rid of
|
||||
// the tabs ... a section for the story and a section per region", the
|
||||
// game's page turning kept: "pagination is already there anyways").
|
||||
//
|
||||
// Measured (docs/quests.md, "Quest book"): the book's quest mode had up to
|
||||
// seven act tabs (bookmarks I, II, ...), an entry's tab being its chapter
|
||||
// (+0x16c), copied from the current main quest when the quest is accepted
|
||||
// (0x46c450); the game knows no area for a quest. The side quests' ids come
|
||||
// in blocks of 100 (1xx .. 25xx) whose accepted quests lie together on the
|
||||
// map: the areas. The other side quests (5xxx, 6xxx, 9xxx, 102xx, 15xxx)
|
||||
// carry no area in their id; once known they join the area nearest to them.
|
||||
//
|
||||
// The book (cUI_Book, one per book mode): the left page shows the current
|
||||
// page's rows, the right page the selected row's detail record, both drawn by
|
||||
// 0x6b27f0 from the same page; a click (0x6b3620) selects the row under the
|
||||
// mouse (page +4).
|
||||
//
|
||||
// The mod: the game's list builder (0x6b04c0) runs on a reordered copy of the
|
||||
// book with our rows (headings, "???") as extra entries, all in the first tab
|
||||
// (the game's page breaks and page turning stay); afterwards each row's entry
|
||||
// index is mapped back to the real book, ours to none (a click on them does
|
||||
// nothing, 0x6b3620 checks the index). The bookmarks are hidden, every page is
|
||||
// titled "Quests" and selects its first quest. Fonts, icons and the detail
|
||||
// page stay the game's.
|
||||
//
|
||||
// "Erledigte anzeigen" (request of 2026-10-25: "some kind of 'Show completed'
|
||||
// checkmark. If unchecked, completed quests are not in the list (as well as
|
||||
// completed regions)"): the first row, its icon the solved mark when on, none
|
||||
// when off; a click toggles it (sacredK.ini [questbook] ShowCompleted) and
|
||||
// rebuilds the book. Off leaves out solved quests and the areas whose quests
|
||||
// are all known and solved; failed quests stay (they can be restarted).
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/engine/quests.h"
|
||||
#include "game/engine/regions.h"
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "re/config.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::questbook {
|
||||
namespace {
|
||||
|
||||
constexpr int kAreaQuestFirst = 100; // ids 100 .. 2599: area = id / 100
|
||||
constexpr int kAreaQuestEnd = 2600; // 2600 .. 3599: generic quests, cloned by Quest_End
|
||||
constexpr int kGenericQuestEnd = 3600;
|
||||
constexpr int kAreaSize = 100;
|
||||
|
||||
// cUI_Book: the book mode (+0x166, 0 = quests), current tab (+0x15e) and page
|
||||
// (+0x162); per tab a vector of pages (+0x97c + tab * 0xc) and of detail
|
||||
// records (+0x9d0 + tab * 0xc). A page (0x1c bytes): title text +0, selected row +4 (u16), a vector
|
||||
// of rows +0x10. A row (0x14 bytes): u16 flags (4 = spacer), title text +4,
|
||||
// detail record +8, book entry +0xc. The bookmarks: 7 controls at +0x168.
|
||||
constexpr size_t kBookMode = 0x166;
|
||||
constexpr uint16_t kModeQuests = 0;
|
||||
constexpr size_t kBookTab = 0x15e;
|
||||
constexpr size_t kBookPage = 0x162;
|
||||
constexpr size_t kBookPages = 0x97c;
|
||||
constexpr size_t kTabStride = 0xc;
|
||||
constexpr size_t kPageSize = 0x1c;
|
||||
constexpr size_t kPageTitle = 0x0;
|
||||
constexpr size_t kPageSelected = 0x4;
|
||||
constexpr size_t kPageRows = 0x10;
|
||||
constexpr size_t kRowSize = 0x14;
|
||||
constexpr size_t kRowEntry = 0xc;
|
||||
constexpr uint16_t kRowSpacer = 0x4;
|
||||
constexpr size_t kBookmarks = 0x168;
|
||||
constexpr size_t kBookmarkStride = 0xbc;
|
||||
constexpr int kTabs = 7;
|
||||
constexpr uint32_t kNoEntry = 0xffffffff;
|
||||
constexpr uint32_t kToggleEntry = 0xfffffffe; // the "Erledigte anzeigen" row
|
||||
constexpr uint16_t kNoRow = 0xffff;
|
||||
|
||||
constexpr const char* kConfigSection = "questbook";
|
||||
constexpr const char* kConfigShowCompleted = "ShowCompleted";
|
||||
|
||||
// The setting, read from the ini on first use.
|
||||
bool& ShowCompleted() {
|
||||
static bool show = re::ConfigInt(kConfigSection, kConfigShowCompleted, 1) != 0;
|
||||
return show;
|
||||
}
|
||||
|
||||
using Entry = std::array<uint8_t, game::kBookEntrySize>;
|
||||
|
||||
template <typename T>
|
||||
T& Field(uint8_t* p, size_t offset) {
|
||||
return *reinterpret_cast<T*>(p + offset);
|
||||
}
|
||||
|
||||
// Our texts, registered once each (RegisterText keeps the pointer).
|
||||
uint32_t TextId(const std::wstring& text) {
|
||||
static std::map<std::wstring, uint32_t> ids;
|
||||
static std::deque<std::wstring> storage;
|
||||
auto found = ids.find(text);
|
||||
if (found != ids.end()) {
|
||||
return found->second;
|
||||
}
|
||||
storage.push_back(text);
|
||||
return ids[text] = game::RegisterText(storage.back().c_str());
|
||||
}
|
||||
|
||||
std::wstring Format(const wchar_t* format, int a, int b = 0, int c = 0) {
|
||||
wchar_t text[128];
|
||||
swprintf(text, 128, format, a, b, c);
|
||||
return text;
|
||||
}
|
||||
|
||||
// --- the list ------------------------------------------------------------------
|
||||
|
||||
// The quest table's side quests that count, by id: their area (0 = none by
|
||||
// id). Not main quests, and not the ones whose name occurs more than once
|
||||
// (generic quests and their clones, script bookkeeping such as "Region 1
|
||||
// befreit").
|
||||
std::map<int32_t, int> CountedQuests() {
|
||||
std::map<int32_t, int> result;
|
||||
std::map<std::string, int> names;
|
||||
for (size_t i = 0, n = game::QuestCount(); i < n; ++i) {
|
||||
const char* name = game::QuestName(game::Quest(static_cast<int>(i)));
|
||||
if (*name) {
|
||||
++names[name];
|
||||
}
|
||||
}
|
||||
for (size_t i = 0, n = game::QuestCount(); i < n; ++i) {
|
||||
uint8_t* quest = game::Quest(static_cast<int>(i));
|
||||
const int32_t id = game::QuestId(quest);
|
||||
const char* name = game::QuestName(quest);
|
||||
if (id <= game::kMainQuestLast || (*name && names[name] > 1) || (id >= kAreaQuestEnd && id < kGenericQuestEnd)) {
|
||||
continue;
|
||||
}
|
||||
result[id] = id >= kAreaQuestFirst && id < kAreaQuestEnd ? id / kAreaSize : 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
struct Area {
|
||||
int total = 0;
|
||||
std::vector<int32_t> known; // quest ids, in book order
|
||||
double x = 0, y = 0;
|
||||
int positions = 0;
|
||||
std::map<uint8_t, int> regions; // region number at its quests: count
|
||||
};
|
||||
|
||||
// The area's name: the game's name of the region most of its quests lie in;
|
||||
// else "Gebiet N".
|
||||
std::wstring AreaName(int number, const Area& area) {
|
||||
uint8_t region = 0;
|
||||
int most = 0;
|
||||
for (const auto& [r, n] : area.regions) {
|
||||
if (n > most) {
|
||||
region = r;
|
||||
most = n;
|
||||
}
|
||||
}
|
||||
const char* key = game::RegionNameKey(region);
|
||||
std::wstring name = key ? game::GameTextByKey(key) : L"";
|
||||
return name.empty() ? Format(L"Gebiet %d", number) : name;
|
||||
}
|
||||
|
||||
// The reordered book: entries (copies of the real ones, or ours), all in the
|
||||
// first tab, and for each the real entry index, kNoEntry for ours.
|
||||
struct Order {
|
||||
std::vector<Entry> entries;
|
||||
std::vector<uint32_t> real;
|
||||
|
||||
void Add(const Entry& entry, uint32_t realIndex) {
|
||||
entries.push_back(entry);
|
||||
Field<uint16_t>(entries.back().data(), game::kBookChapter) = 0;
|
||||
real.push_back(realIndex);
|
||||
}
|
||||
void AddReal(uint32_t index) {
|
||||
Entry entry;
|
||||
std::memcpy(entry.data(), game::BookEntry(index), entry.size());
|
||||
Add(entry, index);
|
||||
}
|
||||
void AddOwn(int32_t kind, const std::wstring& title, int32_t state = 0, uint32_t marker = kNoEntry) {
|
||||
Entry entry = {};
|
||||
Field<int32_t>(entry.data(), game::kBookKind) = kind;
|
||||
Field<int32_t>(entry.data(), game::kBookState) = state;
|
||||
Field<uint32_t>(entry.data(), game::kBookTitle) = TextId(title);
|
||||
Field<uint32_t>(entry.data(), game::kBookText) = TextId(L"");
|
||||
Add(entry, marker);
|
||||
}
|
||||
// A heading: a main-quest-style row; the game puts a spacer before a main
|
||||
// quest row only after a side quest row, so after a main quest an empty
|
||||
// side row goes first.
|
||||
void AddHeading(const std::wstring& title) {
|
||||
if (!entries.empty() && Field<int32_t>(entries.back().data(), game::kBookKind) == game::kBookMain) {
|
||||
AddOwn(game::kBookSide, L" ");
|
||||
}
|
||||
AddOwn(game::kBookMain, title);
|
||||
}
|
||||
};
|
||||
|
||||
Order BuildOrder() {
|
||||
const std::map<int32_t, int> counted = CountedQuests();
|
||||
const size_t count = game::BookCount();
|
||||
std::map<int, Area> areas;
|
||||
for (const auto& [id, area] : counted) {
|
||||
if (area) {
|
||||
++areas[area].total;
|
||||
}
|
||||
}
|
||||
|
||||
// Known side quests, in book order, with their area.
|
||||
std::map<int32_t, int> questArea;
|
||||
std::vector<int32_t> known;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
const int32_t id = game::BookQuestId(entry);
|
||||
if (game::BookKind(entry) == game::kBookMain || questArea.count(id)) {
|
||||
continue;
|
||||
}
|
||||
const auto found = counted.find(id);
|
||||
const int area = found != counted.end() ? found->second : 0;
|
||||
questArea[id] = area;
|
||||
known.push_back(id);
|
||||
if (area) {
|
||||
Area& a = areas[area];
|
||||
a.known.push_back(id);
|
||||
const int32_t x = Field<int32_t>(entry, game::kBookX);
|
||||
const int32_t y = Field<int32_t>(entry, game::kBookY);
|
||||
if (x || y) {
|
||||
a.x += x;
|
||||
a.y += y;
|
||||
++a.positions;
|
||||
++a.regions[game::RegionAtCell(x, y)];
|
||||
}
|
||||
}
|
||||
}
|
||||
// The other known side quests join the area nearest to them (by its known
|
||||
// quests' mean position).
|
||||
int unknownOthers = 0;
|
||||
for (const auto& [id, area] : counted) {
|
||||
unknownOthers += !area && !questArea.count(id);
|
||||
}
|
||||
for (int32_t id : known) {
|
||||
if (questArea[id] || !counted.count(id)) {
|
||||
continue;
|
||||
}
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookQuestId(entry) != id) {
|
||||
continue;
|
||||
}
|
||||
const double x = Field<int32_t>(entry, game::kBookX);
|
||||
const double y = Field<int32_t>(entry, game::kBookY);
|
||||
double bestDistance = 0;
|
||||
int best = 0;
|
||||
for (const auto& [number, a] : areas) {
|
||||
if (!a.positions || (x == 0 && y == 0)) {
|
||||
continue;
|
||||
}
|
||||
const double dx = x - a.x / a.positions;
|
||||
const double dy = y - a.y / a.positions;
|
||||
const double distance = dx * dx + dy * dy;
|
||||
if (!best || distance < bestDistance) {
|
||||
best = number;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
questArea[id] = best;
|
||||
++areas[best].total;
|
||||
areas[best].known.push_back(id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bool showCompleted = ShowCompleted();
|
||||
auto shown = [&](uint8_t* entry) { return showCompleted || game::BookState(entry) != game::kBookSolved; };
|
||||
auto hidden = [&](int32_t id) { // no entry of quest `id` in the list
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookQuestId(entry) == id && game::BookKind(entry) != game::kBookMain && shown(entry)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// The solved icon when on (the game draws state 100 so), no icon when off.
|
||||
Order order;
|
||||
order.AddOwn(game::kBookSide, L"Erledigte anzeigen", showCompleted ? game::kBookSolved : 0, kToggleEntry);
|
||||
auto addQuest = [&](int32_t id) {
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookQuestId(entry) == id && game::BookKind(entry) != game::kBookMain && shown(entry)) {
|
||||
order.AddReal(static_cast<uint32_t>(i));
|
||||
}
|
||||
}
|
||||
};
|
||||
order.AddHeading(L"Story");
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookKind(entry) == game::kBookMain && shown(entry)) {
|
||||
order.AddReal(static_cast<uint32_t>(i));
|
||||
}
|
||||
}
|
||||
// Areas with the same name (several id blocks lie in one region) make one
|
||||
// section, in the order of their first area.
|
||||
int undiscovered = 0;
|
||||
std::vector<std::wstring> sectionNames;
|
||||
std::map<std::wstring, Area> sections;
|
||||
for (const auto& [number, a] : areas) {
|
||||
if (a.known.empty()) {
|
||||
undiscovered += a.total > 0;
|
||||
continue;
|
||||
}
|
||||
const std::wstring name = AreaName(number, a);
|
||||
if (!sections.count(name)) {
|
||||
sectionNames.push_back(name);
|
||||
}
|
||||
Area& section = sections[name];
|
||||
section.total += a.total;
|
||||
section.known.insert(section.known.end(), a.known.begin(), a.known.end());
|
||||
}
|
||||
for (const std::wstring& name : sectionNames) {
|
||||
const Area& a = sections[name];
|
||||
if (!showCompleted && static_cast<int>(a.known.size()) >= a.total &&
|
||||
std::all_of(a.known.begin(), a.known.end(), hidden)) {
|
||||
continue;
|
||||
}
|
||||
const int knownCount = static_cast<int>(a.known.size());
|
||||
order.AddHeading(name + Format(L" (%d/%d)", knownCount, a.total));
|
||||
for (int32_t id : a.known) {
|
||||
addQuest(id);
|
||||
}
|
||||
for (int i = knownCount; i < a.total; ++i) {
|
||||
order.AddOwn(game::kBookSide, L"???");
|
||||
}
|
||||
}
|
||||
bool others = false;
|
||||
for (int32_t id : known) {
|
||||
if (!questArea[id] && !hidden(id)) {
|
||||
if (!others) {
|
||||
order.AddHeading(L"Weitere Nebenquests");
|
||||
others = true;
|
||||
}
|
||||
addQuest(id);
|
||||
}
|
||||
}
|
||||
if (undiscovered || unknownOthers) {
|
||||
order.AddHeading(L"Unentdeckt");
|
||||
if (undiscovered) {
|
||||
order.AddOwn(game::kBookSide, Format(undiscovered == 1 ? L"%d Gebiet" : L"%d Gebiete", undiscovered));
|
||||
}
|
||||
if (unknownOthers) {
|
||||
order.AddOwn(game::kBookSide, Format(unknownOthers == 1 ? L"??? (%d weitere Nebenquest)"
|
||||
: L"??? (%d weitere Nebenquests)",
|
||||
unknownOthers));
|
||||
}
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
// --- the book's pages ------------------------------------------------------------
|
||||
|
||||
uint8_t* CurrentPage(uint8_t* book) {
|
||||
const size_t tab = Field<uint16_t>(book, kBookTab);
|
||||
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
|
||||
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
|
||||
uint8_t* page = pages + Field<uint16_t>(book, kBookPage) * kPageSize;
|
||||
return pages && page < pagesEnd ? page : nullptr;
|
||||
}
|
||||
|
||||
uint8_t* RowsBegin(uint8_t* page) { return Field<uint8_t*>(page, kPageRows); }
|
||||
uint8_t* RowsEnd(uint8_t* page) { return Field<uint8_t*>(page, kPageRows + 4); }
|
||||
|
||||
// The row's book entry; kNoEntry for spacers and rows past the end.
|
||||
uint32_t RowEntry(uint8_t* page, uint16_t row) {
|
||||
uint8_t* r = RowsBegin(page) + row * kRowSize;
|
||||
return r < RowsEnd(page) && (Field<uint16_t>(r, 0) & kRowSpacer) == 0 ? Field<uint32_t>(r, kRowEntry) : kNoEntry;
|
||||
}
|
||||
|
||||
bool IsQuestRow(uint8_t* page, uint16_t row) { return RowEntry(page, row) < kToggleEntry; }
|
||||
|
||||
void SelectFirstQuest(uint8_t* page) {
|
||||
uint16_t& selected = Field<uint16_t>(page, kPageSelected);
|
||||
if (IsQuestRow(page, selected)) {
|
||||
return;
|
||||
}
|
||||
for (uint16_t row = 0; RowsBegin(page) + row * kRowSize < RowsEnd(page); ++row) {
|
||||
if (IsQuestRow(page, row)) {
|
||||
selected = row;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cUI_Book::Rebuild (0x6b1670, thiscall, no arguments): rebuilds the current
|
||||
// mode's pages, keeping tab and page if the page still exists.
|
||||
void RebuildBook(uint8_t* book) {
|
||||
reinterpret_cast<void(__fastcall*)(uint8_t*, void*)>(0x006b1670)(book, nullptr);
|
||||
const size_t tab = Field<uint16_t>(book, kBookTab);
|
||||
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
|
||||
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
|
||||
if (!CurrentPage(book) && pages < pagesEnd) {
|
||||
Field<uint16_t>(book, kBookPage) = static_cast<uint16_t>((pagesEnd - pages) / kPageSize - 1);
|
||||
}
|
||||
if (uint8_t* page = CurrentPage(book)) {
|
||||
SelectFirstQuest(page);
|
||||
}
|
||||
}
|
||||
|
||||
// Rows point into the reordered book; point them at the real entries. Every
|
||||
// page titled "Quests", its first quest selected.
|
||||
void FixPages(uint8_t* book, const Order& order) {
|
||||
for (int tab = 0; tab < kTabs; ++tab) {
|
||||
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
|
||||
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
|
||||
for (uint8_t* page = pages; page && page < pagesEnd; page += kPageSize) {
|
||||
for (uint8_t* row = RowsBegin(page); row && row < RowsEnd(page); row += kRowSize) {
|
||||
uint32_t& index = Field<uint32_t>(row, kRowEntry);
|
||||
if ((Field<uint16_t>(row, 0) & kRowSpacer) == 0 && index < order.real.size()) {
|
||||
index = order.real[index];
|
||||
}
|
||||
}
|
||||
if (tab != 0) {
|
||||
continue;
|
||||
}
|
||||
Field<uint32_t>(page, kPageTitle) = TextId(L"Quests");
|
||||
SelectFirstQuest(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::questbook
|
||||
|
||||
using namespace mods::questbook;
|
||||
|
||||
// cUI_Book's quest list (0x6b04c0, thiscall, no arguments; from the book's
|
||||
// rebuild 0x6b1670 in quest mode, on the same thread as the quest scripts).
|
||||
void __fastcall Book_BuildQuestList(uint8_t* book, void* edx);
|
||||
RE_HOOK(0x006b04c0, Book_BuildQuestList);
|
||||
void __fastcall Book_BuildQuestList(uint8_t* book, void* edx) {
|
||||
Order order = BuildOrder();
|
||||
uint8_t* const begin = game::BookBegin();
|
||||
uint8_t* const end = game::BookEnd();
|
||||
uint8_t* const capacity = game::BookCapacityEnd();
|
||||
uint32_t& selected = game::BookSelected(game::LocalPlayerSlot());
|
||||
const uint32_t realSelected = selected;
|
||||
uint32_t reorderedSelected = static_cast<uint32_t>(order.entries.size());
|
||||
for (size_t i = 0; i < order.real.size(); ++i) {
|
||||
if (order.real[i] == realSelected) {
|
||||
reorderedSelected = static_cast<uint32_t>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
auto* copy = reinterpret_cast<uint8_t*>(order.entries.data());
|
||||
game::BookBegin() = copy;
|
||||
game::BookEnd() = game::BookCapacityEnd() = copy + order.entries.size() * game::kBookEntrySize;
|
||||
selected = reorderedSelected;
|
||||
Book_BuildQuestList_original(book, edx);
|
||||
game::BookBegin() = begin;
|
||||
game::BookEnd() = end;
|
||||
game::BookCapacityEnd() = capacity;
|
||||
selected = realSelected;
|
||||
|
||||
FixPages(book, order);
|
||||
for (int tab = 0; tab < kTabs; ++tab) {
|
||||
game::SetControlShown(book + kBookmarks + tab * kBookmarkStride, false);
|
||||
}
|
||||
}
|
||||
|
||||
// cUI_Book's event handler (0x6b3620, thiscall (event), ret 4): a click on a
|
||||
// heading or "???" row keeps the selection, one on "Erledigte anzeigen"
|
||||
// toggles it.
|
||||
bool __fastcall Book_HandleEvent(uint8_t* book, void* edx, void* event);
|
||||
RE_HOOK(0x006b3620, Book_HandleEvent);
|
||||
bool __fastcall Book_HandleEvent(uint8_t* book, void* edx, void* event) {
|
||||
uint8_t* page = Field<uint16_t>(book, kBookMode) == kModeQuests ? CurrentPage(book) : nullptr;
|
||||
const uint16_t selected = page ? Field<uint16_t>(page, kPageSelected) : kNoRow;
|
||||
const bool handled = Book_HandleEvent_original(book, edx, event);
|
||||
if (page && page == CurrentPage(book) && !IsQuestRow(page, Field<uint16_t>(page, kPageSelected))) {
|
||||
const bool toggle = RowEntry(page, Field<uint16_t>(page, kPageSelected)) == kToggleEntry;
|
||||
Field<uint16_t>(page, kPageSelected) = selected;
|
||||
if (toggle) {
|
||||
ShowCompleted() = !ShowCompleted();
|
||||
re::SetConfigString(kConfigSection, kConfigShowCompleted, ShowCompleted() ? "1" : "0");
|
||||
RebuildBook(book);
|
||||
}
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
// Failed quests can be restarted (request of 2026-10-17): a failed quest is not
|
||||
// final, the quest giver offers it again. Its marker points back to the quest
|
||||
// giver, drawn blue like an open side quest (request of 2026-10-25: "quests
|
||||
// that failed, should have the blue marker point back to the quest giver").
|
||||
//
|
||||
// Measured (docs/quests.md): a failed quest keeps flags 3 (triggered + done) in
|
||||
// the quest table and its quest book entry gets state 101; Quest_Trigger
|
||||
// (0x46de10) does nothing for a quest with flag 1, so the giver never offers it
|
||||
// again. The game itself repeats only the generic quests (ids 2600..3599,
|
||||
// Quest_End copies them under a new id). The maps draw a marker for every book
|
||||
// entry at its quest compass cell (+0x18/+0x1c), coloured by 0x4975d0: blue
|
||||
// for open side quests, red for failed ones. The game does not keep the giver:
|
||||
// a quest is accepted in a dialog answer (cDialogManager::Answer 0x49a090 ->
|
||||
// quest script -> book entry 0x46c450), so the giver is the NPC next to the
|
||||
// hero then.
|
||||
//
|
||||
// The mod: when a quest is accepted in a dialog, the nearest NPC's map cell is
|
||||
// kept as the quest's giver (saved with the hero data, "questGivers"; quest
|
||||
// ids are the same in every world, and so are the givers). After Quest_End
|
||||
// failed a quest (its failure script has run), the quest's flags are reset to
|
||||
// available (not accepted, not done); its book entry stays as failed with the
|
||||
// compass on the giver, or is removed if the giver is not known. Accepting it
|
||||
// again removes the failed entry. Quests that failed before the mod are reset
|
||||
// the same way once after loading.
|
||||
//
|
||||
// Quest audit (2026-10-30, docs/quest-audit.md): a restart also puts back what
|
||||
// the quest's script needs to be offered again (questscript.cpp): its variables
|
||||
// as they were when it became available (kept at Quest_Trigger, saved with the
|
||||
// hero; for quests triggered earlier: 1 for the one named after the quest, else
|
||||
// 0), the giver's accept dialog, the giver and the NPCs it needs from their
|
||||
// spawn instructions; what its QIS_OnEnter created is deleted (accepting again
|
||||
// creates it anew). A quest stays failed when the game repeats it itself
|
||||
// (generic quests), when it has no giver (trigger areas), when its failure ends
|
||||
// the story (2402 sets up 2403, 5003's shaman turns hostile, ...) or when an NPC
|
||||
// it needs is gone for good.
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/engine/quests.h"
|
||||
#include "mods/inventory_save.h"
|
||||
#include "mods/questfail.h"
|
||||
#include "mods/questscript.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::questfail {
|
||||
namespace {
|
||||
|
||||
// A restarted quest stays available (kQuestTriggered): its labels, the giver's
|
||||
// dialog too, run only while it is set (Script_RunLabel 0x46bf11; measured
|
||||
// 2026-10-26, "Dialog faild" without it), and so does accepting it (script
|
||||
// opcode 0x14, 0x48f320). The game sets it when the hero enters the quest's
|
||||
// region (Quest_Trigger 0x46de10).
|
||||
constexpr uint32_t kResetFlags = game::kQuestDone | game::kQuestInProgress | game::kQuestBusy;
|
||||
|
||||
// The giver is the nearest NPC within this distance of the hero when the quest
|
||||
// is accepted (world units; a talk starts at contact distance, about 100).
|
||||
constexpr uint32_t kGiverMaxDistance = 400;
|
||||
|
||||
// Marker colours of 0x4975d0 for an open side quest (state 2): kind 0 the
|
||||
// marker, kind 1 the second colour (white).
|
||||
constexpr uint32_t kOpenSideQuestColour = 0xff006cff;
|
||||
constexpr uint32_t kOpenQuestColour2 = 0xffffffff;
|
||||
|
||||
bool g_resetAfterLoad = false;
|
||||
bool g_inDialogAnswer = false;
|
||||
|
||||
// Quest id -> the giver's map cell.
|
||||
std::map<int32_t, std::pair<int32_t, int32_t>> g_giverCells;
|
||||
|
||||
// Quest id -> its variables when it became available.
|
||||
std::map<int32_t, std::map<std::string, int32_t>> g_freshVariables;
|
||||
|
||||
// The nearest living, not hostile, non-hero creature near the hero, or 0. All
|
||||
// objects are searched (the nearby-creature list 0xad1c60 did not hold the
|
||||
// givers).
|
||||
uint32_t NearestNpc(void* hero) {
|
||||
const int32_t heroX = game::CreatureField(hero, game::kCreatureX);
|
||||
const int32_t heroY = game::CreatureField(hero, game::kCreatureY);
|
||||
uint32_t best = 0;
|
||||
uint32_t bestDistance = kGiverMaxDistance + 1;
|
||||
for (uint32_t id = game::kPlayerHeroIdEnd, end = game::ObjectIdEnd(); id < end; ++id) {
|
||||
void* creature = game::GetCreature(id);
|
||||
if (!creature) {
|
||||
continue;
|
||||
}
|
||||
const int32_t dx = game::CreatureField(creature, game::kCreatureX) - heroX;
|
||||
const int32_t dy = game::CreatureField(creature, game::kCreatureY) - heroY;
|
||||
if (dx < -1000 || dx > 1000 || dy < -1000 || dy > 1000) {
|
||||
continue;
|
||||
}
|
||||
const uint32_t distance = game::DistanceTo(hero, id);
|
||||
const bool alive = game::IsAlive(creature);
|
||||
const bool hostile = game::IsHostileTo(hero, creature);
|
||||
if (alive && !hostile && distance < bestDistance) {
|
||||
best = id;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
void RememberGiver(int32_t questId) {
|
||||
void* hero = game::GetPlayerHero();
|
||||
const uint32_t npc = hero ? NearestNpc(hero) : 0;
|
||||
void* creature = npc ? game::GetCreature(npc) : nullptr;
|
||||
if (!creature) {
|
||||
re::Log("questfail: quest %d accepted, no giver near the hero", questId);
|
||||
return;
|
||||
}
|
||||
const auto* position = reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(creature) +
|
||||
game::kObjectPosition);
|
||||
const game::MapPosition cell = game::CellAt(creature, position->x, position->y);
|
||||
g_giverCells[questId] = {cell.x, cell.y};
|
||||
re::Log("questfail: quest %d accepted from creature %u at cell %d,%d", questId, npc, cell.x, cell.y);
|
||||
}
|
||||
|
||||
// The entry's compass on its quest's giver (a cell target, no radius). False if
|
||||
// the giver is not known.
|
||||
bool PointAtGiver(uint8_t* entry) {
|
||||
const auto giver = g_giverCells.find(game::BookQuestId(entry));
|
||||
if (giver == g_giverCells.end()) {
|
||||
return false;
|
||||
}
|
||||
auto field = [entry](size_t offset) -> int32_t& { return *reinterpret_cast<int32_t*>(entry + offset); };
|
||||
field(game::kBookCompassX) = field(game::kBookX) = giver->second.first;
|
||||
field(game::kBookCompassY) = field(game::kBookY) = giver->second.second;
|
||||
field(game::kBookCompassRadius) = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The variable named after the quest keeps bit 0: "available", which the
|
||||
// giver's offer tests (auftrag402: IsVarBit 402 0; measured 2026-10-28: all 87
|
||||
// quests not yet accepted had value 1).
|
||||
int32_t FreshValue(int32_t questId, const std::string& name) {
|
||||
const int32_t available = name == std::to_string(questId) ? 1 : 0;
|
||||
const auto quest = g_freshVariables.find(questId);
|
||||
if (quest == g_freshVariables.end()) {
|
||||
return available;
|
||||
}
|
||||
const auto value = quest->second.find(name);
|
||||
return (value != quest->second.end() ? value->second : 0) | available;
|
||||
}
|
||||
|
||||
void ResetVariables(int32_t questId, const questscript::Quest& quest) {
|
||||
for (const std::string& name : quest.variables) {
|
||||
int32_t* value = game::ScriptVariable(name.c_str());
|
||||
const int32_t fresh = FreshValue(questId, name);
|
||||
if (!value || *value == fresh) {
|
||||
continue;
|
||||
}
|
||||
re::Log("questfail: quest %d variable '%s' %d -> %d", questId, name.c_str(), *value, fresh);
|
||||
*value = fresh;
|
||||
}
|
||||
}
|
||||
|
||||
// Why failed quest `index` cannot be offered again, or null. `afterLoad`: the
|
||||
// failure is not running now, so any failure of the quest that ends its story
|
||||
// counts.
|
||||
const char* StaysFailed(int index, bool afterLoad) {
|
||||
const int32_t id = game::QuestId(game::Quest(index));
|
||||
const questscript::Quest* quest = questscript::Find(id);
|
||||
if (!quest || (id >= game::kGenericQuestFirst && id <= game::kGenericQuestLast)) {
|
||||
return "the game repeats it itself";
|
||||
}
|
||||
if (!quest->hasGiver && !g_giverCells.count(id)) {
|
||||
return "no giver";
|
||||
}
|
||||
if (afterLoad ? quest->hasFinalFailure : questscript::InFinalFailure()) {
|
||||
return "its failure ends the story";
|
||||
}
|
||||
if (quest->hasGiver && questscript::IsGone(quest->giver) && quest->giver.spawn.empty() &&
|
||||
!questscript::SpawnOf(quest->giver.scriptId, id)) {
|
||||
return "its giver is gone";
|
||||
}
|
||||
for (uint32_t npc : quest->needed) {
|
||||
if (!questscript::LivingNpc(npc) && !questscript::SpawnOf(npc, id)) {
|
||||
re::Log("questfail: quest %d needs Res:%u, gone without a spawn instruction", id, npc);
|
||||
return "an NPC it needs is gone";
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Failed quests in the book: those failed before the mod are restarted unless
|
||||
// they stay failed; those restarted before the variables were reset get their
|
||||
// variables back (their giver may meanwhile offer a later quest, so the
|
||||
// dialog stays).
|
||||
void RestartFailedInBook() {
|
||||
for (size_t i = game::BookCount(); i-- > 0;) {
|
||||
const uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookState(entry) != game::kBookFailed) {
|
||||
continue;
|
||||
}
|
||||
const int index = game::QuestIndex(game::BookQuestId(entry));
|
||||
const uint32_t flags = index >= 0 ? game::QuestFlags(game::Quest(index)) : 0;
|
||||
if (index < 0 || ((flags & game::kQuestInProgress) && !(flags & game::kQuestDone))) {
|
||||
continue;
|
||||
}
|
||||
if (!(flags & game::kQuestDone)) {
|
||||
const int32_t id = game::QuestId(game::Quest(index));
|
||||
if (const questscript::Quest* quest = questscript::Find(id)) {
|
||||
ResetVariables(id, *quest);
|
||||
}
|
||||
} else if (!StaysFailed(index, true)) {
|
||||
Restart(index);
|
||||
i = std::min(i, game::BookCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EraseFailedEntries(int32_t questId) {
|
||||
for (size_t i = game::BookCount(); i-- > 0;) {
|
||||
const uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookQuestId(entry) == questId && game::BookState(entry) == game::kBookFailed) {
|
||||
game::EraseBookEntry(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OnTick(uint8_t*) {
|
||||
if (g_resetAfterLoad) {
|
||||
g_resetAfterLoad = false;
|
||||
RestartFailedInBook();
|
||||
}
|
||||
}
|
||||
|
||||
void OnLoaded() {
|
||||
g_resetAfterLoad = true;
|
||||
}
|
||||
|
||||
// --- saved with the hero: {"quest id": [x, y]} ------------------------------------
|
||||
|
||||
nlohmann::json WriteGivers(unsigned) {
|
||||
if (g_giverCells.empty()) {
|
||||
return nlohmann::json();
|
||||
}
|
||||
nlohmann::json all = nlohmann::json::object();
|
||||
for (const auto& [id, cell] : g_giverCells) {
|
||||
all[std::to_string(id)] = {cell.first, cell.second};
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// Merged: the hero file and the save game both carry the list.
|
||||
void ReadGivers(unsigned, const nlohmann::json& data) {
|
||||
if (!data.is_object()) {
|
||||
return;
|
||||
}
|
||||
for (const auto& [key, cell] : data.items()) {
|
||||
if (cell.is_array() && cell.size() == 2 && cell[0].is_number_integer() && cell[1].is_number_integer()) {
|
||||
g_giverCells[std::stoi(key)] = {cell[0].get<int32_t>(), cell[1].get<int32_t>()};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- saved with the hero: {"quest id": {"variable": value}} ------------------------
|
||||
|
||||
nlohmann::json WriteFreshVariables(unsigned) {
|
||||
if (g_freshVariables.empty()) {
|
||||
return nlohmann::json();
|
||||
}
|
||||
nlohmann::json all = nlohmann::json::object();
|
||||
for (const auto& [id, variables] : g_freshVariables) {
|
||||
nlohmann::json& quest = all[std::to_string(id)] = nlohmann::json::object();
|
||||
for (const auto& [name, value] : variables) {
|
||||
quest[name] = value;
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// Merged, as the givers.
|
||||
void ReadFreshVariables(unsigned, const nlohmann::json& data) {
|
||||
if (!data.is_object()) {
|
||||
return;
|
||||
}
|
||||
for (const auto& [key, variables] : data.items()) {
|
||||
if (!variables.is_object()) {
|
||||
continue;
|
||||
}
|
||||
auto& quest = g_freshVariables[std::stoi(key)];
|
||||
for (const auto& [name, value] : variables.items()) {
|
||||
if (value.is_number_integer()) {
|
||||
quest[name] = value.get<int32_t>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// The quest at table `index` as if it had never been triggered; its failed book
|
||||
// entries point at the giver (or go).
|
||||
void Restart(int index) {
|
||||
uint8_t* quest = game::Quest(index);
|
||||
const int32_t id = game::QuestId(quest);
|
||||
re::Log("questfail: restarting quest %d '%s' (flags %x)", id, game::QuestName(quest), game::QuestFlags(quest));
|
||||
game::QuestFlags(quest) = (game::QuestFlags(quest) & ~kResetFlags) | game::kQuestTriggered;
|
||||
for (size_t i = game::BookCount(); i-- > 0;) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookQuestId(entry) == id && !PointAtGiver(entry)) {
|
||||
game::EraseBookEntry(i);
|
||||
}
|
||||
}
|
||||
const questscript::Quest* script = questscript::Find(id);
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
ResetVariables(id, *script);
|
||||
for (uint32_t npc : script->createdOnEnter) {
|
||||
questscript::Delete(npc);
|
||||
}
|
||||
for (uint32_t npc : script->needed) {
|
||||
const std::vector<uint8_t>* spawn = questscript::LivingNpc(npc) ? nullptr : questscript::SpawnOf(npc, id);
|
||||
if (spawn) {
|
||||
questscript::SpawnWhenGone(npc, *spawn, std::string(), "needed by its quest");
|
||||
}
|
||||
}
|
||||
if (!script->hasGiver) {
|
||||
return;
|
||||
}
|
||||
const questscript::Giver& giver = script->giver;
|
||||
if (!questscript::IsGone(giver)) {
|
||||
questscript::SetDialog(giver.res, giver.dialog);
|
||||
} else if (!giver.spawn.empty()) {
|
||||
questscript::SpawnWhenGone(giver.scriptId, giver.spawn, std::string(), "quest giver");
|
||||
} else if (const std::vector<uint8_t>* spawn = questscript::SpawnOf(giver.scriptId, id)) {
|
||||
questscript::SpawnWhenGone(giver.scriptId, *spawn, giver.dialog, "quest giver");
|
||||
}
|
||||
}
|
||||
|
||||
// Quest_End (0x46ced0, quest manager thiscall (index, ?, success), ret 0xc):
|
||||
// LoseQuest passes success 0, ExitQuest 1.
|
||||
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success);
|
||||
RE_HOOK(0x0046ced0, Quest_End);
|
||||
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success) {
|
||||
const bool known = index >= 0 && static_cast<size_t>(index) < game::QuestCount();
|
||||
const char* staysFailed = !success && known ? StaysFailed(index, false) : nullptr;
|
||||
Quest_End_original(self, edx, index, a2, success);
|
||||
if (!success && known) {
|
||||
if (staysFailed) {
|
||||
re::Log("questfail: quest %d '%s' stays failed: %s", game::QuestId(game::Quest(index)),
|
||||
game::QuestName(game::Quest(index)), staysFailed);
|
||||
} else {
|
||||
Restart(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quest_Trigger (0x46de10, cdecl (index, ?, ?, ?), plain ret): makes the quest
|
||||
// available (flag 1) the first time, running its trigger scripts. Its
|
||||
// variables are kept as they are then: the state a restart goes back to.
|
||||
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4);
|
||||
RE_HOOK(0x0046de10, Quest_Trigger);
|
||||
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4) {
|
||||
if (index >= 0 && static_cast<size_t>(index) < game::QuestCount() &&
|
||||
!(game::QuestFlags(game::Quest(index)) & game::kQuestTriggered)) {
|
||||
const int32_t id = game::QuestId(game::Quest(index));
|
||||
if (const questscript::Quest* quest = questscript::Find(id)) {
|
||||
auto& fresh = g_freshVariables[id];
|
||||
fresh.clear();
|
||||
for (const std::string& name : quest->variables) {
|
||||
if (const int32_t* value = game::ScriptVariable(name.c_str())) {
|
||||
fresh[name] = *value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Quest_Trigger_original(index, a2, a3, a4);
|
||||
}
|
||||
|
||||
// cDialogManager::Answer (0x49a090, thiscall (owner, answer), ret 8): runs the
|
||||
// quest script for the answer.
|
||||
void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer);
|
||||
RE_HOOK(0x0049a090, Dialog_Answer);
|
||||
void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer) {
|
||||
g_inDialogAnswer = true;
|
||||
Dialog_Answer_original(self, edx, owner, answer);
|
||||
g_inDialogAnswer = false;
|
||||
}
|
||||
|
||||
// QuestManager_AddBookEntry (0x46c450, quest manager thiscall (quest index, ?), ret 8): adds
|
||||
// the quest's book entry (state 2).
|
||||
void __fastcall QuestManager_AddBookEntry(uint8_t* self, void* edx, int index, int a2);
|
||||
RE_HOOK(0x0046c450, QuestManager_AddBookEntry);
|
||||
void __fastcall QuestManager_AddBookEntry(uint8_t* self, void* edx, int index, int a2) {
|
||||
const bool known = index >= 0 && static_cast<size_t>(index) < game::QuestCount();
|
||||
const int32_t id = known ? game::QuestId(game::Quest(index)) : 0;
|
||||
if (known) {
|
||||
EraseFailedEntries(id);
|
||||
}
|
||||
QuestManager_AddBookEntry_original(self, edx, index, a2);
|
||||
if (known && g_inDialogAnswer && id > game::kMainQuestLast) {
|
||||
RememberGiver(id);
|
||||
}
|
||||
}
|
||||
|
||||
// QuestManager_MarkerColour (0x4975d0, quest manager thiscall (entry, kind), ret 8): a
|
||||
// failed side quest pointing at its giver is drawn as an open one.
|
||||
uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t index, int16_t kind);
|
||||
RE_HOOK(0x004975d0, QuestManager_MarkerColour);
|
||||
uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t index, int16_t kind) {
|
||||
if (index < game::BookCount() && (kind == 0 || kind == 1)) {
|
||||
const uint8_t* entry = game::BookEntry(index);
|
||||
if (game::BookState(entry) == game::kBookFailed && game::BookKind(entry) == game::kBookSide &&
|
||||
g_giverCells.count(game::BookQuestId(entry))) {
|
||||
return kind == 0 ? kOpenSideQuestColour : kOpenQuestColour2;
|
||||
}
|
||||
}
|
||||
return QuestManager_MarkerColour_original(self, edx, index, kind);
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct Register {
|
||||
Register() {
|
||||
game::OnEngineTick(&OnTick);
|
||||
game::OnSaveGameLoaded(&OnLoaded);
|
||||
inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers);
|
||||
inventory_save::RegisterHeroData("questVariables", &WriteFreshVariables, &ReadFreshVariables);
|
||||
}
|
||||
} g_register;
|
||||
} // namespace
|
||||
|
||||
} // namespace mods::questfail
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
// Failed quests can be restarted (questfail.cpp).
|
||||
|
||||
namespace mods::questfail {
|
||||
|
||||
// The quest at table `index` as if it had never been triggered; its failed book
|
||||
// entries point at the giver (or go).
|
||||
void Restart(int index);
|
||||
|
||||
} // namespace mods::questfail
|
||||
@@ -0,0 +1,137 @@
|
||||
// Quest givers come back (request of 2026-10-26, docs/quests.md): "Jagd auf
|
||||
// die Wilderer" (quest 1501): the giver, an elf, follows the hero and fights;
|
||||
// when she died the quest could not be finished ("change the death script to
|
||||
// spawn her back to her starting point"; "on load we need a repair ... if not
|
||||
// completed or correctly running").
|
||||
//
|
||||
// Measured: the quest's script (FunkCode.bin) gives the giver a death label
|
||||
// when she starts following ("ex1501": bit 1 of quest variable "1501", then the
|
||||
// poachers' death label "tot1501", which ends the quest without a reward once
|
||||
// all poachers are dead; until then it stays in progress with no giver). The
|
||||
// body is removed. The giver is placed by her region's start script
|
||||
// (Region15Init: spawn instruction 1 "Res:17013", type 134, at
|
||||
// pos_auftrag1501, dialog auftrag1501), which the game runs once.
|
||||
//
|
||||
// The giver is the NPC whose spawn instruction carries the quest's accept
|
||||
// dialog (questscript.cpp). When a label of that quest runs for its dead giver
|
||||
// (her death label), the label is skipped and the quest restarts
|
||||
// (questfail::Restart: available again, its book entry failed and pointing at
|
||||
// the giver, its variables as before, the giver spawned again once the body is
|
||||
// gone). After a load, a quest in progress whose giver no longer exists is
|
||||
// repaired the same way, unless the quest itself turns the giver into an enemy
|
||||
// or deletes her (quest audit 2026-10-30: 154 deletes its giver on purpose).
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/engine/quests.h"
|
||||
#include "mods/questfail.h"
|
||||
#include "mods/questscript.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::questgiver {
|
||||
namespace {
|
||||
|
||||
bool g_repairAfterLoad = false;
|
||||
|
||||
// The quest's giver if M2 can bring her back: a numbered script id spawned with
|
||||
// the accept dialog.
|
||||
const questscript::Giver* RespawnableGiver(int32_t questId) {
|
||||
const questscript::Quest* quest = questscript::Find(questId);
|
||||
return quest && quest->hasGiver && quest->giver.scriptId && !quest->giver.spawn.empty() ? &quest->giver
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
// Restarts quest `index` (questfail::Restart, which also brings the giver
|
||||
// back); its book entry is failed.
|
||||
void ResetQuest(int index) {
|
||||
const int32_t id = game::QuestId(game::Quest(index));
|
||||
questfail::Restart(index);
|
||||
for (size_t i = 0; i < game::BookCount(); ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookQuestId(entry) == id) {
|
||||
*reinterpret_cast<int32_t*>(entry + game::kBookState) = game::kBookFailed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accepted and not done (kQuestTriggered alone: available, set when the hero
|
||||
// enters the quest's region).
|
||||
bool IsRunning(int index) {
|
||||
const uint32_t flags = game::QuestFlags(game::Quest(index));
|
||||
return (flags & game::kQuestInProgress) && !(flags & game::kQuestDone);
|
||||
}
|
||||
|
||||
// Quests whose giver turns into an enemy are left alone: a missing giver may
|
||||
// be the one the hero killed as the quest asks; so are those deleting her.
|
||||
void RepairAfterLoad() {
|
||||
for (int index = 0, count = static_cast<int>(game::QuestCount()); index < count; ++index) {
|
||||
const int32_t id = game::QuestId(game::Quest(index));
|
||||
const questscript::Giver* giver = IsRunning(index) ? RespawnableGiver(id) : nullptr;
|
||||
const questscript::Quest* quest = giver ? questscript::Find(id) : nullptr;
|
||||
if (!quest || quest->turnsGiver || quest->deletesGiver || questscript::LivingNpc(giver->scriptId)) {
|
||||
continue;
|
||||
}
|
||||
re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id,
|
||||
game::QuestName(game::Quest(index)), giver->scriptId);
|
||||
ResetQuest(index);
|
||||
}
|
||||
}
|
||||
|
||||
void OnTick(uint8_t*) {
|
||||
if (g_repairAfterLoad) {
|
||||
g_repairAfterLoad = false;
|
||||
RepairAfterLoad();
|
||||
}
|
||||
}
|
||||
|
||||
void OnLoaded() { g_repairAfterLoad = true; }
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
game::OnEngineTick(&OnTick);
|
||||
game::OnSaveGameLoaded(&OnLoaded);
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
} // namespace
|
||||
|
||||
// Script_RunLabel (0x46bd80, cdecl (label, object, flag), plain ret): a label
|
||||
// of a running quest run for its dead giver (her death label) is skipped and
|
||||
// the quest restarted. Not when the giver died as an enemy: some quests turn
|
||||
// the giver hostile and their death label goes on ("Familienbande", 702:
|
||||
// answer change702 turns the giver Res:17061 and two relatives against the
|
||||
// hero, their death label tot702 sends him back to the girl Res:17060). The
|
||||
// running labels are kept for questfail (failures that end a story).
|
||||
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag);
|
||||
RE_HOOK(0x0046bd80, Script_RunLabel);
|
||||
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) {
|
||||
const questscript::LabelScope running(label);
|
||||
const uint8_t* labels = game::ScriptLabelsBegin();
|
||||
if (object && labels && label < (game::ScriptLabelsEnd() - labels) / game::kScriptLabelSize) {
|
||||
const int32_t questId = game::ScriptLabelQuest(labels + label * game::kScriptLabelSize);
|
||||
const int index = questId >= 0 ? game::QuestIndex(questId) : -1;
|
||||
void* creature = index >= 0 ? game::DynamicCast(object, game::kTypeObject, game::kTypeCreature) : nullptr;
|
||||
if (creature && IsRunning(index) && !game::IsAlive(creature)) {
|
||||
const questscript::Giver* giver = RespawnableGiver(questId);
|
||||
if (giver && game::ObjectScriptId(creature) == giver->scriptId) {
|
||||
const char* name = game::ScriptLabelName(labels + label * game::kScriptLabelSize);
|
||||
if (game::IsHostileTo(creature, game::GetPlayerHero())) {
|
||||
re::Log("questgiver: giver Res:%u of quest %d died as an enemy; '%s' runs", giver->scriptId,
|
||||
questId, name);
|
||||
return Script_RunLabel_original(label, object, flag);
|
||||
}
|
||||
re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts", giver->scriptId,
|
||||
questId, name);
|
||||
ResetQuest(index);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Script_RunLabel_original(label, object, flag);
|
||||
}
|
||||
|
||||
} // namespace mods::questgiver
|
||||
@@ -0,0 +1,37 @@
|
||||
// Main quest items blocked by an entry without a script id (request of
|
||||
// 2026-10-27: "I cannot finish the Quest Windgeflüster").
|
||||
//
|
||||
// Measured: picking at the Goldbeerenbusch runs take402, which gives the hero
|
||||
// the berries (item type 6029) and sets the quest's variable; the giver's
|
||||
// dialog then asks for type 6029 in the hero's main quest items. The game
|
||||
// logged "addMainQuestItem() Already has mainQuestItem(6029)" and the berries
|
||||
// never arrived: the dwarf's list held {4609, 0}.
|
||||
//
|
||||
// cInventoryEntry::AddMainQuestItem (0x5d8240, thiscall (type, script id,
|
||||
// notify), list of {type, script id} at +0/+4) refuses a new item when an entry
|
||||
// has the same type or a script id of 0 (0x5d83c6: cmp [edx+4], ebx (0)).
|
||||
// Scripts that hand out an item without a "Res:" id add it with script id 0
|
||||
// (0x486bcb..0x486bd8), so after such an item every later main quest item was
|
||||
// refused. The mod drops the script id test; the same type still blocks.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::questitems {
|
||||
namespace {
|
||||
|
||||
// AddMainQuestItem's duplicate test: `cmp [edx+4], ebx; je 0x5d8418` (ebx 0).
|
||||
constexpr uint32_t kScriptIdTest = 0x005d83c6;
|
||||
|
||||
void Startup() {
|
||||
const uint8_t from[5] = {0x39, 0x5a, 0x04, 0x74, 0x4d};
|
||||
const uint8_t to[5] = {0x90, 0x90, 0x90, 0x90, 0x90};
|
||||
const bool ok = re::ReplaceCode(kScriptIdTest, from, to, sizeof(to));
|
||||
re::Log("questitems: main quest items without a script id no longer block others%s", ok ? "" : " (patch FAILED)");
|
||||
}
|
||||
RE_STARTUP(QuestItemsStartup, Startup);
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::questitems
|
||||
@@ -0,0 +1,155 @@
|
||||
// Quest NPCs after their quest (request of 2026-10-25): NPCs related to a quest
|
||||
// are drawn white on the minimaps and turn towards the player; once their quest
|
||||
// is finished they should be drawn blue and stop turning, like other NPCs.
|
||||
//
|
||||
// Measured: the zoomed map view (0x6cc950, 0x6cec6b) draws a creature white
|
||||
// when cCreature::IsQuestNpc (0x54fa30) says so: creature flags +0x200 bit
|
||||
// 0x100 or 0x200 (set by the quest manager for escorted NPCs, 0x46ae54..), or
|
||||
// a dialog +0x245 (1..59999) whose type (cDialogManager::DialogType 0x49a180:
|
||||
// record +0x48) is below 50. Some quests move their giver on to dialog 0 when
|
||||
// they end (DLG_15074_START -> _OFFEN -> _ZIEL -> 0), many leave it on the
|
||||
// quest's dialog: auftrag10, auftrag2411, AuftragEnde202 kept their dialog
|
||||
// with the quest done (flags 3, book entry solved).
|
||||
//
|
||||
// The turning (found with a hardware write watch on the facing +0x70): the
|
||||
// idle action (cCreature::IdleAction 0x544700, Think's action 0 handler) turns
|
||||
// every creature with a dialog +0x245 in 1..99999 towards the local hero each
|
||||
// Think step (0x546334..0x5463d5, cCreature::SetDirection 0x44df60), unless
|
||||
// its type is of kind 4 in the type table (0x4265c0, call at 0x546357) or
|
||||
// object flag +0x14 bit 2 is set.
|
||||
//
|
||||
// The mod: a dialog whose quest is done and not running again no longer makes
|
||||
// its NPC a quest NPC (blue on the minimap) and no longer turns it towards the
|
||||
// player. A dialog's quest is the game's own: each dialog has a script label
|
||||
// "Dialog:<name>" and each label the quest it belongs to (game/engine/quests.h,
|
||||
// script labels; logged 2026-10-25: Dialog:rg1_goodie1_auftrag 9004,
|
||||
// Dialog:auftrag_wueste 9000, Dialog:auftrag11a1 1101, Dialog:geschlossen101
|
||||
// none).
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "game/engine/quests.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::questnpc {
|
||||
namespace {
|
||||
|
||||
constexpr size_t kCreatureDialog = 0x245; // cCreature::GetDialog 0x549790
|
||||
constexpr int32_t kLastQuestDialog = 59999; // IsQuestNpc: dialogs 1..59999
|
||||
constexpr uintptr_t kDialogManager = 0x00aaaf00;
|
||||
constexpr size_t kDialogRecordsBegin = 0x755c;
|
||||
constexpr size_t kDialogRecordsEnd = 0x7560;
|
||||
constexpr size_t kDialogRecordSize = 0x50;
|
||||
constexpr size_t kDialogRecordName = 0x04; // ANSI
|
||||
constexpr const char* kDialogLabelPrefix = "Dialog:";
|
||||
|
||||
template <typename T>
|
||||
T Field(const uint8_t* p, size_t offset) {
|
||||
return *reinterpret_cast<const T*>(p + offset);
|
||||
}
|
||||
|
||||
// The dialog's record (0x50 bytes: name at +4, type at +0x48), null if none.
|
||||
const uint8_t* DialogRecord(int32_t dialog) {
|
||||
const auto* manager = reinterpret_cast<const uint8_t*>(kDialogManager);
|
||||
const uint8_t* begin = Field<const uint8_t*>(manager, kDialogRecordsBegin);
|
||||
const uint8_t* end = Field<const uint8_t*>(manager, kDialogRecordsEnd);
|
||||
if (dialog <= 0 || !begin || begin + (dialog + 1) * kDialogRecordSize > end) {
|
||||
return nullptr;
|
||||
}
|
||||
return begin + dialog * kDialogRecordSize;
|
||||
}
|
||||
|
||||
// The quest of each dialog label, by dialog name; empty until the scripts are
|
||||
// loaded.
|
||||
const std::map<std::string, int32_t>& DialogLabelQuests() {
|
||||
static std::map<std::string, int32_t> quests;
|
||||
if (quests.empty()) {
|
||||
const size_t prefix = std::strlen(kDialogLabelPrefix);
|
||||
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
|
||||
label += game::kScriptLabelSize) {
|
||||
const char* name = game::ScriptLabelName(label);
|
||||
if (std::strncmp(name, kDialogLabelPrefix, prefix) == 0 && game::ScriptLabelQuest(label) > 0) {
|
||||
quests[std::string(name + prefix, strnlen(name + prefix, game::kScriptLabelNameSize - prefix))] =
|
||||
game::ScriptLabelQuest(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
return quests;
|
||||
}
|
||||
|
||||
// The quest table index of the quest a dialog belongs to, -1 if none. Cached
|
||||
// per dialog: the minimap and the idle action ask for every creature often.
|
||||
int DialogQuest(int32_t dialog) {
|
||||
static std::map<int32_t, int> cache;
|
||||
if (auto it = cache.find(dialog); it != cache.end()) {
|
||||
return it->second;
|
||||
}
|
||||
const uint8_t* record = DialogRecord(dialog);
|
||||
const auto& quests = DialogLabelQuests();
|
||||
if (!record || quests.empty() || game::QuestCount() == 0) {
|
||||
return -1; // not cached before the scripts are loaded
|
||||
}
|
||||
const auto it = quests.find(reinterpret_cast<const char*>(record + kDialogRecordName));
|
||||
const int index = it != quests.end() ? game::QuestIndex(it->second) : -1;
|
||||
cache[dialog] = index;
|
||||
return index;
|
||||
}
|
||||
|
||||
// The creature's dialog belongs to a finished quest.
|
||||
bool QuestFinished(const uint8_t* creature) {
|
||||
const int32_t dialog = Field<int32_t>(creature, kCreatureDialog);
|
||||
if (dialog <= 0 || dialog > kLastQuestDialog) {
|
||||
return false;
|
||||
}
|
||||
const int index = DialogQuest(dialog);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
const uint32_t flags = game::QuestFlags(game::Quest(index));
|
||||
return (flags & game::kQuestDone) && !(flags & game::kQuestInProgress);
|
||||
}
|
||||
|
||||
// The idle action's type check before turning towards the hero (call at
|
||||
// 0x546357: TypeTable_IsKind4 0x4265c0, thiscall (type table, type), ret 4;
|
||||
// true skips the turn), creature in esi.
|
||||
constexpr uint32_t kFacePlayerTypeCheck = 0x00546357;
|
||||
constexpr uint32_t kTypeTableIsKind4 = 0x004265c0;
|
||||
|
||||
char __stdcall KeepsFacing(void* typeTable, int32_t type, const uint8_t* creature) {
|
||||
using Fn = char(__fastcall*)(void*, void*, int32_t);
|
||||
return reinterpret_cast<Fn>(kTypeTableIsKind4)(typeTable, nullptr, type) || QuestFinished(creature);
|
||||
}
|
||||
|
||||
__declspec(naked) void KeepsFacingThunk() {
|
||||
__asm {
|
||||
push esi
|
||||
push dword ptr [esp + 8]
|
||||
push ecx
|
||||
call KeepsFacing
|
||||
ret 4
|
||||
}
|
||||
}
|
||||
|
||||
void Startup() {
|
||||
re::RedirectCall(kFacePlayerTypeCheck, kTypeTableIsKind4, reinterpret_cast<const void*>(&KeepsFacingThunk));
|
||||
}
|
||||
RE_STARTUP(QuestNpcStartup, Startup);
|
||||
|
||||
} // namespace
|
||||
|
||||
// cCreature::IsQuestNpc (0x54fa30, thiscall (), ret): the minimap draws the
|
||||
// creature white.
|
||||
char __fastcall Creature_IsQuestNpc(uint8_t* self, void* edx);
|
||||
RE_HOOK(0x0054fa30, Creature_IsQuestNpc);
|
||||
char __fastcall Creature_IsQuestNpc(uint8_t* self, void* edx) {
|
||||
if (QuestFinished(self)) {
|
||||
return 0;
|
||||
}
|
||||
return Creature_IsQuestNpc_original(self, edx);
|
||||
}
|
||||
|
||||
} // namespace mods::questnpc
|
||||
@@ -0,0 +1,619 @@
|
||||
// The quest scripts read once (quest audit of 2026-10-30, docs/quest-audit.md):
|
||||
// what the failure restart (questfail.cpp) and the giver respawn
|
||||
// (questgiver.cpp) need to know about a quest.
|
||||
//
|
||||
// The compiled script code (FunkCode.bin, open as the quest manager's file) is
|
||||
// a list of labels (the label table, game/engine/quests.h) whose code is a
|
||||
// sequence of instructions {u16 opcode, u16 size, tagged args}; the argument
|
||||
// sizes per tag are those of the game's reader (0x472eb0, as tools/
|
||||
// questscript.mjs decodes them). From it, per quest:
|
||||
// - the giver: the NPC given a dialog whose answer starts the quest (a spawn
|
||||
// instruction carrying it, e.g. Region15Init "Res:17013" dialog auftrag1501;
|
||||
// else an NPC state instruction, as the Underworld's QIS_OnSetUp<id> do);
|
||||
// - its variables: those only its labels set, and the one named after it;
|
||||
// - the NPCs it needs: those its labels set states on but don't create, as
|
||||
// 1302's opponent Res:17173 (Region13Init); they come back from their spawn
|
||||
// instruction elsewhere when the quest restarts;
|
||||
// - what QIS_OnEnter<id> creates (deleted on a restart, accepting again
|
||||
// creates it anew);
|
||||
// - the failures that end its story: a LoseQuest label that also sets up the
|
||||
// next quest (2402), calls another quest's label (2502 finalestart), hands
|
||||
// out a reward (9520), or turns hostile or kills an NPC offering it (5003,
|
||||
// 1701, 1703).
|
||||
|
||||
#include "mods/questscript.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <utility>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/engine/quests.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::questscript {
|
||||
namespace {
|
||||
|
||||
constexpr size_t kInstructionHeader = 4;
|
||||
constexpr size_t kReadChunk = 1 << 20;
|
||||
constexpr DWORD kBodyWaitMs = 30000; // spawn anyway if the body is still there
|
||||
constexpr int32_t kPositionNamed = -2; // position args: -2 + a name, else 3 ints
|
||||
constexpr int32_t kNumberNamed = -0x12d687; // 0x0b args: this + a name (DefNum)
|
||||
constexpr uint8_t kArgHostile = 0x12; // flag after kScriptArgAttitude
|
||||
|
||||
// Argument payloads by tag (0x472eb0).
|
||||
enum class Payload : uint8_t { kUnknown, kString, kU16, kI32, kPosition, kCondition, kFlag, kBytes };
|
||||
|
||||
struct ArgTable {
|
||||
std::array<Payload, 256> payload{};
|
||||
std::array<uint8_t, 256> bytes{};
|
||||
ArgTable() {
|
||||
for (int tag : {0x01, 0x05, 0x09, 0x16, 0x1e, 0x29, 0x3a, 0x41, 0x60, 0x63, 0x67, 0x68, 0x69, 0x6a, 0x71,
|
||||
0x83, 0x9d}) {
|
||||
payload[tag] = Payload::kString;
|
||||
}
|
||||
for (int tag : {0x03, 0x0a, 0x6b, 0x6c, 0x9b, 0x9c}) {
|
||||
payload[tag] = Payload::kU16;
|
||||
}
|
||||
for (int tag : {0x02, 0x0b, 0x11, 0x19, 0x1c, 0x1d, 0x1f, 0x36, 0x38, 0x53, 0x54, 0x55, 0x56, 0x57, 0x5f,
|
||||
0x73, 0x75, 0x84, 0x86}) {
|
||||
payload[tag] = Payload::kI32;
|
||||
}
|
||||
for (int tag : {0x04, 0x0c, 0x0d}) {
|
||||
payload[tag] = Payload::kPosition;
|
||||
}
|
||||
for (int tag : {0x47, 0x48, 0x49, 0x4a}) {
|
||||
payload[tag] = Payload::kCondition;
|
||||
}
|
||||
for (int tag : {0x06, 0x07, 0x08, 0x0e, 0x0f, 0x10, 0x12, 0x13, 0x14, 0x1a, 0x1b, 0x23, 0x24, 0x25, 0x26, 0x27,
|
||||
0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x39, 0x3f, 0x42, 0x43, 0x44, 0x45, 0x46, 0x4c,
|
||||
0x4e, 0x4f, 0x50, 0x51, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x61, 0x62, 0x64, 0x65, 0x66, 0x70, 0x72,
|
||||
0x74, 0x78, 0x7b, 0x7c, 0x80, 0x85, 0x8a, 0x8d, 0x8e, 0x91, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9e,
|
||||
0xa0, 0xa1}) {
|
||||
payload[tag] = Payload::kFlag;
|
||||
}
|
||||
for (auto [tag, size] : std::initializer_list<std::pair<int, int>>{
|
||||
{0x15, 8}, {0x20, 1}, {0x28, 1}, {0x2a, 1}, {0x33, 8}, {0x34, 8}, {0x35, 12}, {0x37, 1},
|
||||
{0x4b, 1}, {0x4d, 1}, {0x79, 8}, {0x87, 8}, {0x88, 8}, {0x89, 8}, {0x8b, 1}}) {
|
||||
payload[tag] = Payload::kBytes;
|
||||
bytes[tag] = static_cast<uint8_t>(size);
|
||||
}
|
||||
}
|
||||
};
|
||||
const ArgTable kArgs;
|
||||
|
||||
struct Arg {
|
||||
uint8_t tag = 0;
|
||||
const char* text = nullptr; // string payloads, in g_code
|
||||
int32_t number = 0;
|
||||
};
|
||||
|
||||
struct Label {
|
||||
std::string name;
|
||||
int32_t quest = -1;
|
||||
size_t code = 0;
|
||||
size_t size = 0;
|
||||
};
|
||||
|
||||
struct Spawn {
|
||||
int32_t quest;
|
||||
const Label* label;
|
||||
std::vector<uint8_t> code;
|
||||
};
|
||||
|
||||
struct Pending {
|
||||
uint32_t scriptId;
|
||||
std::vector<uint8_t> spawn;
|
||||
std::string dialog;
|
||||
void* body;
|
||||
uint32_t bodyId;
|
||||
DWORD since;
|
||||
std::string why;
|
||||
};
|
||||
|
||||
bool g_scanned = false;
|
||||
std::vector<uint8_t> g_code;
|
||||
std::vector<Label> g_labels;
|
||||
std::map<int32_t, Quest> g_quests;
|
||||
std::map<uint32_t, std::vector<Spawn>> g_spawns; // script id -> its spawn instructions
|
||||
std::set<uint32_t> g_finalLabels; // label indices
|
||||
std::vector<uint32_t> g_running; // labels running now
|
||||
std::vector<Pending> g_pending;
|
||||
|
||||
uint16_t U16(size_t at) { return static_cast<uint16_t>(g_code[at] | (g_code[at + 1] << 8)); }
|
||||
int32_t I32(size_t at) {
|
||||
int32_t value;
|
||||
std::memcpy(&value, &g_code[at], sizeof(value));
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string Lower(const char* text) {
|
||||
std::string lower(text);
|
||||
for (char& c : lower) {
|
||||
c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
bool IsRes(const char* text) { return text && _strnicmp(text, "res:", 4) == 0; }
|
||||
|
||||
// The number of "Res:<number>", 0 for a named id.
|
||||
uint32_t ResNumber(const char* text) {
|
||||
if (!IsRes(text) || !text[4]) {
|
||||
return 0;
|
||||
}
|
||||
char* end = nullptr;
|
||||
const unsigned long number = std::strtoul(text + 4, &end, 10);
|
||||
return *end == 0 ? static_cast<uint32_t>(number) : 0;
|
||||
}
|
||||
|
||||
bool ReadCode() {
|
||||
g_code.clear();
|
||||
for (uint32_t offset = 0;; offset += kReadChunk) {
|
||||
g_code.resize(offset + kReadChunk);
|
||||
const size_t read = game::ReadScriptFile(offset, g_code.data() + offset, kReadChunk);
|
||||
g_code.resize(offset + read);
|
||||
if (read < kReadChunk) {
|
||||
return !g_code.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The arguments of the instruction at `at`; false if they don't decode exactly
|
||||
// (conditions are not decoded).
|
||||
bool Decode(size_t at, uint16_t size, std::vector<Arg>& args) {
|
||||
args.clear();
|
||||
const size_t end = at + size;
|
||||
size_t p = at + kInstructionHeader;
|
||||
auto text = [&](size_t from, const char*& out) {
|
||||
const void* zero = from < end ? memchr(&g_code[from], 0, end - from) : nullptr;
|
||||
if (!zero) {
|
||||
return false;
|
||||
}
|
||||
out = reinterpret_cast<const char*>(&g_code[from]);
|
||||
p = static_cast<const uint8_t*>(zero) - g_code.data() + 1;
|
||||
return true;
|
||||
};
|
||||
while (p < end) {
|
||||
Arg arg;
|
||||
arg.tag = g_code[p++];
|
||||
switch (kArgs.payload[arg.tag]) {
|
||||
case Payload::kString:
|
||||
if (!text(p, arg.text)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case Payload::kU16:
|
||||
if (p + 2 > end) {
|
||||
return false;
|
||||
}
|
||||
arg.number = U16(p);
|
||||
p += 2;
|
||||
break;
|
||||
case Payload::kPosition:
|
||||
if (p + 4 > end) {
|
||||
return false;
|
||||
}
|
||||
arg.number = I32(p);
|
||||
p += 4;
|
||||
if (arg.number == kPositionNamed) {
|
||||
if (!text(p, arg.text)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
p += 8;
|
||||
}
|
||||
break;
|
||||
case Payload::kI32:
|
||||
if (p + 4 > end) {
|
||||
return false;
|
||||
}
|
||||
arg.number = I32(p);
|
||||
p += 4;
|
||||
if (arg.tag == game::kScriptArgInt && arg.number == kNumberNamed && !text(p, arg.text)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case Payload::kCondition:
|
||||
if (p + 4 > end) {
|
||||
return false;
|
||||
}
|
||||
arg.number = I32(p);
|
||||
if (!text(p + 4, arg.text)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case Payload::kBytes:
|
||||
p += kArgs.bytes[arg.tag];
|
||||
break;
|
||||
case Payload::kFlag:
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
args.push_back(arg);
|
||||
}
|
||||
return p == end;
|
||||
}
|
||||
|
||||
// Calls `fn(at, opcode, args)` for each decoded instruction of a label.
|
||||
template <typename Fn>
|
||||
void ForEachInstruction(const Label& label, Fn fn) {
|
||||
std::vector<Arg> args;
|
||||
const size_t end = label.code + label.size;
|
||||
for (size_t at = label.code; at + kInstructionHeader <= end && end <= g_code.size();) {
|
||||
const uint16_t opcode = U16(at);
|
||||
const uint16_t size = U16(at + 2);
|
||||
if (size < kInstructionHeader || at + size > end) {
|
||||
return;
|
||||
}
|
||||
if (opcode != game::kScriptIf && opcode != game::kScriptElseIf && Decode(at, size, args)) {
|
||||
fn(at, opcode, args);
|
||||
}
|
||||
at += size;
|
||||
}
|
||||
}
|
||||
|
||||
const Arg* FindArg(const std::vector<Arg>& args, uint8_t tag) {
|
||||
for (const Arg& arg : args) {
|
||||
if (arg.tag == tag) {
|
||||
return &arg;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The first "Res:..." string argument (spawns may name the type first).
|
||||
const char* ResArg(const std::vector<Arg>& args) {
|
||||
for (const Arg& arg : args) {
|
||||
if (arg.tag == game::kScriptArgString && IsRes(arg.text)) {
|
||||
return arg.text;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool SetsHostile(const std::vector<Arg>& args) {
|
||||
for (size_t i = 0; i + 1 < args.size(); ++i) {
|
||||
if (args[i].tag == game::kScriptArgAttitude && args[i + 1].tag == kArgHostile) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> Code(size_t at, uint16_t size) {
|
||||
return std::vector<uint8_t>(g_code.begin() + at, g_code.begin() + at + size);
|
||||
}
|
||||
|
||||
// An instruction with string arguments {tag, text}.
|
||||
std::vector<uint8_t> Instruction(uint16_t opcode, std::initializer_list<std::pair<uint8_t, std::string>> args) {
|
||||
std::vector<uint8_t> code(kInstructionHeader);
|
||||
for (const auto& [tag, text] : args) {
|
||||
code.push_back(tag);
|
||||
code.insert(code.end(), text.begin(), text.end());
|
||||
code.push_back(0);
|
||||
}
|
||||
code[0] = static_cast<uint8_t>(opcode);
|
||||
code[1] = static_cast<uint8_t>(opcode >> 8);
|
||||
code[2] = static_cast<uint8_t>(code.size());
|
||||
code[3] = static_cast<uint8_t>(code.size() >> 8);
|
||||
return code;
|
||||
}
|
||||
|
||||
void Scan() {
|
||||
// Not before the game has the code open and its labels read.
|
||||
if (g_scanned || !game::ScriptFile() || !game::ScriptLabelsBegin() ||
|
||||
game::ScriptLabelsEnd() <= game::ScriptLabelsBegin()) {
|
||||
return;
|
||||
}
|
||||
g_scanned = true;
|
||||
if (!ReadCode()) {
|
||||
re::Log("questscript: script code not readable");
|
||||
return;
|
||||
}
|
||||
std::map<std::string, size_t> byName;
|
||||
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
|
||||
label += game::kScriptLabelSize) {
|
||||
Label entry;
|
||||
entry.name.assign(game::ScriptLabelName(label), strnlen(game::ScriptLabelName(label), game::kScriptLabelNameSize));
|
||||
entry.quest = game::ScriptLabelQuest(label);
|
||||
entry.code = game::ScriptLabelCode(label);
|
||||
entry.size = game::ScriptLabelCodeSize(label);
|
||||
byName.emplace(entry.name, g_labels.size());
|
||||
if (entry.quest >= 0) {
|
||||
g_quests[entry.quest];
|
||||
}
|
||||
g_labels.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
// The accept labels: code starting with "start quest #id" (0x48f320; e.g.
|
||||
// "trigger1501"); the dialogs with an answer (text, label) running one of
|
||||
// their quest.
|
||||
std::map<std::string, int32_t> accepts;
|
||||
for (const Label& label : g_labels) {
|
||||
const size_t at = label.code;
|
||||
if (label.size >= kInstructionHeader + 5 && at + kInstructionHeader + 5 <= g_code.size() &&
|
||||
U16(at) == game::kScriptStartQuest && g_code[at + kInstructionHeader] == game::kScriptArgInt) {
|
||||
accepts.emplace(label.name, I32(at + kInstructionHeader + 1));
|
||||
}
|
||||
}
|
||||
std::map<std::string, int32_t> dialogs; // lower-case dialog name -> quest
|
||||
static const char kDialog[] = "Dialog:";
|
||||
for (const Label& label : g_labels) {
|
||||
if (label.quest < 0 || label.name.compare(0, sizeof(kDialog) - 1, kDialog) != 0) {
|
||||
continue;
|
||||
}
|
||||
ForEachInstruction(label, [&](size_t, uint16_t opcode, const std::vector<Arg>& args) {
|
||||
if (opcode != game::kScriptAnswer || args.size() < 2 || !args[1].text) {
|
||||
return;
|
||||
}
|
||||
const auto accept = accepts.find(args[1].text);
|
||||
if (accept != accepts.end() && accept->second == label.quest) {
|
||||
dialogs.emplace(Lower(label.name.c_str() + sizeof(kDialog) - 1), label.quest);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Spawns, givers (and every NPC offering a quest), variable writers.
|
||||
std::map<std::string, std::set<int32_t>> setters;
|
||||
std::map<int32_t, Giver> stateGivers;
|
||||
std::map<int32_t, std::set<std::string>> offering; // quest -> NPCs given its accept dialog
|
||||
for (const Label& label : g_labels) {
|
||||
ForEachInstruction(label, [&](size_t at, uint16_t opcode, const std::vector<Arg>& args) {
|
||||
if (game::IsScriptVariableWrite(opcode) && !args.empty() && args[0].tag == game::kScriptArgString) {
|
||||
setters[args[0].text].insert(label.quest);
|
||||
}
|
||||
if (opcode != game::kScriptSpawn && opcode != game::kScriptSetNpcState) {
|
||||
return;
|
||||
}
|
||||
const char* res = opcode == game::kScriptSpawn ? ResArg(args) : (args.empty() ? nullptr : args[0].text);
|
||||
if (!IsRes(res)) {
|
||||
return;
|
||||
}
|
||||
if (opcode == game::kScriptSpawn && ResNumber(res)) {
|
||||
g_spawns[ResNumber(res)].push_back({label.quest, &label, Code(at, U16(at + 2))});
|
||||
}
|
||||
const Arg* dialog = FindArg(args, game::kScriptArgDialog);
|
||||
const auto offered = dialog ? dialogs.find(Lower(dialog->text)) : dialogs.end();
|
||||
if (offered == dialogs.end()) {
|
||||
return;
|
||||
}
|
||||
offering[offered->second].insert(Lower(res));
|
||||
Giver giver{Lower(res), ResNumber(res), dialog->text, {}};
|
||||
Quest& quest = g_quests[offered->second];
|
||||
if (opcode == game::kScriptSpawn && !quest.hasGiver) {
|
||||
giver.spawn = Code(at, U16(at + 2));
|
||||
quest.giver = std::move(giver);
|
||||
quest.hasGiver = true;
|
||||
} else if (opcode == game::kScriptSetNpcState) {
|
||||
stateGivers.emplace(offered->second, std::move(giver));
|
||||
}
|
||||
});
|
||||
}
|
||||
for (auto& [id, giver] : stateGivers) {
|
||||
Quest& quest = g_quests[id];
|
||||
if (!quest.hasGiver) {
|
||||
quest.giver = std::move(giver);
|
||||
quest.hasGiver = true;
|
||||
}
|
||||
}
|
||||
for (const auto& [name, quests] : setters) {
|
||||
if (quests.size() == 1 && *quests.begin() >= 0) {
|
||||
g_quests[*quests.begin()].variables.push_back(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Per quest: what its labels create and touch, and its failures.
|
||||
std::map<int32_t, std::set<uint32_t>> created;
|
||||
std::map<int32_t, std::set<uint32_t>> touched;
|
||||
for (uint32_t index = 0; index < g_labels.size(); ++index) {
|
||||
const Label& label = g_labels[index];
|
||||
if (label.quest < 0) {
|
||||
continue;
|
||||
}
|
||||
Quest& quest = g_quests[label.quest];
|
||||
const std::string id = std::to_string(label.quest);
|
||||
const bool setUp = label.name == "QIS_OnSetUp" + id;
|
||||
const bool onEnter = label.name == "QIS_OnEnter" + id;
|
||||
const std::set<std::string>& offers = offering[label.quest];
|
||||
bool loses = false;
|
||||
bool ends = false;
|
||||
ForEachInstruction(label, [&](size_t, uint16_t opcode, const std::vector<Arg>& args) {
|
||||
const char* res = opcode == game::kScriptSpawn || opcode == game::kScriptCreateObject
|
||||
? ResArg(args)
|
||||
: (!args.empty() && args[0].tag == game::kScriptArgString ? args[0].text : nullptr);
|
||||
const uint32_t npc = ResNumber(res);
|
||||
const std::string lower = IsRes(res) ? Lower(res) : std::string();
|
||||
const bool isGiver = quest.hasGiver && !lower.empty() && lower == quest.giver.res;
|
||||
const bool offersIt = !lower.empty() && offers.count(lower);
|
||||
switch (opcode) {
|
||||
case game::kScriptSpawn:
|
||||
case game::kScriptCreateObject:
|
||||
if (npc && !setUp) {
|
||||
created[label.quest].insert(npc);
|
||||
}
|
||||
if (npc && onEnter && !isGiver) {
|
||||
quest.createdOnEnter.push_back(npc);
|
||||
}
|
||||
break;
|
||||
case game::kScriptSetNpcState:
|
||||
if (npc && !setUp) {
|
||||
touched[label.quest].insert(npc);
|
||||
}
|
||||
ends = ends || (offersIt && SetsHostile(args));
|
||||
break;
|
||||
case game::kScriptSetHitPoints:
|
||||
ends = ends || (offersIt && args.size() >= 2 && args[1].number == 0);
|
||||
break;
|
||||
case game::kScriptChangeType:
|
||||
quest.turnsGiver = quest.turnsGiver || isGiver;
|
||||
break;
|
||||
case game::kScriptDelete:
|
||||
quest.deletesGiver = quest.deletesGiver || isGiver;
|
||||
break;
|
||||
case game::kScriptLoseQuest:
|
||||
loses = true;
|
||||
break;
|
||||
case game::kScriptSetUpQuest:
|
||||
case game::kScriptActivateQuest:
|
||||
case game::kScriptGiveExperience:
|
||||
case game::kScriptGiveGold:
|
||||
ends = true;
|
||||
break;
|
||||
case game::kScriptCallLabel: {
|
||||
const auto target = !args.empty() && args[0].text ? byName.find(args[0].text) : byName.end();
|
||||
ends = ends || target == byName.end() || g_labels[target->second].quest != label.quest;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (loses && ends) {
|
||||
g_finalLabels.insert(index);
|
||||
quest.hasFinalFailure = true;
|
||||
}
|
||||
}
|
||||
unsigned givers = 0;
|
||||
for (auto& [id, quest] : g_quests) {
|
||||
const std::string named = std::to_string(id);
|
||||
if (setters.count(named) && std::find(quest.variables.begin(), quest.variables.end(), named) ==
|
||||
quest.variables.end()) {
|
||||
quest.variables.push_back(named);
|
||||
}
|
||||
for (uint32_t npc : touched[id]) {
|
||||
if (!created[id].count(npc) && !(quest.hasGiver && npc == quest.giver.scriptId)) {
|
||||
quest.needed.push_back(npc);
|
||||
}
|
||||
}
|
||||
givers += quest.hasGiver;
|
||||
}
|
||||
re::Log("questscript: %u quests, %u with a giver, %u failure labels ending a story, %u bytes of script code",
|
||||
static_cast<unsigned>(g_quests.size()), givers, static_cast<unsigned>(g_finalLabels.size()),
|
||||
static_cast<unsigned>(g_code.size()));
|
||||
}
|
||||
|
||||
void Run(const std::vector<uint8_t>& code) { game::RunScriptInstruction(code.data()); }
|
||||
|
||||
void SpawnNow(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog, const std::string& why) {
|
||||
const int result = game::RunScriptInstruction(spawn.data());
|
||||
if (!dialog.empty()) {
|
||||
SetDialog("res:" + std::to_string(scriptId), dialog);
|
||||
}
|
||||
re::Log("questscript: Res:%u spawned again (%s, %d): %s", scriptId, why.c_str(), result,
|
||||
LivingNpc(scriptId) ? "back" : "NOT FOUND");
|
||||
}
|
||||
|
||||
void OnTick(uint8_t*) {
|
||||
for (auto it = g_pending.begin(); it != g_pending.end();) {
|
||||
const bool gone = game::GetObjectById(it->bodyId) != it->body;
|
||||
if (!gone && GetTickCount() - it->since < kBodyWaitMs) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
SpawnNow(it->scriptId, it->spawn, it->dialog, it->why);
|
||||
it = g_pending.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
void OnLoaded() { g_pending.clear(); }
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
game::OnEngineTick(&OnTick);
|
||||
game::OnSaveGameLoaded(&OnLoaded);
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
} // namespace
|
||||
|
||||
const Quest* Find(int32_t questId) {
|
||||
Scan();
|
||||
const auto quest = g_quests.find(questId);
|
||||
return quest != g_quests.end() ? &quest->second : nullptr;
|
||||
}
|
||||
|
||||
const std::vector<uint8_t>* SpawnOf(uint32_t scriptId, int32_t questId) {
|
||||
Scan();
|
||||
const auto spawns = g_spawns.find(scriptId);
|
||||
if (spawns == g_spawns.end()) {
|
||||
return nullptr;
|
||||
}
|
||||
const std::string setUp = "QIS_OnSetUp" + std::to_string(questId);
|
||||
const std::vector<uint8_t>* other = nullptr;
|
||||
const std::vector<uint8_t>* own = nullptr;
|
||||
for (const Spawn& spawn : spawns->second) {
|
||||
if (spawn.quest < 0) {
|
||||
return &spawn.code;
|
||||
}
|
||||
if (spawn.quest == questId && spawn.label->name == setUp) {
|
||||
own = own ? own : &spawn.code;
|
||||
} else if (spawn.quest != questId) {
|
||||
other = other ? other : &spawn.code;
|
||||
}
|
||||
}
|
||||
return own ? own : other;
|
||||
}
|
||||
|
||||
void* LivingNpc(uint32_t scriptId) {
|
||||
const uint32_t id = scriptId ? game::ScriptObjectId(scriptId) : 0;
|
||||
void* creature = id && game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
|
||||
return creature && game::ObjectScriptId(creature) == scriptId && game::IsAlive(creature) ? creature : nullptr;
|
||||
}
|
||||
|
||||
bool IsGone(const Giver& giver) { return giver.scriptId && !LivingNpc(giver.scriptId); }
|
||||
|
||||
void SpawnWhenGone(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog,
|
||||
const char* why) {
|
||||
for (const Pending& pending : g_pending) {
|
||||
if (pending.scriptId == scriptId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const uint32_t bodyId = game::ScriptObjectId(scriptId);
|
||||
void* body = bodyId ? game::GetObjectById(bodyId) : nullptr;
|
||||
if (body && game::ObjectScriptId(body) == scriptId) {
|
||||
g_pending.push_back({scriptId, spawn, dialog, body, bodyId, GetTickCount(), why});
|
||||
} else {
|
||||
SpawnNow(scriptId, spawn, dialog, why);
|
||||
}
|
||||
}
|
||||
|
||||
void Delete(uint32_t scriptId) {
|
||||
const uint32_t id = game::ScriptObjectId(scriptId);
|
||||
void* object = id ? game::GetObjectById(id) : nullptr;
|
||||
if (!object || game::ObjectScriptId(object) != scriptId) {
|
||||
return;
|
||||
}
|
||||
re::Log("questscript: Res:%u deleted", scriptId);
|
||||
Run(Instruction(game::kScriptDelete, {{game::kScriptArgString, "res:" + std::to_string(scriptId)}}));
|
||||
}
|
||||
|
||||
void SetDialog(const std::string& res, const std::string& dialog) {
|
||||
Run(Instruction(game::kScriptSetNpcState,
|
||||
{{game::kScriptArgString, res}, {game::kScriptArgDialog, dialog}}));
|
||||
}
|
||||
|
||||
LabelScope::LabelScope(uint32_t label) { g_running.push_back(label); }
|
||||
LabelScope::~LabelScope() { g_running.pop_back(); }
|
||||
|
||||
bool InFinalFailure() {
|
||||
Scan();
|
||||
for (uint32_t label : g_running) {
|
||||
if (g_finalLabels.count(label)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace mods::questscript
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
// What the quest scripts say about each quest (questscript.cpp): its giver, its
|
||||
// variables, the NPCs it needs, the failures that end its story. Shared by the
|
||||
// failure restart (questfail.cpp) and the giver respawn (questgiver.cpp).
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mods::questscript {
|
||||
|
||||
struct Giver {
|
||||
std::string res; // "res:17013" as the scripts name it, lower case
|
||||
uint32_t scriptId = 0; // its number, 0 for named ids ("res:AUFTRAGGEBER_10254")
|
||||
std::string dialog; // the dialog with the accept answer
|
||||
std::vector<uint8_t> spawn; // the spawn instruction carrying that dialog, or empty
|
||||
};
|
||||
|
||||
struct Quest {
|
||||
bool hasGiver = false;
|
||||
Giver giver;
|
||||
std::vector<std::string> variables; // only this quest's labels set them, and the one named after it
|
||||
std::vector<uint32_t> createdOnEnter; // script ids QIS_OnEnter<id> creates (not the giver)
|
||||
std::vector<uint32_t> needed; // NPCs its labels set states on but don't create (not the giver)
|
||||
bool turnsGiver = false; // a label changes the giver's creature type
|
||||
bool deletesGiver = false; // a label deletes the giver
|
||||
bool hasFinalFailure = false; // one of its failures ends the story
|
||||
};
|
||||
|
||||
// The quest's script facts, null if it has no labels (generic quest copies).
|
||||
const Quest* Find(int32_t questId);
|
||||
|
||||
// A spawn instruction for `scriptId` outside the quest's own course: in a label
|
||||
// of no quest (region start), of another quest, or its QIS_OnSetUp; null if none.
|
||||
const std::vector<uint8_t>* SpawnOf(uint32_t scriptId, int32_t questId);
|
||||
|
||||
// The living creature the scripts call Res:<scriptId>, or null.
|
||||
void* LivingNpc(uint32_t scriptId);
|
||||
|
||||
// The NPC Res:<scriptId> is dead or missing (named ids are never known gone).
|
||||
bool IsGone(const Giver& giver);
|
||||
|
||||
// Runs `spawn` now, or once the dead body of Res:<scriptId> is gone; then gives
|
||||
// the NPC `dialog` (if not empty).
|
||||
void SpawnWhenGone(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog,
|
||||
const char* why);
|
||||
|
||||
// Deletes the NPC or object Res:<scriptId> if it exists.
|
||||
void Delete(uint32_t scriptId);
|
||||
|
||||
// Gives NPC `res` the dialog `dialog` (script NPC state instruction).
|
||||
void SetDialog(const std::string& res, const std::string& dialog);
|
||||
|
||||
// A label of the scripts runs (Script_RunLabel): kept on a stack while it runs.
|
||||
class LabelScope {
|
||||
public:
|
||||
explicit LabelScope(uint32_t label);
|
||||
~LabelScope();
|
||||
LabelScope(const LabelScope&) = delete;
|
||||
LabelScope& operator=(const LabelScope&) = delete;
|
||||
};
|
||||
|
||||
// A running label (or one calling it) is a failure that ends its quest's story.
|
||||
bool InFinalFailure();
|
||||
|
||||
} // namespace mods::questscript
|
||||
+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);
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
// 16:9 menu screens (mods/screen.h, docs/screens.md).
|
||||
|
||||
#include "mods/screen.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "game/addresses.h"
|
||||
#include "game/render/dx_driver7.h"
|
||||
#include "game/ui/button.h"
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/draw.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "game/ui/frame.h"
|
||||
#include "game/ui/texture.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "mods/widescreen.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::screen {
|
||||
namespace {
|
||||
|
||||
// Background (user, 2026-10-20: "textured dark", then "doesn't need to be that
|
||||
// dark, but apply it to all screens"): the options window's rock wall,
|
||||
// GUI_OPTION_BACKGROUND<row><column>.TGA (4 x 3 tiles of 256, a 1024x768
|
||||
// picture), scaled to cover the render target, a little darkened, darker
|
||||
// towards the bottom and the sides.
|
||||
constexpr int kBackgroundColumns = 4;
|
||||
constexpr int kBackgroundRows = 3;
|
||||
constexpr float kBackgroundTile = 256.0f;
|
||||
constexpr uint32_t kBackgroundTint = 0xffa0a0a0;
|
||||
constexpr uint32_t kShadeTop = 0x10000000;
|
||||
constexpr uint32_t kShadeBottom = 0x70000000;
|
||||
constexpr uint32_t kVignette = 0x70000000;
|
||||
constexpr int kVignetteWidth = 240;
|
||||
constexpr uint32_t kPanelBackdrop = 0xb4000000;
|
||||
constexpr int kHeadingHeight = 30;
|
||||
|
||||
// Frames drawn so far, to tell a button drawn this frame (events come between
|
||||
// frames).
|
||||
std::atomic<unsigned> g_frame{1};
|
||||
|
||||
// The windows whose screen is shown on the menu thread, with the menu frame
|
||||
// they were last drawn in: the main menu's frame is not drawn centred while
|
||||
// any is (the screen draws itself across the width). Entries not drawn for
|
||||
// kShownFrames frames are dropped: the UI is rebuilt when a game starts or
|
||||
// ends, and a window destroyed while shown kept the menu from being framed
|
||||
// (user, 2026-10-21: "the old main menu is rendering again"). Menu thread only.
|
||||
constexpr unsigned kShownFrames = 2;
|
||||
unsigned g_menuFrame = 0;
|
||||
std::map<const void*, unsigned> g_shown;
|
||||
|
||||
void NoteShown(const void* window, bool shown) {
|
||||
if (!widescreen::IsMenuThread()) {
|
||||
return;
|
||||
}
|
||||
if (shown) {
|
||||
g_shown[window] = g_menuFrame;
|
||||
} else {
|
||||
g_shown.erase(window);
|
||||
}
|
||||
}
|
||||
|
||||
// The windows' own pictures, replaced by the background (user, 2026-10-20:
|
||||
// "the previous backgrounds from settings and mainMenu can be removed"), 12
|
||||
// tiles each (logged: the textures the menu thread asks for, by name): the
|
||||
// main menu GUI_UW_OPTION<1..12>.TGA with the sky above it UI_MAIN_SKY.TGA,
|
||||
// the options window UW_OPTION_1_<01..12>.TGA. The main menu's 3D scene
|
||||
// behind its picture is not drawn either (game::RemoveMenuScene).
|
||||
std::vector<uint32_t> PictureTextureIds() {
|
||||
std::vector<uint32_t> ids = {game::TextureId("UI_MAIN_SKY.TGA")};
|
||||
char name[40];
|
||||
for (int i = 1; i <= 12; ++i) {
|
||||
wsprintfA(name, "GUI_UW_OPTION%d.TGA", i);
|
||||
ids.push_back(game::TextureId(name));
|
||||
wsprintfA(name, "UW_OPTION_1_%02d.TGA", i);
|
||||
ids.push_back(game::TextureId(name));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Their surfaces (loaded now, as the window is about to draw them; the cache
|
||||
// may have made them anew) go to the HUD layer's hidden list.
|
||||
void HidePictures() {
|
||||
static const std::vector<uint32_t> ids = PictureTextureIds();
|
||||
std::vector<void*> surfaces;
|
||||
for (uint32_t id : ids) {
|
||||
if (void* surface = game::TextureSurface(id)) {
|
||||
surfaces.push_back(surface);
|
||||
}
|
||||
}
|
||||
hud::SetHiddenTextures(surfaces.data(), static_cast<int>(surfaces.size()));
|
||||
}
|
||||
|
||||
// The game's logo top left (user, 2026-10-21: "the bright blueish one"),
|
||||
// kLogoMargin from the edges: FX_SACREDLOGO.TGA, the emblem (horns around a
|
||||
// ring of runes) glowing blue on black, no alpha, added onto the background as
|
||||
// the game's effects draw it; the glow within u kLogoU0..U1 of the square
|
||||
// texture, the full height (measured on a screenshot of it drawn whole).
|
||||
constexpr char kLogoTexture[] = "FX_SACREDLOGO.TGA";
|
||||
constexpr float kLogoU0 = 0.13f, kLogoU1 = 0.87f;
|
||||
static_assert(kLogoWidth == static_cast<int>(kLogoHeight * (kLogoU1 - kLogoU0)));
|
||||
|
||||
void DrawLogo(void* device) {
|
||||
if (void* logo = game::TextureSurface(game::TextureId(kLogoTexture))) {
|
||||
const float edge = static_cast<float>(kLogoMargin);
|
||||
game::DrawTexturedAdded(device, logo, edge, edge, edge + kLogoWidth, edge + kLogoHeight, kLogoU0, 0, kLogoU1,
|
||||
1);
|
||||
}
|
||||
}
|
||||
|
||||
void DrawRockWall(void* device, float width, float height) {
|
||||
const float scale = std::max(width / (kBackgroundColumns * kBackgroundTile),
|
||||
height / (kBackgroundRows * kBackgroundTile));
|
||||
const float tile = kBackgroundTile * scale;
|
||||
const float left = (width - kBackgroundColumns * tile) / 2;
|
||||
const float top = (height - kBackgroundRows * tile) / 2;
|
||||
for (int row = 0; row < kBackgroundRows; ++row) {
|
||||
for (int column = 0; column < kBackgroundColumns; ++column) {
|
||||
char name[40];
|
||||
wsprintfA(name, "GUI_OPTION_BACKGROUND%d%d.TGA", row, column);
|
||||
if (void* surface = game::TextureSurface(game::TextureId(name))) {
|
||||
const float x = left + column * tile, y = top + row * tile;
|
||||
game::DrawTextured(device, surface, x, y, x + tile, y + tile, 0, 0, 1, 1, kBackgroundTint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} // namespace
|
||||
|
||||
bool BeginMenuFrame() {
|
||||
++g_menuFrame;
|
||||
std::erase_if(g_shown, [](const auto& entry) { return g_menuFrame - entry.second > kShownFrames; });
|
||||
return !g_shown.empty();
|
||||
}
|
||||
|
||||
void DrawBackground(void* device) {
|
||||
const float width = static_cast<float>(std::max(widescreen::RenderWidth(), kWidth));
|
||||
const float height = static_cast<float>(kHeight);
|
||||
HidePictures();
|
||||
game::FillRect(device, 0, 0, width, height, 0xff000000);
|
||||
DrawRockWall(device, width, height);
|
||||
game::FillVertical(device, 0, 0, width, height, kShadeTop, kShadeBottom);
|
||||
game::FillHorizontal(device, 0, 0, kVignetteWidth, height, kVignette, 0);
|
||||
game::FillHorizontal(device, width - kVignetteWidth, 0, width, height, 0, kVignette);
|
||||
DrawLogo(device);
|
||||
}
|
||||
|
||||
int Render(void* window, void* device, int (*body)(void* context), void* context) {
|
||||
const bool shown = game::ControlShown(window);
|
||||
NoteShown(window, shown);
|
||||
if (!shown || !device) {
|
||||
return 0;
|
||||
}
|
||||
++g_frame;
|
||||
DrawBackground(device);
|
||||
return hud::RenderCanvas(window, body, context);
|
||||
}
|
||||
|
||||
int Handle(void* event, int (*body)(void* context), void* context) {
|
||||
return hud::HandleCanvas(event, body, context);
|
||||
}
|
||||
|
||||
void Fill(void* device, const UiRect& r, uint32_t colour) {
|
||||
game::FillRect(device, static_cast<float>(r.x), static_cast<float>(r.y), static_cast<float>(r.x + r.w),
|
||||
static_cast<float>(r.y + r.h), colour);
|
||||
}
|
||||
|
||||
void Text(void* device, const UiRect& rect, const std::wstring& text, uint32_t colour, uint32_t flags) {
|
||||
game::DrawText(device, kFont, game::CenterLineVertically(kFont, rect), text.c_str(), colour, flags);
|
||||
}
|
||||
|
||||
UiRect Inset(const UiRect& r, int by) {
|
||||
return {r.x + by, r.y + by, static_cast<int16_t>(r.w - 2 * by), static_cast<int16_t>(r.h - 2 * by)};
|
||||
}
|
||||
|
||||
UiRect Panel::Draw(void* device, const UiRect& rect, const std::wstring& heading) {
|
||||
if (!frame_ || rect.x != rect_.x || rect.y != rect_.y || rect.w != rect_.w || rect.h != rect_.h) {
|
||||
if (frame_) {
|
||||
game::DeleteControl(frame_);
|
||||
}
|
||||
frame_ = game::NewFrame(name_, rect);
|
||||
rect_ = rect;
|
||||
}
|
||||
Fill(device, rect, kPanelBackdrop);
|
||||
if (frame_) {
|
||||
game::ControlRender(frame_, device);
|
||||
}
|
||||
UiRect content = Inset(rect, kPanelPadding);
|
||||
if (!heading.empty()) {
|
||||
Text(device, {content.x, content.y, content.w, kHeadingHeight}, heading, kHeadingColour);
|
||||
content.y += kHeadingHeight;
|
||||
content.h = static_cast<int16_t>(content.h - kHeadingHeight);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
void Button::Draw(void* device, const uint8_t* model, const UiRect& rect) {
|
||||
if (!control_) {
|
||||
captionId_ = game::RegisterText(caption_.c_str());
|
||||
control_ = game::NewButtonLike(model, rect, captionId_, name_);
|
||||
if (!control_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
rect_ = rect;
|
||||
*reinterpret_cast<UiRect*>(control_ + game::kControlRect) = rect;
|
||||
game::ControlRender(control_, device);
|
||||
drawnFrame_ = g_frame;
|
||||
}
|
||||
|
||||
bool Button::Clicked(void* event) {
|
||||
if (!control_ || drawnFrame_ != g_frame) {
|
||||
return false;
|
||||
}
|
||||
game::ControlHandleEvent(control_, event);
|
||||
return game::MouseEventKind(event) == game::MouseEvent::ButtonDown &&
|
||||
game::EventField(event, game::kMouseButton) == game::kLeftButton &&
|
||||
game::Inside(rect_, game::EventField(event, game::kMouseButtonX),
|
||||
game::EventField(event, game::kMouseButtonY));
|
||||
}
|
||||
|
||||
void Button::SetCaption(const std::wstring& caption) {
|
||||
caption_ = caption;
|
||||
if (captionId_) {
|
||||
game::SetTextOverride(captionId_, caption_);
|
||||
}
|
||||
}
|
||||
|
||||
int16_t ButtonHeight(const uint8_t* model) {
|
||||
return reinterpret_cast<const UiRect*>(model + game::kControlRect)->h;
|
||||
}
|
||||
|
||||
bool DoubleClick::Click(int target) {
|
||||
const unsigned long now = GetTickCount();
|
||||
const bool twice = target == last_ && now - at_ <= GetDoubleClickTime();
|
||||
last_ = twice ? -1 : target;
|
||||
at_ = now;
|
||||
return twice;
|
||||
}
|
||||
|
||||
|
||||
} // namespace mods::screen
|
||||
|
||||
// The main menu (cUI_Manager's UI render 0x7587b0, thiscall (1 argument), ret
|
||||
// 4, from the menu thread loop 0x756aa1 and the in-game render 0x60ea74): on
|
||||
// the menu thread the background goes first, then the menu's 1024x768 frame
|
||||
// is drawn centred over it (hud::RenderMenuFrame); a 16:9 screen draws its own.
|
||||
namespace game {
|
||||
|
||||
uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument);
|
||||
RE_HOOK(0x007587b0, UiManager_Render);
|
||||
uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument) {
|
||||
using namespace mods;
|
||||
if (!widescreen::IsMenuThread() || widescreen::IsLoadingScreen() || widescreen::RenderWidth() == 0) {
|
||||
return UiManager_Render_original(self, edx, argument);
|
||||
}
|
||||
auto* driver = *reinterpret_cast<DxDriver7**>(addr::g_dxDriver);
|
||||
void* device = driver ? driver->device : nullptr;
|
||||
const bool framed = !screen::BeginMenuFrame();
|
||||
if (framed && device) {
|
||||
screen::DrawBackground(device);
|
||||
}
|
||||
struct Call {
|
||||
void* self;
|
||||
void* edx;
|
||||
uint32_t argument;
|
||||
uint32_t result;
|
||||
} call{self, edx, argument, 0};
|
||||
hud::RenderMenuFrame(
|
||||
device, framed,
|
||||
[](void* context) {
|
||||
auto* c = static_cast<Call*>(context);
|
||||
c->result = UiManager_Render_original(c->self, c->edx, c->argument);
|
||||
return 0;
|
||||
},
|
||||
&call);
|
||||
return call.result;
|
||||
}
|
||||
|
||||
|
||||
// cUI_MainMenu::Render (vf14 0x7122c0) draws a 3D scene (sky UI_UW_MAIN_CENTER,
|
||||
// water UI_MAIN_REF; 0x75e620, thiscall (1 argument), ret 4, on the scene
|
||||
// object 0x75df10) behind its picture: the call at 0x712bd9 goes nowhere.
|
||||
constexpr uint32_t kMainMenuSceneCall = 0x00712bd9;
|
||||
constexpr uint32_t kRenderMenuScene = 0x0075e620;
|
||||
|
||||
void __fastcall SkipMenuScene(void*, void*, uint32_t) {}
|
||||
|
||||
void RemoveMenuScene() {
|
||||
re::RedirectCall(kMainMenuSceneCall, kRenderMenuScene, reinterpret_cast<const void*>(&SkipMenuScene));
|
||||
}
|
||||
RE_STARTUP(MenuSceneStartup, RemoveMenuScene);
|
||||
|
||||
} // namespace game
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user