近日,我彻底搞清楚了超星 PDG 这种古老的文件格式中一些子类别(00H / 02H)的部分技术细节。鉴于互联网上没有找到关于此格式具体的描述,现将我了解的技术细节公布如下。有心者可据此 spec 文档,配合黑白游程表,即可重新实现一个 PDG 的解码器。
参考实现见: https://pdg-viewer.pages.dev。
PDG 00H/02H 格式规范
本规范描述 00H/02H 变体的最小可用实现细节,读完即可复刻解密与解码流程。除非另有说明,所有多字节字段均为小端序(little-endian)。
1. 头部与关键字段
| 偏移量 | 长度 | 名称 | 说明 |
|---|---|---|---|
| 0x00 | 2 | Magic | 固定为 ASCII "HH" |
| 0x0F | 1 | Format | 0x00(00H)或 0x02(02H) |
| 0x10 | 2 | Width | 图像宽度(像素) |
| 0x12 | 2 | Height | 图像高度(像素) |
| 0x18 | 4 | DataOffset | 加密数据起始偏移 |
| 0x1C | 4 | DataSize | 加密数据长度 |
| 0x40 | 0x30 | KeyMaterial | 解密密钥材料(48 字节) |
若 Magic 不匹配或 Format 非 0x00/0x02,不属于本规范的范围,应拒绝处理。
除列出字段外,其余头部字节在本实现中被忽略,不参与校验或解密。
文件长度应不小于 DataOffset + DataSize。
2. 解密算法(XTEA-like)
解密对象为 DataOffset .. DataOffset + DataSize 的字节序列。算法为 16 轮、16 字节分组的 XTEA 变体。本算法为纯 ECB 风格,无反馈、无 IV、无跨块状态。
- 00H:payload 不加密,直接进入后续 CCITT 解码。
- 02H:payload 需按本节规则解密后进入后续 CCITT 解码。
2.1 Key 派生
取 KeyMaterial(0x30 字节)做 MD5,得到 16 字节摘要:
1md5 = MD5(KeyMaterial)
2a, b, c, d = unpack('<4I', md5)
2.2 常量
1DELTA = 0x61C88647
2SUM_INIT = 0xE3779B90
3ROUNDS = 16
4MASK = 0xFFFFFFFF
2.3 16 字节块解密
将 16 字节块按小端拆为 v0, v1, v2, v3:
1v0, v1, v2, v3 = unpack('<4I', block)
2s = SUM_INIT
3for i in range(ROUNDS):
4 v3 = (v3 - ( ((v0<<4)+c) ^ (s+v0) ^ ((v0>>5)+b) )) & MASK
5 v2 = (v2 - ( ((v3<<4)+a) ^ (s+v3) ^ ((v3>>5)+d) )) & MASK
6 v1 = (v1 - ( ((v2<<4)+c) ^ (s+v2) ^ ((v2>>5)+d) )) & MASK
7 tmp = (s + v1) & MASK
8 s = (s + DELTA) & MASK
9 v0 = (v0 - ( ((v1<<4)+a) ^ tmp ^ ((v1>>5)+b) )) & MASK
10return pack('<4I', v0, v1, v2, v3)
2.4 尾部处理
解密阶段不对尾部做 padding;如果密文长度不是 16 的倍数,不足 16 字节的尾部保持原值,并直接作为后续 CCITT 位流的一部分。
3. 解密后 Payload:类 CCITT T.6 (Group 4) 2D 位流
解密结果是二值图像的 CCITT 2D 压缩数据流。输出为 1bpp 位图,行按 32-bit 对齐。该编码为 CCITT T.6 风格的 2D 行间预测编码,但在 bitstream 对齐、run-length 终止条件、行结束判定等方面存在实现特有偏差。
3.1 位流读取
- 以 32-bit 为单位读取
- MSB-first 位序(高位先出)
- 若不足 4 字节,末尾以 0 填充
可用如下状态机读取单 bit:
1state.shift_reg = bswap32(next_word)
2state.bits_left = 32
3bit = (state.shift_reg >> 31) & 1
4state.shift_reg <<= 1
5state.bits_left -= 1
位流初始化时,解码器应立即装填首个 32-bit word 作为 shift register,其行为等价于从 payload 起始 MSB 连续读取。
3.2 Mode 解码(2D 模式码)
按位读取,输出模式值:
11 -> 3
201 -> 4 or 7
3001 -> 2
40001 -> 1
500001 -> 5 or 8
6000001 -> 6 or 9
7000000 -> 0x0B or 0x0C
等价伪码:
1if b1==1: return 3
2if b2==1: return (b3?4:7)
3if b3==1: return 2
4if b4==1: return 1
5if b5==1: return (b6?5:8)
6if b6==1: return (b7?6:9)
7return (b7?0x0C:0x0B)
3.3 Run-length Huffman 码表
使用白/黑两套码表,每个码表包含 {code, len, run} 三元组,code 按 MSB-first 解释,len 为码字位数。
码表必须覆盖:
- 终止码
0..63 - 续接码
64 × N(N = 1..40,即64..2560)
实际码表可与标准 CCITT 不同,必须按本实现所需码表填充。
由于本实现不区分 终止码 (terminating run) 与 续接码 (makeup run),所有 run-length 码值均累加,直到某次返回值 ≤ 63 为止,方视为一个颜色段结束。
当前 PDG 02H 使用的白/黑 Huffman 码表在位级上是前缀无歧义 (prefix-free) 的;因此在现有码表与已知样本数据下,解码过程中不会出现多个可接受匹配。
解码时在实现时,允许码字在位级存在前缀关系;当出现多个可接受匹配时,选择可匹配的最长码字。解码器中对前缀歧义的处理逻辑属于防御性实现,在现有码表与样本数据下不会被触发。
3.4 行解码流程
每行输出由“变色点数组”表示,按白/黑交替的边界位置累加。
伪码(简化):
1ref = 上一行边界数组
2cur = 当前行边界数组
3is_black = false
4u = 0
5
6while True:
7 mode = decode_mode()
8 switch mode:
9 case 1: # pass
10 u = ref[ref_idx + 1]
11 ref_idx += 2
12 case 2: # horizontal
13 # white then black (或反之)
14 run1 = sum(decode_run(color) while run>63)
15 u += run1
16 cur[cur_idx++] = u
17 run2 = sum(decode_run(!color) while run>63)
18 u += run2
19 cur[cur_idx++] = u
20 while ref[ref_idx] <= u: ref_idx += 2
21 case 3..6: # vertical +0..+3
22 u = ref[ref_idx] + (mode-3)
23 cur[cur_idx++] = u
24 ref_idx += 1
25 is_black = !is_black
26 case 7..9: # vertical -1..-3
27 u = ref[ref_idx] - (mode-6)
28 cur[cur_idx++] = u
29 ref_idx += 1
30 is_black = !is_black
31 default:
32 error
33
34 if u >= width:
35 cur[cur_idx..cur_idx+3] = u
36 return cur_idx
特别地,模式码 0x0B / 0x0C 在本实现中视为非法;若出现,应立即失败。
reference line 缓冲区必须显著大于可能的变化点数量,并在尾部填充多个单调递增的哨兵值,以保证 ref[ref_idx] <= u 的线性推进不会越界。
解码第一行前,reference line 必须初始化为仅包含 [0, width] 两个边界点,其后填充哨兵值。
reference line 与 current line 均为严格递增的变化点序列,索引奇偶性隐含当前颜色边界,不另行记录颜色数组。
本格式不包含 CCITT 标准定义的 EOL 或 RTC 标志,位流长度完全由外部 Height 控制。
3.5 行输出到位图
行宽 width,按 32-bit 对齐:
1row_words = (width >> 5) + ((width & 31) != 0)
2row_bytes = row_words * 4
对每对边界 (start, end):
- 将
[start, end)的像素置为 1 - 仅写入当前行
row_bytes
4. 输出格式
输出为 1bpp 原始位图,行按 32-bit 对齐。输出位图中,每字节以 MSB 表示最左像素,LSB 表示最右像素。
1total_size = row_bytes * height
若输出缓冲区不足,应报错。
若在第 N 行解码失败,解码过程提前终止;前 N−1 行输出有效,其余行未定义。
5. 失败条件
- Magic 或 Format 不匹配
- 解密数据为空
- CCITT 位流解析遇到非法码字
- 行解码过程中边界异常
- 输出缓冲区不足
行解码过程中若出现非法 Huffman 码或非法 mode,则解码失败;run 溢出、u > width、reference line 越界在本实现中视为可容忍情况。
6. 参考实现要点
- 位流读取必须 MSB-first
- Huffman 表必须按位前缀树解码
- 行边界数组需设置哨兵值(例如
0x7fffffff) - 行宽对齐方式固定为 32-bit
7. 最小流程摘要
- 校验 Magic 与 Format
- 读取
Width/Height、DataOffset/DataSize、KeyMaterial - MD5 派生
a,b,c,d - 分块解密 payload
- 使用 CCITT 2D + Huffman 码表解码行
- 输出 1bpp 位图(32-bit 对齐)
8. 码表完整性说明
当前解码依赖指定的白/黑 Huffman 表。若输入数据包含未覆盖码字,将解码失败或输出错误。若要扩展兼容性,需补充码表并保证前缀无歧义。
参考资料
- PDG科普篇,作者:马健
- 超星文件加密方式说明v1.5,作者:Che Ming