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/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}