// 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. // // Impact marker (asked 2026-10-25): where the grenade will land and its // explosion radius, a red circle with low alpha on the ground. The landing // point is decided at launch (0x5145d0 case 0x43e: the target's position, or // 200 units from the dwarf with +-15 jitter when closer) and sent to the new // grenade with cEventFX 0x96 (constructed at 0x5169fa: code, owner, 0, // position area / x / y / level). The construct call notes the grenade and // the point; the circle is drawn after the world view's ground (0x62d530, // before the objects) until the impact gathers its targets (the grenade // object itself stays about 30 s longer). #include #include #include #include #include #include #include #include "game/engine/art.h" #include "game/engine/arts.h" #include "game/engine/objects.h" #include "game/engine/orders.h" #include "game/engine/worldview.h" #include "game/ui/draw.h" #include "mods/widescreen.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); void DrawMarkers(const uint8_t* view); 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); } // The explosion radius of a grenade thrown by `owner` (world units; a // creature is hit when within it plus its own size). int32_t ImpactRadiusAt(unsigned level) { const int32_t size = TypeSize(kGrenadeImpactType); return static_cast(static_cast(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep))); } int32_t ImpactRadius(void* owner) { return ImpactRadiusAt(owner ? game::ArtLevel(owner, game::kArtMoerser) : 0); } // The tooltip (game::Art::Tooltip filter, as for Kettenblitz in // mods/chainlightning.cpp): sections start with the level line ("Aktuelle // Stufe:", in the skill book also "Nächste erlernte Stufe:") followed by // "Name: value" lines; each section gets "Explosionsradius: N" after its last // value, in that line's colour. constexpr const wchar_t* kCurrentLevelLine = L"Aktuelle Stufe:"; constexpr const wchar_t* kNextLevelLine = L"Nächste erlernte Stufe:"; constexpr const wchar_t* kValueSeparator = L": "; constexpr size_t kColourCodeLength = 10; // "\cAARRGGBB" std::wstring LeadingColourCodes(const std::wstring& line) { size_t end = 0; while (line.compare(end, 2, L"\\c") == 0 && end + kColourCodeLength <= line.size()) { end += kColourCodeLength; } return line.substr(0, end); } void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) { std::vector lines; for (size_t start = 0; start < text.size();) { size_t end = text.find(L'\n', start); end = end == std::wstring::npos ? text.size() : end + 1; lines.push_back(text.substr(start, end - start)); start = end; } const unsigned current = game::ArtLevel(creature, art.Id()); std::vector out; unsigned level = current; bool inSection = false; size_t lastValue = SIZE_MAX; // index in `out` const auto closeSection = [&] { if (!inSection || lastValue == SIZE_MAX) { return; } std::wstring& after = out[lastValue]; const bool last = after.empty() || after.back() != L'\n'; if (last) { after += L'\n'; } out.insert(out.begin() + static_cast(lastValue) + 1, LeadingColourCodes(after) + L"Explosionsradius: " + std::to_wstring(ImpactRadiusAt(level)) + (last ? L"" : L"\n")); }; for (const std::wstring& line : lines) { const bool currentHeader = line.find(kCurrentLevelLine) != std::wstring::npos; const bool nextHeader = line.find(kNextLevelLine) != std::wstring::npos; if (currentHeader || nextHeader) { closeSection(); inSection = true; level = nextHeader ? current + 1 : current; lastValue = SIZE_MAX; } else if (inSection && line.find(kValueSeparator) != std::wstring::npos) { lastValue = out.size(); } out.push_back(line); } closeSection(); text.clear(); for (const std::wstring& line : out) { text += line; } } // 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 } } // --- impact marker --------------------------------------------------------- constexpr uint32_t kGrenadeEventConstruct = 0x005169fa; // call cEventFX::Construct constexpr uint32_t kFxEventConstruct = 0x0041e0a0; // thiscall, ret 0x44 constexpr uint32_t kFxEventLand = 0x96; constexpr size_t kObjectId = 0xc; constexpr uint32_t kMarkerFill = 0x40ff0000; // red, low alpha constexpr uint32_t kMarkerRim = 0xa0ff2020; constexpr float kRimWidth = 2.0f; // screen pixels constexpr int kCircleSegments = 48; // A grenade object outlives its explosion by about 30 s (measured // 2026-10-26), so its marker ends with the explosion: the impact gathering // its targets (GatherTargets; it does so on several frames, at exactly the // noted landing point). constexpr float kImpactMatchDistance = 100.0f; // world units; the landing jitter is +-15 struct Grenade { uint8_t* object; uint32_t id; uint32_t owner; float x, y; // landing point, world units float radius; }; std::vector g_grenades; // The explosion of `owner`'s grenade at `position` (world units): ends the // marker of that grenade, the owner's one closest to it. void OnImpact(uint32_t owner, float x, float y) { auto closest = g_grenades.end(); float best = kImpactMatchDistance; for (auto it = g_grenades.begin(); it != g_grenades.end(); ++it) { const float distance = std::hypot(it->x - x, it->y - y); if (it->owner == owner && distance < best) { best = distance; closest = it; } } if (closest != g_grenades.end()) { g_grenades.erase(closest); } } // Construct's arguments: code, owner id, target id, position (area, x, y, // level), ...; the new grenade object is in esi. void __cdecl NoteGrenade(const uint32_t* args, uint8_t* grenade) { if (args[0] != kFxEventLand || !grenade) { return; } const auto x = static_cast(static_cast(args[4])); const auto y = static_cast(static_cast(args[5])); const auto radius = static_cast(ImpactRadius(game::GetCreature(args[1]))); g_grenades.push_back({grenade, *reinterpret_cast(grenade + kObjectId), args[1], x, y, radius}); } __declspec(naked) void GrenadeEventThunk() { __asm { pushad lea eax, [esp+24h] // the arguments (above pushad and the return address) push esi // the grenade push eax call NoteGrenade add esp, 8 popad mov eax, kFxEventConstruct jmp eax } } bool Alive(const Grenade& grenade) { return game::GetObjectById(grenade.id) == grenade.object; } using SetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD); using GetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD*); // Render states for a translucent overlay without depth, restored after. class OverlayStates { public: explicit OverlayStates(void* device) : device_(device) { void** vtable = *static_cast(device); set_ = reinterpret_cast(vtable[game::kDeviceSetRenderState]); const auto get = reinterpret_cast(vtable[game::kDeviceGetRenderState]); for (size_t i = 0; i < kCount; ++i) { get(device_, kStates[i], &saved_[i]); set_(device_, kStates[i], kValues[i]); } } ~OverlayStates() { for (size_t i = 0; i < kCount; ++i) { set_(device_, kStates[i], saved_[i]); } } OverlayStates(const OverlayStates&) = delete; OverlayStates& operator=(const OverlayStates&) = delete; private: // D3DRENDERSTATE ZENABLE, ZWRITEENABLE off; alpha blending SRCALPHA / // INVSRCALPHA. static constexpr size_t kCount = 5; static constexpr DWORD kStates[kCount] = {7, 14, game::kStateAlphaBlendEnable, game::kStateSourceBlend, game::kStateDestinationBlend}; static constexpr DWORD kValues[kCount] = {FALSE, FALSE, TRUE, 5, 6}; void* device_; SetRenderStateFn set_ = nullptr; DWORD saved_[kCount] = {}; }; game::ScreenVertex Vertex(const game::Point2& p, uint32_t colour) { return {p.x, p.y, 0, 1, colour, 0, 0, 0}; } // The circle on the ground: a translucent disc and a brighter rim. void DrawMarker(const uint8_t* view, void* device, const Grenade& grenade) { const float halfWidth = static_cast(mods::widescreen::RenderWidth()) / 2; const auto screen = [&](float x, float y) { return game::PlaneToScreen(view, game::WorldToPlane(x, y), halfWidth); }; const game::Point2 centre = screen(grenade.x, grenade.y); game::Point2 rim[kCircleSegments + 1]; for (int i = 0; i <= kCircleSegments; ++i) { const float angle = 6.2831853f * static_cast(i % kCircleSegments) / kCircleSegments; rim[i] = screen(grenade.x + grenade.radius * std::cos(angle), grenade.y + grenade.radius * std::sin(angle)); } // The rim band reaches inwards from the circle by kRimWidth. const auto inner = [&](const game::Point2& p) { const float dx = centre.x - p.x, dy = centre.y - p.y; const float length = std::hypot(dx, dy); return length > kRimWidth ? game::Point2{p.x + dx / length * kRimWidth, p.y + dy / length * kRimWidth} : centre; }; static std::vector vertices; vertices.clear(); for (int i = 0; i < kCircleSegments; ++i) { vertices.insert(vertices.end(), {Vertex(centre, kMarkerFill), Vertex(rim[i], kMarkerFill), Vertex(rim[i + 1], kMarkerFill)}); } for (int i = 0; i < kCircleSegments; ++i) { const game::Point2 a = rim[i], b = rim[i + 1], c = inner(rim[i]), d = inner(rim[i + 1]); vertices.insert(vertices.end(), {Vertex(a, kMarkerRim), Vertex(b, kMarkerRim), Vertex(c, kMarkerRim), Vertex(c, kMarkerRim), Vertex(b, kMarkerRim), Vertex(d, kMarkerRim)}); } game::FillTriangles(device, vertices.data(), vertices.size()); } 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)"); const bool marker = re::RedirectCall(kGrenadeEventConstruct, kFxEventConstruct, reinterpret_cast(&GrenadeEventThunk)); re::Log("dwarfarts: Mörsergranate impact marker%s", marker ? "" : " (patch FAILED)"); } RE_STARTUP(DwarfArtsStartup, Startup); struct Registration { Registration() { game::OnCreatureOrder(&OnOrder); game::AddTooltipFilter(game::kArtMoerser, &RewriteTooltip); } } 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) { const uint32_t owner = *reinterpret_cast(fx + kFxOwner); radius = ImpactRadius(game::GetCreature(owner)); // The position: area (u16), x, y (int32, world units), level. const auto* at = static_cast(position); OnImpact(owner, static_cast(*reinterpret_cast(at + 4)), static_cast(*reinterpret_cast(at + 8))); } 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); } // The impact markers of the grenades in flight, on the ground of `view`. void DrawMarkers(const uint8_t* view) { g_grenades.erase(std::remove_if(g_grenades.begin(), g_grenades.end(), [](const Grenade& g) { return !Alive(g); }), g_grenades.end()); void* device = game::RenderDevice(); if (g_grenades.empty() || !device) { return; } OverlayStates states(device); for (const Grenade& grenade : g_grenades) { DrawMarker(view, device, grenade); } } } // namespace mods::dwarfarts namespace game { // cWorldView0::DrawGroundQuads (0x62d530, thiscall (renderer), ret 4): the // ground; the objects are drawn after it. void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer); RE_HOOK(0x0062d530, WorldView_DrawGroundQuads); void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer) { WorldView_DrawGroundQuads_original(self, edx, renderer); if (self) { mods::dwarfarts::DrawMarkers(self); } } } // namespace game