➡️ Silent Aim
Hello everyone
First of all before I start saying anything, after I explain everything I’ll attach the source code I built in Python.
If you don’t care about the explanation, you can just install any Python version from the official website, as long as it’s Python 3 or higher.
After installing Python, use this command:
And that’s it, congratulations, you can use it directly.
Also keep in mind this may break after updates, so you might need to update the signatures.
Those are only needed if a future update breaks the addresses.
Alright now let’s get into the explanation.
# Explanation
The short version is that the new silent aim method now works at a later stage of the projectile path.
Instead of modifying functions close to the raycast or early aim calculations, the modification now happens at the function that receives the projectile origin and direction already prepared.
The important function is:
The parameters here are roughly:
So when execution reaches this point, the game already prepared the projectile spawn position and projectile direction.
`origin` = projectile spawn position
`direction` = projectile direction vector
The silent aim idea here is extremely simple:
Take the target position, calculate a new direction from the projectile origin to the target, then overwrite the original direction stored in `r8`.
Something like this:
Then write the result into:
The direction vector must be normalized, meaning its length must equal 1, because it’s a direction vector and not a world position.
Quick example:
After that, write `dir_x`, `dir_y`, and `dir_z` into the direction pointer inside `r8`.
The important x64 flow now roughly looks like this:
Short explanation:
Function:
is the closest thing to the actual raycast / aim calculation logic, and contains calculations related to direction and projectile pathing.
But as a practical silent aim hook, it’s much better to hook:
because it’s simpler and cleaner.
At this point the direction is already fully prepared and directly accessible in `r8`, so there’s no need to mess with internal calculation logic.
Instead of modifying how the game calculates the direction, you simply modify the final result before the projectile uses it.
That’s the important difference.
The old method was closer to the direction calculation logic itself.
The new method grabs the direction after everything is already calculated.
That’s why the new method is easier to understand and easier to update, because the location is very clear:
The hook logic becomes basically:
So in very simple terms:
The game prepares the projectile and prepares the direction, and right before the game uses that direction, we replace it with our own target direction.
If a future update breaks the address, you’ll most likely only need to update the hook signature:
And if you want the closest function related to ray / aim calculations, it’s:
But for practical usage, hooking:
is much better because that’s where we directly receive and modify the final direction vector.
And that’s basically the new silent aim method.
Hope everyone enjoys it.
import ctypes
import math
import struct
import sys
import threading
import time
from dataclasses import dataclass
import pymem
import pymem.process
try:
from keystone import Ks, KS_ARCH_X86, KS_MODE_64
except ImportError:
print("[-] Install keystone-engine: pip install keystone-engine")
sys.exit(1)
# ================================================== ===========================
# Config
# ================================================== ===========================
PROCESS_NAME = "Trove_x64.exe"
ENTITY_REFRESH_DELAY = 0.03
TARGET_UPDATE_MIN_DELTA = 0.01
# Max silent-aim target range.
# This does NOT affect entity extraction.
# It only decides whether the closest extracted entity is allowed as a target.
MAX_RANGE = 50.0
# Entity base position is usually low. This lifts the aim point into the body.
AIM_Y_OFFSET = 0.45
# Full camera/projectile parallax compensation.
PARALLAX = 1.0
MIN_LEN_SQ = 0.000001
# Contains-based blacklist:
# If the entity name contains any keyword anywhere, it is ignored.
blacklist_keywords = [
"pet",
"portal",
"abilities",
"cornerstone",
"services",
"client",
#"mana",
"karma",
"adventure",
"placeable",
]
# ================================================== ===========================
# RVAs / offsets
# ================================================== ===========================
EntityList = 0x1396BC0
LOCAL_BASE_PTR = 0x0139F870
CAM_ROOT_OFFSET = 0x40
CAM_POS_OFFSET = 0x140
# Confirmed active SetProjectileOrigin variant.
# Entry args:
# rdx = projectile origin pointer
# r8 = projectile direction pointer
HOOK_RVA = 0x77E9E0
PATCH_LEN = 14
RETURN_RVA = HOOK_RVA + PATCH_LEN
EXPECTED_ORIGINAL = bytes.fromhex(
"48 8B C4 48 89 58 10 44 89 48 20 55 56 57"
)
OFFSETS = {
"hash_base": 0xE0,
"hash_stride": 0xE8,
"hash_count": 0xF0,
"node_next": 0x00,
"node_object": 0x10,
"name": [0x98, 0x0],
"position": [0x130, 0x8, 0xD0],
"scale": [0x130, 0x8, 0x178],
"level": [0x130, 0xA8, 0x208],
"current_health": [0x130, 0x108, 0xD8],
}
NODE_MARK_MASK = 0xFFFFFFFFFFFFFFFE
MAX_BUCKETS = 8192
MAX_CHAIN_DEPTH = 256
MAX_TOTAL_NODES = 30000
NAME_MAX_LEN = 160
# ================================================== ===========================
# Shared data block
# ================================================== ===========================
# Python writes target data here. The codecave reads it on each projectile call.
DATA_ENABLED = 0x00 # u32
DATA_VALID = 0x04 # u32
DATA_X = 0x08 # float
DATA_Y = 0x0C # float
DATA_Z = 0x10 # float
DATA_PARALLAX = 0x14 # float
DATA_MIN_LEN_SQ = 0x18 # float
DATA_LOCAL_BASE_PTR = 0x20 # u64
# ================================================== ===========================
# WinAPI
# ================================================== ===========================
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
MEM_RELEASE = 0x8000
PAGE_EXECUTE_READWRITE = 0x40
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
VirtualAllocEx = kernel32.VirtualAllocEx
VirtualAllocEx.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
ctypes.c_uint32,
ctypes.c_uint32,
]
VirtualAllocEx.restype = ctypes.c_void_p
VirtualFreeEx = kernel32.VirtualFreeEx
VirtualFreeEx.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
ctypes.c_uint32,
]
VirtualFreeEx.restype = ctypes.c_bool
VirtualProtectEx = kernel32.VirtualProtectEx
VirtualProtectEx.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint32),
]
VirtualProtectEx.restype = ctypes.c_bool
FlushInstructionCache = kernel32.FlushInstructionCache
FlushInstructionCache.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
]
FlushInstructionCache.restype = ctypes.c_bool
@dataclass(slots=True)
class Entity:
address: int
name: str
hp: float
x: float
y: float
z: float
dist: float = 0.0
class RemoteAlloc:
"""Simple wrapper around VirtualAllocEx / VirtualFreeEx."""
def __init__(self, pm: pymem.Pymem, size: int):
self.pm = pm
self.size = size
self.address = int(VirtualAllocEx(
pm.process_handle,
None,
size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE,
))
if not self.address:
raise ctypes.WinError(ctypes.get_last_error())
def free(self) -> None:
if self.address:
VirtualFreeEx(
self.pm.process_handle,
ctypes.c_void_p(self.address),
0,
MEM_RELEASE,
)
self.address = 0
# ================================================== ===========================
# Memory helpers
# ================================================== ===========================
def valid_ptr(value: int) -> bool:
return 0x10000 <= value <= 0x00007FFFFFFFFFFF
def module_base(pm: pymem.Pymem, name: str) -> int:
mod = pymem.process.module_from_name(pm.process_handle, name)
if mod is None:
raise RuntimeError(f"Module not found: {name}")
return mod.lpBaseOfDll
def rp(pm: pymem.Pymem, addr: int) -> int:
try:
value = pm.read_ulonglong(addr)
return value if valid_ptr(value) else 0
except Exception:
return 0
def ru64(pm: pymem.Pymem, addr: int) -> int | None:
try:
return pm.read_ulonglong(addr)
except Exception:
return None
def rf32(pm: pymem.Pymem, addr: int) -> float | None:
try:
value = pm.read_float(addr)
return value if math.isfinite(value) else None
except Exception:
return None
def rf64(pm: pymem.Pymem, addr: int) -> float | None:
try:
value = pm.read_double(addr)
return value if math.isfinite(value) else None
except Exception:
return None
def wb(pm: pymem.Pymem, addr: int, data: bytes) -> None:
pm.write_bytes(addr, data, len(data))
def wu32(pm: pymem.Pymem, addr: int, value: int) -> None:
wb(pm, addr, struct.pack("<I", value & 0xFFFFFFFF))
def wu64(pm: pymem.Pymem, addr: int, value: int) -> None:
wb(pm, addr, struct.pack("<Q", value & 0xFFFFFFFFFFFFFFFF))
def wf32(pm: pymem.Pymem, addr: int, value: float) -> None:
wb(pm, addr, struct.pack("<f", float(value)))
def protect(pm: pymem.Pymem, addr: int, size: int, prot: int) -> int:
old = ctypes.c_uint32(0)
ok = VirtualProtectEx(
pm.process_handle,
ctypes.c_void_p(addr),
size,
prot,
ctypes.byref(old),
)
if not ok:
raise ctypes.WinError(ctypes.get_last_error())
return old.value
def flush(pm: pymem.Pymem, addr: int, size: int) -> None:
FlushInstructionCache(
pm.process_handle,
ctypes.c_void_p(addr),
size,
)
# ================================================== ===========================
# Entity reading
# ================================================== ===========================
def chain_addr(pm: pymem.Pymem, base: int, chain: list[int]) -> int:
"""Resolve pointer chain and return final value address."""
cur = base
for off in chain[:-1]:
cur = rp(pm, cur + off)
if not valid_ptr(cur):
return 0
addr = cur + chain[-1]
return addr if valid_ptr(addr) else 0
def read_str(pm: pymem.Pymem, addr: int, max_len: int = NAME_MAX_LEN) -> str:
if not valid_ptr(addr):
return ""
try:
raw = pm.read_bytes(addr, max_len).split(b"\0", 1)[0]
return raw.decode("latin-1", errors="replace").strip() if raw else ""
except Exception:
return ""
def read_name(pm: pymem.Pymem, ent: int) -> str:
"""Name may be direct or behind one extra pointer."""
addr = chain_addr(pm, ent, OFFSETS["name"])
direct = read_str(pm, addr)
if direct:
return direct
return read_str(pm, rp(pm, addr))
def read_vec3(pm: pymem.Pymem, addr: int) -> tuple[float, float, float] | None:
x = rf32(pm, addr)
y = rf32(pm, addr + 4)
z = rf32(pm, addr + 8)
if x is None or y is None or z is None:
return None
if abs(x) > 1_000_000 or abs(y) > 1_000_000 or abs(z) > 1_000_000:
return None
if abs(x) < 0.0001 and abs(y) < 0.0001 and abs(z) < 0.0001:
return None
return x, y, z
def read_vec3_chain(pm: pymem.Pymem, ent: int, chain: list[int]) -> tuple[float, float, float] | None:
return read_vec3(pm, chain_addr(pm, ent, chain))
def blacklisted(name: str) -> bool:
"""
Contains-based blacklist.
This does not require the entity name to start with the keyword.
If the keyword appears anywhere in the name, the entity is ignored.
"""
low = name.lower()
return any(keyword.lower() in low for keyword in blacklist_keywords)
def read_entity(pm: pymem.Pymem, ent: int) -> Entity | None:
name = read_name(pm, ent)
if not name or blacklisted(name):
return None
pos = read_vec3_chain(pm, ent, OFFSETS["position"])
if pos is None:
return None
hp_addr = chain_addr(pm, ent, OFFSETS["current_health"])
hp = rf64(pm, hp_addr)
x, y, z = pos
return Entity(ent, name, hp, x, y, z)
def collect_entities(pm: pymem.Pymem, owner: int) -> list[Entity]:
"""
Walk the entity hash table and return valid targets only.
Important:
MAX_RANGE is not applied here.
This function only extracts valid entities.
Range filtering happens later in closest_entity().
"""
base = rp(pm, owner + OFFSETS["hash_base"])
stride = ru64(pm, owner + OFFSETS["hash_stride"])
count = ru64(pm, owner + OFFSETS["hash_count"])
if not valid_ptr(base) or stride is None or count is None:
return []
if stride <= 0 or stride > 0x400 or count <= 0 or count > MAX_BUCKETS:
return []
out: list[Entity] = []
seen_nodes: set[int] = set()
seen_entities: set[int] = set()
nodes = 0
for bucket in range(int(count)):
node = base + bucket * int(stride)
depth = 0
while valid_ptr(node) and depth < MAX_CHAIN_DEPTH and nodes < MAX_TOTAL_NODES:
if node in seen_nodes:
break
seen_nodes.add(node)
nodes += 1
raw_next = rp(pm, node + OFFSETS["node_next"])
next_node = raw_next & NODE_MARK_MASK if raw_next else 0
ent_ptr = rp(pm, node + OFFSETS["node_object"])
if valid_ptr(ent_ptr) and ent_ptr not in seen_entities:
seen_entities.add(ent_ptr)
ent = read_entity(pm, ent_ptr)
if ent is not None:
out.append(ent)
if raw_next == 1 or not valid_ptr(next_node) or next_node == node:
break
node = next_node
depth += 1
return out
# ================================================== ===========================
# Target selection
# ================================================== ===========================
def cam_pos(pm: pymem.Pymem, base: int) -> tuple[float, float, float] | None:
"""Read current camera position."""
local = rp(pm, base + LOCAL_BASE_PTR)
if not valid_ptr(local):
return None
root = rp(pm, local + CAM_ROOT_OFFSET)
if not valid_ptr(root):
return None
return read_vec3(pm, root + CAM_POS_OFFSET)
def dist3(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
dx = a[0] - b[0]
dy = a[1] - b[1]
dz = a[2] - b[2]
return math.sqrt(dx * dx + dy * dy + dz * dz)
def aim_pos(ent: Entity) -> tuple[float, float, float]:
"""Convert entity base position to aim point."""
return ent.x, ent.y + AIM_Y_OFFSET, ent.z
def closest_entity(
entities: list[Entity],
camera: tuple[float, float, float],
) -> Entity | None:
"""
Select the closest entity inside MAX_RANGE only.
This does not affect extraction.
If all extracted entities are outside MAX_RANGE, return None.
Returning None disables silent aim until a valid target enters range.
"""
best = None
best_dist = float("inf")
for ent in entities:
d = dist3(camera, aim_pos(ent))
if d > MAX_RANGE:
continue
if d < best_dist:
best_dist = d
ent.dist = d
best = ent
return best
# ================================================== ===========================
# Codecave
# ================================================== ===========================
def asm_bytes(asm: str) -> bytes:
"""Assemble code with Keystone after removing comments/empty lines."""
clean = "\n".join(
line.split(";", 1)[0].strip()
for line in asm.splitlines()
if line.split(";", 1)[0].strip()
)
ks = Ks(KS_ARCH_X86, KS_MODE_64)
code, _ = ks.asm(clean)
if not code:
raise RuntimeError("Keystone returned empty code")
return bytes(code)
def jmp_abs(addr: int) -> bytes:
"""14-byte absolute jump, works even when cave is far away."""
return b"\xFF\x25\x00\x00\x00\x00" + struct.pack("<Q", addr)
def init_data(pm: pymem.Pymem, data: int, local_base_ptr: int) -> None:
"""Initialize shared block read by the injected cave."""
wu32(pm, data + DATA_ENABLED, 1)
wu32(pm, data + DATA_VALID, 0)
wf32(pm, data + DATA_X, 0.0)
wf32(pm, data + DATA_Y, 0.0)
wf32(pm, data + DATA_Z, 0.0)
wf32(pm, data + DATA_PARALLAX, PARALLAX)
wf32(pm, data + DATA_MIN_LEN_SQ, MIN_LEN_SQ)
wu64(pm, data + DATA_LOCAL_BASE_PTR, local_base_ptr)
def build_cave(data: int, ret: int) -> bytes:
"""
Entry hook for SetProjectileOrigin.
It writes a corrected normalized direction directly into [r8],
because r8 is the incoming projectile direction pointer.
"""
return asm_bytes(f"""
pushfq
push rax
push rdx
push r10
push r11
sub rsp, 0x60
movdqu xmmword ptr [rsp + 0x00], xmm0
movdqu xmmword ptr [rsp + 0x10], xmm1
movdqu xmmword ptr [rsp + 0x20], xmm2
movdqu xmmword ptr [rsp + 0x30], xmm3
movdqu xmmword ptr [rsp + 0x40], xmm4
movdqu xmmword ptr [rsp + 0x50], xmm5
mov r10, 0x{data:X}
cmp dword ptr [r10 + 0x{DATA_ENABLED:X}], 1
jne skip
cmp dword ptr [r10 + 0x{DATA_VALID:X}], 1
jne skip
test rdx, rdx
je skip
test r8, r8
je skip
mov rax, qword ptr [r10 + 0x{DATA_LOCAL_BASE_PTR:X}]
test rax, rax
je skip
mov rax, qword ptr [rax]
test rax, rax
je skip
mov r11, qword ptr [rax + 0x{CAM_ROOT_OFFSET:X}]
test r11, r11
je skip
movss xmm0, dword ptr [r10 + 0x{DATA_X:X}]
movss xmm3, dword ptr [r11 + 0x{CAM_POS_OFFSET + 0:X}]
movss xmm4, xmm3
subss xmm4, dword ptr [rdx + 0x00]
mulss xmm4, dword ptr [r10 + 0x{DATA_PARALLAX:X}]
addss xmm0, xmm4
subss xmm0, xmm3
movss xmm1, dword ptr [r10 + 0x{DATA_Y:X}]
movss xmm3, dword ptr [r11 + 0x{CAM_POS_OFFSET + 4:X}]
movss xmm4, xmm3
subss xmm4, dword ptr [rdx + 0x04]
mulss xmm4, dword ptr [r10 + 0x{DATA_PARALLAX:X}]
addss xmm1, xmm4
subss xmm1, xmm3
movss xmm2, dword ptr [r10 + 0x{DATA_Z:X}]
movss xmm3, dword ptr [r11 + 0x{CAM_POS_OFFSET + 8:X}]
movss xmm4, xmm3
subss xmm4, dword ptr [rdx + 0x08]
mulss xmm4, dword ptr [r10 + 0x{DATA_PARALLAX:X}]
addss xmm2, xmm4
subss xmm2, xmm3
movss xmm3, xmm0
mulss xmm3, xmm3
movss xmm4, xmm1
mulss xmm4, xmm4
addss xmm3, xmm4
movss xmm5, xmm2
mulss xmm5, xmm5
addss xmm3, xmm5
comiss xmm3, dword ptr [r10 + 0x{DATA_MIN_LEN_SQ:X}]
jbe skip
sqrtss xmm3, xmm3
divss xmm0, xmm3
divss xmm1, xmm3
divss xmm2, xmm3
movss dword ptr [r8 + 0x00], xmm0
movss dword ptr [r8 + 0x04], xmm1
movss dword ptr [r8 + 0x08], xmm2
skip:
movdqu xmm0, xmmword ptr [rsp + 0x00]
movdqu xmm1, xmmword ptr [rsp + 0x10]
movdqu xmm2, xmmword ptr [rsp + 0x20]
movdqu xmm3, xmmword ptr [rsp + 0x30]
movdqu xmm4, xmmword ptr [rsp + 0x40]
movdqu xmm5, xmmword ptr [rsp + 0x50]
add rsp, 0x60
pop r11
pop r10
pop rdx
pop rax
popfq
mov rax, rsp
mov qword ptr [rax + 0x10], rbx
mov dword ptr [rax + 0x20], r9d
push rbp
push rsi
push rdi
mov r11, 0x{ret:X}
jmp r11
""")
def install_hook(pm: pymem.Pymem, hook: int, cave: int) -> bytes:
"""Patch the function entry with an absolute jump."""
original = pm.read_bytes(hook, PATCH_LEN)
if original != EXPECTED_ORIGINAL:
raise RuntimeError(
"Hook bytes mismatch\n"
f"expected: {EXPECTED_ORIGINAL.hex(' ').upper()}\n"
f"actual : {original.hex(' ').upper()}"
)
patch = jmp_abs(cave)
old = protect(pm, hook, PATCH_LEN, PAGE_EXECUTE_READWRITE)
try:
wb(pm, hook, patch)
flush(pm, hook, PATCH_LEN)
finally:
protect(pm, hook, PATCH_LEN, old)
return original
def uninstall_hook(pm: pymem.Pymem, hook: int, original: bytes) -> None:
"""Restore original function bytes."""
old = protect(pm, hook, len(original), PAGE_EXECUTE_READWRITE)
try:
wb(pm, hook, original)
flush(pm, hook, len(original))
finally:
protect(pm, hook, len(original), old)
# ================================================== ===========================
# Target updater
# ================================================== ===========================
def set_valid(pm: pymem.Pymem, data: int, valid: bool) -> None:
wu32(pm, data + DATA_VALID, 1 if valid else 0)
def write_target(pm: pymem.Pymem, data: int, pos: tuple[float, float, float]) -> None:
"""Write selected target position to shared block."""
wf32(pm, data + DATA_X, pos[0])
wf32(pm, data + DATA_Y, pos[1])
wf32(pm, data + DATA_Z, pos[2])
wu32(pm, data + DATA_VALID, 1)
def target_loop(pm: pymem.Pymem, base: int, data: int, stop: threading.Event) -> None:
"""
Background thread.
It extracts entities normally, then applies MAX_RANGE only during final
target selection. This keeps extraction independent from range filtering.
"""
owner_ptr = base + EntityList
last_addr = 0
last_pos = None
last_clear = 0.0
while not stop.is_set():
try:
camera = cam_pos(pm, base)
owner = rp(pm, owner_ptr)
if camera is None or not valid_ptr(owner):
if time.time() - last_clear >= 0.25:
set_valid(pm, data, False)
last_clear = time.time()
time.sleep(ENTITY_REFRESH_DELAY)
continue
entities = collect_entities(pm, owner)
target = closest_entity(entities, camera)
if target is None:
last_addr = 0
last_pos = None
if time.time() - last_clear >= 0.25:
set_valid(pm, data, False)
last_clear = time.time()
time.sleep(ENTITY_REFRESH_DELAY)
continue
pos = aim_pos(target)
moved = 999999.0 if last_pos is None else dist3(last_pos, pos)
if target.address != last_addr or last_pos is None or moved >= TARGET_UPDATE_MIN_DELTA:
write_target(pm, data, pos)
last_addr = target.address
last_pos = pos
time.sleep(ENTITY_REFRESH_DELAY)
except Exception:
set_valid(pm, data, False)
time.sleep(ENTITY_REFRESH_DELAY)
# ================================================== ===========================
# Main
# ================================================== ===========================
def main() -> None:
print(f"[*] Attaching to {PROCESS_NAME}...")
pm = pymem.Pymem(PROCESS_NAME)
base = module_base(pm, PROCESS_NAME)
hook = base + HOOK_RVA
ret = base + RETURN_RVA
local_base_ptr = base + LOCAL_BASE_PTR
data = RemoteAlloc(pm, 0x1000)
cave = RemoteAlloc(pm, 0x4000)
original = b""
installed = False
stop = threading.Event()
worker = None
try:
init_data(pm, data.address, local_base_ptr)
cave_code = build_cave(data.address, ret)
wb(pm, cave.address, cave_code)
flush(pm, cave.address, len(cave_code))
original = install_hook(pm, hook, cave.address)
installed = True
worker = threading.Thread(
target=target_loop,
args=(pm, base, data.address, stop),
daemon=True,
)
worker.start()
print("[+] Silent aim running")
print(f"[+] max range = {MAX_RANGE:.1f}")
print("[*] Press Ctrl+C to stop")
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n[*] Stopping...")
finally:
stop.set()
if worker is not None:
worker.join(timeout=1.0)
# Disable the cave logic before restoring/freeing memory.
# This makes shutdown safer if the game hits the hook during cleanup.
if data is not None and data.address:
try:
wu32(pm, data.address + DATA_ENABLED, 0)
wu32(pm, data.address + DATA_VALID, 0)
except Exception:
pass
if installed:
try:
uninstall_hook(pm, hook, original)
print("[+] Hook restored")
except Exception as exc:
print(f"[-] Failed to restore hook: {exc}")
if cave is not None:
try:
cave.free()
except Exception:
pass
if data is not None:
try:
data.free()
except Exception:
pass
print("[+] Done")
if __name__ == "__main__":
main()
First of all before I start saying anything, after I explain everything I’ll attach the source code I built in Python.
If you don’t care about the explanation, you can just install any Python version from the official website, as long as it’s Python 3 or higher.
After installing Python, use this command:
Code:
pip install pymem keystone-engine
Also keep in mind this may break after updates, so you might need to update the signatures.
Code:
hook_sig = 48 8B C4 48 89 58 10 44 89 48 20 55 56 57 48 81 EC D0 00 00 00 Entitylist_sig = 48 8B 0D ?? ?? ?? ?? 48 85 C9 74 16 48 8B 05 + 0x3 localplayer_sig = 48 8B 0D ?? ?? ?? ?? 48 8D 50 98 + 0x3
Alright now let’s get into the explanation.
# Explanation
The short version is that the new silent aim method now works at a later stage of the projectile path.
Instead of modifying functions close to the raycast or early aim calculations, the modification now happens at the function that receives the projectile origin and direction already prepared.
The important function is:
Code:
Trove_x64.exe+77E9E0
Code:
rcx = projectile / object rdx = projectile origin r8 = projectile direction r9 = mode / type
`origin` = projectile spawn position
`direction` = projectile direction vector
The silent aim idea here is extremely simple:
Take the target position, calculate a new direction from the projectile origin to the target, then overwrite the original direction stored in `r8`.
Something like this:
Code:
direction = normalize(target_pos - projectile_origin)
Code:
[r8]
Quick example:
Code:
dx = target_x - origin_x dy = target_y - origin_y dz = target_z - origin_z length = sqrt(dx*dx + dy*dy + dz*dz) dir_x = dx / length dir_y = dy / length dir_z = dz / length
The important x64 flow now roughly looks like this:
Code:
77D970 -> 77E010 -> 755910 -> 77E9E0
Code:
755910 = closest function to aim / ray direction calculations 77E9E0 = function receiving the final direction
Code:
Trove_x64.exe+755910
But as a practical silent aim hook, it’s much better to hook:
Code:
Trove_x64.exe+77E9E0
At this point the direction is already fully prepared and directly accessible in `r8`, so there’s no need to mess with internal calculation logic.
Instead of modifying how the game calculates the direction, you simply modify the final result before the projectile uses it.
That’s the important difference.
The old method was closer to the direction calculation logic itself.
The new method grabs the direction after everything is already calculated.
That’s why the new method is easier to understand and easier to update, because the location is very clear:
Code:
rdx = origin r8 = direction
Code:
if silent aim enabled and target is valid calculate new direction write direction into [r8] continue original function
The game prepares the projectile and prepares the direction, and right before the game uses that direction, we replace it with our own target direction.
If a future update breaks the address, you’ll most likely only need to update the hook signature:
Code:
48 8B C4 48 89 58 10 44 89 48 20 55 56 57 48 81 EC D0 00 00 00
Code:
Trove_x64.exe+755910
Code:
```cpp Trove_x64.exe+77E9E0
is much better because that’s where we directly receive and modify the final direction vector.
And that’s basically the new silent aim method.
Hope everyone enjoys it.
Source Code
import ctypes
import math
import struct
import sys
import threading
import time
from dataclasses import dataclass
import pymem
import pymem.process
try:
from keystone import Ks, KS_ARCH_X86, KS_MODE_64
except ImportError:
print("[-] Install keystone-engine: pip install keystone-engine")
sys.exit(1)
# ================================================== ===========================
# Config
# ================================================== ===========================
PROCESS_NAME = "Trove_x64.exe"
ENTITY_REFRESH_DELAY = 0.03
TARGET_UPDATE_MIN_DELTA = 0.01
# Max silent-aim target range.
# This does NOT affect entity extraction.
# It only decides whether the closest extracted entity is allowed as a target.
MAX_RANGE = 50.0
# Entity base position is usually low. This lifts the aim point into the body.
AIM_Y_OFFSET = 0.45
# Full camera/projectile parallax compensation.
PARALLAX = 1.0
MIN_LEN_SQ = 0.000001
# Contains-based blacklist:
# If the entity name contains any keyword anywhere, it is ignored.
blacklist_keywords = [
"pet",
"portal",
"abilities",
"cornerstone",
"services",
"client",
#"mana",
"karma",
"adventure",
"placeable",
]
# ================================================== ===========================
# RVAs / offsets
# ================================================== ===========================
EntityList = 0x1396BC0
LOCAL_BASE_PTR = 0x0139F870
CAM_ROOT_OFFSET = 0x40
CAM_POS_OFFSET = 0x140
# Confirmed active SetProjectileOrigin variant.
# Entry args:
# rdx = projectile origin pointer
# r8 = projectile direction pointer
HOOK_RVA = 0x77E9E0
PATCH_LEN = 14
RETURN_RVA = HOOK_RVA + PATCH_LEN
EXPECTED_ORIGINAL = bytes.fromhex(
"48 8B C4 48 89 58 10 44 89 48 20 55 56 57"
)
OFFSETS = {
"hash_base": 0xE0,
"hash_stride": 0xE8,
"hash_count": 0xF0,
"node_next": 0x00,
"node_object": 0x10,
"name": [0x98, 0x0],
"position": [0x130, 0x8, 0xD0],
"scale": [0x130, 0x8, 0x178],
"level": [0x130, 0xA8, 0x208],
"current_health": [0x130, 0x108, 0xD8],
}
NODE_MARK_MASK = 0xFFFFFFFFFFFFFFFE
MAX_BUCKETS = 8192
MAX_CHAIN_DEPTH = 256
MAX_TOTAL_NODES = 30000
NAME_MAX_LEN = 160
# ================================================== ===========================
# Shared data block
# ================================================== ===========================
# Python writes target data here. The codecave reads it on each projectile call.
DATA_ENABLED = 0x00 # u32
DATA_VALID = 0x04 # u32
DATA_X = 0x08 # float
DATA_Y = 0x0C # float
DATA_Z = 0x10 # float
DATA_PARALLAX = 0x14 # float
DATA_MIN_LEN_SQ = 0x18 # float
DATA_LOCAL_BASE_PTR = 0x20 # u64
# ================================================== ===========================
# WinAPI
# ================================================== ===========================
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
MEM_RELEASE = 0x8000
PAGE_EXECUTE_READWRITE = 0x40
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
VirtualAllocEx = kernel32.VirtualAllocEx
VirtualAllocEx.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
ctypes.c_uint32,
ctypes.c_uint32,
]
VirtualAllocEx.restype = ctypes.c_void_p
VirtualFreeEx = kernel32.VirtualFreeEx
VirtualFreeEx.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
ctypes.c_uint32,
]
VirtualFreeEx.restype = ctypes.c_bool
VirtualProtectEx = kernel32.VirtualProtectEx
VirtualProtectEx.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint32),
]
VirtualProtectEx.restype = ctypes.c_bool
FlushInstructionCache = kernel32.FlushInstructionCache
FlushInstructionCache.argtypes = [
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_size_t,
]
FlushInstructionCache.restype = ctypes.c_bool
@dataclass(slots=True)
class Entity:
address: int
name: str
hp: float
x: float
y: float
z: float
dist: float = 0.0
class RemoteAlloc:
"""Simple wrapper around VirtualAllocEx / VirtualFreeEx."""
def __init__(self, pm: pymem.Pymem, size: int):
self.pm = pm
self.size = size
self.address = int(VirtualAllocEx(
pm.process_handle,
None,
size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE,
))
if not self.address:
raise ctypes.WinError(ctypes.get_last_error())
def free(self) -> None:
if self.address:
VirtualFreeEx(
self.pm.process_handle,
ctypes.c_void_p(self.address),
0,
MEM_RELEASE,
)
self.address = 0
# ================================================== ===========================
# Memory helpers
# ================================================== ===========================
def valid_ptr(value: int) -> bool:
return 0x10000 <= value <= 0x00007FFFFFFFFFFF
def module_base(pm: pymem.Pymem, name: str) -> int:
mod = pymem.process.module_from_name(pm.process_handle, name)
if mod is None:
raise RuntimeError(f"Module not found: {name}")
return mod.lpBaseOfDll
def rp(pm: pymem.Pymem, addr: int) -> int:
try:
value = pm.read_ulonglong(addr)
return value if valid_ptr(value) else 0
except Exception:
return 0
def ru64(pm: pymem.Pymem, addr: int) -> int | None:
try:
return pm.read_ulonglong(addr)
except Exception:
return None
def rf32(pm: pymem.Pymem, addr: int) -> float | None:
try:
value = pm.read_float(addr)
return value if math.isfinite(value) else None
except Exception:
return None
def rf64(pm: pymem.Pymem, addr: int) -> float | None:
try:
value = pm.read_double(addr)
return value if math.isfinite(value) else None
except Exception:
return None
def wb(pm: pymem.Pymem, addr: int, data: bytes) -> None:
pm.write_bytes(addr, data, len(data))
def wu32(pm: pymem.Pymem, addr: int, value: int) -> None:
wb(pm, addr, struct.pack("<I", value & 0xFFFFFFFF))
def wu64(pm: pymem.Pymem, addr: int, value: int) -> None:
wb(pm, addr, struct.pack("<Q", value & 0xFFFFFFFFFFFFFFFF))
def wf32(pm: pymem.Pymem, addr: int, value: float) -> None:
wb(pm, addr, struct.pack("<f", float(value)))
def protect(pm: pymem.Pymem, addr: int, size: int, prot: int) -> int:
old = ctypes.c_uint32(0)
ok = VirtualProtectEx(
pm.process_handle,
ctypes.c_void_p(addr),
size,
prot,
ctypes.byref(old),
)
if not ok:
raise ctypes.WinError(ctypes.get_last_error())
return old.value
def flush(pm: pymem.Pymem, addr: int, size: int) -> None:
FlushInstructionCache(
pm.process_handle,
ctypes.c_void_p(addr),
size,
)
# ================================================== ===========================
# Entity reading
# ================================================== ===========================
def chain_addr(pm: pymem.Pymem, base: int, chain: list[int]) -> int:
"""Resolve pointer chain and return final value address."""
cur = base
for off in chain[:-1]:
cur = rp(pm, cur + off)
if not valid_ptr(cur):
return 0
addr = cur + chain[-1]
return addr if valid_ptr(addr) else 0
def read_str(pm: pymem.Pymem, addr: int, max_len: int = NAME_MAX_LEN) -> str:
if not valid_ptr(addr):
return ""
try:
raw = pm.read_bytes(addr, max_len).split(b"\0", 1)[0]
return raw.decode("latin-1", errors="replace").strip() if raw else ""
except Exception:
return ""
def read_name(pm: pymem.Pymem, ent: int) -> str:
"""Name may be direct or behind one extra pointer."""
addr = chain_addr(pm, ent, OFFSETS["name"])
direct = read_str(pm, addr)
if direct:
return direct
return read_str(pm, rp(pm, addr))
def read_vec3(pm: pymem.Pymem, addr: int) -> tuple[float, float, float] | None:
x = rf32(pm, addr)
y = rf32(pm, addr + 4)
z = rf32(pm, addr + 8)
if x is None or y is None or z is None:
return None
if abs(x) > 1_000_000 or abs(y) > 1_000_000 or abs(z) > 1_000_000:
return None
if abs(x) < 0.0001 and abs(y) < 0.0001 and abs(z) < 0.0001:
return None
return x, y, z
def read_vec3_chain(pm: pymem.Pymem, ent: int, chain: list[int]) -> tuple[float, float, float] | None:
return read_vec3(pm, chain_addr(pm, ent, chain))
def blacklisted(name: str) -> bool:
"""
Contains-based blacklist.
This does not require the entity name to start with the keyword.
If the keyword appears anywhere in the name, the entity is ignored.
"""
low = name.lower()
return any(keyword.lower() in low for keyword in blacklist_keywords)
def read_entity(pm: pymem.Pymem, ent: int) -> Entity | None:
name = read_name(pm, ent)
if not name or blacklisted(name):
return None
pos = read_vec3_chain(pm, ent, OFFSETS["position"])
if pos is None:
return None
hp_addr = chain_addr(pm, ent, OFFSETS["current_health"])
hp = rf64(pm, hp_addr)
x, y, z = pos
return Entity(ent, name, hp, x, y, z)
def collect_entities(pm: pymem.Pymem, owner: int) -> list[Entity]:
"""
Walk the entity hash table and return valid targets only.
Important:
MAX_RANGE is not applied here.
This function only extracts valid entities.
Range filtering happens later in closest_entity().
"""
base = rp(pm, owner + OFFSETS["hash_base"])
stride = ru64(pm, owner + OFFSETS["hash_stride"])
count = ru64(pm, owner + OFFSETS["hash_count"])
if not valid_ptr(base) or stride is None or count is None:
return []
if stride <= 0 or stride > 0x400 or count <= 0 or count > MAX_BUCKETS:
return []
out: list[Entity] = []
seen_nodes: set[int] = set()
seen_entities: set[int] = set()
nodes = 0
for bucket in range(int(count)):
node = base + bucket * int(stride)
depth = 0
while valid_ptr(node) and depth < MAX_CHAIN_DEPTH and nodes < MAX_TOTAL_NODES:
if node in seen_nodes:
break
seen_nodes.add(node)
nodes += 1
raw_next = rp(pm, node + OFFSETS["node_next"])
next_node = raw_next & NODE_MARK_MASK if raw_next else 0
ent_ptr = rp(pm, node + OFFSETS["node_object"])
if valid_ptr(ent_ptr) and ent_ptr not in seen_entities:
seen_entities.add(ent_ptr)
ent = read_entity(pm, ent_ptr)
if ent is not None:
out.append(ent)
if raw_next == 1 or not valid_ptr(next_node) or next_node == node:
break
node = next_node
depth += 1
return out
# ================================================== ===========================
# Target selection
# ================================================== ===========================
def cam_pos(pm: pymem.Pymem, base: int) -> tuple[float, float, float] | None:
"""Read current camera position."""
local = rp(pm, base + LOCAL_BASE_PTR)
if not valid_ptr(local):
return None
root = rp(pm, local + CAM_ROOT_OFFSET)
if not valid_ptr(root):
return None
return read_vec3(pm, root + CAM_POS_OFFSET)
def dist3(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
dx = a[0] - b[0]
dy = a[1] - b[1]
dz = a[2] - b[2]
return math.sqrt(dx * dx + dy * dy + dz * dz)
def aim_pos(ent: Entity) -> tuple[float, float, float]:
"""Convert entity base position to aim point."""
return ent.x, ent.y + AIM_Y_OFFSET, ent.z
def closest_entity(
entities: list[Entity],
camera: tuple[float, float, float],
) -> Entity | None:
"""
Select the closest entity inside MAX_RANGE only.
This does not affect extraction.
If all extracted entities are outside MAX_RANGE, return None.
Returning None disables silent aim until a valid target enters range.
"""
best = None
best_dist = float("inf")
for ent in entities:
d = dist3(camera, aim_pos(ent))
if d > MAX_RANGE:
continue
if d < best_dist:
best_dist = d
ent.dist = d
best = ent
return best
# ================================================== ===========================
# Codecave
# ================================================== ===========================
def asm_bytes(asm: str) -> bytes:
"""Assemble code with Keystone after removing comments/empty lines."""
clean = "\n".join(
line.split(";", 1)[0].strip()
for line in asm.splitlines()
if line.split(";", 1)[0].strip()
)
ks = Ks(KS_ARCH_X86, KS_MODE_64)
code, _ = ks.asm(clean)
if not code:
raise RuntimeError("Keystone returned empty code")
return bytes(code)
def jmp_abs(addr: int) -> bytes:
"""14-byte absolute jump, works even when cave is far away."""
return b"\xFF\x25\x00\x00\x00\x00" + struct.pack("<Q", addr)
def init_data(pm: pymem.Pymem, data: int, local_base_ptr: int) -> None:
"""Initialize shared block read by the injected cave."""
wu32(pm, data + DATA_ENABLED, 1)
wu32(pm, data + DATA_VALID, 0)
wf32(pm, data + DATA_X, 0.0)
wf32(pm, data + DATA_Y, 0.0)
wf32(pm, data + DATA_Z, 0.0)
wf32(pm, data + DATA_PARALLAX, PARALLAX)
wf32(pm, data + DATA_MIN_LEN_SQ, MIN_LEN_SQ)
wu64(pm, data + DATA_LOCAL_BASE_PTR, local_base_ptr)
def build_cave(data: int, ret: int) -> bytes:
"""
Entry hook for SetProjectileOrigin.
It writes a corrected normalized direction directly into [r8],
because r8 is the incoming projectile direction pointer.
"""
return asm_bytes(f"""
pushfq
push rax
push rdx
push r10
push r11
sub rsp, 0x60
movdqu xmmword ptr [rsp + 0x00], xmm0
movdqu xmmword ptr [rsp + 0x10], xmm1
movdqu xmmword ptr [rsp + 0x20], xmm2
movdqu xmmword ptr [rsp + 0x30], xmm3
movdqu xmmword ptr [rsp + 0x40], xmm4
movdqu xmmword ptr [rsp + 0x50], xmm5
mov r10, 0x{data:X}
cmp dword ptr [r10 + 0x{DATA_ENABLED:X}], 1
jne skip
cmp dword ptr [r10 + 0x{DATA_VALID:X}], 1
jne skip
test rdx, rdx
je skip
test r8, r8
je skip
mov rax, qword ptr [r10 + 0x{DATA_LOCAL_BASE_PTR:X}]
test rax, rax
je skip
mov rax, qword ptr [rax]
test rax, rax
je skip
mov r11, qword ptr [rax + 0x{CAM_ROOT_OFFSET:X}]
test r11, r11
je skip
movss xmm0, dword ptr [r10 + 0x{DATA_X:X}]
movss xmm3, dword ptr [r11 + 0x{CAM_POS_OFFSET + 0:X}]
movss xmm4, xmm3
subss xmm4, dword ptr [rdx + 0x00]
mulss xmm4, dword ptr [r10 + 0x{DATA_PARALLAX:X}]
addss xmm0, xmm4
subss xmm0, xmm3
movss xmm1, dword ptr [r10 + 0x{DATA_Y:X}]
movss xmm3, dword ptr [r11 + 0x{CAM_POS_OFFSET + 4:X}]
movss xmm4, xmm3
subss xmm4, dword ptr [rdx + 0x04]
mulss xmm4, dword ptr [r10 + 0x{DATA_PARALLAX:X}]
addss xmm1, xmm4
subss xmm1, xmm3
movss xmm2, dword ptr [r10 + 0x{DATA_Z:X}]
movss xmm3, dword ptr [r11 + 0x{CAM_POS_OFFSET + 8:X}]
movss xmm4, xmm3
subss xmm4, dword ptr [rdx + 0x08]
mulss xmm4, dword ptr [r10 + 0x{DATA_PARALLAX:X}]
addss xmm2, xmm4
subss xmm2, xmm3
movss xmm3, xmm0
mulss xmm3, xmm3
movss xmm4, xmm1
mulss xmm4, xmm4
addss xmm3, xmm4
movss xmm5, xmm2
mulss xmm5, xmm5
addss xmm3, xmm5
comiss xmm3, dword ptr [r10 + 0x{DATA_MIN_LEN_SQ:X}]
jbe skip
sqrtss xmm3, xmm3
divss xmm0, xmm3
divss xmm1, xmm3
divss xmm2, xmm3
movss dword ptr [r8 + 0x00], xmm0
movss dword ptr [r8 + 0x04], xmm1
movss dword ptr [r8 + 0x08], xmm2
skip:
movdqu xmm0, xmmword ptr [rsp + 0x00]
movdqu xmm1, xmmword ptr [rsp + 0x10]
movdqu xmm2, xmmword ptr [rsp + 0x20]
movdqu xmm3, xmmword ptr [rsp + 0x30]
movdqu xmm4, xmmword ptr [rsp + 0x40]
movdqu xmm5, xmmword ptr [rsp + 0x50]
add rsp, 0x60
pop r11
pop r10
pop rdx
pop rax
popfq
mov rax, rsp
mov qword ptr [rax + 0x10], rbx
mov dword ptr [rax + 0x20], r9d
push rbp
push rsi
push rdi
mov r11, 0x{ret:X}
jmp r11
""")
def install_hook(pm: pymem.Pymem, hook: int, cave: int) -> bytes:
"""Patch the function entry with an absolute jump."""
original = pm.read_bytes(hook, PATCH_LEN)
if original != EXPECTED_ORIGINAL:
raise RuntimeError(
"Hook bytes mismatch\n"
f"expected: {EXPECTED_ORIGINAL.hex(' ').upper()}\n"
f"actual : {original.hex(' ').upper()}"
)
patch = jmp_abs(cave)
old = protect(pm, hook, PATCH_LEN, PAGE_EXECUTE_READWRITE)
try:
wb(pm, hook, patch)
flush(pm, hook, PATCH_LEN)
finally:
protect(pm, hook, PATCH_LEN, old)
return original
def uninstall_hook(pm: pymem.Pymem, hook: int, original: bytes) -> None:
"""Restore original function bytes."""
old = protect(pm, hook, len(original), PAGE_EXECUTE_READWRITE)
try:
wb(pm, hook, original)
flush(pm, hook, len(original))
finally:
protect(pm, hook, len(original), old)
# ================================================== ===========================
# Target updater
# ================================================== ===========================
def set_valid(pm: pymem.Pymem, data: int, valid: bool) -> None:
wu32(pm, data + DATA_VALID, 1 if valid else 0)
def write_target(pm: pymem.Pymem, data: int, pos: tuple[float, float, float]) -> None:
"""Write selected target position to shared block."""
wf32(pm, data + DATA_X, pos[0])
wf32(pm, data + DATA_Y, pos[1])
wf32(pm, data + DATA_Z, pos[2])
wu32(pm, data + DATA_VALID, 1)
def target_loop(pm: pymem.Pymem, base: int, data: int, stop: threading.Event) -> None:
"""
Background thread.
It extracts entities normally, then applies MAX_RANGE only during final
target selection. This keeps extraction independent from range filtering.
"""
owner_ptr = base + EntityList
last_addr = 0
last_pos = None
last_clear = 0.0
while not stop.is_set():
try:
camera = cam_pos(pm, base)
owner = rp(pm, owner_ptr)
if camera is None or not valid_ptr(owner):
if time.time() - last_clear >= 0.25:
set_valid(pm, data, False)
last_clear = time.time()
time.sleep(ENTITY_REFRESH_DELAY)
continue
entities = collect_entities(pm, owner)
target = closest_entity(entities, camera)
if target is None:
last_addr = 0
last_pos = None
if time.time() - last_clear >= 0.25:
set_valid(pm, data, False)
last_clear = time.time()
time.sleep(ENTITY_REFRESH_DELAY)
continue
pos = aim_pos(target)
moved = 999999.0 if last_pos is None else dist3(last_pos, pos)
if target.address != last_addr or last_pos is None or moved >= TARGET_UPDATE_MIN_DELTA:
write_target(pm, data, pos)
last_addr = target.address
last_pos = pos
time.sleep(ENTITY_REFRESH_DELAY)
except Exception:
set_valid(pm, data, False)
time.sleep(ENTITY_REFRESH_DELAY)
# ================================================== ===========================
# Main
# ================================================== ===========================
def main() -> None:
print(f"[*] Attaching to {PROCESS_NAME}...")
pm = pymem.Pymem(PROCESS_NAME)
base = module_base(pm, PROCESS_NAME)
hook = base + HOOK_RVA
ret = base + RETURN_RVA
local_base_ptr = base + LOCAL_BASE_PTR
data = RemoteAlloc(pm, 0x1000)
cave = RemoteAlloc(pm, 0x4000)
original = b""
installed = False
stop = threading.Event()
worker = None
try:
init_data(pm, data.address, local_base_ptr)
cave_code = build_cave(data.address, ret)
wb(pm, cave.address, cave_code)
flush(pm, cave.address, len(cave_code))
original = install_hook(pm, hook, cave.address)
installed = True
worker = threading.Thread(
target=target_loop,
args=(pm, base, data.address, stop),
daemon=True,
)
worker.start()
print("[+] Silent aim running")
print(f"[+] max range = {MAX_RANGE:.1f}")
print("[*] Press Ctrl+C to stop")
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n[*] Stopping...")
finally:
stop.set()
if worker is not None:
worker.join(timeout=1.0)
# Disable the cave logic before restoring/freeing memory.
# This makes shutdown safer if the game hits the hook during cleanup.
if data is not None and data.address:
try:
wu32(pm, data.address + DATA_ENABLED, 0)
wu32(pm, data.address + DATA_VALID, 0)
except Exception:
pass
if installed:
try:
uninstall_hook(pm, hook, original)
print("[+] Hook restored")
except Exception as exc:
print(f"[-] Failed to restore hook: {exc}")
if cave is not None:
try:
cave.free()
except Exception:
pass
if data is not None:
try:
data.free()
except Exception:
pass
print("[+] Done")
if __name__ == "__main__":
main()
