launch_cq2_mac.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. # -*- coding: utf-8 -*-
  2. """
  3. launch_cq2_mac.py —— 996传奇盒子 Mac 端自动登录 + 启动 cq2 游戏客户端
  4. =====================================================================
  5. 复刻 Mac 盒子(com.mac.BoxMacOS.xq)的原生启动链路(逆向实证,见 docs):
  6. 1. 账号密码登录 api.996hezi.com /auth/v3/login/safe/byPwd
  7. (GeekedTest 解极验v4滑块 + RSA(loginInfo) + BOXAPP-SHA256 签名)
  8. 2. 启动区服 pc-api.996hezi.com /v1/homeapi/gameindex/EnterGameClient
  9. body: {box_game_id, serverid, device_info, appid}
  10. resp: data.start_info ← 正是盒子写入 App Group 的那份凭证
  11. 3. 写共享凭证 defaults write group.com.XianQu.chuanqibox SHBoxMacOS <json>
  12. (App Group 域,经 cfprefsd 守护进程代理写入,无需沙盒权限)
  13. 4. 拉起游戏 /usr/bin open 盒子沙盒内已下载的游戏 app
  14. ~/Library/Containers/com.mac.BoxMacOS.xq/Data/Documents/game/996传奇盒子.app
  15. 用法:
  16. .venv-mac/bin/python launch_cq2_mac.py -u <账号> -p <密码> \
  17. [--zone 14448581] [--game 206923]
  18. 依赖: requests pycryptodome pymysql curl_cffi opencv-python-headless numpy (GeekedTest)
  19. """
  20. import argparse
  21. import json
  22. import os
  23. import random
  24. import string
  25. import subprocess
  26. import sys
  27. import time
  28. import requests as plain_requests
  29. import urllib3
  30. urllib3.disable_warnings()
  31. HERE = os.path.dirname(os.path.abspath(__file__))
  32. sys.path.insert(0, HERE)
  33. sys.path.insert(0, os.path.join(HERE, "已经开发的部分代码", "chuanqi-main", "code", "GeekedTest"))
  34. from login_996pc import ( # noqa: E402
  35. do_login, box_auth, box_headers, APPV, UA, DEVICE_ID,
  36. )
  37. # ----------------------------------------------------------------------------
  38. # 常量(逆向实证)
  39. # ----------------------------------------------------------------------------
  40. PC_API = "https://pc-api.996hezi.com"
  41. ENTER_GAME_PATH = "/v1/homeapi/gameindex/EnterGameClient"
  42. APPID = "GAjWNTqNpOVx" # 996box PC/Mac 盒子 sdkAppid
  43. GAME_APP_PATH = os.path.expanduser(
  44. "~/Library/Containers/com.mac.BoxMacOS.xq/Data/Documents/game/996传奇盒子.app")
  45. APP_GROUP = "group.com.XianQu.chuanqibox" # 盒子+游戏 共享 App Group
  46. PLIST_KEY = "SHBoxMacOS" # 游戏客户端启动时读取的键
  47. DEFAULT_GAME_ID = 206923 # 玖扬复古176
  48. DEFAULT_ZONE_ID = 14448581 # 霸者36区
  49. def enter_game(token: str, game_id: int, zone_id: int) -> dict:
  50. """调 EnterGameClient —— 等价于在盒子里点击【启动】按钮。
  51. 返回 data.start_info(盒子将要传给游戏客户端的全部凭证)。
  52. """
  53. device_info = json.dumps({
  54. "type": "pc", "model": "pc", "os": "Win10",
  55. "width": 1920, "height": 1032, "lang": "zh-CN",
  56. "id": "43b2d8df251be893bf57a67560b1e74e",
  57. "net_type": "unknown", "mac": DEVICE_ID, "ua": UA,
  58. }, separators=(",", ":"))
  59. body = json.dumps({
  60. "box_game_id": game_id,
  61. "serverid": zone_id,
  62. "device_info": device_info,
  63. "appid": APPID,
  64. }, separators=(",", ":"))
  65. h = box_headers(token)
  66. h["X-BOX-AUTHORIZATION"] = box_auth("POST", ENTER_GAME_PATH, body, token)
  67. r = plain_requests.post(PC_API + ENTER_GAME_PATH, data=body.encode(),
  68. headers=h, timeout=25, verify=False)
  69. j = r.json()
  70. code = j.get("code")
  71. if code == 81111:
  72. raise SystemExit(
  73. f"[!] code=81111 请实名认证 —— 该账号未实名(auth_status=0),"
  74. f"服务端拒绝启动游戏。请在盒子/官方页完成实名后重试。")
  75. if code != 200:
  76. raise SystemExit(f"[!] EnterGameClient 失败 code={code} msg={j.get('msg')}")
  77. data = j.get("data") or {}
  78. start = data.get("start_info")
  79. if not start or not start.get("token"):
  80. raise SystemExit(f"[!] 响应缺少 start_info: {json.dumps(data)[:300]}")
  81. return start
  82. GROUP_PLIST = os.path.expanduser(
  83. "~/Library/Group Containers/group.com.XianQu.chuanqibox/"
  84. "Library/Preferences/group.com.XianQu.chuanqibox.plist")
  85. def write_app_group(start_info: dict, game_id: int, zone_id: int) -> None:
  86. env = dict(start_info)
  87. # 与 Mac 盒子写入 SHBoxMacOS 的字段实样对齐。
  88. # start_info 缺的这些字段由盒子端补:hideServerList/hideBackBox 控制游戏内
  89. # 是否弹出选区界面(缺失会停在区服列表等人点击);display 尺寸/osVersion/
  90. # model 供客户端适配窗口;is_adult/is_realname 为防沉迷标记。
  91. env.setdefault("platform", "mac")
  92. env.setdefault("gameId", game_id)
  93. env.setdefault("serverId", str(zone_id))
  94. env.setdefault("boxConfig", 1)
  95. env.setdefault("isBoxLogin", 1)
  96. env.setdefault("gm", "0")
  97. env.setdefault("scene", 0)
  98. env.setdefault("sdkChannel", "996box_mac")
  99. env.setdefault("macAddress", _box_uuid())
  100. env.setdefault("hideServerList", 1)
  101. env.setdefault("hideBackBox", 1)
  102. env.setdefault("displayWidth", "1230")
  103. env.setdefault("displayHeight", "760")
  104. env.setdefault("osVersion", _macos_version())
  105. env.setdefault("app_ver", "1.0.1")
  106. env.setdefault("model", _mac_model())
  107. env.setdefault("is_adult", 1)
  108. env.setdefault("is_realname", 1)
  109. payload = json.dumps(env, ensure_ascii=False, separators=(",", ":"))
  110. # 无沙盒 entitlement 的进程经 cfprefsd 写 group.* 域会落到 ~/Library/Preferences,
  111. # 而游戏进程(带 App Group entitlement)读的是 Group Containers 那份,
  112. # 因此直接 plutil 改写目标 plist 文件。
  113. # 先让 cfprefsd 把它缓存的内存态刷盘,避免我们写入后被回写覆盖。
  114. subprocess.run(["defaults", "read", APP_GROUP, PLIST_KEY], capture_output=True)
  115. os.makedirs(os.path.dirname(GROUP_PLIST), exist_ok=True)
  116. r = subprocess.run(
  117. ["plutil", "-replace", PLIST_KEY, "-string", payload, GROUP_PLIST],
  118. capture_output=True, text=True)
  119. if r.returncode != 0:
  120. r = subprocess.run(
  121. ["plutil", "-insert", PLIST_KEY, "-string", payload, GROUP_PLIST],
  122. capture_output=True, text=True)
  123. if r.returncode != 0:
  124. raise SystemExit(f"[!] 写 Group plist 失败: {r.stderr}")
  125. print(f"[*] 已写入 App Group 凭证 → {GROUP_PLIST}")
  126. print(f" uid={env.get('uid')} token={str(env.get('token'))[:20]}...")
  127. def _box_uuid() -> str:
  128. r = subprocess.run(["defaults", "read", "com.mac.BoxMacOS.xq", "uuidForKeychain"],
  129. capture_output=True, text=True)
  130. if r.returncode == 0 and r.stdout.strip():
  131. return r.stdout.strip().strip('"')
  132. return ""
  133. def _macos_version() -> str:
  134. r = subprocess.run(["sw_vers", "-productVersion"], capture_output=True, text=True)
  135. return r.stdout.strip() or "14.7.1"
  136. def _mac_model() -> str:
  137. r = subprocess.run(["sysctl", "-n", "hw.model"], capture_output=True, text=True)
  138. return r.stdout.strip() or "Mac16,10"
  139. def launch_game_app() -> None:
  140. """用 /usr/bin open 拉起盒子沙盒内的游戏 app(与盒子 spawn 行为一致)。"""
  141. if not os.path.isdir(GAME_APP_PATH):
  142. raise SystemExit(
  143. f"[!] 游戏客户端未下载: {GAME_APP_PATH}\n"
  144. f" 请先用 996传奇盒子(Mac) 正常启动一次该游戏完成下载,再跑本脚本。")
  145. r = subprocess.run(["/usr/bin/open", "-a", GAME_APP_PATH],
  146. capture_output=True, text=True)
  147. if r.returncode != 0:
  148. raise SystemExit(f"[!] open 失败: {r.stderr}")
  149. print(f"[*] 已拉起游戏客户端: {GAME_APP_PATH}")
  150. def _game_pid():
  151. r = subprocess.run(["pgrep", "-f", "AppTranslocation.*996传奇盒子.app/996传奇盒子"],
  152. capture_output=True, text=True, errors="ignore")
  153. if r.returncode == 0 and r.stdout.strip():
  154. return int(r.stdout.split()[0])
  155. return None
  156. def _game_window():
  157. """返回 (window_id, bounds) 或 None。"""
  158. try:
  159. import Quartz
  160. except ImportError:
  161. return None
  162. pid = _game_pid()
  163. if pid is None:
  164. return None
  165. for w in Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListOptionAll,
  166. Quartz.kCGNullWindowID):
  167. if w.get('kCGWindowOwnerPID') == pid:
  168. b = dict(w.get('kCGWindowBounds') or {})
  169. if b.get('Width', 0) > 1000 and b.get('Height', 0) > 600:
  170. return w.get('kCGWindowNumber'), b
  171. return None
  172. def cg_click(x: float, y: float) -> None:
  173. """CGEvent 合成点击。需要终端持有「输入监控」权限,否则事件被丢弃。"""
  174. import Quartz, time
  175. for evt in (Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp):
  176. e = Quartz.CGEventCreateMouseEvent(None, evt, (x, y), Quartz.kCGMouseButtonLeft)
  177. Quartz.CGEventPost(Quartz.kCGHIDEventTap, e)
  178. time.sleep(0.08)
  179. def capture_window(path: str) -> bool:
  180. try:
  181. import Quartz
  182. from Foundation import NSMutableData
  183. except ImportError:
  184. return False
  185. win = _game_window()
  186. if not win:
  187. return False
  188. wid, b = win
  189. rect = Quartz.CGRectMake(b['X'], b['Y'], b['Width'], b['Height'])
  190. img = Quartz.CGWindowListCreateImage(
  191. rect,
  192. Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements,
  193. wid, Quartz.kCGWindowImageBoundsIgnoreFraming)
  194. if not img:
  195. return False
  196. ms = NSMutableData.data()
  197. ti = Quartz.CGImageDestinationCreateWithData(ms, "public.png", 1, None)
  198. Quartz.CGImageDestinationAddImage(ti, img, None)
  199. Quartz.CGImageDestinationFinalize(ti)
  200. with open(path, "wb") as f:
  201. f.write(bytes(ms))
  202. return True
  203. def dismiss_popups(deadline_s: float = 90.0) -> bool:
  204. """关闭游戏启动后的公告弹窗并点击启动面板的【启动】按钮。
  205. 按钮坐标为窗口相对比例(2026-09-18 多次截图实测标定):
  206. - 公告弹窗【确定】: 57% 宽 / 70% 高
  207. - 启动面板【启动】: 52% 宽 / 73% 高(区服/线路已由凭证锁定)
  208. 弹窗可能多条:循环点击确定直至画面稳定,再点启动。
  209. """
  210. import time as _t
  211. def rel_click(b, fx, fy, label):
  212. x = b['X'] + b['Width'] * fx
  213. y = b['Y'] + b['Height'] * fy
  214. cg_click(x, y)
  215. print(f"[*] 点击{label} @({x:.0f},{y:.0f})")
  216. def stable_after_click(b, label, wait=2.5):
  217. """点击后返回画面是否已稳定(无新弹窗弹出)。"""
  218. import hashlib, subprocess as sp
  219. def snap():
  220. if not capture_window("/tmp/_cq2_probe.png"):
  221. return ""
  222. try:
  223. return hashlib.md5(open("/tmp/_cq2_probe.png", "rb").read()).hexdigest()
  224. except OSError:
  225. return ""
  226. before = snap()
  227. rel_click(b, 0.57, 0.70, label)
  228. _t.sleep(wait)
  229. for _ in range(6):
  230. after = snap()
  231. if after and after == before:
  232. return True # 画面不再变化 = 弹窗序列走完
  233. before = after
  234. rel_click(b, 0.57, 0.70, label)
  235. _t.sleep(wait)
  236. return False
  237. print("[*] 自动处理公告弹窗 ...")
  238. end = _t.time() + deadline_s
  239. while _t.time() < end:
  240. win = _game_window()
  241. if not win:
  242. _t.sleep(2)
  243. continue
  244. _, b = win
  245. stable_after_click(b, "公告【确定】")
  246. # 弹窗序列结束后出现启动面板,点【启动】
  247. rel_click(b, 0.52, 0.73, "【启动】")
  248. _t.sleep(6)
  249. # 启动后进入角色选择界面:选1号角色槽并点【开始】(2026-09-18 实测标定)
  250. win2 = _game_window()
  251. if win2:
  252. _, b = win2
  253. rel_click(b, 0.25, 0.45, "角色槽1")
  254. _t.sleep(1.5)
  255. rel_click(b, 0.50, 0.78, "【开始】进游戏")
  256. return True
  257. return False
  258. def wait_game_connected(timeout: float = 180.0) -> bool:
  259. """等游戏进程出现并连上游戏网关(TCP)。"""
  260. print("[*] 等待游戏进程 + 网关连接 ...")
  261. deadline = time.time() + timeout
  262. announced = False
  263. while time.time() < deadline:
  264. # 游戏经 App Translocation 启动,进程路径含 AppTranslocation,bundle id 不出现在 cmdline
  265. r = subprocess.run(["pgrep", "-f", "AppTranslocation.*996传奇盒子.app/996传奇盒子"],
  266. capture_output=True, text=True, errors="ignore")
  267. if r.returncode == 0:
  268. pid = r.stdout.split()[0]
  269. if not announced:
  270. print(f"[*] 游戏进程已启动 pid={pid}")
  271. announced = True
  272. net = subprocess.run(["lsof", "-i", "-n", "-P", "-a", "-p", pid],
  273. capture_output=True, text=True, errors="ignore")
  274. est = [l for l in net.stdout.splitlines()
  275. if "ESTABLISHED" in l and ("8083" in l or "12000" in l)]
  276. if est:
  277. print(f"[✓] 游戏(pid={pid}) 已连接网关: {est[0].split()[-1]}")
  278. return True
  279. time.sleep(3)
  280. print("[!] 超时:未见游戏网关连接(可能仍在加载资源/校验凭证)")
  281. return False
  282. def main():
  283. ap = argparse.ArgumentParser(description="996盒子 Mac 端自动启动 cq2")
  284. ap.add_argument("-u", "--user", required=True, help="996盒子账号")
  285. ap.add_argument("-p", "--password", required=True, help="密码")
  286. ap.add_argument("--game", type=int, default=DEFAULT_GAME_ID,
  287. help=f"盒子 gameId (默认 {DEFAULT_GAME_ID} 玖扬复古176)")
  288. ap.add_argument("--zone", type=int, default=DEFAULT_ZONE_ID,
  289. help=f"区服 serverid (默认 {DEFAULT_ZONE_ID} 霸者36区)")
  290. ap.add_argument("--token-file", help="跳过登录,直接用文件里的 token")
  291. args = ap.parse_args()
  292. # ---- 1. 登录 ----
  293. if args.token_file and os.path.exists(args.token_file):
  294. token = open(args.token_file).read().strip()
  295. print(f"[*] 使用已有 token: {token[:40]}...")
  296. else:
  297. s = plain_requests.Session()
  298. print(f"[*] 登录账号: {args.user}")
  299. j, key = do_login(s, args.user, args.password)
  300. if j.get("code") != 200 or not (j.get("data") or {}).get("token"):
  301. raise SystemExit(f"[!] 登录失败: code={j.get('code')} msg={j.get('msg')}")
  302. token = j["data"]["token"]
  303. info = (j["data"].get("userInfo") or {})
  304. print(f"[✓] 登录成功 uid={info.get('id')} 昵称={info.get('nick_name')}")
  305. # ---- 2. EnterGame(点启动)----
  306. print(f"[*] 启动游戏 gameId={args.game} zone={args.zone} ...")
  307. start_info = enter_game(token, args.game, args.zone)
  308. print(f"[✓] start_info 下发: channel={start_info.get('channel')} "
  309. f"uid={start_info.get('uid')} gameAccount={start_info.get('gameAccount')}")
  310. # ---- 3. 写 App Group 凭证 ----
  311. write_app_group(start_info, args.game, args.zone)
  312. # ---- 4. 拉起游戏 app ----
  313. launch_game_app()
  314. # ---- 5. 自动关闭公告弹窗(需「输入监控」权限)----
  315. time.sleep(8) # 等游戏首帧渲染
  316. dismiss_popups()
  317. # ---- 6. 等待网关连接 ----
  318. ok = wait_game_connected()
  319. sys.exit(0 if ok else 2)
  320. if __name__ == "__main__":
  321. main()