修改像素地牢文档
这份文档是 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. 悬浮窗
- 纯代码 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 keyedgeFade,默认ModDefaults.EDGE_FADE = true)。
v1 为什么废掉(2026-11-07 当天推翻):
- 用
setScaleX/Y(0.72)做“缩小”时,屏幕边缘会多出一个小方框伪影,用户第一眼就受不了; - 「先点一下唤醒、2.5 s 内再点才开面板」,加上虚化时
ACTION_DOWN返回 false 让事件穿透给游戏 —— 点地面时游戏会自己响应,手感更乱。
v2 的结论:只改 alpha 和 margin,不缩放、不穿透、不加唤醒态;要减误触就靠“半个圆 + 变淡”,交互保持原样的一击开面板。
6. 共存原理
只改「应用标识」类的值,绝不改类名。 具体三处:
<manifest package="旧包名">→"旧包名.mfm"- provider 的
android:authorities(必须全局唯一,否则INSTALL_FAILED_CONFLICTING_PROVIDER) <permission>/<uses-permission>里声明的私有权限名,以及android:permission|readPermission|writePermission(必须跟着改,否则INSTALL_FAILED_DUPLICATE_PERMISSION,见 7.9)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 里组件是全限定名:
<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 自动重试):
- 先把所有
const-string改写成const-string/jumbo(32 位字符串索引,永远够用); - 还失败就 拆 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 确实指向我们的回调),
但三处细节叠加起来导致拖放完全静默失败:
DragQueryFileW(hdrop, 0xFFFFFFFF, …)溢出:ctypes 对未声明argtypes的函数 按 Cint传参,0xFFFFFFFF(4294967295)超出有符号 32 位 → 抛OverflowError/ArgumentError,而外层是except Exception: pass。 修:_bind_drag_apis()里给DragQueryFileW声明argtypes = [HANDLE, UINT, LPWSTR, UINT],再传0xFFFFFFFF就合法了。- 返回值句柄被截断:
GetClipboardData、GetAncestor没写restype,ctypes 默认c_int,64 位 HWND/HGLOBAL 被砍成 32 位垃圾。修:全部显式restype = wintypes.HANDLE / HWND / BOOL。 - 在窗口过程里碰 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 的两个坑(已废弃,别再写回去):
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):
apk.resolve_label_text()解析@string/NAME到res/values/strings.xml里的真文字,ApkInfo.label_text存一份;make_coexist发现原 label 是资源引用时,不再写死字符串,而是往strings.xml追加一个<string name="mf_app_name">Phoenix PD Mod</string>,清单改成android:label="@string/mf_app_name";- 原 label 是明文字面量时照旧写字符串,但过一遍
_xml_escape(&、<、"会炸 XML); - 输出文件名也用
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 层。
抓不进游戏内时的排查顺序:
adb shell pidof <包名>:有 pid 说明没崩,是卡在场景加载/等输入;adb logcat -d -s AndroidRuntime:E:为空就是没异常;adb logcat -d | findstr GLSurfaceView:出现showSoftInput一般是点了输入框, 用input text敲字 +input keyevent 66回车;- 实在摸不到按钮:
monkey -p <包名> --throttle 220 -v 70撒一遍随机点击, 之后再看 pid/崩溃日志,能区分「UI 摸不到」和「真崩了」。
10. 后续维护清单
改新版本时的顺序:
python modforge_cli.py <apk> <out>先跑一遍,看「挂点探测」是否 5 项全 OK; 探测只看方法存不存在/结构对不对,不代表锚点能对上——注入环节的SKIP才说明结构变了;- 新改版先看日志第二行
代码包: xxx。若显示「未识别」,说明五锚点不全, 那是真·非本家族或换了大结构,先tools/diag_rc.py <decoded>人工核一遍; - 如果某版本
Item.quantity(I)被重写或改名,probe会提示缺项,此时只注入能命中的部分; - 改完务必反编译成品 dex 核对注入点字节码:返回引用类型的地方必须有
check-cast(java -cp apktool.jar com.android.tools.smali.baksmali.Main dis -o out <dex>); - 装到手机后先看启动是否崩 → 球是否出现 → 面板是否响应 → 能否进游戏;
- 新增功能 = 在
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
已知待办:
- 虾哥版「魂 / 新华字典」、Shattered 4.0.2、重阳版与 RCpd 的道具不消耗,都要在游戏里实测到 「背包数量变化」(本轮只验证到「标题 → 选英雄 → 第 0 层可移动」不崩、悬浮窗与滑杆可用);
- RCpd 的金币/经验倍数还没有「看得见」的对照实验(拾取金币瞬间的入账数字),
下一步可以录屏抓
Dungeon.gold前后值; - 悬浮球贴边触发系统侧滑手势:2026-11-07 起改成「24dp 吸附 + 2dp 内距 + 贴边虚化」,虚化时触摸穿透,误触基本消失(见 5.1 / 7.16);
- 金币倍数只覆盖
Gold.doPickUp,商店卖出/奖励路径未覆盖,需要时在WndTradeItem补点。