diff --git a/docs/products.md b/docs/products.md index 25e114a..9b2f1da 100644 --- a/docs/products.md +++ b/docs/products.md @@ -32,7 +32,8 @@ the request of `unity.export` then holds the units of those objects and every un census `refs` ([units](stages.md#units)), and nothing else of their bundles. What a task reaches: the units holding its roots, then every unit that an item of a reached unit references (the -item's `refs`), through the `unity.export` results. A sprite whose atlas is in another bundle has its `image` from the +item's `refs`; a SpriteAtlas reached through a reference does not reach the sprites it packs), through the +`unity.export` results. A sprite whose atlas is in another bundle has its `image` from the `sprite.crop` result of that atlas. A reached unit that the request did not export is an error of the request; an object that a reached object references and no census holds is listed as `missing`. @@ -53,6 +54,7 @@ changes, or what it reaches changes. ```json {"schema": "nnnotes.objects/1", "roots": ["CAB-…:-4153…"], + "keys": {"Character/Live2D/…/model/…": ["CAB-…:-4153…"]}, "objects": { "CAB-…:-4153…": {"status": "exported", "class": "GameObject", "name": "model", "files": {"json": {"sha256": "…", "size": 5120, "ext": "json"}}}, @@ -63,8 +65,9 @@ changes, or what it reaches changes. "missing": []} ``` -Each object has the status of its item (the most severe when two stages made items for it), its class and name -where known, its files by role (the artifacts of its items), the object whose file holds it (`in`, a contained +`keys` gives the root objects of each catalog key of the product, so an assembler finds an object by the key the +game loads it by. Each object has the status of its item (the most severe when two stages made items for it), its +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. ## Product layout @@ -244,3 +247,31 @@ option defaults of preset 1 (`MasterOptionDefault`), `MasterLiveNoteSe`, `Master The `product` layout makes the `live.music` products of the musics whose BGM key the selection holds, and the default `live.sounds` when the selection holds every cue sheet of its sounds; both need the master data. + +## Note highway + +What a live spawns and animates per note is three products of `nnnotes.pipeline.live`, each shared by every live that +loads it. Their roots are the objects of their keys, and each document names objects of its object table: + +| Product | Subject | Manifest | Roots | Document | +|---|---|---|---|---| +| `live.notes` | `default` | `notes/default.json` | the note view prefabs (`EmbLive/Prefabs/LiveGame/_note_view`), the slide and guide line views, the pair line, the groove loop, and the lane effect settings `Effect/Live/LaneEffect/effect001/LiveLaneEffectAssetSettings` | `notes.json` | +| `live.noteskin` | a note skin, `MasterLiveNoteSkin._assetName` | `noteskins/.json` | the skin asset `Live/Note//LiveNoteSkinAsset` and the three flick views | `noteskin.json` | +| `live.noteeffect` | a note effect set, `MasterLiveNoteEffectSkin._assetName` or its `Light` variant (the set the game loads at quality Low where the catalog has it) | `noteeffects/.json` | the effect settings `Effect/Live/NoteEffect//LiveNoteEffectAssetSettings` | `noteeffect.json` | + +Each document has `format` 1 and + +| Field | | +|---|---| +| `prefabs` (notes) | `{name: object id}` of the prefab GameObjects: `tap_note_view`, ..., `slide_line_view`, `guide_line_view`, `pair_note_line`, `note_groove_loop` | +| `laneEffect` (notes) | the object id of the lane effect settings | +| `settings` (notes) | the option defaults of preset 1 (`optionDefaults`) and the ranges (`optionRanges`) of the items the note runtime and the player read, the `MasterLiveSettings` values of the note speed and slides (`liveSettings`), the note display time of the default speed (`noteDisplayTimeMs`), the lane geometry, the default skin and effect set (`skin`, `effect`) and the lane effect set (`laneEffect`), and the names of every skin and effect set by option value (`skins`, `effects`) | +| `name`, `skin` (noteskin) | the skin's name and the object id of its `LiveNoteSkinAsset` | +| `name`, `settings` (noteeffect) | the set's name and the object id of its `LiveNoteEffectAssetSettings`, which references the effect prefabs | +| `bindings` | `{prefab GameObject id: {Animator node path: {clip id: [{path, attribute}]}}}`: the clips each Animator of the table's prefabs plays (its controller's clips and the `_loopAnim` of a `LoopAnimationTimeSync` on its `_animator`; for a skin, the arrow loop of each flick unit on its flick view's `LoopAnimationTimeSync`), named from the Animator's node (`nnnotes.unity.bindings`) | +| `shaders` | `{shader name: object id}` of the shaders of the object table | + +The object table holds every object the product reaches but the MonoScripts; of a shader it keeps the parsed form +(`json`), the variant index (`index`) and every GLSL ES 3.00 program. The `product` layout makes `live.notes` when the +selection holds all its keys and the master data is there, and the `live.noteskin` and `live.noteeffect` products of +the skin and effect set keys the selection holds. diff --git a/docs/stages.md b/docs/stages.md index 3415322..432043a 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -31,6 +31,9 @@ codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japan | `spot.files` | a spot situation key (`Spot///`) | `objects`; `master`: the spot's master record | `spot.json` and the Spine files | | `live.music` | a music id (`MasterLiveMusic`) | `objects`; `acb:` and `sheets`: the BGM's cue sheet and its `cri.audio` files ([products](products.md#live-sounds)); `master`: the music's master record; parameter `format` (`aac`) | `music.json` and the BGM in the web format | | `live.sounds` | the note sound sets offered: `default` or `NoteSePatternId=` | `objects` (the sound settings of the APK's built-in files); `acb:` and `sheets` of each cue sheet; `apk:assets/Cri/Sound/Sirius.acf`; `master` | `sounds.json` and the decoded sounds | +| `live.notes` | `default` | `objects`: the object table of the note prefabs and the lane effect settings ([products](products.md#note-highway)); `master`: the settings' master record | `notes.json` and the object table | +| `live.noteskin` | a note skin (`skin001`) | `objects`: the object table of the skin and the flick views | `noteskin.json` and the object table | +| `live.noteeffect` | a note effect set (`effect001`, `effect001Light`) | `objects`: the object table of the effect settings | `noteeffect.json` and the object table | | `link.addresses` | global | catalog index, censuses (the built-in files' too) | key -> objects, 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) | | `view.voices` | a catalog (`main`) | `master:
` per master table its rules read, `rules`, `addresses`, `sheets` (each cue sheet the `cri.audio` results are known by that a voice names or the rules' prefixes list: its task and ACB content id), `acb:` (the ACB of each decoded one), `episodes` (per story episode and part, the `unity.export` artifact holding the object its key names), `adv:` (those contents); context `audio` (the keys of those `cri.audio` results); runs after `unity.export` and `cri.audio` | `view.voices:#view` (facts: rows, entries, gaps, unreferenced streams) | @@ -116,7 +119,9 @@ bundle's content changes, so a unit keeps its task across catalog versions. A run exports the units holding an object of the selected bundles, the units of the objects the fact "roots" names (catalog keys, Resources.Load paths, built-in selectors and object ids: a product's roots, [products](products.md)) and every unit reachable from those through what their objects reference (the census `refs`, across the censuses of the run); with `--only-class` -only the units whose root object is of those classes. The selection is not part of any key: a unit's task is the same +only the units whose root object is of those classes. A SpriteAtlas reached through a reference adds its textures +but not the sprites it packs: a sprite needs its atlas' textures, not the atlas' other sprites (an atlas the run +starts from adds them all). The selection is not part of any key: a unit's task is the same whichever request reaches it. A task's key includes the atoms `reader` and `sniff`, the converter `generic.json`, the converters (with their diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index 28c40a9..cd1a1b2 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -122,6 +122,9 @@ def matches(self, stage: str) -> bool: StageSource("spot.files", "nnnotes.pipeline.spot:SpotStage", output=True, product=True), StageSource("live.music", "nnnotes.pipeline.live:MusicStage", output=True, product=True), StageSource("live.sounds", "nnnotes.pipeline.live:SoundsStage", output=True, product=True), + StageSource("live.notes", "nnnotes.pipeline.live:NotesStage", output=True, product=True), + StageSource("live.noteskin", "nnnotes.pipeline.live:NoteSkinStage", output=True, product=True), + StageSource("live.noteeffect", "nnnotes.pipeline.live:NoteEffectStage", 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/livenotes.py b/src/nnnotes/content/livenotes.py index 3ffdabc..3da94dd 100644 --- a/src/nnnotes/content/livenotes.py +++ b/src/nnnotes/content/livenotes.py @@ -1,46 +1,87 @@ -"""Live note / effect runtime data -> livenotes/notes.json + textures + shaders. - -Everything spawned or animated per note in the in-play note highway: -note view prefabs (tap, flick +left/right/direction, -trace, slide/slide_end/connection, guide, slide_combo(+skip), none), long-note -body prefabs (slide_line_view, guide_line_view), the pair line prefab, -the note skin asset (sprites per note type/width/tilt), the note-effect skin settings -of the NoteEffectId option and the lane-effect settings (set effect001) with every effect -prefab they reference (particle systems exported with all module data), the judgement text view and -the judgement / combo sprite assets, the combo animator, the note animation controllers -and clips they reference, and the option / master values the motion formulas read. -Only what the default options load: the effect set of NoteEffectId (307) at quality Middle -(the `Light` set is Low only, `NoteSkinLoadStep.GetLightQualityAddress`; the -`Simple` sets are not used with default options); no bar / skill line prefabs -(MeasureLineDisplay 109 / LiveSkillActivationPositionDisplay 309 FALSE). With liveoptions.LiveOptions -also: the other offered note skins (`noteSkins`, `settings.skins`), the other offered note effect sets -and, with LiveQuality Low offered, their `Light` variants where the catalog has them (in `assets`, -`settings.effects`), with MeasureLineDisplay offered the bar line view (`prefabs.bar_line_view`: the -LiveBarLineView prefab the live scene's LiveBarLineViewContainer instantiates, its _elementPrefab), and the -option tables (`settings.optionDefaults` / `optionRanges` of every item the player offers). - -Objects go through export.Exporter in its "livenotes" clip format (clips and animator -controllers in registries referenced by id; binding paths and attributes left as -"crc32:" until a pass names them), following references to AnimatorControllers -and SpriteAtlases. Clip binding paths that point into the Live scene's UI subtrees -are named from that scene (`add_path_source`), which is not exported here. +"""The note highway of a live: what is spawned or animated per note, and its settings. + +Three products, each read from a product's object table (`Objects`, pipeline.assemble), never from a bundle, and each +shared by every live that loads it: + +notes the highway itself (`notes`, notes.json format NOTES_FORMAT): the note view prefabs (tap, flick + +left/right, trace, slide/slide_end/connection, guide, slide_combo(+skip), none), the long-note body + prefabs (slide_line_view, guide_line_view), the pair line and the groove loop (NOTE_PREFABS), the lane + effect settings (LANE_EFFECT_KEY, the set the game always loads: LaneEffectLoadStep.GetAssetPath), and + `settings`: the option defaults (preset 1) and ranges of the items the note runtime and the player read, + the MasterLiveSettings values, the note display time, the lane geometry, the default note skin and effect + set and the names of every note skin and effect set by option value (notes_settings) +noteskin a note skin (`noteskin`, MasterLiveNoteSkin._assetName: the LiveNoteSkinAsset of its key skin_key), with + the bindings of the arrow loops its flick units give the flick views (FLICK_SKIN_UNITS) +noteeffect a note effect set (`noteeffect`, MasterLiveNoteEffectSkin._assetName or its `Light` variant, which the + game loads at quality Low where the catalog has it: the LiveNoteEffectAssetSettings of its key + effect_settings_key and the effect prefabs it references) + +Each document names objects by id: notes.json `prefabs` {name: GameObject} and `laneEffect`, noteskin.json `skin`, +noteeffect.json `settings`; and each has + + bindings {prefab GameObject id: {Animator node path: {clip id: [{path, attribute}]}}}: the clips each Animator of + the table's prefabs plays (its controller's clips, the `_loopAnim` of a LoopAnimationTimeSync on its + `_animator`; for a skin, the arrow loop of each flick unit on the flick view's LoopAnimationTimeSync, + LiveFlickNoteView.OnSetupNoteSkin), named from the Animator's node (prefabs.clip_bindings) + shaders {shader name: object id} (prefabs.shader_table) + +Of the objects a product keeps all but the MonoScripts; of a shader its parsed form, variant index and the programs of +the player's graphics API. + +`extract` writes the note runtime of a live directory instead (livenotes/notes.json of the default options and the +variants a liveoptions.LiveOptions offers, with textures and shaders) through export.Exporter, following references +to AnimatorControllers and SpriteAtlases. """ from __future__ import annotations +import re from pathlib import Path - -import numpy as np +from typing import TYPE_CHECKING from ..core.jsonio import write_json -from ..source.catalog import Catalog -from ..unity.export import Exporter -from ..unity.player import PlayerData from .liveoptions import OPTION_ITEM_TYPES, PLAYER_ITEMS, LiveOptions -from .livescene import LANE_COUNT, LANE_VIEW -from .score import master_table +from .prefabs import ( + Objects, + Prefab, + clip_bindings, + component_class, + controller_clips, + has_json, + prefab_ids, + ref_id, + script_class, + shader_table, +) + +if TYPE_CHECKING: + from ..source.catalog import Catalog + from ..unity.export import Exporter + from ..unity.player import PlayerData + +__all__ = [ + "EFFECT_KEY", + "FLICK_SKIN_UNITS", + "LANE_EFFECT_KEY", + "NOTE_PREFABS", + "NOTES_FORMAT", + "NOTES_TABLES", + "SKIN_KEY", + "asset_name", + "effect_settings_key", + "extract", + "notes", + "notes_keys", + "notes_record", + "notes_settings", + "noteeffect", + "noteskin", + "noteskin_keys", + "settings", + "skin_key", +] -# --- keys ------------------------------------------------------------------ +NOTES_FORMAT = 1 # notes.json, noteskin.json and noteeffect.json "format" NOTE_VIEWS = [ "tap", "flick", @@ -55,18 +96,38 @@ "slide_combo_skip", "none", ] -PREFABS = {f"{v}_note_view": f"EmbLive/Prefabs/LiveGame/{v}_note_view" for v in NOTE_VIEWS} -PREFABS.update( +NOTE_PREFABS = {f"{v}_note_view": f"EmbLive/Prefabs/LiveGame/{v}_note_view" for v in NOTE_VIEWS} +NOTE_PREFABS.update( { "slide_line_view": "EmbLive/Prefabs/LiveGame/slide_line_view", "guide_line_view": "EmbLive/Prefabs/LiveGame/guide_line_view", "pair_note_line": "EmbLive/Prefabs/LiveGame/PairNoteLine/LiveGamePairNoteView", "note_groove_loop": "EmbLive/Prefabs/LiveGame/Effect/note_groove_loop", - "judge_effect_view": "EmbLive/Prefabs/UILiveNoteJudgeEffectView", } ) +LANE_EFFECT = "effect001" +LANE_EFFECT_KEY = f"Effect/Live/LaneEffect/{LANE_EFFECT}/LiveLaneEffectAssetSettings" +# LiveAddressablePath.GetLiveNoteSkinAssetPath / GetLiveNoteEffectSkinAssetPath +SKIN_KEY = re.compile(r"Live/Note/(?P[^/]+)/LiveNoteSkinAsset") +EFFECT_KEY = re.compile(r"Effect/Live/NoteEffect/(?P[^/]+)/LiveNoteEffectAssetSettings") +NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") # a skin or effect set name, as a manifest path segment +# the flick views and the LiveNoteSkinAsset field of the unit that sets up each one's arrow +FLICK_SKIN_UNITS = { + "flick_note_view": "FlickNoteAsset", + "flick_left_note_view": "LeftFlickNoteAsset", + "flick_right_note_view": "RightFlickNote", +} + +LANE_COUNT = 24 # LiveDataCreator.CreateBootData passes 0x18 +# LiveBootDataCreator.CreateViewData: arguments of LiveSettingCreator.CreateLaneViewSettings +LANE_VIEW = { + "size": (1920.0, 1080.0), + "topRange": 0.05, + "bottomRange": 1.0, + "judgementScreenBottomPosition": 2.24, + "topPosition": 0.0, +} -# --- defaults (fresh profile) ------------------------------------------------- # MasterOptionDefault preset 1 (CurrentPresetIndex 0 -> Preset1); item types from App.Options.OptionItemType. OPTION_ITEMS = { k: OPTION_ITEM_TYPES[k] @@ -104,6 +165,7 @@ 310, ) } +NOTE_ITEMS = dict(sorted({**OPTION_ITEMS, **{k: OPTION_ITEM_TYPES[k] for k in PLAYER_ITEMS}}.items())) LIVE_SETTING_KEYS = [ "note_speed_min", "note_speed_max", @@ -115,14 +177,214 @@ "slide_offset_max_note_lane_width", "note_overlap_lane_buffer", ] +NOTES_TABLES = ( + "MasterOptionDefault", + "MasterOptionRange", + "MasterLiveSettings", + "MasterLiveNoteSkin", + "MasterLiveNoteEffectSkin", +) DEFAULT_SKIN = "skin001" # MasterLiveNoteSkin._id 1 (NoteDesignId default 1) DEFAULT_EFFECT = "effect001" # MasterLiveNoteEffectSkin._id 1 (NoteEffectId default 1) + +def skin_key(name: str) -> str: + """The note skin asset of MasterLiveNoteSkin._assetName `name` (LiveAddressablePath.GetLiveNoteSkinAssetPath).""" + return f"Live/Note/{name}/LiveNoteSkinAsset" + + +def effect_settings_key(effect: str) -> str: + """The note effect settings asset of effect set `effect` (LiveAddressablePath.GetLiveNoteEffectSkinAssetPath).""" + return f"Effect/Live/NoteEffect/{effect}/LiveNoteEffectAssetSettings" + + +def asset_name(name: str) -> str: + """A note skin or effect set name as a subject (ValueError for one that is not URL-safe).""" + if not NAME.fullmatch(name): + raise ValueError(f"{name!r}: not a note skin or effect set name") + return name + + +def notes_keys() -> list[str]: + """The catalog keys of the notes product: its prefabs and the lane effect settings.""" + return sorted({*NOTE_PREFABS.values(), LANE_EFFECT_KEY}) + + +def noteskin_keys(name: str) -> list[str]: + """The catalog keys of the noteskin product of skin `name`: its asset and the flick views its units set up.""" + return sorted({skin_key(asset_name(name)), *(NOTE_PREFABS[v] for v in FLICK_SKIN_UNITS)}) + + +def _lane_settings() -> dict: + return { + "laneCount": LANE_COUNT, + "laneSize": list(LANE_VIEW["size"]), + "laneTopRange": LANE_VIEW["topRange"], + "laneBottomRange": LANE_VIEW["bottomRange"], + "judgementScreenBottomPosition": LANE_VIEW["judgementScreenBottomPosition"], + "laneTopPosition": LANE_VIEW["topPosition"], + "noteViewProgress": {"base": 1.065, "exponentScale": 45.0}, + "tiltCenterLane": 11.5, + } + + +def notes_record(tables: dict) -> dict: + """What notes_settings reads of the master tables `tables` {name: rows} (NOTES_TABLES): the preset-1 defaults and + the ranges of NOTE_ITEMS, the LIVE_SETTING_KEYS rows of MasterLiveSettings, the note skins and effect sets.""" + return { + "MasterOptionDefault": [ + r for r in tables["MasterOptionDefault"] if r["_presetId"] == 1 and r["_optionItemType"] in NOTE_ITEMS + ], + "MasterOptionRange": [r for r in tables["MasterOptionRange"] if r["_optionItemType"] in NOTE_ITEMS], + "MasterLiveSettings": [r for r in tables["MasterLiveSettings"] if r["_key"] in LIVE_SETTING_KEYS], + "MasterLiveNoteSkin": tables["MasterLiveNoteSkin"], + "MasterLiveNoteEffectSkin": tables["MasterLiveNoteEffectSkin"], + } + + +def notes_settings(record: dict) -> dict: + """notes.json `settings` (module documentation) from a notes_record.""" + opts = {NOTE_ITEMS[r["_optionItemType"]]: r["_valueString"] for r in record["MasterOptionDefault"]} + ls = {r["_key"]: r["_value"] for r in record["MasterLiveSettings"]} + skins = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteSkin"]} + effects = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteEffectSkin"]} + return { + **_lane_settings(), + "optionDefaults": dict(sorted(opts.items())), + "optionRanges": { + NOTE_ITEMS[r["_optionItemType"]]: [r["_minValue"], r["_maxValue"]] + for r in sorted(record["MasterOptionRange"], key=lambda r: r["_optionItemType"]) + }, + "liveSettings": {k: ls[k] for k in LIVE_SETTING_KEYS if k in ls}, + "noteDisplayTimeMs": display_time_ms( + float(opts["NoteSpeed"]), + float(ls["note_speed_min"]), + float(ls["note_speed_max"]), + float(ls["note_speed_view_min"]), + float(ls["note_speed_view_max"]), + ), + "skin": skins[int(opts["NoteDesignId"])], + "effect": effects[int(opts["NoteEffectId"])], + "laneEffect": LANE_EFFECT, + "skins": {str(i): n for i, n in sorted(skins.items())}, + "effects": {str(i): n for i, n in sorted(effects.items())}, + } + + +def display_time_ms(speed: float, smin: float, smax: float, vmin: float, vmax: float) -> int: + """NoteBeforePlayingTimeGetter.GetNoteDisplayOffsetTimeMs, in float32.""" + import numpy as np + + f = np.float32 + t = max(f(0), f(speed) - f(smin)) / (f(smax) - f(smin)) + t = min(max(t, f(0)), f(1)) + e = f(1) - f(np.power(f(1) - t, f(1.31))) + e = min(max(e, f(0)), f(1)) + return int(f((f(vmin) + (f(vmax) - f(vmin)) * e) * f(1000))) + + +# ------------------------------------------------------------------ products +def _game_object(objects: Objects, key: str) -> str: + """The prefab GameObject of the catalog key `key`.""" + hits = [o for o in objects.key(key) if objects.info(o).get("class") == "GameObject" and has_json(objects, o)] + if len(hits) != 1: + raise ValueError(f"{key}: {len(hits)} prefab GameObjects") + return hits[0] + + +def _mono(objects: Objects, key: str, cls: str) -> str: + """The MonoBehaviour of script class `cls` of the catalog key `key`.""" + hits = [o for o in objects.key(key) if script_class(objects, o) == cls] + if len(hits) != 1: + raise ValueError(f"{key}: {len(hits)} {cls} MonoBehaviours") + return hits[0] + + +def _loop_clips(prefab: Prefab) -> dict[str, list[str]]: + """{Animator node path: clip ids} the LoopAnimationTimeSyncs of a prefab play: each one's `_loopAnim` on its + `_animator`.""" + out: dict[str, list[str]] = {} + for _, c in prefab.components("LoopAnimationTimeSync"): + clip = ref_id(c["fields"].get("_loopAnim")) + hit = prefab.component_of(c["fields"].get("_animator")) + if clip is not None and hit is not None: + out.setdefault(prefab.nodes[hit[0]]["path"], []).append(clip) + return out + + +def _bindings(objects: Objects) -> dict[str, dict]: + """`bindings` of every prefab of the table: its Animators' controller clips and LoopAnimationTimeSync clips.""" + out = {} + for gid in prefab_ids(objects): + doc = objects.json(gid) + plays = controller_clips(objects, doc) + for path, clips in _loop_clips(Prefab(doc)).items(): + plays.setdefault(path, []).extend(clips) + table = clip_bindings(objects, doc, plays) + if table: + out[gid] = table + return out + + +def _product(objects: Objects, doc: dict) -> tuple[dict, set[str], dict[str, set[str]]]: + shaders, roles = shader_table(objects) + doc["shaders"] = shaders + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + return doc, keep, roles + + +def notes(objects: Objects, record: dict) -> tuple[dict, set[str], dict[str, set[str]]]: + """(notes.json, the object ids the player reads, {shader id: the roles of its files it reads}) of the notes + product (module documentation; `record`: notes_record).""" + doc = { + "format": NOTES_FORMAT, + "settings": notes_settings(record), + "prefabs": {name: _game_object(objects, key) for name, key in NOTE_PREFABS.items()}, + "laneEffect": _mono(objects, LANE_EFFECT_KEY, "LiveLaneEffectAssetSettings"), + "bindings": _bindings(objects), + } + return _product(objects, doc) + + +def noteskin(objects: Objects, name: str) -> tuple[dict, set[str], dict[str, set[str]]]: + """(noteskin.json, the object ids the player reads, shader roles) of the note skin `name` (module + documentation).""" + skin = _mono(objects, skin_key(asset_name(name)), "LiveNoteSkinAsset") + fields = objects.json(skin) + out = {} + for view, unit_field in FLICK_SKIN_UNITS.items(): + unit = ref_id(fields.get(unit_field)) + clip = ref_id(objects.json(unit).get("_arrowLoopAnimation")) if unit and has_json(objects, unit) else None + if clip is None: + continue + gid = _game_object(objects, NOTE_PREFABS[view]) + doc = objects.json(gid) + prefab = Prefab(doc) + syncs = [c for c in doc["nodes"][0]["components"] if "_loopAnimationTimeSync" in c["fields"]] + if len(syncs) != 1: + raise ValueError(f"{view}: {len(syncs)} note views with a _loopAnimationTimeSync on its root") + sync = prefab.component_of(syncs[0]["fields"]["_loopAnimationTimeSync"]) + animator = prefab.component_of(sync[1]["fields"]["_animator"]) if sync is not None else None + if animator is None or component_class(animator[1]) != "Animator": + raise ValueError(f"{view}: its LoopAnimationTimeSync has no Animator") + table = clip_bindings(objects, doc, {prefab.nodes[animator[0]]["path"]: [clip]}) + if table: + out[gid] = table + return _product(objects, {"format": NOTES_FORMAT, "name": name, "skin": skin, "bindings": out}) + + +def noteeffect(objects: Objects, name: str) -> tuple[dict, set[str], dict[str, set[str]]]: + """(noteeffect.json, the object ids the player reads, shader roles) of the note effect set `name` (module + documentation).""" + settings_id = _mono(objects, effect_settings_key(asset_name(name)), "LiveNoteEffectAssetSettings") + doc = {"format": NOTES_FORMAT, "name": name, "settings": settings_id, "bindings": _bindings(objects)} + return _product(objects, doc) + + +# ------------------------------------------------------------------ live directory (export.Exporter) +PREFABS = {**NOTE_PREFABS, "judge_effect_view": "EmbLive/Prefabs/UILiveNoteJudgeEffectView"} # Catalog key families exported whole (every key under the prefix): the note effect settings of the NoteEffectId -# option's set ({effect}; LiveAddressablePath.GetLiveNoteEffectSkinAssetPath) and the lane effect settings, whose -# set is always LANE_EFFECT (LaneEffectLoadStep.GetAssetPath passes that constant to -# LiveAddressablePath.GetLiveLaneEffectSkinAssetPath), with the effect prefabs they reference by GameObject -LANE_EFFECT = "effect001" +# option's set ({effect}) and the lane effect settings (LANE_EFFECT), with the effect prefabs they reference KEY_PREFIXES = [ "Effect/Live/NoteEffect/{effect}/", f"Effect/Live/LaneEffect/{LANE_EFFECT}/", @@ -135,22 +397,14 @@ def settings(master: Path | None, options: LiveOptions = LiveOptions()) -> dict: - """Option defaults and master values the note runtime reads; with `options` also the offered note skins - (`skins`: NoteDesignId -> MasterLiveNoteSkin._assetName) and effect sets (`effects`: NoteEffectId -> - MasterLiveNoteEffectSkin._assetName; also when only other qualities are offered, whose effect set name the - player derives from it), and with its option tables the items of PLAYER_ITEMS as well.""" - out: dict = { - "skin": DEFAULT_SKIN, - "effect": DEFAULT_EFFECT, - "laneCount": LANE_COUNT, - "laneSize": list(LANE_VIEW["size"]), - "laneTopRange": LANE_VIEW["topRange"], - "laneBottomRange": LANE_VIEW["bottomRange"], - "judgementScreenBottomPosition": LANE_VIEW["judgementScreenBottomPosition"], - "laneTopPosition": LANE_VIEW["topPosition"], - "noteViewProgress": {"base": 1.065, "exponentScale": 45.0}, - "tiltCenterLane": 11.5, - } + """The live directory's notes.json `settings`: option defaults and master values the note runtime reads; with + `options` also the offered note skins (`skins`: NoteDesignId -> MasterLiveNoteSkin._assetName) and effect sets + (`effects`: NoteEffectId -> MasterLiveNoteEffectSkin._assetName; also when only other qualities are offered, + whose effect set name the player derives from it), and with its option tables the items of PLAYER_ITEMS as + well.""" + from .score import master_table + + out: dict = {"skin": DEFAULT_SKIN, "effect": DEFAULT_EFFECT, **_lane_settings()} if master is None: return out items = OPTION_ITEMS @@ -189,16 +443,6 @@ def settings(master: Path | None, options: LiveOptions = LiveOptions()) -> dict: return out -def display_time_ms(speed: float, smin: float, smax: float, vmin: float, vmax: float) -> int: - """NoteBeforePlayingTimeGetter.GetNoteDisplayOffsetTimeMs, in float32.""" - f = np.float32 - t = max(f(0), f(speed) - f(smin)) / (f(smax) - f(smin)) - t = min(max(t, f(0)), f(1)) - e = f(1) - f(np.power(f(1) - t, f(1.31))) - e = min(max(e, f(0)), f(1)) - return int(f((f(vmin) + (f(vmax) - f(vmin)) * e) * f(1000))) - - # UI subtrees of [EmbScene/Live] animated by exported clips (combo counter, judgement text); used only to name # clip binding paths, the scene itself is exported by livescene. SCENE_KEY = "EmbScene/Live" @@ -266,16 +510,6 @@ def refs(v): return dropped -def skin_key(name: str) -> str: - """The note skin asset of MasterLiveNoteSkin._assetName `name` (LiveAddressablePath.GetLiveNoteSkinAssetPath).""" - return f"Live/Note/{name}/LiveNoteSkinAsset" - - -def effect_settings_key(effect: str) -> str: - """The note effect settings asset of effect set `effect` (LiveAddressablePath.GetLiveNoteEffectSkinAssetPath).""" - return f"Effect/Live/NoteEffect/{effect}/LiveNoteEffectAssetSettings" - - def effect_keys(cat: Catalog, effect: str) -> list[str]: """The catalog keys of note effect set `effect` and of the lane effects (KEY_PREFIXES), in prefix and catalog order.""" @@ -285,7 +519,7 @@ def effect_keys(cat: Catalog, effect: str) -> list[str]: return keys -def _mono(ex: Exporter, o, cls: str): +def _scene_mono(ex: Exporter, o, cls: str): if o is None or o.type.name != "MonoBehaviour" or ex.script_class(o) != cls: raise RuntimeError(f"expected a {cls} MonoBehaviour, got {o and o.type.name}") return o, o.read_typetree() @@ -308,9 +542,9 @@ def bar_line_prefab(ex: Exporter) -> dict: ] if len(hits) != 1: raise RuntimeError(f"{SCENE_KEY}: {len(hits)} {cls} at {path}") - o, tt = _mono(ex, hits[0], cls) + o, tt = _scene_mono(ex, hits[0], cls) for field, want in BAR_LINE_CHAIN: - o, tt = _mono(ex, ex.deref(o, tt[field]), want) + o, tt = _scene_mono(ex, ex.deref(o, tt[field]), want) root = graph.tf_of_go.get(tt["m_GameObject"]["m_PathID"]) if root is None or graph.tf[root]["m_Father"]["m_PathID"]: raise RuntimeError(f"{SCENE_KEY}: the bar line view is not a prefab root of the scene's bundles") @@ -326,6 +560,8 @@ def extract( ) -> dict: """livenotes/ for the default options, plus the files of the variants `options` offers (the module docstring). The variants' objects are exported after the default ones.""" + from ..unity.export import Exporter + out_dir = Path(out_dir) root = out_dir / "livenotes" root.mkdir(parents=True, exist_ok=True) diff --git a/src/nnnotes/content/liveoptions.py b/src/nnnotes/content/liveoptions.py index d39a53d..7a63288 100644 --- a/src/nnnotes/content/liveoptions.py +++ b/src/nnnotes/content/liveoptions.py @@ -34,8 +34,6 @@ from dataclasses import dataclass, replace from pathlib import Path -from .score import master_table - # App.Options.OptionItemType (every value but None 0), the names of MasterOptionDefault / MasterOptionRange # `_optionItemType` OPTION_ITEM_TYPES = { @@ -312,6 +310,8 @@ def read_variants(self) -> list[dict]: def preset_defaults(master: Path) -> dict[int, str]: """OptionItemType -> MasterOptionDefault preset-1 value string (a fresh profile's options).""" + from .score import master_table + return { r["_optionItemType"]: r["_valueString"] for r in master_table(master, "MasterOptionDefault") @@ -326,6 +326,8 @@ def resolve(request: dict | None, master: Path | None) -> LiveOptions: return LiveOptions() if master is None: raise OptionSpecError("--live-option needs the master data") + from .score import master_table + master = Path(master) everything = bool(request.get(ALL)) preset = preset_defaults(master) diff --git a/src/nnnotes/content/livescene.py b/src/nnnotes/content/livescene.py index 1fc5a4e..76d4931 100644 --- a/src/nnnotes/content/livescene.py +++ b/src/nnnotes/content/livescene.py @@ -38,6 +38,7 @@ from ..core.jsonio import write_json from ..unity.export import Exporter +from .livenotes import LANE_COUNT, LANE_VIEW from .liveoptions import OPTION_ITEM_TYPES from .score import master_table @@ -115,15 +116,6 @@ def master_rows(master: Path, music_id: int) -> dict: # (None, Lane4, Lane6, Lane8, Lane12) SPLIT_MAIN = (0, 4, 6, 8, 12) SPLIT_SUB = (12, 12, 12, 0, 0) -LANE_COUNT = 24 # LiveDataCreator.CreateBootData passes 0x18 -# LiveBootDataCreator.CreateViewData: arguments of LiveSettingCreator.CreateLaneViewSettings -LANE_VIEW = { - "size": (1920.0, 1080.0), - "topRange": 0.05, - "bottomRange": 1.0, - "judgementScreenBottomPosition": 2.24, - "topPosition": 0.0, -} REF_ASPECT = 1.7777778 # LiveGameView.FullInitialize REF_FOV = 54.0 diff --git a/src/nnnotes/content/prefabs.py b/src/nnnotes/content/prefabs.py index 3bd73da..c51e8a3 100644 --- a/src/nnnotes/content/prefabs.py +++ b/src/nnnotes/content/prefabs.py @@ -6,19 +6,44 @@ same hierarchy by path: `{"gameObject": path}`, `{"transform": path}`, or `{"component": type, "gameObject": path, "class": script class}` for a component. `Prefab` gives each node's parent, its world matrix (Unity's left-handed space, the root's own transform included) and whether it is active in the hierarchy, and resolves those references. + +What a player reads with the hierarchies of a table: + +controller_clips the clips each Animator of a prefab plays through its controller (Animator.m_Controller, the + controller.json `clips`), by the Animator's node path +clip_bindings the bindings of the clips each Animator plays, named in the prefab from the Animator's node + (unity.bindings.resolve) +shader_table the shaders of the table by name (copies of one shader in several bundles by the first id), and + of each the files a player draws with: its parsed form (`json`), its variant index (`index`) and + every program of the player's graphics API (formats.shader.player_roles) """ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Iterable, Iterator, Mapping from typing import TYPE_CHECKING, Any, Protocol +from ..formats.shader import player_roles +from ..unity import bindings from ..unity.ids import object_id if TYPE_CHECKING: import numpy as np -__all__ = ["Objects", "Prefab", "component_class", "mat4", "ref_id", "root_game_object", "script_class"] +__all__ = [ + "Objects", + "Prefab", + "clip_bindings", + "component_class", + "controller_clips", + "has_json", + "mat4", + "prefab_ids", + "ref_id", + "root_game_object", + "script_class", + "shader_table", +] class Objects(Protocol): @@ -30,6 +55,8 @@ def __contains__(self, oid: str) -> bool: ... def ids(self, cls: str | None = None) -> list[str]: ... + def key(self, key: str) -> list[str]: ... + def info(self, oid: str) -> dict: ... def bytes(self, oid: str, role: str) -> bytes: ... @@ -50,9 +77,14 @@ def component_class(c: dict) -> str: return c["type"] +def has_json(objects: Objects, oid: str) -> bool: + """`oid` is an object of the table with a `json` file.""" + return oid in objects and "json" in (objects.info(oid).get("files") or {}) + + 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.""" - if "json" not in (objects.info(oid).get("files") or {}): + 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 @@ -66,6 +98,70 @@ def root_game_object(objects: Objects, what: str) -> str: return gos[0] +def prefab_ids(objects: Objects) -> list[str]: + """The GameObjects of the table with a prefab document (the roots of their hierarchies), sorted.""" + return [o for o in objects.ids("GameObject") if has_json(objects, o)] + + +def controller_clips(objects: Objects, prefab: dict) -> dict[str, list[str]]: + """{node path: clip ids} of every Animator of a prefab document whose controller is in the table: the clips its + controller plays, in the controller's order.""" + out: dict[str, list[str]] = {} + for n in prefab["nodes"]: + for c in n["components"]: + ctl = ref_id(c["fields"].get("m_Controller")) if c["type"] == "Animator" else None + if ctl is not None and has_json(objects, ctl): + clips = [ref_id(r) for r in objects.json(ctl).get("clips", ())] + out[n["path"]] = [o for o in clips if o is not None] + return out + + +def clip_bindings(objects: Objects, prefab: dict, plays: Mapping[str, Iterable[str]]) -> dict[str, dict[str, list]]: + """{node path: {clip id: [{path, attribute}]}} of the clips `plays` gives each Animator of a prefab document + ({the Animator's node path: clip ids}), named in the prefab from the Animator's node (unity.bindings.resolve). A + clip without its `json` in the table is left out, and so is an Animator left without clips. A path in `plays` that + holds no Animator raises.""" + + def material(ref) -> dict | None: + oid = ref_id(ref) + return objects.json(oid) if oid is not None and has_json(objects, oid) else None + + animators = {n["path"] for n in prefab["nodes"] for c in n["components"] if c["type"] == "Animator"} + out = {} + for path, clips in sorted(plays.items()): + if path not in animators: + raise ValueError(f"{prefab['name']}: no Animator at {path!r}") + table = {} + for cid in clips: + if cid not in table and has_json(objects, cid): + table[cid] = bindings.resolve(objects.json(cid)["bindings"], prefab, path, material) + if table: + out[path] = dict(sorted(table.items())) + return out + + +def shader_table(objects: Objects) -> tuple[dict[str, str], dict[str, set[str]]]: + """({shader name: object id}, {object id: the roles of its files a player reads}) of the Shaders of the table that + have their variant index (module documentation). Bundles may each hold a copy of a shader: Shaders of one name + whose parsed form and index are the same content are one shader, named by the first id; different ones raise.""" + names: dict[str, str] = {} + roles: dict[str, set[str]] = {} + + def content(sid: str) -> tuple: + files = objects.info(sid)["files"] + return tuple(files[r]["sha256"] for r in ("json", "index")) + + for sid in objects.ids("Shader"): + if "index" not in (objects.info(sid).get("files") or {}): + continue + index = objects.json(sid, "index") + first = names.setdefault(index["name"], sid) + if first != sid and content(first) != content(sid): + raise ValueError(f"shader {index['name']}: objects {first} and {sid} differ") + roles[sid] = {"json", "index"} | player_roles(index["variants"]) + return dict(sorted(names.items())), roles + + def mat4(m: np.ndarray) -> list[float]: """A 4x4 matrix as 16 floats, column-major.""" return [float(x) for x in m.T.reshape(-1)] diff --git a/src/nnnotes/formats/shader.py b/src/nnnotes/formats/shader.py index 08a5a72..85ad550 100644 --- a/src/nnnotes/formats/shader.py +++ b/src/nnnotes/formats/shader.py @@ -183,6 +183,12 @@ def _render(o, tt: dict, name: str) -> tuple[str, list[dict], list[bytes]]: return text, recs, codes +def player_roles(variants: list[dict]) -> set[str]: + """The roles of a shader's programs the player draws with (variant index records): every PLAYER_TYPE program of + PLAYER_PLATFORM.""" + return {v["role"] for v in variants if v["platform"] == PLAYER_PLATFORM and v["type"] == PLAYER_TYPE} + + def player_program(variants: list[dict], keywords) -> dict: """The variant record of a shader (platform, subShader, pass, type, keywords, ...) the player draws a material with `keywords` by: the PLAYER_TYPE program of subshader 0, pass 0 whose keywords are exactly the material's diff --git a/src/nnnotes/pipeline/assemble.py b/src/nnnotes/pipeline/assemble.py index c49f3e3..6ca202b 100644 --- a/src/nnnotes/pipeline/assemble.py +++ b/src/nnnotes/pipeline/assemble.py @@ -4,14 +4,15 @@ objects of the catalog keys `keys(subject)` gives, of the Resources.Load paths `resources(subject)` gives, of the built-in selectors `builtins(subject)` gives (objects of the APK's built-in files by class or script class) and the objects `root_objects(subject)` names (all read from the planning facts alone, so a command can ask for the units its -products need before anything runs: `request`). The -objects it reaches are those of the units holding its roots and every unit an item of a reached unit references -(contract.item `refs`), 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. +products need before anything runs: `request`). The objects it reaches are those of the units holding its roots and +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. 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), the APK files the stage names in `APK` +name, item status and files, the contained ones with the object holding them, and the root objects of each of its +keys), the APK files the stage names in `APK` ("apk:", from the fact "apk"), the cue sheets of the catalog keys `cue_sheets(subject)` gives and what `more_inputs` adds (master tables, parameters' data); it depends on the unity.export and sprite.crop tasks it read and the cri.audio tasks of its cue sheets. A cue sheet is the ACB the key loads: held in a bundle (the `acb` @@ -124,8 +125,9 @@ def get(self, env, tid: str) -> tuple[list, dict]: return doc -def reach(env, roots, results: _Results | None = None) -> Reach: - """The objects reachable from the object ids `roots` (module documentation).""" +def reach(env, roots, results: _Results | None = None, keys: dict | None = None) -> Reach: + """The objects reachable from the object ids `roots` (module documentation); `keys` {catalog key: its object ids, + all among the roots} goes into the table as it is.""" results = results or _Results() table = units.table(env) todo, seen, missing = [], set(), set() @@ -135,6 +137,7 @@ def reach(env, roots, results: _Results | None = None) -> Reach: missing.add(o) else: todo.append(s) + start = set(todo) objects: dict[str, dict] = {} read = [] crops: dict[str, list] = {} # atlas object id -> sprite object ids @@ -158,7 +161,7 @@ def reach(env, roots, results: _Results | None = None) -> Reach: t = table.subject_of(r) if t is None: missing.add(r) - elif t not in seen: + 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) @@ -175,6 +178,7 @@ def reach(env, roots, results: _Results | None = None) -> Reach: doc = { "schema": contract.OBJECTS, "roots": sorted(set(roots)), + "keys": {k: sorted(v) for k, v in sorted((keys or {}).items())}, "objects": dict(sorted(objects.items())), "missing": sorted(missing), } @@ -226,6 +230,7 @@ class Objects: def __init__(self, doc: dict, depot, sheets: dict | None = None): self.doc, self.depot = doc, depot self.roots: list[str] = doc["roots"] + self._keys: dict[str, list[str]] = doc["keys"] self._objects: dict = doc["objects"] self._json: dict = {} self._sheets: dict[str, Sheet] = sheets or {} @@ -237,6 +242,13 @@ def sheet(self, key: str) -> Sheet: except KeyError: raise KeyError(f"cue sheet {key} is not an input of the product") from None + def key(self, key: str) -> list[str]: + """The root objects of the catalog key `key` (one of the stage's keys), sorted.""" + try: + return self._keys[key] + except KeyError: + raise KeyError(f"{key} is not a key of the product") from None + def __contains__(self, oid: str) -> bool: return oid in self._objects @@ -364,18 +376,20 @@ def manifest(self, subject: str) -> str: # ---------------------------------------------------------------- planning def reach(self, subject: str, env) -> Reach: """What `subject` reaches (kept while the results it read are the same).""" + keys = self.keys(subject, env) roots = units.root_objects( env, - self.keys(subject, env), + keys, self.root_objects(subject, env), self.resources(subject, env), self.builtins(subject, env), ) - sig = (tuple(roots), id(units.table(env))) + 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))) memo = self._memo.get(subject) if memo is not None and memo[0] == sig and all(env.key(t) == k for t, k in memo[1]): return memo[2] - r = reach(env, roots, self._results) + r = reach(env, roots, self._results, by_key) self._memo[subject] = (sig, [(t, env.key(t)) for t in r.read], r) return r diff --git a/src/nnnotes/pipeline/live.py b/src/nnnotes/pipeline/live.py index 50b65cd..20faa54 100644 --- a/src/nnnotes/pipeline/live.py +++ b/src/nnnotes/pipeline/live.py @@ -1,7 +1,8 @@ -"""The live sound products: a music's BGM (live.music) and the sounds a live plays besides it (live.sounds). +"""The live products: a music's BGM (live.music), the sounds a live plays besides it (live.sounds) and the note +highway (live.notes, live.noteskin, live.noteeffect). -Both are product stages (pipeline.assemble) that read cue sheets: the ACBs their catalog keys load and the files -cri.audio decoded from them. They keep no objects (their files are all they hold): +All are product stages (pipeline.assemble). The sound products read cue sheets: the ACBs their catalog keys load and +the files cri.audio decoded from them. They keep no objects (their files are all they hold): live.music subject a music id (MasterLiveMusic._id): "#music.json" (kind live.music, liveaudio.music_doc) and "#audio//" for each layer of its BGM sound (kind audio.bgm) in the web @@ -15,8 +16,21 @@ inputs "master" (liveaudio.sounds_record), the CRI ACF of the APK and, as roots, the sound settings of the APK's built-in files (liveaudio.SETTINGS_SCRIPTS). -`select` (the `product` layout) makes the live.music products of the musics whose BGM key the selection holds and -the default live.sounds when it holds every cue sheet of the default sounds; both need the master data. +The note highway products assemble with content.livenotes from the objects of their keys: + +live.notes subject `default`: "#notes.json" (kind live.notes: content.livenotes.notes) and the object + table the player reads; its manifest is notes/default.json, its input "master" the settings' master + record (livenotes.notes_record) +live.noteskin subject a note skin (MasterLiveNoteSkin._assetName, `skin001`): "#noteskin.json" (kind + live.noteskin); its manifest is noteskins/.json +live.noteeffect subject a note effect set (MasterLiveNoteEffectSkin._assetName or its `Light` variant, + `effect001Light`): "#noteeffect.json" (kind live.noteeffect); its manifest is + noteeffects/.json + +`select` (the `product` layout) makes the live.music products of the musics whose BGM key the selection holds, the +default live.sounds when it holds every cue sheet of the default sounds (both need the master data), live.notes when +it holds every key of it (and the master data is there), and the live.noteskin and live.noteeffect products of the +skin and effect set keys it holds. """ from __future__ import annotations @@ -24,16 +38,30 @@ import tempfile from pathlib import Path -from ..content import liveaudio +from ..content import liveaudio, livenotes from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO, mp4_priming, web_encode from . import contract from .assemble import Assembly, ProductStage from .contract import IncompatibleTask, Input from .views import master_rows -__all__ = ["MUSIC", "SOUNDS", "MusicStage", "SoundsStage", "encode_atoms"] +__all__ = [ + "MUSIC", + "NOTEEFFECT", + "NOTES", + "NOTESKIN", + "SOUNDS", + "MusicStage", + "NoteEffectStage", + "NoteSkinStage", + "NotesStage", + "SoundsStage", + "encode_atoms", +] MUSIC, SOUNDS = "live.music", "live.sounds" +NOTES, NOTESKIN, NOTEEFFECT = "live.notes", "live.noteskin", "live.noteeffect" +DEFAULT = "default" MASTER_INPUT = "master" @@ -184,3 +212,87 @@ def sheet(name: str): files[layer["file"]] = (s.file(name), name.rsplit(".", 1)[1], "audio.se") files["sounds.json"] = (contract.encode(doc), "json", "live.sounds") return Assembly(files, set()) + + +def _default(subject: str) -> str: + if subject != DEFAULT: + raise ValueError(f"{subject}: the live notes subject is {DEFAULT}") + return subject + + +def _json(doc: dict, kind: str) -> tuple: + return contract.encode(doc), "json", kind + + +class NotesStage(ProductStage): + """live.notes: the note highway and its settings (module documentation).""" + + name = NOTES + version = 1 + KIND = "LiveNotes" + MASTER = livenotes.NOTES_TABLES + + def select(self, keys: list[str], env) -> list[str]: + if env.facts.get("master") is None: + return [] + return [DEFAULT] if set(livenotes.notes_keys()) <= set(keys) else [] + + def keys(self, subject: str, env) -> list[str]: + _default(subject) + return livenotes.notes_keys() + + def more_inputs(self, subject: str, env) -> list[Input]: + return [_record(env, livenotes.notes_record(master_rows(env, self.MASTER)))] + + def manifest(self, subject: str) -> str: + return f"notes/{_default(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) + doc, keep, roles = livenotes.notes(objects, record) + return Assembly({"notes.json": _json(doc, NOTES)}, keep, roles) + + +class NoteSkinStage(ProductStage): + """live.noteskin: a note skin (module documentation).""" + + name = NOTESKIN + version = 1 + KIND = "LiveNoteSkin" + + def select(self, keys: list[str], env) -> list[str]: + held = set(keys) + names = (m["name"] for m in map(livenotes.SKIN_KEY.fullmatch, keys) if m) + return sorted(n for n in names if livenotes.NAME.fullmatch(n) and set(livenotes.noteskin_keys(n)) <= held) + + def keys(self, subject: str, env) -> list[str]: + return livenotes.noteskin_keys(subject) + + def manifest(self, subject: str) -> str: + return f"noteskins/{livenotes.asset_name(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + doc, keep, roles = livenotes.noteskin(objects, task.subject) + return Assembly({"noteskin.json": _json(doc, NOTESKIN)}, keep, roles) + + +class NoteEffectStage(ProductStage): + """live.noteeffect: a note effect set (module documentation).""" + + name = NOTEEFFECT + version = 1 + KIND = "LiveNoteEffect" + + def select(self, keys: list[str], env) -> list[str]: + names = (m["name"] for m in map(livenotes.EFFECT_KEY.fullmatch, keys) if m) + return sorted(n for n in names if livenotes.NAME.fullmatch(n)) + + def keys(self, subject: str, env) -> list[str]: + return [livenotes.effect_settings_key(livenotes.asset_name(subject))] + + def manifest(self, subject: str) -> str: + return f"noteeffects/{livenotes.asset_name(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + doc, keep, roles = livenotes.noteeffect(objects, task.subject) + return Assembly({"noteeffect.json": _json(doc, NOTEEFFECT)}, keep, roles) diff --git a/src/nnnotes/pipeline/units.py b/src/nnnotes/pipeline/units.py index e6d2b1c..04a4073 100644 --- a/src/nnnotes/pipeline/units.py +++ b/src/nnnotes/pipeline/units.py @@ -9,7 +9,9 @@ fact "roots", and every unit reachable from those through what their objects reference (census `refs`) across the censuses of the bundles of the fact "bundles"; with the fact "classes" (a list of class names; absent or None: every class), only the units whose root object is of those classes. A bundle whose census failed has no units; a -reference into a file no census holds is left out. +reference into a file no census holds is left out. A SpriteAtlas references the sprites it packs, and each of them +references the atlas, whose textures hold its image: an atlas reached through a reference (`reaches`) adds its other +references but not its sprites (PACKED), so a sprite brings its atlas' textures and not the atlas' other sprites. The fact "roots" (absent: none) is {"keys": catalog keys, "resources": resource paths, "builtins": selectors, "objects": object ids}, each list optional: the objects of each key, of each resource path and of each built-in @@ -30,6 +32,8 @@ CENSUS = CensusStage.name ADDRESSES = views.ADDRESSES_STAGE +# 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"} @dataclass(frozen=True) @@ -57,11 +61,12 @@ 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}; `per_bundle` the number of requested units per bundle; `classes` the objects of the - requested units per class.""" + bundle name: its objects}; `kinds` {subject: root class} of every unit of the censuses read; `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): + def __init__(self, units: dict, of: dict, files: dict, objects: dict, kinds: 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.per_bundle = Counter(u.bundle for u in units.values()) self.classes: Counter = Counter() for u in units.values(): @@ -81,6 +86,17 @@ def subject_of(self, oid: str) -> str | None: """The subject of the unit holding object `oid` (None when no census read holds it).""" return self.of.get(oid) + 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) + + +def _reaches(kinds: dict, 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).""" + return start or PACKED.get(kinds.get(source, "")) != kinds.get(target) + def _ref(r: dict | None) -> str | None: """The object id of a census reference record (None for null or an unknown file index).""" @@ -156,22 +172,25 @@ def build(censuses, bundles, selected, classes=None, roots=()) -> Units: of.update(o) files.update(f) refs.update(r) + kinds = {s: u.cls for s, u in units.items()} if every: want = set(units) else: want = {s for s, u in units.items() if u.bundle in selected} want.update(of[o] for o in roots if o in of) + start = set(want) todo = list(want) while todo: - for oid in refs.get(todo.pop(), ()): + source = todo.pop() + for oid in refs.get(source, ()): s = of.get(oid) - if s is not None and s not in want: + if s is not None and s not in want and _reaches(kinds, 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) + return Units({s: units[s] for s in sorted(want)}, of, files, objects, kinds) _MEMO: list = [] # [(signature, Units)] of the last table diff --git a/src/nnnotes/schemas/objects.schema.json b/src/nnnotes/schemas/objects.schema.json index b9d006e..c53b845 100644 --- a/src/nnnotes/schemas/objects.schema.json +++ b/src/nnnotes/schemas/objects.schema.json @@ -7,6 +7,7 @@ "required": [ "schema", "roots", + "keys", "objects", "missing" ], @@ -20,6 +21,16 @@ "type": "string" } }, + "keys": { + "type": "object", + "description": "The root objects of each catalog key of the product.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, "objects": { "type": "object", "additionalProperties": { diff --git a/tests/content/test_livenotes.py b/tests/content/test_livenotes.py new file mode 100644 index 0000000..0cb7f2c --- /dev/null +++ b/tests/content/test_livenotes.py @@ -0,0 +1,112 @@ +"""The note highway products assembled from synthetic object tables (content.livenotes).""" + +import pytest + +from nnnotes.content import livenotes +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import Depot +from tests.helpers import livenotes as ln + +FLICKS = sorted(livenotes.FLICK_SKIN_UNITS) +ARROW = [{"path": "arrow", "attribute": "m_Color.a"}] +GLOW = [{"path": "glow", "attribute": "m_Color.a"}] + + +def objects_of(tmp_path, keys, objs=None) -> Objects: + depot = Depot(tmp_path / "d") + return Objects(ln.table(depot, keys, objs), depot) + + +def test_the_notes_document(tmp_path): + record = livenotes.notes_record(ln.master()) + doc, keep, roles = livenotes.notes(objects_of(tmp_path, ln.NOTES_KEYS), record) + assert doc["format"] == livenotes.NOTES_FORMAT + assert doc["prefabs"] == {n: ln.prefab_id(n) for n in livenotes.NOTE_PREFABS} + assert doc["laneEffect"] == ln.LANE + # every Animator: its controller's clips (a clip the table lacks left out) and the loop clip of its time sync + assert doc["bindings"] == { + **{ln.prefab_id(v): {f"{v}/anim_root": {ln.FLICK_CLIP: ARROW, ln.LOOP_CLIP: ARROW}} for v in FLICKS}, + ln.LANE_FX: {"lane_tap/root": {ln.FX_CLIP: GLOW}}, + ln.EFFECT_FX: {"note_normal/root": {ln.FX_CLIP: GLOW}}, # in the table, so it is bound as well + } + assert doc["shaders"] == {"Sirius/Sprite": ln.SHADER} + assert roles == {ln.SHADER: {"json", "index", "program:gles3/s0p0_progVertex_0", "program:gles3/s0p1_progVertex_1"}} + assert ln.SCRIPT not in keep and keep == set(ln.objects()) - {ln.SCRIPT} + + +def test_the_notes_settings(tmp_path): + record = livenotes.notes_record(ln.master()) + assert all(r["_presetId"] == 1 and r["_optionItemType"] != 999 for r in record["MasterOptionDefault"]) + assert [r["_key"] for r in record["MasterLiveSettings"]] == [ + "note_speed_min", + "note_speed_max", + "note_speed_view_min", + "note_speed_view_max", + "slide_offset_min_note_lane_width", + ] + s = livenotes.notes_settings(record) + assert s["optionDefaults"] == { + "LiveMusicVolume": "100", # an item the player offers + "LiveQuality": "1", + "NoteDesignId": "2", + "NoteEffectId": "1", + "NoteSpeed": "5.00", + } + assert s["optionRanges"] == {"NoteSpeed": [100, 1200]} + assert (s["skin"], s["effect"], s["laneEffect"]) == ("skin002", "effect001", "effect001") + assert s["skins"] == {"1": "skin001", "2": "skin002"} and s["effects"] == {"1": "effect001", "2": "effect001Simple"} + assert s["noteDisplayTimeMs"] == livenotes.display_time_ms(5.0, 1.0, 12.0, 0.4, 7.0) + assert s["laneCount"] == 24 and "judgementSprites" not in s + + +def test_a_skin_binds_the_arrow_loop_of_its_flick_units(tmp_path): + doc, keep, _ = livenotes.noteskin(objects_of(tmp_path, ln.SKIN_KEYS), "skin001") + assert (doc["format"], doc["name"], doc["skin"]) == (livenotes.NOTES_FORMAT, "skin001", ln.SKIN) + # the flick unit's loop plays on the flick view's time sync Animator; the left unit has none, the right is unset + assert doc["bindings"] == {ln.prefab_id("flick_note_view"): {"flick_note_view/anim_root": {ln.SKIN_CLIP: ARROW}}} + assert ln.SCRIPT not in keep + + +def test_an_effect_set(tmp_path): + doc, _, roles = livenotes.noteeffect(objects_of(tmp_path, ln.EFFECT_KEYS), "effect001Light") + assert (doc["name"], doc["settings"]) == ("effect001Light", ln.EFFECT) + assert doc["bindings"][ln.EFFECT_FX] == {"note_normal/root": {ln.FX_CLIP: GLOW}} + assert doc["shaders"] == {"Sirius/Sprite": ln.SHADER} and set(roles) == {ln.SHADER} + + +def test_a_key_names_its_objects(tmp_path): + record = livenotes.notes_record(ln.master()) + keys = {**ln.NOTES_KEYS, livenotes.LANE_EFFECT_KEY: [ln.LANE, ln.EFFECT]} # the settings by their class + assert livenotes.notes(objects_of(tmp_path, keys), record)[0]["laneEffect"] == ln.LANE + with pytest.raises(ValueError, match="LiveLaneEffectAssetSettings: 0 LiveLaneEffectAssetSettings"): + livenotes.notes(objects_of(tmp_path, {**keys, livenotes.LANE_EFFECT_KEY: [ln.EFFECT]}), record) + tap = livenotes.NOTE_PREFABS["tap_note_view"] + with pytest.raises(ValueError, match="tap_note_view: 2 prefab GameObjects"): + livenotes.notes(objects_of(tmp_path, {**keys, tap: [ln.prefab_id("tap_note_view"), ln.LANE_FX]}), record) + with pytest.raises(KeyError, match="not a key of the product"): + livenotes.noteeffect(objects_of(tmp_path, ln.EFFECT_KEYS), "effect001") + + +def test_a_time_sync_plays_on_an_animator(tmp_path): + objs = ln.objects() + view = objs[ln.prefab_id("flick_note_view")][2]["json"] + view["nodes"][1]["components"] = view["nodes"][1]["components"][1:] # the time sync's Animator removed + with pytest.raises(ValueError, match="flick_note_view: 0 components"): + livenotes.noteskin(objects_of(tmp_path, ln.SKIN_KEYS, objs), "skin001") + + +def test_copies_of_a_shader_are_one_and_different_ones_of_a_name_an_error(tmp_path): + objs = ln.objects() + objs["CAB-z:1"] = objs[ln.SHADER] # a copy in another bundle + doc, _, roles = livenotes.noteeffect(objects_of(tmp_path, ln.EFFECT_KEYS, objs), "effect001Light") + assert doc["shaders"] == {"Sirius/Sprite": ln.SHADER} and roles["CAB-z:1"] == roles[ln.SHADER] + cls, name, files = objs[ln.SHADER] + objs["CAB-z:1"] = (cls, name, {**files, "json": {"name": "Sirius/Sprite", "other": 1}}) + with pytest.raises(ValueError, match="shader Sirius/Sprite: objects CAB-n:981 and CAB-z:1 differ"): + livenotes.noteeffect(objects_of(tmp_path, ln.EFFECT_KEYS, objs), "effect001Light") + + +def test_skin_and_effect_names_are_url_safe(): + assert livenotes.noteskin_keys("skin001")[-1] == "Live/Note/skin001/LiveNoteSkinAsset" + with pytest.raises(ValueError, match="not a note skin or effect set name"): + livenotes.noteskin_keys("../x") diff --git a/tests/helpers/live2dmodel.py b/tests/helpers/live2dmodel.py index 604d649..b54bfcc 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), "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "missing": []} diff --git a/tests/helpers/livenotes.py b/tests/helpers/livenotes.py new file mode 100644 index 0000000..c7754f9 --- /dev/null +++ b/tests/helpers/livenotes.py @@ -0,0 +1,224 @@ +"""A synthetic note highway as product object tables (nnnotes.objects/1) whose files are in a depot: the note prefabs +(the flick views with an animated arrow), the lane effect settings and an effect prefab with an Animator, a note skin +whose flick unit overrides the arrow loop, a note effect set, a material and its shader, and a MonoScript.""" + +from __future__ import annotations + +from nnnotes.content import livenotes +from nnnotes.pipeline import contract +from nnnotes.unity.bindings import crc +from tests.helpers.live2dmodel import ref, variant + +CAB = "CAB-n" +SIRIUS = "SiriusAsset|SiriusAsset|" +LIVE = "App.Runtime|App.Live.View|" +FLICK_CTL, FLICK_CLIP, LOOP_CLIP, SKIN_CLIP = (f"{CAB}:{i}" for i in (900, 901, 902, 903)) +LANE, LANE_FX, FX_CTL, FX_CLIP = (f"{CAB}:{i}" for i in (950, 951, 952, 953)) +SKIN, UNIT_FLICK, UNIT_LEFT = f"{CAB}:960", f"{CAB}:961", f"{CAB}:962" +EFFECT, EFFECT_FX = f"{CAB}:970", f"{CAB}:971" +MAT, SHADER, SCRIPT = f"{CAB}:980", f"{CAB}:981", "CAB-s:1" +SHADER_VARIANTS = [ + variant(0, []), + {**variant(1, []), "pass": 1, "role": "program:gles3/s0p1_progVertex_1"}, + variant(2, [], "vulkan", "SPIRV"), +] + + +def prefab_id(name: str) -> str: + return f"{CAB}:{100 + sorted(livenotes.NOTE_PREFABS).index(name)}" + + +def comp(type_: str, pid: int, script: str | None = None, **fields) -> dict: + c = {"type": type_, "pathId": str(pid), "fields": fields} + if script: + c["script"] = script + return c + + +def node(path: str, *components) -> dict: + return {"path": path, "name": path.rsplit("/", 1)[-1], "components": list(components)} + + +def at(path: str, cls: str = "Animator", script: str | None = None) -> dict: + r = {"component": cls, "gameObject": path} + if script: + r["class"] = script + return r + + +def flick_view(name: str) -> dict: + """A flick view: its view on the root names the LoopAnimationTimeSync of anim_root, whose Animator plays the + controller's clip and the sync's loop clip on the arrow.""" + root, anim = name, f"{name}/anim_root" + return { + "name": name, + "nodes": [ + node( + root, + comp( + "MonoBehaviour", + 1, + LIVE + "LiveFlickNoteView", + _loopAnimationTimeSync=at(anim, "MonoBehaviour", "LoopAnimationTimeSync"), + ), + ), + node( + anim, + comp("Animator", 2, m_Controller=ref(FLICK_CTL)), + comp("MonoBehaviour", 3, LIVE + "LoopAnimationTimeSync", _animator=at(anim), _loopAnim=ref(LOOP_CLIP)), + ), + node(f"{anim}/arrow", comp("SpriteRenderer", 4, m_Materials=[ref(MAT)])), + ], + } + + +def plain(name: str) -> dict: + return {"name": name, "nodes": [node(name, comp("SpriteRenderer", 1, m_Materials=[ref(MAT)]))]} + + +def effect_prefab(name: str) -> dict: + return { + "name": name, + "nodes": [ + node(name), + node(f"{name}/root", comp("Animator", 2, m_Controller=ref(FX_CTL))), + node(f"{name}/root/glow", comp("SpriteRenderer", 3, m_Materials=[ref(MAT)])), + ], + } + + +def clip(name: str, path: str, attribute: str = "m_Color.a") -> dict: + binding = {"pathCrc": crc(path), "attributeCrc": crc(attribute), "typeID": 212, "class": "SpriteRenderer"} + return {"name": name, "sampleRate": 60.0, "bindings": [binding]} + + +def objects() -> dict: + """{object id: (class, name, {role: JSON document | bytes})}.""" + out = {} + for name in livenotes.NOTE_PREFABS: + doc = flick_view(name) if name in livenotes.FLICK_SKIN_UNITS else plain(name) + out[prefab_id(name)] = ("GameObject", name, {"json": doc}) + shader_files = {v["role"]: b"program" for v in SHADER_VARIANTS} + out.update( + { + FLICK_CTL: ("AnimatorController", "flick", {"json": {"name": "flick", "clips": [ref(FLICK_CLIP)]}}), + FLICK_CLIP: ("AnimationClip", "flick_loop", {"json": clip("flick_loop", "arrow")}), + LOOP_CLIP: ("AnimationClip", "sync_loop", {"json": clip("sync_loop", "arrow")}), + SKIN_CLIP: ("AnimationClip", "skin_loop", {"json": clip("skin_loop", "arrow")}), + LANE: ( + "MonoBehaviour", + "LiveLaneEffectAssetSettings", + {"json": {"$script": SIRIUS + "LiveLaneEffectAssetSettings", "NormalEffect": ref(LANE_FX)}}, + ), + LANE_FX: ("GameObject", "lane_tap", {"json": effect_prefab("lane_tap")}), + FX_CTL: ( + "AnimatorController", + "tap", + {"json": {"name": "tap", "clips": [ref(FX_CLIP), ref("CAB-gone:1")]}}, + ), + FX_CLIP: ("AnimationClip", "tap_perfect", {"json": clip("tap_perfect", "glow")}), + SKIN: ( + "MonoBehaviour", + "LiveNoteSkinAsset", + { + "json": { + "$script": SIRIUS + "LiveNoteSkinAsset", + "FlickNoteAsset": ref(UNIT_FLICK), + "LeftFlickNoteAsset": ref(UNIT_LEFT), + "RightFlickNote": None, + } + }, + ), + UNIT_FLICK: ( + "MonoBehaviour", + "FlickNoteSkinAssetUnit", + {"json": {"$script": SIRIUS + "FlickNoteSkinAssetUnit", "_arrowLoopAnimation": ref(SKIN_CLIP)}}, + ), + UNIT_LEFT: ( + "MonoBehaviour", + "FlickLeftNoteSkinAssetUnit", + {"json": {"$script": SIRIUS + "FlickNoteSkinAssetUnit", "_arrowLoopAnimation": None}}, + ), + EFFECT: ( + "MonoBehaviour", + "LiveNoteEffectAssetSettings", + {"json": {"$script": SIRIUS + "LiveNoteEffectAssetSettings", "NormalEffect": ref(EFFECT_FX)}}, + ), + EFFECT_FX: ("GameObject", "note_normal", {"json": effect_prefab("note_normal")}), + MAT: ("Material", "sprite", {"json": {"material": "sprite", "shader": ref(SHADER), "keywords": []}}), + SHADER: ( + "Shader", + "Sirius/Sprite", + { + "json": {"name": "Sirius/Sprite"}, + "index": {"name": "Sirius/Sprite", "variants": SHADER_VARIANTS}, + "typetree": {"m_Name": ""}, + **shader_files, + }, + ), + SCRIPT: ("MonoScript", "LiveFlickNoteView", {"json": {"m_ClassName": "LiveFlickNoteView"}}), + } + ) + return out + + +NOTES_KEYS = {**{k: [prefab_id(n)] for n, k in livenotes.NOTE_PREFABS.items()}, livenotes.LANE_EFFECT_KEY: [LANE]} +SKIN_KEYS = { + livenotes.skin_key("skin001"): [SKIN], + **{livenotes.NOTE_PREFABS[v]: [prefab_id(v)] for v in livenotes.FLICK_SKIN_UNITS}, +} +EFFECT_KEYS = {livenotes.effect_settings_key("effect001Light"): [EFFECT]} + + +def table(depot, keys: dict, objs: dict | None = None) -> dict: + """The object table of `objs` (objects()) with the roots of `keys` {key: object ids}, every file put in + `depot`.""" + out = {} + for oid, (cls, name, files) in sorted((objs if objs is not None else objects()).items()): + recs = {} + for role, body in sorted(files.items()): + data = body if isinstance(body, bytes) else contract.encode(body) + ext = "glsl" 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} + roots = sorted({o for v in keys.values() for o in v}) + return {"schema": contract.OBJECTS, "roots": roots, "keys": keys, "objects": out, "missing": []} + + +def master() -> dict: + """Master tables {name: rows} of the notes settings.""" + items = {"NoteSpeed": 1, "LiveQuality": 6, "NoteDesignId": 306, "NoteEffectId": 307, "LiveMusicVolume": 410} + values = { + "NoteSpeed": "5.00", + "LiveQuality": "1", + "NoteDesignId": "2", + "NoteEffectId": "1", + "LiveMusicVolume": "100", + } + defaults = [ + {"_id": i, "_presetId": p, "_optionItemType": t, "_valueString": values[n]} + for p in (1, 2) + for i, (n, t) in enumerate(items.items()) + ] + defaults.append({"_id": 99, "_presetId": 1, "_optionItemType": 999, "_valueString": "x"}) # not a note item + ls = { + "note_speed_min": 1.0, + "note_speed_max": 12.0, + "note_speed_view_min": 0.4, + "note_speed_view_max": 7.0, + "slide_offset_min_note_lane_width": 0.5, + "unrelated": 3, + } + return { + "MasterOptionDefault": defaults, + "MasterOptionRange": [ + {"_optionItemType": 1, "_minValue": 100, "_maxValue": 1200}, + {"_optionItemType": 999, "_minValue": 0, "_maxValue": 1}, + ], + "MasterLiveSettings": [{"_key": k, "_value": v} for k, v in ls.items()], + "MasterLiveNoteSkin": [{"_id": 1, "_assetName": "skin001"}, {"_id": 2, "_assetName": "skin002"}], + "MasterLiveNoteEffectSkin": [ + {"_id": 1, "_assetName": "effect001"}, + {"_id": 2, "_assetName": "effect001Simple"}, + ], + } diff --git a/tests/helpers/spotobjects.py b/tests/helpers/spotobjects.py index 29ff81d..554edd4 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), "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "missing": []} diff --git a/tests/pipeline/test_assemble.py b/tests/pipeline/test_assemble.py index 969334a..5bbb6f2 100644 --- a/tests/pipeline/test_assemble.py +++ b/tests/pipeline/test_assemble.py @@ -7,7 +7,7 @@ import pytest -from nnnotes.pipeline import assemble, contract, layout, link +from nnnotes.pipeline import assemble, contract, layout, link, units from nnnotes.pipeline.contract import Task from nnnotes.pipeline.depot import Depot from nnnotes.pipeline.objexport import ExportStage, SpriteCropStage @@ -159,7 +159,7 @@ def test_the_object_table_is_what_the_root_reaches(tmp_path): exported(depot, env) t, doc = product(depot, env, "m/a", {"m/a": "key/a"}) table = table_of(depot, doc) - assert table["roots"] == [oid(60)] + assert table["roots"] == [oid(60)] and table["keys"] == {"key/a": [oid(60)]} assert table["missing"] and all(m.startswith("CAB-scripts:") for m in table["missing"]) # no census of it # the hierarchy: the root exported, its members contained in its prefab assert table["objects"][oid(60)] == { @@ -218,7 +218,7 @@ def test_a_resources_load_path_is_a_root(tmp_path): exported(depot, env) assert stage.depends("m/a", env) == ["link.addresses:main", "unity.export:all:10", "unity.export:all:60"] _, doc = product(depot, env, "m/a", stage=stage) - assert table_of(depot, doc)["roots"] == [oid(60)] + assert table_of(depot, doc)["roots"] == [oid(60)] and table_of(depot, doc)["keys"] == {} def test_a_builtin_selector_is_a_root(tmp_path): @@ -249,7 +249,9 @@ def test_sprite_images_come_from_the_crop_of_their_atlas(tmp_path): sprite = table["objects"][oid(13)] assert set(sprite["files"]) == {"json", "image"} # the image from sprite.crop assert table["objects"][f"{ATLAS_CAB}:99"]["class"] == "SpriteAtlas" - assert table["objects"][f"{ATLAS_CAB}:97"]["status"] == "exported" # the atlas references its own sprite + # the request starts from the atlas, so its own sprite is exported; the product reaches the atlas through sprite + # 13 and not that sprite + assert f"{ATLAS_CAB}:97" not in table["objects"] and units.table(env).unit("atlas:97") crop = [c for c in [*doc["artifacts"]] if c["semantics"]["kind"] == "sprite.meta"] assert crop == [] # the sprite's meta belongs to its export result, not to the product assert f"{t.id}#objects" in {a["id"] for a in doc["artifacts"]} diff --git a/tests/pipeline/test_live.py b/tests/pipeline/test_live.py index b17983e..0467b35 100644 --- a/tests/pipeline/test_live.py +++ b/tests/pipeline/test_live.py @@ -1,5 +1,5 @@ -"""The live sound products (pipeline.live) and the cue sheets of product stages (pipeline.assemble) on synthetic -master tables, ACBs and decodes.""" +"""The live products (pipeline.live) and the cue sheets of product stages (pipeline.assemble) on synthetic master +tables, ACBs, decodes and object tables.""" import json import subprocess @@ -7,17 +7,18 @@ import pytest -from nnnotes.content import liveaudio +from nnnotes.content import liveaudio, livenotes from nnnotes.formats import encoders from nnnotes.pipeline import assemble, contract, cristages, live from nnnotes.pipeline.assemble import Assembly, ProductStage from nnnotes.pipeline.contract import IncompatibleTask, Input, Task from nnnotes.pipeline.cristages import AudioStage from nnnotes.pipeline.depot import Depot -from nnnotes.pipeline.live import MusicStage, SoundsStage +from nnnotes.pipeline.live import MusicStage, NoteEffectStage, NoteSkinStage, NotesStage, SoundsStage from nnnotes.pipeline.stages import Env, describe, execute, registry from tests.content.test_live_options import write_master from tests.helpers import cri +from tests.helpers import livenotes as ln from tests.pipeline.test_assemble import addresses from tests.pipeline.test_cristages import export_result, fake_tools, planning from tests.site.test_story_site import mp4 @@ -103,7 +104,7 @@ 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), "objects": objects, "missing": []} + doc = {"schema": contract.OBJECTS, "roots": sorted(objects), "keys": {}, "objects": objects, "missing": []} return stored(depot, contract.encode(doc), "objects") @@ -152,6 +153,30 @@ def test_the_stages_name_their_subjects_keys_and_manifests(tmp_path): sounds.keys("NoteSePatternId=1,9", env) +def test_the_note_stages_name_their_subjects_keys_and_manifests(tmp_path): + env = Env(Depot(tmp_path / "d"), {"master": master_dir(tmp_path)}) + notes, skins, effects = NotesStage(), NoteSkinStage(), NoteEffectStage() + keys = notes.keys("default", env) + assert keys == livenotes.notes_keys() and livenotes.LANE_EFFECT_KEY in keys and len(keys) == 17 + assert notes.select(keys, env) == ["default"] and notes.select(keys[1:], env) == [] + assert notes.select(keys, Env(env.depot, {"master": None})) == [] + assert notes.manifest("default") == "notes/default.json" + with pytest.raises(ValueError, match="live notes subject is default"): + notes.keys("NoteDesignId=1", env) + flicks = [livenotes.NOTE_PREFABS[v] for v in livenotes.FLICK_SKIN_UNITS] + assert skins.keys("skin002", env) == sorted([livenotes.skin_key("skin002"), *flicks]) + held = [*keys, livenotes.skin_key("skin001"), livenotes.skin_key("skin002"), "Live/Note/x y/LiveNoteSkinAsset"] + assert skins.select(held, env) == ["skin001", "skin002"] # a name that is no path segment is left out + assert skins.select([livenotes.skin_key("skin001")], env) == [] # without the flick views + assert skins.manifest("skin001") == "noteskins/skin001.json" + fx = [livenotes.effect_settings_key(n) for n in ("effect001", "effect001Light")] + assert effects.select([*fx, "Effect/Live/NoteEffect/effect001/note_normal"], env) == ["effect001", "effect001Light"] + assert effects.keys("effect001Light", env) == [fx[1]] + assert effects.manifest("effect001Light") == "noteeffects/effect001Light.json" + with pytest.raises(ValueError, match="not a note skin or effect set name"): + effects.keys("a/b", env) + + def test_sounds_subjects_name_the_offered_sets(): assert liveaudio.sounds_subject() == "default" and liveaudio.sounds_groups("default") == () assert liveaudio.sounds_subject((3, 1)) == "NoteSePatternId=1,3" @@ -220,6 +245,26 @@ def encode(ffmpeg, src, dst, fmt): run(depot, stage, "7", inputs, None, {"audio.encode": "ffmpeg-other/1"}) +def test_the_notes_product_keeps_what_the_player_reads(tmp_path): + depot = Depot(tmp_path / "d") + env = Env(depot, {"master": master_dir(tmp_path)}) + stage = NotesStage() + objects = stored(depot, contract.encode(ln.table(depot, ln.NOTES_KEYS)), "objects") + t, doc, arts = run(depot, stage, "default", [objects, master_record(env, stage, "default")]) + assert set(arts) == {"notes.json", "objects"} and arts["notes.json"]["semantics"]["kind"] == "live.notes" + notes = contract.loads(depot.read(arts["notes.json"]["content"]["sha256"])) + assert notes["prefabs"]["tap_note_view"] == ln.prefab_id("tap_note_view") + assert notes["settings"]["skins"] == {"1": "skin001", "2": "skin002", "3": "skin003"} + kept = contract.loads(depot.read(arts["objects"]["content"]["sha256"]))["objects"] + assert ln.SCRIPT not in kept and ln.prefab_id("tap_note_view") in kept + assert sorted(kept[ln.SHADER]["files"]) == [ + "index", + "json", + "program:gles3/s0p0_progVertex_0", + "program:gles3/s0p1_progVertex_1", + ] + + def test_the_sounds_product(tmp_path): depot = Depot(tmp_path / "d") env = Env(depot, {"master": master_dir(tmp_path)}) @@ -365,7 +410,8 @@ def test_a_cue_sheet_the_request_does_not_hold_is_an_error(tmp_path): def test_planning_the_stages_does_not_import_unitypy(): code = ( - "import sys, nnnotes.pipeline.live as m; m.MusicStage(); m.SoundsStage(); " + "import sys, nnnotes.pipeline.live as m; m.MusicStage(); m.SoundsStage(); m.NotesStage(); " + "m.NoteSkinStage(); m.NoteEffectStage(); " "print(sorted(x for x in ('UnityPy', 'numpy', 'PIL') if x in sys.modules))" ) r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) diff --git a/tests/pipeline/test_objexport.py b/tests/pipeline/test_objexport.py index 292f288..2934fce 100644 --- a/tests/pipeline/test_objexport.py +++ b/tests/pipeline/test_objexport.py @@ -1370,15 +1370,11 @@ def test_describe_reads_the_units_and_the_script_table(tmp_path): stage = ExportStage(FAKE_READER) subjects = stage.subjects(env) assert "all:60" in subjects and "all:61" not in subjects and "atlas:1" in subjects - # a selection's units and the units their objects reach in other bundles (a sprite's atlas, its texture and - # the sprites it packs); not the rest of those bundles + # a selection's units and the units their objects reach in other bundles (a sprite's atlas and its texture, not + # the other sprites the atlas packs); not the rest of those bundles assert [ s for s in stage.subjects(planning_env(Depot(tmp_path / "t"), selected=["all"])) if s.startswith("atlas") - ] == [ - "atlas:97", - "atlas:98", - "atlas:99", - ] + ] == ["atlas:98", "atlas:99"] assert stage.subjects(planning_env(Depot(tmp_path / "u"), selected=["plain"])) == ["plain:1", "plain:2"] t = describe(stage, "all:10", None, env) assert t.atoms == export_atoms(["Texture2D"], FAKE_READER.id) and t.subject == "all:10" @@ -1504,13 +1500,13 @@ def test_the_context_holds_the_shipped_type_trees_of_its_scripts(tmp_path, monke 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) - assert t.subjects() == ["all:11", "all:12", "all:13", "atlas:97"] + assert t.subjects() == ["all:11", "all:12", "all:13"] # the atlas' own sprite 97 is not reached through 13 u = t.unit("all:13") assert (u.bundle, u.root, u.cls, u.members) == ("all", oid(13), "Sprite", (oid(13),)) assert t.subject_of(oid(62)) == "all:60" and t.subject_of(f"{ATLAS_CAB}:98") == "atlas:98" assert t.subject_of("CAB-plain:2") == "plain:2" and t.subject_of("CAB-x:1") is None assert t.files == {CAB: "all", ATLAS_CAB: "atlas", "CAB-plain": "plain"} - assert t.per_bundle == {"all": 3, "atlas": 1} and t.classes == {"Sprite": 4} + assert t.per_bundle == {"all": 3} and t.classes == {"Sprite": 3} assert units.table(env) is t # kept for the same planning state h = units.table(planning_env(Depot(tmp_path / "t"), ("all",))).unit("all:60") assert h.members == tuple(oid(p) for p in (60, 61, 62, 63, 64, 65)) @@ -1661,3 +1657,33 @@ def changed(ctx, info): problems = golden_problems(expected, actual, p["stage"]) assert "unity.export: fixture font: output changed without a version bump" in problems assert not any("fixture tex.png" in x for x in problems) + + +def test_a_sprite_reaches_its_atlas_and_not_the_atlas_other_sprites(): + from tests.helpers.units import census_doc + + def doc(file, objects, refs): + d = census_doc(file, objects) + for o in d["files"][0]["objects"]: + o["refs"] = [f"{file}:{t}" if isinstance(t, int) else t for t in refs.get(o["pathId"], ())] + return d + + # the atlas packs sprites 2 and 3 and holds texture 4; each sprite references the atlas and the texture + atlas = doc( + "CAB-a", + [(1, "SpriteAtlas"), (2, "Sprite"), (3, "Sprite"), (4, "Texture2D")], + {1: [2, 3, 4], 2: [1, 4], 3: [1, 4]}, + ) + prefab = doc("CAB-p", [(9, "MonoBehaviour")], {9: ["CAB-a:2"]}) + censuses = [("atlas", atlas), ("prefab", prefab)] + t = units.build(censuses, ["atlas", "prefab"], ["prefab"]) + assert t.subjects() == ["atlas:1", "atlas:2", "atlas:4", "prefab:9"] # not sprite 3 + assert not t.reaches("atlas:1", "atlas:3", False) and t.reaches("atlas:1", "atlas:3", True) + assert t.reaches("atlas:1", "atlas:4", False) + # an atlas the request starts from reaches every sprite it packs + assert units.build(censuses, ["atlas", "prefab"], [], roots=["CAB-a:1"]).subjects() == [ + "atlas:1", + "atlas:2", + "atlas:3", + "atlas:4", + ]