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 +6 -0
- sssekai/__main__.py +77 -0
- sssekai/abcache/__init__.py +190 -0
- sssekai/crypto/APIManager.py +24 -0
- sssekai/crypto/AssetBundle.py +32 -0
- sssekai/crypto/__init__.py +0 -0
- sssekai/entrypoint/__init__.py +0 -0
- sssekai/entrypoint/abcache.py +16 -0
- sssekai/entrypoint/abdecrypt.py +9 -0
- sssekai/entrypoint/apidecrypt.py +9 -0
- sssekai/entrypoint/live2dextract.py +165 -0
- sssekai/entrypoint/mitm.py +7 -0
- sssekai/entrypoint/usmdemux.py +32 -0
- sssekai/fmt/__init__.py +0 -0
- sssekai/fmt/moc3.py +40 -0
- sssekai/unity/AnimationClip.py +170 -0
- sssekai/unity/AssetBundle.py +13 -0
- sssekai/unity/__init__.py +2 -0
- sssekai/unity/constant/SekaiLive2DPathNames.py +1076 -0
- sssekai/unity/constant/__init__.py +0 -0
- sssekai-0.0.3.dist-info/METADATA +55 -0
- sssekai-0.0.3.dist-info/RECORD +25 -0
- sssekai-0.0.3.dist-info/WHEEL +5 -0
- sssekai-0.0.3.dist-info/entry_points.txt +2 -0
- sssekai-0.0.3.dist-info/top_level.txt +1 -0
sssekai/__init__.py
ADDED
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)
|
sssekai/fmt/__init__.py
ADDED
|
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))
|