# 修改像素地牢文档

> 这份文档是 ModForge 的全部经验沉淀。以后要改任何一个「像素地牢」系（Shattered Pixel Dungeon 及其改版）的 APK，先读这里，再动 `modforge.py`。
>
> 最后更新：2026-11-07　维护者：DSH（Little Code Sauce）

---

## 0. 一句话结论

`ModForge` 是一个「拖入 APK → 自动反编译 → 列出可改属性 → 注入悬浮窗 → 打包成共存版」的桌面工具，
已经在 **Memory Pixel Dungeon 4.1.0**、**虾哥地牢 1.2.4**、**Shattered Pixel Dungeon 4.0.0 / 4.0.2**、
**Magic Ling 0.9.7.1（重阳版）**、**Reclaimed Pixel Dungeon 0.2.6（RCpd 汉化版）** 六个包上跑通，
其中 4.1.0 / 1.2.4 / 4.0.2 / 0.9.7.1 / 0.2.6 已实机验证。

> 2026-11-04 补充：**代码包不再写死**。`apk.find_game_package()` 会从 manifest 包名派生的候选
> （逐级去掉 `.indev` / `.ling.indev` / `.noelf` 这类后缀）+ 已知清单 + smali 树扫描三路去找
> 「items/Item.smali、items/Gold.smali、actors/hero/Hero.smali、HeroClass.smali、AndroidLauncher.smali」
> 五锚点齐全的那个包，再 `rules.set_base()` 喂给全部规则。加新改版不用再改源码。

---

## 1. 支持的游戏版本

| 游戏 | 包名 | 结构特点 | 结果 |
|---|---|---|---|
| Memory Pixel Dungeon v4.1.0 | `com.memorypixeldungeon` | 单 dex，`items/` 顶层有 Soul 系列 | 6 挂点全中，实机通过 |
| 虾哥地牢 1.2.4（无精灵版） | `com.xiangge.dilao.noelf` | 5 个 dex，`smali_classes2/4`，`onResume` 是 protected | 6 挂点全中，实机通过 |
| Shattered Pixel Dungeon 4.0.0 | `com.shatteredpixel.shatteredpixeldungeon` | 原版基准 | 6 挂点全中（未实机） |
| Shattered Pixel Dungeon 4.0.2 | `com.shatteredpixel.shatteredpixeldungeon` | 单 dex；**manifest 包名 == 代码包名**；`Gold.doPickUp(Hero,int)` | 6 挂点全中，实机通过（见 7.8 / 7.9） |
| Magic Ling 0.9.7.1 重阳版 | `com.ansdomagiclingpixeldungeon.ling.indev` | 4 dex（原包）；**方法数 65536/65536 顶满**；`onResume` protected；`Gold.doPickUp` 自带难度缩放 | 6 挂点全中，实机通过（见 7.10 / 7.11） |
| Reclaimed Pixel Dungeon 0.2.6 汉化版（RCpd） | `com.erebus.reclaimedpixeldungeon.indev`（**代码包** `com.erebus.reclaimedpixeldungeon`） | 4 dex；`onResume/onCreate` 都是 protected；`Hero.earnExp` 拆成 2 参数 wrapper + 3 参数实现；`Gold.doPickUp(Hero,int)` 走 float 加成 | 5 挂点全中，实机通过（见 7.12 / 9） |
| 新碧蓝档案地牢 NBPD 2.2.1 汉化版 | `com.shatteredpixel.rebluearchivepixeldungeoncn`（**代码包** `com.shatteredpixel.shatteredpixeldungeon`） | 单 dex 3913 个 smali（另有 A/a.1…z 混淆库目录）；`Gold.doPickUp(Hero,int)` 先过 `YuzuStatus.yuzuCreditMulti(Hero)F` 再 `Math.round`；`Hero.earnExp` 只有 2 参数 wrapper | 5 挂点全中；金币曾「只显示不入账」，2026-11-07 修好（见 7.15） |
| Phoenix PD 1.02 ppd beta（原版系老内核，2015） | `com.johngohce.phoenixpd` | 单 dex / 3.3 MB，底座是 Watabou noosa 而不是 libGDX；**没有 `android/AndroidLauncher`**，启动 Activity 直接是 `PixelDungeon`（`extends com.johngohce.noosa.Game` → `android.app.Activity`）；`Hero.earnExp(I)V` 单参数；`Item.quantity(I)V` 返回 void；**没有 `HeroClass.isUnlocked()`**；`android:label="@string/app_name"`，targetSdk 20 | 4 挂点中（金币/经验/道具/悬浮窗），实机通过（见 7.17 / 7.18 / 9） |

共同特征（识别条件，**包名不是固定的**）：

```
smali*/<CODE_PKG>/items/Item.smali
smali*/<CODE_PKG>/items/Gold.smali
smali*/<CODE_PKG>/actors/hero/Hero.smali
smali*/<CODE_PKG>/actors/hero/HeroClass.smali
smali*/<CODE_PKG>/android/AndroidLauncher.smali
```

`find_smali_root()` 会遍历 `smali`、`smali_classes2..N`，所以类被挪到哪个 dex 都能找到；
`<CODE_PKG>` 由 `apk.find_game_package(decoded, manifest_pkg)` 动态确定，**manifest 包名只是提示**
（改版普遍在后面挂 `.indev` / `.noelf` 之类的假后缀，而 smali 里用的是真包名）。

**原版系老内核（Phoenix PD 这类 2015 年的分支）只满足前 4 个锚点**：没有 `android/AndroidLauncher.smali`，
启动 Activity 本身就是游戏 Activity（`PixelDungeon extends com.johngohce.noosa.Game extends android.app.Activity`）。
识别仍然成立（`find_game_package` 的兜底只要求 ≥3 个锚点），但挂点规则要按老结构走：

| 挂点 | 老内核写法 | 处理 |
|---|---|---|
| 金币 | `Gold.doPickUp` 里 `sget Dungeon.gold` + `add-int/2addr` + `sput` | 与新内核一致，直接命中 |
| 经验 | `Hero.earnExp(I)V`（没有 `Class` 参数） | `patch_exp` 增加第三档签名匹配 |
| 道具 | `Item.quantity()I` / `quantity(I)V`（setter 返回 void），`detach` 仍是消耗主路径 | `detach` + `detachAll` 足够，`quantityGuard` 跳过 |
| 角色解锁 | 没有 `HeroClass.isUnlocked()` | 面板上的开关只写默认值，不改逻辑（见 7.18 末） |
| 悬浮窗 | 没有 `AndroidLauncher` | 改挂 manifest 里 LAUNCHER 那个 Activity 的 `onResume/onCreate` |

---

## 2. 工具：ModForge

```
D:\mpdwork\ModForge\
├─ modforge.py            GUI（tkinter，支持拖拽 APK）
├─ modforge_cli.py        命令行流水线
├─ 启动ModForge.bat
├─ core/
│  ├─ tools.py            工具链探测（JDK/apktool/d8/zipalign/apksigner/7z）
│  ├─ apk.py              解包、Manifest 解析、框架识别、跨 dex 找类
│  ├─ rules.py            6 条注入规则 + 幂等探测
│  └─ builder.py          javac/d8/回编译/注入 dex/对齐/签名/默认值烘焙
├─ modsrc/com/modforge/menu/
│  ├─ ModMenu.java        入口 attach(Activity)
│  ├─ FloatingPanel.java  悬浮球 + 面板（纯代码 UI）
│  ├─ ModHooks.java       所有 hook 实现
│  ├─ ModConfig.java      开关 + SharedPreferences + 球坐标
│  └─ ModDefaults.java    打包时写入的默认值
└─ tools/
   ├─ android-stub.jar    编译用的 android.jar（API 16 足够）
   └─ modforge.keystore   自签名证书（口令 modforge / 别名 modforge）
```

依赖的外部工具（固定路径，`core/tools.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` |
| 7-Zip | `D:\7-Zip\7z.exe` |
| adb | `D:\mpdwork\tools\platform-tools\adb.exe` |

用法：

```bat
:: GUI
启动ModForge.bat

:: 命令行
python modforge_cli.py "C:\path\game.apk" "D:\mpdwork\out"
```

---

## 3. 注入点全图（6 处）

| # | 类 / 方法 | 注入内容 | 作用 |
|---|---|---|---|
| 1 | `items/Gold.smali` → `doPickUp` | `add-int/2addr` **之前**过 `ModHooks.gold(I)I`，累加改走 `satAdd(II)I` | 金币倍数（见 7.15） |
| 2 | `actors/hero/Hero.smali` → `earnExp` | 方法头 `p1` 过 `ModHooks.exp(I)I` | 经验倍数 |
| 3 | `items/Item.smali` → `detach(Bag)` | `detachMode(Object)I` 三态 0/1/2 | 道具不消耗 / +1 |
| 4 | `items/Item.smali` → `detachAll(Bag)` | `keepOnAll` + `detachAllProxy` | 整堆消耗路径（魂、字典） |
| 5 | `items/Item.smali` → `quantity(I)` | `quantityGuard(Object,I)I` | 数量减少路径（拆分、扣减） |
| 6 | `actors/hero/HeroClass.smali` → `isUnlocked()` | `allHeroes()Z` 恒真 | 解锁全部职业 |
| 7 | `android/AndroidLauncher.smali` → `onResume`/`onCreate` | `ModMenu.attach(Activity)` | 悬浮窗挂载 |

### 为什么道具要三个点

不同道具的「用掉」写法不同，只拦一个会漏：

```java
// 药水 / 卷轴：走 detach(Bag)
item.detach(hero.belongings.backpack);

// 魂（WarriorSoul 等）：数量 >1 走 quantity(I)，=1 走 detachAll
if (quantity() > 1) quantity(quantity() - 1);
else detachAll(hero.belongings.backpack);

// 虾哥的字典（XinhuaDictionary.consumeOne）：两条都走
```

三个点共用同一个开关 `ModConfig.itemMode`（0 关闭 / 1 不消耗 / 2 使用后+1）。

---

## 4. 道具判定：黑名单 + 默认保留

早期版本用药水/卷轴白名单，结果各改版自造的魂、字典全被漏掉（它们直接挂在 `items/` 顶层）。

现在反过来：

> 继承链落在 `.items.` 包内 → **默认算消耗品**；命中黑名单才跳过。

黑名单：

```
.items.weapon.  .items.armor.  .items.rings.  .items.artifacts.
.items.trinkets.  .items.wands.  .items.quest.  .items.keys.
.items.bags.  .items.journal.  .items.remains.  .items.spellbook.
.items.mobform.
EquipableItem  KindOfWeapon  KindofMisc  Sheath  GammaRayGun
Amulet  Waterskin  LostBackpack  DebugItem  items.Gold\u0001
```

判定用「类 + 父类链」拼字符串（`\u0001` 分隔）后做 `contains`，并缓存到 `ConcurrentHashMap`。

注意 `items.Gold\u0001` 末尾的分隔符：避免误伤虾哥的 `items.GoldenGunpowder`。

---

## 5. 悬浮窗

* 纯代码 UI，不加任何资源、不申请 `SYSTEM_ALERT_WINDOW`；
* 挂到 `Activity.getWindow().getDecorView()`，用 `FrameLayout.LayoutParams`，不占满屏所以不拦截游戏触摸；
* 位置**只用** `leftMargin/topMargin` 作为真源，绝不用 `translationX/Y`；
* 拖动范围 clamp 在 `0 … 父宽-球宽`，`onResume` + `addOnLayoutChangeListener` 双重校正；
* 坐标存 `SharedPreferences`（`ballX/ballY`），越界自动回默认右上角；
* 面板里提供「重置悬浮球位置」。

面板项：金币倍数（1–50）、经验倍数（1–50）、道具模式（关闭/不消耗/使用后+1）、解锁全部角色、贴边只留半个圆、重置位置、关闭。

默认值在编译期写入 `ModDefaults.java`，装完即生效，不需要先进面板设置。

### 5.1 贴边只留半个圆（2026-11-07，v2）

球停在左/右边缘时既挡视线又容易被拇指误碰，所以做成「吸附 → 一半缩到屏幕外 → 变淡」：

| 状态 | 表现 | 触摸行为 |
|---|---|---|
| 常显 | alpha 0.85，48dp 圆球 | 单击开面板 / 拖动 |
| 贴边隐藏 | `leftMargin = -w/2`（左）或 `maxX + w/2`（右），屏上只剩**半个圆**；alpha 0.22 | 单击露出的半个圆照常开关面板；只有半圆区域可点，误触面积减半 |

实现要点：

* 吸附阈值 `SNAP_DP = 26`，松手时 `leftMargin <= 26dp` 或 `leftMargin >= maxX - 26dp` 就吸到 `±w/2`；
* 拖动过程允许越过边界（clamp 到 `[-w/2, maxX + w/2]`），松手才判定吸附；
* `placeBall()` 的合法性判断同步放宽到 `[-w/2, maxX + w/2]`，否则踩到边界的坐标每次 resume 都会被“纠回”屏幕内；
* 恢复方式：面板里关掉「贴边只留半个圆」（`restoreInto()` 把球拉回屏内并把 alpha 还原），或「重置悬浮球位置」；
* 开关 `ModConfig.edgeFade`（SP key `edgeFade`，默认 `ModDefaults.EDGE_FADE = true`）。

**v1 为什么废掉**（2026-11-07 当天推翻）：

1. 用 `setScaleX/Y(0.72)` 做“缩小”时，屏幕边缘会多出一个**小方框**伪影，用户第一眼就受不了；
2. 「先点一下唤醒、2.5 s 内再点才开面板」，加上虚化时 `ACTION_DOWN` 返回 false 让事件穿透给游戏 —— 点地面时游戏会自己响应，手感更乱。

v2 的结论：**只改 alpha 和 margin，不缩放、不穿透、不加唤醒态**；要减误触就靠“半个圆 + 变淡”，交互保持原样的一击开面板。

---

## 6. 共存原理

**只改「应用标识」类的值，绝不改类名。** 具体三处：

1. `<manifest package="旧包名">` → `"旧包名.mfm"`
2. provider 的 `android:authorities`（必须全局唯一，否则 `INSTALL_FAILED_CONFLICTING_PROVIDER`）
3. `<permission>` / `<uses-permission>` 里声明的私有权限名，以及
   `android:permission|readPermission|writePermission`（必须跟着改，否则
   `INSTALL_FAILED_DUPLICATE_PERMISSION`，见 7.9）
4. `apktool.yml` 的 `versionName` 追加 `-mfm`，`android:label` 加 ` Mod`

`android:name`（activity / service / receiver / provider / application）**一律不动**：
它们指向 dex 里真实存在的类，跟着包名一起替换就会指向不存在的类（见 7.8）。

`builder.rename_manifest()` 按属性白名单改写，`builder.check_components()` 在打包前
逐个校验 manifest 里的组件类是否真的能在 smali 里找到，找不到就中止。

再用独立证书签名。包名 + 签名都不同 → 系统视为两个应用，数据目录隔离。

---

## 7. 踩坑记录（每条都真实炸过）

### 7.1 VerifyError：返回引用类型必须 check-cast

`detachAll` 声明返回 `Item`，注入时用 `Object` 中转后直接 `return-object`：

```
[Caused by] VerifyError: Verifier rejected class ...items.Item:
  [0xE] returning 'Reference: java.lang.Object', but expected '...items.Item'
```

后果是整个 `Item` 类被判 erroneous，`Potion → Item`、`ExoticPotion → Potion` 全链路作废，
`Catalog.<clinit>` 一加载物品表就崩，表现为「点开游戏立刻闪退」。

**修法**：

```
move-result-object v0
check-cast v0, Lcom/shatteredpixel/shatteredpixeldungeon/items/Item;
return-object v0
```

**规则**：凡跨类型中转，`return-object` 前必须 `check-cast` 到原方法声明类型。`int`/`boolean` 不受影响。

### 7.2 protected 方法导致注入静默跳过

虾哥版的 `AndroidLauncher.onResume()` / `onCreate()` 是 **`protected`**，旧规则写死 `.method public ...`，匹配失败后 `apply_all` 只打一行 SKIP，包照样打出来——结果就是「游戏能进，但球不出现」。

**修法**：按方法名匹配（`\.method [^\n]*\bonResume\(\)V`），并把调用插在 `.locals` 声明之后，完全不依赖 `invoke-super` 的写法。

**规则**：任何 hook 都必须用正则匹配方法头，不要匹配访问修饰符；注入位置选方法头，不选 super 调用附近。

### 7.3 大方法寄存器 v15 限制

虾哥版 `Hero.earnExp` 参数落在 **v17**，`invoke-static {p1}` 只能编码 v0–v15：

```
Invalid register: v17. Must be between v0 and v15, inclusive.
```

**修法**：先转存到局部寄存器再调用：

```
move/from16 v0, p1
invoke-static {v0}, LModHooks;->exp(I)I
move-result v0
move/from16 p1, v0
```

`_ensure_locals(block, n)` 负责保证局部寄存器够用（改 `.locals` 安全，因为原方法都用 `pN` 符号引用参数）。

### 7.4 用 translation 定位导致悬浮球「飞走」

早期用 `View.setX()/setY()`（translation）定位，父容器一重新布局就和 `left/top` 错位，出界还被 `clipChildren` 裁掉 → 球消失。
**改用 `LayoutParams.leftMargin/topMargin` 作为唯一真源。**

### 7.5 detachAll 返回 null 会 NPE

`detachAll` 有 49 处调用，丢弃/交易路径会 `Dungeon.level.drop(item.detachAll(bag), pos)`，返回 null 必崩。
**改成返回同类型克隆体**（`newInstance` + 复制 quantity/identified）：
忽略返回值的调用方（魂）等于什么都没发生；使用返回值的调用方（丢弃）拿到克隆体，不崩、不双引用。

### 7.6 球贴边会被系统手势抢走

球停在左/右边缘时，拖动会触发 MIUI 的「侧滑返回」。测试时游戏直接被切后台。
**可选优化**：clamp 时给 12dp 下限，让球永远不贴边。

### 7.7 已知副作用（「不消耗」的必然代价）

* 拆分堆叠会变成**复制**（拆分本质是 `quantity(原−n)`，被拦下后原堆不变、新堆出现）；
* 丢弃消耗品丢不掉（掉在地上的是克隆体）；
* 快捷栏数字刷新有延迟，背包才是真值（实测 4 → 食用 → 背包 5 → 再食用 → 6）。

mod 关掉（面板选「关闭」）即恢复原版行为。

### 7.8 兜底替换包名会把组件类名改坏 → 装得上、点不开

Shattered 4.0.2 的 manifest 里组件是全限定名：

```xml
<activity android:name="com.shatteredpixel.shatteredpixeldungeon.android.AndroidLauncher"/>
```

早期 `make_coexist()` 是 `s.replace(old_pkg, new_pkg)` 全局替换，于是变成
`com.shatteredpixel.shatteredpixeldungeon.mfm.android.AndroidLauncher`——**这个类不存在**。

症状：

* `adb install` 成功，图标也在；
* 点开立刻回到桌面，logcat 里是
  `Error type 3: Activity class {…mfm/com.shatteredpixel…android.AndroidLauncher} does not exist`；
* `dumpsys package <新包名>` 里 Activity 名字多了一段 `.mfm`。

为什么以前的版本没炸：Memory（`com.memorypixeldungeon`）、虾哥（`com.xiangge.dilao.noelf`）
的 manifest 包名和代码包名**不同**，替换动不到 `com.shatteredpixel…` 那段，所以侥幸没事。
Shattered 系原版「包名 == 代码包名」，一碰就中。**Shattered 4.0.0 之前一直没实机验证，就是这个坑埋着。**

**修法**：只改 `package=` / `authorities` / permission 名，`android:name` 不动（见第 6 节），
并加 `check_components()` 打包前校验。

### 7.9 permission 不改名 → INSTALL_FAILED_DUPLICATE_PERMISSION

按 7.8 改成「只改 package 和 authorities」之后，装包直接失败：

```
INSTALL_FAILED_DUPLICATE_PERMISSION: Package …mfm attempting to redeclare permission
com.shatteredpixel.shatteredpixeldungeon.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
already owned by com.shatteredpixel.shatteredpixeldungeon
```

`<permission>` 声明的私有权限名必须跟着包名走，provider 上的
`android:permission` 也要跟着改，两边同步才一致。

### 7.10 dex 方法数顶到 65536：注入 5 个调用就溢出

重阳 0.9.7.1 的原始 dex：

```
classes.dex    strings=65737 types=7740  methods=65536
classes2.dex   strings=63572 types=10092 methods=65536
classes3.dex   strings=62134 types=9149  methods=65519
```

方法池正好 65536（索引 0…65535）。ModHooks 新增 5 个方法引用后，某些 `invoke-*` 的
16 位 method id 越过 65535，apktool 回编译直接失败：

```
Caused by: Unsigned short value out of range: 65543
  at DexWriter.writeCodeItem(...)
```

注意溢出值会随注入数量变化（65542 → 65543），看到这种「刚好比 65536 大一点」的数字，
就是 id 池满，不是字符串问题。

**修法（两级，`builder.build_apk` 自动重试）**：

1. 先把所有 `const-string` 改写成 `const-string/jumbo`（32 位字符串索引，永远够用）；
2. 还失败就 **拆 dex**：把被注入那个 smali 目录里的一整个子包（`sprites` → `ui` → `windows`
   …按 `DONOR_PKGS` 顺序）搬到新建的 `smali_classesN`，apktool 会把它编成新的 `classesN.dex`。

**坑中坑**：新目录必须叫 `smali_classes2` 之后的下一个编号（`smali_classes5`），
叫 `smali_classes1` 会被 apktool **静默忽略**——包照样打出来，但那些类全丢了，运行时报
`NoClassDefFoundError: …/sprites/ItemSpriteSheet`。所以 `build_apk` 末尾会对比
「smali 目录数 vs apk 内 dex 数」，不一致就告警。

### 7.11 0.9.x 系的 Gold.doPickUp 自己会重算 quantity

Shattered 4.x 是 `iget v1, p0, …Item;->quantity:I` 直接入账；
重阳这类 0.9.x 改版先在方法里按难度缩放 quantity（`mul-float 0.85f` → `iput quantity`），
然后 `sget Dungeon.gold` / `iget …Gold;->quantity:I` / `add-int/2addr` / `sput Dungeon.gold`。

写死 `Item;->quantity:I` 那个锚点会 `SKIP`（探测却显示 OK，因为探测只看方法是否存在）。
**现在 `patch_gold()` 改成结构匹配**：找到所有 `sput …Dungeon;->gold:I`，回退到紧邻的
`add-int/2addr vA, vB`（要求 `vA` 就是被 sput 的寄存器），给 `vB` 套 `ModHooks.gold()`。
两个版本通用，重阳命中 2 处（普通拾取 + 另一条分支）。

### 7.12 代码包写死 → 全挂点「未找到」（RCpd 就是这么撞上的）

`core/rules.py` 早期把 `BASE = "com/shatteredpixel/shatteredpixeldungeon"` 写死，
而 RCpd 的类实际在 `com/erebus/reclaimedpixeldungeon`，manifest 包名还多一段 `.indev`。
症状非常干净：**解包成功、包名版本都读到了，但五个挂点全变 `✘ 未找到 / 类文件缺失`**，
点生成也只会产出一个「没注入任何东西」的包。

**修法**（2026-11-04）：

```
apk.find_game_package(decoded, manifest_pkg)
  ├─ 候选 1：manifest 包名逐级截断（...indev → ...reclaimedpixeldungeon → ...erebus → com）
  ├─ 候选 2：KNOWN_CODE_PKGS 已知清单
  └─ 候选 3：扫 smali*/ 找 items/Gold.smali，反推前缀，要求五锚点里至少 3 个命中
→ rules.set_base(info.code_pkg)   # 每次解析都要调，BASE 是模块级全局
```

`probe()` / `apply_all()` 里所有类路径和方法签名（含 `L.../items/bags/Bag;`、
`L.../items/Item;` 这类参数类型）都用 `BASE` 拼，所以换包只换一个变量。
`builder.dex_relief()` 也改成用 `find_game_package()` 定位宿主 dex，不再依赖 shattered 路径。

**教训**：凡是「按包名找类」的地方都不能有字面量；改版作者最爱改的就是包名。

### 7.13 同名方法的重载：挑错一个就等于没注入

两个真实例子，都在 RCpd 上：

```
Hero.earnExp(ILjava/lang/Class;)V      ← .locals 0，只把 p1/p2/p1 转发下去
Hero.earnExp(ILjava/lang/Class;I)V     ← .locals 17，真正的实现体
Gold.doPickUp(Hero;)Z                  ← 转发
Gold.doPickUp(Hero;I)Z                 ← 真正写 Dungeon.gold
```

* `patch_exp()` 现在**优先匹配 3 参数实现体**（用 `_method_span` 按精确签名找），
  找不到才退回 2 参数 wrapper。注在实现体上只乘一次；注在 wrapper 上则所有转发路径都要
  经过它，容易和平滑叠加或漏掉直接调用实现体的地方。
* `patch_gold()` 改成 `_method_spans(s, "doPickUp")` **扫描所有重载**，只处理方法体里
  真有 `sput …Dungeon;->gold:I` 的那些；一个都没有才报「结构变化」。

`probe()` 里也顺手加了结构校验：方法在但方法体里没有 `Dungeon.gold` 写入 → 提示
「结构可能变了」，避免「探测 OK、注入 SKIP」的假阳性。

### 7.14 tkinter 拖放三连坑：为什么「拖进去毫无反应」

`modforge.py` 用 `WM_DROPFILES` + `SetWindowLongPtrW` 替换窗口过程实现拖放。
机制本身没问题（实测 `WS_EX_ACCEPTFILES` 已设、`GWLP_WNDPROC` 确实指向我们的回调），
但三处细节叠加起来导致**拖放完全静默失败**：

1. **`DragQueryFileW(hdrop, 0xFFFFFFFF, …)` 溢出**：ctypes 对未声明 `argtypes` 的函数
   按 C `int` 传参，`0xFFFFFFFF`（4294967295）超出有符号 32 位 → 抛
   `OverflowError`/`ArgumentError`，而外层是 `except Exception: pass`。
   修：`_bind_drag_apis()` 里给 `DragQueryFileW` 声明
   `argtypes = [HANDLE, UINT, LPWSTR, UINT]`，再传 `0xFFFFFFFF` 就合法了。
2. **返回值句柄被截断**：`GetClipboardData`、`GetAncestor` 没写 `restype`，ctypes 默认
   `c_int`，64 位 HWND/HGLOBAL 被砍成 32 位垃圾。修：全部显式
   `restype = wintypes.HANDLE / HWND / BOOL`。
3. **在窗口过程里碰 Tcl**：`_on_msg` 里直接调 `root.after(...)` 属于在 Tk 的消息泵内部
   重入解释器，Tcl 状态不对时直接抛错（同样被 `except` 吞掉）。
   修：窗口过程只做 `queue.Queue.put(paths)`，Tk 侧在 `_pump()` 里消费。

**验证手段**（以后改这块照抄）——不需要鼠标：

```python
# 手工构造 DROPFILES 内存块（20 字节头 + UTF-16 双 null 结尾路径），
# fWide=1，GlobalAlloc(GHND) 之后 PostMessage(hwnd, WM_DROPFILES, hdrop, 0)
# 脚本：tools/test_drop_sim.py   输出 RESULT: PASS 才算通
```

另外加了两条保底入口：**Ctrl+V**（Explorer 复制的文件以 `CF_HDROP` 在剪贴板里，
`clipboard_files()` 读取，OpenClipboard 带 20×50 ms 重试）和
**把 APK 拖到 `启动ModForge.bat` 图标上**（bat 把 `%~1` 传给 `modforge.py`，启动即载入）。
拖放通道初始化失败时，界面日志第一行会写明原因，而不是什么都不显示。

---

### 7.15 金币 hook 插在 `add-int/2addr` 之后 → 只显示、不入账（NBPD 2.2.1）

症状（用户原话）：*「显示获取了大量金钱，但实际只得到原有的金钱」*。飘字是放大后的数、面板/成就里的 `goldCollected` 也涨了，只有钱包没动。

原因在 `core/rules.py` 的 `patch_gold()`：它找到喂给 `sput Dungeon.gold` 的那条 `add-int/2addr vA, vB`，却把 `ModHooks.gold()` 插在了**这条 add 之后**：

```smali
sget v1, Dungeon;->gold:I
add-int/2addr v1, v0            # v1 = 钱包 + 原值   ← 已经加完了
invoke-static {v0}, ModHooks;->gold(I)I   # 这时才放大 v0，没人用了
move-result v0
sput v1, Dungeon;->gold:I      # 写回的是没放大的和
```

下游 `Statistics.goldCollected` 和飘字用的是放大后的 `v0`，所以「看起来赚翻了」。Shattered 4.0.2 上没炸，是因为那里 `Gold.doPickUp` 的 add 只喂钱包一处、且当时没做端到端对照（第 10 节那条待办就是它）。

**修法（2026-11-07）**，`patch_gold()` 现在把整条 add 替换掉，而不是在它后面追加：

```smali
sget v1, Dungeon;->gold:I
invoke-static {v0}, ModHooks;->gold(I)I
move-result v0
invoke-static {v1, v0}, ModHooks;->satAdd(II)I   # 饱和加法
move-result v1
sput v1, Dungeon;->gold:I
```

顺带解决溢出：`Dungeon.gold` 是 int，x50 倍率 + 长时间刷图会翻成负数（钱包变 -21 亿）。`ModHooks.satAdd(cur, add)` 封顶 `999999999`，`ModHooks.gold()` 单次也按同一上限裁。

**规则**：
* 任何「放大某个值再累加」的 hook，插入点必须在**累加指令之前**，插在后面只能影响后续读者；
* 改动注入顺序后必须打印成品方法体验证，不能只看 `[OK]`；
* 涉及 int 累加的量（金币、经验、层数）一律走饱和加法，不走裸 `add-int`。

寄存器限制仍然有效：只有 `src`、`dst` 的绝对索引都 ≤ 15（`p*` 按 `.locals` 折算）才用 `satAdd` 双参版本，否则退回「只放大、保留原 add」的老写法，避免 `invoke-static` 编不出寄存器号。

### 7.16 悬浮球挡住地牢格子 → 贴边只留半个圆

`decorView` 上浮着一个 48dp 的球，手指点地图时只要落在球上，`OnTouchListener` 就把 DOWN 吃掉并 `togglePanel()`——地牢里「点空地移动」是高频操作，误触一次就弹面板、丢一回合。

v2 修法（2026-11-07，当天推翻 v1）：贴边（≤26dp）吸附后 `leftMargin` 直接设成 `-w/2`（左）或 `maxX + w/2`（右），**半个圆缩到屏幕外**，同时 alpha 降到 0.22；单击照常开面板，交互一步到位。详见 5.1。

v1 的两个坑（已废弃，别再写回去）：

* `setScaleX/Y(0.72)` 想在原位缩小，结果屏幕边缘出现一个**小方框**伪影，用户第一反应就是「这什么鬼」；
* 虚化时 `ACTION_DOWN` 返回 `false` 让事件穿透 —— 点子正好落在球上时游戏会替你响应（弹 tooltip / 高亮格子），比误触还乱；
* 顺带还多了个「先点一下唤醒、2.5 s 内再点才开面板」的二段交互，玩起来最难受的就是它。

**结论**：只动 `alpha` 和 `margin`，不缩放、不穿透、不加中间态；减误触靠「半个圆 + 变淡」本身。

### 7.17 `android:label` 是资源引用时，直接写成字符串 → aapt2 回编译必挂（Phoenix PD，2026-11-14）

Phoenix PD 的清单里是 `android:label="@string/app_name"`，不是明文字面量。
`make_coexist()` 原来一句 `re.sub(r'android:label="[^"]*"', 'android:label="%s"' % new_label, ...)`，
而 `new_label` 来自 `info.label + " Mod"` = **`@string/app_name Mod`** —— 这已经不是一个合法资源名了：

```
W: AndroidManifest.xml:6: error: resource string/app_name Mod
   (aka com.johngohce.phoenixpd.mfm:string/app_name Mod) not found.
W: error: failed processing manifest.
Exception in thread "main" brut.androlib.exceptions.AndrolibException: ...
RuntimeError: apktool 回编译失败
```

现象是「前面 8 步全绿、注入全 OK，卡在最后回编译」，很容易误判成注入把 smali 改坏了。
**修法**（`builder.py`，2026-11-14）：

1. `apk.resolve_label_text()` 解析 `@string/NAME` 到 `res/values/strings.xml` 里的真文字，`ApkInfo.label_text` 存一份；
2. `make_coexist` 发现**原 label 是资源引用**时，不再写死字符串，而是往 `strings.xml` 追加一个
   `<string name="mf_app_name">Phoenix PD Mod</string>`，清单改成 `android:label="@string/mf_app_name"`；
3. 原 label 是明文字面量时照旧写字符串，但过一遍 `_xml_escape`（`&`、`<`、`"` 会炸 XML）；
4. 输出文件名也用 `label_text` —— 否则文件名会变成 `string_app_name_Mod_coexist.apk`。

### 7.18 targetSdk < 24 在 Android 14+ 一律拒装；多 dex 还要求 minSdk ≥ 21（Phoenix PD，2026-11-14）

Phoenix PD 是 2015 年的包：`sdkVersion 9 / targetSdkVersion 20`。签好名也装不上：

* Android 14（API 34）起拒绝 `targetSdk < 23`；
* **Android 15（API 35）起拒绝 `targetSdk < 24`**，报 `INSTALL_FAILED_DEPRECATED_SDK_VERSION`；
* 本工具把 mod 放在 `classes2.dex`，原生 multidex 从 **API 21** 才有，minSdk 9 的包在旧机上 mod 会静默不生效。

`builder.bump_sdk()`（回编译前跑）按需抬：

| 项 | 规则 | 取值理由 |
|---|---|---|
| `targetSdkVersion` | < 24 时抬到 **28** | 28 是「能装」和「保留 legacy 存储」的分界；≥29 就是分区存储，老分支的存档路径可能直接失效 |
| `minSdkVersion` | < 21 时抬到 **21** | `classes2.dex` 的 multidex 门槛 |

同时做了两件配套事：`apksigner --min-sdk-version` 改用**包里真实声明的 minSdk**（写死 21 会给 minSdk 9 的包
签出 SHA-256 的 v1 签名，`apksigner verify` 直接判 `DOES NOT VERIFY`，报
`... digest algorithm SHA-256 ... not supported on API Level(s) 9-17`）；`Patch_launcher` 在没有
`AndroidLauncher.smali` 时改挂 LAUNCHER Activity（见 §1 老内核表）。

**已知不生效项**：Phoenix PD 没有 `HeroClass.isUnlocked()`，面板里的「解锁全部角色」只把默认值写进 dex，
逻辑上没挂点；这个分支的职业本来就靠尸体继承解锁，不是开关能改的。

---

## 8. 构建流水线

```
apktool d            → smali + res + assets + lib
rules.apply_all      → 7 处 smali 注入（幂等，检测到已注入会跳过）
builder.make_coexist → 包名 / authorities / 私有权限 / label / versionName（不动类名）
builder.check_components → manifest 里的组件类必须都能在 smali 找到，否则中止
builder.prepare_src  → 复制模板 + 写入 ModDefaults
javac --release 8    → modsrc → .class        (android-stub.jar)
d8 --min-api 21      → .class  → classes.dex
apktool b            → 回编译 base.apk
  └ 失败且报 "Unsigned short value out of range"
       → sanitize_const_string_jumbo()   const-string → /jumbo
       → dex_relief()                    抽一个子包到新 smali_classesN，最多重试 3 次
7z a                 → 注入 classesN.dex（自动挑空槽位）
zipalign -p 4        → 4 字节对齐
apksigner sign       → v1+v2+v3 签名
apksigner verify     → 校验
```

打包时间：Memory 版约 25–45 秒，Shattered 4.0.2 约 30 秒，虾哥版（138 MB）约 50–60 秒，
重阳版（128 MB，含拆 dex 重试）约 135–140 秒。

---

## 9. 实机验证记录

设备：Redmi `23049RAD8C`，Android 15（SDK 35），1080×2400，440 dpi。

| 项目 | Memory 4.1.0 | 虾哥 1.2.4 | Shattered 4.0.2 | 重阳 0.9.7.1 | RCpd 0.2.6 |
|---|---|---|---|---|---|
| 安装启动 | ✔ 无崩溃 | ✔ 无崩溃 | ✔ 无崩溃 | ✔ 无崩溃 | ✔ 无崩溃（`classes5.dex` 注入） |
| 悬浮球显示 | ✔ | ✔（修复 7.2 后） | ✔ | ✔ | ✔ |
| 面板展开 / 滑杆 | ✔ | ✔ | ✔（x3 默认，滑杆有效） | ✔（拉到 x49 生效） | ✔（x3 默认，拉到 x50 生效） |
| 拖动 / 持久化 / 重置 | ✔ | ✔ | ✔ | ✔ | ✔（force-stop 冷启动后仍在） |
| 道具使用后 +1 | ✔ 浆果 4 → 5 → 6 | 待测 | 待测 | 待测 | 待测 |
| 解锁全部角色 | ✔ 12 职业全亮 | ✔ 12 职业全亮 | ✔ 6 职业可选 | ✔ 自定义英雄全可选 | ✔ 6 英雄可选 |
| 进入游戏内 | ✔ | ✔ | ✔ 标题→选英雄 | ✔ 第 0 层城镇可移动 | ✔ 第 0 层可移动、可交互、无崩溃 |

**Phoenix PD 1.02（2026-11-14，实机）**：成品 `Phoenix_PD_Mod_coexist.apk`（3.24 MB，2 dex，
包名 `com.johngohce.phoenixpd.mfm`，title `Phoenix PD Mod`，targetSdk 28 / minSdk 21）。
`adb install` 一次通过，`am start` 后进程存活、logcat 无 FATAL，Welcome 页右上角悬浮球在位、点开面板
四项开关正常（金币 ×3 / 经验 ×3 / 道具不消耗 / 贴边半圆）。反编译核对：
`items/Gold.smali:141 ModHooks.gold`、`actors/hero/Hero.smali:6008 ModHooks.exp`（`.locals 10`，
`p1=exp` 原位改写）、`items/Item.smali:1060 detachMode`、`PixelDungeon.smali:839 ModMenu.attach(p0)`。
`apksigner verify -v`：v1 / v2 / v3 全 true。

注入点落地校验（对**最终打出来的 APK** 反编译核对，2026-11-04）：

```
Shattered 4.0.2  classes.dex  Gold.doPickUp→ModHooks.gold，Item→detachMode/keepOnAll/
                              detachAllProxy/quantityGuard，Hero→exp，HeroClass→allHeroes，
                              AndroidLauncher→ModMenu.attach
重阳 0.9.7.1      classes2.dex 同上，Gold 命中 2 处
RCpd 0.2.6       classes2.dex  ModHooks（Gold/Item/Hero/HeroClass 全在 smali_classes2）
                 classes4.dex  ModMenu（AndroidLauncher 在 smali_classes4）
                 classes5.dex  ModForge 自己的 dex（7z 挑的空槽位）
```

RCpd 出包实测数据：原包 37.7 MB / 4 dex，成品 32.3 MB / 5 dex，`zipalign` + `apksigner verify`
通过，整条流水线 **35.5 秒**。成品内 `manifest` 的包名是
`com.erebus.reclaimedpixeldungeon.indev.mfm`（AXML 是 UTF-16 字符串池，用 `b"mfm"` 搜不到，
要搜 `"mfm".encode("utf-16-le")`），标题页右上角版本号会显示 `v0.2.6-zhcn2-mfm`，
这行字就是「装的是改版」最省事的肉眼确认。

`detachAll` 的 `check-cast` 在成品 dex 里确认存在（7.1 那条不会再犯）。

**NBPD 2.2.1（2026-11-07，未接设备）**：成品
`ReBlueArchive_Mod_edgehide.apk`（32.15 MB，2 dex）反编译核对——
`classes.dex` 的 `Gold.doPickUp` 是 `sget Dungeon.gold` → `ModHooks.gold` → `ModHooks.satAdd` → `sput`，
八个 hook 调用点各 1 处；`classes2.dex` 含 `ModHooks.satAdd` / `GOLD_CAP=0x3b9ac9ff` /
`FloatingPanel.ALPHA_HIDDEN=0.22` / `ALPHA_NORMAL=0.85` / `SNAP_DP=0x1a(26)` /
`snapAndHide` 里的 `div-int/lit8 …0x2` + `neg-int`（半个圆出屏）/
`restoreInto`（关掉开关拉回屏内）；`setScaleX`、`wakeBack`、`postDelayed` 计数均为 **0**，
`FloatingPanel$3.onTouch` 的 DOWN 分支正常 `return true`（无穿透）；
`apksigner verify` 通过。实机验证待补（本轮 `adb devices` 为空）。

设备连接注意事项：

* `adb shell input tap` 需要开发者选项里的 **「USB 调试（安全设置）」**，否则报
  `SecurityException: INJECT_EVENTS permission`；
* 开了之后 `input tap/swipe` 都能用，坐标按 **实际分辨率**（1080×2400）算，不要用缩略图坐标；
* 摸不到按钮位置时先 `adb shell uiautomator dump` 取 `bounds`，比自己估坐标可靠
  （`input tap` 落点偏 100 px 就会点到隔壁按钮）；
* 装大包偶发 `INSTALL_FAILED_USER_RESTRICTED: Install canceled by user`，
  先 `input keyevent 3` 回桌面再 `adb install -r` 即可；
* MIUI 会限制同一次会话里的连续安装，失败就回桌面重试；
* 抓崩溃：`adb logcat -d -s AndroidRuntime:E`。

分辨率差异：

* Shattered 4.0.2 在 20:9（1080×2400）上镜头比 4.0.0 更「放大」，选英雄界面底部一排头像会被
  屏幕下沿裁掉一部分；`wm size 1080x1920` 后布局比例正常。这是 4.0.2 自己的镜头取值，
  与注入无关——同一 APK 只改包名、不注入 hook 的对照组（`control_build.py`）在标题页
  的标题/正文/按钮位置与改版逐像素一致。测完记得 `wm size reset`。
* RCpd 0.2.6 的英雄详情页同理：底部「开始」按钮贴着屏幕下沿，`uiautomator dump` 抓不到
  （libGDX 全在一个 GLSurfaceView 里画），点击后画面几秒内不变是正常现象——它会在后台
  切场景，logcat 里出现 `showSoftInput() view=…GLSurfaceView20` 就说明点击已生效、正在加载。
  实测点一次「开始」→ 等 1 秒 → `am start` 同包可确认进程仍存活，再点「继续」即可进第 0 层。

抓不进游戏内时的排查顺序：

1. `adb shell pidof <包名>`：有 pid 说明没崩，是卡在场景加载/等输入；
2. `adb logcat -d -s AndroidRuntime:E`：为空就是没异常；
3. `adb logcat -d | findstr GLSurfaceView`：出现 `showSoftInput` 一般是点了输入框，
   用 `input text` 敲字 + `input keyevent 66` 回车；
4. 实在摸不到按钮：`monkey -p <包名> --throttle 220 -v 70` 撒一遍随机点击，
   之后再看 pid/崩溃日志，能区分「UI 摸不到」和「真崩了」。

---

## 10. 后续维护清单

改新版本时的顺序：

1. `python modforge_cli.py <apk> <out>` 先跑一遍，看「挂点探测」是否 5 项全 OK；
   **探测只看方法存不存在/结构对不对，不代表锚点能对上**——注入环节的 `SKIP` 才说明结构变了；
2. 新改版先看日志第二行 `代码包: xxx`。若显示「未识别」，说明五锚点不全，
   那是真·非本家族或换了大结构，先 `tools/diag_rc.py <decoded>` 人工核一遍；
3. 如果某版本 `Item.quantity(I)` 被重写或改名，`probe` 会提示缺项，此时只注入能命中的部分；
4. 改完务必反编译成品 dex 核对注入点字节码：**返回引用类型的地方必须有 `check-cast`**
   （`java -cp apktool.jar com.android.tools.smali.baksmali.Main dis -o out <dex>`）；
5. 装到手机后先看启动是否崩 → 球是否出现 → 面板是否响应 → 能否进游戏；
6. 新增功能 = 在 `core/rules.py` 加一条 `Rule`（相对路径 + 方法名正则 + 插入片段），GUI 会自动多一行属性。

**新改版上手三步**（各 10 秒，不要跳）：

```
1) tools/diag_rc.py <decoded>              # 每个锚点的签名原样打出来，看清重载和返回类型
2) 确认 Hero.earnExp / Gold.doPickUp 有几个重载，挑「真正干活的那个」注入
3) 注入后立刻 grep 成品 smali：ModHooks;-> 调用点数量 = 6 才算齐；
   再打印一遍 `Gold.doPickUp` 方法体，确认 `gold()` / `satAdd()` 出现在 `sput Dungeon.gold` **之前**（7.15 就是这么漏的）；
```

新包上手前的三个体检（都在 CLI 里自动做了）：

| 检查 | 触发症状 | 现成实现 |
|---|---|---|
| manifest 组件类是否存在 | 装得上、点图标秒退回桌面 | `builder.check_components()` |
| 私有 permission 是否跟着改名 | `INSTALL_FAILED_DUPLICATE_PERMISSION` | `builder.rename_manifest()` |
| 原始 dex 的方法/字段/字符串数 | `Unsigned short value out of range: 6554x` | `builder.dex_relief()` + `sanitize_const_string_jumbo()` |

查原始包 id 池大小（决定要不要预留拆 dex）：

```python
import zipfile, struct
z = zipfile.ZipFile(APK)
for n in [x for x in z.namelist() if x.endswith(".dex")]:
    b = z.read(n)
    print(n, [struct.unpack_from("<I", b, o)[0] for o in (0x38, 0x40, 0x48, 0x50, 0x58)])
    # strings / types / protos / fields / methods
```

已知待办：

* 虾哥版「魂 / 新华字典」、Shattered 4.0.2、重阳版与 RCpd 的道具不消耗，都要在游戏里实测到
  「背包数量变化」（本轮只验证到「标题 → 选英雄 → 第 0 层可移动」不崩、悬浮窗与滑杆可用）；
* RCpd 的金币/经验倍数还没有「看得见」的对照实验（拾取金币瞬间的入账数字），
  下一步可以录屏抓 `Dungeon.gold` 前后值；
* 悬浮球贴边触发系统侧滑手势：2026-11-07 起改成「24dp 吸附 + 2dp 内距 + 贴边虚化」，虚化时触摸穿透，误触基本消失（见 5.1 / 7.16）；
* 金币倍数只覆盖 `Gold.doPickUp`，商店卖出/奖励路径未覆盖，需要时在 `WndTradeItem` 补点。
