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/livenotes.py ADDED
@@ -0,0 +1,291 @@
1
+ """Live note / effect runtime data -> livenotes/notes.json + textures + shaders.
2
+
3
+ Everything spawned or animated per note in the in-play note highway:
4
+ note view prefabs (tap, flick +left/right/direction,
5
+ trace, slide/slide_end/connection, guide, slide_combo(+skip), none), long-note
6
+ body prefabs (slide_line_view, guide_line_view), the pair line prefab,
7
+ the note skin asset (sprites per note type/width/tilt), the note-effect skin settings
8
+ of the NoteEffectId option and the lane-effect settings (set effect001) with every effect
9
+ prefab they reference (particle systems exported with all module data), the judgement text view and
10
+ the judgement / combo sprite assets, the combo animator, the note animation controllers
11
+ and clips they reference, and the option / master values the motion formulas read.
12
+ Only what the default options load: the effect set of NoteEffectId (307) at quality Middle
13
+ (the `<name>Light` set is Low only, `NoteSkinLoadStep.GetLightQualityAddress`; the
14
+ `<name>Simple` sets are not used with default options); no bar / skill line prefabs
15
+ (MeasureLineDisplay 109 / LiveSkillActivationPositionDisplay 309 FALSE). With liveoptions.LiveOptions
16
+ also: the other offered note skins (`noteSkins`, `settings.skins`), the other offered note effect sets
17
+ and, with LiveQuality Low offered, their `Light` variants where the catalog has them (in `assets`,
18
+ `settings.effects`), with MeasureLineDisplay offered the bar line view (`prefabs.bar_line_view`: the
19
+ LiveBarLineView prefab the live scene's LiveBarLineViewContainer instantiates, its _elementPrefab), and the
20
+ option tables (`settings.optionDefaults` / `optionRanges` of every item the player offers).
21
+
22
+ Objects go through export.Exporter in its "livenotes" clip format (clips and animator
23
+ controllers in registries referenced by id; binding paths and attributes left as
24
+ "crc32:<n>" until a pass names them), following references to AnimatorControllers
25
+ and SpriteAtlases. Clip binding paths that point into the Live scene's UI subtrees
26
+ are named from that scene (`add_path_source`), which is not exported here.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+
34
+ from .catalog import Catalog
35
+ from .export import Exporter
36
+ from .jsonio import write_json
37
+ from .liveoptions import OPTION_ITEM_TYPES, PLAYER_ITEMS, LiveOptions
38
+ from .livescene import LANE_COUNT, LANE_VIEW
39
+ from .player import PlayerData
40
+ from .score import master_table
41
+
42
+ # --- keys ------------------------------------------------------------------
43
+ NOTE_VIEWS = ["tap", "flick", "flick_left", "flick_right", "trace", "slide", "slide_end",
44
+ "connection", "guide", "slide_combo", "slide_combo_skip", "none"]
45
+ PREFABS = {f"{v}_note_view": f"EmbLive/Prefabs/LiveGame/{v}_note_view" for v in NOTE_VIEWS}
46
+ PREFABS.update({
47
+ "slide_line_view": "EmbLive/Prefabs/LiveGame/slide_line_view",
48
+ "guide_line_view": "EmbLive/Prefabs/LiveGame/guide_line_view",
49
+ "pair_note_line": "EmbLive/Prefabs/LiveGame/PairNoteLine/LiveGamePairNoteView",
50
+ "note_groove_loop": "EmbLive/Prefabs/LiveGame/Effect/note_groove_loop",
51
+ "judge_effect_view": "EmbLive/Prefabs/UILiveNoteJudgeEffectView",
52
+ })
53
+
54
+ # --- defaults (fresh profile) -------------------------------------------------
55
+ # MasterOptionDefault preset 1 (CurrentPresetIndex 0 -> Preset1); item types from App.Options.OptionItemType.
56
+ OPTION_ITEMS = {k: OPTION_ITEM_TYPES[k] for k in (1, 4, 6, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109,
57
+ 112, 113, 117, 119, 200, 205, 206, 207, 208, 300, 301, 302,
58
+ 305, 306, 307, 308, 309, 310)}
59
+ LIVE_SETTING_KEYS = ["note_speed_min", "note_speed_max", "note_speed_view_min", "note_speed_view_max",
60
+ "note_slide_combo_rhythmic_unit", "stop_in_vain_time_ms_diff",
61
+ "slide_offset_min_note_lane_width", "slide_offset_max_note_lane_width",
62
+ "note_overlap_lane_buffer"]
63
+ DEFAULT_SKIN = "skin001" # MasterLiveNoteSkin._id 1 (NoteDesignId default 1)
64
+ DEFAULT_EFFECT = "effect001" # MasterLiveNoteEffectSkin._id 1 (NoteEffectId default 1)
65
+
66
+ # Catalog key families exported whole (every key under the prefix): the note effect settings of the NoteEffectId
67
+ # option's set ({effect}; LiveAddressablePath.GetLiveNoteEffectSkinAssetPath) and the lane effect settings, whose
68
+ # set is always LANE_EFFECT (LaneEffectLoadStep.GetAssetPath passes that constant to
69
+ # LiveAddressablePath.GetLiveLaneEffectSkinAssetPath), with the effect prefabs they reference by GameObject
70
+ LANE_EFFECT = "effect001"
71
+ KEY_PREFIXES = [
72
+ "Effect/Live/NoteEffect/{effect}/",
73
+ f"Effect/Live/LaneEffect/{LANE_EFFECT}/",
74
+ ]
75
+ SINGLE_KEYS = [
76
+ "EmbLive/Animation/Combo/UILiveCombo", # UIComboCounterView's animator controller
77
+ "Live/UiSpriteAssets/LiveComboSpriteAsset",
78
+ "Live/UiSpriteAssets/LiveJudgementSpriteAsset",
79
+ ]
80
+
81
+
82
+ def settings(master: Path | None, options: LiveOptions = LiveOptions()) -> dict:
83
+ """Option defaults and master values the note runtime reads; with `options` also the offered note skins
84
+ (`skins`: NoteDesignId -> MasterLiveNoteSkin._assetName) and effect sets (`effects`: NoteEffectId ->
85
+ MasterLiveNoteEffectSkin._assetName; also when only other qualities are offered, whose effect set name the
86
+ player derives from it), and with its option tables the items of PLAYER_ITEMS as well."""
87
+ out: dict = {"skin": DEFAULT_SKIN, "effect": DEFAULT_EFFECT, "laneCount": LANE_COUNT,
88
+ "laneSize": list(LANE_VIEW["size"]), "laneTopRange": LANE_VIEW["topRange"],
89
+ "laneBottomRange": LANE_VIEW["bottomRange"],
90
+ "judgementScreenBottomPosition": LANE_VIEW["judgementScreenBottomPosition"],
91
+ "laneTopPosition": LANE_VIEW["topPosition"],
92
+ "noteViewProgress": {"base": 1.065, "exponentScale": 45.0},
93
+ "tiltCenterLane": 11.5}
94
+ if master is None:
95
+ return out
96
+ items = OPTION_ITEMS
97
+ if options.tables:
98
+ items = {**OPTION_ITEMS, **{k: OPTION_ITEM_TYPES[k] for k in PLAYER_ITEMS}}
99
+ opts = {}
100
+ for r in master_table(master, "MasterOptionDefault"):
101
+ if r["_presetId"] == 1 and r["_optionItemType"] in items:
102
+ opts[items[r["_optionItemType"]]] = r["_valueString"]
103
+ out["optionDefaults"] = opts
104
+ out["optionRanges"] = {items.get(r["_optionItemType"], r["_optionItemType"]): [r["_minValue"], r["_maxValue"]]
105
+ for r in master_table(master, "MasterOptionRange") if r["_optionItemType"] in items}
106
+ ls = {r["_key"]: r["_value"] for r in master_table(master, "MasterLiveSettings")}
107
+ out["liveSettings"] = {k: ls[k] for k in LIVE_SETTING_KEYS if k in ls}
108
+ skins = {r["_id"]: r["_assetName"] for r in master_table(master, "MasterLiveNoteSkin")}
109
+ effects = {r["_id"]: r["_assetName"] for r in master_table(master, "MasterLiveNoteEffectSkin")}
110
+ out["skin"] = skins[int(opts["NoteDesignId"])]
111
+ out["effect"] = effects[int(opts["NoteEffectId"])]
112
+ out["judgementSprites"] = {r["_noteSimulateJudgement"]: r["_spriteName"]
113
+ for r in master_table(master, "MasterLiveJudgementSprite")}
114
+ out["noteDisplayTimeMs"] = display_time_ms(float(opts["NoteSpeed"]), float(ls["note_speed_min"]),
115
+ float(ls["note_speed_max"]), float(ls["note_speed_view_min"]),
116
+ float(ls["note_speed_view_max"]))
117
+ if options.designs:
118
+ out["skins"] = {str(i): skins[i] for i in options.designs}
119
+ if options.effects or options.qualities:
120
+ out["effects"] = {str(i): effects[i] for i in options.values("NoteEffectId")}
121
+ return out
122
+
123
+
124
+ def display_time_ms(speed: float, smin: float, smax: float, vmin: float, vmax: float) -> int:
125
+ """NoteBeforePlayingTimeGetter.GetNoteDisplayOffsetTimeMs, in float32."""
126
+ f = np.float32
127
+ t = max(f(0), f(speed) - f(smin)) / (f(smax) - f(smin))
128
+ t = min(max(t, f(0)), f(1))
129
+ e = f(1) - f(np.power(f(1) - t, f(1.31)))
130
+ e = min(max(e, f(0)), f(1))
131
+ return int(f((f(vmin) + (f(vmax) - f(vmin)) * e) * f(1000)))
132
+
133
+
134
+ # UI subtrees of [EmbScene/Live] animated by exported clips (combo counter, judgement text); used only to name
135
+ # clip binding paths, the scene itself is exported by livescene.
136
+ SCENE_KEY = "EmbScene/Live"
137
+ SCENE_SUBTREES = ("UILiveCombo", "UILiveJudgement")
138
+ # The bar line view (MeasureLineDisplay): the scene's LiveAllNoteView -> _barLineView (LiveAllBarLineView) ->
139
+ # _container (LiveBarLineViewContainer) -> _elementPrefab (LiveBarLineView, the prefab the container instantiates)
140
+ NOTE_VIEW = ("LiveGameView/LiveGameCamera/screen_root/LiveGameAllNoteView", "LiveAllNoteView")
141
+ BAR_LINE_CHAIN = (("_barLineView", "LiveAllBarLineView"), ("_container", "LiveBarLineViewContainer"),
142
+ ("_elementPrefab", "LiveBarLineView"))
143
+ BAR_LINE_VIEW = "bar_line_view"
144
+
145
+
146
+ TMP_FONT_NOTE = ("not exported: the note highway draws no TextMeshPro text with default options (the judge view's "
147
+ "fast / slow and assist texts stay hidden)")
148
+
149
+
150
+ def _drop_tmp_fonts(doc: dict, root: Path) -> list[str]:
151
+ """Reduce the font asset and material of every TextMeshProUGUI record to a name stub and delete the texture
152
+ files no record references any more (the TMP font atlases). Returns the deleted files."""
153
+ def walk(v):
154
+ if isinstance(v, dict):
155
+ if v.get("class") == "TextMeshProUGUI" and "m_fontAsset" in v:
156
+ fa, mat = v["m_fontAsset"], v.get("m_sharedMaterial")
157
+ if isinstance(fa, dict):
158
+ v["m_fontAsset"] = {"asset": fa.get("asset"), "name": fa.get("name"), "note": TMP_FONT_NOTE}
159
+ if isinstance(mat, dict):
160
+ v["m_sharedMaterial"] = {"material": mat.get("material"), "shader": mat.get("shader"),
161
+ "note": TMP_FONT_NOTE}
162
+ for x in v.values():
163
+ walk(x)
164
+ elif isinstance(v, list):
165
+ for x in v:
166
+ walk(x)
167
+ walk(doc)
168
+ used: set[str] = set()
169
+
170
+ def refs(v):
171
+ if isinstance(v, dict):
172
+ t = v.get("texture")
173
+ if isinstance(t, str):
174
+ used.add(t)
175
+ for x in v.values():
176
+ refs(x)
177
+ elif isinstance(v, list):
178
+ for x in v:
179
+ refs(x)
180
+ refs(doc)
181
+ dropped = []
182
+ for f in sorted((root / "textures").glob("*.png")):
183
+ rel = f"textures/{f.name}"
184
+ if rel not in used:
185
+ f.unlink()
186
+ dropped.append(rel)
187
+ return dropped
188
+
189
+
190
+ def skin_key(name: str) -> str:
191
+ """The note skin asset of MasterLiveNoteSkin._assetName `name` (LiveAddressablePath.GetLiveNoteSkinAssetPath)."""
192
+ return f"Live/Note/{name}/LiveNoteSkinAsset"
193
+
194
+
195
+ def effect_settings_key(effect: str) -> str:
196
+ """The note effect settings asset of effect set `effect` (LiveAddressablePath.GetLiveNoteEffectSkinAssetPath)."""
197
+ return f"Effect/Live/NoteEffect/{effect}/LiveNoteEffectAssetSettings"
198
+
199
+
200
+ def effect_keys(cat: Catalog, effect: str) -> list[str]:
201
+ """The catalog keys of note effect set `effect` and of the lane effects (KEY_PREFIXES), in prefix and catalog
202
+ order."""
203
+ keys: list[str] = []
204
+ for p in KEY_PREFIXES:
205
+ keys += [k for k in cat.keys(p.format(effect=effect)) if k not in keys]
206
+ return keys
207
+
208
+
209
+ def _mono(ex: Exporter, o, cls: str):
210
+ if o is None or o.type.name != "MonoBehaviour" or ex.script_class(o) != cls:
211
+ raise RuntimeError(f"expected a {cls} MonoBehaviour, got {o and o.type.name}")
212
+ return o, o.read_typetree()
213
+
214
+
215
+ def bar_line_prefab(ex: Exporter) -> dict:
216
+ """The bar line view as a node list: the LiveBarLineView prefab the live scene's bar line container
217
+ instantiates (BAR_LINE_CHAIN from the scene's LiveAllNoteView), its root first."""
218
+ env, graph = ex.load(SCENE_KEY)
219
+ path, cls = NOTE_VIEW
220
+ gos = set(graph.gos_at_path(path))
221
+ comps = [c["component"] for go in gos for c in graph.go[go]["m_Component"]]
222
+ owners = [o for o in env.objects if o.type.name == "GameObject" and o.path_id in gos]
223
+ if len(gos) != 1 or len(owners) != 1:
224
+ raise RuntimeError(f"{SCENE_KEY}: {len(gos)} GameObjects at {path}")
225
+ hits = [c for c in (ex.deref(owners[0], p) for p in comps)
226
+ if c is not None and c.type.name == "MonoBehaviour" and ex.script_class(c) == cls]
227
+ if len(hits) != 1:
228
+ raise RuntimeError(f"{SCENE_KEY}: {len(hits)} {cls} at {path}")
229
+ o, tt = _mono(ex, hits[0], cls)
230
+ for field, want in BAR_LINE_CHAIN:
231
+ o, tt = _mono(ex, ex.deref(o, tt[field]), want)
232
+ root = graph.tf_of_go.get(tt["m_GameObject"]["m_PathID"])
233
+ if root is None or graph.tf[root]["m_Father"]["m_PathID"]:
234
+ raise RuntimeError(f"{SCENE_KEY}: the bar line view is not a prefab root of the scene's bundles")
235
+ return {"key": SCENE_KEY, "reference": ".".join(f for f, _ in BAR_LINE_CHAIN),
236
+ "nodes": ex.hierarchy(env, graph, root)}
237
+
238
+
239
+ def extract(cat: Catalog, player: PlayerData, out_dir: Path, master: Path | None = None,
240
+ options: LiveOptions = LiveOptions()) -> dict:
241
+ """livenotes/ for the default options, plus the files of the variants `options` offers (the module
242
+ docstring). The variants' objects are exported after the default ones."""
243
+ out_dir = Path(out_dir)
244
+ root = out_dir / "livenotes"
245
+ root.mkdir(parents=True, exist_ok=True)
246
+ ex = Exporter(cat, root, player=player, clip_format="livenotes", follow=("AnimatorController", "SpriteAtlas"))
247
+ doc: dict = {"settings": settings(master, options)}
248
+ skin = doc["settings"]["skin"]
249
+
250
+ # note / line prefabs first: their hierarchies resolve clip binding paths of skin/effect assets
251
+ doc["prefabs"] = {name: ex.export_key(key) for name, key in PREFABS.items()}
252
+ doc["noteSkin"] = ex.export_key(skin_key(skin))
253
+ keys = effect_keys(cat, doc["settings"]["effect"])
254
+ keys += [k for k in SINGLE_KEYS if cat.has(k) and k not in keys]
255
+ assets, failures = {}, {}
256
+
257
+ def export(ks):
258
+ for k in ks:
259
+ try:
260
+ assets[k] = ex.export_key(k)
261
+ except NotImplementedError as e:
262
+ failures[k] = str(e)
263
+ export(keys)
264
+ other_skins = [n for n in doc["settings"].get("skins", {}).values() if n != skin]
265
+ if other_skins:
266
+ doc["noteSkins"] = {n: ex.export_key(skin_key(n)) for n in other_skins}
267
+ names = {int(i): n for i, n in doc["settings"].get("effects", {}).items()}
268
+ for effect, light in options.effect_sets(names):
269
+ if not cat.has(effect_settings_key(effect)):
270
+ if light:
271
+ continue # the game loads the set itself at quality Low (LiveOptions.effect_sets)
272
+ raise KeyError(f"effect set {effect}: {effect_settings_key(effect)} not in the catalog")
273
+ export([k for k in effect_keys(cat, effect) if k not in assets and k not in failures])
274
+ if options.bar_lines:
275
+ doc["prefabs"][BAR_LINE_VIEW] = bar_line_prefab(ex)
276
+ doc["assets"] = assets
277
+ ex.add_path_source(SCENE_KEY, SCENE_SUBTREES)
278
+ unresolved = ex.resolve_pending()
279
+ doc["clips"] = ex.clips
280
+ doc["controllers"] = ex.controllers
281
+ doc["exportFailures"] = failures
282
+
283
+ dropped = _drop_tmp_fonts(doc, root)
284
+ _, shader_summary = ex.dump_shaders(root / "shaders")
285
+ doc["shaders"] = {"index": "shaders/shaders.json", "names": shader_summary["names"]}
286
+ doc["convention"] = ("Unity space (left-handed, Y up); transforms are local TRS; quaternions (x,y,z,w); "
287
+ "colours as serialized (project colour space: see player.json)")
288
+ write_json(root / "notes.json", doc)
289
+ return {"prefabs": len(doc["prefabs"]), "assets": len(assets), "clips": len(ex.clips),
290
+ "controllers": len(ex.controllers), "textures": len(ex.textures) - len(dropped), "droppedFontTextures": dropped,
291
+ "failures": failures, "unresolvedBindings": unresolved, **shader_summary}
nnnotes/liveoptions.py ADDED
@@ -0,0 +1,250 @@
1
+ """Live options that select files: which option variants a live directory carries files for.
2
+
3
+ The game reads its Live options once, when a live boots (LiveDataCreator.CreateBootData, LiveBootDataCreator
4
+ .CreateViewData, LiveSettingCreator.CreateLiveNoteSettings / CreateSESettings); a fresh profile uses option preset 1
5
+ (MasterOptionDefault rows of `_presetId` 1). A live directory holds the files those defaults read. Most options only
6
+ change values the player computes from the data; the options below also change which files a live reads, so a
7
+ viewer can offer them only when the live directory carries those files as well:
8
+
9
+ MirrorChart the score converted mirrored (SsMusicScoreConverter.Load with isMirror):
10
+ score/<chart>.mirror.notes.json, live.json `notesMirror`
11
+ NoteDesignId note skins (MasterLiveNoteSkin): livenotes settings.skins, noteSkins
12
+ NoteEffectId note effect sets (MasterLiveNoteEffectSkin): livenotes settings.effects, their assets (the lane
13
+ effect set is always effect001, LaneEffectLoadStep.GetAssetPath)
14
+ LiveQuality qualities (MasterLiveQualitySettings); Low (2) loads a note effect set's `<name>Light` variant
15
+ where it exists (NoteSkinLoadStep.GetLightQualityAddress); the chart manifest's
16
+ `options.LiveQuality`
17
+ NoteSePatternId note sound sets (MasterLiveNoteSe groups): audio/live-audio.json noteSe.groups and their sounds
18
+ MeasureLineDisplay the bar lines (LiveAllBarLineView): the bar line view the live scene's LiveBarLineViewContainer
19
+ instantiates (its _elementPrefab), livenotes prefabs.bar_line_view, and its sprite's texture
20
+
21
+ and the option tables: the preset-1 default and the range of every option item the player offers
22
+ (livenotes settings.optionDefaults / optionRanges), implied by any of the above.
23
+
24
+ A request names options as the game does (the names the player's settings use), from command-line specs:
25
+ `MirrorChart`, `NoteDesignId` (every value the master data has) or `NoteDesignId=1,3` (those values), ...,
26
+ `defaults` (the option tables only) and `all` (every option with every value). `resolve` checks a request against
27
+ the master data and gives a LiveOptions; each offered option lists its values including the preset-1 default. An
28
+ empty LiveOptions is the default output, byte for byte.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import itertools
33
+ from dataclasses import dataclass, replace
34
+ from pathlib import Path
35
+
36
+ from .score import master_table
37
+
38
+ # App.Options.OptionItemType (every value but None 0), the names of MasterOptionDefault / MasterOptionRange
39
+ # `_optionItemType`
40
+ OPTION_ITEM_TYPES = {
41
+ 1: "NoteSpeed", 2: "NoteTiming", 3: "ChartPosition", 4: "MirrorChart", 5: "AssistMode", 6: "LiveQuality",
42
+ 7: "Vibration",
43
+ 100: "FastSlowDisplay", 101: "PerfectFastSlowDisplay", 102: "JudgeOffsetMsDisplay", 103: "JudgeResultPositionType",
44
+ 104: "JudgePosition", 105: "JudgePositionDisplay", 106: "SlideOpacity", 107: "GuideOpacity",
45
+ 108: "SimultaneousLineDisplay", 109: "MeasureLineDisplay", 110: "MvQuality", 111: "MvDataRetention",
46
+ 112: "LiveSkillEffect", 113: "ComboEffect", 114: "DamageEffect", 115: "StageEffect", 116: "FcAcChallengeAssist",
47
+ 117: "PreLiveSimpleOption", 118: "FrameRate", 119: "GekisouEffect",
48
+ 200: "ScreenMode", 201: "BackgroundBrightness", 202: "MvModeBrightness", 203: "BackgroundSwitch",
49
+ 205: "SkillEffectDisplay", 206: "ComboCountDisplay", 207: "JudgeDetailDisplay", 208: "ContinuationEffectDisplay",
50
+ 300: "LaneOpacity", 301: "GuidelineOpacity", 302: "GuidelineCount", 303: "GekisouDisplay",
51
+ 304: "GekisouDisplayPositionChange", 305: "LiveSkinId", 306: "NoteDesignId", 307: "NoteEffectId",
52
+ 308: "NoteStartPosition", 309: "LiveSkillActivationPositionDisplay", 310: "GekisouSimpleEffect",
53
+ 400: "SystemBgmVolume", 401: "SystemSeVolume", 402: "SystemVoiceVolume", 403: "SystemBgmMute",
54
+ 404: "SystemSeMute", 405: "SystemVoiceMute",
55
+ 410: "LiveMusicVolume", 411: "LiveNoteSeVolume", 412: "LiveSeVolume", 413: "LiveVoiceVolume",
56
+ 414: "LiveMusicMute", 415: "LiveNoteSeMute", 416: "LiveSeMute", 417: "LiveVoiceMute",
57
+ 420: "NoteSePatternId", 421: "UseIndividualNoteSe",
58
+ 430: "EmptyTapSeId", 431: "EmptyTapSeVolume", 432: "TapSeId", 433: "TapSeVolume", 434: "FlickSeId",
59
+ 435: "FlickSeVolume", 436: "SideFlickSeId", 437: "SideFlickSeVolume", 438: "SlideSeId", 439: "SlideSeVolume",
60
+ 440: "TraceSeId", 441: "TraceSeVolume",
61
+ 450: "GekisouTapSeId", 451: "GekisouTapSeVolume", 452: "GekisouFlickSeId", 453: "GekisouFlickSeVolume",
62
+ 454: "GekisouSideFlickSeId", 455: "GekisouSideFlickSeVolume", 456: "GekisouSlideSeId",
63
+ 457: "GekisouSlideSeVolume", 458: "GekisouTraceSeId", 459: "GekisouTraceSeVolume",
64
+ 460: "EmptyTapSeMute", 461: "TapSeMute", 462: "FlickSeMute", 463: "SideFlickSeMute", 464: "SlideSeMute",
65
+ 465: "TraceSeMute", 466: "GekisouTapSeMute", 467: "GekisouFlickSeMute", 468: "GekisouSideFlickSeMute",
66
+ 469: "GekisouSlideSeMute", 470: "GekisouTraceSeMute",
67
+ 500: "BluetoothNoteTiming", 501: "BluetoothNoteChartPosition",
68
+ 510: "BluetoothSystemBgmVolume", 511: "BluetoothSystemSeVolume", 512: "BluetoothSystemVoiceVolume",
69
+ 513: "BluetoothSystemBgmMute", 514: "BluetoothSystemSeMute", 515: "BluetoothSystemVoiceMute",
70
+ 520: "BluetoothLiveMusicVolume", 521: "BluetoothLiveNoteSeVolume", 522: "BluetoothLiveSeVolume",
71
+ 523: "BluetoothLiveVoiceVolume", 524: "BluetoothLiveMusicMute", 525: "BluetoothLiveNoteSeMute",
72
+ 526: "BluetoothLiveSeMute", 527: "BluetoothLiveVoiceMute",
73
+ 600: "QualitySetting", 601: "PurchaseAlert", 602: "LiveBoostFullRecoveryNotification",
74
+ 603: "LateNightNotification", 604: "OfflineBonusReachedMaxRewardsNotification",
75
+ 999: "SongTitleDisplay",
76
+ }
77
+ # The items the player offers beyond those the note runtime reads (livenotes.OPTION_ITEMS): the option tables add
78
+ # their defaults and ranges. NoteTiming; AssistMode, Vibration, FcAcChallengeAssist, FrameRate (accepted without an
79
+ # effect or at the default); the live volumes and mutes; the note sound set, the individual switch and the per-type
80
+ # sound ids, volumes and mutes (empty tap, tap, flick, side flick, slide, trace).
81
+ PLAYER_ITEMS = (2, 5, 7, 116, 118, *range(410, 418), 420, 421, *range(430, 442), *range(460, 466))
82
+
83
+ # option name -> (OptionItemType, master table and column of its values); MirrorChart is a switch
84
+ ENUM_OPTIONS = {
85
+ "NoteDesignId": (306, "MasterLiveNoteSkin", "_id"),
86
+ "NoteEffectId": (307, "MasterLiveNoteEffectSkin", "_id"),
87
+ "LiveQuality": (6, "MasterLiveQualitySettings", "_quality"),
88
+ "NoteSePatternId": (420, "MasterLiveNoteSe", "_groupID"),
89
+ }
90
+ SWITCH_OPTIONS = {"MirrorChart": 4, "MeasureLineDisplay": 109}
91
+ OPTION_NAMES = (*SWITCH_OPTIONS, *ENUM_OPTIONS)
92
+ TABLES = "defaults" # request item: the option tables only
93
+ ALL = "all" # request item: every option with every value (and the tables)
94
+ FIELDS = {"MirrorChart": "mirror", "NoteDesignId": "designs", "NoteEffectId": "effects", "LiveQuality": "qualities",
95
+ "NoteSePatternId": "se_patterns", "MeasureLineDisplay": "bar_lines"}
96
+ # options whose files are pictures (read-set variants combine them); the note sounds do not depend on them
97
+ VISUAL_OPTIONS = ("MirrorChart", "NoteDesignId", "NoteEffectId", "LiveQuality")
98
+ LOW_QUALITY = 2 # LiveQuality Low: the `<name>Light` effect sets (NoteSkinLoadStep.GetLightQualityAddress)
99
+ LIGHT_SUFFIX = "Light"
100
+
101
+
102
+ class OptionSpecError(ValueError):
103
+ """A malformed --live-option spec, or a value the master data does not have."""
104
+
105
+
106
+ def parse_specs(specs) -> dict:
107
+ """Command-line specs -> a request (JSON-able): {option name: "all" | [values] | True, "defaults": True}.
108
+ `Name` offers every value of an option (a switch: both), `Name=v,v` those values, `defaults` the option tables,
109
+ `all` every option. Repeated names merge."""
110
+ req: dict = {}
111
+ for spec in specs or ():
112
+ s = spec.strip()
113
+ name, eq, vals = s.partition("=")
114
+ name = name.strip()
115
+ if name in (TABLES, ALL):
116
+ if eq:
117
+ raise OptionSpecError(f"--live-option {name} takes no values")
118
+ req[name] = True
119
+ continue
120
+ if name in SWITCH_OPTIONS:
121
+ if eq:
122
+ raise OptionSpecError(f"--live-option {name} is a switch: give it without values")
123
+ req[name] = True
124
+ continue
125
+ if name not in ENUM_OPTIONS:
126
+ raise OptionSpecError(f"--live-option {name!r}: one of {', '.join((*OPTION_NAMES, TABLES, ALL))}")
127
+ if not eq:
128
+ req[name] = "all"
129
+ continue
130
+ try:
131
+ values = sorted({int(v) for v in vals.split(",") if v.strip()})
132
+ except ValueError:
133
+ raise OptionSpecError(f"--live-option {s}: values are integers, comma-separated") from None
134
+ if not values:
135
+ raise OptionSpecError(f"--live-option {s}: no values")
136
+ old = req.get(name)
137
+ if old != "all":
138
+ req[name] = sorted(set(values) | set(old or ()))
139
+ return req
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class LiveOptions:
144
+ """The file-selecting options a live directory carries files for. Each enum option: the offered values in
145
+ ascending order, including the preset-1 default; () when only the default is offered. Each switch: offered
146
+ (both values) or not. `tables`: the option tables (the default and range of every item the player offers)."""
147
+ mirror: bool = False
148
+ bar_lines: bool = False
149
+ designs: tuple = ()
150
+ effects: tuple = ()
151
+ qualities: tuple = ()
152
+ se_patterns: tuple = ()
153
+ tables: bool = False
154
+ defaults: tuple = () # ((option name, preset-1 value), ...) of the enum options, from the master data
155
+
156
+ def __bool__(self) -> bool:
157
+ return bool(self.tables or self.mirror or self.bar_lines or self.designs or self.effects or self.qualities
158
+ or self.se_patterns)
159
+
160
+ def default(self, name: str) -> int:
161
+ return dict(self.defaults)[name]
162
+
163
+ def values(self, name: str) -> list:
164
+ """The values of option `name` the files support (the default alone when it is not offered)."""
165
+ if name in SWITCH_OPTIONS:
166
+ return [False, True] if getattr(self, FIELDS[name]) else [False]
167
+ return list(getattr(self, FIELDS[name]) or (self.default(name),))
168
+
169
+ def manifest_options(self) -> dict | None:
170
+ """The chart manifest's `options`: the values of the options the player takes from the manifest
171
+ (LiveQuality; the other options are offered by the data itself), None when only the default is offered."""
172
+ return {"LiveQuality": list(self.qualities)} if self.qualities else None
173
+
174
+ def effect_sets(self, names: dict) -> list[tuple[str, bool]]:
175
+ """The note effect sets (MasterLiveNoteEffectSkin._assetName, `names`: id -> name) the files may carry beyond
176
+ the default set, as (name, is a Light variant): the other offered sets, and with LiveQuality Low offered the
177
+ `Light` variant of every offered set (the default one included), in id order. The game loads a Light
178
+ variant only where it exists and the set itself otherwise (NoteSkinLoadStep.CollectDownloadAddresses:
179
+ ResourceManager.ExistsAsset; LoadNoteEffectSkinAssetSettingsAsync falls back when the Light load fails)."""
180
+ if not self.defaults:
181
+ return []
182
+ low = LOW_QUALITY in self.qualities
183
+ out = []
184
+ for i in self.values("NoteEffectId"):
185
+ if i != self.default("NoteEffectId"):
186
+ out.append((names[i], False))
187
+ if low:
188
+ out.append((names[i] + LIGHT_SUFFIX, True))
189
+ return out
190
+
191
+ def read_variants(self) -> list[dict]:
192
+ """The settings (option name -> value, non-default values only) whose read sets, with the default one,
193
+ make the union of the files a chart reads over every offered variant: every combination of the offered
194
+ picture options (the note skin, the effect set and its quality variant, and the mirrored score's flick
195
+ directions select pictures together), then the bar lines alone (their sprite does not depend on the other
196
+ options) and each other note sound set alone (sounds do not depend on the pictures). The all-default
197
+ combination is left out."""
198
+ if not self.defaults:
199
+ return []
200
+ dims = []
201
+ for name in VISUAL_OPTIONS:
202
+ vals = self.values(name)
203
+ default = False if name in SWITCH_OPTIONS else self.default(name)
204
+ dims.append([(name, v) for v in [default, *[v for v in vals if v != default]]])
205
+ out = []
206
+ for combo in itertools.product(*dims):
207
+ s = {n: v for n, v in combo if v != (False if n in SWITCH_OPTIONS else self.default(n))}
208
+ if s:
209
+ out.append(s)
210
+ if self.bar_lines:
211
+ out.append({"MeasureLineDisplay": True})
212
+ out += [{"NoteSePatternId": g} for g in self.se_patterns if g != self.default("NoteSePatternId")]
213
+ return out
214
+
215
+
216
+ def preset_defaults(master: Path) -> dict[int, str]:
217
+ """OptionItemType -> MasterOptionDefault preset-1 value string (a fresh profile's options)."""
218
+ return {r["_optionItemType"]: r["_valueString"] for r in master_table(master, "MasterOptionDefault")
219
+ if r["_presetId"] == 1}
220
+
221
+
222
+ def resolve(request: dict | None, master: Path | None) -> LiveOptions:
223
+ """The LiveOptions of a request (parse_specs) with the master data: `all` values from its tables, requested
224
+ values checked against them, the preset-1 defaults added. An empty request (or None) is LiveOptions()."""
225
+ if not request:
226
+ return LiveOptions()
227
+ if master is None:
228
+ raise OptionSpecError("--live-option needs the master data")
229
+ master = Path(master)
230
+ everything = bool(request.get(ALL))
231
+ preset = preset_defaults(master)
232
+ defaults, fields = [], {}
233
+ for name, (item, table, column) in ENUM_OPTIONS.items():
234
+ have = sorted({int(r[column]) for r in master_table(master, table)})
235
+ d = int(preset[item])
236
+ defaults.append((name, d))
237
+ want = "all" if everything else request.get(name)
238
+ if want is None:
239
+ continue
240
+ want = have if want == "all" else list(want)
241
+ bad = [v for v in want if v not in have]
242
+ if bad:
243
+ raise OptionSpecError(f"--live-option {name}: {', '.join(map(str, bad))} not in {table} "
244
+ f"({', '.join(map(str, have))})")
245
+ vals = tuple(sorted(set(want) | {d}))
246
+ if len(vals) > 1:
247
+ fields[FIELDS[name]] = vals
248
+ switches = {FIELDS[name]: everything or bool(request.get(name)) for name in SWITCH_OPTIONS}
249
+ out = LiveOptions(defaults=tuple(defaults), **switches, **fields)
250
+ return replace(out, tables=True) if (out or request.get(TABLES)) else out