找回密码
 立即注册

QQ登录

只需一步,快速开始

查看: 147|回复: 4

[教程] 一个Renpy游戏打包apk资源加密方案

  [复制链接]
发表于 4 天前 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
本帖最后由 blurred 于 2025-8-30 08:52 编辑

前言:
1.此方法由"我"与"清奕云時,~良爷,白"还有"紬·文德斯(星辰冬日亦心)"提出,实现,测试
2.本方案在Ren'py-8.3.7能够实现效果,如果使用,请注意引擎版本
3.涉及到修改引擎的代码,所以请在事前把引擎目录下的rapt相关文件备份

3.学习操作门槛:需要一定的python水平,java小基础以及了解Android应用的相关特性
(包括但不限于一些基础的apk逆向知识,apk的文件结构以及其作用和内容)

4.帖子内自带的加密其实是一种比较弱的加密方案,如果你需要强度高的加密,请自行替换!
5.此加密方法有缺点,见文尾

6.本帖参考:
RenPyUtil: resource_preserver——一种新的加密打包解决方案 - 经验教程 - RenPy中文空间
一个Renpy安卓打包游戏通过安卓平台审核方案 - 经验教程 - RenPy中文空间
一个数据加密方法 - 经验教程 - RenPy中文空间
压缩包:(懒得看帖子可以一键解压即可食用)(内置食用教程和餐具)
安卓打包资源加密-浅唱&~良爷&清奕云時...(第一版).zip (23.56 KB, 下载次数: 0)
方法思路、代码讲解和配置教程
总概:renpy有一个文件调用,其读取目录是在PC和Android是不同的,利用这个特性,我们可以将加密资源的压缩包解压到安卓私有目录,并且在此目录内进行文件操作,这样能够保证资源的安全,同时,apk包内的资源属于加密状态,玩家就算是拆包也无法得到明文,实现了apk内外的数据安全!
(renpy游戏根目录,如图,我们不能保证所有文件都保存在该目录,尤其是在安卓之类的平台上。但我们可以确定images内容可以全部从这里读取

特性1

特性1

加密资源的压缩包:此压缩包通过外置脚本处理,其内部文件处于加密状态,只有被解压解密后的文件才是明文

特性3

特性3

(安卓私有目录:指的是每一个apk都可以向安卓系统申请的一个存放数据文件的目录,除了root用户一般情况玩家无法读取,且此目录仅能提供给对应应用使用)

特性2

特性2

(一)配置
安卓修改千千万,修改源码占一半
打开你的renpy引擎根目录,进入rapt,通过简单的翻阅,我们可以注意到,在PythonSDLActivity.java中,可以查找到renpy对于apk内的资源调用,例如解压一个mp3为后缀,实际上是一个tar.gz的py环境压缩包,而在这里面,我们可以注意到AesstExtract这个方法,通过翻阅可以得知,这个文件管理了Android的资源解压


以下为引擎源码
PythonSDLActivity.java
[RenPy] 纯文本查看 复制代码
if (shouldUnpack) {
            Log.v("python", "Extracting " + resource + " assets.");

            // Delete old libraries & renpy files.
            recursiveDelete(new File(target, "lib"));
            recursiveDelete(new File(target, "renpy"));

            target.mkdirs();

            AssetExtract ae = new AssetExtract(this);
            if (!ae.extractTar(resource + ".mp3", target.getAbsolutePath())) {
                toastError("Could not extract " + resource + " data.");
            }

            try {
                // Write .nomedia.
                new File(target, ".nomedia").createNewFile();

                // Write version file.
                FileOutputStream os = new FileOutputStream(disk_version_fn);
                os.write(data_version.getBytes());
                os.close();
            } catch (Exception e) {
                Log.w("python", e);
            }
        }

AesstExtract.java
[RenPy] 纯文本查看 复制代码
class AssetExtract {

    private AssetManager mAssetManager = null;
    private Activity mActivity = null;

    AssetExtract(Activity act) {
        mActivity = act;
        mAssetManager = act.getAssets();
    }

    public boolean extractTar(String asset, String target) {

        byte buf[] = new byte[1024 * 1024];

        InputStream assetStream = null;
        TarInputStream tis = null;

        Log.i("python", "extracting " + asset + " to " + target);
        
        try {
            assetStream = mAssetManager.open(asset, AssetManager.ACCESS_STREAMING);
            tis = new TarInputStream(new BufferedInputStream(new GZIPInputStream(new BufferedInputStream(assetStream, 8192)), 8192));
        } catch (IOException e) {
            Log.e("python", "opening up extract tar", e);
            return false;
        }
// ...其他内容

也就是说,我们可以通过修改AssetExtract,定义新的方法用于复制读取和解压zip,然后
1.在AssetExtract,写一个新的专门用于解压zip的方法,同时我们是先将zip复制到私有目录在解压,这样可以稍微快一些,从apk内读取比较耗时间
2.在PythonSDLActivity,在其中加入新的解压线程(为了保证解压和复制效果,这里写的是前台线程),如果你的资源文件实在很多,可以分多个压缩包并且按照相同格式加入新线程解压

以下是修改和加入的代码


在AssetExtrac用copyAsset复制到私有目录,在调用unzip读取私有目录的zip进行解压,其中有一个if用于判断是否解压过文件,如果解压过文件则会跳过解压过程



AssetExtract.java
[RenPy] 纯文本查看 复制代码
// 复制用方法
public void copyAsset(String assetName, String destPath) {
        InputStream in = null;
        FileOutputStream out = null;
        try {
            in = mAssetManager.open(assetName);
            File outFile = new File(destPath);
            File parent = outFile.getParentFile();
            if (parent != null && !parent.exists()) {
                parent.mkdirs();
            }
            out = new FileOutputStream(outFile);

            byte[] buffer = new byte[4096];
            int read;
            while ((read = in.read(buffer)) != -1) {
                out.write(buffer, 0, read);
            }
            out.flush();
            Log.i(TAG, "复制成功: " + assetName + " -> " + destPath);
        } catch (IOException e) {
            Log.e(TAG, "复制失败: " + assetName, e);
        } finally {
            try {
                if (in != null) in.close();
                if (out != null) out.close();
            } catch (IOException ignored) {}
        }
    }

[RenPy] 纯文本查看 复制代码
/**
     * 解压 zip 文件到目标目录
     * 如果目标目录已经存在且不为空,则跳过解压
     * 如果目标目录不存在,则创建该目录
     */

    //  这里定义了解压 ZIP 文件的通用方法
    public void unzip(String zipFilePath, String destDirectory) throws IOException {
        File destDir = new File(destDirectory);
        if (destDir.exists() && destDir.isDirectory() && destDir.list().length > 0) {
            Log.i(TAG, "跳过解压,目录已存在: " + destDirectory);
         return;
        }

        if (!destDir.exists()) {
            destDir.mkdirs();
        }

        byte[] buffer = new byte[32768];
        try (FileInputStream fis = new FileInputStream(zipFilePath);
            ZipInputStream zis = new ZipInputStream(fis)) {

            ZipEntry entry = zis.getNextEntry();
            while (entry != null) {
                File newFile = newFile(destDir, entry);

                if (entry.isDirectory()) {
                    if (!newFile.isDirectory() && !newFile.mkdirs()) {
                        throw new IOException("创建目录失败: " + newFile);
                    }
                } else {
                    // 确保父目录存在
                    File parent = newFile.getParentFile();
                    if (!parent.isDirectory() && !parent.mkdirs()) {
                        throw new IOException("创建父目录失败: " + parent);
                    }

                    // 写文件
                    try (FileOutputStream fos = new FileOutputStream(newFile)) {
                        int len;
                        while ((len = zis.read(buffer)) > 0) {
                            fos.write(buffer, 0, len);
                        }
                    }
                    Log.i(TAG, "解压文件: " + newFile.getAbsolutePath());
                }
                zis.closeEntry();
                try {
                    entry = zis.getNextEntry();
                } catch (IllegalArgumentException e) {
                    Log.e(TAG, "ZIP 文件格式不正确或文件损坏: " + entry.getName(), e);
                    continue; // 跳过当前文件,继续解压下一个文件
                }
            }

            zis.closeEntry();
        } catch (IOException e) {
            Log.e(TAG, "解压失败: " + zipFilePath, e);
            throw e;
        }

        Log.i(TAG, "解压完成: " + zipFilePath + " -> " + destDirectory);
    }

    /**
     * 防止 Zip Slip 漏洞
     */
    
    private File newFile(File destinationDir, ZipEntry zipEntry) throws IOException {
        File destFile = new File(destinationDir, zipEntry.getName());

        String destDirPath = destinationDir.getCanonicalPath(); // 根据条目名创建 File 对象
        String destFilePath = destFile.getCanonicalPath();

        if (!destFilePath.startsWith(destDirPath + File.separator)) {
            throw new IOException("解压错误: " + zipEntry.getName()); // 调试日志
        }

        return destFile;
    }

在PythonSDLActivity,我们在PythonSDLActivity中加入解压状态变量后,加入新方法extractImagesAsync,并且在onCreate,preparePython中加入extractImageAsync(); ,waitForImageExtraction();调用解压和等待解压


PythonSDLActivity.java
[RenPy] 纯文本查看 复制代码
// 添加解压状态变量
    private boolean mImages1Extracted = false;
    private final Object mExtractionLock = new Object();

[RenPy] 纯文本查看 复制代码
private void extractImagesAsync() {
        Log.i("AssetExtract", "开始异步解压 images 压缩包");
        
        // 创建并启动第一个解压线程
        // 如果你的图片资源极其大,你可以复制这个thread1并按照相同格式创建多个解压进程
        // 同时规定好路径,并且保证你的资源压缩包在apk中
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    AssetExtract assetExtract = new AssetExtract(PythonSDLActivity.this);
                    assetExtract.unzipSpecificGameZip("x-images", "game/images");
                    
                    // 设置环境变量
                    File imageDir1 = new File(getFilesDir(), "game/images");
                    nativeSetEnv("ANDROID_IMAGE_DIR_1", imageDir1.getAbsolutePath());
                    
                    Log.i("AssetExtract", "x-images 解压完成: " + imageDir1.getAbsolutePath());
                } catch (Exception e) {
                    Log.e("AssetExtract", "x-images 解压过程中出现错误", e);
                } finally {
                    // 标记第一个压缩包解压完成
                    synchronized (mExtractionLock) {
                        mImages1Extracted = true;
                        mExtractionLock.notifyAll();
                    }
                }
            }
        });
        
        // 启动解压线程
        thread1.start();
    }

为了解压顺利,我们加入等待
[RenPy] 纯文本查看 复制代码
public void waitForImageExtraction() {
        synchronized (mExtractionLock) {
            while (!mImages1Extracted) {
                try {
                    mExtractionLock.wait();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }
        
        // 两个压缩包都解压完成后,设置主环境变量(如果需要)
        File mainImageDir = new File(getFilesDir(), "game/images");
        if (mainImageDir.exists() || mainImageDir.mkdirs()) {
            nativeSetEnv("ANDROID_IMAGE_DIR", mainImageDir.getAbsolutePath());
        }
    }


(二)加密和解密
加密方法千千万,修改文件占一半
xor加密有一个特性是,当你对一个文件使用了xor算法,此文件会被无法正常读取,需要再次使用一次xor算法,此特性容易导致如果我们的加密解密顺序搞反,就会造成文件损坏,所以,通过对加密后的文件加入enc后缀,我们可以巧妙地通过这个后缀判断文件是否加密
先使用“用于加密.py”将你需要加密的资源(11.png-->11.png.enc)全部加密
然后打包为images.zip(11.png.enc,22.png.enc......-->images.zip)
将images.zip置于游戏game/同级目录下,删除你加密的资源原文件(11.png.enc,22.png.enc),这一步是为了防止你打包的apk太大
再在你的游戏剧本加入解密,所以游戏内部使用的解密和外置加解密不是同一个文件
游戏内用的解密文件

[RenPy] 纯文本查看 复制代码
"""renpy

python early:

"""

# # 解密单个文件
# $ xor_manager.decrypt_file("images/11.png")

# # 解密整个目录(递归)(会解密所有包括子目录)
# $ xor_manager.decrypt_directory("images/")

# 导入模块
import os
import hashlib

class XORResourceManager:
    def __init__(self, secret_key):
        # 用原始方法生成密钥
        self.hashed_key = hashlib.sha256(secret_key.encode()).digest()
    def _get_absolute_path(self, path):
        """相对路径转成绝对路径"""
        if not os.path.isabs(path):
            return os.path.join(config.gamedir, path)
        return path
    def _xor_data(self, data):
        """这里是XOR解密的核心"""
        key_length = len(self.hashed_key)
        return bytes(b ^ self.hashed_key[i % key_length] for i, b in enumerate(data))
    def decrypt_file(self, file_path):
        """文件解密处理核心"""
        # 路径处理
        abs_path = self._get_absolute_path(file_path)    
        # 检查文件是否存在
        if not os.path.exists(abs_path):
            renpy.notify(f"找不到文件: {file_path}")
            return False 
        if not abs_path.endswith('.enc'):
            return False   
        try:
            # 读取文件
            with open(abs_path, "rb") as f:
                file_data = f.read()        
            # 解密数据
            decrypted_data = self._xor_data(file_data)        
            # 写回文件
            with open(abs_path, "wb") as f:
                f.write(decrypted_data)        
            # 移除 .enc 后缀,恢复原始文件名
            original_path = abs_path[:-4]  # 去掉 .enc 后缀
            os.rename(abs_path, original_path)        
            return True        
        except Exception as e:
            renpy.notify(f"解密文件 {os.path.basename(file_path)} 时出错: {str(e)}")
            return False
    def decrypt_directory(self, directory_path):
        """解密整个目录的文件,只处理 .enc 结尾的文件"""
        abs_path = self._get_absolute_path(directory_path)    
        if not os.path.exists(abs_path):
            renpy.notify(f"目录不存在: {directory_path}")
            return False    
        if not os.path.isdir(abs_path):
            renpy.notify(f"这不是一个目录: {directory_path}")
            return False
        try:
            count = 0 # 解压文件的计数器(其实没啥用可以删)     
            for root, dirs, files in os.walk(abs_path):
                for file in files:
                    if file.endswith('.enc'): 
                        file_path = os.path.join(root, file)
                        if self.decrypt_file(file_path):
                            count += 1  
            if count > 0: 
                renpy.notify(f"完成了 {count} 个加密文件的解密")
            else:
                renpy.notify(f"在目录中未找到 .enc 加密文件")
            return True        
        except Exception as e:
            renpy.notify(f"解密目录时出了点问题: {str(e)}")
            return False
# 初始化资源管理器(保持原始密钥不变)
xor_manager = XORResourceManager(secret_key="1145")


在游戏中使用
[RenPy] 纯文本查看 复制代码
## 解密单个文件
# $ xor_manager.decrypt_file("images/11.png")

[RenPy] 纯文本查看 复制代码
## 解密整个目录(递归)(会解密所有包括子目录)
# $ xor_manager.decrypt_directory("images/")


外置加密脚本
[RenPy] 纯文本查看 复制代码
import os
import hashlib

# 一定一定一定要和游戏里面的解密函数一样!!!!!

def encrypt_image(image_path, hashed_key):
    """加密单个图片文件并添加.enc后缀"""
    try:
        # 读取图片
        with open(image_path, "rb") as f:
            data = f.read()
        # 使用XOR加密
        encrypted = bytes(b ^ hashed_key[i % len(hashed_key)] 
                          for i, b in enumerate(data))
        # 创建加密后的文件(添加.enc后缀)
        encrypted_path = image_path + ".enc"
        with open(encrypted_path, "wb") as f:
            f.write(encrypted)
        # 删除原始文件
        os.remove(image_path)
        return True
    except Exception as e:
        print(f"加密失败 {image_path}: {str(e)}")
        return False
def main():
    # 设置固定密钥
    FIXED_KEY = input("输入密钥: ").encode()
    # 生成密钥
    hashed_key = hashlib.sha256(FIXED_KEY).digest()
    while True:
        target_dir = input("请输入加密目录(输入'quit'退出): ")  
        if target_dir.lower() == 'quit':
            break
        # 检查目录是否存在
        if not os.path.exists(target_dir):
            print(f"错误: 目录 '{target_dir}' 不存在")
            continue
        encrypted_count = 0
        failed_count = 0  
        # 遍历目录并加密图片
        for root, dirs, files in os.walk(target_dir):
            for file in files:
                if file.lower().endswith((".png", ".jpg", ".webp", ".gif")):
                    full_path = os.path.join(root, file)
                    if encrypt_image(full_path, hashed_key):
                        encrypted_count += 1
                        print(f"已加密: {full_path} -> {full_path}.enc")
                    else:
                        failed_count += 1
        print(f"\n加密完成!")
        print(f"成功加密: {encrypted_count} 个文件")
        print(f"加密失败: {failed_count} 个文件")
        print(f"使用的密钥: {FIXED_KEY.decode()}")
        print("-" * 50)
if __name__ == "__main__":
    main()

本教程使用的加密为xor加密,其加密函数和解密都可以在压缩包里面找
[RenPy] 纯文本查看 复制代码
encrypted_path = image_path + ".enc"

(三)总结
本贴的配置和加密解密部分,仔细看可以发现,其2个模块是十分独立的,所以你可以自由修改加密的函数,不仅限于本贴展示的xor,其他如AES等都可以,只要自行替换加密部分即可,而配置部分更像是给加密提供在安卓读取的环境,配置部分也可以自行修改,不一定要使用zip,可以用7z,rar等,也可以加入更多线程解压
缺点:(高情商:给后面的人优化空间;低情商:我又菜又懒
1.如果使用了加密,会导致游戏加载时间延长,造成玩感不好,游戏启动的时候的复制解压也会占用时间,1GB大概是25~40s,第二次启动会缩短时间
2.操作过程比较麻烦,需要加密-->压缩-->删文件-->打包然后测试,解密过程也比较麻烦需要复制-->解压-->解密
3.需要修改引擎源代码,导致配置和操作门槛比较搞,作为教程,我希望它可以更简单一点,但是安卓相关操作目前因个人能力十分有限,所以没有想出什么其他可以不修改引擎源码的方法QAQ


闲言:
(没有绝对的加密和保护,对于一个开源的引擎,有加密只是加了层门槛,帖子内自带的加密以及大部分现有的加密都是只防君子不防小人,真的有人要破解是防不住的
高成本的加密可以做,但是没有必要的,换个思路,要的是把自身作品做好,而加密不是必要的
让创作者保持一定开放性,加密固然保护了资源,但保持开放性也是学习的契机










评分

参与人数 2活力 +300 干货 +6 收起 理由
烈林凤 + 300 + 3 感谢分享!
ZYKsslm + 3 楼主辛苦了!

查看全部评分

 楼主| 发表于 4 天前 | 显示全部楼层
本帖最后由 blurred 于 2025-8-28 13:40 编辑

要注意的一个点是,在加密的图片资源,ui资源什么的都随便,但是请一定不要有中文命名图片,非ACSLL字符一个都不要!因为这个我修了2周的bug!
回复 支持 抱歉

使用道具 举报

发表于 3 天前 | 显示全部楼层
强,但我还是更倾向于提供数据读写接口,由开发者实现而不是直接更改引擎源码
回复 支持 1 抱歉 0

使用道具 举报

发表于 4 天前 | 显示全部楼层
安卓大佬!
回复

使用道具 举报

 楼主| 发表于 前天 08:26 来自手机 | 显示全部楼层
ZYKsslm 发表于 2025-8-29 23:53
强,但我还是更倾向于提供数据读写接口,由开发者实现而不是直接更改引擎源码 ...


更改引擎源码那部分类似于配置环境,提供一个安卓可以读取和操作的目录,因为安卓特性,使得于安卓的部分操作需要更改SDK,其实我也不想改,很麻烦,可奈我技术力不足
回复 支持 抱歉

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|RenPy中文空间 ( 苏ICP备17067825号|苏公网安备 32092302000068号 )

GMT+8, 2025-9-1 12:52 , Processed in 0.080046 second(s), 31 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表