nnnotes 0.1.0__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.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/cache.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Memo caches for work that repeats within a run: decoded textures, PNG encodings, shader dumps, decoded cue sheets.
|
|
2
|
+
|
|
3
|
+
A bucket maps a content key to a value computed from exactly the inputs the key covers, so a hit returns what the
|
|
4
|
+
computation would return: the extractors' outputs stay byte-identical with or without the cache. Buckets live in the
|
|
5
|
+
process (LRU, bounded by a byte budget, thread-safe). Buckets that hold bytes can also keep them on disk
|
|
6
|
+
(`configure(directory=...)`): processes of one build then share the results, and a later run with the same inputs
|
|
7
|
+
reuses them. Keys of the disk layer are salted with the source of the module that produces the value and the
|
|
8
|
+
versions of the libraries it calls, so a code or library change never reads an older result.
|
|
9
|
+
|
|
10
|
+
from nnnotes import cache
|
|
11
|
+
cache.configure(enabled=False) # every get misses, nothing is stored
|
|
12
|
+
cache.configure(memory_mb=512, directory="work/cache")
|
|
13
|
+
PNG = cache.bucket("png", salt=cache.source_salt(__file__))
|
|
14
|
+
k = PNG.key(data, width, height)
|
|
15
|
+
png = PNG.get(k)
|
|
16
|
+
if png is None:
|
|
17
|
+
png = encode(...)
|
|
18
|
+
PNG.put(k, png)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import os
|
|
24
|
+
import secrets
|
|
25
|
+
import struct
|
|
26
|
+
import threading
|
|
27
|
+
from collections import OrderedDict
|
|
28
|
+
from importlib import metadata
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
DISK_MAGIC = b"NNC1"
|
|
32
|
+
DEFAULT_MEMORY_MB = 128
|
|
33
|
+
|
|
34
|
+
_settings = {"enabled": True, "memory": DEFAULT_MEMORY_MB << 20, "directory": None, "closures": 4,
|
|
35
|
+
"closure_bytes": 512 << 20}
|
|
36
|
+
_buckets: dict[str, "Bucket"] = {}
|
|
37
|
+
_lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def configure(*, enabled: bool | None = None, memory_mb: int | None = None, directory=None,
|
|
41
|
+
closures: int | None = None, closure_mb: int | None = None) -> dict:
|
|
42
|
+
"""Set the caches of this process: `enabled`; `memory_mb` (byte budget of each bucket); `directory` (disk layer
|
|
43
|
+
of the byte buckets; "" or None for none); `closures` / `closure_mb` (loaded bundle closures kept by
|
|
44
|
+
unity.load_closure, per thread: count and bundle bytes). Clears the buckets. Returns the settings."""
|
|
45
|
+
with _lock:
|
|
46
|
+
if enabled is not None:
|
|
47
|
+
_settings["enabled"] = bool(enabled)
|
|
48
|
+
if memory_mb is not None:
|
|
49
|
+
_settings["memory"] = max(0, int(memory_mb)) << 20
|
|
50
|
+
if directory is not None:
|
|
51
|
+
_settings["directory"] = Path(directory) if directory else None
|
|
52
|
+
if closures is not None:
|
|
53
|
+
_settings["closures"] = max(0, int(closures))
|
|
54
|
+
if closure_mb is not None:
|
|
55
|
+
_settings["closure_bytes"] = max(0, int(closure_mb)) << 20
|
|
56
|
+
for b in _buckets.values():
|
|
57
|
+
b.clear()
|
|
58
|
+
return settings()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def settings() -> dict:
|
|
62
|
+
"""The current settings (a copy)."""
|
|
63
|
+
return dict(_settings)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def enabled() -> bool:
|
|
67
|
+
return _settings["enabled"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bucket(name: str, *, salt: str = "", disk: bool = False, max_item: int | None = None) -> "Bucket":
|
|
71
|
+
"""The process's bucket `name` (created on first use). `disk`: its values are bytes and may be kept on disk;
|
|
72
|
+
`max_item`: values larger than this many bytes are not kept (default: a quarter of the budget)."""
|
|
73
|
+
with _lock:
|
|
74
|
+
b = _buckets.get(name)
|
|
75
|
+
if b is None:
|
|
76
|
+
b = _buckets[name] = Bucket(name, salt, disk, max_item)
|
|
77
|
+
return b
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def stats() -> dict:
|
|
81
|
+
"""Per bucket: items, bytes, hits, misses, disk hits, disk writes, disk write errors."""
|
|
82
|
+
return {n: b.stats() for n, b in sorted(_buckets.items())}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def clear() -> None:
|
|
86
|
+
for b in _buckets.values():
|
|
87
|
+
b.clear()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- keys
|
|
91
|
+
def _feed(h, part) -> None:
|
|
92
|
+
if isinstance(part, (bytes, bytearray, memoryview)):
|
|
93
|
+
mv = memoryview(part).cast("B")
|
|
94
|
+
h.update(b"b" + struct.pack("<Q", mv.nbytes))
|
|
95
|
+
h.update(mv)
|
|
96
|
+
elif isinstance(part, str):
|
|
97
|
+
b = part.encode("utf-8", "surrogatepass")
|
|
98
|
+
h.update(b"s" + struct.pack("<Q", len(b)) + b)
|
|
99
|
+
elif isinstance(part, (tuple, list)):
|
|
100
|
+
h.update(b"t" + struct.pack("<Q", len(part)))
|
|
101
|
+
for p in part:
|
|
102
|
+
_feed(h, p)
|
|
103
|
+
elif part is None or isinstance(part, (bool, int, float)):
|
|
104
|
+
r = repr(part).encode("ascii")
|
|
105
|
+
h.update(b"r" + struct.pack("<Q", len(r)) + r)
|
|
106
|
+
else:
|
|
107
|
+
raise TypeError(f"cache key part of type {type(part).__name__}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def key(*parts) -> str:
|
|
111
|
+
"""sha256 hex of `parts` (bytes, str, int, float, bool, None and tuples / lists of them), unambiguous."""
|
|
112
|
+
h = hashlib.sha256()
|
|
113
|
+
_feed(h, parts)
|
|
114
|
+
return h.hexdigest()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_salts: dict[str, str] = {}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def source_salt(path, *dists: str) -> str:
|
|
121
|
+
"""A salt for keys of values a module produces: sha256 of the module's source file and the installed versions
|
|
122
|
+
of the distributions `dists` (a missing one counts as "-")."""
|
|
123
|
+
p = Path(path)
|
|
124
|
+
k = f"{p}|{'|'.join(dists)}"
|
|
125
|
+
s = _salts.get(k)
|
|
126
|
+
if s is None:
|
|
127
|
+
versions = []
|
|
128
|
+
for d in dists:
|
|
129
|
+
try:
|
|
130
|
+
versions.append(f"{d}={metadata.version(d)}")
|
|
131
|
+
except metadata.PackageNotFoundError:
|
|
132
|
+
versions.append(f"{d}=-")
|
|
133
|
+
s = _salts[k] = key(p.read_bytes(), versions)
|
|
134
|
+
return s
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def file_id(path) -> tuple:
|
|
138
|
+
"""(path, size, mtime) of a file: identifies an executable or input file in a key."""
|
|
139
|
+
p = Path(path)
|
|
140
|
+
st = p.stat()
|
|
141
|
+
return (str(p), st.st_size, st.st_mtime_ns)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------- buckets
|
|
145
|
+
class Bucket:
|
|
146
|
+
def __init__(self, name: str, salt: str, disk: bool, max_item: int | None):
|
|
147
|
+
self.name, self.salt, self.disk, self._max_item = name, salt, disk, max_item
|
|
148
|
+
self._items: OrderedDict[str, tuple] = OrderedDict()
|
|
149
|
+
self._bytes = 0
|
|
150
|
+
self._lock = threading.Lock()
|
|
151
|
+
self.hits = self.misses = self.disk_hits = self.disk_writes = self.disk_errors = 0
|
|
152
|
+
|
|
153
|
+
def key(self, *parts) -> str:
|
|
154
|
+
return key(self.name, self.salt, parts)
|
|
155
|
+
|
|
156
|
+
def _limit(self) -> int:
|
|
157
|
+
budget = _settings["memory"]
|
|
158
|
+
return budget // 4 if self._max_item is None else min(self._max_item, budget)
|
|
159
|
+
|
|
160
|
+
def get(self, k: str):
|
|
161
|
+
"""The value stored under `k`, or None."""
|
|
162
|
+
if not _settings["enabled"]:
|
|
163
|
+
return None
|
|
164
|
+
with self._lock:
|
|
165
|
+
hit = self._items.get(k)
|
|
166
|
+
if hit is not None:
|
|
167
|
+
self._items.move_to_end(k)
|
|
168
|
+
self.hits += 1
|
|
169
|
+
return hit[0]
|
|
170
|
+
if self.disk and _settings["directory"] is not None:
|
|
171
|
+
data = _disk_read(self._path(k))
|
|
172
|
+
if data is not None:
|
|
173
|
+
with self._lock:
|
|
174
|
+
self.disk_hits += 1
|
|
175
|
+
self._remember(k, data, len(data))
|
|
176
|
+
return data
|
|
177
|
+
with self._lock:
|
|
178
|
+
self.misses += 1
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def put(self, k: str, value, size: int | None = None) -> None:
|
|
182
|
+
"""Store `value` (bytes for a disk bucket) under `k`; `size`: its byte size (default len(value))."""
|
|
183
|
+
if not _settings["enabled"]:
|
|
184
|
+
return
|
|
185
|
+
n = len(value) if size is None else int(size)
|
|
186
|
+
self._remember(k, value, n)
|
|
187
|
+
if self.disk and _settings["directory"] is not None and n <= max(self._limit(), 64 << 20):
|
|
188
|
+
ok = _disk_write(self._path(k), value)
|
|
189
|
+
with self._lock:
|
|
190
|
+
if ok:
|
|
191
|
+
self.disk_writes += 1
|
|
192
|
+
else:
|
|
193
|
+
self.disk_errors += 1
|
|
194
|
+
|
|
195
|
+
def _remember(self, k: str, value, n: int) -> None:
|
|
196
|
+
if n > self._limit():
|
|
197
|
+
return
|
|
198
|
+
with self._lock:
|
|
199
|
+
old = self._items.pop(k, None)
|
|
200
|
+
if old is not None:
|
|
201
|
+
self._bytes -= old[1]
|
|
202
|
+
self._items[k] = (value, n)
|
|
203
|
+
self._bytes += n
|
|
204
|
+
budget = _settings["memory"]
|
|
205
|
+
while self._bytes > budget and self._items:
|
|
206
|
+
_, (_, m) = self._items.popitem(last=False)
|
|
207
|
+
self._bytes -= m
|
|
208
|
+
|
|
209
|
+
def _path(self, k: str) -> Path:
|
|
210
|
+
return _settings["directory"] / self.name / k[:2] / k
|
|
211
|
+
|
|
212
|
+
def clear(self) -> None:
|
|
213
|
+
with self._lock:
|
|
214
|
+
self._items.clear()
|
|
215
|
+
self._bytes = 0
|
|
216
|
+
|
|
217
|
+
def stats(self) -> dict:
|
|
218
|
+
with self._lock:
|
|
219
|
+
return {"items": len(self._items), "bytes": self._bytes, "hits": self.hits, "misses": self.misses,
|
|
220
|
+
"diskHits": self.disk_hits, "diskWrites": self.disk_writes, "diskErrors": self.disk_errors}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _disk_read(p: Path) -> bytes | None:
|
|
224
|
+
try:
|
|
225
|
+
raw = p.read_bytes()
|
|
226
|
+
except OSError:
|
|
227
|
+
return None
|
|
228
|
+
if len(raw) < 12 or raw[:4] != DISK_MAGIC:
|
|
229
|
+
return None
|
|
230
|
+
n, = struct.unpack_from("<Q", raw, 4)
|
|
231
|
+
return raw[12:] if len(raw) == 12 + n else None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def temp_path(dst: Path) -> Path:
|
|
235
|
+
"""A new temporary file name next to `dst` for writing it atomically: short whatever `dst`'s name (a long name
|
|
236
|
+
plus a suffix would pass the file system's name limit), random, so unique across processes and threads."""
|
|
237
|
+
return Path(dst).with_name(f".{secrets.token_hex(8)}.part")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def write_atomic(dst: Path, data: bytes) -> None:
|
|
241
|
+
"""Write `data` to `dst` through temp_path(dst) (created exclusively, default permissions) and a rename; the
|
|
242
|
+
temporary file is removed when anything fails."""
|
|
243
|
+
tmp = temp_path(dst)
|
|
244
|
+
try:
|
|
245
|
+
with open(tmp, "xb") as f:
|
|
246
|
+
f.write(data)
|
|
247
|
+
os.replace(tmp, dst)
|
|
248
|
+
except BaseException:
|
|
249
|
+
try:
|
|
250
|
+
os.unlink(tmp)
|
|
251
|
+
except OSError:
|
|
252
|
+
pass
|
|
253
|
+
raise
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _disk_write(p: Path, data: bytes) -> bool:
|
|
257
|
+
"""Write a disk entry atomically; False when the file system refused (counted as diskErrors: a cache that
|
|
258
|
+
cannot write only misses later, the result is the same)."""
|
|
259
|
+
try:
|
|
260
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
write_atomic(p, DISK_MAGIC + struct.pack("<Q", len(data)) + bytes(data))
|
|
262
|
+
return True
|
|
263
|
+
except OSError:
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------- packing several blobs into one value
|
|
268
|
+
def pack(meta: bytes, blobs: list[bytes]) -> bytes:
|
|
269
|
+
"""One bytes value from a header and blobs (for disk buckets whose value is several files)."""
|
|
270
|
+
out = [struct.pack("<II", len(meta), len(blobs)), meta]
|
|
271
|
+
for b in blobs:
|
|
272
|
+
out += [struct.pack("<Q", len(b)), b]
|
|
273
|
+
return b"".join(out)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def unpack(data: bytes) -> tuple[bytes, list[bytes]]:
|
|
277
|
+
n, count = struct.unpack_from("<II", data, 0)
|
|
278
|
+
pos = 8
|
|
279
|
+
meta = data[pos:pos + n]
|
|
280
|
+
pos += n
|
|
281
|
+
blobs = []
|
|
282
|
+
for _ in range(count):
|
|
283
|
+
m, = struct.unpack_from("<Q", data, pos)
|
|
284
|
+
pos += 8
|
|
285
|
+
blobs.append(data[pos:pos + m])
|
|
286
|
+
pos += m
|
|
287
|
+
if pos != len(data):
|
|
288
|
+
raise ValueError("packed cache value has trailing bytes")
|
|
289
|
+
return meta, blobs
|
nnnotes/catalog.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Addressables catalog access: dependency closures, a bundle cache, remote and APK-local bundles.
|
|
2
|
+
|
|
3
|
+
Remote bundles are fetched from the region's CDN and decrypted (addressables.decrypt); local bundles (shipped inside
|
|
4
|
+
the APK) are read from a user-supplied base.apk. Nothing here bundles or redistributes game data: it only reads
|
|
5
|
+
what the user points it at into a local cache the user controls.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import http.client
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
import zipfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .addressables import BundleKey, decrypt, parse, parse_locations, remote_path
|
|
19
|
+
from .cache import write_atomic as _write_atomic
|
|
20
|
+
from .config import ConfigError, apk_missing
|
|
21
|
+
|
|
22
|
+
LOCAL_PREFIX = "{UnityEngine.AddressableAssets.Addressables.RuntimePath}"
|
|
23
|
+
APK_AA_DIR = "assets/aa/" # + "Android/<bundle>"
|
|
24
|
+
APK_CATALOG = "assets/aa/catalog.bin"
|
|
25
|
+
APK_OFFSET_BASE = 1 << 40 # namespace for APK catalog entry offsets
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Bundle:
|
|
30
|
+
offset: int
|
|
31
|
+
internal_id: str
|
|
32
|
+
name: str # bare file name (embeds content hash)
|
|
33
|
+
remote: bool # True: on CDN; False: shipped inside the APK
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def location_kind(internal_id: str) -> str:
|
|
37
|
+
"""What a location names: "bundle" or "raw" (a file on the CDN or in the APK, `.bundle` or not), "asset" (a path
|
|
38
|
+
inside a bundle), else "other"."""
|
|
39
|
+
if remote_path(internal_id) is not None or internal_id.startswith(LOCAL_PREFIX):
|
|
40
|
+
return "bundle" if internal_id.endswith(".bundle") else "raw"
|
|
41
|
+
if internal_id.startswith(("Assets/", "Packages/")):
|
|
42
|
+
return "asset"
|
|
43
|
+
return "other"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def file_name(internal_id: str) -> str:
|
|
47
|
+
"""The name of a bundle or raw file location: its path below the platform directory (`Android/`), which for a
|
|
48
|
+
bundle is its bare file name."""
|
|
49
|
+
rel = remote_path(internal_id)
|
|
50
|
+
if rel is None:
|
|
51
|
+
rel = internal_id[len(LOCAL_PREFIX):] if internal_id.startswith(LOCAL_PREFIX) else internal_id
|
|
52
|
+
head, sep, tail = rel.partition("/Android/")
|
|
53
|
+
return tail if sep else rel.rsplit("/", 1)[-1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unityfs(data: bytes, name: str, key: BundleKey | None) -> bytes:
|
|
57
|
+
if data[:7] == b"UnityFS":
|
|
58
|
+
return data
|
|
59
|
+
if key is None:
|
|
60
|
+
raise RuntimeError(f"bundle {name} is encrypted and no bundle key was given")
|
|
61
|
+
return decrypt(data, name, key)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Catalog:
|
|
65
|
+
"""Remote content catalog, merged with the APK's local catalog when an APK is given.
|
|
66
|
+
|
|
67
|
+
The game loads both: the APK catalog (embedded assets, e.g. ADV settings and
|
|
68
|
+
shared prefabs) and the remote catalog (downloadable content). Entry offsets
|
|
69
|
+
are per-catalog, so APK entries are moved into their own offset namespace.
|
|
70
|
+
|
|
71
|
+
`cdn`: the region's CDN base (needed only to download what the cache lacks); `bundle_key`: the bundle
|
|
72
|
+
decryption key (needed only for bundles not yet in the cache). Either may be a function that returns it, called
|
|
73
|
+
the first time it is needed (a ConfigError it raises is raised naming the file that needed the setting).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, catalog_bytes: bytes, cache_dir: Path, *, cdn=None, bundle_key=None, apk: Path | None = None):
|
|
77
|
+
self._settings = {"cdn": cdn, "bundle_key": bundle_key}
|
|
78
|
+
self.cache_dir = Path(cache_dir)
|
|
79
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
self.apk = Path(apk) if apk else None
|
|
81
|
+
self._sources = {"remote": catalog_bytes}
|
|
82
|
+
self._locations: list[dict] | None = None
|
|
83
|
+
self._parsed: tuple | None = None
|
|
84
|
+
if self.apk is not None:
|
|
85
|
+
with zipfile.ZipFile(self.apk) as z:
|
|
86
|
+
self._sources["apk"] = z.read(APK_CATALOG)
|
|
87
|
+
|
|
88
|
+
def _parse(self) -> tuple:
|
|
89
|
+
"""(entries, by offset, by primary key), parsed the first time a lookup needs them (a command that only
|
|
90
|
+
wants the catalog files, sources(), does not pay for it)."""
|
|
91
|
+
if self._parsed is None:
|
|
92
|
+
entries = parse(self._sources["remote"])
|
|
93
|
+
if "apk" in self._sources:
|
|
94
|
+
for e in parse(self._sources["apk"]):
|
|
95
|
+
e["offset"] += APK_OFFSET_BASE
|
|
96
|
+
e["dependencies"] = [d + APK_OFFSET_BASE for d in e["dependencies"]]
|
|
97
|
+
entries.append(e)
|
|
98
|
+
by_key: dict[str, list[dict]] = {}
|
|
99
|
+
for e in entries:
|
|
100
|
+
by_key.setdefault(e["primary_key"], []).append(e)
|
|
101
|
+
self._parsed = (entries, {e["offset"]: e for e in entries}, by_key)
|
|
102
|
+
return self._parsed
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def entries(self) -> list[dict]:
|
|
106
|
+
return self._parse()[0]
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def _by_off(self) -> dict[int, dict]:
|
|
110
|
+
return self._parse()[1]
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def _by_key(self) -> dict[str, list[dict]]:
|
|
114
|
+
return self._parse()[2]
|
|
115
|
+
|
|
116
|
+
# --- construction ------------------------------------------------------
|
|
117
|
+
@classmethod
|
|
118
|
+
def load(cls, language: str, cache_dir: Path, *, cdn=None, bundle_key=None,
|
|
119
|
+
apk: Path | None = None) -> "Catalog":
|
|
120
|
+
"""The remote catalog of `language` from the cache, downloaded from the CDN on first use (`cdn` /
|
|
121
|
+
`bundle_key`: as for Catalog)."""
|
|
122
|
+
cache_dir = Path(cache_dir)
|
|
123
|
+
cat = cls.cache_file(language, cache_dir)
|
|
124
|
+
if not cat.exists():
|
|
125
|
+
cdn = _setting(cdn, cat.name)
|
|
126
|
+
if not cdn:
|
|
127
|
+
raise FileNotFoundError(f"{cat} not cached and no CDN base given")
|
|
128
|
+
cat.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
_write_atomic(cat, download(cdn.rstrip("/") + f"/asset/Android/{cat.name}"))
|
|
130
|
+
return cls(cat.read_bytes(), cache_dir, cdn=cdn, bundle_key=bundle_key, apk=apk)
|
|
131
|
+
|
|
132
|
+
def _setting(self, name: str, needed_by: str):
|
|
133
|
+
"""The `cdn` / `bundle_key` given to the catalog, its function called (once) now that `needed_by` needs it."""
|
|
134
|
+
v = self._settings[name] = _setting(self._settings[name], needed_by)
|
|
135
|
+
return v
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def cdn(self) -> str | None:
|
|
139
|
+
"""The CDN base as given (None while it is given as a function not called yet)."""
|
|
140
|
+
v = self._settings["cdn"]
|
|
141
|
+
return None if callable(v) or not v else v.rstrip("/")
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def cache_file(language: str, cache_dir: Path) -> Path:
|
|
145
|
+
"""Where the remote catalog of `language` is cached."""
|
|
146
|
+
return Path(cache_dir) / f"catalog_main_{language}.bin"
|
|
147
|
+
|
|
148
|
+
# --- lookup ------------------------------------------------------------
|
|
149
|
+
def keys(self, prefix: str = "") -> list[str]:
|
|
150
|
+
return sorted(k for k in self._by_key if k.startswith(prefix))
|
|
151
|
+
|
|
152
|
+
def has(self, key: str) -> bool:
|
|
153
|
+
return key in self._by_key
|
|
154
|
+
|
|
155
|
+
def entries_for(self, key: str) -> list[dict]:
|
|
156
|
+
return list(self._by_key.get(key, []))
|
|
157
|
+
|
|
158
|
+
def _entry(self, key: str) -> dict:
|
|
159
|
+
ents = self._by_key.get(key)
|
|
160
|
+
if not ents:
|
|
161
|
+
raise KeyError(key)
|
|
162
|
+
# Prefer the asset location (Assets/...) over a bundle alias.
|
|
163
|
+
for e in ents:
|
|
164
|
+
if e["internal_id"].startswith("Assets/") or e["internal_id"].startswith("Packages/"):
|
|
165
|
+
return e
|
|
166
|
+
return ents[0]
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _as_bundle(e: dict) -> Bundle | None:
|
|
170
|
+
iid = e["internal_id"]
|
|
171
|
+
if iid.endswith(".bundle") and remote_path(iid) is not None:
|
|
172
|
+
return Bundle(e["offset"], iid, iid.rsplit("/", 1)[1], remote=True)
|
|
173
|
+
if iid.startswith(LOCAL_PREFIX) and iid.endswith(".bundle"):
|
|
174
|
+
return Bundle(e["offset"], iid, iid.rsplit("/", 1)[1], remote=False)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def sources(self) -> dict[str, bytes]:
|
|
178
|
+
"""The catalog files this catalog was made of: {"remote": bytes, "apk": bytes (with an APK)}."""
|
|
179
|
+
return dict(self._sources)
|
|
180
|
+
|
|
181
|
+
def locations(self) -> list[dict]:
|
|
182
|
+
"""Every location of the remote catalog and (with an APK) of the APK catalog, fully decoded
|
|
183
|
+
(addressables.parse_locations), with `catalog` ("remote" / "apk") and `kind` (location_kind). Offsets and
|
|
184
|
+
dependencies are those of `entries` (APK ones in their own namespace)."""
|
|
185
|
+
if self._locations is None:
|
|
186
|
+
out = []
|
|
187
|
+
for name, base in (("remote", 0), ("apk", APK_OFFSET_BASE)):
|
|
188
|
+
if name not in self._sources:
|
|
189
|
+
continue
|
|
190
|
+
for e in parse_locations(self._sources[name]):
|
|
191
|
+
e["offset"] += base
|
|
192
|
+
e["dependencies"] = [d + base for d in e["dependencies"]]
|
|
193
|
+
e["catalog"] = name
|
|
194
|
+
e["kind"] = location_kind(e["internal_id"])
|
|
195
|
+
out.append(e)
|
|
196
|
+
self._locations = out
|
|
197
|
+
return [dict(e) for e in self._locations]
|
|
198
|
+
|
|
199
|
+
def bundles(self) -> list[Bundle]:
|
|
200
|
+
"""Every bundle file the catalogs name (remote and APK-local), one per file name, sorted by name. A file the
|
|
201
|
+
APK catalog lists gets the APK catalog's location (the remote catalog's references to APK files may describe
|
|
202
|
+
another build), else its first location."""
|
|
203
|
+
out: dict[str, Bundle] = {}
|
|
204
|
+
for e in sorted(self.entries, key=lambda e: (e["offset"] < APK_OFFSET_BASE, e["offset"])):
|
|
205
|
+
b = self._as_bundle(e)
|
|
206
|
+
if b is not None:
|
|
207
|
+
out.setdefault(b.name, b)
|
|
208
|
+
return [out[n] for n in sorted(out)]
|
|
209
|
+
|
|
210
|
+
def raw_files(self) -> list[dict]:
|
|
211
|
+
"""Every raw file the catalogs name (a CDN or APK file that is not a bundle, e.g. CRI ACB/AWB/USM data), one
|
|
212
|
+
entry per file name (file_name), sorted by name, preferring the APK catalog's as bundles() does. Remote ones
|
|
213
|
+
are what fetch_raw takes."""
|
|
214
|
+
out: dict[str, dict] = {}
|
|
215
|
+
for e in sorted(self.entries, key=lambda e: (e["offset"] < APK_OFFSET_BASE, e["offset"])):
|
|
216
|
+
if location_kind(e["internal_id"]) == "raw":
|
|
217
|
+
out.setdefault(file_name(e["internal_id"]), e)
|
|
218
|
+
return [out[n] for n in sorted(out)]
|
|
219
|
+
|
|
220
|
+
def resolve(self, key: str) -> list[Bundle]:
|
|
221
|
+
"""Full bundle closure for an addressable key (BFS over dependencies)."""
|
|
222
|
+
seen: set[int] = set()
|
|
223
|
+
out: dict[int, Bundle] = {}
|
|
224
|
+
stack = [self._entry(key)["offset"]]
|
|
225
|
+
while stack:
|
|
226
|
+
off = stack.pop()
|
|
227
|
+
if off in seen:
|
|
228
|
+
continue
|
|
229
|
+
seen.add(off)
|
|
230
|
+
e = self._by_off.get(off)
|
|
231
|
+
if e is None:
|
|
232
|
+
continue
|
|
233
|
+
b = self._as_bundle(e)
|
|
234
|
+
if b is not None:
|
|
235
|
+
out.setdefault(b.offset, b)
|
|
236
|
+
for d in e["dependencies"]:
|
|
237
|
+
if d not in seen:
|
|
238
|
+
stack.append(d)
|
|
239
|
+
return list(out.values())
|
|
240
|
+
|
|
241
|
+
# --- fetch -------------------------------------------------------------
|
|
242
|
+
def cached(self, b: Bundle) -> Path | None:
|
|
243
|
+
"""The file fetch(b) returns when the bundle is in the cache already, else None."""
|
|
244
|
+
dst = self.cache_dir / "bundles" / b.name
|
|
245
|
+
return dst if dst.is_file() and dst.stat().st_size > 0 else None
|
|
246
|
+
|
|
247
|
+
def cached_raw(self, e: dict) -> Path | None:
|
|
248
|
+
"""The file fetch_raw(e) returns when it is in the cache already, else None."""
|
|
249
|
+
rel = remote_path(e["internal_id"])
|
|
250
|
+
dst = self.cache_dir / "raw" / rel.lstrip("/") if rel is not None else None
|
|
251
|
+
return dst if dst is not None and dst.is_file() and dst.stat().st_size > 0 else None
|
|
252
|
+
|
|
253
|
+
def fetch(self, b: Bundle) -> Path:
|
|
254
|
+
"""Local path to the decrypted bundle (CDN download or APK read)."""
|
|
255
|
+
dst = self.cache_dir / "bundles" / b.name
|
|
256
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
if dst.exists() and dst.stat().st_size > 0:
|
|
258
|
+
return dst
|
|
259
|
+
if b.remote:
|
|
260
|
+
url = self._url(b.internal_id)
|
|
261
|
+
key = self._setting("bundle_key", b.name) # before the download: a missing key fails first
|
|
262
|
+
data = download(url)
|
|
263
|
+
else:
|
|
264
|
+
if self.apk is None:
|
|
265
|
+
raise apk_missing(f"bundle {b.name}")
|
|
266
|
+
rel = b.internal_id[len(LOCAL_PREFIX):].lstrip("/")
|
|
267
|
+
with zipfile.ZipFile(self.apk) as z:
|
|
268
|
+
data = z.read(APK_AA_DIR + rel)
|
|
269
|
+
key = self._setting("bundle_key", b.name) if data[:7] != b"UnityFS" else None
|
|
270
|
+
_write_atomic(dst, _unityfs(data, b.name, key))
|
|
271
|
+
return dst
|
|
272
|
+
|
|
273
|
+
def _url(self, internal_id: str) -> str:
|
|
274
|
+
name = internal_id.rsplit('/', 1)[-1]
|
|
275
|
+
cdn = self._setting("cdn", name)
|
|
276
|
+
if not cdn:
|
|
277
|
+
raise RuntimeError(f"{name} not cached and no CDN base given")
|
|
278
|
+
return cdn.rstrip("/") + remote_path(internal_id)
|
|
279
|
+
|
|
280
|
+
def fetch_key(self, key: str) -> list[Path]:
|
|
281
|
+
"""Every bundle of the key's closure; APK-local ones only when an APK is set."""
|
|
282
|
+
return [self.fetch(b) for b in self.resolve(key) if b.remote or self.apk]
|
|
283
|
+
|
|
284
|
+
def fetch_raw(self, e: dict) -> Path:
|
|
285
|
+
"""A non-bundle CDN asset (e.g. CRI ACB/AWB data) as stored -- no decryption."""
|
|
286
|
+
iid = e["internal_id"]
|
|
287
|
+
rel = remote_path(iid)
|
|
288
|
+
if rel is None:
|
|
289
|
+
raise ValueError(f"not a CDN asset: {iid}")
|
|
290
|
+
dst = self.cache_dir / "raw" / rel.lstrip("/")
|
|
291
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
if not (dst.exists() and dst.stat().st_size > 0):
|
|
293
|
+
_write_atomic(dst, download(self._url(iid)))
|
|
294
|
+
return dst
|
|
295
|
+
|
|
296
|
+
def apk_bundle(self, name_contains: str) -> Path:
|
|
297
|
+
"""An APK-local addressable bundle whose file name contains the substring."""
|
|
298
|
+
if self.apk is None:
|
|
299
|
+
raise apk_missing("an APK bundle")
|
|
300
|
+
with zipfile.ZipFile(self.apk) as z:
|
|
301
|
+
names = [n for n in z.namelist()
|
|
302
|
+
if n.startswith(APK_AA_DIR) and n.endswith(".bundle")
|
|
303
|
+
and name_contains in n.rsplit("/", 1)[1]]
|
|
304
|
+
if len(names) != 1:
|
|
305
|
+
raise KeyError(f"{name_contains!r}: {len(names)} APK bundles match")
|
|
306
|
+
name = names[0].rsplit("/", 1)[1]
|
|
307
|
+
dst = self.cache_dir / "bundles" / name
|
|
308
|
+
if not (dst.exists() and dst.stat().st_size > 0):
|
|
309
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
data = z.read(names[0])
|
|
311
|
+
key = self._setting("bundle_key", name) if data[:7] != b"UnityFS" else None
|
|
312
|
+
_write_atomic(dst, _unityfs(data, name, key))
|
|
313
|
+
return dst
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
_KEPT = threading.local() # per thread: {(scheme, host): an open connection}
|
|
317
|
+
_AGENT = "Python-urllib/%d.%d" % sys.version_info[:2] # what urllib.request sends
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def download(url: str, timeout: float = 120) -> bytes:
|
|
321
|
+
"""The body of `url`. HTTP(S) goes over one connection per thread and host, kept open between files (a new
|
|
322
|
+
connection per file costs more than a small bundle's transfer); a stale kept connection is replaced once. Other
|
|
323
|
+
schemes, a proxy from the environment, and any answer but a complete 200 (a redirect, an error) go through
|
|
324
|
+
urllib.request, as before, which follows or raises."""
|
|
325
|
+
parts = urllib.parse.urlsplit(url)
|
|
326
|
+
if parts.scheme not in ("http", "https") or _proxied(parts):
|
|
327
|
+
return _urlopen(url, timeout)
|
|
328
|
+
conns = _KEPT.__dict__.setdefault("conns", {})
|
|
329
|
+
key = (parts.scheme, parts.netloc)
|
|
330
|
+
path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
|
|
331
|
+
for _ in range(2):
|
|
332
|
+
conn, kept = conns.pop(key, None), True
|
|
333
|
+
if conn is None:
|
|
334
|
+
kept = False
|
|
335
|
+
make = http.client.HTTPSConnection if parts.scheme == "https" else http.client.HTTPConnection
|
|
336
|
+
conn = make(parts.netloc, timeout=timeout)
|
|
337
|
+
try:
|
|
338
|
+
conn.request("GET", path, headers={"User-Agent": _AGENT})
|
|
339
|
+
r = conn.getresponse()
|
|
340
|
+
body = r.read()
|
|
341
|
+
except (http.client.HTTPException, OSError):
|
|
342
|
+
conn.close()
|
|
343
|
+
if kept:
|
|
344
|
+
continue # closed by the server while idle: once more, on a new one
|
|
345
|
+
break
|
|
346
|
+
if r.status != 200 or r.will_close:
|
|
347
|
+
conn.close()
|
|
348
|
+
else:
|
|
349
|
+
conns[key] = conn
|
|
350
|
+
if r.status == 200:
|
|
351
|
+
return body
|
|
352
|
+
break
|
|
353
|
+
return _urlopen(url, timeout)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _proxied(parts) -> bool:
|
|
357
|
+
proxies = urllib.request.getproxies()
|
|
358
|
+
return parts.scheme in proxies and not urllib.request.proxy_bypass(parts.hostname or "")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _urlopen(url: str, timeout: float) -> bytes:
|
|
362
|
+
with urllib.request.urlopen(url, timeout=timeout) as r:
|
|
363
|
+
return r.read()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _setting(v, needed_by: str):
|
|
367
|
+
"""`v`, or what it returns when it is a function; its ConfigError names `needed_by` (what is not cached)."""
|
|
368
|
+
if not callable(v):
|
|
369
|
+
return v
|
|
370
|
+
try:
|
|
371
|
+
return v()
|
|
372
|
+
except ConfigError as e:
|
|
373
|
+
raise ConfigError(f"{needed_by} is not in the cache: {e}") from None
|