Skills, auto-attack, aura and chain lightning rework, Esc, cache fix

- Skill bar: no art selection; slot 0 is the right-click art, keys 0-6 cast
  slots directly; cooldown labels in exact seconds; slot 0 set apart.
- Arts regenerate on their own (casting no longer refills the group).
- Auto-attack after targeting or casting at a hostile creature, at the speed
  of a held button.
- Auras can be recast while active (renew, no second visual).
- Blicke wie Klingen hits every enemy in range, physical, with life leech.
- Blitzschlag steers the game's own chain: 3 + level/5 hits, 1 + level/10
  chains, re-hits a lone enemy.
- Weapon sets: click selects, drag only from the selected set; the leftmost
  set is clickable (signed hit tests).
- Esc closes open windows before opening the game menu.
- HUD: standalone buttons and texts are centred.
- Record cache sized for widened views (town lag while walking).
- Shared game code: orders, fx events, text drawing, cooldowns, effects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-16 00:11:38 +02:00
1 parent f721d2a486
commit 1fd89524d3
41 files changed
+2347 -214

No files matched your search

+9
View File
@@ -32,6 +32,8 @@ add_library(sacred_re SHARED
src/game/engine/log.cpp
src/game/engine/cmdline.cpp
src/game/engine/events.cpp
src/game/engine/fx.cpp
src/game/engine/orders.cpp
src/mods/borderless.cpp
src/mods/widescreen.cpp
src/mods/hud.cpp
@@ -43,6 +45,13 @@ add_library(sacred_re SHARED
src/mods/skillbar.cpp
src/mods/arttarget.cpp
src/mods/aura_restore.cpp
src/mods/autoattack.cpp
src/mods/aurarecast.cpp
src/mods/artregen.cpp
src/mods/aurahits.cpp
src/mods/chainlightning.cpp
src/mods/escape.cpp
src/mods/recordcache.cpp
)
target_include_directories(sacred_re PRIVATE src)
target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS})
+32
View File
@@ -0,0 +1,32 @@
# Art regeneration (`src/mods/artregen.cpp`, `game::GetArtCooldown`)
Goal (user, 2026-10-15): casting an art no longer affects the cooldowns of
other arts; the cooldown labels show exact seconds.
## How the game regenerates arts (measured 2026-10-15)
Cooldown entries: see `src/game/engine/arts.h` (cCreatureArts +0x102,
0x16 bytes each: group, art, level, bonus levels, total, speed factor,
remaining). Found with a hardware write watch on one entry's remaining time:
- countdown, every creature update (`0x562998`):
`remaining -= speed factor * rate * seconds`, rate = cCreatureArts +0xee
(magic group, Energieblitze) or +0xf2; clamped to 0..total;
- casting an art: the cast code computes the art's regeneration
(`cCreatureArts::RegenFor` `0x582710`) and adds it with
`cCreatureArts::AddGroupRegen` (`0x562330`, (amount, group, filter)) to
**every** entry of the group (combat arts / magic), capped at each total:
one cast puts the whole group into regeneration. Flags +0x148 & 8 / & 4 cut
the amount to 80 % for the magic / combat group; Flammenwerfer, Meteor,
Kanone and Energieblitze always count as magic. A combo (creature +0x2b4,
arts +0x59) or group 3 takes another branch (combo slots at +0x10e);
- the speed factor (+0xe) is reset by the stats recalculation (`0x579b7d`,
`0x570fad`), the level byte +7 raised by item bonuses (`0x581e40`, which
adds the change of the total to the remaining time).
## The mod
For player heroes, an AddGroupRegen call right after RegenFor (same thread)
adds the amount to the cast art's entry only. Other calls (negative amounts,
filters, combos, other callers) go to the game. Labels: seconds =
remaining / (speed factor * rate).
+44
View File
@@ -0,0 +1,44 @@
# Periodic aura hits (`src/mods/aurahits.cpp`)
Rebalancing request (user, 2026-10-15): "Blicke wie Klingen" (art 0x3f8,
`GUI_MOVE_KLINGENBLICK`) hit only one enemy per tick; it should hit all
enemies in range.
## What the game does (measured with a hit log, 2026-10-15)
The aura gives the hero an effect of kind 0x12 (value 0x445f in the test).
Its hits reach the enemies as `cEvent_creature` 5 (attacker id at +8, the
damage at +0x18.. in one of four damage-type slots, chosen at random), sent
from `0x56323d`, one enemy about once per second.
Code (creature arts update, branch `0x562f08`, only for effects of kind 0x12
of the local player's creature):
- timer at effect +8 (a vector holding one int, ms); every 1000 ms:
- walk the creatures near the player (`0xad1c60`, 8 bytes each, count
`0xad1b48`); a creature within `(cCreatureArts +0x56 + 20) * 10` of the
owner (`cCreature::DistanceTo` `0x536f20`) becomes the target if there is
none yet, otherwise replaces it with 1/3 chance;
- player heroes as targets go through PvP checks (`0x4d4be0`);
- the hit: `cEvent_creature` 5 built on the stack, sent with the target's
`ReceiveOrder` (vf18).
## The mod
`mov eax, [ecx]; call [eax+18h]` at `0x56323b` becomes a call of
`HitAllInRange` (via a thunk passing ebp = cCreatureArts and ebx = the
effect's offset): the chosen enemy gets its hit, then every other living
creature of the nearby list within the same range gets a copy of the same
hit. Player heroes are skipped (they need the PvP checks). Applies to every
effect of kind 0x12, i.e. by behaviour, not by art id.
## Damage type and life leech (2026-10-15)
- The game puts the aura's value into one of the four damage slots at
random (physical, fire, magic, poison), so a quarter of the ticks poisoned;
the mod makes every aura hit physical (user's choice).
- Life leech, its beam and sound are applied by the damage code (`0x56aff0`,
leech block `0x5707a4`..`0x570a88`: property 0x349 x difficulty factor x
damage x distance falloff, capped at max health) for the hit's attacker,
order +0x1c. Weapon hits carry the hero's id there, enemy hits the enemy's,
aura hits 0, so they never leeched. The mod sets it to the aura's owner.
+36
View File
@@ -0,0 +1,36 @@
# Recasting active auras (`src/mods/aurarecast.cpp`)
Goal (user, 2026-10-15): auras / buffs can be cast again while active; the
new cast renews the remaining time, it does not add to it.
## What blocks it (measured 2026-10-15)
Heldenmut (0x3f7) on the hero: effects kind 1 and 2 (the buff) and kind 0x22
(the lock `cCreature::StartArtCooldown` 0x539ce0 adds), all with the aura's
duration (66 s); its art cooldown (cArtCooldowns, 301.9 units) ran out while
the aura still had 30 s left, and a cast order (0x105, art 0x3f7) was still
ignored.
`cCreature::IsValidArtTarget` (`0x51d680`) switches on the art: 0x28, 0x29,
0x41, 0x48, 0x3ed, 0x3f7, 0x3f8 and table entries for 0x3fa.. go to
`0x51d8b1`, which rejects the target if it has any effect of the art
(`cCreatureArts::HasEffectOfArt` `0x5829c0`, effect +0x1c). 0x3fa / 0x3fb
and 0x446 have their own variants of the test right after it (not changed).
## The mod
- The `je` after that test (`0x51d8bd`) becomes a `jmp`: the target is valid
whether or not the effect is active. The art's cooldown still applies.
- `cCreatureArts::AddEffect` (`0x582c10`, (kind, ?, value, duration, art,
network, ?, ?), ret 0x20) is hooked: for a buff art (launch type
SelfOrAlly) on a player hero, if an effect of the same kind and art exists,
its remaining time is set to the new duration and nothing is added. The
value of the old effect stays (a recast after raising the art's level keeps
the old strength until it runs out).
- The aura visual is an effect object (cObjectFX, vtable `0x88f99c`) each
cast creates and attaches with a cEventFX 0x42 (owner +8, art +0x3c; the
object keeps the owner at +0x68). `cObjectFX::ReceiveEvent` (`0x5a37b0`)
is hooked: when a new one attaches to a player hero for a buff art, the
previous one for the same hero and art is flagged dead (object +0x14 bit
0x10, as saving does). Untested whether the object manager removes it
promptly.
+54
View File
@@ -0,0 +1,54 @@
# Auto-attack (`src/mods/autoattack.cpp`)
Goal (user, 2026-10-15): once an enemy was targeted, by a left click or by
casting an art at it, the hero keeps attacking it with the weapon between
casts. Left click stays the normal attack; right click casts slot 0.
## What the game does (order log, 2026-10-15)
Orders logged in `cCreature::ReceiveOrder` (`0x52e400`) for the hero, with
the hero's fields per tick:
| Order | Caller | Fields |
|---|---|---|
| left click on an enemy: `cmd 7` | `0x617abb` | +0x18 target id, +0x1c / +0x20 its position |
| art: `cmd 0x105` | `0x6172f9` (`SendCreatureOrder`) | +0x10 target id, +0x18 / +0x1c ground point, +0x28 0x64, +0x2c 1, +0x30 art id |
| walk: `cmd 4` | `0x6172f9` | see docs/wasd.md |
| hit by an enemy: `cmd 5` | `0x4ea478` | +0x8 attacker id, +0x10 the hero, +0x2c 1.0f (every ~0.5 s) |
One left click is one swing: hero +0x150 goes 0x10000 (idle) -> 0x10003
(attack) -> 0x20003 -> 0x10000 within about a second; +0xfc 0 -> 4 ->
0x20004 -> 0 at the start. An art: 0x10001 -> 0x10008 -> 0x20008 -> 0 ->
0x10000. Low word of +0x150: current action (0 none, 1 art, 3 attack, 6
dying), high word its phase. `cCreature::IsAlive` (`0x548ef0`): +0xfc != 9,
+0x150 != 6, health +0x4d8 != 0.
Holding the left button on an enemy (measured): one attack order, then +0x150
stays 0x10003 for as long as the button is held (9 s in the test), no further
orders. At the end of each swing (after the hit, `0x4e6584`) the attack code
calls `cMouseCursor::IsButtonHeld` (`0x655ab0`, cursor word bit 8) at
`0x4e65c6` (attacker in esi, target creature in ebp; measured: the only
caller answered "held" during a held attack): held -> the next swing starts
right away, otherwise recovery (+0x152 = 2) and idle. (`0x4f665a`, a similar
check in another creature update, is not reached by the hero.) Re-sending
the attack order after the hero went idle took ~1.45 s per swing (0.36 s
wind-up, 0.64 s attack, 0.43 s recovery).
## The mod
- An attack order or an art order whose target is hostile
(`cCreature::IsHostileTo` `0x548dd0`, faction relation) sets the combat
target. (First version: "not an ally", which included NPCs: clicking a
trader made auto-attack talk to it again and again, reopening its window
with new stock.) any walk order (mouse, WASD, stop) clears it, as
does the target's death.
- Each engine tick, if the hero is idle (+0x150 low word 0), no mouse button
is held (engine +0x54 & 0x10), input is not blocked (0x4000) and no skill
key is held, it sends the attack order a left click sends. After sending it
waits up to 5 ticks for the swing to start.
- The end-of-swing button check (`call` at `0x4e65c6`) is redirected: it
also answers "held" when the hero hits its auto-attack target, the target
is alive and no skill key is held. Swings then chain like
with a held button; the tick only restarts the attack after casts.
- Self / ally arts pointed at an enemy lose their target
(`src/mods/arttarget.cpp`) and therefore do not start auto-attack.
+57
View File
@@ -0,0 +1,57 @@
# Chain lightning (`src/mods/chainlightning.cpp`)
Rebalancing request (user, 2026-10-15) for the Battlemage's Blitzschlag (art
0x21, icon `GUI_SPELL_BLITZSCHLAG`; 0x0e has the same icon):
- hits per chain N = 3 + level / 5 (the first target included);
- chains B = 1 + level / 10, all starting at the first target
("hero -> T1 -> A1 -> A2 ..., T1 -> B1 -> B2 ...");
- a chain that finds no new target hits the previous one again (a lone enemy
takes N hits).
## How the game casts it (measured with an object / hit log, 2026-10-15)
About 0.3 s after the cast order (0x105, art 0x21) the caster's art code
(`0x5222c0`, Blitzschlag part around `0x526c91`..`0x527102`):
- builds the visual path: up to 20 segment objects of type 799 (0x31f),
each created linked to the previous one (`CreateAt` third argument), ids
kept at caster +0x263;
- creates the bolt object, type 872 (0x368), at the target, linked to the
last segment, copies the caster's +0x2b5 to its +0x39, and sends it a
cEventFX 0x60 (`0x41e0a0`: [2] caster id, [3] target id, [4..7] position,
[8..13] two strings, [14] damage, [16..29] damage block);
- plays sound 0x7db.
The bolt object then sends the hit (`cEvent_creature` 5 from `0x5b52d3`,
attacker = caster, +0x14 = 0x368, +0x18 = the bolt object's id).
## The game's own chain (measured 2026-10-15)
The game's Blitzschlag already chains. The 0x60 event's [14] is the number of
hits left in the chain (3 at level 1, from the art level, `0x4d24f0`). After
its hit, a bolt with hits left looks for the next target in its update
(`cObjectManager::FindJumpTarget` `0x6005d0`, called at `0x5b469f` with
(position, 100000 = squared range, caster, current target): the nearest
creature, excluding only caster and current target), builds the arc (segment
objects 799 created at `0x5b4aff`, each set up with a cEventFX), and creates
the next bolt at the target (`0x5b4d7c`) with [14] - 1. No target: the chain
ends (a lone enemy is hit once). Two enemies: it bounces between them.
## The mod
- The first bolt of a hero's cast (the first 0x60 bolt setup after the cast
order) gets [14] = N = 3 + level / 5 (level = cooldown entry level + bonus
levels of the art cast).
- The jump search call (`0x5b469f`) goes to `NextTarget` (thunk passing the
bolt in ebp): for bolts of our chains (bolt id -> chain, filled when a bolt
of a chain is set up at the target the chain said it jumps to) the next
target is the nearest enemy within the game's range not hit by this chain
yet, else another enemy, else the current one again (a lone enemy takes all
N hits). Other bolts use the game's search.
- B = 1 + level / 10 chains: while the first bolt is set up, B - 1 more bolts
are created at the first jump targets of the other chains (copies of the
event with [14] = N - 1); their first hop has no arc.
First version (bolts copied for every jump, not using the game's chain)
replaced the same day: it doubled the jumps.
+33
View File
@@ -0,0 +1,33 @@
# Esc closes windows first (`src/mods/escape.cpp`)
Asked 2026-10-15: with a dialog open, Esc should close it instead of opening
the game menu.
## The game (`cUI_Manager::HandleEsc` 0x756ba0, measured 2026-10-15)
On an Esc key event the UI manager (`0x757544`) calls HandleEsc. It knows its
windows by slot (cUI_Manager dwords): 0x20 taskbar, 0x21 inventory, 0x22
equipment, 0x23 blacksmith, 0x24 merchant, 0x25 megamap, 0x26 overview map,
0x27 mercenary (party panel), 0x28 stats, 0x29 console, 0x2a diary (quest
book), 0x2b savegame, 0x2c ESC menu, 0x2d purchase, 0x2e master, 0x2f options,
0x30 chest, 0x31 horse, 0x32 network info, 0x33 net portrait, 0x34
character, 0x35 cube. It closes the ESC menu, the megamap, the quest book, or
(with 0x21/0x23/0x24/0x2e/0x30 open) the inventory; hides 0x2b / 0x2f;
otherwise opens the ESC menu. With a trader open only the inventory closed,
and the trader opened it again.
Closing a window: a cEventUI2 (vtable 0x88edd0, 0x80 bytes: +4 code 0x12,
+8 subtype 5 = hide, names via 0x5904e0: sender at +0x28, window at +0x48 as
measured in the commands the game sends when a window is closed by its
button or hotkey; a string at +0x68, 0x423920 / 0x416660) sent to the UI
manager's event handler (vf00, 0x757120), which routes it by name. Sent to
the window's own vf10 it was not understood (first version).
## The mod
HandleEsc is hooked: unless the ESC menu is open, every visible window of a
table (slot, name) gets that hide command, and Esc does nothing else. If none
is open, the game handles Esc as before.
Not covered: dialogs that are not one of these windows, e.g. quest dialogs
(standalone buttons and texts in the UI manager, docs/hud.md).
+12
View File
@@ -58,3 +58,15 @@ copying and writing back hold the real object's lock).
A primitive drawn inside a moved window that covers the whole 1024x768 layout
(e.g. the dimming behind the exit confirmation) is stretched across the render
width instead of shifted.
## Standalone controls (quest dialog)
Some dialogs (e.g. accepting a quest) are not one of the hooked window
classes: their buttons and texts sit directly in the UI manager as top-level
controls (measured: the clicked button's root window was the cUI_Button2
itself). They were drawn unshifted or with whichever window's scope happened
to be active, so the dialog jumped between the 1024 layout and the centre and
clicks only hit at the unshifted position. `cUI_Button2`, `cUI_Static2`,
`cUI_StaticText64`, `cUI_StaticText64FX` (render and event) and
`cUI_TextLines` (render) are hooked as centred windows too; inside other
windows they are in that window's scope already (outermost wins).
+20
View File
@@ -0,0 +1,20 @@
# Performance
## Record cache in widened views (`src/mods/recordcache.cpp`)
Measured 2026-10-15 (Battlemage save in a town, frame time at Flip, sampling
profiler over all threads): walking took 90-130 ms per frame in the widened
view, 17 ms with widescreen off; idle 17 ms in both.
The render thread spent most samples in a std::map walk (0x832b80 /
0x832ba0, called from 0x636222) inside FUN_006360e0: a record-by-id cache
(map at +0xde5c, size at +0xde60, 16-byte value at node +0x14, last-used
stamp at node +0x24 from 0xcd59d0; misses read the record from the file at
+0x23c). With 4096 entries or more, every insert walks the whole map to
erase the single least recently used entry. The widened view needs more
records than that, so walking evicted and reloaded constantly.
Fix: the limit (`cmp ecx, 1000h` at 0x636201) is raised to 0x8000 so the
per-insert walk never runs; before a lookup with 0x6000 entries the mod
erases the less recently used half in one pass (map::erase 0x63da10). Walking
in town: 17 ms per frame again (confirmed by the user).
+47 -1
View File
@@ -115,6 +115,51 @@ the 1024 frame (66-78 px each side). In the widened view the centred bar has
room for that (16:9: 1365 wide, 170 px each side). Decision 2026-10-14: keep
the original spacing; 4:3 is not a target (cut off there).
### Art slots without selection (2026-10-15)
Decision: art slots are no longer selected. Slot 0 (leftmost) is always the
right mouse button's art; left click stays the normal attack. Keys 0-6 cast
slots 0-6 directly (`src/mods/skillkeys.cpp`): key 0 presses the right
button, keys 1-6 select their slot for as long as the key is held and press
the right button. Without a held skill key the selection returns to slot 0
(engine tick). `cUI_Taskbar2::SelectSkillSlot` turns every other request
(clicks on the bar, wheel, COMBINE_SLOTS, slot count check) into slot 0;
slots on the bar are only filled by drag & drop. Weapon sets are still
selected (F1-F7, clicks).
How a slot is drawn (`RenderArtSlots` `0x6e31e0`): each slot is a
`UI_STATIC` control drawn by its vf14; the function swaps the control's atlas
entry and rect, draws, and restores them. An art has 3 icons (indices into
`g_uiAtlasEntries` `0x9eaee0`, 0x54 each): ready + selected slot uses the
third, otherwise the first; while recharging, the slot is drawn twice, split
vertically by the ready fraction (first icon above, second below). Icons
0x276-0x278 replace them when the art cannot be used mounted and
`0x54fa90` holds (fill from creature +0x4e8 / +0x4ec). Over the icon a frame
is drawn: `g_uiTaskbarArtFrameSelected` `0x9f4be4` / `g_uiTaskbarArtFrame`
`0x9f7f14`, `0x9f5d9c` as a drop target, `0x9f696c` with creature flag
0x2000. `UpdateSlotHighlight` (`0x6df9a0`) sets the slot backgrounds
(`0x9ed084` selected / `0x9ed030`). Patched (`kTaskbarSkillSelectionLook`):
frame and background test "slot 0" instead of "selected slot", and every
ready art uses its third icon. Slots 1-6 are moved right by half a slot
(0x21) after the layout, and the decorative strip right of the centre starts
that much further right (`add ecx, 23Ah` at `0x6e01cc`).
Weapon set clicks (2026-10-15): with an empty cursor, the taskbar's mouse
handling jumps to `0x6e10d8` (`je` at `0x6e0ca7`; taskbar in esi, inventory
in ebp, set at [esp+0B8h], hand at [esp+0BCh]), which takes the set's weapon
onto the cursor (`TakeWeaponSetItem`) and re-selects the selected set. That
jump now goes through `WeaponSetClickThunk`: a click on a set that is not
selected only selects it (exit `0x6e124d`, al = 1); dragging starts from the
selected set only.
Negative x (2026-10-15): with 7 slots the leftmost weapon set (set 1, F1)
lies at x = -68..-2 of the layout. The taskbar's own hit test accepts it,
but every place that asks the slot controls (`vf1c`, signed compares) passes
the position as `and reg, 0FFFFh`, so -43 became 65493 and the set could
not be clicked. `kTaskbarSignedHitTests` sign-extends them (slot lookups
`0x6dfcfe`/`0x6dfd5e`, the click loops `0x6e1a0c`/`0x6e1a5c`, drop and
drag `0x6e1e8e`.. `0x6e20f2`).
The two 5 x 0xbc blocks at +0x258 / +0x604 are the belt (3D item views), not
slots. Still to map: rendering of the slot contents (icons, cooldowns,
`Render` `0x6e3ac0`), drag & drop onto slots (`HandleEvent` mouse branch,
@@ -142,4 +187,5 @@ only (or disabled in multiplayer).
(agreed 2026-10-14).
5. Taskbar: 7 controls each (arrays inside the object are fixed at 5: own
table for the extra controls), layout, rendering, hit tests, drag & drop.
6. Keys: 1-7 select and cast art slots, F1-F7 select weapon sets.
6. Keys: 0-6 cast art slots (since 2026-10-15; before: 1-7 select and
cast), F1-F7 select weapon sets.
+89
View File
@@ -8,6 +8,95 @@ recreates it after a load for Heldenmut (0x3f7) and Klingenblick (0x3f8). Spell
auras (Steinhaut, Lichtschild, ...) use other effects and are not covered
yet; check them if their visuals go missing too.
## Data-driven arts instead of hardcoded art ids
Asked 2026-10-15: naming single arts as constants (`kArtHeldenmut`,
`kArtFlammenwerfer`, ... in `src/game/engine/arts.h`) does not scale. Mods
should decide by an art's properties, read from the game's own data:
- the definition tables `g_spellDefs` (`0x950a38`, 71 x 0x94) and
`g_combatMoveDefs` (`0x94ddb0`, 95 x 0x78): id, icon name, launch type,
usable mounted, ... (`tools/arttable.mjs` dumps them; most fields unnamed);
- map the remaining fields (regeneration group, lasting effect / aura,
duration, ...) and expose them as an `ArtInfo` lookup in `src/game/`;
- where the game itself hardcodes ids (e.g. the arts that always count as
magic in `cCreatureArts::AddGroupRegen`, the "already active" switch in
`IsValidArtTarget`), keep one table of those ids with the reason, instead of
named constants spread over the mods;
- optionally an own data file (JSON next to the DLL) for mod-side settings per
art, if we need values the game does not have.
## Art ranges: scaling and indicators
Asked 2026-10-15 (after making periodic aura hits strike everything in
range, docs/aura-hits.md):
- art ranges should scale with the art's level, and possibly with new
attributes;
- possibly show a range indicator (e.g. a ring around the hero for auras, or
at the cursor for aimed arts).
Known so far: the periodic-hit aura (effect kind 0x12) uses
`(cCreatureArts +0x56 + 20) * 10` world units, measured from the owner with
`cCreature::DistanceTo` (`0x536f20`); what +0x56 is (level? a stat?) is not
mapped yet. Other arts' ranges are not mapped. Fits the data-driven arts
item above (range as a property of an art, one place to compute it).
## Rename life leech to "omnivamp"
Asked 2026-10-15: life leech (property 0x349, "Lebensentzug") becomes
"omnivamp". To do: the in-game texts (item property, tooltips, character
window). The leech code is the game's (`0x5707a4`.., property 0x349, in the
damage code `0x56aff0`); it credits the hit's attacker (order +0x1c). Omnivamp usually means healing from all damage,
not only weapon hits: aura hits (docs/aura-hits.md) now name their attacker
so the game applies it;
other damage sources (spells, combat arts' extra hits, damage over time) may
follow.
## Map and map zoom in widened views
Reported 2026-10-15: the map (and zooming it) does not support the widened
render sizes yet (docs/widescreen.md, docs/hud.md: windows are laid out for
1024 and centred). To check: the map window's draw area and the zoom's
hardcoded 1024/768 (or 512/384 centre) values.
## Quest dialog glitches in widened views
Reported 2026-10-15: accepting a quest, the dialog window "kept glitching"
and button clicks were not detected correctly. Probably the HUD layer
(src/mods/hud.cpp: windows shifted while they render / handle events): the
dialog may not be covered, or shifted twice. Details asked from the user.
## WASD walks around walls (caves)
Reported 2026-10-15: in caves WASD movement behaves "as if the player tries
to find another way to the target location if a wall comes up". WASD sends a
walk order to a point 300 world units ahead (docs/wasd.md); when that point is
behind a wall the pathfinder plans a detour. Idea: find the game's straight
line / walkability check and shorten the target when the line is blocked (or
stop / slide along the wall). Related to the pathfinder item above.
## Esc closes an open dialog first
Asked 2026-10-15: with any dialog open (quest, trader, inventory, ...), Esc
should close that dialog instead of opening the game menu (ESC menu,
`cUI_EscMenu`). Esc is handled in the engine / UI manager hotkeys
(docs/wasd.md: engine `0x618130`, UI manager `0x757120`).
## In-DLL smoke tests (later)
A test mode inside the mod DLL, started by an ini setting or command-line flag,
so builds can be checked without play-testing and without driving the real
mouse/keyboard like `tools/autotest-load.ps1`:
1. load a fixed test save;
2. play scripted input through the existing hooks (keys, casting arts, weapon
set switches, save + reload);
3. take back-buffer screenshots at checkpoints and log checks to
`sacred_re.log` (aura effect present after reload, slot 6-7 inventories
survive a save, ...);
4. exit the game.
## Better pathfinder (mouse clicks and monsters)
Replace the game's pathfinding with a more reliable and efficient one, for
+37
View File
@@ -205,6 +205,43 @@ constexpr re::BytePatch kTaskbarSlotLoopBounds[] = { // cmp reg, 5
{0x006dffd2, 3, 5}, // SetSlotCount: cmp si, 5
{0x006df7c5, 1, 5}, {0x006e2593, 1, 5}, {0x006dffd8, 1, 5}, // mov reg, 5 (count / clamp)
};
// Combat-art slots without a selection: slot 0 (the right mouse button's art)
// is drawn as the selected one, every slot's ready art with the selected icon.
constexpr re::CodeReplacement kTaskbarSkillSelectionLook[] = {
// UpdateSlotHighlight, slot background: `cmp ebx, eax` (slot == selected)
// -> `test ebx, ebx` (slot == 0)
{0x006dfa77, {0x3b, 0xd8}, {0x85, 0xdb}, 2},
// RenderArtSlots, frame: `cmp edi, eax` -> `test edi, edi`
{0x006e3813, {0x3b, 0xf8}, {0x85, 0xff}, 2},
// RenderArtSlots: a ready art is drawn with its third ("selected") icon
// only in the selected slot: `jne` skipped
{0x006e3575, {0x75, 0x54}, {0x90, 0x90}, 2},
};
// Taskbar code passes mouse positions to the slot controls' hit tests as
// `and reg, 0FFFFh` (zero-extended u16): with 7 slots the leftmost weapon set
// lies at x < 0 of the layout and never matched. Sign-extended instead:
// `and edx, 0FFFFh` -> `movsx edx, dx`, `and eax, 0FFFFh` -> `cwde`.
constexpr re::CodeReplacement kTaskbarSignedHitTests[] = {
{0x006dfcfe, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006dfd07, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006dfd5e, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006dfd67, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006e1a5c, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006e1a67, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006e2093, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006e209e, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006e20e7, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006e20f2, {0x81, 0xe2, 0xff, 0xff, 0x00, 0x00}, {0x0f, 0xbf, 0xd2, 0x90, 0x90, 0x90}, 6},
{0x006e1a0c, {0x25, 0xff, 0xff, 0x00, 0x00}, {0x98, 0x90, 0x90, 0x90, 0x90}, 5},
{0x006e1a16, {0x25, 0xff, 0xff, 0x00, 0x00}, {0x98, 0x90, 0x90, 0x90, 0x90}, 5},
{0x006e1e8e, {0x25, 0xff, 0xff, 0x00, 0x00}, {0x98, 0x90, 0x90, 0x90, 0x90}, 5},
{0x006e1e98, {0x25, 0xff, 0xff, 0x00, 0x00}, {0x98, 0x90, 0x90, 0x90, 0x90}, 5},
{0x006e1ee1, {0x25, 0xff, 0xff, 0x00, 0x00}, {0x98, 0x90, 0x90, 0x90, 0x90}, 5},
{0x006e1eeb, {0x25, 0xff, 0xff, 0x00, 0x00}, {0x98, 0x90, 0x90, 0x90, 0x90}, 5},
};
// SetSlotCount: the decorative strip right of the centre starts at
// 0x23a + n * 0x40 (`add ecx, 23Ah`).
constexpr uint32_t kTaskbarRightStripStart = 0x006e01cc;
// cCreature::AssignArtToSlot (0x55fac0) rejects slots > 4 (`cmp eax, 4`).
constexpr re::BytePatch kAssignArtMaxSlot[] = {{0x0055fae3, 2, 4}};
// SlotCountForLevel (stdcall, level): (level, count) table at 0x95503c, max 5.
+117
View File
@@ -1,6 +1,7 @@
#pragma once
// Combat arts: the definition tables and how they are targeted.
#include <cstddef>
#include <cstdint>
namespace game {
@@ -39,6 +40,122 @@ inline LaunchType ArtLaunchType(uint16_t id) {
return LaunchType::Unknown;
}
// cCreature::ResolveArtId (0x5521e0, (ref, 0) as the taskbar calls it): the
// art id of an art slot's reference (cInventoryEntry::GetArtSlotRef) for this
// creature.
inline uint16_t ResolveArtId(void* creature, uint32_t slotRef) {
using Fn = uint32_t(__fastcall*)(void*, void*, uint32_t, uint32_t);
return static_cast<uint16_t>(reinterpret_cast<Fn>(0x005521e0)(creature, nullptr, slotRef, 0));
}
// A creature's arts state (cCreatureArts, at creature +0x3a8).
constexpr size_t kCreatureArts = 0x3a8;
// Active effects (buffs, auras, art locks): 0x20-byte entries at +0x11a /
// +0x11e (begin / end) of cCreatureArts: kind +0, remaining float +0x18
// (seconds), art u16 +0x1c. Kind 0x22 is the lock cCreature::StartArtCooldown
// (0x539ce0) adds; cCreatureArts::AddEffect 0x582c10, HasEffectOfArt 0x5829c0.
constexpr size_t kEffectsBegin = 0x11a;
constexpr size_t kEffectsEnd = 0x11e;
constexpr size_t kEffectSize = 0x20;
constexpr size_t kEffectKind = 0x0;
constexpr size_t kEffectRemaining = 0x18;
constexpr size_t kEffectArt = 0x1c;
constexpr uint32_t kEffectKindArtLock = 0x22;
constexpr uint16_t kNoArt = 0xffff; // art field of effects without an art
// Arts named in the code (icon names from g_spellDefs / g_combatMoveDefs).
constexpr uint16_t kArtHeldenmut = 0x3f7;
constexpr uint16_t kArtKlingenblick = 0x3f8;
constexpr uint16_t kArtEnergieblitze = 0x3fb;
constexpr uint16_t kArtFlammenwerfer = 0x43d;
constexpr uint16_t kArtMeteor = 0x43e;
constexpr uint16_t kArtKanone = 0x43f;
// cCreatureArts fields.
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
constexpr size_t kArtsComboRunning = 0x59; // u8: combo step in progress
constexpr size_t kArtsFlags = 0x148;
constexpr uint32_t kArtsFlagFasterMagicRegen = 0x8; // group 1 regeneration at 80 %
constexpr uint32_t kArtsFlagFasterCombatRegen = 0x4; // group 2 regeneration at 80 %
// cCreature: the combo step being executed (u8), 0 outside combos.
constexpr size_t kCreatureComboStep = 0x2b4;
// Regeneration groups of cooldown entries (+0).
constexpr uint32_t kRegenGroupMagic = 1;
constexpr uint32_t kRegenGroupCombat = 2;
constexpr uint32_t kRegenGroupCombo = 3; // handled by the combo branch
// Arts regenerating in the magic group whatever their entry says
// (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze).
inline bool InMagicRegenGroup(uint32_t group, uint16_t art) {
return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMeteor ||
art == kArtKanone;
}
// Art cooldowns ("regeneration"): a vector of 0x16-byte entries at +0x102 /
// +0x106 (begin / end) of cCreatureArts:
// +0 u32 regeneration group (kRegenGroup*)
// +4 u16 art id
// +6 u8 level, +7 u8 bonus levels (the total follows from both)
// +0xa float total
// +0xe float speed factor
// +0x12 float remaining (ready at <= 0.01, cArtCooldowns::IsReady 0x580cb0)
// Every creature update (0x562998) lowers remaining by
// speed factor * rate * seconds,
// rate = cCreatureArts +0xee for the magic group and Energieblitze, +0xf2
// otherwise.
constexpr size_t kCooldownsBegin = 0x102;
constexpr size_t kCooldownsEnd = 0x106;
constexpr size_t kCooldownSize = 0x16;
constexpr size_t kCooldownGroup = 0x0;
constexpr size_t kCooldownArt = 0x4;
constexpr size_t kCooldownLevel = 0x6; // u8
constexpr size_t kCooldownBonusLevels = 0x7; // u8, from items etc.
constexpr size_t kCooldownTotal = 0xa;
constexpr size_t kCooldownSpeed = 0xe;
constexpr size_t kCooldownRemaining = 0x12;
constexpr size_t kArtsRegenRateMagic = 0xee; // float, units per second
constexpr size_t kArtsRegenRateCombat = 0xf2;
constexpr float kArtReadyCooldown = 0.01f;
inline float& Float(uint8_t* p, size_t offset) {
return *reinterpret_cast<float*>(p + offset);
}
// The cooldown entry of art `art` (cArtCooldowns::Find 0x581c00, which also
// matches an art's variants) in cCreatureArts `arts`, or null.
inline uint8_t* FindCooldownEntry(uint8_t* arts, uint16_t art) {
using FindFn = uint32_t(__fastcall*)(void*, void*, uint32_t);
const uint32_t index = reinterpret_cast<FindFn>(0x00581c00)(arts, nullptr, art);
auto* begin = *reinterpret_cast<uint8_t**>(arts + kCooldownsBegin);
auto* end = *reinterpret_cast<uint8_t**>(arts + kCooldownsEnd);
if (!begin || index >= static_cast<uint32_t>(end - begin) / kCooldownSize) {
return nullptr;
}
return begin + index * kCooldownSize;
}
struct ArtCooldown {
float remaining = 0.0f; // in the game's units
float total = 0.0f;
float seconds = 0.0f; // remaining at the current regeneration rate
};
inline ArtCooldown GetArtCooldown(void* creature, uint16_t art) {
auto* arts = static_cast<uint8_t*>(creature) + kCreatureArts;
uint8_t* entry = FindCooldownEntry(arts, art);
if (!entry) {
return {};
}
const bool magic = *reinterpret_cast<uint32_t*>(entry + kCooldownGroup) == kRegenGroupMagic ||
*reinterpret_cast<uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
const float perSecond =
Float(entry, kCooldownSpeed) * Float(arts, magic ? kArtsRegenRateMagic : kArtsRegenRateCombat);
const float remaining = Float(entry, kCooldownRemaining);
return {remaining, Float(entry, kCooldownTotal), perSecond > 0.0f ? remaining / perSecond : 0.0f};
}
// cCreature::IsAlly (0x548f20): whether object `id` is an ally of `creature`.
inline bool IsAlly(void* creature, uint32_t id) {
using Fn = char(__fastcall*)(void*, void*, uint32_t);
+53
View File
@@ -0,0 +1,53 @@
#include "game/engine/fx.h"
#include <vector>
#include "re/hook.h"
namespace game {
namespace {
std::vector<FxEventFn>& Callbacks() {
static std::vector<FxEventFn> callbacks;
return callbacks;
}
std::vector<FxEventFn>& BeforeCallbacks() {
static std::vector<FxEventFn> callbacks;
return callbacks;
}
} // namespace
void OnFxEvent(FxEventFn callback) {
Callbacks().push_back(callback);
}
void BeforeFxEvent(FxEventFn callback) {
BeforeCallbacks().push_back(callback);
}
uint32_t CreateObjectAt(uint32_t type, const void* position, uint32_t link) {
using Fn = uint32_t(__fastcall*)(void* manager, void* edx, uint32_t, const void*, uint32_t, uint32_t, uint32_t);
void* manager = *reinterpret_cast<void**>(0x00ad3bc0);
return manager ? reinterpret_cast<Fn>(0x005fbaa0)(manager, nullptr, type, position, link, 0, 0) : 0;
}
// cObjectFX::ReceiveEvent (0x5a37b0, vf18).
void __fastcall ObjectFX_ReceiveEvent(void* self, void* edx, uint32_t* event);
RE_HOOK(0x005a37b0, ObjectFX_ReceiveEvent);
void __fastcall ObjectFX_ReceiveEvent(void* self, void* edx, uint32_t* event) {
if (self && event) {
for (FxEventFn callback : BeforeCallbacks()) {
callback(static_cast<uint8_t*>(self), event);
}
}
ObjectFX_ReceiveEvent_original(self, edx, event);
if (self && event) {
for (FxEventFn callback : Callbacks()) {
callback(static_cast<uint8_t*>(self), event);
}
}
}
} // namespace game
+42
View File
@@ -0,0 +1,42 @@
#pragma once
// Effect objects (cObjectFX) and the events that set them up (cEventFX).
#include <cstddef>
#include <cstdint>
namespace game {
constexpr uint32_t kObjectFXVtable = 0x0088f99c;
constexpr size_t kObjectType = 0x10; // cObject: the type CreateAt was given
// cEventFX (0x7c bytes, cEventFX::Construct 0x41e0a0): dwords
// [1] code, [2] owner / caster id, [3] target id, [4..7] position,
// [8..10] and [11..13] two strings (by value), [14], [15],
// [16..29] a copied 14-dword block, [30].
// Codes seen: 0x42 attach an aura to its owner (art at [15]); 0x60 a
// Blitzschlag hit (type 872 object: caster, target, damage [14], damage block
// [16..29]; [14] the hits left in its chain, counted down per jump).
constexpr size_t kFxEventDwords = 0x1f;
constexpr uint32_t kFxEventVtable = 0x0088e9b4;
constexpr uint32_t kFxEventAttachAura = 0x42;
constexpr uint32_t kFxEventBoltHit = 0x60;
// Called after cObjectFX::ReceiveEvent (0x5a37b0) handled `event` (while its
// strings are still alive). Register at startup (static initialisation).
using FxEventFn = void (*)(uint8_t* fx, uint32_t* event);
void OnFxEvent(FxEventFn callback);
// Called before the object handles `event` (may change it).
void BeforeFxEvent(FxEventFn callback);
// cObjectManager::CreateAt (0x5fbaa0): a new object of `type` at `position`
// (4 dwords), linked to object `link` (e.g. the previous lightning segment);
// its id.
uint32_t CreateObjectAt(uint32_t type, const void* position, uint32_t link = 0);
// Sends `event` to object `object` (its vf18).
inline void SendEvent(void* object, void* event) {
using Fn = void(__fastcall*)(void*, void*, void*);
reinterpret_cast<Fn>((*reinterpret_cast<void***>(object))[0x18 / 4])(object, nullptr, event);
}
} // namespace game
+29
View File
@@ -1,10 +1,31 @@
#pragma once
// Game objects, RTTI casts and the event kernel: calls shared by the mods.
#include <cstddef>
#include <cstdint>
namespace game {
// Object ids 1..0x10 are the player heroes.
constexpr uint32_t kPlayerHeroIdEnd = 0x11;
inline bool IsPlayerHeroId(uint32_t id) {
return id != 0 && id < kPlayerHeroIdEnd;
}
// cObject +0x14: flags; dead objects (bit 0x10) are dropped by the object
// manager (saving marks effect objects dead, cObjectManager::Place refuses them).
constexpr size_t kObjectFlags = 0x14;
constexpr uint32_t kObjectDead = 0x10;
// The creatures near the player (g_nearbyCreatures 0xad1c60: {object id, ?}
// x g_nearbyCreatureCount 0xad1b48), as the game uses them for aura targets.
inline int NearbyCreatureCount() {
return *reinterpret_cast<const int*>(0x00ad1b48);
}
inline uint32_t NearbyCreatureId(int index) {
return *reinterpret_cast<const uint32_t*>(0x00ad1c60 + index * 8);
}
// MSVC RTTI type descriptors (for DynamicCast).
constexpr uint32_t kTypeObject = 0x008e9648;
constexpr uint32_t kTypeCreature = 0x008e9660;
@@ -29,6 +50,14 @@ inline void* GetCreature(uint32_t id) {
return DynamicCast(GetObjectById(id), kTypeObject, kTypeCreature);
}
// cObjectManager::GetPlayerHero (0x603e90): the local player's hero (a
// cCreature), or null.
inline void* GetPlayerHero() {
using Fn = void*(__fastcall*)(void* manager, void* edx);
void* manager = *reinterpret_cast<void**>(0x00ad3bc0);
return manager ? reinterpret_cast<Fn>(0x00603e90)(manager, nullptr) : nullptr;
}
// cKernel::PostEvent (0x808930) on Kernel_Instance() (0x808490): the kernel
// copies the event and dispatches it (key events right away).
inline void PostEvent(void* event) {
+36
View File
@@ -0,0 +1,36 @@
#include "game/engine/orders.h"
#include <intrin.h>
#include <vector>
#include "re/hook.h"
namespace game {
namespace {
std::vector<CreatureOrderFn>& Callbacks() {
static std::vector<CreatureOrderFn> callbacks;
return callbacks;
}
} // namespace
void OnCreatureOrder(CreatureOrderFn callback) {
Callbacks().push_back(callback);
}
// cCreature::ReceiveOrder (0x52e400, vf18): every order a creature gets.
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order);
RE_HOOK(0x0052e400, Creature_ReceiveOrder);
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) {
if (self && order) {
void* caller = _ReturnAddress();
for (CreatureOrderFn callback : Callbacks()) {
callback(self, *order, caller);
}
}
Creature_ReceiveOrder_original(self, edx, order);
}
} // namespace game
+99
View File
@@ -0,0 +1,99 @@
#pragma once
// Creature orders (cEvent_creature) and the engine fields around them
// (docs/wasd.md, docs/auto-attack.md).
#include <cstddef>
#include <cstdint>
#include "game/engine/objects.h"
namespace game {
constexpr uint32_t kEventCreatureVtable = 0x0088e95c;
// cEngine fields.
constexpr size_t kEngineFlags = 0x54; // 0x10 mouse button held (hold-to-move), 0x4000 input blocked
constexpr size_t kEngineControlledId = 0x13c; // object id of the controlled creature
// Order commands.
constexpr uint32_t kOrderWalk = 4;
constexpr uint32_t kOrderAttack = 7; // left click on a creature: one attack
constexpr uint32_t kOrderUseArt = 0x105;
// cEvent_creature (0x44 bytes). The meaning of +0x10..+0x20 depends on the
// command:
// walk (4): +0x14 2 = ground point at +0x18 / +0x1c (0: stop),
// +0x20 1 = re-aim while a mouse button is held
// attack (7): +0x18 target object id, +0x1c / +0x20 its position
// use art (105): +0x10 target object id (0: ground / self), +0x18 / +0x1c
// ground point, +0x30 art id
// hit (5): +8 sender, +0xc / +0x10 target, +0x1c attacker id (the
// damage code credits it: life leech, ...; aura hits leave
// it 0), +0x20 four u16 damage slots (physical, ...),
// +0x30 float damage factor
struct CreatureOrder {
uint32_t vtable = kEventCreatureVtable;
uint32_t command = 0;
uint32_t unknown08[2] = {};
uint32_t target = 0; // +0x10
uint32_t groundTarget = 0; // +0x14
int32_t a = 0; // +0x18
int32_t b = 0; // +0x1c
int32_t c = 0; // +0x20
uint32_t unknown24[3] = {};
uint32_t art = 0; // +0x30
uint32_t unknown34[4] = {};
};
static_assert(sizeof(CreatureOrder) == 0x44);
static_assert(offsetof(CreatureOrder, art) == 0x30);
// cEngine::SendCreatureOrder (0x6170d0): snaps ground targets, then
// creature->vf18 (cCreature::ReceiveOrder 0x52e400).
inline void SendCreatureOrder(uint8_t* engine, void* creature, CreatureOrder& order) {
using Fn = void(__fastcall*)(void* engine, void* edx, void* creature, void* event);
reinterpret_cast<Fn>(0x006170d0)(engine, nullptr, creature, &order);
}
inline void* ControlledCreature(uint8_t* engine) {
return GetCreature(*reinterpret_cast<uint32_t*>(engine + kEngineControlledId));
}
// Called before cCreature::ReceiveOrder (0x52e400) handles an order (any
// creature; `caller` is the code that sent it). Register at startup.
using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller);
void OnCreatureOrder(CreatureOrderFn callback);
// Creature fields.
constexpr size_t kCreatureId = 0xc;
constexpr size_t kCreatureX = 0x1c;
constexpr size_t kCreatureY = 0x20;
// +0x150: low word the current action (0 none, 1 art, 3 attack, 8 ...), high
// word its phase; 0x10000 when standing idle.
constexpr size_t kCreatureAction = 0x150;
// cCreature::IsAlive (0x548ef0): not dying (+0x150 == 6), not dead (+0xfc ==
// 9), health (+0x4d8) left.
inline bool IsAlive(void* creature) {
using Fn = char(__fastcall*)(void*, void*);
return reinterpret_cast<Fn>(0x00548ef0)(creature, nullptr) != 0;
}
// cCreature::IsHostileTo (0x548dd0, (creature)): `other` is alive and its
// faction is hostile to this creature's (0x4226f0 / 0x423450). NPCs such as
// traders are not hostile (and not allies either).
inline bool IsHostileTo(void* creature, void* other) {
using Fn = char(__fastcall*)(void*, void*, void*);
return other && reinterpret_cast<Fn>(0x00548dd0)(creature, nullptr, other) != 0;
}
// cCreature::DistanceTo (0x536f20, (object id)).
inline uint32_t DistanceTo(void* creature, uint32_t id) {
using Fn = uint32_t(__fastcall*)(void*, void*, uint32_t);
return reinterpret_cast<Fn>(0x00536f20)(creature, nullptr, id);
}
inline int32_t CreatureField(const void* creature, size_t offset) {
return *reinterpret_cast<const int32_t*>(static_cast<const uint8_t*>(creature) + offset);
}
} // namespace game
+66
View File
@@ -0,0 +1,66 @@
#pragma once
// UI controls' screen rects and text drawing with the game's fonts.
#include <cstdint>
namespace game {
// Screen rect as the UI uses it (cUI_Control2::GetScreenRect 0x731c70,
// constructed by 0x6a55f0).
struct UiRect {
int32_t x;
int32_t y;
int16_t w;
int16_t h;
};
static_assert(sizeof(UiRect) == 0xc);
inline UiRect ControlScreenRect(void* control) {
using Fn = UiRect*(__fastcall*)(void*, void*, UiRect*);
UiRect rect = {};
reinterpret_cast<Fn>(0x00731c70)(control, nullptr, &rect);
return rect;
}
// Text flags of the fonts' draw method (cFontTTF2::vf18 0x64eb20; the text
// starts at the top of the rect, there is no vertical alignment).
constexpr uint32_t kTextCenter = 1; // horizontally
constexpr uint32_t kTextRight = 4;
// FontManager::Instance (0x649d10) -> GetFont (0x649de0): font `font`, or null.
inline void* GetFont(uint16_t font) {
using InstanceFn = void*(__cdecl*)();
using GetFontFn = void*(__fastcall*)(void*, void*, uint32_t);
void* fonts = reinterpret_cast<InstanceFn>(0x00649d10)();
return fonts ? reinterpret_cast<GetFontFn>(0x00649de0)(fonts, nullptr, font) : nullptr;
}
// The height of a line of font `font`, 0 if unknown.
// TODO(measure): cFontTTF2 vf28 (0x6518e0) returns +0x200c6, which may be it;
// +0x200ca (set by vf44) looked like 0. Logged by the diagnostics.
inline int FontLineHeight(uint16_t font) {
auto* f = static_cast<uint8_t*>(GetFont(font));
return f ? *reinterpret_cast<const uint16_t*>(f + 0x200c6) : 0;
}
// `rect` with its top moved so one line of font `font` sits in its middle.
inline UiRect CenterLineVertically(uint16_t font, UiRect rect) {
rect.y += (rect.h - FontLineHeight(font)) / 2;
return rect;
}
// Draws `text` into `rect` with font `font` (font vf18 (renderer, rect, text,
// ARGB colour, flags), as cUI_StaticText64::vf14 does). `renderer` is the
// argument of the UI's render methods (vf14).
inline void DrawText(void* renderer, uint16_t font, UiRect rect, const wchar_t* text, uint32_t color,
uint32_t flags) {
void* f = GetFont(font);
if (!f) {
return;
}
using DrawFn = void(__fastcall*)(void*, void*, void* renderer, UiRect*, const wchar_t*, uint32_t, uint32_t);
auto draw = reinterpret_cast<DrawFn>((*reinterpret_cast<void***>(f))[0x18 / 4]);
draw(f, nullptr, renderer, &rect, text, color, flags);
}
} // namespace game
+103
View File
@@ -0,0 +1,103 @@
// Arts regenerate on their own: casting an art no longer puts the other arts
// of its group into regeneration.
//
// After an art is used, the cast code computes its regeneration
// (cCreatureArts::RegenFor 0x582710, (art) -> amount) and passes the amount
// to cCreatureArts::AddGroupRegen (0x562330, (amount, group, filter)), which
// adds it to the remaining time of every cooldown entry of that group (1 or
// 2), capped at each entry's total. For player heroes the amount now goes to
// the cast art's entry only. AddGroupRegen calls that do not directly follow
// a RegenFor (other callers, negative amounts, the combo branch) are left to
// the game. See docs/art-regen.md.
#include <cstdint>
#include "game/engine/arts.h"
#include "game/engine/orders.h"
#include "re/hook.h"
namespace mods::artregen {
namespace {
// The art whose regeneration RegenFor computed last (the cast code calls
// AddGroupRegen right after it, on the same thread).
struct Pending {
void* arts = nullptr;
uint16_t art = 0;
};
thread_local Pending g_pending;
uint8_t* Owner(uint8_t* arts) {
return *reinterpret_cast<uint8_t**>(arts + game::kArtsCreature);
}
bool IsPlayerHero(const uint8_t* creature) {
return game::IsPlayerHeroId(*reinterpret_cast<const uint32_t*>(creature + game::kCreatureId));
}
} // namespace
void OnRegenFor(void* arts, uint32_t art) {
g_pending = {arts, static_cast<uint16_t>(art)};
}
// AddGroupRegen for the cast art only; false to let the game handle the call.
bool AddToCastArt(void* self, float amount, uint32_t group, uint32_t filter) {
const Pending pending = g_pending;
g_pending = {};
auto* arts = static_cast<uint8_t*>(self);
// Only the game's normal branch (no combo running, not the combo group),
// without a filter.
uint8_t* creature = Owner(arts);
if (pending.arts != self || pending.art == 0 || amount < 0.0f || group == game::kRegenGroupCombo ||
filter != 0 || !creature || creature[game::kCreatureComboStep] != 0 || arts[game::kArtsComboRunning] != 0 ||
!IsPlayerHero(creature)) {
return false;
}
uint8_t* entry = game::FindCooldownEntry(arts, pending.art);
if (!entry) {
return true; // nothing to regenerate; the other arts stay untouched
}
// As the game: the flags shorten one group's regeneration to 80 %.
const bool magic = game::InMagicRegenGroup(*reinterpret_cast<uint32_t*>(entry + game::kCooldownGroup),
*reinterpret_cast<uint16_t*>(entry + game::kCooldownArt));
const uint32_t flags = *reinterpret_cast<uint32_t*>(arts + game::kArtsFlags);
if ((magic && (flags & game::kArtsFlagFasterMagicRegen)) ||
(!magic && (flags & game::kArtsFlagFasterCombatRegen))) {
amount *= 0.8f;
}
float& remaining = game::Float(entry, game::kCooldownRemaining);
remaining += amount;
const float total = game::Float(entry, game::kCooldownTotal);
if (remaining > total) {
remaining = total;
}
if (remaining < 0.0f) {
remaining = 0.0f;
}
return true;
}
} // namespace mods::artregen
namespace game {
// cCreatureArts::RegenFor (0x582710, ret 4): the regeneration of art `art`.
float __fastcall Arts_RegenFor(void* self, void* edx, uint32_t art);
RE_HOOK(0x00582710, Arts_RegenFor);
float __fastcall Arts_RegenFor(void* self, void* edx, uint32_t art) {
mods::artregen::OnRegenFor(self, art & 0xffff);
return Arts_RegenFor_original(self, edx, art);
}
// cCreatureArts::AddGroupRegen (0x562330, ret 0xc).
uint32_t __fastcall Arts_AddGroupRegen(void* self, void* edx, float amount, uint32_t group, uint32_t filter);
RE_HOOK(0x00562330, Arts_AddGroupRegen);
uint32_t __fastcall Arts_AddGroupRegen(void* self, void* edx, float amount, uint32_t group, uint32_t filter) {
if (self && mods::artregen::AddToCastArt(self, amount, group, filter)) {
return 0;
}
return Arts_AddGroupRegen_original(self, edx, amount, group, filter);
}
} // namespace game
+11 -24
View File
@@ -11,46 +11,33 @@
#include <cstdint>
#include "game/engine/arts.h"
#include "game/engine/orders.h"
#include "re/hook.h"
namespace game {
namespace {
constexpr uint32_t kOrderUseArt = 0x105;
constexpr uint32_t kPlayerIds = 0x11; // object ids below this are player heroes
// cEvent_creature fields used here.
constexpr size_t kOrderCommand = 0x4;
constexpr size_t kOrderTarget = 0x10; // object id
constexpr size_t kOrderArt = 0x30; // art id (cCreature::ResolveArtId)
uint32_t& Field(void* event, size_t offset) {
return *reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(event) + offset);
}
void RetargetSelfArt(void* creature, void* event) {
if (!creature || !event || Field(event, kOrderCommand) != kOrderUseArt) {
void RetargetSelfArt(void* creature, CreatureOrder* order) {
if (!creature || !order || order->command != kOrderUseArt || order->target == 0) {
return;
}
const uint32_t target = Field(event, kOrderTarget);
const uint32_t casterId = *reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(creature) + 0xc);
if (target == 0 || casterId >= kPlayerIds) {
if (!IsPlayerHeroId(static_cast<uint32_t>(CreatureField(creature, kCreatureId)))) {
return;
}
if (ArtLaunchType(static_cast<uint16_t>(Field(event, kOrderArt))) == LaunchType::SelfOrAlly &&
!IsAlly(creature, target)) {
Field(event, kOrderTarget) = 0;
if (ArtLaunchType(static_cast<uint16_t>(order->art)) == LaunchType::SelfOrAlly &&
!IsAlly(creature, order->target)) {
order->target = 0;
}
}
} // namespace
// cEngine::SendCreatureOrder (0x6170d0): orders for the controlled creature.
void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, void* event);
void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, CreatureOrder* order);
RE_HOOK(0x006170d0, Engine_SendCreatureOrder);
void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, void* event) {
RetargetSelfArt(creature, event);
Engine_SendCreatureOrder_original(self, edx, creature, event);
void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, CreatureOrder* order) {
RetargetSelfArt(creature, order);
Engine_SendCreatureOrder_original(self, edx, creature, order);
}
} // namespace game
+14 -34
View File
@@ -12,62 +12,42 @@
#include <cstdint>
#include <cstring>
#include "game/engine/arts.h"
#include "game/engine/events.h"
#include "game/engine/fx.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/log.h"
namespace mods::aura_restore {
namespace {
constexpr uint32_t kAuraEffectType = 842; // cObjectFX: aura following its owner
constexpr uint16_t kAuraArts[] = {0x3f7, 0x3f8}; // Heldenmut, Klingenblick
constexpr uint32_t kPlayerIds = 0x11; // object ids below this are player heroes
constexpr uint16_t kAuraArts[] = {game::kArtHeldenmut, game::kArtKlingenblick};
constexpr int kTicksAfterLoad = 5; // wait for the load to finish (0.5 s)
// Active effects of a creature: vector of 0x20-byte entries at
// (creature + 0x3a8) + 0x11a / + 0x11e (cCreatureArts, AddEffect 0x582c10).
constexpr size_t kCreatureArts = 0x3a8;
constexpr size_t kEffectsBegin = 0x11a;
constexpr size_t kEffectsEnd = 0x11e;
constexpr size_t kEffectSize = 0x20;
constexpr size_t kEffectKind = 0x0;
constexpr size_t kEffectRemaining = 0x18; // float, seconds
constexpr size_t kEffectArt = 0x1c; // u16
constexpr uint32_t kKindCooldown = 0x22; // the art's cooldown (0x539ce0), not the buff
// cEventFX (0x7c bytes, built by 0x41e0a0).
constexpr uint32_t kEventFXVtable = 0x0088e9b4;
constexpr uint32_t kFxAttach = 0x42;
using namespace game; // effect layout (arts.h)
int g_ticksUntilRestore = -1;
// cObjectManager::CreateAt (0x5fbaa0): object `type` at `position`; its id.
uint32_t CreateAt(uint32_t type, const void* position) {
using Fn = uint32_t(__fastcall*)(void* manager, void* edx, uint32_t, const void*, uint32_t, uint32_t,
uint32_t);
void* manager = *reinterpret_cast<void**>(0x00ad3bc0);
return manager ? reinterpret_cast<Fn>(0x005fbaa0)(manager, nullptr, type, position, 0, 0, 0) : 0;
}
void AttachAura(uint8_t* hero, uint16_t art, float remaining) {
const uint32_t heroId = *reinterpret_cast<uint32_t*>(hero + 0xc);
const uint32_t id = CreateAt(kAuraEffectType, hero + 0x18); // the hero's position
const uint32_t heroId = *reinterpret_cast<uint32_t*>(hero + kCreatureId);
const uint32_t id = CreateObjectAt(kAuraEffectType, hero + 0x18); // the hero's position
auto* effect = static_cast<uint8_t*>(game::GetObjectById(id));
if (!effect) {
return;
}
uint32_t event[0x1f] = {};
event[0] = kEventFXVtable;
event[1] = kFxAttach;
uint32_t event[kFxEventDwords] = {};
event[0] = kFxEventVtable;
event[1] = kFxEventAttachAura;
event[2] = heroId;
std::memcpy(&event[11], &remaining, 4);
event[15] = art;
using ReceiveFn = int(__fastcall*)(void*, void*, void*);
reinterpret_cast<ReceiveFn>((*reinterpret_cast<void***>(effect))[6])(effect, nullptr, event);
SendEvent(effect, event);
re::Log("aura: restored art %x on hero %u (%.1f s left)", art, heroId, remaining);
}
// Longest remaining time of the buffs (any kind but the cooldown) of `art`.
// Longest remaining time of the buffs (any kind but the lock) of `art`.
float RemainingBuff(uint8_t* hero, uint16_t art) {
uint8_t* arts = hero + kCreatureArts;
auto* entry = *reinterpret_cast<uint8_t**>(arts + kEffectsBegin);
@@ -76,7 +56,7 @@ float RemainingBuff(uint8_t* hero, uint16_t art) {
for (; entry && entry < end; entry += kEffectSize) {
float remaining;
std::memcpy(&remaining, entry + kEffectRemaining, 4);
if (*reinterpret_cast<uint32_t*>(entry + kEffectKind) != kKindCooldown &&
if (*reinterpret_cast<uint32_t*>(entry + kEffectKind) != kEffectKindArtLock &&
*reinterpret_cast<uint16_t*>(entry + kEffectArt) == art && remaining > longest) {
longest = remaining;
}
@@ -85,7 +65,7 @@ float RemainingBuff(uint8_t* hero, uint16_t art) {
}
void RestoreAuras() {
for (uint32_t id = 1; id < kPlayerIds; ++id) {
for (uint32_t id = 1; id < kPlayerHeroIdEnd; ++id) {
auto* hero = static_cast<uint8_t*>(game::GetCreature(id));
if (!hero) {
continue;
+120
View File
@@ -0,0 +1,120 @@
// Periodic aura hits strike every creature in range, not one.
//
// Effects of kind 0x12 (Blicke wie Klingen) are handled in the creature arts
// update (branch at 0x562f08, local player only): every second (timer at
// effect +8) it walks the creatures near the player (g_nearbyCreatures,
// 0xad1c60, 8 bytes each, count g_nearbyCreatureCount 0xad1b48), keeps one of
// those within (cCreatureArts +0x56 + 20) * 10 of the owner (the first,
// replaced by later ones with 1/3 chance) and sends it one hit (cEvent_creature
// 5, the effect's value as one random damage type) through its ReceiveOrder.
// The damage is made physical (user's choice: no random fire/magic/poison),
// and the hits name the owner as their attacker, so the game applies its life
// leech (with beam and sound) as for weapon hits.
//
// That call (`mov eax, [ecx]; call [eax+18h]` at 0x56323b) now goes to
// HitAllInRange: the chosen creature gets its hit, then every other creature
// of the list within the same range gets the same hit. Rebalancing request of
// 2026-10-15, see docs/aura-hits.md.
#include <cstdint>
#include <cstring>
#include "game/engine/arts.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::aurahits {
namespace {
constexpr uint32_t kHitCall = 0x0056323b; // mov eax, [ecx]; call [eax+18h]
constexpr uint32_t kEffectKindPeriodicHit = 0x12;
constexpr size_t kArtsHitRangeLevel = 0x56; // u16: range = (value + 20) * 10
// The hit's damage: four u16 slots at +0x20 (physical, fire, magic,
// poison); the game fills one of them at random.
constexpr size_t kHitDamage = 0x20;
constexpr int kDamageTypes = 4;
void MakePhysical(game::CreatureOrder& hit) {
auto* damage = reinterpret_cast<uint16_t*>(reinterpret_cast<uint8_t*>(&hit) + kHitDamage);
uint32_t total = 0;
for (int i = 0; i < kDamageTypes; ++i) {
total += damage[i];
damage[i] = 0;
}
damage[0] = static_cast<uint16_t>(total > 0xfffe ? 0xfffe : total);
}
void Send(void* creature, game::CreatureOrder* order) {
using ReceiveFn = void(__fastcall*)(void*, void*, void*);
reinterpret_cast<ReceiveFn>((*reinterpret_cast<void***>(creature))[0x18 / 4])(creature, nullptr, order);
}
// Sends the hit with the owner as its attacker (order +0x1c): the game leaves
// it 0 for aura hits, so the damage code (0x56aff0) does not apply the
// attacker's life leech, its beam and sound. With the attacker set, it does.
void Hit(void* owner, void* creature, game::CreatureOrder* order) {
order->b = game::CreatureField(owner, game::kCreatureId);
Send(creature, order);
}
} // namespace
// (ecx = the chosen creature, the hit, cCreatureArts in ebp, the effect's
// offset in its list in ebx)
void __fastcall HitAllInRange(void* chosen, void*, game::CreatureOrder* hit, uint8_t* arts, uint32_t effect) {
auto* effects = *reinterpret_cast<uint8_t**>(arts + game::kEffectsBegin);
void* owner = *reinterpret_cast<void**>(arts + game::kArtsCreature);
if (!effects || !owner ||
*reinterpret_cast<uint32_t*>(effects + effect + game::kEffectKind) != kEffectKindPeriodicHit) {
Send(chosen, hit);
return;
}
MakePhysical(*hit);
const game::CreatureOrder original = *hit; // the receiver may change it
Hit(owner, chosen, hit);
const uint32_t range = (*reinterpret_cast<uint16_t*>(arts + kArtsHitRangeLevel) + 20u) * 10u;
const auto chosenId = static_cast<uint32_t>(game::CreatureField(chosen, game::kCreatureId));
const int count = game::NearbyCreatureCount();
for (int i = 0; i < count; ++i) {
const uint32_t id = game::NearbyCreatureId(i);
// Player heroes need the game's PvP checks (0x4d4be0): left out.
if (id == chosenId || game::IsPlayerHeroId(id) || game::DistanceTo(owner, id) >= range) {
continue;
}
void* creature = game::GetCreature(id);
if (!game::IsHostileTo(owner, creature)) {
continue;
}
game::CreatureOrder copy = original;
Hit(owner, creature, &copy);
}
}
namespace {
__declspec(naked) void HitAllInRangeThunk() {
__asm {
push ebx // effect offset
push ebp // cCreatureArts
push dword ptr [esp+0Ch] // the hit (the call's argument)
call HitAllInRange
ret 4
}
}
void Startup() {
const auto target = reinterpret_cast<uintptr_t>(&HitAllInRangeThunk);
const int32_t rel = static_cast<int32_t>(target - (kHitCall + 5));
uint8_t to[5] = {0xe8};
std::memcpy(to + 1, &rel, 4);
const uint8_t from[5] = {0x8b, 0x01, 0xff, 0x50, 0x18};
const bool ok = re::ReplaceCode(kHitCall, from, to, sizeof(to));
re::Log("aurahits: periodic aura hits strike all creatures in range%s", ok ? "" : " (patch FAILED)");
}
RE_STARTUP(AuraHitsStartup, Startup);
} // namespace
} // namespace mods::aurahits
+136
View File
@@ -0,0 +1,136 @@
// Auras and buffs can be cast again while they are active; the new cast
// renews their remaining time instead of adding a second effect.
//
// cCreature::IsValidArtTarget (0x51d680) rejects lasting buffs (Heldenmut,
// Klingenblick, ... : the arts that branch to 0x51d8b1) on a target that
// already has an effect of the art (cCreatureArts::HasEffectOfArt 0x5829c0).
// That test is skipped. The cast then adds its effects again through
// cCreatureArts::AddEffect (0x582c10); for a buff art (launch type
// SelfOrAlly) on a player hero, an effect whose kind and art are already
// present only gets its remaining time set to the new duration. The art's
// cooldown applies as before. The aura's visual (an effect object each cast
// creates) is not doubled: the old one is flagged dead when the new one
// attaches. See docs/aura-recast.md.
#include <cstddef>
#include <cstdint>
#include "game/engine/arts.h"
#include "game/engine/fx.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::aurarecast {
namespace {
// IsValidArtTarget: `call HasEffectOfArt; test al, al; je valid` (0x51d8b6),
// the `je` (0f 84 17 02 00 00) made a `jmp` to the same place (e9 18 02 00
// 00 90).
constexpr re::CodeReplacement kSkipActiveCheck[] = {
{0x0051d8bd, {0x0f, 0x84, 0x17, 0x02, 0x00, 0x00}, {0xe9, 0x18, 0x02, 0x00, 0x00, 0x90}, 6},
};
using namespace game; // effect layout (arts.h)
constexpr size_t kFxOwner = 0x68; // set from the attach event
void Startup() {
const bool ok = re::ReplaceAll(kSkipActiveCheck) == 0;
re::Log("aurarecast: active buffs can be recast%s", ok ? "" : " (patch FAILED)");
}
RE_STARTUP(AuraRecastStartup, Startup);
} // namespace
// An effect of `kind` / `art` that `arts` already has, if it is a buff of a
// player hero: renew it to `duration` and report true.
bool Renew(uint8_t* arts, uint32_t kind, float duration, uint32_t art) {
art &= 0xffff;
const auto heroId = static_cast<uint32_t>(game::CreatureField(arts - game::kCreatureArts, game::kCreatureId));
if (art == game::kNoArt || !game::IsPlayerHeroId(heroId) ||
game::ArtLaunchType(static_cast<uint16_t>(art)) != game::LaunchType::SelfOrAlly) {
return false;
}
auto* entry = *reinterpret_cast<uint8_t**>(arts + kEffectsBegin);
auto* end = *reinterpret_cast<uint8_t**>(arts + kEffectsEnd);
for (; entry && entry < end; entry += kEffectSize) {
if (*reinterpret_cast<uint32_t*>(entry) == kind && *reinterpret_cast<uint16_t*>(entry + kEffectArt) == art) {
*reinterpret_cast<float*>(entry + kEffectRemaining) = duration;
return true;
}
}
return false;
}
// The aura effect object (cObjectFX, attached to its owner by a cEventFX 0x42:
// owner +8, art +0x3c) is created again by every cast. When a new one attaches
// to a player hero for the same art, the old one is flagged dead (object
// +0x14 bit 0x10, as saving does), so the renewed aura shows once.
void OnFxAttach(uint8_t* fx, uint32_t owner, uint32_t art) {
struct Aura {
uint32_t owner;
uint32_t art;
uint32_t fx;
};
static Aura auras[32] = {};
art &= 0xffff;
if (!game::IsPlayerHeroId(owner) || art == 0 || art == game::kNoArt ||
game::ArtLaunchType(static_cast<uint16_t>(art)) != game::LaunchType::SelfOrAlly) {
return;
}
const auto id = *reinterpret_cast<uint32_t*>(fx + game::kCreatureId);
Aura* free = nullptr;
for (Aura& aura : auras) {
if (aura.owner == owner && aura.art == art) {
if (aura.fx != id) {
auto* old = static_cast<uint8_t*>(game::GetObjectById(aura.fx));
// Ids are reused: only the aura object still on this owner.
if (old && *reinterpret_cast<uint32_t*>(old) == game::kObjectFXVtable &&
*reinterpret_cast<uint32_t*>(old + kFxOwner) == owner) {
*reinterpret_cast<uint32_t*>(old + game::kObjectFlags) |= game::kObjectDead;
}
}
aura.fx = id;
return;
}
if (!free && aura.owner == 0) {
free = &aura;
}
}
if (free) {
*free = {owner, art, id};
}
}
namespace {
void OnFxEvent(uint8_t* fx, uint32_t* event) {
if (event[1] == game::kFxEventAttachAura) {
OnFxAttach(fx, event[2], event[15]);
}
}
struct Registration {
Registration() { game::OnFxEvent(&OnFxEvent); }
} g_registration;
} // namespace
} // namespace mods::aurarecast
namespace game {
// cCreatureArts::AddEffect (0x582c10): (kind, ?, value, duration, art,
// network, ?, ?), 1 if added.
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);
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 art, uint32_t a6, uint32_t a7, uint32_t a8) {
if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) {
return 1;
}
return Arts_AddEffect_original(self, edx, kind, a2, value, duration, art, a6, a7, a8);
}
} // namespace game
+179
View File
@@ -0,0 +1,179 @@
// Auto-attack: once the hero attacked or cast an art at an enemy, it keeps
// attacking that enemy with its weapon whenever it would stand idle.
//
// A left click on a creature sends the hero one attack order (cEvent_creature
// 7, target id at +0x18, its position at +0x1c / +0x20); the hero swings once
// and stands again (+0x150 back to 0x10000). Arts are orders 0x105 with the
// target at +0x10. Both are seen in cCreature::ReceiveOrder; their target
// becomes the hero's combat target if it is a living creature and not an ally.
// Every engine tick in which the hero is idle, no mouse button and no skill
// key is held, the attack order a left click sends goes to the target again.
// A walk order (mouse move, WASD, stop) or the target's death ends it.
// Between swings the hero does not go idle at all: the end-of-swing check for
// a held mouse button is answered "held" for the auto-attack target.
// Measured with an order log, see docs/auto-attack.md.
#include <atomic>
#include <cstdint>
#include "game/engine/arts.h"
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "mods/skillkeys.h"
#include "re/hook.h"
#include "re/patch.h"
namespace mods::autoattack {
namespace {
// After an attack order, wait this many ticks for the hero to start it before
// sending another one (it takes about 2 ticks).
constexpr int kStartTicks = 5;
constexpr int32_t kActionAttack = 3; // +0x150 low word
std::atomic<uint32_t> g_target{0};
int g_waitTicks = 0;
bool IsEnemy(void* hero, uint32_t id) {
if (id == 0 || id == static_cast<uint32_t>(game::CreatureField(hero, game::kCreatureId))) {
return false;
}
return game::IsHostileTo(hero, game::GetCreature(id));
}
bool Idle(void* creature) {
return (game::CreatureField(creature, game::kCreatureAction) & 0xffff) == 0;
}
void OnTick(uint8_t* engine) {
const uint32_t id = g_target.load();
if (!id) {
return;
}
void* hero = game::ControlledCreature(engine);
void* target = game::GetCreature(id);
if (!hero || !target || !game::IsAlive(target)) {
g_target = 0;
return;
}
if (!Idle(hero)) {
g_waitTicks = 0;
return;
}
const uint32_t flags = *reinterpret_cast<uint32_t*>(engine + game::kEngineFlags);
if (g_waitTicks > 0 && --g_waitTicks > 0) {
return;
}
if ((flags & 0x4010) != 0 || mods::skillkeys::KeyHeld()) {
return; // mouse button held / input blocked / a skill key casts
}
game::CreatureOrder order;
order.command = game::kOrderAttack;
order.a = static_cast<int32_t>(id);
order.b = game::CreatureField(target, game::kCreatureX);
order.c = game::CreatureField(target, game::kCreatureY);
game::SendCreatureOrder(engine, hero, order);
g_waitTicks = kStartTicks;
}
struct Registration {
Registration() { game::OnEngineTick(&OnTick); }
} g_registration;
// The end of a swing (0x4e6584, after the hit) asks the mouse cursor whether
// the left button is still held (cMouseCursor::IsButtonHeld, call at
// 0x4e65c6; attacker in esi, target creature in ebp): if so the attacker
// swings again right away, otherwise it recovers (+0x152 = 2) and stands.
// While the hero auto-attacks its target, the answer is yes, so swings follow
// each other at the speed of a held button.
constexpr uint32_t kSwingEndButtonCheck = 0x004e65c6;
constexpr uint32_t kIsButtonHeld = 0x00655ab0;
char __fastcall SwingContinues(void* cursor, void*, void* attacker, void* target) {
using IsButtonHeldFn = char(__fastcall*)(void*, void*);
if (reinterpret_cast<IsButtonHeldFn>(kIsButtonHeld)(cursor, nullptr)) {
return 1;
}
const uint32_t id = g_target.load();
void* hero = game::GetPlayerHero();
const uint32_t targetId = target ? static_cast<uint32_t>(game::CreatureField(target, game::kCreatureId)) : 0;
if (!id || !target || attacker != hero || mods::skillkeys::KeyHeld() || targetId != id) {
return 0;
}
return game::IsAlive(target) ? 1 : 0;
}
// Called instead of IsButtonHeld (ecx = the cursor, attacker in esi, target
// in ebp).
__declspec(naked) void SwingContinuesThunk() {
__asm {
push ebp
push esi
call SwingContinues
ret
}
}
// During a swing the attack code also checks the button (call at 0x4e7101)
// and clears the global "attack held" flag (0xab6030) when it is up; the swing
// end only chains while that flag is set (0x4e6641). The flag exists for the
// local player only, so the answer is "held" whenever the hero auto-attacks.
constexpr uint32_t kSwingButtonCheck = 0x004e7101;
char __fastcall AttackHeld(void* cursor, void*) {
using IsButtonHeldFn = char(__fastcall*)(void*, void*);
if (reinterpret_cast<IsButtonHeldFn>(kIsButtonHeld)(cursor, nullptr)) {
return 1;
}
const uint32_t id = g_target.load();
void* hero = game::GetPlayerHero();
if (!id || !hero || mods::skillkeys::KeyHeld() ||
(game::CreatureField(hero, game::kCreatureAction) & 0xffff) != kActionAttack) {
return 0;
}
void* target = game::GetCreature(id);
return target && game::IsAlive(target) ? 1 : 0;
}
void Startup() {
re::RedirectCall(kSwingEndButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&SwingContinuesThunk));
re::RedirectCall(kSwingButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&AttackHeld));
}
RE_STARTUP(AutoAttackStartup, Startup);
} // namespace
namespace {
// An order for the player's hero.
void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
void* hero = game::GetPlayerHero();
if (creature != hero) {
return;
}
switch (order.command) {
case game::kOrderWalk:
g_target = 0;
break;
case game::kOrderAttack:
if (IsEnemy(hero, static_cast<uint32_t>(order.a))) {
g_target = static_cast<uint32_t>(order.a);
}
break;
case game::kOrderUseArt:
if (IsEnemy(hero, order.target)) {
g_target = order.target;
}
break;
}
}
struct OrderRegistration {
OrderRegistration() { game::OnCreatureOrder(&OnOrder); }
} g_orderRegistration;
} // namespace
} // namespace mods::autoattack
+263
View File
@@ -0,0 +1,263 @@
// Blitzschlag as chain lightning (rebalancing request of 2026-10-15).
//
// The game's Blitzschlag already chains (measured, docs/chain-lightning.md):
// the caster's art code creates a bolt object (type 872) at the target and
// sets it up with a cEventFX 0x60 whose [14] is the number of hits left in the
// chain (3 at level 1). The bolt hits its target; then, with hits left, its
// update (0x5b4640..) looks for the next target (0x6005d0 at 0x5b469f: near
// the current target, not the caster, not the current target), draws the arc
// (segment objects) and creates the next bolt with [14] - 1. Without a next
// target the chain ends, and between two enemies it bounces back and forth.
//
// This mod keeps all of that and decides:
// - hits per chain N = 3 + level / 5: the first bolt's [14];
// - the next target: the nearest enemy within the game's range that this
// chain has not hit yet; else another enemy; else the current target again
// (a lone enemy takes all N hits);
// - chains B = 1 + level / 10: B - 1 more chains start at the first target,
// each with a bolt at its own first jump target (created while the first
// bolt is set up, as copies of its event; no arc for that first hop).
#include <cstdint>
#include <cstring>
#include <map>
#include <memory>
#include <vector>
#include <windows.h>
#include "game/engine/arts.h"
#include "game/engine/fx.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::chainlightning {
uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, uint32_t range, uint32_t caster,
uint32_t current, uint8_t* bolt);
namespace {
constexpr uint32_t kBoltObjectType = 0x368; // 872
constexpr uint32_t kJumpSearchCall = 0x005b469f; // in the bolt update
constexpr uint32_t kFindJumpTarget = 0x006005d0; // cObjectManager::FindJumpTarget
constexpr uint32_t kJumpRange = 100000; // the game's argument (a squared distance)
constexpr size_t kObjectOwnerPlayer = 0x39; // u8, copied from the caster like the game does
constexpr size_t kCreaturePosition = 0x18; // 4 dwords, as effects are placed
constexpr size_t kEventHitsLeft = 14;
constexpr DWORD kChainLifetimeMs = 10000;
struct Chain {
std::vector<uint32_t> hit; // targets in order
DWORD started = 0;
};
struct Bolt {
std::shared_ptr<Chain> chain;
uint32_t hitsLeft = 0;
};
// The art the hero cast last (cast orders), for its level; a cast order arms
// the next bolt setup as a chain start.
uint16_t g_heroArt = 0;
bool g_armed = false;
// Bolt objects of our chains, by object id; and the chain the next bolt set
// up at a given target belongs to (filled by NextTarget and branch spawning).
std::map<uint32_t, Bolt> g_bolts;
struct Pending {
std::shared_ptr<Chain> chain;
uint32_t target;
};
std::vector<Pending> g_pending;
void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
if (order.command == game::kOrderUseArt && creature == game::GetPlayerHero()) {
g_heroArt = static_cast<uint16_t>(order.art);
g_armed = true;
}
}
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) {
for (uint32_t i : ids) {
if (i == id) {
return true;
}
}
return false;
}
bool IsTarget(void* caster, uint32_t id) {
return !game::IsPlayerHeroId(id) && game::IsHostileTo(caster, game::GetCreature(id));
}
// The nearest living enemy of `caster` within `range` (squared) of `from`, not
// `current`; one not in `avoid` if there is any. 0 if none.
uint32_t Nearest(void* caster, void* from, uint32_t range, uint32_t current, const std::vector<uint32_t>& avoid) {
uint32_t best = 0;
double bestDistance = 0.0;
bool bestFresh = false;
const int count = game::NearbyCreatureCount();
for (int i = 0; i < count; ++i) {
const uint32_t id = game::NearbyCreatureId(i);
if (id == current || !IsTarget(caster, id)) {
continue;
}
const double d = static_cast<double>(game::DistanceTo(from, id));
if (d * d > static_cast<double>(range)) {
continue;
}
const bool fresh = !Contains(avoid, id);
if (best && ((bestFresh && !fresh) || (bestFresh == fresh && d >= bestDistance))) {
continue;
}
best = id;
bestDistance = d;
bestFresh = fresh;
}
return best;
}
void Forget() {
const DWORD now = GetTickCount();
for (auto it = g_bolts.begin(); it != g_bolts.end();) {
it = now - it->second.chain->started > kChainLifetimeMs ? g_bolts.erase(it) : std::next(it);
}
g_pending.clear();
}
// A bolt object at `target`, set up with a copy of `event` (while its strings
// are alive) with `hitsLeft`.
void SpawnBolt(const uint8_t* original, const uint32_t* event, uint32_t target, uint32_t hitsLeft) {
auto* creature = static_cast<uint8_t*>(game::GetCreature(target));
if (!creature) {
return;
}
auto* bolt = static_cast<uint8_t*>(
game::GetObjectById(game::CreateObjectAt(kBoltObjectType, creature + kCreaturePosition)));
if (!bolt) {
return;
}
bolt[kObjectOwnerPlayer] = original[kObjectOwnerPlayer];
uint32_t copy[game::kFxEventDwords];
std::memcpy(copy, event, sizeof(copy));
copy[3] = target;
std::memcpy(&copy[4], creature + kCreaturePosition, 16);
copy[kEventHitsLeft] = hitsLeft;
game::SendEvent(bolt, copy);
}
void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
if (event[1] != game::kFxEventBoltHit || *reinterpret_cast<uint32_t*>(fx + game::kObjectType) != kBoltObjectType ||
!game::IsPlayerHeroId(event[2])) {
return;
}
const uint32_t id = *reinterpret_cast<uint32_t*>(fx + game::kCreatureId);
const uint32_t target = event[3];
if (g_bolts.count(id)) {
return; // set up again later: already ours
}
// A jump of one of our chains (NextTarget or a branch said where it goes).
for (auto it = g_pending.begin(); it != g_pending.end(); ++it) {
if (it->target == target) {
g_bolts[id] = {it->chain, event[kEventHitsLeft]};
it->chain->hit.push_back(target);
g_pending.erase(it);
return;
}
}
if (!g_armed) {
return;
}
// The first bolt of a cast.
g_armed = false;
void* caster = game::GetCreature(event[2]);
void* first = game::GetCreature(target);
if (!caster || !first) {
return;
}
Forget();
const unsigned level = ArtLevel(caster, g_heroArt);
const uint32_t hits = 3 + level / 5;
const unsigned chains = 1 + level / 10;
event[kEventHitsLeft] = hits;
auto chain = std::make_shared<Chain>();
chain->hit.push_back(target);
chain->started = GetTickCount();
g_bolts[id] = {chain, hits};
// More chains: each starts with a bolt at its own first jump target.
std::vector<uint32_t> taken{target};
for (unsigned c = 1; c < chains && hits > 1; ++c) {
const uint32_t next = Nearest(caster, first, kJumpRange, target, taken);
const uint32_t branchTarget = next ? next : target;
taken.push_back(branchTarget);
auto branch = std::make_shared<Chain>();
branch->hit.push_back(target);
branch->started = chain->started;
g_pending.push_back({branch, branchTarget});
SpawnBolt(fx, event, branchTarget, hits - 1);
}
re::Log("chainlightning: art %x level %u: %u chain(s) of %u hits from %x", g_heroArt, level, chains, hits,
target);
}
// Called instead of the jump search (ecx = the object manager, four stack
// arguments); the bolt is in ebp.
__declspec(naked) void NextTargetThunk() {
__asm {
push ebp // the bolt
push dword ptr [esp+14h] // current target
push dword ptr [esp+14h] // caster
push dword ptr [esp+14h] // range
push dword ptr [esp+14h] // position
call NextTarget
ret 10h
}
}
void Startup() {
const bool ok = re::RedirectCall(kJumpSearchCall, kFindJumpTarget, reinterpret_cast<const void*>(&NextTargetThunk));
re::Log("chainlightning: jump targets%s", ok ? "" : " (patch FAILED)");
}
RE_STARTUP(ChainLightningStartup, Startup);
struct Registration {
Registration() {
game::OnCreatureOrder(&OnOrder);
game::BeforeFxEvent(&BeforeFxEvent);
}
} g_registration;
} // namespace
// The next target of `bolt`'s chain (the game's search for bolts that are
// not ours).
uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, uint32_t range, uint32_t caster,
uint32_t current, uint8_t* bolt) {
const uint32_t id = bolt ? *reinterpret_cast<uint32_t*>(bolt + game::kCreatureId) : 0;
const auto it = g_bolts.find(id);
if (it == g_bolts.end()) {
using FindFn = uint32_t(__fastcall*)(void*, void*, const int32_t*, uint32_t, uint32_t, uint32_t);
return reinterpret_cast<FindFn>(kFindJumpTarget)(manager, nullptr, position, range, caster, current);
}
void* casterCreature = game::GetCreature(caster);
void* from = game::GetCreature(current);
if (!casterCreature || !from || it->second.hitsLeft <= 1) {
return 0;
}
const std::shared_ptr<Chain> chain = it->second.chain;
uint32_t next = Nearest(casterCreature, from, range, current, chain->hit);
if (!next) {
next = current; // nobody else: hit the same enemy again
}
g_pending.push_back({chain, next});
return next;
}
} // namespace mods::chainlightning
+101
View File
@@ -0,0 +1,101 @@
// Esc closes open windows before it opens the game menu.
//
// cUI_Manager::HandleEsc (0x756ba0) closes the ESC menu, the large map, the
// quest book or the inventory, hides two more windows, and otherwise opens
// the ESC menu. With a trader (and others) open it only closed the inventory,
// which the trader window opened again. Now every open window of the table
// below is sent the command the game itself uses to close the quest book: a
// cEventUI2 "hide" (code 0x12, subtype 5, the window's name) to the UI
// manager's event handler. The game's handling stays for the ESC menu itself and when
// none of these windows is open. See docs/escape.md.
#include <cstdint>
#include <cstring>
#include "re/hook.h"
#include "re/log.h"
namespace mods::escape {
namespace {
// cUI_Manager: window pointers by slot (dwords).
struct ClosableWindow {
int slot;
const char* name;
};
constexpr ClosableWindow kClosable[] = {
{0x21, "UI_WND_INVENTORY"}, {0x22, "UI_WND_EQUIPMENT"}, {0x23, "UI_WND_BLACKSMITH"},
{0x24, "UI_WND_MERCHANT"}, {0x25, "UI_WND_MEGAMAP"}, {0x28, "UI_WND_STATS_CHAR"},
{0x2a, "UI_WND_QUESTBOOK"}, {0x2b, "UI_WND_SAVEGAME"}, {0x2d, "UI_WND_PURCHASE"},
{0x2e, "UI_WND_MASTER"}, {0x2f, "UI_WND_OPTIONS"}, {0x30, "UI_WND_CHEST"},
{0x31, "UI_WND_HORSE"}, {0x35, "UI_WND_CUBE"},
};
constexpr int kEscMenuSlot = 0x2c;
constexpr size_t kControlFlags = 0x10; // bit 0: visible
// cEventUI2 as HandleEsc builds it (event at [esp+10h] there).
constexpr uint32_t kEventUiVtable = 0x0088edd0;
constexpr uint32_t kEventUiWindowCommand = 0x12;
constexpr uint32_t kWindowCommandHide = 5;
constexpr size_t kEventUiSize = 0x80;
// Names, written by 0x5904e0 (sender at +0, window at +0x20 of this): measured
// in the UI commands the game sends when a window is closed with its button or
// hotkey (window name at +0x48).
constexpr size_t kEventUiNames = 0x28;
constexpr size_t kEventUiText = 0x68; // a string, 0x423920 / 0x416660
bool Visible(const uint8_t* window) {
return window && (window[kControlFlags] & 1) != 0;
}
// The window command goes to the UI manager's event handler (vf00,
// 0x757120), which routes it by name, as HandleEsc does for the inventory.
// (Sent to the window's own handler it was not understood the same way: the
// trader changed its stock instead of closing.)
void Hide(uint32_t* manager, const char* name) {
alignas(4) uint8_t event[kEventUiSize] = {};
*reinterpret_cast<uint32_t*>(event) = kEventUiVtable;
*reinterpret_cast<uint32_t*>(event + 4) = kEventUiWindowCommand;
*reinterpret_cast<uint32_t*>(event + 8) = kWindowCommandHide;
using SetNamesFn = void(__fastcall*)(void*, void*, const char* window, const char* sender);
reinterpret_cast<SetNamesFn>(0x005904e0)(event + kEventUiNames, nullptr, name, "");
using StringFn = void(__fastcall*)(void*, void*);
reinterpret_cast<StringFn>(0x00423920)(event + kEventUiText, nullptr); // construct
using HandleFn = int(__fastcall*)(void*, void*, void*);
reinterpret_cast<HandleFn>((*reinterpret_cast<void***>(manager))[0])(manager, nullptr, event);
reinterpret_cast<StringFn>(0x00416660)(event + kEventUiText, nullptr); // destroy
}
} // namespace
// True if Esc closed windows (the game's handling is skipped).
bool CloseWindows(uint32_t* manager) {
if (Visible(reinterpret_cast<uint8_t*>(manager[kEscMenuSlot]))) {
return false;
}
bool closed = false;
for (const ClosableWindow& w : kClosable) {
auto* window = reinterpret_cast<uint8_t*>(manager[w.slot]);
if (Visible(window)) {
Hide(manager, w.name);
closed = true;
}
}
return closed;
}
} // namespace mods::escape
namespace game {
// cUI_Manager::HandleEsc (0x756ba0): 1 if handled.
uint32_t __fastcall UiManager_HandleEsc(uint32_t* self, void* edx);
RE_HOOK(0x00756ba0, UiManager_HandleEsc);
uint32_t __fastcall UiManager_HandleEsc(uint32_t* self, void* edx) {
if (self && mods::escape::CloseWindows(self)) {
return 1;
}
return UiManager_HandleEsc_original(self, edx);
}
} // namespace game
+16
View File
@@ -44,6 +44,7 @@ thread_local int g_offsetX = 0;
// Whether the UI event currently being handled has already been shifted.
thread_local bool g_eventShifted = false;
enum class Anchor { Center, Right };
int OffsetFor(Anchor anchor) {
@@ -500,6 +501,21 @@ HUD_RENDER(0x0071dd80, UiSavegame_Render, Anchor::Center)
HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center)
HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
// Controls that can be top-level windows of their own, e.g. the quest dialog
// (buttons and texts placed directly in the UI manager). Inside one of the
// windows above they are already in its scope (outermost wins); on their own
// they were drawn and clicked unshifted (or shifted by whichever window was
// active), which made the dialog jump and its buttons miss.
HUD_EVENT(0x0072adf0, UiButton_HandleEvent, Anchor::Center)
HUD_RENDER(0x0072af90, UiButton_Render, Anchor::Center)
HUD_EVENT(0x00753c70, UiStaticText_HandleEvent, Anchor::Center)
HUD_RENDER(0x00753a20, UiStaticText_Render, Anchor::Center)
HUD_EVENT(0x00754610, UiStaticTextFx_HandleEvent, Anchor::Center)
HUD_RENDER(0x007541e0, UiStaticTextFx_Render, Anchor::Center)
HUD_EVENT(0x00753360, UiStatic_HandleEvent, Anchor::Center)
HUD_RENDER(0x007530a0, UiStatic_Render, Anchor::Center)
HUD_RENDER(0x007228b0, UiTextLines_Render, Anchor::Center)
// Tooltips: placed by the hovered window in its own layout (see TrackHover).
int __fastcall UiTooltip_Render(void* self, void* edx, void* device);
RE_HOOK(0x006e7300, UiTooltip_Render);
+12 -13
View File
@@ -5,9 +5,9 @@
// several event receivers (engine 0x618130, UI manager 0x757120, taskbar
// 0x6e1970). This mod reads the raw key messages instead (virtual keys,
// independent of the keyboard layout): a bound key's messages are swallowed
// and its action runs. Slot keys call the taskbar's selection directly
// (src/mods/skillbar.cpp); keys standing in for an original hotkey post that
// hotkey's key event to the game's event kernel, the way the window
// and its action runs. Skill keys cast their slot (src/mods/skillkeys.cpp),
// weapon set keys select it on the taskbar; keys standing in for an original
// hotkey post that hotkey's key event to the game's event kernel, the way the window
// procedure does (the kernel dispatches it right away, on the window thread).
//
// While a text field has the keyboard, or Ctrl / Alt is held, keys pass
@@ -15,7 +15,7 @@
//
// sacred_re.ini (key names: A-Z, 0-9, F1-F24, Space, None):
// [keys]
// SkillSlot1=1 ... SkillSlot7=7 ; select and cast combat-art slot
// SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art)
// WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set
// ActionSlot3=G ; what W did (W is movement)
// PickUp=V ; what A did (pick up items nearby)
@@ -85,13 +85,13 @@ struct Binding {
};
Binding g_bindings[] = {
{"SkillSlot1", "1", Kind::SkillSlot, 0, 0},
{"SkillSlot2", "2", Kind::SkillSlot, 1, 0},
{"SkillSlot3", "3", Kind::SkillSlot, 2, 0},
{"SkillSlot4", "4", Kind::SkillSlot, 3, 0},
{"SkillSlot5", "5", Kind::SkillSlot, 4, 0},
{"SkillSlot6", "6", Kind::SkillSlot, 5, 0},
{"SkillSlot7", "7", Kind::SkillSlot, 6, 0},
{"SkillSlot0", "0", Kind::SkillSlot, 0, 0},
{"SkillSlot1", "1", Kind::SkillSlot, 1, 0},
{"SkillSlot2", "2", Kind::SkillSlot, 2, 0},
{"SkillSlot3", "3", Kind::SkillSlot, 3, 0},
{"SkillSlot4", "4", Kind::SkillSlot, 4, 0},
{"SkillSlot5", "5", Kind::SkillSlot, 5, 0},
{"SkillSlot6", "6", Kind::SkillSlot, 6, 0},
{"WeaponSet1", "F1", Kind::WeaponSet, 0, 0},
{"WeaponSet2", "F2", Kind::WeaponSet, 1, 0},
{"WeaponSet3", "F3", Kind::WeaponSet, 2, 0},
@@ -149,8 +149,7 @@ void Press(const Binding& binding, int vk) {
const auto slot = static_cast<unsigned>(binding.index);
switch (binding.kind) {
case Kind::SkillSlot:
mods::skillkeys::CastOnSelect(slot, vk);
mods::skillbar::SelectSkillSlot(slot);
mods::skillkeys::KeyPressed(slot, vk);
break;
case Kind::WeaponSet:
mods::skillbar::SelectWeaponSet(slot);
+96
View File
@@ -0,0 +1,96 @@
// The game's record cache is sized for the widened view.
//
// FUN_006360e0 (on an object whose record file is at +0x23c) returns a
// 16-byte record by id: from a std::map at +0xde5c (size at +0xde60, value at
// node +0x14, "last used" stamp at node +0x24 = g_frameStamp 0xcd59d0), or
// read from the file and inserted. With 4096 (0x1000) entries or more, every
// insert walks the whole map to erase the single least recently used entry.
// The 1024 view stays below that; the widened view needs more records per
// frame, so they were evicted and reloaded all the time, each with a full map
// walk: 90-130 ms per frame in towns instead of 17 (measured with a sampling
// profiler, 2026-10-15).
//
// The game's limit is raised to 0x8000 so that its per-insert walk never
// runs; instead, once the map holds kSweepAt entries, one pass erases the
// less recently used half. See docs/performance.md.
#include <algorithm>
#include <cstdint>
#include <vector>
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::recordcache {
namespace {
constexpr uint32_t kLimitCompare = 0x00636201; // cmp ecx, 1000h
constexpr uint32_t kGameLimit = 0x1000;
constexpr uint32_t kNewLimit = 0x8000;
constexpr uint32_t kSweepAt = 0x6000;
constexpr size_t kMap = 0xde5c; // std::map: header node pointer
constexpr size_t kMapSize = 0xde60;
constexpr size_t kNodeStamp = 0x24;
constexpr size_t kHeaderFirst = 8; // header node: the first (leftmost) node
using NextFn = uint8_t*(__cdecl*)(uint8_t* node); // tree successor (0x832ba0)
using EraseFn = void(__fastcall*)(void* map, void*, uint8_t* node); // map::erase (0x63da10)
const auto Next = reinterpret_cast<NextFn>(0x00832ba0);
const auto Erase = reinterpret_cast<EraseFn>(0x0063da10);
void Startup() {
const bool ok = re::PatchInstructionDword(kLimitCompare, kGameLimit, kNewLimit);
re::Log("recordcache: limit %x, batch eviction from %x%s", kNewLimit, kSweepAt, ok ? "" : " (patch FAILED)");
}
RE_STARTUP(RecordCacheStartup, Startup);
// Erases the less recently used half of the map.
void Sweep(uint8_t* owner) {
auto* map = owner + kMap;
auto* header = *reinterpret_cast<uint8_t**>(map);
std::vector<uint32_t> stamps;
stamps.reserve(*reinterpret_cast<uint32_t*>(owner + kMapSize));
for (uint8_t* node = *reinterpret_cast<uint8_t**>(header + kHeaderFirst); node != header; node = Next(node)) {
stamps.push_back(*reinterpret_cast<uint32_t*>(node + kNodeStamp));
}
if (stamps.empty()) {
return;
}
const size_t half = stamps.size() / 2;
std::nth_element(stamps.begin(), stamps.begin() + half, stamps.end());
const uint32_t cutoff = stamps[half];
size_t erased = 0;
for (uint8_t* node = *reinterpret_cast<uint8_t**>(header + kHeaderFirst); node != header && erased < half;) {
uint8_t* next = Next(node);
if (*reinterpret_cast<uint32_t*>(node + kNodeStamp) <= cutoff) {
Erase(map, nullptr, node);
++erased;
}
node = next;
}
}
} // namespace
void BeforeLookup(uint8_t* owner) {
if (*reinterpret_cast<uint32_t*>(owner + kMapSize) >= kSweepAt) {
Sweep(owner);
}
}
} // namespace mods::recordcache
namespace game {
// FUN_006360e0 (record by id, ret 4).
void* __fastcall RecordCache_Get(void* self, void* edx, uint32_t id);
RE_HOOK(0x006360e0, RecordCache_Get);
void* __fastcall RecordCache_Get(void* self, void* edx, uint32_t id) {
if (self) {
mods::recordcache::BeforeLookup(static_cast<uint8_t*>(self));
}
return RecordCache_Get_original(self, edx, id);
}
} // namespace game
+194 -15
View File
@@ -12,6 +12,15 @@
// The old arrays keep a copy of the first 5 controls, so code that was missed
// still sees valid controls.
//
// Combat-art slots are not selected any more: slot 0 is always the right
// mouse button's art (the game's "selected" slot), the others are cast with
// their keys (src/mods/skillkeys.cpp). Selecting another slot is refused
// unless a skill key asks for it. Slot 0 is drawn with the selected look and
// set apart from slots 1-6 by a gap; every ready art uses its selected icon.
//
// Recharging arts show their remaining cooldown (seconds, one decimal) on the
// slot, drawn with the game's font after RenderArtSlots.
//
// Also: 7 entries in the slot-count-by-level table (6 at level 45, 7 at 65),
// and cCreature::AssignArtToSlot accepts slots up to 6.
@@ -19,14 +28,21 @@
#include <atomic>
#include <cstring>
#include <cwchar>
#include "game/addresses.h"
#include "game/engine/arts.h"
#include "game/engine/objects.h"
#include "game/ui/text.h"
#include "mods/skillslots.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::skillbar {
void WeaponSetClickThunk();
namespace {
using namespace game::addr;
@@ -39,10 +55,36 @@ constexpr uint32_t kNewTaskbarSize = kSkillSlots + kSlots * 4 + 0x8;
constexpr int8_t kOldDistance = static_cast<int8_t>(kTaskbarSkillSlots - kTaskbarWeaponSlots); // 0x14
constexpr int8_t kNewDistance = static_cast<int8_t>(kSkillSlots - kWeaponSlots); // 0x1c
// cUI_Taskbar2 fields.
constexpr size_t kInventoryIndex = 0x158; // u16: the hero's inventory
constexpr size_t kSlotCount = 0x15c; // u16: slots shown, from SetSlotCount
// UI control: x at +0x24 (relative to the taskbar).
constexpr size_t kControlX = 0x24;
// Gap between slot 0 (right mouse button) and slots 1-6: half a slot.
constexpr int kRightButtonSlotGap = 0x21;
constexpr uint32_t kRightStripStart = 0x23a;
// Weapon set clicks (WeaponSetClickThunk).
constexpr uint32_t kWeaponSetEmptyCursorJump = 0x006e0ca7;
constexpr uint32_t kWeaponSetDrag = 0x006e10d8;
// Cooldown labels on the art slots.
constexpr uint16_t kCooldownFont = 1;
constexpr uint32_t kCooldownColor = 0xffffffff;
constexpr uint32_t kCooldownAlign = game::kTextCenter;
// (level, slots): the game's table plus slots 6 and 7.
constexpr uint16_t kSlotLevels[kSlots] = {0, 2, 8, 16, 30, 45, 65};
std::atomic<uint8_t*> g_taskbar{nullptr};
// A skill key is selecting a slot other than 0 (window thread).
bool g_keySelects = false;
// Where the cooldown labels go: computed on the first frame after the layout
// (render thread), fixed to the slots from then on.
game::UiRect g_labelRects[kSlots] = {};
bool g_labelRectsValid = false;
void Startup() {
int failed = 0;
@@ -53,6 +95,12 @@ void Startup() {
failed += re::PatchAll(kTaskbarSlotDistanceUp, static_cast<uint8_t>(kNewDistance));
failed += re::PatchAll(kTaskbarSlotLoopBounds, static_cast<uint8_t>(kSlots));
failed += re::PatchAll(kAssignArtMaxSlot, static_cast<uint8_t>(kSlots - 1));
failed += re::ReplaceAll(kTaskbarSkillSelectionLook);
failed += re::ReplaceAll(kTaskbarSignedHitTests);
failed += !re::PatchInstructionDword(kTaskbarRightStripStart, kRightStripStart,
kRightStripStart + kRightButtonSlotGap);
failed += !re::RedirectJcc(kWeaponSetEmptyCursorJump, kWeaponSetDrag,
reinterpret_cast<const void*>(&WeaponSetClickThunk));
static_assert(kOldDistance == 0x14);
re::Log("skillbar: %u weapon sets / art slots on the taskbar (%d patches failed)", kSlots, failed);
}
@@ -66,39 +114,143 @@ void Select(uint32_t function, unsigned slot) {
}
}
uint8_t* HeroInventory() {
const uint8_t* taskbar = g_taskbar.load();
return taskbar ? skillslots::Inventory(*reinterpret_cast<const uint16_t*>(taskbar + kInventoryIndex))
: nullptr;
}
} // namespace
void SelectSkillSlot(unsigned slot) {
g_keySelects = slot != 0;
Select(0x006e17d0, slot); // cUI_Taskbar2::SelectSkillSlot
g_keySelects = false;
}
// With the game option COMBINE_SLOTS (Options::CombineSlots 0x81c020) a
// weapon set also selects the art slot with the same number, as the digit
// keys do (cUI_Taskbar2::HandleEvent).
void SelectWeaponSet(unsigned slot) {
Select(0x006e1440, slot); // cUI_Taskbar2::SelectWeaponSet
using CombineSlotsFn = char(__cdecl*)();
if (reinterpret_cast<CombineSlotsFn>(0x0081c020)()) {
SelectSkillSlot(slot);
}
}
bool HasSkillSlot(const void* taskbar, unsigned slot) {
const auto* p = static_cast<const uint8_t*>(taskbar);
constexpr size_t kSlotCount = 0x15c; // u16: slots shown, from SetSlotCount
return slot < kSlots && slot < *reinterpret_cast<const uint16_t*>(p + kSlotCount) &&
reinterpret_cast<void* const*>(p + kSkillSlots)[slot] != nullptr;
unsigned SelectedSkillSlot() {
const uint8_t* inventory = HeroInventory();
return inventory ? reinterpret_cast<const uint16_t*>(inventory + skillslots::kSelected)[1] : 0;
}
bool SkillSlotHasArt(unsigned slot) {
const uint8_t* taskbar = g_taskbar.load();
uint8_t* inventory = HeroInventory();
if (!taskbar || !inventory || slot >= kSlots ||
slot >= *reinterpret_cast<const uint16_t*>(taskbar + kSlotCount) ||
!reinterpret_cast<void* const*>(taskbar + kSkillSlots)[slot]) {
return false;
}
using GetArtSlotRefFn = uint32_t(__fastcall*)(void* inventory, void* edx, uint32_t slot);
return reinterpret_cast<GetArtSlotRefFn>(0x005d9420)(inventory, nullptr, slot) != 0;
}
bool KeySelects() {
return g_keySelects;
}
void OnTaskbar(void* taskbar) {
g_taskbar = static_cast<uint8_t*>(taskbar);
}
// Copy of the first 5 controls of each row into the old arrays.
void MirrorOldArrays(void* taskbar) {
// After the layout: slots 1-6 move right, away from slot 0; the first 5
// controls of each row are copied into the old arrays.
void AfterLayout(void* taskbar) {
auto* p = static_cast<uint8_t*>(taskbar);
auto* const* skills = reinterpret_cast<uint8_t* const*>(p + kSkillSlots);
for (unsigned slot = 1; slot < kSlots; ++slot) {
if (skills[slot]) {
*reinterpret_cast<int*>(skills[slot] + kControlX) += kRightButtonSlotGap;
}
}
std::memcpy(p + kTaskbarWeaponSlots, p + kWeaponSlots, kGameSlots * 4);
std::memcpy(p + kTaskbarSkillSlots, p + kSkillSlots, kGameSlots * 4);
g_labelRectsValid = false;
}
void UpdateLabelRects(uint8_t* const* skills) {
for (unsigned slot = 0; slot < kSlots; ++slot) {
if (skills[slot]) {
g_labelRects[slot] =
game::CenterLineVertically(kCooldownFont, game::ControlScreenRect(skills[slot]));
}
}
g_labelRectsValid = true;
}
// The remaining cooldown in seconds (at the hero's current regeneration
// rate, game::GetArtCooldown), to one decimal, centred on each art slot that
// is recharging (combat moves and spells: slot types 2 / 3).
void RenderCooldowns(void* taskbar, void* renderer) {
auto* p = static_cast<uint8_t*>(taskbar);
uint8_t* inventory = skillslots::Inventory(*reinterpret_cast<const uint16_t*>(p + kInventoryIndex));
void* hero = game::GetPlayerHero();
if (!inventory || !hero) {
return;
}
using SlotFn = uint32_t(__fastcall*)(void* inventory, void* edx, uint32_t slot);
const auto getType = reinterpret_cast<SlotFn>(0x005d93e0); // cInventoryEntry::GetArtSlotType
const auto getRef = reinterpret_cast<SlotFn>(0x005d9420); // cInventoryEntry::GetArtSlotRef
auto* const* skills = reinterpret_cast<uint8_t* const*>(p + kSkillSlots);
if (!g_labelRectsValid) {
UpdateLabelRects(skills);
}
const unsigned shown = *reinterpret_cast<const uint16_t*>(p + kSlotCount);
for (unsigned slot = 0; slot < kSlots && slot < shown; ++slot) {
const uint32_t type = getType(inventory, nullptr, slot);
const uint32_t ref = getRef(inventory, nullptr, slot);
if (!skills[slot] || (type != 2 && type != 3) || !ref) {
continue;
}
const uint16_t art = game::ResolveArtId(hero, ref);
const game::ArtCooldown cooldown = game::GetArtCooldown(hero, art);
if (cooldown.remaining <= game::kArtReadyCooldown) {
continue; // ready: no label, the game's icon shows it
}
wchar_t text[16];
swprintf_s(text, L"%.1f", cooldown.seconds);
game::DrawText(renderer, kCooldownFont, g_labelRects[slot], text, kCooldownColor, kCooldownAlign);
}
}
// A click on a weapon set with an empty cursor (cUI_Taskbar2's mouse handling,
// `je 0x6e10d8` at 0x6e0ca7) takes the set's weapon onto the cursor, i.e.
// starts dragging it. Only a click on the selected set does that now; a click
// on another set just selects it.
bool __fastcall SelectInsteadOfDrag(void* taskbar, void*, const uint8_t* inventory, uint32_t set) {
set &= 0xffff;
const unsigned selected = reinterpret_cast<const uint16_t*>(inventory + skillslots::kSelected)[0];
if (set == selected) {
return false;
}
using SelectFn = void(__fastcall*)(void*, void*, uint32_t, int);
reinterpret_cast<SelectFn>(0x006e1440)(taskbar, nullptr, set, 1); // cUI_Taskbar2::SelectWeaponSet
return true;
}
// Jumped to instead of 0x6e10d8 (taskbar in esi, inventory in ebp, the set
// at [esp+0B8h]).
__declspec(naked) void WeaponSetClickThunk() {
__asm {
mov eax, [esp+0B8h]
push eax
push ebp
mov ecx, esi
call SelectInsteadOfDrag
test al, al
jz drag
mov al, 1
mov edx, 0x006e124d // handler exit, al = handled
jmp edx
drag:
mov edx, 0x006e10d8 // the drag (kWeaponSetDrag)
jmp edx
}
}
uint16_t SlotCountForLevel(unsigned level) {
@@ -122,7 +274,7 @@ RE_HOOK(0x006e4ba0, UiTaskbar_Layout);
void __fastcall UiTaskbar_Layout(void* self, void* edx) {
mods::skillbar::OnTaskbar(self);
UiTaskbar_Layout_original(self, edx);
mods::skillbar::MirrorOldArrays(self);
mods::skillbar::AfterLayout(self);
}
// cUI_Taskbar2::SetSlotCount (0x6dffa0): shows the first n slots of each row.
@@ -133,6 +285,33 @@ void __fastcall UiTaskbar_SetSlotCount(void* self, void* edx, uint32_t count) {
UiTaskbar_SetSlotCount_original(self, edx, count);
}
// cUI_Taskbar2::SelectSkillSlot (0x6e17d0): makes combat-art slot `slot` the
// right mouse button's art. Clicks on the bar, the mouse wheel, COMBINE_SLOTS
// and the slot count check all end up here; only a skill key may select a
// slot other than 0.
void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, uint32_t slot, int notify);
RE_HOOK(0x006e17d0, UiTaskbar_SelectSkillSlot);
void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, uint32_t slot, int notify) {
if (!mods::skillbar::KeySelects()) {
slot = 0;
}
UiTaskbar_SelectSkillSlot_original(self, edx, slot, notify);
// It also moves the selected slot background (orange) to the new slot;
// redo the backgrounds, which mark slot 0 (kTaskbarSkillSelectionLook).
if (self) {
using UpdateSlotHighlightFn = void(__fastcall*)(void*, void*, int force);
reinterpret_cast<UpdateSlotHighlightFn>(0x006df9a0)(self, nullptr, 1);
}
}
// cUI_Taskbar2::RenderArtSlots (0x6e31e0): then the cooldown labels.
void __fastcall UiTaskbar_RenderArtSlots(void* self, void* edx, void* renderer);
RE_HOOK(0x006e31e0, UiTaskbar_RenderArtSlots);
void __fastcall UiTaskbar_RenderArtSlots(void* self, void* edx, void* renderer) {
UiTaskbar_RenderArtSlots_original(self, edx, renderer);
mods::skillbar::RenderCooldowns(self, renderer);
}
uint32_t __stdcall SlotCountForLevel(uint32_t level);
RE_HOOK(addr::SlotCountForLevel, SlotCountForLevel);
uint32_t __stdcall SlotCountForLevel(uint32_t level) {
+9 -5
View File
@@ -2,12 +2,16 @@
namespace mods::skillbar {
// Select weapon set / combat-art slot `slot` (0-6) on the taskbar, as its key
// or a click does.
void SelectSkillSlot(unsigned slot);
// Select weapon set `slot` (0-6) on the taskbar, as its key or a click does.
void SelectWeaponSet(unsigned slot);
// Whether the taskbar shows combat-art slot `slot` (unlocked, control exists).
bool HasSkillSlot(const void* taskbar, unsigned slot);
// Make combat-art slot `slot` (0-6) the right mouse button's art. Only skill
// keys select a slot other than 0, for the duration of their cast; any other
// selection is turned into slot 0.
void SelectSkillSlot(unsigned slot);
unsigned SelectedSkillSlot();
// Whether combat-art slot `slot` is shown (unlocked) and holds an art.
bool SkillSlotHasArt(unsigned slot);
} // namespace mods::skillbar
+49 -77
View File
@@ -1,24 +1,24 @@
// Skill keys cast the skill, not only select it.
// Skill keys cast their combat-art slot.
//
// The taskbar's combat-art slots are selected with a key (cUI_Taskbar2::
// HandleEvent -> SelectSkillSlot 0x6e17d0); the selected art is then used with
// the right mouse button. When a skill key (src/mods/input.cpp) selects a slot,
// this mod also presses the right mouse button: a WM_RBUTTONDOWN is posted to
// the game window, so the game handles it exactly like a real right-click at
// the cursor (UI hit tests, target under the cursor, cEvent_creature 0x105).
// The button is released with the key, so holding the key is holding the
// button. Slots selected by clicking them with the mouse are not cast.
// The game casts one art with the right mouse button: the one in the
// "selected" art slot, which is always slot 0 now (src/mods/skillbar.cpp).
// Slot 0's key presses the right mouse button; the keys of slots 1-6 first
// make their slot the selected one. The button is pressed by posting
// WM_RBUTTONDOWN to the game window, so the game handles it exactly like a
// real right-click at the cursor (UI hit tests, target under the cursor,
// cEvent_creature 0x105), and released with the key: holding the key is
// holding the button. While no skill key is held, slot 0 is selected again
// (checked every engine tick, which also covers saves loaded with another
// selection).
#include "mods/skillkeys.h"
#include "mods/skillbar.h"
#include <cstdint>
#include <mutex>
#include <atomic>
#include <windows.h>
#include "re/hook.h"
#include "game/engine/events.h"
#include "mods/skillbar.h"
namespace mods::skillkeys {
namespace {
@@ -40,77 +40,49 @@ LPARAM CursorClientPos(HWND window) {
return MAKELPARAM(cursor.x, cursor.y);
}
// The key that is to cast slot `slot` when it gets selected, then the key
// holding the right mouse button down. Set on the window thread, used on the
// thread that handles UI events.
struct State {
std::mutex lock;
int pendingKey = 0;
unsigned pendingSlot = 0;
bool keyDown = false;
int heldKey = 0; // right button is down for this key
HWND window = nullptr;
};
State g_state;
// The key holding the right mouse button down, and the window it was pressed
// in. Set on the window thread, read by the engine tick.
std::atomic<int> g_heldKey{0};
HWND g_window = nullptr;
void OnTick(uint8_t*) {
if (!g_heldKey.load() && mods::skillbar::SelectedSkillSlot() != 0) {
mods::skillbar::SelectSkillSlot(0);
}
}
struct Registration {
Registration() { game::OnEngineTick(&OnTick); }
} g_registration;
} // namespace
void CastOnSelect(unsigned slot, int vk) {
std::lock_guard guard(g_state.lock);
g_state.pendingKey = vk;
g_state.pendingSlot = slot;
g_state.keyDown = true;
void KeyPressed(unsigned slot, int vk) {
if (g_heldKey.load() || !mods::skillbar::SkillSlotHasArt(slot)) {
return;
}
HWND window = GameWindow();
if (!window) {
return;
}
g_window = window;
g_heldKey = vk; // before selecting: the tick leaves the selection alone
if (slot != 0) {
mods::skillbar::SelectSkillSlot(slot);
}
PostMessageW(window, WM_RBUTTONDOWN, MK_RBUTTON, CursorClientPos(window));
}
bool KeyHeld() {
return g_heldKey.load() != 0;
}
void KeyReleased(int vk) {
std::lock_guard guard(g_state.lock);
if (g_state.pendingKey == vk) {
g_state.keyDown = false; // released before the slot was selected
}
if (g_state.heldKey == vk) {
g_state.heldKey = 0;
PostMessageW(g_state.window, WM_RBUTTONUP, 0, CursorClientPos(g_state.window));
}
}
// After a slot was selected: cast if a skill key asked for it.
void OnSlotSelected(void* taskbar, unsigned slot) {
std::lock_guard guard(g_state.lock);
if (!g_state.pendingKey || g_state.pendingSlot != slot) {
if (g_heldKey.load() != vk) {
return;
}
const int key = g_state.pendingKey;
g_state.pendingKey = 0;
if (!mods::skillbar::HasSkillSlot(taskbar, slot)) {
return; // empty slot: nothing was selected
}
HWND window = GameWindow();
if (!window || g_state.heldKey) {
return;
}
PostMessageW(window, WM_RBUTTONDOWN, MK_RBUTTON, CursorClientPos(window));
if (g_state.keyDown) {
g_state.heldKey = key;
g_state.window = window;
} else {
PostMessageW(window, WM_RBUTTONUP, 0, CursorClientPos(window));
}
PostMessageW(g_window, WM_RBUTTONUP, 0, CursorClientPos(g_window));
g_heldKey = 0;
}
} // namespace mods::skillkeys
namespace game {
// cUI_Taskbar2::SelectSkillSlot (0x6e17d0): makes combat-art slot `slot` the
// right mouse button's art (keys, clicks on the slot).
void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, unsigned slot, int notify);
RE_HOOK(0x006e17d0, UiTaskbar_SelectSkillSlot);
void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, unsigned slot, int notify) {
UiTaskbar_SelectSkillSlot_original(self, edx, slot, notify);
if (self) {
mods::skillkeys::OnSlotSelected(self, slot & 0xffff);
}
}
} // namespace game
+6 -3
View File
@@ -2,9 +2,12 @@
namespace mods::skillkeys {
// The skill key `vk` was pressed to select combat-art slot `slot`: when the
// game selects it, press the right mouse button until KeyReleased(vk).
void CastOnSelect(unsigned slot, int vk);
// The skill key `vk` of combat-art slot `slot` (0-6) went down / up: casts
// the slot's art with the right mouse button while the key is held.
void KeyPressed(unsigned slot, int vk);
void KeyReleased(int vk);
// Whether a skill key is held (its art is being cast).
bool KeyHeld();
} // namespace mods::skillkeys
+5
View File
@@ -45,6 +45,11 @@ int IndexOf(const void* inventory) {
} // namespace
uint8_t* Inventory(unsigned index) {
auto* base = *reinterpret_cast<uint8_t**>(kInventoryBlock);
return base && index < kInventories ? base + index * kInventorySize : nullptr;
}
uint8_t* ShadowFor(const void* inventory, bool create) {
const int index = IndexOf(inventory);
if (index < 0) {
+3
View File
@@ -20,6 +20,9 @@ constexpr size_t kArtSlotSize = 0x28;
constexpr size_t kArtEntrySize = 0x14;
constexpr size_t kSelected = 0xd58; // u16 [0] weapon set, [1] art slot
// Inventory `index` (0-31) of cInventoryManager's block, or null.
uint8_t* Inventory(unsigned index);
// The shadow inventory holding slots 6-7 (at indices 0-1) of `inventory`, or
// null if it has none yet and `create` is false.
uint8_t* ShadowFor(const void* inventory, bool create);
+15 -40
View File
@@ -22,34 +22,16 @@
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "mods/input.h"
#include "re/hook.h"
namespace mods::wasd {
namespace {
// --- sacred.exe ---------------------------------------------------------------
constexpr uint32_t kEventCreatureVtable = 0x0088e95c;
using SendOrderFn = void(__fastcall*)(void* engine, void* edx, void* creature, void* event);
const auto SendOrder = reinterpret_cast<SendOrderFn>(0x006170d0); // adjusts ground targets, then vf18
// cEngine fields.
constexpr size_t kEngineFlags = 0x54; // 0x10 mouse button held (hold-to-move), 0x4000 input blocked
constexpr size_t kEngineControlledId = 0x13c; // object id of the controlled creature
// cEvent_creature, command 4 = walk.
struct CreatureEvent {
uint32_t vtable;
uint32_t command;
uint32_t unknown08[3];
uint32_t groundTarget; // 2: x, y are a point on the ground
int32_t x;
int32_t y;
uint32_t held; // 1: re-aim while a mouse button is held (cEngine::Tick)
uint32_t unknown24[8];
};
static_assert(sizeof(CreatureEvent) == 0x44);
using game::ControlledCreature;
using game::CreatureOrder;
using game::kEngineFlags;
// Walk orders aim this far (world units) from the creature, about where the
// cursor would be.
@@ -59,27 +41,21 @@ bool KeyDown(int key) {
return (GetAsyncKeyState(key) & 0x8000) != 0;
}
void* ControlledCreature(uint8_t* engine) {
return game::GetCreature(*reinterpret_cast<uint32_t*>(engine + kEngineControlledId));
}
void SendWalk(uint8_t* engine, void* creature, int x, int y) {
CreatureEvent event = {};
event.vtable = kEventCreatureVtable;
event.command = 4;
event.groundTarget = 2;
event.x = x;
event.y = y;
SendOrder(engine, nullptr, creature, &event);
CreatureOrder order;
order.command = game::kOrderWalk;
order.groundTarget = 2;
order.a = x;
order.b = y;
game::SendCreatureOrder(engine, creature, order);
}
// A walk order without a target stops the creature where it is (what
// releasing a held mouse button sends, cEngine::HandleMouseButton).
void SendStop(uint8_t* engine, void* creature) {
CreatureEvent event = {};
event.vtable = kEventCreatureVtable;
event.command = 4;
SendOrder(engine, nullptr, creature, &event);
CreatureOrder order;
order.command = game::kOrderWalk;
game::SendCreatureOrder(engine, creature, order);
}
struct MoveState {
@@ -119,10 +95,9 @@ void Update(uint8_t* engine) {
if (!creature) {
return;
}
const auto* c = static_cast<const uint8_t*>(creature);
const double length = std::sqrt(static_cast<double>(dx * dx + dy * dy));
const int x = *reinterpret_cast<const int32_t*>(c + 0x1c) + static_cast<int>(dx / length * kTargetDistance);
const int y = *reinterpret_cast<const int32_t*>(c + 0x20) + static_cast<int>(dy / length * kTargetDistance);
const int x = game::CreatureField(creature, game::kCreatureX) + static_cast<int>(dx / length * kTargetDistance);
const int y = game::CreatureField(creature, game::kCreatureY) + static_cast<int>(dy / length * kTargetDistance);
SendWalk(engine, creature, x, y);
g_move.moving = true;
}
+57
View File
@@ -61,6 +61,63 @@ bool PatchInstructionByte(uint32_t instruction, size_t offset, uint8_t from, uin
return WriteCode(instruction + static_cast<uint32_t>(offset), &to, 1);
}
bool ReplaceCode(uint32_t address, const void* from, const void* to, size_t size) {
const auto* code = reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(address));
if (std::memcmp(code, from, size) != 0) {
Log("patch 0x%08x: expected %u other bytes (found %02x %02x ...), skipped", address,
static_cast<unsigned>(size), code[0], size > 1 ? code[1] : 0);
return false;
}
return WriteCode(address, to, size);
}
namespace {
// Redirects the rel32 operand at instruction + opcodeLength (instruction
// length opcodeLength + 4) from `from` to `to`.
bool RedirectRel32(uint32_t instruction, size_t opcodeLength, uint32_t from, const void* to) {
const auto* code = reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(instruction));
const uint32_t end = instruction + static_cast<uint32_t>(opcodeLength) + 4;
int32_t rel;
std::memcpy(&rel, code + opcodeLength, 4);
if (end + static_cast<uint32_t>(rel) != from) {
Log("patch 0x%08x: expected a branch to 0x%08x, skipped", instruction, from);
return false;
}
const auto target = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(to));
const int32_t newRel = static_cast<int32_t>(target - end);
return WriteCode(instruction + static_cast<uint32_t>(opcodeLength), &newRel, 4);
}
} // namespace
bool RedirectCall(uint32_t instruction, uint32_t from, const void* to) {
const auto* code = reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(instruction));
if (code[0] != 0xe8) {
Log("patch 0x%08x: expected a call, skipped", instruction);
return false;
}
return RedirectRel32(instruction, 1, from, to);
}
bool RedirectJcc(uint32_t instruction, uint32_t from, const void* to) {
const auto* code = reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(instruction));
if (code[0] != 0x0f || (code[1] & 0xf0) != 0x80) {
Log("patch 0x%08x: expected a conditional jump, skipped", instruction);
return false;
}
return RedirectRel32(instruction, 2, from, to);
}
int ReplaceAll(const CodeReplacement* replacements, size_t count) {
int failed = 0;
for (size_t i = 0; i < count; ++i) {
const CodeReplacement& r = replacements[i];
failed += !ReplaceCode(r.address, r.from, r.to, r.size);
}
return failed;
}
int PatchAll(const BytePatch* patches, size_t count, uint8_t to) {
int failed = 0;
for (size_t i = 0; i < count; ++i) {
+24
View File
@@ -20,6 +20,30 @@ bool PatchInstructionDword(uint32_t instruction, uint32_t from, uint32_t to);
// Replace the byte `from` at `instruction + offset` (an imm8 or disp8) by `to`.
bool PatchInstructionByte(uint32_t instruction, size_t offset, uint8_t from, uint8_t to);
// Replace the code bytes `from` at `address` by `to` (same length), e.g. a
// whole short instruction.
bool ReplaceCode(uint32_t address, const void* from, const void* to, size_t size);
// The `call rel32` at `instruction` (E8) calls `from`; make it call `to`.
bool RedirectCall(uint32_t instruction, uint32_t from, const void* to);
// The near conditional jump at `instruction` (0F 8x rel32) goes to `from`;
// make it go to `to`.
bool RedirectJcc(uint32_t instruction, uint32_t from, const void* to);
// A code replacement, for tables.
struct CodeReplacement {
uint32_t address;
uint8_t from[8];
uint8_t to[8];
uint8_t size;
};
int ReplaceAll(const CodeReplacement* replacements, size_t count);
template <size_t N>
int ReplaceAll(const CodeReplacement (&replacements)[N]) {
return ReplaceAll(replacements, N);
}
// One byte operand of an instruction, for tables of byte patches.
struct BytePatch {
uint32_t instruction;
+8 -2
View File
@@ -1,4 +1,5 @@
# Automated smoke test: start the game, load the first save game, take
# Automated smoke test: start the game, load a save game (-Save: its row in
# the list, 1 = the first, selected by default), take
# screenshots in-game and with the ESC menu open, then stop the game.
#
# .\tools\autotest-load.ps1 -Name ws_on # current sacred_re.ini
@@ -11,6 +12,8 @@ param(
[string]$Name = "autotest",
[string]$Ini = "",
[int]$LoadSeconds = 30,
# Row of the save game in the load screen's list (1-3 visible rows).
[int]$Save = 1,
# Runs in-game after loading, before the screenshots (e.g. clicks).
[scriptblock]$InGame = $null
)
@@ -26,7 +29,10 @@ $game = $null
try {
$game = Start-Sacred
Click-At 1720 779 -AfterMs 4000 # main menu: "Spiel laden"
Click-At 2382 568 -AfterMs 2000 # load screen: "Laden" (first save selected)
if ($Save -gt 1) {
Click-At 1342 (609 + ($Save - 1) * 254) -AfterMs 800 # load screen: the save's name (rows 254 px apart)
}
Click-At 2382 568 -AfterMs 2000 # load screen: "Laden"
Click-At 1551 826 -AfterMs ($LoadSeconds * 1000) # confirm: "Ok"
if ($InGame) { & $InGame }
Save-Screen "screenshots\${Name}_ingame.png" | Out-Null
+14
View File
@@ -153,3 +153,17 @@
0x005a0980 cObjectFX::Construct
0x005a37b0 cObjectFX::ReceiveEvent # vf18; cEventFX 0x42 attaches it to an owner
0x0041e0a0 cEventFX::Construct
0x009eaee0 g_uiAtlasEntries # 0x54 each: texture +0, uv +4..+0x10; indexed by icon id (art icons, 3 per art)
0x009ed030 g_uiTaskbarSlotBackground # atlas entry: slot control background
0x009ed084 g_uiTaskbarSlotBackgroundSelected
0x009f7f14 g_uiTaskbarArtFrame # atlas entry: frame over an art slot
0x009f4be4 g_uiTaskbarArtFrameSelected
0x009f5d9c g_uiTaskbarArtFrameDropTarget # while dragging an art
0x00581c00 cArtCooldowns::Find # (art) -> entry index (also an art's variants); count if none
0x005829c0 cCreatureArts::HasEffectOfArt # (art): any active effect of the art (+0x1c)
0x00553840 cCreatureArts::FindEffectByKind # (kind) -> entry or null
0x00582710 cCreatureArts::RegenFor # (art) -> regeneration amount (float)
0x00562330 cCreatureArts::AddGroupRegen # (amount, group, filter): adds to every cooldown entry of the group
0x00536f20 cCreature::DistanceTo # (object id)
0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player
0x00ad1b48 g_nearbyCreatureCount