nnnotes 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/deckdata.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Deck data: one JSON file with every live chart as the client builds it at runtime and the master data tables that
|
|
2
|
+
deck-building tools read, for one master data version (format `nnnotes.deck-data/1`, docs/deck-data.md).
|
|
3
|
+
|
|
4
|
+
Master data is decoded from the files as served: a directory with `MasterManifest.json` and the `.bin` files it
|
|
5
|
+
lists (`nnnotes master download`), or the same layout inside the APK (`assets/Master/`). Each file is checked against
|
|
6
|
+
the manifest's SHA-256 and decoded with master.decode. Charts are the TextAssets `Live/MusicScore/<file name>` of
|
|
7
|
+
every MasterLiveMusicScore row, read from the catalog and converted by score.runtime_score; notes are listed in the
|
|
8
|
+
order the client enumerates them.
|
|
9
|
+
|
|
10
|
+
The output is canonical: minified UTF-8 with one trailing LF, keys in a fixed order, charts sorted by score id,
|
|
11
|
+
numbers the master data writes with a fraction or exponent as the shortest decimal that reads back as the same
|
|
12
|
+
binary32 value (infinity as `1e999` / `-1e999`; a NaN is an error). The same inputs give the same bytes; a `.gz`
|
|
13
|
+
output is gzip with no file name and a zero modification time. The file is written only when every table and chart
|
|
14
|
+
was read; any missing or unreadable input is a DeckDataError naming it.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import gzip
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import zipfile
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Callable
|
|
26
|
+
|
|
27
|
+
FORMAT = "nnnotes.deck-data/1"
|
|
28
|
+
CHART_FORMAT = "nnnotes.live-score/1" # score.convert's format: the converter the notes come from
|
|
29
|
+
CHART_PREFIX = "Live/MusicScore/" # + MasterLiveMusicScore._musicScoreTextFileName
|
|
30
|
+
MANIFEST = "MasterManifest.json"
|
|
31
|
+
APK_MASTER = "assets/Master/"
|
|
32
|
+
EMBEDDED = "embedded" # provenance region and master source of the APK's master data
|
|
33
|
+
API = "api"
|
|
34
|
+
|
|
35
|
+
_SKILL_EFFECT = ("_level _skillTriggerConditionGroup _skillTriggerType _skillConditionGroup "
|
|
36
|
+
"_skillReleaseConditionGroup _skillTargetIDs _skillEffectType _activationTimeSecond _effectValue "
|
|
37
|
+
"_maxEffectValue _effectLimitCount _skillCumulativeConditionID _effectExecuteLimitCount "
|
|
38
|
+
"_effectExecuteLimitResetConditionGroup")
|
|
39
|
+
_WHOLE = None # every column of the table, in the order the rows first have them
|
|
40
|
+
|
|
41
|
+
# (table, columns), in file order. A table with a column list exports those columns; a whole table all of its own.
|
|
42
|
+
TABLES: tuple[tuple[str, tuple[str, ...] | None], ...] = tuple(
|
|
43
|
+
(t, None if c is _WHOLE else tuple(c.split())) for t, c in (
|
|
44
|
+
("MasterMemberCard", "_id _characterID _rarity _cardType _bestMusicTagIDs _performancePowerMax _technicPowerMax "
|
|
45
|
+
"_visualPowerMax _memberCardLevelGroup _memberCardAwakeGroup _memberCardRankGroup "
|
|
46
|
+
"_leaderSkillID _liveSkillID _gekisouSkillID"),
|
|
47
|
+
("MasterMemberCardLevel", "_id _group _level _exp _performanceRate _technicRate _visualRate"),
|
|
48
|
+
("MasterMemberCardLevelLimit", "_id _rarity _awakeCount _limitLevel"),
|
|
49
|
+
("MasterMemberCardAwake", "_id _group _awakeCount _performanceRate _technicRate _visualRate"),
|
|
50
|
+
("MasterMemberCardRank", "_id _group _rank _performanceRate _technicRate _visualRate _leaderSkillLevel "
|
|
51
|
+
"_musicTypeBonusRate _musicTagBonusRate"),
|
|
52
|
+
("MasterSupportCard", "_id _characterIDs _rarity _cardType _performancePowerMax _technicPowerMax "
|
|
53
|
+
"_visualPowerMax _supportCardLevelGroup _supportCardRankGroup _supportSkillId01 "
|
|
54
|
+
"_supportSkillId02 _gekisouSupportSkillId01 _gekisouSupportSkillId02"),
|
|
55
|
+
("MasterSupportCardLevel", "_id _group _level _exp _performanceRate _technicRate _visualRate"),
|
|
56
|
+
("MasterSupportCardRank", "_id _group _rank _limitLevel _cardTypeLinkBonusRate _supportSkill01Level "
|
|
57
|
+
"_supportSkill02Level _gekisouSupportSkill01Level _gekisouSupportSkill02Level"),
|
|
58
|
+
("MasterCharacter", "_id _bandID"),
|
|
59
|
+
("MasterBand", "_id"),
|
|
60
|
+
("MasterCharacterRank", "_id _rank _bonus"),
|
|
61
|
+
("MasterCharacterTotalRank", "_id _totalRank _bonus"),
|
|
62
|
+
("MasterBandItemSkillEffect", "_id _bandItemId _level _skillTargetIDs _skillEffectType _effectValue"),
|
|
63
|
+
("MasterBandItem", "_id _bandId"),
|
|
64
|
+
("MasterBandItemLevel", "_id _bandItemId _level _playerRank"),
|
|
65
|
+
("MasterVipRankBonus", "_id _vipRank _vipBonusType _value"),
|
|
66
|
+
("MasterMemoryMusicGroup", "_id _skillTargetIds"),
|
|
67
|
+
("MasterMemoryMusic", "_id _groupId"),
|
|
68
|
+
("MasterMemoryMusicBonus", "_id _groupId _scoreRank _performance _technic _visual"),
|
|
69
|
+
("MasterMemoryMemberLevel", "_id _point _performance _technic _visual"),
|
|
70
|
+
("MasterMemorySupportLevel", "_id _point _performance _technic _visual"),
|
|
71
|
+
("MasterSkillTarget", "_id _skillTargetType _characterID _bandID _cardType _tagID _judgement _liveMusicType "
|
|
72
|
+
"_gekisouMissionType _liveSkillCategories _gekisouSkillCategories"),
|
|
73
|
+
("MasterSkillCondition", "_id _conditionType _conditionValues _isPositive _conditionTargetIDs"),
|
|
74
|
+
("MasterSkillConditionSet", "_id _group _conditionIds"),
|
|
75
|
+
("MasterSkillCumulativeCondition", "_id _skillCumulativeConditionType _conditionValues _conditionTargetIDs "
|
|
76
|
+
"_maxCumulativeCount"),
|
|
77
|
+
("MasterSkillEffectSetting", "_id _skillEffectType _phase"),
|
|
78
|
+
("MasterLeaderSkillEffect", "_id _leaderSkillID _level _skillConditionGroup _skillTargetIDs _skillEffectType "
|
|
79
|
+
"_effectValue _skillCumulativeConditionID"),
|
|
80
|
+
("MasterLiveSkill", "_id _skillCategories"),
|
|
81
|
+
("MasterLiveSkillEffect", "_id _liveSkillID _level _skillConditionGroup _skillReleaseConditionGroup "
|
|
82
|
+
"_skillTargetIDs _skillEffectType _activationTimeSecond _effectValue _maxEffectValue "
|
|
83
|
+
"_effectLimitCount _skillCumulativeConditionID _effectExecuteLimitCount "
|
|
84
|
+
"_effectExecuteLimitResetConditionGroup"),
|
|
85
|
+
("MasterSupportSkill", "_id"),
|
|
86
|
+
("MasterSupportSkillEffect", "_id _supportSkillID " + _SKILL_EFFECT),
|
|
87
|
+
("MasterGekisouSkill", "_id _gekisouMissionType _skillCategories"),
|
|
88
|
+
("MasterGekisouSkillEffect", "_id _gekisouSkillID " + _SKILL_EFFECT),
|
|
89
|
+
("MasterGekisouSupportSkill", "_id _gekisouSupportSkillExecTiming _gekisouMissionType"),
|
|
90
|
+
("MasterGekisouSupportSkillEffect", "_id _gekisouSupportSkillID " + _SKILL_EFFECT),
|
|
91
|
+
("MasterLiveNoteParameter", "_id _noteOperateType _scorePercent"),
|
|
92
|
+
("MasterLiveJudgementParameter", "_id _noteSimulateJudgement _scorePercent _damage"),
|
|
93
|
+
("MasterLiveJudgementTiming", "_id _assistLevel _judgementPriority _noteJudgementType _noteSimulateJudgement "
|
|
94
|
+
"_beforeMs _afterMs"),
|
|
95
|
+
("MasterLiveComboScoreBonus", "_id _comboBonusType _requiredComboCount _bonusFactor"),
|
|
96
|
+
("MasterLiveSettings", _WHOLE),
|
|
97
|
+
("MasterParameter", _WHOLE),
|
|
98
|
+
("MasterLiveGekisouLuckBasePoint", "_id _noteCategory _noteSimulateJudgement _weight _basePoint"),
|
|
99
|
+
("MasterLiveGekisouLuckBonusLot", "_id _chanceLotType _lotResult _weight"),
|
|
100
|
+
("MasterLiveGekisouRankingScoreBonus", "_id _missionPattern _rank _count _scoreBonusPercent"),
|
|
101
|
+
("MasterLiveMusic", "_id _musicType _bestMusicTagIDs _liveScoreRankGroup _easyID _normalID _hardID _expertID "
|
|
102
|
+
"_gekisouMission1 _gekisouMission2 _gekisouMission3"),
|
|
103
|
+
("MasterLiveMusicScore", "_id _musicScoreTextFileName _musicScoreLevel _fullComboCount"),
|
|
104
|
+
("MasterArenaMusic", _WHOLE),
|
|
105
|
+
("MasterChallengeMusic", _WHOLE),
|
|
106
|
+
("MasterLiveScoreRank", "_id _group _liveScoreRank _requiredScore _battleLiveRequiredScore"),
|
|
107
|
+
("MasterEvent", _WHOLE),
|
|
108
|
+
("MasterEventEffect", _WHOLE),
|
|
109
|
+
("MasterLiveEventPoint", _WHOLE),
|
|
110
|
+
("MasterChallengeLiveEventPoint", _WHOLE),
|
|
111
|
+
("MasterLiveChallengePoint", _WHOLE),
|
|
112
|
+
("MasterLiveMusicBoostBonus", "_id _consumedLiveBoostCount _liveMusicRewardRate _playerExpRate "
|
|
113
|
+
"_memberCardExpRate _friendshipExpRate _eventPointRate"),
|
|
114
|
+
("MasterChallengeMusicBoostBonus", _WHOLE),
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class DeckDataError(ValueError):
|
|
119
|
+
"""An input the deck data cannot be made from (the message names it)."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------- master data as served
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class MasterSource:
|
|
125
|
+
"""Master data files as served: `version` and the SHA-256 of each file name from the manifest, and a reader of
|
|
126
|
+
files by name."""
|
|
127
|
+
source: str # API or EMBEDDED
|
|
128
|
+
where: str # the directory or APK, for messages
|
|
129
|
+
version: str | None
|
|
130
|
+
hashes: dict[str, str] # file name -> sha256 (lowercase hex; "" when not listed)
|
|
131
|
+
read: Callable[[list[str]], dict[str, bytes]] # file names -> {name: bytes}; a missing file raises KeyError
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _manifest(raw: bytes, where: str) -> tuple[str | None, dict[str, str]]:
|
|
135
|
+
try:
|
|
136
|
+
doc = json.loads(raw.decode("utf-8"))
|
|
137
|
+
files = doc["files"]
|
|
138
|
+
hashes = {f["name"]: str(f.get("hash") or "").lower() for f in files}
|
|
139
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
140
|
+
raise DeckDataError(f"{where}: {MANIFEST} is not a master data manifest") from None
|
|
141
|
+
version = doc.get("version")
|
|
142
|
+
return (str(version) if version is not None else None), hashes
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def master_files(directory) -> MasterSource:
|
|
146
|
+
"""The master data files of a directory written by `nnnotes master download` (MasterManifest.json + .bin)."""
|
|
147
|
+
d = Path(directory)
|
|
148
|
+
m = d / MANIFEST
|
|
149
|
+
if not m.is_file():
|
|
150
|
+
raise DeckDataError(f"{d}: no {MANIFEST} (a directory written by `nnnotes master download`)")
|
|
151
|
+
version, hashes = _manifest(m.read_bytes(), str(d))
|
|
152
|
+
|
|
153
|
+
def read(names):
|
|
154
|
+
out = {}
|
|
155
|
+
for n in names:
|
|
156
|
+
try:
|
|
157
|
+
out[n] = (d / n).read_bytes()
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
raise KeyError(n) from None
|
|
160
|
+
return out
|
|
161
|
+
return MasterSource(API, str(d), version, hashes, read)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def apk_master(apk) -> MasterSource:
|
|
165
|
+
"""The master data files the APK ships (`assets/Master/`: MasterManifest.json + .bin)."""
|
|
166
|
+
apk = Path(apk)
|
|
167
|
+
try:
|
|
168
|
+
with zipfile.ZipFile(apk) as z:
|
|
169
|
+
raw = z.read(APK_MASTER + MANIFEST)
|
|
170
|
+
except KeyError:
|
|
171
|
+
raise DeckDataError(f"{apk}: no {APK_MASTER}{MANIFEST}") from None
|
|
172
|
+
except (zipfile.BadZipFile, OSError):
|
|
173
|
+
raise DeckDataError(f"{apk}: not a readable APK") from None
|
|
174
|
+
version, hashes = _manifest(raw, f"{apk} {APK_MASTER}")
|
|
175
|
+
|
|
176
|
+
def read(names):
|
|
177
|
+
with zipfile.ZipFile(apk) as z:
|
|
178
|
+
have = set(z.namelist())
|
|
179
|
+
missing = [n for n in names if APK_MASTER + n not in have]
|
|
180
|
+
if missing:
|
|
181
|
+
raise KeyError(missing[0])
|
|
182
|
+
return {n: z.read(APK_MASTER + n) for n in names}
|
|
183
|
+
return MasterSource(EMBEDDED, f"{apk} {APK_MASTER}", version, hashes, read)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def read_master(src: MasterSource, key) -> tuple[dict[str, list[dict]], dict[str, str]]:
|
|
187
|
+
"""The rows (`_allData`) of every table of TABLES and the SHA-256 of each file as served. `key`: a
|
|
188
|
+
master.MasterKey."""
|
|
189
|
+
from . import master
|
|
190
|
+
names = {t: f"{t}.bin" for t, _ in TABLES}
|
|
191
|
+
unlisted = [t for t, n in names.items() if n not in src.hashes]
|
|
192
|
+
if unlisted:
|
|
193
|
+
raise DeckDataError(f"master data {src.where}: {MANIFEST} lists no {', '.join(unlisted)}")
|
|
194
|
+
try:
|
|
195
|
+
data = src.read(list(names.values()))
|
|
196
|
+
except KeyError as e:
|
|
197
|
+
raise DeckDataError(f"master data {src.where}: no file {e.args[0]}") from None
|
|
198
|
+
rk = master.round_keys(key.key)
|
|
199
|
+
tables, shas = {}, {}
|
|
200
|
+
for t, n in names.items():
|
|
201
|
+
raw = data[n]
|
|
202
|
+
sha = hashlib.sha256(raw).hexdigest()
|
|
203
|
+
if src.hashes[n] and sha != src.hashes[n]:
|
|
204
|
+
raise DeckDataError(f"master data {src.where}: {n}: sha256 differs from the manifest")
|
|
205
|
+
try:
|
|
206
|
+
doc = json.loads(master.decode(raw, key, rk).decode("utf-8"))
|
|
207
|
+
except Exception as e: # padding, gzip, UTF-8 or JSON: the file cannot be read
|
|
208
|
+
raise DeckDataError(f"master data {src.where}: {n} cannot be decoded ({type(e).__name__}: "
|
|
209
|
+
f"{str(e)[:120]})") from None
|
|
210
|
+
rows = doc.get("_allData") if isinstance(doc, dict) else None
|
|
211
|
+
if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows):
|
|
212
|
+
raise DeckDataError(f"master data {src.where}: {n} has no `_allData` rows")
|
|
213
|
+
tables[t], shas[t] = rows, sha
|
|
214
|
+
return tables, shas
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------- canonical values
|
|
218
|
+
class _Num(str):
|
|
219
|
+
"""A number token, written as it is."""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def f32_token(v: float) -> _Num:
|
|
223
|
+
"""The shortest decimal that reads back as binary32(v); +/-infinity as 1e999 / -1e999; NaN raises ValueError."""
|
|
224
|
+
import numpy as np
|
|
225
|
+
with np.errstate(over="ignore"):
|
|
226
|
+
x = np.float32(v)
|
|
227
|
+
if np.isnan(x):
|
|
228
|
+
raise ValueError("NaN (no JSON representation)")
|
|
229
|
+
if np.isinf(x):
|
|
230
|
+
return _Num("1e999" if x > 0 else "-1e999")
|
|
231
|
+
if x == 0 or 1e-4 <= abs(x) < 1e16:
|
|
232
|
+
return _Num(np.format_float_positional(x, unique=True, trim="0"))
|
|
233
|
+
return _Num(np.format_float_scientific(x, unique=True, trim="0"))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _value(v, where: str):
|
|
237
|
+
if isinstance(v, float):
|
|
238
|
+
try:
|
|
239
|
+
return f32_token(v)
|
|
240
|
+
except ValueError as e:
|
|
241
|
+
raise DeckDataError(f"{where}: {e}") from None
|
|
242
|
+
if isinstance(v, list):
|
|
243
|
+
return [_value(x, where) for x in v]
|
|
244
|
+
if isinstance(v, dict):
|
|
245
|
+
return {k: _value(x, where) for k, x in v.items()}
|
|
246
|
+
return v
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def master_subset(tables: dict[str, list[dict]]) -> dict:
|
|
250
|
+
"""{table: {"columns", "rows"}} for TABLES, rows in the served order. A table with a column list must have each
|
|
251
|
+
of them in every row; a whole table exports the columns its rows have (none when it has no rows)."""
|
|
252
|
+
out = {}
|
|
253
|
+
for name, cols in TABLES:
|
|
254
|
+
rows = tables[name]
|
|
255
|
+
if cols is None:
|
|
256
|
+
seen: dict[str, None] = {}
|
|
257
|
+
for r in rows:
|
|
258
|
+
seen.update(dict.fromkeys(r))
|
|
259
|
+
cols = tuple(seen)
|
|
260
|
+
for i, r in enumerate(rows):
|
|
261
|
+
missing = [c for c in cols if c not in r]
|
|
262
|
+
if missing:
|
|
263
|
+
raise DeckDataError(f"{name}: row {i} (_id {r.get('_id')}) has no column {missing[0]}")
|
|
264
|
+
out[name] = {"columns": list(cols),
|
|
265
|
+
"rows": [[_value(r[c], f"{name} row {i} {c}") for c in cols] for i, r in enumerate(rows)]}
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------- charts
|
|
270
|
+
def chart_key(file_name: str) -> str:
|
|
271
|
+
return CHART_PREFIX + file_name
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def chart_record(score_id: int, key: str, raw: bytes) -> dict:
|
|
275
|
+
"""The record of one chart: the TextAsset bytes as shipped -> runtime notes in enumeration order, skill events
|
|
276
|
+
in chart order, fever ranges sorted by start."""
|
|
277
|
+
from . import score
|
|
278
|
+
try:
|
|
279
|
+
root = score.load_bytes(raw)
|
|
280
|
+
except (ValueError, OSError, EOFError):
|
|
281
|
+
raise DeckDataError(f"chart {key}: not a chart (gzip or JSON cannot be read)") from None
|
|
282
|
+
try:
|
|
283
|
+
rs = score.runtime_score(root)
|
|
284
|
+
except Exception as e: # the converter's own errors and malformed chart fields
|
|
285
|
+
raise DeckDataError(f"chart {key}: cannot be converted ({type(e).__name__}: {e})") from None
|
|
286
|
+
ids, ops, judgements, times = [], [], [], []
|
|
287
|
+
seen = set()
|
|
288
|
+
for n in rs.notes:
|
|
289
|
+
if n.id in seen:
|
|
290
|
+
raise DeckDataError(f"chart {key}: note id {n.id} occurs twice")
|
|
291
|
+
seen.add(n.id)
|
|
292
|
+
try:
|
|
293
|
+
jt = score.judgement_type(n.op, n.crit)
|
|
294
|
+
except ValueError:
|
|
295
|
+
raise DeckDataError(f"chart {key}: note {n.id} has operate type {n.op}, which has no judgement type") \
|
|
296
|
+
from None
|
|
297
|
+
ids.append(int(n.id))
|
|
298
|
+
ops.append(int(n.op))
|
|
299
|
+
judgements.append(int(jt))
|
|
300
|
+
times.append(int(n.pos.ms))
|
|
301
|
+
return {
|
|
302
|
+
"scoreId": score_id,
|
|
303
|
+
"asset": {"key": key, "sha256": hashlib.sha256(raw).hexdigest()},
|
|
304
|
+
"notes": {"id": ids, "op": ops, "judgementType": judgements, "timeMs": times},
|
|
305
|
+
"skillEvents": {"timeMs": [int(p.ms) for _, p in rs.skills]},
|
|
306
|
+
"fevers": {"startMs": [int(a.ms) for _, a, _ in rs.fevers], "endMs": [int(b.ms) for _, _, b in rs.fevers]},
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def charts(score_rows: list[dict], fetch: Callable[[str], bytes]) -> list[dict]:
|
|
311
|
+
"""One record per MasterLiveMusicScore row, sorted by `_id`. `fetch(file name)`: the chart TextAsset's bytes;
|
|
312
|
+
KeyError when there is none."""
|
|
313
|
+
dup = sorted(i for i, n in Counter(r["_id"] for r in score_rows).items() if n > 1)
|
|
314
|
+
if dup:
|
|
315
|
+
raise DeckDataError(f"MasterLiveMusicScore: _id {dup[0]} occurs twice")
|
|
316
|
+
out = []
|
|
317
|
+
for r in sorted(score_rows, key=lambda r: r["_id"]):
|
|
318
|
+
key = chart_key(r["_musicScoreTextFileName"])
|
|
319
|
+
try:
|
|
320
|
+
raw = fetch(r["_musicScoreTextFileName"])
|
|
321
|
+
except KeyError:
|
|
322
|
+
raise DeckDataError(f"chart {key} (MasterLiveMusicScore {r['_id']}): no such asset") from None
|
|
323
|
+
out.append(chart_record(r["_id"], key, raw))
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def catalog_fetch(cat) -> Callable[[str], bytes]:
|
|
328
|
+
"""fetch() for charts from a catalog: score.fetch_chart, KeyError for a key the catalog does not have."""
|
|
329
|
+
from . import score
|
|
330
|
+
|
|
331
|
+
def fetch(file_name: str) -> bytes:
|
|
332
|
+
if not cat.has(chart_key(file_name)):
|
|
333
|
+
raise KeyError(chart_key(file_name))
|
|
334
|
+
return score.fetch_chart(cat, file_name)
|
|
335
|
+
return fetch
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------- provenance
|
|
339
|
+
def apk_client(apk) -> dict:
|
|
340
|
+
"""{versionName, versionCode} of an APK's AndroidManifest.xml (None when absent)."""
|
|
341
|
+
from .player import MANIFEST_IN_APK, manifest_version_code, manifest_version_name
|
|
342
|
+
try:
|
|
343
|
+
with zipfile.ZipFile(apk) as z:
|
|
344
|
+
data = z.read(MANIFEST_IN_APK) if MANIFEST_IN_APK in z.namelist() else None
|
|
345
|
+
except (zipfile.BadZipFile, OSError):
|
|
346
|
+
raise DeckDataError(f"{apk}: not a readable APK") from None
|
|
347
|
+
if data is None:
|
|
348
|
+
return {"versionName": None, "versionCode": None}
|
|
349
|
+
return {"versionName": manifest_version_name(data), "versionCode": manifest_version_code(data)}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def catalog_info(cat, store_root=None) -> dict:
|
|
353
|
+
"""{resourceVersion, sha256} of a catalog's remote catalog file (resource_version)."""
|
|
354
|
+
sha = hashlib.sha256(cat.sources()["remote"]).hexdigest()
|
|
355
|
+
return {"resourceVersion": resource_version(store_root, sha), "sha256": sha}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def resource_version(store_root, remote_sha: str) -> str | None:
|
|
359
|
+
"""The resource version recorded for the remote catalog `remote_sha` by the catalog versions of a store
|
|
360
|
+
(the latest import that has one), None when none is recorded. Read only."""
|
|
361
|
+
if store_root is None:
|
|
362
|
+
return None
|
|
363
|
+
from .catalogdb import CatalogDB
|
|
364
|
+
try:
|
|
365
|
+
versions = CatalogDB(store_root).versions()
|
|
366
|
+
except (OSError, ValueError):
|
|
367
|
+
return None
|
|
368
|
+
for v in reversed(versions):
|
|
369
|
+
if v.get("remote", {}).get("sha256") == remote_sha and v.get("resourceVersion"):
|
|
370
|
+
return v["resourceVersion"]
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------- document
|
|
375
|
+
def build(tables: dict[str, list[dict]], table_sha: dict[str, str], fetch: Callable[[str], bytes], *, region: str,
|
|
376
|
+
client: dict, catalog: dict, master_source: str, master_version: str | None) -> dict:
|
|
377
|
+
"""The deck data document. `catalog`: {resourceVersion, sha256} of the catalog the charts come from."""
|
|
378
|
+
from . import __version__
|
|
379
|
+
subset = master_subset(tables)
|
|
380
|
+
return {
|
|
381
|
+
"format": FORMAT,
|
|
382
|
+
"provenance": {
|
|
383
|
+
"region": region,
|
|
384
|
+
"client": {"versionName": client.get("versionName"), "versionCode": client.get("versionCode")},
|
|
385
|
+
"catalog": {"resourceVersion": catalog.get("resourceVersion"), "sha256": catalog.get("sha256")},
|
|
386
|
+
"master": {"source": master_source, "version": master_version,
|
|
387
|
+
"tables": {t: {"sha256": table_sha[t]} for t, _ in TABLES}},
|
|
388
|
+
"exporter": {"name": "nnnotes", "version": __version__, "chartFormat": CHART_FORMAT},
|
|
389
|
+
},
|
|
390
|
+
"master": subset,
|
|
391
|
+
"charts": charts(tables["MasterLiveMusicScore"], fetch),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _emit(v, out: list) -> None:
|
|
396
|
+
if isinstance(v, _Num):
|
|
397
|
+
out.append(v)
|
|
398
|
+
elif isinstance(v, str):
|
|
399
|
+
out.append(json.dumps(v, ensure_ascii=False))
|
|
400
|
+
elif v is None:
|
|
401
|
+
out.append("null")
|
|
402
|
+
elif v is True:
|
|
403
|
+
out.append("true")
|
|
404
|
+
elif v is False:
|
|
405
|
+
out.append("false")
|
|
406
|
+
elif isinstance(v, int):
|
|
407
|
+
out.append(str(int(v)))
|
|
408
|
+
elif isinstance(v, dict):
|
|
409
|
+
out.append("{")
|
|
410
|
+
for i, (k, x) in enumerate(v.items()):
|
|
411
|
+
if not isinstance(k, str):
|
|
412
|
+
raise TypeError(f"key {k!r} is not a string")
|
|
413
|
+
if i:
|
|
414
|
+
out.append(",")
|
|
415
|
+
out.append(json.dumps(k, ensure_ascii=False))
|
|
416
|
+
out.append(":")
|
|
417
|
+
_emit(x, out)
|
|
418
|
+
out.append("}")
|
|
419
|
+
elif isinstance(v, (list, tuple)):
|
|
420
|
+
if all(type(x) is int for x in v):
|
|
421
|
+
out.append("[" + ",".join(map(str, v)) + "]")
|
|
422
|
+
return
|
|
423
|
+
out.append("[")
|
|
424
|
+
for i, x in enumerate(v):
|
|
425
|
+
if i:
|
|
426
|
+
out.append(",")
|
|
427
|
+
_emit(x, out)
|
|
428
|
+
out.append("]")
|
|
429
|
+
else: # a float must have been made a _Num (f32_token)
|
|
430
|
+
raise TypeError(f"no canonical form for {type(v).__name__}")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def encode(doc: dict) -> bytes:
|
|
434
|
+
"""The canonical bytes of a document: minified UTF-8, keys in their order, one trailing LF."""
|
|
435
|
+
out: list[str] = []
|
|
436
|
+
_emit(doc, out)
|
|
437
|
+
out.append("\n")
|
|
438
|
+
return "".join(out).encode("utf-8")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def file_bytes(data: bytes, gz: bool) -> bytes:
|
|
442
|
+
"""The bytes written: `data`, or with `gz` gzip with no file name and modification time 0."""
|
|
443
|
+
return gzip.compress(data, compresslevel=9, mtime=0) if gz else data
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def export(out, src: MasterSource, key, fetch: Callable[[str], bytes], *, region: str, client: dict,
|
|
447
|
+
catalog: dict) -> dict:
|
|
448
|
+
"""Read the master data and every chart, then write the file `out` (gzip when it ends in `.gz`) through a
|
|
449
|
+
temporary file and a rename. Returns the summary."""
|
|
450
|
+
from .cache import write_atomic
|
|
451
|
+
out = Path(out)
|
|
452
|
+
tables, shas = read_master(src, key)
|
|
453
|
+
doc = build(tables, shas, fetch, region=region, client=client, catalog=catalog, master_source=src.source,
|
|
454
|
+
master_version=src.version)
|
|
455
|
+
data = encode(doc)
|
|
456
|
+
written = file_bytes(data, out.name.endswith(".gz"))
|
|
457
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
write_atomic(out, written)
|
|
459
|
+
return {"out": str(out), "format": FORMAT, "region": region, "masterSource": src.source,
|
|
460
|
+
"masterVersion": src.version, "tables": len(TABLES), "rows": sum(len(r) for r in tables.values()),
|
|
461
|
+
"charts": len(doc["charts"]), "notes": sum(len(c["notes"]["id"]) for c in doc["charts"]),
|
|
462
|
+
"bytes": len(data), "fileBytes": len(written), "sha256": hashlib.sha256(written).hexdigest()}
|