diff --git a/docs/cli.md b/docs/cli.md index 9cd8867..db06a5f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -225,6 +225,9 @@ a live (music + difficulty) -> the live product and the products it plays with ( | `--band` `BAND` | band of the LightWeight background and start timeline (default: band of the music's first vocal character) | | `--leader-card` `LEADER_CARD` | deck centre MasterMemberCard id (its character's band) | | `--live-option` `OPTION[=VALUES]` | also export the files of a Live option's variants (repeatable): MirrorChart, MeasureLineDisplay, NoteDesignId, NoteEffectId, LiveQuality, NoteSePatternId (every value of the master data, or =v,v...), defaults (the option defaults and ranges only) or all | +| `--live-languages` `CODES` | comma-separated UI languages, or all (default: catalog language) | +| `--fonts` `FONTS` | glyph source: open font files (default) or game TMP assets (one of `open`, `game`) | +| `--font` `LANG=PATH` | font file for a language (repeatable; [paths] fonts.) | | `-o`, `--out` `OUT` | output directory (required) | | `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | | `--workers` `N` | worker processes (default: the usable CPUs) | diff --git a/docs/commands.md b/docs/commands.md index e6f9e27..16ff58e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -380,6 +380,7 @@ status 2 and a line naming the class, the game version and the Unity version (as nnnotes export live MUSIC_ID -o OUT [--difficulty easy|normal|hard|expert] [--format aac|flac|mp3|opus|vorbis] [--flac-level N] [--band BAND | --leader-card CARD_ID] [--live-option OPTION[=VALUES] ...] + [--live-languages LANG,...] [--fonts open|game] [--font LANG=PATH] ``` One chart (music + difficulty, default `expert`) and the products it plays with, in the product layout @@ -396,6 +397,7 @@ OUT/noteskins/.json a note skin OUT/noteeffects/.json a note effect set OUT/sounds/.json the note and live sounds with their CRI routing and volumes OUT/music/.json the BGM in a web format +OUT/liveui/__.json localized start-canvas text, glyph pages and image bindings OUT/assets/. every file the manifests name, once ``` @@ -409,6 +411,14 @@ of the music's first vocal character; `live.json` `band` records the choice, and band only when one of the two flags is given. A band whose start timeline or background the catalog does not hold is a usage error. +`--live-languages` selects UI languages separated by commas (`all` selects all client languages); the default is +`[catalog] language`. `--fonts open` (default) generates glyph pages from `[paths.fonts]` or repeatable +`--font LANG=PATH` flags. Supply fonts for the requested language and its fallback languages. `--fonts game` uses +the client's TMP assets. Both modes require the `fonts` extra and use the client's font metrics and layout +settings. Font bytes participate in the task key. The UI's canvas and difficulty icons are in `live.scene`, and +its jacket and text shaders are in the UI product's object table. `live.json` names UI products by language in +`parts.ui`. + The products hold what a live reads with the game's default options (option preset 1 of a fresh profile), and always the mirrored notes, the bar line view and the option tables. `--live-option` (repeatable) adds the products of other values of the options that select files, so that a player can offer them; the options are named as in the game (the diff --git a/docs/contracts.md b/docs/contracts.md index f133367..b8b21af 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -15,6 +15,10 @@ Episode font data uses the `format` field `nnnotes.story-fonts/1`, described in [story-fonts.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-fonts.schema.json) and [story products](products.md#stories). +Live start UI data uses the `format` field `nnnotes.live-ui/1`, described in +[live-ui.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live-ui.schema.json) +and [live start UI products](products.md#live-start-ui). + Stages carry their own version. A change of what a stage writes for the same inputs is a new stage version, which changes the key of every task of that stage (golden tests fail when a stage's output changes while its version stays). diff --git a/docs/export.md b/docs/export.md index 15efdbb..3fbb9b3 100644 --- a/docs/export.md +++ b/docs/export.md @@ -40,18 +40,23 @@ Details: [export episode](commands.md#export-episode), [export story](commands.m ## A live ```sh -nnnotes export live 100001 --difficulty expert -o out/lives/100001_expert +nnnotes export live 100001 --difficulty expert --fonts game -o out/lives/100001_expert ``` One chart and the products it plays with, in the layout ournotes-player reads: the chart as shipped and its converted runtime notes, the 3D scene, the note highway with its skins and effect sets, the sound effects with their -routing, and the BGM in a web format. +routing, the BGM in a web format, and localized start-canvas text with glyph pages. - `--band BAND` or `--leader-card CARD_ID` picks the band of the background and start timeline (default: the band of the music's first vocal character). - `--live-option NAME[=VALUES]` (repeatable) adds the products of other values of the live options, such as `NoteDesignId` or `NoteSePatternId`; `--live-option all` adds every option with every value. +- `--live-languages ja,en` selects UI languages (default: catalog language; `all` selects all client languages). +- `--fonts game` uses the client's TMP fonts. The default, `--fonts open`, reads font files from `[paths.fonts]` + or repeatable `--font LANG=PATH` flags. Supply the languages used by the fallback chain. Both modes need the + `fonts` extra. + Details: [export live](commands.md#export-live). ## A Live2D model diff --git a/docs/products.md b/docs/products.md index a308aa6..670fc88 100644 --- a/docs/products.md +++ b/docs/products.md @@ -359,6 +359,31 @@ The object table holds the jacket Sprite and its texture; the chart's `TextAsset layout makes the live of each chart whose key and jacket the selection holds (with the master data), without a band. `export live` asks for one live and its parts ([export live](commands.md#export-live)). +### Live start UI + +`live.ui` has subject `//` and manifest +`liveui/__.json`. It reads the live scene, the jacket, the client's font assets, +LocalizeManager and TMP Settings, plus the chart's master rows. `ui.json` uses format `nnnotes.live-ui/1`. + +- `scene` identifies the manifest, prefab and canvas path in `live.scene`. +- `strings` contains the title, singer, credits, level and a fresh profile's high score (`0`). The title uses the + Japanese column; singer and credit labels use the requested language. Empty localized strings stay empty. +- `texts` maps TMP component ids to their path, displayed `text`, `setByInitialize`, localized layout `style`, + original `localized` font selection and generated `font` selection. +- `images` maps Image component ids to the selected jacket and difficulty Sprite ids. `difficultyIcons` lists + the canvas's icons, and `jacket` identifies the jacket. Canvas components and difficulty sprites belong to + `live.scene`; the jacket and its texture belong to this product. +- `fonts`, `materials`, `textures`, `materialKeywords`, `coverage`, `lineBreaking` and `shaders` describe the + generated text resources, with the same fields as the story font product. + +The `fonts` parameter is `open` (default) or `game`. Both modes generate glyph pages and use the client's font +metrics, layout settings and fallback chains. Open fonts use explicit `font:` byte inputs; their +contents participate in the task key. The exported object table holds the jacket and text shaders. The canvas +hierarchy is shared through `live.scene`. + +`live.json` includes `parts.ui`, a language-to-manifest map. `export live` requests the languages selected by +`--live-languages`, defaulting to `[catalog] language`. + ## Stories A story episode plays with products that several episodes share, made by `nnnotes.pipeline.story`: diff --git a/docs/schemas.md b/docs/schemas.md index 4d2922a..54bc85c 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -25,6 +25,7 @@ schemas.load("plan") # the schema as a dict | `coverage` | `nnnotes.coverage/1` | the coverage of a run per class and per reason code | | `failures` | `nnnotes.failures/1` | the failed tasks and items of a run | | `layout` | `nnnotes.layout/1` | the files a layout owns in its output directory | +| `live-ui` | `nnnotes.live-ui/1` (`format`) | live start strings, text styles, glyph pages and image bindings | | `music-data` | `nnnotes.music-data/2` | every song and chart of one master data version ([music data](music-data.md)) | | `objects` | `nnnotes.objects/1` | the objects a product reaches: their class, item status and files ([products](products.md)) | | `plan` | `nnnotes.plan/1` | what a run would do and why (`depot plan`) | diff --git a/docs/stages.md b/docs/stages.md index ee10230..9500ac9 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -36,6 +36,7 @@ codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japan | `live.noteeffect` | a note effect set (`effect001`, `effect001Light`) | `objects`: the object table of the effect settings | `noteeffect.json` and the object table | | `live.scene` | `default` | `objects`: the object table of the scene, the bands' start timelines and backgrounds, the lane skins, the UI assets and the built-in graphics settings ([products](products.md#live-scene)); `master`: the master record of its settings, lane skins and bands | `scene.json` and the object table | | `live` | a live: `/[/]` | `objects`: the object table of the chart and the jacket ([products](products.md#lives)); `master`: the live's master record and the manifest paths of its parts | `live.json`, `chart.json`, `notes.json`, `notes.mirror.json` and the object table | +| `live.ui` | `//` | `objects`: scene, fonts, jacket and localization settings; `master`; `jacket.objects`; `font-bundles`; `apk:AndroidManifest.xml`; explicit `font:` inputs in open mode; parameter `fonts` (`open` or `game`) | `ui.json`, glyph pages, jacket and text shaders ([products](products.md#live-start-ui)) | | `story.sound` | a cue sheet name | `objects`; `acb:` and `sheets`: the cue sheet and its `cri.audio` files ([products](products.md#stories)); parameter `audio` (`aac`) | `cues.json` (each cue's nonempty CRI `categories` included) and the cues' sounds in the web format | | `story.crilips` | `default` | `objects` (empty); `apk:lib/arm64-v8a/libcri_lips_unity.so` | `crilips.json` and `crilips.bin` | | `story.scene` | `default` | `objects`: the object table of the ADV scene, its prefabs and settings and the built-in graphics settings ([products](products.md#stories)) | `scene.json` and the object table | diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index ef07298..52fe306 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -127,6 +127,7 @@ def matches(self, stage: str) -> bool: StageSource("live.noteeffect", "nnnotes.pipeline.live:NoteEffectStage", output=True, product=True), StageSource("live.scene", "nnnotes.pipeline.live:SceneStage", output=True, product=True), StageSource("live", "nnnotes.pipeline.live:LiveStage", output=True, product=True), + StageSource("live.ui", "nnnotes.pipeline.live:UiStage", output=True, product=True), StageSource("story.sound", "nnnotes.pipeline.story:SoundStage", output=True, product=True), StageSource("story.crilips", "nnnotes.pipeline.story:CriLipsStage", output=True, product=True), StageSource("story.scene", "nnnotes.pipeline.story:SceneStage", output=True, product=True), @@ -771,7 +772,7 @@ def facts(self, pipeline: list[str], fetch: bool, background: bool = False) -> d "raw": Inputs({}), "product_inputs": self.product_inputs, } - if "story.fonts" in products: + if {"story.fonts", "live.ui"}.intersection(products): from ..source.languages import LANGUAGES font_paths = {code: self.cfg.path("paths.fonts", code) for code in (*LANGUAGES, "emoji")} diff --git a/src/nnnotes/cli/export.py b/src/nnnotes/cli/export.py index f9583ab..7dbaeda 100644 --- a/src/nnnotes/cli/export.py +++ b/src/nnnotes/cli/export.py @@ -120,6 +120,7 @@ def cmd_live(args, cfg): from ..content import live, liveaudio, livenotes, liveoptions from ..pipeline import live as lp from ..pipeline.stages import Env + from ..source.languages import check from ..source.master import table cfg.require_path("paths", "apk") # the built-in graphics and sound settings and the CRI ACF are in the APK @@ -140,18 +141,28 @@ def cmd_live(args, cfg): ws = assets.Workspace(args, cfg, layouts=["product"]) env = Env(ws.depot, {"master": md}) stage, subject = lp.LiveStage(), live.live_subject(args.music_id, args.difficulty, band) + wanted_languages = getattr(args, "live_languages", None) or [check(cfg.get("catalog", "language"))] try: # no chart of the difficulty, a music without a vocal character, a master table missing b = stage.live_record(subject, env)["band"]["band"] - products = {lp.LIVE: [subject], **stage.parts(subject, env)} + products = {lp.LIVE: [subject], **stage.parts(subject, env, wanted_languages)} except (KeyError, ValueError) as e: usage(args, e.args[0] if e.args else type(e).__name__) ws.request(products, []) ws.params(ws.pipeline()) # the flags the stages take, checked before the index ws.run_index() + env.facts["index"] = ws.index() held = {loc["primaryKey"] for loc in ws.index()["locations"]} missing = [k.format(band=b) for k in live.BAND_KEYS if k.format(band=b) not in held] if missing: usage(args, f"band {b}: {', '.join(missing)} not in the catalog") + if getattr(args, "fonts", "open") == "open": + supplied = dict(getattr(args, "font", None) or []) + for code in wanted_languages: + path = Path(supplied[code]) if code in supplied else cfg.path("paths.fonts", code) + if path is None or not path.is_file(): + raise ConfigError( + f"the live text of {code} needs a font file: --font {code}=PATH or [paths.fonts] {code}" + ) skins = {r["_id"]: r["_assetName"] for r in table(md, "MasterLiveNoteSkin")} effects = {r["_id"]: r["_assetName"] for r in table(md, "MasterLiveNoteEffectSkin")} products[lp.NOTESKIN] += [skins[i] for i in options.designs if skins[i] not in products[lp.NOTESKIN]] @@ -376,6 +387,25 @@ def register(sub) -> None: flac_level_arg(c, "of the decoded sounds") band_args(c) live_option_arg(c) + c.add_argument( + "--live-languages", + type=parse_languages, + metavar="CODES", + help="comma-separated UI languages, or all (default: catalog language)", + ) + c.add_argument( + "--fonts", + choices=("open", "game"), + default="open", + help="glyph source: open font files (default) or game TMP assets", + ) + c.add_argument( + "--font", + type=parse_font, + action="append", + metavar="LANG=PATH", + help="font file for a language (repeatable; [paths] fonts.)", + ) out_arg(c, "output directory") product_args(c) diff --git a/src/nnnotes/content/liveui.py b/src/nnnotes/content/liveui.py index 9b35261..0bf9d1d 100644 --- a/src/nnnotes/content/liveui.py +++ b/src/nnnotes/content/liveui.py @@ -26,15 +26,17 @@ import json from pathlib import Path +from typing import TYPE_CHECKING from ..core.jsonio import write_json from ..source import languages -from ..source.catalog import Catalog -from ..unity import fontsettings, textstyle, tmpfont -from ..unity.export import Exporter, TexelPacker, _safe -from ..unity.player import PlayerData +from ..unity import fontsettings, textstyle from ..unity.textstyle import LANGUAGE_LINE_SPACING -from .score import master_table + +if TYPE_CHECKING: + from ..source.catalog import Catalog + from ..unity.export import Exporter, TexelPacker + from ..unity.player import PlayerData SCENE_KEY = "EmbScene/Live" CANVAS = "Live/UILiveStartCanvas" @@ -66,8 +68,19 @@ def _comp(node: dict, cls: str) -> dict | None: def resolve_strings(master: Path, music_id: int, difficulty: str, column: str) -> tuple[dict, dict]: """The strings UILiveStartCanvas.Initialize sets (IReadOnlyLiveMusicScore slots 2..8), for a fresh profile, with the text table column `column` of the current LanguageMode. -> (strings, sources).""" - music = next(r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id) - text = {r["_id"]: r for r in master_table(master, "MasterText")} + from .score import master_table + + tables = { + name: master_table(master, name) + for name in ("MasterLiveMusic", "MasterText", "MasterBand", "MasterLiveMusicScore") + } + return record_strings(tables, music_id, difficulty, column) + + +def record_strings(tables: dict, music_id: int, difficulty: str, column: str) -> tuple[dict, dict]: + """The live start strings and their sources from explicit master-table rows.""" + music = next(r for r in tables["MasterLiveMusic"] if r["_id"] == music_id) + text = {r["_id"]: r for r in tables["MasterText"]} def localized(tid: str) -> str: # MasterLoader.GetLocalizedText in the current LanguageMode: the column as it is, "" included (no @@ -93,10 +106,10 @@ def credit(label_id: str, word_id: str) -> str: # GetBandNameTextId: _bandNameTextID, else MasterBand(_bandIDs[0])._nameTextID band_tid = music["_bandNameTextID"] if not band_tid: - band = next(r for r in master_table(master, "MasterBand") if r["_id"] == music["_bandIDs"][0]) + band = next(r for r in tables["MasterBand"] if r["_id"] == music["_bandIDs"][0]) band_tid = band["_nameTextID"] score_id = music[f"_{difficulty}ID"] - score = next(r for r in master_table(master, "MasterLiveMusicScore") if r["_id"] == score_id) + score = next(r for r in tables["MasterLiveMusicScore"] if r["_id"] == score_id) strings = { "musicTitle": title, "singerName": localized(band_tid), @@ -145,6 +158,8 @@ def credit(label_id: str, word_id: str) -> str: def _game_fonts(ex: Exporter, fontset, texts: dict, chars_by_font: dict, packer: TexelPacker): """The game's TMP fonts of the texts, reduced to the characters shown (tmpfont), their atlas blocks added to `packer`. -> (glyph coverage, materials, fonts).""" + from ..unity import tmpfont + coverage: dict[str, dict] = {} needed: dict[str, set] = {} runtime_units: dict[str, set] = {} @@ -191,6 +206,10 @@ def extract( /livescene/shaders/shaders.json (livescene.extract) for the shader check. `language`: the client language (a languages.LANGUAGES code); `fonts`: "open" (text records, font names only) or "game" (the game's TMP fonts reduced to the characters shown; needs the `fonts` extra).""" + from ..unity import tmpfont + from ..unity.export import Exporter, TexelPacker, _safe + from .score import master_table + out_dir = Path(out_dir) base = out_dir / "liveui" base.mkdir(parents=True, exist_ok=True) diff --git a/src/nnnotes/content/liveuiproduct.py b/src/nnnotes/content/liveuiproduct.py new file mode 100644 index 0000000..8cb7e49 --- /dev/null +++ b/src/nnnotes/content/liveuiproduct.py @@ -0,0 +1,168 @@ +"""Live start text, font pages and image bindings assembled from object tables.""" + +from __future__ import annotations + +from ..source import languages +from ..unity import textstyle +from ..unity.ids import object_id, parse_object_id +from . import live, liveui +from .prefabs import component_class, component_ref, has_json, key_object, mono_json, ref_id + +FORMAT = "nnnotes.live-ui/1" +TABLES = ("MasterLiveMusic", "MasterLiveMusicScore", "MasterBand", "MasterText") + + +def parse_subject(subject: str) -> tuple[int, str, str]: + """The music id, difficulty and language of //.""" + parts = subject.split("/") + if len(parts) != 3: + raise ValueError(f"{subject!r}: expected //") + music, difficulty, _ = live.parse_subject("/".join(parts[:2])) + return music, difficulty, languages.check(parts[2]) + + +def record(tables: dict, subject: str) -> dict: + """Master rows read by the selected chart's start canvas in one language.""" + music_id, difficulty, code = parse_subject(subject) + music = next(r for r in tables["MasterLiveMusic"] if r["_id"] == music_id) + score = next(r for r in tables["MasterLiveMusicScore"] if r["_id"] == music[f"_{difficulty}ID"]) + column = languages.column(code) + return { + "MasterLiveMusic": [music], + "MasterLiveMusicScore": [score], + "MasterBand": [r for r in tables["MasterBand"] if r["_id"] in music["_bandIDs"]], + "MasterText": [ + {"_id": r["_id"], **{c: r.get(c) or "" for c in {column, languages.column("ja")}}} + for r in tables["MasterText"] + ], + } + + +def keys(master: dict) -> list[str]: + return [liveui.SCENE_KEY, live.jacket_key(master["MasterLiveMusic"][0]["_jacketAssetName"])] + + +def canvas(objects) -> tuple[str, dict, dict]: + """The scene prefab, canvas node and UILiveStartCanvas component.""" + hits = [ + (oid, node, comp) + for oid in objects.key(liveui.SCENE_KEY) + if oid in objects and objects.info(oid)["class"] == "GameObject" and has_json(objects, oid) + for node in objects.json(oid)["nodes"] + if node["path"] == liveui.CANVAS + for comp in node["components"] + if component_class(comp) == "UILiveStartCanvas" + ] + if len(hits) != 1: + raise ValueError(f"{liveui.CANVAS}: expected one UILiveStartCanvas, found {len(hits)}") + return hits[0] + + +def text_records(objects, master: dict, subject: str, settings) -> tuple[dict, dict, dict]: + """Canvas metadata, TMP records by component id and LocalizeManager font data.""" + from . import storyui + + music_id, difficulty, language = parse_subject(subject) + prefab, node, component = canvas(objects) + file, _ = parse_object_id(prefab) + nodes = objects.json(prefab)["nodes"] + fields = component["fields"] + strings, _ = liveui.record_strings(master, music_id, difficulty, languages.column(language)) + lang = textstyle.language_fonts_json(mono_json(objects, storyui.script_root(objects, "LocalizeManager"))) + styles = storyui._Styles(lang, languages.mode(language), settings) + + def target(value): + hit = component_ref(objects, prefab, value) + if hit is None: + raise ValueError("live start canvas requires a component target") + holder, index, comp = hit + return object_id(parse_object_id(holder)[0], int(comp["pathId"])), objects.json(holder)["nodes"][index]["path"] + + initialized = {} + for field, key in liveui.TEXT_FIELDS.items(): + refs = fields[field] if isinstance(fields[field], list) else [fields[field]] + for value in refs: + _, path = target(value) + initialized[path] = key + records = {} + paths = {} + table = {str(r["_id"]): r[languages.column(language)] for r in master["MasterText"]} + for n in nodes: + if not liveui._drawn(n["path"]): + continue + texts = [c for c in n["components"] if component_class(c) in storyui.TEXT_CLASSES] + locs = [c for c in n["components"] if component_class(c) == "LocalizeText"] + if not texts: + continue + if len(texts) != 1 or len(locs) > 1 or n["path"] in paths: + raise ValueError(f"{n['path']}: ambiguous text or localization components") + comp = {"class": component_class(texts[0]), **texts[0]["fields"]} + comp["m_fontAsset"] = {"name": storyui._name(objects, comp["m_fontAsset"])} + comp["m_sharedMaterial"] = {"material": storyui._name(objects, comp["m_sharedMaterial"])} + localize = {"class": "LocalizeText", **locs[0]["fields"]} if locs else None + rec = styles.record(n["path"], comp, localize) + text_key = rec["style"].get("textKey") + shown = ( + strings[initialized[n["path"]]] + if n["path"] in initialized + else (table[str(text_key)] if text_key else comp["m_text"]) + ) + tid = object_id(file, int(texts[0]["pathId"])) + paths[n["path"]] = tid + rec.update(path=n["path"], text=shown, setByInitialize=n["path"] in initialized) + rec["font"] = dict( + rec["localized"] + or { + "fontAsset": comp["m_fontAsset"]["name"], + "material": comp["m_sharedMaterial"]["material"], + "lineSpacing": comp["m_lineSpacing"], + } + ) + records[tid] = rec + missing = [p for p in initialized if liveui._drawn(p) and p not in paths] + if missing: + raise ValueError(f"live start text targets have no TMP records: {missing}") + jacket = key_object(objects, live.jacket_key(master["MasterLiveMusic"][0]["_jacketAssetName"]), "Sprite") + icons = {str(p["Difficulty"]): ref_id(p["Icon"]) for p in fields["_difficultyPairs"]} + icon = icons[str(liveui.LIVE_DIFFICULTY[difficulty])] + if icon is None: + raise ValueError(f"{difficulty}: no difficulty icon") + images = { + target(value)[0]: sprite + for field, sprite in (("_jacketImages", jacket), ("_difficultyImages", icon)) + for value in fields[field] + } + metadata = { + "musicId": music_id, + "difficulty": difficulty, + "scene": {"manifest": "scenes/default.json", "prefab": prefab, "canvas": node["path"]}, + "strings": strings, + "textStyle": styles.summary(), + "images": images, + "jacket": jacket, + "difficultyIcons": icons, + } + return metadata, records, lang + + +def build(objects, master: dict, subject: str, settings, source: str, font_files: dict): + """ui.json, PNG pages and font shader objects for a live start canvas.""" + from .storyfontproduct import font_product + + _, _, language = parse_subject(subject) + metadata, records, lang = text_records(objects, master, subject, settings) + doc, pages, keep, roles = font_product( + objects, + records, + {}, + [r["text"] for r in records.values()], + language, + lang, + settings, + source, + font_files, + format=FORMAT, + subset="live start canvas text, synthesized control characters, underline and missing glyph", + ) + doc.update(metadata) + return doc, pages, keep, roles diff --git a/src/nnnotes/content/storyfontproduct.py b/src/nnnotes/content/storyfontproduct.py index 1fcc573..10f8268 100644 --- a/src/nnnotes/content/storyfontproduct.py +++ b/src/nnnotes/content/storyfontproduct.py @@ -117,7 +117,7 @@ def sprite_record(objects, fs): return advui.sprite_record(tt, objects.json(sheet), fs.material(material)), sheet -def game_font_set(fs, bindings, shown, missing_char, sprite_drawn) -> dict: +def game_font_set(fs, bindings, shown, missing_char, sprite_drawn, *, subset: str) -> dict: """Reduced game font assets, their materials, fallback chains and PNG page bytes.""" primaries = sorted({b["localized"]["fontAsset"] for b in bindings}) needed, runtime, reports, missing_glyphs = {}, {}, {}, {} @@ -157,7 +157,7 @@ def game_font_set(fs, bindings, shown, missing_char, sprite_drawn) -> dict: runtime, text_materials, packer, - subset="episode text, synthesized control characters, underline and missing glyph", + subset=subset, ) textures, pages = packer.build() tmpfont.resolve_font_blocks(fonts) @@ -204,6 +204,36 @@ def build( status = storyui.chat_status_texts(objects, master) ids = objects.json(key_object(objects, storyui.MASTER_ID_SETTINGS_KEY, "AdvMasterIdSettings")) shown = shown_texts(episode, records, master, ids, status, frames, languages.column(language)[1:]) + return font_product( + objects, + records, + sprite_bindings, + shown, + language, + lang, + settings, + source, + font_files, + format=FORMAT, + subset="episode text, synthesized control characters, underline and missing glyph", + ) + + +def font_product( + objects, + records: dict, + sprite_bindings: dict, + shown: list[str], + language: str, + lang: dict, + settings, + source: str, + font_files: dict, + *, + format: str, + subset: str, +) -> tuple[dict, dict, set, dict]: + """Font records and PNG pages for explicit component records and displayed strings.""" chars = sorted({ord(c) for s in shown for c in s}) bindings = {tid: {"localized": dict(r["font"])} for tid, r in records.items()} fs = tmpassets.FontSet(objects) @@ -251,7 +281,7 @@ def font_of(code): settings=settings, ) elif source == "game": - result = game_font_set(fs, list(bindings.values()), shown, missing_char, sprite_drawn) + result = game_font_set(fs, list(bindings.values()), shown, missing_char, sprite_drawn, subset=subset) else: raise ValueError(f"font source {source!r}: expected open or game") for tid, record in records.items(): @@ -260,7 +290,7 @@ def font_of(code): if result["substituted"]: coverage["missingGlyph"] = [chr(u) for u in sorted(result["substituted"])] doc = { - "format": FORMAT, + "format": format, "language": language, "source": source, **{k: result[k] for k in ("fonts", "textures", "materials")}, diff --git a/src/nnnotes/pipeline/live.py b/src/nnnotes/pipeline/live.py index b5c329d..cfd7e7d 100644 --- a/src/nnnotes/pipeline/live.py +++ b/src/nnnotes/pipeline/live.py @@ -33,13 +33,18 @@ its settings, lane skins and bands (livescene.scene_record), its roots also the built-in graphics settings (livescene.GRAPHICS) +live.ui subject `//`: ui.json with start-canvas strings, text records, + generated glyph pages and image bindings, with manifest liveui/__.json. + It reads the client's font assets, localization settings, jacket and master text. The `fonts` + parameter is open or game; open font bytes are explicit inputs. The canvas belongs to live.scene. + live subject a live (content.live.live_subject: `/[/]`): "#live.json" (kind live: content.live.live), "#chart.json" (kind live.chart: the chart as shipped), "#notes.json" and "#notes.mirror.json" (kind live.notes.chart: the converted chart) and the object table of its jacket; its manifest is lives/_[_].json, its input "master" the live's master record (live.live_record) with the manifest paths of its parts. `parts` gives the products a live plays with: the scene, the note highway with the default note skin and - effect set, the default sounds and the music. + effect set, the default sounds, the music and UI manifests by language. `select` (the `product` layout) makes the live.music products of the musics whose BGM key the selection holds, the default live.sounds when it holds every cue sheet of the default sounds (both need the master data), live.notes when @@ -53,11 +58,15 @@ import tempfile from pathlib import Path -from ..content import live, liveaudio, livenotes, livescene +from ..content import live, liveaudio, livenotes, livescene, liveuiproduct +from ..content.prefabs import mono_json from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO, mp4_priming, web_encode -from . import contract -from .assemble import MASTER_INPUT, Assembly, ProductStage, master_input +from ..source import languages +from ..unity import textstyle +from . import contract, units +from .assemble import MASTER_INPUT, Assembly, ProductStage, master_input, reach from .contract import IncompatibleTask, Input +from .textfonts import FontInputsStage, font_atoms, font_settings, record_input from .views import master_rows __all__ = [ @@ -68,6 +77,7 @@ "NOTESKIN", "SCENE", "SOUNDS", + "UI", "LiveStage", "MusicStage", "NoteEffectStage", @@ -75,6 +85,7 @@ "NotesStage", "SceneStage", "SoundsStage", + "UiStage", "encode_atoms", ] @@ -82,6 +93,7 @@ NOTES, NOTESKIN, NOTEEFFECT = "live.notes", "live.noteskin", "live.noteeffect" SCENE = "live.scene" LIVE = "live" +UI = "live.ui" DEFAULT = "default" @@ -345,6 +357,80 @@ def assemble(self, task, objects) -> Assembly: return Assembly({"scene.json": _json(doc, SCENE)}, keep, roles) +class UiStage(FontInputsStage): + """live.ui: start-canvas strings, text styles, font pages and image bindings.""" + + name = UI + version = 1 + KIND = "LiveUi" + MASTER = liveuiproduct.TABLES + PARAMS = {"fonts": "open"} + + def normalize(self, params): + p = super().normalize(params) + if p["fonts"] not in ("open", "game"): + raise ValueError(f"stage {self.name}: fonts must be open or game") + return p + + def uses(self, subject, env): + return font_atoms() + + def record(self, subject, env): + return liveuiproduct.record(master_rows(env, self.MASTER), subject) + + def keys(self, subject, env): + from ..content import storyui + + catalog = {loc["primaryKey"] for loc in env.fact("index")["locations"]} + return sorted({*liveuiproduct.keys(self.record(subject, env)), *storyui.font_keys(catalog)}) + + def resources(self, subject, env): + liveuiproduct.parse_subject(subject) + return ["TMP Settings"] + + def builtins(self, subject, env): + liveuiproduct.parse_subject(subject) + return ["MonoBehaviour:LocalizeManager"] + + def more_inputs(self, subject, env): + record = self.record(subject, env) + jacket = live.jacket_key(record["MasterLiveMusic"][0]["_jacketAssetName"]) + roots = units.root_objects(env, [jacket]) + table = reach(env, roots, self._results, {jacket: roots}).table + out = [ + master_input(env, record), + self.font_bundles(subject, env), + record_input(env, "jacket.objects", table), + ] + if env.params["fonts"] == "open": + for code, path in sorted((env.facts.get("font_files") or {}).items()): + data = Path(path).read_bytes() + out.append(Input(f"font:{code}", env.depot.put(data), len(data), None, ({"kind": "depot"},))) + return out + + def manifest(self, subject): + music, difficulty, language = liveuiproduct.parse_subject(subject) + return f"liveui/{music}_{difficulty}_{language}.json" + + def assemble(self, task, objects): + from ..content import storyui + + if task.atoms != font_atoms(): + raise IncompatibleTask(f"task {task.id}: the font libraries differ from the task") + lang = textstyle.language_fonts_json(mono_json(objects, storyui.script_root(objects, "LocalizeManager"))) + settings = font_settings(task, objects, lang) + record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) + fonts = {i.role[5:]: objects.depot.input_bytes(i) for i in task.inputs if i.role.startswith("font:")} + doc, pages, keep, roles = liveuiproduct.build( + objects, record, task.subject, settings, task.params["fonts"], fonts + ) + jacket = contract.loads(objects.depot.input_bytes(task.input("jacket.objects"))) + keep.update(o for o, row in jacket["objects"].items() if row["class"] != "MonoScript") + files = {p: (body, "png", "live.glyphs") for p, body in pages.items()} + files["ui.json"] = _json(doc, UI) + return Assembly(files, keep, roles) + + # the parts of a live: {part: product stage} PARTS = { "scene": SCENE, @@ -361,6 +447,7 @@ def assemble(self, task, objects) -> Assembly: NOTEEFFECT: NoteEffectStage, SOUNDS: SoundsStage, MUSIC: MusicStage, + UI: UiStage, } @@ -368,7 +455,7 @@ class LiveStage(ProductStage): """live: a music's chart at a difficulty and the products it plays with (module documentation).""" name = LIVE - version = 1 + version = 2 KIND = "Live" MASTER = tuple(sorted({*live.LIVE_TABLES, *livenotes.NOTES_TABLES})) @@ -393,11 +480,11 @@ def live_record(self, subject: str, env) -> dict: def keys(self, subject: str, env) -> list[str]: return live.live_keys(self.live_record(subject, env)) - def parts(self, subject: str, env) -> dict[str, list[str]]: + def parts(self, subject: str, env, requested_languages=()) -> dict[str, list[str]]: """{product stage: [subject]} of the products the live plays with (module documentation).""" - music_id, _, _ = live.parse_subject(subject) + music_id, difficulty, _ = live.parse_subject(subject) skin, effect = livenotes.default_assets(livenotes.notes_record(master_rows(env, livenotes.NOTES_TABLES))) - return { + parts = { SCENE: [DEFAULT], NOTES: [DEFAULT], NOTESKIN: [skin], @@ -405,10 +492,17 @@ def parts(self, subject: str, env) -> dict[str, list[str]]: SOUNDS: [liveaudio.sounds_subject()], MUSIC: [str(music_id)], } + if requested_languages: + parts[UI] = [ + f"{music_id}/{difficulty}/{languages.check(code)}" for code in sorted(set(requested_languages)) + ] + return parts def more_inputs(self, subject: str, env) -> list[Input]: parts = self.parts(subject, env) paths = {part: PART_STAGES[name]().manifest(parts[name][0]) for part, name in PARTS.items()} + music_id, difficulty, _ = live.parse_subject(subject) + paths["ui"] = {code: UiStage().manifest(f"{music_id}/{difficulty}/{code}") for code in languages.LANGUAGES} return [master_input(env, {"live": self.live_record(subject, env), "parts": paths})] def manifest(self, subject: str) -> str: diff --git a/src/nnnotes/pipeline/story.py b/src/nnnotes/pipeline/story.py index e6c4093..390f1fc 100644 --- a/src/nnnotes/pipeline/story.py +++ b/src/nnnotes/pipeline/story.py @@ -67,20 +67,23 @@ from ..content import liveaudio, storyasset, storychat, storyepisode, storyscene, storyui, storyvideo from ..content.livescene import GRAPHICS -from ..content.prefabs import mono_json, script_class +from ..content.prefabs import mono_json from ..formats import acb, crilips from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO, mp4_priming, web_encode from ..source import languages -from ..source.apkset import MANIFEST_IN_APK, manifest_version_name -from ..unity import fontsettings, textstyle +from ..source.apkset import MANIFEST_IN_APK +from ..unity import textstyle from ..unity.atoms import Unsupported -from ..unity.ids import parse_object_id from . import contract, units from .assemble import MASTER_INPUT, Assembly, Objects, ProductStage, master_input, reach from .contract import IncompatibleTask, Input from .layout import escape_segment from .live import encode_atoms from .live2d import MODEL, ModelStage +from .textfonts import FontInputsStage +from .textfonts import font_atoms as font_atoms +from .textfonts import font_settings as _font_settings +from .textfonts import record_input as _record_input from .views import master_rows __all__ = [ @@ -342,7 +345,7 @@ def _language(subject: str) -> str: return subject -class UiStage(ProductStage): +class UiStage(FontInputsStage): """story.ui: the ADV story UI in one client language (module documentation).""" name = UI @@ -387,18 +390,6 @@ def more_inputs(self, subject: str, env) -> list: self.font_bundles(subject, env), ] - def font_bundles(self, subject: str, env) -> Input: - """The bundle names of the font assets read by this product.""" - objects = Objects(self.reach(subject, env).table, env.depot) - font_files = set(_font_files(objects).values()) - bundles = {} - for catalog in sorted(env.fact("catalogs")): - addresses = units.address_doc(env, catalog) - bundles.update( - {f: addresses["bundles"][b]["name"] for f, b in addresses["files"].items() if f in font_files} - ) - return _record_input(env, "font-bundles", bundles) - def manifest(self, subject: str) -> str: return f"storyui/{_language(subject)}.json" @@ -414,18 +405,6 @@ def assemble(self, task, objects) -> Assembly: return Assembly({"ui.json": (contract.encode(doc), "json", UI)}, keep, roles, ui_table) -def _font_settings(task, objects, lang): - from ..content import tmpassets - - bundles = contract.loads(objects.depot.input_bytes(task.input("font-bundles"))) - files = _font_files(objects) - version = manifest_version_name(objects.depot.input_bytes(task.input(f"apk:{MANIFEST_IN_APK}"))) - if version is None: - raise ValueError(f"{task.stage}: the APK manifest has no version name") - doc = tmpassets.build_font_settings(objects, lang, version, lambda name: bundles[files[name]]) - return fontsettings.FontSettings(doc) - - def font_subject(subject: str) -> tuple[str, str]: episode, sep, language = subject.partition("/") if not sep or str(_adv_id(episode)) != episode: @@ -433,12 +412,6 @@ def font_subject(subject: str) -> tuple[str, str]: return episode, _language(language) -def font_atoms() -> dict: - from ..unity.atoms import impl_id - - return {"text.glyphs": impl_id(("fonttools", "freetype-py", "scipy", "numpy", "Pillow"), 1)} - - class FontsStage(UiStage): """An episode and language's font records and glyph pages from its object and font inputs.""" @@ -513,21 +486,6 @@ def assemble(self, task, objects): return Assembly(files, keep, roles) -def _record_input(env, role: str, record: dict) -> Input: - """A JSON record read by a product, stored as a content-addressed input.""" - body = contract.encode(record) - return Input(role, env.depot.put(body), len(body), None, ({"kind": "depot"},)) - - -def _font_files(objects: Objects) -> dict[str, str]: - """The serialized file holding each TMP font asset, by asset name.""" - return { - objects.info(o)["name"]: parse_object_id(o)[0] - for o in objects.ids("MonoBehaviour") - if script_class(objects, o) == fontsettings.FONT_CLASS - } - - def _adv_id(subject: str) -> int: if not subject.isdigit(): raise ValueError(f"{subject}: not an episode id") diff --git a/src/nnnotes/pipeline/textfonts.py b/src/nnnotes/pipeline/textfonts.py new file mode 100644 index 0000000..42cca80 --- /dev/null +++ b/src/nnnotes/pipeline/textfonts.py @@ -0,0 +1,58 @@ +"""Content-addressed font inputs shared by text products.""" + +from __future__ import annotations + +from ..content.prefabs import script_class +from ..source.apkset import MANIFEST_IN_APK, manifest_version_name +from ..unity import fontsettings +from ..unity.ids import parse_object_id +from . import contract, units +from .assemble import Objects, ProductStage +from .contract import Input + + +def record_input(env, role: str, record: dict) -> Input: + body = contract.encode(record) + return Input(role, env.depot.put(body), len(body), None, ({"kind": "depot"},)) + + +def font_files(objects: Objects) -> dict[str, str]: + """The serialized file holding each TMP font asset, by asset name.""" + return { + objects.info(o)["name"]: parse_object_id(o)[0] + for o in objects.ids("MonoBehaviour") + if script_class(objects, o) == fontsettings.FONT_CLASS + } + + +def font_settings(task, objects, lang): + from ..content import tmpassets + + bundles = contract.loads(objects.depot.input_bytes(task.input("font-bundles"))) + files = font_files(objects) + version = manifest_version_name(objects.depot.input_bytes(task.input(f"apk:{MANIFEST_IN_APK}"))) + if version is None: + raise ValueError(f"{task.stage}: the APK manifest has no version name") + doc = tmpassets.build_font_settings(objects, lang, version, lambda name: bundles[files[name]]) + return fontsettings.FontSettings(doc) + + +def font_atoms() -> dict: + from ..unity.atoms import impl_id + + return {"text.glyphs": impl_id(("fonttools", "freetype-py", "scipy", "numpy", "Pillow"), 1)} + + +class FontInputsStage(ProductStage): + """Font bundle identities and the APK version used by text layout and glyph generation.""" + + APK = (MANIFEST_IN_APK,) + + def font_bundles(self, subject: str, env) -> Input: + objects = Objects(self.reach(subject, env).table, env.depot) + files = set(font_files(objects).values()) + bundles = {} + for catalog in sorted(env.fact("catalogs")): + addresses = units.address_doc(env, catalog) + bundles.update({f: addresses["bundles"][b]["name"] for f, b in addresses["files"].items() if f in files}) + return record_input(env, "font-bundles", bundles) diff --git a/src/nnnotes/schemas/live-ui.schema.json b/src/nnnotes/schemas/live-ui.schema.json new file mode 100644 index 0000000..1a74ded --- /dev/null +++ b/src/nnnotes/schemas/live-ui.schema.json @@ -0,0 +1,278 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:nnnotes:schema:live-ui", + "title": "Live start canvas text and font pages", + "type": "object", + "required": [ + "format", + "language", + "source", + "fonts", + "textures", + "materials", + "materialKeywords", + "texts", + "sprites", + "coverage", + "lineBreaking", + "shaders", + "musicId", + "difficulty", + "scene", + "strings", + "textStyle", + "images", + "jacket", + "difficultyIcons" + ], + "properties": { + "format": { + "const": "nnnotes.live-ui/1" + }, + "language": { + "enum": [ + "ja", + "en", + "zh-Hant", + "zh-Hans", + "ko" + ] + }, + "source": { + "enum": [ + "open", + "game" + ] + }, + "fonts": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "characters", + "glyphs", + "material", + "fallbacks" + ] + } + }, + "textures": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "texture", + "width", + "height" + ], + "properties": { + "texture": { + "type": "string" + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "materials": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "materialKeywords": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "texts": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "style", + "localized", + "font", + "path", + "text", + "setByInitialize" + ], + "properties": { + "style": { + "type": "object" + }, + "localized": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/font" + } + ] + }, + "font": { + "$ref": "#/$defs/font" + }, + "path": { + "type": "string" + }, + "text": { + "type": "string" + }, + "setByInitialize": { + "type": "boolean" + } + } + } + }, + "sprites": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "spriteAsset", + "m_tintAllSprites" + ] + } + }, + "coverage": { + "type": "object", + "required": [ + "characters", + "missing" + ], + "properties": { + "characters": { + "type": "integer", + "minimum": 0 + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "lineBreaking": { + "type": "object", + "required": [ + "leading", + "following", + "useModernHangulLineBreakingRules" + ], + "properties": { + "leading": { + "type": "string" + }, + "following": { + "type": "string" + }, + "useModernHangulLineBreakingRules": { + "type": "boolean" + } + } + }, + "shaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "spriteAssets": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "emojiSpriteAsset": { + "type": "string" + }, + "musicId": { + "type": "integer" + }, + "difficulty": { + "enum": [ + "easy", + "normal", + "hard", + "expert" + ] + }, + "scene": { + "type": "object", + "required": [ + "manifest", + "prefab", + "canvas" + ], + "properties": { + "manifest": { + "type": "string" + }, + "prefab": { + "type": "string" + }, + "canvas": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "textStyle": { + "type": "object" + }, + "images": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "jacket": { + "type": "string" + }, + "difficultyIcons": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "$defs": { + "font": { + "type": "object", + "required": [ + "fontAsset", + "material", + "lineSpacing" + ], + "properties": { + "fontAsset": { + "type": "string" + }, + "material": { + "type": "string" + }, + "lineSpacing": { + "type": "number" + } + } + } + } +} diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index cce082e..a7f37dc 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -519,7 +519,17 @@ def export(args, cfg, ws): monkeypatch.setattr(assets, "export", export) opts = ["NoteDesignId=2", "NoteEffectId", "LiveQuality", "NoteSePatternId=1,3"] - flags = [*(x for o in opts for x in ("--live-option", o)), "--format", "flac", "--flac-level", "5"] + flags = [ + *(x for o in opts for x in ("--live-option", o)), + "--format", + "flac", + "--flac-level", + "5", + "--fonts", + "game", + "--live-languages", + "ja,zh-Hant", + ] code, _, err = run(argv + ["export", "live", "7", "--leader-card", "5", *flags, "-o", str(tmp_path / "o")], capsys) assert code == 0, err assert seen["products"] == { @@ -530,6 +540,7 @@ def export(args, cfg, ws): "live.noteeffect": ["fxA", "fxALight", "fxASimple"], # no Light variant of fxASimple in the catalog "live.sounds": ["NoteSePatternId=1,3", "default"], "live.music": ["7"], + "live.ui": ["7/expert/ja", "7/expert/zh-Hant"], } keys = {s[4:] for s in seen["selection"]} assert {"EmbScene/Live", "Image/Jacket/jacket7", "Live/MusicScore/m7_03", "Cri/Sound/se3"} <= keys diff --git a/tests/content/test_liveuiproduct.py b/tests/content/test_liveuiproduct.py new file mode 100644 index 0000000..4f67433 --- /dev/null +++ b/tests/content/test_liveuiproduct.py @@ -0,0 +1,210 @@ +"""Live UI products with synthetic scenes, fonts and master rows.""" + +import copy +import io +from types import SimpleNamespace + +import pytest +from jsonschema import Draft202012Validator +from PIL import Image + +from nnnotes import schemas +from nnnotes.content import liveui, liveuiproduct +from nnnotes.pipeline import contract, live, textfonts +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import Depot +from nnnotes.pipeline.stages import Env +from tests.content.test_storyfontproduct import fixture as font_fixture +from tests.helpers import livenotes as ln +from tests.helpers import storyui as sui +from tests.helpers.fonts import make_font +from tests.helpers.live2dmodel import ref +from tests.pipeline.test_live import run, stored + +SCENE, JACKET, ICON = "CAB-live:1", "CAB-jacket:1", "CAB-icon:1" + + +def master(): + def text(tid, ja, zh=None): + return {"_id": tid, "_japanese": ja, "_traditionalChinese": ja if zh is None else zh} + + return { + "MasterLiveMusic": [ + { + "_id": 7, + "_expertID": 73, + "_normalID": 71, + "_titleTextID": "title", + "_bandNameTextID": "", + "_bandIDs": [2], + "_lyricistTextID": "author", + "_composerTextID": "", + "_arrangerTextID": "author", + "_jacketAssetName": "jacket7", + } + ], + "MasterLiveMusicScore": [{"_id": 73, "_musicScoreLevel": 27}, {"_id": 71, "_musicScoreLevel": 5}], + "MasterBand": [{"_id": 2, "_nameTextID": "band"}], + "MasterText": [ + text("title", "AB", "BA"), + text("author", "A", "B"), + text("band", "A", "BA"), + text("ui_credit_lyrics", "{0} A"), + text("ui_credit_composer", "B{0}"), + text("ui_credit_arranger", ""), + text("label", "A", "B"), + ], + } + + +def fixture(depot): + inputs, table, _ = font_fixture(depot) + fields, nodes = {}, [] + for index, (field, _) in enumerate(liveui.TEXT_FIELDS.items()): + path = f"{liveui.DRAWN[1]}/{index}" + nodes.append( + ln.node( + path, ln.comp("TextMeshProUGUI", 100 + index, **sui.tmp_fields(sui.FONT_JA, sui.MAT_JA_DEFAULT, "AB")) + ) + ) + target = ln.at(path, "TextMeshProUGUI") + fields[field] = [target] if field.endswith("Texts") else target + path = f"{liveui.DRAWN[0]}/label" + nodes.append( + ln.node( + path, + ln.comp("TextMeshProUGUI", 150, **sui.tmp_fields(sui.FONT_JA, sui.MAT_JA_DEFAULT, "AB")), + ln.comp("MonoBehaviour", 151, sui.LOCALIZE_SCRIPT, m_Enabled=1, _localizeEnabled=1, _masterTextID="label"), + ) + ) + for i, field in enumerate(("_jacketImages", "_difficultyImages")): + path = f"{liveui.DRAWN[2]}/{i}" + nodes.append(ln.node(path, ln.comp("Image", 200 + i, m_Sprite=None))) + fields[field] = [ln.at(path, "Image")] + fields["_difficultyPairs"] = [{"Difficulty": 3, "Icon": ref(ICON)}] + nodes.insert(0, ln.node(liveui.CANVAS, ln.comp("MonoBehaviour", 2, "App|App.Live|UILiveStartCanvas", **fields))) + extra = ln.table( + depot, + {liveui.SCENE_KEY: [SCENE], "Image/Jacket/jacket7": [JACKET]}, + { + SCENE: ("GameObject", "Live", {"json": {"name": "Live", "nodes": nodes}}), + JACKET: ("Sprite", "jacket7", {"json": {}}), + ICON: ("Sprite", "Expert", {"json": {}}), + }, + ) + table["objects"].update(extra["objects"]) + table["keys"].update(extra["keys"]) + table["roots"] += extra["roots"] + inputs = [i for i in inputs if i.role not in ("objects", "master", "episode")] + inputs += [ + stored(depot, contract.encode(table), "objects"), + stored(depot, contract.encode(liveuiproduct.record(master(), "7/expert/zh-Hant")), "master"), + stored(depot, contract.encode({"objects": {JACKET: table["objects"][JACKET]}}), "jacket.objects"), + ] + return inputs, table + + +@pytest.mark.parametrize("source", ["open", "game"]) +def test_start_canvas_text_pages_and_image_bindings(tmp_path, source): + depot = Depot(tmp_path / "d") + inputs, table = fixture(depot) + if source == "open": + data = make_font(tmp_path / "font.ttf", underscore=True, extra={ord(c): "A" for c in "0257"}).read_bytes() + inputs += [stored(depot, data, f"font:{code}") for code in ("ja", "zh-Hant")] + _, _, arts = run(depot, live.UiStage(), "7/expert/zh-Hant", inputs, {"fonts": source}, textfonts.font_atoms()) + doc = contract.loads(depot.read(arts["ui.json"]["content"]["sha256"])) + Draft202012Validator(schemas.load("live-ui")).validate(doc) + assert doc["strings"] == { + "musicTitle": "AB", + "singerName": "BA", + "lyricsWriter": "B A", + "composer": "B", + "arranger": "", + "musicLevel": "27", + "highScore": "0", + } + assert doc["texts"]["CAB-live:100"]["text"] == "AB" + assert doc["texts"]["CAB-live:101"]["text"] == "BA" + assert doc["texts"]["CAB-live:150"]["text"] == "B" + assert not doc["texts"]["CAB-live:150"]["setByInitialize"] + assert doc["scene"] == {"manifest": "scenes/default.json", "prefab": SCENE, "canvas": liveui.CANVAS} + assert doc["images"] == {"CAB-live:200": JACKET, "CAB-live:201": ICON} + kept = contract.loads(depot.read(arts["objects"]["content"]["sha256"]))["objects"] + assert set(kept) == {JACKET, sui.SHADER} + for texture in doc["textures"].values(): + image = Image.open(io.BytesIO(depot.read(arts[texture["texture"]]["content"]["sha256"]))) + assert image.size == (texture["width"], texture["height"]) + for rec in doc["texts"].values(): + assert rec["font"]["fontAsset"] in doc["fonts"] + assert rec["font"]["material"] in doc["materials"] + objects = Objects(table, depot) + # The shared input remains usable by subsequent products. + assert objects.json(SCENE)["nodes"][1]["components"][0]["fields"]["m_text"] == "AB" + + +def test_master_record_tracks_difficulty_and_language(): + tables = master() + record = liveuiproduct.record(tables, "7/normal/zh-Hant") + assert record["MasterLiveMusicScore"] == [{"_id": 71, "_musicScoreLevel": 5}] + assert liveui.record_strings(record, 7, "normal", "_traditionalChinese")[0]["musicLevel"] == "5" + record["MasterText"][0]["_traditionalChinese"] = "" + assert liveui.record_strings(record, 7, "normal", "_traditionalChinese")[0]["musicTitle"] == "AB" + + +def test_ui_subject_manifest_and_requested_languages(tmp_path): + stage = live.UiStage() + assert stage.manifest("7/expert/ja") == "liveui/7_expert_ja.json" + for value in ("7", "7/expert", "7/expert/ja/1", "7/impossible/ja"): + with pytest.raises(ValueError): + stage.manifest(value) + with pytest.raises(ValueError): + stage.normalize({"fonts": "invalid"}) + assert stage.normalize(None) == {"fonts": "open"} + from tests.pipeline.test_live import live_master_dir + + env = Env(Depot(tmp_path / "d"), {"master": live_master_dir(tmp_path / "m")}) + assert live.LiveStage().parts("7/expert/1", env, ["ja", "ja", "zh-Hant"])[live.UI] == [ + "7/expert/ja", + "7/expert/zh-Hant", + ] + + +def test_static_label_language_changes_the_text_record(tmp_path): + depot = Depot(tmp_path / "d") + inputs, table = fixture(depot) + objects = Objects(table, depot) + from nnnotes.unity import textstyle + + task = contract.Task(live.UI, 1, "7/expert/zh-Hant", {}, {}, tuple(inputs)) + settings = textfonts.font_settings(task, objects, textstyle.language_fonts_json(sui.LOCALIZE_JSON)) + tables = copy.deepcopy(master()) + tables["MasterText"][-1]["_traditionalChinese"] = "" + _, records, _ = liveuiproduct.text_records(Objects(table, depot), tables, "7/expert/zh-Hant", settings) + assert records["CAB-live:150"]["text"] == "" + + +def test_live_ui_task_key_tracks_font_bytes_and_localized_master_text(tmp_path, monkeypatch): + depot = Depot(tmp_path / "d") + path = tmp_path / "font.ttf" + path.write_bytes(b"font revision one") + env = Env(depot, {"font_files": {"ja": path}}) + env.params = {"fonts": "open"} + stage = live.UiStage() + record = liveuiproduct.record(master(), "7/expert/ja") + monkeypatch.setattr(stage, "record", lambda subject, env: record) + monkeypatch.setattr(stage, "font_bundles", lambda subject, env: stored(depot, b"{}", "font-bundles")) + monkeypatch.setattr(live.units, "root_objects", lambda *a, **kw: [JACKET]) + monkeypatch.setattr(live, "reach", lambda *a, **kw: SimpleNamespace(table={"objects": {}})) + + def task(): + return contract.Task(live.UI, 1, "7/expert/ja", env.params, {}, tuple(stage.more_inputs("7/expert/ja", env))) + + first = task() + path.write_bytes(b"font revision two") + second = task() + assert first.key != second.key + assert depot.input_bytes(first.input("font:ja")) == b"font revision one" + record["MasterText"][0]["_japanese"] = "BA" + assert task().key != second.key + env.params = {"fonts": "game"} + assert all(not i.role.startswith("font:") for i in task().inputs) diff --git a/tests/pipeline/test_live.py b/tests/pipeline/test_live.py index 19c88f2..ca8124d 100644 --- a/tests/pipeline/test_live.py +++ b/tests/pipeline/test_live.py @@ -279,6 +279,7 @@ def test_the_live_product_holds_the_chart_and_the_manifests_of_its_parts(tmp_pat "noteskin": "noteskins/skin001.json", "scene": "scenes/default.json", "sounds": "sounds/default.json", + "ui": {code: f"liveui/7_expert_{code}.json" for code in ("ja", "en", "zh-Hant", "zh-Hans", "ko")}, } objects = stored(depot, contract.encode(lh.table(depot)), "objects") _, _, arts = run(depot, stage, "7/expert", [objects, record])