---
layout: post
title: 如何节约游戏占用的硬盘空间？
tags: [dedupe, RPG制作大师, 游戏]
---

  浪费硬盘空间是可耻的！<!--more-->   

# 起因
  在几年前，我写过一篇在[MacBook上玩游戏](/2023/10/21/game.html)的文章，在那之后，我已经在我的Mac上下载了几十部游戏。只不过有个问题……我的Mac只有256GiB的硬盘存储空间，下载一堆游戏会让我的硬盘空间不够用，但是又不太想删，所以我该怎么尽可能让游戏占用更少的空间呢？   
  首先为了能在Mac上尽可能流畅地玩，我玩的游戏大多都是用跨平台能力很强的引擎编写的游戏，比如[Ren'Py](https://github.com/renpy/renpy)、RPG制作大师、Godot之类的，而像RPG制作大师这种引擎制作的游戏还有一个特点，开发者一般都会使用引擎自带的素材进行开发，有时候还会用不少第三方的罐头素材之类的（实际上甚至还有好多AVG为了蹭这些引擎的公用素材刻意用它们），所以这几十个游戏里应该有非常多的重复素材，如果能想办法把它们去个重，应该能节省相当多的空间吧……   

# 去重的方法
  如果想要对文件进行去重，我搜了一下，有个叫做[jdupes](https://codeberg.org/jbruchon/jdupes)的工具就很不错，它支持多种去重方式，比如使用硬链接，或者用一些文件系统的写时复制特性。不过如果用写时复制特性，jdupes在第二次执行的时候会认为去重后的文件还是单独的文件，就会重复去重了，而且最终也不好统计，反正对我玩的游戏来说，要去重的都是游戏素材，不存在后续修改的可能性，所以我打算全部用硬链接。   
  所以最终要执行的命令也非常简单，直接一句`jdupes -r -L Game`就可以了，这样以后每次下载了新的游戏之后重复执行这个操作，就可以将游戏中和其他游戏里有的素材去重了。   
  不过实际上很多游戏并不能直接用这种方式去重，因为它们的资源文件有些是打包成单个文件，有些进行了简单的加密，导致即使是相同的素材，文件也并不相同，所以我必须让所有的资源以单独原始的形态出现。对于不同的引擎也有不同的处理方式，所以接下来我需要对它们进行一些研究。   

# 不同引擎的处理方式
## RPG制作大师MV/MZ
  对于RPG制作大师MV/MZ开发的游戏来说，解密很简单，比较知名的是一个叫做[RPG-Maker-MV-Decrypter](https://gitlab.com/Petschko/RPG-Maker-MV-Decrypter)的工具，它可以在浏览器中进行解密，但一个游戏的资源文件非常多……要是全上传给浏览器实在是太麻烦了……后来我又搜了一下，有一个用C#写的叫[RPG Maker Decrypter](https://github.com/uuksu/RPGMakerDecrypter)工具也很不错，它作为命令行工具比在浏览器中执行简单多了，而且还能只把资源文件单独提出来，这样就可以剔除掉游戏自带的浏览器文件。不过他这个仓库的代码有个问题，它在选择文件的时候似乎会区分大小写，文件夹名中含有大写字母的似乎会被剔除……这样不太符合我的要求啊，当然我不会C#，于是我用AI改了一下，还给他提了个[PR](https://github.com/uuksu/RPGMakerDecrypter/pull/28)，不过这家伙看起来似乎不太喜欢AI写的代码，看起来不打算合我的PR😅。不过无所谓了，反正我也是自用，他爱合不合吧。   
  这个工具的用法也非常简单，一句`RPGMakerDecrypter-cli [input] -p -o [output]`就处理好了，处理完之后只需要把`data/System.json`中的`hasEncryptedImages`和`hasEncryptedAudio`设置为false就可以正常识别，以后在Mac中只要在游戏路径下执行`python3 -m http.server`就可以在浏览器中游玩了。   
  在这个过程中，我还发现有一些游戏喜欢把原画文件直接放到游戏里面，一张图片好几M，但RPG制作大师的引擎在渲染的时候根本不会渲染出那么高的分辨率，结果毫无意义地浪费一大堆存储空间，而且因为图片是加密的，对大多数人来说也没有收藏价值。所以在解密完之后我就想干脆把这些图片全部有损压缩一遍，估计能节省不少存储空间，于是让AI写了个简单的压缩脚本处理了一下：   
```python
#!/usr/bin/env python3
"""
图片压缩脚本（多进程版本）
将 pictures.orig 文件夹中的图片使用 WebP 格式进行高效压缩，
保持分辨率不变，肉眼看不出差异，压缩后的图片保存到 pictures 文件夹。

使用方法:
    python3 compress_images.py

压缩策略:
    - 保持原始分辨率不变
    - 使用 WebP 格式（有损压缩，高质量）
    - 质量设置为 85，在保持视觉质量的同时显著减小文件大小
    - 文件名和后缀保持不变
    - 多进程并行处理
    - 处理失败时自动复制原文件
"""

import os
import shutil
from PIL import Image
from pathlib import Path
from multiprocessing import Pool, cpu_count
from functools import partial

# 配置路径
SOURCE_DIR = "pictures.orig"
OUTPUT_DIR = "pictures"

# WebP 质量设置 (0-100，数值越高质量越好，文件也越大)
# 85 是一个很好的平衡点，肉眼几乎看不出差异
WEBP_QUALITY = 85

# 对于带有透明通道的图片，可以设置不同的质量
WEBP_QUALITY_WITH_ALPHA = 80

# 并行进程数，默认为 CPU 核心数
NUM_WORKERS = cpu_count()


def compress_single_image(img_file: tuple[str, str, str]) -> tuple[str, bool, int, int]:
    """
    压缩单个图片文件（用于多进程）
    
    Args:
        img_file: (源文件路径, 输出文件路径, 输出目录) 元组
        
    Returns:
        (文件名, 是否成功, 原始大小, 压缩后大小) 元组
    """
    source_path, output_path_str, output_dir = img_file
    source_path = Path(source_path)
    output_path = Path(output_path_str)
    
    original_size = source_path.stat().st_size
    
    try:
        img = Image.open(source_path)
        
        # 检查是否有透明通道
        has_alpha = img.mode in ('RGBA', 'LA', 'PA') or (img.mode == 'P' and 'transparency' in img.info)
        
        # 确定使用的质量
        quality = WEBP_QUALITY_WITH_ALPHA if has_alpha else WEBP_QUALITY
        
        # 保存为 WebP 格式，但使用原始的文件扩展名
        img.save(
            str(output_path),
            format='WEBP',
            quality=quality,
            method=6  # 压缩方法 0-6，6 是最慢但压缩率最高的
        )
        
        compressed_size = output_path.stat().st_size
        return (source_path.name, True, original_size, compressed_size)
        
    except Exception as e:
        # 处理失败时，复制原文件到输出目录
        try:
            shutil.copy2(source_path, output_path)
            compressed_size = output_path.stat().st_size
            return (source_path.name, False, original_size, compressed_size)
        except Exception as copy_error:
            return (source_path.name, False, original_size, 0)


def main():
    source_dir = Path(SOURCE_DIR)
    output_dir = Path(OUTPUT_DIR)
    
    # 检查源目录是否存在
    if not source_dir.exists():
        print(f"错误: 源目录 '{SOURCE_DIR}' 不存在")
        return
    
    # 创建输出目录
    output_dir.mkdir(exist_ok=True)
    
    # 获取所有图片文件（支持多种格式）
    image_extensions = ('*.png', '*.jpg', '*.jpeg', '*.bmp', '*.gif', '*.tiff', '*.webp')
    image_files = []
    for ext in image_extensions:
        image_files.extend(source_dir.glob(ext))
    image_files = sorted(set(image_files))  # 去重并排序
    
    if not image_files:
        print(f"在 '{SOURCE_DIR}' 中没有找到图片文件")
        return
    
    # 构建任务列表
    tasks = []
    for img_file in image_files:
        output_path = output_dir / img_file.name  # 保持原文件名和后缀
        tasks.append((str(img_file), str(output_path), str(output_dir)))
    
    print(f"找到 {len(tasks)} 个图片文件")
    print(f"源目录: {SOURCE_DIR}")
    print(f"输出目录: {OUTPUT_DIR}")
    print(f"WebP 质量设置: {WEBP_QUALITY}")
    print(f"并行进程数: {NUM_WORKERS}")
    print("-" * 70)
    
    # 使用多进程池处理图片
    success_count = 0
    fail_count = 0
    total_original = 0
    total_compressed = 0
    
    with Pool(processes=NUM_WORKERS) as pool:
        for i, (filename, success, original_size, compressed_size) in enumerate(pool.imap(compress_single_image, tasks), 1):
            total_original += original_size
            total_compressed += compressed_size
            
            if success:
                success_count += 1
                marker = "✓"
                reduction = (1 - compressed_size / original_size) * 100 if original_size > 0 else 0
                status_msg = f"{reduction:+.1f}%"
            else:
                fail_count += 1
                marker = "✗"
                status_msg = "复制原文件"
            
            status = f"[{i}/{len(tasks)}] {filename}"
            print(f"{marker} {status:50} {original_size/1024:>8.1f}KB -> {compressed_size/1024:>8.1f}KB ({status_msg})")
    
    # 输出总结
    print("-" * 70)
    total_reduction = (1 - total_compressed / total_original) * 100 if total_original > 0 else 0
    print(f"压缩完成!")
    print(f"  成功处理: {success_count}/{len(tasks)} 个文件")
    if fail_count > 0:
        print(f"  失败(已复制原文件): {fail_count}/{len(tasks)} 个文件")
    print(f"  原始总大小: {total_original / 1024 / 1024:.2f} MB ({total_original / 1024:.1f} KB)")
    print(f"  压缩后大小: {total_compressed / 1024 / 1024:.2f} MB ({total_compressed / 1024:.1f} KB)")
    print(f"  总压缩率: {total_reduction:.1f}%")
    print(f"  节省空间: {(total_original - total_compressed) / 1024 / 1024:.2f} MB")


if __name__ == "__main__":
    main()
```
  最终压缩完之后我把原图上传到了[EH画廊](https://e-hentai.org/g/3901673/426a7a17ba/)中，本地只留压缩后的图片，大小从原来的2GiB多下降到了300多MiB，可以说效果相当显著了。   
  除此之外还有一些游戏使用了Ogg FLAC背景音乐，这种音乐不仅占用磁盘空间很大，而且我在Safari上玩的时候浏览器根本没法解析（Chrome应该可以）。虽然我听音乐是会考虑[HiFi](/2025/03/22/hifi.html)，但玩游戏就没必要了吧……所以像这种音乐，就得用一句：   
```bash
ffmpeg -i input.flac.ogg -c:a vorbis -strict -2 -q:a 10 output.ogg
```
  转换为正常有损的Ogg音乐了。   
## RPG制作大师XP/VX/VA
  对于RPG制作大师XP/VX/VA引擎开发的游戏来说，它们都是基于用Ruby语言开发的RGSS编写的，作为脚本来说，倒是有跨平台的条件，但因为官方并没有做跨平台，所以不能直接在Mac上运行。不过有一款叫做[mkxp-z](https://github.com/mkxp-z/mkxp-z)的工具允许跨平台运行使用RPG制作大师XP/VX/VA制作的游戏，因此这类游戏我也收集了一些。   
  这些游戏的资源通常会进行简单的混淆加密，一般会打包成单个RGSSAD文件，这个解包也很简单，用刚刚的RPG Maker Decrypter就可以。不过这种游戏还有个特点，有些游戏需要使用[RTP](https://www.rpgmakerweb.com/run-time-package)才能运行，它这个RTP其实就是RPG制作大师自带的素材包，当时设计出来估计也是想着用来节约硬盘空间吧，就是不知道为什么到后来的MV/MZ却取消了这种方式……虽然mkxp-z是支持通过配置文件引入RTP的，但既然我已经选择了硬链接的方式，就没必要单独搞RTP了，我选择把RTP直接和游戏合并，然后让jdupes直接去重就好了，这样相比于RTP的方式还有一些好处就是XP/VX/VA可能有一些和MV/MZ使用相同的素材，这部分也可以不用占用重复的空间了。   
## Ren'Py
  对于Ren'Py来说，因为这个引擎并没有自带的公共资源，所以重复素材的问题并不是很大。不过在我之前对[Ren'Py的探索](/2024/01/20/renpy.html)中提到过，我玩的一些游戏是系列游戏，这种系列游戏有非常多的素材复用，但显然开发者并不会为了节约玩家硬盘空间而共享这部分资源，而且Ren'Py游戏也都是打包成单个文件的，所以接下来我们依然得要解包才能进行去重处理。   
  Ren'Py使用的rpa文件解包起来依然很简单，有一款现成的工具[unrpa](https://github.com/Lattyware/unrpa)可以直接解包，用pip就能安装。不知道为什么这些引擎总是喜欢把资源文件都打成一个包，明明很容易就能解包……难道是为了性能吗？   
  不过也正是因为Ren'Py的公共资源不多，如果玩的不是系列游戏，就没有解包的必要了，解包之后一堆小文件有可能会比整个rpa文件更大，毕竟文件系统存在“簇”，有可能会消耗没对齐的空间。   

# 验证结果
  最终进行完上述操作，可以通过执行`du -sh`和`du -shl`进行对比来验证节约的硬盘空间，我在这次游戏的瘦身中节约了：
```
~ % du -sh Game                                
 33G	Game
~ % du -shl Game
 47G	Game
```
  看起来还是相当可观啊……尤其是在当下硬盘价格大涨的情况下，如果很多人能通过这些方式来节约硬盘空间，就能减少对硬盘容量的需求吧……不过说到底其实也都是网上能下到的资源，也许玩完之后就删掉才是最好的节约硬盘的方式吧😂。   

<input name="live2dBGM" value="https://music.163.com/song/media/outer/url?id=1968116350.mp3" type="hidden" />