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