diff --git a/CMakeLists.txt b/CMakeLists.txt index 66f72cc..ec45fb3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/docs/art-regen.md b/docs/art-regen.md new file mode 100644 index 0000000..57da08c --- /dev/null +++ b/docs/art-regen.md @@ -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). diff --git a/docs/aura-hits.md b/docs/aura-hits.md new file mode 100644 index 0000000..f8a2ce3 --- /dev/null +++ b/docs/aura-hits.md @@ -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. diff --git a/docs/aura-recast.md b/docs/aura-recast.md new file mode 100644 index 0000000..7bb4272 --- /dev/null +++ b/docs/aura-recast.md @@ -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. diff --git a/docs/auto-attack.md b/docs/auto-attack.md new file mode 100644 index 0000000..0fc4133 --- /dev/null +++ b/docs/auto-attack.md @@ -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. diff --git a/docs/chain-lightning.md b/docs/chain-lightning.md new file mode 100644 index 0000000..a0f8fdf --- /dev/null +++ b/docs/chain-lightning.md @@ -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. diff --git a/docs/escape.md b/docs/escape.md new file mode 100644 index 0000000..9d03653 --- /dev/null +++ b/docs/escape.md @@ -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). diff --git a/docs/hud.md b/docs/hud.md index 4a0fe3d..e85104a 100644 --- a/docs/hud.md +++ b/docs/hud.md @@ -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). diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..e13a45b --- /dev/null +++ b/docs/performance.md @@ -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). diff --git a/docs/skill-slots.md b/docs/skill-slots.md index 14da3b5..8f1bb1b 100644 --- a/docs/skill-slots.md +++ b/docs/skill-slots.md @@ -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. diff --git a/docs/todo.md b/docs/todo.md index 98e3697..5946c8d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 diff --git a/src/game/addresses.h b/src/game/addresses.h index 3950215..025e158 100644 --- a/src/game/addresses.h +++ b/src/game/addresses.h @@ -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. diff --git a/src/game/engine/arts.h b/src/game/engine/arts.h index 59b5a56..fad83a6 100644 --- a/src/game/engine/arts.h +++ b/src/game/engine/arts.h @@ -1,6 +1,7 @@ #pragma once // Combat arts: the definition tables and how they are targeted. +#include #include 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(reinterpret_cast(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(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(0x00581c00)(arts, nullptr, art); + auto* begin = *reinterpret_cast(arts + kCooldownsBegin); + auto* end = *reinterpret_cast(arts + kCooldownsEnd); + if (!begin || index >= static_cast(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(creature) + kCreatureArts; + uint8_t* entry = FindCooldownEntry(arts, art); + if (!entry) { + return {}; + } + const bool magic = *reinterpret_cast(entry + kCooldownGroup) == kRegenGroupMagic || + *reinterpret_cast(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); diff --git a/src/game/engine/fx.cpp b/src/game/engine/fx.cpp new file mode 100644 index 0000000..aa9de58 --- /dev/null +++ b/src/game/engine/fx.cpp @@ -0,0 +1,53 @@ +#include "game/engine/fx.h" + +#include + +#include "re/hook.h" + +namespace game { +namespace { + +std::vector& Callbacks() { + static std::vector callbacks; + return callbacks; +} + +std::vector& BeforeCallbacks() { + static std::vector 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(0x00ad3bc0); + return manager ? reinterpret_cast(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(self), event); + } + } + ObjectFX_ReceiveEvent_original(self, edx, event); + if (self && event) { + for (FxEventFn callback : Callbacks()) { + callback(static_cast(self), event); + } + } +} + +} // namespace game diff --git a/src/game/engine/fx.h b/src/game/engine/fx.h new file mode 100644 index 0000000..8a92dcb --- /dev/null +++ b/src/game/engine/fx.h @@ -0,0 +1,42 @@ +#pragma once +// Effect objects (cObjectFX) and the events that set them up (cEventFX). + +#include +#include + +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((*reinterpret_cast(object))[0x18 / 4])(object, nullptr, event); +} + +} // namespace game diff --git a/src/game/engine/objects.h b/src/game/engine/objects.h index 16242c4..331631e 100644 --- a/src/game/engine/objects.h +++ b/src/game/engine/objects.h @@ -1,10 +1,31 @@ #pragma once // Game objects, RTTI casts and the event kernel: calls shared by the mods. +#include #include 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(0x00ad1b48); +} +inline uint32_t NearbyCreatureId(int index) { + return *reinterpret_cast(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(0x00ad3bc0); + return manager ? reinterpret_cast(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) { diff --git a/src/game/engine/orders.cpp b/src/game/engine/orders.cpp new file mode 100644 index 0000000..2f541b8 --- /dev/null +++ b/src/game/engine/orders.cpp @@ -0,0 +1,36 @@ +#include "game/engine/orders.h" + +#include + +#include + +#include "re/hook.h" + +namespace game { +namespace { + +std::vector& Callbacks() { + static std::vector 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 diff --git a/src/game/engine/orders.h b/src/game/engine/orders.h new file mode 100644 index 0000000..720fcf9 --- /dev/null +++ b/src/game/engine/orders.h @@ -0,0 +1,99 @@ +#pragma once +// Creature orders (cEvent_creature) and the engine fields around them +// (docs/wasd.md, docs/auto-attack.md). + +#include +#include + +#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(0x006170d0)(engine, nullptr, creature, &order); +} + +inline void* ControlledCreature(uint8_t* engine) { + return GetCreature(*reinterpret_cast(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(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(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(0x00536f20)(creature, nullptr, id); +} + +inline int32_t CreatureField(const void* creature, size_t offset) { + return *reinterpret_cast(static_cast(creature) + offset); +} + +} // namespace game diff --git a/src/game/ui/text.h b/src/game/ui/text.h new file mode 100644 index 0000000..352c496 --- /dev/null +++ b/src/game/ui/text.h @@ -0,0 +1,66 @@ +#pragma once +// UI controls' screen rects and text drawing with the game's fonts. + +#include + +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(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(0x00649d10)(); + return fonts ? reinterpret_cast(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(GetFont(font)); + return f ? *reinterpret_cast(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((*reinterpret_cast(f))[0x18 / 4]); + draw(f, nullptr, renderer, &rect, text, color, flags); +} + +} // namespace game diff --git a/src/mods/artregen.cpp b/src/mods/artregen.cpp new file mode 100644 index 0000000..4664ab1 --- /dev/null +++ b/src/mods/artregen.cpp @@ -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 + +#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(arts + game::kArtsCreature); +} + +bool IsPlayerHero(const uint8_t* creature) { + return game::IsPlayerHeroId(*reinterpret_cast(creature + game::kCreatureId)); +} + +} // namespace + +void OnRegenFor(void* arts, uint32_t art) { + g_pending = {arts, static_cast(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(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(entry + game::kCooldownGroup), + *reinterpret_cast(entry + game::kCooldownArt)); + const uint32_t flags = *reinterpret_cast(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 diff --git a/src/mods/arttarget.cpp b/src/mods/arttarget.cpp index aed2caf..aaa4d8c 100644 --- a/src/mods/arttarget.cpp +++ b/src/mods/arttarget.cpp @@ -11,46 +11,33 @@ #include #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(static_cast(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(static_cast(creature) + 0xc); - if (target == 0 || casterId >= kPlayerIds) { + if (!IsPlayerHeroId(static_cast(CreatureField(creature, kCreatureId)))) { return; } - if (ArtLaunchType(static_cast(Field(event, kOrderArt))) == LaunchType::SelfOrAlly && - !IsAlly(creature, target)) { - Field(event, kOrderTarget) = 0; + if (ArtLaunchType(static_cast(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 diff --git a/src/mods/aura_restore.cpp b/src/mods/aura_restore.cpp index 46f36ba..dd772b2 100644 --- a/src/mods/aura_restore.cpp +++ b/src/mods/aura_restore.cpp @@ -12,62 +12,42 @@ #include #include +#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(0x00ad3bc0); - return manager ? reinterpret_cast(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(hero + 0xc); - const uint32_t id = CreateAt(kAuraEffectType, hero + 0x18); // the hero's position + const uint32_t heroId = *reinterpret_cast(hero + kCreatureId); + const uint32_t id = CreateObjectAt(kAuraEffectType, hero + 0x18); // the hero's position auto* effect = static_cast(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((*reinterpret_cast(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(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(entry + kEffectKind) != kKindCooldown && + if (*reinterpret_cast(entry + kEffectKind) != kEffectKindArtLock && *reinterpret_cast(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(game::GetCreature(id)); if (!hero) { continue; diff --git a/src/mods/aurahits.cpp b/src/mods/aurahits.cpp new file mode 100644 index 0000000..383be28 --- /dev/null +++ b/src/mods/aurahits.cpp @@ -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 +#include + +#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(reinterpret_cast(&hit) + kHitDamage); + uint32_t total = 0; + for (int i = 0; i < kDamageTypes; ++i) { + total += damage[i]; + damage[i] = 0; + } + damage[0] = static_cast(total > 0xfffe ? 0xfffe : total); +} + +void Send(void* creature, game::CreatureOrder* order) { + using ReceiveFn = void(__fastcall*)(void*, void*, void*); + reinterpret_cast((*reinterpret_cast(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(arts + game::kEffectsBegin); + void* owner = *reinterpret_cast(arts + game::kArtsCreature); + if (!effects || !owner || + *reinterpret_cast(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(arts + kArtsHitRangeLevel) + 20u) * 10u; + const auto chosenId = static_cast(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, ©); + } +} + +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(&HitAllInRangeThunk); + const int32_t rel = static_cast(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 diff --git a/src/mods/aurarecast.cpp b/src/mods/aurarecast.cpp new file mode 100644 index 0000000..85242d3 --- /dev/null +++ b/src/mods/aurarecast.cpp @@ -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 +#include + +#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(game::CreatureField(arts - game::kCreatureArts, game::kCreatureId)); + if (art == game::kNoArt || !game::IsPlayerHeroId(heroId) || + game::ArtLaunchType(static_cast(art)) != game::LaunchType::SelfOrAlly) { + return false; + } + auto* entry = *reinterpret_cast(arts + kEffectsBegin); + auto* end = *reinterpret_cast(arts + kEffectsEnd); + for (; entry && entry < end; entry += kEffectSize) { + if (*reinterpret_cast(entry) == kind && *reinterpret_cast(entry + kEffectArt) == art) { + *reinterpret_cast(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(art)) != game::LaunchType::SelfOrAlly) { + return; + } + const auto id = *reinterpret_cast(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(game::GetObjectById(aura.fx)); + // Ids are reused: only the aura object still on this owner. + if (old && *reinterpret_cast(old) == game::kObjectFXVtable && + *reinterpret_cast(old + kFxOwner) == owner) { + *reinterpret_cast(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(self), kind, duration, art)) { + return 1; + } + return Arts_AddEffect_original(self, edx, kind, a2, value, duration, art, a6, a7, a8); +} + + +} // namespace game diff --git a/src/mods/autoattack.cpp b/src/mods/autoattack.cpp new file mode 100644 index 0000000..ce50a03 --- /dev/null +++ b/src/mods/autoattack.cpp @@ -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 +#include + +#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 g_target{0}; +int g_waitTicks = 0; + +bool IsEnemy(void* hero, uint32_t id) { + if (id == 0 || id == static_cast(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(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(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(kIsButtonHeld)(cursor, nullptr)) { + return 1; + } + const uint32_t id = g_target.load(); + void* hero = game::GetPlayerHero(); + const uint32_t targetId = target ? static_cast(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(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(&SwingContinuesThunk)); + re::RedirectCall(kSwingButtonCheck, kIsButtonHeld, reinterpret_cast(&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(order.a))) { + g_target = static_cast(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 + diff --git a/src/mods/chainlightning.cpp b/src/mods/chainlightning.cpp new file mode 100644 index 0000000..77f0197 --- /dev/null +++ b/src/mods/chainlightning.cpp @@ -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 +#include +#include +#include +#include + +#include + +#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 hit; // targets in order + DWORD started = 0; +}; + +struct Bolt { + std::shared_ptr 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 g_bolts; +struct Pending { + std::shared_ptr chain; + uint32_t target; +}; +std::vector g_pending; + +void OnOrder(void* creature, const game::CreatureOrder& order, void*) { + if (order.command == game::kOrderUseArt && creature == game::GetPlayerHero()) { + g_heroArt = static_cast(order.art); + g_armed = true; + } +} + +unsigned ArtLevel(void* creature, uint16_t art) { + uint8_t* entry = game::FindCooldownEntry(static_cast(creature) + game::kCreatureArts, art); + return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0; +} + +bool Contains(const std::vector& 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& 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(game::DistanceTo(from, id)); + if (d * d > static_cast(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(game::GetCreature(target)); + if (!creature) { + return; + } + auto* bolt = static_cast( + 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(©[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(fx + game::kObjectType) != kBoltObjectType || + !game::IsPlayerHeroId(event[2])) { + return; + } + const uint32_t id = *reinterpret_cast(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->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 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(); + 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(&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(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(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 = 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 diff --git a/src/mods/escape.cpp b/src/mods/escape.cpp new file mode 100644 index 0000000..b4b552c --- /dev/null +++ b/src/mods/escape.cpp @@ -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 +#include + +#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(event) = kEventUiVtable; + *reinterpret_cast(event + 4) = kEventUiWindowCommand; + *reinterpret_cast(event + 8) = kWindowCommandHide; + using SetNamesFn = void(__fastcall*)(void*, void*, const char* window, const char* sender); + reinterpret_cast(0x005904e0)(event + kEventUiNames, nullptr, name, ""); + using StringFn = void(__fastcall*)(void*, void*); + reinterpret_cast(0x00423920)(event + kEventUiText, nullptr); // construct + using HandleFn = int(__fastcall*)(void*, void*, void*); + reinterpret_cast((*reinterpret_cast(manager))[0])(manager, nullptr, event); + reinterpret_cast(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(manager[kEscMenuSlot]))) { + return false; + } + bool closed = false; + for (const ClosableWindow& w : kClosable) { + auto* window = reinterpret_cast(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 diff --git a/src/mods/hud.cpp b/src/mods/hud.cpp index 552e084..27107d7 100644 --- a/src/mods/hud.cpp +++ b/src/mods/hud.cpp @@ -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); diff --git a/src/mods/input.cpp b/src/mods/input.cpp index 243e9a9..39315e3 100644 --- a/src/mods/input.cpp +++ b/src/mods/input.cpp @@ -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(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); diff --git a/src/mods/recordcache.cpp b/src/mods/recordcache.cpp new file mode 100644 index 0000000..6c37ff5 --- /dev/null +++ b/src/mods/recordcache.cpp @@ -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 +#include +#include + +#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(0x00832ba0); +const auto Erase = reinterpret_cast(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(map); + std::vector stamps; + stamps.reserve(*reinterpret_cast(owner + kMapSize)); + for (uint8_t* node = *reinterpret_cast(header + kHeaderFirst); node != header; node = Next(node)) { + stamps.push_back(*reinterpret_cast(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(header + kHeaderFirst); node != header && erased < half;) { + uint8_t* next = Next(node); + if (*reinterpret_cast(node + kNodeStamp) <= cutoff) { + Erase(map, nullptr, node); + ++erased; + } + node = next; + } +} + +} // namespace + +void BeforeLookup(uint8_t* owner) { + if (*reinterpret_cast(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(self)); + } + return RecordCache_Get_original(self, edx, id); +} + +} // namespace game diff --git a/src/mods/skillbar.cpp b/src/mods/skillbar.cpp index 79c5c1a..c42a7ed 100644 --- a/src/mods/skillbar.cpp +++ b/src/mods/skillbar.cpp @@ -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 #include +#include #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(kTaskbarSkillSlots - kTaskbarWeaponSlots); // 0x14 constexpr int8_t kNewDistance = static_cast(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 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(kNewDistance)); failed += re::PatchAll(kTaskbarSlotLoopBounds, static_cast(kSlots)); failed += re::PatchAll(kAssignArtMaxSlot, static_cast(kSlots - 1)); + failed += re::ReplaceAll(kTaskbarSkillSelectionLook); + failed += re::ReplaceAll(kTaskbarSignedHitTests); + failed += !re::PatchInstructionDword(kTaskbarRightStripStart, kRightStripStart, + kRightStripStart + kRightButtonSlotGap); + failed += !re::RedirectJcc(kWeaponSetEmptyCursorJump, kWeaponSetDrag, + reinterpret_cast(&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(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(0x0081c020)()) { - SelectSkillSlot(slot); - } } -bool HasSkillSlot(const void* taskbar, unsigned slot) { - const auto* p = static_cast(taskbar); - constexpr size_t kSlotCount = 0x15c; // u16: slots shown, from SetSlotCount - return slot < kSlots && slot < *reinterpret_cast(p + kSlotCount) && - reinterpret_cast(p + kSkillSlots)[slot] != nullptr; +unsigned SelectedSkillSlot() { + const uint8_t* inventory = HeroInventory(); + return inventory ? reinterpret_cast(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(taskbar + kSlotCount) || + !reinterpret_cast(taskbar + kSkillSlots)[slot]) { + return false; + } + using GetArtSlotRefFn = uint32_t(__fastcall*)(void* inventory, void* edx, uint32_t slot); + return reinterpret_cast(0x005d9420)(inventory, nullptr, slot) != 0; +} + +bool KeySelects() { + return g_keySelects; } void OnTaskbar(void* taskbar) { g_taskbar = static_cast(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(taskbar); + auto* const* skills = reinterpret_cast(p + kSkillSlots); + for (unsigned slot = 1; slot < kSlots; ++slot) { + if (skills[slot]) { + *reinterpret_cast(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(taskbar); + uint8_t* inventory = skillslots::Inventory(*reinterpret_cast(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(0x005d93e0); // cInventoryEntry::GetArtSlotType + const auto getRef = reinterpret_cast(0x005d9420); // cInventoryEntry::GetArtSlotRef + auto* const* skills = reinterpret_cast(p + kSkillSlots); + if (!g_labelRectsValid) { + UpdateLabelRects(skills); + } + const unsigned shown = *reinterpret_cast(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(inventory + skillslots::kSelected)[0]; + if (set == selected) { + return false; + } + using SelectFn = void(__fastcall*)(void*, void*, uint32_t, int); + reinterpret_cast(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(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) { diff --git a/src/mods/skillbar.h b/src/mods/skillbar.h index 6208310..10493db 100644 --- a/src/mods/skillbar.h +++ b/src/mods/skillbar.h @@ -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 diff --git a/src/mods/skillkeys.cpp b/src/mods/skillkeys.cpp index cbfb7fb..c727aaf 100644 --- a/src/mods/skillkeys.cpp +++ b/src/mods/skillkeys.cpp @@ -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 -#include +#include #include -#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 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 - diff --git a/src/mods/skillkeys.h b/src/mods/skillkeys.h index e36c135..deb3a43 100644 --- a/src/mods/skillkeys.h +++ b/src/mods/skillkeys.h @@ -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 diff --git a/src/mods/skillslots.cpp b/src/mods/skillslots.cpp index 1fced59..a75ab4b 100644 --- a/src/mods/skillslots.cpp +++ b/src/mods/skillslots.cpp @@ -45,6 +45,11 @@ int IndexOf(const void* inventory) { } // namespace +uint8_t* Inventory(unsigned index) { + auto* base = *reinterpret_cast(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) { diff --git a/src/mods/skillslots.h b/src/mods/skillslots.h index 3fae497..61bc1bc 100644 --- a/src/mods/skillslots.h +++ b/src/mods/skillslots.h @@ -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); diff --git a/src/mods/wasd.cpp b/src/mods/wasd.cpp index e8a4c77..4e091c4 100644 --- a/src/mods/wasd.cpp +++ b/src/mods/wasd.cpp @@ -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(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(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(creature); const double length = std::sqrt(static_cast(dx * dx + dy * dy)); - const int x = *reinterpret_cast(c + 0x1c) + static_cast(dx / length * kTargetDistance); - const int y = *reinterpret_cast(c + 0x20) + static_cast(dy / length * kTargetDistance); + const int x = game::CreatureField(creature, game::kCreatureX) + static_cast(dx / length * kTargetDistance); + const int y = game::CreatureField(creature, game::kCreatureY) + static_cast(dy / length * kTargetDistance); SendWalk(engine, creature, x, y); g_move.moving = true; } diff --git a/src/re/patch.cpp b/src/re/patch.cpp index b96c081..5785847 100644 --- a/src/re/patch.cpp +++ b/src/re/patch.cpp @@ -61,6 +61,63 @@ bool PatchInstructionByte(uint32_t instruction, size_t offset, uint8_t from, uin return WriteCode(instruction + static_cast(offset), &to, 1); } +bool ReplaceCode(uint32_t address, const void* from, const void* to, size_t size) { + const auto* code = reinterpret_cast(static_cast(address)); + if (std::memcmp(code, from, size) != 0) { + Log("patch 0x%08x: expected %u other bytes (found %02x %02x ...), skipped", address, + static_cast(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(static_cast(instruction)); + const uint32_t end = instruction + static_cast(opcodeLength) + 4; + int32_t rel; + std::memcpy(&rel, code + opcodeLength, 4); + if (end + static_cast(rel) != from) { + Log("patch 0x%08x: expected a branch to 0x%08x, skipped", instruction, from); + return false; + } + const auto target = static_cast(reinterpret_cast(to)); + const int32_t newRel = static_cast(target - end); + return WriteCode(instruction + static_cast(opcodeLength), &newRel, 4); +} + +} // namespace + +bool RedirectCall(uint32_t instruction, uint32_t from, const void* to) { + const auto* code = reinterpret_cast(static_cast(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(static_cast(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) { diff --git a/src/re/patch.h b/src/re/patch.h index ed2e629..8b033f6 100644 --- a/src/re/patch.h +++ b/src/re/patch.h @@ -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 +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; diff --git a/tools/autotest-load.ps1 b/tools/autotest-load.ps1 index ca76ea4..c533b5d 100644 --- a/tools/autotest-load.ps1 +++ b/tools/autotest-load.ps1 @@ -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 diff --git a/tools/names.txt b/tools/names.txt index 5afbeee..ea0c8dc 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -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