Anti-cheat checks removed

All mirror checks are skipped by code patches (the game's own "not the local
hero" paths, or straight past the check), the level trap in Think and
VerifyHeroMirror are off, the cheater flag is never set (stream checks,
loadHero, character selection). The VirtualProtect mirror refresh is gone; it
never refreshed the second mirror, a separate heap block, which killed the
hero again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-25 11:36:42 +01:00
1 parent b452558122
commit 34cacefcfc
2 files changed
+92 -108

No files matched your search

+50 -25
View File
@@ -1,10 +1,11 @@
# Anti-cheat off # Anti-cheat removed
## Request (2026-10-25) ## Request (2026-10-25)
The Gladiator (Character03, level 106) "randomly exploded" fighting an The Gladiator (Character03, level 106) "randomly exploded" fighting an
underleveled dragon on Silber. The user: the cheater detection is not 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 ## 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 `Hase!` and `cObjectManager::destroy: tried to delete local hero #1!`; the hero
came back with 1199 health and died to the next hit. came back with 1199 health and died to the next hit.
- The game mirrors the local hero's fields on a protected page (pointers - The game mirrors the local hero's fields (pointers `0x182cddc`, on a
`0x182cddc`, second copy `0x182cde0`): +0 hero +0x3b4, +4 hero +0x3ee (gold), protected page, and `0x182cde0`, a separate heap block: `HeapAlloc` 0x1000
+8 hero +0x3fe (u16), xor-ed with the hero's key byte (+0x38) repeated four at `0x816541`): +0 hero +0x3b4, +4 hero +0x3ee (gold), +8 hero +0x3fe (u16),
times (the second copy: key << 1); +0xc a raw copy of hero +0x3b0..+0x49d xor-ed with the hero's key byte (+0x38) repeated four times (the second
(attributes, skill levels). Writer: `0x603d9c` and other places after copy: key << 1); +0xc a raw copy of hero +0x3b0..+0x49d (attributes, skill
legitimate changes, all starting with `VirtualProtect(page, 0x1000, levels). Writer: `0x603d9c` and about 30 inline copies after legitimate
PAGE_READWRITE)` and ending with `0x201`. changes, all starting with `VirtualProtect(page, 0x1000, PAGE_READWRITE)`
and ending with `0x201`.
- Checks: `cCreature::Think` about once in 101 ticks (`rand() % 101 == 10`, - Checks: `cCreature::Think` about once in 101 ticks (`rand() % 101 == 10`,
`0x5389b5`), `VerifyHeroMirror` `0x5905f0` (callers `0x57fc71`, `0x5389b5`), `VerifyHeroMirror` `0x5905f0` (callers `0x57fc71`,
`0x580001`, `0x580331`, `0x61ce22`), inline copies at `0x4659f6`, `0x580001`, `0x580331`, `0x61ce22`), inline copies at `0x4659f6`,
`0x564c6f`, `0x57e09c`, `0x585bd0`, `0x619d33`, and a trap in Think `0x564c6f`, `0x57e09c`, `0x585bd0`, `0x619d33`, and a trap in Think
(`0x5385a6`: a creature whose level exceeds its experience by more than 3 (`0x5385a6`: a hero whose level exceeds its experience's by more than 3
gets fields raised behind the mirror's back). A mismatch resets the field gets +0x3b4 / +0x3ee / +0x3fe raised behind the mirrors' back). A mismatch
(attributes 1, skill levels 0); any mismatch, or no hero at all, kills the resets the field (attributes 1, skill levels 0); any mismatch, or no hero at
hero ("*** Cheater detected! ***", health 0, action dying) and sets the all, kills the hero ("*** Cheater detected! ***", health 0, action dying)
cheater flag `0x182cdd0`, saved in the hero file (record +0xf3, `0x604409`) and sets the cheater flag `0x182cdd0`, saved in the hero file (record +0xf3,
and read back by loadHero (`0x6066eb`). `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 - Stream checks in the quest manager's hero data (`0x465980`): a marker
`0xfea4fea4` (`0x467782`) and three stored values compared with the hero `0xfea4fea4` (`0x467782`) and three stored values compared with the hero
(`0x46a60b`, `0x46a64e`, `0x46a696`). (`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 (changes outside the game's mirror writers, e.g. the shared hero loaded into
another world). another world).
## Mod (`src/mods/anticheat.cpp`) ## History
- `VirtualProtect` hook: when the mirror page is made writable, the mirror is - First mod (confirmed): a `VirtualProtect` hook rewrote the mirror from the
rewritten from the current hero first. Checks then compare the hero with hero whenever a check opened its page, so checks compared the hero with
itself; writers overwrite it as before. The game's control flow is itself.
unchanged (skipping the inline checks would not be safe: code after some of - The hero exploded again (`Hase!` twice, no `cheat statBonus` / `SkillLevel`
them uses registers they set). line): the hook refreshed the second mirror only if it lay on the protected
- Patches: the stream checks' jumps past the punishment are unconditional; page, so never; the checks compare its three xor-ed fields after the first
loadHero never sets the cheater flag (a flagged character is clean after its mirror (e.g. `0x585f83`) without a log line. Refreshing both fixed it (user:
next save). "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.
+42 -83
View File
@@ -1,31 +1,23 @@
// The game's anti-cheat is off (request of 2026-10-25, after the Gladiator // The game's anti-cheat is gone (requests of 2026-10-25: the Gladiator
// "randomly exploded" fighting a dragon on Silber). // "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 // Measured: the game keeps two xor-ed mirrors of the local hero's attribute /
// fields on a protected page (pointers 0x182cddc and 0x182cde0, see // skill / gold fields (0x182cddc on a protected page, 0x182cde0 a heap block),
// RefreshMirror). Checks compare the hero with it - cCreature::Think about once // written after every legitimate change. Checks compare the hero with them,
// in 101 ticks (0x5389be), VerifyHeroMirror 0x5905f0 (4 callers) and inline // reset every mismatching field (attributes to 1, skill levels to 0) and on
// copies at 0x4659f6, 0x564c6f, 0x57e09c, 0x585bd0, 0x619d33 - reset every // any mismatch kill the hero ("*** Cheater detected! *** Hase!") and set the
// mismatching field (attributes to 1, skill levels to 0), and on any mismatch // cheater flag 0x182cdd0, which the hero file keeps. Any change the game does
// kill the hero ("*** Cheater detected! *** Hase!") and set the cheater flag // not route through its mirror writers (e.g. loading the shared hero into
// 0x182cdd0, saved in the hero file (record +0xf3). The log showed the hero at // another world) made the checks fire.
// 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 // The mod: every check is skipped through the game's own path for "not the
// VirtualProtect(mirror, 0x1000, PAGE_READWRITE); at that moment the mirror is // local hero" (or straight to the code after the check, with the registers that
// refreshed from the hero, so checks compare the hero with itself and writers // code reads), so no field is reset and no hero killed; the cheater flag is
// overwrite it as before. The stream checks of the quest manager's hero data // never set. The mirror writers stay (they only fill the unused mirrors).
// (a cheat marker 0xfea4fea4 and three stored values) never punish, and loading
// a hero file no longer sets the cheater flag.
#include <windows.h>
#include <cstdint> #include <cstdint>
#include <cstring>
#include "game/engine/objects.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
#include "re/patch.h" #include "re/patch.h"
@@ -33,77 +25,44 @@
namespace mods::anticheat { namespace mods::anticheat {
namespace { namespace {
// Mirror layout (writer 0x603d9c): +0 hero +0x3b4, +4 hero +0x3ee (gold), constexpr re::CodeReplacement kNoAntiCheat[] = {
// +8 hero +0x3fe (u16), each xor-ed with the hero's key byte (+0x38) repeated // cCreature::Think: the trap for a level more than 3 above the experience's
// four times; +0xc a copy of hero +0x3b0..+0x49d. The second mirror uses the // (raises fields behind the mirrors' back): `jle` past it -> `jmp`
// key shifted left by one. {0x005385ca, {0x0f, 0x8e, 0xd8, 0x03, 0x00, 0x00}, {0xe9, 0xd9, 0x03, 0x00, 0x00, 0x90}, 6},
constexpr uintptr_t kMirrorPointer = 0x0182cddc; // cCreature::Think: the check in 1 of 101 ticks: `jne` past it -> `jmp`
constexpr uintptr_t kSecondMirrorPointer = 0x0182cde0; {0x005389b8, {0x0f, 0x85, 0x0d, 0x06, 0x00, 0x00}, {0xe9, 0x0e, 0x06, 0x00, 0x00, 0x90}, 6},
constexpr size_t kHeroKey = 0x38; // inline checks behind "is this the local hero": `jne` past them -> `jmp`
constexpr size_t kField0 = 0x3b4; {0x00564c50, {0x0f, 0x85, 0x89, 0x05, 0x00, 0x00}, {0xe9, 0x8a, 0x05, 0x00, 0x00, 0x90}, 6},
constexpr size_t kGold = 0x3ee; {0x0057e07d, {0x0f, 0x85, 0xd8, 0x05, 0x00, 0x00}, {0xe9, 0xd9, 0x05, 0x00, 0x00, 0x90}, 6},
constexpr size_t kField2 = 0x3fe; // u16 {0x00585bb0, {0x0f, 0x85, 0x55, 0x06, 0x00, 0x00}, {0xe9, 0x56, 0x06, 0x00, 0x00, 0x90}, 6},
constexpr size_t kCopyStart = 0x3b0; // the quest manager's hero data writer: past the check to 0x46606b (it
constexpr size_t kCopySize = 0x3b * 4 + 2; // rep movsd 0x3b + movsw // writes registers before reading them; the stream keeps its clean marker)
constexpr size_t kMirrorCopy = 0xc; {0x004659dc, {0x8b, 0x0d, 0xc0, 0x3b, 0xad, 0x00}, {0xe9, 0x8a, 0x06, 0x00, 0x00, 0x90}, 6},
constexpr size_t kPageSize = 0x1000; // 0x619d33: past the check to 0x61a2de with ebp as the check leaves it
// (`mov ebp, [esp+0x34]`; edi already holds 0x88e874)
uint32_t U32(const uint8_t* p) { {0x00619d33, {0x8b, 0x0d, 0xdc, 0xcd, 0x82, 0x01}, {0x8b, 0x6c, 0x24, 0x34, 0x90, 0x90}, 6},
uint32_t v; {0x00619d39, {0x8d, 0x44, 0x24, 0x14, 0x50}, {0xe9, 0xa0, 0x05, 0x00, 0x00}, 5},
std::memcpy(&v, p, 4); // VerifyHeroMirror (0x5905f0, plain ret, 4 callers test al): `mov al, 1; ret`
return v; {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
void WriteMirror(uint8_t* mirror, const uint8_t* hero, uint32_t key) { // values compared with the hero (`je` -> `jmp`)
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<const uint8_t*>(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<uint8_t**>(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}, {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}, {0x0046a60b, {0x74, 0x18}, {0xeb, 0x18}, 2},
{0x0046a64e, {0x74, 0x18}, {0xeb, 0x18}, 2}, {0x0046a64e, {0x74, 0x18}, {0xeb, 0x18}, 2},
{0x0046a696, {0x74, 0x18}, {0xeb, 0x18}, 2}, {0x0046a696, {0x74, 0x18}, {0xeb, 0x18}, 2},
// loadHero: the hero file's cheater flag (`mov byte [0x182cdd0], 1` -> 0) // 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}, {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() { void Startup() {
const int failed = re::ReplaceAll(kNoStreamPunishment); const int failed = re::ReplaceAll(kNoAntiCheat);
re::Log("anticheat: off (%d patches failed)", failed); re::Log("anticheat: removed (%d patches failed)", failed);
} }
RE_STARTUP(AntiCheatStartup, Startup); RE_STARTUP(AntiCheatStartup, Startup);
} // namespace } // namespace
} // namespace mods::anticheat } // 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<uint8_t**>(mods::anticheat::kMirrorPointer);
if (result && protect == PAGE_READWRITE && mirror && address == mirror) {
mods::anticheat::RefreshMirror(mirror);
}
return result;
}