Preface
Cutoff: 24/01/05 Revision: 24/09/26
其实分析工作主要是在此后才真正起步…不过实在找不到时间写帖子orz
对最新知识有兴趣或也想参与研究的话,还请参阅以下Repo及其Wiki:
Project SEKAI 逆向(1): 文件解密及API初步静态分析
- 分析variant:ColorfulStage 2.4.1 (Google Play 美服)
1. 解密 metadata
- 利用 Il2CppDumper 对apk中提取出来的
global-metadata.data
和il2cpp.so
直接分析,可见至少metadata
有混淆
- IDA静态分析
libunity.so
, 定位到export的il2cpp_init
;没有发现有关混淆的处理
- 考虑直接分析
il2cpp.so
,定位到global-metadata.dat
有关流程
从这里的xref可以很轻松的摸到Il2Cpp的metadata加载流程
(注:部分变量已更名)
_BYTE *__fastcall MetadataLoader::LoadMetadataFile(char *a1)
{
unsigned __int64 v2; // x8
char *v3; // x9
__int64 v4; // x0
_BYTE *v5; // x8
unsigned __int64 v6; // x9
const char *v7; // x0
int v8; // w21
int v9; // w20
_BYTE *mapped_metadata; // x19
__int64 v11; // x8
__int64 v13; // [xsp+0h] [xbp-E0h] BYREF
unsigned __int64 v14; // [xsp+8h] [xbp-D8h]
char *v15; // [xsp+10h] [xbp-D0h]
size_t len[2]; // [xsp+30h] [xbp-B0h]
__int64 v17[2]; // [xsp+80h] [xbp-60h] BYREF
char *v18; // [xsp+90h] [xbp-50h]
char *v19; // [xsp+98h] [xbp-48h] BYREF
__int64 v20; // [xsp+A0h] [xbp-40h]
unsigned __int8 v21; // [xsp+A8h] [xbp-38h]
_BYTE v22[15]; // [xsp+A9h] [xbp-37h] BYREF
_BYTE *v23; // [xsp+B8h] [xbp-28h]
sub_17A953C();
v19 = "Metadata";
v20 = 8LL;
v2 = (unsigned __int64)(unsigned __int8)v13 >> 1;
if ( (v13 & 1) != 0 )
v3 = v15;
else
v3 = (char *)&v13 + 1;
if ( (v13 & 1) != 0 )
v2 = v14;
v17[0] = (__int64)v3;
v17[1] = v2;
sub_173B820(v17, &v19);
if ( (v13 & 1) != 0 )
operator delete(v15);
v4 = strlen(a1);
if ( (v21 & 1) != 0 )
v5 = v23;
else
v5 = v22;
if ( (v21 & 1) != 0 )
v6 = *(_QWORD *)&v22[7];
else
v6 = (unsigned __int64)v21 >> 1;
v19 = a1;
v20 = v4;
v13 = (__int64)v5;
v14 = v6;
sub_173B820(&v13, &v19);
if ( (v17[0] & 1) != 0 )
v7 = v18;
else
v7 = (char *)v17 + 1;
v8 = open(v7, 0);
if ( v8 == -1 )
goto LABEL_25;
if ( fstat(v8, (struct stat *)&v13) == -1 )
{
close(v8);
goto LABEL_25;
}
v9 = len[0];
mapped_metadata = mmap(0LL, len[0], 3, 2, v8, 0LL);
close(v8);
if ( mapped_metadata == (_BYTE *)-1LL )
{
LABEL_25:
mapped_metadata = 0LL;
goto UNENCRYPTED;
}
if ( v9 >= 1 )
{
v11 = 0LL;
do
{
mapped_metadata[v11] ^= METADATA_KEY[v11 & 0x7F];
++v11;
}
while ( v9 != (_DWORD)v11 );
}
UNENCRYPTED:
if ( (v17[0] & 1) != 0 )
operator delete(v18);
if ( (v21 & 1) != 0 )
operator delete(v23);
return mapped_metadata;
}
对比 Unity 的默认实现 (https://github.com/mos9527/il2cpp-27/blob/main/libil2cpp/vm/MetadataLoader.cpp):
void* MetadataLoader::LoadMetadataFile(const char* fileName)
{
std::string resourcesDirectory = utils::PathUtils::Combine(utils::Runtime::GetDataDir(), utils::StringView<char>("Metadata"));
std::string resourceFilePath = utils::PathUtils::Combine(resourcesDirectory, utils::StringView<char>(fileName, strlen(fileName)));
int error = 0;
FileHandle* handle = File::Open(resourceFilePath, kFileModeOpen, kFileAccessRead, kFileShareRead, kFileOptionsNone, &error);
if (error != 0)
return NULL;
void* fileBuffer = utils::MemoryMappedFile::Map(handle);
File::Close(handle, &error);
if (error != 0)
{
utils::MemoryMappedFile::Unmap(fileBuffer);
fileBuffer = NULL;
return NULL;
}
return fileBuffer;
}
可见该伪代码块涉及到混淆流程:
if ( v9 >= 1 )
{
v11 = 0LL;
do
{
mapped_metadata[v11] ^= METADATA_KEY[v11 & 0x7F];
++v11;
}
while ( v9 != (_DWORD)v11 );
}
最后,解密脚本如下:
key = bytearray([
0xC3, 0x2B, 0x75, 0xB9, 0xAF, 0x84, 0x3C, 0x1F, 0x2E, 0xFB,
0xBF, 0x6C, 0x63, 0x19, 0x70, 0xE4, 0xF0, 0x92, 0xA3, 0x3E,
0xD1, 0x5C, 0x30, 0x0A, 0xCB, 0x9B, 0x04, 0xF8, 0x16, 0xC7,
0x91, 0x4A, 0x8D, 0xAE, 0xFA, 0xBA, 0x7E, 0x71, 0x65, 0x53,
0xAF, 0x98, 0x2E, 0xC2, 0xC0, 0xC6, 0xA3, 0x81, 0x74, 0xD4,
0xA3, 0x2C, 0x3F, 0xC2, 0x97, 0x66, 0xFB, 0x6B, 0xEE, 0x14,
0x80, 0x43, 0x09, 0x67, 0x69, 0x75, 0xDE, 0xB4, 0x1F, 0xB5,
0x65, 0x7E, 0x2D, 0x50, 0x8E, 0x38, 0x2E, 0x6D, 0x4A, 0x05,
0xF7, 0x82, 0x84, 0x41, 0x23, 0x64, 0x0A, 0xCB, 0x16, 0x93,
0xBE, 0x13, 0x83, 0x50, 0xD2, 0x6C, 0x8F, 0xC7, 0x58, 0x4A,
0xE7, 0xEE, 0x62, 0xBE, 0x6F, 0x25, 0xFE, 0xEF, 0x33, 0x5E,
0x38, 0x8D, 0x21, 0xE8, 0x1C, 0xFE, 0xBE, 0xC7, 0x43, 0x05,
0x6A, 0x13, 0x9D, 0x8B, 0xF6, 0x52, 0xFA, 0xDC
])
with open('global-metadata.dat','rb') as E:
with open('global-metadata-decrypt.dat','wb') as D:
data = bytearray(E.read())
for i in range(0, len(data)):
data[i] ^= key[i & 0x7f]
D.write(data)
处理后再次Dump, metadata
已经能够顺利加载
**注:**由 ERROR: This file may be protected.
,后续还将对il2cpp.so
继续处理;请看下文
private bool CheckProtection()
{
try
{
//.init_proc
if (dynamicSection.Any(x => x.d_tag == DT_INIT))
{
Console.WriteLine("WARNING: find .init_proc");
return true;
}
//JNI_OnLoad
ulong dynstrOffset = MapVATR(dynamicSection.First(x => x.d_tag == DT_STRTAB).d_un);
foreach (var symbol in symbolTable)
{
var name = ReadStringToNull(dynstrOffset + symbol.st_name);
switch (name)
{
case "JNI_OnLoad":
Console.WriteLine("WARNING: find JNI_OnLoad");
return true;
}
}
if (sectionTable != null && sectionTable.Any(x => x.sh_type == SHT_LOUSER))
{
Console.WriteLine("WARNING: find SHT_LOUSER section");
return true;
}
}
catch
{
// ignored
}
return false;
}
2.提取 libil2cpp.so
准备
手上没有Root的安卓实体机,这里就用WSA了
https://github.com/MustardChef/WSABuilds很方便地提供了集成Magisk的方案
Dump 运行时
libil2cpp.so
从 /proc/[pid]/maps 里读到基址后可借助 GameGuardian 提取
重复之前步骤
可以发现这次没有ERROR: This file may be protected.
- 修补后载入 IDA,详见该 Issue
3. Dump的部分发现
这里的混淆器是BeeByte;虽然只是更名混淆,但要拿这些符号分析业务逻辑的话还是很头疼
- 貌似Class部分名称仍然完好
4. API 解密,first look
抓包工具:Reqable,
CA验证可以通过https://github.com/NVISOsecurity/MagiskTrustUserCerts轻松绕过,这里不多说了
- 找到疑似解密业务的逻辑如下
Bingo!
想在下一次用动态调试 (i.e. Frida) 拿Key/IV,还请见后文
SEE YOU SPACE COWBOY…
References
https://katyscode.wordpress.com/2021/02/23/il2cpp-finding-obfuscated-global-metadata/
Project SEKAI 逆向(2): Frida动态调试及取API AES KEY/IV
- 分析variant:世界計劃 2.6.1 (Google Play 台服)
0. 版本更新
毕竟美服的数据库滞后好几个版本号…
换到台服之后版本号(2.4.1->2.6.1)有变化,同时上一篇所说的metadata
加密手段也换了
- 同样,拉取一份修复过的dump分析,可见:
metadata
加载部分不再进行全局加解密,观察二进制也可以发现:
2.4.0解密后metadata
2.6.1源metadata
多出来8字节?
- 加载部分出入不大,尝试删掉8字节直接分析
- 卡在读string这一块;到
Il2CppGlobalMetadataHeader.stringOffset
检查一下
- 果然有混淆;这部分应该是明文,而unity的例子中开始应该是
mscrolib
- 而so中il2cpp对metadata的使用却没加多余步骤;推测这部分在加载时进行了原地解密
1. metadata 动态提取
- 和上一篇记录的
il2cpp.so
提取步骤如出一辙,这里不再多说
- 比对apk的和dump出来的二进制:
果然string部分明朗了;继续dump
- 成功!带信息进ida;而非常戏剧化的事情是:
混淆没了。
果然是非常pro-customer的版本更新啊(确信)
2. frida-gadget 注入
WSA上注入各种碰壁,不过实体机上即使无Root也可以通过frida-gadget
修改apk注入
之前用过改dex的方式尝试过,并不成功;挑一个运行时会加载的.so下手:
注: 后面目标lib换成了libFastAES.so
,截图尚未更新
拿apktool打包后签名即可安装到真机
注: 我的frida跑在WSL上,拿 Windows 机器的adb做后端在 Win/Linux 机器配置分别如下
adb.exe -a -P 5555 nodaemon server
export ADB_SERVER_SOCKET=tcp:192.168.0.2:5555
3. IL2CPP 动态调用 runtime
接下来就可以直接调用runtime的东西了
- 回顾上文,貌似API业务逻辑只加了一套对称加密(AES128 CBC);这里同样如此
总结:
APIManager
为单例实例APIManager.get_Crypt()
取得Crypt
,其aesAlgo
即为.NET标准的AesManaged
综上,简单写一个脚本:
import "frida-il2cpp-bridge";
Il2Cpp.perform(() => {
Il2Cpp.domain.assemblies.forEach((i)=>{
console.log(i.name);
})
const game = Il2Cpp.domain.assembly("Assembly-CSharp").image;
const apiManager = game.class("Sekai.APIManager");
Il2Cpp.gc.choose(apiManager).forEach((instance: Il2Cpp.Object) => {
console.log("instance found")
const crypt = instance.method<Il2Cpp.Object>("get_Crypt").invoke();
const aes = crypt.field<Il2Cpp.Object>("aesAlgo");
const key = aes.value.method("get_Key").invoke();
const iv = aes.value.method("get_IV").invoke();
console.log(key);
console.log(iv);
});
});
输出如下:
- 测试解密
测试脚本如下:
from Crypto.Cipher import AES
def unpad(data):
padding_len = data[-1]
return data[:-padding_len]
def decrypt_aes_cbc(data, key, iv):
cipher = AES.new(key, AES.MODE_CBC, iv)
return unpad(cipher.decrypt(data))
payload = open('payload','rb').read()
key = b"g2fcC0ZczN9MTJ61"
iv = b"msx3IV0i9XE5uYZ1"
plaintext = decrypt_aes_cbc(payload, key, iv)
print(plaintext)
不清楚序列化格式是什么,可能是protobuf;还是在下一篇继续调查吧
SEE YOU SPACE COWBOY…
References
https://lief-project.github.io/doc/latest/tutorials/09_frida_lief.html
https://github.com/vfsfitvnm/frida-il2cpp-bridge
Project SEKAI 逆向(3): pjsk API中间人攻击 POC
- 分析variant:世界計劃 2.6.1 (Google Play 台服)
这里和il2cpp关系貌似不大;主要记录一下api包劫持可以用的手段
1. 工具
host使用https://github.com/mitmproxy/mitmproxy,
victim上安装https://github.com/NVISOsecurity/MagiskTrustUserCerts后,导入mitmproxy的CA,重启就能把它变成根证书
最后,mitmproxy所用脚本:https://github.com/mos9527/sssekai/blob/main/sssekai/scripts/mitmproxy_sekai_api.py
2. 分析
上一篇猜测api的封包用了protobuf;并不然,这里是MessagePack
如此,数据schema和报文在一起;不用额外挖了
用mitmproxy做个poc实时解密转json
看看:
3. MITM
没错,搞这个只是想看看MASTER谱面长什么样…
锁MASTER的字段貌似在这里;尝试直接修改
def response(self, flow : http.HTTPFlow):
if self.filter(flow):
body = self.log_flow(flow.response.content, flow)
if body:
if 'userMusics' in body:
print('! Intercepted userMusics')
for music in body['userMusics']:
for stat in music['userMusicDifficultyStatuses']:
stat['musicDifficultyStatus'] = 'available'
flow.response.content = sekai_api_encrypt(packb(body))
选项可以点亮;但无果:貌似live会在服务端鉴权
不过live鉴权id貌似不会在开始live中使用;抓包中没有对id的二次引用
考虑可能谱面难度选择只在客户端进行,那么修改上报服务器的难度系数也许能够绕过
def request(self,flow: http.HTTPFlow):
print(flow.request.host_header, flow.request.url)
if self.filter(flow):
body = self.log_flow(flow.request.content, flow)
if body:
if 'musicDifficultyId' in body:
print('! Intercepted Live request')
body['musicDifficultyId'] = 4 # Expert
flow.request.content = sekai_api_encrypt(packb(body))
再次启动:
当然,分数上报只会按expert难度进行
意外较少,后续应该不会再玩 MITM 相关内容了
接下来…提取游戏asset?在此之前…
SEE YOU SPACE COWBOY…
References
https://msgpack.org/index.html
https://github.com/mitmproxy/mitmproxy
Project SEKAI 逆向(4): pjsk AssetBundle 反混淆 + PV 动画导入
- 分析variant:世界計劃 2.6.1 (Google Play 台服)
1. 数据提取
pjsk资源采用热更新模式;本体运行时之外,还会有3~4G左右的资源 (**注:**不定量,见下一篇)
- 尝试从本机提取资源
没有magic UnityFS
,考虑ab文件有混淆
2. 加载流程分析
- 进dnSpy直接搜assetbundle找相关Class
- 进ida看impl,可以很轻松的找到加载ab的嫌疑流程
- 最后直接调用了unity的
LoadFromStream
,Sekai.AssetBundleStream
实现了这样的Stream:
可以注意到
加载时根据
_isInverted
flag 决定是否进行反混淆操作如果有,则先跳过4bytes,之后5bytes按位取反
最后移交
InvertedBytesAB
继续处理- 注意到
n00
应为128,v20
为读取offset
- 注意到
这里考虑offset=0情况,那么仅前128字节需要处理
跟进InvertedBytesAB
可见,这里即跳过4bytes后,每 8bytes,取反前5bytes
综上,解密流程分析完毕;附脚本:
import sys
import os
def decrypt(infile, outfile):
with open(infile, 'rb') as fin:
magic = fin.read(4)
if magic == b'\x10\x00\x00\x00':
with open(outfile,'wb') as fout:
for _ in range(0,128,8):
block = bytearray(fin.read(8))
for i in range(5):
block[i] = ~block[i] & 0xff
fout.write(block)
while (block := fin.read(8)):
fout.write(block)
else:
print('copy %s -> %s', infile, outfile)
fin.seek(0)
with open(outfile,'wb') as fout:
while (block := fin.read(8)):
fout.write(block)
if len(sys.argv) == 1:
print('usage: %s <in dir> <out dir>' % sys.argv[0])
else:
for root, dirs, files in os.walk(sys.argv[1]):
for fname in files:
file = os.path.join(root,fname)
if (os.path.isfile(file)):
decrypt(file, os.path.join(sys.argv[2], fname))
3. 提取资源
- 文件处理完后,就可以靠https://github.com/Perfare/AssetStudio查看资源了:
- 不过版本号很好找,这里是
2020.3.21f1
:
- 加载可行,如图:
4. AssetBundleInfo?
在数据目录里发现了这个文件,同时在Sekai_AssetBundleManager__LoadClientAssetBundleInfo
中:
用的是和API一样的密钥和封包手段,解开看看
注: 工具移步 https://github.com/mos9527/sssekai;内部解密流程在文章中都有描述
python -m sssekai apidecrypt .\AssetBundleInfo .\AssetBundleInfo.json
5. 资源使用?
- 角色模型数很少
猜测这里的资源被热加载;在blender直接看看已经有的mesh吧:
bind pose有问题,修正FBX导出设置可以解决;不过暂且不往这个方向深究
- 同时也许可以试试导入 Unity?
https://github.com/AssetRipper/AssetRipper/ 可以做到这一点,尝试如下:
- 拖进 Editor
- 注意shader并没有被拉出来,暂时用standard替补
- face/body mesh分开;需绑定face root bone(Neck)到body (Neck)
using UnityEngine;
public class BoneAttach : MonoBehaviour
{
public GameObject src;
public GameObject target;
void Start()
{
Update();
}
void Update()
{
target.transform.position = src.transform.position;
target.transform.rotation = src.transform.rotation;
target.transform.localScale = src.transform.localScale;
}
}
- 注意到blendshape/morph名字对不上
爬了下issue:这里的数字是名称的crc32(见 https://github.com/AssetRipper/AssetRipper/issues/954)
- 拿blendshape名字做个map修复后,动画key正常
- 加上timeline后的播放效果
不知道什么时候写之后的,暂时画几个饼:
- 资源导入Blender + toon shader 复刻
- 资源导入 Foundation
- 脱离游戏解析+下载资源
SEE YOU SPACE COWBOY…
References
https://github.com/AssetRipper/AssetRipper/
https://github.com/AssetRipper/AssetRipper/issues/954
https://github.com/mos9527/Foundation
Project SEKAI 逆向(5): AssetBundle 脱机 + USM 提取
- 分析variant:世界計劃 2.6.1 (Google Play 台服)
1. AssetBundleInfo
前文提及的AssetBundleInfo
是从设备提取出来的;假设是已经加载过的所有资源的缓存的话:
- 在刚刚完成下载的设备上提取该文件时,该文件 4MB
- 但是在初始化后重现抓包时发现的该文件为 13MB
curl -X GET 'https://184.26.43.87/obj/sf-game-alisg/gdl_app_5245/AssetBundle/2.6.0/Release/online/android21/AssetBundleInfo.json' -H 'Host: lf16-mkovscdn-sg.bytedgame.com' -H 'User-Agent: UnityPlayer/2020.3.32f1 (UnityWebRequest/1.0, libcurl/7.80.0-DEV)' -H 'Accept-Encoding: deflate, gzip' -H 'X-Unity-Version: 2020.3.32f1'
- 推测设备上文件为已缓存资源库,而这里的即为全量资源集合;尝试dump
sssekai apidecrypt .\assetbundleinfo .\assetbundleinfo.json
- 查身体模型数看看吧
- 此外,这里的数据还会多几个field
新数据库:
"live_pv/model/character/body/21/0001/ladies_s": {
"bundleName": "live_pv/model/character/body/21/0001/ladies_s",
"cacheFileName": "db0ad5ee5cc11c50613e7a9a1abc4c55",
"cacheDirectoryName": "33a2",
"hash": "28b258e96108e44578028d36ec1a1565",
"category": "Live_pv",
"crc": 2544770552,
"fileSize": 588586,
"dependencies": [
"android1/shader/live"
],
"paths": null,
"isBuiltin": false,
"md5Hash": "f9ac19a16b2493fb3f6f0438ada7e269",
"downloadPath": "android1/live_pv/model/character/body/21/0001/ladies_s"
},
设备数据库:
"live_pv/model/character/body/21/0001/ladies_s": {
"bundleName": "live_pv/model/character/body/21/0001/ladies_s",
"cacheFileName": "db0ad5ee5cc11c50613e7a9a1abc4c55",
"cacheDirectoryName": "33a2",
"hash": "28b258e96108e44578028d36ec1a1565",
"category": "Live_pv",
"crc": 2544770552,
"fileSize": 588586,
"dependencies": [
"android1/shader/live"
],
"paths": null,
"isBuiltin": false,
"md5Hash": "",
"downloadPath": ""
},
多出的downloadPath
可以利用,继续吧…
2. CDN?
- 启动下载后,能抓到一堆这种包:
curl -X GET 'https://184.26.43.74/obj/sf-game-alisg/gdl_app_5245/AssetBundle/2.6.0/Release/online/android1/actionset/group1?t=20240101203510' -H 'Host: lf16-mkovscdn-sg.bytedgame.com' -H 'User-Agent: UnityPlayer/2020.3.32f1 (UnityWebRequest/1.0, libcurl/7.80.0-DEV)' -H 'Accept-Encoding: deflate, gzip' -H 'X-Unity-Version: 2020.3.32f1'
downloadPath
字段在这里出现了;看起来https://184.26.43.74/obj/sf-game-alisg/gdl_app_5245/AssetBundle/2.6.0/Release/online
` 是这里的AB的根路径
而184.26.43.74
就是cdn了,毕竟
- cdn的地址看起来是内嵌的;在dump出来的strings中:
3. 热更新 Cache
考虑pjsk更新频率大,每次重新下所有数据不是很高效
做一个本地cache动机充分;细节就不在这里说了,请看 https://github.com/mos9527/sssekai/blob/main/sssekai/abcache/__init__.py
尝试拉取全部资源,貌似需要27GB
4. 文件一览
- 在 WinDirStat 中查看分布
- 动画资源:
- VO
其它的话,貌似是音视频文件居多
揭开后可以发现封包格式是CriWare中间件格式(i.e. USM视频流,HCA音频流)
5. USM 提取
动机:应该很简单orz
没有 Magic
CRID
回到IDA,看起来USM资源并不是直接从assetbundle中提取;中间有缓存到文件系统的流程
- 果然,在
/sdcard/Android/data/[...]/cache/movies
下有这样的文件
- 而且用WannaCri可以直接demux,没有额外密钥
- 回顾asset中USM文件
- 利用
MovieBundleBuildData
猜测可以拼接出源文件
**脚本:**https://github.com/mos9527/sssekai/blob/main/sssekai/entrypoint/usmdemux.py
SEE YOU SPACE COWBOY…
References
https://github.com/mos9527/sssekai
https://github.com/donmai-me/WannaCRI
Project SEKAI 逆向(6):Live2D 资源
- 分析variant:世界計劃 2.6.1 (Google Play 台服)
1. Live2D 模型
- 所有live2d资源都可以在
[abcache]/live2d/
下找到;包括模型及动画
首先,.moc3
,.model3
,.physics3
资源都可以直接利用Live2D Cubism Editor直接打开
而模型材质需要额外更名;这些信息都在BuildModelData
中
- 补全后即可导入,效果如图
2. 动画 Key 预处理
可惜动画并不是
.motion3
格式封包中有的是Unity自己的Animation Clip
在提取资源时,所有的动画key只能读到对应key string的CRC32 hash;导出/操作必须知道string-hash关系
- 这些string在
moc3
以外的文件中未知:当然,碰撞出string也不现实;猜想string和Live2D参数有关
尝试搜索无果
幸运的是Live2D Unity SDK可以免费取得,而且附带样例
还记得前文处理BlendShape时,可以知道
AnimationClip
的源.anim
会有path的源string,而不是crc
尝试加入前缀
可以定位;下面介绍如何构建CRC表,完成crc-string map
3. moc3 反序列化 + CRC打表
每次读取都从
moc3
文件构造应该可行;不过考虑到有导入纯动画的需求,显然一个常量map是需要的故需要能读取
moc3
中所有参数名;参照https://raw.githubusercontent.com/OpenL2D/moc3ingbird/master/src/moc3.hexpat在 ImHex 中可见:
- 提取参数名脚本如下:
from typing import BinaryIO
from struct import unpack
## https://github.com/OpenL2D/moc3ingbird/blob/master/src/moc3.hexpat
class moc3:
Parameters : list
Parts: list
def __init__(self, file : BinaryIO) -> None:
# Header: 64 bytes
file.seek(0)
assert file.read(4) == b'MOC3'
version = unpack('<c',file.read(1))[0]
isBigEndian = unpack('<b',file.read(1))[0]
assert not isBigEndian
# TODO: Other fields
file.seek(0x40)
pCountInfo = unpack('<I',file.read(4))[0]
file.seek(pCountInfo)
numParts = unpack('<I',file.read(4))[0]
file.seek(0x10, 1)
numParameters = unpack('<I',file.read(4))[0]
file.seek(0x4C)
pParts = unpack('<I',file.read(4))[0]
file.seek(0x108)
pParameters = unpack('<I',file.read(4))[0]
def read_strings(offset, count):
for i in range(0,count):
file.seek(offset + i * 0x40)
buffer = bytearray()
while b := file.read(1)[0]:
buffer.append(b)
yield buffer.decode(encoding='utf-8')
self.Parts = list(read_strings(pParts,numParts))
self.Parameters = list(read_strings(pParameters,numParameters))
- 之后,构造CRC表就很简单了
from io import BytesIO
from sssekai.unity.AssetBundle import load_assetbundle
from sssekai.fmt.moc3 import moc3
import sys, os
from UnityPy.enums import ClassIDType
ParameterNames = set()
PartNames = set()
tree = os.walk(sys.argv[1])
for root, dirs, files in tree:
for fname in files:
file = os.path.join(root,fname)
with open(file,'rb') as f:
env = load_assetbundle(f)
for obj in env.objects:
if obj.type == ClassIDType.TextAsset:
data = obj.read()
out_name : str = data.name
if out_name.endswith('.moc3'):
moc = moc3(BytesIO(data.script.tobytes()))
for name in moc.Parameters:
ParameterNames.add(name)
for name in moc.Parts:
PartNames.add(name)
from zlib import crc32
print('NAMES_CRC_TBL = {')
for name in sorted(list(PartNames)):
fullpath = 'Parts/' + name
print(' %d:"%s",' % (crc32(fullpath.encode('utf-8')), fullpath))
for name in sorted(list(ParameterNames)):
fullpath = 'Parameters/' + name
print(' %d:"%s",' % (crc32(fullpath.encode('utf-8')), fullpath))
print('}')
- 导出结果如下:
4. AnimationClip 转换
Live2D有自己私有的动画格式motion3
,幸运的是UnityLive2DExtractor已做了相当多的解析实现,可供参考
由于上文介绍的细节出入,对PJSK的转换并不能直接使用这个工具
索性在sssekai重现;细节非常繁琐,再次不多说;有兴趣的话还请参考源码
- 使用例:将转化所有找到的
AnimationClip
为.motion3.json
sssekai live2dextract c:\Users\mos9527\.sssekai\abcache\live2d\motion\21miku_motion_base .
- 效果如图
SEE YOU SPACE COWBOY…
References
https://github.com/AssetRipper/AssetRipper
https://github.com/OpenL2D/moc3ingbird
https://github.com/Perfare/UnityLive2DExtractor
Project SEKAI 逆向(7):3D 模型
1. 文件结构
- 目前发现的 3D 模型基本都在
[ab cache]/live_pv/model/
下
初步观察:
- (1) Body 即目标模型;当然,作为skinned mesh,而且带有blend shapes,处理细节会很多;后面继续讲
- (2) 处的
MonoBehavior
就其名字猜测是碰撞盒 - (3) 处的几个
Texture2D
则作为texture map
2. 模型收集
利用sssekai
取得数据的流程在(5)中已有描述,这里不再多说
首先整理下根据mesh发现的数据需求
- (1) Static Mesh
pjsk发现的所有mesh在相应assetbundle中会有1个或更多GameObject
的ref;对于这些ref,static mesh会出现在m_MeshRenderer
之中
其他细节暂且不说;因为做 Skinned Mesh 导入时都是我们要处理的东西
- (2) Skinned Mesh
不同于static mesh,这些ref会出现在m_SkinnedMeshRenderer
之中
同时,我们也会需要骨骼结构的信息;bone weight以外,也需要bone path(后面会用来反向hash)和transform
(3) Blend Shapes
这些可以出现在static/skinned mesh之中;如果存在,我们也会需要blend shape名字的hash,理由和bone path一致
加之,Unity存在aseetbundle中动画path也都是crc,blendshape不是例外
总结:
(1) 所以对于static mesh,搜集对应
GameObject
即可(2) 对于skinned mesh,同时也需要构造bone hierarchy(就是个单根有向无环图啦),并且整理vertex权重;
则需要收集的,反而只是bone的transform而已;transform有子/父节点信息,也有拥有transform的
GameObject
的ref(3) 的数据,在(1)(2)中都会有
3. 模型导入
当然,这里就不考虑将模型转化为中间格式了(i.e. FBX,GLTF)
利用Blender Python,可以直接给这些素材写个importer
实现细节上,有几个值得注意的地方:
Unity读到的mesh是triangle list
Blender使用右手系,Unity/Direct3D使用左手系
坐标系 | 前 | 上 | 左 |
---|---|---|---|
Unity | Z | Y | X |
Blender | -Y | Z | -X |
意味着对向量需要如下转化
$\vec{V_{blender}}(X,Y,Z) = \vec(-V_{unity}.X,-V_{unity}.Z,V_{unity}.Y)$
对四元数XYZ部分
$\vec{Q_{blender}}(W,X,Y,Z) = \overline{\vec(V_{unity}.W,-V_{unity}.X,-V_{unity}.Z,V_{unity}.Y)}$
Unity存储vector类型数据可能以2,3,4或其他个数浮点数读取,而vector不会额外封包,需要从flat float array中读取
意味着需要这样的处理
vtxFloats = int(len(data.m_Vertices) / data.m_VertexCount) vert = bm.verts.new(swizzle_vector3( data.m_Vertices[vtx * vtxFloats], # x,y,z data.m_Vertices[vtx * vtxFloats + 1], data.m_Vertices[vtx * vtxFloats + 2] ))
嗯。这里的
vtxFloats
就有可能是$4$. 虽然$w$项并用不到对于BlendShape, blender并不支持用他们修改法线或uv;这些信息只能丢掉
Blender的BlendShape名字不能超过64字,否则名称会被截取
对于bone,他们会以
Transform
的方式呈现;但在模型(和动画文件)中,他们只会以Scene
中这些transform的完整路径的hash存储然后,Blender的Vertex Group(bone weight group)同样也不能有64+长名字
对于vertex color,blender的
vertex_colors
layer在4.0已被弃用;不过可以放在Color Attributes
**注:**Blender中对写脚本帮助很大的一个小功能
4. Shaders!
Texture2D
和其他meta信息导入后,接下来就是做shader了
- 手头有的纹理资源如下:
tex_[...]_C
Base Color Map,没什么好说的
tex_[...]_S
Shadowed Color Map(乱猜
NPR渲染中常用的阈值Map;为节省性能(和细节质量),引擎也许并不会绘制真正的Shadow Map
在很多 NPR Shader中,你会见到这样的逻辑:
if (dot(N, L) > threshold) { diffuse = Sample(ColorMap, uv); } else { diffuse = Sample(ShadowColorMap, uv); }
即:对NdotL作阈值处理,光线亮(NdotL更大)采用原map,光线暗/无法照明(NdotL更小或为负)采用阴影map
tex_[...]_H
Hightlight Map
注意到Format
出于某种原因竟然是未压缩的RGB565
;同时,$R$通道恒为$0$,$B$通道恒为$132$,只有G通道有带意义的信息- UPD (20240907): 据指正,$R$通道定义肤色,$B$定义NPR阴影阈值,$G$通道标记了高光部分
- UPD (20240907): 同时,在三周年(JP)更新后,$a$通道指定了光泽度
Vertex Color
- 虽然不是texture map,但是放这里讲会合适不少
这里只有RG通道有信息,猜测:
$R$通道决定是否接受描边
$G$通道决定高光强度
5. Shader 实现
- 阴影 / Diffuse
注: BSDF应为Diffuse BSDF,截图暂未更新
这里实现的即为上文所述的阈值阴影,不多说了
- Specular
直接利用Specular BSDF的输出和前文所提到的weight,mix到输出即可
- Emissive
用_H
材质的$G$通道叠加,node如图
至此Shader部分介绍完毕,效果如图
6.描边
游戏使用了经典的shell shading技术
- 细节上,重现如图效果的话… (PV: 愛して愛して愛して))
- 可见$1$区域带明显描边而$2$区域没有,观察vertex color:
这和之前对描边做的猜测是一致的; $R$值决定是否描边
显然在 Blender 中使用 Geometry Node 可以很轻松地实现这个效果
SEE YOU SPACE COWBOY…
References
https://github.com/mos9527/sssekai_blender_io 👈 插件在这
https://github.com/KH40-khoast40/Shadekai
https://github.com/KhronosGroup/glTF-Blender-IO
https://github.com/theturboturnip/yk_gmd_io