Compare commits

..
5 Commits
Author SHA1 Message Date
khallaandClaude Opus 5.5 e539cab604 Followers: escorts left behind are teleported to the hero
A follower that fell more than 40 cells behind was removed when its sector
was switched off, and the game ran its death label (the husband of "Die
Sklaven der Orks"). Every 500 ms followers of the local hero that far away
are teleported to him with cCreature::Teleport.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 23:39:21 +01:00
khallaandClaude Opus 5.5 42b99526a4 Guard: grenade impact particles out of the colour table
Mörsergranate's explosion crashed twice reading the colour of a particle
with a huge life (sacred.exe+0x361b54). Particles with life >= 256 or the
unset fourth row are switched off before drawing; logged cases were all
row 3 with garbage sizes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 23:39:20 +01:00
khallaandClaude Opus 5.5 91e7f844cc Mörsergranate: explosion radius in the tooltip
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 22:21:50 +01:00
khallaandClaude Opus 5.5 a4d5683403 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>
2026-10-26 22:08:08 +01:00
khallaandClaude Opus 5.5 32f953fc01 Large map: explored border as a continuous line along the fog edge
The game drew 2x2 px red dots per visited-sector neighbour (0x6c8580), in
64-tile steps and unrelated to the explored mask. Replaced by lines traced
from the explored cells (2x2 tiles), simplified to 0.75 px, rebuilt while
the map is open when the masks change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 21:57:30 +01:00
17 changed files with 1021 additions and 48 deletions

No files matched your search

+1
View File
@@ -76,6 +76,7 @@ add_library(sacredK SHARED
src/mods/screen.cpp src/mods/screen.cpp
src/mods/characters.cpp src/mods/characters.cpp
src/mods/mainmenu.cpp src/mods/mainmenu.cpp
src/mods/mapborder.cpp
src/mods/mapreveal.cpp src/mods/mapreveal.cpp
src/mods/questbook.cpp src/mods/questbook.cpp
src/mods/followers.cpp src/mods/followers.cpp
+41
View File
@@ -46,3 +46,44 @@
- 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.
Tooltip (2026-10-26, "the skills tooltip does not show the current radius"):
a game::Art tooltip filter adds "Explosionsradius: N" after the last value
line of each level section (current level, and the next level's in the skill
book), in that line's colour, from the same `ImpactRadiusAt(level)` as the
hit check and the marker.
## Grenade impact crash (2026-10-26)
Crashes at sacred.exe+0x361b54 (2026-10-26 00:12 and 22:39) in the particle
drawer `0x7613e0`, called by `cParticleSystem_grenadeimpact::Render` (vf04
0x7b66b0): colour = table +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and a
particle had a life of over 500000. Guard (`mods/guards.cpp`): live particles
with life >= 256 or row >= 3 are switched off before drawing. Logged in the
next session: 5 particles, all row 3, life 255, garbage sizes (e.g. 190555,
4.6e-40): the explosion emits into a fourth row whose emitter parameters it
never sets (vf10 0x7b6980 fills three rows at +0x30d0). No crash since.
+21
View File
@@ -42,3 +42,24 @@ default (0x6ce677), 300 where 0x63d280 holds for the hero's area (0x6ce6b5),
0x182cdb8 & 2 shows them. The mod scales the three base values by 10 as well 0x182cdb8 & 2 shows them. The mod scales the three base values by 10 as well
(about 120 tiles by day). The view only lists creatures of the sectors around (about 120 tiles by day). The view only lists creatures of the sectors around
the hero (25 entries at [esp+1ech]). the hero (25 entries at [esp+1ech]).
## Border line on the large map (2026-10-26)
"The red outline on the mega map is choppy and feels weird."
Measured (disassembly): `cUI_Megamap::DrawVisitedBorder` 0x6c8580 drew a
2x2 px red dot (0xffff0000) per bit of a 100x100 byte grid (world +0xb73c):
unvisited sectors next to visited ones (sector flag 0x20000, set when the
hero's sector becomes the centre of the active 3x3, 0x626603), one bit per
visited neighbour, the dot 10 / 20 px from the sector origin towards it.
Built on load by 0x633230 and per sector change by 0x6332d0 / 0x6334f0. So:
dots in 64-tile steps, following the sectors stood in rather than the
explored mask the fog shows.
Mod (`mods/mapborder.cpp`): the draw is replaced. The sides between explored
and unexplored cells (2x2 tiles) of the 100x100 map sectors are chained into
polylines (clockwise around explored cells; at a corner touch the chain turns
right), projected with `WorldToMapPixel` (linear), simplified to 0.75 px
(Douglas-Peucker) and drawn as 2 px red lines. Rebuilt while the map is open
when the masks change (checked every 250 ms). Cells of missing sectors count
as neither explored nor unexplored, so no line runs along the world's edge.
+19
View File
@@ -236,3 +236,22 @@ audit of how quests can fail.
Lesson (2026-10-26): flag 1 means available, not running; a first version Lesson (2026-10-26): flag 1 means available, not running; a first version
took it for running and reset 16 available quests and spawned their NPCs on took it for running and reset 16 available quests and spawned their NPCs on
Borf (repaired once, then saved). Borf (repaired once, then saved).
## Escorts never left behind (2026-10-26)
"I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no
longer following me" (Borf). Measured in the log: the husband (Res:17584,
type 86; spawned in `startcode.bin` with death label `drop2412`) fell behind,
the game's own catch-up teleport moved him once (type 86 line), then the hero
walked on, the sectors behind were switched off, and the game removed him
and ran `drop2412` (quest variable 5, a token item res:17585 dropped where he
was; picking it up runs `take2412`, the "husband lost" branch). His object id
was reused right after: removed, not dead. The teleport potion came 50 s
later and was not involved; portals and the potion take followers along.
`mods/followers.cpp`: every 500 ms each follower of the local hero more than
40 cells away (sectors are switched off at 65 and more) on the same level is
teleported to the hero with `cCreature::Teleport` 0x54d840 (thiscall (cell x,
cell y, 0, 1), ret 0x10). Borf's husband was respawned once by a temporary
repair (his spawn instruction, variable 1, token removed, `follow2412`); the
quest then finished with him alive.
-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
+32
View File
@@ -14,6 +14,38 @@ namespace game {
// The world's sector grid (ecx of World_SetExplored). // The world's sector grid (ecx of World_SetExplored).
inline void* World() { return *reinterpret_cast<void**>(0x00ad14e0); } inline void* World() { return *reinterpret_cast<void**>(0x00ad14e0); }
// The sector grid: 128x128 sectors of 64x64 tiles (tiles < 0x2000), a u16
// index per sector at +0x284 (row-major, 0 = none) into the sector pointer
// vector at +0x240 (World_SetExplored 0x638f00).
constexpr int kSectorTiles = 64;
constexpr int kSectorGrid = 128;
constexpr int kExploredCellTiles = 2; // one mask bit per 2x2 tiles
constexpr int kSectorCells = kSectorTiles / kExploredCellTiles; // 32
constexpr size_t kSectorExploredMask = 0x180; // 32 rows of 4 bytes
inline const uint8_t* SectorAt(int sectorX, int sectorY) {
const auto* world = static_cast<const uint8_t*>(World());
if (!world || sectorX < 0 || sectorY < 0 || sectorX >= kSectorGrid || sectorY >= kSectorGrid) {
return nullptr;
}
const uint16_t index = reinterpret_cast<const uint16_t*>(world + 0x284)[sectorY * kSectorGrid + sectorX];
if (index == 0) {
return nullptr;
}
return reinterpret_cast<const uint8_t* const*>(*reinterpret_cast<const uintptr_t*>(world + 0x240))[index];
}
// The sector's explored mask (128 bytes), or null without a sector.
inline const uint8_t* SectorExploredMask(int sectorX, int sectorY) {
const uint8_t* sector = SectorAt(sectorX, sectorY);
return sector ? sector + kSectorExploredMask : nullptr;
}
// Bit of cell (x, y) within its sector's mask (Sector_SetExplored 0x622bf0).
inline bool MaskCellExplored(const uint8_t* mask, int cellX, int cellY) {
return (mask[cellY * 4 + (cellX >> 3)] >> (cellX & 7)) & 1;
}
// World_SetExplored (0x638f00, thiscall (tile x, tile y, explored), ret 0xc): // World_SetExplored (0x638f00, thiscall (tile x, tile y, explored), ret 0xc):
// sets the bit of the tile's sector; tiles outside the world (>= 0x2000) or in // sets the bit of the tile's sector; tiles outside the world (>= 0x2000) or in
// a sector that isn't loaded are ignored. // a sector that isn't loaded are ignored.
+18 -5
View File
@@ -97,16 +97,29 @@ inline void AddFollower(void* leader, const FollowerEntry& entry) {
reinterpret_cast<Fn>(0x0054b070)(leader, nullptr, &entry); reinterpret_cast<Fn>(0x0054b070)(leader, nullptr, &entry);
} }
inline bool HasFollower(const void* leader, uint32_t id) { // The ids on the leader's follower list.
template <typename Fn>
void ForEachFollower(const void* leader, Fn&& fn) {
const auto* creature = static_cast<const uint8_t*>(leader); const auto* creature = static_cast<const uint8_t*>(leader);
const auto* begin = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersBegin); const auto* begin = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersBegin);
const auto* end = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersEnd); const auto* end = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersEnd);
for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) { for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) {
if (*reinterpret_cast<const uint32_t*>(entry) == id) { fn(*reinterpret_cast<const uint32_t*>(entry));
return true;
} }
} }
return false;
inline bool HasFollower(const void* leader, uint32_t id) {
bool found = false;
ForEachFollower(leader, [&](uint32_t follower) { found = found || follower == id; });
return found;
}
// cCreature::Teleport (0x54d840, thiscall (cell x, cell y, 0, 1), ret 0x10):
// as the portals move the hero (EE_USEPORTAL 0x6197c3); the game's log line
// "Teleport (type): <type>, ... (x,y)".
inline void Teleport(void* creature, int32_t cellX, int32_t cellY) {
using Fn = void(__fastcall*)(void*, void*, int32_t, int32_t, int32_t, int32_t);
reinterpret_cast<Fn>(0x0054d840)(creature, nullptr, cellX, cellY, 0, 1);
} }
// The walk path (measured 2026-10-18): waypoints {x, y} at +0x2ed + i * 8, // The walk path (measured 2026-10-18): waypoints {x, y} at +0x2ed + i * 8,
+1
View File
@@ -5,6 +5,7 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <initializer_list>
namespace game { namespace game {
+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
+16
View File
@@ -80,6 +80,22 @@ inline void FillCorners(void* device, float x0, float y0, float x1, float y1, ui
quad, 4, 0); quad, 4, 0);
} }
// Untextured triangles, three vertices each (a triangle list), in batches the
// device takes in one call.
inline void FillTriangles(void* device, ScreenVertex* vertices, size_t count) {
constexpr DWORD kTriangleList = 4;
constexpr size_t kBatch = 3 * 10000;
void** vtable = *static_cast<void***>(device);
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
for (size_t first = 0; first < count; first += kBatch) {
const size_t batch = count - first < kBatch ? count - first : kBatch;
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(
device, kTriangleList, kFvfScreenColourTexture, vertices + first, static_cast<DWORD>(batch), 0);
}
}
// Fills (x0, y0)-(x1, y1) with the ARGB colour `color`. // Fills (x0, y0)-(x1, y1) with the ARGB colour `color`.
inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) { inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) {
FillCorners(device, x0, y0, x1, y1, color, color, color, color); FillCorners(device, x0, y0, x1, y1, color, color, color, color);
+37
View File
@@ -0,0 +1,37 @@
#pragma once
// The large map (M, cUI_Megamap): a scrollable view of the 2048x2816 world
// map image. docs/hud.md, mods/megamap.cpp.
#include <cstdint>
namespace game {
// Scroll position (map pixel at the view's left / top edge).
constexpr size_t kMegamapScrollX = 0xc016c;
constexpr size_t kMegamapScrollY = 0xc0170;
inline int32_t MegamapScrollX(const uint8_t* megamap) {
return *reinterpret_cast<const int32_t*>(megamap + kMegamapScrollX);
}
inline int32_t MegamapScrollY(const uint8_t* megamap) {
return *reinterpret_cast<const int32_t*>(megamap + kMegamapScrollY);
}
struct Vec3 {
float x, y, z;
};
// cUI_Megamap::WorldToMapPixel (0x6cb130, thiscall (out, world x / y / level),
// ret 8): the map pixel of a world position (world units, 53.66563 per tile).
// Linear: the world plane projection 0x622700, then x * 0.007446 + 1018.75,
// y * 0.014914 - 186.95.
inline Vec3 WorldToMapPixel(uint8_t* megamap, const Vec3& world) {
Vec3 pixel = {};
using Fn = void(__fastcall*)(uint8_t*, void*, Vec3*, const Vec3*);
reinterpret_cast<Fn>(0x006cb130)(megamap, nullptr, &pixel, &world);
return pixel;
}
constexpr float kWorldUnitsPerTile = 53.66563f; // 0x8946b0
} // namespace game
+280 -5
View File
@@ -24,16 +24,33 @@
// 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/art.h"
#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 +59,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 +99,81 @@ 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 ImpactRadiusAt(unsigned level) {
const int32_t size = TypeSize(kGrenadeImpactType);
return static_cast<int32_t>(static_cast<float>(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<std::wstring> 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<std::wstring> 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<ptrdiff_t>(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), // 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 +261,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,11 +423,17 @@ 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);
struct Registration { struct Registration {
Registration() { game::OnCreatureOrder(&OnOrder); } Registration() {
game::OnCreatureOrder(&OnOrder);
game::AddTooltipFilter(game::kArtMoerser, &RewriteTooltip);
}
} g_registration; } g_registration;
} // namespace } // namespace
@@ -197,13 +441,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
+65 -10
View File
@@ -1,15 +1,30 @@
// Escorted NPCs stay on the hero's follower list (2026-10-26, docs/quests.md). // Escorted NPCs stay with the hero (2026-10-26, docs/quests.md).
// "Furchtbares Verbrechen" (quest 9008) did not go on at its target point: //
// the NPC walked after the hero, but the script's test there ("@npc", 0x498aa0 // On the follower list: "Furchtbares Verbrechen" (quest 9008) did not go on at
// case 0x40) looks the NPC up in the hero's follower list (hero +0x39c), and // its target point: the NPC walked after the hero, but the script's test
// that list was empty (measured on Borf's save: NPC 0x1fa with leader 1 and // there ("@npc", 0x498aa0 case 0x40) looks the NPC up in the hero's follower
// the follow bit, no followers on the hero). The NPC keeps its side of the // list (hero +0x39c), and that list was empty (measured on Borf's save: NPC
// escort (leader +0x251, status bit 4) across a load; the hero's list does // 0x1fa with leader 1 and the follow bit, no followers on the hero). The NPC
// not come back. The mod adds every NPC that follows a hero but is missing // keeps its side of the escort (leader +0x251, status bit 4) across a load;
// from its list back with the game's own AddFollower (0x54b070), with the // the hero's list does not come back. The mod adds every NPC that follows a
// entry the follow order uses ({id, 1, 1}), once after loading. // hero but is missing from its list back with the game's own AddFollower
// (0x54b070), with the entry the follow order uses ({id, 1, 1}), once after
// loading.
//
// Never left behind: in "Die Sklaven der Orks" (2412) the husband fell
// behind (the game's own catch-up teleport moved him once), and when the hero
// walked on, his sector was switched off: the game removed him and ran his
// spawn's death label (drop2412: quest variable 5, a token item dropped where
// he was), with him alive (measured 2026-10-26 in the log: his object id
// reused right after). Sectors are switched off at 2 sectors (65 tiles and
// more) from the hero's; the mod teleports every follower of the local hero
// that is more than kLeftBehindCells away to the hero (cCreature::Teleport,
// as the portals), checked every kCheckMs.
#include <windows.h>
#include <cstdint> #include <cstdint>
#include <cstdlib>
#include "game/engine/events.h" #include "game/engine/events.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
@@ -20,6 +35,8 @@ namespace mods::followers {
namespace { namespace {
constexpr int kTicksAfterStart = 30; constexpr int kTicksAfterStart = 30;
constexpr int32_t kLeftBehindCells = 40;
constexpr DWORD kCheckMs = 500;
// Small town NPCs run after the hero for a few seconds (status 0x404, leader // Small town NPCs run after the hero for a few seconds (status 0x404, leader
// the hero) without joining his list (measured 2026-10-26: types 0x2be, // the hero) without joining his list (measured 2026-10-26: types 0x2be,
@@ -27,11 +44,20 @@ constexpr int kTicksAfterStart = 30;
constexpr int32_t kStatusTagAlong = 0x400; constexpr int32_t kStatusTagAlong = 0x400;
int g_ticksToCheck = -1; int g_ticksToCheck = -1;
DWORD g_lastCheck = 0;
int32_t Field(const void* creature, size_t offset) { int32_t Field(const void* creature, size_t offset) {
return *reinterpret_cast<const int32_t*>(static_cast<const uint8_t*>(creature) + offset); return *reinterpret_cast<const int32_t*>(static_cast<const uint8_t*>(creature) + offset);
} }
const game::MapPosition& Position(void* object) {
return *reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(object) + game::kObjectPosition);
}
int32_t Cell(int32_t units) {
return static_cast<int32_t>(static_cast<float>(units) / game::kMapCellSize);
}
void RepairFollowers() { void RepairFollowers() {
for (uint32_t id = 1; id < game::ObjectIdEnd(); ++id) { for (uint32_t id = 1; id < game::ObjectIdEnd(); ++id) {
if (game::IsPlayerHeroId(id) || !game::GetObjectById(id)) { if (game::IsPlayerHeroId(id) || !game::GetObjectById(id)) {
@@ -52,10 +78,39 @@ void RepairFollowers() {
} }
} }
// Followers of the local hero too far behind are teleported to him.
void BringAlong() {
void* hero = game::GetPlayerHero();
if (!hero) {
return;
}
const game::MapPosition& at = Position(hero);
const int32_t heroX = Cell(at.x), heroY = Cell(at.y);
game::ForEachFollower(hero, [&](uint32_t id) {
void* follower = game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
if (!follower || !game::IsAlive(follower)) {
return;
}
const game::MapPosition& position = Position(follower);
const int32_t x = Cell(position.x), y = Cell(position.y);
if (position.level != at.level ||
(std::abs(x - heroX) <= kLeftBehindCells && std::abs(y - heroY) <= kLeftBehindCells)) {
return;
}
re::Log("followers: %x left behind at %d,%d, brought to the hero at %d,%d", id, x, y, heroX, heroY);
game::Teleport(follower, heroX + 1, heroY + 1);
});
}
void OnTick(uint8_t*) { void OnTick(uint8_t*) {
if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) { if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) {
RepairFollowers(); RepairFollowers();
} }
const DWORD now = GetTickCount();
if (g_ticksToCheck <= 0 && now - g_lastCheck >= kCheckMs) {
g_lastCheck = now;
BringAlong();
}
} }
void OnStart() { void OnStart() {
+48
View File
@@ -3,6 +3,9 @@
#include <intrin.h> #include <intrin.h>
#include <cstddef>
#include <cstdint>
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -31,4 +34,49 @@ void __fastcall BurningBone_Render(void* self, void* edx, void* device, Matrix4
BurningBone_Render_original(self, edx, device, transform); BurningBone_Render_original(self, edx, device, transform);
} }
// cParticleSystem_grenadeimpact::Render (vf04 0x7b66b0, vtable 0x896cdc;
// (device, matrix by value), ret 0x44): Mörsergranate's explosion. Crashed
// twice (2026-10-26 00:12 and 22:39, sacred.exe+0x361b54 in the particle
// drawer 0x7613e0): a particle's colour is read from the table at
// +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and the particle had a life of
// over 500000 (it counts down from about 255, at 250..400 per second, by
// row: cParticleSystem update 0x763ea0). Particles are 0x40 bytes in the
// vector +0x68..+0x6c; alive while life (+0x2c) and size (+0x28) are above 0.
// How a particle gets there is not known yet: such particles are switched
// off (life 0, the game's dead state) and the first ones logged.
constexpr size_t kParticlesBegin = 0x68;
constexpr size_t kParticlesEnd = 0x6c;
constexpr size_t kParticleSize = 0x40;
constexpr size_t kParticleSizeValue = 0x28; // float
constexpr size_t kParticleLife = 0x2c; // float, colour column 0..255
constexpr size_t kParticleRow = 0x3d; // u8, colour row
constexpr float kColourColumns = 256.0f;
constexpr uint8_t kGrenadeImpactRows = 3; // emitter parameter rows at +0x30d0 (0x20 bytes each)
constexpr size_t kGrenadeImpactPhase = 0x20a9; // u8: 0 burning, 1 waiting, 2 fading (vf08 0x7b64c0)
void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform);
RE_HOOK(0x007b66b0, GrenadeImpact_Render);
void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform) {
static int logged = 0;
uint8_t* begin = *reinterpret_cast<uint8_t**>(self + kParticlesBegin);
uint8_t* end = *reinterpret_cast<uint8_t**>(self + kParticlesEnd);
for (uint8_t* particle = begin; particle && particle < end; particle += kParticleSize) {
float& life = *reinterpret_cast<float*>(particle + kParticleLife);
const float size = *reinterpret_cast<const float*>(particle + kParticleSizeValue);
const uint8_t row = particle[kParticleRow];
if (!(life > 0.0f) || !(size > 0.0f) || (life < kColourColumns && row < kGrenadeImpactRows)) {
continue;
}
if (logged < 5) {
++logged;
re::Log("guards: grenade impact particle %d of %d out of range (life %g, row %u, size %g, phase %u), "
"switched off",
static_cast<int>((particle - begin) / kParticleSize), static_cast<int>((end - begin) / kParticleSize),
life, row, size, self[kGrenadeImpactPhase]);
}
life = 0.0f;
}
GrenadeImpact_Render_original(self, edx, device, transform);
}
} // namespace game } // namespace game
+376
View File
@@ -0,0 +1,376 @@
// The explored-area border on the large map (M) as a continuous line along
// the fog's edge.
//
// Request (2026-10-26): "the red outline on the mega map is choppy and feels
// weird"; replace it, following the explored area (option A).
//
// Measured (disassembly): cUI_Megamap::DrawVisitedBorder (0x6c8580, called by
// the map's render) draws one 2x2 px red dot (0xffff0000) per bit of a 100x100
// byte grid (world +0xb73c), at a fixed offset of 10 / 20 px from the sector's
// origin towards each neighbour. The grid marks unvisited sectors next to
// visited ones (sector flag 0x20000, set when the hero's sector becomes the
// centre of the active 3x3, 0x626603; rebuilt on load by 0x633230 and per
// sector change by 0x6332d0). So the line was dots in 64-tile steps, and it
// followed the sectors stood in, not the explored mask the fog shows (since
// the 10x map reveal, far inside the cleared area).
//
// The mod replaces the draw: the edges between explored and unexplored cells
// (2x2 tiles, game/engine/explored.h) of the map's 100x100 sectors are chained
// into polylines, simplified to within a pixel and drawn as 2 px lines in the
// game's red. The lines are rebuilt while the map is open whenever the masks
// changed (checked every 250 ms). Cells of a missing sector count as neither,
// so no line runs along the edge of the world.
#include <windows.h>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <vector>
#include "game/engine/explored.h"
#include "game/ui/draw.h"
#include "game/ui/megamap.h"
#include "mods/widescreen.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::mapborder {
namespace {
// The map's sector grid (World_RebuildVisitedBorder 0x633230: 100x100).
constexpr int kMapSectors = 100;
constexpr int kMapCells = kMapSectors * game::kSectorCells;
constexpr uint32_t kColour = 0xffff0000; // the game's border colour (0x6c870e)
constexpr float kLineWidth = 2.0f; // the game's dots: 2x2 px
constexpr double kTolerance = 0.75; // map pixels a simplified line may deviate
constexpr DWORD kCheckIntervalMs = 250;
constexpr int kScreenHeight = 768;
enum class Cell : uint8_t { None, Unexplored, Explored };
struct Grid {
const uint8_t* masks[kMapSectors][kMapSectors];
void Load() {
for (int y = 0; y < kMapSectors; ++y) {
for (int x = 0; x < kMapSectors; ++x) {
masks[y][x] = game::SectorExploredMask(x, y);
}
}
}
Cell At(int x, int y) const {
if (x < 0 || y < 0 || x >= kMapCells || y >= kMapCells) {
return Cell::None;
}
const uint8_t* mask = masks[y / game::kSectorCells][x / game::kSectorCells];
if (!mask) {
return Cell::None;
}
return game::MaskCellExplored(mask, x % game::kSectorCells, y % game::kSectorCells) ? Cell::Explored
: Cell::Unexplored;
}
// FNV-1a over the masks and which sectors exist.
uint64_t Hash() const {
uint64_t hash = 0xcbf29ce484222325ull;
const auto add = [&](uint8_t byte) { hash = (hash ^ byte) * 0x100000001b3ull; };
for (const auto& row : masks) {
for (const uint8_t* mask : row) {
add(mask != nullptr);
for (size_t i = 0; mask && i < 128; ++i) {
add(mask[i]);
}
}
}
return hash;
}
};
struct Point {
double x, y;
};
// Cell corner -> map pixel: WorldToMapPixel is linear, so three points give
// it for the whole grid.
struct Projection {
Point origin, stepX, stepY;
static Point Corner(uint8_t* megamap, int cellX, int cellY) {
const float unitsPerCell = game::kExploredCellTiles * game::kWorldUnitsPerTile;
const game::Vec3 pixel = game::WorldToMapPixel(megamap, {cellX * unitsPerCell, cellY * unitsPerCell, 0});
return {pixel.x, pixel.y};
}
explicit Projection(uint8_t* megamap) {
origin = Corner(megamap, 0, 0);
const Point right = Corner(megamap, kMapCells, 0);
const Point down = Corner(megamap, 0, kMapCells);
stepX = {(right.x - origin.x) / kMapCells, (right.y - origin.y) / kMapCells};
stepY = {(down.x - origin.x) / kMapCells, (down.y - origin.y) / kMapCells};
}
Point operator()(int cellX, int cellY) const {
return {origin.x + cellX * stepX.x + cellY * stepY.x, origin.y + cellX * stepX.y + cellY * stepY.y};
}
};
// Corners on the cell grid, (kMapCells + 1)^2 of them.
uint32_t Key(int x, int y) {
return static_cast<uint32_t>(y) * (kMapCells + 1) + static_cast<uint32_t>(x);
}
struct Edge {
int x0, y0, x1, y1;
};
// Every side between an explored and an unexplored cell, clockwise around
// the explored cell (screen orientation, y down), so the edges of a border
// chain head to tail.
std::vector<Edge> BorderEdges(const Grid& grid) {
std::vector<Edge> edges;
for (int sy = 0; sy < kMapSectors; ++sy) {
for (int sx = 0; sx < kMapSectors; ++sx) {
const uint8_t* mask = grid.masks[sy][sx];
bool any = false;
for (int i = 0; mask && i < 128 && !any; ++i) {
any = mask[i] != 0;
}
if (!any) {
continue;
}
for (int ly = 0; ly < game::kSectorCells; ++ly) {
for (int lx = 0; lx < game::kSectorCells; ++lx) {
if (!game::MaskCellExplored(mask, lx, ly)) {
continue;
}
const int x = sx * game::kSectorCells + lx;
const int y = sy * game::kSectorCells + ly;
if (grid.At(x, y - 1) == Cell::Unexplored) {
edges.push_back({x, y, x + 1, y});
}
if (grid.At(x + 1, y) == Cell::Unexplored) {
edges.push_back({x + 1, y, x + 1, y + 1});
}
if (grid.At(x, y + 1) == Cell::Unexplored) {
edges.push_back({x + 1, y + 1, x, y + 1});
}
if (grid.At(x - 1, y) == Cell::Unexplored) {
edges.push_back({x, y + 1, x, y});
}
}
}
}
}
return edges;
}
using Chain = std::vector<std::pair<int, int>>;
// Edges chained into polylines: open ones (from a corner no edge ends at,
// at a missing sector) first, then the closed loops. Where two cells touch
// only at a corner, the chain turns right, staying around its own cell.
std::vector<Chain> ChainEdges(const std::vector<Edge>& edges) {
std::unordered_map<uint32_t, std::vector<size_t>> starting;
std::unordered_map<uint32_t, int> ending;
starting.reserve(edges.size());
ending.reserve(edges.size());
for (size_t i = 0; i < edges.size(); ++i) {
starting[Key(edges[i].x0, edges[i].y0)].push_back(i);
++ending[Key(edges[i].x1, edges[i].y1)];
}
std::vector<bool> used(edges.size(), false);
std::vector<Chain> chains;
const auto follow = [&](size_t first) {
Chain chain = {{edges[first].x0, edges[first].y0}};
size_t current = first;
for (;;) {
used[current] = true;
const Edge& e = edges[current];
chain.emplace_back(e.x1, e.y1);
const int dx = e.x1 - e.x0;
const int dy = e.y1 - e.y0;
size_t next = SIZE_MAX;
int bestTurn = 3;
for (size_t candidate : starting[Key(e.x1, e.y1)]) {
if (used[candidate]) {
continue;
}
const int cx = edges[candidate].x1 - edges[candidate].x0;
const int cy = edges[candidate].y1 - edges[candidate].y0;
// 0 right (-dy, dx), 1 straight, 2 left.
const int turn = (cx == -dy && cy == dx) ? 0 : (cx == dx && cy == dy) ? 1 : 2;
if (turn < bestTurn) {
bestTurn = turn;
next = candidate;
}
}
if (next == SIZE_MAX) {
break;
}
current = next;
}
chains.push_back(std::move(chain));
};
for (size_t i = 0; i < edges.size(); ++i) {
if (!used[i] && ending.find(Key(edges[i].x0, edges[i].y0)) == ending.end()) {
follow(i);
}
}
for (size_t i = 0; i < edges.size(); ++i) {
if (!used[i]) {
follow(i);
}
}
return chains;
}
double DistanceToSegment(const Point& p, const Point& a, const Point& b) {
const double dx = b.x - a.x;
const double dy = b.y - a.y;
const double length2 = dx * dx + dy * dy;
double t = length2 > 0 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / length2 : 0;
t = t < 0 ? 0 : t > 1 ? 1 : t;
return std::hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
// Douglas-Peucker: keeps first and last, and every point needed to stay
// within kTolerance of the original.
void Simplify(const std::vector<Point>& points, size_t first, size_t last, std::vector<Point>& out) {
double farthest = 0;
size_t index = first;
for (size_t i = first + 1; i < last; ++i) {
const double d = DistanceToSegment(points[i], points[first], points[last]);
if (d > farthest) {
farthest = d;
index = i;
}
}
if (farthest > kTolerance) {
Simplify(points, first, index, out);
Simplify(points, index, last, out);
} else {
out.push_back(points[last]);
}
}
std::vector<Point> SimplifiedLine(const Chain& chain, const Projection& project) {
std::vector<Point> points;
points.reserve(chain.size());
for (const auto& [x, y] : chain) {
points.push_back(project(x, y));
}
std::vector<Point> out = {points.front()};
if (points.size() < 2) {
return out;
}
// A closed loop is split at its point farthest from the start, so both
// halves have distinct ends.
size_t split = points.size() - 1;
if (chain.front() == chain.back()) {
double farthest = -1;
for (size_t i = 1; i + 1 < points.size(); ++i) {
const double d = std::hypot(points[i].x - points[0].x, points[i].y - points[0].y);
if (d > farthest) {
farthest = d;
split = i;
}
}
}
Simplify(points, 0, split, out);
if (split < points.size() - 1) {
Simplify(points, split, points.size() - 1, out);
}
return out;
}
// Map-pixel line segments of the current border.
struct Segment {
float x0, y0, x1, y1;
};
std::vector<Segment> g_segments;
uint64_t g_hash = 0;
DWORD g_lastCheck = 0;
bool g_built = false;
void Rebuild(uint8_t* megamap) {
static Grid grid;
grid.Load();
const uint64_t hash = grid.Hash();
if (g_built && hash == g_hash) {
return;
}
const DWORD start = GetTickCount();
const Projection project(megamap);
const std::vector<Edge> edges = BorderEdges(grid);
const std::vector<Chain> chains = ChainEdges(edges);
g_segments.clear();
for (const Chain& chain : chains) {
const std::vector<Point> line = SimplifiedLine(chain, project);
for (size_t i = 1; i < line.size(); ++i) {
g_segments.push_back({static_cast<float>(line[i - 1].x), static_cast<float>(line[i - 1].y),
static_cast<float>(line[i].x), static_cast<float>(line[i].y)});
}
}
if (!g_built) {
re::Log("map border: %zu edges, %zu lines, %zu segments, %lu ms", edges.size(), chains.size(),
g_segments.size(), GetTickCount() - start);
}
g_hash = hash;
g_built = true;
}
void Draw(uint8_t* megamap, void* device) {
const DWORD now = GetTickCount();
if (!g_built || now - g_lastCheck >= kCheckIntervalMs) {
g_lastCheck = now;
Rebuild(megamap);
}
const float scrollX = static_cast<float>(game::MegamapScrollX(megamap));
const float scrollY = static_cast<float>(game::MegamapScrollY(megamap));
const float width = static_cast<float>(mods::widescreen::RenderWidth());
const float half = kLineWidth / 2;
static std::vector<game::ScreenVertex> vertices;
vertices.clear();
for (const Segment& s : g_segments) {
const float x0 = s.x0 - scrollX, y0 = s.y0 - scrollY;
const float x1 = s.x1 - scrollX, y1 = s.y1 - scrollY;
if ((x0 < -half && x1 < -half) || (x0 > width + half && x1 > width + half) ||
(y0 < -half && y1 < -half) || (y0 > kScreenHeight + half && y1 > kScreenHeight + half)) {
continue;
}
const float length = std::hypot(x1 - x0, y1 - y0);
if (length <= 0) {
continue;
}
// Along and across the segment, half the width each; the ends reach
// half the width further, closing the joints.
const float ax = (x1 - x0) / length * half, ay = (y1 - y0) / length * half;
const float nx = -ay, ny = ax;
const game::ScreenVertex a = {x0 - ax + nx, y0 - ay + ny, 0, 1, kColour, 0, 0, 0};
const game::ScreenVertex b = {x0 - ax - nx, y0 - ay - ny, 0, 1, kColour, 0, 0, 0};
const game::ScreenVertex c = {x1 + ax + nx, y1 + ay + ny, 0, 1, kColour, 0, 0, 0};
const game::ScreenVertex d = {x1 + ax - nx, y1 + ay - ny, 0, 1, kColour, 0, 0, 0};
vertices.insert(vertices.end(), {a, b, c, c, b, d});
}
if (!vertices.empty()) {
game::FillTriangles(device, vertices.data(), vertices.size());
}
}
} // namespace
} // namespace mods::mapborder
namespace game {
// cUI_Megamap::DrawVisitedBorder (0x6c8580, thiscall (device), ret 4).
void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void* edx, void* device);
RE_HOOK(0x006c8580, UiMegamap_DrawVisitedBorder);
void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void*, void* device) {
if (self && device) {
mods::mapborder::Draw(self, device);
}
}
} // namespace game
+5 -19
View File
@@ -24,6 +24,7 @@
#include "game/engine/events.h" #include "game/engine/events.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "game/ui/megamap.h"
#include "game/ui/windows.h" #include "game/ui/windows.h"
#include "mods/hud.h" #include "mods/hud.h"
#include "mods/megamap.h" #include "mods/megamap.h"
@@ -90,27 +91,12 @@ constexpr uint32_t kZoomedByButton = 0x100;
constexpr size_t kZoomHeld = 0xc06bc; constexpr size_t kZoomHeld = 0xc06bc;
constexpr size_t kZoomPointX = 0xc0438; constexpr size_t kZoomPointX = 0xc0438;
constexpr size_t kZoomPointY = 0xc043c; constexpr size_t kZoomPointY = 0xc043c;
constexpr size_t kScrollX = 0xc016c;
constexpr size_t kScrollY = 0xc0170;
template <typename T> template <typename T>
T& Field(uint8_t* p, size_t offset) { T& Field(uint8_t* p, size_t offset) {
return *reinterpret_cast<T*>(p + offset); return *reinterpret_cast<T*>(p + offset);
} }
// cUI_Megamap 0x6cb130 (thiscall (out x, y, 0, world x, y, level), ret 8): the
// map pixel of a world position, as opening the map centres on the hero
// (0x6c7bad..).
struct Vec3 {
float x, y, z;
};
Vec3 MapPixel(uint8_t* megamap, const Vec3& world) {
Vec3 pixel = {};
using Fn = void(__fastcall*)(uint8_t*, void*, Vec3*, const Vec3*);
reinterpret_cast<Fn>(0x006cb130)(megamap, nullptr, &pixel, &world);
return pixel;
}
// Every tick while the zoom button's zoom is on: the zoom point on the hero. // Every tick while the zoom button's zoom is on: the zoom point on the hero.
void ZoomOnHero(uint8_t*) { void ZoomOnHero(uint8_t*) {
uint8_t* megamap = game::UiWindow(game::UiManager(), game::kUiSlotMegamap); uint8_t* megamap = game::UiWindow(game::UiManager(), game::kUiSlotMegamap);
@@ -122,12 +108,12 @@ void ZoomOnHero(uint8_t*) {
if (!hero) { if (!hero) {
return; return;
} }
const Vec3 world = {static_cast<float>(game::CreatureField(hero, game::kCreatureX)), const game::Vec3 world = {static_cast<float>(game::CreatureField(hero, game::kCreatureX)),
static_cast<float>(game::CreatureField(hero, game::kCreatureY)), static_cast<float>(game::CreatureField(hero, game::kCreatureY)),
static_cast<float>(hero[game::kObjectLevel])}; static_cast<float>(hero[game::kObjectLevel])};
const Vec3 pixel = MapPixel(megamap, world); const game::Vec3 pixel = game::WorldToMapPixel(megamap, world);
Field<int32_t>(megamap, kZoomPointX) = static_cast<int32_t>(pixel.x) - Field<int32_t>(megamap, kScrollX); Field<int32_t>(megamap, kZoomPointX) = static_cast<int32_t>(pixel.x) - game::MegamapScrollX(megamap);
Field<int32_t>(megamap, kZoomPointY) = static_cast<int32_t>(pixel.y) - Field<int32_t>(megamap, kScrollY); Field<int32_t>(megamap, kZoomPointY) = static_cast<int32_t>(pixel.y) - game::MegamapScrollY(megamap);
} }
struct Registration { struct Registration {
+13
View File
@@ -431,3 +431,16 @@
0x005ff820 cObjectManager::ObjectsInRange # thiscall (out, position, type, only, radius = -1: type sizes), ret 0x14 0x005ff820 cObjectManager::ObjectsInRange # thiscall (out, position, type, only, radius = -1: type sizes), ret 0x14
0x00428bb0 TypeTable::ObjectSize # thiscall (type), ret 4: +0x14, default 50 0x00428bb0 TypeTable::ObjectSize # thiscall (type), ret 4: +0x14, default 50
0x004fc810 Creature_StunChance # (level, creature): level * 5 / creature level * 100 0x004fc810 Creature_StunChance # (level, creature): level * 5 / creature level * 100
0x00633230 World_RebuildVisitedBorder # thiscall: clears the 100x100 border bytes (+0xb73c) and runs 0x6332d0 for every sector with flag 0x20000 (visited); on load (0x471086, 0x60c621)
0x006332d0 World_UpdateVisitedBorderAround # thiscall (sector cx, cy), ret 8: border byte of each unvisited 3x3 neighbour (0x6334f0)
0x006334f0 World_ComputeVisitedBorderCell # thiscall (cx, cy), ret 8: 8 bits, one per neighbour sector with flag 0x20000
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
0x007b66b0 cParticleSystem_grenadeimpact::Render # vf04 (device, matrix by value), ret 0x44; vtable 0x896cdc
0x007b64c0 cParticleSystem_grenadeimpact::Update # vf08 (dt), ret 4; phase +0x20a9
0x007613e0 cParticleSystem::DrawParticles # colour table row (+0x3d) * 256 + life (+0x2c)
0x00763ea0 cParticleSystem::UpdateParticles # thiscall (dt, emitter rows, mode), particles 0x40 bytes at +0x68