Compare commits

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

Not yet tested in game.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 01:33:43 +02:00
66 changed files with 6161 additions and 110 deletions

No files matched your search

+13
View File
@@ -39,6 +39,7 @@ add_library(sacredK SHARED
src/game/engine/stream.cpp src/game/engine/stream.cpp
src/game/ui/texts.cpp src/game/ui/texts.cpp
src/game/engine/art.cpp src/game/engine/art.cpp
src/mods/monitor.cpp
src/mods/borderless.cpp src/mods/borderless.cpp
src/mods/widescreen.cpp src/mods/widescreen.cpp
src/mods/hud.cpp src/mods/hud.cpp
@@ -59,6 +60,7 @@ add_library(sacredK SHARED
src/mods/combos.cpp src/mods/combos.cpp
src/mods/aurahits.cpp src/mods/aurahits.cpp
src/mods/chainlightning.cpp src/mods/chainlightning.cpp
src/mods/dwarfarts.cpp
src/mods/arttooltip.cpp src/mods/arttooltip.cpp
src/mods/escape.cpp src/mods/escape.cpp
src/mods/recordcache.cpp src/mods/recordcache.cpp
@@ -74,6 +76,17 @@ 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/mapborder.cpp
src/mods/mapreveal.cpp
src/mods/questbook.cpp
src/mods/followers.cpp
src/mods/questfail.cpp
src/mods/questgiver.cpp
src/mods/questitems.cpp
src/mods/questnpc.cpp
src/mods/questscript.cpp
src/mods/anticheat.cpp
src/mods/loadingscreen.cpp
) )
target_include_directories(sacredK PRIVATE src) target_include_directories(sacredK PRIVATE src)
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS}) target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
+76
View File
@@ -0,0 +1,76 @@
# Anti-cheat removed
## Request (2026-10-25)
The Gladiator (Character03, level 106) "randomly exploded" fighting an
underleveled dragon on Silber. The user: the cheater detection is not
necessary, remove it. After a second explosion: "i want all that weird
cheater detection code gone".
## Measured
A temporary damage log showed the hero at full health (17200) going to 0 in
one tick, with the game logging `cheat statBonus 0/3`, `cheat SkillLevel 0..7`,
`Hase!` and `cObjectManager::destroy: tried to delete local hero #1!`; the hero
came back with 1199 health and died to the next hit.
- The game mirrors the local hero's fields (pointers `0x182cddc`, on a
protected page, and `0x182cde0`, a separate heap block: `HeapAlloc` 0x1000
at `0x816541`): +0 hero +0x3b4, +4 hero +0x3ee (gold), +8 hero +0x3fe (u16),
xor-ed with the hero's key byte (+0x38) repeated four times (the second
copy: key << 1); +0xc a raw copy of hero +0x3b0..+0x49d (attributes, skill
levels). Writer: `0x603d9c` and about 30 inline copies after legitimate
changes, all starting with `VirtualProtect(page, 0x1000, PAGE_READWRITE)`
and ending with `0x201`.
- Checks: `cCreature::Think` about once in 101 ticks (`rand() % 101 == 10`,
`0x5389b5`), `VerifyHeroMirror` `0x5905f0` (callers `0x57fc71`,
`0x580001`, `0x580331`, `0x61ce22`), inline copies at `0x4659f6`,
`0x564c6f`, `0x57e09c`, `0x585bd0`, `0x619d33`, and a trap in Think
(`0x5385a6`: a hero whose level exceeds its experience's by more than 3
gets +0x3b4 / +0x3ee / +0x3fe raised behind the mirrors' back). A mismatch
resets the field (attributes 1, skill levels 0); any mismatch, or no hero at
all, kills the hero ("*** Cheater detected! ***", health 0, action dying)
and sets the cheater flag `0x182cdd0`, saved in the hero file (record +0xf3,
`0x604409`) and read back by loadHero (`0x6066eb`) and the character
selection (`0x70da99`, from the record's mark +0x3ef).
- Stream checks in the quest manager's hero data (`0x465980`): a marker
`0xfea4fea4` (`0x467782`) and three stored values compared with the hero
(`0x46a60b`, `0x46a64e`, `0x46a696`).
- In the failing log every skill level mismatched: the mirror was stale
(changes outside the game's mirror writers, e.g. the shared hero loaded into
another world).
## History
- First mod (confirmed): a `VirtualProtect` hook rewrote the mirror from the
hero whenever a check opened its page, so checks compared the hero with
itself.
- The hero exploded again (`Hase!` twice, no `cheat statBonus` / `SkillLevel`
line): the hook refreshed the second mirror only if it lay on the protected
page, so never; the checks compare its three xor-ed fields after the first
mirror (e.g. `0x585f83`) without a log line. Refreshing both fixed it (user:
"i no longer explode").
- Then the checks were removed entirely (below), the hook is gone.
## Mod (`src/mods/anticheat.cpp`): checks removed
Every check is skipped by a code patch (`re::ReplaceAll`): no field is reset,
no hero killed, the cheater flag never set.
| Check | Patch |
|---|---|
| Think trap `0x5385a6` | `0x5385ca` `jle` -> `jmp` |
| Think, 1 tick in 101 (`0x5389be`) | `0x5389b8` `jne` -> `jmp` |
| `0x564c6f`, `0x57e09c`, `0x585bd0` | the "not the local hero" `jne` before each (`0x564c50`, `0x57e07d`, `0x585bb0`) -> `jmp`: the game's own path past the check |
| quest manager hero data `0x4659f6` | `0x4659dc` `jmp 0x46606b` (the code there writes esi / edi before reading them; the stream keeps its clean marker 0xfeedf00d) |
| `0x619d33` | `mov ebp, [esp+0x34]; jmp 0x61a2de`, the registers as the check leaves them (edi already 0x88e874) |
| `VerifyHeroMirror` `0x5905f0` (callers test al) | `mov al, 1; ret` |
| stream checks `0x467782`, `0x46a60b`, `0x46a64e`, `0x46a696` | jumps past the punishment unconditional |
| loadHero `0x6066eb` | writes 0 to the cheater flag |
| character selection `0x70da8c` | `setne cl` -> `xor cl, cl` |
All field resets (`mov [reg+0x3b4], 1` ...) lie inside these checks; all
writes of the flag `0x182cdd0` (`0x465ebe`, `0x538e53`, `0x56508a`,
`0x57e528`, `0x57fcfd`, `0x58008d`, `0x5803bd`, `0x586090`, `0x61a17e`,
`0x61ceac`) are their punishments. The mirror writers stay: they only fill
the now unread mirrors.
+143
View File
@@ -0,0 +1,143 @@
# Dwarf arts: Kanonenschuss, Mörsergranate, Eingraben
## Requests (2026-10-25 / 2026-10-26)
- Kanonenschuss (art 0x43f, `gui_spells_kanone`): "remove the knockback,
instead it can be just a stun" (chosen: no push, only a stun); the range
increased for the wider view; the stun particles not on targets that
avoided the stun. The game's stun resistance stays (2026-10-26: "we dont
ignore anything, the skill is fine for now").
- Mörsergranate (art 0x43e, `gui_spells_moerser`): "the radius of the
explosion should increase every 5 skill levels".
## Measured
- The cannon's code is the combat move function `0x5145d0`, case 0x43f. Its
ray: `cmp edi,19h` at `0x517389`, 25 steps of 40 = 1000 range. Hostile
targets get `cEvent_creature` 5 from `0x51768d`.
- Stun chance `0x4fc810(level, creature)` = level × 5 / creature level × 100
(`UI_STAT_STUNNCHANCE`). On a successful roll: `0x5936e0(MOTION_HIT_A 0xaf,
3.0, 0.25)` at `0x517709` (pushes from `0x5176f8`), then the stun order
`cEvent_creature` 3 with +0x14 = 100, sent by `call [eax+18h]` at
`0x5177aa` (returns to `0x5177ad`).
- The stun *is* the knockback. `cCreature::ReceiveOrder` `0x52e400` case 3:
first the resistance `vf24(0x50)` (`0x55d0a0`: flag +0x4f0 & 0x1000 resists
2 in 3, effect 0x35d a percentage); otherwise push left +0x24d = 100, flag
+0x200 |= 0x20000, attacker +0x259, ResetToIdle. `cCreature::Update` branch
`0x594c82` moves a creature with that flag away from the attacker and lowers
the push left by each step (`0x594deb`), measured about 100 units in 0.3 s;
at 0 the flag is cleared.
- The impact effect `TYPE_FX_DWARF_SHOOT_IMPACT` (0x47f) is created on every
hit target by `CreateAt` at `0x517855` (target in ebx), resisted or not.
- Mörsergranate: the grenade (type 0x3fa) lands and creates
`TYPE_FX_GRENADEIMPACT` (0x3fc), which gathers its targets with
`cObjectManager 0x5ff820` (called from `0x5ab400` at `0x5ab63d`): a creature
is hit when its distance is below the impact's size (150, type table
`0x428bb0`) plus its own type's size.
## The mod (`src/mods/dwarfarts.cpp`)
- The HIT_A motion is jumped over (`0x5176f8`: `jmp` past the call).
- The push step at `0x594deb` calls `PushStepThunk`: creatures the cannon
stunned (order 3 from `0x5177ad`, kept with an end time) move by 0 and keep
their push left above 0 for 1.5 s, so they stand still, then carry on.
- The impact effect is created only on targets in the stun state.
- The ray gets 45 steps (1800); confirmed to reach across the widened view.
- The impact's gather call gets a radius of size × (1 + 0.1 × (level / 5)),
with the owner's Mörsergranate level (`game::ArtLevel`). The growth 0.1 per
step is our choice; not yet seen in the game (Borf's level is 2).
## Mörsergranate impact marker (asked 2026-10-25, done 2026-10-26)
"Show exactly where the grenade will land and its explosion radius, a red
circle with low alpha on the ground."
Measured:
- The landing point is decided at launch (`0x5145d0` case 0x43e): the
target's position, or, closer than 200, a point 200 from the dwarf with a
+-15 jitter. It goes to the new grenade (type 0x3fa) in cEventFX 0x96,
constructed at `0x5169fa` (`cEventFX::Construct` 0x41e0a0, thiscall, ret
0x44: code, owner id, target id, position area / x / y / level, ...).
- The impact gathers its targets at exactly that point, on several frames.
The grenade object stays about 30 s after the explosion.
- Ground -> screen: `0x622700` (world units -> world pixels, the isometric
plane), then (pixel - camera +0x14/+0x18) / zoom +0xb60 + screen centre
(floating texts 0x41e546). Checked with the hero: the screen centre.
- The world view draws the ground (`0x62d530`, ground quad list), then the
objects.
Mod: the construct call notes grenade, owner, landing point and radius
(`ImpactRadius`, shared with the hit check); after `0x62d530` a disc (alpha
0x40) with a 2 px rim (alpha 0xa0) is drawn there, without depth test, until
the impact of that owner's grenade at that point gathers its targets.
Tooltip (2026-10-26, "the skills tooltip does not show the current radius"):
a game::Art tooltip filter adds "Explosionsradius: N" after the last value
line of each level section (current level, and the next level's in the skill
book), in that line's colour, from the same `ImpactRadiusAt(level)` as the
hit check and the marker.
## Grenade impact crash (2026-10-26)
Crashes at sacred.exe+0x361b54 (2026-10-26 00:12 and 22:39) in the particle
drawer `0x7613e0`, called by `cParticleSystem_grenadeimpact::Render` (vf04
0x7b66b0): colour = table +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and a
particle had a life of over 500000. Guard (`mods/guards.cpp`): live particles
with life >= 256 or row >= 3 are switched off before drawing. Logged in the
next session: 5 particles, all row 3, life 255, garbage sizes (e.g. 190555,
4.6e-40): the explosion emits into a fourth row whose emitter parameters it
never sets (vf10 0x7b6980 fills three rows at +0x30d0). No crash since.
## Eingraben: the dwarf's escape (request 2026-10-28)
"I want it to be the Dwarfs option to Teleport/Escape. The Skill will cast a
protective barrier around the Dwarf (current visual effect), clear him of all
movement impending effects and then teleport to the position marked by the
cursor (if possible). The Teleport is visually shown by the dwarf going
underground and coming out again at target position." Range: the visible
area; the barrier is the skill's current defensive bonus for 3 s; digging in
and out 1 s each; "he should be able to see the target location", compared
to the battlemage's Phasenverschiebung; the original lock in place goes.
Found in the code:
- Spells run in `cCreature::CastSpell` (0x5222c0, thiscall (cast), ret 4;
cast +0x08 / +0x0c the ground point, +0x20 the spell id). Eingraben is
spell 0x39 (`gui_moves_eingraben`, launch type 5 around the caster).
- Case 0x39 creates object type 0x480 at the caster with cEventFX 0x9d (owner
and target the caster) and plays sound 0x840: the barrier, which follows
the dwarf.
- Case 1 (Phasenverschiebung, launch type 3) walks from the caster towards the
target in steps of 10 while the cell is passable (`cWorld::IsPassable`
0x636b60) and keeps the last point a creature can stand on
(`cWorld::CanStand` 0x636eb0); a wall or cliff in the way ends the walk.
Logged (2026-10-28): the barrier adds, all with art 0x39, kind 0x3a value 0
from its particles (0x7b9602), the lock in place (the dwarf was stuck 2-3 s),
and kind 2 value 0.5 plus kinds 7..0xa value 30 from its update (0x5b5862..),
the defensive bonus (resistances up while it lasts).
Mod (`mods/dwarfarts.cpp`): Eingraben's definition +0x3c becomes 3 (a point);
after the original case the dwarf is teleported to the end of
Phasenverschiebung's walk towards the point (the cursor keeps it within the
visible area). The effect hook (`aurarecast.cpp`, `KeepEffect`) drops kind
0x3a for art 0x39. Confirmed 2026-10-28: aimed, stops before walls and
cliffs, casts at once at the screen edge, the barrier moves with him, no lock.
Duration ("more like 8 seconds as described in the tooltip, i want that to go
down to 4 seconds"): an art's duration is 0x4d24f0 on its definition,
+0x76 + level x +0x78, at most +0x82 when set (Eingraben: 7 + level, at most
15). The barrier's lifetime (shield +0x20a8), its bonus (the effect applier,
0x5b56ab) and the tooltip all use it; the mod sets 4 / 0 / 0.
Digging in and out: lowering the dwarf's height (creature +0xf0) moved only
his marker; lowering the terrain height the game asks for him (0x449b20)
moved the camera with him, so he never looked sunk. Instead ("change the
barrier animation to turn from transparent to solid and back"): the barrier
is `cParticleSystem_dwarf_shield` (constructor 0x7b8fc0, update vf08
0x7b91a0, draw vf04 0x7b92b0; models fx_shield_phase1..4.grn). Its draw
passes the model with alpha pulse x 128 (pulse 0.4..1 at +0x20b8; __ftol at
0x7b9483), then again additively. The mod redirects that __ftol: during a dig
the alpha goes to 255 over 1 s, the dwarf is moved (engine tick), and it goes
back over 1 s. Orders to the dwarf are refused meanwhile
(`game::FilterCreatureOrders`). Confirmed 2026-10-28, barrier and bonus 4 s.
Open: clearing slows, roots and stuns.
+15
View File
@@ -101,6 +101,8 @@ widened instead, when the render size is applied (`PatchLayout`):
| view centre (zoom frame, centred image) | 0x6ca53f, 0x6ca637, 0x6ca645 | 0x200 | width / 2 | | view centre (zoom frame, centred image) | 0x6ca53f, 0x6ca637, 0x6ca645 | 0x200 | width / 2 |
| scroll centred on the hero; zoomed view; its quest markers | `[0x88e6fc]` (512.0) at 0x6c7bfe, 0x6cb27a, 0x6ccb7f, 0x6cf782 | 512 | own float | | scroll centred on the hero; zoomed view; its quest markers | `[0x88e6fc]` (512.0) at 0x6c7bfe, 0x6cb27a, 0x6ccb7f, 0x6cf782 | 512 | own float |
| zoomed view: other markers at 768 − d (centre + 256) | `[0x890040]` (768.0) at 0x6ce156 | 768 | width / 2 + 256 | | zoomed view: other markers at 768 − d (centre + 256) | `[0x890040]` (768.0) at 0x6ce156 | 768 | width / 2 + 256 |
| zoomed view: heroes' markers, 6 px quad at d + 509 / 515 (2026-10-25) | `[0x894780]` (509.0) at 0x6cf1bd, `[0x894778]` (515.0) at 0x6cf1dd | 512 ± 3 | centre ± 3 |
| zoomed view: other symbols (quad, icons, labels) as integers (2026-10-25) | mov / add reg, 200h at 0x6ced82, 0x6cedb0, 0x6cf47a, 0x6cf4ac, 0x6cf5e4 | 0x200 | centre, rewritten when it changes |
| dark band, right outer / inner edge | vertex immediates 0x6ca774, 0x6ca808 / 0x6ca7bc, 0x6ca84c | 1024 / 954 | width / width − 70 | | dark band, right outer / inner edge | vertex immediates 0x6ca774, 0x6ca808 / 0x6ca7bc, 0x6ca84c | 1024 / 954 | width / width − 70 |
The frame is child statics of the window (vector at +0x78..+0x7c; measured: The frame is child statics of the window (vector at +0x78..+0x7c; measured:
@@ -116,6 +118,19 @@ game lays them out at creation only).
A window anchored `Screen` still counts as the outermost scope for events, so A window anchored `Screen` still counts as the outermost scope for events, so
its child buttons (hooked as standalone controls, `Center`) don't shift again. its child buttons (hooked as standalone controls, `Center`) don't shift again.
### Zoom on the hero (2026-10-25)
Reported: the zoomed view did not start centred on the hero. Read: the map
zooms on its zoom point +0xc0438 / +0xc043c (screen) plus the scroll
+0xc016c / +0xc0170 (render 0x6ca4d6). Holding the mouse button on the map
(+0xc06bc, 0x6c8274) moves the point with the mouse (outside the zoom
controls, 0x6ca47b); the zoom button (flag 0x100 in +0xc0154, 0x6c7fac)
never sets it, so it zoomed on the last held point, or 0,0 after the map was
created. Now, while the button zoom is on and the mouse is not held, every
engine tick puts the point on the hero: map pixel by 0x6cb130 (thiscall
(out, world x/y/level floats), ret 8; as opening the map centres on the hero,
0x6c7bad..) minus the scroll. The held zoom still follows the mouse.
## Viewports (cursor in the quest book, 2026-10-17) ## Viewports (cursor in the quest book, 2026-10-17)
`SetViewport` inside a moved window is shifted like its vertices. The quest `SetViewport` inside a moved window is shifted like its vertices. The quest
+12 -4
View File
@@ -13,12 +13,21 @@ Decided with the user: the filter replaces the game's PICKUPAUTO choice
(all / valuable / gold only; its radio buttons are hidden, the animation (all / valuable / gold only; its radio buttons are hidden, the animation
checkbox stays); Void deletes loot only (monster drops, chests), not items checkbox stays); Void deletes loot only (monster drops, chests), not items
the player puts down. Settings: `[loot]` in `sacredK.ini` the player puts down. Settings: `[loot]` in `sacredK.ini`
(`Gold=Default`, keys Gold, Potions, White, Blue, Yellow, Brown, Green; (`Gold=Default`, keys Quest, Gold, Potions, White, Blue, Yellow, Brown, Green;
values Void, Ignore, Default, AlwaysShow). Code: `src/mods/lootfilter.cpp`, values Void, Ignore, Default, AlwaysShow). Code: `src/mods/lootfilter.cpp`,
page in `src/mods/options.cpp`. page in `src/mods/options.cpp`.
## What decides the category (read from the game) ## What decides the category (read from the game)
- Quest items (request 2026-10-25: "Quest Items are currently potentially
being destroyed, it needs to be top priority, also Löschen is not an option
for those"), checked first: the type's quest number (type table +0x1c) or
the scripts' QuestItem / MainQuestItem marks (+0x61 / +0x62; the script
parser 0x4584a9 turns those words into tokens 0x61 / 0x62), as the quest
book reads them (0x43ad80). The game's always-collect test `0x428550`
requires type flag 0x1000000 besides, so quest items without it fell into
their colour's category and could be voided. Their row has no *Löschen*
box; a `Void` in the ini is read as Default.
- Gold: `0x43ae20` (thiscall on the item type table, (type)): one of four - Gold: `0x43ae20` (thiscall on the item type table, (type)): one of four
types listed at `0x947920`. The labels show its amount, the collect mode types listed at `0x947920`. The labels show its amount, the collect mode
takes it without inventory space, the loot drop sets the amount after takes it without inventory space, the loot drop sets the amount after
@@ -31,9 +40,8 @@ page in `src/mods/options.cpp`.
`ffa0a0ff`, 12..14 yellow `fff0e040`, 15 brown `ffc19a33`; set green `ffa0a0ff`, 12..14 yellow `fff0e040`, 15 brown `ffc19a33`; set green
`ff15ff25`. (Without UNIQUE_COLOR, items with special bits get other `ff15ff25`. (Without UNIQUE_COLOR, items with special bits get other
colours from `0x5c1260`; the filter uses the tier regardless.) colours from `0x5c1260`; the filter uses the tier regardless.)
- Not filtered: runes (class 0x1c), objects with flag 0x800 and the types - Not filtered: runes (class 0x1c) and objects with flag 0x800 (the game's
`0x428550` reports (CanCollectItem always takes both; quest items, CanCollectItem always takes those).
presumably).
## Where it acts ## Where it acts
+65
View File
@@ -0,0 +1,65 @@
# Map reveal (fog of war)
## Request (2026-10-25)
"Can we increase the base vision range (for map fog of war reveal) by x10 or
so?"
## Measured
- Each sector (64x64 tiles) keeps a 32x32 bit mask at +0x180 (128 bytes, one
bit per 2x2 tiles): `Sector_SetExplored` 0x622bf0, copied out by 0x622c60,
loaded from the save by 0x622c80 (0x606ae0). The zoomed map view turns it
into its fog texture (FOGOFWAR01.TGA, 0x639030).
- `World_SetExplored` 0x638f00 (thiscall on `*0xad14e0`, (tile x, tile y,
explored), ret 0xc) finds the tile's sector; tiles >= 0x2000 and sectors
that aren't loaded are ignored.
- `RevealAroundHero` 0x599730 (creature thiscall (tile x, tile y, sight), ret
0xc), called from about ten places in the creature code: only for the local
hero, only when its tile changed (last tile 0xad2384 / 0xad2388), it calls
`World_SetExplored` for every tile of a stamp centred on the hero. Sight 0:
the 29x29 circle at 0x88f5cc (radius 14 tiles). The callers choose 1 or 2
from the weather object (0x417e50: +0x9e0c set -> 2, 0x41ba90 true -> 1);
those use the 15x15 stamps at 0x88f328 + (sight - 1) * 225 (radius 7, 5;
a third one, radius 3, is unused).
## Mod (`mods/mapreveal.cpp`)
After the original revealed at a new tile, the same stamp scaled up 10x is
revealed too, one cell (2x2 tiles) at a time through `World_SetExplored`: by
day a circle of about 140 tiles radius, at night / in fog 70 / 50. About 15000
cells per tile step by day. Sectors beyond the loaded ones are not marked.
## Creatures on the map (2026-10-25)
"Enemies on the TAB map don't show unless in very short range." The zoomed
map view (0x6cc950, large map and TAB map) draws a creature off explored
ground, or a hostile one, only within a sight range of the hero: |dx| and |dy|
(world units, about 53.7 per tile) against a float at [esp+14h], 650 by
default (0x6ce677), 300 where 0x63d280 holds for the hero's area (0x6ce6b5),
350 when the weather object's 0x41ba40 is false (0x6ce6cf), plus a skill
(0x355, 0x570d90) and equipment bonuses. Out of range only the debug flag
0x182cdb8 & 2 shows them. The mod scales the three base values by 10 as well
(about 120 tiles by day). The view only lists creatures of the sectors around
the hero (25 entries at [esp+1ech]).
## Border line on the large map (2026-10-26)
"The red outline on the mega map is choppy and feels weird."
Measured (disassembly): `cUI_Megamap::DrawVisitedBorder` 0x6c8580 drew a
2x2 px red dot (0xffff0000) per bit of a 100x100 byte grid (world +0xb73c):
unvisited sectors next to visited ones (sector flag 0x20000, set when the
hero's sector becomes the centre of the active 3x3, 0x626603), one bit per
visited neighbour, the dot 10 / 20 px from the sector origin towards it.
Built on load by 0x633230 and per sector change by 0x6332d0 / 0x6334f0. So:
dots in 64-tile steps, following the sectors stood in rather than the
explored mask the fog shows.
Mod (`mods/mapborder.cpp`): the draw is replaced. The sides between explored
and unexplored cells (2x2 tiles) of the 100x100 map sectors are chained into
polylines (clockwise around explored cells; at a corner touch the chain turns
right), projected with `WorldToMapPixel` (linear), simplified to 0.75 px
(Douglas-Peucker) and drawn as 2 px red lines. Rebuilt while the map is open
when the masks change (checked every 250 ms). Cells of missing sectors count
as neither explored nor unexplored, so no line runs along the world's edge.
+29
View File
@@ -132,3 +132,32 @@ the borders: screenshots 2026-10-20). One row *Autosave (Min.)*: Aus / 5 /
lighter one). A click selects, OK writes lighter one). A click selects, OK writes
`[characters] AutosaveMinutes` (`characters::SetAutosaveMinutes`, the timer `[characters] AutosaveMinutes` (`characters::SetAutosaveMinutes`, the timer
uses it at once), Cancel drops it. uses it at once), Cancel drops it.
## Allgemein: preferred monitor (2026-10-25)
Request: a setting for the monitor the game starts on; with none set, or the
preferred monitor not connected, the system's main monitor.
- `src/mods/monitor.cpp`: the monitors (`EnumDisplayMonitors`), named and
told apart through the display configuration (`QueryDisplayConfig`,
`DisplayConfigGetDeviceInfo`: the GDI name `\.\DISPLAYn` of each path's
source, the monitor's EDID name and device path of its target). The device
path is the id: it stays the same across restarts and when monitors are
rearranged, unlike `\.\DISPLAYn`. Kept in `sacredK.ini`
`[display] Monitor=` (empty: the main monitor).
- `GameMonitorRect` is asked where the screen is decided at startup: the
borderless window covers that monitor (`borderless.cpp`), and the render
size follows its aspect ratio (`widescreen.cpp` `ApplyRenderSize`). The
log line `monitor: <name> WxH at (x,y)` says which one was used and why.
A change applies at the next start.
- In the options (user's choice of place): the *Internet-Optionen* frame
(Datendurchsatz, multiplayer only) becomes *Bildschirm*. Its controls
(`UI_CFG_LANSPEED` label, DSL / ISDN radio buttons, everything inside
`UI_FRAME_INTERNET`) and the title label `UI_LABEL_WAN` are hidden; the
game keeps the saved Datendurchsatz. The title text (key `UI_CFG_WAN`) is
overridden with "Bildschirm" before the window is made.
- One row: *Monitor* and a box with the choice (Hauptmonitor, then each
monitor as "name (WxH)", "Nicht verbunden" for a preferred monitor that is
missing). A click opens the list upwards (the frame is the lowest one); a
click on an entry picks it, any click closes the list. A changed choice
shows "Ab dem nächsten Spielstart" below the box. OK saves, Cancel drops.
+278
View File
@@ -0,0 +1,278 @@
# Quest audit: how quests fail or get stuck (2026-10-30)
Request (2026-10-26, started 2026-10-30): "iterate through all quests in the
game, and check their conditions for potential failing or stuck up points
given the current implementation and our attempted reset". Done one quest at
a time from the compiled scripts with `tools/questscript.mjs` (full dump:
`<dir> <id>`; compact: `<dir> --facts <id>`).
Scripts: Ancaria per hero class `bin\TYPE_NPC_<class>\` (the game writes the
one in use to `bin\sgf.bin`; this audit reads `TYPE_NPC_GLADIATOR`, the class
variants differ in a few class quests), Underworld `bin\Addon\NetScriptCamp\`.
Ancaria: 603 quests in the table (87 + 13 side quests, ~37 main quests, 352
generic `DQ_` quests from 16 templates, multiplayer / unnamed sets);
Underworld: 190.
## What our mods do (the "reset")
- **M1 failure restart** (`questfail.cpp`, `Quest_End` hook): after
`LoseQuest` ran the quest's failure script (`QIS_OnLose<id>`), the flags go
back to available (1), the book entry stays failed with its compass on the
giver. Quest variables and NPC states are left as they are.
- **M2 giver death** (`questgiver.cpp`): a label of a running quest run for
its dead giver (the giver: the NPC spawned with a dialog that has the accept
button) is skipped, unless the giver is hostile to the hero (2026-10-30);
the quest restarts (M1 plus the variables only this quest writes: 0, the
one named after the quest: 1) and the giver's spawn instruction runs again
once the body is gone.
- **M3 load repair** (`questgiver.cpp`): a running quest whose giver is not
alive after loading gets M2, unless the quest morphs its giver.
- **M4 followers** (`followers.cpp`): escorts back on the hero's follower
list after loading; followers more than 40 cells away are teleported to the
hero.
## General findings
- **G1 M1 keeps the quest variables.** Givers' dialogs choose their branch by
bits of the quest variable (`IF 49(bit, "<id>")`); bits set while the quest
ran stay set after a failure, so the giver shows a "still running" branch
without the accept button and the quest can never be taken again. Example
702: the girl's death runs `lose702`; variable 702 keeps bit 1, the giver
answers "Qoffen01" with `ok702`. Fix: M1 resets the quest's own variables
like M2.
- **G2 generic quests (2600..3599)** are the game's random quest pool;
`Quest_End` already copies them under a new id. M1 should leave them (and
their runtime copies) to the game.
- **G3 one-time world progress.** Many targets are spawned once by the start
script or `Region<N>Init`, not by the quest. When M2/M3 reset the quest's
variables after such a target died, the progress is lost and the target
never comes back: stuck. Either keep the variables on M2/M3 or respawn the
quest's one-time NPCs (see G6).
- **G4 duplicates on re-accept.** `QIS_OnEnter` creates the quest's NPCs and
objects again; after a failure the old ones may still be alive.
- **G5 givers deleted by the script** (`DelNPC` on the giver): M3 restarts a
quest whose giver is gone on purpose.
- **G6 restart without the NPCs.** The restart is pointless when the NPCs the
quest needs are one-time spawns and dead or deleted. Idea: respawn them from
their spawn instructions like the giver.
- **G7 quests without a giver** (trigger areas): M1 "restarts" them and erases
the failed book entry.
- **G8 givers turned hostile by `SetNPCState ... 08 12`** instead of `Morph`
are not in `g_giverTurns`; M3 restarts those quests wrongly, M1 points the
book at an enemy.
- **G9 shared variables.** M2 doesn't reset quest variables another quest's
label also writes, so the respawned giver shows a running branch.
- **G10 giver killed after the quest ended** (by its own script): M2 doesn't
act, the giver stays dead.
- **G11 failure continues a chain.** Some failures are part of a quest chain
(`LoseQuest` together with `SetUpQuest` of the next quest, giver's dialog
moved on); M1 must not restart those.
- **G12 state in dialogs (Underworld).** Underworld quests keep their state
in the NPCs' dialogs; a failure sets a "done/lost" dialog (or none), and M1
doesn't put the accept dialog back (7614, 10260).
- **G13 named ids.** Spawns with named ids (`res:AUFTRAGGEBER_10254`,
`res:Sam1_Knappe`) or the id after the type argument aren't read by
`InstructionScriptId`; those givers aren't in `g_givers`.
- **V1/V2 vanilla bugs:** 1101's time-out never ends the quest; Res:17163 is
used by both 602 and 1202, and 602 deletes 1202's patient.
## Per quest
Columns: how it can fail (vanilla), what happens with our mods, verdict
(ok / G<n> / finding).
| Quest | Fails by | With our mods | Verdict |
|---|---|---|---|
| 151 Tierischer Wilddieb | never (no LoseQuest); wolf Res:17081 (start script, death `stirb151`: bit 2, triggers the quest) | M2/M3 on giver death: variable 151 reset to 1; if the wolf is already dead, bit 2 is lost and the wolf never respawns: stuck | G3 |
| 152 Botendienst | time limit (`SetTimer` 4320 -> `zeit152`: bit 4, `LoseQuest`) | M1: bits 1 + 4 stay, the giver answers "Qfail" / "Qpostfail" without the accept button: stuck. With G1 reset: offered again; re-accept spawns the parcel (Res:6027) and later the recipient Res:17077 again (the old recipient stays, dialog off) | G1, G4 |
| 154 verschwundener Schmied | never | the script deletes the giver Res:17626 (`talkedbride154`, `take154`): after the next load M3 restarts the running quest and spawns the giver again | G5 |
| 201 Vermisste Tochter nach Ausritt | daughter Res:18006 dies (`Tochtertot201`: bit 8, `LoseQuest`); she is spawned once (`Region2Init`) | M1: bit 8 stays, giver answers `okt201` then `ok201`: stuck. With G1: offered again, but the daughter stays dead: cannot be finished | G1, G6 |
| 202 Eskorte für Händler | merchant Res:18012 (the giver) and bodyguard Res:18013 both dead, or the bodyguard alone at the bridge | the merchant's death is M2 (restart, respawn; his dialog has no bit tests); the bodyguard is not needed | ok |
| 203 Schatzkarte | never | giver death -> M2; re-accept creates map + chest again (old chest stays) | ok (G4) |
| 204 Die Bücher der Gelehrigkeit | never | giver death -> M2; the boss and the book come from `QIS_OnEnter204`, so a restart can be redone | ok |
| 301 Orks auf Bauernhof | time limit (`SetTimer` 1440 -> `lose301`); farmer Res:17033 and daughter Res:17032 both dead | no giver (trigger area, deleted on start); `QIS_OnLose301` deletes both NPCs. M1 makes it available but nothing can start it again, and with no known giver its failed book entry is erased | G7 |
| 302 Von Räubern und Rindern | six cattle dead (`302a` = 6, `LoseQuest`); the giver Res:17029 (escorted) dies (`tot1_302`) | giver death -> M2 (but `302a` is also set by `startcode`, so it is not "only this quest's" and keeps its count). Cattle failure -> M1: the dialog's `48(6,"302a")` branch sets bit 3 -> "ok" forever: stuck, also with G1 unless G1 counts start-script initialisation as the quest's own. Re-accept spawns the six cattle again | G1 (start-script variables), G4 |
| 303 Bodycount Orks | never; ogre Res:17031 is a one-time spawn (`Region3Init`, death `tot303`: bit 2) | as 151: giver death resets bit 2, a dead ogre never returns: stuck | G3 |
| 401 Jagd auf wildernde Wölfe | never; wolves one-time (`Region4Init`, death `stirb401`: bit 2) | as 151 | G3 |
| 402 Kräuter für Kräuterhexe | never | herbs from a permanent `MouseEvent` (`Region4Init`), can be picked again after M2 | ok |
| 403 Kühe vor Wölfen retten | all cows dead (bit 4), then the giver's answer runs `lose403` | M1: bits 4 + 5 stay, giver "ok403" forever: stuck. With G1: offered again, but the cows (`Region4Init`) are gone | G1, G6 |
| 501 Geister auf Landsitz | never by `LoseQuest`; choice `change501` / `nochange501` | `postchange501` turns giver Res:17071 and Res:17070 hostile with `SetNPCState ... 08 12` (not Morph): M2 sees the hostility at death, but M3 after a load would restart the quest (only Morph is known as "turns") | G8 |
| 502 Verstecktes Waffenlager | never | enemies and boss from `QIS_OnEnter502` (variant by `Globalchange1`): redoable after M2 | ok |
| 503 Botschaft an Geliebte | never | giver Res:17105's death label `weg503` belongs to no quest (M2 does not react); not needed: the quest ends at the lover Res:17104 | ok |
| 601 Kunstraub von Händler | never | thief Res:17135 gives the item by dialog; redoable after M2 | ok |
| 602 Wegelagerer | never | the knight Res:17136 is beaten, not killed (`ritter602` sets his HP back); after M2 his dialog offers the fight again | ok |
| 603 Entlaufener Bräutigam | never | the groom Res:17140 is beaten (`braut603` heals him), the fight can be repeated after M2 | ok |
| 701 Vergifteter Brunnen | never (`lose701` is not called) | boss Res:17063 one-time (`Region7Init`, drops the items); M2 resets bit 20 (item taken): stuck if the boss is dead | G3 |
| 702 Mädchen bei Ausritt verschwunden ("Familienbande") | the girl Res:17060 dies (`lose702`) | giver turned hostile and killed: handled (7ec0fa2). Girl's death -> M1: bit 1 stays, giver "Qoffen01": stuck; with G1 redoable (the girl comes from `QIS_OnEnter702`) | G1 |
| 703 Objektschutz Wegbrücke | leaving the bridge area / region before the bandits are dead (`zeit703`: bit 4), then the giver's answer runs `lose703` | M1: bits 4 + 5 stay, giver "ok703": stuck. With G1: redoable (bandits not all dead, else the quest would have been won) | G1 |
| 801 Holzfällerstreit mit den Elfen | the giver Res:17144 dies (`tod801` -> `lose801`) | giver death -> M2 (restart, respawn) instead of the failure | ok |
| 802 Königliche Jäger Verschollen | never | hunters, herbs and amulet are one-time (`Region8Init`); M2 resets 802 (progress bits): stuck when they are used up | G3 |
| 803 Kopfgeldjagd | never | targets one-time (`Region8Init`). On giver death M2 does not reset `803` (quest 801's elf dialog also sets a bit in it), so the respawned giver shows the running branch without accept and the flags say "available": stuck | G3, G9 |
| 901 Soldaten verschleppen Frauen | time limit (`zeit901`, also kills quest 902's women); the women Res:17051..17054 die (`lose901`: bit 5), then the giver's answer runs `lose901` | M1: bit 6 stays, giver "ok901": stuck. With G1: redoable after the time limit; after the women died they are gone (`Region9Init`) | G1, G6 |
| 902 Rache an Reichem Händler | never | boss Res:17049 one-time (`Region9Init`, death `tot902`: bit 2) | G3 |
| 903 Robin Hood | never | Res:17045 is beaten and healed (`re903`), the choice can be made again after M2 | ok |
| 1001 Übergriffe auf die Bevölkerung | never | boss Res:17041 one-time (`Region10Init`, drops the proof) | G3 |
| 1002 Spionage | seen by the spy's group (tag 0x60 `lose1002` on the NPCs from `QIS_OnEnter1002`: bit 4, `LoseQuest`) | M1: giver sets bit 5, "ok" forever: stuck. With G1 redoable; the old group (now hostile) stays next to the new one | G1, G4 |
| 1003 Schlägertypen | the giver Res:17034 dies (`verreckt1003`); seven villagers die (bit 4), then the giver's answer runs `lose1003` | giver death -> M2. Villagers -> M1: bit 5, "ok" forever: stuck. With G1 redoable (the attackers come from the accept trigger again) | G1 |
| 1101 Sektenpriester opfern Jungfrauen | the virgin Res:17112 dies (`Verloren1101` -> `lose11a1`); time limit (`Zeitum1101`: demon instead of the virgin, bit 3) | death -> M1: giver sets bit 5, "ok": stuck; with G1 redoable (all from `QIS_OnEnter1101`, old priests stay). Time limit: vanilla never ends the quest (the giver's bit 3 branch has no `LoseQuest`): running forever | G1, V1 |
| 1102 Kompromittierende Situatuion | time limit (`Zeitum1102`: bit 3, `LoseQuest`) | M1: bit 1 stays, giver "Ok1102": stuck; with G1 redoable (the lady and the letter come from `QIS_OnEnter1102`). After the time limit the lady's bit 3 answer kills the giver (`SetHP 0`); the quest is no longer running then, so M2 does not respawn him | G1, G10 |
| 1103 Verhandelnder Räuber | never | the robber Res:17118 is beaten and healed (`tot1103`) | ok |
| 1111 DeModreys Steuerpresser | the escorted Res:17121 dies (`lose1111` -> `geld1111`: bit 5, `LoseQuest`) | M1: giver sets bit 6, "ok": stuck. With G1: the boss Res:17123 is one-time (`Region111Init`) and `geld1111` deleted his proof Res:6051: cannot be finished | G1, G6 |
| 1112 Animagus terrorisiert Bauernhof | the spared Animagus Res:17127 dies (`ex1112` with bit 4: `LoseQuest`) | M1: bit 4 stays, the giver's answer then runs `postchange1112` (turns the giver hostile) instead of an offer. With G1: the Animagus is one-time (`Region111Init`). `postchange1112` turns the giver hostile by `SetNPCState` (M3 after a load: G8) | G1, G6, G8 |
| 1113 Elfischer Kunstraub | never | thief Res:17129 and the goal object Res:6052 one-time (`Region111Init`) | G3 |
| 1201 Sekte beschwört kleine Dämonen | never | cult one-time (`Region13Init`, group dead: bit 2) | G3 |
| 1202 Heilmittel für Händler | time limit (`zeit1202` kills the patient Res:17163, `LoseQuest`) | M1: giver sets bit 5, "ok": stuck. With G1 the patient (`Region13Init`) is dead: cannot be finished. Vanilla: quest 602's `ok1_602` / `ritterweg_602` walk away and delete `res:17163` too (the same id): finishing 602 removes 1202's patient | G1, G6, V2 |
| 1203 Der Richter und sein Henker | never | judge Res:17166 and executioner Res:17167 one-time (`Region13Init`) | G3 |
| 1301 Entlaufener Gladiatoren-Minotaurus | never | the giver's death label is the quest's end (`exit1301`): `attack1301` turns him hostile (M2 lets it run since 7ec0fa2), the quest ends at once (no M3). Minotaur from `QIS_OnEnter1301` | ok |
| 1302 Das Soldaten-Tunier | timer → `lose1302` | after M1 bits 4/5 and `1302a` stay (G1); opponent Res:17173 is deleted by `attacke1302_1` and created only by region init (G6). Vanilla typos: `DelNPC res17173`/`res17174` (no colon), `attacke1302_2` attacks 17174 | G1 + G6 |
| 1303 Händler - legaler Warentransport | timer → `lose1303` | after M1 bits 3/4 stay (G1); re-accept creates recipient Res:17177 again (G4) | G1 + G4 |
| 1401 Händler - Warenschmuggel | the side chosen dies (`lose1401`) | after M1 bits 1-6 and `1401a`/`1401b` stay (G1); re-accept creates Res:17180/17181 again (G4). Vanilla: soldier's death label `attack1401` doesn't exist (`attacke1401`) | G1 + G4 |
| 1402 Junge Dame vor Übergriffen eines Offiziers schützen | never | officer and soldiers from `QIS_OnEnter1402` | ok |
| 1403 Eskortiere Händler zu geheimen Treffen | recipient after the escorted giver died (`losequest1403`) | giver death is caught by M2 first (restart, giver back); re-accept creates recipient Res:17187 again (G4). Vanilla typo `ok1024` | G4 |
| 1404 Eskortiere Dame zu Treffen mit Geliebten | the lady dies (`lose1404`) | after M1 bits 1/4 stay (G1); lady Res:17189 and boss Res:17190 are one-time (region init), dead lady makes a restart pointless (G6) | G1 + G6 |
| 1405 Sekte von Dämonenanbetern | never | sect leader Res:17195, cultists and the three captives are one-time (region init); M2 reset after the leader died loses the progress | G3 |
| 1501 Menschlicher Wilderer | never | the giver follows; vanilla his death (`ex1501`) lets the quest go on, M2 restarts it and brings him back instead. Re-accept creates the poachers again (G4) | M2 changes vanilla + G4 |
| 1502 Hetzjagd | a label deletes the giver Res:17015, then `losequest1502` | the giver is spawned once by trigger area `hetz1_1502` (deleted after use); after M1 the book points at a deleted giver and nothing spawns him again (G6). Vanilla: his death label `lose1502` doesn't exist | G6 |
| 1503 Arak-Attack | never | spiders and the target from `QIS_OnEnter1503` | ok |
| 1601 Kunstraub | never | thief from `QIS_OnEnter1601` | ok |
| 1602 Attentäter abfangen | the target dies (`target1602`) | after M1 bit 4 stays (G1); target Res:17205 and assassin Res:17201 are one-time (region init), the target is dead (G6) | G1 + G6 |
| 1603 Blockadebrecher | never | | ok |
| 1701 Getrennte Liebende | the lover dies (`lose1701`); afterwards the giver turns hostile and fails it again (`ende1701`) | after M1 bit 6 stays, the giver's dialog runs `ende1701` (G1); lover Res:17209 one-time (G6); `ende1701` makes the giver hostile by `SetNPCState 08 12`, M1 then points at an enemy (G8) | G1 + G6 + G8 |
| 1702 Korruption | never | official Res:17215 one-time (region init) | G3 |
| 1703 Unschuldiger am Galgen | judge after the time-out (`lose1703`) | after M1 bits 6/7 stay (G1); victim Res:17218 and boss Res:17221 are deleted by `zeit1703`, the victim is one-time (G6). `lose1703` kills the giver by `SetHP 0` on purpose; M2 skips his death label `less1703` and brings him back | G1 + G6, M2 changes vanilla |
| 1801 Monströsere Wilderer | never | poachers one-time (region init) | G3 |
| 1802 Dunkle Priester beschwören Dämon | never | priests from `QIS_OnEnter1802` | ok |
| 1803 Dunkelelfengift | time-out (`zeitum1803`) | after M1 bit 4 stays, the giver's dialog runs `losequest1803` again (G1); boss Res:17229 one-time (region init), once killed the poison can't drop again (G6) | G1 + G6 |
| 1901 Rache an bösem Anführer in Dungeon | never | boss Res:17232 one-time (region init) | G3 |
| 1902 Monster haben Item verschleppt in Dungeon | never | monster Res:17020 one-time (region init) | G3 |
| 1903 Entlaufene 'Tochter' | the girl dies (`lose1903`) | after M1 bit 5 stays, the giver's dialog morphs him into the monster at once (G1); girl Res:17237 one-time (G6) | G1 + G6 |
| 2001 Untote Kühe auf Wiesen | never | boss Res:17239 spawns on the 4th trigger area; `2001a` starts at 1 from the start script, a reset to 0 matches no branch of `move2001` and the boss never comes (G1 start-script variable); trigger bits in `2001` | G3 (start-script variable) |
| 2002 Kämpfe gegen Wilde Monster | never | repeatable arena, waves from `monster2_2002` | ok |
| 2003 Anführer der Dämonensekte | never | the giver is the cult leader: `QIS_OnEnter2003` makes him hostile by `SetNPCState 08 12` (G8, M3 would restart). His death runs (hostile, 7ec0fa2). Vanilla typo `res:174242` | G8 |
| 2101 Hands of my Dragon | never | chain with 2102/2103 (and 2303's egg) on dragon Res:17245 (one-time); prince Res:17246 and his guards from `QIS_OnEnter2101`. Shared variables `riesendiamant`/`drachenei` are written by several of these quests (G9 if one is restarted) | ok |
| 2102 Drache - Zahnstocher | never | prince is the giver; boss Res:17249 from `QIS_OnEnter2102` | ok |
| 2103 Return for Refill | never | knights created one after the other by `ziel2103` | ok |
| 2201 Kuss der Spinnefrau | the girl dies (`lose2201`) | after M1 bits stay (G1); girl Res:17283 one-time (G6); `postchange2201` turns the giver Res:17281 hostile by `SetNPCState 08 12` (G8) | G1 + G6 + G8 |
| 2202 Narzisstische Zauberin | never | `change2202` turns the giver Res:17255 hostile by `SetNPCState 08 12` (G8) | G8 |
| 2203 Hinterhältiger Giftmischer | never | poisoner Res:17260 and the six barrels (counter `2303a`) are one-time (region init) | G3 |
| 2301 Besorge Reagentium | never | bush is a fixed MouseEvent, item re-created in OnEnter | ok |
| 2302 Larbortiere | never | the four monsters are one-time (region init), counted in `2302b` | G3 |
| 2401 Botschaft für Scouts | never | chain 2401-2404 on giver Res:17268 (his death label `mann2404` belongs to 2404); scouts one-time (region init), moved/morphed by the quest | G3 |
| 2402 Verlaufene Sielder finden | the settler dies after joining (`losequest2402`) | the failure continues the chain (`SetUpQuest 2403`, giver's dialog set to 2403); M1 restarting 2402 is wrong (G11) | G11 |
| 2403 Verschleppte Sielder befreien | all settlers die (`losequest2403`) | like 2402: the failure continues the chain with 2404 (G11) | G11 |
| 2404 Verteidige Lager | never | vanilla the giver's death (`mann2404`) hands the reward to Res:17280; M2 restarts and brings him back instead | M2 changes vanilla |
| 2411 Das Orkische Relikt | never | relic from `QIS_OnEnter2411` | ok |
| 2412 Der Feige Hund | never | dog Res:17584 one-time (region init) | G3 |
| 2413 Kleine Wuselorks | never | all from `QIS_OnEnter2413` | ok |
| 2501 Finde Drachnschuppe für Rüstung | never | dragon Res:17292 one-time (region init); counts towards `finale` | G3 |
| 2502 Eierdieb | the time-out, reported to the giver (`losequest2502`) | the failure counts towards the region's `finale` (bit 2 + `finalestart`) like a success (G11); after M1 bits 4/5 stay (G1) | G11 |
| 2503 Drachentöter vertreiben | never | dragon slayers one-time (region init) | G3 |
| 2504 Rette Jungfrau | the maiden (= giver) dies (`lose2504`) | M2 catches her death first: restart and respawn instead of failure + `finale` bit; redoable | M2 changes vanilla |
| 9003 Versteckter Händler | `rg3_goodie1_lose` (merchant's death) | progress lives in the shared variable `goodies` (8 quests), M1/M2 can't reset it (G1 + G9): after M1 bit 1 stays and the dialog offers the reward branch (`ExitQuest`) without the quest running | G1 + G9 |
| 9004 Vermisstes Kind | `rg1_goodie_lose` (child's death) | `goodies` bit 4 stays, the giver only answers "ok" (G1 + G9); child Res:17513 one-time (G6) | G1 + G6 + G9 |
| 9005 Der Waldläufer | never | | ok |
| 9006 Der unbekannte Krieger | never | progress in shared `goodies` | ok |
| 9007 Jacquaire's Waffen | never | guards from `QIS_OnEnter9007` | ok |
| 9008 Furchtbares Verbrechen | escorted giver Res:17539 dies (`losequest9008`) | M2 catches the death (restart, respawn); `QIS_OnEnter9008` sets the trigger area again | M2 changes vanilla |
| 9009 Whiskeylieferung | never | | ok |
| 9010 Vergrabene Sklaven | escorted giver Res:17575 dies (`lose9010`) | M2 restarts; but `9010` is a counter (0 = offer, needs key Res:17580 in the inventory), M2's reset to 1 shows the "follow" branch, and `QIS_OnEnter9010` deleted the key, so it can't be accepted again (G6) | G6 |
| 9011 Jacquaires Vergangenheit | never | boss and target from `QIS_OnEnter9011` | ok |
| main quests 1-81, 670 | never | started by trigger areas (`SelfTriggerQuest<N>`), no giver, so M1-M3 don't touch them; 31's giver is only set by `SetNPCState` (not in `g_givers`) | ok |
| 65 Trockenlegung durch Zwergenstaudamm (main) | never | giver Res:18306 (Torfstecher, start script) is in `g_givers`. Vanilla his death leaves 65 unfinishable (61 is activated by `Exit65`); M2 brings him back, but resets `65` incl. bit 5 (pump switched by `SwitchPump65`); if the pump can't be used twice the quest is stuck (G3). Re-accept creates the rune Res:17569 again (G4) | G3 + G4 |
| 210 Verschleppte Novizinnen in Räuberöhle | never | boss Res:17083 one-time (start script) | G3 |
| 9000 Das Untotenlager | the shaman's drop is picked up while the quest runs | refusing makes the giver Res:17500 attackable by `SetNPCState 08 14` (not Morph, G8); killing him drops the item whose label opens the Khorad Nur gate (`ablehnensieg_goodie_wueste`). If M2 sees him as not hostile it skips `drop_goodie_wueste`: **no item, the gate (main quest 55's way) never opens**. Check `IsHostileTo` for `08 14` | G8, critical |
| 5003 Opferritual | the hero refuses at the end (`nein5003`) | a story choice: the shaman Res:17699 and his tribe turn hostile (`SetNPCState 08 12`) and the quest fails. M1 restarts it with bit 6 kept, so the (now hostile) shaman would offer the choice again (G11 + G8) | G8 + G11 |
| 5005 Pony Express | a time limit runs out (`losepe`) | `losepe` deletes the giver Res:17753 (spawned once by main quest label `NachOrkland55`) and all stations; after M1 the book points at nobody and the quest can't be taken again (G5/G6); `5005c` stays (G1) | G1 + G5 |
| 6001 Die Invasion der Eiselfen | all elves of the camp die (`od_welfen6001`) | after M1 bits stay (G1); the elves (incl. giver Res:17706) are one-time, dead (G6); re-accept creates the invaders again (G4). Accept label starts with `SetNPCState`, so the giver isn't in `g_givers` (no M2/M3) | G1 + G4 + G6 |
| 6002 Das Orklager | never | | ok |
| 6003 Elfenbeschützer | the escorted elf Res:17750 dies (`lose6003`) | after M1 bit 3 stays (G1); the elf is one-time (G6); her dialog is set by `SetNPCState`, so she is probably not in `g_givers` (no M2) | G1 + G6 |
| 2600-3599 generic quests (16 templates × 22 regions) | `DQ_RG<n>_..._LOSE_<id>` (and `_ABORT_` for escorts) | no giver (runtime-assigned NPC), so no M2/M3; M1 restarts them although `Quest_End` already copies them (G2) | G2 |
### Underworld (`bin\Addon\NetScriptCamp`)
Underworld quests keep their state in the NPCs' dialogs (`SetNPCState ...
dialog"dlg_<npc>_<id>_<step>"`), rarely in variables, and most givers are
spawned by `QIS_OnSetUp<id>` (run from the start script) without a dialog.
| Quest | Fails when | Finding | Class |
|---|---|---|---|
| 7612 Eskorte | escorted Nanoduk Res:19547 dies (`od_nanoduk`) | he is the giver and one-time (`QIS_OnSetUp7612`); after M1 nobody offers the quest (G6) | G6 |
| 7614 Vermisstes Kind (Underworld) | the child Res:19549 dies (`od_erdriac`) | the giver's dialog is set to the "child is dead" step `_d` (only "ok"); M1 doesn't restore the accept dialog `_a`, so it can't be taken again (G12). The child comes from `QIS_OnEnter7614` | G12 |
| 7617 Eskorte in den Süden | escorted giver Res:19586 dies (`od_7617`) | he is spawned with his accept dialog, so M2 catches his death: restart and respawn | M2 changes vanilla |
| 7619 Tod den Toten | the giver Res:19545 dies (`od_threepwood_7619`) | his accept dialog is only set by `SetNPCState` in `QIS_OnSetUp7619` (not in `g_givers`); after M1 he is dead (G6) | G6 |
| 9518 Unter der Herrschaft von Dämonen | the mother Res:19530 dies while following (`od_lose9518`) | after M1 bits 1/2/4 stay, the giver only answers "ok" (G1); re-accept creates mother and demons again (G4) | G1 + G4 |
| 9520 Cookies Quest | the hero gives the cookies to Von Endlich, then the girl ends it (`btn_FinishLose9520`, gold for experience) | a story choice (G11); after M1 bit 4 stays, so the girl offers `btn_FinishLose9520` again and again (repeatable gold-for-experience trade) | G11 |
| 10254 Der Hilferuf | the giver dies (`od_Auftraggeber_10254`) | the giver is spawned once when the letter is picked up (`ot_Brief_10254`); after M1 he's dead and the letter gone (G6). The 10250-10261 givers have named ids (`res:AUFTRAGGEBER_10254`, after the type argument), which `InstructionScriptId` doesn't read: none of them is in `g_givers` (G13) | G6 + G13 |
| 10255 Panik vor Spinnen | never (its "lose" is a label name; the giver offers a restart himself) | | ok |
| 10260 Das Rätsel in der Wüßte | the wrong chest is opened (`fillchest_chestQJ_0x`) | the giver's dialog is removed (`0a#0`); after M1 nobody offers it (G12) | G12 |
| other Underworld quests (main 11-56, 7600-7630, 9511-9522, 9900, 11101-11110, 15001-15084) | never | main quests have no giver (untouched). Side quests: givers spawned with their accept dialog in `QIS_OnSetUp<id>` are in `g_givers` (7600, 7601, 7605, 7609, 7614, 7617, 7618, 7620, 7622-7624, 7626, 7630, 9511, 9513, 9515, 9518, 9520, 9521); a chain's later quests on the same giver (dialog by `SetNPCState`) are not. Their targets also come from `QIS_OnSetUp` (one-time), so M2 after a target died loses progress only where variables hold it (G3, rare here); named ids (`res:Sam1_Knappe`, `res:AUFTRAGGEBER_…`) are skipped (G13) | ok / G3 |
## Summary and proposed fixes
Counted over the side quests (Ancaria 151-2504, 9003-9011, 210, 65, the
5003-6003 set and the Underworld quests that can fail): about a third are
fine, the rest fall into the classes above. In order of impact:
1. **G8 critical, 9000** (Khorad Nur gate): verify by a log whether a creature
set by `SetNPCState ... 08 14` is hostile to the hero (`IsHostileTo`). If
not, M2 skips the shaman's death label and the gate never opens. Fix
either way: treat a giver as "turned" when any of its quest's labels set
`08 12`/`08 14` on it (extends `ScanGiverTurns` beyond Morph); that also
fixes M3 for 1112, 1301, 1701, 2003, 2201, 2202, 501, 5003.
2. **G1 reset on failure:** M1 resets the quest's own variables like M2
(including the ones only start/region scripts initialise, with their
initial values, e.g. 302a, 2001a = 1). Fixes most "giver only says ok"
cases (152, 702, 703, 1002, 1003, 1302, 1303, 1401, 1404, 1602, 1803,
1903, 9518, ...).
3. **G11 failures that are part of a story or chain** (2402, 2403, 2502,
5003, 9520): M1 must not restart a quest whose failure label also calls
`SetUpQuest`/`finalestart`, or sets the giver hostile.
4. **G2 generic quests:** M1 leaves 2600-3599 (and their runtime copies) to
the game.
5. **G6 restart without NPCs / G3 lost progress:** the bigger design
question. Options: (a) on restart, respawn the quest's one-time NPCs from
their spawn instructions in start/region scripts (like the giver), (b) keep
the variables on M2/M3 (no progress loss, but the giver may answer with a
running branch), (c) don't restart quests whose needed NPCs are gone and
leave them failed. Recommendation: (a) for NPCs the quest's dialogs or
death labels name, else (c).
6. **G4 duplicates:** before re-running `QIS_OnEnter`, delete the quest's
still-alive named NPCs/objects it is about to create.
7. **G5/G7** (154, 301, 1502, 5005): M3 skips quests whose labels `DelNPC`
their giver; M1 skips quests without a giver.
8. **G9/G12/G13** (803, 9003/9004 `goodies`, 7614, 10260, named ids): lower
priority; G12 needs the accept dialog put back (it is in the giver's spawn
instruction or `QIS_OnSetUp`).
## Fixes (2026-10-30)
Request: "all, but leave 1, as i cant play for now" (2-8 above; G8 waits for a
measurement in the game). Not yet tested in the game.
- `mods/questscript.cpp` (new) reads the script code once and keeps, per
quest: the giver (spawned with the accept dialog, else given it by an NPC
state instruction: the Underworld's `QIS_OnSetUp<id>`), its variables (set
only by its labels, plus the one named after it: G9), what `QIS_OnEnter<id>`
creates, the NPCs it needs (its labels set their state, don't create them),
whether it morphs or deletes its giver, and the failure labels that end its
story (`LoseQuest` with `SetUpQuest`/`ActivateQuest`, experience or gold, a
call into another quest's label, or an NPC offering the quest turned hostile
or set to 0 hit points: 1701 `ende1701`, 1703, 2402, 2403, 2502, 2504, 5003,
9520). It also keeps the labels running (for `Quest_End`) and spawns NPCs
once their bodies are gone.
- `questfail.cpp`: at `Quest_Trigger` (first time) the quest's variables are
kept ("questVariables" with the hero). A failed quest stays failed when it
is a generic quest or a copy (G2), has no giver (G7), its failure ends the
story (G11) or its giver / an NPC it needs is gone without a spawn
instruction (G5/G6). Else the restart also: resets its variables to the kept
values (G1; without them: 1 for the one named after the quest, 0 else),
deletes what `QIS_OnEnter` created (G4), spawns dead needed NPCs and the giver
again (G6, from region start labels, another quest's label or
`QIS_OnSetUp`), and gives a living giver the accept dialog again (G12).
After a load, failed quests restarted earlier get their variables reset
(repairs saves stuck by G1).
- `questgiver.cpp` uses the same scan; the load repair skips quests that
delete their giver (G5); the giver comes back through `questfail::Restart`.
- Not done: G8 (fix 1), G3 for progress held in one-time objects (2203's
barrels), G10, G13 (named ids such as `res:AUFTRAGGEBER_10254`: the game's
name table is unknown, those givers can't be checked or respawned).
+309
View File
@@ -0,0 +1,309 @@
# Quests
## Quest data (measured 2026-10-17 / 2026-10-25)
- Quest table `g_quests` (vector begin/end `0xaa9e98` / `0xaa9e9c`), 0x124
bytes per quest: id +0, internal name +4 (ANSI), scripts +0x10c (run on
trigger), +0x114 (one-time, cleared after it ran), +0x118 success end,
+0x11c failure end (only quests with one can fail), flags +0x120:
1 triggered, 2 done, 4 in progress, 8 busy (while the trigger runs its
scripts). `game/engine/quests.h`.
- Quest manager: static object `0xaaaf00` (script interpreter). Quest book
vector +0x424..+0x428, plain 0x174-byte entries (serialized field by field):
state +4 (2 active, 100 solved, 101 failed), quest id +8, map position
+0x14..+0x20; per player (16) a selected entry index at +0x3a8 + player * 8.
- Script opcodes (keyword table `0x452540`): LoseQuest `0x36` and ExitQuest
`0x0f` -> `0x48f110` -> `Quest_End` `0x46ced0` (thiscall (index, ?,
success), ret 0xc; LoseQuest success 0, ExitQuest 1). QuestDone `0x0a` tests
flag 2, QuestInProgress `0x0c` flag 4.
- `Quest_Trigger` `0x46de10` (cdecl (index, ?, ?, ?), plain ret): does
nothing if flag 1 is set; else sets 1 | 8, runs +0x114 (then cleared) and
+0x10c, clears 8. Talking to a giver triggered a quest (flags 0 -> 1, no
book entry yet); accepting it set flag 4 and added a book entry, state 2.
- `Quest_End`: if the quest is in progress and not done, runs the end script
(success / failure) and sets done (flags 3); the book entry's state becomes
100 / 101. Quests with ids 2600..3599 (the generic "Bringe NPC zu ...",
"Eskortiere ...", ...) are copied to the end of the table under a new id
(largest + 1, at least 10000): the game repeats those itself.
- Compiled quest scripts: `bin\sgf.bin` (labels such as `LoseQuest_10255`,
texts `res:...`; random side quests use `DQ_` names, time limits
`DQ_ZEITFENSTER_TAGE`).
- Example (Gladiator, Silber, 2026-10-25): failed Hetzjagd 1502, Pony Express
5005, Rette Jungfrau 2504, Eskortiere Händler zu geheimen Treffen 1403, all
flags 3, book state 101.
## Failed quests restartable (request 2026-10-17)
A failed quest is not final: talking to the quest giver again offers it
again. `mods/questfail.cpp`: after `Quest_End` with success 0 (its failure
script has run) the quest's flags 2/4/8 are cleared and flag 1 is kept
(book entry: see below); quests failed before the mod are reset once
after loading. Confirmed in the log (the four quests above reset, 2026-10-25);
the giver offering them again is still to be tested in the game.
2026-10-26: the restart first cleared flag 1 as well. Measured: a quest's
script labels, its giver's dialog too, run only while flag 1 is set
(Script_RunLabel `0x46bf11`; "Dialog faild" in the game log otherwise), and
accepting it (opcode 0x14, `0x48f320`) needs it too; the game sets it when the
hero enters the quest's region (`Quest_Trigger`). A restarted quest is now
"available": flags 1.
2026-10-30 (quest audit, `docs/quest-audit.md`): a restart also resets the
quest's variables, deletes what its `QIS_OnEnter` created, brings back the
giver and the NPCs it needs, and gives the giver the accept dialog again; some
failures stay final (generic quests, no giver, story endings, NPCs gone for
good). The script facts come from `mods/questscript.cpp`.
Marker back to the giver (request 2026-10-25: "quests that failed, should
have the blue marker point back to the quest giver"):
- Measured: the maps draw a marker for every book entry at its compass cell
+0x18/+0x1c, colour from `0x4975d0` (thiscall (entry, kind), ret 8): open
side quest `ff006cff` (blue), main `ffffff80`, solved green, failed red.
The compass target +0x10/+0x14 is set by the scripts' QuestkompassPos
(`0x49a7a0`: a cell, radius +0x20 "rad:") and QuestkompassOBJ
(`0x49af70`: 0xffffffff + object id, or 0xeeeeeeee + script NPC id);
`0x4a6740` resolves it into +0x18/+0x1c. The game keeps no giver per quest.
- A quest is accepted in a dialog answer (`cDialogManager::Answer` 0x49a090 ->
quest script -> `QuestManager_AddBookEntry` 0x46c450). The mod keeps the map
cell of the NPC nearest to the hero then (within 400) as the giver, saved
with the hero data (`questGivers`: quest id -> cell, merged from save game
and hero file; quest ids and givers are the same in every world).
- On failure the book entry stays (state 101) with its compass on the giver
and is drawn blue; without a known giver it is removed as before (all
quests that failed before this change). Accepting the quest again removes
the failed entry.
## Quest book (measured 2026-10-25)
- Book entries (quest manager +0x424, 0x174 bytes): kind +0 (3 main quest,
4 side quest: ids 1..99 are main quests, 0x46c450), state +4, quest id +8,
title / text ids +0x24 / +0x28, progress log text ids +0x2c.., map position
+0x18 / +0x1c (0x497830), chapter +0x16c (u16). A new entry (0x46c450,
thiscall (quest index, ?), ret 8, called by the script interpreter
0x48f602) copies its chapter from the player's current main quest entry
(+0x3a4 + slot * 8; selected entry +0x3a8 + slot * 8, read by 0x49dd40).
- cUI_Book (one per book mode, 0x7a8[mode] of the book window; quest mode
+0x166 = 0): list builder 0x6b04c0 (thiscall, from the rebuild 0x6b1670),
page-full check 0x6b1a70 (thiscall (tab, page), ret 8), render 0x6b27f0
(thiscall (renderer, bookmarks only), ret 8: the left page's rows, then the
selected row's detail record on the right, 0x6b1cd0 thiscall (renderer,
record, sub-page), ret 0xc), events 0x6b3620 (thiscall (event), ret 4).
Current tab +0x15e, page +0x162; per tab pages +0x97c + tab * 0xc (0x1c
bytes: title text +0, selected row +4, rows +0x10) and detail records
+0x9d0 + tab * 0xc (0x40 bytes). Row (0x14 bytes): u16 flags (4 spacer,
0x40 done, 0x80 entry flag 1), title +4, detail record +8, book entry +0xc,
icon +0x10. Act tabs: chapter text table 0x9de7e4 (9000 "Pfade des
Schicksals", 9001 "Unter blutigen Bannern", 9002 "Die rastlosen Toten",
9003 "Zeitenwende"); bookmarks: 7 controls at +0x168, 0xbc apart.
- The book's list and the quest scripts run on the same thread (logged).
- Quest table (Gladiator, Silber): 28 main quests, 87 side quests in id
blocks of 100 (1xx .. 25xx, 2 to 7 each; their accepted quests lie together
on the map), about 120 other side quests (5xxx, 6xxx, 9xxx, 102xx, 15xxx),
352 generic ones (2600..3599) and bookkeeping entries (0, 99 "MP-Start",
5081..5089 "Region 1 befreit").
## Quest book as one list (request 2026-10-25)
"Show all quests, unknown ones as ???, so a player knows how many quests an
area holds"; then: "get rid of the tabs ... a section for story, and then a
section per region", the game's page turning kept. `mods/questbook.cpp`: the
game's builder runs on a reordered copy of the book (story, then a section
"<region> (known/all)" with "???" rows, other side quests joined to the
nearest area, "Unentdeckt" counts), all in the first tab; rows are mapped
back to the real entries afterwards (ours to none, which clicks ignore). The
bookmarks are hidden, pages titled "Quests", each page selects its first
quest, a click on a heading keeps the selection. Counted: side quests whose
name is unique in the table.
Area names (measured 2026-10-25): an area is an id block (1xx .. 25xx); its
name is the game's name of the region most of its known quests lie in, as the
megamap names the hero's region: book cell +0x18/+0x1c (x 53.66563 = world
units) -> `World_SectorAt` 0x634970 -> `Sector_Region` 0x622e30 ->
`Region_NameKey` 0x425370 -> text (`game/engine/regions.h`). Several blocks
lie in one named region (regions 1, 2, 3 are all `UI_REGION_SOUTHKERN`), so
blocks with the same name make one section; blocks without a known position
are "Gebiet N".
"Erledigte anzeigen" (request 2026-10-25: "some kind of 'Show completed'
checkmark. If unchecked, completed quests are not in the list (as well as
completed regions)"): the list's first row, an own entry whose state draws the
solved mark (100) when on and no icon (0) when off; its row carries the marker
entry 0xfffffffe. A click toggles it (`sacredK.ini` `[questbook]
ShowCompleted`, default 1) and calls `cUI_Book::Rebuild` 0x6b1670 (thiscall,
no arguments; keeps tab and page if the page still exists, else ours clamps
it). Off leaves out solved entries (main and side) and areas whose quests are
all known and solved; failed quests stay (restartable).
## Quest NPCs after the quest (request 2026-10-25)
Request: NPCs related to a quest are drawn white on the minimaps and turn
towards the player; once their quest is finished they should be drawn blue
and stop turning, like other NPCs. `src/mods/questnpc.cpp`.
Measured:
- The zoomed map view (0x6cc950, call at 0x6cec6b) draws a creature white
when `cCreature::IsQuestNpc` (0x54fa30) is true: creature flags +0x200 bit
0x100 / 0x200 (escorted NPCs, set by the quest manager 0x46ae54..), or a
dialog +0x245 in 1..59999 whose type (`cDialogManager::DialogType`
0x49a180, record +0x48 of the 0x50-byte records at manager 0xaaaf00
+0x755c, name at record +4) is below 50.
- Some quests move their giver on to dialog 0 at the end
(`DLG_15074_START` type 10 → `_OFFEN` 126 → `_ZIEL` 11 → 0); many leave it
on the quest's dialog: `auftrag10`, `auftrag2411`, `AuftragEnde202`,
`auftrag303` kept their dialog with the quest done (flags 3, book entry
solved).
- The turning, found with a hardware write watch (DR0) on the facing +0x70:
the idle action (`cCreature::IdleAction` 0x544700, Think's action 0
handler) turns every creature with a dialog +0x245 in 1..99999 towards the
local hero each Think step (0x546334..0x5463d5, `cCreature::SetDirection`
0x44df60 → 0x44ddc0), unless its type is of kind 4 in the type table
(`TypeTable_IsKind4` 0x4265c0, call at 0x546357) or object flag +0x14 bit 2
is set. So every NPC with a dialog turns, not only quest NPCs.
- Script labels (vector 0xaa9688..0xaa968c, 0x54 bytes each; quest scripts
and dialog answers are indices into it, run by `Script_RunLabel` 0x46bd80):
name +0, the quest the label belongs to +0x48 (-1 none; the label only runs
once that quest is triggered, 0x46bf11). Each dialog has a label
`Dialog:<dialog name>` (logged 2026-10-25: `Dialog:rg1_goodie1_auftrag`
9004 Vermisstes Kind, the Aufgeregter Bauer; `Dialog:auftrag_wueste` 9000;
`Dialog:auftrag11a1` 1101; `Dialog:DLG_15003_ZIEL` 15003;
`Dialog:geschlossen101` none).
The quest of a dialog is its label's quest (`game/engine/quests.h`, script
labels), looked up by the dialog record's name; cached per dialog. A first
version read the quest id from the numbers in the dialog's name; that missed
dialogs without one (the Aufgeregter Bauer's `rg1_goodie1_auftrag`, user
report 2026-10-25) and misread others (`auftrag11a1`). When that quest
exists, is done (flag 0x2) and not in progress (0x4):
- the hook on `IsQuestNpc` returns false (blue on the minimap);
- the type check call at 0x546357 is redirected to a thunk that also answers
true, so the idle action skips the turn.
Dialogs without a quest (healers, closed shops: `geschlossen101`,
`heilen_global`) stay as the game has them.
## Quest scripts (measured 2026-10-26)
- Code: `binNetScriptFunkCode.bin` (`binAddonNetScript` differs only past
0x9a653), open as the quest manager's FILE* +4 (`0xaaaf04`). Instructions
{u16 opcode, u16 size with the header, args}; args tagged: 0x01 string,
0x0b int32, 0x09 dialog, 0x05 label (e.g. a death label), ... Jump table of
the executor `0x475970` at `0x4787b0` (opcode * 4).
- Labels: the script label table (`0xaa9688`, 0x54 bytes; from
`Vectoren.bin`): name, +0x40 code offset, +0x44 code size, +0x48 quest.
`Script_RunLabel` `0x46bd80` (cdecl (label, object, flag)) reads the code
and runs it with `0x475970` (quest manager thiscall (code, &offset, quest,
object, flag, 0), ret 0x18; any memory buffer with offset 0 works).
- Opcodes seen: 1 spawn ("Res:<script id>", type, position, 0x09 dialog,
0x05 death label), 3 NPC mode (0x09 dialog, 0x0a follow + death label),
4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named
position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if
(0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f
compass, 0x43 / 0x44 / 0x45 / 0x4b variables (see below), 0x48 move NPC,
0x56 dialog state.
- Script NPC names: "Res:<n>" is a script id (object +0x3c, `0x44a390`); the
quest manager maps it to the object (`0x464ec0`, map +0x7668); a spawn sets
the entry.
- Example "Jagd auf die Wilderer" (1501): `Region15Init` spawns the elf
(Res:17013, type 134, dialog auftrag1501); accepting (`trigger1501`: 0x14)
runs `QIS_OnEnter1501`: poachers with death label `tot1501`, the elf follows
with death label `ex1501` (variable "1501" = 1, then `tot1501`); `tot1501`
once all poachers are dead: variable 0 -> back to the elf, 1 -> quest ended
without the reward. "Hetzjagd" (1502): the daughter (Res:17015, the giver)
is spawned only when the hero walks into trigger area `hetz1_1502`, with
three cultists; her father (Res:17016) only asks for her.
## Escorts on the follower list (request 2026-10-26)
"Furchtbares Verbrechen" (9008) did not go on at its target point: the
script's test there ("@npc", `0x498aa0` case 0x40) looks the NPC up in the
hero's follower list (+0x39c, 9-byte entries), which was empty after a load,
while the NPC still had its leader (+0x251) and follow bit (+0x1f4: 4 for the
follow order, 0x100 for script escorts). `mods/followers.cpp` adds such NPCs
back with `cCreature::AddFollower` `0x54b070` once after loading. Town NPCs
that run after the hero for a few seconds (status 0x404) are left out.
Confirmed 2026-10-26.
## Quest givers come back (request 2026-10-26)
"Change the death script to spawn her back to her starting point", and "on
load we need a repair ... if not completed or correctly running".
`mods/questgiver.cpp`: a quest's giver is the NPC whose spawn instruction
carries a dialog with an answer that accepts the quest (85 givers). When a
label of a running quest runs for its dead giver, it is skipped, the quest
restarts (failed entry at the giver, the variables only its labels set back:
0, the one named after the quest 1), and once the body is gone the spawn instruction runs again. After a load,
an accepted quest (flag 4) without its giver gets the same. Not yet seen in
the game (the escort of "Hetzjagd" arrived alive); see `docs/todo.md` for the
audit of how quests can fail.
Lesson (2026-10-26): flag 1 means available, not running; a first version
took it for running and reset 16 available quests and spawned their NPCs on
Borf (repaired once, then saved).
Givers killed as enemies (2026-10-30): "Familienbande" (702, book title
"Mädchen bei Ausritt verschwunden") was restarted twice when the hero killed
its giver. The quest wants that: back at the giver with the girl
(Res:17060), answer `change702` changes the creature type of the giver
Res:17061 (668 -> 262) and two relatives (opcode 0x4f {"Res:<id>", 0x02
type}), they attack, and their death label `tot702` sends the hero back to the
girl (`ziel702`, `exit702` ends the quest). Now a giver hostile to the hero
at death runs the label as the game has it, and the load repair skips quests
whose labels change their giver's type (0x4f on the giver; 2 of the 85 known
givers, 702 among them). Confirmed in game 2026-10-30.
## Quest variables are bit fields (measured 2026-10-28)
Found while repairing "Kräuter für Kräuterhexe" (402; the user knows it as
Windgeflüster). Variables live in the quest manager's vector +0x7550 /
+0x7554 (0x24-byte entries: name[0x20], value +0x20; `game::ScriptVariable`).
Script writes: 0x43 sets the value, 0x44 sets bit n (0x49bb30: value |= 1 <<
n), 0x45 clears bit n (0x49c450), 0x4b adds n (0x49cc20). Conditions
(`Script_Condition` 0x498aa0): 0x49 bit n set, 0x4a bit n clear (logged "if
IsNotVarBit"), 0x48 value == n; a missing variable is false. The variable
named after a quest has bit 0 while the quest is available: all 87 quests not
yet accepted in a dwarf save had value 1, and nothing in the quest's own labels
sets it. Quest 402: accepting sets bit 1, the berries bit 2, the reward bit 3;
the giver's dialog offers the quest on bit 0.
The quest giver restart first ran 0x44 with 0 ("variable = 0"), which set bit
0 and cleared nothing: the giver kept saying "Zögerst du in deinen Taten?".
A reset to 0 then matched no dialog branch at all. Now the value is written
directly: 0, or 1 for the quest's own variable. Confirmed 2026-10-28.
## Main quest items refused (request 2026-10-28)
"I cannot finish the Quest Windgeflüster." The berries (type 6029) are an item
`Region4Init` places once on the ground near the Goldbeerenbusch (op 0x5e,
pickup label `take402`); picking it up logged "cInventoryEntry::addMainQuestItem()
Already has mainQuestItem(6029)" and the item was gone.
`cInventoryEntry::AddMainQuestItem` (0x5d8240, thiscall (type, script id,
notify)) keeps {type, script id} pairs and refuses a new item when an entry
has the same type or script id 0 (0x5d83c6); scripts that give an item
without a "Res:" id add it with 0 (0x486bd8). The dwarf held {4609, 0}, so
every later main quest item was refused (a bug of the original).
`mods/questitems.cpp` removes the script id test. The save was repaired once
by running `take402` for the hero after the load (quest accepted, bit 2
missing); the quest then finished. Confirmed 2026-10-28.
## Escorts never left behind (2026-10-26)
"I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no
longer following me" (Borf). Measured in the log: the husband (Res:17584,
type 86; spawned in `startcode.bin` with death label `drop2412`) fell behind,
the game's own catch-up teleport moved him once (type 86 line), then the hero
walked on, the sectors behind were switched off, and the game removed him
and ran `drop2412` (quest variable 5, a token item res:17585 dropped where he
was; picking it up runs `take2412`, the "husband lost" branch). His object id
was reused right after: removed, not dead. The teleport potion came 50 s
later and was not involved; portals and the potion take followers along.
`mods/followers.cpp`: every 500 ms each follower of the local hero more than
40 cells away (sectors are switched off at 65 and more) on the same level is
teleported to the hero with `cCreature::Teleport` 0x54d840 (thiscall (cell x,
cell y, 0, 1), ret 0x10). Borf's husband was respawned once by a temporary
repair (his spawn instruction, variable 1, token removed, `follow2412`); the
quest then finished with him alive.
+18
View File
@@ -88,3 +88,21 @@ window's own pictures removed.
offset 0 while the menu showed. Now only while the menu frame is drawn offset 0 while the menu showed. Now only while the menu frame is drawn
centred (it shifts the windows itself); otherwise dialogs are centred and centred (it shifts the windows itself); otherwise dialogs are centred and
their full-layout dimming stretched, as in a game. their full-layout dimming stretched, as in a game.
## Load / save progress screen (2026-10-25)
Request: the progress screen shown while loading / saving should use the
menu screens' background. The game's screen (`DxDriver7::LoadingScreen`
`0x6469d0`, thiscall (progress, text)) draws with GDI into the back buffer's
DC (surface +0xbc, GetDC vf 0x44 / ReleaseDC vf 0x68): the 1024x768 picture
(HBITMAP +0x200) at 0, 0, the bar (+0x204 / +0x208) and the text at y 700,
then Flip; it redraws only when the bar moves. Until now it was presented as
a 1024x768 "classic" frame scaled to the monitor.
`mods/loadingscreen.cpp`: before the game's drawing, `DrawBackground` across
the whole render width in a scene of its own (`DxDriver7` BeginScene
`0x646f50` / EndScene `0x646fd0`); the game's GDI drawing is moved to the
centred 1024 frame with `SetViewportOrgEx` on the back buffer's DC, caught in
`CreateCompatibleDC(hdc)` which the game calls right after getting it. Every
frame is now presented at the full render width (borderless.cpp has one
layout).
+10
View File
@@ -189,3 +189,13 @@ only (or disabled in multiplayer).
table for the extra controls), layout, rendering, hit tests, drag & drop. table for the extra controls), layout, rendering, hit tests, drag & drop.
6. Keys: 0-6 cast art slots (since 2026-10-15; before: 1-7 select and 6. Keys: 0-6 cast art slots (since 2026-10-15; before: 1-7 select and
cast), F1-F7 select weapon sets. cast), F1-F7 select weapon sets.
### Hero files: items of sets 6-7 (2026-10-25)
Reported: the Gladiator lost the weapons of sets 6/7 on Silber. saveHero
collects the items it writes (chunk 0xc2) from the weapon sets with
`GetWeaponSetItem` over sets 0..4 (`cmp esi, 5` at `0x605046`), so sets 6-7
kept ids of items that were never in the hero file; a world that takes its
hero from the hero file (Silber without its own hero inventory) had nothing
there. The loop bound is patched to 7 (inventory_save.cpp); loadHero's id
replacement already covers the shadow slots. Confirmed 2026-10-25.
+61
View File
@@ -223,3 +223,64 @@ 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").
## Audit how every quest can fail (2026-10-26)
Request: "at some point we need to check ALL the quests and how they possibly
can fail". Context: "Jagd auf die Wilderer" (1501) - the giver's death label
`ex1501` sets the quest variable and runs `tot1501`, which ends the quest as
solved without the reward once the poachers are dead; her survival is not
needed for the quest itself. `src/mods/questgiver.cpp` currently skips a dead
giver's label, restarts the quest and respawns the giver. Plan: from
FunkCode.bin (label table in Vectoren.bin, 0x54-byte records: name, +0x40
code offset, +0x44 size, +0x48 quest; instructions {u16 op, u16 size, args})
list per quest: death labels given to NPCs (spawn / follow instructions with
a 0x05 label), the labels that end it (opcode 0x0f) or lose it, and which
NPCs must survive (escorts). Then decide per kind: respawn the giver only,
or restart the quest.
## Quest giver death rule untested (2026-10-26)
`src/mods/questgiver.cpp` restarts a running quest whose giver died and
spawns the giver again at her start (docs/quests.md, "Quest givers come
back"). Built and checked against the scripts, not yet seen in the game: test
with an escort whose giver dies (e.g. "Jagd auf die Wilderer" on the
Gladiator, or "Hetzjagd"). Borf's case (the quest ended without the reward)
was repaired once by hand.
## Inventory drawn without its dark backgrounds (2026-10-25, intermittent)
Reported twice: on opening the inventory the item list had no dark
background behind the grid, and the tab texts had an odd background colour.
Looks like render state (texture / blend / colour) left behind by an earlier
draw; not reproduced yet. To find out: what was open or shown just before.
Possibly related (2026-10-25, once): right after a game start the main menu
texts were drawn as solid gold blocks (the gold text texture without the
font's letter mask). Gone after a restart; Alt+Tab does not cause it. The
log showed nothing unusual (all hooks and patches applied).
## Frame drops while walking (2026-10-27, deferred 2026-10-30)
Reported 2026-10-27. Measured 2026-10-29 (about 54 min, temporary timing in
the Flip, engine tick and map border, since removed): 549 frames over 40 ms,
mostly 0-1 per 10 s, worst 833 ms. Present (Flip) was slow only once; the
time goes to the game's own engine tick (227 ticks of 18-70 ms). Our tick
callbacks were slow only at the 10-min autosave. The map border is drawn only
while the map is open, so it is not the cause. Next step if resumed: time the
parts of the game tick (sector streaming while walking is the main suspect).
Deferred by the user 2026-10-30.
## Eingraben clears slows, roots and stuns (deferred 2026-10-30)
Step 3 of Eingraben (docs/dwarf-arts.md): cast clears slowing, rooting and
stunning effects on the hero. The effect kinds are unknown; plan: temporary
log of the effects added to the hero while the user gets slowed or frozen,
then remove those kinds on cast. Deferred by the user 2026-10-30.
+4 -3
View File
@@ -67,9 +67,10 @@ Elsewhere (unknown / UI, leave alone for now): `0x40694a`, `0x4ec036`,
| `0x006469d0` | loading screen: GDI BitBlt of bitmap + progress bar + text into the back buffer (fixed 1024x768) | render thread | | `0x006469d0` | loading screen: GDI BitBlt of bitmap + progress bar + text into the back buffer (fixed 1024x768) | render thread |
| `0x006a0c60` | video/intro playback (unexamined) | | | `0x006a0c60` | video/intro playback (unexamined) | |
The widescreen mod gives the loading screen a 1024x768 D3D viewport (set at Since 2026-10-25 the loading screen uses the full render width too: the menu
`BeginScene` when the scene depth `+0x1d8` is 0) and the borderless mod background is drawn across it and the game's GDI drawing is centred
presents only that area, scaled to the monitor. The main menu thread renders (mods/loadingscreen.cpp, docs/screens.md "Load / save progress screen"); the
1024x768 "classic" viewport and present are gone. The main menu thread renders
the full width since 2026-10-20: its 1024x768 frame is drawn centred on the the full width since 2026-10-20: its 1024x768 frame is drawn centred on the
menu screens' background (docs/screens.md, "Main menu frame"). menu screens' background (docs/screens.md, "Main menu frame").
+2 -1
View File
@@ -16,7 +16,8 @@ constexpr uint32_t GetMouseClientPos = 0x0066e500;
constexpr uint32_t MainWindowProc = 0x008122a0; constexpr uint32_t MainWindowProc = 0x008122a0;
constexpr uint32_t DxDriver7_SetDisplayMode = 0x00645390; constexpr uint32_t DxDriver7_SetDisplayMode = 0x00645390;
constexpr uint32_t DxDriver7_Flip = 0x00645f70; constexpr uint32_t DxDriver7_Flip = 0x00645f70;
constexpr uint32_t DxDriver7_BeginScene = 0x00646f50; constexpr uint32_t DxDriver7_BeginScene = 0x00646f50; // thiscall, plain ret; nests (+0x1d8)
constexpr uint32_t DxDriver7_EndScene = 0x00646fd0; // thiscall, plain ret
constexpr uint32_t DxDriver7_LoadingScreen = 0x006469d0; // GDI progress screen, 1024x768 constexpr uint32_t DxDriver7_LoadingScreen = 0x006469d0; // GDI progress screen, 1024x768
constexpr uint32_t UiManager_ThreadLoop = 0x00756940; // main menu render loop (own thread) constexpr uint32_t UiManager_ThreadLoop = 0x00756940; // main menu render loop (own thread)
constexpr uint32_t Engine_RenderThreadLoop = 0x0060e3f0; // in-game render loop constexpr uint32_t Engine_RenderThreadLoop = 0x0060e3f0; // in-game render loop
+14 -3
View File
@@ -86,8 +86,9 @@ constexpr uint16_t kArtHeldenmut = 0x3f7;
constexpr uint16_t kArtKlingenblick = 0x3f8; constexpr uint16_t kArtKlingenblick = 0x3f8;
constexpr uint16_t kArtEnergieblitze = 0x3fb; constexpr uint16_t kArtEnergieblitze = 0x3fb;
constexpr uint16_t kArtFlammenwerfer = 0x43d; constexpr uint16_t kArtFlammenwerfer = 0x43d;
constexpr uint16_t kArtMeteor = 0x43e; constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
constexpr uint16_t kArtKanone = 0x43f; constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone)
constexpr uint16_t kArtEingraben = 0x39; // spell (gui_moves_eingraben)
// cCreatureArts fields. // cCreatureArts fields.
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
@@ -106,7 +107,7 @@ constexpr uint32_t kRegenGroupCombo = 3; // handled by the combo branch
// Arts regenerating in the magic group whatever their entry says // Arts regenerating in the magic group whatever their entry says
// (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze). // (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze).
inline bool InMagicRegenGroup(uint32_t group, uint16_t art) { inline bool InMagicRegenGroup(uint32_t group, uint16_t art) {
return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMeteor || return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMoerser ||
art == kArtKanone; art == kArtKanone;
} }
@@ -160,6 +161,16 @@ inline bool EntryAtMagicRate(const uint8_t* entry) {
*reinterpret_cast<const uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze; *reinterpret_cast<const uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
} }
// The level of art `art` in cCreatureArts `arts` (cooldown entry level + bonus
// levels), 0 if it has none.
inline unsigned ArtLevelIn(uint8_t* arts, uint16_t art) {
const uint8_t* entry = FindCooldownEntry(arts, art);
return entry ? entry[kCooldownLevel] + entry[kCooldownBonusLevels] : 0;
}
inline unsigned ArtLevel(void* creature, uint16_t art) {
return ArtLevelIn(static_cast<uint8_t*>(creature) + kCreatureArts, art);
}
struct ArtCooldown { struct ArtCooldown {
float remaining = 0.0f; // in the game's units float remaining = 0.0f; // in the game's units
float total = 0.0f; float total = 0.0f;
+79
View File
@@ -0,0 +1,79 @@
#pragma once
// The explored map (fog of war on the large map and the TAB map), measured
// 2026-10-25. docs/map-reveal.md.
//
// Each sector (64x64 tiles) holds a 32x32 bit mask at +0x180 (one bit per 2x2
// tiles, 128 bytes; Sector_SetExplored 0x622bf0, copied out by 0x622c60 and
// in from the save by 0x622c80). The zoomed map view builds its fog texture
// (FOGOFWAR01.TGA) from it (0x639030).
#include <cstdint>
namespace game {
// The world's sector grid (ecx of World_SetExplored).
inline void* World() { return *reinterpret_cast<void**>(0x00ad14e0); }
// The sector grid: 128x128 sectors of 64x64 tiles (tiles < 0x2000), a u16
// index per sector at +0x284 (row-major, 0 = none) into the sector pointer
// vector at +0x240 (World_SetExplored 0x638f00).
constexpr int kSectorTiles = 64;
constexpr int kSectorGrid = 128;
constexpr int kExploredCellTiles = 2; // one mask bit per 2x2 tiles
constexpr int kSectorCells = kSectorTiles / kExploredCellTiles; // 32
constexpr size_t kSectorExploredMask = 0x180; // 32 rows of 4 bytes
inline const uint8_t* SectorAt(int sectorX, int sectorY) {
const auto* world = static_cast<const uint8_t*>(World());
if (!world || sectorX < 0 || sectorY < 0 || sectorX >= kSectorGrid || sectorY >= kSectorGrid) {
return nullptr;
}
const uint16_t index = reinterpret_cast<const uint16_t*>(world + 0x284)[sectorY * kSectorGrid + sectorX];
if (index == 0) {
return nullptr;
}
return reinterpret_cast<const uint8_t* const*>(*reinterpret_cast<const uintptr_t*>(world + 0x240))[index];
}
// The sector's explored mask (128 bytes), or null without a sector.
inline const uint8_t* SectorExploredMask(int sectorX, int sectorY) {
const uint8_t* sector = SectorAt(sectorX, sectorY);
return sector ? sector + kSectorExploredMask : nullptr;
}
// Bit of cell (x, y) within its sector's mask (Sector_SetExplored 0x622bf0).
inline bool MaskCellExplored(const uint8_t* mask, int cellX, int cellY) {
return (mask[cellY * 4 + (cellX >> 3)] >> (cellX & 7)) & 1;
}
// World_SetExplored (0x638f00, thiscall (tile x, tile y, explored), ret 0xc):
// sets the bit of the tile's sector; tiles outside the world (>= 0x2000) or in
// a sector that isn't loaded are ignored.
inline void SetExplored(int tileX, int tileY) {
using Fn = void(__fastcall*)(void*, void*, int, int, int);
reinterpret_cast<Fn>(0x00638f00)(World(), nullptr, tileX, tileY, 1);
}
// RevealAroundHero (0x599730, creature thiscall (tile x, tile y, sight), ret
// 0xc): for the local hero, when its tile changed (last tile 0xad2384 /
// 0xad2388), sets every tile of a stamp centred on it explored. Sight 0 (day):
// a 29x29 circle (radius 14); sight 1..3 (night, weather; chosen by the
// callers from the weather object 0x417e50) a 15x15 stamp, the smaller ones
// (radius 7, 5, 3) at 0x88f328 + (sight - 1) * 225.
constexpr uintptr_t kRevealAroundHero = 0x00599730;
inline int32_t LastRevealTileX() { return *reinterpret_cast<const int32_t*>(0x00ad2384); }
inline int32_t LastRevealTileY() { return *reinterpret_cast<const int32_t*>(0x00ad2388); }
struct RevealStamp {
const uint8_t* cells; // size x size bytes, non-zero = revealed
int size;
};
inline RevealStamp RevealStampFor(int sight) {
if (sight <= 0) {
return {reinterpret_cast<const uint8_t*>(0x0088f5cc), 29};
}
return {reinterpret_cast<const uint8_t*>(0x0088f328) + (sight - 1) * 15 * 15, 15};
}
} // namespace game
+15 -7
View File
@@ -79,13 +79,21 @@ inline bool ItemIsSetItem(uint32_t type) {
return group && group[0x23] != 0; return group && group[0x23] != 0;
} }
// 0x428550 (thiscall on the type table, (type), ret 4): cCreature:: // Quest items: the type's quest number (+0x1c) or the scripts' QuestItem /
// CanCollectItem takes items of such types whatever the pickup setting (type // MainQuestItem marks (+0x61 / +0x62; tokens of those script words, 0x4584a9),
// flag 0x1000000 with +0x61 / +0x62 / +0x1c set; quest items, presumably). // as the quest book reads them (0x43ad80). The game's CanCollectItem
inline bool ItemAlwaysCollected(uint32_t type) { // (0x428550) takes them always, but only with type flag 0x1000000 set too.
using Fn = bool(__fastcall*)(void* table, void* edx, uint32_t type); constexpr size_t kItemTypeQuest = 0x1c;
void* table = *reinterpret_cast<void**>(kItemTypeTable); constexpr size_t kItemTypeQuestItem = 0x61;
return table && reinterpret_cast<Fn>(0x00428550)(table, nullptr, type); constexpr size_t kItemTypeMainQuestItem = 0x62;
inline bool ItemIsQuestItem(uint32_t type) {
const auto* table = *reinterpret_cast<const uint8_t* const*>(kItemTypeTable);
if (!table || type == 0 || type >= kItemTypeCount) {
return false;
}
const uint8_t* entry = table + type * kItemTypeEntrySize;
return *reinterpret_cast<const uint32_t*>(entry + kItemTypeQuest) != 0 || entry[kItemTypeQuestItem] ||
entry[kItemTypeMainQuestItem];
} }
// The item's quality block (+0x118, cArmor3D::vf24 case 2 hands it to the // The item's quality block (+0x118, cArmor3D::vf24 case 2 hands it to the
+12
View File
@@ -75,6 +75,18 @@ inline void RemoveFromMap(uint32_t id) {
} }
} }
// The end of the object ids (0x5fe730: object vector +4..+8, 4 bytes each);
// ids 1..end-1 can be passed to GetObjectById.
inline uint32_t ObjectIdEnd() {
const auto* manager = static_cast<const uint8_t*>(ObjectManager());
if (!manager) {
return 0;
}
const auto begin = *reinterpret_cast<const uintptr_t*>(manager + 4);
const auto end = *reinterpret_cast<const uintptr_t*>(manager + 8);
return static_cast<uint32_t>((end - begin) / 4);
}
// The object `id` as a cCreature, or null. // The object `id` as a cCreature, or null.
inline void* GetCreature(uint32_t id) { inline void* GetCreature(uint32_t id) {
return DynamicCast(GetObjectById(id), kTypeObject, kTypeCreature); return DynamicCast(GetObjectById(id), kTypeObject, kTypeCreature);
+14
View File
@@ -15,8 +15,17 @@ std::vector<CreatureOrderFn>& Callbacks() {
return callbacks; return callbacks;
} }
std::vector<CreatureOrderFilter>& Filters() {
static std::vector<CreatureOrderFilter> filters;
return filters;
}
} // namespace } // namespace
void FilterCreatureOrders(CreatureOrderFilter filter) {
Filters().push_back(filter);
}
void OnCreatureOrder(CreatureOrderFn callback) { void OnCreatureOrder(CreatureOrderFn callback) {
Callbacks().push_back(callback); Callbacks().push_back(callback);
} }
@@ -26,6 +35,11 @@ void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* orde
RE_HOOK(0x0052e400, Creature_ReceiveOrder); RE_HOOK(0x0052e400, Creature_ReceiveOrder);
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) { void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) {
if (self && order) { if (self && order) {
for (CreatureOrderFilter filter : Filters()) {
if (!filter(self, *order)) {
return;
}
}
void* caller = _ReturnAddress(); void* caller = _ReturnAddress();
for (CreatureOrderFn callback : Callbacks()) { for (CreatureOrderFn callback : Callbacks()) {
callback(self, *order, caller); callback(self, *order, caller);
+59
View File
@@ -63,11 +63,70 @@ inline void* ControlledCreature(uint8_t* engine) {
using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller); using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller);
void OnCreatureOrder(CreatureOrderFn callback); void OnCreatureOrder(CreatureOrderFn callback);
// Asked before the callbacks: an order any filter refuses (returns false) is
// dropped, the creature never gets it.
using CreatureOrderFilter = bool (*)(void* creature, const CreatureOrder& order);
void FilterCreatureOrders(CreatureOrderFilter filter);
// Creature fields. // Creature fields.
constexpr size_t kCreatureId = 0xc; constexpr size_t kCreatureId = 0xc;
constexpr size_t kCreatureX = 0x1c; constexpr size_t kCreatureX = 0x1c;
constexpr size_t kCreatureY = 0x20; constexpr size_t kCreatureY = 0x20;
// Escorts (measured 2026-10-26, docs/quests.md): an NPC told to follow
// (cCreature::ReceiveOrder 0x52e400, e.g. 0x52e64f) stores its leader id at
// +0x251, sets bit 4 of +0x1f4 (escorts of quest scripts have 0x100 instead,
// measured on "Jagd auf die Wilderer"; the leave order clears both) and is added to the leader's follower list
// (+0x39c..+0x3a0, 9-byte entries {u32 id, u32, u8}) with AddFollower.
// Quest scripts test "@npc" (0x498aa0 case 0x40) against that list.
constexpr size_t kCreatureLeader = 0x251;
constexpr size_t kCreatureStatus = 0x1f4;
constexpr uint32_t kStatusFollowing = 0x104;
constexpr size_t kCreatureFollowersBegin = 0x39c;
constexpr size_t kCreatureFollowersEnd = 0x3a0;
constexpr size_t kFollowerEntrySize = 9;
#pragma pack(push, 1)
struct FollowerEntry {
uint32_t id;
uint32_t value; // 1 for escorts, as the follow order adds them
uint8_t flag; // 1 for escorts
};
#pragma pack(pop)
static_assert(sizeof(FollowerEntry) == kFollowerEntrySize);
// cCreature::AddFollower (0x54b070, thiscall (const FollowerEntry*), ret 4):
// adds the entry to the leader's list unless its id is in it.
inline void AddFollower(void* leader, const FollowerEntry& entry) {
using Fn = void(__fastcall*)(void*, void*, const FollowerEntry*);
reinterpret_cast<Fn>(0x0054b070)(leader, nullptr, &entry);
}
// The ids on the leader's follower list.
template <typename Fn>
void ForEachFollower(const void* leader, Fn&& fn) {
const auto* creature = static_cast<const uint8_t*>(leader);
const auto* begin = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersBegin);
const auto* end = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersEnd);
for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) {
fn(*reinterpret_cast<const uint32_t*>(entry));
}
}
inline bool HasFollower(const void* leader, uint32_t id) {
bool found = false;
ForEachFollower(leader, [&](uint32_t follower) { found = found || follower == id; });
return found;
}
// cCreature::Teleport (0x54d840, thiscall (cell x, cell y, 0, 1), ret 0x10):
// as the portals move the hero (EE_USEPORTAL 0x6197c3); the game's log line
// "Teleport (type): <type>, ... (x,y)".
inline void Teleport(void* creature, int32_t cellX, int32_t cellY) {
using Fn = void(__fastcall*)(void*, void*, int32_t, int32_t, int32_t, int32_t);
reinterpret_cast<Fn>(0x0054d840)(creature, nullptr, cellX, cellY, 0, 1);
}
// The walk path (measured 2026-10-18): waypoints {x, y} at +0x2ed + i * 8, // The walk path (measured 2026-10-18): waypoints {x, y} at +0x2ed + i * 8,
// the current segment's start index at +0x36d, the count at +0x371, and the // the current segment's start index at +0x36d, the count at +0x371, and the
// step's sub-unit remainder (x, y, 1/1024 units) at +0x37d / +0x381. // step's sub-unit remainder (x, y, 1/1024 units) at +0x37d / +0x381.
+276
View File
@@ -0,0 +1,276 @@
#pragma once
// Quests: the quest table (one entry per quest, from the scripts) and the quest
// manager's quest book (the hero's accepted quests). docs/quests.md.
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <initializer_list>
namespace game {
// Quest table g_quests: vector begin/end (0xaa9e98 / 0xaa9e9c), 0x124 bytes per quest.
constexpr uintptr_t kQuestsBegin = 0x00aa9e98;
constexpr uintptr_t kQuestsEnd = 0x00aa9e9c;
constexpr size_t kQuestSize = 0x124;
constexpr size_t kQuestId = 0x00;
constexpr size_t kQuestName = 0x04; // internal name, ANSI
constexpr size_t kQuestTriggerScript = 0x10c; // run by Quest_Trigger 0x46de10
constexpr size_t kQuestSuccessScript = 0x118; // run by Quest_End on success
constexpr size_t kQuestFailureScript = 0x11c; // run by Quest_End on failure
constexpr size_t kQuestFlags = 0x120;
// Quest flags (+0x120); QuestDone tests 2, QuestInProgress 4.
constexpr uint32_t kQuestTriggered = 0x1;
constexpr uint32_t kQuestDone = 0x2;
constexpr uint32_t kQuestInProgress = 0x4;
constexpr uint32_t kQuestBusy = 0x8; // while Quest_Trigger runs the scripts
inline uint8_t* QuestsBegin() { return *reinterpret_cast<uint8_t**>(kQuestsBegin); }
inline uint8_t* QuestsEnd() { return *reinterpret_cast<uint8_t**>(kQuestsEnd); }
inline size_t QuestCount() {
uint8_t* begin = QuestsBegin();
return begin && QuestsEnd() > begin ? (QuestsEnd() - begin) / kQuestSize : 0;
}
inline uint8_t* Quest(int index) { return QuestsBegin() + index * kQuestSize; }
inline int32_t& QuestId(uint8_t* quest) { return *reinterpret_cast<int32_t*>(quest + kQuestId); }
inline uint32_t& QuestFlags(uint8_t* quest) { return *reinterpret_cast<uint32_t*>(quest + kQuestFlags); }
inline const char* QuestName(const uint8_t* quest) { return reinterpret_cast<const char*>(quest + kQuestName); }
// Table index of quest `id`, -1 if unknown.
inline int QuestIndex(int32_t id) {
for (size_t i = 0, n = QuestCount(); i < n; ++i) {
if (QuestId(Quest(static_cast<int>(i))) == id) {
return static_cast<int>(i);
}
}
return -1;
}
// Script labels (vector begin/end 0xaa9688 / 0xaa968c, 0x54 bytes each; the
// quests' scripts are indices into it, run by 0x46bd80): name +0 (e.g.
// "Dialog:auftrag2411", "rg1_goodie1_annehmen"), the quest the label belongs to
// +0x48 (-1 for none; 0x46bf11: runs only once that quest is triggered).
constexpr uintptr_t kScriptLabelsBegin = 0x00aa9688;
constexpr uintptr_t kScriptLabelsEnd = 0x00aa968c;
constexpr size_t kScriptLabelSize = 0x54;
constexpr size_t kScriptLabelNameSize = 0x48;
constexpr size_t kScriptLabelQuest = 0x48;
constexpr size_t kScriptLabelCode = 0x40; // offset of its code in FunkCode.bin
constexpr size_t kScriptLabelCodeSize = 0x44; // bytes
inline const uint8_t* ScriptLabelsBegin() { return *reinterpret_cast<const uint8_t* const*>(kScriptLabelsBegin); }
inline const uint8_t* ScriptLabelsEnd() { return *reinterpret_cast<const uint8_t* const*>(kScriptLabelsEnd); }
inline const char* ScriptLabelName(const uint8_t* label) { return reinterpret_cast<const char*>(label); }
inline int32_t ScriptLabelQuest(const uint8_t* label) {
return *reinterpret_cast<const int32_t*>(label + kScriptLabelQuest);
}
inline uint32_t ScriptLabelCode(const uint8_t* label) {
return *reinterpret_cast<const uint32_t*>(label + kScriptLabelCode);
}
inline uint32_t ScriptLabelCodeSize(const uint8_t* label) {
return *reinterpret_cast<const uint32_t*>(label + kScriptLabelCodeSize);
}
// Script code (FunkCode.bin, open as the quest manager's FILE* +4; measured
// 2026-10-26): instructions {u16 opcode, u16 size (with these 4 bytes), args};
// args are tagged (0x01 a string, 0x0b an int32, ...). Script_RunLabel
// (0x46bd80) reads a label's code and runs it instruction by instruction with
// 0x475970. Opcodes used here: 1 spawns a creature ("Res:<script id>", type,
// position, 0x09 + its dialog, ...; e.g. Region15Init). Script variables are
// bit fields ({0x01 name, 0x0b n}; measured 2026-10-27): 0x43 sets the value,
// 0x44 sets bit n (0x49bb30), 0x45 clears bit n (0x49c450), 0x4b adds n
// (0x49cc20); conditions 0x49 / 0x4a test bit n set / clear, 0x48 the value
// (0x498aa0; a missing variable is false).
constexpr uint16_t kScriptSpawn = 0x01;
// More opcodes (keyword table 0x452540, name i <-> opcode; docs/quest-audit.md):
// 3 NPC state ("Res:<id>", 0x09 dialog, flag 0x08 with 0x12 hostile, ...),
// 8 create an object, 0x0f / 0x36 end the quest (ExitQuest / LoseQuest), 0x11 /
// 0x12 give experience / gold, 0x14 start quest #id, 0x15 set up quest #id,
// 0x16 call a label, 0x34 set hit points ("Res:<id>", percent), 0x37 delete an
// NPC or object, 0x3a / 0x42 if / else if, 0x3c dialog answer (text, label),
// 0x4f change creature type, 0x75 activate quest #id.
constexpr uint16_t kScriptSetNpcState = 0x03;
constexpr uint16_t kScriptCreateObject = 0x08;
constexpr uint16_t kScriptGiveExperience = 0x11;
constexpr uint16_t kScriptGiveGold = 0x12;
constexpr uint16_t kScriptStartQuest = 0x14;
constexpr uint16_t kScriptSetUpQuest = 0x15;
constexpr uint16_t kScriptCallLabel = 0x16;
constexpr uint16_t kScriptSetHitPoints = 0x34;
constexpr uint16_t kScriptLoseQuest = 0x36;
constexpr uint16_t kScriptDelete = 0x37;
constexpr uint16_t kScriptIf = 0x3a;
constexpr uint16_t kScriptAnswer = 0x3c;
constexpr uint16_t kScriptElseIf = 0x42;
constexpr uint16_t kScriptChangeType = 0x4f;
constexpr uint16_t kScriptActivateQuest = 0x75;
constexpr uint16_t kScriptSetVarValue = 0x43;
constexpr uint16_t kScriptSetVarBit = 0x44;
constexpr uint16_t kScriptClearVarBit = 0x45;
constexpr uint16_t kScriptAddToVar = 0x4b;
inline bool IsScriptVariableWrite(uint16_t opcode) {
return opcode == kScriptSetVarValue || opcode == kScriptSetVarBit || opcode == kScriptClearVarBit ||
opcode == kScriptAddToVar;
}
constexpr uint8_t kScriptArgString = 0x01;
constexpr uint8_t kScriptArgInt = 0x0b;
constexpr uint8_t kScriptArgDialog = 0x09;
constexpr uint8_t kScriptArgAttitude = 0x08; // flag, followed by 0x12 (hostile) or 0x14
// The generic quests (Quest_End copies them under a new id; docs/quests.md).
constexpr int32_t kGenericQuestFirst = 2600;
constexpr int32_t kGenericQuestLast = 3599;
// CRT fseek (0x84866d) / fread (0x848726) of the game, on its own FILE*.
inline void* ScriptFile() { return *reinterpret_cast<void**>(0x00aaaf04); }
inline size_t ReadScriptFile(uint32_t offset, void* out, size_t size) {
using SeekFn = int(__cdecl*)(void*, long, int);
using ReadFn = size_t(__cdecl*)(void*, size_t, size_t, void*);
void* file = ScriptFile();
if (!file || reinterpret_cast<SeekFn>(0x0084866d)(file, static_cast<long>(offset), 0) != 0) {
return 0;
}
return reinterpret_cast<ReadFn>(0x00848726)(out, 1, size, file);
}
// Runs the one instruction at `code` (0x475970 on the quest manager, thiscall
// (code, &offset, quest, object, flag, 0), ret 0x18; with an offset other than
// -1 it reads from memory, -1 from the script file). 0 on an error.
inline int RunScriptInstruction(const uint8_t* code, int32_t quest = -1, void* object = nullptr) {
using Fn = int(__fastcall*)(void*, void*, const uint8_t*, int32_t*, int32_t, void*, int32_t, int32_t);
int32_t offset = 0;
return reinterpret_cast<Fn>(0x00475970)(reinterpret_cast<void*>(0x00aaaf00), nullptr, code, &offset, quest,
object, 0, 0);
}
// The script id of an object (0x44a390, thiscall (), ret: +0x3c, or its
// type's default), as "Res:<id>" names it.
inline uint32_t ObjectScriptId(void* object) {
using Fn = uint32_t(__fastcall*)(void*, void*);
return reinterpret_cast<Fn>(0x0044a390)(object, nullptr);
}
// The object id the scripts know as `scriptId` (0x464ec0 on the quest
// manager, thiscall (script id, &object id), ret 8: its map +0x7668, which
// may still hold a dead object's id, then all objects). 0 if none.
// The quest manager's map script id -> object id (+0x7668, std::map;
// operator[] 0x4b37b0, thiscall (&key), ret 4; spawning sets it, 0x4839fa).
inline uint32_t& ScriptObjectSlot(uint32_t scriptId) {
using Fn = uint32_t*(__fastcall*)(void*, void*, const uint32_t*);
return *reinterpret_cast<Fn>(0x004b37b0)(reinterpret_cast<uint8_t*>(0x00aaaf00) + 0x7668, nullptr, &scriptId);
}
inline uint32_t ScriptObjectId(uint32_t scriptId) {
using Fn = char(__fastcall*)(void*, void*, uint32_t, uint32_t*);
uint32_t id = 0;
return reinterpret_cast<Fn>(0x00464ec0)(reinterpret_cast<void*>(0x00aaaf00), nullptr, scriptId, &id) ? id : 0;
}
// The quest manager, a static object (script interpreter; ecx of 0x475970 and
// of Quest_End 0x46ced0). Its quest book is a vector of plain 0x174-byte entries
// (serialized field by field, Quest_End 0x46d9d7..); per player a selected
// entry index at +0x3a8 + player * 8 (Quest_End sets it to the count = none).
inline uint8_t* QuestManager() { return reinterpret_cast<uint8_t*>(0x00aaaf00); }
constexpr size_t kBookBegin = 0x424;
constexpr size_t kBookEnd = 0x428;
constexpr size_t kBookEntrySize = 0x174;
// The script variables: a vector at quest manager +0x7550 / +0x7554 of 0x24-byte
// entries {name[0x20], value} (read by the conditions, 0x498aa0 case 0x49).
constexpr size_t kVariablesBegin = 0x7550;
constexpr size_t kVariablesEnd = 0x7554;
constexpr size_t kVariableSize = 0x24;
constexpr size_t kVariableValue = 0x20;
// The value slot of script variable `name`, nullptr if it does not exist yet.
inline int32_t* ScriptVariable(const char* name) {
uint8_t* begin = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesBegin);
uint8_t* end = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesEnd);
for (uint8_t* entry = begin; entry && entry < end; entry += kVariableSize) {
if (strncmp(reinterpret_cast<const char*>(entry), name, kVariableValue) == 0) {
return reinterpret_cast<int32_t*>(entry + kVariableValue);
}
}
return nullptr;
}
constexpr size_t kBookCapacity = 0x42c;
constexpr size_t kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450)
constexpr size_t kBookState = 0x04;
constexpr size_t kBookQuestId = 0x08;
constexpr size_t kBookTitle = 0x24; // text ids
constexpr size_t kBookText = 0x28;
// The quest compass (the quest's marker on the maps, script QuestkompassPos /
// QuestkompassOBJ 0x49a7a0 / 0x49af70): target +0x10/+0x14 is a map cell
// (x, y), or kCompassObject + object id, or kCompassScriptNpc + script NPC id
// (resolved to an object later); radius +0x20 ("rad:"). The marker's cell
// +0x18/+0x1c is resolved from the target by 0x4a6740 (thiscall (entry), ret 4).
constexpr size_t kBookCompassX = 0x10;
constexpr size_t kBookCompassY = 0x14;
constexpr size_t kBookCompassRadius = 0x20;
constexpr uint32_t kCompassObject = 0xffffffff;
constexpr uint32_t kCompassScriptNpc = 0xeeeeeeee;
constexpr size_t kBookX = 0x18; // marker cell (0x497830)
constexpr size_t kBookY = 0x1c;
constexpr size_t kBookChapter = 0x16c; // u16, book tab + 1 (0 = tab 0), copied from the current main quest
constexpr size_t kBookMainIndex = 0x3a4; // per player: the current main quest's entry
constexpr size_t kBookSelected = 0x3a8;
constexpr size_t kBookSelectedStride = 8;
constexpr int kBookPlayers = 16; // +0x3a4.. 16 pairs up to the book at +0x424
constexpr int32_t kBookMain = 3;
constexpr int32_t kBookSide = 4;
constexpr int kMainQuestLast = 99; // quest ids 1..99 are main quests (0x46c450)
// Book entry states (+4); Quest_End writes 100 / 101.
constexpr int32_t kBookActive = 2;
constexpr int32_t kBookSolved = 100;
constexpr int32_t kBookFailed = 101;
inline uint8_t*& BookBegin() { return *reinterpret_cast<uint8_t**>(QuestManager() + kBookBegin); }
inline uint8_t*& BookEnd() { return *reinterpret_cast<uint8_t**>(QuestManager() + kBookEnd); }
inline uint8_t*& BookCapacityEnd() { return *reinterpret_cast<uint8_t**>(QuestManager() + kBookCapacity); }
inline size_t BookCount() {
return BookBegin() && BookEnd() > BookBegin() ? (BookEnd() - BookBegin()) / kBookEntrySize : 0;
}
inline uint8_t* BookEntry(size_t index) { return BookBegin() + index * kBookEntrySize; }
inline int32_t BookState(const uint8_t* entry) { return *reinterpret_cast<const int32_t*>(entry + kBookState); }
inline int32_t BookQuestId(const uint8_t* entry) { return *reinterpret_cast<const int32_t*>(entry + kBookQuestId); }
inline int32_t BookKind(const uint8_t* entry) { return *reinterpret_cast<const int32_t*>(entry + kBookKind); }
inline uint16_t BookChapter(const uint8_t* entry) { return *reinterpret_cast<const uint16_t*>(entry + kBookChapter); }
// The local player's slot in the per-player book fields (session +0x14 is the
// player number 1..16; 0x46c450, 0x49dd40).
inline int LocalPlayerSlot() {
using InstanceFn = uint8_t*(__cdecl*)();
const int player = *reinterpret_cast<int32_t*>(reinterpret_cast<InstanceFn>(0x007d7c40)() + 0x14);
return player >= 1 && player <= kBookPlayers ? player - 1 : 0;
}
inline uint32_t& BookSelected(int slot) {
return *reinterpret_cast<uint32_t*>(QuestManager() + kBookSelected + slot * kBookSelectedStride);
}
inline uint32_t& BookMainIndex(int slot) {
return *reinterpret_cast<uint32_t*>(QuestManager() + kBookMainIndex + slot * kBookSelectedStride);
}
// Removes book entry `index`; the players' selected indices are kept on their
// entries (or "none" = the new count), and so are their main quest indices.
inline void EraseBookEntry(size_t index) {
const size_t count = BookCount();
if (index >= count) {
return;
}
std::memmove(BookEntry(index), BookEntry(index + 1), (count - index - 1) * kBookEntrySize);
BookEnd() -= kBookEntrySize;
for (int player = 0; player < kBookPlayers; ++player) {
for (uint32_t* kept : {&BookSelected(player), &BookMainIndex(player)}) {
if (*kept == index || *kept >= count) {
*kept = static_cast<uint32_t>(count - 1);
} else if (*kept > index) {
--*kept;
}
}
}
}
} // namespace game
+40
View File
@@ -0,0 +1,40 @@
#pragma once
// World regions: each sector carries a region number; the region name table
// (from scripts/regionsnamen) maps region numbers to name text keys. The
// megamap names the hero's region so (0x6c6d73..): position -> sector
// (0x634970) -> region (0x622e30) -> key (0x425370) -> text.
#include <cstdint>
#include "game/engine/objects.h"
#include "game/engine/orders.h"
namespace game {
// The region number at map cell (x, y) (quest book positions +0x18/+0x1c are
// cells); 0 if none. World_SectorAt 0x634970 (thiscall (position*), ret 4,
// world units) on the object manager's world (manager +0), Sector_Region
// 0x622e30 (thiscall, plain ret).
inline uint8_t RegionAtCell(int32_t x, int32_t y) {
void* manager = ObjectManager();
void* world = manager ? *reinterpret_cast<void**>(manager) : nullptr;
if (!world) {
return 0;
}
MapPosition position;
position.x = static_cast<int32_t>(x * kMapCellSize);
position.y = static_cast<int32_t>(y * kMapCellSize);
using SectorFn = void*(__fastcall*)(void* world, void* edx, MapPosition* position);
using RegionFn = uint32_t(__fastcall*)(void* sector, void* edx);
void* sector = reinterpret_cast<SectorFn>(0x00634970)(world, nullptr, &position);
return sector ? static_cast<uint8_t>(reinterpret_cast<RegionFn>(0x00622e30)(sector, nullptr)) : 0;
}
// The name text key of region `region` (Region_NameKey 0x425370, cdecl), or
// null.
inline const char* RegionNameKey(uint8_t region) {
using Fn = const char*(__cdecl*)(char region);
return region ? reinterpret_cast<Fn>(0x00425370)(static_cast<char>(region)) : nullptr;
}
} // namespace game
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// The world view (cWorldView0, vtable 0x8900f8): camera and the projection of
// world positions onto the screen. docs/widescreen.md.
#include <cstdint>
#include "game/addresses.h"
#include "game/render/dx_driver7.h"
namespace game {
constexpr size_t kViewCameraX = 0x14; // int32, world pixels
constexpr size_t kViewCameraY = 0x18;
constexpr size_t kViewZoom = 0xb60; // float, 0.5 .. 2.0
constexpr float kViewHalfHeight = 384.0f; // the render height stays 768
struct Point2 {
float x, y;
};
// World units -> world pixels: the ground plane's isometric projection
// (0x622700, cdecl (out, in), plain ret; matrix at 0xad5710; a 53.66563-unit
// tile is a 96x48 pixel diamond). The large map uses it too.
inline Point2 WorldToPlane(float x, float y) {
float in[3] = {x, y, 0};
float out[3] = {};
using Fn = void(__cdecl*)(float*, const float*);
reinterpret_cast<Fn>(0x00622700)(out, in);
return {out[0], out[1]};
}
// World pixels -> screen: (pixel - camera) / zoom + screen centre (the
// floating texts 0x41e546, the object passes 0x62f925). `halfWidth`: half
// the render width (the centre the widescreen mod gives the view).
inline Point2 PlaneToScreen(const uint8_t* view, const Point2& plane, float halfWidth) {
const float zoom = *reinterpret_cast<const float*>(view + kViewZoom);
const auto cameraX = static_cast<float>(*reinterpret_cast<const int32_t*>(view + kViewCameraX));
const auto cameraY = static_cast<float>(*reinterpret_cast<const int32_t*>(view + kViewCameraY));
return {(plane.x - cameraX) / zoom + halfWidth, (plane.y - cameraY) / zoom + kViewHalfHeight};
}
// The Direct3D 7 device the world is drawn with.
inline void* RenderDevice() {
const auto* driver = *reinterpret_cast<DxDriver7* const*>(addr::g_dxDriver);
return driver ? driver->device : nullptr;
}
} // namespace game
+16
View File
@@ -80,6 +80,22 @@ inline void FillCorners(void* device, float x0, float y0, float x1, float y1, ui
quad, 4, 0); quad, 4, 0);
} }
// Untextured triangles, three vertices each (a triangle list), in batches the
// device takes in one call.
inline void FillTriangles(void* device, ScreenVertex* vertices, size_t count) {
constexpr DWORD kTriangleList = 4;
constexpr size_t kBatch = 3 * 10000;
void** vtable = *static_cast<void***>(device);
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
for (size_t first = 0; first < count; first += kBatch) {
const size_t batch = count - first < kBatch ? count - first : kBatch;
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(
device, kTriangleList, kFvfScreenColourTexture, vertices + first, static_cast<DWORD>(batch), 0);
}
}
// Fills (x0, y0)-(x1, y1) with the ARGB colour `color`. // Fills (x0, y0)-(x1, y1) with the ARGB colour `color`.
inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) { inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) {
FillCorners(device, x0, y0, x1, y1, color, color, color, color); FillCorners(device, x0, y0, x1, y1, color, color, color, color);
+37
View File
@@ -0,0 +1,37 @@
#pragma once
// The large map (M, cUI_Megamap): a scrollable view of the 2048x2816 world
// map image. docs/hud.md, mods/megamap.cpp.
#include <cstdint>
namespace game {
// Scroll position (map pixel at the view's left / top edge).
constexpr size_t kMegamapScrollX = 0xc016c;
constexpr size_t kMegamapScrollY = 0xc0170;
inline int32_t MegamapScrollX(const uint8_t* megamap) {
return *reinterpret_cast<const int32_t*>(megamap + kMegamapScrollX);
}
inline int32_t MegamapScrollY(const uint8_t* megamap) {
return *reinterpret_cast<const int32_t*>(megamap + kMegamapScrollY);
}
struct Vec3 {
float x, y, z;
};
// cUI_Megamap::WorldToMapPixel (0x6cb130, thiscall (out, world x / y / level),
// ret 8): the map pixel of a world position (world units, 53.66563 per tile).
// Linear: the world plane projection 0x622700, then x * 0.007446 + 1018.75,
// y * 0.014914 - 186.95.
inline Vec3 WorldToMapPixel(uint8_t* megamap, const Vec3& world) {
Vec3 pixel = {};
using Fn = void(__fastcall*)(uint8_t*, void*, Vec3*, const Vec3*);
reinterpret_cast<Fn>(0x006cb130)(megamap, nullptr, &pixel, &world);
return pixel;
}
constexpr float kWorldUnitsPerTile = 53.66563f; // 0x8946b0
} // namespace game
+7 -2
View File
@@ -156,11 +156,16 @@ void SetTextOverride(uint32_t id, const std::wstring& text) {
entry->string = {begin, begin + entry->text.size(), begin + entry->text.size()}; entry->string = {begin, begin + entry->text.size(), begin + entry->text.size()};
} }
std::wstring GameTextByKey(const char* key) { uint32_t GameTextIdByKey(const char* key) {
// The key's hash (0x80e010, cdecl), looked up "by key" as GetTextByKey does. // The key's hash (0x80e010, cdecl), looked up "by key" as GetTextByKey does.
using HashFn = uint32_t(__cdecl*)(const char*); using HashFn = uint32_t(__cdecl*)(const char*);
const uint32_t hash = reinterpret_cast<HashFn>(0x0080e010)(key); const uint32_t hash = reinterpret_cast<HashFn>(0x0080e010)(key);
return hash ? GameText(hash | 0x80000000) : std::wstring(); return hash ? hash | 0x80000000 : 0;
}
std::wstring GameTextByKey(const char* key) {
const uint32_t id = GameTextIdByKey(key);
return id ? GameText(id) : std::wstring();
} }
std::wstring WideStringText(const void* string) { std::wstring WideStringText(const void* string) {
+3
View File
@@ -33,6 +33,9 @@ void SetTextOverride(uint32_t id, const std::wstring& text);
// The game's text under key `key` (TextManager::GetTextByKey 0x672fd0), e.g. // The game's text under key `key` (TextManager::GetTextByKey 0x672fd0), e.g.
// "UI_ATTRIB_STRENGTH"; empty if there is none. // "UI_ATTRIB_STRENGTH"; empty if there is none.
std::wstring GameTextByKey(const char* key); std::wstring GameTextByKey(const char* key);
// The id of the game's text under key `key` (0 if the key has no hash), e.g.
// for SetTextOverride.
uint32_t GameTextIdByKey(const char* key);
// A game wide string ({begin, end, capacity end}, e.g. a tooltip's text) as // A game wide string ({begin, end, capacity end}, e.g. a tooltip's text) as
// text, and replaced by `value` (grown with the game's allocator). // text, and replaced by `value` (grown with the game's allocator).
+14
View File
@@ -13,6 +13,20 @@ inline uint32_t* UiManager() {
return *reinterpret_cast<uint32_t**>(kUiManager); return *reinterpret_cast<uint32_t**>(kUiManager);
} }
// The UI manager's window pointers by slot (dwords).
constexpr int kUiSlotMegamap = 0x25; // cUI_Megamap, UI_WND_MEGAMAP
inline uint8_t* UiWindow(const uint32_t* manager, int slot) {
return manager ? reinterpret_cast<uint8_t*>(static_cast<uintptr_t>(manager[slot])) : nullptr;
}
// The window in `slot` exists and is visible (control flags +0x10 bit 0).
inline bool UiWindowShown(const uint32_t* manager, int slot) {
constexpr size_t kControlFlags = 0x10;
const uint8_t* window = UiWindow(manager, slot);
return window && (*reinterpret_cast<const uint32_t*>(window + kControlFlags) & 1) != 0;
}
// cEventUI2 window commands (code at +4, subtype 5 at +8, names at +0x28): the // cEventUI2 window commands (code at +4, subtype 5 at +8, names at +0x28): the
// UI manager's event handler (vf00, 0x757120) routes them by window name. // UI manager's event handler (vf00, 0x757120) routes them by window name.
// Measured: HandleEsc hides with 0x12 (docs/escape.md); the S hotkey // Measured: HandleEsc hides with 0x12 (docs/escape.md); the S hotkey
+68
View File
@@ -0,0 +1,68 @@
// The game's anti-cheat is gone (requests of 2026-10-25: the Gladiator
// "randomly exploded" fighting a dragon on Silber; then "i want all that weird
// cheater detection code gone"). docs/anticheat.md.
//
// Measured: the game keeps two xor-ed mirrors of the local hero's attribute /
// skill / gold fields (0x182cddc on a protected page, 0x182cde0 a heap block),
// written after every legitimate change. Checks compare the hero with them,
// reset every mismatching field (attributes to 1, skill levels to 0) and on
// any mismatch kill the hero ("*** Cheater detected! *** Hase!") and set the
// cheater flag 0x182cdd0, which the hero file keeps. Any change the game does
// not route through its mirror writers (e.g. loading the shared hero into
// another world) made the checks fire.
//
// The mod: every check is skipped through the game's own path for "not the
// local hero" (or straight to the code after the check, with the registers that
// code reads), so no field is reset and no hero killed; the cheater flag is
// never set. The mirror writers stay (they only fill the unused mirrors).
#include <cstdint>
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::anticheat {
namespace {
constexpr re::CodeReplacement kNoAntiCheat[] = {
// cCreature::Think: the trap for a level more than 3 above the experience's
// (raises fields behind the mirrors' back): `jle` past it -> `jmp`
{0x005385ca, {0x0f, 0x8e, 0xd8, 0x03, 0x00, 0x00}, {0xe9, 0xd9, 0x03, 0x00, 0x00, 0x90}, 6},
// cCreature::Think: the check in 1 of 101 ticks: `jne` past it -> `jmp`
{0x005389b8, {0x0f, 0x85, 0x0d, 0x06, 0x00, 0x00}, {0xe9, 0x0e, 0x06, 0x00, 0x00, 0x90}, 6},
// inline checks behind "is this the local hero": `jne` past them -> `jmp`
{0x00564c50, {0x0f, 0x85, 0x89, 0x05, 0x00, 0x00}, {0xe9, 0x8a, 0x05, 0x00, 0x00, 0x90}, 6},
{0x0057e07d, {0x0f, 0x85, 0xd8, 0x05, 0x00, 0x00}, {0xe9, 0xd9, 0x05, 0x00, 0x00, 0x90}, 6},
{0x00585bb0, {0x0f, 0x85, 0x55, 0x06, 0x00, 0x00}, {0xe9, 0x56, 0x06, 0x00, 0x00, 0x90}, 6},
// the quest manager's hero data writer: past the check to 0x46606b (it
// writes registers before reading them; the stream keeps its clean marker)
{0x004659dc, {0x8b, 0x0d, 0xc0, 0x3b, 0xad, 0x00}, {0xe9, 0x8a, 0x06, 0x00, 0x00, 0x90}, 6},
// 0x619d33: past the check to 0x61a2de with ebp as the check leaves it
// (`mov ebp, [esp+0x34]`; edi already holds 0x88e874)
{0x00619d33, {0x8b, 0x0d, 0xdc, 0xcd, 0x82, 0x01}, {0x8b, 0x6c, 0x24, 0x34, 0x90, 0x90}, 6},
{0x00619d39, {0x8d, 0x44, 0x24, 0x14, 0x50}, {0xe9, 0xa0, 0x05, 0x00, 0x00}, 5},
// VerifyHeroMirror (0x5905f0, plain ret, 4 callers test al): `mov al, 1; ret`
{0x005905f0, {0x51, 0x8b, 0x0d}, {0xb0, 0x01, 0xc3}, 3},
// stream checks of the quest manager's hero data: the cheat marker
// 0xfea4fea4 read (`jne` past the punishment -> `jmp`) and three stored
// values compared with the hero (`je` -> `jmp`)
{0x00467782, {0x0f, 0x85, 0xbd, 0x01, 0x00, 0x00}, {0xe9, 0xbe, 0x01, 0x00, 0x00, 0x90}, 6},
{0x0046a60b, {0x74, 0x18}, {0xeb, 0x18}, 2},
{0x0046a64e, {0x74, 0x18}, {0xeb, 0x18}, 2},
{0x0046a696, {0x74, 0x18}, {0xeb, 0x18}, 2},
// loadHero: the hero file's cheater flag (`mov byte [0x182cdd0], 1` -> 0)
{0x006066eb, {0xc6, 0x05, 0xd0, 0xcd, 0x82, 0x01, 0x01}, {0xc6, 0x05, 0xd0, 0xcd, 0x82, 0x01, 0x00}, 7},
// character selection: the flag from the hero record's mark (+0x3ef):
// `setne cl` -> `xor cl, cl`
{0x0070da8c, {0x0f, 0x95, 0xc1}, {0x32, 0xc9, 0x90}, 3},
};
void Startup() {
const int failed = re::ReplaceAll(kNoAntiCheat);
re::Log("anticheat: removed (%d patches failed)", failed);
}
RE_STARTUP(AntiCheatStartup, Startup);
} // namespace
} // namespace mods::anticheat
+4
View File
@@ -19,6 +19,7 @@
#include "game/engine/fx.h" #include "game/engine/fx.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "mods/dwarfarts.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
#include "re/patch.h" #include "re/patch.h"
@@ -126,6 +127,9 @@ uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_
RE_HOOK(0x00582c10, Arts_AddEffect); RE_HOOK(0x00582c10, Arts_AddEffect);
uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_t a2, float value, float duration, uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_t a2, float value, float duration,
uint32_t art, uint32_t a6, uint32_t a7, uint32_t a8) { uint32_t art, uint32_t a6, uint32_t a7, uint32_t a8) {
if (!mods::dwarfarts::KeepEffect(kind, art)) {
return 1;
}
if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) { if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) {
return 1; return 1;
} }
+5 -16
View File
@@ -3,7 +3,7 @@
// The game renders at a fixed resolution (1024x768). Unmodified, windowed mode // The game renders at a fixed resolution (1024x768). Unmodified, windowed mode
// shows that as a 1024x768 popup centred on the desktop; fullscreen mode // shows that as a 1024x768 popup centred on the desktop; fullscreen mode
// switches the display mode. With this mod the game always runs windowed, the // switches the display mode. With this mod the game always runs windowed, the
// popup covers the whole monitor, and each frame is scaled up into the largest // popup covers the whole monitor (mods/monitor.h: the preferred one), and each frame is scaled up into the largest
// centred rectangle with the game's aspect ratio (black bars elsewhere). // centred rectangle with the game's aspect ratio (black bars elsewhere).
// //
// Mouse positions are mapped back from that rectangle to game pixels, both in // Mouse positions are mapped back from that rectangle to game pixels, both in
@@ -35,6 +35,7 @@
#include "game/render/dx_driver7.h" #include "game/render/dx_driver7.h"
#include "mods/hud.h" #include "mods/hud.h"
#include "mods/input.h" #include "mods/input.h"
#include "mods/monitor.h"
#include "mods/widescreen.h" #include "mods/widescreen.h"
#include "re/config.h" #include "re/config.h"
#include "game/addresses.h" #include "game/addresses.h"
@@ -56,14 +57,12 @@ struct Viewport {
HWND window = nullptr; HWND window = nullptr;
bool runInBackground = false; bool runInBackground = false;
Layout full; // the whole back buffer Layout full; // the whole back buffer
Layout classic; // its top-left 1024x768 (menus, loading screen; widescreen mod)
}; };
Viewport g_viewport; Viewport g_viewport;
std::atomic<bool> g_classicShown = false; // which layout the last presented frame used
const Layout& CurrentLayout() { const Layout& CurrentLayout() {
return g_classicShown ? g_viewport.classic : g_viewport.full; return g_viewport.full;
} }
bool Enabled() { bool Enabled() {
@@ -92,9 +91,7 @@ RECT FitRect(int areaWidth, int areaHeight, int width, int height, bool integerS
void ApplyBorderless(game::DxDriver7* driver) { void ApplyBorderless(game::DxDriver7* driver) {
HWND window = driver->window; HWND window = driver->window;
MONITORINFO monitor = {sizeof(monitor)}; const RECT area = mods::monitor::GameMonitorRect();
GetMonitorInfoW(MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST), &monitor);
const RECT& area = monitor.rcMonitor;
const int areaWidth = area.right - area.left; const int areaWidth = area.right - area.left;
const int areaHeight = area.bottom - area.top; const int areaHeight = area.bottom - area.top;
@@ -116,10 +113,8 @@ void ApplyBorderless(game::DxDriver7* driver) {
viewport.window = window; viewport.window = window;
viewport.runInBackground = re::ConfigInt("borderless", "RunInBackground", 1) != 0; viewport.runInBackground = re::ConfigInt("borderless", "RunInBackground", 1) != 0;
viewport.full = makeLayout(driver->mode.width, driver->mode.height); viewport.full = makeLayout(driver->mode.width, driver->mode.height);
viewport.classic = makeLayout(std::min<int>(driver->mode.width, 1024), std::min<int>(driver->mode.height, 768));
viewport.active = true; viewport.active = true;
g_viewport = viewport; g_viewport = viewport;
g_classicShown = false;
// Paint the bars: the window class background brush is black. // Paint the bars: the window class background brush is black.
InvalidateRect(window, nullptr, TRUE); InvalidateRect(window, nullptr, TRUE);
@@ -175,7 +170,7 @@ int __fastcall DxDriver7_SetDisplayMode(DxDriver7* self, void* edx, void* device
} }
DxDisplayMode windowed = *mode; DxDisplayMode windowed = *mode;
windowed.fullscreen = 0; windowed.fullscreen = 0;
mods::widescreen::ApplyRenderSize(windowed, self->window); mods::widescreen::ApplyRenderSize(windowed);
g_viewport.active = false; g_viewport.active = false;
const int result = DxDriver7_SetDisplayMode_original(self, edx, deviceCaps, &windowed, unknown); const int result = DxDriver7_SetDisplayMode_original(self, edx, deviceCaps, &windowed, unknown);
if (result) { if (result) {
@@ -188,12 +183,6 @@ int __fastcall DxDriver7_Flip(DxDriver7* self, void* edx) {
// The original computes the blit rectangles once, for a centred window of // The original computes the blit rectangles once, for a centred window of
// exactly the game's size; override them on every present. // exactly the game's size; override them on every present.
if (g_viewport.active && self->window == g_viewport.window) { if (g_viewport.active && self->window == g_viewport.window) {
// Menus and the loading screen only fill the top-left 1024x768 of a
// widened back buffer; show just that part, scaled like before.
const bool classic = mods::widescreen::IsClassicFrame();
if (g_classicShown.exchange(classic) != classic) {
InvalidateRect(self->window, nullptr, TRUE); // repaint the bars for the new layout
}
const Layout& layout = CurrentLayout(); const Layout& layout = CurrentLayout();
self->blitSource = {0, 0, layout.gameWidth, layout.gameHeight}; self->blitSource = {0, 0, layout.gameWidth, layout.gameHeight};
self->blitDest = layout.screen; self->blitDest = layout.screen;
+2 -7
View File
@@ -149,11 +149,6 @@ unsigned Chains(unsigned level) {
return 1 + level / 10; return 1 + level / 10;
} }
unsigned ArtLevel(void* creature, uint16_t art) {
uint8_t* entry = game::FindCooldownEntry(static_cast<uint8_t*>(creature) + game::kCreatureArts, art);
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
}
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) { bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
return std::find(ids.begin(), ids.end(), id) != ids.end(); return std::find(ids.begin(), ids.end(), id) != ids.end();
} }
@@ -276,7 +271,7 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
return; return;
} }
Forget(); Forget();
const unsigned level = ArtLevel(caster, g_heroArt); const unsigned level = game::ArtLevel(caster, g_heroArt);
const uint32_t hits = Hits(level); const uint32_t hits = Hits(level);
const unsigned chains = Chains(level); const unsigned chains = Chains(level);
event[kEventHitsLeft] = hits; event[kEventHitsLeft] = hits;
@@ -416,7 +411,7 @@ std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) {
} }
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) { void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
text = Rewrite(text, ArtLevel(creature, art.Id())); text = Rewrite(text, game::ArtLevel(creature, art.Id()));
} }
struct TooltipRegistration { struct TooltipRegistration {
+1 -2
View File
@@ -33,8 +33,7 @@ using namespace game; // combo and cooldown layout (arts.h)
// The art's level now (cooldown entry level + bonus levels), 0 if unknown. // The art's level now (cooldown entry level + bonus levels), 0 if unknown.
uint16_t CurrentLevel(uint8_t* arts, uint32_t art) { uint16_t CurrentLevel(uint8_t* arts, uint32_t art) {
uint8_t* entry = art ? FindCooldownEntry(arts, static_cast<uint16_t>(art)) : nullptr; return art ? static_cast<uint16_t>(ArtLevelIn(arts, static_cast<uint16_t>(art))) : 0;
return entry ? static_cast<uint16_t>(entry[kCooldownLevel] + entry[kCooldownBonusLevels]) : 0;
} }
void SyncCombos(uint8_t* hero) { void SyncCombos(uint8_t* hero) {
+684
View File
@@ -0,0 +1,684 @@
// The dwarf's Kanonenschuss (0x43f) and Mörsergranate (0x43e) (request of
// 2026-10-25, docs/dwarf-arts.md).
//
// Kanonenschuss: "remove the knockback, instead it can be just a stun". On a
// successful stun roll the dwarf's combat move code (0x5145d0, case 0x43f)
// plays MOTION_HIT_A on the target (0x5936e0 at 0x517709) and sends it the
// stun order (cEvent_creature 3 from 0x5177aa, +0x14 = 100). The game's stun
// is a push (measured: about 100 units away from the attacker in 0.3 s): the
// order sets creature flag 0x20000, the push left (+0x24d) and the attacker
// (+0x259); while the flag is set, cCreature::Update (branch 0x594c82) skips
// everything else and moves the creature away from the attacker, lowering
// the push left by each step, and clears the flag at 0. The mod skips the
// motion, and for creatures the cannon stunned the push step (0x594deb)
// moves them by 0 and keeps the push left above 0 for kStunMs: they stand
// still, doing nothing, then carry on. The shot's ray (25 steps of 40 = 1000) gets 45 steps (1800),
// for the widened view ("range needs to be increased to accommodate for the
// increased viewport", 2026-10-26).
//
// Mörsergranate: "the radius of the explosion should increase every 5 skill
// levels". The grenade (type 0x3fa) creates an impact object
// (TYPE_FX_GRENADEIMPACT, 0x3fc) where it lands; that object gathers its
// targets with cObjectManager 0x5ff820 (called from 0x5ab400 at 0x5ab63d): a
// creature is hit when its distance is below the impact type's size (150)
// plus the creature type's size (object type table 0x428bb0). The mod passes
// that call a radius of size × (1 + kRadiusGrowth × (level / 5)), with the
// level of the owner's Mörsergranate.
//
// Impact marker (asked 2026-10-25): where the grenade will land and its
// explosion radius, a red circle with low alpha on the ground. The landing
// point is decided at launch (0x5145d0 case 0x43e: the target's position, or
// 200 units from the dwarf with +-15 jitter when closer) and sent to the new
// grenade with cEventFX 0x96 (constructed at 0x5169fa: code, owner, 0,
// position area / x / y / level). The construct call notes the grenade and
// the point; the circle is drawn after the world view's ground (0x62d530,
// before the objects) until the impact gathers its targets (the grenade
// object itself stays about 30 s longer).
//
// Eingraben (spell 0x39), the dwarf's escape (request of 2026-10-28): "cast a
// protective barrier around the Dwarf (current visual effect), clear him of
// all movement impeding effects and then teleport to the position marked by
// the cursor (if possible)", shown as the dwarf going underground and coming
// out at the target, 1 s each; range the visible area; the barrier is the
// skill's current defensive bonus for 3 s; "he should be able to see the
// target location"; like the battlemage's Phasenverschiebung. The spells run
// in cCreature::CastSpell (0x5222c0, thiscall (cast), ret 4; cast +0x08 /
// +0x0c the ground point, +0x20 the spell). Case 0x39 creates object type
// 0x480 at the caster with cEventFX 0x9d (owner and target the caster) and
// plays sound 0x840: the barrier, which gives the bonus. Case 1
// (Phasenverschiebung) walks from the caster towards the target in steps of
// 10 while the cell is passable (world 0x636b60) and keeps the last point a
// creature can stand on (0x636eb0); a wall in the way ends the walk, so the
// target has to be in sight. The mod aims Eingraben at a point (definition
// +0x3c: 5 around the caster -> 3 a point), runs the original case (the barrier
// and its bonus last kBarrierSeconds) and moves the dwarf to the end of that
// walk; the cursor limits it to the visible area. The move takes 2 s without
// orders: the barrier turns solid, he moves inside it, it clears again
// ("not instant ... from transparent to solid and back", 2026-10-28; sinking
// him into the ground failed: the camera follows his height).
#include "mods/dwarfarts.h"
#include <windows.h>
#include <cstdint>
#include <cstring>
#include <algorithm>
#include <cmath>
#include <map>
#include <vector>
#include "game/engine/art.h"
#include "game/engine/arts.h"
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/worldview.h"
#include "game/ui/draw.h"
#include "mods/widescreen.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::dwarfarts {
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
uint32_t only, int32_t radius, const uint8_t* fx);
void DrawMarkers(const uint8_t* view);
namespace {
// The stunned target's motion: push 3.0f, push 0.25f... (0x5176f8) up to the
// call of 0x5936e0 (0x517709, 5 bytes): jumped over.
constexpr uint32_t kCannonStunMotion = 0x005176f8;
constexpr uint8_t kStunMotionPush[] = {0x68, 0x00}; // push 0x3e800000 (first two bytes)
constexpr uint8_t kSkipStunMotion[] = {0xeb, 0x14}; // jmp 0x51770e, after the call
// The ray's step count: cmp edi, 19h (0x517389, imm8 at +2); steps of 40.
constexpr uint32_t kCannonRaySteps = 0x00517389;
constexpr uint8_t kRayStepsOriginal = 25;
constexpr uint8_t kRaySteps = 45;
// The cannon's stun order: the return address of its call [eax+18h].
constexpr uint32_t kCannonStunOrderReturn = 0x005177ad;
constexpr uint32_t kOrderStun = 3;
constexpr DWORD kStunMs = 1500;
// The push step in cCreature::Update: lea edx,[ebp+18h]; mov edi,[esp+60h]
// (y offset; x offset in ebx); mov [ebp+24dh],eax (push left).
constexpr uint32_t kPushStep = 0x00594deb;
constexpr uint8_t kPushStepCode[] = {0x8d, 0x55, 0x18, 0x8b, 0x7c, 0x24, 0x60, 0x89, 0x85, 0x4d, 0x02, 0x00, 0x00};
std::map<uint32_t, DWORD> g_stunEnds; // creature id -> GetTickCount at which its stun ends
constexpr uint32_t kGrenadeImpactType = 0x3fc; // TYPE_FX_GRENADEIMPACT
constexpr uint32_t kImpactGatherCall = 0x005ab63d; // in 0x5ab400
constexpr uint32_t kGatherInRange = 0x005ff820; // cObjectManager: objects within type sizes
constexpr size_t kFxOwner = 0x54; // u32: the creature the effect hits for
constexpr unsigned kLevelsPerStep = 5;
constexpr float kRadiusGrowth = 0.1f; // of the impact's own size, per step
// The size of object type `type` (0x428bb0 on the type table *0xaa9564,
// 50 when the table has none).
int32_t TypeSize(uint32_t type) {
using Fn = int32_t(__fastcall*)(void* table, void* edx, uint32_t type);
return reinterpret_cast<Fn>(0x00428bb0)(*reinterpret_cast<void**>(0x00aa9564), nullptr, type);
}
// The explosion radius of a grenade thrown by `owner` (world units; a
// creature is hit when within it plus its own size).
int32_t ImpactRadiusAt(unsigned level) {
const int32_t size = TypeSize(kGrenadeImpactType);
return static_cast<int32_t>(static_cast<float>(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep)));
}
int32_t ImpactRadius(void* owner) {
return ImpactRadiusAt(owner ? game::ArtLevel(owner, game::kArtMoerser) : 0);
}
// The tooltip (game::Art::Tooltip filter, as for Kettenblitz in
// mods/chainlightning.cpp): sections start with the level line ("Aktuelle
// Stufe:", in the skill book also "Nächste erlernte Stufe:") followed by
// "Name: value" lines; each section gets "Explosionsradius: N" after its last
// value, in that line's colour.
constexpr const wchar_t* kCurrentLevelLine = L"Aktuelle Stufe:";
constexpr const wchar_t* kNextLevelLine = L"Nächste erlernte Stufe:";
constexpr const wchar_t* kValueSeparator = L": ";
constexpr size_t kColourCodeLength = 10; // "\cAARRGGBB"
std::wstring LeadingColourCodes(const std::wstring& line) {
size_t end = 0;
while (line.compare(end, 2, L"\\c") == 0 && end + kColourCodeLength <= line.size()) {
end += kColourCodeLength;
}
return line.substr(0, end);
}
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
std::vector<std::wstring> lines;
for (size_t start = 0; start < text.size();) {
size_t end = text.find(L'\n', start);
end = end == std::wstring::npos ? text.size() : end + 1;
lines.push_back(text.substr(start, end - start));
start = end;
}
const unsigned current = game::ArtLevel(creature, art.Id());
std::vector<std::wstring> out;
unsigned level = current;
bool inSection = false;
size_t lastValue = SIZE_MAX; // index in `out`
const auto closeSection = [&] {
if (!inSection || lastValue == SIZE_MAX) {
return;
}
std::wstring& after = out[lastValue];
const bool last = after.empty() || after.back() != L'\n';
if (last) {
after += L'\n';
}
out.insert(out.begin() + static_cast<ptrdiff_t>(lastValue) + 1,
LeadingColourCodes(after) + L"Explosionsradius: " + std::to_wstring(ImpactRadiusAt(level)) +
(last ? L"" : L"\n"));
};
for (const std::wstring& line : lines) {
const bool currentHeader = line.find(kCurrentLevelLine) != std::wstring::npos;
const bool nextHeader = line.find(kNextLevelLine) != std::wstring::npos;
if (currentHeader || nextHeader) {
closeSection();
inSection = true;
level = nextHeader ? current + 1 : current;
lastValue = SIZE_MAX;
} else if (inSection && line.find(kValueSeparator) != std::wstring::npos) {
lastValue = out.size();
}
out.push_back(line);
}
closeSection();
text.clear();
for (const std::wstring& line : out) {
text += line;
}
}
// 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius),
// ret 0x14) with the effect object in ebx.
__declspec(naked) void GatherTargetsThunk() {
__asm {
push ebx // the effect object
push dword ptr [esp+18h] // radius (-1: the type's size)
push dword ptr [esp+18h] // only objects of this type (0: any)
push dword ptr [esp+18h] // type
push dword ptr [esp+18h] // position
push dword ptr [esp+18h] // out vector
call GatherTargets
ret 14h
}
}
void OnOrder(void* creature, const game::CreatureOrder& order, void* caller) {
if (order.command == kOrderStun && reinterpret_cast<uint32_t>(caller) == kCannonStunOrderReturn) {
g_stunEnds[*reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(creature) + game::kCreatureId)] =
GetTickCount() + kStunMs;
}
}
// The push step of `creature` with `left` push left after it: the push left
// to store (low dword) and whether to stay in place (high dword).
uint64_t __cdecl PushStep(uint8_t* creature, int32_t left) {
const auto it = g_stunEnds.find(*reinterpret_cast<uint32_t*>(creature + game::kCreatureId));
if (it == g_stunEnds.end()) {
return static_cast<uint32_t>(left);
}
const bool stunned = static_cast<int32_t>(GetTickCount() - it->second) < 0;
if (!stunned) {
g_stunEnds.erase(it);
}
return (uint64_t{1} << 32) | static_cast<uint32_t>(stunned ? 1 : 0);
}
__declspec(naked) void PushStepThunk() {
__asm {
push eax // push left after this step
push ebp // the creature
call PushStep
add esp, 8
test edx, edx
jz move
xor ebx, ebx // x offset
mov dword ptr [esp+64h], 0 // y offset (the caller's [esp+60h])
move:
mov edi, [esp+64h]
mov [ebp+24dh], eax
lea edx, [ebp+18h]
ret
}
}
// The cannon creates its impact effect (TYPE_FX_DWARF_SHOOT_IMPACT 0x47f,
// attached to the target with cEventFX 0x9c) on every target it hits; it stays
// on targets that were not pushed. CreateAt (0x5fbaa0, thiscall (type,
// position, link, 0, 0), ret 0x14) at 0x517855, the target in ebx: only for
// targets in the stun state (the order was not resisted); 0 otherwise, which
// the game skips (GetObject(0) is null).
constexpr uint32_t kImpactCreate = 0x00517855;
constexpr uint32_t kCreateAt = 0x005fbaa0;
constexpr size_t kCreatureFlags = 0x200;
constexpr uint32_t kCreaturePushed = 0x20000; // the stun's push state (cCreature::Update 0x594c82)
uint32_t __fastcall CreateImpact(void* manager, void*, uint32_t type, const void* position, uint32_t link, uint32_t a,
uint32_t b, const uint8_t* target) {
if (!target || !(*reinterpret_cast<const uint32_t*>(target + kCreatureFlags) & kCreaturePushed)) {
return 0;
}
using Fn = uint32_t(__fastcall*)(void*, void*, uint32_t, const void*, uint32_t, uint32_t, uint32_t);
return reinterpret_cast<Fn>(kCreateAt)(manager, nullptr, type, position, link, a, b);
}
__declspec(naked) void CreateImpactThunk() {
__asm {
push ebx // the target
push dword ptr [esp+18h]
push dword ptr [esp+18h]
push dword ptr [esp+18h]
push dword ptr [esp+18h]
push dword ptr [esp+18h]
call CreateImpact
ret 14h
}
}
// --- impact marker ---------------------------------------------------------
constexpr uint32_t kGrenadeEventConstruct = 0x005169fa; // call cEventFX::Construct
constexpr uint32_t kFxEventConstruct = 0x0041e0a0; // thiscall, ret 0x44
constexpr uint32_t kFxEventLand = 0x96;
constexpr size_t kObjectId = 0xc;
constexpr uint32_t kMarkerFill = 0x40ff0000; // red, low alpha
constexpr uint32_t kMarkerRim = 0xa0ff2020;
constexpr float kRimWidth = 2.0f; // screen pixels
constexpr int kCircleSegments = 48;
// A grenade object outlives its explosion by about 30 s (measured
// 2026-10-26), so its marker ends with the explosion: the impact gathering
// its targets (GatherTargets; it does so on several frames, at exactly the
// noted landing point).
constexpr float kImpactMatchDistance = 100.0f; // world units; the landing jitter is +-15
struct Grenade {
uint8_t* object;
uint32_t id;
uint32_t owner;
float x, y; // landing point, world units
float radius;
};
std::vector<Grenade> g_grenades;
// The explosion of `owner`'s grenade at `position` (world units): ends the
// marker of that grenade, the owner's one closest to it.
void OnImpact(uint32_t owner, float x, float y) {
auto closest = g_grenades.end();
float best = kImpactMatchDistance;
for (auto it = g_grenades.begin(); it != g_grenades.end(); ++it) {
const float distance = std::hypot(it->x - x, it->y - y);
if (it->owner == owner && distance < best) {
best = distance;
closest = it;
}
}
if (closest != g_grenades.end()) {
g_grenades.erase(closest);
}
}
// Construct's arguments: code, owner id, target id, position (area, x, y,
// level), ...; the new grenade object is in esi.
void __cdecl NoteGrenade(const uint32_t* args, uint8_t* grenade) {
if (args[0] != kFxEventLand || !grenade) {
return;
}
const auto x = static_cast<float>(static_cast<int32_t>(args[4]));
const auto y = static_cast<float>(static_cast<int32_t>(args[5]));
const auto radius = static_cast<float>(ImpactRadius(game::GetCreature(args[1])));
g_grenades.push_back({grenade, *reinterpret_cast<uint32_t*>(grenade + kObjectId), args[1], x, y, radius});
}
__declspec(naked) void GrenadeEventThunk() {
__asm {
pushad
lea eax, [esp+24h] // the arguments (above pushad and the return address)
push esi // the grenade
push eax
call NoteGrenade
add esp, 8
popad
mov eax, kFxEventConstruct
jmp eax
}
}
bool Alive(const Grenade& grenade) {
return game::GetObjectById(grenade.id) == grenade.object;
}
using SetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD);
using GetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD*);
// Render states for a translucent overlay without depth, restored after.
class OverlayStates {
public:
explicit OverlayStates(void* device) : device_(device) {
void** vtable = *static_cast<void***>(device);
set_ = reinterpret_cast<SetRenderStateFn>(vtable[game::kDeviceSetRenderState]);
const auto get = reinterpret_cast<GetRenderStateFn>(vtable[game::kDeviceGetRenderState]);
for (size_t i = 0; i < kCount; ++i) {
get(device_, kStates[i], &saved_[i]);
set_(device_, kStates[i], kValues[i]);
}
}
~OverlayStates() {
for (size_t i = 0; i < kCount; ++i) {
set_(device_, kStates[i], saved_[i]);
}
}
OverlayStates(const OverlayStates&) = delete;
OverlayStates& operator=(const OverlayStates&) = delete;
private:
// D3DRENDERSTATE ZENABLE, ZWRITEENABLE off; alpha blending SRCALPHA /
// INVSRCALPHA.
static constexpr size_t kCount = 5;
static constexpr DWORD kStates[kCount] = {7, 14, game::kStateAlphaBlendEnable, game::kStateSourceBlend,
game::kStateDestinationBlend};
static constexpr DWORD kValues[kCount] = {FALSE, FALSE, TRUE, 5, 6};
void* device_;
SetRenderStateFn set_ = nullptr;
DWORD saved_[kCount] = {};
};
game::ScreenVertex Vertex(const game::Point2& p, uint32_t colour) {
return {p.x, p.y, 0, 1, colour, 0, 0, 0};
}
// The circle on the ground: a translucent disc and a brighter rim.
void DrawMarker(const uint8_t* view, void* device, const Grenade& grenade) {
const float halfWidth = static_cast<float>(mods::widescreen::RenderWidth()) / 2;
const auto screen = [&](float x, float y) {
return game::PlaneToScreen(view, game::WorldToPlane(x, y), halfWidth);
};
const game::Point2 centre = screen(grenade.x, grenade.y);
game::Point2 rim[kCircleSegments + 1];
for (int i = 0; i <= kCircleSegments; ++i) {
const float angle = 6.2831853f * static_cast<float>(i % kCircleSegments) / kCircleSegments;
rim[i] = screen(grenade.x + grenade.radius * std::cos(angle), grenade.y + grenade.radius * std::sin(angle));
}
// The rim band reaches inwards from the circle by kRimWidth.
const auto inner = [&](const game::Point2& p) {
const float dx = centre.x - p.x, dy = centre.y - p.y;
const float length = std::hypot(dx, dy);
return length > kRimWidth ? game::Point2{p.x + dx / length * kRimWidth, p.y + dy / length * kRimWidth}
: centre;
};
static std::vector<game::ScreenVertex> vertices;
vertices.clear();
for (int i = 0; i < kCircleSegments; ++i) {
vertices.insert(vertices.end(),
{Vertex(centre, kMarkerFill), Vertex(rim[i], kMarkerFill), Vertex(rim[i + 1], kMarkerFill)});
}
for (int i = 0; i < kCircleSegments; ++i) {
const game::Point2 a = rim[i], b = rim[i + 1], c = inner(rim[i]), d = inner(rim[i + 1]);
vertices.insert(vertices.end(), {Vertex(a, kMarkerRim), Vertex(b, kMarkerRim), Vertex(c, kMarkerRim),
Vertex(c, kMarkerRim), Vertex(b, kMarkerRim), Vertex(d, kMarkerRim)});
}
game::FillTriangles(device, vertices.data(), vertices.size());
}
// --- Eingraben ----------------------------------------------------------------
// The art's duration (0x4d24f0: definition +0x76 + level × +0x78, at most
// +0x82 when set; Eingraben 7 + level, at most 15), which the barrier, its
// bonus and the tooltip use: a fixed kBarrierSeconds ("down to 4 seconds",
// 2026-10-28).
constexpr int16_t kBarrierSeconds = 4;
constexpr size_t kSpellDurationBase = 0x76; // i16
constexpr size_t kSpellDurationPerLevel = 0x78; // i16
constexpr size_t kSpellDurationMax = 0x82; // u16, 0: none
// The barrier's effects (logged 2026-10-28, all with art 0x39): kind 0x3a
// value 0 from its particles (0x7b9602), which holds the dwarf in place, and
// kind 2 value 0.5 plus kinds 7..0xa value 30 from its update (0x5b5862..),
// the defensive bonus.
constexpr uint32_t kEffectDugIn = 0x3a;
constexpr int32_t kWalkStep = 10; // Phasenverschiebung's step (0x52288a: 10.0)
constexpr size_t kSpellLaunchType = 0x3c;
// The world (cWorld, the object manager's first field), as the art code
// passes it to its cell checks.
void* World() {
void* manager = game::ObjectManager();
return manager ? *static_cast<void**>(manager) : nullptr;
}
// cWorld 0x636b60 (thiscall (position), ret 4): the cell is passable.
bool Passable(void* world, game::MapPosition& at) {
using Fn = bool(__fastcall*)(void*, void*, game::MapPosition*);
return reinterpret_cast<Fn>(0x00636b60)(world, nullptr, &at);
}
// cWorld 0x636eb0 (thiscall (creature id, position), ret 8): the creature can
// stand there (may correct the position's area).
bool Standable(void* world, uint32_t creature, game::MapPosition& at) {
using Fn = bool(__fastcall*)(void*, void*, uint32_t, game::MapPosition*);
return reinterpret_cast<Fn>(0x00636eb0)(world, nullptr, creature, &at);
}
// Phasenverschiebung's walk (0x5227a6..): from the creature towards (x, y),
// the last point it can stand on before a cell that is not passable.
game::MapPosition DigDestination(uint8_t* creature, int32_t x, int32_t y) {
const auto& from = *reinterpret_cast<const game::MapPosition*>(creature + game::kObjectPosition);
game::MapPosition best = from;
void* world = World();
const float dx = static_cast<float>(x - from.x), dy = static_cast<float>(y - from.y);
const float length = std::sqrt(dx * dx + dy * dy);
if (!world || length < kWalkStep) {
return best;
}
const auto stepX = static_cast<int32_t>(dx / length * kWalkStep);
const auto stepY = static_cast<int32_t>(dy / length * kWalkStep);
const auto steps = static_cast<int32_t>(length / kWalkStep);
const uint32_t id = *reinterpret_cast<const uint32_t*>(creature + game::kCreatureId);
game::MapPosition at = from;
for (int32_t i = 0; i <= steps && Passable(world, at); ++i) {
game::MapPosition stand = at;
if (Standable(world, id, stand)) {
best = stand;
}
at.x += stepX;
at.y += stepY;
}
return best;
}
// The dig takes kFadeMs twice: the barrier turns solid, the dwarf moves
// inside it, and it turns transparent again; he takes no orders meanwhile.
constexpr DWORD kFadeMs = 1000;
struct Digging {
uint32_t id = 0; // 0: none
DWORD start = 0; // GetTickCount of the cast, or of the move once moved
game::MapPosition to;
bool moved = false;
};
Digging g_dig;
void Dig(uint8_t* creature, int32_t x, int32_t y) {
const auto& from = *reinterpret_cast<const game::MapPosition*>(creature + game::kObjectPosition);
const game::MapPosition to = DigDestination(creature, x, y);
re::Log("dwarfarts: Eingraben %d,%d towards %d,%d -> %d,%d", from.x, from.y, x, y, to.x, to.y);
if (to.x == from.x && to.y == from.y) {
return;
}
g_dig = {*reinterpret_cast<const uint32_t*>(creature + game::kCreatureId), GetTickCount(), to, false};
}
// Engine tick: once the barrier is solid, the move; the dig's end.
void OnTick(uint8_t*) {
auto* creature = g_dig.id ? static_cast<uint8_t*>(game::GetCreature(g_dig.id)) : nullptr;
if (!creature || GetTickCount() - g_dig.start < kFadeMs) {
g_dig.id = creature ? g_dig.id : 0;
return;
}
if (g_dig.moved) {
g_dig.id = 0;
return;
}
game::Teleport(creature, static_cast<int32_t>(g_dig.to.x / game::kMapCellSize),
static_cast<int32_t>(g_dig.to.y / game::kMapCellSize));
g_dig.moved = true;
g_dig.start = GetTickCount();
}
bool AcceptOrder(void* creature, const game::CreatureOrder&) {
return !g_dig.id || *reinterpret_cast<const uint32_t*>(static_cast<uint8_t*>(creature) + game::kCreatureId) != g_dig.id;
}
void OnGameEnd() {
g_dig = {};
}
// The barrier (cParticleSystem_dwarf_shield, draw 0x7b92b0) draws its model
// with alpha pulse × 128 (pulse 0.4..1, +0x20b8; __ftol at 0x7b9483), then
// again additively. While a dig runs that alpha goes to opaque and back.
constexpr uint32_t kShieldAlphaToInt = 0x007b9483;
constexpr uint32_t kFloatToInt = 0x00848f94; // __ftol
constexpr int32_t kOpaque = 255;
int32_t __cdecl ShieldAlpha(int32_t alpha) {
if (!g_dig.id) {
return alpha;
}
const float part = std::fmin(static_cast<float>(GetTickCount() - g_dig.start) / kFadeMs, 1.0f);
const float solid = g_dig.moved ? 1.0f - part : part;
return alpha + static_cast<int32_t>(static_cast<float>(kOpaque - alpha) * solid);
}
__declspec(naked) void ShieldAlphaThunk() {
__asm {
mov eax, kFloatToInt
call eax
push eax
call ShieldAlpha
add esp, 4
ret
}
}
bool AimEingraben() {
auto* def = const_cast<uint8_t*>(game::FindSpellDef(game::kArtEingraben));
if (!def || def[kSpellLaunchType] != static_cast<uint8_t>(game::LaunchType::AroundSelf)) {
return false;
}
def[kSpellLaunchType] = static_cast<uint8_t>(game::LaunchType::Ground);
*reinterpret_cast<int16_t*>(def + kSpellDurationBase) = kBarrierSeconds;
*reinterpret_cast<int16_t*>(def + kSpellDurationPerLevel) = 0;
*reinterpret_cast<uint16_t*>(def + kSpellDurationMax) = 0;
return true;
}
void Startup() {
const bool aim = AimEingraben();
re::Log("dwarfarts: Eingraben aimed at a point, barrier %d s%s", kBarrierSeconds, aim ? "" : " (patch FAILED)");
const bool fade = re::RedirectCall(kShieldAlphaToInt, kFloatToInt, reinterpret_cast<const void*>(&ShieldAlphaThunk));
re::Log("dwarfarts: Eingraben barrier turns solid%s", fade ? "" : " (patch FAILED)");
const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk));
re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)");
uint8_t call[sizeof(kPushStepCode)];
std::memset(call, 0x90, sizeof(call));
call[0] = 0xe8;
const int32_t rel = reinterpret_cast<int32_t>(&PushStepThunk) - static_cast<int32_t>(kPushStep + 5);
std::memcpy(call + 1, &rel, sizeof(rel));
const bool hold = re::ReplaceCode(kPushStep, kPushStepCode, call, sizeof(call));
re::Log("dwarfarts: Kanonenschuss stun holds in place%s", hold ? "" : " (patch FAILED)");
const bool stun = re::ReplaceCode(kCannonStunMotion, kStunMotionPush, kSkipStunMotion, sizeof(kSkipStunMotion));
re::Log("dwarfarts: Kanonenschuss without stun motion%s", stun ? "" : " (patch FAILED)");
const bool range = re::PatchInstructionByte(kCannonRaySteps, 2, kRayStepsOriginal, kRaySteps);
re::Log("dwarfarts: Kanonenschuss range %d%s", kRaySteps * 40, range ? "" : " (patch FAILED)");
const bool radius =
re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast<const void*>(&GatherTargetsThunk));
re::Log("dwarfarts: Mörsergranate radius%s", radius ? "" : " (patch FAILED)");
const bool marker = re::RedirectCall(kGrenadeEventConstruct, kFxEventConstruct,
reinterpret_cast<const void*>(&GrenadeEventThunk));
re::Log("dwarfarts: Mörsergranate impact marker%s", marker ? "" : " (patch FAILED)");
}
RE_STARTUP(DwarfArtsStartup, Startup);
struct Registration {
Registration() {
game::OnCreatureOrder(&OnOrder);
game::FilterCreatureOrders(&AcceptOrder);
game::OnGameEnd(&OnGameEnd);
game::OnEngineTick(&OnTick);
game::AddTooltipFilter(game::kArtMoerser, &RewriteTooltip);
}
} g_registration;
} // namespace
bool KeepEffect(uint32_t kind, uint32_t art) {
return art != game::kArtEingraben || kind != kEffectDugIn;
}
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
uint32_t only, int32_t radius, const uint8_t* fx) {
if (type == kGrenadeImpactType && radius < 0 && fx) {
const uint32_t owner = *reinterpret_cast<const uint32_t*>(fx + kFxOwner);
radius = ImpactRadius(game::GetCreature(owner));
// The position: area (u16), x, y (int32, world units), level.
const auto* at = static_cast<const uint8_t*>(position);
OnImpact(owner, static_cast<float>(*reinterpret_cast<const int32_t*>(at + 4)),
static_cast<float>(*reinterpret_cast<const int32_t*>(at + 8)));
}
using Fn = uint32_t*(__fastcall*)(void*, void*, uint32_t*, const void*, uint32_t, uint32_t, int32_t);
return reinterpret_cast<Fn>(kGatherInRange)(manager, nullptr, out, position, type, only, radius);
}
// The impact markers of the grenades in flight, on the ground of `view`.
void DrawMarkers(const uint8_t* view) {
g_grenades.erase(std::remove_if(g_grenades.begin(), g_grenades.end(), [](const Grenade& g) { return !Alive(g); }),
g_grenades.end());
void* device = game::RenderDevice();
if (g_grenades.empty() || !device) {
return;
}
OverlayStates states(device);
for (const Grenade& grenade : g_grenades) {
DrawMarker(view, device, grenade);
}
}
} // namespace mods::dwarfarts
namespace game {
// cWorldView0::DrawGroundQuads (0x62d530, thiscall (renderer), ret 4): the
// ground; the objects are drawn after it.
void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer);
RE_HOOK(0x0062d530, WorldView_DrawGroundQuads);
void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer) {
WorldView_DrawGroundQuads_original(self, edx, renderer);
if (self) {
mods::dwarfarts::DrawMarkers(self);
}
}
// cCreature::CastSpell (0x5222c0, thiscall (cast), ret 4): the spells; after
// Eingraben's own case the dwarf digs to the point (cast +0x08 / +0x0c).
void __fastcall Creature_CastSpell(uint8_t* self, void* edx, uint32_t* cast);
RE_HOOK(0x005222c0, Creature_CastSpell);
void __fastcall Creature_CastSpell(uint8_t* self, void* edx, uint32_t* cast) {
const bool dig = self && cast && cast[8] == kArtEingraben;
const int32_t x = dig ? static_cast<int32_t>(cast[2]) : 0, y = dig ? static_cast<int32_t>(cast[3]) : 0;
Creature_CastSpell_original(self, edx, cast);
if (dig) {
mods::dwarfarts::Dig(self, x, y);
}
}
} // namespace game
+12
View File
@@ -0,0 +1,12 @@
#pragma once
// The dwarf's arts (dwarfarts.cpp).
#include <cstdint>
namespace mods::dwarfarts {
// Whether cCreatureArts::AddEffect adds an effect of `kind` for art `art`:
// Eingraben no longer holds the dwarf in place.
bool KeepEffect(uint32_t kind, uint32_t art);
} // namespace mods::dwarfarts
+128
View File
@@ -0,0 +1,128 @@
// Escorted NPCs stay with the hero (2026-10-26, docs/quests.md).
//
// On the follower list: "Furchtbares Verbrechen" (quest 9008) did not go on at
// its target point: the NPC walked after the hero, but the script's test
// there ("@npc", 0x498aa0 case 0x40) looks the NPC up in the hero's follower
// list (hero +0x39c), and that list was empty (measured on Borf's save: NPC
// 0x1fa with leader 1 and the follow bit, no followers on the hero). The NPC
// keeps its side of the escort (leader +0x251, status bit 4) across a load;
// the hero's list does not come back. The mod adds every NPC that follows a
// hero but is missing from its list back with the game's own AddFollower
// (0x54b070), with the entry the follow order uses ({id, 1, 1}), once after
// loading.
//
// Never left behind: in "Die Sklaven der Orks" (2412) the husband fell
// behind (the game's own catch-up teleport moved him once), and when the hero
// walked on, his sector was switched off: the game removed him and ran his
// spawn's death label (drop2412: quest variable 5, a token item dropped where
// he was), with him alive (measured 2026-10-26 in the log: his object id
// reused right after). Sectors are switched off at 2 sectors (65 tiles and
// more) from the hero's; the mod teleports every follower of the local hero
// that is more than kLeftBehindCells away to the hero (cCreature::Teleport,
// as the portals), checked every kCheckMs.
#include <windows.h>
#include <cstdint>
#include <cstdlib>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/log.h"
namespace mods::followers {
namespace {
constexpr int kTicksAfterStart = 30;
constexpr int32_t kLeftBehindCells = 40;
constexpr DWORD kCheckMs = 500;
// Small town NPCs run after the hero for a few seconds (status 0x404, leader
// the hero) without joining his list (measured 2026-10-26: types 0x2be,
// 0x2c0); they are not escorts.
constexpr int32_t kStatusTagAlong = 0x400;
int g_ticksToCheck = -1;
DWORD g_lastCheck = 0;
int32_t Field(const void* creature, size_t offset) {
return *reinterpret_cast<const int32_t*>(static_cast<const uint8_t*>(creature) + offset);
}
const game::MapPosition& Position(void* object) {
return *reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(object) + game::kObjectPosition);
}
int32_t Cell(int32_t units) {
return static_cast<int32_t>(static_cast<float>(units) / game::kMapCellSize);
}
void RepairFollowers() {
for (uint32_t id = 1; id < game::ObjectIdEnd(); ++id) {
if (game::IsPlayerHeroId(id) || !game::GetObjectById(id)) {
continue;
}
void* creature = game::GetCreature(id);
const int32_t status = creature ? Field(creature, game::kCreatureStatus) : 0;
if (!(status & game::kStatusFollowing) || (status & kStatusTagAlong)) {
continue;
}
const auto leaderId = static_cast<uint32_t>(Field(creature, game::kCreatureLeader));
void* leader = game::IsPlayerHeroId(leaderId) ? game::GetCreature(leaderId) : nullptr;
if (!leader || game::HasFollower(leader, id)) {
continue;
}
game::AddFollower(leader, {id, 1, 1});
re::Log("followers: %x back on the follower list of hero %x", id, leaderId);
}
}
// Followers of the local hero too far behind are teleported to him.
void BringAlong() {
void* hero = game::GetPlayerHero();
if (!hero) {
return;
}
const game::MapPosition& at = Position(hero);
const int32_t heroX = Cell(at.x), heroY = Cell(at.y);
game::ForEachFollower(hero, [&](uint32_t id) {
void* follower = game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
if (!follower || !game::IsAlive(follower)) {
return;
}
const game::MapPosition& position = Position(follower);
const int32_t x = Cell(position.x), y = Cell(position.y);
if (position.level != at.level ||
(std::abs(x - heroX) <= kLeftBehindCells && std::abs(y - heroY) <= kLeftBehindCells)) {
return;
}
re::Log("followers: %x left behind at %d,%d, brought to the hero at %d,%d", id, x, y, heroX, heroY);
game::Teleport(follower, heroX + 1, heroY + 1);
});
}
void OnTick(uint8_t*) {
if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) {
RepairFollowers();
}
const DWORD now = GetTickCount();
if (g_ticksToCheck <= 0 && now - g_lastCheck >= kCheckMs) {
g_lastCheck = now;
BringAlong();
}
}
void OnStart() {
g_ticksToCheck = kTicksAfterStart;
}
struct Registration {
Registration() {
game::OnEngineTick(&OnTick);
game::OnGameStart(&OnStart);
}
} g_registration;
} // namespace
} // namespace mods::followers
+48
View File
@@ -3,6 +3,9 @@
#include <intrin.h> #include <intrin.h>
#include <cstddef>
#include <cstdint>
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -31,4 +34,49 @@ void __fastcall BurningBone_Render(void* self, void* edx, void* device, Matrix4
BurningBone_Render_original(self, edx, device, transform); BurningBone_Render_original(self, edx, device, transform);
} }
// cParticleSystem_grenadeimpact::Render (vf04 0x7b66b0, vtable 0x896cdc;
// (device, matrix by value), ret 0x44): Mörsergranate's explosion. Crashed
// twice (2026-10-26 00:12 and 22:39, sacred.exe+0x361b54 in the particle
// drawer 0x7613e0): a particle's colour is read from the table at
// +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and the particle had a life of
// over 500000 (it counts down from about 255, at 250..400 per second, by
// row: cParticleSystem update 0x763ea0). Particles are 0x40 bytes in the
// vector +0x68..+0x6c; alive while life (+0x2c) and size (+0x28) are above 0.
// How a particle gets there is not known yet: such particles are switched
// off (life 0, the game's dead state) and the first ones logged.
constexpr size_t kParticlesBegin = 0x68;
constexpr size_t kParticlesEnd = 0x6c;
constexpr size_t kParticleSize = 0x40;
constexpr size_t kParticleSizeValue = 0x28; // float
constexpr size_t kParticleLife = 0x2c; // float, colour column 0..255
constexpr size_t kParticleRow = 0x3d; // u8, colour row
constexpr float kColourColumns = 256.0f;
constexpr uint8_t kGrenadeImpactRows = 3; // emitter parameter rows at +0x30d0 (0x20 bytes each)
constexpr size_t kGrenadeImpactPhase = 0x20a9; // u8: 0 burning, 1 waiting, 2 fading (vf08 0x7b64c0)
void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform);
RE_HOOK(0x007b66b0, GrenadeImpact_Render);
void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform) {
static int logged = 0;
uint8_t* begin = *reinterpret_cast<uint8_t**>(self + kParticlesBegin);
uint8_t* end = *reinterpret_cast<uint8_t**>(self + kParticlesEnd);
for (uint8_t* particle = begin; particle && particle < end; particle += kParticleSize) {
float& life = *reinterpret_cast<float*>(particle + kParticleLife);
const float size = *reinterpret_cast<const float*>(particle + kParticleSizeValue);
const uint8_t row = particle[kParticleRow];
if (!(life > 0.0f) || !(size > 0.0f) || (life < kColourColumns && row < kGrenadeImpactRows)) {
continue;
}
if (logged < 5) {
++logged;
re::Log("guards: grenade impact particle %d of %d out of range (life %g, row %u, size %g, phase %u), "
"switched off",
static_cast<int>((particle - begin) / kParticleSize), static_cast<int>((end - begin) / kParticleSize),
life, row, size, self[kGrenadeImpactPhase]);
}
life = 0.0f;
}
GrenadeImpact_Render_original(self, edx, device, transform);
}
} // namespace game } // namespace game
+1 -1
View File
@@ -66,7 +66,7 @@ int OffsetFor(Anchor anchor) {
// The main menu's frame shifts the windows in it; over a 16:9 screen // The main menu's frame shifts the windows in it; over a 16:9 screen
// (no frame) dialogs are centred like in a game (user, 2026-10-21). // (no frame) dialogs are centred like in a game (user, 2026-10-21).
const bool inMenuFrame = mods::widescreen::MenuIsShowing() && g_menuFrameDx != 0; const bool inMenuFrame = mods::widescreen::MenuIsShowing() && g_menuFrameDx != 0;
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() || inMenuFrame || if (!enabled || width <= kLayoutWidth || mods::widescreen::IsLoadingScreen() || inMenuFrame ||
anchor == Anchor::Screen) { anchor == Anchor::Screen) {
return 0; return 0;
} }
+4 -12
View File
@@ -261,7 +261,6 @@ void OnTick(uint8_t* engine) {
// letter switch (upper-case character - 'C': index bytes at 0x75868c, targets // letter switch (upper-case character - 'C': index bytes at 0x75868c, targets
// at 0x758660) sends M to index 10, "no hotkey", and the closing branch // at 0x758660) sends M to index 10, "no hotkey", and the closing branch
// compares with -1 instead of 'M' (cmp eax, 4Dh at 0x757f86). // compares with -1 instead of 'M' (cmp eax, 4Dh at 0x757f86).
constexpr int kMegamapSlot = 0x25;
constexpr int kMapBlockingSlots[] = {0x2b, 0x2c, 0x2f}; constexpr int kMapBlockingSlots[] = {0x2b, 0x2c, 0x2f};
constexpr uint32_t kMapBlockedFlag = 0x017e82c4; constexpr uint32_t kMapBlockedFlag = 0x017e82c4;
constexpr uint32_t kMapDisabledFlag = 0x017e8264; constexpr uint32_t kMapDisabledFlag = 0x017e8264;
@@ -270,32 +269,25 @@ constexpr uint32_t kUiLetterIndices = 0x0075868c;
constexpr uint8_t kUiLetterMap = 5; constexpr uint8_t kUiLetterMap = 5;
constexpr uint8_t kUiNoHotkey = 10; constexpr uint8_t kUiNoHotkey = 10;
constexpr uint32_t kUiMapCloseCompare = 0x00757f86; constexpr uint32_t kUiMapCloseCompare = 0x00757f86;
constexpr size_t kControlFlagsOffset = 0x10; // bit 0: visible
bool WindowShown(const uint32_t* ui, int slot) {
const auto* window = reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(ui[slot]));
return window && (*reinterpret_cast<const uint32_t*>(window + kControlFlagsOffset) & 1) != 0;
}
bool Flag(uint32_t address) { bool Flag(uint32_t address) {
return *reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(address)) != 0; return *reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(address)) != 0;
} }
void ToggleMap() { void ToggleMap() {
uint32_t* ui = game::UiManager(); uint32_t* ui = game::UiManager();
if (!ui || !ui[kMegamapSlot]) { if (!ui || !ui[game::kUiSlotMegamap]) {
return; return;
} }
if (WindowShown(ui, kMegamapSlot)) { if (game::UiWindowShown(ui, game::kUiSlotMegamap)) {
game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Hide); game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Hide);
return; return;
} }
for (const int slot : kMapBlockingSlots) { for (const int slot : kMapBlockingSlots) {
if (WindowShown(ui, slot)) { if (game::UiWindowShown(ui, slot)) {
return; return;
} }
} }
if (!Flag(kMapBlockedFlag) && !Flag(kMapDisabledFlag) && !WindowShown(ui, kMapDisabledSlot)) { if (!Flag(kMapBlockedFlag) && !Flag(kMapDisabledFlag) && !game::UiWindowShown(ui, kMapDisabledSlot)) {
game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Show); game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Show);
} }
} }
+15
View File
@@ -32,6 +32,7 @@
#include "mods/sharedchest.h" #include "mods/sharedchest.h"
#include "mods/skillslots.h" #include "mods/skillslots.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/patch.h"
#include "re/log.h" #include "re/log.h"
namespace mods::inventory_save { namespace mods::inventory_save {
@@ -327,6 +328,20 @@ void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn rea
HeroDataList().push_back({key, write, read}); HeroDataList().push_back({key, write, read});
} }
// saveHero writes the hero's items (chunk 0xc2) from its inventories, equipment
// and weapon sets; the weapon-set loop (GetWeaponSetItem over sets 0..4, `cmp
// esi, 5`) runs over all 7 so the items of sets 6-7 are in hero files too. Without
// them a world loading the hero from its hero file had sets 6-7 pointing at
// items that did not exist (2026-10-25).
constexpr uint32_t kSaveHeroWeaponSetLoop = 0x00605046;
void Startup() {
if (!re::PatchInstructionByte(kSaveHeroWeaponSetLoop, 2, kGameSlots, kSlots)) {
re::Log("inventory: saveHero weapon-set loop not patched");
}
}
RE_STARTUP(InventorySaveStartup, Startup);
} // namespace mods::inventory_save } // namespace mods::inventory_save
namespace game { namespace game {
+134
View File
@@ -0,0 +1,134 @@
// The load / save progress screen on the menu screens' background (request of
// 2026-10-25).
//
// The game's progress screen (DxDriver7::LoadingScreen 0x6469d0, thiscall
// (progress, text)) draws with GDI into the back buffer's DC (surface +0xbc,
// GetDC vf 0x44 / ReleaseDC vf 0x68): the 1024x768 picture (HBITMAP +0x200)
// at 0, 0, the progress bar (+0x204 / +0x208) and the text at y 700, then
// Flip. It only redraws when the bar moves.
//
// The mod: first the menu background across the whole render width in a
// Direct3D scene (screen::DrawBackground), then the game's drawing with the
// DC's origin moved to the centred 1024 frame. The game asks for a memory DC
// compatible with the back buffer's DC right after getting it
// (CreateCompatibleDC(hdc)), which is where the origin is set.
#include <windows.h>
#include <algorithm>
#include <cstdint>
#include "game/addresses.h"
#include "game/render/dx_driver7.h"
#include "mods/loadingscreen.h"
#include "mods/screen.h"
#include "mods/widescreen.h"
#include "re/hook.h"
namespace mods::loadingscreen {
namespace {
constexpr int kLayoutWidth = 1024;
using BeginSceneFn = char(__fastcall*)(game::DxDriver7* self, void* edx);
using EndSceneFn = void(__fastcall*)(game::DxDriver7* self, void* edx);
} // namespace
// While the progress screen draws on this thread: its frame's x.
thread_local int g_offsetX = 0;
void Begin(game::DxDriver7* driver) {
g_offsetX = 0;
const int width = widescreen::RenderWidth();
if (width <= kLayoutWidth || !driver || !driver->device || !driver->backBuffer) {
return;
}
if (reinterpret_cast<BeginSceneFn>(static_cast<uintptr_t>(game::addr::DxDriver7_BeginScene))(driver, nullptr)) {
screen::DrawBackground(driver->device);
reinterpret_cast<EndSceneFn>(static_cast<uintptr_t>(game::addr::DxDriver7_EndScene))(driver, nullptr);
}
g_offsetX = (width - kLayoutWidth) / 2;
}
void End() {
g_offsetX = 0;
}
} // namespace mods::loadingscreen
namespace mods::loadingscreen {
namespace {
constexpr int kLayoutHeight = 768;
// The game's pictures are artwork on black (PAK\loading0.bmp, loadgame.bmp,
// ...). Blended instead of copied: each pixel's alpha is its brightest
// channel, colours stay as they are (premultiplied), so black shows the
// background and the glow fades into it.
bool BlendPicture(HDC target, HDC source) {
const int offsetX = g_offsetX;
g_offsetX = 0; // our own GDI calls below pass the hooks unchanged
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(info.bmiHeader);
info.bmiHeader.biWidth = kLayoutWidth;
info.bmiHeader.biHeight = -kLayoutHeight;
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
HBITMAP picture = CreateDIBSection(source, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
if (!picture) {
g_offsetX = offsetX;
return false;
}
HDC copy = CreateCompatibleDC(source);
HGDIOBJ previous = SelectObject(copy, picture);
BitBlt(copy, 0, 0, kLayoutWidth, kLayoutHeight, source, 0, 0, SRCCOPY);
GdiFlush();
auto* pixel = static_cast<uint8_t*>(bits);
for (int i = 0; i < kLayoutWidth * kLayoutHeight; ++i, pixel += 4) {
pixel[3] = std::max({pixel[0], pixel[1], pixel[2]});
}
const BLENDFUNCTION blend = {AC_SRC_OVER, 0, 255, AC_SRC_ALPHA};
const bool done =
GdiAlphaBlend(target, 0, 0, kLayoutWidth, kLayoutHeight, copy, 0, 0, kLayoutWidth, kLayoutHeight, blend) != 0;
SelectObject(copy, previous);
DeleteDC(copy);
DeleteObject(picture);
g_offsetX = offsetX;
return done;
}
} // namespace
} // namespace mods::loadingscreen
// The picture: BitBlt(hdc, 0, 0, 1024, 768, picture DC, 0, 0, SRCCOPY), or
// BLACKNESS without a picture (then nothing is drawn over the background).
BOOL WINAPI BitBltForLoadingScreen(HDC target, int x, int y, int width, int height, HDC source, int sx, int sy,
DWORD rop);
RE_HOOK_API(BitBlt, BitBltForLoadingScreen);
BOOL WINAPI BitBltForLoadingScreen(HDC target, int x, int y, int width, int height, HDC source, int sx, int sy,
DWORD rop) {
using namespace mods::loadingscreen;
if (g_offsetX != 0 && x == 0 && y == 0 && width == 1024 && height == 768) {
if (rop == BLACKNESS) {
return TRUE;
}
if (rop == SRCCOPY && source && BlendPicture(target, source)) {
return TRUE;
}
}
return BitBltForLoadingScreen_original(target, x, y, width, height, source, sx, sy, rop);
}
HDC WINAPI CreateCompatibleDCForLoadingScreen(HDC hdc);
RE_HOOK_API(CreateCompatibleDC, CreateCompatibleDCForLoadingScreen);
HDC WINAPI CreateCompatibleDCForLoadingScreen(HDC hdc) {
if (hdc && mods::loadingscreen::g_offsetX != 0) {
SetViewportOrgEx(hdc, mods::loadingscreen::g_offsetX, 0, nullptr);
}
return CreateCompatibleDCForLoadingScreen_original(hdc);
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
// The load / save progress screen on the menu background (loadingscreen.cpp).
#include "game/render/dx_driver7.h"
namespace mods::loadingscreen {
// Around DxDriver7::LoadingScreen (widescreen.cpp's hook): Begin draws the
// background and centres the game's GDI drawing, End stops centring.
void Begin(game::DxDriver7* driver);
void End();
} // namespace mods::loadingscreen
+25 -13
View File
@@ -1,6 +1,6 @@
// Loot filter (request of 2026-10-19), set on the options window's Beute page // Loot filter (request of 2026-10-19), set on the options window's Beute page
// (mods/options.cpp): per category - Gold, Tränke, and the item colours white, // (mods/options.cpp): per category - Questgegenstände, Gold, Tränke, and the
// blue, yellow, brown, green - one of // item colours white, blue, yellow, brown, green - one of
// Void a drop is deleted at once (monster loot, chests; items the // Void a drop is deleted at once (monster loot, chests; items the
// player puts down stay), // player puts down stay),
// Ignore the pickup key (Q, the collect mode) leaves it, // Ignore the pickup key (Q, the collect mode) leaves it,
@@ -9,10 +9,14 @@
// It replaces the game's PICKUPAUTO setting (all / valuable / gold only), // It replaces the game's PICKUPAUTO setting (all / valuable / gold only),
// whose radio buttons are gone from the page. // whose radio buttons are gone from the page.
// //
// - Categories from the game's data: gold by type (0x43ae20), potions by item // - Categories from the game's data: quest items first (request of
// class 9, set items green (0x433f00 +0x23), otherwise the quality tier // 2026-10-25: they were voided as their colour's category; "it needs to be
// that picks the item colour (0x5c1330, game/engine/items.h). Runes and the // top priority, also Löschen is not an option for those"; type's quest
// types CanCollectItem always takes (quest items) are not filtered. // number or quest-item mark, game::ItemIsQuestItem), gold by type
// (0x43ae20), potions by item class 9, set items green (0x433f00 +0x23),
// otherwise the quality tier that picks the item colour (0x5c1330,
// game/engine/items.h). Runes and objects flagged always-collected are not
// filtered.
// - Pickup: cCreature::CanCollectItem (0x4ec170) is the collect mode's filter // - Pickup: cCreature::CanCollectItem (0x4ec170) is the collect mode's filter
// (both of its scans; a clicked item does not ask it). // (both of its scans; a clicked item does not ask it).
// - Void: items made by the loot drop (cCreature 0x559a30, via // - Void: items made by the loot drop (cCreature 0x559a30, via
@@ -44,7 +48,7 @@
namespace mods::lootfilter { namespace mods::lootfilter {
namespace { namespace {
enum Category : int { kGold, kPotions, kWhite, kBlue, kYellow, kBrown, kGreen, kNone = -1 }; enum Category : int { kQuest, kGold, kPotions, kWhite, kBlue, kYellow, kBrown, kGreen, kNone = -1 };
struct CategoryInfo { struct CategoryInfo {
const char* key; // in [loot] const char* key; // in [loot]
@@ -53,6 +57,7 @@ struct CategoryInfo {
}; };
// Gold and potions as the game's labels show them: white. // Gold and potions as the game's labels show them: white.
constexpr CategoryInfo kCategories[kCategoryCount] = { constexpr CategoryInfo kCategories[kCategoryCount] = {
{"Quest", L"Questgegenstände", game::kItemColourWhite},
{"Gold", L"Gold", game::kItemColourWhite}, {"Potions", L"Tränke", game::kItemColourWhite}, {"Gold", L"Gold", game::kItemColourWhite}, {"Potions", L"Tränke", game::kItemColourWhite},
{"White", L"Weiß", game::kItemColourWhite}, {"Blue", L"Blau", game::kItemColourBlue}, {"White", L"Weiß", game::kItemColourWhite}, {"Blue", L"Blau", game::kItemColourBlue},
{"Yellow", L"Gelb", game::kItemColourYellow}, {"Brown", L"Braun", game::kItemColourBrown}, {"Yellow", L"Gelb", game::kItemColourYellow}, {"Brown", L"Braun", game::kItemColourBrown},
@@ -67,10 +72,10 @@ constexpr ModeInfo kModes[kModeCount] = {
{"Void", L"Löschen"}, {"Ignore", L"Ignorieren"}, {"Default", L"Standard"}, {"AlwaysShow", L"Immer anzeigen"}}; {"Void", L"Löschen"}, {"Ignore", L"Ignorieren"}, {"Default", L"Standard"}, {"AlwaysShow", L"Immer anzeigen"}};
Mode g_modes[kCategoryCount] = {Mode::Default, Mode::Default, Mode::Default, Mode::Default, Mode g_modes[kCategoryCount] = {Mode::Default, Mode::Default, Mode::Default, Mode::Default,
Mode::Default, Mode::Default, Mode::Default}; Mode::Default, Mode::Default, Mode::Default, Mode::Default};
// cObject +0x14 bit 0x800: CanCollectItem takes such objects whatever the // cObject +0x14 bit 0x800: the game's CanCollectItem takes such objects
// pickup setting (as the types of ItemAlwaysCollected). // whatever the pickup setting.
constexpr uint32_t kObjectAlwaysCollected = 0x800; constexpr uint32_t kObjectAlwaysCollected = 0x800;
int CategoryOf(void* object) { int CategoryOf(void* object) {
@@ -82,8 +87,11 @@ int CategoryOf(void* object) {
if (type == 0 || type >= game::kItemTypeCount) { if (type == 0 || type >= game::kItemTypeCount) {
return kNone; return kNone;
} }
if (game::ItemIsQuestItem(type)) {
return kQuest;
}
const uint32_t flags = *reinterpret_cast<const uint32_t*>(static_cast<const uint8_t*>(object) + game::kObjectFlags); const uint32_t flags = *reinterpret_cast<const uint32_t*>(static_cast<const uint8_t*>(object) + game::kObjectFlags);
if ((flags & kObjectAlwaysCollected) || game::ItemAlwaysCollected(type)) { if (flags & kObjectAlwaysCollected) {
return kNone; return kNone;
} }
if (game::ItemIsGold(type)) { if (game::ItemIsGold(type)) {
@@ -125,7 +133,7 @@ void Load() {
for (int i = 0; i < kCategoryCount; ++i) { for (int i = 0; i < kCategoryCount; ++i) {
const std::string text = re::ConfigString("loot", kCategories[i].key, kModes[int(Mode::Default)].key); const std::string text = re::ConfigString("loot", kCategories[i].key, kModes[int(Mode::Default)].key);
for (int m = 0; m < kModeCount; ++m) { for (int m = 0; m < kModeCount; ++m) {
if (_stricmp(text.c_str(), kModes[m].key) == 0) { if (_stricmp(text.c_str(), kModes[m].key) == 0 && ModeAllowed(i, static_cast<Mode>(m))) {
g_modes[i] = static_cast<Mode>(m); g_modes[i] = static_cast<Mode>(m);
} }
} }
@@ -207,6 +215,10 @@ const wchar_t* ModeLabel(Mode mode) {
return kModes[static_cast<int>(mode)].label; return kModes[static_cast<int>(mode)].label;
} }
bool ModeAllowed(int category, Mode mode) {
return category != kQuest || mode != Mode::Void;
}
Mode GetMode(int category) { Mode GetMode(int category) {
return g_modes[category]; return g_modes[category];
} }
@@ -225,7 +237,7 @@ namespace game {
using namespace mods::lootfilter; using namespace mods::lootfilter;
// cCreature::CanCollectItem (0x4ec170, (item), ret 4): carried items never; // cCreature::CanCollectItem (0x4ec170, (item), ret 4): carried items never;
// quest items, runes and anything not filtered always; else by the mode. // runes and anything not filtered always; else by the mode.
bool __fastcall Creature_CanCollectItem(void* self, void* edx, uint8_t* item); bool __fastcall Creature_CanCollectItem(void* self, void* edx, uint8_t* item);
RE_HOOK(0x004ec170, Creature_CanCollectItem); RE_HOOK(0x004ec170, Creature_CanCollectItem);
bool __fastcall Creature_CanCollectItem(void*, void*, uint8_t* item) { bool __fastcall Creature_CanCollectItem(void*, void*, uint8_t* item) {
+4 -3
View File
@@ -13,13 +13,14 @@ enum class Mode : int {
}; };
constexpr int kModeCount = 4; constexpr int kModeCount = 4;
// Categories: Gold, Tränke, then the item colours white, blue, yellow, brown, // Categories: Questgegenstände (first: checked before the others, never
// green. // Void), Gold, Tränke, then the item colours white, blue, yellow, brown, green.
constexpr int kCategoryCount = 7; constexpr int kCategoryCount = 8;
const wchar_t* CategoryLabel(int category); const wchar_t* CategoryLabel(int category);
uint32_t CategoryColour(int category); // as the game colours their names uint32_t CategoryColour(int category); // as the game colours their names
const wchar_t* ModeLabel(Mode mode); const wchar_t* ModeLabel(Mode mode);
bool ModeAllowed(int category, Mode mode);
Mode GetMode(int category); Mode GetMode(int category);
// Sets all modes (`modes[kCategoryCount]`) and writes them to sacredK.ini. // Sets all modes (`modes[kCategoryCount]`) and writes them to sacredK.ini.
+376
View File
@@ -0,0 +1,376 @@
// The explored-area border on the large map (M) as a continuous line along
// the fog's edge.
//
// Request (2026-10-26): "the red outline on the mega map is choppy and feels
// weird"; replace it, following the explored area (option A).
//
// Measured (disassembly): cUI_Megamap::DrawVisitedBorder (0x6c8580, called by
// the map's render) draws one 2x2 px red dot (0xffff0000) per bit of a 100x100
// byte grid (world +0xb73c), at a fixed offset of 10 / 20 px from the sector's
// origin towards each neighbour. The grid marks unvisited sectors next to
// visited ones (sector flag 0x20000, set when the hero's sector becomes the
// centre of the active 3x3, 0x626603; rebuilt on load by 0x633230 and per
// sector change by 0x6332d0). So the line was dots in 64-tile steps, and it
// followed the sectors stood in, not the explored mask the fog shows (since
// the 10x map reveal, far inside the cleared area).
//
// The mod replaces the draw: the edges between explored and unexplored cells
// (2x2 tiles, game/engine/explored.h) of the map's 100x100 sectors are chained
// into polylines, simplified to within a pixel and drawn as 2 px lines in the
// game's red. The lines are rebuilt while the map is open whenever the masks
// changed (checked every 250 ms). Cells of a missing sector count as neither,
// so no line runs along the edge of the world.
#include <windows.h>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <vector>
#include "game/engine/explored.h"
#include "game/ui/draw.h"
#include "game/ui/megamap.h"
#include "mods/widescreen.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::mapborder {
namespace {
// The map's sector grid (World_RebuildVisitedBorder 0x633230: 100x100).
constexpr int kMapSectors = 100;
constexpr int kMapCells = kMapSectors * game::kSectorCells;
constexpr uint32_t kColour = 0xffff0000; // the game's border colour (0x6c870e)
constexpr float kLineWidth = 2.0f; // the game's dots: 2x2 px
constexpr double kTolerance = 0.75; // map pixels a simplified line may deviate
constexpr DWORD kCheckIntervalMs = 250;
constexpr int kScreenHeight = 768;
enum class Cell : uint8_t { None, Unexplored, Explored };
struct Grid {
const uint8_t* masks[kMapSectors][kMapSectors];
void Load() {
for (int y = 0; y < kMapSectors; ++y) {
for (int x = 0; x < kMapSectors; ++x) {
masks[y][x] = game::SectorExploredMask(x, y);
}
}
}
Cell At(int x, int y) const {
if (x < 0 || y < 0 || x >= kMapCells || y >= kMapCells) {
return Cell::None;
}
const uint8_t* mask = masks[y / game::kSectorCells][x / game::kSectorCells];
if (!mask) {
return Cell::None;
}
return game::MaskCellExplored(mask, x % game::kSectorCells, y % game::kSectorCells) ? Cell::Explored
: Cell::Unexplored;
}
// FNV-1a over the masks and which sectors exist.
uint64_t Hash() const {
uint64_t hash = 0xcbf29ce484222325ull;
const auto add = [&](uint8_t byte) { hash = (hash ^ byte) * 0x100000001b3ull; };
for (const auto& row : masks) {
for (const uint8_t* mask : row) {
add(mask != nullptr);
for (size_t i = 0; mask && i < 128; ++i) {
add(mask[i]);
}
}
}
return hash;
}
};
struct Point {
double x, y;
};
// Cell corner -> map pixel: WorldToMapPixel is linear, so three points give
// it for the whole grid.
struct Projection {
Point origin, stepX, stepY;
static Point Corner(uint8_t* megamap, int cellX, int cellY) {
const float unitsPerCell = game::kExploredCellTiles * game::kWorldUnitsPerTile;
const game::Vec3 pixel = game::WorldToMapPixel(megamap, {cellX * unitsPerCell, cellY * unitsPerCell, 0});
return {pixel.x, pixel.y};
}
explicit Projection(uint8_t* megamap) {
origin = Corner(megamap, 0, 0);
const Point right = Corner(megamap, kMapCells, 0);
const Point down = Corner(megamap, 0, kMapCells);
stepX = {(right.x - origin.x) / kMapCells, (right.y - origin.y) / kMapCells};
stepY = {(down.x - origin.x) / kMapCells, (down.y - origin.y) / kMapCells};
}
Point operator()(int cellX, int cellY) const {
return {origin.x + cellX * stepX.x + cellY * stepY.x, origin.y + cellX * stepX.y + cellY * stepY.y};
}
};
// Corners on the cell grid, (kMapCells + 1)^2 of them.
uint32_t Key(int x, int y) {
return static_cast<uint32_t>(y) * (kMapCells + 1) + static_cast<uint32_t>(x);
}
struct Edge {
int x0, y0, x1, y1;
};
// Every side between an explored and an unexplored cell, clockwise around
// the explored cell (screen orientation, y down), so the edges of a border
// chain head to tail.
std::vector<Edge> BorderEdges(const Grid& grid) {
std::vector<Edge> edges;
for (int sy = 0; sy < kMapSectors; ++sy) {
for (int sx = 0; sx < kMapSectors; ++sx) {
const uint8_t* mask = grid.masks[sy][sx];
bool any = false;
for (int i = 0; mask && i < 128 && !any; ++i) {
any = mask[i] != 0;
}
if (!any) {
continue;
}
for (int ly = 0; ly < game::kSectorCells; ++ly) {
for (int lx = 0; lx < game::kSectorCells; ++lx) {
if (!game::MaskCellExplored(mask, lx, ly)) {
continue;
}
const int x = sx * game::kSectorCells + lx;
const int y = sy * game::kSectorCells + ly;
if (grid.At(x, y - 1) == Cell::Unexplored) {
edges.push_back({x, y, x + 1, y});
}
if (grid.At(x + 1, y) == Cell::Unexplored) {
edges.push_back({x + 1, y, x + 1, y + 1});
}
if (grid.At(x, y + 1) == Cell::Unexplored) {
edges.push_back({x + 1, y + 1, x, y + 1});
}
if (grid.At(x - 1, y) == Cell::Unexplored) {
edges.push_back({x, y + 1, x, y});
}
}
}
}
}
return edges;
}
using Chain = std::vector<std::pair<int, int>>;
// Edges chained into polylines: open ones (from a corner no edge ends at,
// at a missing sector) first, then the closed loops. Where two cells touch
// only at a corner, the chain turns right, staying around its own cell.
std::vector<Chain> ChainEdges(const std::vector<Edge>& edges) {
std::unordered_map<uint32_t, std::vector<size_t>> starting;
std::unordered_map<uint32_t, int> ending;
starting.reserve(edges.size());
ending.reserve(edges.size());
for (size_t i = 0; i < edges.size(); ++i) {
starting[Key(edges[i].x0, edges[i].y0)].push_back(i);
++ending[Key(edges[i].x1, edges[i].y1)];
}
std::vector<bool> used(edges.size(), false);
std::vector<Chain> chains;
const auto follow = [&](size_t first) {
Chain chain = {{edges[first].x0, edges[first].y0}};
size_t current = first;
for (;;) {
used[current] = true;
const Edge& e = edges[current];
chain.emplace_back(e.x1, e.y1);
const int dx = e.x1 - e.x0;
const int dy = e.y1 - e.y0;
size_t next = SIZE_MAX;
int bestTurn = 3;
for (size_t candidate : starting[Key(e.x1, e.y1)]) {
if (used[candidate]) {
continue;
}
const int cx = edges[candidate].x1 - edges[candidate].x0;
const int cy = edges[candidate].y1 - edges[candidate].y0;
// 0 right (-dy, dx), 1 straight, 2 left.
const int turn = (cx == -dy && cy == dx) ? 0 : (cx == dx && cy == dy) ? 1 : 2;
if (turn < bestTurn) {
bestTurn = turn;
next = candidate;
}
}
if (next == SIZE_MAX) {
break;
}
current = next;
}
chains.push_back(std::move(chain));
};
for (size_t i = 0; i < edges.size(); ++i) {
if (!used[i] && ending.find(Key(edges[i].x0, edges[i].y0)) == ending.end()) {
follow(i);
}
}
for (size_t i = 0; i < edges.size(); ++i) {
if (!used[i]) {
follow(i);
}
}
return chains;
}
double DistanceToSegment(const Point& p, const Point& a, const Point& b) {
const double dx = b.x - a.x;
const double dy = b.y - a.y;
const double length2 = dx * dx + dy * dy;
double t = length2 > 0 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / length2 : 0;
t = t < 0 ? 0 : t > 1 ? 1 : t;
return std::hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
// Douglas-Peucker: keeps first and last, and every point needed to stay
// within kTolerance of the original.
void Simplify(const std::vector<Point>& points, size_t first, size_t last, std::vector<Point>& out) {
double farthest = 0;
size_t index = first;
for (size_t i = first + 1; i < last; ++i) {
const double d = DistanceToSegment(points[i], points[first], points[last]);
if (d > farthest) {
farthest = d;
index = i;
}
}
if (farthest > kTolerance) {
Simplify(points, first, index, out);
Simplify(points, index, last, out);
} else {
out.push_back(points[last]);
}
}
std::vector<Point> SimplifiedLine(const Chain& chain, const Projection& project) {
std::vector<Point> points;
points.reserve(chain.size());
for (const auto& [x, y] : chain) {
points.push_back(project(x, y));
}
std::vector<Point> out = {points.front()};
if (points.size() < 2) {
return out;
}
// A closed loop is split at its point farthest from the start, so both
// halves have distinct ends.
size_t split = points.size() - 1;
if (chain.front() == chain.back()) {
double farthest = -1;
for (size_t i = 1; i + 1 < points.size(); ++i) {
const double d = std::hypot(points[i].x - points[0].x, points[i].y - points[0].y);
if (d > farthest) {
farthest = d;
split = i;
}
}
}
Simplify(points, 0, split, out);
if (split < points.size() - 1) {
Simplify(points, split, points.size() - 1, out);
}
return out;
}
// Map-pixel line segments of the current border.
struct Segment {
float x0, y0, x1, y1;
};
std::vector<Segment> g_segments;
uint64_t g_hash = 0;
DWORD g_lastCheck = 0;
bool g_built = false;
void Rebuild(uint8_t* megamap) {
static Grid grid;
grid.Load();
const uint64_t hash = grid.Hash();
if (g_built && hash == g_hash) {
return;
}
const DWORD start = GetTickCount();
const Projection project(megamap);
const std::vector<Edge> edges = BorderEdges(grid);
const std::vector<Chain> chains = ChainEdges(edges);
g_segments.clear();
for (const Chain& chain : chains) {
const std::vector<Point> line = SimplifiedLine(chain, project);
for (size_t i = 1; i < line.size(); ++i) {
g_segments.push_back({static_cast<float>(line[i - 1].x), static_cast<float>(line[i - 1].y),
static_cast<float>(line[i].x), static_cast<float>(line[i].y)});
}
}
if (!g_built) {
re::Log("map border: %zu edges, %zu lines, %zu segments, %lu ms", edges.size(), chains.size(),
g_segments.size(), GetTickCount() - start);
}
g_hash = hash;
g_built = true;
}
void Draw(uint8_t* megamap, void* device) {
const DWORD now = GetTickCount();
if (!g_built || now - g_lastCheck >= kCheckIntervalMs) {
g_lastCheck = now;
Rebuild(megamap);
}
const float scrollX = static_cast<float>(game::MegamapScrollX(megamap));
const float scrollY = static_cast<float>(game::MegamapScrollY(megamap));
const float width = static_cast<float>(mods::widescreen::RenderWidth());
const float half = kLineWidth / 2;
static std::vector<game::ScreenVertex> vertices;
vertices.clear();
for (const Segment& s : g_segments) {
const float x0 = s.x0 - scrollX, y0 = s.y0 - scrollY;
const float x1 = s.x1 - scrollX, y1 = s.y1 - scrollY;
if ((x0 < -half && x1 < -half) || (x0 > width + half && x1 > width + half) ||
(y0 < -half && y1 < -half) || (y0 > kScreenHeight + half && y1 > kScreenHeight + half)) {
continue;
}
const float length = std::hypot(x1 - x0, y1 - y0);
if (length <= 0) {
continue;
}
// Along and across the segment, half the width each; the ends reach
// half the width further, closing the joints.
const float ax = (x1 - x0) / length * half, ay = (y1 - y0) / length * half;
const float nx = -ay, ny = ax;
const game::ScreenVertex a = {x0 - ax + nx, y0 - ay + ny, 0, 1, kColour, 0, 0, 0};
const game::ScreenVertex b = {x0 - ax - nx, y0 - ay - ny, 0, 1, kColour, 0, 0, 0};
const game::ScreenVertex c = {x1 + ax + nx, y1 + ay + ny, 0, 1, kColour, 0, 0, 0};
const game::ScreenVertex d = {x1 + ax - nx, y1 + ay - ny, 0, 1, kColour, 0, 0, 0};
vertices.insert(vertices.end(), {a, b, c, c, b, d});
}
if (!vertices.empty()) {
game::FillTriangles(device, vertices.data(), vertices.size());
}
}
} // namespace
} // namespace mods::mapborder
namespace game {
// cUI_Megamap::DrawVisitedBorder (0x6c8580, thiscall (device), ret 4).
void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void* edx, void* device);
RE_HOOK(0x006c8580, UiMegamap_DrawVisitedBorder);
void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void*, void* device) {
if (self && device) {
mods::mapborder::Draw(self, device);
}
}
} // namespace game
+108
View File
@@ -0,0 +1,108 @@
// A larger vision range for the explored map (request of 2026-10-25: "increase
// the base vision range for the map's fog of war reveal by x10 or so").
//
// Measured (game/engine/explored.h): RevealAroundHero stamps a fixed pattern
// around the hero into the sectors' explored masks whenever the hero's tile
// changes, a 14-tile circle by day, smaller ones at night.
//
// The mod: after the original revealed at a new tile, the same pattern scaled
// up kScale times is revealed too (by day a 140-tile circle), one cell (2x2
// tiles) at a time through the game's own World_SetExplored. Only loaded
// sectors can be marked; they reach further than the scaled stamp.
//
// Creatures on the zoomed map views (large map, TAB map; 0x6cc950): one off
// explored ground, or a hostile one, is only drawn within a sight range of the
// hero (|dx| and |dy| in world units, about 53.7 per tile): 650 (about 12
// tiles), 300 where 0x63d280 holds for the hero's area, 350 when the weather
// object's 0x41ba40 is false; plus bonuses from a skill (0x355) and the hero's
// equipment values. The three base values are scaled by kScale as well.
#include <cstdint>
#include <cstring>
#include "game/engine/explored.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::mapreveal {
namespace {
constexpr int kScale = 10;
constexpr int kCellTiles = 2; // one mask bit per 2x2 tiles
// The stamp cell a tile offset falls into: cell c covers offsets
// c * kScale - kScale / 2 .. c * kScale + kScale / 2 - 1.
int StampOffset(int tiles) {
const int shifted = tiles + kScale / 2;
return (shifted >= 0 ? shifted : shifted - kScale + 1) / kScale;
}
// mov dword ptr [esp+14h], <float>: the map's creature sight range.
struct SightRange {
uint32_t instruction;
float value;
};
constexpr SightRange kCreatureSightRanges[] = {
{0x006ce677, 650.0f},
{0x006ce6b5, 300.0f},
{0x006ce6cf, 350.0f},
};
uint32_t FloatBits(float value) {
uint32_t bits;
std::memcpy(&bits, &value, sizeof bits);
return bits;
}
void Startup() {
int failed = 0;
for (const SightRange& range : kCreatureSightRanges) {
failed += !re::PatchInstructionDword(range.instruction, FloatBits(range.value), FloatBits(range.value * kScale));
}
if (failed) {
re::Log("mapreveal: %d creature sight range patches FAILED", failed);
}
}
void RevealScaled(int tileX, int tileY, int sight) {
const game::RevealStamp stamp = game::RevealStampFor(sight);
const int centre = stamp.size / 2;
const int radius = centre * kScale + kScale / 2;
// Cells start at even tiles (sector origins are multiples of 64).
const int left = (tileX - radius) & ~(kCellTiles - 1);
const int top = (tileY - radius) & ~(kCellTiles - 1);
for (int y = top; y <= tileY + radius; y += kCellTiles) {
const int row = centre + StampOffset(y + kCellTiles / 2 - tileY);
if (row < 0 || row >= stamp.size) {
continue;
}
for (int x = left; x <= tileX + radius; x += kCellTiles) {
const int column = centre + StampOffset(x + kCellTiles / 2 - tileX);
if (column >= 0 && column < stamp.size && stamp.cells[row * stamp.size + column]) {
game::SetExplored(x, y);
}
}
}
}
} // namespace
RE_STARTUP(MapRevealStartup, Startup);
} // namespace mods::mapreveal
void __fastcall RevealAroundHero(void* creature, void* edx, int tileX, int tileY, int sight);
RE_HOOK(game::kRevealAroundHero, RevealAroundHero);
void __fastcall RevealAroundHero(void* creature, void* edx, int tileX, int tileY, int sight) {
const int lastX = game::LastRevealTileX();
const int lastY = game::LastRevealTileY();
RevealAroundHero_original(creature, edx, tileX, tileY, sight);
// The original revealed (local hero, new tile) iff it moved its last tile
// here; it ignores tile 0, 0.
const bool revealed = (tileX != 0 || tileY != 0) && (lastX != tileX || lastY != tileY) &&
game::LastRevealTileX() == tileX && game::LastRevealTileY() == tileY;
if (revealed && sight >= 0 && sight <= 3) {
mods::mapreveal::RevealScaled(tileX, tileY, sight);
}
}
+75
View File
@@ -9,6 +9,11 @@
// to the right screen edge and its top and bottom tiles stretch across, and // to the right screen edge and its top and bottom tiles stretch across, and
// the zoom buttons at the bottom stay centred. The window is drawn and clicked // the zoom buttons at the bottom stay centred. The window is drawn and clicked
// unshifted (mods/hud.cpp, Anchor::Screen). See docs/hud.md. // unshifted (mods/hud.cpp, Anchor::Screen). See docs/hud.md.
//
// Zoom (request of 2026-10-25: the zoomed view did not start centred on the
// hero): held mouse button zooms on the mouse (the game's magnifier); the zoom
// button now zooms on the hero and follows him (the game kept the last
// magnifier point, or 0,0).
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
@@ -16,6 +21,11 @@
#include <unordered_map> #include <unordered_map>
#include "game/addresses.h" #include "game/addresses.h"
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/ui/megamap.h"
#include "game/ui/windows.h"
#include "mods/hud.h" #include "mods/hud.h"
#include "mods/megamap.h" #include "mods/megamap.h"
#include "mods/widescreen.h" #include "mods/widescreen.h"
@@ -51,15 +61,71 @@ constexpr uint32_t kZoomView = 0x006cc950;
constexpr uint32_t kZoomCentreUses[] = {0x006ccb7f, 0x006cf782}; constexpr uint32_t kZoomCentreUses[] = {0x006ccb7f, 0x006cf782};
constexpr uint32_t kZoomMarkerX = 0x006ce156; constexpr uint32_t kZoomMarkerX = 0x006ce156;
constexpr float kZoomMarkerFromCentre = 256.0f; constexpr float kZoomMarkerFromCentre = 256.0f;
// The heroes' markers in the zoomed view: a 6 px quad at x = d + 509 / 515
// (fadd [509.0] 0x6cf1bd, fadd [515.0] 0x6cf1dd), i.e. the centre 512 +- 3
// (2026-10-25: they stayed at the 1024 layout's centre).
constexpr uint32_t kZoomHeroMarkerLeft = 0x006cf1bd;
constexpr uint32_t kZoomHeroMarkerRight = 0x006cf1dd;
constexpr uintptr_t kZoomHeroMarkerLeftF = 0x00894780; // 509.0
constexpr uintptr_t kZoomHeroMarkerRightF = 0x00894778; // 515.0
constexpr float kZoomHeroMarkerHalf = 3.0f;
// The other symbols in the zoomed view take the centre as an integer
// immediate 200h (mov reg / add reg, 200h): a quad x = 200h - w / 200h + w
// (0x6ced82, 0x6cedb0), icons (0x6cf47a, 0x6cf4ac) and labels centred on
// their x (0x6cf5e4) (2026-10-25: they stayed at the 1024 layout's centre).
// Rewritten whenever the centre changes.
constexpr uint32_t kZoomCentreImmediates[] = {0x006ced82, 0x006cedb0, 0x006cf47a, 0x006cf4ac, 0x006cf5e4};
// The dark band around the view (10 vertices, mov [esp+..], imm32): its right // The dark band around the view (10 vertices, mov [esp+..], imm32): its right
// outer edge x 1024 and inner edge x 954. // outer edge x 1024 and inner edge x 954.
constexpr uint32_t kBandRightOuter[] = {0x006ca774, 0x006ca808}; constexpr uint32_t kBandRightOuter[] = {0x006ca774, 0x006ca808};
constexpr uint32_t kBandRightInner[] = {0x006ca7bc, 0x006ca84c}; constexpr uint32_t kBandRightInner[] = {0x006ca7bc, 0x006ca84c};
constexpr int kBandWidth = 70; constexpr int kBandWidth = 70;
// The zoom (cUI_Megamap's render 0x6c9ce0, 0x6ca442..0x6ca5cf): flags +0xc0154
// bit 0x100 set by the zoom button (0x6c7fac); +0xc06bc set while the mouse
// button is held on the map (0x6c8274). The zoom point +0xc0438 / +0xc043c
// (screen) follows the mouse only while held; the view zooms on point + scroll
// (+0xc016c / +0xc0170), the map pixel.
constexpr size_t kMapFlags = 0xc0154;
constexpr uint32_t kZoomedByButton = 0x100;
constexpr size_t kZoomHeld = 0xc06bc;
constexpr size_t kZoomPointX = 0xc0438;
constexpr size_t kZoomPointY = 0xc043c;
template <typename T>
T& Field(uint8_t* p, size_t offset) {
return *reinterpret_cast<T*>(p + offset);
}
// Every tick while the zoom button's zoom is on: the zoom point on the hero.
void ZoomOnHero(uint8_t*) {
uint8_t* megamap = game::UiWindow(game::UiManager(), game::kUiSlotMegamap);
if (!game::UiWindowShown(game::UiManager(), game::kUiSlotMegamap) ||
!(Field<uint32_t>(megamap, kMapFlags) & kZoomedByButton) || Field<uint8_t>(megamap, kZoomHeld)) {
return;
}
auto* hero = static_cast<uint8_t*>(game::GetPlayerHero());
if (!hero) {
return;
}
const game::Vec3 world = {static_cast<float>(game::CreatureField(hero, game::kCreatureX)),
static_cast<float>(game::CreatureField(hero, game::kCreatureY)),
static_cast<float>(hero[game::kObjectLevel])};
const game::Vec3 pixel = game::WorldToMapPixel(megamap, world);
Field<int32_t>(megamap, kZoomPointX) = static_cast<int32_t>(pixel.x) - game::MegamapScrollX(megamap);
Field<int32_t>(megamap, kZoomPointY) = static_cast<int32_t>(pixel.y) - game::MegamapScrollY(megamap);
}
struct Registration {
Registration() { game::OnEngineTick(&ZoomOnHero); }
} g_registration;
float g_halfWidth = 512.0f; float g_halfWidth = 512.0f;
float g_zoomCentre = 512.0f; float g_zoomCentre = 512.0f;
float g_zoomMarkerX = 768.0f; float g_zoomMarkerX = 768.0f;
float g_zoomHeroMarkerLeft = 509.0f;
float g_zoomHeroMarkerRight = 515.0f;
uint32_t g_zoomCentreImmediate = kLayoutWidth / 2;
uint32_t FloatBits(float value) { uint32_t FloatBits(float value) {
uint32_t bits; uint32_t bits;
@@ -183,6 +249,8 @@ void PatchLayout(int width) {
failed += !re::RedirectOperand(instruction, game::addr::kScreenHalfWidthF, &g_zoomCentre); failed += !re::RedirectOperand(instruction, game::addr::kScreenHalfWidthF, &g_zoomCentre);
} }
failed += !re::RedirectOperand(kZoomMarkerX, game::addr::kScreenHeightF, &g_zoomMarkerX); failed += !re::RedirectOperand(kZoomMarkerX, game::addr::kScreenHeightF, &g_zoomMarkerX);
failed += !re::RedirectOperand(kZoomHeroMarkerLeft, kZoomHeroMarkerLeftF, &g_zoomHeroMarkerLeft);
failed += !re::RedirectOperand(kZoomHeroMarkerRight, kZoomHeroMarkerRightF, &g_zoomHeroMarkerRight);
failed += re::PatchAll(kBandRightOuter, FloatBits(1024.0f), FloatBits(static_cast<float>(width))); failed += re::PatchAll(kBandRightOuter, FloatBits(1024.0f), FloatBits(static_cast<float>(width)));
failed += re::PatchAll(kBandRightInner, FloatBits(1024.0f - kBandWidth), failed += re::PatchAll(kBandRightInner, FloatBits(1024.0f - kBandWidth),
FloatBits(static_cast<float>(width - kBandWidth))); FloatBits(static_cast<float>(width - kBandWidth)));
@@ -196,6 +264,13 @@ void __cdecl SetZoomCentre() {
const int width = std::max(mods::widescreen::RenderWidth(), kLayoutWidth); const int width = std::max(mods::widescreen::RenderWidth(), kLayoutWidth);
g_zoomCentre = width / 2.0f - static_cast<float>(mods::hud::DrawOffsetX()); g_zoomCentre = width / 2.0f - static_cast<float>(mods::hud::DrawOffsetX());
g_zoomMarkerX = g_zoomCentre + kZoomMarkerFromCentre; g_zoomMarkerX = g_zoomCentre + kZoomMarkerFromCentre;
g_zoomHeroMarkerLeft = g_zoomCentre - kZoomHeroMarkerHalf;
g_zoomHeroMarkerRight = g_zoomCentre + kZoomHeroMarkerHalf;
const auto immediate = static_cast<uint32_t>(width / 2 - mods::hud::DrawOffsetX());
if (immediate != g_zoomCentreImmediate &&
re::PatchAll(kZoomCentreImmediates, g_zoomCentreImmediate, immediate) == 0) {
g_zoomCentreImmediate = immediate;
}
} }
} // namespace mods::megamap } // namespace mods::megamap
+160
View File
@@ -0,0 +1,160 @@
// Preferred monitor (request of 2026-10-25): a setting in the graphics options
// picks the monitor the game starts on; with none set, or the preferred one
// not connected, the system's main monitor.
//
// The game window and its render size are decided when the display mode is
// set at startup (borderless.cpp, widescreen.cpp ApplyRenderSize): both ask
// GameMonitorRect. A change applies at the next start.
//
// Monitors are told apart by their device path (DisplayConfigGetDeviceInfo,
// target name: "\\?\DISPLAY#GSM5B7F#..."), which survives restarts, monitor
// order changes and other ports' changes, unlike the GDI names \\.\DISPLAYn.
//
// sacredK.ini:
// [display]
// Monitor=<device path> ; empty: the main monitor
#include "mods/monitor.h"
#include <algorithm>
#include "re/config.h"
#include "re/log.h"
namespace mods::monitor {
namespace {
constexpr const char* kSection = "display";
constexpr const char* kKey = "Monitor";
std::string Utf8(const wchar_t* text) {
const int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr);
if (size <= 1) {
return {};
}
std::string result(static_cast<size_t>(size - 1), '\0');
WideCharToMultiByte(CP_UTF8, 0, text, -1, result.data(), size, nullptr, nullptr);
return result;
}
struct Target {
std::wstring gdiName; // \\.\DISPLAYn of the source showing it
std::string id;
std::wstring name;
};
// The active display paths: which monitor shows which GDI display.
std::vector<Target> Targets() {
UINT32 pathCount = 0;
UINT32 modeCount = 0;
std::vector<DISPLAYCONFIG_PATH_INFO> paths;
std::vector<DISPLAYCONFIG_MODE_INFO> modes;
LONG result;
do {
if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount) != ERROR_SUCCESS) {
return {};
}
paths.resize(pathCount);
modes.resize(modeCount);
result = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &pathCount, paths.data(), &modeCount, modes.data(),
nullptr);
} while (result == ERROR_INSUFFICIENT_BUFFER);
if (result != ERROR_SUCCESS) {
return {};
}
paths.resize(pathCount);
std::vector<Target> targets;
for (const DISPLAYCONFIG_PATH_INFO& path : paths) {
DISPLAYCONFIG_SOURCE_DEVICE_NAME source = {};
source.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
source.header.size = sizeof(source);
source.header.adapterId = path.sourceInfo.adapterId;
source.header.id = path.sourceInfo.id;
DISPLAYCONFIG_TARGET_DEVICE_NAME target = {};
target.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
target.header.size = sizeof(target);
target.header.adapterId = path.targetInfo.adapterId;
target.header.id = path.targetInfo.id;
if (DisplayConfigGetDeviceInfo(&source.header) != ERROR_SUCCESS ||
DisplayConfigGetDeviceInfo(&target.header) != ERROR_SUCCESS) {
continue;
}
targets.push_back({source.viewGdiDeviceName, Utf8(target.monitorDevicePath), target.monitorFriendlyDeviceName});
}
return targets;
}
BOOL CALLBACK AddMonitor(HMONITOR handle, HDC, LPRECT, LPARAM data) {
auto* gdi = reinterpret_cast<std::vector<MONITORINFOEXW>*>(data);
MONITORINFOEXW info = {};
info.cbSize = sizeof(info);
if (GetMonitorInfoW(handle, &info)) {
gdi->push_back(info);
}
return TRUE;
}
} // namespace
std::vector<Monitor> List() {
std::vector<MONITORINFOEXW> gdi;
EnumDisplayMonitors(nullptr, nullptr, &AddMonitor, reinterpret_cast<LPARAM>(&gdi));
const std::vector<Target> targets = Targets();
std::vector<Monitor> monitors;
for (const MONITORINFOEXW& info : gdi) {
Monitor monitor{"", L"", info.rcMonitor, (info.dwFlags & MONITORINFOF_PRIMARY) != 0};
for (const Target& target : targets) {
if (target.gdiName == info.szDevice) { // mirrored displays: the first monitor
monitor.id = target.id;
monitor.name = target.name;
break;
}
}
if (monitor.id.empty()) {
monitor.id = Utf8(info.szDevice); // no display config (remote desktop, ...)
}
if (monitor.name.empty()) {
monitor.name = L"Monitor " + std::to_wstring(monitors.size() + 1);
}
monitors.push_back(monitor);
}
std::stable_sort(monitors.begin(), monitors.end(),
[](const Monitor& a, const Monitor& b) { return a.primary && !b.primary; });
return monitors;
}
std::string PreferredId() {
return re::ConfigString(kSection, kKey, "");
}
void SetPreferredId(const std::string& id) {
re::SetConfigString(kSection, kKey, id.c_str());
}
RECT GameMonitorRect() {
const std::vector<Monitor> monitors = List();
const std::string preferred = PreferredId();
const Monitor* chosen = nullptr;
for (const Monitor& monitor : monitors) {
if ((!preferred.empty() && monitor.id == preferred) || (!chosen && monitor.primary)) {
chosen = &monitor;
}
}
if (!chosen) {
// No monitor reported: the main monitor's rectangle as Windows has it.
MONITORINFO info = {sizeof(info)};
GetMonitorInfoW(MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY), &info);
return info.rcMonitor;
}
const bool found = !preferred.empty() && chosen->id == preferred;
static bool logged = false;
if (!logged) {
logged = true;
re::Log("monitor: %ls %ldx%ld at (%ld,%ld)%s", chosen->name.c_str(), chosen->rect.right - chosen->rect.left,
chosen->rect.bottom - chosen->rect.top, chosen->rect.left, chosen->rect.top,
found ? "" : preferred.empty() ? ", main monitor" : ", preferred monitor not connected: main monitor");
}
return chosen->rect;
}
} // namespace mods::monitor
+31
View File
@@ -0,0 +1,31 @@
#pragma once
// The monitor the game runs on (request of 2026-10-25): a preferred monitor
// from the options, or the system's main monitor when none is set or the
// preferred one is not connected. See monitor.cpp.
#include <string>
#include <vector>
#include <windows.h>
namespace mods::monitor {
struct Monitor {
std::string id; // stable across restarts and reordering: the monitor's device path
std::wstring name; // the monitor's own name (EDID), e.g. "LG ULTRAGEAR"
RECT rect; // desktop coordinates
bool primary;
};
// The connected monitors, the main monitor first.
std::vector<Monitor> List();
// The preferred monitor's id from sacredK.ini, empty for none.
std::string PreferredId();
void SetPreferredId(const std::string& id);
// The rectangle of the monitor to run on: the preferred one if connected,
// else the main monitor.
RECT GameMonitorRect();
} // namespace mods::monitor
+171 -2
View File
@@ -41,6 +41,7 @@
#include "mods/hud.h" #include "mods/hud.h"
#include "mods/input.h" #include "mods/input.h"
#include "mods/lootfilter.h" #include "mods/lootfilter.h"
#include "mods/monitor.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -522,7 +523,8 @@ game::UiRect LootBoxRect(void* window, int row, int mode) {
int LootBoxAt(void* window, int x, int y) { int LootBoxAt(void* window, int x, int y) {
for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) { for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) {
for (int mode = 0; mode < mods::lootfilter::kModeCount; ++mode) { for (int mode = 0; mode < mods::lootfilter::kModeCount; ++mode) {
if (game::Inside(LootBoxRect(window, category + 1, mode), x, y)) { if (mods::lootfilter::ModeAllowed(category, static_cast<mods::lootfilter::Mode>(mode)) &&
game::Inside(LootBoxRect(window, category + 1, mode), x, y)) {
return category * mods::lootfilter::kModeCount + mode; return category * mods::lootfilter::kModeCount + mode;
} }
} }
@@ -547,6 +549,9 @@ void DrawLootPage(const Window& w, void* device) {
game::DrawText(device, kFont, game::CenterLineVertically(kFont, LootRowRect(window, row, 0, kLootLabelWidth)), game::DrawText(device, kFont, game::CenterLineVertically(kFont, LootRowRect(window, row, 0, kLootLabelWidth)),
CategoryLabel(category), CategoryColour(category), 0); CategoryLabel(category), CategoryColour(category), 0);
for (int mode = 0; mode < kModeCount; ++mode) { for (int mode = 0; mode < kModeCount; ++mode) {
if (!ModeAllowed(category, static_cast<Mode>(mode))) {
continue;
}
const bool selected = g_lootPending[category] == static_cast<Mode>(mode); const bool selected = g_lootPending[category] == static_cast<Mode>(mode);
const bool hover = g_lootHover == category * kModeCount + mode; const bool hover = g_lootHover == category * kModeCount + mode;
FillBox(device, LootBoxRect(window, row, mode), FillBox(device, LootBoxRect(window, row, mode),
@@ -684,6 +689,160 @@ bool HandleSavesEvent(Window& w, void* event) {
} }
} }
// --- monitor (Allgemein page) ----------------------------------------------------
// Preferred monitor (request of 2026-10-25, mods/monitor.h) in place of the
// Internet-Optionen frame (Datendurchsatz, multiplayer only; the user's
// choice): its controls are hidden (the game keeps the saved value), its title
// reads "Bildschirm" (text UI_CFG_WAN, set by 0x672a20 / 0x724d50 when the
// window is made). One row: "Monitor" and a dropdown box; the list opens
// upwards (the frame is the lowest one), a click picks, a click elsewhere
// closes it. OK saves the choice, which applies at the next start.
constexpr const char* kScreenFrameName = "UI_FRAME_INTERNET";
constexpr const char* kScreenTitleName = "UI_LABEL_WAN";
constexpr const char* kScreenTitleKey = "UI_CFG_WAN";
constexpr int kScreenLabelWidth = 0x70;
constexpr int kArrowWidth = 9; // the box's down arrow, 5 rows
struct ScreenChoice {
std::string id; // empty: the main monitor
std::wstring text;
};
game::UiRect g_screenFrame = {};
std::vector<ScreenChoice> g_screenChoices;
std::string g_screenPending;
bool g_screenOpen = false;
int g_screenHover = -1; // list entry under the mouse
void CreateScreenControls(Window& w) {
for (const Control& c : w.controls) {
if (IsFrame(c.control) && game::ControlNamed(c.control, kScreenFrameName)) {
g_screenFrame = *reinterpret_cast<const game::UiRect*>(c.control + game::kControlRect);
}
}
for (Control& c : w.controls) {
const game::UiRect r = *reinterpret_cast<const game::UiRect*>(c.control + game::kControlRect);
const bool inside = r.x >= g_screenFrame.x && r.y >= g_screenFrame.y &&
r.x < g_screenFrame.x + g_screenFrame.w && r.y < g_screenFrame.y + g_screenFrame.h;
if (c.group == Group::Common &&
(game::ControlNamed(c.control, kScreenTitleName) || (inside && !IsFrame(c.control)))) {
c.group = Group::Hidden;
}
}
}
void LoadScreenPending() {
g_screenChoices = {{"", L"Hauptmonitor"}};
for (const mods::monitor::Monitor& m : mods::monitor::List()) {
g_screenChoices.push_back({m.id, m.name + L" (" + std::to_wstring(m.rect.right - m.rect.left) + L"x" +
std::to_wstring(m.rect.bottom - m.rect.top) + L")"});
}
g_screenPending = mods::monitor::PreferredId();
g_screenOpen = false;
g_screenHover = -1;
}
const wchar_t* ScreenPendingText() {
for (const ScreenChoice& choice : g_screenChoices) {
if (choice.id == g_screenPending) {
return choice.text.c_str();
}
}
return L"Nicht verbunden";
}
game::UiRect ScreenRowRect(void* window, int dx, int width) {
const game::UiRect origin = game::ControlScreenRect(window);
return {origin.x + g_screenFrame.x + kTextInset + dx,
origin.y + g_screenFrame.y + (g_screenFrame.h - kBoxHeight) / 2, static_cast<int16_t>(width),
static_cast<int16_t>(kBoxHeight)};
}
game::UiRect ScreenBoxRect(void* window) {
return ScreenRowRect(window, kScreenLabelWidth, g_screenFrame.w - 2 * kTextInset - kScreenLabelWidth);
}
// List entry `index`, upwards from the box: the last entry right above it.
game::UiRect ScreenEntryRect(void* window, int index) {
game::UiRect r = ScreenBoxRect(window);
r.y -= static_cast<int>(g_screenChoices.size() - index) * kBoxHeight;
return r;
}
int ScreenEntryAt(void* window, int x, int y) {
for (int i = 0; i < static_cast<int>(g_screenChoices.size()); ++i) {
if (game::Inside(ScreenEntryRect(window, i), x, y)) {
return i;
}
}
return -1;
}
void DrawScreen(const Window& w, void* device) {
void* window = w.window;
game::DrawText(device, kFont, game::CenterLineVertically(kFont, ScreenRowRect(window, 0, kScreenLabelWidth)),
L"Monitor", kTextColour, 0);
const game::UiRect box = ScreenBoxRect(window);
FillBox(device, box, g_screenOpen ? kSavesBoxHoverColour : kSavesBoxColour);
game::UiRect text = box;
text.x += 6;
text.w = static_cast<int16_t>(text.w - 6 - kArrowWidth - 8);
game::DrawText(device, kFont, game::CenterLineVertically(kFont, text), ScreenPendingText(), kTextColour, 0);
// The arrow: rows narrowing to a point.
const int arrowX = box.x + box.w - kArrowWidth - 6;
const int arrowY = box.y + (box.h - 5) / 2;
for (int row = 0; row < 5; ++row) {
FillBox(device, {arrowX + row, arrowY + row, static_cast<int16_t>(kArrowWidth - 2 * row), 1}, kTextColour);
}
if (g_screenPending != mods::monitor::PreferredId()) {
game::UiRect hint = {box.x, box.y + box.h + 2, box.w, static_cast<int16_t>(game::FontLineHeight(kFont))};
game::DrawText(device, kFont, hint, L"Ab dem nächsten Spielstart", kUnboundColour, 0);
}
if (!g_screenOpen) {
return;
}
for (int i = 0; i < static_cast<int>(g_screenChoices.size()); ++i) {
const game::UiRect entry = ScreenEntryRect(window, i);
const bool selected = g_screenChoices[i].id == g_screenPending;
FillBox(device, entry, selected ? kBoxSelectedColour : i == g_screenHover ? kBoxHoverColour : kBoxColour);
game::UiRect label = entry;
label.x += 6;
game::DrawText(device, kFont, game::CenterLineVertically(kFont, label), g_screenChoices[i].text.c_str(),
kTextColour, 0);
}
}
bool HandleScreenEvent(Window& w, void* event) {
switch (game::MouseEventKind(event)) {
case game::MouseEvent::Move:
g_screenHover =
g_screenOpen ? ScreenEntryAt(w.window, game::EventField(event, 0xc), game::EventField(event, 0x10)) : -1;
return false;
case game::MouseEvent::ButtonDown: {
const int x = game::EventField(event, game::kMouseButtonX);
const int y = game::EventField(event, game::kMouseButtonY);
if (g_screenOpen) {
const int entry = ScreenEntryAt(w.window, x, y);
if (entry >= 0 && game::EventField(event, game::kMouseButton) == game::kLeftButton) {
g_screenPending = g_screenChoices[entry].id;
}
g_screenOpen = false;
g_screenHover = -1;
return true; // the click only closes the list
}
if (game::EventField(event, game::kMouseButton) == game::kLeftButton &&
game::Inside(ScreenBoxRect(w.window), x, y)) {
g_screenOpen = true;
return true;
}
return false;
}
default:
return false;
}
}
// --- hooks --------------------------------------------------------------------- // --- hooks ---------------------------------------------------------------------
struct RenderCall { struct RenderCall {
@@ -706,6 +865,7 @@ int RenderBody(void* context) {
LoadPending(); LoadPending();
LoadLootPending(); LoadLootPending();
g_autosavePending = mods::characters::AutosaveMinutes(); g_autosavePending = mods::characters::AutosaveMinutes();
LoadScreenPending();
} }
w->wasVisible = visible; w->wasVisible = visible;
const uint16_t page = game::ActiveTab(w->sheet); const uint16_t page = game::ActiveTab(w->sheet);
@@ -720,6 +880,8 @@ int RenderBody(void* context) {
} }
if (!visible || page != kPageCommon) { if (!visible || page != kPageCommon) {
g_savesHover = -1; g_savesHover = -1;
g_screenOpen = false;
g_screenHover = -1;
} }
if (!visible) { if (!visible) {
return result; return result;
@@ -731,6 +893,7 @@ int RenderBody(void* context) {
DrawLootPage(*w, call->device); DrawLootPage(*w, call->device);
} else if (page == kPageCommon && g_pendingLoaded) { } else if (page == kPageCommon && g_pendingLoaded) {
DrawSaves(*w, call->device); DrawSaves(*w, call->device);
DrawScreen(*w, call->device);
} }
return result; return result;
} }
@@ -773,7 +936,7 @@ int EventBody(void* context) {
return 1; return 1;
} }
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageCommon && if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageCommon &&
g_pendingLoaded && HandleSavesEvent(*w, call->event)) { g_pendingLoaded && (HandleScreenEvent(*w, call->event) || HandleSavesEvent(*w, call->event))) {
return 1; return 1;
} }
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys && if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys &&
@@ -824,11 +987,16 @@ void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, Ui
RE_HOOK(0x00716570, UiOptions_Construct); RE_HOOK(0x00716570, UiOptions_Construct);
void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent, void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent,
uint32_t flags) { uint32_t flags) {
static const bool titled = [] {
game::SetTextOverride(game::GameTextIdByKey(kScreenTitleKey), L"Bildschirm");
return true;
}();
void* window = UiOptions_Construct_original(self, edx, name, rect, parent, flags); void* window = UiOptions_Construct_original(self, edx, name, rect, parent, flags);
if (window) { if (window) {
g_windows.push_back({static_cast<uint8_t*>(window)}); g_windows.push_back({static_cast<uint8_t*>(window)});
Window& w = g_windows.back(); Window& w = g_windows.back();
Classify(w); Classify(w);
CreateScreenControls(w);
CreateKeyControls(w); CreateKeyControls(w);
CreateLootControls(w); CreateLootControls(w);
CreateSavesControls(w); CreateSavesControls(w);
@@ -873,6 +1041,7 @@ void __fastcall UiOptions_Apply(void* self, void* edx) {
mods::input::SetBindingKeys(g_pending); mods::input::SetBindingKeys(g_pending);
mods::lootfilter::SetModes(g_lootPending); mods::lootfilter::SetModes(g_lootPending);
mods::characters::SetAutosaveMinutes(g_autosavePending); mods::characters::SetAutosaveMinutes(g_autosavePending);
mods::monitor::SetPreferredId(g_screenPending);
} }
} }
+499
View File
@@ -0,0 +1,499 @@
// The quest book as one list: the story, then the side quests by area,
// unknown ones as "???" (requests of 2026-10-25: a player sees how many
// quests an area holds without being told where they are; then "get rid of
// the tabs ... a section for the story and a section per region", the
// game's page turning kept: "pagination is already there anyways").
//
// Measured (docs/quests.md, "Quest book"): the book's quest mode had up to
// seven act tabs (bookmarks I, II, ...), an entry's tab being its chapter
// (+0x16c), copied from the current main quest when the quest is accepted
// (0x46c450); the game knows no area for a quest. The side quests' ids come
// in blocks of 100 (1xx .. 25xx) whose accepted quests lie together on the
// map: the areas. The other side quests (5xxx, 6xxx, 9xxx, 102xx, 15xxx)
// carry no area in their id; once known they join the area nearest to them.
//
// The book (cUI_Book, one per book mode): the left page shows the current
// page's rows, the right page the selected row's detail record, both drawn by
// 0x6b27f0 from the same page; a click (0x6b3620) selects the row under the
// mouse (page +4).
//
// The mod: the game's list builder (0x6b04c0) runs on a reordered copy of the
// book with our rows (headings, "???") as extra entries, all in the first tab
// (the game's page breaks and page turning stay); afterwards each row's entry
// index is mapped back to the real book, ours to none (a click on them does
// nothing, 0x6b3620 checks the index). The bookmarks are hidden, every page is
// titled "Quests" and selects its first quest. Fonts, icons and the detail
// page stay the game's.
//
// "Erledigte anzeigen" (request of 2026-10-25: "some kind of 'Show completed'
// checkmark. If unchecked, completed quests are not in the list (as well as
// completed regions)"): the first row, its icon the solved mark when on, none
// when off; a click toggles it (sacredK.ini [questbook] ShowCompleted) and
// rebuilds the book. Off leaves out solved quests and the areas whose quests
// are all known and solved; failed quests stay (they can be restarted).
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <map>
#include <string>
#include <vector>
#include "game/engine/quests.h"
#include "game/engine/regions.h"
#include "game/ui/control.h"
#include "game/ui/texts.h"
#include "re/config.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::questbook {
namespace {
constexpr int kAreaQuestFirst = 100; // ids 100 .. 2599: area = id / 100
constexpr int kAreaQuestEnd = 2600; // 2600 .. 3599: generic quests, cloned by Quest_End
constexpr int kGenericQuestEnd = 3600;
constexpr int kAreaSize = 100;
// cUI_Book: the book mode (+0x166, 0 = quests), current tab (+0x15e) and page
// (+0x162); per tab a vector of pages (+0x97c + tab * 0xc) and of detail
// records (+0x9d0 + tab * 0xc). A page (0x1c bytes): title text +0, selected row +4 (u16), a vector
// of rows +0x10. A row (0x14 bytes): u16 flags (4 = spacer), title text +4,
// detail record +8, book entry +0xc. The bookmarks: 7 controls at +0x168.
constexpr size_t kBookMode = 0x166;
constexpr uint16_t kModeQuests = 0;
constexpr size_t kBookTab = 0x15e;
constexpr size_t kBookPage = 0x162;
constexpr size_t kBookPages = 0x97c;
constexpr size_t kTabStride = 0xc;
constexpr size_t kPageSize = 0x1c;
constexpr size_t kPageTitle = 0x0;
constexpr size_t kPageSelected = 0x4;
constexpr size_t kPageRows = 0x10;
constexpr size_t kRowSize = 0x14;
constexpr size_t kRowEntry = 0xc;
constexpr uint16_t kRowSpacer = 0x4;
constexpr size_t kBookmarks = 0x168;
constexpr size_t kBookmarkStride = 0xbc;
constexpr int kTabs = 7;
constexpr uint32_t kNoEntry = 0xffffffff;
constexpr uint32_t kToggleEntry = 0xfffffffe; // the "Erledigte anzeigen" row
constexpr uint16_t kNoRow = 0xffff;
constexpr const char* kConfigSection = "questbook";
constexpr const char* kConfigShowCompleted = "ShowCompleted";
// The setting, read from the ini on first use.
bool& ShowCompleted() {
static bool show = re::ConfigInt(kConfigSection, kConfigShowCompleted, 1) != 0;
return show;
}
using Entry = std::array<uint8_t, game::kBookEntrySize>;
template <typename T>
T& Field(uint8_t* p, size_t offset) {
return *reinterpret_cast<T*>(p + offset);
}
// Our texts, registered once each (RegisterText keeps the pointer).
uint32_t TextId(const std::wstring& text) {
static std::map<std::wstring, uint32_t> ids;
static std::deque<std::wstring> storage;
auto found = ids.find(text);
if (found != ids.end()) {
return found->second;
}
storage.push_back(text);
return ids[text] = game::RegisterText(storage.back().c_str());
}
std::wstring Format(const wchar_t* format, int a, int b = 0, int c = 0) {
wchar_t text[128];
swprintf(text, 128, format, a, b, c);
return text;
}
// --- the list ------------------------------------------------------------------
// The quest table's side quests that count, by id: their area (0 = none by
// id). Not main quests, and not the ones whose name occurs more than once
// (generic quests and their clones, script bookkeeping such as "Region 1
// befreit").
std::map<int32_t, int> CountedQuests() {
std::map<int32_t, int> result;
std::map<std::string, int> names;
for (size_t i = 0, n = game::QuestCount(); i < n; ++i) {
const char* name = game::QuestName(game::Quest(static_cast<int>(i)));
if (*name) {
++names[name];
}
}
for (size_t i = 0, n = game::QuestCount(); i < n; ++i) {
uint8_t* quest = game::Quest(static_cast<int>(i));
const int32_t id = game::QuestId(quest);
const char* name = game::QuestName(quest);
if (id <= game::kMainQuestLast || (*name && names[name] > 1) || (id >= kAreaQuestEnd && id < kGenericQuestEnd)) {
continue;
}
result[id] = id >= kAreaQuestFirst && id < kAreaQuestEnd ? id / kAreaSize : 0;
}
return result;
}
struct Area {
int total = 0;
std::vector<int32_t> known; // quest ids, in book order
double x = 0, y = 0;
int positions = 0;
std::map<uint8_t, int> regions; // region number at its quests: count
};
// The area's name: the game's name of the region most of its quests lie in;
// else "Gebiet N".
std::wstring AreaName(int number, const Area& area) {
uint8_t region = 0;
int most = 0;
for (const auto& [r, n] : area.regions) {
if (n > most) {
region = r;
most = n;
}
}
const char* key = game::RegionNameKey(region);
std::wstring name = key ? game::GameTextByKey(key) : L"";
return name.empty() ? Format(L"Gebiet %d", number) : name;
}
// The reordered book: entries (copies of the real ones, or ours), all in the
// first tab, and for each the real entry index, kNoEntry for ours.
struct Order {
std::vector<Entry> entries;
std::vector<uint32_t> real;
void Add(const Entry& entry, uint32_t realIndex) {
entries.push_back(entry);
Field<uint16_t>(entries.back().data(), game::kBookChapter) = 0;
real.push_back(realIndex);
}
void AddReal(uint32_t index) {
Entry entry;
std::memcpy(entry.data(), game::BookEntry(index), entry.size());
Add(entry, index);
}
void AddOwn(int32_t kind, const std::wstring& title, int32_t state = 0, uint32_t marker = kNoEntry) {
Entry entry = {};
Field<int32_t>(entry.data(), game::kBookKind) = kind;
Field<int32_t>(entry.data(), game::kBookState) = state;
Field<uint32_t>(entry.data(), game::kBookTitle) = TextId(title);
Field<uint32_t>(entry.data(), game::kBookText) = TextId(L"");
Add(entry, marker);
}
// A heading: a main-quest-style row; the game puts a spacer before a main
// quest row only after a side quest row, so after a main quest an empty
// side row goes first.
void AddHeading(const std::wstring& title) {
if (!entries.empty() && Field<int32_t>(entries.back().data(), game::kBookKind) == game::kBookMain) {
AddOwn(game::kBookSide, L" ");
}
AddOwn(game::kBookMain, title);
}
};
Order BuildOrder() {
const std::map<int32_t, int> counted = CountedQuests();
const size_t count = game::BookCount();
std::map<int, Area> areas;
for (const auto& [id, area] : counted) {
if (area) {
++areas[area].total;
}
}
// Known side quests, in book order, with their area.
std::map<int32_t, int> questArea;
std::vector<int32_t> known;
for (size_t i = 0; i < count; ++i) {
uint8_t* entry = game::BookEntry(i);
const int32_t id = game::BookQuestId(entry);
if (game::BookKind(entry) == game::kBookMain || questArea.count(id)) {
continue;
}
const auto found = counted.find(id);
const int area = found != counted.end() ? found->second : 0;
questArea[id] = area;
known.push_back(id);
if (area) {
Area& a = areas[area];
a.known.push_back(id);
const int32_t x = Field<int32_t>(entry, game::kBookX);
const int32_t y = Field<int32_t>(entry, game::kBookY);
if (x || y) {
a.x += x;
a.y += y;
++a.positions;
++a.regions[game::RegionAtCell(x, y)];
}
}
}
// The other known side quests join the area nearest to them (by its known
// quests' mean position).
int unknownOthers = 0;
for (const auto& [id, area] : counted) {
unknownOthers += !area && !questArea.count(id);
}
for (int32_t id : known) {
if (questArea[id] || !counted.count(id)) {
continue;
}
for (size_t i = 0; i < count; ++i) {
uint8_t* entry = game::BookEntry(i);
if (game::BookQuestId(entry) != id) {
continue;
}
const double x = Field<int32_t>(entry, game::kBookX);
const double y = Field<int32_t>(entry, game::kBookY);
double bestDistance = 0;
int best = 0;
for (const auto& [number, a] : areas) {
if (!a.positions || (x == 0 && y == 0)) {
continue;
}
const double dx = x - a.x / a.positions;
const double dy = y - a.y / a.positions;
const double distance = dx * dx + dy * dy;
if (!best || distance < bestDistance) {
best = number;
bestDistance = distance;
}
}
if (best) {
questArea[id] = best;
++areas[best].total;
areas[best].known.push_back(id);
}
break;
}
}
const bool showCompleted = ShowCompleted();
auto shown = [&](uint8_t* entry) { return showCompleted || game::BookState(entry) != game::kBookSolved; };
auto hidden = [&](int32_t id) { // no entry of quest `id` in the list
for (size_t i = 0; i < count; ++i) {
uint8_t* entry = game::BookEntry(i);
if (game::BookQuestId(entry) == id && game::BookKind(entry) != game::kBookMain && shown(entry)) {
return false;
}
}
return true;
};
// The solved icon when on (the game draws state 100 so), no icon when off.
Order order;
order.AddOwn(game::kBookSide, L"Erledigte anzeigen", showCompleted ? game::kBookSolved : 0, kToggleEntry);
auto addQuest = [&](int32_t id) {
for (size_t i = 0; i < count; ++i) {
uint8_t* entry = game::BookEntry(i);
if (game::BookQuestId(entry) == id && game::BookKind(entry) != game::kBookMain && shown(entry)) {
order.AddReal(static_cast<uint32_t>(i));
}
}
};
order.AddHeading(L"Story");
for (size_t i = 0; i < count; ++i) {
uint8_t* entry = game::BookEntry(i);
if (game::BookKind(entry) == game::kBookMain && shown(entry)) {
order.AddReal(static_cast<uint32_t>(i));
}
}
// Areas with the same name (several id blocks lie in one region) make one
// section, in the order of their first area.
int undiscovered = 0;
std::vector<std::wstring> sectionNames;
std::map<std::wstring, Area> sections;
for (const auto& [number, a] : areas) {
if (a.known.empty()) {
undiscovered += a.total > 0;
continue;
}
const std::wstring name = AreaName(number, a);
if (!sections.count(name)) {
sectionNames.push_back(name);
}
Area& section = sections[name];
section.total += a.total;
section.known.insert(section.known.end(), a.known.begin(), a.known.end());
}
for (const std::wstring& name : sectionNames) {
const Area& a = sections[name];
if (!showCompleted && static_cast<int>(a.known.size()) >= a.total &&
std::all_of(a.known.begin(), a.known.end(), hidden)) {
continue;
}
const int knownCount = static_cast<int>(a.known.size());
order.AddHeading(name + Format(L" (%d/%d)", knownCount, a.total));
for (int32_t id : a.known) {
addQuest(id);
}
for (int i = knownCount; i < a.total; ++i) {
order.AddOwn(game::kBookSide, L"???");
}
}
bool others = false;
for (int32_t id : known) {
if (!questArea[id] && !hidden(id)) {
if (!others) {
order.AddHeading(L"Weitere Nebenquests");
others = true;
}
addQuest(id);
}
}
if (undiscovered || unknownOthers) {
order.AddHeading(L"Unentdeckt");
if (undiscovered) {
order.AddOwn(game::kBookSide, Format(undiscovered == 1 ? L"%d Gebiet" : L"%d Gebiete", undiscovered));
}
if (unknownOthers) {
order.AddOwn(game::kBookSide, Format(unknownOthers == 1 ? L"??? (%d weitere Nebenquest)"
: L"??? (%d weitere Nebenquests)",
unknownOthers));
}
}
return order;
}
// --- the book's pages ------------------------------------------------------------
uint8_t* CurrentPage(uint8_t* book) {
const size_t tab = Field<uint16_t>(book, kBookTab);
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
uint8_t* page = pages + Field<uint16_t>(book, kBookPage) * kPageSize;
return pages && page < pagesEnd ? page : nullptr;
}
uint8_t* RowsBegin(uint8_t* page) { return Field<uint8_t*>(page, kPageRows); }
uint8_t* RowsEnd(uint8_t* page) { return Field<uint8_t*>(page, kPageRows + 4); }
// The row's book entry; kNoEntry for spacers and rows past the end.
uint32_t RowEntry(uint8_t* page, uint16_t row) {
uint8_t* r = RowsBegin(page) + row * kRowSize;
return r < RowsEnd(page) && (Field<uint16_t>(r, 0) & kRowSpacer) == 0 ? Field<uint32_t>(r, kRowEntry) : kNoEntry;
}
bool IsQuestRow(uint8_t* page, uint16_t row) { return RowEntry(page, row) < kToggleEntry; }
void SelectFirstQuest(uint8_t* page) {
uint16_t& selected = Field<uint16_t>(page, kPageSelected);
if (IsQuestRow(page, selected)) {
return;
}
for (uint16_t row = 0; RowsBegin(page) + row * kRowSize < RowsEnd(page); ++row) {
if (IsQuestRow(page, row)) {
selected = row;
return;
}
}
}
// cUI_Book::Rebuild (0x6b1670, thiscall, no arguments): rebuilds the current
// mode's pages, keeping tab and page if the page still exists.
void RebuildBook(uint8_t* book) {
reinterpret_cast<void(__fastcall*)(uint8_t*, void*)>(0x006b1670)(book, nullptr);
const size_t tab = Field<uint16_t>(book, kBookTab);
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
if (!CurrentPage(book) && pages < pagesEnd) {
Field<uint16_t>(book, kBookPage) = static_cast<uint16_t>((pagesEnd - pages) / kPageSize - 1);
}
if (uint8_t* page = CurrentPage(book)) {
SelectFirstQuest(page);
}
}
// Rows point into the reordered book; point them at the real entries. Every
// page titled "Quests", its first quest selected.
void FixPages(uint8_t* book, const Order& order) {
for (int tab = 0; tab < kTabs; ++tab) {
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
for (uint8_t* page = pages; page && page < pagesEnd; page += kPageSize) {
for (uint8_t* row = RowsBegin(page); row && row < RowsEnd(page); row += kRowSize) {
uint32_t& index = Field<uint32_t>(row, kRowEntry);
if ((Field<uint16_t>(row, 0) & kRowSpacer) == 0 && index < order.real.size()) {
index = order.real[index];
}
}
if (tab != 0) {
continue;
}
Field<uint32_t>(page, kPageTitle) = TextId(L"Quests");
SelectFirstQuest(page);
}
}
}
} // namespace
} // namespace mods::questbook
using namespace mods::questbook;
// cUI_Book's quest list (0x6b04c0, thiscall, no arguments; from the book's
// rebuild 0x6b1670 in quest mode, on the same thread as the quest scripts).
void __fastcall Book_BuildQuestList(uint8_t* book, void* edx);
RE_HOOK(0x006b04c0, Book_BuildQuestList);
void __fastcall Book_BuildQuestList(uint8_t* book, void* edx) {
Order order = BuildOrder();
uint8_t* const begin = game::BookBegin();
uint8_t* const end = game::BookEnd();
uint8_t* const capacity = game::BookCapacityEnd();
uint32_t& selected = game::BookSelected(game::LocalPlayerSlot());
const uint32_t realSelected = selected;
uint32_t reorderedSelected = static_cast<uint32_t>(order.entries.size());
for (size_t i = 0; i < order.real.size(); ++i) {
if (order.real[i] == realSelected) {
reorderedSelected = static_cast<uint32_t>(i);
break;
}
}
auto* copy = reinterpret_cast<uint8_t*>(order.entries.data());
game::BookBegin() = copy;
game::BookEnd() = game::BookCapacityEnd() = copy + order.entries.size() * game::kBookEntrySize;
selected = reorderedSelected;
Book_BuildQuestList_original(book, edx);
game::BookBegin() = begin;
game::BookEnd() = end;
game::BookCapacityEnd() = capacity;
selected = realSelected;
FixPages(book, order);
for (int tab = 0; tab < kTabs; ++tab) {
game::SetControlShown(book + kBookmarks + tab * kBookmarkStride, false);
}
}
// cUI_Book's event handler (0x6b3620, thiscall (event), ret 4): a click on a
// heading or "???" row keeps the selection, one on "Erledigte anzeigen"
// toggles it.
bool __fastcall Book_HandleEvent(uint8_t* book, void* edx, void* event);
RE_HOOK(0x006b3620, Book_HandleEvent);
bool __fastcall Book_HandleEvent(uint8_t* book, void* edx, void* event) {
uint8_t* page = Field<uint16_t>(book, kBookMode) == kModeQuests ? CurrentPage(book) : nullptr;
const uint16_t selected = page ? Field<uint16_t>(page, kPageSelected) : kNoRow;
const bool handled = Book_HandleEvent_original(book, edx, event);
if (page && page == CurrentPage(book) && !IsQuestRow(page, Field<uint16_t>(page, kPageSelected))) {
const bool toggle = RowEntry(page, Field<uint16_t>(page, kPageSelected)) == kToggleEntry;
Field<uint16_t>(page, kPageSelected) = selected;
if (toggle) {
ShowCompleted() = !ShowCompleted();
re::SetConfigString(kConfigSection, kConfigShowCompleted, ShowCompleted() ? "1" : "0");
RebuildBook(book);
}
}
return handled;
}
+435
View File
@@ -0,0 +1,435 @@
// Failed quests can be restarted (request of 2026-10-17): a failed quest is not
// final, the quest giver offers it again. Its marker points back to the quest
// giver, drawn blue like an open side quest (request of 2026-10-25: "quests
// that failed, should have the blue marker point back to the quest giver").
//
// Measured (docs/quests.md): a failed quest keeps flags 3 (triggered + done) in
// the quest table and its quest book entry gets state 101; Quest_Trigger
// (0x46de10) does nothing for a quest with flag 1, so the giver never offers it
// again. The game itself repeats only the generic quests (ids 2600..3599,
// Quest_End copies them under a new id). The maps draw a marker for every book
// entry at its quest compass cell (+0x18/+0x1c), coloured by 0x4975d0: blue
// for open side quests, red for failed ones. The game does not keep the giver:
// a quest is accepted in a dialog answer (cDialogManager::Answer 0x49a090 ->
// quest script -> book entry 0x46c450), so the giver is the NPC next to the
// hero then.
//
// The mod: when a quest is accepted in a dialog, the nearest NPC's map cell is
// kept as the quest's giver (saved with the hero data, "questGivers"; quest
// ids are the same in every world, and so are the givers). After Quest_End
// failed a quest (its failure script has run), the quest's flags are reset to
// available (not accepted, not done); its book entry stays as failed with the
// compass on the giver, or is removed if the giver is not known. Accepting it
// again removes the failed entry. Quests that failed before the mod are reset
// the same way once after loading.
//
// Quest audit (2026-10-30, docs/quest-audit.md): a restart also puts back what
// the quest's script needs to be offered again (questscript.cpp): its variables
// as they were when it became available (kept at Quest_Trigger, saved with the
// hero; for quests triggered earlier: 1 for the one named after the quest, else
// 0), the giver's accept dialog, the giver and the NPCs it needs from their
// spawn instructions; what its QIS_OnEnter created is deleted (accepting again
// creates it anew). A quest stays failed when the game repeats it itself
// (generic quests), when it has no giver (trigger areas), when its failure ends
// the story (2402 sets up 2403, 5003's shaman turns hostile, ...) or when an NPC
// it needs is gone for good.
#include <algorithm>
#include <cstdint>
#include <map>
#include <string>
#include <utility>
#include <vector>
#include <nlohmann/json.hpp>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/quests.h"
#include "mods/inventory_save.h"
#include "mods/questfail.h"
#include "mods/questscript.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::questfail {
namespace {
// A restarted quest stays available (kQuestTriggered): its labels, the giver's
// dialog too, run only while it is set (Script_RunLabel 0x46bf11; measured
// 2026-10-26, "Dialog faild" without it), and so does accepting it (script
// opcode 0x14, 0x48f320). The game sets it when the hero enters the quest's
// region (Quest_Trigger 0x46de10).
constexpr uint32_t kResetFlags = game::kQuestDone | game::kQuestInProgress | game::kQuestBusy;
// The giver is the nearest NPC within this distance of the hero when the quest
// is accepted (world units; a talk starts at contact distance, about 100).
constexpr uint32_t kGiverMaxDistance = 400;
// Marker colours of 0x4975d0 for an open side quest (state 2): kind 0 the
// marker, kind 1 the second colour (white).
constexpr uint32_t kOpenSideQuestColour = 0xff006cff;
constexpr uint32_t kOpenQuestColour2 = 0xffffffff;
bool g_resetAfterLoad = false;
bool g_inDialogAnswer = false;
// Quest id -> the giver's map cell.
std::map<int32_t, std::pair<int32_t, int32_t>> g_giverCells;
// Quest id -> its variables when it became available.
std::map<int32_t, std::map<std::string, int32_t>> g_freshVariables;
// The nearest living, not hostile, non-hero creature near the hero, or 0. All
// objects are searched (the nearby-creature list 0xad1c60 did not hold the
// givers).
uint32_t NearestNpc(void* hero) {
const int32_t heroX = game::CreatureField(hero, game::kCreatureX);
const int32_t heroY = game::CreatureField(hero, game::kCreatureY);
uint32_t best = 0;
uint32_t bestDistance = kGiverMaxDistance + 1;
for (uint32_t id = game::kPlayerHeroIdEnd, end = game::ObjectIdEnd(); id < end; ++id) {
void* creature = game::GetCreature(id);
if (!creature) {
continue;
}
const int32_t dx = game::CreatureField(creature, game::kCreatureX) - heroX;
const int32_t dy = game::CreatureField(creature, game::kCreatureY) - heroY;
if (dx < -1000 || dx > 1000 || dy < -1000 || dy > 1000) {
continue;
}
const uint32_t distance = game::DistanceTo(hero, id);
const bool alive = game::IsAlive(creature);
const bool hostile = game::IsHostileTo(hero, creature);
if (alive && !hostile && distance < bestDistance) {
best = id;
bestDistance = distance;
}
}
return best;
}
void RememberGiver(int32_t questId) {
void* hero = game::GetPlayerHero();
const uint32_t npc = hero ? NearestNpc(hero) : 0;
void* creature = npc ? game::GetCreature(npc) : nullptr;
if (!creature) {
re::Log("questfail: quest %d accepted, no giver near the hero", questId);
return;
}
const auto* position = reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(creature) +
game::kObjectPosition);
const game::MapPosition cell = game::CellAt(creature, position->x, position->y);
g_giverCells[questId] = {cell.x, cell.y};
re::Log("questfail: quest %d accepted from creature %u at cell %d,%d", questId, npc, cell.x, cell.y);
}
// The entry's compass on its quest's giver (a cell target, no radius). False if
// the giver is not known.
bool PointAtGiver(uint8_t* entry) {
const auto giver = g_giverCells.find(game::BookQuestId(entry));
if (giver == g_giverCells.end()) {
return false;
}
auto field = [entry](size_t offset) -> int32_t& { return *reinterpret_cast<int32_t*>(entry + offset); };
field(game::kBookCompassX) = field(game::kBookX) = giver->second.first;
field(game::kBookCompassY) = field(game::kBookY) = giver->second.second;
field(game::kBookCompassRadius) = 0;
return true;
}
// The variable named after the quest keeps bit 0: "available", which the
// giver's offer tests (auftrag402: IsVarBit 402 0; measured 2026-10-28: all 87
// quests not yet accepted had value 1).
int32_t FreshValue(int32_t questId, const std::string& name) {
const int32_t available = name == std::to_string(questId) ? 1 : 0;
const auto quest = g_freshVariables.find(questId);
if (quest == g_freshVariables.end()) {
return available;
}
const auto value = quest->second.find(name);
return (value != quest->second.end() ? value->second : 0) | available;
}
void ResetVariables(int32_t questId, const questscript::Quest& quest) {
for (const std::string& name : quest.variables) {
int32_t* value = game::ScriptVariable(name.c_str());
const int32_t fresh = FreshValue(questId, name);
if (!value || *value == fresh) {
continue;
}
re::Log("questfail: quest %d variable '%s' %d -> %d", questId, name.c_str(), *value, fresh);
*value = fresh;
}
}
// Why failed quest `index` cannot be offered again, or null. `afterLoad`: the
// failure is not running now, so any failure of the quest that ends its story
// counts.
const char* StaysFailed(int index, bool afterLoad) {
const int32_t id = game::QuestId(game::Quest(index));
const questscript::Quest* quest = questscript::Find(id);
if (!quest || (id >= game::kGenericQuestFirst && id <= game::kGenericQuestLast)) {
return "the game repeats it itself";
}
if (!quest->hasGiver && !g_giverCells.count(id)) {
return "no giver";
}
if (afterLoad ? quest->hasFinalFailure : questscript::InFinalFailure()) {
return "its failure ends the story";
}
if (quest->hasGiver && questscript::IsGone(quest->giver) && quest->giver.spawn.empty() &&
!questscript::SpawnOf(quest->giver.scriptId, id)) {
return "its giver is gone";
}
for (uint32_t npc : quest->needed) {
if (!questscript::LivingNpc(npc) && !questscript::SpawnOf(npc, id)) {
re::Log("questfail: quest %d needs Res:%u, gone without a spawn instruction", id, npc);
return "an NPC it needs is gone";
}
}
return nullptr;
}
// Failed quests in the book: those failed before the mod are restarted unless
// they stay failed; those restarted before the variables were reset get their
// variables back (their giver may meanwhile offer a later quest, so the
// dialog stays).
void RestartFailedInBook() {
for (size_t i = game::BookCount(); i-- > 0;) {
const uint8_t* entry = game::BookEntry(i);
if (game::BookState(entry) != game::kBookFailed) {
continue;
}
const int index = game::QuestIndex(game::BookQuestId(entry));
const uint32_t flags = index >= 0 ? game::QuestFlags(game::Quest(index)) : 0;
if (index < 0 || ((flags & game::kQuestInProgress) && !(flags & game::kQuestDone))) {
continue;
}
if (!(flags & game::kQuestDone)) {
const int32_t id = game::QuestId(game::Quest(index));
if (const questscript::Quest* quest = questscript::Find(id)) {
ResetVariables(id, *quest);
}
} else if (!StaysFailed(index, true)) {
Restart(index);
i = std::min(i, game::BookCount());
}
}
}
void EraseFailedEntries(int32_t questId) {
for (size_t i = game::BookCount(); i-- > 0;) {
const uint8_t* entry = game::BookEntry(i);
if (game::BookQuestId(entry) == questId && game::BookState(entry) == game::kBookFailed) {
game::EraseBookEntry(i);
}
}
}
void OnTick(uint8_t*) {
if (g_resetAfterLoad) {
g_resetAfterLoad = false;
RestartFailedInBook();
}
}
void OnLoaded() {
g_resetAfterLoad = true;
}
// --- saved with the hero: {"quest id": [x, y]} ------------------------------------
nlohmann::json WriteGivers(unsigned) {
if (g_giverCells.empty()) {
return nlohmann::json();
}
nlohmann::json all = nlohmann::json::object();
for (const auto& [id, cell] : g_giverCells) {
all[std::to_string(id)] = {cell.first, cell.second};
}
return all;
}
// Merged: the hero file and the save game both carry the list.
void ReadGivers(unsigned, const nlohmann::json& data) {
if (!data.is_object()) {
return;
}
for (const auto& [key, cell] : data.items()) {
if (cell.is_array() && cell.size() == 2 && cell[0].is_number_integer() && cell[1].is_number_integer()) {
g_giverCells[std::stoi(key)] = {cell[0].get<int32_t>(), cell[1].get<int32_t>()};
}
}
}
// --- saved with the hero: {"quest id": {"variable": value}} ------------------------
nlohmann::json WriteFreshVariables(unsigned) {
if (g_freshVariables.empty()) {
return nlohmann::json();
}
nlohmann::json all = nlohmann::json::object();
for (const auto& [id, variables] : g_freshVariables) {
nlohmann::json& quest = all[std::to_string(id)] = nlohmann::json::object();
for (const auto& [name, value] : variables) {
quest[name] = value;
}
}
return all;
}
// Merged, as the givers.
void ReadFreshVariables(unsigned, const nlohmann::json& data) {
if (!data.is_object()) {
return;
}
for (const auto& [key, variables] : data.items()) {
if (!variables.is_object()) {
continue;
}
auto& quest = g_freshVariables[std::stoi(key)];
for (const auto& [name, value] : variables.items()) {
if (value.is_number_integer()) {
quest[name] = value.get<int32_t>();
}
}
}
}
} // namespace
// The quest at table `index` as if it had never been triggered; its failed book
// entries point at the giver (or go).
void Restart(int index) {
uint8_t* quest = game::Quest(index);
const int32_t id = game::QuestId(quest);
re::Log("questfail: restarting quest %d '%s' (flags %x)", id, game::QuestName(quest), game::QuestFlags(quest));
game::QuestFlags(quest) = (game::QuestFlags(quest) & ~kResetFlags) | game::kQuestTriggered;
for (size_t i = game::BookCount(); i-- > 0;) {
uint8_t* entry = game::BookEntry(i);
if (game::BookQuestId(entry) == id && !PointAtGiver(entry)) {
game::EraseBookEntry(i);
}
}
const questscript::Quest* script = questscript::Find(id);
if (!script) {
return;
}
ResetVariables(id, *script);
for (uint32_t npc : script->createdOnEnter) {
questscript::Delete(npc);
}
for (uint32_t npc : script->needed) {
const std::vector<uint8_t>* spawn = questscript::LivingNpc(npc) ? nullptr : questscript::SpawnOf(npc, id);
if (spawn) {
questscript::SpawnWhenGone(npc, *spawn, std::string(), "needed by its quest");
}
}
if (!script->hasGiver) {
return;
}
const questscript::Giver& giver = script->giver;
if (!questscript::IsGone(giver)) {
questscript::SetDialog(giver.res, giver.dialog);
} else if (!giver.spawn.empty()) {
questscript::SpawnWhenGone(giver.scriptId, giver.spawn, std::string(), "quest giver");
} else if (const std::vector<uint8_t>* spawn = questscript::SpawnOf(giver.scriptId, id)) {
questscript::SpawnWhenGone(giver.scriptId, *spawn, giver.dialog, "quest giver");
}
}
// Quest_End (0x46ced0, quest manager thiscall (index, ?, success), ret 0xc):
// LoseQuest passes success 0, ExitQuest 1.
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success);
RE_HOOK(0x0046ced0, Quest_End);
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success) {
const bool known = index >= 0 && static_cast<size_t>(index) < game::QuestCount();
const char* staysFailed = !success && known ? StaysFailed(index, false) : nullptr;
Quest_End_original(self, edx, index, a2, success);
if (!success && known) {
if (staysFailed) {
re::Log("questfail: quest %d '%s' stays failed: %s", game::QuestId(game::Quest(index)),
game::QuestName(game::Quest(index)), staysFailed);
} else {
Restart(index);
}
}
}
// Quest_Trigger (0x46de10, cdecl (index, ?, ?, ?), plain ret): makes the quest
// available (flag 1) the first time, running its trigger scripts. Its
// variables are kept as they are then: the state a restart goes back to.
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4);
RE_HOOK(0x0046de10, Quest_Trigger);
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4) {
if (index >= 0 && static_cast<size_t>(index) < game::QuestCount() &&
!(game::QuestFlags(game::Quest(index)) & game::kQuestTriggered)) {
const int32_t id = game::QuestId(game::Quest(index));
if (const questscript::Quest* quest = questscript::Find(id)) {
auto& fresh = g_freshVariables[id];
fresh.clear();
for (const std::string& name : quest->variables) {
if (const int32_t* value = game::ScriptVariable(name.c_str())) {
fresh[name] = *value;
}
}
}
}
return Quest_Trigger_original(index, a2, a3, a4);
}
// cDialogManager::Answer (0x49a090, thiscall (owner, answer), ret 8): runs the
// quest script for the answer.
void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer);
RE_HOOK(0x0049a090, Dialog_Answer);
void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer) {
g_inDialogAnswer = true;
Dialog_Answer_original(self, edx, owner, answer);
g_inDialogAnswer = false;
}
// QuestManager_AddBookEntry (0x46c450, quest manager thiscall (quest index, ?), ret 8): adds
// the quest's book entry (state 2).
void __fastcall QuestManager_AddBookEntry(uint8_t* self, void* edx, int index, int a2);
RE_HOOK(0x0046c450, QuestManager_AddBookEntry);
void __fastcall QuestManager_AddBookEntry(uint8_t* self, void* edx, int index, int a2) {
const bool known = index >= 0 && static_cast<size_t>(index) < game::QuestCount();
const int32_t id = known ? game::QuestId(game::Quest(index)) : 0;
if (known) {
EraseFailedEntries(id);
}
QuestManager_AddBookEntry_original(self, edx, index, a2);
if (known && g_inDialogAnswer && id > game::kMainQuestLast) {
RememberGiver(id);
}
}
// QuestManager_MarkerColour (0x4975d0, quest manager thiscall (entry, kind), ret 8): a
// failed side quest pointing at its giver is drawn as an open one.
uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t index, int16_t kind);
RE_HOOK(0x004975d0, QuestManager_MarkerColour);
uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t index, int16_t kind) {
if (index < game::BookCount() && (kind == 0 || kind == 1)) {
const uint8_t* entry = game::BookEntry(index);
if (game::BookState(entry) == game::kBookFailed && game::BookKind(entry) == game::kBookSide &&
g_giverCells.count(game::BookQuestId(entry))) {
return kind == 0 ? kOpenSideQuestColour : kOpenQuestColour2;
}
}
return QuestManager_MarkerColour_original(self, edx, index, kind);
}
namespace {
struct Register {
Register() {
game::OnEngineTick(&OnTick);
game::OnSaveGameLoaded(&OnLoaded);
inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers);
inventory_save::RegisterHeroData("questVariables", &WriteFreshVariables, &ReadFreshVariables);
}
} g_register;
} // namespace
} // namespace mods::questfail
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Failed quests can be restarted (questfail.cpp).
namespace mods::questfail {
// The quest at table `index` as if it had never been triggered; its failed book
// entries point at the giver (or go).
void Restart(int index);
} // namespace mods::questfail
+137
View File
@@ -0,0 +1,137 @@
// Quest givers come back (request of 2026-10-26, docs/quests.md): "Jagd auf
// die Wilderer" (quest 1501): the giver, an elf, follows the hero and fights;
// when she died the quest could not be finished ("change the death script to
// spawn her back to her starting point"; "on load we need a repair ... if not
// completed or correctly running").
//
// Measured: the quest's script (FunkCode.bin) gives the giver a death label
// when she starts following ("ex1501": bit 1 of quest variable "1501", then the
// poachers' death label "tot1501", which ends the quest without a reward once
// all poachers are dead; until then it stays in progress with no giver). The
// body is removed. The giver is placed by her region's start script
// (Region15Init: spawn instruction 1 "Res:17013", type 134, at
// pos_auftrag1501, dialog auftrag1501), which the game runs once.
//
// The giver is the NPC whose spawn instruction carries the quest's accept
// dialog (questscript.cpp). When a label of that quest runs for its dead giver
// (her death label), the label is skipped and the quest restarts
// (questfail::Restart: available again, its book entry failed and pointing at
// the giver, its variables as before, the giver spawned again once the body is
// gone). After a load, a quest in progress whose giver no longer exists is
// repaired the same way, unless the quest itself turns the giver into an enemy
// or deletes her (quest audit 2026-10-30: 154 deletes its giver on purpose).
#include <cstdint>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/quests.h"
#include "mods/questfail.h"
#include "mods/questscript.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::questgiver {
namespace {
bool g_repairAfterLoad = false;
// The quest's giver if M2 can bring her back: a numbered script id spawned with
// the accept dialog.
const questscript::Giver* RespawnableGiver(int32_t questId) {
const questscript::Quest* quest = questscript::Find(questId);
return quest && quest->hasGiver && quest->giver.scriptId && !quest->giver.spawn.empty() ? &quest->giver
: nullptr;
}
// Restarts quest `index` (questfail::Restart, which also brings the giver
// back); its book entry is failed.
void ResetQuest(int index) {
const int32_t id = game::QuestId(game::Quest(index));
questfail::Restart(index);
for (size_t i = 0; i < game::BookCount(); ++i) {
uint8_t* entry = game::BookEntry(i);
if (game::BookQuestId(entry) == id) {
*reinterpret_cast<int32_t*>(entry + game::kBookState) = game::kBookFailed;
}
}
}
// Accepted and not done (kQuestTriggered alone: available, set when the hero
// enters the quest's region).
bool IsRunning(int index) {
const uint32_t flags = game::QuestFlags(game::Quest(index));
return (flags & game::kQuestInProgress) && !(flags & game::kQuestDone);
}
// Quests whose giver turns into an enemy are left alone: a missing giver may
// be the one the hero killed as the quest asks; so are those deleting her.
void RepairAfterLoad() {
for (int index = 0, count = static_cast<int>(game::QuestCount()); index < count; ++index) {
const int32_t id = game::QuestId(game::Quest(index));
const questscript::Giver* giver = IsRunning(index) ? RespawnableGiver(id) : nullptr;
const questscript::Quest* quest = giver ? questscript::Find(id) : nullptr;
if (!quest || quest->turnsGiver || quest->deletesGiver || questscript::LivingNpc(giver->scriptId)) {
continue;
}
re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id,
game::QuestName(game::Quest(index)), giver->scriptId);
ResetQuest(index);
}
}
void OnTick(uint8_t*) {
if (g_repairAfterLoad) {
g_repairAfterLoad = false;
RepairAfterLoad();
}
}
void OnLoaded() { g_repairAfterLoad = true; }
struct Registration {
Registration() {
game::OnEngineTick(&OnTick);
game::OnSaveGameLoaded(&OnLoaded);
}
} g_registration;
} // namespace
// Script_RunLabel (0x46bd80, cdecl (label, object, flag), plain ret): a label
// of a running quest run for its dead giver (her death label) is skipped and
// the quest restarted. Not when the giver died as an enemy: some quests turn
// the giver hostile and their death label goes on ("Familienbande", 702:
// answer change702 turns the giver Res:17061 and two relatives against the
// hero, their death label tot702 sends him back to the girl Res:17060). The
// running labels are kept for questfail (failures that end a story).
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag);
RE_HOOK(0x0046bd80, Script_RunLabel);
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) {
const questscript::LabelScope running(label);
const uint8_t* labels = game::ScriptLabelsBegin();
if (object && labels && label < (game::ScriptLabelsEnd() - labels) / game::kScriptLabelSize) {
const int32_t questId = game::ScriptLabelQuest(labels + label * game::kScriptLabelSize);
const int index = questId >= 0 ? game::QuestIndex(questId) : -1;
void* creature = index >= 0 ? game::DynamicCast(object, game::kTypeObject, game::kTypeCreature) : nullptr;
if (creature && IsRunning(index) && !game::IsAlive(creature)) {
const questscript::Giver* giver = RespawnableGiver(questId);
if (giver && game::ObjectScriptId(creature) == giver->scriptId) {
const char* name = game::ScriptLabelName(labels + label * game::kScriptLabelSize);
if (game::IsHostileTo(creature, game::GetPlayerHero())) {
re::Log("questgiver: giver Res:%u of quest %d died as an enemy; '%s' runs", giver->scriptId,
questId, name);
return Script_RunLabel_original(label, object, flag);
}
re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts", giver->scriptId,
questId, name);
ResetQuest(index);
return 0;
}
}
}
return Script_RunLabel_original(label, object, flag);
}
} // namespace mods::questgiver
+37
View File
@@ -0,0 +1,37 @@
// Main quest items blocked by an entry without a script id (request of
// 2026-10-27: "I cannot finish the Quest Windgeflüster").
//
// Measured: picking at the Goldbeerenbusch runs take402, which gives the hero
// the berries (item type 6029) and sets the quest's variable; the giver's
// dialog then asks for type 6029 in the hero's main quest items. The game
// logged "addMainQuestItem() Already has mainQuestItem(6029)" and the berries
// never arrived: the dwarf's list held {4609, 0}.
//
// cInventoryEntry::AddMainQuestItem (0x5d8240, thiscall (type, script id,
// notify), list of {type, script id} at +0/+4) refuses a new item when an entry
// has the same type or a script id of 0 (0x5d83c6: cmp [edx+4], ebx (0)).
// Scripts that hand out an item without a "Res:" id add it with script id 0
// (0x486bcb..0x486bd8), so after such an item every later main quest item was
// refused. The mod drops the script id test; the same type still blocks.
#include <cstdint>
#include "re/log.h"
#include "re/patch.h"
namespace mods::questitems {
namespace {
// AddMainQuestItem's duplicate test: `cmp [edx+4], ebx; je 0x5d8418` (ebx 0).
constexpr uint32_t kScriptIdTest = 0x005d83c6;
void Startup() {
const uint8_t from[5] = {0x39, 0x5a, 0x04, 0x74, 0x4d};
const uint8_t to[5] = {0x90, 0x90, 0x90, 0x90, 0x90};
const bool ok = re::ReplaceCode(kScriptIdTest, from, to, sizeof(to));
re::Log("questitems: main quest items without a script id no longer block others%s", ok ? "" : " (patch FAILED)");
}
RE_STARTUP(QuestItemsStartup, Startup);
} // namespace
} // namespace mods::questitems
+155
View File
@@ -0,0 +1,155 @@
// Quest NPCs after their quest (request of 2026-10-25): NPCs related to a quest
// are drawn white on the minimaps and turn towards the player; once their quest
// is finished they should be drawn blue and stop turning, like other NPCs.
//
// Measured: the zoomed map view (0x6cc950, 0x6cec6b) draws a creature white
// when cCreature::IsQuestNpc (0x54fa30) says so: creature flags +0x200 bit
// 0x100 or 0x200 (set by the quest manager for escorted NPCs, 0x46ae54..), or
// a dialog +0x245 (1..59999) whose type (cDialogManager::DialogType 0x49a180:
// record +0x48) is below 50. Some quests move their giver on to dialog 0 when
// they end (DLG_15074_START -> _OFFEN -> _ZIEL -> 0), many leave it on the
// quest's dialog: auftrag10, auftrag2411, AuftragEnde202 kept their dialog
// with the quest done (flags 3, book entry solved).
//
// The turning (found with a hardware write watch on the facing +0x70): the
// idle action (cCreature::IdleAction 0x544700, Think's action 0 handler) turns
// every creature with a dialog +0x245 in 1..99999 towards the local hero each
// Think step (0x546334..0x5463d5, cCreature::SetDirection 0x44df60), unless
// its type is of kind 4 in the type table (0x4265c0, call at 0x546357) or
// object flag +0x14 bit 2 is set.
//
// The mod: a dialog whose quest is done and not running again no longer makes
// its NPC a quest NPC (blue on the minimap) and no longer turns it towards the
// player. A dialog's quest is the game's own: each dialog has a script label
// "Dialog:<name>" and each label the quest it belongs to (game/engine/quests.h,
// script labels; logged 2026-10-25: Dialog:rg1_goodie1_auftrag 9004,
// Dialog:auftrag_wueste 9000, Dialog:auftrag11a1 1101, Dialog:geschlossen101
// none).
#include <cstdint>
#include <cstring>
#include <map>
#include <string>
#include "game/engine/quests.h"
#include "re/hook.h"
#include "re/patch.h"
namespace mods::questnpc {
namespace {
constexpr size_t kCreatureDialog = 0x245; // cCreature::GetDialog 0x549790
constexpr int32_t kLastQuestDialog = 59999; // IsQuestNpc: dialogs 1..59999
constexpr uintptr_t kDialogManager = 0x00aaaf00;
constexpr size_t kDialogRecordsBegin = 0x755c;
constexpr size_t kDialogRecordsEnd = 0x7560;
constexpr size_t kDialogRecordSize = 0x50;
constexpr size_t kDialogRecordName = 0x04; // ANSI
constexpr const char* kDialogLabelPrefix = "Dialog:";
template <typename T>
T Field(const uint8_t* p, size_t offset) {
return *reinterpret_cast<const T*>(p + offset);
}
// The dialog's record (0x50 bytes: name at +4, type at +0x48), null if none.
const uint8_t* DialogRecord(int32_t dialog) {
const auto* manager = reinterpret_cast<const uint8_t*>(kDialogManager);
const uint8_t* begin = Field<const uint8_t*>(manager, kDialogRecordsBegin);
const uint8_t* end = Field<const uint8_t*>(manager, kDialogRecordsEnd);
if (dialog <= 0 || !begin || begin + (dialog + 1) * kDialogRecordSize > end) {
return nullptr;
}
return begin + dialog * kDialogRecordSize;
}
// The quest of each dialog label, by dialog name; empty until the scripts are
// loaded.
const std::map<std::string, int32_t>& DialogLabelQuests() {
static std::map<std::string, int32_t> quests;
if (quests.empty()) {
const size_t prefix = std::strlen(kDialogLabelPrefix);
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
label += game::kScriptLabelSize) {
const char* name = game::ScriptLabelName(label);
if (std::strncmp(name, kDialogLabelPrefix, prefix) == 0 && game::ScriptLabelQuest(label) > 0) {
quests[std::string(name + prefix, strnlen(name + prefix, game::kScriptLabelNameSize - prefix))] =
game::ScriptLabelQuest(label);
}
}
}
return quests;
}
// The quest table index of the quest a dialog belongs to, -1 if none. Cached
// per dialog: the minimap and the idle action ask for every creature often.
int DialogQuest(int32_t dialog) {
static std::map<int32_t, int> cache;
if (auto it = cache.find(dialog); it != cache.end()) {
return it->second;
}
const uint8_t* record = DialogRecord(dialog);
const auto& quests = DialogLabelQuests();
if (!record || quests.empty() || game::QuestCount() == 0) {
return -1; // not cached before the scripts are loaded
}
const auto it = quests.find(reinterpret_cast<const char*>(record + kDialogRecordName));
const int index = it != quests.end() ? game::QuestIndex(it->second) : -1;
cache[dialog] = index;
return index;
}
// The creature's dialog belongs to a finished quest.
bool QuestFinished(const uint8_t* creature) {
const int32_t dialog = Field<int32_t>(creature, kCreatureDialog);
if (dialog <= 0 || dialog > kLastQuestDialog) {
return false;
}
const int index = DialogQuest(dialog);
if (index < 0) {
return false;
}
const uint32_t flags = game::QuestFlags(game::Quest(index));
return (flags & game::kQuestDone) && !(flags & game::kQuestInProgress);
}
// The idle action's type check before turning towards the hero (call at
// 0x546357: TypeTable_IsKind4 0x4265c0, thiscall (type table, type), ret 4;
// true skips the turn), creature in esi.
constexpr uint32_t kFacePlayerTypeCheck = 0x00546357;
constexpr uint32_t kTypeTableIsKind4 = 0x004265c0;
char __stdcall KeepsFacing(void* typeTable, int32_t type, const uint8_t* creature) {
using Fn = char(__fastcall*)(void*, void*, int32_t);
return reinterpret_cast<Fn>(kTypeTableIsKind4)(typeTable, nullptr, type) || QuestFinished(creature);
}
__declspec(naked) void KeepsFacingThunk() {
__asm {
push esi
push dword ptr [esp + 8]
push ecx
call KeepsFacing
ret 4
}
}
void Startup() {
re::RedirectCall(kFacePlayerTypeCheck, kTypeTableIsKind4, reinterpret_cast<const void*>(&KeepsFacingThunk));
}
RE_STARTUP(QuestNpcStartup, Startup);
} // namespace
// cCreature::IsQuestNpc (0x54fa30, thiscall (), ret): the minimap draws the
// creature white.
char __fastcall Creature_IsQuestNpc(uint8_t* self, void* edx);
RE_HOOK(0x0054fa30, Creature_IsQuestNpc);
char __fastcall Creature_IsQuestNpc(uint8_t* self, void* edx) {
if (QuestFinished(self)) {
return 0;
}
return Creature_IsQuestNpc_original(self, edx);
}
} // namespace mods::questnpc
+619
View File
@@ -0,0 +1,619 @@
// The quest scripts read once (quest audit of 2026-10-30, docs/quest-audit.md):
// what the failure restart (questfail.cpp) and the giver respawn
// (questgiver.cpp) need to know about a quest.
//
// The compiled script code (FunkCode.bin, open as the quest manager's file) is
// a list of labels (the label table, game/engine/quests.h) whose code is a
// sequence of instructions {u16 opcode, u16 size, tagged args}; the argument
// sizes per tag are those of the game's reader (0x472eb0, as tools/
// questscript.mjs decodes them). From it, per quest:
// - the giver: the NPC given a dialog whose answer starts the quest (a spawn
// instruction carrying it, e.g. Region15Init "Res:17013" dialog auftrag1501;
// else an NPC state instruction, as the Underworld's QIS_OnSetUp<id> do);
// - its variables: those only its labels set, and the one named after it;
// - the NPCs it needs: those its labels set states on but don't create, as
// 1302's opponent Res:17173 (Region13Init); they come back from their spawn
// instruction elsewhere when the quest restarts;
// - what QIS_OnEnter<id> creates (deleted on a restart, accepting again
// creates it anew);
// - the failures that end its story: a LoseQuest label that also sets up the
// next quest (2402), calls another quest's label (2502 finalestart), hands
// out a reward (9520), or turns hostile or kills an NPC offering it (5003,
// 1701, 1703).
#include "mods/questscript.h"
#include <algorithm>
#include <array>
#include <cstdlib>
#include <cstring>
#include <map>
#include <set>
#include <utility>
#include <windows.h>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/quests.h"
#include "re/log.h"
namespace mods::questscript {
namespace {
constexpr size_t kInstructionHeader = 4;
constexpr size_t kReadChunk = 1 << 20;
constexpr DWORD kBodyWaitMs = 30000; // spawn anyway if the body is still there
constexpr int32_t kPositionNamed = -2; // position args: -2 + a name, else 3 ints
constexpr int32_t kNumberNamed = -0x12d687; // 0x0b args: this + a name (DefNum)
constexpr uint8_t kArgHostile = 0x12; // flag after kScriptArgAttitude
// Argument payloads by tag (0x472eb0).
enum class Payload : uint8_t { kUnknown, kString, kU16, kI32, kPosition, kCondition, kFlag, kBytes };
struct ArgTable {
std::array<Payload, 256> payload{};
std::array<uint8_t, 256> bytes{};
ArgTable() {
for (int tag : {0x01, 0x05, 0x09, 0x16, 0x1e, 0x29, 0x3a, 0x41, 0x60, 0x63, 0x67, 0x68, 0x69, 0x6a, 0x71,
0x83, 0x9d}) {
payload[tag] = Payload::kString;
}
for (int tag : {0x03, 0x0a, 0x6b, 0x6c, 0x9b, 0x9c}) {
payload[tag] = Payload::kU16;
}
for (int tag : {0x02, 0x0b, 0x11, 0x19, 0x1c, 0x1d, 0x1f, 0x36, 0x38, 0x53, 0x54, 0x55, 0x56, 0x57, 0x5f,
0x73, 0x75, 0x84, 0x86}) {
payload[tag] = Payload::kI32;
}
for (int tag : {0x04, 0x0c, 0x0d}) {
payload[tag] = Payload::kPosition;
}
for (int tag : {0x47, 0x48, 0x49, 0x4a}) {
payload[tag] = Payload::kCondition;
}
for (int tag : {0x06, 0x07, 0x08, 0x0e, 0x0f, 0x10, 0x12, 0x13, 0x14, 0x1a, 0x1b, 0x23, 0x24, 0x25, 0x26, 0x27,
0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x39, 0x3f, 0x42, 0x43, 0x44, 0x45, 0x46, 0x4c,
0x4e, 0x4f, 0x50, 0x51, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x61, 0x62, 0x64, 0x65, 0x66, 0x70, 0x72,
0x74, 0x78, 0x7b, 0x7c, 0x80, 0x85, 0x8a, 0x8d, 0x8e, 0x91, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9e,
0xa0, 0xa1}) {
payload[tag] = Payload::kFlag;
}
for (auto [tag, size] : std::initializer_list<std::pair<int, int>>{
{0x15, 8}, {0x20, 1}, {0x28, 1}, {0x2a, 1}, {0x33, 8}, {0x34, 8}, {0x35, 12}, {0x37, 1},
{0x4b, 1}, {0x4d, 1}, {0x79, 8}, {0x87, 8}, {0x88, 8}, {0x89, 8}, {0x8b, 1}}) {
payload[tag] = Payload::kBytes;
bytes[tag] = static_cast<uint8_t>(size);
}
}
};
const ArgTable kArgs;
struct Arg {
uint8_t tag = 0;
const char* text = nullptr; // string payloads, in g_code
int32_t number = 0;
};
struct Label {
std::string name;
int32_t quest = -1;
size_t code = 0;
size_t size = 0;
};
struct Spawn {
int32_t quest;
const Label* label;
std::vector<uint8_t> code;
};
struct Pending {
uint32_t scriptId;
std::vector<uint8_t> spawn;
std::string dialog;
void* body;
uint32_t bodyId;
DWORD since;
std::string why;
};
bool g_scanned = false;
std::vector<uint8_t> g_code;
std::vector<Label> g_labels;
std::map<int32_t, Quest> g_quests;
std::map<uint32_t, std::vector<Spawn>> g_spawns; // script id -> its spawn instructions
std::set<uint32_t> g_finalLabels; // label indices
std::vector<uint32_t> g_running; // labels running now
std::vector<Pending> g_pending;
uint16_t U16(size_t at) { return static_cast<uint16_t>(g_code[at] | (g_code[at + 1] << 8)); }
int32_t I32(size_t at) {
int32_t value;
std::memcpy(&value, &g_code[at], sizeof(value));
return value;
}
std::string Lower(const char* text) {
std::string lower(text);
for (char& c : lower) {
c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
}
return lower;
}
bool IsRes(const char* text) { return text && _strnicmp(text, "res:", 4) == 0; }
// The number of "Res:<number>", 0 for a named id.
uint32_t ResNumber(const char* text) {
if (!IsRes(text) || !text[4]) {
return 0;
}
char* end = nullptr;
const unsigned long number = std::strtoul(text + 4, &end, 10);
return *end == 0 ? static_cast<uint32_t>(number) : 0;
}
bool ReadCode() {
g_code.clear();
for (uint32_t offset = 0;; offset += kReadChunk) {
g_code.resize(offset + kReadChunk);
const size_t read = game::ReadScriptFile(offset, g_code.data() + offset, kReadChunk);
g_code.resize(offset + read);
if (read < kReadChunk) {
return !g_code.empty();
}
}
}
// The arguments of the instruction at `at`; false if they don't decode exactly
// (conditions are not decoded).
bool Decode(size_t at, uint16_t size, std::vector<Arg>& args) {
args.clear();
const size_t end = at + size;
size_t p = at + kInstructionHeader;
auto text = [&](size_t from, const char*& out) {
const void* zero = from < end ? memchr(&g_code[from], 0, end - from) : nullptr;
if (!zero) {
return false;
}
out = reinterpret_cast<const char*>(&g_code[from]);
p = static_cast<const uint8_t*>(zero) - g_code.data() + 1;
return true;
};
while (p < end) {
Arg arg;
arg.tag = g_code[p++];
switch (kArgs.payload[arg.tag]) {
case Payload::kString:
if (!text(p, arg.text)) {
return false;
}
break;
case Payload::kU16:
if (p + 2 > end) {
return false;
}
arg.number = U16(p);
p += 2;
break;
case Payload::kPosition:
if (p + 4 > end) {
return false;
}
arg.number = I32(p);
p += 4;
if (arg.number == kPositionNamed) {
if (!text(p, arg.text)) {
return false;
}
} else {
p += 8;
}
break;
case Payload::kI32:
if (p + 4 > end) {
return false;
}
arg.number = I32(p);
p += 4;
if (arg.tag == game::kScriptArgInt && arg.number == kNumberNamed && !text(p, arg.text)) {
return false;
}
break;
case Payload::kCondition:
if (p + 4 > end) {
return false;
}
arg.number = I32(p);
if (!text(p + 4, arg.text)) {
return false;
}
break;
case Payload::kBytes:
p += kArgs.bytes[arg.tag];
break;
case Payload::kFlag:
break;
default:
return false;
}
args.push_back(arg);
}
return p == end;
}
// Calls `fn(at, opcode, args)` for each decoded instruction of a label.
template <typename Fn>
void ForEachInstruction(const Label& label, Fn fn) {
std::vector<Arg> args;
const size_t end = label.code + label.size;
for (size_t at = label.code; at + kInstructionHeader <= end && end <= g_code.size();) {
const uint16_t opcode = U16(at);
const uint16_t size = U16(at + 2);
if (size < kInstructionHeader || at + size > end) {
return;
}
if (opcode != game::kScriptIf && opcode != game::kScriptElseIf && Decode(at, size, args)) {
fn(at, opcode, args);
}
at += size;
}
}
const Arg* FindArg(const std::vector<Arg>& args, uint8_t tag) {
for (const Arg& arg : args) {
if (arg.tag == tag) {
return &arg;
}
}
return nullptr;
}
// The first "Res:..." string argument (spawns may name the type first).
const char* ResArg(const std::vector<Arg>& args) {
for (const Arg& arg : args) {
if (arg.tag == game::kScriptArgString && IsRes(arg.text)) {
return arg.text;
}
}
return nullptr;
}
bool SetsHostile(const std::vector<Arg>& args) {
for (size_t i = 0; i + 1 < args.size(); ++i) {
if (args[i].tag == game::kScriptArgAttitude && args[i + 1].tag == kArgHostile) {
return true;
}
}
return false;
}
std::vector<uint8_t> Code(size_t at, uint16_t size) {
return std::vector<uint8_t>(g_code.begin() + at, g_code.begin() + at + size);
}
// An instruction with string arguments {tag, text}.
std::vector<uint8_t> Instruction(uint16_t opcode, std::initializer_list<std::pair<uint8_t, std::string>> args) {
std::vector<uint8_t> code(kInstructionHeader);
for (const auto& [tag, text] : args) {
code.push_back(tag);
code.insert(code.end(), text.begin(), text.end());
code.push_back(0);
}
code[0] = static_cast<uint8_t>(opcode);
code[1] = static_cast<uint8_t>(opcode >> 8);
code[2] = static_cast<uint8_t>(code.size());
code[3] = static_cast<uint8_t>(code.size() >> 8);
return code;
}
void Scan() {
// Not before the game has the code open and its labels read.
if (g_scanned || !game::ScriptFile() || !game::ScriptLabelsBegin() ||
game::ScriptLabelsEnd() <= game::ScriptLabelsBegin()) {
return;
}
g_scanned = true;
if (!ReadCode()) {
re::Log("questscript: script code not readable");
return;
}
std::map<std::string, size_t> byName;
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
label += game::kScriptLabelSize) {
Label entry;
entry.name.assign(game::ScriptLabelName(label), strnlen(game::ScriptLabelName(label), game::kScriptLabelNameSize));
entry.quest = game::ScriptLabelQuest(label);
entry.code = game::ScriptLabelCode(label);
entry.size = game::ScriptLabelCodeSize(label);
byName.emplace(entry.name, g_labels.size());
if (entry.quest >= 0) {
g_quests[entry.quest];
}
g_labels.push_back(std::move(entry));
}
// The accept labels: code starting with "start quest #id" (0x48f320; e.g.
// "trigger1501"); the dialogs with an answer (text, label) running one of
// their quest.
std::map<std::string, int32_t> accepts;
for (const Label& label : g_labels) {
const size_t at = label.code;
if (label.size >= kInstructionHeader + 5 && at + kInstructionHeader + 5 <= g_code.size() &&
U16(at) == game::kScriptStartQuest && g_code[at + kInstructionHeader] == game::kScriptArgInt) {
accepts.emplace(label.name, I32(at + kInstructionHeader + 1));
}
}
std::map<std::string, int32_t> dialogs; // lower-case dialog name -> quest
static const char kDialog[] = "Dialog:";
for (const Label& label : g_labels) {
if (label.quest < 0 || label.name.compare(0, sizeof(kDialog) - 1, kDialog) != 0) {
continue;
}
ForEachInstruction(label, [&](size_t, uint16_t opcode, const std::vector<Arg>& args) {
if (opcode != game::kScriptAnswer || args.size() < 2 || !args[1].text) {
return;
}
const auto accept = accepts.find(args[1].text);
if (accept != accepts.end() && accept->second == label.quest) {
dialogs.emplace(Lower(label.name.c_str() + sizeof(kDialog) - 1), label.quest);
}
});
}
// Spawns, givers (and every NPC offering a quest), variable writers.
std::map<std::string, std::set<int32_t>> setters;
std::map<int32_t, Giver> stateGivers;
std::map<int32_t, std::set<std::string>> offering; // quest -> NPCs given its accept dialog
for (const Label& label : g_labels) {
ForEachInstruction(label, [&](size_t at, uint16_t opcode, const std::vector<Arg>& args) {
if (game::IsScriptVariableWrite(opcode) && !args.empty() && args[0].tag == game::kScriptArgString) {
setters[args[0].text].insert(label.quest);
}
if (opcode != game::kScriptSpawn && opcode != game::kScriptSetNpcState) {
return;
}
const char* res = opcode == game::kScriptSpawn ? ResArg(args) : (args.empty() ? nullptr : args[0].text);
if (!IsRes(res)) {
return;
}
if (opcode == game::kScriptSpawn && ResNumber(res)) {
g_spawns[ResNumber(res)].push_back({label.quest, &label, Code(at, U16(at + 2))});
}
const Arg* dialog = FindArg(args, game::kScriptArgDialog);
const auto offered = dialog ? dialogs.find(Lower(dialog->text)) : dialogs.end();
if (offered == dialogs.end()) {
return;
}
offering[offered->second].insert(Lower(res));
Giver giver{Lower(res), ResNumber(res), dialog->text, {}};
Quest& quest = g_quests[offered->second];
if (opcode == game::kScriptSpawn && !quest.hasGiver) {
giver.spawn = Code(at, U16(at + 2));
quest.giver = std::move(giver);
quest.hasGiver = true;
} else if (opcode == game::kScriptSetNpcState) {
stateGivers.emplace(offered->second, std::move(giver));
}
});
}
for (auto& [id, giver] : stateGivers) {
Quest& quest = g_quests[id];
if (!quest.hasGiver) {
quest.giver = std::move(giver);
quest.hasGiver = true;
}
}
for (const auto& [name, quests] : setters) {
if (quests.size() == 1 && *quests.begin() >= 0) {
g_quests[*quests.begin()].variables.push_back(name);
}
}
// Per quest: what its labels create and touch, and its failures.
std::map<int32_t, std::set<uint32_t>> created;
std::map<int32_t, std::set<uint32_t>> touched;
for (uint32_t index = 0; index < g_labels.size(); ++index) {
const Label& label = g_labels[index];
if (label.quest < 0) {
continue;
}
Quest& quest = g_quests[label.quest];
const std::string id = std::to_string(label.quest);
const bool setUp = label.name == "QIS_OnSetUp" + id;
const bool onEnter = label.name == "QIS_OnEnter" + id;
const std::set<std::string>& offers = offering[label.quest];
bool loses = false;
bool ends = false;
ForEachInstruction(label, [&](size_t, uint16_t opcode, const std::vector<Arg>& args) {
const char* res = opcode == game::kScriptSpawn || opcode == game::kScriptCreateObject
? ResArg(args)
: (!args.empty() && args[0].tag == game::kScriptArgString ? args[0].text : nullptr);
const uint32_t npc = ResNumber(res);
const std::string lower = IsRes(res) ? Lower(res) : std::string();
const bool isGiver = quest.hasGiver && !lower.empty() && lower == quest.giver.res;
const bool offersIt = !lower.empty() && offers.count(lower);
switch (opcode) {
case game::kScriptSpawn:
case game::kScriptCreateObject:
if (npc && !setUp) {
created[label.quest].insert(npc);
}
if (npc && onEnter && !isGiver) {
quest.createdOnEnter.push_back(npc);
}
break;
case game::kScriptSetNpcState:
if (npc && !setUp) {
touched[label.quest].insert(npc);
}
ends = ends || (offersIt && SetsHostile(args));
break;
case game::kScriptSetHitPoints:
ends = ends || (offersIt && args.size() >= 2 && args[1].number == 0);
break;
case game::kScriptChangeType:
quest.turnsGiver = quest.turnsGiver || isGiver;
break;
case game::kScriptDelete:
quest.deletesGiver = quest.deletesGiver || isGiver;
break;
case game::kScriptLoseQuest:
loses = true;
break;
case game::kScriptSetUpQuest:
case game::kScriptActivateQuest:
case game::kScriptGiveExperience:
case game::kScriptGiveGold:
ends = true;
break;
case game::kScriptCallLabel: {
const auto target = !args.empty() && args[0].text ? byName.find(args[0].text) : byName.end();
ends = ends || target == byName.end() || g_labels[target->second].quest != label.quest;
break;
}
default:
break;
}
});
if (loses && ends) {
g_finalLabels.insert(index);
quest.hasFinalFailure = true;
}
}
unsigned givers = 0;
for (auto& [id, quest] : g_quests) {
const std::string named = std::to_string(id);
if (setters.count(named) && std::find(quest.variables.begin(), quest.variables.end(), named) ==
quest.variables.end()) {
quest.variables.push_back(named);
}
for (uint32_t npc : touched[id]) {
if (!created[id].count(npc) && !(quest.hasGiver && npc == quest.giver.scriptId)) {
quest.needed.push_back(npc);
}
}
givers += quest.hasGiver;
}
re::Log("questscript: %u quests, %u with a giver, %u failure labels ending a story, %u bytes of script code",
static_cast<unsigned>(g_quests.size()), givers, static_cast<unsigned>(g_finalLabels.size()),
static_cast<unsigned>(g_code.size()));
}
void Run(const std::vector<uint8_t>& code) { game::RunScriptInstruction(code.data()); }
void SpawnNow(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog, const std::string& why) {
const int result = game::RunScriptInstruction(spawn.data());
if (!dialog.empty()) {
SetDialog("res:" + std::to_string(scriptId), dialog);
}
re::Log("questscript: Res:%u spawned again (%s, %d): %s", scriptId, why.c_str(), result,
LivingNpc(scriptId) ? "back" : "NOT FOUND");
}
void OnTick(uint8_t*) {
for (auto it = g_pending.begin(); it != g_pending.end();) {
const bool gone = game::GetObjectById(it->bodyId) != it->body;
if (!gone && GetTickCount() - it->since < kBodyWaitMs) {
++it;
continue;
}
SpawnNow(it->scriptId, it->spawn, it->dialog, it->why);
it = g_pending.erase(it);
}
}
void OnLoaded() { g_pending.clear(); }
struct Registration {
Registration() {
game::OnEngineTick(&OnTick);
game::OnSaveGameLoaded(&OnLoaded);
}
} g_registration;
} // namespace
const Quest* Find(int32_t questId) {
Scan();
const auto quest = g_quests.find(questId);
return quest != g_quests.end() ? &quest->second : nullptr;
}
const std::vector<uint8_t>* SpawnOf(uint32_t scriptId, int32_t questId) {
Scan();
const auto spawns = g_spawns.find(scriptId);
if (spawns == g_spawns.end()) {
return nullptr;
}
const std::string setUp = "QIS_OnSetUp" + std::to_string(questId);
const std::vector<uint8_t>* other = nullptr;
const std::vector<uint8_t>* own = nullptr;
for (const Spawn& spawn : spawns->second) {
if (spawn.quest < 0) {
return &spawn.code;
}
if (spawn.quest == questId && spawn.label->name == setUp) {
own = own ? own : &spawn.code;
} else if (spawn.quest != questId) {
other = other ? other : &spawn.code;
}
}
return own ? own : other;
}
void* LivingNpc(uint32_t scriptId) {
const uint32_t id = scriptId ? game::ScriptObjectId(scriptId) : 0;
void* creature = id && game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
return creature && game::ObjectScriptId(creature) == scriptId && game::IsAlive(creature) ? creature : nullptr;
}
bool IsGone(const Giver& giver) { return giver.scriptId && !LivingNpc(giver.scriptId); }
void SpawnWhenGone(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog,
const char* why) {
for (const Pending& pending : g_pending) {
if (pending.scriptId == scriptId) {
return;
}
}
const uint32_t bodyId = game::ScriptObjectId(scriptId);
void* body = bodyId ? game::GetObjectById(bodyId) : nullptr;
if (body && game::ObjectScriptId(body) == scriptId) {
g_pending.push_back({scriptId, spawn, dialog, body, bodyId, GetTickCount(), why});
} else {
SpawnNow(scriptId, spawn, dialog, why);
}
}
void Delete(uint32_t scriptId) {
const uint32_t id = game::ScriptObjectId(scriptId);
void* object = id ? game::GetObjectById(id) : nullptr;
if (!object || game::ObjectScriptId(object) != scriptId) {
return;
}
re::Log("questscript: Res:%u deleted", scriptId);
Run(Instruction(game::kScriptDelete, {{game::kScriptArgString, "res:" + std::to_string(scriptId)}}));
}
void SetDialog(const std::string& res, const std::string& dialog) {
Run(Instruction(game::kScriptSetNpcState,
{{game::kScriptArgString, res}, {game::kScriptArgDialog, dialog}}));
}
LabelScope::LabelScope(uint32_t label) { g_running.push_back(label); }
LabelScope::~LabelScope() { g_running.pop_back(); }
bool InFinalFailure() {
Scan();
for (uint32_t label : g_running) {
if (g_finalLabels.count(label)) {
return true;
}
}
return false;
}
} // namespace mods::questscript
+66
View File
@@ -0,0 +1,66 @@
#pragma once
// What the quest scripts say about each quest (questscript.cpp): its giver, its
// variables, the NPCs it needs, the failures that end its story. Shared by the
// failure restart (questfail.cpp) and the giver respawn (questgiver.cpp).
#include <cstdint>
#include <string>
#include <vector>
namespace mods::questscript {
struct Giver {
std::string res; // "res:17013" as the scripts name it, lower case
uint32_t scriptId = 0; // its number, 0 for named ids ("res:AUFTRAGGEBER_10254")
std::string dialog; // the dialog with the accept answer
std::vector<uint8_t> spawn; // the spawn instruction carrying that dialog, or empty
};
struct Quest {
bool hasGiver = false;
Giver giver;
std::vector<std::string> variables; // only this quest's labels set them, and the one named after it
std::vector<uint32_t> createdOnEnter; // script ids QIS_OnEnter<id> creates (not the giver)
std::vector<uint32_t> needed; // NPCs its labels set states on but don't create (not the giver)
bool turnsGiver = false; // a label changes the giver's creature type
bool deletesGiver = false; // a label deletes the giver
bool hasFinalFailure = false; // one of its failures ends the story
};
// The quest's script facts, null if it has no labels (generic quest copies).
const Quest* Find(int32_t questId);
// A spawn instruction for `scriptId` outside the quest's own course: in a label
// of no quest (region start), of another quest, or its QIS_OnSetUp; null if none.
const std::vector<uint8_t>* SpawnOf(uint32_t scriptId, int32_t questId);
// The living creature the scripts call Res:<scriptId>, or null.
void* LivingNpc(uint32_t scriptId);
// The NPC Res:<scriptId> is dead or missing (named ids are never known gone).
bool IsGone(const Giver& giver);
// Runs `spawn` now, or once the dead body of Res:<scriptId> is gone; then gives
// the NPC `dialog` (if not empty).
void SpawnWhenGone(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog,
const char* why);
// Deletes the NPC or object Res:<scriptId> if it exists.
void Delete(uint32_t scriptId);
// Gives NPC `res` the dialog `dialog` (script NPC state instruction).
void SetDialog(const std::string& res, const std::string& dialog);
// A label of the scripts runs (Script_RunLabel): kept on a stack while it runs.
class LabelScope {
public:
explicit LabelScope(uint32_t label);
~LabelScope();
LabelScope(const LabelScope&) = delete;
LabelScope& operator=(const LabelScope&) = delete;
};
// A running label (or one calling it) is a failure that ends its quest's story.
bool InFinalFailure();
} // namespace mods::questscript
+1 -1
View File
@@ -259,7 +259,7 @@ uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument);
RE_HOOK(0x007587b0, UiManager_Render); RE_HOOK(0x007587b0, UiManager_Render);
uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument) { uint32_t __fastcall UiManager_Render(void* self, void* edx, uint32_t argument) {
using namespace mods; using namespace mods;
if (!widescreen::IsMenuThread() || widescreen::IsClassicFrame() || widescreen::RenderWidth() == 0) { if (!widescreen::IsMenuThread() || widescreen::IsLoadingScreen() || widescreen::RenderWidth() == 0) {
return UiManager_Render_original(self, edx, argument); return UiManager_Render_original(self, edx, argument);
} }
auto* driver = *reinterpret_cast<DxDriver7**>(addr::g_dxDriver); auto* driver = *reinterpret_cast<DxDriver7**>(addr::g_dxDriver);
+16 -16
View File
@@ -24,7 +24,9 @@
#include "mods/widescreen.h" #include "mods/widescreen.h"
#include "mods/hud.h" #include "mods/hud.h"
#include "mods/loadingscreen.h"
#include "mods/megamap.h" #include "mods/megamap.h"
#include "mods/monitor.h"
#include <algorithm> #include <algorithm>
#include <atomic> #include <atomic>
@@ -63,9 +65,9 @@ bool g_pushesPatched = false;
int g_renderWidth = 0; int g_renderWidth = 0;
int g_renderHeight = 0; int g_renderHeight = 0;
// The loading screen is laid out for 1024x768 ("classic"); the main menu // Every frame uses the full render width: the main menu thread (its windows
// thread renders the full width too (its windows drawn centred by // drawn centred by mods/screen.cpp), the loading screen (centred on the menu
// mods/screen.cpp), the in-game view is widened. // background, mods/loadingscreen.cpp), the widened in-game view.
std::atomic<DWORD> g_menuThread = 0; std::atomic<DWORD> g_menuThread = 0;
// Whether the most recent scene (on any thread) was the main menu's or the // Whether the most recent scene (on any thread) was the main menu's or the
// loading screen's. UI input is processed outside the menu thread. // loading screen's. UI input is processed outside the menu thread.
@@ -269,17 +271,16 @@ void PatchLetterbox(int width) {
} // namespace } // namespace
void ApplyRenderSize(game::DxDisplayMode& mode, HWND window) { void ApplyRenderSize(game::DxDisplayMode& mode) {
if (!Enabled()) { if (!Enabled()) {
return; return;
} }
MONITORINFO monitor = {sizeof(monitor)}; const RECT monitor = mods::monitor::GameMonitorRect();
GetMonitorInfoW(MonitorFromWindow(window, MONITOR_DEFAULTTOPRIMARY), &monitor);
// Default: keep the original 768 pixels of world height (so everything // Default: keep the original 768 pixels of world height (so everything
// keeps its on-screen size once scaled to the monitor) and extend the width // keeps its on-screen size once scaled to the monitor) and extend the width
// to the monitor's aspect ratio. Height=0 renders at the monitor height 1:1. // to the monitor's aspect ratio. Height=0 renders at the monitor height 1:1.
const int monitorWidth = monitor.rcMonitor.right - monitor.rcMonitor.left; const int monitorWidth = monitor.right - monitor.left;
const int monitorHeight = monitor.rcMonitor.bottom - monitor.rcMonitor.top; const int monitorHeight = monitor.bottom - monitor.top;
int height = re::ConfigInt("widescreen", "Height", static_cast<int>(kOriginalHeight)); int height = re::ConfigInt("widescreen", "Height", static_cast<int>(kOriginalHeight));
if (height <= 0) { if (height <= 0) {
height = monitorHeight; height = monitorHeight;
@@ -343,7 +344,7 @@ bool MenuIsShowing() {
return g_lastSceneMenu; return g_lastSceneMenu;
} }
bool IsClassicFrame() { bool IsLoadingScreen() {
return g_renderWidth != 0 && g_loadingScreenDepth > 0; return g_renderWidth != 0 && g_loadingScreenDepth > 0;
} }
@@ -369,22 +370,21 @@ void __fastcall UiManager_ThreadLoop(void* self, void* edx) {
void __fastcall DxDriver7_LoadingScreen(DxDriver7* self, void* edx, uint32_t progress, const char* text) { void __fastcall DxDriver7_LoadingScreen(DxDriver7* self, void* edx, uint32_t progress, const char* text) {
++g_loadingScreenDepth; ++g_loadingScreenDepth;
mods::loadingscreen::Begin(self);
DxDriver7_LoadingScreen_original(self, edx, progress, text); DxDriver7_LoadingScreen_original(self, edx, progress, text);
mods::loadingscreen::End();
--g_loadingScreenDepth; --g_loadingScreenDepth;
} }
// The loading screen draws for a 1024x768 screen: give it a 1024x768 // Every frame gets the full render size as its viewport.
// viewport (the borderless mod then presents just that area, scaled), and
// everything else the full render size.
int __fastcall DxDriver7_BeginScene(DxDriver7* self, void* edx) { int __fastcall DxDriver7_BeginScene(DxDriver7* self, void* edx) {
mods::hud::OnDevice(self->device); mods::hud::OnDevice(self->device);
if (self->sceneDepth == 0) { if (self->sceneDepth == 0) {
g_lastSceneMenu = IsClassicFrame() || IsMenuThread(); g_lastSceneMenu = IsLoadingScreen() || IsMenuThread();
} }
if (g_renderWidth != 0 && self->sceneDepth == 0 && self->device) { if (g_renderWidth != 0 && self->sceneDepth == 0 && self->device) {
const bool classic = IsClassicFrame(); Viewport7 viewport = {0, 0, static_cast<DWORD>(g_renderWidth), static_cast<DWORD>(g_renderHeight), 0.0f,
Viewport7 viewport = {0, 0, classic ? 1024u : static_cast<DWORD>(g_renderWidth), 1.0f};
classic ? 768u : static_cast<DWORD>(g_renderHeight), 0.0f, 1.0f};
using SetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport); using SetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport);
auto setViewport = reinterpret_cast<SetViewportFn>((*static_cast<void***>(self->device))[13]); auto setViewport = reinterpret_cast<SetViewportFn>((*static_cast<void***>(self->device))[13]);
setViewport(self->device, &viewport); setViewport(self->device, &viewport);
+3 -4
View File
@@ -9,11 +9,10 @@ namespace mods::widescreen {
// Called by the borderless mod before the renderer creates its surfaces: // Called by the borderless mod before the renderer creates its surfaces:
// sets the render size to the monitor (or the configured size) and resizes // sets the render size to the monitor (or the configured size) and resizes
// the world view to match. No-op when disabled. // the world view to match. No-op when disabled.
void ApplyRenderSize(game::DxDisplayMode& mode, HWND window); void ApplyRenderSize(game::DxDisplayMode& mode);
// True while the current thread renders a frame laid out for 1024x768 (the // True while the current thread draws the load / save progress screen.
// loading screen); the borderless mod then presents only that area. bool IsLoadingScreen();
bool IsClassicFrame();
// Applied render width (0 while the original 1024x768 is in use). // Applied render width (0 while the original 1024x768 is in use).
int RenderWidth(); int RenderWidth();
+4 -2
View File
@@ -10,6 +10,7 @@ passed explicitly.
| `find-imm.mjs <exe> /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) | | `find-imm.mjs <exe> /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) |
| `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI | | `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI |
| `decompile.ps1 <addr...>` | decompile from the persistent Ghidra project `ghidra/` | | `decompile.ps1 <addr...>` | decompile from the persistent Ghidra project `ghidra/` |
| `questscript.mjs <dir> <quest id...>` | decode a quest script folder (`bin\TYPE_NPC_<class>`, `bin\Addon\NetScriptCamp`): a quest's labels and where other labels use its NPCs/variables; `--list`, `--facts [ids]` (giver, death labels), `--label <name>`, `--grep <regex>` (matching instructions in all labels) |
| `rename.ps1` | apply the function names in `names.txt` to the Ghidra project (add names there as functions are identified) | | `rename.ps1` | apply the function names in `names.txt` to the Ghidra project (add names there as functions are identified) |
## Persistent Ghidra project (`ghidra/`, not in git) ## Persistent Ghidra project (`ghidra/`, not in git)
@@ -28,7 +29,8 @@ $env:GHIDRA_HEADLESS_MAXMEM = "8G"
## Automated in-game test ## Automated in-game test
`autotest-load.ps1` starts the game through the launcher, clicks `autotest-load.ps1` starts the game through the launcher, clicks
"Spiel laden" -> "Laden" -> "Ok" (first save game), takes screenshots in-game "Spielen" -> a character (`-Save`: row in the character list) -> "Spielen"
(its last world), takes screenshots in-game
and with the ESC menu open, and stops the game. `-Ini` appends temporary and with the ESC menu open, and stops the game. `-Ini` appends temporary
settings to `build\x86-debug\sacredK.ini` (restored afterwards), e.g. settings to `build\x86-debug\sacredK.ini` (restored afterwards), e.g.
@@ -36,6 +38,6 @@ settings to `build\x86-debug\sacredK.ini` (restored afterwards), e.g.
.\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0" .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
``` ```
Click positions assume the classic menu layout scaled onto a 3440x1440 Click positions are for the menu screens (2026-10-25) on a 3440x1440
monitor. `game-input.ps1` has the building blocks (start, click, key, hold key, monitor. `game-input.ps1` has the building blocks (start, click, key, hold key,
screenshot). Don't touch mouse/keyboard while a test runs. screenshot). Don't touch mouse/keyboard while a test runs.
+8 -10
View File
@@ -1,18 +1,18 @@
# Automated smoke test: start the game, load a save game (-Save: its row in # Automated smoke test: start the game, load a character's last world (-Save:
# the list, 1 = the first, selected by default), take # its row in the Spielen screen's character list, 1 = the first), take
# screenshots in-game and with the ESC menu open, then stop the game. # screenshots in-game and with the ESC menu open, then stop the game.
# #
# .\tools\autotest-load.ps1 -Name ws_on # current sacredK.ini # .\tools\autotest-load.ps1 -Name ws_on # current sacredK.ini
# .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0" # .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0"
# #
# -Ini temporarily appends settings to build\x86-debug\sacredK.ini. # -Ini temporarily appends settings to build\x86-debug\sacredK.ini.
# Click positions are for the classic (menu) layout scaled onto a 3440x1440 # Click positions are for the menu screens (2026-10-25) on a 3440x1440
# monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs. # monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs.
param( param(
[string]$Name = "autotest", [string]$Name = "autotest",
[string]$Ini = "", [string]$Ini = "",
[int]$LoadTimeoutSeconds = 60, [int]$LoadTimeoutSeconds = 60,
# Row of the save game in the load screen's list (1-3 visible rows). # Row of the character in the Spielen screen's list (its last world loads).
[int]$Save = 1, [int]$Save = 1,
# Runs in-game after loading, before the screenshots (e.g. clicks). # Runs in-game after loading, before the screenshots (e.g. clicks).
[scriptblock]$InGame = $null [scriptblock]$InGame = $null
@@ -28,12 +28,10 @@ if ($Ini) { Set-Content $iniPath ($iniBackup + "`r`n" + $Ini + "`r`n") -Encoding
$game = $null $game = $null
try { try {
$game = Start-Sacred $game = Start-Sacred
Click-At 1720 779 -AfterMs 1500 # main menu: "Spiel laden" Click-At 1717 538 -AfterMs 2000 # main menu: "Spielen"
if ($Save -gt 1) { Click-At 817 (509 + ($Save - 1) * 105) -AfterMs 1000 # character list row (105 px apart)
Click-At 1342 (609 + ($Save - 1) * 254) -AfterMs 800 # load screen: the save's name (rows 254 px apart) Click-At 2714 1347 -AfterMs 1500 # "Spielen": the character's last world
} Click-At 1548 824 -AfterMs 0 # "Spielstand laden?": "Ok"
Click-At 2382 568 -AfterMs 2000 # load screen: "Laden"
Click-At 1551 826 -AfterMs 0 # confirm: "Ok"
Wait-Log "cEngine End and exit constructor" -TimeoutSeconds $LoadTimeoutSeconds -AfterMs 1500 -Game $game Wait-Log "cEngine End and exit constructor" -TimeoutSeconds $LoadTimeoutSeconds -AfterMs 1500 -Game $game
if ($InGame) { & $InGame } if ($InGame) { & $InGame }
Save-Screen "screenshots\${Name}_ingame.png" | Out-Null Save-Screen "screenshots\${Name}_ingame.png" | Out-Null
+8 -1
View File
@@ -5,6 +5,7 @@
# Click-At 1720 779 # screen pixels (left click) # Click-At 1720 779 # screen pixels (left click)
# Press-Key 0x1B # virtual key (ESC) # Press-Key 0x1B # virtual key (ESC)
# Hold-Key 0x27 -Ms 1500 # hold a key (right arrow) # Hold-Key 0x27 -Ms 1500 # hold a key (right arrow)
# Scroll-Wheel -3 # mouse wheel, 3 notches down
# Save-Screen screenshots\x.png # primary monitor area of the game # Save-Screen screenshots\x.png # primary monitor area of the game
# Stop-Sacred $game # only the given process # Stop-Sacred $game # only the given process
# #
@@ -25,7 +26,7 @@ $script:Root = Split-Path $PSScriptRoot
# synthetic Alt press lifts that restriction for SetForegroundWindow. # synthetic Alt press lifts that restriction for SetForegroundWindow.
function Focus-Sacred($Game) { function Focus-Sacred($Game) {
$Game.Refresh() $Game.Refresh()
if ($Game.MainWindowHandle -eq [IntPtr]::Zero) { return $false } if (-not $Game.MainWindowHandle -or $Game.MainWindowHandle -eq [IntPtr]::Zero) { return $false }
[SacredRe.Input]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) [SacredRe.Input]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
[SacredRe.Input]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) [SacredRe.Input]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
[SacredRe.Input]::SetForegroundWindow($Game.MainWindowHandle) [SacredRe.Input]::SetForegroundWindow($Game.MainWindowHandle)
@@ -116,3 +117,9 @@ function Hold-Key([byte]$VirtualKey, [int]$Ms = 1500, [int]$AfterMs = 500) {
[SacredRe.Input]::keybd_event($VirtualKey, 0, 2, [UIntPtr]::Zero) [SacredRe.Input]::keybd_event($VirtualKey, 0, 2, [UIntPtr]::Zero)
Start-Sleep -Milliseconds $AfterMs Start-Sleep -Milliseconds $AfterMs
} }
# Turns the mouse wheel by $Notches (negative: towards the user, "down").
function Scroll-Wheel([int]$Notches, [int]$AfterMs = 500) {
[SacredRe.Input]::mouse_event(0x0800, 0, 0, [BitConverter]::ToUInt32([BitConverter]::GetBytes([int]($Notches * 120)), 0), [UIntPtr]::Zero)
Start-Sleep -Milliseconds $AfterMs
}
+84
View File
@@ -186,6 +186,7 @@
0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player 0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player
0x00ad1b48 g_nearbyCreatureCount 0x00ad1b48 g_nearbyCreatureCount
0x0049a090 cDialogManager::Answer # (owner, answer): cEvent_creature 0x10c to all heroes (ends the dialog lock); answers < 100000 -> quest script 0x46bd80 0x0049a090 cDialogManager::Answer # (owner, answer): cEvent_creature 0x10c to all heroes (ends the dialog lock); answers < 100000 -> quest script 0x46bd80
0x0046bd80 Script_RunLabel # cdecl (label index, ?, ?): script labels 0xaa9688 (0x54 bytes, quest +0x48); runs only once the label's quest is triggered
0x006e7120 cUI_Tooltip::HandleEvent # dialog answers: click on an entry (+0x2188 list) -> hide, cDialogManager::Answer 0x006e7120 cUI_Tooltip::HandleEvent # dialog answers: click on an entry (+0x2188 list) -> hide, cDialogManager::Answer
0x0075a870 cUI_Manager::TooltipCreate # a panel from the pool at +0x124 0x0075a870 cUI_Manager::TooltipCreate # a panel from the pool at +0x124
0x00756ba0 cUI_Manager::HandleEsc 0x00756ba0 cUI_Manager::HandleEsc
@@ -371,3 +372,86 @@
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
0x0046de10 Quest_Trigger # cdecl (index, ?, ?): makes a quest available (flag 1, busy 8), runs its init label +0x114 once and its trigger label +0x10c
0x0046ced0 Quest_End # quest manager thiscall (index, ?, success), ret 0xc: book entry 100 / 101
0x00475970 Script_RunInstruction # quest manager thiscall (code, &offset, quest, object, flag, 0), ret 0x18: one instruction; offset -1 reads from the FILE*
0x00498aa0 Script_Condition # thiscall: script conditions; case 0x40 "@npc": npc on the hero's follower list +0x39c (or +0x570)
0x0048f320 Script_StartQuest # opcode 0x14 #quest: needs quest flag 1; runs the trigger label, adds the book entry
0x0048f110 Script_EndQuest # opcode 0x0f: Quest_End of the current quest
0x00482800 Script_Spawn # opcode 1: spawns a creature ("Res:<script id>", type, position, 0x09 dialog, 0x05 death label, ...)
0x0048be30 Script_NpcMode # opcode 3: an NPC's mode (0x09 dialog, 0x0a follow + death label, ...)
0x00464ec0 QuestManager_ScriptObjectId # thiscall (script id, &object id), ret 8: map +0x7668, then all objects
0x0044a390 cObject::ScriptId # thiscall, plain ret: +0x3c or the type's default
0x0044a380 cObject::SetScriptId # thiscall (script id)
0x004b37b0 ScriptIdMap::operator[] # thiscall (&key), ret 4: quest manager +0x7668
0x0054b070 cCreature::AddFollower # thiscall ({u32 id, u32, u8}*), ret 4: hero +0x39c list, unless the id is in it
0x005145d0 cCreature::CombatMove # combat arts' moves; case 0x43f Kanonenschuss, 0x43e Mörsergranate
0x005ab400 cObjectFX::GatherTargets # effect objects collect their targets (0x5ff820 at 0x5ab63d, effect in ebx)
0x005ff820 cObjectManager::ObjectsInRange # thiscall (out, position, type, only, radius = -1: type sizes), ret 0x14
0x00428bb0 TypeTable::ObjectSize # thiscall (type), ret 4: +0x14, default 50
0x004fc810 Creature_StunChance # (level, creature): level * 5 / creature level * 100
0x00633230 World_RebuildVisitedBorder # thiscall: clears the 100x100 border bytes (+0xb73c) and runs 0x6332d0 for every sector with flag 0x20000 (visited); on load (0x471086, 0x60c621)
0x006332d0 World_UpdateVisitedBorderAround # thiscall (sector cx, cy), ret 8: border byte of each unvisited 3x3 neighbour (0x6334f0)
0x006334f0 World_ComputeVisitedBorderCell # thiscall (cx, cy), ret 8: 8 bits, one per neighbour sector with flag 0x20000
0x006336b0 World_VisitedBorderCell # thiscall (cx, cy), ret 8: border byte, 0 outside 100x100
0x006c8580 cUI_Megamap::DrawVisitedBorder # thiscall (device), ret 4: 2x2 px red (0xffff0000) dot per border bit, +-10/+-20 px from the sector origin
0x006c87c0 cUI_Megamap::DrawQuestMarkers # thiscall (device), ret 4: quest book entries (0xaaaf00)
0x00622700 WorldToPlane # cdecl (out vec3, in vec3), plain ret: world units -> world pixels (iso matrix 0xad5710)
0x0062d530 cWorldView0::DrawGroundQuads # thiscall (renderer), ret 4: the ground, before the objects
0x0041e0a0 cEventFX::Construct # thiscall (code, owner, target, position 4 dwords, ...), ret 0x44
0x007b66b0 cParticleSystem_grenadeimpact::Render # vf04 (device, matrix by value), ret 0x44; vtable 0x896cdc
0x007b64c0 cParticleSystem_grenadeimpact::Update # vf08 (dt), ret 4; phase +0x20a9
0x007613e0 cParticleSystem::DrawParticles # colour table row (+0x3d) * 256 + life (+0x2c)
0x00763ea0 cParticleSystem::UpdateParticles # thiscall (dt, emitter rows, mode), particles 0x40 bytes at +0x68
0x005d8240 cInventoryEntry::AddMainQuestItem # thiscall (type, script id, notify): {type, script id} pairs; refused if same type (script id 0 test patched out)
0x0049bb30 QuestManager_SetVarBit # script op 0x44: variable |= 1 << n
0x0049c450 QuestManager_ClearVarBit # script op 0x45: variable &= ~(1 << n)
0x0049cc20 QuestManager_AddToVar # script op 0x4b: variable += n
0x005222c0 cCreature::CastSpell # thiscall (cast), ret 4: switch on the spell (cast +0x20); +0x08/+0x0c ground point; case 1 Phasenverschiebung, 0x39 Eingraben
0x00636b60 cWorld::IsPassable # thiscall (position), ret 4: world = *ObjectManager
0x00636eb0 cWorld::CanStand # thiscall (creature id, position), ret 8: may correct the position's area
0x004d24f0 cArtDef::Duration # thiscall (index, level): +0x76 + level * +0x78 (i16), at most +0x82 when set
0x007b8fc0 cParticleSystem_dwarf_shield::cParticleSystem_dwarf_shield # Eingraben's barrier (type 0x480)
0x007b91a0 cParticleSystem_dwarf_shield::Update # vf08 (dt), ret 4; pulse +0x20b8, phase +0x20ac, end +0x20a8
0x007b92b0 cParticleSystem_dwarf_shield::Draw # vf04, ret 0x44; model alpha pulse * 128 (__ftol at 0x7b9483)
+341
View File
@@ -0,0 +1,341 @@
// Decode the compiled quest scripts (docs/quests.md, "Quest scripts").
// node tools/questscript.mjs <script dir> <quest id...> one quest: its labels and every
// reference to it from other labels
// node tools/questscript.mjs <script dir> --list all quests of the quest table
// node tools/questscript.mjs <script dir> --label <name> one label
// <script dir>: the folder with FunkCode.bin + Vectoren.bin, e.g.
// "<Sacred Gold>\bin\TYPE_NPC_GLADIATOR" (Ancaria, one per hero class; the game
// writes the one in use to bin\sgf.bin) or "<Sacred Gold>\bin\Addon\NetScriptCamp"
// (Underworld).
//
// Formats: Vectoren.bin = u32 n, n labels of 0x54 bytes (name, +0x40 code offset,
// +0x44 size, +0x48 quest), then u32 count, count quest records of 0x124 bytes
// (id, name). FunkCode.bin = instructions {u16 opcode, u16 size with the header,
// tagged args}. Opcode names from the game's keyword tables (0x451db0, 0x452300,
// 0x4523d0, 0x452490, 0x452540); argument sizes from the reader 0x472eb0.
// Conditions (IF 0x3a / ELSEIF 0x42) have their own tags, decoded by shape.
import { readFileSync } from "node:fs";
import { join } from "node:path";
const OPS = {
0x1: "CreateNPC", 0x2: "SetObjState", 0x3: "SetNPCState", 0x4: "SetBaseTrigger", 0x5: "DelBaseTrigger",
0x6: "TalkTo", 0x7: "EquipNPC", 0x8: "CreateObj", 0x9: "StartScript", 0xa: "QuestDone", 0xb: "QuestNotDone",
0xc: "QuestInProgress", 0xd: "QuestNotInProgress", 0xe: "DeleteFunktionBlock", 0xf: "ExitQuest", 0x10: "MoveOver",
0x11: "AddExp", 0x12: "AddGold", 0x13: "AddGems", 0x14: "TriggerQuest", 0x15: "SetUpQuest", 0x16: "CallFunktion",
0x17: "DefPos", 0x18: "GroupIsDead", 0x19: "CheckForQuestPool", 0x1a: "Text", 0x1b: "MaxOffen:", 0x1c: "Delay:",
0x1d: "Rettungsmission:", 0x1e: "ToDoBloecke:", 0x1f: "Dialog:", 0x20: "Belohnungen:", 0x21: "HideTmpToDo:",
0x22: "Schatzsuche:", 0x23: "FillRegion", 0x24: "AddHandel", 0x25: "SetRG", 0x26: "UnsetRG", 0x2b: "ExitDlg",
0x2c: "DelDlg", 0x2d: "StartPosition", 0x2e: "Teleport", 0x30: "CreateTrigger", 0x31: "SetTriggerState",
0x32: "TriggerPatch", 0x33: "FillSector", 0x34: "SetHP", 0x35: "QuestBook", 0x36: "LoseQuest", 0x37: "DelNPC",
0x38: "SetTimer", 0x39: "DelTimer", 0x3a: "IF", 0x3b: "ELSE", 0x3c: "SetButton", 0x3d: "ChDlgText", 0x3e: "ENDIF",
0x3f: "QuestKompassPos", 0x40: "QuestKompassObj", 0x41: "DefNum", 0x42: "ELSEIF", 0x43: "SetVar", 0x44: "SetVarBit",
0x45: "UnsetVarBit", 0x46: "SetFocus", 0x47: "NPC_TalkTo", 0x48: "NPC_Goto", 0x49: "SetGroupState", 0x4a: "GroupGoto",
0x4b: "IncVar", 0x4c: "DecVar", 0x4d: "UnTriggerQuest", 0x4e: "FillChest", 0x4f: "Morph", 0x50: "SetST",
0x51: "SetGS", 0x52: "SetWI", 0x53: "SetRP", 0x54: "SetRM", 0x55: "SetCH", 0x56: "SetIcon", 0x57: "SetQuestInfo",
0x58: "SetAnimMode", 0x59: "SetPlayMode", 0x5a: "Wait", 0x5b: "PlayAnim", 0x5c: "Attack", 0x5d: "ActObj",
0x5e: "MouseEvent", 0x5f: "Popup", 0x60: "ReplaceSpawn", 0x61: "SetMapIcon", 0x62: "SecInfo", 0x63: "Partikel",
0x64: "SpawnValues", 0x65: "Toete_NPC", 0x66: "Begleite", 0x67: "SetCV", 0x68: "PlaySound", 0x69: "RndVar",
0x6a: "SetRgnDialog", 0x6b: "BalanceSpawnItem", 0x6c: "AddPoolPos", 0x6d: "GetPoolPos", 0x6e: "RenPos",
0x6f: "ToBegin", 0x70: "AddPoolRgn", 0x71: "GetPoolRgn", 0x72: "Waffenpool", 0x73: "MakeGroup",
0x74: "RegionChange", 0x75: "ActivateQuest", 0x76: "SetClientVars", 0x77: "GetClientVars", 0x78: "GameEnd",
0x79: "AutoSave", 0x7a: "SolveTime:", 0x7b: "Strafen:", 0x7c: "RegionFrei", 0x7d: "Gewinn", 0x7e: "RejectQuest",
0x7f: "PlayMusic", 0x80: "PlayJingle", 0x81: "SetScriptSoundControle", 0x82: "Teleporter", 0x83: "GameActive",
0x84: "InfoPlayer", 0x85: "GetPoolPosition", 0x86: "SetCinemaMode", 0x87: "SetOnKill", 0x88: "SetOnCollect",
0x89: "SetDrop", 0x8a: "GiveStat", 0x8b: "GiveSkill", 0x8c: "MachVersteck",
};
// Argument tags (reader 0x472eb0): payload kinds.
const STR = new Set([0x01, 0x05, 0x09, 0x16, 0x1e, 0x29, 0x3a, 0x41, 0x60, 0x63, 0x67, 0x68, 0x69, 0x6a, 0x71, 0x83, 0x9d]);
const U16 = new Set([0x03, 0x0a, 0x6b, 0x6c, 0x9b, 0x9c]);
const I32 = new Set([0x02, 0x0b, 0x11, 0x19, 0x1c, 0x1d, 0x1f, 0x36, 0x38, 0x53, 0x54, 0x55, 0x56, 0x57, 0x5f, 0x73, 0x75, 0x84, 0x86]);
const BYTES = { 0x15: 8, 0x20: 1, 0x28: 1, 0x2a: 1, 0x33: 8, 0x34: 8, 0x35: 12, 0x37: 1, 0x4b: 1, 0x4d: 1, 0x79: 8, 0x87: 8, 0x88: 8, 0x89: 8, 0x8b: 1 };
const FLAG = new Set([0x06, 0x07, 0x08, 0x0e, 0x0f, 0x10, 0x12, 0x13, 0x14, 0x1a, 0x1b, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2b,
0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x39, 0x3f, 0x42, 0x43, 0x44, 0x45, 0x46, 0x4c, 0x4e, 0x4f, 0x50, 0x51, 0x58, 0x59,
0x5a, 0x5b, 0x5c, 0x61, 0x62, 0x64, 0x65, 0x66, 0x70, 0x72, 0x74, 0x78, 0x7b, 0x7c, 0x80, 0x85, 0x8a, 0x8d, 0x8e, 0x91, 0x96,
0x97, 0x98, 0x99, 0x9a, 0x9e, 0xa0, 0xa1]);
// Condition tags with {int32, variable}: 0x49 bit set, 0x4a bit clear (Script_Condition
// 0x498aa0), 0x47 / 0x48 / 0x6e compare values.
const COND_VAR = new Set([0x47, 0x48, 0x49, 0x4a, 0x6e]);
// Opcodes whose first argument is an NPC ("Res:<id>").
const NPC_OPS = new Set([0x01, 0x03, 0x06, 0x07, 0x1d, 0x2e, 0x34, 0x37, 0x40, 0x46, 0x47, 0x48, 0x4f, 0x5b, 0x5c, 0x65, 0x66]);
const TAG_NAMES = { 0x05: "death", 0x09: "dialog", 0x02: "type" };
const hex = (n) => n.toString(16).padStart(2, "0");
function cString(b, p) {
const e = b.indexOf(0, p);
return [b.toString("latin1", p, e), e + 1];
}
function printableAt(b, p, end) {
const e = b.indexOf(0, p);
if (e < 0 || e >= end) return false;
for (let i = p; i < e; i++) if (b[i] < 0x20) return false;
return true;
}
// Decoded arguments: [{tag, kind, value}], ok when the instruction was consumed exactly.
function decodeArgs(b, p, end, op) {
const args = [];
const cond = op === 0x3a || op === 0x42;
while (p < end) {
const tag = b[p++];
let s;
if (cond) {
if (COND_VAR.has(tag) && p + 4 < end) {
const n = b.readInt32LE(p);
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "cond", value: [n, s] });
} else if (p + 4 < end && !printableAt(b, p, end) && printableAt(b, p + 4, end) && Math.abs(b.readInt32LE(p)) < 1e6) {
const n = b.readInt32LE(p);
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "cond", value: [n, s] });
} else if (printableAt(b, p, end)) {
[s, p] = cString(b, p);
args.push({ tag, kind: "str", value: s });
} else if (p + 4 <= end) {
args.push({ tag, kind: "int", value: b.readInt32LE(p) });
p += 4;
} else {
args.push({ tag, kind: "flag" });
}
continue;
}
if (STR.has(tag)) {
[s, p] = cString(b, p);
args.push({ tag, kind: "str", value: s });
} else if (U16.has(tag)) {
args.push({ tag, kind: "int", value: b.readUInt16LE(p) });
p += 2;
} else if (tag === 0x04 || tag === 0x0c || tag === 0x0d) {
const v = b.readInt32LE(p);
p += 4;
if (v === -2) {
[s, p] = cString(b, p);
args.push({ tag, kind: "pos", value: s });
} else {
args.push({ tag, kind: "pos", value: `${v},${b.readInt32LE(p)}` });
p += 8;
}
} else if (tag === 0x0b && b.readInt32LE(p) === -0x12d687) {
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "num", value: s });
} else if (I32.has(tag)) {
args.push({ tag, kind: "int", value: b.readInt32LE(p) });
p += 4;
} else if (BYTES[tag] !== undefined) {
args.push({ tag, kind: "bytes", value: [...b.slice(p, p + BYTES[tag])].map(hex).join("") });
p += BYTES[tag];
} else if (tag === 0x47 || tag === 0x48 || tag === 0x49 || tag === 0x4a) {
const n = b.readInt32LE(p);
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "cond", value: [n, s] });
} else if (FLAG.has(tag)) {
args.push({ tag, kind: "flag" });
} else {
args.push({ tag, kind: "raw", value: [...b.slice(p, end)].map(hex).join("") });
return { args, ok: false };
}
}
return { args, ok: p === end };
}
function formatArg(a) {
const t = TAG_NAMES[a.tag] ?? hex(a.tag);
switch (a.kind) {
case "str": return `${t}"${a.value}"`;
case "int": return `${t}#${a.value}`;
case "num": return `${t}#"${a.value}"`;
case "pos": return `${t}@${a.value}`;
case "cond": return `${t}(${a.value[0]},"${a.value[1]}")`;
case "bytes": return `${t}[${a.value}]`;
case "flag": return t;
default: return `?${t}[${a.value}]`;
}
}
export function load(dir) {
const code = readFileSync(join(dir, "FunkCode.bin"));
const vec = readFileSync(join(dir, "Vectoren.bin"));
const n = vec.readUInt32LE(0);
const labels = [];
for (let i = 0; i < n; i++) {
const o = 4 + i * 0x54;
const label = {
name: vec.toString("latin1", o, vec.indexOf(0, o)),
quest: vec.readInt32LE(o + 0x48),
instructions: [],
};
let p = vec.readUInt32LE(o + 0x40);
const end = p + vec.readUInt32LE(o + 0x44);
while (p + 4 <= end && end <= code.length) {
const op = code.readUInt16LE(p);
const size = code.readUInt16LE(p + 2);
if (size < 4) break;
label.instructions.push({ op, ...decodeArgs(code, p + 4, p + size, op) });
p += size;
}
labels.push(label);
}
// startcode.bin: the world's start script (initial spawns), one instruction stream.
try {
const start = readFileSync(join(dir, "startcode.bin"));
const label = { name: "startcode", quest: -1, instructions: [] };
for (let p = 0; p + 4 <= start.length;) {
const op = start.readUInt16LE(p);
const size = start.readUInt16LE(p + 2);
if (size < 4) break;
label.instructions.push({ op, ...decodeArgs(start, p + 4, Math.min(p + size, start.length), op) });
p += size;
}
labels.push(label);
} catch {
// no start script in this folder
}
const quests = new Map();
let o = 4 + n * 0x54;
const count = vec.readUInt32LE(o);
o += 4;
for (let i = 0; i < count && o + 0x124 <= vec.length; i++, o += 0x124) {
quests.set(vec.readInt32LE(o), vec.toString("latin1", o + 4, vec.indexOf(0, o + 4)).trim());
}
return { labels, quests };
}
export function formatInstruction(ins) {
const name = OPS[ins.op] ?? `op${hex(ins.op)}`;
return `${name} ${ins.args.map(formatArg).join(" ")}${ins.ok ? "" : " (!)"}`.trimEnd();
}
const strings = (ins) => ins.args.flatMap((a) => (a.kind === "str" ? [a.value] : a.kind === "cond" ? [a.value[1]] : []));
const VAR_WRITES = new Set([0x43, 0x44, 0x45, 0x4b, 0x4c, 0x69]);
function printLabel(label, indent = " ") {
console.log(`${indent}${label.name}${label.quest >= 0 ? "" : " (no quest)"}`);
let depth = 0;
for (const ins of label.instructions) {
if (ins.op === 0x3b || ins.op === 0x42 || ins.op === 0x3e) depth = Math.max(0, depth - 1);
console.log(`${indent} ${" ".repeat(depth)}${formatInstruction(ins)}`);
if (ins.op === 0x3a || ins.op === 0x3b || ins.op === 0x42) depth++;
}
}
function printQuest({ labels, quests }, id) {
const own = labels.filter((l) => l.quest === id);
console.log(`== quest ${id} ${quests.get(id) ?? "(not in the quest table)"}: ${own.length} labels`);
for (const label of own) printLabel(label);
// Names other labels may use: own labels, dialogs, NPCs, variables.
const names = new Set(own.map((l) => l.name));
for (const l of own) if (l.name.startsWith("Dialog:")) names.add(l.name.slice(7));
const npcs = new Set();
const vars = new Set();
for (const l of own) {
for (const ins of l.instructions) {
for (const a of ins.args) if (a.kind === "cond") vars.add(a.value[1]);
if (NPC_OPS.has(ins.op) && ins.args[0]?.kind === "str" && ins.args[0].value && ins.args[0].value.toLowerCase() !== "hero") npcs.add(ins.args[0].value.toLowerCase());
if (VAR_WRITES.has(ins.op) && ins.args[0]?.kind === "str") vars.add(ins.args[0].value);
}
}
vars.delete("");
// Variables many quests use (region moods, "goodies") are only named.
const shared = [];
for (const v of vars) {
const users = new Set(labels.filter((l) => l.quest !== id && l.instructions.some((ins) => strings(ins).includes(v))).map((l) => l.quest));
if (users.size > 2) {
vars.delete(v);
shared.push(`${v} (${users.size} quests)`);
}
}
console.log(` -- references from other labels (NPCs ${[...npcs].join(" ") || "-"}; variables ${[...vars].join(" ") || "-"}; shared ${shared.join(" ") || "-"})`);
for (const l of labels) {
if (l.quest === id) continue;
const hits = l.instructions.filter((ins) =>
strings(ins).some((s) => names.has(s) || (vars.has(s) && (VAR_WRITES.has(ins.op) || ins.op === 0x3a || ins.op === 0x42))) ||
(NPC_OPS.has(ins.op) && npcs.has(String(ins.args[0]?.value).toLowerCase())) ||
((ins.op === 0x14 || ins.op === 0x0a || ins.op === 0x0b || ins.op === 0x0c || ins.op === 0x0d || ins.op === 0x4d || ins.op === 0x75) &&
ins.args.some((a) => a.kind === "int" && a.value === id)));
if (hits.length) {
console.log(` ${l.name} [quest ${l.quest}]`);
for (const ins of hits) console.log(` ${formatInstruction(ins)}`);
}
}
}
// Where a label is used: "<op>:<tag> in <label>" for every argument naming it
// (dialog buttons, death labels, CallFunktion, timers, triggers, ...).
function labelUses(labels, name) {
const uses = [];
for (const l of labels) {
for (const ins of l.instructions) {
for (const a of ins.args) {
if (a.kind === "str" && a.value === name && !(ins.op === 0x01 && a.tag === 0x01)) {
uses.push(`${OPS[ins.op] ?? hex(ins.op)}${a.tag === 0x05 ? "(death of " + ins.args[0]?.value + ")" : ""} in ${l.name}`);
}
}
}
}
return uses;
}
// Compact facts for the audit: giver, how the quest ends or fails, NPC deaths.
function printFacts({ labels, quests }, id) {
const own = labels.filter((l) => l.quest === id);
const ownNames = new Set(own.map((l) => l.name));
console.log(`== ${id} ${quests.get(id) ?? "(not in the quest table)"} (${own.length} labels)`);
// Givers: NPCs whose dialog has a button starting the quest.
const accepts = new Set(own.filter((l) => l.instructions[0]?.op === 0x14).map((l) => l.name));
const giverDialogs = own.filter((l) => l.name.startsWith("Dialog:") &&
l.instructions.some((ins) => ins.op === 0x3c && accepts.has(ins.args[1]?.value))).map((l) => l.name.slice(7));
for (const l of labels) {
for (const ins of l.instructions) {
if ((ins.op === 0x01 || ins.op === 0x03) && ins.args.some((a) => a.tag === 0x09 && giverDialogs.includes(a.value))) {
console.log(` giver ${ins.args[0]?.value} (${OPS[ins.op]} in ${l.name}${l.quest === id ? ", own label" : ""}): ${formatInstruction(ins)}`);
}
}
}
if (!giverDialogs.length) console.log(" giver: none found (accept labels: " + ([...accepts].join(" ") || "-") + ")");
for (const l of own) {
const ends = l.instructions.filter((ins) => ins.op === 0x0f || ins.op === 0x36).map((ins) => OPS[ins.op]);
const morph = l.instructions.filter((ins) => ins.op === 0x4f || ins.op === 0x37 || ins.op === 0x2e || ins.op === 0x38).map(formatInstruction);
if (!ends.length && !morph.length) continue;
const uses = labelUses(labels, l.name).filter((u) => !u.endsWith(` in ${l.name}`));
console.log(` ${l.name}: ${[...ends, ...morph].join("; ")} <- ${uses.join("; ") || (l.name.startsWith("QIS_") ? "quest end script" : "?")}`);
}
// Death labels anywhere that run this quest's labels.
for (const l of labels) {
for (const ins of l.instructions) {
const death = ins.args.find((a) => a.tag === 0x05 && ownNames.has(a.value));
if (death) console.log(` death ${ins.args[0]?.value} -> ${death.value} (${OPS[ins.op]} in ${l.name})`);
}
}
}
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop());
if (isMain) {
const [dir, ...rest] = process.argv.slice(2);
if (!dir || !rest.length) {
console.error("usage: node questscript.mjs <script dir> <quest id...> | --list | --facts [ids] | --label <name> | --grep <regex>");
process.exit(1);
}
const script = load(dir);
if (rest[0] === "--list") {
for (const [id, name] of script.quests) {
console.log(`${id}\t${script.labels.filter((l) => l.quest === id).length}\t${name}`);
}
} else if (rest[0] === "--facts") {
const ids = rest.length > 1 ? rest.slice(1).map(Number) : [...script.quests.keys()];
for (const id of ids) printFacts(script, id);
} else if (rest[0] === "--grep") {
// Instructions matching a regular expression, in all labels.
const re = new RegExp(rest[1]);
for (const l of script.labels) {
for (const ins of l.instructions) {
const text = formatInstruction(ins);
if (re.test(text)) console.log(`${l.name} [quest ${l.quest}]: ${text}`);
}
}
} else if (rest[0] === "--label") {
for (const l of script.labels.filter((l) => l.name === rest[1])) printLabel(l, "");
} else {
for (const id of rest) printQuest(script, Number(id));
}
}