Compare commits

..
34 Commits
Author SHA1 Message Date
khallaandClaude Opus 5.5 e13b82f040 Quests: restart resets variables, respawns giver and NPCs; final failures
Quest audit (docs/quest-audit.md, tools/questscript.mjs), fixes 2-8.
New mods/questscript.cpp scans the script code once (giver, own variables,
needed NPCs, QIS_OnEnter creations, story-ending failure labels).
questfail: generic, giverless, story-ending and NPC-gone failures stay
final; a restart restores the variables snapshot taken at Quest_Trigger,
deletes OnEnter leftovers, respawns the giver and needed NPCs and gives the
accept dialog back. questgiver slimmed on top of questscript.

Not yet tested in game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:03:20 +01:00
khallaandClaude Opus 5.5 7ec0fa2b8e Quest givers: givers killed as enemies keep their death script
"Familienbande" (702) turns its giver hostile and wants him killed; the
giver death rule restarted it instead. A giver hostile to the hero at death
now runs the game's label, and the load repair skips quests whose script
changes their giver's creature type (opcode 0x4f). Deferred notes: frame
drops while walking, Eingraben clearing slows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:57:43 +01:00
khallaandClaude Opus 5.5 471b8b13d5 Eingraben: 2 s move inside a barrier turning solid, 4 s duration
The barrier's model alpha goes to opaque over 1 s, the dwarf is moved,
and it clears over 1 s; orders to him are refused meanwhile (new
game::FilterCreatureOrders). The art's duration in its definition is a
fixed 4 s (was 7 + level), which the barrier, its bonus and the tooltip use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:28:07 +01:00
khallaandClaude Opus 5.5 198bbab6f9 Eingraben: aimed escape to a point in sight, 3 s barrier, no lock
Eingraben becomes ground-targeted; after its own case the dwarf moves to
the end of Phasenverschiebung's walk towards the point (stops before walls
and cliffs). The barrier's bonus lasts 3 s and its lock in place
(effect kind 0x3a) is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:01:43 +01:00
khallaandClaude Opus 5.5 55e48dd302 Quests: main quest items no longer refused, restart resets bit-field variables
AddMainQuestItem refused every new main quest item once the list held an
entry with script id 0 (scripts give such items); the Goldbeeren of quest
402 were picked up and lost. The script id test is patched out.

Quest variables are bit fields (op 0x44 sets a bit): the quest giver
restart now writes the values directly, 0 and 1 (available) for the
variable named after the quest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:07:06 +01:00
khallaandClaude Opus 5.5 e539cab604 Followers: escorts left behind are teleported to the hero
A follower that fell more than 40 cells behind was removed when its sector
was switched off, and the game ran its death label (the husband of "Die
Sklaven der Orks"). Every 500 ms followers of the local hero that far away
are teleported to him with cCreature::Teleport.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 23:39:21 +01:00
khallaandClaude Opus 5.5 42b99526a4 Guard: grenade impact particles out of the colour table
Mörsergranate's explosion crashed twice reading the colour of a particle
with a huge life (sacred.exe+0x361b54). Particles with life >= 256 or the
unset fourth row are switched off before drawing; logged cases were all
row 3 with garbage sizes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 23:39:20 +01:00
khallaandClaude Opus 5.5 91e7f844cc Mörsergranate: explosion radius in the tooltip
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 22:21:50 +01:00
khallaandClaude Opus 5.5 a4d5683403 Mörsergranate: impact marker on the ground until the explosion
The landing point is noted from the grenade's cEventFX 0x96 at launch; a
translucent red circle with the explosion radius (shared ImpactRadius) is
drawn after the world's ground, under the objects, and ends when that
grenade's impact gathers its targets. Ground -> screen projection in
game/engine/worldview.h.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 22:08:08 +01:00
khallaandClaude Opus 5.5 32f953fc01 Large map: explored border as a continuous line along the fog edge
The game drew 2x2 px red dots per visited-sector neighbour (0x6c8580), in
64-tile steps and unrelated to the explored mask. Replaced by lines traced
from the explored cells (2x2 tiles), simplified to 0.75 px, rebuilt while
the map is open when the masks change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 21:57:30 +01:00
khallaandClaude Opus 5.5 7d8089c299 Quests: escorts stay on the follower list, quest givers come back
Escorts lost their place on the hero's follower list after a load, so
quest checks like the one in "Furchtbares Verbrechen" failed; they are
added back once after loading (town NPCs that tag along briefly are left
out). Restarted quests stay available (flag 1), which their giver's dialog
and accepting them need. Quest givers are read from the quest scripts:
when a running quest's giver dies, the quest restarts and the giver is
spawned again at her start; an accepted quest without its giver is repaired
on load. Notes on the script format and opcodes in docs/quests.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 02:30:41 +01:00
khallaandClaude Opus 5.5 2cab6c216d Dwarf arts: Kanonenschuss stuns without knockback, Mörsergranate radius grows
Kanonenschuss: the stun no longer plays the hit motion or pushes the
target; stunned creatures stand still for 1.5 s. The impact effect only
shows on targets that did not resist, and the ray reaches 1800 for the
wider view. Mörsergranate: the impact radius grows by a tenth of its size
every 5 skill levels. Art levels come from a shared helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 02:30:29 +01:00
khallaandClaude Opus 5.5 10c97f75e1 Autotest: load through the Spielen screen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 18:24:29 +01:00
khallaandClaude Opus 5.5 401e20b1f2 Loot filter: quest items are their own category, first and never voided
Quest items are recognized by the type's quest number or the scripts'
QuestItem / MainQuestItem marks, checked before the other categories.
Their row in the Beute tab has no Löschen box; Void in the ini reads as
Default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 18:24:28 +01:00
khallaandClaude Opus 5.5 c0eedbce7f Quest NPCs turn blue and stop facing the hero once their quest is done
A dialog's quest comes from the game's script labels ("Dialog:<name>",
quest at +0x48). When that quest is done and not in progress, IsQuestNpc
answers false (blue on the minimap) and the idle action's type check
skips the turn towards the hero.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 18:13:43 +01:00
khallaandClaude Opus 5.5 9bc557cd95 Preferred monitor: chosen in Optionen > Allgemein, used at the next start
The Internet-Optionen frame becomes Bildschirm with a Monitor dropdown
(Hauptmonitor or a connected monitor, kept by device path in
[display] Monitor=). The borderless window and the render size use that
monitor, falling back to the main monitor when it is not connected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 16:06:20 +01:00
khallaandClaude Opus 5.5 c7d54a6cfd Megamap: the zoom button zooms on the hero and follows him
The zoom button never set the zoom point, so it zoomed on the last
magnifier point (or 0,0). While the button's zoom is on, the point is put on
the hero's map pixel every engine tick; the held mouse button still zooms on
the mouse. game::UiWindow / UiWindowShown shared from windows.h.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 11:54:59 +01:00
khallaandClaude Opus 5.5 1d16fc65c4 Quest book as one list: story, then side quests by region with ??? rows
The game's list builder runs on a reordered copy of the book: the story, then
one section per region (id blocks of 100 grouped by the region their quests
lie in, named as the megamap names regions), unknown quests as "???", the
rest joined to the nearest area, undiscovered counts. Act tabs hidden, page
turning kept. "Erledigte anzeigen" row toggles completed quests
([questbook] ShowCompleted).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 11:41:19 +01:00
khallaandClaude Opus 5.5 34cacefcfc Anti-cheat checks removed
All mirror checks are skipped by code patches (the game's own "not the local
hero" paths, or straight past the check), the level trap in Think and
VerifyHeroMirror are off, the cheater flag is never set (stream checks,
loadHero, character selection). The VirtualProtect mirror refresh is gone; it
never refreshed the second mirror, a separate heap block, which killed the
hero again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 11:36:42 +01:00
khallaandClaude Opus 5.5 b452558122 Map: zoomed-view symbols centred, 10x reveal and enemy range; progress screen on the menu background
- Megamap / TAB map zoomed view: heroes' markers and the other symbols
  (integer 200h centre immediates) follow the view's centre.
- Map reveal: the hero's reveal stamp scaled 10x (mods/mapreveal.cpp,
  docs/map-reveal.md); creatures on the zoomed map shown within 10x the
  sight range.
- Load / save progress screen drawn on the rock background across the full
  width, the picture blended over it (mods/loadingscreen.cpp); the classic
  1024x768 frame is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 02:07:59 +02:00
khallaandClaude Opus 5.5 3f03228c29 Failed quests restartable
A failed quest gets its flags cleared and its quest book entries removed
after its failure script ran, so the quest giver can offer it again; quests
failed before the mod are reset once after loading. Quest table and quest
book in game/engine/quests.h, measurements in docs/quests.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 01:44:01 +02:00
khallaandClaude Opus 5.5 91bbc52cf0 Anti-cheat off, weapon sets 6-7 in hero files
- The game's cheater detection killed the hero ("Hase!") whenever its
  protected stat mirror was stale; the mirror is now refreshed from the hero
  whenever it is unlocked, stream checks never punish, loadHero no longer
  sets the cheater flag (docs/anticheat.md).
- saveHero wrote the items of weapon sets 1-5 only: its loop runs over all 7
  sets, so sets 6-7 survive a world loading the hero from the hero file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 01:33:43 +02:00
khallaandClaude Opus 5.5 3978060954 Teleportationstrank: the running campaign's portal list
In the Underworld the potion opens the Underworld portals (busy dialog mode
6, as the teleporters there do); g_campaign moved to game/addresses.h.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:42:55 +02:00
khallaandClaude Opus 5.5 0ef3cf4bbd Shared chest, menu after a game, dialogs over 16:9 screens
- Shared chest: emptied and not saved from the moment a save game is read
  until it reloads; a hero saved inside a load wrote the new world's
  objects (stale ids) as the chest's items.
- Main menu after a game: shown 16:9 screens are tracked on the menu thread
  only and expire after 2 frames; a destroyed in-game window kept the menu
  unframed (old pictures, frame at the left).
- Dialogs over a 16:9 screen in the main menu are centred, their dimming
  covers the screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:41:12 +02:00
khallaandClaude Opus 5.5 c4aacbcdd2 Hide menu pictures only inside the menu frames
The hidden-texture list stayed active in a game; a game texture made at the
address of a freed menu picture was skipped (rendering glitches in-game).
Hiding now applies only while the main menu's frame or the in-game options
draw over the menu background.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:10:30 +02:00
khallaandClaude Opus 5.5 db87e276bf Menu screens: rock background, centred frame, logo, 3D hero
- Spielen hero panel shows the selected hero in 3D (cUI_NetGranny,
  game/ui/granny.h).
- All menu screens draw on the options' rock wall; the main menu's 1024
  frame is drawn centred over it, mouse and cursor shifted to match.
- The main menu's and options' own pictures and the main menu's 3D scene
  are left out (textures hidden by surface, scene call redirected).
- Logo top left: FX_SACREDLOGO.TGA, drawn added.
- Game textures by name (game/ui/texture.h), textured and added quads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:05:55 +02:00
khallaandClaude Opus 5.5 34a119433a Characters: 16:9 "Spielen" screen, autosave interval option
- Shared screen kit (mods/screen, docs/screens.md): a 1365x768 canvas
  centred in the render width on our own dark background, the game's
  frames and buttons; the main menu renders full width while it is shown.
- The load window (cUI_Savegame) is replaced by the Spielen screen:
  character list, hero panel, campaign x difficulty grid, save/autosave
  choice, rename/delete/reset/new character dialogs.
- Migrated characters without a shared hero load their hero's world
  first, then start the new world.
- Options, Allgemein: autosave interval (Aus/5/10/15/30 minutes).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 23:20:45 +02:00
khallaandClaude Opus 5.5 732d9c2abb Characters: Laden in the Esc menu, timed autosave
- Esc menu: "Exportieren" became "Laden"; it opens the load window, always
  in load mode. In a game every world loads (the current one reloads); a
  "(neu)" world stops the game (cCommand_armaStop) and starts the new one
  without the main menu. The character being played cannot be deleted.
- Silent autosave every sacredK.ini [characters] AutosaveMinutes (default
  10, 0 = off) into the character's autosave file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 22:41:20 +02:00
khallaandClaude Opus 5.5 feebe72ab7 Characters: reset a difficulty
"Zuruecksetzen" in the load window lists the selected character's worlds
and asks for confirmation. The world goes (a copy in characters\deleted),
with an autosave of it, and the shared hero's story completion of that
difficulty is cleared. The dialog is one generic list (title + entries).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 22:04:41 +02:00
khallaandClaude Opus 5.5 e0d8d0ce7e Esc menu without "Exportieren"; export screen mod removed
- cUI_EscMenu::BuildLines hook: the UI_ESC_EXPORT line is removed, the
  lines below move up, the window shrinks by one line, centred.
- game/ui/control.h: control vector helpers (ControlsIn, RemoveControl,
  SetControlAt, DeleteControl, ControlNamed), used by the main menu too.
- The export screen is unreachable now: charexport.cpp and
  docs/char-export.md deleted. Spielen screen redesign noted in todo.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 21:56:11 +02:00
khallaandClaude Opus 5.5 618288e310 Characters: campaigns, "Spielen" main menu, story unlocks
- One world per campaign and difficulty in a character file (sections
  ancaria<d> / underworld<d>; old world<d> renamed when read). Difficulty
  unlocks by the shared hero's story completions (hero header +0xed..),
  shared by both campaigns.
- Main menu: "Kampagne Ancaria", "Kampagne Underworld" and "Spielstaende"
  become "Spielen" (mods/mainmenu.cpp). The load window lists all
  characters, the world dialog offers both campaigns, "Neuer Charakter"
  asks for the campaign and opens character creation.
- Character window: no Underworld import mode (new Underworld heroes, all
  classes, slots light up), no "Importieren"; load window without
  "Speichern".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 21:48:47 +02:00
khallaandClaude Opus 5.5 5bccb0734e Characters: one file per character with a world per difficulty
- CharacterNN.sacredK holds the shared hero and one world per difficulty,
  plus an autosave file; the game works in save\characters\_work
- Load window: one row per character, a difficulty dialog on Laden
  (worlds, newer autosaves, "(neu)" starts a new game with the shared
  hero), Umbenennen, delete moves the character to characters\deleted
- The world keeps its own portals and hero position when the shared hero
  is put on
- K and the Esc menu's "Speichern" save directly (quicksave path)
- One-time migration of the old save rows and exported heroes, after a
  backup
- Startup no longer hangs in the game's foreground loop (0x810af0)
- Text overrides apply to our own text ids too; shared cUI_Button2
  helpers in game/ui/button.h

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 01:41:42 +02:00
khallaandClaude Opus 5.5 5df7efc198 Docs: local Sacred Gold install required, no hard-coded game path
README explains --game-dir and the stored GameDir; the VS Code launch
uses the stored path and builds through build.ps1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 00:14:36 +02:00
khallaandClaude Opus 5.5 6c467e22c4 User folders: save games and Settings.cfg in Documents\Sacred
The game's ANSI file API calls are hooked; paths under save\ and
Settings.cfg are redirected to Documents\Sacred\save and
Documents\Sacred\config, copied there once from the game folder.
The shared chest uses the same save folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 00:14:36 +02:00
93 changed files with 10835 additions and 393 deletions

No files matched your search

+4 -2
View File
@@ -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"
}, },
{ {
+7 -4
View File
@@ -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"]
} }
+5 -3
View File
@@ -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.
+18 -1
View File
@@ -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,9 +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/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})
+15 -2
View File
@@ -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.
+76
View File
@@ -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.
-48
View File
@@ -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.
+318
View File
@@ -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`).
+143
View File
@@ -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
View File
@@ -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
+12 -4
View File
@@ -13,12 +13,21 @@ Decided with the user: the filter replaces the game's PICKUPAUTO choice
(all / valuable / gold only; its radio buttons are hidden, the animation (all / valuable / gold only; its radio buttons are hidden, the animation
checkbox stays); Void deletes loot only (monster drops, chests), not items checkbox stays); Void deletes loot only (monster drops, chests), not items
the player puts down. Settings: `[loot]` in `sacredK.ini` the player puts down. Settings: `[loot]` in `sacredK.ini`
(`Gold=Default`, keys Gold, Potions, White, Blue, Yellow, Brown, Green; (`Gold=Default`, keys Quest, Gold, Potions, White, Blue, Yellow, Brown, Green;
values Void, Ignore, Default, AlwaysShow). Code: `src/mods/lootfilter.cpp`, values Void, Ignore, Default, AlwaysShow). Code: `src/mods/lootfilter.cpp`,
page in `src/mods/options.cpp`. page in `src/mods/options.cpp`.
## What decides the category (read from the game) ## 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 - Gold: `0x43ae20` (thiscall on the item type table, (type)): one of four
types listed at `0x947920`. The labels show its amount, the collect mode types listed at `0x947920`. The labels show its amount, the collect mode
takes it without inventory space, the loot drop sets the amount after takes it without inventory space, the loot drop sets the amount after
@@ -31,9 +40,8 @@ page in `src/mods/options.cpp`.
`ffa0a0ff`, 12..14 yellow `fff0e040`, 15 brown `ffc19a33`; set green `ffa0a0ff`, 12..14 yellow `fff0e040`, 15 brown `ffc19a33`; set green
`ff15ff25`. (Without UNIQUE_COLOR, items with special bits get other `ff15ff25`. (Without UNIQUE_COLOR, items with special bits get other
colours from `0x5c1260`; the filter uses the tier regardless.) colours from `0x5c1260`; the filter uses the tier regardless.)
- Not filtered: runes (class 0x1c), objects with flag 0x800 and the types - Not filtered: runes (class 0x1c) and objects with flag 0x800 (the game's
`0x428550` reports (CanCollectItem always takes both; quest items, CanCollectItem always takes those).
presumably).
## Where it acts ## Where it acts
+65
View File
@@ -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.
+44
View File
@@ -117,3 +117,47 @@ choice (PICKUPAUTO radio buttons, their labels, the separator; matched by
pointer and control name at +0x30) is never shown, a framed table below the 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 auto-pickup frame sets a mode per category. Its frame is made like the key
page's (`AddFrame`). 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.
+278
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+10
View File
@@ -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.
+8
View File
@@ -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
View File
@@ -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.
+55
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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;
+79
View File
@@ -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
+15 -7
View File
@@ -79,13 +79,21 @@ inline bool ItemIsSetItem(uint32_t type) {
return group && group[0x23] != 0; return group && group[0x23] != 0;
} }
// 0x428550 (thiscall on the type table, (type), ret 4): cCreature:: // Quest items: the type's quest number (+0x1c) or the scripts' QuestItem /
// CanCollectItem takes items of such types whatever the pickup setting (type // MainQuestItem marks (+0x61 / +0x62; tokens of those script words, 0x4584a9),
// flag 0x1000000 with +0x61 / +0x62 / +0x1c set; quest items, presumably). // as the quest book reads them (0x43ad80). The game's CanCollectItem
inline bool ItemAlwaysCollected(uint32_t type) { // (0x428550) takes them always, but only with type flag 0x1000000 set too.
using Fn = bool(__fastcall*)(void* table, void* edx, uint32_t type); constexpr size_t kItemTypeQuest = 0x1c;
void* table = *reinterpret_cast<void**>(kItemTypeTable); constexpr size_t kItemTypeQuestItem = 0x61;
return table && reinterpret_cast<Fn>(0x00428550)(table, nullptr, type); 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 // The item's quality block (+0x118, cArmor3D::vf24 case 2 hands it to the
+12
View File
@@ -75,6 +75,18 @@ inline void RemoveFromMap(uint32_t 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);
+14
View File
@@ -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);
+59
View File
@@ -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.
+276
View File
@@ -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
+40
View File
@@ -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
+48
View File
@@ -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
+66
View File
@@ -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
+41
View File
@@ -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
View File
@@ -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
+36
View File
@@ -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
+67
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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];
+3
View File
@@ -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).
+34
View File
@@ -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
+14
View File
@@ -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
+68
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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;
}
+2 -7
View File
@@ -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
+15
View File
@@ -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
-104
View File
@@ -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
+321
View File
@@ -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(&section.offset, entry + kSectionNameSize, 4);
std::memcpy(&section.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
+103
View File
@@ -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
View File
@@ -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) {
+684
View File
@@ -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
+12
View File
@@ -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
+128
View File
@@ -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
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -32
View File
@@ -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);
} }
} }
@@ -376,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;
+15
View File
@@ -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 {
+134
View File
@@ -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);
}
+13
View File
@@ -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
+25 -13
View File
@@ -1,6 +1,6 @@
// Loot filter (request of 2026-10-19), set on the options window's Beute page // Loot filter (request of 2026-10-19), set on the options window's Beute page
// (mods/options.cpp): per category - Gold, Tränke, and the item colours white, // (mods/options.cpp): per category - Questgegenstände, Gold, Tränke, and the
// blue, yellow, brown, green - one of // item colours white, blue, yellow, brown, green - one of
// Void a drop is deleted at once (monster loot, chests; items the // Void a drop is deleted at once (monster loot, chests; items the
// player puts down stay), // player puts down stay),
// Ignore the pickup key (Q, the collect mode) leaves it, // Ignore the pickup key (Q, the collect mode) leaves it,
@@ -9,10 +9,14 @@
// It replaces the game's PICKUPAUTO setting (all / valuable / gold only), // It replaces the game's PICKUPAUTO setting (all / valuable / gold only),
// whose radio buttons are gone from the page. // whose radio buttons are gone from the page.
// //
// - Categories from the game's data: gold by type (0x43ae20), potions by item // - Categories from the game's data: quest items first (request of
// class 9, set items green (0x433f00 +0x23), otherwise the quality tier // 2026-10-25: they were voided as their colour's category; "it needs to be
// that picks the item colour (0x5c1330, game/engine/items.h). Runes and the // top priority, also Löschen is not an option for those"; type's quest
// types CanCollectItem always takes (quest items) are not filtered. // 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 // - Pickup: cCreature::CanCollectItem (0x4ec170) is the collect mode's filter
// (both of its scans; a clicked item does not ask it). // (both of its scans; a clicked item does not ask it).
// - Void: items made by the loot drop (cCreature 0x559a30, via // - Void: items made by the loot drop (cCreature 0x559a30, via
@@ -44,7 +48,7 @@
namespace mods::lootfilter { namespace mods::lootfilter {
namespace { namespace {
enum Category : int { kGold, kPotions, kWhite, kBlue, kYellow, kBrown, kGreen, kNone = -1 }; enum Category : int { kQuest, kGold, kPotions, kWhite, kBlue, kYellow, kBrown, kGreen, kNone = -1 };
struct CategoryInfo { struct CategoryInfo {
const char* key; // in [loot] const char* key; // in [loot]
@@ -53,6 +57,7 @@ struct CategoryInfo {
}; };
// Gold and potions as the game's labels show them: white. // Gold and potions as the game's labels show them: white.
constexpr CategoryInfo kCategories[kCategoryCount] = { constexpr CategoryInfo kCategories[kCategoryCount] = {
{"Quest", L"Questgegenstände", game::kItemColourWhite},
{"Gold", L"Gold", game::kItemColourWhite}, {"Potions", L"Tränke", game::kItemColourWhite}, {"Gold", L"Gold", game::kItemColourWhite}, {"Potions", L"Tränke", game::kItemColourWhite},
{"White", L"Weiß", game::kItemColourWhite}, {"Blue", L"Blau", game::kItemColourBlue}, {"White", L"Weiß", game::kItemColourWhite}, {"Blue", L"Blau", game::kItemColourBlue},
{"Yellow", L"Gelb", game::kItemColourYellow}, {"Brown", L"Braun", game::kItemColourBrown}, {"Yellow", L"Gelb", game::kItemColourYellow}, {"Brown", L"Braun", game::kItemColourBrown},
@@ -67,10 +72,10 @@ constexpr ModeInfo kModes[kModeCount] = {
{"Void", L"Löschen"}, {"Ignore", L"Ignorieren"}, {"Default", L"Standard"}, {"AlwaysShow", L"Immer anzeigen"}}; {"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 g_modes[kCategoryCount] = {Mode::Default, Mode::Default, Mode::Default, Mode::Default,
Mode::Default, Mode::Default, Mode::Default}; Mode::Default, Mode::Default, Mode::Default, Mode::Default};
// cObject +0x14 bit 0x800: CanCollectItem takes such objects whatever the // cObject +0x14 bit 0x800: the game's CanCollectItem takes such objects
// pickup setting (as the types of ItemAlwaysCollected). // whatever the pickup setting.
constexpr uint32_t kObjectAlwaysCollected = 0x800; constexpr uint32_t kObjectAlwaysCollected = 0x800;
int CategoryOf(void* object) { int CategoryOf(void* object) {
@@ -82,8 +87,11 @@ int CategoryOf(void* object) {
if (type == 0 || type >= game::kItemTypeCount) { if (type == 0 || type >= game::kItemTypeCount) {
return kNone; 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); const uint32_t flags = *reinterpret_cast<const uint32_t*>(static_cast<const uint8_t*>(object) + game::kObjectFlags);
if ((flags & kObjectAlwaysCollected) || game::ItemAlwaysCollected(type)) { if (flags & kObjectAlwaysCollected) {
return kNone; return kNone;
} }
if (game::ItemIsGold(type)) { if (game::ItemIsGold(type)) {
@@ -125,7 +133,7 @@ void Load() {
for (int i = 0; i < kCategoryCount; ++i) { for (int i = 0; i < kCategoryCount; ++i) {
const std::string text = re::ConfigString("loot", kCategories[i].key, kModes[int(Mode::Default)].key); const std::string text = re::ConfigString("loot", kCategories[i].key, kModes[int(Mode::Default)].key);
for (int m = 0; m < kModeCount; ++m) { for (int m = 0; m < kModeCount; ++m) {
if (_stricmp(text.c_str(), kModes[m].key) == 0) { if (_stricmp(text.c_str(), kModes[m].key) == 0 && ModeAllowed(i, static_cast<Mode>(m))) {
g_modes[i] = static_cast<Mode>(m); g_modes[i] = static_cast<Mode>(m);
} }
} }
@@ -207,6 +215,10 @@ const wchar_t* ModeLabel(Mode mode) {
return kModes[static_cast<int>(mode)].label; return kModes[static_cast<int>(mode)].label;
} }
bool ModeAllowed(int category, Mode mode) {
return category != kQuest || mode != Mode::Void;
}
Mode GetMode(int category) { Mode GetMode(int category) {
return g_modes[category]; return g_modes[category];
} }
@@ -225,7 +237,7 @@ namespace game {
using namespace mods::lootfilter; using namespace mods::lootfilter;
// cCreature::CanCollectItem (0x4ec170, (item), ret 4): carried items never; // cCreature::CanCollectItem (0x4ec170, (item), ret 4): carried items never;
// quest items, runes and anything not filtered always; else by the mode. // runes and anything not filtered always; else by the mode.
bool __fastcall Creature_CanCollectItem(void* self, void* edx, uint8_t* item); bool __fastcall Creature_CanCollectItem(void* self, void* edx, uint8_t* item);
RE_HOOK(0x004ec170, Creature_CanCollectItem); RE_HOOK(0x004ec170, Creature_CanCollectItem);
bool __fastcall Creature_CanCollectItem(void*, void*, uint8_t* item) { bool __fastcall Creature_CanCollectItem(void*, void*, uint8_t* item) {
+4 -3
View File
@@ -13,13 +13,14 @@ enum class Mode : int {
}; };
constexpr int kModeCount = 4; constexpr int kModeCount = 4;
// Categories: Gold, Tränke, then the item colours white, blue, yellow, brown, // Categories: Questgegenstände (first: checked before the others, never
// green. // Void), Gold, Tränke, then the item colours white, blue, yellow, brown, green.
constexpr int kCategoryCount = 7; constexpr int kCategoryCount = 8;
const wchar_t* CategoryLabel(int category); const wchar_t* CategoryLabel(int category);
uint32_t CategoryColour(int category); // as the game colours their names uint32_t CategoryColour(int category); // as the game colours their names
const wchar_t* ModeLabel(Mode mode); const wchar_t* ModeLabel(Mode mode);
bool ModeAllowed(int category, Mode mode);
Mode GetMode(int category); Mode GetMode(int category);
// Sets all modes (`modes[kCategoryCount]`) and writes them to sacredK.ini. // Sets all modes (`modes[kCategoryCount]`) and writes them to sacredK.ini.
+93
View File
@@ -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
+376
View File
@@ -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
+108
View File
@@ -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);
}
}
+75
View File
@@ -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
+160
View File
@@ -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
+31
View File
@@ -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
+325 -52
View File
@@ -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.
// //
@@ -28,15 +29,19 @@
#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/lootfilter.h"
#include "mods/monitor.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -54,21 +59,15 @@ constexpr size_t kOkButton = 0x15c;
constexpr size_t kPickupRadios[] = {0x16c, 0x170, 0x174}; constexpr size_t kPickupRadios[] = {0x16c, 0x170, 0x174};
constexpr const char* kPickupControlNames[] = {"UI_LABEL_COLLECT", "UI_FRAME_SEP_AUTOCOLLECT"}; constexpr const char* kPickupControlNames[] = {"UI_LABEL_COLLECT", "UI_FRAME_SEP_AUTOCOLLECT"};
constexpr size_t kControlName = 0x30; // char[32], set by 0x732090 constexpr size_t kControlName = 0x30; // char[32], set by 0x732090
// cUI_Frame (vtable 0x8955ac): its render (0x73a540) doesn't test the visible // cUI_Frame (game/ui/frame.h) draws by +0xdc, not by the visible flag.
// flag but +0xdc (set to 1 by its constructors, read nowhere else).
constexpr uint32_t kFrameVtable = 0x008955ac;
constexpr size_t kFrameDrawn = 0xdc;
// The key bindings page gets a translucent backdrop and a frame per column, // 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.
@@ -119,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):
@@ -223,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;
} }
} }
} }
@@ -326,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);
@@ -412,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);
@@ -439,27 +422,17 @@ 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); PushControl(window, button);
std::copy(cancel + kButtonCaptionOffset, cancel + kButtonCaptionOffset + 4, button + kButtonCaptionOffset); }
auto* rect = reinterpret_cast<game::UiRect*>(button + game::kControlRect);
*rect = *reinterpret_cast<const game::UiRect*>(cancel + game::kControlRect);
rect->x = kResetX;
rect->w = kResetWidth;
using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t);
reinterpret_cast<CaptionFn>(0x0072ad60)(button, nullptr, kCaption, 1);
using NameFn = void(__fastcall*)(void*, void*, const char*);
reinterpret_cast<NameFn>(0x00732090)(button, nullptr, "UI_BTN_KEYS_RESET");
PushControl(window, button);
return button; return button;
} }
@@ -470,9 +443,8 @@ void AddFrame(uint8_t* window, game::UiRect frame, const char* name) {
static_cast<int16_t>(frame.h + 2 * kFrameBorder)}; static_cast<int16_t>(frame.h + 2 * kFrameBorder)};
using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t); using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t);
reinterpret_cast<AddBackdropFn>(0x00724ed0)(window, nullptr, &backdrop, kBackdropColour); reinterpret_cast<AddBackdropFn>(0x00724ed0)(window, nullptr, &backdrop, kBackdropColour);
if (void* memory = game::GameNew(kFrameSize)) { if (uint8_t* control = game::NewFrame(name, frame)) {
using FrameFn = void*(__fastcall*)(void*, void*, const char*, uint32_t, game::UiRect*, uint32_t); PushControl(window, control);
PushControl(window, reinterpret_cast<FrameFn>(0x00739f90)(memory, nullptr, name, kMainFrame, &frame, 1));
} }
} }
@@ -551,7 +523,8 @@ game::UiRect LootBoxRect(void* window, int row, int mode) {
int LootBoxAt(void* window, int x, int y) { int LootBoxAt(void* window, int x, int y) {
for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) { for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) {
for (int mode = 0; mode < mods::lootfilter::kModeCount; ++mode) { for (int mode = 0; mode < mods::lootfilter::kModeCount; ++mode) {
if (Inside(LootBoxRect(window, category + 1, mode), x, y)) { 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 category * mods::lootfilter::kModeCount + mode;
} }
} }
@@ -576,6 +549,9 @@ void DrawLootPage(const Window& w, void* device) {
game::DrawText(device, kFont, game::CenterLineVertically(kFont, LootRowRect(window, row, 0, kLootLabelWidth)), game::DrawText(device, kFont, game::CenterLineVertically(kFont, LootRowRect(window, row, 0, kLootLabelWidth)),
CategoryLabel(category), CategoryColour(category), 0); CategoryLabel(category), CategoryColour(category), 0);
for (int mode = 0; mode < kModeCount; ++mode) { 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 selected = g_lootPending[category] == static_cast<Mode>(mode);
const bool hover = g_lootHover == category * kModeCount + mode; const bool hover = g_lootHover == category * kModeCount + mode;
FillBox(device, LootBoxRect(window, row, mode), FillBox(device, LootBoxRect(window, row, mode),
@@ -592,6 +568,281 @@ void DrawLootPage(const Window& w, void* device) {
} }
} }
// --- saves (Allgemein page) ------------------------------------------------------
// The autosave interval (request of 2026-10-20), in the left column below
// the general frame (moved up, it is taller than the frames beside it): a
// frame of its own, as the right column's lowest frame (Internet-Optionen):
// its height, and the gap the right column has between its frames, both
// measured when the window is made. (A frame lower than that breaks its
// side ornaments; screenshots 2026-10-20.)
constexpr int kSavesFallbackGap = 0x50;
constexpr int16_t kSavesFallbackHeight = 0x40;
constexpr int kSavesLabelWidth = 0x90;
constexpr int kSavesBoxGap = 8;
constexpr int kAutosaveChoices[] = {0, 5, 10, 15, 30}; // minutes, 0: off
constexpr int kAutosaveChoiceCount = static_cast<int>(std::size(kAutosaveChoices));
constexpr int kSavesBoxWidth =
(kFrameWidth - 2 * kTextInset - kSavesLabelWidth - (kAutosaveChoiceCount - 1) * kSavesBoxGap) /
kAutosaveChoiceCount;
// Lighter than kBoxColour: on the frame's dark backdrop that one is not seen.
constexpr uint32_t kSavesBoxColour = 0xc0303030;
constexpr uint32_t kSavesBoxHoverColour = 0xc0484848;
game::UiRect g_savesFrame = {kFrameX[0], 0, kFrameWidth, kSavesFallbackHeight};
int g_autosavePending = 0; // minutes until OK
int g_savesHover = -1; // choice under the mouse
void CreateSavesControls(Window& w) {
int bottom = 0;
std::vector<game::UiRect> right;
for (const Control& c : w.controls) {
if (c.group != Group::Common || !IsFrame(c.control)) {
continue;
}
const game::UiRect rect = *reinterpret_cast<const game::UiRect*>(c.control + game::kControlRect);
if (ControlX(c.control) < kColumnSplit) {
bottom = std::max(bottom, rect.y + rect.h);
} else if (rect.w == kFrameWidth) {
right.push_back(rect); // the main frames, not their label plates (200 wide)
}
}
// The lowest frame and the nearest one ending above it. (Measured: the
// right column's frames are 384 wide at y 88, 312, 536, 128 / 128 / 64
// high: gaps of 96. Their label plates are frames too, 200 wide.)
std::sort(right.begin(), right.end(), [](const game::UiRect& a, const game::UiRect& b) { return a.y < b.y; });
int gap = kSavesFallbackGap;
if (!right.empty()) {
const game::UiRect& lowest = right.back();
int aboveBottom = -1;
for (const game::UiRect& r : right) {
if (r.y + r.h <= lowest.y) {
aboveBottom = std::max(aboveBottom, r.y + r.h);
}
}
if (aboveBottom >= 0) {
gap = lowest.y - aboveBottom;
}
g_savesFrame.h = lowest.h;
}
g_savesFrame.y = bottom + gap;
const size_t existing = ControlCount(w.window);
AddFrame(w.window, g_savesFrame, "UI_FRAME_SAVES");
AssignFrom(w, existing, Group::Common);
}
game::UiRect SavesRowRect(void* window, int dx, int width) {
const game::UiRect origin = game::ControlScreenRect(window);
return {origin.x + g_savesFrame.x + kTextInset + dx, origin.y + g_savesFrame.y + (g_savesFrame.h - kBoxHeight) / 2,
static_cast<int16_t>(width), static_cast<int16_t>(kBoxHeight)};
}
game::UiRect SavesBoxRect(void* window, int choice) {
return SavesRowRect(window, kSavesLabelWidth + choice * (kSavesBoxWidth + kSavesBoxGap), kSavesBoxWidth);
}
int SavesBoxAt(void* window, int x, int y) {
for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) {
if (game::Inside(SavesBoxRect(window, choice), x, y)) {
return choice;
}
}
return -1;
}
void DrawSaves(const Window& w, void* device) {
void* window = w.window;
game::DrawText(device, kFont, game::CenterLineVertically(kFont, SavesRowRect(window, 0, kSavesLabelWidth)),
L"Autosave (Min.)", kTextColour, 0);
for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) {
const game::UiRect box = SavesBoxRect(window, choice);
const int minutes = kAutosaveChoices[choice];
FillBox(device, box,
g_autosavePending == minutes ? kBoxSelectedColour
: g_savesHover == choice ? kSavesBoxHoverColour
: kSavesBoxColour);
const std::wstring text = minutes ? std::to_wstring(minutes) : L"Aus";
game::DrawText(device, kFont, game::CenterLineVertically(kFont, box), text.c_str(), kTextColour,
game::kTextCenter);
}
}
bool HandleSavesEvent(Window& w, void* event) {
switch (game::MouseEventKind(event)) {
case game::MouseEvent::Move:
g_savesHover = SavesBoxAt(w.window, game::EventField(event, 0xc), game::EventField(event, 0x10));
return false;
case game::MouseEvent::ButtonDown: {
if (game::EventField(event, game::kMouseButton) != game::kLeftButton) {
return false;
}
const int box = SavesBoxAt(w.window, game::EventField(event, game::kMouseButtonX),
game::EventField(event, game::kMouseButtonY));
if (box < 0) {
return false;
}
g_autosavePending = kAutosaveChoices[box];
return true;
}
default:
return false;
}
}
// --- 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;
}
}
// --- hooks --------------------------------------------------------------------- // --- hooks ---------------------------------------------------------------------
struct RenderCall { struct RenderCall {
@@ -613,6 +864,8 @@ int RenderBody(void* context) {
if (visible && !w->wasVisible) { if (visible && !w->wasVisible) {
LoadPending(); LoadPending();
LoadLootPending(); 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);
@@ -625,6 +878,11 @@ int RenderBody(void* context) {
if (!visible || page != kPageLoot) { if (!visible || page != kPageLoot) {
g_lootHover = -1; g_lootHover = -1;
} }
if (!visible || page != kPageCommon) {
g_savesHover = -1;
g_screenOpen = false;
g_screenHover = -1;
}
if (!visible) { if (!visible) {
return result; return result;
} }
@@ -633,6 +891,9 @@ int RenderBody(void* context) {
DrawKeysPage(*w, call->device); DrawKeysPage(*w, call->device);
} else if (page == kPageLoot && g_pendingLoaded) { } else if (page == kPageLoot && g_pendingLoaded) {
DrawLootPage(*w, call->device); DrawLootPage(*w, call->device);
} else if (page == kPageCommon && g_pendingLoaded) {
DrawSaves(*w, call->device);
DrawScreen(*w, call->device);
} }
return result; return result;
} }
@@ -674,6 +935,10 @@ int EventBody(void* context) {
g_pendingLoaded && HandleLootEvent(*w, call->event)) { g_pendingLoaded && HandleLootEvent(*w, call->event)) {
return 1; 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)) {
@@ -690,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;
} }
@@ -722,13 +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); 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()));
} }
@@ -769,6 +1040,8 @@ void __fastcall UiOptions_Apply(void* self, void* edx) {
if (g_pendingLoaded) { if (g_pendingLoaded) {
mods::input::SetBindingKeys(g_pending); mods::input::SetBindingKeys(g_pending);
mods::lootfilter::SetModes(g_lootPending); mods::lootfilter::SetModes(g_lootPending);
mods::characters::SetAutosaveMinutes(g_autosavePending);
mods::monitor::SetPreferredId(g_screenPending);
} }
} }
+499
View File
@@ -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;
}
+435
View File
@@ -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
+10
View File
@@ -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
+137
View File
@@ -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
+37
View File
@@ -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
+155
View File
@@ -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
+619
View File
@@ -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
+66
View File
@@ -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
+302
View File
@@ -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
+117
View File
@@ -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 -8
View File
@@ -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
+1 -1
View File
@@ -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
+15 -4
View File
@@ -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;
} }
} }
+282
View File
@@ -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
+16
View File
@@ -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
+25 -23
View File
@@ -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);
+8 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8 -1
View File
@@ -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
}
+127 -1
View File
@@ -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)
@@ -329,3 +330,128 @@
0x006116c0 cEngine::dropItem # the player's drop into the world 0x006116c0 cEngine::dropItem # the player's drop into the world
0x005fc910 cObjectManager::RemoveFromMap # (id), ret 4 0x005fc910 cObjectManager::RemoveFromMap # (id), ret 4
0x00819580 Options_PickupAuto # PICKUPAUTO from the config (0 all, 1 valuable, 2 gold) 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)
+341
View File
@@ -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));
}
}