Compare commits
36
Commits
70673dc301
..
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 |
No files matched your search
@@ -6,3 +6,4 @@
|
|||||||
sacredK.ini
|
sacredK.ini
|
||||||
/ghidra/
|
/ghidra/
|
||||||
/screenshots/
|
/screenshots/
|
||||||
|
*.stackdump
|
||||||
Vendored
+4
-2
@@ -3,13 +3,15 @@
|
|||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
// Builds, then starts the game through the launcher (no debugger in the game).
|
// Builds, then starts the game through the launcher (no debugger in the game).
|
||||||
|
// 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)",
|
"name": "Run Sacred (sacredK)",
|
||||||
"type": "cppvsdbg",
|
"type": "cppvsdbg",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/build/x86-debug/sacredK.exe",
|
"program": "${workspaceFolder}/build/x86-debug/sacredK.exe",
|
||||||
"args": ["--game-dir", "D:\\Gaming\\Sacred Gold", "log"],
|
"args": ["log"],
|
||||||
"cwd": "${workspaceFolder}/build/x86-debug",
|
"cwd": "${workspaceFolder}/build/x86-debug",
|
||||||
"preLaunchTask": "CMake: build",
|
"preLaunchTask": "Build x86-debug",
|
||||||
"console": "internalConsole"
|
"console": "internalConsole"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+7
-4
@@ -2,10 +2,13 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "CMake: build",
|
// build.ps1 enters the MSVC x86 environment and builds the x86-debug
|
||||||
"type": "cmake",
|
// preset; unlike a CMake Tools task it needs no preset picked in VS Code.
|
||||||
"command": "build",
|
"label": "Build x86-debug",
|
||||||
"targets": ["all"],
|
"type": "process",
|
||||||
|
"command": "powershell",
|
||||||
|
"args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "${workspaceFolder}/build.ps1", "x86-debug"],
|
||||||
|
"options": { "cwd": "${workspaceFolder}" },
|
||||||
"group": { "kind": "build", "isDefault": true },
|
"group": { "kind": "build", "isDefault": true },
|
||||||
"problemMatcher": ["$msCompile"]
|
"problemMatcher": ["$msCompile"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ the work so far is **mods** — behaviour changes on top of the original
|
|||||||
auto-attack, skill reworks, respec, map fixes, ...). The game stays playable
|
auto-attack, skill reworks, respec, map fixes, ...). The game stays playable
|
||||||
at every step; the user plays it to test.
|
at every step; the user plays it to test.
|
||||||
|
|
||||||
- Target binary: the original `sacred.exe` in `D:\Gaming\Sacred Gold\`
|
- Target binary: the original `sacred.exe` of a local Sacred Gold 2.28
|
||||||
(path in `build\x86-debug\sacredK.ini`, `[paths] GameDir`). Ignore the
|
installation (see `README.md`, Supported build). The game is not in the
|
||||||
`SacredReborn*` files in that folder.
|
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 texts are German (Fertigkeiten, Attribute, Blitzschlag, ...); keep the
|
||||||
game's names when talking about features.
|
game's names when talking about features.
|
||||||
- The user's monitor is 3440x1440; the game renders about 1835x768 scaled up.
|
- The user's monitor is 3440x1440; the game renders about 1835x768 scaled up.
|
||||||
@@ -141,6 +143,6 @@ into a breakpoint exception (0x80000003, "ESP was not properly saved").
|
|||||||
## Memory
|
## Memory
|
||||||
|
|
||||||
Project memory lives in
|
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
|
(`project-status.md`: what is done and confirmed, what is open). Update it
|
||||||
after commits and before long pauses.
|
after commits and before long pauses.
|
||||||
+19
-1
@@ -39,6 +39,7 @@ add_library(sacredK SHARED
|
|||||||
src/game/engine/stream.cpp
|
src/game/engine/stream.cpp
|
||||||
src/game/ui/texts.cpp
|
src/game/ui/texts.cpp
|
||||||
src/game/engine/art.cpp
|
src/game/engine/art.cpp
|
||||||
|
src/mods/monitor.cpp
|
||||||
src/mods/borderless.cpp
|
src/mods/borderless.cpp
|
||||||
src/mods/widescreen.cpp
|
src/mods/widescreen.cpp
|
||||||
src/mods/hud.cpp
|
src/mods/hud.cpp
|
||||||
@@ -59,6 +60,7 @@ add_library(sacredK SHARED
|
|||||||
src/mods/combos.cpp
|
src/mods/combos.cpp
|
||||||
src/mods/aurahits.cpp
|
src/mods/aurahits.cpp
|
||||||
src/mods/chainlightning.cpp
|
src/mods/chainlightning.cpp
|
||||||
|
src/mods/dwarfarts.cpp
|
||||||
src/mods/arttooltip.cpp
|
src/mods/arttooltip.cpp
|
||||||
src/mods/escape.cpp
|
src/mods/escape.cpp
|
||||||
src/mods/recordcache.cpp
|
src/mods/recordcache.cpp
|
||||||
@@ -67,8 +69,24 @@ add_library(sacredK SHARED
|
|||||||
src/mods/runetrade.cpp
|
src/mods/runetrade.cpp
|
||||||
src/mods/teleportpotion.cpp
|
src/mods/teleportpotion.cpp
|
||||||
src/mods/guards.cpp
|
src/mods/guards.cpp
|
||||||
src/mods/charexport.cpp
|
|
||||||
src/mods/options.cpp
|
src/mods/options.cpp
|
||||||
|
src/mods/lootfilter.cpp
|
||||||
|
src/mods/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(sacredK PRIVATE src)
|
target_include_directories(sacredK PRIVATE src)
|
||||||
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
|
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
|
||||||
|
|||||||
@@ -41,11 +41,24 @@ the CMake presets.
|
|||||||
|
|
||||||
## Running
|
## 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
|
```powershell
|
||||||
build\x86-debug\sacredK.exe --game-dir "D:\Gaming\Sacred Gold" # first run; remembered in sacredK.ini
|
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
|
build\x86-debug\sacredK.exe log # later runs; extra args go to the game
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
`sacredK.log` (next to the DLL) records what was hooked plus every message the
|
||||||
game sends to its internal logger, even when the game's own logging is off.
|
game sends to its internal logger, even when the game's own logging is off.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Character export screen (`src/mods/charexport.cpp`)
|
|
||||||
|
|
||||||
Request (2026-10-18): the export screen (Esc menu "Exportieren") was not
|
|
||||||
adapted to widescreen (layout and clicks shifted), slots could not be
|
|
||||||
cleared or overwritten; the screen and the save game window need black
|
|
||||||
beside the background picture.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
See `hud.md`, "Full-screen windows": the 3D scene was stretched across the
|
|
||||||
wide viewport; the window now draws with a viewport on the centred frame.
|
|
||||||
|
|
||||||
## cUI_Character
|
|
||||||
|
|
||||||
One window for the main menu's character selection / import and the export
|
|
||||||
screen (vtable 0x894fa8, render vf44 0x70f160, events vf10 0x70e860).
|
|
||||||
|
|
||||||
| Field | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| +0x390 | mode: 4 selection, 5 export, 6 overwrite confirmation (7 import) |
|
|
||||||
| +0x394 | previous mode |
|
|
||||||
| +0x1b4 | 8 slot controls; hero id at +0x2f8 (0: empty) |
|
|
||||||
| +0x1d6 | selected slot (char, -1 none) |
|
|
||||||
| +0x3b4 | erase button ("UI_CHAR_ERASE_TOOLTIP") |
|
|
||||||
|
|
||||||
| Address | Function |
|
|
||||||
|---|---|
|
|
||||||
| 0x70c760 | SetMode (mode), ret 4; entering mode 5 reloads the heroes |
|
|
||||||
| 0x70c440 | SelectSlot (char slot), ret 4; shows the erase button in modes 4 / 7 only |
|
|
||||||
| 0x70d0f0 | ExecuteAction (action, slot, extra), ret 0xc: 6 export into slot (`Save\Hero%.2d.pax`), 9 export with confirmation if occupied |
|
|
||||||
| 0x70e650 | right-hand button: in mode 5 action 6 (empty slot) or 9 (occupied) |
|
|
||||||
| 0x7f01a0 | HeroSlots_DeleteHero (slot + 1), thiscall on 0x7d7c40(), ret 4 |
|
|
||||||
|
|
||||||
## Overwrite
|
|
||||||
|
|
||||||
Measured with a log: exporting to an occupied slot was already the game's own
|
|
||||||
path (action 9 -> mode 6 confirmation -> action 6). It only failed because
|
|
||||||
clicks hit the wrong place before the layout fix.
|
|
||||||
|
|
||||||
## Clear
|
|
||||||
|
|
||||||
The erase button is created for every mode, and its click handler (vf10)
|
|
||||||
does not check the mode: it opens the game's confirmation
|
|
||||||
(UI_CHAR_ERASE_CONFIRM, cUI_BusyDlg), whose answer (0x72087b) calls
|
|
||||||
DeleteHero. The mod shows the button in export mode whenever an occupied slot
|
|
||||||
is selected (after SetMode / SelectSlot). After DeleteHero, if the screen is
|
|
||||||
in export mode, it enters export mode again (reloads the heroes) and clears
|
|
||||||
the selection; otherwise the erased hero stayed until the next export.
|
|
||||||
@@ -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.
|
||||||
+15
@@ -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 |
|
| 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 |
|
| 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: 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 |
|
| 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:
|
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
|
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.
|
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)
|
## Viewports (cursor in the quest book, 2026-10-17)
|
||||||
|
|
||||||
`SetViewport` inside a moved window is shifted like its vertices. The quest
|
`SetViewport` inside a moved window is shifted like its vertices. The quest
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -109,3 +109,55 @@ Request: M (map), Tab (minimap) and Alt (text overlay) bindable too.
|
|||||||
instead: `LabelsKeyState` returns the bound key's state.
|
instead: `LabelsKeyState` returns the bound key's state.
|
||||||
- Alt and F10 come as WM_SYSKEYDOWN: the key capture takes those too.
|
- 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.
|
- 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).
|
||||||
+17
-1
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
The chest gets a second tab that belongs to no single hero: a chest shared by
|
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
|
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
|
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;
|
(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
|
use the inventory's Rucksack / Kampfkünste / Combos header look with its
|
||||||
@@ -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`,
|
- loadHero's extra fix-up for some item classes (`0x5beaa0` / `0x5d3a20`,
|
||||||
item classes 5, 6, 8, 0xd, 0x11..0x19, 0x1b, 0x1d) is not repeated.
|
item classes 5, 6, 8, 0xd, 0x11..0x19, 0x1b, 0x1d) is not repeated.
|
||||||
- Multiplayer: not supported.
|
- Multiplayer: not supported.
|
||||||
|
|
||||||
|
## Items replaced by a world's (fixed 2026-10-21)
|
||||||
|
|
||||||
|
Reported: the shared chest's items were gone, other items (potions, gear) in
|
||||||
|
their cells. The grid holds object ids. A save game loaded into the running
|
||||||
|
engine (Esc "Laden", cEngine::Load from the render loop 0x60e68d) replaces
|
||||||
|
all objects, so until the chest reloads on the next tick its ids name the new
|
||||||
|
world's objects. A hero saved inside that load (characters.cpp Engine_Load: a
|
||||||
|
migrated character's first load makes its world hero the shared hero with
|
||||||
|
SaveHero) wrote the shared chest, i.e. those objects, into
|
||||||
|
shared_chest.json; the next tick loaded them as the chest. Now the chest is
|
||||||
|
emptied and marked unloaded as soon as a save game is read or a game starts
|
||||||
|
(RequestLoad), so no save writes it before it is loaded again. The user's
|
||||||
|
chest was restored from the 2026-10-19 backup (the broken file kept as
|
||||||
|
backup/shared_chest.broken-2026-10-21.json).
|
||||||
@@ -189,3 +189,13 @@ only (or disabled in multiplayer).
|
|||||||
table for the extra controls), layout, rendering, hit tests, drag & drop.
|
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
|
6. Keys: 0-6 cast art slots (since 2026-10-15; before: 1-7 select and
|
||||||
cast), F1-F7 select weapon sets.
|
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.
|
||||||
@@ -32,3 +32,11 @@ one of them.
|
|||||||
- The potion is used up (flag, RemoveItem, DestroyObject as UseItem does; a
|
- The potion is used up (flag, RemoveItem, DestroyObject as UseItem does; a
|
||||||
stack loses one) only when cCreature::Teleport is called from the portal
|
stack loses one) only when cCreature::Teleport is called from the portal
|
||||||
case while it is pending; closing the window keeps it.
|
case while it is pending; closing the window keeps it.
|
||||||
|
|
||||||
|
## Campaign (2026-10-21)
|
||||||
|
|
||||||
|
The potion showed Ancaria's portals in the Underworld. cInterpretSQW::Teleporter
|
||||||
|
(0x4ae330) chooses the busy dialog's mode by the portal touched: index > 0xd
|
||||||
|
(Underworld) mode 6, else mode 5, both with the hero's known-portal mask
|
||||||
|
(+0x578). The potion now chooses by the running campaign, g_campaign
|
||||||
|
(0x182cdc8, u16: 0 Ancaria, 1 Underworld): mode 6 in the Underworld.
|
||||||
+118
@@ -166,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
|
standing still. Not reproduced yet: a load log (hero mode, action, animation
|
||||||
fields, orders) showed nothing unusual; right after a load the game itself
|
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.
|
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.
|
||||||
+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 |
|
| `0x006469d0` | loading screen: GDI BitBlt of bitmap + progress bar + text into the back buffer (fixed 1024x768) | render thread |
|
||||||
| `0x006a0c60` | video/intro playback (unexamined) | |
|
| `0x006a0c60` | video/intro playback (unexamined) | |
|
||||||
|
|
||||||
The widescreen mod gives these frames a 1024x768 D3D viewport (set at
|
Since 2026-10-25 the loading screen uses the full render width too: the menu
|
||||||
`BeginScene` when the scene depth `+0x1d8` is 0) and the borderless mod
|
background is drawn across it and the game's GDI drawing is centred
|
||||||
presents only that area, scaled to the monitor.
|
(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
|
## Direct3D 7 render target limit
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ constexpr uint32_t GetMouseClientPos = 0x0066e500;
|
|||||||
constexpr uint32_t MainWindowProc = 0x008122a0;
|
constexpr uint32_t MainWindowProc = 0x008122a0;
|
||||||
constexpr uint32_t DxDriver7_SetDisplayMode = 0x00645390;
|
constexpr uint32_t DxDriver7_SetDisplayMode = 0x00645390;
|
||||||
constexpr uint32_t DxDriver7_Flip = 0x00645f70;
|
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 DxDriver7_LoadingScreen = 0x006469d0; // GDI progress screen, 1024x768
|
||||||
constexpr uint32_t UiManager_ThreadLoop = 0x00756940; // main menu render loop (own thread)
|
constexpr uint32_t UiManager_ThreadLoop = 0x00756940; // main menu render loop (own thread)
|
||||||
constexpr uint32_t Engine_RenderThreadLoop = 0x0060e3f0; // in-game render loop
|
constexpr uint32_t Engine_RenderThreadLoop = 0x0060e3f0; // in-game render loop
|
||||||
@@ -28,6 +29,7 @@ constexpr uint32_t WorldToScreen = 0x00623c40;
|
|||||||
// --- globals -----------------------------------------------------------------
|
// --- globals -----------------------------------------------------------------
|
||||||
constexpr uint32_t g_dxDriver = 0x00cda99c; // DxDriver7*
|
constexpr uint32_t g_dxDriver = 0x00cda99c; // DxDriver7*
|
||||||
constexpr uint32_t g_appFlags = 0x0182cdb8; // uint32_t, 0x20000 = inactive
|
constexpr uint32_t g_appFlags = 0x0182cdb8; // uint32_t, 0x20000 = inactive
|
||||||
|
constexpr uint32_t g_campaign = 0x0182cdc8; // uint16_t, the running game's campaign: 0 Ancaria, 1 Underworld
|
||||||
constexpr uint32_t g_mouseCursor = 0x00cdbadc; // mouse cursor object*: x +4, y +8 (render pixels)
|
constexpr uint32_t g_mouseCursor = 0x00cdbadc; // mouse cursor object*: x +4, y +8 (render pixels)
|
||||||
|
|
||||||
// --- read-only constants -----------------------------------------------------
|
// --- read-only constants -----------------------------------------------------
|
||||||
|
|||||||
+14
-3
@@ -86,8 +86,9 @@ constexpr uint16_t kArtHeldenmut = 0x3f7;
|
|||||||
constexpr uint16_t kArtKlingenblick = 0x3f8;
|
constexpr uint16_t kArtKlingenblick = 0x3f8;
|
||||||
constexpr uint16_t kArtEnergieblitze = 0x3fb;
|
constexpr uint16_t kArtEnergieblitze = 0x3fb;
|
||||||
constexpr uint16_t kArtFlammenwerfer = 0x43d;
|
constexpr uint16_t kArtFlammenwerfer = 0x43d;
|
||||||
constexpr uint16_t kArtMeteor = 0x43e;
|
constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
|
||||||
constexpr uint16_t kArtKanone = 0x43f;
|
constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone)
|
||||||
|
constexpr uint16_t kArtEingraben = 0x39; // spell (gui_moves_eingraben)
|
||||||
|
|
||||||
// cCreatureArts fields.
|
// cCreatureArts fields.
|
||||||
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
|
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
|
||||||
@@ -106,7 +107,7 @@ constexpr uint32_t kRegenGroupCombo = 3; // handled by the combo branch
|
|||||||
// Arts regenerating in the magic group whatever their entry says
|
// Arts regenerating in the magic group whatever their entry says
|
||||||
// (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze).
|
// (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze).
|
||||||
inline bool InMagicRegenGroup(uint32_t group, uint16_t art) {
|
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;
|
art == kArtKanone;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +161,16 @@ inline bool EntryAtMagicRate(const uint8_t* entry) {
|
|||||||
*reinterpret_cast<const uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
|
*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 {
|
struct ArtCooldown {
|
||||||
float remaining = 0.0f; // in the game's units
|
float remaining = 0.0f; // in the game's units
|
||||||
float total = 0.0f;
|
float total = 0.0f;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -60,6 +60,69 @@ inline uint32_t ItemType(const void* item) {
|
|||||||
return *reinterpret_cast<const uint32_t*>(static_cast<const uint8_t*>(item) + kItemType);
|
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).
|
// The potion kind of object `id`, 0 if it is no potion (or gone).
|
||||||
inline int PotionKindOf(uint32_t id) {
|
inline int PotionKindOf(uint32_t id) {
|
||||||
const void* item = id ? GetObjectById(id) : nullptr;
|
const void* item = id ? GetObjectById(id) : nullptr;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ inline bool IsPlayerHeroId(uint32_t id) {
|
|||||||
// manager (saving marks effect objects dead, cObjectManager::Place refuses them).
|
// manager (saving marks effect objects dead, cObjectManager::Place refuses them).
|
||||||
constexpr size_t kObjectFlags = 0x14;
|
constexpr size_t kObjectFlags = 0x14;
|
||||||
constexpr uint32_t kObjectDead = 0x10;
|
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, ?}
|
// The creatures near the player (g_nearbyCreatures 0xad1c60: {object id, ?}
|
||||||
// x g_nearbyCreatureCount 0xad1b48), as the game uses them for aura targets.
|
// 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 kTypeCreature = 0x008e9660;
|
||||||
constexpr uint32_t kTypeControl = 0x009de168;
|
constexpr uint32_t kTypeControl = 0x009de168;
|
||||||
constexpr uint32_t kTypeEditControl = 0x009e3608;
|
constexpr uint32_t kTypeEditControl = 0x009e3608;
|
||||||
|
constexpr uint32_t kTypeItemBase = 0x0094cbc0;
|
||||||
|
|
||||||
// __RTDynamicCast (0x849701).
|
// __RTDynamicCast (0x849701).
|
||||||
inline void* DynamicCast(void* object, uint32_t sourceType, uint32_t targetType) {
|
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
|
// cObjectManager::DestroyObject (0x5fbe10), with the arguments cCreature::UseItem
|
||||||
// destroys a drunk potion with (id, 1, 0, 0).
|
// destroys a drunk potion with (id, 1, 0, 0); the loot drop (0x559a30) an
|
||||||
inline void DestroyObject(uint32_t id) {
|
// 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);
|
using Fn = uint32_t(__fastcall*)(void* manager, void* edx, uint32_t, uint32_t, uint32_t, uint32_t);
|
||||||
if (void* manager = ObjectManager()) {
|
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.
|
// The object `id` as a cCreature, or null.
|
||||||
inline void* GetCreature(uint32_t id) {
|
inline void* GetCreature(uint32_t id) {
|
||||||
return DynamicCast(GetObjectById(id), kTypeObject, kTypeCreature);
|
return DynamicCast(GetObjectById(id), kTypeObject, kTypeCreature);
|
||||||
|
|||||||
@@ -15,8 +15,17 @@ std::vector<CreatureOrderFn>& Callbacks() {
|
|||||||
return callbacks;
|
return callbacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<CreatureOrderFilter>& Filters() {
|
||||||
|
static std::vector<CreatureOrderFilter> filters;
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
void FilterCreatureOrders(CreatureOrderFilter filter) {
|
||||||
|
Filters().push_back(filter);
|
||||||
|
}
|
||||||
|
|
||||||
void OnCreatureOrder(CreatureOrderFn callback) {
|
void OnCreatureOrder(CreatureOrderFn callback) {
|
||||||
Callbacks().push_back(callback);
|
Callbacks().push_back(callback);
|
||||||
}
|
}
|
||||||
@@ -26,6 +35,11 @@ void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* orde
|
|||||||
RE_HOOK(0x0052e400, Creature_ReceiveOrder);
|
RE_HOOK(0x0052e400, Creature_ReceiveOrder);
|
||||||
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) {
|
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) {
|
||||||
if (self && order) {
|
if (self && order) {
|
||||||
|
for (CreatureOrderFilter filter : Filters()) {
|
||||||
|
if (!filter(self, *order)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
void* caller = _ReturnAddress();
|
void* caller = _ReturnAddress();
|
||||||
for (CreatureOrderFn callback : Callbacks()) {
|
for (CreatureOrderFn callback : Callbacks()) {
|
||||||
callback(self, *order, caller);
|
callback(self, *order, caller);
|
||||||
|
|||||||
@@ -63,11 +63,70 @@ inline void* ControlledCreature(uint8_t* engine) {
|
|||||||
using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller);
|
using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller);
|
||||||
void OnCreatureOrder(CreatureOrderFn callback);
|
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.
|
// Creature fields.
|
||||||
constexpr size_t kCreatureId = 0xc;
|
constexpr size_t kCreatureId = 0xc;
|
||||||
constexpr size_t kCreatureX = 0x1c;
|
constexpr size_t kCreatureX = 0x1c;
|
||||||
constexpr size_t kCreatureY = 0x20;
|
constexpr size_t kCreatureY = 0x20;
|
||||||
|
|
||||||
|
// 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 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
|
// 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.
|
// step's sub-unit remainder (x, y, 1/1024 units) at +0x37d / +0x381.
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -2,13 +2,54 @@
|
|||||||
// cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still
|
// cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still
|
||||||
// renders but handles no events).
|
// renders but handles no events).
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace game {
|
namespace game {
|
||||||
|
|
||||||
constexpr size_t kControlFlagsOffset = 0x10;
|
constexpr size_t kControlFlagsOffset = 0x10;
|
||||||
constexpr uint32_t kControlFlagVisible = 1;
|
constexpr uint32_t kControlFlagVisible = 1;
|
||||||
|
constexpr size_t kControlName = 0x30; // char[], set by 0x732090 (e.g. "UI_LABEL_NEWGAME")
|
||||||
|
|
||||||
|
inline bool ControlNamed(const void* control, const char* name) {
|
||||||
|
return std::strcmp(static_cast<const char*>(control) + kControlName, name) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows keep their lines / entries in game vectors of control pointers
|
||||||
|
// {begin, end, capacity end} at `offset` of the window.
|
||||||
|
inline std::vector<uint8_t*> ControlsIn(void* window, size_t offset) {
|
||||||
|
auto* field = static_cast<uint8_t*>(window) + offset;
|
||||||
|
auto** begin = *reinterpret_cast<uint8_t***>(field);
|
||||||
|
auto** end = *reinterpret_cast<uint8_t***>(field + 4);
|
||||||
|
return std::vector<uint8_t*>(begin, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Takes `control` out of the vector (the order of the others stays).
|
||||||
|
inline void RemoveControl(void* window, size_t offset, const void* control) {
|
||||||
|
auto* field = static_cast<uint8_t*>(window) + offset;
|
||||||
|
auto** begin = *reinterpret_cast<uint8_t***>(field);
|
||||||
|
auto**& end = *reinterpret_cast<uint8_t***>(field + 4);
|
||||||
|
auto** found = std::find(begin, end, control);
|
||||||
|
if (found != end) {
|
||||||
|
std::memmove(found, found + 1, (end - found - 1) * sizeof(*found));
|
||||||
|
--end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaces the vector's entry `index`.
|
||||||
|
inline void SetControlAt(void* window, size_t offset, size_t index, uint8_t* control) {
|
||||||
|
(*reinterpret_cast<uint8_t***>(static_cast<uint8_t*>(window) + offset))[index] = control;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroys a control made with the game's allocator (vf00, the scalar deleting
|
||||||
|
// destructor, e.g. cUI_StaticText64 0x754680); out of every list first.
|
||||||
|
inline void DeleteControl(void* control) {
|
||||||
|
using DeleteFn = void*(__fastcall*)(void*, void*, int);
|
||||||
|
reinterpret_cast<DeleteFn>((*static_cast<void***>(control))[0])(control, nullptr, 1);
|
||||||
|
}
|
||||||
|
|
||||||
inline bool ControlShown(const void* control) {
|
inline bool ControlShown(const void* control) {
|
||||||
return (static_cast<const uint8_t*>(control)[kControlFlagsOffset] & kControlFlagVisible) != 0;
|
return (static_cast<const uint8_t*>(control)[kControlFlagsOffset] & kControlFlagVisible) != 0;
|
||||||
|
|||||||
+84
-6
@@ -10,6 +10,12 @@ namespace game {
|
|||||||
|
|
||||||
constexpr int kDeviceDrawPrimitive = 25; // IDirect3DDevice7 vtable slots
|
constexpr int kDeviceDrawPrimitive = 25; // IDirect3DDevice7 vtable slots
|
||||||
constexpr int kDeviceSetTexture = 35;
|
constexpr int kDeviceSetTexture = 35;
|
||||||
|
constexpr int kDeviceSetRenderState = 20;
|
||||||
|
constexpr int kDeviceGetRenderState = 21;
|
||||||
|
constexpr DWORD kStateSourceBlend = 19; // D3DRENDERSTATETYPE
|
||||||
|
constexpr DWORD kStateDestinationBlend = 20;
|
||||||
|
constexpr DWORD kStateAlphaBlendEnable = 27;
|
||||||
|
constexpr DWORD kBlendOne = 2; // D3DBLEND
|
||||||
constexpr DWORD kTriangleStrip = 5;
|
constexpr DWORD kTriangleStrip = 5;
|
||||||
constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1
|
constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1
|
||||||
|
|
||||||
@@ -19,18 +25,90 @@ struct ScreenVertex {
|
|||||||
float u, v;
|
float u, v;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fills (x0, y0)-(x1, y1) with the ARGB colour `color`.
|
// Draws (x0, y0)-(x1, y1) with `surface` (a texture's Direct3D surface,
|
||||||
inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) {
|
// game/ui/texture.h) from (u0, v0) to (u1, v1), tinted by `colour`.
|
||||||
|
inline void DrawTextured(void* device, void* surface, float x0, float y0, float x1, float y1, float u0, float v0,
|
||||||
|
float u1, float v1, uint32_t colour = 0xffffffff) {
|
||||||
|
void** vtable = *static_cast<void***>(device);
|
||||||
|
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
|
||||||
|
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
|
||||||
|
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, surface);
|
||||||
|
ScreenVertex quad[4] = {{x0, y0, 0, 1, colour, 0, u0, v0},
|
||||||
|
{x1, y0, 0, 1, colour, 0, u1, v0},
|
||||||
|
{x0, y1, 0, 1, colour, 0, u0, v1},
|
||||||
|
{x1, y1, 0, 1, colour, 0, u1, v1}};
|
||||||
|
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture,
|
||||||
|
quad, 4, 0);
|
||||||
|
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawTextured added onto what is drawn (source and destination blend ONE):
|
||||||
|
// for the effect textures, drawn on black without alpha.
|
||||||
|
inline void DrawTexturedAdded(void* device, void* surface, float x0, float y0, float x1, float y1, float u0,
|
||||||
|
float v0, float u1, float v1) {
|
||||||
|
void** vtable = *static_cast<void***>(device);
|
||||||
|
using SetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD);
|
||||||
|
using GetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD*);
|
||||||
|
const auto set = reinterpret_cast<SetRenderStateFn>(vtable[kDeviceSetRenderState]);
|
||||||
|
const auto get = reinterpret_cast<GetRenderStateFn>(vtable[kDeviceGetRenderState]);
|
||||||
|
DWORD blending = 0, source = 0, destination = 0;
|
||||||
|
get(device, kStateAlphaBlendEnable, &blending);
|
||||||
|
get(device, kStateSourceBlend, &source);
|
||||||
|
get(device, kStateDestinationBlend, &destination);
|
||||||
|
set(device, kStateAlphaBlendEnable, TRUE);
|
||||||
|
set(device, kStateSourceBlend, kBlendOne);
|
||||||
|
set(device, kStateDestinationBlend, kBlendOne);
|
||||||
|
DrawTextured(device, surface, x0, y0, x1, y1, u0, v0, u1, v1);
|
||||||
|
set(device, kStateAlphaBlendEnable, blending);
|
||||||
|
set(device, kStateSourceBlend, source);
|
||||||
|
set(device, kStateDestinationBlend, destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fills (x0, y0)-(x1, y1), ARGB colours per corner: top left, top right,
|
||||||
|
// bottom left, bottom right (blended between them).
|
||||||
|
inline void FillCorners(void* device, float x0, float y0, float x1, float y1, uint32_t topLeft, uint32_t topRight,
|
||||||
|
uint32_t bottomLeft, uint32_t bottomRight) {
|
||||||
void** vtable = *static_cast<void***>(device);
|
void** vtable = *static_cast<void***>(device);
|
||||||
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
|
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
|
||||||
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
|
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
|
||||||
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
|
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
|
||||||
ScreenVertex quad[4] = {{x0, y0, 0, 1, color, 0, 0, 0},
|
ScreenVertex quad[4] = {{x0, y0, 0, 1, topLeft, 0, 0, 0},
|
||||||
{x1, y0, 0, 1, color, 0, 1, 0},
|
{x1, y0, 0, 1, topRight, 0, 1, 0},
|
||||||
{x0, y1, 0, 1, color, 0, 0, 1},
|
{x0, y1, 0, 1, bottomLeft, 0, 0, 1},
|
||||||
{x1, y1, 0, 1, color, 0, 1, 1}};
|
{x1, y1, 0, 1, bottomRight, 0, 1, 1}};
|
||||||
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture,
|
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture,
|
||||||
quad, 4, 0);
|
quad, 4, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
} // 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
|
||||||
+10
-2
@@ -156,11 +156,16 @@ void SetTextOverride(uint32_t id, const std::wstring& text) {
|
|||||||
entry->string = {begin, begin + entry->text.size(), begin + entry->text.size()};
|
entry->string = {begin, begin + entry->text.size(), begin + entry->text.size()};
|
||||||
}
|
}
|
||||||
|
|
||||||
std::wstring GameTextByKey(const char* key) {
|
uint32_t GameTextIdByKey(const char* key) {
|
||||||
// The key's hash (0x80e010, cdecl), looked up "by key" as GetTextByKey does.
|
// The key's hash (0x80e010, cdecl), looked up "by key" as GetTextByKey does.
|
||||||
using HashFn = uint32_t(__cdecl*)(const char*);
|
using HashFn = uint32_t(__cdecl*)(const char*);
|
||||||
const uint32_t hash = reinterpret_cast<HashFn>(0x0080e010)(key);
|
const uint32_t hash = reinterpret_cast<HashFn>(0x0080e010)(key);
|
||||||
return hash ? GameText(hash | 0x80000000) : std::wstring();
|
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) {
|
std::wstring WideStringText(const void* string) {
|
||||||
@@ -182,6 +187,9 @@ void SetWideString(void* string, const std::wstring& value) {
|
|||||||
const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) {
|
const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) {
|
||||||
if (id >= kFirstOwnText && id < 0x80000000) {
|
if (id >= kFirstOwnText && id < 0x80000000) {
|
||||||
std::lock_guard guard(g_lock);
|
std::lock_guard guard(g_lock);
|
||||||
|
if (const auto found = g_overrides.find(id); found != g_overrides.end()) {
|
||||||
|
return &found->second->string;
|
||||||
|
}
|
||||||
const size_t index = id - kFirstOwnText;
|
const size_t index = id - kFirstOwnText;
|
||||||
if (index < Texts().size()) {
|
if (index < Texts().size()) {
|
||||||
return &Texts()[index];
|
return &Texts()[index];
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ void SetTextOverride(uint32_t id, const std::wstring& text);
|
|||||||
// The game's text under key `key` (TextManager::GetTextByKey 0x672fd0), e.g.
|
// The game's text under key `key` (TextManager::GetTextByKey 0x672fd0), e.g.
|
||||||
// "UI_ATTRIB_STRENGTH"; empty if there is none.
|
// "UI_ATTRIB_STRENGTH"; empty if there is none.
|
||||||
std::wstring GameTextByKey(const char* key);
|
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
|
// 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).
|
// text, and replaced by `value` (grown with the game's allocator).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,6 +13,20 @@ inline uint32_t* UiManager() {
|
|||||||
return *reinterpret_cast<uint32_t**>(kUiManager);
|
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
|
// 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.
|
// UI manager's event handler (vf00, 0x757120) routes them by window name.
|
||||||
// Measured: HandleEsc hides with 0x12 (docs/escape.md); the S hotkey
|
// Measured: HandleEsc hides with 0x12 (docs/escape.md); the S hotkey
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
#include "game/engine/fx.h"
|
#include "game/engine/fx.h"
|
||||||
#include "game/engine/objects.h"
|
#include "game/engine/objects.h"
|
||||||
#include "game/engine/orders.h"
|
#include "game/engine/orders.h"
|
||||||
|
#include "mods/dwarfarts.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
#include "re/patch.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);
|
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 __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) {
|
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)) {
|
if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-16
@@ -3,7 +3,7 @@
|
|||||||
// The game renders at a fixed resolution (1024x768). Unmodified, windowed mode
|
// The game renders at a fixed resolution (1024x768). Unmodified, windowed mode
|
||||||
// shows that as a 1024x768 popup centred on the desktop; fullscreen 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
|
// 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).
|
// centred rectangle with the game's aspect ratio (black bars elsewhere).
|
||||||
//
|
//
|
||||||
// Mouse positions are mapped back from that rectangle to game pixels, both in
|
// Mouse positions are mapped back from that rectangle to game pixels, both in
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
#include "game/render/dx_driver7.h"
|
#include "game/render/dx_driver7.h"
|
||||||
#include "mods/hud.h"
|
#include "mods/hud.h"
|
||||||
#include "mods/input.h"
|
#include "mods/input.h"
|
||||||
|
#include "mods/monitor.h"
|
||||||
#include "mods/widescreen.h"
|
#include "mods/widescreen.h"
|
||||||
#include "re/config.h"
|
#include "re/config.h"
|
||||||
#include "game/addresses.h"
|
#include "game/addresses.h"
|
||||||
@@ -56,14 +57,12 @@ struct Viewport {
|
|||||||
HWND window = nullptr;
|
HWND window = nullptr;
|
||||||
bool runInBackground = false;
|
bool runInBackground = false;
|
||||||
Layout full; // the whole back buffer
|
Layout full; // the whole back buffer
|
||||||
Layout classic; // its top-left 1024x768 (menus, loading screen; widescreen mod)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Viewport g_viewport;
|
Viewport g_viewport;
|
||||||
std::atomic<bool> g_classicShown = false; // which layout the last presented frame used
|
|
||||||
|
|
||||||
const Layout& CurrentLayout() {
|
const Layout& CurrentLayout() {
|
||||||
return g_classicShown ? g_viewport.classic : g_viewport.full;
|
return g_viewport.full;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Enabled() {
|
bool Enabled() {
|
||||||
@@ -92,9 +91,7 @@ RECT FitRect(int areaWidth, int areaHeight, int width, int height, bool integerS
|
|||||||
|
|
||||||
void ApplyBorderless(game::DxDriver7* driver) {
|
void ApplyBorderless(game::DxDriver7* driver) {
|
||||||
HWND window = driver->window;
|
HWND window = driver->window;
|
||||||
MONITORINFO monitor = {sizeof(monitor)};
|
const RECT area = mods::monitor::GameMonitorRect();
|
||||||
GetMonitorInfoW(MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST), &monitor);
|
|
||||||
const RECT& area = monitor.rcMonitor;
|
|
||||||
const int areaWidth = area.right - area.left;
|
const int areaWidth = area.right - area.left;
|
||||||
const int areaHeight = area.bottom - area.top;
|
const int areaHeight = area.bottom - area.top;
|
||||||
|
|
||||||
@@ -116,10 +113,8 @@ void ApplyBorderless(game::DxDriver7* driver) {
|
|||||||
viewport.window = window;
|
viewport.window = window;
|
||||||
viewport.runInBackground = re::ConfigInt("borderless", "RunInBackground", 1) != 0;
|
viewport.runInBackground = re::ConfigInt("borderless", "RunInBackground", 1) != 0;
|
||||||
viewport.full = makeLayout(driver->mode.width, driver->mode.height);
|
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;
|
viewport.active = true;
|
||||||
g_viewport = viewport;
|
g_viewport = viewport;
|
||||||
g_classicShown = false;
|
|
||||||
|
|
||||||
// Paint the bars: the window class background brush is black.
|
// Paint the bars: the window class background brush is black.
|
||||||
InvalidateRect(window, nullptr, TRUE);
|
InvalidateRect(window, nullptr, TRUE);
|
||||||
@@ -138,6 +133,7 @@ POINT ToGame(POINT p) {
|
|||||||
POINT result;
|
POINT result;
|
||||||
result.x = std::clamp(MulDiv(p.x - layout.client.left, layout.gameWidth, width), 0, layout.gameWidth - 1);
|
result.x = std::clamp(MulDiv(p.x - layout.client.left, layout.gameWidth, width), 0, layout.gameWidth - 1);
|
||||||
result.y = std::clamp(MulDiv(p.y - layout.client.top, layout.gameHeight, height), 0, layout.gameHeight - 1);
|
result.y = std::clamp(MulDiv(p.y - layout.client.top, layout.gameHeight, height), 0, layout.gameHeight - 1);
|
||||||
|
result.x -= mods::hud::MenuFrameOffsetX(); // the main menu's centred frame
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +170,7 @@ int __fastcall DxDriver7_SetDisplayMode(DxDriver7* self, void* edx, void* device
|
|||||||
}
|
}
|
||||||
DxDisplayMode windowed = *mode;
|
DxDisplayMode windowed = *mode;
|
||||||
windowed.fullscreen = 0;
|
windowed.fullscreen = 0;
|
||||||
mods::widescreen::ApplyRenderSize(windowed, self->window);
|
mods::widescreen::ApplyRenderSize(windowed);
|
||||||
g_viewport.active = false;
|
g_viewport.active = false;
|
||||||
const int result = DxDriver7_SetDisplayMode_original(self, edx, deviceCaps, &windowed, unknown);
|
const int result = DxDriver7_SetDisplayMode_original(self, edx, deviceCaps, &windowed, unknown);
|
||||||
if (result) {
|
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
|
// The original computes the blit rectangles once, for a centred window of
|
||||||
// exactly the game's size; override them on every present.
|
// exactly the game's size; override them on every present.
|
||||||
if (g_viewport.active && self->window == g_viewport.window) {
|
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();
|
const Layout& layout = CurrentLayout();
|
||||||
self->blitSource = {0, 0, layout.gameWidth, layout.gameHeight};
|
self->blitSource = {0, 0, layout.gameWidth, layout.gameHeight};
|
||||||
self->blitDest = layout.screen;
|
self->blitDest = layout.screen;
|
||||||
@@ -256,3 +246,41 @@ SHORT WINAPI GetAsyncKeyStateWhenFocused(int key) {
|
|||||||
}
|
}
|
||||||
return GetAsyncKeyStateWhenFocused_original(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;
|
||||||
|
}
|
||||||
@@ -149,11 +149,6 @@ unsigned Chains(unsigned level) {
|
|||||||
return 1 + level / 10;
|
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) {
|
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
|
||||||
return std::find(ids.begin(), ids.end(), id) != ids.end();
|
return std::find(ids.begin(), ids.end(), id) != ids.end();
|
||||||
}
|
}
|
||||||
@@ -276,7 +271,7 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Forget();
|
Forget();
|
||||||
const unsigned level = ArtLevel(caster, g_heroArt);
|
const unsigned level = game::ArtLevel(caster, g_heroArt);
|
||||||
const uint32_t hits = Hits(level);
|
const uint32_t hits = Hits(level);
|
||||||
const unsigned chains = Chains(level);
|
const unsigned chains = Chains(level);
|
||||||
event[kEventHitsLeft] = hits;
|
event[kEventHitsLeft] = hits;
|
||||||
@@ -416,7 +411,7 @@ std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
|
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
|
||||||
text = Rewrite(text, ArtLevel(creature, art.Id()));
|
text = Rewrite(text, game::ArtLevel(creature, art.Id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TooltipRegistration {
|
struct TooltipRegistration {
|
||||||
|
|||||||
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
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// Character export (Esc menu "Exportieren"): slots can be cleared.
|
|
||||||
//
|
|
||||||
// Request (2026-10-18): the export screen was not adapted to widescreen, and
|
|
||||||
// slots could not be cleared or overwritten. The layout is fixed in hud.cpp
|
|
||||||
// (FrameScope); overwriting turned out to be the game's own path once clicks
|
|
||||||
// hit the right slot (measured: action 9 on an occupied slot -> mode 6
|
|
||||||
// confirmation -> action 6 exports into it).
|
|
||||||
//
|
|
||||||
// cUI_Character serves the main menu's character selection and the export
|
|
||||||
// screen: mode at +0x390 (4 selection, 5 export, 6 overwrite confirmation),
|
|
||||||
// selected slot (char, -1 none) at +0x1d6, slot controls at +0x1b4 (8, hero
|
|
||||||
// id at +0x2f8, 0: empty). Its erase button (+0x3b4, "UI_CHAR_ERASE_TOOLTIP")
|
|
||||||
// is created for both, but SelectSlot (0x70c440) shows it only in modes 4 / 7
|
|
||||||
// and SetMode (0x70c760) hides it. The click handler (vf10 0x70e860) does not
|
|
||||||
// check the mode: it opens the game's confirmation (UI_CHAR_ERASE_CONFIRM, a
|
|
||||||
// cUI_BusyDlg), whose answer deletes the hero (0x7f01a0, slot + 1). So in
|
|
||||||
// export mode the button is shown whenever an occupied slot is selected.
|
|
||||||
//
|
|
||||||
// The export screen reloads its heroes when it enters export mode (SetMode 5,
|
|
||||||
// also after an export); after a delete nothing did, so the erased hero stayed
|
|
||||||
// until the next export (user, 2026-10-18). After a delete from the export
|
|
||||||
// screen it enters export mode again and the selection is cleared.
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
#include "game/ui/control.h"
|
|
||||||
#include "re/hook.h"
|
|
||||||
|
|
||||||
namespace mods::charexport {
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
constexpr size_t kMode = 0x390;
|
|
||||||
constexpr size_t kSelectedSlot = 0x1d6;
|
|
||||||
constexpr size_t kSlots = 0x1b4;
|
|
||||||
constexpr size_t kSlotHeroId = 0x2f8;
|
|
||||||
constexpr size_t kEraseButton = 0x3b4;
|
|
||||||
constexpr int kSlotCount = 8;
|
|
||||||
constexpr int kModeExport = 5;
|
|
||||||
constexpr int kNoSlot = -1;
|
|
||||||
|
|
||||||
// The cUI_Character instance (one, owned by the UI manager), noted when its
|
|
||||||
// mode or selection changes.
|
|
||||||
void* g_window = nullptr;
|
|
||||||
|
|
||||||
template <typename T>
|
|
||||||
T& Field(void* window, size_t offset) {
|
|
||||||
return *reinterpret_cast<T*>(static_cast<uint8_t*>(window) + offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool SelectedSlotOccupied(void* window) {
|
|
||||||
const int slot = Field<int8_t>(window, kSelectedSlot);
|
|
||||||
if (slot < 0 || slot >= kSlotCount) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
void* control = Field<void*>(window, kSlots + slot * sizeof(void*));
|
|
||||||
return control && Field<uint32_t>(control, kSlotHeroId) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void UpdateEraseButton(void* window) {
|
|
||||||
void* button = Field<void*>(window, kEraseButton);
|
|
||||||
if (button && Field<int>(window, kMode) == kModeExport) {
|
|
||||||
game::SetControlShown(button, SelectedSlotOccupied(window));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
} // namespace mods::charexport
|
|
||||||
|
|
||||||
namespace game {
|
|
||||||
|
|
||||||
// cUI_Character::SetMode (0x70c760, thiscall (mode), ret 4).
|
|
||||||
void __fastcall UiCharacter_SetMode(void* self, void* edx, int mode);
|
|
||||||
RE_HOOK(0x0070c760, UiCharacter_SetMode);
|
|
||||||
void __fastcall UiCharacter_SetMode(void* self, void* edx, int mode) {
|
|
||||||
mods::charexport::g_window = self;
|
|
||||||
UiCharacter_SetMode_original(self, edx, mode);
|
|
||||||
mods::charexport::UpdateEraseButton(self);
|
|
||||||
}
|
|
||||||
|
|
||||||
// cUI_Character::SelectSlot (0x70c440, thiscall (char slot, -1 none), ret 4).
|
|
||||||
void __fastcall UiCharacter_SelectSlot(void* self, void* edx, int slot);
|
|
||||||
RE_HOOK(0x0070c440, UiCharacter_SelectSlot);
|
|
||||||
void __fastcall UiCharacter_SelectSlot(void* self, void* edx, int slot) {
|
|
||||||
mods::charexport::g_window = self;
|
|
||||||
UiCharacter_SelectSlot_original(self, edx, slot);
|
|
||||||
mods::charexport::UpdateEraseButton(self);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HeroSlots_DeleteHero (0x7f01a0, thiscall on 0x7d7c40() (slot + 1), ret 4;
|
|
||||||
// returns a bool in al), called by the erase confirmation's answer.
|
|
||||||
int __fastcall HeroSlots_DeleteHero(void* self, void* edx, int slotPlusOne);
|
|
||||||
RE_HOOK(0x007f01a0, HeroSlots_DeleteHero);
|
|
||||||
int __fastcall HeroSlots_DeleteHero(void* self, void* edx, int slotPlusOne) {
|
|
||||||
const int result = HeroSlots_DeleteHero_original(self, edx, slotPlusOne);
|
|
||||||
void* window = mods::charexport::g_window;
|
|
||||||
using mods::charexport::Field;
|
|
||||||
if (window && Field<int>(window, mods::charexport::kMode) == mods::charexport::kModeExport) {
|
|
||||||
UiCharacter_SetMode(window, nullptr, mods::charexport::kModeExport);
|
|
||||||
UiCharacter_SelectSlot(window, nullptr, mods::charexport::kNoSlot);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace game
|
|
||||||
@@ -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.
|
// The art's level now (cooldown entry level + bonus levels), 0 if unknown.
|
||||||
uint16_t CurrentLevel(uint8_t* arts, uint32_t art) {
|
uint16_t CurrentLevel(uint8_t* arts, uint32_t art) {
|
||||||
uint8_t* entry = art ? FindCooldownEntry(arts, static_cast<uint16_t>(art)) : nullptr;
|
return art ? static_cast<uint16_t>(ArtLevelIn(arts, static_cast<uint16_t>(art))) : 0;
|
||||||
return entry ? static_cast<uint16_t>(entry[kCooldownLevel] + entry[kCooldownBonusLevels]) : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void SyncCombos(uint8_t* hero) {
|
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
|
||||||
@@ -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
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
#include <intrin.h>
|
#include <intrin.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
@@ -31,4 +34,49 @@ void __fastcall BurningBone_Render(void* self, void* edx, void* device, Matrix4
|
|||||||
BurningBone_Render_original(self, edx, device, transform);
|
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
|
} // namespace game
|
||||||
+160
-19
@@ -21,6 +21,7 @@
|
|||||||
// Enabled=1
|
// Enabled=1
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -30,6 +31,8 @@
|
|||||||
#include "game/addresses.h"
|
#include "game/addresses.h"
|
||||||
#include "game/render/dx_driver7.h"
|
#include "game/render/dx_driver7.h"
|
||||||
#include "game/ui/events.h"
|
#include "game/ui/events.h"
|
||||||
|
#include "mods/hud.h"
|
||||||
|
#include "mods/screen.h"
|
||||||
#include "mods/widescreen.h"
|
#include "mods/widescreen.h"
|
||||||
#include "re/config.h"
|
#include "re/config.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
@@ -47,13 +50,24 @@ thread_local bool g_eventShifted = false;
|
|||||||
|
|
||||||
|
|
||||||
// Screen: laid out across the whole render width by its mod (drawn unshifted).
|
// Screen: laid out across the whole render width by its mod (drawn unshifted).
|
||||||
enum class Anchor { Center, Right, Screen };
|
// Canvas: a 16:9 screen (kCanvasWidth), centred.
|
||||||
|
enum class Anchor { Center, Right, Screen, Canvas };
|
||||||
|
|
||||||
|
// Offset of the main menu's 1024 frame while it is drawn centred (set each
|
||||||
|
// menu frame by RenderMenuFrame; 0 while a 16:9 screen draws itself).
|
||||||
|
std::atomic<int> g_menuFrameDx = 0;
|
||||||
|
|
||||||
int OffsetFor(Anchor anchor) {
|
int OffsetFor(Anchor anchor) {
|
||||||
static const bool enabled = re::ConfigInt("hud", "Enabled", 1) != 0;
|
static const bool enabled = re::ConfigInt("hud", "Enabled", 1) != 0;
|
||||||
const int width = mods::widescreen::RenderWidth();
|
const int width = mods::widescreen::RenderWidth();
|
||||||
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() ||
|
if (anchor == Anchor::Canvas) {
|
||||||
mods::widescreen::MenuIsShowing() || anchor == Anchor::Screen) {
|
return width > kCanvasWidth ? (width - kCanvasWidth) / 2 : 0;
|
||||||
|
}
|
||||||
|
// The main menu's frame shifts the windows in it; over a 16:9 screen
|
||||||
|
// (no frame) dialogs are centred like in a game (user, 2026-10-21).
|
||||||
|
const bool inMenuFrame = mods::widescreen::MenuIsShowing() && g_menuFrameDx != 0;
|
||||||
|
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsLoadingScreen() || inMenuFrame ||
|
||||||
|
anchor == Anchor::Screen) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return anchor == Anchor::Center ? (width - kLayoutWidth) / 2 : width - kLayoutWidth;
|
return anchor == Anchor::Center ? (width - kLayoutWidth) / 2 : width - kLayoutWidth;
|
||||||
@@ -263,6 +277,29 @@ private:
|
|||||||
CursorOffset cursor_;
|
CursorOffset cursor_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// RAII: shift drawing only (the cursor is not seen shifted), for things
|
||||||
|
// already in the shifted layout's coordinates (the main menu's cursor).
|
||||||
|
class DrawShift {
|
||||||
|
public:
|
||||||
|
explicit DrawShift(int dx) : active_(!g_drawScoped && dx != 0) {
|
||||||
|
if (active_) {
|
||||||
|
g_drawScoped = true;
|
||||||
|
g_offsetX = dx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
~DrawShift() {
|
||||||
|
if (active_) {
|
||||||
|
g_drawScoped = false;
|
||||||
|
g_offsetX = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DrawShift(const DrawShift&) = delete;
|
||||||
|
DrawShift& operator=(const DrawShift&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool active_;
|
||||||
|
};
|
||||||
|
|
||||||
// Stores the offsets of a mouse event's x fields (game/ui/events.h); returns
|
// Stores the offsets of a mouse event's x fields (game/ui/events.h); returns
|
||||||
// their count (0: no mouse event).
|
// their count (0: no mouse event).
|
||||||
int MouseEventXOffsets(void* event, int (&offsets)[2]) {
|
int MouseEventXOffsets(void* event, int (&offsets)[2]) {
|
||||||
@@ -341,6 +378,9 @@ size_t VertexSize(DWORD fvf) {
|
|||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 16:9 screen draws (RenderCanvas): no 1024 layout to stretch.
|
||||||
|
thread_local bool g_canvasScope = false;
|
||||||
|
|
||||||
// Returns vertices shifted by g_offsetX, or the input when nothing to do.
|
// Returns vertices shifted by g_offsetX, or the input when nothing to do.
|
||||||
// Primitives covering the whole 1024x768 layout (e.g. the dimming behind
|
// Primitives covering the whole 1024x768 layout (e.g. the dimming behind
|
||||||
// modal dialogs) are stretched across the render width instead.
|
// modal dialogs) are stretched across the render width instead.
|
||||||
@@ -359,7 +399,7 @@ void* ShiftVertices(DWORD fvf, void* vertices, DWORD count) {
|
|||||||
minY = std::min(minY, position[1]);
|
minY = std::min(minY, position[1]);
|
||||||
maxY = std::max(maxY, position[1]);
|
maxY = std::max(maxY, position[1]);
|
||||||
}
|
}
|
||||||
const bool fullLayout = minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f;
|
const bool fullLayout = !g_canvasScope && minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f;
|
||||||
const float scale = static_cast<float>(mods::widescreen::RenderWidth()) / kLayoutWidth;
|
const float scale = static_cast<float>(mods::widescreen::RenderWidth()) / kLayoutWidth;
|
||||||
for (DWORD i = 0; i < count; ++i) {
|
for (DWORD i = 0; i < count; ++i) {
|
||||||
auto* x = reinterpret_cast<float*>(buffer.data() + stride * i);
|
auto* x = reinterpret_cast<float*>(buffer.data() + stride * i);
|
||||||
@@ -375,13 +415,59 @@ using DrawIndexedPrimitiveFn = HRESULT(__stdcall*)(void* device, DWORD type, DWO
|
|||||||
DrawPrimitiveFn g_drawPrimitive = nullptr;
|
DrawPrimitiveFn g_drawPrimitive = nullptr;
|
||||||
DrawIndexedPrimitiveFn g_drawIndexedPrimitive = nullptr;
|
DrawIndexedPrimitiveFn g_drawIndexedPrimitive = nullptr;
|
||||||
|
|
||||||
|
// Textures not drawn (SetHiddenTextures): the surface bound to stage 0 is
|
||||||
|
// tracked per thread (each thread draws with its own device calls). Only
|
||||||
|
// inside a frame drawn over the menu background (g_hidingPictures: the main
|
||||||
|
// menu's, the options' in a game): the list keeps surfaces the cache may
|
||||||
|
// have freed since, and a game texture made at the same address was skipped
|
||||||
|
// in the world view (user, 2026-10-21: "rendering glitches ingame").
|
||||||
|
constexpr int kHiddenTextureMax = 64;
|
||||||
|
std::atomic<void*> g_hiddenTextures[kHiddenTextureMax] = {};
|
||||||
|
thread_local void* g_boundTexture = nullptr;
|
||||||
|
thread_local int g_hidingPictures = 0;
|
||||||
|
|
||||||
|
struct HidingPictures {
|
||||||
|
HidingPictures() { ++g_hidingPictures; }
|
||||||
|
~HidingPictures() { --g_hidingPictures; }
|
||||||
|
HidingPictures(const HidingPictures&) = delete;
|
||||||
|
HidingPictures& operator=(const HidingPictures&) = delete;
|
||||||
|
};
|
||||||
|
|
||||||
|
using SetTextureFn = HRESULT(__stdcall*)(void* device, DWORD stage, void* surface);
|
||||||
|
SetTextureFn g_setTexture = nullptr;
|
||||||
|
|
||||||
|
HRESULT __stdcall SetTextureTracked(void* device, DWORD stage, void* surface) {
|
||||||
|
if (stage == 0) {
|
||||||
|
g_boundTexture = surface;
|
||||||
|
}
|
||||||
|
return g_setTexture(device, stage, surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BoundTextureHidden() {
|
||||||
|
if (!g_boundTexture || g_hidingPictures == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const auto& hidden : g_hiddenTextures) {
|
||||||
|
if (hidden.load(std::memory_order_relaxed) == g_boundTexture) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
HRESULT __stdcall DrawPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count,
|
HRESULT __stdcall DrawPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count,
|
||||||
DWORD flags) {
|
DWORD flags) {
|
||||||
|
if (BoundTextureHidden()) {
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
return g_drawPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, flags);
|
return g_drawPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
HRESULT __stdcall DrawIndexedPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count,
|
HRESULT __stdcall DrawIndexedPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count,
|
||||||
WORD* indices, DWORD indexCount, DWORD flags) {
|
WORD* indices, DWORD indexCount, DWORD flags) {
|
||||||
|
if (BoundTextureHidden()) {
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
return g_drawIndexedPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, indices,
|
return g_drawIndexedPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, indices,
|
||||||
indexCount, flags);
|
indexCount, flags);
|
||||||
}
|
}
|
||||||
@@ -432,36 +518,33 @@ HRESULT __stdcall ClearShifted(void* device, DWORD count, Rect7* rects, DWORD fl
|
|||||||
|
|
||||||
using GetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport);
|
using GetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport);
|
||||||
constexpr int kVtableGetViewport = 15; // IDirect3DDevice7 (13 SetViewport, 14 MultiplyTransform)
|
constexpr int kVtableGetViewport = 15; // IDirect3DDevice7 (13 SetViewport, 14 MultiplyTransform)
|
||||||
constexpr DWORD kClearTarget = 1; // D3DCLEAR_TARGET
|
|
||||||
|
|
||||||
// RAII for full-screen windows (character export, save games) that fill the
|
// RAII for full-screen windows (options in a game) that fill the 1024 frame
|
||||||
// 1024 frame with a picture: the render target left and right of the frame is
|
// with a picture: the menu screens' background (mods/screen.h) goes under
|
||||||
// cleared black, and the window draws with a viewport on the frame. The
|
// it, and the window draws with a viewport on the frame. The
|
||||||
// export screen's 3D scene (heroes, background) uses a projection for a
|
// export screen's 3D scene (heroes, background) uses a projection for a
|
||||||
// 1024x768 viewport; across the widened one it was stretched 1.8x wide, away
|
// 1024x768 viewport; across the widened one it was stretched 1.8x wide, away
|
||||||
// from the slot labels and hit areas (measured on a screenshot, 2026-10-18).
|
// from the slot labels and hit areas (measured on a screenshot, 2026-10-18).
|
||||||
// Clear clips to the viewport, so the sides are cleared before it is set.
|
|
||||||
class FrameScope {
|
class FrameScope {
|
||||||
public:
|
public:
|
||||||
FrameScope(void* window, void* device, int dx) {
|
FrameScope(void* window, void* device, int dx) {
|
||||||
const auto* w = static_cast<const uint8_t*>(window);
|
const auto* w = static_cast<const uint8_t*>(window);
|
||||||
if (dx == 0 || g_drawScoped || !device || !g_setViewport || !g_clear || (w[kControlFlags] & 1) == 0) {
|
if (dx == 0 || g_drawScoped || !device || !g_setViewport || (w[kControlFlags] & 1) == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
auto getViewport = reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]);
|
auto getViewport = reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]);
|
||||||
if (FAILED(getViewport(device, &previous_))) {
|
if (FAILED(getViewport(device, &previous_))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const LONG height = static_cast<LONG>(previous_.height);
|
mods::screen::DrawBackground(device);
|
||||||
Rect7 sides[] = {{0, 0, dx, height},
|
|
||||||
{dx + kLayoutWidth, 0, mods::widescreen::RenderWidth(), height}};
|
|
||||||
g_clear(device, 2, sides, kClearTarget, 0, 1.0f, 0);
|
|
||||||
Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous_.minZ, previous_.maxZ};
|
Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous_.minZ, previous_.maxZ};
|
||||||
device_ = device;
|
device_ = device;
|
||||||
g_setViewport(device, &frame);
|
g_setViewport(device, &frame);
|
||||||
|
++g_hidingPictures;
|
||||||
}
|
}
|
||||||
~FrameScope() {
|
~FrameScope() {
|
||||||
if (device_) {
|
if (device_) {
|
||||||
|
--g_hidingPictures;
|
||||||
g_setViewport(device_, &previous_);
|
g_setViewport(device_, &previous_);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,6 +587,10 @@ void OnDevice(void* device) {
|
|||||||
g_drawIndexedPrimitive = reinterpret_cast<DrawIndexedPrimitiveFn>(vtable[26]);
|
g_drawIndexedPrimitive = reinterpret_cast<DrawIndexedPrimitiveFn>(vtable[26]);
|
||||||
re::AttachRuntimeHook("D3D7 DrawIndexedPrimitive", reinterpret_cast<void**>(&g_drawIndexedPrimitive),
|
re::AttachRuntimeHook("D3D7 DrawIndexedPrimitive", reinterpret_cast<void**>(&g_drawIndexedPrimitive),
|
||||||
reinterpret_cast<void*>(&DrawIndexedPrimitiveShifted));
|
reinterpret_cast<void*>(&DrawIndexedPrimitiveShifted));
|
||||||
|
// SetTexture 35.
|
||||||
|
g_setTexture = reinterpret_cast<SetTextureFn>(vtable[35]);
|
||||||
|
re::AttachRuntimeHook("D3D7 SetTexture", reinterpret_cast<void**>(&g_setTexture),
|
||||||
|
reinterpret_cast<void*>(&SetTextureTracked));
|
||||||
// Clear 10, SetViewport 13.
|
// Clear 10, SetViewport 13.
|
||||||
g_clear = reinterpret_cast<ClearFn>(vtable[10]);
|
g_clear = reinterpret_cast<ClearFn>(vtable[10]);
|
||||||
re::AttachRuntimeHook("D3D7 Clear", reinterpret_cast<void**>(&g_clear), reinterpret_cast<void*>(&ClearShifted));
|
re::AttachRuntimeHook("D3D7 Clear", reinterpret_cast<void**>(&g_clear), reinterpret_cast<void*>(&ClearShifted));
|
||||||
@@ -695,8 +782,7 @@ HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center)
|
|||||||
HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center)
|
HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center)
|
||||||
HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game
|
HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game
|
||||||
// cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp.
|
// cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp.
|
||||||
HUD_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center)
|
// cUI_Savegame (0x71d130 / 0x71dd80): hooked by mods/characters.cpp.
|
||||||
HUD_RENDER_FRAMED(0x0071dd80, UiSavegame_Render)
|
|
||||||
HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center)
|
HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center)
|
||||||
HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
|
HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
|
||||||
|
|
||||||
@@ -789,6 +875,56 @@ int HandleCentred(void* event, int (*body)(void* context), void* context) {
|
|||||||
return body(context);
|
return body(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SetHiddenTextures(void* const* surfaces, int count) {
|
||||||
|
for (int i = 0; i < kHiddenTextureMax; ++i) {
|
||||||
|
g_hiddenTextures[i].store(i < count ? surfaces[i] : nullptr, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int MenuFrameOffsetX() {
|
||||||
|
return mods::widescreen::MenuIsShowing() ? g_menuFrameDx.load() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int RenderMenuFrame(void* device, bool framed, int (*body)(void* context), void* context) {
|
||||||
|
const int width = mods::widescreen::RenderWidth();
|
||||||
|
const int dx = framed && width > kLayoutWidth ? (width - kLayoutWidth) / 2 : 0;
|
||||||
|
g_menuFrameDx = dx;
|
||||||
|
HidingPictures hiding;
|
||||||
|
Viewport7 previous;
|
||||||
|
auto getViewport =
|
||||||
|
device ? reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]) : nullptr;
|
||||||
|
if (dx == 0 || g_drawScoped || !g_setViewport || !getViewport || FAILED(getViewport(device, &previous))) {
|
||||||
|
return body(context);
|
||||||
|
}
|
||||||
|
Viewport7 frame = {static_cast<DWORD>(dx), 0, kLayoutWidth, 768, previous.minZ, previous.maxZ};
|
||||||
|
g_setViewport(device, &frame);
|
||||||
|
g_drawScoped = true; // the windows inside draw in the frame's coordinates
|
||||||
|
g_offsetX = dx;
|
||||||
|
const int result = body(context);
|
||||||
|
g_offsetX = 0;
|
||||||
|
g_drawScoped = false;
|
||||||
|
g_setViewport(device, &previous);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int CanvasOffsetX() {
|
||||||
|
return OffsetFor(Anchor::Canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
int RenderCanvas(void* window, int (*body)(void* context), void* context) {
|
||||||
|
DrawOffset offset(window, OffsetFor(Anchor::Canvas));
|
||||||
|
const bool outer = !g_canvasScope;
|
||||||
|
g_canvasScope = true;
|
||||||
|
const int result = body(context);
|
||||||
|
g_canvasScope = !outer;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int HandleCanvas(void* event, int (*body)(void* context), void* context) {
|
||||||
|
EventOffset offset(event, OffsetFor(Anchor::Canvas));
|
||||||
|
return body(context);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace mods::hud
|
} // namespace mods::hud
|
||||||
|
|
||||||
namespace game {
|
namespace game {
|
||||||
@@ -805,12 +941,17 @@ int __fastcall UiTaskbar_HitTest(void* self, void* edx, int x, int y) {
|
|||||||
return UiTaskbar_HitTest_original(self, edx, mods::hud::HitTestX(self, x), y);
|
return UiTaskbar_HitTest_original(self, edx, mods::hud::HitTestX(self, x), y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The item carried by the mouse cursor (0x6555e0, drawn by the render loop
|
// The cursor and the item it carries (0x6555e0, drawn by the render loop
|
||||||
// after the UI) is placed like UI items: relative to x = 512 of the layout.
|
// after the UI) are placed like UI items: relative to x = 512 of the layout.
|
||||||
// Items are only picked up from centred windows.
|
// Items are only picked up from centred windows. In the main menu the cursor
|
||||||
|
// is in the centred frame's coordinates (MenuFrameOffsetX).
|
||||||
int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b);
|
int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b);
|
||||||
RE_HOOK(0x006555e0, MouseCursor_DrawItem);
|
RE_HOOK(0x006555e0, MouseCursor_DrawItem);
|
||||||
int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b) {
|
int __fastcall MouseCursor_DrawItem(void* self, void* edx, int a, int b) {
|
||||||
|
if (mods::widescreen::MenuIsShowing()) {
|
||||||
|
mods::hud::DrawShift shift(mods::hud::MenuFrameOffsetX());
|
||||||
|
return MouseCursor_DrawItem_original(self, edx, a, b);
|
||||||
|
}
|
||||||
mods::hud::DrawOffset offset(nullptr, mods::hud::OffsetFor(Anchor::Center));
|
mods::hud::DrawOffset offset(nullptr, mods::hud::OffsetFor(Anchor::Center));
|
||||||
return MouseCursor_DrawItem_original(mods::hud::CursorFor(self), edx, a, b);
|
return MouseCursor_DrawItem_original(mods::hud::CursorFor(self), edx, a, b);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-2
@@ -26,8 +26,31 @@ int VisibleDialogTooltips(void** out, int max);
|
|||||||
// shifted) or event handler (mouse position shifted by the window's offset).
|
// shifted) or event handler (mouse position shifted by the window's offset).
|
||||||
int RenderCentred(void* window, int (*body)(void* context), void* context);
|
int RenderCentred(void* window, int (*body)(void* context), void* context);
|
||||||
int HandleCentred(void* event, int (*body)(void* context), void* context);
|
int HandleCentred(void* event, int (*body)(void* context), void* context);
|
||||||
// RenderCentred for full-screen windows: black beside the 1024 frame, drawn
|
// RenderCentred for full-screen windows: the menu screens' background beside
|
||||||
// with a viewport on it (as the save game and export screens).
|
// the 1024 frame, drawn with a viewport on it (as the options in a game).
|
||||||
int RenderFramed(void* window, void* device, int (*body)(void* context), void* context);
|
int RenderFramed(void* window, void* device, int (*body)(void* context), void* context);
|
||||||
|
|
||||||
|
// 16:9 screens (mods/screen.h): laid out on a 1365x768 canvas, centred in
|
||||||
|
// the render width. RenderCanvas / HandleCanvas run body(context) with drawing,
|
||||||
|
// cursor and mouse events in canvas coordinates; CanvasOffsetX is the canvas'
|
||||||
|
// left edge in the render target.
|
||||||
|
constexpr int kCanvasWidth = 1365;
|
||||||
|
constexpr int kCanvasHeight = 768;
|
||||||
|
int CanvasOffsetX();
|
||||||
|
int RenderCanvas(void* window, int (*body)(void* context), void* context);
|
||||||
|
int HandleCanvas(void* event, int (*body)(void* context), void* context);
|
||||||
|
|
||||||
|
// The main menu (mods/screen.cpp): with `framed`, body(context) draws the
|
||||||
|
// menu's 1024x768 frame centred in the render width (shifted, with a
|
||||||
|
// viewport on it); mouse positions are then given in frame coordinates
|
||||||
|
// (MenuFrameOffsetX, applied by the borderless mod). Without, the menu draws
|
||||||
|
// unshifted (a 16:9 screen is shown).
|
||||||
|
int RenderMenuFrame(void* device, bool framed, int (*body)(void* context), void* context);
|
||||||
|
int MenuFrameOffsetX();
|
||||||
|
|
||||||
|
// Quads drawn with one of these textures (Direct3D surfaces) are left out
|
||||||
|
// inside the frames drawn over the menu background (RenderMenuFrame, the
|
||||||
|
// in-game options), until the list changes (at most 64).
|
||||||
|
void SetHiddenTextures(void* const* surfaces, int count);
|
||||||
|
|
||||||
} // namespace mods::hud
|
} // namespace mods::hud
|
||||||
+22
-32
@@ -26,7 +26,7 @@
|
|||||||
// AllyPotion=None ; kind 1 on the weakest ally (the game's own potion keys
|
// AllyPotion=None ; kind 1 on the weakest ally (the game's own potion keys
|
||||||
// ; Space/B/Q/W/E/R are switched off)
|
// ; Space/B/Q/W/E/R are switched off)
|
||||||
// PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up)
|
// PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up)
|
||||||
// SaveGame=K ; what S did (save game window)
|
// SaveGame=K ; save the game at once (S opened the save window)
|
||||||
// Map=M ; open / close the map (the game's M is switched off)
|
// 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)
|
// 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)
|
// Labels=Alt ; names of items and creatures while held (the game's Alt is switched off)
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
#include "game/engine/items.h"
|
#include "game/engine/items.h"
|
||||||
#include "game/engine/orders.h"
|
#include "game/engine/orders.h"
|
||||||
#include "game/ui/windows.h"
|
#include "game/ui/windows.h"
|
||||||
|
#include "mods/characters.h"
|
||||||
#include "mods/skillbar.h"
|
#include "mods/skillbar.h"
|
||||||
#include "mods/skillkeys.h"
|
#include "mods/skillkeys.h"
|
||||||
#include "re/config.h"
|
#include "re/config.h"
|
||||||
@@ -252,24 +253,6 @@ void OnTick(uint8_t* engine) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The save game window, as the UI manager's S hotkey opens it (0x757775):
|
|
||||||
// only with the window built (manager slot 0x2b) and no blocking window open;
|
|
||||||
// it posts an engine event (code 0x13, 5, 1; with 0 for the kernel's second
|
|
||||||
// argument) and shows UI_WND_SAVEGAME.
|
|
||||||
constexpr int kSaveGameSlot = 0x2b;
|
|
||||||
constexpr uint32_t kEngineEventVtable = 0x0088eb38;
|
|
||||||
constexpr uint32_t kEngineEventSaveGame = 0x13;
|
|
||||||
|
|
||||||
void OpenSaveGame() {
|
|
||||||
uint32_t* ui = game::UiManager();
|
|
||||||
if (!ui || !ui[kSaveGameSlot] || !game::UiCanOpenWindow(ui)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uint32_t event[9] = {kEngineEventVtable, kEngineEventSaveGame, 5, 1};
|
|
||||||
game::PostEvent(event, 0);
|
|
||||||
game::SendWindowCommand(ui, "UI_WND_SAVEGAME", game::WindowCommand::Show);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The map (cUI_Megamap, manager slot 0x25), as the UI manager's M hotkey
|
// 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
|
// 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
|
// nothing while one of the windows it checks first is open (slots 0x2b save
|
||||||
@@ -278,7 +261,6 @@ void OpenSaveGame() {
|
|||||||
// letter switch (upper-case character - 'C': index bytes at 0x75868c, targets
|
// letter switch (upper-case character - 'C': index bytes at 0x75868c, targets
|
||||||
// at 0x758660) sends M to index 10, "no hotkey", and the closing branch
|
// at 0x758660) sends M to index 10, "no hotkey", and the closing branch
|
||||||
// compares with -1 instead of 'M' (cmp eax, 4Dh at 0x757f86).
|
// compares with -1 instead of 'M' (cmp eax, 4Dh at 0x757f86).
|
||||||
constexpr int kMegamapSlot = 0x25;
|
|
||||||
constexpr int kMapBlockingSlots[] = {0x2b, 0x2c, 0x2f};
|
constexpr int kMapBlockingSlots[] = {0x2b, 0x2c, 0x2f};
|
||||||
constexpr uint32_t kMapBlockedFlag = 0x017e82c4;
|
constexpr uint32_t kMapBlockedFlag = 0x017e82c4;
|
||||||
constexpr uint32_t kMapDisabledFlag = 0x017e8264;
|
constexpr uint32_t kMapDisabledFlag = 0x017e8264;
|
||||||
@@ -287,32 +269,25 @@ constexpr uint32_t kUiLetterIndices = 0x0075868c;
|
|||||||
constexpr uint8_t kUiLetterMap = 5;
|
constexpr uint8_t kUiLetterMap = 5;
|
||||||
constexpr uint8_t kUiNoHotkey = 10;
|
constexpr uint8_t kUiNoHotkey = 10;
|
||||||
constexpr uint32_t kUiMapCloseCompare = 0x00757f86;
|
constexpr uint32_t kUiMapCloseCompare = 0x00757f86;
|
||||||
constexpr size_t kControlFlagsOffset = 0x10; // bit 0: visible
|
|
||||||
|
|
||||||
bool WindowShown(const uint32_t* ui, int slot) {
|
|
||||||
const auto* window = reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(ui[slot]));
|
|
||||||
return window && (*reinterpret_cast<const uint32_t*>(window + kControlFlagsOffset) & 1) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Flag(uint32_t address) {
|
bool Flag(uint32_t address) {
|
||||||
return *reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(address)) != 0;
|
return *reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(address)) != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ToggleMap() {
|
void ToggleMap() {
|
||||||
uint32_t* ui = game::UiManager();
|
uint32_t* ui = game::UiManager();
|
||||||
if (!ui || !ui[kMegamapSlot]) {
|
if (!ui || !ui[game::kUiSlotMegamap]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (WindowShown(ui, kMegamapSlot)) {
|
if (game::UiWindowShown(ui, game::kUiSlotMegamap)) {
|
||||||
game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Hide);
|
game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Hide);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const int slot : kMapBlockingSlots) {
|
for (const int slot : kMapBlockingSlots) {
|
||||||
if (WindowShown(ui, slot)) {
|
if (game::UiWindowShown(ui, slot)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!Flag(kMapBlockedFlag) && !Flag(kMapDisabledFlag) && !WindowShown(ui, kMapDisabledSlot)) {
|
if (!Flag(kMapBlockedFlag) && !Flag(kMapDisabledFlag) && !game::UiWindowShown(ui, kMapDisabledSlot)) {
|
||||||
game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Show);
|
game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Show);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,7 +318,14 @@ constexpr uint32_t kGetAsyncKeyStateImport = 0x0088e2b0;
|
|||||||
constexpr uint32_t kLabelsKeyCalls[] = {0x00599ebf, 0x0060f227, 0x00612015, 0x006176ec,
|
constexpr uint32_t kLabelsKeyCalls[] = {0x00599ebf, 0x0060f227, 0x00612015, 0x006176ec,
|
||||||
0x00626ffe, 0x00627299, 0x006c80b0};
|
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) {
|
SHORT WINAPI LabelsKeyState(int) {
|
||||||
|
if (g_labelsForced) {
|
||||||
|
return static_cast<SHORT>(0x8000);
|
||||||
|
}
|
||||||
for (const Binding& binding : g_bindings) {
|
for (const Binding& binding : g_bindings) {
|
||||||
if (binding.kind == Kind::Labels) {
|
if (binding.kind == Kind::Labels) {
|
||||||
return binding.vk ? GetAsyncKeyState(binding.vk) : 0;
|
return binding.vk ? GetAsyncKeyState(binding.vk) : 0;
|
||||||
@@ -369,7 +351,7 @@ void Press(const Binding& binding, int vk) {
|
|||||||
mods::skillbar::SelectWeaponSet(slot);
|
mods::skillbar::SelectWeaponSet(slot);
|
||||||
break;
|
break;
|
||||||
case Kind::SaveGame:
|
case Kind::SaveGame:
|
||||||
OpenSaveGame();
|
characters::RequestSave(); // at once, no save window
|
||||||
break;
|
break;
|
||||||
case Kind::PickUp:
|
case Kind::PickUp:
|
||||||
g_pickUp = true;
|
g_pickUp = true;
|
||||||
@@ -487,6 +469,14 @@ bool TypingIntoTextField() {
|
|||||||
return FocusedTextField() != nullptr;
|
return FocusedTextField() != nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool LabelsKeyHeld() {
|
||||||
|
return (LabelsKeyState(VK_MENU) & 0x8000) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ForceLabels(bool forced) {
|
||||||
|
g_labelsForced = forced;
|
||||||
|
}
|
||||||
|
|
||||||
int MovementKey(Move direction) {
|
int MovementKey(Move direction) {
|
||||||
for (const Binding& binding : g_bindings) {
|
for (const Binding& binding : g_bindings) {
|
||||||
if (binding.kind == Kind::Move && binding.index == static_cast<int>(direction)) {
|
if (binding.kind == Kind::Move && binding.index == static_cast<int>(direction)) {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ bool HandleWindowMessage(HWND window, UINT message, WPARAM wParam, LPARAM lParam
|
|||||||
enum class Move { Up, Left, Down, Right };
|
enum class Move { Up, Left, Down, Right };
|
||||||
int MovementKey(Move direction);
|
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.
|
// The key bound to drinking potion kind `kind` (1..5); 0 when unbound.
|
||||||
int PotionKey(int kind);
|
int PotionKey(int kind);
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
#include "mods/sharedchest.h"
|
#include "mods/sharedchest.h"
|
||||||
#include "mods/skillslots.h"
|
#include "mods/skillslots.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
|
#include "re/patch.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
namespace mods::inventory_save {
|
namespace mods::inventory_save {
|
||||||
@@ -327,6 +328,20 @@ void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn rea
|
|||||||
HeroDataList().push_back({key, write, read});
|
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 mods::inventory_save
|
||||||
|
|
||||||
namespace game {
|
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
|
// 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
|
// the zoom buttons at the bottom stay centred. The window is drawn and clicked
|
||||||
// unshifted (mods/hud.cpp, Anchor::Screen). See docs/hud.md.
|
// 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 <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -16,6 +21,11 @@
|
|||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
#include "game/addresses.h"
|
#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/hud.h"
|
||||||
#include "mods/megamap.h"
|
#include "mods/megamap.h"
|
||||||
#include "mods/widescreen.h"
|
#include "mods/widescreen.h"
|
||||||
@@ -51,15 +61,71 @@ constexpr uint32_t kZoomView = 0x006cc950;
|
|||||||
constexpr uint32_t kZoomCentreUses[] = {0x006ccb7f, 0x006cf782};
|
constexpr uint32_t kZoomCentreUses[] = {0x006ccb7f, 0x006cf782};
|
||||||
constexpr uint32_t kZoomMarkerX = 0x006ce156;
|
constexpr uint32_t kZoomMarkerX = 0x006ce156;
|
||||||
constexpr float kZoomMarkerFromCentre = 256.0f;
|
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
|
// The dark band around the view (10 vertices, mov [esp+..], imm32): its right
|
||||||
// outer edge x 1024 and inner edge x 954.
|
// outer edge x 1024 and inner edge x 954.
|
||||||
constexpr uint32_t kBandRightOuter[] = {0x006ca774, 0x006ca808};
|
constexpr uint32_t kBandRightOuter[] = {0x006ca774, 0x006ca808};
|
||||||
constexpr uint32_t kBandRightInner[] = {0x006ca7bc, 0x006ca84c};
|
constexpr uint32_t kBandRightInner[] = {0x006ca7bc, 0x006ca84c};
|
||||||
constexpr int kBandWidth = 70;
|
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_halfWidth = 512.0f;
|
||||||
float g_zoomCentre = 512.0f;
|
float g_zoomCentre = 512.0f;
|
||||||
float g_zoomMarkerX = 768.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 FloatBits(float value) {
|
||||||
uint32_t bits;
|
uint32_t bits;
|
||||||
@@ -183,6 +249,8 @@ void PatchLayout(int width) {
|
|||||||
failed += !re::RedirectOperand(instruction, game::addr::kScreenHalfWidthF, &g_zoomCentre);
|
failed += !re::RedirectOperand(instruction, game::addr::kScreenHalfWidthF, &g_zoomCentre);
|
||||||
}
|
}
|
||||||
failed += !re::RedirectOperand(kZoomMarkerX, game::addr::kScreenHeightF, &g_zoomMarkerX);
|
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(kBandRightOuter, FloatBits(1024.0f), FloatBits(static_cast<float>(width)));
|
||||||
failed += re::PatchAll(kBandRightInner, FloatBits(1024.0f - kBandWidth),
|
failed += re::PatchAll(kBandRightInner, FloatBits(1024.0f - kBandWidth),
|
||||||
FloatBits(static_cast<float>(width - kBandWidth)));
|
FloatBits(static_cast<float>(width - kBandWidth)));
|
||||||
@@ -196,6 +264,13 @@ void __cdecl SetZoomCentre() {
|
|||||||
const int width = std::max(mods::widescreen::RenderWidth(), kLayoutWidth);
|
const int width = std::max(mods::widescreen::RenderWidth(), kLayoutWidth);
|
||||||
g_zoomCentre = width / 2.0f - static_cast<float>(mods::hud::DrawOffsetX());
|
g_zoomCentre = width / 2.0f - static_cast<float>(mods::hud::DrawOffsetX());
|
||||||
g_zoomMarkerX = g_zoomCentre + kZoomMarkerFromCentre;
|
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
|
} // 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
|
||||||
+501
-69
@@ -1,5 +1,6 @@
|
|||||||
// Options window in three tabs: Allgemein, Beute, Tastenbelegung (request of
|
// Options window in three tabs: Allgemein, Beute, Tastenbelegung (request of
|
||||||
// 2026-10-18). Beute holds the game's auto-pickup settings (first iteration:
|
// 2026-10-18). Allgemein also gets the autosave interval (characters.cpp);
|
||||||
|
// Beute holds the game's auto-pickup settings (first iteration:
|
||||||
// moved there from the common page); Tastenbelegung sets the key bindings of
|
// moved there from the common page); Tastenbelegung sets the key bindings of
|
||||||
// mods/input.cpp, which had only been settable in sacredK.ini.
|
// mods/input.cpp, which had only been settable in sacredK.ini.
|
||||||
//
|
//
|
||||||
@@ -22,19 +23,25 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include "game/ui/button.h"
|
||||||
#include "game/ui/control.h"
|
#include "game/ui/control.h"
|
||||||
#include "game/ui/draw.h"
|
#include "game/ui/draw.h"
|
||||||
#include "game/ui/events.h"
|
#include "game/ui/events.h"
|
||||||
|
#include "game/ui/frame.h"
|
||||||
#include "game/ui/tabs.h"
|
#include "game/ui/tabs.h"
|
||||||
#include "game/ui/text.h"
|
#include "game/ui/text.h"
|
||||||
#include "game/ui/texts.h"
|
#include "game/ui/texts.h"
|
||||||
|
#include "mods/characters.h"
|
||||||
#include "mods/hud.h"
|
#include "mods/hud.h"
|
||||||
#include "mods/input.h"
|
#include "mods/input.h"
|
||||||
|
#include "mods/lootfilter.h"
|
||||||
|
#include "mods/monitor.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
@@ -45,21 +52,22 @@ namespace {
|
|||||||
constexpr size_t kWindowControls = 0x90; // vector of its controls: begin, +4 end
|
constexpr size_t kWindowControls = 0x90; // vector of its controls: begin, +4 end
|
||||||
constexpr size_t kCancelButton = 0x158;
|
constexpr size_t kCancelButton = 0x158;
|
||||||
constexpr size_t kOkButton = 0x15c;
|
constexpr size_t kOkButton = 0x15c;
|
||||||
// cUI_Frame (vtable 0x8955ac): its render (0x73a540) doesn't test the visible
|
// The game's pickup choice (PICKUPAUTO all / valuable / gold only): radio
|
||||||
// flag but +0xdc (set to 1 by its constructors, read nowhere else).
|
// buttons, their labels (UI_LABEL_COLLECT, UI_LABEL_COLLECT1..3) and the
|
||||||
constexpr uint32_t kFrameVtable = 0x008955ac;
|
// separator above them (UI_FRAME_SEP_AUTOCOLLECT). The loot filter replaces
|
||||||
constexpr size_t kFrameDrawn = 0xdc;
|
// it; they are never shown.
|
||||||
|
constexpr size_t kPickupRadios[] = {0x16c, 0x170, 0x174};
|
||||||
|
constexpr const char* kPickupControlNames[] = {"UI_LABEL_COLLECT", "UI_FRAME_SEP_AUTOCOLLECT"};
|
||||||
|
constexpr size_t kControlName = 0x30; // char[32], set by 0x732090
|
||||||
|
// cUI_Frame (game/ui/frame.h) draws by +0xdc, not by the visible flag.
|
||||||
// The key bindings page gets a translucent backdrop and a frame per column,
|
// The key bindings page gets a translucent backdrop and a frame per column,
|
||||||
// made as 0x7184e0 makes the others: cUI_Window2 0x724ed0 (rect*, colour;
|
// made as 0x7184e0 makes the others: cUI_Window2 0x724ed0 (rect*, colour;
|
||||||
// ret 8) adds a backdrop (cUI_Static) to +0x90; frames are cUI_Frame
|
// ret 8) adds a backdrop (cUI_Static) to +0x90; frames (game/ui/frame.h)
|
||||||
// (0xe0 bytes, constructor 0x739f90 (name, type 1, rect*, 1), ret 0x10)
|
|
||||||
// pushed onto +0x90 (vector push_back 0x6bcf50, (&pointer), ret 4).
|
// pushed onto +0x90 (vector push_back 0x6bcf50, (&pointer), ret 4).
|
||||||
// (Drawing the existing ones at other rects did not fit: a frame's border
|
// (Drawing the existing ones at other rects did not fit: a frame's border
|
||||||
// pieces keep the place they got when it was made.)
|
// pieces keep the place they got when it was made.)
|
||||||
constexpr uint32_t kBackdropColour = 0x9f000000;
|
constexpr uint32_t kBackdropColour = 0x9f000000;
|
||||||
constexpr int kFrameBorder = 0x14; // backdrop beyond the frame on each side
|
constexpr int kFrameBorder = 0x14; // backdrop beyond the frame on each side
|
||||||
constexpr size_t kFrameSize = 0xe0;
|
|
||||||
constexpr uint32_t kMainFrame = 1;
|
|
||||||
|
|
||||||
// Layout of 0x7184e0 (window coordinates): the frames' borders begin 0x14
|
// Layout of 0x7184e0 (window coordinates): the frames' borders begin 0x14
|
||||||
// left of / above the frame positions, their labels at y 0x20 / 0x100 / 0x1e0.
|
// left of / above the frame positions, their labels at y 0x20 / 0x100 / 0x1e0.
|
||||||
@@ -75,7 +83,7 @@ constexpr game::UiRect kSheetRect = {0x2c, 0x2c, 0, 0};
|
|||||||
constexpr int kContentShift = 24;
|
constexpr int kContentShift = 24;
|
||||||
|
|
||||||
enum Page : uint16_t { kPageCommon, kPageLoot, kPageKeys };
|
enum Page : uint16_t { kPageCommon, kPageLoot, kPageKeys };
|
||||||
enum class Group { Always, Common, Loot, Keys };
|
enum class Group { Always, Common, Loot, Keys, Hidden };
|
||||||
|
|
||||||
struct Control {
|
struct Control {
|
||||||
uint8_t* control;
|
uint8_t* control;
|
||||||
@@ -110,7 +118,7 @@ int32_t& ControlY(uint8_t* control) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool IsFrame(const uint8_t* control) {
|
bool IsFrame(const uint8_t* control) {
|
||||||
return *reinterpret_cast<const uint32_t*>(control) == kFrameVtable;
|
return *reinterpret_cast<const uint32_t*>(control) == game::kFrameVtable;
|
||||||
}
|
}
|
||||||
|
|
||||||
// cUI_Scrollbar (vtable 0x895664, the volume and map transparency sliders):
|
// cUI_Scrollbar (vtable 0x895664, the volume and map transparency sliders):
|
||||||
@@ -135,6 +143,21 @@ uint8_t* Field(uint8_t* window, size_t offset) {
|
|||||||
return *reinterpret_cast<uint8_t**>(window + offset);
|
return *reinterpret_cast<uint8_t**>(window + offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool IsPickupChoice(uint8_t* window, const uint8_t* control) {
|
||||||
|
for (const size_t radio : kPickupRadios) {
|
||||||
|
if (control == Field(window, radio)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const char* name = reinterpret_cast<const char*>(control + kControlName);
|
||||||
|
for (const char* prefix : kPickupControlNames) {
|
||||||
|
if (std::strncmp(name, prefix, std::strlen(prefix)) == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Sorts the controls into pages, moves the auto-pickup and general frames.
|
// Sorts the controls into pages, moves the auto-pickup and general frames.
|
||||||
void Classify(Window& w) {
|
void Classify(Window& w) {
|
||||||
auto** begin = *reinterpret_cast<uint8_t***>(w.window + kWindowControls);
|
auto** begin = *reinterpret_cast<uint8_t***>(w.window + kWindowControls);
|
||||||
@@ -152,7 +175,7 @@ void Classify(Window& w) {
|
|||||||
if (control == ok || control == cancel) {
|
if (control == ok || control == cancel) {
|
||||||
group = Group::Always;
|
group = Group::Always;
|
||||||
} else if (x < kColumnSplit && y >= kMiddleRowTop && y < kBottomRowTop) {
|
} else if (x < kColumnSplit && y >= kMiddleRowTop && y < kBottomRowTop) {
|
||||||
group = Group::Loot;
|
group = IsPickupChoice(w.window, control) ? Group::Hidden : Group::Loot;
|
||||||
MoveDown(control, -kRowStep);
|
MoveDown(control, -kRowStep);
|
||||||
} else if (x < kColumnSplit && y >= kBottomRowTop) {
|
} else if (x < kColumnSplit && y >= kBottomRowTop) {
|
||||||
MoveDown(control, -kRowStep);
|
MoveDown(control, -kRowStep);
|
||||||
@@ -182,6 +205,7 @@ void CreateTabs(Window& w) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool OnPage(Group group, uint16_t page) {
|
bool OnPage(Group group, uint16_t page) {
|
||||||
|
// Group::Hidden: on no page.
|
||||||
return group == Group::Always || (group == Group::Common && page == kPageCommon) ||
|
return group == Group::Always || (group == Group::Common && page == kPageCommon) ||
|
||||||
(group == Group::Loot && page == kPageLoot) || (group == Group::Keys && page == kPageKeys);
|
(group == Group::Loot && page == kPageLoot) || (group == Group::Keys && page == kPageKeys);
|
||||||
}
|
}
|
||||||
@@ -198,7 +222,7 @@ void ShowPage(Window& w, uint16_t page) {
|
|||||||
c.hiddenByUs = false;
|
c.hiddenByUs = false;
|
||||||
}
|
}
|
||||||
if (IsFrame(c.control)) {
|
if (IsFrame(c.control)) {
|
||||||
c.control[kFrameDrawn] = show ? 1 : 0;
|
c.control[game::kFrameDrawn] = show ? 1 : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,10 +325,6 @@ game::UiRect OnWindow(void* window, const game::UiRect& r) {
|
|||||||
return {origin.x + r.x, origin.y + r.y, r.w, r.h};
|
return {origin.x + r.x, origin.y + r.y, r.w, r.h};
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Inside(const game::UiRect& r, int x, int y) {
|
|
||||||
return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
|
|
||||||
}
|
|
||||||
|
|
||||||
void FillBox(void* device, const game::UiRect& r, uint32_t colour) {
|
void FillBox(void* device, const game::UiRect& r, uint32_t colour) {
|
||||||
game::FillRect(device, static_cast<float>(r.x), static_cast<float>(r.y), static_cast<float>(r.x + r.w),
|
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);
|
static_cast<float>(r.y + r.h), colour);
|
||||||
@@ -387,25 +407,13 @@ void ResetPending() {
|
|||||||
// The binding whose box is at (x, y), -1 if none.
|
// The binding whose box is at (x, y), -1 if none.
|
||||||
int BoxAt(void* window, int x, int y) {
|
int BoxAt(void* window, int x, int y) {
|
||||||
for (const Line& line : Layout()) {
|
for (const Line& line : Layout()) {
|
||||||
if (line.kind == LineKind::Binding && Inside(BoxRect(window, line), x, y)) {
|
if (line.kind == LineKind::Binding && game::Inside(BoxRect(window, line), x, y)) {
|
||||||
return line.binding;
|
return line.binding;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// cUI_Button2 (vtable 0x895308, 0xbc bytes, constructor 0x72a180, plain
|
|
||||||
// ret). 0x7184e0 sets up Cancel and OK the same way: images (+0x54..+0x87),
|
|
||||||
// sounds (0x72acf0 -> +0x90..+0x9f), caption (0x72ad60 (text id, font 1) ->
|
|
||||||
// +0xa4 / +0xa0), caption offset (0x72ad40 -> +0xb0), name (0x732090).
|
|
||||||
constexpr size_t kButtonSize = 0xbc;
|
|
||||||
constexpr size_t kButtonLook = 0x54;
|
|
||||||
constexpr size_t kButtonLookEnd = 0x88;
|
|
||||||
constexpr size_t kButtonSounds = 0x90;
|
|
||||||
constexpr size_t kButtonSoundsEnd = 0xa0;
|
|
||||||
constexpr size_t kButtonCaptionOffset = 0xb0; // x, y (int16)
|
|
||||||
constexpr uint32_t kButtonFlags = 5; // 0x731e70, as 0x7184e0 passes
|
|
||||||
|
|
||||||
void PushControl(uint8_t* window, void* control) {
|
void PushControl(uint8_t* window, void* control) {
|
||||||
using PushFn = void(__fastcall*)(void*, void*, void**);
|
using PushFn = void(__fastcall*)(void*, void*, void**);
|
||||||
reinterpret_cast<PushFn>(0x006bcf50)(window + kWindowControls, nullptr, &control);
|
reinterpret_cast<PushFn>(0x006bcf50)(window + kWindowControls, nullptr, &control);
|
||||||
@@ -414,58 +422,424 @@ void PushControl(uint8_t* window, void* control) {
|
|||||||
// "Alle zurücksetzen": a button with Cancel's look.
|
// "Alle zurücksetzen": a button with Cancel's look.
|
||||||
uint8_t* CreateResetButton(uint8_t* window) {
|
uint8_t* CreateResetButton(uint8_t* window) {
|
||||||
static const uint32_t kCaption = game::RegisterText(L"Alle zurücksetzen");
|
static const uint32_t kCaption = game::RegisterText(L"Alle zurücksetzen");
|
||||||
uint8_t* cancel = Field(window, kCancelButton);
|
const uint8_t* cancel = Field(window, kCancelButton);
|
||||||
void* memory = cancel ? game::GameNew(kButtonSize) : nullptr;
|
if (!cancel) {
|
||||||
if (!memory) {
|
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
using ConstructFn = uint8_t*(__fastcall*)(void*, void*);
|
game::UiRect rect = *reinterpret_cast<const game::UiRect*>(cancel + game::kControlRect);
|
||||||
uint8_t* button = reinterpret_cast<ConstructFn>(0x0072a180)(memory, nullptr);
|
rect.x = kResetX;
|
||||||
using FlagsFn = void(__fastcall*)(void*, void*, uint32_t);
|
rect.w = kResetWidth;
|
||||||
reinterpret_cast<FlagsFn>(0x00731e70)(button, nullptr, kButtonFlags);
|
uint8_t* button = game::NewButtonLike(cancel, rect, kCaption, "UI_BTN_KEYS_RESET");
|
||||||
std::copy(cancel + kButtonLook, cancel + kButtonLookEnd, button + kButtonLook);
|
if (button) {
|
||||||
std::copy(cancel + kButtonSounds, cancel + kButtonSoundsEnd, button + kButtonSounds);
|
|
||||||
std::copy(cancel + kButtonCaptionOffset, cancel + kButtonCaptionOffset + 4, button + kButtonCaptionOffset);
|
|
||||||
auto* rect = reinterpret_cast<game::UiRect*>(button + game::kControlRect);
|
|
||||||
*rect = *reinterpret_cast<const game::UiRect*>(cancel + game::kControlRect);
|
|
||||||
rect->x = kResetX;
|
|
||||||
rect->w = kResetWidth;
|
|
||||||
using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t);
|
|
||||||
reinterpret_cast<CaptionFn>(0x0072ad60)(button, nullptr, kCaption, 1);
|
|
||||||
using NameFn = void(__fastcall*)(void*, void*, const char*);
|
|
||||||
reinterpret_cast<NameFn>(0x00732090)(button, nullptr, "UI_BTN_KEYS_RESET");
|
|
||||||
PushControl(window, button);
|
PushControl(window, button);
|
||||||
|
}
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A backdrop and a frame at `frame`, as the window's own.
|
||||||
|
void AddFrame(uint8_t* window, game::UiRect frame, const char* name) {
|
||||||
|
game::UiRect backdrop = {frame.x - kFrameBorder, frame.y - kFrameBorder,
|
||||||
|
static_cast<int16_t>(frame.w + 2 * kFrameBorder),
|
||||||
|
static_cast<int16_t>(frame.h + 2 * kFrameBorder)};
|
||||||
|
using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t);
|
||||||
|
reinterpret_cast<AddBackdropFn>(0x00724ed0)(window, nullptr, &backdrop, kBackdropColour);
|
||||||
|
if (uint8_t* control = game::NewFrame(name, frame)) {
|
||||||
|
PushControl(window, control);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t ControlCount(uint8_t* window) {
|
||||||
|
auto* controls = window + kWindowControls;
|
||||||
|
return static_cast<size_t>(*reinterpret_cast<uint8_t***>(controls + 4) - *reinterpret_cast<uint8_t***>(controls));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window's controls from index `first` on go to page group `group`.
|
||||||
|
void AssignFrom(Window& w, size_t first, Group group) {
|
||||||
|
auto** begin = *reinterpret_cast<uint8_t***>(w.window + kWindowControls);
|
||||||
|
auto** end = *reinterpret_cast<uint8_t***>(w.window + kWindowControls + 4);
|
||||||
|
for (auto** it = begin + first; it < end; ++it) {
|
||||||
|
if (*it) {
|
||||||
|
w.controls.push_back({*it, group});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The key bindings page's own controls, shown only there: a backdrop and
|
// The key bindings page's own controls, shown only there: a backdrop and
|
||||||
// frame around each column, the reset button.
|
// frame around each column, the reset button.
|
||||||
void CreateKeyControls(Window& w) {
|
void CreateKeyControls(Window& w) {
|
||||||
static const char* const kFrameNames[2] = {"UI_FRAME_KEYS_LEFT", "UI_FRAME_KEYS_RIGHT"};
|
static const char* const kFrameNames[2] = {"UI_FRAME_KEYS_LEFT", "UI_FRAME_KEYS_RIGHT"};
|
||||||
auto* controls = w.window + kWindowControls;
|
const size_t existing = ControlCount(w.window);
|
||||||
const auto existing = static_cast<size_t>(*reinterpret_cast<uint8_t***>(controls + 4) -
|
|
||||||
*reinterpret_cast<uint8_t***>(controls));
|
|
||||||
for (int column = 0; column < 2; ++column) {
|
for (int column = 0; column < 2; ++column) {
|
||||||
game::UiRect frame = {kFrameX[column], kFrameTop, kFrameWidth, kFrameHeight};
|
AddFrame(w.window, {kFrameX[column], kFrameTop, kFrameWidth, kFrameHeight}, kFrameNames[column]);
|
||||||
game::UiRect backdrop = {frame.x - kFrameBorder, frame.y - kFrameBorder,
|
|
||||||
static_cast<int16_t>(frame.w + 2 * kFrameBorder),
|
|
||||||
static_cast<int16_t>(frame.h + 2 * kFrameBorder)};
|
|
||||||
using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t);
|
|
||||||
reinterpret_cast<AddBackdropFn>(0x00724ed0)(w.window, nullptr, &backdrop, kBackdropColour);
|
|
||||||
if (void* memory = game::GameNew(kFrameSize)) {
|
|
||||||
using FrameFn = void*(__fastcall*)(void*, void*, const char*, uint32_t, game::UiRect*, uint32_t);
|
|
||||||
PushControl(w.window, reinterpret_cast<FrameFn>(0x00739f90)(memory, nullptr, kFrameNames[column],
|
|
||||||
kMainFrame, &frame, 1));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
w.resetButton = CreateResetButton(w.window);
|
w.resetButton = CreateResetButton(w.window);
|
||||||
// Everything after the window's own controls is ours.
|
AssignFrom(w, existing, Group::Keys);
|
||||||
auto** begin = *reinterpret_cast<uint8_t***>(controls);
|
|
||||||
auto** end = *reinterpret_cast<uint8_t***>(controls + 4);
|
|
||||||
for (auto** it = begin + existing; it < end; ++it) {
|
|
||||||
if (*it) {
|
|
||||||
w.controls.push_back({*it, Group::Keys});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- loot filter page ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Below the auto-pickup frame, across both columns: from the middle row's
|
||||||
|
// frame top to the bottom of the lowest frame. A row per category, a box per
|
||||||
|
// mode; two lines of hints at the bottom.
|
||||||
|
constexpr game::UiRect kLootFrame = {0x40, 0x120 + kContentShift, 0x220 + kFrameWidth - 0x40,
|
||||||
|
0x200 + 0x40 - 0x120};
|
||||||
|
constexpr int kLootTop = kLootFrame.y + 10;
|
||||||
|
constexpr int kLootLabelWidth = 0xd0;
|
||||||
|
constexpr int kLootBoxGap = 8;
|
||||||
|
constexpr int kLootBoxWidth =
|
||||||
|
(kLootFrame.w - 2 * kTextInset - kLootLabelWidth - (mods::lootfilter::kModeCount - 1) * kLootBoxGap) /
|
||||||
|
mods::lootfilter::kModeCount;
|
||||||
|
constexpr uint32_t kBoxSelectedColour = kBoxCaptureColour;
|
||||||
|
const wchar_t* const kLootHints[2] = {
|
||||||
|
L"Löschen: Beute verschwindet sofort. Ignorieren: die Aufheben-Taste lässt sie liegen.",
|
||||||
|
L"Standard: wird aufgehoben, Name mit der Beschriftungstaste. Immer anzeigen: Name auch ohne."};
|
||||||
|
|
||||||
|
mods::lootfilter::Mode g_lootPending[mods::lootfilter::kCategoryCount] = {};
|
||||||
|
int g_lootHover = -1; // category * kModeCount + mode under the mouse
|
||||||
|
|
||||||
|
void LoadLootPending() {
|
||||||
|
for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) {
|
||||||
|
g_lootPending[category] = mods::lootfilter::GetMode(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CreateLootControls(Window& w) {
|
||||||
|
const size_t existing = ControlCount(w.window);
|
||||||
|
AddFrame(w.window, kLootFrame, "UI_FRAME_LOOTFILTER");
|
||||||
|
AssignFrom(w, existing, Group::Loot);
|
||||||
|
}
|
||||||
|
|
||||||
|
game::UiRect LootRowRect(void* window, int row, int dx, int width) {
|
||||||
|
const game::UiRect origin = game::ControlScreenRect(window);
|
||||||
|
return {origin.x + kLootFrame.x + kTextInset + dx, origin.y + kLootTop + row * kLine,
|
||||||
|
static_cast<int16_t>(width), static_cast<int16_t>(kBoxHeight)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 0: the mode names; rows 1.. the categories.
|
||||||
|
game::UiRect LootBoxRect(void* window, int row, int mode) {
|
||||||
|
return LootRowRect(window, row, kLootLabelWidth + mode * (kLootBoxWidth + kLootBoxGap), kLootBoxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
int LootBoxAt(void* window, int x, int y) {
|
||||||
|
for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) {
|
||||||
|
for (int mode = 0; mode < mods::lootfilter::kModeCount; ++mode) {
|
||||||
|
if (mods::lootfilter::ModeAllowed(category, static_cast<mods::lootfilter::Mode>(mode)) &&
|
||||||
|
game::Inside(LootBoxRect(window, category + 1, mode), x, y)) {
|
||||||
|
return category * mods::lootfilter::kModeCount + mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawLootPage(const Window& w, void* device) {
|
||||||
|
using namespace mods::lootfilter;
|
||||||
|
void* window = w.window;
|
||||||
|
const game::UiRect origin = game::ControlScreenRect(window);
|
||||||
|
// The title in the header row's label cell (above the frame it met the
|
||||||
|
// frame's backdrop).
|
||||||
|
game::DrawText(device, kFont, game::CenterLineVertically(kFont, LootRowRect(window, 0, 0, kLootLabelWidth)),
|
||||||
|
L"Beutefilter", kHeadingColour, 0);
|
||||||
|
for (int mode = 0; mode < kModeCount; ++mode) {
|
||||||
|
game::DrawText(device, kFont, game::CenterLineVertically(kFont, LootBoxRect(window, 0, mode)),
|
||||||
|
ModeLabel(static_cast<Mode>(mode)), kHeadingColour, game::kTextCenter);
|
||||||
|
}
|
||||||
|
for (int category = 0; category < kCategoryCount; ++category) {
|
||||||
|
const int row = category + 1;
|
||||||
|
game::DrawText(device, kFont, game::CenterLineVertically(kFont, LootRowRect(window, row, 0, kLootLabelWidth)),
|
||||||
|
CategoryLabel(category), CategoryColour(category), 0);
|
||||||
|
for (int mode = 0; mode < kModeCount; ++mode) {
|
||||||
|
if (!ModeAllowed(category, static_cast<Mode>(mode))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bool selected = g_lootPending[category] == static_cast<Mode>(mode);
|
||||||
|
const bool hover = g_lootHover == category * kModeCount + mode;
|
||||||
|
FillBox(device, LootBoxRect(window, row, mode),
|
||||||
|
selected ? kBoxSelectedColour : hover ? kBoxHoverColour : kBoxColour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int lineHeight = game::FontLineHeight(kFont);
|
||||||
|
game::UiRect hint = {origin.x + kLootFrame.x + kTextInset,
|
||||||
|
origin.y + kLootFrame.y + kLootFrame.h - 2 * lineHeight - 10,
|
||||||
|
static_cast<int16_t>(kLootFrame.w - 2 * kTextInset), static_cast<int16_t>(lineHeight)};
|
||||||
|
for (const wchar_t* text : kLootHints) {
|
||||||
|
game::DrawText(device, kFont, hint, text, kTextColour, game::kTextCenter);
|
||||||
|
hint.y += lineHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- saves (Allgemein page) ------------------------------------------------------
|
||||||
|
|
||||||
|
// The autosave interval (request of 2026-10-20), in the left column below
|
||||||
|
// the general frame (moved up, it is taller than the frames beside it): a
|
||||||
|
// frame of its own, as the right column's lowest frame (Internet-Optionen):
|
||||||
|
// its height, and the gap the right column has between its frames, both
|
||||||
|
// measured when the window is made. (A frame lower than that breaks its
|
||||||
|
// side ornaments; screenshots 2026-10-20.)
|
||||||
|
constexpr int kSavesFallbackGap = 0x50;
|
||||||
|
constexpr int16_t kSavesFallbackHeight = 0x40;
|
||||||
|
constexpr int kSavesLabelWidth = 0x90;
|
||||||
|
constexpr int kSavesBoxGap = 8;
|
||||||
|
constexpr int kAutosaveChoices[] = {0, 5, 10, 15, 30}; // minutes, 0: off
|
||||||
|
constexpr int kAutosaveChoiceCount = static_cast<int>(std::size(kAutosaveChoices));
|
||||||
|
constexpr int kSavesBoxWidth =
|
||||||
|
(kFrameWidth - 2 * kTextInset - kSavesLabelWidth - (kAutosaveChoiceCount - 1) * kSavesBoxGap) /
|
||||||
|
kAutosaveChoiceCount;
|
||||||
|
// Lighter than kBoxColour: on the frame's dark backdrop that one is not seen.
|
||||||
|
constexpr uint32_t kSavesBoxColour = 0xc0303030;
|
||||||
|
constexpr uint32_t kSavesBoxHoverColour = 0xc0484848;
|
||||||
|
|
||||||
|
game::UiRect g_savesFrame = {kFrameX[0], 0, kFrameWidth, kSavesFallbackHeight};
|
||||||
|
int g_autosavePending = 0; // minutes until OK
|
||||||
|
int g_savesHover = -1; // choice under the mouse
|
||||||
|
|
||||||
|
void CreateSavesControls(Window& w) {
|
||||||
|
int bottom = 0;
|
||||||
|
std::vector<game::UiRect> right;
|
||||||
|
for (const Control& c : w.controls) {
|
||||||
|
if (c.group != Group::Common || !IsFrame(c.control)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const game::UiRect rect = *reinterpret_cast<const game::UiRect*>(c.control + game::kControlRect);
|
||||||
|
if (ControlX(c.control) < kColumnSplit) {
|
||||||
|
bottom = std::max(bottom, rect.y + rect.h);
|
||||||
|
} else if (rect.w == kFrameWidth) {
|
||||||
|
right.push_back(rect); // the main frames, not their label plates (200 wide)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The lowest frame and the nearest one ending above it. (Measured: the
|
||||||
|
// right column's frames are 384 wide at y 88, 312, 536, 128 / 128 / 64
|
||||||
|
// high: gaps of 96. Their label plates are frames too, 200 wide.)
|
||||||
|
std::sort(right.begin(), right.end(), [](const game::UiRect& a, const game::UiRect& b) { return a.y < b.y; });
|
||||||
|
int gap = kSavesFallbackGap;
|
||||||
|
if (!right.empty()) {
|
||||||
|
const game::UiRect& lowest = right.back();
|
||||||
|
int aboveBottom = -1;
|
||||||
|
for (const game::UiRect& r : right) {
|
||||||
|
if (r.y + r.h <= lowest.y) {
|
||||||
|
aboveBottom = std::max(aboveBottom, r.y + r.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aboveBottom >= 0) {
|
||||||
|
gap = lowest.y - aboveBottom;
|
||||||
|
}
|
||||||
|
g_savesFrame.h = lowest.h;
|
||||||
|
}
|
||||||
|
g_savesFrame.y = bottom + gap;
|
||||||
|
const size_t existing = ControlCount(w.window);
|
||||||
|
AddFrame(w.window, g_savesFrame, "UI_FRAME_SAVES");
|
||||||
|
AssignFrom(w, existing, Group::Common);
|
||||||
|
}
|
||||||
|
|
||||||
|
game::UiRect SavesRowRect(void* window, int dx, int width) {
|
||||||
|
const game::UiRect origin = game::ControlScreenRect(window);
|
||||||
|
return {origin.x + g_savesFrame.x + kTextInset + dx, origin.y + g_savesFrame.y + (g_savesFrame.h - kBoxHeight) / 2,
|
||||||
|
static_cast<int16_t>(width), static_cast<int16_t>(kBoxHeight)};
|
||||||
|
}
|
||||||
|
|
||||||
|
game::UiRect SavesBoxRect(void* window, int choice) {
|
||||||
|
return SavesRowRect(window, kSavesLabelWidth + choice * (kSavesBoxWidth + kSavesBoxGap), kSavesBoxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
int SavesBoxAt(void* window, int x, int y) {
|
||||||
|
for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) {
|
||||||
|
if (game::Inside(SavesBoxRect(window, choice), x, y)) {
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawSaves(const Window& w, void* device) {
|
||||||
|
void* window = w.window;
|
||||||
|
game::DrawText(device, kFont, game::CenterLineVertically(kFont, SavesRowRect(window, 0, kSavesLabelWidth)),
|
||||||
|
L"Autosave (Min.)", kTextColour, 0);
|
||||||
|
for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) {
|
||||||
|
const game::UiRect box = SavesBoxRect(window, choice);
|
||||||
|
const int minutes = kAutosaveChoices[choice];
|
||||||
|
FillBox(device, box,
|
||||||
|
g_autosavePending == minutes ? kBoxSelectedColour
|
||||||
|
: g_savesHover == choice ? kSavesBoxHoverColour
|
||||||
|
: kSavesBoxColour);
|
||||||
|
const std::wstring text = minutes ? std::to_wstring(minutes) : L"Aus";
|
||||||
|
game::DrawText(device, kFont, game::CenterLineVertically(kFont, box), text.c_str(), kTextColour,
|
||||||
|
game::kTextCenter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HandleSavesEvent(Window& w, void* event) {
|
||||||
|
switch (game::MouseEventKind(event)) {
|
||||||
|
case game::MouseEvent::Move:
|
||||||
|
g_savesHover = SavesBoxAt(w.window, game::EventField(event, 0xc), game::EventField(event, 0x10));
|
||||||
|
return false;
|
||||||
|
case game::MouseEvent::ButtonDown: {
|
||||||
|
if (game::EventField(event, game::kMouseButton) != game::kLeftButton) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int box = SavesBoxAt(w.window, game::EventField(event, game::kMouseButtonX),
|
||||||
|
game::EventField(event, game::kMouseButtonY));
|
||||||
|
if (box < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
g_autosavePending = kAutosaveChoices[box];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- monitor (Allgemein page) ----------------------------------------------------
|
||||||
|
|
||||||
|
// Preferred monitor (request of 2026-10-25, mods/monitor.h) in place of the
|
||||||
|
// Internet-Optionen frame (Datendurchsatz, multiplayer only; the user's
|
||||||
|
// choice): its controls are hidden (the game keeps the saved value), its title
|
||||||
|
// reads "Bildschirm" (text UI_CFG_WAN, set by 0x672a20 / 0x724d50 when the
|
||||||
|
// window is made). One row: "Monitor" and a dropdown box; the list opens
|
||||||
|
// upwards (the frame is the lowest one), a click picks, a click elsewhere
|
||||||
|
// closes it. OK saves the choice, which applies at the next start.
|
||||||
|
constexpr const char* kScreenFrameName = "UI_FRAME_INTERNET";
|
||||||
|
constexpr const char* kScreenTitleName = "UI_LABEL_WAN";
|
||||||
|
constexpr const char* kScreenTitleKey = "UI_CFG_WAN";
|
||||||
|
constexpr int kScreenLabelWidth = 0x70;
|
||||||
|
constexpr int kArrowWidth = 9; // the box's down arrow, 5 rows
|
||||||
|
|
||||||
|
struct ScreenChoice {
|
||||||
|
std::string id; // empty: the main monitor
|
||||||
|
std::wstring text;
|
||||||
|
};
|
||||||
|
|
||||||
|
game::UiRect g_screenFrame = {};
|
||||||
|
std::vector<ScreenChoice> g_screenChoices;
|
||||||
|
std::string g_screenPending;
|
||||||
|
bool g_screenOpen = false;
|
||||||
|
int g_screenHover = -1; // list entry under the mouse
|
||||||
|
|
||||||
|
void CreateScreenControls(Window& w) {
|
||||||
|
for (const Control& c : w.controls) {
|
||||||
|
if (IsFrame(c.control) && game::ControlNamed(c.control, kScreenFrameName)) {
|
||||||
|
g_screenFrame = *reinterpret_cast<const game::UiRect*>(c.control + game::kControlRect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Control& c : w.controls) {
|
||||||
|
const game::UiRect r = *reinterpret_cast<const game::UiRect*>(c.control + game::kControlRect);
|
||||||
|
const bool inside = r.x >= g_screenFrame.x && r.y >= g_screenFrame.y &&
|
||||||
|
r.x < g_screenFrame.x + g_screenFrame.w && r.y < g_screenFrame.y + g_screenFrame.h;
|
||||||
|
if (c.group == Group::Common &&
|
||||||
|
(game::ControlNamed(c.control, kScreenTitleName) || (inside && !IsFrame(c.control)))) {
|
||||||
|
c.group = Group::Hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadScreenPending() {
|
||||||
|
g_screenChoices = {{"", L"Hauptmonitor"}};
|
||||||
|
for (const mods::monitor::Monitor& m : mods::monitor::List()) {
|
||||||
|
g_screenChoices.push_back({m.id, m.name + L" (" + std::to_wstring(m.rect.right - m.rect.left) + L"x" +
|
||||||
|
std::to_wstring(m.rect.bottom - m.rect.top) + L")"});
|
||||||
|
}
|
||||||
|
g_screenPending = mods::monitor::PreferredId();
|
||||||
|
g_screenOpen = false;
|
||||||
|
g_screenHover = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wchar_t* ScreenPendingText() {
|
||||||
|
for (const ScreenChoice& choice : g_screenChoices) {
|
||||||
|
if (choice.id == g_screenPending) {
|
||||||
|
return choice.text.c_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return L"Nicht verbunden";
|
||||||
|
}
|
||||||
|
|
||||||
|
game::UiRect ScreenRowRect(void* window, int dx, int width) {
|
||||||
|
const game::UiRect origin = game::ControlScreenRect(window);
|
||||||
|
return {origin.x + g_screenFrame.x + kTextInset + dx,
|
||||||
|
origin.y + g_screenFrame.y + (g_screenFrame.h - kBoxHeight) / 2, static_cast<int16_t>(width),
|
||||||
|
static_cast<int16_t>(kBoxHeight)};
|
||||||
|
}
|
||||||
|
|
||||||
|
game::UiRect ScreenBoxRect(void* window) {
|
||||||
|
return ScreenRowRect(window, kScreenLabelWidth, g_screenFrame.w - 2 * kTextInset - kScreenLabelWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List entry `index`, upwards from the box: the last entry right above it.
|
||||||
|
game::UiRect ScreenEntryRect(void* window, int index) {
|
||||||
|
game::UiRect r = ScreenBoxRect(window);
|
||||||
|
r.y -= static_cast<int>(g_screenChoices.size() - index) * kBoxHeight;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ScreenEntryAt(void* window, int x, int y) {
|
||||||
|
for (int i = 0; i < static_cast<int>(g_screenChoices.size()); ++i) {
|
||||||
|
if (game::Inside(ScreenEntryRect(window, i), x, y)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawScreen(const Window& w, void* device) {
|
||||||
|
void* window = w.window;
|
||||||
|
game::DrawText(device, kFont, game::CenterLineVertically(kFont, ScreenRowRect(window, 0, kScreenLabelWidth)),
|
||||||
|
L"Monitor", kTextColour, 0);
|
||||||
|
const game::UiRect box = ScreenBoxRect(window);
|
||||||
|
FillBox(device, box, g_screenOpen ? kSavesBoxHoverColour : kSavesBoxColour);
|
||||||
|
game::UiRect text = box;
|
||||||
|
text.x += 6;
|
||||||
|
text.w = static_cast<int16_t>(text.w - 6 - kArrowWidth - 8);
|
||||||
|
game::DrawText(device, kFont, game::CenterLineVertically(kFont, text), ScreenPendingText(), kTextColour, 0);
|
||||||
|
// The arrow: rows narrowing to a point.
|
||||||
|
const int arrowX = box.x + box.w - kArrowWidth - 6;
|
||||||
|
const int arrowY = box.y + (box.h - 5) / 2;
|
||||||
|
for (int row = 0; row < 5; ++row) {
|
||||||
|
FillBox(device, {arrowX + row, arrowY + row, static_cast<int16_t>(kArrowWidth - 2 * row), 1}, kTextColour);
|
||||||
|
}
|
||||||
|
if (g_screenPending != mods::monitor::PreferredId()) {
|
||||||
|
game::UiRect hint = {box.x, box.y + box.h + 2, box.w, static_cast<int16_t>(game::FontLineHeight(kFont))};
|
||||||
|
game::DrawText(device, kFont, hint, L"Ab dem nächsten Spielstart", kUnboundColour, 0);
|
||||||
|
}
|
||||||
|
if (!g_screenOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < static_cast<int>(g_screenChoices.size()); ++i) {
|
||||||
|
const game::UiRect entry = ScreenEntryRect(window, i);
|
||||||
|
const bool selected = g_screenChoices[i].id == g_screenPending;
|
||||||
|
FillBox(device, entry, selected ? kBoxSelectedColour : i == g_screenHover ? kBoxHoverColour : kBoxColour);
|
||||||
|
game::UiRect label = entry;
|
||||||
|
label.x += 6;
|
||||||
|
game::DrawText(device, kFont, game::CenterLineVertically(kFont, label), g_screenChoices[i].text.c_str(),
|
||||||
|
kTextColour, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HandleScreenEvent(Window& w, void* event) {
|
||||||
|
switch (game::MouseEventKind(event)) {
|
||||||
|
case game::MouseEvent::Move:
|
||||||
|
g_screenHover =
|
||||||
|
g_screenOpen ? ScreenEntryAt(w.window, game::EventField(event, 0xc), game::EventField(event, 0x10)) : -1;
|
||||||
|
return false;
|
||||||
|
case game::MouseEvent::ButtonDown: {
|
||||||
|
const int x = game::EventField(event, game::kMouseButtonX);
|
||||||
|
const int y = game::EventField(event, game::kMouseButtonY);
|
||||||
|
if (g_screenOpen) {
|
||||||
|
const int entry = ScreenEntryAt(w.window, x, y);
|
||||||
|
if (entry >= 0 && game::EventField(event, game::kMouseButton) == game::kLeftButton) {
|
||||||
|
g_screenPending = g_screenChoices[entry].id;
|
||||||
|
}
|
||||||
|
g_screenOpen = false;
|
||||||
|
g_screenHover = -1;
|
||||||
|
return true; // the click only closes the list
|
||||||
|
}
|
||||||
|
if (game::EventField(event, game::kMouseButton) == game::kLeftButton &&
|
||||||
|
game::Inside(ScreenBoxRect(w.window), x, y)) {
|
||||||
|
g_screenOpen = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +863,9 @@ int RenderBody(void* context) {
|
|||||||
const bool visible = game::ControlShown(call->window);
|
const bool visible = game::ControlShown(call->window);
|
||||||
if (visible && !w->wasVisible) {
|
if (visible && !w->wasVisible) {
|
||||||
LoadPending();
|
LoadPending();
|
||||||
|
LoadLootPending();
|
||||||
|
g_autosavePending = mods::characters::AutosaveMinutes();
|
||||||
|
LoadScreenPending();
|
||||||
}
|
}
|
||||||
w->wasVisible = visible;
|
w->wasVisible = visible;
|
||||||
const uint16_t page = game::ActiveTab(w->sheet);
|
const uint16_t page = game::ActiveTab(w->sheet);
|
||||||
@@ -498,12 +875,25 @@ int RenderBody(void* context) {
|
|||||||
}
|
}
|
||||||
g_hover = -1;
|
g_hover = -1;
|
||||||
}
|
}
|
||||||
|
if (!visible || page != kPageLoot) {
|
||||||
|
g_lootHover = -1;
|
||||||
|
}
|
||||||
|
if (!visible || page != kPageCommon) {
|
||||||
|
g_savesHover = -1;
|
||||||
|
g_screenOpen = false;
|
||||||
|
g_screenHover = -1;
|
||||||
|
}
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
ShowPage(*w, page);
|
ShowPage(*w, page);
|
||||||
if (page == kPageKeys && g_pendingLoaded) {
|
if (page == kPageKeys && g_pendingLoaded) {
|
||||||
DrawKeysPage(*w, call->device);
|
DrawKeysPage(*w, call->device);
|
||||||
|
} else if (page == kPageLoot && g_pendingLoaded) {
|
||||||
|
DrawLootPage(*w, call->device);
|
||||||
|
} else if (page == kPageCommon && g_pendingLoaded) {
|
||||||
|
DrawSaves(*w, call->device);
|
||||||
|
DrawScreen(*w, call->device);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -514,9 +904,41 @@ struct EventCall {
|
|||||||
int(__fastcall* original)(void*, void*, void*);
|
int(__fastcall* original)(void*, void*, void*);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The loot filter page: a click on a box picks that mode.
|
||||||
|
bool HandleLootEvent(Window& w, void* event) {
|
||||||
|
switch (game::MouseEventKind(event)) {
|
||||||
|
case game::MouseEvent::Move:
|
||||||
|
g_lootHover = LootBoxAt(w.window, game::EventField(event, 0xc), game::EventField(event, 0x10));
|
||||||
|
return false;
|
||||||
|
case game::MouseEvent::ButtonDown: {
|
||||||
|
if (game::EventField(event, game::kMouseButton) != game::kLeftButton) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int box = LootBoxAt(w.window, game::EventField(event, game::kMouseButtonX),
|
||||||
|
game::EventField(event, game::kMouseButtonY));
|
||||||
|
if (box < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
g_lootPending[box / mods::lootfilter::kModeCount] =
|
||||||
|
static_cast<mods::lootfilter::Mode>(box % mods::lootfilter::kModeCount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int EventBody(void* context) {
|
int EventBody(void* context) {
|
||||||
auto* call = static_cast<EventCall*>(context);
|
auto* call = static_cast<EventCall*>(context);
|
||||||
Window* w = Find(call->window);
|
Window* w = Find(call->window);
|
||||||
|
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageLoot &&
|
||||||
|
g_pendingLoaded && HandleLootEvent(*w, call->event)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageCommon &&
|
||||||
|
g_pendingLoaded && (HandleScreenEvent(*w, call->event) || HandleSavesEvent(*w, call->event))) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys &&
|
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys &&
|
||||||
g_pendingLoaded) {
|
g_pendingLoaded) {
|
||||||
switch (game::MouseEventKind(call->event)) {
|
switch (game::MouseEventKind(call->event)) {
|
||||||
@@ -533,7 +955,7 @@ int EventBody(void* context) {
|
|||||||
if (g_capturing >= 0) {
|
if (g_capturing >= 0) {
|
||||||
StopCapture();
|
StopCapture();
|
||||||
}
|
}
|
||||||
if (left && w->resetButton && Inside(game::ControlScreenRect(w->resetButton), x, y)) {
|
if (left && w->resetButton && game::Inside(game::ControlScreenRect(w->resetButton), x, y)) {
|
||||||
ResetPending(); // the button still gets the click (pressed look, sound)
|
ResetPending(); // the button still gets the click (pressed look, sound)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -565,12 +987,19 @@ void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, Ui
|
|||||||
RE_HOOK(0x00716570, UiOptions_Construct);
|
RE_HOOK(0x00716570, UiOptions_Construct);
|
||||||
void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent,
|
void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent,
|
||||||
uint32_t flags) {
|
uint32_t flags) {
|
||||||
|
static const bool titled = [] {
|
||||||
|
game::SetTextOverride(game::GameTextIdByKey(kScreenTitleKey), L"Bildschirm");
|
||||||
|
return true;
|
||||||
|
}();
|
||||||
void* window = UiOptions_Construct_original(self, edx, name, rect, parent, flags);
|
void* window = UiOptions_Construct_original(self, edx, name, rect, parent, flags);
|
||||||
if (window) {
|
if (window) {
|
||||||
g_windows.push_back({static_cast<uint8_t*>(window)});
|
g_windows.push_back({static_cast<uint8_t*>(window)});
|
||||||
Window& w = g_windows.back();
|
Window& w = g_windows.back();
|
||||||
Classify(w);
|
Classify(w);
|
||||||
|
CreateScreenControls(w);
|
||||||
CreateKeyControls(w);
|
CreateKeyControls(w);
|
||||||
|
CreateLootControls(w);
|
||||||
|
CreateSavesControls(w);
|
||||||
CreateTabs(w);
|
CreateTabs(w);
|
||||||
re::Log("options: tabs created (%u controls)", static_cast<unsigned>(w.controls.size()));
|
re::Log("options: tabs created (%u controls)", static_cast<unsigned>(w.controls.size()));
|
||||||
}
|
}
|
||||||
@@ -610,6 +1039,9 @@ void __fastcall UiOptions_Apply(void* self, void* edx) {
|
|||||||
UiOptions_Apply_original(self, edx);
|
UiOptions_Apply_original(self, edx);
|
||||||
if (g_pendingLoaded) {
|
if (g_pendingLoaded) {
|
||||||
mods::input::SetBindingKeys(g_pending);
|
mods::input::SetBindingKeys(g_pending);
|
||||||
|
mods::lootfilter::SetModes(g_lootPending);
|
||||||
|
mods::characters::SetAutosaveMinutes(g_autosavePending);
|
||||||
|
mods::monitor::SetPreferredId(g_screenPending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#pragma once
|
||||||
|
// 16:9 menu screens (request of 2026-10-20, docs/screens.md): a screen of our
|
||||||
|
// own replaces a full-screen window of the game's (its render and mouse
|
||||||
|
// handling), laid out on a 1365x768 canvas (1920x1080 in UI units), centred in
|
||||||
|
// the render width; wider screens only get more background at the sides. The
|
||||||
|
// look is the game's: its frames and buttons on a dark background of ours.
|
||||||
|
//
|
||||||
|
// A screen hooks its window's render and event methods and calls Render /
|
||||||
|
// Handle with a body that draws / handles in canvas coordinates with the
|
||||||
|
// helpers below.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "game/ui/text.h"
|
||||||
|
#include "mods/hud.h"
|
||||||
|
|
||||||
|
namespace mods::screen {
|
||||||
|
|
||||||
|
using game::UiRect;
|
||||||
|
|
||||||
|
constexpr int kWidth = hud::kCanvasWidth;
|
||||||
|
constexpr int kHeight = hud::kCanvasHeight;
|
||||||
|
|
||||||
|
// Spacing: the game's frames reach about 0x20 beyond their rects, buttons
|
||||||
|
// about 0x21 (measured on the options window).
|
||||||
|
constexpr int kFrameOrnament = 0x20;
|
||||||
|
// The frames are made of pieces of 64: other sizes leave gaps (the game's
|
||||||
|
// frames are 384x128, 384x64; screenshot 2026-10-20).
|
||||||
|
constexpr int kFrameStep = 64;
|
||||||
|
constexpr int kButtonOrnament = 0x21;
|
||||||
|
constexpr int kMargin = kFrameOrnament + 16; // canvas edge to a panel's rect
|
||||||
|
constexpr int kPanelGap = 96; // between two panels' rects, as the options window's columns
|
||||||
|
constexpr int kPanelPadding = 16; // panel rect to its content
|
||||||
|
|
||||||
|
// The logo in the background's top left corner (render target coordinates;
|
||||||
|
// screens may overlap it).
|
||||||
|
constexpr int kLogoMargin = 48;
|
||||||
|
constexpr int kLogoHeight = 124;
|
||||||
|
constexpr int kLogoWidth = kLogoHeight * 74 / 100; // the emblem's aspect (screen.cpp)
|
||||||
|
|
||||||
|
constexpr uint16_t kFont = 1;
|
||||||
|
constexpr uint32_t kTextColour = 0xffe6d6aa; // as the options' texts
|
||||||
|
constexpr uint32_t kHeadingColour = 0xfff0c060; // as the options' headings
|
||||||
|
constexpr uint32_t kDimColour = 0xff8c8474;
|
||||||
|
constexpr uint32_t kRowColour = 0x40302418;
|
||||||
|
constexpr uint32_t kRowHoverColour = 0x80403020;
|
||||||
|
constexpr uint32_t kRowSelectedColour = 0xc0604818;
|
||||||
|
|
||||||
|
// The background of the menu screens across the whole render target (also
|
||||||
|
// beside the main menu's centred frame and full-screen windows in a game).
|
||||||
|
void DrawBackground(void* device);
|
||||||
|
// Called once per main menu frame (menu thread): whether a screen is shown
|
||||||
|
// (the main menu then does not draw centred).
|
||||||
|
bool BeginMenuFrame();
|
||||||
|
|
||||||
|
// Draws the screen of `window`: the background across the whole render
|
||||||
|
// target, then body(context) in canvas coordinates.
|
||||||
|
int Render(void* window, void* device, int (*body)(void* context), void* context);
|
||||||
|
// Runs body(context) with the event's mouse position in canvas coordinates.
|
||||||
|
int Handle(void* event, int (*body)(void* context), void* context);
|
||||||
|
|
||||||
|
void Fill(void* device, const UiRect& rect, uint32_t colour);
|
||||||
|
// One line of text in `rect`, centred vertically (game::kTextCenter /
|
||||||
|
// kTextRight for horizontal alignment).
|
||||||
|
void Text(void* device, const UiRect& rect, const std::wstring& text, uint32_t colour, uint32_t flags = 0);
|
||||||
|
|
||||||
|
UiRect Inset(const UiRect& rect, int by);
|
||||||
|
|
||||||
|
// A panel: a dark backdrop with the game's frame around `rect` (made anew when
|
||||||
|
// the rect changes) and an optional heading at its top.
|
||||||
|
class Panel {
|
||||||
|
public:
|
||||||
|
explicit Panel(const char* name) : name_(name) {}
|
||||||
|
// The content area below the heading.
|
||||||
|
UiRect Draw(void* device, const UiRect& rect, const std::wstring& heading = {});
|
||||||
|
|
||||||
|
private:
|
||||||
|
const char* name_;
|
||||||
|
UiRect rect_ = {};
|
||||||
|
uint8_t* frame_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A button of the game's (look and sounds of `model`, a button of the
|
||||||
|
// window), made on first use.
|
||||||
|
class Button {
|
||||||
|
public:
|
||||||
|
Button(const char* name, std::wstring caption) : name_(name), caption_(std::move(caption)) {}
|
||||||
|
void Draw(void* device, const uint8_t* model, const UiRect& rect);
|
||||||
|
// Passes the event to the button (pressed look, sound); true for a left
|
||||||
|
// click on it. Only for buttons drawn this frame.
|
||||||
|
bool Clicked(void* event);
|
||||||
|
void SetCaption(const std::wstring& caption);
|
||||||
|
|
||||||
|
private:
|
||||||
|
const char* name_;
|
||||||
|
std::wstring caption_;
|
||||||
|
uint32_t captionId_ = 0;
|
||||||
|
uint8_t* control_ = nullptr;
|
||||||
|
UiRect rect_ = {};
|
||||||
|
unsigned drawnFrame_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Height of the model's buttons (as the window made them).
|
||||||
|
int16_t ButtonHeight(const uint8_t* model);
|
||||||
|
|
||||||
|
// Two clicks on the same thing within the system's double-click time.
|
||||||
|
class DoubleClick {
|
||||||
|
public:
|
||||||
|
bool Click(int target);
|
||||||
|
|
||||||
|
private:
|
||||||
|
int last_ = -1;
|
||||||
|
unsigned long at_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mods::screen
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// serializer (vf1c(stream, 1, 0)); loadHero recreates them with
|
// serializer (vf1c(stream, 1, 0)); loadHero recreates them with
|
||||||
// cObjectManager::Create(type, 0, 0, 0), vf1c(stream, 0, file version) and
|
// cObjectManager::Create(type, 0, 0, 0), vf1c(stream, 0, file version) and
|
||||||
// gives them their new id. The shared chest does the same into
|
// gives them their new id. The shared chest does the same into
|
||||||
// save\shared_chest.json: written whenever a hero is saved, read at the start
|
// shared_chest.json in the save folder (mods/userfolders.cpp): written whenever a hero is saved, read at the start
|
||||||
// of each game session; cleared when the session ends (the ids die with the
|
// of each game session; cleared when the session ends (the ids die with the
|
||||||
// object manager).
|
// object manager).
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
#include "mods/itemstack.h"
|
#include "mods/itemstack.h"
|
||||||
#include "mods/sharedchest.h"
|
#include "mods/sharedchest.h"
|
||||||
#include "mods/skillslots.h"
|
#include "mods/skillslots.h"
|
||||||
#include "re/config.h"
|
#include "mods/userfolders.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
@@ -102,11 +102,7 @@ bool SharedOpen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string FilePath() {
|
std::string FilePath() {
|
||||||
std::string dir = re::ConfigString("paths", "GameDir", "");
|
return mods::userfolders::SaveDir() + "shared_chest.json";
|
||||||
if (!dir.empty() && dir.back() != '\\' && dir.back() != '/') {
|
|
||||||
dir += '\\';
|
|
||||||
}
|
|
||||||
return dir + "save\\shared_chest.json";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- items <-> JSON ------------------------------------------------------------
|
// --- items <-> JSON ------------------------------------------------------------
|
||||||
@@ -170,9 +166,17 @@ void Load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A session started or a save game was loaded into it (its objects replace
|
// A session started or a save game was loaded into it (its objects replace
|
||||||
// the old ones): (re)load on the next tick with a hero.
|
// the old ones): (re)load on the next tick with a hero. Until then the grid's
|
||||||
|
// ids are stale (they name the new world's objects) and nothing is saved: a
|
||||||
|
// hero saved inside the load (characters.cpp, Engine_Load, a migrated
|
||||||
|
// character's first load) wrote them into the file, the chest's items
|
||||||
|
// replaced by the world's (user, 2026-10-21).
|
||||||
void RequestLoad() {
|
void RequestLoad() {
|
||||||
|
g_loaded = false;
|
||||||
g_loadPending = true;
|
g_loadPending = true;
|
||||||
|
if (g_constructed) {
|
||||||
|
ClearGrid();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chest grid's place and size, as the cUI_Chest constructor sets them for
|
// The chest grid's place and size, as the cUI_Chest constructor sets them for
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace mods::sharedchest {
|
namespace mods::sharedchest {
|
||||||
|
|
||||||
// Writes the shared chest (save\shared_chest.json); called after a hero is saved.
|
// Writes the shared chest (shared_chest.json in the save folder); called after a hero is saved.
|
||||||
void Save();
|
void Save();
|
||||||
|
|
||||||
} // namespace mods::sharedchest
|
} // namespace mods::sharedchest
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
// whose tooltip names the potion too). Touching a teleporter runs
|
// whose tooltip names the potion too). Touching a teleporter runs
|
||||||
// cInterpretSQW::Teleporter (0x4ae330): it adds the portal to the hero's known ones (bit mask at hero +0x578) and posts
|
// cInterpretSQW::Teleporter (0x4ae330): it adds the portal to the hero's known ones (bit mask at hero +0x578) and posts
|
||||||
// a UI event (type 0x39) to DEFAULT_BUSYDLG with +8 = 2, +0xc = 5 (portal
|
// a UI event (type 0x39) to DEFAULT_BUSYDLG with +8 = 2, +0xc = 5 (portal
|
||||||
// mode; 6 for the underworld portals above 0xd) and +0x10 = the mask;
|
// mode; 6 for the underworld portals above 0xd, the same mask) and +0x10 =
|
||||||
|
// the mask;
|
||||||
// cUI_BusyDlg::HandleEvent (0x720c10) then calls SetMode (0x720180, (mode, a,
|
// cUI_BusyDlg::HandleEvent (0x720c10) then calls SetMode (0x720180, (mode, a,
|
||||||
// b), ret 0xc). Choosing a portal sends the engine an event whose case
|
// b), ret 0xc). Choosing a portal sends the engine an event whose case
|
||||||
// (0x619671, "EE_USEPORTAL") teleports the hero with cCreature::Teleport
|
// (0x619671, "EE_USEPORTAL") teleports the hero with cCreature::Teleport
|
||||||
@@ -26,11 +27,13 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "game/addresses.h"
|
||||||
#include "game/engine/events.h"
|
#include "game/engine/events.h"
|
||||||
#include "game/engine/items.h"
|
#include "game/engine/items.h"
|
||||||
#include "game/engine/objects.h"
|
#include "game/engine/objects.h"
|
||||||
#include "game/engine/orders.h"
|
#include "game/engine/orders.h"
|
||||||
#include "game/ui/texts.h"
|
#include "game/ui/texts.h"
|
||||||
|
#include "mods/charfile.h"
|
||||||
#include "mods/skillslots.h"
|
#include "mods/skillslots.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
@@ -55,7 +58,8 @@ constexpr uint32_t kUiEventBusyDialog = 0x39;
|
|||||||
constexpr uintptr_t kBusyDialogName = 0x0094d700; // "DEFAULT_BUSYDLG"
|
constexpr uintptr_t kBusyDialogName = 0x0094d700; // "DEFAULT_BUSYDLG"
|
||||||
constexpr uintptr_t kNoSender = 0x00aa24bc; // the game's empty name
|
constexpr uintptr_t kNoSender = 0x00aa24bc; // the game's empty name
|
||||||
constexpr uint32_t kBusyDialogSetMode = 2;
|
constexpr uint32_t kBusyDialogSetMode = 2;
|
||||||
constexpr uint32_t kBusyModePortals = 5;
|
constexpr uint32_t kBusyModePortals = 5; // Ancaria's portals
|
||||||
|
constexpr uint32_t kBusyModeUnderworldPortals = 6;
|
||||||
constexpr size_t kUiEventSize = 0x80;
|
constexpr size_t kUiEventSize = 0x80;
|
||||||
constexpr size_t kUiEventString = 0x68;
|
constexpr size_t kUiEventString = 0x68;
|
||||||
|
|
||||||
@@ -65,6 +69,13 @@ constexpr uintptr_t kPortalTeleportReturn = 0x006197c8;
|
|||||||
constexpr size_t kControlFlags = 0x10;
|
constexpr size_t kControlFlags = 0x10;
|
||||||
constexpr uint32_t kControlVisible = 1;
|
constexpr uint32_t kControlVisible = 1;
|
||||||
|
|
||||||
|
// The portal list of the running campaign (user, 2026-10-21: the potion
|
||||||
|
// showed Ancaria's portals in the Underworld), as the teleporters choose it.
|
||||||
|
uint32_t PortalMode() {
|
||||||
|
const uint16_t campaign = *reinterpret_cast<const uint16_t*>(game::addr::g_campaign);
|
||||||
|
return campaign == characters::kCampaignUnderworld ? kBusyModeUnderworldPortals : kBusyModePortals;
|
||||||
|
}
|
||||||
|
|
||||||
// The potion that opened the portal window, until it is used or the window
|
// The potion that opened the portal window, until it is used or the window
|
||||||
// closes; and the dialog, once it showed for it.
|
// closes; and the dialog, once it showed for it.
|
||||||
uint32_t g_pending = 0;
|
uint32_t g_pending = 0;
|
||||||
@@ -77,7 +88,7 @@ void OpenPortalWindow(uint32_t knownPortals) {
|
|||||||
uint32_t, uint32_t, uint32_t);
|
uint32_t, uint32_t, uint32_t);
|
||||||
reinterpret_cast<CtorFn>(0x00553080)(event, nullptr, kUiEventBusyDialog, reinterpret_cast<const char*>(kBusyDialogName),
|
reinterpret_cast<CtorFn>(0x00553080)(event, nullptr, kUiEventBusyDialog, reinterpret_cast<const char*>(kBusyDialogName),
|
||||||
reinterpret_cast<const char*>(kNoSender),
|
reinterpret_cast<const char*>(kNoSender),
|
||||||
kBusyDialogSetMode, kBusyModePortals, knownPortals, 0, 0, 0);
|
kBusyDialogSetMode, PortalMode(), knownPortals, 0, 0, 0);
|
||||||
game::PostEvent(event);
|
game::PostEvent(event);
|
||||||
using FreeFn = void(__fastcall*)(void*, void*);
|
using FreeFn = void(__fastcall*)(void*, void*);
|
||||||
reinterpret_cast<FreeFn>(0x006e85d0)(event + kUiEventString, nullptr);
|
reinterpret_cast<FreeFn>(0x006e85d0)(event + kUiEventString, nullptr);
|
||||||
@@ -162,7 +173,7 @@ void __fastcall BusyDlg_SetMode(void* self, void* edx, uint32_t mode, uint32_t a
|
|||||||
using namespace mods::teleportpotion;
|
using namespace mods::teleportpotion;
|
||||||
if (g_pending) {
|
if (g_pending) {
|
||||||
g_dialog = static_cast<uint8_t*>(self);
|
g_dialog = static_cast<uint8_t*>(self);
|
||||||
if (mode != kBusyModePortals) {
|
if (mode != kBusyModePortals && mode != kBusyModeUnderworldPortals) {
|
||||||
g_pending = 0;
|
g_pending = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// User folders (request of 2026-10-19): save games and the game's settings
|
||||||
|
// live in the user's Documents instead of the game folder:
|
||||||
|
// Documents\Sacred\save\ GAMEnn.PAK, Heronn.pax, shared_chest.json
|
||||||
|
// Documents\Sacred\config\ Settings.cfg
|
||||||
|
//
|
||||||
|
// Measured: sacred.exe names these files with paths relative to the game
|
||||||
|
// folder (the working directory), from 37 code sites: "./SAVE/GAME%.2d.PAK",
|
||||||
|
// "SAVE/GAME%.2d.PAK", "./SAVE/GAMEF%d.PAK", "./SAVE/SAVE02.PAK",
|
||||||
|
// "Save\Hero%.2d.pax", ".\Save", "Save", "SAVE\" and "Settings.cfg"
|
||||||
|
// (0x95c4ec..0x95d5f4, 0x9e15a0, 0xa18dc4). It reaches files only through
|
||||||
|
// kernel32's ANSI file functions, so those are hooked: a path whose first
|
||||||
|
// component is "save", or which is "Settings.cfg", is rewritten into the user
|
||||||
|
// folder; every other path passes unchanged. One choke point instead of 37
|
||||||
|
// patched operands, and no long absolute path in the game's fixed buffers.
|
||||||
|
//
|
||||||
|
// Migration: the first time a folder is used and does not exist yet, the game
|
||||||
|
// folder's files are copied into it (the originals stay where they were).
|
||||||
|
// Config.exe and gameserver.exe are separate programs and still use the game
|
||||||
|
// folder.
|
||||||
|
//
|
||||||
|
// The game's own view of the save folder can be moved into a subfolder
|
||||||
|
// (SetGameSaveSubfolder): the characters mod (characters.cpp) keeps the game's
|
||||||
|
// files in a work folder and its own files beside it.
|
||||||
|
|
||||||
|
#include "mods/userfolders.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <shlobj.h>
|
||||||
|
|
||||||
|
#include "re/hook.h"
|
||||||
|
#include "re/log.h"
|
||||||
|
|
||||||
|
namespace mods::userfolders {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr char kSaveFolder[] = "save";
|
||||||
|
constexpr char kSettingsFile[] = "Settings.cfg";
|
||||||
|
constexpr wchar_t kUserFolder[] = L"Sacred";
|
||||||
|
constexpr wchar_t kUserSaveFolder[] = L"save";
|
||||||
|
constexpr wchar_t kUserConfigFolder[] = L"config";
|
||||||
|
|
||||||
|
bool IsSeparator(char c) {
|
||||||
|
return c == '\\' || c == '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EqualsNoCase(std::string_view a, std::string_view b) {
|
||||||
|
return a.size() == b.size() && _strnicmp(a.data(), b.data(), a.size()) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The game folder (sacred.exe's), with trailing separator.
|
||||||
|
const std::string& GameDir() {
|
||||||
|
static const std::string dir = [] {
|
||||||
|
char path[MAX_PATH];
|
||||||
|
DWORD length = GetModuleFileNameA(nullptr, path, MAX_PATH);
|
||||||
|
std::string result(path, length);
|
||||||
|
return result.substr(0, result.find_last_of("\\/") + 1);
|
||||||
|
}();
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring Widen(const std::string& text) {
|
||||||
|
std::wstring result(MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, nullptr, 0), L'\0');
|
||||||
|
MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, result.data(), static_cast<int>(result.size()));
|
||||||
|
result.pop_back();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `path` in the game's code page; empty when a character has no ANSI form.
|
||||||
|
std::string Narrow(const std::wstring& path) {
|
||||||
|
char buffer[MAX_PATH];
|
||||||
|
BOOL lossy = FALSE;
|
||||||
|
int length = WideCharToMultiByte(CP_ACP, WC_NO_BEST_FIT_CHARS, path.c_str(), -1, buffer, MAX_PATH, nullptr,
|
||||||
|
&lossy);
|
||||||
|
return length > 0 && !lossy ? std::string(buffer) : std::string();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Exists(const std::wstring& path) {
|
||||||
|
return GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copies the files of `from` into `to` (not recursive; the save folder is flat).
|
||||||
|
void CopyFiles(const std::wstring& from, const std::wstring& to) {
|
||||||
|
WIN32_FIND_DATAW found;
|
||||||
|
HANDLE find = FindFirstFileW((from + L"\\*").c_str(), &found);
|
||||||
|
if (find == INVALID_HANDLE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int copied = 0;
|
||||||
|
do {
|
||||||
|
if (!(found.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) &&
|
||||||
|
CopyFileW((from + L"\\" + found.cFileName).c_str(), (to + L"\\" + found.cFileName).c_str(), TRUE)) {
|
||||||
|
++copied;
|
||||||
|
}
|
||||||
|
} while (FindNextFileW(find, &found));
|
||||||
|
FindClose(find);
|
||||||
|
re::Log("user folders: copied %d files from the game's save folder", copied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user folders without trailing separator, in the game's code page; both
|
||||||
|
// empty when the Documents folder can't be used (the game folder stays).
|
||||||
|
struct Folders {
|
||||||
|
std::string save;
|
||||||
|
std::string config;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wide file functions only: the hooked ANSI ones would map the game folder's
|
||||||
|
// files onto themselves.
|
||||||
|
Folders FindFolders() {
|
||||||
|
PWSTR documents = nullptr;
|
||||||
|
HRESULT result = SHGetKnownFolderPath(FOLDERID_Documents, 0, nullptr, &documents);
|
||||||
|
std::wstring root = SUCCEEDED(result) ? std::wstring(documents) + L"\\" + kUserFolder : std::wstring();
|
||||||
|
CoTaskMemFree(documents);
|
||||||
|
|
||||||
|
Folders folders;
|
||||||
|
if (root.empty() || Narrow(root).empty()) {
|
||||||
|
re::Log("user folders: Documents folder unusable (0x%08lx), files stay in the game folder", result);
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
const std::wstring save = root + L"\\" + kUserSaveFolder;
|
||||||
|
const std::wstring config = root + L"\\" + kUserConfigFolder;
|
||||||
|
const std::wstring gameDir = Widen(GameDir());
|
||||||
|
CreateDirectoryW(root.c_str(), nullptr);
|
||||||
|
if (!Exists(save)) {
|
||||||
|
CreateDirectoryW(save.c_str(), nullptr);
|
||||||
|
CopyFiles(gameDir + Widen(kSaveFolder), save);
|
||||||
|
}
|
||||||
|
if (!Exists(config)) {
|
||||||
|
CreateDirectoryW(config.c_str(), nullptr);
|
||||||
|
if (CopyFileW((gameDir + Widen(kSettingsFile)).c_str(), (config + L"\\" + Widen(kSettingsFile)).c_str(),
|
||||||
|
TRUE)) {
|
||||||
|
re::Log("user folders: copied the game's %s", kSettingsFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folders.save = Narrow(save);
|
||||||
|
folders.config = Narrow(config);
|
||||||
|
re::Log("user folders: save games in %s, settings in %s", folders.save.c_str(), folders.config.c_str());
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below the save folder, with leading separator ("\characters\_work"); empty:
|
||||||
|
// the save folder itself.
|
||||||
|
std::string& GameSaveSubfolder() {
|
||||||
|
static std::string subfolder;
|
||||||
|
return subfolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looked up on first use, not at startup: the hooks are installed under the
|
||||||
|
// loader lock, where the shell's folder lookup must not run.
|
||||||
|
const Folders& UserFolders() {
|
||||||
|
static const Folders folders = FindFolders();
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `path` as the game means it, redirected into the user folders; nullptr when
|
||||||
|
// it stays as it is. `buffer` holds the result.
|
||||||
|
const char* Redirect(const char* path, std::string& buffer) {
|
||||||
|
if (!path) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
std::string_view rest(path);
|
||||||
|
const std::string& gameDir = GameDir();
|
||||||
|
if (rest.size() >= gameDir.size() && EqualsNoCase(rest.substr(0, gameDir.size()), gameDir)) {
|
||||||
|
rest.remove_prefix(gameDir.size());
|
||||||
|
} else if ((rest.size() >= 2 && rest[1] == ':') || (!rest.empty() && IsSeparator(rest[0]))) {
|
||||||
|
return nullptr; // absolute, elsewhere
|
||||||
|
}
|
||||||
|
while (rest.size() >= 2 && rest[0] == '.' && IsSeparator(rest[1])) {
|
||||||
|
rest.remove_prefix(2);
|
||||||
|
}
|
||||||
|
const size_t end = rest.find_first_of("\\/");
|
||||||
|
const std::string_view first = rest.substr(0, end);
|
||||||
|
const bool save = EqualsNoCase(first, kSaveFolder);
|
||||||
|
if (!save && !(end == std::string_view::npos && EqualsNoCase(first, kSettingsFile))) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const Folders& folders = UserFolders();
|
||||||
|
if (save) {
|
||||||
|
if (folders.save.empty() && GameSaveSubfolder().empty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
buffer = (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + GameSaveSubfolder();
|
||||||
|
for (char c : rest.substr(first.size())) {
|
||||||
|
buffer += c == '/' ? '\\' : c;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (folders.config.empty()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
buffer = folders.config + "\\" + kSettingsFile;
|
||||||
|
}
|
||||||
|
return buffer.c_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The path to hand to the original function.
|
||||||
|
const char* Mapped(const char* path, std::string& buffer) {
|
||||||
|
const char* redirected = Redirect(path, buffer);
|
||||||
|
return redirected ? redirected : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void SetGameSaveSubfolder(const std::string& subfolder) {
|
||||||
|
GameSaveSubfolder() = subfolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SaveDir() {
|
||||||
|
const Folders& folders = UserFolders();
|
||||||
|
return (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + "\\";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mods::userfolders
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using mods::userfolders::Mapped;
|
||||||
|
|
||||||
|
HANDLE WINAPI CreateFileInUserFolders(LPCSTR path, DWORD access, DWORD share, LPSECURITY_ATTRIBUTES security,
|
||||||
|
DWORD disposition, DWORD flags, HANDLE templateFile);
|
||||||
|
BOOL WINAPI DeleteFileInUserFolders(LPCSTR path);
|
||||||
|
HANDLE WINAPI FindFirstFileInUserFolders(LPCSTR pattern, LPWIN32_FIND_DATAA found);
|
||||||
|
DWORD WINAPI GetFileAttributesInUserFolders(LPCSTR path);
|
||||||
|
BOOL WINAPI GetFileAttributesExInUserFolders(LPCSTR path, GET_FILEEX_INFO_LEVELS level, LPVOID info);
|
||||||
|
BOOL WINAPI SetFileAttributesInUserFolders(LPCSTR path, DWORD attributes);
|
||||||
|
BOOL WINAPI CreateDirectoryInUserFolders(LPCSTR path, LPSECURITY_ATTRIBUTES security);
|
||||||
|
BOOL WINAPI CopyFileInUserFolders(LPCSTR from, LPCSTR to, BOOL failIfExists);
|
||||||
|
|
||||||
|
RE_HOOK_API(CreateFileA, CreateFileInUserFolders);
|
||||||
|
RE_HOOK_API(DeleteFileA, DeleteFileInUserFolders);
|
||||||
|
RE_HOOK_API(FindFirstFileA, FindFirstFileInUserFolders);
|
||||||
|
RE_HOOK_API(GetFileAttributesA, GetFileAttributesInUserFolders);
|
||||||
|
RE_HOOK_API(GetFileAttributesExA, GetFileAttributesExInUserFolders);
|
||||||
|
RE_HOOK_API(SetFileAttributesA, SetFileAttributesInUserFolders);
|
||||||
|
RE_HOOK_API(CreateDirectoryA, CreateDirectoryInUserFolders);
|
||||||
|
RE_HOOK_API(CopyFileA, CopyFileInUserFolders);
|
||||||
|
|
||||||
|
HANDLE WINAPI CreateFileInUserFolders(LPCSTR path, DWORD access, DWORD share, LPSECURITY_ATTRIBUTES security,
|
||||||
|
DWORD disposition, DWORD flags, HANDLE templateFile) {
|
||||||
|
std::string buffer;
|
||||||
|
return CreateFileInUserFolders_original(Mapped(path, buffer), access, share, security, disposition, flags,
|
||||||
|
templateFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL WINAPI DeleteFileInUserFolders(LPCSTR path) {
|
||||||
|
std::string buffer;
|
||||||
|
return DeleteFileInUserFolders_original(Mapped(path, buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
HANDLE WINAPI FindFirstFileInUserFolders(LPCSTR pattern, LPWIN32_FIND_DATAA found) {
|
||||||
|
std::string buffer;
|
||||||
|
return FindFirstFileInUserFolders_original(Mapped(pattern, buffer), found);
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD WINAPI GetFileAttributesInUserFolders(LPCSTR path) {
|
||||||
|
std::string buffer;
|
||||||
|
return GetFileAttributesInUserFolders_original(Mapped(path, buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL WINAPI GetFileAttributesExInUserFolders(LPCSTR path, GET_FILEEX_INFO_LEVELS level, LPVOID info) {
|
||||||
|
std::string buffer;
|
||||||
|
return GetFileAttributesExInUserFolders_original(Mapped(path, buffer), level, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL WINAPI SetFileAttributesInUserFolders(LPCSTR path, DWORD attributes) {
|
||||||
|
std::string buffer;
|
||||||
|
return SetFileAttributesInUserFolders_original(Mapped(path, buffer), attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL WINAPI CreateDirectoryInUserFolders(LPCSTR path, LPSECURITY_ATTRIBUTES security) {
|
||||||
|
std::string buffer;
|
||||||
|
return CreateDirectoryInUserFolders_original(Mapped(path, buffer), security);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL WINAPI CopyFileInUserFolders(LPCSTR from, LPCSTR to, BOOL failIfExists) {
|
||||||
|
std::string fromBuffer;
|
||||||
|
std::string toBuffer;
|
||||||
|
return CopyFileInUserFolders_original(Mapped(from, fromBuffer), Mapped(to, toBuffer), failIfExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace mods::userfolders {
|
||||||
|
|
||||||
|
// Folder of the save games, with trailing separator: Documents\Sacred\save\,
|
||||||
|
// or the game's own save\ when the Documents folder is unusable.
|
||||||
|
std::string SaveDir();
|
||||||
|
|
||||||
|
// The game's save folder becomes SaveDir() + `subfolder` (leading separator,
|
||||||
|
// e.g. "\characters\_work"); SaveDir() itself stays for sacredK's files.
|
||||||
|
// Set during static initialisation, before the game touches a file.
|
||||||
|
void SetGameSaveSubfolder(const std::string& subfolder);
|
||||||
|
|
||||||
|
} // namespace mods::userfolders
|
||||||
+24
-22
@@ -24,7 +24,9 @@
|
|||||||
#include "mods/widescreen.h"
|
#include "mods/widescreen.h"
|
||||||
|
|
||||||
#include "mods/hud.h"
|
#include "mods/hud.h"
|
||||||
|
#include "mods/loadingscreen.h"
|
||||||
#include "mods/megamap.h"
|
#include "mods/megamap.h"
|
||||||
|
#include "mods/monitor.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
@@ -63,12 +65,13 @@ bool g_pushesPatched = false;
|
|||||||
int g_renderWidth = 0;
|
int g_renderWidth = 0;
|
||||||
int g_renderHeight = 0;
|
int g_renderHeight = 0;
|
||||||
|
|
||||||
// Frames from the main menu thread and the loading screen are laid out for
|
// Every frame uses the full render width: the main menu thread (its windows
|
||||||
// 1024x768 ("classic"); everything else is the widened in-game view.
|
// drawn centred by mods/screen.cpp), the loading screen (centred on the menu
|
||||||
|
// background, mods/loadingscreen.cpp), the widened in-game view.
|
||||||
std::atomic<DWORD> g_menuThread = 0;
|
std::atomic<DWORD> g_menuThread = 0;
|
||||||
// Whether the most recent scene (on any thread) was a classic one: the main
|
// Whether the most recent scene (on any thread) was the main menu's or the
|
||||||
// menu is showing. UI input is processed outside the menu thread.
|
// loading screen's. UI input is processed outside the menu thread.
|
||||||
std::atomic<bool> g_lastSceneClassic = false;
|
std::atomic<bool> g_lastSceneMenu = false;
|
||||||
thread_local int g_loadingScreenDepth = 0;
|
thread_local int g_loadingScreenDepth = 0;
|
||||||
|
|
||||||
// D3DVIEWPORT7 (d3d.h is not in current Windows SDKs).
|
// D3DVIEWPORT7 (d3d.h is not in current Windows SDKs).
|
||||||
@@ -268,17 +271,16 @@ void PatchLetterbox(int width) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void ApplyRenderSize(game::DxDisplayMode& mode, HWND window) {
|
void ApplyRenderSize(game::DxDisplayMode& mode) {
|
||||||
if (!Enabled()) {
|
if (!Enabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MONITORINFO monitor = {sizeof(monitor)};
|
const RECT monitor = mods::monitor::GameMonitorRect();
|
||||||
GetMonitorInfoW(MonitorFromWindow(window, MONITOR_DEFAULTTOPRIMARY), &monitor);
|
|
||||||
// Default: keep the original 768 pixels of world height (so everything
|
// Default: keep the original 768 pixels of world height (so everything
|
||||||
// keeps its on-screen size once scaled to the monitor) and extend the width
|
// keeps its on-screen size once scaled to the monitor) and extend the width
|
||||||
// to the monitor's aspect ratio. Height=0 renders at the monitor height 1:1.
|
// to the monitor's aspect ratio. Height=0 renders at the monitor height 1:1.
|
||||||
const int monitorWidth = monitor.rcMonitor.right - monitor.rcMonitor.left;
|
const int monitorWidth = monitor.right - monitor.left;
|
||||||
const int monitorHeight = monitor.rcMonitor.bottom - monitor.rcMonitor.top;
|
const int monitorHeight = monitor.bottom - monitor.top;
|
||||||
int height = re::ConfigInt("widescreen", "Height", static_cast<int>(kOriginalHeight));
|
int height = re::ConfigInt("widescreen", "Height", static_cast<int>(kOriginalHeight));
|
||||||
if (height <= 0) {
|
if (height <= 0) {
|
||||||
height = monitorHeight;
|
height = monitorHeight;
|
||||||
@@ -339,14 +341,15 @@ int RenderWidth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool MenuIsShowing() {
|
bool MenuIsShowing() {
|
||||||
return g_lastSceneClassic;
|
return g_lastSceneMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IsClassicFrame() {
|
bool IsLoadingScreen() {
|
||||||
if (g_renderWidth == 0) {
|
return g_renderWidth != 0 && g_loadingScreenDepth > 0;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return g_loadingScreenDepth > 0 || GetCurrentThreadId() == g_menuThread.load();
|
|
||||||
|
bool IsMenuThread() {
|
||||||
|
return GetCurrentThreadId() == g_menuThread.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace mods::widescreen
|
} // namespace mods::widescreen
|
||||||
@@ -367,22 +370,21 @@ void __fastcall UiManager_ThreadLoop(void* self, void* edx) {
|
|||||||
|
|
||||||
void __fastcall DxDriver7_LoadingScreen(DxDriver7* self, void* edx, uint32_t progress, const char* text) {
|
void __fastcall DxDriver7_LoadingScreen(DxDriver7* self, void* edx, uint32_t progress, const char* text) {
|
||||||
++g_loadingScreenDepth;
|
++g_loadingScreenDepth;
|
||||||
|
mods::loadingscreen::Begin(self);
|
||||||
DxDriver7_LoadingScreen_original(self, edx, progress, text);
|
DxDriver7_LoadingScreen_original(self, edx, progress, text);
|
||||||
|
mods::loadingscreen::End();
|
||||||
--g_loadingScreenDepth;
|
--g_loadingScreenDepth;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Menus draw their 3D parts for a 1024x768 screen: give them a 1024x768
|
// Every frame gets the full render size as its viewport.
|
||||||
// viewport (the borderless mod then presents just that area, scaled), and the
|
|
||||||
// world the full render size.
|
|
||||||
int __fastcall DxDriver7_BeginScene(DxDriver7* self, void* edx) {
|
int __fastcall DxDriver7_BeginScene(DxDriver7* self, void* edx) {
|
||||||
mods::hud::OnDevice(self->device);
|
mods::hud::OnDevice(self->device);
|
||||||
if (self->sceneDepth == 0) {
|
if (self->sceneDepth == 0) {
|
||||||
g_lastSceneClassic = IsClassicFrame();
|
g_lastSceneMenu = IsLoadingScreen() || IsMenuThread();
|
||||||
}
|
}
|
||||||
if (g_renderWidth != 0 && self->sceneDepth == 0 && self->device) {
|
if (g_renderWidth != 0 && self->sceneDepth == 0 && self->device) {
|
||||||
const bool classic = IsClassicFrame();
|
Viewport7 viewport = {0, 0, static_cast<DWORD>(g_renderWidth), static_cast<DWORD>(g_renderHeight), 0.0f,
|
||||||
Viewport7 viewport = {0, 0, classic ? 1024u : static_cast<DWORD>(g_renderWidth),
|
1.0f};
|
||||||
classic ? 768u : static_cast<DWORD>(g_renderHeight), 0.0f, 1.0f};
|
|
||||||
using SetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport);
|
using SetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport);
|
||||||
auto setViewport = reinterpret_cast<SetViewportFn>((*static_cast<void***>(self->device))[13]);
|
auto setViewport = reinterpret_cast<SetViewportFn>((*static_cast<void***>(self->device))[13]);
|
||||||
setViewport(self->device, &viewport);
|
setViewport(self->device, &viewport);
|
||||||
|
|||||||
@@ -9,17 +9,19 @@ namespace mods::widescreen {
|
|||||||
// Called by the borderless mod before the renderer creates its surfaces:
|
// Called by the borderless mod before the renderer creates its surfaces:
|
||||||
// sets the render size to the monitor (or the configured size) and resizes
|
// sets the render size to the monitor (or the configured size) and resizes
|
||||||
// the world view to match. No-op when disabled.
|
// the world view to match. No-op when disabled.
|
||||||
void ApplyRenderSize(game::DxDisplayMode& mode, HWND window);
|
void ApplyRenderSize(game::DxDisplayMode& mode);
|
||||||
|
|
||||||
// True while the current thread renders a frame laid out for 1024x768 (main
|
// True while the current thread draws the load / save progress screen.
|
||||||
// menu, loading screen); the borderless mod then presents only that area.
|
bool IsLoadingScreen();
|
||||||
bool IsClassicFrame();
|
|
||||||
|
|
||||||
// Applied render width (0 while the original 1024x768 is in use).
|
// Applied render width (0 while the original 1024x768 is in use).
|
||||||
int RenderWidth();
|
int RenderWidth();
|
||||||
|
|
||||||
// True while the main menu / loading screen (classic layout) is on screen,
|
// True while the main menu or the loading screen is on screen, regardless of
|
||||||
// regardless of the calling thread.
|
// the calling thread.
|
||||||
bool MenuIsShowing();
|
bool MenuIsShowing();
|
||||||
|
|
||||||
|
// True on the main menu's render thread (cUI_Manager's thread loop).
|
||||||
|
bool IsMenuThread();
|
||||||
|
|
||||||
} // namespace mods::widescreen
|
} // namespace mods::widescreen
|
||||||
+4
-2
@@ -10,6 +10,7 @@ passed explicitly.
|
|||||||
| `find-imm.mjs <exe> /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) |
|
| `find-imm.mjs <exe> /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) |
|
||||||
| `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI |
|
| `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI |
|
||||||
| `decompile.ps1 <addr...>` | decompile from the persistent Ghidra project `ghidra/` |
|
| `decompile.ps1 <addr...>` | decompile from the persistent Ghidra project `ghidra/` |
|
||||||
|
| `questscript.mjs <dir> <quest id...>` | decode a quest script folder (`bin\TYPE_NPC_<class>`, `bin\Addon\NetScriptCamp`): a quest's labels and where other labels use its NPCs/variables; `--list`, `--facts [ids]` (giver, death labels), `--label <name>`, `--grep <regex>` (matching instructions in all labels) |
|
||||||
| `rename.ps1` | apply the function names in `names.txt` to the Ghidra project (add names there as functions are identified) |
|
| `rename.ps1` | apply the function names in `names.txt` to the Ghidra project (add names there as functions are identified) |
|
||||||
|
|
||||||
## Persistent Ghidra project (`ghidra/`, not in git)
|
## Persistent Ghidra project (`ghidra/`, not in git)
|
||||||
@@ -28,7 +29,8 @@ $env:GHIDRA_HEADLESS_MAXMEM = "8G"
|
|||||||
## Automated in-game test
|
## Automated in-game test
|
||||||
|
|
||||||
`autotest-load.ps1` starts the game through the launcher, clicks
|
`autotest-load.ps1` starts the game through the launcher, clicks
|
||||||
"Spiel laden" -> "Laden" -> "Ok" (first save game), takes screenshots in-game
|
"Spielen" -> a character (`-Save`: row in the character list) -> "Spielen"
|
||||||
|
(its last world), takes screenshots in-game
|
||||||
and with the ESC menu open, and stops the game. `-Ini` appends temporary
|
and with the ESC menu open, and stops the game. `-Ini` appends temporary
|
||||||
settings to `build\x86-debug\sacredK.ini` (restored afterwards), e.g.
|
settings to `build\x86-debug\sacredK.ini` (restored afterwards), e.g.
|
||||||
|
|
||||||
@@ -36,6 +38,6 @@ settings to `build\x86-debug\sacredK.ini` (restored afterwards), e.g.
|
|||||||
.\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
|
.\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Click positions assume the classic menu layout scaled onto a 3440x1440
|
Click positions are for the menu screens (2026-10-25) on a 3440x1440
|
||||||
monitor. `game-input.ps1` has the building blocks (start, click, key, hold key,
|
monitor. `game-input.ps1` has the building blocks (start, click, key, hold key,
|
||||||
screenshot). Don't touch mouse/keyboard while a test runs.
|
screenshot). Don't touch mouse/keyboard while a test runs.
|
||||||
+8
-10
@@ -1,18 +1,18 @@
|
|||||||
# Automated smoke test: start the game, load a save game (-Save: its row in
|
# Automated smoke test: start the game, load a character's last world (-Save:
|
||||||
# the list, 1 = the first, selected by default), take
|
# its row in the Spielen screen's character list, 1 = the first), take
|
||||||
# screenshots in-game and with the ESC menu open, then stop the game.
|
# screenshots in-game and with the ESC menu open, then stop the game.
|
||||||
#
|
#
|
||||||
# .\tools\autotest-load.ps1 -Name ws_on # current sacredK.ini
|
# .\tools\autotest-load.ps1 -Name ws_on # current sacredK.ini
|
||||||
# .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
|
# .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
|
||||||
#
|
#
|
||||||
# -Ini temporarily appends settings to build\x86-debug\sacredK.ini.
|
# -Ini temporarily appends settings to build\x86-debug\sacredK.ini.
|
||||||
# Click positions are for the classic (menu) layout scaled onto a 3440x1440
|
# Click positions are for the menu screens (2026-10-25) on a 3440x1440
|
||||||
# monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs.
|
# monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs.
|
||||||
param(
|
param(
|
||||||
[string]$Name = "autotest",
|
[string]$Name = "autotest",
|
||||||
[string]$Ini = "",
|
[string]$Ini = "",
|
||||||
[int]$LoadTimeoutSeconds = 60,
|
[int]$LoadTimeoutSeconds = 60,
|
||||||
# Row of the save game in the load screen's list (1-3 visible rows).
|
# Row of the character in the Spielen screen's list (its last world loads).
|
||||||
[int]$Save = 1,
|
[int]$Save = 1,
|
||||||
# Runs in-game after loading, before the screenshots (e.g. clicks).
|
# Runs in-game after loading, before the screenshots (e.g. clicks).
|
||||||
[scriptblock]$InGame = $null
|
[scriptblock]$InGame = $null
|
||||||
@@ -28,12 +28,10 @@ if ($Ini) { Set-Content $iniPath ($iniBackup + "`r`n" + $Ini + "`r`n") -Encoding
|
|||||||
$game = $null
|
$game = $null
|
||||||
try {
|
try {
|
||||||
$game = Start-Sacred
|
$game = Start-Sacred
|
||||||
Click-At 1720 779 -AfterMs 1500 # main menu: "Spiel laden"
|
Click-At 1717 538 -AfterMs 2000 # main menu: "Spielen"
|
||||||
if ($Save -gt 1) {
|
Click-At 817 (509 + ($Save - 1) * 105) -AfterMs 1000 # character list row (105 px apart)
|
||||||
Click-At 1342 (609 + ($Save - 1) * 254) -AfterMs 800 # load screen: the save's name (rows 254 px apart)
|
Click-At 2714 1347 -AfterMs 1500 # "Spielen": the character's last world
|
||||||
}
|
Click-At 1548 824 -AfterMs 0 # "Spielstand laden?": "Ok"
|
||||||
Click-At 2382 568 -AfterMs 2000 # load screen: "Laden"
|
|
||||||
Click-At 1551 826 -AfterMs 0 # confirm: "Ok"
|
|
||||||
Wait-Log "cEngine End and exit constructor" -TimeoutSeconds $LoadTimeoutSeconds -AfterMs 1500 -Game $game
|
Wait-Log "cEngine End and exit constructor" -TimeoutSeconds $LoadTimeoutSeconds -AfterMs 1500 -Game $game
|
||||||
if ($InGame) { & $InGame }
|
if ($InGame) { & $InGame }
|
||||||
Save-Screen "screenshots\${Name}_ingame.png" | Out-Null
|
Save-Screen "screenshots\${Name}_ingame.png" | Out-Null
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
# Click-At 1720 779 # screen pixels (left click)
|
# Click-At 1720 779 # screen pixels (left click)
|
||||||
# Press-Key 0x1B # virtual key (ESC)
|
# Press-Key 0x1B # virtual key (ESC)
|
||||||
# Hold-Key 0x27 -Ms 1500 # hold a key (right arrow)
|
# Hold-Key 0x27 -Ms 1500 # hold a key (right arrow)
|
||||||
|
# Scroll-Wheel -3 # mouse wheel, 3 notches down
|
||||||
# Save-Screen screenshots\x.png # primary monitor area of the game
|
# Save-Screen screenshots\x.png # primary monitor area of the game
|
||||||
# Stop-Sacred $game # only the given process
|
# Stop-Sacred $game # only the given process
|
||||||
#
|
#
|
||||||
@@ -25,7 +26,7 @@ $script:Root = Split-Path $PSScriptRoot
|
|||||||
# synthetic Alt press lifts that restriction for SetForegroundWindow.
|
# synthetic Alt press lifts that restriction for SetForegroundWindow.
|
||||||
function Focus-Sacred($Game) {
|
function Focus-Sacred($Game) {
|
||||||
$Game.Refresh()
|
$Game.Refresh()
|
||||||
if ($Game.MainWindowHandle -eq [IntPtr]::Zero) { return $false }
|
if (-not $Game.MainWindowHandle -or $Game.MainWindowHandle -eq [IntPtr]::Zero) { return $false }
|
||||||
[SacredRe.Input]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
|
[SacredRe.Input]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
|
||||||
[SacredRe.Input]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
|
[SacredRe.Input]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
|
||||||
[SacredRe.Input]::SetForegroundWindow($Game.MainWindowHandle)
|
[SacredRe.Input]::SetForegroundWindow($Game.MainWindowHandle)
|
||||||
@@ -116,3 +117,9 @@ function Hold-Key([byte]$VirtualKey, [int]$Ms = 1500, [int]$AfterMs = 500) {
|
|||||||
[SacredRe.Input]::keybd_event($VirtualKey, 0, 2, [UIntPtr]::Zero)
|
[SacredRe.Input]::keybd_event($VirtualKey, 0, 2, [UIntPtr]::Zero)
|
||||||
Start-Sleep -Milliseconds $AfterMs
|
Start-Sleep -Milliseconds $AfterMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Turns the mouse wheel by $Notches (negative: towards the user, "down").
|
||||||
|
function Scroll-Wheel([int]$Notches, [int]$AfterMs = 500) {
|
||||||
|
[SacredRe.Input]::mouse_event(0x0800, 0, 0, [BitConverter]::ToUInt32([BitConverter]::GetBytes([int]($Notches * 120)), 0), [UIntPtr]::Zero)
|
||||||
|
Start-Sleep -Milliseconds $AfterMs
|
||||||
|
}
|
||||||
+136
-1
@@ -186,6 +186,7 @@
|
|||||||
0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player
|
0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player
|
||||||
0x00ad1b48 g_nearbyCreatureCount
|
0x00ad1b48 g_nearbyCreatureCount
|
||||||
0x0049a090 cDialogManager::Answer # (owner, answer): cEvent_creature 0x10c to all heroes (ends the dialog lock); answers < 100000 -> quest script 0x46bd80
|
0x0049a090 cDialogManager::Answer # (owner, answer): cEvent_creature 0x10c to all heroes (ends the dialog lock); answers < 100000 -> quest script 0x46bd80
|
||||||
|
0x0046bd80 Script_RunLabel # cdecl (label index, ?, ?): script labels 0xaa9688 (0x54 bytes, quest +0x48); runs only once the label's quest is triggered
|
||||||
0x006e7120 cUI_Tooltip::HandleEvent # dialog answers: click on an entry (+0x2188 list) -> hide, cDialogManager::Answer
|
0x006e7120 cUI_Tooltip::HandleEvent # dialog answers: click on an entry (+0x2188 list) -> hide, cDialogManager::Answer
|
||||||
0x0075a870 cUI_Manager::TooltipCreate # a panel from the pool at +0x124
|
0x0075a870 cUI_Manager::TooltipCreate # a panel from the pool at +0x124
|
||||||
0x00756ba0 cUI_Manager::HandleEsc
|
0x00756ba0 cUI_Manager::HandleEsc
|
||||||
@@ -302,7 +303,7 @@
|
|||||||
0x006e7730 cUI_Tooltip::Layout # measures the text, width = longest line + 8 unless flag 0x100
|
0x006e7730 cUI_Tooltip::Layout # measures the text, width = longest line + 8 unless flag 0x100
|
||||||
0x0075ac70 cUI_Manager::ShowTooltip # (x, y, text, ?, flags, show)
|
0x0075ac70 cUI_Manager::ShowTooltip # (x, y, text, ?, flags, show)
|
||||||
|
|
||||||
# Character selection / export screen (src/mods/charexport.cpp)
|
# Character selection / export screen (cUI_Character)
|
||||||
0x0070c760 cUI_Character::SetMode # (mode), ret 4: +0x390 (4 selection, 5 export, 6 overwrite confirm)
|
0x0070c760 cUI_Character::SetMode # (mode), ret 4: +0x390 (4 selection, 5 export, 6 overwrite confirm)
|
||||||
0x0070c440 cUI_Character::SelectSlot # (char slot, -1 none), ret 4
|
0x0070c440 cUI_Character::SelectSlot # (char slot, -1 none), ret 4
|
||||||
0x0070d0f0 cUI_Character::ExecuteAction # (action, slot, extra), ret 0xc: 6 export into slot, 9 export (confirm if occupied)
|
0x0070d0f0 cUI_Character::ExecuteAction # (action, slot, extra), ret 0xc: 6 export into slot, 9 export (confirm if occupied)
|
||||||
@@ -320,3 +321,137 @@
|
|||||||
0x0075a690 cUI_Manager::CanOpenWindow # (1), ret 4: false while a blocking window is open
|
0x0075a690 cUI_Manager::CanOpenWindow # (1), ret 4: false while a blocking window is open
|
||||||
0x007320c0 cUI_Control2::SetTooltipText # (text id) -> +0x20, ret 4
|
0x007320c0 cUI_Control2::SetTooltipText # (text id) -> +0x20, ret 4
|
||||||
0x007320d0 cUI_Control2::TooltipText # +0x20 once hovered long enough, else 0
|
0x007320d0 cUI_Control2::TooltipText # +0x20 once hovered long enough, else 0
|
||||||
|
0x0043ae20 cItemTypeTable::IsGold # (type), ret 4: one of the four gold pile types (0x947920)
|
||||||
|
0x00433f00 cItemTypeTable::GetGroup # (type), ret 4: group record (0x102 bytes), +0x23 set item
|
||||||
|
0x00428550 cItemTypeTable::IsAlwaysCollected # (type), ret 4: CanCollectItem takes it whatever PICKUPAUTO
|
||||||
|
0x005c1330 cItemBase::GetColour # (quality block or 0), ret 4: name colour by quality tier / set
|
||||||
|
0x00559a30 cCreature::DropLoot # (forced type, ?, ?, killer), ret 0x10; "Drop from [%d] %s"
|
||||||
|
0x005c9b10 cChest3D::dropItems # (?), ret 4: throws out the contents (ids at +0x1e4)
|
||||||
|
0x006116c0 cEngine::dropItem # the player's drop into the world
|
||||||
|
0x005fc910 cObjectManager::RemoveFromMap # (id), ret 4
|
||||||
|
0x00819580 Options_PickupAuto # PICKUPAUTO from the config (0 all, 1 valuable, 2 gold)
|
||||||
|
0x0061d190 LoadHeroFile # cdecl (path, 1, 0): a hero file onto the player hero (whole object, position too)
|
||||||
|
0x0061cda0 cEngine::SaveHero # cdecl (path): the player hero as a hero file
|
||||||
|
0x0061b790 cEngine::Load # (path, quiet), ret 8: reads a GAME.PAK
|
||||||
|
0x00619c90 cEngine::Save # (path), ret 4; render thread, engine +0x54 & 0x40
|
||||||
|
0x0060dc00 cEngine::InitGame # (int), ret 4: new game, start hero
|
||||||
|
0x0071c4f0 cUI_Savegame::SetMode # (mode), ret 4: 1 save, 2 load (+0x75c)
|
||||||
|
0x0071cfb0 cUI_Savegame::Show # vf24 (bool), ret 4: picks save mode in a game
|
||||||
|
0x0071c520 cUI_Savegame::Action # (action), ret 4: 2 save, 4 load, 8 delete, 10 rescan, 0xb default
|
||||||
|
0x0071e330 cUI_Savegame::Scan # (dir, multiplayer), ret 8: "./SAVE/*.PAK"
|
||||||
|
0x0071be90 cUI_Savegame::SendSaveLoad # (kind, row): engine event 0xf
|
||||||
|
0x00755ac0 cCommand_armaPlay::execute # posts 0x8002 (mode, argument) to the main window
|
||||||
|
0x00755b30 cCommand_armaStop::execute # vf04, plain ret: ends the game, posts 0x8001 to the main window (engine deleted, main menu)
|
||||||
|
0x007d7c40 Session_Instance
|
||||||
|
0x007d7cf0 Session::SetHeroClass # (class) -> +0x44, ret 4
|
||||||
|
0x00417e50 GameSetup_Instance # 0x12e6c bytes; +0 set by both character-screen starts
|
||||||
|
0x006bb870 cUI_EscMenu::BuildLines # line actions; "Spielstaende" text 0x41b at 0x6bc1f7
|
||||||
|
0x00810af0 BringWindowToFront # (hwnd, timeout), ret 8; loops forever with timeout -1
|
||||||
|
0x0072ad60 cUI_Button2::SetCaption # (text id, font), ret 8 -> +0xa4 / +0xa0
|
||||||
|
0x00712f30 cUI_MainMenu::BuildEntries # thiscall, plain ret: the menu entries (cUI_StaticText64FX); main page vector +0x158
|
||||||
|
0x00711fa0 cUI_MainMenu::HandleEvent # vf10: sets g_campaign 0x182cdc8 by entry name (NEWGAME 0, NEWADDON 1)
|
||||||
|
0x00753d70 cUI_StaticText64FX::cUI_StaticText64FX # (name, rect, parent, ?, text id, command, flags)
|
||||||
|
0x0070e2a0 cUI_Character::Show # vf24 (shown), ret 4: single player + g_campaign 1 -> mode 7 (Underworld import, level 25+)
|
||||||
|
|
||||||
|
# cUI_NetGranny: 3D hero view (game/ui/granny.h)
|
||||||
|
0x006f06f0 cUI_NetGranny::cUI_NetGranny # (rect by value), ret 0xc
|
||||||
|
0x006f17f0 cUI_NetGranny::Render # vf44 (device, 0, label), ret 0xc
|
||||||
|
0x006f4b20 cUI_NetGranny::SetHero # (hero info, x, y, z, turn), ret 0x14
|
||||||
|
0x006f4ae0 cUI_NetGranny::ClearHero
|
||||||
|
0x006f0f10 cUI_NetGranny::SetLights # (on): +0x53f white set
|
||||||
|
0x006f4d70 cUI_NetGranny::InitCamera
|
||||||
|
0x0070beb0 cUI_Character::LoadExportedHeroes # Hero00..07.pax -> slot grannies +0x1b4
|
||||||
|
|
||||||
|
# Textures by name (game/ui/texture.h)
|
||||||
|
0x00656c80 TextureManager_Instance # cdecl (), kept at 0x13e57b4
|
||||||
|
0x00657620 TextureManager::IdOf # (name), ret 4
|
||||||
|
0x0065f010 TextureCache::Get # (id, 0), ret 8; cache at 0x13e57b8, D3D surface +0x14
|
||||||
|
|
||||||
|
# Main menu (mods/screen.cpp)
|
||||||
|
0x007122c0 cUI_MainMenu::Render # vf14
|
||||||
|
0x0075df10 MenuScene_Instance # 0x1a84 bytes, kept at 0x17ecb40
|
||||||
|
0x0075e620 MenuScene::Render # (1 argument), ret 4: sky + water (UI_UW_MAIN_CENTER, UI_MAIN_REF)
|
||||||
|
0x0075e820 MenuScene::Update # (seconds)
|
||||||
|
0x005905f0 VerifyHeroMirror # anti-cheat: local hero vs protected mirror (0x182cddc); resets mismatches, returns 1 if all matched (mods/anticheat.cpp)
|
||||||
|
|
||||||
|
# --- explored map (fog of war, 2026-10-25) -------------------------------------
|
||||||
|
0x00599730 RevealAroundHero # creature thiscall (tile x, y, sight 0..3), ret 0xc; stamps 0x88f5cc / 0x88f328 when the hero's tile changed
|
||||||
|
0x00638f00 World_SetExplored # thiscall (tile x, y, explored), ret 0xc; ecx *0xad14e0
|
||||||
|
0x00622bf0 Sector_SetExplored # thiscall (tile x, y, explored), ret 0xc; bit in the 32x32 mask +0x180
|
||||||
|
0x00622c60 Sector_GetExploredMask # (out 128 bytes), ret 4
|
||||||
|
0x00622c80 Sector_SetExploredMask # (128 bytes), ret 4; from the save (0x606ae0)
|
||||||
|
0x00639030 Sector_BuildFogTexture # FOGOFWAR01.TGA from a mask, for the zoomed map view
|
||||||
|
|
||||||
|
# --- quest book (2026-10-25) -----------------------------------------------------
|
||||||
|
0x0046c450 QuestManager_AddBookEntry # thiscall (quest index, ?), ret 8; kind 3 for ids 1..99, chapter from the current main quest
|
||||||
|
0x00497830 QuestManager_BookEntryPosition # thiscall (entry index, out), ret 8; from entry +0x18 / +0x1c
|
||||||
|
0x0049dd40 QuestManager_SelectedBookEntry # thiscall; +0x3a0 + player * 8
|
||||||
|
0x0049dd70 QuestManager_MainBookEntry # thiscall; +0x39c + player * 8 (= +0x3a4 for player 1)
|
||||||
|
0x004975d0 QuestManager_MarkerColour # thiscall (entry, kind), ret 8; kind 0: side open blue ff006cff, main ffffff80, solved green, failed red
|
||||||
|
0x00497910 QuestManager_EntriesInSectors # thiscall (sectors, out, open only), ret 0xc; book entries whose marker lies in the sectors
|
||||||
|
0x0049a7a0 QuestManager_CompassPos # script QuestkompassPos: entry +0x10/+0x14 = cell, +0x20 radius ("rad:")
|
||||||
|
0x0049af70 QuestManager_CompassObject # script QuestkompassOBJ: +0x10 = 0xffffffff + object id, or 0xeeeeeeee + script NPC id
|
||||||
|
0x004a6740 QuestManager_ResolveCompass # thiscall (entry), ret 4; marker cell +0x18/+0x1c from the compass target
|
||||||
|
0x006b04c0 cUI_Book::BuildQuestList # thiscall; rows per tab (chapter +0x16c), pages via PageFull
|
||||||
|
0x006b1670 cUI_Book::Rebuild # by book mode +0x166: 0 quests 0x6b04c0, 1 0x6b0ae0, 2 0x6b0190
|
||||||
|
0x006b1a70 cUI_Book::PageFull # thiscall (tab, page), ret 8
|
||||||
|
0x006b1cd0 cUI_Book::DrawDetail # thiscall (renderer, record 0x40, sub-page), ret 0xc
|
||||||
|
0x006b27f0 cUI_Book::RenderPage # thiscall (renderer, bookmarks only), ret 8
|
||||||
|
0x006b3620 cUI_Book::HandleEvent # vf10, thiscall (event), ret 4
|
||||||
|
0x0043ad60 cItemTypeTable::IsQuestItem # (type), ret 4: type +0x61 (script word QuestItem)
|
||||||
|
0x0043ad80 cItemTypeTable::GetQuestInfo # (type, &questItem, &mainQuestItem, &quest, &quest+1), ret 0x14: type +0x1c/+0x61/+0x62
|
||||||
|
0x00622640 MapPosition_Set # thiscall (u16 sector, x, y, u8 layer), ret 0x10
|
||||||
|
0x00634970 World_SectorAt # thiscall (MapPosition*), ret 4: the sector object or null
|
||||||
|
0x00622e30 Sector_Region # thiscall: region number (sector +0x17c -> +0xd7)
|
||||||
|
0x00425370 Region_NameKey # cdecl (char region): name text key from the region name table 0xaa8ca8, or null
|
||||||
|
0x00425300 Region_GetInfo # cdecl (char region, out, out): table +0x38 / +0x30 of the region's record
|
||||||
|
0x006cb130 cUI_Megamap::WorldToMapPixel # thiscall (out vec3, world vec3 x/y/level), ret 8: x * 0.007446 + 1018.75, y * 0.014914 - 186.95 (after 0x622700)
|
||||||
|
0x0054fa30 cCreature::IsQuestNpc # thiscall, plain ret: +0x200 bit 0x100 / 0x200, or dialog +0x245 of a type < 50; white on the zoomed map view
|
||||||
|
0x00549790 cCreature::GetDialog # thiscall: dialog +0x245, or a random NPC chatter text (returns 0xd431)
|
||||||
|
0x00544700 cCreature::IdleAction # thiscall: Think action 0 handler; turns creatures with a dialog towards the local hero (0x546334)
|
||||||
|
0x0044df60 cCreature::SetDirection # thiscall (x, y, z floats), ret 0xc: through 0x44ddc0
|
||||||
|
0x0044ddc0 cCreature::SetDirectionVector # thiscall (vec3*, ?), ret 8: writes +0x70..+0x78
|
||||||
|
0x004265c0 TypeTable_IsKind4 # thiscall (type table *0xaa9564, type), ret 4: the type's kind byte is 4
|
||||||
|
0x0049a180 cDialogManager::DialogType # thiscall (dialog, creature), ret 8: record +0x48 of 0x50-byte records at +0x755c; creature flags 0x1000.. -> 0x20..0x23
|
||||||
|
0x0046de10 Quest_Trigger # cdecl (index, ?, ?): makes a quest available (flag 1, busy 8), runs its init label +0x114 once and its trigger label +0x10c
|
||||||
|
0x0046ced0 Quest_End # quest manager thiscall (index, ?, success), ret 0xc: book entry 100 / 101
|
||||||
|
0x00475970 Script_RunInstruction # quest manager thiscall (code, &offset, quest, object, flag, 0), ret 0x18: one instruction; offset -1 reads from the FILE*
|
||||||
|
0x00498aa0 Script_Condition # thiscall: script conditions; case 0x40 "@npc": npc on the hero's follower list +0x39c (or +0x570)
|
||||||
|
0x0048f320 Script_StartQuest # opcode 0x14 #quest: needs quest flag 1; runs the trigger label, adds the book entry
|
||||||
|
0x0048f110 Script_EndQuest # opcode 0x0f: Quest_End of the current quest
|
||||||
|
0x00482800 Script_Spawn # opcode 1: spawns a creature ("Res:<script id>", type, position, 0x09 dialog, 0x05 death label, ...)
|
||||||
|
0x0048be30 Script_NpcMode # opcode 3: an NPC's mode (0x09 dialog, 0x0a follow + death label, ...)
|
||||||
|
0x00464ec0 QuestManager_ScriptObjectId # thiscall (script id, &object id), ret 8: map +0x7668, then all objects
|
||||||
|
0x0044a390 cObject::ScriptId # thiscall, plain ret: +0x3c or the type's default
|
||||||
|
0x0044a380 cObject::SetScriptId # thiscall (script id)
|
||||||
|
0x004b37b0 ScriptIdMap::operator[] # thiscall (&key), ret 4: quest manager +0x7668
|
||||||
|
0x0054b070 cCreature::AddFollower # thiscall ({u32 id, u32, u8}*), ret 4: hero +0x39c list, unless the id is in it
|
||||||
|
0x005145d0 cCreature::CombatMove # combat arts' moves; case 0x43f Kanonenschuss, 0x43e Mörsergranate
|
||||||
|
0x005ab400 cObjectFX::GatherTargets # effect objects collect their targets (0x5ff820 at 0x5ab63d, effect in ebx)
|
||||||
|
0x005ff820 cObjectManager::ObjectsInRange # thiscall (out, position, type, only, radius = -1: type sizes), ret 0x14
|
||||||
|
0x00428bb0 TypeTable::ObjectSize # thiscall (type), ret 4: +0x14, default 50
|
||||||
|
0x004fc810 Creature_StunChance # (level, creature): level * 5 / creature level * 100
|
||||||
|
0x00633230 World_RebuildVisitedBorder # thiscall: clears the 100x100 border bytes (+0xb73c) and runs 0x6332d0 for every sector with flag 0x20000 (visited); on load (0x471086, 0x60c621)
|
||||||
|
0x006332d0 World_UpdateVisitedBorderAround # thiscall (sector cx, cy), ret 8: border byte of each unvisited 3x3 neighbour (0x6334f0)
|
||||||
|
0x006334f0 World_ComputeVisitedBorderCell # thiscall (cx, cy), ret 8: 8 bits, one per neighbour sector with flag 0x20000
|
||||||
|
0x006336b0 World_VisitedBorderCell # thiscall (cx, cy), ret 8: border byte, 0 outside 100x100
|
||||||
|
0x006c8580 cUI_Megamap::DrawVisitedBorder # thiscall (device), ret 4: 2x2 px red (0xffff0000) dot per border bit, +-10/+-20 px from the sector origin
|
||||||
|
0x006c87c0 cUI_Megamap::DrawQuestMarkers # thiscall (device), ret 4: quest book entries (0xaaaf00)
|
||||||
|
0x00622700 WorldToPlane # cdecl (out vec3, in vec3), plain ret: world units -> world pixels (iso matrix 0xad5710)
|
||||||
|
0x0062d530 cWorldView0::DrawGroundQuads # thiscall (renderer), ret 4: the ground, before the objects
|
||||||
|
0x0041e0a0 cEventFX::Construct # thiscall (code, owner, target, position 4 dwords, ...), ret 0x44
|
||||||
|
0x007b66b0 cParticleSystem_grenadeimpact::Render # vf04 (device, matrix by value), ret 0x44; vtable 0x896cdc
|
||||||
|
0x007b64c0 cParticleSystem_grenadeimpact::Update # vf08 (dt), ret 4; phase +0x20a9
|
||||||
|
0x007613e0 cParticleSystem::DrawParticles # colour table row (+0x3d) * 256 + life (+0x2c)
|
||||||
|
0x00763ea0 cParticleSystem::UpdateParticles # thiscall (dt, emitter rows, mode), particles 0x40 bytes at +0x68
|
||||||
|
0x005d8240 cInventoryEntry::AddMainQuestItem # thiscall (type, script id, notify): {type, script id} pairs; refused if same type (script id 0 test patched out)
|
||||||
|
0x0049bb30 QuestManager_SetVarBit # script op 0x44: variable |= 1 << n
|
||||||
|
0x0049c450 QuestManager_ClearVarBit # script op 0x45: variable &= ~(1 << n)
|
||||||
|
0x0049cc20 QuestManager_AddToVar # script op 0x4b: variable += n
|
||||||
|
0x005222c0 cCreature::CastSpell # thiscall (cast), ret 4: switch on the spell (cast +0x20); +0x08/+0x0c ground point; case 1 Phasenverschiebung, 0x39 Eingraben
|
||||||
|
0x00636b60 cWorld::IsPassable # thiscall (position), ret 4: world = *ObjectManager
|
||||||
|
0x00636eb0 cWorld::CanStand # thiscall (creature id, position), ret 8: may correct the position's area
|
||||||
|
0x004d24f0 cArtDef::Duration # thiscall (index, level): +0x76 + level * +0x78 (i16), at most +0x82 when set
|
||||||
|
0x007b8fc0 cParticleSystem_dwarf_shield::cParticleSystem_dwarf_shield # Eingraben's barrier (type 0x480)
|
||||||
|
0x007b91a0 cParticleSystem_dwarf_shield::Update # vf08 (dt), ret 4; pulse +0x20b8, phase +0x20ac, end +0x20a8
|
||||||
|
0x007b92b0 cParticleSystem_dwarf_shield::Draw # vf04, ret 0x44; model alpha pulse * 128 (__ftol at 0x7b9483)
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// Decode the compiled quest scripts (docs/quests.md, "Quest scripts").
|
||||||
|
// node tools/questscript.mjs <script dir> <quest id...> one quest: its labels and every
|
||||||
|
// reference to it from other labels
|
||||||
|
// node tools/questscript.mjs <script dir> --list all quests of the quest table
|
||||||
|
// node tools/questscript.mjs <script dir> --label <name> one label
|
||||||
|
// <script dir>: the folder with FunkCode.bin + Vectoren.bin, e.g.
|
||||||
|
// "<Sacred Gold>\bin\TYPE_NPC_GLADIATOR" (Ancaria, one per hero class; the game
|
||||||
|
// writes the one in use to bin\sgf.bin) or "<Sacred Gold>\bin\Addon\NetScriptCamp"
|
||||||
|
// (Underworld).
|
||||||
|
//
|
||||||
|
// Formats: Vectoren.bin = u32 n, n labels of 0x54 bytes (name, +0x40 code offset,
|
||||||
|
// +0x44 size, +0x48 quest), then u32 count, count quest records of 0x124 bytes
|
||||||
|
// (id, name). FunkCode.bin = instructions {u16 opcode, u16 size with the header,
|
||||||
|
// tagged args}. Opcode names from the game's keyword tables (0x451db0, 0x452300,
|
||||||
|
// 0x4523d0, 0x452490, 0x452540); argument sizes from the reader 0x472eb0.
|
||||||
|
// Conditions (IF 0x3a / ELSEIF 0x42) have their own tags, decoded by shape.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const OPS = {
|
||||||
|
0x1: "CreateNPC", 0x2: "SetObjState", 0x3: "SetNPCState", 0x4: "SetBaseTrigger", 0x5: "DelBaseTrigger",
|
||||||
|
0x6: "TalkTo", 0x7: "EquipNPC", 0x8: "CreateObj", 0x9: "StartScript", 0xa: "QuestDone", 0xb: "QuestNotDone",
|
||||||
|
0xc: "QuestInProgress", 0xd: "QuestNotInProgress", 0xe: "DeleteFunktionBlock", 0xf: "ExitQuest", 0x10: "MoveOver",
|
||||||
|
0x11: "AddExp", 0x12: "AddGold", 0x13: "AddGems", 0x14: "TriggerQuest", 0x15: "SetUpQuest", 0x16: "CallFunktion",
|
||||||
|
0x17: "DefPos", 0x18: "GroupIsDead", 0x19: "CheckForQuestPool", 0x1a: "Text", 0x1b: "MaxOffen:", 0x1c: "Delay:",
|
||||||
|
0x1d: "Rettungsmission:", 0x1e: "ToDoBloecke:", 0x1f: "Dialog:", 0x20: "Belohnungen:", 0x21: "HideTmpToDo:",
|
||||||
|
0x22: "Schatzsuche:", 0x23: "FillRegion", 0x24: "AddHandel", 0x25: "SetRG", 0x26: "UnsetRG", 0x2b: "ExitDlg",
|
||||||
|
0x2c: "DelDlg", 0x2d: "StartPosition", 0x2e: "Teleport", 0x30: "CreateTrigger", 0x31: "SetTriggerState",
|
||||||
|
0x32: "TriggerPatch", 0x33: "FillSector", 0x34: "SetHP", 0x35: "QuestBook", 0x36: "LoseQuest", 0x37: "DelNPC",
|
||||||
|
0x38: "SetTimer", 0x39: "DelTimer", 0x3a: "IF", 0x3b: "ELSE", 0x3c: "SetButton", 0x3d: "ChDlgText", 0x3e: "ENDIF",
|
||||||
|
0x3f: "QuestKompassPos", 0x40: "QuestKompassObj", 0x41: "DefNum", 0x42: "ELSEIF", 0x43: "SetVar", 0x44: "SetVarBit",
|
||||||
|
0x45: "UnsetVarBit", 0x46: "SetFocus", 0x47: "NPC_TalkTo", 0x48: "NPC_Goto", 0x49: "SetGroupState", 0x4a: "GroupGoto",
|
||||||
|
0x4b: "IncVar", 0x4c: "DecVar", 0x4d: "UnTriggerQuest", 0x4e: "FillChest", 0x4f: "Morph", 0x50: "SetST",
|
||||||
|
0x51: "SetGS", 0x52: "SetWI", 0x53: "SetRP", 0x54: "SetRM", 0x55: "SetCH", 0x56: "SetIcon", 0x57: "SetQuestInfo",
|
||||||
|
0x58: "SetAnimMode", 0x59: "SetPlayMode", 0x5a: "Wait", 0x5b: "PlayAnim", 0x5c: "Attack", 0x5d: "ActObj",
|
||||||
|
0x5e: "MouseEvent", 0x5f: "Popup", 0x60: "ReplaceSpawn", 0x61: "SetMapIcon", 0x62: "SecInfo", 0x63: "Partikel",
|
||||||
|
0x64: "SpawnValues", 0x65: "Toete_NPC", 0x66: "Begleite", 0x67: "SetCV", 0x68: "PlaySound", 0x69: "RndVar",
|
||||||
|
0x6a: "SetRgnDialog", 0x6b: "BalanceSpawnItem", 0x6c: "AddPoolPos", 0x6d: "GetPoolPos", 0x6e: "RenPos",
|
||||||
|
0x6f: "ToBegin", 0x70: "AddPoolRgn", 0x71: "GetPoolRgn", 0x72: "Waffenpool", 0x73: "MakeGroup",
|
||||||
|
0x74: "RegionChange", 0x75: "ActivateQuest", 0x76: "SetClientVars", 0x77: "GetClientVars", 0x78: "GameEnd",
|
||||||
|
0x79: "AutoSave", 0x7a: "SolveTime:", 0x7b: "Strafen:", 0x7c: "RegionFrei", 0x7d: "Gewinn", 0x7e: "RejectQuest",
|
||||||
|
0x7f: "PlayMusic", 0x80: "PlayJingle", 0x81: "SetScriptSoundControle", 0x82: "Teleporter", 0x83: "GameActive",
|
||||||
|
0x84: "InfoPlayer", 0x85: "GetPoolPosition", 0x86: "SetCinemaMode", 0x87: "SetOnKill", 0x88: "SetOnCollect",
|
||||||
|
0x89: "SetDrop", 0x8a: "GiveStat", 0x8b: "GiveSkill", 0x8c: "MachVersteck",
|
||||||
|
};
|
||||||
|
// Argument tags (reader 0x472eb0): payload kinds.
|
||||||
|
const STR = new Set([0x01, 0x05, 0x09, 0x16, 0x1e, 0x29, 0x3a, 0x41, 0x60, 0x63, 0x67, 0x68, 0x69, 0x6a, 0x71, 0x83, 0x9d]);
|
||||||
|
const U16 = new Set([0x03, 0x0a, 0x6b, 0x6c, 0x9b, 0x9c]);
|
||||||
|
const I32 = new Set([0x02, 0x0b, 0x11, 0x19, 0x1c, 0x1d, 0x1f, 0x36, 0x38, 0x53, 0x54, 0x55, 0x56, 0x57, 0x5f, 0x73, 0x75, 0x84, 0x86]);
|
||||||
|
const BYTES = { 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 };
|
||||||
|
const FLAG = new Set([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]);
|
||||||
|
// Condition tags with {int32, variable}: 0x49 bit set, 0x4a bit clear (Script_Condition
|
||||||
|
// 0x498aa0), 0x47 / 0x48 / 0x6e compare values.
|
||||||
|
const COND_VAR = new Set([0x47, 0x48, 0x49, 0x4a, 0x6e]);
|
||||||
|
// Opcodes whose first argument is an NPC ("Res:<id>").
|
||||||
|
const NPC_OPS = new Set([0x01, 0x03, 0x06, 0x07, 0x1d, 0x2e, 0x34, 0x37, 0x40, 0x46, 0x47, 0x48, 0x4f, 0x5b, 0x5c, 0x65, 0x66]);
|
||||||
|
const TAG_NAMES = { 0x05: "death", 0x09: "dialog", 0x02: "type" };
|
||||||
|
|
||||||
|
const hex = (n) => n.toString(16).padStart(2, "0");
|
||||||
|
|
||||||
|
function cString(b, p) {
|
||||||
|
const e = b.indexOf(0, p);
|
||||||
|
return [b.toString("latin1", p, e), e + 1];
|
||||||
|
}
|
||||||
|
function printableAt(b, p, end) {
|
||||||
|
const e = b.indexOf(0, p);
|
||||||
|
if (e < 0 || e >= end) return false;
|
||||||
|
for (let i = p; i < e; i++) if (b[i] < 0x20) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoded arguments: [{tag, kind, value}], ok when the instruction was consumed exactly.
|
||||||
|
function decodeArgs(b, p, end, op) {
|
||||||
|
const args = [];
|
||||||
|
const cond = op === 0x3a || op === 0x42;
|
||||||
|
while (p < end) {
|
||||||
|
const tag = b[p++];
|
||||||
|
let s;
|
||||||
|
if (cond) {
|
||||||
|
if (COND_VAR.has(tag) && p + 4 < end) {
|
||||||
|
const n = b.readInt32LE(p);
|
||||||
|
[s, p] = cString(b, p + 4);
|
||||||
|
args.push({ tag, kind: "cond", value: [n, s] });
|
||||||
|
} else if (p + 4 < end && !printableAt(b, p, end) && printableAt(b, p + 4, end) && Math.abs(b.readInt32LE(p)) < 1e6) {
|
||||||
|
const n = b.readInt32LE(p);
|
||||||
|
[s, p] = cString(b, p + 4);
|
||||||
|
args.push({ tag, kind: "cond", value: [n, s] });
|
||||||
|
} else if (printableAt(b, p, end)) {
|
||||||
|
[s, p] = cString(b, p);
|
||||||
|
args.push({ tag, kind: "str", value: s });
|
||||||
|
} else if (p + 4 <= end) {
|
||||||
|
args.push({ tag, kind: "int", value: b.readInt32LE(p) });
|
||||||
|
p += 4;
|
||||||
|
} else {
|
||||||
|
args.push({ tag, kind: "flag" });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (STR.has(tag)) {
|
||||||
|
[s, p] = cString(b, p);
|
||||||
|
args.push({ tag, kind: "str", value: s });
|
||||||
|
} else if (U16.has(tag)) {
|
||||||
|
args.push({ tag, kind: "int", value: b.readUInt16LE(p) });
|
||||||
|
p += 2;
|
||||||
|
} else if (tag === 0x04 || tag === 0x0c || tag === 0x0d) {
|
||||||
|
const v = b.readInt32LE(p);
|
||||||
|
p += 4;
|
||||||
|
if (v === -2) {
|
||||||
|
[s, p] = cString(b, p);
|
||||||
|
args.push({ tag, kind: "pos", value: s });
|
||||||
|
} else {
|
||||||
|
args.push({ tag, kind: "pos", value: `${v},${b.readInt32LE(p)}` });
|
||||||
|
p += 8;
|
||||||
|
}
|
||||||
|
} else if (tag === 0x0b && b.readInt32LE(p) === -0x12d687) {
|
||||||
|
[s, p] = cString(b, p + 4);
|
||||||
|
args.push({ tag, kind: "num", value: s });
|
||||||
|
} else if (I32.has(tag)) {
|
||||||
|
args.push({ tag, kind: "int", value: b.readInt32LE(p) });
|
||||||
|
p += 4;
|
||||||
|
} else if (BYTES[tag] !== undefined) {
|
||||||
|
args.push({ tag, kind: "bytes", value: [...b.slice(p, p + BYTES[tag])].map(hex).join("") });
|
||||||
|
p += BYTES[tag];
|
||||||
|
} else if (tag === 0x47 || tag === 0x48 || tag === 0x49 || tag === 0x4a) {
|
||||||
|
const n = b.readInt32LE(p);
|
||||||
|
[s, p] = cString(b, p + 4);
|
||||||
|
args.push({ tag, kind: "cond", value: [n, s] });
|
||||||
|
} else if (FLAG.has(tag)) {
|
||||||
|
args.push({ tag, kind: "flag" });
|
||||||
|
} else {
|
||||||
|
args.push({ tag, kind: "raw", value: [...b.slice(p, end)].map(hex).join("") });
|
||||||
|
return { args, ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { args, ok: p === end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArg(a) {
|
||||||
|
const t = TAG_NAMES[a.tag] ?? hex(a.tag);
|
||||||
|
switch (a.kind) {
|
||||||
|
case "str": return `${t}"${a.value}"`;
|
||||||
|
case "int": return `${t}#${a.value}`;
|
||||||
|
case "num": return `${t}#"${a.value}"`;
|
||||||
|
case "pos": return `${t}@${a.value}`;
|
||||||
|
case "cond": return `${t}(${a.value[0]},"${a.value[1]}")`;
|
||||||
|
case "bytes": return `${t}[${a.value}]`;
|
||||||
|
case "flag": return t;
|
||||||
|
default: return `?${t}[${a.value}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function load(dir) {
|
||||||
|
const code = readFileSync(join(dir, "FunkCode.bin"));
|
||||||
|
const vec = readFileSync(join(dir, "Vectoren.bin"));
|
||||||
|
const n = vec.readUInt32LE(0);
|
||||||
|
const labels = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const o = 4 + i * 0x54;
|
||||||
|
const label = {
|
||||||
|
name: vec.toString("latin1", o, vec.indexOf(0, o)),
|
||||||
|
quest: vec.readInt32LE(o + 0x48),
|
||||||
|
instructions: [],
|
||||||
|
};
|
||||||
|
let p = vec.readUInt32LE(o + 0x40);
|
||||||
|
const end = p + vec.readUInt32LE(o + 0x44);
|
||||||
|
while (p + 4 <= end && end <= code.length) {
|
||||||
|
const op = code.readUInt16LE(p);
|
||||||
|
const size = code.readUInt16LE(p + 2);
|
||||||
|
if (size < 4) break;
|
||||||
|
label.instructions.push({ op, ...decodeArgs(code, p + 4, p + size, op) });
|
||||||
|
p += size;
|
||||||
|
}
|
||||||
|
labels.push(label);
|
||||||
|
}
|
||||||
|
// startcode.bin: the world's start script (initial spawns), one instruction stream.
|
||||||
|
try {
|
||||||
|
const start = readFileSync(join(dir, "startcode.bin"));
|
||||||
|
const label = { name: "startcode", quest: -1, instructions: [] };
|
||||||
|
for (let p = 0; p + 4 <= start.length;) {
|
||||||
|
const op = start.readUInt16LE(p);
|
||||||
|
const size = start.readUInt16LE(p + 2);
|
||||||
|
if (size < 4) break;
|
||||||
|
label.instructions.push({ op, ...decodeArgs(start, p + 4, Math.min(p + size, start.length), op) });
|
||||||
|
p += size;
|
||||||
|
}
|
||||||
|
labels.push(label);
|
||||||
|
} catch {
|
||||||
|
// no start script in this folder
|
||||||
|
}
|
||||||
|
const quests = new Map();
|
||||||
|
let o = 4 + n * 0x54;
|
||||||
|
const count = vec.readUInt32LE(o);
|
||||||
|
o += 4;
|
||||||
|
for (let i = 0; i < count && o + 0x124 <= vec.length; i++, o += 0x124) {
|
||||||
|
quests.set(vec.readInt32LE(o), vec.toString("latin1", o + 4, vec.indexOf(0, o + 4)).trim());
|
||||||
|
}
|
||||||
|
return { labels, quests };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInstruction(ins) {
|
||||||
|
const name = OPS[ins.op] ?? `op${hex(ins.op)}`;
|
||||||
|
return `${name} ${ins.args.map(formatArg).join(" ")}${ins.ok ? "" : " (!)"}`.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
const strings = (ins) => ins.args.flatMap((a) => (a.kind === "str" ? [a.value] : a.kind === "cond" ? [a.value[1]] : []));
|
||||||
|
const VAR_WRITES = new Set([0x43, 0x44, 0x45, 0x4b, 0x4c, 0x69]);
|
||||||
|
|
||||||
|
function printLabel(label, indent = " ") {
|
||||||
|
console.log(`${indent}${label.name}${label.quest >= 0 ? "" : " (no quest)"}`);
|
||||||
|
let depth = 0;
|
||||||
|
for (const ins of label.instructions) {
|
||||||
|
if (ins.op === 0x3b || ins.op === 0x42 || ins.op === 0x3e) depth = Math.max(0, depth - 1);
|
||||||
|
console.log(`${indent} ${" ".repeat(depth)}${formatInstruction(ins)}`);
|
||||||
|
if (ins.op === 0x3a || ins.op === 0x3b || ins.op === 0x42) depth++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printQuest({ labels, quests }, id) {
|
||||||
|
const own = labels.filter((l) => l.quest === id);
|
||||||
|
console.log(`== quest ${id} ${quests.get(id) ?? "(not in the quest table)"}: ${own.length} labels`);
|
||||||
|
for (const label of own) printLabel(label);
|
||||||
|
|
||||||
|
// Names other labels may use: own labels, dialogs, NPCs, variables.
|
||||||
|
const names = new Set(own.map((l) => l.name));
|
||||||
|
for (const l of own) if (l.name.startsWith("Dialog:")) names.add(l.name.slice(7));
|
||||||
|
const npcs = new Set();
|
||||||
|
const vars = new Set();
|
||||||
|
for (const l of own) {
|
||||||
|
for (const ins of l.instructions) {
|
||||||
|
for (const a of ins.args) if (a.kind === "cond") vars.add(a.value[1]);
|
||||||
|
if (NPC_OPS.has(ins.op) && ins.args[0]?.kind === "str" && ins.args[0].value && ins.args[0].value.toLowerCase() !== "hero") npcs.add(ins.args[0].value.toLowerCase());
|
||||||
|
if (VAR_WRITES.has(ins.op) && ins.args[0]?.kind === "str") vars.add(ins.args[0].value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vars.delete("");
|
||||||
|
// Variables many quests use (region moods, "goodies") are only named.
|
||||||
|
const shared = [];
|
||||||
|
for (const v of vars) {
|
||||||
|
const users = new Set(labels.filter((l) => l.quest !== id && l.instructions.some((ins) => strings(ins).includes(v))).map((l) => l.quest));
|
||||||
|
if (users.size > 2) {
|
||||||
|
vars.delete(v);
|
||||||
|
shared.push(`${v} (${users.size} quests)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` -- references from other labels (NPCs ${[...npcs].join(" ") || "-"}; variables ${[...vars].join(" ") || "-"}; shared ${shared.join(" ") || "-"})`);
|
||||||
|
for (const l of labels) {
|
||||||
|
if (l.quest === id) continue;
|
||||||
|
const hits = l.instructions.filter((ins) =>
|
||||||
|
strings(ins).some((s) => names.has(s) || (vars.has(s) && (VAR_WRITES.has(ins.op) || ins.op === 0x3a || ins.op === 0x42))) ||
|
||||||
|
(NPC_OPS.has(ins.op) && npcs.has(String(ins.args[0]?.value).toLowerCase())) ||
|
||||||
|
((ins.op === 0x14 || ins.op === 0x0a || ins.op === 0x0b || ins.op === 0x0c || ins.op === 0x0d || ins.op === 0x4d || ins.op === 0x75) &&
|
||||||
|
ins.args.some((a) => a.kind === "int" && a.value === id)));
|
||||||
|
if (hits.length) {
|
||||||
|
console.log(` ${l.name} [quest ${l.quest}]`);
|
||||||
|
for (const ins of hits) console.log(` ${formatInstruction(ins)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a label is used: "<op>:<tag> in <label>" for every argument naming it
|
||||||
|
// (dialog buttons, death labels, CallFunktion, timers, triggers, ...).
|
||||||
|
function labelUses(labels, name) {
|
||||||
|
const uses = [];
|
||||||
|
for (const l of labels) {
|
||||||
|
for (const ins of l.instructions) {
|
||||||
|
for (const a of ins.args) {
|
||||||
|
if (a.kind === "str" && a.value === name && !(ins.op === 0x01 && a.tag === 0x01)) {
|
||||||
|
uses.push(`${OPS[ins.op] ?? hex(ins.op)}${a.tag === 0x05 ? "(death of " + ins.args[0]?.value + ")" : ""} in ${l.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uses;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact facts for the audit: giver, how the quest ends or fails, NPC deaths.
|
||||||
|
function printFacts({ labels, quests }, id) {
|
||||||
|
const own = labels.filter((l) => l.quest === id);
|
||||||
|
const ownNames = new Set(own.map((l) => l.name));
|
||||||
|
console.log(`== ${id} ${quests.get(id) ?? "(not in the quest table)"} (${own.length} labels)`);
|
||||||
|
// Givers: NPCs whose dialog has a button starting the quest.
|
||||||
|
const accepts = new Set(own.filter((l) => l.instructions[0]?.op === 0x14).map((l) => l.name));
|
||||||
|
const giverDialogs = own.filter((l) => l.name.startsWith("Dialog:") &&
|
||||||
|
l.instructions.some((ins) => ins.op === 0x3c && accepts.has(ins.args[1]?.value))).map((l) => l.name.slice(7));
|
||||||
|
for (const l of labels) {
|
||||||
|
for (const ins of l.instructions) {
|
||||||
|
if ((ins.op === 0x01 || ins.op === 0x03) && ins.args.some((a) => a.tag === 0x09 && giverDialogs.includes(a.value))) {
|
||||||
|
console.log(` giver ${ins.args[0]?.value} (${OPS[ins.op]} in ${l.name}${l.quest === id ? ", own label" : ""}): ${formatInstruction(ins)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!giverDialogs.length) console.log(" giver: none found (accept labels: " + ([...accepts].join(" ") || "-") + ")");
|
||||||
|
for (const l of own) {
|
||||||
|
const ends = l.instructions.filter((ins) => ins.op === 0x0f || ins.op === 0x36).map((ins) => OPS[ins.op]);
|
||||||
|
const morph = l.instructions.filter((ins) => ins.op === 0x4f || ins.op === 0x37 || ins.op === 0x2e || ins.op === 0x38).map(formatInstruction);
|
||||||
|
if (!ends.length && !morph.length) continue;
|
||||||
|
const uses = labelUses(labels, l.name).filter((u) => !u.endsWith(` in ${l.name}`));
|
||||||
|
console.log(` ${l.name}: ${[...ends, ...morph].join("; ")} <- ${uses.join("; ") || (l.name.startsWith("QIS_") ? "quest end script" : "?")}`);
|
||||||
|
}
|
||||||
|
// Death labels anywhere that run this quest's labels.
|
||||||
|
for (const l of labels) {
|
||||||
|
for (const ins of l.instructions) {
|
||||||
|
const death = ins.args.find((a) => a.tag === 0x05 && ownNames.has(a.value));
|
||||||
|
if (death) console.log(` death ${ins.args[0]?.value} -> ${death.value} (${OPS[ins.op]} in ${l.name})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop());
|
||||||
|
if (isMain) {
|
||||||
|
const [dir, ...rest] = process.argv.slice(2);
|
||||||
|
if (!dir || !rest.length) {
|
||||||
|
console.error("usage: node questscript.mjs <script dir> <quest id...> | --list | --facts [ids] | --label <name> | --grep <regex>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const script = load(dir);
|
||||||
|
if (rest[0] === "--list") {
|
||||||
|
for (const [id, name] of script.quests) {
|
||||||
|
console.log(`${id}\t${script.labels.filter((l) => l.quest === id).length}\t${name}`);
|
||||||
|
}
|
||||||
|
} else if (rest[0] === "--facts") {
|
||||||
|
const ids = rest.length > 1 ? rest.slice(1).map(Number) : [...script.quests.keys()];
|
||||||
|
for (const id of ids) printFacts(script, id);
|
||||||
|
} else if (rest[0] === "--grep") {
|
||||||
|
// Instructions matching a regular expression, in all labels.
|
||||||
|
const re = new RegExp(rest[1]);
|
||||||
|
for (const l of script.labels) {
|
||||||
|
for (const ins of l.instructions) {
|
||||||
|
const text = formatInstruction(ins);
|
||||||
|
if (re.test(text)) console.log(`${l.name} [quest ${l.quest}]: ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (rest[0] === "--label") {
|
||||||
|
for (const l of script.labels.filter((l) => l.name === rest[1])) printLabel(l, "");
|
||||||
|
} else {
|
||||||
|
for (const id of rest) printQuest(script, Number(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user