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/cli.py ADDED
@@ -0,0 +1,744 @@
1
+ """nnnotes command line.
2
+
3
+ Every setting (keys, the CDN base of each region, the paths of your own data and tools) comes from the config file,
4
+ the environment or the flags below; see config.py and nnnotes.example.toml. JSON summaries are printed as UTF-8
5
+ whatever the console encoding.
6
+
7
+ nnnotes catalog --prefix Spot/ --limit 40
8
+ nnnotes browse [--port 8000]
9
+ nnnotes pull <key> [<key> ...]
10
+ nnnotes servers [--show-hosts]
11
+ nnnotes master version
12
+ nnnotes master decode <dir or .bin files> -o <out dir>
13
+ nnnotes master download --version <master version> | --latest -o <dir>
14
+ nnnotes adv 10462 -o out/adv_10462.json
15
+ nnnotes story 10462 -o out/story_10462
16
+ nnnotes live2d <Character/Live2D/.../model/... | model id> -o out/live2d/x
17
+ nnnotes spot 10001 -o out/spot_10001
18
+ nnnotes room <Spot/.../Background/...> -o out/room.glb
19
+ nnnotes shader --key <key> | --apk-bundle <substring> -o out/shaders
20
+ nnnotes audio <cueSheet> -o out/audio
21
+ nnnotes voices list [--from out/assets] [--character <id | name>] [--category <category>] [--source <source>]
22
+ [--episode <adv id | asset>]
23
+ nnnotes voices search <text> [--from out/assets] [--language ja] [--character ...] [--category ...]
24
+ nnnotes voices summary [--from out/assets]
25
+ nnnotes voices get <row id | sound id> -o out/voice [--from out/assets] [--format flac]
26
+ nnnotes crikey [--write <dir>]
27
+ nnnotes player -o out/player.json
28
+ nnnotes live 100001 --difficulty expert [--band 1 | --leader-card <MasterMemberCard id>] [--fonts game]
29
+ -o out/live_100001
30
+ nnnotes web out/site --player <ournotes-player> [--pair 100001:expert [--pair ...] | --all] [--format aac]
31
+ [--live2d <model id | key> [--live2d ...] | --all-live2d]
32
+ [--story 10462 [--story ...] | --all-stories] [--story-languages en,ja] [--font en=<file>]
33
+ [--font emoji=<file>]
34
+ [--region <region> [--region ...] | --all-regions]
35
+ nnnotes deck-data --master-files <master download dir> | --apk-master -o out/deck-data.json[.gz]
36
+ nnnotes export -o out/assets [--select group:<group> | key:<prefix> | bundle:<glob> ...] [--layout original,cas]
37
+ nnnotes plan [--select ...] [--json] [--check] [--emit-tasks <dir>]
38
+ nnnotes run-stage <task.json> [...]
39
+ nnnotes catalogs list | import <catalog.bin> | fetch | diff <old> <new>
40
+ nnnotes store verify
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import os
46
+ import re
47
+ import sys
48
+ from pathlib import Path
49
+
50
+ # numpy's OpenBLAS starts one busy thread per CPU in every process that imports it, and no export uses its
51
+ # parallelism. Set when the command line loads, before a command imports numpy; the worker processes of a build
52
+ # inherit it.
53
+ os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
54
+
55
+ from . import __version__, cli_assets, voices
56
+ from .addressables import BundleKey
57
+ from .catalog import Catalog
58
+ from .config import Config, ConfigError, use
59
+ from .jsonio import dumps, write_json
60
+ from .webaudio import DEFAULT_AUDIO_FORMAT, WEB_AUDIO
61
+
62
+ DIFFICULTY_CHOICES = ("easy", "normal", "hard", "expert")
63
+ AUDIO_CHOICES = ("flac", "ogg", "wav")
64
+ # command-line flags of settings: (section, key) -> (argparse dest, flag)
65
+ FLAG_SETTINGS = {
66
+ ("catalog", "region"): ("region", "--region"),
67
+ ("catalog", "language"): ("language", "--language"),
68
+ ("paths", "catalog"): ("catalog", "--catalog"),
69
+ ("paths", "cache"): ("cache", "--cache"),
70
+ ("paths", "master"): ("master", "--master"),
71
+ ("paths", "apk"): ("apk", "--apk"),
72
+ ("paths", "ffmpeg"): ("ffmpeg", "--ffmpeg"),
73
+ ("paths", "vgmstream"): ("vgmstream", "--vgmstream"),
74
+ ("paths", "node"): ("node", "--node"),
75
+ ("paths", "player"): ("player", "--player"),
76
+ ("paths", "store"): ("store", "--store"),
77
+ }
78
+
79
+
80
+ # ---------------------------------------------------------------- settings -> data
81
+ def load_config(args) -> Config:
82
+ overrides = {k: getattr(args, dest, None) for k, (dest, _) in FLAG_SETTINGS.items()}
83
+ return use(Config.load(getattr(args, "config", None), overrides=overrides,
84
+ flags={k: flag for k, (_, flag) in FLAG_SETTINGS.items()}))
85
+
86
+
87
+ def bundle_key(cfg: Config) -> BundleKey:
88
+ return BundleKey(cfg.hex("bundle", "key", 16), cfg.hex("bundle", "nonce_seed"))
89
+
90
+
91
+ def _existing(cfg: Config, section: str, key: str, kind: str = "file") -> Path | None:
92
+ p = cfg.path(section, key)
93
+ if p is not None and not (p.is_file() if kind == "file" else p.is_dir()):
94
+ raise ConfigError(f"setting {section}.{key}: {kind} {p} not found")
95
+ return p
96
+
97
+
98
+ def open_catalog(cfg: Config, bundles: bool = True, region: str | None = None) -> Catalog:
99
+ """The catalog of [catalog] language (merged with the APK's when [paths] apk is set), fetching from the CDN of
100
+ `region` (default: [catalog] region); the regions serve the same catalog for a language, so one cached file
101
+ serves them all. `bundles`: bundles will be fetched; else only the catalog is read. The region, its CDN base
102
+ and the bundle key are read from the settings only when something must be downloaded (every file in the cache:
103
+ none of them is needed), then a missing one is a ConfigError naming the setting."""
104
+ cache = cfg.require_path("paths", "cache")
105
+ catbin = _existing(cfg, "paths", "catalog")
106
+ apk = _existing(cfg, "paths", "apk")
107
+ language = cfg.require("catalog", "language") if catbin is None else None
108
+ cdn = (lambda: cfg.cdn(region or cfg.region())) if bundles or catbin is None else None
109
+ key = (lambda: bundle_key(cfg)) if bundles else None
110
+ if catbin is not None:
111
+ return Catalog(catbin.read_bytes(), cache, cdn=cdn, bundle_key=key, apk=apk)
112
+ return Catalog.load(language, cache, cdn=cdn, bundle_key=key, apk=apk)
113
+
114
+
115
+ def master_dir(cfg: Config, region: str | None = None) -> Path:
116
+ """The decoded master data of `region` (default: [catalog] region when it is set): the --master flag, else
117
+ [servers.<region>] master, else [paths] master."""
118
+ section, key = cfg.master(region or cfg.get("catalog", "region"))
119
+ cfg.require_path(section, key)
120
+ return _existing(cfg, section, key, "directory")
121
+
122
+
123
+ def player_data(cfg: Config):
124
+ from .player import PlayerData
125
+ cfg.require_path("paths", "apk")
126
+ return PlayerData(_existing(cfg, "paths", "apk"))
127
+
128
+
129
+ def master_key(cfg: Config):
130
+ from .master import MasterKey
131
+ return MasterKey(cfg.hex("master", "key", 32), cfg.hex("master", "iv", 32))
132
+
133
+
134
+ def known_keys(args, cat: Catalog, keys) -> None:
135
+ """A usage error (exit 2) naming the keys the catalog does not have."""
136
+ unknown = [k for k in keys if not cat.has(k)]
137
+ if unknown:
138
+ args.usage(f"not a key of the catalog: {', '.join(unknown)}")
139
+
140
+
141
+ def known_row(args, md: Path, table: str, row_id: int, what: str) -> None:
142
+ """A usage error (exit 2) naming `row_id` when the master table `table` has no row with that `_id`."""
143
+ from .master import has_row
144
+ try:
145
+ found = has_row(md, table, row_id)
146
+ except FileNotFoundError:
147
+ raise ConfigError(f"master data {md}: no {table}.json (a directory written by `nnnotes master decode`)") \
148
+ from None
149
+ if not found:
150
+ args.usage(f"{what} {row_id}: no {table} row with this id")
151
+
152
+
153
+ def _print_json(r) -> None:
154
+ """Print a JSON summary as UTF-8 bytes (a GBK / cp932 console cannot encode every string)."""
155
+ sys.stdout.flush()
156
+ sys.stdout.buffer.write(dumps(r, ensure_ascii=False, indent=1).encode("utf-8") + b"\n")
157
+ sys.stdout.buffer.flush()
158
+
159
+
160
+ # ---------------------------------------------------------------- commands
161
+ def cmd_catalog(args, cfg):
162
+ cat = open_catalog(cfg, bundles=False)
163
+ ks = cat.keys(args.prefix)
164
+ for k in ks[: args.limit]:
165
+ print(k)
166
+ print(f"# {len(ks)} keys", file=sys.stderr)
167
+
168
+
169
+ def cmd_browse(args, cfg):
170
+ from .addressables import Region, serve
171
+ names = cfg.regions()
172
+ if not names:
173
+ raise ConfigError("no region configured: add a [servers.<region>] table with `cdn` to the config file "
174
+ "(or set NNNOTES_SERVERS_<REGION>_CDN)")
175
+ regions = []
176
+ for r in names:
177
+ langs = cfg.get_list(f"servers.{r}", "languages")
178
+ if not langs:
179
+ raise cfg.missing(f"servers.{r}", "languages")
180
+ regions.append(Region(r, cfg.get(f"servers.{r}", "name") or r, cfg.cdn(r), langs))
181
+ serve(regions, bundle_key(cfg), cfg.require_path("paths", "cache"), args.port, args.host)
182
+
183
+
184
+ def cmd_pull(args, cfg):
185
+ cat = open_catalog(cfg)
186
+ known_keys(args, cat, args.keys)
187
+ for key in args.keys:
188
+ for p in cat.fetch_key(key):
189
+ print(p)
190
+
191
+
192
+ def cmd_master_decode(args, cfg):
193
+ from . import master
194
+ key = master_key(cfg)
195
+ files = master.input_files(args.inputs)
196
+ if not files:
197
+ raise SystemExit("nnnotes: no master data files (*.bin) among the inputs")
198
+ r = master.decode_files(files, Path(args.out), key, workers=args.workers)
199
+ _print_json(r)
200
+ if r["failed"]:
201
+ sys.exit(1)
202
+
203
+
204
+ def cmd_servers(args, cfg):
205
+ from . import gameapi
206
+ try:
207
+ servers = gameapi.server_list(cfg)
208
+ except gameapi.GameApiError as e:
209
+ sys.exit(f"nnnotes: {e}")
210
+ configured = gameapi.configured_roots(cfg)
211
+ _print_json({"servers": [gameapi.server_summary(s, configured, args.show_hosts) for s in servers]})
212
+
213
+
214
+ def cmd_master_version(args, cfg):
215
+ from . import gameapi
216
+ region = cfg.region()
217
+ try:
218
+ v = gameapi.master_version(cfg, region)
219
+ except gameapi.GameApiError as e:
220
+ sys.exit(f"nnnotes: {e}")
221
+ _print_json({"region": region, "masterVersion": v.version, "resourceVersion": v.resource_version})
222
+
223
+
224
+ def cmd_master_download(args, cfg):
225
+ from . import gameapi, master
226
+ region = cfg.region()
227
+ cdn = cfg.cdn(region)
228
+ try:
229
+ version = gameapi.master_version(cfg, region).version if args.latest else args.version
230
+ r = master.download(cdn, version, Path(args.out), workers=args.workers)
231
+ except (gameapi.GameApiError, master.DownloadError) as e:
232
+ sys.exit(f"nnnotes: {e}")
233
+ _print_json(r)
234
+ if r["failed"]:
235
+ sys.exit(1)
236
+
237
+
238
+ def cmd_adv(args, cfg):
239
+ from . import adv
240
+ md = master_dir(cfg)
241
+ known_row(args, md, "MasterAdv", args.adv_id, "episode")
242
+ cat = open_catalog(cfg)
243
+ doc = adv.to_json(adv.extract(cat, md, args.adv_id))
244
+ out = Path(args.out)
245
+ out.parent.mkdir(parents=True, exist_ok=True)
246
+ write_json(out, doc)
247
+ print(f"{out} ({doc['commandCount']} commands, {len(doc['resources'])} resources)")
248
+
249
+
250
+ def cmd_live2d(args, cfg):
251
+ from . import live2d, webmodel
252
+ cfg.require_path("paths", "apk") # the Cubism component classes are read from the APK
253
+ cat = open_catalog(cfg)
254
+ try: # a model id or a model key
255
+ (key,) = webmodel.catalog_models(cat, [args.key]).values()
256
+ except ValueError as e:
257
+ args.usage(str(e))
258
+ r = live2d.extract_model(cat, key, Path(args.out))
259
+ print(dumps(r, ensure_ascii=False))
260
+
261
+
262
+ def cmd_spot(args, cfg):
263
+ from . import spot, room, shader
264
+ cfg.require_path("paths", "apk") # the Spot / Spine component classes are read from the APK
265
+ md = master_dir(cfg)
266
+ known_row(args, md, "MasterHomeSpot", args.spot_id, "spot")
267
+ cat = open_catalog(cfg)
268
+ out = Path(args.out)
269
+ doc = spot.extract(cat, md, args.spot_id, out)
270
+ print(f"{out}/spot.json ({len(doc['characters'])} tap targets, {len(doc['skeletons'])} skeletons)")
271
+ bg = doc["master"]["_backgroundAssetPath"]
272
+ r = room.extract_room(cat, bg, out / "room.glb")
273
+ print(f"{out}/room.glb ({r['meshCount']} meshes, {r['inactiveMeshes']} inactive)")
274
+ bundles = cat.fetch_key(bg) + cat.fetch_key(doc["master"]["_situationAssetPath"])
275
+ s = shader.dump(bundles, out / "shaders")
276
+ print(f"{out}/shaders/ ({s['count']} shaders, {s['variants']} variants)")
277
+
278
+
279
+ def cmd_room(args, cfg):
280
+ from . import room
281
+ cat = open_catalog(cfg)
282
+ known_keys(args, cat, [args.key])
283
+ r = room.extract_room(cat, args.key, Path(args.out))
284
+ print(f"{r['glb']} ({r['meshCount']} meshes, {r['inactiveMeshes']} inactive)")
285
+
286
+
287
+ def cmd_shader(args, cfg):
288
+ from . import shader
289
+ cat = open_catalog(cfg)
290
+ if args.key:
291
+ known_keys(args, cat, [args.key])
292
+ bundles = cat.fetch_key(args.key)
293
+ else:
294
+ if cat.apk is None:
295
+ cfg.require_path("paths", "apk")
296
+ bundles = []
297
+ for s in args.apk_bundle:
298
+ try:
299
+ bundles.append(cat.apk_bundle(s))
300
+ except KeyError as e: # none or several bundles match
301
+ args.usage(f"--apk-bundle {e.args[0]}")
302
+ r = shader.dump(bundles, Path(args.out))
303
+ print(f"{r['count']} shaders, {r['variants']} variants")
304
+ for n in r["names"]:
305
+ print(f" {n}")
306
+
307
+
308
+ def cmd_audio(args, cfg):
309
+ from . import cri
310
+ flac = _flac_options(args)
311
+ cfg.require_path("paths", "apk") # the HCA keycode is read from the APK
312
+ cat = open_catalog(cfg)
313
+ if not cat.has(f"Cri/Sound/{args.cue_sheet}"):
314
+ args.usage(f"cue sheet {args.cue_sheet}: no key Cri/Sound/{args.cue_sheet} in the catalog")
315
+ out = Path(args.out)
316
+ r = cri.decode(cat, args.cue_sheet, out, fmt=args.format, **flac)
317
+ print(f"{out} ({len(r)} cues)")
318
+
319
+
320
+ def cmd_crikey(args, cfg):
321
+ from . import crikey
322
+ cfg.require_path("paths", "apk")
323
+ k = crikey.find_key(_existing(cfg, "paths", "apk"))
324
+ print(f"HCA keycode found ({len(str(k))} digits)" if k else "no HCA keycode (decryption disabled)")
325
+ if args.write and k:
326
+ d = Path(args.write)
327
+ d.mkdir(parents=True, exist_ok=True)
328
+ print(crikey.write_hcakey(k, d))
329
+
330
+
331
+ def cmd_player(args, cfg):
332
+ g = player_data(cfg).graphics()
333
+ out = Path(args.out)
334
+ out.parent.mkdir(parents=True, exist_ok=True)
335
+ write_json(out, g)
336
+ print(f"{out} ({g['colorSpace']}, {len(g['qualityLevels'])} quality levels, "
337
+ f"{len(g['renderers'])} renderers)")
338
+
339
+
340
+ def cmd_story(args, cfg):
341
+ from . import languages, story
342
+ if args.fonts == "game":
343
+ from .tmpfont import require_extra
344
+ require_extra("--fonts game")
345
+ flac = _flac_options(args)
346
+ languages.check(cfg.require("catalog", "language")) # the story UI's language (advui reads the setting)
347
+ md = master_dir(cfg)
348
+ known_row(args, md, "MasterAdv", args.adv_id, "episode")
349
+ cat = open_catalog(cfg)
350
+ r = story.build(cat, md, player_data(cfg), args.adv_id, Path(args.out),
351
+ audio_format=args.format, audio=not args.no_audio, fonts=args.fonts, audio_options=flac)
352
+ _print_json(r)
353
+
354
+
355
+ def _flac_options(args) -> dict:
356
+ """The cri.decode options of --flac-level ({} without it); a level out of range, or the flag where no FLAC is
357
+ written, is a usage error."""
358
+ level = args.flac_level
359
+ if level is None:
360
+ return {}
361
+ if not 0 <= level <= 12:
362
+ args.usage(f"--flac-level {level}: expected 0 to 12")
363
+ if args.format != "flac":
364
+ args.usage(f"--flac-level: --format {args.format} writes no FLAC")
365
+ if getattr(args, "no_audio", False):
366
+ args.usage("--flac-level: --no-audio decodes no audio")
367
+ return {"flac_level": level}
368
+
369
+
370
+ def _fonts_extra(fonts: str) -> None:
371
+ """`--fonts game` needs the optional `fonts` dependencies."""
372
+ if fonts == "game":
373
+ from .tmpfont import require_extra
374
+ require_extra("--fonts game")
375
+
376
+
377
+ def _live_options(args) -> dict:
378
+ """The --live-option specs as a request (liveoptions.parse_specs); a malformed spec is a usage error."""
379
+ from .liveoptions import OptionSpecError, parse_specs
380
+ try:
381
+ return parse_specs(args.live_option)
382
+ except OptionSpecError as e:
383
+ args.usage(str(e))
384
+
385
+
386
+ def cmd_live(args, cfg):
387
+ from . import languages, live, liveoptions
388
+ from .web import all_pairs
389
+ _fonts_extra(args.fonts)
390
+ request = _live_options(args)
391
+ flac = _flac_options(args)
392
+ language = languages.check(cfg.require("catalog", "language"))
393
+ md = master_dir(cfg)
394
+ known_row(args, md, "MasterLiveMusic", args.music_id, "music")
395
+ if (args.music_id, args.difficulty) not in all_pairs(md):
396
+ args.usage(f"music {args.music_id}: no {args.difficulty} chart (no MasterLiveMusicScore row)")
397
+ try:
398
+ options = liveoptions.resolve(request, md)
399
+ except liveoptions.OptionSpecError as e:
400
+ args.usage(str(e))
401
+ cat = open_catalog(cfg)
402
+ try: # an unknown --leader-card, a --band without its scene keys
403
+ live.resolve_band(cat, md, args.music_id, band=args.band, leader_card=args.leader_card)
404
+ except (KeyError, ValueError) as e:
405
+ args.usage(e.args[0] if e.args else type(e).__name__)
406
+ r = live.build(cat, md, player_data(cfg), args.music_id, args.difficulty, Path(args.out),
407
+ audio_format=args.format, band=args.band, leader_card=args.leader_card, language=language,
408
+ fonts=args.fonts, options=options, **flac)
409
+ _print_json(r)
410
+
411
+
412
+ def cmd_web(args, cfg):
413
+ from . import liveoptions, storysite, web, webmodel
414
+ live_options = _live_options(args)
415
+ charts, models = bool(args.pair or args.all), bool(args.live2d or args.all_live2d)
416
+ stories = bool(args.story or args.all_stories)
417
+ if not (charts or models or stories or args.player_only or args.reingest_json):
418
+ args.usage("one of the arguments --pair --all --live2d --all-live2d --story --all-stories --player-only "
419
+ "--reingest-json is required")
420
+ if models and (args.player_only or args.reingest_json):
421
+ args.usage("--player-only and --reingest-json build nothing: leave out --live2d / --all-live2d")
422
+ if stories and (args.player_only or args.reingest_json):
423
+ args.usage("--player-only and --reingest-json build nothing: leave out --story / --all-stories")
424
+ if live_options and not charts:
425
+ args.usage("--live-option applies to charts: give --pair or --all")
426
+ player = cfg.path("paths", "player")
427
+ out = Path(args.out)
428
+ if args.player_only or args.reingest_json:
429
+ r = {**(web.reingest_json(out) if args.reingest_json else {}),
430
+ **web.write_player(out, web.check_player(player)), **web.write_index(out)}
431
+ else:
432
+ web.check_player(player)
433
+ regions = (web.site_regions(cfg, args.web_regions, args.all_regions)
434
+ if args.web_regions or args.all_regions else None) # None: the one [catalog] region
435
+ base = {"region": regions[0]} if regions else {}
436
+ unknown = web.unknown_pairs(cfg, args.pair, regions) if args.pair else []
437
+ if unknown:
438
+ args.usage(f"chart{'s' if len(unknown) > 1 else ''} {', '.join(f'{m}:{d}' for m, d in unknown)}: no "
439
+ f"MasterLiveMusicScore row in the master data of the site's region(s)")
440
+ if live_options: # a value a region's master data does not have
441
+ try:
442
+ for md in web.region_masters(cfg, web.site_regions(cfg, regions)).values():
443
+ liveoptions.resolve(live_options, md)
444
+ except liveoptions.OptionSpecError as e:
445
+ args.usage(str(e))
446
+ r = {}
447
+ if models:
448
+ cfg.require_path("paths", "apk") # the Cubism component classes and mask materials: the APK
449
+ try:
450
+ selected = webmodel.catalog_models(open_catalog(cfg, bundles=False, **base), args.live2d)
451
+ except ValueError as e:
452
+ args.usage(str(e))
453
+ r.update(webmodel.build(out, selected, cfg, player, force=args.force, tmp_dir=args.tmp,
454
+ workers=args.workers, **base))
455
+ if charts:
456
+ _fonts_extra(args.fonts)
457
+ r.update(web.build(out, None if args.all else args.pair, cfg, player, args.format,
458
+ audio=not args.no_audio, force=args.force, tmp_dir=args.tmp, workers=args.workers,
459
+ band=args.band, leader_card=args.leader_card, regions=regions, fonts=args.fonts,
460
+ read_workers=args.read_workers, live_options=live_options))
461
+ if stories:
462
+ from .tmpfont import require_extra
463
+ require_extra("--story / --all-stories") # the font assets of the story text (open and game)
464
+ cfg.require_path("paths", "apk") # the ADV settings and UI are embedded content of the APK
465
+ unknown = storysite.unknown_stories(cfg, args.story, regions) if args.story else []
466
+ if unknown:
467
+ noun = "stories" if len(unknown) > 1 else "story"
468
+ args.usage(f"{noun} {', '.join(map(str, unknown))}: no MasterAdv row in the master data of the "
469
+ f"site's region(s)")
470
+ r.update(storysite.build(out, args.story, cfg, player, args.format, audio=not args.no_audio,
471
+ force=args.force, tmp_dir=args.tmp, workers=args.workers, regions=regions,
472
+ story_languages=args.story_languages, fonts=args.fonts,
473
+ fonts_flags=dict(args.font or [])))
474
+ _print_json(r)
475
+ if r.get("failed") or r.get("modelsFailed") or r.get("storiesFailed"):
476
+ sys.exit(1)
477
+
478
+
479
+ def cmd_deck_data(args, cfg):
480
+ from . import deckdata
481
+ if args.apk_master:
482
+ cfg.require_path("paths", "apk") # the master data files ship in the APK
483
+ apk = _existing(cfg, "paths", "apk")
484
+ try:
485
+ if args.apk_master:
486
+ src, region = deckdata.apk_master(apk), deckdata.EMBEDDED
487
+ else:
488
+ src, region = deckdata.master_files(Path(args.master_files)), cfg.region()
489
+ key = master_key(cfg)
490
+ cat = open_catalog(cfg)
491
+ r = deckdata.export(Path(args.out), src, key, deckdata.catalog_fetch(cat), region=region,
492
+ client=deckdata.apk_client(apk) if apk is not None else {},
493
+ catalog=deckdata.catalog_info(cat, cli_assets.store_root(args, cfg)))
494
+ except deckdata.DeckDataError as e:
495
+ sys.exit(f"nnnotes: {e}")
496
+ _print_json(r)
497
+
498
+
499
+ # ---------------------------------------------------------------- parser
500
+ def parse_pair(s: str) -> tuple[int, str]:
501
+ m = re.fullmatch(r"(\d+)[:_](easy|normal|hard|expert)", s)
502
+ if not m:
503
+ raise argparse.ArgumentTypeError(f"{s}: expected <musicId>:<difficulty>")
504
+ return int(m.group(1)), m.group(2)
505
+
506
+
507
+ def parse_languages(s: str) -> list[str]:
508
+ from .languages import LANGUAGES
509
+ codes = [c.strip() for c in s.split(",") if c.strip()]
510
+ if not codes or any(c not in LANGUAGES for c in codes):
511
+ raise argparse.ArgumentTypeError(f"{s}: expected languages from {', '.join(LANGUAGES)}, comma-separated")
512
+ return codes
513
+
514
+
515
+ EMOJI_FONT = "emoji" # --font emoji=<file>, as storysite.EMOJI_FONT
516
+
517
+
518
+ def parse_font(s: str) -> tuple[str, str]:
519
+ from .languages import LANGUAGES
520
+ lang, sep, path = s.partition("=")
521
+ if not sep or (lang not in LANGUAGES and lang != EMOJI_FONT) or not path:
522
+ raise argparse.ArgumentTypeError(f"{s}: expected <language>=<font file> or {EMOJI_FONT}=<emoji font file>, "
523
+ f"language one of {', '.join(LANGUAGES)}")
524
+ return lang, path
525
+
526
+
527
+ def _live_option_arg(c) -> None:
528
+ c.add_argument("--live-option", action="append", metavar="OPTION[=VALUES]",
529
+ help="also export the files of a Live option's variants (repeatable): MirrorChart, "
530
+ "MeasureLineDisplay, NoteDesignId, NoteEffectId, LiveQuality, NoteSePatternId (every value "
531
+ "of the master data, or =v,v...), defaults (the option defaults and ranges only) or all")
532
+
533
+
534
+ def _band_args(c) -> None:
535
+ g = c.add_mutually_exclusive_group()
536
+ g.add_argument("--band", type=int, help="band of the LightWeight background and start timeline "
537
+ "(default: band of the music's first vocal character)")
538
+ g.add_argument("--leader-card", type=int, help="deck centre MasterMemberCard id (its character's band)")
539
+
540
+
541
+ def _fonts_arg(c) -> None:
542
+ c.add_argument("--fonts", default="open", choices=("open", "game"),
543
+ help="start canvas text: open: layout and style only, no font data (default); game: also the "
544
+ "game's TMP fonts (needs the 'fonts' extra)")
545
+
546
+
547
+ def _audio_args(c) -> None:
548
+ """--format and --flac-level of a command that decodes cue sheets."""
549
+ c.add_argument("--format", default="flac", choices=AUDIO_CHOICES, help="audio file format (default flac)")
550
+ c.add_argument("--flac-level", type=int, metavar="N",
551
+ help="FLAC compression level 0-12 of --format flac (default 8; every level decodes to the same "
552
+ "samples)")
553
+
554
+
555
+ def _out(c, what: str, required: bool = True) -> None:
556
+ c.add_argument("-o", "--out", required=required, help=what)
557
+
558
+
559
+ def build_parser() -> argparse.ArgumentParser:
560
+ p = argparse.ArgumentParser(prog="nnnotes", description="BanG Dream! Our Notes data toolkit")
561
+ p.add_argument("--version", action="version", version=f"nnnotes {__version__}")
562
+ p.add_argument("--config", help="TOML config file (else NNNOTES_CONFIG, else ./nnnotes.toml)")
563
+ p.add_argument("--region", help="region: a [servers.<region>] table ([catalog] region)")
564
+ p.add_argument("--language", help="catalog and client language: ja, en, zh-Hant, zh-Hans or ko "
565
+ "([catalog] language)")
566
+ p.add_argument("--catalog", help="catalog .bin file ([paths] catalog; else downloaded into the cache)")
567
+ p.add_argument("--cache", help="cache directory ([paths] cache)")
568
+ p.add_argument("--master", help="decoded master data directory ([paths] master)")
569
+ p.add_argument("--apk", help="base.apk ([paths] apk)")
570
+ p.add_argument("--ffmpeg", help="ffmpeg executable ([paths] ffmpeg; else on PATH)")
571
+ p.add_argument("--vgmstream", help="vgmstream-cli executable ([paths] vgmstream; else on PATH)")
572
+ p.add_argument("--node", help="Node.js executable ([paths] node; else on PATH)")
573
+ sub = p.add_subparsers(dest="cmd", required=True, metavar="<command>")
574
+
575
+ c = sub.add_parser("catalog", help="list addressable keys")
576
+ c.add_argument("--prefix", default="", help="only the keys that start with this prefix")
577
+ c.add_argument("--limit", type=int, default=200, help="print at most N keys (default 200; the total goes "
578
+ "to stderr)")
579
+ c.set_defaults(func=cmd_catalog)
580
+
581
+ c = sub.add_parser("browse", help="browse the configured regions' catalogs and bundles in a local web page")
582
+ c.add_argument("--port", type=int, default=8000, help="port (default 8000)")
583
+ c.add_argument("--host", default="127.0.0.1", help="address to listen on (default 127.0.0.1)")
584
+ c.set_defaults(func=cmd_browse)
585
+
586
+ c = sub.add_parser("pull", help="fetch the bundle closure of keys into the cache")
587
+ c.add_argument("keys", nargs="+", metavar="KEY", help="addressable keys (`catalog` lists them)")
588
+ c.set_defaults(func=cmd_pull, usage=c.error)
589
+
590
+ c = sub.add_parser("servers", help="the server list of the bootstrap API root: regions, their CDN and API roots")
591
+ c.add_argument("--show-hosts", action="store_true", help="print the CDN and API roots, not only their counts")
592
+ c.set_defaults(func=cmd_servers)
593
+
594
+ c = sub.add_parser("master", help="master data files")
595
+ msub = c.add_subparsers(dest="master_cmd", required=True, metavar="<master command>")
596
+ m = msub.add_parser("version", help="the master data and resource version the region serves now (game API)")
597
+ m.set_defaults(func=cmd_master_version)
598
+ m = msub.add_parser("decode", help="master data .bin files -> <Table>.json")
599
+ m.add_argument("inputs", nargs="+", type=Path, help="directories (their *.bin files) or files")
600
+ _out(m, "output directory")
601
+ m.add_argument("--workers", type=int, default=8, help="parallel decodes")
602
+ m.set_defaults(func=cmd_master_decode)
603
+ m = msub.add_parser("download", help="a master data version from the region's CDN (SHA-256 checked)")
604
+ g = m.add_mutually_exclusive_group(required=True)
605
+ g.add_argument("--version", help="master data version")
606
+ g.add_argument("--latest", action="store_true", help="the version the region serves now (`master version`)")
607
+ _out(m, "directory for MasterManifest.json and the .bin files")
608
+ m.add_argument("--workers", type=int, default=16, help="parallel downloads")
609
+ m.set_defaults(func=cmd_master_download)
610
+
611
+ c = sub.add_parser("adv", help="ADV episode -> JSON")
612
+ c.add_argument("adv_id", type=int, help="MasterAdv id of the episode")
613
+ _out(c, "output .json file")
614
+ c.set_defaults(func=cmd_adv, usage=c.error)
615
+
616
+ c = sub.add_parser("story", help="ADV episode -> story dir (episode, models, audio, scene, UI, media, videos)")
617
+ c.add_argument("adv_id", type=int, help="MasterAdv id of the episode")
618
+ _out(c, "output directory")
619
+ _audio_args(c)
620
+ c.add_argument("--no-audio", action="store_true", help="do not decode the cue sheets")
621
+ c.add_argument("--fonts", default="open", choices=("open", "game"),
622
+ help="open: text layout and style only, no font data (default); game: also the game's TMP fonts "
623
+ "(needs the 'fonts' extra)")
624
+ c.set_defaults(func=cmd_story, usage=c.error)
625
+
626
+ c = sub.add_parser("live2d", help="Live2D model -> runtime model dir")
627
+ c.add_argument("key", help="model key Character/Live2D/<group>/<name>/model/<name>, or its model id <name>")
628
+ _out(c, "output directory")
629
+ c.set_defaults(func=cmd_live2d, usage=c.error)
630
+
631
+ c = sub.add_parser("spot", help="spot -> spot.json + Spine + room.glb + shaders")
632
+ c.add_argument("spot_id", type=int, help="MasterSpot id")
633
+ _out(c, "output directory")
634
+ c.set_defaults(func=cmd_spot, usage=c.error)
635
+
636
+ c = sub.add_parser("room", help="background prefab -> glb")
637
+ c.add_argument("key", help="addressable key of the prefab")
638
+ _out(c, "output .glb file")
639
+ c.set_defaults(func=cmd_room, usage=c.error)
640
+
641
+ c = sub.add_parser("shader", help="dump shaders of a key's closure or of APK bundles")
642
+ g = c.add_mutually_exclusive_group(required=True)
643
+ g.add_argument("--key", help="addressable key: the shaders of its bundle closure")
644
+ g.add_argument("--apk-bundle", nargs="+", help="substring(s) of APK bundle file names")
645
+ _out(c, "output directory")
646
+ c.set_defaults(func=cmd_shader, usage=c.error)
647
+
648
+ c = sub.add_parser("audio", help="decode a CRI cue sheet")
649
+ c.add_argument("cue_sheet", help="cue sheet name: the key Cri/Sound/<cue_sheet> of the catalog")
650
+ _out(c, "output directory")
651
+ _audio_args(c)
652
+ c.set_defaults(func=cmd_audio, usage=c.error)
653
+
654
+ c = sub.add_parser("crikey", help="find the HCA keycode in base.apk")
655
+ c.add_argument("--write", help="directory to write .hcakey into")
656
+ c.set_defaults(func=cmd_crikey)
657
+
658
+ c = sub.add_parser("player", help="player graphics settings -> JSON")
659
+ _out(c, "output .json file")
660
+ c.set_defaults(func=cmd_player)
661
+
662
+ c = sub.add_parser("live", help="live (music + difficulty) -> self-contained live dir")
663
+ c.add_argument("music_id", type=int, help="MasterLiveMusic id")
664
+ c.add_argument("--difficulty", default="expert", choices=DIFFICULTY_CHOICES,
665
+ help="chart difficulty (default expert)")
666
+ _audio_args(c)
667
+ _fonts_arg(c)
668
+ _band_args(c)
669
+ _live_option_arg(c)
670
+ _out(c, "output directory")
671
+ c.set_defaults(func=cmd_live, usage=c.error)
672
+
673
+ c = sub.add_parser("web", help="live charts, Live2D models and stories -> static site for ournotes-player "
674
+ "(shared player + per-chart / per-model / per-story data)")
675
+ c.add_argument("out", help="site directory")
676
+ c.add_argument("--player", help="ournotes-player checkout (built) or installed package ([paths] player)")
677
+ g = c.add_mutually_exclusive_group()
678
+ g.add_argument("--pair", type=parse_pair, action="append", help="<musicId>:<difficulty> (repeatable)")
679
+ g.add_argument("--all", action="store_true", help="every MasterLiveMusic x difficulty")
680
+ g.add_argument("--player-only", action="store_true",
681
+ help="rewrite the player files, charts.json and models.json only")
682
+ g.add_argument("--reingest-json", action="store_true", help="store every chart's and model's JSON files again")
683
+ m = c.add_mutually_exclusive_group()
684
+ m.add_argument("--live2d", action="append", metavar="MODEL",
685
+ help="Live2D model id or key Character/Live2D/<group>/<name>/model/<name> (repeatable)")
686
+ m.add_argument("--all-live2d", action="store_true", help="every Live2D model of the catalog")
687
+ c.add_argument("--format", default=DEFAULT_AUDIO_FORMAT, choices=tuple(WEB_AUDIO), help="BGM format")
688
+ c.add_argument("--no-audio", action="store_true", help="export no audio files")
689
+ c.add_argument("--force", action="store_true", help="rebuild charts and models whose manifest exists")
690
+ c.add_argument("--tmp", help="directory for the temporary live and model builds (default <site>.tmp)")
691
+ c.add_argument("--workers", type=int,
692
+ help="parallel music / model processes (default a quarter of the CPUs, up to 8 / up to 4; "
693
+ "1 = this process)")
694
+ c.add_argument("--read-workers", type=int,
695
+ help="chart read sets run at a time (default half the CPUs, up to 16)")
696
+ t = c.add_mutually_exclusive_group()
697
+ t.add_argument("--story", type=int, action="append", metavar="ADV_ID",
698
+ help="story episode: its MasterAdv id (repeatable)")
699
+ t.add_argument("--all-stories", action="store_true", help="every story episode (MasterAdv) of the master data")
700
+ c.add_argument("--story-languages", type=parse_languages, metavar="LANGS",
701
+ help="languages of the stories' text, comma-separated (default: ja,en,zh-Hant,zh-Hans,ko)")
702
+ c.add_argument("--font", type=parse_font, action="append", metavar="LANG=PATH",
703
+ help="font file the story text of a language is drawn with (repeatable; [paths] fonts.<lang>); "
704
+ "emoji=PATH: the colour emoji font the emoji sprites are drawn from ([paths] fonts.emoji)")
705
+ r = c.add_mutually_exclusive_group()
706
+ r.add_argument("--region", dest="web_regions", action="append", metavar="REGION",
707
+ help="a region the site serves: a [servers.<region>] table (repeatable; the first is the base; "
708
+ "default: [catalog] region)")
709
+ r.add_argument("--all-regions", action="store_true", help="every configured region")
710
+ c.add_argument("--fonts", default="open", choices=("open", "game"),
711
+ help="text of the start canvas and the stories: open (default): the start canvas has layout and "
712
+ "style only, the stories glyphs from your font files (--font); game: the game's TMP fonts "
713
+ "(needs the 'fonts' extra)")
714
+ _band_args(c)
715
+ _live_option_arg(c)
716
+ c.set_defaults(func=cmd_web, usage=c.error)
717
+
718
+ c = sub.add_parser("deck-data", help="every live chart and the master data tables deck-building tools read -> "
719
+ "one JSON file")
720
+ g = c.add_mutually_exclusive_group(required=True)
721
+ g.add_argument("--master-files", metavar="DIR",
722
+ help="master data files as served: MasterManifest.json and the .bin files it lists "
723
+ "(`master download`)")
724
+ g.add_argument("--apk-master", action="store_true", help="the master data files of base.apk ([paths] apk)")
725
+ _out(c, "output file (.json, or .json.gz for gzip)")
726
+ c.set_defaults(func=cmd_deck_data, usage=c.error)
727
+
728
+ cli_assets.register(sub, argparse.Namespace(open_catalog=open_catalog, print_json=_print_json))
729
+ voices.register(sub, argparse.Namespace(open_catalog=open_catalog, master_dir=master_dir))
730
+ return p
731
+
732
+
733
+ def main(argv=None):
734
+ args = build_parser().parse_args(argv)
735
+ try:
736
+ cfg = load_config(args)
737
+ args.func(args, cfg)
738
+ except ConfigError as e:
739
+ print(f"nnnotes: {e}", file=sys.stderr)
740
+ sys.exit(2)
741
+
742
+
743
+ if __name__ == "__main__":
744
+ main()