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/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)}