import math
import time
from dataclasses import dataclass
import pymem
import pymem.process
PROCESS_NAME = "Trove_x64.exe"
OWNER_RVA = 0x1396BC0
# All confirmed chains are kept here.
OFFSETS = {
# owner = [module_base + OWNER_RVA]
"hash_base": [0xE0],
"hash_stride": [0xE8],
"hash_count": [0xF0],
# Hash node layout.
"node_next": [0x00],
"node_object": [0x10],
# Entity fields.
"name": [0x98, 0x0],
"position": [0x130, 0x8, 0xD0],
"scale": [0x130, 0x8, 0x178],
"health": [0x130, 0x108, 0xD8],
# Current best level/rank candidate.
# Normal mobs usually showed 1..3, boss candidate showed 4+.
"level": [0x130, 0xA8, 0x208],
}
NODE_MARK_MASK = 0xFFFFFFFFFFFFFFFE
MAX_BUCKETS = 8192
MAX_CHAIN_DEPTH = 256
MAX_TOTAL_NODES = 30000
NAME_MAX_LEN = 160
REFRESH_DELAY = 1.0
CLEAR_SCREEN = False
SKIP_ZERO_POSITION = True
# Empty = show everything with name and position.
# Mobs only:
# NAME_PREFIX_FILTERS = ["npc/"]
NAME_PREFIX_FILTERS = ["npc/"]
@
dataclass(slots=True)
class Entity:
index: str
name: str
health: float | None
level: int | None
scale: float | None
x: float
y: float
z: float
def is_plausible_ptr(value: int) -> bool:
return 0x10000 <= value <= 0x00007FFFFFFFFFFF
def read_ptr(pm: pymem.Pymem, address: int) -> int:
try:
value = pm.read_ulonglong(address)
return value if is_plausible_ptr(value) else 0
except Exception:
return 0
def read_u64(pm: pymem.Pymem, address: int) -> int | None:
try:
return pm.read_ulonglong(address)
except Exception:
return None
def read_u32(pm: pymem.Pymem, address: int) -> int | None:
try:
return pm.read_uint(address)
except Exception:
return None
def read_float(pm: pymem.Pymem, address: int) -> float | None:
try:
value = pm.read_float(address)
if not math.isfinite(value):
return None
return value
except Exception:
return None
def read_double(pm: pymem.Pymem, address: int) -> float | None:
try:
value = pm.read_double(address)
if not math.isfinite(value):
return None
return value
except Exception:
return None
def read_latin1_string(pm: pymem.Pymem, address: int, max_len: int = NAME_MAX_LEN) -> str:
if not is_plausible_ptr(address):
return ""
try:
data = pm.read_bytes(address, max_len)
except Exception:
return ""
raw = data.split(b"\0", 1)[0]
if not raw:
return ""
try:
return raw.decode("latin-1", errors="replace").strip()
except Exception:
return ""
def get_module_base(pm: pymem.Pymem, module_name: str) -> int:
module = pymem.process.module_from_name(pm.process_handle, module_name)
if module is None:
raise RuntimeError(f"Module not found: {module_name}")
return module.lpBaseOfDll
def resolve_pointer_chain(pm: pymem.Pymem, base: int, chain: list[int]) -> int:
current = base
for offset in chain:
if not is_plausible_ptr(current):
return 0
current = read_ptr(pm, current + offset)
if not is_plausible_ptr(current):
return 0
return current
def resolve_value_address(pm: pymem.Pymem, base: int, chain: list[int]) -> int:
if not chain or not is_plausible_ptr(base):
return 0
current = base
# Every offset except the last one is a pointer hop.
for offset in chain[:-1]:
current = read_ptr(pm, current + offset)
if not is_plausible_ptr(current):
return 0
address = current + chain[-1]
return address if is_plausible_ptr(address) else 0
def read_string_chain(pm: pymem.Pymem, base: int, chain: list[int]) -> str:
address = resolve_value_address(pm, base, chain)
if not is_plausible_ptr(address):
return ""
# This path usually resolves directly to the latin-1 text address.
direct = read_latin1_string(pm, address)
if direct:
return direct
# Fallback if the last address stores a pointer to the text.
pointed = read_ptr(pm, address)
return read_latin1_string(pm, pointed)
def read_float_chain(pm: pymem.Pymem, base: int, chain: list[int]) -> float | None:
address = resolve_value_address(pm, base, chain)
if not is_plausible_ptr(address):
return None
return read_float(pm, address)
def read_double_chain(pm: pymem.Pymem, base: int, chain: list[int]) -> float | None:
address = resolve_value_address(pm, base, chain)
if not is_plausible_ptr(address):
return None
return read_double(pm, address)
def read_u32_chain(pm: pymem.Pymem, base: int, chain: list[int]) -> int | None:
address = resolve_value_address(pm, base, chain)
if not is_plausible_ptr(address):
return None
return read_u32(pm, address)
def read_vec3_chain(pm: pymem.Pymem, base: int, chain: list[int]) -> tuple[float, float, float] | None:
address = resolve_value_address(pm, base, chain)
if not is_plausible_ptr(address):
return None
x = read_float(pm, address)
y = read_float(pm, address + 0x4)
z = read_float(pm, address + 0x8)
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 SKIP_ZERO_POSITION and abs(x) < 0.0001 and abs(y) < 0.0001 and abs(z) < 0.0001:
return None
return x, y, z
def read_hash_header(pm: pymem.Pymem, owner: int) -> tuple[int, int, int]:
base = resolve_pointer_chain(pm, owner, OFFSETS["hash_base"])
stride_address = resolve_value_address(pm, owner, OFFSETS["hash_stride"])
count_address = resolve_value_address(pm, owner, OFFSETS["hash_count"])
stride = read_u64(pm, stride_address)
count = read_u64(pm, count_address)
if not is_plausible_ptr(base):
raise RuntimeError(f"Bad hash base: 0x{base:X}")
if stride is None or stride <= 0 or stride > 0x400:
raise RuntimeError(f"Bad stride: {stride}")
if count is None or count <= 0 or count > MAX_BUCKETS:
raise RuntimeError(f"Bad count: {count}")
return base, int(stride), int(count)
def name_allowed(name: str) -> bool:
if not name:
return False
if not NAME_PREFIX_FILTERS:
return True
return any(name.startswith(prefix) for prefix in NAME_PREFIX_FILTERS)
def read_entity(pm: pymem.Pymem, bucket_index: int, depth: int, entity_object: int) -> Entity | None:
name = read_string_chain(pm, entity_object, OFFSETS["name"])
if not name_allowed(name):
return None
position = read_vec3_chain(pm, entity_object, OFFSETS["position"])
if position is None:
return None
health = read_double_chain(pm, entity_object, OFFSETS["health"])
level = read_u32_chain(pm, entity_object, OFFSETS["level"])
scale = read_float_chain(pm, entity_object, OFFSETS["scale"])
x, y, z = position
return Entity(
index=f"{bucket_index}:{depth}",
name=name,
health=health,
level=level,
scale=scale,
x=x,
y=y,
z=z,
)
def collect_entities(pm: pymem.Pymem, owner: int) -> tuple[list[Entity], dict[str, int]]:
base, stride, count = read_hash_header(pm, owner)
entities: list[Entity] = []
seen_nodes: set[int] = set()
seen_objects: set[int] = set()
stats = {
"base": base,
"stride": stride,
"count": count,
"nodes": 0,
"shown": 0,
"skipped": 0,
"duplicates": 0,
}
for bucket_index in range(count):
node = base + bucket_index * stride
depth = 0
while is_plausible_ptr(node) and depth < MAX_CHAIN_DEPTH:
if stats["nodes"] >= MAX_TOTAL_NODES:
break
if node in seen_nodes:
break
seen_nodes.add(node)
stats["nodes"] += 1
raw_next = resolve_pointer_chain(pm, node, OFFSETS["node_next"])
next_node = raw_next & NODE_MARK_MASK if raw_next else 0
entity_object = resolve_pointer_chain(pm, node, OFFSETS["node_object"])
if is_plausible_ptr(entity_object):
if entity_object in seen_objects:
stats["duplicates"] += 1
else:
seen_objects.add(entity_object)
entity = read_entity(pm, bucket_index, depth, entity_object)
if entity is None:
stats["skipped"] += 1
else:
entities.append(entity)
if raw_next == 1:
break
if not is_plausible_ptr(next_node):
break
if next_node == node:
break
node = next_node
depth += 1
entities.sort(
key=lambda item: (
item.name.lower(),
item.level if item.level is not None else 999999,
item.index,
)
)
stats["shown"] = len(entities)
return entities, stats
def format_health(value: float | None) -> str:
if value is None:
return "None"
if abs(value) >= 1_000_000:
return f"{value:.0f}"
if abs(value - round(value)) < 0.001:
return f"{value:.0f}"
return f"{value:.3f}"
def format_level(value: int | None) -> str:
if value is None:
return "None"
return str(value)
def format_scale(value: float | None) -> str:
if value is None:
return "None"
return f"{value:.3f}"
def print_entities(owner: int, stats: dict[str, int], entities: list[Entity]) -> None:
if CLEAR_SCREEN:
print("\033[2J\033[H", end="")
print(f"Owner : 0x{owner:X}")
print(f"Base : 0x{stats['base']:X}")
print(f"Stride : 0x{stats['stride']:X} ({stats['stride']})")
print(f"Count : {stats['count']}")
print(f"Nodes : {stats['nodes']}")
print(f"Shown : {stats['shown']}")
print(f"Skip : {stats['skipped']}")
print(f"Dup : {stats['duplicates']}")
print("-" * 150)
print(
f"{'#':<3} "
f"{'idx':<7} "
f"{'name':<64} "
f"{'health':>14} "
f"{'level':>7} "
f"{'scale':>8} "
f"position"
)
for row, entity in enumerate(entities, start=1):
position = f"({entity.x:.3f}, {entity.y:.3f}, {entity.z:.3f})"
print(
f"{row:<3} "
f"{entity.index:<7} "
f"{entity.name[:63]:<64} "
f"{format_health(entity.health):>14} "
f"{format_level(entity.level):>7} "
f"{format_scale(entity.scale):>8} "
f"{position}"
)
print()
def main() -> None:
pm = pymem.Pymem(PROCESS_NAME)
module_base = get_module_base(pm, PROCESS_NAME)
owner_ptr_address = module_base + OWNER_RVA
owner = read_ptr(pm, owner_ptr_address)
if not is_plausible_ptr(owner):
raise RuntimeError(f"Owner pointer is invalid: 0x{owner:X}")
print(f"Module base : 0x{module_base:X}")
print(f"Owner ptr address: 0x{owner_ptr_address:X}")
print(f"Owner : 0x{owner:X}")
print()
while True:
try:
entities, stats = collect_entities(pm, owner)
print_entities(owner, stats, entities)
time.sleep(REFRESH_DELAY)
except KeyboardInterrupt:
print("\nStopped")
break
except Exception as exc:
print(f"Collector error: {exc}")
time.sleep(REFRESH_DELAY)
if __name__ == "__main__":
main()