Anti-cheat off, weapon sets 6-7 in hero files
- The game's cheater detection killed the hero ("Hase!") whenever its
protected stat mirror was stale; the mirror is now refreshed from the hero
whenever it is unlocked, stream checks never punish, loadHero no longer
sets the cheater flag (docs/anticheat.md).
- saveHero wrote the items of weapon sets 1-5 only: its loop runs over all 7
sets, so sets 6-7 survive a world loading the hero from the hero file.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3978060954
commit
91bbc52cf0
7 files changed
+210
No files matched your search
@@ -74,6 +74,7 @@ add_library(sacredK SHARED
|
|||||||
src/mods/screen.cpp
|
src/mods/screen.cpp
|
||||||
src/mods/characters.cpp
|
src/mods/characters.cpp
|
||||||
src/mods/mainmenu.cpp
|
src/mods/mainmenu.cpp
|
||||||
|
src/mods/anticheat.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(sacredK PRIVATE src)
|
target_include_directories(sacredK PRIVATE src)
|
||||||
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
|
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS})
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -189,3 +189,13 @@ only (or disabled in multiplayer).
|
|||||||
table for the extra controls), layout, rendering, hit tests, drag & drop.
|
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
|
6. Keys: 0-6 cast art slots (since 2026-10-15; before: 1-7 select and
|
||||||
cast), F1-F7 select weapon sets.
|
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.
|
||||||
@@ -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:
|
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
|
list scrolling beyond 9 characters, larger heading font, the options
|
||||||
window's layout in the width beyond its 1024 frame.
|
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.
|
||||||
@@ -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 <windows.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#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<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},
|
||||||
|
// 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<uint8_t**>(mods::anticheat::kMirrorPointer);
|
||||||
|
if (result && protect == PAGE_READWRITE && mirror && address == mirror) {
|
||||||
|
mods::anticheat::RefreshMirror(mirror);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
#include "mods/sharedchest.h"
|
#include "mods/sharedchest.h"
|
||||||
#include "mods/skillslots.h"
|
#include "mods/skillslots.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
|
#include "re/patch.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
namespace mods::inventory_save {
|
namespace mods::inventory_save {
|
||||||
@@ -327,6 +328,20 @@ void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn rea
|
|||||||
HeroDataList().push_back({key, write, read});
|
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 mods::inventory_save
|
||||||
|
|
||||||
namespace game {
|
namespace game {
|
||||||
|
|||||||
@@ -371,3 +371,4 @@
|
|||||||
0x0075df10 MenuScene_Instance # 0x1a84 bytes, kept at 0x17ecb40
|
0x0075df10 MenuScene_Instance # 0x1a84 bytes, kept at 0x17ecb40
|
||||||
0x0075e620 MenuScene::Render # (1 argument), ret 4: sky + water (UI_UW_MAIN_CENTER, UI_MAIN_REF)
|
0x0075e620 MenuScene::Render # (1 argument), ret 4: sky + water (UI_UW_MAIN_CENTER, UI_MAIN_REF)
|
||||||
0x0075e820 MenuScene::Update # (seconds)
|
0x0075e820 MenuScene::Update # (seconds)
|
||||||
|
0x005905f0 VerifyHeroMirror # anti-cheat: local hero vs protected mirror (0x182cddc); resets mismatches, returns 1 if all matched (mods/anticheat.cpp)
|
||||||
Reference in new issue
Block a user