From e539cab604a8c0013583463edf87edb50efdbc62 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Mon, 26 Oct 2026 23:39:21 +0100 Subject: [PATCH] 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) --- docs/quests.md | 19 ++++++++++ src/game/engine/orders.h | 23 +++++++++--- src/game/engine/quests.h | 1 + src/mods/followers.cpp | 75 ++++++++++++++++++++++++++++++++++------ tools/names.txt | 4 +++ 5 files changed, 107 insertions(+), 15 deletions(-) diff --git a/docs/quests.md b/docs/quests.md index 4ebbbc7..54950fa 100644 --- a/docs/quests.md +++ b/docs/quests.md @@ -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. diff --git a/src/game/engine/orders.h b/src/game/engine/orders.h index 6780e0a..71addd1 100644 --- a/src/game/engine/orders.h +++ b/src/game/engine/orders.h @@ -97,16 +97,29 @@ inline void AddFollower(void* leader, const FollowerEntry& entry) { reinterpret_cast(0x0054b070)(leader, nullptr, &entry); } -inline bool HasFollower(const void* leader, uint32_t id) { +// The ids on the leader's follower list. +template +void ForEachFollower(const void* leader, Fn&& fn) { const auto* creature = static_cast(leader); const auto* begin = *reinterpret_cast(creature + kCreatureFollowersBegin); const auto* end = *reinterpret_cast(creature + kCreatureFollowersEnd); for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) { - if (*reinterpret_cast(entry) == id) { - return true; - } + fn(*reinterpret_cast(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): , ... (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(0x0054d840)(creature, nullptr, cellX, cellY, 0, 1); } // The walk path (measured 2026-10-18): waypoints {x, y} at +0x2ed + i * 8, diff --git a/src/game/engine/quests.h b/src/game/engine/quests.h index 1aca8e1..36e01ef 100644 --- a/src/game/engine/quests.h +++ b/src/game/engine/quests.h @@ -5,6 +5,7 @@ #include #include #include +#include namespace game { diff --git a/src/mods/followers.cpp b/src/mods/followers.cpp index aab2a0a..e7aa782 100644 --- a/src/mods/followers.cpp +++ b/src/mods/followers.cpp @@ -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 #include +#include #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(static_cast(creature) + offset); } +const game::MapPosition& Position(void* object) { + return *reinterpret_cast(static_cast(object) + game::kObjectPosition); +} + +int32_t Cell(int32_t units) { + return static_cast(static_cast(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() { diff --git a/tools/names.txt b/tools/names.txt index 95ca81e..e34d043 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -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