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/__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
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
|
nnnotes/addressables.py
ADDED
|
@@ -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()
|