nnnotes 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/adv.py ADDED
@@ -0,0 +1,243 @@
1
+ """ADV (2D story) extractor: episode script + text/sound/video shards -> episode.json.
2
+
3
+ The episode `.asset` is an AdvEpisodeCollection MonoBehaviour whose `Collection`
4
+ is the flat command list (UnityPy read_typetree decodes it directly). The
5
+ `-Text/-Sound/-SoundCueSheet/-Video` sibling keys are plain-JSON TextAssets.
6
+ Output is a self-contained JSON a viewer can play without any game code.
7
+
8
+ `resources` is the episode's asset closure as AdvEpisodeResourceLoader.AddEpisodeLoadingTask
9
+ builds it row by row (see `closure`); cue sheets are not listed (the -SoundCueSheet shard holds them).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from .catalog import Catalog
17
+ from .config import apk_missing
18
+ from . import master, unity
19
+ from .advcommand import COMMAND
20
+
21
+ # AdvEpisodeResourceLoader.AddEpisodeLoadingTask, per command: (resource kind, catalog-key prefix) of the asset named
22
+ # by TargetAssetName. The address is prefix + name; a blank name loads nothing (GetXxxAssetAddress). Costume rows load
23
+ # nothing (the model is the Character row with the same TargetName and TargetAssetIndex).
24
+ RESOURCE_PREFIX = {
25
+ "Character": ("live2d", "Character/Live2D/"),
26
+ "Stage": ("stage", "Adv/Stage/"),
27
+ "Still": ("still", "Adv/Still/"),
28
+ "Frame": ("frame", "Adv/Frame/"),
29
+ "Effect": ("effect", "Adv/Effect/"), # instance keyed by TargetName (LoadParticleEffect)
30
+ "PostEffect": ("posteffect", "Adv/PostEffect/"),
31
+ "Timeline": ("timeline", "Adv/Timeline/"), # TargetAssetName, else TargetName
32
+ "ChatStamp": ("chatstamp", "Adv/Chat/Stamp/"),
33
+ }
34
+ # Resources derived from other fields
35
+ TRANSITION_PREFIX = "Adv/Transition/" # FadeOut / FadeIn: TargetAssetName, else the player settings' default
36
+ TALK_WINDOW_PREFIX = "UI/Prefab/Parts/Adv/Talk/" # TalkWindow: TargetAssetName, else DEFAULT_TALK_WINDOW
37
+ DEFAULT_TALK_WINDOW = "UIDefaultTalkWindow"
38
+ CHAT_WINDOW_PREFIX = "Adv/Chat/Prefabs/" # ChatWindow: MasterAdvChat[TargetChatID]._chatWindowAssetName
39
+ CHAT_ICON_PREFIX = "Adv/Chat/Icon/" # ChatTalk / ChatStamp: MasterAdvChat[TargetChatID]._chatIconAssetName
40
+ VIDEO_ADDRESS = "Cri/Video/{0}" # Movie / Clip: the -Video row of VideoID, _assetName (VideoAssetHelper)
41
+ EMBEDDED_PREFIX = "Emb" # AddressableConsts.TryResolveAddressInternal: address, else "Emb" + address
42
+ PLAYER_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvPlayerSettings"
43
+ TRANSITION_COMMANDS = ("FadeOut", "FadeIn")
44
+ VIDEO_COMMANDS = ("Movie", "Clip")
45
+ CHAT_ICON_COMMANDS = ("ChatTalk", "ChatStamp")
46
+
47
+ LANGS = ("_japanese", "_english", "_traditionalChinese", "_simplifiedChinese", "_korean")
48
+
49
+
50
+ @dataclass
51
+ class AdvExtract:
52
+ adv_id: int
53
+ asset: str
54
+ commands: list[dict]
55
+ text: dict
56
+ sounds: dict
57
+ cuesheets: dict
58
+ videos: dict
59
+ resources: list[dict]
60
+ master: dict # MasterAdv row: playback mode, title text id
61
+ title: dict | None # MasterText lines of the title (embedded master)
62
+
63
+
64
+ def _master_row(adv_id: int, master_dir: Path) -> dict:
65
+ for r in master.table(master_dir, "MasterAdv"):
66
+ if r["_id"] == adv_id:
67
+ return r
68
+ raise KeyError(f"MasterAdv has no row {adv_id}")
69
+
70
+
71
+ def _master_text(text_id: str, master_dir: Path) -> dict:
72
+ for r in master.table(master_dir, "MasterText"):
73
+ if r["_id"] == text_id:
74
+ return {lang[1:]: r[lang] for lang in LANGS}
75
+ raise KeyError(f"MasterText has no row {text_id}")
76
+
77
+
78
+ def _text_of(cat: Catalog, key: str) -> str | None:
79
+ if not cat.has(key):
80
+ return None
81
+ for p in cat.fetch_key(key):
82
+ ta = unity.textassets(unity.load(p))
83
+ if ta:
84
+ return next(iter(ta.values()))
85
+ return None
86
+
87
+
88
+ def extract(cat: Catalog, master_dir: Path, adv_id: int) -> AdvExtract:
89
+ row = _master_row(adv_id, master_dir)
90
+ a = row["_advEpisodeAsset"]
91
+ base = f"Adv/Episode/{a}/{a}"
92
+
93
+ # 1) episode command list (MonoBehaviour typetree)
94
+ coll = None
95
+ for p in cat.fetch_key(base):
96
+ tt = unity.first_mono(unity.load(p), must_have="Collection")
97
+ if tt:
98
+ coll = tt["Collection"]
99
+ break
100
+ if coll is None:
101
+ raise RuntimeError(f"episode Collection not found for {a}")
102
+
103
+ # 2) shards
104
+ def shard(suf):
105
+ t = _text_of(cat, f"{base}{suf}")
106
+ return unity.parse_shard(t)["rows"] if t else []
107
+
108
+ text_rows = shard("-Text")
109
+ sound_rows = shard("-Sound")
110
+ cue_rows = shard("-SoundCueSheet")
111
+ video_rows = shard("-Video")
112
+ text_idx = unity.shard_index(text_rows)
113
+ sound_idx = unity.shard_index(sound_rows)
114
+
115
+ # 3) enrich commands
116
+ out_cmds = []
117
+ for c in coll:
118
+ name = COMMAND.get(c["Command"], f"Cmd{c['Command']}")
119
+ item = {"i": c["Index"], "cmd": name, "raw": c["Command"]}
120
+ # Keep every non-default field verbatim (speaker text ids/colors,
121
+ # camera distance, motion fade, parameters...), dropping only empties.
122
+ for k, v in c.items():
123
+ if k in ("Index", "Command"):
124
+ continue
125
+ if v in (None, "", 0, 0.0) or v == []:
126
+ continue
127
+ item[k] = v
128
+ if c.get("VoiceIDs"):
129
+ item["voiceCues"] = [
130
+ sound_idx[v].get("_cueName") for v in c["VoiceIDs"] if v in sound_idx
131
+ ]
132
+ tid = c.get("AdvTextID")
133
+ if tid and tid in text_idx:
134
+ r = text_idx[tid]
135
+ item["lines"] = {lang[1:]: r.get(lang, "") for lang in LANGS}
136
+ out_cmds.append(item)
137
+
138
+ # 4) resource closure
139
+ chats: dict = {}
140
+ default_transition: list[str] = []
141
+
142
+ def chat(chat_id: int) -> dict:
143
+ if not chats:
144
+ chats.update({r["_id"]: r for r in master.table(master_dir, "MasterAdvChat")})
145
+ if chat_id not in chats:
146
+ raise KeyError(f"MasterAdvChat has no row {chat_id}")
147
+ return chats[chat_id]
148
+
149
+ def transition() -> str:
150
+ if not default_transition:
151
+ default_transition.append(_default_transition(cat))
152
+ return default_transition[0]
153
+
154
+ resources = closure(coll, cat.has, unity.shard_index(video_rows), chat, transition)
155
+
156
+ return AdvExtract(
157
+ adv_id=adv_id, asset=a, commands=out_cmds,
158
+ text={r["_id"]: {lang[1:]: r.get(lang, "") for lang in LANGS} for r in text_rows if "_id" in r},
159
+ sounds={r["_id"]: r for r in sound_rows if "_id" in r},
160
+ cuesheets={r["_id"]: r for r in cue_rows if "_id" in r},
161
+ videos={r["_id"]: r for r in video_rows if "_id" in r},
162
+ resources=resources,
163
+ master=row,
164
+ title=_master_text(row["_titleTextId"], master_dir) if row["_titleTextId"] else None,
165
+ )
166
+
167
+
168
+ def _name(v) -> str | None:
169
+ """A name field as GetXxxAssetAddress reads it: None when null, empty or white space, else the value as is."""
170
+ return v if isinstance(v, str) and v.strip() else None
171
+
172
+
173
+ def closure(rows: list[dict], has, videos: dict, chat, default_transition) -> list[dict]:
174
+ """The assets AddEpisodeLoadingTask loads for the episode's rows (raw `Collection` rows, `Command` numbers),
175
+ as [{kind, address, present}] sorted by (kind, address).
176
+
177
+ `has(address)`: whether the catalog has the address; `videos`: -Video rows by id; `chat(id)`: the MasterAdvChat
178
+ row of a chat id; `default_transition()`: the player settings' default transition (called only when a
179
+ FadeOut / FadeIn row has no transition name). A row with IgnoreData only registers its Key: it loads nothing.
180
+ An Effect instance is keyed by TargetName: a row without one loads nothing. Talk window prefabs are embedded
181
+ content: the address resolves to the "Emb" key when the catalog does not have the plain one.
182
+ """
183
+ found: dict[tuple, dict] = {}
184
+
185
+ def add(kind: str, address: str) -> None:
186
+ found.setdefault((kind, address), {"kind": kind, "address": address, "present": has(address)})
187
+
188
+ for c in rows:
189
+ if c.get("IgnoreData"):
190
+ continue
191
+ name = COMMAND.get(c["Command"])
192
+ tgt = _name(c.get("TargetAssetName"))
193
+ if name in RESOURCE_PREFIX:
194
+ kind, prefix = RESOURCE_PREFIX[name]
195
+ if name == "Timeline":
196
+ tgt = tgt or _name(c.get("TargetName"))
197
+ if name == "Effect" and not _name(c.get("TargetName")):
198
+ tgt = None
199
+ if tgt:
200
+ add(kind, prefix + tgt)
201
+ if name in TRANSITION_COMMANDS:
202
+ add("transition", TRANSITION_PREFIX + (tgt or default_transition()))
203
+ elif name == "TalkWindow":
204
+ address = TALK_WINDOW_PREFIX + (tgt or DEFAULT_TALK_WINDOW)
205
+ add("talkwindow", address if has(address) else EMBEDDED_PREFIX + address)
206
+ elif name in VIDEO_COMMANDS and (c.get("VideoID") or 0) > 0:
207
+ # PrepareVideoTask: a VideoID without a -Video row (or without an asset name) loads nothing
208
+ asset = _name((videos.get(c["VideoID"]) or {}).get("_assetName"))
209
+ if asset:
210
+ add("video", VIDEO_ADDRESS.format(asset))
211
+ if (c.get("TargetChatID") or 0) > 0:
212
+ if name == "ChatWindow":
213
+ window = _name(chat(c["TargetChatID"]).get("_chatWindowAssetName"))
214
+ if window:
215
+ add("chatwindow", CHAT_WINDOW_PREFIX + window)
216
+ elif name in CHAT_ICON_COMMANDS:
217
+ icon = _name(chat(c["TargetChatID"]).get("_chatIconAssetName"))
218
+ if icon:
219
+ add("chaticon", CHAT_ICON_PREFIX + icon)
220
+ return sorted(found.values(), key=lambda d: (d["kind"], d["address"]))
221
+
222
+
223
+ def _default_transition(cat: Catalog) -> str:
224
+ """AdvPlayerSettings._defaultTransitionAssetAddress (embedded content: the catalog needs the APK's)."""
225
+ if not cat.has(PLAYER_SETTINGS_KEY):
226
+ raise apk_missing("the default ADV transition (AdvPlayerSettings)")
227
+ for p in cat.fetch_key(PLAYER_SETTINGS_KEY):
228
+ tt = unity.first_mono(unity.load(p), must_have="_defaultTransitionAssetAddress")
229
+ if tt:
230
+ return tt["_defaultTransitionAssetAddress"]
231
+ raise RuntimeError(f"{PLAYER_SETTINGS_KEY}: no _defaultTransitionAssetAddress")
232
+
233
+
234
+ def to_json(x: AdvExtract) -> dict:
235
+ return {
236
+ "advId": x.adv_id, "asset": x.asset,
237
+ "commandCount": len(x.commands),
238
+ "commands": x.commands,
239
+ "text": x.text, "sounds": x.sounds,
240
+ "cuesheets": x.cuesheets, "videos": x.videos,
241
+ "resources": x.resources,
242
+ "master": x.master, "title": x.title,
243
+ }
nnnotes/advcommand.py ADDED
@@ -0,0 +1,29 @@
1
+ """The ADV command numbers (AdvSystem.Asset.AdvCommand): the `Command` of an episode's rows, by value.
2
+
3
+ A module of its own, with no imports, so that readers of episode data that load no bundles (the voices index) name
4
+ commands as the story export does."""
5
+
6
+ # AdvCommand enum; values 0-69, 8 and 22 unused.
7
+ COMMAND = {
8
+ 0: "In", 1: "Out", 2: "Talk", 3: "Delay", 4: "Shake", 5: "FadeOut",
9
+ 6: "FadeIn", 7: "Focus", 9: "Forward", 10: "Back", 11: "Flash",
10
+ 12: "Brightness", 13: "MoveToRight", 14: "MoveToLeft", 15: "Bgm",
11
+ 16: "SoundVolume", 17: "Expression", 18: "Pause", 19: "Resume",
12
+ 20: "Location", 21: "Motion", 23: "Character", 24: "Costume", 25: "Stage",
13
+ 26: "Movie", 27: "Clip", 28: "Subtitles", 29: "Wait", 30: "Still",
14
+ 31: "Se", 32: "Angle", 33: "Pan", 34: "Tilt", 35: "TalkWindow",
15
+ 36: "ChatWindow", 37: "ChatTalk", 38: "ChatStamp", 39: "ChatRead",
16
+ 40: "ChoiceSet", 41: "ChoiceShow", 42: "GoTo", 43: "PostEffect",
17
+ 44: "Frame", 45: "Timeline", 46: "Look", 47: "Pedestal", 48: "Track",
18
+ 49: "DoF", 50: "Role", 51: "CameraShake", 52: "Voice", 53: "Zoom",
19
+ 54: "Effect", 55: "Alpha", 56: "ForceAuto", 57: "StageEnv",
20
+ 58: "RimLight", 59: "CancelDelay", 60: "MoveToUp", 61: "MoveToDown",
21
+ 62: "MoveToForward", 63: "MoveToBack", 64: "MoveToDirection",
22
+ 65: "ChatTyping", 66: "LookTarget", 67: "PanV2", 68: "MotionLoop",
23
+ 69: "EyeBlink",
24
+ }
25
+
26
+
27
+ def name(value) -> str:
28
+ """The command's name ("Cmd<value>" for a value the enum does not list)."""
29
+ return COMMAND.get(value, f"Cmd{value}")
nnnotes/advmedia.py ADDED
@@ -0,0 +1,178 @@
1
+ """ADV episode assets beyond the scene: frames, particle effects, post-effect profiles, stills, chat and talk windows.
2
+
3
+ One JSON file per kind at the story root, written only when the episode's resource closure (adv.closure) has that
4
+ kind; textures go to the story's `textures/` (PNG, named like the scene's) and every Shader object of the loaded
5
+ bundle closures is handed back to advscene, which dumps them with the scene's shaders into `shaders/`:
6
+
7
+ frames.json Frame: "Adv/Frame/" + TargetAssetName (UIAdvWidget.SetFrame / AdvFrame), keyed by
8
+ TargetAssetName; each a prefab node list (AdvFrame, CanvasGroup, uGUI, Animator with its
9
+ controller and clips, UIParticle / ParticleSystem). Frame texts (TargetTextIDs) are episode
10
+ text ids (episode.json `text`).
11
+ effects.json Effect: "Adv/Effect/" + TargetAssetName, instantiated once per TargetName
12
+ (AdvEpisodeResourceLoader.LoadParticleEffect): `effects` by TargetAssetName (AdvParticleEffect,
13
+ ParticleSystem modules and renderers, Animator), `instances` TargetName -> TargetAssetName
14
+ posteffects.json PostEffect: "Adv/PostEffect/" + TargetAssetName, a URP VolumeProfile (not instantiated;
15
+ AdvGlobalVolume.ToggleVolume adds it as a child volume), keyed by TargetAssetName
16
+ stills.json Still: "Adv/Still/" + TargetAssetName (AdvStill, CanvasGroup, uGUI, DOTweenSequence
17
+ animations), keyed by TargetAssetName
18
+ chat.json ChatWindow / ChatTalk / ChatStamp: `chats` (the MasterAdvChat rows of the episode's chat ids),
19
+ `windows` ("Adv/Chat/Prefabs/" + _chatWindowAssetName, AdvChatWindow prefabs), `icons`
20
+ ("Adv/Chat/Icon/" + _chatIconAssetName) and `stamps` ("Adv/Chat/Stamp/" + TargetAssetName) as
21
+ sprites, `text` / `sounds` / `cueSheets`: the rows of the shared chat shards
22
+ (Adv/Chat/AdvChat-Text, -Sound, -SoundCueSheet); the cue sheet those rows name, AdvSe_Common,
23
+ is in no language's catalog and not in the APK (1.0.1 data), so nothing is decoded for them
24
+ talkwindows.json TalkWindow: "UI/Prefab/Parts/Adv/Talk/" + TargetAssetName (embedded content), keyed by window
25
+ name (the default window is also in ui/ui.json in its drawn form)
26
+
27
+ Prefabs are exported with export.Exporter (references resolved, AnimatorControllers followed, clips in the
28
+ generic format). TextMeshPro text components always keep their layout and style (size, alignment, colours,
29
+ margins, text). The font data follows `fonts`: "open" (default) exports none of it -- font / sprite / style sheet
30
+ assets become {asset, name} and TextMeshPro materials {material, shader}, each with a note, so a viewer draws the
31
+ text with a font of its own; "game" exports the font assets and their materials with the atlas textures like any
32
+ other asset. A MonoBehaviour whose script is missing (Unity runs nothing for it) is recorded as such.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ from pathlib import Path
37
+
38
+ from .catalog import Catalog
39
+ from .export import HEADER, Exporter
40
+ from .jsonio import write_json
41
+ from .player import PlayerData
42
+ from . import adv, unity
43
+ from . import master as master_mod
44
+
45
+ TMP_SHADER_PREFIX = "TextMeshPro/"
46
+ TMP_ASSETS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet")
47
+ FONT_CHOICES = ("open", "game")
48
+ FONT_NOTE = "font data not exported (fonts: open): draw the text with a font of your own"
49
+ CHAT_SHARDS = {"text": "Adv/Chat/AdvChat-Text", "sounds": "Adv/Chat/AdvChat-Sound",
50
+ "cueSheets": "Adv/Chat/AdvChat-SoundCueSheet"}
51
+ # resource kind -> (story.json key = file key, file, address prefix removed from the entry keys; None: last segment)
52
+ FILES = {
53
+ "frame": ("frames", "frames.json", adv.RESOURCE_PREFIX["Frame"][1]),
54
+ "effect": ("effects", "effects.json", adv.RESOURCE_PREFIX["Effect"][1]),
55
+ "posteffect": ("postEffects", "posteffects.json", adv.RESOURCE_PREFIX["PostEffect"][1]),
56
+ "still": ("stills", "stills.json", adv.RESOURCE_PREFIX["Still"][1]),
57
+ "talkwindow": ("talkWindows", "talkwindows.json", None),
58
+ }
59
+ CHAT_KINDS = ("chatwindow", "chaticon", "chatstamp")
60
+ CHAT = ("chat", "chat.json")
61
+ KINDS = tuple(FILES) + CHAT_KINDS
62
+ INDEX_KEYS = tuple(k for k, _, _ in FILES.values()) + (CHAT[0],)
63
+
64
+
65
+ class MediaExporter(Exporter):
66
+ """export.Exporter for uGUI and particle prefabs: AnimatorControllers followed, missing-script MonoBehaviours
67
+ recorded; with fonts="open" the TextMeshPro font / sprite / style sheet assets and materials as name stubs."""
68
+
69
+ def __init__(self, cat: Catalog, out_dir: Path, player: PlayerData, fonts: str = "open"):
70
+ if fonts not in FONT_CHOICES:
71
+ raise ValueError(f"fonts={fonts!r}")
72
+ super().__init__(cat, out_dir, player=player, stub_assets=TMP_ASSETS if fonts == "open" else (),
73
+ follow=("AnimatorController",))
74
+ self.fonts = fonts
75
+
76
+ def scriptable(self, o) -> dict:
77
+ out = super().scriptable(o)
78
+ if self.fonts == "open" and out.get("asset") in TMP_ASSETS:
79
+ return {**out, "note": FONT_NOTE}
80
+ return out
81
+
82
+ def material(self, o) -> dict:
83
+ if self.fonts == "game":
84
+ return super().material(o)
85
+ tt = o.read_typetree()
86
+ shader = self.deref(o, tt["m_Shader"])
87
+ name = self.add_shader(shader) if shader is not None else None
88
+ if name is None or not name.startswith(TMP_SHADER_PREFIX):
89
+ return super().material(o)
90
+ return {"material": tt["m_Name"], "shader": {"shader": name}, "note": FONT_NOTE}
91
+
92
+ def component(self, o) -> dict:
93
+ if o.type.name == "MonoBehaviour":
94
+ tt = o.read_typetree()
95
+ if not tt["m_Script"]["m_PathID"]:
96
+ return {"type": "MonoBehaviour", "class": None, "missingScript": True,
97
+ **self.value(o, {k: v for k, v in tt.items() if k not in HEADER})}
98
+ return super().component(o)
99
+
100
+
101
+ def _suffix(address: str, prefix: str | None) -> str:
102
+ if prefix is None:
103
+ return address.rsplit("/", 1)[-1]
104
+ if not address.startswith(prefix):
105
+ raise ValueError(f"{address}: not under {prefix}")
106
+ return address[len(prefix):]
107
+
108
+
109
+ def effect_instances(commands: list[dict]) -> dict[str, str]:
110
+ """TargetName -> TargetAssetName of the effect instances (LoadParticleEffect: the first asset loaded under a
111
+ TargetName is the instance every later Effect row with that TargetName drives)."""
112
+ out: dict[str, str] = {}
113
+ for c in commands:
114
+ if c["cmd"] == "Effect" and not c.get("IgnoreData") and adv._name(c.get("TargetName")) \
115
+ and adv._name(c.get("TargetAssetName")):
116
+ out.setdefault(c["TargetName"], c["TargetAssetName"])
117
+ return out
118
+
119
+
120
+ def _chat(cat: Catalog, master: Path, ex: Exporter, episode: dict, resources: list[dict]) -> dict:
121
+ rows = {r["_id"]: r for r in master_mod.table(master, "MasterAdvChat")}
122
+ ids = sorted({c["TargetChatID"] for c in episode["commands"] if c.get("TargetChatID")})
123
+ doc: dict = {"chats": {str(i): rows[i] for i in ids if i in rows},
124
+ "windows": {}, "icons": {}, "stamps": {}}
125
+ for r in resources:
126
+ if r["kind"] == "chatwindow":
127
+ doc["windows"][_suffix(r["address"], adv.CHAT_WINDOW_PREFIX)] = ex.export_key(r["address"])
128
+ elif r["kind"] == "chaticon":
129
+ doc["icons"][_suffix(r["address"], adv.CHAT_ICON_PREFIX)] = ex.key_sprite(r["address"])
130
+ elif r["kind"] == "chatstamp":
131
+ doc["stamps"][_suffix(r["address"], adv.RESOURCE_PREFIX["ChatStamp"][1])] = ex.key_sprite(r["address"])
132
+ for name, key in CHAT_SHARDS.items():
133
+ t = adv._text_of(cat, key)
134
+ doc[name] = unity.parse_shard(t)["rows"] if t else []
135
+ return doc
136
+
137
+
138
+ def extract(cat: Catalog, player: PlayerData, master: Path, episode: dict, out_dir: Path,
139
+ fonts: str = "open") -> dict:
140
+ """Write the kind files the episode needs (`fonts`: see the module docstring). Returns {"files": {index key:
141
+ file or None} for every INDEX_KEYS entry, "shaders": {name: Shader object}, "counts": {index key: entries},
142
+ "textures": textures written}."""
143
+ out_dir = Path(out_dir)
144
+ by_kind: dict[str, list[dict]] = {}
145
+ for r in episode["resources"]:
146
+ if r["kind"] in KINDS:
147
+ by_kind.setdefault(r["kind"], []).append(r)
148
+ files: dict[str, str | None] = dict.fromkeys(INDEX_KEYS)
149
+ shaders: dict = {}
150
+ counts: dict[str, int] = {}
151
+ textures = 0
152
+
153
+ def done(ex: Exporter, name: str, doc: dict) -> None:
154
+ nonlocal textures
155
+ ex.resolve_pending()
156
+ write_json(out_dir / name, doc)
157
+ for k, o in ex.shaders.items():
158
+ shaders.setdefault(k, o)
159
+ textures += len(ex.textures)
160
+
161
+ for kind, (key, name, prefix) in FILES.items():
162
+ if kind not in by_kind:
163
+ continue
164
+ ex = MediaExporter(cat, out_dir, player, fonts)
165
+ entries = {_suffix(r["address"], prefix): ex.export_key(r["address"]) for r in by_kind[kind]}
166
+ doc: dict = {key: entries}
167
+ if kind == "effect":
168
+ doc["instances"] = effect_instances(episode["commands"])
169
+ done(ex, name, doc)
170
+ files[key] = name
171
+ counts[key] = len(entries)
172
+ chat = [r for k in CHAT_KINDS for r in by_kind.get(k, [])]
173
+ if chat:
174
+ ex = MediaExporter(cat, out_dir, player, fonts)
175
+ done(ex, CHAT[1], _chat(cat, master, ex, episode, chat))
176
+ files[CHAT[0]] = CHAT[1]
177
+ counts[CHAT[0]] = len(chat)
178
+ return {"files": files, "shaders": shaders, "counts": counts, "textures": textures}
nnnotes/advscene.py ADDED
@@ -0,0 +1,113 @@
1
+ """ADV presentation data -> scene.json + textures + shaders.
2
+
3
+ Everything the game uses to present an ADV episode besides the episode script,
4
+ the Live2D models and the audio:
5
+
6
+ player colour space, quality levels, URP pipelines, renderer data and
7
+ features, post-process data (APK boot data, see player.py)
8
+ cameraManager the app's Main/Sub camera prefab
9
+ advScene the ADV scene (AdvSceneRoot + scene camera)
10
+ characterField AdvCharacterField prefab (Field / Anchors / Stages)
11
+ backgroundField AdvBackgroundField prefab (backdrop mesh + sprite renderer)
12
+ globalVolume AdvGlobalVolume prefab (volumes + profiles)
13
+ settings AdvPlayerSettings (+ focus data), AdvMasterIdSettings
14
+ stages every stage prefab the episode uses (AdvStage, lights,
15
+ background sprite, volume profiles)
16
+ resources Resources.Load materials the runtime draws with (Cubism mask
17
+ pass: Mask / MaskCulling)
18
+ postTextures per camera renderer: post-process data textures used by the
19
+ URP chain (film grain textures, indexed by FilmGrain.type)
20
+
21
+ Objects are exported with export.Exporter (references resolved, textures as
22
+ PNG). Shaders referenced anywhere, every Shader in the loaded bundle closures,
23
+ the ADV camera renderer's shaders and the shaders handed in by the caller (those
24
+ of advmedia's kind files) are dumped with shader.py into one `shaders/`.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from pathlib import Path
29
+
30
+ from .catalog import Catalog
31
+ from .export import Exporter
32
+ from .jsonio import write_json
33
+ from .player import PlayerData
34
+ from . import shader as shader_mod
35
+
36
+ ADV_KEYS = {
37
+ "cameraManager": "EmbCommon/Prefab/CameraManager",
38
+ "advScene": "EmbScene/Adv",
39
+ "characterField": "EmbCommon/Adv/Prefab/AdvCharacterField",
40
+ "backgroundField": "EmbCommon/Adv/Prefab/AdvBackgroundField",
41
+ "globalVolume": "EmbCommon/Adv/Prefab/AdvGlobalVolume",
42
+ }
43
+ SETTINGS_KEYS = {
44
+ "playerSettings": "EmbCommon/Adv/Settings/AdvPlayerSettings",
45
+ "masterIdSettings": "EmbCommon/Adv/Settings/AdvMasterIdSettings",
46
+ }
47
+ STAGE_PREFIX = "Adv/Stage/"
48
+
49
+
50
+ # Resources.Load paths used by the ADV runtime (Cubism mask pass materials)
51
+ RESOURCES = {
52
+ "cubismMask": "Live2D/Cubism/Materials/Mask",
53
+ "cubismMaskCulling": "Live2D/Cubism/Materials/MaskCulling",
54
+ }
55
+
56
+
57
+ def camera_renderer(player_graphics: dict, pipeline: str, renderer_index: int) -> str:
58
+ rl = player_graphics["pipelines"][pipeline]["m_RendererDataList"]
59
+ i = player_graphics["pipelines"][pipeline]["m_DefaultRendererIndex"] if renderer_index < 0 else renderer_index
60
+ return rl[i]
61
+
62
+
63
+ def extract(cat: Catalog, player: PlayerData, episode: dict, out_dir: Path, shaders: dict | None = None) -> dict:
64
+ """Write scene.json, textures/ and shaders/. `shaders`: further {name: Shader object} to dump with the scene's
65
+ (the scene's object wins for a name both have)."""
66
+ out_dir = Path(out_dir)
67
+ out_dir.mkdir(parents=True, exist_ok=True)
68
+ ex = Exporter(cat, out_dir, player=player)
69
+ graphics = player.graphics()
70
+ doc: dict = {"player": graphics}
71
+ for name, key in ADV_KEYS.items():
72
+ doc[name] = ex.prefab(key)
73
+ doc["settings"] = {name: ex.asset(key) for name, key in SETTINGS_KEYS.items()}
74
+ doc["resources"] = {name: ex.material(player.resource(path)) for name, path in RESOURCES.items()}
75
+
76
+ stages = {}
77
+ for c in episode["commands"]:
78
+ if c["cmd"] == "Stage" and c.get("TargetAssetName"):
79
+ key = STAGE_PREFIX + c["TargetAssetName"]
80
+ stages.setdefault(c["TargetAssetName"], None)
81
+ if stages[c["TargetAssetName"]] is None:
82
+ stages[c["TargetAssetName"]] = ex.prefab(key)
83
+ doc["stages"] = stages
84
+
85
+ # shaders: model closures, everything referenced above, the ADV camera renderer
86
+ for r in episode["resources"]:
87
+ if r["kind"] == "live2d":
88
+ ex.closure_shaders(r["address"])
89
+ renderers = set()
90
+ for node in doc["cameraManager"]["nodes"]:
91
+ for comp in node["components"]:
92
+ if comp.get("class") == "UniversalAdditionalCameraData":
93
+ renderers.add(camera_renderer(graphics, graphics["defaultPipeline"],
94
+ comp["m_RendererIndex"]))
95
+ for rn in sorted(renderers):
96
+ for o in player.renderer_shaders(rn):
97
+ ex._shader(o)
98
+ for name, o in (shaders or {}).items():
99
+ ex.shaders.setdefault(name, o)
100
+ # post-process textures the URP chain samples (film grain lookup by FilmGrain.type)
101
+ doc["postTextures"] = {rn: {"filmGrainTex": [ex.texture(o) for o in player.post_textures(rn, "filmGrainTex")]}
102
+ for rn in sorted(renderers)}
103
+ sdir = out_dir / "shaders"
104
+ index: list = []
105
+ for name in sorted(ex.shaders):
106
+ shader_mod.dump_objects([ex.shaders[name]], sdir, ex.shaders[name].assets_file.name, index)
107
+ shader_summary = shader_mod.write_index(index, sdir)
108
+ doc["shaders"] = {"index": "shaders/shaders.json", "names": shader_summary["names"],
109
+ "cameraRenderers": sorted(renderers)}
110
+ doc["convention"] = ("Unity space (left-handed, Y up); transforms are local TRS; "
111
+ "quaternions (x,y,z,w); colours in the project's Gamma space")
112
+ write_json(out_dir / "scene.json", doc)
113
+ return {"stages": list(stages), "textures": len(ex.textures), **shader_summary}