近日学偶公测,于是就试着提一下里面的模型给玩mmd的朋友,记录了下遇到的坑和其特点

1.资源解密

通过其他大佬得知,这个游戏的资源加密方式和这个工作室上一个游戏偶像荣耀几乎一样,数据库是使用AES加密的,秘钥存在so里。资源是根据manifest里记录的资源名字生成一个Key,异或加密的。

因此,获得资源的步骤为:

解密so -> 拿到so中的key -> 解密manifest -> 拿到manifest里资源的名字生成Key -> 解密资源

这些步骤想要自己完成要花不少时间,幸好它的前辈偶像荣耀已经有大佬造好了轮子,简单的修改一下即可搞定,十分感谢这些大佬的付出!

  • 解密so可以查看:https://www.chinosk6.cn/index.php/archives/164/

2.资源处理

搞定了资源,先AS走一波,发现AB包无法读取,UnityPy提示找到版本为0.0.0,用记事本打开AB包,发现版本果然是被隐藏了。

不过问题不大,打开apk里的文件,里面有一些未加密的Unity通用资源AB包,这些AB包由于程序启动就要加载,且一般不包含游戏的敏感资源,所以一般不会走加密的流程,用记事本打开,可以看到里面的资源版本为2022.3.21f1

再次打开AS,指定版本号为2022.3.21f1再加载,搞定。

将身体,头发,和脸的模型分别导出并导入Unity编辑器,期间发现脸的模型无法跳转到资源树,有点奇怪,导入到编辑器后果然发现了问题,脸的模型是静态网格,没有蒙皮信息

在Unity中加载这个AB包发现了里面挂载着这个脚本VLActorFaceModel,里面引用了这个Mesh,原来是这个游戏自己实现了一个蒙皮网格渲染器,而没有用Unity自带的SkinnedMeshRenderer,所以AS无法跳转资源树,权重信息全部记录在脚本里,在运行时动态给Mesh刷权重和BlendShape,所以导出的脸部模型只有静态模型

因此,想要导出带权重的模型,就需要读取这个脚本的信息,给脸部模型重新上蒙皮

用il2cppdump出类型,dnspy查看缺失的类,并在unity里还原这些类结构(脚本集、类文件、命名空间),即可在ab包加载时正确保留这些信息

重新上权重的核心逻辑如下,附带将头发和脸和身体合并成为一个完整的模型:

using System.Collections;
using System.Collections.Generic;
using System.IO;
using Unity.Collections;
using UnityEngine;
using VL.FaceSystem;

public class ModelLoader : MonoBehaviour
{
    // Start is called before the first frame update
    public string ShaderFile;
    public string BodyFile;
    public string FaceFile;
    public string HairFile;
    public string PMXPath;

    public List<Shader> ShaderList = new List<Shader>();

    public GameObject Body;
    public GameObject Face;
    public GameObject Hair;

    public List<Object> AssetHolder = new List<Object>();

    public Transform ConnectBone;
    public Light DirectionalLight;
    void Start()
    {
        if (ShaderFile != "")
        {
            var shader_ab = AssetBundle.LoadFromFile(ShaderFile);
            ShaderList.AddRange(shader_ab.LoadAllAssets<Shader>());
        }

        if (BodyFile != "" && File.Exists(BodyFile))
        {
            var body_ab = AssetBundle.LoadFromFile(BodyFile);
            foreach (var ab in body_ab.LoadAllAssets())
            {
                if(ab is GameObject go)
                {
                    Body = Instantiate(go);
                    ConnectBone = Body.transform.Find("Reference/Hips/Spine/Spine1/Spine2/Neck/Head");
                }
                AssetHolder.Add(ab);
            }
        }

        if (FaceFile != "")
        {
            var face_ab = AssetBundle.LoadFromFile(FaceFile);
            foreach (var ab in face_ab.LoadAllAssets())
            {
                if (ab is GameObject go)
                {
                    Face = Instantiate(go);
                    var vl = Face.GetComponentInChildren<VLActorFaceModel>();
                    var skinned = vl.gameObject.AddComponent<SkinnedMeshRenderer>();
                    var mesh = vl.mesh; //开始重新上蒙皮
                    byte[] bonesPerVertex = new byte[mesh.vertexCount];
                    for(int i=0;i<bonesPerVertex.Length;i++)
                    {
                        bonesPerVertex[i] = 1;
                    }
                    BoneWeight1[] weights = new BoneWeight1[mesh.vertexCount];
                    for (int i = 0; i < weights.Length; i++)
                    {
                        weights[i].boneIndex = 0;
                        weights[i].weight = 1;
                    }

                    var bonesPerVertexArray = new NativeArray<byte>(bonesPerVertex, Allocator.Temp);
                    var weightsArray = new NativeArray<BoneWeight1>(weights, Allocator.Temp);
                    mesh.SetBoneWeights(bonesPerVertexArray, weightsArray);

                    skinned.sharedMesh = vl.mesh; //开始重新上BlendShape
                    skinned.bones = vl.bones;
                    mesh.bindposes = vl.bindposes;
                    foreach(var bs in vl.blendShapes)
                    {
                        var del_ver = new Vector3[mesh.vertexCount];
                        foreach(var ver in bs.blendShapeVertices)
                        {
                            del_ver[ver.vertIndex] = ver.position;
                        }
                        mesh.AddBlendShapeFrame(bs.blendShapeName, 1, del_ver, null, null);
                    }
                    skinned.localBounds = vl.localBounds;
                    skinned.rootBone = vl.rootBone;
                    skinned.materials = vl.sharedMaterials;
                    if (ConnectBone) //将脸和身体合并骨骼
                    {
                        Face.transform.SetParent(ConnectBone, false);
                        skinned.bones[0] = ConnectBone;
                        skinned.rootBone = ConnectBone;
                    }
                }
                AssetHolder.Add(ab);
            }
        }

        if (HairFile != "")
        {
            var hair_ab = AssetBundle.LoadFromFile(HairFile);
            foreach (var ab in hair_ab.LoadAllAssets())
            {
                if (ab is GameObject go)
                {
                    Hair = Instantiate(go);
                    if (ConnectBone)  //将头发和身体合并骨骼
                    {
                        Hair.transform.SetParent(ConnectBone, false);
                        SkinnedMeshRenderer skinned = Hair.GetComponentInChildren<SkinnedMeshRenderer>();
                        skinned.bones[0] = ConnectBone;
                        skinned.rootBone = ConnectBone;
                    }
                }
                AssetHolder.Add(ab);
            }
        }
    }
}

3.资源导出

至此,模型已经携带了需要的所有信息,可以进行导出了,之前在赛马娘查看器中写过unity直接导出PMX模型代码,省去导出FBX,再转为PMX的过程,能省去很多麻烦,正好借这次机会将这个功能单独分离成一个插件,给这里使用:

https://github.com/croakfang/UnityPMXExporter

导入插件,然后直接在场景资源树里右键模型,选择导出,即可将模型导出,十分方便

导出PE查看模型,完美!

4.总结

这次的提取还不算困难,几小时就搞定了,主要时间是消耗在分析这个脸的权重去哪了,相比之下,前期解密部分的难度就比这个高多了,十分感谢其他大佬造好的思路和轮子让这次提取可以直接快进到处理部分

最后附上项目工程有需自取:IDOL.zip

EX:工程的快速使用指南

我把上述逻辑的脚本挂在了MainCamra上,只需在ModelLoader里填好这些信息,点击运行即可

ShaderFile:Shader 的AB包的路径 ,这个游戏的shader都在一个ab包里,此项可不填,填后可以在编辑器里看到模型,而不是一团黑

BodyFile、FaceFile、HairFile: 身体、脸部、头发的AB包路径,必填

PMXPath:导出模型的路径,比如Assets/Export/MyModel.pmx

填好信息后运行,脚本会自动加载、合并模型并显示出来,并把模型导出为pmx格式到PMXPath