• 注册
  • Mod制作教程 Mod制作教程 关注:1830 内容:83

    使用16进制编辑器修改游戏内音频的一般教程(大概)

  • 查看作者
  • 打赏作者
  • 4
  • Mod制作教程
  • 圆转纯熟

    [作者: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长这样,找到你想要替换的音频,比如图上这个

    使用16进制编辑器修改游戏内音频的一般教程(大概)

    对于替换来说重要的是这几个,事关你要使用什么样的音频素材:

    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,长这样

    使用16进制编辑器修改游戏内音频的一般教程(大概)

    那个.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,接下来我要说的事至关重要!!!!

    使用16进制编辑器修改游戏内音频的一般教程(大概)

    这是mix00.wav,看见红圈的RIFF和data了吗,这一部分都是文件头,实测从0x2C开始才是有效的音频部分,可以把0x2C之前的都删去

    然后狠狠往下翻

    使用16进制编辑器修改游戏内音频的一般教程(大概)

    使用16进制编辑器修改游戏内音频的一般教程(大概)

    你会在接近末尾的地方看到LISTL字样的东西,往下开始是标注的日期,使用软件版本之类无用的东西,然后

    .<?xpacket end=”w”?>是文件结尾,我不知道删掉是什么样,但我一直没删过

    删掉LISTL和下面的所有东西,但保留.<?xpacket end=”w”?>,最好把LISTL上面也删了,以防等会放不下

    然后在walk_gravel_02.wav里面,从0x2C开始往下拖几十一百多个字节,复制,进CAB-90dd1506c231aa6e8c011cb2145309b0.resource里面查找

    使用16进制编辑器修改游戏内音频的一般教程(大概)

    找到了

    然后去你已经删好的mix00.wav里面,全选,复制,进CAB-90dd1506c231aa6e8c011cb2145309b0.resource里面覆盖式粘贴,粘贴后检查一个东西

    使用16进制编辑器修改游戏内音频的一般教程(大概)

    FSB5,我猜测这是.resource里每个音频的文件头,不要覆盖了这个东西,它被覆盖后果很严重

    然后保存,在UABEA里往sounds.bundle导入修改好的CAB-90dd1506c231aa6e8c011cb2145309b0.resource,然后去AS里听听它对不对劲,也可以进游戏测试。

    以上就是全流程了,熟练之后速度也还行吧,不到一分钟换好一个

    谁有更高效的方法可以评论或者联系我,现在这个如果要替换的太多的话还是偏慢

    补:完工之后我会把我改好的东西发出来,有没有大佬会做MOD的教教我怎么把它做成MOD

    二补:我神奇的发现AU导出时不勾包含标记和其他元数据就没有LISTL和.<?xpacket end=”w”?>这些东西,把文件头一删就能用

    三补:依靠拷打AI获得了一份极其NB的代码,有代码基础的抽这个,劲儿大 [s-44] 

    #!/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社区里面,代码因为字数限制我也放在那个帖子了


    初窥堂奥

    注意力惊人 [s-20]

    回复
    圆转纯熟
    刚刚通过拷打大模型的方式获得了一份非常NB的代码,等我手边有电脑的时候上传上来,有代码基础的抽这个,劲儿大 [s-44]
    回复
    炉火纯青
    VIP4

    支持!!!!!!!

    回复
    登堂入室

    大佬,666


    回复

    请登录之后再进行评论

    登录
    离线版教程
  • 今日 0
  • 内容 1071
  • 关注 1830
  • 聊天
    关注 1

    【招募】GRIFFIN TKF项目开工 期待你的加入 || 你是否想加入格里芬书写自己与人形的故事

    捐助我们

    • 微信
    • 支付宝
  • 签到
  • 任务
  • 发布
  • 模式切换
  • 偏好设置
  • 帖子间隔 侧栏位置: