把一个开源 PC 老游戏搬进 Switch:从自写 SDL3 后端到正式应用 NSP(NRO / NSP 包结构详解)

本文记录把开源游戏引擎 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)。摆在面前两条路:

  1. 把游戏回退到 SDL2——因为 devkitPro 官方只给 Switch 提供了 SDL2。但这等于把游戏改得面目全非,
    只为救这一个游戏,脏且不可复用。
  2. 给 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 / libnxdevkitA64 的 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、timetm_gmtoffutc_offset=0 顶替(newlib 没有)。

踩坑 ①:编译链接全绿,驱动却是”空壳”

第一版编出来,SDL_Init(VIDEO) 直接 “No available video device” 闪退。明明驱动代码都写了、库也链进去了。

真机日志(后面会讲怎么把日志 fsync 到 SD 卡)+ Atmosphère 崩溃报告才挖出根因,三连

  1. SDL_build_config.h.cmake 模板里少了 #cmakedefine SDL_VIDEO_DRIVER_SWITCH(音频、手柄同理)。
    没有它,驱动文件被 #ifdef 整个掉成.o——编得过、链得过、里面啥也没有。
  2. 上面提到的 elseif(UNIX) 抢匹配。
  3. 驱动真编进来后引用 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
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────┐
│ NRO Header(魔数 "NRO0"、大小、段表) │
├─────────────────────────────────────────┤
│ .text (代码) │
│ .rodata (只读数据) │
│ .data (数据) ← 这些是可重定位的程序段 │
├─────────────────────────────────────────┤
│ ASET Header(魔数 "ASET") ← 可选资产段 │
│ ├─ icon :256×256 JPEG 图标 │
│ ├─ nacp :名称/作者/版本(.nacp) │
│ └─ romfs :内嵌只读文件系统(可选) │
└─────────────────────────────────────────┘

关键点:ELF 里 ~80% 是调试信息,elf2nro 会自动剥离,所以 24MB 的 ELF 转出来的 NRO 只有几 MB
纯代码。图标用 nacptool 生成 nacp、配一张 256×256 JPEG,elf2nro--icon= / --nacp= 带上:

1
2
nacptool --create "游戏名" "作者" "1.0.0" game.nacp
elf2nro supreme.elf supreme.nro --icon=icon.jpg --nacp=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
2
3
4
5
6
7
8
9
10
11
supreme.nsp                         ← PFS0 容器(Partition FileSystem,把若干 NCA 拼在一起)
├── <hash>.nca Program NCA ← 程序本体
│ ├── exefs/
│ │ ├── main ← NSO(程序可执行体,从 supreme.elf 转)
│ │ └── main.npdm ← 进程元数据 + 权限清单
│ ├── romfs/ ← 只读资源(游戏资产都在这)
│ └── logo/ ← 开机 logo(可跳过)
├── <hash>.nca Control NCA ← "门面"信息
│ ├── control.nacp ← 名称/作者/版本/titleid(系统读这个)
│ └── icon_AmericanEnglish.dat ← 256×256 baseline JPEG 图标(每语言一份)
└── <hash>.cnmt.nca Meta NCA ← CNMT 内容元数据,登记上面几个 NCA 的 hash/类型

几个容易混的概念,一次说清:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1) exefs:程序本体 + 进程元数据
elf2nso supreme.elf exefs/main
npdmtool application.npdm.json exefs/main.npdm

# 2) control:门面(titleid 真正的来源是这里!)
nacptool --create "Dr. Lunatic Supreme With Cheese" "Hamumu" "6.2" \
control/control.nacp --titleid=0500444C53000000
sips -s format jpeg -z 256 256 icon.jpg --out control/icon_AmericanEnglish.dat # 去 EXIF、强制 256²

# 3) romfs:只读资源(见第四节)
cp -R assets/. romfs/

# 4) 打包成 NSP
hacbrewpack -k prod.keys --nologo \
--exefsdir exefs --romfsdir romfs --controldir control

几个关键坑,都是真血泪:

  • 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 dup2sdmc:/switch/supreme.log 并每行 fsync,这样哪怕黑屏
硬崩也能 FTP 把完整日志拉回来;系统级崩溃看 sdmc:/atmosphere/crash_reports/,配 addr2line 定位源码行。)

踩坑 ③:装成应用就 EGL 初始化失败(NRO 却好好的)

日志里赫然写着:

1
2
3
SDL chose video backend 'switch'
Couldn't create renderer opengles2: eglInitialize() failed: 12289 ← 0x3001 EGL_NOT_INITIALIZED
FATAL: Failed to create renderer

同一份代码,做成 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
2
// kernel_capabilities 里必须有:
{ "type": "application_type", "value": 2 } // 2 = Application,系统据此把进程当应用,才给 GPU

顺手还把 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.jspitch 版安装包里只有 66 个精灵,而代码要用到第 67+ 个——需要用 Steam 版
更新过的 intface.jsp(69 个精灵)。我确实把这个”覆盖资源”放进了 NSP 的 romfs,可它没生效

诊断神器是 hactool:把 NSP 拆成 NCA、再把 Program NCA 的 romfs 解出来,确认覆盖文件确实在 romfs 里

1
2
3
hactool -k prod.keys -t pfs0 --pfs0dir=out  supreme.nsp          # NSP → NCA
hactool -k prod.keys -t nca --romfsdir=rfs out/<program>.nca # NCA → romfs
# rfs/assets/supreme/graphics/intface.jsp ← 文件在!但运行时读不到

文件在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
源码 ──(devkitA64 + libnx + 自写 SDL3 后端)──► supreme.elf

┌─────────────────────────────────────┴───────────────────────────────┐
▼ ▼
elf2nro elf2nso
│ │
supreme.nro exefs/main
(NRO header + 段 + ASET{icon,nacp,romfs}) + npdmtool → exefs/main.npdm(含 application_type=2)
丢 /switch/ 用 HBL 跑,applet 448MB + nacptool → control/control.nacp(titleid)
+ 256² JPEG → control/icon_*.dat
+ 资源 → romfs/(只读,单档整体读)

hacbrewpack -k prod.keys

PFS0{ Program NCA, Control NCA, Meta NCA } = supreme.nsp
DBI 安装,主界面真图标,application 满内存

整个项目最值钱的几条经验:

  1. SDL3 没有公开 Switch 后端,但可以自己补(视频/音频/手柄三块 + 一堆构建系统接线);游戏代码几乎零改动。
  2. 全静态 + 全新平台上,”编译链接通过”≠能跑——必须 nm 验符号 + 真机验证。
  3. NRO 和应用 NSP 是两套世界:前者 applet/继承 GPU,后者要靠 NPDM 的 application_type 让系统认你是应用、
    才给 GPU。GL 自制软件做成正式应用,不是只能 forwarder。
  4. romfs 资源用单文件归档整体读,别逐文件读子目录。
  5. 包结构层层嵌套:NSP(PFS0) → NCA(Program/Control/Meta) → exefs(main+main.npdm)/romfs/control

SDL3 的 Switch 后端我已经清理脱敏后开源:https://github.com/neomody77/sdl3-switch。游戏资源是 Hamumu
的版权内容,不含在内,自己去 hamumu.itch.io 免费获取即可。

收工,去打小怪了。🍕