返回

《恶魔城:月下夜想曲》在 ESP32-S3 上原生运行

XIAO ESP32S3 掌机在炼金实验室中运行《恶魔城:月下夜想曲》

在《合金装备》那篇文章中,我展示了自己围绕 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 的重新实现。

因此,这项工作分为三部分:

  1. 为 Xtensa 编译游戏。
  2. 用面向该 SoC 的后端替换 psyz 的 PC 后端。
  3. 让所有东西都塞得下。

目标 SoC 是 ESP32-S3:

架构

从游戏到硬件的各层:

  1. 游戏:反编译的引擎、玩家与武器代码、关卡
  2. psyz:SDK 重新实现,使用 PSYZ_RENDERER=soft
  3. 平台层(esp32/main):VSync、根计数器、输入、FAT 文件、音频、LCD 扫描输出
  4. ESP32-S3 硬件:LX7 核心、SRAM、PSRAM、flash、SPI LCD

移植的各层与两个核心:游戏和光栅化器在核心 0 上,LCD 扫描输出和音频在核心 1 上,掌机上面板和 microSD 共享 SPI2

有两个设计决策决定了其余的一切。

VRAM 保持为游戏自己的缓冲区。 PlayStation 有 1 MB 显存,即 1024×512 个 16 位色像素。反编译项目已经把它建模为一个普通数组。光栅化器直接绘制到其中(位于 PSRAM),LCD 也从它取数据。没有影子副本,除了最后转换为面板的 RGB565 之外也没有格式转换。

关卡采用静态链接。 在 PC 上,每个城堡区域都是按需加载的 DLL。ESP32-S3 没有动态链接器,因此每个区域都被编译进固件,并用一张小表将关卡名映射到其初始化函数。这个决定两次引发了问题,详见下文“PlayStation 所宽容的 bug”和“添加一个关卡”两节。

把一款 PlayStation 游戏塞进 512 KB

阿鲁卡多站在实验室的一座雕像旁

第一次链接时,内部 RAM 超出了 3.34 MB。其中大部分并不是游戏本身的问题:

最后一条有个陷阱:有些“只读”数据其实会被写入。当打开一个音色库时,SDK 会就地更新声音库头部。如果声明为 const,它们就位于 flash 中,而通过缓存写入 flash 会触发硬件错误(“Dbus write to cache”)。可行的做法是:在 flash 中保留一份 const 主副本,启动时复制到 PSRAM 缓冲区,让游戏写入那份副本。

经过 28 轮链接之后,数字如下:

软件光栅化器,以及如何达到 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:

  1. 通过 OpenOCD 复位并暂停 SoC。
  2. 在 panic 处理函数上设置断点。
  3. 命中后,从 panic 帧中解码出真正的 PC。
  4. 始终对照烧录时所用的那个确切的 ELF 来解码,因为每次重新构建地址都会变化。

越界的调色板动画

炼金研究所请求调色板动画 (tileset & 0xFF) + 0x7FFF | 0x4000,这会索引该关卡调色板表中的第 2 项。而该表只有一项。这次越界读取落在了相邻的精灵库表上,随后该表被注册为调色板动画描述符,并且每帧都被写入。

修复方法是拒绝那些范围超出调色板缓冲区的描述符。同样的未定义行为在上游也存在;PC 版靠一大块 BSS 把它吸收掉了。

两个关卡,一个 HitDetection

由于关卡是静态链接的,有 122 个全局符号在不止一个关卡中被定义。共享头文件实现了碰撞和实体更新之类的东西,而在 PlayStation 上,任何时候只会加载一个关卡。静态归档让链接器悄悄地把每个名字解析到某一个定义上。炼金研究所运行的是 Warp Room 的 HitDetection,却拿它去操作自己的实体表,于是损坏在更后一层才显现出来。

修复方法是生成一份列出两个关卡共享的每个符号的清单,并用 -D 定义为每个关卡重命名。

会改动自身地图的房间

门和可破坏的墙会写入房间的瓦片地图。由于生成的地图位于 flash 中,第一扇门就让游戏崩溃了。修复方法是设置一个单一入口点,把当前房间的前景层复制到一块可写的 PSRAM 缓冲区中。

敌人名字

屏幕底部的敌人名字框,由同一段曾经溢出栈的 BottomCornerText 代码绘制

有了妖精卷轴遗物后,游戏会打印你击中的敌人的名字。BottomCornerText 会扫描字符串以寻找 PlayStation 的 FF 00 终止符。PC 字符串是普通的 C 字符串,所以扫描会越过末尾,并覆盖一个 64 字节的栈缓冲区。症状就是“我一碰到敌人它就卡死”。修复方法是给扫描加上边界。

一个在 PlayStation 上不存在的竞态

音频运行在第二个核心上,而音频拉取会推进根计数器。这些计数器会触发游戏的 VSync 处理函数、纹理上传和 GPU 队列回调。在 PlayStation 上,这些是同一颗 CPU 上的中断。而在这里,它们与另一个核心上的游戏并发运行。

修复方法是在核心 1 上累积计数器 tick,并在游戏线程上触发处理函数。

从未到达的输入

反馈是“我没法跳”。这是三个 bug 叠加在一起:

掌机:在 XIAO ESP32S3 Sense 上的硬件调试

从上方看完成的掌机:ILI9341 面板、无人机云台摇杆、按钮和洞洞板上的 XIAO

掌机的背面:洞洞板上的点对点布线,以及面板自带的 SD 卡槽(未使用)。旁边是已拆下的 XIAO 摄像头模块

掌机正面,旁边是 XIAO 的摄像头模块,移植版用不到它,已被拆下

目标是做一台掌上游戏机。它和后来运行《合金装备 索利德》的是同一套硬件:SOTN 是它上面跑的第一款游戏。零件如下:

XIAO 有十一个可用引脚。面板需要五个(SCK、MOSI、MISO、CS、DC)外加一条复位线,摇杆需要两个 ADC 引脚。这样只剩下三个引脚给八个按钮。

其中六个按钮通过一个电阻梯形网络共用一个 ADC 引脚:一个 10k 上拉电阻,以及每个按钮对应一个接地电阻(0 Ω、2k2、4k7、10k、22k、47k)。每个按钮会产生不同的电压。局限在于,同时按住两个按钮会被读成较低的那个。每款游戏会同时按的那一对(SOTN 中的攻击和跳跃)则接到剩下的两个专用引脚上。

完整的接线图、零件清单和按钮映射都在仓库的硬件指南中。

掌机的接线图,使用 Velxio 的元件图绘制:XIAO ESP32S3 Sense、ILI9341 面板、模拟摇杆、六按钮电阻梯形网络和两个直连按钮

硬件调试本身就是一连串教训。在碰游戏之前,我先写了一个单独的测试固件,用来绘制彩条,并在屏幕上显示每个按钮和摇杆的状态:

掌机:显示、输入,以及一个被观察点捕获的崩溃

一切都在 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。这有一点帮助,但对扫描输出做插桩后,真相才显现出来:

时间线:游戏每 16.7 ms 向缓冲区 A 和 B 绘制一帧,而显示屏接收一帧大约需要 24 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,但菜单没有。两行代码就修好了。

两个 GPU 缓冲区在内存中紧挨着;暂停菜单的第 513 个精灵落在了下一个缓冲区的 next 链接上,被硬件观察点捕获

共享的 SPI 总线

紧接着,在关卡加载时出现了另一种崩溃:SPI 驱动中的断言 running_cmd == 0。存储卡在显示屏 DMA 传输还在线上时发起了一条命令。在 Waveshare 板子上,存储卡只负责流式读取可选的音乐;而在掌机上,它承载着一切。

解决办法是一把互斥锁:

插桩时别把自己误导了

这一阶段有两条教训:

不用刷板子就能运行每个构建

在几个小时的反复试错之后,每次改动都刷板子成了整个循环中最慢的一环:通过 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,那个提交很顺利地合并了过来。

还有两个发现:

生成的文件是从每位用户自己的光盘重新生成的,所以 const 的改动放在脚本里(esp32/const_stage_tables.py),而不是放在生成的源码中。

给类似移植的建议

当前状态与后续计划

在掌机上进行的又一场炼金实验室战斗

代码位于 GitHub 上的 davidmonterocrespo24/sotn-decomp(分支 esp32-port)。接线图和物料清单见硬件指南。你需要自备游戏副本;该仓库不包含任何游戏数据。

感谢 SOTN 反编译项目的贡献者,以及 Xeeynamo 的 psyz。本项目建立在他们工作的基础之上。


分享到:
David Montero

作者

David Montero

Creator of Velxio, the open-source circuit and Arduino simulator.

GitHub velxio.dev

相关文章


下一篇
合金装备在 ESP32-S3 上原生运行