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.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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}
|