diff --git a/docs/anticheat.md b/docs/anticheat.md index 2cead80..77714aa 100644 --- a/docs/anticheat.md +++ b/docs/anticheat.md @@ -1,10 +1,11 @@ -# Anti-cheat off +# Anti-cheat removed ## 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. +necessary, remove it. After a second explosion: "i want all that weird +cheater detection code gone". ## Measured @@ -13,23 +14,25 @@ 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`. +- The game mirrors the local hero's fields (pointers `0x182cddc`, on a + protected page, and `0x182cde0`, a separate heap block: `HeapAlloc` 0x1000 + at `0x816541`): +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 about 30 inline copies 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`). + (`0x5385a6`: a hero whose level exceeds its experience's by more than 3 + gets +0x3b4 / +0x3ee / +0x3fe raised behind the mirrors' 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`) and the character + selection (`0x70da99`, from the record's mark +0x3ef). - 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`). @@ -37,15 +40,37 @@ came back with 1199 health and died to the next hit. (changes outside the game's mirror writers, e.g. the shared hero loaded into another world). -## Mod (`src/mods/anticheat.cpp`) +## History -- `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). +- First mod (confirmed): a `VirtualProtect` hook rewrote the mirror from the + hero whenever a check opened its page, so checks compared the hero with + itself. +- The hero exploded again (`Hase!` twice, no `cheat statBonus` / `SkillLevel` + line): the hook refreshed the second mirror only if it lay on the protected + page, so never; the checks compare its three xor-ed fields after the first + mirror (e.g. `0x585f83`) without a log line. Refreshing both fixed it (user: + "i no longer explode"). +- Then the checks were removed entirely (below), the hook is gone. -Confirmed by the user 2026-10-25 (attributes fine, no more exploding). +## Mod (`src/mods/anticheat.cpp`): checks removed + +Every check is skipped by a code patch (`re::ReplaceAll`): no field is reset, +no hero killed, the cheater flag never set. + +| Check | Patch | +|---|---| +| Think trap `0x5385a6` | `0x5385ca` `jle` -> `jmp` | +| Think, 1 tick in 101 (`0x5389be`) | `0x5389b8` `jne` -> `jmp` | +| `0x564c6f`, `0x57e09c`, `0x585bd0` | the "not the local hero" `jne` before each (`0x564c50`, `0x57e07d`, `0x585bb0`) -> `jmp`: the game's own path past the check | +| quest manager hero data `0x4659f6` | `0x4659dc` `jmp 0x46606b` (the code there writes esi / edi before reading them; the stream keeps its clean marker 0xfeedf00d) | +| `0x619d33` | `mov ebp, [esp+0x34]; jmp 0x61a2de`, the registers as the check leaves them (edi already 0x88e874) | +| `VerifyHeroMirror` `0x5905f0` (callers test al) | `mov al, 1; ret` | +| stream checks `0x467782`, `0x46a60b`, `0x46a64e`, `0x46a696` | jumps past the punishment unconditional | +| loadHero `0x6066eb` | writes 0 to the cheater flag | +| character selection `0x70da8c` | `setne cl` -> `xor cl, cl` | + +All field resets (`mov [reg+0x3b4], 1` ...) lie inside these checks; all +writes of the flag `0x182cdd0` (`0x465ebe`, `0x538e53`, `0x56508a`, +`0x57e528`, `0x57fcfd`, `0x58008d`, `0x5803bd`, `0x586090`, `0x61a17e`, +`0x61ceac`) are their punishments. The mirror writers stay: they only fill +the now unread mirrors. diff --git a/src/mods/anticheat.cpp b/src/mods/anticheat.cpp index 770987a..33f3b0d 100644 --- a/src/mods/anticheat.cpp +++ b/src/mods/anticheat.cpp @@ -1,31 +1,23 @@ -// The game's anti-cheat is off (request of 2026-10-25, after the Gladiator -// "randomly exploded" fighting a dragon on Silber). +// The game's anti-cheat is gone (requests of 2026-10-25: the Gladiator +// "randomly exploded" fighting a dragon on Silber; then "i want all that weird +// cheater detection code gone"). docs/anticheat.md. // -// 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). +// Measured: the game keeps two xor-ed mirrors of the local hero's attribute / +// skill / gold fields (0x182cddc on a protected page, 0x182cde0 a heap block), +// written after every legitimate change. Checks compare the hero with them, +// 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, which the hero file keeps. Any change the game does +// not route through its mirror writers (e.g. loading the shared hero into +// another world) made the checks fire. // -// 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 +// The mod: every check is skipped through the game's own path for "not the +// local hero" (or straight to the code after the check, with the registers that +// code reads), so no field is reset and no hero killed; the cheater flag is +// never set. The mirror writers stay (they only fill the unused mirrors). #include -#include -#include "game/engine/objects.h" #include "re/hook.h" #include "re/log.h" #include "re/patch.h" @@ -33,77 +25,44 @@ 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` +constexpr re::CodeReplacement kNoAntiCheat[] = { + // cCreature::Think: the trap for a level more than 3 above the experience's + // (raises fields behind the mirrors' back): `jle` past it -> `jmp` + {0x005385ca, {0x0f, 0x8e, 0xd8, 0x03, 0x00, 0x00}, {0xe9, 0xd9, 0x03, 0x00, 0x00, 0x90}, 6}, + // cCreature::Think: the check in 1 of 101 ticks: `jne` past it -> `jmp` + {0x005389b8, {0x0f, 0x85, 0x0d, 0x06, 0x00, 0x00}, {0xe9, 0x0e, 0x06, 0x00, 0x00, 0x90}, 6}, + // inline checks behind "is this the local hero": `jne` past them -> `jmp` + {0x00564c50, {0x0f, 0x85, 0x89, 0x05, 0x00, 0x00}, {0xe9, 0x8a, 0x05, 0x00, 0x00, 0x90}, 6}, + {0x0057e07d, {0x0f, 0x85, 0xd8, 0x05, 0x00, 0x00}, {0xe9, 0xd9, 0x05, 0x00, 0x00, 0x90}, 6}, + {0x00585bb0, {0x0f, 0x85, 0x55, 0x06, 0x00, 0x00}, {0xe9, 0x56, 0x06, 0x00, 0x00, 0x90}, 6}, + // the quest manager's hero data writer: past the check to 0x46606b (it + // writes registers before reading them; the stream keeps its clean marker) + {0x004659dc, {0x8b, 0x0d, 0xc0, 0x3b, 0xad, 0x00}, {0xe9, 0x8a, 0x06, 0x00, 0x00, 0x90}, 6}, + // 0x619d33: past the check to 0x61a2de with ebp as the check leaves it + // (`mov ebp, [esp+0x34]`; edi already holds 0x88e874) + {0x00619d33, {0x8b, 0x0d, 0xdc, 0xcd, 0x82, 0x01}, {0x8b, 0x6c, 0x24, 0x34, 0x90, 0x90}, 6}, + {0x00619d39, {0x8d, 0x44, 0x24, 0x14, 0x50}, {0xe9, 0xa0, 0x05, 0x00, 0x00}, 5}, + // VerifyHeroMirror (0x5905f0, plain ret, 4 callers test al): `mov al, 1; ret` + {0x005905f0, {0x51, 0x8b, 0x0d}, {0xb0, 0x01, 0xc3}, 3}, + // stream checks of the quest manager's hero data: the cheat marker + // 0xfea4fea4 read (`jne` past the punishment -> `jmp`) and three stored + // values compared with the hero (`je` -> `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}, + // character selection: the flag from the hero record's mark (+0x3ef): + // `setne cl` -> `xor cl, cl` + {0x0070da8c, {0x0f, 0x95, 0xc1}, {0x32, 0xc9, 0x90}, 3}, }; void Startup() { - const int failed = re::ReplaceAll(kNoStreamPunishment); - re::Log("anticheat: off (%d patches failed)", failed); + const int failed = re::ReplaceAll(kNoAntiCheat); + re::Log("anticheat: removed (%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; -}