沙威玛传奇游戏引擎解包

逆向工程 实用技术 其它引擎解包GameMakerYoYo Runtime
浏览数 - 61发布于 - 2026-04-29 - 01:20

重新编辑于 - 2026-04-29 - 01:29

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 文件开头:

text
46 4f 52 4d ... 47 45 4e 38
FORM         ... GEN8

按 GameMaker 的 FORM chunk 结构扫描后,可以看到这些 chunk:

text
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 开头可以看到:

text
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

外层结构:

text
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

text
0x00: "2zoq"
0x04: 00 08 -> 2048
0x06: 00 08 -> 2048
0x08: 6f 7b 56 00 -> 0x567b6f
0x0c: "BZh9"

bzip2 解压后得到 fioq 数据。

fioq 内层结构

bzip2 解压后的开头:

text
66 69 6f 71 00 08 00 08 63 7b 56 00 ...
f  i  o  q  width height encoded_size

内层结构:

text
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 中搜索字符串/立即数:

text
"fioq"
"2zoq"
"GIF8"
"PNG"

能定位到多处图片格式分发逻辑。其中最关键的一处文件偏移在:

text
0x50f000 - 0x50f3b0

这个函数会同时处理:

  • 2zoq:BZ2 + GameMaker QOI

  • fioq:GameMaker QOI

  • GIF8

  • JPEG/PNG 等其他路径

反汇编中的关键判断:

text
cmp eax/edx, 0x716f7a32    ; "2zoq" little-endian
cmp eax/edx, 0x716f6966    ; "fioq" little-endian

0x716f7a32 按 little-endian 展开是:

text
32 7a 6f 71 = "2zoq"

0x716f6966 按 little-endian 展开是:

text
66 69 6f 71 = "fioq"

IDA 依据:2zoq 先走 BZ2

0x50f06a 附近有 2zoq 判断:

text
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 相关字符串,例如:

text
bzip

这和 yytexBZh9 文件头互相印证。

IDA 依据:fioq 头字段

0x50f0d70x50f121 附近,函数开始验证并读取 fioq 头。

关键反汇编含义:

text
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 头可以确认:

text
0x00 dword magic  "fioq"
0x04 word  width
0x06 word  height
0x08 dword encoded payload size
0x0c bytes encoded payload

像素初始状态:

text
r = 0
g = 0
b = 0
a = 255

以 32-bit 表示就是:

text
0xff000000

IDA 依据:像素循环

0x50f121 后面能看到:

text
movzx   ecx, word ptr [r14+6]
imul    ecx, ebp
movsxd  r9, ecx

含义:

text
total_pixels = width * height

然后循环从 rsi = fioq + 0x0c 读取 opcode,每次向输出缓冲写 4 字节像素。

输出写入处:

text
mov     dword ptr [r8], ebx
add     r8, 4

说明每个像素是 32-bit。

IDA 依据:opcode 分支

主分支从读取一个 opcode 开始:

text
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 附近:

text
test    eax, eax           ; b1 & 0x40
jne     run_path
mov     ebx, [rsp+rdx*4+50h]

含义:

text
if b1 < 0x40:
    pixel = index[b1]

0x40-0x5f:short run

0x50f2d9 附近:

text
mov     edi, edx
and     edi, 1Fh
test    dl, 20h
je      write_repeated

含义:

text
if 0x40 <= b1 < 0x60:
    run = b1 & 0x1f

0x60-0x7f:long run

紧接着:

text
movzx   eax, byte ptr [rsi]
inc     rsi
shl     edi, 8
or      edi, eax
add     edi, 20h

含义:

text
if 0x60 <= b1 < 0x80:
    run = ((b1 & 0x1f) << 8) | next_byte
    run += 0x20

0x80-0xbf:diff8

0x50f153 附近:

text
; b1 >= 0x80 且 !(b1 & 0x40)

delta 只取低 6 位中的颜色位,tag 位不能参与:

text
r delta: b1 & 0x30
g delta: b1 & 0x0c
b delta: b1 & 0x03

脚本中对应:

python
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 附近:

text
; b1 >= 0xc0 且 !(b1 & 0x20)
movzx   eax, byte ptr [rsi]
inc     rsi
shl     edx, 8
or      edx, eax

对应合并两个字节:

text
merged = (b1 << 8) | b2

有效字段:

text
r delta: merged & 0x1f00
g delta: merged & 0x00f0
b delta: merged & 0x000f

0xe0-0xef:diff24

0x50f1de 附近:

text
; b1 >= 0xe0 且 !(b1 & 0x10)
movzx   eax, byte ptr [rsi]
movzx   ecx, byte ptr [rsi+1]
add     rsi, 2

对应合并三个字节:

text
merged = (b1 << 16) | (b2 << 8) | b3

有效字段:

text
r delta: merged & 0x0f8000
g delta: merged & 0x007c00
b delta: merged & 0x0003e0
a delta: merged & 0x00001f

0xf0-0xff:literal color mask

0x50f249 附近:

text
test    dl, 8   ; read r
test    dl, 4   ; read g
test    dl, 2   ; read b
test    dl, 1   ; read a

含义:

text
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 表。

关键逻辑:

text
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

这个逻辑化简后等价于:

text
index[(r ^ g ^ b ^ a) & 63] = pixel

这里 pixel 是 32-bit 状态,脚本里按 little-endian 写出。

为什么第一版会花屏

第一版按标准 QOI 猜测,错误点主要有两个:

  • 标准 QOI 的 tag 排布和 GameMaker fioq 不同。

  • delta 计算时没有 mask 掉 tag 位。

例如 diff8 中,必须只取:

text
b1 & 0x30
b1 & 0x0c
b1 & 0x03

如果直接拿整个 b1 做位移,0x80 这个 tag 位会混进颜色差分。由于 QOI 是状态式编码,当前像素错了会影响后续 delta 和 index,所以表现为横向扫线花屏。

audiogroup 音频格式

audiogroup*.dat 开头:

text
46 4f 52 4d ... 41 55 44 4f
FORM         ... AUDO

结构:

text
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 指向:

text
0x00  4 bytes   ogg size
0x04  ...       OggS data

例如 audiogroup1.dat

text
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

输出结构

脚本输出到:

text
unpacked/

按原始封包文件名建立目录:

text
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

使用方法

在游戏目录运行:

powershell
python .\unpack_shawarma.py

当前已验证输出:

text
21 PNG textures
963 OGG files from audiogroup*.dat
17 loose OGG files copied

依赖

脚本依赖 Python 和 Pillow:

powershell
python -m pip install pillow

Python 标准库使用:

  • bz2

  • struct

  • pathlib

  • shutil

交叉验证

除了 IDA 里看到的运行时逻辑,还对照了 UndertaleModTool 的 GameMaker QOI 实现。

UndertaleModTool 中相关文件:

text
UndertaleModLib/Util/QoiConverter.cs
UndertaleModLib/Util/GMImage.cs

其中也把这个格式标为 GameMaker custom QOI,并使用同样的 opcode 分布和哈希规则。这作为交叉验证,但脚本实现主要依据仍是本游戏 EXE 中的解码函数。

前置环境

javascript
pip install pillow

python脚本如下

javascript
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()

本文版权遵循 CC BY-NC 协议 本站版权政策

(。>︿<。) 已经一滴回复都不剩了哦~