This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/tools/AnantaInjector/unified_loader.py
T

1025 lines
50 KiB
Python
Raw Normal View History

# -*- 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