From 4c59aaaaa66e0b002b0b7291bb23e9e9f250eaa3 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 17 Oct 2026 15:27:47 +0800 Subject: [PATCH 1/5] feat(pipeline): fence font references in product closures Keep references into font bundles as named entries in object tables. Allow products to retain a root closure while reading additional objects as assembly inputs. --- src/nnnotes/content/prefabs.py | 22 ++++- src/nnnotes/pipeline/assemble.py | 43 ++++++++- src/nnnotes/pipeline/cristages.py | 4 +- src/nnnotes/pipeline/objexport.py | 4 +- src/nnnotes/pipeline/units.py | 121 ++++++++++++++++++++---- src/nnnotes/schemas/objects.schema.json | 20 ++++ 6 files changed, 184 insertions(+), 30 deletions(-) diff --git a/src/nnnotes/content/prefabs.py b/src/nnnotes/content/prefabs.py index 6569466..7d13e3a 100644 --- a/src/nnnotes/content/prefabs.py +++ b/src/nnnotes/content/prefabs.py @@ -49,6 +49,7 @@ "key_object", "mat4", "mesh_roles", + "mono_json", "prefab_ids", "ref_id", "root_game_object", @@ -68,8 +69,12 @@ def ids(self, cls: str | None = None) -> list[str]: ... def key(self, key: str) -> list[str]: ... + def keys(self) -> list[str]: ... + def info(self, oid: str) -> dict: ... + def fenced(self, oid: str) -> dict | None: ... + def bytes(self, oid: str, role: str) -> bytes: ... def json(self, oid: str, role: str = "json") -> Any: ... @@ -94,13 +99,28 @@ def has_json(objects: Objects, oid: str) -> bool: def script_class(objects: Objects, oid: str) -> str | None: - """The script class of a MonoBehaviour outside a hierarchy (its `json`'s `$script`), None without one.""" + """The script class of a MonoBehaviour or a contained component, None without one.""" + if oid in objects and objects.info(oid).get("in"): + file, pid = oid.rsplit(":", 1) + hit = component_ref(objects, "", {"$ref": {"file": file, "pathId": int(pid)}}) + return component_class(hit[2]) if hit is not None else None if not has_json(objects, oid): return None entry = objects.json(oid).get("$script") return entry.rsplit("|", 1)[-1] if isinstance(entry, str) else None +def mono_json(objects: Objects, oid: str) -> dict: + """The serialized fields of a MonoBehaviour, including a component stored in a prefab's document.""" + if objects.info(oid).get("in"): + file, pid = oid.rsplit(":", 1) + hit = component_ref(objects, "", {"$ref": {"file": file, "pathId": int(pid)}}) + if hit is None or hit[2]["type"] != "MonoBehaviour": + raise ValueError(f"{oid}: not a MonoBehaviour component") + return {"$script": hit[2].get("script"), **hit[2]["fields"]} + return objects.json(oid) + + def root_game_object(objects: Objects, what: str) -> str: """The one GameObject among the table's roots (ValueError naming `what` otherwise).""" gos = [o for o in objects.roots if o in objects and objects.info(o).get("class") == "GameObject"] diff --git a/src/nnnotes/pipeline/assemble.py b/src/nnnotes/pipeline/assemble.py index 586b943..951013e 100644 --- a/src/nnnotes/pipeline/assemble.py +++ b/src/nnnotes/pipeline/assemble.py @@ -8,10 +8,12 @@ every unit an item of a reached unit references (contract.item `refs`; an atlas reached through a reference does not reach the sprites it packs, units.PACKED), through the unity.export results; a sprite whose atlas is in another bundle has its image from sprite.crop. A reached unit that the request did not export is an error of the request; a -referenced object that no census holds is listed as missing. +referenced object that no census holds is listed as missing. A referenced object of a fenced bundle (units.fences) that +the walk does not reach is listed as fenced. A task's inputs are the object table of what it reaches ("objects", nnnotes.objects/1: every object with its class, -name, item status and files, the contained ones with the object holding them, and the root objects of each of its keys), +name, item status and files, the contained ones with the object holding them, the root objects of each of its keys, +and the font objects the fence cuts off as "fenced"), the APK files the stage names in `APK` ("apk:", from the fact "apk"), the cue sheets of the catalog keys `cue_sheets(subject)` gives, the movies of the catalog keys `movies(subject)` gives and what `more_inputs` adds (a master record as "master": master_input; parameters' data); it depends on the unity.export and sprite.crop tasks it @@ -166,6 +168,7 @@ def reach(env, roots, results: _Results | None = None, keys: dict | None = None) objects: dict[str, dict] = {} read = [] crops: dict[str, list] = {} # atlas object id -> sprite object ids + blocked: dict[str, dict] = {} # objects a reached object references across the font fence: {id: class, name} while todo: s = todo.pop() if s in seen: @@ -186,10 +189,23 @@ def reach(env, roots, results: _Results | None = None, keys: dict | None = None) t = table.subject_of(r) if t is None: missing.add(r) + elif table.fences(s, t): + blocked[r] = table.info[r] elif t not in seen and table.reaches(s, t, s in start): todo.append(t) for atlas, sprites in sorted(crops.items()): s = table.subject_of(atlas) + # a sprite of an unfenced bundle takes no image from a fenced atlas the walk does not reach + cut = { + o + for o in sprites + if s is not None and s not in seen and (src := table.subject_of(o)) is not None and table.fences(src, s) + } + if cut: + blocked[atlas] = table.info[atlas] + sprites = [o for o in sprites if o not in cut] + if not sprites: + continue tid = contract.task_id(CROP, s) if s is not None else None if tid is None or not env.known(tid): # the atlas is not exported: its sprites have no image missing.add(atlas) @@ -205,6 +221,7 @@ def reach(env, roots, results: _Results | None = None, keys: dict | None = None) "roots": sorted(set(roots)), "keys": {k: sorted(v) for k, v in sorted((keys or {}).items())}, "objects": dict(sorted(objects.items())), + "fenced": {r: v for r, v in sorted(blocked.items()) if r not in objects}, "missing": sorted(missing), } size = sum(f["size"] for e in objects.values() for f in e.get("files", {}).values()) @@ -287,6 +304,7 @@ def __init__(self, doc: dict, depot, sheets: dict | None = None, movies: dict | self.roots: list[str] = doc["roots"] self._keys: dict[str, list[str]] = doc["keys"] self._objects: dict = doc["objects"] + self._fenced: dict = doc["fenced"] self._json: dict = {} self._sheets: dict[str, Sheet] = sheets or {} self._movies: dict[str, Movie] = movies or {} @@ -312,6 +330,10 @@ def key(self, key: str) -> list[str]: except KeyError: raise KeyError(f"{key} is not a key of the product") from None + def keys(self) -> list[str]: + """The catalog keys of the product, sorted (each with its root objects, possibly none).""" + return sorted(self._keys) + def __contains__(self, oid: str) -> bool: return oid in self._objects @@ -325,6 +347,11 @@ def info(self, oid: str) -> dict: except KeyError: raise KeyError(f"object {oid} is not in the product's object table") from None + def fenced(self, oid: str) -> dict | None: + """The class and name of the object `oid` when a reached object references it across the font fence (None: + no reached object does).""" + return self._fenced.get(oid) + def bytes(self, oid: str, role: str) -> bytes: files = self.info(oid).get("files") or {} if role not in files: @@ -344,11 +371,13 @@ class Assembly: """What `assemble` makes: the product's files {path: (bytes or Stored, extension, kind)} and what of the object table they use: `objects` the object ids (None: every object of the table), `roles` {object id: the roles of its files the product keeps} for an object of which it uses some files only (a shader's programs for one graphics - API). The product's object table, and so its manifest, holds only those.""" + API). `table` optionally supplies the table of the retained roots when the inputs also include objects read only + during assembly. The product's object table, and so its manifest, holds only those.""" files: dict objects: set | None = None roles: dict = field(default_factory=dict) + table: dict | None = None def _prune(tid: str, objects: dict, made: Assembly) -> dict: @@ -447,7 +476,9 @@ def reach(self, subject: str, env) -> Reach: keys = self.keys(subject, env) named = (self.root_objects(subject, env), self.resources(subject, env), self.builtins(subject, env)) if not keys and not any(named): - return Reach({"schema": contract.OBJECTS, "roots": [], "keys": {}, "objects": {}, "missing": []}) + return Reach( + {"schema": contract.OBJECTS, "roots": [], "keys": {}, "objects": {}, "fenced": {}, "missing": []} + ) roots = units.root_objects(env, keys, *named) by_key = {k: units.root_objects(env, [k]) for k in keys} sig = (tuple(roots), tuple((k, tuple(v)) for k, v in sorted(by_key.items())), id(units.table(env))) @@ -546,7 +577,9 @@ def run(self, task, depot) -> Output: else: content = depot.add(body, ext) arts.append(contract.artifact(aid, content, prov, {"kind": kind})) - if made.objects is not None or made.roles: + if made.table is not None: + doc = made.table + if made.table is not None or made.objects is not None or made.roles: doc = dict(doc, objects=_prune(task.id, doc["objects"], made)) data = contract.encode(doc) aid = contract.artifact_id(task.id, PRODUCT_TABLE) diff --git a/src/nnnotes/pipeline/cristages.py b/src/nnnotes/pipeline/cristages.py index 3991e02..1c1eb8f 100644 --- a/src/nnnotes/pipeline/cristages.py +++ b/src/nnnotes/pipeline/cristages.py @@ -289,7 +289,7 @@ class AudioStage(_CriStage): name = AUDIO version = 1 - after = (EXPORT,) + after = (EXPORT, units.ADDRESSES) CLASS = "ACB" PARAMS = {"flac": {"level": FLAC_LEVEL}} @@ -475,7 +475,7 @@ class MovieStage(_CriStage): name = MOVIE version = 2 - after = (EXPORT,) + after = (EXPORT, units.ADDRESSES) CLASS = "USM" PARAMS = {"format": "mkv", "flac": {"level": FLAC_LEVEL}} diff --git a/src/nnnotes/pipeline/objexport.py b/src/nnnotes/pipeline/objexport.py index 1644ff1..e303232 100644 --- a/src/nnnotes/pipeline/objexport.py +++ b/src/nnnotes/pipeline/objexport.py @@ -382,7 +382,7 @@ class ExportStage(Stage): name = EXPORT version = 2 - after = (CENSUS, SCRIPTS) + after = (CENSUS, SCRIPTS, units.ADDRESSES) PARAMS = {"blobMin": 4096, "png": {"level": 6}} def __init__(self, reader: Impl | None = None): @@ -517,7 +517,7 @@ class SpriteCropStage(Stage): name = CROP version = 1 - after = (EXPORT,) + after = (EXPORT, units.ADDRESSES) PARAMS = {"png": {"level": 6}} CROP_ATOMS = ("png.encode", "sprite.crop") diff --git a/src/nnnotes/pipeline/units.py b/src/nnnotes/pipeline/units.py index 04a4073..e359965 100644 --- a/src/nnnotes/pipeline/units.py +++ b/src/nnnotes/pipeline/units.py @@ -19,6 +19,12 @@ APK's built-in files, compared ignoring case; a selector names objects of the built-in files by class or script class; one without objects adds none) and the objects named. The products of a request start from them (pipeline.assemble). + +The font fence: the bundles holding a catalog key under a font folder (unity.fontsettings: "EmbFont/" and "Font/"; +the bundles `font_bundles` finds in the address tables of link.addresses) are fenced. A reference of a unit of an +unfenced bundle into a fenced one does not reach that unit, neither from a root nor from a unit the walk reaches; a +unit of a fenced bundle reaches what it references as usual, fenced bundles included (a font asset's fallbacks). A +product's object table lists the objects its reached objects reference this way (`fenced`, pipeline.assemble). """ from __future__ import annotations @@ -26,12 +32,14 @@ from collections import Counter from dataclasses import dataclass +from ..unity.fontsettings import FONT_ADDRESS, FONT_PREFIX from . import contract, link, views from .census import CensusStage from .stages import Pending CENSUS = CensusStage.name ADDRESSES = views.ADDRESSES_STAGE +FONT_FOLDERS = (FONT_PREFIX, FONT_ADDRESS) # the catalog keys of the font folders: the bundles holding them are fenced # a unit's root class -> the class of the units it packs, which it does not reach when it is reached itself PACKED = {"SpriteAtlas": "Sprite"} @@ -61,12 +69,23 @@ def classes(self) -> tuple: class Units: """The units of a request: `units` {subject: Unit} of the requested units; `of` {object id: subject} of the objects of every unit of the censuses read; `files` {serialized file: stable bundle name}; `objects` {stable - bundle name: its objects}; `kinds` {subject: root class} of every unit of the censuses read; `per_bundle` the number + bundle name: its objects}; `kinds` {subject: root class} of every unit of the censuses read; `fenced` the subjects + of the units of the fenced bundles and `info` {object id: {class, name}} of their objects; `per_bundle` the number of requested units per bundle; `classes` the objects of the requested units per class.""" - def __init__(self, units: dict, of: dict, files: dict, objects: dict, kinds: dict | None = None): + def __init__( + self, + units: dict, + of: dict, + files: dict, + objects: dict, + kinds: dict | None = None, + fenced: frozenset = frozenset(), + info: dict | None = None, + ): self.units, self.of, self.files, self.objects = units, of, files, objects self.kinds: dict[str, str] = kinds if kinds is not None else {s: u.cls for s, u in units.items()} + self.fenced, self.info = frozenset(fenced), info or {} self.per_bundle = Counter(u.bundle for u in units.values()) self.classes: Counter = Counter() for u in units.values(): @@ -88,13 +107,25 @@ def subject_of(self, oid: str) -> str | None: def reaches(self, source: str, target: str, start: bool) -> bool: """A reference of the unit `source` to the unit `target` makes the target reachable (_reaches).""" - return _reaches(self.kinds, source, target, start) + return _reaches(self.kinds, self.fenced, source, target, start) + + def fences(self, source: str, target: str) -> bool: + """The font fence cuts a reference of the unit `source` to the unit `target` (_fences).""" + return _fences(self.fenced, source, target) + +def _fences(fenced, source: str, target: str) -> bool: + """The font fence: a reference of an unfenced unit into a fenced one does not reach it (`fenced`: the subjects of + the units of the fenced bundles).""" + return target in fenced and source not in fenced -def _reaches(kinds: dict, source: str, target: str, start: bool) -> bool: + +def _reaches(kinds: dict, fenced, source: str, target: str, start: bool) -> bool: """A reference of the unit `source` to the unit `target` ({subject: root class} `kinds`) makes the target - reachable: always from a unit the walk starts from (`start`), else unless the source packs units of the target's - class (PACKED).""" + reachable: never across the font fence (_fences), always from a unit the walk starts from (`start`), else unless + the source packs units of the target's class (PACKED).""" + if _fences(fenced, source, target): + return False return start or PACKED.get(kinds.get(source, "")) != kinds.get(target) @@ -155,16 +186,27 @@ def _census_units(stable: str, doc: dict, need_refs: bool): return units, of, {f["name"]: stable for f in doc["files"]}, refs -def build(censuses, bundles, selected, classes=None, roots=()) -> Units: +def _census_info(doc: dict) -> dict: + """{object id: {class, name}} of the objects of a census document (an object without m_Name has an empty name).""" + return { + contract.object_id(f["name"], o["pathId"]): {"class": o["class"], "name": o.get("name") or ""} + for f in doc["files"] + for o in f["objects"] + } + + +def build(censuses, bundles, selected, classes=None, roots=(), fenced=()) -> Units: """The Units of a request over `censuses` [(stable bundle name, census document)] of the bundles `bundles`, from - the units of the bundles `selected` and of the objects `roots` (module documentation).""" - selected = set(selected) + the units of the bundles `selected` and of the objects `roots`; `fenced` the stable names of the fenced bundles + (module documentation).""" + selected, fenced = set(selected), set(fenced) every = selected >= set(bundles) # no reference can add a unit: none is read units: dict[str, Unit] = {} of: dict[str, str] = {} files: dict[str, str] = {} refs: dict[str, set] = {} objects: dict[str, int] = {} + info: dict[str, dict] = {} for stable, doc in censuses: objects[stable] = int(doc["objects"]) u, o, f, r = _census_units(stable, doc, need_refs=not every) @@ -172,7 +214,10 @@ def build(censuses, bundles, selected, classes=None, roots=()) -> Units: of.update(o) files.update(f) refs.update(r) + if stable in fenced: + info.update(_census_info(doc)) kinds = {s: u.cls for s, u in units.items()} + fenced_units = frozenset(s for s, u in units.items() if u.bundle in fenced) if every: want = set(units) else: @@ -184,30 +229,63 @@ def build(censuses, bundles, selected, classes=None, roots=()) -> Units: source = todo.pop() for oid in refs.get(source, ()): s = of.get(oid) - if s is not None and s not in want and _reaches(kinds, source, s, source in start): + if s is not None and s not in want and _reaches(kinds, fenced_units, source, s, source in start): want.add(s) todo.append(s) if classes is not None: keep = set(classes) want = {s for s in want if units[s].cls in keep} - return Units({s: units[s] for s in sorted(want)}, of, files, objects, kinds) + return Units({s: units[s] for s in sorted(want)}, of, files, objects, kinds, fenced_units, info) _MEMO: list = [] # [(signature, Units)] of the last table -_ADDRESSES: dict = {} # {sha256: AddressIndex} of the last address table read +_DOCS: dict = {} # {sha256: address table} of the last one read +_INDEXES: dict = {} # {sha256: AddressIndex} of the last one indexed +_FENCE: list = [] # [(content ids of the address tables, fenced bundles)] of the last call of font_bundles + + +def address_sha(env, subject: str) -> str: + """The content id of the address table of link.addresses for the catalog `subject` (Pending while not run).""" + return env.artifact(contract.task_id(ADDRESSES, subject), "addresses")["content"]["sha256"] + + +def address_doc(env, subject: str) -> dict: + """The address table (nnnotes.addresses/3) of the catalog `subject` (Pending while link.addresses has not run).""" + sha = address_sha(env, subject) + doc = _DOCS.get(sha) + if doc is None: + doc = contract.loads(env.depot.read(sha)) + _DOCS.clear() + _DOCS[sha] = doc + return doc def address_index(env, subject: str) -> views.AddressIndex: - """The AddressIndex of the result of link.addresses for the catalog `subject` (Pending while it has not run).""" - sha = env.artifact(contract.task_id(ADDRESSES, subject), "addresses")["content"]["sha256"] - index = _ADDRESSES.get(sha) + """The AddressIndex of the address table of the catalog `subject` (Pending while link.addresses has not run).""" + sha = address_sha(env, subject) + index = _INDEXES.get(sha) if index is None: - index = views.AddressIndex.from_addresses(contract.loads(env.depot.read(sha))) - _ADDRESSES.clear() - _ADDRESSES[sha] = index + index = views.AddressIndex.from_addresses(address_doc(env, subject)) + _INDEXES.clear() + _INDEXES[sha] = index return index +def font_bundles(env) -> frozenset: + """The fenced bundles: those whose address table lists a catalog key under a font folder (FONT_FOLDERS), over the + catalogs of the fact "catalogs" (Pending while link.addresses has not run for one of them).""" + subjects = sorted(env.fact("catalogs")) + shas = tuple(address_sha(env, s) for s in subjects) + if not _FENCE or _FENCE[0][0] != shas: + fenced: set[str] = set() + for s in subjects: + for stable, b in address_doc(env, s)["bundles"].items(): + if any(k.startswith(FONT_FOLDERS) for k in b["keys"]): + fenced.add(stable) + _FENCE[:] = [(shas, frozenset(fenced))] + return _FENCE[0][1] + + def root_objects(env, keys=(), objects=(), resources=(), builtins=()) -> list[str]: """The object ids of the catalog `keys`, the resource paths `resources` and the built-in selectors `builtins` (through the address tables of the fact "catalogs") and the `objects`, sorted.""" @@ -227,7 +305,8 @@ def root_objects(env, keys=(), objects=(), resources=(), builtins=()) -> list[st def table(env) -> Units: """The Units of the request of a planning environment: the censuses of the fact "bundles" (Pending while one has not run; a failed one is skipped), the facts "selected", "roots" (Pending while link.addresses has not run, - when it names keys or resource paths) and "classes". Kept for the planning state it was made in.""" + when it names keys or resource paths), "classes" and "catalogs" (the fenced bundles: font_bundles). Kept for the + planning state it was made in.""" bundles = sorted(env.fact("bundles")) keys = [] for b in bundles: @@ -246,12 +325,14 @@ def table(env) -> Units: request.get("resources", ()), request.get("builtins", ()), ) + fenced = font_bundles(env) sig = ( id(env.depot), tuple(keys), tuple(env.fact("selected")), None if classes is None else tuple(classes), tuple(roots), + tuple(sorted(fenced)), ) if _MEMO and _MEMO[0][0] == sig: return _MEMO[0][1] @@ -263,6 +344,6 @@ def censuses(): rec = next(a for a in env.depot.committed(key, tid)["artifacts"] if a["id"] == aid) yield b, contract.loads(env.depot.read(rec["content"]["sha256"])) - t = build(censuses(), bundles, env.fact("selected"), classes, roots) + t = build(censuses(), bundles, env.fact("selected"), classes, roots, fenced) _MEMO[:] = [(sig, t)] return t diff --git a/src/nnnotes/schemas/objects.schema.json b/src/nnnotes/schemas/objects.schema.json index c53b845..fbbeda6 100644 --- a/src/nnnotes/schemas/objects.schema.json +++ b/src/nnnotes/schemas/objects.schema.json @@ -9,6 +9,7 @@ "roots", "keys", "objects", + "fenced", "missing" ], "properties": { @@ -89,6 +90,25 @@ } } }, + "fenced": { + "type": "object", + "description": "Objects a reached object references in a font bundle the product does not reach (the font fence of pipeline.units), with their class and name.", + "additionalProperties": { + "type": "object", + "required": [ + "class", + "name" + ], + "properties": { + "class": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + }, "missing": { "type": "array", "description": "Objects a reached object references that no census holds.", From 5ce3482f69065ec34263c71f7ba9878dabebecab Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 17 Oct 2026 15:27:48 +0800 Subject: [PATCH 2/5] feat(unity): read localization settings from builtin components Include MonoBehaviour components in builtin script selectors and ship the aligned localization manager type tree. Read language font settings from serialized object records. --- scripts/gen_typetrees.py | 28 ++++++++++++++--- src/nnnotes/pipeline/link.py | 8 ++--- src/nnnotes/schemas/addresses.schema.json | 2 +- src/nnnotes/unity/textstyle.py | 32 ++++++++++++++------ src/nnnotes/unity/typetrees/6000.3.12f1.json | 1 + 5 files changed, 51 insertions(+), 20 deletions(-) diff --git a/scripts/gen_typetrees.py b/scripts/gen_typetrees.py index 6ce8f75..75f3ad3 100644 --- a/scripts/gen_typetrees.py +++ b/scripts/gen_typetrees.py @@ -2,10 +2,11 @@ nnnotes). nnnotes reads a few MonoBehaviours of the APK's boot data (`assets/bin/Data/data.unity3d`): the URP pipeline -assets, renderer data, renderer features and post-process data, DOTween and TextMeshPro settings, and the sound -and app-config defaults (the `apk/data.unity3d` tasks of `unity.export`, and PlayerData). IL2CPP player builds -strip script typetrees from that data, so nnnotes ships the typetrees of exactly these classes (CLASSES below) in -`src/nnnotes/unity/typetrees/.json`, one file per Unity version (read by nnnotes.unity.scripttypes). +assets, renderer data, renderer features and post-process data, DOTween and TextMeshPro settings, the sound and +app-config defaults and the localization manager (the `apk/data.unity3d` tasks of `unity.export`, and PlayerData). +IL2CPP player builds strip script typetrees from that data, so nnnotes ships the typetrees of exactly these classes +(CLASSES below) in `src/nnnotes/unity/typetrees/.json`, one file per Unity version (read by +nnnotes.unity.scripttypes). Each entry holds the typetree nodes as [level, type, name, meta flag] rows and the type hash the serialized file records for the class; the file also records the game version it was generated from. @@ -14,7 +15,9 @@ structs carry their native names (`Vector3f`, `ColorRGBA`, `AABB`, ...), and arrays of them, of strings and of PPtrs are `vector`; the align flag (0x4000, align the stream to 4 bytes after the node) sits where Unity puts it. unity_rows turns the rows of TypeTreeGeneratorAPI's default (AssetsTools) backend into that layout. The other meta -flag bits are editor-only and left 0. +flag bits are editor-only and left 0. A field after which the serialized bytes are aligned, although the generated +tree does not say so, is listed in ALIGNED_FIELDS; generate() sets its align flag and the read check of its class +verifies it. Supporting a new game version ----------------------------- @@ -56,6 +59,7 @@ ("Adv", "AdvSystem.Rendering", "AdvFieldRendererFeature"), ("App.Runtime", "App.Config", "AppConfigDefaultData"), ("DOTween.dll", "DG.Tweening.Core", "DOTweenSettings"), + ("Fwk", "Fwk.Localization", "LocalizeManager"), ("Fwk", "Fwk.Rendering", "ScreenCaptureRendererFeature"), ("Fwk", "Fwk.Sound", "SoundVolumeSettings"), ("Fwk", "Fwk.UI.Rendering", "UIRendererFeature"), @@ -76,6 +80,8 @@ ALIGN = 0x4000 # TransferMetaFlags.kAlignBytesFlag # the MonoBehaviour header: field -> meta flag HEADER = {"m_GameObject": 0, "m_Enabled": ALIGN, "m_Script": 0, "m_Name": 0} +# class -> its root fields after which the serialized bytes are aligned (the flag the generated tree leaves out) +ALIGNED_FIELDS = {"LocalizeManager": ("_dontDestroyOnload",)} # built-in structs the generator names after their C# type -> the native type name Unity serializes NATIVE_TYPES = { "Bounds": "AABB", @@ -196,6 +202,17 @@ def unity_rows(rows: list) -> list: return _rows(root) +def _aligned(rows: list, names: tuple) -> list: + """The rows with the align flag set on the root fields `names` (ALIGNED_FIELDS); ValueError for a missing one.""" + roots = {r[2] for r in rows if r[0] == 1} + if not set(names) <= roots: + raise ValueError(f"no root field {sorted(set(names) - roots)}") + for r in rows: + if r[0] == 1 and r[2] in names: + r[3] |= ALIGN + return rows + + def objects_by_class(player: PlayerData) -> dict[tuple[str, str, str], list]: out: dict[tuple[str, str, str], list] = {} for o in player._by_type.get("MonoBehaviour", []): @@ -222,6 +239,7 @@ def generate(player: PlayerData, gen: TypeTreeGenerator, game_version: str) -> d for n in gen.get_nodes(asm if asm.endswith(".dll") else f"{asm}.dll", name) ] ) + rows = _aligned(rows, ALIGNED_FIELDS.get(cls, ())) except ValueError as e: raise SystemExit(f"{name} ({asm}): {e}") from e node = typetree_node(rows) diff --git a/src/nnnotes/pipeline/link.py b/src/nnnotes/pipeline/link.py index cf4294a..3e599f6 100644 --- a/src/nnnotes/pipeline/link.py +++ b/src/nnnotes/pipeline/link.py @@ -17,8 +17,8 @@ null when a dependency bundle has no census resources {resource path: [object ids]}: the paths of the ResourceManager container of the built-in files (what Resources.Load finds, as stored), the objects each names in stored order - builtins {selector: [object ids]}: the objects of the built-in files (not MonoScripts, a hierarchy by its root - GameObject alone) by class, "", and a MonoBehaviour by its script's class, + builtins {selector: [object ids]}: the objects of the built-in files (not MonoScripts) by class, "", and + a MonoBehaviour, including a component of a hierarchy, by its script's class, "MonoBehaviour:" (its MonoScript in the built-in files), each list sorted objects {stable address: {object, class, name, byteSize, typeHash}} of the objects a container path, a scene or a resource path names @@ -242,7 +242,7 @@ def builtin_selectors(docs) -> dict[str, list[str]]: for doc in docs: for file, o in census.objects(doc): oid = contract.object_id(file, o["pathId"]) - if o["class"] == "MonoScript" or "unit" in o: + if o["class"] == "MonoScript" or ("unit" in o and o["class"] != "MonoBehaviour"): continue if o["class"] == "MonoBehaviour": s = o.get("script") @@ -335,7 +335,7 @@ class AddressesStage(Stage): skips failed ones. Artifact "link.addresses:#addresses".""" name = "link.addresses" - version = 4 + version = 5 after = ("catalog.index", "unity.census") def subjects(self, env) -> list[str]: diff --git a/src/nnnotes/schemas/addresses.schema.json b/src/nnnotes/schemas/addresses.schema.json index 40f6249..1db399e 100644 --- a/src/nnnotes/schemas/addresses.schema.json +++ b/src/nnnotes/schemas/addresses.schema.json @@ -162,7 +162,7 @@ } }, "builtins": { - "description": "Per selector the objects of the APK's built-in files it names (not MonoScripts; a hierarchy by its root GameObject alone): \"\" by class, \"MonoBehaviour:\" a MonoBehaviour by its script's class; sorted.", + "description": "Per selector the objects of the APK's built-in files it names: \"\" by class, \"MonoBehaviour:\" a MonoBehaviour, including a hierarchy's component, by its script's class; sorted.", "type": "object", "additionalProperties": { "type": "array", diff --git a/src/nnnotes/unity/textstyle.py b/src/nnnotes/unity/textstyle.py index a49893e..dba56e8 100644 --- a/src/nnnotes/unity/textstyle.py +++ b/src/nnnotes/unity/textstyle.py @@ -5,9 +5,10 @@ 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) - localize_text LocalizeText.Init / OnFontChanged: the font asset, material and line spacing a TMP text ends up - with in a language + 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`). @@ -20,10 +21,13 @@ from __future__ import annotations import struct +from typing import TYPE_CHECKING from ..source.languages import LANGUAGES -from .export import Exporter -from .player import PlayerData + +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 @@ -107,11 +111,9 @@ def strings(self) -> list[str]: def language_fonts(player: PlayerData) -> dict: """Fwk.Localization.LocalizeManager (level0): font names per LanguageMode. - Read field by field: the generated typetree for this class omits the - alignment after `_dontDestroyOnload`, so the serialized bytes are parsed - directly (layout: m_GameObject, m_Enabled, m_Script, m_Name, - _dontDestroyOnload, _languageDataList[{_languageMode, _fontNames[], - _additionalFontNames[]}], _materialTypeList[]). + 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 @@ -140,6 +142,16 @@ def language_fonts(player: PlayerData) -> dict: 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.""" + langs = { + d["_languageMode"]: {"fontNames": list(d["_fontNames"]), "additionalFontNames": list(d["_additionalFontNames"])} + for d in localize["_languageDataList"] + } + return {"languages": langs, "materialTypes": list(localize["_materialTypeList"])} + + # -------------------------------------------------------------------------- # localized font / material of a TMP text (LocalizeText) # -------------------------------------------------------------------------- diff --git a/src/nnnotes/unity/typetrees/6000.3.12f1.json b/src/nnnotes/unity/typetrees/6000.3.12f1.json index 36f83af..c4cde7d 100644 --- a/src/nnnotes/unity/typetrees/6000.3.12f1.json +++ b/src/nnnotes/unity/typetrees/6000.3.12f1.json @@ -2,6 +2,7 @@ "Adv|AdvSystem.Rendering|AdvFieldRendererFeature":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","m_Active",16384],[1,"PPtr<$Shader>","_alphaBlendShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"PPtr<$Shader>","_characterAlphaBlendShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"PPtr<$Shader>","_blurShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"PPtr<$Shader>","_curvedLensShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"int","_maxFieldRendererEntryCount",0],[1,"float","_characterBlurIntensityRate",0],[1,"float","_characterBlurScale",0],[1,"float","_backgroundBlurIntensityRate",0],[1,"float","_backgroundBlurScale",0]],"typeHash":"b366a9b62e0f9de433e9ca044d26c9d1"}, "App.Runtime|App.Config|AppConfigDefaultData":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"AppConfigDataModel","_defaultData",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0]],"typeHash":"2b53ff748caf6baa04ce8616a3f29be9"}, "DOTween.dll|DG.Tweening.Core|DOTweenSettings":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","useSafeMode",16384],[1,"SafeModeOptions","safeModeOptions",0],[2,"int","logBehaviour",0],[2,"int","nestedTweenFailureBehaviour",0],[1,"float","timeScale",0],[1,"float","unscaledTimeScale",0],[1,"UInt8","useSmoothDeltaTime",16384],[1,"float","maxSmoothUnscaledTime",0],[1,"int","rewindCallbackMode",0],[1,"UInt8","showUnityEditorReport",16384],[1,"int","logBehaviour",0],[1,"UInt8","drawGizmos",16384],[1,"UInt8","defaultRecyclable",16384],[1,"int","defaultAutoPlay",0],[1,"int","defaultUpdateType",0],[1,"UInt8","defaultTimeScaleIndependent",16384],[1,"int","defaultEaseType",0],[1,"float","defaultEaseOvershootOrAmplitude",0],[1,"float","defaultEasePeriod",0],[1,"UInt8","defaultAutoKill",16384],[1,"int","defaultLoopType",0],[1,"UInt8","debugMode",16384],[1,"UInt8","debugStoreTargetId",16384],[1,"UInt8","showPreviewPanel",16384],[1,"int","storeSettingsLocation",0],[1,"ModulesSetup","modules",0],[2,"UInt8","showPanel",16384],[2,"UInt8","audioEnabled",16384],[2,"UInt8","physicsEnabled",16384],[2,"UInt8","physics2DEnabled",16384],[2,"UInt8","spriteEnabled",16384],[2,"UInt8","uiEnabled",16384],[2,"UInt8","textMeshProEnabled",16384],[2,"UInt8","tk2DEnabled",16384],[2,"UInt8","deAudioEnabled",16384],[2,"UInt8","deUnityExtendedEnabled",16384],[2,"UInt8","epoOutlineEnabled",16384],[1,"UInt8","createASMDEF",16384],[1,"UInt8","showPlayingTweens",16384],[1,"UInt8","showPausedTweens",16384]],"typeHash":"4f1dec93fa2a0b0e7b8b47518da31b1c"}, +"Fwk|Fwk.Localization|LocalizeManager":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","_dontDestroyOnload",16384],[1,"LanguageData","_languageDataList",0],[2,"Array","Array",0],[3,"int","size",0],[3,"LanguageData","data",0],[4,"int","_languageMode",0],[4,"vector","_fontNames",0],[5,"Array","Array",16384],[6,"int","size",0],[6,"string","data",0],[7,"Array","Array",16384],[8,"int","size",0],[8,"char","data",0],[4,"vector","_additionalFontNames",0],[5,"Array","Array",16384],[6,"int","size",0],[6,"string","data",0],[7,"Array","Array",16384],[8,"int","size",0],[8,"char","data",0],[1,"vector","_materialTypeList",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"string","data",0],[4,"Array","Array",16384],[5,"int","size",0],[5,"char","data",0]],"typeHash":"597220768ec4996f80d7cfd5e139645f"}, "Fwk|Fwk.Rendering|ScreenCaptureRendererFeature":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","m_Active",16384]],"typeHash":"ce87445dd61732c847969290a1daa3b1"}, "Fwk|Fwk.Sound|SoundVolumeSettings":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"SoundVolumeData","_collection",0],[2,"Array","Array",0],[3,"int","size",0],[3,"SoundVolumeData","data",0],[4,"string","CategoryName",0],[5,"Array","Array",16384],[6,"int","size",0],[6,"char","data",0],[4,"float","DefaultVolume",0]],"typeHash":"6fb3a84e8c1ec611fde964d8d6005276"}, "Fwk|Fwk.UI.Rendering|UIRendererFeature":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","m_Active",16384],[1,"BitField","_layerMask",0],[2,"unsigned int","m_Bits",0],[1,"int","_blurIterations",0],[1,"float","_blurOffset",0],[1,"int","_blurDownsample",0],[1,"float","_blurBlendRateMax",0],[1,"PPtr<$Shader>","_dualKawaseBlurShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0]],"typeHash":"12463b61d2af82f284b2248ea336f5f6"}, From b5130504176ccaa835745ba7b0c069b4702a9f7c Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 17 Oct 2026 15:27:48 +0800 Subject: [PATCH 3/5] feat(content): assemble localized story UI products Export UI roots, text styles and chat status texts for each client language. Build text metrics from the client's font assets and retain the UI objects in the product. --- src/nnnotes/cli/assets.py | 1 + src/nnnotes/content/storyui.py | 308 +++++++++++++++++++++++++++++++++ src/nnnotes/pipeline/story.py | 124 ++++++++++++- 3 files changed, 428 insertions(+), 5 deletions(-) create mode 100644 src/nnnotes/content/storyui.py diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index 952930a..a95798f 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -133,6 +133,7 @@ def matches(self, stage: str) -> bool: StageSource("story.asset", "nnnotes.pipeline.story:AssetStage", output=True, product=True), StageSource("story.video", "nnnotes.pipeline.story:VideoStage", output=True, product=True), StageSource("story.chat", "nnnotes.pipeline.story:ChatStage", output=True, product=True), + StageSource("story.ui", "nnnotes.pipeline.story:UiStage", output=True, product=True), StageSource("story", "nnnotes.pipeline.story:StoryStage", output=True, product=True), StageSource("view.", "nnnotes.pipeline.views:stages", output=True), StageSource("view.voices", "nnnotes.pipeline.voices:VoiceStage", output=True), diff --git a/src/nnnotes/content/storyui.py b/src/nnnotes/content/storyui.py new file mode 100644 index 0000000..71204e6 --- /dev/null +++ b/src/nnnotes/content/storyui.py @@ -0,0 +1,308 @@ +"""The ADV story UI of a product: what the ADV front canvas, its talk windows, the chat phone, the dialogs and the +settings draw with in one client language. One product (`ui`, ui.json format UI_FORMAT), read from a product's object +table (`Objects`, pipeline.assemble) the way storyscene reads the story scene: + + widget the GameObject of UIAdvWidget (WIDGET_KEY); its prefab document holds the drawn hierarchy + talkWindows {window name: GameObject} of the talk windows of the catalog (window_keys); the default window + UIDefaultTalkWindow is always one of them + chatWidget the GameObject of the phone of the chat rows (UIAdvChatWidget) + dialogs {name: GameObject} of the skip confirm dialog and the common dialog (DIALOG_KEYS) + uiManager the GameObject of UIManager (its camera controller and the UI camera) + letterbox the Sprite of the letterbox bands + clips {name: AnimationClip} of the ADV animations (CLIP_KEYS) + controllers {name: AnimatorController} of the ADV animations (CONTROLLER_KEYS) + settings the AdvMasterIdSettings, DOTweenSettings, TMP_Settings and LocalizeManager objects + graphics {graphics: GraphicsSettings} + language the client language: its code, LanguageMode, text field and line spacing, the font names and + material types LocalizeManager holds for it (textstyle.language_fonts_json) and the font swap + texts {TMP component id: {localized, style}} of every TMP text of the prefabs above. `localized` is + LocalizeText's font asset, material and line spacing (textstyle.localize_text), null when the + text has no enabled LocalizeText; `style` is the text record (textstyle.text_style) with the font + settings read from the client's font assets + textStyle the language's field, the units and the line metrics of the font roles the texts use + masterIdTexts the AdvMasterIdSettings texts of MASTER_ID_TEXTS: {field: {id, text}} + chatStatusTexts the incoming-call and lock-screen texts: {MasterText key: text} + bindings the animator bindings of the prefabs (livenotes.animator_bindings) + shaders {shader name: object id} (prefabs.shader_table) + +A text's font asset and material are named by the object the table holds for them, or by the class and name the table +fences them with (Objects.fenced): a reference across the font fence carries only its file and path id. + +Of the objects the document 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 (prefabs.mesh_roles). + +The master record is {"texts": {MasterText id: text in the language's text field}}. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from ..source import languages +from ..unity import fontsettings, textstyle +from ..unity.ids import object_id, parse_object_id +from . import adv +from .livenotes import animator_bindings +from .prefabs import ( + Objects, + builtin_root, + component_class, + key_object, + mesh_roles, + mono_json, + ref_id, + script_class, + shader_table, +) + +UI_FORMAT = 1 # ui.json "format" +WIDGET_KEY = "EmbUI/Prefab/UIAdvWidget" +WINDOW_KEY = "EmbUI/Prefab/Parts/Adv/Talk/UIDefaultTalkWindow" # the default talk window (embedded address) +CHAT_WIDGET_KEY = "EmbUI/Prefab/UIAdvChatWidget" +UI_MANAGER_KEY = "EmbCommon/Prefab/UIManager" +LETTERBOX_KEY = "Textures/letterbox-image" +MASTER_ID_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvMasterIdSettings" +DIALOG_KEYS = { + "UIAdvSkipConfirmDialogWidget": "EmbUI/Prefab/UIAdvSkipConfirmDialogWidget", + "UICommonDialogWidget": "EmbUI/Prefab/UICommonDialogWidget", +} +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", +} +# AdvMasterIdSettings fields of the texts the drawn parts show: the video skip dialog, the skip confirm dialog and the +# interruption dialog +MASTER_ID_TEXTS = ( + "_skipVideoMessageTextId", + "_skipButtonTextId", + "_cancelButtonTextId", + "_skipMessageTextId", + "_continuousSkipMessageTextId", + "_backEpisodeListButtonTextId", + "_continueEpisodeButtonTextId", + "_interruptionTitleTextId", + "_interruptionMessageTextId", + "_interruptionButtonTextId", +) +TEXT_CLASSES = ("TextMeshProUGUI", "RubyTextMeshProUGUI", "RubyEmojiTextMeshProUGUI") +# the built-in objects and the Resources.Load paths of the document (ProductStage.builtins, ProductStage.resources) +BUILTINS = ("GraphicsSettings", "MonoBehaviour:LocalizeManager") +RESOURCES = ("DOTweenSettings", "TMP Settings") +CHAT_STATUS_KEYS = ("_incomingCallStatusTextKey", "_lockScreenStatusTextKey") + + +def window_keys(catalog: Iterable[str]) -> dict[str, str]: + """{talk window name: prefab key} of the talk windows of the catalog keys `catalog`: the names under the talk window + prefix or its embedded form, each with its plain key when the catalog has that and else its embedded key (the + rule of content.advui.window_key), and the default window.""" + keys = set(catalog) + prefixes = (adv.TALK_WINDOW_PREFIX, adv.EMBEDDED_PREFIX + adv.TALK_WINDOW_PREFIX) + names = {k[len(p) :] for k in keys for p in prefixes if k.startswith(p)} | {adv.DEFAULT_TALK_WINDOW} + out = {} + for name in sorted(names): + if name == adv.DEFAULT_TALK_WINDOW: + out[name] = WINDOW_KEY + else: + address = adv.TALK_WINDOW_PREFIX + name + out[name] = address if address in keys else adv.EMBEDDED_PREFIX + address + return out + + +def keys(catalog: Iterable[str]) -> list[str]: + """The catalog keys of the story UI in the catalog `catalog` (its keys): the widget, the chat widget, UIManager, the + letterbox, the master id settings, the dialogs, the animations and the talk windows (window_keys), sorted.""" + return sorted( + { + WIDGET_KEY, + CHAT_WIDGET_KEY, + UI_MANAGER_KEY, + LETTERBOX_KEY, + MASTER_ID_SETTINGS_KEY, + *DIALOG_KEYS.values(), + *CLIP_KEYS.values(), + *CONTROLLER_KEYS.values(), + *window_keys(catalog).values(), + } + ) + + +def font_keys(catalog: Iterable[str]) -> list[str]: + """The keys of font assets and materials under the client's font folders, sorted.""" + return sorted({k for k in catalog if k.startswith((fontsettings.FONT_PREFIX, fontsettings.FONT_ADDRESS))}) + + +def chat_window_keys(catalog: Iterable[str]) -> list[str]: + """The chat-window prefab keys from which the shared status-text keys are read, sorted.""" + return sorted({k for k in catalog if k.startswith(adv.CHAT_WINDOW_PREFIX)}) + + +def chat_status_texts(objects: Objects, texts: dict) -> dict[str, str]: + """The incoming-call and lock-screen texts of the chat windows in `objects`, resolved in `texts`.""" + wanted = set() + for key in chat_window_keys(objects.keys()): + prefab = key_object(objects, key, "GameObject") + for node in objects.json(prefab)["nodes"]: + for comp in node["components"]: + if component_class(comp) == "AdvChatWindow": + wanted.update(str(comp["fields"][k]) for k in CHAT_STATUS_KEYS if comp["fields"].get(k)) + return {k: texts[k] for k in sorted(wanted)} + + +def ui( + objects: Objects, + language: str, + master: dict, + fonts: fontsettings.FontSettings, + *, + status_texts: dict[str, str] | None = None, +) -> tuple[dict, set[str], dict[str, set[str]]]: + """(ui.json, the object ids the player reads, {object id: the roles of its files it reads}) of the story UI in the + client language `language` (module documentation).""" + code = languages.check(language) + mode, field = languages.mode(code), languages.column(code)[1:] + localize = script_root(objects, "LocalizeManager") + lang = textstyle.language_fonts_json(mono_json(objects, localize)) + styles = _Styles(lang, mode, fonts) + widget = key_object(objects, WIDGET_KEY, "GameObject") + windows = {name: key_object(objects, key, "GameObject") for name, key in window_keys(objects.keys()).items()} + chat = key_object(objects, CHAT_WIDGET_KEY, "GameObject") + ui_manager = key_object(objects, UI_MANAGER_KEY, "GameObject") + dialogs = {name: key_object(objects, key, "GameObject") for name, key in DIALOG_KEYS.items()} + texts = {} + for prefab in (widget, *windows.values(), chat, *dialogs.values(), ui_manager): + texts.update(_texts(objects, prefab, styles)) + settings = key_object(objects, MASTER_ID_SETTINGS_KEY, "AdvMasterIdSettings") + graphics = builtin_root(objects, "GraphicsSettings") + always = (ref_id(r) for r in objects.json(graphics).get("m_AlwaysIncludedShaders", ())) + shaders, roles = shader_table(objects, found=[o for o in always if o is not None]) + doc = { + "format": UI_FORMAT, + "language": { + "code": code, + "mode": mode, + "field": field, + "lineSpacing": textstyle.LANGUAGE_LINE_SPACING[mode], + "fontNames": lang["languages"][mode]["fontNames"], + "additionalFontNames": lang["languages"][mode]["additionalFontNames"], + "materialTypes": lang["materialTypes"], + "fontSwap": styles.swap, + }, + "widget": widget, + "talkWindows": windows, + "chatWidget": chat, + "dialogs": dialogs, + "uiManager": ui_manager, + "letterbox": key_object(objects, LETTERBOX_KEY, "Sprite"), + "clips": {name: key_object(objects, key, "AnimationClip") for name, key in CLIP_KEYS.items()}, + "controllers": {name: key_object(objects, key, "AnimatorController") for name, key in CONTROLLER_KEYS.items()}, + "settings": { + "masterIdSettings": settings, + "dotween": script_root(objects, "DOTweenSettings"), + "tmpSettings": script_root(objects, "TMP_Settings"), + "localizeManager": localize, + }, + "graphics": {"graphics": graphics}, + "texts": texts, + "textStyle": styles.summary(), + "masterIdTexts": master_id_texts(objects.json(settings), master["texts"]), + "chatStatusTexts": status_texts if status_texts is not None else chat_status_texts(objects, master["texts"]), + "bindings": animator_bindings(objects), + "shaders": shaders, + } + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + return doc, keep, roles + + +def master_id_texts(settings: dict, texts: dict) -> dict: + """masterIdTexts: {AdvMasterIdSettings field of MASTER_ID_TEXTS: {id (MasterText id), text}}; `texts` is the master + record's {MasterText id: text} (JSON keys are strings). A missing id raises KeyError.""" + return {k: {"id": settings[k], "text": texts[str(settings[k])]} for k in MASTER_ID_TEXTS} + + +class _Styles: + """The text records of one language: LocalizeText's resolution of a text, the style values of its material, and the + line metrics of the font roles the texts use.""" + + def __init__(self, lang: dict, mode: int, fonts: fontsettings.FontSettings): + self.lang, self.mode, self.fonts = lang, mode, fonts + self.swap = textstyle.font_swap(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: + """{localized, style} of the TMP text `comp` (serialized fields, font and material named) with its LocalizeText + `localize` (or None).""" + fa, mat = comp["m_fontAsset"]["name"], comp["m_sharedMaterial"]["material"] + localized = None + if localize and localize["m_Enabled"] and localize["_localizeEnabled"]: + localized = textstyle.localize_text(path, fa, mat, self.swap, self.lang, self.mode) + fa, mat = localized["fontAsset"], localized["material"] + if (fa, mat) not in self._styles: + face, _ = self._face(fa) + self._styles[(fa, mat)] = textstyle.material_style( + self.fonts.material(fa, mat), face["m_PointSize"], face["m_Scale"] + ) + style = textstyle.text_style(comp, localize, self.lang, self._styles[(fa, mat)], self.mode) + if style["fontRole"] not in self.roles: + face, extra = self._face(fa) + self.roles[style["fontRole"]] = textstyle.face_metrics(face, extra) + return {"localized": localized, "style": style} + + def summary(self) -> dict: + return {"language": textstyle.LANGUAGE_FIELDS[self.mode], "units": textstyle.UNITS, "roles": self.roles} + + +def _texts(objects: Objects, prefab: str, styles: _Styles) -> dict[str, dict]: + """{TMP component id: {localized, style}} of the TMP texts of the prefab document of GameObject `prefab`. + A component id is the prefab's file and the component's path id.""" + file, _ = parse_object_id(prefab) + out = {} + for n in objects.json(prefab)["nodes"]: + tmp = [c for c in n["components"] if component_class(c) in TEXT_CLASSES] + loc = [c for c in n["components"] if component_class(c) == "LocalizeText"] + if not tmp: + continue + if len(tmp) > 1 or len(loc) > 1: + raise ValueError(f"{n['path']}: {len(tmp)} TMP texts and {len(loc)} LocalizeText components") + comp = {"class": component_class(tmp[0]), **tmp[0]["fields"]} + comp["m_fontAsset"] = {"name": _name(objects, comp["m_fontAsset"])} + comp["m_sharedMaterial"] = {"material": _name(objects, comp["m_sharedMaterial"])} + localize = {"class": "LocalizeText", **loc[0]["fields"]} if loc else None + out[object_id(file, int(tmp[0]["pathId"]))] = styles.record(n["path"], comp, localize) + return out + + +def _name(objects: Objects, ref) -> str: + """The name of the object a reference names: from the table, or from the class and name the table fences it with.""" + oid = ref_id(ref) + if oid is None: + raise ValueError(f"not a reference to an object: {ref!r}") + hit = objects.info(oid) if oid in objects else objects.fenced(oid) + if hit is None or not hit.get("name"): + raise ValueError(f"{oid}: no name in the object table") + return hit["name"] + + +def script_root(objects: Objects, script: str) -> str: + """The one MonoBehaviour root of the script class `script` (ValueError otherwise).""" + hits = [ + o + for o in objects.roots + if o in objects and objects.info(o).get("class") == "MonoBehaviour" and script_class(objects, o) == script + ] + if len(hits) != 1: + raise ValueError(f"{len(hits)} {script} roots") + return hits[0] diff --git a/src/nnnotes/pipeline/story.py b/src/nnnotes/pipeline/story.py index 0257911..a9503b9 100644 --- a/src/nnnotes/pipeline/story.py +++ b/src/nnnotes/pipeline/story.py @@ -37,6 +37,15 @@ story.chat subject `default`: "#chat.json" (kind story.chat: content.storychat.chat, the rows of the shared chat shards). Its manifest is storychat/default.json. +story.ui subject a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`): "#ui.json" (kind story.ui: + content.storyui.ui, the ADV front canvas, talk windows, chat phone, dialogs and settings with the text + records in that language) and the object table the player reads. Its roots are the catalog keys of the + story UI (storyui.keys), the built-in settings (storyui.BUILTINS) and the resources (storyui.RESOURCES); + its input "master" the MasterText rows, each in the language's text column. Font assets and chat + windows are also read for text styles and status texts; the retained table is the closure of the UI + roots (input "ui.objects"). Its manifest is + storyui/.json. A client requests it for each language it uses. + story subject a MasterAdv id: "#episode.json" (kind story.episode: content.storyepisode.episode_json, the command list with its text, sounds, videos and resource closure) and "#story.json" (kind story: content.storyepisode.story, the episode's chat rows, its effect instances and `parts`, the @@ -56,14 +65,19 @@ import tempfile from pathlib import Path -from ..content import liveaudio, storyasset, storychat, storyepisode, storyscene, storyvideo +from ..content import liveaudio, storyasset, storychat, storyepisode, storyscene, storyui, storyvideo from ..content.livescene import GRAPHICS +from ..content.prefabs import mono_json, script_class from ..formats import acb, crilips from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO, mp4_priming, web_encode +from ..source import languages +from ..source.apkset import MANIFEST_IN_APK, manifest_version_name +from ..unity import fontsettings, textstyle from ..unity.atoms import Unsupported -from . import contract -from .assemble import MASTER_INPUT, Assembly, ProductStage, master_input -from .contract import IncompatibleTask +from ..unity.ids import parse_object_id +from . import contract, units +from .assemble import MASTER_INPUT, Assembly, Objects, ProductStage, master_input, reach +from .contract import IncompatibleTask, Input from .layout import escape_segment from .live import encode_atoms from .live2d import MODEL, ModelStage @@ -84,13 +98,15 @@ "SceneStage", "SoundStage", "StoryStage", + "UI", + "UiStage", "VideoStage", "cues", "part_products", ] SOUND, CRILIPS, SCENE, ASSET = "story.sound", "story.crilips", "story.scene", "story.asset" -CHAT, STORY, VIDEO = "story.chat", "story", "story.video" +CHAT, STORY, UI, VIDEO = "story.chat", "story", "story.ui", "story.video" DEFAULT = "default" SHEET_PREFIX = "Cri/Sound/" LIBRARY = crilips.LIB_ENTRY_SUFFIX @@ -317,6 +333,104 @@ def assemble(self, task, objects) -> Assembly: return Assembly({"chat.json": (contract.encode(doc), "json", CHAT)}, keep, roles) +def _language(subject: str) -> str: + if subject not in languages.LANGUAGES: + raise ValueError(f"{subject!r}: not a client language ({', '.join(languages.LANGUAGES)})") + return subject + + +class UiStage(ProductStage): + """story.ui: the ADV story UI in one client language (module documentation).""" + + name = UI + version = 1 + KIND = "StoryUi" + MASTER = ("MasterText",) + APK = (MANIFEST_IN_APK,) + + def ui_keys(self, subject: str, env) -> list[str]: + _language(subject) + return storyui.keys({loc["primaryKey"] for loc in env.fact("index")["locations"]}) + + def keys(self, subject: str, env) -> list[str]: + _language(subject) + catalog = {loc["primaryKey"] for loc in env.fact("index")["locations"]} + return sorted({*self.ui_keys(subject, env), *storyui.font_keys(catalog), *storyui.chat_window_keys(catalog)}) + + def resources(self, subject: str, env) -> list[str]: + _language(subject) + return list(storyui.RESOURCES) + + def builtins(self, subject: str, env) -> list[str]: + _language(subject) + return list(storyui.BUILTINS) + + def record(self, subject: str, env) -> dict: + """The master record of the language: the text of every MasterText row in its text column.""" + column = languages.column(_language(subject)) + rows = master_rows(env, self.MASTER)["MasterText"] + return {"texts": {str(r["_id"]): r.get(column) or "" for r in rows}} + + def more_inputs(self, subject: str, env) -> list: + keys = self.ui_keys(subject, env) + roots = units.root_objects( + env, keys, resources=self.resources(subject, env), builtins=self.builtins(subject, env) + ) + by_key = {k: units.root_objects(env, [k]) for k in keys} + ui_table = reach(env, roots, self._results, by_key).table + objects = Objects(self.reach(subject, env).table, env.depot) + font_files = set(_font_files(objects).values()) + bundles = {} + for catalog in sorted(env.fact("catalogs")): + addresses = units.address_doc(env, catalog) + bundles.update( + {f: addresses["bundles"][b]["name"] for f, b in addresses["files"].items() if f in font_files} + ) + return [ + master_input(env, self.record(subject, env)), + _record_input(env, "ui.objects", ui_table), + _record_input(env, "font-bundles", bundles), + ] + + def manifest(self, subject: str) -> str: + return f"storyui/{_language(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + from ..content import tmpassets + + record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) + ui_table = contract.loads(objects.depot.input_bytes(task.input("ui.objects"))) + ui_objects = Objects(ui_table, objects.depot) + localize = storyui.script_root(ui_objects, "LocalizeManager") + lang = textstyle.language_fonts_json(mono_json(ui_objects, localize)) + bundles = contract.loads(objects.depot.input_bytes(task.input("font-bundles"))) + font_files = _font_files(objects) + game_version = manifest_version_name(objects.depot.input_bytes(task.input(f"apk:{MANIFEST_IN_APK}"))) + if game_version is None: + raise ValueError("story.ui: the APK manifest has no version name") + settings = tmpassets.build_font_settings(objects, lang, game_version, lambda name: bundles[font_files[name]]) + status = storyui.chat_status_texts(objects, record["texts"]) + doc, keep, roles = storyui.ui( + ui_objects, task.subject, record, fontsettings.FontSettings(settings), status_texts=status + ) + return Assembly({"ui.json": (contract.encode(doc), "json", UI)}, keep, roles, ui_table) + + +def _record_input(env, role: str, record: dict) -> Input: + """A JSON record read by a product, stored as a content-addressed input.""" + body = contract.encode(record) + return Input(role, env.depot.put(body), len(body), None, ({"kind": "depot"},)) + + +def _font_files(objects: Objects) -> dict[str, str]: + """The serialized file holding each TMP font asset, by asset name.""" + return { + objects.info(o)["name"]: parse_object_id(o)[0] + for o in objects.ids("MonoBehaviour") + if script_class(objects, o) == fontsettings.FONT_CLASS + } + + def _adv_id(subject: str) -> int: if not subject.isdigit(): raise ValueError(f"{subject}: not an episode id") From d347bf5ebd3af724c1d1ec096f28d1505e6e705c Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 17 Oct 2026 15:27:49 +0800 Subject: [PATCH 4/5] test(pipeline): cover localized UI and font reference closures Validate font fences, retained closures, builtin components and language text styles with synthetic objects and serialized data. --- tests/content/test_storyui.py | 203 +++++++++++++++++++++ tests/helpers/live2dmodel.py | 2 +- tests/helpers/livenotes.py | 2 +- tests/helpers/livescene.py | 2 +- tests/helpers/spotobjects.py | 2 +- tests/helpers/storyui.py | 292 +++++++++++++++++++++++++++++++ tests/pipeline/test_assemble.py | 71 +++++++- tests/pipeline/test_cristages.py | 1 + tests/pipeline/test_link.py | 10 +- tests/pipeline/test_live.py | 9 +- tests/pipeline/test_objexport.py | 22 ++- tests/pipeline/test_story.py | 77 +++++++- tests/unity/test_textstyle.py | 123 +++++++++++++ tests/unity/test_typetrees.py | 13 ++ 14 files changed, 814 insertions(+), 15 deletions(-) create mode 100644 tests/content/test_storyui.py create mode 100644 tests/helpers/storyui.py diff --git a/tests/content/test_storyui.py b/tests/content/test_storyui.py new file mode 100644 index 0000000..8042e54 --- /dev/null +++ b/tests/content/test_storyui.py @@ -0,0 +1,203 @@ +"""The story UI document (content.storyui) on a synthetic object table: the talk window and catalog keys, the fields +of the document in a language, the text records with the font asset and material their language draws with, the +master id texts and validation errors.""" + +import pytest + +from nnnotes.content import prefabs, storyui +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import Depot +from tests.helpers import storyui as sui +from tests.helpers.livenotes import comp + + +def ui(objects, language, master): + return storyui.ui(objects, language, master, sui.font_settings()) + + +def story(tmp_path, name: str = "d", **kw) -> Objects: + depot = Depot(tmp_path / name) + return Objects(sui.table_doc(depot, **kw), depot) + + +def test_the_talk_windows_take_the_plain_key_or_the_embedded_one_and_the_default_window(): + assert storyui.window_keys(sui.catalog_keys()) == { + "UIBoth": "UI/Prefab/Parts/Adv/Talk/UIBoth", + "UIDefaultTalkWindow": storyui.WINDOW_KEY, + "UIEmb": "EmbUI/Prefab/Parts/Adv/Talk/UIEmb", + "UIPlain": "UI/Prefab/Parts/Adv/Talk/UIPlain", + } + assert storyui.window_keys([]) == {"UIDefaultTalkWindow": storyui.WINDOW_KEY} + + +def test_the_story_ui_keys_are_the_keys_of_its_table(): + keys = storyui.keys(sui.catalog_keys()) + assert keys == sorted(sui.table_keys()) and len(keys) == 19 + + +def test_the_document_has_the_story_ui_fields_in_the_language(tmp_path): + doc, keep, roles = ui(story(tmp_path), "zh-Hant", sui.MASTER_RECORD) + assert set(doc) == { + "format", + "language", + "widget", + "talkWindows", + "chatWidget", + "dialogs", + "uiManager", + "letterbox", + "clips", + "controllers", + "settings", + "graphics", + "texts", + "textStyle", + "masterIdTexts", + "chatStatusTexts", + "bindings", + "shaders", + } + assert doc["format"] == storyui.UI_FORMAT == 1 + assert doc["language"] == { + "code": "zh-Hant", + "mode": 2, + "field": "traditionalChinese", + "lineSpacing": 5.0, + "fontNames": ["MainT", "Digits"], + "additionalFontNames": ["Extra"], + "materialTypes": ["Default", "Outline"], + "fontSwap": {"MainJ SDF": "MainT SDF", "Digits SDF": "Digits SDF"}, + } + assert doc["widget"] == sui.WIDGET and doc["chatWidget"] == sui.CHAT + assert doc["uiManager"] == sui.UI_MANAGER and doc["letterbox"] == sui.LETTERBOX + assert doc["talkWindows"] == { + "UIBoth": sui.BOTH_UI, + "UIDefaultTalkWindow": sui.DEFAULT, + "UIEmb": sui.EMB, + "UIPlain": sui.PLAIN, + } + assert doc["dialogs"] == {"UIAdvSkipConfirmDialogWidget": sui.DIALOG_A, "UICommonDialogWidget": sui.DIALOG_B} + assert doc["clips"] == dict(zip(storyui.CLIP_KEYS, sui.CLIPS)) + assert doc["controllers"] == dict(zip(storyui.CONTROLLER_KEYS, sui.CONTROLLERS)) + assert doc["settings"] == { + "masterIdSettings": sui.SETTINGS, + "dotween": sui.DOTWEEN, + "tmpSettings": sui.TMP_SETTINGS, + "localizeManager": sui.LOCALIZE, + } + assert doc["graphics"] == {"graphics": sui.GRAPHICS} + assert doc["bindings"] == {} + assert doc["shaders"] == {"UI/Default": sui.SHADER} + assert keep == set(sui.objects()) - {sui.SCRIPT} + assert set(roles) == {sui.SHADER} and {"json", "index"} <= roles[sui.SHADER] + + +def test_a_text_draws_with_the_font_and_material_of_its_language(tmp_path): + doc, _, _ = ui(story(tmp_path), "zh-Hant", sui.MASTER_RECORD) + assert set(doc["texts"]) == {"CAB-ui:101", "CAB-ui:103", "CAB-ui:111"} + talk = doc["texts"]["CAB-ui:101"] + assert talk["localized"] == {"fontAsset": "MainT SDF", "material": "MainT - Outline", "lineSpacing": 5.0} + style = talk["style"] + assert style["class"] == "TextMeshProUGUI" and style["text"] == "talk" and style["localized"] is True + assert style["fontRole"] == "primary" and style["materialType"] == "Outline" and style["textKey"] == "101" + assert style["alignment"] == {"horizontal": "center", "vertical": "middle"} + assert style["lineSpacing"]["applied"] == 5.0 + assert style["outline"]["widthEm"] == 0.03 + plain = doc["texts"]["CAB-ui:103"] + assert plain["localized"] is None and plain["style"]["localized"] is False + assert plain["style"]["textKey"] is None and plain["style"]["materialType"] == "Default" + assert plain["style"]["outline"] is None + window = doc["texts"]["CAB-ui:111"] + assert window["localized"] is None and window["style"]["textKey"] is None + assert doc["textStyle"]["language"] == "traditionalChinese" + assert set(doc["textStyle"]["roles"]) == {"primary"} + + +def test_the_japanese_texts_draw_with_the_japanese_font(tmp_path): + doc, _, _ = ui(story(tmp_path), "ja", sui.MASTER_RECORD) + assert doc["texts"]["CAB-ui:101"]["localized"] == { + "fontAsset": "MainJ SDF", + "material": "MainJ - Outline", + "lineSpacing": 0.0, + } + assert doc["language"]["fontSwap"] == {"MainJ SDF": "MainJ SDF", "Digits SDF": "Digits SDF"} + assert doc["textStyle"]["roles"] == { + "primary": {"lineHeightEm": 1.2, "ascentEm": 0.9, "descentEm": -0.3, "spacingOffset": 0.0, "boldSpacing": 7.0} + } + + +def test_the_master_id_texts_are_the_texts_of_the_settings_ids(tmp_path): + doc, _, _ = ui(story(tmp_path), "zh-Hant", sui.MASTER_RECORD) + assert doc["masterIdTexts"] == { + field: {"id": sui.MASTER_IDS[field], "text": f"zh {sui.MASTER_IDS[field]}"} for field in storyui.MASTER_ID_TEXTS + } + + +def test_a_master_id_without_a_text_is_a_key_error(tmp_path): + with pytest.raises(KeyError): + ui(story(tmp_path, name="missing"), "zh-Hant", {"texts": {}}) + + +def test_a_second_localize_manager_is_refused(tmp_path): + depot = Depot(tmp_path / "d") + objs = {**sui.objects(), "CAB-lm:2": ("MonoBehaviour", "LocalizeManager", {"json": sui.LOCALIZE_JSON})} + doc = sui.table_doc(depot, objs=objs, extra_roots=["CAB-lm:2"]) + with pytest.raises(ValueError, match="2 LocalizeManager roots"): + ui(Objects(doc, depot), "zh-Hant", sui.MASTER_RECORD) + + +def test_localize_manager_fields_are_read_from_its_scene_component(tmp_path): + depot = Depot(tmp_path / "d") + holder = "CAB-lm:20" + fields = {k: v for k, v in sui.LOCALIZE_JSON.items() if k != "$script"} + objs = { + **sui.objects(), + holder: ( + "GameObject", + "Localization", + {"json": sui.prefab("Localization", comp("MonoBehaviour", 1, sui.LOCALIZE_JSON["$script"], **fields))}, + ), + } + doc = sui.table_doc(depot, objs=objs) + doc["objects"][sui.LOCALIZE] = {"class": "MonoBehaviour", "status": "contained", "in": holder} + objects = Objects(doc, depot) + assert prefabs.script_class(objects, sui.LOCALIZE) == "LocalizeManager" + assert prefabs.mono_json(objects, sui.LOCALIZE) == sui.LOCALIZE_JSON + result, _, _ = ui(objects, "zh-Hant", sui.MASTER_RECORD) + assert result["settings"]["localizeManager"] == sui.LOCALIZE + assert result["language"]["fontNames"] == ["MainT", "Digits"] + + +def test_a_font_asset_the_fence_does_not_name_is_refused(tmp_path): + depot = Depot(tmp_path / "d") + doc = sui.table_doc(depot) + doc["fenced"] = {} + with pytest.raises(ValueError, match="CAB-font:5: no name in the object table"): + ui(Objects(doc, depot), "zh-Hant", sui.MASTER_RECORD) + + +def test_the_supplied_font_metrics_and_materials_determine_the_text_style(tmp_path): + settings = sui.font_settings() + settings.fonts["MainT SDF"]["faceInfo"] = {**settings.fonts["MainT SDF"]["faceInfo"], "m_LineHeight": 150.0} + settings.materials["MainT - Outline"]["floats"]["_OutlineWidth"] = 0.4 + doc, _, _ = storyui.ui(story(tmp_path), "zh-Hant", sui.MASTER_RECORD, settings) + assert doc["textStyle"]["roles"]["primary"]["lineHeightEm"] == 1.5 + assert doc["texts"]["CAB-ui:101"]["style"]["outline"]["widthEm"] == 0.06 + + +def test_chat_status_texts_resolve_each_nonempty_key_once(tmp_path): + depot = Depot(tmp_path / "d") + key, oid, entry = sui.chat_window() + objects = Objects( + sui.table_doc(depot, objs={**sui.objects(), oid: entry}, keys={**sui.table_keys(), key: [oid]}), depot + ) + assert storyui.chat_status_texts(objects, sui.MASTER_RECORD["texts"]) == {"301": "zh 301", "302": "zh 302"} + assert storyui.chat_status_texts(story(tmp_path, name="empty"), sui.MASTER_RECORD["texts"]) == {} + with pytest.raises(KeyError, match="302"): + storyui.chat_status_texts(objects, {"301": "incoming"}) + + +def test_font_keys_cover_both_font_folders_and_chat_windows_have_their_own_roots(): + keys = ["Font/Extra/Extra SDF", "EmbFont/MainJ/MainJ - Outline", "Adv/Chat/Prefabs/win_7", "UI/Prefab/UIAdvWidget"] + assert storyui.font_keys(keys) == ["EmbFont/MainJ/MainJ - Outline", "Font/Extra/Extra SDF"] + assert storyui.chat_window_keys(keys) == ["Adv/Chat/Prefabs/win_7"] diff --git a/tests/helpers/live2dmodel.py b/tests/helpers/live2dmodel.py index b54bfcc..436d2ac 100644 --- a/tests/helpers/live2dmodel.py +++ b/tests/helpers/live2dmodel.py @@ -240,4 +240,4 @@ def table(depot, objs: dict | None = None, roots=(ROOT, MASK_MAT, CULL_MAT)) -> ext = ("png" if role == "image" else "bin") if isinstance(body, bytes) else "json" recs[role] = {"sha256": depot.put(data), "size": len(data), "ext": ext} out[oid] = {"status": "exported", "class": cls, "name": name, "files": recs} - return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "fenced": {}, "missing": []} diff --git a/tests/helpers/livenotes.py b/tests/helpers/livenotes.py index c7754f9..97d676d 100644 --- a/tests/helpers/livenotes.py +++ b/tests/helpers/livenotes.py @@ -182,7 +182,7 @@ def table(depot, keys: dict, objs: dict | None = None) -> dict: recs[role] = {"sha256": depot.put(data), "size": len(data), "ext": ext} out[oid] = {"status": "exported", "class": cls, "name": name, "files": recs} roots = sorted({o for v in keys.values() for o in v}) - return {"schema": contract.OBJECTS, "roots": roots, "keys": keys, "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": roots, "keys": keys, "objects": out, "fenced": {}, "missing": []} def master() -> dict: diff --git a/tests/helpers/livescene.py b/tests/helpers/livescene.py index e2681ce..51e1682 100644 --- a/tests/helpers/livescene.py +++ b/tests/helpers/livescene.py @@ -305,7 +305,7 @@ def table(depot, record: dict, objs: dict | None = None) -> dict: out[oid] = {"status": "contained", "class": "Component", "in": holder} ks = keys(record) roots = sorted({o for v in ks.values() for o in v} | {GRAPHICS, QUALITY, PLAYER}) - return {"schema": contract.OBJECTS, "roots": roots, "keys": ks, "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": roots, "keys": ks, "objects": out, "fenced": {}, "missing": []} def master() -> dict: diff --git a/tests/helpers/spotobjects.py b/tests/helpers/spotobjects.py index 554edd4..982fcdb 100644 --- a/tests/helpers/spotobjects.py +++ b/tests/helpers/spotobjects.py @@ -354,4 +354,4 @@ def table(depot, objs: dict, roots) -> dict: ext = {"image": "png", "mesh": "glb"}.get(role, "bin") if isinstance(body, bytes) else "json" recs[role] = {"sha256": depot.put(data), "size": len(data), "ext": ext} out[oid] = {"status": "exported", "class": cls, "name": name, "files": recs, **extra} - return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "fenced": {}, "missing": []} diff --git a/tests/helpers/storyui.py b/tests/helpers/storyui.py new file mode 100644 index 0000000..abb9176 --- /dev/null +++ b/tests/helpers/storyui.py @@ -0,0 +1,292 @@ +"""A synthetic story UI as an object table (content.storyui): the ADV widget and its talk windows, the chat phone, the +dialogs, UIManager, the letterbox and the settings, and the built-in LocalizeManager, GraphicsSettings, DOTweenSettings +and TMP_Settings. The font asset of the texts is fenced out of the table; their materials are in it.""" + +from nnnotes.content import storyui +from nnnotes.unity import fontsettings +from tests.helpers import livescene as ls +from tests.helpers.live2dmodel import ref +from tests.helpers.livenotes import comp, node, table + +CAB = "CAB-ui" +WIDGET, PLAIN, BOTH_UI, EMB, DEFAULT, CHAT, UI_MANAGER, DIALOG_A, DIALOG_B = (f"{CAB}:{i}" for i in range(1, 10)) +LETTERBOX = f"{CAB}:10" +CLIPS = [f"{CAB}:{i}" for i in range(11, 15)] +CONTROLLERS = [f"{CAB}:{i}" for i in range(15, 19)] +MAT_JA_DEFAULT, MAT_JA_OUTLINE = f"{CAB}:20", f"{CAB}:21" +FONT_JA = "CAB-font:5" # the font asset of the Japanese texts: in a font bundle, so fenced +SETTINGS = "CAB-st:1" +GRAPHICS, LOCALIZE, DOTWEEN, TMP_SETTINGS = "globalgamemanagers:7", "CAB-lm:1", "CAB-dt:1", "CAB-tmp:1" +SHADER, SCRIPT = "CAB-sh:1", "CAB-m:1" +ADV_SCRIPT = "App.Runtime|App.Adv|" +LOCALIZE_SCRIPT = "App.Runtime|Fwk.Localization|LocalizeText" +WHITE = {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0} +BLACK = {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0} +BUILTIN_ROOTS = [GRAPHICS, LOCALIZE, DOTWEEN, TMP_SETTINGS] +# catalog keys the story UI is planned from that its table does not hold: an embedded talk window whose plain key the +# catalog also has, and a key of another feature +CATALOG_EXTRA = ["EmbUI/Prefab/Parts/Adv/Talk/UIBoth", "Adv/Chat/Prefabs/win_7"] +MASTER_IDS = {k: 101 + i for i, k in enumerate(storyui.MASTER_ID_TEXTS)} +WINDOWS = { # catalog key -> the talk window's GameObject + "UI/Prefab/Parts/Adv/Talk/UIPlain": PLAIN, + "UI/Prefab/Parts/Adv/Talk/UIBoth": BOTH_UI, + "EmbUI/Prefab/Parts/Adv/Talk/UIEmb": EMB, + storyui.WINDOW_KEY: DEFAULT, +} +LOCALIZE_JSON = { + "$script": "Fwk|Fwk.Localization|LocalizeManager", + "_dontDestroyOnload": 1, + "_languageDataList": [ + {"_languageMode": 0, "_fontNames": ["MainJ", "Digits"], "_additionalFontNames": []}, + {"_languageMode": 2, "_fontNames": ["MainT", "Digits"], "_additionalFontNames": ["Extra"]}, + ], + "_materialTypeList": ["Default", "Outline"], +} +# MasterText rows: the texts of the ids of MASTER_IDS in each language, and one row without a traditional Chinese text +MASTER_TEXTS = [ + {"_id": i, "_japanese": f"ja {i}", "_traditionalChinese": f"zh {i}", "_english": f"en {i}"} + for i in (*MASTER_IDS.values(), 301, 302) +] + [{"_id": 999, "_traditionalChinese": None}] +# the master record of the traditional Chinese texts (the texts of the MASTER_IDS ids) +MASTER_RECORD = {"texts": {str(i): f"zh {i}" for i in (*MASTER_IDS.values(), 301, 302)}} + + +def tmp_fields(font: str, material: str, text: str) -> dict: + """The serialized fields of a TMP text, its font asset and material as references (the fields textstyle reads).""" + return { + "m_Enabled": 1, + "m_fontAsset": ref(font), + "m_sharedMaterial": ref(material), + "m_text": text, + "m_isRichText": 1, + "m_parseCtrlCharacters": 1, + "m_fontSize": 36.0, + "m_enableAutoSizing": 0, + "m_fontSizeMin": 18.0, + "m_fontSizeMax": 40.0, + "m_charWidthMaxAdj": 0.0, + "m_lineSpacingMax": 0.0, + "m_fontStyle": 0, + "m_fontWeight": 400, + "m_HorizontalAlignment": 2, + "m_VerticalAlignment": 512, + "m_TextWrappingMode": 1, + "m_overflowMode": 0, + "m_margin": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 0.0}, + "m_lineSpacing": 0.0, + "m_paragraphSpacing": 0.0, + "m_characterSpacing": 0.0, + "m_wordSpacing": 0.0, + "m_enableKerning": 0, + "m_ActiveFontFeatures": [], + "m_isRightToLeft": 0, + "m_isOrthographic": 1, + "m_fontColor": WHITE, + "m_colorMode": 0, + "m_enableVertexGradient": 0, + "m_fontColorGradient": {}, + "m_overrideHtmlColors": 0, + } + + +def material(name: str, outline: bool = False) -> dict: + """A TMP distance-field material record: the values of the font settings' material (and of the Material object).""" + floats = {"_GradientScale": 16.0, "_WeightNormal": 0.0, "_WeightBold": 0.0, "_FaceDilate": 0.0} + colors, keywords = {}, [] + if outline: + floats.update({"_OutlineWidth": 0.2, "_OutlineSoftness": 0.0}) + colors["_OutlineColor"] = BLACK + keywords.append("OUTLINE_ON") + return {"material": name, "keywords": keywords, "floats": floats, "colors": colors, "textures": {}} + + +def prefab(name: str, *components) -> dict: + return {"name": name, "nodes": [node(name, *components)]} + + +def widget() -> dict: + """UIAdvWidget: a text with an enabled LocalizeText (its key 101) and a text without a LocalizeText.""" + return { + "name": "UIAdvWidget", + "nodes": [ + node("UIAdvWidget"), + node( + "UIAdvWidget/Talk", + comp("TextMeshProUGUI", 101, **tmp_fields(FONT_JA, MAT_JA_OUTLINE, "talk")), + comp("MonoBehaviour", 102, LOCALIZE_SCRIPT, m_Enabled=1, _localizeEnabled=1, _masterTextID="101"), + ), + node("UIAdvWidget/Plain", comp("TextMeshProUGUI", 103, **tmp_fields(FONT_JA, MAT_JA_DEFAULT, "plain"))), + ], + } + + +def plain_window() -> dict: + """The plain talk window: its text's LocalizeText is switched off.""" + return { + "name": "UIPlain", + "nodes": [ + node( + "UIPlain", + comp("TextMeshProUGUI", 111, **tmp_fields(FONT_JA, MAT_JA_DEFAULT, "window")), + comp("MonoBehaviour", 112, LOCALIZE_SCRIPT, m_Enabled=1, _localizeEnabled=0, _masterTextID="0"), + ) + ], + } + + +def objects() -> dict: + """{object id: (class, name, {role: JSON document | bytes})} of the story UI and its built-in objects.""" + out = { + WIDGET: ("GameObject", "UIAdvWidget", {"json": widget()}), + PLAIN: ("GameObject", "UIPlain", {"json": plain_window()}), + BOTH_UI: ("GameObject", "UIBoth", {"json": prefab("UIBoth")}), + EMB: ("GameObject", "UIEmb", {"json": prefab("UIEmb")}), + DEFAULT: ("GameObject", "UIDefaultTalkWindow", {"json": prefab("UIDefaultTalkWindow")}), + CHAT: ("GameObject", "UIAdvChatWidget", {"json": prefab("UIAdvChatWidget")}), + UI_MANAGER: ("GameObject", "UIManager", {"json": prefab("UIManager")}), + DIALOG_A: ("GameObject", "UIAdvSkipConfirmDialogWidget", {"json": prefab("UIAdvSkipConfirmDialogWidget")}), + DIALOG_B: ("GameObject", "UICommonDialogWidget", {"json": prefab("UICommonDialogWidget")}), + LETTERBOX: ("Sprite", "letterbox-image", {}), + MAT_JA_DEFAULT: ("Material", "MainJ - Default", {"json": material("MainJ - Default")}), + MAT_JA_OUTLINE: ("Material", "MainJ - Outline", {"json": material("MainJ - Outline", outline=True)}), + SETTINGS: ( + "MonoBehaviour", + "AdvMasterIdSettings", + {"json": {"$script": ADV_SCRIPT + "AdvMasterIdSettings", **MASTER_IDS}}, + ), + GRAPHICS: ("GraphicsSettings", None, {"json": {"m_AlwaysIncludedShaders": [ref(SHADER)]}}), + LOCALIZE: ("MonoBehaviour", "LocalizeManager", {"json": LOCALIZE_JSON}), + DOTWEEN: ( + "MonoBehaviour", + "DOTweenSettings", + {"json": {"$script": "DOTween.dll|DG.Tweening.Core|DOTweenSettings", "timeScale": 1.0}}, + ), + TMP_SETTINGS: ( + "MonoBehaviour", + "TMP Settings", + {"json": {"$script": "Unity.TextMeshPro|TMPro|TMP_Settings", "m_defaultFontAsset": None}}, + ), + SHADER: ls.shader("UI/Default"), + SCRIPT: ("MonoScript", "AdvMasterIdSettings", {"json": {"m_ClassName": "AdvMasterIdSettings"}}), + } + for oid, name in zip(CLIPS, storyui.CLIP_KEYS): + out[oid] = ("AnimationClip", name, {"json": {"name": name, "sampleRate": 60.0, "bindings": []}}) + for oid, name in zip(CONTROLLERS, storyui.CONTROLLER_KEYS): + out[oid] = ("AnimatorController", name, {"json": {"name": name, "clips": []}}) + return out + + +def table_keys() -> dict: + """{catalog key: root object ids} of the story UI's table: the keys storyui.keys gives for the catalog.""" + out = { + storyui.WIDGET_KEY: [WIDGET], + storyui.CHAT_WIDGET_KEY: [CHAT], + storyui.UI_MANAGER_KEY: [UI_MANAGER], + storyui.LETTERBOX_KEY: [LETTERBOX], + storyui.MASTER_ID_SETTINGS_KEY: [SETTINGS], + storyui.DIALOG_KEYS["UIAdvSkipConfirmDialogWidget"]: [DIALOG_A], + storyui.DIALOG_KEYS["UICommonDialogWidget"]: [DIALOG_B], + } + out.update({key: [oid] for key, oid in zip(storyui.CLIP_KEYS.values(), CLIPS)}) + out.update({key: [oid] for key, oid in zip(storyui.CONTROLLER_KEYS.values(), CONTROLLERS)}) + out.update({key: [oid] for key, oid in WINDOWS.items()}) + return out + + +def catalog_keys() -> list[str]: + """The keys of the catalog the story UI is planned from: the keys of the table and CATALOG_EXTRA.""" + return sorted({*table_keys(), *CATALOG_EXTRA}) + + +def index() -> dict: + """The catalog index fact of the planning (its locations' primary keys).""" + return {"locations": [{"primaryKey": key} for key in catalog_keys()]} + + +def fenced() -> dict: + return {FONT_JA: {"class": "TMP_FontAsset", "name": "MainJ SDF"}} + + +def table_doc(depot, objs: dict | None = None, keys: dict | None = None, extra_roots=()) -> dict: + """The object table (nnnotes.objects/1) of the story UI, or of `objs` with the roots of `keys`: the built-in + objects and `extra_roots` are roots as well, and the font asset is fenced.""" + doc = table(depot, table_keys() if keys is None else keys, objects() if objs is None else objs) + doc["roots"] = sorted({*doc["roots"], *BUILTIN_ROOTS, *extra_roots}) + doc["fenced"] = fenced() + return doc + + +def font_settings() -> fontsettings.FontSettings: + """The font settings the texts draw with: the Japanese and the traditional Chinese font assets, and the default and + outline materials of the Japanese font and the outline material of the traditional Chinese one.""" + face = {"m_Scale": 1.0, "m_PointSize": 100.0, "m_LineHeight": 120.0, "m_AscentLine": 90.0, "m_DescentLine": -30.0} + fonts = { + name: {"faceInfo": face, "normalSpacingOffset": 0.0, "boldSpacing": 7.0} for name in ("MainJ SDF", "MainT SDF") + } + materials = { + "MainJ - Default": material("MainJ - Default"), + "MainJ - Outline": material("MainJ - Outline", outline=True), + "MainT - Outline": material("MainT - Outline", outline=True), + } + return fontsettings.FontSettings({"format": fontsettings.FORMAT, "fonts": fonts, "materials": materials}) + + +def font_objects() -> tuple[dict, dict]: + """Synthetic font assets and materials read while building text styles, with their catalog keys.""" + out, keys = {}, {} + font_ids = {"MainJ SDF": FONT_JA, "MainT SDF": "CAB-font:6", "Digits SDF": "CAB-font:7", "Extra SDF": "CAB-font:8"} + for i, (name, oid) in enumerate(font_ids.items()): + base = name[:-4] + mats = {} + for j, kind in enumerate(("Default", "Outline")): + mid = f"CAB-font:{20 + 2 * i + j}" + mat = material(f"{base} - {kind}", outline=kind == "Outline") + mat.update( + {"shader": None, "invalidKeywords": [], "renderQueue": -1, "tags": {}, "disabledPasses": [], "ints": {}} + ) + out[mid] = ("Material", mat["material"], {"json": mat}) + keys[fontsettings.font_dir(name) + mat["material"]] = [mid] + mats[kind] = mid + settings = font_settings().fonts["MainJ SDF"] + doc = { + "$script": "Unity.TextMeshPro|TMPro|TMP_FontAsset", + "m_Name": name, + "m_CharacterTable": [], + "m_GlyphTable": [], + "m_AtlasPopulationMode": 0, + "m_AtlasTextures": [], + "m_Material": ref(mats["Default"]), + "m_FallbackFontAssetTable": [], + "m_FontWeightTable": [], + "m_SourceFontFile": None, + "m_FaceInfo": {"m_FaceIndex": 0, **settings["faceInfo"]}, + "m_AtlasPadding": 15, + "m_AtlasRenderMode": 4134, + **{k: settings.get(k, 0) for k in fontsettings.STYLE_KEYS}, + } + out[oid] = ("MonoBehaviour", name, {"json": doc}) + keys[fontsettings.font_dir(name) + name] = [oid] + return out, keys + + +def chat_window() -> tuple[str, str, tuple]: + """A chat window with two status keys and one empty key.""" + oid = "CAB-chat:1" + doc = prefab( + "win_7", + comp( + "MonoBehaviour", + 2, + ADV_SCRIPT + "AdvChatWindow", + _incomingCallStatusTextKey="301", + _lockScreenStatusTextKey="302", + ), + comp( + "MonoBehaviour", + 3, + ADV_SCRIPT + "AdvChatWindow", + _incomingCallStatusTextKey="301", + _lockScreenStatusTextKey="", + ), + ) + return "Adv/Chat/Prefabs/win_7", oid, ("GameObject", "win_7", {"json": doc}) diff --git a/tests/pipeline/test_assemble.py b/tests/pipeline/test_assemble.py index 5bbb6f2..549e541 100644 --- a/tests/pipeline/test_assemble.py +++ b/tests/pipeline/test_assemble.py @@ -15,9 +15,9 @@ from tests.pipeline.test_objexport import ATLAS_CAB, FAKE_READER, oid, planning_env -def addresses(depot, env, keys: dict, resources=None, builtins=None) -> None: +def addresses(depot, env, keys: dict, resources=None, builtins=None, fonts=()) -> None: """A committed link.addresses result whose table maps `keys` {catalog key: [object ids]}, `resources` - {resource path: [object ids]} and `builtins` {selector: [object ids]}.""" + {resource path: [object ids]} and `builtins` {selector: [object ids]}; the bundles `fonts` hold a font key.""" t = Task("link.addresses", 1, "main", {}, {}, ()) doc = { "schema": link.ADDRESSES, @@ -26,7 +26,7 @@ def addresses(depot, env, keys: dict, resources=None, builtins=None) -> None: "builtins": dict(sorted((builtins or {}).items())), "objects": {}, "files": {}, - "bundles": {}, + "bundles": {b: {"keys": [f"EmbFont/{b}"]} for b in fonts}, } rec = contract.artifact( contract.artifact_id(t.id, "addresses"), @@ -106,11 +106,13 @@ def builtins(self, subject, env): return self.selectors -def env_for(depot, keys=None, names=("all", "atlas"), roots_objects=(), selected=None, resources=None, builtins=None): +def env_for( + depot, keys=None, names=("all", "atlas"), roots_objects=(), selected=None, resources=None, builtins=None, fonts=() +): """A planning environment over the fake bundles with a committed address table and the facts of a request.""" env = planning_env(depot, names, selected=selected) env.facts["catalogs"] = {"main": []} - addresses(depot, env, keys if keys is not None else {}, resources, builtins) + addresses(depot, env, keys if keys is not None else {}, resources, builtins, fonts) subjects = sorted(keys if keys is not None else {}) env.facts["products"] = {"toy.thing": subjects} env.facts["roots"] = {"keys": subjects, "objects": list(roots_objects)} @@ -257,6 +259,44 @@ def test_sprite_images_come_from_the_crop_of_their_atlas(tmp_path): assert f"{t.id}#objects" in {a["id"] for a in doc["artifacts"]} +def test_the_font_fence_cuts_a_reference_from_an_unfenced_bundle(tmp_path): + """The units of an unfenced bundle do not reach a fenced bundle's unit through a reference: the object table lists + the objects its reached objects reference that way as fenced, and the sprite of the unfenced bundle takes no image + from the atlas the product does not reach.""" + depot = Depot(tmp_path / "s") + env = env_for(depot, {"key/a": [oid(13)]}, fonts=["atlas"]) + exported(depot, env) + _, doc = product(depot, env, "m/a", {"m/a": "key/a"}) + table = table_of(depot, doc) + atlas = f"{ATLAS_CAB}:99" + assert table["fenced"] == {atlas: {"class": "SpriteAtlas", "name": "remote"}} + assert atlas not in table["objects"] and atlas not in table["missing"] + assert set(table["objects"][oid(13)]["files"]) == {"json"} + objects = assemble.Objects(table, depot) + assert objects.fenced(atlas) == {"class": "SpriteAtlas", "name": "remote"} and objects.fenced(oid(13)) is None + + +def test_the_font_fence_keeps_a_request_out_of_a_fenced_bundle(tmp_path): + """A request for the unfenced bundle exports its units and not the fenced atlas its sprite references.""" + fenced = env_for(Depot(tmp_path / "f"), {"key/a": [oid(13)]}, selected=["all"], fonts=["atlas"]) + plain = env_for(Depot(tmp_path / "p"), {"key/a": [oid(13)]}, selected=["all"]) + assert "atlas:99" not in ExportStage(FAKE_READER).subjects(fenced) + assert "atlas:99" in ExportStage(FAKE_READER).subjects(plain) + assert units.table(fenced).fences("all:13", "atlas:99") and not units.table(fenced).fences("atlas:99", "all:13") + + +def test_a_fenced_root_reaches_out_of_the_fence_and_takes_the_image_of_its_atlas(tmp_path): + """The objects a product starts from reach what they reference, also out of a fenced bundle: the sprite of the + fenced bundle reaches the unfenced atlas it is packed in, and its image comes from that atlas.""" + depot = Depot(tmp_path / "s") + env = env_for(depot, {}, selected=["all"], roots_objects=[oid(13)], fonts=["all"]) + exported(depot, env) + _, doc = product(depot, env, "m/a", stage=Rooted({}, [oid(13)])) + table = table_of(depot, doc) + assert table["roots"] == [oid(13)] and table["fenced"] == {} + assert f"{ATLAS_CAB}:99" in table["objects"] and set(table["objects"][oid(13)]["files"]) == {"json", "image"} + + def test_depends_names_the_units_it_reads(tmp_path): depot = Depot(tmp_path / "s") env = env_for(depot, {"key/a": [oid(60)]}) @@ -335,6 +375,27 @@ def assemble(self, task, objects): assert list(table_of(depot, doc)["objects"]) == [oid(60)] +def test_a_product_retains_one_root_closure_while_reading_additional_objects(tmp_path): + class Closure(Toy): + def assemble(self, task, objects): + made = super().assemble(task, objects) + retained = contract.loads(objects.depot.input_bytes(task.input("retained"))) + return assemble.Assembly(made.files, table=retained) + + depot = Depot(tmp_path / "s") + env = env_for(depot, {"key/a": [oid(60)], "key/b": [oid(40)]}) + exported(depot, env) + retained = assemble.reach(env, [oid(60)], keys={"key/a": [oid(60)]}).table + stage = Closure({"m/a": "key/a"}) + planned = describe(stage, "m/a", None, env) + data = contract.encode(retained) + extra = contract.Input("retained", depot.put(data), len(data), None, ({"kind": "depot"},)) + task = Task(stage.name, stage.version, "m/a", {}, {}, (*planned.inputs, extra)) + execute(task, depot, registry(stage), force=True) + doc = depot.result(task.key) + assert table_of(depot, doc) == retained + + def test_a_product_names_an_object_the_table_lacks(tmp_path): class Wrong(Toy): def assemble(self, task, objects): diff --git a/tests/pipeline/test_cristages.py b/tests/pipeline/test_cristages.py index 95653db..fee08ce 100644 --- a/tests/pipeline/test_cristages.py +++ b/tests/pipeline/test_cristages.py @@ -119,6 +119,7 @@ def planning(depot, tmp, files: dict, keys=None, boot=True, bundles=()) -> Env: facts = { "raw": raw, "index": catalog_index(keys or {}, bundles), + "catalogs": {}, "boot": boot_input(depot, tmp) if boot else None, } return Env(depot, facts) diff --git a/tests/pipeline/test_link.py b/tests/pipeline/test_link.py index d2bd240..4bc0faf 100644 --- a/tests/pipeline/test_link.py +++ b/tests/pipeline/test_link.py @@ -178,7 +178,7 @@ def test_resource_paths_name_objects_of_the_builtin_files(): def test_builtin_selectors_name_objects_of_the_builtin_files_by_class(): """The builtins table: objects of the built-in files by class and MonoBehaviours by their script's class; no - MonoScript, no member of a hierarchy, nothing of a bundle.""" + MonoBehaviour components are selected by script class as well.""" idx = index(synth.CatalogWriter().build(catalog_entries())) settings = {"file": "globalgamemanagers.assets", "pathId": 20} other = {"file": "globalgamemanagers.assets", "pathId": 21} @@ -195,6 +195,7 @@ def test_builtin_selectors_name_objects_of_the_builtin_files_by_class(): obj(34, "MonoBehaviour", ""), obj(40, "GameObject", "root"), {**obj(41, "Transform"), "unit": "globalgamemanagers.assets:40"}, + obj(42, "MonoBehaviour", "", script=settings, unit="globalgamemanagers.assets:40"), obj(50, "Texture2D", "t"), ], }, @@ -204,7 +205,11 @@ def test_builtin_selectors_name_objects_of_the_builtin_files_by_class(): assert doc["builtins"] == { "GameObject": ["globalgamemanagers.assets:40"], "GraphicsSettings": ["globalgamemanagers:1"], - "MonoBehaviour:SoundSettings": ["globalgamemanagers.assets:30", "globalgamemanagers.assets:31"], + "MonoBehaviour:SoundSettings": [ + "globalgamemanagers.assets:30", + "globalgamemanagers.assets:31", + "globalgamemanagers.assets:42", + ], "Texture2D": ["globalgamemanagers.assets:50"], } ix = views.AddressIndex.from_addresses(doc) @@ -212,6 +217,7 @@ def test_builtin_selectors_name_objects_of_the_builtin_files_by_class(): assert [o.id for o in ix.builtin("MonoBehaviour:SoundSettings")] == [ "globalgamemanagers.assets:30", "globalgamemanagers.assets:31", + "globalgamemanagers.assets:42", ] assert ix.builtin("MonoBehaviour:Unnamed") is None and ix.builtin("MonoScript") is None assert link.addresses(idx, censuses())["builtins"] == {} diff --git a/tests/pipeline/test_live.py b/tests/pipeline/test_live.py index 2e4c78b..19c88f2 100644 --- a/tests/pipeline/test_live.py +++ b/tests/pipeline/test_live.py @@ -114,7 +114,14 @@ def table(depot, monos=()) -> Input: "class": "MonoBehaviour", "files": {"json": {"sha256": depot.put(data), "size": len(data), "ext": "json"}}, } - doc = {"schema": contract.OBJECTS, "roots": sorted(objects), "keys": {}, "objects": objects, "missing": []} + doc = { + "schema": contract.OBJECTS, + "roots": sorted(objects), + "keys": {}, + "objects": objects, + "fenced": {}, + "missing": [], + } return stored(depot, contract.encode(doc), "objects") diff --git a/tests/pipeline/test_objexport.py b/tests/pipeline/test_objexport.py index ba54dbd..1a1cb2b 100644 --- a/tests/pipeline/test_objexport.py +++ b/tests/pipeline/test_objexport.py @@ -632,7 +632,12 @@ def planning_env(depot, names=("all", "atlas"), selected=None, scripts=SCRIPTS, for n in names: data = f"fake:{n}".encode() bundles[n] = Input("bundle", depot.put(data), len(data), f"{n}.bundle", ({"kind": "depot"},)) - facts = {"bundles": bundles, "selected": sorted(selected if selected is not None else names), "classes": classes} + facts = { + "bundles": bundles, + "catalogs": {}, + "selected": sorted(selected if selected is not None else names), + "classes": classes, + } env = Env(depot, facts) for n in names: t = Task(census_mod.CensusStage.name, census_mod.CensusStage.version, n, {}, {}, (bundles[n],)) @@ -1514,6 +1519,21 @@ def test_the_context_holds_the_shipped_type_trees_of_its_scripts(tmp_path, monke assert "typetrees" not in describe(stage, "all:70", None, planning_env(Depot(tmp_path / "t"))).context +def test_the_font_fence_of_a_request(tmp_path): + """units.build: a reference of an unfenced unit into a fenced bundle reaches nothing, from a root or from a unit + the walk reaches; a fenced unit's references reach as usual; `info` holds the objects of the fenced units.""" + censuses = [(n, census_mod.census_env(BUNDLES[n](), shipped=True)) for n in ("all", "atlas")] + atlas = f"{ATLAS_CAB}:99" + t = units.build(censuses, ["all", "atlas"], ["all"], fenced=["atlas"]) + assert "atlas:99" not in t.subjects() and "all:13" in t.subjects() + assert t.fences("all:13", "atlas:99") and not t.reaches("all:13", "atlas:99", True) + assert not t.fences("atlas:99", "all:13") and t.reaches("atlas:99", "all:13", True) + assert t.fenced == {s for s in t.kinds if s.startswith("atlas:")} + assert t.info[atlas] == {"class": "SpriteAtlas", "name": "remote"} and oid(13) not in t.info + r = units.build(censuses, ["all", "atlas"], [], roots=[atlas], fenced=["atlas"]) + assert r.subjects() == ["atlas:97", "atlas:98", "atlas:99"] # the root's references, across no fence + + def test_the_unit_table(tmp_path): env = planning_env(Depot(tmp_path / "s"), ("all", "atlas", "plain"), selected=["all"], classes=["Sprite"]) t = units.table(env) diff --git a/tests/pipeline/test_story.py b/tests/pipeline/test_story.py index dc59de6..8b97523 100644 --- a/tests/pipeline/test_story.py +++ b/tests/pipeline/test_story.py @@ -2,7 +2,7 @@ import pytest -from nnnotes.content import storychat, storyepisode, storyscene +from nnnotes.content import storychat, storyepisode, storyscene, storyui from nnnotes.formats import crilips from nnnotes.pipeline import assemble, contract, cristages, live, story from nnnotes.pipeline.assemble import Objects @@ -12,18 +12,23 @@ from nnnotes.pipeline.live2d import MODEL from nnnotes.pipeline.stages import Env, describe, execute, registry from nnnotes.pipeline.story import ( + PART_STAGES, + UI, AssetStage, ChatStage, CriLipsStage, SceneStage, SoundStage, StoryStage, + UiStage, VideoStage, ) from tests.formats.test_acb_categories import acb_with_categories from tests.formats.test_crilips import _elf_with_rodata, _synthetic_version from tests.formats.test_usm import KEY from tests.helpers import story as sh +from tests.helpers import storyui as sui +from tests.helpers import synth from tests.pipeline.test_assemble import addresses from tests.pipeline.test_cristages import export_result, fake_tools, planning, usm_file from tests.pipeline.test_live import master_record, run, stored, table @@ -364,6 +369,67 @@ def test_a_chat_shard_without_a_text_asset_has_no_rows(tmp_path): assert chat["sounds"] == [] and chat["cueSheets"] == [] +# ---------------------------------------------------------------- the story UI product + + +def test_the_story_ui_product_has_one_subject_per_client_language(tmp_path): + env = Env(Depot(tmp_path / "d"), {"index": sui.index()}) + stage = UiStage() + assert stage.ui_keys("zh-Hant", env) == storyui.keys(sui.catalog_keys()) + assert stage.keys("ja", env) == sorted([*storyui.keys(sui.catalog_keys()), "Adv/Chat/Prefabs/win_7"]) + assert stage.resources("ja", None) == ["DOTweenSettings", "TMP Settings"] + assert stage.builtins("en", None) == ["GraphicsSettings", "MonoBehaviour:LocalizeManager"] + assert stage.manifest("zh-Hant") == "storyui/zh-Hant.json" + assert UI not in PART_STAGES + with pytest.raises(ValueError, match="not a client language"): + stage.manifest("fr") + with pytest.raises(ValueError, match="not a client language"): + stage.keys("fr", env) + + +def test_the_story_ui_master_input_holds_the_texts_of_the_language(tmp_path): + depot = Depot(tmp_path / "d") + env = Env(depot, {"master": sh.master_dir(tmp_path, {"MasterText": sui.MASTER_TEXTS})}) + stage = UiStage() + inp = assemble.master_input(env, stage.record("zh-Hant", env)) + assert inp.role == assemble.MASTER_INPUT + assert contract.loads(depot.input_bytes(inp)) == { + "texts": {**{str(i): f"zh {i}" for i in (*range(101, 111), 301, 302)}, "999": ""} + } + ja = contract.loads(depot.input_bytes(assemble.master_input(env, stage.record("ja", env)))) + assert ja["texts"]["101"] == "ja 101" and ja["texts"]["999"] == "" + + +def test_the_story_ui_product_holds_ui_objects_and_reads_font_assets_and_chat_status_texts(tmp_path): + depot = Depot(tmp_path / "d") + fonts, keys = sui.font_objects() + chat_key, chat_oid, chat = sui.chat_window() + obj_doc = sui.table_doc( + depot, objs={**sui.objects(), **fonts, chat_oid: chat}, keys={**sui.table_keys(), **keys, chat_key: [chat_oid]} + ) + ui_doc = sui.table_doc(depot) + master = stored( + depot, contract.encode({"texts": {str(i): f"zh {i}" for i in (*range(101, 111), 301, 302)}}), "master" + ) + inputs = [ + stored(depot, contract.encode(obj_doc), "objects"), + stored(depot, contract.encode(ui_doc), "ui.objects"), + stored(depot, contract.encode({"CAB-font": "font.bundle"}), "font-bundles"), + stored(depot, synth.axml(["versionName", "manifest", "1.2.3"], 2), "apk:AndroidManifest.xml"), + master, + ] + _, _, arts = run(depot, UiStage(), "zh-Hant", inputs) + assert {p: a["semantics"]["kind"] for p, a in arts.items()} == {"ui.json": "story.ui", "objects": "product.objects"} + doc = read_json(depot, arts["ui.json"]) + assert doc["language"]["mode"] == 2 and doc["widget"] == sui.WIDGET + assert doc["masterIdTexts"]["_skipButtonTextId"] == {"id": 102, "text": "zh 102"} + assert doc["chatStatusTexts"] == {"301": "zh 301", "302": "zh 302"} + assert set(read_json(depot, arts["objects"])["objects"]) == set(sui.objects()) - {sui.SCRIPT} + assert read_json(depot, arts["objects"])["fenced"] == sui.fenced() + assert read_json(depot, arts["objects"])["roots"] == ui_doc["roots"] + assert read_json(depot, arts["objects"])["keys"] == sui.table_keys() + + # ---------------------------------------------------------------- the products a story plays with def test_part_paths_give_the_manifest_of_each_part(): parts = { @@ -454,7 +520,14 @@ def video_table(depot, key=VIDEO_KEY, info=None) -> Input: oid: {"status": "exported", "class": "MonoBehaviour", "files": {"json": {"sha256": depot.put(data), "size": len(data), "ext": "json"}}}, } # fmt: skip - doc = {"schema": contract.OBJECTS, "roots": [oid], "keys": {key: [oid]}, "objects": objects, "missing": []} + doc = { + "schema": contract.OBJECTS, + "roots": [oid], + "keys": {key: [oid]}, + "objects": objects, + "fenced": {}, + "missing": [], + } return stored(depot, contract.encode(doc), "objects") diff --git a/tests/unity/test_textstyle.py b/tests/unity/test_textstyle.py index 6e15b11..0be5a53 100644 --- a/tests/unity/test_textstyle.py +++ b/tests/unity/test_textstyle.py @@ -1,13 +1,18 @@ """textstyle: TMP text records and material style values (synthetic data).""" import json +import struct +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 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} @@ -369,3 +374,121 @@ def lines(ja, en): 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 +# -------------------------------------------------------------------------- +ALIGN = 0x4000 # the align flag of a type tree node +LOCALIZE_HASH = "597220768ec4996f80d7cfd5e139645f" +LOCALIZE_LANGUAGES = [ + (0, ["A-OTF-Main", "VibeMOPro"], ["NotoSansJP"]), + (2, ["TW-Main", "Digits"], []), +] +LOCALIZE_MATERIALS = ["Default", "Outline"] +EXPECTED_LANGUAGES = { + "languages": { + 0: {"fontNames": ["A-OTF-Main", "VibeMOPro"], "additionalFontNames": ["NotoSansJP"]}, + 2: {"fontNames": ["TW-Main", "Digits"], "additionalFontNames": []}, + }, + "materialTypes": ["Default", "Outline"], +} + + +class _Out: + """Serialized fields as the reader of textstyle reads them: little-endian, each string aligned to 4 bytes.""" + + def __init__(self): + self.raw = bytearray() + + def i32(self, v: int) -> None: + self.raw += struct.pack(" None: + self.raw += struct.pack(" None: + self.raw.append(v) + + def align(self) -> None: + self.raw += b"\0" * (-len(self.raw) % 4) + + def string(self, s: str) -> None: + data = s.encode("utf-8") + self.i32(len(data)) + self.raw += data + self.align() + + def strings(self, values) -> None: + self.i32(len(values)) + for s in values: + self.string(s) + + +def localize_manager_bytes(languages_, materials) -> bytes: + """The serialized LocalizeManager: its MonoBehaviour header, _dontDestroyOnload, languages and materials.""" + out = _Out() + out.i32(0) # m_GameObject + out.i64(0) + out.u8(1) # m_Enabled + out.align() + out.i32(1) # m_Script + out.i64(7) + out.string("LocalizeManager") # m_Name + out.u8(1) # _dontDestroyOnload + out.align() + out.i32(len(languages_)) + for mode, fonts, additional in languages_: + out.i32(mode) + out.strings(fonts) + out.strings(additional) + out.strings(materials) + return bytes(out.raw) + + +class _Boot: + """Boot data with one MonoBehaviour: the LocalizeManager of the serialized bytes `raw`.""" + + def __init__(self, raw: bytes): + self._by_type = {"MonoBehaviour": [SimpleNamespace(get_raw_data=lambda: raw)]} + + 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, + "_languageDataList": [ + {"_languageMode": mode, "_fontNames": fonts, "_additionalFontNames": additional} + for mode, fonts, additional in LOCALIZE_LANGUAGES + ], + "_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)) diff --git a/tests/unity/test_typetrees.py b/tests/unity/test_typetrees.py index ce285e6..e1a0ceb 100644 --- a/tests/unity/test_typetrees.py +++ b/tests/unity/test_typetrees.py @@ -515,3 +515,16 @@ def test_unity_rows_refuses_what_it_has_no_layout_for(fields, message): gen_typetrees().unity_rows(GEN_HEADER + fields) with pytest.raises(ValueError, match="unexpected MonoBehaviour header"): gen_typetrees().unity_rows(GEN_HEADER[:1] + GEN_HEADER[5:]) + + +def test_aligned_fields_get_the_align_flag_and_missing_ones_are_refused(): + g = gen_typetrees() + rows = [[0, "MonoBehaviour", "Base", 0], [1, "UInt8", "_dontDestroyOnload", 0], [1, "int", "count", 0]] + assert g._aligned(rows, ("_dontDestroyOnload",)) == [ + [0, "MonoBehaviour", "Base", 0], + [1, "UInt8", "_dontDestroyOnload", ALIGN], + [1, "int", "count", 0], + ] + assert g.ALIGNED_FIELDS == {"LocalizeManager": ("_dontDestroyOnload",)} + with pytest.raises(ValueError, match="no root field \\['missing'\\]"): + g._aligned(rows, ("missing",)) From e1cb5cb7cf65cfaba05d0b041553cd49ff9dc052 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 17 Oct 2026 15:27:49 +0800 Subject: [PATCH 5/5] docs: describe font references and story UI products Document the fenced object entries, localization inputs and the per-language UI document. --- docs/products.md | 24 ++++++++++++++++++++++++ docs/stages.md | 1 + 2 files changed, 25 insertions(+) diff --git a/docs/products.md b/docs/products.md index eb469cf..ca9eaae 100644 --- a/docs/products.md +++ b/docs/products.md @@ -66,6 +66,7 @@ changes, or what it reaches changes. "CAB-…:40": {"status": "exported", "class": "Texture2D", "name": "texture_00", "files": {"image": {"sha256": "…", "size": 812345, "ext": "png"}, "json": {"sha256": "…", "size": 640, "ext": "json"}}}}, + "fenced": {}, "missing": []} ``` @@ -74,6 +75,11 @@ game loads it by. Each object has the status of its item (the most severe when t class and name where known, its files by role (the artifacts of its items), the object whose file holds it (`in`, a contained object), or the reason code of a generic, unsupported or failed object. +`fenced` names the objects that a reached object references across the font fence: the font bundles (the keys under +`EmbFont/` and `Font/`) are not followed from objects outside them, so each such reference is listed with the class and +name of the object it names, while the reference itself carries only its file and path id. A reference to an object of +the table is not listed. + ## Product layout A product layout places products for a player (`export assets --layout product`, `export live`, @@ -365,6 +371,7 @@ A story episode plays with products that several episodes share, made by `nnnote | `story.asset` | a catalog key a story loads: a stage (`Adv/Stage/`), frame, effect, post effect, still, timeline, chat window, chat icon or stamp, transition | `storyassets/.json`, `` the key with `/` as `~` | the key | `asset.json` and the object table | | `story.video` | a video key `Cri/Video/` (the video of a Movie / Clip command) | `videos/.json`, `` the asset with `/` as `~` | the key | `video.json` and `movie.webm` | | `story.chat` | `default` | `storychat/default.json` | the shared chat shards `Adv/Chat/AdvChat-Text`, `Adv/Chat/AdvChat-Sound` and `Adv/Chat/AdvChat-SoundCueSheet` | `chat.json` | +| `story.ui` | a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) | `storyui/.json` | the story UI's catalog keys (the widget, talk windows, chat widget, dialogs, UIManager, letterbox, animations and settings), the built-in `GraphicsSettings` and `LocalizeManager`, and the resources `DOTweenSettings` and `TMP Settings` | `ui.json` and the object table | | `story` | a `MasterAdv` id | `stories/.json` | the episode key `Adv/Episode//` (``: the row's `_advEpisodeAsset`), its shard keys (the key with `-Text`, `-Sound`, `-SoundCueSheet`, `-Video`) and `EmbCommon/Adv/Settings/AdvPlayerSettings` | `episode.json` and `story.json` | `story.sound`, `story.crilips`, `story.video`, `story.chat` and `story` keep no objects: their files are all they @@ -416,6 +423,23 @@ chat message texts), `sounds` (their sound rows) and `cueSheets` (the cue sheet rows of its shard as stored; a shard key without a TextAsset gives `[]`. The `product` layout makes it when the selection holds the three shard keys. +`story.ui` is the ADV story UI in one client language: the ADV front canvas, the talk windows, the chat phone, the +dialogs and the settings, with the text records of their texts in that language. Its input `master` holds the +`MasterText` rows, each text in the language's column (`{"texts": {MasterText id: text}}`, the empty text when the row +has none). `ui.json` has `format` 1 and `language` (`code`, `mode` (the LanguageMode), `field` (the text column), +`lineSpacing`, `fontNames`, `additionalFontNames`, `materialTypes` and `fontSwap`, the Japanese font asset names mapped +to the language's), `widget`, `talkWindows` (each window's name to its object), `chatWidget`, `dialogs`, `uiManager`, +`letterbox`, `clips`, `controllers`, `settings` (`masterIdSettings`, `dotween`, `tmpSettings`, `localizeManager`), +`graphics`, `texts` (each TMP text by its component id: `localized`, the font asset, material and line spacing its +LocalizeText gives it in the language, or null, and `style`, its text record), `textStyle` (the language's field, the +units and the line metrics of the font roles the texts use), `masterIdTexts` (each `AdvMasterIdSettings` text with its +MasterText id and text), `chatStatusTexts` (incoming-call and lock-screen status keys to their texts), `bindings` +and `shaders`. Text styles use settings read from the client's font assets. The input `ui.objects` is the closure +of the UI roots; font assets and chat windows are additional assembly inputs for metrics and status text keys. +The retained object table is the UI closure, so the font assets' files remain in the depot. The input +`font-bundles` names the bundles holding the font assets, and `apk:AndroidManifest.xml` supplies the client version. +A client requests the product for each language it uses. + `story` is one episode. Its input `master` is the episode's master record: its `MasterAdv` row, the `MasterText` row of its title (none when `_titleTextId` is empty) and every `MasterAdvChat` row. `episode.json` is the episode as `export episode` writes it, each resource given by its kind and address: the command list with the lines of every diff --git a/docs/stages.md b/docs/stages.md index 68b59d1..3c6fda4 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -42,6 +42,7 @@ codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japan | `story.asset` | a catalog key a story loads | `objects`: the object table of what the key reaches | `asset.json` and the object table | | `story.video` | a video key `Cri/Video/` | `objects`: the object table of the key; `usm:` and `movies`: the key's USM and its `cri.movie` files with their facts ([products](products.md#stories)); the `cri.movie` task takes `format` `webm` | `video.json` and `movie.webm` | | `story.chat` | `default` | `objects`: the object table of the shared chat shards | `chat.json` | +| `story.ui` | a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) | `objects`: UI, font and chat-window objects; `ui.objects`: the retained UI closure; `master`: localized `MasterText` rows; `font-bundles`; `apk:AndroidManifest.xml` ([products](products.md#stories)) | `ui.json` (text styles and chat status texts) and the UI object table | | `story` | a `MasterAdv` id | `objects`: the object table of the episode's command list, its shards and the ADV player settings ([products](products.md#stories)); `master`: the episode's master record | `episode.json` and `story.json` | | `link.addresses` | global | catalog index, censuses (the built-in files' too) | key -> objects (a scene key: the objects of the scene's file outside a hierarchy), Resources.Load path -> objects, built-in selector (class, MonoBehaviour script class) -> objects, serialized file -> bundle or built-in file, bundle -> keys | | `view.` | a catalog (`main`) | `master:` per master table it reads, `rules` (the view's rule and the resolvers it uses), `addresses` (the part of the address table it read: the keys looked up and the prefixes listed); no parameters, atoms or context | `view.:#view` (facts: rows, entries, gaps, unreferenced) |