重建 VCD 3.0 运行环境:COMPHTML、YUVBMP 与 VCDSCRIPT
互动教育光盘在标准 VCD 媒体轨道之外,搭载了页面容器、调色板图像、交互热区与脚本文本。本文以一张 ESS / EnReach Interactive VCD 3.0 演示盘为例,介绍其专有互动运行环境在现代 Rust 播放器中的兼容实现与边界。
1. 样本与证据边界
样本根目录含有 AUTORUN.EXE、PROGRAM/JAVA/AUTORUN.CLS、DATA/VCD_DATA/ 下的 .CHM / .YBM 资源,以及可播放的 .DAT 媒体文件。可执行文件的版本资源显示 I-Reader Application、IREADER.EXE 和 EnReach Technology 的 1997–1998 年版权信息,因此可以把它识别为该样本使用的 I-Reader 宿主程序。
本文对信息来源作三种标注:
| 标识 | 含义 |
|---|---|
[SPEC] | 公开格式或语言规范,例如 Java ClassFile、VCD 2.0 和 MPEG-1 |
[OBS] | 从本样本文件、文件头和版本资源中直接读到的值 |
[IMP] | 当前 Rust 播放器已经实现并可由源码或测试夹具复现的行为 |
如果某个解释只与样本吻合、仍缺少另一份样本或原始文档印证,本文会直接说明它的范围,而不会把它写成格式标准。
2. 从目录走到页面
播放器装载目录后,先判断它是否含有 AUTORUN.CLS、DATA/VCD_DATA 和至少一个 .CHM 页面。满足这些条件时,界面进入互动模式;否则按 VCD 2.0 PBC 或线性视频模式处理。
这一流程刻意分开了标准 VCD 数据与样本的互动资源。前者适用于更多光盘,后者只在识别到对应资源时启用。
3. AUTORUN.CLS:ClassFile 作为配置来源
AUTORUN.CLS 的前四个字节为 CA FE BA BE,这是 Java ClassFile 的固定魔数 [OBS]。本样本的版本号为 major 45、minor 3;这个版本与早期 Java 类格式兼容,但版本号本身不足以确认由哪一个 JDK 编译,也不足以说明它在何种运行时中执行。
当前播放器把 .CLS 用作启动配置来源 [IMP]。它验证魔数和常量池边界,从 UTF-8 常量中提取封面 .YBM、首页 .CHM、可能的 .DAT 开场视频、标题和许可字符串。下面是与当前实现相符的简化逻辑:
let config = AutoRunConfig::parse(&class_bytes)?;
let initial_page = config.homepage_chm;
let opening_video = config.opening_mpeg;
当前播放器的目标是从已知样本中快速提取封面、首页与开场视频等元数据,遇到缺失或不完整字段时使用保守默认值,不涉及通用 Java 虚拟机指令解释。
4. COMPHTML:页面容器与 Chunk 体系实现
样本中的 .CHM 并不是 Windows Compiled HTML Help 文件。文件开头为 <COMPHTML>,随后是 208 字节页面头、1024 字节 YUV 调色板和一连串由 12 字节通用头引导的 Chunk 数据流 [OBS]:
0x000 .. 0x00B ( 12B): 魔数 "<COMPHTML>\0\0"
0x00C .. 0x0DB (208B): 页面全局元数据(宽 352、高 288、透明色索引、调色板项数、标题与签名)
0x0DC .. 0x4DC (1024B): 256 项 × 4 字节的 YUV BT.601 调色板
0x4DC .. 结尾前: 连续紧凑排列的 Chunk 序列
结尾 11 字节: "</COMPHTML>" 结束标签
每个 Chunk 均由 12 字节的大端序头部引导:
struct CompHtmlChunkHeader {
uint32_t type; // Chunk 类型 ID (0 ~ 17)
uint32_t size; // 负载数据有效字节数
uint32_t flags; // 标志位 / 关联图元对象 ID
};
宿主程序内部通过跳转表分发 18 种 Chunk 类型(ID 0..17)。当前播放器解析器对页面呈现的核心 Chunk 进行了完整建模与字段提取 [IMP]:
4.1 核心 Chunk 类型与解析实现
| 类型 ID | 标识常量 | 内部结构与关键字段 | 页面呈现与调度语义 |
|---|---|---|---|
| Type 0 | TITLE | 0 结尾的 ASCII 字符串(如 "homepage"、"T_B") | 窗口标题与页面内部路由标识 |
| Type 1 | BODY_STYLE | 0x00..0x20 字体名(如 "Courier"),0x40.. 源工程路径 | 全局文本排版式样与制作工程回溯信息 |
| Type 3 | IMG | 0x00..0x08 为左上角 (x, y);0x08..0x10 为尺寸 (w, h);0x1C.. 为热区绑定名(如 "#map1");0x60 为大端 4 字节文件名长度及 ASCII 文件名(如 "HOMEPAGE.YBM") | 放置主背景图或局部图层覆盖物,生成页面图层图元 |
| Type 11 | MAP | 0x00..0x20 Map 名称;0x40 覆盖层标记;0x64 脚本入口行号;0x74 微脚本指令集;0x124 顶点数 num_points;0x12C 起每点 8 字节 (x, y);其后为动作目标文件名(.CHM / .DAT / .WAV) | 定义可交互热区多边形、跳转目标或音效播放触发 |
| Type 13 | HREF | 目标文件名字符串(支持 .CHM 或 .DAT) | 全屏/全页面默认超链接,点击背景时触发跳转 |
| Type 14 | VARIABLE_TEXT | 0x00..0x08 为坐标 (x, y);0x08..0x0C 为绑定的变量名称(如 "A", "SCORE") | 动态文本排版层,实时读取解释器中的变量数值并格式化绘制 |
| Type 15 | BGSOUND | 0x00..0x04 循环计数 loop_count;0x04..0x08 文件名长度;0x08.. 音频文件名(如 "STAMP.WAV") | 页面装载时自动触发的声音/背景音频 |
| Type 16 | VCDSCRIPT | 前 10 字节为 "VCDSCRIPT\0";0x14..0x18 为 32 位文本长度;0x18.. 为明文脚本源码 | 嵌入式交互逻辑与流程控制代码(见第 6 节) |
页面载入时,各 Chunk 解析生成的元素按照五层画布流水线进行自底向上的光栅化合成:
4.2 热区碰撞检测:二阶段匹配策略
样本中的热区数据记录了一组顶点。在实际盘片中存在两类特殊布局:
- 密集相邻按钮:如心理测试题等界面的选项,各按钮垂直无缝紧挨,单项高度仅 11~13px;
- 退化单点锚点:如部分页面的返回小图标,制作端仅记录了单个中心锚点坐标,即 且 (面积为 0)。
为了避免密集按钮相互误触,同时保证单点图标能够被舒适点击,播放器采用了二阶段匹配策略 [IMP]:
- 阶段一(原生包围盒判定):对所有宽高 的正常热区,严格使用其顶点外接矩形 执行点包含判定,不作外扩,确保相邻紧密排布的按键互不干扰;
- 阶段二(退化单点吸附):若阶段一未命中任何按钮,且页面存在退化单点(宽高 ),则在鼠标光标周围以欧氏距离(如 )寻找最近的单点锚点触发,使面积为 0 的图标具备舒适的点击范围。
此外,当热区绑定的目标文件为 .WAV(例如《中国民族乐器》页面点击乐器按钮试听独奏)时,触发时必须立即中断并停止当前未读完的 BGSOUND 页面解说音,确保用户清晰听到点击触发的音效。
4.3 二进制解析实战:处理编译器“脏内存”残留
在解析真实盘片的 COMPHTML 文件时,还遇到了一个极具时代特征的二进制陷阱:当时的光盘制作工具在打包 IMG Chunk 时,局部内存缓冲区复用了上一次脚本编译残留的数据。在 Chunk 偏移 0x20..0x40 区域,经常残留着未清空的脚本代码片段(如 MAGE "HAND.YBM",163,52,0\r\n40 END),而真正的图像资源文件名实际上存放在固定的 0x60 偏移处。
如果解析器简单地按紧凑字符串扫描,就会误把残留的废弃指令当成图片资源文件名而加载失败。针对这一情况,解析器建立了防御性的定点字段提取机制,并在单元测试中构建了模拟脏内存的合成用例,以确保在历史制作工具生成的异常载荷下依然能稳定工作。
4.4 未知 Chunk 的完整语义仍待补齐:具体指的是哪些?
在宿主程序的跳转表中共有 18 种分发类型(0..17)。前文建立的 8 种类型已完全覆盖界面展现与基本交互。
所谓的未补齐 Chunk,具体是指:
- Type 2:在现有样本中偶见于特定静态页面头部之后,长度通常仅十几个字节,带有类似排版边界或背景擦除模式的标记;
- Type 4 至 Type 10:在分析的数百个课件页面中出现频率极低,推测为保留给表单控件(如文本输入框、复选框容器)或当时尚未启用的富媒体扩展插槽;
- Type 12:疑似特定复杂多边形或嵌入式对象的附属描述符;
- Type 17:出现在部分特定交互结尾处,可能与特定外部硬件外设或扩展中断信号相关。
由于这几类 Chunk 在样本光盘中出现概率不到 1%,且原宿主未暴露公开规范,当前 Rust 解析器将所有未显式列出的类型统一分流至 ChunkPayload::Raw { chunk_type, flags, data }。这种设计既完整保留了原始字节,又凭借定长 size 头保证了解析游标能够安全步进至下一个有效 Chunk,确保了整页解析流程不会因个别冷门控制标记而发生中断。
5. YUVBMP:索引图像与 BT.601 调色板
.YBM 文件以 <YUVBMP> 开头,主体借用了 8 位 BMP 的头部布局,但整数使用大端序。当前解析器使用以下固定位置 [IMP]:
0x00 .. 0x0F 签名与版本信息
0x10 .. 0x47 大端的 BMP 风格头部
0x48 .. 0x447 256 项、每项 4 字节的 YUV 调色板
0x448 .. 8 位索引像素,按从上到下的行顺序排列
每个调色板项按 [reserved, Cr ^ 0x80, Cb ^ 0x80, Y ^ 0x80] 解释,再用 BT.601 系数转换为 RGB。对 352×288 的页面背景,像素区长度为:
加上 0x448 = 1096 字节的头部和调色板,核心图像数据共 102,472 字节。样本中常见的 102,484 字节文件还多出 12 个字节 [OBS]。播放器目前只读取声明尺寸对应的像素区,保留这 12 字节的含义为未确认项;它们可能是填充或其他附加数据,现有证据不足以命名。
6. VCDSCRIPT:语法体系、指令集与协程虚拟机
页面的脚本 Chunk 以 VCDSCRIPT 开头,包含 32 位大端长度字段和明文 BASIC 源码 [OBS]。当前播放器并未引入笨重的通用解释器,而是专门为该语法体系构建了一套紧凑的抽象语法树(AST)与非阻塞协程状态机 [IMP]。
6.1 抽象语法树(AST)与完整指令集
VCDSCRIPT 源码按行号组织,支持在一行中通过冒号 : 串联多条语句。解析器将其建模为分类明确的语句树:
1. 流程控制指令
GOTO <line>:无条件跳转到指定行号;GOSUB <line>与RETURN:过程调用与返回。执行GOSUB时将当前行号与下一语句索引压入调用栈(call_stack);执行RETURN时弹出恢复程序计数器;IF <lhs> <op> <rhs> THEN <stmt> [ELSE <stmt>]:条件分支。支持=(Eq),<>/!=(Ne),<(Lt),>(Gt),<=(Le),>=(Ge) 六种比较操作符;THEN后面可跟随任意合法单语句;FOR <var> = <start> TO <end>与NEXT <var>:步进循环。将循环变量、目标终值压入循环控制栈,遇到NEXT自动自增并执行边界检查;END与REM:END终止脚本运行;REM为单行注释,词法分析时直接跳过。
2. 变量与算术表达式
- 标量寄存器:全局定义 26 个单字母整型变量
A至Z。虚拟机底层使用[i32; 26]数组直接通过 ASCII 码做偏移寻址,实现 速度的存取; - 四则运算表达式:支持加减乘除与括号嵌套,严格遵循乘除优先于加减的算术优先级。除法内置防御性非零检查,避免除以零导致虚拟机崩溃;
- 赋值语句:支持标准形式
LET X = expr或紧凑形式X = expr。
3. 画面绘图与光标
DRAWIMAGE <file>, <x>, <y>, <mode>:在指定坐标贴附.YBM精灵图或前景局部图层,mode控制透明色覆盖;DRAWCURSOR <x>, <y>:在屏幕上移动或绘制指示光标。
4. 输入交互与系统调用
CALL IRKEY(var):捕获红外遥控器输入。键值包括确认/播放(31)、停止/退出(32)、方向键(上 34、下 35)及数字键 1~9。读取结果存入目标变量;CALL TIME(var):获取系统时间戳(单位为 100ms),常用于脚本自主构建微秒级延时或轮询等待循环;CALL RAND(var):生成伪随机数。虚拟机内部采用经典的 Park-Miller 最小标准线性同余生成器(PRNG):
5. 多媒体与音轨切换
PLAYSOUND <file>:异步触发播放指定的短.WAV音效,不阻塞当前脚本继续执行;PLAYVIDEO <file>, <start_frame>, <end_frame>, <exit_page>:播放指定.DAT视频文件的区间帧片段。播放期间虚拟机进入等待状态,并在视频播放完毕后自动跳转至exit_page(如返回指定的.CHM页面);KARAOKE SET/GET/DEL/INS/PLAY:控制卡拉 OK 原唱/伴奏声道路由与选曲列表。
6.2 协程状态机 (VmState) 与时序调度
在交互课件中,连续多帧动画(如人物开口眨眼、按钮按压反馈)通常由多条连续的 DRAWIMAGE 指令驱动。如果解释器在主线程中同步执行,或者使用阻塞式的 sleep,会导致窗口事件循环卡死,画面撕裂且无法响应鼠标。
为此,播放器将虚拟机建模为一个协程步进状态机:
pub enum VmState {
/// 常规执行态,可继续步进执行下一条语句
Running,
/// 延时等待态:让出执行权,等待主时钟达到指定时间戳
WaitingForDelay { until_time: u64 },
/// 按键挂起态:等待遥控器或键盘按键输入
WaitingForKey { target_var: u8 },
/// 带超时的按键等待态(如 1 秒轮询)
WaitingForKeyWithTimeout { target_var: u8, until_time: u64 },
/// 媒体等待态:等待后台视频解码播放完成
WaitingForVideo,
/// 脚本执行完毕或挂起终止
Finished,
}
// 核心步进调度片段 (节选自 program/src/core/script_vm.rs)
match stmt {
Statement::DrawImage { file, x, y, mode } => {
let x_val = x.eval(&self.variables);
let y_val = y.eval(&self.variables);
let mode_val = mode.eval(&self.variables);
host.draw_image(&file, x_val, y_val, mode_val);
self.pc = next_pc;
// 关键时序设计:每次绘图后延时 3 个时间单位 (300ms)
self.state = VmState::WaitingForDelay {
until_time: host.get_time_units() + 3,
};
}
Statement::CallIrkey(target_var) => {
self.pc = next_pc;
if self.has_call_time {
// 处于 CALL TIME 延时循环内时,启用 1 秒超时轮询
self.has_call_time = false;
let until = host.get_time_units() + 10;
self.state = VmState::WaitingForKeyWithTimeout { target_var, until_time: until };
} else {
self.state = VmState::WaitingForKey { target_var };
}
}
Statement::PlayVideo { file, start_frame, end_frame, exit_page } => {
let start = start_frame.eval(&self.variables);
let end = end_frame.eval(&self.variables);
host.play_video(&file, start, end, exit_page.as_deref());
self.pc = next_pc;
self.state = VmState::WaitingForVideo;
}
// 其余指令调度...
}
时序机制以 1 个时间单位 = 100 毫秒 为基准。每次执行 DRAWIMAGE 贴图后,虚拟机立即设置 until_time = host.get_time_units() + 3 并挂起,主动让出主循环执行权。在随后的 300 毫秒窗口期内:
- UI 渲染主循环维持 60 FPS 顺畅刷新,不产生界面假死;
- 页面临时忽略鼠标光标的悬停与点击事件,避免用户急躁连击破坏预设的时序节奏;
- 时间戳到达后,虚拟机在下一帧事件循环中平滑唤醒恢复执行,使多帧精灵图动画与伴随的
.WAV语音达到高度自然的视听同步。
7. 现代播放器的实现范围
| 模块 | 已实现的行为 | 当前边界 |
|---|---|---|
| 光盘入口 | 读取挂载卷或解压目录,识别 VCD 1.x、2.0 PBC、SVCD 标识和互动资源 | 不读取 BIN/CUE、ISO 或物理设备原始扇区 |
.DAT 媒体 | 识别 RIFF/CDXA,按 2352 字节扇区提取 MPEG Program Stream | 不重算 EDC,也不实现完整原始镜像读取 |
.YBM | 解码调色板和索引像素,生成 RGBA 画布 | 没有独立 PNG 导出功能 |
.CHM | 解析已知 Chunk、图像、链接、热区、背景音和脚本文本 | 未知 Chunk 的完整语义仍待补齐 |
.CLS | 校验 ClassFile 外壳并提取启动配置字符串 | 不执行方法字节码 |
VCDSCRIPT | 执行常用语句、协程等待、音视频和卡拉 OK 控制 | 未识别指令会暂停并提示 |
| VCD 2.0 PBC | 支持标准与扩展 PBC(含 PSD.VCD 与 PSD_X.VCD,0x10、0x18、0x1A、0x1F 描述符与指针热区) | 尚未覆盖多默认选择与全部特殊 offset |
这份范围表也决定了工程测试策略。为了保证跨平台构建与持续集成的稳定性,项目采用了分级的测试防线:
- Tier 1(纯内存单元测试):使用代码中内建的合成字节流验证各二进制解析器的字段切片与边界保护;
- Tier 2(内置 Mock 夹具测试):依赖仓库内置的轻量模拟光盘结构(
tests/fixtures/mock_disc),覆盖目录探测、PBC 初始解析和 CHM/YBM 加载,保证在任何无实际光盘的干净环境中均可自动运行且 100% 通过; - Tier 3(真实光盘集成测试):必须且仅能通过显式环境变量(如
$env:VCD_TEST_DISC = "I:\")触发。在未配置环境变量或指定路径不存在时测试安全跳过(Early Return),杜绝将个人设备盘符与物理介质硬编码进常规构建流程。
8. 仍待确认的问题
目前最值得继续收集证据的地方有四项:
- YBM 尾部 12 字节的用途;
- 低频 CHM Chunk 的完整载荷定义;
- 更多互动光盘是否共享同一套 CHM、YBM 和 VCDSCRIPT 约定;
- 原宿主对 ClassFile 方法、内存管理和媒体时钟的实际处理方式。
这些问题不妨碍播放器恢复当前样本的主要页面和交互路径,但它们决定了兼容层能否从“支持一张盘”扩大到“支持一类盘”。
9. 项目开源与体验
本项目已开源,代码基于 Rust 2024 编写,仓库地址:vcd30player。
如果你手头刚好保存有当年的 VCD 3.0 互动课件(如英语学习、少儿百科等)、经典 VCD 2.0 PBC 选单光盘或常规视频盘,非常欢迎下载使用并测试手中的盘片。
你可以直接克隆仓库,在本地传入光盘挂载盘符(如 I:\)或已解压的光盘目录直接启动播放:
# 使用内置夹具快速体验
cargo run -- tests/fixtures/mock_disc
# 播放物理/虚拟光盘或已解压的光盘目录
cargo run -- "I:\"
如果在不同批次或不同厂商制作的光盘上遇到了未支持的特殊标记、排版异常或交互分支问题,欢迎在 GitHub 提交 Issue 交流,共同补齐对这类早期多媒体扩展的兼容拼图。