From a4d56834037416a84e00a9b7ff30538326b1b4c3 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Mon, 26 Oct 2026 22:08:08 +0100 Subject: [PATCH] =?UTF-8?q?M=C3=B6rsergranate:=20impact=20marker=20on=20th?= =?UTF-8?q?e=20ground=20until=20the=20explosion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/dwarf-arts.md | 24 ++++ docs/todo.md | 9 -- src/game/engine/worldview.h | 48 ++++++++ src/mods/dwarfarts.cpp | 212 +++++++++++++++++++++++++++++++++++- tools/names.txt | 3 + 5 files changed, 283 insertions(+), 13 deletions(-) create mode 100644 src/game/engine/worldview.h diff --git a/docs/dwarf-arts.md b/docs/dwarf-arts.md index 72e5b6f..c9efccb 100644 --- a/docs/dwarf-arts.md +++ b/docs/dwarf-arts.md @@ -46,3 +46,27 @@ - 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). + +## 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. diff --git a/docs/todo.md b/docs/todo.md index c144d2b..ddcee32 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 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" Asked 2026-10-15: life leech (property 0x349, "Lebensentzug") becomes diff --git a/src/game/engine/worldview.h b/src/game/engine/worldview.h new file mode 100644 index 0000000..b8fac38 --- /dev/null +++ b/src/game/engine/worldview.h @@ -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 + +#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(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(view + kViewZoom); + const auto cameraX = static_cast(*reinterpret_cast(view + kViewCameraX)); + const auto cameraY = static_cast(*reinterpret_cast(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(addr::g_dxDriver); + return driver ? driver->device : nullptr; +} + +} // namespace game diff --git a/src/mods/dwarfarts.cpp b/src/mods/dwarfarts.cpp index b747bde..9ae0f59 100644 --- a/src/mods/dwarfarts.cpp +++ b/src/mods/dwarfarts.cpp @@ -24,16 +24,32 @@ // 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/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" @@ -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 only, int32_t radius, const uint8_t* fx); +void DrawMarkers(const uint8_t* view); namespace { @@ -81,6 +98,14 @@ int32_t TypeSize(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 ImpactRadius(void* owner) { + const unsigned level = owner ? game::ArtLevel(owner, game::kArtMoerser) : 0; + const int32_t size = TypeSize(kGrenadeImpactType); + return static_cast(static_cast(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep))); +} + // 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius), // ret 0x14) with the effect object in ebx. __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 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)"); @@ -185,6 +355,9 @@ void Startup() { 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); @@ -197,13 +370,44 @@ struct Registration { 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))); + 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 diff --git a/tools/names.txt b/tools/names.txt index 3b271d1..95ca81e 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -437,3 +437,6 @@ 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 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