Koikake / Amuse Craft GE-PGD 图片格式逆向与 PNG 转换报告
其实Garbro已经有现成了,这里重复造轮子学习一下吧,多学也不是坏事
前言
Koikake 资源包解包后会得到大量 .PGD 文件。此类文件虽然看起来像图片资源,但是是引擎内部的格式图片文件
经过样本分析和 PAL.dll 图像加载逻辑逆向,可以确认常见 .PGD 文件内部存在一种 GE 图像子格式。该格式并不是标准图片格式,而是游戏引擎自己的纹理存储格式:文件头保存宽高、滤波类型和压缩长度,主体数据经过自定义 LZ 类压缩,解压后还需要继续做颜色还原或行差分还原,最后才能得到 BGR / BGRA 像素数据。
本报告整理 GE 型 .PGD 的用途、文件结构、读取流程、压缩算法、像素还原算法、伪 C、汇编特征、转换脚本使用方式以及当前限制,方便后续分析、移植或编写转换工具。
结论概览
已确认的关键结论如下:
-
.PGD是游戏内部图片/纹理资源,不是通用图片格式。 -
常见样本内部魔数为
"GE \0",十六进制为47 45 20 00。 -
.PGD扩展名只是资源命名,不代表文件头一定是PGD。 -
PAL.dll会识别GE、PGD2、PGD3等不同图片子格式。 -
本报告当前覆盖的是
GE型.PGD。 -
GE数据使用自定义 LZ 类压缩。 -
解压后需要根据
filter_type继续还原像素。 -
已确认
filter_type = 2和filter_type = 3的转换方式。 -
最终像素顺序为 BGR 或 BGRA,保存 PNG 前需要转为 RGB 或 RGBA。
完整查看链路如下:
PAC 资源包
-> 解包得到 PGD
-> 识别 GE 文件头
-> 解压 GE payload
-> 根据 filter_type 还原像素
-> BGR/BGRA 转 RGB/RGBA
-> 保存为 PNG
适用范围
本报告和转换脚本适用于:
magic = "GE \0"
filter_type = 2
filter_type = 3
bit depth = 24-bit BGR 或 32-bit BGRA
暂不覆盖:
PGD2
PGD3
其他未知 magic
其他未知 filter_type
如果遇到不是 "GE \0" 开头的 .PGD,不应强行套用本文算法。.PGD 是外层扩展名,内部可能还有不同子格式。
PGD 是什么
.PGD 在该游戏中主要承担图片、纹理、遮罩、特效图等资源用途。解包后的资源名中可以看到类似文件:
BK000D.PGD
BK000E.PGD
EV001A01.PGD
FA01A_A010.PGD
EMSA019.PGD
常见命名习惯大致如下:
BK = background,背景图
EV = event CG,事件图
FA = face,角色表情或脸部素材
ST = standing / stage 相关素材
ED = ending / extra 相关素材
这些前缀不是格式字段,只是资源管理习惯。判断文件格式仍然要看文件头、读取逻辑和解码过程。
样本中可见:
BK000D.PGD -> 1920 x 1080
EMSA019.PGD -> 256 x 256
这说明 .PGD 不是脚本、配置或索引,而是直接服务于渲染系统的图像资源。
为什么 PGD 不能直接打开
普通图片查看器依赖标准图片魔数,例如:
PNG 89 50 4E 47
JPG FF D8 FF
BMP 42 4D
而 GE 型 .PGD 文件开头是:
47 45 20 00
解释为 ASCII:
"GE \0"
因此它不是标准 PNG、JPG 或 BMP。即使改成 .png 扩展名,查看器也无法识别。正确方式是按游戏引擎的规则完成解压和像素还原。
PAC、PGD、PNG 的关系
这里需要区分两个概念:
解包:从 PAC 容器里取出文件。
解码:把 PGD 私有图片格式转成普通图像。
PAC 是资源容器,负责保存文件表和文件数据。PAC 解包后得到 .PGD,只是完成了容器层提取。
PGD 是图片资源本体。对 GE 型 PGD 来说,文件内部仍然是压缩后的引擎纹理数据,还不是普通像素图。
PNG 是最终输出格式。转换器的目标就是把引擎纹理数据恢复成标准 PNG。
所以完整流程是:
PAC -> PGD -> PNG
如果 PAC 已经成功解包,但 .PGD 仍然打不开,这是正常情况,说明还缺少 PGD 解码步骤。
逆向分析思路
分析过程可以分成三层:
第一层:容器层
PAC 如何保存文件、如何提取单个文件。
第二层:图片格式层
PGD 文件头是什么,宽高、压缩大小、解压大小在哪里。
第三层:像素还原层
解压后的数据如何还原为 RGB/RGBA 像素。
这样拆分后,问题会清楚很多。PAC 负责“拿到文件”,PGD 解码负责“变成图片”,两者不能混在一起判断。
样本观察
先取不同类型的 .PGD 样本做十六进制观察,可以发现多个样本开头一致:
47 45 20 00
对应:
"GE \0"
继续按 32-bit 小端整数读取,可以在背景图样本中看到:
0x00000780 = 1920
0x00000438 = 1080
这与背景图实际分辨率吻合,因此可以初步判断文件头中直接保存了宽高。
追踪程序读取逻辑
在 PAL.dll 导出函数中可以看到和图像相关的入口:
PalSpriteLoad
PalSpriteLoadMemory
PalSpriteSaveTextureToFile
PalSpriteSaveTextureToPngFile
这些名称说明图像加载、内存加载、纹理保存、PNG 保存都集中在 PAL 引擎层。
PalSpriteLoad 的行为可以概括为:
读取文件内容到内存
检查文件头 magic
根据 magic 进入不同图片加载分支
关键伪汇编如下:
call PalFileCreateEx
ReadFile(file, global_buffer, file_size, &read, NULL)
mov eax, 0x4547 ; "GE"
cmp word ptr [buffer], ax
je load_ge_path
cmp dword ptr [buffer], 0x32444750 ; "PGD2"
je load_pgd23_path
cmp dword ptr [buffer], 0x33444750 ; "PGD3"
je load_pgd23_path
由于 x86 使用小端序,0x4547 在内存中对应:
47 45 = "GE"
这证明 "GE" 是引擎实际识别的图片子格式。
从汇编特征确认算法
逆向时不是直接凭空得到完整算法,而是通过多类特征逐步确认。
第一类是字段读取特征。宽高和滤波类型附近可抽象为:
mov eax, [esi+0Ch] ; width
mov ecx, [esi+10h] ; height
mov dx, [esi+1Ch] ; filter_type
这些偏移与样本观察互相吻合。
第二类是 LZ 控制位特征。解压循环中会出现右移、检查 0x100、补入 0xFF00 的模式:
shr control, 1
test control, 100h
jnz has_control_bit
movzx control, byte ptr [input]
or control, 0FF00h
这说明压缩流使用滚动控制位,每个控制字节管理多个 token。
第三类是回溯复制特征。LZ 类压缩会从已经输出的数据中往回复制:
src = output_pos - lookbehind
dst = output_pos
copy length bytes
第四类是像素滤波特征。filter_type = 3 分支中能看到逐行处理、读取每行模式、访问左侧像素和上一行像素:
row[x - channels] -> 左侧像素
prev_row[x] -> 上方像素
mode per line -> 每行一个滤波模式
这些特征共同证明:GE-PGD 的处理流程是“读头部 -> 解压 -> 滤波还原 -> 输出纹理”。
GE 文件头结构
GE 型 .PGD 文件头可整理为:
offset size meaning
0x00 4 magic,固定为 "GE \0"
0x02 2 header_size
0x04 8 reserved / unknown
0x0C 4 width
0x10 4 height
0x14 4 canvas_width / texture_width
0x18 4 canvas_height / texture_height
0x1C 2 filter_type
0x1E 2 reserved / unknown
0x20 4 unpacked_size
0x24 4 packed_size
0x28 ... compressed payload
用 C 结构可表示为:
#pragma pack(push, 1)
typedef struct GeHeader {
char magic[4]; // "GE \0"
uint32_t reserved0;
uint32_t reserved1;
uint32_t width;
uint32_t height;
uint32_t canvas_width;
uint32_t canvas_height;
uint16_t filter_type;
uint16_t reserved2;
uint32_t unpacked_size;
uint32_t packed_size;
} GeHeader;
#pragma pack(pop)
需要特别注意 header_size。"GE \0" 的第 3、4 字节同时可作为小端 uint16_t 读取:
file[2] = 0x20
file[3] = 0x00
header_size = 0x0020
压缩数据的真正起点不是简单从 0x20 开始,而是:
payload_offset = header_size + 8;
这里的 +8 对应两个字段:
unpacked_size u32
packed_size u32
常见样本中:
header_size = 0x20
payload_offset = 0x20 + 8 = 0x28
如果误从 0x20 开始解压,会把 unpacked_size 和 packed_size 当成压缩流内容,通常会立刻导致 back-reference 错误。
文件头样例
背景图样本头部可抽象为:
47 45 20 00 00 00 00 00 00 00 00 00 80 07 00 00
38 04 00 00 80 07 00 00 38 04 00 00 03 00 ...
解析结果:
magic = "GE \0"
width = 0x0780 = 1920
height = 0x0438 = 1080
filter_type = 3
字段对应关系:
offset raw bytes value
0x00 47 45 20 00 "GE \0"
0x0C 80 07 00 00 1920
0x10 38 04 00 00 1080
0x14 80 07 00 00 1920
0x18 38 04 00 00 1080
0x1C 03 00 filter_type = 3
整体解码流程
GE-PGD 的完整解码流程如下:
读取 GE header
|
v
检查 magic / width / height / filter_type
|
v
根据 header_size + 8 定位 payload
|
v
按 packed_size 读取压缩数据
|
v
LZ 类解压,得到 unpacked data
|
v
根据 filter_type 分支
|
+-- filter_type = 2 -> 三平面颜色还原
|
+-- filter_type = 3 -> 行差分像素还原
|
v
BGR/BGRA 转 RGB/RGBA
|
v
保存 PNG
整体伪 C:
Image decode_ge_pgd(uint8_t *file, size_t file_size) {
if (file_size < 0x28) {
error("file too small");
}
if (memcmp(file, "GE \0", 4) != 0) {
error("not GE PGD");
}
uint16_t header_size = read_u16le(file + 0x02);
uint32_t width = read_u32le(file + 0x0C);
uint32_t height = read_u32le(file + 0x10);
uint16_t filter_type = read_u16le(file + 0x1C);
uint32_t unpacked_size = read_u32le(file + 0x20);
uint32_t packed_size = read_u32le(file + 0x24);
uint32_t payload_offset = header_size + 8;
if (payload_offset + packed_size > file_size) {
error("payload out of range");
}
uint8_t *packed = file + payload_offset;
uint8_t *unpacked = ge_decompress(packed, packed_size, unpacked_size);
if (filter_type == 2) {
return decode_filter2(unpacked, width, height);
}
if (filter_type == 3) {
return decode_filter3(unpacked, width, height);
}
error("unsupported filter_type");
}
LZ 类压缩算法
GE 使用自定义 LZ 类压缩。核心思想是:如果后续数据能引用已经输出过的数据,就保存“回溯距离 + 长度”;否则直接保存原始字节。
压缩流中有两类 token:
literal run
直接复制一段原始字节。
back-reference
从已经输出的数据中回溯复制。
控制位逻辑:
control >>= 1;
if (!(control & 0x100)) {
control = read_u8() | 0xFF00;
}
最低位决定 token 类型:
0 = literal run
1 = back-reference
完整伪 C:
void ge_decompress(uint8_t *in, uint8_t *out, size_t out_size) {
uint16_t control = 0;
size_t ip = 0;
size_t op = 0;
while (op < out_size) {
control >>= 1;
if (!(control & 0x100)) {
control = in[ip++] | 0xFF00;
}
if (control & 1) {
uint32_t tmp = read_u16le(in + ip);
ip += 2;
size_t length;
size_t lookbehind;
if (tmp & 8) {
length = (tmp & 7) + 4;
lookbehind = tmp >> 4;
} else {
tmp = (tmp << 8) | in[ip++];
length = ((((tmp & 0xFFC) >> 2) + 1) << 2) | (tmp & 3);
lookbehind = tmp >> 12;
}
size_t src = op - lookbehind;
while (op < out_size && length--) {
out[op++] = out[src++];
}
} else {
size_t length = in[ip++];
memcpy(out + op, in + ip, length);
ip += length;
op += length;
}
}
}
literal run
字面量块结构:
u8 length
u8 data[length]
表示直接复制 length 字节到输出缓冲区。
short back-reference
当 tmp & 8 != 0 时使用短格式:
tmp: 16-bit little-endian
bits 0..2 length - 4
bit 3 short-format flag
bits 4..15 lookbehind
对应公式:
length = (tmp & 7) + 4;
lookbehind = tmp >> 4;
long back-reference
当 tmp & 8 == 0 时,还要多读 1 字节,组成近似 24-bit 的值:
tmp = (tmp << 8) | read_u8();
对应公式:
length = ((((tmp & 0xFFC) >> 2) + 1) << 2) | (tmp & 3);
lookbehind = tmp >> 12;
filter_type = 2
filter_type = 2 使用三平面数据,大致类似亮度/色度拆分。它不是标准图片文件的 YCbCr 头,而是该格式自己的整数颜色还原方式。
解压后的数据布局:
plane1: width * height / 4 bytes,signed chroma-like
plane2: width * height / 4 bytes,signed chroma-like
plane3: width * height bytes,luma-like
每个色度采样对应一个 2x2 像素块:
(x, y)
(x + 1, y)
(x, y + 1)
(x + 1, y + 1)
还原公式:
B = clamp(((Y << 7) + 226 * Cb) >> 7);
G = clamp(((Y << 7) - 43 * Cb - 89 * Cr) >> 7);
R = clamp(((Y << 7) + 179 * Cr) >> 7);
汇编中可关注这些常数:
imul reg, 226
imul reg, -43
imul reg, -89
imul reg, 179
sar reg, 7
伪 C:
void decode_filter2(uint8_t *data, int width, int height, uint8_t *out_bgr) {
int block_size = width * height / 4;
int8_t *plane1 = (int8_t *)(data + 0 * block_size);
int8_t *plane2 = (int8_t *)(data + 1 * block_size);
uint8_t *plane3 = data + 2 * block_size;
for (int y = 0; y < height / 2; y++) {
for (int x = 0; x < width / 2; x++) {
int cb = *plane1++;
int cr = *plane2++;
int value_b = 226 * cb;
int value_g = -43 * cb - 89 * cr;
int value_r = 179 * cr;
for each pixel in 2x2 block {
int Y = plane3[pixel_index];
B = clamp(((Y << 7) + value_b) >> 7);
G = clamp(((Y << 7) + value_g) >> 7);
R = clamp(((Y << 7) + value_r) >> 7);
}
}
}
}
filter_type = 3
当前样本中最常见的是 filter_type = 3。这种类型使用行差分滤波,保存的不是最终像素,而是与预测值相关的差值。
解压后的结构:
offset size meaning
0x00 2 unknown
0x02 2 bit depth,通常 24 或 32
0x04 2 width
0x06 2 height
0x08 height delta_spec,每行一个滤波模式
... variable pixel delta data
通道数:
channels = depth >> 3;
常见情况:
depth = 24 -> BGR
depth = 32 -> BGRA
每行一个 delta 模式:
1 = left predictor
2 = upper predictor
4 = average(left, upper) predictor
mode 1
使用左侧像素作为预测:
dst[x] = dst[x - channels] - dst[x];
mode 2
使用上一行像素作为预测:
dst[x] = prev_line[x] - dst[x];
mode 4
使用左侧和上方平均值作为预测:
mean = (prev_line[x] + dst[x - channels]) / 2;
dst[x] = mean - dst[x];
注意这里是减法,不是加法。若写成 predictor + delta,图像会出现明显噪声、颜色错乱或轮廓异常。
伪 C:
void decode_filter3(uint8_t *data, int width, int height) {
uint16_t depth = read_u16le(data + 2);
uint16_t w2 = read_u16le(data + 4);
uint16_t h2 = read_u16le(data + 6);
if (w2 != width || h2 != height) {
error("dimension mismatch");
}
int channels = depth / 8;
uint8_t *delta_spec = data + 8;
uint8_t *pixels = data + 8 + height;
int stride = width * channels;
for (int y = 0; y < height; y++) {
uint8_t mode = delta_spec[y];
uint8_t *row = pixels + y * stride;
uint8_t *prev = pixels + (y - 1) * stride;
if (mode == 1) {
for (int x = channels; x < stride; x++) {
row[x] = row[x - channels] - row[x];
}
} else if (mode == 2) {
for (int x = 0; x < stride; x++) {
row[x] = prev[x] - row[x];
}
} else if (mode == 4) {
for (int x = channels; x < stride; x++) {
int mean = (prev[x] + row[x - channels]) / 2;
row[x] = mean - row[x];
}
} else {
error("unknown delta mode");
}
}
}
实际实现时需要注意第一行边界。mode = 2 和 mode = 4 都依赖上一行,第一行没有上一行,建议将上一行视为 0 或做显式保护。
BGR / BGRA 转 PNG
GE 解码后的像素顺序是 BGR 或 BGRA,而 PNG 常用 RGB 或 RGBA。保存 PNG 前需要交换红蓝通道。
BGR 转 RGB:
R = BGR[2];
G = BGR[1];
B = BGR[0];
BGRA 转 RGBA:
R = BGRA[2];
G = BGRA[1];
B = BGRA[0];
A = BGRA[3];
如果转换结果整体偏蓝或偏红,通常就是没有交换 R/B 通道。
转换脚本说明
转换脚本名称:
pgd_ge_to_png.py
支持命令行和 UI。
打开 UI:
python pgd_ge_to_png.py
或:
python pgd_ge_to_png.py --ui
单文件转换:
python pgd_ge_to_png.py BK000D.PGD -o BK000D.png --overwrite
目录批量转换:
python pgd_ge_to_png.py pgd_folder -o png_out --overwrite
多文件并行转换:
python pgd_ge_to_png.py pgd_folder -o png_out --overwrite -j 4
UI 功能包括:
-
选择多个 PGD 文件。
-
选择 PGD 文件夹。
-
可选递归扫描子目录。
-
可选 PNG 导出目录。
-
可选覆盖已存在 PNG。
-
可设置并行数。
-
可中途停止转换。
-
日志显示成功、跳过和失败原因。
调试与排错
头部检查:
magic 是否为 "GE \0"
width / height 是否合理
filter_type 是否为 2 或 3
packed_size 是否小于文件大小
payload_offset + packed_size 是否越界
解压检查:
解压输出长度是否等于 unpacked_size
是否出现 invalid back-reference
literal run 是否越界
控制位方向是否正确
short / long back-reference 判断是否写反
filter 3 检查:
depth 是否为 24 或 32
channels 是否为 3 或 4
width2 / height2 是否等于 header 中的 width / height
delta_spec 长度是否等于 height
pixel_data 长度是否足够
输出检查:
PNG 尺寸是否正确
颜色是否红蓝互换
透明通道是否正常
图片是否出现横向错位
常见错误定位:
解压阶段报错:
优先检查 payload_offset、packed_size、unpacked_size 和控制位解析。
图像横向错位:
优先检查 stride、width、channels。
颜色偏蓝或偏红:
优先检查 BGR/BGRA 到 RGB/RGBA 的通道交换。
图像噪声明显:
优先检查 filter_type = 3 的减法方向,以及 mode 1/2/4 分支。
验证结果
已验证样本包括:
BK000D.PGD -> 1920 x 1080 PNG
EMSA019.PGD -> 256 x 256 PNG
背景图样本可以正确输出为标准 PNG。遮罩或特效类样本也能正常显示,说明解压、delta 还原、BGR/BGRA 通道转换均与样本行为吻合。
逆向证据链总结
文件样本证据:
多个 PGD 样本以 "GE \0" 开头。
头部 0x0C / 0x10 可读出合理宽高。
程序行为证据:
PAL.dll 检查 "GE"、"PGD2"、"PGD3"。
GE 分支读取 width、height、filter_type、packed_size、unpacked_size。
算法证据:
解压循环存在 LZ 回溯复制特征。
filter_type = 2 分支存在固定颜色还原系数。
filter_type = 3 分支存在逐行 delta 还原逻辑。
结果验证证据:
转换出的 PNG 宽高与 header 一致。
背景图、遮罩图可正常显示。
BGR/BGRA 转 RGB/RGBA 后颜色正常。
这些证据形成闭环:文件头能解释程序读取,程序分支能解释算法,算法输出能被样本图像验证。
发布与许可说明
算法分析参考并校对过公开项目中 Amuse Craft PGD GE 解码相关实现
注意
本文提供工具用于 GE 型 PGD 转 PNG。
使用前需要先从 PAC 中解包出 PGD 文件。
当前支持 filter_type = 2 / 3。
当前不支持 PGD2 / PGD3。
