diff --git a/.gitignore b/.gitignore index 64aa5d3..a30933b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ logs_bin build dist *.egg-info -*.test* \ No newline at end of file +*.test* +.temp +test.py diff --git a/.vscode/launch.json b/.vscode/launch.json index aa9444b..bf6585e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -169,5 +169,15 @@ ], "justMyCode": true }, + { + "name": "Python: Test File", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/tests/tests_main.py", + "console": "integratedTerminal", + "justMyCode": false, + "args": [ + ] + }, ] } \ No newline at end of file diff --git a/setup.py b/setup.py index 4ef7c79..2b22feb 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ install_requires=[ "msgpack", "pycryptodome", - "unitypy >= 1.10.14, < 1.20", + "unitypy >= 1.20.4", "wannacri", "python-json-logger", "tqdm", diff --git a/sssekai/entrypoint/apphash.py b/sssekai/entrypoint/apphash.py index 4d16fdb..3b9811b 100644 --- a/sssekai/entrypoint/apphash.py +++ b/sssekai/entrypoint/apphash.py @@ -91,6 +91,6 @@ def main_apphash(args): env.load_file(stream) for obj in env.objects: obj = obj.read() - hashStr = HASHREGEX.finditer(obj.raw_data) + hashStr = HASHREGEX.finditer(obj.object_reader.get_raw_data()) for m in hashStr: - print(m.group().decode(), obj.name) + print(m.group().decode(), obj.m_Name) diff --git a/sssekai/entrypoint/live2dextract.py b/sssekai/entrypoint/live2dextract.py index 47da1dd..d5a3e4d 100644 --- a/sssekai/entrypoint/live2dextract.py +++ b/sssekai/entrypoint/live2dextract.py @@ -18,22 +18,22 @@ def main_live2dextract(args): for obj in env.objects: data = obj.read() if obj.type in {ClassIDType.MonoBehaviour}: - monobehaviors[data.name] = data + monobehaviors[data.m_Name] = data if obj.type in {ClassIDType.Texture2D}: - textures[data.name] = data + textures[data.m_Name] = data if obj.type in {ClassIDType.AnimationClip}: - animations[data.name] = data + animations[data.m_Name] = data modelData = monobehaviors.get("BuildModelData", None) if not modelData: logger.warning("BuildModelData absent. Not extracting Live2D models!") else: - modelData = modelData.read_typetree() + # modelData = modelData.read_typetree() # TextAssets are directly extracted # Usually there are *.moc3, *.model3, *.physics3; the last two should be renamed to *.*.json for obj in env.objects: if obj.type == ClassIDType.TextAsset: data = obj.read() - out_name: str = data.name + out_name: str = data.m_Name if ( out_name.endswith(".moc3") or out_name.endswith(".model3") @@ -45,9 +45,9 @@ def main_live2dextract(args): out_name += ".json" with open(path.join(args.outdir, out_name), "wb") as fout: logger.info("Extracting Live2D Asset %s" % out_name) - fout.write(data.script) + fout.write(data.m_Script.encode("utf-8", "surrogateescape")) # Textures always needs conversion and is placed under specific folders - for texture in modelData["TextureNames"]: + for texture in modelData.TextureNames: name = path.basename(texture) folder = path.dirname(texture) out_folder = path.join(args.outdir, folder) diff --git a/sssekai/entrypoint/moc3paths.py b/sssekai/entrypoint/moc3paths.py index 7047a56..6000f5e 100644 --- a/sssekai/entrypoint/moc3paths.py +++ b/sssekai/entrypoint/moc3paths.py @@ -20,7 +20,9 @@ def main_moc3paths(args): out_name: str = data.name if out_name.endswith(".moc3"): parts, parameters = read_moc3( - BytesIO(data.script.tobytes()) + BytesIO( + data.m_Script.encode("utf-8", "surrogateescape") + ) ) ParameterNames.update(parameters) PartNames.update(parts) diff --git a/sssekai/entrypoint/mvdata.py b/sssekai/entrypoint/mvdata.py index 0720910..15b25b6 100644 --- a/sssekai/entrypoint/mvdata.py +++ b/sssekai/entrypoint/mvdata.py @@ -20,7 +20,7 @@ def main_mvdata(args): data = obj.read() if data.name == "data": index = len(mvdata_items) - mvdata_items.append(data.read_typetree()) + mvdata_items.append(data) # .read_typetree()) mvdata_lut[str(data.name)] = index mvdata_lut[str(data.id)] = index break diff --git a/sssekai/entrypoint/rla2json.py b/sssekai/entrypoint/rla2json.py index e1693ee..7c407d7 100644 --- a/sssekai/entrypoint/rla2json.py +++ b/sssekai/entrypoint/rla2json.py @@ -48,7 +48,7 @@ def main_rla2json(args): for obj in rla_env.objects: if obj.type in {ClassIDType.TextAsset}: data = obj.read() - datas[data.name] = data.script.tobytes() + datas[data.m_Name] = data.script.tobytes() header = datas.get("sekai.rlh", None) assert header, "RLH Header file not found!" makedirs(args.outdir, exist_ok=True) diff --git a/sssekai/entrypoint/spineextract.py b/sssekai/entrypoint/spineextract.py index e62ee22..3ba5d06 100644 --- a/sssekai/entrypoint/spineextract.py +++ b/sssekai/entrypoint/spineextract.py @@ -13,12 +13,12 @@ def main_spineextract(args): env = load_assetbundle(f) objects = [pobj.read() for pobj in env.objects] binaries = { - obj.name: obj + obj.m_Name: obj for obj in objects if getattr(obj, "type", None) in {ClassIDType.TextAsset} } textures = { - obj.name: obj + obj.m_Name: obj for obj in objects if getattr(obj, "type", None) in {ClassIDType.Texture2D} } @@ -34,7 +34,7 @@ def main_spineextract(args): if atlas: logger.info("...has Atlas %s" % spine) with open(os.path.join(outdir, spine, spine + ".atlas.txt"), "wb") as f: - f.write(atlas.script) + f.write(atlas.m_Script.encode("utf-8", "surrogateescape")) texfiles = [line.strip() for line in atlas.text.split("\n")] texfiles = [ ".".join(line.split(".")[:-1]) @@ -59,6 +59,6 @@ def main_spineextract(args): with open( os.path.join(outdir, spine, spine + ".skel.bytes"), "wb" ) as f: - f.write(skel.script) + f.write(skel.m_Script.encode("utf-8", "surrogateescape")) else: logger.warning("No skel found for %s" % spine) diff --git a/sssekai/entrypoint/usmdemux.py b/sssekai/entrypoint/usmdemux.py index 2bfa891..cb106fe 100644 --- a/sssekai/entrypoint/usmdemux.py +++ b/sssekai/entrypoint/usmdemux.py @@ -14,10 +14,10 @@ def main_usmdemux(args): for obj in env.objects: if obj.type in {ClassIDType.MonoBehaviour, ClassIDType.TextAsset}: data = obj.read() - datas[data.name] = data + datas[data.m_Name] = data movieInfo = datas.get("MovieBundleBuildData", None) assert movieInfo, "Invalid AssetBundle. No MovieBundleBuildData found!" - movieInfo = movieInfo.read_typetree() + # movieInfo = movieInfo.read_typetree() usm_name = movieInfo["movieBundleDatas"][0]["usmFileName"][: -len(".bytes")] logger.info("USM: %s" % usm_name) usm_folder = path.join(args.outdir, usm_name) @@ -27,7 +27,7 @@ def main_usmdemux(args): for data in movieInfo["movieBundleDatas"]: usm = data["usmFileName"][: -len(".bytes")] usm = datas[usm] - usmstream.write(usm.script) + usmstream.write(usm.m_Script.encode("utf-8", "surrogateescape")) usm = Usm.open(usm_temp, encoding="shift-jis") usm.demux(path.join(args.outdir, usm_name), usm_name) remove(usm_temp) diff --git a/sssekai/unity/AnimationClip.py b/sssekai/unity/AnimationClip.py index 5a296a5..f0544b6 100644 --- a/sssekai/unity/AnimationClip.py +++ b/sssekai/unity/AnimationClip.py @@ -1,8 +1,9 @@ from collections import OrderedDict from typing import Dict, List, Tuple from UnityPy.enums import ClassIDType -from UnityPy.classes import AnimationClip -from UnityPy.math import Matrix4x4, Quaternion, Vector3 +from UnityPy.classes import AnimationClip, StreamedClip, AnimationClipBindingConstant +from UnityPy.classes.math import Vector3f as Vector3, Quaternionf as Quaternion +from UnityPy.streams.EndianBinaryReader import EndianBinaryReader from dataclasses import dataclass from enum import IntEnum from logging import getLogger @@ -67,6 +68,89 @@ def __init__(self) -> None: self.TransformTracks[TransformType.Scaling] = dict() +# Backported from 47b1bde027fd79d78af3de4d5e3bebd05f8ceeb8 +class StreamedCurveKey: + def __init__(self, reader): + self.index = reader.read_int() + self.coeff = reader.read_float_array(4) + + self.outSlope = self.coeff[2] + self.value = self.coeff[3] + + def CalculateNextInSlope(self, dx: float, rhs): + """ + :param dx: float + :param rhs: StreamedCurvedKey + :return: + """ + # Stepped + if self.coeff[0] == 0 and self.coeff[1] == 0 and self.coeff[2] == 0: + return float("inf") + + dx = max(dx, 0.0001) + dy = rhs.value - self.value + length = 1.0 / (dx * dx) + d1 = self.outSlope * dx + d2 = dy + dy + dy - d1 - d1 - self.coeff[1] / length + return d2 / dx + + +class StreamedFrame: + def __init__(self, reader): + self.time = reader.read_float() + numKeys = reader.read_int() + self.keyList = [StreamedCurveKey(reader) for _ in range(numKeys)] + + +def StreamedClipReadData(self): + frameList = [] + buffer = b"".join(val.to_bytes(4, "big") for val in self.data) + reader = EndianBinaryReader(buffer) + while reader.Position < reader.Length: + frameList.append(StreamedFrame(reader)) + + for frameIndex in range(2, len(frameList) - 1): + frame = frameList[frameIndex] + for curveKey in frame.keyList: + i = frameIndex - 1 + while i >= 0: + preFrame = frameList[i] + try: + preCurveKey = [ + x for x in preFrame.keyList if x.index == curveKey.index + ][0] + curveKey.inSlope = preCurveKey.CalculateNextInSlope( + frame.time - preFrame.time, curveKey + ) + break + except IndexError: + pass + i -= 1 + return frameList +StreamedClip.ReadData = StreamedClipReadData + +def AnimationClipBindingConstantFindBinding(self, index): + curves = 0 + for b in self.genericBindings: + if b.typeID == ClassIDType.Transform: # + switch = b.attribute + + if switch in [1, 3, 4]: + # case 1: #kBindTransformPosition + # case 3: #kBindTransformScale + # case 4: #kBindTransformEuler + curves += 3 + elif switch == 2: # kBindTransformRotation + curves += 4 + else: + curves += 1 + else: + curves += 1 + if curves > index: + return b + return None +AnimationClipBindingConstant.FindBinding = AnimationClipBindingConstantFindBinding + def read_animation(animationClip: AnimationClip) -> Animation: """Reads AnimationClip data and converts it to a list of Tracks @@ -76,7 +160,7 @@ def read_animation(animationClip: AnimationClip) -> Animation: Returns: List[Track]: List of Tracks """ - m_Clip = animationClip.m_MuscleClip.m_Clip + m_Clip = animationClip.m_MuscleClip.m_Clip.data streamedFrames = m_Clip.m_StreamedClip.ReadData() m_ClipBindingConstant = animationClip.m_ClipBindingConstant animationTracks = Animation() @@ -195,7 +279,9 @@ def get_next_curve_index(current, keyList): curveIndex = 0 while curveIndex < len(m_ConstantClip.data): index = streamCount + denseCount + curveIndex - binding = m_ClipBindingConstant.FindBinding(index) + binding = AnimationClipBindingConstant.FindBinding( + m_ClipBindingConstant, index + ) if binding.typeID == ClassIDType.Transform: transformType = TransformType(binding.attribute) dimension = DimensionOfTransformType(transformType) diff --git a/sssekai/unity/Mesh.py b/sssekai/unity/Mesh.py new file mode 100644 index 0000000..15e3369 --- /dev/null +++ b/sssekai/unity/Mesh.py @@ -0,0 +1,423 @@ +from enum import IntEnum +import struct +from typing import List +from UnityPy.UnityPyBoost import unpack_vertexdata as unpack_vertexdata_boost +from UnityPy.enums import GfxPrimitiveType +from UnityPy.classes import StreamInfo, VertexData, Mesh + + +# Backported from UnityPy 47b1bde027fd79d78af3de4d5e3bebd05f8ceeb8 w/ Modifications +# Mesh.py: https://github.com/K0lb3/UnityPy/blob/47b1bde027fd79d78af3de4d5e3bebd05f8ceeb8/UnityPy/classes/Mesh.py +class MeshHelper: + @staticmethod + def GetFormatSize(format: int) -> int: + if format in [ + VertexFormat.kVertexFormatFloat, + VertexFormat.kVertexFormatUInt32, + VertexFormat.kVertexFormatSInt32, + ]: + return 4 + elif format in [ + VertexFormat.kVertexFormatFloat16, + VertexFormat.kVertexFormatUNorm16, + VertexFormat.kVertexFormatSNorm16, + VertexFormat.kVertexFormatUInt16, + VertexFormat.kVertexFormatSInt16, + ]: + return 2 + elif format in [ + VertexFormat.kVertexFormatUNorm8, + VertexFormat.kVertexFormatSNorm8, + VertexFormat.kVertexFormatUInt8, + VertexFormat.kVertexFormatSInt8, + ]: + return 1 + raise ValueError(format) + + @staticmethod + def IsIntFormat(version, format: int) -> bool: + if version[0] < 2017: + return format == 4 + elif version[0] < 2019: + return format >= 7 + else: + return format >= 6 + + @staticmethod + def BytesToFloatArray(inputBytes, size, vformat: "VertexFormat") -> List[float]: + if vformat == VertexFormat.kVertexFormatFloat: + return struct.unpack(f">{'f'*(len(inputBytes)//4)}", inputBytes) + elif vformat == VertexFormat.kVertexFormatFloat16: + return struct.unpack(f">{'e'*(len(inputBytes)//2)}", inputBytes) + elif vformat == VertexFormat.kVertexFormatUNorm8: + return [byte / 255.0 for byte in inputBytes] + elif vformat == VertexFormat.kVertexFormatSNorm8: + return [max(((byte - 128) / 127.0), -1.0) for byte in inputBytes] + elif vformat == VertexFormat.kVertexFormatUNorm16: + return [ + x / 65535.0 + for x in struct.unpack(f">{'H'*(len(inputBytes)//2)}", inputBytes) + ] + elif vformat == VertexFormat.kVertexFormatSNorm16: + return [ + max(((x - 32768) / 32767.0), -1.0) + for x in struct.unpack(f">{'h'*(len(inputBytes)//2)}", inputBytes) + ] + + @staticmethod + def BytesToIntArray(inputBytes, size): + if size == 1: + return [x for x in inputBytes] + elif size == 2: + return [ + x for x in struct.unpack(f">{'h'*(len(inputBytes)//2)}", inputBytes) + ] + elif size == 4: + return [ + x for x in struct.unpack(f">{'i'*(len(inputBytes)//4)}", inputBytes) + ] + + @staticmethod + def ToVertexFormat(format: int, version: List[int]) -> "VertexFormat": + if version[0] < 2017: + if format == VertexChannelFormat.kChannelFormatFloat: + return VertexFormat.kVertexFormatFloat + elif format == VertexChannelFormat.kChannelFormatFloat16: + return VertexFormat.kVertexFormatFloat16 + elif format == VertexChannelFormat.kChannelFormatColor: # in 4.x is size 4 + return VertexFormat.kVertexFormatUNorm8 + elif format == VertexChannelFormat.kChannelFormatByte: + return VertexFormat.kVertexFormatUInt8 + elif format == VertexChannelFormat.kChannelFormatUInt32: # in 5.x + return VertexFormat.kVertexFormatUInt32 + else: + raise ValueError(f"Failed to convert {format.name} to VertexFormat") + elif version[0] < 2019: + if format == VertexFormat2017.kVertexFormatFloat: + return VertexFormat.kVertexFormatFloat + elif format == VertexFormat2017.kVertexFormatFloat16: + return VertexFormat.kVertexFormatFloat16 + elif ( + format == VertexFormat2017.kVertexFormatColor + or format == VertexFormat2017.kVertexFormatUNorm8 + ): + return VertexFormat.kVertexFormatUNorm8 + elif format == VertexFormat2017.kVertexFormatSNorm8: + return VertexFormat.kVertexFormatSNorm8 + elif format == VertexFormat2017.kVertexFormatUNorm16: + return VertexFormat.kVertexFormatUNorm16 + elif format == VertexFormat2017.kVertexFormatSNorm16: + return VertexFormat.kVertexFormatSNorm16 + elif format == VertexFormat2017.kVertexFormatUInt8: + return VertexFormat.kVertexFormatUInt8 + elif format == VertexFormat2017.kVertexFormatSInt8: + return VertexFormat.kVertexFormatSInt8 + elif format == VertexFormat2017.kVertexFormatUInt16: + return VertexFormat.kVertexFormatUInt16 + elif format == VertexFormat2017.kVertexFormatSInt16: + return VertexFormat.kVertexFormatSInt16 + elif format == VertexFormat2017.kVertexFormatUInt32: + return VertexFormat.kVertexFormatUInt32 + elif format == VertexFormat2017.kVertexFormatSInt32: + return VertexFormat.kVertexFormatSInt32 + else: + raise ValueError(f"Failed to convert {format.name} to VertexFormat") + else: + return VertexFormat(format) + + +class VertexChannelFormat(IntEnum): + kChannelFormatFloat = 0 + kChannelFormatFloat16 = 1 + kChannelFormatColor = 2 + kChannelFormatByte = 3 + kChannelFormatUInt32 = 4 + + +class VertexFormat2017(IntEnum): + kVertexFormatFloat = 0 + kVertexFormatFloat16 = 1 + kVertexFormatColor = 2 + kVertexFormatUNorm8 = 3 + kVertexFormatSNorm8 = 4 + kVertexFormatUNorm16 = 5 + kVertexFormatSNorm16 = 6 + kVertexFormatUInt8 = 7 + kVertexFormatSInt8 = 8 + kVertexFormatUInt16 = 9 + kVertexFormatSInt16 = 10 + kVertexFormatUInt32 = 11 + kVertexFormatSInt32 = 12 + + +class VertexFormat(IntEnum): + kVertexFormatFloat = 0 + kVertexFormatFloat16 = 1 + kVertexFormatUNorm8 = 2 + kVertexFormatSNorm8 = 3 + kVertexFormatUNorm16 = 4 + kVertexFormatSNorm16 = 5 + kVertexFormatUInt8 = 6 + kVertexFormatSInt8 = 7 + kVertexFormatUInt16 = 8 + kVertexFormatSInt16 = 9 + kVertexFormatUInt32 = 10 + kVertexFormatSInt32 = 11 + + +# Monkey patch old helper functions into the new classes +def VertexDataGetStreams(self, version): + streamCount = 1 + if self.m_Channels: + streamCount += max(x.stream for x in self.m_Channels) + + self.m_Streams = {} + offset = 0 + for s in range(streamCount): + chnMask = 0 + stride = 0 + for chn, m_Channel in enumerate(self.m_Channels): + if m_Channel.stream == s: + if m_Channel.dimension > 0: + chnMask |= 1 << chn # Shift 1UInt << chn + stride += m_Channel.dimension * MeshHelper.GetFormatSize( + MeshHelper.ToVertexFormat(m_Channel.format, version) + ) + self.m_Streams[s] = StreamInfo( + channelMask=chnMask, + offset=offset, + stride=stride, + dividerOp=0, + frequency=0, + ) + offset += self.m_VertexCount * stride + # static size_t align_streamSize (size_t size) { return (size + (kVertexStreamAlign-1)) & ~(kVertexStreamAlign-1) + offset = (offset + (16 - 1)) & ~(16 - 1) # (offset + (16u - 1u)) & ~(16u - 1u); + + +VertexData.GetStreams = VertexDataGetStreams + + +class BoneWeights4: + def __init__(self): + self.weight = [0.0] * 4 + self.boneIndex = [0] * 4 + + +def InitMSkin(self): + self.m_Skin = [BoneWeights4() for _ in range(self.m_VertexCount)] + + +Mesh.InitMSkin = InitMSkin + + +def MeshReadVertexData(self: Mesh): + version = self.object_reader.version + m_VertexData = self.m_VertexData + m_VertexData.GetStreams(version) + m_VertexCount = self.m_VertexCount = m_VertexData.m_VertexCount + + for chn, m_Channel in enumerate(m_VertexData.m_Channels): + if m_Channel.dimension > 0: + m_Stream = m_VertexData.m_Streams[m_Channel.stream] + channelMask = bin(m_Stream.channelMask)[::-1] + if channelMask[chn] == "1": + if version[0] < 2018 and chn == 2 and m_Channel.format == 2: + m_Channel.dimension = 4 + + componentByteSize = MeshHelper.GetFormatSize( + MeshHelper.ToVertexFormat(m_Channel.format, version) + ) + swap = self.object_reader.reader.endian == "<" and componentByteSize > 1 + + componentBytes = unpack_vertexdata_boost( + bytes(m_VertexData.m_DataSize), + componentByteSize, + m_VertexCount, + m_Stream.offset, + m_Stream.stride, + m_Channel.offset, + m_Channel.dimension, + swap, + ) + + if MeshHelper.IsIntFormat(version, m_Channel.format): + componentsIntArray = MeshHelper.BytesToIntArray( + componentBytes, componentByteSize + ) + else: + componentsFloatArray = MeshHelper.BytesToFloatArray( + componentBytes, + componentByteSize, + MeshHelper.ToVertexFormat(m_Channel.format, version), + ) + + if version[0] >= 2018: + if chn == 0: # kShaderChannelVertex + self.m_Vertices = componentsFloatArray + elif chn == 1: # kShaderChannelNormal + self.m_Normals = componentsFloatArray + elif chn == 2: # kShaderChannelTangent + self.m_Tangents = componentsFloatArray + elif chn == 3: # kShaderChannelColor + self.m_Colors = componentsFloatArray + elif chn == 4: # kShaderChannelTexCoord0 + self.m_UV0 = componentsFloatArray + elif chn == 5: # kShaderChannelTexCoord1 + self.m_UV1 = componentsFloatArray + elif chn == 6: # kShaderChannelTexCoord2 + self.m_UV2 = componentsFloatArray + elif chn == 7: # kShaderChannelTexCoord3 + self.m_UV3 = componentsFloatArray + elif chn == 8: # kShaderChannelTexCoord4 + self.m_UV4 = componentsFloatArray + elif chn == 9: # kShaderChannelTexCoord5 + self.m_UV5 = componentsFloatArray + elif chn == 10: # kShaderChannelTexCoord6 + self.m_UV6 = componentsFloatArray + elif chn == 11: # kShaderChannelTexCoord7 + self.m_UV7 = componentsFloatArray + # 2018.2 and up + elif chn == 12: # kShaderChannelBlendWeight + if not self.m_Skin: + self.InitMSkin() + for i in range(m_VertexCount): + for j in range(m_Channel.dimension): + self.m_Skin[i].weight[j] = componentsFloatArray[ + i * m_Channel.dimension + j + ] + elif chn == 13: # kShaderChannelBlendIndices + if not self.m_Skin: + self.InitMSkin() + for i in range(m_VertexCount): + for j in range(m_Channel.dimension): + self.m_Skin[i].boneIndex[j] = componentsIntArray[ + i * m_Channel.dimension + j + ] + else: + if chn == 0: # kShaderChannelVertex + self.m_Vertices = componentsFloatArray + elif chn == 1: # kShaderChannelNormal + self.m_Normals = componentsFloatArray + elif chn == 2: # kShaderChannelColor + self.m_Colors = componentsFloatArray + elif chn == 3: # kShaderChannelTexCoord0 + self.m_UV0 = componentsFloatArray + elif chn == 4: # kShaderChannelTexCoord1 + self.m_UV1 = componentsFloatArray + elif chn == 5: + if version[0] >= 5: # kShaderChannelTexCoord2 + self.m_UV2 = componentsFloatArray + else: # kShaderChannelTangent + self.m_Tangents = componentsFloatArray + elif chn == 6: # kShaderChannelTexCoord3 + self.m_UV3 = componentsFloatArray + elif chn == 7: # kShaderChannelTangent + self.m_Tangents = componentsFloatArray + + +Mesh.ReadVertexData = MeshReadVertexData + + +def MeshRepackIndexBuffer(self): + self.m_Use16BitIndices = self.m_IndexFormat == 0 + raw_indices = bytes(self.m_IndexBuffer) + if self.m_Use16BitIndices: + char = "H" + index_size = 2 + else: + char = "I" + index_size = 4 + + self.m_IndexBuffer = struct.unpack( + f"<{len(raw_indices) // index_size}{char}", raw_indices + ) + + +Mesh.RepackIndexBuffer = MeshRepackIndexBuffer + + +def MeshGetTriangles(self): + m_IndexBuffer = self.m_IndexBuffer + m_Indices = self.m_Indices = getattr(self, "m_Indices", list()) + + for m_SubMesh in self.m_SubMeshes: + firstIndex = m_SubMesh.firstByte // 2 + if not self.m_Use16BitIndices: + firstIndex //= 2 + + indexCount = m_SubMesh.indexCount + topology = m_SubMesh.topology + if topology == GfxPrimitiveType.kPrimitiveTriangles: + m_Indices.extend( + m_IndexBuffer[firstIndex : firstIndex + indexCount - indexCount % 3] + ) + + elif ( + self.version[0] < 4 or topology == GfxPrimitiveType.kPrimitiveTriangleStrip + ): + # de-stripify : + triIndex = 0 + for i in range(indexCount - 2): + a, b, c = m_IndexBuffer[firstIndex + i : firstIndex + i + 3] + + # skip degenerates + if a == b or a == c or b == c: + continue + + # do the winding flip-flop of strips : + m_Indices.extend([b, a, c] if ((i & 1) == 1) else [a, b, c]) + triIndex += 3 + # fix indexCount + m_SubMesh.indexCount = triIndex + + elif topology == GfxPrimitiveType.kPrimitiveQuads: + for q in range(0, indexCount, 4): + m_Indices.extend( + [ + m_IndexBuffer[firstIndex + q], + m_IndexBuffer[firstIndex + q + 1], + m_IndexBuffer[firstIndex + q + 2], + m_IndexBuffer[firstIndex + q], + m_IndexBuffer[firstIndex + q + 2], + m_IndexBuffer[firstIndex + q + 3], + ] + ) + # fix indexCount + m_SubMesh.indexCount = indexCount // 2 * 3 + + else: + raise NotImplementedError( + "Failed getting triangles. Submesh topology is lines or points." + ) + + +Mesh.GetTriangles = MeshGetTriangles + + +def MeshPreprocessData(self): + self.ReadVertexData() + self.RepackIndexBuffer() + self.GetTriangles() + return self + + +Mesh.PreprocessData = MeshPreprocessData + + +MESH_PROCESS_FLAG = "_preprocess_flag" + + +def read_mesh(mesh: Mesh) -> Mesh: + """Populate the mesh data (e.g. vertices,indices,bone weights, etc) from the raw data. + + Args: + mesh (Mesh): source mesh data. the modifications are done in-place. + + Returns: + Mesh: processed mesh data + """ + if not hasattr(mesh, MESH_PROCESS_FLAG): + mesh._process_flag = True + mesh.PreprocessData() + return mesh diff --git a/sssekai/unity/__init__.py b/sssekai/unity/__init__.py index 76841ae..8900d25 100644 --- a/sssekai/unity/__init__.py +++ b/sssekai/unity/__init__.py @@ -17,3 +17,10 @@ def sssekai_set_unity_version(value): f"Setting Unity Version from {_sssekai_unity_version} to {value}" ) _sssekai_unity_version = value + + +# Monkey-patching modules +from . import Mesh, AnimationClip + +# Other modules +from . import AssetBundle diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..09f9042 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,24 @@ +import os + +from coloredlogs import install +from logging import getLogger, DEBUG + +install(level=DEBUG) +logger = getLogger("tests") + +import UnityPy, sssekai + +logger.info("UnityPy Version: %s" % UnityPy.__version__) +logger.info("SSSekai Version: %s" % sssekai.__version__) + +SOURCE_DIR = os.path.dirname(__file__) +sample_file_path = lambda *args: os.path.join(SOURCE_DIR, *args) +TEMP_DIR = sample_file_path(".temp") + + +class NamedDict(dict): + def __getattribute__(self, name: str): + try: + return super().__getattribute__(name) + except AttributeError: + return self.get(name, None) diff --git a/tests/live2d/21miku_motion_base b/tests/live2d/21miku_motion_base new file mode 100644 index 0000000..174b976 Binary files /dev/null and b/tests/live2d/21miku_motion_base differ diff --git a/tests/live2d/21miku_night b/tests/live2d/21miku_night new file mode 100644 index 0000000..2bd32d4 Binary files /dev/null and b/tests/live2d/21miku_night differ diff --git a/tests/test_apphash.py b/tests/test_apphash.py new file mode 100644 index 0000000..7bb6ff1 --- /dev/null +++ b/tests/test_apphash.py @@ -0,0 +1,7 @@ +from . import * + + +def test_apphash(): + from sssekai.entrypoint.apphash import main_apphash + + result = main_apphash(NamedDict()) diff --git a/tests/test_live2d.py b/tests/test_live2d.py new file mode 100644 index 0000000..1756e34 --- /dev/null +++ b/tests/test_live2d.py @@ -0,0 +1,27 @@ +from . import * + + +def test_live2d_motion(): + from sssekai.entrypoint.live2dextract import main_live2dextract + + result = main_live2dextract( + NamedDict( + { + "infile": sample_file_path("live2d", "21miku_motion_base"), + "outdir": TEMP_DIR, + } + ) + ) + + +def test_live2d_model(): + from sssekai.entrypoint.live2dextract import main_live2dextract + + result = main_live2dextract( + NamedDict( + { + "infile": sample_file_path("live2d", "21miku_night"), + "outdir": TEMP_DIR, + } + ) + )