本文记录把开源游戏引擎 HamSandwich(代表作 Dr. Lunatic Supreme With Cheese,胡默姆 Hamumu 出品)
移植到 Nintendo Switch 自制软件(Atmosphère CFW) 的全过程:先给 SDL3 补一份 Switch 后端让游戏
跑起来,再把它做成.nro,最后打包成一个像卡带一样装在主界面的正式应用 NSP。重点会把 NRO 和 NSP 的包结构讲清楚——这是整个折腾里最绕、网上资料最零碎的部分。
注:文中涉及的 prod.keys(主机密钥)、内网地址、FTP 账号等均已脱敏;titleid、工具名、文件格式
等公开技术信息予以保留。所有操作只针对自己合法持有的主机和自己编译的程序,不涉及任何盗版分发。
前言:为什么是”自己写 SDL3 后端”
Dr. Lunatic Supreme 是我小时候玩过的一个像素动作游戏,作者 Hamumu 后来把引擎
HamSandwich 开源了(MIT 协议),游戏资源仍是版权内容、
免费在 itch.io 发放。引擎是标准的 SDL 架构,于是”搬上 Switch 破解机”看起来顺理成章。
但一上手就撞墙:这游戏重度依赖 SDL3。扫一遍代码,1600 多处 SDL 调用,而且大量用 SDL3 独有的范式
(SDL_IOStream 抽象、SDL_PropertiesID、新版 SDL_CreateRenderer)。摆在面前两条路:
- 把游戏回退到 SDL2——因为 devkitPro 官方只给 Switch 提供了 SDL2。但这等于把游戏改得面目全非,
只为救这一个游戏,脏且不可复用。 - 给 SDL3 补一份 Switch 后端——游戏一行不改,后端还能复用给任何 SDL3 程序。
SDL3 其实有官方 Switch 移植,但它是 NDA 锁死的、只发给有正式开发授权的人,自制软件圈拿不到。
devkitPro 也只打包了 SDL2。这中间就是个空洞。我决定走第 2 条:自己 fork、自己维护一份带 Switch
后端的 SDL3,参照 SDL3 自带的 3DS(n3ds)/PSVita(vita) 掌机后端 + zlib 协议的 switch-sdl2
(Wohlstand fork) + libnx 文档来写(官方那份 NDA 代码严禁参考)。
这份后端我已经清理脱敏后公开了:https://github.com/neomody77/sdl3-switch(zlib,附完整 patch)。
因为是 AI 辅助生成的,按 SDL 项目的贡献政策不会上游官方,仅作个人维护与参考。
一、给 SDL3 补 Switch 后端
Switch 自制软件的工具链是开放的 devkitPro / libnx(devkitA64 的 aarch64 GCC + libnx 运行库),
不需要任何官方 SDK。需要补的就是 SDL 的三块”系统后端”:
| 子系统 | 怎么接 |
|---|---|
| 视频 | libnx nwindowGetDefault() 拿默认窗口 → switch-mesa 的 EGL → GLES2,直接复用 SDL 自带的 opengles2 渲染器(零新渲染代码)。固定全屏 1280×720。 |
| 音频 | libnx AUDOUT 服务,固定 48kHz / 立体声 / S16,双缓冲。 |
| 手柄 | libnx pad HID API,16 个按钮 + 双摇杆 4 轴,配一张完整的 SDL Gamepad 映射。 |
写驱动只是一半。真正的坑在”接线”(plumbing)——让 SDL 的构建系统认识这个新平台、把驱动真正编进库里。
这几处缺一不可:
cmake/sdlplatform.cmake:识别CMAKE_SYSTEM_NAME == NintendoSwitch→ 置SWITCH。CMakeLists.txt:加elseif(SWITCH)选源码块;并且要给原来那两处elseif(UNIX ...)加上AND NOT SWITCH,否则 Switch 会被 UNIX 分支抢先匹配掉。- 在
SDL_video.c/SDL_audio.c/SDL_joystick.c里注册各自的 bootstrap。 - libnx 兼容垫片:
dynapi关掉(SDL_DYNAMIC_API 0)、pthread 的线程优先级/pthread_sigmask
在 Switch 上 no-op、time的tm_gmtoff用utc_offset=0顶替(newlib 没有)。
踩坑 ①:编译链接全绿,驱动却是”空壳”
第一版编出来,SDL_Init(VIDEO) 直接 “No available video device” 闪退。明明驱动代码都写了、库也链进去了。
真机日志(后面会讲怎么把日志 fsync 到 SD 卡)+ Atmosphère 崩溃报告才挖出根因,三连:
SDL_build_config.h.cmake模板里少了#cmakedefine SDL_VIDEO_DRIVER_SWITCH(音频、手柄同理)。
没有它,驱动文件被#ifdef整个掉成空.o——编得过、链得过、里面啥也没有。- 上面提到的
elseif(UNIX)抢匹配。 - 驱动真编进来后引用 switch-mesa(EGL/nouveau 是 C++),全静态链接顺序错 → 把
EGL/GLESv2/glapi/drm_nouveau并进链接组。
教训:在 Switch(全静态 + 全新平台)上,”编译链接通过”完全不等于驱动真在库里。
必须nm libSDL3.a | grep SWITCH_bootstrap确认关键符号真的被定义,并且真机/模拟器跑过才算数。
修好后supreme.elf里能看到SWITCH_bootstrap和 nouveau 的nv50_ir符号,库也从 3.4MB 变 8.5MB
(之前小,正是因为是空壳)。
踩坑 ②:全静态链接的循环依赖
整套静态库(SDL3 + SDL3_image + SDL3_mixer + EGL + GLES + nouveau)互相循环引用,普通链接顺序怎么排都有
未定义符号。解法是把它们塞进一个链接组 -Wl,--start-group ... --end-group,CMake 里用LINK_GROUP RESCAN。另外 -fsigned-char 必需(aarch64 默认 unsigned char,游戏假设 signed)。
SDL3_image / SDL3_mixer 用各自内置的 stb 后端(PNG 走 stb、OGG 走 stb_vorbis),零外部编解码依赖,
直接就能为 Switch 编出来。
到这里,游戏 ham 引擎 + supreme 主程序全部编译链接通过,产出一个 supreme.elf。下一步:变成能跑的 .nro。
二、NRO:自制软件的可执行包
2.1 NRO 包结构
.nro(Nintendo Relocatable Object)是 Homebrew Launcher 加载的格式。用 devkitPro 的 elf2nro
从 ELF 转换。它的结构是这样的:
1 | ┌─────────────────────────────────────────┐ |
关键点:ELF 里 ~80% 是调试信息,elf2nro 会自动剥离,所以 24MB 的 ELF 转出来的 NRO 只有几 MB
纯代码。图标用 nacptool 生成 nacp、配一张 256×256 JPEG,elf2nro 用 --icon= / --nacp= 带上:
1 | nacptool --create "游戏名" "作者" "1.0.0" game.nacp |
2.2 NRO 怎么跑、资源放哪
NRO 丢进 SD 卡的 /switch/,从 Homebrew Launcher(相册入口进去那个)里点开运行。游戏资源(基础
安装包 + 覆盖资源)就散放在 .nro 旁边,引擎用相对路径(当前工作目录)去读。
这条路能跑、能玩,画面/声音/手柄都正常。但有两个不爽的地方,正是后面要升级成 NSP 的动机:
- 内存:NRO 跑在 applet 模式,只有约 448MB 可用。大型游戏会 OOM。
- 门面:它藏在”相册 → Homebrew → 找 nro”里,不是主界面上一个真游戏图标。
顺带提一句”相册里”的由来:Homebrew Launcher 是借相册 applet 的入口启动的,所以自制软件天然就在
applet 沙盒里,内存受限。要拿满内存、要上主界面,就得做成应用(Application)。
三、NSP:做成”正式 Switch 游戏”
3.1 三种形态的区别
| 形态 | 怎么跑 | 内存 | 主界面图标 |
|---|---|---|---|
| NRO | Homebrew Launcher | applet ~448MB | ❌(在相册里) |
| Forwarder NSP | 装一次,图标点开后转去加载某个 .nro | 取决于被转发的 nro | ✅(但本质是壳) |
| Application NSP(本文目标) | 装一次,自包含,点开直接玩 | application 模式,满内存(掌机约 3.2GB) | ✅(真·游戏) |
我要的是第三种:资源进包、装一次像卡带、主界面真图标、拿满内存。
3.2 NSP 包结构(重点)
很多人以为 NSP 就是”一个安装包”,其实它是层层嵌套的。从外到里拆开是这样:
1 | supreme.nsp ← PFS0 容器(Partition FileSystem,把若干 NCA 拼在一起) |
几个容易混的概念,一次说清:
- PFS0:最外层就是个简单的”分区文件系统”容器,把多个 NCA 打包进一个文件。
build_pfs0干这个。 - NCA(Nintendo Content Archive):任天堂的内容容器,加密的(所以需要 prod.keys)。一个 NSP 至少有
三个 NCA:Program(程序)、Control(门面)、Meta(清单)。 - NSO vs NRO:两个都是可执行格式,但 NSO 是”已安装应用”用的(带压缩、给应用加载器),NRO 是
自制软件用的可重定位格式。所以做应用要elf2nso(不是elf2nro)。 - NPDM:Program NCA 的 exefs 里那个
main.npdm,描述进程要多大栈、能调哪些内核系统调用、能访问哪些
服务和文件系统、是不是应用。这玩意儿是后面踩坑 ③ 的主角。 - CNMT:Meta NCA 里的内容元数据,相当于”这个 NSP 由哪几个 NCA 组成”的目录。
3.3 从 ELF 到 NSP 的完整管线
工具:devkitPro 自带 elf2nso / npdmtool / nacptool;打包用社区的 hacBrewPack(自己编一个);
加密 NCA 需要从自己主机 dump 的 prod.keys。
1 | # 1) exefs:程序本体 + 进程元数据 |
几个关键坑,都是真血泪:
- titleid 的真正来源是 control.nacp:hacBrewPack 从
control.nacp偏移0x3038(也就是nacptool --titleid=写进去的那个)读 titleid,不是从 npdm 读。而 npdm 里的 title_id 必须和它
一字不差地相同,否则应用加载器会因为 program-id 不匹配而拒绝启动。 - titleid 选
0x05段(如0x0500444C53000000)以避开任天堂正版游戏 ID。0x01是正版区段,别碰。 - 图标必须是 256×256 baseline JPEG(非 progressive、无 EXIF,Switch 的解析器很挑),
sips重编码最省事。
四、引擎侧:romfs 只读 + SD 卡存档
NSP 的 romfs 是只读的,存档不能往里写。所以引擎的资源层要分两条路:
- 基础资源从
romfs:/...读; - 存档/配置写到可写的 SD 路径
sdmc:/switch/<游戏>/appdata。
libnx 的 devoptab 让 romfs:/ 和 sdmc:/ 都能用标准 fopen/opendir 访问,所以引擎只要在它的 VFS 层
加一个 Switch 分支:romfsInit() 之后,只读根指向 romfs:/,可写根指向 sdmc:/...。HamSandwich 的资源
栈本来就支持”多层挂载、高优先级覆盖低优先级”,照着它的 Android 分支抄一个 Switch 分支即可。
五、真机两连崩,与最后的修复
打包出第一个自包含应用 NSP,用 DBI 装上、主界面出图标,点开——秒崩。接下来是两轮真机调试。
(调试基建:在程序入口把 stdout/stderr dup2 到 sdmc:/switch/supreme.log 并每行 fsync,这样哪怕黑屏
硬崩也能 FTP 把完整日志拉回来;系统级崩溃看 sdmc:/atmosphere/crash_reports/,配 addr2line 定位源码行。)
踩坑 ③:装成应用就 EGL 初始化失败(NRO 却好好的)
日志里赫然写着:
1 | SDL chose video backend 'switch' |
同一份代码,做成 NRO 能渲染,装成应用就 EGL 起不来。差别在于:
- NRO 经 Homebrew Launcher 启动,跑在 applet 环境里,GPU 是从 hbloader 继承来的,EGL 直接能用。
- 应用必须被系统认成”应用”,
nvservices才会把 GPU 分给它(拿到 ARUID → 连nvdrv→ EGL 才能初始化)。
我一度以为”GL 自制软件只能做 forwarder”。但 RetroArch 等就是装成正式应用跑 GL 的——说明可行,
是我的配置漏了东西。对照 nx-hbloader 的 hbl.json(专门用来跑 GL 自制软件的那个应用,是
“application + GL” 的黄金参考)一比,发现我的 NPDM 缺了一个关键能力位:
1 | // kernel_capabilities 里必须有: |
顺手还把 CPU 核从 0–3 改成 0–2(核 3 是系统保留的,应用别抢)、地址空间设 36-bit。只改 NPDM、不用
重编游戏,重打包即可。重装——EGL 起来了,Created renderer: opengles2,进了游戏。
踩坑 ④:进关卡又崩,界面精灵空指针
进游戏后没多久崩在 RenderInterface → sprite_t::Draw 空指针。日志关键一行:
1 | sprite Load(graphics/intface.jsp): count=66 |
这游戏的界面图 intface.jsp,itch 版安装包里只有 66 个精灵,而代码要用到第 67+ 个——需要用 Steam 版
更新过的 intface.jsp(69 个精灵)。我确实把这个”覆盖资源”放进了 NSP 的 romfs,可它没生效。
诊断神器是 hactool:把 NSP 拆成 NCA、再把 Program NCA 的 romfs 解出来,确认覆盖文件确实在 romfs 里:
1 | hactool -k prod.keys -t pfs0 --pfs0dir=out supreme.nsp # NSP → NCA |
文件在 romfs 里,运行时却打不开。根因很反直觉:
在 libnx 上,”目录挂载 + 逐文件打开 romfs 子路径”(
fopen("romfs:/a/b/c.jsp")一个个开)不可靠——
即使文件确实在 romfs 里也会读不到。 而基础安装包能用,是因为它被当成单个压缩档整体打开一次
(一次fopen("romfs:/installers/xxx")成功,之后在档案内部按需取文件)。
也就是说,单文件整体读 OK,逐文件目录读不 OK。于是把覆盖资源也改成”单文件整体读”:打成一个romfs:/assets/<游戏>.zip,引擎用 open_zip 整体打开(和基础包同一条可靠路径)。
1 | sprite Load(graphics/intface.jsp): count=69 ← 覆盖 zip 生效了 |
重装——能进关卡了,画面、声音、手柄全正常,可以玩。🎮
通用教训:Switch 应用 NSP 里的只读资源,一律走”单文件压缩档 + 整体读”(zip / 自带归档格式),
不要用”目录挂载逐文件读 romfs 子目录”。
六、一个让人血压升高的小插曲:FTP 传包
86MB 的 NSP 要传到 Switch 的 ftpd 上,结果连续两次都在快传完时断掉——Switch 一息屏,ftpd
就断,最后几十 KB 没落盘,文件被截断(看着像传完了,其实少了一截)。
经验:
- 传大文件期间让屏幕常亮 / 关掉自动休眠;
- 传完一定要核对 SD 上的字节数与本地精确相等,不等就重传——别信”看起来传完了”;
- 顺带,如果开了 SOCKS5 之类的系统代理,
curl走 FTP 要加--noproxy '*',否则代理会劫持局域网地址。
七、小结:包结构一图流
把这一路的”格式转换链”收束成一条线,其实很清晰:
1 | 源码 ──(devkitA64 + libnx + 自写 SDL3 后端)──► supreme.elf |
整个项目最值钱的几条经验:
- SDL3 没有公开 Switch 后端,但可以自己补(视频/音频/手柄三块 + 一堆构建系统接线);游戏代码几乎零改动。
- 全静态 + 全新平台上,”编译链接通过”≠能跑——必须
nm验符号 + 真机验证。 - NRO 和应用 NSP 是两套世界:前者 applet/继承 GPU,后者要靠 NPDM 的
application_type让系统认你是应用、
才给 GPU。GL 自制软件能做成正式应用,不是只能 forwarder。 - romfs 资源用单文件归档整体读,别逐文件读子目录。
- 包结构层层嵌套:
NSP(PFS0) → NCA(Program/Control/Meta) → exefs(main+main.npdm)/romfs/control。
SDL3 的 Switch 后端我已经清理脱敏后开源:https://github.com/neomody77/sdl3-switch。游戏资源是 Hamumu
的版权内容,不含在内,自己去 hamumu.itch.io 免费获取即可。
收工,去打小怪了。🍕