Compare commits
5
Commits
7d8089c299
...
e539cab604
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e539cab604 | ||
|
|
42b99526a4 | ||
|
|
91e7f844cc | ||
|
|
a4d5683403 | ||
|
|
32f953fc01 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <initializer_list>
|
||||||
|
|
||||||
namespace game {
|
namespace game {
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+7
-21
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user