马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
本帖最后由 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
(加密资源的压缩包:此压缩包通过外置脚本处理,其内部文件处于加密状态,只有被解压解密后的文件才是明文)
特性3
(安卓私有目录:指的是每一个apk都可以向安卓系统申请的一个存放数据文件的目录,除了root用户一般情况玩家无法读取,且此目录仅能提供给对应应用使用)
特性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
闲言:
(没有绝对的加密和保护,对于一个开源的引擎,有加密只是加了层门槛,帖子内自带的加密以及大部分现有的加密都是只防君子不防小人,真的有人要破解是防不住的
高成本的加密可以做,但是没有必要的,换个思路,重要的是把自身作品做好,而加密不是必要的
让创作者保持一定开放性,加密固然保护了资源,但保持开放性也是学习的契机)
|