diff --git a/CMakeLists.txt b/CMakeLists.txt index a6261d9..ee3f6c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,7 @@ add_library(sacredK SHARED src/mods/screen.cpp src/mods/characters.cpp src/mods/mainmenu.cpp + src/mods/anticheat.cpp ) target_include_directories(sacredK PRIVATE src) target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS}) diff --git a/docs/anticheat.md b/docs/anticheat.md new file mode 100644 index 0000000..2cead80 --- /dev/null +++ b/docs/anticheat.md @@ -0,0 +1,51 @@ +# Anti-cheat off + +## Request (2026-10-25) + +The Gladiator (Character03, level 106) "randomly exploded" fighting an +underleveled dragon on Silber. The user: the cheater detection is not +necessary, remove it. + +## Measured + +A temporary damage log showed the hero at full health (17200) going to 0 in +one tick, with the game logging `cheat statBonus 0/3`, `cheat SkillLevel 0..7`, +`Hase!` and `cObjectManager::destroy: tried to delete local hero #1!`; the hero +came back with 1199 health and died to the next hit. + +- The game mirrors the local hero's fields on a protected page (pointers + `0x182cddc`, second copy `0x182cde0`): +0 hero +0x3b4, +4 hero +0x3ee (gold), + +8 hero +0x3fe (u16), xor-ed with the hero's key byte (+0x38) repeated four + times (the second copy: key << 1); +0xc a raw copy of hero +0x3b0..+0x49d + (attributes, skill levels). Writer: `0x603d9c` and other places after + legitimate changes, all starting with `VirtualProtect(page, 0x1000, + PAGE_READWRITE)` and ending with `0x201`. +- Checks: `cCreature::Think` about once in 101 ticks (`rand() % 101 == 10`, + `0x5389b5`), `VerifyHeroMirror` `0x5905f0` (callers `0x57fc71`, + `0x580001`, `0x580331`, `0x61ce22`), inline copies at `0x4659f6`, + `0x564c6f`, `0x57e09c`, `0x585bd0`, `0x619d33`, and a trap in Think + (`0x5385a6`: a creature whose level exceeds its experience by more than 3 + gets fields raised behind the mirror's back). A mismatch resets the field + (attributes 1, skill levels 0); any mismatch, or no hero at all, kills the + hero ("*** Cheater detected! ***", health 0, action dying) and sets the + cheater flag `0x182cdd0`, saved in the hero file (record +0xf3, `0x604409`) + and read back by loadHero (`0x6066eb`). +- Stream checks in the quest manager's hero data (`0x465980`): a marker + `0xfea4fea4` (`0x467782`) and three stored values compared with the hero + (`0x46a60b`, `0x46a64e`, `0x46a696`). +- In the failing log every skill level mismatched: the mirror was stale + (changes outside the game's mirror writers, e.g. the shared hero loaded into + another world). + +## Mod (`src/mods/anticheat.cpp`) + +- `VirtualProtect` hook: when the mirror page is made writable, the mirror is + rewritten from the current hero first. Checks then compare the hero with + itself; writers overwrite it as before. The game's control flow is + unchanged (skipping the inline checks would not be safe: code after some of + them uses registers they set). +- Patches: the stream checks' jumps past the punishment are unconditional; + loadHero never sets the cheater flag (a flagged character is clean after its + next save). + +Confirmed by the user 2026-10-25 (attributes fine, no more exploding). diff --git a/docs/skill-slots.md b/docs/skill-slots.md index 8f1bb1b..6c6dfe9 100644 --- a/docs/skill-slots.md +++ b/docs/skill-slots.md @@ -189,3 +189,13 @@ only (or disabled in multiplayer). table for the extra controls), layout, rendering, hit tests, drag & drop. 6. Keys: 0-6 cast art slots (since 2026-10-15; before: 1-7 select and cast), F1-F7 select weapon sets. + +### Hero files: items of sets 6-7 (2026-10-25) + +Reported: the Gladiator lost the weapons of sets 6/7 on Silber. saveHero +collects the items it writes (chunk 0xc2) from the weapon sets with +`GetWeaponSetItem` over sets 0..4 (`cmp esi, 5` at `0x605046`), so sets 6-7 +kept ids of items that were never in the hero file; a world that takes its +hero from the hero file (Silber without its own hero inventory) had nothing +there. The loop bound is patched to 7 (inventory_save.cpp); loadHero's id +replacement already covers the shadow slots. Confirmed 2026-10-25. diff --git a/docs/todo.md b/docs/todo.md index 113d61d..62930a5 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -223,3 +223,26 @@ hero), but both the Spielen screen and the options window still need a lot of fine-tuning. Details to be collected when we get to it; known so far: list scrolling beyond 9 characters, larger heading font, the options window's layout in the width beyond its 1024 frame. + +## Quest book: all quests, unknown ones as "???" (2026-10-25) + +Request: every quest appears in the quest book; quests not yet activated show +as "???", so a player knows how many quests an area holds without being told +where they are. Notes: the quest table (g_quests 0xaa9e98, 0x124 per quest, +game/engine/quests.h) lists all quests of the campaign, including the generic +repeatable ones (ids 2600..3599, cloned by Quest_End) and DQ_ dynamic quests; +which of them count for an area, and how the book groups by area, is still to +be measured. + +## Megamap zoomed view: markers shifted (2026-10-25) + +Reported: on the M map, clicking a location to zoom in shows the markers on +the zoomed map wrong / shifted (megamap.cpp). + +## Load / save progress screen: rock background (2026-10-25) + +Reported: the progress screen shown while loading / saving does not use the +rock-wall background of the other menu screens yet (hud::RenderMenuFrame, +docs/screens.md). The GDI progress screen is DxDriver7_LoadingScreen +0x6469d0 (1024x768, game/addresses.h); pictures PAK\loading0/1.bmp, +loadgame.bmp, savegame.bmp, LoadingUW00/01.bmp. diff --git a/src/mods/anticheat.cpp b/src/mods/anticheat.cpp new file mode 100644 index 0000000..770987a --- /dev/null +++ b/src/mods/anticheat.cpp @@ -0,0 +1,109 @@ +// The game's anti-cheat is off (request of 2026-10-25, after the Gladiator +// "randomly exploded" fighting a dragon on Silber). +// +// Measured: the game keeps a mirror of the local hero's attribute / skill +// fields on a protected page (pointers 0x182cddc and 0x182cde0, see +// RefreshMirror). Checks compare the hero with it - cCreature::Think about once +// in 101 ticks (0x5389be), VerifyHeroMirror 0x5905f0 (4 callers) and inline +// copies at 0x4659f6, 0x564c6f, 0x57e09c, 0x585bd0, 0x619d33 - reset every +// mismatching field (attributes to 1, skill levels to 0), and on any mismatch +// kill the hero ("*** Cheater detected! *** Hase!") and set the cheater flag +// 0x182cdd0, saved in the hero file (record +0xf3). The log showed the hero at +// full health dying with every skill level mismatching: the mirror was stale +// (any change the game does not route through its own mirror writers, e.g. +// loading the shared hero into another world, leaves it behind). +// +// The mod: every check and every mirror writer starts with +// VirtualProtect(mirror, 0x1000, PAGE_READWRITE); at that moment the mirror is +// refreshed from the hero, so checks compare the hero with itself and writers +// overwrite it as before. The stream checks of the quest manager's hero data +// (a cheat marker 0xfea4fea4 and three stored values) never punish, and loading +// a hero file no longer sets the cheater flag. + +#include + +#include +#include + +#include "game/engine/objects.h" +#include "re/hook.h" +#include "re/log.h" +#include "re/patch.h" + +namespace mods::anticheat { +namespace { + +// Mirror layout (writer 0x603d9c): +0 hero +0x3b4, +4 hero +0x3ee (gold), +// +8 hero +0x3fe (u16), each xor-ed with the hero's key byte (+0x38) repeated +// four times; +0xc a copy of hero +0x3b0..+0x49d. The second mirror uses the +// key shifted left by one. +constexpr uintptr_t kMirrorPointer = 0x0182cddc; +constexpr uintptr_t kSecondMirrorPointer = 0x0182cde0; +constexpr size_t kHeroKey = 0x38; +constexpr size_t kField0 = 0x3b4; +constexpr size_t kGold = 0x3ee; +constexpr size_t kField2 = 0x3fe; // u16 +constexpr size_t kCopyStart = 0x3b0; +constexpr size_t kCopySize = 0x3b * 4 + 2; // rep movsd 0x3b + movsw +constexpr size_t kMirrorCopy = 0xc; +constexpr size_t kPageSize = 0x1000; + +uint32_t U32(const uint8_t* p) { + uint32_t v; + std::memcpy(&v, p, 4); + return v; +} + +void WriteMirror(uint8_t* mirror, const uint8_t* hero, uint32_t key) { + const uint32_t values[3] = {U32(hero + kField0) ^ key, U32(hero + kGold) ^ key, + (U32(hero + kField2) & 0xffff) ^ key}; + std::memcpy(mirror, values, sizeof values); + std::memcpy(mirror + kMirrorCopy, hero + kCopyStart, kCopySize); +} + +// The page is writable when this runs (inside VirtualProtect below). +void RefreshMirror(uint8_t* page) { + const auto* hero = static_cast(game::GetPlayerHero()); + if (!hero) { + return; + } + const uint32_t keyByte = hero[kHeroKey]; + const uint32_t key = keyByte | keyByte << 8 | keyByte << 16 | keyByte << 24; + WriteMirror(page, hero, key); + auto* second = *reinterpret_cast(kSecondMirrorPointer); + if (second >= page && second + kMirrorCopy + kCopySize <= page + kPageSize) { + WriteMirror(second, hero, key << 1); + } +} + +constexpr re::CodeReplacement kNoStreamPunishment[] = { + // cheat marker 0xfea4fea4 read: `jne` past the punishment -> `jmp` + {0x00467782, {0x0f, 0x85, 0xbd, 0x01, 0x00, 0x00}, {0xe9, 0xbe, 0x01, 0x00, 0x00, 0x90}, 6}, + // stored values compared with the hero: `je` past the punishment -> `jmp` + {0x0046a60b, {0x74, 0x18}, {0xeb, 0x18}, 2}, + {0x0046a64e, {0x74, 0x18}, {0xeb, 0x18}, 2}, + {0x0046a696, {0x74, 0x18}, {0xeb, 0x18}, 2}, + // loadHero: the hero file's cheater flag (`mov byte [0x182cdd0], 1` -> 0) + {0x006066eb, {0xc6, 0x05, 0xd0, 0xcd, 0x82, 0x01, 0x01}, {0xc6, 0x05, 0xd0, 0xcd, 0x82, 0x01, 0x00}, 7}, +}; + +void Startup() { + const int failed = re::ReplaceAll(kNoStreamPunishment); + re::Log("anticheat: off (%d patches failed)", failed); +} +RE_STARTUP(AntiCheatStartup, Startup); + +} // namespace +} // namespace mods::anticheat + +BOOL WINAPI VirtualProtectMirrorFresh(LPVOID address, SIZE_T size, DWORD protect, PDWORD old); +RE_HOOK_API(VirtualProtect, VirtualProtectMirrorFresh); + +BOOL WINAPI VirtualProtectMirrorFresh(LPVOID address, SIZE_T size, DWORD protect, PDWORD old) { + const BOOL result = VirtualProtectMirrorFresh_original(address, size, protect, old); + auto* mirror = *reinterpret_cast(mods::anticheat::kMirrorPointer); + if (result && protect == PAGE_READWRITE && mirror && address == mirror) { + mods::anticheat::RefreshMirror(mirror); + } + return result; +} diff --git a/src/mods/inventory_save.cpp b/src/mods/inventory_save.cpp index 4b91df0..2f3901c 100644 --- a/src/mods/inventory_save.cpp +++ b/src/mods/inventory_save.cpp @@ -32,6 +32,7 @@ #include "mods/sharedchest.h" #include "mods/skillslots.h" #include "re/hook.h" +#include "re/patch.h" #include "re/log.h" namespace mods::inventory_save { @@ -327,6 +328,20 @@ void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn rea HeroDataList().push_back({key, write, read}); } +// saveHero writes the hero's items (chunk 0xc2) from its inventories, equipment +// and weapon sets; the weapon-set loop (GetWeaponSetItem over sets 0..4, `cmp +// esi, 5`) runs over all 7 so the items of sets 6-7 are in hero files too. Without +// them a world loading the hero from its hero file had sets 6-7 pointing at +// items that did not exist (2026-10-25). +constexpr uint32_t kSaveHeroWeaponSetLoop = 0x00605046; + +void Startup() { + if (!re::PatchInstructionByte(kSaveHeroWeaponSetLoop, 2, kGameSlots, kSlots)) { + re::Log("inventory: saveHero weapon-set loop not patched"); + } +} +RE_STARTUP(InventorySaveStartup, Startup); + } // namespace mods::inventory_save namespace game { diff --git a/tools/names.txt b/tools/names.txt index e494f73..07f6059 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -371,3 +371,4 @@ 0x0075df10 MenuScene_Instance # 0x1a84 bytes, kept at 0x17ecb40 0x0075e620 MenuScene::Render # (1 argument), ret 4: sky + water (UI_UW_MAIN_CENTER, UI_MAIN_REF) 0x0075e820 MenuScene::Update # (seconds) +0x005905f0 VerifyHeroMirror # anti-cheat: local hero vs protected mirror (0x182cddc); resets mismatches, returns 1 if all matched (mods/anticheat.cpp)