diff --git a/CMakeLists.txt b/CMakeLists.txt index 94504af..7731227 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,7 @@ add_library(sacredK SHARED src/mods/combos.cpp src/mods/aurahits.cpp src/mods/chainlightning.cpp + src/mods/dwarfarts.cpp src/mods/arttooltip.cpp src/mods/escape.cpp src/mods/recordcache.cpp diff --git a/docs/dwarf-arts.md b/docs/dwarf-arts.md new file mode 100644 index 0000000..72e5b6f --- /dev/null +++ b/docs/dwarf-arts.md @@ -0,0 +1,48 @@ +# Dwarf arts: Kanonenschuss and Mörsergranate + +## Requests (2026-10-25 / 2026-10-26) + +- Kanonenschuss (art 0x43f, `gui_spells_kanone`): "remove the knockback, + instead it can be just a stun" (chosen: no push, only a stun); the range + increased for the wider view; the stun particles not on targets that + avoided the stun. The game's stun resistance stays (2026-10-26: "we dont + ignore anything, the skill is fine for now"). +- Mörsergranate (art 0x43e, `gui_spells_moerser`): "the radius of the + explosion should increase every 5 skill levels". + +## Measured + +- The cannon's code is the combat move function `0x5145d0`, case 0x43f. Its + ray: `cmp edi,19h` at `0x517389`, 25 steps of 40 = 1000 range. Hostile + targets get `cEvent_creature` 5 from `0x51768d`. +- Stun chance `0x4fc810(level, creature)` = level × 5 / creature level × 100 + (`UI_STAT_STUNNCHANCE`). On a successful roll: `0x5936e0(MOTION_HIT_A 0xaf, + 3.0, 0.25)` at `0x517709` (pushes from `0x5176f8`), then the stun order + `cEvent_creature` 3 with +0x14 = 100, sent by `call [eax+18h]` at + `0x5177aa` (returns to `0x5177ad`). +- The stun *is* the knockback. `cCreature::ReceiveOrder` `0x52e400` case 3: + first the resistance `vf24(0x50)` (`0x55d0a0`: flag +0x4f0 & 0x1000 resists + 2 in 3, effect 0x35d a percentage); otherwise push left +0x24d = 100, flag + +0x200 |= 0x20000, attacker +0x259, ResetToIdle. `cCreature::Update` branch + `0x594c82` moves a creature with that flag away from the attacker and lowers + the push left by each step (`0x594deb`), measured about 100 units in 0.3 s; + at 0 the flag is cleared. +- The impact effect `TYPE_FX_DWARF_SHOOT_IMPACT` (0x47f) is created on every + hit target by `CreateAt` at `0x517855` (target in ebx), resisted or not. +- Mörsergranate: the grenade (type 0x3fa) lands and creates + `TYPE_FX_GRENADEIMPACT` (0x3fc), which gathers its targets with + `cObjectManager 0x5ff820` (called from `0x5ab400` at `0x5ab63d`): a creature + is hit when its distance is below the impact's size (150, type table + `0x428bb0`) plus its own type's size. + +## The mod (`src/mods/dwarfarts.cpp`) + +- The HIT_A motion is jumped over (`0x5176f8`: `jmp` past the call). +- The push step at `0x594deb` calls `PushStepThunk`: creatures the cannon + stunned (order 3 from `0x5177ad`, kept with an end time) move by 0 and keep + their push left above 0 for 1.5 s, so they stand still, then carry on. +- The impact effect is created only on targets in the stun state. +- The ray gets 45 steps (1800); confirmed to reach across the widened view. +- The impact's gather call gets a radius of size × (1 + 0.1 × (level / 5)), + with the owner's Mörsergranate level (`game::ArtLevel`). The growth 0.1 per + step is our choice; not yet seen in the game (Borf's level is 2). diff --git a/src/game/engine/arts.h b/src/game/engine/arts.h index 1531290..e9c6cc4 100644 --- a/src/game/engine/arts.h +++ b/src/game/engine/arts.h @@ -86,8 +86,8 @@ 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; +constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser) +constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone) // cCreatureArts fields. constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner @@ -106,7 +106,7 @@ 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 || + return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMoerser || art == kArtKanone; } @@ -160,6 +160,16 @@ inline bool EntryAtMagicRate(const uint8_t* entry) { *reinterpret_cast(entry + kCooldownArt) == kArtEnergieblitze; } +// The level of art `art` in cCreatureArts `arts` (cooldown entry level + bonus +// levels), 0 if it has none. +inline unsigned ArtLevelIn(uint8_t* arts, uint16_t art) { + const uint8_t* entry = FindCooldownEntry(arts, art); + return entry ? entry[kCooldownLevel] + entry[kCooldownBonusLevels] : 0; +} +inline unsigned ArtLevel(void* creature, uint16_t art) { + return ArtLevelIn(static_cast(creature) + kCreatureArts, art); +} + struct ArtCooldown { float remaining = 0.0f; // in the game's units float total = 0.0f; diff --git a/src/mods/chainlightning.cpp b/src/mods/chainlightning.cpp index 5425037..6ab264f 100644 --- a/src/mods/chainlightning.cpp +++ b/src/mods/chainlightning.cpp @@ -149,11 +149,6 @@ unsigned Chains(unsigned level) { return 1 + level / 10; } -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) { return std::find(ids.begin(), ids.end(), id) != ids.end(); } @@ -276,7 +271,7 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) { return; } Forget(); - const unsigned level = ArtLevel(caster, g_heroArt); + const unsigned level = game::ArtLevel(caster, g_heroArt); const uint32_t hits = Hits(level); const unsigned chains = Chains(level); event[kEventHitsLeft] = hits; @@ -416,7 +411,7 @@ std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) { } void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) { - text = Rewrite(text, ArtLevel(creature, art.Id())); + text = Rewrite(text, game::ArtLevel(creature, art.Id())); } struct TooltipRegistration { diff --git a/src/mods/combos.cpp b/src/mods/combos.cpp index 5e5999d..47dddc2 100644 --- a/src/mods/combos.cpp +++ b/src/mods/combos.cpp @@ -33,8 +33,7 @@ using namespace game; // combo and cooldown layout (arts.h) // The art's level now (cooldown entry level + bonus levels), 0 if unknown. uint16_t CurrentLevel(uint8_t* arts, uint32_t art) { - uint8_t* entry = art ? FindCooldownEntry(arts, static_cast(art)) : nullptr; - return entry ? static_cast(entry[kCooldownLevel] + entry[kCooldownBonusLevels]) : 0; + return art ? static_cast(ArtLevelIn(arts, static_cast(art))) : 0; } void SyncCombos(uint8_t* hero) { diff --git a/src/mods/dwarfarts.cpp b/src/mods/dwarfarts.cpp new file mode 100644 index 0000000..b747bde --- /dev/null +++ b/src/mods/dwarfarts.cpp @@ -0,0 +1,209 @@ +// The dwarf's Kanonenschuss (0x43f) and Mörsergranate (0x43e) (request of +// 2026-10-25, docs/dwarf-arts.md). +// +// Kanonenschuss: "remove the knockback, instead it can be just a stun". On a +// successful stun roll the dwarf's combat move code (0x5145d0, case 0x43f) +// plays MOTION_HIT_A on the target (0x5936e0 at 0x517709) and sends it the +// stun order (cEvent_creature 3 from 0x5177aa, +0x14 = 100). The game's stun +// is a push (measured: about 100 units away from the attacker in 0.3 s): the +// order sets creature flag 0x20000, the push left (+0x24d) and the attacker +// (+0x259); while the flag is set, cCreature::Update (branch 0x594c82) skips +// everything else and moves the creature away from the attacker, lowering +// the push left by each step, and clears the flag at 0. The mod skips the +// motion, and for creatures the cannon stunned the push step (0x594deb) +// moves them by 0 and keeps the push left above 0 for kStunMs: they stand +// still, doing nothing, then carry on. The shot's ray (25 steps of 40 = 1000) gets 45 steps (1800), +// for the widened view ("range needs to be increased to accommodate for the +// increased viewport", 2026-10-26). +// +// Mörsergranate: "the radius of the explosion should increase every 5 skill +// levels". The grenade (type 0x3fa) creates an impact object +// (TYPE_FX_GRENADEIMPACT, 0x3fc) where it lands; that object gathers its +// targets with cObjectManager 0x5ff820 (called from 0x5ab400 at 0x5ab63d): a +// creature is hit when its distance is below the impact type's size (150) +// plus the creature type's size (object type table 0x428bb0). The mod passes +// that call a radius of size × (1 + kRadiusGrowth × (level / 5)), with the +// level of the owner's Mörsergranate. + +#include + +#include +#include +#include + +#include "game/engine/arts.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::dwarfarts { + +uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type, + uint32_t only, int32_t radius, const uint8_t* fx); + +namespace { + +// The stunned target's motion: push 3.0f, push 0.25f... (0x5176f8) up to the +// call of 0x5936e0 (0x517709, 5 bytes): jumped over. +constexpr uint32_t kCannonStunMotion = 0x005176f8; +constexpr uint8_t kStunMotionPush[] = {0x68, 0x00}; // push 0x3e800000 (first two bytes) +constexpr uint8_t kSkipStunMotion[] = {0xeb, 0x14}; // jmp 0x51770e, after the call + +// The ray's step count: cmp edi, 19h (0x517389, imm8 at +2); steps of 40. +constexpr uint32_t kCannonRaySteps = 0x00517389; +constexpr uint8_t kRayStepsOriginal = 25; +constexpr uint8_t kRaySteps = 45; + +// The cannon's stun order: the return address of its call [eax+18h]. +constexpr uint32_t kCannonStunOrderReturn = 0x005177ad; +constexpr uint32_t kOrderStun = 3; +constexpr DWORD kStunMs = 1500; +// The push step in cCreature::Update: lea edx,[ebp+18h]; mov edi,[esp+60h] +// (y offset; x offset in ebx); mov [ebp+24dh],eax (push left). +constexpr uint32_t kPushStep = 0x00594deb; +constexpr uint8_t kPushStepCode[] = {0x8d, 0x55, 0x18, 0x8b, 0x7c, 0x24, 0x60, 0x89, 0x85, 0x4d, 0x02, 0x00, 0x00}; + +std::map g_stunEnds; // creature id -> GetTickCount at which its stun ends + +constexpr uint32_t kGrenadeImpactType = 0x3fc; // TYPE_FX_GRENADEIMPACT +constexpr uint32_t kImpactGatherCall = 0x005ab63d; // in 0x5ab400 +constexpr uint32_t kGatherInRange = 0x005ff820; // cObjectManager: objects within type sizes +constexpr size_t kFxOwner = 0x54; // u32: the creature the effect hits for +constexpr unsigned kLevelsPerStep = 5; +constexpr float kRadiusGrowth = 0.1f; // of the impact's own size, per step + +// The size of object type `type` (0x428bb0 on the type table *0xaa9564, +// 50 when the table has none). +int32_t TypeSize(uint32_t type) { + using Fn = int32_t(__fastcall*)(void* table, void* edx, uint32_t type); + return reinterpret_cast(0x00428bb0)(*reinterpret_cast(0x00aa9564), nullptr, type); +} + +// 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius), +// ret 0x14) with the effect object in ebx. +__declspec(naked) void GatherTargetsThunk() { + __asm { + push ebx // the effect object + push dword ptr [esp+18h] // radius (-1: the type's size) + push dword ptr [esp+18h] // only objects of this type (0: any) + push dword ptr [esp+18h] // type + push dword ptr [esp+18h] // position + push dword ptr [esp+18h] // out vector + call GatherTargets + ret 14h + } +} + +void OnOrder(void* creature, const game::CreatureOrder& order, void* caller) { + if (order.command == kOrderStun && reinterpret_cast(caller) == kCannonStunOrderReturn) { + g_stunEnds[*reinterpret_cast(static_cast(creature) + game::kCreatureId)] = + GetTickCount() + kStunMs; + } +} + +// The push step of `creature` with `left` push left after it: the push left +// to store (low dword) and whether to stay in place (high dword). +uint64_t __cdecl PushStep(uint8_t* creature, int32_t left) { + const auto it = g_stunEnds.find(*reinterpret_cast(creature + game::kCreatureId)); + if (it == g_stunEnds.end()) { + return static_cast(left); + } + const bool stunned = static_cast(GetTickCount() - it->second) < 0; + if (!stunned) { + g_stunEnds.erase(it); + } + return (uint64_t{1} << 32) | static_cast(stunned ? 1 : 0); +} + +__declspec(naked) void PushStepThunk() { + __asm { + push eax // push left after this step + push ebp // the creature + call PushStep + add esp, 8 + test edx, edx + jz move + xor ebx, ebx // x offset + mov dword ptr [esp+64h], 0 // y offset (the caller's [esp+60h]) + move: + mov edi, [esp+64h] + mov [ebp+24dh], eax + lea edx, [ebp+18h] + ret + } +} + +// The cannon creates its impact effect (TYPE_FX_DWARF_SHOOT_IMPACT 0x47f, +// attached to the target with cEventFX 0x9c) on every target it hits; it stays +// on targets that were not pushed. CreateAt (0x5fbaa0, thiscall (type, +// position, link, 0, 0), ret 0x14) at 0x517855, the target in ebx: only for +// targets in the stun state (the order was not resisted); 0 otherwise, which +// the game skips (GetObject(0) is null). +constexpr uint32_t kImpactCreate = 0x00517855; +constexpr uint32_t kCreateAt = 0x005fbaa0; +constexpr size_t kCreatureFlags = 0x200; +constexpr uint32_t kCreaturePushed = 0x20000; // the stun's push state (cCreature::Update 0x594c82) + +uint32_t __fastcall CreateImpact(void* manager, void*, uint32_t type, const void* position, uint32_t link, uint32_t a, + uint32_t b, const uint8_t* target) { + if (!target || !(*reinterpret_cast(target + kCreatureFlags) & kCreaturePushed)) { + return 0; + } + using Fn = uint32_t(__fastcall*)(void*, void*, uint32_t, const void*, uint32_t, uint32_t, uint32_t); + return reinterpret_cast(kCreateAt)(manager, nullptr, type, position, link, a, b); +} + +__declspec(naked) void CreateImpactThunk() { + __asm { + push ebx // the target + push dword ptr [esp+18h] + push dword ptr [esp+18h] + push dword ptr [esp+18h] + push dword ptr [esp+18h] + push dword ptr [esp+18h] + call CreateImpact + ret 14h + } +} + +void Startup() { + const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast(&CreateImpactThunk)); + re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)"); + uint8_t call[sizeof(kPushStepCode)]; + std::memset(call, 0x90, sizeof(call)); + call[0] = 0xe8; + const int32_t rel = reinterpret_cast(&PushStepThunk) - static_cast(kPushStep + 5); + std::memcpy(call + 1, &rel, sizeof(rel)); + const bool hold = re::ReplaceCode(kPushStep, kPushStepCode, call, sizeof(call)); + re::Log("dwarfarts: Kanonenschuss stun holds in place%s", hold ? "" : " (patch FAILED)"); + const bool stun = re::ReplaceCode(kCannonStunMotion, kStunMotionPush, kSkipStunMotion, sizeof(kSkipStunMotion)); + re::Log("dwarfarts: Kanonenschuss without stun motion%s", stun ? "" : " (patch FAILED)"); + const bool range = re::PatchInstructionByte(kCannonRaySteps, 2, kRayStepsOriginal, kRaySteps); + re::Log("dwarfarts: Kanonenschuss range %d%s", kRaySteps * 40, range ? "" : " (patch FAILED)"); + const bool radius = + re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast(&GatherTargetsThunk)); + re::Log("dwarfarts: Mörsergranate radius%s", radius ? "" : " (patch FAILED)"); +} +RE_STARTUP(DwarfArtsStartup, Startup); + +struct Registration { + Registration() { game::OnCreatureOrder(&OnOrder); } +} g_registration; + +} // namespace + +uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type, + uint32_t only, int32_t radius, const uint8_t* fx) { + if (type == kGrenadeImpactType && radius < 0 && fx) { + void* owner = game::GetCreature(*reinterpret_cast(fx + kFxOwner)); + const unsigned level = owner ? game::ArtLevel(owner, game::kArtMoerser) : 0; + const int32_t size = TypeSize(type); + radius = static_cast(static_cast(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep))); + } + using Fn = uint32_t*(__fastcall*)(void*, void*, uint32_t*, const void*, uint32_t, uint32_t, int32_t); + return reinterpret_cast(kGatherInRange)(manager, nullptr, out, position, type, only, radius); +} + +} // namespace mods::dwarfarts