源码fork后
https://github.com/Kinotern/KrkrHxv4Hash
Gemini-3.1Pro研究
一、 基础准备:工具
盐值参数
在这个程序的开头,作者引入了必要的工具,并准备了哈希过程中的“妙妙参数”也就是盐值
#include <iostream>
#include <Windows.h>
const wchar_t* salt = L"xp3hnp"; // 盐值xp3hnp
想象你在做饭,如果只放相同的食材,做出来的味道总是一样的(很容易被别人猜出配方)。
在密码学中,“加盐”就是给原始数据(比如文件名)强行加上一段固定的字符串。
在这里,无论是处理什么文件,代码都会在文件名后面偷偷加上 xp3hnp 这个词。
这样可以防止别人用普通的破解库反推出原始文件名。
混淆器说明
说明书"魔数"(全程魔法数字)
static const uint32_t IV[8] = { ... }; // 初始向量
static const uint8_t sigma[10][16] = { ... }; // 混合顺序表
它使用了国际知名的哈希算法 BLAKE2s 的标准常数
你可以把它们理解为“榨汁机”出厂时自带的齿轮设定和说明书。
程序在打碎数据时,会严格按照这些数字规定的顺序来回搅拌。
打乱的过程
旋转与混合
无论是算文件名的指纹,还是算路径的指纹,本质都是把数据的二进制位(0和1)打乱重组
1. 移位操作(像是转动密码锁)
// 核心动作:把数字的二进制位向右循环移动
static inline uint32_t ror32(uint32_t value, uint32_t count) {
return (value >> count) | (value << (32 - count));
}
假设有个数字是 123456,向右循环移动2位,
就会变成 561234。在计算机底层,这种操作极快,并且能极其有效地把数据打散。
2. G函数与SipRound:刀片组
// BLAKE2s 算法的搅拌刀片
void G(uint32_t v[16], int a, int b, int c, int d, uint32_t x, uint32_t y) {
v[a] += v[b] + x;
v[d] = ror32(v[d] ^ v[a], 16);
// ...省略部分代码...
}
// SipHash 算法的搅拌刀片
void sipround(uint64_t* v0, uint64_t* v1, uint64_t* v2, uint64_t* v3) {
*v0 += *v1; *v1 = rol64(*v1, 13); *v1 ^= *v0; // ...
}
这里出现了两个著名的密码学函数,它们就是哈希算法的“刀片”:
G函数:属于 BLAKE2s 算法(用于文件名)。它通过“相加”、“异或(一种逻辑混合)”和“循环移位”,把输入的数据彻底绞碎。sipround函数:属于 SipHash 算法(用于文件路径)。同样通过加法、移位和异或,实现极高强度的混合。
你不需要看懂刀片是怎么转的,只需要知道数据一经过这两个函数,原来的样子就绝对找不回来了。
二、 实战应用1:给“文件名”生成指纹
现在我们来看外部如何调用这个程序来处理文件名。 核心函数是 get_filename_hash
extern "C" __declspec(dllexport)
const uint8_t* get_filename_hash(const wchar_t* input_string2) {
// 1. 准备一个预设好初始状态的“盆子”(outName),大小是48个字节。
uint8_t outName[0x30] = { 0x47, 0xE6, 0x08... };
// 2. 测量文件名和“盐”的长度,把它们拼接到一起。
// 比如:原文件名是 "bgm.ogg",拼接后变成 "bgm.oggxp3hnp"
size_t len_input = wcslen(input_string2);
size_t len_salt = wcslen(salt);
// ... 拼接操作 ...
// 3. 把长长的名字分块(Chunk),一块一块地扔进榨汁机
while (remaining_bytes > 0) {
// ... 判断是不是最后一块(is_last_chunk)...
// 4. 调用 FilenameHash (其实就是 BLAKE2s 算法) 进行搅拌
FilenameHash(reinterpret_cast<unsigned int*>(outName), reinterpret_cast<unsigned char*>(buffer));
}
// 5. 返回最终生成的 32 字节指纹
return outName32;
}
运行逻辑通俗版:
- 引擎把文件名(比如
pic.png)传进来。 - 代码把文件名加点盐,变成
pic.pngxp3hnp。 - 如果这串字太长,程序会把它切成一小块一小块的(最多64字节一块)。
- 把每一块依次扔进
FilenameHash(里面包含刚才说的G函数刀片)里去搅拌。前一块搅出来的糊糊,会作为下一块的底料继续搅。 - 所有文字处理完后,端出一杯 32 字节大小的“数字指纹”返回给游戏引擎。
三、 实战应用2:给“文件路径”生成指纹
处理目录/路径(比如 data/system/)时
程序换了另一种算法(SipHash)
核心函数是 get_path_hash
extern "C" __declspec(dllexport)
uint64_t get_path_hash(const wchar_t* input) {
uint8_t seed[16] = { 0 }; // 准备一个全为0的密钥
// 1. 特殊情况处理:如果传入的仅仅是一个根目录 "/"
if (wcslen(input) == 1 && input[0] == L'/') {
// 只对“盐”(xp3hnp)进行哈希处理
uint64_t hash = PathHash((unsigned char*)salt, wcslen(salt) * 2, seed);
// 核心动作:把结果的字节顺序倒过来(翻转端序)
std::reverse(p, p + 8);
return hash;
}
// 2. 常规情况:拼接路径和盐
// 比如路径是 "data/bg",拼接后变成 "data/bgxp3hnp"
// ... 拼接代码 ...
// 3. 调用 PathHash (SipHash 算法) 进行生成
uint64_t hash = PathHash((unsigned char*)input_string, wcslen(input_string) * 2, seed);
// 4. 再次翻转字节顺序
unsigned char* p = reinterpret_cast<unsigned char*>(&hash);
std::reverse(p, p + 8);
hash = *reinterpret_cast<uint64_t*>(p);
return hash;
}
运行逻辑通俗版:
- 这个函数专门处理较长的文件路径。
- 它依然贯彻了“加盐”的传统(拼上
xp3hnp)。 - 使用
PathHash,把文字转换成一个 64位的数字(相对于文件名的指纹,这个稍微短一点)。 - 最巧妙的一步是
std::reverse:算出来的指纹,比如是0x1122334455667788,程序会把它颠倒过来变成0x8877665544332211。这主要是为了适配不同计算机硬件在内存中存储数据的习惯(术语叫大小端序转换,Endianness),确保游戏在不同设备上算出的结果一致。
它就是一个“双模榨汁系统”
当面对文件名时,开启 BLAKE2s 模式,产出 32 字节的长指纹。
当面对文件路径时,开启 SipHash 模式,产出 8 字节(64位)的短指纹,
并且最后把杯子倒扣(翻转字节顺序)
无论使用哪种模式,它都会在你要处理的食材里偷偷加上一勺名为 xp3hnp 的盐
通过这种方式,Kirikiri 引擎在打包和读取游戏资源时,不需要去比对又长又慢的字符串文本,只需要对比这串短小精悍的“指纹”数字,大大加快了游戏的运行和加载速度?我不这么认为
首先我们来看计算文件名哈希的代价:
// 每次查找文件,都要经历这个循环 10 次的魔鬼搅拌
for (int r = 0; r < 10; ++r) {
G(v, 0, 4, 8, 12, m[sigma[r][0]], m[sigma[r][1]]);
// ... 另外 7 次 G 函数调用 ...
}
CPU 密集型开销:BLAKE2s 包含大量的位移(ror32)、异或(^)和加法。
即使现代 CPU 算这些很快,但它依然需要消耗 CPU 周期。
内存拷贝与加盐:每次都要 new wchar_t 申请内存、把路径和盐(xp3hnp)拷过去
算完再 delete[] 释放。频繁在堆上分配/释放内存,在底层是非常昂贵的开销
如果游戏直接用原始字符串 data/bgimage/main_menu.png 作为 Key
用常规的 std::unordered_map(哈希表)来存,虽然也需要算一点简单的字符串哈希
但绝对不需要用到 BLAKE2s 和 SipHash 这种安全级别/高散列度的重型武器
既然这么重,它图什么?核心原因在于 Kirikiri 游戏引擎(尤其是 Hxv4 这个商业加密版本)的设计初衷,
不仅仅是为了“查找”,更是为了“反解压”和“防止目录被轻易窥探”
原因 1:极度追求索引表的“固定长度”与“内存紧凑性”
普通的汉字或英文路径,长度是不固定的(可能几个字节,也可能上百字节)。
如果在游戏启动时,把成千上万个文件的原始路径全部读进内存拉一个大表,会导致:
内存碎片化:大量变长字符串在内存中乱飞。
指针开销:每个字符串都需要额外的指针和长度计数器。
而通过这段代码:
-
无论路径有多深(
a/b/c/d/e/f/g/file.png),经过get_path_hash出来永远是 -
固定 8 个字节(64位)的数字。
无论文件名多长,出来永远是固定的 32 字节。
游戏引擎在加载 XP3 资源包时,会把整个目录索引(Index)读入内存
因为所有文件的 Key 都是固定长度的,引擎可以用极其紧凑的连续内存(比如一个结构体数组)来存索引
对 CPU 缓存(L1/L2 Cache)来说,连续的、固定大小的内存遍历和二分查找,
速度甚至可以超越复杂的变长字符串哈希表。
原因2:为了“盲找”(Blind Lookup)与反混淆
这是最核心的商业机密。 很多二次元游戏为了防止玩家(或汉化组)直接提取
提取立绘、文本,会把资源加密。
如果封包里存的是原始路径 script/main.ks,解包者一眼就能看出这是核心剧情脚本
但 Kirikiri Hxv4 选择了:封包的索引表里不存任何原始文件名,只存这串算出来的哈希值!
当游戏想要加载 script/main.ks 时:
- 游戏引擎在内存中,用这段代码把
script/main.ks算成哈希。 - 引擎拿着这个哈希,去 XP3 封包的索引表里对暗号。
- 对上了,就直接根据偏移量(Offset)去读文件数据。
在这个过程中,封包里从头到尾都没有出现过“script/main.ks”这几个字
只有Tjs2100还有scn残留着这些文件的拼图线索
** **解包工具如果不知道“原始文件名”,面对那一堆哈希数字,根本不知道哪个文件对应的是什么,
从而极大地增加了逆向工程的难度。
3. 性能的“快”与“慢”,取决于参照物
我们来重新定义这个“快”:
单次计算慢:如果只对比单条路径,这段代码因为加盐、内存分配、SipHash/BLAKE2s 复杂的位运算,
绝对比单纯的比对字符串要慢。
整体检索快:当游戏有 上万个资源文件 需要频繁加载时,引擎通过固定长度的哈希值,
在内存连续的、高度优化的 B-树 或 排序数组 中进行查找,省去了字符串逐字比对的昂贵开销,
并换取了极小的内存占用。
每次从tjs2100的字符串或者scn想要加载这个资源,当第一次加载时那么你就得从头算一次
有就从CACH内存表加载
但只要游戏进程一关,内存被系统回收,缓存表瞬间灰飞烟灭
又回到了第一次加载算一次去xp3里头拿出来展示
假设游戏里正在跑一段剧情,
如果每次遇到 storage="xxx" 都要进 DLL 算一遍哈希、加一遍盐、拼一遍内存,
CPU 就会在短时间内被高频的 new/delete 和位运算死死卡住,玩家就会明显感觉到掉帧或卡顿
为了解决这个问题,引擎采用了“一次计算,终身受益”和“批量预处理”的策略
第一次加载 "bgm.ogg"
↓
[查找内存 Cache 表] -> 没找到!
↓
[调用 KrkrHxv4Hash.dll] -> 消耗 CPU 算出一串哈希
↓
[存入内存 Cache 表] (把 "bgm.ogg" 和 哈希值 绑定)
↓
去 XP3 封包里读数据
第二次加载 "bgm.ogg"
↓
[查找内存 Cache 表] -> 找到了!直接拿走哈希值!
↓
(完全跳过那堆复杂的哈希计算 DLL)
或许你该抽空看看你的temp路径
krkr_一堆数字的文件夹不清理,拉屎不冲一样的感觉,咦~
