
在《合金装备》那篇文章中,我展示了自己围绕 XIAO ESP32S3 Sense 打造的掌机。《恶魔城:月下夜想曲》(SOTN)是它运行的另一款 PlayStation 游戏,也是更早跑起来的那一款:在掌机诞生之前,它就已经在 Waveshare ESP32-S3 开发板上以 60 fps 运行,后来才迁移到掌机上。
它能跑起来的原因和《合金装备》一样。SOTN 的社区反编译项目可以编译为可移植的 C 代码,因此这款游戏可以被编译到 ESP32-S3 上,而不是通过模拟运行。
掌机上的实机演示:在 YouTube 上观看。
本文将介绍移植的整体结构、游戏如何塞进 SoC 的内存、软件光栅化器如何达到 60 fps、原版硬件一直掩盖着的那些 bug,以及我围绕 Seeed XIAO ESP32S3 Sense 打造的一台掌机。
为什么原生移植是可行的
在 ESP32-S3 上模拟 PlayStation 是不现实的。模拟器必须逐条解释 MIPS CPU 和 GPU 指令,所需的性能是这颗 SoC 的好几倍。
移植则不同。SOTN 反编译项目已经将游戏重建为可编译成 PC 可执行文件的 C 源码。游戏逻辑就是普通的 C 代码,唯一把它和主机绑在一起的是索尼的 SDK:那些绘制多边形、上传纹理、读取手柄和播放声音的库调用。PC 版本用 psyz 替换了这套 SDK,后者是基于 SDL 的重新实现。
因此,这项工作分为三部分:
- 为 Xtensa 编译游戏。
- 用面向该 SoC 的后端替换 psyz 的 PC 后端。
- 让所有东西都塞得下。
目标 SoC 是 ESP32-S3:
- 两个 240 MHz 的 Xtensa LX7 核心。
- 512 KB 内部 SRAM。
- 8 MB 八线 PSRAM(外部,速度慢得多)。
- 第一块开发板有 16 MB flash,掌机上是 8 MB。
- 没有 GPU。
架构
从游戏到硬件的各层:
- 游戏:反编译的引擎、玩家与武器代码、关卡
- psyz:SDK 重新实现,使用 PSYZ_RENDERER=soft
- 平台层(esp32/main):VSync、根计数器、输入、FAT 文件、音频、LCD 扫描输出
- ESP32-S3 硬件:LX7 核心、SRAM、PSRAM、flash、SPI LCD
- 游戏:反编译的引擎(
src/dra)、玩家与武器代码,以及每个城堡区域(“关卡”)。除了 bug 修复之外,这些代码保持不变。 - psyz:SDK 的重新实现。我为它添加了第三种渲染器,
PSYZ_RENDERER=soft。它是一个数据包解析器加上一个不依赖 SDL 的光栅化器核心,因此同一个文件既能在 Windows 上编译(用于与 GPU 参考实现对比测试),也能在 SoC 上编译。 - 平台层(
esp32/main):以 59.94 Hz 进行 VSync 节拍控制、驱动声音音序器的根计数器、手柄输入、通过 FAT 访问文件、在第二个核心上进行音频混音,以及 LCD 扫描输出。
有两个设计决策决定了其余的一切。
VRAM 保持为游戏自己的缓冲区。 PlayStation 有 1 MB 显存,即 1024×512 个 16 位色像素。反编译项目已经把它建模为一个普通数组。光栅化器直接绘制到其中(位于 PSRAM),LCD 也从它取数据。没有影子副本,除了最后转换为面板的 RGB565 之外也没有格式转换。
关卡采用静态链接。 在 PC 上,每个城堡区域都是按需加载的 DLL。ESP32-S3 没有动态链接器,因此每个区域都被编译进固件,并用一张小表将关卡名映射到其初始化函数。这个决定两次引发了问题,详见下文“PlayStation 所宽容的 bug”和“添加一个关卡”两节。
把一款 PlayStation 游戏塞进 512 KB

第一次链接时,内部 RAM 超出了 3.34 MB。其中大部分并不是游戏本身的问题:
g_TileDefDataPool被声明为TileDefinition[0x40][4][0x1000]。在 32 位指针下,那是 16 到 32 MB,而实际数据只有约 1 MB。把它重新声明为字节数组,解决了最大的一项。- 声音库在栈上分配了一个 512 KB 的数组。这在 PC 上没问题;在单片机上会立刻崩溃。
- 那些只是偶尔被访问的大缓冲区,用
EXT_RAM_BSS_ATTR移到了 PSRAM。 - 只读表被标记为
const,从而放入 flash。
最后一条有个陷阱:有些“只读”数据其实会被写入。当打开一个音色库时,SDK 会就地更新声音库头部。如果声明为 const,它们就位于 flash 中,而通过缓存写入 flash 会触发硬件错误(“Dbus write to cache”)。可行的做法是:在 flash 中保留一份 const 主副本,启动时复制到 PSRAM 缓冲区,让游戏写入那份副本。
经过 28 轮链接之后,数字如下:
- 内部 RAM 中 207 KB 已初始化数据。
- 内部 RAM 中 43 KB 零初始化数据。
- 55 KB IRAM 代码。
- PSRAM 中 3 MB 静态数据。
- 运行时约 100 KB 空闲内部堆。
软件光栅化器,以及如何达到 60 fps

SOTN 是一款 2D 游戏,其图元种类很少:带纹理和 Gouraud 着色的四边形、用于图层的 16×16 精灵、矩形和线段。没有 3D,也没有几何变换引擎,这使得软件光栅化器成为现实可行的方案。
光栅化器与 GPU 参考构建逐位一致。每次优化之后,我都会对比 PC 构建与开发板的帧校验和。
大部分帧时间都花在了内存访问上。 每个带纹理的像素都要从 PSRAM 读取一个纹素和一个调色板项,并向 PSRAM 写入一个像素。真正起作用的优化都在减少这些流量:
| 步骤 | 结果 |
|---|---|
| 三角形边缘步进:先除法,再 64 位累加器,最后 32 位 16.16 | 在 32 位核心上 64 位反而更慢;最终采用 16.16 |
| 扫描输出移到核心 1,由 notify 驱动 | 游戏再也不必等待 SPI |
| 内部 RAM 中的调色板缓存,IRAM 中的光栅循环 | Warp Room 达到 31 fps |
| 图层快速路径:每次读取 4 个纹素,像素对以 32 位存储写入 | 每帧从 10.4 ms 降到 6.1 ms |
| 从游戏刚刚画完的缓冲区进行扫描输出 | 无需拷贝,延迟减少一帧 |
| PSRAM 和 flash 从 80 MHz 提升到 120 MHz | 每帧从 19.4 ms 降到 16.4 ms:60 fps |
我还尝试过在三角形光栅化器中加入纹理行缓存,后来回退了。它没有任何收益,因为 GCC 已经把这些加载提升到了循环外。
从已完成缓冲区进行扫描输出这一改动需要解释一下。显示内容最显而易见的来源是 SDK 当前的显示区域,但它会滞后一帧:在 VSync 时,它指向的是游戏即将绘制的缓冲区。从它扫描输出意味着 DMA 和光栅化器整帧都在争抢 VRAM 的同一半。改为从游戏自己的缓冲区结构体中读取显示原点,就能让两者在结构上分别处理不同的两半。
PlayStation 会原谅的那些 bug
PlayStation 没有内存保护。一次越界读取会返回那里恰好存在的内容,一次越界写入会落在通常没人检查的内存里。PC 版构建也大多能掩盖这些 bug,因为它的静态数据区又大又宽容。而 ESP32-S3 有 MMU、内存紧张,还有真正要紧的邻居,所以移植到它上面,结果成了找出潜在 bug 的绝佳方式。

我是怎么找到它们的
panic 回溯会指出受害者,而不是元凶。真正管用的工具是通过 SoC 内置 USB-JTAG 运行的 OpenOCD,配合 GDB:
- 通过 OpenOCD 复位并暂停 SoC。
- 在 panic 处理函数上设置断点。
- 命中后,从 panic 帧中解码出真正的 PC。
- 始终对照烧录时所用的那个确切的 ELF 来解码,因为每次重新构建地址都会变化。
越界的调色板动画
炼金研究所请求调色板动画 (tileset & 0xFF) + 0x7FFF | 0x4000,这会索引该关卡调色板表中的第 2 项。而该表只有一项。这次越界读取落在了相邻的精灵库表上,随后该表被注册为调色板动画描述符,并且每帧都被写入。
修复方法是拒绝那些范围超出调色板缓冲区的描述符。同样的未定义行为在上游也存在;PC 版靠一大块 BSS 把它吸收掉了。
两个关卡,一个 HitDetection
由于关卡是静态链接的,有 122 个全局符号在不止一个关卡中被定义。共享头文件实现了碰撞和实体更新之类的东西,而在 PlayStation 上,任何时候只会加载一个关卡。静态归档让链接器悄悄地把每个名字解析到某一个定义上。炼金研究所运行的是 Warp Room 的 HitDetection,却拿它去操作自己的实体表,于是损坏在更后一层才显现出来。
修复方法是生成一份列出两个关卡共享的每个符号的清单,并用 -D 定义为每个关卡重命名。
会改动自身地图的房间
门和可破坏的墙会写入房间的瓦片地图。由于生成的地图位于 flash 中,第一扇门就让游戏崩溃了。修复方法是设置一个单一入口点,把当前房间的前景层复制到一块可写的 PSRAM 缓冲区中。
敌人名字

有了妖精卷轴遗物后,游戏会打印你击中的敌人的名字。BottomCornerText 会扫描字符串以寻找 PlayStation 的 FF 00 终止符。PC 字符串是普通的 C 字符串,所以扫描会越过末尾,并覆盖一个 64 字节的栈缓冲区。症状就是“我一碰到敌人它就卡死”。修复方法是给扫描加上边界。
一个在 PlayStation 上不存在的竞态
音频运行在第二个核心上,而音频拉取会推进根计数器。这些计数器会触发游戏的 VSync 处理函数、纹理上传和 GPU 队列回调。在 PlayStation 上,这些是同一颗 CPU 上的中断。而在这里,它们与另一个核心上的游戏并发运行。
修复方法是在核心 1 上累积计数器 tick,并在游戏线程上触发处理函数。
从未到达的输入
反馈是“我没法跳”。这是三个 bug 叠加在一起:
- USB 串口在 DTR 为低时会丢弃传入的字节,而我的键盘脚本一直让它保持低电平,以避免复位开发板。
- 控制台被配置为使用 UART,而线缆却接在原生 USB 上。
- 一个未接线的按钮引脚被读成永久按下,这让 CROSS 键一直处于按下状态。
掌机:在 XIAO ESP32S3 Sense 上的硬件调试



目标是做一台掌上游戏机。它和后来运行《合金装备 索利德》的是同一套硬件:SOTN 是它上面跑的第一款游戏。零件如下:
- Seeed XIAO ESP32S3 Sense:同样是 ESP32-S3 SoC,带 8 MB PSRAM、8 MB flash,其扩展板上还有一个 microSD 卡槽。
- ILI9341 320×240 SPI 面板:PlayStation 的原生分辨率,因此无需缩放。
- 一个模拟摇杆,取自无人机遥控器。
- 八个按钮。
XIAO 有十一个可用引脚。面板需要五个(SCK、MOSI、MISO、CS、DC)外加一条复位线,摇杆需要两个 ADC 引脚。这样只剩下三个引脚给八个按钮。
其中六个按钮通过一个电阻梯形网络共用一个 ADC 引脚:一个 10k 上拉电阻,以及每个按钮对应一个接地电阻(0 Ω、2k2、4k7、10k、22k、47k)。每个按钮会产生不同的电压。局限在于,同时按住两个按钮会被读成较低的那个。每款游戏会同时按的那一对(SOTN 中的攻击和跳跃)则接到剩下的两个专用引脚上。
完整的接线图、零件清单和按钮映射都在仓库的硬件指南中。
硬件调试本身就是一连串教训。在碰游戏之前,我先写了一个单独的测试固件,用来绘制彩条,并在屏幕上显示每个按钮和摇杆的状态:
- 面板是白屏,而且按按钮时还会变暗。 它没有接地线,所以它通过数据引脚上的保护二极管给自己供电。加上 GND 后立刻就好了。
- 面板其实是 ILI9341,而不是我原本计划的 ILI9488。 这是另一种控制器,像素格式也不同(SPI 上是 RGB565,而不是 RGB666)。它的驱动没有内置在 ESP-IDF 中,需要从组件注册表获取。
- 一条真正的复位线很重要。 当 RESET 被拉高、只靠软件复位时,面板无法可靠启动。
- 四脚按钮:同一侧的两只脚在内部本来就是连通的。如果你接的是这两只脚,按钮就会一直处于按下状态。请使用对角的两只脚。
- 摇杆会自己漂移。 无人机云台会停在弹簧把它推到的任何位置(我的是 4095 中的 2317,而你可能会以为应该是 2048),而且 ADC 有噪声。修复方法:
- 在启动时校准中心点。
- 在使用过程中学习每个轴的活动范围。
- 对 8 个采样取平均。
- 应用 60 个计数的死区,这是静止时测得噪声的四倍。
- Y 轴被反转了两次。 测试固件对 Y 取反,是为了在一块 Y 轴向下增长的屏幕上实现“向上移动点就向上”。而游戏接收的是方向位,其中上就是上。把屏幕的约定带过去,就又把它反转了一次。
掌机:显示、输入,以及一个被观察点捕获的崩溃
一切都在 SD 卡上
8 MB 的 flash 装不下一个 3 MB 的应用再加上 3.7 MB 的游戏数据,所以在掌机上所有数据都来自 microSD 卡。这张卡与显示屏共用 SPI 总线,后来这引发了一次独立的崩溃(见下文“共享的 SPI 总线”)。
这块板子特有的两个陷阱
GPIO43 既是显示屏的数据/命令引脚,又是 UART0 TX。 Waveshare 的代码为它的串口控制台安装了 UART 驱动。这样做会悄悄地把该引脚交还给 UART,显示屏便无法再区分命令和像素。掌机只使用原生 USB 作为控制台。
通过原生 USB 输出 printf 时,如果没人读取就会阻塞。 接着电脑时你根本不会察觉。一旦拔掉,发送缓冲区几秒钟就会填满,游戏会停在一个 printf 里——而这台设备本就是要脱机运行的。现在所有输出,包括 ESP-IDF 自身的日志,都经过一个包装函数,以零超时写入,并丢弃没人读取的内容。
先是闪烁,然后是滑动的矩形
第一个版本会闪烁。我以为是 SPI 时钟太慢,就把它从 40 MHz 提高到 80 MHz。这有一点帮助,但对扫描输出做插桩后,真相才显现出来:
- 发送一帧大约需要 24 ms。
- 游戏每 16.7 ms 就产生一帧。
- 所以游戏跑得比显示屏还快:它交换了缓冲区,并开始重绘 DMA 仍在传输的那一个。
这是缓冲区一致性问题。解决办法是采用 Waveshare 移植版作为安全网保留下来的拷贝路径:先拷贝已完成的帧,再发送这份拷贝。我的第一版实现有两个 bug:
- 它只拷贝光栅化器当前裁剪矩形内的行,而某个特效可能把裁剪矩形缩小到只剩几行。结果游戏还在运行,屏幕却卡住了。
- 使用两个拷贝缓冲区时,它可能覆盖仍在发送的那一个。在水平滚动的场景中,这会表现为矩形横向滑动。
即使修好了这两个问题,矩形依然存在。把 SPI 时钟降回 40 MHz 后它们就消失了:杜邦线撑不住 80 MHz,而一个字节的错位就会让整整 8 行的条带发生偏移。S3 的 SPI 时钟是对 80 MHz 源时钟分频得到的,所以只有 80 和 40 MHz 两个选项,中间没有别的档位。
既然瓶颈在线上,剩下的办法就是少发数据。每条 8 行条带在转换成 RGB565 时都会计算指纹,与显示屏当前内容完全相同的条带会被跳过。丢帧率从大约 40% 降到了 6%。这样在画面静止时屏幕上大约能跑到 55 fps,而游戏逻辑仍保持全速。
硬件观察点捕获的崩溃
接下来的反馈是“我一按 START 它就重启了”。之前还有另一条:“文字出现了,然后屏幕变白,接着就重启了。”
panic 发生在 ClearOTag 中,从主循环调用时传入的排序表指针是 0x4b8。主循环通过 g_CurrentBuffer = g_CurrentBuffer->next 前进,所以倒推回去,是有东西往两个 GPU 缓冲区之一的 next 链接里写入了 0x44。
ESP32-S3 有两个硬件观察点。我把两个都用上了,分别设在两个缓冲区的 next 字段上,在启动两秒后启用——那时唯一合法的写入早已完成。按下 START 后,CPU 在罪魁祸首的存储指令处停了下来:
Debug exception reason: Watchpoint 1 triggered
AddPrim ← MenuDrawImg ← MenuDrawChar ← MenuDrawStr ← MenuDrawStats ← MenuDraw
暂停菜单用 &g_CurrentBuffer->sprite[g_GpuUsage.sp] 取下一个空闲精灵,却从不检查边界。sprite[] 是 GPU 缓冲区的最后一个字段,而第二个缓冲区紧接着它开始,开头正是它的 next 链接。绘制状态文字时精灵数超过了 512,AddPrim 就把一个图元头写到了那个链接上。同一款游戏在别处会检查 g_GpuUsage.sp < MAX_SPRT_COUNT,但菜单没有。两行代码就修好了。
共享的 SPI 总线
紧接着,在关卡加载时出现了另一种崩溃:SPI 驱动中的断言 running_cmd == 0。存储卡在显示屏 DMA 传输还在线上时发起了一条命令。在 Waveshare 板子上,存储卡只负责流式读取可选的音乐;而在掌机上,它承载着一切。
解决办法是一把互斥锁:
- 显示屏持有一帧的时间,并在释放前排空正在进行的 DMA。
- 存储卡在每条命令前后获取它,通过包装驱动的
do_transaction钩子实现。
插桩时别把自己误导了
这一阶段有两条教训:
- 我的串口监听程序在复位板子。 用默认方式打开端口会拉低 DTR/RTS,而在 S3 的原生 USB 上,这两个信号是复位和启动选择。每次我连上去查看卡住的屏幕,实际上都重启了板子,读到的是一份健康的日志。解决办法是先构造端口对象,拉低这两条线,然后再打开。
- 要测量整个操作。 我最初的扫描输出计时把“等待”和“转换”分开统计,却漏掉了绘制调用本身——当 SPI 队列满时它会阻塞。它报告一帧耗时 5 ms,而实际是 24 ms。
不用刷板子就能运行每个构建
在几个小时的反复试错之后,每次改动都刷板子成了整个循环中最慢的一环:通过 USB 写入镜像、等待重启、在不再次复位板子的情况下重连串口、读日志、重复。所以为了运行和检查每个构建,我用了 velxio-cli,也就是 Velxio 的命令行客户端。它接收我的工具链产出的同一个 .bin,在 Velxio 模拟的 ESP32-S3 上运行固定的模拟时长,然后返回串口输出,这样我不用碰硬件就能读取计数器和计时数据。只有当某个构建值得时,板子才会拿到新镜像。
# velxio.toml, next to the build: board = "xiao-esp32-s3", firmware = the merged .bin
velxio-cli run --timeout 5000 --timeout-exit-code 0 --serial-log-file serial.log .
安装并首次运行只需几分钟:参见 velxio-cli 快速上手。
添加一个关卡:又是内存预算
走出炼金实验室会来到城堡入口(NP3)。把它加进来后,内部 RAM 溢出了 160 KB。该关卡的压缩图形、调色板、图块地图和精灵表都被声明为可写,因此启动时会被拷贝到 RAM 中。把它们标记为 const 后就被移到了 flash。上游的 PC 移植版早已加入了 NP3,那个提交很顺利地合并了过来。
还有两个发现:
- 里希特的精灵(序章角色)在 RAM 中是 85 KB 的可写数据,正常游玩中根本用不到。再次标记为
const后,加入 NP3 之后内部 RAM 的占用反而比之前更低。 - 链接器报告 NP3 与其他关卡之间有 16 个重复符号。实际有 31 个。 用
nm对比符号表后找出了另外 15 个,包括EntitySlogra和EntityGaibon:这些是 NP3 会从炼金实验室悄悄借用的 boss 代码。
生成的文件是从每位用户自己的光盘重新生成的,所以 const 的改动放在脚本里(esp32/const_stage_tables.py),而不是放在生成的源码中。
给类似移植的建议
- 选择一个已经能构建可移植目标的反编译项目。 正是这一点让原生移植成为可能,而模拟器做不到。
- 让板子告诉你哪里出了问题。 使用计数器、拆分到各部分的计时,以及帧校验和。我大多数错误的假设都是被一个数字推翻的。
- 回溯只能指出受害者。 对于内存损坏,在被破坏的地址上设置硬件观察点才能指出罪魁祸首。
- 内存就是性能预算。 在这颗 SoC 上,减少 PSRAM 访问永远胜过聪明的算术。
- 原硬件的宽容会掩盖 bug。 做好发现一些的准备。
当前状态与后续计划

- Waveshare 开发板:炼金实验室场景达到 60 fps。
- 掌机:游戏逻辑全速运行,镜头静止时屏幕约为 55 fps。
- 已连通关卡:炼金实验室、传送室,以及标题/存档菜单。城堡入口已连通,等待在掌机上进行首次测试。
- 声音:音效正常,当光盘镜像位于存储卡上时 XA 音乐可以播放。
- 下一步:更多关卡(内存预算现在已有余量),以及为掌机焊接一套线束,再次尝试 80 MHz SPI。
代码位于 GitHub 上的 davidmonterocrespo24/sotn-decomp(分支 esp32-port)。接线图和物料清单见硬件指南。你需要自备游戏副本;该仓库不包含任何游戏数据。
感谢 SOTN 反编译项目的贡献者,以及 Xeeynamo 的 psyz。本项目建立在他们工作的基础之上。