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/score.py ADDED
@@ -0,0 +1,1139 @@
1
+ """Live chart (MusicScore) extraction and a re-implementation of the game's chart converter.
2
+
3
+ Each function follows the game method named in its docstring. The pipeline,
4
+ as in the game:
5
+
6
+ MusicScoreLoader.Load (gunzip when the bytes start 1F 8B)
7
+ SsRootDeserializer (JSON -> SsRoot, field defaults) -> parse()
8
+ SsTickConverter (tick -> bar position / ms) -> TickConverter
9
+ SsMusicScoreConverter (SsNote -> NoteInfoData, merges, lines) -> _build_note_infos()
10
+ MusicScoreNoteCreator (NoteInfoData -> notes, slide combo ticks)-> _Creator
11
+ MusicScoreUtility.GetTimeMsFromBar (note time, float32) -> time_ms_from_bar()
12
+
13
+ `convert()` returns the runtime note list (the notes the game puts in
14
+ MusicScore.NoteDictionary, including SlideComboNote ticks) in position order, the
15
+ note lines and the events. `runtime_score()` gives the same notes in the order the
16
+ game enumerates MusicScore.NoteDictionary. `extract()` writes the shipped chart,
17
+ the converted notes, the master rows and the decoded song for one music/difficulty.
18
+
19
+ Game resources are only read from the user's own catalog/cache and written to
20
+ the user's output directory.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import gzip
25
+ import json
26
+ import math
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+
30
+ import numpy as np
31
+
32
+ from . import cri, languages
33
+ from .jsonio import write_json
34
+
35
+ f32 = np.float32
36
+
37
+ LANE_COUNT = 24 # MusicScore.LaneCount (SsMusicScoreConverter.Load sets 0x18)
38
+ TICKS_PER_BEAT = 480 # 60000 / (bpm * 480) in SsTickConverter.TickToTimeMs
39
+ TICKS_PER_WHOLE = 1920 # ticks per measure = numerator * 1920 / denominator
40
+ MAX_LINES = 20 # LineIndexAssigner(0x14)
41
+ DIFFICULTIES = ("easy", "normal", "hard", "expert") # _easyID.._expertID -> file suffix _00.._03
42
+
43
+ # SsNoteType / SsFlickDir / SsEase / SsFadeType (SsRootDeserializer.Parse*)
44
+ NOTE_TYPES = {"tap": 0, "flick": 1, "trace": 2, "long": 3, "guide": 4, "node": 5}
45
+ FLICK_DIRS = {"up": 0, "left": 1, "right": 2, "down": 3}
46
+ EASES = {"linear": 0, "in": 1, "out": 2}
47
+ FADES = {"none": 0, "in": 1, "out": 2} # unknown string -> none (no exception)
48
+
49
+ # NoteOperateType
50
+ OP_NAMES = {
51
+ 0: "None", 1: "Normal", 20: "SlideBegin", 21: "SlideConnection", 22: "SlideEnd",
52
+ 40: "Flick", 41: "SlideBeginFlick", 42: "SlideEndFlick", 60: "Trace", 61: "SlideBeginTrace",
53
+ 62: "SlideEndTrace", 63: "SlideConnectionTrace", 80: "HiddenSlideBegin", 82: "HiddenSlideEnd",
54
+ 100: "GuideBegin", 101: "GuideBeginNormal", 102: "GuideBeginFlick", 103: "GuideEnd",
55
+ 104: "GuideBeginTrace", 105: "GuideEndTrace", 120: "Combo", 121: "ComboSkip", 122: "Hidden",
56
+ 123: "InvalidHidden",
57
+ }
58
+ # NoteLineEaseType as the runtime names it (LiveCalculator.GetNoteLineTypeEasing):
59
+ # 0 Linear t, 1 "EaseIn" (2-t)t, 2 "EaseOut" t*t. Chart "in" maps to 2 and "out" to 1 (ConvertEase).
60
+ LINE_EASE_NAMES = {0: "Linear", 1: "EaseIn", 2: "EaseOut"}
61
+ DIRECTION_NAMES = {0: "Normal", 1: "Left", 2: "Right"}
62
+ # NoteJudgementType (FTLiveSimulator.Runtime)
63
+ JUDGEMENT_TYPE_NAMES = {0: "None", 1: "Normal", 2: "EasyNormal", 5: "Flick", 10: "SlideBegin",
64
+ 11: "SlideEnd", 12: "SlideEndFlick", 15: "SlideBeginEasy", 21: "Trace",
65
+ 22: "SlideEndTrace"}
66
+ # JudgementAreaOffsetType
67
+ OFFSET_TYPE_NAMES = {0: "Default", 1: "Slide", 2: "SlideBegin", 3: "SlideEnd", 4: "Flick", 5: "Trace",
68
+ 6: "SlideMin", 7: "SlideMax", 8: "EasyDefault", 9: "EasySlideBegin"}
69
+
70
+ SLIDE_BEGIN_CLASS = {20, 41, 61, 80, 104} # classes derived from SlideBeginNote (have AddCombo)
71
+ SLIDE_END_CLASS = {22, 42, 62, 82, 105} # classes derived from SlideEndNote (ISlideNoteEnd)
72
+ GUIDE_BEGIN_OPS = {100, 101, 102, 104}
73
+ GUIDE_END_OPS = {103, 105}
74
+
75
+
76
+ # --------------------------------------------------------------------------- numeric helpers
77
+ def net_round(x: float) -> float:
78
+ """System.Math.Round(double) (MidpointRounding.ToEven) exactly as the inlined IL2CPP code does it."""
79
+ x = float(x)
80
+ frac, ip = math.modf(x)
81
+ if x >= 0.0:
82
+ if frac == 0.5:
83
+ return ip + 1.0 if int(ip) & 1 else ip
84
+ return float(math.floor(x + 0.5))
85
+ if frac == -0.5:
86
+ return ip - 1.0 if int(ip) & 1 else ip
87
+ return float(int(x - 0.5)) # (double)(long)(x - 0.5): truncation toward zero
88
+
89
+
90
+ def net_round_int(x: float) -> int:
91
+ v = net_round(x)
92
+ return int(v) if math.isfinite(v) else -2**31
93
+
94
+
95
+ def is_judgement_note(op: int) -> bool:
96
+ """NoteOperateTypeUtility.IsJudgementNote."""
97
+ return op not in (0, 80, 82, 100, 103, 121, 122, 123)
98
+
99
+
100
+ def is_flick_type(op: int) -> bool:
101
+ """NoteOperateTypeUtility.IsFlickType."""
102
+ return op in (40, 41, 42, 102)
103
+
104
+
105
+ def judgement_type(op: int, crit: bool) -> int:
106
+ """NoteJudgementTypeMap.ConvertJudgementType."""
107
+ if op in (0, 21):
108
+ return op
109
+ if op == 1:
110
+ return 2 if crit else 1
111
+ if op == 20:
112
+ return 15 if crit else 10
113
+ if op == 22:
114
+ return 11
115
+ if op in (40, 41, 42, 102):
116
+ return 5
117
+ if op in (60, 61, 63, 104, 105, 120):
118
+ return 21
119
+ if op == 62:
120
+ return 22
121
+ if op in (80, 82, 100, 101, 103, 121, 122):
122
+ return 1
123
+ raise ValueError(f"ConvertJudgementType: operateType {op}")
124
+
125
+
126
+ MATHF_EPSILON = f32(1.401298e-45) # Mathf.Epsilon (denormal min; 1.17549435e-38 if flush-to-zero is on, assumed off)
127
+
128
+
129
+ def mathf_approximately(a: float, b: float) -> bool:
130
+ """UnityEngine.Mathf.Approximately (inlined in IsSamePosition / EqualMusicInfoPosition)."""
131
+ a, b = f32(a), f32(b)
132
+ m = max(abs(a), abs(b))
133
+ tol = max(f32(m * f32(1e-6)), f32(MATHF_EPSILON * f32(8.0)))
134
+ return bool(abs(f32(b - a)) < tol)
135
+
136
+
137
+ # --------------------------------------------------------------------------- SsRoot (ReadNote/ReadEvents)
138
+ @dataclass
139
+ class SsNote:
140
+ type: int = 0 # SsNoteType, default Tap
141
+ t: int = 0
142
+ pos: np.float32 = f32(0.0)
143
+ pos_auto: bool = False
144
+ size: np.float32 = f32(6.0) # ReadNote writes 6.0f (0x40c00000) before parsing
145
+ crit: bool = False
146
+ dir: int = 0 # SsFlickDir, default Up
147
+ ease_l: int = 0 # SsEase
148
+ ease_r: int = 0
149
+ visible: bool = True # ReadNote writes 1 before parsing
150
+ alpha: int = 0 # SsFadeType
151
+ node: list | None = None
152
+ src: tuple = () # (note index, node index) in the shipped JSON, for traceability
153
+
154
+
155
+ def _to_int(v) -> int:
156
+ """(int)JToken: Newtonsoft converts floats with Convert.ToInt32 (round half to even)."""
157
+ if isinstance(v, bool):
158
+ return int(v)
159
+ if isinstance(v, int):
160
+ return v
161
+ if isinstance(v, float):
162
+ return net_round_int(v)
163
+ return int(str(v).strip())
164
+
165
+
166
+ def _to_f32(v) -> np.float32:
167
+ if isinstance(v, str):
168
+ return f32(float(v))
169
+ return f32(v)
170
+
171
+
172
+ def _enum(table: dict, s: str, what: str) -> int:
173
+ if s not in table:
174
+ raise ValueError(f"ss {what} value '{s}'") # InvalidOperationException("ss" + s + ...)
175
+ return table[s]
176
+
177
+
178
+ def read_note(n: dict, src: tuple = ()) -> SsNote:
179
+ """SsRootDeserializer.ReadNote."""
180
+ o = SsNote(src=src)
181
+ if isinstance(n.get("type"), str):
182
+ o.type = _enum(NOTE_TYPES, n["type"], "type")
183
+ if n.get("t") is not None:
184
+ o.t = _to_int(n["t"])
185
+ p = n.get("pos")
186
+ if p is not None:
187
+ if isinstance(p, str) and p == "auto":
188
+ o.pos_auto = True
189
+ else:
190
+ o.pos = _to_f32(p)
191
+ if n.get("size") is not None:
192
+ o.size = _to_f32(n["size"])
193
+ if n.get("crit") is not None:
194
+ o.crit = bool(n["crit"])
195
+ if isinstance(n.get("dir"), str):
196
+ o.dir = _enum(FLICK_DIRS, n["dir"], "dir")
197
+ e = n.get("ease")
198
+ if e is not None:
199
+ if isinstance(e, list):
200
+ o.ease_l = _enum(EASES, e[0], "ease")
201
+ o.ease_r = _enum(EASES, e[1], "ease")
202
+ else:
203
+ o.ease_l = o.ease_r = _enum(EASES, e, "ease")
204
+ if n.get("visible") is not None:
205
+ o.visible = bool(n["visible"])
206
+ if isinstance(n.get("alpha"), str):
207
+ o.alpha = FADES.get(n["alpha"], 0)
208
+ if isinstance(n.get("node"), list):
209
+ o.node = [read_note(c, src + (i,)) for i, c in enumerate(n["node"])]
210
+ return o
211
+
212
+
213
+ @dataclass
214
+ class SsEvents:
215
+ bpm: list = field(default_factory=list) # [(t, bpm f32)]
216
+ sig: list = field(default_factory=list) # [(t, num, den)]
217
+ skill: list = field(default_factory=list) # [t]
218
+ fever: list = field(default_factory=list) # [(t0, t1)]
219
+ call: list = field(default_factory=list) # [(t, [timing ints])]
220
+
221
+
222
+ def read_events(ev: dict) -> SsEvents:
223
+ """SsRootDeserializer.ReadEvents."""
224
+ o = SsEvents()
225
+ for b in ev.get("bpm") or []:
226
+ o.bpm.append((_to_int(b["t"]), _to_f32(b["bpm"])))
227
+ for s in ev.get("sig") or []:
228
+ o.sig.append((_to_int(s["t"]), _to_int(s["sig"][0]), _to_int(s["sig"][1])))
229
+ for t in ev.get("skill") or []:
230
+ o.skill.append(_to_int(t))
231
+ for f in ev.get("fever") or []:
232
+ o.fever.append((_to_int(f[0]), _to_int(f[1])))
233
+ for c in ev.get("call") or []:
234
+ o.call.append((_to_int(c["t"]), [_to_int(x) for x in c["timing"]]))
235
+ return o
236
+
237
+
238
+ def load_bytes(data: bytes) -> dict:
239
+ """MusicScoreLoader.Load: gunzip when the first two bytes are 1F 8B, then UTF-8 JSON."""
240
+ if len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B:
241
+ data = gzip.decompress(data)
242
+ return json.loads(data.decode("utf-8"))
243
+
244
+
245
+ def parse(root: dict) -> tuple[SsEvents, list[SsNote]]:
246
+ """SsRootDeserializer.ReadRoot/ReadScore: only `score` is read (`meta` is ignored)."""
247
+ score = root.get("score")
248
+ if not isinstance(score, dict):
249
+ raise ValueError("ss score") # SsMusicScoreConverter.Error("ss score")
250
+ ev = read_events(score["events"]) if isinstance(score.get("events"), dict) else SsEvents()
251
+ notes = [read_note(n, (i,)) for i, n in enumerate(score.get("notes") or [])]
252
+ return ev, notes
253
+
254
+
255
+ # --------------------------------------------------------------------------- SsTickConverter
256
+ @dataclass(frozen=True)
257
+ class BarPos:
258
+ bar: int
259
+ rhythm: int
260
+ unit: int # RhythmicUnit = ticks per measure of the segment
261
+ progress: np.float32 # (float)rhythm / (float)unit
262
+
263
+ @property
264
+ def key(self) -> np.float32: # BarProgress + (float)Bar, the dictionary key of the converter
265
+ return f32(self.progress + f32(self.bar))
266
+
267
+
268
+ class TickConverter:
269
+ """SsTickConverter (its constructor builds the sig / bpm segments)."""
270
+
271
+ def __init__(self, sigs: list, bpms: list):
272
+ self.sig = self._sig_segments(sigs)
273
+ self.bpm = self._bpm_segments(bpms)
274
+
275
+ @staticmethod
276
+ def _sig_segments(sigs):
277
+ """BuildSigSegments -> [(startTick, startBar, ticksPerMeasure, num, den)]."""
278
+ s = sorted(sigs, key=lambda x: x[0]) # List.Sort(Comparison by T); ties keep chart order here
279
+ out = []
280
+ if not s or s[0][0] > 0:
281
+ out.append((0, 0, 1920, 4, 4))
282
+ bar, prev_t, tpm = 0, 0, 1920
283
+ for t, num, den in s:
284
+ q = int((t - prev_t) / tpm) if tpm != 0 else 0 # C# int division truncates
285
+ if t - prev_t != 0 and prev_t <= t:
286
+ bar += q
287
+ tpm = int(num * 1920 / den) if den != 0 else 0
288
+ out.append((t, bar, tpm, num, den))
289
+ prev_t = t
290
+ return out
291
+
292
+ @staticmethod
293
+ def _bpm_segments(bpms):
294
+ """BuildBpmSegments -> [(startTick, startMs, bpm f32)]; startMs = Math.Round(acc)."""
295
+ s = sorted(bpms, key=lambda x: x[0])
296
+ out = []
297
+ if not s or s[0][0] > 0:
298
+ out.append((0, 0, f32(120.0)))
299
+ acc, prev_t, prev_bpm = 0.0, 0, f32(120.0)
300
+ for t, bpm in s:
301
+ if t - prev_t != 0 and prev_t <= t:
302
+ acc += (float(t - prev_t) * 60000.0) / float(f32(prev_bpm * f32(480.0)))
303
+ out.append((t, net_round_int(acc), bpm))
304
+ prev_t, prev_bpm = t, bpm
305
+ return out
306
+
307
+ def _find(self, segs, tick):
308
+ for s in reversed(segs):
309
+ if s[0] <= tick:
310
+ return s
311
+ return segs[0]
312
+
313
+ def time_ms(self, tick: int) -> int:
314
+ """TickToTimeMs: floor(double(dt)*60000.0 / double(float(bpm*480f)) + startMs)."""
315
+ t0, ms0, bpm = self._find(self.bpm, tick)
316
+ v = (float(tick - t0) * 60000.0) / float(f32(bpm * f32(480.0))) + float(ms0)
317
+ return int(math.floor(v))
318
+
319
+ def bar_position(self, tick: int) -> BarPos:
320
+ """TickToBarPosition."""
321
+ t0, bar0, tpm, _, _ = self._find(self.sig, tick)
322
+ q = int((tick - t0) / tpm) if tpm != 0 else 0
323
+ r = (tick - t0) - q * tpm
324
+ return BarPos(bar0 + q, r, tpm, f32(f32(r) / f32(tpm)))
325
+
326
+ def bar_head_tick(self, bar: int) -> int:
327
+ """BarHeadTick."""
328
+ seg = self.sig[0]
329
+ for s in reversed(self.sig):
330
+ if s[1] <= bar:
331
+ seg = s
332
+ break
333
+ return seg[0] + (bar - seg[1]) * seg[2]
334
+
335
+
336
+ # --------------------------------------------------------------------------- runtime positions/events
337
+ @dataclass
338
+ class Pos:
339
+ """MusicScorePosition."""
340
+ bar: int
341
+ rhythm: int
342
+ unit: int
343
+ progress: np.float32
344
+ ms: int
345
+
346
+ @property
347
+ def key(self) -> np.float32:
348
+ return f32(self.progress + f32(self.bar))
349
+
350
+ def before(self, bar: int, progress) -> bool:
351
+ return self.bar < bar or (self.bar == bar and self.progress < progress)
352
+
353
+
354
+ def make_position(tick: int, tc: TickConverter) -> Pos:
355
+ """SsMusicScoreConverter.MakePosition."""
356
+ bp = tc.bar_position(tick)
357
+ return Pos(bp.bar, bp.rhythm, bp.unit, bp.progress, tc.time_ms(tick))
358
+
359
+
360
+ def time_ms_from_bar(bar: int, progress, bpm_events: list, bar_events: list) -> int:
361
+ """MusicScoreUtility.GetTimeMsFromBar (all float32, floor)."""
362
+ progress = f32(progress)
363
+ ref = Pos(0, 0, 0, f32(0.0), 0)
364
+ beats = f32(4.0)
365
+ for value, p in bar_events: # BarChangeEvent (ChangeBar, position)
366
+ if p.before(bar, progress):
367
+ beats = value
368
+ if p.ms > ref.ms:
369
+ ref = p
370
+ bpm = f32(160.0)
371
+ for value, p in bpm_events: # BPMChangeEvent (ChangeBPM, position)
372
+ if p.before(bar, progress):
373
+ bpm = value
374
+ if p.ms > ref.ms:
375
+ ref = p
376
+ spb = f32(f32(beats * f32(60.0)) / bpm)
377
+ x = f32(f32(spb * f32(bar - ref.bar)) + f32(spb * f32(progress - ref.progress)))
378
+ x = f32(x * f32(1000.0))
379
+ return ref.ms + int(math.floor(float(x)))
380
+
381
+
382
+ def bar_rhythm(bar: int, bar_events: list) -> np.float32:
383
+ """MusicScoreUtility.GetBarRhythm."""
384
+ v = f32(4.0)
385
+ for value, p in bar_events:
386
+ if bar < p.bar:
387
+ return v
388
+ v = value
389
+ return v
390
+
391
+
392
+ def bpm_at_ms(ms: int, bpm_events: list) -> np.float32:
393
+ """MusicScoreNoteCreator.GetBpmAtTimeMs."""
394
+ v = f32(160.0)
395
+ for value, p in bpm_events:
396
+ if p.ms <= ms:
397
+ v = value
398
+ return v
399
+
400
+
401
+ def same_position(a: Pos, b: Pos) -> bool:
402
+ """MusicScoreNoteCreator.IsSamePosition / MusicScoreUtility.EqualMusicInfoPosition."""
403
+ return a.bar == b.bar and mathf_approximately(a.progress, b.progress)
404
+
405
+
406
+ def ease(t, kind: int) -> np.float32:
407
+ """LiveCalculator.GetNoteLineTypeEasing."""
408
+ t = f32(t)
409
+ if kind == 0:
410
+ return t
411
+ if kind == 2:
412
+ return f32(t * t)
413
+ if kind == 1:
414
+ return f32(f32(f32(2.0) - t) * t)
415
+ raise ValueError(f"noteLineEaseType {kind}")
416
+
417
+
418
+ def convert_ease(e: int) -> int:
419
+ """SsMusicScoreConverter.ConvertEase: chart in(1) -> 2, out(2) -> 1, linear -> 0."""
420
+ return 2 if e == 1 else (1 if e == 2 else 0)
421
+
422
+
423
+ def flick_direction(n: SsNote) -> int:
424
+ """ResolveFlickDir: left -> Left(1), right -> Right(2), up/down -> Normal(0)."""
425
+ return 1 if n.dir == 1 else (2 if n.dir == 2 else 0)
426
+
427
+
428
+ def mirror_direction(d: int, mirror: bool) -> int:
429
+ """ApplyMirrorDirection."""
430
+ if not mirror:
431
+ return d
432
+ return 2 if d == 1 else (1 if d == 2 else 0)
433
+
434
+
435
+ def apply_mirror(lane, width, mirror: bool) -> np.float32:
436
+ """ApplyMirror: 24 - (lane + 0) - width when mirrored, else lane + 0."""
437
+ if mirror:
438
+ return f32(f32(f32(24.0) - f32(lane + f32(0.0))) - width)
439
+ return f32(lane + f32(0.0))
440
+
441
+
442
+ def overlap_key(t: int, pos, size) -> tuple:
443
+ """MakeOverlapKey: (t, Math.Round(pos), Math.Round(size))."""
444
+ return (t, net_round_int(float(pos)), net_round_int(float(size)))
445
+
446
+
447
+ # --------------------------------------------------------------------------- NoteInfoData
448
+ @dataclass
449
+ class NoteInfo:
450
+ bar: int
451
+ rhythm: int
452
+ unit: int
453
+ progress: np.float32
454
+ lane: int
455
+ lane_f: np.float32
456
+ width: int
457
+ width_f: np.float32
458
+ op: int
459
+ slide_along: bool = False
460
+ crit: bool = False
461
+ direction: int = 0
462
+ ease: int = 0
463
+ ease_r: int = 0
464
+ lines: list = field(default_factory=list) # LineIndexList (line *indices*, 0..19)
465
+ src: tuple = ()
466
+ tick: int = 0
467
+ visible: bool = True
468
+ alpha: int = 0
469
+
470
+ @property
471
+ def key(self) -> np.float32:
472
+ return f32(self.progress + f32(self.bar))
473
+
474
+
475
+ class LineIndexAssigner:
476
+ """SsMusicScoreConverter.LineIndexAssigner (Acquire, Register)."""
477
+
478
+ def __init__(self, max_lines: int = MAX_LINES):
479
+ self.in_use: list[tuple[int, int]] = []
480
+ self.max = max_lines
481
+
482
+ def acquire(self, start_tick: int) -> int:
483
+ for i in range(len(self.in_use) - 1, -1, -1):
484
+ if self.in_use[i][1] < start_tick:
485
+ del self.in_use[i]
486
+ used = {i for i, _ in self.in_use}
487
+ for i in range(self.max):
488
+ if i not in used:
489
+ return i
490
+ raise ValueError(f"ss lineIndex {self.max} startTick {start_tick}")
491
+
492
+ def register(self, idx: int, end_tick: int) -> None:
493
+ self.in_use.append((idx, end_tick))
494
+
495
+
496
+ def _single_info(n: SsNote, tc: TickConverter, mirror: bool) -> NoteInfo:
497
+ """BuildSingleNoteInfo."""
498
+ bp = tc.bar_position(n.t)
499
+ lane_f = apply_mirror(n.pos, n.size, mirror)
500
+ lane = net_round_int(float(lane_f))
501
+ width = max(1, net_round_int(float(n.size)))
502
+ if n.type == 1:
503
+ op, d = 40, flick_direction(n)
504
+ elif n.type == 2:
505
+ op, d = 60, 0
506
+ else:
507
+ op, d = 1, 0
508
+ return NoteInfo(bp.bar, bp.rhythm, bp.unit, bp.progress, lane, lane_f, width, n.size, op,
509
+ False, n.crit, mirror_direction(d, mirror), 0, 0, [], n.src, n.t, n.visible, n.alpha)
510
+
511
+
512
+ def _long_node_op(nodes: list, i: int) -> int:
513
+ """ResolveLongNodeOperateType."""
514
+ n = nodes[i]
515
+ if i == 0:
516
+ if not n.visible:
517
+ return 80
518
+ return 41 if n.type == 1 else (61 if n.type == 2 else 20)
519
+ if i == len(nodes) - 1:
520
+ if not n.visible:
521
+ return 82
522
+ return 42 if n.type == 1 else (62 if n.type == 2 else 22)
523
+ if n.pos_auto:
524
+ return 21
525
+ if not n.visible:
526
+ return 122
527
+ return 63 if n.type == 2 else 21
528
+
529
+
530
+ def _guide_node_op(nodes: list, i: int, guide_map: dict | None) -> int:
531
+ """ResolveGuideNodeOperateType (its constant table: tap 101, flick 102, trace 104)."""
532
+ n = nodes[i]
533
+ if i == 0:
534
+ if guide_map is not None and not n.pos_auto:
535
+ s = guide_map.get(overlap_key(n.t, n.pos, n.size))
536
+ if s is not None and s.type < 3:
537
+ return (101, 102, 104)[s.type]
538
+ return 100
539
+ if i == len(nodes) - 1:
540
+ if not n.visible:
541
+ return 103
542
+ return 105 if n.type == 2 else 103
543
+ if n.pos_auto:
544
+ return 63
545
+ return 122 if not n.visible else 63
546
+
547
+
548
+ def _line_infos(chains: list, is_long: bool, tc: TickConverter, mirror: bool, out: list,
549
+ guide_map: dict | None, assigner: LineIndexAssigner) -> None:
550
+ """BuildLongOrGuideNoteInfos."""
551
+ for _, chain in chains:
552
+ nodes = chain.node
553
+ N = len(nodes)
554
+ line_idx = assigner.acquire(nodes[0].t)
555
+ end_tick = nodes[-1].t
556
+ for i, nd in enumerate(nodes):
557
+ op = _long_node_op(nodes, i) if is_long else _guide_node_op(nodes, i, guide_map)
558
+ if not nd.pos_auto:
559
+ pos, size = nd.pos, nd.size
560
+ elif i == 0 or i >= N - 1:
561
+ pos, size = nodes[0].pos, nodes[0].size
562
+ else:
563
+ j = i
564
+ while True: # nearest non-auto node in [1, i-1], else node 0
565
+ if j < 2:
566
+ j = 0
567
+ break
568
+ j -= 1
569
+ if not nodes[j].pos_auto:
570
+ break
571
+ k = i + 1
572
+ while k < N - 1 and nodes[k].pos_auto: # nearest non-auto node in [i+1, N-2], else N-1
573
+ k += 1
574
+ a, b = nodes[j], nodes[k]
575
+ span = b.t - a.t
576
+ prog = f32(0.0) if span < 1 else f32(f32(nd.t - a.t) / f32(span))
577
+ el = ease(prog, convert_ease(a.ease_l))
578
+ er = ease(prog, convert_ease(a.ease_r))
579
+ pos = f32(a.pos + f32(el * f32(b.pos - a.pos)))
580
+ a_r = f32(a.pos + a.size)
581
+ right = f32(a_r + f32(er * f32(f32(b.pos + b.size) - a_r)))
582
+ size = f32(right - pos)
583
+ bp = tc.bar_position(nd.t)
584
+ lane_f = apply_mirror(pos, size, mirror)
585
+ lane = net_round_int(float(lane_f))
586
+ width = max(1, net_round_int(float(size)))
587
+ dir_src = nd
588
+ if guide_map is not None and i == 0 and not is_long and not nd.pos_auto:
589
+ s = guide_map.get(overlap_key(nd.t, nd.pos, nd.size))
590
+ if s is not None:
591
+ dir_src = s
592
+ d = flick_direction(dir_src) if dir_src.type == 1 else 0
593
+ out.append(NoteInfo(bp.bar, bp.rhythm, bp.unit, bp.progress, lane, lane_f, width, size, op,
594
+ nd.pos_auto, nd.crit, mirror_direction(d, mirror),
595
+ convert_ease(nd.ease_l), convert_ease(nd.ease_r), [line_idx], nd.src,
596
+ nd.t, nd.visible, nd.alpha))
597
+ assigner.register(line_idx, end_tick)
598
+
599
+
600
+ def _build_note_infos(notes: list, tc: TickConverter, mirror: bool) -> list[NoteInfo]:
601
+ """BuildNoteInfoList."""
602
+ guide_starts = set()
603
+ for n in notes:
604
+ if n.type == 4 and n.node:
605
+ first = n.node[0]
606
+ if not first.pos_auto:
607
+ guide_starts.add(overlap_key(first.t, first.pos, first.size))
608
+ guide_map: dict = {}
609
+ merged: set[int] = set()
610
+ for i, n in enumerate(notes):
611
+ if n.type < 3:
612
+ k = overlap_key(n.t, n.pos, n.size)
613
+ if k in guide_starts and k not in guide_map:
614
+ guide_map[k] = n
615
+ merged.add(i)
616
+ out: list[NoteInfo] = []
617
+ for i, n in enumerate(notes):
618
+ if i not in merged and n.type < 3:
619
+ out.append(_single_info(n, tc, mirror))
620
+ order = lambda it: (it[1].node[0].t, it[0]) # <BuildNoteInfoList>b__7_0/7_1
621
+ longs = sorted(((i, n) for i, n in enumerate(notes) if n.type == 3 and n.node and len(n.node) > 1), key=order)
622
+ assigner = LineIndexAssigner(MAX_LINES)
623
+ _line_infos(longs, True, tc, mirror, out, None, assigner)
624
+ guides = sorted(((i, n) for i, n in enumerate(notes) if n.type == 4 and n.node and len(n.node) > 1), key=order)
625
+ _line_infos(guides, False, tc, mirror, out, guide_map, assigner)
626
+ return out
627
+
628
+
629
+ MERGEABLE = {20, 22, 41, 42, 61, 62, 80, 82, 100, 101, 102, 103, 104, 105} # IsMergeableSlideEndpoint
630
+
631
+
632
+ def _info_dictionary(infos: list[NoteInfo], log: list) -> dict:
633
+ """BuildNoteInfoDictionary + TryMergeSlideEndpoint."""
634
+ d: dict = {}
635
+ for n in infos:
636
+ lanes = d.setdefault(n.key, {})
637
+ lst = lanes.setdefault(n.lane, [])
638
+ if n.op in MERGEABLE and n.lines:
639
+ hit = next((e for e in lst if e.op == n.op and e.width == n.width and e.crit == n.crit
640
+ and e.direction == n.direction and e.ease == n.ease and e.ease_r == n.ease_r), None)
641
+ if hit is not None:
642
+ hit.lines.extend(n.lines)
643
+ continue
644
+ if is_judgement_note(n.op):
645
+ cnt = sum(1 for e in lst if is_judgement_note(e.op))
646
+ if cnt > 2:
647
+ log.append(f"{3} bar {n.bar} barProgress {float(n.progress)} lane {n.lane} op {OP_NAMES[n.op]}")
648
+ lst.append(n)
649
+ return d
650
+
651
+
652
+ def _priority(op: int) -> int:
653
+ """<CreateNoteDictionary>b__23_2 (sort priority)."""
654
+ if op in (20, 41, 61, 80, 100, 101, 102, 104):
655
+ return 8
656
+ if op == 122:
657
+ return 9
658
+ return 10
659
+
660
+
661
+ # --------------------------------------------------------------------------- MusicScoreNoteCreator
662
+ @dataclass(eq=False)
663
+ class Note:
664
+ id: int
665
+ pos: Pos
666
+ op: int
667
+ lane_start: int
668
+ lane_end: int
669
+ lane_start_f: np.float32
670
+ lane_end_f: np.float32
671
+ width: np.float32
672
+ slide_along: bool
673
+ crit: bool
674
+ direction: int
675
+ line_ids: list
676
+ ease: int
677
+ ease_r: int
678
+ offset_type: int
679
+ pair_id: int = 0
680
+ fever: int | None = None
681
+ info: NoteInfo | None = None
682
+ # line bookkeeping (SlideBeginNote / GuideBeginNote / SlideEndNote)
683
+ view_notes: list = field(default_factory=list)
684
+ combo_notes: list = field(default_factory=list)
685
+ begin_notes: list = field(default_factory=list)
686
+ end_combo: list = field(default_factory=list)
687
+ begin_ref: "Note | None" = None
688
+ end_ref: "Note | None" = None
689
+
690
+
691
+ class _Creator:
692
+ """MusicScoreNoteCreator (Initialize, Create)."""
693
+
694
+ def __init__(self, start_id: int, bpm_events, bar_events, fevers):
695
+ self.bpm_events, self.bar_events, self.fevers = bpm_events, bar_events, fevers
696
+ self.cur_id = start_id
697
+ self.cur_line = start_id
698
+ self.cur_guide_line = start_id
699
+ self.pair_tmp: Note | None = None
700
+ self.begin_by_line: dict[int, Note] = {}
701
+ self.line_arr = [-1] * MAX_LINES
702
+ self.guide_arr = [-1] * MAX_LINES
703
+ self.log: list[str] = []
704
+
705
+ def _note(self, info: NoteInfo, pos: Pos, op: int, line_ids: list, offset: int) -> Note:
706
+ # NoteBase(id, pos, noteInfo, lineIds, offsetType)
707
+ return Note(self.cur_id, pos, op, info.lane, info.lane + info.width - 1, info.lane_f,
708
+ f32(f32(info.lane_f + info.width_f) - f32(1.0)), info.width_f, info.slide_along,
709
+ info.crit, info.direction, line_ids, info.ease, info.ease_r, offset, info=info)
710
+
711
+ def create(self, info: NoteInfo) -> Note | None:
712
+ ms = time_ms_from_bar(info.bar, info.progress, self.bpm_events, self.bar_events)
713
+ pos = Pos(info.bar, info.rhythm, info.unit, info.progress, ms)
714
+ self.cur_id += 1
715
+ op = info.op
716
+ crit = info.crit
717
+ if op == 1:
718
+ note = self._note(info, pos, op, [], 8 if crit else 0)
719
+ elif op in (20, 41, 61, 80):
720
+ ids = []
721
+ for li in info.lines:
722
+ self.cur_line += 1
723
+ ids.append(self.cur_line)
724
+ self.line_arr[li] = self.cur_line
725
+ offset = {20: (9 if crit else 2), 41: 4, 61: 5, 80: 5}[op] # Create*Begin*Note ctor argument
726
+ note = self._note(info, pos, op, ids, offset)
727
+ for lid in ids:
728
+ self.begin_by_line[lid] = note
729
+ elif op in (21, 63):
730
+ arr = self.line_arr if op == 21 else self.guide_arr
731
+ lid = arr[info.lines[0]]
732
+ note = self._note(info, pos, op, [lid], 1 if op == 21 else 5)
733
+ b = self.begin_by_line.get(lid)
734
+ if b is not None:
735
+ b.view_notes.append(note)
736
+ elif op in (22, 42, 62, 82):
737
+ ids = []
738
+ for li in info.lines:
739
+ ids.append(self.line_arr[li])
740
+ self.line_arr[li] = -1
741
+ note = self._note(info, pos, op, ids, {22: 3, 42: 4, 62: 5, 82: 5}[op])
742
+ self._end_line(note)
743
+ elif op == 40:
744
+ note = self._note(info, pos, op, [], 4)
745
+ elif op == 60:
746
+ note = self._note(info, pos, op, [], 5)
747
+ elif op in (100, 101, 102, 104):
748
+ ids = []
749
+ for li in info.lines:
750
+ lid = self.cur_guide_line + 10001
751
+ self.cur_guide_line += 1
752
+ ids.append(lid)
753
+ self.guide_arr[li] = lid
754
+ note = self._note(info, pos, op, ids, 5 if op == 104 else 0)
755
+ for lid in ids:
756
+ self.begin_by_line[lid] = note
757
+ elif op in (103, 105):
758
+ ids = []
759
+ for li in info.lines:
760
+ ids.append(self.guide_arr[li])
761
+ self.guide_arr[li] = -1
762
+ note = self._note(info, pos, op, ids, 5 if op == 105 else 0)
763
+ self._end_line(note)
764
+ elif op == 122:
765
+ lid = self.line_arr[info.lines[0]]
766
+ if lid < 1:
767
+ lid = self.guide_arr[info.lines[0]]
768
+ if lid < 1:
769
+ self.log.append(f"noteId {self.cur_id} pos {pos}")
770
+ return None
771
+ note = self._note(info, pos, op, [lid], 0)
772
+ b = self.begin_by_line.get(lid)
773
+ if b is not None:
774
+ b.view_notes.append(note)
775
+ else:
776
+ self.log.append(f"noteId {self.cur_id} type {OP_NAMES.get(op, op)}")
777
+ return None
778
+ if op in (1, 20, 22, 40, 41, 42): # IsPairNoteType
779
+ t = self.pair_tmp
780
+ if t is not None and same_position(t.pos, note.pos): # TrySetPairNoteId
781
+ note.pair_id, t.pair_id = t.id, note.id
782
+ self.pair_tmp = note
783
+ note.fever = self._fever(note.pos.ms)
784
+ return note
785
+
786
+ def _fever(self, ms: int) -> int | None:
787
+ """IsFeverTargetNote: first fever with start.ms <= ms <= end.ms."""
788
+ for idx, start, end in self.fevers:
789
+ if start.ms <= ms <= end.ms:
790
+ return idx
791
+ return None
792
+
793
+ def _end_line(self, end: Note) -> None:
794
+ """TrySetSlideNoteId, then SlideEndNote.AddBeginNote."""
795
+ begins = []
796
+ ok = True
797
+ for lid in end.line_ids:
798
+ begin = self.begin_by_line.get(lid)
799
+ if begin is None:
800
+ ok = False
801
+ break
802
+ begins.append(begin)
803
+ notes = [x for x in begin.view_notes if lid in x.line_ids]
804
+ notes.sort(key=lambda x: (x.pos.bar, float(x.pos.progress))) # OrderBy(Bar).ThenBy(BarProgress)
805
+ notes.append(end)
806
+ cur = next(x for x in notes if not x.slide_along) # First(!SlideAlong)
807
+ start_pos, end_pos = begin.pos, end.pos
808
+ mids = [x.pos for x in notes if x is not end and is_judgement_note(x.op)] # <>c__DisplayClass43_0.b__4
809
+ beats = list(_beats_between(start_pos, end_pos, mids, self.bpm_events, self.bar_events))
810
+ prev = begin
811
+ walk = 0
812
+ for k, beat in enumerate(beats):
813
+ if any(is_judgement_note(x.op) and same_position(x.pos, beat) for x in notes): # b__6
814
+ continue
815
+ window = 15000.0 / float(bpm_at_ms(beat.ms, self.bpm_events))
816
+ skip = any(float(mp.ms) - window <= float(beat.ms) and beat.ms < mp.ms for mp in mids)
817
+ if k == 0 and window > float(beat.ms - start_pos.ms):
818
+ skip = True
819
+ if k == len(beats) - 1 and window > float(end_pos.ms - beat.ms):
820
+ skip = True
821
+ while cur.pos.key < beat.key: # walk to the enclosing segment
822
+ nxt = notes[walk]
823
+ walk += 1
824
+ if nxt.id != cur.id and not nxt.slide_along:
825
+ prev, cur = cur, nxt
826
+ if walk >= len(notes):
827
+ break
828
+ ls, le = lane_position(beat, prev, cur)
829
+ cid = lid + k * 10000 + 10000
830
+ # SlideComboNote..ctor: LaneStart/EndIndex = floor (not truncation), Width = end - start + 1
831
+ combo = Note(cid, beat, 121 if skip else 120, int(math.floor(float(ls))), int(math.floor(float(le))),
832
+ ls, le, f32(f32(le - ls) + f32(1.0)), False, False, 0, [lid], 0, 0, 1)
833
+ combo.begin_ref, combo.end_ref = begin, end
834
+ combo.fever = self._fever(beat.ms)
835
+ if begin.op in SLIDE_BEGIN_CLASS: # GuideBeginNote.AddCombo is empty
836
+ begin.combo_notes.append(combo)
837
+ self.begin_by_line.pop(lid, None)
838
+ if ok:
839
+ for b in begins:
840
+ end.begin_notes.append(b)
841
+ if end.op in SLIDE_END_CLASS and b.op in SLIDE_BEGIN_CLASS:
842
+ end.end_combo.extend(b.combo_notes)
843
+
844
+
845
+ def _beats_between(start: Pos, end: Pos, mids: list, bpm_events, bar_events):
846
+ """GetBeatsBetweenNotes d__44: eighth notes of every [boundary, next boundary) segment."""
847
+ bounds = [start, *mids, end]
848
+ for s in range(len(bounds) - 1):
849
+ yield from _eighths(bounds[s], bounds[s + 1], bpm_events, bar_events)
850
+
851
+
852
+ def _eighths(seg_start: Pos, seg_end: Pos, bpm_events, bar_events):
853
+ """GetRelativeEighthNotePositions d__45 (double accumulator, restarts at the segment start)."""
854
+ bar = seg_start.bar
855
+ prog = float(seg_start.progress)
856
+ r = bar_rhythm(bar, bar_events)
857
+ step = 1.0 / (float(r) + float(r))
858
+ while True:
859
+ prog += step
860
+ if prog >= 1.0:
861
+ bar += 1
862
+ prog -= 1.0
863
+ r = bar_rhythm(bar, bar_events)
864
+ step = 1.0 / (float(r) + float(r))
865
+ fp = f32(prog)
866
+ ms = time_ms_from_bar(bar, fp, bpm_events, bar_events)
867
+ if seg_end.ms <= ms:
868
+ return
869
+ r2 = bar_rhythm(bar, bar_events)
870
+ unit = int(f32(r2 + r2))
871
+ rhythm = net_round_int(float(f32(fp * f32(unit))))
872
+ yield Pos(bar, rhythm, unit, fp, ms)
873
+
874
+
875
+ def lane_position(pos: Pos, a: Note, b: Note) -> tuple:
876
+ """MusicScoreUtility.GetLanePosition (one ease, a.LineEaseType, for both edges)."""
877
+ span = f32(b.pos.key - a.pos.key)
878
+ if span <= 0.0:
879
+ e = ease(f32(1.0), a.ease)
880
+ else:
881
+ e = ease(f32(f32(pos.key - a.pos.key) / span), a.ease)
882
+ e = min(max(e, f32(0.0)), f32(1.0))
883
+ return (f32(a.lane_start_f + f32(e * f32(b.lane_start_f - a.lane_start_f))),
884
+ f32(a.lane_end_f + f32(e * f32(b.lane_end_f - a.lane_end_f))))
885
+
886
+
887
+ # --------------------------------------------------------------------------- convert
888
+ @dataclass
889
+ class RuntimeScore:
890
+ """The runtime score SsMusicScoreConverter.Load builds, before it is written as JSON."""
891
+ by_key: dict # position key -> notes, keys in the order CreateNoteDictionary adds them
892
+ infos: list # NoteInfoData
893
+ tc: TickConverter
894
+ events: SsEvents
895
+ bpm_events: list # (bpm, Pos), BpmChangeEventList
896
+ bar_events: list # (beats per bar, Pos), BarChangeEventList
897
+ fevers: list # (index, start Pos, end Pos) sorted by start, FeverList
898
+ skills: list # (index, Pos) in chart order, SkillEventList
899
+ calls: list # (Pos, rhythms), CallChangeEventList
900
+ bar_line_ms: list # BarLineTimeMsList
901
+ log: list
902
+
903
+ @property
904
+ def notes(self) -> list:
905
+ """MusicScore.NoteDictionary in enumeration order. SetNoteList flattens the position dictionary in the
906
+ order its keys were added, then list order; a slide combo tick on a position no other note has is added
907
+ with its slide end, so it comes after the end's position."""
908
+ return [n for lst in self.by_key.values() for n in lst]
909
+
910
+
911
+ def runtime_score(root: dict, mirror: bool = False, start_note_id: int = 0) -> RuntimeScore:
912
+ """SsMusicScoreConverter.Load up to MusicScore.SetNoteList."""
913
+ ev, notes = parse(root)
914
+ tc = TickConverter(ev.sig, ev.bpm)
915
+ infos = _build_note_infos(notes, tc, mirror)
916
+
917
+ bpm_events = [(b, make_position(t, tc)) for t, b in sorted(ev.bpm, key=lambda x: x[0])] # BuildBpmEvents
918
+ bar_events = [(f32(f32(f32(n) * f32(4.0)) / f32(d)), make_position(t, tc)) # BuildBarEvents
919
+ for t, n, d in sorted(ev.sig, key=lambda x: x[0])]
920
+ fevers = [(i, make_position(a, tc), make_position(b, tc)) # BuildFeverList
921
+ for i, (a, b) in enumerate(sorted(ev.fever, key=lambda x: x[0]))]
922
+ skills = [(i, make_position(t, tc)) for i, t in enumerate(ev.skill)] # BuildSkillList
923
+ calls = [] # BuildCallList
924
+ for t, timing in sorted(ev.call, key=lambda x: x[0]):
925
+ n = len(timing)
926
+ calls.append((make_position(t, tc), [float(f32(f32(i + 1.0) / f32(n))) for i, v in enumerate(timing) if v == 1]))
927
+
928
+ max_bar = max((n.bar for n in infos), default=-1) # <Load>b__5_0
929
+ bar_line_ms = [tc.time_ms(tc.bar_head_tick(b)) for b in range(max_bar + 1)]
930
+
931
+ log: list[str] = []
932
+ idict = _info_dictionary(infos, log)
933
+ creator = _Creator(start_note_id, bpm_events, bar_events, fevers)
934
+ by_key: dict = {} # CreateNoteDictionary
935
+ for key in sorted(idict):
936
+ flat = [x for lst in idict[key].values() for x in lst]
937
+ flat.sort(key=lambda x: _priority(x.op))
938
+ for info in flat:
939
+ note = creator.create(info)
940
+ if note is None:
941
+ continue
942
+ by_key.setdefault(note.pos.key, []).append(note)
943
+ if note.op in SLIDE_END_CLASS and note.end_combo:
944
+ for c in note.end_combo:
945
+ lst = by_key.setdefault(c.pos.key, [])
946
+ if c not in lst:
947
+ lst.append(c)
948
+ log += creator.log
949
+ return RuntimeScore(by_key, infos, tc, ev, bpm_events, bar_events, fevers, skills, calls, bar_line_ms, log)
950
+
951
+
952
+ def convert(root: dict, mirror: bool = False, start_note_id: int = 0) -> dict:
953
+ """SsMusicScoreConverter.Load -> plain dict of the runtime score, notes in position order."""
954
+ s = runtime_score(root, mirror, start_note_id)
955
+ ordered = [n for k in sorted(s.by_key) for n in s.by_key[k]]
956
+ return _to_json(ordered, s.infos, s.tc, s.events, s.bpm_events, s.bar_events, s.fevers, s.skills, s.calls,
957
+ s.bar_line_ms, s.log, mirror, start_note_id)
958
+
959
+
960
+ def _pos_json(p: Pos) -> dict:
961
+ return {"bar": p.bar, "rhythm": p.rhythm, "rhythmicUnit": p.unit, "barProgress": float(p.progress), "timeMs": p.ms}
962
+
963
+
964
+ def _to_json(notes, infos, tc, ev, bpm_events, bar_events, fevers, skills, calls, bar_line_ms, log, mirror, start_id):
965
+ out_notes = []
966
+ lines: dict[int, dict] = {}
967
+ for n in notes:
968
+ info = n.info
969
+ rec = {
970
+ "id": n.id, "op": n.op, "opName": OP_NAMES[n.op],
971
+ "timeMs": n.pos.ms, "bar": n.pos.bar, "rhythm": n.pos.rhythm, "rhythmicUnit": n.pos.unit,
972
+ "barProgress": float(n.pos.progress),
973
+ "laneStart": n.lane_start, "laneEnd": n.lane_end,
974
+ "laneStartFloat": float(n.lane_start_f), "laneEndFloat": float(n.lane_end_f), "width": float(n.width),
975
+ "critical": n.crit, "direction": DIRECTION_NAMES[n.direction], "slideAlong": n.slide_along,
976
+ "lineIds": list(n.line_ids), "lineEase": LINE_EASE_NAMES[n.ease], "lineEaseR": LINE_EASE_NAMES[n.ease_r],
977
+ "pairNoteId": n.pair_id, "fever": n.fever,
978
+ "judgement": is_judgement_note(n.op), "flick": is_flick_type(n.op),
979
+ "judgementType": JUDGEMENT_TYPE_NAMES[judgement_type(n.op, n.crit)] if n.op != 123 else None,
980
+ "judgementAreaOffset": OFFSET_TYPE_NAMES[n.offset_type],
981
+ }
982
+ if info is not None:
983
+ rec.update({"tick": info.tick, "laneIndexFloat": float(info.lane_f), "widthFloat": float(info.width_f),
984
+ "laneIndex": info.lane, "laneWidth": info.width, "lineIndices": list(info.lines),
985
+ "visible": info.visible, "alpha": ["none", "in", "out"][info.alpha], "src": list(info.src)})
986
+ else: # SlideComboNote
987
+ rec.update({"lineBeginId": n.begin_ref.id, "lineEndId": n.end_ref.id})
988
+ out_notes.append(rec)
989
+ for lid in n.line_ids:
990
+ ln = lines.setdefault(lid, {"lineId": lid, "type": "guide" if lid > start_id + 10000 else "long",
991
+ "noteIds": [], "comboIds": []})
992
+ (ln["comboIds"] if info is None else ln["noteIds"]).append(n.id)
993
+ judged = sum(1 for n in notes if is_judgement_note(n.op))
994
+ return {
995
+ "format": "nnnotes.live-score/1",
996
+ "mirror": mirror, "startNoteId": start_id, "laneCount": LANE_COUNT,
997
+ "judgementNoteCount": judged,
998
+ "counts": {OP_NAMES[k]: v for k, v in sorted(_count_ops(notes).items())},
999
+ "notes": out_notes,
1000
+ "lines": sorted(lines.values(), key=lambda x: x["lineId"]),
1001
+ "bpmChanges": [{"bpm": float(v), **_pos_json(p)} for v, p in bpm_events],
1002
+ "barChanges": [{"beatsPerBar": float(v), **_pos_json(p)} for v, p in bar_events],
1003
+ "tickSegments": {"bpm": [{"startTick": a, "startTimeMs": b, "bpm": float(c)} for a, b, c in tc.bpm],
1004
+ "sig": [{"startTick": a, "startBar": b, "ticksPerMeasure": c, "numerator": d,
1005
+ "denominator": e} for a, b, c, d, e in tc.sig]},
1006
+ "barLineTimeMs": bar_line_ms,
1007
+ "fever": [{"index": i, "start": _pos_json(a), "end": _pos_json(b)} for i, a, b in fevers],
1008
+ "skill": [{"index": i, **_pos_json(p)} for i, p in skills],
1009
+ "call": [{**_pos_json(p), "rhythms": r} for p, r in calls],
1010
+ "lastNoteTimeMs": max((n.pos.ms for n in notes), default=0),
1011
+ "log": log,
1012
+ }
1013
+
1014
+
1015
+ def _count_ops(notes) -> dict:
1016
+ c: dict = {}
1017
+ for n in notes:
1018
+ c[n.op] = c.get(n.op, 0) + 1
1019
+ return c
1020
+
1021
+
1022
+ # --------------------------------------------------------------------------- extraction
1023
+ def master_table(master_dir: Path, name: str) -> list:
1024
+ """Rows of a decoded master table (<master_dir>/<name>.json, `_allData`)."""
1025
+ return json.loads((Path(master_dir) / f"{name}.json").read_text(encoding="utf-8"))["_allData"]
1026
+
1027
+
1028
+ def chart_key(file_name: str) -> str:
1029
+ """LiveEntry loads `Live/MusicScore/` + MasterLiveMusicScore._musicScoreTextFileName."""
1030
+ return f"Live/MusicScore/{file_name}"
1031
+
1032
+
1033
+ def fetch_chart(cat, file_name: str) -> bytes:
1034
+ """Raw TextAsset bytes of a chart (still gzip, as shipped)."""
1035
+ from .unity import load_closure
1036
+ key = chart_key(file_name)
1037
+ name = file_name.rsplit("/", 1)[-1]
1038
+ for p in cat.fetch_key(key):
1039
+ env = load_closure([p])
1040
+ for o in env.objects:
1041
+ if o.type.name == "TextAsset":
1042
+ d = o.read()
1043
+ if d.m_Name != name:
1044
+ continue
1045
+ raw = d.m_Script
1046
+ if isinstance(raw, str):
1047
+ raw = raw.encode("utf-8", "surrogateescape")
1048
+ return bytes(raw)
1049
+ raise KeyError(key)
1050
+
1051
+
1052
+ def music_rows(master_dir: Path, music_id: int) -> dict:
1053
+ """MasterLiveMusic row, its 4 MasterLiveMusicScore rows, title / band name texts in every language
1054
+ (languages.LANGUAGES) and sound rows."""
1055
+ music = next(r for r in master_table(master_dir, "MasterLiveMusic") if r["_id"] == music_id)
1056
+ scores = {r["_id"]: r for r in master_table(master_dir, "MasterLiveMusicScore")}
1057
+ text = {r["_id"]: r for r in master_table(master_dir, "MasterText")}
1058
+ bands = {r["_id"]: r for r in master_table(master_dir, "MasterBand")}
1059
+ sounds = {r["_id"]: r for r in master_table(master_dir, "MasterSound")}
1060
+ sheets = {r["_id"]: r for r in master_table(master_dir, "MasterSoundCueSheet")}
1061
+
1062
+ def tx(i):
1063
+ r = text.get(i)
1064
+ return None if r is None else {"id": i, **languages.texts(r)}
1065
+
1066
+ def snd(i):
1067
+ r = sounds.get(i)
1068
+ if r is None:
1069
+ return None
1070
+ return {"MasterSound": r, "MasterSoundCueSheet": sheets.get(r["_soundCueSheetID"])}
1071
+
1072
+ return {
1073
+ "MasterLiveMusic": music,
1074
+ "MasterLiveMusicScore": {d: scores.get(music[f"_{d}ID"]) for d in DIFFICULTIES},
1075
+ "title": tx(music["_titleTextID"]),
1076
+ "bands": [{"MasterBand": bands.get(b), "name": tx(bands[b]["_nameTextID"]) if b in bands else None}
1077
+ for b in music["_bandIDs"]],
1078
+ "music": snd(music["_musicSoundID"]),
1079
+ "jingle": snd(music["_jingleSoundID"]),
1080
+ }
1081
+
1082
+
1083
+ def extract_audio(cat, master_dir, music_id: int, out_dir, audio_fmt: str = "flac", *,
1084
+ flac_level: int = cri.FLAC_LEVEL, also=None, rows: dict | None = None) -> dict:
1085
+ """<out>/audio/<cueSheet>/: the live BGM cue sheet decoded per cue + cues.json (cri.decode; `flac_level`,
1086
+ `also` as there). -> the `audio` entry of extract's summary (paths relative to out_dir)."""
1087
+ out_dir = Path(out_dir)
1088
+ rows = rows or music_rows(Path(master_dir), music_id)
1089
+ sheet = rows["music"]["MasterSoundCueSheet"]["_cueSheetName"]
1090
+ cue = rows["music"]["MasterSound"]["_cueName"]
1091
+ files = cri.decode(cat, sheet, out_dir / "audio" / sheet, fmt=audio_fmt, flac_level=flac_level, also=also)
1092
+
1093
+ def rel(p) -> str:
1094
+ return Path(p).relative_to(out_dir).as_posix()
1095
+ return {"cueSheet": sheet, "cue": cue, "file": rel(files[cue]) if cue in files else None,
1096
+ "source": cri.layout(cat, sheet), "cues": {k: rel(v) for k, v in files.items()}}
1097
+
1098
+
1099
+ def extract(cat, master_dir, music_id: int, difficulty: str | int, out_dir, audio: bool = True,
1100
+ audio_fmt: str = "flac", *, flac_level: int = cri.FLAC_LEVEL, also=None, mirror: bool = False) -> dict:
1101
+ """Write <out>/score/<file>.json (shipped chart JSON, un-gzipped), <file>.notes.json (converted runtime
1102
+ notes), <out>/score/master.json (master rows) and <out>/audio/<cueSheet>/ (the live BGM cue sheet decoded
1103
+ per cue + cues.json, extract_audio). `mirror`: also <file>.mirror.notes.json, the notes converted with the
1104
+ MirrorChart option on (convert(mirror=True); summary `notesMirror`)."""
1105
+ out_dir = Path(out_dir)
1106
+ sdir = out_dir / "score"
1107
+ sdir.mkdir(parents=True, exist_ok=True)
1108
+ rows = music_rows(Path(master_dir), music_id)
1109
+ diff = DIFFICULTIES[difficulty] if isinstance(difficulty, int) else difficulty
1110
+ score_row = rows["MasterLiveMusicScore"][diff]
1111
+ fname = score_row["_musicScoreTextFileName"]
1112
+ raw = fetch_chart(cat, fname)
1113
+ txt = gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw
1114
+ base = fname.rsplit("/", 1)[-1]
1115
+ (sdir / f"{base}.json").write_bytes(txt)
1116
+ source = {"key": chart_key(fname), "musicId": music_id, "difficulty": diff,
1117
+ "masterLiveMusicScoreId": score_row["_id"], "fullComboCount": score_row["_fullComboCount"]}
1118
+ root = load_bytes(raw)
1119
+ conv = convert(root)
1120
+ conv["source"] = source
1121
+ write_json(sdir / f"{base}.notes.json", conv)
1122
+ if mirror:
1123
+ conv_m = convert(root, mirror=True)
1124
+ conv_m["source"] = source
1125
+ write_json(sdir / f"{base}.mirror.notes.json", conv_m)
1126
+ write_json(sdir / "master.json", rows)
1127
+ def rel(p) -> str: # paths in summary.json are relative to out_dir, POSIX separators (loadable as relative URLs)
1128
+ return Path(p).relative_to(out_dir).as_posix()
1129
+
1130
+ summary = {"chart": rel(sdir / f"{base}.json"), "notes": rel(sdir / f"{base}.notes.json"),
1131
+ **({"notesMirror": rel(sdir / f"{base}.mirror.notes.json")} if mirror else {}),
1132
+ "master": rel(sdir / "master.json"), "judgementNoteCount": conv["judgementNoteCount"],
1133
+ "fullComboCount": score_row["_fullComboCount"], "noteCount": len(conv["notes"]),
1134
+ "lastNoteTimeMs": conv["lastNoteTimeMs"]}
1135
+ if audio:
1136
+ summary["audio"] = extract_audio(cat, master_dir, music_id, out_dir, audio_fmt, flac_level=flac_level,
1137
+ also=also, rows=rows)
1138
+ write_json(out_dir / "score" / "summary.json", summary)
1139
+ return summary