Mörsergranate: impact marker on the ground until the explosion

The landing point is noted from the grenade's cEventFX 0x96 at launch; a
translucent red circle with the explosion radius (shared ImpactRadius) is
drawn after the world's ground, under the objects, and ends when that
grenade's impact gathers its targets. Ground -> screen projection in
game/engine/worldview.h.

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 22:08:08 +01:00
1 parent 32f953fc01
commit a4d5683403
5 files changed
+283 -13

No files matched your search

+24
View File
@@ -46,3 +46,27 @@
- The impact's gather call gets a radius of size × (1 + 0.1 × (level / 5)), - 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 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). step is our choice; not yet seen in the game (Borf's level is 2).
## Mörsergranate impact marker (asked 2026-10-25, done 2026-10-26)
"Show exactly where the grenade will land and its explosion radius, a red
circle with low alpha on the ground."
Measured:
- The landing point is decided at launch (`0x5145d0` case 0x43e): the
target's position, or, closer than 200, a point 200 from the dwarf with a
+-15 jitter. It goes to the new grenade (type 0x3fa) in cEventFX 0x96,
constructed at `0x5169fa` (`cEventFX::Construct` 0x41e0a0, thiscall, ret
0x44: code, owner id, target id, position area / x / y / level, ...).
- The impact gathers its targets at exactly that point, on several frames.
The grenade object stays about 30 s after the explosion.
- Ground -> screen: `0x622700` (world units -> world pixels, the isometric
plane), then (pixel - camera +0x14/+0x18) / zoom +0xb60 + screen centre
(floating texts 0x41e546). Checked with the hero: the screen centre.
- The world view draws the ground (`0x62d530`, ground quad list), then the
objects.
Mod: the construct call notes grenade, owner, landing point and radius
(`ImpactRadius`, shared with the hit check); after `0x62d530` a disc (alpha
0x40) with a 2 px rim (alpha 0xa0) is drawn there, without depth test, until
the impact of that owner's grenade at that point gathers its targets.
-9
View File
@@ -42,15 +42,6 @@ Known so far: the periodic-hit aura (effect kind 0x12) uses
mapped yet. Other arts' ranges are not mapped. Fits the data-driven arts 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). item above (range as a property of an art, one place to compute it).
Mörsergranate impact indicator (asked 2026-10-25, while reworking the dwarf's
Kanonenschuss / Mörsergranate): show exactly where the grenade will land and
its explosion radius, a red circle with low alpha on the ground. The landing
point seems to be decided at launch (cCreature combat move code `0x5145d0`,
case 0x43e: the target point, changed by a random offset when closer than
200; passed as the position of the grenade's cEventFX 0x96), so the circle
could be drawn from the launch until the explosion; to be confirmed with the
diagnostic log. To be built after the radius-per-level change.
## Rename life leech to "omnivamp" ## Rename life leech to "omnivamp"
Asked 2026-10-15: life leech (property 0x349, "Lebensentzug") becomes Asked 2026-10-15: life leech (property 0x349, "Lebensentzug") becomes
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// The world view (cWorldView0, vtable 0x8900f8): camera and the projection of
// world positions onto the screen. docs/widescreen.md.
#include <cstdint>
#include "game/addresses.h"
#include "game/render/dx_driver7.h"
namespace game {
constexpr size_t kViewCameraX = 0x14; // int32, world pixels
constexpr size_t kViewCameraY = 0x18;
constexpr size_t kViewZoom = 0xb60; // float, 0.5 .. 2.0
constexpr float kViewHalfHeight = 384.0f; // the render height stays 768
struct Point2 {
float x, y;
};
// World units -> world pixels: the ground plane's isometric projection
// (0x622700, cdecl (out, in), plain ret; matrix at 0xad5710; a 53.66563-unit
// tile is a 96x48 pixel diamond). The large map uses it too.
inline Point2 WorldToPlane(float x, float y) {
float in[3] = {x, y, 0};
float out[3] = {};
using Fn = void(__cdecl*)(float*, const float*);
reinterpret_cast<Fn>(0x00622700)(out, in);
return {out[0], out[1]};
}
// World pixels -> screen: (pixel - camera) / zoom + screen centre (the
// floating texts 0x41e546, the object passes 0x62f925). `halfWidth`: half
// the render width (the centre the widescreen mod gives the view).
inline Point2 PlaneToScreen(const uint8_t* view, const Point2& plane, float halfWidth) {
const float zoom = *reinterpret_cast<const float*>(view + kViewZoom);
const auto cameraX = static_cast<float>(*reinterpret_cast<const int32_t*>(view + kViewCameraX));
const auto cameraY = static_cast<float>(*reinterpret_cast<const int32_t*>(view + kViewCameraY));
return {(plane.x - cameraX) / zoom + halfWidth, (plane.y - cameraY) / zoom + kViewHalfHeight};
}
// The Direct3D 7 device the world is drawn with.
inline void* RenderDevice() {
const auto* driver = *reinterpret_cast<DxDriver7* const*>(addr::g_dxDriver);
return driver ? driver->device : nullptr;
}
} // namespace game
+208 -4
View File
@@ -24,16 +24,32 @@
// plus the creature type's size (object type table 0x428bb0). The mod passes // 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 // that call a radius of size × (1 + kRadiusGrowth × (level / 5)), with the
// level of the owner's Mörsergranate. // 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 <windows.h> #include <windows.h>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <algorithm>
#include <cmath>
#include <map> #include <map>
#include <vector>
#include "game/engine/arts.h" #include "game/engine/arts.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.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/hook.h"
#include "re/log.h" #include "re/log.h"
#include "re/patch.h" #include "re/patch.h"
@@ -42,6 +58,7 @@ namespace mods::dwarfarts {
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type, 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); uint32_t only, int32_t radius, const uint8_t* fx);
void DrawMarkers(const uint8_t* view);
namespace { namespace {
@@ -81,6 +98,14 @@ int32_t TypeSize(uint32_t type) {
return reinterpret_cast<Fn>(0x00428bb0)(*reinterpret_cast<void**>(0x00aa9564), nullptr, type); return reinterpret_cast<Fn>(0x00428bb0)(*reinterpret_cast<void**>(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 ImpactRadius(void* owner) {
const unsigned level = owner ? game::ArtLevel(owner, game::kArtMoerser) : 0;
const int32_t size = TypeSize(kGrenadeImpactType);
return static_cast<int32_t>(static_cast<float>(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep)));
}
// 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius), // 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius),
// ret 0x14) with the effect object in ebx. // ret 0x14) with the effect object in ebx.
__declspec(naked) void GatherTargetsThunk() { __declspec(naked) void GatherTargetsThunk() {
@@ -168,6 +193,151 @@ __declspec(naked) void CreateImpactThunk() {
} }
} }
// --- 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<Grenade> 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<float>(static_cast<int32_t>(args[4]));
const auto y = static_cast<float>(static_cast<int32_t>(args[5]));
const auto radius = static_cast<float>(ImpactRadius(game::GetCreature(args[1])));
g_grenades.push_back({grenade, *reinterpret_cast<uint32_t*>(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<void***>(device);
set_ = reinterpret_cast<SetRenderStateFn>(vtable[game::kDeviceSetRenderState]);
const auto get = reinterpret_cast<GetRenderStateFn>(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<float>(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<float>(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<game::ScreenVertex> 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() { void Startup() {
const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk)); const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk));
re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)"); re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)");
@@ -185,6 +355,9 @@ void Startup() {
const bool radius = const bool radius =
re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast<const void*>(&GatherTargetsThunk)); re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast<const void*>(&GatherTargetsThunk));
re::Log("dwarfarts: Mörsergranate radius%s", radius ? "" : " (patch FAILED)"); re::Log("dwarfarts: Mörsergranate radius%s", radius ? "" : " (patch FAILED)");
const bool marker = re::RedirectCall(kGrenadeEventConstruct, kFxEventConstruct,
reinterpret_cast<const void*>(&GrenadeEventThunk));
re::Log("dwarfarts: Mörsergranate impact marker%s", marker ? "" : " (patch FAILED)");
} }
RE_STARTUP(DwarfArtsStartup, Startup); RE_STARTUP(DwarfArtsStartup, Startup);
@@ -197,13 +370,44 @@ struct Registration {
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type, 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) { uint32_t only, int32_t radius, const uint8_t* fx) {
if (type == kGrenadeImpactType && radius < 0 && fx) { if (type == kGrenadeImpactType && radius < 0 && fx) {
void* owner = game::GetCreature(*reinterpret_cast<const uint32_t*>(fx + kFxOwner)); const uint32_t owner = *reinterpret_cast<const uint32_t*>(fx + kFxOwner);
const unsigned level = owner ? game::ArtLevel(owner, game::kArtMoerser) : 0; radius = ImpactRadius(game::GetCreature(owner));
const int32_t size = TypeSize(type); // The position: area (u16), x, y (int32, world units), level.
radius = static_cast<int32_t>(static_cast<float>(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep))); const auto* at = static_cast<const uint8_t*>(position);
OnImpact(owner, static_cast<float>(*reinterpret_cast<const int32_t*>(at + 4)),
static_cast<float>(*reinterpret_cast<const int32_t*>(at + 8)));
} }
using Fn = uint32_t*(__fastcall*)(void*, void*, uint32_t*, const void*, uint32_t, uint32_t, int32_t); 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); return reinterpret_cast<Fn>(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 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
+3
View File
@@ -437,3 +437,6 @@
0x006336b0 World_VisitedBorderCell # thiscall (cx, cy), ret 8: border byte, 0 outside 100x100 0x006336b0 World_VisitedBorderCell # thiscall (cx, cy), ret 8: border byte, 0 outside 100x100
0x006c8580 cUI_Megamap::DrawVisitedBorder # thiscall (device), ret 4: 2x2 px red (0xffff0000) dot per border bit, +-10/+-20 px from the sector origin 0x006c8580 cUI_Megamap::DrawVisitedBorder # thiscall (device), ret 4: 2x2 px red (0xffff0000) dot per border bit, +-10/+-20 px from the sector origin
0x006c87c0 cUI_Megamap::DrawQuestMarkers # thiscall (device), ret 4: quest book entries (0xaaaf00) 0x006c87c0 cUI_Megamap::DrawQuestMarkers # thiscall (device), ret 4: quest book entries (0xaaaf00)
0x00622700 WorldToPlane # cdecl (out vec3, in vec3), plain ret: world units -> world pixels (iso matrix 0xad5710)
0x0062d530 cWorldView0::DrawGroundQuads # thiscall (renderer), ret 4: the ground, before the objects
0x0041e0a0 cEventFX::Construct # thiscall (code, owner, target, position 4 dwords, ...), ret 0x44