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/live2d.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Live2D (Cubism) model: Cubism-for-Unity prefab -> prefab.json + Cubism runtime files.
|
|
2
|
+
|
|
3
|
+
Components are identified by their MonoScript class (resolved through the APK's
|
|
4
|
+
shared_monoscripts bundle, so the Catalog must be opened with `apk=`).
|
|
5
|
+
|
|
6
|
+
`extract_runtime` writes what a re-implementation of the game's own runtime needs (`export_runtime` writes the
|
|
7
|
+
same and also returns the exporter state, for callers that export more of the model's bundles):
|
|
8
|
+
|
|
9
|
+
<name>.moc3 CubismMoc._bytes
|
|
10
|
+
textures/*.png atlas pages (Texture2D)
|
|
11
|
+
<name>.prefab.json the whole model prefab exported by export.Exporter:
|
|
12
|
+
every GameObject with every component (CubismRenderer/
|
|
13
|
+
MeshRenderer + materials per drawable, controllers,
|
|
14
|
+
Live2DCharacter, eye blink, harmonic motion, motion
|
|
15
|
+
sync, anchors ...), with the fade motion list, the
|
|
16
|
+
expression list and every AnimationClip (Mecanim clip
|
|
17
|
+
data + resolved bindings) inlined; plus `canvas` (moc3)
|
|
18
|
+
|
|
19
|
+
`extract_model` adds the Cubism SDK interchange files for other tools:
|
|
20
|
+
|
|
21
|
+
<exp>.exp3.json CubismExpressionData
|
|
22
|
+
<name>.physics3.json CubismPhysicsController._rig (inverse of ToRig), when the
|
|
23
|
+
model has physics
|
|
24
|
+
motions/<m>.motion3.json CubismFadeMotionData (see motion.py)
|
|
25
|
+
<name>.model3.json file refs + EyeBlink/LipSync groups from the
|
|
26
|
+
model's own CubismEyeBlinkParameter/CubismMouthParameter
|
|
27
|
+
|
|
28
|
+
model3.json lists the atlas pages sorted by name (the Cubism Editor's
|
|
29
|
+
texture_NN naming). The texture each drawable is actually drawn with is its
|
|
30
|
+
CubismRenderer._mainTexture in prefab.json, keyed by CubismDrawable._unmanagedIndex
|
|
31
|
+
(the Cubism Core drawable index).
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import struct
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from .catalog import Catalog
|
|
41
|
+
from .config import apk_missing
|
|
42
|
+
from .export import Exporter
|
|
43
|
+
from .jsonio import write_json
|
|
44
|
+
from . import motion as motion_mod
|
|
45
|
+
from .unity import SceneGraph, script_class
|
|
46
|
+
|
|
47
|
+
# Live2D.Cubism.Core.CubismParameterBlendMode -> exp3.json "Blend"
|
|
48
|
+
EXP_BLEND = {0: "Overwrite", 1: "Add", 2: "Multiply"}
|
|
49
|
+
# Live2D.Cubism.Framework.Physics.CubismPhysicsSourceComponent -> physics3 "Type"
|
|
50
|
+
PHYS_TYPE = {0: "X", 1: "Y", 2: "Angle"}
|
|
51
|
+
# the moc is written as <name>.moc3; prefab.json names it only
|
|
52
|
+
STUB_ASSETS = ("CubismMoc",)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def moc3_canvas(moc: bytes) -> dict:
|
|
56
|
+
"""CanvasInfo from a moc3 (section table at 0x40; entry 1 = canvas info)."""
|
|
57
|
+
if moc[:4] != b"MOC3":
|
|
58
|
+
raise ValueError("not a moc3")
|
|
59
|
+
canvas_off = struct.unpack_from("<I", moc, 0x40 + 4)[0]
|
|
60
|
+
ppu, ox, oy, w, h = struct.unpack_from("<5f", moc, canvas_off)
|
|
61
|
+
return {"pixelsPerUnit": ppu, "originX": ox, "originY": oy,
|
|
62
|
+
"width": w, "height": h, "mocVersion": moc[4]}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _vec2(v):
|
|
66
|
+
return {"X": v["x"], "Y": v["y"]}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def physics_json(rig: dict, names: list[str]) -> dict:
|
|
70
|
+
settings, dictionary = [], []
|
|
71
|
+
n_in = n_out = n_vtx = 0
|
|
72
|
+
for i, sr in enumerate(rig["SubRigs"]):
|
|
73
|
+
sid = f"PhysicsSetting{i + 1}"
|
|
74
|
+
dictionary.append({"Id": sid, "Name": sr["Name"]})
|
|
75
|
+
inputs = [{
|
|
76
|
+
"Source": {"Target": "Parameter", "Id": x["SourceId"]},
|
|
77
|
+
"Weight": x["Weight"], "Type": PHYS_TYPE[x["SourceComponent"]],
|
|
78
|
+
"Reflect": bool(x["IsInverted"]),
|
|
79
|
+
} for x in sr["Input"]]
|
|
80
|
+
outputs = [{
|
|
81
|
+
"Destination": {"Target": "Parameter", "Id": x["DestinationId"]},
|
|
82
|
+
"VertexIndex": x["ParticleIndex"], "Scale": x["AngleScale"],
|
|
83
|
+
"Weight": x["Weight"], "Type": PHYS_TYPE[x["SourceComponent"]],
|
|
84
|
+
"Reflect": bool(x["IsInverted"]),
|
|
85
|
+
} for x in sr["Output"]]
|
|
86
|
+
verts = [{
|
|
87
|
+
"Position": _vec2(p["InitialPosition"]), "Mobility": p["Mobility"],
|
|
88
|
+
"Delay": p["Delay"], "Acceleration": p["Acceleration"], "Radius": p["Radius"],
|
|
89
|
+
} for p in sr["Particles"]]
|
|
90
|
+
nz = sr["Normalization"]
|
|
91
|
+
settings.append({
|
|
92
|
+
"Id": sid, "Input": inputs, "Output": outputs, "Vertices": verts,
|
|
93
|
+
"Normalization": {
|
|
94
|
+
"Position": {k: nz["Position"][k] for k in ("Minimum", "Default", "Maximum")},
|
|
95
|
+
"Angle": {k: nz["Angle"][k] for k in ("Minimum", "Default", "Maximum")},
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
n_in += len(inputs); n_out += len(outputs); n_vtx += len(verts)
|
|
99
|
+
meta = {
|
|
100
|
+
"PhysicsSettingCount": len(settings), "TotalInputCount": n_in,
|
|
101
|
+
"TotalOutputCount": n_out, "VertexCount": n_vtx,
|
|
102
|
+
"EffectiveForces": {"Gravity": _vec2(rig["Gravity"]), "Wind": _vec2(rig["Wind"])},
|
|
103
|
+
"PhysicsDictionary": dictionary,
|
|
104
|
+
}
|
|
105
|
+
if rig["Fps"] > 0: # 0 = unset in physics3 terms
|
|
106
|
+
meta["Fps"] = rig["Fps"]
|
|
107
|
+
return {"Version": 3, "Meta": meta, "PhysicsSettings": settings}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def expression_json(tt: dict) -> dict:
|
|
111
|
+
return {
|
|
112
|
+
"Type": tt["Type"],
|
|
113
|
+
"FadeInTime": tt["FadeInTime"],
|
|
114
|
+
"FadeOutTime": tt["FadeOutTime"],
|
|
115
|
+
"Parameters": [{"Id": p["Id"], "Value": p["Value"], "Blend": EXP_BLEND[p["Blend"]]}
|
|
116
|
+
for p in tt["Parameters"]],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _classes(env) -> dict[str, list]:
|
|
121
|
+
by_cls: dict[str, list] = {}
|
|
122
|
+
for o in env.objects:
|
|
123
|
+
if o.type.name == "MonoBehaviour":
|
|
124
|
+
by_cls.setdefault(script_class(o), []).append(o)
|
|
125
|
+
return by_cls
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _only(by_cls, cls: str, name: str) -> dict:
|
|
129
|
+
objs = by_cls.get(cls, [])
|
|
130
|
+
if len(objs) != 1:
|
|
131
|
+
raise RuntimeError(f"{name}: {len(objs)} {cls}")
|
|
132
|
+
return objs[0].read_typetree()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class RuntimeExport:
|
|
137
|
+
"""What `export_runtime` wrote and read: `summary` (the result of `extract_runtime`), the Exporter it wrote with
|
|
138
|
+
(its shaders, textures, ...), the loaded bundle environment, its scene graph and the MonoBehaviours by script
|
|
139
|
+
class."""
|
|
140
|
+
summary: dict
|
|
141
|
+
exporter: Exporter
|
|
142
|
+
env: object
|
|
143
|
+
graph: SceneGraph
|
|
144
|
+
classes: dict[str, list]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def export_runtime(cat: Catalog, key: str, out_dir: Path) -> RuntimeExport:
|
|
148
|
+
"""Write the runtime files of the model `key` into `out_dir` (module docstring) and return them with the
|
|
149
|
+
exporter state."""
|
|
150
|
+
if cat.apk is None:
|
|
151
|
+
raise apk_missing("resolving the Cubism component classes")
|
|
152
|
+
out_dir = Path(out_dir)
|
|
153
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
name = key.rsplit("/", 1)[-1]
|
|
155
|
+
ex = Exporter(cat, out_dir, inline_meshes=False, stub_assets=STUB_ASSETS)
|
|
156
|
+
env, graph = ex.load(key)
|
|
157
|
+
by_cls = _classes(env)
|
|
158
|
+
moc = bytes(_only(by_cls, "CubismMoc", name)["_bytes"])
|
|
159
|
+
(out_dir / f"{name}.moc3").write_bytes(moc)
|
|
160
|
+
prefab = ex.prefab(key, loaded=(env, graph))
|
|
161
|
+
prefab["canvas"] = moc3_canvas(moc)
|
|
162
|
+
write_json(out_dir / f"{name}.prefab.json", prefab, indent=None)
|
|
163
|
+
pages = sorted((o for o in env.objects if o.type.name == "Texture2D"), key=lambda o: o.read().m_Name)
|
|
164
|
+
tex_files = [ex.texture(o)["texture"] for o in pages]
|
|
165
|
+
res = {"name": name, "moc3": f"{name}.moc3", "prefab": f"{name}.prefab.json",
|
|
166
|
+
"moc3Bytes": len(moc), "textures": tex_files, "nodes": len(prefab["nodes"])}
|
|
167
|
+
return RuntimeExport(res, ex, env, graph, by_cls)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def extract_runtime(cat: Catalog, key: str, out_dir: Path) -> dict:
|
|
171
|
+
"""Write the runtime files of the model `key` into `out_dir`; returns the summary: name, moc3, prefab,
|
|
172
|
+
moc3Bytes, textures, nodes."""
|
|
173
|
+
return export_runtime(cat, key, out_dir).summary
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def extract_model(cat: Catalog, key: str, out_dir: Path) -> dict:
|
|
177
|
+
out_dir = Path(out_dir)
|
|
178
|
+
rt = export_runtime(cat, key, out_dir)
|
|
179
|
+
res, env, graph, by_cls = rt.summary, rt.env, rt.graph, rt.classes
|
|
180
|
+
name = res["name"]
|
|
181
|
+
(out_dir / "motions").mkdir(parents=True, exist_ok=True)
|
|
182
|
+
|
|
183
|
+
def owner_names(cls):
|
|
184
|
+
return sorted(graph.go[o.read_typetree()["m_GameObject"]["m_PathID"]]["m_Name"]
|
|
185
|
+
for o in by_cls.get(cls, []))
|
|
186
|
+
|
|
187
|
+
# expressions
|
|
188
|
+
exp_refs = []
|
|
189
|
+
for o in by_cls.get("CubismExpressionData", []):
|
|
190
|
+
tt = o.read_typetree()
|
|
191
|
+
ename = tt["m_Name"].removesuffix(".exp3")
|
|
192
|
+
fn = f"{ename}.exp3.json"
|
|
193
|
+
write_json(out_dir / fn, expression_json(tt), indent=None)
|
|
194
|
+
exp_refs.append({"Name": ename, "File": fn})
|
|
195
|
+
exp_refs.sort(key=lambda e: e["Name"])
|
|
196
|
+
|
|
197
|
+
# physics (none without a CubismPhysicsController)
|
|
198
|
+
rig = (_only(by_cls, "CubismPhysicsController", name)["_rig"] if by_cls.get("CubismPhysicsController")
|
|
199
|
+
else {"SubRigs": []})
|
|
200
|
+
phys_file = None
|
|
201
|
+
if rig["SubRigs"]:
|
|
202
|
+
phys_file = f"{name}.physics3.json"
|
|
203
|
+
write_json(out_dir / phys_file, physics_json(rig, []), indent=None)
|
|
204
|
+
|
|
205
|
+
# motions
|
|
206
|
+
motions = motion_mod.extract_motions(env, out_dir / "motions")
|
|
207
|
+
model_fades = json.loads((out_dir / "motions" / "_fades.json").read_text(encoding="utf-8"))
|
|
208
|
+
|
|
209
|
+
# model3.json
|
|
210
|
+
fr = {"Moc": res["moc3"], "Textures": res["textures"]}
|
|
211
|
+
if phys_file:
|
|
212
|
+
fr["Physics"] = phys_file
|
|
213
|
+
if exp_refs:
|
|
214
|
+
fr["Expressions"] = exp_refs
|
|
215
|
+
|
|
216
|
+
def motion_entry(m, f):
|
|
217
|
+
e = {"File": f"motions/{f}"}
|
|
218
|
+
for k in ("FadeInTime", "FadeOutTime"):
|
|
219
|
+
if model_fades[m][k] >= 0:
|
|
220
|
+
e[k] = model_fades[m][k]
|
|
221
|
+
return [e]
|
|
222
|
+
fr["Motions"] = {m: motion_entry(m, f) for m, f in sorted(motions.items())}
|
|
223
|
+
groups = []
|
|
224
|
+
eye_ids = owner_names("CubismEyeBlinkParameter")
|
|
225
|
+
mouth_ids = owner_names("CubismMouthParameter")
|
|
226
|
+
if eye_ids:
|
|
227
|
+
groups.append({"Target": "Parameter", "Name": "EyeBlink", "Ids": eye_ids})
|
|
228
|
+
if mouth_ids:
|
|
229
|
+
groups.append({"Target": "Parameter", "Name": "LipSync", "Ids": mouth_ids})
|
|
230
|
+
model3 = {"Version": 3, "FileReferences": fr, "Groups": groups}
|
|
231
|
+
write_json(out_dir / f"{name}.model3.json", model3)
|
|
232
|
+
|
|
233
|
+
return {**res, "model3": f"{name}.model3.json", "expressions": len(exp_refs),
|
|
234
|
+
"motions": len(motions), "physicsSettings": len(rig["SubRigs"]),
|
|
235
|
+
"eyeBlink": eye_ids, "lipSync": mouth_ids}
|
nnnotes/liveaudio.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Live sounds for one music: SE / cheer / voice cue sheets, their CRI cue data and the live-audio index.
|
|
2
|
+
|
|
3
|
+
`extract(cat, master, player, music_id, out_dir)` decodes every cue sheet the live plays in the preview slice
|
|
4
|
+
(auto play at Perfect, fresh profile, default options) with `cri.decode` into `<out>/audio/<cueSheet>/`
|
|
5
|
+
and writes `<out>/audio/live-audio.json`:
|
|
6
|
+
|
|
7
|
+
sounds MasterSound id -> {row, sheet, cue, categories, volume, busSends, layers[{file, sampleRate, samples,
|
|
8
|
+
loopStart, loopEnd, loopFlag, volume, busSends}], lengthMs}; the cue structure (sequence -> tracks ->
|
|
9
|
+
synth -> waveform) is read from the ACB (nnnotes.acb; command 65 categories, 146 volume, 111 bus send)
|
|
10
|
+
categories CRI category volumes for a fresh profile (SoundVolumeSettings x AppConfigDefaultData)
|
|
11
|
+
react the ACF's REACT (ducking) entries between live categories
|
|
12
|
+
music the BGM sound id and PlayMusic volume
|
|
13
|
+
noteSe LiveNoteSeType -> sound id / volume / mute for the default preset (LiveSettingCreator.CreateSESettings);
|
|
14
|
+
with liveoptions.LiveOptions offering note sound sets also `groups`: set id -> LiveNoteSeType ->
|
|
15
|
+
sound id (MasterLiveNoteSe), every offered set's sounds decoded as well
|
|
16
|
+
liveSe LiveSeType -> sound id (MasterLiveSe); `sounds` has those of LIVE_SE_SOUNDS: the start and finish
|
|
17
|
+
cheers and the finish direction of every result (the one a live ends with depends on how it is played)
|
|
18
|
+
timeline intro / end timings the runtime needs
|
|
19
|
+
voice the start / finish character voice rule and the voice character the caller chose (none by default)
|
|
20
|
+
|
|
21
|
+
Cue sheets come through `cri` (any of its layouts; the note SE / live SE / voice sheets are embedded ACBs);
|
|
22
|
+
each cue layer maps to its vgmstream stream by `streams.json` (memory AWB order). A sheet already decoded into
|
|
23
|
+
`<out>/audio/<sheet>/` (the BGM by score.py) is reused.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import struct
|
|
28
|
+
import zipfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from .acb import commands as _commands, tables as _tables, u16s as _u16s
|
|
32
|
+
from .catalog import Catalog
|
|
33
|
+
from .jsonio import write_json
|
|
34
|
+
from .liveoptions import LiveOptions
|
|
35
|
+
from .player import PlayerData
|
|
36
|
+
from .score import master_table
|
|
37
|
+
from . import cri
|
|
38
|
+
|
|
39
|
+
ACF_IN_APK = "assets/Cri/Sound/Sirius.acf"
|
|
40
|
+
|
|
41
|
+
# App.Master.LiveNoteSeType / LiveSeType
|
|
42
|
+
NOTE_SE_TYPES = {1: "InVain", 2: "Good", 3: "Great", 4: "Perfect", 5: "Flick", 6: "FlickDirection", 7: "Slide",
|
|
43
|
+
8: "Just", 9: "Trace", 10: "SlideConnect", 11: "GekisouTap", 12: "GekisouFlick",
|
|
44
|
+
13: "GekisouFlickDirection", 14: "GekisouSlide"}
|
|
45
|
+
LIVE_SE_TYPES = {1: "GekisouCountDown", 2: "LuckHit", 3: "LuckSuperHit", 4: "LuckCritical", 5: "LuckMiss",
|
|
46
|
+
6: "LuckRushDouble", 7: "LuckRushTriple", 8: "LuckRushMax", 9: "StartCheers", 10: "FinishCheers",
|
|
47
|
+
11: "GekisouFirstCheers", 12: "GekisouOthersCheers", 13: "LiveClearDirection",
|
|
48
|
+
14: "FullComboDirection", 15: "AssistFullComboDirection", 16: "AllPerfectDirection"}
|
|
49
|
+
# SoundSettingsExtensions.ToOptionItemType (its constant table: note SE type 1..14 -> volume OptionItemType)
|
|
50
|
+
NOTE_SE_VOLUME_ITEM = [431, 433, 433, 433, 435, 437, 439, 433, 441, 439, 451, 453, 455, 457]
|
|
51
|
+
# OptionSoundUtility.GetMuteTypeForVolume: volume item -> mute item (OptionItemType 431..459 -> 460..470)
|
|
52
|
+
NOTE_SE_MUTE_ITEM = {431: 460, 433: 461, 435: 462, 437: 463, 439: 464, 441: 465, 451: 466, 453: 467, 455: 468,
|
|
53
|
+
457: 469, 459: 470}
|
|
54
|
+
# LiveSettingCreator.BuildIndividualNoteSeDictionary: the sound set item (OptionItemType) of each note SE type
|
|
55
|
+
NOTE_SE_ID_ITEM = {1: 430, 2: 432, 3: 432, 4: 432, 8: 432, 5: 434, 6: 436, 7: 438, 10: 438, 9: 440, 11: 450,
|
|
56
|
+
12: 452, 13: 454, 14: 456}
|
|
57
|
+
# live SE whose sounds a live directory carries: the start / finish cheers (9, 10) and the finish direction of every
|
|
58
|
+
# result (LivePlayingStateNodeBase.PlayFinishVoiceAndCheer: after the result's voice and the finish cheer, 16
|
|
59
|
+
# AllPerfectDirection, else 14 FullComboDirection, else 15 AssistFullComboDirection, else 13 LiveClearDirection; none
|
|
60
|
+
# when the life is below 1), in type order
|
|
61
|
+
LIVE_SE_SOUNDS = (9, 10, 13, 14, 15, 16)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _params(cmds, categories: dict[int, str], buses: list[str]) -> dict:
|
|
65
|
+
"""Parameters of one command list. 65 = category ids, 146 = volume, 111 = bus send (index into the ACB's
|
|
66
|
+
StringValueTable bus names, level / 10000). Other codes are kept as hex, uninterpreted."""
|
|
67
|
+
d = {"categories": [], "volume": 1.0, "busSends": {}, "other": {}}
|
|
68
|
+
for code, v in cmds:
|
|
69
|
+
if code == 65:
|
|
70
|
+
d["categories"] += [categories[struct.unpack_from(">I", v, i)[0]] for i in range(0, len(v), 4)]
|
|
71
|
+
elif code == 146:
|
|
72
|
+
d["volume"] *= struct.unpack(">f", v)[0]
|
|
73
|
+
elif code == 111:
|
|
74
|
+
bus, level = struct.unpack(">HH", v)
|
|
75
|
+
d["busSends"][buses[bus]] = level / 10000
|
|
76
|
+
elif code in (0, 2000):
|
|
77
|
+
pass
|
|
78
|
+
else:
|
|
79
|
+
d["other"][str(code)] = v.hex()
|
|
80
|
+
return d
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def acb_cues(acb: bytes) -> dict[str, dict]:
|
|
84
|
+
"""{cueName: cue data} of an ACB whose cues are sequences of tracks that note-on one synth each (the only shape
|
|
85
|
+
in the live sheets; anything else raises)."""
|
|
86
|
+
top, T = _tables(acb)
|
|
87
|
+
categories = {r["Id"]: r["Name"] for r in T.get("AcfReferenceTable", []) if r["Type"] == 3}
|
|
88
|
+
buses = [r["StringValue"] for r in T.get("StringValueTable", [])]
|
|
89
|
+
names = {r["CueIndex"]: r["CueName"] for r in T["CueNameTable"]}
|
|
90
|
+
|
|
91
|
+
def cmd(table, index):
|
|
92
|
+
return [] if index == 65535 else _commands(T[table][index]["Command"])
|
|
93
|
+
|
|
94
|
+
out = {}
|
|
95
|
+
for ci, c in enumerate(T["CueTable"]):
|
|
96
|
+
name = names[ci]
|
|
97
|
+
if c["ReferenceType"] != 3:
|
|
98
|
+
raise ValueError(f"{name}: cue reference type {c['ReferenceType']} (only sequences handled)")
|
|
99
|
+
seq = T["SequenceTable"][c["ReferenceIndex"]]
|
|
100
|
+
if seq["Type"] != 0:
|
|
101
|
+
raise ValueError(f"{name}: sequence type {seq['Type']} (only polyphonic handled)")
|
|
102
|
+
sp = _params(cmd("SeqCommandTable", seq["CommandIndex"]), categories, buses)
|
|
103
|
+
layers = []
|
|
104
|
+
for ti in _u16s(seq["TrackIndex"]):
|
|
105
|
+
tr = T["TrackTable"][ti]
|
|
106
|
+
tp = _params(cmd("TrackCommandTable", tr["CommandIndex"]), categories, buses)
|
|
107
|
+
ons = [v for code, v in cmd("TrackEventTable", tr["EventIndex"]) if code == 2000]
|
|
108
|
+
if len(ons) != 1:
|
|
109
|
+
raise ValueError(f"{name}: track {ti} has {len(ons)} note-ons")
|
|
110
|
+
kind, si = struct.unpack(">HH", ons[0][:4])
|
|
111
|
+
if kind != 2:
|
|
112
|
+
raise ValueError(f"{name}: track {ti} note-on kind {kind}")
|
|
113
|
+
syn = T["SynthTable"][si]
|
|
114
|
+
yp = _params(cmd("SynthCommandTable", syn["CommandIndex"]), categories, buses)
|
|
115
|
+
items = [struct.unpack_from(">HH", syn["ReferenceItems"], i) for i in range(0, len(syn["ReferenceItems"]), 4)]
|
|
116
|
+
if syn["Type"] != 0 or len(items) != 1 or items[0][0] != 1:
|
|
117
|
+
raise ValueError(f"{name}: synth {si} type {syn['Type']} items {items}")
|
|
118
|
+
w = T["WaveformTable"][items[0][1]]
|
|
119
|
+
if w["Streaming"] != 0:
|
|
120
|
+
raise ValueError(f"{name}: streamed waveform (memory AWB expected)")
|
|
121
|
+
layers.append({"awbId": w["MemoryAwbId"], "samples": w["NumSamples"], "loopFlag": w["LoopFlag"],
|
|
122
|
+
"volume": tp["volume"] * yp["volume"], "busSends": {**tp["busSends"], **yp["busSends"]},
|
|
123
|
+
"trackOther": tp["other"], "synthOther": yp["other"]})
|
|
124
|
+
out[name] = {"cueId": c["CueId"], "lengthMs": c["Length"], "categories": sp["categories"],
|
|
125
|
+
"volume": sp["volume"], "busSends": sp["busSends"], "sequenceOther": sp["other"],
|
|
126
|
+
"acbVolume": top.get("AcbVolume", 1.0), "layers": layers}
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def acf_info(apk: Path) -> dict:
|
|
131
|
+
"""Categories, buses and REACT entries of the game's ACF (`assets/Cri/Sound/Sirius.acf` in the APK)."""
|
|
132
|
+
with zipfile.ZipFile(apk) as z:
|
|
133
|
+
top, T = _tables(z.read(ACF_IN_APK))
|
|
134
|
+
cat_names = {r["Index"]: r["Name"] for r in T["CategoryNameTable"]}
|
|
135
|
+
cats = {}
|
|
136
|
+
for r in T["CategoryTable"]:
|
|
137
|
+
if r["CommandIndex"] != 65535:
|
|
138
|
+
raise ValueError(f"ACF category {cat_names[r['Id']]} has commands (default volume) - not handled")
|
|
139
|
+
cats[cat_names[r["Id"]]] = {"id": r["Id"], "group": r["GroupIndex"]}
|
|
140
|
+
bus_names = [r["StringValue"] for r in T["BusNameTable"]]
|
|
141
|
+
buses = [{"name": bus_names[b["BusNameIndex"]], "volume": b["Volume"], "fx": b["NumFxs"]} for b in T["BusTable"]]
|
|
142
|
+
react = [{"name": r["REACTName"], "src": cat_names[r["Src"]], "dest": cat_names[r["Dest"]], "level": r["Level"],
|
|
143
|
+
"decrementMs": r["DecrementTime"], "incrementMs": r["IncrementTime"], "holdType": r["HoldType"],
|
|
144
|
+
"holdMs": r["HoldTime"], "startCurve": [r["StartCurveType"], r["StartCurveStrength"]],
|
|
145
|
+
"endCurve": [r["EndCurveType"], r["EndCurveStrength"]], "parameterId": r["ParameterId"],
|
|
146
|
+
"targetType": r["TargetType"]} for r in T.get("ReactTable", [])]
|
|
147
|
+
return {"name": top["Name"], "categories": cats, "buses": buses, "react": react}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------- game settings
|
|
151
|
+
def _player_mono(player: PlayerData, cls: str) -> dict:
|
|
152
|
+
hits = []
|
|
153
|
+
for o in player.env.objects:
|
|
154
|
+
if o.type.name != "MonoBehaviour":
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
if player.script(o)[2] != cls:
|
|
158
|
+
continue
|
|
159
|
+
except Exception: # MonoBehaviours without a resolvable script are not ours
|
|
160
|
+
continue
|
|
161
|
+
hits.append(player.mono(o))
|
|
162
|
+
if len(hits) != 1:
|
|
163
|
+
raise RuntimeError(f"{cls}: {len(hits)} objects in the player data")
|
|
164
|
+
return hits[0]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def category_volumes(player: PlayerData) -> dict:
|
|
168
|
+
"""Fresh-profile CRI category volumes: `<Cat>` = SoundVolumeSettings.DefaultVolume x 1.0
|
|
169
|
+
(SoundManager.Boot ChangeVolume("All", 1)); `Live<X>Config` = LocalCacheData live volume = AppConfigDefaultData
|
|
170
|
+
(AppConfig.UpdateLiveSoundVolumeConfig); Bgm/Se/VoiceConfig = system option 400-402 / 100 (preset 1: 1.0)."""
|
|
171
|
+
svs = {e["CategoryName"]: e["DefaultVolume"] for e in _player_mono(player, "SoundVolumeSettings")["_collection"]}
|
|
172
|
+
acd = _player_mono(player, "AppConfigDefaultData")["_defaultData"]
|
|
173
|
+
live = {"LiveBgm": "LiveBgmSoundVolume", "LiveSe": "LiveSeSoundVolume", "LiveVoice": "LiveVoiceSoundVolume",
|
|
174
|
+
"LiveNotesSe": "LiveNotesSeSoundVolume"}
|
|
175
|
+
out = dict(svs)
|
|
176
|
+
for cat, field in live.items():
|
|
177
|
+
out[cat + "Config"] = acd[f"<{field}>k__BackingField"]
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _option(rows: list[dict], preset: int, item: int) -> str:
|
|
182
|
+
hit = [r["_valueString"] for r in rows if r["_presetId"] == preset and r["_optionItemType"] == item]
|
|
183
|
+
if len(hit) != 1:
|
|
184
|
+
raise KeyError(f"MasterOptionDefault preset {preset} item {item}: {len(hit)} rows")
|
|
185
|
+
return hit[0]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def note_se_groups(master: Path) -> dict[int, dict[int, int]]:
|
|
189
|
+
"""MasterLiveNoteSe: sound set (_groupID) -> LiveNoteSeType -> sound id."""
|
|
190
|
+
out: dict[int, dict[int, int]] = {}
|
|
191
|
+
for r in master_table(master, "MasterLiveNoteSe"):
|
|
192
|
+
out.setdefault(r["_groupID"], {})[r["_liveNoteSeType"]] = r["_seId"]
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _volume(opt: list[dict], preset: int, item: int) -> float:
|
|
197
|
+
"""OptionSoundUtility.GetIndividualNoteSeVolume: 0 when the item's mute (GetMuteTypeForVolume) is on, else
|
|
198
|
+
clamp01(value / 100)."""
|
|
199
|
+
if _option(opt, preset, NOTE_SE_MUTE_ITEM[item]).upper() == "TRUE":
|
|
200
|
+
return 0.0
|
|
201
|
+
return min(max(int(_option(opt, preset, item)) / 100.0, 0.0), 1.0)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def note_se_settings(master: Path, preset: int = 1, groups: tuple = ()) -> dict:
|
|
205
|
+
"""LiveSettingCreator.CreateSESettings for a fresh profile (option preset `preset`): pattern mode takes the
|
|
206
|
+
MasterLiveNoteSe rows of the set NoteSePatternId (420); UseIndividualNoteSe (421) takes, per note SE type, the
|
|
207
|
+
row of the set its sound item names (BuildIndividualNoteSeDictionary; a type whose set has no row for it has no
|
|
208
|
+
entry). `groups`: sound set ids whose tables are written as `groups` as well (the sets a viewer may choose)."""
|
|
209
|
+
opt = master_table(master, "MasterOptionDefault")
|
|
210
|
+
table = note_se_groups(master)
|
|
211
|
+
group = int(_option(opt, preset, 420))
|
|
212
|
+
individual = _option(opt, preset, 421).upper() == "TRUE"
|
|
213
|
+
if individual:
|
|
214
|
+
types = {}
|
|
215
|
+
for t, item in NOTE_SE_ID_ITEM.items():
|
|
216
|
+
se = table.get(int(_option(opt, preset, item)), {}).get(t)
|
|
217
|
+
if se is not None:
|
|
218
|
+
types[t] = se
|
|
219
|
+
else:
|
|
220
|
+
types = dict(table.get(group, {}))
|
|
221
|
+
volumes, mutes = {}, {}
|
|
222
|
+
for t in range(1, 15):
|
|
223
|
+
item = NOTE_SE_VOLUME_ITEM[t - 1]
|
|
224
|
+
mute = _option(opt, preset, NOTE_SE_MUTE_ITEM[item]).upper() == "TRUE"
|
|
225
|
+
# GetIndividualNoteSeVolume (mute -> 0) / GetNoteSeMute (GetMuteTypeForVolume)
|
|
226
|
+
volumes[t], mutes[t] = _volume(opt, preset, item), mute
|
|
227
|
+
out = {"preset": preset, "patternId": group, "useIndividualNoteSe": individual,
|
|
228
|
+
"types": {str(k): types[k] for k in sorted(types)}, "typeNames": NOTE_SE_TYPES,
|
|
229
|
+
"volumes": {str(k): v for k, v in volumes.items()}, "mutes": {str(k): v for k, v in mutes.items()},
|
|
230
|
+
"gekisouTraceVolume": _volume(opt, preset, 459)}
|
|
231
|
+
if groups:
|
|
232
|
+
missing = [g for g in groups if g not in table]
|
|
233
|
+
if missing:
|
|
234
|
+
raise KeyError(f"MasterLiveNoteSe has no rows of sound set(s) {missing}")
|
|
235
|
+
out["groups"] = {str(g): {str(t): table[g][t] for t in sorted(table[g])} for g in groups}
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --------------------------------------------------------------------------- extraction
|
|
240
|
+
def _decode(cat: Catalog, sheet: str, out_dir: Path, fmt: str, flac_level: int) -> list[dict]:
|
|
241
|
+
"""Decode a cue sheet into <out>/audio/<sheet>/ (reusing an existing decode) and return its stream list."""
|
|
242
|
+
d = out_dir / "audio" / sheet
|
|
243
|
+
if not (d / "streams.json").exists():
|
|
244
|
+
cri.decode(cat, sheet, d, fmt=fmt, flac_level=flac_level)
|
|
245
|
+
import json
|
|
246
|
+
return json.loads((d / "streams.json").read_text(encoding="utf-8"))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _sound_entry(row: dict, sheet: str, cue: dict, streams: list[dict], cue_name: str) -> dict:
|
|
250
|
+
layers = []
|
|
251
|
+
for L in cue["layers"]:
|
|
252
|
+
s = streams[L["awbId"]] # vgmstream streams follow the memory AWB order
|
|
253
|
+
if s["name"] != cue_name or abs(s["samples"] - L["samples"]) > 8:
|
|
254
|
+
raise RuntimeError(f"{sheet}/{cue_name}: AWB {L['awbId']} -> stream {s} does not match")
|
|
255
|
+
layers.append({"file": f"audio/{sheet}/{s['file']}", "stream": s["stream"], "awbId": L["awbId"],
|
|
256
|
+
"sampleRate": s["sampleRate"], "channels": s["channels"], "samples": s["samples"],
|
|
257
|
+
"loopStart": s.get("loopStart"), "loopEnd": s.get("loopEnd"), "loopFlag": L["loopFlag"],
|
|
258
|
+
"volume": L["volume"], "busSends": L["busSends"],
|
|
259
|
+
"other": {"track": L["trackOther"], "synth": L["synthOther"]}})
|
|
260
|
+
return {"row": row, "sheet": sheet, "cue": cue_name, "category": row["_category"],
|
|
261
|
+
"categories": cue["categories"], "volume": cue["volume"] * cue["acbVolume"], "busSends": cue["busSends"],
|
|
262
|
+
"lengthMs": cue["lengthMs"], "other": cue["sequenceOther"], "layers": layers}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def extract(cat: Catalog, master: Path, player: PlayerData, music_id: int, out_dir: Path,
|
|
266
|
+
fmt: str = "flac", voice_character: int | None = None, options: LiveOptions = LiveOptions(), *,
|
|
267
|
+
flac_level: int = cri.FLAC_LEVEL) -> dict:
|
|
268
|
+
"""Decode the live's sound cue sheets and write <out>/audio/live-audio.json. Returns a summary.
|
|
269
|
+
voice_character: None = no character voice (the game picks the voice from the player's deck; there is no rule
|
|
270
|
+
without a deck). `options`: the note sound sets it offers are written as noteSe.groups, their sounds decoded
|
|
271
|
+
after the default ones. `flac_level`: ffmpeg's compression level of the FLAC files (cri.decode)."""
|
|
272
|
+
out_dir = Path(out_dir)
|
|
273
|
+
if cat.apk is None:
|
|
274
|
+
raise RuntimeError("liveaudio needs the Catalog opened with apk= (ACF, HCA key)")
|
|
275
|
+
sounds = {str(r["_id"]): r for r in master_table(master, "MasterSound")}
|
|
276
|
+
sheets = {r["_id"]: r["_cueSheetName"] for r in master_table(master, "MasterSoundCueSheet")}
|
|
277
|
+
music = [r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id]
|
|
278
|
+
if len(music) != 1:
|
|
279
|
+
raise KeyError(f"MasterLiveMusic {music_id}")
|
|
280
|
+
music_sound = music[0]["_musicSoundID"]
|
|
281
|
+
nse = note_se_settings(master, groups=options.se_patterns)
|
|
282
|
+
live_se = {r["_liveSeType"]: r["_seId"] for r in master_table(master, "MasterLiveSe")}
|
|
283
|
+
want = [music_sound] + sorted(set(nse["types"].values())) + [live_se[t] for t in LIVE_SE_SOUNDS]
|
|
284
|
+
voice = {"decision": "caller", "character": voice_character,
|
|
285
|
+
"rule": "LotteryStartVoice: random deck member, random MasterLiveStartCharacterVoice row of that "
|
|
286
|
+
"character; finish voice from LotteryFinishVoice; no deck -> undefined",
|
|
287
|
+
"startVoiceSoundIds": []}
|
|
288
|
+
if voice_character is not None:
|
|
289
|
+
rows = [r for r in master_table(master, "MasterLiveStartCharacterVoice")
|
|
290
|
+
if r["_characterId"] == voice_character]
|
|
291
|
+
voice["startVoiceSoundIds"] = [r["_voiceSoundId"] for r in rows]
|
|
292
|
+
want += voice["startVoiceSoundIds"]
|
|
293
|
+
for se in sorted({v for g in nse.get("groups", {}).values() for v in g.values()}):
|
|
294
|
+
if se not in want:
|
|
295
|
+
want.append(se)
|
|
296
|
+
|
|
297
|
+
by_sheet: dict[str, list[int]] = {}
|
|
298
|
+
for sid in want:
|
|
299
|
+
r = sounds[str(sid)]
|
|
300
|
+
by_sheet.setdefault(sheets[r["_soundCueSheetID"]], []).append(sid)
|
|
301
|
+
entries, decoded = {}, {}
|
|
302
|
+
for sheet, ids in by_sheet.items():
|
|
303
|
+
acb, layout = cri.acb_data(cat, sheet)
|
|
304
|
+
cues = acb_cues(acb["acb"])
|
|
305
|
+
streams = _decode(cat, sheet, out_dir, fmt, flac_level)
|
|
306
|
+
decoded[sheet] = {"layout": layout, "cues": len(cues), "streams": len(streams)}
|
|
307
|
+
for sid in ids:
|
|
308
|
+
r = sounds[str(sid)]
|
|
309
|
+
if r["_isRandomPitch"]:
|
|
310
|
+
raise NotImplementedError(f"sound {sid}: random pitch")
|
|
311
|
+
entries[str(sid)] = _sound_entry(r, sheet, cues[r["_cueName"]], streams, r["_cueName"])
|
|
312
|
+
|
|
313
|
+
acf = acf_info(cat.apk)
|
|
314
|
+
doc = {
|
|
315
|
+
"musicId": music_id,
|
|
316
|
+
"spec": "nnnotes.liveaudio",
|
|
317
|
+
"sounds": entries,
|
|
318
|
+
"categories": category_volumes(player),
|
|
319
|
+
"acf": {"name": acf["name"], "buses": acf["buses"], "categories": acf["categories"]},
|
|
320
|
+
"react": acf["react"],
|
|
321
|
+
"music": {"soundId": music_sound, "volume": 1.0, "startTimeSec": 0.0},
|
|
322
|
+
"noteSe": nse,
|
|
323
|
+
"liveSe": {str(k): v for k, v in sorted(live_se.items())}, "liveSeNames": LIVE_SE_TYPES,
|
|
324
|
+
"timeline": {"startCheerDelaySec": 1.0, "playVoiceSignalSec": 1.483333, "startCheerStopFadeSec": 1.0,
|
|
325
|
+
"finishCheerStopFadeSec": 1.0},
|
|
326
|
+
"voice": voice,
|
|
327
|
+
"decoded": decoded,
|
|
328
|
+
}
|
|
329
|
+
write_json(out_dir / "audio" / "live-audio.json", doc)
|
|
330
|
+
return {"index": "audio/live-audio.json", "sheets": sorted(by_sheet), "sounds": len(entries),
|
|
331
|
+
"musicSoundId": music_sound}
|