Shawarma Legend 解包说明
本文记录 Shawarma Legend 当前目录资源的解包依据、IDA 逆向证据,以及 unpack_shawarma.py 的实现逻辑。
文章采取MIT协议
结论概览
这是 GameMaker/YoYo Runtime 的资源布局。
目录中的关键文件:
-
data.win:GameMaker 主数据容器。 -
*.yytex:外置纹理页。 -
audiogroup*.dat:外置音频组。 -
*.ogg:外置散装音频。
当前脚本完成:
-
*.yytex-> PNG -
audiogroup*.dat-> OGG -
散装
*.ogg-> 复制到对应输出目录
当前脚本没有做:
- 解析
data.win中的SPRT/TPAG来把大图集切成单个 sprite。
data.win 依据
data.win 文件开头:
46 4f 52 4d ... 47 45 4e 38
FORM ... GEN8
按 GameMaker 的 FORM chunk 结构扫描后,可以看到这些 chunk:
GEN8
OPTN
LANG
EXTN
SOND
AGRP
SPRT
BGND
PATH
SCPT
GLOB
SHDR
FONT
OBJT
ROOM
EMBI
TPAG
TGIN
STRG
TXTR
AUDO
这些名字和 GameMaker 数据文件结构一致,因此优先按 GameMaker 资源格式处理。
yytex 外层结构
任意 *.yytex 开头可以看到:
32 7a 6f 71 00 08 00 08 6f 7b 56 00 42 5a 68 39
2 z o q width height size B Z h 9
外层结构:
0x00 4 bytes magic: "2zoq"
0x04 2 bytes width, little-endian
0x06 2 bytes height, little-endian
0x08 4 bytes uncompressed fioq size, little-endian
0x0c ... bzip2 stream, starts with "BZh"
例如 tg_game_0.yytex:
0x00: "2zoq"
0x04: 00 08 -> 2048
0x06: 00 08 -> 2048
0x08: 6f 7b 56 00 -> 0x567b6f
0x0c: "BZh9"
bzip2 解压后得到 fioq 数据。
fioq 内层结构
bzip2 解压后的开头:
66 69 6f 71 00 08 00 08 63 7b 56 00 ...
f i o q width height encoded_size
内层结构:
0x00 4 bytes magic: "fioq"
0x04 2 bytes width
0x06 2 bytes height
0x08 4 bytes encoded pixel payload size
0x0c ... encoded pixel payload
注意:fioq 不是标准 QOI。标准 QOI 解码器会输出横向扫线花屏。
IDA 依据:图片识别入口
在 Shawarma Legend.exe 中搜索字符串/立即数:
"fioq"
"2zoq"
"GIF8"
"PNG"
能定位到多处图片格式分发逻辑。其中最关键的一处文件偏移在:
0x50f000 - 0x50f3b0
这个函数会同时处理:
-
2zoq:BZ2 + GameMaker QOI -
fioq:GameMaker QOI -
GIF8 -
JPEG/PNG 等其他路径
反汇编中的关键判断:
cmp eax/edx, 0x716f7a32 ; "2zoq" little-endian
cmp eax/edx, 0x716f6966 ; "fioq" little-endian
0x716f7a32 按 little-endian 展开是:
32 7a 6f 71 = "2zoq"
0x716f6966 按 little-endian 展开是:
66 69 6f 71 = "fioq"
IDA 依据:2zoq 先走 BZ2
在 0x50f06a 附近有 2zoq 判断:
cmp r13d, 716F7A32h ; "2zoq"
jne short use_input_as_fioq
mov edi, [rbx+8] ; 读取 0x08 的解压后长度
lea r8, [rbx+0Ch] ; BZ2 数据从 0x0c 开始
call ... ; BZ2 解压相关函数
这说明:
-
2zoq文件不是直接图片数据。 -
[rbx+8]是解压后的fioq数据长度。 -
[rbx+0x0c]是 bzip2 数据起点。 -
解压成功后,后续会把解压结果当成
fioq继续处理。
EXE 内还可以搜到 bzip 相关字符串,例如:
bzip
这和 yytex 中 BZh9 文件头互相印证。
IDA 依据:fioq 头字段
在 0x50f0d7 到 0x50f121 附近,函数开始验证并读取 fioq 头。
关键反汇编含义:
movzx ebp, word ptr [r14+4] ; width
cmp word ptr [r14+6], 0 ; height 非 0
cmp dword ptr [r14], 716F6966h ; "fioq"
lea rsi, [r14+0Ch] ; pixel payload 起点
mov ebx, 0FF000000h ; 初始像素 alpha = 255
因此 fioq 头可以确认:
0x00 dword magic "fioq"
0x04 word width
0x06 word height
0x08 dword encoded payload size
0x0c bytes encoded payload
像素初始状态:
r = 0
g = 0
b = 0
a = 255
以 32-bit 表示就是:
0xff000000
IDA 依据:像素循环
在 0x50f121 后面能看到:
movzx ecx, word ptr [r14+6]
imul ecx, ebp
movsxd r9, ecx
含义:
total_pixels = width * height
然后循环从 rsi = fioq + 0x0c 读取 opcode,每次向输出缓冲写 4 字节像素。
输出写入处:
mov dword ptr [r8], ebx
add r8, 4
说明每个像素是 32-bit。
IDA 依据:opcode 分支
主分支从读取一个 opcode 开始:
movzx edx, byte ptr [rsi]
inc rsi
mov eax, edx
and eax, 40h
test dl, dl
jns index_or_run_path
也就是:
-
b1 < 0x80:走 index/run。 -
b1 >= 0x80:走 diff/color。
0x00-0x3f:index
在 0x50f2cf 附近:
test eax, eax ; b1 & 0x40
jne run_path
mov ebx, [rsp+rdx*4+50h]
含义:
if b1 < 0x40:
pixel = index[b1]
0x40-0x5f:short run
在 0x50f2d9 附近:
mov edi, edx
and edi, 1Fh
test dl, 20h
je write_repeated
含义:
if 0x40 <= b1 < 0x60:
run = b1 & 0x1f
0x60-0x7f:long run
紧接着:
movzx eax, byte ptr [rsi]
inc rsi
shl edi, 8
or edi, eax
add edi, 20h
含义:
if 0x60 <= b1 < 0x80:
run = ((b1 & 0x1f) << 8) | next_byte
run += 0x20
0x80-0xbf:diff8
在 0x50f153 附近:
; b1 >= 0x80 且 !(b1 & 0x40)
delta 只取低 6 位中的颜色位,tag 位不能参与:
r delta: b1 & 0x30
g delta: b1 & 0x0c
b delta: b1 & 0x03
脚本中对应:
px = patch_lane(px, sar32((b1 & 0x30) << 26, 30), 0x000000FF)
px = patch_lane(px, sar32((b1 & 0x0C) << 28, 22) & 0xFFFFFF00, 0x0000FF00)
px = patch_lane(px, sar32((b1 & 0x03) << 30, 14), 0x00FF0000)
0xc0-0xdf:diff16
在 0x50f188 附近:
; b1 >= 0xc0 且 !(b1 & 0x20)
movzx eax, byte ptr [rsi]
inc rsi
shl edx, 8
or edx, eax
对应合并两个字节:
merged = (b1 << 8) | b2
有效字段:
r delta: merged & 0x1f00
g delta: merged & 0x00f0
b delta: merged & 0x000f
0xe0-0xef:diff24
在 0x50f1de 附近:
; b1 >= 0xe0 且 !(b1 & 0x10)
movzx eax, byte ptr [rsi]
movzx ecx, byte ptr [rsi+1]
add rsi, 2
对应合并三个字节:
merged = (b1 << 16) | (b2 << 8) | b3
有效字段:
r delta: merged & 0x0f8000
g delta: merged & 0x007c00
b delta: merged & 0x0003e0
a delta: merged & 0x00001f
0xf0-0xff:literal color mask
在 0x50f249 附近:
test dl, 8 ; read r
test dl, 4 ; read g
test dl, 2 ; read b
test dl, 1 ; read a
含义:
if b1 & 0x08: r = next_byte
if b1 & 0x04: g = next_byte
if b1 & 0x02: b = next_byte
if b1 & 0x01: a = next_byte
IDA 依据:index 哈希
在写像素后,0x50f2a7 附近会更新 index 表。
关键逻辑:
mov edx, ebx
mov eax, ebx
shr rax, 8
mov rcx, rax
xor rcx, rdx
shr rcx, 10h
xor rcx, rax
xor rcx, rdx
and ecx, 3Fh
mov [rsp+rcx*4+50h], ebx
这个逻辑化简后等价于:
index[(r ^ g ^ b ^ a) & 63] = pixel
这里 pixel 是 32-bit 状态,脚本里按 little-endian 写出。
为什么第一版会花屏
第一版按标准 QOI 猜测,错误点主要有两个:
-
标准 QOI 的 tag 排布和 GameMaker
fioq不同。 -
delta 计算时没有 mask 掉 tag 位。
例如 diff8 中,必须只取:
b1 & 0x30
b1 & 0x0c
b1 & 0x03
如果直接拿整个 b1 做位移,0x80 这个 tag 位会混进颜色差分。由于 QOI 是状态式编码,当前像素错了会影响后续 delta 和 index,所以表现为横向扫线花屏。
audiogroup 音频格式
audiogroup*.dat 开头:
46 4f 52 4d ... 41 55 44 4f
FORM ... AUDO
结构:
0x00 4 bytes magic: "FORM"
0x04 4 bytes form size
0x08 4 bytes magic: "AUDO"
0x0c 4 bytes audio payload size
0x10 4 bytes entry count
0x14 ... uint32 offset table
每个 offset 指向:
0x00 4 bytes ogg size
0x04 ... OggS data
例如 audiogroup1.dat:
0x00: "FORM"
0x08: "AUDO"
0x10: 2f 00 00 00 -> 47 entries
0xd0: 52 8b 00 00 -> first ogg size
0xd4: 4f 67 67 53 -> "OggS"
所以脚本按 offset table 读取每个 size + OggS,导出为独立 .ogg。
输出结构
脚本输出到:
unpacked/
按原始封包文件名建立目录:
unpacked/
tg_logo_0/
tg_logo_0.png
tg_bg_0_0/
tg_bg_0_0.png
audiogroup1/
audiogroup1_000.ogg
audiogroup1_001.ogg
music_1/
music_1.ogg
使用方法
在游戏目录运行:
python .\unpack_shawarma.py
当前已验证输出:
21 PNG textures
963 OGG files from audiogroup*.dat
17 loose OGG files copied
依赖
脚本依赖 Python 和 Pillow:
python -m pip install pillow
Python 标准库使用:
-
bz2 -
struct -
pathlib -
shutil
交叉验证
除了 IDA 里看到的运行时逻辑,还对照了 UndertaleModTool 的 GameMaker QOI 实现。
UndertaleModTool 中相关文件:
UndertaleModLib/Util/QoiConverter.cs
UndertaleModLib/Util/GMImage.cs
其中也把这个格式标为 GameMaker custom QOI,并使用同样的 opcode 分布和哈希规则。这作为交叉验证,但脚本实现主要依据仍是本游戏 EXE 中的解码函数。
前置环境
pip install pillow
python脚本如下
from __future__ import annotations
import bz2
import shutil
import struct
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent
OUT = ROOT / "unpacked"
def u32(value: int) -> int:
return value & 0xFFFFFFFF
def sar32(value: int, shift: int) -> int:
value &= 0xFFFFFFFF
if value & 0x80000000:
value -= 0x100000000
return value >> shift
def fioq_hash(px: int) -> int:
return (px ^ (px >> 8) ^ (px >> 16) ^ (px >> 24)) & 0x3F
def patch_lane(px: int, value: int, mask: int) -> int:
return px ^ (((px + value) ^ px) & mask)
def decode_fioq(payload: bytes) -> Image.Image:
if payload[:4] != b"fioq":
raise ValueError(f"not a fioq payload: {payload[:4]!r}")
width, height = struct.unpack_from("<HH", payload, 4)
data = memoryview(payload)[12:]
pixels = bytearray(width * height * 4)
index = [0] * 64
px = 0xFF000000
in_pos = 0
out_pos = 0
run = 0
while out_pos < len(pixels):
if run:
run -= 1
else:
b1 = data[in_pos]
in_pos += 1
if b1 < 0x80:
if b1 < 0x40:
px = index[b1]
else:
run = b1 & 0x1F
if b1 & 0x20:
run = (run << 8) | data[in_pos]
in_pos += 1
run += 0x20
else:
if not (b1 & 0x40):
px = patch_lane(px, sar32((b1 & 0x30) << 26, 30), 0x000000FF)
px = patch_lane(px, sar32((b1 & 0x0C) << 28, 22) & 0xFFFFFF00, 0x0000FF00)
px = patch_lane(px, sar32((b1 & 0x03) << 30, 14), 0x00FF0000)
elif not (b1 & 0x20):
b2 = data[in_pos]
in_pos += 1
value = (b1 << 8) | b2
px = patch_lane(px, sar32((value & 0x1F00) << 19, 27), 0x000000FF)
px = patch_lane(px, sar32((value & 0x00F0) << 24, 20) & 0xFFFFFF00, 0x0000FF00)
px = patch_lane(px, sar32((value & 0x000F) << 28, 12), 0x00FF0000)
elif not (b1 & 0x10):
b2 = data[in_pos]
b3 = data[in_pos + 1]
in_pos += 2
value = (b1 << 16) | (b2 << 8) | b3
px = patch_lane(px, sar32((value & 0x0F8000) << 12, 27), 0x000000FF)
px = patch_lane(px, sar32((value & 0x007C00) << 17, 19) & 0xFFFFFF00, 0x0000FF00)
px = patch_lane(px, sar32((value & 0x0003E0) << 22, 11), 0x00FF0000)
px = u32(px + (sar32((value & 0x00001F) << 27, 3) & 0xFF000000))
else:
if b1 & 0x08:
px = (px & 0xFFFFFF00) | data[in_pos]
in_pos += 1
if b1 & 0x04:
px = (px & 0xFFFF00FF) | (data[in_pos] << 8)
in_pos += 1
if b1 & 0x02:
px = (px & 0xFF00FFFF) | (data[in_pos] << 16)
in_pos += 1
if b1 & 0x01:
px = (px & 0x00FFFFFF) | (data[in_pos] << 24)
in_pos += 1
index[fioq_hash(px)] = px
pixels[out_pos : out_pos + 4] = px.to_bytes(4, "little")
out_pos += 4
return Image.frombytes("RGBA", (width, height), bytes(pixels))
def unpack_yytex(path: Path, out_dir: Path) -> None:
raw = path.read_bytes()
if raw[:4] != b"2zoq" or raw[12:15] != b"BZh":
raise ValueError(f"unknown yytex header in {path.name}")
image = decode_fioq(bz2.decompress(raw[12:]))
image.save(out_dir / f"{path.stem}.png")
def unpack_audiogroup(path: Path, out_dir: Path) -> int:
raw = path.read_bytes()
if raw[:4] != b"FORM" or raw[8:12] != b"AUDO":
raise ValueError(f"unknown audiogroup header in {path.name}")
count = struct.unpack_from("<I", raw, 16)[0]
offsets = [struct.unpack_from("<I", raw, 20 + i * 4)[0] for i in range(count)]
written = 0
for i, off in enumerate(offsets):
if off + 4 > len(raw):
continue
size = struct.unpack_from("<I", raw, off)[0]
start = off + 4
end = start + size
chunk = raw[start:end]
if chunk[:4] != b"OggS":
continue
(out_dir / f"{path.stem}_{i:03}.ogg").write_bytes(chunk)
written += 1
return written
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
tex_count = 0
for path in sorted(ROOT.glob("*.yytex")):
tex_out = OUT / path.stem
tex_out.mkdir(parents=True, exist_ok=True)
unpack_yytex(path, tex_out)
tex_count += 1
audio_count = 0
for path in sorted(ROOT.glob("audiogroup*.dat")):
audio_out = OUT / path.stem
audio_out.mkdir(parents=True, exist_ok=True)
audio_count += unpack_audiogroup(path, audio_out)
copied_ogg = 0
for path in sorted(ROOT.glob("*.ogg")):
audio_out = OUT / path.stem
audio_out.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, audio_out / path.name)
copied_ogg += 1
print(f"textures: {tex_count} PNG files -> {OUT}\\<yytex name>\\")
print(f"audiogroup sounds: {audio_count} OGG files -> {OUT}\\<audiogroup name>\\")
print(f"loose ogg copied: {copied_ogg} files -> {OUT}\\<ogg name>\\")
if __name__ == "__main__":
main()