sssekai 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sssekai might be problematic. Click here for more details.

sssekai/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ __VERSION_MAJOR__ = 0
2
+ __VERSION_MINOR__ = 0
3
+ __VERSION_PATCH__ = 3
4
+
5
+ __version__ = '%s.%s.%s' % (__VERSION_MAJOR__,__VERSION_MINOR__,__VERSION_PATCH__)
6
+
sssekai/__main__.py ADDED
@@ -0,0 +1,77 @@
1
+ import codecs
2
+ import sys, os
3
+ import argparse
4
+ from sssekai.entrypoint.apidecrypt import main_apidecrypt
5
+ from sssekai.entrypoint.abdecrypt import main_abdecrypt
6
+ from sssekai.entrypoint.mitm import main_mitm
7
+ from sssekai.entrypoint.usmdemux import main_usmdemux
8
+ from sssekai.entrypoint.abcache import main_abcache, DEFAULT_CACHE_DIR
9
+ from sssekai.entrypoint.live2dextract import main_live2dextract
10
+ from sssekai.unity import SEKAI_UNITY_VERSION
11
+ def __main__():
12
+ import coloredlogs
13
+ from logging import basicConfig
14
+ coloredlogs.install(
15
+ level='INFO',
16
+ fmt="%(asctime)s %(name)s [%(levelname).4s] %(message)s",
17
+ isatty=True,
18
+ )
19
+ basicConfig(
20
+ level='INFO', format="[%(levelname).4s] %(name)s %(message)s"
21
+ )
22
+
23
+ parser = argparse.ArgumentParser(description='''SSSekai Proejct SEKAI feat. Hatsune Miku (Android) Modding Tools
24
+ Installation:
25
+ pip install git+https://github.com/mos9527/sssekai
26
+ ''', formatter_class=argparse.RawTextHelpFormatter)
27
+ subparsers = parser.add_subparsers(title='subcommands', description='valid subcommands', help='additional help')
28
+ # apidecrypt
29
+ apidecrypt_parser = subparsers.add_parser('apidecrypt', help='''API crypto dumper
30
+ This crypto applies to:
31
+ - API request/response body dumped by packet sniffer (mitmproxy, wireshark, etc.)
32
+ - AssetBundleInfo (can be found at /sdcard/Android/data/com.hermes.mk.asia/files/data/AssetBundleInfo,or see sssekai.abcache)''')
33
+ apidecrypt_parser.add_argument('infile', type=str, help='input dump file')
34
+ apidecrypt_parser.add_argument('outfile', type=str, help='output json file')
35
+ apidecrypt_parser.set_defaults(func=main_apidecrypt)
36
+ # abdecrypt
37
+ abdecrypt_parser = subparsers.add_parser('abdecrypt', help='''Decrypt Sekai AssetBundle
38
+ These can be found at /sdcard/Android/data/com.hermes.mk.asia/files/data/
39
+ ''')
40
+ abdecrypt_parser.add_argument('indir', type=str, help='input directory')
41
+ abdecrypt_parser.add_argument('outdir', type=str, help='output directory')
42
+ abdecrypt_parser.set_defaults(func=main_abdecrypt)
43
+ # usmdemux
44
+ usmdemux_parser = subparsers.add_parser('usmdemux', help='''Demux Sekai USM Video in a AssetBundle''')
45
+ usmdemux_parser.add_argument('infile', type=str, help='input file')
46
+ usmdemux_parser.add_argument('outdir', type=str, help='output directory')
47
+ usmdemux_parser.set_defaults(func=main_usmdemux)
48
+ # abcache
49
+ abcache_parser = subparsers.add_parser('abcache', help='''Sekai AssetBundle local cache
50
+ Downloads/Updates *ALL* PJSK JP assets to local devices.
51
+ NOTE: The assets can take quite a lot of space (est. 42.5GB) so be prepared
52
+ NOTE: The AssetBundles *cached* are NOT OBFUSCATED. They can be used as is by various Unity ripping tools (and sssekai by extension)
53
+ that supports stripped Unity version (should be %s. the version is ripped).''' % SEKAI_UNITY_VERSION)
54
+ abcache_parser.add_argument('--cache-dir', type=str, help='cache directory',default=DEFAULT_CACHE_DIR)
55
+ abcache_parser.add_argument('--skip-update',action='store_true',help='skip all updates and use cached assets as is.')
56
+ abcache_parser.add_argument('--open', action='store_true',help='open cache directory. this will skip all updates.')
57
+ abcache_parser.set_defaults(func=main_abcache)
58
+ # live2dextract
59
+ live2dextract_parser = subparsers.add_parser('live2dextract', help='''Extract Sekai Live2D Models in a AssetBundle''')
60
+ live2dextract_parser.add_argument('infile', type=str, help='input file')
61
+ live2dextract_parser.add_argument('outdir', type=str, help='output directory')
62
+ live2dextract_parser.add_argument('--no-anim',action='store_true',help='don\'t extract animation clips')
63
+ live2dextract_parser.set_defaults(func=main_live2dextract)
64
+ # mitm
65
+ mitm_parser = subparsers.add_parser('mitm', help='Run Sekai API MITM proxy (WIP)')
66
+ mitm_parser.set_defaults(func=main_mitm)
67
+ # parse args
68
+ args = parser.parse_args()
69
+ if 'func' in args:
70
+ args.func(args)
71
+ else:
72
+ parser.print_help()
73
+
74
+
75
+ if __name__ == "__main__":
76
+ __main__()
77
+ sys.exit(0)
@@ -0,0 +1,190 @@
1
+ from os import path, remove, makedirs, stat
2
+ from typing import List, Mapping
3
+ from dataclasses import dataclass, asdict
4
+ from requests import Session
5
+ from logging import getLogger
6
+ from json import dump, load
7
+ from pathlib import Path
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ logger = getLogger('sssekai.abcache')
10
+
11
+ from sssekai.crypto.APIManager import decrypt
12
+ from sssekai.crypto.AssetBundle import decrypt_headaer_inplace, SEKAI_AB_MAGIC
13
+ from msgpack import unpackb
14
+ from tqdm import tqdm
15
+ DEFAULT_CACHE_DIR = '~/.sssekai/abcache'
16
+ # 2.6.1
17
+ SEKAI_CDN = 'https://production-cf2d2388-assetbundle-info.sekai.colorfulpalette.org/'
18
+ SEKAI_AB_CDN = 'https://production-cf2d2388-assetbundle.sekai.colorfulpalette.org/'
19
+ SEKAI_AB_BASE_PATH = '3.3.0.20/8fd5e993-0146-c881-8902-4ed113efcdbf/android/'
20
+ SEKAI_AB_INDEX_PATH = 'api/version/3.3.0.20/os/android'
21
+ # HACK: Bypasses the check for now. Would this expire though?
22
+ SEKAI_API_COOKIES = 'CloudFront-Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKiIsIkNvbmRpdGlvbiI6eyJEYXRlTGVzc1RoYW4iOnsiQVdTOkVwb2NoVGltZSI6MTcwNzcwNDc1Nn0sIklwQWRkcmVzcyI6eyJBV1M6U291cmNlSXAiOiIwLjAuMC4wLzAifX19XX0_; CloudFront-Signature=KKEPARo-8R5aSq~AsEyXL~Bl7OaZ5P7U7KhxcQCjCtgVeCTdLxehu3fUt22cS-UOg25ZhzLLc4vEsFFTAz4LPq~I7lOZcGNj5Jan5-fQg52iRHVnZw~otkRrrsyZh6aDnKMxeNmqSOBtvf4Nw0tqe6vRPo398l6IqBeHZ-5owKQxG0SreAmq1wm8VSnMTow2l5Atxw~reEqQ9TFsktMsebL1eqExROXF4e~135zrcFuN9C~vPOs4m9CogLaYKPXcCau-l1D~Qs7PXXL3ZSY6r40kwdICfd50MiVM14Ip~926Xh6MO3BfEGR20WFqCA-VPGfa3ANUHR9Rj-Gzddxlrw__; CloudFront-Key-Pair-Id=KV6QGCE0D633X;'
23
+ class ThreadpoolDownloader(ThreadPoolExecutor):
24
+ session : Session
25
+ progress : tqdm
26
+
27
+ def download(self, url, fname, length):
28
+ RETRIES = 1
29
+ for _ in range(0,RETRIES):
30
+ try:
31
+ resp = self.session.get(url,stream=True)
32
+ resp.raise_for_status()
33
+ makedirs(path.dirname(fname),exist_ok=True)
34
+ with open(fname, 'wb') as f:
35
+ magic = next(resp.iter_content(4))
36
+ if magic == SEKAI_AB_MAGIC:
37
+ header = next(resp.iter_content(128))
38
+ self.progress.update(128)
39
+ f.write(decrypt_headaer_inplace(bytearray(header)))
40
+ else:
41
+ f.write(magic)
42
+ for chunk in resp.iter_content(65536):
43
+ self.progress.update(len(chunk))
44
+ f.write(chunk)
45
+ return
46
+ except Exception as e:
47
+ logger.error('While downloading %s : %s. Retrying' % (url,e))
48
+ if _ == RETRIES - 1:
49
+ logger.critical('Did not download %s' % url)
50
+ def add_link(self, url, fname, length):
51
+ self.progress.total += length
52
+ return self.submit(self.download, url, fname, length)
53
+ def __init__(self, session = None) -> None:
54
+ self.session = session or Session()
55
+ self.progress = tqdm(bar_format="{desc}: {percentage:.1f}%|{bar}| {n_fmt}/{total_fmt} {rate_fmt} {elapsed}<{remaining}", total=0,unit='B', unit_scale=True, unit_divisor=1024,desc="Downloading")
56
+ super().__init__() # workers = processor count * 5
57
+
58
+ class AbCacheConfig:
59
+ cache_dir : str # absolute path to cache directory
60
+ downloader : ThreadpoolDownloader
61
+ def __init__(self, downloader : ThreadpoolDownloader, cache_dir: str = DEFAULT_CACHE_DIR) -> None:
62
+ self.cache_dir = path.expanduser(cache_dir)
63
+ self.cache_dir = path.abspath(self.cache_dir)
64
+ self.downloader = downloader
65
+
66
+ @dataclass
67
+ class AbCacheEntry(dict):
68
+ bundleName: str
69
+ cacheFileName: str
70
+ cacheDirectoryName: str
71
+ hash: str
72
+ category: str
73
+ crc: int
74
+ fileSize:int
75
+ dependencies : List[str]
76
+ paths : List[str]
77
+ isBuiltin : bool
78
+
79
+ def up_to_date(self, config: AbCacheConfig, other) -> bool:
80
+ fsize = self.get_file_size(config)
81
+ return self.hash == other.hash and self.fileSize <= fsize # fsize may be fileSize + 4
82
+
83
+ def get_file_size(self, config: AbCacheConfig):
84
+ if self.get_file_exists(config):
85
+ return stat(self.get_file_path(config)).st_size
86
+ else:
87
+ return 0
88
+
89
+ def get_file_exists(self, config: AbCacheConfig) -> bool:
90
+ return path.exists(self.get_file_path(config))
91
+
92
+ def get_file_path(self, config: AbCacheConfig) -> str:
93
+ return path.join(config.cache_dir, self.bundleName)
94
+ @dataclass
95
+ class AbCacheIndex(dict):
96
+ version : str = 'unknown'
97
+ os : str = 'android'
98
+ bundles : Mapping[str, AbCacheEntry] = None
99
+ def get_bundle_by_abcache_path(self, config: AbCacheConfig, fpath) -> AbCacheEntry | None:
100
+ # "C:\Users\mos9527\.sssekai\abcache\title_screen\anniversary_2nd_bg"
101
+ fpath = Path(fpath)
102
+ cpath = Path(config.cache_dir)
103
+ assert fpath.is_relative_to(cpath), "File provided is not inside the cache"
104
+ relpath = fpath.relative_to(cpath).as_posix()
105
+ return self.bundles.get(relpath,None)
106
+
107
+ class AbCache(Session):
108
+ config : AbCacheConfig
109
+ index : AbCacheIndex
110
+
111
+ def download_cache_index(self) -> AbCacheIndex:
112
+ resp = self.get(
113
+ url=SEKAI_CDN + SEKAI_AB_INDEX_PATH,
114
+ headers={
115
+ 'Accept-Encoding': 'deflate, gzip'
116
+ }
117
+ )
118
+ resp.raise_for_status()
119
+ data = decrypt(resp.content)
120
+ data = unpackb(data)
121
+ cache = AbCacheIndex(**data)
122
+ for k,v in cache.bundles.items():
123
+ cache.bundles[k] = AbCacheEntry(**v)
124
+ return cache
125
+
126
+ def update_cahce_entry(self, entry : AbCacheEntry, new_entry : AbCacheEntry = None):
127
+ if new_entry:
128
+ entry = new_entry
129
+ return self.config.downloader.add_link(
130
+ SEKAI_AB_CDN + SEKAI_AB_BASE_PATH + entry.bundleName,
131
+ entry.get_file_path(self.config),
132
+ entry.fileSize
133
+ )
134
+
135
+ def update_cahce_index(self):
136
+ logger.info('Downloading Asset Bundle Index')
137
+ dl = self.download_cache_index()
138
+ all_keys = sorted(list({k for k in dl.bundles.keys()}.union({k for k in self.index.bundles.keys()})))
139
+ update_count = 0
140
+ for k in all_keys:
141
+ if k in dl.bundles and k in self.index.bundles:
142
+ # update
143
+ if not dl.bundles[k].up_to_date(self.config, self.index.bundles[k]):
144
+ logger.info('Updating bundle %s', k)
145
+ self.update_cahce_entry(self.index.bundles[k], dl.bundles[k])
146
+ update_count+=1
147
+ else:
148
+ logger.debug('Bundle %s is up to date' % k)
149
+ pass
150
+ elif k in dl.bundles:
151
+ # append
152
+ logger.debug('Adding bundle %s', k)
153
+ self.index.bundles[k] = dl.bundles[k]
154
+ self.update_cahce_entry(self.index.bundles[k])
155
+ update_count+=1
156
+ else:
157
+ # removal
158
+ logger.info('Removing bundle %s', k)
159
+ if path.exists(self.index.bundles[k].get_file_path(self.config)):
160
+ remove(self.index.bundles[k].get_file_path(self.config))
161
+ del self.index.bundles[k]
162
+ logger.info('Saving AssetBundle index')
163
+ self.save()
164
+ logger.info('...Saved. Need %d updates' % update_count)
165
+ self.config.downloader.shutdown(wait=True)
166
+ logger.info('AssetBundles are now up-to-date')
167
+
168
+ def __init__(self, config : AbCacheConfig) -> None:
169
+ super().__init__()
170
+ self.headers['Cookie'] = SEKAI_API_COOKIES
171
+ self.config = config
172
+ makedirs(self.config.cache_dir,exist_ok=True)
173
+ try:
174
+ self.load()
175
+ except Exception as e:
176
+ logger.warning('Failed to load cache index. Creating new one. (%s)' % e)
177
+ self.index = AbCacheIndex(bundles=dict())
178
+ self.save()
179
+
180
+ def save(self):
181
+ with open(path.join(self.config.cache_dir, 'abindex.json'),'w',encoding='utf-8') as f:
182
+ dump(asdict(self.index), f, indent=4, ensure_ascii=False)
183
+ logger.info("Saved %d entries to cache index" % len(self.index.bundles))
184
+
185
+ def load(self):
186
+ with open(path.join(self.config.cache_dir, 'abindex.json'),'r',encoding='utf-8') as f:
187
+ self.index = AbCacheIndex(**load(f))
188
+ for k,v in self.index.bundles.items():
189
+ self.index.bundles[k] = AbCacheEntry(**v)
190
+ logger.info("Loaded %d entries from cache index" % len(self.index.bundles))
@@ -0,0 +1,24 @@
1
+ from Crypto.Cipher import AES
2
+ def PKCS7_pad(data : bytes,bs) -> bytes:
3
+ size = (bs - len(data) % bs)
4
+ return data + bytes([size] * size)
5
+
6
+ def PKCS7_unpad(data : bytes,bs) -> bytes:
7
+ pad = data[-1]
8
+ return data[:-pad]
9
+
10
+ def decrypt_aes_cbc(data : bytes, key, iv, unpad=PKCS7_unpad) -> bytes:
11
+ cipher = AES.new(key, AES.MODE_CBC, iv)
12
+ return unpad(cipher.decrypt(data), cipher.block_size)
13
+
14
+ def encrypt_aes_cbc(data : bytes, key, iv, pad=PKCS7_pad) -> bytes:
15
+ cipher = AES.new(key, AES.MODE_CBC, iv)
16
+ return cipher.encrypt(pad(data, cipher.block_size))
17
+
18
+ SEKAI_APIMANAGER_KEY = b"g2fcC0ZczN9MTJ61"
19
+ SEKAI_APIMANAGER_IV = b"msx3IV0i9XE5uYZ1"
20
+ def encrypt(data : bytes) -> bytes:
21
+ return encrypt_aes_cbc(data,SEKAI_APIMANAGER_KEY,SEKAI_APIMANAGER_IV, PKCS7_pad)
22
+
23
+ def decrypt(data : bytes) -> bytes:
24
+ return decrypt_aes_cbc(data,SEKAI_APIMANAGER_KEY,SEKAI_APIMANAGER_IV, PKCS7_unpad)
@@ -0,0 +1,32 @@
1
+ from io import BytesIO
2
+ from typing import BinaryIO
3
+
4
+ SEKAI_AB_MAGIC = b'\x10\x00\x00\x00'
5
+ def decrypt_headaer_inplace(header : bytearray):
6
+ assert len(header) == 128
7
+ for i in range(0,128,8):
8
+ for j in range(5):
9
+ header[i + j] = ~header[i + j] & 0xff
10
+ return header
11
+
12
+ def decrypt(fin : BinaryIO, fout : BinaryIO = None) -> None:
13
+ if not fout:
14
+ fout = BytesIO()
15
+ magic = fin.read(4)
16
+ if magic == SEKAI_AB_MAGIC:
17
+ fout.write(decrypt_headaer_inplace(bytearray(fin.read(128))))
18
+ while (block := fin.read(65536)):
19
+ fout.write(block)
20
+ else:
21
+ fin.seek(0)
22
+ while (block := fin.read(65536)):
23
+ fout.write(block)
24
+ return fout
25
+
26
+ def encrypt(fin, fout):
27
+ raise NotImplementedError
28
+
29
+ def has_magic(fin : BinaryIO) -> bool:
30
+ magic = fin.read(4)
31
+ fin.seek(0)
32
+ return magic == SEKAI_AB_MAGIC
File without changes
File without changes
@@ -0,0 +1,16 @@
1
+ from sssekai.abcache import AbCache, AbCacheConfig,ThreadpoolDownloader, logger, DEFAULT_CACHE_DIR
2
+ def main_abcache(args):
3
+ downloader = ThreadpoolDownloader()
4
+ cache_dir = args.cache_dir or DEFAULT_CACHE_DIR
5
+ config = AbCacheConfig(downloader, cache_dir)
6
+ if args.open:
7
+ from os import startfile
8
+ startfile(config.cache_dir)
9
+ return
10
+ cache = AbCache(config)
11
+ downloader.session = cache
12
+ if not args.skip_update:
13
+ cache.update_cahce_index()
14
+ downloader.shutdown()
15
+ cache.save()
16
+ logger.info('AssetBundle cache is now ready. Visit: %s' % cache_dir)
@@ -0,0 +1,9 @@
1
+ import os
2
+ def main_abdecrypt(args):
3
+ from sssekai.crypto.AssetBundle import decrypt
4
+ tree = os.walk(args.indir)
5
+ for root, dirs, files in tree:
6
+ for fname in files:
7
+ file = os.path.join(root,fname)
8
+ if (os.path.isfile(file)):
9
+ decrypt(open(file,'rb'), open(os.path.join(args.outdir, fname),'wb'))
@@ -0,0 +1,9 @@
1
+ import os
2
+ def main_apidecrypt(args):
3
+ from sssekai.crypto.APIManager import decrypt
4
+ import json, msgpack
5
+ data = open(args.infile,'rb').read()
6
+ with open(args.outfile,'w',encoding='utf-8') as fout:
7
+ plain = decrypt(data)
8
+ msg = msgpack.unpackb(plain)
9
+ json.dump(msg, fout, indent=4, ensure_ascii=False)
@@ -0,0 +1,165 @@
1
+ from sssekai.unity.AssetBundle import load_assetbundle
2
+ from sssekai.unity.AnimationClip import read_animation
3
+ from UnityPy.classes import AnimationClip
4
+ from os import path,remove,makedirs
5
+ from logging import getLogger
6
+ import json
7
+ logger = getLogger(__name__)
8
+
9
+ # Thanks! https://github.com/Perfare/UnityLive2DExtractor/blob/master/UnityLive2DExtractor/CubismMotion3Converter.cs
10
+ def animation_clip_to_live2d_motion3(animationClip: AnimationClip, pathTable : dict) -> dict:
11
+ '''Convert Unity AnimationClip to Live2D Motion3 format
12
+
13
+ Args:
14
+ animationClip (AnimationClip): animationClip
15
+ pathTable (dict): CRC32 to Live2D path table
16
+
17
+ Returns:
18
+ dict: Live2D Motion3 data
19
+ '''
20
+ motion = {
21
+ 'Version': 3,
22
+ 'Meta': {
23
+ "Name":animationClip.m_Name,
24
+ "Duration":animationClip.m_MuscleClip.m_StopTime,
25
+ "Fps":animationClip.m_SampleRate,
26
+ "Loop":True,
27
+ "AreBeziersRestricted":True,
28
+ "CurveCount":0,
29
+ "UserDataCount":0,
30
+ "TotalPointCount":0,
31
+ "TotalSegmentCount":0,
32
+ "TotalUserDataSize":0
33
+ },
34
+ 'Curves': [],
35
+ 'UserData': []
36
+ }
37
+ animation = read_animation(animationClip)
38
+ floatCurves = animation.FloatTracks
39
+ motion['Meta']['CurveCount'] = len(floatCurves)
40
+ for track in [track for path in floatCurves.values() for track in path.values()]:
41
+ segments = list()
42
+ segments.append(0)
43
+ segments.append(track.Curve[0].value)
44
+ curveIndex = 1
45
+ while curveIndex < len(track.Curve):
46
+ curve = track.Curve[curveIndex]
47
+ preCurve = track.Curve[curveIndex - 1]
48
+ if (abs(curve.time - preCurve.time - 0.01) < 0.0001):
49
+ nextCurve = track.Curve[curveIndex + 1]
50
+ if (nextCurve.value == curve.value):
51
+ segments.append(3) # InverseSteppedSegment
52
+ segments.append(nextCurve.time)
53
+ segments.append(nextCurve.value)
54
+ motion['Meta']['TotalPointCount'] += 1
55
+ motion['Meta']['TotalSegmentCount'] += 1
56
+ curveIndex += 1
57
+ continue
58
+ if (curve.inSlope == float('+inf')):
59
+ segments.append(2) # SteppedSegment
60
+ segments.append(curve.time)
61
+ segments.append(curve.value)
62
+ motion['Meta']['TotalPointCount'] += 1
63
+ elif (preCurve.outSlope == 0 and abs(curve.inSlope) < 0.0001):
64
+ segments.append(0) # LinearSegment
65
+ segments.append(curve.time)
66
+ segments.append(curve.value)
67
+ motion['Meta']['TotalPointCount'] += 1
68
+ else:
69
+ tangentLength = (curve.time - preCurve.time) / 3
70
+ segments.append(1) # BezierSegment
71
+ segments.append(preCurve.time + tangentLength)
72
+ segments.append(preCurve.outSlope * tangentLength + preCurve.value)
73
+ segments.append(curve.time - tangentLength)
74
+ segments.append(curve.value - curve.inSlope * tangentLength)
75
+ segments.append(curve.time)
76
+ segments.append(curve.value)
77
+ motion['Meta']['TotalPointCount'] += 3
78
+ motion['Meta']['TotalSegmentCount'] += 1
79
+ curveIndex+=1
80
+ path = track.Path
81
+ if path in pathTable:
82
+ target, id = pathTable[path].split('/')
83
+ if target == 'Parameters': target = 'Parameter'
84
+ if target == 'Parts': target = 'PartOpacity'
85
+ else:
86
+ logger.warning('Failed to bind path CRC %s to any Live2D path' % path)
87
+ target,id = 'PartOpacity', str(path)
88
+ motion['Curves'].append(
89
+ {
90
+ 'Target' : target,
91
+ 'Id': id,
92
+ 'Segments': segments
93
+ }
94
+ )
95
+ for event in animationClip.m_Events:
96
+ motion['UserData'].append(
97
+ {
98
+ 'time' : event.time,
99
+ 'value': event.data
100
+ }
101
+ )
102
+ motion['Meta']['UserDataCount'] += 1
103
+ motion['Meta']['TotalUserDataSize'] += len(event.data)
104
+ return motion
105
+
106
+ def main_live2dextract(args):
107
+ with open(args.infile,'rb') as f:
108
+ from UnityPy.enums import ClassIDType
109
+ makedirs(args.outdir,exist_ok=True)
110
+ env = load_assetbundle(f)
111
+ monobehaviors = dict()
112
+ textures = dict()
113
+ animations = dict()
114
+ for obj in env.objects:
115
+ data = obj.read()
116
+ if obj.type in {ClassIDType.MonoBehaviour}:
117
+ monobehaviors[data.name] = data
118
+ if obj.type in {ClassIDType.Texture2D}:
119
+ textures[data.name] = data
120
+ if obj.type in {ClassIDType.AnimationClip}:
121
+ animations[data.name] = data
122
+ modelData = monobehaviors.get('BuildModelData',None)
123
+ if not modelData:
124
+ logger.warning('BuildModelData absent. Not extracting Live2D models!')
125
+ else:
126
+ modelData = modelData.read_typetree()
127
+ # TextAssets are directly extracted
128
+ # Usually there are *.moc3, *.model3, *.physics3; the last two should be renamed to *.*.json
129
+ for obj in env.objects:
130
+ if obj.type == ClassIDType.TextAsset:
131
+ data = obj.read()
132
+ out_name : str = data.name
133
+ if out_name.endswith('.moc3') or out_name.endswith('.model3') or out_name.endswith('.physics3'):
134
+ if out_name.endswith('.model3') or out_name.endswith('.physics3'):
135
+ out_name += '.json'
136
+ with open(path.join(args.outdir, out_name),'wb') as fout:
137
+ logger.info('Extracting Live2D Asset %s' % out_name)
138
+ fout.write(data.script)
139
+ # Textures always needs conversion and is placed under specific folders
140
+ for texture in modelData['TextureNames']:
141
+ name = path.basename(texture)
142
+ folder = path.dirname(texture)
143
+ out_folder = path.join(args.outdir, folder)
144
+ makedirs(out_folder,exist_ok=True)
145
+ out_name = path.join(out_folder, name)
146
+ logger.info('Extracting Texture %s' % out_name)
147
+ name_wo_ext = '.'.join(name.split('.')[:-1])
148
+ textures[name_wo_ext].image.save(out_name)
149
+ # Animations are serialized into AnimationClip
150
+ if not args.no_anim:
151
+ from sssekai.unity.constant.SekaiLive2DPathNames import NAMES_CRC_TBL
152
+ for clipName, clip in animations.items():
153
+ logger.info('Extracting Animation %s' % clipName)
154
+ data = animation_clip_to_live2d_motion3(clip, NAMES_CRC_TBL)
155
+ # https://til.simonwillison.net/python/json-floating-point
156
+ def round_floats(o):
157
+ if isinstance(o, float):
158
+ return round(o, 3)
159
+ if isinstance(o, dict):
160
+ return {k: round_floats(v) for k, v in o.items()}
161
+ if isinstance(o, (list, tuple)):
162
+ return [round_floats(x) for x in o]
163
+ return o
164
+ json.dump(round_floats(data), open(path.join(args.outdir, clipName+'.motion3.json'),'w'), indent=4, ensure_ascii=False)
165
+ pass
@@ -0,0 +1,7 @@
1
+ import os,sys
2
+ def main_mitm(args):
3
+ from mitmproxy.tools.main import mitmdump
4
+ current_dir = os.path.dirname(os.path.realpath(__file__))
5
+ args = [*sys.argv[1:], '-s', '"%s"' % os.path.join(os.path.dirname(os.path.dirname(current_dir)),'mitmproxy_sekai_api.py')]
6
+ print('running mitmdump with args:', *args)
7
+ mitmdump(args=args)
@@ -0,0 +1,32 @@
1
+ from sssekai.crypto.AssetBundle import SEKAI_AB_MAGIC
2
+ from sssekai.unity.AssetBundle import load_assetbundle
3
+
4
+ from os import path,remove,makedirs
5
+ def main_usmdemux(args):
6
+ from UnityPy.enums import ClassIDType
7
+ from wannacri.usm import Usm
8
+
9
+ with open(args.infile,'rb') as f:
10
+ env = load_assetbundle(f)
11
+ datas = dict()
12
+ for obj in env.objects:
13
+ if obj.type in {ClassIDType.MonoBehaviour, ClassIDType.TextAsset}:
14
+ data = obj.read()
15
+ datas[data.name] = data
16
+ movieInfo = datas.get('MovieBundleBuildData',None)
17
+ assert movieInfo, "Invalid AssetBundle. No MovieBundleBuildData found!"
18
+ movieInfo = movieInfo.read_typetree()
19
+ usm_name = movieInfo['movieBundleDatas'][0]['usmFileName'][:-len('.bytes')]
20
+ print('USM: %s' % usm_name)
21
+ usm_folder = path.join(args.outdir,usm_name)
22
+ makedirs(usm_folder,exist_ok=True)
23
+ usm_temp = path.join(usm_folder,usm_name + '.tmp')
24
+ with open(usm_temp,'wb') as usmstream:
25
+ for data in movieInfo['movieBundleDatas']:
26
+ usm = data['usmFileName'][:-len('.bytes')]
27
+ usm = datas[usm]
28
+ usmstream.write(usm.script)
29
+ usm = Usm.open(usm_temp,encoding='shift-jis')
30
+ usm.demux(path.join(args.outdir,usm_name),usm_name)
31
+ remove(usm_temp)
32
+ print('Saved to %s/' % usm_folder)
File without changes
sssekai/fmt/moc3.py ADDED
@@ -0,0 +1,40 @@
1
+ from typing import BinaryIO
2
+ from struct import unpack
3
+
4
+ # https://github.com/OpenL2D/moc3ingbird/blob/master/src/moc3.hexpat
5
+ class moc3:
6
+ Parameters : list
7
+ Parts: list
8
+ def __init__(self, file : BinaryIO) -> None:
9
+ # Header: 64 bytes
10
+ file.seek(0)
11
+ assert file.read(4) == b'MOC3'
12
+ version = unpack('<c',file.read(1))[0]
13
+ isBigEndian = unpack('<b',file.read(1))[0]
14
+ assert not isBigEndian
15
+
16
+ # TODO: Other fields
17
+ file.seek(0x40)
18
+ pCountInfo = unpack('<I',file.read(4))[0]
19
+
20
+ file.seek(pCountInfo)
21
+ numParts = unpack('<I',file.read(4))[0]
22
+ file.seek(0x10, 1)
23
+ numParameters = unpack('<I',file.read(4))[0]
24
+
25
+ file.seek(0x4C)
26
+ pParts = unpack('<I',file.read(4))[0]
27
+
28
+ file.seek(0x108)
29
+ pParameters = unpack('<I',file.read(4))[0]
30
+
31
+ def read_strings(offset, count):
32
+ for i in range(0,count):
33
+ file.seek(offset + i * 0x40)
34
+ buffer = bytearray()
35
+ while b := file.read(1)[0]:
36
+ buffer.append(b)
37
+ yield buffer.decode(encoding='utf-8')
38
+
39
+ self.Parts = list(read_strings(pParts,numParts))
40
+ self.Parameters = list(read_strings(pParameters,numParameters))