#!/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())