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/liveui.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Live start canvas (Live/UILiveStartCanvas, LightWeight mode) -> <live>/liveui/ (liveui.json, textures/).
|
|
2
|
+
|
|
3
|
+
livescene/scene.json already carries the canvas hierarchy with every component (serialized TMP texts, inline
|
|
4
|
+
sprite records, the start timeline and its UILiveStartCanvasTrack clips) and livescene/shaders the shaders it
|
|
5
|
+
draws with. This module adds what a renderer needs beyond that to draw the canvas the way
|
|
6
|
+
UILiveStartCanvas.Initialize leaves it:
|
|
7
|
+
|
|
8
|
+
strings the texts Initialize sets: music title, singer, the credit lines (LocalizeText master text id +
|
|
9
|
+
replacement word), music level, high score, resolved as the game resolves them
|
|
10
|
+
texts per TMP text of the drawn part: the LocalizeText font / material / line spacing overrides; with
|
|
11
|
+
fonts "open" also its text record (textstyle: layout, colours, style values of its material)
|
|
12
|
+
fonts fonts "game": TMP font assets reduced to the characters shown (tmpfont); FZLTH's runtime glyphs
|
|
13
|
+
generated from its source font; VibeMOPro's glyph pair adjustment lookup (kerning). fonts "open"
|
|
14
|
+
(default): the font asset names only, no font data and no atlases
|
|
15
|
+
materials fonts "game": the localized TMP materials ("<font> - Default") and the fonts' default materials;
|
|
16
|
+
fonts "open": none
|
|
17
|
+
sprites WhiteRect, Songtitle_base, the Difficulty_* icons of _difficultyPairs and the music jacket; their
|
|
18
|
+
texel blocks copied into textures/ (export.TexelPacker, deferred-texture mode)
|
|
19
|
+
controller the root Animator's controller (evidence only: the start timeline drives the canvas)
|
|
20
|
+
|
|
21
|
+
Texts and fonts follow the client language `language` (a catalog language code, languages.LANGUAGES): its
|
|
22
|
+
LanguageMode selects the text table column, the LocalizeManager fonts and the line spacing.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .catalog import Catalog
|
|
30
|
+
from .export import Exporter, TexelPacker, _safe
|
|
31
|
+
from .jsonio import write_json
|
|
32
|
+
from .player import PlayerData
|
|
33
|
+
from .score import master_table
|
|
34
|
+
from . import languages, textstyle, tmpfont
|
|
35
|
+
from .textstyle import LANGUAGE_LINE_SPACING
|
|
36
|
+
|
|
37
|
+
SCENE_KEY = "EmbScene/Live"
|
|
38
|
+
CANVAS = "Live/UILiveStartCanvas"
|
|
39
|
+
CONTENT = f"{CANVAS}/root/UISafeArea/ContentArea"
|
|
40
|
+
# Parts drawn in LightWeight mode: root/base (a _simpleModeOnlyObjects entry), center_bottom and
|
|
41
|
+
# simple_music_jacket_panel (left_top is _normalModeOnlyObjects, right_top/battle_panel is _battleLiveGameObjects).
|
|
42
|
+
DRAWN = (f"{CANVAS}/root/base", f"{CONTENT}/center_bottom", f"{CONTENT}/simple_music_jacket_panel")
|
|
43
|
+
JACKET_KEY = "Image/Jacket/{jacket}"
|
|
44
|
+
# App.LiveBase.LiveDifficulty
|
|
45
|
+
LIVE_DIFFICULTY = {"easy": 0, "normal": 1, "hard": 2, "expert": 3, "master": 4}
|
|
46
|
+
TMP_STUBS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet")
|
|
47
|
+
TMP_CLASS = "TextMeshProUGUI"
|
|
48
|
+
JAPANESE_COLUMN = languages.column("ja")
|
|
49
|
+
FONT_CHOICES = ("open", "game")
|
|
50
|
+
FONT_NOTE = "not exported (fonts \"open\"): the text record in `texts` describes the text without font data"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _drawn(path: str) -> bool:
|
|
54
|
+
return any(path == d or path.startswith(d + "/") for d in DRAWN)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _comp(node: dict, cls: str) -> dict | None:
|
|
58
|
+
hit = [c for c in node["components"] if c.get("class", c["type"]) == cls]
|
|
59
|
+
if len(hit) > 1:
|
|
60
|
+
raise RuntimeError(f"{node['path']}: {len(hit)} {cls}")
|
|
61
|
+
return hit[0] if hit else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_strings(master: Path, music_id: int, difficulty: str, column: str) -> tuple[dict, dict]:
|
|
65
|
+
"""The strings UILiveStartCanvas.Initialize sets (IReadOnlyLiveMusicScore slots 2..8), for a fresh profile, with
|
|
66
|
+
the text table column `column` of the current LanguageMode. -> (strings, sources)."""
|
|
67
|
+
music = next(r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id)
|
|
68
|
+
text = {r["_id"]: r for r in master_table(master, "MasterText")}
|
|
69
|
+
|
|
70
|
+
def localized(tid: str) -> str:
|
|
71
|
+
# MasterLoader.GetLocalizedText in the current LanguageMode: the column as it is, "" included (no
|
|
72
|
+
# fallback language); LocalizedTextTokenResolver.Resolve returns an empty text unchanged
|
|
73
|
+
return text[tid][column]
|
|
74
|
+
|
|
75
|
+
def word(tid: str) -> str:
|
|
76
|
+
# MasterLiveMusic.get_LyricistText / get_ComposerText / get_ArrangerText
|
|
77
|
+
# -> LocalizeManager.GetLocalizedText: an empty id is returned as it is, else the loader's text
|
|
78
|
+
return localized(tid) if tid else tid
|
|
79
|
+
|
|
80
|
+
def credit(label_id: str, word_id: str) -> str:
|
|
81
|
+
# LocalizeText.RegisterReplacementWord("{0}", word) stores the word and re-runs SetMasterTextId: an empty
|
|
82
|
+
# label text goes to the text as it is, otherwise String.Replace(key, word) for each registered word
|
|
83
|
+
# (ordinal, every occurrence; an empty word leaves "編曲:"). LocalizedTextTokenResolver.Resolve knows no
|
|
84
|
+
# "{0}" token. Initialize and FitUI test no text for emptiness.
|
|
85
|
+
label = localized(label_id)
|
|
86
|
+
return label.replace("{0}", word(word_id)) if label else label
|
|
87
|
+
|
|
88
|
+
# SongTitleDisplaySetting._useTranslation false (option 999 unset, GetBool default false):
|
|
89
|
+
# MasterLiveMusic.TitleText = loader.Get(_titleTextID, Japanese)
|
|
90
|
+
title = text[music["_titleTextID"]][JAPANESE_COLUMN]
|
|
91
|
+
# GetBandNameTextId: _bandNameTextID, else MasterBand(_bandIDs[0])._nameTextID
|
|
92
|
+
band_tid = music["_bandNameTextID"]
|
|
93
|
+
if not band_tid:
|
|
94
|
+
band = next(r for r in master_table(master, "MasterBand") if r["_id"] == music["_bandIDs"][0])
|
|
95
|
+
band_tid = band["_nameTextID"]
|
|
96
|
+
score_id = music[f"_{difficulty}ID"]
|
|
97
|
+
score = next(r for r in master_table(master, "MasterLiveMusicScore") if r["_id"] == score_id)
|
|
98
|
+
strings = {
|
|
99
|
+
"musicTitle": title,
|
|
100
|
+
"singerName": localized(band_tid),
|
|
101
|
+
"lyricsWriter": credit("ui_credit_lyrics", music["_lyricistTextID"]),
|
|
102
|
+
"composer": credit("ui_credit_composer", music["_composerTextID"]),
|
|
103
|
+
"arranger": credit("ui_credit_arranger", music["_arrangerTextID"]),
|
|
104
|
+
# MasterLiveMusicScore.get_MusicScoreLevel (_musicScoreLevel), UIText.SetText(int)
|
|
105
|
+
"musicLevel": str(int(score["_musicScoreLevel"])),
|
|
106
|
+
# Player.GetLiveMusicResult: a fresh profile has no entry -> new LiveMusicResult(id), get_HighScore = 0.
|
|
107
|
+
# The IFix patch checks inside these getters (patch ids 0x13e0, 0x1b5b) depend on patches loaded at run
|
|
108
|
+
# time; the code assumes none is loaded
|
|
109
|
+
"highScore": "0",
|
|
110
|
+
}
|
|
111
|
+
sources = {
|
|
112
|
+
"musicTitle": {"textId": music["_titleTextID"], "column": JAPANESE_COLUMN,
|
|
113
|
+
"why": "SongTitleDisplaySetting._useTranslation false (option 999 default)"},
|
|
114
|
+
"singerName": {"textId": band_tid, "column": column},
|
|
115
|
+
"lyricsWriter": {"label": "ui_credit_lyrics", "word": music["_lyricistTextID"], "column": column},
|
|
116
|
+
"composer": {"label": "ui_credit_composer", "word": music["_composerTextID"], "column": column},
|
|
117
|
+
"arranger": {"label": "ui_credit_arranger", "word": music["_arrangerTextID"], "column": column},
|
|
118
|
+
"musicLevel": {"MasterLiveMusicScore": score_id, "field": "_musicScoreLevel"},
|
|
119
|
+
"highScore": {"why": "fresh profile: no LiveMusicResult entry, HighScore 0"},
|
|
120
|
+
}
|
|
121
|
+
for key in ("lyricsWriter", "composer", "arranger"):
|
|
122
|
+
if not word(sources[key]["word"]):
|
|
123
|
+
sources[key]["emptyWord"] = True # the label is shown without the word
|
|
124
|
+
return strings, sources
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# UILiveStartCanvas field -> strings key (UIText fields and arrays set by Initialize)
|
|
128
|
+
TEXT_FIELDS = {"_musicTitleText": "musicTitle", "_singerText": "singerName", "_lyricistText": "lyricsWriter",
|
|
129
|
+
"_compositterText": "composer", "_compositterText2": "composer", "_arrangerText": "arranger",
|
|
130
|
+
"_musicLevelTexts": "musicLevel", "_highScoreTexts": "highScore"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _game_fonts(ex: Exporter, fontset, texts: dict, chars_by_font: dict, packer: TexelPacker):
|
|
134
|
+
"""The game's TMP fonts of the texts, reduced to the characters shown (tmpfont), their atlas blocks added to
|
|
135
|
+
`packer`. -> (glyph coverage, materials, fonts)."""
|
|
136
|
+
coverage: dict[str, dict] = {}
|
|
137
|
+
needed: dict[str, set] = {}
|
|
138
|
+
runtime_units: dict[str, set] = {}
|
|
139
|
+
primaries = set()
|
|
140
|
+
for fname in sorted(chars_by_font):
|
|
141
|
+
primary = fontset.by_key(textstyle.font_dir(fname) + fname)
|
|
142
|
+
primaries.add(primary.name)
|
|
143
|
+
cov, nd, rt = tmpfont.glyph_coverage(fontset, primary, sorted(chars_by_font[fname]))
|
|
144
|
+
if cov["missing"]:
|
|
145
|
+
raise RuntimeError(f"{fname}: characters {cov['missing']} missing from the font chain")
|
|
146
|
+
coverage[fname] = cov
|
|
147
|
+
for k, v in nd.items():
|
|
148
|
+
needed.setdefault(k, set()).update(v)
|
|
149
|
+
for k, v in rt.items():
|
|
150
|
+
runtime_units.setdefault(k, set()).update(v)
|
|
151
|
+
materials, text_mats = tmpfont.text_materials(ex, fontset, [t["localized"] for t in texts.values()],
|
|
152
|
+
primaries, needed, runtime_units)
|
|
153
|
+
font_out = tmpfont.export_fonts(ex, fontset, primaries, needed, runtime_units, text_mats, packer,
|
|
154
|
+
"characters shown by the live start canvas (plus baked control characters); "
|
|
155
|
+
"runtime characters generated from the source font (runtimeGlyphs)")
|
|
156
|
+
return coverage, materials, font_out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def extract(cat: Catalog, player: PlayerData, out_dir: Path, master: Path, music_id: int,
|
|
160
|
+
difficulty: str, *, language: str, fonts: str = "open") -> dict:
|
|
161
|
+
"""Write <out_dir>/liveui/ (liveui.json, textures/) for one live and return a summary. Needs
|
|
162
|
+
<out_dir>/livescene/shaders/shaders.json (livescene.extract) for the shader check. `language`: the client
|
|
163
|
+
language (a languages.LANGUAGES code); `fonts`: "open" (text records, font names only) or "game" (the game's
|
|
164
|
+
TMP fonts reduced to the characters shown; needs the `fonts` extra)."""
|
|
165
|
+
out_dir = Path(out_dir)
|
|
166
|
+
base = out_dir / "liveui"
|
|
167
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
if difficulty not in LIVE_DIFFICULTY:
|
|
169
|
+
raise KeyError(f"difficulty {difficulty}")
|
|
170
|
+
if fonts not in FONT_CHOICES:
|
|
171
|
+
raise ValueError(f"fonts {fonts}: one of {', '.join(FONT_CHOICES)}")
|
|
172
|
+
mode, column = languages.mode(language), languages.column(language)
|
|
173
|
+
ex = Exporter(cat, base, player=player, textures="deferred", stub_assets=TMP_STUBS,
|
|
174
|
+
follow=("AnimatorController",))
|
|
175
|
+
packer = TexelPacker(base)
|
|
176
|
+
|
|
177
|
+
# -- the canvas subtree of the scene (deferred: sprite / texture references) -------------------------
|
|
178
|
+
env, graph = ex.load(SCENE_KEY)
|
|
179
|
+
roots = [p for p in graph.tf if graph.path(p) == CANVAS]
|
|
180
|
+
if len(roots) != 1:
|
|
181
|
+
raise RuntimeError(f"{CANVAS}: {len(roots)} transforms")
|
|
182
|
+
nodes = ex.hierarchy(env, graph, roots[0])
|
|
183
|
+
by_path: dict[str, list] = {}
|
|
184
|
+
for n in nodes:
|
|
185
|
+
by_path.setdefault(n["path"], []).append(n)
|
|
186
|
+
canvas = _comp(by_path[CANVAS][0], "UILiveStartCanvas")
|
|
187
|
+
if canvas is None:
|
|
188
|
+
raise RuntimeError(f"{CANVAS}: no UILiveStartCanvas")
|
|
189
|
+
|
|
190
|
+
# -- strings, and which texts Initialize writes them to ---------------------------------------------
|
|
191
|
+
strings, sources = resolve_strings(master, music_id, difficulty, column)
|
|
192
|
+
text_of: dict[str, str] = {}
|
|
193
|
+
for field, key in TEXT_FIELDS.items():
|
|
194
|
+
refs = canvas[field] if isinstance(canvas[field], list) else [canvas[field]]
|
|
195
|
+
for r in refs:
|
|
196
|
+
text_of[r["gameObject"]] = strings[key]
|
|
197
|
+
|
|
198
|
+
# -- TMP texts of the drawn part: LocalizeText overrides and the characters they show -------------
|
|
199
|
+
styles = textstyle.TextStyles(ex, player, mode) if fonts == "open" else None
|
|
200
|
+
lang = styles.lang if styles else textstyle.language_fonts(player)
|
|
201
|
+
swap = styles.swap if styles else textstyle.font_swap(lang, mode)
|
|
202
|
+
fontset = tmpfont.FontSet(ex) if fonts == "game" else None
|
|
203
|
+
texts: dict[str, dict] = {}
|
|
204
|
+
chars_by_font: dict[str, set] = {}
|
|
205
|
+
for n in nodes:
|
|
206
|
+
if not _drawn(n["path"]):
|
|
207
|
+
continue
|
|
208
|
+
t = _comp(n, TMP_CLASS)
|
|
209
|
+
if t is None:
|
|
210
|
+
continue
|
|
211
|
+
if len(by_path[n["path"]]) != 1:
|
|
212
|
+
raise RuntimeError(f"{n['path']}: text node path not unique")
|
|
213
|
+
lt = _comp(n, "LocalizeText")
|
|
214
|
+
if not (lt and lt["m_Enabled"] and lt["_localizeEnabled"]):
|
|
215
|
+
raise NotImplementedError(f"{n['path']}: text without an enabled LocalizeText")
|
|
216
|
+
loc = textstyle.localize_text(n["path"], t["m_fontAsset"]["name"], t["m_sharedMaterial"]["material"],
|
|
217
|
+
swap, lang, mode)
|
|
218
|
+
shown = text_of.get(n["path"], t["m_text"])
|
|
219
|
+
texts[n["path"]] = {"localized": loc, "text": shown, "setByInitialize": n["path"] in text_of}
|
|
220
|
+
if styles:
|
|
221
|
+
record = styles.record(n["path"], t, lt)
|
|
222
|
+
record.pop("text") # the serialized text; `text` above is the one shown
|
|
223
|
+
texts[n["path"]]["style"] = record
|
|
224
|
+
chars_by_font.setdefault(loc["fontAsset"], set()).update(ord(ch) for ch in shown)
|
|
225
|
+
for p in text_of:
|
|
226
|
+
if _drawn(p) and p not in texts:
|
|
227
|
+
raise RuntimeError(f"{p}: Initialize writes a text that is not exported")
|
|
228
|
+
|
|
229
|
+
if fontset is not None:
|
|
230
|
+
coverage, materials, font_out = _game_fonts(ex, fontset, texts, chars_by_font, packer)
|
|
231
|
+
else: # name stubs: no font data, no atlases
|
|
232
|
+
coverage, materials = None, {}
|
|
233
|
+
font_out = {f: {"name": f, "note": FONT_NOTE} for f in sorted(chars_by_font)}
|
|
234
|
+
|
|
235
|
+
# -- sprites: drawn Images, the difficulty icons, the jacket ----------------------------------------
|
|
236
|
+
replaced = {r["gameObject"] for f in ("_jacketImages", "_difficultyImages") for r in canvas[f]}
|
|
237
|
+
sprite_refs: dict[str, str] = {} # sprite name -> spriteRef
|
|
238
|
+
for n in nodes:
|
|
239
|
+
im = _comp(n, "Image")
|
|
240
|
+
if not _drawn(n["path"]) or im is None or n["path"] in replaced or not im["m_Sprite"]:
|
|
241
|
+
continue
|
|
242
|
+
sprite_refs.setdefault(im["m_Sprite"]["name"], im["m_Sprite"]["spriteRef"])
|
|
243
|
+
difficulty_icons = {}
|
|
244
|
+
for p in canvas["_difficultyPairs"]:
|
|
245
|
+
s = p["Icon"]
|
|
246
|
+
difficulty_icons[str(p["Difficulty"])] = s["name"]
|
|
247
|
+
sprite_refs.setdefault(s["name"], s["spriteRef"])
|
|
248
|
+
music = next(r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id)
|
|
249
|
+
jacket = ex.key_sprite(JACKET_KEY.format(jacket=music["_jacketAssetName"]))
|
|
250
|
+
sprite_refs.setdefault(jacket["name"], jacket["spriteRef"])
|
|
251
|
+
for name, ref in sorted(sprite_refs.items()):
|
|
252
|
+
s = ex.sprite_recs[ref]
|
|
253
|
+
if s["name"] != name:
|
|
254
|
+
raise RuntimeError(f"sprite {name}: record named {s['name']}")
|
|
255
|
+
ex.pack_sprite(ref, packer, f"sprites_{_safe(s['tex'].read_typetree()['m_Name'])}")
|
|
256
|
+
|
|
257
|
+
textures = packer.build()
|
|
258
|
+
if fontset is not None:
|
|
259
|
+
tmpfont.resolve_font_blocks(font_out)
|
|
260
|
+
sprites_out = {name: ex.packed_sprite(ref) for name, ref in sorted(sprite_refs.items())}
|
|
261
|
+
for m in materials.values():
|
|
262
|
+
for v in m["textures"].values():
|
|
263
|
+
if v["texture"] is not None:
|
|
264
|
+
v["texture"] = {"name": v["texture"]["name"], "width": v["texture"]["width"],
|
|
265
|
+
"height": v["texture"]["height"], "format": v["texture"]["format"]}
|
|
266
|
+
|
|
267
|
+
# -- shaders: livescene's dump (same name and source bundle), GLES3 variant per material ------------
|
|
268
|
+
index = json.loads((out_dir / "livescene" / "shaders" / "shaders.json").read_text(encoding="utf-8"))
|
|
269
|
+
by_name = {r["name"]: r for r in index}
|
|
270
|
+
keywords = {}
|
|
271
|
+
for m in materials.values():
|
|
272
|
+
sh = m["shader"]["shader"]
|
|
273
|
+
rec = by_name.get(sh)
|
|
274
|
+
if rec is None:
|
|
275
|
+
raise RuntimeError(f"{m['material']}: shader {sh} not in livescene/shaders")
|
|
276
|
+
if sh not in ex.shaders or ex.shaders[sh].assets_file.name != rec["source"]:
|
|
277
|
+
raise RuntimeError(f"{m['material']}: shader {sh} comes from another bundle than livescene's")
|
|
278
|
+
known = {k for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3" for k in v["keywords"]}
|
|
279
|
+
want = sorted(k for k in m["keywords"] if k in known)
|
|
280
|
+
if not any(v["platform"] == "gles3" and v["type"] == "GLES3" and v["stage"] == "vertex"
|
|
281
|
+
and sorted(v["keywords"]) == want for v in rec["variants"]):
|
|
282
|
+
raise RuntimeError(f"{sh}: no GLES3 variant for material {m['material']} keywords {want}")
|
|
283
|
+
keywords[m["material"]] = want
|
|
284
|
+
if "UI/Default" not in by_name:
|
|
285
|
+
raise RuntimeError("UI/Default not in livescene/shaders")
|
|
286
|
+
|
|
287
|
+
# -- the root Animator's controller (evidence) -----------------------------------------------------
|
|
288
|
+
an = _comp(by_path[f"{CANVAS}/root"][0], "Animator")
|
|
289
|
+
controller = an["m_Controller"] if an else None
|
|
290
|
+
tmp_settings = player.names_in(player.resource("TMP Settings"), player.mono(player.resource("TMP Settings")))
|
|
291
|
+
|
|
292
|
+
doc = {
|
|
293
|
+
"about": f"Live start canvas (Live/UILiveStartCanvas, LightWeight mode): what livescene/scene.json lacks, "
|
|
294
|
+
f"{language}",
|
|
295
|
+
"canvas": CANVAS,
|
|
296
|
+
"slice": {"musicId": music_id, "difficulty": difficulty, "liveDifficulty": LIVE_DIFFICULTY[difficulty],
|
|
297
|
+
"lightweight": True, "profile": "fresh (no play records)"},
|
|
298
|
+
"language": {"mode": mode, "field": column[1:], "fonts": lang, "fontSwap": swap,
|
|
299
|
+
"lineSpacing": LANGUAGE_LINE_SPACING[mode]},
|
|
300
|
+
"strings": strings,
|
|
301
|
+
"stringSources": sources,
|
|
302
|
+
"texts": texts,
|
|
303
|
+
"sprites": sprites_out,
|
|
304
|
+
"difficultyIcons": difficulty_icons,
|
|
305
|
+
"jacket": jacket["name"],
|
|
306
|
+
"textures": textures,
|
|
307
|
+
"materials": materials,
|
|
308
|
+
"materialKeywords": keywords,
|
|
309
|
+
"fonts": font_out,
|
|
310
|
+
"glyphCoverage": coverage,
|
|
311
|
+
"tmpSettings": {k: tmp_settings[k] for k in ("m_GetFontFeaturesAtRuntime", "m_matchMaterialPreset",
|
|
312
|
+
"m_enableExtraPadding", "m_missingGlyphCharacter")},
|
|
313
|
+
# images draw with Graphic.defaultGraphicMaterial (built-in UI/Default: livescene's dump of it has the
|
|
314
|
+
# same variants as the unity_builtin_extra one the ADV exports, checked byte for byte)
|
|
315
|
+
"shaders": {"index": "../livescene/shaders/shaders.json",
|
|
316
|
+
"names": sorted({m["shader"]["shader"] for m in materials.values()} | {"UI/Default"})},
|
|
317
|
+
"controller": controller,
|
|
318
|
+
}
|
|
319
|
+
if styles:
|
|
320
|
+
doc["fontData"] = "open"
|
|
321
|
+
doc["textStyle"] = styles.summary()
|
|
322
|
+
write_json(base / "liveui.json", doc)
|
|
323
|
+
return {"liveui": str(base / "liveui.json"), "texts": len(texts), "sprites": sorted(sprites_out),
|
|
324
|
+
"textures": {k: (v["width"], v["height"]) for k, v in textures.items()},
|
|
325
|
+
"fonts": sorted(font_out), "coverage": {k: v["counts"] for k, v in (coverage or {}).items()},
|
|
326
|
+
"strings": strings,
|
|
327
|
+
"runtimeGlyphs": {k: len(v["runtimeGlyphs"]["generated"]) for k, v in font_out.items()
|
|
328
|
+
if v.get("runtimeGlyphs")}}
|
nnnotes/master.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Master data files -> JSON tables.
|
|
2
|
+
|
|
3
|
+
A master data file (`<Table>.bin`) is a 64-byte prefix followed by the table encrypted with Rijndael (256-bit
|
|
4
|
+
block, 256-bit key, CBC, PKCS7 padding); the plaintext is gzip-compressed UTF-8 JSON (`{"_allData": [rows]}`).
|
|
5
|
+
The game reads it in MasterDataModelBox<,>.Load: RijndaelEncryption.FixedDecrypt (the data after the prefix,
|
|
6
|
+
the CryptProvider key and IV), then Compression.Decompress (GZipStream), then JsonUtility.FromJson.
|
|
7
|
+
|
|
8
|
+
AES is Rijndael with 128-bit blocks only, so the 256-bit block cipher is implemented here (numpy, every block of a
|
|
9
|
+
file at once). Key and IV come from the configuration (`[master] key`, `[master] iv`).
|
|
10
|
+
|
|
11
|
+
`download` fetches a master data version from the region's CDN: `<cdn>/master/<version>/MasterManifest.json`
|
|
12
|
+
(`{"version", "files": [{"name", "hash", "size"}]}`) and the files it lists, each checked against its SHA-256.
|
|
13
|
+
The version a region serves now comes from the game's API (`gameapi.master_version`).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import gzip
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from . import jsonio
|
|
31
|
+
|
|
32
|
+
PREFIX = 64 # bytes before the ciphertext
|
|
33
|
+
BLOCK = 32 # 256-bit block
|
|
34
|
+
NB = NK = 8 # block / key length in 32-bit words
|
|
35
|
+
NR = 14 # rounds
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class MasterKey:
|
|
40
|
+
"""Key and IV of the master data encryption (never shown in a repr)."""
|
|
41
|
+
key: bytes = field(repr=False)
|
|
42
|
+
iv: bytes = field(repr=False)
|
|
43
|
+
|
|
44
|
+
def __post_init__(self):
|
|
45
|
+
if len(self.key) != 32 or len(self.iv) != 32:
|
|
46
|
+
raise ValueError("master key and IV must be 32 bytes each")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------- Rijndael-256
|
|
50
|
+
def _xtime(a: int) -> int:
|
|
51
|
+
a <<= 1
|
|
52
|
+
return (a ^ 0x1B) & 0xFF if a & 0x100 else a
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _gmul(a: int, b: int) -> int:
|
|
56
|
+
r = 0
|
|
57
|
+
while b:
|
|
58
|
+
if b & 1:
|
|
59
|
+
r ^= a
|
|
60
|
+
a = _xtime(a)
|
|
61
|
+
b >>= 1
|
|
62
|
+
return r
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sbox() -> list[int]:
|
|
66
|
+
"""The Rijndael S-box (multiplicative inverse in GF(2^8) followed by the affine map)."""
|
|
67
|
+
box = [0] * 256
|
|
68
|
+
p = q = 1
|
|
69
|
+
while True:
|
|
70
|
+
p = p ^ ((p << 1) & 0xFF) ^ (0x1B if p & 0x80 else 0)
|
|
71
|
+
q ^= (q << 1) & 0xFF
|
|
72
|
+
q ^= (q << 2) & 0xFF
|
|
73
|
+
q ^= (q << 4) & 0xFF
|
|
74
|
+
if q & 0x80:
|
|
75
|
+
q ^= 0x09
|
|
76
|
+
x = q ^ ((q << 1 | q >> 7) & 0xFF) ^ ((q << 2 | q >> 6) & 0xFF) \
|
|
77
|
+
^ ((q << 3 | q >> 5) & 0xFF) ^ ((q << 4 | q >> 4) & 0xFF)
|
|
78
|
+
box[p] = (x ^ 0x63) & 0xFF
|
|
79
|
+
if p == 1:
|
|
80
|
+
break
|
|
81
|
+
box[0] = 0x63
|
|
82
|
+
return box
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
SBOX = _sbox()
|
|
86
|
+
INV_SBOX = np.zeros(256, dtype=np.uint8)
|
|
87
|
+
INV_SBOX[np.array(SBOX)] = np.arange(256, dtype=np.uint8)
|
|
88
|
+
MUL = {k: np.array([_gmul(i, k) for i in range(256)], dtype=np.uint8) for k in (9, 11, 13, 14)}
|
|
89
|
+
INV_SHIFT = (0, 1, 3, 4) # InvShiftRows: row r moves right by INV_SHIFT[r] columns (Nb = 8)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def round_keys(key: bytes) -> np.ndarray:
|
|
93
|
+
"""Key expansion (Nk = 8) -> (NR + 1, 4 rows, NB columns) uint8."""
|
|
94
|
+
if len(key) != 32:
|
|
95
|
+
raise ValueError("key must be 32 bytes")
|
|
96
|
+
words = [list(key[4 * i:4 * i + 4]) for i in range(NK)]
|
|
97
|
+
rcon = 1
|
|
98
|
+
for i in range(NK, NB * (NR + 1)):
|
|
99
|
+
t = list(words[i - 1])
|
|
100
|
+
if i % NK == 0:
|
|
101
|
+
t = t[1:] + t[:1]
|
|
102
|
+
t = [SBOX[b] for b in t]
|
|
103
|
+
t[0] ^= rcon
|
|
104
|
+
rcon = _xtime(rcon)
|
|
105
|
+
elif i % NK == 4:
|
|
106
|
+
t = [SBOX[b] for b in t]
|
|
107
|
+
words.append([a ^ b for a, b in zip(words[i - NK], t)])
|
|
108
|
+
return np.array(words, dtype=np.uint8).reshape(NR + 1, NB, 4).transpose(0, 2, 1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def decrypt_blocks(ct: np.ndarray, rk: np.ndarray) -> np.ndarray:
|
|
112
|
+
"""(n, 32) ciphertext blocks -> (n, 32) block decryptions (no CBC chaining). A block's bytes are its state
|
|
113
|
+
column by column."""
|
|
114
|
+
s = ct.reshape(-1, NB, 4).transpose(0, 2, 1).copy() # (n, 4, NB): s[r][c]
|
|
115
|
+
s ^= rk[NR]
|
|
116
|
+
cols = np.arange(NB)
|
|
117
|
+
for rnd in range(NR - 1, -1, -1):
|
|
118
|
+
for r in range(4): # InvShiftRows
|
|
119
|
+
s[:, r, :] = s[:, r, (cols - INV_SHIFT[r]) % NB]
|
|
120
|
+
s = INV_SBOX[s] # InvSubBytes
|
|
121
|
+
s ^= rk[rnd] # AddRoundKey
|
|
122
|
+
if rnd: # InvMixColumns
|
|
123
|
+
a0, a1, a2, a3 = s[:, 0, :], s[:, 1, :], s[:, 2, :], s[:, 3, :]
|
|
124
|
+
s = np.stack([
|
|
125
|
+
MUL[14][a0] ^ MUL[11][a1] ^ MUL[13][a2] ^ MUL[9][a3],
|
|
126
|
+
MUL[9][a0] ^ MUL[14][a1] ^ MUL[11][a2] ^ MUL[13][a3],
|
|
127
|
+
MUL[13][a0] ^ MUL[9][a1] ^ MUL[14][a2] ^ MUL[11][a3],
|
|
128
|
+
MUL[11][a0] ^ MUL[13][a1] ^ MUL[9][a2] ^ MUL[14][a3],
|
|
129
|
+
], axis=1)
|
|
130
|
+
return s.transpose(0, 2, 1).reshape(-1, BLOCK)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def decrypt_cbc(data: bytes, key: bytes, iv: bytes, rk: np.ndarray | None = None) -> bytes:
|
|
134
|
+
"""Rijndael-256 CBC decryption, padding kept."""
|
|
135
|
+
if not data or len(data) % BLOCK:
|
|
136
|
+
raise ValueError(f"ciphertext length {len(data)} is not a positive multiple of {BLOCK}")
|
|
137
|
+
ct = np.frombuffer(data, dtype=np.uint8).reshape(-1, BLOCK)
|
|
138
|
+
pt = decrypt_blocks(ct, round_keys(key) if rk is None else rk)
|
|
139
|
+
pt ^= np.vstack([np.frombuffer(iv, dtype=np.uint8)[None, :], ct[:-1]])
|
|
140
|
+
return pt.tobytes()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def decode(raw: bytes, key: MasterKey, rk: np.ndarray | None = None) -> bytes:
|
|
144
|
+
"""A master data file as stored -> its JSON text (UTF-8 bytes)."""
|
|
145
|
+
out = decrypt_cbc(raw[PREFIX:], key.key, key.iv, rk)
|
|
146
|
+
pad = out[-1]
|
|
147
|
+
if not 1 <= pad <= BLOCK or out[-pad:] != bytes([pad]) * pad:
|
|
148
|
+
raise ValueError("bad PKCS7 padding (wrong key or IV, or not a master data file)")
|
|
149
|
+
return gzip.decompress(out[:-pad])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def table_json(text: bytes) -> str:
|
|
153
|
+
"""The stored form of a decoded table: parsed and written again by jsonio.dumps (UTF-8, one-space indent,
|
|
154
|
+
non-finite numbers as 1e999 / -1e999; a NaN raises ValueError naming where it is)."""
|
|
155
|
+
return jsonio.dumps(json.loads(text.decode("utf-8")), ensure_ascii=False, indent=1)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def decode_files(files: list[Path], out_dir: Path, key: MasterKey, workers: int = 8) -> dict:
|
|
159
|
+
"""Decode master data files into <out_dir>/<stem>.json. Returns {decoded, failed: [{file, error}]}."""
|
|
160
|
+
out_dir = Path(out_dir)
|
|
161
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
rk = round_keys(key.key)
|
|
163
|
+
|
|
164
|
+
def one(p: Path):
|
|
165
|
+
try:
|
|
166
|
+
text = table_json(decode(p.read_bytes(), key, rk))
|
|
167
|
+
except Exception as e: # report and continue with the other tables
|
|
168
|
+
return {"file": p.name, "error": f"{type(e).__name__}: {e}"}
|
|
169
|
+
(out_dir / f"{p.stem}.json").write_text(text, encoding="utf-8", newline="\n")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
with ThreadPoolExecutor(max(1, workers)) as ex:
|
|
173
|
+
failed = [r for r in ex.map(one, files) if r is not None]
|
|
174
|
+
return {"decoded": len(files) - len(failed), "failed": failed, "out": str(out_dir)}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def table(master_dir: Path, name: str) -> list[dict]:
|
|
178
|
+
"""The rows of a decoded master table: `_allData` of <master_dir>/<name>.json."""
|
|
179
|
+
return json.loads((Path(master_dir) / f"{name}.json").read_text(encoding="utf-8"))["_allData"]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def has_row(master_dir: Path, name: str, row_id) -> bool:
|
|
183
|
+
"""Whether the decoded master table `name` has a row whose `_id` is `row_id`."""
|
|
184
|
+
return any(r.get("_id") == row_id for r in table(master_dir, name))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def input_files(inputs: list[Path]) -> list[Path]:
|
|
188
|
+
"""Files given, and the `*.bin` files of directories given, sorted by name per directory."""
|
|
189
|
+
out: list[Path] = []
|
|
190
|
+
for p in map(Path, inputs):
|
|
191
|
+
if p.is_dir():
|
|
192
|
+
out += sorted(f for f in p.iterdir() if f.is_file() and f.suffix.lower() == ".bin")
|
|
193
|
+
elif p.is_file():
|
|
194
|
+
out.append(p)
|
|
195
|
+
else:
|
|
196
|
+
raise FileNotFoundError(p)
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------- download
|
|
201
|
+
class DownloadError(RuntimeError):
|
|
202
|
+
"""A CDN download failed (the message names the file, not the URL)."""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _get(url: str, retries: int = 3, timeout: int = 60) -> bytes:
|
|
206
|
+
name = url.rsplit("/", 1)[-1]
|
|
207
|
+
for i in range(retries):
|
|
208
|
+
try:
|
|
209
|
+
with urllib.request.urlopen(url, timeout=timeout) as r:
|
|
210
|
+
return r.read()
|
|
211
|
+
except urllib.error.HTTPError as e:
|
|
212
|
+
if e.code < 500 or i == retries - 1:
|
|
213
|
+
raise DownloadError(f"download of {name} failed: HTTP {e.code}") from None
|
|
214
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
215
|
+
if i == retries - 1:
|
|
216
|
+
raise DownloadError(f"download of {name} failed: {type(e).__name__}") from None
|
|
217
|
+
time.sleep(0.5 * (i + 1))
|
|
218
|
+
raise AssertionError("unreachable")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def download(cdn: str, version: str, out_dir: Path, workers: int = 16) -> dict:
|
|
222
|
+
"""Master data `version` from the CDN into <out_dir>/ (the manifest and every listed file, SHA-256 checked;
|
|
223
|
+
files already present with the right hash are kept)."""
|
|
224
|
+
out_dir = Path(out_dir)
|
|
225
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
base = f"{cdn.rstrip('/')}/master/{version}"
|
|
227
|
+
manifest_raw = _get(f"{base}/MasterManifest.json")
|
|
228
|
+
manifest = json.loads(manifest_raw.decode("utf-8"))
|
|
229
|
+
(out_dir / "MasterManifest.json").write_bytes(manifest_raw)
|
|
230
|
+
files = manifest.get("files", [])
|
|
231
|
+
|
|
232
|
+
def one(f: dict):
|
|
233
|
+
name, sha = f["name"], (f.get("hash") or "").lower()
|
|
234
|
+
if "/" in name or "\\" in name or name in ("", ".", ".."):
|
|
235
|
+
return {"file": name, "error": "unexpected file name"}
|
|
236
|
+
dst = out_dir / name
|
|
237
|
+
if sha and dst.is_file() and hashlib.sha256(dst.read_bytes()).hexdigest() == sha:
|
|
238
|
+
return "kept"
|
|
239
|
+
data = _get(f"{base}/{name}")
|
|
240
|
+
if sha and hashlib.sha256(data).hexdigest() != sha:
|
|
241
|
+
return {"file": name, "error": "sha256 differs from the manifest"}
|
|
242
|
+
tmp = dst.with_name(f"{name}.{os.getpid()}.part")
|
|
243
|
+
tmp.write_bytes(data)
|
|
244
|
+
os.replace(tmp, dst)
|
|
245
|
+
return "downloaded"
|
|
246
|
+
|
|
247
|
+
with ThreadPoolExecutor(max(1, workers)) as ex:
|
|
248
|
+
results = list(ex.map(one, files))
|
|
249
|
+
return {"version": manifest.get("version", version), "files": len(files),
|
|
250
|
+
"downloaded": results.count("downloaded"), "kept": results.count("kept"),
|
|
251
|
+
"failed": [r for r in results if isinstance(r, dict)], "out": str(out_dir)}
|