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:
1 parent
10c97f75e1
commit
2cab6c216d
6 files changed
+274
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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<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 {
|
||||
float remaining = 0.0f; // in the game's units
|
||||
float total = 0.0f;
|
||||
|
||||
@@ -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<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) {
|
||||
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 {
|
||||
|
||||
+1
-2
@@ -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<uint16_t>(art)) : nullptr;
|
||||
return entry ? static_cast<uint16_t>(entry[kCooldownLevel] + entry[kCooldownBonusLevels]) : 0;
|
||||
return art ? static_cast<uint16_t>(ArtLevelIn(arts, static_cast<uint16_t>(art))) : 0;
|
||||
}
|
||||
|
||||
void SyncCombos(uint8_t* hero) {
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user