受[作者:Magyeong] 12/30更新 4.0动漫皮肤mod-ODDBA社区,3.11-R girl 逃离塔科夫女性角色模组(更新-v1.0.1)-ODDBA社区及3.11少前物品语音替换 莱娜-ODDBA社区启发,既然人物已经穿上了高跟鞋,那为什么没有人做人物脚步声的替换呢?
这是这个帖子的由来,同时也借这个帖子找找有没有愿意一起干这个活的冤种(bushi),如果有的话请联系我
闲话休提,言归正传,接下来开始介绍我是怎么使用16进制编辑器把脚步声替换成高跟鞋走路声的(未完工),其他音频替换思路差不多(我觉得)
我使用的工具:UABEA,AssetStudio(以下简称AS),HxD(以下称HXD),Audition(以下称AU)
首先是注意力惊人的我发现脚步声都存在这个文件里,里面还有一些其他诸如人物倒地,物品落地的音效,想要整活可以把它们都换成钢管落地(笑):
游戏根目录\EscapeFromTarkov_Data\StreamingAssets\Windows\assets\content\audio\prefabs\movement\sounds.bundle
使用AS打开,它的Asset List长这样,找到你想要替换的音频,比如图上这个
对于替换来说重要的是这几个,事关你要使用什么样的音频素材:
int m_Channels = 1,声道数,1是单声道,2是立体声
int m_Frequency = 44100,采样率,这里是44.1kHz
int m_BitsPerSample = 16,采样类型,这里是16位
float m_Length = 0.6840816,音频长度,单位s,注意素材可以比这个短,但不可以比这个长
FileSize m_Offset = 94478080,音频在.resource文件中的位置,但是我没有用上这玩意,关于.resource我会在后文讲
UInt64 m_Size = 60704,音频字节数,素材同样不可大于它
int m_CompressionFormat = 0,是否压缩,0为不压缩,2为有损压缩,其他的我暂时没见到,另外压缩过的会略复杂一些,我暂时没动那些压缩过的音频
右键导出,你会得到一个.wav文件,比如我这个就是walk_gravel_02.wav,这个就是它对应的音频了,但改它没用,它只是接下来要用到的妙妙工具,因为你不能直接往.bundle文件导入音频(笑)
然后就去找你想要的素材吧,也可以用AU来转换成相符的格式
然后到了UABEA的部分
在UABEA中打开sounds.bundle,长这样
那个.resource文件里存的是音频,把它导出,那个无后缀的存的是诸如dump之类的,就是你在第一张图看见的那些东西
现在我有了这些东西:CAB-90dd1506c231aa6e8c011cb2145309b0.resource,walk_gravel_02.wav,素材.wav,然后最好把它们备份一下,此外特别注意素材.wav要符合我上面说的那些
使用AU打开walk_gravel_02.wav的副本和素材.wav的副本,把walk_gravel_02.wav副本静音,然后把两个音频混音在同一个轨道上,这里是为了保证得到的音频和要替换的音频时长和大小尽量相同,然后把混音项目导出为.wav,就比如说,mix00.wav
使用HXD打开CAB-90dd1506c231aa6e8c011cb2145309b0.resource,walk_gravel_02.wav,mix00.wav,接下来我要说的事至关重要!!!!
这是mix00.wav,看见红圈的RIFF和data了吗,这一部分都是文件头,实测从0x2C开始才是有效的音频部分,可以把0x2C之前的都删去
然后狠狠往下翻
你会在接近末尾的地方看到LISTL字样的东西,往下开始是标注的日期,使用软件版本之类无用的东西,然后
.<?xpacket end=”w”?>是文件结尾,我不知道删掉是什么样,但我一直没删过
删掉LISTL和下面的所有东西,但保留.<?xpacket end=”w”?>,最好把LISTL上面也删了,以防等会放不下
然后在walk_gravel_02.wav里面,从0x2C开始往下拖几十一百多个字节,复制,进CAB-90dd1506c231aa6e8c011cb2145309b0.resource里面查找
找到了
然后去你已经删好的mix00.wav里面,全选,复制,进CAB-90dd1506c231aa6e8c011cb2145309b0.resource里面覆盖式粘贴,粘贴后检查一个东西:
FSB5,我猜测这是.resource里每个音频的文件头,不要覆盖了这个东西,它被覆盖后果很严重
然后保存,在UABEA里往sounds.bundle导入修改好的CAB-90dd1506c231aa6e8c011cb2145309b0.resource,然后去AS里听听它对不对劲,也可以进游戏测试。
以上就是全流程了,熟练之后速度也还行吧,不到一分钟换好一个
谁有更高效的方法可以评论或者联系我,现在这个如果要替换的太多的话还是偏慢
补:完工之后我会把我改好的东西发出来,有没有大佬会做MOD的教教我怎么把它做成MOD
二补:我神奇的发现AU导出时不勾包含标记和其他元数据就没有LISTL和.<?xpacket end=”w”?>这些东西,把文件头一删就能用
三补:依靠拷打AI获得了一份极其NB的代码,有代码基础的抽这个,劲儿大
#!/usr/bin/env python3
"""
塔科夫脚步声替换工具
替换 .resource 文件中所有 sprint_* 音频
使用 短_01.wav ~ 短_10.wav 轮流替换
只替换单声道 PCM 音频,跳过 ADPCM 和立体声
"""
import os
import sys
import shutil
import struct
from pathlib import Path
try:
import UnityPy
except ImportError:
print("错误:需要安装 UnityPy 库")
print("请运行:pip install UnityPy")
sys.exit(1)
# ==================== 配置区域 ====================
CAB_FILE = r"E:\download\1\CAB-90dd1506c231aa6e8c011cb2145309b0"
RESOURCE_FILE = r"E:\download\1\CAB-90dd1506c231aa6e8c011cb2145309b0.resource"
REPLACE_AUDIO_DIR = r"E:\download\素材\单声道"
# 替换音频文件列表(轮流使用)
REPLACE_FILES = [
"长_01.wav",
"长_02.wav",
"长_03.wav",
"长_04.wav",
"长_05.wav",
"长_06.wav",
]
# 安全边距(字节),防止覆盖下一个音频的头部
SAFETY_MARGIN = 300
# 要替换的音频前缀
TARGET_PREFIX = "walk2_"
# =================================================
def parse_fsb5_header(data):
"""解析 FSB5 文件头,返回头部大小和数据大小"""
if len(data) < 60 or data[:4] != b'FSB5':
return None
info = {}
info['version'] = struct.unpack('<I', data[4:8])[0]
info['num_samples'] = struct.unpack('<I', data[8:12])[0]
info['sample_header_size'] = struct.unpack('<I', data[12:16])[0]
info['name_table_size'] = struct.unpack('<I', data[16:20])[0]
info['data_size'] = struct.unpack('<I', data[20:24])[0]
info['mode'] = struct.unpack('<I', data[24:28])[0]
# mode: 2=PCM16, 7=IMAADPCM
mode_names = {1: "PCM8", 2: "PCM16", 7: "IMAADPCM"}
info['mode_name'] = mode_names.get(info['mode'], f"Unknown({info['mode']})")
# 计算头部总大小
info['header_size'] = 60 + info['sample_header_size'] + info['name_table_size']
info['total_size'] = info['header_size'] + info['data_size']
return info
def get_wav_raw_data(wav_path):
"""读取 WAV 文件的原始音频数据(不含头)"""
with open(wav_path, 'rb') as f:
riff = f.read(4)
if riff != b'RIFF':
return None, None, "不是有效的 WAV 文件"
f.read(4) # file size
wave = f.read(4)
if wave != b'WAVE':
return None, None, "不是有效的 WAV 文件"
fmt_info = {}
data_content = None
while True:
chunk_id = f.read(4)
if len(chunk_id) < 4:
break
chunk_size = struct.unpack('<I', f.read(4))[0]
if chunk_id == b'fmt ':
fmt_info['format'] = struct.unpack('<H', f.read(2))[0]
fmt_info['channels'] = struct.unpack('<H', f.read(2))[0]
fmt_info['sample_rate'] = struct.unpack('<I', f.read(4))[0]
f.read(4) # byte rate
f.read(2) # block align
fmt_info['bits'] = struct.unpack('<H', f.read(2))[0]
remaining = chunk_size - 16
if remaining > 0:
f.read(remaining)
elif chunk_id == b'data':
data_content = f.read(chunk_size)
break
else:
f.seek(chunk_size, 1)
return fmt_info, data_content, None
def format_compression_name(compression_format):
"""Unity 压缩格式名称"""
return {0: "PCM", 2: "ADPCM", 1: "Vorbis"}.get(compression_format, f"Unknown({compression_format})")
def main():
print("=" * 70)
print(f"塔科夫脚步声替换工具 - {TARGET_PREFIX}* 音频批量替换")
print(f"使用 {REPLACE_FILES[0]} ~ {REPLACE_FILES[-1]} 轮流替换")
print(f"安全边距: {SAFETY_MARGIN} bytes")
print("=" * 70)
# 检查路径
if not os.path.exists(CAB_FILE):
print(f"错误:CAB 文件不存在 - {CAB_FILE}")
return
if not os.path.exists(RESOURCE_FILE):
print(f"错误:Resource 文件不存在 - {RESOURCE_FILE}")
return
# 检查替换音频
replace_wav_paths = []
print(f"\n检查替换音频文件:")
for fname in REPLACE_FILES:
fpath = os.path.join(REPLACE_AUDIO_DIR, fname)
if os.path.exists(fpath):
print(f" ✓ {fname}")
replace_wav_paths.append(fpath)
else:
print(f" ✗ {fname} - 不存在!")
if not replace_wav_paths:
print("错误:没有找到任何替换音频文件")
return
print(f"\n将使用 {len(replace_wav_paths)} 个音频文件轮流替换")
# 预加载替换音频的 PCM 数据
replace_pcm_data = []
for fpath in replace_wav_paths:
fmt_info, data, err = get_wav_raw_data(fpath)
if err:
print(f"错误:无法读取 {fpath} - {err}")
return
replace_pcm_data.append({
'path': fpath,
'name': os.path.basename(fpath),
'fmt': fmt_info,
'data': data,
'size': len(data),
})
print(f" {os.path.basename(fpath)}: {fmt_info['channels']}声道, {fmt_info['sample_rate']}Hz, PCM数据 {len(data)} bytes")
# 备份 resource 文件
backup_file = RESOURCE_FILE + ".backup"
if not os.path.exists(backup_file):
print(f"\n正在备份 .resource 文件...")
shutil.copy2(RESOURCE_FILE, backup_file)
print(f"备份完成: {backup_file}")
else:
print(f"\n备份文件已存在: {backup_file}")
# 解析 CAB 文件
print("\n正在解析 CAB 文件...")
env = UnityPy.load(CAB_FILE)
# 筛选目标音频
target_clips = []
for obj in env.objects:
if obj.type.name == "AudioClip":
data = obj.read()
if data.m_Name.startswith(TARGET_PREFIX):
target_clips.append({
"name": data.m_Name,
"channels": data.m_Channels,
"frequency": data.m_Frequency,
"bits_per_sample": data.m_BitsPerSample,
"compression_format": data.m_CompressionFormat,
"offset": data.m_Resource.m_Offset,
"size": data.m_Resource.m_Size,
"length": data.m_Length,
})
target_clips.sort(key=lambda x: x['offset'])
print(f"\n找到 {len(target_clips)} 个 {TARGET_PREFIX}* 音频")
# 统计格式
pcm_count = sum(1 for c in target_clips if c['compression_format'] == 0)
adpcm_count = sum(1 for c in target_clips if c['compression_format'] == 2)
mono_count = sum(1 for c in target_clips if c['channels'] == 1)
stereo_count = sum(1 for c in target_clips if c['channels'] == 2)
print(f" 格式: PCM {pcm_count} 个, ADPCM {adpcm_count} 个")
print(f" 声道: 单声道 {mono_count} 个, 立体声 {stereo_count} 个")
print("-" * 70)
# 记录跳过的音频
skipped_clips = []
replaced_count = 0
error_count = 0
replace_index = 0 # 轮流替换的索引
# 打开 resource 文件
with open(RESOURCE_FILE, 'r+b') as res_file:
for i, clip in enumerate(target_clips):
format_name = format_compression_name(clip['compression_format'])
print(f"\n[{i+1}/{len(target_clips)}] {clip['name']}")
print(f" 原始格式: {format_name}, {clip['channels']}声道, {clip['frequency']}Hz")
print(f" 时长: {clip['length']:.3f}秒, 总大小: {clip['size']} bytes")
# 跳过立体声
if clip['channels'] != 1:
print(f" ✗ 跳过:立体声音频({clip['channels']}声道)")
skipped_clips.append({
'name': clip['name'],
'reason': f"立体声音频({clip['channels']}声道)",
'original_data_size': clip['size'],
'new_data_size': 0,
'replace_file': '-',
})
continue
# 跳过 ADPCM(compression_format == 2)
if clip['compression_format'] == 2:
print(f" ✗ 跳过:ADPCM 格式(暂不支持)")
skipped_clips.append({
'name': clip['name'],
'reason': "ADPCM 格式(暂不支持)",
'original_data_size': clip['size'],
'new_data_size': 0,
'replace_file': '-',
})
continue
# 读取原始 FSB5 头部
res_file.seek(clip['offset'])
original_fsb = res_file.read(clip['size'])
fsb_info = parse_fsb5_header(original_fsb)
if not fsb_info:
print(f" 错误:无法解析 FSB5 头部")
error_count += 1
continue
print(f" FSB5: {fsb_info['mode_name']}, 头部 {fsb_info['header_size']} bytes, 数据 {fsb_info['data_size']} bytes")
# 再次确认是 PCM16
if fsb_info['mode'] != 2:
print(f" ✗ 跳过:FSB5 格式不是 PCM16 (mode={fsb_info['mode']})")
skipped_clips.append({
'name': clip['name'],
'reason': f"FSB5 格式不是 PCM16 (mode={fsb_info['mode']})",
'original_data_size': fsb_info['data_size'],
'new_data_size': 0,
'replace_file': '-',
})
continue
# 获取当前替换音频
replace_info = replace_pcm_data[replace_index % len(replace_pcm_data)]
# 直接使用 PCM 数据
new_audio_data = replace_info['data']
print(f" 使用: {replace_info['name']} (PCM, {replace_info['size']} bytes)")
# 计算可用空间(减去安全边距)
max_allowed_size = fsb_info['data_size'] - SAFETY_MARGIN
# 检查大小(含安全边距)
if len(new_audio_data) > max_allowed_size:
overflow = len(new_audio_data) - max_allowed_size
print(f" ✗ 跳过:数据超出 {overflow} bytes (可用空间 {max_allowed_size} bytes, 含{SAFETY_MARGIN}B安全边距)")
skipped_clips.append({
'name': clip['name'],
'reason': f"数据超出 {overflow} bytes (含{SAFETY_MARGIN}B安全边距)",
'original_data_size': fsb_info['data_size'],
'new_data_size': len(new_audio_data),
'replace_file': replace_info['name'],
})
continue
# 填充到原始大小
if len(new_audio_data) < fsb_info['data_size']:
padding = fsb_info['data_size'] - len(new_audio_data)
new_audio_data = new_audio_data + b'\x00' * padding
print(f" 填充 {padding} bytes")
# 构建新的 FSB5 数据(保留原始头部)
new_fsb = original_fsb[:fsb_info['header_size']] + new_audio_data
# 写入
res_file.seek(clip['offset'])
res_file.write(new_fsb)
print(f" ✓ 替换成功!")
replaced_count += 1
replace_index += 1 # 轮流下一个
# 输出结果
print("\n" + "=" * 70)
print(f"替换完成!")
print(f" 成功: {replaced_count}")
print(f" 跳过: {len(skipped_clips)}")
print(f" 错误: {error_count}")
print(f" 原始文件备份: {backup_file}")
if skipped_clips:
print("\n" + "=" * 70)
print("跳过的音频:")
print("-" * 70)
for skip in skipped_clips:
print(f" {skip['name']}")
print(f" 原因: {skip['reason']}")
if skip['new_data_size'] > 0:
print(f" 原始数据大小: {skip['original_data_size']} bytes")
print(f" 替换文件: {skip['replace_file']} ({skip['new_data_size']} bytes)")
print()
print("=" * 70)
if __name__ == "__main__":
main()
完成了没有压缩的音频的替换,放在这个帖子里人物脚步声替换为高跟鞋音效-ODDBA社区
墓前正在替换ADPCM压缩的单声道音频,它的编码方式和常见的IMA ADPCM比差别太大了,没法直接用ffmpeg转换过来的ADPCM,比PCM的替换麻烦太多了,另外ADPCM的立体声音频更是麻烦,我不一定会做
除了ADPCM的立体声音频之外都换好了,替换的文件依旧放在人物脚步声替换为高跟鞋音效-ODDBA社区里面,代码因为字数限制我也放在那个帖子了

















注意力惊人
支持!!!!!!!
大佬,666