# 碎碎视界（Event Horizon）修改器 · SSMH 文档

> 目标包：`碎碎视界3.12.26.4916.2.apk`
> 包名：`com.suisui.EventHorizon`　引擎：Unity **IL2CPP**（metadata v29，arm64-v8a）
> 工具：`D:\suisui_mod\SSMH`（GUI `启动SSMH.bat` / CLI `ssmh_cli.py`）
> 最后更新：2026-11-05（新增「资源反加」，原「掉落率增加」改名并升级为「资源掉落倍率」）

---

## 0. 一句话结论

`SSMH` = **解包 → 解析 il2cpp 元数据 → 核对 hook 目标 → 注入 Frida gadget + 悬浮窗 dex → 改包名共存 → 回编译签名** 的桌面工具。
和目标像素地牢那套（改 smali 逻辑）不同：这是 Unity IL2CPP 包，游戏逻辑全在 `libil2cpp.so` 里，
smali 里只有壳，所以**改数值只能在运行期做**——用 Frida gadget 在进程内按名字找到 C# 方法并挂钩，
Java 侧只负责悬浮窗 UI 和开关存储。

---

## 1. 包体结构

```
碎碎视界3.12.26.4916.2.apk
├─ classes.dex                     3.1 MB   只有 Unity 壳 + AndroidX + GPGS，没有游戏逻辑
├─ lib/arm64-v8a/
│  ├─ libil2cpp.so                85.5 MB   全部游戏逻辑（C# 编译产物）
│  ├─ libunity.so                 21.4 MB   Unity 运行时
│  ├─ libmain.so / lib_burst_generated.so
├─ assets/bin/Data/
│  ├─ Managed/Metadata/global-metadata.dat  12.6 MB  IL2CPP 元数据（v29，15 147 个类型）
│  ├─ data.unity3d                67.9 MB   资源
│  └─ resources.resource          53.7 MB
└─ AndroidManifest.xml            package=com.suisui.EventHorizon，单个 UnityPlayerActivity
```

要点：

* **只有 arm64-v8a**，没有 armeabi-v7a / x86 → 修改包也只能装 64 位设备（现在基本都是）。
* `android:extractNativeLibs="true"` → `System.loadLibrary("gadget")` 能从 `lib/arm64-v8a` 正常加载。
* manifest 里没有 `<provider>`、没有自定义私有 `<permission>`，所以共存版改名只动 `package=` 一处就够。

---

## 2. 工程结构

```
D:\suisui_mod\SSMH\
├─ SSMH.py                  tkinter GUI（选包 → 解析 → 生成）
├─ ssmh_cli.py              命令行：python ssmh_cli.py <apk> [--probe] [--no-coexist]
├─ 启动SSMH.bat
├─ core/
│  ├─ paths.py              工具链探测（java/apktool/d8/zipalign/apksigner/7z/keystore/gadget）
│  ├─ meta.py               global-metadata.dat v29 解析 + 8 个 hook 目标自检
│  └─ build.py              解包 → 编译 dex → 打补丁 → 投 gadget → 改包名 → 回编译签名
├─ modsrc/com/ssmh/mod/
│  ├─ Config.java           开关与倍率（静态字段，agent 每 250ms 读一次）
│  ├─ Loader.java           onCreate 里解 agent 脚本 + loadLibrary("gadget")；onResume 挂悬浮窗
│  ├─ ModMenu.java          悬浮窗入口
│  └─ FloatingPanel.java    悬浮球 + 面板（纯代码 UI，贴边自动隐藏）
├─ agent/agent.js           运行期引擎：il2cpp 符号解析 / 数值同步 / 掉落倍率 / 资源反加
├─ tools/
│  ├─ libgadget.so          Frida gadget 16.7.19 android-arm64（必须 16.x，17 起无 Java 桥）
│  ├─ android-stub.jar      编译 dex 用的 android.jar
│  └─ modforge.keystore     自签名证书（口令 modforge，别名 modforge）
├─ work/                    解包缓存与中间产物
└─ output/                  成品 APK
```

外部工具（`core/paths.py` 里改，支持同名环境变量覆盖）：

| 工具 | 路径 |
|---|---|
| JDK 17 | `D:\apkwork\jdk\jdk-17.0.20.1+1` |
| apktool 2.11.1 | `D:\apkwork\apktool.jar` |
| build-tools 34 | `D:\apkwork\buildtools\android-14`（d8 / zipalign / apksigner） |
| 7-Zip | `D:\7-Zip\7z.exe` |

---

## 3. 数据模型（为什么这样挂钩）

`global-metadata.dat` 反出来（Il2CppDumper v6.7.46，配置 `Change il2cpp version to 29.1`）之后，钱/油/星币的位置是这样：

```
Session.Content.ResourcesData          // 存档镜像（ISerializableData）
  _money  : ObscuredLong  @0x18
  _fuel   : ObscuredLong  @0x68
  _stars  : ObscuredLong  @0xB8
  _tokens : ObscuredLong  @0x108
  _basicCryptocurrency : BasicCryptocurrency @0x1A8   // ← 玩法真正读写的那份

BasicCryptocurrency
  _money @0x10   _stars @0x18   _tokens @0x20   _fuel @0x28   // 全部是 Cryptocurrency 对象

Cryptocurrency（引用类型，混淆很轻）
  _value @0x20（隐藏值）  _mask @0x28
  GetCurrteValue() = _value ^ _mask
  SetCurrteValue(v) 写回 _value = v ^ _mask，并触发 ValueChanged 信号（UI 刷新走这里）

ObscuredLong（ACTk 七槽轮转混淆，0x50 字节）
  _value1.._value7 @0x00..0x30, _index @0x38, _values[] @0x40, _newindex @0x48
  → 不要手写内存，直接调 GetValue(this) / SetValue(this, v)

GameServices.Player.PlayerResources
  TryConsumeFuel(int)          // 判断油量够不够 + 扣掉，返回 bool
  RemoveResource(ItemId,long)  // 扣任务/普通资源
  AddResource(ItemId,long)     // 加任务/普通资源（掉落、采集、任务奖励的唯一入账口）
  get_BasicCryptocurrency()    // 拿到上面那四个货币对象

GameServices.Player.PlayerSkills
  _experience : Experience(含 ObscuredLong) @0x38   // 玩家等级经验，上限 337 500 000

GameServices.Research.Research
  AddResearchPoints(Faction, long)   // 科技点唯一入口（5 处调用）

Domain.Quests.Loot
  _random : System.Random @0x18      // 战利品判定专用的随机数发生器
GameDatabase.DataModel.ResourceItem
  get_Chance()                       // 星球资源掉率
```

三条设计决定：

1. **不手改 `ObscuredLong` 内存**：它是七槽轮转 + `_values[]` 托管数组，写错槽位等于写坏存档；
   统一走 `GetValue/SetValue`。
2. **货币不碰 `ResourcesData._money`**：那是序列化镜像，玩法只认 `BasicCryptocurrency` 里的 `Cryptocurrency`；
   改镜像不生效、改对象才生效。
3. **倍率用「差额补足」不用「直接乘」**：读当前值 → 和 300ms 前的值比 → 增量 ×(倍率−1) 补进去。
   好处是不会把商店扣款、修理费这些「减少」也算成倍率，也不会每帧写值把存档写花。
4. **反加是「补得比扣得多」**：同一个差额检测，方向反过来——值变少时把少的补回去，
   再额外补 (N−1) 倍；N=1 就是「消耗不减」，N=2 就是「消耗多少反赚多少」。
   油量不参与这条（它有自己的 `TryConsumeFuel` 开关）。

---

## 4. 八个功能怎么实现

| # | 面板文字 | 挂点 | 做法 |
|---|---|---|---|
| 1 | 油量不减 | `PlayerResources.TryConsumeFuel(int)` | onEnter 把参数 `amount` 改成 0：原逻辑照样返回 true，一滴油都不掉 |
| 2 | 信用点获取倍率 | `Cryptocurrency`(money) | 每 300ms 读一次，钱变多就把差额 ×(倍率−1) 补进去 |
| 3 | 星币获取倍率 | `Cryptocurrency`(stars) | 同上 |
| 4 | 经验值获取倍率 | `PlayerSkills._experience`@0x38 | `ObscuredLong.GetValue/SetValue` 读改写，补差额，封顶 337 500 000 |
| 5 | 科技点获取倍率 | `Research.AddResearchPoints(Faction,long)` | onEnter 直接把 `amount × 倍率`（奖励入口唯一，不会重复放大） |
| 6 | 资源使用时不减少 | `PlayerResources.RemoveResource(ItemId,long)` | onEnter 把 `amount` 改成 0（反加的 N=1 特例，保留旧开关） |
| 7 | **资源掉落倍率** | `Loot._random` + `System.Random.Next(int)` + `ResourceItem.get_Chance()` + **`PlayerResources.AddResource(ItemId,long)`** | 前三条让判定更容易过（`Next(max)` 返回值除以倍率、星球掉率乘倍率）；`AddResource` 是资源入账唯一口，onEnter 把 `amount × 倍率`，到手数量直接翻倍 |
| 8 | **资源反加**（油量以外的资源） | `Cryptocurrency`(money/stars/tokens) + `PlayerResources.RemoveResource` | 货币：差额检测方向反过来，值变少 `M` 时补回 `M ×(N−1)`（N 由「反加返还倍率」控制）；任务材料：`amount` 取反成负数，原逻辑「减去」变成「加上」，净返还 `M ×(N−1)`。油量不走这条 |

补充说明：

* 7/8 里凡是「乘倍率/取反」的入参都是 64 位（`long`）。正数用 `ptr(v)` 写，
  负数（反加用）先用 `int64(-n)`，运行时没有 `int64()` 就手写补码指针（`i64()` helper）。
* 7 依赖「调用点没被内联」。`ResourceItem.get_Chance` 在 dump 里查不到直接调用（说明被内联了），
  所以它只是**兜底**；真正起作用的是 `Loot._random` 和 `AddResource` 两条路。
  `AddResource` 是资源入账唯一口，倍率同时覆盖掉落、采集、任务奖励。
* 8 的两种路径互补：货币（信用点/星币/加密币）由主循环 300ms 差额补加，不依赖调用点；
  任务材料/物品在 `RemoveResource` 上直接改参数，即时生效、不用等下一拍。
* `TryConsumeFuel` / `RemoveResource` / `AddResource` 用「改参数」而不是 `Interceptor.replace`，
  因为 replace 之后没法再调原函数，功能一关就废了；改参数可以随时开关。
* 所有 hook 回调里读的是 JS 侧缓存的开关值（`S.cfgJs`），不直接 JNI 读 Java 字段——
  少一次跨层调用，也避免在游戏线程上碰 JVM。

---

## 5. 悬浮窗

* 纯代码 UI（不加资源、不申请 `SYSTEM_ALERT_WINDOW`），挂到 `Activity.getWindow().getDecorView()`：
  它是 Unity 的 FrameLayout 根，球不占满屏，不拦游戏触摸。
* 位置真源只有 `FrameLayout.LayoutParams.leftMargin/topMargin`（不用 translation），
  拖动范围 clamp 到 `[-hide, 父宽-球宽]`。
* **贴边自动隐藏**：松手时看球心在左半还是右半，自动吸到那一侧，
  `leftMargin` 设成 `-hide`（球一半出屏），透明度降到 0.35；再拖出来就恢复。
  阈值 `hide = max(10dp, 球宽/2 - 6dp)`。
* 坐标存 `SharedPreferences`，越界自动回默认位置；面板里有「重置悬浮球位置」。
* 面板内容：6 个倍率滑杆（1 = 关闭；「反加返还倍率」的 1 是有效值，表示等额返还）
  + 3 个开关（油量不减 / 资源使用时不减少 / 资源反加）+ 实时状态行（钩子数、当前生效值）。

---

## 6. 注入链（第二轮重写版）

```
UnityPlayerActivity.onCreate()      ← smali 注入（.locals 之后，不动原有寄存器）
   └ Lcom/ssmh/mod/Loader;->onCreate(Activity)
        ├ Config.init()                       读 SharedPreferences
        ├ 把 assets/ssmh_agent.js 复制到 /data/user/0/<pkg>/files/ssmh_agent.js
        └ System.loadLibrary("gadget")        ← 此时 config 里的脚本路径已经存在
             └ libgadget.so 读 libgadget.config.so，起 QuickJS 跑 agent
UnityPlayerActivity.onResume()      ← smali 注入
   └ Lcom/ssmh/mod/Loader;->onResume(Activity)
        └ ModMenu.attach() → 悬浮球
```

gadget 配置（打包时写死新包名）：

```json
{"interaction":{"type":"script","path":"/data/user/0/<新包名>/files/ssmh_agent.js","on_change":"reload"},
 "log":{"level":"info","console":true}}
```

### 6.1 必须用 Frida **16.x** 的 gadget

从 **Frida 17.0.0 起，Java 桥（frida-java-bridge）不再内置在运行时里**，
脚本里 `Java.perform()` 直接 `ReferenceError: Java is not defined`（被 try 吞掉就是完全静默）。
本轮第一版用的 17.25.2，症状就是：悬浮球出来了（Java 层正常），引擎一行日志都没有。

现在 `tools/libgadget.so` 是 **16.7.19 android-arm64**（25.8 MB）。
要升 17+ 就得用 `frida-compile` 打包脚本并 `import Java from 'frida-java-bridge'`，本轮没走这条路。

### 6.2 脚本里**不要**用 JS 定时器去调 Java

`setInterval/setTimeout` 的回调跑在 gadget 自己的线程上，在 16.7.19 里：
定时器**能**正常触发（文件日志能看到），但回调里 `Java.perform(...)` 会**静默卡死**，
连 `android.util.Log` 都写不出来。所以配置/状态改成**文件桥**：

```
files/ssmh_cfg.txt     ← Java 写（开关/倍率），agent 每 300ms 读
files/ssmh_status.txt  ← agent 写（引擎状态/当前倍率），面板每秒读来显示
```

面板左上角那四行「引擎: 已注入 (14 钩子) / 信用点x.. 星币x.. … 反加x2 / 油量.. / hooks=14」
就是 agent 写回来的。

### 6.3 il2cpp 初始化之前不能碰任何 il2cpp API

脚本在 `loadLibrary` 那刻就跑，此时 `il2cpp_init` 还没执行。这时调 `il2cpp_domain_get()`
会直接 SIGSEGV；更麻烦的是**这个异常会把整个脚本运行时搞死**（定时器从此不再触发）。
所以 agent 里有硬延迟：`Date.now() - t0 < 5000` 之前一律不探测。

### 6.4 镜像名带 `.dll`

`il2cpp_image_get_name()` 返回的是 `Assembly-CSharp.dll`，不是 `Assembly-CSharp`。
第一版写死不带后缀 → 72 个 assembly 全读到了却永远匹配不上，boot 一直空转。

### 6.5 数值怎么落地

* **货币（信用点/星币/油）**：直接改 `Cryptocurrency` 的两个字段 ——
  `[0x20]` 存的是 `值 ^ mask`，`[0x28]` 是 mask。写 `hidden = 新值 ^ mask` 即可，
  **不触发 ValueChanged 事件**，所以从 JS 线程写也不会牵连 Unity UI。
* **经验**：`PlayerSkills._experience`(0x38) 是 `ObscuredLong` 七槽结构，手写会坏档，
  只能调 `ObscuredLong.GetValue/SetValue`；调用前先 `il2cpp_thread_attach(domain)` 挂到运行时。
* **数值同步**：每 300ms 读一次，和上一拍比较，增量 ×(倍率−1) 补回去。
  花钱/扣油是「减少」，默认不会被补，所以商店扣款照常扣——这是设计，不是 bug。
* **资源反加**：同一个比较，方向反过来。`cur < last` 且开关打开时，
  `cur = last + (last−cur) × (N−1)` 后写回（N = 反加返还倍率）：
  N=1 回到扣款前的值（消耗不减），N=2 扣 M 变净赚 M，以此类推。
  写回同样走 `_value ^ _mask` 直写字段 + `ObscuredLong.SetValue` 写存档镜像，
  所以退出重进数值还在。任务材料在 `RemoveResource` 里把 `amount` 取反，
  净返还 `M ×(N−1)`，与货币口径一致。
* **资源掉落倍率**：`AddResource(id, amount)` 入参 `amount × 倍率`——
  掉落、采集、任务奖励都会走这里，所以是「到手数量」的倍率；
  再叠加 `Loot._random` 的判定缩放，掉落判定和掉落量同时放大。
* **存档**：实测不需要自己写 `ResourcesData` 镜像，游戏在存档时会把
  `Cryptocurrency` 的值同步回 `ResourcesData`（见 §11 的持久化验证）。


---

## 7. 共存与签名

只改「应用标识」，**绝不动类名**（这条是像素地牢那边用命换来的经验，这里同样适用）：

1. `<manifest package="com.suisui.EventHorizon">` → `com.suisui.EventHorizon.ssmh`
2. `android:authorities` / 私有 `<permission>` / `android:permission|readPermission|writePermission` 跟着改名
3. `apktool.yml` 的 `versionName` 追加 `-ssmh`
4. `android:name`（activity/service/receiver/provider）**一律不动**

再注入自己的 dex 并用独立证书签名 → 系统视为两个应用，数据目录隔离，可以和原版同装。
`--no-coexist` 时跳过第 1 步，用于覆盖安装原版（会破坏原版数据兼容性，慎用）。

---

## 8. 流水线

```
apktool d                      → smali + res + assets + lib
meta.Metadata(...)             → 8 个 hook 目标自检（类/方法/字段）
javac --release 8 (android-stub.jar) → d8 --min-api 21 → classes.dex
build.patch_activity           → UnityPlayerActivity onCreate/onResume 插入 2 条 invoke-static
build.install_gadget           → libgadget.so + libgadget.config.so + assets/ssmh_agent.js
build.rename_manifest          → 包名/权限/authorities（不动类名）
apktool b                      → base.apk
7z a                           → 注入 classes2.dex（apktool 只产 classes.dex）
zipalign -p 4                  → 4 字节对齐
apksigner sign / verify        → v1+v2+v3
```

耗时参考（这台机器，i5-12400）：**首次全流程 30.9 s**（解包 + 元数据 + dex + 回编译 + 签名），
复用 `work/` 缓存时 **12.2 s**；成品 165.4 MB（原包 151.3 MB + gadget 37.6 MB）。

实测产物（2026-11-05，新增反加 + 掉落倍率）：

```
output/SSMH-3.12.26.4916.2.apk          161.0 MB
├─ classes.dex       3 125 756 B   原包 smali 回编译，UnityPlayerActivity 两条注入都在
├─ classes2.dex         21 260 B   自建悬浮窗 dex（com.ssmh.mod.*，6 滑杆 + 3 开关）
├─ lib/arm64-v8a/libgadget.so       25 822 568 B  Frida gadget 16.7.19
├─ lib/arm64-v8a/libgadget.config.so       174 B  指向 /data/user/0/com.suisui.EventHorizon.ssmh/files/ssmh_agent.js
├─ assets/ssmh_agent.js                 29 783 B
├─ AndroidManifest.xml  package=com.suisui.EventHorizon.ssmh
zipalign -c -p 4 通过；apksigner verify 通过（CN=ModForge）
```

---

## 9. 验证方法

**装包**

```bat
adb install -r "D:\suisui_mod\SSMH\output\SSMH-3.12.26.4916.2.apk"
```

**看引擎起没起**（最关键的一步）

```bat
:: 引擎日志（最可靠，任何线程都能写）
adb shell cat /sdcard/Android/data/com.suisui.EventHorizon.ssmh/files/ssmh_agent.log
:: 加载器日志（gadget 有没有起来）
adb logcat -d -s SSMH:V
```

正常会看到（实测输出，节选）：

```
agent script loaded
主循环已启动 (300ms)
镜像: Assembly-CSharp.dll | mscorlib.dll | ...
类解析: PlayerResources=true PlayerSkills=true Research=true Crypto=true Obscured=true Loot=true Random=true
已 attach 当前线程到 il2cpp
就绪: 钩子 14
状态: money=42662098 star=6140 token=0 fuel=100 exp=121431 cfg={"creditMul":99,"starMul":99,"expMul":99,"techMul":99,"dropMul":20,"resAdd":true,"resAddMul":2,"fuelKeep":true,"resKeep":true}
钩子命中: fuel=1 res=0 resAdd=0 addRes=0 tech=0 loot=0 rand=22204
```

看不到就按顺序查：

| 现象 | 原因 | 处理 |
|---|---|---|
| 球出来了但日志文件一直只有 1 行 | gadget 17.x 没有 Java 桥 | 换回 16.7.19（`tools/libgadget.so`） |
| 完全没有日志文件 | gadget 没加载 | `adb logcat -d \| findstr nativeloader`，确认 `libgadget.so` 真的被 dlopen |
| `boot#N 等 il2cpp 初始化: asm=-2` | 探测太早，`il2cpp_init` 还没跑 | 保持 5 秒硬延迟，别改成 0 |
| `boot#N ... imgs=72` 但一直不就绪 | 镜像名要带 `.dll` | `imageByName()` 已兼容两种写法 |
| `method 未找到: xxx` | 版本换了类/方法名 | 跑 `ssmh_cli.py <apk> --probe` 看哪一项「注意」 |
| 面板显示「引擎: gadget 加载失败 …」 | `System.loadLibrary("gadget")` 失败 | 看 `logcat -s SSMH` 里的 Loader 堆栈 |

**看功能生效**：进游戏 → 点悬浮球 → 把「信用点获取倍率」拉到 5 → 打一场拿钱，
数字应该是原来的 5 倍（面板状态行会显示当前生效倍率，日志里会出现 `补足 money: a -> b`）。
注意：**花钱仍然会扣**——获取倍率只放大「获取」，不补「消费」。

**看反加生效**（2026-11-05 新增）：

1. 面板里勾上「资源反加（油量以外的资源，消耗转增加）」，把「反加返还倍率」拖到 2；
2. 看日志里的 cfg 立刻变成 `"resAdd":true,"resAddMul":2`（300ms 内，说明文件桥是通的）；
3. 回游戏里花一次钱（商店/修理/升级），下一拍日志出现
   `反加 money: 42662098 -> 42662198 (返还 x2)` —— 扣 100 反而净赚 100；
4. `resAddMul=1` 时是「等额返还」（净 0，等于消耗不减），想只在特定场景不减就拖到 1。

**看掉落倍率生效**：「资源掉落倍率」拉高 → 战斗/采集结算时日志里 `addRes` 计数增长，
同一次入账的数量按倍率放大；`rand` 计数一直在涨说明 `Loot` 判定那条路也在拦。

**无设备时的自检**（只能验证结构，不能验证行为）：

```bat
python ssmh_cli.py <apk> --probe          :: 只解析，看 8 个目标是否 OK
python -c "import zipfile;print([n for n in zipfile.ZipFile(r'output/xxx-SSMH.apk').namelist() if 'gadget' in n or 'ssmh' in n])"
```

---

## 10. 踩坑记录

### 10.1 smali 注入不能改寄存器
`UnityPlayerActivity.onCreate` 原本 `.locals 2` 且 `p1` 被复用。注入点选在 `.locals` 之后、
用 `invoke-static {p0}` 只吃 `p0`，不动 v0/p1，避免 VerifyError。

### 10.2 gadget 必须在脚本落地之后再 load
`Loader.onCreate` 里先解 assets 到 `filesDir`，再 `loadLibrary("gadget")`。
反过来的话 gadget 启动时脚本还不存在，直接静默不加载。

### 10.3 replace 会吃掉原函数
`Interceptor.replace` 之后原函数指针就废了，功能需要「可关」的时候必须用
`Interceptor.attach` + 改参数（`args[1] = 0`），而不是 replace。

### 10.4 从 JS 线程调托管代码 = 随机崩
已改成 `Handler.postDelayed` 到主 Looper（见第 6 节）。`setInterval` 只负责投递，不碰 il2cpp。

### 10.5 `ObscuredLong` 字段间距是 0x50，不是 0x20
`ResourcesData` 里 `_money@0x18`、`_fuel@0x68` 差了 0x50；它是七槽结构
（`_value1.._value7` + `_index` + `_values[]` + `_newindex`）。
按 ACTk 老版本 0x20 的布局去猜会写坏存档。

### 10.6 7z 注入 dex 会顶掉同名 entry（真实炸过）
第一版直接 `7z a staged.apk build\dex\classes.dex`，7z 按**文件名**入包，
把 apktool 刚编出来的 3.1 MB `classes.dex` 覆盖成自己的 17 KB → 游戏壳没了，装上也点不开。

修法（`core/build.py: build_apk`）：先读现有 entry，挑一个没被占用的 `classesN.dex` 再复制改名注入。
**N 必须从 2 开始**：`classes1.dex` 系统根本不加载（第一版就是这么写成 `classes1.dex`，dex 白放）。
注入后还要回读 zip 确认 entry 真的进去了。

### 10.7 中文路径会把 aapt2 送走
`work\碎碎视界3.12...-dec` 这种目录，apktool 调 aapt2 时命令行编码对不上，报
`failed to open directory: (2)` 和一堆 `\ufffd`。工作目录一律 `safe_stem()` 成纯 ASCII。

### 10.8 复用缓存会二次加后缀
`rename_manifest` 是就地改文件，第二次跑 `manifest_package()` 读到的已经是 `xxx.ssmh`，
再拼一次就成了 `xxx.ssmh.ssmh`。`build()` 里先剥掉已知后缀再算新包名。

### 10.9 注入必须是幂等的
`patch_activity` 第一条会 `raise`（"没匹配到"），复用缓存时调用已经在里面，
于是被误判成失败。改成：已有这两条 invoke 就记「已有」，只有连方法头都找不到才报错。

### 10.10 编译缓存会把旧 dex 打进包（第一次「改完没变化」就是这么来的）
`build_dex()` 早期只看文件存在，`modsrc` 改了照样复用旧 `classes.dex`，
现象是加载器日志一行都没有、和改之前完全一样。现在按 mtime 判定，源码新于 dex 就重编。

### 10.11 Frida 17 没有 Java 桥
见 §6.1。症状：悬浮球正常，引擎静默。判断方法：`libgadget.so` 是 17.x 就一定中招。

### 10.12 gadget 脚本里的日志去哪了
`console.log` 在 Android 上是 **/dev/null**（不是 logcat），
`Java.perform + android.util.Log` 又只在「loadLibrary 那条线程」上能用。
最终方案：`new File('/sdcard/Android/data/<pkg>/files/ssmh_agent.log','a').write(...)`，
`adb shell cat` 直接看，任何线程都能写。

### 10.13 File 读文件：`readAll()` 在 16.7.19 里不是函数
`new File(path,'r')` 能开，但 `f.readAll()` 抛 `TypeError: not a function`。
改成 `typeof f.readAll === 'function' ? f.readAll() : f.readBytes(f.size)`，
再 `new Uint8Array(buf)` 逐字节转字符串（QuickJS 里 `ArrayBuffer.toString()` 得到的是 `[object ArrayBuffer]`）。

### 10.14 定时器线程 + Java = 静默死锁
同一份脚本，脚本顶部（loadLibrary 线程）调 `Java.perform` 成功；
放到 `setInterval` 回调里就再也没有下文（try/catch 也来不及写日志）。
所以所有 Java 交互（配置、状态、加载器错误）都被挪到了「Java 写文件 / agent 读文件」。

### 10.15 第一次读数时 `PlayerResources` 还没抓到
构造函数 hook 只在会话创建时跑一次，而 agent 要等 5 秒（等 il2cpp 初始化）才挂钩，
大多数启动都会错过。补了两个「UI 每帧都在调」的 getter hook
（`get_BasicCryptocurrency`、`get_Experience`），谁先到就用谁。

---

## 11. 当前验证状态（2026-11-05 00:35，真机 Redmi 23049RAD8C / Android 15）

| 项目 | 结果 |
|---|---|
| il2cpp 元数据解析（8 个目标类/方法/字段） | ✔ 全 OK（含新增的 `get_Basic_Tokens` 检查） |
| 悬浮窗 dex 编译 / classes2.dex 注入 | ✔ 21 260 B，6 滑杆 + 3 开关 |
| smali 注入（onCreate / onResume） | ✔ 成品 classes.dex 反编译核对存在 |
| gadget 加载 | ✔ `nativeloader: Load ... libgadget.so ... ok` |
| agent 启动 | ✔ 文件日志 `agent script loaded` / `主循环已启动 (300ms)` |
| 符号解析 | ✔ PlayerResources / PlayerSkills / Research / Cryptocurrency / ObscuredLong / Loot / Random 全 true |
| 钩子安装 | ✔ **14 个**（日志 `就绪: 钩子 14`，新增 `AddResource`） |
| 读数 | ✔ `money=42662098 star=6140 token=0 fuel=100`（和游戏内一致） |
| 倍率补足 | ✔ `补足 money` / 经验/星币补足在面板与日志双端一致 |
| 存档持久化 | ✔ 走正常退出存档后重启，数值保持 |
| 面板 ↔ 引擎 文件桥 | ✔ 勾「资源反加」+ 拖到 x2 后 cfg 立刻变 `"resAdd":true,"resAddMul":2` |
| 面板文字改名 | ✔ 「掉落率增加」→「资源掉落倍率」（上限 20 → 50） |
| 「资源反加」控件 | ✔ 复选框 + 返还倍率滑杆，x1 不再显示「关闭」（1 是有效值） |
| 贴边自动隐藏 | ✔ 球贴右边缘只露一半，点击可展开 |
| `TryConsumeFuel` 钩子 | ✔ 本次实机计数 `fuel=1`（进入行星系时被调用，油量 100 未减） |
| `System.Random.Next` 钩子 | ✔ 每 5s 计数增长（掉落路径的随机数发生器已拦到） |
| `AddResource` 钩子 | ✔ 已挂上；`libil2cpp.so` 里 2 处真实 `bl`（0x2196A6C / 0x2196F40，都归属 `private bool Resources(int id)`），本轮战斗无资源产出，计数仍 0 |
| `RemoveResource` 调用点 | ℹ️ 全 so 只有 1 处 `bl`，在 `ClearAll()` 里 → 玩家扣材料的调用点被内联，反加主路径是货币差额 |
| 反加·消费触发 | ⚠ 待对局：需要在商店/修理处真实花一次钱看 `反加 money: a -> b` |
| 实机「跑图掉油」「战斗结算掉落」体感 | ⚠ 未做长时间对局验证 |

## 12. 换版本怎么办

1. `python ssmh_cli.py <新apk> --probe`，看 8 个目标哪个「注意」；
2. 「注意」的项多半是方法改名/加参数：在 `core/meta.py` 的 `TARGETS` 里改候选名，
   再到 `agent/agent.js` 的 `S.targets` 同步参数个数；
3. 字段偏移（0x10/0x18/0x28、0x38、0x18）如果变了，用 Il2CppDumper 重新 dump 一次
   `dump.cs` 对照（工具：`D:\sssj_work\tools\il2cppdumper`）；
4. 出包后先看 `logcat -s SSMH` 有没有「就绪」，再逐项开功能。

已知待办：

* 「资源反加」的消费触发还没做长时间对局验证：货币路径是靠 300ms 差额采样，
  如果某次扣款和一次入账落在同一个采样周期里，看到的是净变化，反加会按净额补；
* 「资源使用时不减少」与「资源反加」同时打开时，反加优先（`resAdd` 覆盖 `resKeep`），
  面板上两条都勾着也不冲突，只是以反加为准；
* 掉落倍率目前靠 `Loot._random` + `AddResource` 两条路；战斗结算里如果有独立的
  `Random` 实例或直接写 `Cryptocurrency` 的掉落，需要再登记一条；
* 「经验倍率」写的是 `PlayerSkills._experience`，如果后续版本把经验挪进
  `Session.Content.xxxData`，得跟着换字段偏移；
* 悬浮球贴边后点击区域只有半个球宽，手指粗的可能要拖两次；
* 面板滑杆没有输入框，想精确设成 x50 只能拖到底（掉落倍率上限已从 20 提到 50）。

---

## 13. 变更记录

### 13.1 2026-11-05 · 新增「资源反加」，掉落功能改名并升级

| 项 | 之前 | 现在 |
|---|---|---|
| 面板第 7 项 | 掉落率增加（上限 20） | **资源掉落倍率**（上限 50，`AddResource` 入账数量也乘倍率） |
| 面板新增 | — | **资源反加** 复选框 + **反加返还倍率** 滑杆（1~9） |
| Config 字段 | `dropMul` | `dropMul`（语义改为掉落倍率）+ `resAdd` + `resAddMul` |
| agent 钩子 | 13 个 | 14 个（新增 `PlayerResources.AddResource`） |
| cfg 行 | 7 行 | 9 行（多 `resAdd` / `resAddMul`） |

设计口径：

* 「资源反加」只作用于**油量以外**的资源——信用点、星币、加密币走 300ms 差额补加，
  任务材料/物品走 `RemoveResource` 参数取反；油量继续用「油量不减」。
* 返还倍率 `N` 是「消耗 M 返还 M×N」：N=1 等额返还（净 0 = 消耗不减），
  N=2 扣多少反而多拿多少，N=9 就是 9 倍返还（净 8 倍）。
* 旧开关「资源使用时不减少」保留，等价于反加的 N=1 特例；两个都开时以反加为准。
* 「资源掉落倍率」= 判定更容易过（`Loot._random`）+ 到手数量翻倍（`AddResource`），
  两段叠加，所以体感比旧版「掉落率增加」强得多。
