diff --git a/README.en.md b/README.en.md index e6c39ee..528cc5e 100644 --- a/README.en.md +++ b/README.en.md @@ -51,6 +51,7 @@ nnnotes config check # each setting's origin and whether it is valid | `audio` | CRI cue sheet | one audio file per cue (FLAC / Ogg / WAV) + cue metadata | | `crikey` | APK | the CRI HCA keycode from the game's boot data (shows whether it was found; can write a `.hcakey`) | | `player` | APK | render-related global settings (color space, quality levels, renderers) as JSON | +| `ui` (experimental) | APK set and its embedded catalog | an offline serialized prefab library with dependency roots, AnimatorControllers, stable references and PNG/font resources for the optional ournotes-player UI preview ([format and limits](https://github.com/MetaSekaiLab/nnnotes/blob/main/docs/ui.md)) | | `live` | music ID + difficulty | a full chart directory: chart and runtime notes, 3D scene, note and effect assets, BGM and sounds, sound routing | | `web` | `--pair music:difficulty` (repeatable) or `--all`; `--live2d model` (repeatable) or `--all-live2d`; `--story episode` (repeatable) or `--all-stories`; `--region region` (repeatable) or `--all-regions` | an ournotes-player static site: shared player + per-chart / per-model / per-episode manifests + content-addressed assets; the Live2D models of the stories are built first as models, listed in `models.json`, and the story manifests reference them; one site can serve several regions, with listing texts in five languages; a story's interface texts are grouped by language, with TextMeshPro font assets generated from open fonts (the game's fonts with `--fonts game`); compressible assets (JSON, shaders, moc3, ...) are stored gzip encoded by default (`--compress br` / `none`) | | `music-data` | master data files (`--master-files` directory or `--apk-master`), or decoded master data with its manifest (`--decoded-master`) | one JSON file with every song and chart: titles and credits in five languages, bands, vocal characters, category, tags, release time, score ranks, BGM length; per difficulty the level, note counts, BPM, chart times, skill events and fever ranges; and the chart statistics the deck model ournotes-deck (built into nnnotes) measures on its whole-live simulation (the no-skill score, the weight of every score-up skill kind at every position). `--full` adds the deck model's input: every chart's runtime notes and the master data tables about cards, skills, bonuses, scores and events ([format](https://github.com/MetaSekaiLab/nnnotes/blob/main/docs/music-data.md)) | diff --git a/README.md b/README.md index ab28b70..8fdce22 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ nnnotes config check # 每项设置的来源和格式是否有效, | `audio` | CRI cue sheet | 每个 cue 一个音频文件(FLAC / Ogg / WAV)+ cue 元数据 | | `crikey` | APK | 读出游戏启动数据中的 CRI HCA 解码密钥(只显示是否找到,可写成 `.hcakey`) | | `player` | APK | 渲染相关的全局设置(色彩空间、画质等级、渲染器)JSON | +| `ui`(实验性) | APK 集合及内嵌 catalog | 离线序列化预制体库,包含依赖根对象、AnimatorController、稳定引用与 PNG/字体资源,供 ournotes-player 可选 UI 预览使用([格式与边界](https://github.com/MetaSekaiLab/nnnotes/blob/main/docs/ui.md)) | | `live` | 曲目 ID + 难度 | 完整谱面目录:谱面与运行时音符、3D 场景、音符与特效资源、BGM 与音效、声音路由 | | `web` | `--pair 曲目:难度`(可重复)或 `--all`;`--live2d 模型`(可重复)或 `--all-live2d`;`--story 剧情 ID`(可重复)或 `--all-stories`;`--region 区服`(可重复)或 `--all-regions` | ournotes-player 静态站点:共享播放器 + 每谱 / 每模型 / 每集剧情清单 + 内容寻址资源;剧情用到的 Live2D 模型先按模型构建并列入 `models.json`,剧情清单引用它们;一个站点可服务多个区服,列表文本含五种语言;剧情的界面文字按语言分组,字形由开源字体生成 TextMeshPro 字体资源(`--fonts game` 时用游戏字体);可压缩的资源(JSON、着色器、moc3 等)默认以 gzip 存储,`--compress br` / `none` 可改 | | `music-data` | masterdata 文件(`--master-files` 目录或 `--apk-master`),或带清单的解码 masterdata(`--decoded-master`) | 全部歌曲与谱面的单个 JSON:五语标题与作词作曲编曲、乐队、演唱角色、分类、标签、上线时间、评级线、BGM 时长;每个难度的等级、音符数、BPM、谱面时间、技能事件与 fever 区间;以及组卡模型 ournotes-deck(内置于 nnnotes)在整场模拟上实测的谱面统计(无技能得分、每种加分技能在每个演出位的权重)。`--full` 另附组卡模型的输入:每张谱面的运行时音符与卡牌、技能、加成、分数、活动相关的 masterdata 表([格式](docs/music-data.md)) | diff --git a/docs/ui.md b/docs/ui.md new file mode 100644 index 0000000..57c1686 --- /dev/null +++ b/docs/ui.md @@ -0,0 +1,66 @@ +# Serialized UI libraries (experimental) + +`nnnotes ui` exports an offline prefab library for the optional `ournotes-player/ui` preview module. It reads the +embedded Addressables catalog and bundle closure of the APK set you supply; it does not contact a game API. Keep +outputs outside the nnnotes and ournotes-player source repositories. The export contains game data that these +repositories and their packages do not redistribute. + +```sh +nnnotes --apk /path/to/base.apk ui --prefix EmbUI/Prefab/ --limit 10 -o /path/to/ui-data +nnnotes --apk /path/to/base.apk ui --key YOUR_EXACT_APK_KEY -o /path/to/ui-data +``` + +`--key` is repeatable. Without it, `--prefix` defaults to `EmbUI/`. `--no-dependencies` omits additional root +prefabs and AnimatorControllers; `--force` re-exports the selected keys. A failed key is retained as an explicit +index entry and makes the command exit with status 1. Existing unrelated entries of the same input stay present. + +## Inputs and regions + +The command uses the same `ApkSet` reader as the existing extractors: one APK, base.apk with adjacent splits, a +directory of splits, or an APKS/XAPK archive. Explicit `--apk` wins over the selected region's APK configuration. +The configured cache and bundle decryption settings are used when needed. Readable unencrypted local bundles do +not require a key. Default Unity resources are read from the selected APK set through the existing player reader. + +```sh +nnnotes --region jp ui --prefix EmbUI/Prefab/ --limit 10 -o /path/to/jp-ui-data +``` + +For JP, configure the actual JP APK set as described in [jp.md](jp.md). Do not point a JP region at an international +APK: this command exports the APK selected by the configuration, not a replacement fetched from that region. +Keep international and JP libraries in separate output directories. Their keys, classes and fonts can differ. + +Current real UI smoke checks used international client 1.0.1 (versionCode 25). A synthetic split-APK test verifies +the JP command/configuration path; it is not a full JP UI export or pixel-fidelity verification. Existing JP +catalog, chart, story and model validation is described separately in [jp.md](jp.md). + +## Files and resume + +`index.json` has `schema: 1`, `format: "ournotes-ui-library"`, and three lists: + +- `assets`: selected catalog keys, IDs, kind, export status, pack file and SHA-256; failures contain an error type + and message rather than a pack file. +- `embedded`: additional root GameObjects found in a selected key's dependency closure. +- `controllers`: AnimatorControllers found in that closure, keyed by actual serialized object identity. + +Pack files under `packs/`, `embedded/` and `controllers/` have `format: "ournotes-ui-pack"`, a `document`, +`resources` and `resourceBase: "../"`. Hierarchy nodes retain serialized rectangles, transforms, active flags, +component fields and preorder paths. `nodeId` and reference `nodeId` values distinguish same-named instances; +controller references carry a matching ID. Negative intermediate rectangle sizes are preserved. + +Resources contain Sprite metadata and texture/font paths. PNGs and embedded TTFs are written under `textures/` +and `fonts/`. Each used VibeMO numeric font face has separate ASCII glyph metrics in `fontMetricsByAsset`; the +legacy `fontMetrics` field names the first one. A metrics file's texture is relative to that file when its +`textureBase` is `"metrics"`. Rotated packed sprites are decoded to standalone RGBA images. + +The index records hashes of every pack and its texture/font/metrics files, as well as dependency IDs. A resumed +key is skipped only when these files and its dependency records still validate. Missing or corrupt files are +re-exported. Provenance records hashes of the APK catalog and effective APK-set member metadata, the manifest +version and configured region; it contains no credentials or server addresses. A different input is rejected +before updating an existing library. The index is replaced atomically after each key. + +## Scope + +This is inspectable serialized data, not an implementation of every game Presenter. The preview implements a +subset of layout, graphics, component binding and animation. Exported particle, shader/material, localization, +video, camera and custom component fields do not imply that the preview executes them. `runtime_verified` stays +false. The companion player documentation lists rendering limits and the optional viewer. diff --git a/src/nnnotes/cli.py b/src/nnnotes/cli.py index c7f549b..87a7cb5 100644 --- a/src/nnnotes/cli.py +++ b/src/nnnotes/cli.py @@ -755,6 +755,11 @@ def _out(c, what: str, required: bool = True) -> None: c.add_argument("-o", "--out", required=required, help=what) +def cmd_ui(args, cfg): + from . import ui + ui.command(args, cfg) + + def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="nnnotes", description="BanG Dream! Our Notes data toolkit") p.add_argument("--version", action="version", version=f"nnnotes {__version__}") @@ -987,6 +992,14 @@ def target(m, what): cli_assets.register(sub, argparse.Namespace(open_catalog=open_catalog, print_json=_print_json)) voices.register(sub, argparse.Namespace(open_catalog=open_catalog, master_dir=master_dir)) + c = sub.add_parser("ui", help="export an offline UI prefab library for ournotes-player/ui") + _out(c, "UI data directory outside the source repositories") + c.add_argument("--key", action="append", help="exact APK catalog key (repeatable)") + c.add_argument("--prefix", default="EmbUI/", help="APK UI key prefix (default EmbUI/)") + c.add_argument("--limit", type=int, help="maximum number of keys") + c.add_argument("--no-dependencies", action="store_true", help="omit additional prefab roots and controllers") + c.add_argument("--force", action="store_true", help="re-export the selected keys") + c.set_defaults(func=cmd_ui, usage=c.error) return p diff --git a/src/nnnotes/ui.py b/src/nnnotes/ui.py new file mode 100644 index 0000000..ad9b092 --- /dev/null +++ b/src/nnnotes/ui.py @@ -0,0 +1,310 @@ +"""Serialized UI library for ournotes-player/ui, from user-supplied game files. + +Outputs contain game data and belong outside either source repository. This is +an inspectable prefab preview format, not a claim of Unity runtime parity. +""" +from __future__ import annotations + +from collections import Counter +import hashlib +import json +from pathlib import Path + +from .export import Exporter +from .jsonio import dumps, write_json +from .cache import write_atomic + + +def identity(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:24] + + +def object_id(obj) -> str: + return identity(f"{obj.assets_file.name}:{obj.path_id}") + + +class UIExporter(Exporter): + """The generic Exporter plus portable images and stable PPtr identities.""" + + def ref(self, owner, pointer): + out = super().ref(owner, pointer) + if not isinstance(out, dict): + return out + if "gameObject" not in out and "transform" not in out and out.get("object") != "AnimatorController": + return out + obj = self.deref(owner, pointer) + if obj is None: + return out + if obj.type.name == "AnimatorController": + return {**out, "id": object_id(obj)} + go = obj if obj.type.name == "GameObject" else self.deref(obj, obj.read_typetree()["m_GameObject"]) + return {**out, "nodeId": object_id(go)} if go is not None else out + + def hierarchy(self, env, graph, root_tf): + nodes = super().hierarchy(env, graph, root_tf) + transforms = [] + + def walk(pid): + transforms.append(pid) + for child in graph.tf[pid]["m_Children"]: + walk(child["m_PathID"]) + + walk(root_tf) + for node, pid in zip(nodes, transforms, strict=True): + go_pid = graph.tf[pid]["m_GameObject"]["m_PathID"] + node["nodeId"] = identity(f"{self._go_file[go_pid]}:{go_pid}") + return nodes + + def sprite(self, obj): + try: + return super().sprite(obj) + except NotImplementedError as exc: + if "packing rotation" not in str(exc): + raise + tt = obj.read_typetree() + image = obj.read().image.convert("RGBA") + key = f"{obj.assets_file.name}:{obj.path_id}" + texture_key = "decoded-sprite:" + key + if not hasattr(self, "decoded_sprites"): + self.decoded_sprites = {} + self.decoded_sprites[texture_key] = image + self.sprite_recs[key] = { + "name": tt["m_Name"], "textureRef": texture_key, + "rect": {"x": 0, "y": 0, "width": image.width, "height": image.height}, + "textureRect": {"x": 0, "y": 0, "width": image.width, "height": image.height}, + "textureRectOffset": {"x": 0, "y": 0}, "border": tt["m_Border"], + "pivot": tt["m_Pivot"], "pixelsPerUnit": tt["m_PixelsToUnits"], "settingsRaw": 0, + } + return {"spriteRef": key, "name": tt["m_Name"]} + + def atlas(self, obj): + tt = obj.read_typetree() + return {"atlas": tt["m_Name"], "sprites": [self.sprite(sprite) for ptr in tt.get("m_PackedSprites", []) + if (sprite := self.deref(obj, ptr)) is not None]} + + +def _font_metrics(ex, document, out): + used = {c["m_fontAsset"]["name"] for n in document.get("nodes", []) for c in n["components"] + if isinstance(c.get("m_fontAsset"), dict) and "VibeMO" in c["m_fontAsset"].get("name", "")} + if not used: + return {} + (out / 'fonts').mkdir(parents=True, exist_ok=True) + result = {} + for obj in ex._objects.values(): + if obj.type.name != "MonoBehaviour" or ex.script_class(obj) != "TMP_FontAsset": + continue + tt = obj.read_typetree() + if tt.get("m_Name") not in used: + continue + atlas = ex.deref(obj, tt["m_AtlasTextures"][0]) + if atlas is None: + raise ValueError(f"UI font {tt['m_Name']}: no atlas") + key = f"{atlas.assets_file.name}:{atlas.path_id}" + ex.tex_objs[key] = atlas + chars = {str(c["m_Unicode"]): c["m_GlyphIndex"] for c in tt["m_CharacterTable"] if 32 <= c["m_Unicode"] < 127} + glyphs = {str(g["m_Index"]): g for g in tt["m_GlyphTable"] if g["m_Index"] in chars.values()} + rel = f"fonts/{object_id(obj)}.json" + write_json(out / rel, {"name": tt["m_Name"], "face": tt["m_FaceInfo"], "characters": chars, + "glyphs": glyphs, "texture": f"../textures/{identity(key)}.png", "textureBase": "metrics"}) + result[tt['m_Name']] = rel + if used - result.keys(): + raise ValueError("UI font metrics not present in the loaded dependency closure") + return result + + +def resources(ex, document, out: Path, written=None) -> dict: + written = written if written is not None else set() + result = {"textures": {}, "sprites": {}, "fonts": {}} + metrics = _font_metrics(ex, document, out) + if metrics: + result["fontMetrics"] = next(iter(metrics.values())) + result["fontMetricsByAsset"] = metrics + (out / "textures").mkdir(parents=True, exist_ok=True) + for key, obj in ex.tex_objs.items(): + rel = f"textures/{identity(key)}.png" + if rel not in written or not (out / rel).is_file(): + obj.read().image.save(out / rel, format="PNG") + written.add(rel) + result["textures"][key] = rel + for key, sprite in ex.sprite_recs.items(): + texture_key = sprite.get("textureRef") + if texture_key is None: + texture_key = f"{sprite['tex'].assets_file.name}:{sprite['tex'].path_id}" + result["sprites"][key] = {**{k: v for k, v in sprite.items() if k != "tex"}, "textureRef": texture_key} + for key, image in getattr(ex, "decoded_sprites", {}).items(): + rel = f"textures/{identity(key)}.png" + if rel not in written or not (out / rel).is_file(): + image.save(out / rel, format="PNG") + written.add(rel) + result["textures"][key] = rel + for obj in ex._objects.values(): + if obj.type.name != "Font": + continue + font = obj.read() + if font.m_FontData: + rel = f"fonts/{object_id(obj)}.ttf" + (out / "fonts").mkdir(exist_ok=True) + if rel not in written or not (out / rel).is_file(): + (out / rel).write_bytes(bytes(font.m_FontData)) + written.add(rel) + result["fonts"][font.m_Name] = rel + return result + + +def _record(out, ident, name, kind, document, resource_table, directory, **extra): + rel = f"{directory}/{ident}.json" + write_json(out / rel, {"schema": 1, "format": "ournotes-ui-pack", "resourceBase": "../", + "document": document, "resources": resource_table}) + nodes = document.get("nodes", []) + files = {rel, *resource_table.get('textures', {}).values(), *resource_table.get('fonts', {}).values(), + *resource_table.get('fontMetricsByAsset', {}).values()} + if resource_table.get('fontMetrics'): + files.add(resource_table['fontMetrics']) + return {"id": ident, "name": name, "kind": kind, "file": rel, "status": "exported", + "sha256": hashlib.sha256((out / rel).read_bytes()).hexdigest(), "node_count": len(nodes), + "classes": dict(Counter(c.get("class", c["type"]) for n in nodes for c in n["components"])), + "files": {name: hashlib.sha256(_output_file(out, name).read_bytes()).hexdigest() for name in sorted(files)}, + "runtime_verified": False, **extra} + + +def _output_file(out, name): + file = (out / name).resolve() + if not file.is_relative_to(out): + raise ValueError("UI index contains a file outside its output directory") + return file + + +def _valid_record(out, record): + files = record.get('files') if record else None + if not files or record.get('status') != 'exported': + return False + if record.get('file') not in files: + return False + _output_file(out, record['file']) + for name, expected in files.items(): + file = _output_file(out, name) + if not file.is_file() or hashlib.sha256(file.read_bytes()).hexdigest() != expected: + return False + return True + + +def export_library(cat, out, *, player=None, keys=None, prefix="EmbUI/", dependencies=True, force=False, provenance=None): + """Incrementally export keys, their unregistered prefab roots and controllers. + + Resume verifies packs, dependency packs and every texture/font hash; failures stay explicit in index.json. + Existing unrelated entries remain present when exporting an additional key. + """ + out = Path(out).resolve() + out.mkdir(parents=True, exist_ok=True) + for directory in ("packs", "embedded", "controllers", "fonts", "textures"): + (out / directory).mkdir(exist_ok=True) + keys = sorted(set(keys if keys is not None else cat.keys(prefix))) + old = json.loads((out / "index.json").read_text(encoding="utf-8")) if (out / "index.json").is_file() else {} + provenance = provenance or {'apk_catalog_sha256': hashlib.sha256(cat._sources.get('apk', cat._sources['remote'])).hexdigest()} + if old and old.get('provenance') != provenance: + raise ValueError("UI output belongs to different input data; use a separate output directory") + assets = {e["key"]: e for e in old.get("assets", [])} + embedded = {e["id"]: e for e in old.get("embedded", [])} + controllers = {e["id"]: e for e in old.get("controllers", [])} + written, processed_roots, processed_controllers = set(), set(), set() + + def exporter(): + return UIExporter(cat, out, player=player, textures="deferred", inline_meshes=False, + stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet")) + + def save_index(): + rows = sorted(assets.values(), key=lambda e: e["key"]) + index = {"schema": 1, "format": "ournotes-ui-library", "scope": "serialized UI assets and dependency roots", + "counts": dict(Counter(e["status"] for e in rows)), "assets": rows, + "embedded": sorted(embedded.values(), key=lambda e: e["id"]), + "controllers": sorted(controllers.values(), key=lambda e: e["id"]), "runtime_verified": False, + "provenance": provenance} + write_atomic(out / 'index.json', dumps(index, indent=1, ensure_ascii=False).encode('utf-8')) + return index + + for key in keys: + previous = assets.get(key) + if not force and _valid_record(out, previous): + refs = previous.get('dependencies', {}) + valid_deps = (not dependencies or previous.get('dependency_export') and + all(_valid_record(out, embedded.get(i)) for i in refs.get('embedded', [])) and + all(_valid_record(out, controllers.get(i)) for i in refs.get('controllers', []))) + if valid_deps: + continue + ident, name = identity(key), key.rsplit("/", 1)[-1] + try: + ex = exporter() + refs = {'embedded': [], 'controllers': []} + document = ex.export_key(key) + env, graph = ex.load(key) + main_roots = {n["nodeId"] for n in document.get("nodes", []) if n["path"].count("/") == 0} + if dependencies and document.get("nodes"): + for pid, transform in graph.tf.items(): + if transform["m_Father"]["m_PathID"]: + continue + go_pid = transform["m_GameObject"]["m_PathID"] + root_id = identity(f"{ex._go_file[go_pid]}:{go_pid}") + if root_id in main_roots: + continue + refs['embedded'].append(root_id) + go_name = graph.go[go_pid]["m_Name"] + if root_id not in processed_roots and (force or not _valid_record(out, embedded.get(root_id))): + doc = {"key": go_name, "nodes": ex.hierarchy(env, graph, pid)} + embedded[root_id] = _record(out, root_id, go_name, "prefab", doc, resources(ex, doc, out, written), "embedded", source_key=key) + processed_roots.add(root_id) + if dependencies: + for obj in env.objects: + if obj.type.name != "AnimatorController": + continue + cid = object_id(obj) + refs['controllers'].append(cid) + if cid in processed_controllers or not force and _valid_record(out, controllers.get(cid)): + continue + fresh = exporter() + fresh.load(key) + owned = fresh._objects[(obj.assets_file.name, obj.path_id)] + doc = fresh.controller(owned) + controllers[cid] = _record(out, cid, doc["name"], "controller", doc, resources(fresh, doc, out, written), "controllers", source_key=key) + processed_controllers.add(cid) + kind = cat._entry(key)["internal_id"].rsplit(".", 1)[-1] + assets[key] = _record(out, ident, name, kind, document, resources(ex, document, out, written), "packs", key=key, + dependencies=refs, dependency_export=dependencies) + except Exception as exc: + assets[key] = {"id": ident, "name": name, "key": key, "status": "failed", "error_type": type(exc).__name__, "error": str(exc), "runtime_verified": False} + save_index() + return save_index() + + +def command(args, cfg): + from .cli import bundle_key, player_data + from .apkset import ApkSet + from .catalog import Catalog, APK_CATALOG + from .config import ConfigError + from .player import MANIFEST_IN_APK, manifest_version_name, manifest_version_code + if args.limit is not None and args.limit < 1: + args.usage("--limit must be positive") + apk = cfg.require_path("paths", "apk") + with ApkSet(apk) as source: + catalog_data = source.read(APK_CATALOG) + members = sorted((i.filename, i.CRC, i.file_size) for i in source.infolist()) + manifest = source.read(MANIFEST_IN_APK) if MANIFEST_IN_APK in source.namelist() else b'' + provenance = {'apk_catalog_sha256': hashlib.sha256(catalog_data).hexdigest(), + 'apk_members_sha256': hashlib.sha256(dumps(members).encode('utf-8')).hexdigest(), + 'region': cfg.get('catalog', 'region'), + 'client': {'versionName': manifest_version_name(manifest), 'versionCode': manifest_version_code(manifest)}} + cat = Catalog(catalog_data, cfg.require_path("paths", "cache"), bundle_key=lambda: bundle_key(cfg), apk=apk) + keys = args.key or cat.keys(args.prefix) + if args.limit is not None: + keys = keys[:args.limit] + unknown = sorted(set(keys) - set(cat.keys(""))) + if unknown: + raise ConfigError("UI key not in the APK catalog: " + ", ".join(unknown)) + try: + index = export_library(cat, args.out, player=player_data(cfg), keys=keys, dependencies=not args.no_dependencies, + force=args.force, provenance=provenance) + except ValueError as exc: + raise ConfigError(str(exc)) from None + print(json.dumps({"index": str(Path(args.out).resolve() / "index.json"), "counts": index["counts"], + "embedded": len(index["embedded"]), "controllers": len(index["controllers"])})) + if index["counts"].get("failed"): + raise SystemExit(1) diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..c36030c --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,183 @@ +"""UI export/resume contracts with synthetic objects, images and split packages only.""" +import json +from types import SimpleNamespace as NS +import zipfile + +from PIL import Image +import pytest + +from nnnotes import cli, ui +from nnnotes.config import Config + +KEY = 'EmbUI/Example/Box' + + +class Catalog: + def __init__(self, data=b'synthetic catalog', *args, **kwargs): + self._sources = {'remote': data, 'apk': data} + + def keys(self, prefix): + return [k for k in [KEY, KEY + 'Alias'] if k.startswith(prefix)] + + def _entry(self, key): + return {'internal_id': 'Assets/Example/Box.prefab'} + + +class Exporter: + exports = 0 + controller_exports = 0 + fail = False + + def __init__(self, *args, **kwargs): + self.controller_obj = NS(type=NS(name='AnimatorController'), assets_file=NS(name='synthetic'), path_id=3) + texture = NS(type=NS(name='Texture2D'), read=lambda: NS(image=Image.new('RGBA', (2, 2), (8, 16, 24, 255)))) + self.tex_objs = {'synthetic:7': texture} + self.sprite_recs = {} + self._objects = {('synthetic', 3): self.controller_obj} + self._go_file = {1: 'synthetic', 2: 'synthetic'} + + def export_key(self, key): + type(self).exports += 1 + if self.fail: + raise RuntimeError('synthetic unsupported component') + return {'key': key, 'nodes': [{'nodeId': ui.identity('synthetic:1'), 'path': 'Box', + 'rect': {'m_SizeDelta': {'x': -30, 'y': 90}}, 'components': []}]} + + def load(self, key): + transforms = {10: {'m_Father': {'m_PathID': 0}, 'm_GameObject': {'m_PathID': 1}}, + 20: {'m_Father': {'m_PathID': 0}, 'm_GameObject': {'m_PathID': 2}}} + return NS(objects=[self.controller_obj]), NS(tf=transforms, go={1: {'m_Name': 'Box'}, 2: {'m_Name': 'Extra'}}) + + def hierarchy(self, env, graph, pid): + return [{'nodeId': ui.identity('synthetic:2'), 'path': 'Extra', 'components': []}] + + def controller(self, obj): + assert obj is self.controller_obj, 'the new exporter must use its own loaded object' + type(self).controller_exports += 1 + return {'name': 'Example', 'clips': []} + + +@pytest.fixture +def exporter(monkeypatch): + Exporter.exports = Exporter.controller_exports = 0 + Exporter.fail = False + monkeypatch.setattr(ui, 'UIExporter', Exporter) + return Exporter + + +def read(path): + return json.loads(path.read_text(encoding='utf-8')) + + +def test_export_preserves_signed_rects_and_stable_dependency_identity(tmp_path, exporter): + index = ui.export_library(Catalog(), tmp_path, keys=[KEY]) + entry = index['assets'][0] + assert entry['kind'] == 'prefab' + pack = read(tmp_path / entry['file']) + assert pack['document']['nodes'][0]['rect']['m_SizeDelta']['x'] == -30 + assert len(index['embedded']) == len(index['controllers']) == 1 + assert entry['dependencies']['controllers'] == [index['controllers'][0]['id']] + assert all((tmp_path / name).is_file() for name in entry['files']) + assert index['runtime_verified'] is False + + +@pytest.mark.parametrize('damage', ['pack', 'texture', 'controller', 'embedded']) +def test_resume_repairs_corrupt_or_missing_dependencies(tmp_path, exporter, damage): + first = ui.export_library(Catalog(), tmp_path, keys=[KEY]) + before = exporter.exports + ui.export_library(Catalog(), tmp_path, keys=[KEY]) + assert exporter.exports == before + entry = first['assets'][0] + target = entry['file'] if damage == 'pack' else ('textures/' + ui.identity('synthetic:7') + '.png' if damage == 'texture' + else first['controllers' if damage == 'controller' else 'embedded'][0]['file']) + (tmp_path / target).write_bytes(b'corrupt') + result = ui.export_library(Catalog(), tmp_path, keys=[KEY]) + assert exporter.exports == before + 1 + assert (tmp_path / target).read_bytes() != b'corrupt' + assert ui._valid_record(tmp_path, result['assets'][0]) + + +def test_enabling_dependency_export_after_a_minimal_export(tmp_path, exporter): + first = ui.export_library(Catalog(), tmp_path, keys=[KEY], dependencies=False) + assert not first['controllers'] and not first['embedded'] + complete = ui.export_library(Catalog(), tmp_path, keys=[KEY]) + assert len(complete['controllers']) == len(complete['embedded']) == 1 + + +def test_force_exports_a_shared_controller_once_and_keeps_unrelated_entries(tmp_path, exporter): + ui.export_library(Catalog(), tmp_path, keys=[KEY]) + exporter.controller_exports = 0 + result = ui.export_library(Catalog(), tmp_path, force=True) + assert len(result['assets']) == 2 + assert exporter.controller_exports == 1 + + +def test_a_different_input_cannot_mix_with_an_existing_library(tmp_path, exporter): + ui.export_library(Catalog(), tmp_path, keys=[KEY]) + original = (tmp_path / 'index.json').read_bytes() + with pytest.raises(ValueError, match='separate output directory'): + ui.export_library(Catalog(b'another release'), tmp_path, keys=[KEY]) + assert (tmp_path / 'index.json').read_bytes() == original + + +def test_resume_rejects_paths_outside_the_output(tmp_path, exporter): + result = ui.export_library(Catalog(), tmp_path, keys=[KEY]) + result['assets'][0]['files']['../outside.png'] = 'invalid' + (tmp_path / 'index.json').write_text(json.dumps(result), encoding='utf-8') + with pytest.raises(ValueError, match='outside'): + ui.export_library(Catalog(), tmp_path, keys=[KEY]) + + +def test_failure_is_an_explicit_index_entry(tmp_path, exporter): + exporter.fail = True + result = ui.export_library(Catalog(), tmp_path, keys=[KEY]) + assert result['counts'] == {'failed': 1} + assert result['assets'][0]['error_type'] == 'RuntimeError' + + +def test_cli_selects_the_jp_split_apk_and_keeps_public_provenance(tmp_path, exporter, monkeypatch, capsys): + from nnnotes import catalog + apk = tmp_path / 'jp' + apk.mkdir() + with zipfile.ZipFile(apk / 'base.apk', 'w') as z: + z.writestr('assets/boot-data', b'synthetic boot') + with zipfile.ZipFile(apk / 'split_Unity.apk', 'w') as z: + z.writestr(catalog.APK_CATALOG, b'synthetic catalog') + monkeypatch.setattr(catalog, 'Catalog', Catalog) + monkeypatch.setattr(cli, 'player_data', lambda cfg: None) + cfg = Config({'catalog': {'region': 'jp'}, 'servers': {'jp': {'apk': str(apk)}}, + 'paths': {'apk': 'wrong-global.apk', 'cache': str(tmp_path / 'cache')}}, environ={}) + args = cli.build_parser().parse_args(['ui', '--key', KEY, '-o', str(tmp_path / 'out')]) + ui.command(args, cfg) + index = read(tmp_path / 'out/index.json') + assert index['provenance']['region'] == 'jp' + assert len(index['provenance']['apk_members_sha256']) == 64 + assert 'exported' in capsys.readouterr().out + + +def test_limit_is_validated_before_any_apk_read(tmp_path, capsys): + args = cli.build_parser().parse_args(['ui', '--limit', '0', '-o', str(tmp_path)]) + with pytest.raises(SystemExit) as exc: + ui.command(args, Config(environ={})) + assert exc.value.code == 2 + assert '--limit must be positive' in capsys.readouterr().err + + +def test_all_used_numeric_font_faces_keep_independent_metrics_and_resource_hashes(tmp_path): + atlas = NS(type=NS(name='Texture2D'), assets_file=NS(name='synthetic-font'), path_id=9, + read=lambda: NS(image=Image.new('RGBA', (2, 2), (128, 128, 128, 128)))) + objects = {} + names = ['VibeMO Synthetic Regular', 'VibeMO Synthetic Medium'] + for i, name in enumerate(names): + tree = {'m_Name': name, 'm_AtlasTextures': [{}], 'm_FaceInfo': {'m_PointSize': 32}, + 'm_CharacterTable': [{'m_Unicode': 65, 'm_GlyphIndex': 1}], 'm_GlyphTable': [{'m_Index': 1}]} + objects[name] = NS(type=NS(name='MonoBehaviour'), path_id=i, assets_file=NS(name='synthetic-font'), read_typetree=lambda tree=tree: tree) + ex = NS(_objects=objects, tex_objs={}, sprite_recs={}, script_class=lambda o: 'TMP_FontAsset', deref=lambda o, ptr: atlas) + doc = {'nodes': [{'components': [{'type': 'MonoBehaviour', 'class': 'TextMeshProUGUI', 'm_fontAsset': {'name': name}}]} for name in names]} + table = ui.resources(ex, doc, tmp_path) + assert set(table['fontMetricsByAsset']) == set(names) + assert len(set(table['fontMetricsByAsset'].values())) == 2 + for file in table['fontMetricsByAsset'].values(): + data = read(tmp_path / file) + assert data['textureBase'] == 'metrics' + assert (tmp_path / file).parent.joinpath(data['texture']).is_file()