Jonathan8520/patch-tasm2
GitHub: Jonathan8520/patch-tasm2
通过修改九条 arm64 指令,让服务器已关停的 iOS 版《超凡蜘蛛侠 2》实现完全离线启动、游玩和存档持久化。
Stars: 0 | Forks: 0
# TASM2 离线补丁 — 超凡蜘蛛侠 2 (iOS 1.3.1)
Gameloft 的《超凡蜘蛛侠 2》服务器大约自 2018 年起就已停用,
并且 iOS 版本会永远卡在 **“Downloading profile”**。本补丁修改了
游戏,使其可以完全离线启动、游玩 **并保存**。
九条指令,全部位于 arm64 切片中,每一条都源自
反汇编并在实际发布的二进制文件上进行了验证。没有新的存档
格式,没有注入的代码,没有大小变化。
**已在设备上确认** (LiveContainer, iOS):连续通关了
序章、整个第一章并进入第二章,期间 App 被多次
强制退出。设置、奖杯、探索、技能、位置、
教程状态和故事进度全都得以保留。

这一行就是整个项目的一句话总结。未打补丁且离线状态下,这个
屏幕永远不会出现 —— 游戏会卡在 “Downloading profile”。打补丁后,它
报告的章节来自 `ud_OObjects.sav`,即 [edit 8](#8-let-the-profile-reach-disk--0x10021163c) 最终允许写入的
配置文件:本地任务服务器在启动时从中读取了 `"ch2"`,
并利用内置的 `ch2.json` 重建了故事进度,整个过程没有任何服务器参与。
## 获取补丁
工作流会从 archive.org 的副本构建 IPA,而运行工作流
需要其所在仓库的写入权限 —— 因此 **请先 Fork 本仓库**。
别人仓库的 Actions 标签页不会为你运行它。
1. **Fork**,然后选择 **Actions** 标签页 → *Patch TASM2 IPA* → **Run workflow**
2. 从运行创建的发布版中下载 `SpiderMan2_patched.ipa`
3. 使用 LiveContainer / SideStore / Sideloadly 安装
## 有效功能
| | |
|---|---|
| 启动 | 不再出现 “Downloading profile” 加载圈 |
| 故事 | 序章只播放一次,然后章节会自动推进 |
| 存档 | 全部 17 个存档对象会持久化到 `Documents/ud_*.sav` |
| 配置文件 | 故事进度会持久化在 `ud_OObjects.sav` 中 |
| 设置、奖杯、战争迷雾、技能、位置、教程状态 | 全部持久化 |

该画面中的所有内容曾经都通过 Gameloft 配置文件传输,现在变为了
本地文件:刚刚触发的奖杯是 `ud_Trophy.sav`,小地图已解锁的
部分是 `ud_FogOfWar.sav`,蜘蛛侠即将重生的地点是
`ud_InitPos.sav`。十七个存档对象中有十二个曾被保存在服务器上,
直到 [edit 2](#2-make-every-save-object-local--0x10021bc7c) 修改了一个字节。
## 依然无效的内容
- **任何真正需要联网的内容**:商店购买、活动、好友。服务器
已经没了;游戏会通过 *“Le réseau actuel est indisponible”*
(当前网络不可用)对话框如实告知。
- **等待加载圈**可能会在请求挂起时停留在屏幕上。整个游戏
只有一个该组件,只要掩码位被设置就会显示,而
离线状态下有设置却没有对应的清除操作:原因 0 有 47 次设置和 36 次
清除,原因 3 有五次设置和仅一次清除。一个永远
得不到响应的请求会让它在你接下来打开的每个屏幕上一直显示——这就是为什么它看起来
像是技能菜单的加载圈,或者是主菜单的加载圈。Edit 9 修复了由
我们导致的那一种情况;`--fail-network` 缩短了那些真正需要
等待的时间;`--kill-spinner` 则完全停止显示该组件。请参阅
下文了解这两者。
- **“网络不可用”对话框**仍然会时不时出现。它是从 15 个
独立位置发出的,每个位置都会查找文本并通过游戏中
其他所有对话框都在使用的相同辅助函数构建自己的消息框——
没有统一的开关可以翻转。在一个已经正常运行的
版本上,为了省去一次点击 OK 的操作,意味着要进行十五次
猜测性的控制流修改。因此刻意保留了原样。
## 工作原理
### 1. 跳过登录 — `0x1000ab7b8`
`UI_DOWNLOADING_PROFILE` 只有一个触发点。一个共享谓词
决定显示该加载圈还是游戏自己的 `UI_FIRST_CHECK`(“没有
需要下载的内容,继续”),并且由于登录永远
无法完成,它总是回答“是”。
```
bl -> mov w0, #0
```
仅应用于该调用点;谓词本身在约 50 个其他
位置被调用,保持原样。通过字符串的唯一引用进行自定位:收集所有构成该地址的 ADRP/ADD 指令对,每个 ADD 必须在其 ADRP 的八条指令之内,并且必须只剩下一个符合条件的候选——如果过期的寄存器
值曾经产生过候选,那么首次匹配规则将静默地修补错误的 `bl`。
### 2. 将所有存档对象设为本地 — `0x10021bc7c`
每个存档对象在 `+0x25` 处都有一个字节:*“将我持久化到本地
`ud_.sav`”*。构造函数在全部十七个对象中的**五个**(即
设置)上设置了它。其他所有内容都搭载在 Gameloft 配置文件中传输。
`CSaveMgr::ReloadAll` 已经遍历了全部十七个对象,并且在 `w25` 中已经持有 `1`,
它对每个对象的第一步操作就是跳过非本地对象的分支:
```
0x10021bc78 ldrb w8, [x20, #0x25]
0x10021bc7c cbz w8, -> strb w25, [x20, #0x25]
```
从那里开始,其他十二个对象完全走那五个对象已经在走的路径,
两端都使用游戏自己的序列化器。
### 3–4. 在 `ud_QuestManager` 中携带章节信息 — `0x1001ff4c8`, `0x1001ff594`
该对象持久化了两个整数。第二个,`progressMgr+0x2dc`,是
待处理任务 ID:构造函数将其设置为 `-1`,它唯一的生产者
在使用后立即将其源重置为 `-1`,而它唯一的
消费者将 `-1` 读取为“无待处理内容”。每次保存
都存储了构造函数的默认值,因此该插槽是
空闲的。
```
0x1001ff4c8 ldr w1, [x20, #0x2dc] -> mov w1, #1 serialise
0x1001ff594 str w0, [x19, #0x2dc] -> str w0, [x19, #0x2a4] restore
```
在版本 3 中仍然是两个整数——格式、长度和版本字节保持不变。
### 5. 在本地生成章节 — `0x1001edd28`
`0x1001edd54` 并没有计算章节;它是从
任务结果 map 中读取章节,而该 map 唯一的批量写入者是
*mission finished* HTTP 请求的 JSON 回调。离线时,map 保持为空,
并且 `map["chapter"]` 默认插入 0。
```
0x1001edd28 ldr w20, [sp, #0x10] -> mov w20, #1
```
在该加载和存储操作之间,`w20` 没有在任何地方被读取。
### 6–7. 防止教程位掩码被丢弃 — `0x1003cc5f4`, `0x1003cc5b8`
有两条路径将其丢弃,设备数据捕获到了这两者:
```
0x1003cc5f4 cbz w0, +0x14 -> nop the deserialiser stops discarding
the saved value when the chapter is 0
0x1003cc5b8 str wzr, [x0, #4] -> nop CTutorialMgr::Reset becomes a no-op
```
第二个最为重要:它的单一调用者是 `0x1001205ec` 处脚本
分发器的尾部,因此是游戏自己的脚本请求了重置,并且
只要播放开场动画就会运行一个脚本。修改前:`132926 → 62`
(`0x0002073e → 0x0000003e`,九个教程步骤减少到五个)。修改后:
`132926 → 153406 → 161790`。
### 8. 让配置文件存入磁盘 — `0x10021163c`
**这是让故事进度得以运作的关键。**
`progressMgr+0x50` 在这个版本中不是 HTTP 客户端。它是一个**本地
任务服务器**(`0x100416000..0x100422000`),负责读取内置的
`ch0.json … ch8.json` 并合成 Gameloft 服务器过去常常
发送的响应:请求 `0x16` 从
`profile["_ca"] == "chN"` 填充整个故事进度指针(`progressMgr+0x110`,一个以 `"mm"`/`"sm"`/`"prm"`/`"rm"` 为键的 `map>`);请求 `0x15` 将 `profile["_ca"]` 推进至
`chapterDoc["nc"]`。
它的状态是存档对象 16——即配置文件文档——而对象 16 是十七个对象中
唯一一个写入器拒绝写入的:
```
0x100211620 ldr w8, [x19, #0x1c] ; SaveIndex
0x100211624 cmp w8, #0x10
0x100211628 b.ne
0x100211634 ldr w8, [saveMgr, #0xfc8]
0x100211638 and w8, w8, #0xff00ff
0x10021163c cbz w8, -> nop
```
`+0xfc8` 代表 *“构造管理器时此文件是否已存在”*;
`+0xfca` 代表 *“云配置文件已被触及”*,仅在两条
死寂的离线路径上设置。全新安装 ⇒ 均为零 ⇒ 永不写入 ⇒ 永远不存在。这是一个锁死了自己的循环,也是为什么游戏每次启动都是从对象 16 的 `Reset` 默认值 `"ch0"` 开始的原因。
回读侧已经无条件地为对象 16 运行,因此 `nop`
并没有启用任何新的代码路径——只是让这个文件开始存在了而已。
### 9. 给主菜单加载圈一个退出的方式 — `0x1000abc1c`
Edit 1 有一个需要设备报告才能注意到的副作用。主菜单
状态机引发了一次启动加载圈,然后根据谓词进行分支:
在线分支在 `0x10110b0bb` 处设置了一个标志,而离线分支——即
edit 1 强制执行的那个——清除了它。然后两者都使用自己的
标签显示相同的组件。
该标志是加载圈的**两个**隐藏动作的门控:状态机的
(`0x1000ab60c` → `0x1000ab62c`)和菜单析构函数的
(`0x1000a8a70` → `0x1000a8a8c`)。当它为零时,两者都无法触发,并且
析构函数在经过时会清除*另一个*标志,因此一旦菜单被销毁,
就没有任何东西可以让该组件消失或重新出现。二进制文件中仅有的两处
无条件 `HideWaiting(12)` 站点都是没有直接调用者的响应回调,
离线状态下它们永远不会运行。
所以离线分支现在将 `w20` 存储到了原来存储 `wzr` 的位置——这与
在线分支已经执行的指令相同,即 `0x3902ed14`,相同的寄存器,
相同的偏移量。`w20` 在前四条指令处是 1,而 `0x1000ab7c0` 是
`__text` 中唯一跳转到此处的分支。
该标志恰好被写入两个位置并被读取两次,它们全都是
这些隐藏动作——这是通过扫描引用
该页面的所有 952 个函数中对 `0xb9`、`0xba` 或 `0xbb` 的任何字节访问来确认的,因为线性
寄存器扫描漏掉了这个站点:它的 `ADRP` 回溯了 `0x460` 个字节,位于分支的另一侧。
### 互补修改
已停用的 Gameloft 主机名被重写为 `.invalid`(RFC 6761:永远
无法解析),越狱检测路径和函数均已失效。
每次修改都精确保持了长度不变。
| 失效主机 | 角色 |
|---|---|
| livewebapp.gameloft.com | autologin.php |
| eve.gameloft.com | 个人资料服务 |
| pjsmmm-legacy.gameloft.com | 遗留后端 |
| ingameads.gameloft.com | 广告 / iphoneloading.php |
| 201205igp.gameloft.com | IGP / 免费增值服务 |
### 可选:结束等待 — `--fail-network`, `0x100346cd0`
尚未包含在发布的版本中;它需要先在设备上运行一次。
`0x100346c10` 是游戏的可达性谓词——它会向 `Reachability`
询问当前状态,将 wifi 和 wwan 计为可达,并
将答案缓存一秒钟。有五十个调用站点会请求它,而包含它们的
二十八个函数中的每一个都是网络功能:登录、商店和
IAP、排行榜、好友和邮件、分析、新闻和支持按钮、
存档管理器中的云配置文件上传。本地任务服务器中没有任何内容
调用它,存档/恢复路径上也没有任何内容调用它。
它的尾部是 `cmp w8,#0 ; cset w0,ne ; ret`。将 `cset` 替换为
`mov w0,#0` 会在任何地方都回答“无网络”,这是 Gameloft
在这五十个站点的每一个中都为其发布过分支的状态——也就是
上面的主补丁已经在其中一个强制执行过的相同分支。
技能菜单就是说明其重要性的一个例子。`SP_ShowSkillTree`
(`0x1003eafe8`)调用 `0x1000bf130`,后者在商品
类别未加载时会引发加载圈,开始获取数据,然后读取谓词:
可达 → 状态 1,*等待响应*;不可达 → 状态 3,即
离线分支,管理器的更新(`0x1000b1cd0`)会通过 3 → 4 → −3 运行它
而不进行任何 I/O,并且其处理程序调用 `0x1000b1c0c`——这是二进制文件中
唯一清除该加载圈的代码。相同的屏幕,相同的组件,相同的清除路径,
在一帧之内即可达成,而无需等待超时。
除了等待之外,还有一个可见的变化:在真正的首次
启动时,由于尚无配置文件,启动流程会显示一个 `UI_cloud_data_reminder` 对话框,其
OK 按钮会落在在线分支曾经跳转到的状态。一旦文件存在
——这就是上面 edit 8 的作用——它就再也不会出现了。
### 可选:移除组件 — `--kill-spinner`, `0x1001e7bc4`
默认情况下也不会发布,并且旨在*与* `--fail-network` 配合使用。
`ShowWaiting` 设置其比特位,然后显示该组件。这里从掩码更新
之后直接跳转到函数自身的尾声,因此掩码
仍然得到维护,仅仅跳过了显示:
```
0x1001e7bc0 str w0, [x19, #0x330] ; mask |= 1< b 0x1001e7f28 ; the epilogue
```
这刻意不是我们尝试的第一种方法,第一种方法是在 `0x1001e7b94` 处使用 `ret`——那
也会跳过掩码更新,导致 `ShowWaiting` 和 `HideWaiting` 对屏幕上的内容产生分歧。
函数体准确地写入了两个管理器字段,即 `+0x31d`
和 `+0x10`,它们都是“组件可见”的簿记;保持为零,
`HideWaiting` 会发现没有什么可隐藏的,而商店的隐藏一切辅助程序会
提前返回。两者都是正确的,因为根本没有显示任何内容。
这种取舍是真实存在的:该组件是页面仍在加载的
唯一信号。这就是为什么它与 `--fail-network` 配对使用,后者使得过去需要
等待的页面在一帧之内失败。
### 退出选项
`--no-local-save`, `--no-persist-chapter`, `--no-tutorial-guards`,
`--no-profile-save`, `--no-boot-spinner-fix`。未知的标志是一个错误,
而不是静默的空操作,因此拼写错误无法悄悄丢弃某次修改;
`--no-force-prologue-skip` 仍然作为 `--no-tutorial-guards` 的
曾用名发挥作用。
如果站点缺失或存在歧义,补丁程序不会写入任何内容,并且
`patch_tasm2.py --verify ` 会重新检查发布的二进制文件:全部九个
修改——包括主补丁——加上越狱修改,加上确认没有任何
存活的 Gameloft 主机,加上确认已移除的跳过序章修改确实**不存在**,
因此由较旧的补丁程序构建的二进制文件无法通过。构建工作流在重新压缩后运行它,
这是发布版的唯一关卡。
## 工具
`tools/` 是分析工具套件。三个自行打开可执行文件的脚本
—— `scan.py`, `summary.py`, `st.py` —— 从 `TASM2_BIN` 读取其路径,
默认为 `/home/user/AmazingSpiderMan2`;扫描和闭包从 `st.py` 导入它,
因此该变量也覆盖它们。`machoscan.py` 是一个库,将
路径作为参数,而 `decode_sav.py` / `encode_sav.py` 作用于
命令行中指定的 `.sav` 文件,而非可执行文件。导入是相对于每个脚本自身的路径解析的,
因此该目录无论被克隆到何处都能正常工作:
```
pip install -r tools/requirements.txt # capstone, for the disassembler
TASM2_BIN=/path/to/AmazingSpiderMan2 python3 tools/scan.py dis 0x10021bc3c 40
```
`patch_tasm2.py` 本身不依赖于标准库之外的任何内容,这
就是为什么构建工作流不需要安装任何东西的原因。
| | |
|---|---|
| `machoscan.py` | FAT/Mach-O 解析,用于获取精确函数边界的 `LC_FUNCTION_STARTS`,来自间接符号表的 libc 桩名称,ADRP/ADD 交叉引用 |
| `scan.py` | `dis` / `fn` / `str` / `sref` / `xref` / `callers` / `info` |
| `summary.py` | 密集的函数级概览 |
| `decode_sav.py` | 将 `ud_*.sav` 一路解码为纯字节 |
| `encode_sav.py` | 逆向操作;拒绝写入它无法读回的内容 |
| `sav-reader.html` | 与解码器相同的自包含网页,用于在手机上读取存档 |
| `st.py`, `sweep2.py`, `sweep2d8.py` | 匹配存储的**字节范围**而非其立即数的字段扫描——见下文 |
| `thisclose.py`, `closure2.py`, `closure3.py` | 通过函数对基址寄存器进行符号跟踪 |
将 `str Wt,[Xn,#imm]` 与偏移量匹配**并不是**对某个
字段写入者的枚举:它会遗漏所有较低偏移量处的更宽存储。这并不是
假设情况——这正是进度管理器的构造函数
(`str d1,[x9]`, `x9 = progressMgr+0x2a4`)逃脱本仓库第一次
扫描的原因,并让我们付出了进行一次设备测试的代价。
## 二进制文件事实
- FAT `armv7 + arm64`;所有修改均在 arm64 切片中
- 两个切片上 `cryptid=0` → 已经解密,没有 Apple ID 提示
- SDK iphoneos10.3 / Xcode 8.3, MinimumOSVersion 8.0
- 原始 SHA1 `b3d322a788bbeeb1a006ba0da23a28300a5b7105`,33,375,152 字节,
打补丁后保持不变
IPA 大约为 769 MB,这就是为什么工作流将其发布为 **Release**
(2 GB 限制)而不是构件(受限于 500 MB 账户配额)的原因。
## 为什么没有简单地替换服务器
在设备上尝试过为游戏提供一个服务器,这是一条死胡同。重定向
可以端到端地工作——手机确实连接到了我们控制下的 Cloudflare Worker
——但是唯一的 HTTP 流量是广告 WebView。`autologin.php`
从未被调用,即使移除了配置文件补丁,让游戏尝试真正的
登录也是如此。
配置文件完全通过**联合服务器:原始 TCP,基于操作码的二进制协议**(`_socket`/`_connect`/`_send`/`_recv`,
`port=7744&type=gs`,`Send federation request, opcode[%d]`)。其框架、
握手、操作码和任何加密都必须在没有任何
参考数据包的情况下重建,因为服务器自 2018 年左右就已经死掉了。主机名长度
*并不是*障碍——DNS 重写可以将任何名称指向任何地方,而无需
触碰二进制文件。
这就是该项目的讽刺之处:Gameloft 在同一个二进制文件中提供的本地
替代品一直都在那里,仅仅隔着一个 `cbz`。
## 无补丁替代方案
在**首次启动之前**通过 DNS (NextDNS, AdGuard, iOS 配置文件) 阻止这些域名,会产生
与主机重写相同的网络故障,而不
触碰任何文件且完全可逆。它不能解决存档问题。
## 延伸阅读
[LOCAL_SAVE_DESIGN.md](LOCAL_SAVE_DESIGN.md) — 存档子系统、解码的
文件格式、每次修改背后的全部理由,以及记录了
所有尝试过但不起作用的内容的日志,包括两个必须由设备
数据推翻的结论。
## 许可证
[MIT](LICENSE),它涵盖**本仓库自身的代码**——`patch_tasm2.py`、
`tools/`、工作流和文档。Fork 它,修改它,发布它。
它不也也不可能涵盖《超凡蜘蛛侠 2》本身。该游戏属于
Gameloft;这里的任何内容都不是对其的许可。补丁程序不发布任何游戏代码——
它只在你自行提供的二进制文件中重写了九条指令。
标签:Arm64, iOS, 二进制修补, 云资产清单, 游戏补丁, 离线模式, 逆向工程