Compare commits

..
10 Commits
Author SHA1 Message Date
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
43 changed files with 2321 additions and 98 deletions

No files matched your search

+7
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
@@ -74,6 +75,12 @@ add_library(sacredK SHARED
src/mods/screen.cpp src/mods/screen.cpp
src/mods/characters.cpp src/mods/characters.cpp
src/mods/mainmenu.cpp src/mods/mainmenu.cpp
src/mods/mapreveal.cpp
src/mods/questbook.cpp
src/mods/questfail.cpp
src/mods/questnpc.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})
+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.
+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
+44
View File
@@ -0,0 +1,44 @@
# 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]).
+29
View File
@@ -132,3 +132,32 @@ the borders: screenshots 2026-10-20). One row *Autosave (Min.)*: Aus / 5 /
lighter one). A click selects, OK writes lighter one). A click selects, OK writes
`[characters] AutosaveMinutes` (`characters::SetAutosaveMinutes`, the timer `[characters] AutosaveMinutes` (`characters::SetAutosaveMinutes`, the timer
uses it at once), Cancel drops it. 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.
+153
View File
@@ -0,0 +1,153 @@
# 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 1/2/4/8 are cleared and its book entries
removed, as if never triggered; 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.
## 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.
+18
View File
@@ -88,3 +88,21 @@ window's own pictures removed.
offset 0 while the menu showed. Now only while the menu frame is drawn 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 centred (it shifts the windows itself); otherwise dialogs are centred and
their full-layout dimming stretched, as in a game. 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).
+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.
+19
View File
@@ -223,3 +223,22 @@ 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: 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 list scrolling beyond 9 characters, larger heading font, the options
window's layout in the width beyond its 1024 frame. 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").
## 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).
+4 -3
View File
@@ -67,9 +67,10 @@ 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 the loading screen 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. The main menu thread renders (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 the full width since 2026-10-20: its 1024x768 frame is drawn centred on the
menu screens' background (docs/screens.md, "Main menu frame"). menu screens' background (docs/screens.md, "Main menu frame").
+2 -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
+47
View File
@@ -0,0 +1,47 @@
#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); }
// 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
+153
View File
@@ -0,0 +1,153 @@
#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>
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;
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);
}
// 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;
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
+7 -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) {
+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).
+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
+5 -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);
@@ -175,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) {
@@ -188,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;
+1 -1
View File
@@ -66,7 +66,7 @@ int OffsetFor(Anchor anchor) {
// The main menu's frame shifts the windows in it; over a 16:9 screen // 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). // (no frame) dialogs are centred like in a game (user, 2026-10-21).
const bool inMenuFrame = mods::widescreen::MenuIsShowing() && g_menuFrameDx != 0; const bool inMenuFrame = mods::widescreen::MenuIsShowing() && g_menuFrameDx != 0;
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() || inMenuFrame || if (!enabled || width <= kLayoutWidth || mods::widescreen::IsLoadingScreen() || inMenuFrame ||
anchor == Anchor::Screen) { anchor == Anchor::Screen) {
return 0; return 0;
} }
+4 -12
View File
@@ -261,7 +261,6 @@ void OnTick(uint8_t* engine) {
// 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;
@@ -270,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);
} }
} }
+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.
+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);
}
}
+89
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,10 @@
#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/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 +60,86 @@ 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;
constexpr size_t kScrollX = 0xc016c;
constexpr size_t kScrollY = 0xc0170;
template <typename T>
T& Field(uint8_t* p, size_t offset) {
return *reinterpret_cast<T*>(p + offset);
}
// cUI_Megamap 0x6cb130 (thiscall (out x, y, 0, world x, y, level), ret 8): the
// map pixel of a world position, as opening the map centres on the hero
// (0x6c7bad..).
struct Vec3 {
float x, y, z;
};
Vec3 MapPixel(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;
}
// 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 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 Vec3 pixel = MapPixel(megamap, world);
Field<int32_t>(megamap, kZoomPointX) = static_cast<int32_t>(pixel.x) - Field<int32_t>(megamap, kScrollX);
Field<int32_t>(megamap, kZoomPointY) = static_cast<int32_t>(pixel.y) - Field<int32_t>(megamap, kScrollY);
}
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 +263,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 +278,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
+171 -2
View File
@@ -41,6 +41,7 @@
#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"
@@ -522,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 (game::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;
} }
} }
@@ -547,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),
@@ -684,6 +689,160 @@ bool HandleSavesEvent(Window& w, void* event) {
} }
} }
// --- 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 {
@@ -706,6 +865,7 @@ int RenderBody(void* context) {
LoadPending(); LoadPending();
LoadLootPending(); LoadLootPending();
g_autosavePending = mods::characters::AutosaveMinutes(); 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);
@@ -720,6 +880,8 @@ int RenderBody(void* context) {
} }
if (!visible || page != kPageCommon) { if (!visible || page != kPageCommon) {
g_savesHover = -1; g_savesHover = -1;
g_screenOpen = false;
g_screenHover = -1;
} }
if (!visible) { if (!visible) {
return result; return result;
@@ -731,6 +893,7 @@ int RenderBody(void* context) {
DrawLootPage(*w, call->device); DrawLootPage(*w, call->device);
} else if (page == kPageCommon && g_pendingLoaded) { } else if (page == kPageCommon && g_pendingLoaded) {
DrawSaves(*w, call->device); DrawSaves(*w, call->device);
DrawScreen(*w, call->device);
} }
return result; return result;
} }
@@ -773,7 +936,7 @@ int EventBody(void* context) {
return 1; return 1;
} }
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageCommon && if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageCommon &&
g_pendingLoaded && HandleSavesEvent(*w, call->event)) { g_pendingLoaded && (HandleScreenEvent(*w, call->event) || HandleSavesEvent(*w, call->event))) {
return 1; 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 &&
@@ -824,11 +987,16 @@ 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); CreateSavesControls(w);
@@ -873,6 +1041,7 @@ void __fastcall UiOptions_Apply(void* self, void* edx) {
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::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;
}
+90
View File
@@ -0,0 +1,90 @@
// Failed quests can be restarted (request of 2026-10-17): a failed quest is not
// final, the quest giver offers it again.
//
// 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 mod: after Quest_End failed a quest (its failure script has run), the
// quest's flags are cleared and its book entries removed, as if it had never
// been triggered. Quests that failed before the mod are reset the same way once
// after loading.
#include <algorithm>
#include <cstdint>
#include "game/engine/events.h"
#include "game/engine/quests.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::questfail {
namespace {
constexpr uint32_t kResetFlags = game::kQuestTriggered | game::kQuestDone | game::kQuestInProgress | game::kQuestBusy;
bool g_resetAfterLoad = false;
// The quest at table `index` as if it had never been triggered.
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) &= ~kResetFlags;
for (size_t i = game::BookCount(); i-- > 0;) {
if (game::BookQuestId(game::BookEntry(i)) == id) {
game::EraseBookEntry(i);
}
}
}
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));
if (index >= 0) {
Restart(index);
}
i = std::min(i, game::BookCount());
}
}
void OnTick(uint8_t*) {
if (g_resetAfterLoad) {
g_resetAfterLoad = false;
RestartFailedInBook();
}
}
void OnLoaded() {
g_resetAfterLoad = true;
}
} // namespace
// 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) {
Quest_End_original(self, edx, index, a2, success);
if (!success && index >= 0 && static_cast<size_t>(index) < game::QuestCount()) {
Restart(index);
}
}
namespace {
struct Register {
Register() {
game::OnEngineTick(&OnTick);
game::OnSaveGameLoaded(&OnLoaded);
}
} g_register;
} // namespace
} // namespace mods::questfail
+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
+1 -1
View File
@@ -259,7 +259,7 @@ uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument);
RE_HOOK(0x007587b0, UiManager_Render); RE_HOOK(0x007587b0, UiManager_Render);
uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument) { uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument) {
using namespace mods; using namespace mods;
if (!widescreen::IsMenuThread() || widescreen::IsClassicFrame() || widescreen::RenderWidth() == 0) { if (!widescreen::IsMenuThread() || widescreen::IsLoadingScreen() || widescreen::RenderWidth() == 0) {
return UiManager_Render_original(self, edx, argument); return UiManager_Render_original(self, edx, argument);
} }
auto* driver = *reinterpret_cast<DxDriver7**>(addr::g_dxDriver); auto* driver = *reinterpret_cast<DxDriver7**>(addr::g_dxDriver);
+16 -16
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,9 +65,9 @@ bool g_pushesPatched = false;
int g_renderWidth = 0; int g_renderWidth = 0;
int g_renderHeight = 0; int g_renderHeight = 0;
// The loading screen is laid out for 1024x768 ("classic"); the main menu // Every frame uses the full render width: the main menu thread (its windows
// thread renders the full width too (its windows drawn centred by // drawn centred by mods/screen.cpp), the loading screen (centred on the menu
// mods/screen.cpp), the in-game view is widened. // 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 the main menu's or the // Whether the most recent scene (on any thread) was the main menu's or the
// loading screen's. UI input is processed outside the menu thread. // loading screen's. UI input is processed outside the menu thread.
@@ -269,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;
@@ -343,7 +344,7 @@ bool MenuIsShowing() {
return g_lastSceneMenu; return g_lastSceneMenu;
} }
bool IsClassicFrame() { bool IsLoadingScreen() {
return g_renderWidth != 0 && g_loadingScreenDepth > 0; return g_renderWidth != 0 && g_loadingScreenDepth > 0;
} }
@@ -369,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;
} }
// The loading screen draws for a 1024x768 screen: give it a 1024x768 // Every frame gets the full render size as its viewport.
// viewport (the borderless mod then presents just that area, scaled), and
// everything else 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_lastSceneMenu = IsClassicFrame() || IsMenuThread(); 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);
+3 -4
View File
@@ -9,11 +9,10 @@ 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 (the // True while the current thread draws the load / save progress screen.
// 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();
+3 -2
View File
@@ -28,7 +28,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 +37,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
}
+42
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
@@ -371,3 +372,44 @@
0x0075df10 MenuScene_Instance # 0x1a84 bytes, kept at 0x17ecb40 0x0075df10 MenuScene_Instance # 0x1a84 bytes, kept at 0x17ecb40
0x0075e620 MenuScene::Render # (1 argument), ret 4: sky + water (UI_UW_MAIN_CENTER, UI_MAIN_REF) 0x0075e620 MenuScene::Render # (1 argument), ret 4: sky + water (UI_UW_MAIN_CENTER, UI_MAIN_REF)
0x0075e820 MenuScene::Update # (seconds) 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