2005-2011 年间的便携 DVD 播放器内置了 80+ 款游戏,使用私有的 Native32 格式。本文记录了从零逆向这个格式并实现 libretro 模拟器核心的过程,包括 DES 非标准加密破解、ActionScript-like 虚拟机实现、YUV 图像解码、MPEG-1 纯 Rust 解码器等技术细节。
0x01 这东西是什么
简单说:台湾凌阳科技(Sunplus)给 DVD 播放器芯片做了一套游戏方案,叫 Native32。游戏由 Potatoo Multimedia Studio 开发,文件格式是私有的,没有公开文档。
硬件淘汰后,游戏库基本从互联网上消失了。有人在 Internet Archive 上存了游戏文件,但原话是"没有已知的方法可以在真实硬件之外运行它们"。
我逆向了格式,用 Rust 做了个模拟器,现在作为 libretro 核心可以在 RetroArch 上跑,支持 Windows/Linux/macOS/Android。
0x02 文件格式
游戏文件扩展名是 .smf、.sgm 或 .ssl,都是同一个二进制格式。魔数头 _YUVGamemaker 1.3.12。
整体结构
┌─────────────────────────────┐
│ SWFT 缩略图(可选,跳过) │
├─────────────────────────────┤
│ _YUV / ARGB 色彩空间标记 │
├─────────────────────────────┤
│ 生成器字符串(48字节) │
│ 如 "Resolution_320x240" │
├─────────────────────────────┤
│ 基础偏移量(colorspace+0x60)│
├─────────────────────────────┤
│ 加密头部(32字节) │ ← DES 加密,资源偏移量藏在这里
├─────────────────────────────┤
│ 光标数据 │
├─────────────────────────────┤
│ 声音表 Sound Table │
├─────────────────────────────┤
│ 帧表 Frame Table │ ← 定义每帧包含哪些对象
├─────────────────────────────┤
│ 图像表 Image Table │ ← YUV/ARGB 压缩图像
├─────────────────────────────┤
│ 动作表 Action Table │ ← 字节码指令
├─────────────────────────────┤
│ 影片表 Movie Table │ ← 动画序列
├─────────────────────────────┤
│ 按钮表 Button Table │ ← UI 交互元素
└─────────────────────────────┘
资源对象类型
| 类型 ID | 名称 | 说明 |
|---|---|---|
| 1 | Image | 图像资源 |
| 2 | Movie | 动画/影片 |
| 3 | Button | 交互按钮 |
| 4 | Action | 动作/脚本指令 |
| 5 | Sound | 音频资源 |
两种使用方式
1. 独立完整游戏
位于 E* 分类目录下的 .smf 文件(如教育类游戏),包含完整的游戏逻辑和资源,可直接运行。
2. 跳板/入口文件
如 EACT/EBBLADE.smf(仅 11KB),不包含完整游戏内容,而是通过 SSL_PlayNext 指令跳转到 NA32SSL 目录下的多文件游戏。
0x03 DES 加密:非标准实现
坑在哪
文件头 32 字节用 DES ECB 加密。我一开始直接用了标准 DES 库,解出来全是乱码。又试了 3DES、DESede、DES-CBC,都不对。
对比输入输出才发现:凌阳改了所有的置换表和 S-boxes。算法框架没变(初始置换 → 16 轮 Feistel → 最终置换),但查表值全部不同。标准 DES 库根本解不开。
密钥来自常量字符串 aber3801,和芯片型号 SPHE8202 有关。
需要手写的查找表
rust
// crates/native32emu-core/src/des_constants.rs
// 初始消息置换(非标准,64个值)
pub const INITIAL_MESSAGE_PERMUTATION: [u8; 64] = [
0x3a, 0x32, 0x2a, 0x22, 0x1a, 0x12, 0x0a, 0x02,
0x3c, 0x34, 0x2c, 0x24, 0x1c, 0x14, 0x0c, 0x04,
0x3e, 0x36, 0x2e, 0x26, 0x1e, 0x16, 0x0e, 0x06,
0x40, 0x38, 0x30, 0x28, 0x20, 0x18, 0x10, 0x08,
0x39, 0x31, 0x29, 0x21, 0x19, 0x11, 0x09, 0x01,
0x3b, 0x33, 0x2b, 0x23, 0x1b, 0x13, 0x0b, 0x03,
0x3d, 0x35, 0x2d, 0x25, 0x1d, 0x15, 0x0d, 0x05,
0x3f, 0x37, 0x2f, 0x27, 0x1f, 0x17, 0x0f, 0x07,
];
// 最终消息置换(非标准,64个值)
pub const FINAL_MESSAGE_PERMUTATION: [u8; 64] = [
0x28, 0x08, 0x30, 0x10, 0x38, 0x18, 0x40, 0x20,
// ...
];
// 消息洗牌表(48个值)
pub const MESSAGE_SHUFFLE: [u8; 48] = [ /* 非标准 */ ];
// 右子消息置换表(32个值)
pub const RIGHT_SUB_MESSAGE_PERMUTATION: [u32; 32] = [ /* 非标准 */ ];
// 初始密钥置换表(56个值)
pub const INITIAL_KEY_PERMUTATION: [u8; 56] = [ /* 非标准 */ ];
// 子密钥置换表
pub const SUB_KEY_PERMUTATION: [u8; 48] = [ /* 非标准 */ ];
// 8个 S-box(每个64个值)
pub const DES_SBOXES: [[u8; 64]; 8] = [ /* 全部非标准 */ ];
// 密钥轮移位表
pub const KEY_SHIFT_SIZES: [u8; 16] = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
验证方式
正确解密的标志:解密后的 32 字节中,资源表偏移量指向有效的帧表结构。如果任何一个置换值错了,结果就是垃圾数据。
我是通过对比参考实现的输入输出对,逐个表核对的。拿同一个加密块,用参考实现解一遍,再用我的解一遍,逐字节比较。找到第一个不一致的字节,回溯是哪个置换表导致的。
0x04 Action 虚拟机
指令集
36 个操作码的栈式虚拟机。操作码值和 Flash ActionScript 高度重合,但编码不同:
rust
#[repr(u32)]
pub enum Action {
// 帧控制
End = 0x00,
NextFrame = 0x04,
PreviousFrame = 0x05,
Play = 0x06,
Stop = 0x07,
// 算术
Add = 0x0a,
Subtract = 0x0b,
Multiply = 0x0c,
Divide = 0x0d,
// 比较
Equals = 0x0e,
Less = 0x0f,
StringEquals = 0x13,
StringLess = 0x29,
// 逻辑
And = 0x10,
Or = 0x11,
Not = 0x12,
// 变量
GetVariable = 0x1c,
SetVariable = 0x1d,
// 精灵
CloneSprite = 0x24,
RemoveSprite = 0x25,
// 跳转
GotoFrame = 0x81,
WaitForFrame = 0x8a,
Jump = 0x99,
If = 0x9d,
Call = 0x9e,
// 宿主调用
Push = 0x96,
GetUrl2 = 0x9a,
}
宿主调用系统
GetUrl2 操作码是 Native32 的核心扩展。根据 URL 字符串触发不同的平台操作:
| 宿主调用 | 功能 |
|---|---|
SSL+SSL_PlayNext+<文件路径> |
加载并切换到下一个 SSL 场景文件 |
SSL+SSL_SaveSSLData+<变量名> |
将 VM 变量保存到 .ssl_sav 文件 |
SSL+SSL_GetSSLData+<变量名> |
从 .ssl_sav 文件读取数据到 VM 变量 |
LoadImage+<精灵名>+D+<路径> |
从 .dat 文件解码名称横幅图 |
LoadImage+<精灵名>+J+<路径> |
从 .dat 文件解码预览截图 |
StartGame+<路径> |
加载并运行所选游戏的 .smf |
GetFileNum+<目录> |
返回目录下的游戏数量 |
GetFirstFile+<目录> |
重置迭代器并返回第一个游戏名 |
GetNextFile+<目录> |
返回下一个游戏名 |
GetContext / SaveContext |
读取/保存菜单导航状态 |
VM 实现
rust
pub struct ActionVM {
stack: Vec<Value>,
vars: HashMap<String, Value>,
pc: usize,
bytecode: Vec<u8>,
}
impl ActionVM {
pub fn execute_frame(&mut self, host: &mut dyn VmHost) -> Result<()> {
loop {
let opcode = self.read_u8()?;
match opcode {
0x00 => return Ok(()),
0x04 => host.next_frame(),
0x06 => self.play(),
0x07 => self.stop(),
0x0a => {
let b = self.pop_num()?;
let a = self.pop_num()?;
self.push_num(a + b)?;
}
0x0b => {
let b = self.pop_num()?;
let a = self.pop_num()?;
self.push_num(a - b)?;
}
0x0e => {
let b = self.pop_num()?;
let a = self.pop_num()?;
self.push_bool(a == b);
}
0x1c => {
let name = self.pop_string()?;
let val = self.get_var(&name)?;
self.push(val);
}
0x1d => {
let val = self.pop();
let name = self.pop_string()?;
self.set_var(&name, val);
}
0x96 => self.push()?,
0x99 => self.jump()?,
0x9a => {
let url = self.pop_string()?;
host.get_url2(&url)?;
}
0x9d => self.conditional_jump()?,
0x9e => self.call()?,
_ => return Err(anyhow!("Unknown opcode: 0x{:02x}", opcode)),
}
}
}
}
VmHost trait
rust
pub trait VmHost {
fn next_frame(&mut self);
fn play(&mut self);
fn stop(&mut self);
fn get_url2(&mut self, url: &str) -> Result<()>;
fn clone_sprite(&mut self, target: &str, depth: i32) -> Result<()>;
fn remove_sprite(&mut self, target: &str) -> Result<()>;
fn get_property(&mut self, target: &str, prop: &str) -> Result<Value>;
fn set_property(&mut self, target: &str, prop: &str, val: Value) -> Result<()>;
}
独立桌面端和 libretro 核心各自实现了这个 trait。
0x05 图像解码
YUV 4:2:0 + Packbits/RLE
Y 通道全分辨率(320×240),U/V 半分辨率(160×120),需 2x 垂直插值上采样。
压缩编码:
- Packbits:短重复用标记+重复值,不重复用原始数据
- RLE:更长的重复序列
解码流程:
压缩数据 → packbits/RLE 解压 → U/V 2x 垂直插值 → YUV→RGB 转换 → ARGB 缓冲区
色度插值的坑:
标准双线性插值在图像边缘会产生不正确的色度值。参考实现用了一种"借用"策略:如果当前像素 U/V 值为 0 且不在边界,借用上一行的值;在上边界则借用下一行。
rust
fn interpolate_y(data: &[u8], w: usize, h: usize) -> Vec<u8> {
let h1 = h * 2;
let mut result = vec![0u8; w * h1];
for y in 0..h {
for dy in 0..2 {
let y1 = y * 2 + dy;
for x in 0..w {
let val = if dy == 0 {
if y == 0 || get(y * w + x) != 0 {
get(y * w + x)
} else {
get((y - 1) * w + x) // 向上借用
}
} else {
if y == h - 1 || get(y * w + x) != 0 {
get(y * w + x)
} else {
get((y + 1) * w + x) // 向下借用
}
};
result[y1 * w + x] = val;
}
}
}
result
}
YUV→RGB 转换:
rust
fn yuv_to_argb(y: u8, u: u8, v: u8) -> u32 {
let c = y as i32 - 16;
let d = u as i32 - 128;
let e = v as i32 - 128;
let r = clamp((298 * c + 409 * e + 128) >> 8);
let g = clamp((298 * c - 100 * d - 208 * e + 128) >> 8);
let b = clamp((298 * c + 516 * d + 128) >> 8);
(0xFF << 24) | (r << 16) | (g << 8) | b
}
ARGB1555
16 位格式,1 位 alpha + 5 位 R/G/B:
rust
fn decode_argb1555(pixel: u16) -> u32 {
let a = if pixel & 0x8000 != 0 { 0xFF } else { 0x00 };
let r = ((pixel >> 10) & 0x1F) as u32 * 8;
let g = ((pixel >> 5) & 0x1F) as u32 * 8;
let b = (pixel & 0x1F) as u32 * 8;
(a << 24) | (r << 16) | (g << 8) | b
}
0x06 SSL 多文件系统
大游戏使用跳板文件 + 场景文件的方案:
EBBLADE.smf (11KB)
→ NALOGO.mpg(Logo 视频)
→ BBSTART.SSL(标题,1.8MB)
→ BBMENU.SSL(主菜单,3.5MB)
→ BBPLAY10.SSL(第1关,2.6MB)
→ BBPLAY20 → BBPLAY30 → ...
→ BBFINISH.SSL / BBOVER.SSL
.ssl 文件和 .smf 用完全相同的二进制格式。区别仅在用途。
每个 .ssl 可以有对应的 .ssl_sav 存档文件(纯文本数字字符串)。比如 BBMENU.SSL.ssl_sav 内容是 1109600000002。
NA32SSL 目录下有两套完全独立的游戏集:
- CHINESE --- 中文教育游戏
- ENGLISH --- 英文动作冒险游戏
不是翻译关系,是完全不同的内容。
0x07 架构设计
crates/
├── native32emu-core/ # 平台无关引擎
│ └── src/
│ ├── emulator.rs # Emulator 结构体
│ ├── action_vm.rs # 字节码解释器
│ ├── file_loader.rs # 文件格式解析
│ ├── header_decryptor.rs # DES 解密
│ ├── image_decoder.rs # YUV/ARGB 解码
│ ├── sprite_system.rs # 精灵管理
│ ├── renderer.rs # 渲染器
│ ├── audio_engine.rs # 音频
│ ├── mpeg.rs # MPEG-1 解码
│ ├── content_loader.rs # SSL 切换
│ ├── save_manager.rs # 存档
│ ├── file_browser.rs # FHUI 菜单
│ └── dat_loader.rs # .dat 元数据
├── native32emu/ # 桌面端(minifb)
└── native32emu-libretro/ # libretro 核心
核心 crate 零平台依赖,前后端共享 Emulator。
libretro 核心
rust
#[no_mangle]
pub extern "C" fn retro_init() { /* ... */ }
#[no_mangle]
pub extern "C" fn retro_run() {
let emu = unsafe { EMU.as_mut().unwrap() };
emu.input.update(get_input_state());
emu.tick().unwrap();
video_cb(emu.renderer.framebuffer(), 320, 240, 320 * 4);
let samples = emu.audio.buffer();
audio_cb(samples.as_ptr() as *const _, samples.len() as i32);
}
#[no_mangle]
pub extern "C" fn retro_load_game(info: &retro_game_info) -> bool {
// 支持 .smf / .sgm / .ssl / .zip
}
视频:XRGB8888,音频:16-bit 立体声 PCM。
0x08 游戏兼容性
84 款游戏全部通过测试:
| 分类 | 数量 | 状态 |
|---|---|---|
| 主菜单 | 1 | ✅ |
| 动作 (EACT) | 11 | ✅ |
| 教育 (EELA) | 32 | ✅ |
| 热门 (EPOP) | 9 | ✅ |
| 益智 (EPUZ) | 24 | ✅ |
| 体育 (ESPG) | 3 | ✅ |
| 棋牌 (ETAB) | 4 | ✅ |




几个有意思的发现:
- 《风暴之翼》的过场动画里有个角色说 "Shit"------在儿童 DVD 播放器上,确实离谱
- 《符文之语》的文件名是 ERuneWod.smf,少了个 r(应该是 ERuneWord),导致在菜单里排序异常
- NA32SSL 目录下中文和英文两套游戏是完全不同的内容,不是翻译关系
- 一些 8Bit Game 分类里的 NES ROM 是那种你懂的不当 ROM hack------"Dick Dug" 和 "Dick & Milk"
- 《枪火》的标题画面里有 "Start" 和 "Option" 两个按钮,但 Option 功能实际上没有实现