Files
Ananta-ps/tools/AnantaInjector/unified_loader.py
T

1026 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
Ananta 注入器 · 只做两件事:保龄球瓶 + 气球
★ 这不是「统一加载器」(AnantaUnifiedLoader)那个全通道诊断版 —— 那份还在
C:\\Game\\Ananta_mainleakflow_keitaro_gg\\AnantaUnifiedLoader,开着是为了排查问题时
临时启用 A/B/D/E 那些通道。**日常跑服只用这一份**,它只挂 bowling + balloon 两个模块。
★ 会把自己的状态写进同目录 injector_status.json,供根目录的「一键启动」界面读:
starting 正在启动
waiting_game 已启动,等游戏
waiting_world 已启动,等玩家进入游戏世界(进入世界闸门)
attached 已挂上客户端
injected 已注入并生效(bowling + balloon 的 hook 都装好了)
stopped 已退出
一个 frida 会话 + 多个 script,把调研文档里「注入端能做到」的通道一次性全开:
| 模块 | 通道 | 做什么 |
|-----------------|----------|-----------------------------------------------------|
| gadget_world | A | 室外/建筑内 gadget 网格 |
| world_mod | B | 场景物品:路灯/街边道具/可破坏物/念动力目标 |
| train | A'+D(+E) | 地铁车厢与座位;自带 loadindoor / sectorstate |
| bowling | C | 保龄球球瓶(补表类:伪造 SceneItemBlob 查表结果) |
| balloon | C | 打气球的气球本体(补表类,需服务端同机写 side-channel)|
| addbtn | — | ★ 通用 F 提示按钮(InteractBtnMgr.AddBtn) |
★ addbtn 是**不可退让**的那一项:协议层没有任何 S2C 能 AddBtn,
服务端再怎么回 Ask* 也变不出 F 提示 —— 只有注入端能补。
★ 一键启动(2026-09-29):双击同目录的 start_game_and_loader.cmd
—— 自提权 → 开 Ananta.exe → 等进程 → 自动挂本加载器。见 使用说明.md §2.5。
用法(源码模式,需先装 frida:pip install frida frida-tools):
python unified_loader.py 常驻(推荐;先开它再开游戏也行)
python unified_loader.py diag 加载全部模块并各跑一次状态,打印后退出
python unified_loader.py scan 只读:列出身边可补按钮的物件
python unified_loader.py --no train,balloon 临时关掉某几个模块
⛑ 硬约束
1. 同一时刻只能有一个 frida 会话注入 Ananta.exe。
开本加载器前必须关掉 GymLoader / TrainSeatLoader / BowlingLoader /
BalloonLoader / GadgetLoader / WorldMod / PropScanner —— 本程序每轮都会查,
查到就让位等待,不会硬抢。
2. 必须以**管理员**运行(游戏是提权启动的,非管理员 attach 会永久阻塞)。
3. balloon 模块依赖服务端在同机写 side-channel 文件
%LOCALAPPDATA%\\AnantaBalloon\\balloon_push.json;
主线 v0.3 目前没有 GameRouter.Balloon.cs,写不出这个文件 ⇒ 气球不会出。
需要的话可把 v2.1 包里的 GameRouter.Balloon.cs + BalloonWireContracts.cs 移植过来。
"""
import sys, os, json, time, math, atexit, glob
# ⛑⛑ 2026-09-30 修「别的电脑上注入器一启动就崩」:
# 随包分发的便携 Python(tools\python)是 embeddable 版,带 python313._pth ⇒
# 运行时是**隔离模式**:不会把「脚本所在目录」加进 sys.path,_pth 里的 "." 也是相对
# **python.exe 所在目录**(tools\python),不是本脚本所在目录。
# ⇒ `from loader_common import ...` 直接 ModuleNotFoundError ⇒ 进程秒退,
# 启动器那边表现为「每 60 秒 spawn 一次、每次立刻死」。
# 开发机上用的是普通 venv python(没有 _pth),所以一直复现不出来。
# 这里显式把本脚本目录塞进 sys.path,不管用什么 python、从哪个 cwd 启动都能导入。
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from loader_common import (
Ctx, Mod, _LIVE, _atexit_close, other_loader_running,
resolve_pid, print, ts, sleep_slice, STOP,
acquire_instance_lock,
)
HERE = os.path.dirname(os.path.abspath(__file__))
CFG_PATH = os.path.join(HERE, "config.json")
# ★ 状态文件:给「一键启动」界面读的,靠它显示「已启动 / 等待注入 / 已注入」,
# 而不是让界面去瞎猜(界面看不到注入器控制台里的字)。
STATUS_PATH = os.path.join(HERE, "injector_status.json")
def write_status(state, **kw):
try:
d = {"state": state, "ts": time.time(), "pid": os.getpid()}
d.update(kw)
tmp = STATUS_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(d, fh, ensure_ascii=False)
os.replace(tmp, STATUS_PATH) # 原子替换,界面不会读到半截文件
except Exception:
pass
def load_cfg():
try:
return json.loads(open(CFG_PATH, encoding="utf-8").read())
except Exception as e:
print("[!] 读 config.json 失败(%s)—— 用默认全开" % e)
return {"modules": [], "addbtnRadiusMeters": 6.0, "addbtnBatch": 3,
"addbtnEverySec": 2.0, "seatRadiusMeters": 8.0, "statusPollSec": 30.0}
def build_modules(cfg, disable=None):
"""★ 2026-10-01:新增 **分组开关**(`config.json` 的 `groups`)。
每个模块用 `"group": "xxx"` 声明组别;groups 里该组为 false 就整组不挂,
没有声明 group 的模块不受影响(保持老行为)。
⛔ traffic 组(vehicle_go.js)2026-10-01 晚已删除:它 hook 的
`AetherVehicleViewManager` 实机 8 探针全 0 命中、类根本没在用
⇒ 车流改走服务端 trafficCruiseDrive,别再往这条路上投时间。
⛑ 与既有两条过滤**叠加**(不是替换):模块自身 `enabled=false` 仍然优先,
命令行 `--no a,b` 也仍然有效。
⛑ 没写 group 的模块不受分组影响(保持老行为)。
"""
groups = cfg.get("groups") or {}
out = []
for m in cfg.get("modules") or []:
name = m.get("name")
if disable and name in disable:
print(" · %s(本次 --no 已关闭)" % name)
continue
if not m.get("enabled", True):
print(" · %s(config.json 里 enabled=false)" % name)
continue
g = m.get("group")
if g and groups and not groups.get(g, True):
print(" · %s(groups.%s=false,本次不挂)" % (name, g))
continue
out.append(Mod(name, os.path.join(HERE, m.get("file"))))
return out
def fmt_status(name, rpc, raw):
"""把状态 RPC 的返回压成一行人能看的日志;解析不了就原样截断。"""
s = str(raw)
try:
d = json.loads(s)
except Exception:
return s[:200]
if name == "gadget_loader" and rpc == "status":
w = d.get("world") or {}
if d.get("warmupLeft"):
return "预热中,%d 秒后开始(gadgets=%s)" % (d.get("warmupLeft"), w.get("GadgetsInWorld"))
return ("cell=%s raid=%s loaded=%d(confirmed=%d) pend=%d giveup=%d ok=%d fail=%d "
"gadgets=%s speed=%.2f%s burst=%d retry=%d refresh=%d 到达救援=%d 已走=%.1f格 全扫=%d 静扫=%d"
% (d.get("cell"), d.get("raid"), d.get("loadedCells", 0), d.get("confirmed", 0),
d.get("pendingCells", 0), d.get("giveUpCells", 0), d.get("callsOk", 0),
d.get("callsFail", 0), w.get("GadgetsInWorld"), d.get("speed", 0),
" FAST" if d.get("fast") else "", d.get("burst", 0),
d.get("retryTotal", 0), d.get("refreshTotal", 0),
d.get("arriveCount", 0), d.get("movedCells", 0),
d.get("sweepAllCount", 0), d.get("idleSweepCount", 0)))
return s[:200]
def load_cell_sectors(path):
"""cells.json: {"cells": [[x, z, sectorId, ...], ...]} → {"x,z": [sectorId...]}
照抄 AnantaGadgetLoader/src/gadget_world.py:load_cell_sectors。
一个格子可能属于多个 sector —— 室内 Gadget 通常不在 sector 0。
"""
try:
data = json.loads(open(path, encoding="utf-8").read())
except Exception as e:
return {}, "读不了 %s(%s)" % (path, e)
rows = data.get("cells") if isinstance(data, dict) else data
if not rows:
return {}, "文件里没有 cells"
m = {}
for c in rows:
if not isinstance(c, (list, tuple)) or len(c) < 3:
continue
try:
m["%d,%d" % (int(c[0]), int(c[1]))] = [int(v) for v in c[2:]]
except Exception:
continue
return m, "%d 格" % len(m)
def run_setup(ctx, cfg):
"""按 config.json 的 setup 数组,把各模块的**启动序列**跑一遍。
★ 为什么必须有这一步:
原版加载器是 python 驱动的 —— agent 加载进去之后还要
setr/probe → setsectors → sectormode → tune → start 才真正开始加载。
只 load 不 start = 什么都不会发生(本加载器第一版就是这个症状)。
"""
for m in cfg.get("modules") or []:
mod = ctx.mod(m.get("name"))
if mod is None:
continue
for step in m.get("setup") or []:
rpc = step.get("rpc")
if not rpc:
continue
arg = step.get("arg")
if step.get("cellsFile"):
mapping, note = load_cell_sectors(os.path.join(HERE, step["cellsFile"]))
if not mapping:
print("[%s] · %s.%s 跳过(%s)" % (ts(), m.get("name"), rpc, note))
continue
arg = json.dumps(mapping)
try:
r = mod.call(rpc, arg, timeout=step.get("timeout", 30))
s = str(r)
print("[%s] · %s.%s => %s" % (ts(), m.get("name"), rpc,
s if len(s) <= 220 else s[:220] + "…"))
except Exception as e:
print("[%s] [!] %s.%s 失败:%s" % (ts(), m.get("name"), rpc, str(e)[:160]))
def apply_addbtn_cfg(ctx, cfg):
"""addbtn 的开关是**脚本内状态**,每次 attach 后要显式下发一次。
(每个 rpc 调用都是一次新的 attach + 新的 script realm,所以不写进
addbtn.js 的默认值,而是由 Python 这边按 config.json 下发。)
"""
m = ctx.mod("addbtn")
if m is None:
return
try:
m.call("abUseObjects", "true" if cfg.get("addbtnUseObjects") else "false", timeout=8)
except Exception as e:
print("[%s] [!] abUseObjects 下发失败:%s" % (ts(), str(e)[:120]))
return
try:
m.call("abObjRadius", str(cfg.get("addbtnObjectRadius", 5.0)), timeout=8)
except Exception as e:
print("[%s] [!] abObjRadius 下发失败:%s" % (ts(), str(e)[:120]))
def once_status(ctx, mod, names):
for n in names or []:
try:
r = mod.call(n, timeout=8)
return n, str(r)[:600]
except Exception:
continue
return None, None
def _newest_server_log(cfg):
"""服务端 console 日志里最新那份(每局一份 console-<时间戳>.log)。"""
g = cfg.get("attachGate") or {}
here = os.path.dirname(os.path.abspath(__file__))
d = os.path.normpath(os.path.join(here, g.get("serverLogDir", "../../logs")))
try:
files = glob.glob(os.path.join(d, g.get("serverLogGlob", "console-*.log")))
except Exception:
return None
return max(files, key=lambda p: os.path.getmtime(p)) if files else None
def gate_ok(cfg, st):
"""★ 进入世界闸门:返回 True 才允许 attach。
为什么需要(2026-09-29):bowling_agent.js / balloon_fix.js 里装 hook 的那段
`Il2Cpp.perform(...)` **没有重试** —— 类拿不到就 `sendlog('ERR: ... not found'); return;`
永久放弃,直到重新 attach 才会再试。所以「游戏刚起就挂」是有真实风险的。
这里先等服务端日志出现「只有 world-ready 之后才会打」的行,再 attach,
等价于复现手动操作时「进世界 → 再开加载器」这条已验证的顺序。
⛑ 只扫**本次开始跟踪之后新增的字节** —— 否则会把上一局的历史内容当成「已进世界」。
"""
g = cfg.get("attachGate") or {}
mode = str(g.get("mode") or "serverLog").lower()
poll = float(g.get("pollSec", 2.0))
timeout = float(g.get("timeoutSec", 900))
# ★ 2026-09-30:命中 marker 后还要再等「爆发期」结束(delayAfterMarkerSec)。
# 命中瞬间正是客户端「进世界黑屏加载」的最热阶段:此刻 attach
# (V8 编译 ~440KB 两个脚本 + Il2Cpp 域遍历 + 在物件加载热路径上连挂 5 个 hook)
# 会把客户端钉死在黑屏上 —— 实测 14:51:27 命中、14:51:31 客户端日志停在
# 角色动作加载段,直到注入器断开(armedGuard 重挂)客户端才放行进世界。
# 等爆发期过了再挂:大世界已加载完、热路径流量低,装上无感(13:14 局实测如此)。
# 代价只是进世界后头 delay 秒里没有注入器 —— 反正走到保龄球馆/气球区也要时间。
delay = float(g.get("delayAfterMarkerSec", 0) or 0)
now = time.time()
pid = resolve_pid()
if pid is None:
# 换局了 / 游戏没开 ⇒ 闸门状态整个作废
if st.get("passed"):
st["passed"] = False
st["file"] = None
st["hit_t"] = None
if now - st.get("last", 0.0) >= 15.0:
st["last"] = now
print("[%s] [闸门] 等游戏启动(没找到 Ananta.exe)……" % ts())
return False
if st.get("pid") != pid: # 客户端重启 ⇒ 重新等一次
st["pid"] = pid
st["passed"] = False
st["file"] = None
st["hit_t"] = None
if st.get("passed"):
return True # 这一局已经进过世界了,重挂不用再等
# ⛑ mode 上面已经 lower() 过了,这里必须跟小写比 —— 写成 "serverLog" 会恒不相等
# ⇒ 闸门形同虚设(2026-09-29 单元测抓到)。
if mode != "serverlog":
return True
path = _newest_server_log(cfg)
if path is None:
if now - st.get("last", 0.0) >= 15.0:
st["last"] = now
print("[%s] [闸门] 还没看到服务端 console 日志,继续等(服务端起了吗?)" % ts())
return False
if st.get("file") != path:
st["file"] = path
try:
st["off"] = os.path.getsize(path)
except Exception:
st["off"] = 0
st["t0"] = now
print("[%s] [闸门] 跟踪 %s(从第 %d 字节之后开始看)"
% (ts(), os.path.basename(path), st["off"]))
try:
sz = os.path.getsize(path)
except Exception:
return False
off = int(st.get("off") or 0)
if sz > off:
try:
with open(path, "rb") as fh:
fh.seek(off)
chunk = fh.read(min(sz - off, 4 * 1024 * 1024))
except Exception:
chunk = b""
st["off"] = off + len(chunk)
txt = chunk.decode("utf-8", errors="ignore")
for mk in (g.get("markers") or []):
if mk and mk in txt:
# ★ 2026-09-30:命中后**不立刻放行** —— 先记 hit_t,等爆发期过去
# (见函数头上 delayAfterMarkerSec 的说明)。
if st.get("hit_t") is None:
st["hit_t"] = now
print("[%s] [闸门] 命中 %r —— 客户端还在进世界,等 %.0fs 爆发期过去再注入"
% (ts(), mk, delay))
break
hit_t = st.get("hit_t")
if hit_t is not None and now - hit_t >= delay:
st["hit_t"] = None
st["passed"] = True
print("[%s] [闸门] 爆发期已过(+%.0fs)—— 玩家已进世界,开始注入" % (ts(), now - hit_t))
return True
if st.get("t0") is None:
st["t0"] = now
waited = now - st["t0"]
if now - st.get("last", 0.0) >= 15.0:
st["last"] = now
print("[%s] [闸门] 等玩家进入世界…… 已等 %ds(%ds 后按 fallback=%s 处理)"
% (ts(), int(waited), int(timeout), g.get("fallback", "attachAnyway")))
if waited >= timeout:
print("[%s] [闸门] 超时 —— fallback=%s" % (ts(), g.get("fallback", "attachAnyway")))
st["passed"] = True # 别每秒重试,直接放行
return str(g.get("fallback", "attachAnyway")).lower() != "giveup"
return False
def check_armed(ctx, cfg, st):
"""★ armed 守卫:attach 之后确认 hook 真装上了。
闸门只保证「时机对」,不保证脚本里那次 `bfClass(...)` 成功。
两个模块的 bfStatus 都带 armed 字段:
armed=true → 正常(tpl=false 是正常的,那只是还没拿到模板,**不要**因此重挂)
armed=false → 脚本里 return 掉了 ⇒ 到点主动重挂
"""
ag = cfg.get("armedGuard") or {}
if not ag.get("enabled", True):
return "ok"
now = time.time()
if st.get("t0") is None:
st["t0"] = now
st["next"] = 0.0
if now < float(st.get("next") or 0.0):
return st.get("lastRes", "wait")
st["next"] = now + float(ag.get("recheckEverySec", 10))
bad = []
for m in (cfg.get("modules") or []):
mod = ctx.mod(m.get("name"))
if mod is None:
continue
try:
d = json.loads(str(mod.call("bfStatus", timeout=8)))
except Exception:
bad.append((m.get("name"), "bfStatus 拿不到"))
continue
if not d.get("armed"):
bad.append((m.get("name"), "armed=false"))
if not bad:
st["lastRes"] = "ok"
return "ok"
waited = now - st["t0"]
print("[%s] [守卫] hook 还没装上(%s)—— 已等 %ds"
% (ts(), ", ".join("%s:%s" % b for b in bad), int(waited)))
st["lastRes"] = "reattach" if waited >= float(ag.get("maxWaitSec", 60)) else "wait"
return st["lastRes"]
def run_diag(disable=None):
cfg = load_cfg()
mods = build_modules(cfg, disable)
o = other_loader_running()
if o:
print("[!] 另一个加载器在跑(%s)—— 请先关掉" % o)
return 1
if resolve_pid() is None:
print("[!] 没找到 Ananta.exe(游戏没开,或你不是管理员看不到它)")
return 1
ctx = Ctx(mods)
_LIVE.append(ctx)
if not ctx.attach():
print("[!] 连接/加载失败")
return 1
print("\n" + "=" * 60)
for m in cfg.get("modules") or []:
mod = ctx.mod(m.get("name"))
if mod is None:
continue
print("\n--- %s:%s" % (m.get("name"), m.get("desc", "")))
n, r = once_status(ctx, mod, m.get("statusRpc"))
if r is None:
print(" (无状态 RPC / 全部不可用;模块自驱动,看游戏里效果)")
else:
print(" %s => %s" % (n, r))
print("\n" + "=" * 60)
ctx.close()
return 0
def run_scan(disable=None):
cfg = load_cfg()
mods = build_modules(cfg, disable)
ctx = Ctx(mods)
_LIVE.append(ctx)
if not ctx.attach():
print("[!] 连接失败")
return 1
m = ctx.mod("addbtn")
if m is None:
print("[!] addbtn 模块没加载")
return 1
try:
r = json.loads(m.call("abScan", "8", timeout=20))
print("身边候选 %d 个(已有按钮 %d 个):" % (r.get("count", 0), r.get("existing", 0)))
for row in r.get("rows") or []:
print(" %-46s hasBtn=%s d=%sm" % (str(row.get("n"))[:46], row.get("hasBtn"), row.get("d")))
except Exception as e:
print("abScan 失败: %s" % str(e)[:300])
ctx.close()
return 0
def run_rpc(rest, disable=None):
"""通用 RPC 直通:不写包装,直接调任意模块的任意导出。
例:
... unified_loader.py rpc gadget_loader sample
... unified_loader.py rpc gadget_loader spawn 1443076546,0,0
... unified_loader.py rpc gadget_loader indoorsectors
... unified_loader.py rpc addbtn abDiag
"""
if len(rest) < 3:
print("用法:unified_loader.py rpc <模块名> <RPC名> [参数1] [参数2] [参数3]")
print("可用模块见 config.json(gadget_loader / world_mod / train / bowling / balloon / addbtn)")
return 1
# 三个参数够用(如 findobj <关键字> <maxNodes> <maxDepth>);不足就补 None。
args = (rest[3:6] + [None, None, None])[:3]
mod_name, rpc_name = rest[1], rest[2]
arg, arg2, arg3 = args[0], args[1], args[2]
cfg = load_cfg()
mods = build_modules(cfg, disable)
ctx = Ctx(mods)
_LIVE.append(ctx)
if not ctx.attach():
print("[!] 连接失败")
return 1
m = ctx.mod(mod_name)
if m is None:
print("[!] 没有模块 %s(没启用或加载失败)" % mod_name)
ctx.close()
return 1
apply_addbtn_cfg(ctx, cfg)
try:
if arg3 is not None:
r = m.call3(rpc_name, arg, arg2, arg3, timeout=40)
elif arg2 is not None:
r = m.call(rpc_name, arg, arg2, timeout=30)
else:
r = m.call(rpc_name, arg, timeout=30)
s = str(r)
print("%s.%s =>" % (mod_name, rpc_name))
# 排障时要看全量(如 abComp 拉几百个物件名),所以上限给到 400 KB
print(s if len(s) <= 400000 else s[:400000] + "\n...(截断,共 %d 字符)" % len(s))
except Exception as e:
print("%s.%s 失败:%s" % (mod_name, rpc_name, str(e)[:300]))
ctx.close()
return 0
def run_watch(disable=None):
cfg = load_cfg()
print("=" * 64)
print(" Ananta 注入器 · 保龄球瓶 + 气球")
print(" (全通道诊断版在 AnantaUnifiedLoader,日常不用它)")
print("=" * 64)
write_status("starting")
mods = build_modules(cfg, disable)
if not mods:
print("[!] 没有启用任何模块(config.json 全关或 --no 关完了)")
write_status("stopped", note="没有启用任何模块")
return 1
# ⛑ 常驻模式互斥:两个 frida 会话同时挂 Ananta.exe = 游戏必崩(踩过 3 次)。
# 改成「一键启动」之后重复双击的概率大增,这里必须程序自己挡。
owner = acquire_instance_lock()
if owner:
print("[X] 已经有另一个常驻加载器在跑(pid=%s)—— 同一时刻只能有一个 frida 会话。" % owner)
print(" 想重开请先关掉那一个(Ctrl+C 或直接关窗口);")
print(" 若确认它已经死了,删掉 .loader.lock 再启动。")
return 1
print(" 已启用:%s" % ", ".join(m.name for m in mods))
# 2026-09-28:AddBtn 已降级为兜底(客户端自己会出 F),只在 config 里还配了 drive 时才这么说
_ab = next((m for m in (cfg.get("modules") or []) if m.get("name") == "addbtn"), None)
if _ab and _ab.get("drive"):
print(" ★ addbtn 会在你走近物件时自动补 F 提示按钮")
elif _ab:
print(" · addbtn 已降级为**兜底**(客户端自己会出 F)—— 不自动补")
print(" 要手动补:rpc addbtn abTick 3 2 收起:rpc addbtn abHide")
print(" 停止:Ctrl+C 或关窗口(都会先安全卸载注入)")
print("=" * 64)
ctx = Ctx(mods)
_LIVE.append(ctx)
ab_r = str(cfg.get("addbtnRadiusMeters", 6.0))
ab_b = str(cfg.get("addbtnBatch", 3))
seat_r = str(cfg.get("seatRadiusMeters", 8.0))
ab_every = float(cfg.get("addbtnEverySec", 2.0))
poll_every = float(cfg.get("statusPollSec", 30.0))
last_ab, last_poll, last_seat = 0.0, 0.0, 0.0
last_seatscan = 0.0
# 进入世界闸门 / armed 守卫 的状态(见 gate_ok / check_armed 的说明)
gate_state = {"pid": None, "file": None, "off": 0, "t0": None,
"last": 0.0, "passed": False}
armed_st = {"t0": None, "next": 0.0, "lastRes": "wait",
"attempts": 0, "cool": 0.0}
last_generic = {} # (模块名, rpc) -> 上次调用时间
drive_last = {} # 模块名 -> 最后一次状态(gadgets 数 / cell),自动收工用
idle_since = {} # 模块名 -> 上次看到的对象数
idle_t0 = {} # 模块名 -> 对象数开始没动静的时刻
stopped = {} # 模块名 -> 收工时所处格子(None = 正在跑)
# 2026-09-28 新增:移动后补货的救援状态(取代「不涨就收工」)
rescue_state = {} # 模块名 -> {prev_cell, moved, stable_since, peak, last_rescue}
rescue_ok = {} # 模块名 -> 该模块有没有 sweep RPC
rescue_cd = float(cfg.get("rescueCooldownSec", 25) or 25)
# ⛑ 这两个开关写在**模块**段里(和 autoStopAfterIdleSec 一起),不是顶层。
# 22:19 实机就栽在这:顶层读不到 ⇒ rescue_drop=0 ⇒ `cnt <= peak*1.0` 恒真
# ⇒ 每 25 秒无脑重扫一次。所以每个模块单独取,顶层值只作兜底。
rescue_cells = float(cfg.get("rescueMinCells", 3.0) or 3.0)
rescue_idle = float(cfg.get("rescueIdleSec", 3.0) or 3.0)
bad = 0
warned_busy = False
while True:
try:
if STOP["ask"]:
break
o = other_loader_running()
if o:
if ctx.session is not None:
print("[%s] [X] %s 也在注入 —— 立刻让出(同时注入会把游戏带崩)" % (ts(), o))
# ★ 不重建 Ctx:close() 已把 session/script 清空,下一轮直接重 attach。
# 重建再 append 会让 _LIVE 无限增长,退出时要逐个 close 一遍。
ctx.close()
if not warned_busy:
warned_busy = True
print("[%s] [i] %s 在跑,本程序等它退出(每 5 秒复查)" % (ts(), o))
sleep_slice(5.0)
continue
warned_busy = False
if ctx.session is None:
# ★ 进入世界闸门:没等到「玩家已进世界」就先不 attach
if not gate_ok(cfg, gate_state):
write_status("waiting_game" if resolve_pid() is None else "waiting_world")
sleep_slice(float((cfg.get("attachGate") or {}).get("pollSec", 2.0)))
continue
if not ctx.attach():
bad += 1
wait = min(3.0 * bad, 20.0)
if bad == 1 or bad % 10 == 0:
print("[%s] 没连上 Ananta.exe —— 等游戏启动(第 %d 次)" % (ts(), bad))
sleep_slice(wait)
continue
bad = 0
print("[%s] 已连接 pid=%d" % (ts(), ctx.pid))
run_setup(ctx, cfg)
apply_addbtn_cfg(ctx, cfg)
# 每次新建会话都重新计时:给 hook 安装 maxWaitSec 的机会
armed_st.update({"t0": time.time(), "next": 0.0,
"lastRes": "wait", "attempts": 0})
write_status("attached", game_pid=ctx.pid)
now = time.time()
# ★ 客户端崩了/重启之后,旧 script 会变成 "script has been destroyed"。
# 不主动 close() 的话 session 还在、但永远调不动,加载器就假死。
if ctx.dead:
print("[%s] [!] 注入已失效(客户端多半崩了/重启了)—— 准备重新注入" % ts())
ctx.close()
gate_state["passed"] = False # 新的一局 ⇒ 闸门重新等
write_status("waiting_game", note="客户端注入失效,准备重新注入")
bad = 0
sleep_slice(3.0)
continue
# ---- armed 守卫:hook 没装上 ⇒ 主动重挂,别干等 ----
_ar = check_armed(ctx, cfg, armed_st)
if _ar == "ok":
armed_st["attempts"] = 0
write_status("injected", game_pid=ctx.pid,
modules=[m.name for m in mods])
elif _ar == "reattach":
ag = cfg.get("armedGuard") or {}
if time.time() < float(armed_st.get("cool") or 0.0):
sleep_slice(2.0)
continue
armed_st["attempts"] = int(armed_st.get("attempts") or 0) + 1
if armed_st["attempts"] > int(ag.get("maxAttempts", 5)):
armed_st["cool"] = time.time() + float(ag.get("backoffSec", 120))
armed_st["attempts"] = 0
print("[%s] [守卫] 连试 %s 次都没装上 —— 退避 %ss 再试"
% (ts(), ag.get("maxAttempts", 5), ag.get("backoffSec", 120)))
else:
print("[%s] [守卫] 主动重挂(第 %d 次)" % (ts(), armed_st["attempts"]))
ctx.close()
armed_st.update({"t0": None, "next": 0.0, "lastRes": "wait"})
write_status("attached", note="hook 没装上,正在重挂")
sleep_slice(3.0)
continue
else:
sleep_slice(2.0)
continue
# ---- train:地铁车厢与座位(通道 D)----
# ⛑⛑ 2026-09-29 00:1x:这一段原来和 addbtn 一样是**无条件**跑的(硬编码每 1 秒
# 一次 seatfix),压根不读 config 的 drive ⇒ 单通道逐个测时关不掉、参数也改不动
# (同一类坑:addbtn 因此重复 AddBtn 把客户端刷崩,见上面那段注释)。
# 现在改成**以 config 的 drive 为唯一开关**:drive 为空 ⇒ 一次都不跑,
# 只留手动 `rpc train seatfix <半径>`。
tr_drive, tr_status = None, []
for _m in (cfg.get("modules") or []):
if _m.get("name") != "train":
continue
for _d in (_m.get("drive") or []):
if _d.get("rpc") == "seatfix":
tr_drive = _d
for _s in (_m.get("statusRpc") or []):
if _s in ("seatscan", "seattree", "trainnear", "trainhier", "where"):
tr_status.append(_s)
tr = ctx.mod("train")
if tr is not None and tr_drive and now - last_seat >= float(tr_drive.get("everySec", 1.0)):
last_seat = now
_sr = str(tr_drive.get("arg", seat_r))
try:
raw = tr.call("seatfix", _sr, timeout=15)
try:
r = json.loads(raw)
except Exception:
r = {}
# ★ 关键计数:total=缓存座位总数 / alreadyInited=已初始化 / fixed=本轮新开 /
# far=被 R_ALL(=max(半径,30)m) 门槛跳过的 —— 「整列车有一节坐不了」多半就是
# 那节的座位落进了 far。所以这几个数必须打出来,不能只打新开的那几行。
_bad = [x for x in (r.get("rows") or []) if x.get("err")]
print("[%s] [座椅] total=%s 已开=%s 本轮新开=%s 超距跳过=%s 扫描=%sms%s" % (
ts(), r.get("total"), r.get("alreadyInited"), r.get("fixed"),
r.get("far"), r.get("scanMs"),
(" Init报错=%d" % len(_bad)) if _bad else ""))
for _b in _bad[:3]:
print("[%s] ! %s err=%s" % (ts(), _b.get("n"), str(_b.get("err"))[:120]))
for row in r.get("rows") or []:
if row.get("after") == "true":
print("[%s] [座椅] 已开启 %s(%.1fm)" % (ts(), row.get("n"), row.get("d") or -1))
except Exception as e:
if "seatfix" not in str(e):
print("[%s] [座椅] %s" % (ts(), str(e)[:140]))
# 状态:seatscan / seattree / trainnear(通道 D 的判据看这里)
if tr is not None and tr_status and now - last_seatscan >= float(cfg.get("trainStatusEverySec", 5.0)):
last_seatscan = now
for _sr in tr_status:
try:
_raw = str(tr.call(_sr, timeout=15))
except Exception as e:
print("[%s] [train] %s %s" % (ts(), _sr, str(e)[:140]))
continue
# 原始扫描落盘:数「每节车厢到底几个座位」要靠它(seatscan 可能把
# 名为 Seats 的分组节点本身也算进去,必须按名字核)
try:
with open("_seats_last_%s.json" % _sr, "w", encoding="utf-8") as _fh:
_fh.write(_raw)
except Exception:
pass
# seattree / seatscan 都带 rows[].path ⇒ 按车厢汇总,一眼看出哪节没起来
_sum = None
try:
_j = json.loads(_raw)
_rows = _j.get("rows") or []
if _rows and isinstance(_rows[0], dict) and (_rows[0].get("path") or _rows[0].get("p")):
_cars = {}
for _rw in _rows:
_p = str(_rw.get("path") or _rw.get("p") or "")
_parts = _p.split("/")
_car = "/".join(_parts[:2]) if len(_parts) >= 2 else _parts[0]
_s = _cars.setdefault(_car, [0, 0])
_s[0] += 1
if str(_rw.get("ini")) == "true":
_s[1] += 1
_sum = "count=%d " % _j.get("count", len(_rows)) + " ".join(
"%s:%d/%d" % (k, v[1], v[0]) for k, v in sorted(_cars.items()))
except Exception:
_sum = None
if _sum:
print("[%s] [train·%s] %s" % (ts(), _sr, _sum))
else:
print("[%s] [train·%s] %s" % (ts(), _sr, _raw[:400]))
# ---- addbtn:补 F 按钮 ----
# ⛑⛑ 2026-09-28 23:0x:这一段原来是**无条件**跑的,压根不读 config 的 drive。
# 所以上一轮把 drive 清空之后它照样每 4 秒 abTick 一轮 ⇒
# 客户端 23:02:22 刷 `添加了重复的按钮 ... pid 14013633775178039662(衣柜)`
# —— 客户端自己已经出了那颗按钮,我们又 AddBtn 一个同 pid 的,
# btnInfos[pid] 的 List 无限增长,这是已记录的必崩项。
# 现在改成和别的模块一样:**以 config 的 drive 为唯一开关**。
# drive 为空 ⇒ 一个都不补,只留手动 `rpc addbtn abTick <半径> <批量>`。
ab_drive = None
for _m in (cfg.get("modules") or []):
if _m.get("name") != "addbtn":
continue
for _d in (_m.get("drive") or []):
if _d.get("rpc") == "abTick":
ab_drive = _d
break
if ab_drive:
ab_every = float(ab_drive.get("everySec", ab_every))
ab_r = str(ab_drive.get("arg", ab_r))
ab_b = str(ab_drive.get("arg2", ab_b))
ab = ctx.mod("addbtn")
if ab is not None and ab_drive and now - last_ab >= ab_every:
last_ab = now
try:
raw = ab.call("abTick", ab_r, ab_b, timeout=20)
try:
r = json.loads(raw)
except Exception:
r = {}
if r.get("err"):
# 模板按钮没拿到是最常见的初始状态,别每 2 秒刷屏
if now - last_poll >= poll_every:
last_poll = now
print("[%s] [F按钮] %s" % (ts(), str(r.get("err"))[:160]))
continue
for row in r.get("made") or []:
# ★ 光看「已补」没用 —— 按钮造出来不等于会显示。
# 把客户端自己对这颗按钮的判定(CanShow / InternalCanShow / 原因)打出来。
ck = row.get("check") or {}
print("[%s] [F按钮] 已补 %s(%.1fm)mode=%s | canShow=%s internal=%s isShow=%s dis=%s text=%r reason=%s"
% (ts(), row.get("n") or row.get("uid"), row.get("d") or -1, row.get("mode"),
ck.get("canShow"), ck.get("internalCanShow"), ck.get("isShow"),
ck.get("dis"), ck.get("text"), ck.get("reason")))
for row in r.get("fail") or []:
print("[%s] [F按钮] 失败 %s:%s" % (ts(), row.get("n"), str(row.get("err"))[:100]))
except Exception as e:
if now - last_poll >= poll_every:
last_poll = now
print("[%s] [F按钮] %s" % (ts(), str(e)[:160]))
# ---- 其余模块:按 config 的 drive 节奏驱动 ----
for m in cfg.get("modules") or []:
nm = m.get("name")
if nm in ("train", "addbtn"):
continue # 这两个上面有专门分支
mod = ctx.mod(nm)
if mod is None:
continue
# ================= 移动后补货:网格救援 =================
# ⛔ 原来「物件数不涨就 stop()」的做法已作废(2026-09-28):它与「移动之后
# 周围的网格也要加载出物件」根本冲突 —— 收工后玩家走到新区域,身边那批
# 格子再也没人重试。现在流一直开着(靠 tune 节流),出问题用 sweep 主动重扫。
if nm not in rescue_ok:
try:
rescue_ok[nm] = (mod.script is not None
and hasattr(mod.script.exports_sync, "sweep"))
except Exception:
rescue_ok[nm] = False
st0 = drive_last.get(nm) or {}
cnt0 = st0.get("gadgets")
cell0 = st0.get("cell")
rs = rescue_state.get(nm)
if rs is None:
rs = rescue_state[nm] = {"prev_cell": cell0, "moved": 0.0,
"stable_since": None, "peak": cnt0 or 0,
"last_rescue": 0.0}
if isinstance(cell0, (list, tuple)) and len(cell0) == 2:
pc0 = rs.get("prev_cell")
if isinstance(pc0, (list, tuple)) and len(pc0) == 2:
d0 = ((cell0[0] - pc0[0]) ** 2 + (cell0[1] - pc0[1]) ** 2) ** 0.5
if d0 > 0:
rs["moved"] += d0
rs["stable_since"] = None
if rs.get("prev_cell") != cell0:
rs["prev_cell"] = cell0
if rs.get("stable_since") is None:
rs["stable_since"] = now
if isinstance(cnt0, int):
rs["peak"] = max(rs.get("peak") or 0, cnt0)
if rescue_ok.get(nm) and cell0 and now - rs.get("last_rescue", 0.0) >= rescue_cd:
stable_enough = (rs.get("stable_since") is not None
and now - rs["stable_since"] >= rescue_idle)
# ⛑ 模块段优先(这两个开关和 autoStopAfterIdleSec 写在一起),顶层作兜底
drop_pct = float(m.get("rescueOnDropPct",
cfg.get("rescueOnDropPct", 0)) or 0)
why = None
if rs.get("moved", 0.0) >= rescue_cells and stable_enough:
why = "移动 %.1f 格后停下(JS 到达救援没响,Python 兜底)" % rs["moved"]
elif (stable_enough and drop_pct > 0 and isinstance(cnt0, int)
and rs.get("peak", 0) >= 10
and cnt0 < rs["peak"] * (1.0 - drop_pct / 100.0)):
why = ("物件数从峰值 %d 掉到 %d(-%.0f%%)"
% (rs["peak"], cnt0,
(1.0 - cnt0 / float(rs["peak"])) * 100))
if why:
try:
out = mod.call("sweep", str(int(m.get("rescueSweepRadius", 3))),
timeout=20)
rs["last_rescue"] = now
rs["moved"] = 0.0
rs["peak"] = cnt0 if isinstance(cnt0, int) else 0
print("[%s] [网格救援] %s ⇒ %s" % (ts(), why, str(out)[:200]))
except Exception as e:
print("[%s] [网格救援] sweep 失败:%s" % (ts(), str(e)[:140]))
# ★ 自动收工:物件数不再增长 ⇒ 调 stop(),别让「空地重试」无限刷
# ⛔ 默认 0 = 关闭(与「移动后继续补货」冲突)。真要防崩请调 tune 的节流参数。
idle_sec = float(m.get("autoStopAfterIdleSec") or 0)
if idle_sec > 0:
st = drive_last.get(nm)
cnt = (st or {}).get("gadgets")
cell = (st or {}).get("cell")
if stopped.get(nm):
# 玩家换格子了 ⇒ 重新开一轮
if m.get("restartOnCellChange") and cell and cell != stopped.get(nm):
print("[%s] [%s] 玩家移动到 %s —— 重新开始加载" % (ts(), nm, cell))
stopped[nm] = None
idle_since.pop(nm, None)
try:
mod.call("start", str(cfg.get("worldRaidId", 23300888)), timeout=15)
except Exception as e:
print("[%s] [%s] start 失败:%s" % (ts(), nm, str(e)[:120]))
elif cnt is not None:
if idle_since.get(nm) != cnt:
idle_since[nm] = cnt
idle_t0[nm] = now
elif now - idle_t0.get(nm, now) >= idle_sec:
try:
mod.call("stop", timeout=15)
stopped[nm] = cell
print("[%s] [%s] 物件数 %s 已 %ss 没增长 —— 收工(走动后自动重开)"
% (ts(), nm, cnt, int(idle_sec)))
except Exception as e:
print("[%s] [%s] stop 失败:%s" % (ts(), nm, str(e)[:120]))
if stopped.get(nm):
continue # 已收工,本轮不再驱动
for d in m.get("drive") or []:
rpc = d.get("rpc")
if not rpc:
continue
every = float(d.get("everySec", 5.0))
key = (nm, rpc)
if now - last_generic.get(key, 0.0) < every:
continue
last_generic[key] = now
try:
raw = mod.call(rpc, d.get("arg"), timeout=d.get("timeout", 20))
line = fmt_status(nm, rpc, raw)
if line:
print("[%s] [%s] %s" % (ts(), nm, line))
# 记下最新状态,供「自动收工」判断物件数有没有增长
try:
sd = json.loads(str(raw))
# ⛑ 别让 gorender 这类「不报状态」的 RPC 把 cell/gadgets 冲成 None
# (救援逻辑靠这两个值判断玩家有没有移动)
if not (isinstance(sd, dict) and ("world" in sd or "cell" in sd)):
continue
w = sd.get("world") or {}
# ⛑ cell 存原始 list 而不是 str —— 救援逻辑要算两格之间的距离
drive_last[nm] = {"gadgets": w.get("GadgetsInWorld"),
"cell": sd.get("cell"),
"confirmed": sd.get("confirmed"),
"giveUp": sd.get("giveUpCells"),
"pending": sd.get("pendingCells"),
"arrive": sd.get("arriveCount")}
except Exception:
pass
except Exception as e:
print("[%s] [%s] %s 失败:%s" % (ts(), nm, rpc, str(e)[:140]))
# ---- 其余自驱动模块:定期轮询一次状态 ----
if now - last_poll >= poll_every:
last_poll = now
# addbtn 只在「补成功/失败」时才说话,平时一声不响,
# 排障时根本不知道是「没候选」还是「造不出来」。所以每轮报一次家底。
abm = ctx.mod("addbtn")
if abm is not None:
try:
print("[%s] [F按钮·状态] %s" % (ts(), str(abm.call("abStatus", timeout=10))[:400]))
except Exception as e:
print("[%s] [F按钮·状态] %s" % (ts(), str(e)[:140]))
try:
sc = json.loads(str(abm.call("abScan", ab_r, timeout=15)))
print("[%s] [F按钮·候选] %d 个(已有按钮 %d 个)%s" % (
ts(), sc.get("count", 0), sc.get("existing", 0),
", ".join("%s@%sm" % (str(r.get("n"))[:24], r.get("d"))
for r in (sc.get("rows") or [])[:6])))
except Exception as e:
print("[%s] [F按钮·候选] %s" % (ts(), str(e)[:140]))
try:
dg = json.loads(str(abm.call("abDiag", timeout=20)))
print("[%s] [F按钮·体检] pos=%s holdTotal=%s holdNear10m=%s useful=%s ctor=%s" % (
ts(), dg.get("playerPos"), dg.get("holdTotal"), dg.get("holdNear10m"),
dg.get("usefulListCount"), dg.get("ctorParamCounts")))
except Exception as e:
print("[%s] [F按钮·体检] %s" % (ts(), str(e)[:140]))
# 现有按钮逐个问客户端「你为什么不让这个显示」
try:
cs = json.loads(str(abm.call("abCanShow", timeout=20)))
g = cs.get("global") or {}
print("[%s] [F按钮·可见性] 全局=%s/%s 按钮数=%s" % (
ts(), g.get("globalInteractAble"), g.get("hideName"), cs.get("count")))
for row in (cs.get("rows") or [])[:5]:
print("[%s] pid=%s %s canShow=%s internal=%s dis=%s reason=%s"
% (ts(), row.get("pid"), row.get("cls"),
row.get("canShow"), row.get("internalCanShow"),
row.get("dis"), row.get("reason")))
except Exception as e:
print("[%s] [F按钮·可见性] %s" % (ts(), str(e)[:140]))
for m in cfg.get("modules") or []:
if m.get("name") in ("train", "addbtn"):
continue
mod = ctx.mod(m.get("name"))
if mod is None:
continue
n, r = once_status(ctx, mod, m.get("statusRpc"))
if r:
print("[%s] [%s] %s" % (ts(), m.get("name"), r[:300]))
sleep_slice(0.5)
except KeyboardInterrupt:
break
except Exception as e:
try:
ctx.close()
except Exception:
pass
bad += 1
wait = min(3.0 * bad, 20.0)
print("[%s] 连接中断(%s)—— 第 %d 次,%.0f 秒后重连" % (ts(), str(e)[:130], bad, wait))
sleep_slice(wait)
print("[%s] 正在退出并卸载注入……" % ts())
write_status("stopped")
try:
ctx.close()
except Exception:
pass
STOP["done"] = True
return 0
def main():
atexit.register(_atexit_close)
argv = sys.argv[1:]
disable = None
rest = []
i = 0
while i < len(argv):
if argv[i] in ("--no", "--disable") and i + 1 < len(argv):
disable = set(x.strip() for x in argv[i + 1].split(",") if x.strip())
i += 2
continue
rest.append(argv[i])
i += 1
a0 = (rest[0].lower() if rest else "")
if a0 in ("diag", "debug", "check", "test"):
return run_diag(disable)
if a0 in ("scan", "abscan"):
return run_scan(disable)
if a0 in ("rpc", "call"):
return run_rpc(rest, disable) # rest 已含 "rpc" 本身
if a0 in ("-h", "--help", "help", "?"):
print(__doc__)
return 0
return run_watch(disable)
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
print("[X] %s" % e)
try:
import traceback
traceback.print_exc()
except Exception:
pass
if getattr(sys.stdin, None) and sys.stdin.isatty():
try:
input("\n按回车退出……")
except Exception:
pass