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.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. 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