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 被多次 强制退出。设置、奖杯、探索、技能、位置、 教程状态和故事进度全都得以保留。 ![离线主菜单,显示第二章完成度 7%](https://static.pigsec.cn/wp-content/uploads/repos/cas/ce/ce409f60fd2a14c0fbadc3a0f419eb73f7eb4af3a814b519a5c6d47211c9fb09.jpg) 这一行就是整个项目的一句话总结。未打补丁且离线状态下,这个 屏幕永远不会出现 —— 游戏会卡在 “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` 中 | | 设置、奖杯、战争迷雾、技能、位置、教程状态 | 全部持久化 | ![开放世界,触发了一个奖杯,地图已部分解锁](https://static.pigsec.cn/wp-content/uploads/repos/cas/04/041136fb47b96ca54ec62df63a21d19c4dfec15b33cd1a0057b15a55a1a41428.jpg) 该画面中的所有内容曾经都通过 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, 二进制修补, 云资产清单, 游戏补丁, 离线模式, 逆向工程