这是什么
《商店街的外星房客:想要色色能量》(Alien Tenant Wants Horny Energy)原本是一款 Godot 4.7.2 的 Windows 游戏,exe + 7.1 GB 的 .pck 资源包,纯 GDScript(全部编译成 .gdc 字节码),GL Compatibility 渲染后端,零联网代码。
这个版本是安卓移植版:不改剧情、不改美术,把整套资源搬进 APK,并注入一层虚拟按键,让纯触摸操作可玩。
为什么不能直接搬
移植前先做了三轮检查,结论是引擎层面基本通,但有两个真问题。
| 项 | 状态 |
|---|
渲染后端 gl_compatibility | ✅ Android GLES3 原生支持 |
| 脚本纯 GDScript,无 C#/.NET | ✅ |
.ctex 贴图内部是 WebP,平台无关 | ✅ 抽查全是 RGB8/RGBA8 未压缩 |
存档全走 user:// | ✅ Android 自动映射,无写死路径 |
| Steam 扩展 | 声明了 gdextension 但 .so 一个都没打进包,游戏走 Engine.has_singleton 降级分支,安卓上表现和原版一致 |
| 7.1 GB pck 放不进 APK | ❌ 硬阻塞 |
| 3 个小游戏用键盘轮询 | ❌ 需注入输入桥 |
| Android 平台分支为零 | ⚠️ 全项目 0 处 OS.has_feature("android") |
关键发现一:APK 有 4 GiB 硬上限
Android 的 APK v2 验签器在 ApkSigningBlockUtils.findSignature 里,遇到带 ZIP64 EOCD locator 的包会直接抛 SignatureNotFoundException("ZIP64 APK not supported");apksigner 同样解析不了 ZIP64(把 central directory offset 读成 0xFFFFFFFF)。
也就是说:
- 完整 7.62 GB 的 pck 永远签不了名
- 必须先把 pck 压到 4 GiB 以下
- 而且打包工具不能写 ZIP64 —— Python 的
zipfile 对超过 2GB 的条目会自动加 ZIP64 extra field,所以最后是自己手写 zip 头(32 位字段、4 字节对齐、pck 4096 对齐、STORED 不压缩)
好消息:385 个 .ogv 视频占了 4.19 GB,而实测把它们全删掉后游戏照样启动到主菜单、零报错。所以 lite 版 = 完整 pck 去视频 + compact,从 7.62 GB 压到 3.44 GB。
代价:剧情过场动画没了。文字、立绘、语音、H 场景全部保留。
关键发现二:字节码可以反编译
脚本是 .gdc(GDSC + zstd,bytecode version 101 = Godot 4.5-stable)。GDRE Tools v2.7.4 完全支持:222/222 个脚本反编译成功、0 失败,产出 41 万行可读 GDScript 和完整 res:// 目录树。
然后是闭环验证(这才是真正的门槛):
| 环节 | 做法 | 结果 |
|---|
| 反编译 | gdre_tools --recover | 222 脚本 + 22197/22198 资源 |
.gd → .gdc | gdre_tools --compile --bytecode=4.5.0-stable | 头部与游戏内 .gdc 同构 |
| 重打包 pck | 自写 pack.js(增/删/替换/校验) | 44850 条目 md5 全对 |
| 注入 autoload | 改 project.binary(ECFG) | 游戏内 _ready 真实执行 |
反编译出的代码有个小瑕疵:无类型标注的 const 会输出成 const X: = 70。不用改,GDRE 自己的编译器认这个写法。
关键发现三:is_physical_key_pressed 能被合成事件骗过
项目里 StealthRaidGame(潜行)、WiretapRadarGame(窃听)、BathhousePeepGame(澡堂偷窥)三个小游戏用键盘轮询,触摸注入救不回来——但实测合成的按键事件能让 is_physical_key_pressed 返回 true。
所以三个小游戏全部不用改源码,纯注入桥就救回来了。这是整个项目最省事的一个发现。
输入桥做了什么
一个 ~300 行的 autoload,注入进 pck,游戏源码一行不改:
- 左下 D-pad(4 方向)+ 右下 A(Space) / B(ESC),左下角
≡ 折叠开关
- 注入的键位覆盖全部 6 个小游戏 + 全局返回:方向键一套同时喂饱
is_physical_key_pressed、event.keycode 和 get_vector("ui_*") 三种判定
- Android 返回键 → ESC → CancelStack,不再是"按一下直接杀进程"
- 多指独立跟踪(按
touch.index),按住方向的同时能按 A
- 按钮上的 Button 控件只做视觉和兜底 shield,触摸由桥按 index 自己算,透过按钮的点击不会误触底层菜单(实测点 D-pad 覆盖的"回想"不会触发)
- 幽灵化样式:默认几乎看不见,按下才变实心蓝
真机测试抓到的两个 bug
在 MuMu 12(Android 12,arm64 转译)实测,2.2 秒冷启到主菜单,[ATWHE-BRIDGE] ready buttons=6 android=true,渲染 OpenGL ES 3.2 - Compatibility - Adreno 640。抓到两个必修 bug:
| 级别 | 现象 | 根因 | 修法 |
|---|
| P0 | 返回键直接杀游戏 | quit_on_go_back 在 SceneTree 构造时一次性读入 main.cpp,autoload 的 _ready 里 ProjectSettings.set_setting 已经太晚 | 打包前把 quit_on_go_back=false 静态写进 project.binary |
| P1 | 模态弹窗开着时桥收不到触摸 | _ready() 里 get_parent().move_child(self, -1) 报 Parent node is busy setting up children,桥没抢到输入优先级,模态遮罩先 set_input_as_handled() 吃掉事件 | move_child.call_deferred(self, -1) |
两个都是小改动,改完重打一轮,返回键能回上级菜单、弹窗里按钮也能用了。
另外确认:emulate_mouse_from_touch 本来就开着,所以游戏本身的 UI 完全不用改,Android 触摸自动转成鼠标事件,桥只需要吃掉落在自己按钮上的合成鼠标事件。
三个需要知道的限制
- 过场视频没了。要保留得走 Play Asset Pack + 首启挂载,需要改引擎侧加载逻辑,这版没做。
- 本作菜单不吃方向键。用真机按键
input keyevent 20(DPAD_DOWN)在主菜单也毫无反应——游戏 UI 是纯触摸/鼠标驱动。所以 D-pad 默认隐藏,只在少数小游戏里展开。
- 没有真机验证。全程在 MuMu 12 模拟器上测(arm64 转译),冷启、渲染、按键、折叠、返回键都验过;但没有在真机(Adreno/Mali 原生、刘海屏、手势条)上跑过,按钮坐标是按 1280×720 设计空间 ×2 缩放的固定值。
怎么装
见下面"相关教程"里的安装与操作说明。简版:
- 下载 APK(3.46 GB,arm64-v8a,minSdk 24 / targetSdk 36)
adb install 或直接点安装(3.5 GB 拷贝较慢,耐心等)
- 包名
dev.atwhe.test,首次启动约 2-3 秒到主菜单
技术栈一览
| 组件 | 用途 |
|---|
| Godot 4.7.2 stable | 反编译工程、导出骨架 APK |
| GDRE Tools 2.7.4 | .gdc 反编译 / 重新编译 / pck 校验 |
自写 pack.js | pck 增删替换(新数据追加尾部,不用重排 7.6 GB) |
自写 build_apk2.py | 手写 zip 头打包(绕开 ZIP64) |
| Android SDK 34 + JDK 17 | apksigner 签名、zipalign(打包脚本内建对齐) |
全部产物与构建步骤都在本地工作目录里,可复现。