逆向DVD播放器Native32游戏格式,从DES加密到模拟器实现

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 功能实际上没有实现

0x09 链接