dade 0.0.1__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 (289) hide show
  1. dade/__init__.py +4 -0
  2. dade/__main__.py +6 -0
  3. dade/amplitude/__init__.py +6 -0
  4. dade/amplitude/__main__.py +6 -0
  5. dade/amplitude/main.py +91 -0
  6. dade/amplitude/unpacker.py +18 -0
  7. dade/bit192/__init__.py +21 -0
  8. dade/bit192/__main__.py +6 -0
  9. dade/bit192/audio.py +75 -0
  10. dade/bit192/commands/__init__.py +1 -0
  11. dade/bit192/commands/decrypt_cz.py +33 -0
  12. dade/bit192/commands/extract.py +42 -0
  13. dade/bit192/commands/save.py +145 -0
  14. dade/bit192/commands/utils.py +22 -0
  15. dade/bit192/cz.py +88 -0
  16. dade/bit192/extract.py +224 -0
  17. dade/bit192/main.py +26 -0
  18. dade/bit192/save.py +251 -0
  19. dade/bitrock/__init__.py +9 -0
  20. dade/bitrock/__main__.py +6 -0
  21. dade/bitrock/archive.py +146 -0
  22. dade/bitrock/commands/__init__.py +7 -0
  23. dade/bitrock/commands/crack.py +377 -0
  24. dade/bitrock/commands/extract.py +119 -0
  25. dade/bitrock/crypto.py +250 -0
  26. dade/bitrock/exceptions.py +33 -0
  27. dade/bitrock/io.py +6 -0
  28. dade/bitrock/password_cracker/__init__.py +7 -0
  29. dade/bitrock/password_cracker/crack.py +471 -0
  30. dade/bitrock/password_cracker/cuda.py +321 -0
  31. dade/bitrock/password_cracker/kernel_source.py +84 -0
  32. dade/bitrock/password_cracker/opencl.py +431 -0
  33. dade/bitrock/password_cracker/rules.py +145 -0
  34. dade/bitrock/password_cracker/typing.py +12 -0
  35. dade/bitrock/sansio.py +308 -0
  36. dade/bitrock/typing.py +45 -0
  37. dade/bitrock/unpack.py +149 -0
  38. dade/common/__init__.py +6 -0
  39. dade/common/apple_png.py +122 -0
  40. dade/common/audio.py +78 -0
  41. dade/common/bfcodec.py +433 -0
  42. dade/common/compress.py +43 -0
  43. dade/common/context.py +96 -0
  44. dade/common/cookfs.py +320 -0
  45. dade/common/cuebin.py +75 -0
  46. dade/common/disc.py +123 -0
  47. dade/common/exceptions.py +8 -0
  48. dade/common/fonts.py +69 -0
  49. dade/common/image.py +75 -0
  50. dade/common/io.py +407 -0
  51. dade/common/iso9660.py +249 -0
  52. dade/common/json.py +41 -0
  53. dade/common/lz.py +74 -0
  54. dade/common/obj.py +113 -0
  55. dade/common/png.py +59 -0
  56. dade/common/ppm.py +37 -0
  57. dade/common/ps2_icon.py +159 -0
  58. dade/common/registry.py +65 -0
  59. dade/common/smf.py +300 -0
  60. dade/common/ssq.py +365 -0
  61. dade/common/stepmania.py +205 -0
  62. dade/common/text.py +91 -0
  63. dade/common/tools.py +51 -0
  64. dade/common/twofish.py +481 -0
  65. dade/common/typing.py +37 -0
  66. dade/common/utils.py +73 -0
  67. dade/common/wav.py +94 -0
  68. dade/common/workers.py +18 -0
  69. dade/ddrsplus/__init__.py +37 -0
  70. dade/ddrsplus/bfcodec.py +170 -0
  71. dade/ddrsplus/commands/__init__.py +4 -0
  72. dade/ddrsplus/commands/extract_gen.py +77 -0
  73. dade/ddrsplus/commands/utils.py +25 -0
  74. dade/ddrsplus/extract.py +256 -0
  75. dade/ddrsplus/gap.py +212 -0
  76. dade/ddrsplus/gen.py +362 -0
  77. dade/ddrsplus/main.py +22 -0
  78. dade/ddrsplus/pvr.py +258 -0
  79. dade/frequency/__init__.py +6 -0
  80. dade/frequency/__main__.py +6 -0
  81. dade/frequency/main.py +91 -0
  82. dade/frequency/unpacker.py +18 -0
  83. dade/harmonix/__init__.py +1 -0
  84. dade/harmonix/ark.py +380 -0
  85. dade/harmonix/audio.py +455 -0
  86. dade/harmonix/bitmap.py +364 -0
  87. dade/harmonix/dataarray.py +111 -0
  88. dade/harmonix/mesh.py +308 -0
  89. dade/harmonix/midi.py +110 -0
  90. dade/harmonix/milo.py +187 -0
  91. dade/harmonix/movie.py +108 -0
  92. dade/harmonix/pipeline.py +359 -0
  93. dade/harmonix/rndobject.py +492 -0
  94. dade/harmonix/typing.py +304 -0
  95. dade/harmonix/unpacker.py +228 -0
  96. dade/harmonix/video.py +84 -0
  97. dade/harmonix/workers.py +191 -0
  98. dade/i76/__init__.py +8 -0
  99. dade/i76/__main__.py +6 -0
  100. dade/i76/bwd2.py +191 -0
  101. dade/i76/commands/__init__.py +1 -0
  102. dade/i76/commands/build_horizon.py +82 -0
  103. dade/i76/commands/decode_texture.py +67 -0
  104. dade/i76/commands/inspect_chunks.py +76 -0
  105. dade/i76/commands/pak_extract.py +33 -0
  106. dade/i76/commands/sdf2obj.py +45 -0
  107. dade/i76/commands/stage_i82.py +78 -0
  108. dade/i76/commands/stage_i82_objects.py +162 -0
  109. dade/i76/commands/unpack_i82sim.py +36 -0
  110. dade/i76/commands/utils.py +12 -0
  111. dade/i76/commands/zfs_extract.py +35 -0
  112. dade/i76/commands/zfs_list.py +56 -0
  113. dade/i76/geo.py +69 -0
  114. dade/i76/horizon.py +146 -0
  115. dade/i76/i82.py +150 -0
  116. dade/i76/i82_objects.py +183 -0
  117. dade/i76/lzo.py +269 -0
  118. dade/i76/main.py +34 -0
  119. dade/i76/pak.py +170 -0
  120. dade/i76/pe_unpack.py +228 -0
  121. dade/i76/sdf.py +216 -0
  122. dade/i76/textures.py +193 -0
  123. dade/i76/typing.py +140 -0
  124. dade/i76/zfs.py +199 -0
  125. dade/incoming/__init__.py +1 -0
  126. dade/incoming/__main__.py +6 -0
  127. dade/incoming/commands/__init__.py +1 -0
  128. dade/incoming/commands/extract_pvr_pack.py +38 -0
  129. dade/incoming/commands/ian2obj.py +62 -0
  130. dade/incoming/commands/utils.py +12 -0
  131. dade/incoming/converters/__init__.py +7 -0
  132. dade/incoming/converters/_base.py +18 -0
  133. dade/incoming/converters/audio.py +68 -0
  134. dade/incoming/converters/data.py +119 -0
  135. dade/incoming/converters/images.py +162 -0
  136. dade/incoming/converters/models.py +170 -0
  137. dade/incoming/converters/models_dc.py +232 -0
  138. dade/incoming/converters/registry.py +37 -0
  139. dade/incoming/converters/sound_dc.py +165 -0
  140. dade/incoming/converters/state.py +969 -0
  141. dade/incoming/converters/text.py +35 -0
  142. dade/incoming/dispatch.py +180 -0
  143. dade/incoming/main.py +109 -0
  144. dade/incoming/pvrpack.py +87 -0
  145. dade/incoming/py.typed +0 -0
  146. dade/incoming/sources.py +130 -0
  147. dade/incoming/test_utils.py +216 -0
  148. dade/incoming/textures.py +175 -0
  149. dade/incoming/tools.py +108 -0
  150. dade/incoming/typing.py +6 -0
  151. dade/jubeatplus/__init__.py +33 -0
  152. dade/jubeatplus/archives.py +190 -0
  153. dade/jubeatplus/audio.py +17 -0
  154. dade/jubeatplus/chart.py +162 -0
  155. dade/jubeatplus/cipher.py +169 -0
  156. dade/jubeatplus/commands/__init__.py +6 -0
  157. dade/jubeatplus/commands/unpack.py +80 -0
  158. dade/jubeatplus/images.py +60 -0
  159. dade/jubeatplus/main.py +22 -0
  160. dade/jubeatplus/pipeline.py +324 -0
  161. dade/jubeatplus/plists.py +89 -0
  162. dade/jubeatplus/typing.py +78 -0
  163. dade/main.py +101 -0
  164. dade/marmalade/__init__.py +30 -0
  165. dade/marmalade/__main__.py +6 -0
  166. dade/marmalade/commands/__init__.py +1 -0
  167. dade/marmalade/commands/extract_dz.py +47 -0
  168. dade/marmalade/commands/extract_group.py +48 -0
  169. dade/marmalade/commands/utils.py +22 -0
  170. dade/marmalade/convert.py +148 -0
  171. dade/marmalade/derbh.py +339 -0
  172. dade/marmalade/font.py +126 -0
  173. dade/marmalade/hashstring.py +40 -0
  174. dade/marmalade/main.py +24 -0
  175. dade/marmalade/material.py +67 -0
  176. dade/marmalade/model.py +136 -0
  177. dade/marmalade/resgroup.py +167 -0
  178. dade/marmalade/test_utils.py +243 -0
  179. dade/marmalade/texture.py +76 -0
  180. dade/marmalade/typing.py +39 -0
  181. dade/marmalade/viewer.py +381 -0
  182. dade/misc/__init__.py +34 -0
  183. dade/misc/certificate.py +443 -0
  184. dade/misc/commands/__init__.py +9 -0
  185. dade/misc/commands/coredata.py +50 -0
  186. dade/misc/commands/macho.py +48 -0
  187. dade/misc/commands/sc_info.py +80 -0
  188. dade/misc/commands/strings.py +38 -0
  189. dade/misc/commands/utils.py +30 -0
  190. dade/misc/coredata.py +882 -0
  191. dade/misc/macho.py +356 -0
  192. dade/misc/main.py +28 -0
  193. dade/misc/sc_info.py +2350 -0
  194. dade/misc/strings.py +92 -0
  195. dade/misc/typing.py +73 -0
  196. dade/monopoly08/__init__.py +2 -0
  197. dade/monopoly08/__main__.py +6 -0
  198. dade/monopoly08/audio.py +999 -0
  199. dade/monopoly08/bigfile.py +142 -0
  200. dade/monopoly08/detect.py +99 -0
  201. dade/monopoly08/images.py +1389 -0
  202. dade/monopoly08/main.py +38 -0
  203. dade/monopoly08/meshes.py +537 -0
  204. dade/monopoly08/namehash.py +87 -0
  205. dade/monopoly08/packs.py +281 -0
  206. dade/monopoly08/pipeline.py +153 -0
  207. dade/monopoly08/refpack.py +134 -0
  208. dade/monopoly08/structured.py +1078 -0
  209. dade/monopoly08/typing.py +18 -0
  210. dade/py.typed +0 -0
  211. dade/rbplus/__init__.py +31 -0
  212. dade/rbplus/archive.py +161 -0
  213. dade/rbplus/chart.py +265 -0
  214. dade/rbplus/cipher.py +126 -0
  215. dade/rbplus/commands/__init__.py +8 -0
  216. dade/rbplus/commands/dump_chart.py +182 -0
  217. dade/rbplus/commands/extract_assets.py +73 -0
  218. dade/rbplus/commands/unpack.py +108 -0
  219. dade/rbplus/main.py +26 -0
  220. dade/rbplus/package.py +359 -0
  221. dade/rbplus/pipeline.py +512 -0
  222. dade/rbplus/render.py +982 -0
  223. dade/rbplus/typing.py +154 -0
  224. dade/rhythmin/__init__.py +43 -0
  225. dade/rhythmin/aep.py +656 -0
  226. dade/rhythmin/bfcodec.py +94 -0
  227. dade/rhythmin/chara.py +89 -0
  228. dade/rhythmin/commands/__init__.py +10 -0
  229. dade/rhythmin/commands/dump_chara.py +41 -0
  230. dade/rhythmin/commands/dump_idx.py +143 -0
  231. dade/rhythmin/commands/dump_map.py +56 -0
  232. dade/rhythmin/commands/dump_sheet.py +176 -0
  233. dade/rhythmin/commands/extract_dialogue.py +60 -0
  234. dade/rhythmin/commands/utils.py +44 -0
  235. dade/rhythmin/dialogue.py +332 -0
  236. dade/rhythmin/main.py +30 -0
  237. dade/rhythmin/render.py +15 -0
  238. dade/rhythmin/sheet.py +1198 -0
  239. dade/rhythmin/treasure_map.py +562 -0
  240. dade/thps2pc/__init__.py +28 -0
  241. dade/thps2pc/__main__.py +6 -0
  242. dade/thps2pc/analysis.py +143 -0
  243. dade/thps2pc/commands/__init__.py +1 -0
  244. dade/thps2pc/commands/convert_scene.py +144 -0
  245. dade/thps2pc/commands/decode_textures.py +87 -0
  246. dade/thps2pc/commands/dump_descriptors.py +36 -0
  247. dade/thps2pc/commands/psx_info.py +27 -0
  248. dade/thps2pc/commands/render.py +295 -0
  249. dade/thps2pc/commands/unpack_pkr.py +128 -0
  250. dade/thps2pc/commands/utils.py +161 -0
  251. dade/thps2pc/imagemagick.py +136 -0
  252. dade/thps2pc/main.py +36 -0
  253. dade/thps2pc/mesh.py +195 -0
  254. dade/thps2pc/pkr.py +346 -0
  255. dade/thps2pc/psx.py +414 -0
  256. dade/thps2pc/raster.py +215 -0
  257. dade/thps2pc/render.py +350 -0
  258. dade/thps2pc/test_utils.py +302 -0
  259. dade/thps2pc/textures.py +250 -0
  260. dade/thps2pc/typing.py +56 -0
  261. dade/xg2/__init__.py +23 -0
  262. dade/xg2/__main__.py +6 -0
  263. dade/xg2/albank.py +205 -0
  264. dade/xg2/alcseq.py +242 -0
  265. dade/xg2/archive.py +231 -0
  266. dade/xg2/bmc.py +88 -0
  267. dade/xg2/displaylist.py +257 -0
  268. dade/xg2/extract_pc.py +186 -0
  269. dade/xg2/extract_xg1.py +406 -0
  270. dade/xg2/extract_xg2.py +308 -0
  271. dade/xg2/fluidsynth.py +80 -0
  272. dade/xg2/images.py +253 -0
  273. dade/xg2/lzhuf.py +61 -0
  274. dade/xg2/main.py +347 -0
  275. dade/xg2/mfs.py +144 -0
  276. dade/xg2/models.py +117 -0
  277. dade/xg2/montage.py +111 -0
  278. dade/xg2/offsets.py +160 -0
  279. dade/xg2/rom.py +313 -0
  280. dade/xg2/smf.py +53 -0
  281. dade/xg2/soundfont.py +381 -0
  282. dade/xg2/typing.py +165 -0
  283. dade/xg2/vadpcm.py +169 -0
  284. dade/xg2/wav.py +103 -0
  285. dade-0.0.1.dist-info/METADATA +441 -0
  286. dade-0.0.1.dist-info/RECORD +289 -0
  287. dade-0.0.1.dist-info/WHEEL +4 -0
  288. dade-0.0.1.dist-info/entry_points.txt +2 -0
  289. dade-0.0.1.dist-info/licenses/LICENSE.txt +18 -0
dade/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Extract and convert assets from a collection of PC and console video games."""
2
+ from __future__ import annotations
3
+
4
+ __version__ = '0.0.1'
dade/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m dade`` invocation."""
2
+ from __future__ import annotations
3
+
4
+ from dade.main import main
5
+
6
+ main()
@@ -0,0 +1,6 @@
1
+ """Game unpacker and asset converter for PS2 Amplitude and FreQuency (Harmonix)."""
2
+ from __future__ import annotations
3
+
4
+ from .unpacker import AmplitudeUnpacker
5
+
6
+ __all__ = ('AmplitudeUnpacker',)
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m dade.amplitude`` invocation."""
2
+ from __future__ import annotations
3
+
4
+ from dade.amplitude.main import main
5
+
6
+ main()
dade/amplitude/main.py ADDED
@@ -0,0 +1,91 @@
1
+ """Command-line entry point for the Amplitude game unpacker."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+ import asyncio
7
+
8
+ from rich.console import Console
9
+ import bascom
10
+ import click
11
+
12
+ from dade.amplitude.unpacker import AmplitudeUnpacker
13
+ from dade.common.exceptions import InvalidFormatError
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
18
+ __all__ = ('main',)
19
+
20
+ console = Console()
21
+ """The shared Rich :py:class:`~rich.console.Console` used for the progress spinner.
22
+
23
+ :meta hide-value:
24
+ """
25
+
26
+ debug_option = bascom.debug_option({'dade.amplitude': {}, 'dade.common': {}, 'dade.harmonix': {}})
27
+ """Attach ``-d/--debug`` to a command and route it through :py:func:`bascom.setup_logging`.
28
+
29
+ :meta hide-value:
30
+ """
31
+
32
+
33
+ @click.command(context_settings={'help_option_names': ('-h', '--help')})
34
+ @click.argument('input_', metavar='DISC', type=click.Path(exists=True, path_type=Path))
35
+ @debug_option
36
+ @click.option('-o',
37
+ '--output-dir',
38
+ type=click.Path(file_okay=False, path_type=Path),
39
+ default=Path(),
40
+ help='Output directory (created if missing).')
41
+ @click.option('-j',
42
+ '--jobs',
43
+ type=click.IntRange(min=0),
44
+ default=0,
45
+ help='Number of concurrent workers; 0 uses all CPUs.')
46
+ @click.option('--no-convert', is_flag=True, help='Extract assets without converting them.')
47
+ @click.option('--no-gunzip',
48
+ is_flag=True,
49
+ help='Extract .gz entries verbatim instead of decompressing.')
50
+ @click.option('--keep-gz',
51
+ is_flag=True,
52
+ help='Keep the original .gz entry alongside the decompressed output.')
53
+ @click.option('--ignore-failures',
54
+ is_flag=True,
55
+ help='Log and skip a conversion failure instead of stopping.')
56
+ @click.option('--delete',
57
+ is_flag=True,
58
+ help='Delete converted intermediate files (the source is never touched).')
59
+ def main(input_: Path,
60
+ *,
61
+ output_dir: Path,
62
+ jobs: int = 0,
63
+ no_convert: bool = False,
64
+ no_gunzip: bool = False,
65
+ keep_gz: bool = False,
66
+ ignore_failures: bool = False,
67
+ delete: bool = False) -> None:
68
+ """Unpack a PS2 Amplitude disc and convert its assets.""" # noqa: DOC501
69
+ unpacker = AmplitudeUnpacker(input_)
70
+
71
+ async def run(on_status: Callable[[str], None] | None) -> dict[str, str]:
72
+ return await unpacker.unpack(output_dir,
73
+ convert=not no_convert,
74
+ delete=delete,
75
+ gunzip=not no_gunzip,
76
+ ignore_failures=ignore_failures,
77
+ jobs=jobs,
78
+ keep_gz=keep_gz,
79
+ on_status=on_status)
80
+
81
+ # With ``--debug`` the streaming debug log owns the terminal, so no live spinner is started.
82
+ try:
83
+ if click.get_current_context().params.get('debug', False):
84
+ stats = asyncio.run(run(None))
85
+ else:
86
+ with console.status('Unpacking...') as status:
87
+ stats = asyncio.run(run(status.update))
88
+ except (InvalidFormatError, ValueError) as e:
89
+ raise click.ClickException(str(e)) from e
90
+ for label, summary in stats.items():
91
+ click.echo(f'{label}: {summary}')
@@ -0,0 +1,18 @@
1
+ """Developer-facing unpacker for the PS2 game Amplitude (Harmonix)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, ClassVar
5
+
6
+ from dade.harmonix.unpacker import Unpacker
7
+
8
+ if TYPE_CHECKING:
9
+ from dade.harmonix.typing import ArkLayout
10
+
11
+ __all__ = ('AmplitudeUnpacker',)
12
+
13
+
14
+ class AmplitudeUnpacker(Unpacker):
15
+ """Unpacker for the PS2 game Amplitude (its magic-less ``GEN/MAIN.ARK`` layout)."""
16
+
17
+ ark_layout: ClassVar[ArkLayout] = 'amplitude'
18
+ game_name: ClassVar[str] = 'Amplitude'
@@ -0,0 +1,21 @@
1
+ """
2
+ Tone Sphere (bit192labs) toolkit.
3
+
4
+ ``bit192`` holds the game-specific pieces that sit on top of the generic Marmalade support in
5
+ :mod:`marmalade`:
6
+
7
+ - :func:`~dade.bit192.cz.decrypt` - undo Tone Sphere's ``.cz`` XOR layer to recover a Derbh
8
+ archive.
9
+ - :func:`~dade.bit192.extract.extract` - unpack and decode every asset from an
10
+ ``.xapk``/``.apk``/``.obb``.
11
+ - :func:`dade.bit192.audio.wrap_wav` - wrap Tone Sphere's headerless 16-bit PCM as WAV.
12
+ - :class:`dade.bit192.save.SaveFile` - read and edit ``save.bin`` (e.g. unlock DLC).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .audio import wrap_wav
17
+ from .cz import decrypt
18
+ from .extract import extract
19
+ from .save import SaveFile
20
+
21
+ __all__ = ('SaveFile', 'decrypt', 'extract', 'wrap_wav')
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m bit192`` invocation."""
2
+ from __future__ import annotations
3
+
4
+ from dade.bit192.main import main
5
+
6
+ main()
dade/bit192/audio.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ Raw PCM helper for Tone Sphere.
3
+
4
+ Tone Sphere ships some sounds as headerless little-endian 16-bit PCM (``.raw``) played through
5
+ Marmalade's ``s3eSound`` at 44100 Hz stereo (the rate/channel count are this game's choice, not a
6
+ Marmalade constant). This module wraps such data in a canonical WAV container so it plays anywhere.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from dade.common.wav import wrap_pcm
13
+
14
+ __all__ = ('DEFAULT_CHANNELS', 'DEFAULT_RATE', 'wrap_raw_file', 'wrap_wav')
15
+
16
+ DEFAULT_RATE = 44100
17
+ """Tone Sphere's ``s3eSound`` sample rate (Hz).
18
+
19
+ :meta hide-value:
20
+ """
21
+ DEFAULT_CHANNELS = 2
22
+ """Tone Sphere's channel count (stereo).
23
+
24
+ :meta hide-value:
25
+ """
26
+ _BITS = 16
27
+
28
+
29
+ def wrap_wav(pcm: bytes, rate: int = DEFAULT_RATE, channels: int = DEFAULT_CHANNELS) -> bytes:
30
+ """
31
+ Wrap headerless 16-bit PCM in a WAV container.
32
+
33
+ Parameters
34
+ ----------
35
+ pcm : bytes
36
+ Little-endian signed 16-bit PCM samples (interleaved if stereo).
37
+ rate : int
38
+ Sample rate in Hz.
39
+ channels : int
40
+ Channel count (1 = mono, 2 = stereo).
41
+
42
+ Returns
43
+ -------
44
+ bytes
45
+ A complete RIFF/WAVE file.
46
+ """
47
+ block = channels * (_BITS // 8)
48
+ data = pcm[:len(pcm) - (len(pcm) % block)] if block else pcm
49
+ return wrap_pcm(data, rate=rate, channels=channels, bits=_BITS)
50
+
51
+
52
+ def wrap_raw_file(raw_path: str | Path,
53
+ rate: int = DEFAULT_RATE,
54
+ channels: int = DEFAULT_CHANNELS) -> Path:
55
+ """
56
+ Write a ``.wav`` next to a headerless ``.raw`` file.
57
+
58
+ Parameters
59
+ ----------
60
+ raw_path : str or pathlib.Path
61
+ Path to the ``.raw`` PCM file.
62
+ rate : int
63
+ Sample rate in Hz.
64
+ channels : int
65
+ Channel count.
66
+
67
+ Returns
68
+ -------
69
+ pathlib.Path
70
+ The written ``.wav`` path.
71
+ """
72
+ src = Path(raw_path)
73
+ dst = src.with_suffix('.wav')
74
+ dst.write_bytes(wrap_wav(src.read_bytes(), rate=rate, channels=channels))
75
+ return dst
@@ -0,0 +1 @@
1
+ """Command exports."""
@@ -0,0 +1,33 @@
1
+ """``tonesphere decrypt-cz`` - undo the ``.cz`` XOR layer to recover a Derbh archive."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import logging
6
+
7
+ import click
8
+
9
+ from dade.bit192 import cz
10
+
11
+ from .utils import console, debug_option
12
+
13
+ __all__ = ('decrypt_cz',)
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @click.command(name='decrypt-cz')
19
+ @click.argument('source', type=click.Path(exists=True, dir_okay=False, path_type=Path))
20
+ @click.argument('dest', type=click.Path(dir_okay=False, path_type=Path))
21
+ @debug_option
22
+ def decrypt_cz(source: Path, dest: Path) -> None:
23
+ """
24
+ Decrypt SOURCE (a ``.cz`` file) to DEST (a Derbh ``.dz`` archive).
25
+
26
+ Unpack DEST afterwards with ``marm extract-dz``.
27
+ """
28
+ log.debug('Decrypting %s to %s.', source, dest)
29
+ data = cz.decrypt(source.read_bytes())
30
+ if data[:4] != b'DTRZ':
31
+ console.print('[yellow]Warning:[/yellow] the result is not a Derbh archive (wrong keys?).')
32
+ dest.write_bytes(data)
33
+ console.print(f'[green]Decrypted {source.name} to {dest}.[/green]')
@@ -0,0 +1,42 @@
1
+ """``tonesphere extract`` - unpack and decode every Tone Sphere asset."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import logging
6
+
7
+ import click
8
+
9
+ from dade.bit192.extract import extract as extract_assets
10
+
11
+ from .utils import console, debug_option
12
+
13
+ __all__ = ('extract',)
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @click.command(name='extract')
19
+ @click.argument('inputs',
20
+ nargs=-1,
21
+ required=True,
22
+ type=click.Path(exists=True, dir_okay=False, path_type=Path))
23
+ @click.option('--keep-group-bin',
24
+ is_flag=True,
25
+ help='Keep raw .group.bin files alongside their decoded folders.')
26
+ @click.option('-o',
27
+ '--out',
28
+ required=True,
29
+ type=click.Path(file_okay=False, path_type=Path),
30
+ help='Output directory.')
31
+ @debug_option
32
+ def extract(inputs: tuple[Path, ...], out: Path, *, keep_group_bin: bool) -> None:
33
+ """
34
+ Extract and decode every asset from INPUTS into the output directory.
35
+
36
+ INPUTS is an ``.xapk``/``.apkm`` bundle, or an ``.apk``, optionally with its ``.obb``. Archives
37
+ are unpacked, ``.group.bin`` resources are decoded to open formats in place, and ``.raw`` PCM is
38
+ wrapped as ``.wav``.
39
+ """
40
+ log.debug('Extracting %d input(s) into %s.', len(inputs), out)
41
+ root = extract_assets(inputs, out, keep_group_bin=keep_group_bin)
42
+ console.print(f'[green]Extracted all assets to {root}.[/green]')
@@ -0,0 +1,145 @@
1
+ """``tonesphere save`` - inspect and edit ``save.bin``."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import logging
6
+
7
+ import click
8
+
9
+ from dade.bit192.save import DLC_OFFSETS, SaveFile
10
+
11
+ from .utils import console, debug_option
12
+
13
+ __all__ = ('save',)
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @click.group()
19
+ def save() -> None:
20
+ """Inspect and edit a Tone Sphere ``save.bin``."""
21
+
22
+
23
+ @save.command(name='device-id')
24
+ @click.argument('save_path', type=click.Path(exists=True, dir_okay=False, path_type=Path))
25
+ @debug_option
26
+ def device_id(save_path: Path) -> None:
27
+ """Print the device id cached in SAVE_PATH (DLC tokens are bound to it)."""
28
+ found = SaveFile.load(save_path).device_id
29
+ console.print(f'Device ID: {found}.' if found else 'No device id is cached; run the game once.')
30
+
31
+
32
+ @save.command(name='unlock-dlc')
33
+ @click.argument('save_path', type=click.Path(exists=True, dir_okay=False, path_type=Path))
34
+ @click.option('-o',
35
+ '--out',
36
+ type=click.Path(dir_okay=False, path_type=Path),
37
+ help='Write to this path instead of overwriting SAVE_PATH.')
38
+ @click.option('-p',
39
+ '--pack',
40
+ 'packs',
41
+ multiple=True,
42
+ type=click.Choice(sorted(DLC_OFFSETS)),
43
+ help='Unlock only this pack (repeatable). Default: all packs.')
44
+ @debug_option
45
+ def unlock_dlc(save_path: Path, out: Path | None, packs: tuple[str, ...]) -> None:
46
+ """
47
+ Write DLC ownership tokens into SAVE_PATH.
48
+
49
+ Tokens are ``MD5(device_id + pack)``, so the save must come from the target device (its device
50
+ id is read straight out of the file). The integrity hash is never verified on load, so no
51
+ checksum fix-up is needed for local use.
52
+ """
53
+ sf = SaveFile.load(save_path)
54
+ log.debug('Editing save with device id %r.', sf.device_id)
55
+ unlocked = [*packs] if packs else sf.unlock_all_dlc()
56
+ for name in packs:
57
+ sf.unlock_dlc(name)
58
+ dest = out or save_path
59
+ sf.save(dest)
60
+ console.print(f'[green]Unlocked {", ".join(unlocked)} and wrote {dest}.[/green]')
61
+
62
+
63
+ @save.command(name='unlock-songs')
64
+ @click.argument('save_path', type=click.Path(exists=True, dir_okay=False, path_type=Path))
65
+ @click.option('-o',
66
+ '--out',
67
+ type=click.Path(dir_okay=False, path_type=Path),
68
+ help='Write to this path instead of overwriting SAVE_PATH.')
69
+ @debug_option
70
+ def unlock_songs(save_path: Path, out: Path | None) -> None:
71
+ """
72
+ Unlock every regular (non-DLC) song in SAVE_PATH.
73
+
74
+ Sets the whole song unlock-flag array, so every ``UnlockNum``-gated song becomes visible without
75
+ meeting its ``CondChart``/``CondStar`` condition. DLC episodes need device-bound tokens instead;
76
+ use ``unlock-dlc`` (or ``unlock-all``) for those.
77
+ """
78
+ sf = SaveFile.load(save_path)
79
+ count = sf.unlock_all_songs()
80
+ dest = out or save_path
81
+ sf.save(dest)
82
+ console.print(f'[green]Set {count} song unlock flags and wrote {dest}.[/green]')
83
+
84
+
85
+ @save.command(name='unlock-all')
86
+ @click.argument('save_path', type=click.Path(exists=True, dir_okay=False, path_type=Path))
87
+ @click.option('-o',
88
+ '--out',
89
+ type=click.Path(dir_okay=False, path_type=Path),
90
+ help='Write to this path instead of overwriting SAVE_PATH.')
91
+ @debug_option
92
+ def unlock_all(save_path: Path, out: Path | None) -> None:
93
+ """
94
+ Unlock everything in SAVE_PATH: every regular song and every DLC pack.
95
+
96
+ Combines ``unlock-songs`` and ``unlock-dlc``. DLC tokens are device-bound, so the save must come
97
+ from the target device (its device id is read straight out of the file).
98
+ """
99
+ sf = SaveFile.load(save_path)
100
+ log.debug('Editing save with device id %r.', sf.device_id)
101
+ count = sf.unlock_all_songs()
102
+ packs = sf.unlock_all_dlc()
103
+ dest = out or save_path
104
+ sf.save(dest)
105
+ console.print(
106
+ f'[green]Set {count} song flags and unlocked {", ".join(packs)}; wrote {dest}.[/green]')
107
+
108
+
109
+ @save.command(name='generate')
110
+ @click.argument('out', type=click.Path(dir_okay=False, path_type=Path))
111
+ @click.option('-i',
112
+ '--device-id',
113
+ default='',
114
+ help='Device id for DLC tokens ("iOS" on iOS; the ANDROID_ID/SSAID on Android).')
115
+ @click.option('-f',
116
+ '--from-save',
117
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
118
+ help='Copy the device id from an existing save instead of passing --device-id.')
119
+ @click.option('--no-dlc', is_flag=True, help='Unlock songs only; do not write DLC tokens.')
120
+ @debug_option
121
+ def generate(out: Path, device_id: str, from_save: Path | None, *, no_dlc: bool) -> None:
122
+ """
123
+ Generate a fresh, fully-unlocked ``save.bin`` at OUT (from a zero-initialised save).
124
+
125
+ Every regular song is unlocked unconditionally. DLC tokens are device-bound
126
+ (``MD5(device_id + pack)``), so supply the target device's id via ``--device-id`` or copy it
127
+ from an existing save with ``--from-save``; on iOS the id is always ``"iOS"``. Songs do not
128
+ depend on the device id. The integrity hash is never verified on load, so the result loads
129
+ as-is.
130
+ """
131
+ resolved_id = SaveFile.load(from_save).device_id if from_save is not None else device_id
132
+ sf = SaveFile.blank()
133
+ sf.set_device_id(resolved_id)
134
+ log.debug('Generating save with device id %r.', resolved_id)
135
+ count = sf.unlock_all_songs()
136
+ if no_dlc:
137
+ sf.save(out)
138
+ console.print(f'[green]Generated {out} with {count} songs unlocked (DLC skipped).[/green]')
139
+ return
140
+ packs = sf.unlock_all_dlc()
141
+ sf.save(out)
142
+ console.print(f'[green]Generated {out}: {count} song flags + {len(packs)} DLC packs.[/green]')
143
+ if not resolved_id:
144
+ console.print('[yellow]Note:[/yellow] no device id was set, so the DLC tokens will not '
145
+ 'match a real device. Pass --device-id or --from-save to bind them.')
@@ -0,0 +1,22 @@
1
+ """Shared helpers for the ``tonesphere`` command-line tool."""
2
+ from __future__ import annotations
3
+
4
+ from rich.console import Console
5
+ import bascom
6
+
7
+ __all__ = ('console', 'debug_option')
8
+
9
+ console = Console()
10
+ """
11
+ Shared Rich :py:class:`~rich.console.Console` used by command-side output.
12
+
13
+ :meta hide-value:
14
+ """
15
+
16
+ debug_option = bascom.debug_option({'dade.bit192': {}, 'dade.common': {}, 'dade.marmalade': {}})
17
+ """Attach ``-d/--debug`` to a leaf command and route it through :py:func:`bascom.setup_logging`.
18
+
19
+ When set, both the ``bit192`` and ``marmalade`` loggers switch to ``DEBUG`` for the command.
20
+
21
+ :meta hide-value:
22
+ """
dade/bit192/cz.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ ``.cz`` decryption.
3
+
4
+ A ``.cz`` file is an ordinary Marmalade Derbh (DTRZ) archive wrapped in two layered repeating-key
5
+ XOR ciphers that bit192labs added on top of the SDK::
6
+
7
+ plain[i] = cz[i] ^ KEY1[i % len(KEY1)] ^ KEY2[i % len(KEY2)]
8
+
9
+ The two keys are hard-coded constants lifted from the game binary (``FUN_4a06bd68``; key tables at
10
+ ``0x4a18c3e0`` and ``0x4a18c3c8``). They are exposed here as :data:`KEY1` / :data:`KEY2` and used as
11
+ the defaults, but :func:`decrypt` accepts arbitrary keys so the same XOR scheme can be reused.
12
+
13
+ Decrypting a ``.cz`` yields the ``DTRZ`` bytes that :func:`marmalade.derbh.unpack` reads.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ __all__ = ('KEY1', 'KEY2', 'decrypt', 'looks_like_cz')
18
+
19
+ KEY1 = bytes((0xE9, 0xBD, 0xA8, 0x08, 0xE3, 0x9B, 0x59, 0x0C, 0xBC, 0xEF, 0xEF, 0x3C, 0xC2, 0x23,
20
+ 0xEA, 0x01, 0xFA, 0x59, 0x5B, 0xAA, 0x00, 0x00))
21
+ """First XOR key (22 bytes), from the Tone Sphere binary at ``0x4a18c3e0``.
22
+
23
+ :meta hide-value:
24
+ """
25
+ KEY2 = bytes((0xAC, 0x3E, 0x11, 0x19, 0x20, 0x00, 0x10, 0x6A, 0x6D, 0x24, 0x5B, 0x4F, 0x99, 0x14,
26
+ 0xA3, 0x4E, 0x00, 0x00))
27
+ """Second XOR key (18 bytes), from the Tone Sphere binary at ``0x4a18c3c8``.
28
+
29
+ :meta hide-value:
30
+ """
31
+
32
+ _DTRZ = b'DTRZ'
33
+
34
+
35
+ def decrypt(data: bytes, key1: bytes = KEY1, key2: bytes = KEY2) -> bytes:
36
+ """
37
+ Decrypt ``.cz`` bytes to the underlying ``DTRZ`` archive.
38
+
39
+ The cipher is its own inverse (XOR), so this also re-encrypts.
40
+
41
+ Parameters
42
+ ----------
43
+ data : bytes
44
+ Encrypted ``.cz`` contents.
45
+ key1 : bytes
46
+ First repeating XOR key. Defaults to :data:`KEY1`.
47
+ key2 : bytes
48
+ Second repeating XOR key. Defaults to :data:`KEY2`.
49
+
50
+ Returns
51
+ -------
52
+ bytes
53
+ Decrypted contents (a ``DTRZ`` archive for valid Tone Sphere input).
54
+
55
+ Raises
56
+ ------
57
+ ValueError
58
+ If either key is empty.
59
+ """
60
+ n1, n2 = len(key1), len(key2)
61
+ if not n1 or not n2:
62
+ msg = 'XOR keys must be non-empty.'
63
+ raise ValueError(msg)
64
+ out = bytearray(len(data))
65
+ for i, b in enumerate(data):
66
+ out[i] = b ^ key1[i % n1] ^ key2[i % n2]
67
+ return bytes(out)
68
+
69
+
70
+ def looks_like_cz(data: bytes, key1: bytes = KEY1, key2: bytes = KEY2) -> bool:
71
+ """
72
+ Return ``True`` if *data* decrypts to a ``DTRZ`` archive with the given keys.
73
+
74
+ Parameters
75
+ ----------
76
+ data : bytes
77
+ Candidate ``.cz`` contents.
78
+ key1 : bytes
79
+ First repeating XOR key. Defaults to :data:`KEY1`.
80
+ key2 : bytes
81
+ Second repeating XOR key. Defaults to :data:`KEY2`.
82
+
83
+ Returns
84
+ -------
85
+ bool
86
+ Whether the first four decrypted bytes are the ``DTRZ`` magic.
87
+ """
88
+ return decrypt(data[:4], key1, key2) == _DTRZ