简介:面向狼头游戏(Wolf RPG Editor)开发者、独立游戏制作者和喜欢研究拆包玩法的玩家,这款资源提供 arcconv(arc)与 TouhouSE 两套解码工具,专门处理加密压缩的 .wolf 资源文件,解决图形、音频、脚本等内容无法直接查看和编辑的痛点,也能应对 arcconv 无法直接解压的特殊加密版本,由 TouhouSE 补充兼容性。压缩包共收录 121 个文件,合计不到 2MB,包含 62 个 cpp 与 33 个 h 源码、7 个可直接运行的 exe 工具,以及 txt 说明、vcxproj 工程配置等,既适合直接调用,也便于在 Visual Studio 中重新编译学习。从文件构成看,资源覆盖东方系列 th135/th145/th155 的列表解析、anm2png 图片转换、pal_extract 调色板提取等功能模块,可帮助快速理解解包流程。目前已有 16528 人学习下载,适合资源解包入门者与进阶用户作为工具手册和代码参考。使用时请遵守版权规定,仅在合法范围内提取素材、学习机制或进行二次创作,尊重原作者的劳动成果。
1. 解包狼头游戏 arc 与 TouhouSE:这套解码工具到底能打开什么
做汉化、做素材复刻、做引擎研究的人,遇到“狼头游戏”这种小厂引擎封包,多半都经历过同一个卡点:得先弄清 arc 是什么、用什么工具能解,否则后面工作根本推不下去。很多横版作品用 arc 归档了几乎全部资源,包括地图数据、文本脚本、立绘和音频,TouhouSE 在这套工具链里专啃音效这块,能把封装在音频容器里的 BGM、人声、SE 单独剥出来转成通用格式。这篇笔记会从 arc 的二进制布局开始讲,再到具体解码参数、踩坑记录和验证方式,适合汉化组、素材研究者、独立游戏开发者参考,拿到手就能照着跑通一条完整的解包链路。
2. arc 文件格式拆解:先看魔数与偏移表,再写通用解析器
2.1 魔数与头部结构:5 分钟判断这是不是标准引擎包
arc 文件本质是一个二进制归档,多数游戏引擎会往头部写一个固定魔数,用于快速识别文件类型和字节序。拿到一个未知 arc 文件,第一步用十六进制工具看头部前 16 字节,常见情况有这几种:
- 带
ARC、PACK、WOLF字样:明文魔数,说明作者没刻意混淆,直接按偏移表解析即可。 - 只有长度字段没有任何字符串:可能是“裸归档”,头部就是 4 字节大端或小端整数,从第 0 字节开始就是文件表。
- 头部出现 ZLIB 或 LZMA 特征:说明归档表可能被压缩,需要先解压再读取索引。
我一般会用 Python 打开文件读前 64 字节做判断,这样能快速的确立解析方向。判断字节序也很关键,用头部前两个字节来做区分,如果前两字节是 0x0000 到 0x0100 之间,多半是小端序,反之要谨慎处理。
import struct def sniff_arc_header(path: str) -> None: with open(path, "rb") as f: head = f.read(64) print("前16字节:", head[:16].hex()) # 检查魔数 if head[:4] == b"ARC\x00" or head[:4] == b"PACK": print("带明文魔数,推测标准归档") else: # 猜测字节序:拿头部第4个字节到第8个字节当u32读 little_endian = struct.unpack("<I", head[4:8])[0] big_endian = struct.unpack(">I", head[4:8])[0] if 0 < little_endian < 0x10000: print("疑似小端字节序,文件表偏移:", little_endian) elif 0 < big_endian < 0x10000: print("疑似大端字节序,文件表偏移:", big_endian) else: print("头部没有直接暴露文件表偏移,需要进一步扫描")这段脚本只做探测,不做实际解码。参数说明:读取前 64 字节而不是全文件,是为了避免一次性载入过大的归档占用内存;<I和>I分别表示小端和大端无符号 32 位整数,如果两个方向读出来都超过合理值,说明头部还可能被加密或做 XOR 处理,就要走搜索特征码的路线。
判断完字节序和魔数之后,真正决定解码成本的往往是文件表的位置。有些引擎会把文件表放在文件尾部,头部只保留一个偏移量,这时候解析顺序是:先读尾部偏移,再跳过去读文件表。还有一种情况是头部全零,要从文件中间找特征,这种就要写一个滑动窗口扫描候选偏移。
2.2 偏移表与文件名表:写出第一个能够运行的 arc 解析器
文件表结构里最核心的东西有三块:文件名的偏移或长度、文件数据的偏移、文件数据的长度。有的引擎会额外记录 CRC32 或时间戳,但那些不直接影响提取。写解析器的第一步是把这三块字段按自己探测到的布局填进一个namedtuple里,然后是批量读表、校验偏移是否越界。
import struct import os from dataclasses import dataclass @dataclass class ArcEntry: name_offset: int data_offset: int size: int name: str = "" def read_arc_table(path: str, table_offset: int, count: int, entry_size: int = 16) -> list[ArcEntry]: entries = [] with open(path, "rb") as f: f.seek(table_offset) raw = f.read(entry_size * count) for i in range(count): base = i * entry_size name_offset, data_offset, size = struct.unpack_from("<III", raw, base) if data_offset == 0 or size == 0: continue # 空条目 entries.append(ArcEntry(name_offset=name_offset, data_offset=data_offset, size=size)) return entries说明:entry_size默认按 16 字节处理,很多简单引擎的文件表条目就是三个 u32,分别是名字偏移、数据偏移和大小。实际使用中如果发现解析出来的偏移大量越界,先调entry_size到 20 或 24 再试。这个参数没法猜,要靠对比正常文件和报错偏移位置的差异来确定。参数里table_offset就是前面从头部读到的那 4 字节值。
拿到条目之后就要读文件名。文件名表的工作方式分两种:一种在文件表里直接存定长字符串(比如每个名字固定占 32 字节),另一种是文件名数据集中在某一块区域,文件表里只存起始偏移和长度。定长的好办,直接切片解码;不定长的要按偏移跳过去读到下一个\x00为止。
def read_name_at(path: str, offset: int) -> str: with open(path, "rb") as f: f.seek(offset) buf = bytearray() while True: ch = f.read(1) if not ch or ch == b"\x00": break buf.append(ch[0]) if len(buf) > 260: break for encoding in ("utf-8", "shift_jis", "gbk"): try: return buf.decode(encoding) except UnicodeDecodeError: continue return buf.hex()这里有一个常见的坑:如果游戏是日系厂商做的,文件名大概率是日文 Shift-JIS 编码,而国内汉化组压包时可能转成了 GBK。解码失败时不要盲目的用errors="ignore",先尝试三种编码各解一次,哪种能成功用哪种。如果三种都失败,通常是文件名表本身没有定位正确,检查name_offset是否为绝对偏移,有些引擎在这里用的是相对偏移,需要加一个基地址才能拿到正确位置。
2.3 数据块压缩与加密变体:不是所有 arc 都一路平推
最省心的 arc 是文件数据直接裸存,读出来就是原始资源。但不少狼头引擎类游戏会做一层简单的 XOR 加密或 ZLIB 压缩,目的不是防破解,而是缩小包体或者防止玩家直接替换素材。遇到这种情况,解析器在读完文件表和名字后,要针对数据区做一层预处理。
常见处理方式包括:
- ZLIB 压缩:头部字节通常以
0x78 0x9C、0x78 0xDA开头,直接用zlib.decompress恢复。 - XOR 单字节加密:整个文件或每个条目单独用一个固定 key 逐字节异或,最常见的写法是
b ^ 0x5A或b ^ 0x3C,也有的会用 4 字节循环 key。 - AES 或自定义流加密:这类一般会带一个密钥推导过程,业内通常直接跳过,转为定位内存中的解密后数据。
import zlib def decompress_entry(data: bytes, xor_key: int = None) -> bytes: if xor_key is not None: data = bytes([b ^ xor_key for b in data]) try: if data[:2] == b"\x78\x9c" or data[:2] == b"\x78\xda": return zlib.decompress(data) return data except zlib.error: return data用这个函数时,先不要默认每一条数据都压缩,先解一个条目看看结果是不是目标文件格式,比如解出来 PNG 会以\x89PNG开头,音频会以RIFF开头,能对上就继续,对不上就把xor_key换成别的值再试。这里需要强调一下,XOR 解密属于常规逆向技术,用于自己拥有的游戏素材研究或已获授权的项目中完全没问题,别用在破解商业引擎或绕过授权验证上。
3. TouhouSE 音效提取:从音频识别到批量导出的完整链路
3.1 识别音频编码:为什么同一个包里有 OGG 也有 RIFF
arc 解出来的音频不一定都是同一种编码。狼头引擎类游戏经常混合使用多种容器:BGM 用 OGG Vorbis,人声和 SE 可能用无压缩 WAV 或者 ADPCM。写 TouhouSE 提取脚本之前,先要对解出来的文件做一次格式探测,避免拿到数据后统一按 WAV 处理导致音色全乱。
import os def probe_audio_format(filepath: str) -> str: with open(filepath, "rb") as f: head = f.read(16) if head[:4] == b"RIFF" and head[8:12] == b"WAVE": return "wav" if head[:4] == b"OggS": return "ogg" if head[:4] == b"fLaC": return "flac" if head[:3] == b"ID3" or head[:3] == b"\xff\xfb": return "mp3" return "unknown"这个函数适合把所有解出的音频文件丢进一个目录批量扫描,输出格式清单。参数说明:head[:4]读取的是文件头 4 字节魔数,RIFF 是 WAV 的容器标识,OggS 是 OGG 容器的起始页同步码,\xff\xfb是 MPEG 音频帧头的常见起始字节。如果返回unknown,先别急着丢,可能只是文件头被加了自定义标记,后续要在脚本里做个“跳过自定头部”的容错。
3.2 批量提取与文件名还原:写一个可复用的转换脚本
TouhouSE 这类工具的实际落点,是把已经解出的 .ogg、.wav 混合文件,统一转成目标格式并按原文件名规则重命名,方便导入游戏模板或音频工作站。这一步通常配合 FFmpeg 操作,因为 FFmpeg 能处理绝大多数音频容器转换。
import subprocess import os from pathlib import Path def convert_and_rename(src_dir: str, dst_dir: str, sample_rate: int = 44100, channels: int = 2) -> list[str]: os.makedirs(dst_dir, exist_ok=True) results = [] src_path = Path(src_dir) for f in sorted(src_path.iterdir()): if f.suffix.lower() not in (".ogg", ".wav", ".flac"): continue stem = f.stem if "se_" in stem.lower() or "sfx" in stem.lower(): out_name = stem + "_cleaned.wav" else: out_name = stem + ".wav" out_path = os.path.join(dst_dir, out_name) cmd = [ "ffmpeg", "-y", "-i", str(f), "-ar", str(sample_rate), "-ac", str(channels), "-c:a", "pcm_s16le", out_path ] proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode == 0: results.append(out_name) else: print("转换失败:", f, proc.stderr[-300:]) return results这段脚本的核心逻辑是两步:先根据文件名中的se_或sfx标记区分音效与 BGM,再统一转成 16 位 PCM WAV。`
参数说明:-ar 44100是把所有音频统一到 44.1kHz 采样率,适合大多数游戏引擎的播放标准;-ac 2强制双声道;-c:a pcm_s16le表示输出为无压缩的 16 位小端 PCM 编码。实际使用时有个细节,如果某个音源本身就是 48kHz 或 5.1 声道,强制转换会损失一些高频细节,建议先看源文件参数再决定是否加-ar参数,而不是一股脑统一。
3.3 循环标记与音量归一化:音频处理里常被忽略的两个细节
游戏音效和普通音频不同之处在于很多 BGM 需要无缝循环,arc 包内的音频如果带了循环点标记,直接转成 WAV 再重新压缩时会丢掉这些标记,导致游戏内播放卡顿。TouhouSE 的做法通常是解析 OGG 里的循环标记或直接从封装格式的头部读取 loop 信息,然后在处理时单独记录到一个文本清单。
from mutagen.oggvorbis import OggVorbis def read_loop_points(filepath: str) -> dict: audio = OggVorbis(filepath) tags = audio.tags or {} loop_start = tags.get("loop_start", ["0"])[0] loop_end = tags.get("loop_end", ["0"])[0] return {"loop_start": int(loop_start), "loop_end": int(loop_end)}参数说明:Mutagen 是 Python 里常用的音频元数据库,OggVorbis 类能直接读取 OGG 的内嵌标签。loop_start和loop_end不是每个游戏都会写,有的引擎把循环点放在资源管理器配置表里而不是音频文件内部,这种情况下只要把音频按原样导出,再在引擎的配置表里保留对应循环点数据即可。
音量归一化方面,很多音效提取出来后响度差异很大,因为游戏内播放时引擎会做实时混音,裸导出数据听起来“忽大忽小”。处理和不当会破坏动态范围,反而让音色变闷。我一般只在响度差距超过 6dB 的文件上做轻量归一化,其他保持原样。
4. 解包常见问题排查:四条踩坑记录与对应解法
4.1 现象:解析器跑完,解出的文件全是乱码,十六进制里看不到任何有效文件头
原因通常有三种:一是文件表偏移读取错误,把头部某个无关字段当作偏移量,导致后面所有条目错位;二是字节序判断反了,整个文件表全部错位;三是数据被加密,文件本身是有效数据但被 xor 处理过。
解决方法是:先用一个已知的小文件手工做解析验证,打开十六进制视图手动查找第一条有效数据的文件头(比如 PNG 的\x89PNG),反推出它对应的偏移量和大小,再和解析器输出的第一条记录对比。如果差值固定,多半是头部有额外字段没算进去;如果不固定,就要回头检查文件表结构定义。
4.2 现象:音效文件能解出来,但播放时明显“变快”或“变速”
这种现象几乎都是采样率设置错误。源文件实际是 22050Hz,脚本强制输出成 44100Hz,播放器按 44.1k 播放会显得音调偏高、时长变短。反过来则是音调偏低。
解决方式:在转换前先用ffprobe查看原始采样率和位深,不要默认所有数据都是 44.1kHz/16 位。尤其是掌机或低成本引擎的游戏,经常出现 22050Hz 或 8 位 ADPCM 音频。处理时按源参数输出,或者单独建一个映射表记录每个文件的源采样率。
4.3 现象:文件名全部是乱码或空字符串,但提取的数据偏移和大小是正确的
这个情况一般不是编码问题,而是文件名表的定位规则和文件表不同。有的引擎在文件表里存的是相对偏移,需要加上某个常量才能得到真实位置;还有的是字符串表放在了一个独立区域,需要先计算字符串表基地址。
解决方式:别急着解析全部文件,先取一个小样本,手动在十六进制里找到可读的文件名字符串,然后看它在文件中的绝对偏移,再减掉文件表里的name_offset字段,就能得到每次都一样的基准值。把这个基准值加到读取逻辑里,就能正常取出所有名字。
4.4 现象:某些文件解出来只有几字节,或者大小明显异常,远小于真实资源体积
原因多是文件表里记录的数据大小被压缩了,而解析器没有做解压处理,直接把压缩数据当最终结果导出。
解决方式:把解出来的小文件丢回压缩判断流程,检查前两个字节是否为 ZLIB 特征;如果不是,则可能对应空条目或者文件表里存在嵌套子表,需要再解析一层子表结构。不建议直接跳过这些文件,因为有些关键配置表就藏在这种“异常小”的条目里。
5. 验证解码结果与进阶用法:用校验和和波形分析确认每一步输出
解包最怕的就是“看起来解出来了,实际上数据是错的”。验证方法有两种,一种是对已知文件做校验和对比,适合已经拥有官方原文件的场景;另一种是纯靠波形和频谱分析判断音频是否完好。
sha1sum "原始BGM.ogg" "解包输出BGM.ogg"如果两份文件一致,SHA-1 哈希完全相同。如果解包过程涉及重编码,则哈希不一致,这时改用频谱查看器确认音质是否损失。常见做法是把解包输出与已知样本同时拖入音频编辑器,对比波形包络是否一致,如果只是相位不同可能来自编码造成的延迟,不影响使用。
进阶用法是把整个解包工具链固化成一套命令行流程。我个人的习惯是把 arc 解析、音频导出、格式探测三个步骤串成一个入口脚本,每次拿到新版本游戏资源包时只换文件路径。
# decode_all.py import sys from arc_parser import read_arc_table, read_name_at from audio_tools import convert_and_rename, probe_audio_format if __name__ == "__main__": arc_path = sys.argv[1] out_dir = sys.argv[2] entries = read_arc_table(arc_path, table_offset=0x14, count=0x200) with open(arc_path, "rb") as f: for e in entries: f.seek(e.data_offset) data = f.read(e.size) # 写文件、探测格式、批量转换 convert_and_rename(out_dir, out_dir + "/wav", sample_rate=44100, channels=2)这段脚本关键点在于把table_offset和count做成了可配置的值,因为不同版本的游戏资源包偏移量经常有变化。把0x14和0x200换成实际值后,直接跑python decode_all.py game.arc ./out就可以完成整个流程。从那以后,我每次解包都强制走一遍“先探测头部、再解文件表、最后抽检音频波形”的流程,很少再被加密变体或偏移错位坑到。希望帮到你。
本文还有配套的精品资源,点击获取