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>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-26 23:39:21 +01:00
1 parent 42b99526a4
commit e539cab604
5 files changed
+107 -15

No files matched your search

+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
took it for running and reset 16 available quests and spawned their NPCs on
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.
+18 -5
View File
@@ -97,16 +97,29 @@ inline void AddFollower(void* leader, const FollowerEntry& 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* begin = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersBegin);
const auto* end = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersEnd);
for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) {
if (*reinterpret_cast<const uint32_t*>(entry) == id) {
return true;
}
fn(*reinterpret_cast<const uint32_t*>(entry));
}
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,
+1
View File
@@ -5,6 +5,7 @@
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <initializer_list>
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).
// "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
// case 0x40) looks the NPC up in the hero's follower list (hero +0x39c), and
// that list was empty (measured on Borf's save: NPC 0x1fa with leader 1 and
// the follow bit, no followers on the hero). The NPC keeps its side of the
// escort (leader +0x251, status bit 4) across a load; the hero's list does
// not come back. The mod adds every NPC that follows a 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.
// Escorted NPCs stay with the hero (2026-10-26, docs/quests.md).
//
// On the follower list: "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 case 0x40) looks the NPC up in the hero's follower
// list (hero +0x39c), and that list was empty (measured on Borf's save: NPC
// 0x1fa with leader 1 and the follow bit, no followers on the hero). The NPC
// keeps its side of the escort (leader +0x251, status bit 4) across a load;
// the hero's list does not come back. The mod adds every NPC that follows a
// 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 <cstdlib>
#include "game/engine/events.h"
#include "game/engine/objects.h"
@@ -20,6 +35,8 @@ namespace mods::followers {
namespace {
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
// 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;
int g_ticksToCheck = -1;
DWORD g_lastCheck = 0;
int32_t Field(const void* creature, size_t 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() {
for (uint32_t id = 1; id < game::ObjectIdEnd(); ++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*) {
if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) {
RepairFollowers();
}
const DWORD now = GetTickCount();
if (g_ticksToCheck <= 0 && now - g_lastCheck >= kCheckMs) {
g_lastCheck = now;
BringAlong();
}
}
void OnStart() {
+4
View File
@@ -440,3 +440,7 @@
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