找回密码
 registerss
搜索
查看: 3|回复: 1

[游戏攻略] TLBB .Skin 皮肤文件解包提取内嵌图片

[复制链接]
Waylee 发表于 2026-9-20 14:47 | 显示全部楼层 |阅读模式 | Google Chrome | Windows 10

马上注册,查看网站隐藏内容!!

您需要 登录 才可以下载或查看,没有账号?registerss

×

title: TLBB .Skin 皮肤文件解包提取内嵌图片
summary: 解析天龙八部 UI 皮肤文件(*.Skin,TLSkin 格式 = zlib 包 + 1 张大 PNG 图集 + N 张顺序拼接的 BMP 按钮图),提取出全部内嵌图片并导出为可预览 PNG
read_when:

  • 需要从 *.Skin 文件里提取图片 / 素材
  • 看到文件头是 78da(zlib)或解压后出现 TLSkin 魔数
  • 要还原天龙八部登录器 / 客户端的按钮、图集素材
    agent_created: true

TLBB .Skin 解包提取图片

格式真相

*.Skin = 单个 zlib 流(文件头 78 da)。解压后得到 TLSkin 结构:

区段 内容
0x00 0x06 + 魔数 "TLSkin"(7 字节)+ 补零到 0x20
0x20 20 字节/条的 图集切片表(flag:u32, x:u32, y:u32, w:u32, h:u32),遇到不合理的大数值即表结束
~0x160 杂项/调色/尺寸表,可忽略
0x234(=564) 图集起始(固定偏移,3/3 样本一致),格式有两种:<br>\x89PNG → 到 IEND+CRC 止(PNG 变体)<br>\xff\xd8\xff → 到 \xff\xd9 EOI 止(JPEG 变体
图集结束后 N 张 BMP 顺序紧密排列,无分隔符。每张自带 BM 头 + bfSize,按 bfSize 递推即可

图集切片表的 (x,y,w,h) 正好落在图集尺寸范围内(例:831,243,18,18),对应 UI 控件在背景图上的取图区域。BMP 则是按钮的多种态(常态/悬停/按下/禁用),命名里能看出用途。

实测样本差异

文件 图集格式 图集尺寸 BMP 张数
皮肤.Skin PNG 1023x721 20
皮肤2.Skin JPEG 800x600 17
皮肤5.Skin PNG 855x700 30

不要假设图集一定是 PNG。判定顺序:先按 0x234 读前 8 字节,\x89PNG 走 PNG 分支,\xff\xd8\xff 走 JPEG 分支。

一键提取

脚本:extract_skin.py(本 skill 目录内)

python extract_skin.py "皮肤3.Skin" -o 皮肤3_extracted

产出:

  • skin_atlas.png / skin_atlas.jpg — 图集原图(按实际格式无损直出)
  • image_NN_<w>x<h>_<bpp>bpp.bmp — 原始 BMP 逐张切出,文件名带尺寸便于识别
  • preview_png/*.png — BMP 转 PNG,方便直接预览
  • contact_sheet.png — 全部缩略图拼版总览

批量:for f in *.Skin; do python extract_skin.py "$f"; done(各自输出到 <名>_extracted/

必须按顺序走的坑

  1. 不要用 d.find(b'BM') 全量扫——图集像素数据(PNG 或 JPEG)里会命中原生 BM 字节,产生一堆假 BMP(bfSize 是天文数字,实测 41 个命中里 24 个是假的)。必须从图集结束点之后开始,按 bfSize 顺序递推,并用 bfSize 合法性 + 尺寸 < 4096 + bpp ∈ {1,4,8,16,24,32} 做三重校验。
  2. PNG 结束点 = d.find(b'IEND') + 4 + 4(IEND 类型 4 字节 + CRC 4 字节),不是 +4
  3. JPEG 结束点不能用 find(b'\xff\xd9')——熵编码数据里可能碰巧出现。正确做法是走 marker 链:跳过 APP/DQT/SOF/DHT,遇到 FFDA(SOS) 后扫到下一个非 0x00 填充、非 RST(FFD0..FFD7) 的 marker,再继续走到 FFD9(EOI)。
  4. JPEG 变体的 APP13/APP1 里嵌有缩略图,会在文件中途再次出现 JFIF / \xff\xd8\xff 字样,别误判成多张图。(皮肤2 实测 564 / 2014 / 6568 三处命中,实际只有 1 张。)
  5. BMP 的文件头里 bfOffBits 可能是 54 / 1078 / 其他——8bpp 带调色板时是 1078,说明是索引色图,转 PNG 时用 PIL 直接 convert('RGBA') 即可,别手动当 24bpp 解。32bpp 的按钮(皮肤.Skin 的「首页」)带 alpha。
  6. BMP 的 biHeight 为正表示自底向上存储,用 PIL 打开会自动处理,不要自己按行倒序。
  7. 提取目录和源文件同名前缀,避免多次操作互相覆盖。

依赖

pip install pillow(PIL 11.3.0 实测可用)。zlib / struct 为标准库。




 楼主| Waylee 发表于 2026-9-20 14:53 | 显示全部楼层 | Google Chrome | Windows 10
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""TLBB .Skin 皮肤文件解包 —— 提取内嵌的 PNG 图集与全部 BMP 按钮图。

用法:
    python extract_skin.py <file.Skin> [-o 输出目录] [--no-preview]

格式说明见同目录 SKILL.md。
"""
import argparse
import os
import struct
import sys
import zlib

PNG_SIG = b'\x89PNG\r\n\x1a\n'
JPG_SOI = b'\xff\xd8\xff'
# 图集固定从 0x234 (564) 开始 —— 三个样本一致
ATLAS_OFF = 0x234
VALID_BPP = (1, 4, 8, 16, 24, 32)

def load(path):
    raw = open(path, 'rb').read()
    if raw[:2] == b'\x78\xda' or raw[:1] == b'\x78':
        data = zlib.decompress(raw)
        print(f'[i] zlib 解压: {len(raw)} -> {len(data)} bytes')
    else:
        data = raw
        print(f'[i] 未压缩, 直接解析: {len(data)} bytes')
    if b'TLSkin' not in data[:64]:
        print('[!] 警告: 前 64 字节未见 TLSkin 魔数, 仍继续尝试')
    return data

def parse_slices(data):
    """解析 0x20 起的图集切片表, 返回 [(flag,x,y,w,h), ...]"""
    out = []
    o = 0x20
    while o + 20 <= len(data):
        flag, x, y, w, h = struct.unpack_from('<5I', data, o)
        if w == 0 or h == 0 or w > 4096 or h > 4096 or x > 8192 or y > 8192:
            break
        out.append((flag, x, y, w, h))
        o += 20
    return out

def _jpeg_end(data, soi):
    """从 SOI 起走 marker 链, 返回 EOI 之后的下一个字节偏移; 失败返回 None。"""
    o = soi + 2
    n = len(data)
    while o < n - 1:
        if data[o] != 0xFF:
            o += 1
            continue
        m = data[o + 1]
        if m == 0xD8:          # SOI
            o += 2
            continue
        if m == 0xD9:          # EOI
            return o + 2
        if m == 0x01 or 0xD0 <= m <= 0xD7:
            o += 2
            continue
        if o + 4 > n:
            return None
        ln = struct.unpack_from('>H', data, o + 2)[0]
        if ln < 2:
            return None
        if m == 0xDA:          # SOS -> 跳到熵编码结束
            o += 2 + ln
            while o < n - 1:
                if data[o] == 0xFF and data[o + 1] != 0x00 and not (0xD0 <= data[o + 1] <= 0xD7):
                    break
                o += 1
            continue
        o += 2 + ln
    return None

def extract_atlas(data):
    """定位并切出图集。返回 (bytes, ext, width, height, end_offset)。"""
    from PIL import Image
    import io
    for name, sig, ext in (('PNG', PNG_SIG, 'png'), ('JPEG', JPG_SOI, 'jpg')):
        # 优先固定偏移, 再全局搜一次兜底
        for p in (ATLAS_OFF, data.find(sig)):
            if p < 0 or data[p:p + len(sig)] != sig:
                continue
            end = (data.find(b'IEND', p) + 4 + 4) if ext == 'png' else _jpeg_end(data, p)
            if not end or end <= p:
                continue
            blob = data[p:end]
            try:
                w, h = Image.open(io.BytesIO(blob)).size
            except Exception:
                continue
            return blob, ext, w, h, end
    return None, None, 0, 0, ATLAS_OFF

def extract_bmps(data, start):
    """从 start 起按 bfSize 顺序递推切出 BMP。返回 [(offset, bytes), ...]"""
    files = []
    o = start
    while o + 54 <= len(data):
        if data[o:o + 2] == b'BM':
            size = struct.unpack_from('<I', data, o + 2)[0]
            w = struct.unpack_from('<i', data, o + 18)[0]
            h = struct.unpack_from('<i', data, o + 22)[0]
            bpp = struct.unpack_from('<H', data, o + 28)[0]
            if (0 < size and 54 <= size and o + size <= len(data)
                    and 0 < w < 4096 and 0 < abs(h) < 4096 and bpp in VALID_BPP):
                files.append((o, data[o:o + size]))
                o += size
                continue
        o += 1
    return files

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('skin', help='*.Skin 文件路径')
    ap.add_argument('-o', '--out', default=None, help='输出目录 (默认 <文件名>_extracted)')
    ap.add_argument('--no-preview', action='store_true', help='跳过 BMP->PNG 转换与拼版')
    args = ap.parse_args()

    base = os.path.splitext(os.path.basename(args.skin))[0]
    out = args.out or f'{base}_extracted'
    os.makedirs(out, exist_ok=True)

    data = load(args.skin)

    slices = parse_slices(data)
    print(f'[i] 图集切片表: {len(slices)} 条')
    for s in slices:
        print('    flag=%d  x=%d y=%d  %dx%d' % s)

    png, ext, aw, ah, png_end = extract_atlas(data)
    if png:
        fn = f'skin_atlas.{ext}'
        open(os.path.join(out, fn), 'wb').write(png)
        print(f'[+] {fn}  {aw}x{ah}  ({len(png)} bytes, offset={png_end - len(png)})')
    else:
        print('[!] 未找到图集 (PNG/JPEG)')
        png_end = ATLAS_OFF

    bmps = extract_bmps(data, png_end)
    print(f'[+] BMP: {len(bmps)} 张')
    names = []
    for n, (off, blob) in enumerate(bmps, 1):
        w = struct.unpack_from('<i', blob, 18)[0]
        h = struct.unpack_from('<i', blob, 22)[0]
        bpp = struct.unpack_from('<H', blob, 28)[0]
        fn = f'image_{n:02d}_{w}x{abs(h)}_{bpp}bpp.bmp'
        open(os.path.join(out, fn), 'wb').write(blob)
        names.append(fn)
        print(f'    {fn:<40} offset={off} size={len(blob)}')

    if not args.no_preview and names:
        try:
            from PIL import Image, ImageDraw
        except ImportError:
            print('[!] 未安装 pillow, 跳过预览转换 (pip install pillow)')
            return
        pv = os.path.join(out, 'preview_png')
        os.makedirs(pv, exist_ok=True)
        if png:
            Image.open(os.path.join(out, f'skin_atlas.{ext}')).save(os.path.join(pv, 'skin_atlas.png'))
        for fn in names:
            im = Image.open(os.path.join(out, fn)).convert('RGBA')
            im.save(os.path.join(pv, os.path.splitext(fn)[0] + '.png'))
        # 拼版总览
        cols = 4
        cw, ch = 280, 120
        rows = (len(names) + cols - 1) // cols
        sheet = Image.new('RGB', (cols * cw, rows * ch), (30, 30, 36))
        dr = ImageDraw.Draw(sheet)
        for i, fn in enumerate(names):
            im = Image.open(os.path.join(out, fn)).convert('RGBA')
            bg = Image.new('RGBA', im.size, (60, 60, 70, 255))
            bg.alpha_composite(im)
            im = bg.convert('RGB')
            im.thumbnail((cw - 20, ch - 40))
            x = (i % cols) * cw + 10
            y = (i // cols) * ch + 28
            sheet.paste(im, (x, y))
            dr.text((x, (i // cols) * ch + 8), fn[:34], fill=(220, 220, 220))
        sheet.save(os.path.join(out, 'contact_sheet.png'))
        print(f'[+] preview_png/ 与 contact_sheet.png 已生成')

    print(f'\n完成 -> {os.path.abspath(out)}')

if __name__ == '__main__':
    sys.exit(main())
您需要登录后才可以回帖 登录 | registerss

本版积分规则

QQ|手机版|雪舞知识库 ( 浙ICP备15015590号-1 | 萌ICP备20232229号|浙公网安备33048102000118号 )

GMT+8, 2026-9-21 15:11 , Processed in 0.060234 second(s), 21 queries .

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表