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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """nnnotes: offline data toolkit for BanG Dream! Our Notes game files.
2
+
3
+ Reads Addressables catalogs, fetches and decrypts asset bundles, decodes master data and exports stories, Live2D
4
+ models, spots, shaders, CRI audio and live charts as JSON and common file formats. Game files, keys and server
5
+ addresses are supplied by the user through the configuration; nothing of the game is included.
6
+ """
7
+ __version__ = "0.1.0"
nnnotes/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
nnnotes/acb.py ADDED
@@ -0,0 +1,190 @@
1
+ """CRI ACB cue tables: which streams each cue of a cue sheet plays, read from the ACB's @UTF tables without decoding
2
+ any audio.
3
+
4
+ An ACB is a tree of @UTF tables (big endian). A cue (CueTable, named in CueNameTable) references a waveform, a synth,
5
+ a sequence or a block sequence; synths reference waveforms, synths and sequences (ReferenceItems: u16 type, u16
6
+ index); sequences and blocks list tracks, whose events (TrackEventTable, older sheets CommandTable) note on synths
7
+ and sequences (command 2000 or 2003: u16 type, u16 index). A waveform stored in the sheet's memory AWB (AFS2, little
8
+ endian) has an AWB id (MemoryAwbId); the decoder's stream number is the 1-based position of that id in the AWB's id
9
+ list, the order in which cri.decode numbers the streams of `streams.json`.
10
+
11
+ `cue_streams(acb)` gives, per cue name, its cue id, length (ms) and the streams it reaches in reference order with
12
+ the waveform's sample count, rate and channels (for checking against the decoded streams). A waveform streamed from
13
+ an external AWB has no stream number here (`streamed`).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+
19
+ UTF_MAGIC, AFS2_MAGIC = b"@UTF", b"AFS2"
20
+ REF_WAVEFORM, REF_SYNTH, REF_SEQUENCE, REF_BLOCK_SEQUENCE = 1, 2, 3, 8
21
+ NOTE_ON = (2000, 2003)
22
+ MAX_DEPTH = 32
23
+ _FORMATS = {0: ">B", 1: ">b", 2: ">H", 3: ">h", 4: ">I", 5: ">i", 6: ">Q", 7: ">q", 8: ">f", 9: ">d"}
24
+
25
+
26
+ class AcbError(ValueError):
27
+ """Bytes that are not an ACB this module reads."""
28
+
29
+
30
+ def utf_table(buf: bytes) -> list[dict]:
31
+ """Rows of a CRI @UTF table (big endian; column storage 0x10 name flag, 0x30 constant, 0x50 per row)."""
32
+ if bytes(buf[:4]) != UTF_MAGIC:
33
+ raise AcbError("not an @UTF table")
34
+ size, = struct.unpack_from(">I", buf, 4)
35
+ t = memoryview(buf)[8:8 + size]
36
+ rows_off, = struct.unpack_from(">H", t, 2)
37
+ str_off, data_off = struct.unpack_from(">II", t, 4)
38
+ ncol, row_w, nrow = struct.unpack_from(">HHI", t, 16)
39
+ strings = bytes(t[str_off:data_off] if data_off > str_off else t[str_off:])
40
+
41
+ def cstr(o):
42
+ return strings[o:strings.index(b"\0", o)].decode("utf-8")
43
+
44
+ def value(p, typ):
45
+ f = _FORMATS.get(typ)
46
+ if f:
47
+ return struct.unpack_from(f, t, p)[0], p + struct.calcsize(f)
48
+ if typ == 0xA:
49
+ return cstr(struct.unpack_from(">I", t, p)[0]), p + 4
50
+ if typ == 0xB:
51
+ o, n = struct.unpack_from(">II", t, p)
52
+ return bytes(t[data_off + o:data_off + o + n]), p + 8
53
+ raise AcbError(f"@UTF column type {typ}")
54
+
55
+ cols, p = [], 24
56
+ for _ in range(ncol):
57
+ flag = t[p]
58
+ p += 1
59
+ name = None
60
+ if flag & 0x10:
61
+ name = cstr(struct.unpack_from(">I", t, p)[0])
62
+ p += 4
63
+ const = None
64
+ if flag & 0xF0 == 0x30:
65
+ const, p = value(p, flag & 0x0F)
66
+ cols.append((name, flag & 0xF0, flag & 0x0F, const))
67
+ rows = []
68
+ for r in range(nrow):
69
+ q, row = rows_off + r * row_w, {}
70
+ for name, storage, typ, const in cols:
71
+ if storage == 0x50:
72
+ row[name], q = value(q, typ)
73
+ else:
74
+ row[name] = const
75
+ rows.append(row)
76
+ return rows
77
+
78
+
79
+ def tables(acb: bytes) -> tuple[dict, dict[str, list[dict]]]:
80
+ """(the ACB's header row, {table name: rows} of its @UTF sub-tables)."""
81
+ top = utf_table(acb)
82
+ if not top:
83
+ raise AcbError("an @UTF table without rows")
84
+ head = top[0]
85
+ return head, {k: utf_table(v) for k, v in head.items() if isinstance(v, bytes) and v[:4] == UTF_MAGIC}
86
+
87
+
88
+ def commands(b: bytes) -> list[tuple[int, bytes]]:
89
+ """An ACB command list: repeated (u16 code, u8 size, payload)."""
90
+ out, p = [], 0
91
+ while p + 3 <= len(b):
92
+ code, = struct.unpack_from(">H", b, p)
93
+ n = b[p + 2]
94
+ out.append((code, b[p + 3:p + 3 + n]))
95
+ p += 3 + n
96
+ return out
97
+
98
+
99
+ def u16s(b: bytes) -> list[int]:
100
+ return [struct.unpack_from(">H", b, i)[0] for i in range(0, len(b) - 1, 2)]
101
+
102
+
103
+ def awb_ids(awb: bytes) -> list[int]:
104
+ """The file ids of an AFS2 archive in stored order (u16 little endian after the 16-byte header)."""
105
+ if bytes(awb[:4]) != AFS2_MAGIC:
106
+ raise AcbError("not an AFS2 archive")
107
+ count, = struct.unpack_from("<I", awb, 8)
108
+ width = awb[6] or 2
109
+ fmt = {2: "<H", 4: "<I"}.get(width)
110
+ if fmt is None:
111
+ raise AcbError(f"AFS2 id width {width}")
112
+ return [struct.unpack_from(fmt, awb, 16 + i * width)[0] for i in range(count)]
113
+
114
+
115
+ class _Walk:
116
+ """The waveforms (WaveformTable indices) a reference reaches, in reference order, each once."""
117
+
118
+ def __init__(self, T: dict[str, list[dict]]):
119
+ self.T = T
120
+ self.events = T.get("TrackEventTable") or T.get("CommandTable") or []
121
+
122
+ def _row(self, table: str, i: int):
123
+ rows = self.T.get(table) or []
124
+ return rows[i] if 0 <= i < len(rows) else None
125
+
126
+ def ref(self, kind: int, index: int, out: list, depth: int = 0) -> None:
127
+ if depth > MAX_DEPTH:
128
+ raise AcbError(f"references nested deeper than {MAX_DEPTH}")
129
+ if kind == REF_WAVEFORM:
130
+ if index not in out and self._row("WaveformTable", index) is not None:
131
+ out.append(index)
132
+ elif kind == REF_SYNTH:
133
+ syn = self._row("SynthTable", index)
134
+ items = (syn or {}).get("ReferenceItems") or b""
135
+ for p in range(0, len(items) - 3, 4):
136
+ t, i = struct.unpack_from(">HH", items, p)
137
+ if t == 0:
138
+ break
139
+ self.ref(t, i, out, depth + 1)
140
+ elif kind == REF_SEQUENCE:
141
+ seq = self._row("SequenceTable", index)
142
+ self._tracks((seq or {}).get("TrackIndex") or b"", out, depth)
143
+ elif kind == REF_BLOCK_SEQUENCE:
144
+ bs = self._row("BlockSequenceTable", index) or {}
145
+ self._tracks(bs.get("TrackIndex") or b"", out, depth)
146
+ for bi in u16s(bs.get("BlockIndex") or b""):
147
+ self._tracks((self._row("BlockTable", bi) or {}).get("TrackIndex") or b"", out, depth)
148
+
149
+ def _tracks(self, index: bytes, out: list, depth: int) -> None:
150
+ for ti in u16s(index):
151
+ tr = self._row("TrackTable", ti)
152
+ ev = (tr or {}).get("EventIndex")
153
+ if ev is None or ev == 0xFFFF or not 0 <= ev < len(self.events):
154
+ continue
155
+ for code, v in commands(self.events[ev].get("Command") or b""):
156
+ if code in NOTE_ON and len(v) >= 4:
157
+ t, i = struct.unpack_from(">HH", v, 0)
158
+ self.ref(t, i, out, depth + 1)
159
+
160
+
161
+ def cue_streams(acb: bytes) -> dict[str, dict]:
162
+ """{cue name: {cueId, lengthMs, streams: [{stream, awbId, samples, sampleRate, channels} | {streamed, ...}]}} of
163
+ an ACB (module docstring); a name given to two cues keeps the first."""
164
+ head, T = tables(acb)
165
+ awb = head.get("AwbFile")
166
+ position = {aid: i + 1 for i, aid in enumerate(awb_ids(awb))} if isinstance(awb, bytes) and awb else {}
167
+ waves = T.get("WaveformTable") or []
168
+ cues = T.get("CueTable") or []
169
+ walk = _Walk(T)
170
+ out: dict[str, dict] = {}
171
+ for r in T.get("CueNameTable") or []:
172
+ name, ci = r.get("CueName"), r.get("CueIndex")
173
+ if not name or name in out or ci is None or not 0 <= ci < len(cues):
174
+ continue
175
+ c = cues[ci]
176
+ found: list[int] = []
177
+ walk.ref(c.get("ReferenceType") or 0, c.get("ReferenceIndex") or 0, found)
178
+ streams = []
179
+ for wi in found:
180
+ w = waves[wi]
181
+ info = {"samples": w.get("NumSamples"), "sampleRate": w.get("SamplingRate"),
182
+ "channels": w.get("NumChannels")}
183
+ memory = (w.get("Streaming") or 0) != 1
184
+ aid = w.get("MemoryAwbId") if "MemoryAwbId" in w else w.get("Id")
185
+ if memory and aid in position:
186
+ streams.append({"stream": position[aid], "awbId": aid, **info})
187
+ else:
188
+ streams.append({"streamed": True, "awbId": w.get("StreamAwbId", aid), **info})
189
+ out[name] = {"cueId": c.get("CueId"), "lengthMs": c.get("Length"), "streams": streams}
190
+ return out
@@ -0,0 +1,413 @@
1
+ """Addressables content catalogs, asset bundle decryption and a local catalog browser.
2
+
3
+ A remote content catalog (`catalog_main_<language>.bin`, Addressables binary catalog format version 2) lists every
4
+ addressable key with its location: an asset path inside a bundle, a bundle, or a raw file. Remote locations are
5
+ absolute URLs on a placeholder host that the game replaces with its CDN base at load time. `parse` reads what the
6
+ extractors need (keys, internal ids, dependencies); `parse_locations` decodes every field of every location,
7
+ including the AssetBundleRequestOptions of bundle and raw file locations (content hash, CRC, size, internal bundle
8
+ name), and `parse_header` / `parse_keys` the header and the key table.
9
+
10
+ Asset bundles on the CDN and inside the APK are encrypted in their first 16 KiB with AES-128 in CTR mode: the nonce
11
+ is the first 8 bytes of SHA-256(nonce seed + UTF-8 bundle file name), the counter block is nonce + 64-bit big-endian
12
+ counter starting at 0. A file that already starts with the `UnityFS` signature is not encrypted. The key and the
13
+ nonce seed come from the configuration (`[bundle] key`, `[bundle] nonce_seed`).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import struct
19
+ from dataclasses import dataclass, field
20
+ from html import escape
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+ from pathlib import Path
23
+ from urllib.parse import quote, unquote, urlsplit
24
+ from urllib.request import urlopen
25
+
26
+ from Crypto.Cipher import AES
27
+
28
+ from . import __version__
29
+
30
+ UNITYFS = b"UnityFS\0"
31
+ ENCRYPTED_BYTES = 16384 # only the first 16 KiB of a bundle are encrypted
32
+ CATALOG_MAGIC = 0x0DE38942
33
+ CATALOG_VERSION = 2
34
+ NONE = 0xFFFFFFFF
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class BundleKey:
39
+ """AES-128 key and nonce seed of the bundle encryption (never shown in a repr)."""
40
+ key: bytes = field(repr=False)
41
+ seed: bytes = field(repr=False)
42
+
43
+ def __post_init__(self):
44
+ if len(self.key) != 16:
45
+ raise ValueError("bundle key must be 16 bytes")
46
+
47
+
48
+ def decrypt(data: bytes, filename: str, key: BundleKey) -> bytes:
49
+ """A bundle file as stored -> UnityFS bytes. `filename`: the bundle's bare file name (it seeds the nonce)."""
50
+ if data.startswith(UNITYFS):
51
+ return data
52
+ nonce = hashlib.sha256(key.seed + filename.encode("utf-8")).digest()[:8]
53
+ aes = AES.new(key.key, AES.MODE_CTR, nonce=nonce, initial_value=0)
54
+ return aes.decrypt(data[:ENCRYPTED_BYTES]) + data[ENCRYPTED_BYTES:]
55
+
56
+
57
+ def remote_path(internal_id: str) -> str | None:
58
+ """The path below the CDN base of a remote location (an absolute URL: everything after its host), else None."""
59
+ scheme, sep, rest = internal_id.partition("://")
60
+ if not sep or not scheme.isalpha():
61
+ return None
62
+ _, slash, path = rest.partition("/")
63
+ return slash + path
64
+
65
+
66
+ def parse(data: bytes) -> list[dict]:
67
+ """Every location of a binary catalog: {offset, primary_key, internal_id, dependencies (location offsets)}."""
68
+ def u32(offset):
69
+ return struct.unpack_from("<I", data, offset)[0]
70
+
71
+ def array(offset):
72
+ if offset == NONE:
73
+ return []
74
+ return struct.unpack_from(f"<{u32(offset - 4) // 4}I", data, offset)
75
+
76
+ def text(offset):
77
+ if offset == NONE:
78
+ return ""
79
+ if offset & 0x40000000: # a path of parts, stored last part first
80
+ parts = []
81
+ while offset != NONE:
82
+ part, offset = struct.unpack_from("<II", data, offset & 0x3FFFFFFF)
83
+ parts.append(text(part))
84
+ return "/".join(reversed(parts))
85
+ pos = offset & 0x3FFFFFFF
86
+ return data[pos:pos + u32(pos - 4)].decode("utf-16-le" if offset & 0x80000000 else "ascii")
87
+
88
+ magic, version, keys = struct.unpack_from("<III", data)
89
+ if magic != CATALOG_MAGIC or version != CATALOG_VERSION:
90
+ raise ValueError("unsupported catalog format")
91
+ locations = set()
92
+ for pos in range(keys, keys + u32(keys - 4), 8):
93
+ locations.update(array(u32(pos + 4)))
94
+ result = []
95
+ for pos in sorted(locations):
96
+ primary, internal, _, deps = struct.unpack_from("<4I", data, pos)
97
+ result.append({"offset": pos, "primary_key": text(primary),
98
+ "internal_id": text(internal), "dependencies": array(deps)})
99
+ return result
100
+
101
+
102
+ # ---------------------------------------------------------------- full decode
103
+ # The binary catalog is a BinaryStorageBuffer: every value lives at an offset (0xFFFFFFFF = null). Fixed-size values
104
+ # (the header, ResourceLocation.Serializer.Data, ObjectTypeData, TypeSerializer.Data, DynamicString,
105
+ # AssetBundleRequestOptionsSerializationAdapter.SerializedData and .Common, Hash128) are read as `sizeof` bytes at
106
+ # their offset with no length prefix; arrays and strings carry a u32 byte length just before their offset. A string
107
+ # offset has flags in its top bits: bit 31 UTF-16LE (else one byte per character), bit 30 a DynamicString chain
108
+ # {u32 part, u32 previous} that starts at the last part; the reader supplies the separator the parts are joined with.
109
+ UNICODE = 0x80000000
110
+ DYNAMIC = 0x40000000
111
+ OFFSET_MASK = 0x3FFFFFFF
112
+ HEADER = struct.Struct("<iiIIIIII") # ContentCatalogData.ResourceLocator.Header, 32 bytes
113
+ LOCATION = struct.Struct("<IIIIiII") # ResourceLocation.Serializer.Data, 28 bytes
114
+ REQUEST_OPTIONS = "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleRequestOptions"
115
+ # AssetBundleRequestOptions flag bits (SerializedData.Common.flags)
116
+ FLAG_BITS = (("assetLoadMode", 1), ("chunkedTransfer", 2), ("useCrcForCachedBundle", 4),
117
+ ("useUnityWebRequestForLocalBundles", 8), ("clearOtherCachedVersionsWhenLoaded", 16))
118
+
119
+
120
+ class _Buffer:
121
+ """Typed reads from a binary catalog. Plain strings and type names are read once per offset: the parts of the
122
+ internal ids (the CDN prefix, directories) and the few type names repeat in every location."""
123
+
124
+ def __init__(self, data: bytes):
125
+ self.data = data
126
+ self._plain: dict[int, str] = {}
127
+ self._types: dict[int, tuple] = {}
128
+
129
+ def u32(self, offset: int) -> int:
130
+ return struct.unpack_from("<I", self.data, offset)[0]
131
+
132
+ def array(self, offset: int) -> list[int]:
133
+ """A u32 array (its byte length is the u32 before it); [] for null."""
134
+ if offset == NONE:
135
+ return []
136
+ n = self.u32(offset - 4)
137
+ if n % 4:
138
+ raise ValueError(f"catalog array at {offset}: byte length {n} is not a multiple of 4")
139
+ return list(struct.unpack_from(f"<{n // 4}I", self.data, offset))
140
+
141
+ def plain(self, offset: int) -> str:
142
+ s = self._plain.get(offset)
143
+ if s is None:
144
+ pos = offset & OFFSET_MASK
145
+ raw = self.data[pos:pos + self.u32(pos - 4)]
146
+ s = self._plain[offset] = raw.decode("utf-16-le" if offset & UNICODE else "ascii")
147
+ return s
148
+
149
+ def string(self, offset: int, sep: str) -> str | None:
150
+ """A string read with separator `sep` ("" reads plain strings only); None for null."""
151
+ if offset == NONE:
152
+ return None
153
+ if not offset & DYNAMIC:
154
+ return self.plain(offset)
155
+ if not sep:
156
+ raise ValueError(f"catalog string at {offset & OFFSET_MASK}: a part list where a plain string belongs")
157
+ parts, link, seen = [], offset, set()
158
+ while link != NONE:
159
+ pos = link & OFFSET_MASK
160
+ if pos in seen:
161
+ raise ValueError(f"catalog string at {offset & OFFSET_MASK}: the part chain loops")
162
+ seen.add(pos)
163
+ part, link = struct.unpack_from("<II", self.data, pos)
164
+ if part != NONE and part & DYNAMIC:
165
+ raise ValueError(f"catalog string at {pos}: a nested part list")
166
+ parts.append("" if part == NONE else self.plain(part))
167
+ return sep.join(reversed(parts))
168
+
169
+ def type_name(self, offset: int) -> tuple[str | None, str | None] | None:
170
+ """TypeSerializer.Data {assembly, class}, both read with '.'; None for null."""
171
+ if offset == NONE:
172
+ return None
173
+ t = self._types.get(offset)
174
+ if t is None:
175
+ assembly, cls = struct.unpack_from("<II", self.data, offset)
176
+ t = self._types[offset] = (self.string(assembly, "."), self.string(cls, "."))
177
+ return t
178
+
179
+ def object_init(self, offset: int) -> dict | None:
180
+ """ObjectInitializationData.Serializer.Data {id, type, data}: id and data plain strings, type a
181
+ TypeSerializer.Data."""
182
+ if offset == NONE:
183
+ return None
184
+ ident, typ, data = struct.unpack_from("<III", self.data, offset)
185
+ t = self.type_name(typ) or (None, None)
186
+ return {"id": self.string(ident, ""), "assembly": t[0], "type": t[1], "data": self.string(data, "")}
187
+
188
+ def key(self, offset: int) -> tuple[str | None, object]:
189
+ """A key object (ObjectTypeData {type, object}) -> (type name, value). System.String keys are an
190
+ ObjectToStringRemap {u32 string, u16 separator}; System.Int32 keys a 4-byte integer; other types None."""
191
+ typ, obj = struct.unpack_from("<II", self.data, offset)
192
+ cls = (self.type_name(typ) or (None, None))[1]
193
+ if cls == "System.String":
194
+ sid, sep = struct.unpack_from("<IH", self.data, obj)
195
+ return cls, self.string(sid, chr(sep) if sep else "")
196
+ if cls == "System.Int32":
197
+ return cls, struct.unpack_from("<i", self.data, obj)[0]
198
+ return cls, None
199
+
200
+ def extra(self, offset: int) -> dict | None:
201
+ """A location's extra data (ObjectTypeData {type, object}): {"type": class name} plus, for
202
+ AssetBundleRequestOptions, the decoded options; None for null."""
203
+ if offset == NONE:
204
+ return None
205
+ typ, obj = struct.unpack_from("<II", self.data, offset)
206
+ cls = (self.type_name(typ) or (None, None))[1]
207
+ out: dict = {"type": cls}
208
+ if cls == REQUEST_OPTIONS and obj != NONE:
209
+ out.update(self.request_options(obj))
210
+ return out
211
+
212
+ def request_options(self, offset: int) -> dict:
213
+ """AssetBundleRequestOptionsSerializationAdapter.SerializedData {u32 hash, u32 bundleName, u32 crc,
214
+ u32 bundleSize, u32 common}: hash a Hash128 (16 raw bytes, hex in stored order), bundleName read with '_',
215
+ common a SerializedData.Common {i16 timeout, u8 redirectLimit, u8 retryCount, i32 flags}."""
216
+ hash_id, name_id, crc, size, common = struct.unpack_from("<5I", self.data, offset)
217
+ out = {"hash": None if hash_id == NONE else self.data[hash_id:hash_id + 16].hex(),
218
+ "bundleName": self.string(name_id, "_"), "crc": crc, "bundleSize": size}
219
+ if common == NONE:
220
+ out.update(timeout=None, redirectLimit=None, retryCount=None, flags=None)
221
+ return out
222
+ timeout, redirects, retries, flags = struct.unpack_from("<hBBi", self.data, common)
223
+ out.update(timeout=timeout, redirectLimit=redirects, retryCount=retries, flags=flags)
224
+ for name, bit in FLAG_BITS:
225
+ out[name] = (flags & bit) // bit if name == "assetLoadMode" else bool(flags & bit)
226
+ return out
227
+
228
+
229
+ def parse_header(data: bytes) -> dict:
230
+ """The header of a binary catalog: magic, version, keysOffset, locatorId, instanceProvider, sceneProvider
231
+ ({id, assembly, type, data}), initObjects (the same, the providers the catalog initializes), buildResultHash."""
232
+ if len(data) < HEADER.size:
233
+ raise ValueError("unsupported catalog format")
234
+ magic, version, keys, locator, instance, scene, init, build = HEADER.unpack_from(data, 0)
235
+ if magic != CATALOG_MAGIC or version != CATALOG_VERSION:
236
+ raise ValueError("unsupported catalog format")
237
+ buf = _Buffer(data)
238
+ return {"magic": magic, "version": version, "keysOffset": keys, "locatorId": buf.string(locator, ""),
239
+ "instanceProvider": buf.object_init(instance), "sceneProvider": buf.object_init(scene),
240
+ "initObjects": [buf.object_init(o) for o in buf.array(init)],
241
+ "buildResultHash": buf.string(build, "")}
242
+
243
+
244
+ def parse_keys(data: bytes) -> list[dict]:
245
+ """The key table of a binary catalog in stored order (ContentCatalogData.ResourceLocator.KeyData {u32 key object,
246
+ u32 location set}): {"key": value, "type": the key's type name, "locations": location offsets}."""
247
+ header = parse_header(data)
248
+ buf = _Buffer(data)
249
+ out = []
250
+ table = header["keysOffset"]
251
+ n = buf.u32(table - 4)
252
+ if n % 8:
253
+ raise ValueError(f"catalog key table: byte length {n} is not a multiple of 8")
254
+ for pos in range(table, table + n, 8):
255
+ key_obj, locations = struct.unpack_from("<II", data, pos)
256
+ cls, value = buf.key(key_obj) if key_obj != NONE else (None, None)
257
+ out.append({"key": value, "type": cls, "locations": buf.array(locations)})
258
+ return out
259
+
260
+
261
+ def parse_locations(data: bytes) -> list[dict]:
262
+ """Every location of a binary catalog, fully decoded, by offset: what parse() gives (offset, primary_key,
263
+ internal_id, dependencies) plus provider, dependency_hash, resource_type (class names) and extra_data.
264
+
265
+ A location record (ResourceLocation.Serializer.Data) is a fixed 28-byte value with no length prefix: primary key
266
+ and internal id (strings read with '/'), provider (read with '.'), dependency set (u32 array of location
267
+ offsets), dependency hash (i32), extra data (an ObjectTypeData) and resource type (a TypeSerializer.Data).
268
+ extra_data of bundle and raw file locations is an AssetBundleRequestOptions: {type, hash, bundleName, crc,
269
+ bundleSize, timeout, redirectLimit, retryCount, flags and the flag bits by name}."""
270
+ header = parse_header(data)
271
+ buf = _Buffer(data)
272
+ offsets: set[int] = set()
273
+ table = header["keysOffset"]
274
+ for pos in range(table, table + buf.u32(table - 4), 8):
275
+ offsets.update(buf.array(buf.u32(pos + 4)))
276
+ out = []
277
+ for pos in sorted(offsets):
278
+ if pos + LOCATION.size > len(data):
279
+ raise ValueError(f"catalog location at {pos}: past the end of the catalog")
280
+ primary, internal, provider, deps, dep_hash, extra, typ = LOCATION.unpack_from(data, pos)
281
+ rtype = buf.type_name(typ)
282
+ out.append({"offset": pos, "primary_key": buf.string(primary, "/") or "",
283
+ "internal_id": buf.string(internal, "/") or "", "dependencies": buf.array(deps),
284
+ "provider": buf.string(provider, "."), "dependency_hash": dep_hash,
285
+ "resource_type": rtype[1] if rtype else None, "extra_data": buf.extra(extra)})
286
+ return out
287
+
288
+
289
+ # ---------------------------------------------------------------- local browser
290
+ def browse(entries: list[dict]) -> tuple[dict, dict]:
291
+ """(remote bundles by offset, {asset path or `bundles/<key>`: bundle offsets}) of a parsed catalog."""
292
+ bundles = {e["offset"]: e for e in entries
293
+ if remote_path(e["internal_id"]) is not None and e["internal_id"].endswith(".bundle")}
294
+ files: dict[str, set] = {}
295
+ for entry in entries:
296
+ if entry["internal_id"].startswith("Assets/"):
297
+ ids = [i for i in entry["dependencies"] if i in bundles]
298
+ if ids:
299
+ files.setdefault(entry["primary_key"], set()).update(ids)
300
+ for ident, entry in bundles.items():
301
+ files["bundles/" + entry["primary_key"]] = {ident}
302
+ return bundles, files
303
+
304
+
305
+ @dataclass
306
+ class Region:
307
+ """One region of the browser: label, CDN base and catalog languages."""
308
+ name: str
309
+ label: str
310
+ cdn: str = field(repr=False)
311
+ languages: list[str]
312
+
313
+
314
+ class Handler(BaseHTTPRequestHandler):
315
+ """/ -> regions, /<region>/ -> languages, /<region>/<language>/<dir>/ -> listing,
316
+ /<region>/<language>/download/<offset> -> the decrypted bundle."""
317
+
318
+ def listing(self, label: str, rows) -> None:
319
+ title = f"nnnotes {__version__} /" + label
320
+ data = ("<!doctype html><meta charset=\"utf-8\"><title>" + escape(title)
321
+ + "</title><h1>" + escape(title) + "</h1><hr><ul>"
322
+ + "\n".join(sorted(rows)) + "</ul><hr>").encode()
323
+ self.send_response(200)
324
+ self.send_header("Content-Type", "text/html; charset=utf-8")
325
+ self.send_header("Content-Length", str(len(data)))
326
+ self.end_headers()
327
+ self.wfile.write(data)
328
+
329
+ def catalog(self, region: Region, language: str):
330
+ key = (region.name, language)
331
+ if key not in self.server.catalogs:
332
+ catalog = self.server.cache / "catalogs" / region.name / f"catalog_main_{language}.bin"
333
+ if not catalog.exists():
334
+ with urlopen(region.cdn + f"/asset/Android/{catalog.name}", timeout=60) as response:
335
+ raw = response.read()
336
+ catalog.parent.mkdir(parents=True, exist_ok=True)
337
+ catalog.write_bytes(raw)
338
+ self.server.catalogs[key] = browse(parse(catalog.read_bytes()))
339
+ return self.server.catalogs[key]
340
+
341
+ def do_GET(self):
342
+ regions: dict[str, Region] = self.server.regions
343
+ path = unquote(urlsplit(self.path).path)
344
+ parts = path.strip("/").split("/") if path.strip("/") else []
345
+ if not parts:
346
+ self.listing("", [f"<li><a href=\"/{quote(r.name)}/\">{escape(r.label)}/</a></li>" for r in regions.values()])
347
+ return
348
+ region = regions.get(parts[0])
349
+ if region is None:
350
+ self.send_error(404)
351
+ return
352
+ if len(parts) == 1:
353
+ self.listing(region.label + "/", ["<li><a href=\"/\">../</a></li>"] +
354
+ [f"<li><a href=\"/{quote(region.name)}/{quote(lang)}/\">{escape(lang)}/</a></li>"
355
+ for lang in region.languages])
356
+ return
357
+ language = parts[1]
358
+ if language not in region.languages:
359
+ self.send_error(404)
360
+ return
361
+ bundles, files = self.catalog(region, language)
362
+ base = f"/{quote(region.name)}/{quote(language)}/"
363
+ if len(parts) > 2 and parts[2] == "download":
364
+ ident = parts[3] if len(parts) == 4 else ""
365
+ if not ident.isdecimal() or int(ident) not in bundles:
366
+ self.send_error(404)
367
+ return
368
+ entry = bundles[int(ident)]
369
+ name = entry["internal_id"].rsplit("/", 1)[1]
370
+ with urlopen(region.cdn + remote_path(entry["internal_id"]), timeout=60) as response:
371
+ data = decrypt(response.read(), name, self.server.bundle_key)
372
+ self.send_response(200)
373
+ self.send_header("Content-Type", "application/octet-stream")
374
+ self.send_header("Content-Disposition", "attachment; filename*=UTF-8''" + quote(name))
375
+ self.send_header("Content-Length", str(len(data)))
376
+ self.end_headers()
377
+ self.wfile.write(data)
378
+ return
379
+ prefix = "/".join(parts[2:])
380
+ prefix += "/" if prefix else ""
381
+ parent = base + prefix.rstrip("/").rsplit("/", 1)[0] + "/" if "/" in prefix.rstrip("/") else base
382
+ if not prefix:
383
+ parent = f"/{quote(region.name)}/"
384
+ rows = {f"<li><a href=\"{quote(parent)}\">../</a></li>"}
385
+ for name, ids in files.items():
386
+ if not name.startswith(prefix):
387
+ continue
388
+ tail = name[len(prefix):]
389
+ if "/" in tail:
390
+ directory = prefix + tail.split("/")[0] + "/"
391
+ rows.add(f"<li><a href=\"{base}{quote(directory)}\">{escape(tail.split('/')[0])}/</a></li>")
392
+ else:
393
+ for ident in sorted(ids):
394
+ label = tail if len(ids) == 1 else tail + " — " + bundles[ident]["primary_key"]
395
+ rows.add(f"<li><a href=\"{base}download/{ident}\">{escape(label)}</a></li>")
396
+ self.listing(region.label + "/" + language + "/" + prefix, rows)
397
+
398
+
399
+ def serve(regions: list[Region], bundle_key: BundleKey, cache: Path, port: int, host: str = "127.0.0.1") -> None:
400
+ """Browse the catalogs of `regions` and download decrypted bundles on `host:port` (catalogs cached under
401
+ <cache>/catalogs/<region>/)."""
402
+ server = ThreadingHTTPServer((host, port), Handler)
403
+ server.regions = {r.name: r for r in regions}
404
+ server.bundle_key = bundle_key
405
+ server.cache = Path(cache)
406
+ server.catalogs = {}
407
+ print(f"nnnotes browse: serving on {host}:{port}", flush=True)
408
+ try:
409
+ server.serve_forever()
410
+ except KeyboardInterrupt:
411
+ pass
412
+ finally:
413
+ server.server_close()