Dwarf arts: Kanonenschuss stuns without knockback, Mörsergranate radius grows

Kanonenschuss: the stun no longer plays the hit motion or pushes the
target; stunned creatures stand still for 1.5 s. The impact effect only
shows on targets that did not resist, and the ray reaches 1800 for the
wider view. Mörsergranate: the impact radius grows by a tenth of its size
every 5 skill levels. Art levels come from a shared helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-26 02:30:29 +01:00
1 parent 10c97f75e1
commit 2cab6c216d
6 files changed
+274 -12

No files matched your search

+1
View File
@@ -60,6 +60,7 @@ add_library(sacredK SHARED
src/mods/combos.cpp src/mods/combos.cpp
src/mods/aurahits.cpp src/mods/aurahits.cpp
src/mods/chainlightning.cpp src/mods/chainlightning.cpp
src/mods/dwarfarts.cpp
src/mods/arttooltip.cpp src/mods/arttooltip.cpp
src/mods/escape.cpp src/mods/escape.cpp
src/mods/recordcache.cpp src/mods/recordcache.cpp
+48
View File
@@ -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).
+13 -3
View File
@@ -86,8 +86,8 @@ constexpr uint16_t kArtHeldenmut = 0x3f7;
constexpr uint16_t kArtKlingenblick = 0x3f8; constexpr uint16_t kArtKlingenblick = 0x3f8;
constexpr uint16_t kArtEnergieblitze = 0x3fb; constexpr uint16_t kArtEnergieblitze = 0x3fb;
constexpr uint16_t kArtFlammenwerfer = 0x43d; constexpr uint16_t kArtFlammenwerfer = 0x43d;
constexpr uint16_t kArtMeteor = 0x43e; constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
constexpr uint16_t kArtKanone = 0x43f; constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone)
// cCreatureArts fields. // cCreatureArts fields.
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner 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 // Arts regenerating in the magic group whatever their entry says
// (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze). // (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze).
inline bool InMagicRegenGroup(uint32_t group, uint16_t art) { inline bool InMagicRegenGroup(uint32_t group, uint16_t art) {
return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMeteor || return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMoerser ||
art == kArtKanone; art == kArtKanone;
} }
@@ -160,6 +160,16 @@ inline bool EntryAtMagicRate(const uint8_t* entry) {
*reinterpret_cast<const uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze; *reinterpret_cast<const uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
} }
// The level of art `art` in cCreatureArts `arts` (cooldown entry level + bonus
// levels), 0 if it has none.
inline unsigned ArtLevelIn(uint8_t* arts, uint16_t art) {
const uint8_t* entry = FindCooldownEntry(arts, art);
return entry ? entry[kCooldownLevel] + entry[kCooldownBonusLevels] : 0;
}
inline unsigned ArtLevel(void* creature, uint16_t art) {
return ArtLevelIn(static_cast<uint8_t*>(creature) + kCreatureArts, art);
}
struct ArtCooldown { struct ArtCooldown {
float remaining = 0.0f; // in the game's units float remaining = 0.0f; // in the game's units
float total = 0.0f; float total = 0.0f;
+2 -7
View File
@@ -149,11 +149,6 @@ unsigned Chains(unsigned level) {
return 1 + level / 10; return 1 + level / 10;
} }
unsigned ArtLevel(void* creature, uint16_t art) {
uint8_t* entry = game::FindCooldownEntry(static_cast<uint8_t*>(creature) + game::kCreatureArts, art);
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
}
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) { bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
return std::find(ids.begin(), ids.end(), id) != ids.end(); return std::find(ids.begin(), ids.end(), id) != ids.end();
} }
@@ -276,7 +271,7 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
return; return;
} }
Forget(); Forget();
const unsigned level = ArtLevel(caster, g_heroArt); const unsigned level = game::ArtLevel(caster, g_heroArt);
const uint32_t hits = Hits(level); const uint32_t hits = Hits(level);
const unsigned chains = Chains(level); const unsigned chains = Chains(level);
event[kEventHitsLeft] = hits; event[kEventHitsLeft] = hits;
@@ -416,7 +411,7 @@ std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) {
} }
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) { void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
text = Rewrite(text, ArtLevel(creature, art.Id())); text = Rewrite(text, game::ArtLevel(creature, art.Id()));
} }
struct TooltipRegistration { struct TooltipRegistration {
+1 -2
View File
@@ -33,8 +33,7 @@ using namespace game; // combo and cooldown layout (arts.h)
// The art's level now (cooldown entry level + bonus levels), 0 if unknown. // The art's level now (cooldown entry level + bonus levels), 0 if unknown.
uint16_t CurrentLevel(uint8_t* arts, uint32_t art) { uint16_t CurrentLevel(uint8_t* arts, uint32_t art) {
uint8_t* entry = art ? FindCooldownEntry(arts, static_cast<uint16_t>(art)) : nullptr; return art ? static_cast<uint16_t>(ArtLevelIn(arts, static_cast<uint16_t>(art))) : 0;
return entry ? static_cast<uint16_t>(entry[kCooldownLevel] + entry[kCooldownBonusLevels]) : 0;
} }
void SyncCombos(uint8_t* hero) { void SyncCombos(uint8_t* hero) {
+209
View File
@@ -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 <windows.h>
#include <cstdint>
#include <cstring>
#include <map>
#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<uint32_t, DWORD> g_stunEnds; // creature id -> GetTickCount at which its stun ends
constexpr uint32_t kGrenadeImpactType = 0x3fc; // TYPE_FX_GRENADEIMPACT
constexpr uint32_t kImpactGatherCall = 0x005ab63d; // in 0x5ab400
constexpr uint32_t kGatherInRange = 0x005ff820; // cObjectManager: objects within type sizes
constexpr size_t kFxOwner = 0x54; // u32: the creature the effect hits for
constexpr unsigned kLevelsPerStep = 5;
constexpr float kRadiusGrowth = 0.1f; // of the impact's own size, per step
// The size of object type `type` (0x428bb0 on the type table *0xaa9564,
// 50 when the table has none).
int32_t TypeSize(uint32_t type) {
using Fn = int32_t(__fastcall*)(void* table, void* edx, uint32_t type);
return reinterpret_cast<Fn>(0x00428bb0)(*reinterpret_cast<void**>(0x00aa9564), nullptr, type);
}
// 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius),
// ret 0x14) with the effect object in ebx.
__declspec(naked) void GatherTargetsThunk() {
__asm {
push ebx // the effect object
push dword ptr [esp+18h] // radius (-1: the type's size)
push dword ptr [esp+18h] // only objects of this type (0: any)
push dword ptr [esp+18h] // type
push dword ptr [esp+18h] // position
push dword ptr [esp+18h] // out vector
call GatherTargets
ret 14h
}
}
void OnOrder(void* creature, const game::CreatureOrder& order, void* caller) {
if (order.command == kOrderStun && reinterpret_cast<uint32_t>(caller) == kCannonStunOrderReturn) {
g_stunEnds[*reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(creature) + game::kCreatureId)] =
GetTickCount() + kStunMs;
}
}
// The push step of `creature` with `left` push left after it: the push left
// to store (low dword) and whether to stay in place (high dword).
uint64_t __cdecl PushStep(uint8_t* creature, int32_t left) {
const auto it = g_stunEnds.find(*reinterpret_cast<uint32_t*>(creature + game::kCreatureId));
if (it == g_stunEnds.end()) {
return static_cast<uint32_t>(left);
}
const bool stunned = static_cast<int32_t>(GetTickCount() - it->second) < 0;
if (!stunned) {
g_stunEnds.erase(it);
}
return (uint64_t{1} << 32) | static_cast<uint32_t>(stunned ? 1 : 0);
}
__declspec(naked) void PushStepThunk() {
__asm {
push eax // push left after this step
push ebp // the creature
call PushStep
add esp, 8
test edx, edx
jz move
xor ebx, ebx // x offset
mov dword ptr [esp+64h], 0 // y offset (the caller's [esp+60h])
move:
mov edi, [esp+64h]
mov [ebp+24dh], eax
lea edx, [ebp+18h]
ret
}
}
// The cannon creates its impact effect (TYPE_FX_DWARF_SHOOT_IMPACT 0x47f,
// attached to the target with cEventFX 0x9c) on every target it hits; it stays
// on targets that were not pushed. CreateAt (0x5fbaa0, thiscall (type,
// position, link, 0, 0), ret 0x14) at 0x517855, the target in ebx: only for
// targets in the stun state (the order was not resisted); 0 otherwise, which
// the game skips (GetObject(0) is null).
constexpr uint32_t kImpactCreate = 0x00517855;
constexpr uint32_t kCreateAt = 0x005fbaa0;
constexpr size_t kCreatureFlags = 0x200;
constexpr uint32_t kCreaturePushed = 0x20000; // the stun's push state (cCreature::Update 0x594c82)
uint32_t __fastcall CreateImpact(void* manager, void*, uint32_t type, const void* position, uint32_t link, uint32_t a,
uint32_t b, const uint8_t* target) {
if (!target || !(*reinterpret_cast<const uint32_t*>(target + kCreatureFlags) & kCreaturePushed)) {
return 0;
}
using Fn = uint32_t(__fastcall*)(void*, void*, uint32_t, const void*, uint32_t, uint32_t, uint32_t);
return reinterpret_cast<Fn>(kCreateAt)(manager, nullptr, type, position, link, a, b);
}
__declspec(naked) void CreateImpactThunk() {
__asm {
push ebx // the target
push dword ptr [esp+18h]
push dword ptr [esp+18h]
push dword ptr [esp+18h]
push dword ptr [esp+18h]
push dword ptr [esp+18h]
call CreateImpact
ret 14h
}
}
void Startup() {
const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk));
re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)");
uint8_t call[sizeof(kPushStepCode)];
std::memset(call, 0x90, sizeof(call));
call[0] = 0xe8;
const int32_t rel = reinterpret_cast<int32_t>(&PushStepThunk) - static_cast<int32_t>(kPushStep + 5);
std::memcpy(call + 1, &rel, sizeof(rel));
const bool hold = re::ReplaceCode(kPushStep, kPushStepCode, call, sizeof(call));
re::Log("dwarfarts: Kanonenschuss stun holds in place%s", hold ? "" : " (patch FAILED)");
const bool stun = re::ReplaceCode(kCannonStunMotion, kStunMotionPush, kSkipStunMotion, sizeof(kSkipStunMotion));
re::Log("dwarfarts: Kanonenschuss without stun motion%s", stun ? "" : " (patch FAILED)");
const bool range = re::PatchInstructionByte(kCannonRaySteps, 2, kRayStepsOriginal, kRaySteps);
re::Log("dwarfarts: Kanonenschuss range %d%s", kRaySteps * 40, range ? "" : " (patch FAILED)");
const bool radius =
re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast<const void*>(&GatherTargetsThunk));
re::Log("dwarfarts: Mörsergranate radius%s", radius ? "" : " (patch FAILED)");
}
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<const uint32_t*>(fx + kFxOwner));
const unsigned level = owner ? game::ArtLevel(owner, game::kArtMoerser) : 0;
const int32_t size = TypeSize(type);
radius = static_cast<int32_t>(static_cast<float>(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<Fn>(kGatherInRange)(manager, nullptr, out, position, type, only, radius);
}
} // namespace mods::dwarfarts