修改像素地牢文档

这份文档是 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

用法:

:: 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) 悬浮窗挂载

为什么道具要三个点

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

// 药水 / 卷轴:走 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. 悬浮窗

面板项:金币倍数(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 单击露出的半个圆照常开关面板;只有半圆区域可点,误触面积减半

实现要点:

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 已知副作用(「不消耗」的必然代价)

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

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

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

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

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

症状:

为什么以前的版本没炸: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

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() 里消费。

验证手段(以后改这块照抄)——不需要鼠标:

# 手工构造 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 之后:

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 替换掉,而不是在它后面追加:

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 的两个坑(已废弃,别再写回去):

结论:只动 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。签好名也装不上:

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 为空)。

设备连接注意事项:

分辨率差异:

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

  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):

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

已知待办:

generated by cloud_sync · 2026-11-14 03:02