Compare commits
10
Commits
3978060954
...
10c97f75e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10c97f75e1 | ||
|
|
401e20b1f2 | ||
|
|
c0eedbce7f | ||
|
|
9bc557cd95 | ||
|
|
c7d54a6cfd | ||
|
|
1d16fc65c4 | ||
|
|
34cacefcfc | ||
|
|
b452558122 | ||
|
|
3f03228c29 | ||
|
|
91bbc52cf0 |
No files matched your search
@@ -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})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]).
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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").
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ inline uint32_t* UiManager() {
|
|||||||
return *reinterpret_cast<uint32_t**>(kUiManager);
|
return *reinterpret_cast<uint32_t**>(kUiManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The UI manager's window pointers by slot (dwords).
|
||||||
|
constexpr int kUiSlotMegamap = 0x25; // cUI_Megamap, UI_WND_MEGAMAP
|
||||||
|
|
||||||
|
inline uint8_t* UiWindow(const uint32_t* manager, int slot) {
|
||||||
|
return manager ? reinterpret_cast<uint8_t*>(static_cast<uintptr_t>(manager[slot])) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window in `slot` exists and is visible (control flags +0x10 bit 0).
|
||||||
|
inline bool UiWindowShown(const uint32_t* manager, int slot) {
|
||||||
|
constexpr size_t kControlFlags = 0x10;
|
||||||
|
const uint8_t* window = UiWindow(manager, slot);
|
||||||
|
return window && (*reinterpret_cast<const uint32_t*>(window + kControlFlags) & 1) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
// cEventUI2 window commands (code at +4, subtype 5 at +8, names at +0x28): the
|
// cEventUI2 window commands (code at +4, subtype 5 at +8, names at +0x28): the
|
||||||
// UI manager's event handler (vf00, 0x757120) routes them by window name.
|
// UI manager's event handler (vf00, 0x757120) routes them by window name.
|
||||||
// Measured: HandleEsc hides with 0x12 (docs/escape.md); the S hotkey
|
// Measured: HandleEsc hides with 0x12 (docs/escape.md); the S hotkey
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// The game's anti-cheat is gone (requests of 2026-10-25: the Gladiator
|
||||||
|
// "randomly exploded" fighting a dragon on Silber; then "i want all that weird
|
||||||
|
// cheater detection code gone"). docs/anticheat.md.
|
||||||
|
//
|
||||||
|
// Measured: the game keeps two xor-ed mirrors of the local hero's attribute /
|
||||||
|
// skill / gold fields (0x182cddc on a protected page, 0x182cde0 a heap block),
|
||||||
|
// written after every legitimate change. Checks compare the hero with them,
|
||||||
|
// reset every mismatching field (attributes to 1, skill levels to 0) and on
|
||||||
|
// any mismatch kill the hero ("*** Cheater detected! *** Hase!") and set the
|
||||||
|
// cheater flag 0x182cdd0, which the hero file keeps. Any change the game does
|
||||||
|
// not route through its mirror writers (e.g. loading the shared hero into
|
||||||
|
// another world) made the checks fire.
|
||||||
|
//
|
||||||
|
// The mod: every check is skipped through the game's own path for "not the
|
||||||
|
// local hero" (or straight to the code after the check, with the registers that
|
||||||
|
// code reads), so no field is reset and no hero killed; the cheater flag is
|
||||||
|
// never set. The mirror writers stay (they only fill the unused mirrors).
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "re/hook.h"
|
||||||
|
#include "re/log.h"
|
||||||
|
#include "re/patch.h"
|
||||||
|
|
||||||
|
namespace mods::anticheat {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr re::CodeReplacement kNoAntiCheat[] = {
|
||||||
|
// cCreature::Think: the trap for a level more than 3 above the experience's
|
||||||
|
// (raises fields behind the mirrors' back): `jle` past it -> `jmp`
|
||||||
|
{0x005385ca, {0x0f, 0x8e, 0xd8, 0x03, 0x00, 0x00}, {0xe9, 0xd9, 0x03, 0x00, 0x00, 0x90}, 6},
|
||||||
|
// cCreature::Think: the check in 1 of 101 ticks: `jne` past it -> `jmp`
|
||||||
|
{0x005389b8, {0x0f, 0x85, 0x0d, 0x06, 0x00, 0x00}, {0xe9, 0x0e, 0x06, 0x00, 0x00, 0x90}, 6},
|
||||||
|
// inline checks behind "is this the local hero": `jne` past them -> `jmp`
|
||||||
|
{0x00564c50, {0x0f, 0x85, 0x89, 0x05, 0x00, 0x00}, {0xe9, 0x8a, 0x05, 0x00, 0x00, 0x90}, 6},
|
||||||
|
{0x0057e07d, {0x0f, 0x85, 0xd8, 0x05, 0x00, 0x00}, {0xe9, 0xd9, 0x05, 0x00, 0x00, 0x90}, 6},
|
||||||
|
{0x00585bb0, {0x0f, 0x85, 0x55, 0x06, 0x00, 0x00}, {0xe9, 0x56, 0x06, 0x00, 0x00, 0x90}, 6},
|
||||||
|
// the quest manager's hero data writer: past the check to 0x46606b (it
|
||||||
|
// writes registers before reading them; the stream keeps its clean marker)
|
||||||
|
{0x004659dc, {0x8b, 0x0d, 0xc0, 0x3b, 0xad, 0x00}, {0xe9, 0x8a, 0x06, 0x00, 0x00, 0x90}, 6},
|
||||||
|
// 0x619d33: past the check to 0x61a2de with ebp as the check leaves it
|
||||||
|
// (`mov ebp, [esp+0x34]`; edi already holds 0x88e874)
|
||||||
|
{0x00619d33, {0x8b, 0x0d, 0xdc, 0xcd, 0x82, 0x01}, {0x8b, 0x6c, 0x24, 0x34, 0x90, 0x90}, 6},
|
||||||
|
{0x00619d39, {0x8d, 0x44, 0x24, 0x14, 0x50}, {0xe9, 0xa0, 0x05, 0x00, 0x00}, 5},
|
||||||
|
// VerifyHeroMirror (0x5905f0, plain ret, 4 callers test al): `mov al, 1; ret`
|
||||||
|
{0x005905f0, {0x51, 0x8b, 0x0d}, {0xb0, 0x01, 0xc3}, 3},
|
||||||
|
// stream checks of the quest manager's hero data: the cheat marker
|
||||||
|
// 0xfea4fea4 read (`jne` past the punishment -> `jmp`) and three stored
|
||||||
|
// values compared with the hero (`je` -> `jmp`)
|
||||||
|
{0x00467782, {0x0f, 0x85, 0xbd, 0x01, 0x00, 0x00}, {0xe9, 0xbe, 0x01, 0x00, 0x00, 0x90}, 6},
|
||||||
|
{0x0046a60b, {0x74, 0x18}, {0xeb, 0x18}, 2},
|
||||||
|
{0x0046a64e, {0x74, 0x18}, {0xeb, 0x18}, 2},
|
||||||
|
{0x0046a696, {0x74, 0x18}, {0xeb, 0x18}, 2},
|
||||||
|
// loadHero: the hero file's cheater flag (`mov byte [0x182cdd0], 1` -> 0)
|
||||||
|
{0x006066eb, {0xc6, 0x05, 0xd0, 0xcd, 0x82, 0x01, 0x01}, {0xc6, 0x05, 0xd0, 0xcd, 0x82, 0x01, 0x00}, 7},
|
||||||
|
// character selection: the flag from the hero record's mark (+0x3ef):
|
||||||
|
// `setne cl` -> `xor cl, cl`
|
||||||
|
{0x0070da8c, {0x0f, 0x95, 0xc1}, {0x32, 0xc9, 0x90}, 3},
|
||||||
|
};
|
||||||
|
|
||||||
|
void Startup() {
|
||||||
|
const int failed = re::ReplaceAll(kNoAntiCheat);
|
||||||
|
re::Log("anticheat: removed (%d patches failed)", failed);
|
||||||
|
}
|
||||||
|
RE_STARTUP(AntiCheatStartup, Startup);
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
} // namespace mods::anticheat
|
||||||
+5
-16
@@ -3,7 +3,7 @@
|
|||||||
// The game renders at a fixed resolution (1024x768). Unmodified, windowed mode
|
// The game renders at a fixed resolution (1024x768). Unmodified, windowed mode
|
||||||
// shows that as a 1024x768 popup centred on the desktop; fullscreen mode
|
// shows that as a 1024x768 popup centred on the desktop; fullscreen mode
|
||||||
// switches the display mode. With this mod the game always runs windowed, the
|
// switches the display mode. With this mod the game always runs windowed, the
|
||||||
// popup covers the whole monitor, and each frame is scaled up into the largest
|
// popup covers the whole monitor (mods/monitor.h: the preferred one), and each frame is scaled up into the largest
|
||||||
// centred rectangle with the game's aspect ratio (black bars elsewhere).
|
// centred rectangle with the game's aspect ratio (black bars elsewhere).
|
||||||
//
|
//
|
||||||
// Mouse positions are mapped back from that rectangle to game pixels, both in
|
// Mouse positions are mapped back from that rectangle to game pixels, both in
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
#include "game/render/dx_driver7.h"
|
#include "game/render/dx_driver7.h"
|
||||||
#include "mods/hud.h"
|
#include "mods/hud.h"
|
||||||
#include "mods/input.h"
|
#include "mods/input.h"
|
||||||
|
#include "mods/monitor.h"
|
||||||
#include "mods/widescreen.h"
|
#include "mods/widescreen.h"
|
||||||
#include "re/config.h"
|
#include "re/config.h"
|
||||||
#include "game/addresses.h"
|
#include "game/addresses.h"
|
||||||
@@ -56,14 +57,12 @@ struct Viewport {
|
|||||||
HWND window = nullptr;
|
HWND window = nullptr;
|
||||||
bool runInBackground = false;
|
bool runInBackground = false;
|
||||||
Layout full; // the whole back buffer
|
Layout full; // the whole back buffer
|
||||||
Layout classic; // its top-left 1024x768 (menus, loading screen; widescreen mod)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Viewport g_viewport;
|
Viewport g_viewport;
|
||||||
std::atomic<bool> g_classicShown = false; // which layout the last presented frame used
|
|
||||||
|
|
||||||
const Layout& CurrentLayout() {
|
const Layout& CurrentLayout() {
|
||||||
return g_classicShown ? g_viewport.classic : g_viewport.full;
|
return g_viewport.full;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Enabled() {
|
bool Enabled() {
|
||||||
@@ -92,9 +91,7 @@ RECT FitRect(int areaWidth, int areaHeight, int width, int height, bool integerS
|
|||||||
|
|
||||||
void ApplyBorderless(game::DxDriver7* driver) {
|
void ApplyBorderless(game::DxDriver7* driver) {
|
||||||
HWND window = driver->window;
|
HWND window = driver->window;
|
||||||
MONITORINFO monitor = {sizeof(monitor)};
|
const RECT area = mods::monitor::GameMonitorRect();
|
||||||
GetMonitorInfoW(MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST), &monitor);
|
|
||||||
const RECT& area = monitor.rcMonitor;
|
|
||||||
const int areaWidth = area.right - area.left;
|
const int areaWidth = area.right - area.left;
|
||||||
const int areaHeight = area.bottom - area.top;
|
const int areaHeight = area.bottom - area.top;
|
||||||
|
|
||||||
@@ -116,10 +113,8 @@ void ApplyBorderless(game::DxDriver7* driver) {
|
|||||||
viewport.window = window;
|
viewport.window = window;
|
||||||
viewport.runInBackground = re::ConfigInt("borderless", "RunInBackground", 1) != 0;
|
viewport.runInBackground = re::ConfigInt("borderless", "RunInBackground", 1) != 0;
|
||||||
viewport.full = makeLayout(driver->mode.width, driver->mode.height);
|
viewport.full = makeLayout(driver->mode.width, driver->mode.height);
|
||||||
viewport.classic = makeLayout(std::min<int>(driver->mode.width, 1024), std::min<int>(driver->mode.height, 768));
|
|
||||||
viewport.active = true;
|
viewport.active = true;
|
||||||
g_viewport = viewport;
|
g_viewport = viewport;
|
||||||
g_classicShown = false;
|
|
||||||
|
|
||||||
// Paint the bars: the window class background brush is black.
|
// Paint the bars: the window class background brush is black.
|
||||||
InvalidateRect(window, nullptr, TRUE);
|
InvalidateRect(window, nullptr, TRUE);
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
#include "mods/sharedchest.h"
|
#include "mods/sharedchest.h"
|
||||||
#include "mods/skillslots.h"
|
#include "mods/skillslots.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
|
#include "re/patch.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
namespace mods::inventory_save {
|
namespace mods::inventory_save {
|
||||||
@@ -327,6 +328,20 @@ void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn rea
|
|||||||
HeroDataList().push_back({key, write, read});
|
HeroDataList().push_back({key, write, read});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saveHero writes the hero's items (chunk 0xc2) from its inventories, equipment
|
||||||
|
// and weapon sets; the weapon-set loop (GetWeaponSetItem over sets 0..4, `cmp
|
||||||
|
// esi, 5`) runs over all 7 so the items of sets 6-7 are in hero files too. Without
|
||||||
|
// them a world loading the hero from its hero file had sets 6-7 pointing at
|
||||||
|
// items that did not exist (2026-10-25).
|
||||||
|
constexpr uint32_t kSaveHeroWeaponSetLoop = 0x00605046;
|
||||||
|
|
||||||
|
void Startup() {
|
||||||
|
if (!re::PatchInstructionByte(kSaveHeroWeaponSetLoop, 2, kGameSlots, kSlots)) {
|
||||||
|
re::Log("inventory: saveHero weapon-set loop not patched");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RE_STARTUP(InventorySaveStartup, Startup);
|
||||||
|
|
||||||
} // namespace mods::inventory_save
|
} // namespace mods::inventory_save
|
||||||
|
|
||||||
namespace game {
|
namespace game {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// The load / save progress screen on the menu screens' background (request of
|
||||||
|
// 2026-10-25).
|
||||||
|
//
|
||||||
|
// The game's progress screen (DxDriver7::LoadingScreen 0x6469d0, thiscall
|
||||||
|
// (progress, text)) draws with GDI into the back buffer's DC (surface +0xbc,
|
||||||
|
// GetDC vf 0x44 / ReleaseDC vf 0x68): the 1024x768 picture (HBITMAP +0x200)
|
||||||
|
// at 0, 0, the progress bar (+0x204 / +0x208) and the text at y 700, then
|
||||||
|
// Flip. It only redraws when the bar moves.
|
||||||
|
//
|
||||||
|
// The mod: first the menu background across the whole render width in a
|
||||||
|
// Direct3D scene (screen::DrawBackground), then the game's drawing with the
|
||||||
|
// DC's origin moved to the centred 1024 frame. The game asks for a memory DC
|
||||||
|
// compatible with the back buffer's DC right after getting it
|
||||||
|
// (CreateCompatibleDC(hdc)), which is where the origin is set.
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "game/addresses.h"
|
||||||
|
#include "game/render/dx_driver7.h"
|
||||||
|
#include "mods/loadingscreen.h"
|
||||||
|
#include "mods/screen.h"
|
||||||
|
#include "mods/widescreen.h"
|
||||||
|
#include "re/hook.h"
|
||||||
|
|
||||||
|
namespace mods::loadingscreen {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kLayoutWidth = 1024;
|
||||||
|
|
||||||
|
using BeginSceneFn = char(__fastcall*)(game::DxDriver7* self, void* edx);
|
||||||
|
using EndSceneFn = void(__fastcall*)(game::DxDriver7* self, void* edx);
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// While the progress screen draws on this thread: its frame's x.
|
||||||
|
thread_local int g_offsetX = 0;
|
||||||
|
|
||||||
|
void Begin(game::DxDriver7* driver) {
|
||||||
|
g_offsetX = 0;
|
||||||
|
const int width = widescreen::RenderWidth();
|
||||||
|
if (width <= kLayoutWidth || !driver || !driver->device || !driver->backBuffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reinterpret_cast<BeginSceneFn>(static_cast<uintptr_t>(game::addr::DxDriver7_BeginScene))(driver, nullptr)) {
|
||||||
|
screen::DrawBackground(driver->device);
|
||||||
|
reinterpret_cast<EndSceneFn>(static_cast<uintptr_t>(game::addr::DxDriver7_EndScene))(driver, nullptr);
|
||||||
|
}
|
||||||
|
g_offsetX = (width - kLayoutWidth) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
void End() {
|
||||||
|
g_offsetX = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mods::loadingscreen
|
||||||
|
|
||||||
|
namespace mods::loadingscreen {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kLayoutHeight = 768;
|
||||||
|
|
||||||
|
// The game's pictures are artwork on black (PAK\loading0.bmp, loadgame.bmp,
|
||||||
|
// ...). Blended instead of copied: each pixel's alpha is its brightest
|
||||||
|
// channel, colours stay as they are (premultiplied), so black shows the
|
||||||
|
// background and the glow fades into it.
|
||||||
|
bool BlendPicture(HDC target, HDC source) {
|
||||||
|
const int offsetX = g_offsetX;
|
||||||
|
g_offsetX = 0; // our own GDI calls below pass the hooks unchanged
|
||||||
|
BITMAPINFO info = {};
|
||||||
|
info.bmiHeader.biSize = sizeof(info.bmiHeader);
|
||||||
|
info.bmiHeader.biWidth = kLayoutWidth;
|
||||||
|
info.bmiHeader.biHeight = -kLayoutHeight;
|
||||||
|
info.bmiHeader.biPlanes = 1;
|
||||||
|
info.bmiHeader.biBitCount = 32;
|
||||||
|
info.bmiHeader.biCompression = BI_RGB;
|
||||||
|
void* bits = nullptr;
|
||||||
|
HBITMAP picture = CreateDIBSection(source, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||||
|
if (!picture) {
|
||||||
|
g_offsetX = offsetX;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
HDC copy = CreateCompatibleDC(source);
|
||||||
|
HGDIOBJ previous = SelectObject(copy, picture);
|
||||||
|
BitBlt(copy, 0, 0, kLayoutWidth, kLayoutHeight, source, 0, 0, SRCCOPY);
|
||||||
|
GdiFlush();
|
||||||
|
auto* pixel = static_cast<uint8_t*>(bits);
|
||||||
|
for (int i = 0; i < kLayoutWidth * kLayoutHeight; ++i, pixel += 4) {
|
||||||
|
pixel[3] = std::max({pixel[0], pixel[1], pixel[2]});
|
||||||
|
}
|
||||||
|
const BLENDFUNCTION blend = {AC_SRC_OVER, 0, 255, AC_SRC_ALPHA};
|
||||||
|
const bool done =
|
||||||
|
GdiAlphaBlend(target, 0, 0, kLayoutWidth, kLayoutHeight, copy, 0, 0, kLayoutWidth, kLayoutHeight, blend) != 0;
|
||||||
|
SelectObject(copy, previous);
|
||||||
|
DeleteDC(copy);
|
||||||
|
DeleteObject(picture);
|
||||||
|
g_offsetX = offsetX;
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
} // namespace mods::loadingscreen
|
||||||
|
|
||||||
|
// The picture: BitBlt(hdc, 0, 0, 1024, 768, picture DC, 0, 0, SRCCOPY), or
|
||||||
|
// BLACKNESS without a picture (then nothing is drawn over the background).
|
||||||
|
BOOL WINAPI BitBltForLoadingScreen(HDC target, int x, int y, int width, int height, HDC source, int sx, int sy,
|
||||||
|
DWORD rop);
|
||||||
|
RE_HOOK_API(BitBlt, BitBltForLoadingScreen);
|
||||||
|
|
||||||
|
BOOL WINAPI BitBltForLoadingScreen(HDC target, int x, int y, int width, int height, HDC source, int sx, int sy,
|
||||||
|
DWORD rop) {
|
||||||
|
using namespace mods::loadingscreen;
|
||||||
|
if (g_offsetX != 0 && x == 0 && y == 0 && width == 1024 && height == 768) {
|
||||||
|
if (rop == BLACKNESS) {
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if (rop == SRCCOPY && source && BlendPicture(target, source)) {
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BitBltForLoadingScreen_original(target, x, y, width, height, source, sx, sy, rop);
|
||||||
|
}
|
||||||
|
|
||||||
|
HDC WINAPI CreateCompatibleDCForLoadingScreen(HDC hdc);
|
||||||
|
RE_HOOK_API(CreateCompatibleDC, CreateCompatibleDCForLoadingScreen);
|
||||||
|
|
||||||
|
HDC WINAPI CreateCompatibleDCForLoadingScreen(HDC hdc) {
|
||||||
|
if (hdc && mods::loadingscreen::g_offsetX != 0) {
|
||||||
|
SetViewportOrgEx(hdc, mods::loadingscreen::g_offsetX, 0, nullptr);
|
||||||
|
}
|
||||||
|
return CreateCompatibleDCForLoadingScreen_original(hdc);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
// The load / save progress screen on the menu background (loadingscreen.cpp).
|
||||||
|
|
||||||
|
#include "game/render/dx_driver7.h"
|
||||||
|
|
||||||
|
namespace mods::loadingscreen {
|
||||||
|
|
||||||
|
// Around DxDriver7::LoadingScreen (widescreen.cpp's hook): Begin draws the
|
||||||
|
// background and centres the game's GDI drawing, End stops centring.
|
||||||
|
void Begin(game::DxDriver7* driver);
|
||||||
|
void End();
|
||||||
|
|
||||||
|
} // namespace mods::loadingscreen
|
||||||
+25
-13
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// A larger vision range for the explored map (request of 2026-10-25: "increase
|
||||||
|
// the base vision range for the map's fog of war reveal by x10 or so").
|
||||||
|
//
|
||||||
|
// Measured (game/engine/explored.h): RevealAroundHero stamps a fixed pattern
|
||||||
|
// around the hero into the sectors' explored masks whenever the hero's tile
|
||||||
|
// changes, a 14-tile circle by day, smaller ones at night.
|
||||||
|
//
|
||||||
|
// The mod: after the original revealed at a new tile, the same pattern scaled
|
||||||
|
// up kScale times is revealed too (by day a 140-tile circle), one cell (2x2
|
||||||
|
// tiles) at a time through the game's own World_SetExplored. Only loaded
|
||||||
|
// sectors can be marked; they reach further than the scaled stamp.
|
||||||
|
//
|
||||||
|
// Creatures on the zoomed map views (large map, TAB map; 0x6cc950): one off
|
||||||
|
// explored ground, or a hostile one, is only drawn within a sight range of the
|
||||||
|
// hero (|dx| and |dy| in world units, about 53.7 per tile): 650 (about 12
|
||||||
|
// tiles), 300 where 0x63d280 holds for the hero's area, 350 when the weather
|
||||||
|
// object's 0x41ba40 is false; plus bonuses from a skill (0x355) and the hero's
|
||||||
|
// equipment values. The three base values are scaled by kScale as well.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "game/engine/explored.h"
|
||||||
|
#include "re/hook.h"
|
||||||
|
#include "re/log.h"
|
||||||
|
#include "re/patch.h"
|
||||||
|
|
||||||
|
namespace mods::mapreveal {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kScale = 10;
|
||||||
|
constexpr int kCellTiles = 2; // one mask bit per 2x2 tiles
|
||||||
|
|
||||||
|
// The stamp cell a tile offset falls into: cell c covers offsets
|
||||||
|
// c * kScale - kScale / 2 .. c * kScale + kScale / 2 - 1.
|
||||||
|
int StampOffset(int tiles) {
|
||||||
|
const int shifted = tiles + kScale / 2;
|
||||||
|
return (shifted >= 0 ? shifted : shifted - kScale + 1) / kScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mov dword ptr [esp+14h], <float>: the map's creature sight range.
|
||||||
|
struct SightRange {
|
||||||
|
uint32_t instruction;
|
||||||
|
float value;
|
||||||
|
};
|
||||||
|
constexpr SightRange kCreatureSightRanges[] = {
|
||||||
|
{0x006ce677, 650.0f},
|
||||||
|
{0x006ce6b5, 300.0f},
|
||||||
|
{0x006ce6cf, 350.0f},
|
||||||
|
};
|
||||||
|
|
||||||
|
uint32_t FloatBits(float value) {
|
||||||
|
uint32_t bits;
|
||||||
|
std::memcpy(&bits, &value, sizeof bits);
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Startup() {
|
||||||
|
int failed = 0;
|
||||||
|
for (const SightRange& range : kCreatureSightRanges) {
|
||||||
|
failed += !re::PatchInstructionDword(range.instruction, FloatBits(range.value), FloatBits(range.value * kScale));
|
||||||
|
}
|
||||||
|
if (failed) {
|
||||||
|
re::Log("mapreveal: %d creature sight range patches FAILED", failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RevealScaled(int tileX, int tileY, int sight) {
|
||||||
|
const game::RevealStamp stamp = game::RevealStampFor(sight);
|
||||||
|
const int centre = stamp.size / 2;
|
||||||
|
const int radius = centre * kScale + kScale / 2;
|
||||||
|
// Cells start at even tiles (sector origins are multiples of 64).
|
||||||
|
const int left = (tileX - radius) & ~(kCellTiles - 1);
|
||||||
|
const int top = (tileY - radius) & ~(kCellTiles - 1);
|
||||||
|
for (int y = top; y <= tileY + radius; y += kCellTiles) {
|
||||||
|
const int row = centre + StampOffset(y + kCellTiles / 2 - tileY);
|
||||||
|
if (row < 0 || row >= stamp.size) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int x = left; x <= tileX + radius; x += kCellTiles) {
|
||||||
|
const int column = centre + StampOffset(x + kCellTiles / 2 - tileX);
|
||||||
|
if (column >= 0 && column < stamp.size && stamp.cells[row * stamp.size + column]) {
|
||||||
|
game::SetExplored(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
RE_STARTUP(MapRevealStartup, Startup);
|
||||||
|
|
||||||
|
} // namespace mods::mapreveal
|
||||||
|
|
||||||
|
void __fastcall RevealAroundHero(void* creature, void* edx, int tileX, int tileY, int sight);
|
||||||
|
RE_HOOK(game::kRevealAroundHero, RevealAroundHero);
|
||||||
|
void __fastcall RevealAroundHero(void* creature, void* edx, int tileX, int tileY, int sight) {
|
||||||
|
const int lastX = game::LastRevealTileX();
|
||||||
|
const int lastY = game::LastRevealTileY();
|
||||||
|
RevealAroundHero_original(creature, edx, tileX, tileY, sight);
|
||||||
|
// The original revealed (local hero, new tile) iff it moved its last tile
|
||||||
|
// here; it ignores tile 0, 0.
|
||||||
|
const bool revealed = (tileX != 0 || tileY != 0) && (lastX != tileX || lastY != tileY) &&
|
||||||
|
game::LastRevealTileX() == tileX && game::LastRevealTileY() == tileY;
|
||||||
|
if (revealed && sight >= 0 && sight <= 3) {
|
||||||
|
mods::mapreveal::RevealScaled(tileX, tileY, sight);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
// to the right screen edge and its top and bottom tiles stretch across, and
|
// to the right screen edge and its top and bottom tiles stretch across, and
|
||||||
// the zoom buttons at the bottom stay centred. The window is drawn and clicked
|
// the zoom buttons at the bottom stay centred. The window is drawn and clicked
|
||||||
// unshifted (mods/hud.cpp, Anchor::Screen). See docs/hud.md.
|
// unshifted (mods/hud.cpp, Anchor::Screen). See docs/hud.md.
|
||||||
|
//
|
||||||
|
// Zoom (request of 2026-10-25: the zoomed view did not start centred on the
|
||||||
|
// hero): held mouse button zooms on the mouse (the game's magnifier); the zoom
|
||||||
|
// button now zooms on the hero and follows him (the game kept the last
|
||||||
|
// magnifier point, or 0,0).
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -16,6 +21,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
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Preferred monitor (request of 2026-10-25): a setting in the graphics options
|
||||||
|
// picks the monitor the game starts on; with none set, or the preferred one
|
||||||
|
// not connected, the system's main monitor.
|
||||||
|
//
|
||||||
|
// The game window and its render size are decided when the display mode is
|
||||||
|
// set at startup (borderless.cpp, widescreen.cpp ApplyRenderSize): both ask
|
||||||
|
// GameMonitorRect. A change applies at the next start.
|
||||||
|
//
|
||||||
|
// Monitors are told apart by their device path (DisplayConfigGetDeviceInfo,
|
||||||
|
// target name: "\\?\DISPLAY#GSM5B7F#..."), which survives restarts, monitor
|
||||||
|
// order changes and other ports' changes, unlike the GDI names \\.\DISPLAYn.
|
||||||
|
//
|
||||||
|
// sacredK.ini:
|
||||||
|
// [display]
|
||||||
|
// Monitor=<device path> ; empty: the main monitor
|
||||||
|
|
||||||
|
#include "mods/monitor.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "re/config.h"
|
||||||
|
#include "re/log.h"
|
||||||
|
|
||||||
|
namespace mods::monitor {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char* kSection = "display";
|
||||||
|
constexpr const char* kKey = "Monitor";
|
||||||
|
|
||||||
|
std::string Utf8(const wchar_t* text) {
|
||||||
|
const int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr);
|
||||||
|
if (size <= 1) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
std::string result(static_cast<size_t>(size - 1), '\0');
|
||||||
|
WideCharToMultiByte(CP_UTF8, 0, text, -1, result.data(), size, nullptr, nullptr);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Target {
|
||||||
|
std::wstring gdiName; // \\.\DISPLAYn of the source showing it
|
||||||
|
std::string id;
|
||||||
|
std::wstring name;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The active display paths: which monitor shows which GDI display.
|
||||||
|
std::vector<Target> Targets() {
|
||||||
|
UINT32 pathCount = 0;
|
||||||
|
UINT32 modeCount = 0;
|
||||||
|
std::vector<DISPLAYCONFIG_PATH_INFO> paths;
|
||||||
|
std::vector<DISPLAYCONFIG_MODE_INFO> modes;
|
||||||
|
LONG result;
|
||||||
|
do {
|
||||||
|
if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount) != ERROR_SUCCESS) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
paths.resize(pathCount);
|
||||||
|
modes.resize(modeCount);
|
||||||
|
result = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &pathCount, paths.data(), &modeCount, modes.data(),
|
||||||
|
nullptr);
|
||||||
|
} while (result == ERROR_INSUFFICIENT_BUFFER);
|
||||||
|
if (result != ERROR_SUCCESS) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
paths.resize(pathCount);
|
||||||
|
std::vector<Target> targets;
|
||||||
|
for (const DISPLAYCONFIG_PATH_INFO& path : paths) {
|
||||||
|
DISPLAYCONFIG_SOURCE_DEVICE_NAME source = {};
|
||||||
|
source.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
|
||||||
|
source.header.size = sizeof(source);
|
||||||
|
source.header.adapterId = path.sourceInfo.adapterId;
|
||||||
|
source.header.id = path.sourceInfo.id;
|
||||||
|
DISPLAYCONFIG_TARGET_DEVICE_NAME target = {};
|
||||||
|
target.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
|
||||||
|
target.header.size = sizeof(target);
|
||||||
|
target.header.adapterId = path.targetInfo.adapterId;
|
||||||
|
target.header.id = path.targetInfo.id;
|
||||||
|
if (DisplayConfigGetDeviceInfo(&source.header) != ERROR_SUCCESS ||
|
||||||
|
DisplayConfigGetDeviceInfo(&target.header) != ERROR_SUCCESS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targets.push_back({source.viewGdiDeviceName, Utf8(target.monitorDevicePath), target.monitorFriendlyDeviceName});
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL CALLBACK AddMonitor(HMONITOR handle, HDC, LPRECT, LPARAM data) {
|
||||||
|
auto* gdi = reinterpret_cast<std::vector<MONITORINFOEXW>*>(data);
|
||||||
|
MONITORINFOEXW info = {};
|
||||||
|
info.cbSize = sizeof(info);
|
||||||
|
if (GetMonitorInfoW(handle, &info)) {
|
||||||
|
gdi->push_back(info);
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<Monitor> List() {
|
||||||
|
std::vector<MONITORINFOEXW> gdi;
|
||||||
|
EnumDisplayMonitors(nullptr, nullptr, &AddMonitor, reinterpret_cast<LPARAM>(&gdi));
|
||||||
|
const std::vector<Target> targets = Targets();
|
||||||
|
std::vector<Monitor> monitors;
|
||||||
|
for (const MONITORINFOEXW& info : gdi) {
|
||||||
|
Monitor monitor{"", L"", info.rcMonitor, (info.dwFlags & MONITORINFOF_PRIMARY) != 0};
|
||||||
|
for (const Target& target : targets) {
|
||||||
|
if (target.gdiName == info.szDevice) { // mirrored displays: the first monitor
|
||||||
|
monitor.id = target.id;
|
||||||
|
monitor.name = target.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (monitor.id.empty()) {
|
||||||
|
monitor.id = Utf8(info.szDevice); // no display config (remote desktop, ...)
|
||||||
|
}
|
||||||
|
if (monitor.name.empty()) {
|
||||||
|
monitor.name = L"Monitor " + std::to_wstring(monitors.size() + 1);
|
||||||
|
}
|
||||||
|
monitors.push_back(monitor);
|
||||||
|
}
|
||||||
|
std::stable_sort(monitors.begin(), monitors.end(),
|
||||||
|
[](const Monitor& a, const Monitor& b) { return a.primary && !b.primary; });
|
||||||
|
return monitors;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string PreferredId() {
|
||||||
|
return re::ConfigString(kSection, kKey, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetPreferredId(const std::string& id) {
|
||||||
|
re::SetConfigString(kSection, kKey, id.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
RECT GameMonitorRect() {
|
||||||
|
const std::vector<Monitor> monitors = List();
|
||||||
|
const std::string preferred = PreferredId();
|
||||||
|
const Monitor* chosen = nullptr;
|
||||||
|
for (const Monitor& monitor : monitors) {
|
||||||
|
if ((!preferred.empty() && monitor.id == preferred) || (!chosen && monitor.primary)) {
|
||||||
|
chosen = &monitor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!chosen) {
|
||||||
|
// No monitor reported: the main monitor's rectangle as Windows has it.
|
||||||
|
MONITORINFO info = {sizeof(info)};
|
||||||
|
GetMonitorInfoW(MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY), &info);
|
||||||
|
return info.rcMonitor;
|
||||||
|
}
|
||||||
|
const bool found = !preferred.empty() && chosen->id == preferred;
|
||||||
|
static bool logged = false;
|
||||||
|
if (!logged) {
|
||||||
|
logged = true;
|
||||||
|
re::Log("monitor: %ls %ldx%ld at (%ld,%ld)%s", chosen->name.c_str(), chosen->rect.right - chosen->rect.left,
|
||||||
|
chosen->rect.bottom - chosen->rect.top, chosen->rect.left, chosen->rect.top,
|
||||||
|
found ? "" : preferred.empty() ? ", main monitor" : ", preferred monitor not connected: main monitor");
|
||||||
|
}
|
||||||
|
return chosen->rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mods::monitor
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
// The monitor the game runs on (request of 2026-10-25): a preferred monitor
|
||||||
|
// from the options, or the system's main monitor when none is set or the
|
||||||
|
// preferred one is not connected. See monitor.cpp.
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
namespace mods::monitor {
|
||||||
|
|
||||||
|
struct Monitor {
|
||||||
|
std::string id; // stable across restarts and reordering: the monitor's device path
|
||||||
|
std::wstring name; // the monitor's own name (EDID), e.g. "LG ULTRAGEAR"
|
||||||
|
RECT rect; // desktop coordinates
|
||||||
|
bool primary;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The connected monitors, the main monitor first.
|
||||||
|
std::vector<Monitor> List();
|
||||||
|
|
||||||
|
// The preferred monitor's id from sacredK.ini, empty for none.
|
||||||
|
std::string PreferredId();
|
||||||
|
void SetPreferredId(const std::string& id);
|
||||||
|
|
||||||
|
// The rectangle of the monitor to run on: the preferred one if connected,
|
||||||
|
// else the main monitor.
|
||||||
|
RECT GameMonitorRect();
|
||||||
|
|
||||||
|
} // namespace mods::monitor
|
||||||
+171
-2
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
// The quest book as one list: the story, then the side quests by area,
|
||||||
|
// unknown ones as "???" (requests of 2026-10-25: a player sees how many
|
||||||
|
// quests an area holds without being told where they are; then "get rid of
|
||||||
|
// the tabs ... a section for the story and a section per region", the
|
||||||
|
// game's page turning kept: "pagination is already there anyways").
|
||||||
|
//
|
||||||
|
// Measured (docs/quests.md, "Quest book"): the book's quest mode had up to
|
||||||
|
// seven act tabs (bookmarks I, II, ...), an entry's tab being its chapter
|
||||||
|
// (+0x16c), copied from the current main quest when the quest is accepted
|
||||||
|
// (0x46c450); the game knows no area for a quest. The side quests' ids come
|
||||||
|
// in blocks of 100 (1xx .. 25xx) whose accepted quests lie together on the
|
||||||
|
// map: the areas. The other side quests (5xxx, 6xxx, 9xxx, 102xx, 15xxx)
|
||||||
|
// carry no area in their id; once known they join the area nearest to them.
|
||||||
|
//
|
||||||
|
// The book (cUI_Book, one per book mode): the left page shows the current
|
||||||
|
// page's rows, the right page the selected row's detail record, both drawn by
|
||||||
|
// 0x6b27f0 from the same page; a click (0x6b3620) selects the row under the
|
||||||
|
// mouse (page +4).
|
||||||
|
//
|
||||||
|
// The mod: the game's list builder (0x6b04c0) runs on a reordered copy of the
|
||||||
|
// book with our rows (headings, "???") as extra entries, all in the first tab
|
||||||
|
// (the game's page breaks and page turning stay); afterwards each row's entry
|
||||||
|
// index is mapped back to the real book, ours to none (a click on them does
|
||||||
|
// nothing, 0x6b3620 checks the index). The bookmarks are hidden, every page is
|
||||||
|
// titled "Quests" and selects its first quest. Fonts, icons and the detail
|
||||||
|
// page stay the game's.
|
||||||
|
//
|
||||||
|
// "Erledigte anzeigen" (request of 2026-10-25: "some kind of 'Show completed'
|
||||||
|
// checkmark. If unchecked, completed quests are not in the list (as well as
|
||||||
|
// completed regions)"): the first row, its icon the solved mark when on, none
|
||||||
|
// when off; a click toggles it (sacredK.ini [questbook] ShowCompleted) and
|
||||||
|
// rebuilds the book. Off leaves out solved quests and the areas whose quests
|
||||||
|
// are all known and solved; failed quests stay (they can be restarted).
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <deque>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "game/engine/quests.h"
|
||||||
|
#include "game/engine/regions.h"
|
||||||
|
#include "game/ui/control.h"
|
||||||
|
#include "game/ui/texts.h"
|
||||||
|
#include "re/config.h"
|
||||||
|
#include "re/hook.h"
|
||||||
|
#include "re/log.h"
|
||||||
|
|
||||||
|
namespace mods::questbook {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kAreaQuestFirst = 100; // ids 100 .. 2599: area = id / 100
|
||||||
|
constexpr int kAreaQuestEnd = 2600; // 2600 .. 3599: generic quests, cloned by Quest_End
|
||||||
|
constexpr int kGenericQuestEnd = 3600;
|
||||||
|
constexpr int kAreaSize = 100;
|
||||||
|
|
||||||
|
// cUI_Book: the book mode (+0x166, 0 = quests), current tab (+0x15e) and page
|
||||||
|
// (+0x162); per tab a vector of pages (+0x97c + tab * 0xc) and of detail
|
||||||
|
// records (+0x9d0 + tab * 0xc). A page (0x1c bytes): title text +0, selected row +4 (u16), a vector
|
||||||
|
// of rows +0x10. A row (0x14 bytes): u16 flags (4 = spacer), title text +4,
|
||||||
|
// detail record +8, book entry +0xc. The bookmarks: 7 controls at +0x168.
|
||||||
|
constexpr size_t kBookMode = 0x166;
|
||||||
|
constexpr uint16_t kModeQuests = 0;
|
||||||
|
constexpr size_t kBookTab = 0x15e;
|
||||||
|
constexpr size_t kBookPage = 0x162;
|
||||||
|
constexpr size_t kBookPages = 0x97c;
|
||||||
|
constexpr size_t kTabStride = 0xc;
|
||||||
|
constexpr size_t kPageSize = 0x1c;
|
||||||
|
constexpr size_t kPageTitle = 0x0;
|
||||||
|
constexpr size_t kPageSelected = 0x4;
|
||||||
|
constexpr size_t kPageRows = 0x10;
|
||||||
|
constexpr size_t kRowSize = 0x14;
|
||||||
|
constexpr size_t kRowEntry = 0xc;
|
||||||
|
constexpr uint16_t kRowSpacer = 0x4;
|
||||||
|
constexpr size_t kBookmarks = 0x168;
|
||||||
|
constexpr size_t kBookmarkStride = 0xbc;
|
||||||
|
constexpr int kTabs = 7;
|
||||||
|
constexpr uint32_t kNoEntry = 0xffffffff;
|
||||||
|
constexpr uint32_t kToggleEntry = 0xfffffffe; // the "Erledigte anzeigen" row
|
||||||
|
constexpr uint16_t kNoRow = 0xffff;
|
||||||
|
|
||||||
|
constexpr const char* kConfigSection = "questbook";
|
||||||
|
constexpr const char* kConfigShowCompleted = "ShowCompleted";
|
||||||
|
|
||||||
|
// The setting, read from the ini on first use.
|
||||||
|
bool& ShowCompleted() {
|
||||||
|
static bool show = re::ConfigInt(kConfigSection, kConfigShowCompleted, 1) != 0;
|
||||||
|
return show;
|
||||||
|
}
|
||||||
|
|
||||||
|
using Entry = std::array<uint8_t, game::kBookEntrySize>;
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
T& Field(uint8_t* p, size_t offset) {
|
||||||
|
return *reinterpret_cast<T*>(p + offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our texts, registered once each (RegisterText keeps the pointer).
|
||||||
|
uint32_t TextId(const std::wstring& text) {
|
||||||
|
static std::map<std::wstring, uint32_t> ids;
|
||||||
|
static std::deque<std::wstring> storage;
|
||||||
|
auto found = ids.find(text);
|
||||||
|
if (found != ids.end()) {
|
||||||
|
return found->second;
|
||||||
|
}
|
||||||
|
storage.push_back(text);
|
||||||
|
return ids[text] = game::RegisterText(storage.back().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring Format(const wchar_t* format, int a, int b = 0, int c = 0) {
|
||||||
|
wchar_t text[128];
|
||||||
|
swprintf(text, 128, format, a, b, c);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the list ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The quest table's side quests that count, by id: their area (0 = none by
|
||||||
|
// id). Not main quests, and not the ones whose name occurs more than once
|
||||||
|
// (generic quests and their clones, script bookkeeping such as "Region 1
|
||||||
|
// befreit").
|
||||||
|
std::map<int32_t, int> CountedQuests() {
|
||||||
|
std::map<int32_t, int> result;
|
||||||
|
std::map<std::string, int> names;
|
||||||
|
for (size_t i = 0, n = game::QuestCount(); i < n; ++i) {
|
||||||
|
const char* name = game::QuestName(game::Quest(static_cast<int>(i)));
|
||||||
|
if (*name) {
|
||||||
|
++names[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (size_t i = 0, n = game::QuestCount(); i < n; ++i) {
|
||||||
|
uint8_t* quest = game::Quest(static_cast<int>(i));
|
||||||
|
const int32_t id = game::QuestId(quest);
|
||||||
|
const char* name = game::QuestName(quest);
|
||||||
|
if (id <= game::kMainQuestLast || (*name && names[name] > 1) || (id >= kAreaQuestEnd && id < kGenericQuestEnd)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[id] = id >= kAreaQuestFirst && id < kAreaQuestEnd ? id / kAreaSize : 0;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Area {
|
||||||
|
int total = 0;
|
||||||
|
std::vector<int32_t> known; // quest ids, in book order
|
||||||
|
double x = 0, y = 0;
|
||||||
|
int positions = 0;
|
||||||
|
std::map<uint8_t, int> regions; // region number at its quests: count
|
||||||
|
};
|
||||||
|
|
||||||
|
// The area's name: the game's name of the region most of its quests lie in;
|
||||||
|
// else "Gebiet N".
|
||||||
|
std::wstring AreaName(int number, const Area& area) {
|
||||||
|
uint8_t region = 0;
|
||||||
|
int most = 0;
|
||||||
|
for (const auto& [r, n] : area.regions) {
|
||||||
|
if (n > most) {
|
||||||
|
region = r;
|
||||||
|
most = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const char* key = game::RegionNameKey(region);
|
||||||
|
std::wstring name = key ? game::GameTextByKey(key) : L"";
|
||||||
|
return name.empty() ? Format(L"Gebiet %d", number) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reordered book: entries (copies of the real ones, or ours), all in the
|
||||||
|
// first tab, and for each the real entry index, kNoEntry for ours.
|
||||||
|
struct Order {
|
||||||
|
std::vector<Entry> entries;
|
||||||
|
std::vector<uint32_t> real;
|
||||||
|
|
||||||
|
void Add(const Entry& entry, uint32_t realIndex) {
|
||||||
|
entries.push_back(entry);
|
||||||
|
Field<uint16_t>(entries.back().data(), game::kBookChapter) = 0;
|
||||||
|
real.push_back(realIndex);
|
||||||
|
}
|
||||||
|
void AddReal(uint32_t index) {
|
||||||
|
Entry entry;
|
||||||
|
std::memcpy(entry.data(), game::BookEntry(index), entry.size());
|
||||||
|
Add(entry, index);
|
||||||
|
}
|
||||||
|
void AddOwn(int32_t kind, const std::wstring& title, int32_t state = 0, uint32_t marker = kNoEntry) {
|
||||||
|
Entry entry = {};
|
||||||
|
Field<int32_t>(entry.data(), game::kBookKind) = kind;
|
||||||
|
Field<int32_t>(entry.data(), game::kBookState) = state;
|
||||||
|
Field<uint32_t>(entry.data(), game::kBookTitle) = TextId(title);
|
||||||
|
Field<uint32_t>(entry.data(), game::kBookText) = TextId(L"");
|
||||||
|
Add(entry, marker);
|
||||||
|
}
|
||||||
|
// A heading: a main-quest-style row; the game puts a spacer before a main
|
||||||
|
// quest row only after a side quest row, so after a main quest an empty
|
||||||
|
// side row goes first.
|
||||||
|
void AddHeading(const std::wstring& title) {
|
||||||
|
if (!entries.empty() && Field<int32_t>(entries.back().data(), game::kBookKind) == game::kBookMain) {
|
||||||
|
AddOwn(game::kBookSide, L" ");
|
||||||
|
}
|
||||||
|
AddOwn(game::kBookMain, title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Order BuildOrder() {
|
||||||
|
const std::map<int32_t, int> counted = CountedQuests();
|
||||||
|
const size_t count = game::BookCount();
|
||||||
|
std::map<int, Area> areas;
|
||||||
|
for (const auto& [id, area] : counted) {
|
||||||
|
if (area) {
|
||||||
|
++areas[area].total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known side quests, in book order, with their area.
|
||||||
|
std::map<int32_t, int> questArea;
|
||||||
|
std::vector<int32_t> known;
|
||||||
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
uint8_t* entry = game::BookEntry(i);
|
||||||
|
const int32_t id = game::BookQuestId(entry);
|
||||||
|
if (game::BookKind(entry) == game::kBookMain || questArea.count(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto found = counted.find(id);
|
||||||
|
const int area = found != counted.end() ? found->second : 0;
|
||||||
|
questArea[id] = area;
|
||||||
|
known.push_back(id);
|
||||||
|
if (area) {
|
||||||
|
Area& a = areas[area];
|
||||||
|
a.known.push_back(id);
|
||||||
|
const int32_t x = Field<int32_t>(entry, game::kBookX);
|
||||||
|
const int32_t y = Field<int32_t>(entry, game::kBookY);
|
||||||
|
if (x || y) {
|
||||||
|
a.x += x;
|
||||||
|
a.y += y;
|
||||||
|
++a.positions;
|
||||||
|
++a.regions[game::RegionAtCell(x, y)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The other known side quests join the area nearest to them (by its known
|
||||||
|
// quests' mean position).
|
||||||
|
int unknownOthers = 0;
|
||||||
|
for (const auto& [id, area] : counted) {
|
||||||
|
unknownOthers += !area && !questArea.count(id);
|
||||||
|
}
|
||||||
|
for (int32_t id : known) {
|
||||||
|
if (questArea[id] || !counted.count(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
uint8_t* entry = game::BookEntry(i);
|
||||||
|
if (game::BookQuestId(entry) != id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double x = Field<int32_t>(entry, game::kBookX);
|
||||||
|
const double y = Field<int32_t>(entry, game::kBookY);
|
||||||
|
double bestDistance = 0;
|
||||||
|
int best = 0;
|
||||||
|
for (const auto& [number, a] : areas) {
|
||||||
|
if (!a.positions || (x == 0 && y == 0)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double dx = x - a.x / a.positions;
|
||||||
|
const double dy = y - a.y / a.positions;
|
||||||
|
const double distance = dx * dx + dy * dy;
|
||||||
|
if (!best || distance < bestDistance) {
|
||||||
|
best = number;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
questArea[id] = best;
|
||||||
|
++areas[best].total;
|
||||||
|
areas[best].known.push_back(id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool showCompleted = ShowCompleted();
|
||||||
|
auto shown = [&](uint8_t* entry) { return showCompleted || game::BookState(entry) != game::kBookSolved; };
|
||||||
|
auto hidden = [&](int32_t id) { // no entry of quest `id` in the list
|
||||||
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
uint8_t* entry = game::BookEntry(i);
|
||||||
|
if (game::BookQuestId(entry) == id && game::BookKind(entry) != game::kBookMain && shown(entry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The solved icon when on (the game draws state 100 so), no icon when off.
|
||||||
|
Order order;
|
||||||
|
order.AddOwn(game::kBookSide, L"Erledigte anzeigen", showCompleted ? game::kBookSolved : 0, kToggleEntry);
|
||||||
|
auto addQuest = [&](int32_t id) {
|
||||||
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
uint8_t* entry = game::BookEntry(i);
|
||||||
|
if (game::BookQuestId(entry) == id && game::BookKind(entry) != game::kBookMain && shown(entry)) {
|
||||||
|
order.AddReal(static_cast<uint32_t>(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
order.AddHeading(L"Story");
|
||||||
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
uint8_t* entry = game::BookEntry(i);
|
||||||
|
if (game::BookKind(entry) == game::kBookMain && shown(entry)) {
|
||||||
|
order.AddReal(static_cast<uint32_t>(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Areas with the same name (several id blocks lie in one region) make one
|
||||||
|
// section, in the order of their first area.
|
||||||
|
int undiscovered = 0;
|
||||||
|
std::vector<std::wstring> sectionNames;
|
||||||
|
std::map<std::wstring, Area> sections;
|
||||||
|
for (const auto& [number, a] : areas) {
|
||||||
|
if (a.known.empty()) {
|
||||||
|
undiscovered += a.total > 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::wstring name = AreaName(number, a);
|
||||||
|
if (!sections.count(name)) {
|
||||||
|
sectionNames.push_back(name);
|
||||||
|
}
|
||||||
|
Area& section = sections[name];
|
||||||
|
section.total += a.total;
|
||||||
|
section.known.insert(section.known.end(), a.known.begin(), a.known.end());
|
||||||
|
}
|
||||||
|
for (const std::wstring& name : sectionNames) {
|
||||||
|
const Area& a = sections[name];
|
||||||
|
if (!showCompleted && static_cast<int>(a.known.size()) >= a.total &&
|
||||||
|
std::all_of(a.known.begin(), a.known.end(), hidden)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int knownCount = static_cast<int>(a.known.size());
|
||||||
|
order.AddHeading(name + Format(L" (%d/%d)", knownCount, a.total));
|
||||||
|
for (int32_t id : a.known) {
|
||||||
|
addQuest(id);
|
||||||
|
}
|
||||||
|
for (int i = knownCount; i < a.total; ++i) {
|
||||||
|
order.AddOwn(game::kBookSide, L"???");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool others = false;
|
||||||
|
for (int32_t id : known) {
|
||||||
|
if (!questArea[id] && !hidden(id)) {
|
||||||
|
if (!others) {
|
||||||
|
order.AddHeading(L"Weitere Nebenquests");
|
||||||
|
others = true;
|
||||||
|
}
|
||||||
|
addQuest(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (undiscovered || unknownOthers) {
|
||||||
|
order.AddHeading(L"Unentdeckt");
|
||||||
|
if (undiscovered) {
|
||||||
|
order.AddOwn(game::kBookSide, Format(undiscovered == 1 ? L"%d Gebiet" : L"%d Gebiete", undiscovered));
|
||||||
|
}
|
||||||
|
if (unknownOthers) {
|
||||||
|
order.AddOwn(game::kBookSide, Format(unknownOthers == 1 ? L"??? (%d weitere Nebenquest)"
|
||||||
|
: L"??? (%d weitere Nebenquests)",
|
||||||
|
unknownOthers));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the book's pages ------------------------------------------------------------
|
||||||
|
|
||||||
|
uint8_t* CurrentPage(uint8_t* book) {
|
||||||
|
const size_t tab = Field<uint16_t>(book, kBookTab);
|
||||||
|
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
|
||||||
|
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
|
||||||
|
uint8_t* page = pages + Field<uint16_t>(book, kBookPage) * kPageSize;
|
||||||
|
return pages && page < pagesEnd ? page : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t* RowsBegin(uint8_t* page) { return Field<uint8_t*>(page, kPageRows); }
|
||||||
|
uint8_t* RowsEnd(uint8_t* page) { return Field<uint8_t*>(page, kPageRows + 4); }
|
||||||
|
|
||||||
|
// The row's book entry; kNoEntry for spacers and rows past the end.
|
||||||
|
uint32_t RowEntry(uint8_t* page, uint16_t row) {
|
||||||
|
uint8_t* r = RowsBegin(page) + row * kRowSize;
|
||||||
|
return r < RowsEnd(page) && (Field<uint16_t>(r, 0) & kRowSpacer) == 0 ? Field<uint32_t>(r, kRowEntry) : kNoEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsQuestRow(uint8_t* page, uint16_t row) { return RowEntry(page, row) < kToggleEntry; }
|
||||||
|
|
||||||
|
void SelectFirstQuest(uint8_t* page) {
|
||||||
|
uint16_t& selected = Field<uint16_t>(page, kPageSelected);
|
||||||
|
if (IsQuestRow(page, selected)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (uint16_t row = 0; RowsBegin(page) + row * kRowSize < RowsEnd(page); ++row) {
|
||||||
|
if (IsQuestRow(page, row)) {
|
||||||
|
selected = row;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cUI_Book::Rebuild (0x6b1670, thiscall, no arguments): rebuilds the current
|
||||||
|
// mode's pages, keeping tab and page if the page still exists.
|
||||||
|
void RebuildBook(uint8_t* book) {
|
||||||
|
reinterpret_cast<void(__fastcall*)(uint8_t*, void*)>(0x006b1670)(book, nullptr);
|
||||||
|
const size_t tab = Field<uint16_t>(book, kBookTab);
|
||||||
|
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
|
||||||
|
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
|
||||||
|
if (!CurrentPage(book) && pages < pagesEnd) {
|
||||||
|
Field<uint16_t>(book, kBookPage) = static_cast<uint16_t>((pagesEnd - pages) / kPageSize - 1);
|
||||||
|
}
|
||||||
|
if (uint8_t* page = CurrentPage(book)) {
|
||||||
|
SelectFirstQuest(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows point into the reordered book; point them at the real entries. Every
|
||||||
|
// page titled "Quests", its first quest selected.
|
||||||
|
void FixPages(uint8_t* book, const Order& order) {
|
||||||
|
for (int tab = 0; tab < kTabs; ++tab) {
|
||||||
|
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
|
||||||
|
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
|
||||||
|
for (uint8_t* page = pages; page && page < pagesEnd; page += kPageSize) {
|
||||||
|
for (uint8_t* row = RowsBegin(page); row && row < RowsEnd(page); row += kRowSize) {
|
||||||
|
uint32_t& index = Field<uint32_t>(row, kRowEntry);
|
||||||
|
if ((Field<uint16_t>(row, 0) & kRowSpacer) == 0 && index < order.real.size()) {
|
||||||
|
index = order.real[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tab != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Field<uint32_t>(page, kPageTitle) = TextId(L"Quests");
|
||||||
|
SelectFirstQuest(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
} // namespace mods::questbook
|
||||||
|
|
||||||
|
using namespace mods::questbook;
|
||||||
|
|
||||||
|
// cUI_Book's quest list (0x6b04c0, thiscall, no arguments; from the book's
|
||||||
|
// rebuild 0x6b1670 in quest mode, on the same thread as the quest scripts).
|
||||||
|
void __fastcall Book_BuildQuestList(uint8_t* book, void* edx);
|
||||||
|
RE_HOOK(0x006b04c0, Book_BuildQuestList);
|
||||||
|
void __fastcall Book_BuildQuestList(uint8_t* book, void* edx) {
|
||||||
|
Order order = BuildOrder();
|
||||||
|
uint8_t* const begin = game::BookBegin();
|
||||||
|
uint8_t* const end = game::BookEnd();
|
||||||
|
uint8_t* const capacity = game::BookCapacityEnd();
|
||||||
|
uint32_t& selected = game::BookSelected(game::LocalPlayerSlot());
|
||||||
|
const uint32_t realSelected = selected;
|
||||||
|
uint32_t reorderedSelected = static_cast<uint32_t>(order.entries.size());
|
||||||
|
for (size_t i = 0; i < order.real.size(); ++i) {
|
||||||
|
if (order.real[i] == realSelected) {
|
||||||
|
reorderedSelected = static_cast<uint32_t>(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* copy = reinterpret_cast<uint8_t*>(order.entries.data());
|
||||||
|
game::BookBegin() = copy;
|
||||||
|
game::BookEnd() = game::BookCapacityEnd() = copy + order.entries.size() * game::kBookEntrySize;
|
||||||
|
selected = reorderedSelected;
|
||||||
|
Book_BuildQuestList_original(book, edx);
|
||||||
|
game::BookBegin() = begin;
|
||||||
|
game::BookEnd() = end;
|
||||||
|
game::BookCapacityEnd() = capacity;
|
||||||
|
selected = realSelected;
|
||||||
|
|
||||||
|
FixPages(book, order);
|
||||||
|
for (int tab = 0; tab < kTabs; ++tab) {
|
||||||
|
game::SetControlShown(book + kBookmarks + tab * kBookmarkStride, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cUI_Book's event handler (0x6b3620, thiscall (event), ret 4): a click on a
|
||||||
|
// heading or "???" row keeps the selection, one on "Erledigte anzeigen"
|
||||||
|
// toggles it.
|
||||||
|
bool __fastcall Book_HandleEvent(uint8_t* book, void* edx, void* event);
|
||||||
|
RE_HOOK(0x006b3620, Book_HandleEvent);
|
||||||
|
bool __fastcall Book_HandleEvent(uint8_t* book, void* edx, void* event) {
|
||||||
|
uint8_t* page = Field<uint16_t>(book, kBookMode) == kModeQuests ? CurrentPage(book) : nullptr;
|
||||||
|
const uint16_t selected = page ? Field<uint16_t>(page, kPageSelected) : kNoRow;
|
||||||
|
const bool handled = Book_HandleEvent_original(book, edx, event);
|
||||||
|
if (page && page == CurrentPage(book) && !IsQuestRow(page, Field<uint16_t>(page, kPageSelected))) {
|
||||||
|
const bool toggle = RowEntry(page, Field<uint16_t>(page, kPageSelected)) == kToggleEntry;
|
||||||
|
Field<uint16_t>(page, kPageSelected) = selected;
|
||||||
|
if (toggle) {
|
||||||
|
ShowCompleted() = !ShowCompleted();
|
||||||
|
re::SetConfigString(kConfigSection, kConfigShowCompleted, ShowCompleted() ? "1" : "0");
|
||||||
|
RebuildBook(book);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return handled;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -1,18 +1,18 @@
|
|||||||
# Automated smoke test: start the game, load a save game (-Save: its row in
|
# Automated smoke test: start the game, load a character's last world (-Save:
|
||||||
# the list, 1 = the first, selected by default), take
|
# its row in the Spielen screen's character list, 1 = the first), take
|
||||||
# screenshots in-game and with the ESC menu open, then stop the game.
|
# screenshots in-game and with the ESC menu open, then stop the game.
|
||||||
#
|
#
|
||||||
# .\tools\autotest-load.ps1 -Name ws_on # current sacredK.ini
|
# .\tools\autotest-load.ps1 -Name ws_on # current sacredK.ini
|
||||||
# .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
|
# .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
|
||||||
#
|
#
|
||||||
# -Ini temporarily appends settings to build\x86-debug\sacredK.ini.
|
# -Ini temporarily appends settings to build\x86-debug\sacredK.ini.
|
||||||
# Click positions are for the classic (menu) layout scaled onto a 3440x1440
|
# Click positions are for the menu screens (2026-10-25) on a 3440x1440
|
||||||
# monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs.
|
# monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs.
|
||||||
param(
|
param(
|
||||||
[string]$Name = "autotest",
|
[string]$Name = "autotest",
|
||||||
[string]$Ini = "",
|
[string]$Ini = "",
|
||||||
[int]$LoadTimeoutSeconds = 60,
|
[int]$LoadTimeoutSeconds = 60,
|
||||||
# Row of the save game in the load screen's list (1-3 visible rows).
|
# Row of the character in the Spielen screen's list (its last world loads).
|
||||||
[int]$Save = 1,
|
[int]$Save = 1,
|
||||||
# Runs in-game after loading, before the screenshots (e.g. clicks).
|
# Runs in-game after loading, before the screenshots (e.g. clicks).
|
||||||
[scriptblock]$InGame = $null
|
[scriptblock]$InGame = $null
|
||||||
@@ -28,12 +28,10 @@ if ($Ini) { Set-Content $iniPath ($iniBackup + "`r`n" + $Ini + "`r`n") -Encoding
|
|||||||
$game = $null
|
$game = $null
|
||||||
try {
|
try {
|
||||||
$game = Start-Sacred
|
$game = Start-Sacred
|
||||||
Click-At 1720 779 -AfterMs 1500 # main menu: "Spiel laden"
|
Click-At 1717 538 -AfterMs 2000 # main menu: "Spielen"
|
||||||
if ($Save -gt 1) {
|
Click-At 817 (509 + ($Save - 1) * 105) -AfterMs 1000 # character list row (105 px apart)
|
||||||
Click-At 1342 (609 + ($Save - 1) * 254) -AfterMs 800 # load screen: the save's name (rows 254 px apart)
|
Click-At 2714 1347 -AfterMs 1500 # "Spielen": the character's last world
|
||||||
}
|
Click-At 1548 824 -AfterMs 0 # "Spielstand laden?": "Ok"
|
||||||
Click-At 2382 568 -AfterMs 2000 # load screen: "Laden"
|
|
||||||
Click-At 1551 826 -AfterMs 0 # confirm: "Ok"
|
|
||||||
Wait-Log "cEngine End and exit constructor" -TimeoutSeconds $LoadTimeoutSeconds -AfterMs 1500 -Game $game
|
Wait-Log "cEngine End and exit constructor" -TimeoutSeconds $LoadTimeoutSeconds -AfterMs 1500 -Game $game
|
||||||
if ($InGame) { & $InGame }
|
if ($InGame) { & $InGame }
|
||||||
Save-Screen "screenshots\${Name}_ingame.png" | Out-Null
|
Save-Screen "screenshots\${Name}_ingame.png" | Out-Null
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
# Click-At 1720 779 # screen pixels (left click)
|
# Click-At 1720 779 # screen pixels (left click)
|
||||||
# Press-Key 0x1B # virtual key (ESC)
|
# Press-Key 0x1B # virtual key (ESC)
|
||||||
# Hold-Key 0x27 -Ms 1500 # hold a key (right arrow)
|
# Hold-Key 0x27 -Ms 1500 # hold a key (right arrow)
|
||||||
|
# Scroll-Wheel -3 # mouse wheel, 3 notches down
|
||||||
# Save-Screen screenshots\x.png # primary monitor area of the game
|
# Save-Screen screenshots\x.png # primary monitor area of the game
|
||||||
# Stop-Sacred $game # only the given process
|
# Stop-Sacred $game # only the given process
|
||||||
#
|
#
|
||||||
@@ -25,7 +26,7 @@ $script:Root = Split-Path $PSScriptRoot
|
|||||||
# synthetic Alt press lifts that restriction for SetForegroundWindow.
|
# synthetic Alt press lifts that restriction for SetForegroundWindow.
|
||||||
function Focus-Sacred($Game) {
|
function Focus-Sacred($Game) {
|
||||||
$Game.Refresh()
|
$Game.Refresh()
|
||||||
if ($Game.MainWindowHandle -eq [IntPtr]::Zero) { return $false }
|
if (-not $Game.MainWindowHandle -or $Game.MainWindowHandle -eq [IntPtr]::Zero) { return $false }
|
||||||
[SacredRe.Input]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
|
[SacredRe.Input]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
|
||||||
[SacredRe.Input]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
|
[SacredRe.Input]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
|
||||||
[SacredRe.Input]::SetForegroundWindow($Game.MainWindowHandle)
|
[SacredRe.Input]::SetForegroundWindow($Game.MainWindowHandle)
|
||||||
@@ -116,3 +117,9 @@ function Hold-Key([byte]$VirtualKey, [int]$Ms = 1500, [int]$AfterMs = 500) {
|
|||||||
[SacredRe.Input]::keybd_event($VirtualKey, 0, 2, [UIntPtr]::Zero)
|
[SacredRe.Input]::keybd_event($VirtualKey, 0, 2, [UIntPtr]::Zero)
|
||||||
Start-Sleep -Milliseconds $AfterMs
|
Start-Sleep -Milliseconds $AfterMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Turns the mouse wheel by $Notches (negative: towards the user, "down").
|
||||||
|
function Scroll-Wheel([int]$Notches, [int]$AfterMs = 500) {
|
||||||
|
[SacredRe.Input]::mouse_event(0x0800, 0, 0, [BitConverter]::ToUInt32([BitConverter]::GetBytes([int]($Notches * 120)), 0), [UIntPtr]::Zero)
|
||||||
|
Start-Sleep -Milliseconds $AfterMs
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user