diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccbec8..423fb79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ schemas in `nnnotes.schemas` and the documented Python API. Before 1.0 a minor v ### Features +- Store content, task inputs, results and catalog versions in a persistent depot with optional remote storage, verified collection and bounded source downloads. +- Assemble Live2D, room, spot, live, story and UI products from shared object tables and explicit manifest references. +- Generate localized live and story text products with game or supplied fonts, shared glyph atlases and material records. - Build named sites through depot product, player read-set, asset and index tasks. - Retain unselected regional topics, publish immutable product dependencies in order, and restore complete sites from their saved depot layouts. - Offer Live variant products from chart manifests and select story UI/font languages explicitly. @@ -16,6 +19,7 @@ schemas in `nnnotes.schemas` and the documented Python API. Before 1.0 a minor v - Site construction uses shared product requests, named depot catalogs and ordered layout publication. Player page collection and catalog model queries have dedicated interfaces. - Conversion memoization is bounded within each process, with persistent reuse provided by depot task results. +- Product assembly consumes stored class-converter outputs. Font lookup, texel packing and material construction share object-based implementations. ## [0.2.1](https://github.com/MetaSekaiLab/nnnotes/compare/v0.2.0...v0.2.1) - 2026-10-13 diff --git a/docs/architecture.md b/docs/architecture.md index 385d351..d3e28b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,7 @@ subpackage of `nnnotes` and imports only the layers below it. | Products | `nnnotes.site`, `nnnotes.musicdata` | the ournotes-player site (charts, models, stories, indexes); the music data file and its deck statistics | | Pipeline | `nnnotes.pipeline` | stages, task descriptions and results, the content-addressed depot, plans, layouts, the catalog versions, the views of the master data | | Content | `nnnotes.content` | the game's subjects as exports: ADV episodes and stories, lives and scores, spots, rooms, the UI library | -| Unity objects | `nnnotes.unity` | bundle reading, object ids, the atoms (the pure functions objects are converted with: texture decoding, sprite images, mesh arrays, glTF, PNG, file sniffing), the converters (one per Unity class, `nnnotes.unity.convert`), clip bindings named in the hierarchy that plays a clip (`nnnotes.unity.bindings`), the type trees shipped for the player's script classes (`nnnotes.unity.scripttypes`), the typetree exporter, the player data, Live2D prefabs, text styles and TextMesh Pro fonts | +| Unity objects | `nnnotes.unity` | bundle reading, object ids, atomic texture/sprite/mesh/glTF/PNG conversions, class converters (`nnnotes.unity.convert`), clip bindings (`nnnotes.unity.bindings`), script type trees (`nnnotes.unity.scripttypes`), player data, text styles, font protocols and texel packing | | File formats | `nnnotes.formats` | formats read from bytes: CRI ACB/AWB and HCA, USM movies, CRI Lips data, shaders, motions | | Sources | `nnnotes.source` | the configuration, the APK, Addressables catalogs and bundles, the game API, master data, languages, `Session` | | Core | `nnnotes.core` | the one JSON writer, canonical JSON and its hashes, media types, the process caches and the downloads cache | @@ -40,6 +40,8 @@ decrypted bundles in the depot's `downloads/` directory. `export live`, `export story`, `export live2d`, `export spot`, `export room` and `export ui` request the products of selected subjects through that same pipeline. Stories first produce an episode, then request its referenced products. Shared objects and unchanged products reuse committed tasks. +Class converters emit object records and binary files. Product assemblers read those stored records through an object table and identify shared dependencies by object id and product manifest. Text products use the same font lookup, material and in-memory atlas functions. A product request contains its complete object and media dependencies before assembly. + `site build` uses the same product requests, then runs player read-set, asset, document and index tasks. A named catalog retains unselected topics and references immutable products. Publication writes dependencies before entry manifests and indexes. The successful run stores the complete layout so a new output directory can be restored through the depot. The site's `Builder` composes product snapshots, catalog indexes and player page files. Product assembly and conversion belong to the pipeline and content layers. `catalog_models` queries model subjects from a source catalog. diff --git a/docs/products.md b/docs/products.md index 6a76c50..95597d3 100644 --- a/docs/products.md +++ b/docs/products.md @@ -543,6 +543,8 @@ every episode whose key the selection holds. A stage with `INCLUDE_FONTS = True` requests traversal through its referenced font objects. The request records these roots in `font_roots`; unit selection and assembly follow the same rule. Other products keep their own font selection behavior. +The maintainer tool `scripts/gen_font_settings.py TASK.json --depot DEPOT -o OUTPUT.json` generates a font settings table from a saved text-product task. It reads the task's object table, font-bundle identities and APK manifest through the depot. The task must contain the complete font inputs, as used by `story.fonts` or `live.ui`. + ## Site snapshots `nnnotes.site.products.Snapshot` takes a depot and product-layout manifests keyed by path. It builds each product's diff --git a/scripts/atom_eval.py b/scripts/atom_eval.py index 1e23446..4ed1e51 100644 --- a/scripts/atom_eval.py +++ b/scripts/atom_eval.py @@ -1184,7 +1184,7 @@ def _apply_atoms(atoms) -> None: def _import_stage_modules() -> dict: """Import what a unity.export task needs, timed (wall, CPU).""" t0, c0 = time.perf_counter(), time.process_time() - for m in ("numpy", "PIL.Image", "nnnotes.pipeline.objexport", "nnnotes.unity.export"): + for m in ("numpy", "PIL.Image", "nnnotes.pipeline.objexport", "nnnotes.unity.texels"): importlib.import_module(m) return {"wall": round(time.perf_counter() - t0, 4), "cpu": round(time.process_time() - c0, 4)} @@ -1409,7 +1409,7 @@ def cmd_m3_one(a) -> None: # ---------------------------------------------------------------- imports def cmd_imports(a) -> dict: - sets = [m.split(",") for m in (a.module or ["numpy,PIL.Image,nnnotes.pipeline.objexport,nnnotes.unity.export"])] + sets = [m.split(",") for m in (a.module or ["numpy,PIL.Image,nnnotes.pipeline.objexport,nnnotes.unity.texels"])] code = ( "import resource, sys, time, json\n" "t = time.perf_counter(); c = time.process_time()\n" diff --git a/scripts/gen_font_settings.py b/scripts/gen_font_settings.py index e431593..0db28d3 100644 --- a/scripts/gen_font_settings.py +++ b/scripts/gen_font_settings.py @@ -1,54 +1,45 @@ -"""Regenerate the font settings table the open-font exports read (maintainer tool; not installed, not needed to use -nnnotes). - -With `--fonts open` (the default) the story, start canvas and talk window exports give each localized game font -asset an open font asset with that asset's point size, padding, render mode, style settings, line metrics, fallback -chain and text materials. nnnotes ships these values in `src/nnnotes/unity/fontsettings.json` (fontsettings.FORMAT), so -those exports read no game font asset; this script reads them from the game's font assets and writes the file. - -Supporting a new game version ------------------------------ -When the game changes its font assets or their materials (an open-font export stops with "... not in the font -settings", or the values differ from the game's), regenerate the table with a configured catalog and APK of that -version (`nnnotes config check`): - - python scripts/gen_font_settings.py [--region REGION] [--game-version VERSION] [-o PATH] - -It reads LocalizeManager's font names of every language and their material types from the player data, each of -those font assets with the assets of its fallback chain, their default materials and every localized text material -(" - ") the catalog has, and writes the table (`--game-version` default: the APK's versionName). The -`fonts` extra must be installed. +"""Generate a font settings table from the inputs of a saved text-product task. + +Usage: python scripts/gen_font_settings.py TASK.json --depot DEPOT [-o OUTPUT] + +The task's object table, font-bundle identities and APK manifest are read through the depot. The table contains +the font assets LocalizeManager names, their fallback chains, materials and object ids. Text products with these +inputs include story.fonts and live.ui. The fonts extra must be installed. """ from __future__ import annotations import argparse import sys -import tempfile from pathlib import Path +from nnnotes.content import storyui +from nnnotes.content.prefabs import mono_json +from nnnotes.pipeline import contract, textfonts +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import open_depot from nnnotes.source.config import Config, use -from nnnotes.source.session import Session -from nnnotes.unity import fontsettings, tmpfont -from nnnotes.unity.export import Exporter -from nnnotes.unity.player import PlayerData +from nnnotes.unity import fontsettings, textstyle + + +def generate(task, depot) -> dict: + """Font settings from the complete stored inputs of a text product task.""" + objects = Objects(contract.loads(depot.input_bytes(task.input("objects"))), depot) + localize = storyui.script_root(objects, "LocalizeManager") + language = textstyle.language_fonts_json(mono_json(objects, localize)) + return textfonts.font_settings(task, objects, language).doc def main(argv=None) -> None: - p = argparse.ArgumentParser( - description="regenerate src/nnnotes/unity/fontsettings.json from the game's font assets" - ) - p.add_argument("--region", help="region of the catalog (default: [catalog] region)") - p.add_argument("--game-version", help="game version to record (default: the APK manifest's versionName)") - p.add_argument("-o", "--out", type=Path, default=fontsettings.FILE, help="output file") - args = p.parse_args(argv) - cfg = use(Config.load()) - session = Session(cfg) - cat = session.catalog(region=args.region) - player = PlayerData(session.apk(args.region)) - with tempfile.TemporaryDirectory(prefix="fontsettings-") as work: - ex = Exporter(cat, Path(work), player=player, textures="deferred") - doc = tmpfont.build_font_settings(ex, player, args.game_version or player.game_version) + parser = argparse.ArgumentParser(description="generate font settings from a saved text-product task") + parser.add_argument("task", type=Path, help="nnnotes.task/1 JSON with font inputs") + parser.add_argument("--depot", type=Path, help="depot holding the task inputs ([paths] depot)") + parser.add_argument("-o", "--out", type=Path, default=fontsettings.FILE, help="output table") + args = parser.parse_args(argv) + overrides = {("paths", "depot"): str(args.depot.resolve())} if args.depot else {} + cfg = use(Config.load(overrides=overrides)) + task = contract.Task.from_json(contract.loads(args.task.read_bytes())) + doc = generate(task, open_depot(cfg)) fontsettings.write(doc, args.out) print( f"{args.out}: {len(doc['fonts'])} font assets, {len(doc['materials'])} materials, " diff --git a/src/nnnotes/content/advmedia.py b/src/nnnotes/content/advmedia.py deleted file mode 100644 index 07e0e2a..0000000 --- a/src/nnnotes/content/advmedia.py +++ /dev/null @@ -1,186 +0,0 @@ -"""ADV episode assets beyond the scene: frames, particle effects, post-effect profiles, stills, chat and talk windows. - -One JSON file per kind at the story root, written only when the episode's resource closure (adv.closure) has that -kind; textures go to the story's `textures/` (PNG, named like the scene's) and every Shader object of the loaded -bundle closures is handed back to advscene, which dumps them with the scene's shaders into `shaders/`: - - frames.json Frame: "Adv/Frame/" + TargetAssetName (UIAdvWidget.SetFrame / AdvFrame), keyed by - TargetAssetName; each a prefab node list (AdvFrame, CanvasGroup, uGUI, Animator with its - controller and clips, UIParticle / ParticleSystem). Frame texts (TargetTextIDs) are episode - text ids (episode.json `text`). - effects.json Effect: "Adv/Effect/" + TargetAssetName, instantiated once per TargetName - (AdvEpisodeResourceLoader.LoadParticleEffect): `effects` by TargetAssetName (AdvParticleEffect, - ParticleSystem modules and renderers, Animator), `instances` TargetName -> TargetAssetName - posteffects.json PostEffect: "Adv/PostEffect/" + TargetAssetName, a URP VolumeProfile (not instantiated; - AdvGlobalVolume.ToggleVolume adds it as a child volume), keyed by TargetAssetName - stills.json Still: "Adv/Still/" + TargetAssetName (AdvStill, CanvasGroup, uGUI, DOTweenSequence - animations), keyed by TargetAssetName - chat.json ChatWindow / ChatTalk / ChatStamp: `chats` (the MasterAdvChat rows of the episode's chat ids), - `windows` ("Adv/Chat/Prefabs/" + _chatWindowAssetName, AdvChatWindow prefabs), `icons` - ("Adv/Chat/Icon/" + _chatIconAssetName) and `stamps` ("Adv/Chat/Stamp/" + TargetAssetName) as - sprites, `text` / `sounds` / `cueSheets`: the rows of the shared chat shards - (Adv/Chat/AdvChat-Text, -Sound, -SoundCueSheet); the cue sheet those rows name, AdvSe_Common, - is in no language's catalog and not in the APK (1.0.1 data), so nothing is decoded for them - talkwindows.json TalkWindow: "UI/Prefab/Parts/Adv/Talk/" + TargetAssetName (embedded content), keyed by window - name (the default window is also in ui/ui.json in its drawn form) - -Prefabs are exported with export.Exporter (references resolved, AnimatorControllers followed, clips in the -generic format). TextMeshPro text components always keep their layout and style (size, alignment, colours, -margins, text). The font data follows `fonts`: "open" (default) exports none of it -- font / sprite / style sheet -assets become {asset, name} and TextMeshPro materials {material, shader}, each with a note, so a viewer draws the -text with a font of its own; "game" exports the font assets and their materials with the atlas textures like any -other asset. A MonoBehaviour whose script is missing (Unity runs nothing for it) is recorded as such. -""" - -from __future__ import annotations - -from pathlib import Path - -from ..core.jsonio import write_json -from ..source import master as master_mod -from ..source.catalog import Catalog -from ..unity import fontsettings -from ..unity.export import HEADER, Exporter -from ..unity.player import PlayerData -from . import adv -from .storychat import SHARDS as CHAT_SHARDS - -TMP_SHADER_PREFIX = "TextMeshPro/" -TMP_ASSETS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet") -FONT_CHOICES = ("open", "game") -FONT_NOTE = "font data not exported (fonts: open): draw the text with a font of your own" -# resource kind -> (story.json key = file key, file, address prefix removed from the entry keys; None: last segment) -FILES = { - "frame": ("frames", "frames.json", adv.RESOURCE_PREFIX["Frame"][1]), - "effect": ("effects", "effects.json", adv.RESOURCE_PREFIX["Effect"][1]), - "posteffect": ("postEffects", "posteffects.json", adv.RESOURCE_PREFIX["PostEffect"][1]), - "still": ("stills", "stills.json", adv.RESOURCE_PREFIX["Still"][1]), - "talkwindow": ("talkWindows", "talkwindows.json", None), -} -CHAT_KINDS = ("chatwindow", "chaticon", "chatstamp") -CHAT = ("chat", "chat.json") -KINDS = tuple(FILES) + CHAT_KINDS -INDEX_KEYS = tuple(k for k, _, _ in FILES.values()) + (CHAT[0],) - - -class MediaExporter(Exporter): - """export.Exporter for uGUI and particle prefabs: AnimatorControllers followed, missing-script MonoBehaviours - recorded; with fonts="open" the TextMeshPro font / sprite / style sheet assets and materials as name stubs.""" - - def __init__(self, cat: Catalog, out_dir: Path, player: PlayerData, fonts: str = "open"): - if fonts not in FONT_CHOICES: - raise ValueError(f"fonts={fonts!r}") - super().__init__( - cat, - out_dir, - player=player, - stub_assets=TMP_ASSETS if fonts == "open" else (), - follow=("AnimatorController",), - font_stubs=fontsettings.builtin() if fonts == "open" else None, - ) - self.fonts = fonts - - def scriptable(self, o) -> dict: - out = super().scriptable(o) - if self.fonts == "open" and out.get("asset") in TMP_ASSETS: - return {**out, "note": FONT_NOTE} - return out - - def material(self, o) -> dict: - if self.fonts == "game": - return super().material(o) - tt = o.read_typetree() - shader = self.deref(o, tt["m_Shader"]) - name = self.add_shader(shader) if shader is not None else None - if name is None or not name.startswith(TMP_SHADER_PREFIX): - return super().material(o) - return {"material": tt["m_Name"], "shader": {"shader": name}, "note": FONT_NOTE} - - def font_stub(self, o) -> dict: - """As material and scriptable: a TMP material or asset of a font bundle is only named, with FONT_NOTE.""" - out = super().font_stub(o) - if o.rec["type"] == "Material": - name = out["shader"]["shader"] - if not name.startswith(TMP_SHADER_PREFIX): - return out - return {"material": out["material"], "shader": {"shader": name}, "note": FONT_NOTE} - return {**out, "note": FONT_NOTE} if out["asset"] in TMP_ASSETS else out - - def component(self, o) -> dict: - if o.type.name == "MonoBehaviour": - tt = o.read_typetree() - if not tt["m_Script"]["m_PathID"]: - return { - "type": "MonoBehaviour", - "class": None, - "missingScript": True, - **{k: self.value(o, v) for k, v in tt.items() if k not in HEADER}, - } - return super().component(o) - - -def _suffix(address: str, prefix: str | None) -> str: - if prefix is None: - return address.rsplit("/", 1)[-1] - if not address.startswith(prefix): - raise ValueError(f"{address}: not under {prefix}") - return address[len(prefix) :] - - -def _chat(cat: Catalog, master: Path, ex: Exporter, episode: dict, resources: list[dict]) -> dict: - rows = {r["_id"]: r for r in master_mod.table(master, "MasterAdvChat")} - ids = sorted({c["TargetChatID"] for c in episode["commands"] if c.get("TargetChatID")}) - doc: dict = {"chats": {str(i): rows[i] for i in ids if i in rows}, "windows": {}, "icons": {}, "stamps": {}} - for r in resources: - if r["kind"] == "chatwindow": - doc["windows"][_suffix(r["address"], adv.CHAT_WINDOW_PREFIX)] = ex.export_key(r["address"]) - elif r["kind"] == "chaticon": - doc["icons"][_suffix(r["address"], adv.CHAT_ICON_PREFIX)] = ex.key_sprite(r["address"]) - elif r["kind"] == "chatstamp": - doc["stamps"][_suffix(r["address"], adv.RESOURCE_PREFIX["ChatStamp"][1])] = ex.key_sprite(r["address"]) - for name, key in CHAT_SHARDS.items(): - t = adv._text_of(cat, key) - doc[name] = adv.parse_shard(t)["rows"] if t else [] - return doc - - -def extract(cat: Catalog, player: PlayerData, master: Path, episode: dict, out_dir: Path, fonts: str = "open") -> dict: - """Write the kind files the episode needs (`fonts`: see the module docstring). Returns {"files": {index key: - file or None} for every INDEX_KEYS entry, "shaders": {name: Shader object}, "counts": {index key: entries}, - "textures": textures written}.""" - out_dir = Path(out_dir) - by_kind: dict[str, list[dict]] = {} - for r in episode["resources"]: - if r["kind"] in KINDS: - by_kind.setdefault(r["kind"], []).append(r) - files: dict[str, str | None] = dict.fromkeys(INDEX_KEYS) - shaders: dict = {} - counts: dict[str, int] = {} - textures = 0 - - def done(ex: Exporter, name: str, doc: dict) -> None: - nonlocal textures - ex.resolve_pending() - write_json(out_dir / name, doc) - for k, o in ex.shaders.items(): - shaders.setdefault(k, o) - textures += len(ex.textures) - - for kind, (key, name, prefix) in FILES.items(): - if kind not in by_kind: - continue - ex = MediaExporter(cat, out_dir, player, fonts) - entries = {_suffix(r["address"], prefix): ex.export_key(r["address"]) for r in by_kind[kind]} - doc: dict = {key: entries} - if kind == "effect": - doc["instances"] = adv.effect_instances(episode["commands"]) - done(ex, name, doc) - files[key] = name - counts[key] = len(entries) - chat = [r for k in CHAT_KINDS for r in by_kind.get(k, [])] - if chat: - ex = MediaExporter(cat, out_dir, player, fonts) - done(ex, CHAT[1], _chat(cat, master, ex, episode, chat)) - files[CHAT[0]] = CHAT[1] - counts[CHAT[0]] = len(chat) - return {"files": files, "shaders": shaders, "counts": counts, "textures": textures} diff --git a/src/nnnotes/content/advscene.py b/src/nnnotes/content/advscene.py deleted file mode 100644 index 26ac2a1..0000000 --- a/src/nnnotes/content/advscene.py +++ /dev/null @@ -1,108 +0,0 @@ -"""ADV presentation data -> scene.json + textures + shaders. - -Everything the game uses to present an ADV episode besides the episode script, -the Live2D models (with the materials of their mask pass: webmodel.py) and the -audio: - - player colour space, quality levels, URP pipelines, renderer data and - features, post-process data (APK boot data, see player.py) - cameraManager the app's Main/Sub camera prefab - advScene the ADV scene (AdvSceneRoot + scene camera) - characterField AdvCharacterField prefab (Field / Anchors / Stages) - backgroundField AdvBackgroundField prefab (backdrop mesh + sprite renderer) - globalVolume AdvGlobalVolume prefab (volumes + profiles) - settings AdvPlayerSettings (+ focus data), AdvMasterIdSettings - stages every stage prefab the episode uses (AdvStage, lights, - background sprite, volume profiles) - postTextures per camera renderer: post-process data textures used by the - URP chain (film grain textures, indexed by FilmGrain.type) - -Objects are exported with export.Exporter (references resolved, textures as -PNG). Shaders referenced anywhere, every Shader in the loaded bundle closures, -the ADV camera renderer's shaders and the shaders handed in by the caller (those -of advmedia's kind files) are dumped with shader.py into one `shaders/`. -""" - -from __future__ import annotations - -from pathlib import Path - -from ..core.jsonio import write_json -from ..formats import shader as shader_mod -from ..source.catalog import Catalog -from ..unity.export import Exporter -from ..unity.player import PlayerData - -ADV_KEYS = { - "cameraManager": "EmbCommon/Prefab/CameraManager", - "advScene": "EmbScene/Adv", - "characterField": "EmbCommon/Adv/Prefab/AdvCharacterField", - "backgroundField": "EmbCommon/Adv/Prefab/AdvBackgroundField", - "globalVolume": "EmbCommon/Adv/Prefab/AdvGlobalVolume", -} -SETTINGS_KEYS = { - "playerSettings": "EmbCommon/Adv/Settings/AdvPlayerSettings", - "masterIdSettings": "EmbCommon/Adv/Settings/AdvMasterIdSettings", -} -STAGE_PREFIX = "Adv/Stage/" - - -def camera_renderer(player_graphics: dict, pipeline: str, renderer_index: int) -> str: - rl = player_graphics["pipelines"][pipeline]["m_RendererDataList"] - i = player_graphics["pipelines"][pipeline]["m_DefaultRendererIndex"] if renderer_index < 0 else renderer_index - return rl[i] - - -def extract(cat: Catalog, player: PlayerData, episode: dict, out_dir: Path, shaders: dict | None = None) -> dict: - """Write scene.json, textures/ and shaders/. `shaders`: further {name: Shader object} to dump with the scene's - (the scene's object wins for a name both have).""" - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - ex = Exporter(cat, out_dir, player=player) - graphics = player.graphics() - doc: dict = {"player": graphics} - for name, key in ADV_KEYS.items(): - doc[name] = ex.prefab(key) - doc["settings"] = {name: ex.asset(key) for name, key in SETTINGS_KEYS.items()} - - stages = {} - for c in episode["commands"]: - if c["cmd"] == "Stage" and c.get("TargetAssetName"): - key = STAGE_PREFIX + c["TargetAssetName"] - stages.setdefault(c["TargetAssetName"], None) - if stages[c["TargetAssetName"]] is None: - stages[c["TargetAssetName"]] = ex.prefab(key) - doc["stages"] = stages - - # shaders: everything referenced above, the ADV camera renderer - renderers = set() - for node in doc["cameraManager"]["nodes"]: - for comp in node["components"]: - if comp.get("class") == "UniversalAdditionalCameraData": - renderers.add(camera_renderer(graphics, graphics["defaultPipeline"], comp["m_RendererIndex"])) - for rn in sorted(renderers): - for o in player.renderer_shaders(rn): - ex._shader(o) - for name, o in (shaders or {}).items(): - ex.shaders.setdefault(name, o) - # post-process textures the URP chain samples (film grain lookup by FilmGrain.type) - doc["postTextures"] = { - rn: {"filmGrainTex": [ex.texture(o) for o in player.post_textures(rn, "filmGrainTex")]} - for rn in sorted(renderers) - } - sdir = out_dir / "shaders" - index: list = [] - for name in sorted(ex.shaders): - shader_mod.dump_objects([ex.shaders[name]], sdir, ex.shaders[name].assets_file.name, index) - shader_summary = shader_mod.write_index(index, sdir) - doc["shaders"] = { - "index": "shaders/shaders.json", - "names": shader_summary["names"], - "cameraRenderers": sorted(renderers), - } - doc["convention"] = ( - "Unity space (left-handed, Y up); transforms are local TRS; " - "quaternions (x,y,z,w); colours in the project's Gamma space" - ) - write_json(out_dir / "scene.json", doc) - return {"stages": list(stages), "textures": len(ex.textures), **shader_summary} diff --git a/src/nnnotes/content/advui.py b/src/nnnotes/content/advui.py index 1a2f503..00beb55 100644 --- a/src/nnnotes/content/advui.py +++ b/src/nnnotes/content/advui.py @@ -1,1086 +1,35 @@ -"""ADV story UI -> /ui/ (ui.json, textures/, shaders/). - -Everything a player needs to draw the ADV front canvas (talk window, speaker -plate, location caption, episode title, rule transition cover, menu entry -button, menu panel and video buttons, backlog, choices, curtains, flash, -subtitles, next indicator, the tap areas), the screen canvases of stills, -frames and videos, the letterbox bands and the dialogs of the ADV screen, -taken from the game data: - - nodes RectTransform hierarchy of the drawn parts of UIAdvWidget - (DRAWN) with the talk windows under UIContainer/TalkView - (AdvTalkView.SetWindow): the default UIDefaultTalkWindow, then - the other windows the episode's TalkWindow rows attach, and the - front canvas' CenterTalkBackdrop when one of them uses the - backdrop filter (UIAdvTalkWindow._useBackdropFilter); per - node the uGUI components the runtime needs (Canvas and scaler, - Image, RawImage, CanvasGroup, UIGradientImage, layout groups, - DOTweenSequence durations, the views' serialized references as - node paths, `behaviours`: every serialized field of the other - game components of BEHAVIOURS, references as node paths and - sprites as names) and, for a TMP text, its text record `textStyle` - (textstyle.py: layout, colours, style flags, face / outline / - underlay values in em, font role) - videoAndStillCamera the camera the still and video canvases render with - (UIAdvWidget._videoAndStillCamera) and the screen image showing - its output (_videoAndStillScreenImage) - uiCamera the UI camera of the UIManager prefab, which renders the frame - canvas (its canvas camera is assigned at run time), in the form - of videoAndStillCamera - masterIdTexts the AdvMasterIdSettings texts of the skip and video skip - dialogs and the interruption dialog in the language (with the - master data) - dialogs the skip confirm dialog (UIAdvSkipConfirmDialogWidget) and - CommonDialogManager's dialog (UICommonDialogWidget) as node - records with text records - chatWidget the phone of the chat rows (UIAdvChatWidget): its canvas and - scaler, AdvChatView with its durations and eases, the window - parent rect - chatTexts per chat window of the episode (AdvChatWindow prefab), the text - record of each TMP text in the language, and chatStatusTexts: - the incoming call / lock screen status texts the windows set - blur with a backdrop-filter talk window: the UI blur pass settings - (the UIRendererFeature of renderer 0: Dual Kawase iterations, - offset, downsample, blend rate maximum, shader) - sprites sprite geometry (rect, border, pivot, pixels per unit, texture - rect/offset) remapped into packed textures - materials UI-Transition, the built-in Default UI Material, the materials - of the drawn images - clips Animator clips (NextIndicator, AutoNext, AdvLocation/AdvTitle Play) - transitions RuleTransitionSettings of every transition the episode or the - player settings reference - shaders UI/Default, UI/Transition, the blur shader with `blur` - -Language (`language`, a catalog language code; default `[catalog] language`): -the LanguageMode of the localized fonts, materials and line spacing -(LocalizeText) and the text field of the lines the episode shows. - -Fonts (`fonts`): "open" (default) writes no font data; a renderer draws the -text records with fonts of its own. "game" also writes the game's TMP font -data (tmpfont.py, optional `fonts` extra): per node the serialized TMP text -settings with the localized font / material (`text`), the TMP font assets -reached from the localized fonts (face info, style values, fallback chain) -with the character and glyph tables reduced to the characters the episode -shows (characters a dynamic font asset would add at runtime are generated the -way FontEngine renders them, see tmpfont.RuntimeGlyphs), their materials, the -glyph coverage, the TMP settings and the TextMeshPro/Mobile/Distance Field -shader. - -Textures: sprite atlases and SDF font atlases are large; only the texel blocks -a draw can sample are kept (export.Exporter deferred-texture mode with -export.TexelPacker). Each block is the sprite's texture rect or a glyph rect -grown by the widest footprint the shader samples, plus one texel for bilinear -filtering, read with the source wrap mode, and copied into a packed texture. -UVs are remapped by an integer translation, so sampling returns the same texel -values as the full atlas. -""" +"""TextMesh Pro sprite metadata, emoji sequence lookup and chat runtime text.""" from __future__ import annotations -import re -from collections.abc import Sequence -from pathlib import Path +from ..unity import textstyle -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.textstyle import LANGUAGE_LINE_SPACING -from . import adv, advscene - -WIDGET_KEY = "EmbUI/Prefab/UIAdvWidget" # UIManager: UIManager._cameraController -> UICameraController._uiCamera / _uiCameraData -UI_MANAGER_KEY = "EmbCommon/Prefab/UIManager" -WINDOW_KEY = "EmbUI/Prefab/Parts/Adv/Talk/UIDefaultTalkWindow" -WINDOW_PARENT = "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/TalkView" # UIAdvWidget._centerTalkBackdropCanvasGroup / _centerTalkBackdropImage: the backdrop of a backdrop-filter talk window -BACKDROP_REFS = ("_centerTalkBackdropCanvasGroup", "_centerTalkBackdropImage") -LETTERBOX_KEY = "Textures/letterbox-image" -TRANSITION_PREFIX = "Adv/Transition/" -PLAYER_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvPlayerSettings" -MASTER_ID_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvMasterIdSettings" -CLIP_KEYS = { - "NextIndicator/Loop": "EmbUI/Animations/NextIndicator/Loop", - "AutoNext/Loop": "EmbUI/Animations/Auto/Loop", - "AdvLocation/Play": "EmbUI/Animations/AdvLocation/Play", - "AdvTitle/Play": "EmbUI/Animations/AdvTitle/Play", -} -CONTROLLER_KEYS = { - "NextIndicator": "EmbUI/Animations/NextIndicator/NextIndicator", - "AutoNext": "EmbUI/Animations/Auto/AutoNext", - "AdvLocation": "EmbUI/Animations/AdvLocation/AdvLocation", - "AdvTitle": "EmbUI/Animations/AdvTitle/AdvTitle", -} # Parts of the widget the runtime draws (and their ancestors). The block canvas # is not exported. -DRAWN = ( - "UIAdvWidget/VideoCanvas", - "UIAdvWidget/StillCanvas", - "UIAdvWidget/VideoAndStillRenderScreenCanvas", - "UIAdvWidget/FrameCanvas", - "UIAdvWidget/FrontCanvas/FlashView", - "UIAdvWidget/FrontCanvas/RuleTransition", - "UIAdvWidget/FrontCanvas/LocationVIew", - "UIAdvWidget/FrontCanvas/RightCurtain", - "UIAdvWidget/FrontCanvas/LeftCurtain", - "UIAdvWidget/FrontCanvas/CancelFullScreenButton", - "UIAdvWidget/FrontCanvas/TalkLogView", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/NextButton", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/ChoiceView", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/SubtitlesView", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/TalkView", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/NextIndicator", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/MenuView/MenuButtonsParent", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/MenuView/MenuEntryButton", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/MenuView/VideoButtons", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/TitleView", - "UIAdvWidget/AdvLetterBoxCanvas", -) # the view components of the drawn parts: record key and the serialized references kept (as node paths) -VIEWS = { - "AdvVideoView": ("videoView", ("_video", "_maskArea", "_curtainCanvasGroup", "_videoCanvasGroup")), - "AdvStillView": ("stillView", ("_overlay", "_background", "_target")), - "AdvFrameView": ("frameView", ()), - "AdvFlashView": ("flashView", ("_flash",)), - "AdvSubtitlesView": ("subtitlesView", ("_subtitles", "_subtitlesText")), - "AdvFrontScreenView": ( - "frontScreenView", - ("_nextButton", "_nextIndicator", "_cancelFullScreenButton", "_uiContainer"), - ), -} # views recorded with every serialized field (references as node paths) -FULL_VIEWS = {"AdvTalkLogView": "talkLogView", "AdvChoiceView": "choiceView"} # the other game and uGUI components recorded in `behaviours` (per class, every component of the node in order): # buttons and their state and animation parts, raycast targets, colour synchronizers, texts' UIText, safe areas, # scrolling, DOTween animations, localized sprites, the backlog entry and choice item views, the dialog parts -BEHAVIOURS = ( - "UIButton", - "ButtonActiveState", - "ButtonAnimationScale", - "ButtonSound", - "SimpleRaycastTarget", - "GraphicColorSynchronizer", - "UIText", - "AspectRatioFitter", - "UIToggle", - "UIToggleGroup", - "UIToggleButtonFrame", - "UIDecoratedNormalButton", - "UIDecoratedNormalButtonFrame", - "UIAddressableImage", - "UISafeArea", - "AdvViewportSafeArea", - "EnhancedScrollRect", - "EnhancedScroller", - "ScrollRect", - "RectMask2D", - "Scrollbar", - "DOTweenAnimation", - "LocalizeSpriteEvent", - "AdvTalkLogEntryView", - "AdvChoiceItem", - "DialogButtons", - "DialogAnimation", - "DialogSizeFitter", - "UIAdvSkipConfirmDialogWidget", - "UICommonDialogWidget", -) -SERIALIZED_SKIP = ( - "type", - "class", - "m_GameObject", - "m_Script", - "m_Name", - "m_EditorHideFlags", - "m_EditorClassIdentifier", -) # the dialogs of the ADV screen: the skip confirm dialog and CommonDialogManager's dialog widget -DIALOG_KEYS = { - "UIAdvSkipConfirmDialogWidget": "EmbUI/Prefab/UIAdvSkipConfirmDialogWidget", - "UICommonDialogWidget": "EmbUI/Prefab/UICommonDialogWidget", -} -CAMERA_FIELDS = ( - "m_Enabled", - "m_ClearFlags", - "m_BackGroundColor", - "m_NormalizedViewPortRect", - "near clip plane", - "far clip plane", - "field of view", - "orthographic", - "orthographic size", - "m_Depth", - "m_CullingMask", - "m_HDR", - "m_AllowMSAA", -) -CAMERA_DATA_FIELDS = ( - "m_RenderPostProcessing", - "m_VolumeLayerMask", - "m_RendererIndex", - "m_Antialiasing", - "m_AntialiasingQuality", - "m_RenderShadows", - "m_RequiresDepthTextureOption", - "m_RequiresOpaqueTextureOption", - "m_CameraType", - "m_VolumeFrameworkUpdateModeOption", - "m_Dithering", - "m_StopNaN", -) # AdvMasterIdSettings text ids the drawn parts show: the video skip dialog (AdvMenuView's skip video button), the # skip confirm dialog and the interruption dialog -MASTER_ID_TEXTS = ( - "_skipVideoMessageTextId", - "_skipButtonTextId", - "_cancelButtonTextId", - "_skipMessageTextId", - "_continuousSkipMessageTextId", - "_backEpisodeListButtonTextId", - "_continueEpisodeButtonTextId", - "_interruptionTitleTextId", - "_interruptionMessageTextId", - "_interruptionButtonTextId", -) -RUBY = re.compile(r"]*)>") # ruby reading markup of the text tables: base # the phone of the chat rows: UIAdvChatWidget's canvas, AdvChatView and the parent of the attached window -CHAT_WIDGET_KEY = "EmbUI/Prefab/UIAdvChatWidget" -CHAT_WIDGET_NODES = ( - "UIAdvChatWidget/ChatCanvas", - "UIAdvChatWidget/ChatCanvas/AdvChatView", - "UIAdvChatWidget/ChatCanvas/AdvChatView/Target", -) -CHAT_VIEW_FIELDS = ( - "_showEaseDuration", - "_hideEaseDuration", - "_showEase", - "_hideEase", - "_scrollDuration", - "_typingDelay", - "_typingTextBoxMinHeight", - "_screenModeTransitionDuration", - "_screenModeTransitionEase", - "_incomingCallPositionOffset", -) -CHAT_STATUS_KEYS = ("_incomingCallStatusTextKey", "_lockScreenStatusTextKey") # AdvChatWindow.RefreshStatusTexts CHAT_COMMANDS = ("ChatWindow", "ChatTalk", "ChatStamp", "ChatRead", "ChatTyping") # AdvMenuView: its serialized references (node paths), the fast-forward button sprites (AdvMenuView.SetFastForwardSpeed) -MENU_VIEW_REFS = ( - "_menuButtonsParent", - "_menuEntryButton", - "_skipButton", - "_autoButton", - "_fastForwardButton", - "_logButton", - "_continuousButton", - "_fullScreenButton", - "_interruptionButton", - "_menuButtonsBackground", - "_canvasGroup", - "_skipVideoButton", - "_pauseVideoButton", - "_subtitlesButton", - "_videoButtonParent", - "_videoFilterButton", - "_fastForwardButtonImage", -) -MENU_VIEW_SPRITES = ( - "_fastForwardNormalSprite", - "_fastForwardOnePointFiveSprite", - "_fastForwardOnePointSevenSprite", - "_fastForwardDoubleSprite", -) EMOJI_SPRITE_ASSET_KEY = "Font/SiriusEmoji/SiriusEmojiData" # LocalizeManager.EmojiSpriteAssetAddress -EMOJI_CLASSES = ("RubyEmojiTextMeshProUGUI",) # TMP_Text.GetUnderlineSpecialCharacter: the underline and the text highlight (DrawTextHighlight) draw with the '_' # of the text's font asset (no fallback), sampled on the asset's first atlas page UNDERLINE_CHARACTER = 0x5F TMP_CLASSES = ("TextMeshProUGUI", "RubyTextMeshProUGUI", "RubyEmojiTextMeshProUGUI") -TMP_FIELDS = ( - "m_fontSize", - "m_fontSizeBase", - "m_fontStyle", - "m_fontWeight", - "m_HorizontalAlignment", - "m_VerticalAlignment", - "m_characterSpacing", - "m_wordSpacing", - "m_lineSpacing", - "m_paragraphSpacing", - "m_characterHorizontalScale", - "m_TextWrappingMode", - "m_overflowMode", - "m_enableAutoSizing", - "m_fontSizeMin", - "m_fontSizeMax", - "m_enableKerning", - "m_ActiveFontFeatures", - "m_enableExtraPadding", - "m_isRichText", - "m_parseCtrlCharacters", - "m_isOrthographic", - "m_overrideHtmlColors", - "m_margin", - "m_fontColor", - "m_Color", - "m_colorMode", - "m_enableVertexGradient", - "m_TextStyleHashCode", - "m_useMaxVisibleDescender", - "m_horizontalMapping", - "m_verticalMapping", - "m_charWidthMaxAdj", - "m_lineSpacingMax", - "m_isRightToLeft", - "m_text", - "m_monospaceDistEm", # TMP_EmojiTextUGUI (the emoji text classes) -) -BLUR_FEATURE = "Fwk.UI.Rendering.UIRendererFeature" -FONT_MODES = ("open", "game") -IMAGE_FIELDS = ( - "m_Enabled", - "m_Color", - "m_Type", - "m_PreserveAspect", - "m_FillCenter", - "m_FillMethod", - "m_FillAmount", - "m_UseSpriteMesh", - "m_PixelsPerUnitMultiplier", - "m_Maskable", -) - - -def _strip_tags(s: str) -> str: - return re.sub(r"<[^<>]*>", "", s) - - -def _ref_path(v) -> str | None: - """The node path of a serialized reference as the exporter writes it ({component | transform | gameObject}).""" - if not v: - return None - return v.get("gameObject") or v.get("transform") - - -def _need_path(v) -> str: - """The node path of a serialized reference that must not be null (RuntimeError for a null one).""" - p = _ref_path(v) - if p is None: - raise RuntimeError(f"a required node reference is null ({v!r})") - return p - - -def _plain(v, sprites: list): - """A serialized value as plain data: a reference as its node path, a sprite as its name (its reference added to - `sprites`), a ScriptableObject or material as its name, a texture as its name; containers element-wise.""" - if isinstance(v, list): - return [_plain(x, sprites) for x in v] - if not isinstance(v, dict): - return v - if "spriteRef" in v: - sprites.append(v["spriteRef"]) - return v["name"] - if "gameObject" in v or "transform" in v: - return _ref_path(v) - if "asset" in v or ("material" in v and "shader" in v): - return v.get("name") or v.get("material") - if "textureRef" in v: - return v.get("name") - return {k: _plain(x, sprites) for k, x in v.items()} - - -def _serialized(c: dict, sprites: list) -> dict: - """Every serialized field of a component (SERIALIZED_SKIP left out), as _plain values.""" - return {k: _plain(v, sprites) for k, v in c.items() if k not in SERIALIZED_SKIP} - - -def _pictogram(c: dict) -> dict: - """App.UI.UIPictogram: its key, image and native-size flag and the catalog it draws from (the sprite is - resolved against the catalog in extract).""" - return { - "m_Enabled": c["m_Enabled"], - "_key": c["_key"], - "_image": _ref_path(c["_image"]), - "_setNativeSize": c["_setNativeSize"], - "catalog": (c["_spriteCatalog"] or {}).get("name"), - } - - -def _layout_components(node: dict) -> dict: - out: dict = {} - sprites: list = [] - behaviours: dict = {} - image_states = [c for c in node["components"] if c.get("class") == "ButtonImageState"] - for c in node["components"]: - cls = c.get("class") - t = c["type"] - if t == "CanvasGroup": - out["canvasGroup"] = {k: c[k] for k in ("m_Enabled", "m_Alpha", "m_IgnoreParentGroups")} - elif t == "Canvas": - out["canvas"] = { - k: c[k] - for k in ( - "m_Enabled", - "m_RenderMode", - "m_SortingOrder", - "m_VertexColorAlwaysGammaSpace", - "m_AdditionalShaderChannelsFlag", - "m_OverrideSorting", - "m_PixelPerfect", - ) - } - if c["m_RenderMode"] == 1: # screen space - camera: the plane it is drawn at, its camera if set - out["canvas"]["m_PlaneDistance"] = c["m_PlaneDistance"] - if c.get("m_Camera"): - out["canvas"]["camera"] = _ref_path(c["m_Camera"]) - elif t == "Animator": - out["animator"] = { - "controller": (c["m_Controller"] or {}).get("name"), - "enabled": c["m_Enabled"], - "updateMode": c["m_UpdateMode"], - "keepStateOnDisable": c["m_KeepAnimatorStateOnDisable"], - } - elif cls in ("CanvasScaler", "ClampedCanvasScaler"): - out["canvasScaler"] = { - k: c[k] - for k in ( - "m_Enabled", - "m_UiScaleMode", - "m_ReferencePixelsPerUnit", - "m_ScaleFactor", - "m_ReferenceResolution", - "m_ScreenMatchMode", - "m_MatchWidthOrHeight", - ) - } - if cls == "ClampedCanvasScaler": # Fwk.UI.ClampedCanvasScaler: the scale clamped past an aspect ratio - out["canvasScaler"].update({"class": cls, "_maxAspectThreshold": c["_maxAspectThreshold"]}) - elif cls == "Image": - img = {k: c[k] for k in IMAGE_FIELDS} - img["sprite"] = c["m_Sprite"]["name"] if c["m_Sprite"] else None - img["spriteRef"] = c["m_Sprite"]["spriteRef"] if c["m_Sprite"] else None - img["material"] = c["m_Material"]["material"] if c["m_Material"] else None - out["image"] = img - elif cls == "RawImage": - out["rawImage"] = {k: c[k] for k in ("m_Enabled", "m_Color", "m_UVRect", "m_RaycastTarget", "m_Maskable")} - out["rawImage"]["texture"] = (c["m_Texture"] or {}).get("name") - out["rawImage"]["material"] = c["m_Material"]["material"] if c["m_Material"] else None - elif cls == "UIGradientImage": - out["gradient"] = { - "enabled": c["m_Enabled"], - "gradient": c["_gradient"], - "angleDeg": c["_angleDeg"], - "splitAtKeysWhenAxisAligned": c["_splitAtKeysWhenAxisAligned"], - "blendMode": c["_blendMode"], - } - elif cls in TMP_CLASSES: - txt = {k: c[k] for k in TMP_FIELDS if k in c} - txt["class"] = cls - txt["enabled"] = c["m_Enabled"] - txt["fontAsset"] = c["m_fontAsset"]["name"] - txt["material"] = c["m_sharedMaterial"]["material"] - out["text"] = txt - elif cls == "LocalizeText": - out["localizeText"] = {"enabled": c["m_Enabled"], "localizeEnabled": c["_localizeEnabled"]} - elif cls in ("HorizontalLayoutGroup", "VerticalLayoutGroup"): - out["layoutGroup"] = {"class": cls, **{k: c[k] for k in c if k.startswith("m_")}} - elif cls == "ContentSizeFitter": - out["contentSizeFitter"] = {k: c[k] for k in ("m_Enabled", "m_HorizontalFit", "m_VerticalFit")} - elif cls == "LayoutElement": - out["layoutElement"] = {k: c[k] for k in c if k.startswith("m_")} - elif cls == "DOTweenSequence": - out["tweenSequence"] = { - "list": [{k: e[k] for k in ("_commandType", "_duration") if k in e} for e in c["_list"]], - "raw": {k: c[k] for k in c if k in ("updateType", "isSpeedBased")}, - } - elif cls == "SimpleAnimationTrigger": - out["animationTrigger"] = {"blendTime": c["_blendTime"]} - elif cls == "UIAdvTalkWindow": - out["talkWindow"] = { - k: c[k] - for k in ( - "_typingDelay", - "_safeAreaTalkBackgroundExpansionFactor", - "_useBackdropFilter", - "_backdropFilterColor", - "_talkTextColor", - "_talkTextOutlineColor", - ) - } - elif cls == "ButtonImageState" and len(image_states) > 1: - behaviours.setdefault(cls, []).append(_serialized(c, sprites)) - elif cls == "ButtonImageState": - out["buttonImageState"] = {"normal": c["_normaledSprite"]["name"] if c["_normaledSprite"] else None} - if c["_selectedSprite"]: # ButtonImageState.SetSelected: the sprite of the selected state - out["buttonImageState"].update( - { - "selected": c["_selectedSprite"]["name"], - "selectedRef": c["_selectedSprite"]["spriteRef"], - "target": _ref_path(c["_target"]), - } - ) - elif cls == "UISafeAreaEdgeAnchor": - out["safeAreaEdgeAnchor"] = { - "enabled": c["m_Enabled"], - **{k: c[k] for k in ("_left", "_right", "_top", "_bottom")}, - } - elif cls in VIEWS: - key, refs = VIEWS[cls] - out[key] = {"enabled": c["m_Enabled"], **{k: _ref_path(c[k]) for k in refs}} - elif cls == "AdvMenuView": - out["menuView"] = { - "enabled": c["m_Enabled"], - **{k: _ref_path(c[k]) for k in MENU_VIEW_REFS}, - **{k: c[k]["name"] if c[k] else None for k in MENU_VIEW_SPRITES}, - "_fadeDuration": c["_fadeDuration"], - } - sprites += [c[k]["spriteRef"] for k in MENU_VIEW_SPRITES if c[k]] - elif cls in FULL_VIEWS: - out[FULL_VIEWS[cls]] = {"enabled": c["m_Enabled"], **_serialized(c, sprites)} - elif cls == "UIPictogram": - behaviours.setdefault(cls, []).append(_pictogram(c)) - elif cls in BEHAVIOURS: - behaviours.setdefault(cls, []).append(_serialized(c, sprites)) - elif cls == "AdvChatView": - out["chatView"] = { - "enabled": c["m_Enabled"], - **{k: c[k] for k in CHAT_VIEW_FIELDS}, - "_windowParentRect": _ref_path(c["_windowParentRect"]), - } - elif cls == "Outline": - out["outline"] = {"enabled": c["m_Enabled"]} - if behaviours: - out["behaviours"] = behaviours - if sprites: - out["_spriteRefs"] = sprites # packed into the UI textures, removed in extract - return out # -------------------------------------------------------------------------- # main entry # -------------------------------------------------------------------------- -def _node_record(n: dict) -> dict: - """A node of ui.json: transform, rect and the uGUI component records (_layout_components).""" - rec = { - "path": n["path"], - "name": n["name"], - "active": n["active"], - "localPosition": n["localPosition"], - "localRotation": n["localRotation"], - "localScale": n["localScale"], - "rect": n.get("rect"), - } - rec.update(_layout_components(n)) - return rec - - -def _component(node: dict, classes: tuple) -> dict | None: - hit = [c for c in node["components"] if c.get("class") in classes] - if len(hit) > 1: - raise RuntimeError(f"{node['path']}: {len(hit)} components of {classes}") - return hit[0] if hit else None - - -def _need_component(node: dict, classes: tuple) -> dict: - """The one component of `classes` on `node` (RuntimeError when it has none).""" - c = _component(node, classes) - if c is None: - raise RuntimeError(f"{node['path']}: no component of {classes}") - return c - - -def extract( - cat: Catalog, - player: PlayerData, - episode: dict, - out_dir: Path, - fonts: str = "open", - *, - language: str, - master: Path | None = None, -) -> dict: - """Write /ui/. `fonts`: "open" (text records only) or "game" (also the game's TMP font data); - `language`: the client language (a languages.LANGUAGES code); - `master`: the decoded master data (MasterText of `masterIdTexts`; None: no `masterIdTexts`).""" - if fonts not in FONT_MODES: - raise ValueError(f"fonts must be one of {FONT_MODES}, not {fonts!r}") - language = languages.check(language) - mode, field = languages.mode(language), languages.column(language)[1:] - game = fonts == "game" - if game: - tmpfont.require_extra() - ui_dir = Path(out_dir) / "ui" - ui_dir.mkdir(parents=True, exist_ok=True) - ex = Exporter( - cat, - ui_dir, - player=player, - textures="deferred", - stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"), - font_stubs=None if game else fontsettings.builtin(), - ) - packer = TexelPacker(ui_dir) - - # -- prefabs ------------------------------------------------------------- - widget = ex.prefab(WIDGET_KEY)["nodes"] - windows = talk_windows(episode) - window = window_nodes(ex, windows) - paths = {n["path"] for n in widget} - if WINDOW_PARENT not in paths: - raise RuntimeError(f"{WINDOW_PARENT} not in {WIDGET_KEY}") - widget_comp = next(c for c in widget[0]["components"] if c.get("class") == "UIAdvWidget") - drawn = DRAWN + ((backdrop_path(widget, widget_comp),) if uses_backdrop_filter(window) else ()) - - def kept(p: str) -> bool: - return any(p == d or p.startswith(d + "/") or d.startswith(p + "/") for d in drawn) - - nodes = [] - for n in widget: - if n["path"] == "UIAdvWidget" or not kept(n["path"]): - continue - nodes.append(n) - if n["path"] == WINDOW_PARENT: - nodes += window - for d in drawn: - if d not in {n["path"] for n in nodes}: - raise RuntimeError(f"{d} missing from the prefabs") - fonts_of = tmpfont.AssetFontSettings(ex) if game else fontsettings.builtin() - styles = textstyle.TextStyles(ex, player, mode, fonts_of) - dialog_nodes = {name: ex.prefab(key)["nodes"] for name, key in DIALOG_KEYS.items()} - catalogs = pictogram_catalogs(widget + window + [n for dn in dialog_nodes.values() for n in dn]) - - def records(prefab_nodes: list, keep_text: bool) -> list: - out = [] - for n in prefab_nodes: - rec = _node_record(n) - if "text" in rec: - if not (rec.get("localizeText") or {}).get("localizeEnabled"): - raise NotImplementedError(f"{n['path']}: text without LocalizeText") - rec["textStyle"] = styles.record( - n["path"], _need_component(n, TMP_CLASSES), _component(n, ("LocalizeText",)) - ) - if not keep_text: - del rec["text"] - resolve_pictograms(rec, catalogs) - out.append(rec) - return out - - out_nodes = records(nodes, game) - dialogs = {name: {"key": DIALOG_KEYS[name], "nodes": records(dn, False)} for name, dn in dialog_nodes.items()} - front = [n for n in widget if n["path"].startswith("UIAdvWidget/FrontCanvas/") and n["path"].count("/") == 2] - front_order = [n["name"] for n in front] - widget_rec = widget_canvases(widget, widget_comp) - camera = _camera(widget, _ref_path(widget_comp["_videoAndStillCamera"])) - screen_image = _ref_path(widget_comp["_videoAndStillScreenImage"]) - if screen_image not in {n["path"] for n in out_nodes}: - raise RuntimeError(f"{screen_image} (_videoAndStillScreenImage) is not a drawn node") - master_ids = ex.asset(MASTER_ID_SETTINGS_KEY) # exported once: a second export is a reference - id_texts = master_id_texts(master_ids, master, field) if master is not None else None - chat_nodes = {n["path"]: n for n in ex.prefab(CHAT_WIDGET_KEY)["nodes"]} - missing = [p for p in CHAT_WIDGET_NODES if p not in chat_nodes] - if missing: - raise RuntimeError(f"{missing} missing from {CHAT_WIDGET_KEY}") - chat_widget = {"nodes": [_node_record(chat_nodes[p]) for p in CHAT_WIDGET_NODES]} - chat, status_ids = chat_texts(ex, styles.fonts, styles.lang, mode, episode) - status_texts = master_texts(master, status_ids, field) if master is not None and status_ids else None - - materials: dict = {} - if game: - materials, font_doc = _game_fonts( - ex, - styles, - episode, - out_nodes, - field, - master_ids, - [t["text"] for t in (id_texts or {}).values()] + list((status_texts or {}).values()), - ) - rule = next(n for n in out_nodes if n["path"].endswith("FrontCanvas/RuleTransition")) - rt_comp = [ - c - for c in next(w for w in widget if w["path"] == rule["path"])["components"] - if c.get("class") == "UIAdvRuleTransitionView" - ][0] - materials[rt_comp["_material"]["material"]] = rt_comp["_material"] - rule["ruleTransition"] = {"material": rt_comp["_material"]["material"]} - # Graphic.defaultGraphicMaterial (Canvas.GetDefaultCanvasMaterial): the player - # ships the built-in UI/Default shader (unity_builtin_extra) but no serialized - # "Default UI Material", so its values are the shader's property defaults. - ui_default = player.shader("UI/Default", file="unity_builtin_extra") - materials["Default UI Material"] = { - "material": "Default UI Material", - "shader": {"shader": "UI/Default"}, - "keywords": [], - "textures": {}, - "ints": {}, - "floats": {}, - "colors": {}, - "shaderDefaults": True, - } - # the materials of the drawn images (the video mask and the curtain it masks) - for n in nodes + [n for dn in dialog_nodes.values() for n in dn]: - for c in n["components"]: - if c.get("class") in ("Image", "RawImage") and c["m_Material"]: - materials.setdefault(c["m_Material"]["material"], c["m_Material"]) - - # -- glyph blocks ------------------------------------------------------------- - if game: - font_out = tmpfont.export_fonts( - font_doc["fonts"], - font_doc["primaries"], - font_doc["needed"], - font_doc["runtime"], - font_doc["textMaterials"], - packer, - "characters shown by this episode (plus baked control characters); runtime " - "characters generated from the source font (runtimeGlyphs)", - ) - for name, mg in font_doc["missingGlyph"].items(): - font_out[name]["missingGlyph"] = mg - - # -- sprites ------------------------------------------------------------------- - sprite_refs = set() - all_records = out_nodes + [n for d in dialogs.values() for n in d["nodes"]] - for n in all_records: - if n.get("image") and n["image"]["spriteRef"]: - sprite_refs.add(n["image"]["spriteRef"]) - if (n.get("buttonImageState") or {}).get("selectedRef"): - sprite_refs.add(n["buttonImageState"]["selectedRef"]) - sprite_refs.update(n.get("_spriteRefs", ())) - for p in (n.get("behaviours") or {}).get("UIPictogram", ()): - sprite_refs.update(r for r in (p.pop("_spriteRef", None), p.pop("_defaultRef", None)) if r) - lb_sprite = ex.key_sprite(LETTERBOX_KEY) - sprite_refs.add(lb_sprite["spriteRef"]) - sprites_out = {} - for ref in sorted(sprite_refs): - s = ex.sprite_recs[ref] - tex_name = s["tex"].read_typetree()["m_Name"] - ex.pack_sprite(ref, packer, "sprites" if "FixUiSpriteAtlas" in tex_name else f"sprite_{s['name']}") - # transitions - ps = ex.asset(PLAYER_SETTINGS_KEY) - addresses = {ps["_defaultTransitionAssetAddress"]} - for c in list(ps["_initializeEpisodes"]) + list(ps["_finalizeEpisodes"]) + episode["commands"]: - cmd = c.get("Command") if "Command" in c else c.get("raw") - if cmd in (5, 6) and c.get("TargetAssetName"): - addresses.add(c["TargetAssetName"]) - transitions = {} - for addr in sorted(addresses): - a = ex.asset(TRANSITION_PREFIX + addr) - tex = a["_texture"] - if tex is None: - raise RuntimeError(f"transition {addr}: no rule texture") - if tex["mipCount"] > 1: - raise NotImplementedError(f"transition {addr}: mipmapped rule texture") - o = ex.tex_objs[tex["textureRef"]] - a["_block"] = packer.add(f"rule_{_safe(addr)}", o, 0, 0, tex["width"], tex["height"]) - transitions[addr] = a - - textures = packer.build() - - # resolve blocks - if game: - tmpfont.resolve_font_blocks(font_out) - for ref in sorted(sprite_refs): - sprites_out[ex.sprite_recs[ref]["name"]] = ex.packed_sprite(ref) - for n in all_records: - if n.get("image"): - n["image"].pop("spriteRef", None) - if n.get("buttonImageState"): - n["buttonImageState"].pop("selectedRef", None) - n.pop("_spriteRefs", None) - for addr, a in transitions.items(): - b = a.pop("_block") - t = textures[b["texture"]] - # rule textures are sampled with screen-space UVs over [0, 1]: they stay whole - if b["offset"] != (0, 0) or (t["width"], t["height"]) != (a["_texture"]["width"], a["_texture"]["height"]): - raise RuntimeError(f"transition {addr}: rule texture not copied whole") - a["_texture"] = {"texture": b["texture"], "name": a["_texture"]["name"]} - for m in materials.values(): - for _k, v in m["textures"].items(): - if v["texture"] is not None: - v["texture"] = { - "name": v["texture"]["name"], - "width": v["texture"]["width"], - "height": v["texture"]["height"], - "format": v["texture"]["format"], - } - - # -- clips ---------------------------------------------------------------------- - clips = {} - for name, key in CLIP_KEYS.items(): - o = ex.key_object(key) - if o.type.name != "AnimationClip": - raise RuntimeError(f"{key}: container asset is {o.type.name}") - clips[name] = ex.clip_curves(o) - controllers = {name: ex.controller_states(ex.key_object(key)) for name, key in CONTROLLER_KEYS.items()} - - # -- DOTween defaults (Resources/DOTweenSettings) ------------------------------------ - dts = player.mono(player.resource("DOTweenSettings")) - - # -- shaders ---------------------------------------------------------------------- - needed = {} - for name in sorted({m["shader"]["shader"] for m in materials.values()}): - if name == "UI/Default": - needed[name] = ui_default - elif name in ex.shaders: - needed[name] = ex.shaders[name] - else: - raise RuntimeError(f"shader {name} not in the loaded bundles") - blur = None - if uses_backdrop_filter(window): - blur, blur_shader = blur_settings(player) - needed[blur["shader"]] = blur_shader - index, shader_summary = ex.dump_shaders(ui_dir / "shaders", needed) - if blur is not None: - gles = [ - v - for r in index - if r["name"] == blur["shader"] - for v in r["variants"] - if v["platform"] == "gles3" and v["type"] == "GLES3" - ] - if not any(v["keywords"] == [] and v["stage"] == "vertex" for v in gles): - raise RuntimeError(f"{blur['shader']}: no GLES3 variant without keywords (the blur pass)") - variants_needed = {} - for m in materials.values(): - sh = m["shader"]["shader"] - rec = next(r for r in index if r["name"] == sh) - known = {k for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3" for k in v["keywords"]} - want = sorted(k for k in m["keywords"] if k in known) - ok = any( - v["platform"] == "gles3" - and v["type"] == "GLES3" - and sorted(v["keywords"]) == want - and v["stage"] == "vertex" - for v in rec["variants"] - ) - if not ok: - raise RuntimeError(f"{sh}: no GLES3 variant for material {m['material']} keywords {want}") - variants_needed[m["material"]] = want - # read last: loading the UIManager bundles registers their shaders - ui_cam = ui_camera(ex) - - doc = { - "about": about(language, windows), - "language": language_doc(language, styles if game else None), - "frontCanvasOrder": front_order, - "widget": widget_rec, - "nodes": out_nodes, - "videoAndStillCamera": camera, - "uiCamera": ui_cam, - "videoAndStillScreenImage": screen_image, - "chatWidget": chat_widget, - "sprites": sprites_out, - "letterBoxSprite": lb_sprite["name"], - "textures": textures, - "materials": materials, - "materialKeywords": variants_needed, - } - if blur is not None: - doc["blur"] = blur - if game: - tmp = player.resource("TMP Settings") - tmp_settings = {k: player.names_in(tmp, v) for k, v in player.mono(tmp).items()} - doc["fonts"] = font_out - doc["tmpSettings"] = { - k: tmp_settings[k] - for k in ( - "m_fallbackFontAssets", - "m_defaultFontAsset", - "m_missingGlyphCharacter", - "m_matchMaterialPreset", - "m_enableExtraPadding", - "m_warningsDisabled", - ) - } - doc.update( - { - "clips": clips, - "controllers": controllers, - "transitions": transitions, - "playerSettings": { - k: ps[k] - for k in ( - "_defaultTransitionAssetAddress", - "_waitAfterVoiceTime", - "_waitTalkTextUnitTime", - "_minTalkDisplayTime", - "_isAdvViewportFollowOnResolutionChanged", - ) - }, - "dotween": { - k: dts[k] - for k in ( - "defaultEaseType", - "defaultUpdateType", - "defaultTimeScaleIndependent", - "timeScale", - "defaultEaseOvershootOrAmplitude", - "defaultEasePeriod", - ) - }, - "shaders": {"index": "shaders/shaders.json", "names": shader_summary["names"]}, - } - ) - if game: - doc["glyphCoverage"] = font_doc["coverage"] - if id_texts is not None: - doc["masterIdTexts"] = id_texts - doc["dialogs"] = dialogs - if chat: - doc["chatTexts"] = chat - if status_texts: - doc["chatStatusTexts"] = status_texts - doc["textStyle"] = styles.summary() - write_json(ui_dir / "ui.json", doc) - out = {"textures": {k: (v["width"], v["height"]) for k, v in textures.items()}, "shaders": shader_summary["names"]} - if not game: - return {**out, "fonts": "open", "texts": sum(1 for n in out_nodes if "textStyle" in n)} - return { - **out, - "coverage": font_doc["coverage"]["counts"], - "fonts": sorted(font_out), - "runtimeGlyphs": { - k: { - "generated": len(v["runtimeGlyphs"]["generated"]), - "validation": { - kk: v["runtimeGlyphs"]["validation"][kk] - for kk in ("glyphs", "maxAbsError", "meanAbsError", "exactGlyphs") - }, - } - for k, v in font_out.items() - if v["runtimeGlyphs"] - }, - } - - -def talk_windows(episode: dict) -> list[str]: - """The talk windows the story attaches, in the order of their nodes under TalkView: UIDefaultTalkWindow (AdvPlayer - PlayInitialCommands attaches it), then by name the other talk windows of the episode's resources (the TalkWindow - rows, adv.closure kind "talkwindow").""" - names = {r["address"][r["address"].rindex("/") + 1 :] for r in episode["resources"] if r["kind"] == "talkwindow"} - return [adv.DEFAULT_TALK_WINDOW] + sorted(names - {adv.DEFAULT_TALK_WINDOW}) - - -def window_key(cat, name: str) -> str: - """The prefab key of the talk window `name`: WINDOW_KEY for the default window, else the TalkWindow address - ("UI/Prefab/Parts/Adv/Talk/" + name) resolved as adv.closure resolves it (embedded content: the "Emb" key when - the catalog lacks the plain one).""" - if name == adv.DEFAULT_TALK_WINDOW: - return WINDOW_KEY - address = adv.TALK_WINDOW_PREFIX + name - return address if cat.has(address) else adv.EMBEDDED_PREFIX + address - - -def window_nodes(ex: Exporter, names: list[str]) -> list[dict]: - """The prefab nodes of the talk windows `names`, in that order, with their paths under TalkView - (AdvTalkView.SetWindow parents the attached window there at localPosition 0).""" - out = [] - for name in names: - nodes = ex.prefab(window_key(ex.cat, name))["nodes"] - if not nodes or nodes[0]["path"] != name: - raise RuntimeError(f"talk window {name}: the prefab root is not {name}") - out += [{**w, "path": f"{WINDOW_PARENT}/{w['path']}"} for w in nodes] - return out - - -def uses_backdrop_filter(window: list[dict]) -> bool: - """Whether a talk window of the nodes (window_nodes) has UIAdvTalkWindow._useBackdropFilter set.""" - return any(c.get("class") == "UIAdvTalkWindow" and c["_useBackdropFilter"] for n in window for c in n["components"]) - - -def backdrop_path(widget: list[dict], comp: dict) -> str: - """The node of the centre-talk backdrop (UIAdvWidget BACKDROP_REFS: its CanvasGroup and Image, on one node).""" - paths = {_ref_path(comp[k]) for k in BACKDROP_REFS} - path = next(iter(paths)) if len(paths) == 1 else None - if path is None: - raise RuntimeError(f"UIAdvWidget {BACKDROP_REFS}: {sorted(map(str, paths))}") - node = next((n for n in widget if n["path"] == path), None) - if node is None: - raise RuntimeError(f"{path}: not a widget node") - if not any(c["type"] == "CanvasGroup" for c in node["components"]) or _component(node, ("Image",)) is None: - raise RuntimeError(f"{path}: no CanvasGroup or Image") - return path - - -def blur_record(graphics: dict) -> dict: - """blur: the UIRendererFeature of renderer 0 (UIBlurController.ExecBlur -> UIRenderPass CaptureAndBlur): its - Dual Kawase iterations, offset, downsample, blend rate maximum and shader name, read from the player data - (renderer 0 of every pipeline must be the same renderer).""" - first = {p["m_RendererDataList"][0] for p in graphics["pipelines"].values()} - if len(first) != 1: - raise NotImplementedError(f"renderer 0 differs between the pipelines: {sorted(first)}") - name = advscene.camera_renderer(graphics, graphics["defaultPipeline"], 0) - feats = [f for f in graphics["renderers"][name]["m_RendererFeatures"] if f["class"] == BLUR_FEATURE] - if len(feats) != 1: - raise RuntimeError(f"renderer {name}: {len(feats)} {BLUR_FEATURE}") - f = feats[0] - return { - "renderer": name, - "active": bool(f["m_Active"]), - "iterations": f["_blurIterations"], - "offset": f["_blurOffset"], - "downsample": f["_blurDownsample"], - "blendRateMax": f["_blurBlendRateMax"], - "shader": f["_dualKawaseBlurShader"], - } - - -def blur_settings(player: PlayerData) -> tuple[dict, object]: - """ui.json `blur` and its Shader object, for a backdrop-filter talk window (UIAdvWidget.UpdateAdvBlurAndBackdrop -> - UIManager.UseBlur): the UIRendererFeature of renderer 0 (blur_record; CameraManager and UICameraController set - renderer 0 on the main, sub and UI cameras, and the UI overlay camera runs the pass).""" - blur = blur_record(player.graphics()) - if not blur["active"]: - raise NotImplementedError(f"renderer {blur['renderer']}: the UIRendererFeature is not active") - objs = [o for o in player.renderer_shaders(blur["renderer"]) if o.read().m_ParsedForm.m_Name == blur["shader"]] - if len(objs) != 1: - raise RuntimeError(f"renderer {blur['renderer']}: {len(objs)} shaders {blur['shader']}") - return blur, objs[0] - - -def pictogram_catalogs(nodes: list) -> dict: - """{catalog name: its serialized record} of the UIPictogram sprite catalogs the prefab nodes name (the exporter - writes an asset in full at its first reference).""" - out = {} - for n in nodes: - for c in n["components"]: - cat = c.get("_spriteCatalog") if c.get("class") == "UIPictogram" else None - if cat and "_spritesByKey" in cat: - out.setdefault(cat["name"], cat) - return out - - -def resolve_pictograms(rec: dict, catalogs: dict) -> None: - """The sprite of each UIPictogram record of a node (UISpriteCatalogBase.GetSprite: the catalog's sprite of the - key, `sprite`; null when the catalog has none) and the catalog's default sprite (`defaultSprite`).""" - for p in (rec.get("behaviours") or {}).get("UIPictogram", ()): - cat = catalogs.get(p["catalog"]) - if cat is None: - raise RuntimeError(f"{rec['path']}: sprite catalog {p['catalog']} not exported") - by_key = {e["Key"]: e["Value"] for e in cat["_spritesByKey"]["_list"]} - hit, default = by_key.get(p["_key"]), cat.get("_defaultSprite") - p["sprite"] = hit["name"] if hit else None - p["defaultSprite"] = default["name"] if default else None - p["_spriteRef"] = hit["spriteRef"] if hit else None - p["_defaultRef"] = default["spriteRef"] if default and not hit else None - - -def widget_canvases(widget: list, comp: dict) -> dict: - """widget: UIWidget._canvasSortOrder and the sorting order of each canvas of UIWidget._canvases, in list order - (UIWidget.TrueCanvasSortOrder reads the first).""" - by_path = {n["path"]: n for n in widget} - canvases = [] - for ref in comp["_canvases"]: - path = _ref_path(ref) - hit = [c for c in (by_path.get(path) or {"components": []})["components"] if c["type"] == "Canvas"] - if len(hit) != 1: - raise RuntimeError(f"_canvases: {path} has {len(hit)} Canvas components") - canvases.append({"path": path, "sortingOrder": hit[0]["m_SortingOrder"]}) - return {"canvasSortOrder": comp["_canvasSortOrder"], "canvases": canvases} def emoji_sequence_key(name: str, unicode: int) -> str | None: @@ -1107,20 +56,6 @@ def emoji_sequence_key(name: str, unicode: int) -> str | None: return "".join(out) -def sprite_asset_record(ex: Exporter, key: str = EMOJI_SPRITE_ASSET_KEY) -> dict: - """The TMP sprite asset at `key` (LocalizeManager's emoji sprite asset by default) as the text layout reads it: - `name`, `faceInfo` (m_FaceInfo), `characters` (m_SpriteCharacterTable in order: `index`, `unicode`, `name`, - `glyph`, `scale`), `glyphs` ({glyph index: metrics, rect (in the sprite sheet), scale, atlasIndex}), - `sequences` (the entries of the legacy sprite list, spriteInfoList, whose name holds a '-', in order: `name`, - `unicode`; TMP_EmojiSearchEngine builds its sequence table from them), `sheet` (the sprite sheet's name, width, - height, mip count and texture settings) and `material` (its material record). Fallback sprite assets are not - implemented.""" - o = ex.key_object(key) - tt = o.read_typetree() - sheet = ex.referent(o, tt["spriteSheet"]).read_typetree() - return sprite_record(tt, sheet, ex.material(ex.deref(o, tt["m_Material"]))) - - def sprite_record(tt: dict, sheet: dict, material: dict) -> dict: """The text layout's sprite asset record from its fields, sheet metadata and material.""" if tt.get("fallbackSpriteAssets"): @@ -1220,91 +155,6 @@ def emoji_sequence_names(table: dict[tuple, str], fast: set[tuple], text: str) - return out -def _camera(widget: list, path: str | None, field: str = "_videoAndStillCamera") -> dict: - """videoAndStillCamera: the Camera (CAMERA_FIELDS) and UniversalAdditionalCameraData (CAMERA_DATA_FIELDS) of the - node at `path` (UIAdvWidget._videoAndStillCamera; `field` names the reference in errors).""" - node = next((n for n in widget if n["path"] == path), None) - if node is None: - raise RuntimeError(f"{field}: no node {path}") - cam = [c for c in node["components"] if c["type"] == "Camera"] - data = [c for c in node["components"] if c.get("class") == "UniversalAdditionalCameraData"] - if len(cam) != 1 or len(data) != 1: - raise RuntimeError(f"{path}: {len(cam)} Camera, {len(data)} UniversalAdditionalCameraData") - return { - "path": path, - "active": node["active"], - "camera": {k: cam[0][k] for k in CAMERA_FIELDS}, - "additionalCameraData": {k: data[0][k] for k in CAMERA_DATA_FIELDS}, - } - - -def ui_camera(ex: Exporter) -> dict: - """uiCamera: the camera UICameraController._uiCamera of the UIManager prefab (UIManager._cameraController), with - its UniversalAdditionalCameraData (_uiCameraData, on the same node), as _camera's record. The components are read - from their typetrees, so nothing they reference is exported.""" - go = ex.key_object(UI_MANAGER_KEY) - if go is None or go.type.name != "GameObject": - raise RuntimeError(f"{UI_MANAGER_KEY}: container asset is {go and go.type.name}") - _, graph = ex.load(UI_MANAGER_KEY) - - def mono(owner, pptr, cls: str, what: str): - o = ex.deref(owner, pptr) if pptr else None - if o is None or o.type.name != "MonoBehaviour" or ex.script_class(o) != cls: - raise RuntimeError(f"{UI_MANAGER_KEY}: {what} is not a {cls}") - return o - - comps = [ex.deref(go, c["component"]) for c in go.read_typetree()["m_Component"]] - managers = [ - c for c in comps if c is not None and c.type.name == "MonoBehaviour" and ex.script_class(c) == "UIManager" - ] - if len(managers) != 1: - raise RuntimeError(f"{UI_MANAGER_KEY}: {len(managers)} UIManager on the prefab root") - ctrl = mono( - managers[0], - managers[0].read_typetree()["_cameraController"], - "UICameraController", - "UIManager._cameraController", - ) - ctrl_tt = ctrl.read_typetree() - cam = ex.deref(ctrl, ctrl_tt["_uiCamera"]) if ctrl_tt["_uiCamera"] else None - if cam is None or cam.type.name != "Camera": - raise RuntimeError(f"{UI_MANAGER_KEY}: UICameraController._uiCamera is not a Camera") - data = mono(ctrl, ctrl_tt["_uiCameraData"], "UniversalAdditionalCameraData", "UICameraController._uiCameraData") - cam_tt, data_tt = cam.read_typetree(), data.read_typetree() - go_pid = cam_tt["m_GameObject"]["m_PathID"] - if data_tt["m_GameObject"]["m_PathID"] != go_pid: - raise RuntimeError(f"{UI_MANAGER_KEY}: _uiCamera and _uiCameraData are on different GameObjects") - path = graph.path(graph.tf_of_go[go_pid]) - node = { - "path": path, - "active": bool(graph.go[go_pid]["m_IsActive"]), - "components": [ - {"type": "Camera", **cam_tt}, - {"type": "MonoBehaviour", "class": "UniversalAdditionalCameraData", **data_tt}, - ], - } - return _camera([node], path, "_uiCamera") - - -def master_texts(master: Path, ids, field: str) -> dict[str, str]: - """{MasterText id: its text in the text field `field`} of `ids`; a missing id raises.""" - from ..source import master as master_mod - - ids = set(ids) - rows = {r["_id"]: r for r in master_mod.table(Path(master), "MasterText") if r["_id"] in ids} - missing = sorted(ids - set(rows)) - if missing: - raise KeyError(f"text ids not in MasterText: {missing}") - return {i: rows[i].get(f"_{field}") or "" for i in sorted(ids)} - - -def master_id_texts(settings: dict, master: Path, field: str) -> dict: - """masterIdTexts: {AdvMasterIdSettings field of MASTER_ID_TEXTS: {id (MasterText id), text (in `field`)}}.""" - ids = {k: settings[k] for k in MASTER_ID_TEXTS} - texts = master_texts(master, ids.values(), field) - return {k: {"id": i, "text": texts[i]} for k, i in ids.items()} - - def _lookup_lang(lang: dict) -> dict: """`lang` (textstyle.language_fonts) with the Japanese font names of LocalizeManager.InitJapaneseFontLookUpTable: the Japanese font names, then its additional font names (the table TryGetFontIndex searches).""" @@ -1313,71 +163,6 @@ def _lookup_lang(lang: dict) -> dict: return {**lang, "languages": {**lang["languages"], textstyle.JAPANESE_MODE: table}} -def chat_localize(font_asset: str, material: str, lang: dict, mode: int) -> dict: - """LocalizeText.Init -> OnFontChanged of a text with _localizeEnabled whose font may be an additional font (the - chat windows' font): TryGetFontIndex = its index in the lookup table (_lookup_lang), the font of that index among - the language's loaded fonts (its font names, then the additional fonts LocalizeManager.LoadAdditionalAsset - appends; an index out of range: font 0), GetFontMaterial of the material type, ApplyLanguageLineSpacing. - -> {fontAsset, material, lineSpacing}.""" - table = [f"{n} SDF" for n in _lookup_lang(lang)["languages"][textstyle.JAPANESE_MODE]["fontNames"]] - if font_asset not in table: - raise NotImplementedError(f"font {font_asset} not in the Japanese font lookup table") - own = lang["languages"][mode] - fonts = [f"{n} SDF" for n in own["fontNames"] + own["additionalFontNames"]] - i = table.index(font_asset) - font = fonts[i] if i < len(fonts) else fonts[0] - mat_type = textstyle.material_type(material) - if mat_type not in lang["materialTypes"]: - raise NotImplementedError(f"material type {mat_type} not in LocalizeManager._materialTypeList") - return {"fontAsset": font, "material": f"{font[:-4]} - {mat_type}", "lineSpacing": LANGUAGE_LINE_SPACING[mode]} - - -def chat_windows(episode: dict) -> dict[str, str]: - """{window name: address} of the chat windows of the episode's resources ("Adv/Chat/Prefabs/" + name).""" - return { - r["address"][len(adv.CHAT_WINDOW_PREFIX) :]: r["address"] - for r in episode["resources"] - if r["kind"] == "chatwindow" - } - - -def chat_text_nodes(ex: Exporter, episode: dict): - """(window name, node, TMP component, LocalizeText component or None) of every TMP text of the episode's chat - windows, and the status text ids their AdvChatWindow components set (CHAT_STATUS_KEYS).""" - out, status = [], set() - for name, address in sorted(chat_windows(episode).items()): - for n in ex.prefab(address)["nodes"]: - win = _component(n, ("AdvChatWindow",)) - if win is not None: - status |= {win[k] for k in CHAT_STATUS_KEYS if win.get(k)} - comp = _component(n, TMP_CLASSES) - if comp is not None: - out.append((name, n, comp, _component(n, ("LocalizeText",)))) - return out, sorted(status) - - -def chat_texts(ex: Exporter, fonts, lang: dict, mode: int, episode: dict) -> tuple[dict, list[str]]: - """chatTexts {window: {node path: {textStyle, localizeText}}} of the episode's chat windows in the language `mode` - (the text record of textstyle.text_style with the font and material chat_localize gives, their settings from - `fonts` (fontsettings.FontSettings); font roles are the slots of the lookup table) and the status text ids of the - windows.""" - table_lang, styles, out = _lookup_lang(lang), {}, {} - nodes, status = chat_text_nodes(ex, episode) - for name, n, comp, loc in nodes: - fa, mat = comp["m_fontAsset"]["name"], comp["m_sharedMaterial"]["material"] - if loc and loc["m_Enabled"] and loc["_localizeEnabled"]: - loc_font = chat_localize(fa, mat, lang, mode) - fa, mat = loc_font["fontAsset"], loc_font["material"] - if (fa, mat) not in styles: - face = fonts.font(fa)["faceInfo"] - styles[(fa, mat)] = textstyle.material_style(fonts.material(fa, mat), face["m_PointSize"], face["m_Scale"]) - out.setdefault(name, {})[n["path"]] = { - "textStyle": textstyle.text_style(comp, loc, table_lang, styles[(fa, mat)], mode), - "localizeText": {"enabled": loc["m_Enabled"], "localizeEnabled": loc["_localizeEnabled"]} if loc else None, - } - return out, status - - def _number(v) -> float | None: try: return float(v) @@ -1404,132 +189,3 @@ def chat_runtime_texts(episode: dict) -> list[str]: elif c["cmd"] in ("ChatTalk", "ChatStamp", "ChatRead") and p1 is not None and p1 == int(p1) and p1 >= 1: out.append(f" {int(p1)}") return out - - -def about(language: str, windows: Sequence[str] = (adv.DEFAULT_TALK_WINDOW,)) -> str: - """ui.json `about`: what the file holds (the widget and its talk windows), in which client language.""" - return f"ADV front canvas UI data (UIAdvWidget + {', '.join(windows)}), {language}" - - -def language_doc(language: str, styles: textstyle.TextStyles | None = None) -> dict: - """ui.json `language`: the LanguageMode and text field of `language`, with `styles` (fonts game) the - LocalizeManager font lists and the font swap, and the line spacing LocalizeText applies.""" - mode = languages.mode(language) - doc = {"mode": mode, "field": languages.column(language)[1:]} - if styles is not None: - doc.update({"fonts": styles.lang, "fontSwap": styles.swap}) - doc["lineSpacing"] = LANGUAGE_LINE_SPACING[mode] - return doc - - -def shown_texts(episode: dict, master_ids: dict, field: str) -> list[str]: - """The texts the episode shows in the text field `field` (e.g. "traditionalChinese"), tags stripped: Talk, - Location and Subtitles lines with their ruby readings (), the speaker names of the Talk rows - (TargetTextIDs; the unknown-speaker text for TargetStatus 1, joined names with the split text; none for - TargetStatus 2) and the title. `master_ids`: AdvMasterIdSettings.""" - lines = [] - for c in episode["commands"]: - if c["cmd"] in ("Talk", "Location", "Subtitles", "ChoiceSet") and c.get("lines"): - lines.append(c["lines"][field]) - lines += RUBY.findall(c["lines"][field] or "") - names = set() - for c in episode["commands"]: - if c["cmd"] == "Talk" and c.get("TargetName") and c.get("TargetStatus", 0) != 2 and c.get("AdvTextID"): - ids = list(c.get("TargetTextIDs") or []) - if c.get("TargetStatus", 0) == 1: - ids = [master_ids["_unknownCharacterNameTextId"]] - elif len(ids) > 1: - ids.append(master_ids["_splitCharacterNameTextId"]) - for i in ids: - row = episode["text"].get(i) - if row is None: - raise KeyError(f"speaker text id {i} not in the episode text shard") - names.add(row[field]) - for c in episode["commands"]: # chat rows: lines, window / sender names, stamp logs - if c["cmd"] not in CHAT_COMMANDS or c.get("IgnoreData"): - continue - if c.get("lines"): - lines.append(c["lines"][field]) - ids = list(c.get("TargetTextIDs") or []) - if c["cmd"] == "ChatStamp" and c.get("TargetAssetName"): # AdvChatStampCommand.GetStampLogText - ids.append(c["TargetAssetName"].rsplit("/", 1)[-1] + "_log") - for i in ids: - row = episode["text"].get(i) - if row is not None: - names.add(row[field]) - if any(c["cmd"] in CHAT_COMMANDS for c in episode["commands"]): - for k in ("_chatReadTextId", "_chatStampLogTextId"): - row = episode["text"].get(master_ids.get(k)) - if row is not None: - names.add(row[field]) - title = episode.get("title") - if not title: - raise RuntimeError("episode.json has no title lines (adv.extract)") - return [_strip_tags(s) for s in lines + sorted(names) + [title[field]]] + chat_runtime_texts(episode) - - -def _game_fonts( - ex: Exporter, - styles: textstyle.TextStyles, - episode: dict, - out_nodes: list, - field: str, - master_ids: dict, - ui_texts: Sequence[str] = (), -) -> tuple[dict, dict]: - """`--fonts game`: the localized font / material of every text node (`text.localized`), the TMP font assets, - the glyph coverage of the characters the episode shows (in the text field `field`; `master_ids`: - AdvMasterIdSettings; `ui_texts`: texts of the UI itself) and the text materials. - -> (materials, the inputs of tmpfont.export_fonts + coverage).""" - fonts = tmpfont.FontSet(ex) - used_text_nodes = [n for n in out_nodes if "text" in n] - primaries = {} - for n in used_text_nodes: - t = n["text"] - t["localized"] = textstyle.localize_text( - n["path"], t["fontAsset"], t["material"], styles.swap, styles.lang, styles.mode - ) - primaries[t["localized"]["fontAsset"]] = None - for fname in primaries: - primaries[fname] = fonts.by_key(fontsettings.font_dir(fname) + fname) - - # -- characters the episode shows ------------------------------------------ - shown = shown_texts(episode, master_ids, field) + list(ui_texts) - chars = sorted({ord(ch) for s in shown for ch in s}) - - primary = [primaries[p] for p in primaries] - if len(primary) != 1: - raise NotImplementedError(f"texts use {len(primary)} primary fonts") - primary = primary[0] - coverage, needed, runtime_units = tmpfont.glyph_coverage(fonts, primary, chars) - # the underline character from the primary asset itself (baked, or generated as its dynamic asset would) - kind, font = fonts.lookup(primary, UNDERLINE_CHARACTER) or (None, None) - in_asset = font is primary and kind in ("baked", "runtime") - if in_asset: - (needed if kind == "baked" else runtime_units).setdefault(primary.name, set()).add(UNDERLINE_CHARACTER) - coverage["underline"] = {"font": primary.name, "character": chr(UNDERLINE_CHARACTER), "inAsset": in_asset} - # the missing glyph (tmpfont.missing_glyph): its substitute from the asset that has it - tmpfont.check_glyph_variants(fonts, primary, shown) - mg = tmpfont.missing_glyph(fonts, primary, chars, tmpfont.missing_glyph_character(ex.player)) - if mg: - found = fonts.lookup(primary, mg["unicode"]) - if found is None: - raise RuntimeError(f"missing glyph U+{mg['unicode']:04X}: in no font asset of {primary.name}") - kind, f = found - if kind != "synthesized": - (needed if kind == "baked" else runtime_units).setdefault(f.name, set()).add(mg["unicode"]) - coverage["missingGlyph"] = [chr(u) for u in mg["characters"]] - - # -- materials --------------------------------------------------------------- - materials, text_materials = tmpfont.text_materials( - ex, fonts, [n["text"]["localized"] for n in used_text_nodes], {primary.name}, needed, runtime_units - ) - return materials, { - "fonts": fonts, - "primaries": {primary.name}, - "needed": needed, - "runtime": runtime_units, - "textMaterials": text_materials, - "coverage": coverage, - "missingGlyph": {primary.name: mg} if mg else {}, - } diff --git a/src/nnnotes/content/advvideo.py b/src/nnnotes/content/advvideo.py deleted file mode 100644 index ee0cfc0..0000000 --- a/src/nnnotes/content/advvideo.py +++ /dev/null @@ -1,132 +0,0 @@ -"""ADV videos: Movie / Clip VideoID -> the episode's -Video row -> `Cri/Video/<_assetName>` (a USM, usm.py) -> -/videos/.webm + videos/videos.json. - -An addressable key `Cri/Video/` depends on a small bundle holding the CriWare.Assets MonoBehaviour of the -movie (`movieInfo`: size, frame rate, frame count, codec, alpha / audio / subtitle stream counts; `assetInfo`) and on -the USM itself, stored as-is on the CDN like the cue sheets' raw data (VideoManager.Prepare hands it to CRI Mana). A -movie asset may instead hold the USM bytes itself (a CriSerializedBytesAssetImpl). -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -from ..core.jsonio import write_json -from ..formats.usm import AUDIO_BITRATE, VP9_ENCODE, adx_info, demux, hca_info, webm_reencoded, webm_streams, write_webm - -if TYPE_CHECKING: - from ..source.catalog import Catalog - -VIDEO_DIR = "videos" -INDEX = "videos/videos.json" -# movieInfo.codecType (CriMana.CodecType) -CODECS = {1: "sofdec.prime", 5: "h264", 9: "vp9"} - - -def usm_asset(cat: Catalog, key: str) -> tuple[dict, Path]: - """(the movie's CriWare.Assets MonoBehaviour typetree, the USM file) of a `Cri/Video/...` key.""" - import UnityPy - - from ..source.addressables import remote_path - - info, raw = None, None - for e in cat.dependencies(key): - if remote_path(e["internal_id"]) is None: - continue - bundle = cat.as_bundle(e) - if bundle is not None: - env = UnityPy.load(str(cat.fetch(bundle))) - for o in env.objects: - if o.type.name == "MonoBehaviour": - tt = o.read_typetree() - if "movieInfo" in tt: - info = tt - else: - raw = cat.fetch_raw(e) - if info is None or raw is None: - raise RuntimeError(f"{key}: movie asset or USM data not among the dependencies") - return info, raw - - -def export(cat: Catalog, key: str, dst: Path, cri_key: int, work: Path) -> dict: - """One USM -> dst (.webm, write_webm: VP9 copied, or re-encoded when the movie's video is not VP9 or has an alpha - stream; the first audio stream as Opus). Returns its record (movie info, the audio's source; `encode` when the - video was re-encoded).""" - from ..source.config import tool - - info, raw = usm_asset(cat, key) - mi = info["movieInfo"] - codec = CODECS.get(mi["codecType"], f"codecType {mi['codecType']}") - streams = demux(raw.read_bytes(), cri_key) - if mi["numAudioStreams"] and not any(s.kind == "audio" for s in streams): - raise RuntimeError(f"{key}: movie info lists an audio stream the USM does not carry") - hca = any(s.kind == "audio" and s.codec == "hca" for s in webm_streams(streams)) - picked = write_webm( - streams, dst, work, tool("ffmpeg", "ffmpeg"), cri_key, tool("vgmstream", "vgmstream-cli") if hca else None - ) - rec = { - "key": key, - "codec": codec, - "width": mi["width"], - "height": mi["height"], - "displayWidth": mi["dispWidth"], - "displayHeight": mi["dispHeight"], - "frameRate": [mi["framerateN"], mi["framerateD"]], - "frames": mi["totalFrames"], - "assetInfo": info.get("assetInfo"), - } - if webm_reencoded(picked): - rec["encode"] = { - "codec": "vp9", - "crf": int(VP9_ENCODE[VP9_ENCODE.index("-crf") + 1]), - "alpha": any(s.kind == "alpha" for s in picked), - } - audio = next((s for s in picked if s.kind == "audio"), None) - if audio is not None: - rec["audio"] = { - "codec": "opus", - "bitrate": AUDIO_BITRATE, - "source": adx_info(audio.data) if audio.codec == "adx" else hca_info(audio.data), - } - return rec - - -def extract(cat: Catalog, episode: dict, out_dir: Path, cri_key: int | None = None) -> dict | None: - """Every video of the episode's closure -> videos/.webm; videos/videos.json maps each VideoID of the - Movie / Clip rows to its file, its -Video row (`master`: size, autoStop, hasAudio) and the stream facts. - Returns the index, or None when the episode has no video.""" - from ..formats import cri - from . import adv - - keys = sorted(r["address"] for r in episode["resources"] if r["kind"] == "video") - if not keys: - return None - if cri_key is None: - if cat.apk is None: - raise RuntimeError("USM key: pass cri_key= or open the Catalog with apk=") - cri_key = cri.hca_key(cat.apk) - out_dir = Path(out_dir) - files: dict[str, dict] = {} - names: dict[str, str] = {} - for key in keys: - name = key.rsplit("/", 1)[-1] - if name in names: - raise RuntimeError(f"videos {names[name]} and {key} share the file name {name}") - names[name] = key - rel = f"{VIDEO_DIR}/{name}.webm" - files[key] = {"file": rel, **export(cat, key, out_dir / rel, cri_key, out_dir / VIDEO_DIR / "_work")} - videos = {} - for c in episode["commands"]: - vid = c.get("VideoID") - if c["cmd"] not in adv.VIDEO_COMMANDS or not vid or str(vid) in videos: - continue - row = episode["videos"].get(vid) or episode["videos"].get(str(vid)) - if not row: - continue - key = adv.VIDEO_ADDRESS.format(row["_assetName"]) - if key in files: - videos[str(vid)] = {"master": row, **files[key]} - index = {"videos": videos, "format": "WebM: VP9 as stored in the USM, audio as Opus"} - write_json(out_dir / INDEX, index) - return index diff --git a/src/nnnotes/content/hostspot.py b/src/nnnotes/content/hostspot.py deleted file mode 100644 index 0f74f88..0000000 --- a/src/nnnotes/content/hostspot.py +++ /dev/null @@ -1,372 +0,0 @@ -"""The spot files a story's home host writes from the bundles of a spot (content.storyhost): the background as -binary glTF and the situation's spot.json with its Spine files, read with UnityPy from the bundle closures of the -spot's catalog keys. - -`extract_room` writes .glb and .json as content.room makes room.glb and room.json from a product's objects -(the same material translation, sampler and glTF writer): every MeshFilter of the background's bundle closure, in the -bundles' object order; the empty meshes listed apart (`skippedEmptyMeshes`: no vertex data, -`meshesWithoutTriangles`: vertices only). `extract_spot` writes spot.json and spine/ as content.spot makes them, -the skeletons in the bundles' object order, and `resources`: the bundle names of the background and situation keys. -""" - -from __future__ import annotations - -import io -import json -from pathlib import Path - -import numpy as np -import UnityPy - -from ..core.jsonio import write_json -from ..formats.shader import parsed_summary -from ..source.catalog import Catalog -from ..source.config import apk_missing -from ..unity.convert.material import material_doc -from ..unity.reader import SceneGraph, deref, mesh_arrays, referent, script_class, strip_pptrs, texture_image -from . import room -from .prefabs import mat4 -from .spot import LANGS, atlas_pages - -__all__ = [ - "drawn_meshes", - "extract_room", - "extract_spot", - "main_textures", - "mesh_components", - "object_key", - "submesh_primitives", - "text_bytes", -] - - -def object_key(obj) -> tuple[str, int]: - """(serialized file name, path id) of an object: a path id is unique only within its file.""" - return obj.assets_file.name, obj.path_id - - -def main_textures(owner, mat_tt: dict, shader_name: str) -> list: - """[(texture object, texture env)] of a material's main texture slot (room.main_tex_slot) when it holds a texture; - `owner` is the material's object, whose file the env's PPtr is read from.""" - slot = room.main_tex_slot(shader_name) - return [ - (deref(owner, env["m_Texture"]), env) - for key, env in mat_tt["m_SavedProperties"]["m_TexEnvs"] - if key == slot and env["m_Texture"]["m_PathID"] - ] - - -# ---- extraction --------------------------------------------------------- -def submesh_primitives(tris: list, mats: list, material_for, on_null) -> list: - """[(glTF triangle indices, material index)] of a mesh's submeshes: `tris` (per submesh, Unity winding), - `mats` the renderer's material PPtrs of the same index. An empty submesh gives nothing; a submesh whose material - slot is empty (path id 0) gives nothing and is reported to `on_null(submesh index)`.""" - prims = [] - for i, t in enumerate(tris): - if not len(t): - continue - if not mats[i].path_id: # empty slot: no material to draw the submesh with - on_null(i) - continue - prims.append((t[:, [0, 2, 1]].reshape(-1).astype(np.uint32), material_for(mats[i]))) - return prims - - -def mesh_components(env) -> tuple[dict, dict]: - """({GameObject key: MeshFilter}, {GameObject key: MeshRenderer}) of an environment. The key is (file name, - GameObject path id), as object_key: a component is in its GameObject's file.""" - mf_by_go, mr_by_go = {}, {} - for o in env.objects: - if o.type.name == "MeshFilter": - mf_by_go[(o.assets_file.name, o.read_typetree()["m_GameObject"]["m_PathID"])] = o - elif o.type.name == "MeshRenderer": - mr_by_go[(o.assets_file.name, o.read_typetree()["m_GameObject"]["m_PathID"])] = o - return mf_by_go, mr_by_go - - -def drawn_meshes(mf_by_go: dict, mr_by_go: dict, graph, on_null_mesh): - """(transform path id, Mesh, material PPtrs) of each GameObject with a MeshFilter and an enabled MeshRenderer - (mesh_components maps). A MeshFilter whose mesh reference is empty (path id 0) draws nothing: its object path - goes to `on_null_mesh(path)`.""" - for key, mf in mf_by_go.items(): - tf_pid = graph.tf_of_go.get(key[1]) - mr = mr_by_go.get(key) - if tf_pid is None or mr is None: - continue - if not mr.read_typetree().get("m_Enabled", 1): - continue - mesh_ref = mf.read().m_Mesh - if not mesh_ref.path_id: - on_null_mesh(graph.path(tf_pid)) - continue - yield tf_pid, mesh_ref.read(), mr.read().m_Materials - - -def extract_room(cat: Catalog, bg_key: str, out_path: Path) -> dict: - env = UnityPy.load(*[str(p) for p in cat.fetch_key(bg_key)]) - graph = SceneGraph(env) - mf_by_go, mr_by_go = mesh_components(env) - - glb = room.Glb() - tex_cache: dict[tuple[str, int], int] = {} # texture object_key -> glTF texture - mat_cache: dict[tuple[str, int], int] = {} # material object_key -> glTF material - - def material_for(pptr) -> int: - mat = pptr.deref() - mkey = object_key(mat) - if mkey in mat_cache: - return mat_cache[mkey] - mat_tt = mat.read_typetree() - shader_tt = referent(mat, mat_tt["m_Shader"]).read_typetree() - gm = room.translate_material(material_doc(mat_tt, lambda p: p), parsed_summary(shader_tt)) - for tex_obj, env_ in main_textures(mat, mat_tt, shader_tt["m_ParsedForm"]["m_Name"]): - tid = object_key(tex_obj) - if tid not in tex_cache: - tex_tt = tex_obj.read_typetree() - buf = io.BytesIO() - texture_image(tex_obj.read()).save(buf, format="PNG") - tex_cache[tid] = glb.texture(buf.getvalue(), tex_tt["m_Name"], room.sampler(tex_tt)) - info, transform = room.texture_info(tex_cache[tid], env_["m_Scale"], env_["m_Offset"]) - if transform: - glb.ext_used.add("KHR_texture_transform") - gm["pbrMetallicRoughness"]["baseColorTexture"] = info - mat_cache[mkey] = glb.material(gm) - return mat_cache[mkey] - - n_mesh = 0 - n_inactive = 0 - skipped = [] - null_slots = [] # submeshes whose material slot is empty - null_meshes = [] # objects whose MeshFilter has no mesh - no_triangles = [] # meshes with vertices but no triangles - for tf_pid, mesh, mats in drawn_meshes(mf_by_go, mr_by_go, graph, null_meshes.append): - arrays = mesh_arrays(mesh) - if arrays is None: - skipped.append(mesh.m_Name) # empty mesh (no vertex data) - continue - v, vn, uv0, tris = arrays - if not any(len(t) for t in tris): # vertices only (e.g. a placeholder mesh without index data) - no_triangles.append({"mesh": mesh.m_Name, "path": graph.path(tf_pid)}) - continue - if len(mats) < len(tris): - raise NotImplementedError(f"{mesh.m_Name}: {len(tris)} submeshes, {len(mats)} materials") - m = graph.world(tf_pid) - p = (np.hstack([v, np.ones((len(v), 1))]) @ m.T)[:, :3] - p[:, 2] *= -1.0 # Unity LH -> glTF RH - if len(vn): - nw = vn @ np.linalg.inv(m[:3, :3]).T - nw /= np.maximum(np.linalg.norm(nw, axis=1, keepdims=True), 1e-12) - nw[:, 2] *= -1.0 - else: - nw = np.empty((0, 3)) - uv = uv0.copy() - uv[:, 1] = 1.0 - uv[:, 1] # Unity UV origin bottom-left -> glTF top-left - prims = submesh_primitives( - tris, - mats, - material_for, - lambda i: null_slots.append({"mesh": mesh.m_Name, "path": graph.path(tf_pid), "submesh": i}), - ) - if not prims: - continue - is_active = graph.active(tf_pid) - glb.add_mesh(mesh.m_Name, p, nw, uv, prims, active=is_active) - n_mesh += 1 - n_inactive += 0 if is_active else 1 - - out_path = Path(out_path) - out_path.parent.mkdir(parents=True, exist_ok=True) - out_path.write_bytes(glb.bytes({"backgroundKey": bg_key})) - doc = { - "backgroundKey": bg_key, - "glb": out_path.name, - "meshCount": n_mesh, - "inactiveMeshes": n_inactive, - "skippedEmptyMeshes": skipped, - "nullMaterialSubmeshes": null_slots, - "nullMeshFilters": null_meshes, - "meshesWithoutTriangles": no_triangles, - **glb.summary(), - } - write_json(out_path.with_suffix(".json"), doc) - return doc - - -def _master(md: Path, name: str) -> list[dict]: - return json.loads((md / f"{name}.json").read_text(encoding="utf-8"))["_allData"] - - -def text_bytes(ta) -> bytes: - s = ta.m_Script - return s.encode("utf-8", "surrogateescape") if isinstance(s, str) else bytes(s) - - -def _target(ref): - """The object a PPtr refers to, None for an empty reference (path id 0).""" - return ref.read() if ref.path_id else None - - -def write_skeleton(o, write, written_atlases: dict[int, str] | None = None) -> dict: - """The Spine files of the SkeletonDataAsset object `o` (module docstring), each given to `write(file name, - bytes)` in this order: the skeleton, then for each of its atlases not in `written_atlases` ({SpineAtlasAsset - path id: atlas file name}, updated here) the atlas text as stored and one PNG per page. Returns the skeleton's - record of spot.json: name, skeleton file, atlas files, scale, default mix, mixes.""" - written = {} if written_atlases is None else written_atlases - sda = o.read_typetree() - sk = o.read().skeletonJSON.read() - raw = text_bytes(sk) - is_json = raw.lstrip()[:1] == b"{" - ext = ".json" if is_json else ".skel" - sk_file = sk.m_Name if sk.m_Name.endswith(ext) else f"{sk.m_Name}{ext}" # as the atlas below - write(sk_file, raw) - atlases = [] - for ap in o.read().atlasAssets: - atlas_asset = ap.read() - if ap.path_id not in written: - af = atlas_asset.atlasFile.read() - araw = text_bytes(af) - atext = araw.decode("utf-8") - a_file = af.m_Name if af.m_Name.endswith(".atlas") else f"{af.m_Name}.atlas" - write(a_file, araw) # the atlas text exactly as the game stores it - pages = atlas_pages(atext) - mats = list(atlas_asset.materials) - if len(mats) != len(pages): - raise RuntimeError(f"{a_file}: {len(pages)} pages vs {len(mats)} materials") - for page, mp in zip(pages, mats): - mat = mp.read() - tex = next(e.m_Texture.read() for k, e in mat.m_SavedProperties.m_TexEnvs if k == "_MainTex") - buf = io.BytesIO() - texture_image(tex).save(buf, format="PNG") - write(page, buf.getvalue()) - written[ap.path_id] = a_file - atlases.append(written[ap.path_id]) - return { - "name": sda["m_Name"], - "skeleton": sk_file, - "atlases": atlases, - "scale": sda["scale"], - "defaultMix": sda["defaultMix"], - "mixes": [ - {"from": f, "to": t, "duration": d} - for f, t, d in zip(sda.get("fromAnimation", []), sda.get("toAnimation", []), sda.get("duration", [])) - ], - } - - -def spine_characters(objs: list, graph, skeletons: dict, world_of) -> list[dict]: - """SpotSpineCharacter components: object path, the SkeletonAnimation they drive (its skeleton data name, - initial animation settings and world matrix). `skeletons`: SkeletonDataAsset path id -> skeleton record. One - whose `_animation` is empty has those three null.""" - out = [] - for o in objs: - ssc = o.read_typetree() - path = graph.path(graph.tf_of_go[ssc["m_GameObject"]["m_PathID"]]) - anim_o = _target(o.read()._animation) - if anim_o is None: - out.append({"path": path, "skeletonData": None, "animation": None, "world": None}) - continue - at = anim_o.object_reader.read_typetree() - out.append( - { - "path": path, - "skeletonData": skeletons[anim_o.skeletonDataAsset.path_id]["name"], - "animation": { - k: at[k] - for k in ( - "_animationName", - "loop", - "timeScale", - "initialSkinName", - "initialFlipX", - "initialFlipY", - "pmaVertexColors", - "tintBlack", - "zSpacing", - ) - }, - "world": world_of(at["m_GameObject"]), - } - ) - return out - - -def tap_targets(objs: list, graph, world_of) -> list[dict]: - """SpotCharacter components (tap targets) by character id: object path, their fields, world matrix and the world - matrix of their `_focus` transform (null when the reference is empty).""" - out = [] - for o in objs: - sc = o.read_typetree() - focus_o = _target(o.read()._focus) - out.append( - { - "path": graph.path(graph.tf_of_go[sc["m_GameObject"]["m_PathID"]]), - **strip_pptrs({k: v for k, v in sc.items() if k != "_focus"}), - "world": world_of(sc["m_GameObject"]), - "focusWorld": None - if focus_o is None - else world_of(focus_o.object_reader.read_typetree()["m_GameObject"]), - } - ) - out.sort(key=lambda c: c["_characterId"]) - return out - - -def extract_spot(cat: Catalog, md: Path, spot_id: int, out_dir: Path) -> dict: - if cat.apk is None: - raise apk_missing("resolving the Spot / Spine component classes") - spots = {r["_id"]: r for r in _master(md, "MasterHomeSpot")} - sp = spots[spot_id] - txt = {r["_id"]: r for r in _master(md, "MasterText")} - taps: dict[int, list[int]] = {} - for r in _master(md, "MasterStoryHomeSpotTapTalkEpisode"): - if r.get("_spotId") == spot_id: - taps.setdefault(r["_characterId"], []).append(r["_advId"]) - - out_dir = Path(out_dir) - spine_dir = out_dir / "spine" - spine_dir.mkdir(parents=True, exist_ok=True) - - env = UnityPy.load(*[str(p) for p in cat.fetch_key(sp["_situationAssetPath"])]) - graph = SceneGraph(env) - by_cls: dict[str | None, list] = {} - for o in env.objects: - if o.type.name == "MonoBehaviour": - by_cls.setdefault(script_class(o), []).append(o) - - def world_of(go_pptr) -> list[float]: - return mat4(graph.world_of_go(go_pptr["m_PathID"])) - - settings = strip_pptrs(by_cls["SpotSituationSettings"][0].read_typetree()) - controller_o = by_cls["SpotCharacterController"][0] - controller = strip_pptrs(controller_o.read_typetree()) - - # Spine skeleton data -> files - def write(name: str, data: bytes) -> None: - (spine_dir / name).write_bytes(data) - - skeleton_files: dict[int, dict] = {} - written_atlases: dict[int, str] = {} - for o in by_cls.get("SkeletonDataAsset", []): - skeleton_files[o.path_id] = write_skeleton(o, write, written_atlases) - - spine_chars = spine_characters(by_cls.get("SpotSpineCharacter", []), graph, skeleton_files, world_of) - characters = tap_targets(by_cls.get("SpotCharacter", []), graph, world_of) - - doc = { - "spotId": spot_id, - "master": sp, - "name": {lang[1:]: txt.get(sp.get("_nameTextId"), {}).get(lang, "") for lang in LANGS}, - "tapTalk": {str(k): v for k, v in sorted(taps.items())}, - "situationSettings": settings, - "controller": {k: v for k, v in controller.items() if k not in ("_spotSpineCharacters", "_characters")}, - "characters": characters, - "spineCharacters": spine_chars, - "skeletons": list(skeleton_files.values()), - "resources": { - "background": [b.name for b in cat.resolve(sp["_backgroundAssetPath"])], - "situation": [b.name for b in cat.resolve(sp["_situationAssetPath"])], - }, - "matrixConvention": "Unity world space (left-handed), 4x4 column-major", - } - write_json(out_dir / "spot.json", doc) - return doc diff --git a/src/nnnotes/content/live.py b/src/nnnotes/content/live.py index 470902c..0865498 100644 --- a/src/nnnotes/content/live.py +++ b/src/nnnotes/content/live.py @@ -1,64 +1,12 @@ -"""A live (a music's chart at a difficulty): the `live` product, and the self-contained live directory of `build`. - -The `live` product (live.json format LIVE_FORMAT), read from a product's object table (`Objects`, -pipeline.assemble), never from a bundle, holds what belongs to one chart and names the products it plays with by -their manifest paths: - - chart.json the chart as shipped (the TextAsset of chart_key, un-gzipped) - notes.json the runtime notes the game converts it to (score.convert), with `source` - notes.mirror.json the same with the MirrorChart option on - live.json musicId, difficulty, `band` (band_choice: the band whose start timeline and background the - live plays), the files above, the counts of the converted chart (judgementNoteCount, - fullComboCount, noteCount, lastNoteTimeMs), `master` (music_record: the music's rows, title - and band names), `jacket` (the Sprite of jacket_key) and `parts` {part: manifest path}: the - scene (live.scene), the note highway (live.notes) with the default note skin and effect set - (live.noteskin, live.noteeffect), the default sounds (live.sounds) and the music - (live.music) - -A subject names a chart and optionally the band: `/[/]` (live_subject). Without a band -the live takes the band of DEFAULT_BAND_RULE. - -`build` writes the live directory instead, the data format the player reads from a directory: - - /score/ chart as shipped, converted runtime notes, master rows (score.py) - /audio// the live BGM cue sheet decoded per cue + cues.json + streams.json (score.py) - /audio/live-audio.json the live's sounds (BGM, note SE, live SE) with their CRI routing, and the - note SE / live SE cue sheets decoded next to the BGM (liveaudio.py) - /livescene/ scene graph, cameras, lane, LightWeight background, start timeline, - textures and shaders (livescene.py) - /livenotes/ note / line / effect prefabs, skins, clips, particle systems, - textures and shaders (livenotes.py) - /liveui/ the start canvas (music title, credits, difficulty) in the client language: - strings, text records or the game's fonts, sprites (liveui.py) - /live.json index of the above - -`options` (liveoptions.LiveOptions, from `--live-option`) adds the files of the option variants it offers: the -mirrored score (score.py, live.json `notesMirror`), the other note skins and effect sets, the bar line view and the -option tables (livenotes.py), the other note sound sets (liveaudio.py). Without it the directory holds what the -default options read. - -The band of the LightWeight background and of the start timeline is the band of the player's deck centre -(`SelfMemberList[2]`: LightWeightBackgroundLoadStep.d__3 reads its BandID, LiveResourceBandResolver.Resolve -its MemberCard's Character.Band; MasterMemberCard.get_BandID = Character._bandID). The preview has no deck: -`band_choice` takes it from a leader card or a band given, else from DEFAULT_BAND_RULE. -""" +"""Live products assembled from chart objects, master records and shared part manifests.""" from __future__ import annotations import gzip -from pathlib import Path -from typing import TYPE_CHECKING -from ..core.jsonio import write_json -from ..formats.encoders import FLAC_LEVEL from ..source import languages -from .liveoptions import LiveOptions from .prefabs import Objects, key_object -if TYPE_CHECKING: - from ..source.catalog import Catalog - from ..unity.player import PlayerData - LIVE_FORMAT = 1 # live.json "format" DIFFICULTIES = ("easy", "normal", "hard", "expert") # _easyID.._expertID -> file suffix _00.._03 # the master tables of a music record (music_record) and of a live record (live_record) @@ -242,111 +190,3 @@ def live(objects: Objects, record: dict, parts: dict) -> tuple[dict, bytes, dict chart = gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw keep = {o for o in objects.ids() if o != text and objects.info(o).get("class") != "MonoScript"} return doc, chart, {"notes.json": notes, "notes.mirror.json": mirror}, keep - - -def resolve_band( - cat: Catalog, master: Path, music_id: int, band: int | None = None, leader_card: int | None = None -) -> dict: - """The preview's band (band_choice; recorded in livescene/scene.json slice.bandChoice) of a band whose start - timeline and background the catalog holds (BAND_KEYS).""" - from .score import master_table - - names = ("MasterCharacter", "MasterLiveMusic") + (("MasterMemberCard",) if leader_card is not None else ()) - choice = band_choice({n: master_table(master, n) for n in names}, music_id, band, leader_card) - b = choice["band"] - missing = [k.format(band=b) for k in BAND_KEYS if not cat.has(k.format(band=b))] - if missing: - raise KeyError(f"band {b}: {', '.join(missing)} not in the catalog") - return choice - - -def index_doc(music_id: int, difficulty: str, score_summary: dict, audio: dict, live_audio: str) -> dict: - """live.json: `score_summary` from score.extract (its `notesMirror` when the score was also converted - mirrored), `audio` its BGM entry, `live_audio` the path of the live sounds (liveaudio.extract).""" - s = score_summary - return { - "musicId": music_id, - "difficulty": difficulty, - "chart": s["chart"], - "notes": s["notes"], - **({"notesMirror": s["notesMirror"]} if "notesMirror" in s else {}), - "master": s["master"], - "audio": audio, - "liveAudio": live_audio, - "scene": "livescene/scene.json", - "noteAssets": "livenotes/notes.json", - "liveUi": "liveui/liveui.json", - } - - -def build( - cat: Catalog, - master: Path, - player: PlayerData, - music_id: int, - difficulty: str, - out_dir: Path, - audio_format: str = "flac", - band: int | None = None, - leader_card: int | None = None, - *, - language: str, - fonts: str = "open", - options: LiveOptions = LiveOptions(), - flac_level: int = FLAC_LEVEL, -) -> dict: - """One live directory. `language`: the client language of the start canvas (a languages.LANGUAGES code); - `fonts`: "open" or "game" (liveui.extract); `options`: the option variants whose files it carries as well - (liveoptions.resolve); `flac_level`: ffmpeg's compression level of the FLAC audio (cri.decode).""" - from . import liveaudio, livenotes, livescene, liveui, score - - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - choice = resolve_band(cat, Path(master), music_id, band=band, leader_card=leader_card) - s = score.extract( - cat, - master, - music_id, - difficulty, - out_dir, - audio=True, - audio_fmt=audio_format, - flac_level=flac_level, - mirror=options.mirror, - ) - la = liveaudio.extract( - cat, - Path(master), - player, - music_id, - out_dir, - fmt=audio_format, # reuses the BGM decode - options=options, - flac_level=flac_level, - ) - sc = livescene.extract( - cat, player, out_dir, master=Path(master), music_id=music_id, band=choice["band"], band_choice=choice - ) - lu = liveui.extract( - cat, - player, - out_dir, - master=Path(master), - music_id=music_id, # reads livescene's shaders - difficulty=difficulty, - language=language, - fonts=fonts, - ) - nt = livenotes.extract(cat, player, out_dir, master=Path(master), options=options) - index = index_doc(music_id, difficulty, s, s["audio"], la["index"]) - write_json(out_dir / "live.json", index) - return { - **index, - "band": choice, - "sceneSummary": sc, - "noteAssetSummary": nt, - "liveAudioSummary": la, - "liveUiSummary": lu, - "judgementNoteCount": s["judgementNoteCount"], - "fullComboCount": s["fullComboCount"], - } diff --git a/src/nnnotes/content/liveaudio.py b/src/nnnotes/content/liveaudio.py index b491023..9b6f193 100644 --- a/src/nnnotes/content/liveaudio.py +++ b/src/nnnotes/content/liveaudio.py @@ -1,56 +1,12 @@ -"""Live sounds: the cue structure of the live's cue sheets, the game's sound settings and the live-audio documents. - -What a live plays besides its chart, from the master data (MasterSound, MasterSoundCueSheet, MasterLiveMusic, -MasterLiveNoteSe, MasterLiveSe, MasterOptionDefault), the cue sheets' ACBs and their decoded streams (cri.decode or -the cri.audio stage), the ACF of the APK (ACF_IN_APK) and two MonoBehaviours of the APK's built-in files -(SETTINGS_SCRIPTS). A sound entry (sound_entry): - - {row, sheet, cue, category, categories, volume, busSends, lengthMs, other, layers[{file, stream, awbId, - sampleRate, channels, samples, loopStart, loopEnd, loopFlag, volume, busSends, other}]} - -the cue structure (sequence -> tracks -> synth -> waveform) read from the ACB (acb_cues: command 65 categories, 146 -volume, 111 bus send), each layer the decoded stream `audio//`. - -music_doc (the live.music product, format MUSIC_FORMAT): `music` {soundId, volume, startTimeSec} the BGM sound, and -`sounds` {its id: its entry}, each layer in the product's audio format. - -sounds_doc (the live.sounds product, format SOUNDS_FORMAT), the sounds of the default options (auto play at Perfect, -fresh profile) and of the note sound sets offered: - - sounds MasterSound id -> entry: the note SE of the default preset and of every offered set, and the live - SE of LIVE_SE_SOUNDS (the start and finish cheers and the finish direction of every result: the one a - live ends with depends on how it is played) - categories CRI category volumes for a fresh profile (SoundVolumeSettings x AppConfigDefaultData) - acf, react the ACF's buses and categories, its REACT (ducking) entries between live categories - noteSe LiveNoteSeType -> sound id / volume / mute for the default preset (LiveSettingCreator.CreateSESettings); - with sets offered also `groups`: set id -> LiveNoteSeType -> sound id (MasterLiveNoteSe) - liveSe LiveSeType -> sound id (MasterLiveSe) - timeline intro / end timings the runtime needs - -A live.sounds subject names the sets offered: `default`, or `NoteSePatternId=,,...` (sounds_subject). - -extract() writes the same for one music into a live directory (audio/live-audio.json: the fields of both documents -in one, the BGM sound among the sounds) from a Catalog, decoding each cue sheet with cri.decode into -`/audio//` (a sheet already decoded there, the BGM by score.py, is reused). -""" +"""Music and live sound product records from explicit master data, cue sheets and player settings.""" from __future__ import annotations import struct -from pathlib import Path -from typing import TYPE_CHECKING -from ..core.jsonio import write_json from ..formats.acb import commands as _commands from ..formats.acb import tables as _tables from ..formats.acb import u16s as _u16s -from ..formats.encoders import FLAC_LEVEL -from ..source.apkset import ApkSet - -if TYPE_CHECKING: - from ..source.catalog import Catalog - from ..unity.player import PlayerData - from .liveoptions import LiveOptions ACF_IN_APK = "assets/Cri/Sound/Sirius.acf" MUSIC_FORMAT, SOUNDS_FORMAT = 1, 1 # the "format" of music_doc / sounds_doc @@ -548,94 +504,3 @@ def sounds_doc(tables: dict, subject: str, sheet, svs: dict, acd: dict, acf: byt # --------------------------------------------------------------------------- extraction -def _decode(cat: Catalog, sheet: str, out_dir: Path, fmt: str, flac_level: int) -> list[dict]: - """Decode a cue sheet into /audio// (reusing an existing decode) and return its stream list.""" - from ..formats import cri - - d = out_dir / "audio" / sheet - if not (d / "streams.json").exists(): - cri.decode(cat, sheet, d, fmt=fmt, flac_level=flac_level) - import json - - return json.loads((d / "streams.json").read_text(encoding="utf-8")) - - -def _player_mono(player: PlayerData, cls: str) -> dict: - hits = [] - for o in player.env.objects: - if o.type.name != "MonoBehaviour": - continue - try: - if player.script(o)[2] != cls: - continue - except Exception: # MonoBehaviours without a resolvable script are not ours - continue - hits.append(player.mono(o)) - if len(hits) != 1: - raise RuntimeError(f"{cls}: {len(hits)} objects in the player data") - return hits[0] - - -def extract( - cat: Catalog, - master: Path, - player: PlayerData, - music_id: int, - out_dir: Path, - fmt: str = "flac", - options: LiveOptions | None = None, - *, - flac_level: int = FLAC_LEVEL, -) -> dict: - """Decode the live's sound cue sheets and write /audio/live-audio.json (module documentation). Returns a - summary. `options`: the note sound sets it offers are written as noteSe.groups, their sounds decoded after the - default ones. `flac_level`: ffmpeg's compression level of the FLAC files (cri.decode).""" - from ..formats import cri - from .score import master_table - - out_dir = Path(out_dir) - if cat.apk is None: - raise RuntimeError("liveaudio needs the Catalog opened with apk= (ACF, HCA key)") - tables = {t: master_table(master, t) for t in dict.fromkeys((*MUSIC_TABLES, *SOUND_TABLES))} - music_row, _ = music_sound(tables, music_id) - live = live_sounds(tables, options.se_patterns if options is not None else ()) - layouts = {} - - def sheet(name: str): - acb, layouts[name] = cri.acb_data(cat, name) - return acb["acb"], _decode(cat, name, out_dir, fmt, flac_level) - - entries, read = sound_entries(tables, list(dict.fromkeys([music_row["_id"], *live["ids"]])), sheet) - with ApkSet(cat.apk) as z: - acf = acf_info(z.read(ACF_IN_APK)) - svs, acd = (_player_mono(player, s) for s in SETTINGS_SCRIPTS) - doc = { - "musicId": music_id, - "spec": "nnnotes.content.liveaudio", - "sounds": entries, - "categories": category_volumes(svs, acd), - "acf": {"name": acf["name"], "buses": acf["buses"], "categories": acf["categories"]}, - "react": acf["react"], - "music": {"soundId": music_row["_id"], "volume": 1.0, "startTimeSec": 0.0}, - "noteSe": live["noteSe"], - "liveSe": {str(k): v for k, v in sorted(live["liveSe"].items())}, - "liveSeNames": LIVE_SE_TYPES, - "timeline": TIMELINE, - "voice": { - "decision": "caller", - "character": None, - "rule": "LotteryStartVoice: random deck member, random MasterLiveStartCharacterVoice row of that " - "character; finish voice from LotteryFinishVoice; no deck -> undefined", - "startVoiceSoundIds": [], - }, - "decoded": { - n: {"layout": layouts[n], "cues": len(cues), "streams": len(streams)} for n, (cues, streams) in read.items() - }, - } - write_json(out_dir / "audio" / "live-audio.json", doc) - return { - "index": "audio/live-audio.json", - "sheets": sorted(read), - "sounds": len(entries), - "musicSoundId": music_row["_id"], - } diff --git a/src/nnnotes/content/livenotes.py b/src/nnnotes/content/livenotes.py index 01e136f..3d35d62 100644 --- a/src/nnnotes/content/livenotes.py +++ b/src/nnnotes/content/livenotes.py @@ -1,48 +1,11 @@ -"""The note highway of a live: what is spawned or animated per note, and its settings. - -Three products, each read from a product's object table (`Objects`, pipeline.assemble), never from a bundle, and each -shared by every live that loads it: - -notes the highway itself (`notes`, notes.json format NOTES_FORMAT): the note view prefabs (tap, flick - +left/right, trace, slide/slide_end/connection, guide, slide_combo(+skip), none), the long-note body - prefabs (slide_line_view, guide_line_view), the pair line and the groove loop (NOTE_PREFABS), the lane - effect settings (LANE_EFFECT_KEY, the set the game always loads: LaneEffectLoadStep.GetAssetPath), and - `settings`: the option defaults (preset 1) and ranges of the items the note runtime and the player read, - the MasterLiveSettings values, the note display time, the lane geometry, the default note skin and effect - set and the names of every note skin and effect set by option value (notes_settings) -noteskin a note skin (`noteskin`, MasterLiveNoteSkin._assetName: the LiveNoteSkinAsset of its key skin_key), with - the bindings of the arrow loops its flick units give the flick views (FLICK_SKIN_UNITS) -noteeffect a note effect set (`noteeffect`, MasterLiveNoteEffectSkin._assetName or its `Light` variant, which the - game loads at quality Low where the catalog has it: the LiveNoteEffectAssetSettings of its key - effect_settings_key and the effect prefabs it references) - -Each document names objects by id: notes.json `prefabs` {name: GameObject} and `laneEffect`, noteskin.json `skin`, -noteeffect.json `settings`; and each has - - bindings {prefab GameObject id: {Animator node path: {clip id: [{path, attribute}]}}}: the clips each Animator of - the table's prefabs plays (its controller's clips, the `_loopAnim` of a LoopAnimationTimeSync on its - `_animator`; for a skin, the arrow loop of each flick unit on the flick view's LoopAnimationTimeSync, - LiveFlickNoteView.OnSetupNoteSkin), named from the Animator's node (prefabs.clip_bindings) - shaders {shader name: object id}: what a lookup by name finds (prefabs.shader_table; a material names its - shader by id) - -Of the objects a product keeps all but the MonoScripts; of a shader its parsed form, variant index and the programs of -the player's graphics API; of a Mesh its typetree JSON and its `arrays` document (prefabs.mesh_roles). - -`extract` writes the note runtime of a live directory instead (livenotes/notes.json of the default options and the -variants a liveoptions.LiveOptions offers, with textures and shaders) through export.Exporter, following references -to AnimatorControllers and SpriteAtlases. -""" +"""Note, skin and effect products assembled from shared object tables and master records.""" from __future__ import annotations import re from collections.abc import Iterable, Mapping -from pathlib import Path -from typing import TYPE_CHECKING -from ..core.jsonio import write_json -from .liveoptions import OPTION_ITEM_TYPES, PLAYER_ITEMS, LiveOptions +from .liveoptions import OPTION_ITEM_TYPES, PLAYER_ITEMS from .prefabs import ( Objects, Prefab, @@ -57,11 +20,6 @@ shader_table, ) -if TYPE_CHECKING: - from ..source.catalog import Catalog - from ..unity.export import Exporter - from ..unity.player import PlayerData - __all__ = [ "EFFECT_KEY", "FLICK_SKIN_UNITS", @@ -74,7 +32,6 @@ "asset_name", "default_assets", "effect_settings_key", - "extract", "notes", "notes_keys", "notes_record", @@ -82,7 +39,6 @@ "noteeffect", "noteskin", "noteskin_keys", - "settings", "skin_key", ] @@ -189,8 +145,6 @@ "MasterLiveNoteSkin", "MasterLiveNoteEffectSkin", ) -DEFAULT_SKIN = "skin001" # MasterLiveNoteSkin._id 1 (NoteDesignId default 1) -DEFAULT_EFFECT = "effect001" # MasterLiveNoteEffectSkin._id 1 (NoteEffectId default 1) def skin_key(name: str) -> str: @@ -383,72 +337,12 @@ def noteeffect(objects: Objects, name: str) -> tuple[dict, set[str], dict[str, s return _product(objects, doc) -# ------------------------------------------------------------------ live directory (export.Exporter) -PREFABS = {**NOTE_PREFABS, "judge_effect_view": "EmbLive/Prefabs/UILiveNoteJudgeEffectView"} # Catalog key families exported whole (every key under the prefix): the note effect settings of the NoteEffectId # option's set ({effect}) and the lane effect settings (LANE_EFFECT), with the effect prefabs they reference -KEY_PREFIXES = [ - "Effect/Live/NoteEffect/{effect}/", - f"Effect/Live/LaneEffect/{LANE_EFFECT}/", -] -SINGLE_KEYS = [ - "EmbLive/Animation/Combo/UILiveCombo", # UIComboCounterView's animator controller - "Live/UiSpriteAssets/LiveComboSpriteAsset", - "Live/UiSpriteAssets/LiveJudgementSpriteAsset", -] - - -def settings(master: Path | None, options: LiveOptions = LiveOptions()) -> dict: - """The live directory's notes.json `settings`: option defaults and master values the note runtime reads; with - `options` also the offered note skins (`skins`: NoteDesignId -> MasterLiveNoteSkin._assetName) and effect sets - (`effects`: NoteEffectId -> MasterLiveNoteEffectSkin._assetName; also when only other qualities are offered, - whose effect set name the player derives from it), and with its option tables the items of PLAYER_ITEMS as - well.""" - from .score import master_table - - out: dict = {"skin": DEFAULT_SKIN, "effect": DEFAULT_EFFECT, **_lane_settings()} - if master is None: - return out - items = OPTION_ITEMS - if options.tables: - items = {**OPTION_ITEMS, **{k: OPTION_ITEM_TYPES[k] for k in PLAYER_ITEMS}} - opts = {} - for r in master_table(master, "MasterOptionDefault"): - if r["_presetId"] == 1 and r["_optionItemType"] in items: - opts[items[r["_optionItemType"]]] = r["_valueString"] - out["optionDefaults"] = opts - out["optionRanges"] = { - items.get(r["_optionItemType"], r["_optionItemType"]): [r["_minValue"], r["_maxValue"]] - for r in master_table(master, "MasterOptionRange") - if r["_optionItemType"] in items - } - ls = {r["_key"]: r["_value"] for r in master_table(master, "MasterLiveSettings")} - out["liveSettings"] = {k: ls[k] for k in LIVE_SETTING_KEYS if k in ls} - skins = {r["_id"]: r["_assetName"] for r in master_table(master, "MasterLiveNoteSkin")} - effects = {r["_id"]: r["_assetName"] for r in master_table(master, "MasterLiveNoteEffectSkin")} - out["skin"] = skins[int(opts["NoteDesignId"])] - out["effect"] = effects[int(opts["NoteEffectId"])] - out["judgementSprites"] = { - r["_noteSimulateJudgement"]: r["_spriteName"] for r in master_table(master, "MasterLiveJudgementSprite") - } - out["noteDisplayTimeMs"] = display_time_ms( - float(opts["NoteSpeed"]), - float(ls["note_speed_min"]), - float(ls["note_speed_max"]), - float(ls["note_speed_view_min"]), - float(ls["note_speed_view_max"]), - ) - if options.designs: - out["skins"] = {str(i): skins[i] for i in options.designs} - if options.effects or options.qualities: - out["effects"] = {str(i): effects[i] for i in options.values("NoteEffectId")} - return out # UI subtrees of [EmbScene/Live] animated by exported clips (combo counter, judgement text); used only to name # clip binding paths, the scene itself is exported by livescene. -SCENE_KEY = "EmbScene/Live" -SCENE_SUBTREES = ("UILiveCombo", "UILiveJudgement") # The bar line view (MeasureLineDisplay): the scene's LiveAllNoteView -> _barLineView (LiveAllBarLineView) -> # _container (LiveBarLineViewContainer) -> _elementPrefab (LiveBarLineView, the prefab the container instantiates) NOTE_VIEW = ("LiveGameView/LiveGameCamera/screen_root/LiveGameAllNoteView", "LiveAllNoteView") @@ -457,170 +351,3 @@ def settings(master: Path | None, options: LiveOptions = LiveOptions()) -> dict: ("_container", "LiveBarLineViewContainer"), ("_elementPrefab", "LiveBarLineView"), ) -BAR_LINE_VIEW = "bar_line_view" - - -TMP_FONT_NOTE = ( - "not exported: the note highway draws no TextMeshPro text with default options (the judge view's " - "fast / slow and assist texts stay hidden)" -) - - -def _drop_tmp_fonts(doc: dict, root: Path) -> list[str]: - """Reduce the font asset and material of every TextMeshProUGUI record to a name stub and delete the texture - files no record references any more (the TMP font atlases). Returns the deleted files.""" - - def walk(v): - if isinstance(v, dict): - if v.get("class") == "TextMeshProUGUI" and "m_fontAsset" in v: - fa, mat = v["m_fontAsset"], v.get("m_sharedMaterial") - if isinstance(fa, dict): - v["m_fontAsset"] = {"asset": fa.get("asset"), "name": fa.get("name"), "note": TMP_FONT_NOTE} - if isinstance(mat, dict): - v["m_sharedMaterial"] = { - "material": mat.get("material"), - "shader": mat.get("shader"), - "note": TMP_FONT_NOTE, - } - for x in v.values(): - walk(x) - elif isinstance(v, list): - for x in v: - walk(x) - - walk(doc) - used: set[str] = set() - - def refs(v): - if isinstance(v, dict): - t = v.get("texture") - if isinstance(t, str): - used.add(t) - for x in v.values(): - refs(x) - elif isinstance(v, list): - for x in v: - refs(x) - - refs(doc) - dropped = [] - for f in sorted((root / "textures").glob("*.png")): - rel = f"textures/{f.name}" - if rel not in used: - f.unlink() - dropped.append(rel) - return dropped - - -def effect_keys(cat: Catalog, effect: str) -> list[str]: - """The catalog keys of note effect set `effect` and of the lane effects (KEY_PREFIXES), in prefix and catalog - order.""" - keys: list[str] = [] - for p in KEY_PREFIXES: - keys += [k for k in cat.keys(p.format(effect=effect)) if k not in keys] - return keys - - -def _scene_mono(ex: Exporter, o, cls: str): - if o is None or o.type.name != "MonoBehaviour" or ex.script_class(o) != cls: - raise RuntimeError(f"expected a {cls} MonoBehaviour, got {o and o.type.name}") - return o, o.read_typetree() - - -def bar_line_prefab(ex: Exporter) -> dict: - """The bar line view as a node list: the LiveBarLineView prefab the live scene's bar line container - instantiates (BAR_LINE_CHAIN from the scene's LiveAllNoteView), its root first.""" - env, graph = ex.load(SCENE_KEY) - path, cls = NOTE_VIEW - gos = set(graph.gos_at_path(path)) - comps = [c["component"] for go in gos for c in graph.go[go]["m_Component"]] - owners = [o for o in env.objects if o.type.name == "GameObject" and o.path_id in gos] - if len(gos) != 1 or len(owners) != 1: - raise RuntimeError(f"{SCENE_KEY}: {len(gos)} GameObjects at {path}") - hits = [ - c - for c in (ex.deref(owners[0], p) for p in comps) - if c is not None and c.type.name == "MonoBehaviour" and ex.script_class(c) == cls - ] - if len(hits) != 1: - raise RuntimeError(f"{SCENE_KEY}: {len(hits)} {cls} at {path}") - o, tt = _scene_mono(ex, hits[0], cls) - for field, want in BAR_LINE_CHAIN: - o, tt = _scene_mono(ex, ex.deref(o, tt[field]), want) - root = graph.tf_of_go.get(tt["m_GameObject"]["m_PathID"]) - if root is None or graph.tf[root]["m_Father"]["m_PathID"]: - raise RuntimeError(f"{SCENE_KEY}: the bar line view is not a prefab root of the scene's bundles") - return { - "key": SCENE_KEY, - "reference": ".".join(f for f, _ in BAR_LINE_CHAIN), - "nodes": ex.hierarchy(env, graph, root), - } - - -def extract( - cat: Catalog, player: PlayerData, out_dir: Path, master: Path | None = None, options: LiveOptions = LiveOptions() -) -> dict: - """livenotes/ for the default options, plus the files of the variants `options` offers (the module - docstring). The variants' objects are exported after the default ones.""" - from ..unity.export import Exporter - - out_dir = Path(out_dir) - root = out_dir / "livenotes" - root.mkdir(parents=True, exist_ok=True) - ex = Exporter(cat, root, player=player, clip_format="livenotes", follow=("AnimatorController", "SpriteAtlas")) - doc: dict = {"settings": settings(master, options)} - skin = doc["settings"]["skin"] - - # note / line prefabs first: their hierarchies resolve clip binding paths of skin/effect assets - doc["prefabs"] = {name: ex.export_key(key) for name, key in PREFABS.items()} - doc["noteSkin"] = ex.export_key(skin_key(skin)) - keys = effect_keys(cat, doc["settings"]["effect"]) - keys += [k for k in SINGLE_KEYS if cat.has(k) and k not in keys] - assets, failures = {}, {} - - def export(ks): - for k in ks: - try: - assets[k] = ex.export_key(k) - except NotImplementedError as e: - failures[k] = str(e) - - export(keys) - other_skins = [n for n in doc["settings"].get("skins", {}).values() if n != skin] - if other_skins: - doc["noteSkins"] = {n: ex.export_key(skin_key(n)) for n in other_skins} - names = {int(i): n for i, n in doc["settings"].get("effects", {}).items()} - for effect, light in options.effect_sets(names): - if not cat.has(effect_settings_key(effect)): - if light: - continue # the game loads the set itself at quality Low (LiveOptions.effect_sets) - raise KeyError(f"effect set {effect}: {effect_settings_key(effect)} not in the catalog") - export([k for k in effect_keys(cat, effect) if k not in assets and k not in failures]) - if options.bar_lines: - doc["prefabs"][BAR_LINE_VIEW] = bar_line_prefab(ex) - doc["assets"] = assets - ex.add_path_source(SCENE_KEY, SCENE_SUBTREES) - unresolved = ex.resolve_pending() - doc["clips"] = ex.clips - doc["controllers"] = ex.controllers - doc["exportFailures"] = failures - - dropped = _drop_tmp_fonts(doc, root) - _, shader_summary = ex.dump_shaders(root / "shaders") - doc["shaders"] = {"index": "shaders/shaders.json", "names": shader_summary["names"]} - doc["convention"] = ( - "Unity space (left-handed, Y up); transforms are local TRS; quaternions (x,y,z,w); " - "colours as serialized (project colour space: see player.json)" - ) - write_json(root / "notes.json", doc) - return { - "prefabs": len(doc["prefabs"]), - "assets": len(assets), - "clips": len(ex.clips), - "controllers": len(ex.controllers), - "textures": len(ex.textures) - len(dropped), - "droppedFontTextures": dropped, - "failures": failures, - "unresolvedBindings": unresolved, - **shader_summary, - } diff --git a/src/nnnotes/content/livescene.py b/src/nnnotes/content/livescene.py index 092870a..29c79a9 100644 --- a/src/nnnotes/content/livescene.py +++ b/src/nnnotes/content/livescene.py @@ -1,71 +1,8 @@ -"""The scene of a live: what exists on the live screen before the first note, and the start timeline of each band. - -One product (`scene`, scene.json format SCENE_FORMAT), read from a product's object table (`Objects`, -pipeline.assemble), never from a bundle, and shared by every live: - - scene the root GameObjects of the addressable scene EmbScene/Live, each a prefab document (the Live - prefab instance with the cameras, render canvas, background view and UI canvases; LiveGameView - with the lane camera, the lane and the note and effect containers; LiveStartTimelineDirector); - their references to each other are `$ref`s to components (prefabs.component_ref) - renderSettings the scene's RenderSettings - graphics the built-in settings the render path reads, by id: `graphics` (GraphicsSettings: the render - pipeline asset, its renderers and their post-process data), `quality` (QualitySettings), `player` - (PlayerSettings: the colour space) - laneSkins {lane skin asset name: {part: Sprite}} of the lane skins of MasterLiveLaneSkin (LANE_SKIN_PARTS) - prefabs `laneLine` (LiveLaneLineView._linePrefab), `judgeEffectView` (UILiveNoteJudgeEffectView) and - `barLineView` (the LiveBarLineView prefab the scene's bar line container instantiates, - livenotes.BAR_LINE_CHAIN) - spriteAssets the combo and judgement sprite assets of the live UI (SPRITE_ASSETS) - bands {band id: {timeline, background, tracks}} of the bands of MasterBand whose start timeline the - catalog holds: the live_start_playable_timeline prefab (LiveStartTimelineLoadStep), the - LightWeight background sprite (LightWeightBackgroundLoadStep) and {AnimationTrack id: {name, - prefab, animator}}, the Animator each track of the timeline plays on (TIMELINE_TRACK_ANIMATORS) - settings the option defaults (preset 1) and ranges of the items the render path reads (OPTION_ITEMS), the - MasterLiveQualitySettings rows, the default lane skin and every lane skin by option value, and - the judgement sprite names by judgement (scene_settings) - bindings {prefab GameObject id: {Animator node path: {clip id: [{path, attribute}]}}}: the clips each - Animator plays through its controller and LoopAnimationTimeSyncs (livenotes.animator_bindings) - and the clips of each band's timeline tracks on theirs - shaders {shader name: object id}: what a lookup by name finds (prefabs.shader_table; of different - shaders of one name the one GraphicsSettings always includes), holding the shaders the live - screen finds by name (PLAYER_SHADERS); a material names its shader by id - -A live plays the timeline and the background of one band: the band of the player's deck centre, which the live -chooses. Of the objects the product keeps all but the MonoScripts; of a shader its parsed form, variant index and the -programs of the player's graphics API; of a Mesh its typetree JSON and its `arrays` document (prefabs.mesh_roles). - -`extract` writes the scene of a live directory instead (livescene/scene.json with textures and shaders) through -export.Exporter in its "livescene" clip format (clips inlined once, bindings with crc32 keys, path candidates and -curve counts): - - player colour space, quality levels, URP pipelines, renderer data (player.py) - scene the scene as a node list - laneSkin lane skin sprites of the LiveSkinId option's MasterLiveLaneSkin row - assets further prefabs by key: the start timeline of the band, the lane line prefab - master master rows the render path reads (music, quality settings, option defaults) - derived lane geometry computed with the game's formulas for the default options - (each entry names the function it mirrors) - postTextures film grain textures of the live renderers' post-process data - shaders every Shader in the loaded bundle closures, everything referenced, and the - shaders of the live camera renderers (GLES3 + other platforms, shader.py layout) - -Only what the LightWeight screen (mode 3) loads is exported (LiveResourceLoadPipelineFactory.Create): the mode-1 -stage (Band/{b}/live_stage/live_stage, its Volume Profile, StageBlinkLight), the background director and camera -blends are not loaded in mode 3; EmbLive/Volume/LiveStageVolume is referenced by nothing; the LiveGameVolume -profile is part of the scene (LiveEffectCamera's Volume). Only the music row, the slice and the jacket sprite depend -on the music: `band_part` exports the rest once for a band and `music_part` completes it for each music of that band -(the same document as `extract`). -""" +"""Live scene products assembled from shared prefab, shader and master records.""" from __future__ import annotations -import json -import math -from pathlib import Path -from typing import TYPE_CHECKING - -from ..core.jsonio import write_json -from .livenotes import BAR_LINE_CHAIN, LANE_COUNT, LANE_VIEW, NOTE_VIEW, animator_bindings +from .livenotes import BAR_LINE_CHAIN, NOTE_VIEW, animator_bindings from .liveoptions import OPTION_ITEM_TYPES from .prefabs import ( Objects, @@ -80,9 +17,6 @@ shader_table, ) -if TYPE_CHECKING: - from ..unity.export import Exporter - # --- keys ------------------------------------------------------------------- SCENE_KEY = "EmbScene/Live" LANE_SKIN_PARTS = ("lane_base", "lane_tap_area", "out_side_line") @@ -112,7 +46,6 @@ # Scene nodes whose textures the LightWeight preview draws (Live/RenderCanvas images); # every other texture of the scene prefab belongs to UI the preview does not show (headers, gauges, pause, gekisou, # skill cut-in, finish direction, mode-1 background) and is not written: its record stays, with "exported": false. -SCENE_DRAWN_TEXTURE_NODES = ("Live/RenderCanvas/LightWeightBackgroundShadowImage",) # shaders the live screen finds by name (GraphicsSettings always-included, APK player data): # LightWeight composite and blur, MV/VJ video PLAYER_SHADERS = ( @@ -123,7 +56,6 @@ "UI/Default", ) # URP renderer indices used by the scene's live cameras -LIVE_RENDERERS = (2, 3, 4, 5) # option items the render path reads OPTION_ITEMS = { k: OPTION_ITEM_TYPES[k] @@ -158,166 +90,9 @@ def lane_skin_key(name: str, part: str) -> str: return f"Live/Lane/{name}/{part}" -def master_rows(master: Path, music_id: int) -> dict: - from .score import master_table - - music = next(r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id) - opts = { - r["_optionItemType"]: r["_valueString"] - for r in master_table(master, "MasterOptionDefault") - if r["_presetId"] == 1 and r["_optionItemType"] in OPTION_ITEMS - } - return { - "liveMusic": music, - "liveQualitySettings": master_table(master, "MasterLiveQualitySettings"), - "liveLaneSkin": master_table(master, "MasterLiveLaneSkin"), - "liveStageVideo": master_table(master, "MasterLiveStageVideo"), - "optionDefaultsPreset1": {OPTION_ITEMS[k]: {"id": k, "value": v} for k, v in sorted(opts.items())}, - "optionRanges": { - OPTION_ITEMS[r["_optionItemType"]]: [r["_minValue"], r["_maxValue"]] - for r in master_table(master, "MasterOptionRange") - if r["_optionItemType"] in OPTION_ITEMS - }, - } - - # --- derived lane geometry (mirrors the game functions named per entry) --- # LiveLaneLineView's constant tables: split border main / sub counts per LiveLaneSplitCountType # (None, Lane4, Lane6, Lane8, Lane12) -SPLIT_MAIN = (0, 4, 6, 8, 12) -SPLIT_SUB = (12, 12, 12, 0, 0) -REF_ASPECT = 1.7777778 # LiveGameView.FullInitialize -REF_FOV = 54.0 - - -def vertical_fov(width: int, height: int) -> float: - """LiveGameView.CalcVerticalFovKeepingHorizontal, aspect clamped to <= 16:9 by the caller.""" - a = min(width / height, REF_ASPECT) - h = math.atan(math.tan(math.radians(REF_FOV) * 0.5) * REF_ASPECT) - return math.degrees(2 * math.atan(math.tan(h) / a)) - - -def _node(nodes: list, path: str) -> dict: - hit = [n for n in nodes if n["path"] == path] - if len(hit) != 1: - raise KeyError(f"{path}: {len(hit)} nodes") - return hit[0] - - -def _comp(node: dict, cls: str) -> dict: - hit = [c for c in node["components"] if c.get("class", c["type"]) == cls] - if len(hit) != 1: - raise KeyError(f"{node['path']}: {len(hit)} {cls}") - return hit[0] - - -def derive_lane(nodes: list, options: dict) -> dict: - """Lane geometry for the given option values (MasterOptionDefault preset 1 strings).""" - import numpy as np - - lv = _comp(_node(nodes, "LiveGameView/root/LiveGameLane"), "LiveLaneView") - llv = _comp(_node(nodes, "LiveGameView/root/LiveGameLane/lines"), "LiveLaneLineView") - jr = _node(nodes, "LiveGameView/root/LiveGameLane/judgement_root") - n = LANE_COUNT - width = float(np.float32(lv["_laneWidth"])) # double field, passed on as (float) - length = lv["_lineLength"] - unit = width / n - judge_z = jr["localPosition"]["z"] # parents are identity: world z of position 0 - split = int(options["GuidelineCount"]) - main, sub = SPLIT_MAIN[split], SPLIT_SUB[split] - settings = {s["LineType"]: s for s in llv["_settings"]} - lines = [] - for i in range(n + 1): # LiveLaneLineView.UpdateProperties - if i == 0 or i == n: - t = 1 # GetSettings: OutSide - elif main and i % (n // main) == 0: - t = 0 # Normal - elif sub and i % (n // sub) == 0: - t = 2 # Space - else: - lines.append({"index": i, "active": False}) - continue - st = settings[t] - x = -width * 0.5 + i * unit - if t == 2: - ln, z0 = llv["_spaceLineLength"], judge_z - llv["_spaceLineLength"] * 0.5 - else: - ln, z0 = length, 0.0 - to_w = st["LineWidthTo"] * 2 if (i == n // 2 and t != 2) else st["LineWidthTo"] - # LiveLaneLine.SetLine: _anchor 2 (left side line) x -= startWidth/2, _anchor 1 (right) x += startWidth/2 - if i == 0: - x -= st["LineWidthFrom"] * 0.5 - elif i == n: - x += st["LineWidthFrom"] * 0.5 - lines.append( - { - "index": i, - "active": True, - "type": ("Normal", "OutSide", "Space")[t], - "start": [x, 0.0, z0], - "end": [x, 0.0, z0 + ln], - "startWidth": st["LineWidthFrom"], - "endWidth": to_w, - "gradient": st["LineColor"], - "alphaControlled": t != 1, - } - ) - return { - "laneCount": n, - "laneWidth": width, - "unitWidth": unit, - "lineLength": length, - "judgementZ": judge_z, - "unitCentersX": [unit * (i + 0.5) - width * 0.5 for i in range(n)], - "spawnRoots": [[unit * (i + 0.5) - width * 0.5, 0.0, length] for i in range(n)], - "judgementPositions": [[unit * (i + 0.5) - width * 0.5, 0.0, judge_z] for i in range(n)], - "splitCountType": split, - "splitBorderMainCount": main, - "splitBorderSubCount": sub, - "lines": lines, - "lineAlpha": min(max(int(options["GuidelineOpacity"]) / 100.0, 0.0), 1.0), - "laneBaseAlpha": min(max(int(options["LaneOpacity"]) / 100.0, 0.0), 1.0), - "showJudgementLine": options["JudgePositionDisplay"].upper() == "TRUE", - "laneJudgementPosOffset": (int(options["JudgePosition"]) + 5) / -10.0 + 1.0, - "screenPlane": lane_2d(), - "sources": { - "lines": "LiveLaneLineView.UpdateProperties, GetSettings, LiveLaneLine.SetLine", - "spawnRoots": "LiveNoteSpawnRoot.UpdateProperties", - "judgementPositions": "LiveNoteJudgementRoot3D.UpdateProperties", - "options": "LiveBootDataCreator.CreateViewData", - "screenPlane": "LiveSettingCreator.CreateLaneViewSettings, JudgementPositionLogic.GetJudgementPositions, " - "LiveViewUtility.GetNoteSpawnPosition", - }, - } - - -def lane_2d(lane_count: int = LANE_COUNT) -> dict: - """LiveSettingCreator.CreateLaneViewSettings as CreateViewData calls it (LANE_VIEW: size 1920x1080, - topRange 0.05, bottomRange 1, judgementScreenBottomPosition 2.24, topPosition 0) and - JudgementPositionLogic.GetJudgementPositions; units of 100 reference pixels.""" - w, h = LANE_VIEW["size"][0] / 100.0, LANE_VIEW["size"][1] / 100.0 - top, bottom = LANE_VIEW["topRange"], LANE_VIEW["bottomRange"] - lt, rt = [-w * top * 0.5, h * 0.5, 0.0], [w * top * 0.5, h * 0.5, 0.0] - lb, rb = [-w * bottom * 0.5, -h * 0.5, 0.0], [w * bottom * 0.5, -h * 0.5, 0.0] - t = min(max(LANE_VIEW["judgementScreenBottomPosition"] / (lt[1] - lb[1]), 0.0), 1.0) - jp = [] - for i in range(lane_count): - f = min(max(i / lane_count, 0.0), 1.0) - xb = (rb[0] - lb[0]) / lane_count * 0.5 + lb[0] + (rb[0] - lb[0]) * f - xt = (rt[0] - lt[0]) / lane_count * 0.5 + lt[0] + (rt[0] - lt[0]) * f - yb, yt = lb[1] + (rb[1] - lb[1]) * f, lt[1] + (rt[1] - lt[1]) * f - jp.append([xb + t * (xt - xb), yb + (yt - yb) * t, 0.0]) - # LiveViewUtility.GetNoteSpawnPosition: intersection of the two side edges - s1 = (lb[1] - lt[1]) / (lb[0] - lt[0]) - s2 = (rb[1] - rt[1]) / (rb[0] - rt[0]) - c1 = lt[1] - lt[0] * s1 - sx = ((rt[1] - rt[0] * s2) - c1) / (s1 - s2) - return { - "vertices": [lt, rt, lb, rb], - "judgementPositions": jp, - "spawnPosition": [sx, c1 + s1 * sx], - "pixelsPerUnit": 100.0, - } # --- product ---------------------------------------------------------------- @@ -499,285 +274,3 @@ def scene(objects: Objects, record: dict) -> tuple[dict, set[str], dict[str, set # --- extraction ------------------------------------------------------------- -def _component(nodes: list, cls: str) -> dict: - hit = [c for n in nodes for c in n["components"] if c.get("class", c["type"]) == cls] - if len(hit) != 1: - raise KeyError(f"{len(hit)} {cls} components") - return hit[0] - - -def bind_timeline(ex: Exporter, doc: dict, key: str) -> dict: - """Bind the clips of the start timeline's tracks to the Animators the game binds them to - (TIMELINE_TRACK_ANIMATORS): binding paths resolve against that GameObject's subtree, and each track records - `boundAnimator` {key, gameObject, field}. Returns binding counts per track.""" - tl = doc["assets"]["startTimeline"] - env, tl_graph = ex.load(key) - scene_graph = ex.load(SCENE_KEY)[1] - dir_json = _component(tl["nodes"], "PlayableDirector") - dir_path = next(n["path"] for n in tl["nodes"] if any(c is dir_json for c in n["components"])) - directors = [ - o - for o in env.objects - if o.type.name == "PlayableDirector" - and tl_graph.path(tl_graph.tf_of_go[o.read_typetree()["m_GameObject"]["m_PathID"]]) == dir_path - ] - if len(directors) != 1: - raise RuntimeError(f"{key}: {len(directors)} PlayableDirectors at {dir_path}") - asset_o = ex.deref(directors[0], directors[0].read_typetree()["m_PlayableAsset"]) - tracks_json = {t["m_Name"]: t for t in dir_json["m_PlayableAsset"]["m_Tracks"]} - counts = {} - for _track_o, tt, clips in ex.timeline_tracks(asset_o): - name = tt["m_Name"] - if name not in TIMELINE_TRACK_ANIMATORS: - if clips: - raise RuntimeError(f"{key}: track {name} with clips has no known binding") - continue - where, cls, field = TIMELINE_TRACK_ANIMATORS[name] - nodes, graph, owner = ( - (doc["scene"]["nodes"], scene_graph, SCENE_KEY) if where == "scene" else (tl["nodes"], tl_graph, key) - ) - ref = _component(nodes, cls)[field] - if not ref or ref.get("component") != "Animator": - raise RuntimeError(f"{cls}.{field}: {ref}") - root = ref["gameObject"] - tracks_json[name]["boundAnimator"] = {"key": owner, "gameObject": root, "field": f"{cls}.{field}"} - counts[name] = [ex.bind_clip(c, graph, root) for c in clips] - return counts - - -def lane_skin(rows: dict) -> str: - """Lane skin asset name: option LiveSkinId (305) -> MasterLiveLaneSkin row of that id (LaneSkinLoadStep).""" - sid = int(rows["optionDefaultsPreset1"]["LiveSkinId"]["value"]) - hit = [r for r in rows["liveLaneSkin"] if r["_id"] == sid] - if len(hit) != 1: - raise KeyError(f"MasterLiveLaneSkin: {len(hit)} rows with id {sid} (option LiveSkinId)") - return hit[0]["_assetName"] - - -def _texture_records(v, out: list) -> None: - if isinstance(v, dict): - if isinstance(v.get("texture"), str) and v["texture"].startswith("textures/"): - out.append(v) - for x in v.values(): - _texture_records(x, out) - elif isinstance(v, list): - for x in v: - _texture_records(x, out) - - -def _drop_scene_textures(doc: dict, base: Path, unlink: bool = True) -> list[str]: - """The texture files only the undrawn part of the scene prefab references (SCENE_DRAWN_TEXTURE_NODES): their - records marked "exported": false, the files deleted (`unlink`; else the caller does not write them). Returns - the files.""" - keep: list = [] - for k, v in doc.items(): - if k != "scene": - _texture_records(v, keep) - for n in doc["scene"]["nodes"]: - if n["path"] in SCENE_DRAWN_TEXTURE_NODES: - _texture_records(n["components"], keep) - kept = {r["texture"] for r in keep} - scene: list = [] - _texture_records(doc["scene"], scene) - dropped = sorted({r["texture"] for r in scene} - kept) - for r in scene: - if r["texture"] in dropped: - r["exported"] = False - if unlink: - for rel in dropped: - (base / rel).unlink() - return dropped - - -class SharedSceneMismatch(RuntimeError): - """A music's scene cannot be composed from its band's part (see music_part); export it whole.""" - - -def _export( - cat, - player, - base: Path, - rows: dict | None, - band: int, - skin: str, - extra_keys: dict | None, - slice_: dict | None, - jacket: str | None, -): - """The export sequence of extract (jacket None: without the jacket sprite, the slice and the music rows left as - placeholders for music_part). -> (exporter, doc, shader index, shader summary, timeline counts, dropped).""" - from ..unity.export import Exporter - - ex = Exporter(cat, base, player=player, clip_format="livescene", defer_writes=True) - graphics = player.graphics() - doc: dict = {"player": graphics} - doc["slice"] = slice_ - doc["scene"] = ex.prefab(SCENE_KEY) - doc["laneSkin"] = {part: ex.key_sprite(f"Live/Lane/{skin}/{part}") for part in LANE_SKIN_PARTS} - keys = dict(ASSET_KEYS) - keys.update(extra_keys or {}) - doc["assets"] = {name: ex.export_key(key.format(band=band)) for name, key in keys.items()} - timeline = bind_timeline(ex, doc, keys["startTimeline"].format(band=band)) - doc["sprites"] = { - name: ex.key_sprite(key.format(band=band, jacket=jacket)) - for name, key in SPRITE_KEYS.items() - if jacket is not None or name != "jacket" - } - if rows: - doc["master"] = rows if jacket is not None else None - opts = {k: v["value"] for k, v in rows["optionDefaultsPreset1"].items()} - doc["derived"] = { - "lane": derive_lane(doc["scene"]["nodes"], opts), - "fovRule": { - "refFov": REF_FOV, - "refAspect": REF_ASPECT, - "source": "LiveGameView.FullInitialize / CalcVerticalFovKeepingHorizontal", - "examples": { - f"{w}x{h}": vertical_fov(w, h) for w, h in ((2340, 1080), (1920, 1080), (1440, 1080), (2048, 1536)) - }, - }, - } - for name in PLAYER_SHADERS: - ex.add_shader(player.shader(name)) - rl = graphics["pipelines"][graphics["defaultPipeline"]]["m_RendererDataList"] - renderers = [rl[i] for i in LIVE_RENDERERS] - for rn in renderers: - for o in player.renderer_shaders(rn): - ex.add_shader(o) - doc["postTextures"] = {} - for rn in renderers: - if graphics["renderers"][rn].get("postProcessData"): - doc["postTextures"][rn] = { - "filmGrainTex": [ex.texture(o) for o in player.post_textures(rn, "filmGrainTex")] - } - index, summary = ex.dump_shaders(base / "shaders") - doc["shaders"] = {"index": "shaders/shaders.json", "names": summary["names"], "cameraRenderers": renderers} - doc["convention"] = ( - "Unity space (left-handed, Y up); transforms are local TRS; " - "quaternions (x,y,z,w); colours in the project's Gamma space" - ) - ex.resolve_pending() # bindings whose target hierarchy / component class was exported after the clip - dropped = _drop_scene_textures(doc, base, unlink=False) - ex.write_textures(skip=dropped) - return ex, doc, index, summary, timeline, dropped - - -def _summary(doc: dict, index: list, summary: dict, textures: int, dropped: list, timeline) -> dict: - gles3 = sum(1 for r in index for v in r["variants"] if v["platform"] == "gles3") - return { - "scene": "livescene/scene.json", - "nodes": len(doc["scene"]["nodes"]), - "assets": list(doc["assets"]), - "textures": textures - len(dropped), - "droppedTextures": len(dropped), - "shaders": summary["count"], - "variants": summary["variants"], - "gles3Variants": gles3, - "timelineBindings": timeline, - } - - -def extract( - cat, - player, - out_dir: Path, - master: Path | None = None, - music_id: int = 100001, - band: int | None = None, - band_choice: dict | None = None, - extra_keys: dict | None = None, -) -> dict: - """Write /livescene/scene.json (+ textures/, shaders/) and return a summary. - - `band`: the band of the LightWeight background (LightWeightBackgroundLoadStep.d__3: - SelfMemberList[2].BandID) and of the start timeline (LiveResourceBandResolver.Resolve). The game - takes it from the player's deck; the caller chooses it (live.resolve_band) and `band_choice` records how. - `master` (decoded master JSON dir) adds the music/option rows and the derived lane geometry; with `master` - the band is required. Without `master`: band 1 unless given, lane skin skin001.""" - out_dir = Path(out_dir) - rows = master_rows(master, music_id) if master else None - if band is None: - if rows: - raise ValueError( - f"music {music_id}: no band given; the LightWeight background and the start timeline " - "take the band of the deck centre (SelfMemberList[2]), see live.resolve_band" - ) - band = 1 - skin = lane_skin(rows) if rows else "skin001" - base = out_dir / "livescene" - base.mkdir(parents=True, exist_ok=True) - slice_ = {"musicId": music_id, "band": band, "laneSkin": skin} - if band_choice: - slice_["bandChoice"] = band_choice - jacket = rows["liveMusic"]["_jacketAssetName"] if rows else "jkt_001_100001" - ex, doc, index, summary, timeline, dropped = _export( - cat, player, base, rows, band, skin, extra_keys, slice_, jacket - ) - write_json(base / "scene.json", doc) - return _summary(doc, index, summary, len(ex.textures), dropped, timeline) - - -BAND_DOC = "band.json" - - -def band_part( - cat, player, out_dir: Path, master: Path, music_id: int, band: int, extra_keys: dict | None = None -) -> dict: - """What `extract` writes for any music of `band`: /livescene/ (textures, shaders; no scene.json) and - /band.json (the document with the slice, the jacket sprite and the music rows left out). `music_id`: - any music of the band (the option rows it reads are the same for every music).""" - out_dir = Path(out_dir) - rows = master_rows(master, music_id) - base = out_dir / "livescene" - base.mkdir(parents=True, exist_ok=True) - ex, doc, index, summary, timeline, dropped = _export( - cat, player, base, rows, band, lane_skin(rows), extra_keys, None, None - ) - part = { - "band": band, - "doc": doc, - "summary": _summary(doc, index, summary, len(ex.textures), dropped, timeline), - "textures": sorted({r["texture"] for r in _records(doc["scene"])}), - } - write_json(out_dir / BAND_DOC, part) - return part - - -def _records(v) -> list: - out: list = [] - _texture_records(v, out) - return out - - -def music_part( - cat, player, out_dir: Path, part: dict, master: Path, music_id: int, band_choice: dict | None = None -) -> dict: - """/livescene/scene.json of one music from its band's part (band_part; the part's livescene/ files - already in /livescene): the document extract writes. Raises SharedSceneMismatch when the jacket's - bundle closure holds shaders or a hierarchy (extract would dump or resolve against them) or the jacket texture - is one of the scene's (extract would keep it).""" - from ..unity.export import Exporter - - out_dir = Path(out_dir) - band = part["band"] - rows = master_rows(master, music_id) - doc = json.loads(json.dumps(part["doc"])) - key = SPRITE_KEYS["jacket"].format(band=band, jacket=rows["liveMusic"]["_jacketAssetName"]) - ex = Exporter(cat, out_dir / "livescene", player=player, clip_format="livescene") - env, _ = ex.load(key) - kinds = {o.type.name for o in env.objects} & {"Shader", "GameObject", "Transform", "RectTransform"} - if kinds: - raise SharedSceneMismatch(f"{key}: its closure holds {', '.join(sorted(kinds))}") - jacket = ex.key_sprite(key) - if {r["texture"] for r in _records(jacket)} & set(part["textures"]): - raise SharedSceneMismatch(f"{key}: its texture is one of the scene's") - slice_ = {"musicId": music_id, "band": band, "laneSkin": lane_skin(rows)} - if band_choice: - slice_["bandChoice"] = band_choice - doc["slice"] = slice_ - doc["sprites"]["jacket"] = jacket - doc["master"] = rows - write_json(out_dir / "livescene" / "scene.json", doc) - s = dict(part["summary"]) - s["textures"] += len(ex.textures) - return s diff --git a/src/nnnotes/content/liveui.py b/src/nnnotes/content/liveui.py index 0bf9d1d..a2732d6 100644 --- a/src/nnnotes/content/liveui.py +++ b/src/nnnotes/content/liveui.py @@ -1,42 +1,8 @@ -"""Live start canvas (Live/UILiveStartCanvas, LightWeight mode) -> /liveui/ (liveui.json, textures/). - -livescene/scene.json already carries the canvas hierarchy with every component (serialized TMP texts, inline -sprite records, the start timeline and its UILiveStartCanvasTrack clips) and livescene/shaders the shaders it -draws with. This module adds what a renderer needs beyond that to draw the canvas the way -UILiveStartCanvas.Initialize leaves it: - - strings the texts Initialize sets: music title, singer, the credit lines (LocalizeText master text id + - replacement word), music level, high score, resolved as the game resolves them - texts per TMP text of the drawn part: the LocalizeText font / material / line spacing overrides; with - fonts "open" also its text record (textstyle: layout, colours, style values of its material) - fonts fonts "game": TMP font assets reduced to the characters shown (tmpfont); FZLTH's runtime glyphs - generated from its source font; VibeMOPro's glyph pair adjustment lookup (kerning). fonts "open" - (default): the font asset names only, no font data and no atlases - materials fonts "game": the localized TMP materials (" - Default") and the fonts' default materials; - fonts "open": none - sprites WhiteRect, Songtitle_base, the Difficulty_* icons of _difficultyPairs and the music jacket; their - texel blocks copied into textures/ (export.TexelPacker, deferred-texture mode) - controller the root Animator's controller (evidence only: the start timeline drives the canvas) - -Texts and fonts follow the client language `language` (a catalog language code, languages.LANGUAGES): its -LanguageMode selects the text table column, the LocalizeManager fonts and the line spacing. -""" +"""Live start-canvas paths and localized strings from explicit master records.""" from __future__ import annotations -import json -from pathlib import Path -from typing import TYPE_CHECKING - -from ..core.jsonio import write_json from ..source import languages -from ..unity import fontsettings, textstyle -from ..unity.textstyle import LANGUAGE_LINE_SPACING - -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" @@ -44,39 +10,15 @@ # Parts drawn in LightWeight mode: root/base (a _simpleModeOnlyObjects entry), center_bottom and # simple_music_jacket_panel (left_top is _normalModeOnlyObjects, right_top/battle_panel is _battleLiveGameObjects). DRAWN = (f"{CANVAS}/root/base", f"{CONTENT}/center_bottom", f"{CONTENT}/simple_music_jacket_panel") -JACKET_KEY = "Image/Jacket/{jacket}" # App.LiveBase.LiveDifficulty LIVE_DIFFICULTY = {"easy": 0, "normal": 1, "hard": 2, "expert": 3, "master": 4} -TMP_STUBS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet") -TMP_CLASS = "TextMeshProUGUI" JAPANESE_COLUMN = languages.column("ja") -FONT_CHOICES = ("open", "game") -FONT_NOTE = 'not exported (fonts "open"): the text record in `texts` describes the text without font data' def _drawn(path: str) -> bool: return any(path == d or path.startswith(d + "/") for d in DRAWN) -def _comp(node: dict, cls: str) -> dict | None: - hit = [c for c in node["components"] if c.get("class", c["type"]) == cls] - if len(hit) > 1: - raise RuntimeError(f"{node['path']}: {len(hit)} {cls}") - return hit[0] if hit else 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).""" - 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) @@ -153,267 +95,3 @@ def credit(label_id: str, word_id: str) -> str: "_musicLevelTexts": "musicLevel", "_highScoreTexts": "highScore", } - - -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] = {} - primaries = set() - for fname in sorted(chars_by_font): - primary = fontset.by_key(fontsettings.font_dir(fname) + fname) - primaries.add(primary.name) - cov, nd, rt = tmpfont.glyph_coverage(fontset, primary, sorted(chars_by_font[fname])) - if cov["missing"]: - raise RuntimeError(f"{fname}: characters {cov['missing']} missing from the font chain") - coverage[fname] = cov - for k, v in nd.items(): - needed.setdefault(k, set()).update(v) - for k, v in rt.items(): - runtime_units.setdefault(k, set()).update(v) - materials, text_mats = tmpfont.text_materials( - ex, fontset, [t["localized"] for t in texts.values()], primaries, needed, runtime_units - ) - font_out = tmpfont.export_fonts( - fontset, - primaries, - needed, - runtime_units, - text_mats, - packer, - "characters shown by the live start canvas (plus baked control characters); " - "runtime characters generated from the source font (runtimeGlyphs)", - ) - return coverage, materials, font_out - - -def extract( - cat: Catalog, - player: PlayerData, - out_dir: Path, - master: Path, - music_id: int, - difficulty: str, - *, - language: str, - fonts: str = "open", -) -> dict: - """Write /liveui/ (liveui.json, textures/) for one live and return a summary. Needs - /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) - if difficulty not in LIVE_DIFFICULTY: - raise KeyError(f"difficulty {difficulty}") - if fonts not in FONT_CHOICES: - raise ValueError(f"fonts {fonts}: one of {', '.join(FONT_CHOICES)}") - mode, column = languages.mode(language), languages.column(language) - ex = Exporter( - cat, - base, - player=player, - textures="deferred", - stub_assets=TMP_STUBS, - follow=("AnimatorController",), - font_stubs=fontsettings.builtin() if fonts == "open" else None, - ) - packer = TexelPacker(base) - - # -- the canvas subtree of the scene (deferred: sprite / texture references) ------------------------- - env, graph = ex.load(SCENE_KEY) - roots = [p for p in graph.tf if graph.path(p) == CANVAS] - if len(roots) != 1: - raise RuntimeError(f"{CANVAS}: {len(roots)} transforms") - nodes = ex.hierarchy(env, graph, roots[0]) - by_path: dict[str, list] = {} - for n in nodes: - by_path.setdefault(n["path"], []).append(n) - canvas = _comp(by_path[CANVAS][0], "UILiveStartCanvas") - if canvas is None: - raise RuntimeError(f"{CANVAS}: no UILiveStartCanvas") - - # -- strings, and which texts Initialize writes them to --------------------------------------------- - strings, sources = resolve_strings(master, music_id, difficulty, column) - text_of: dict[str, str] = {} - for field, key in TEXT_FIELDS.items(): - refs = canvas[field] if isinstance(canvas[field], list) else [canvas[field]] - for r in refs: - text_of[r["gameObject"]] = strings[key] - - # -- TMP texts of the drawn part: LocalizeText overrides and the characters they show ------------- - styles = textstyle.TextStyles(ex, player, mode, fontsettings.builtin()) if fonts == "open" else None - lang = styles.lang if styles else textstyle.language_fonts(player) - swap = styles.swap if styles else textstyle.font_swap(lang, mode) - fontset = tmpfont.FontSet(ex) if fonts == "game" else None - texts: dict[str, dict] = {} - chars_by_font: dict[str, set] = {} - for n in nodes: - if not _drawn(n["path"]): - continue - t = _comp(n, TMP_CLASS) - if t is None: - continue - if len(by_path[n["path"]]) != 1: - raise RuntimeError(f"{n['path']}: text node path not unique") - lt = _comp(n, "LocalizeText") - if not (lt and lt["m_Enabled"] and lt["_localizeEnabled"]): - raise NotImplementedError(f"{n['path']}: text without an enabled LocalizeText") - loc = textstyle.localize_text( - n["path"], t["m_fontAsset"]["name"], t["m_sharedMaterial"]["material"], swap, lang, mode - ) - shown = text_of[n["path"]] if n["path"] in text_of else t["m_text"] - texts[n["path"]] = {"localized": loc, "text": shown, "setByInitialize": n["path"] in text_of} - if styles: - record = styles.record(n["path"], t, lt) - record.pop("text") # the serialized text; `text` above is the one shown - texts[n["path"]]["style"] = record - chars_by_font.setdefault(loc["fontAsset"], set()).update(ord(ch) for ch in shown) - for p in text_of: - if _drawn(p) and p not in texts: - raise RuntimeError(f"{p}: Initialize writes a text that is not exported") - - if fontset is not None: - coverage, materials, font_out = _game_fonts(ex, fontset, texts, chars_by_font, packer) - else: # name stubs: no font data, no atlases - coverage, materials = None, {} - font_out: dict = {f: {"name": f, "note": FONT_NOTE} for f in sorted(chars_by_font)} - - # -- sprites: drawn Images, the difficulty icons, the jacket ---------------------------------------- - replaced = {r["gameObject"] for f in ("_jacketImages", "_difficultyImages") for r in canvas[f]} - sprite_refs: dict[str, str] = {} # sprite name -> spriteRef - for n in nodes: - im = _comp(n, "Image") - if not _drawn(n["path"]) or im is None or n["path"] in replaced or not im["m_Sprite"]: - continue - sprite_refs.setdefault(im["m_Sprite"]["name"], im["m_Sprite"]["spriteRef"]) - difficulty_icons = {} - for p in canvas["_difficultyPairs"]: - s = p["Icon"] - difficulty_icons[str(p["Difficulty"])] = s["name"] - sprite_refs.setdefault(s["name"], s["spriteRef"]) - music = next(r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id) - jacket = ex.key_sprite(JACKET_KEY.format(jacket=music["_jacketAssetName"])) - sprite_refs.setdefault(jacket["name"], jacket["spriteRef"]) - for name, ref in sorted(sprite_refs.items()): - s = ex.sprite_recs[ref] - if s["name"] != name: - raise RuntimeError(f"sprite {name}: record named {s['name']}") - ex.pack_sprite(ref, packer, f"sprites_{_safe(s['tex'].read_typetree()['m_Name'])}") - - textures = packer.build() - if fontset is not None: - tmpfont.resolve_font_blocks(font_out) - sprites_out = {name: ex.packed_sprite(ref) for name, ref in sorted(sprite_refs.items())} - for m in materials.values(): - for v in m["textures"].values(): - if v["texture"] is not None: - v["texture"] = { - "name": v["texture"]["name"], - "width": v["texture"]["width"], - "height": v["texture"]["height"], - "format": v["texture"]["format"], - } - - # -- shaders: livescene's dump (same name and source bundle), GLES3 variant per material ------------ - index = json.loads((out_dir / "livescene" / "shaders" / "shaders.json").read_text(encoding="utf-8")) - by_name = {r["name"]: r for r in index} - keywords = {} - for m in materials.values(): - sh = m["shader"]["shader"] - rec = by_name.get(sh) - if rec is None: - raise RuntimeError(f"{m['material']}: shader {sh} not in livescene/shaders") - if sh not in ex.shaders or ex.shaders[sh].assets_file.name != rec["source"]: - raise RuntimeError(f"{m['material']}: shader {sh} comes from another bundle than livescene's") - known = {k for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3" for k in v["keywords"]} - want = sorted(k for k in m["keywords"] if k in known) - if not any( - v["platform"] == "gles3" - and v["type"] == "GLES3" - and v["stage"] == "vertex" - and sorted(v["keywords"]) == want - for v in rec["variants"] - ): - raise RuntimeError(f"{sh}: no GLES3 variant for material {m['material']} keywords {want}") - keywords[m["material"]] = want - if "UI/Default" not in by_name: - raise RuntimeError("UI/Default not in livescene/shaders") - - # -- the root Animator's controller (evidence) ----------------------------------------------------- - an = _comp(by_path[f"{CANVAS}/root"][0], "Animator") - controller = an["m_Controller"] if an else None - tmp = player.resource("TMP Settings") - tmp_settings = {k: player.names_in(tmp, v) for k, v in player.mono(tmp).items()} - - doc = { - "about": f"Live start canvas (Live/UILiveStartCanvas, LightWeight mode): what livescene/scene.json lacks, " - f"{language}", - "canvas": CANVAS, - "slice": { - "musicId": music_id, - "difficulty": difficulty, - "liveDifficulty": LIVE_DIFFICULTY[difficulty], - "lightweight": True, - "profile": "fresh (no play records)", - }, - "language": { - "mode": mode, - "field": column[1:], - "fonts": lang, - "fontSwap": swap, - "lineSpacing": LANGUAGE_LINE_SPACING[mode], - }, - "strings": strings, - "stringSources": sources, - "texts": texts, - "sprites": sprites_out, - "difficultyIcons": difficulty_icons, - "jacket": jacket["name"], - "textures": textures, - "materials": materials, - "materialKeywords": keywords, - "fonts": font_out, - "glyphCoverage": coverage, - "tmpSettings": { - k: tmp_settings[k] - for k in ( - "m_GetFontFeaturesAtRuntime", - "m_matchMaterialPreset", - "m_enableExtraPadding", - "m_missingGlyphCharacter", - ) - }, - # images draw with Graphic.defaultGraphicMaterial (built-in UI/Default: livescene's dump of it has the - # same variants as the unity_builtin_extra one the ADV exports, checked byte for byte) - "shaders": { - "index": "../livescene/shaders/shaders.json", - "names": sorted({m["shader"]["shader"] for m in materials.values()} | {"UI/Default"}), - }, - "controller": controller, - } - if styles: - doc["fontData"] = "open" - doc["textStyle"] = styles.summary() - write_json(base / "liveui.json", doc) - return { - "liveui": str(base / "liveui.json"), - "texts": len(texts), - "sprites": sorted(sprites_out), - "textures": {k: (v["width"], v["height"]) for k, v in textures.items()}, - "fonts": sorted(font_out), - "coverage": {k: v["counts"] for k, v in (coverage or {}).items()}, - "strings": strings, - "runtimeGlyphs": { - k: len(v["runtimeGlyphs"]["generated"]) for k, v in font_out.items() if v.get("runtimeGlyphs") - }, - } diff --git a/src/nnnotes/content/story.py b/src/nnnotes/content/story.py deleted file mode 100644 index 850be88..0000000 --- a/src/nnnotes/content/story.py +++ /dev/null @@ -1,162 +0,0 @@ -"""One ADV episode -> a self-contained story directory that a player can load without game code. - - /episode.json command list + text/sound/video shards + resource closure (adv.py) - /audio// every cue sheet in the episode's -SoundCueSheet - shard, decoded per cue + cues.json (cri.py) - /scene.json, textures/, shaders/ - player graphics, cameras, ADV fields, volumes, - settings and stages (advscene.py); textures/ and - shaders/ also hold those of the kind files below - /frames.json, effects.json, posteffects.json, stills.json, talkwindows.json, chat.json - the episode's frames, particle effects, post-effect - profiles, stills, talk windows and chat assets - (advmedia.py; each written only when used) - /videos/ Movie / Clip videos as WebM + videos.json (advvideo.py) - /ui/ ADV front canvas UI: ui.json, packed textures, - UI shaders; also the rule transitions (advui.py) - /crilips/ crilips.json + crilips.bin: the CRI Lips analysis data read from the - APK (crilips.py), when a voice reaches the CRI Lips path (needs_cri_lips) - /story.json index of the above (a kind file the episode does not use is null) - -The Live2D models the episode uses come from a model source (webmodel: SiteModels), `ensure(address) -> -{"id", "motionSync"}`: story.json `models` maps each model's address to its id, plus the fields of the source's -`story_fields` (SiteModels adds none). Every resource kind of the closure has an exporter (KIND_OUTPUT); the build -stops before writing anything when a resource is missing from the catalog or of a kind without one. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -from ..core.jsonio import write_json -from ..formats import cri, crilips -from ..source.catalog import Catalog -from ..unity.player import PlayerData -from . import adv, advmedia, advscene, advui, advvideo - -# resource kind -> where the story directory holds it -KIND_OUTPUT = { - "live2d": "story.json models (the model source)", - "stage": "scene.json stages", - "transition": "ui/ui.json transitions", - "video": advvideo.INDEX, - **{k: advmedia.FILES[k][1] for k in advmedia.FILES}, - **{k: advmedia.CHAT[1] for k in advmedia.CHAT_KINDS}, -} - - -CRILIPS_DIR = "crilips" -AIR_LIP_SYNC = ("airlipsync", "airlipsync_holdopen") # Talk Parameter1 modes without a voice analysis - - -def needs_cri_lips(episode: dict, split_key: str, motion_sync: list[bool]) -> bool: - """A voice of the episode reaches the CRI Lips analysis: a lip-synced voice row (Talk or Voice with TargetName and - VoiceIDs; IgnoreLipSync and IgnoreData not set) when a model of the episode has no MotionSync controller - (`motion_sync`: per model, model.json `motionSync`), or a Talk row whose voice also drives other speakers, which - use the analysis whatever their model (StartSharedAnalyzerLipSync: the EveryoneLipSync mode, or more speakers than - voices).""" - for c in episode["commands"]: - if c["cmd"] not in ("Talk", "Voice") or c.get("IgnoreData") or c.get("IgnoreLipSync"): - continue - if not c.get("TargetName") or not c.get("VoiceIDs"): - continue - if not all(motion_sync): - return True - if c["cmd"] == "Talk": - mode = (c.get("Parameter1") or "").strip().lower() - if mode == "everyonelipsync": - return True - if mode not in AIR_LIP_SYNC and len(c["TargetName"].split(split_key)) > len(c["VoiceIDs"]): - return True - return False - - -def check_kinds(resources: list[dict]) -> None: - """Raise for the first resource missing from the catalog or of a kind the story export does not handle.""" - missing = [r["address"] for r in resources if not r["present"]] - if missing: - raise RuntimeError(f"resources not in catalog: {missing}") - for r in resources: - if r["kind"] not in KIND_OUTPUT: - raise NotImplementedError(f"resource kind {r['kind']}: {r['address']}") - - -def build( - cat: Catalog, - master: Path, - player: PlayerData, - adv_id: int, - out_dir: Path, - audio_format: str = "flac", - audio: bool = True, - fonts: str = "open", - *, - models, - ui: bool = True, - language: str | None = None, - audio_options: dict | None = None, -) -> dict: - """Build the story directory with the models of the model source `models` (module docstring). `audio=False` - leaves the cue sheets undecoded (story.json `audio` is empty); `fonts`: "open" exports no font data of the game, - "game" exports it (advmedia, advui). `ui=False` writes no ui/ (story.json still names ui/ui.json: the caller - writes it, e.g. per language as storysite does); `language`: the UI's client language (a languages.LANGUAGES - code, needed with the UI); `audio_options`: further cri.decode options of every cue sheet - (flac_level, also). With audio, the CRI Lips data is read from the catalog's APK when a voice reaches it - (needs_cri_lips; story.json `crilips`, else null).""" - if ui and language is None: - raise ValueError("language: the client language of the UI is needed with ui=True") - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - episode = adv.to_json(adv.extract(cat, master, adv_id)) - check_kinds(episode["resources"]) - write_json(out_dir / "episode.json", episode) - - used = {r["address"]: models.ensure(r["address"]) for r in episode["resources"] if r["kind"] == "live2d"} - - sheets = {} - if audio: - for sheet in sorted({c["_cueSheetName"] for c in episode["cuesheets"].values()}): - cri.decode(cat, sheet, out_dir / "audio" / sheet, fmt=audio_format, **(audio_options or {})) - sheets[sheet] = f"audio/{sheet}" - - media = advmedia.extract(cat, player, master, episode, out_dir, fonts=fonts) - scene = advscene.extract(cat, player, episode, out_dir, shaders=media["shaders"]) - ui_summary = ( - advui.extract(cat, player, episode, out_dir, fonts=fonts, language=language, master=master) - if ui and language is not None - else None - ) - videos = advvideo.extract(cat, episode, out_dir) - lips = None - if audio: - split = json.loads((out_dir / "scene.json").read_text(encoding="utf-8"))["settings"]["playerSettings"][ - "_targetNameSplitKey" - ] - if needs_cri_lips(episode, split, [m["motionSync"] for m in used.values()]): - if cat.apk is None: - raise crilips.CriLipsError("the CRI Lips data is read from the APK: set [paths] apk") - crilips.write_data(cat.apk, out_dir / CRILIPS_DIR) - lips = {"descriptor": f"{CRILIPS_DIR}/crilips.json", "data": f"{CRILIPS_DIR}/crilips.bin"} - index = { - "advId": adv_id, - "episode": "episode.json", - "scene": "scene.json", - "ui": "ui/ui.json", - "models": {a: m["id"] for a, m in used.items()}, - **models.story_fields(out_dir), - "audio": sheets, - **media["files"], - "videos": advvideo.INDEX if videos else None, - "crilips": lips, - } - write_json(out_dir / "story.json", index) - return { - **index, - "stages": scene["stages"], - "shaders": scene["count"], - "textures": scene["textures"], - "uiSummary": ui_summary, - "media": {"entries": media["counts"], "textures": media["textures"]}, - "videoCount": len(videos["videos"]) if videos else 0, - } diff --git a/src/nnnotes/content/storyfontproduct.py b/src/nnnotes/content/storyfontproduct.py index dace25a..3ec9ea9 100644 --- a/src/nnnotes/content/storyfontproduct.py +++ b/src/nnnotes/content/storyfontproduct.py @@ -6,8 +6,8 @@ from ..source import languages from ..unity import fontsettings, textstyle, tmpfont -from ..unity.export import MemoryPacker, _safe, packed_png from ..unity.ids import object_id, parse_object_id +from ..unity.texels import MemoryPacker, _safe, packed_png from . import advui, storyfonts, storysimpleui, storyui, tmpassets from .prefabs import component_class, key_object, mono_json, ref_id, shader_table diff --git a/src/nnnotes/content/storyfonts.py b/src/nnnotes/content/storyfonts.py index f60380f..3637038 100644 --- a/src/nnnotes/content/storyfonts.py +++ b/src/nnnotes/content/storyfonts.py @@ -1,48 +1,4 @@ -"""The TextMeshPro font data of a story's texts in one language -> /ui/fonts.json, ui/fonts/*.png and -ui/languages.json (the site's per-language files, storysite.py; format in ournotes-player docs/story-data-format.md). - - ui/fonts.json font assets reduced to the characters the episode shows in the language, their glyph pages - (ui/fonts/*.png), the text materials, their GLES3 keyword sets and the text binding of every - text node of ui/ui.json: its serialized TextMeshPro fields and the font asset, material and - line spacing it uses in the language (LocalizeText); with open fonts also `chatTexts` and - `dialogTexts`, the bindings of the chat window texts of ui.json `chatTexts` and of the text - nodes of its `dialogs` - ui/languages.json the language's LanguageMode, text field, line spacing and the font asset and line metrics of - each font role - ui/shaders/ the shader the text materials name (TextMeshPro/Mobile/Distance Field) is added to the UI's - -Two sources give the same format: - - open_fonts (default) glyphs generated from a font file per language: each text's localized game font asset - (LocalizeText) is replaced by an asset of the font file with the game asset's point size, padding, - style settings and render mode, holding the characters of the episode's text table, its title, the - static labels of the UI and the chat windows' status and run-time texts in that language plus - TextMeshPro's synthesized control characters and U+005F (UNDERLINE_CHARACTER). Face info and glyph - metrics come from the font file at that point size (FreeType, no hinting); the distance field is - tmpfont.RuntimeGlyphs' (the supersampled render modes rasterize at up to MAX_OVERSAMPLE times the size, - recorded in atlasRenderMode; the anti-aliased distance-field modes are generated as the 1X distance - field and recorded as SDF). Glyphs are shelf-packed, U+005F first (on the first page), then in code - point order of their first character into pages of PAGE_WIDTH texels, at most PAGE_MAX_HEIGHT high; - every page of an asset has the same size. The text materials are the game's localized materials with - the page as _MainTex and the page size as _TextureWidth / _TextureHeight. The game asset's fallback - chain is mirrored (open_font_set): each game fallback asset stands for an asset of the font file of - the language LocalizeManager lists it for, which holds the characters the assets before it in the - chain lack. The game assets' settings, character coverage, fallback lists and text materials come from - nnnotes' table of them (fontsettings.builtin()): no game font asset, material, glyph or font bundle is - read. - game_fonts the game's TMP font assets (advui.extract with fonts="game", tmpfont.export_fonts): the same records - moved from its ui.json into fonts.json, the glyph pages into ui/fonts/. - -Emoji: every text a Fwk.UI.UIText drives has LocalizeManager's emoji sprite asset (UIText.Awake), which draws the -characters its font assets lack and the tags the emoji search writes for emoji sequences. With open -fonts the sprite asset holds the sprites the episode's texts can draw (sprite_needs), each image drawn from the emoji -font (EmojiFont; none: empty glyphs) at the game sprite's size, and the open font assets of those texts leave out the -code points a sprite draws; with game fonts it is the game's asset with its sheet. Without a sprite to draw, fonts.json -has no sprite asset. - -Same inputs (font file, episode, game data) and library versions give the same bytes; generated glyph cells are kept -in the process cache by font file, sizes and glyph. -""" +"""Font file readers, glyph generation, atlas packing and material records for text products.""" from __future__ import annotations @@ -50,30 +6,22 @@ import hashlib import json import math -import shutil import unicodedata from pathlib import Path import numpy as np -from ..core import cache, jsonio -from ..formats import shader as shader_mod -from ..source import languages, master +from ..core import cache +from ..source import languages from ..source.config import ConfigError from ..unity import fontsettings, textstyle, tmpfont -from ..unity.export import Exporter, _safe, packed_png, texture_png +from ..unity.texels import _safe, packed_png from . import adv, advui -FONTS_FORMAT = "ournotes.story-fonts/1" -LANGUAGE_FORMAT = "ournotes.story-language/1" -FONTS_DOC = "fonts.json" -LANGUAGE_DOC = "languages.json" PAGES_DIR = "fonts" PAGE_WIDTH = 2048 PAGE_MAX_HEIGHT = 4096 MAX_OVERSAMPLE = 8 -SOURCES = ("open", "game") -TEXT_SHADER_PREFIX = "TextMeshPro/" # UnityEngine.TextCore.LowLevel.GlyphRasterModes flags of the supersampled render modes -> raster pixels per texel OVERSAMPLE_FLAGS = {tmpfont.RASTER_MODE["8X"]: 8, tmpfont.RASTER_MODE["16X"]: 16, tmpfont.RASTER_MODE["32X"]: 32} ELEMENT_CHARACTER = 1 # TMP TextElementType.Character @@ -86,39 +34,12 @@ KOREAN_ADJUST_CLASS = "LocalizeKoreanAdjust" # the font style a text gets in Korean (LocalizeKoreanAdjust.Apply) UI_TEXT_CLASSES = ("UIText", "UIRubyText") # Fwk.UI.UIText and its subclass GLYPHS = cache.bucket("storyglyphs") -FACE_KEYS = ( - "m_FaceIndex", - "m_FamilyName", - "m_StyleName", - "m_PointSize", - "m_Scale", - "m_UnitsPerEM", - "m_LineHeight", - "m_AscentLine", - "m_CapLine", - "m_MeanLine", - "m_Baseline", - "m_DescentLine", - "m_SuperscriptOffset", - "m_SuperscriptSize", - "m_SubscriptOffset", - "m_SubscriptSize", - "m_UnderlineOffset", - "m_UnderlineThickness", - "m_StrikethroughOffset", - "m_StrikethroughThickness", - "m_TabWidth", -) STYLE_KEYS = fontsettings.STYLE_KEYS # OpenType name table ids NAME_FAMILY, NAME_STYLE, NAME_VERSION, NAME_LICENSE, NAME_LICENSE_URL = 1, 2, 5, 13, 14 NAME_TYPO_FAMILY, NAME_TYPO_STYLE = 16, 17 -def _dump(obj) -> bytes: - return jsonio.dumps(obj, ensure_ascii=False, indent=1, sort_keys=True).encode("utf-8") + b"\n" - - def _salt() -> str: return cache.key(cache.source_salt(__file__), cache.source_salt(tmpfont.__file__, "freetype-py", "scipy", "numpy")) @@ -279,69 +200,6 @@ def pack_cells(cells: list[tuple[int, int, int]]) -> tuple[dict[int, tuple[int, # ---------------------------------------------------------------- texts -def _ui_prefab_nodes(ex: Exporter, ui_doc: dict) -> tuple[dict[str, dict], dict[str, dict]]: - """({path: node} of UIAdvWidget's prefab, {path: node} of the talk windows of ui.json under TalkView).""" - widget = {n["path"]: n for n in ex.prefab(advui.WIDGET_KEY)["nodes"]} - names = [rec["name"] for rec in ui_doc["nodes"] if rec["path"] == f"{advui.WINDOW_PARENT}/{rec['name']}"] - return widget, {n["path"]: n for n in advui.window_nodes(ex, names)} - - -def ui_text_targets(nodes, root: str = "") -> set[str]: - """The paths (`root` + the prefab path) of the texts the Fwk.UI.UIText components (and its subclass UIRubyText) of - the prefab `nodes` drive (_targetText, on any node): UIText.Awake registers OnEmojiSpriteAssetLoaded with - LocalizeManager, which sets the text's sprite asset (TMP_Text.spriteAsset) to LocalizeManager's emoji sprite - asset, over any serialized one (the asset is loaded at the title, before any story). A UIText without a target - text drives none.""" - out = set() - for n in nodes: - for c in n["components"]: - if c.get("class") in UI_TEXT_CLASSES: - target = (c.get("_targetText") or {}).get("gameObject") - if target is not None: - out.add(root + target) - return out - - -def story_ui_text_targets(ex: Exporter, ui_doc: dict) -> set[str]: - """ui_text_targets of UIAdvWidget and of the talk windows of ui.json (paths under TalkView).""" - names = [rec["name"] for rec in ui_doc["nodes"] if rec["path"] == f"{advui.WINDOW_PARENT}/{rec['name']}"] - out = ui_text_targets(ex.prefab(advui.WIDGET_KEY)["nodes"]) - for name in names: - out |= ui_text_targets(ex.prefab(advui.window_key(ex.cat, name))["nodes"], f"{advui.WINDOW_PARENT}/") - return out - - -def sprite_binding(path: str, node: list[dict], driven: bool, sprite: str | None) -> dict: - """The sprite settings of a text binding: for a text a UIText drives (`driven`), `spriteAsset` = `sprite` (the - emoji sprite asset's name in fonts.json) and the text's serialized `m_tintAllSprites`; {} for any other text, which - keeps its serialized sprite asset (only none is implemented).""" - comps = [c for c in node if c.get("class") in advui.TMP_CLASSES] - if len(comps) != 1: - raise RuntimeError(f"{path}: {len(comps)} text components") - c = comps[0] - if not driven: - if c.get("m_spriteAsset"): - raise NotImplementedError(f"{path}: a text with its own sprite asset") - return {} - if sprite is None: - raise RuntimeError(f"{path}: a UIText text without the emoji sprite asset") - return {"spriteAsset": sprite, "m_tintAllSprites": c["m_tintAllSprites"]} - - -def text_components(ex: Exporter, ui_doc: dict) -> dict[str, list[dict]]: - """{node path: the node's components} of every text node of the story UI (ui.json nodes with a text record), from - the prefabs advui assembles (UIAdvWidget with the talk windows of ui.json, the children of TalkView, under it).""" - widget, window = _ui_prefab_nodes(ex, ui_doc) - out = {} - for rec in ui_doc["nodes"]: - if "textStyle" not in rec: - continue - path = rec["path"] - hits = [n for n in (widget.get(path), window.get(path)) if n is not None] - if len(hits) != 1: - raise RuntimeError(f"{path}: {len(hits)} prefab nodes") - out[path] = hits[0]["components"] - return out def text_extras(path: str, comps: list[dict]) -> dict: @@ -362,145 +220,11 @@ def text_extras(path: str, comps: list[dict]) -> dict: return out -def _binding( - path: str, node: list[dict], swap: dict, lang: dict, mode: int, driven: bool = False, sprite: str | None = None -) -> dict: - """The text binding of a text node (its components `node`): the serialized TextMeshPro fields of its text - component, as advui writes them with fonts="game", the settings of its other text components (text_extras), - `localized` = textstyle.localize_text (LocalizeText) in the language `mode` and its sprite settings - (sprite_binding: `driven` by a UIText, `sprite` the emoji sprite asset's name).""" - comps = [c for c in node if c.get("class") in advui.TMP_CLASSES] - if len(comps) != 1: - raise RuntimeError(f"{path}: {len(comps)} text components") - c = comps[0] - t = {k: c[k] for k in advui.TMP_FIELDS if k in c} - t.update( - { - "class": c["class"], - "enabled": c["m_Enabled"], - "fontAsset": c["m_fontAsset"]["name"], - "material": c["m_sharedMaterial"]["material"], - } - ) - t.update(text_extras(path, node)) - t["localized"] = textstyle.localize_text(path, t["fontAsset"], t["material"], swap, lang, mode) - t.update(sprite_binding(path, node, driven, sprite)) - return t - - -def text_bindings(ex: Exporter, ui_doc: dict, lang: dict, mode: int, sprite: str | None = None) -> dict[str, dict]: - """{node path: text binding (_binding)} of every text node of the story UI (text_components); `sprite` = the - emoji sprite asset's name for the texts a UIText drives (story_ui_text_targets).""" - swap = textstyle.font_swap(lang, mode) - driven = story_ui_text_targets(ex, ui_doc) - return { - path: _binding(path, node, swap, lang, mode, path in driven, sprite) - for path, node in text_components(ex, ui_doc).items() - } - - -def dialog_bindings( - ex: Exporter, ui_doc: dict, lang: dict, mode: int, sprite: str | None = None -) -> dict[str, dict[str, dict]]: - """{dialog: {node path: text binding (_binding)}} of the text nodes of the dialogs of ui.json (`dialogs`, each - from its prefab `key`); `sprite` as text_bindings.""" - swap = textstyle.font_swap(lang, mode) - out: dict[str, dict[str, dict]] = {} - for name, d in sorted((ui_doc.get("dialogs") or {}).items()): - nodes = ex.prefab(d["key"])["nodes"] - prefab, driven = {n["path"]: n["components"] for n in nodes}, ui_text_targets(nodes) - for rec in d["nodes"]: - if "textStyle" in rec: - out.setdefault(name, {})[rec["path"]] = _binding( - rec["path"], prefab[rec["path"]], swap, lang, mode, rec["path"] in driven, sprite - ) - return out - - -def chat_bindings( - ex: Exporter, episode: dict, lang: dict, mode: int, sprite: str | None = None -) -> dict[str, dict[str, dict]]: - """{chat window: {node path: text binding}} of the TMP texts of the episode's chat windows (advui.chat_text_nodes): - the fields text_bindings gives, `localized` from advui.chat_localize for a text with LocalizeText enabled, else - the serialized font asset, material and line spacing; `sprite` as text_bindings.""" - out: dict[str, dict[str, dict]] = {} - nodes, _ = advui.chat_text_nodes(ex, episode) - driven = set().union( - *(ui_text_targets(ex.prefab(address)["nodes"]) for address in advui.chat_windows(episode).values()) - ) - for window, node, c, loc in nodes: - t = {k: c[k] for k in advui.TMP_FIELDS if k in c} - t.update( - { - "class": c["class"], - "enabled": c["m_Enabled"], - "fontAsset": c["m_fontAsset"]["name"], - "material": c["m_sharedMaterial"]["material"], - } - ) - t.update(text_extras(node["path"], node["components"])) - if loc and loc["m_Enabled"] and loc["_localizeEnabled"]: - t["localized"] = advui.chat_localize(t["fontAsset"], t["material"], lang, mode) - else: - t["localized"] = {"fontAsset": t["fontAsset"], "material": t["material"], "lineSpacing": c["m_lineSpacing"]} - t.update(sprite_binding(node["path"], node["components"], node["path"] in driven, sprite)) - out.setdefault(window, {})[node["path"]] = t - return out - - # the IAdvFrameTextReceiver implementations (AdvSystem.Asset.UI): a frame with one gets its texts at run time FRAME_TEXT_RECEIVERS = ("AdvSlanderCommentFrame",) FRAME_PREFIX = adv.RESOURCE_PREFIX["Frame"][1] -def frame_prefabs(ex: Exporter, episode: dict) -> dict[str, list[dict]]: - """{frame name (its address without the Frame prefix, the key of story/frames.json): its prefab node list} of the - episode's Frame resources in the catalog.""" - return { - r["address"][len(FRAME_PREFIX) :]: ex.prefab(r["address"])["nodes"] - for r in episode["resources"] - if r["kind"] == "frame" and r.get("present", True) - } - - -def frame_bindings( - frames: dict[str, list[dict]], lang: dict, mode: int, sprite: str | None = None -) -> dict[str, dict[str, dict]]: - """{frame name: {node path: text binding}} of the TMP texts of the episode's frames (`frames`: frame_prefabs), as - chat_bindings: `localized` from LocalizeText (advui.chat_localize) when it is enabled, else the serialized font - asset, material and line spacing; `sprite` as text_bindings (the texts a UIText drives).""" - out: dict[str, dict[str, dict]] = {} - for name, nodes in frames.items(): - driven = ui_text_targets(nodes) - for node in nodes: - comps = [c for c in node["components"] if c.get("class") in advui.TMP_CLASSES] - if not comps: - continue - c = comps[0] - loc = next((x for x in node["components"] if x.get("class") == "LocalizeText"), None) - t = {k: c[k] for k in advui.TMP_FIELDS if k in c} - t.update( - { - "class": c["class"], - "enabled": c["m_Enabled"], - "fontAsset": c["m_fontAsset"]["name"], - "material": c["m_sharedMaterial"]["material"], - } - ) - t.update(text_extras(node["path"], node["components"])) - if loc and loc["m_Enabled"] and loc["_localizeEnabled"]: - t["localized"] = advui.chat_localize(t["fontAsset"], t["material"], lang, mode) - else: - t["localized"] = { - "fontAsset": t["fontAsset"], - "material": t["material"], - "lineSpacing": c["m_lineSpacing"], - } - t.update(sprite_binding(node["path"], node["components"], node["path"] in driven, sprite)) - out.setdefault(name, {})[node["path"]] = t - return out - - def frame_texts(frames: dict[str, list[dict]], episode: dict, field: str) -> list[str]: """The texts the episode's frames (`frames`: frame_prefabs) show: the serialized texts of their text nodes, and the texts AdvFrameCommand.SetFrameTexts gives the frames with an IAdvFrameTextReceiver (FRAME_TEXT_RECEIVERS): per @@ -532,35 +256,6 @@ def frame_texts(frames: dict[str, list[dict]], episode: dict, field: str) -> lis return serialized + (out + ["@\n"] if out else out) -def shown_texts(episode: dict, ui_doc: dict, master_dir: Path | None, field: str) -> list[str]: - """The texts the episode shows in the text field `field`: every text of its text table (lines, speaker names, - frame and choice texts), its title, the static labels of the UI (the MasterText rows of the text keys of the text - nodes and of the dialogs' text nodes, the `masterIdTexts` of ui.json) and of the chat windows - (`chatStatusTexts`), the serialized texts of the chat windows' text nodes (`chatTexts`, shown until the player - sets them) and the texts the chat windows format at run time (advui.chat_runtime_texts), markup included.""" - texts = [row.get(field) for row in episode["text"].values()] - texts.append((episode.get("title") or {}).get(field)) - texts += [t["text"] for t in (ui_doc.get("masterIdTexts") or {}).values()] - texts += list((ui_doc.get("chatStatusTexts") or {}).values()) + advui.chat_runtime_texts(episode) - texts += [rec["textStyle"].get("text") for w in (ui_doc.get("chatTexts") or {}).values() for rec in w.values()] - nodes = list(ui_doc["nodes"]) + [n for d in (ui_doc.get("dialogs") or {}).values() for n in d["nodes"]] - keys = {rec["textStyle"]["textKey"] for rec in nodes if "textStyle" in rec and rec["textStyle"].get("textKey")} - if keys: - if master_dir is None: - raise RuntimeError(f"UI text keys {sorted(keys)} need the master data") - rows = {r["_id"]: r for r in master.table(master_dir, "MasterText") if r["_id"] in keys} - missing = sorted(keys - set(rows)) - if missing: - raise KeyError(f"UI text keys not in MasterText: {missing}") - texts += [rows[k].get(f"_{field}") for k in sorted(keys)] - return [s for s in texts if isinstance(s, str)] - - -def shown_characters(episode: dict, ui_doc: dict, master_dir: Path | None, field: str) -> list[int]: - """The code points of shown_texts, sorted.""" - return sorted({ord(ch) for s in shown_texts(episode, ui_doc, master_dir, field) for ch in s}) - - def _material_textures(m: dict) -> None: """Texture references of a material as advui writes them: {name, width, height, format}.""" for v in m["textures"].values(): @@ -569,76 +264,6 @@ def _material_textures(m: dict) -> None: v["texture"] = {"name": t["name"], "width": t["width"], "height": t["height"], "format": t.get("format")} -def text_shaders(ex: Exporter, ui_dir: Path, materials: dict) -> dict[str, list[str]]: - """Add the shaders the text `materials` name to ui/shaders (index kept in name order) and return each - material's GLES3 keyword set (its keywords that the shader's GLES3 variants use; a vertex program with exactly - those must exist).""" - sdir = Path(ui_dir) / "shaders" - index = json.loads((sdir / "shaders.json").read_text(encoding="utf-8")) - have = {r["name"] for r in index} - for name in sorted({m["shader"]["shader"] for m in materials.values()}): - if name in have: - continue - if name not in ex.shaders: - raise RuntimeError(f"shader {name} not in the loaded bundles") - shader_mod.dump_objects([ex.shaders[name]], sdir, ex.shaders[name].assets_file.name, index) - index.sort(key=lambda r: r["name"]) - shader_mod.write_index(index, sdir) - recs = {r["name"]: r for r in index} - out = {} - for name, m in sorted(materials.items()): - rec = recs[m["shader"]["shader"]] - gles = [v for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3"] - known = {k for v in gles for k in v["keywords"]} - want = sorted(k for k in m["keywords"] if k in known) - if not any(sorted(v["keywords"]) == want and v["stage"] == "vertex" for v in gles): - raise RuntimeError(f"{rec['name']}: no GLES3 variant for material {name} keywords {want}") - out[name] = want - return out - - -def language_doc(language: str, source: str, fonts: dict, texts: dict, lang: dict) -> dict: - """ui/languages.json of `language`: LanguageMode, text field, line spacing and per font role the font asset - the role's texts use with its line metrics (textstyle.face_metrics).""" - mode = languages.mode(language) - roles: dict[str, dict] = {} - for _path, t in sorted(texts.items()): - role = textstyle.font_role(t["fontAsset"], lang) - fa = t["localized"]["fontAsset"] - if roles.get(role, {}).get("fontAsset", fa) != fa: - raise NotImplementedError(f"font role {role} uses two font assets ({roles[role]['fontAsset']}, {fa})") - f = fonts[fa] - roles[role] = { - "fontAsset": fa, - **textstyle.face_metrics(f["faceInfo"], {k: f[k] for k in ("normalSpacingOffset", "boldSpacing")}), - } - return { - "format": LANGUAGE_FORMAT, - "language": language, - "mode": mode, - "field": languages.column(language)[1:], - "lineSpacing": textstyle.LANGUAGE_LINE_SPACING[mode], - "fonts": source, - "roles": roles, - } - - -def _write(ui_dir: Path, fonts_doc: dict, lang_doc: dict) -> None: - (Path(ui_dir) / FONTS_DOC).write_bytes(_dump(fonts_doc)) - (Path(ui_dir) / LANGUAGE_DOC).write_bytes(_dump(lang_doc)) - - -def _exporter(cat, player, work: Path, font_stubs=None) -> Exporter: - return Exporter( - cat, - Path(work), - player=player, - textures="deferred", - stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"), - font_stubs=font_stubs, - ) - - # ---------------------------------------------------------------- open fonts PAGE_SETTINGS = {"m_FilterMode": 1, "m_Aniso": 1, "m_MipBias": 0.0, "m_WrapU": 1, "m_WrapV": 1, "m_WrapW": 1} SUBSET = ( @@ -784,23 +409,6 @@ def open_asset( } -def line_breaking(player) -> dict: - """TMP_Settings' line breaking rules: the text of its leading and following characters TextAssets (as stored) - and m_UseModernHangulLineBreakingRules.""" - o = player.resource("TMP Settings") - tt = player.mono(o) - - def text(key: str) -> str: - t = player.deref(o, tt[key]) - return t.read_typetree()["m_Script"] if t is not None else "" - - return { - "leading": text("m_leadingCharacters"), - "following": text("m_followingCharacters"), - "useModernHangulLineBreakingRules": bool(tt["m_UseModernHangulLineBreakingRules"]), - } - - def search_order(fallbacks_of, font: str, exported: set) -> list[str]: """The font assets TMP searches after `font` for a character `font` lacks, as tmpfont.FontSet.lookup (TMP_FontAssetUtilities.GetCharacterFromFontAsset_Internal: each fallback, then that fallback's own fallbacks, @@ -822,20 +430,6 @@ def walk(f: str) -> None: return out -def fallback_chains(ex: Exporter, primaries: set, exported: set) -> dict[str, list[str]]: - """{exported font asset: its search_order}, over the game's font assets reachable from the `primaries` (font - asset names): the fallback list of a font record once the assets that serve no shown character are left out.""" - fs = tmpfont.FontSet(ex) - for n in sorted(primaries): - fs.by_key(fontsettings.font_dir(n) + n) - missing = sorted(exported - set(fs.fonts)) - if missing: - raise RuntimeError(f"font assets {missing} are not reachable from {sorted(primaries)}") - return { - n: search_order(lambda f: [fb.name for fb in fs.fallbacks(fs.fonts[f])], n, exported) for n in sorted(exported) - } - - FALLBACK_FLOATS = ("_GradientScale", "_TextureWidth", "_TextureHeight", "_WeightNormal", "_WeightBold") SCALE_CLAMP = 1.0 # ShaderUtilities.m_clamp @@ -912,61 +506,6 @@ def fallback_material(m: dict, target: dict, name: str) -> dict: return out -def open_font_set( - cat, - player, - ex: Exporter, - language: str, - font: FontFile, - bindings: list[dict], - shown: list[str], - ui_dir: Path, - font_of=no_font_file, - sprite_drawn: dict | None = None, - margin_materials: dict | None = None, - settings=None, -) -> dict: - """The open font assets of the text `bindings` (their `localized` game font asset and material, replaced in - place by the open asset and its material) in `language`, for the texts `shown`, with the game font assets of - `settings` (fontsettings.FontSettings; default: the built-in table): - - - per localized game font asset (a primary) an asset of `font` (the language's font file) with the game asset's - point size, padding, style settings, render mode and text materials (open_asset); - - the game asset's fallback chain mirrored: each asset of its search order (search_order) stands for an asset of - the font file of its counterpart_language (`font_of(language)` -> FontFile, asked for when a character - reaches it) with that game asset's settings; the game's own fallback assets no language lists are left out; - - each shown code point goes to the first asset of its primary's chain whose font file maps it, as - TMP_Text.GetTextElement finds a character; one no asset maps stays with the primary as `missing`. The code - points the game's chain lacks take the missing glyph (tmpfont.missing_glyph: the primary gets `missingGlyph`; - its substitute goes through the chain like any character) and those a sprite draws for the primary's texts - (`sprite_drawn` {game font asset: code points}) are left out; - - an asset's `fallbacks` are the assets of its chain that hold a character (in search order), and each text - material M of a primary has, per fallback of the primary, the fallback material "M + " - (fallback_material). - - `margin_materials` {game font asset: game material names}: further text materials of that asset whose underlay - the glyph margin covers (so the pages equal another document's with the same characters). The pages go to - `ui_dir`/fonts/. -> {fonts, textures, materials, missing (code points no asset maps), substituted (code points - drawn as a missing glyph)}.""" - result = build_open_font_set( - language, - font, - bindings, - shown, - textstyle.language_fonts(player), - tmpfont.missing_glyph_character(player), - font_of, - sprite_drawn, - margin_materials, - settings, - ) - for path, body in result.pop("pages").items(): - dest = ui_dir / path - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(body) - return result - - def build_open_font_set( language: str, font: FontFile, @@ -1105,105 +644,6 @@ def name_of(key: tuple[str, str]) -> str: } -def open_fonts( - cat, - player, - episode: dict, - ui_dir: Path, - language: str, - font: FontFile, - master_dir: Path | None = None, - emoji: EmojiFont | None = None, - font_of=no_font_file, -) -> dict: - """ui/fonts.json, ui/fonts/*.png and ui/languages.json of `language` from the font file `font` and the emoji font - `emoji` (module docstring; without one the sprites keep their metrics and have no texels), next to the - ui/ui.json advui.extract wrote for that language in `ui_dir`; the text shaders go to ui/shaders. `font_of`: - the font file of another language for the fallback chains (open_font_set). Returns a summary.""" - tmpfont.require_extra() - ui_dir = Path(ui_dir) - ui_doc = json.loads((ui_dir / "ui.json").read_text(encoding="utf-8")) - mode, field = languages.mode(language), languages.column(language)[1:] - settings = fontsettings.builtin() - ex = _exporter(cat, player, ui_dir / "_fonts", settings) - lang = textstyle.language_fonts(player) - sprite_name = emoji.asset_name if emoji is not None else NO_EMOJI_FONT - texts = text_bindings(ex, ui_doc, lang, mode, sprite_name) - dialogs = dialog_bindings(ex, ui_doc, lang, mode, sprite_name) - chat = chat_bindings(ex, episode, lang, mode, sprite_name) if ui_doc.get("chatTexts") else {} - frames = frame_prefabs(ex, episode) - frame = frame_bindings(frames, lang, mode, sprite_name) - shown = shown_texts(episode, ui_doc, master_dir, field) + frame_texts(frames, episode, field) - chars = sorted({ord(ch) for s in shown for ch in s}) - - bindings = list(texts.values()) + [ - t for group in (dialogs, chat, frame) for w in group.values() for t in w.values() - ] - # the code points the sprite asset draws for the texts with it (sprite_drawn); the open font asset of those - # texts leaves them out - sprite_drawn: dict[str, set[int]] = {} - rec = None - if any("spriteAsset" in t for t in bindings): - rec = advui.sprite_asset_record(ex) - sprite_drawn = drawn_by_sprites(rec, [t for t in bindings if "spriteAsset" in t], chars, settings.lookup) - # the open font assets with the mirrored fallback chains, the missing glyph and the fallback materials - fset = open_font_set( - cat, player, ex, language, font, bindings, shown, ui_dir, font_of, sprite_drawn, settings=settings - ) - fonts, textures, materials = fset["fonts"], fset["textures"], fset["materials"] - missing, substituted = fset["missing"], fset["substituted"] - coverage = {"characters": len(chars), "missing": [chr(u) for u in sorted(missing)]} - if substituted: - coverage["missingGlyph"] = [chr(u) for u in sorted(substituted)] - sprites = None - indices = sprite_needs(rec, shown, set().union(*sprite_drawn.values())) if rec is not None else [] - if rec is None or not indices: # no sprite to draw: the data holds no sprite asset - for t in bindings: - t.pop("spriteAsset", None) - t.pop("m_tintAllSprites", None) - else: - sprites = open_sprite_asset(emoji, rec, indices, sprite_name) - for pname, px in sprites["pages"]: - (ui_dir / PAGES_DIR).mkdir(parents=True, exist_ok=True) - (ui_dir / sprites["textures"][pname]["texture"]).write_bytes(packed_png(px)) - textures.update(sprites["textures"]) - materials[sprites["material"]["material"]] = sprites["material"] - coverage["sprites"] = {"characters": len(sprites["asset"]["characters"]), "missing": sprites["missing"]} - keywords = text_shaders(ex, ui_dir, materials) - fonts_doc = { - "format": FONTS_FORMAT, - "language": language, - "source": "open", - "fonts": fonts, - "textures": textures, - "materials": materials, - "materialKeywords": keywords, - "texts": texts, - "coverage": coverage, - "lineBreaking": line_breaking(player), - } - if sprites is not None: - fonts_doc["spriteAssets"] = {sprite_name: sprites["asset"]} - fonts_doc["emojiSpriteAsset"] = sprite_name - if dialogs: - fonts_doc["dialogTexts"] = dialogs - if chat: - fonts_doc["chatTexts"] = chat - if frame: - fonts_doc["frameTexts"] = frame - _write(ui_dir, fonts_doc, language_doc(language, "open", fonts, texts, lang)) - shutil.rmtree(ui_dir / "_fonts", ignore_errors=True) - return { - "fonts": sorted(fonts), - "characters": len(chars), - "missing": len(missing), - "glyphs": sum(len(f["glyphs"]) for f in fonts.values()), - "pages": len(textures), - "sprites": len(sprites["asset"]["characters"]) if sprites else 0, - "missingSprites": len(sprites["missing"]) if sprites else 0, - } - - # ---------------------------------------------------------------- emoji sprites (open) EMOJI_VS16 = 0xFE0F SPRITE_MARGIN = 1 # transparent texels around each sprite cell (a bilinear quad edge samples half a texel) @@ -1433,155 +873,3 @@ def open_sprite_asset(emoji: EmojiFont | None, game: dict, indices: list[int], n # ---------------------------------------------------------------- game fonts -def game_sprite_asset(ex: Exporter, ui_dir: Path, rec: dict) -> dict: - """LocalizeManager's emoji sprite asset from the game data (`rec`, advui.sprite_asset_record): every character and - glyph, the sprite sheet as one page (ui/fonts/, the glyph rects unchanged) and its material sampling it. - -> {asset_name, asset, textures, material}.""" - sh = rec["sheet"] - if sh["mipCount"] != 1: - raise NotImplementedError(f"{rec['name']}: sprite sheet with {sh['mipCount']} mip levels") - o = ex.key_object(advui.EMOJI_SPRITE_ASSET_KEY) - sheet = ex.referent(o, o.read_typetree()["spriteSheet"]) - page = f"sprite_{rec['name']}" - dst = f"{PAGES_DIR}/{_safe(page)}.png" - (Path(ui_dir) / PAGES_DIR).mkdir(parents=True, exist_ok=True) - (Path(ui_dir) / dst).write_bytes(texture_png(sheet.read())) - glyphs = { - k: { - **g, - **( - {"packed": {"texture": page, "dx": 0, "dy": 0}} - if g["rect"]["m_Width"] > 0 and g["rect"]["m_Height"] > 0 - else {} - ), - } - for k, g in rec["glyphs"].items() - } - material = _sheet_material(rec["material"], rec["material"]["material"], page, sh["width"], sh["height"]) - asset = { - "faceInfo": rec["faceInfo"], - "characters": rec["characters"], - "glyphs": glyphs, - "sequences": rec["sequences"], - "material": material["material"], - } - textures = { - page: { - "texture": dst, - "name": sh["name"], - "width": sh["width"], - "height": sh["height"], - "mipCount": 1, - "settings": sh["settings"], - } - } - return {"asset_name": rec["name"], "asset": asset, "textures": textures, "material": material} - - -def game_fonts( - cat, - player, - game_ui_dir: Path, - ui_dir: Path, - language: str, - episode: dict | None = None, - master_dir: Path | None = None, -) -> dict: - """ui/fonts.json, ui/fonts/*.png and ui/languages.json of `language` from the ui/ directory advui.extract wrote - with fonts="game" for that language (`game_ui_dir`): its font records (each font's `fallbacks` reduced to the - exported assets in search order, fallback_chains), glyph pages, text materials, keyword sets, text records, glyph - coverage and TMP settings, next to the fonts-open ui/ui.json in `ui_dir`; the text shaders go to ui/shaders as - with open fonts. With the `episode` (and the master data for its UI labels), the texts a UIText drives get the - game's emoji sprite asset when they draw a sprite (sprite_needs; its code points leave coverage.missing). Returns a - summary.""" - game_ui_dir, ui_dir = Path(game_ui_dir), Path(ui_dir) - doc = json.loads((game_ui_dir / "ui.json").read_text(encoding="utf-8")) - ui_doc = json.loads((ui_dir / "ui.json").read_text(encoding="utf-8")) - texts = {n["path"]: n["text"] for n in doc["nodes"] if "text" in n} - open_paths = {n["path"] for n in ui_doc["nodes"] if "textStyle" in n} - if set(texts) != open_paths: - raise RuntimeError("the text nodes of the fonts-game and fonts-open UI differ") - fonts = doc["fonts"] - pages = sorted({g["packed"]["texture"] for f in fonts.values() for g in f["glyphs"].values() if "packed" in g}) - textures = {} - (ui_dir / PAGES_DIR).mkdir(parents=True, exist_ok=True) - for p in pages: - t = dict(doc["textures"][p]) - dst = f"{PAGES_DIR}/{Path(t['texture']).name}" - shutil.copyfile(game_ui_dir / t["texture"], ui_dir / dst) - t["texture"] = dst - textures[p] = t - names = {t["localized"]["material"] for t in texts.values()} | {f["material"] for f in fonts.values()} - materials = {n: doc["materials"][n] for n in sorted(names)} - ex = _exporter(cat, player, ui_dir / "_fonts") - chains = fallback_chains(ex, {t["localized"]["fontAsset"] for t in texts.values()}, set(fonts)) - fonts = {n: {**f, "fallbacks": chains[n]} for n, f in fonts.items()} - for t in texts.values(): # TMP's fallback materials of the text materials (fallback_material) - loc = t["localized"] - for fb in fonts[loc["fontAsset"]]["fallbacks"]: - name = f"{loc['material']} + {fb}" - if name not in materials: - materials[name] = fallback_material(materials[loc["material"]], materials[fonts[fb]["material"]], name) - driven = story_ui_text_targets(ex, ui_doc) & set(texts) - sprites, drawn = None, set() - if episode is not None and driven: - rec = advui.sprite_asset_record(ex) - shown = shown_texts(episode, ui_doc, master_dir, languages.column(language)[1:]) - chars = sorted({ord(ch) for s in shown for ch in s}) - fs = tmpfont.FontSet(ex) - - def lookup(name, u): - return fs.lookup(fs.by_key(fontsettings.font_key(ex.cat, name, name)), u) - - drawn = set().union(*drawn_by_sprites(rec, [texts[p] for p in sorted(driven)], chars, lookup).values()) - if sprite_needs(rec, shown, drawn): - sprites = game_sprite_asset(ex, ui_dir, rec) - if sprites is None: - driven, drawn = set(), set() - sprite_name = sprites["asset_name"] if sprites else None - for path, comps in text_components(ex, ui_doc).items(): - texts[path] = { - **texts[path], - **text_extras(path, comps), - **sprite_binding(path, comps, path in driven, sprite_name), - } - if sprites is not None: - textures.update(sprites["textures"]) - materials[sprites["material"]["material"]] = sprites["material"] - for n in sorted({t["localized"]["material"] for t in texts.values()}): # the text shaders for text_shaders - fa = next(t["localized"]["fontAsset"] for t in texts.values() if t["localized"]["material"] == n) - ex.material(ex.key_object(fontsettings.font_dir(fa) + n)) - keywords = text_shaders(ex, ui_dir, materials) - cov = doc["glyphCoverage"] - if sprites is not None: - cov = { - **cov, - "missing": [c for c in cov["missing"] if ord(c) not in drawn], - "sprites": {"characters": len(sprites["asset"]["characters"]), "missing": []}, - } - fonts_doc = { - "format": FONTS_FORMAT, - "language": language, - "source": "game", - "fonts": fonts, - "textures": textures, - "materials": materials, - "materialKeywords": keywords, - "texts": texts, - "coverage": cov, - "tmpSettings": doc["tmpSettings"], - "lineBreaking": line_breaking(player), - } - if sprites is not None: - fonts_doc["spriteAssets"] = {sprite_name: sprites["asset"]} - fonts_doc["emojiSpriteAsset"] = sprite_name - lang = textstyle.language_fonts(player) - _write(ui_dir, fonts_doc, language_doc(language, "game", fonts, texts, lang)) - shutil.rmtree(ui_dir / "_fonts", ignore_errors=True) - return { - "fonts": sorted(fonts), - "characters": cov["characters"], - "missing": len(cov["missing"]), - "glyphs": sum(len(f["glyphs"]) for f in fonts.values()), - "pages": len(textures), - } diff --git a/src/nnnotes/content/storyhost.py b/src/nnnotes/content/storyhost.py deleted file mode 100644 index 8183627..0000000 --- a/src/nnnotes/content/storyhost.py +++ /dev/null @@ -1,1506 +0,0 @@ -"""The host screen of an Overlay story (MasterAdv._playbackMode 1) -> /host/ and /ui/simple/. - -The game plays these episodes with App.Adv.SimpleAdvPlayer inside the screen that starts them, not in the ADV scene: -SimpleAdvView lays the characters out in the slots of the host's SimpleAdvLayoutRoot (each drawn by its own -CameraTargetRenderer) and attaches the UISimpleAdvTalkWindow under the root's TalkRoot. The host is the story group -of the episode: a tap talk (MasterStoryHomeSpotTapTalkEpisode) or an area talk (MasterHomeSpot._advId) of a home -spot (SpotManager), or the reward phase of a live result (LiveResultDisplayPresenter). - - host/host.json kind, the capture camera of a slot (CameraTargetRenderer prefab), the SimpleAdv root and its - SimpleAdvLayoutRoot fields, the sequences the host screen has played when the talk starts, - and per kind: - home spot.json + Spine files (hostspot.extract_spot) and room.glb + room.json - (hostspot.extract_room) under host/spot/; per placed glb node its object path, its - state after SpotBackground.Prepare and the SpotSceneRoot.SetObject "floor" render - queue; the room materials as saved; the "Spot" scene's object root, lights, - volumes and RenderSettings; the lights of the placed prefabs; the background - Volume; the Spine materials; the blur of renderer 0's UIRendererFeature; the - situation's ambient sound; shaders in host/shaders/ - afterlive the result background node and sprite, the reward panel, the substitutes - host/ui/ui.json the host UI in the advui record format (no text nodes) - ui/simple/ui.json per language: UISimpleAdvTalkWindow (and UISystemMessageWidget for an area talk) in the - advui record format with text records, fonts.json and glyph pages (storyfonts format, open - fonts) - -Same inputs give the same bytes. -""" - -from __future__ import annotations - -import json -import shutil -import struct -from pathlib import Path - -import numpy as np -import UnityPy - -from ..core.jsonio import dumps, write_json -from ..formats import shader as shader_mod -from ..source import languages, master -from ..unity import fontsettings, textstyle, tmpfont -from ..unity.bindings import RECT_PROPS -from ..unity.convert.animation import streamed_frames -from ..unity.export import Exporter, TexelPacker -from ..unity.reader import SceneGraph, deref, mesh_arrays, referent, script_class -from . import advui, hostspot, storyfonts -from .spot import atlas_pages - -FORMAT = "ournotes.story-host/1" -OVERLAY = 1 # MasterAdv._playbackMode of the episodes SimpleAdvPlayer plays -HOST_DIR = "host" -HOST_DOC = f"{HOST_DIR}/host.json" -HOST_UI_DOC = f"{HOST_DIR}/ui/ui.json" -SIMPLE_DIR = "simple" -SIMPLE_DOC = f"ui/{SIMPLE_DIR}/ui.json" -FONT_MODES = ("open", "game") -TMP_STUBS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet") -# game addresses; a missing one is loaded from "Emb" + address (emb_key) -ADDRESSES = { - "cameraTarget": "Common/Prefab/CameraTargetRenderer", - "talkWindow": "UI/Prefab/Parts/Adv/Talk/UISimpleAdvTalkWindow", - "systemMessage": "UI/Prefab/UISystemMessageWidget", - "spotWidget": "UI/Prefab/UISpotWidget", - "spotLetterbox": "UI/Prefab/Parts/Spot/UISpotLetterbox", - "spotScene": "Scene/Spot", - "resultWidget": "UI/Prefab/UILiveResultWidget", - "resultBackground": "UI/Prefab/UILiveResultBackgroundWidget", - "resultBackgroundSprite": "LowImage/LiveResult/live_result_background", -} -KIND_KEYS = { - "home": ("spotWidget", "spotLetterbox", "spotScene"), - "afterlive": ("resultWidget", "resultBackground", "resultBackgroundSprite"), -} -# story group kind (storysite.story_groups) -> (host kind, talk) -HOST_GROUPS = {"spotTalk": ("home", "tap"), "spot": ("home", "area"), "liveResult": ("afterlive", None)} -SUBSTITUTES = ["musicResultPanel", "rewardItems", "memberCard", "navigation", "gradeUpEffect"] -CLONE = "(Clone)" # Object.Instantiate's name suffix, removed by SpotBackground.Prepare -FLOOR_NAME, FLOOR_EXCLUDE, FLOOR_QUEUE = "floor", "shadow", 2000 # SpotSceneRoot.SetObject -REWARD_PANEL = "RewardPanel" -COMPONENT_HEADER = ("type", "class", "m_Name") -NO_EPISODE = {"text": {}, "commands": []} # the episode argument of storyfonts.shown_characters for a UI alone -DOTWEEN_FIELDS = ( - "defaultEaseType", - "defaultUpdateType", - "defaultTimeScaleIndependent", - "timeScale", - "defaultEaseOvershootOrAmplitude", - "defaultEasePeriod", -) -# components that light the renderers of a scene (lighting_components) -LIGHTING_TYPES = ("Light", "ReflectionProbe") -# RenderSettings values of the active scene the renderers' lighting depends on; object references by name -# (render_settings) -RENDER_SETTINGS_FIELDS = ( - "m_Fog", - "m_AmbientMode", - "m_AmbientSkyColor", - "m_AmbientEquatorColor", - "m_AmbientGroundColor", - "m_AmbientIntensity", - "m_SubtractiveShadowColor", - "m_DefaultReflectionMode", - "m_DefaultReflectionResolution", - "m_ReflectionBounces", - "m_ReflectionIntensity", - "m_UseRadianceAmbientProbe", - "m_IndirectSpecularColor", -) -RENDER_SETTINGS_REFS = ("m_SkyboxMaterial", "m_CustomReflection", "m_Sun") - - -# ---------------------------------------------------------------- host, keys -def host_of(groups: list[dict]) -> dict: - """The host of an Overlay story from its story groups (storysite.story_groups): a spotTalk group is a home tap - talk (SpotManager.OnTalkCharacter: spot and tapped SpotCharacter id), a spot group a home area talk - (SpotManager.PlayAreaTalkAsync), a liveResult group the live result reward phase. Exactly one such group.""" - hits = [g for g in groups if g.get("kind") in HOST_GROUPS] - if len(hits) != 1: - raise ValueError( - f"an Overlay story needs exactly one spotTalk, spot or liveResult group, not {len(hits)} " - f"(groups: {[g.get('kind') for g in groups]})" - ) - g = hits[0] - kind, talk = HOST_GROUPS[g["kind"]] - out = {"kind": kind, "group": {"kind": g["kind"], "id": g["id"]}} - if kind == "home": - chars = g.get("characters") or [] - out.update(spotId=g["spot"]["id"], talk=talk, characterId=chars[0]["id"] if talk == "tap" and chars else None) - return out - - -def emb_key(cat, address: str) -> str: - """The catalog key of a game address: the address itself, else the embedded copy "Emb" + address.""" - for key in (address, f"Emb{address}"): - if cat.has(key): - return key - raise KeyError(f"{address}: neither it nor Emb{address} is in the catalog") - - -def _parent(path: str) -> str: - return path.rsplit("/", 1)[0] if "/" in path else "" - - -def _trs(node: dict) -> dict: - return {k: node[k] for k in ("localPosition", "localRotation", "localScale")} - - -def _first(nodes: list[dict], path: str) -> dict: - hits = [n for n in nodes if n["path"] == path] - if len(hits) != 1: - raise RuntimeError(f"{path}: {len(hits)} nodes") - return hits[0] - - -# ---------------------------------------------------------------- records -def camera_target(nodes: list[dict]) -> dict: - """cameraTarget: the CameraTargetRenderer prefab (SimpleAdvView slot capture): the transforms of the parent of - `_stage` and `_captureCamera` (Container), of the stage and of the capture camera with its Camera and - UniversalAdditionalCameraData settings (the slot's RenderTexture, layer and stage offset are code).""" - crt = advui._component(nodes[0], ("CameraTargetRenderer",)) - if crt is None: - raise RuntimeError(f"{nodes[0]['path']}: no CameraTargetRenderer") - stage, cam = advui._ref_path(crt["_stage"]), advui._ref_path(crt["_captureCamera"]) - if not stage or not cam or _parent(cam) != _parent(stage): - raise RuntimeError("CameraTargetRenderer: stage and capture camera not under one parent") - container = _parent(stage) - node = _first(nodes, cam) - c = [x for x in node["components"] if x["type"] == "Camera"] - d = [x for x in node["components"] if x.get("class") == "UniversalAdditionalCameraData"] - if len(c) != 1 or len(d) != 1: - raise RuntimeError(f"{cam}: {len(c)} Camera, {len(d)} UniversalAdditionalCameraData") - c, d = c[0], d[0] - return { - "container": {"path": container, **_trs(_first(nodes, container))}, - "stage": {"path": stage, **_trs(_first(nodes, stage))}, - "camera": { - "path": cam, - "localPosition": node["localPosition"], - "localRotation": node["localRotation"], - "fieldOfView": c["field of view"], - "near": c["near clip plane"], - "far": c["far clip plane"], - "orthographic": bool(c["orthographic"]), - "orthographicSize": c["orthographic size"], - "clearFlags": c["m_ClearFlags"], - "backgroundColor": c["m_BackGroundColor"], - "hdr": bool(c["m_HDR"]), - "allowMSAA": bool(c["m_AllowMSAA"]), - "rendererIndex": d["m_RendererIndex"], - "renderPostProcessing": bool(d["m_RenderPostProcessing"]), - "antialiasing": d["m_Antialiasing"], - }, - } - - -def _fields(c: dict) -> dict: - return {k: v for k, v in c.items() if k not in COMPONENT_HEADER} - - -def layout_root(nodes: list[dict], path: str) -> dict: - """layoutRoot: the serialized fields of the SimpleAdvLayoutRoot on the node at `path` (the profile - SimpleAdvLayoutRoot.CreateProfile makes).""" - c = advui._component(_first(nodes, path), ("SimpleAdvLayoutRoot",)) - if c is None: - raise RuntimeError(f"{path}: no SimpleAdvLayoutRoot") - return _fields(c) - - -def kept(path: str, subtrees=(), singles=()) -> bool: - """A node kept in a host UI: inside one of `subtrees` (root included), one of `singles`, or an ancestor of - either.""" - return any(path == s or path.startswith(s + "/") for s in subtrees) or any( - s == path or s.startswith(path + "/") for s in (*subtrees, *singles) - ) - - -# serialized references kept as node paths: component class -> (record key, fields) -REF_RECORDS = { - "UIAdvTalkWindow": ( - "talkWindowRefs", - ( - "SpeakerText", - "TalkText", - "TalkArea", - "_speakerNameObjects", - "_talkNextIndicator", - "_talkBackground", - "_autoIcon", - "_fastIcon", - ), - ), - "UISystemMessage": ("systemMessage", ("_message", "_showAnimation", "_canvasGroup", "_animator")), - "SpotLetterboxBands": ("letterboxBands", ("_topBand", "_bottomBand", "_leftBand", "_rightBand")), -} -WIDGET_FIELDS = ("_canvasSortOrder", "_isMainWidget", "_useBlur", "_blurDuration", "_useGlassMorphism") -TWEEN_EVENT = 1 # DOTweenSequence command type of an event entry (persistent calls) - - -def _plain(v): - """A value with serialized references ({gameObject | transform}) as node paths.""" - if isinstance(v, list): - return [_plain(x) for x in v] - if isinstance(v, dict) and ("gameObject" in v or "transform" in v): - return advui._ref_path(v) - return v - - -def tween_calls(entry: dict) -> list[dict]: - """The persistent calls of a DOTweenSequence event entry (UnityEvent m_PersistentCalls).""" - calls = (((entry.get("_tweenEvent") or {}).get("m_PersistentCalls") or {}).get("m_Calls")) or [] - out = [] - for x in calls: - t, a = x.get("m_Target") or {}, x.get("m_Arguments") or {} - out.append( - { - "target": advui._ref_path(t), - "class": t.get("class") or t.get("component"), - "method": x.get("m_MethodName"), - "string": a.get("m_StringArgument"), - "float": a.get("m_FloatArgument"), - "int": a.get("m_IntArgument"), - "bool": a.get("m_BoolArgument"), - "mode": x.get("m_Mode"), - } - ) - return out - - -def node_record(node: dict) -> dict: - """The advui record of a prefab node (transform, rect, advui._layout_components) with the records of the - components the host screens add: aspectRatioFitter, simpleAdvLayoutRoot, safeArea (UISafeArea), the - references of UIAdvTalkWindow / UISystemMessage / SpotLetterboxBands as node paths, `widget` (the UIManager - widget fields of a *Widget component) and, per DOTweenSequence event entry, its persistent `calls`.""" - rec = { - "path": node["path"], - "name": node["name"], - "active": node["active"], - "localPosition": node["localPosition"], - "localRotation": node["localRotation"], - "localScale": node["localScale"], - "rect": node.get("rect"), - } - rec.update(advui._layout_components(node)) - for c in node["components"]: - cls = c.get("class") - if cls == "AspectRatioFitter": - rec["aspectRatioFitter"] = {k: c[k] for k in ("m_Enabled", "m_AspectMode", "m_AspectRatio")} - elif cls == "SimpleAdvLayoutRoot": - rec["simpleAdvLayoutRoot"] = _fields(c) - elif cls == "UISafeArea": - rec["safeArea"] = {k: _plain(v) for k, v in _fields(c).items()} - elif cls in REF_RECORDS: - key, names = REF_RECORDS[cls] - rec[key] = {k: _plain(c.get(k)) for k in names} - elif cls == "DOTweenSequence": - for entry, out in zip(c["_list"], rec["tweenSequence"]["list"]): - if entry.get("_commandType") == TWEEN_EVENT: - out["calls"] = tween_calls(entry) - elif cls and cls.endswith("Widget") and "_canvasSortOrder" in c: - rec["widget"] = {"class": cls, **{k: c[k] for k in WIDGET_FIELDS if k in c}} - return rec - - -# ---------------------------------------------------------------- clips, shaders -TRANSFORM_CURVES = {"m_LocalPosition": "xyz", "m_LocalScale": "xyz", "m_LocalRotation": "xyzw"} - - -def clip_view(ex: Exporter, o, graph, root: str) -> dict: - """An AnimationClip played by the Animator at `root` as Exporter.clip_curves writes it, bindings below the - animated root included: their properties are keyed ":". Properties: - RectTransform driven fields, CanvasGroup.m_Alpha, Transform local position / rotation / scale / euler angles, - GameObject.m_IsActive and MonoBehaviour fields ("."). Streamed curves as keys, dense curves as - {samples, sampleRate, beginTime} (the dense clip's frames), constant curves as {constant}; the discrete curves - that follow the streamed float curves in the stream (a sprite reference's ".m_Sprite": the value indexes - `pptrCurveMapping`, the names of the referenced objects) as {discrete: [[time, value]]}, the value held until - the next key. A binding whose path names no object of the prefab (the Animator ignores it) is keyed - "#:" and its crc listed in `unresolvedPaths`.""" - rec = ex.clip_record(o) - ex.bind_clip(o, graph, root) - tt, mc, st, de = rec["tt"], rec["muscle"], rec["streamed"], rec["dense"] - name, const = rec["name"], rec["constant"] - n_st, n_disc, n_de = st["curveCount"], st.get("discreteCurveCount", 0), de["m_CurveCount"] - if n_disc and n_de: - raise NotImplementedError(f"clip {name}: discrete and dense curves") - props, lost = [], [] - for r in rec["bindings"]: - if r["int"] or r["serializeReference"]: - raise NotImplementedError(f"clip {name}: binding {r['raw']}") - if r["path"] is None: - lost.append(r["pathCrc"]) - pre = f"#{r['pathCrc']}:" if r["path"] is None else f"{r['path']}:" if r["path"] else "" - tid, attr = r["typeID"], r["attribute"] - if tid == 224 and attr in RECT_PROPS: - names = ["RectTransform." + attr] - elif tid == 225 and attr == "m_Alpha": - names = ["CanvasGroup.m_Alpha"] - elif tid == 4 and r["attributeCrc"] == 4: # localEulerAnglesRaw - names = [f"Transform.localEulerAngles.{a}" for a in "xyz"] - elif tid == 4 and r["transformAttr"] and attr in TRANSFORM_CURVES: - names = [f"Transform.{attr}.{a}" for a in TRANSFORM_CURVES[attr]] - elif tid == 1 and attr == "m_IsActive": - names = ["GameObject.m_IsActive"] - elif tid == 114 and r["script"] and attr: - names = [f"{r['script'][1]}.{attr}"] - elif r["pptr"] and attr == "m_Sprite" and tid == 212: - names = ["SpriteRenderer.m_Sprite"] - else: - raise NotImplementedError(f"clip {name}: binding {r['raw']}") - if r["pptr"] != (n_st <= len(props) < n_st + n_disc): - raise NotImplementedError(f"clip {name}: reference curve {r['raw']} outside the discrete curves") - props += [pre + n for n in names] - if n_st + n_disc + n_de + len(const) != len(props): - raise RuntimeError(f"clip {name}: curve count != bound properties") - curves = {p: {"keys": []} for p in props[:n_st]} - curves.update({p: {"discrete": []} for p in props[n_st : n_st + n_disc]}) - for t, keys in streamed_frames(st["data"]): - for ci, c0, c1, c2, c3 in keys: - if ci < n_st: - curves[props[ci]]["keys"].append([t, c0, c1, c2, c3]) - else: - curves[props[ci]]["discrete"].append([t, c3]) - samples = list(de["m_SampleArray"]) - for j in range(n_de): - curves[props[n_st + j]] = { - "samples": samples[j::n_de][: de["m_FrameCount"]], - "sampleRate": de["m_SampleRate"], - "beginTime": de["m_BeginTime"], - } - for i, v in enumerate(const): - curves[props[n_st + n_disc + n_de + i]] = {"constant": v} - out = { - "name": name, - "sampleRate": tt["m_SampleRate"], - "startTime": mc["m_StartTime"], - "stopTime": mc["m_StopTime"], - "loopTime": bool(mc["m_LoopTime"]), - "events": tt["m_Events"], - "curves": curves, - "keyFormat": "[time, c0, c1, c2, c3]; value(t) = ((c0*x + c1)*x + c2)*x + c3, x = t - time, " - "from the last key with time <= t (first key at -FLT_MAX)", - } - if rec["pptrCurveMapping"]: - refs = [ex.deref(o, p) for p in rec["pptrCurveMapping"]] - out["pptrCurveMapping"] = [x.read_typetree().get("m_Name") if x is not None else None for x in refs] - if lost: - out["unresolvedPaths"] = lost - return out - - -def gles3_keywords(index: list, shader: str, keywords, what: str) -> list[str]: - """The keywords of `keywords` that the GLES3 variants of `shader` use; a GLES3 vertex program with exactly - those must exist (as advui checks its materials).""" - rec = next((r for r in index if r["name"] == shader), None) - if rec is None: - raise RuntimeError(f"shader {shader} of {what} not dumped") - gles = [v for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3"] - known = {k for v in gles for k in v["keywords"]} - want = sorted(k for k in keywords if k in known) - if not any(sorted(v["keywords"]) == want and v["stage"] == "vertex" for v in gles): - raise RuntimeError(f"{shader}: no GLES3 variant for {what} keywords {want}") - return want - - -def dump_gles3(objects: dict, out_dir: Path) -> tuple[list, dict]: - """Dump the Shader objects {name: object} in name order (shader.py layout) keeping only the GLES3 programs: - the index lists those, the other programs are not kept. -> (index, write_index summary).""" - index: list = [] - for name in sorted(objects): - shader_mod.dump_objects([objects[name]], out_dir, objects[name].assets_file.name, index) - for rec in index: - keep = [v for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3"] - for v in rec["variants"]: - if v not in keep: - (Path(out_dir) / v["file"]).unlink(missing_ok=True) - rec["variants"] = keep - for d in sorted(Path(out_dir).rglob("*"), reverse=True): - if d.is_dir() and not any(d.iterdir()): - d.rmdir() - return index, shader_mod.write_index(index, out_dir) - - -# ---------------------------------------------------------------- UI documents -class UiDoc: - """A ui.json of prefab parts in the advui record format, written into `out_dir`: node records (node_record; - a TMP text gets its advui text record `textStyle` in the LanguageMode `mode`, None: no text allowed), the packed - sprites and textures, the materials with their GLES3 keyword sets, the UI shaders, the DOTween defaults, and - per Animator its controller (Exporter.controller_states) and clips (clip_view); a node whose controller cannot - be exported loses its `animator` record and is listed in `animatorsDropped`.""" - - def __init__(self, cat, player, out_dir: Path, mode: int | None = None): - self.player, self.dir = player, Path(out_dir) - self.dir.mkdir(parents=True, exist_ok=True) - self.ex = Exporter( - cat, self.dir, player=player, textures="deferred", stub_assets=TMP_STUBS, font_stubs=fontsettings.builtin() - ) - self.packer = TexelPacker(self.dir) - self.styles = textstyle.TextStyles(self.ex, player, mode, fontsettings.builtin()) if mode is not None else None - self.nodes: list[dict] = [] - self.sources: list[tuple[str, dict]] = [] # (key, prefab node) of each record - self.roots: list[dict] = [] - self.doc: dict | None = None - - def prefab(self, key: str) -> list[dict]: - return self.ex.prefab(key)["nodes"] - - def add(self, key: str, nodes: list[dict], subtrees=None, singles=()) -> list[dict]: - """Records of the prefab `nodes` of `key` (every node, or those `kept` by subtrees / singles).""" - out = [] - for n in nodes: - if subtrees is not None and not kept(n["path"], subtrees, singles): - continue - rec = node_record(n) - if "text" in rec: - if self.styles is None: - raise RuntimeError(f"{n['path']}: a text node in a UI without texts") - if not (rec.get("localizeText") or {}).get("localizeEnabled"): - raise NotImplementedError(f"{n['path']}: text without LocalizeText") - rec["textStyle"] = self.styles.record( - n["path"], advui._need_component(n, advui.TMP_CLASSES), advui._component(n, ("LocalizeText",)) - ) - del rec["text"] - self.nodes.append(rec) - self.sources.append((key, n)) - out.append(rec) - self.roots.append({"root": nodes[0]["path"], "key": key}) - return out - - def sprite(self, key: str) -> dict: - """{spriteRef, name} of the sprite a key names (a Sprite, or a Texture2D's sprite sub-asset).""" - o = self.ex.key_object(key) - return self.ex.sprite(o) if o.type.name == "Sprite" else self.ex.key_sprite(key) - - def _controller(self, key: str, path: str): - env, graph = self.ex.load(key) - hits = [] - for o in env.objects: - if o.type.name == "Animator": - tf = graph.tf_of_go.get(o.read_typetree()["m_GameObject"]["m_PathID"]) - if tf is not None and graph.path(tf) == path: - hits.append(o) - if len(hits) != 1: - return None - ctrl = self.ex.deref(hits[0], hits[0].read_typetree()["m_Controller"]) - if ctrl is None: - return None - try: - states = self.ex.controller_states(ctrl) - clips = {} - for p in ctrl.read_typetree()["m_AnimationClips"]: - c = self.ex.deref(ctrl, p) - if c is None or c.type.name != "AnimationClip": - raise NotImplementedError("not an AnimationClip") - v = clip_view(self.ex, c, graph, path) - clips[f"{states['name']}/{v['name']}"] = v - except NotImplementedError: - return None - return states, clips - - def write(self, about: str, head: dict | None = None, tail: dict | None = None) -> dict: - clips, controllers, dropped = {}, {}, [] - for rec, (key, n) in zip(self.nodes, self.sources): - if "animator" in rec: - got = self._controller(key, n["path"]) - if got is None: - del rec["animator"] - dropped.append(rec["path"]) - else: - controllers[got[0]["name"]] = got[0] - clips.update(got[1]) - ex, refs = self.ex, set() - for rec in self.nodes: - if (rec.get("image") or {}).get("spriteRef"): - refs.add(rec["image"]["spriteRef"]) - if (rec.get("buttonImageState") or {}).get("selectedRef"): - refs.add(rec["buttonImageState"]["selectedRef"]) - for ref in sorted(refs): - s = ex.sprite_recs[ref] - tex = s["tex"].read_typetree()["m_Name"] - ex.pack_sprite(ref, self.packer, "sprites" if "FixUiSpriteAtlas" in tex else f"sprite_{s['name']}") - textures = self.packer.build() - sprites = {ex.sprite_recs[ref]["name"]: ex.packed_sprite(ref) for ref in sorted(refs)} - for rec in self.nodes: - (rec.get("image") or {}).pop("spriteRef", None) - (rec.get("buttonImageState") or {}).pop("selectedRef", None) - materials = { - "Default UI Material": { - "material": "Default UI Material", - "shader": {"shader": "UI/Default"}, - "keywords": [], - "textures": {}, - "ints": {}, - "floats": {}, - "colors": {}, - "shaderDefaults": True, - } - } - for _, n in self.sources: - for c in n["components"]: - if c.get("class") in ("Image", "RawImage") and c.get("m_Material"): - materials.setdefault(c["m_Material"]["material"], json.loads(dumps(c["m_Material"]))) - for m in materials.values(): - storyfonts._material_textures(m) - needed = {} - for name in sorted({m["shader"]["shader"] for m in materials.values()}): - if name == "UI/Default": - needed[name] = self.player.shader("UI/Default", file="unity_builtin_extra") - elif name in ex.shaders: - needed[name] = ex.shaders[name] - else: - raise RuntimeError(f"shader {name} not in the loaded bundles") - index, summary = ex.dump_shaders(self.dir / "shaders", needed) - keywords = { - k: gles3_keywords(index, m["shader"]["shader"], m["keywords"], f"material {k}") - for k, m in materials.items() - } - dts = self.player.mono(self.player.resource("DOTweenSettings")) - doc = { - "about": about, - **(head or {}), - "roots": self.roots, - "nodes": self.nodes, - "sprites": sprites, - "textures": textures, - "materials": materials, - "materialKeywords": keywords, - "clips": clips, - "controllers": controllers, - "animatorsDropped": dropped, - "dotween": {k: dts[k] for k in DOTWEEN_FIELDS}, - "shaders": {"index": "shaders/shaders.json", "names": summary["names"]}, - } - if self.styles is not None: - doc["textStyle"] = self.styles.summary() - doc.update(tail or {}) - write_json(self.dir / "ui.json", doc) - self.doc = doc - return doc - - -# ---------------------------------------------------------------- home -def container_go(env, internal_id: str): - """The GameObject an AssetBundle container entry names (a prefab's root).""" - for o in env.objects: - if o.type.name == "AssetBundle": - for path, info in o.read_typetree()["m_Container"]: - if path.lower() == internal_id.lower(): - go = deref(o, info["asset"]) - if go is not None and go.type.name == "GameObject": - return go - raise KeyError(f"{internal_id}: no GameObject in the AssetBundle containers") - - -def _go_ref(owner, pptr: dict) -> int | None: - if not pptr["m_PathID"]: - return None - if pptr["m_FileID"] == 0: - return pptr["m_PathID"] - o = deref(owner, pptr) - return o.path_id if o is not None else None - - -def background(cat, key: str, situation_name: str, ex: Exporter) -> dict: - """The placed background prefab `key` as SpotSceneRoot.SetObject leaves it, over the nodes and materials of - hostspot.extract_room (the same bundle load, glb_order): SpotBackground.Prepare(situation_name) on the prefab - root, the "floor" render queue (FindFirstChildWithName(background, "floor", "shadow"), its - GetComponentInChildren().material.renderQueue = 2000, before the root is activated), then - SetActiveFast(background, true). Objects outside the placed prefab (other prefabs of the bundle closure) are - not placed: inactive.""" - env = UnityPy.load(*[str(p) for p in cat.fetch_key(key)]) - graph = SceneGraph(env) - mf_by_go, mr_by_go = hostspot.mesh_components(env) - rendered = {go for _, go in mr_by_go} # SceneGraph: a GameObject path id is in one file only - root_go = container_go(env, cat.entry(key)["internal_id"]).path_id - root = graph.tf_of_go[root_go] - spot_bg, volumes = [], [] - for o in env.objects: - if o.type.name != "MonoBehaviour": - continue - tf = graph.tf_of_go.get(o.read_typetree()["m_GameObject"]["m_PathID"]) - if tf is None or not under(graph, tf, root): - continue - cls = script_class(o) - if cls == "SpotBackground" and tf == root: # background.GetComponent() - spot_bg.append(o) - elif cls == "Volume": - volumes.append((o, tf)) - if spot_bg: - tt = spot_bg[0].read_typetree() - entries = [ - (e.get("SituationName"), [_go_ref(spot_bg[0], h) for h in e.get("HideObjects") or []]) - for e in tt.get("_situationObjects") or [] - ] - prep = prepare(graph, root, situation_name, entries) - else: - prep = { - "name": situation_name.replace(CLONE, ""), - "matched": False, - "activate": [], - "hide": [], - "overrides": {}, - } - overrides = prep["overrides"] - floor = find_first_child_with_name(graph, root, FLOOR_NAME, FLOOR_EXCLUDE) - renderer = ( - None - if floor is None - else component_in_children( - graph, floor, lambda t: _go(graph, t) in rendered, lambda t: active_in_hierarchy(graph, t, overrides) - ) - ) - shown = {**overrides, root_go: True} - nodes, mats = glb_order(hostspot.drawn_meshes(mf_by_go, mr_by_go, graph, lambda p: None)) - rows = [] - for p in mats: - mat = p.deref() - tt = mat.read_typetree() - sh = referent(mat, tt["m_Shader"]) - rows.append((mat, tt, sh.read_typetree()["m_ParsedForm"]["m_Name"], sh)) - tex_index = glb_textures([(mat, tt, name) for mat, tt, name, _ in rows]) - if len(volumes) > 1: - raise NotImplementedError(f"{key}: {len(volumes)} Volume components") - volume = None - if volumes: - o, tf = volumes[0] - volume = { - "path": graph.path(tf), - "active": active_in_hierarchy(graph, tf, shown), - **volume_record(ex.component(o), graph.go[_go(graph, tf)]["m_Layer"]), - } - t = graph.tf[root] - return { - "roomRoot": { - "path": graph.path(root), - "localPosition": t["m_LocalPosition"], - "localRotation": t["m_LocalRotation"], - "localScale": t["m_LocalScale"], - }, - "roomNodes": [ - { - "path": graph.path(tf), - "placed": under(graph, tf, root), - "active": under(graph, tf, root) and active_in_hierarchy(graph, tf, shown), - "renderQueue": FLOOR_QUEUE if tf == renderer else None, - } - for tf, _ in nodes - ], - "roomMaterials": [room_material(mat, tt, name, tex_index) for mat, tt, name, _ in rows], - "situation": { - "name": prep["name"], - "matched": prep["matched"], - "activate": [graph.path(x) for x in prep["activate"]], - "hide": [graph.path(graph.tf_of_go[g]) if g in graph.tf_of_go else None for g in prep["hide"]], - }, - "floor": { - "object": graph.path(floor) if floor is not None else None, - "renderer": graph.path(renderer) if renderer is not None else None, - "renderQueue": FLOOR_QUEUE, - }, - "volume": volume, - "lights": [ - {"prefab": "background", **r} - for r in lighting_components(env, graph, root, lambda tf: active_in_hierarchy(graph, tf, shown)) - ], - "shaders": {name: sh for _, _, name, sh in reversed(rows)}, # the first material's object - "check": { - "nodes": [n for _, n in nodes], - "textures": len(tex_index), - "mirrored": sum( - 1 for tf, _ in nodes if under(graph, tf, root) and np.linalg.det(graph.world(tf)[:3, :3]) < 0 - ), - }, - } - - -def spine_records(env, ex: Exporter) -> tuple[dict, list, dict]: - """The Spine data of a situation prefab's bundle closure (loaded as hostspot.extract_spot loads it): the material of - every atlas page (Exporter.material records by name), per SpotSpineCharacter in spot.json's order its - SkeletonAnimation's MeshRenderer sorting order / layer and {atlas page: material name} (SpineAtlasAsset - materials in page order), and the shaders the materials name. -> (materials, characters, {name: shader}).""" - graph = SceneGraph(env) - by_cls: dict[str | None, list] = {} - mr_by_go = {} - for o in env.objects: - if o.type.name == "MonoBehaviour": - by_cls.setdefault(script_class(o), []).append(o) - elif o.type.name == "MeshRenderer": - mr_by_go[o.read_typetree()["m_GameObject"]["m_PathID"]] = o - materials, shaders, pages_of = {}, {}, {} - - def pages(sda) -> dict: - if sda.path_id not in pages_of: - out = {} - for ap in sda.read_typetree()["atlasAssets"]: - a = deref(sda, ap) - if a is None: - continue - att = a.read_typetree() - names = atlas_pages(hostspot.text_bytes(referent(a, att["atlasFile"]).read()).decode("utf-8")) - mats = [deref(a, m) for m in att["materials"]] - if len(names) != len(mats): - raise RuntimeError(f"atlas {att.get('m_Name')}: {len(names)} pages vs {len(mats)} materials") - for page, m in zip(names, mats): - rec = None if m is None else ex.material(m) - if rec is not None: - storyfonts._material_textures(rec) - materials.setdefault(rec["material"], rec) - sh = rec["shader"]["shader"] - shaders.setdefault(sh, ex.shaders[sh]) - out[page] = rec and rec["material"] - pages_of[sda.path_id] = out - return pages_of[sda.path_id] - - for o in by_cls.get("SkeletonDataAsset", []): - pages(o) - chars = [] - for o in by_cls.get("SpotSpineCharacter", []): - tt = o.read_typetree() - rec = { - "path": graph.path(graph.tf_of_go[tt["m_GameObject"]["m_PathID"]]), - "sortingOrder": None, - "sortingLayer": None, - "pageMaterials": {}, - } - anim = deref(o, tt["_animation"]) - if anim is not None: - at = anim.read_typetree() - mr = mr_by_go.get(at["m_GameObject"]["m_PathID"]) - if mr is not None: - mt = mr.read_typetree() - rec.update(sortingOrder=mt.get("m_SortingOrder"), sortingLayer=mt.get("m_SortingLayerID")) - sda = deref(anim, at["skeletonDataAsset"]) - rec["pageMaterials"] = dict(pages(sda)) if sda is not None else {} - chars.append(rec) - return {k: materials[k] for k in sorted(materials)}, chars, shaders - - -def render_settings(env) -> dict: - """renderSettings: the scene's RenderSettings (RENDER_SETTINGS_FIELDS; RENDER_SETTINGS_REFS as {"name"} or null) and - `lightmaps`, the lightmap count of its LightmapSettings. SpotManager.ActiveSpotScene makes the "Spot" scene the - active one, whose settings light the spot.""" - rs = [o for o in env.objects if o.type.name == "RenderSettings"] - lm = [o for o in env.objects if o.type.name == "LightmapSettings"] - if len(rs) != 1 or len(lm) != 1: - raise RuntimeError(f"scene: {len(rs)} RenderSettings and {len(lm)} LightmapSettings") - tt = rs[0].read_typetree() - out = {k: _plain(tt[k]) for k in RENDER_SETTINGS_FIELDS if k in tt} - for k in RENDER_SETTINGS_REFS: - ref = tt.get(k) - if not ref or not ref["m_PathID"]: - out[k] = None - continue - target = deref(rs[0], ref) - out[k] = {"name": target.read_typetree().get("m_Name") if target is not None else None} - out["lightmaps"] = len(lm[0].read_typetree().get("m_Lightmaps") or []) - return out - - -def lighting_components(env, graph, root_tf: int, active) -> list[dict]: - """The Light / ReflectionProbe components under `root_tf` in path order: path, type, m_Enabled, `active(tf)` - (activeInHierarchy as placed) and a Light's m_Type.""" - out = [] - for o in env.objects: - if o.type.name not in LIGHTING_TYPES: - continue - tt = o.read_typetree() - tf = graph.tf_of_go.get(tt["m_GameObject"]["m_PathID"]) - if tf is None or not under(graph, tf, root_tf): - continue - rec = { - "path": graph.path(tf), - "type": o.type.name, - "m_Enabled": int(tt.get("m_Enabled", 1)), - "active": bool(active(tf)), - } - if o.type.name == "Light": - rec["m_Type"] = tt.get("m_Type") - out.append(rec) - return sorted(out, key=lambda r: (r["path"], r["type"])) - - -def _active_path(nodes: list[dict], path: str) -> bool: - """activeInHierarchy of the node at `path` in a node list (it and its ancestors active).""" - return all(n["active"] for n in nodes if n["path"] == path or path.startswith(n["path"] + "/")) - - -def scene_root(ex: Exporter, key: str | None) -> dict: - """sceneRoot: the "Spot" scene's SpotSceneRoot: world TRS of `_objRoot` (the parent SetObject puts the - background and the situation under), its Light / ReflectionProbe components (with `active`, the object's - activeInHierarchy), its Volumes, the scene camera (`_sceneCamera`, advui camera record) and the scene's - RenderSettings (render_settings); null / [] when the scene or the component is not there.""" - out = {"key": key, "objRoot": None, "lights": [], "volumes": [], "sceneCamera": None, "renderSettings": None} - if key is None: - return out - nodes = ex.prefab(key)["nodes"] - out["renderSettings"] = render_settings(ex.load(key)[0]) - roots = [(n, c) for n in nodes for c in n["components"] if c.get("class") == "SpotSceneRoot"] - if len(roots) != 1: - return out - c = roots[0][1] - obj, cam = advui._ref_path(c.get("_objRoot")), advui._ref_path(c.get("_sceneCamera")) - out["objRoot"] = {"path": obj, **world_trs(nodes, obj)} if obj else None - out["lights"] = [ - { - "path": n["path"], - "type": x["type"], - "active": _active_path(nodes, n["path"]), - **{k: _plain(v) for k, v in x.items() if k != "type"}, - } - for n in nodes - for x in n["components"] - if x["type"] in LIGHTING_TYPES - ] - out["volumes"] = [ - {"path": n["path"], **volume_record(x, n["layer"])} - for n in nodes - for x in n["components"] - if x.get("class") == "Volume" - ] - out["sceneCamera"] = advui._camera(nodes, cam) if cam else None - return out - - -def home_host(cat, master_dir, player, host: dict, hdir: Path, keys: dict) -> dict: - """host.json `home`: hostspot.extract_spot and hostspot.extract_room into host/spot/, the placed background - (background), the Spine records, the Spot scene root, the blur, the ambient sound and the shaders (host/shaders/, - GLES3 programs only) with each material's GLES3 keyword set.""" - sdir = hdir / "spot" - doc = hostspot.extract_spot(cat, Path(master_dir), host["spotId"], sdir) - row = doc["master"] - # A tap talk's character need not be among the spot's SpotCharacters (spot.json `characters`): the game then has - # no tap target that starts the talk (HomeSpotAfterTalkRequestFactory.TrySelectAdv matches the tapped - # SpotCharacter's _characterId), and the player plays it without the camera focus - bg_key, sit_key = row["_backgroundAssetPath"], row["_situationAssetPath"] - room_doc = hostspot.extract_room(cat, bg_key, sdir / "room.glb") - ex = Exporter(cat, hdir, player=player, textures="deferred", inline_meshes=False) - sit_env = UnityPy.load(*[str(p) for p in cat.fetch_key(sit_key)]) - sit_go = container_go(sit_env, cat.entry(sit_key)["internal_id"]) - sit_name = sit_go.read_typetree()["m_Name"] - sit_graph = SceneGraph(sit_env) - sit_lights = [ - {"prefab": "situation", **r} - for r in lighting_components( - sit_env, - sit_graph, - sit_graph.tf_of_go[sit_go.path_id], - lambda tf: active_in_hierarchy(sit_graph, tf, {sit_go.path_id: True}), - ) - ] - bg = background(cat, bg_key, sit_name + CLONE, ex) - check_room(bg_key, glb_json(sdir / "room.glb"), room_doc, bg) - spine_mats, spine_chars, spine_shaders = spine_records(sit_env, ex) - if [c["path"] for c in spine_chars] != [c["path"] for c in doc["spineCharacters"]]: - raise RuntimeError(f"{sit_key}: Spine characters differ from spot.json's") - blur = advui.blur_record(player.graphics()) - blur_obj = [o for o in player.renderer_shaders(blur["renderer"]) if o.read().m_ParsedForm.m_Name == blur["shader"]] - if len(blur_obj) != 1: - raise RuntimeError(f"renderer {blur['renderer']}: {len(blur_obj)} shaders {blur['shader']}") - needed = dict(bg["shaders"]) - for name, o in [*spine_shaders.items(), (blur["shader"], blur_obj[0])]: - needed.setdefault(name, o) - index, summary = dump_gles3(needed, hdir / "shaders") - keywords = { - "room": [ - gles3_keywords(index, m["shader"], m["keywords"], f"room material {m['name']}") for m in bg["roomMaterials"] - ], - "spine": { - k: gles3_keywords(index, m["shader"]["shader"], m["keywords"], f"Spine material {k}") - for k, m in spine_mats.items() - }, - } - gles3_keywords(index, blur["shader"], [], "the blur pass") - return { - "spotId": host["spotId"], - "talk": host["talk"], - "characterId": host["characterId"], - "titleTextId": row.get("_advNameTextId") if host["talk"] == "area" else None, - "spot": f"{HOST_DIR}/spot/spot.json", - "room": f"{HOST_DIR}/spot/room.glb", - "roomRoot": bg["roomRoot"], - "roomNodes": bg["roomNodes"], - "roomMaterials": bg["roomMaterials"], - "situation": bg["situation"], - "floor": bg["floor"], - "sceneRoot": scene_root(ex, keys.get("spotScene")), - "volume": bg["volume"], - "lights": bg["lights"] + sit_lights, - "spineMaterials": spine_mats, - "spineCharacters": spine_chars, - "shaders": {"index": f"{HOST_DIR}/shaders/shaders.json", "names": summary["names"]}, - "materialKeywords": keywords, - "blur": blur, - "ambient": ambient_record(doc["situationSettings"], master_dir), - } - - -def home_ui(ui: UiDoc, keys: dict) -> tuple[str, dict, list]: - """host/ui nodes of a home talk: UISpotWidget's root, View, View/Fade, its DOTweenSequence_* nodes (and the - nodes their calls target), the SimpleAdv root (UISpotWidget._afterTalkContents) subtree; UISpotLetterbox. - The opened sequence is _fadeIn: the spot appears through SpotManager.PlayInAnimation -> SpotPresenter.FadeIn - (the Fade cover from 1 to 0); a spot change (ChangeSpotAsync) covers with _transitionIn and reveals with - _transitionOut, which ends with the cover at 0 as well. - -> (overlay root, layout root, opened sequences).""" - nodes = ui.prefab(keys["spotWidget"]) - w = advui._need_component(nodes[0], ("UISpotWidget",)) - overlay = advui._need_path(w["_afterTalkContents"]) - seqs = [advui._need_path(w[k]) for k in ("_transitionIn", "_transitionOut", "_fadeIn", "_fadeOut") if w.get(k)] - fade = f"{_parent(overlay)}/Fade" - ui.add(keys["spotWidget"], nodes, [overlay, fade, *seqs], _call_targets(nodes, seqs)) - ui.add(keys["spotLetterbox"], ui.prefab(keys["spotLetterbox"])) - return overlay, layout_root(nodes, overlay), [advui._need_path(w["_fadeIn"])] - - -def _call_targets(nodes: list[dict], seqs: list[str]) -> list[str]: - """The nodes the DOTweenSequence event entries of the nodes at `seqs` call.""" - out = [] - for n in nodes: - if n["path"] in seqs: - for c in n["components"]: - if c.get("class") == "DOTweenSequence": - for e in c["_list"]: - out += [x["target"] for x in tween_calls(e) if x["target"] and x["target"] not in out] - return out - - -def afterlive_ui(ui: UiDoc, keys: dict) -> tuple[str, dict, list, dict]: - """host/ui nodes of a live result talk: UILiveResultBackgroundWidget (its _backgroundImage showing the - live_result_background sprite); UILiveResultWidget's chain to the SimpleAdv root (_advSimpleContents) and its - subtree, the widget's and UISoloLiveMusicResultView's _inAnimation sequences and the nodes their calls target. - -> (overlay root, layout root, opened sequences, host.json `afterlive`).""" - bnodes = ui.prefab(keys["resultBackground"]) - bw = advui._need_component(bnodes[0], ("UILiveResultBackgroundWidget",)) - bg_path = advui._ref_path(bw["_backgroundImage"]) - recs = [r for r in ui.add(keys["resultBackground"], bnodes) if r["path"] == bg_path and r.get("image")] - if len(recs) != 1: - raise RuntimeError(f"{bg_path}: {len(recs)} image nodes") - sprite = ui.sprite(keys["resultBackgroundSprite"]) - recs[0]["image"].update(sprite=sprite["name"], spriteRef=sprite["spriteRef"]) - nodes = ui.prefab(keys["resultWidget"]) - rw = advui._need_component(nodes[0], ("UILiveResultWidget",)) - overlay = advui._need_path(rw["_advSimpleContents"]) - view = [ - (n, c) - for n in nodes - for c in n["components"] - if c.get("class") == "UISoloLiveMusicResultView" and overlay.startswith(n["path"] + "/") - ] - if len(view) != 1: - raise RuntimeError(f"{overlay}: {len(view)} UISoloLiveMusicResultView ancestors") - seqs = [ - advui._need_path(bw["_inAnimation"]), - advui._need_path(rw["_inAnimation"]), - advui._need_path(view[0][1]["_inAnimation"]), - ] - ui.add(keys["resultWidget"], nodes, [overlay, *seqs[1:]], _call_targets(nodes, seqs[1:])) - parts = overlay.split("/") - reward = next(("/".join(parts[: i + 1]) for i in range(len(parts) - 2, 0, -1) if parts[i] == REWARD_PANEL), None) - if reward is None: - raise RuntimeError(f"{overlay}: no {REWARD_PANEL} ancestor") - return ( - overlay, - layout_root(nodes, overlay), - seqs, - { - "backgroundNode": bg_path, - "backgroundSprite": sprite["name"], - "rewardPanel": reward, - "substitutes": list(SUBSTITUTES), - }, - ) - - -# ---------------------------------------------------------------- background (SpotBackground, SpotSceneRoot) -def _go(graph, tf: int) -> int: - return graph.tf[tf]["m_GameObject"]["m_PathID"] - - -def _children(graph, tf: int) -> list[int]: - return [c["m_PathID"] for c in graph.tf[tf]["m_Children"]] - - -def prepare(graph, background_tf: int, situation_name: str, entries: list) -> dict: - """SpotBackground.Prepare(situationName) on the background at `background_tf` (entries: its _situationObjects - as [(SituationName, [HideObjects GameObject path ids | None])]): "(Clone)" removed from the name; the first - entry of that name activates every child of transform.GetChild(0) and deactivates its HideObjects; no entry of - that name keeps the prefab state. -> {name, matched, activate [transform ids], hide [GameObject ids | None], - overrides {GameObject id: activeSelf}}.""" - name = situation_name.replace(CLONE, "") - kids = _children(graph, background_tf) - if not kids: - raise RuntimeError(f"{graph.path(background_tf)}: SpotBackground without a child (GetChild(0))") - for entry_name, hide in entries: - if entry_name == name: - activate = _children(graph, kids[0]) - overrides = {_go(graph, t): True for t in activate} - overrides.update({g: False for g in hide if g}) - return {"name": name, "matched": True, "activate": activate, "hide": list(hide), "overrides": overrides} - return {"name": name, "matched": False, "activate": [], "hide": [], "overrides": {}} - - -def active_in_hierarchy(graph, tf: int, overrides: dict) -> bool: - """GameObject.activeInHierarchy of the object at `tf`, with `overrides` {GameObject id: activeSelf} over the - serialized m_IsActive.""" - while tf in graph.tf: - go = _go(graph, tf) - if not overrides.get(go, bool(graph.go.get(go, {}).get("m_IsActive", 1))): - return False - tf = graph.tf[tf]["m_Father"]["m_PathID"] - return True - - -def under(graph, tf: int, root: int) -> bool: - """The transform `tf` is `root` or below it.""" - while tf in graph.tf: - if tf == root: - return True - tf = graph.tf[tf]["m_Father"]["m_PathID"] - return False - - -def find_first_child_with_name(graph, tf: int, contains: str, excludes: str) -> int | None: - """SpotSceneRoot.SetObject's FindFirstChildWithName: depth first over the descendants of `tf` (itself - excluded), children in order, the first whose name contains `contains` and not `excludes` (ordinal).""" - for c in _children(graph, tf): - name = graph.go.get(_go(graph, c), {}).get("m_Name", "") - if contains in name and excludes not in name: - return c - hit = find_first_child_with_name(graph, c, contains, excludes) - if hit is not None: - return hit - return None - - -def component_in_children(graph, tf: int, has, active) -> int | None: - """GameObject.GetComponentInChildren() (inactive objects skipped): depth first from `tf` itself, the first - transform whose object is active in the hierarchy (`active(tf)`) and has the component (`has(tf)`).""" - if active(tf) and has(tf): - return tf - for c in _children(graph, tf): - hit = component_in_children(graph, c, has, active) - if hit is not None: - return hit - return None - - -def glb_order(drawn, arrays_of=mesh_arrays) -> tuple[list, list]: - """The nodes and materials hostspot.extract_room writes, in its order, from its hostspot.drawn_meshes items - [(transform id, Mesh, material PPtrs)]: a mesh without vertex data or triangles, or whose submeshes all lack - a material, is no node; a material is numbered at its first use (hostspot.submesh_primitives order, by - hostspot.object_key). -> ([(transform id, mesh name)], [material PPtr]).""" - index: dict[tuple[str, int], int] = {} - mats, nodes = [], [] - - def material_for(p) -> int: - key = hostspot.object_key(p.deref()) - if key not in index: - index[key] = len(mats) - mats.append(p) - return index[key] - - for tf, mesh, pptrs in drawn: - arrays = arrays_of(mesh) - if arrays is None: - continue - tris = arrays[3] - if not any(len(t) for t in tris): - continue - if len(pptrs) < len(tris): - raise NotImplementedError(f"{mesh.m_Name}: {len(tris)} submeshes, {len(pptrs)} materials") - if hostspot.submesh_primitives(tris, pptrs, material_for, lambda i: None): - nodes.append((tf, mesh.m_Name)) - return nodes, mats - - -def glb_textures(materials: list) -> dict[tuple[str, int], int]: - """{texture hostspot.object_key: glb texture index} of hostspot.extract_room for its materials [(material object, - typetree, shader name)] in glb order: the main texture slot of each (hostspot.main_textures), numbered at first - use.""" - index: dict[tuple[str, int], int] = {} - for owner, tt, shader_name in materials: - for tex, _ in hostspot.main_textures(owner, tt, shader_name): - index.setdefault(hostspot.object_key(tex), len(index)) - return index - - -def _texture_key(owner, ref: dict) -> tuple[str, int] | None: - """hostspot.object_key of the texture a material's PPtr `ref` names (read from `owner`'s typetree); None for an - empty slot or a texture outside the loaded files (so not in the glb).""" - if not ref["m_PathID"]: - return None - try: - return hostspot.object_key(deref(owner, ref)) - except FileNotFoundError: - return None - - -def room_material(owner, tt: dict, shader_name: str, tex_index: dict) -> dict: - """A room material (`owner` its object, `tt` its typetree) as saved (its shader runs as the game's): name, - shader, keywords, float / int / colour properties, custom render queue, and per texture slot the glb texture - index (null when the texture is not in the glb), scale and offset.""" - sp = tt["m_SavedProperties"] - keywords = tt.get("m_ValidKeywords") - if keywords is None: - keywords = (tt.get("m_ShaderKeywords") or "").split() - return { - "name": tt["m_Name"], - "shader": shader_name, - "keywords": list(keywords), - "floats": dict(sp.get("m_Floats", [])), - "ints": dict(sp.get("m_Ints", [])), - "colors": dict(sp.get("m_Colors", [])), - "renderQueue": tt.get("m_CustomRenderQueue"), - "texEnvs": { - k: { - "texture": tex_index.get(_texture_key(owner, v["m_Texture"])), - "scale": [v["m_Scale"]["x"], v["m_Scale"]["y"]], - "offset": [v["m_Offset"]["x"], v["m_Offset"]["y"]], - } - for k, v in sp["m_TexEnvs"] - }, - } - - -def glb_json(path) -> dict: - """The JSON chunk of a binary glTF file.""" - data = Path(path).read_bytes() - (n,) = struct.unpack_from(" None: - """hostspot.extract_room's room.glb (its JSON chunk `glb`) and room.json (`room_doc`) have the nodes, materials and - glTF textures of the background record `bg` (background), in its order.""" - if ( - [n["name"] for n in glb["nodes"]] != bg["check"]["nodes"] - or room_doc["meshCount"] != len(bg["roomNodes"]) - or [m["name"] for m in glb.get("materials", [])] != [m["name"] for m in bg["roomMaterials"]] - or len(glb.get("textures", [])) != bg["check"]["textures"] - ): - raise RuntimeError(f"{key}: room.glb nodes / materials / textures differ from the background's order") - - -def volume_record(comp: dict, layer: int) -> dict: - """A Volume component (Exporter record: sharedProfile and its components inlined) -> enabled, isGlobal, - weight, priority, blendDistance, layer, profile name and per profile component its class, active flag and the - parameters it overrides (m_OverrideState set) with their values.""" - prof = comp.get("sharedProfile") or {} - comps = [] - for c in prof.get("components") or []: - if not c: - continue - over = { - k: _plain(v["m_Value"]) - for k, v in c.items() - if isinstance(v, dict) and "m_OverrideState" in v and v["m_OverrideState"] - } - comps.append({"class": c.get("asset"), "active": bool(c.get("active", 1)), **over}) - return { - "enabled": bool(comp.get("m_Enabled", 1)), - "isGlobal": bool(comp["m_IsGlobal"]), - "weight": comp["weight"], - "priority": comp["priority"], - "blendDistance": comp["blendDistance"], - "layer": layer, - "profile": prof.get("name"), - "components": comps, - } - - -def _qmul(a, b): - ax, ay, az, aw = a - bx, by, bz, bw = b - return ( - aw * bx + ax * bw + ay * bz - az * by, - aw * by - ax * bz + ay * bw + az * bx, - aw * bz + ax * by - ay * bx + az * bw, - aw * bw - ax * bx - ay * by - az * bz, - ) - - -def _rotate(q, v): - x, y, z, _ = _qmul(_qmul(q, (v[0], v[1], v[2], 0.0)), (-q[0], -q[1], -q[2], q[3])) - return (x, y, z) - - -def world_trs(nodes: list[dict], path: str) -> dict: - """World position, rotation and (lossy) scale of the node at `path` from the local TRS of it and its - ancestors (position through the parents' rotation and scale, rotation the product of the local rotations, - scale the product of the local scales).""" - parts = path.split("/") - pos, rot, scl = (0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0) - for i in range(1, len(parts) + 1): - n = _first(nodes, "/".join(parts[:i])) - p, r, s = n["localPosition"], n["localRotation"], n["localScale"] - d = _rotate(rot, (scl[0] * p["x"], scl[1] * p["y"], scl[2] * p["z"])) - pos = (pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]) - rot = _qmul(rot, (r["x"], r["y"], r["z"], r["w"])) - scl = (scl[0] * s["x"], scl[1] * s["y"], scl[2] * s["z"]) - return {"position": dict(zip("xyz", pos)), "rotation": dict(zip("xyzw", rot)), "scale": dict(zip("xyz", scl))} - - -def _rows(master_dir, name: str) -> dict: - try: - return {r["_id"]: r for r in master.table(Path(master_dir), name)} - except FileNotFoundError: - return {} - - -def ambient_record(settings: dict, master_dir) -> dict | None: - """ambient: the situation's ambient sound (SpotSituationSettings._soundId, ambientSoundVolume) with its cue - sheet and cue (MasterSound, MasterSoundCueSheet; null when the master data lacks the row); null without one.""" - sid = settings.get("_soundId") or 0 - if not sid: - return None - snd = _rows(master_dir, "MasterSound").get(sid) or {} - sheet = _rows(master_dir, "MasterSoundCueSheet").get(snd.get("_soundCueSheetID")) or {} - return { - "soundId": sid, - "volume": settings.get("ambientSoundVolume"), - "cueSheet": sheet.get("_cueSheetName"), - "cue": snd.get("_cueName"), - } - - -# ---------------------------------------------------------------- per language: ui/simple/ -def text_bindings(ui: UiDoc, lang: dict, mode: int) -> dict[str, dict]: - """{node path: text binding} of the text nodes of `ui`, as storyfonts.text_bindings makes them for the story - UI: the serialized TextMeshPro fields, storyfonts.text_extras and `localized` (LocalizeText in `mode`).""" - swap = textstyle.font_swap(lang, mode) - out = {} - for rec, (_, n) in zip(ui.nodes, ui.sources): - if "textStyle" not in rec: - continue - path = rec["path"] - if path in out: - raise RuntimeError(f"{path}: two text nodes") - c = advui._need_component(n, advui.TMP_CLASSES) - t = {k: c[k] for k in advui.TMP_FIELDS if k in c} - t.update( - { - "class": c["class"], - "enabled": c["m_Enabled"], - "fontAsset": c["m_fontAsset"]["name"], - "material": c["m_sharedMaterial"]["material"], - } - ) - t.update(storyfonts.text_extras(path, n["components"])) - t["localized"] = textstyle.localize_text(path, t["fontAsset"], t["material"], swap, lang, mode) - out[path] = t - return out - - -def story_text_materials(story_fonts: dict | None, lang: dict, mode: int) -> dict[str, set]: - """{localized game font asset: its localized game text materials} of the story UI's texts (the `texts` of - the story's ui/fonts.json, open fonts).""" - if not story_fonts or story_fonts.get("source") != "open": - return {} - swap, out = textstyle.font_swap(lang, mode), {} - for path, t in story_fonts.get("texts", {}).items(): - loc = textstyle.localize_text(path, t["fontAsset"], t["material"], swap, lang, mode) - out.setdefault(loc["fontAsset"], set()).add(loc["material"]) - return out - - -def open_fonts( - ui: UiDoc, - cat, - player, - episode: dict, - story_ui: dict, - language: str, - font, - master_dir, - texts: list[str], - story_fonts: dict | None = None, - font_of=storyfonts.no_font_file, -) -> dict: - """ui/simple/fonts.json and ui/simple/fonts/*.png from the font file `font` for the text nodes of `ui`, with the - story UI's font logic (storyfonts.open_font_set: the fallback chains, whose other font files `font_of` gives, - the missing glyph, the fallback materials): the texts are storyfonts.shown_texts over the story and its own - ui/ui.json (`story_ui`), the MasterText rows of the simple UI's text keys and `texts`. The glyph margin also - covers the story UI's text materials of the same font (`story_fonts`, the story's ui/fonts.json), so the glyph - pages equal the story UI's when the characters do.""" - tmpfont.require_extra() - mode, field = languages.mode(language), languages.column(language)[1:] - lang = textstyle.language_fonts(player) - bindings = text_bindings(ui, lang, mode) - if ui.doc is None: - raise RuntimeError("the simple UI's texts are read from its written ui.json") - shown = ( - storyfonts.shown_texts(episode, story_ui, master_dir, field) - + storyfonts.shown_texts(NO_EPISODE, ui.doc, master_dir, field) - + [s for s in texts if isinstance(s, str)] - ) - fset = storyfonts.open_font_set( - cat, - player, - ui.ex, - language, - font, - list(bindings.values()), - shown, - ui.dir, - font_of, - margin_materials=story_text_materials(story_fonts, lang, mode), - ) - materials = fset["materials"] - keywords = storyfonts.text_shaders(ui.ex, ui.dir, materials) - chars = {ord(ch) for s in shown for ch in s} - coverage = {"characters": len(chars), "missing": [chr(u) for u in sorted(fset["missing"])]} - if fset["substituted"]: - coverage["missingGlyph"] = [chr(u) for u in sorted(fset["substituted"])] - doc = { - "format": storyfonts.FONTS_FORMAT, - "language": language, - "source": "open", - "fonts": fset["fonts"], - "textures": fset["textures"], - "materials": materials, - "materialKeywords": keywords, - "texts": bindings, - "coverage": coverage, - "lineBreaking": storyfonts.line_breaking(player), - } - (ui.dir / storyfonts.FONTS_DOC).write_bytes( - dumps(doc, ensure_ascii=False, indent=1, sort_keys=True).encode("utf-8") + b"\n" - ) - return { - "fonts": sorted(fset["fonts"]), - "characters": len(chars), - "missing": len(fset["missing"]), - "pages": len(fset["textures"]), - } - - -def simple_ui( - cat, - master_dir, - player, - episode: dict, - ldir: Path, - language: str, - keys: dict, - host: dict, - title_id: str | None, - font, - font_of=storyfonts.no_font_file, -) -> dict: - """/ui/simple/: ui.json (UISimpleAdvTalkWindow, the window SimpleAdvPlayer.LoadInitialTalkWindow - attaches; for an area talk UISystemMessageWidget, which SystemMessageManager.ShowMessageByTextIdAsync shows the - spot's title with, and `texts.areaTitle`) and its open fonts.""" - sdir = Path(ldir) / "ui" / SIMPLE_DIR - story_ui = json.loads((Path(ldir) / "ui" / "ui.json").read_text(encoding="utf-8")) - fonts_doc = Path(ldir) / "ui" / storyfonts.FONTS_DOC - story_fonts = json.loads(fonts_doc.read_text(encoding="utf-8")) if fonts_doc.is_file() else None - ui = UiDoc(cat, player, sdir, languages.mode(language)) - ui.add(keys["talkWindow"], ui.prefab(keys["talkWindow"])) - texts = {} - if host.get("talk") == "area": - ui.add(keys["systemMessage"], ui.prefab(keys["systemMessage"])) - field = languages.column(language) - row = _rows(master_dir, "MasterText").get(title_id) - if row is None: - raise KeyError(f"area talk title {title_id} not in MasterText") - texts["areaTitle"] = row.get(field) or "" - ui.write( - f"SimpleAdvPlayer talk window UI data, {language}", - head={"language": advui.language_doc(language)}, - tail={"texts": texts} if texts else None, - ) - return open_fonts( - ui, cat, player, episode, story_ui, language, font, master_dir, list(texts.values()), story_fonts, font_of - ) - - -# ---------------------------------------------------------------- entry -def build( - cat, - master_dir, - player, - episode: dict, - story_dir, - language_dirs: dict, - groups: list, - *, - fonts: str = "open", - font_files: dict | None = None, - font_of=None, -) -> dict | None: - """The host data of `episode` (episode.json of story.build) when it is an Overlay episode (master - `_playbackMode` 1; else None and nothing written): /host/ (host.json, ui/, spot/, shaders/) and in - each language directory of `language_dirs` {language: dir with the story's ui/ui.json} its ui/simple/. - `groups`: the story groups of the episode (storysite.story_groups), which name the host (host_of); `fonts`: - "open" with `font_files` {language: storyfonts.FontFile} and `font_of` (language -> FontFile, the fallback - chains' other font files; default: those of `font_files`). -> {kind, doc, ui} (the story manifest's `host`).""" - if fonts not in FONT_MODES: - raise ValueError(f"fonts must be one of {FONT_MODES}, not {fonts!r}") - if (episode.get("master") or {}).get("_playbackMode") != OVERLAY: - return None - host = host_of(groups) - if fonts == "game": - raise NotImplementedError("the talk window UI of an Overlay story has open fonts only (fonts='open')") - font_files = dict(font_files or {}) - lost = [lang for lang in language_dirs if lang not in font_files] - if lost: - raise ValueError(f"no font file for {lost}") - - def own_font(code): - return font_files[code] if code in font_files else storyfonts.no_font_file(code) - - font_of = font_of or own_font - keys: dict = {k: emb_key(cat, ADDRESSES[k]) for k in ("cameraTarget", "talkWindow")} - if host.get("talk") == "area": - keys["systemMessage"] = emb_key(cat, ADDRESSES["systemMessage"]) - for k in KIND_KEYS[host["kind"]]: - try: - keys[k] = emb_key(cat, ADDRESSES[k]) - except KeyError: - if k != "spotScene": - raise - keys[k] = None # scene root: null / [] (scene_root) - hdir = Path(story_dir) / HOST_DIR - shutil.rmtree(hdir, ignore_errors=True) - ui = UiDoc(cat, player, hdir / "ui") - cam = camera_target(ui.prefab(keys["cameraTarget"])) - home = afterlive = None - if host["kind"] == "home": - overlay, layout, opened = home_ui(ui, keys) - home = home_host(cat, master_dir, player, host, hdir, keys) - else: - overlay, layout, opened, afterlive = afterlive_ui(ui, keys) - ui.write(f"host screen UI data of an Overlay story ({host['kind']}), no texts") - doc = { - "format": FORMAT, - "about": "Host screen of an Overlay story (SimpleAdvPlayer): the slot capture camera, the SimpleAdv " - "root and its layout, the host UI and the home spot or live result screen it is shown over", - "advId": episode.get("advId"), - "kind": host["kind"], - "group": host["group"], - "keys": keys, - "cameraTarget": cam, - "overlayRoot": overlay, - "layoutRoot": layout, - "openedSequences": opened, - "ui": HOST_UI_DOC, - "home": home, - "afterlive": afterlive, - } - write_json(hdir / "host.json", doc) - for language, ldir in language_dirs.items(): - shutil.rmtree(Path(ldir) / "ui" / SIMPLE_DIR, ignore_errors=True) - simple_ui( - cat, - master_dir, - player, - episode, - Path(ldir), - language, - keys, - host, - (home or {}).get("titleTextId"), - font_files[language], - font_of, - ) - return {"kind": host["kind"], "doc": HOST_DOC, "ui": SIMPLE_DOC} diff --git a/src/nnnotes/content/tmpassets.py b/src/nnnotes/content/tmpassets.py index 8f11b4d..d134750 100644 --- a/src/nnnotes/content/tmpassets.py +++ b/src/nnnotes/content/tmpassets.py @@ -1,19 +1,7 @@ -"""TextMesh Pro font assets read from an object table (content/prefabs.Objects). +"""TextMesh Pro fonts, materials and texture pages read from shared object tables. -The records tmpfont.py reads through an Exporter, read from the object layer instead: a font asset's fields from its -`json`, its source font file from its `font` file, its atlas pages from their `image` files (PNG of the decoded image), -its materials from their `json` (the shader by name, the textures as texture records). - - Texture a Texture2D of the table: its record and its RGBA texels - Font a TMP_FontAsset of the table (the fields tmpfont.Font reads) - FontSet the font assets reached from a font asset, with the TMP character lookup of tmpfont.FontLookup - build_font_settings the font settings table (fontsettings.FORMAT) read from the table - missing_glyph_character - the game's TMP Settings resource (tmpfont.missing_glyph_character) - -The glyph tables and runtime glyphs are the tmpfont functions (export_fonts, runtime_glyphs), which take an -export.MemoryPacker to hold the packed pages in memory. Reading a font needs the `fonts` extra (tmpfont.require_extra). -""" +Font and FontSet implement the glyph-generation protocols in unity.tmpfont. Texture supplies decoded RGBA +texels to unity.texels.MemoryPacker. build_font_settings assembles the localized font settings table.""" from __future__ import annotations @@ -26,9 +14,9 @@ import numpy as np from PIL import Image -from ..unity.export import texel_rows from ..unity.fontsettings import FONT_CLASS, font_key from ..unity.ids import parse_object_id +from ..unity.texels import texel_rows from ..unity.tmpfont import ( MISSING_GLYPH_CHARACTER, BuiltFontSettings, @@ -98,7 +86,7 @@ def pixels(self) -> np.ndarray: return self._pixels def record(self) -> dict: - """The record of a material's texture reference (export.Exporter.texture in deferred mode).""" + """The texture reference and its dimensions and sampler settings.""" return { "textureRef": self.oid, "name": self.name, @@ -157,7 +145,7 @@ def atlas_settings(self) -> list[dict]: return [a.settings for a in self.atlases if a is not None] def default_material(self) -> dict: - """The record of the default material (export.Exporter.material).""" + """The font asset's default material record.""" return self._fonts.material(self.material_oid) @@ -198,7 +186,7 @@ def of(self, oid: str) -> Font: return self._by_oid[oid] def material(self, oid: str) -> dict: - """The record of the Material `oid` (export.Exporter.material): its shader by name, its textures as texture + """The record of the Material `oid`: its shader by name, its textures as texture records, its other fields as in its `json`.""" doc = self.objects.json(oid) shader = _ref(doc["shader"]) @@ -234,7 +222,7 @@ def named_material(self, font: FontAsset, name: str) -> dict: class TableFontSettings(BuiltFontSettings): """FontSettings read from the font assets of an object table (FontSet): each font asset's bundle from - `bundle_of(name)`, the records as tmpfont.AssetFontSettings reads them from the game's assets.""" + `bundle_of(name)`, the records required by the font settings table.""" def __init__(self, fonts: FontSet, bundle_of: Callable[[str], str]): super().__init__() diff --git a/src/nnnotes/musicdata/__init__.py b/src/nnnotes/musicdata/__init__.py index 53e6fce..74cf1c5 100644 --- a/src/nnnotes/musicdata/__init__.py +++ b/src/nnnotes/musicdata/__init__.py @@ -244,16 +244,12 @@ def jacket_webp(image, size: int = JACKET_SIZE) -> bytes: def catalog_jacket(cat, size: int = JACKET_SIZE) -> Callable[[str], bytes]: """jacket(name) for a catalog: the WebP bytes (jacket_webp) of the Texture2D `Image/Jacket/`.""" - import tempfile - - from ..unity.export import Exporter - - ex = Exporter(cat, Path(tempfile.gettempdir()), textures="deferred") + from ..unity.reader import container_asset, load_closure def jacket(name: str) -> bytes: key = JACKET_KEY.format(jacket=name) try: - o = ex.key_object(key) + o = container_asset(load_closure(cat.fetch_key(key)), cat.entry(key)["internal_id"]) except KeyError: raise MusicDataError(f"jacket {name}: no asset {key}") from None except Exception as e: diff --git a/src/nnnotes/unity/export.py b/src/nnnotes/unity/export.py deleted file mode 100644 index 8ba2707..0000000 --- a/src/nnnotes/unity/export.py +++ /dev/null @@ -1,1622 +0,0 @@ -"""Typetree export with every reference resolved. - -`Exporter` turns Unity objects into JSON-ready values: textures become PNG -files (or, in deferred mode, references whose texels a `TexelPacker` copies -later), sprites carry their mesh and UVs (texture-bound or packed in a -SpriteAtlas), materials carry shader name, keywords and properties, meshes carry -vertex data (optional), ScriptableObject MonoBehaviours are inlined once, and -references to GameObjects, Transforms and components become hierarchy paths. -Prefabs and scenes are exported as ordered node lists (hierarchy order, -components in GameObject order). AnimationClips carry the Mecanim clip data the -Animator samples (streamed cubic keys, dense samples, constants) with their -bindings resolved to relative paths and property names; AnimatorControllers -carry layers, states, transitions and parameters. `export_key` exports any -addressable key by the type of its container asset. - -Clip, binding and controller JSON comes in several shapes (`clip_format`: -"generic", or "livescene" / "livenotes" for the live scene and live notes exports; -`clip_curves` and `controller_states` for the ADV UI). All of them are views of one record -(`clip_record` / `controller_record`), so resolution lives here only. -""" - -from __future__ import annotations - -import hashlib -import io -import json -import math -import re -from pathlib import Path -from typing import Literal - -import numpy as np -from PIL import Image -from UnityPy.files import ObjectReader -from UnityPy.helpers import MeshHelper - -from ..core import cache -from ..source.catalog import Catalog -from .bindings import ENGINE_PROPS, RECT_PROPS, crc, flat_names, material_attribute, material_prop_names -from .convert.animation import CLASS_ID, TRANSFORM_ATTR, controller_doc, streamed_frames -from .convert.material import material_doc -from .reader import ( - DEFAULT_RESOURCES, - SceneGraph, - check_texture, - deref, - external_path, - is_pptr, - load_closure, - mesh_arrays, - script_class, - texture_image, -) - -HEADER = ("m_GameObject", "m_Script") -_SCALARS = (int, float, str, bool, type(None)) -_LIBS = ("UnityPy", "Pillow", "texture2ddecoder", "astc-encoder-py", "etcpak", "numpy") -# decoded textures and PNG encodings memoized by content within this process -PNG = cache.bucket("png", salt=cache.source_salt(__file__, *_LIBS)) -PIXELS = cache.bucket("pixels", salt=cache.source_salt(__file__, *_LIBS)) - - -def _decode_inputs(tex) -> tuple: - """What UnityPy's Texture2D.image depends on (Texture2DConverter.get_image_from_texture2d): image data, size, - format, version, platform, platform blob.""" - r = tex.object_reader - blob = getattr(tex, "m_PlatformBlob", None) - return ( - bytes(tex.get_image_data()), - tex.m_Width, - tex.m_Height, - int(tex.m_TextureFormat), - tuple(getattr(r, "version", (0, 0, 0, 0))), - int(getattr(r, "platform", 0) or 0), - None if blob is None else bytes(blob), - ) - - -def texture_png(tex) -> bytes: - """PNG bytes of a Texture2D's decoded image as Pillow writes it by default (`tex.image.save(PNG)`), cached by - the decoder's inputs.""" - check_texture(tex) - k = PNG.key("texture", _decode_inputs(tex)) - data = PNG.get(k) - if data is None: - buf = io.BytesIO() - tex.image.save(buf, format="PNG") - data = buf.getvalue() - PNG.put(k, data) - return data - - -def packed_png(rgba: np.ndarray) -> bytes: - """PNG bytes (Pillow, optimize=True) of an RGBA array (bottom row first), cached by the array's content.""" - rgba = np.ascontiguousarray(rgba, np.uint8) - k = PNG.key("packed", rgba.shape, rgba.data) - data = PNG.get(k) - if data is None: - buf = io.BytesIO() - Image.fromarray(rgba[::-1].copy(), "RGBA").save(buf, format="PNG", optimize=True) - data = buf.getvalue() - PNG.put(k, data) - return data - - -def _safe(s: str) -> str: - return re.sub(r"[^A-Za-z0-9_.-]+", "_", s) - - -def _key(o) -> tuple[str, int]: - return (o.assets_file.name, o.path_id) - - -def _rid(o, name: str) -> str: - """Stable id for an object exported into a registry: name + hash of (file, path id).""" - return f"{name}#{hashlib.sha1(f'{o.assets_file.name}:{o.path_id}'.encode()).hexdigest()[:8]}" - - -def _events(tt: dict) -> list[dict]: - return [ - {x: e[x] for x in ("time", "functionName", "data", "floatParameter", "intParameter", "messageOptions")} - for e in tt["m_Events"] - ] - - -# --- texel blocks -> packed textures --------------------------------------------- -def grow_box(x0: float, y0: float, x1: float, y1: float, margin: int) -> tuple[int, int, int, int]: - """Texel box covering [x0, x1) x [y0, y1) grown by `margin` texels on every side.""" - return (math.floor(x0) - margin, math.floor(y0) - margin, math.ceil(x1) + margin, math.ceil(y1) + margin) - - -def wrap_mode(mode: int) -> Literal["wrap", "clip"]: - """The np.take mode of a texture's wrap mode (m_WrapU / m_WrapV: 0 repeat, 1 clamp).""" - if mode == 0: - return "wrap" - if mode == 1: - return "clip" - raise NotImplementedError(f"texture wrap mode {mode}") - - -def texel_rows(img, texture_format: int, width: int, height: int, name: str) -> np.ndarray: - """The RGBA texels of a decoded Texture2D image `img` (a PIL image; the texture's format, size and name), bottom - row first. Alpha8 keeps its value in the alpha channel.""" - if texture_format == 1: # Alpha8: value in the alpha channel - if img.mode == "L": - a = np.asarray(img, np.uint8) - elif img.mode == "RGBA": - a = np.asarray(img, np.uint8)[:, :, 3] - else: - raise NotImplementedError(f"Alpha8 decoded as {img.mode}") - arr = np.zeros(a.shape + (4,), np.uint8) - arr[:, :, 3] = a # rgb 0 (GL alpha texture); TMP reads .a only - else: - arr = np.asarray(img.convert("RGBA"), np.uint8) - if arr.shape[:2] != (height, width): - raise RuntimeError(f"{name}: decoded size {arr.shape}") - return arr[::-1].copy() # bottom row first (Unity v = 0) - - -def pack_group(group: str, handles: list[dict], pixels, width_limit: int) -> tuple[dict, np.ndarray]: - """The layout and the texels of one packed texture `group` of the blocks `handles` (TexelPacker.build). Returns its - record without its file (name, width, height, mipCount, settings, sources, blocks) and its RGBA array, bottom row - first. `pixels(tex)` gives the texels of a block's source texture. Every handle gets its texture (`group`) and its - integer `offset`.""" - settings = {json.dumps(h["settings"], sort_keys=True) for h in handles} - if len(settings) != 1: - raise RuntimeError(f"{group}: blocks from textures with different sampler settings") - # dedupe identical blocks of the same texture - uniq: dict[tuple, dict] = {} - for h in handles: - uniq.setdefault((h["key"], h["box"]), h) - blocks = sorted(uniq.values(), key=lambda h: (-(h["box"][3] - h["box"][1]), h["box"])) - x = y = shelf = 0 - width = max(width_limit, max(b["box"][2] - b["box"][0] for b in blocks)) - for b in blocks: - w, hgt = b["box"][2] - b["box"][0], b["box"][3] - b["box"][1] - if x + w > width: - x, y, shelf = 0, y + shelf, 0 - b["dst"] = (x, y) - x += w - shelf = max(shelf, hgt) - height = y + shelf - if len(blocks) == 1: # a single block keeps its own size - width = blocks[0]["box"][2] - blocks[0]["box"][0] - out = np.zeros((height, width, 4), np.uint8) - for b in blocks: - x0, y0, x1, y1 = b["box"] - dx, dy = b["dst"] - if "array" in b: - out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = b["array"] - continue - src = pixels(b["tex"]) - st = b["settings"] - rows = np.take(np.arange(src.shape[0]), np.arange(y0, y1), mode=wrap_mode(st["m_WrapV"])) - cols = np.take(np.arange(src.shape[1]), np.arange(x0, x1), mode=wrap_mode(st["m_WrapU"])) - out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = src[np.ix_(rows, cols)] - for h in handles: - d = uniq[(h["key"], h["box"])]["dst"] - h["offset"] = (d[0] - h["box"][0], d[1] - h["box"][1]) - h["texture"] = group - s = next(iter(handles))["settings"] - rec = { - "name": group, - "width": width, - "height": height, - "mipCount": 1, - "settings": s, - "sources": sorted({h["srcName"] for h in handles}), - "blocks": len(blocks), - } - return rec, out - - -def texture_block( - key: tuple, settings: dict, name: str, size: tuple[int, int], box: tuple[int, int, int, int], tex -) -> dict: - """A block that copies the texel box `box` of the texture `tex` (its texels come from the packer's `pixels`). - `key` identifies the texture, `settings` is its m_TextureSettings, `name` and `size` (width, height) its name and - size.""" - return {"tex": tex, "key": key, "settings": settings, "box": box, "srcName": name, "srcSize": size} - - -def array_block(name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: - """A block of generated texels: `rgba` (RGBA, bottom row first) placed at `origin` of its virtual texture.""" - ox, oy = origin - return { - "array": rgba, - "key": ("generated", name), - "settings": settings, - "box": (ox, oy, ox + rgba.shape[1], oy + rgba.shape[0]), - "srcName": name, - "srcSize": (rgba.shape[1], rgba.shape[0]), - } - - -class TexelPacker: - """Copies texel blocks of source textures into packed textures. - - Block coordinates are Unity texel coordinates (origin bottom-left). A block - [x0, x1) x [y0, y1) is read with the source texture's wrap mode, so texels - a sampler would fetch across an edge are the ones copied. Blocks keep their - size; a block's UVs move by the integer translation `offset` (in texels), so - sampling a packed texture returns the same texel values as the source. - """ - - WIDTH = 2048 - - def __init__(self, out_dir: Path): - self.out = Path(out_dir) - self._pixels: dict[tuple, np.ndarray] = {} - self.groups: dict[str, list] = {} - - def pixels(self, tex_obj) -> np.ndarray: - """The texture's texels as RGBA, bottom row first (read-only; decoded once per content, see PIXELS).""" - k = _key(tex_obj) - if k not in self._pixels: - tt = tex_obj.read_typetree() - tex = tex_obj.read() - ck = PIXELS.key(_decode_inputs(tex), tt["m_TextureFormat"] == 1, tt["m_Width"], tt["m_Height"]) - arr = PIXELS.get(ck) - if arr is None: - arr = texel_rows(texture_image(tex), tt["m_TextureFormat"], tt["m_Width"], tt["m_Height"], tt["m_Name"]) - arr.setflags(write=False) - PIXELS.put(ck, arr, arr.nbytes) - self._pixels[k] = arr - return self._pixels[k] - - def add(self, group: str, tex_obj, x0: int, y0: int, x1: int, y1: int) -> dict: - """Register a block; returns a handle filled in by build().""" - tt = tex_obj.read_typetree() - size = (tt["m_Width"], tt["m_Height"]) - h = texture_block(_key(tex_obj), tt["m_TextureSettings"], tt["m_Name"], size, (x0, y0, x1, y1), tex_obj) - self.groups.setdefault(group, []).append(h) - return h - - def add_array(self, group: str, name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: - """Register generated texels (RGBA, bottom row first) covering [origin, origin + shape) of a - virtual texture as one block; the whole array is copied (no wrap).""" - h = array_block(name, rgba, origin, settings) - self.groups.setdefault(group, []).append(h) - return h - - def build(self) -> dict: - textures = {} - for group, handles in self.groups.items(): - rec, out = pack_group(group, handles, self.pixels, self.WIDTH) - fn = f"textures/{_safe(group)}.png" - (self.out / "textures").mkdir(parents=True, exist_ok=True) - (self.out / fn).write_bytes(packed_png(out)) - textures[group] = {"texture": fn, **rec} - return textures - - -class MemoryPacker: - """TexelPacker over texture objects held in memory instead of Unity objects: a block's texture has `key`, `name`, - `settings` (m_TextureSettings), `width`, `height` and `pixels` (its RGBA texels, bottom row first). `build()` gives - the packed textures' records and their PNG bytes by file name, the same records and bytes TexelPacker writes.""" - - WIDTH = TexelPacker.WIDTH - - def __init__(self): - self.groups: dict[str, list] = {} - - @staticmethod - def pixels(tex) -> np.ndarray: - return tex.pixels - - def add(self, group: str, tex, x0: int, y0: int, x1: int, y1: int) -> dict: - """Register a block; returns a handle filled in by build().""" - h = texture_block(tex.key, tex.settings, tex.name, (tex.width, tex.height), (x0, y0, x1, y1), tex) - self.groups.setdefault(group, []).append(h) - return h - - def add_array(self, group: str, name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: - """Register generated texels as TexelPacker.add_array does.""" - h = array_block(name, rgba, origin, settings) - self.groups.setdefault(group, []).append(h) - return h - - def build(self) -> tuple[dict, dict[str, bytes]]: - """The records of the packed textures by group, and the PNG bytes of their pages by file name.""" - textures: dict[str, dict] = {} - pages: dict[str, bytes] = {} - for group, handles in self.groups.items(): - rec, out = pack_group(group, handles, self.pixels, self.WIDTH) - fn = f"textures/{_safe(group)}.png" - pages[fn] = packed_png(out) - textures[group] = {"texture": fn, **rec} - return textures, pages - - -CLIP_FORMATS = ("generic", "livescene", "livenotes") - - -class FontStub: - """A reference into a font bundle left out of a closure (Exporter `font_stubs`): the record of its object in - the font settings' `objects` ({type, class, name}).""" - - def __init__(self, rec: dict, path_id: int): - self.rec, self.path_id = rec, path_id - - -class Exporter: - """`player` resolves references into `unity default resources` (PlayerData); - `inline_meshes=False` emits meshes as name + vertex count; MonoBehaviour - classes in `stub_assets` (assets exported elsewhere, e.g. motions) are - emitted as {asset, name} only. - - `textures="deferred"`: textures and sprites become references - ({textureRef} / {spriteRef}) and nothing is written; `tex_objs` / `sprite_recs` - hold the objects, and `pack_sprite` / `packed_sprite` with a `TexelPacker` copy - only the texel blocks a draw can sample. - `clip_format`: the JSON shape of clips and bindings ("generic": inlined once, - bindings {path, typeID, class, attribute}; "livescene": inlined once, bindings with - crc32 keys, candidates and curve counts; "livenotes": clips and controllers in the - `clips` / `controllers` registries, referenced by id). - `follow`: referenced kinds exported instead of named ("AnimatorController", - "SpriteAtlas"). - `defer_writes` (textures "write"): texture records are made as usual but their PNG files are written only by - `write_textures`, which can leave out the files a caller would delete. - `class_of(o)`: the class name of a MonoBehaviour (default: unity.script_class, through its m_Script with the - script bundle loaded). - `font_stubs` (fontsettings.FontSettings): the bundles of its font assets are left out of every closure; a - reference into them resolves through its `objects` (a font asset to {asset, name}, a material to its record).""" - - def __init__( - self, - cat: Catalog, - out_dir: Path, - player=None, - inline_meshes: bool = True, - stub_assets: tuple[str, ...] = (), - textures: str = "write", - clip_format: str = "generic", - follow: tuple[str, ...] = (), - defer_writes: bool = False, - class_of=None, - font_stubs=None, - ): - if textures not in ("write", "deferred"): - raise ValueError(f"textures={textures!r}") - if clip_format not in CLIP_FORMATS: - raise ValueError(f"clip_format={clip_format!r}") - self.cat = cat - self.player = player - self.out = Path(out_dir) - self.inline_meshes = inline_meshes - self.stub_assets = stub_assets - self.deferred = textures == "deferred" - self.clip_format = clip_format - self.follow = tuple(follow) - self.defer_writes = defer_writes - self.script_class = class_of or script_class - self.font_stubs = font_stubs - self._font_bundles: frozenset | None = None - self._unwritten: dict[str, object] = {} # defer_writes: file -> Texture2D - self.textures: dict[tuple, dict] = {} - self.shaders: dict[str, ObjectReader] = {} - self.tex_objs: dict[str, object] = {} # deferred: "file:pathID" -> Texture2D - self.sprite_recs: dict[str, dict] = {} # deferred: "file:pathID" -> sprite render record - self.clips: dict[str, dict] = {} # livenotes format: id -> clip - self.controllers: dict[str, dict] = {} # livenotes format: id -> controller - self.material_props: set[str] = set() # float/colour property names of exported materials - self._graph_of_file: dict[int, SceneGraph] = {} - self._done: dict[tuple, dict] = {} # MonoBehaviour/clip/controller/atlas -> reference once exported - self._component_ref: dict[tuple, str] = {} - self._objects: dict[tuple, ObjectReader] = {} - self._go_file: dict[int, str] = {} - self._loaded: dict[str, tuple] = {} - self._clip_recs: dict[tuple, dict] = {} - self._atlas_maps: dict[tuple, tuple] = {} - self._names_of: dict[tuple, list] = {} # object -> flat field names - self._names_by_class: dict = {} # typeID | ("MonoBehaviour", class) -> names of exported components - self._class_of: dict[tuple, str | None] = {} - self._rel_tables: dict[tuple, dict] = {} - self._gos_at_cache: dict[tuple, list] = {} - self._path_sources: list[tuple[str, dict]] = [] - self._crcs: dict[str, int] = {} - - # --- loading --------------------------------------------------------- - def load(self, key: str): - """(env, graph) of a key's bundle closure, loaded once.""" - if key in self._loaded: - return self._loaded[key] - if self.font_stubs is None: - env = load_closure(self.cat.fetch_key(key)) - else: - env = load_closure(self.cat.fetch_key(key, self.font_bundles())) - graph = SceneGraph(env) - go_file = self._go_file - for o in env.objects: - self._graph_of_file.setdefault(id(o.assets_file), graph) - self._objects[_key(o)] = o - if o.type.name == "Shader": - self._shader(o) - elif o.type.name == "GameObject": - go_file[o.path_id] = o.assets_file.name - # Components living on GameObjects are exported where they sit in the - # hierarchy; references to them elsewhere become {component, gameObject}. - for go_pid, go in graph.go.items(): - path = graph.path(graph.tf_of_go[go_pid]) if go_pid in graph.tf_of_go else go["m_Name"] - for c in go["m_Component"]: - self._component_ref[(go_file[go_pid], c["component"]["m_PathID"])] = path - self._loaded[key] = (env, graph) - return env, graph - - def font_bundles(self) -> frozenset: - """The names of the bundles that hold the font assets of `font_stubs` (none without it).""" - if self._font_bundles is None: - self._font_bundles = frozenset() if self.font_stubs is None else self.font_stubs.bundles(self.cat) - return self._font_bundles - - def font_stub(self, o: FontStub) -> dict: - """The value of a reference into a font bundle left out (`font_stubs`): a material's record, a font asset's - {asset, name}.""" - if o.rec["type"] == "Material" and self.font_stubs is not None: - return self.font_stubs.material(None, o.rec["name"]) - return {"asset": o.rec["class"], "name": o.rec["name"]} - - def deref(self, owner, pptr: dict) -> ObjectReader | None: - """The object `pptr` (read from `owner`'s typetree) names; None for a null reference. A reference into a font - bundle left out (`font_stubs`) is an error here: only `ref` writes such a reference, as its record.""" - o = self._lookup(owner, pptr) - if isinstance(o, FontStub): - raise FileNotFoundError( - f"path id {o.path_id} is in a font bundle left out of the closure ({o.rec['name']})" - ) - return o - - def referent(self, owner, pptr: dict) -> ObjectReader: - """The object a reference that must not be null names (KeyError naming `owner` for a null one).""" - o = self.deref(owner, pptr) - if o is None: - raise KeyError(f"{owner.type.name} {owner.path_id}: a required reference is null") - return o - - def _lookup(self, owner, pptr: dict) -> ObjectReader | FontStub | None: - if not pptr["m_PathID"]: - return None - if external_path(owner, pptr) == DEFAULT_RESOURCES: - if self.player is None: - raise RuntimeError("reference into unity default resources needs player data") - return self.player.deref(owner, pptr) - try: - return deref(owner, pptr) - except FileNotFoundError: - rec = self.font_stubs.objects.get(pptr["m_PathID"]) if self.font_stubs is not None else None - if rec is None: - raise - return FontStub(rec, pptr["m_PathID"]) - - def graph(self, o) -> SceneGraph: - return self._graph_of_file[id(o.assets_file)] - - # --- values ---------------------------------------------------------- - def value(self, owner, v): - if is_pptr(v): - return self.ref(owner, v) - if isinstance(v, dict): - return {k: self.value(owner, x) for k, x in v.items()} - if isinstance(v, list): - if all(type(x) in _SCALARS for x in v): # numbers / strings: nothing to resolve - return list(v) - return [self.value(owner, x) for x in v] - if isinstance(v, (bytes, bytearray)): - return v.hex() - return v - - def ref(self, owner, pptr: dict): - o = self._lookup(owner, pptr) - if o is None: - return None - if isinstance(o, FontStub): - return self.font_stub(o) - t = o.type.name - if t in ("AnimatorController", "AnimatorOverrideController") and "AnimatorController" in self.follow: - return self.controller(o) - if t == "SpriteAtlas" and "SpriteAtlas" in self.follow: - return self.atlas(o) - if t == "Texture2D": - return self.texture(o) - if t == "Sprite": - return self.sprite(o) - if t == "Material": - return self.material(o) - if t == "Shader": - return {"shader": self._shader(o)} - if t == "Mesh": - return self.mesh(o) - if t == "AnimationClip": - return self.clip(o) - if t == "GameObject": - g = self.graph(o) - return {"gameObject": g.path(g.tf_of_go[o.path_id])} - if t in ("Transform", "RectTransform"): - return {"transform": self.graph(o).path(o.path_id)} - if _key(o) in self._component_ref: - desc: dict[str, str | None] = {"component": t, "gameObject": self._component_ref[_key(o)]} - if t == "MonoBehaviour": - desc["class"] = self.script_class(o) - return desc - if t == "MonoBehaviour": - return self.scriptable(o) - if t == "MonoScript": - ms = o.read() - return {"script": f"{ms.m_Namespace}.{ms.m_ClassName}".lstrip(".")} - return {"object": t, "name": getattr(o.read(), "m_Name", None)} - - # --- shaders, textures, sprites, materials, meshes --------------------- - def add_shader(self, o) -> str: - """Register a Shader object (dumped by the caller from `shaders`); returns its name.""" - name = o.read().m_ParsedForm.m_Name - self.shaders.setdefault(name, o) - return name - - _shader = add_shader - - def texture(self, o) -> dict: - tt = o.read_typetree() - if self.deferred: - ref = f"{o.assets_file.name}:{o.path_id}" - self.tex_objs[ref] = o - return { - "textureRef": ref, - "name": tt["m_Name"], - "width": tt["m_Width"], - "height": tt["m_Height"], - "format": tt["m_TextureFormat"], - "mipCount": tt.get("m_MipCount", 1), - "settings": tt.get("m_TextureSettings"), - } - k = _key(o) - if k not in self.textures: - tex = o.read() - h = hashlib.sha1(f"{k[0]}:{k[1]}".encode()).hexdigest()[:8] - fn = f"textures/{_safe(tt['m_Name'])}-{h}.png" - (self.out / "textures").mkdir(parents=True, exist_ok=True) - if self.defer_writes: - self._unwritten[fn] = tex - else: - (self.out / fn).write_bytes(texture_png(tex)) - self.textures[k] = { - "texture": fn, - "name": tt["m_Name"], - "width": tt["m_Width"], - "height": tt["m_Height"], - "format": tt["m_TextureFormat"], - "mipCount": tt.get("m_MipCount", 1), - "colorSpace": tt.get("m_ColorSpace"), - "settings": tt.get("m_TextureSettings"), - } - return self.textures[k] - - def write_textures(self, skip=()) -> list[str]: - """defer_writes: write the PNG file of every texture recorded since the last call, except the files in - `skip` (paths relative to the output directory, "textures/.png"). Returns the files written.""" - skip, written = set(skip), [] - for fn, tex in sorted(self._unwritten.items()): - if fn not in skip: - (self.out / fn).write_bytes(texture_png(tex)) - written.append(fn) - self._unwritten.clear() - return written - - def sprite_render_data(self, o, tt: dict | None = None): - """(render data, texture owner, atlas typetree) of a Sprite: its own m_RD when it is bound to a - texture; for a sprite packed into a SpriteAtlas (m_RD.texture null) the atlas' m_RenderDataMap entry - under the sprite's m_RenderDataKey, which holds texture, textureRect and uvTransform (Unity - SpriteAtlas runtime binding). (None, None, None) when the sprite has neither.""" - tt = tt or o.read_typetree() - rd = tt["m_RD"] - if rd["texture"]["m_PathID"]: - return rd, o, None - atlas_o = self.deref(o, tt["m_SpriteAtlas"]) - if atlas_o is None: - return None, None, None - k = _key(atlas_o) - if k not in self._atlas_maps: - at = atlas_o.read_typetree() - index: dict[tuple, list] = {} - for rk, d in at["m_RenderDataMap"]: - index.setdefault((json.dumps(rk[0], sort_keys=True), rk[1]), []).append(d) - self._atlas_maps[k] = (at, index) - at, index = self._atlas_maps[k] - key = tt["m_RenderDataKey"] - hits = index.get((json.dumps(key[0], sort_keys=True), key[1]), []) - if len(hits) != 1: - raise RuntimeError(f"sprite {tt['m_Name']}: {len(hits)} atlas render data entries") - return hits[0], atlas_o, at - - def sprite(self, o) -> dict: - tt = o.read_typetree() - src, owner, at = self.sprite_render_data(o, tt) - if self.deferred: - return self._deferred_sprite(o, tt, src, owner) - if src is None: - return {"sprite": tt["m_Name"], "texture": None, "unboundAtlasTags": tt.get("m_AtlasTags")} - tex = self.texture(self.referent(owner, src["texture"])) - rd = tt["m_RD"] - vd = rd["m_VertexData"] - raw = vd["m_DataSize"] - raw = raw if isinstance(raw, (bytes, bytearray)) else bytes(raw) - n = vd["m_VertexCount"] - ch0 = vd["m_Channels"][0] - if ch0["format"] != 0 or ch0["dimension"] != 3 or ch0["stream"] != 0 or ch0["offset"] != 0: - raise NotImplementedError(f"sprite {tt['m_Name']}: position channel {ch0}") - pos = np.frombuffer(raw, " dict: - if src is None: - raise RuntimeError(f"sprite {tt['m_Name']}: no texture and no atlas") - tex = self.referent(owner, src["texture"]) - if src["alphaTexture"]["m_PathID"] or src.get("secondaryTextures"): - raise NotImplementedError(f"sprite {tt['m_Name']}: alpha/secondary textures") - raw = src["settingsRaw"] - if (raw >> 2) & 0xF: - raise NotImplementedError(f"sprite {tt['m_Name']}: packing rotation {(raw >> 2) & 0xF}") - if src.get("downscaleMultiplier", 1.0) != 1.0: - raise NotImplementedError(f"sprite {tt['m_Name']}: downscaleMultiplier") - ref = f"{o.assets_file.name}:{o.path_id}" - self.tex_objs[f"{tex.assets_file.name}:{tex.path_id}"] = tex - self.sprite_recs[ref] = { - "name": tt["m_Name"], - "tex": tex, - "rect": tt["m_Rect"], - "border": tt["m_Border"], - "pivot": tt["m_Pivot"], - "pixelsPerUnit": tt["m_PixelsToUnits"], - "textureRect": src["textureRect"], - "textureRectOffset": src["textureRectOffset"], - "settingsRaw": raw, - } - return {"spriteRef": ref, "name": tt["m_Name"]} - - def pack_sprite(self, ref: str, packer: TexelPacker, group: str, margin: int = 1) -> None: - """Deferred mode: register the texels a sprite draw samples (its texture rect grown by `margin` - texels for bilinear filtering) as a block of `group`.""" - s = self.sprite_recs[ref] - tr = s["textureRect"] - s["_block"] = packer.add( - group, s["tex"], *grow_box(tr["x"], tr["y"], tr["x"] + tr["width"], tr["y"] + tr["height"], margin) - ) - - def packed_sprite(self, ref: str) -> dict: - """Deferred mode, after packer.build(): the sprite with its texture rect moved into the packed texture.""" - s = self.sprite_recs[ref] - b = s.pop("_block") - tr = s["textureRect"] - return { - "texture": b["texture"], - "rect": s["rect"], - "border": s["border"], - "pivot": s["pivot"], - "pixelsPerUnit": s["pixelsPerUnit"], - "settingsRaw": s["settingsRaw"], - "textureRect": { - "x": tr["x"] + b["offset"][0], - "y": tr["y"] + b["offset"][1], - "width": tr["width"], - "height": tr["height"], - }, - "textureRectOffset": s["textureRectOffset"], - "source": b["srcName"], - } - - def material(self, o) -> dict: - m = material_doc(o.read_typetree(), lambda p: self.ref(o, p)) - self.material_props.update(m["floats"]) - self.material_props.update(m["colors"]) - return m - - def mesh(self, o) -> dict: - m = o.read() - if not self.inline_meshes: - return {"mesh": m.m_Name, "vertexCount": m.m_VertexData.m_VertexCount} - arrays = mesh_arrays(m) - if arrays is None: - return {"mesh": m.m_Name, "empty": True} - v, n, uv, tris = arrays - out = { - "mesh": m.m_Name, - "vertices": v.tolist(), - "normals": n.tolist(), - "uv0": uv.tolist(), - "submeshes": [t.reshape(-1).tolist() for t in tris], - } - tt = o.read_typetree() - if tt.get("m_BindPose"): - out.update(self._skin(m, tt, len(v))) - return out - - @staticmethod - def _skin(m, tt: dict, vertex_count: int) -> dict: - """A skinned mesh's skin as serialized: `m_Skin` (BoneWeights4 per vertex: weight[4], boneIndex[4], from - the BlendWeight / BlendIndices vertex channels, or m_Skin in older layouts; unused slots 0), - `m_BindPose` (Matrix4x4f per bone, e00..e33), `m_BoneNameHashes`, `m_RootBoneNameHash`. Bone index i is - the renderer's m_Bones[i].""" - h = MeshHelper.MeshHandler(m) - h.process() - w, ix = h.m_BoneWeights, h.m_BoneIndices - if not w or not ix or len(w) != vertex_count or len(ix) != vertex_count: - raise RuntimeError(f"mesh {tt['m_Name']}: {len(w or [])} bone weights for {vertex_count} vertices") - skin = [] - for ws, bs in zip(w, ix): - if len(ws) > 4 or len(bs) != len(ws): - raise NotImplementedError(f"mesh {tt['m_Name']}: {len(ws)} weights / {len(bs)} bone indices per vertex") - pad = 4 - len(ws) - skin.append({"weight": [float(x) for x in ws] + [0.0] * pad, "boneIndex": [int(x) for x in bs] + [0] * pad}) - return { - "m_Skin": skin, - "m_BindPose": tt["m_BindPose"], - "m_BoneNameHashes": tt.get("m_BoneNameHashes", []), - "m_RootBoneNameHash": tt.get("m_RootBoneNameHash"), - } - - # --- field names (crc32 resolution) ------------------------------------ - def _crc_of(self, name: str) -> int: - c = self._crcs.get(name) - if c is None: - c = self._crcs[name] = crc(name) - return c - - def _names(self, o, tt: dict | None = None) -> list[str]: - """Flat field paths of an object's typetree, computed once.""" - k = _key(o) - if k not in self._names_of: - self._names_of[k] = flat_names(tt if tt is not None else o.read_typetree()) - return self._names_of[k] - - def _mono_class(self, o) -> str | None: - k = _key(o) - if k not in self._class_of: - try: - self._class_of[k] = self.script_class(o) - except Exception: # script not loadable from this closure - self._class_of[k] = None - return self._class_of[k] - - def _register_names(self, o, tt: dict) -> None: - """Field paths of an exported component, by class (names a clip binding can target).""" - k = ("MonoBehaviour", self._mono_class(o)) if o.type.name == "MonoBehaviour" else o.type.value - self._names_by_class.setdefault(k, set()).update(self._names(o, tt)) - - # --- clip bindings ----------------------------------------------------- - def _rel_table(self, graph: SceneGraph) -> dict[int, set[str]]: - """crc32 -> transform paths relative to any of their ancestors (or themselves: ""). - - An Animator binding path is relative to the Animator's GameObject, which need not be - the hierarchy root, and the clip does not say which GameObject plays it.""" - t = self._rel_tables.get(("relative", id(graph))) - if t is None: - t = {0: {""}} - for tf_pid in graph.tf: - parts = graph.path(tf_pid).split("/") - for i in range(1, len(parts)): - s = "/".join(parts[i:]) - t.setdefault(self._crc_of(s), set()).add(s) - self._rel_tables[("relative", id(graph))] = t - return t - - def _all_graphs(self) -> list[SceneGraph]: - return list({id(g): g for g in self._graph_of_file.values()}.values()) - - def add_path_source(self, key: str, roots: tuple[str, ...] = ()) -> int: - """Name binding paths from a hierarchy that is not exported (e.g. the scene whose UI a clip animates): - paths relative to any transform inside the subtrees rooted at transforms named in `roots` (the whole - hierarchy when empty). Used after the loaded hierarchies; resolved bindings record the key. Returns - the number of subtrees.""" - env = load_closure(self.cat.fetch_key(key)) - g = SceneGraph(env) - paths = [g.path(pid) for pid in g.tf] - bases = [p for p in paths if not roots or p.rsplit("/", 1)[-1] in roots] - table: dict[int, set[str]] = {} - for base in bases: - d = base.count("/") - for full in paths: - if full == base or full.startswith(base + "/"): - parts = full.split("/") - for i in range(d, len(parts)): - s = "/".join(parts[i + 1 :]) - table.setdefault(self._crc_of(s), set()).add(s) - self._path_sources.append((key, table)) - return len(bases) - - def _gos_at(self, graph: SceneGraph, rel: str) -> list[int]: - """GameObjects a relative binding path can name ("" = any).""" - k = (id(graph), rel) - if k not in self._gos_at_cache: - self._gos_at_cache[k] = [ - go_pid - for go_pid, tf in graph.tf_of_go.items() - if rel == "" or graph.path(tf) == rel or graph.path(tf).endswith("/" + rel) - ] - return self._gos_at_cache[k] - - def _binding_gos(self, r: dict): - """(graph, GameObject) pairs a resolved binding path can name: exactly root/path when the binding was - found below its animated root (bind_clip), else every GameObject whose path ends with it.""" - for g in r["graphs"]: - if r["root"] is not None: - full = r["root"] if r["path"] == "" else f"{r['root']}/{r['path']}" - yield from ((g, go) for go in g.gos_at_path(full)) - else: - yield from ((g, go) for go in self._gos_at(g, r["path"])) - - def _bound_names(self, r: dict) -> set[str]: - """Field paths of the components a resolved binding path can name (and the bound component kind).""" - tid, names = r["typeID"], set() - for g, go_pid in self._binding_gos(r): - if tid == 1: - r["component"] = "GameObject" - names.update(flat_names(g.go[go_pid])) - for c in g.go[go_pid]["m_Component"]: - co = self._objects.get((self._go_file[go_pid], c["component"]["m_PathID"])) - if co is None: - continue - if tid == 114: - if ( - r["script"] is not None - and co.type.name == "MonoBehaviour" - and self._mono_class(co) == r["script"][1] - ): - names.update(self._names(co)) - elif co.type.value == tid: - r["component"] = co.type.name - names.update(self._names(co)) - return names - - def _material_attribute(self, r: dict) -> str | None: - """Attribute of a renderer material property binding (customType 22): a property of the materials of the - renderer the binding names (bindings.material_attribute).""" - kinds: dict[str, str] = {} - for g, go_pid in self._binding_gos(r): - for c in g.go[go_pid]["m_Component"]: - co = self._objects.get((self._go_file[go_pid], c["component"]["m_PathID"])) - if co is None or co.type.value != r["typeID"]: - continue - for mp in co.read_typetree().get("m_Materials", []): - mo = self.deref(co, mp) - if mo is None: - continue - sp = mo.read_typetree()["m_SavedProperties"] - kinds.update({k: "float" for k, _ in sp.get("m_Floats", [])}) - kinds.update({k: "vector" for k, _ in sp.get("m_Colors", [])}) - return material_attribute(r["attributeCrc"], kinds) - - def _class_names(self, r: dict) -> set[str]: - """Names any binding of the class can target: engine properties, material properties of the - exported materials, and field paths of every exported component of the class.""" - k = ("MonoBehaviour", r["script"][1]) if r["typeID"] == 114 and r["script"] else r["typeID"] - return ( - set(ENGINE_PROPS) - | set(RECT_PROPS) - | set(material_prop_names(self.material_props)) - | self._names_by_class.get(k, set()) - ) - - def _path_tiers(self, owner): - """(graphs, source, crc32 -> paths | None for the union of `graphs`) in resolution order: the clip's - own hierarchy, every loaded hierarchy, then the path sources (after bind_clip, the animated root's - subtree comes first: see _resolve).""" - own = self._graph_of_file.get(id(owner.assets_file)) - if own is not None: - yield [own], None, self._rel_table(own) - yield self._all_graphs(), None, None - for key, table in self._path_sources: - yield [], key, table - - def _resolve(self, r: dict) -> None: - """Name a binding's path and attribute from their crc32: first tier with exactly one hit.""" - crc = r["pathCrc"] - if r["path"] is None and r["bound"]: - graph, root = r["bound"] - cands = self._subtree_table(graph, root).get(crc, set()) - if len(cands) == 1: - r.update(path=next(iter(cands)), pathSource=None, graphs=[graph], pathCandidates=None, root=root) - if r["path"] is None: - first = None - for graphs, source, table in self._path_tiers(r["owner"]): - if table is not None: - cands = table.get(crc, set()) - else: - cands = set().union(*(self._rel_table(g).get(crc, ()) for g in graphs)) - if cands and first is None: - first = cands - if len(cands) == 1: - r.update(path=next(iter(cands)), pathSource=source, graphs=graphs, pathCandidates=None) - break - else: - r["pathCandidates"] = sorted(first) if first and len(first) > 1 else None - if r["attribute"] is None and r["customType"] == 22: # RendererMaterial: not a field path crc - if r["path"] is not None: - self._bound_names(r) # names the bound component - r["attribute"] = self._material_attribute(r) - elif r["attribute"] is None: - a = r["attributeCrc"] - hit = [x for x in (self._bound_names(r) if r["path"] is not None else ()) if self._crc_of(x) == a] - if len(hit) != 1: - hit = [x for x in self._class_names(r) if self._crc_of(x) == a] - if len(hit) == 1: - r["attribute"] = hit[0] - - def _binding_record(self, owner, b: dict) -> dict: - tid = b["typeID"] - r = { - "owner": owner, - "raw": b, - "pathCrc": b["path"], - "typeID": tid, - "attributeCrc": b["attribute"], - "pptr": bool(b["isPPtrCurve"]), - "int": bool(b["isIntCurve"]), - "customType": b.get("customType"), - "serializeReference": bool(b.get("isSerializeReferenceCurve")), - "path": None, - "pathCandidates": None, - "pathSource": None, - "graphs": [], - "attribute": None, - "curveCount": 1, - "transformAttr": False, - "component": None, - "script": None, - "bound": False, - "root": None, - } - if tid == 4 and b["attribute"] in TRANSFORM_ATTR and not r["pptr"]: - r["attribute"], r["curveCount"] = TRANSFORM_ATTR[b["attribute"]] - r["transformAttr"] = True - if tid == 114: - so = self.deref(owner, b["script"]) - if so is not None: - ms = so.read() - r["script"] = (ms.m_Namespace, ms.m_ClassName) - self._resolve(r) - return r - - def resolve_pending(self) -> int: - """Resolve bindings left unresolved when their clip was exported (target hierarchy, material or - component class seen later, path sources added since) and update the emitted binding JSON in - place. Returns the number of paths and attributes still unresolved.""" - left = 0 - for rec in self._clip_recs.values(): - changed = False - for r in rec["bindings"]: - if r["path"] is None or r["attribute"] is None: - before = (r["path"], r["attribute"], r["component"]) - self._resolve(r) - changed |= before != (r["path"], r["attribute"], r["component"]) - left += (r["path"] is None) + (r["attribute"] is None) - if changed: - self._refresh_views(rec) - return left - - @staticmethod - def _refresh_views(rec: dict) -> None: - """Re-render a clip's emitted binding JSON in place after its records changed.""" - for view, dicts in rec["views"]: - for r, d in zip(rec["bindings"], dicts): - new = view(r) - if new != d: - d.clear() - d.update(new) - - def _subtree_table(self, graph: SceneGraph, root: str) -> dict[int, set[str]]: - """crc32 -> paths relative to the transform at `root` of every transform in its subtree ("" = root).""" - k = ("subtree", id(graph), root) - if k not in self._rel_tables: - t: dict[int, set[str]] = {} - for tf_pid in graph.tf: - full = graph.path(tf_pid) - if full == root or full.startswith(root + "/"): - rel = full[len(root) + 1 :] - t.setdefault(self._crc_of(rel), set()).add(rel) - if not t: - raise KeyError(f"animated root {root} not in the hierarchy") - self._rel_tables[k] = t - return self._rel_tables[k] - - def bind_clip(self, clip_o, graph: SceneGraph, root: str) -> dict: - """The clip is played by the Animator on the GameObject at `root` in `graph` (e.g. the object a timeline - track is bound to): binding paths are relative to it. A path found exactly once in root's subtree replaces - the guess of the generic tiers; a path not in the subtree keeps it (objects parented below root at - runtime). In the livescene format the clip's bindings also get the livenotes binding fields (`class`, - `curves`). The clip must have been exported. Returns {"subtree": n, "outside": n, "unresolved": n}.""" - rec = self._clip_recs[_key(clip_o)] - table = self._subtree_table(graph, root) - counts = {"subtree": 0, "outside": 0, "unresolved": 0} - for r in rec["bindings"]: - r["bound"] = (graph, root) - cands = table.get(r["pathCrc"], set()) - if len(cands) == 1: - path = next(iter(cands)) - if r["path"] != path: # the generic guess named another object: re-resolve below root - r.update(component=None, attribute=r["attribute"] if r["transformAttr"] else None) - r.update(path=path, pathSource=None, graphs=[graph], pathCandidates=None, root=root) - counts["subtree"] += 1 - self._resolve(r) - if r["root"] is None: - counts["outside" if r["path"] is not None else "unresolved"] += 1 - self._refresh_views(rec) - return counts - - def timeline_tracks(self, asset_o) -> list[tuple]: - """(track object, track typetree, [AnimationClip objects]) of every track of a TimelineAsset (depth first - through group tracks): the clips of its TimelineClips' AnimationPlayableAssets and its infinite clip.""" - out = [] - - def walk(pptrs, owner): - for p in pptrs: - t = self.deref(owner, p) - if t is None: - continue - tt = t.read_typetree() - clips = [] - for c in tt.get("m_Clips", []): - a = self.deref(t, c["m_Asset"]) - at = a.read_typetree() if a is not None else {} - if isinstance(at.get("m_Clip"), dict) and at["m_Clip"].get("m_PathID"): - clips.append(self.deref(a, at["m_Clip"])) - if isinstance(tt.get("m_AnimClip"), dict) and tt["m_AnimClip"].get("m_PathID"): - clips.append(self.deref(t, tt["m_AnimClip"])) - out.append((t, tt, clips)) - walk(tt.get("m_Children", []), t) - - walk(asset_o.read_typetree()["m_Tracks"], asset_o) - return out - - # --- clips ------------------------------------------------------------- - def clip_record(self, o) -> dict: - """Clip data and resolved binding records, built once (every clip view reads this).""" - k = _key(o) - rec = self._clip_recs.get(k) - if rec is None: - tt = o.read_typetree() - if tt["m_Legacy"] or tt["m_Compressed"]: - raise NotImplementedError(f"clip {tt['m_Name']}: legacy/compressed") - mc = tt["m_MuscleClip"] - data = mc["m_Clip"]["data"] - cbc = tt["m_ClipBindingConstant"] - rec = { - "object": o, - "tt": tt, - "name": tt["m_Name"], - "muscle": mc, - "streamed": data["m_StreamedClip"], - "dense": data["m_DenseClip"], - "constant": list(data["m_ConstantClip"]["data"]), - "pptrCurveMapping": list(cbc.get("pptrCurveMapping", [])), - "bindings": [self._binding_record(o, b) for b in cbc["genericBindings"]], - "views": [], - } - self._clip_recs[k] = rec - return rec - - @staticmethod - def _curve_total(rec: dict) -> int: - return rec["streamed"]["curveCount"] + rec["dense"]["m_CurveCount"] + len(rec["constant"]) - - @staticmethod - def _clip_header(rec: dict) -> dict: - tt, mc = rec["tt"], rec["muscle"] - return { - "sampleRate": tt["m_SampleRate"], - "wrapMode": tt["m_WrapMode"], - "startTime": mc["m_StartTime"], - "stopTime": mc["m_StopTime"], - "loopTime": bool(mc["m_LoopTime"]), - "cycleOffset": mc["m_CycleOffset"], - "events": _events(tt), - } - - @staticmethod - def _dense(rec: dict) -> dict: - de = rec["dense"] - return { - "curveCount": de["m_CurveCount"], - "frameCount": de["m_FrameCount"], - "sampleRate": de["m_SampleRate"], - "beginTime": de["m_BeginTime"], - "samples": list(de["m_SampleArray"]), - } - - def clip(self, o) -> dict: - """An AnimationClip with the data the Animator samples, in `clip_format`. - - Curve indices run over streamed curves, then dense, then constant curves; each - binding takes its curve count of them in `bindings` order.""" - k = _key(o) - if k in self._done: - return self._done[k] - rec = self.clip_record(o) - return {"generic": self._clip_generic, "livescene": self._clip_livescene, "livenotes": self._clip_livenotes}[ - self.clip_format - ](rec) - - def _views(self, rec: dict, view) -> list[dict]: - dicts = [view(r) for r in rec["bindings"]] - rec["views"].append((view, dicts)) - return dicts - - def _clip_generic(self, rec: dict) -> dict: - o, st = rec["object"], rec["streamed"] - bindings = self._views(rec, self._binding_generic) - floats = sum(r["curveCount"] for r in rec["bindings"] if not r["pptr"]) - if self._curve_total(rec) != floats: - raise RuntimeError(f"clip {rec['name']}: curve count != binding curve count") - ref = {"clip": rec["name"]} - self._done[_key(o)] = ref - streamed = {"curveCount": st["curveCount"], "frames": streamed_frames(st["data"])} - if st.get("discreteCurveCount"): - streamed["discreteCurveCount"] = st["discreteCurveCount"] - out = { - **ref, - **self._clip_header(rec), - "bindings": bindings, - "streamed": streamed, - "dense": self._dense(rec), - "constant": rec["constant"], - } - if rec["pptrCurveMapping"]: - out["pptrCurveMapping"] = [self.ref(o, p) for p in rec["pptrCurveMapping"]] - return out - - @staticmethod - def _binding_generic(r: dict) -> dict: - if r["serializeReference"]: - raise NotImplementedError(f"clip binding kind {r['raw']}") - tid = r["typeID"] - cls = r["script"][1] if tid == 114 and r["script"] else CLASS_ID.get(tid, f"classID {tid}") - d = {"path": r["path"], "typeID": tid, "class": cls, "attribute": r["attribute"]} - if r["curveCount"] != 1: - d["curves"] = r["curveCount"] - if r["pptr"]: - d["pptr"] = True - if r["int"]: - d["int"] = True - if r["path"] is None: - d["pathCrc"] = r["pathCrc"] - if r["attribute"] is None: - d["attributeCrc"] = r["attributeCrc"] - return d - - def _clip_livescene(self, rec: dict) -> dict: - o, st = rec["object"], rec["streamed"] - bindings = self._views(rec, self._binding_livescene) - ref = {"clip": rec["name"]} - self._done[_key(o)] = ref - float_curves = sum(b["curveCount"] for b in bindings if not b["isPPtrCurve"]) - pptr = [self.value(o, p) for p in rec["pptrCurveMapping"]] - out = { - **ref, - **self._clip_header(rec), - "curveOrder": "streamed, dense, constant (each binding takes curveCount curves in order)", - "bindings": bindings, - "streamed": { - "curveCount": st["curveCount"], - "discreteCurveCount": st.get("discreteCurveCount", 0), - "frames": streamed_frames(st["data"]), - }, - "dense": self._dense(rec), - "constant": rec["constant"], - "pptrCurveMapping": pptr, - } - total = self._curve_total(rec) - npptr = sum(1 for b in bindings if b["isPPtrCurve"]) - if total != float_curves + npptr: - out["curveCountMismatch"] = {"data": total, "bindings": float_curves, "pptrBindings": npptr} - return out - - @staticmethod - def _binding_livescene(r: dict) -> dict: - tid = r["typeID"] - d = { - "pathCrc": r["pathCrc"], - "path": r["path"], - "typeID": tid, - "attributeCrc": r["attributeCrc"], - "isPPtrCurve": r["pptr"], - "isIntCurve": r["int"], - "customType": r["customType"], - } - if r["pathCandidates"]: - d["pathCandidates"] = r["pathCandidates"] - if r["transformAttr"]: - d["attribute"] = r["attribute"] - else: - if tid == 114 and r["script"]: - d["class"] = f"{r['script'][0]}.{r['script'][1]}".lstrip(".") - if tid != 114 and r["component"]: - d["component"] = r["component"] - if r["attribute"] is not None: - d["attribute"] = r["attribute"] - d["curveCount"] = r["curveCount"] - if r["bound"]: # timeline clips also carry the livenotes binding fields (class, curves) - if "class" not in d: - d["class"] = CLASS_ID.get(tid, f"classID {tid}") - d["curves"] = r["curveCount"] - return d - - def _clip_livenotes(self, rec: dict) -> dict: - o, st = rec["object"], rec["streamed"] - bindings = self._views(rec, self._binding_livenotes) - float_curves = sum(r["curveCount"] for r in rec["bindings"] if not r["pptr"]) - de = rec["dense"] - if self._curve_total(rec) != float_curves: - raise RuntimeError( - f"clip {rec['name']}: curve count != binding curve count " - f"({st['curveCount']}+{de['m_CurveCount']}+{len(rec['constant'])} vs {float_curves})" - ) - pptr = [self.ref(o, p) for p in rec["pptrCurveMapping"]] - rid = _rid(o, rec["name"]) - ref = {"clip": rid} - self._done[_key(o)] = ref - try: - frames = streamed_frames(st["data"]) - except ValueError as e: - raise ValueError(f"clip {rec['name']}: {e}") from None - self.clips[rid] = { - "name": rec["name"], - **self._clip_header(rec), - "bindings": bindings, - "streamed": {"curveCount": st["curveCount"], "frames": frames}, - "dense": self._dense(rec), - "constant": rec["constant"], - "pptrCurveMapping": pptr, - "discreteCurveCount": st.get("discreteCurveCount", 0), - } - return ref - - @staticmethod - def _binding_livenotes(r: dict) -> dict: - tid = r["typeID"] - if tid == 114 and not r["pptr"] and r["script"] and r["path"] is not None and r["attribute"] is not None: - d = {"path": r["path"], "typeID": tid, "class": r["script"][1], "attribute": r["attribute"], "curves": 1} - else: - d = { - "path": r["path"] if r["path"] is not None else f"crc32:{r['pathCrc']}", - "typeID": tid, - "class": CLASS_ID.get(tid, f"classID {tid}"), - } - if r["pptr"]: - d["pptr"] = True - if r["transformAttr"]: - d.update(attribute=r["attribute"], curves=r["curveCount"]) - else: - if tid == 114: - d["script"] = r["script"][1] if r["script"] else None - d["attribute"] = r["attribute"] if r["attribute"] is not None else f"crc32:{r['attributeCrc']}" - d["curves"] = r["curveCount"] - if r["int"]: - d["int"] = True - if r["pathSource"]: - d["scene"] = r["pathSource"] - return d - - def clip_curves(self, o) -> dict: - """A clip as per-property streamed cubic keys, for runtimes that animate RectTransform, - CanvasGroup alpha and Transform euler angles of the animated root only (ADV UI).""" - rec = self.clip_record(o) - tt, mc, st, de = rec["tt"], rec["muscle"], rec["streamed"], rec["dense"] - name, const = rec["name"], rec["constant"] - if st["discreteCurveCount"] or de["m_CurveCount"]: - raise NotImplementedError(f"clip {name}: discrete/dense curves") - props = [] - for r in rec["bindings"]: - if r["pathCrc"] != 0: - raise NotImplementedError(f"clip {name}: binding below the animated root") - if r["pptr"] or r["int"] or r["serializeReference"]: - raise NotImplementedError(f"clip {name}: binding kind {r['raw']}") - if r["typeID"] == 224 and r["attribute"] in RECT_PROPS: - props.append("RectTransform." + r["attribute"]) - elif r["typeID"] == 225 and r["attribute"] == "m_Alpha": - props.append("CanvasGroup.m_Alpha") - elif r["typeID"] == 4 and r["attributeCrc"] == 4: # localEulerAnglesRaw (x, y, z) - props += [ - "Transform.localEulerAngles.x", - "Transform.localEulerAngles.y", - "Transform.localEulerAngles.z", - ] - else: - raise NotImplementedError(f"clip {name}: binding {r['raw']}") - if st["curveCount"] + len(const) != len(props): - raise RuntimeError(f"clip {name}: curve count != bound properties") - curves = {p: {"keys": []} for p in props[: st["curveCount"]]} - for t, keys in streamed_frames(st["data"]): - for ci, c0, c1, c2, c3 in keys: - curves[props[ci]]["keys"].append([t, c0, c1, c2, c3]) - for i, v in enumerate(const): - curves[props[st["curveCount"] + i]] = {"constant": v} - return { - "name": name, - "sampleRate": tt["m_SampleRate"], - "startTime": mc["m_StartTime"], - "stopTime": mc["m_StopTime"], - "loopTime": bool(mc["m_LoopTime"]), - "events": tt["m_Events"], - "curves": curves, - "keyFormat": "[time, c0, c1, c2, c3]; value(t) = ((c0*x + c1)*x + c2)*x + c3, x = t - time, " - "from the last key with time <= t (first key at -FLT_MAX)", - } - - # --- animator controllers, sprite atlases -------------------------------- - def controller_record(self, o) -> dict: - tt = o.read_typetree() - if o.type.name != "AnimatorController": - raise NotImplementedError(f"{o.type.name} {tt.get('m_Name')}") - return {"object": o, "tt": tt, "name": tt["m_Name"], "tos": dict(tt["m_TOS"])} - - def controller(self, o) -> dict: - """AnimatorController: layers, state machines (states with speed, loop, blend trees, transitions), - parameters and default values; clips exported in `clip_format`. Inlined once, or in the - `controllers` registry for the livenotes format.""" - k = _key(o) - if k in self._done: - return self._done[k] - rec = self.controller_record(o) - registry = self.clip_format == "livenotes" - cid = _rid(o, rec["name"]) if registry else rec["name"] - ref = {"controller": cid} - self._done[k] = ref - body = controller_doc(rec["tt"], lambda p: self.ref(o, p), lambda v: self.value(o, v)) - if registry: - self.controllers[cid] = body - return ref - return {**ref, **body} - - def controller_states(self, o) -> dict: - """A single-layer AnimatorController without transitions, blend trees or parameter-driven states - as its state list (name, speed, loop, cycle offset, clip name) -- what the ADV UI runtime plays.""" - rec = self.controller_record(o) - tt, tos = rec["tt"], rec["tos"] - clips = [self.referent(o, p).read().m_Name for p in tt["m_AnimationClips"]] - ctrl = tt["m_Controller"] - if len(ctrl["m_StateMachineArray"]) != 1 or len(ctrl["m_LayerArray"]) != 1: - raise NotImplementedError(f"controller {tt['m_Name']}: several layers") - sm = ctrl["m_StateMachineArray"][0]["data"] - states = [] - for st in sm["m_StateConstantArray"]: - d = st["data"] - motion = [] - for bt in d["m_BlendTreeConstantArray"]: - for node in bt["data"]["m_NodeArray"]: - n = node["data"] - if n["m_ChildIndices"]: - raise NotImplementedError(f"controller {tt['m_Name']}: blend trees") - motion.append(clips[n["m_ClipID"]] if n["m_ClipID"] != 0xFFFFFFFF else None) - if d["m_SpeedParamID"] or d["m_TimeParamID"] or d["m_CycleOffsetParamID"]: - raise NotImplementedError(f"controller {tt['m_Name']}: parameter-driven state") - if d["m_TransitionConstantArray"]: - raise NotImplementedError(f"controller {tt['m_Name']}: state transitions") - states.append( - { - "name": tos[d["m_NameID"]], - "speed": d["m_Speed"], - "loop": d["m_Loop"], - "cycleOffset": d["m_CycleOffset"], - "clip": motion[0] if motion else None, - } - ) - if sm["m_AnyStateTransitionConstantArray"]: - raise NotImplementedError(f"controller {tt['m_Name']}: any-state transitions") - return {"name": tt["m_Name"], "defaultState": states[sm["m_DefaultState"]]["name"], "states": states} - - def atlas(self, o) -> dict: - """SpriteAtlas: every packed sprite (name -> sprite with atlas texture and UVs), inlined once.""" - k = _key(o) - if k in self._done: - return self._done[k] - tt = o.read_typetree() - ref = {"atlas": tt["m_Name"]} - self._done[k] = ref - sprites = {} - for p in tt["m_PackedSprites"]: - s = self.ref(o, p) - if s is not None: - sprites[s["sprite"]] = s - return {**ref, "tag": tt.get("m_Tag"), "isVariant": tt.get("m_IsVariant"), "sprites": sprites} - - # --- MonoBehaviours, components ------------------------------------------ - def scriptable(self, o) -> dict: - """A MonoBehaviour that is not a component (ScriptableObject asset), inlined once.""" - k = _key(o) - if k in self._done: - return self._done[k] - cls = self.script_class(o) - tt = o.read_typetree() - ref = {"asset": cls, "name": tt.get("m_Name", "")} - if cls in self.stub_assets: - return ref - self._done[k] = ref - return {**ref, **{x: self.value(o, y) for x, y in tt.items() if x not in HEADER}} - - def component(self, o) -> dict: - tt = o.read_typetree() - self._register_names(o, tt) - out = {"type": o.type.name} - if o.type.name == "MonoBehaviour": - out["class"] = self.script_class(o) - out.update({k: self.value(o, v) for k, v in tt.items() if k not in HEADER}) - return out - - # --- hierarchies ----------------------------------------------------- - def hierarchy(self, env, graph: SceneGraph, root_tf: int) -> list[dict]: - by_pid = {} - for o in env.objects: - by_pid.setdefault(o.path_id, []).append(o) - nodes = [] - - def walk(tf_pid): - t = graph.tf[tf_pid] - go = graph.go[t["m_GameObject"]["m_PathID"]] - comps = [] - for c in go["m_Component"]: - objs = [x for x in by_pid.get(c["component"]["m_PathID"], []) if x.type.name not in ("GameObject",)] - if len(objs) != 1: - raise RuntimeError(f"{graph.path(tf_pid)}: component {c} resolves to {len(objs)}") - co = objs[0] - if co.type.name in ("Transform", "RectTransform"): - continue - comps.append(self.component(co)) - node = { - "path": graph.path(tf_pid), - "name": go["m_Name"], - "active": bool(go["m_IsActive"]), - "layer": go["m_Layer"], - "tag": go.get("m_Tag"), - "localPosition": t["m_LocalPosition"], - "localRotation": t["m_LocalRotation"], - "localScale": t["m_LocalScale"], - "components": comps, - } - if "m_AnchorMin" in t: - node["rect"] = { - k: t[k] for k in ("m_AnchorMin", "m_AnchorMax", "m_AnchoredPosition", "m_SizeDelta", "m_Pivot") - } - nodes.append(node) - for ch in t["m_Children"]: - walk(ch["m_PathID"]) - - walk(root_tf) - return nodes - - # --- keys -------------------------------------------------------------- - def _container_asset(self, env, internal_id: str) -> ObjectReader: - for o in env.objects: - if o.type.name != "AssetBundle": - continue - tt = o.read_typetree() - for path, info in tt["m_Container"]: - if path.lower() == internal_id.lower(): - asset = self.deref(o, info["asset"]) - if asset is None: - raise KeyError(f"{internal_id}: the container entry is a null reference") - return asset - raise KeyError(f"{internal_id} not in any AssetBundle container") - - def key_object(self, key: str) -> ObjectReader: - """The object an addressable key names (its bundle's container asset).""" - env, _ = self.load(key) - return self._container_asset(env, self.cat.entry(key)["internal_id"]) - - def prefab(self, key: str, loaded=None) -> dict: - """A prefab or scene as a node list; `loaded` = (env, graph) from load(key).""" - env, graph = loaded or self.load(key) - iid = self.cat.entry(key)["internal_id"] - if iid.endswith(".unity"): - scene_files = set() - for o in env.objects: - if o.type.name == "AssetBundle": - for path, cab in o.read_typetree().get("m_SceneHashes", []): - if path.lower() == iid.lower(): - scene_files.add(cab.lower()) - roots = [ - o.path_id - for o in env.objects - if o.type.name in ("Transform", "RectTransform") - and o.assets_file.name.lower().startswith(tuple(scene_files)) - and not graph.tf[o.path_id]["m_Father"]["m_PathID"] - ] - if not roots: - raise RuntimeError(f"{key}: no scene roots") - return { - "key": key, - "scene": True, - "nodes": [n for r in sorted(roots, key=graph.path) for n in self.hierarchy(env, graph, r)], - } - go = self._container_asset(env, iid) - if go is None or go.type.name != "GameObject": - raise RuntimeError(f"{key}: container asset is {go and go.type.name}") - return {"key": key, "nodes": self.hierarchy(env, graph, graph.tf_of_go[go.path_id])} - - def asset(self, key: str) -> dict: - o = self.key_object(key) - if o.type.name != "MonoBehaviour": - raise RuntimeError(f"{key}: asset is {o.type.name}") - return {"key": key, **self.scriptable(o)} - - def export_key(self, key: str) -> dict: - """Any addressable key by its container asset: prefab or scene (node list), ScriptableObject, - AnimatorController, AnimationClip, SpriteAtlas, Material, Texture2D, Sprite, Mesh; other kinds as - {object, value} (typetree with references resolved).""" - iid = self.cat.entry(key)["internal_id"] - if iid.endswith(".unity"): - return self.prefab(key) - env, graph = self.load(key) - o = self._container_asset(env, iid) - t = o.type.name - if t == "GameObject": - return self.prefab(key, (env, graph)) - kinds = { - "MonoBehaviour": self.scriptable, - "AnimatorController": self.controller, - "AnimatorOverrideController": self.controller, - "AnimationClip": self.clip, - "SpriteAtlas": self.atlas, - "Material": self.material, - "Texture2D": self.texture, - "Sprite": self.sprite, - "Mesh": self.mesh, - } - if t in kinds: - return {"key": key, **kinds[t](o)} - return {"key": key, "object": t, "value": self.value(o, o.read_typetree())} - - def key_sprite(self, key: str) -> dict: - """The Sprite that a texture key's bundle holds for its texture (Addressables sprite sub-asset).""" - env, _ = self.load(key) - tex = self.key_object(key) - hits = [] - for o in env.objects: - if o.type.name == "Sprite": - t = self.deref(o, o.read_typetree()["m_RD"]["texture"]) - if t is not None and t.path_id == tex.path_id: - hits.append(o) - if len(hits) != 1: - raise RuntimeError(f"{key}: {len(hits)} sprites for the texture") - return {"key": key, **self.sprite(hits[0])} - - # --- shaders --------------------------------------------------------- - def dump_shaders(self, out_dir: Path, objects: dict | None = None) -> tuple[list, dict]: - """Dump shaders (name -> Shader object; default every registered one) in name order with - shader.py's layout into `out_dir`; returns (index records, write_index summary).""" - from ..formats import shader as shader_mod - - objects = self.shaders if objects is None else objects - index: list = [] - for name in sorted(objects): - shader_mod.dump_objects([objects[name]], out_dir, objects[name].assets_file.name, index) - return index, shader_mod.write_index(index, out_dir) diff --git a/src/nnnotes/unity/fontsettings.py b/src/nnnotes/unity/fontsettings.py index 316baca..fd316b2 100644 --- a/src/nnnotes/unity/fontsettings.py +++ b/src/nnnotes/unity/fontsettings.py @@ -1,26 +1,9 @@ -"""The settings of the game's TextMeshPro font assets that the exports with open fonts (`--fonts open`) take. - -An open font asset stands for a game font asset: it gets that asset's point size, padding, render mode, style -settings, line metrics and text materials, mirrors its fallback chain, and the characters the game asset lacks with -its fallbacks take TextMeshPro's missing glyph (storyfonts.open_font_set). These values come from FontSettings: - - builtin() the table shipped with nnnotes (fontsettings.json, FORMAT), so the open-font exports read no - game font asset, material or font bundle - tmpfont.AssetFontSettings - the same values read from the game's font assets through an Exporter (`--fonts game`, and - tmpfont.build_font_settings, which makes the table) - -The table holds, per font asset LocalizeManager lists for a language and per asset of their fallback chains: -`faceInfo` (m_FaceInfo), `atlasPadding`, `atlasRenderMode`, the style settings (STYLE_KEYS), `material` (the name of -its default material), `fallbacks` (the names of its fallback font asset table, in order), `coverage` (the code -points the asset has: its character table, and for a dynamic asset the character map of its source font file, as a -bit set: bit u of the zlib-compressed bytes, base64), `variants` (the [code point, variation selector] pairs its -source font file maps to a glyph other than the code point's) and `bundle` (the file name of the bundle that holds -it); per material the material record (Exporter.material, its texture references reduced: table_material) of every -font asset's default material and of every localized text material (" - " for the types of -LocalizeManager); and `objects`, the path id, type and name of each of those font assets and materials, by which -references into the font bundles resolve without loading them (export.Exporter `font_stubs`). -""" +"""Font settings tables for TextMesh Pro assets and open-font generation. + +A table records each font's face metrics, atlas parameters, style settings, fallback order, character coverage, +variation sequences and bundle identity. Materials carry shader names and texture settings. The object index +maps font and material path ids to their names. FontSettings reads either the bundled table or a table assembled +from product objects by content.tmpassets.build_font_settings.""" from __future__ import annotations diff --git a/src/nnnotes/unity/live2d.py b/src/nnnotes/unity/live2d.py deleted file mode 100644 index e426c65..0000000 --- a/src/nnnotes/unity/live2d.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Live2D (Cubism) model: Cubism-for-Unity prefab -> prefab.json + Cubism runtime files. - -Components are identified by their MonoScript class (resolved through the APK's -shared_monoscripts bundle, so the Catalog must be opened with `apk=`). - -`extract_runtime` writes what a re-implementation of the game's own runtime needs (`export_runtime` writes the -same and also returns the exporter state, for callers that export more of the model's bundles): - - .moc3 CubismMoc._bytes - textures/*.png atlas pages (Texture2D) - .prefab.json the whole model prefab exported by export.Exporter: - every GameObject with every component (CubismRenderer/ - MeshRenderer + materials per drawable, controllers, - Live2DCharacter, eye blink, harmonic motion, motion - sync, anchors ...), with the fade motion list, the - expression list and every AnimationClip (Mecanim clip - data + resolved bindings) inlined; plus `canvas` (moc3) - -The texture each drawable is drawn with is its CubismRenderer._mainTexture in prefab.json, keyed by -CubismDrawable._unmanagedIndex (the Cubism Core drawable index). -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -from ..core.jsonio import write_json -from ..formats.cubism import moc3_canvas -from ..source.catalog import Catalog -from ..source.config import apk_missing -from .export import Exporter -from .reader import SceneGraph, script_class - -# the moc is written as .moc3; prefab.json names it only -STUB_ASSETS = ("CubismMoc",) - - -def _classes(env) -> dict[str | None, list]: - by_cls: dict[str | None, list] = {} - for o in env.objects: - if o.type.name == "MonoBehaviour": - by_cls.setdefault(script_class(o), []).append(o) - return by_cls - - -def _only(by_cls, cls: str, name: str) -> dict: - objs = by_cls.get(cls, []) - if len(objs) != 1: - raise RuntimeError(f"{name}: {len(objs)} {cls}") - return objs[0].read_typetree() - - -@dataclass -class RuntimeExport: - """What `export_runtime` wrote and read: `summary` (the result of `extract_runtime`), the Exporter it wrote with - (its shaders, textures, ...), the loaded bundle environment, its scene graph and the MonoBehaviours by script - class.""" - - summary: dict - exporter: Exporter - env: object - graph: SceneGraph - classes: dict[str | None, list] - - -def export_runtime(cat: Catalog, key: str, out_dir: Path) -> RuntimeExport: - """Write the runtime files of the model `key` into `out_dir` (module docstring) and return them with the - exporter state.""" - if cat.apk is None: - raise apk_missing("resolving the Cubism component classes") - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - name = key.rsplit("/", 1)[-1] - ex = Exporter(cat, out_dir, inline_meshes=False, stub_assets=STUB_ASSETS) - env, graph = ex.load(key) - by_cls = _classes(env) - moc = bytes(_only(by_cls, "CubismMoc", name)["_bytes"]) - (out_dir / f"{name}.moc3").write_bytes(moc) - prefab = ex.prefab(key, loaded=(env, graph)) - prefab["canvas"] = moc3_canvas(moc) - write_json(out_dir / f"{name}.prefab.json", prefab, indent=None) - pages = sorted((o for o in env.objects if o.type.name == "Texture2D"), key=lambda o: o.read().m_Name) - tex_files = [ex.texture(o)["texture"] for o in pages] - res = { - "name": name, - "moc3": f"{name}.moc3", - "prefab": f"{name}.prefab.json", - "moc3Bytes": len(moc), - "textures": tex_files, - "nodes": len(prefab["nodes"]), - } - return RuntimeExport(res, ex, env, graph, by_cls) - - -def extract_runtime(cat: Catalog, key: str, out_dir: Path) -> dict: - """Write the runtime files of the model `key` into `out_dir`; returns the summary: name, moc3, prefab, - moc3Bytes, textures, nodes.""" - return export_runtime(cat, key, out_dir).summary diff --git a/src/nnnotes/unity/reader.py b/src/nnnotes/unity/reader.py index 962e10c..9fb3c13 100644 --- a/src/nnnotes/unity/reader.py +++ b/src/nnnotes/unity/reader.py @@ -165,6 +165,20 @@ def first_mono(env, must_have: str | None = None) -> dict | None: return None +def container_asset(env, internal_id: str) -> ObjectReader: + """The object named by a bundle's container path, matched without case sensitivity.""" + for owner in env.objects: + if owner.type.name != "AssetBundle": + continue + for path, info in owner.read_typetree()["m_Container"]: + if path.lower() == internal_id.lower(): + asset = deref(owner, info["asset"]) + if asset is None: + raise KeyError(f"{internal_id}: the container entry is a null reference") + return asset + raise KeyError(f"{internal_id} not in any AssetBundle container") + + def textassets(env) -> dict[str, str]: out = {} for o in env.objects: diff --git a/src/nnnotes/unity/texels.py b/src/nnnotes/unity/texels.py new file mode 100644 index 0000000..d815803 --- /dev/null +++ b/src/nnnotes/unity/texels.py @@ -0,0 +1,182 @@ +"""In-memory texel block packing and PNG encoding for object-based text products.""" + +from __future__ import annotations + +import io +import json +import math +import re +from typing import Literal + +import numpy as np +from PIL import Image + +from ..core import cache + +_LIBS = ("UnityPy", "Pillow", "texture2ddecoder", "astc-encoder-py", "etcpak", "numpy") +# decoded textures and PNG encodings memoized by content within this process +PNG = cache.bucket("png", salt=cache.source_salt(__file__, *_LIBS)) + + +def packed_png(rgba: np.ndarray) -> bytes: + """PNG bytes (Pillow, optimize=True) of an RGBA array (bottom row first), cached by the array's content.""" + rgba = np.ascontiguousarray(rgba, np.uint8) + k = PNG.key("packed", rgba.shape, rgba.data) + data = PNG.get(k) + if data is None: + buf = io.BytesIO() + Image.fromarray(rgba[::-1].copy(), "RGBA").save(buf, format="PNG", optimize=True) + data = buf.getvalue() + PNG.put(k, data) + return data + + +def _safe(s: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "_", s) + + +# --- texel blocks -> packed textures --------------------------------------------- +def grow_box(x0: float, y0: float, x1: float, y1: float, margin: int) -> tuple[int, int, int, int]: + """Texel box covering [x0, x1) x [y0, y1) grown by `margin` texels on every side.""" + return (math.floor(x0) - margin, math.floor(y0) - margin, math.ceil(x1) + margin, math.ceil(y1) + margin) + + +def wrap_mode(mode: int) -> Literal["wrap", "clip"]: + """The np.take mode of a texture's wrap mode (m_WrapU / m_WrapV: 0 repeat, 1 clamp).""" + if mode == 0: + return "wrap" + if mode == 1: + return "clip" + raise NotImplementedError(f"texture wrap mode {mode}") + + +def texel_rows(img, texture_format: int, width: int, height: int, name: str) -> np.ndarray: + """The RGBA texels of a decoded Texture2D image `img` (a PIL image; the texture's format, size and name), bottom + row first. Alpha8 keeps its value in the alpha channel.""" + if texture_format == 1: # Alpha8: value in the alpha channel + if img.mode == "L": + a = np.asarray(img, np.uint8) + elif img.mode == "RGBA": + a = np.asarray(img, np.uint8)[:, :, 3] + else: + raise NotImplementedError(f"Alpha8 decoded as {img.mode}") + arr = np.zeros(a.shape + (4,), np.uint8) + arr[:, :, 3] = a # rgb 0 (GL alpha texture); TMP reads .a only + else: + arr = np.asarray(img.convert("RGBA"), np.uint8) + if arr.shape[:2] != (height, width): + raise RuntimeError(f"{name}: decoded size {arr.shape}") + return arr[::-1].copy() # bottom row first (Unity v = 0) + + +def pack_group(group: str, handles: list[dict], pixels, width_limit: int) -> tuple[dict, np.ndarray]: + """The layout and the texels of one packed texture `group` of the blocks `handles` (MemoryPacker.build). Returns its + record without its file (name, width, height, mipCount, settings, sources, blocks) and its RGBA array, bottom row + first. `pixels(tex)` gives the texels of a block's source texture. Every handle gets its texture (`group`) and its + integer `offset`.""" + settings = {json.dumps(h["settings"], sort_keys=True) for h in handles} + if len(settings) != 1: + raise RuntimeError(f"{group}: blocks from textures with different sampler settings") + # dedupe identical blocks of the same texture + uniq: dict[tuple, dict] = {} + for h in handles: + uniq.setdefault((h["key"], h["box"]), h) + blocks = sorted(uniq.values(), key=lambda h: (-(h["box"][3] - h["box"][1]), h["box"])) + x = y = shelf = 0 + width = max(width_limit, max(b["box"][2] - b["box"][0] for b in blocks)) + for b in blocks: + w, hgt = b["box"][2] - b["box"][0], b["box"][3] - b["box"][1] + if x + w > width: + x, y, shelf = 0, y + shelf, 0 + b["dst"] = (x, y) + x += w + shelf = max(shelf, hgt) + height = y + shelf + if len(blocks) == 1: # a single block keeps its own size + width = blocks[0]["box"][2] - blocks[0]["box"][0] + out = np.zeros((height, width, 4), np.uint8) + for b in blocks: + x0, y0, x1, y1 = b["box"] + dx, dy = b["dst"] + if "array" in b: + out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = b["array"] + continue + src = pixels(b["tex"]) + st = b["settings"] + rows = np.take(np.arange(src.shape[0]), np.arange(y0, y1), mode=wrap_mode(st["m_WrapV"])) + cols = np.take(np.arange(src.shape[1]), np.arange(x0, x1), mode=wrap_mode(st["m_WrapU"])) + out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = src[np.ix_(rows, cols)] + for h in handles: + d = uniq[(h["key"], h["box"])]["dst"] + h["offset"] = (d[0] - h["box"][0], d[1] - h["box"][1]) + h["texture"] = group + s = next(iter(handles))["settings"] + rec = { + "name": group, + "width": width, + "height": height, + "mipCount": 1, + "settings": s, + "sources": sorted({h["srcName"] for h in handles}), + "blocks": len(blocks), + } + return rec, out + + +def texture_block( + key: tuple, settings: dict, name: str, size: tuple[int, int], box: tuple[int, int, int, int], tex +) -> dict: + """A block that copies the texel box `box` of the texture `tex` (its texels come from the packer's `pixels`). + `key` identifies the texture, `settings` is its m_TextureSettings, `name` and `size` (width, height) its name and + size.""" + return {"tex": tex, "key": key, "settings": settings, "box": box, "srcName": name, "srcSize": size} + + +def array_block(name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: + """A block of generated texels: `rgba` (RGBA, bottom row first) placed at `origin` of its virtual texture.""" + ox, oy = origin + return { + "array": rgba, + "key": ("generated", name), + "settings": settings, + "box": (ox, oy, ox + rgba.shape[1], oy + rgba.shape[0]), + "srcName": name, + "srcSize": (rgba.shape[1], rgba.shape[0]), + } + + +class MemoryPacker: + """Pack in-memory texture blocks into PNG pages. Textures supply a key, name, settings, width, height and + bottom-first RGBA pixels. build() returns page records and encoded bytes by file name.""" + + WIDTH = 2048 + + def __init__(self): + self.groups: dict[str, list] = {} + + @staticmethod + def pixels(tex) -> np.ndarray: + return tex.pixels + + def add(self, group: str, tex, x0: int, y0: int, x1: int, y1: int) -> dict: + """Register a block; returns a handle filled in by build().""" + h = texture_block(tex.key, tex.settings, tex.name, (tex.width, tex.height), (x0, y0, x1, y1), tex) + self.groups.setdefault(group, []).append(h) + return h + + def add_array(self, group: str, name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: + """Register generated RGBA texels at their virtual texture origin.""" + h = array_block(name, rgba, origin, settings) + self.groups.setdefault(group, []).append(h) + return h + + def build(self) -> tuple[dict, dict[str, bytes]]: + """The records of the packed textures by group, and the PNG bytes of their pages by file name.""" + textures: dict[str, dict] = {} + pages: dict[str, bytes] = {} + for group, handles in self.groups.items(): + rec, out = pack_group(group, handles, self.pixels, self.WIDTH) + fn = f"textures/{_safe(group)}.png" + pages[fn] = packed_png(out) + textures[group] = {"texture": fn, **rec} + return textures, pages diff --git a/src/nnnotes/unity/textstyle.py b/src/nnnotes/unity/textstyle.py index dba56e8..7db9a2e 100644 --- a/src/nnnotes/unity/textstyle.py +++ b/src/nnnotes/unity/textstyle.py @@ -1,34 +1,9 @@ -"""TextMesh Pro text layout and style as plain values (shared by the UI exports). - -A text record describes what a TMP text draws with any font: its layout settings (size, auto-size range, -alignment, wrapping, overflow, spacing, margins), its colours and style flags, and the look of its TMP -distance-field material turned into face / outline / underlay values in em. `fontRole` names which of the -game's localized font slots the text uses, so a renderer maps roles to fonts of its own. - - language_fonts Fwk.Localization.LocalizeManager font names per LanguageMode (level0) - language_fonts_json the same from the object's json (the shipped typetree of the class) - localize_text LocalizeText.Init / OnFontChanged: the font asset, material and line spacing a TMP text ends up - with in a language - -The language is a Fwk.Localization.LanguageMode (`mode`), the value languages.mode gives for a catalog language code; -the callers take it from the client language they export (`[catalog] language`). - font_role the role of a font asset: its slot in LocalizeManager's font name list - material_style TMP distance-field material properties -> face / outline / underlay values in em - text_style one TMP text component -> its text record - TextStyles text records of an exporter's TMP texts (reads the localized materials and face info) -""" +"""TextMesh Pro layout, localization, font roles and material style records.""" from __future__ import annotations -import struct -from typing import TYPE_CHECKING - from ..source.languages import LANGUAGES -if TYPE_CHECKING: # annotations only: the module loads without UnityPy - from .export import Exporter - from .player import PlayerData - # Fwk.Localization.LanguageMode / language field names of the text tables. JAPANESE_MODE = 0 LANGUAGE_FIELDS = {mode: column[1:] for mode, column in LANGUAGES.values()} # LanguageMode -> text field @@ -73,75 +48,6 @@ } -class _Reader: - """Little-endian serialized-field reader with 4-byte alignment.""" - - def __init__(self, raw: bytes, pos: int = 0): - self.raw, self.pos = raw, pos - - def align(self): - self.pos = (self.pos + 3) & ~3 - - def i32(self) -> int: - v = struct.unpack_from(" int: - v = struct.unpack_from(" int: - v = self.raw[self.pos] - self.pos += 1 - return v - - def string(self) -> str: - n = self.i32() - s = self.raw[self.pos : self.pos + n].decode("utf-8") - self.pos += n - self.align() - return s - - def strings(self) -> list[str]: - return [self.string() for _ in range(self.i32())] - - -def language_fonts(player: PlayerData) -> dict: - """Fwk.Localization.LocalizeManager (level0): font names per LanguageMode. - - Read field by field from the serialized bytes; language_fonts_json gives the same dictionary from the object's - json. Layout: m_GameObject, m_Enabled, m_Script, m_Name, _dontDestroyOnload, _languageDataList[{_languageMode, - _fontNames[], _additionalFontNames[]}], _materialTypeList[]. - """ - hits = [ - o - for o in player._by_type.get("MonoBehaviour", []) - if player.script(o)[1:] == ("Fwk.Localization", "LocalizeManager") - ] - if len(hits) != 1: - raise RuntimeError(f"LocalizeManager: {len(hits)} objects") - r = _Reader(hits[0].get_raw_data()) - r.i32() - r.i64() # m_GameObject - r.u8() - r.align() # m_Enabled - r.i32() - r.i64() # m_Script - r.string() # m_Name - r.u8() - r.align() # _dontDestroyOnload - langs = {} - for _ in range(r.i32()): - mode = r.i32() - langs[mode] = {"fontNames": r.strings(), "additionalFontNames": r.strings()} - materials = r.strings() - if r.pos != len(r.raw): - raise RuntimeError(f"LocalizeManager: {len(r.raw) - r.pos} trailing bytes") - return {"languages": langs, "materialTypes": materials} - - def language_fonts_json(localize: dict) -> dict: """language_fonts of the LocalizeManager's object json (its shipped type tree: the fields as serialized), the same {"languages": {mode: {"fontNames", "additionalFontNames"}}, "materialTypes"} dictionary.""" @@ -356,41 +262,3 @@ def text_style(comp: dict, localize: dict | None, lang: dict, style: dict, mode: **style, } return rec - - -class TextStyles: - """Text records of TMP texts read through an Exporter: the material each text draws with in the language - `mode` (LocalizeText) and the face info of its font asset, from `fonts` (fontsettings.FontSettings: the table - shipped with nnnotes, fontsettings.builtin(), or the game's font assets, tmpfont.AssetFontSettings); no font data - is kept.""" - - def __init__(self, ex: Exporter, player: PlayerData, mode: int, fonts): - self.ex, self.mode = ex, mode - self.fonts = fonts - self.lang = language_fonts(player) - self.swap = font_swap(self.lang, mode) - self.roles: dict[str, dict] = {} - self._styles: dict[tuple, dict] = {} - - def _face(self, font_asset: str) -> tuple[dict, dict]: - f = self.fonts.font(font_asset) - return f["faceInfo"], {k: f[k] for k in ("normalSpacingOffset", "boldSpacing")} - - def record(self, path: str, comp: dict, localize: dict | None) -> dict: - """The text record of `comp` (TMP text component) with its LocalizeText component `localize`.""" - fa, mat = comp["m_fontAsset"]["name"], comp["m_sharedMaterial"]["material"] - if localize and localize["m_Enabled"] and localize["_localizeEnabled"]: - loc = localize_text(path, fa, mat, self.swap, self.lang, self.mode) - fa, mat = loc["fontAsset"], loc["material"] - if (fa, mat) not in self._styles: - face, extra = self._face(fa) - self._styles[(fa, mat)] = material_style(self.fonts.material(fa, mat), face["m_PointSize"], face["m_Scale"]) - rec = text_style(comp, localize, self.lang, self._styles[(fa, mat)], self.mode) - if rec["fontRole"] not in self.roles: - face, extra = self._face(fa) - self.roles[rec["fontRole"]] = face_metrics(face, extra) - return rec - - def summary(self) -> dict: - """The document-level part: the language's text field, units and the line metrics of each role used.""" - return {"language": LANGUAGE_FIELDS[self.mode], "units": UNITS, "roles": self.roles} diff --git a/src/nnnotes/unity/tmpfont.py b/src/nnnotes/unity/tmpfont.py index 241cddc..b780dd7 100644 --- a/src/nnnotes/unity/tmpfont.py +++ b/src/nnnotes/unity/tmpfont.py @@ -1,20 +1,7 @@ -"""TextMeshPro font assets for the UI exports with game fonts (`--fonts game`). - - FontSet TMP font assets reachable from the localized fonts (fallback chains) with TMP - character lookup; RuntimeGlyphs generates the SDF glyphs a dynamic font asset adds - at runtime (FontEngine) from the source font file, validated against the baked ones - glyph_coverage / export_fonts / resolve_font_blocks - the character and glyph tables reduced to the characters a page shows, their texel - blocks copied into packed textures (export.TexelPacker) - -The localized font / material / line spacing of a text (LocalizeText) and the text records are in textstyle.py. -Needs the optional dependencies of the `fonts` extra (require_extra). -""" +"""TextMesh Pro font lookup, glyph rendering and material records over font-asset protocols.""" from __future__ import annotations -import copy -import hashlib import importlib.util import io import math @@ -24,7 +11,6 @@ import numpy as np from ..source.config import ConfigError -from .export import Exporter, MemoryPacker, TexelPacker, _key, grow_box from .fontsettings import ( END_OF_TEXT, FONT_CLASS, @@ -34,12 +20,10 @@ TMP_SYNTHESIZED, FontSettings, encode_coverage, - font_dir, - font_key, is_variation_selector, table_material, ) -from .textstyle import language_fonts +from .texels import MemoryPacker, grow_box EXTRA = "fonts" EXTRA_MODULES = {"fontTools": "fonttools", "freetype": "freetype-py", "scipy": "scipy"} @@ -77,8 +61,7 @@ def font_cmap(data: bytes, face_index: int) -> tuple[set[int], dict[tuple[int, i class FontAsset(Protocol): - """A TextMesh Pro font asset as the lookup, the glyph tables and the runtime glyphs read it: Font (read through an - Exporter) and tmpassets.Font (read from an object table).""" + """A TextMesh Pro font asset supplying character lookup, glyph tables and runtime glyph generation.""" name: str tt: dict @@ -98,50 +81,6 @@ def atlas_settings(self) -> list[dict]: ... def default_material(self) -> dict: ... -class Font: - def __init__(self, ex: Exporter, obj): - self.obj = obj - self._ex = ex - tt = obj.read_typetree() - self.tt = tt - self.name = tt["m_Name"] - self.chars = {c["m_Unicode"]: c for c in tt["m_CharacterTable"]} - self.glyphs = {g["m_Index"]: g for g in tt["m_GlyphTable"]} - self.dynamic = tt["m_AtlasPopulationMode"] == 1 - self.atlases = [ex.deref(obj, p) for p in tt["m_AtlasTextures"]] - self.material_obj = ex.referent(obj, tt["m_Material"]) - self.fallback_objs = [ex.deref(obj, p) for p in tt["m_FallbackFontAssetTable"]] - if any(p["regularTypeface"]["m_PathID"] or p["italicTypeface"]["m_PathID"] for p in tt["m_FontWeightTable"]): - raise NotImplementedError(f"{self.name}: font weight table typefaces") - self.cmap: set[int] = set() - self.variants: dict[tuple[int, int], str] = {} # (base, selector) -> a variant glyph other than the base's - self.font_data: bytes | None = None - src = ex.deref(obj, tt["m_SourceFontFile"]) if tt["m_SourceFontFile"]["m_PathID"] else None - if src is not None: - data = bytes(src.read_typetree()["m_FontData"]) - self.cmap, self.variants = font_cmap(data, tt["m_FaceInfo"]["m_FaceIndex"]) - self.font_data = data - self.source_font = { - "name": src.read_typetree()["m_Name"], - "bytes": len(data), - "sha256": hashlib.sha256(data).hexdigest(), - } - else: - self.source_font = None - - def atlas_names(self) -> list[str | None]: - """The names of the atlas textures (None for a missing page).""" - return [a.read_typetree()["m_Name"] if a is not None else None for a in self.atlases] - - def atlas_settings(self) -> list[dict]: - """The sampler settings of the atlas textures that are there.""" - return [a.read_typetree()["m_TextureSettings"] for a in self.atlases if a is not None] - - def default_material(self) -> dict: - """The record of the default material (Exporter.material).""" - return self._ex.material(self.material_obj) - - class FontLookup: """TMP character lookup over font assets: a font's own characters, then its fallback chain (`fallbacks(f)`, as TextMeshPro searches it; see lookup). Results are (kind, font asset) with kind "baked", "runtime" (a dynamic asset @@ -197,49 +136,12 @@ def baked_anywhere(self, u: int) -> list[str]: return sorted(f.name for f in self.fonts.values() if u in f.chars) -class FontSet(FontLookup): - """Font assets reachable from the localized fonts, with TMP character lookup.""" - - def __init__(self, ex: Exporter): - super().__init__() - self.ex = ex - self._by_obj: dict[tuple, Font] = {} - - def by_key(self, key: str) -> Font: - return self.of(self.ex.key_object(key)) - - def loaded(self, name: str) -> Font: - """The font asset `name`, read by `of` or `by_key`.""" - return cast(Font, self.fonts[name]) - - def of(self, obj) -> Font: - k = _key(obj) - if k not in self._by_obj: - f = Font(self.ex, obj) - if f.name in self.fonts: - raise RuntimeError(f"two font assets named {f.name}") - self._by_obj[k] = f - self.fonts[f.name] = f - self._fallback_names[f.name] = [self.of(fb).name for fb in f.fallback_objs if fb is not None] - return self._by_obj[k] - - # -------------------------------------------------------------------------- # missing glyph # -------------------------------------------------------------------------- MISSING_GLYPH_CHARACTER = 0x25A1 # TMP_Settings.missingGlyphCharacter 0: U+25A1 (TMP_Text.SetArraySizes) -def missing_glyph_character(player) -> int: - """TMP_Settings.missingGlyphCharacter of the game's TMP Settings (0: U+25A1). FontSet.lookup searches no global - fallback: the settings' fallback font assets and default font asset must be unset.""" - o = player.resource("TMP Settings") - tt = player.mono(o) - if tt["m_fallbackFontAssets"] or tt["m_defaultFontAsset"]["m_PathID"]: - raise NotImplementedError("TMP Settings fallback font assets / default font asset") - return tt["m_missingGlyphCharacter"] or MISSING_GLYPH_CHARACTER - - def missing_glyph(fonts: FontLookup, font: FontAsset, chars, character: int = MISSING_GLYPH_CHARACTER) -> dict | None: """The missing glyph of a text of `font` (TMP_Text.SetArraySizes, a character GetTextElement finds in no font asset and no sprite asset): {unicode: the substitute, characters: the code points of `chars` that `font` and its @@ -253,13 +155,6 @@ def missing_glyph(fonts: FontLookup, font: FontAsset, chars, character: int = MI return {"unicode": sub, "characters": miss} -def underline_character(fonts: FontLookup, font: FontAsset) -> bool: - """Whether `font` itself has U+005F (TMP_Text.GetUnderlineSpecialCharacter: GetCharacterFromFontAsset without - fallbacks; a dynamic asset adds it from its source font): the glyph its texts' underline and highlight draw with.""" - r = fonts._in_font(font, 0x5F) - return r is not None and r[0] in ("baked", "runtime") - - def check_glyph_variants(fonts: FontLookup, font: FontAsset, texts) -> None: """Raise NotImplementedError when a character of `texts` that `font` (or a fallback) has is followed by a variation selector its source font maps to a variant glyph (SetArraySizes: GetGlyphVariantIndex -> the alternative glyph); @@ -460,7 +355,7 @@ def sdf(self, gi: int) -> tuple[dict, tuple, np.ndarray]: a = np.floor(255.0 * np.clip(0.5 + d / (2.0 * self.spread), 0.0, 1.0) + 0.5).astype(np.uint8) return m, (xmin, ymin, w, h), a - def validate(self, packer: TexelPacker | MemoryPacker, units) -> dict: + def validate(self, packer: MemoryPacker, units) -> dict: """Generate the baked characters `units` of the same font asset and compare with its atlas.""" f, p = self.font, self.pad errs, per, metric_diff = [], {}, {k: 0.0 for k in GLYPH_METRIC_FIELDS} @@ -516,13 +411,6 @@ def rgba_alpha(a: np.ndarray) -> np.ndarray: return out -def material_by_name(ex: Exporter, font: FontAsset, mat_name: str): - key = font_dir(font.name) + mat_name - if not ex.cat.has(key): - raise KeyError(f"material key {key}") - return ex.material(ex.key_object(key)) - - def shared_settings(name: str, settings: list[dict]) -> dict: """The sampler settings that all the atlas pages of the font asset `name` have (RuntimeError when they differ or there is no page).""" @@ -536,7 +424,7 @@ def runtime_glyphs( extra: set, baked: set, margin: int, - packer: TexelPacker | MemoryPacker, + packer: MemoryPacker, chars_out: dict, glyphs_out: dict, ) -> dict: @@ -683,22 +571,13 @@ def text_material_records( return materials, text_mats -def text_materials( - ex: Exporter, fonts: FontSet, localized: list[dict], primaries: set, needed: dict, runtime_units: dict -) -> tuple[dict, dict]: - """text_material_records for the font assets of `fonts`, their materials read through `ex`.""" - return text_material_records( - fonts, localized, primaries, needed, runtime_units, lambda f, name: material_by_name(ex, f, name) - ) - - def export_fonts( fonts: FontLookup, primaries: set, needed: dict, runtime_units: dict, text_mats: dict, - packer: TexelPacker | MemoryPacker, + packer: MemoryPacker, subset: str, ) -> dict: """Font asset records with the character / glyph tables reduced to the `needed` baked characters (plus the @@ -809,7 +688,7 @@ def value(r: dict) -> dict: def resolve_font_blocks(font_out: dict) -> None: - """After TexelPacker.build(): glyph texel blocks -> {texture, dx, dy} (integer offset into the packed texture).""" + """After MemoryPacker.build(): glyph texel blocks -> {texture, dx, dy} (integer offset into the packed texture).""" for fo in font_out.values(): for g in fo["glyphs"].values(): b = g.pop("_block", None) @@ -844,57 +723,6 @@ def __init__(self): self.material_ids: dict[str, int] = {} # material name -> its object's path id -class AssetFontSettings(BuiltFontSettings): - """FontSettings read from the game's font assets (`ex`: an Exporter of the catalog and player data): a font asset - at its address (fontsettings.font_key), its fallbacks through their references (FontSet), its materials at their - addresses. Needs the `fonts` extra for the source font files of dynamic assets.""" - - def __init__(self, ex): - super().__init__() - self.ex = ex - self._set = None - - def _fontset(self): - if self._set is None: - self._set = FontSet(self.ex) - return self._set - - def _font_obj(self, name: str) -> Font: - fs = self._fontset() - if name not in fs.fonts: - fs.by_key(font_key(self.ex.cat, name, name)) - return fs.loaded(name) - - def font(self, name: str) -> dict: - if name not in self.fonts: - f = self._font_obj(name) - mat = f.default_material() - self.materials.setdefault(mat["material"], mat) - self.material_ids.setdefault(mat["material"], f.material_obj.path_id) - self.font_ids[name] = f.obj.path_id - fallbacks = [fb.name for fb in self._fontset().fallbacks(f)] - self.fonts[name] = font_record(f, mat["material"], fallbacks, f.obj.assets_file.parent.name) - return self.fonts[name] - - def has_material(self, font: str, name: str) -> bool: - return name in self.materials or self.ex.cat.has(font_key(self.ex.cat, font, name)) - - def material(self, font: str | None, name: str) -> dict: - if name not in self.materials: - if font is None: - raise KeyError(f"material {name}: not read yet, and no font asset to find it by") - key = font_key(self.ex.cat, font, name) - if not self.ex.cat.has(key): - raise KeyError(f"material key {key}") - obj = self.ex.key_object(key) - self.material_ids[name] = obj.path_id - self.materials[name] = self.ex.material(obj) - return copy.deepcopy(self.materials[name]) - - def where(self) -> str: - return "the game's font assets" - - def listed_fonts(lang: dict) -> list[str]: """The font assets LocalizeManager lists for the languages (`lang`: textstyle.language_fonts): " SDF" for every font name and additional font name, sorted.""" @@ -931,12 +759,3 @@ def font_document(settings: BuiltFontSettings, names: list[str], game_version: s "materials": {n: table_material(settings.materials[n]) for n in sorted(settings.materials)}, "objects": dict(sorted(objects.items(), key=lambda kv: int(kv[0]))), } - - -def build_font_settings(ex, player, game_version: str) -> dict: - """The font settings table (fontsettings.FORMAT) from the game's assets: every font asset LocalizeManager lists - for a language (font names and additional font names) with the assets of their fallback chains, their default - materials and the localized text materials of the listed assets that the catalog has (table_material), and the - objects index of those assets.""" - settings = AssetFontSettings(ex) - return font_document(settings, load_listed(settings, language_fonts(player)), game_version) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index e7dc822..a401ac4 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -465,36 +465,6 @@ def test_flac_level_reaches_the_decode(data, tmp_path, capsys, monkeypatch): assert code == 0 and seen == [level], (argv, err) -def test_live_passes_the_flac_level_to_its_decodes(tmp_path, monkeypatch): - from nnnotes.content import live, liveaudio, score - from nnnotes.formats import cri - - class Stop(Exception): - pass - - seen = {} - monkeypatch.setattr(live, "resolve_band", lambda *a, **kw: {"band": 1}) - monkeypatch.setattr(score, "extract", lambda *a, **kw: seen.update(score=kw["flac_level"]) or {}) - - def sounds(*a, **kw): - seen["liveaudio"] = kw["flac_level"] - raise Stop - - monkeypatch.setattr(liveaudio, "extract", sounds) - with pytest.raises(Stop): - live.build(None, tmp_path, None, 7, "expert", tmp_path / "o", language="ja", flac_level=3) - assert seen == {"score": 3, "liveaudio": 3} - - def decode(cat, sheet, d, **kw): - seen["decode"] = kw - d.mkdir(parents=True) - (d / "streams.json").write_text("[]", encoding="utf-8") - - monkeypatch.setattr(cri, "decode", decode) - assert liveaudio._decode(None, "s", tmp_path, "flac", 3) == [] - assert seen["decode"] == {"fmt": "flac", "flac_level": 3} - - def test_export_live_asks_for_the_live_and_the_products_it_plays_with(data, tmp_path, capsys, monkeypatch): from nnnotes.cli import assets from nnnotes.content import livenotes diff --git a/tests/content/test_hostspot.py b/tests/content/test_hostspot.py deleted file mode 100644 index cabd194..0000000 --- a/tests/content/test_hostspot.py +++ /dev/null @@ -1,509 +0,0 @@ -"""hostspot (the spot files of a story's home host, read from bundles): empty material slots and mesh filters, what -draws nothing, materials and textures of a bundle whose serialized files reuse path ids, Spine characters and tap -targets with and without the references the prefab leaves empty, and the Spine files of a skeleton (synthetic -objects).""" - -import copy -import io -import json -import struct -from types import SimpleNamespace - -import numpy as np -import pytest -from PIL import Image -from UnityPy.classes import PPtr as UnityPPtr - -from nnnotes.content import hostspot, room -from nnnotes.unity.reader import DEFAULT_RESOURCES -from tests.helpers.fakeunity import F32, POSITION, UV0, mesh_tt, submesh, unitypy_mesh -from tests.helpers.spotobjects import fv, material, shader - - -class PPtr: - def __init__(self, path_id): - self.path_id = path_id - - -def test_empty_material_slots_are_skipped_and_reported(): - tris = [np.array([[0, 1, 2]]), np.zeros((0, 3), int), np.array([[2, 3, 0], [0, 1, 3]])] - mats = [PPtr(0), PPtr(11), PPtr(12)] - nulls, used = [], [] - - def material_for(p): - used.append(p.path_id) - return p.path_id * 10 - - prims = hostspot.submesh_primitives(tris, mats, material_for, nulls.append) - assert nulls == [0] and used == [12] # the empty submesh 1 gives nothing, silently - assert len(prims) == 1 and prims[0][1] == 120 - assert prims[0][0].tolist() == [2, 0, 3, 0, 3, 1] # winding reversed for glTF - assert hostspot.submesh_primitives(tris[:1], mats[:1], material_for, nulls.append) == [] and nulls == [0, 0] - - -class Obj: - """A component: its typetree and the object UnityPy reads for it.""" - - def __init__(self, tt, **fields): - self.tt, self.fields = tt, fields - - def read_typetree(self): - return self.tt - - def read(self): - return type("Read", (), self.fields)() - - -class Ref(PPtr): - def __init__(self, path_id, target=None): - super().__init__(path_id) - self.target = target - - def read(self): - assert self.path_id, "an empty reference is not read" - return self.target - - -class Graph: - tf_of_go = {1: 11, 2: 12, 3: 13, 4: 14} - - def path(self, tf_pid): - return f"Room/obj{tf_pid}" - - -def test_a_mesh_filter_without_a_mesh_is_skipped_and_reported(): - mesh = object() - mfs = { - ("f", 1): Obj({}, m_Mesh=Ref(21, mesh)), - ("f", 2): Obj({}, m_Mesh=Ref(0)), - ("f", 3): Obj({}, m_Mesh=Ref(0)), - ("f", 4): Obj({}, m_Mesh=Ref(24, mesh)), - } - mrs = { - ("f", 1): Obj({"m_Enabled": 1}, m_Materials=["m1"]), - ("f", 2): Obj({"m_Enabled": 1}, m_Materials=[]), - ("f", 3): Obj({"m_Enabled": 0}, m_Materials=[]), # 3: disabled renderer, 4: no renderer - ("g", 4): Obj({"m_Enabled": 1}, m_Materials=["m4"]), - } # another file's GameObject 4 - nulls = [] - drawn = list(hostspot.drawn_meshes(mfs, mrs, Graph(), nulls.append)) - assert drawn == [(11, mesh, ["m1"])] - assert nulls == ["Room/obj12"] # the disabled one draws nothing either way - - -def test_extract_room_lists_what_draws_nothing(tmp_path, monkeypatch): - """A mesh without triangles, a MeshFilter without a mesh and a submesh without a material are not written but - listed in hostspot.json.""" - - class Mesh: - def __init__(self, name): - self.m_Name = name - - class Cat: - def fetch_key(self, key): - return [] - - class SceneGraphStub(Graph): - def __init__(self, env): - pass - - def world(self, tf_pid): - return np.eye(4) - - def active(self, tf_pid): - return True - - tri = np.array([[0, 1, 2]]) - arrays = { - "quad": (np.zeros((3, 3)), np.empty((0, 3)), np.zeros((3, 2)), [tri]), - "placeholder": (np.zeros((4, 3)), np.empty((0, 3)), np.zeros((4, 2)), [np.zeros((0, 3), int)]), - "bare": (np.zeros((4, 3)), np.empty((0, 3)), np.zeros((4, 2)), []), - } - - def drawn(mf_by_go, mr_by_go, graph, on_null_mesh): - on_null_mesh("Room/obj14") - yield 11, Mesh("quad"), [PPtr(0)] - yield 12, Mesh("placeholder"), [] - yield 13, Mesh("bare"), [] - - monkeypatch.setattr(hostspot.UnityPy, "load", lambda *a: type("Env", (), {"objects": []})()) - monkeypatch.setattr(hostspot, "SceneGraph", SceneGraphStub) - monkeypatch.setattr(hostspot, "drawn_meshes", drawn) - monkeypatch.setattr(hostspot, "mesh_arrays", lambda mesh: arrays[mesh.m_Name]) - doc = hostspot.extract_room(Cat(), "Spot/a/Background/b", tmp_path / "hostspot.glb") - assert doc["meshCount"] == 0 and doc["materials"] == [] - assert doc["nullMeshFilters"] == ["Room/obj14"] - assert doc["meshesWithoutTriangles"] == [ - {"mesh": "placeholder", "path": "Room/obj12"}, - {"mesh": "bare", "path": "Room/obj13"}, - ] - assert doc["nullMaterialSubmeshes"] == [{"mesh": "quad", "path": "Room/obj11", "submesh": 0}] - assert (tmp_path / "hostspot.glb").is_file() and (tmp_path / "hostspot.json").is_file() - - -# ---------------------------------------------------------------- a bundle whose serialized files reuse path ids -V0, V1 = {"x": 0.0, "y": 0.0, "z": 0.0}, {"x": 1.0, "y": 1.0, "z": 1.0} -Q0 = {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0} -ROOM_KEY = "Spot/a/Background/b" -PREFAB = "Assets/Room.prefab" -# the quads under the prefab root: (object name, material path id, its file id in CAB-a: 1 is CAB-b) -ROOM_OBJECTS = [("wall", 5, 0), ("wall_b", 5, 1), ("floor", 6, 0), ("wall_c", 5, 0)] - - -def ref(pid, file_id=0): - return {"m_FileID": file_id, "m_PathID": pid} - - -class File: - """A serialized file as UnityPy's PPtr resolves it: name, objects by path id, externals (paths) and the bundle - holding it (`parent`); a file outside the loaded bundles is not found.""" - - def __init__(self, name, bundle, externals=()): - self.name, self.parent, self.objects = name, bundle, {} - self.externals = [SimpleNamespace(path=p) for p in externals] - self.environment = SimpleNamespace(find_file=lambda name: None) - bundle.files[name] = self - - def add(self, kind, pid, tt, obj=None): - o = SimpleNamespace( - type=SimpleNamespace(name=kind), - path_id=pid, - assets_file=self, - read_typetree=lambda: copy.deepcopy(tt), - read=lambda: obj, - ) - self.objects[pid] = o - return o - - def pptr(self, pid, file_id=0): - """A PPtr as UnityPy parses it from an object of this file.""" - return UnityPPtr(m_FileID=file_id, m_PathID=pid, assetsfile=self) - - -def tex_env(pid, file_id=0): - return {"m_Texture": ref(pid, file_id), "m_Scale": {"x": 1.0, "y": 1.0}, "m_Offset": {"x": 0.0, "y": 0.0}} - - -def textured(name, **slots): - """A material typetree of shader 3 with the texture envs `slots` {slot: tex_env}.""" - tt = material(name=name) - tt.update(m_Shader=ref(3), m_ValidKeywords=[], m_CustomRenderQueue=-1) - tt["m_SavedProperties"]["m_TexEnvs"] = [[k, v] for k, v in slots.items()] - return tt - - -def quad(name): - return unitypy_mesh( - mesh_tt(name, 3, {POSITION: (F32, 3, np.eye(3)), UV0: (F32, 2, np.zeros((3, 2)))}, [0, 1, 2], [submesh(0, 3)]) - ) - - -def two_file_room(): - """The environment of a bundle of two serialized files that reuse path ids. CAB-a holds the background prefab - (root `room`, GameObject 100, over ROOM_OBJECTS), shader 3, material 5 `a_wall` (main texture its texture 7, - `_Mask` in unity default resources), material 6 `a_floor` (main texture CAB-b's texture 7, `_Detail` its own - texture 7) and texture 7 `wall_a`. CAB-b holds shader 3, material 5 `b_wall` (main texture its texture 7) and - texture 7 `wall_b`: the pixels of `wall_a` with another filter.""" - bundle = SimpleNamespace(files={}) - a = File("CAB-a", bundle, ["archive:/CAB-b/CAB-b", DEFAULT_RESOURCES]) - b = File("CAB-b", bundle) - pixels = Image.new("RGBA", (2, 2), (200, 120, 40, 255)) - for f in (a, b): - f.add("Shader", 3, shader(name="Unlit/Transparent", srcBlend=fv(5.0), destBlend=fv(10.0))) - a.add("Material", 5, textured("a_wall", _MainTex=tex_env(7), _Mask=tex_env(9, 2))) - a.add("Material", 6, textured("a_floor", _MainTex=tex_env(7, 1), _Detail=tex_env(7))) - b.add("Material", 5, textured("b_wall", _MainTex=tex_env(7))) - for f, name, filter_mode in ((a, "wall_a", 1), (b, "wall_b", 0)): - f.add( - "Texture2D", - 7, - { - "m_Name": name, - "m_MipCount": 1, - "m_TextureSettings": {"m_FilterMode": filter_mode, "m_WrapU": 1, "m_WrapV": 1}, - }, - pixels.copy(), - ) - a.add("AssetBundle", 1, {"m_Container": [[PREFAB.lower(), {"asset": ref(100)}]]}) - a.add("GameObject", 100, {"m_Name": "room", "m_IsActive": 1, "m_Layer": 0}) - a.add( - "Transform", - 200, - { - "m_GameObject": ref(100), - "m_Father": ref(0), - "m_LocalPosition": V0, - "m_LocalRotation": Q0, - "m_LocalScale": V1, - "m_Children": [ref(201 + i) for i in range(len(ROOM_OBJECTS))], - }, - ) - for i, (name, mat, file_id) in enumerate(ROOM_OBJECTS): - go, mesh = 101 + i, quad(f"{name}_mesh") - a.add("GameObject", go, {"m_Name": name, "m_IsActive": 1, "m_Layer": 0}) - a.add( - "Transform", - 201 + i, - { - "m_GameObject": ref(go), - "m_Father": ref(200), - "m_Children": [], - "m_LocalPosition": V0, - "m_LocalRotation": Q0, - "m_LocalScale": V1, - }, - ) - a.add( - "MeshFilter", - 301 + i, - {"m_GameObject": ref(go)}, - SimpleNamespace(m_Mesh=SimpleNamespace(path_id=21 + i, read=lambda mesh=mesh: mesh)), - ) - a.add( - "MeshRenderer", - 401 + i, - {"m_GameObject": ref(go), "m_Enabled": 1}, - SimpleNamespace(m_Materials=[a.pptr(mat, file_id)]), - ) - return SimpleNamespace(objects=[o for f in (a, b) for o in f.objects.values()]) - - -class RoomCatalog: - def fetch_key(self, key): - return [] - - def entry(self, key): - return {"internal_id": PREFAB} - - -def load_room(monkeypatch, env): - """UnityPy.load gives `env`; a texture's image is the object it reads as.""" - monkeypatch.setattr(hostspot.UnityPy, "load", lambda *a: env) - monkeypatch.setattr(hostspot, "texture_image", lambda image: image) - - -def glb_json(path): - data = path.read_bytes() - (n,) = struct.unpack_from(" bytes: - buf = io.BytesIO() - image.save(buf, format="PNG") - return buf.getvalue() - - -def test_write_skeleton_writes_the_skeleton_atlas_and_pages_in_order(): - a, b = images() - shared = SpineRef(40, atlas_asset("chr", [Tex(a), Tex(b)])) - got = [] - written = {} - rec = hostspot.write_skeleton(skeleton("chr", b"\x00binary", [shared]), lambda n, d: got.append((n, d)), written) - assert [n for n, _ in got] == ["chr.skel", "chr.atlas", "p1.png", "p2.png"] - assert got[0][1] == b"\x00binary" and got[1][1] == ATLAS_TEXT.encode() - assert got[2][1] == png_of(a) and got[3][1] == png_of(b) - assert rec == { - "name": "chr_SkeletonData", - "skeleton": "chr.skel", - "atlases": ["chr.atlas"], - "scale": 0.01, - "defaultMix": 0.2, - "mixes": [{"from": "a", "to": "b", "duration": 0.5}], - } - assert written == {40: "chr.atlas"} - got.clear() # a second skeleton on the same atlas: written once - rec2 = hostspot.write_skeleton( - skeleton("chr2", ' {"skeleton": 1}', [shared]), lambda n, d: got.append((n, d)), written - ) - assert got == [("chr2.json", b' {"skeleton": 1}')] and rec2["atlases"] == ["chr.atlas"] - - -def test_write_skeleton_keeps_a_name_that_already_has_the_extension(): - a, b = images() - got = [] - shared = SpineRef(40, atlas_asset("chr.atlas", [Tex(a), Tex(b)])) - rec = hostspot.write_skeleton(skeleton("chr.skel", b"\x00binary", [shared]), lambda n, d: got.append((n, d))) - assert [n for n, _ in got] == ["chr.skel", "chr.atlas", "p1.png", "p2.png"] and rec["skeleton"] == "chr.skel" - - -def test_write_skeleton_checks_pages_against_materials(): - a, _ = images() - with pytest.raises(RuntimeError, match="2 pages vs 1 materials"): - hostspot.write_skeleton( - skeleton("x", b"s", [SpineRef(41, atlas_asset("x.atlas", [Tex(a)]))]), lambda n, d: None - ) diff --git a/tests/content/test_live_options.py b/tests/content/test_live_options.py index cc25e06..80b9d0b 100644 --- a/tests/content/test_live_options.py +++ b/tests/content/test_live_options.py @@ -4,7 +4,7 @@ import pytest -from nnnotes.content import live, liveaudio, livenotes, liveoptions, livescene, score +from nnnotes.content import liveaudio, liveoptions, score from nnnotes.content.liveoptions import LiveOptions, OptionSpecError, parse_specs, resolve from tests.helpers.live import CHART @@ -225,115 +225,10 @@ def test_effect_sets(tmp_path): assert LiveOptions().effect_sets(names) == [] -def test_option_names_follow_the_game_enum(): - assert livescene.OPTION_ITEM_TYPES is liveoptions.OPTION_ITEM_TYPES - t = liveoptions.OPTION_ITEM_TYPES - assert (t[2], t[421], t[436], t[465], t[999]) == ( - "NoteTiming", - "UseIndividualNoteSe", - "SideFlickSeId", - "TraceSeMute", - "SongTitleDisplay", - ) - assert set(liveoptions.PLAYER_ITEMS) <= set(t) and not set(liveoptions.PLAYER_ITEMS) & set(livenotes.OPTION_ITEMS) - - # ---------------------------------------------------------------- note assets settings -def test_livenotes_settings_default_unchanged(tmp_path): - m = write_master(tmp_path / "m") - a = livenotes.settings(m) - assert livenotes.settings(m, LiveOptions()) == a - assert json.dumps(a) == json.dumps(livenotes.settings(m, LiveOptions())) - assert set(a["optionDefaults"]) == set(livenotes.OPTION_ITEMS.values()) - assert "skins" not in a and "effects" not in a and "NoteTiming" not in a["optionRanges"] - - -def test_livenotes_settings_tables_and_variants(tmp_path): - m = write_master(tmp_path / "m") - a = livenotes.settings(m) - t = livenotes.settings(m, resolve({"defaults": True}, m)) - names = {liveoptions.OPTION_ITEM_TYPES[k] for k in liveoptions.PLAYER_ITEMS} - assert livenotes.settings(m, resolve(parse_specs(["MeasureLineDisplay"]), m)) == t # the tables only - assert set(t["optionDefaults"]) == set(a["optionDefaults"]) | names - assert {k: t["optionDefaults"][k] for k in a["optionDefaults"]} == a["optionDefaults"] - assert t["optionDefaults"]["NoteTiming"] == "0.00" and t["optionDefaults"]["TapSeVolume"] == "90" - assert t["optionRanges"]["NoteTiming"] == [-300, 300] and t["optionRanges"]["LiveMusicVolume"] == [0, 100] - assert "QualitySetting" not in t["optionRanges"] # not an item the player offers - o = livenotes.settings(m, resolve(parse_specs(["NoteDesignId=1,3", "LiveQuality=2"]), m)) - assert o["skins"] == {"1": "skin001", "3": "skin003"} and o["effects"] == {"1": "fxA"} - assert o["skin"] == "skin001" and o["effect"] == "fxA" - o = livenotes.settings(m, resolve(parse_specs(["NoteEffectId"]), m)) - assert o["effects"] == {"1": "fxA", "2": "fxASimple"} and "skins" not in o # ---------------------------------------------------------------- bar line view -class _Obj: - """A serialized object: type, path id, typetree, script class.""" - - def __init__(self, t, pid, tt, cls=None): - self.type = type("T", (), {"name": t})() - self.path_id, self._tt, self.cls = pid, tt, cls - - def read_typetree(self): - return self._tt - - -class _Graph: - def __init__(self, paths, fathers): - self.paths, self.tf = paths, {tf: {"m_Father": {"m_PathID": f}} for tf, f in fathers.items()} - self.tf_of_go = {go: tf for tf, (go, _) in paths.items()} - self.go = {} - - def gos_at_path(self, path): - return [go for tf, (go, p) in self.paths.items() if p == path] - - -class _Ex: - """The Exporter calls bar_line_prefab makes, over a scene with LiveAllNoteView -> LiveAllBarLineView -> - LiveBarLineViewContainer -> the LiveBarLineView prefab (GameObject 50, transform 51, a root).""" - - def __init__(self, element_cls="LiveBarLineView", prefab_father=0): - def p(pid): - return {"m_FileID": 0, "m_PathID": pid} - - objs = [ - _Obj("GameObject", 10, {}), - _Obj("MonoBehaviour", 11, {"m_GameObject": p(10), "_barLineView": p(21)}, "LiveAllNoteView"), - _Obj("MonoBehaviour", 21, {"m_GameObject": p(20), "_container": p(31)}, "LiveAllBarLineView"), - _Obj("MonoBehaviour", 31, {"m_GameObject": p(30), "_elementPrefab": p(52)}, "LiveBarLineViewContainer"), - _Obj("MonoBehaviour", 52, {"m_GameObject": p(50), "_renderer": p(62)}, element_cls), - ] - self.objs = {o.path_id: o for o in objs} - self.env = type("Env", (), {"objects": objs})() - self.graph = _Graph({12: (10, livenotes.NOTE_VIEW[0]), 51: (50, "LiveBarLineView")}, {12: 5, 51: prefab_father}) - self.graph.go = {10: {"m_Component": [{"component": p(11)}]}} - self.loaded, self.walked = [], [] - - def load(self, key): - self.loaded.append(key) - return self.env, self.graph - - def deref(self, owner, pptr): - return self.objs.get(pptr["m_PathID"]) - - def script_class(self, o): - return o.cls - - def hierarchy(self, env, graph, root_tf): - self.walked.append(root_tf) - return [{"path": "LiveBarLineView", "components": [{"type": "MonoBehaviour", "class": "LiveBarLineView"}]}] - - -def test_bar_line_prefab_follows_the_scene_references(): - ex = _Ex() - rec = livenotes.bar_line_prefab(ex) - assert ex.loaded == [livenotes.SCENE_KEY] and ex.walked == [51] - assert rec["key"] == "EmbScene/Live" and rec["reference"] == "_barLineView._container._elementPrefab" - assert rec["nodes"][0]["path"] == "LiveBarLineView" - with pytest.raises(RuntimeError, match="LiveBarLineView MonoBehaviour"): - livenotes.bar_line_prefab(_Ex(element_cls="Other")) - with pytest.raises(RuntimeError, match="not a prefab root"): - livenotes.bar_line_prefab(_Ex(prefab_father=12)) # ---------------------------------------------------------------- note sounds @@ -403,22 +298,3 @@ def test_mirrored_conversion(): assert y["laneIndexFloat"] == 24 - x["laneIndexFloat"] - x["widthFloat"] assert [n["direction"] for n in b["notes"]][:2] == ["Normal", "Right"] # ApplyMirrorDirection assert b["notes"][4]["laneIndex"] == 16 # 16.5 rounds to even - - -def test_live_index_doc(): - s = {"chart": "score/c.json", "notes": "score/c.notes.json", "master": "score/master.json"} - a = live.index_doc(1, "expert", s, {"cueSheet": "x"}, "audio/live-audio.json") - assert list(a) == [ - "musicId", - "difficulty", - "chart", - "notes", - "master", - "audio", - "liveAudio", - "scene", - "noteAssets", - "liveUi", - ] - b = live.index_doc(1, "expert", {**s, "notesMirror": "score/c.mirror.notes.json"}, {}, "audio/live-audio.json") - assert list(b)[3:5] == ["notes", "notesMirror"] and b["notesMirror"] == "score/c.mirror.notes.json" diff --git a/tests/content/test_story_fonts.py b/tests/content/test_story_fonts.py index 010b5ae..2e6fb9f 100644 --- a/tests/content/test_story_fonts.py +++ b/tests/content/test_story_fonts.py @@ -12,7 +12,7 @@ pytest.importorskip("scipy") pytest.importorskip("fontTools") -from nnnotes.content import advui, storyfonts +from nnnotes.content import storyfonts from nnnotes.core import cache from nnnotes.unity import fontsettings, tmpfont # noqa: E402 from tests.helpers.fonts import UPEM, make_font @@ -223,232 +223,6 @@ def test_open_asset_checks_the_gradient_scale(font): storyfonts.open_asset(font, "X SDF", [0x41], g, {}) -def test_language_doc(): - lang = {"languages": {0: {"fontNames": ["GameA", "GameNum"], "additionalFontNames": []}}, "materialTypes": []} - fonts = { - "Open SDF": { - "faceInfo": { - "m_PointSize": 40.0, - "m_Scale": 1.0, - "m_LineHeight": 40.0, - "m_AscentLine": 35.2, - "m_DescentLine": -4.8, - }, - "normalSpacingOffset": 0.0, - "boldSpacing": 7.0, - } - } - texts = { - "a/b": { - "fontAsset": "GameA SDF", - "localized": {"fontAsset": "Open SDF", "material": "m", "lineSpacing": -100.0}, - } - } - doc = storyfonts.language_doc("en", "open", fonts, texts, lang) - assert doc == { - "format": storyfonts.LANGUAGE_FORMAT, - "language": "en", - "mode": 1, - "field": "english", - "lineSpacing": -100.0, - "fonts": "open", - "roles": { - "primary": { - "fontAsset": "Open SDF", - "lineHeightEm": 1.0, - "ascentEm": 0.88, - "descentEm": -0.12, - "spacingOffset": 0.0, - "boldSpacing": 7.0, - } - }, - } - - -def test_line_breaking(): - class Obj: - def __init__(self, text): - self.text = text - - def read_typetree(self): - return {"m_Script": self.text} - - class Player: - def resource(self, path): - assert path == "TMP Settings" - return "settings" - - def mono(self, o): - return { - "m_leadingCharacters": {"m_PathID": 1}, - "m_followingCharacters": {"m_PathID": 2}, - "m_UseModernHangulLineBreakingRules": 0, - } - - def deref(self, owner, pptr): - return Obj({1: "([", 2: ")]"}[pptr["m_PathID"]]) - - assert storyfonts.line_breaking(Player()) == { - "leading": "([", - "following": ")]", - "useModernHangulLineBreakingRules": False, - } - - -def test_shown_characters(): - episode = { - "text": {"a": {"english": "Hi x", "japanese": "ね"}, "b": {"english": None}}, - "title": {"english": "T1"}, - "commands": [], - } - ui = {"nodes": [{"path": "n", "textStyle": {"textKey": None, "text": "placeholder"}}]} - got = storyfonts.shown_characters(episode, ui, None, "english") - assert got == sorted({ord(c) for c in "Hi xT1"}) - # chat windows: their status texts and the texts they format at run time - chat = {**episode, "commands": [{"cmd": "ChatWindow", "Parameter1": "57", "Parameter4": "4"}]} - got = storyfonts.shown_characters(chat, {**ui, "chatStatusTexts": {"k": "Call"}}, None, "english") - assert got == sorted({ord(c) for c in "Hi xT1Call...57%(4)"}) - ui["nodes"][0]["textStyle"]["textKey"] = "k" - with pytest.raises(RuntimeError, match="master data"): - storyfonts.shown_characters(episode, ui, None, "english") - - -def test_game_fonts_moves_the_records(tmp_path, monkeypatch): - game_ui, ui = tmp_path / "game" / "ui", tmp_path / "open" / "ui" - (game_ui / "textures").mkdir(parents=True) - ui.mkdir(parents=True) - (game_ui / "textures" / "font_G_SDF.png").write_bytes(b"png") - text = { - "m_fontSize": 30, - "m_text": "", - "class": "TextMeshProUGUI", - "enabled": 1, - "fontAsset": "G SDF", - "material": "G - Default", - "localized": {"fontAsset": "G SDF", "material": "G - Default", "lineSpacing": 0.0}, - } - font = { - "faceInfo": { - "m_PointSize": 40.0, - "m_Scale": 1.0, - "m_LineHeight": 40.0, - "m_AscentLine": 30.0, - "m_DescentLine": -10.0, - }, - "normalSpacingOffset": 0.0, - "boldSpacing": 7.0, - "material": "G SDF Material", - "fallbacks": ["Other SDF"], - "glyphs": {"5": {"packed": {"texture": "font_G SDF", "dx": 1, "dy": 2}}, "3": {}}, - } - doc = { - "nodes": [{"path": "t", "text": text}, {"path": "img"}], - "fonts": {"G SDF": font}, - "textures": { - "font_G SDF": { - "texture": "textures/font_G_SDF.png", - "name": "font_G SDF", - "width": 8, - "height": 8, - "mipCount": 1, - "settings": {}, - }, - "sprites": {"texture": "textures/sprites.png"}, - }, - "materials": { - "G - Default": {"material": "G - Default"}, - "G SDF Material": {"material": "G SDF Material"}, - "UI-Transition": {}, - }, - "glyphCoverage": {"characters": 2, "missing": ["?"]}, - "tmpSettings": {"m_warningsDisabled": 0}, - } - (game_ui / "ui.json").write_text(json.dumps(doc), encoding="utf-8") - (ui / "ui.json").write_text( - json.dumps({"nodes": [{"path": "t", "textStyle": {}}, {"path": "img"}]}), encoding="utf-8" - ) - - class Ex: - def key_object(self, key): - assert key == "EmbFont/G/G - Default" - return key - - def material(self, o): - return {} - - ruby = { - "class": "RubyTextMeshProUGUI", - "_rubyVerticalOffset": "1em", - "_rubyScale": 0.5, - "_rubyLineHeight": "", - "_rubyShowType": 0, - "_rubyMargin": 10.0, - "m_tintAllSprites": 0, - } - sprite = { - "asset_name": "Emoji", - "asset": {"characters": [], "glyphs": {}, "material": "Emoji Material"}, - "textures": {"sprite_Emoji": {"texture": "fonts/sprite_Emoji.png"}}, - "material": {"material": "Emoji Material"}, - } - monkeypatch.setattr(storyfonts, "_exporter", lambda cat, player, work: Ex()) - monkeypatch.setattr( - storyfonts, - "text_components", - lambda ex, ui_doc: {"t": [ruby, {"class": "UIRubyText", "_rubyMarginTop": -22.0}]}, - ) - monkeypatch.setattr(storyfonts, "story_ui_text_targets", lambda ex, ui_doc: {"t"}) - monkeypatch.setattr(storyfonts.advui, "sprite_asset_record", lambda ex: "rec") - monkeypatch.setattr(storyfonts, "shown_texts", lambda episode, ui_doc, master_dir, field: ["?\U0001f600"]) - monkeypatch.setattr(storyfonts, "drawn_by_sprites", lambda ex, rec, bindings, chars: {"G SDF": {ord("?")}}) - monkeypatch.setattr(storyfonts, "sprite_needs", lambda rec, texts, drawn: [0] if drawn == {ord("?")} else []) - monkeypatch.setattr(storyfonts, "game_sprite_asset", lambda ex, ui_dir, rec: sprite) - monkeypatch.setattr(storyfonts, "text_shaders", lambda ex, ui_dir, mats: {n: [] for n in mats}) - monkeypatch.setattr( - storyfonts, - "fallback_chains", - lambda ex, primaries, exported: ( - {n: [] for n in exported} if primaries == {"G SDF"} else pytest.fail(f"primaries {primaries}") - ), - ) - monkeypatch.setattr( - storyfonts, - "line_breaking", - lambda player: {"leading": "", "following": "", "useModernHangulLineBreakingRules": False}, - ) - monkeypatch.setattr( - storyfonts.textstyle, - "language_fonts", - lambda player: {"languages": {0: {"fontNames": ["G"]}}, "materialTypes": ["Default"]}, - ) - r = storyfonts.game_fonts(None, None, game_ui, ui, "ja", {"text": {}}) - out = json.loads((ui / "fonts.json").read_text(encoding="utf-8")) - assert r["pages"] == 2 and (ui / "fonts" / "font_G_SDF.png").read_bytes() == b"png" - assert out["source"] == "game" and out["fonts"] == {"G SDF": {**font, "fallbacks": []}} - assert out["texts"] == { - "t": { - **text, - "ruby": {k: ruby[k] for k in storyfonts.RUBY_FIELDS}, - "uiRubyText": {"_rubyMarginTop": -22.0}, - "spriteAsset": "Emoji", - "m_tintAllSprites": 0, - } - } - assert out["textures"] == { - "font_G SDF": {**doc["textures"]["font_G SDF"], "texture": "fonts/font_G_SDF.png"}, - **sprite["textures"], - } - assert set(out["materials"]) == {"G - Default", "G SDF Material", "Emoji Material"} - assert out["tmpSettings"] == doc["tmpSettings"] - assert out["spriteAssets"] == {"Emoji": sprite["asset"]} and out["emojiSpriteAsset"] == "Emoji" - assert out["coverage"] == {"characters": 2, "missing": [], "sprites": {"characters": 0, "missing": []}} - storyfonts.game_fonts(None, None, game_ui, ui, "ja") # without the episode: no sprite asset - out = json.loads((ui / "fonts.json").read_text(encoding="utf-8")) - assert "spriteAssets" not in out and "spriteAsset" not in out["texts"]["t"] and out["coverage"]["missing"] == ["?"] - lang = json.loads((ui / "languages.json").read_text(encoding="utf-8")) - assert lang["roles"]["primary"]["fontAsset"] == "G SDF" and lang["mode"] == 0 - - def test_text_extras(): comps = [ { @@ -481,71 +255,6 @@ def test_text_extras(): storyfonts.text_extras("p", [{"class": "RubyTextMeshProUGUI", "_rubyScale": 0.5}]) -def test_missing_glyph(): - """The game's missing glyph: the code points no font of the chain has, the substitute U+25A1, else space, else - U+0003; the TMP settings it needs; glyph variants of a variation selector raise.""" - - class F: - def __init__(self, name, variants=()): - self.name, self.variants = name, dict.fromkeys(variants, "v") - - class Fonts: - def __init__(self, have): - self.have = have - - def lookup(self, font, u): - return ("baked", self.have[u]) if u in self.have else None - - p, fb = F("P"), F("FB", [(0x2661, 0xFE0F)]) - fonts = Fonts({0x41: p, 0x25A1: fb, 0x20: p, 0x2661: fb, 0x03: p}) - assert tmpfont.missing_glyph(fonts, p, [0x41, 0x20]) is None - assert tmpfont.missing_glyph(fonts, p, [0x41, 0xFE0F, 0x26A1]) == { - "unicode": 0x25A1, - "characters": [0x26A1, 0xFE0F], - } - no_box = Fonts({0x41: p, 0x20: p, 0x03: p}) - assert tmpfont.missing_glyph(no_box, p, [0x42])["unicode"] == 0x20 - assert tmpfont.missing_glyph(Fonts({0x03: p}), p, [0x42])["unicode"] == 0x03 - assert tmpfont.missing_glyph(fonts, p, [0x42], 0x3F)["unicode"] == 0x20 # a set missingGlyphCharacter - assert [ - u for u in (0xFDFF, 0xFE00, 0xFE0F, 0xFE10, 0xE0100, 0xE01EF, 0xE01F0) if fontsettings.is_variation_selector(u) - ] == [0xFE00, 0xFE0F, 0xE0100, 0xE01EF] - tmpfont.check_glyph_variants(fonts, p, ["A\ufe0f", "\u2661", "\u26a1\ufe0f"]) - with pytest.raises(NotImplementedError, match="glyph variant of U\\+2661 U\\+FE0F"): - tmpfont.check_glyph_variants(fonts, p, ["x\u2661\ufe0f"]) - - class Own: - def __init__(self, r): - self.r = r - - def _in_font(self, font, u): - assert u == 0x5F - return self.r - - assert [tmpfont.underline_character(Own(r), p) for r in (("baked", p), ("runtime", p), None)] == [True, True, False] - - class Player: - def __init__(self, **tt): - self.tt = { - "m_fallbackFontAssets": [], - "m_defaultFontAsset": {"m_FileID": 0, "m_PathID": 0}, - "m_missingGlyphCharacter": 0, - **tt, - } - - def resource(self, path): - assert path == "TMP Settings" - return "settings" - - def mono(self, o): - return self.tt - - assert tmpfont.missing_glyph_character(Player()) == 0x25A1 - assert tmpfont.missing_glyph_character(Player(m_missingGlyphCharacter=0x3F)) == 0x3F - with pytest.raises(NotImplementedError, match="fallback font assets"): - tmpfont.missing_glyph_character(Player(m_fallbackFontAssets=[{"m_PathID": 5}])) - - def test_search_order(): """TMP's depth-first fallback search, every asset once, reduced to the exported assets.""" chain = {"P": ["A", "B"], "A": ["P", "C"], "B": ["C", "D"], "C": [], "D": ["A"]} @@ -558,234 +267,6 @@ def test_search_order(): assert storyfonts.search_order(fallbacks, "C", {"P"}) == [] -def test_chat_bindings(monkeypatch): - """A binding per chat window text: the serialized fields, the extras, `localized` from advui.chat_localize for - a localized text and the serialized font, material and line spacing for the others.""" - name = { - "class": "TextMeshProUGUI", - "m_Enabled": 1, - "m_fontAsset": {"name": "CHAT SDF"}, - "m_sharedMaterial": {"material": "CHAT - Default"}, - "m_fontSize": 30.0, - "m_text": "", - "m_lineSpacing": 2.0, - "m_monospaceDistEm": 0.0, - } - pct = { - **name, - "class": "RubyEmojiTextMeshProUGUI", - "m_text": "100%", - "_rubyVerticalOffset": "1em", - "_rubyScale": 0.5, - "_rubyLineHeight": "", - "_rubyShowType": 0, - "_rubyMargin": 0.0, - } - loc = {"class": "LocalizeText", "m_Enabled": 1, "_localizeEnabled": 1} - nodes = [ - ("Line", {"path": "Line/Name", "components": [name, loc]}, name, loc), - ("Line", {"path": "Line/Pct", "components": [pct]}, pct, None), - ] - monkeypatch.setattr(storyfonts.advui, "chat_text_nodes", lambda ex, episode: (nodes, [])) - monkeypatch.setattr(storyfonts.advui, "chat_windows", lambda episode: {}) - lang = { - "languages": { - 0: {"fontNames": ["J"], "additionalFontNames": ["CHAT"]}, - 1: {"fontNames": ["E"], "additionalFontNames": ["EC"]}, - }, - "materialTypes": ["Default"], - } - got = storyfonts.chat_bindings(None, {}, lang, 1) - assert set(got) == {"Line"} and set(got["Line"]) == {"Line/Name", "Line/Pct"} - n, p = got["Line"]["Line/Name"], got["Line"]["Line/Pct"] - assert n["localized"] == {"fontAsset": "EC SDF", "material": "EC - Default", "lineSpacing": -100.0} - assert (n["class"], n["fontAsset"], n["m_monospaceDistEm"]) == ("TextMeshProUGUI", "CHAT SDF", 0.0) - assert p["localized"] == {"fontAsset": "CHAT SDF", "material": "CHAT - Default", "lineSpacing": 2.0} - assert p["ruby"]["_rubyScale"] == 0.5 and "m_lineSpacing" in p - assert "m_monospaceDistEm" in advui.TMP_FIELDS - - -def test_frame_bindings_and_texts(): - """A binding per TMP text of each frame (as chat_bindings, a UIText's text with the sprite asset); the serialized - frame texts and the texts a frame with an IAdvFrameTextReceiver gets at run time from its Frame rows' - TargetTextIDs (NFC, then "@" and a line feed).""" - body = { - "class": "TextMeshProUGUI", - "m_Enabled": 1, - "m_fontAsset": {"name": "J SDF"}, - "m_sharedMaterial": {"material": "J - Default"}, - "m_fontSize": 52.0, - "m_text": "", - "m_lineSpacing": 0.0, - "m_tintAllSprites": 0, - } - loc = {"class": "LocalizeText", "m_Enabled": 1, "_localizeEnabled": 1} - ui = {"class": "UIText", "_targetText": {"gameObject": "Card/Body"}} - frames = { - "slander": [ - {"path": "Card", "components": [{"class": "AdvSlanderCommentFrame"}]}, - {"path": "Card/Body", "components": [body, loc, ui]}, - ], - "plain": [ - {"path": "Img", "components": [{"class": "Image"}]}, - {"path": "Label", "components": [{**body, "m_text": "Fixed"}]}, - ], - } - lang = { - "languages": { - 0: {"fontNames": ["J"], "additionalFontNames": []}, - 1: {"fontNames": ["E"], "additionalFontNames": []}, - }, - "materialTypes": ["Default"], - } - got = storyfonts.frame_bindings(frames, lang, 1, "Emoji") - assert set(got) == {"slander", "plain"} and set(got["slander"]) == {"Card/Body"} and set(got["plain"]) == {"Label"} - b = got["slander"]["Card/Body"] - assert b["localized"] == {"fontAsset": "E SDF", "material": "E - Default", "lineSpacing": -100.0} - assert (b["spriteAsset"], b["m_tintAllSprites"], b["m_fontSize"]) == ("Emoji", 0, 52.0) - episode = { - "text": {"t1": {"english": "Ame\u0301lie"}, "t2": {"english": "id"}, "t3": {"english": 5}}, - "commands": [ - {"cmd": "Frame", "TargetAssetName": "slander", "TargetTextIDs": ["t1", "t2", "t3", "nope"]}, - {"cmd": "Frame", "TargetAssetName": "plain", "TargetTextIDs": ["t2"]}, - {"cmd": "Frame", "TargetAssetName": "slander", "TargetTextIDs": ["t2"], "IgnoreData": 1}, - {"cmd": "Talk", "TargetTextIDs": ["t2"]}, - ], - } - assert storyfonts.frame_texts(frames, episode, "english") == ["Fixed", "Am\u00e9lie", "id", "@\n"] - assert storyfonts.frame_texts({"plain": frames["plain"]}, episode, "english") == ["Fixed"] - - -def test_dialog_bindings(): - """A binding per text node of each dialog of ui.json, from the components of the dialog's prefab; `localized` by - LocalizeText's font swap in the language.""" - text = { - "class": "TextMeshProUGUI", - "m_Enabled": 1, - "m_fontAsset": {"name": "J SDF"}, - "m_sharedMaterial": {"material": "J - Default"}, - "m_fontSize": 30.0, - "m_text": "", - "m_lineSpacing": 0.0, - } - - class Ex: - def prefab(self, key): - assert key == "EmbUI/Prefab/D" - return { - "nodes": [ - {"path": "D/Title", "components": [text, {"class": "LocalizeText", "m_Enabled": 1}]}, - { - "path": "D/Body", - "components": [ - {**text, "m_tintAllSprites": 0}, - {"class": "UIText", "_targetText": {"gameObject": "D/Body"}}, - ], - }, - {"path": "D/Image", "components": [{"class": "Image"}]}, - ] - } - - ui = { - "dialogs": { - "D": { - "key": "EmbUI/Prefab/D", - "nodes": [ - {"path": "D/Title", "textStyle": {"textKey": "ui_ok"}}, - {"path": "D/Body", "textStyle": {}}, - {"path": "D/Image"}, - ], - } - } - } - lang = {"languages": {0: {"fontNames": ["J"]}, 1: {"fontNames": ["E"]}}, "materialTypes": ["Default"]} - got = storyfonts.dialog_bindings(Ex(), ui, lang, 1, "Emoji") - assert set(got) == {"D"} and set(got["D"]) == {"D/Title", "D/Body"} - assert "spriteAsset" not in got["D"]["D/Title"] - assert (got["D"]["D/Body"]["spriteAsset"], got["D"]["D/Body"]["m_tintAllSprites"]) == ("Emoji", 0) - t = got["D"]["D/Title"] - assert (t["class"], t["fontAsset"], t["material"]) == ("TextMeshProUGUI", "J SDF", "J - Default") - assert t["m_fontSize"] == 30.0 - assert t["localized"]["fontAsset"] == "E SDF" and t["localized"]["material"] == "E - Default" - assert storyfonts.dialog_bindings(Ex(), {"nodes": []}, lang, 1) == {} - # the dialogs' text keys are shown characters - episode = {"text": {}, "title": {"english": ""}, "commands": []} - with pytest.raises(RuntimeError, match="master data"): - storyfonts.shown_characters(episode, {"nodes": [], **ui}, None, "english") - - -def test_text_components_of_every_talk_window(): - """The components of each text node of ui.json from the widget prefab or from the prefab of its talk window (the - TalkView children of ui.json, advui.window_nodes); a text node found in neither raises.""" - text = {"class": "TextMeshProUGUI", "m_Enabled": 1} - view = advui.WINDOW_PARENT - - class Cat: - def has(self, address): - return False - - class Ex: - cat = Cat() - - def prefab(self, key): - if key == advui.WIDGET_KEY: - return {"nodes": [{"path": "UIAdvWidget/FrontCanvas/Title", "components": [text, {"id": "title"}]}]} - name = key.rsplit("/", 1)[1] - return { - "nodes": [ - {"path": name, "components": []}, - {"path": f"{name}/TalkText", "components": [text, {"id": name}]}, - ] - } - - nodes = [ - {"path": "UIAdvWidget/FrontCanvas/Title", "name": "Title", "textStyle": {}}, - {"path": view, "name": "TalkView"}, - {"path": f"{view}/UIDefaultTalkWindow", "name": "UIDefaultTalkWindow"}, - {"path": f"{view}/UIDefaultTalkWindow/TalkText", "name": "TalkText", "textStyle": {}}, - {"path": f"{view}/UICenterTalkWindow", "name": "UICenterTalkWindow"}, - {"path": f"{view}/UICenterTalkWindow/TalkText", "name": "TalkText", "textStyle": {}}, - ] - got = storyfonts.text_components(Ex(), {"nodes": nodes}) - assert {p: c[1]["id"] for p, c in got.items()} == { - "UIAdvWidget/FrontCanvas/Title": "title", - f"{view}/UIDefaultTalkWindow/TalkText": "UIDefaultTalkWindow", - f"{view}/UICenterTalkWindow/TalkText": "UICenterTalkWindow", - } - lone = [n for n in nodes if "UICenterTalkWindow" not in n["path"]] + [ - {"path": f"{view}/UICenterTalkWindow/TalkText", "name": "TalkText", "textStyle": {}} - ] - with pytest.raises(RuntimeError, match="0 prefab nodes"): - storyfonts.text_components(Ex(), {"nodes": lone}) - - -def test_ui_text_targets_and_sprite_binding(): - """A text a UIText (or UIRubyText) drives, on its node or another, gets the emoji sprite asset; any other text - keeps its serialized sprite asset (only none is).""" - text = {"class": "TextMeshProUGUI", "m_tintAllSprites": 0, "m_spriteAsset": None} - nodes = [ - {"path": "W/A", "components": [text, {"class": "UIText", "_targetText": {"gameObject": "W/A"}}]}, - {"path": "W/B", "components": [text]}, - ] - assert storyfonts.ui_text_targets(nodes) == {"W/A"} - rerooted = [{"path": "P/W/A", "components": [text, {"class": "UIRubyText", "_targetText": {"gameObject": "W/A"}}]}] - assert storyfonts.ui_text_targets(rerooted, "P/") == {"P/W/A"} - other = [ - { - "path": "W", - "components": [ - {"class": "UIText", "_targetText": {"gameObject": "W/B"}}, - {"class": "UIText", "_targetText": None}, - ], - } - ] - assert storyfonts.ui_text_targets(other) == {"W/B"}, "the text of another node; none without a target" - assert storyfonts.sprite_binding("W/A", [text], True, "Emoji") == {"spriteAsset": "Emoji", "m_tintAllSprites": 0} - assert storyfonts.sprite_binding("W/B", [text], False, "Emoji") == {} - with pytest.raises(NotImplementedError, match="own sprite asset"): - storyfonts.sprite_binding("W/B", [{**text, "m_spriteAsset": {"name": "Own"}}], False, "Emoji") - - def sprite_record(): """A sprite asset record as advui.sprite_asset_record gives it: 32 x 32 glyphs in a 128 x 128 sheet.""" names = [ @@ -948,33 +429,6 @@ def __init__(self, name, have, fallbacks=(), pad=4, point=40): self.name, self.chars, self.fb, self.pad, self.point = name, set(have), list(fallbacks), pad, point -def chain_env(monkeypatch, fonts, lang): - """open_font_set's game side: the font settings of the ChainFont list `fonts` (their default materials and a - text material " - OutlineX" each) and the LocalizeManager table `lang`.""" - table = {"format": fontsettings.FORMAT, "fonts": {}, "materials": {}} - for f in fonts: - base = f.name[:-4] - default = json.loads(json.dumps(game(pad=f.pad)["material"])) - default["material"] = f"{base} SDF Material" - default["floats"].update({"_WeightNormal": 0.0, "_WeightBold": 0.5 + f.pad / 100, "_FaceDilate": 0.0}) - text = outline_material(pad=f.pad) - text["material"] = f"{base} - OutlineX" - text["floats"].update({"_WeightNormal": 0.0, "_WeightBold": 0.75, "_FaceDilate": 0.0}) - table["materials"].update({default["material"]: default, text["material"]: text}) - table["fonts"][f.name] = { - "faceInfo": {"m_PointSize": float(f.point)}, - "atlasPadding": f.pad, - "atlasRenderMode": 4134, - **game()["style"], - "material": default["material"], - "fallbacks": f.fb, - "coverage": fontsettings.encode_coverage(f.chars), - } - monkeypatch.setattr(tmpfont, "missing_glyph_character", lambda player: 0x25A1) - monkeypatch.setattr(storyfonts.textstyle, "language_fonts", lambda player: lang) - return fontsettings.FontSettings(table) - - # LocalizeManager fonts: ja and en J, zh Z, ko K CHAIN_LANG = { "languages": { @@ -1027,75 +481,3 @@ def test_fallback_material(): rng = f32(f32(f32(0.9) / f32(4)) * f32(4)) assert f["_ScaleRatioC"] == float(f32(f32(f32(4) - rng) / f32(5))) assert storyfonts.fallback_material({**m, "keywords": ["RATIOS_OFF"]}, t, "x")["floats"]["_ScaleRatioA"] == 1.0 - - -def test_open_font_set_mirrors_the_fallback_chain(tmp_path, monkeypatch): - """ko texts of K (fallback J, whose fallbacks are a game-only asset X and K): a character the ko font file - lacks goes to the asset of the ja font file standing for J; the missing glyph; the fallback materials; the - bindings.""" - ko = storyfonts.FontFile( - make_font(tmp_path / "Ko.ttf", family="Ko Sans", underscore=True, drop={0x53E3}, extra={0x25A1: "A"}) - ) - ja = storyfonts.FontFile(make_font(tmp_path / "Ja.ttf", family="Ja Sans", underscore=True)) - fonts = [ - ChainFont("K SDF", {0x41, 0x42, 0x20, 0x5F, 0x25A1, 0x78}, ["J SDF"], pad=15, point=100), - ChainFont("J SDF", {0x53E3, 0x48, 0x5F}, ["X SDF", "K SDF"]), - ChainFont("X SDF", {0x2606}), - ] - settings = chain_env(monkeypatch, fonts, CHAIN_LANG) - binding = {"localized": {"fontAsset": "K SDF", "material": "K - OutlineX", "lineSpacing": 0}} - asked = [] - - def font_of(code): - asked.append(code) - return {"ja": ja}[code] - - r = storyfonts.open_font_set( - None, "player", None, "ko", ko, [binding], ["AB \u53e3Hx\u2661_"], tmp_path, font_of, settings=settings - ) - prim, fb = "Ko Sans Regular SDF", "Ja Sans Regular SDF (J SDF)" - assert asked == ["ja"] and set(r["fonts"]) == {prim, fb} - p, f = r["fonts"][prim], r["fonts"][fb] - assert p["fallbacks"] == [fb] and f["fallbacks"] == [prim] - assert {ord(c) for c in "AB Hx_\u25a1"} <= {int(u) for u in p["characters"]} - assert str(0x53E3) not in p["characters"] and str(0x2661) not in p["characters"] - assert str(0x53E3) in f["characters"] and str(0x41) not in f["characters"] and str(0x5F) not in f["characters"] - assert p["missingGlyph"] == {"unicode": 0x25A1, "characters": [0x2661]} and "missingGlyph" not in f - assert r["missing"] == set() and r["substituted"] == {0x2661} - assert p["atlasPadding"] == 15 and f["atlasPadding"] == 4 and f["faceInfo"]["m_PointSize"] == 40 - text, fbm = "Ko Sans Regular - OutlineX", f"Ko Sans Regular - OutlineX + {fb}" - assert set(r["materials"]) == {f"{prim} Material", text, f"{fb} Material", fbm} - m, own = r["materials"][fbm], r["materials"][text] - assert m["floats"]["_GradientScale"] == 5.0 and own["floats"]["_GradientScale"] == 16.0 - assert m["textures"]["_MainTex"] == r["materials"][f"{fb} Material"]["textures"]["_MainTex"] - assert m["keywords"] == own["keywords"] and m["floats"]["_OutlineWidth"] == own["floats"]["_OutlineWidth"] - assert binding["localized"] == {"fontAsset": prim, "material": text, "lineSpacing": 0} - assert all((tmp_path / t["texture"]).is_file() for t in r["textures"].values()) - # nothing the ko font lacks: no fallback asset, no fallback material, no other font file asked for - asked.clear() - b2 = {"localized": {"fontAsset": "K SDF", "material": "K - OutlineX", "lineSpacing": 0}} - r2 = storyfonts.open_font_set( - None, "player", None, "ko", ko, [b2], ["ABx"], tmp_path / "b", font_of, settings=settings - ) - assert asked == [] and set(r2["fonts"]) == {prim} and r2["fonts"][prim]["fallbacks"] == [] - assert set(r2["materials"]) == {f"{prim} Material", text} and "missingGlyph" not in r2["fonts"][prim] - # a character only the ja file has, without it: a ConfigError naming the setting - b3 = {"localized": {"fontAsset": "K SDF", "material": "K - OutlineX", "lineSpacing": 0}} - with pytest.raises(storyfonts.ConfigError, match="needs the font file of ja"): - storyfonts.open_font_set(None, "player", None, "ko", ko, [b3], ["A\u53e3"], tmp_path / "c", settings=settings) - # a character no file of the chain maps stays with the primary as missing; a sprite-drawn one is left out - b4 = {"localized": {"fontAsset": "K SDF", "material": "K - OutlineX", "lineSpacing": 0}} - r4 = storyfonts.open_font_set( - None, - "player", - None, - "ko", - ko, - [b4], - ["A\u2606B"], - tmp_path / "d", - font_of, - sprite_drawn={"K SDF": {0x42}}, - settings=settings, - ) - assert r4["missing"] == {0x2606} and str(0x42) not in r4["fonts"][prim]["characters"] diff --git a/tests/content/test_story_host.py b/tests/content/test_story_host.py deleted file mode 100644 index 57e9462..0000000 --- a/tests/content/test_story_host.py +++ /dev/null @@ -1,798 +0,0 @@ -"""The host data of Overlay stories (storyhost.py) on synthetic prefab nodes, hierarchies and master rows: the host -of a story group, the slot capture camera and layout root records, SpotBackground.Prepare, the floor render queue -rule, the room node, material and texture order, the build's entry and return value. Nothing here comes from game -data.""" - -import json -from types import SimpleNamespace - -import numpy as np -import pytest - -from nnnotes.content import advui, hostspot, storyhost -from nnnotes.unity.reader import SceneGraph -from tests.content.test_hostspot import ROOM_KEY, File, RoomCatalog, load_room, two_file_room - -V0 = {"x": 0.0, "y": 0.0, "z": 0.0} -V1 = {"x": 1.0, "y": 1.0, "z": 1.0} -Q0 = {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0} - - -# ---------------------------------------------------------------- synthetic hierarchies -def obj(kind, pid, tt): - return SimpleNamespace(type=SimpleNamespace(name=kind), path_id=pid, read_typetree=lambda: tt) - - -def graph(tree, inactive=()): - """A SceneGraph of `tree` {name: [children as (name, subtree) pairs]} given as nested lists [(name, [...])]: - GameObject ids 1000 + n, transform ids n, in depth-first order; names in `inactive` are saved inactive.""" - objs, ids = [], {} - - def walk(name, kids, father): - tf = len(ids) + 1 - ids[name] = tf - objs.append( - obj( - "GameObject", - 1000 + tf, - {"m_Name": name.split("#")[0], "m_IsActive": name not in inactive, "m_Layer": 0, "m_Component": []}, - ) - ) - entry = { - "m_GameObject": {"m_FileID": 0, "m_PathID": 1000 + tf}, - "m_Father": {"m_FileID": 0, "m_PathID": father}, - "m_Children": [], - "m_LocalPosition": V0, - "m_LocalRotation": Q0, - "m_LocalScale": V1, - } - objs.append(obj("Transform", tf, entry)) - for child, sub in kids: - entry["m_Children"].append({"m_FileID": 0, "m_PathID": walk(child, sub, tf)}) - return tf - - for name, kids in tree: - walk(name, kids, 0) - return SceneGraph(SimpleNamespace(objects=objs)), ids - - -BACKGROUND = [ - ( - "bg", - [ - ( - "light", - [ - ("table", []), - ("chair", []), - ("wall_floor_shadow", [("floor_mesh", [])]), - ("obj_floor", [("mesh_a", []), ("mesh_b", [])]), - ], - ) - ], - ), - ("model", [("table#m", [])]), -] - - -def test_prepare_matches_the_situation_without_clone_suffix(): - g, ids = graph(BACKGROUND, inactive=("chair",)) - entries = [("other", [1000 + ids["table"]]), ("sit_01", [1000 + ids["table"], None])] - got = storyhost.prepare(g, ids["bg"], "sit_01(Clone)", entries) - assert got["name"] == "sit_01" and got["matched"] - assert got["activate"] == [ids["table"], ids["chair"], ids["wall_floor_shadow"], ids["obj_floor"]] - assert got["hide"] == [1000 + ids["table"], None] - ov = got["overrides"] - assert ov[1000 + ids["chair"]] is True and ov[1000 + ids["table"]] is False # hidden after activation - assert not storyhost.active_in_hierarchy(g, ids["table"], ov) - assert storyhost.active_in_hierarchy(g, ids["chair"], ov) - assert not storyhost.active_in_hierarchy(g, ids["chair"], {}) # saved state - - -def test_prepare_without_a_matching_entry_keeps_the_prefab_state(): - g, ids = graph(BACKGROUND) - got = storyhost.prepare(g, ids["bg"], "sit_09(Clone)", [("sit_01", [1000 + ids["table"]])]) - assert got == {"name": "sit_09", "matched": False, "activate": [], "hide": [], "overrides": {}} - g2, ids2 = graph([("bg", [])]) - with pytest.raises(RuntimeError, match="GetChild"): - storyhost.prepare(g2, ids2["bg"], "x", []) - - -def test_floor_rule_skips_shadow_names_and_inactive_renderers(): - g, ids = graph(BACKGROUND, inactive=("mesh_a",)) - floor = storyhost.find_first_child_with_name(g, ids["bg"], "floor", "shadow") - assert floor == ids["floor_mesh"] # below the excluded "wall_floor_shadow", depth first - g2, ids2 = graph( - [("bg", [("a_shadow_floor", []), ("obj_floor", [("mesh_a", []), ("mesh_b", [])])])], inactive=("mesh_a",) - ) - floor = storyhost.find_first_child_with_name(g2, ids2["bg"], "floor", "shadow") - assert floor == ids2["obj_floor"] - renderers = {ids2["mesh_a"], ids2["mesh_b"]} - hit = storyhost.component_in_children( - g2, floor, renderers.__contains__, lambda t: storyhost.active_in_hierarchy(g2, t, {}) - ) - assert hit == ids2["mesh_b"] # GetComponentInChildren skips the inactive mesh_a - assert storyhost.component_in_children(g2, floor, set().__contains__, lambda t: True) is None - assert storyhost.find_first_child_with_name(g2, ids2["mesh_b"], "floor", "shadow") is None - - -def test_under_tells_the_placed_prefab_from_other_roots(): - g, ids = graph(BACKGROUND) - assert storyhost.under(g, ids["mesh_a"], ids["bg"]) and storyhost.under(g, ids["bg"], ids["bg"]) - assert not storyhost.under(g, ids["table#m"], ids["bg"]) - - -# ---------------------------------------------------------------- room order -class Mesh: - def __init__(self, name, tris): - self.m_Name, self.tris = name, [np.asarray(t, np.int64).reshape(-1, 3) for t in tris] - - -def pp(pid, file="CAB-a"): - """A material PPtr: its path id and the object it names in `file`.""" - return SimpleNamespace( - path_id=pid, deref=lambda: SimpleNamespace(assets_file=SimpleNamespace(name=file), path_id=pid) - ) - - -def arrays(mesh): - return None if mesh.m_Name == "empty" else (None, None, None, mesh.tris) - - -def test_glb_json_reads_the_json_chunk(tmp_path): - import struct - - js = json.dumps({"nodes": [{"name": "a"}]}).encode("utf-8") - js += b" " * (-len(js) % 4) - (tmp_path / "r.glb").write_bytes( - b"glTF" + struct.pack("読み"), - "TargetName": "a", - "AdvTextID": "t", - "TargetTextIDs": ["n"], - }, - {"cmd": "Subtitles", "lines": lines("字幕")}, - {"cmd": "Subtitles"}, - {"cmd": "Bgm", "lines": lines("no")}, - ], - "text": {"n": lines("名")}, - "title": lines("題"), - } - got = advui.shown_texts(episode, {"_unknownCharacterNameTextId": "u", "_splitCharacterNameTextId": "s"}, "japanese") - assert got == ["読み", "よみ", "字幕", "名", "題"] - - -def test_master_id_texts(tmp_path): - settings = {k: f"id_{i}" for i, k in enumerate(advui.MASTER_ID_TEXTS)} - settings.update( - { - "_skipVideoMessageTextId": "ui_skip_movie", - "_skipButtonTextId": "ui_skip", - "_cancelButtonTextId": "ui_cancel", - "_unknownCharacterNameTextId": "x", - } - ) - rows = [{"_id": i, "_japanese": f"ja {i}", "_english": f"en {i}"} for i in [*settings.values(), "y"]] - (tmp_path / "MasterText.json").write_text(json.dumps({"_allData": rows}), encoding="utf-8") - got = advui.master_id_texts(settings, tmp_path, "english") - assert set(got) == set(advui.MASTER_ID_TEXTS) and "_unknownCharacterNameTextId" not in got - assert got["_skipVideoMessageTextId"] == {"id": "ui_skip_movie", "text": "en ui_skip_movie"} - assert got["_interruptionButtonTextId"]["text"].startswith("en id_") - with pytest.raises(KeyError, match="not in MasterText"): - advui.master_id_texts({**settings, "_skipButtonTextId": "gone"}, tmp_path, "english") - - def ref(path, component="MonoBehaviour", cls="UIButton"): return {"component": component, "gameObject": path, "class": cls} -def test_menu_and_chat_view_records(): - menu = { - "type": "MonoBehaviour", - "class": "AdvMenuView", - "m_Enabled": 1, - "_fadeDuration": 0.2, - **{k: ref(f"M/{k}") for k in advui.MENU_VIEW_REFS}, - "_menuButtonsBackground": {"transform": "M/Back"}, - "_fastForwardNormalSprite": {"spriteRef": "cab:5", "name": "fast_off"}, - "_fastForwardOnePointFiveSprite": {"spriteRef": "cab:6", "name": "fast15"}, - "_fastForwardOnePointSevenSprite": None, - "_fastForwardDoubleSprite": {"spriteRef": "cab:8", "name": "fast2"}, - } - node = advui._layout_components({"components": [menu]}) - rec = node["menuView"] - assert rec["_skipButton"] == "M/_skipButton" and rec["_menuButtonsBackground"] == "M/Back" - assert (rec["_fastForwardNormalSprite"], rec["_fastForwardOnePointSevenSprite"]) == ("fast_off", None) - assert rec["_fadeDuration"] == 0.2 and node["_spriteRefs"] == ["cab:5", "cab:6", "cab:8"] - assert set(rec) == {"enabled", "_fadeDuration", *advui.MENU_VIEW_REFS, *advui.MENU_VIEW_SPRITES} - chat = { - "type": "MonoBehaviour", - "class": "AdvChatView", - "m_Enabled": 1, - **{k: 0.25 for k in advui.CHAT_VIEW_FIELDS}, - "_windowParentRect": {"transform": "C/Canvas/View/Target"}, - } - rec = advui._layout_components({"components": [chat]})["chatView"] - assert rec == { - "enabled": 1, - **{k: 0.25 for k in advui.CHAT_VIEW_FIELDS}, - "_windowParentRect": "C/Canvas/View/Target", - } - - -def test_widget_canvases(): - widget = [ - {"path": "W", "components": []}, - {"path": "W/Block", "components": [canvas(order=300)]}, - {"path": "W/Front", "components": [canvas(mode=1, order=304)]}, - {"path": "W/Letter", "components": [canvas(mode=0, order=0)]}, - ] - comp = { - "_canvasSortOrder": 99999, - "_canvases": [{"component": "Canvas", "gameObject": p} for p in ("W/Block", "W/Front", "W/Letter")], - } - assert advui.widget_canvases(widget, comp) == { - "canvasSortOrder": 99999, - "canvases": [ - {"path": "W/Block", "sortingOrder": 300}, - {"path": "W/Front", "sortingOrder": 304}, - {"path": "W/Letter", "sortingOrder": 0}, - ], - } - with pytest.raises(RuntimeError, match="0 Canvas"): - advui.widget_canvases(widget, {**comp, "_canvases": [{"component": "Canvas", "gameObject": "W"}]}) - - def test_emoji_sequence_keys(): key = advui.emoji_sequence_key assert key("1f636-200d-1f32b-fe0f", 0x1F636) == "\U0001f636\u200d\U0001f32b\ufe0f" @@ -387,65 +91,6 @@ def read_typetree(self): return self.tree -def test_sprite_asset_record(): - """The emoji sprite asset as the layout reads it: face info, the character table in order, the glyphs, the - sequence entries of the legacy sprite list, the sheet and the material.""" - metrics = {"m_Width": 32.0, "m_Height": 32.0} - tree = { - "m_Name": "Emoji", - "fallbackSpriteAssets": [], - "m_FaceInfo": {"m_PointSize": 0.0}, - "spriteSheet": "sheet", - "m_Material": "mat", - "spriteInfoList": [ - {"name": "1f600", "unicode": 0x1F600}, - {"name": "2764-fe0f", "unicode": 0x2764}, - {"name": "", "unicode": 0}, - ], - "m_SpriteCharacterTable": [ - {"m_Unicode": 0x1F600, "m_Name": "1f600", "m_GlyphIndex": 0, "m_Scale": 1.0}, - {"m_Unicode": 0x2764, "m_Name": "2764-fe0f", "m_GlyphIndex": 1, "m_Scale": 1.0}, - ], - "m_GlyphTable": [ - {"m_Index": i, "m_Metrics": metrics, "m_GlyphRect": {"m_X": 32 * i}, "m_Scale": 1.0, "m_AtlasIndex": 0} - for i in (0, 1) - ], - } - sheet = { - "m_Name": "Sheet", - "m_Width": 64, - "m_Height": 32, - "m_MipCount": 1, - "m_TextureSettings": {"m_FilterMode": 1}, - } - - class Ex: - def key_object(self, key): - assert key == advui.EMOJI_SPRITE_ASSET_KEY - return FakeObj(tree) - - def deref(self, o, ref): - return FakeObj(sheet) if ref == "sheet" else ref - - referent = deref - - def material(self, o): - return {"material": f"{o} record"} - - rec = advui.sprite_asset_record(Ex()) - assert rec["characters"] == [ - {"index": 0, "unicode": 0x1F600, "name": "1f600", "glyph": 0, "scale": 1.0}, - {"index": 1, "unicode": 0x2764, "name": "2764-fe0f", "glyph": 1, "scale": 1.0}, - ] - assert rec["glyphs"]["1"] == {"metrics": metrics, "rect": {"m_X": 32}, "scale": 1.0, "atlasIndex": 0} - assert rec["sequences"] == [{"name": "2764-fe0f", "unicode": 0x2764}] - assert rec["sheet"] == {"name": "Sheet", "width": 64, "height": 32, "mipCount": 1, "settings": {"m_FilterMode": 1}} - assert rec["material"] == {"material": "mat record"} and rec["faceInfo"] == {"m_PointSize": 0.0} - tree["fallbackSpriteAssets"] = [{"m_PathID": 1}] - with pytest.raises(NotImplementedError, match="fallback sprite assets"): - advui.sprite_asset_record(Ex()) - - def test_emoji_sequence_parse(): """TMP_EmojiSearchEngine: the table (the first entry of a key wins) and the names a parse writes: the longest run of UTF-16 units whose prefixes are all in the fast set, no backtracking, the scan going on after a failed run.""" @@ -483,26 +128,6 @@ def test_tmp_hash(): } -def test_chat_localize(): - """TryGetFontIndex over the Japanese font names and additional fonts, the font of that index among the - language's loaded fonts (index out of range: font 0), the material of the type.""" - assert advui.chat_localize("CHAT SDF", "CHAT - Default", LANG, 1) == { - "fontAsset": "EN-CHAT SDF", - "material": "EN-CHAT - Default", - "lineSpacing": -100.0, - } - assert advui.chat_localize("CHAT SDF", "CHAT - Outline", LANG, 4)["fontAsset"] == "KO-R SDF" - assert advui.chat_localize("JA-N SDF", "JA-N - Outline", LANG, 0) == { - "fontAsset": "JA-N SDF", - "material": "JA-N - Outline", - "lineSpacing": 0.0, - } - with pytest.raises(NotImplementedError, match="lookup table"): - advui.chat_localize("Other SDF", "Other - Default", LANG, 1) - with pytest.raises(NotImplementedError, match="material type"): - advui.chat_localize("CHAT SDF", "CHAT - Glow", LANG, 1) - - def lines(s): return {"japanese": s, "english": s} @@ -525,24 +150,6 @@ def chat_episode(): } -def test_chat_runtime_texts_and_shown_chat_rows(): - ep = chat_episode() - assert advui.chat_runtime_texts(ep) == ["...", "80%", "(3)", " 2", " 1"] - assert advui.chat_runtime_texts({"commands": [{"cmd": "Talk"}]}) == [] - got = advui.shown_texts( - ep, - { - "_unknownCharacterNameTextId": "u", - "_splitCharacterNameTextId": "p", - "_chatReadTextId": "60001", - "_chatStampLogTextId": "60004", - }, - "japanese", - ) - assert got == ["やあ", *sorted(["窓", "送", "スタンプ", "既読"]), "題", "...", "80%", "(3)", " 2", " 1"] - assert advui.chat_windows(ep) == {"Line": "Adv/Chat/Prefabs/Line"} - - def tmp_text(font, material, text=""): return { "type": "MonoBehaviour", @@ -588,324 +195,3 @@ def localize(enabled=1, key="0"): "_localizeEnabled": enabled, "_masterTextID": key, } - - -def test_chat_texts(monkeypatch): - window = [ - { - "path": "Line", - "components": [ - {"class": "AdvChatWindow", "_incomingCallStatusTextKey": "call", "_lockScreenStatusTextKey": ""} - ], - }, - {"path": "Line/Top/Name", "components": [tmp_text("CHAT SDF", "CHAT - Default"), localize()]}, - {"path": "Line/Top/Percent", "components": [tmp_text("JA-N SDF", "JA-N - Outline", "100%")]}, - ] - - class Ex: - def prefab(self, address): - assert address == "Adv/Chat/Prefabs/Line" - return {"nodes": window} - - face = {"m_PointSize": 40.0, "m_Scale": 1.0} - fonts = fontsettings.FontSettings( - { - "format": fontsettings.FORMAT, - "fonts": {"EN-CHAT SDF": {"faceInfo": face}, "JA-N SDF": {"faceInfo": face}}, - "materials": { - "EN-CHAT - Default": {"floats": {}, "colors": {}, "keywords": []}, - "JA-N - Outline": {"floats": {}, "colors": {}, "keywords": []}, - }, - } - ) - monkeypatch.setattr(advui.textstyle, "material_style", lambda m, point, scale: {"face": {"point": point}}) - got, status = advui.chat_texts(Ex(), fonts, LANG, 1, chat_episode()) - assert status == ["call"] - name, pct = got["Line"]["Line/Top/Name"], got["Line"]["Line/Top/Percent"] - assert name["localizeText"] == {"enabled": 1, "localizeEnabled": 1} - assert name["textStyle"]["fontRole"] == "font2" and name["textStyle"]["localized"] is True - assert name["textStyle"]["lineSpacing"]["applied"] == -100.0 and name["textStyle"]["face"] == {"point": 40.0} - assert pct["localizeText"] is None and pct["textStyle"]["fontRole"] == "number" - assert pct["textStyle"]["lineSpacing"]["applied"] == 3.0 - # a font's folder: the embedded copy when the catalog has it, else LocalizeManager's Font/ - no_emb = type("C", (), {"has": lambda self, k: False})() - emb = type("C", (), {"has": lambda self, k: True})() - assert fontsettings.font_key(no_emb, "EN-CHAT SDF", "EN-CHAT - Default") == "Font/EN-CHAT/EN-CHAT - Default" - assert fontsettings.font_key(emb, "A SDF", "A - Default") == "EmbFont/A/A - Default" - - -def test_behaviours_record_every_serialized_field(): - """BEHAVIOURS components: every field but the object header, references as node paths, sprites as names (the - references collected for the UI textures), assets and materials as names; several components of a class in - order; a node with several ButtonImageState components has them there instead of `buttonImageState`.""" - sprite = {"spriteRef": "cab:9", "name": "sp_voice"} - comps = [ - { - "type": "MonoBehaviour", - "class": "UIButton", - "m_Enabled": 1, - "m_GameObject": {"m_PathID": 1}, - "m_Script": {"m_PathID": 2}, - "m_Name": "", - "_initialButtonState": 0, - "_buttonStates": [ref("B/State"), ref("B/State2", cls="ButtonImageState")], - "_buttonAnimation": ref("B", cls="ButtonAnimationScale"), - "m_OnClick": { - "m_PersistentCalls": {"m_Calls": [{"m_Target": ref("B/T", cls="Image"), "m_MethodName": "x"}]} - }, - }, - { - "type": "MonoBehaviour", - "class": "ButtonActiveState", - "m_Enabled": 1, - "_target": {"gameObject": "B/On"}, - "_selectedActive": 1, - }, - {"type": "MonoBehaviour", "class": "ButtonActiveState", "m_Enabled": 0, "_target": None, "_selectedActive": 0}, - { - "type": "MonoBehaviour", - "class": "ButtonImageState", - "m_Enabled": 1, - "_target": ref("B/Icon", cls="Image"), - "_normaledSprite": sprite, - "_selectedSprite": None, - }, - { - "type": "MonoBehaviour", - "class": "ButtonImageState", - "m_Enabled": 1, - "_target": ref("B/Frame", cls="Image"), - "_normaledSprite": {"spriteRef": "cab:10", "name": "frame"}, - "_selectedSprite": sprite, - }, - { - "type": "MonoBehaviour", - "class": "SimpleRaycastTarget", - "m_Enabled": 1, - "m_Material": None, - "m_RaycastPadding": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 0.0}, - }, - { - "type": "MonoBehaviour", - "class": "LocalizeSpriteEvent", - "m_Enabled": 1, - "m_LocalizedAssetReference": { - "m_TableReference": {"m_TableCollectionName": "GUID:abc"}, - "m_TableEntryReference": {"m_KeyId": 7, "m_Key": ""}, - }, - }, - {"type": "MonoBehaviour", "class": "GraphicRaycaster", "m_Enabled": 1}, - ] - rec = advui._layout_components({"components": comps}) - b = rec["behaviours"] - assert set(b) == {"UIButton", "ButtonActiveState", "ButtonImageState", "SimpleRaycastTarget", "LocalizeSpriteEvent"} - assert b["UIButton"] == [ - { - "m_Enabled": 1, - "_initialButtonState": 0, - "_buttonStates": ["B/State", "B/State2"], - "_buttonAnimation": "B", - "m_OnClick": {"m_PersistentCalls": {"m_Calls": [{"m_Target": "B/T", "m_MethodName": "x"}]}}, - } - ] - assert [x["_target"] for x in b["ButtonActiveState"]] == ["B/On", None] - assert [x["_normaledSprite"] for x in b["ButtonImageState"]] == ["sp_voice", "frame"] - assert "buttonImageState" not in rec and rec["_spriteRefs"] == ["cab:9", "cab:10", "cab:9"] - assert b["LocalizeSpriteEvent"][0]["m_LocalizedAssetReference"]["m_TableEntryReference"]["m_KeyId"] == 7 - one = advui._layout_components({"components": comps[3:4]}) - assert one["buttonImageState"] == {"normal": "sp_voice"} and "behaviours" not in one - assert ( - advui._plain({"asset": "UIPictogramCatalog", "name": "UIPictogramCatalog", "_list": []}, []) - == "UIPictogramCatalog" - ) - assert advui._plain({"material": "M", "shader": {"shader": "UI/Default"}}, []) == "M" - - -def test_full_views_and_pictograms(): - view = { - "type": "MonoBehaviour", - "class": "AdvChoiceView", - "m_Enabled": 1, - "m_Script": {}, - "_choiceItems": [ref("C/Item", cls="AdvChoiceItem"), ref("C/Item (1)", cls="AdvChoiceItem")], - "_choiceItemParent": {"transform": "C/Choices"}, - "_animationIntervalIn": 0.1, - } - rec = advui._layout_components({"components": [view]}) - assert rec["choiceView"] == { - "enabled": 1, - "m_Enabled": 1, - "_choiceItems": ["C/Item", "C/Item (1)"], - "_choiceItemParent": "C/Choices", - "_animationIntervalIn": 0.1, - } - catalog = { - "asset": "Cat", - "name": "Cat", - "m_Enabled": 1, - "_spritesByKey": {"_list": [{"Key": 98, "Value": {"spriteRef": "cab:98", "name": "IconClose"}}]}, - "_defaultSprite": {"spriteRef": "cab:1", "name": "IconSetting"}, - } - picto = { - "type": "MonoBehaviour", - "class": "UIPictogram", - "m_Enabled": 1, - "_key": 98, - "_spriteCatalog": catalog, - "_image": ref("C/Close", cls="Image"), - "_setNativeSize": 0, - } - nodes = [ - {"path": "C/Close", "components": [picto]}, - {"path": "C/Other", "components": [{**picto, "_key": 5, "_spriteCatalog": {"asset": "Cat", "name": "Cat"}}]}, - ] - cats = advui.pictogram_catalogs(nodes) - assert set(cats) == {"Cat"} - recs = [] - for n in nodes: - r = {"path": n["path"], **advui._layout_components(n)} - advui.resolve_pictograms(r, cats) - recs.append(r["behaviours"]["UIPictogram"][0]) - assert recs[0] == { - "m_Enabled": 1, - "_key": 98, - "_image": "C/Close", - "_setNativeSize": 0, - "catalog": "Cat", - "sprite": "IconClose", - "defaultSprite": "IconSetting", - "_spriteRef": "cab:98", - "_defaultRef": None, - } - assert (recs[1]["sprite"], recs[1]["_defaultRef"]) == (None, "cab:1") - with pytest.raises(RuntimeError, match="not exported"): - advui.resolve_pictograms({"path": "x", **advui._layout_components(nodes[1])}, {}) - - -def test_talk_windows_and_their_nodes(): - """The default talk window first, then the episode's other talk windows by name; each window's prefab nodes under - TalkView, the default from WINDOW_KEY and the others from the TalkWindow address as adv.closure resolves it.""" - res = [ - {"kind": "talkwindow", "address": "EmbUI/Prefab/Parts/Adv/Talk/UIDefaultTalkWindow"}, - {"kind": "talkwindow", "address": "EmbUI/Prefab/Parts/Adv/Talk/UICenterTalkWindow"}, - {"kind": "chatwindow", "address": "Adv/Chat/Prefabs/Zed"}, - {"kind": "talkwindow", "address": "UI/Prefab/Parts/Adv/Talk/UIBTalkWindow"}, - ] - assert advui.talk_windows({"resources": res}) == ["UIDefaultTalkWindow", "UIBTalkWindow", "UICenterTalkWindow"] - assert advui.talk_windows({"resources": []}) == ["UIDefaultTalkWindow"] - - class Cat: - def has(self, address): - return address == "UI/Prefab/Parts/Adv/Talk/UIBTalkWindow" - - class Ex: - cat = Cat() - - def __init__(self): - self.keys = [] - - def prefab(self, key): - self.keys.append(key) - name = key.rsplit("/", 1)[1] - return {"nodes": [{"path": name, "components": []}, {"path": f"{name}/TalkArea", "components": []}]} - - assert advui.window_key(Cat(), "UIDefaultTalkWindow") == advui.WINDOW_KEY - assert advui.window_key(Cat(), "UIBTalkWindow") == "UI/Prefab/Parts/Adv/Talk/UIBTalkWindow" - assert advui.window_key(Cat(), "UICenterTalkWindow") == "EmbUI/Prefab/Parts/Adv/Talk/UICenterTalkWindow" - ex = Ex() - got = advui.window_nodes(ex, ["UIDefaultTalkWindow", "UICenterTalkWindow"]) - assert [n["path"] for n in got] == [ - f"{advui.WINDOW_PARENT}/{p}" - for p in ( - "UIDefaultTalkWindow", - "UIDefaultTalkWindow/TalkArea", - "UICenterTalkWindow", - "UICenterTalkWindow/TalkArea", - ) - ] - assert ex.keys == [advui.WINDOW_KEY, "EmbUI/Prefab/Parts/Adv/Talk/UICenterTalkWindow"] - - class Wrong(Ex): - def prefab(self, key): - return {"nodes": [{"path": "Other", "components": []}]} - - with pytest.raises(RuntimeError, match="prefab root"): - advui.window_nodes(Wrong(), ["UICenterTalkWindow"]) - assert ( - advui.about("en", ["UIDefaultTalkWindow", "UICenterTalkWindow"]) - == "ADV front canvas UI data (UIAdvWidget + UIDefaultTalkWindow, UICenterTalkWindow), en" - ) - - -def test_backdrop_filter_and_the_backdrop_node(): - """The centre-talk backdrop is kept when a talk window uses the backdrop filter: the node UIAdvWidget's backdrop - CanvasGroup and Image references name (one node with both components).""" - - def win(on): - return [ - {"path": "W", "components": [{"class": "UIAdvTalkWindow", "_useBackdropFilter": on}]}, - {"path": "W/TalkArea", "components": [image()]}, - ] - - assert advui.uses_backdrop_filter(win(0) + win(1)) and not advui.uses_backdrop_filter(win(0)) - path = "UIAdvWidget/FrontCanvas/CenterTalkBackdrop" - widget = [ - {"path": "UIAdvWidget", "components": []}, - {"path": path, "components": [{"type": "CanvasGroup", "m_Alpha": 0.0}, image()]}, - {"path": "UIAdvWidget/FrontCanvas/Plain", "components": [image()]}, - ] - comp = {"_centerTalkBackdropCanvasGroup": ref(path, "CanvasGroup", None), "_centerTalkBackdropImage": ref(path)} - assert advui.backdrop_path(widget, comp) == path - with pytest.raises(RuntimeError, match="BACKDROP|_centerTalkBackdrop"): - advui.backdrop_path(widget, {**comp, "_centerTalkBackdropImage": ref("UIAdvWidget/FrontCanvas/Plain")}) - with pytest.raises(RuntimeError, match="no CanvasGroup"): - advui.backdrop_path(widget, {k: ref("UIAdvWidget/FrontCanvas/Plain") for k in comp}) - - -def test_blur_settings(): - """The blur of a backdrop-filter talk window: renderer 0's UIRendererFeature record and its shader object.""" - feature = { - "class": "Fwk.UI.Rendering.UIRendererFeature", - "m_Active": 1, - "_blurIterations": 3, - "_blurOffset": 1.0, - "_blurDownsample": 1, - "_blurBlendRateMax": 0.3, - "_dualKawaseBlurShader": "Hidden/UI/DualKawaseBlur", - } - - class Shader: - def __init__(self, name): - self.m_ParsedForm = type("P", (), {"m_Name": name}) - - def read(self): - return self - - class Player: - def __init__(self, active=1): - self.active = active - - def graphics(self): - return { - "defaultPipeline": "P", - "pipelines": {"P": {"m_RendererDataList": ["R0"], "m_DefaultRendererIndex": 0}}, - "renderers": {"R0": {"m_RendererFeatures": [{**feature, "m_Active": self.active}]}}, - } - - def renderer_shaders(self, name): - assert name == "R0" - return [Shader("Hidden/Other"), Shader("Hidden/UI/DualKawaseBlur")] - - blur, shader = advui.blur_settings(Player()) - assert blur == { - "renderer": "R0", - "active": True, - "iterations": 3, - "offset": 1.0, - "downsample": 1, - "blendRateMax": 0.3, - "shader": "Hidden/UI/DualKawaseBlur", - } - assert shader.m_ParsedForm.m_Name == "Hidden/UI/DualKawaseBlur" - with pytest.raises(NotImplementedError, match="not active"): - advui.blur_settings(Player(0)) diff --git a/tests/content/test_tmpassets.py b/tests/content/test_tmpassets.py index 4886590..1136118 100644 --- a/tests/content/test_tmpassets.py +++ b/tests/content/test_tmpassets.py @@ -11,7 +11,7 @@ from nnnotes.content import tmpassets from nnnotes.unity import fontsettings -from nnnotes.unity.export import MemoryPacker, packed_png +from nnnotes.unity.texels import MemoryPacker, packed_png from tests.helpers.fonts import make_font FILE = "CAB-tmpassets" diff --git a/tests/formats/test_crilips.py b/tests/formats/test_crilips.py index e799fc9..a169d06 100644 --- a/tests/formats/test_crilips.py +++ b/tests/formats/test_crilips.py @@ -184,31 +184,3 @@ def test_supported_versions_carry_the_player_configuration(): def _talk(names, voices, **kw): return {"cmd": "Talk", "TargetName": names, "VoiceIDs": voices, **kw} - - -def test_needs_cri_lips(): - from nnnotes.content import story - - def ep(*rows): - return {"commands": list(rows)} - - one = _talk("a", [1]) - # a voiced row reaches the analysis on a model without MotionSync - assert story.needs_cri_lips(ep(one), "・", [True, False]) - assert not story.needs_cri_lips(ep(one), "・", [True, True]) - # rows without lip sync never do - for row in ( - _talk("a", [1], IgnoreLipSync=1), - _talk("a", [1], IgnoreData=1), - _talk("", [1]), - _talk("a", []), - {"cmd": "Bgm", "VoiceIDs": [1], "TargetName": "a"}, - ): - assert not story.needs_cri_lips(ep(row), "・", [False]) - # a Voice row on a model without MotionSync - assert story.needs_cri_lips(ep({"cmd": "Voice", "TargetName": "a", "VoiceIDs": [1]}), "・", [False]) - # a voice shared by more speakers, or every showing character, uses the analysis on any model - assert story.needs_cri_lips(ep(_talk("a・b", [1])), "・", [True]) - assert not story.needs_cri_lips(ep(_talk("a・b", [1, 2])), "・", [True]) - assert story.needs_cri_lips(ep(_talk("a", [1], Parameter1=" EveryoneLipSync ")), "・", [True]) - assert not story.needs_cri_lips(ep(_talk("a・b", [1], Parameter1="AirLipSync")), "・", [True]) diff --git a/tests/formats/test_usm.py b/tests/formats/test_usm.py index fb9a22c..2396773 100644 --- a/tests/formats/test_usm.py +++ b/tests/formats/test_usm.py @@ -1,7 +1,6 @@ """usm: USM masks, demux of every stream kind, subtitles, the movie files' ffmpeg arguments; advvideo: the video index (synthetic data, made-up key).""" -import json import random import shutil import struct @@ -10,7 +9,6 @@ import pytest -from nnnotes.content import advvideo from nnnotes.formats import usm from nnnotes.formats.usm import Stream @@ -670,140 +668,6 @@ def test_webm_arguments(): ] -def test_story_export_keeps_its_command(tmp_path, monkeypatch): - """The story's WebM of a VP9 + ADX movie: the command it always ran (plus -hide_banner), the same record.""" - info = { - "movieInfo": { - "codecType": 9, - "width": 320, - "height": 180, - "dispWidth": 320, - "dispHeight": 180, - "framerateN": 30, - "framerateD": 1, - "totalFrames": 3, - "numAlphaStreams": 0, - "numSubtitleChannels": 0, - "numAudioStreams": 1, - }, - "assetInfo": {"loop": 0}, - } - movie = tmp_path / "m.usm" - movie.write_bytes( - build([(b"@SFV", 0, None, VP9, "video"), (b"@SFA", 0, None, [adx(samples=100) + rand(4 * 36, 1)], "audio")]) - ) - monkeypatch.setattr(advvideo, "usm_asset", lambda cat, key: (info, movie)) - monkeypatch.setattr("nnnotes.source.config.tool", lambda name, exe: name) - calls = [] - - def fake_run(args, **kw): - calls.append(args) - Path(args[-1]).write_bytes(b"webm") - return subprocess.CompletedProcess(args, 0, "", "") - - monkeypatch.setattr(usm.subprocess, "run", fake_run) - work = tmp_path / "videos" / "_work" - rec = advvideo.export(None, "Cri/Video/adv/m/m", tmp_path / "videos" / "m.webm", KEY, work) - assert calls == [ - [ - "ffmpeg", - "-hide_banner", - "-y", - "-loglevel", - "error", - "-f", - "ivf", - "-i", - str(work / "video.ivf"), - "-f", - "adx", - "-i", - str(work / "audio.adx"), - "-map", - "0:v:0", - "-map", - "1:a:0", - "-c:a", - "libopus", - "-b:a", - "192k", - "-flags:a", - "+bitexact", - "-c:v", - "copy", - "-map_metadata", - "-1", - "-fflags", - "+bitexact", - "-f", - "webm", - str(tmp_path / "videos" / "m.webm"), - ] - ] - assert list(rec) == [ - "key", - "codec", - "width", - "height", - "displayWidth", - "displayHeight", - "frameRate", - "frames", - "assetInfo", - "audio", - ] - assert rec["audio"] == { - "codec": "opus", - "bitrate": "192k", - "source": {"codec": "adx", "channels": 2, "sampleRate": 48000, "samples": 100}, - } - assert not work.exists() - movie.write_bytes( - build( - [(b"@SFV", 0, video_header(9), VP9, "video"), (b"@ALP", 0, video_header(1, alpha_type=1), MPEG1, "video")] - ) - ) - info["movieInfo"]["numAudioStreams"] = 0 - rec = advvideo.export(None, "Cri/Video/x", tmp_path / "videos" / "x.webm", KEY, work) - assert rec["encode"] == {"codec": "vp9", "crf": 20, "alpha": True} and "audio" not in rec - assert "alphamerge" in calls[-1][calls[-1].index("-filter_complex") + 1] - - -def test_video_index(tmp_path, monkeypatch): - def fake_export(cat, key, dst, cri_key, work): - assert cri_key == KEY - dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_bytes(b"webm") - return {"key": key, "codec": "vp9", "frames": 10} - - monkeypatch.setattr(advvideo, "export", fake_export) - episode = { - "resources": [ - {"kind": "video", "address": "Cri/Video/adv/m_b/m_b", "present": True}, - {"kind": "video", "address": "Cri/Video/adv/m_a/m_a", "present": True}, - {"kind": "stage", "address": "Adv/Stage/s/s", "present": True}, - ], - "commands": [ - {"cmd": "Movie", "VideoID": 2}, - {"cmd": "Clip", "VideoID": 1}, - {"cmd": "Clip"}, - {"cmd": "Clip", "VideoID": 2}, - {"cmd": "Movie", "VideoID": 9}, - {"cmd": "Stage", "VideoID": 1}, - ], - "videos": { - 1: {"_id": 1, "_assetName": "adv/m_a/m_a", "_hasAudio": True}, - 2: {"_id": 2, "_assetName": "adv/m_b/m_b", "_hasAudio": False}, - }, - } - index = advvideo.extract(None, episode, tmp_path, cri_key=KEY) - assert list(index["videos"]) == ["2", "1"] - assert index["videos"]["1"]["file"] == "videos/m_a.webm" and index["videos"]["2"]["master"]["_hasAudio"] is False - assert (tmp_path / "videos" / "m_a.webm").read_bytes() == b"webm" - assert json.loads((tmp_path / "videos" / "videos.json").read_text(encoding="utf-8")) == index - assert advvideo.extract(None, {"resources": [], "commands": [], "videos": {}}, tmp_path / "none") is None - - # ---------------------------------------------------------------- with ffmpeg (skipped without it) def _encoders(ffmpeg): r = subprocess.run([ffmpeg, "-hide_banner", "-encoders"], capture_output=True, text=True) diff --git a/tests/pipeline/test_cristages.py b/tests/pipeline/test_cristages.py index fee08ce..0698408 100644 --- a/tests/pipeline/test_cristages.py +++ b/tests/pipeline/test_cristages.py @@ -603,7 +603,7 @@ def test_movie_parameters(): def test_stage_modules_import_nothing_heavy(): code = ( "import sys; import nnnotes.pipeline.cristages as c; c.AudioStage().normalize(None); " - "heavy = ('UnityPy', 'numpy', 'PIL', 'nnnotes.formats.cri', 'nnnotes.formats.usm', 'nnnotes.unity.export'); " + "heavy = ('UnityPy', 'numpy', 'PIL', 'nnnotes.formats.cri', 'nnnotes.formats.usm', 'nnnotes.unity.texels'); " "print(sorted(m for m in heavy if m in sys.modules))" ) out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=ROOT, check=True) diff --git a/tests/pipeline/test_objexport.py b/tests/pipeline/test_objexport.py index 1a1cb2b..6316257 100644 --- a/tests/pipeline/test_objexport.py +++ b/tests/pipeline/test_objexport.py @@ -815,10 +815,9 @@ def test_textures(exported): assert img.size == (W, H) and img.mode == "RGBA" expected = Image.frombytes("RGBA", (W, H), TEX_PIXELS).transpose(Image.Transpose.FLIP_TOP_BOTTOM) assert img.tobytes() == expected.tobytes() - from nnnotes.unity.export import texture_png - - tex = bundle_all().assets[0].objects[10].obj - assert png == texture_png(tex) # the other commands' bytes + encoded = __import__("io").BytesIO() + expected.save(encoded, format="PNG") + assert png == encoded.getvalue() facts = art(doc, f"{oid(10)}#image")["semantics"]["facts"] assert facts == { "width": W, @@ -1596,7 +1595,7 @@ def test_sprite_crop_waits_for_exports(tmp_path): def test_modules_import_without_unitypy(): code = ( "import sys; import nnnotes.pipeline.objexport as o; o.ExportStage().ATOMS; o.SpriteCropStage().ATOMS; " - "print(sorted(m for m in ('UnityPy', 'PIL', 'nnnotes.unity.export') if m in sys.modules))" + "print(sorted(m for m in ('UnityPy', 'PIL', 'nnnotes.unity.texels') if m in sys.modules))" ) out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=ROOT, check=True) assert out.stdout.strip() == "[]" diff --git a/tests/pipeline/test_views.py b/tests/pipeline/test_views.py index 78a02dc..9485fde 100644 --- a/tests/pipeline/test_views.py +++ b/tests/pipeline/test_views.py @@ -1000,7 +1000,7 @@ def test_views_plan_and_run_after_the_address_table(tmp_path): def test_views_import_neither_unitypy_nor_numpy(): code = ( "import sys, nnnotes.pipeline.views; " - "print(sorted(m for m in ('UnityPy', 'numpy', 'PIL', 'nnnotes.unity.export') if m in sys.modules))" + "print(sorted(m for m in ('UnityPy', 'numpy', 'PIL', 'nnnotes.unity.texels') if m in sys.modules))" ) out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=ROOT, check=True).stdout assert out.strip() == "[]" diff --git a/tests/site/test_regions.py b/tests/site/test_regions.py index d604ccd..c4ab20e 100644 --- a/tests/site/test_regions.py +++ b/tests/site/test_regions.py @@ -89,7 +89,12 @@ def test_music_rows_have_every_language(tmp_path): @pytest.mark.parametrize("code", LANGS) def test_start_canvas_strings_follow_the_language(tmp_path, code): - strings, sources = liveui.resolve_strings(write_master(tmp_path / "m"), 1, "expert", languages.column(code)) + master = write_master(tmp_path / "m") + tables = { + name: score.master_table(master, name) + for name in ("MasterLiveMusic", "MasterText", "MasterBand", "MasterLiveMusicScore") + } + strings, sources = liveui.record_strings(tables, 1, "expert", languages.column(code)) assert strings["musicTitle"] == "Song-ja" # the game shows the Japanese title by default assert strings["singerName"] == f"Band-{code}" assert strings["lyricsWriter"] == f"L:Lyricist-{code}" diff --git a/tests/unity/test_atoms_texture.py b/tests/unity/test_atoms_texture.py index a87d90b..28b9f79 100644 --- a/tests/unity/test_atoms_texture.py +++ b/tests/unity/test_atoms_texture.py @@ -1,6 +1,4 @@ -"""texture.decode, png.encode and astc.container on synthetic textures (decode = UnityPy's parse_image_data; PNG bytes -= export.texture_png's), the reader's objects, containers and texture inputs through a fake environment, and the -atom registry.""" +"""Texture decoding, lossless PNG output and container inputs over synthetic textures.""" import io import re @@ -13,7 +11,6 @@ from PIL import Image from UnityPy.export.Texture2DConverter import parse_image_data -from nnnotes.unity import export from nnnotes.unity.atoms import ATOMS, Impl, Unsupported, astc, env_name, ids, impl_id, png, resolve from nnnotes.unity.atoms.reader import ObjectInfo, UnityBundle from nnnotes.unity.atoms.texture import ASTC_BLOCKS, TextureInput, decode, is_hdr_astc @@ -69,9 +66,12 @@ def test_decode_is_parse_image_data(fmt): @pytest.mark.parametrize("fmt", FORMATS) -def test_png_bytes_equal_export_texture_png(fmt): +def test_png_bytes_match_the_decoded_image(fmt): tex = fake(fmt, seed=fmt) - assert png.encode(decode(inp(tex))) == export.texture_png(tex) + image = parse_image_data(tex.get_image_data(), tex.m_Width, tex.m_Height, fmt, VERSION, ANDROID, [], True) + expected = io.BytesIO() + image.save(expected, format="PNG") + assert png.encode(decode(inp(tex))) == expected.getvalue() @pytest.mark.parametrize("fmt", [RGBA32, ASTC_6x6]) @@ -81,11 +81,6 @@ def test_further_mip_levels_do_not_change_the_image(fmt): assert decode(inp(mips)).tobytes() == decode(inp(base)).tobytes() -def test_decode_input_key_is_export_decode_inputs(): - tex = fake(RGBA32) - assert inp(tex).key() == export._decode_inputs(tex) - - @pytest.mark.parametrize("fmt", [f for f in range(66, 72)]) def test_hdr_astc_is_refused(fmt): assert is_hdr_astc(fmt) @@ -204,7 +199,7 @@ def test_reader_texture_inputs(): env, view, inline, streamed_data = bundle() objs = {o.path_id: o for o in view.objects()} ti = view.texture_input(objs[-5]) - assert ti.key() == export._decode_inputs(inline) + assert ti.key() == inp(inline).key() assert view.texture_input(objs[7]).data == streamed_data and env.lookups == ["CAB-aa.resS"] env.lookups.clear() empty = view.texture_input(objs[9]) diff --git a/tests/unity/test_container.py b/tests/unity/test_container.py new file mode 100644 index 0000000..d6f9b67 --- /dev/null +++ b/tests/unity/test_container.py @@ -0,0 +1,66 @@ +"""Container-path lookup and jacket decoding over synthetic bundle objects.""" + +from types import SimpleNamespace + +import pytest +from PIL import Image + +from nnnotes.musicdata import MusicDataError, catalog_jacket +from nnnotes.unity import reader + + +def environment(monkeypatch, asset): + pointer = {"m_FileID": 1, "m_PathID": 7} + owner = SimpleNamespace( + type=SimpleNamespace(name="AssetBundle"), + read_typetree=lambda: {"m_Container": [("Assets/Jacket.PNG", {"asset": pointer})]}, + ) + + def dereference(source, ref): + assert source is owner and ref == pointer + return asset + + monkeypatch.setattr(reader, "deref", dereference) + return SimpleNamespace(objects=[SimpleNamespace(type=SimpleNamespace(name="Texture2D")), owner]) + + +def test_container_matches_full_paths_and_resolves_external_references(monkeypatch): + asset = object() + env = environment(monkeypatch, asset) + assert reader.container_asset(env, "assets/jacket.png") is asset + with pytest.raises(KeyError, match="not in any AssetBundle container"): + reader.container_asset(env, "jacket.png") + with pytest.raises(KeyError, match="null reference"): + reader.container_asset(environment(monkeypatch, None), "assets/jacket.png") + + +def test_jacket_reads_and_resizes_the_named_texture(monkeypatch): + import io + + asset = SimpleNamespace( + type=SimpleNamespace(name="Texture2D"), + read=lambda: SimpleNamespace(image=Image.new("RGBA", (20, 8), (64, 128, 192, 255))), + ) + env = environment(monkeypatch, asset) + requested = [] + + class Catalog: + def fetch_key(self, key): + requested.append(key) + return ["bundle"] + + def entry(self, key): + return {"internal_id": "Assets/Jacket.PNG"} + + def load(paths): + assert paths == ["bundle"] + return env + + monkeypatch.setattr(reader, "load_closure", load) + get = catalog_jacket(Catalog(), size=10) + image = Image.open(io.BytesIO(get("sample"))) + assert image.format == "WEBP" and image.size == (10, 4) + assert requested == ["Image/Jacket/sample"] + asset.type.name = "Sprite" + with pytest.raises(MusicDataError, match="is not a Texture2D"): + get("sample") diff --git a/tests/unity/test_fontsettings.py b/tests/unity/test_fontsettings.py index 2a10e0e..42da8ff 100644 --- a/tests/unity/test_fontsettings.py +++ b/tests/unity/test_fontsettings.py @@ -4,7 +4,7 @@ import pytest -from nnnotes.unity import export, fontsettings +from nnnotes.unity import fontsettings from nnnotes.unity.fontsettings import END_OF_TEXT, SPACE FACE = {"m_PointSize": 40.0, "m_Scale": 1.0} @@ -147,16 +147,6 @@ def test_write_one_entry_per_line(tmp_path): assert sum(ln.startswith('"7":') or ln.startswith('"8":') for ln in lines) == 2 -def test_font_stub_resolves_from_the_objects(): - """A reference into a font bundle left out of the closure: a font asset is named, a material is its record.""" - s = fontsettings.FontSettings(table(**{"A SDF": font("A", material="A SDF Material")})) - ex = export.Exporter.__new__(export.Exporter) - ex.font_stubs = s - asset = export.FontStub(s.objects[7], 7) - assert ex.font_stub(asset) == {"asset": fontsettings.FONT_CLASS, "name": "A SDF"} - assert ex.font_stub(export.FontStub(s.objects[8], 8))["floats"] == {"_GradientScale": 10.0} - - def test_builtin_table_is_complete(): """Every fallback, default material and object the shipped table names is in it.""" s = fontsettings.builtin() diff --git a/tests/unity/test_mesh_texture_guards.py b/tests/unity/test_mesh_texture_guards.py index 2a42cc8..8d7e383 100644 --- a/tests/unity/test_mesh_texture_guards.py +++ b/tests/unity/test_mesh_texture_guards.py @@ -6,7 +6,6 @@ import pytest from UnityPy.enums import TextureFormat -from nnnotes.unity import export from nnnotes.unity import reader as unity @@ -106,8 +105,6 @@ def test_hdr_astc_textures_are_refused_before_decoding(fmt): tex = FakeTexture(int(fmt)) # UnityPy may give the format as a plain int with pytest.raises(unity.UnsupportedTexture, match=rf"'number1_glow': {fmt.name} \(HDR ASTC\)"): unity.texture_image(tex) - with pytest.raises(unity.UnsupportedTexture): - export.texture_png(tex) assert tex.decoded == 0 diff --git a/tests/unity/test_speed_exact.py b/tests/unity/test_speed_exact.py index 6f8c112..1e89b98 100644 --- a/tests/unity/test_speed_exact.py +++ b/tests/unity/test_speed_exact.py @@ -20,9 +20,8 @@ from nnnotes.core import cache, jsonio from nnnotes.formats import cri, encoders, shader -from nnnotes.unity import export from nnnotes.unity import reader as unity -from nnnotes.unity.export import Exporter, TexelPacker +from nnnotes.unity import texels as export from nnnotes.unity.reader import SceneGraph @@ -217,26 +216,6 @@ def ref_value(ex, owner, v): return v -def test_value_fast_path(tmp_path): - ex = Exporter(None, tmp_path) - ex.ref = lambda owner, p: {"ref": p["m_PathID"]} - rng = random.Random(5) - for _ in range(200): - v = [rng.random() for _ in range(rng.randrange(5))] - doc = { - "a": v, - "b": [1, "x", None, True], - "c": [{"m_FileID": 0, "m_PathID": 3}, 2.0], - "d": [b"\x01", 1], - "e": [[1, 2], [3]], - "f": [], - "g": {"h": [{"m_FileID": 1, "m_PathID": 0}]}, - } - out = ex.value(None, doc) - assert out == ref_value(ex, None, doc) - assert out["a"] is not v and out["f"] == [] # new lists, as before - - # ---------------------------------------------------------------- textures class FakeTex: decodes = 0 @@ -283,33 +262,6 @@ def png_of(img, **kw): return b.getvalue() -def test_texture_png_cached_and_equal(): - t = FakeTex(b"abcdefgh" * 10, 16, 8) - FakeTex.decodes = 0 - want = png_of(t.image) - assert export.texture_png(t) == want - assert export.texture_png(FakeTex(b"abcdefgh" * 10, 16, 8)) == want # same inputs: no decode - assert FakeTex.decodes == 2 - assert export.texture_png(FakeTex(b"abcdefgh" * 10, 8, 16)) != want # other size: decoded - cache.configure(enabled=False) - assert export.texture_png(t) == want - - -def test_exporter_texture_files_immediate_and_deferred(tmp_path): - objs = [tex_obj(FakeTex(bytes([i]) * 64, 8, 4), name=f"t{i}", pid=i) for i in range(4)] - a = Exporter(None, tmp_path / "a") - recs_a = [a.texture(o) for o in objs] - b = Exporter(None, tmp_path / "b", defer_writes=True) - recs_b = [b.texture(o) for o in objs] - assert recs_a == recs_b - assert not list((tmp_path / "b" / "textures").iterdir()) - skip = {recs_b[1]["texture"]} - assert b.write_textures(skip=skip) == sorted(r["texture"] for r in recs_b if r["texture"] not in skip) - ta = tree(tmp_path / "a") - assert tree(tmp_path / "b") == {k: v for k, v in ta.items() if k not in skip} - assert ta[recs_a[0]["texture"]] == png_of(objs[0].read().image) - - def ref_pixels(tex_obj): tt = tex_obj.read_typetree() img = tex_obj.read().image @@ -322,26 +274,6 @@ def ref_pixels(tex_obj): return arr[::-1].copy() -@pytest.mark.parametrize("fmt,mode", [(4, "RGBA"), (3, "RGB"), (1, "L"), (1, "RGBA")]) -def test_packer_pixels_and_png(tmp_path, fmt, mode): - o = tex_obj(FakeTex(b"seed%d" % fmt + mode.encode(), 12, 6, fmt, mode)) - FakeTex.decodes = 0 - want = ref_pixels(o) - p1, p2 = TexelPacker(tmp_path / "1"), TexelPacker(tmp_path / "2") - a = p1.pixels(o) - assert np.array_equal(a, want) and not a.flags.writeable - assert np.array_equal(p2.pixels(o), want) - assert FakeTex.decodes == 2 # the reference, then one decode for both packers - for p in (p1, p2): - p.add("g", o, 0, 0, 12, 6) - p.add("g", o, -1, -1, 3, 3) - r1, r2 = p1.build(), p2.build() - assert r1 == r2 and tree(tmp_path / "1") == tree(tmp_path / "2") - out = tree(tmp_path / "1")["textures/g.png"] - arr = np.asarray(Image.open(io.BytesIO(out))) - assert out == png_of(Image.fromarray(arr.copy(), "RGBA"), optimize=True) - - def test_packed_png_matches_pillow(): rgba = np.random.default_rng(1).integers(0, 256, (5, 7, 4), dtype=np.uint8) assert export.packed_png(rgba) == png_of(Image.fromarray(rgba[::-1].copy(), "RGBA"), optimize=True) diff --git a/tests/unity/test_textstyle.py b/tests/unity/test_textstyle.py index 0be5a53..2b7a2f7 100644 --- a/tests/unity/test_textstyle.py +++ b/tests/unity/test_textstyle.py @@ -5,14 +5,9 @@ from types import SimpleNamespace import pytest -from UnityPy.helpers.TypeTreeHelper import read_typetree -from UnityPy.streams.EndianBinaryReader import EndianBinaryReader -from nnnotes.content import advui from nnnotes.source import languages -from nnnotes.source.config import ConfigError -from nnnotes.unity import fontsettings, textstyle, tmpfont -from nnnotes.unity.scripttypes import load_typetrees, typetree_key, typetree_node +from nnnotes.unity import fontsettings, textstyle WHITE = {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0} DARK = {"r": 0.2, "g": 0.2, "b": 0.2, "a": 0.8} @@ -261,121 +256,6 @@ def test_no_font_data_in_the_record(): } -def test_text_styles_reads_the_localized_material(monkeypatch): - """The face info and the localized material come from the font settings (no font asset read).""" - monkeypatch.setattr(textstyle, "language_fonts", lambda player: LANG) - face = {"m_Scale": 1.0, "m_PointSize": 100.0, "m_LineHeight": 120.0, "m_AscentLine": 90.0, "m_DescentLine": -30.0} - fonts = fontsettings.FontSettings( - { - "format": fontsettings.FORMAT, - "fonts": {"MainT SDF": {"faceInfo": face, "normalSpacingOffset": 0.0, "boldSpacing": 7.0}}, - "materials": {"MainT - Outline": material(["OUTLINE_ON"], _OutlineWidth=0.25)}, - } - ) - styles = textstyle.TextStyles(None, player=None, mode=TW, fonts=fonts) - rec = styles.record("Canvas/Text", component(), LOCALIZE) - assert rec["outline"]["widthEm"] == pytest.approx(0.25 * 15 / 16 * 0.16, abs=1e-6) - assert styles.summary()["roles"] == { - "primary": {"lineHeightEm": 1.2, "ascentEm": 0.9, "descentEm": -0.3, "spacingOffset": 0.0, "boldSpacing": 7.0} - } - assert styles.summary()["language"] == "traditionalChinese" - with pytest.raises(KeyError, match="MainT - Default of MainT SDF not in the font settings"): - styles.record("Canvas/Text", component(m_sharedMaterial={"material": "MainJ - Default"}), LOCALIZE) - - -def test_font_modes_and_extra(monkeypatch): - assert advui.FONT_MODES == ("open", "game") - with pytest.raises(ValueError, match="fonts must be one of"): - advui.extract(None, None, {}, ".", fonts="other", language="ja") - monkeypatch.setattr(tmpfont.importlib.util, "find_spec", lambda name: None) - with pytest.raises(tmpfont.ConfigError, match=r"nnnotes\[fonts\]") as e: - tmpfont.require_extra() - assert "\n" not in str(e.value) and "fonttools" in str(e.value) - - -def test_the_language_is_an_argument(): - assert not hasattr(textstyle, "LANGUAGE_MODE") and not hasattr(textstyle, "LANGUAGE_FIELD") - en = languages.mode("en") - swap = textstyle.font_swap(LANG, en) - assert swap == {"MainJ SDF": "MainE SDF", "Digits SDF": "DigitsE SDF"} - assert textstyle.localize_text("p", "MainJ SDF", "MainJ - Outline", swap, LANG, en) == { - "fontAsset": "MainE SDF", - "material": "MainE - Outline", - "lineSpacing": -100.0, - } - assert textstyle.font_swap(LANG, languages.mode("ja")) == {"MainJ SDF": "MainJ SDF", "Digits SDF": "Digits SDF"} - style = textstyle.material_style(material(), point_size=100.0) - assert textstyle.text_style(component(), LOCALIZE, LANG, style, en)["lineSpacing"]["applied"] == -100.0 - - -def test_story_ui_language_records(tmp_path): - assert advui.about("en") == "ADV front canvas UI data (UIAdvWidget + UIDefaultTalkWindow), en" - assert advui.language_doc("en") == {"mode": 1, "field": "english", "lineSpacing": -100.0} - assert advui.language_doc("zh-Hant") == {"mode": 2, "field": "traditionalChinese", "lineSpacing": 5.0} - assert list(advui.language_doc("ko")) == ["mode", "field", "lineSpacing"] - # an unknown language is refused before anything is read, naming the setting - with pytest.raises(ConfigError, match="catalog.language: not one of the game's languages"): - advui.extract(None, None, {}, tmp_path, language="xx") - - -def test_shown_texts_take_the_language_field(): - def lines(ja, en): - return {"japanese": ja, "english": en} - - episode = { - "commands": [ - { - "cmd": "Talk", - "TargetName": "a", - "AdvTextID": "t1", - "TargetTextIDs": ["n1"], - "lines": lines("ja1", "en1"), - }, - {"cmd": "Talk", "TargetName": "b", "AdvTextID": "t2", "TargetStatus": 1, "lines": lines("ja2", "en2")}, - { - "cmd": "Talk", - "TargetName": "c", - "AdvTextID": "t3", - "TargetStatus": 2, - "TargetTextIDs": ["n3"], - "lines": lines("ja3", "en3"), - }, - {"cmd": "Location", "lines": lines("jaL", "enL")}, - { - "cmd": "Talk", - "TargetName": "d", - "AdvTextID": "t4", - "TargetTextIDs": ["n1", "n2"], - "lines": lines("ja4", "en4"), - }, - ], - "text": { - "n1": lines("名1", "Name1"), - "n2": lines("名2", "Name2"), - "n3": lines("名3", "Name3"), - "unk": lines("?", "???"), - "split": lines("・", "&"), - }, - "title": lines("題", "Title"), - } - ids = {"_unknownCharacterNameTextId": "unk", "_splitCharacterNameTextId": "split"} - assert advui.shown_texts(episode, ids, "english") == [ - "en1", - "en2", - "en3", - "enL", - "en4", - "&", - "???", - "Name1", - "Name2", - "Title", - ] - assert advui.shown_texts(episode, ids, "japanese")[:2] == ["ja1", "ja2"] - with pytest.raises(RuntimeError, match="no title"): - advui.shown_texts({**episode, "title": None}, ids, "english") - - # -------------------------------------------------------------------------- # LocalizeManager: the serialized bytes, the object json of the shipped type tree # -------------------------------------------------------------------------- @@ -456,17 +336,6 @@ def script(self, o) -> tuple: return ("Fwk", "Fwk.Localization", "LocalizeManager") -def test_language_fonts_reads_the_serialized_fields(): - raw = localize_manager_bytes(LOCALIZE_LANGUAGES, LOCALIZE_MATERIALS) - assert textstyle.language_fonts(_Boot(raw)) == EXPECTED_LANGUAGES - - -def test_language_fonts_refuses_trailing_bytes(): - raw = localize_manager_bytes(LOCALIZE_LANGUAGES, LOCALIZE_MATERIALS) + b"\0\0\0\0" - with pytest.raises(RuntimeError, match="4 trailing bytes"): - textstyle.language_fonts(_Boot(raw)) - - def test_language_fonts_json_is_the_same_dictionary_from_the_object_json(): doc = { "_dontDestroyOnload": 1, @@ -477,18 +346,3 @@ def test_language_fonts_json_is_the_same_dictionary_from_the_object_json(): "_materialTypeList": LOCALIZE_MATERIALS, } assert textstyle.language_fonts_json(doc) == EXPECTED_LANGUAGES - - -def test_the_shipped_type_tree_reads_the_localize_manager_bytes(): - entry = load_typetrees("6000.3.12f1")["classes"][typetree_key("Fwk", "Fwk.Localization", "LocalizeManager")] - assert entry["typeHash"] == LOCALIZE_HASH - assert [1, "UInt8", "_dontDestroyOnload", ALIGN] in entry["nodes"] - raw = localize_manager_bytes(LOCALIZE_LANGUAGES, LOCALIZE_MATERIALS) - tt = read_typetree( - typetree_node(entry["nodes"]), - EndianBinaryReader(raw, "<"), - as_dict=True, - byte_size=len(raw), - check_read=True, - ) - assert textstyle.language_fonts_json(tt) == EXPECTED_LANGUAGES == textstyle.language_fonts(_Boot(raw))