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.
- dade/__init__.py +4 -0
- dade/__main__.py +6 -0
- dade/amplitude/__init__.py +6 -0
- dade/amplitude/__main__.py +6 -0
- dade/amplitude/main.py +91 -0
- dade/amplitude/unpacker.py +18 -0
- dade/bit192/__init__.py +21 -0
- dade/bit192/__main__.py +6 -0
- dade/bit192/audio.py +75 -0
- dade/bit192/commands/__init__.py +1 -0
- dade/bit192/commands/decrypt_cz.py +33 -0
- dade/bit192/commands/extract.py +42 -0
- dade/bit192/commands/save.py +145 -0
- dade/bit192/commands/utils.py +22 -0
- dade/bit192/cz.py +88 -0
- dade/bit192/extract.py +224 -0
- dade/bit192/main.py +26 -0
- dade/bit192/save.py +251 -0
- dade/bitrock/__init__.py +9 -0
- dade/bitrock/__main__.py +6 -0
- dade/bitrock/archive.py +146 -0
- dade/bitrock/commands/__init__.py +7 -0
- dade/bitrock/commands/crack.py +377 -0
- dade/bitrock/commands/extract.py +119 -0
- dade/bitrock/crypto.py +250 -0
- dade/bitrock/exceptions.py +33 -0
- dade/bitrock/io.py +6 -0
- dade/bitrock/password_cracker/__init__.py +7 -0
- dade/bitrock/password_cracker/crack.py +471 -0
- dade/bitrock/password_cracker/cuda.py +321 -0
- dade/bitrock/password_cracker/kernel_source.py +84 -0
- dade/bitrock/password_cracker/opencl.py +431 -0
- dade/bitrock/password_cracker/rules.py +145 -0
- dade/bitrock/password_cracker/typing.py +12 -0
- dade/bitrock/sansio.py +308 -0
- dade/bitrock/typing.py +45 -0
- dade/bitrock/unpack.py +149 -0
- dade/common/__init__.py +6 -0
- dade/common/apple_png.py +122 -0
- dade/common/audio.py +78 -0
- dade/common/bfcodec.py +433 -0
- dade/common/compress.py +43 -0
- dade/common/context.py +96 -0
- dade/common/cookfs.py +320 -0
- dade/common/cuebin.py +75 -0
- dade/common/disc.py +123 -0
- dade/common/exceptions.py +8 -0
- dade/common/fonts.py +69 -0
- dade/common/image.py +75 -0
- dade/common/io.py +407 -0
- dade/common/iso9660.py +249 -0
- dade/common/json.py +41 -0
- dade/common/lz.py +74 -0
- dade/common/obj.py +113 -0
- dade/common/png.py +59 -0
- dade/common/ppm.py +37 -0
- dade/common/ps2_icon.py +159 -0
- dade/common/registry.py +65 -0
- dade/common/smf.py +300 -0
- dade/common/ssq.py +365 -0
- dade/common/stepmania.py +205 -0
- dade/common/text.py +91 -0
- dade/common/tools.py +51 -0
- dade/common/twofish.py +481 -0
- dade/common/typing.py +37 -0
- dade/common/utils.py +73 -0
- dade/common/wav.py +94 -0
- dade/common/workers.py +18 -0
- dade/ddrsplus/__init__.py +37 -0
- dade/ddrsplus/bfcodec.py +170 -0
- dade/ddrsplus/commands/__init__.py +4 -0
- dade/ddrsplus/commands/extract_gen.py +77 -0
- dade/ddrsplus/commands/utils.py +25 -0
- dade/ddrsplus/extract.py +256 -0
- dade/ddrsplus/gap.py +212 -0
- dade/ddrsplus/gen.py +362 -0
- dade/ddrsplus/main.py +22 -0
- dade/ddrsplus/pvr.py +258 -0
- dade/frequency/__init__.py +6 -0
- dade/frequency/__main__.py +6 -0
- dade/frequency/main.py +91 -0
- dade/frequency/unpacker.py +18 -0
- dade/harmonix/__init__.py +1 -0
- dade/harmonix/ark.py +380 -0
- dade/harmonix/audio.py +455 -0
- dade/harmonix/bitmap.py +364 -0
- dade/harmonix/dataarray.py +111 -0
- dade/harmonix/mesh.py +308 -0
- dade/harmonix/midi.py +110 -0
- dade/harmonix/milo.py +187 -0
- dade/harmonix/movie.py +108 -0
- dade/harmonix/pipeline.py +359 -0
- dade/harmonix/rndobject.py +492 -0
- dade/harmonix/typing.py +304 -0
- dade/harmonix/unpacker.py +228 -0
- dade/harmonix/video.py +84 -0
- dade/harmonix/workers.py +191 -0
- dade/i76/__init__.py +8 -0
- dade/i76/__main__.py +6 -0
- dade/i76/bwd2.py +191 -0
- dade/i76/commands/__init__.py +1 -0
- dade/i76/commands/build_horizon.py +82 -0
- dade/i76/commands/decode_texture.py +67 -0
- dade/i76/commands/inspect_chunks.py +76 -0
- dade/i76/commands/pak_extract.py +33 -0
- dade/i76/commands/sdf2obj.py +45 -0
- dade/i76/commands/stage_i82.py +78 -0
- dade/i76/commands/stage_i82_objects.py +162 -0
- dade/i76/commands/unpack_i82sim.py +36 -0
- dade/i76/commands/utils.py +12 -0
- dade/i76/commands/zfs_extract.py +35 -0
- dade/i76/commands/zfs_list.py +56 -0
- dade/i76/geo.py +69 -0
- dade/i76/horizon.py +146 -0
- dade/i76/i82.py +150 -0
- dade/i76/i82_objects.py +183 -0
- dade/i76/lzo.py +269 -0
- dade/i76/main.py +34 -0
- dade/i76/pak.py +170 -0
- dade/i76/pe_unpack.py +228 -0
- dade/i76/sdf.py +216 -0
- dade/i76/textures.py +193 -0
- dade/i76/typing.py +140 -0
- dade/i76/zfs.py +199 -0
- dade/incoming/__init__.py +1 -0
- dade/incoming/__main__.py +6 -0
- dade/incoming/commands/__init__.py +1 -0
- dade/incoming/commands/extract_pvr_pack.py +38 -0
- dade/incoming/commands/ian2obj.py +62 -0
- dade/incoming/commands/utils.py +12 -0
- dade/incoming/converters/__init__.py +7 -0
- dade/incoming/converters/_base.py +18 -0
- dade/incoming/converters/audio.py +68 -0
- dade/incoming/converters/data.py +119 -0
- dade/incoming/converters/images.py +162 -0
- dade/incoming/converters/models.py +170 -0
- dade/incoming/converters/models_dc.py +232 -0
- dade/incoming/converters/registry.py +37 -0
- dade/incoming/converters/sound_dc.py +165 -0
- dade/incoming/converters/state.py +969 -0
- dade/incoming/converters/text.py +35 -0
- dade/incoming/dispatch.py +180 -0
- dade/incoming/main.py +109 -0
- dade/incoming/pvrpack.py +87 -0
- dade/incoming/py.typed +0 -0
- dade/incoming/sources.py +130 -0
- dade/incoming/test_utils.py +216 -0
- dade/incoming/textures.py +175 -0
- dade/incoming/tools.py +108 -0
- dade/incoming/typing.py +6 -0
- dade/jubeatplus/__init__.py +33 -0
- dade/jubeatplus/archives.py +190 -0
- dade/jubeatplus/audio.py +17 -0
- dade/jubeatplus/chart.py +162 -0
- dade/jubeatplus/cipher.py +169 -0
- dade/jubeatplus/commands/__init__.py +6 -0
- dade/jubeatplus/commands/unpack.py +80 -0
- dade/jubeatplus/images.py +60 -0
- dade/jubeatplus/main.py +22 -0
- dade/jubeatplus/pipeline.py +324 -0
- dade/jubeatplus/plists.py +89 -0
- dade/jubeatplus/typing.py +78 -0
- dade/main.py +101 -0
- dade/marmalade/__init__.py +30 -0
- dade/marmalade/__main__.py +6 -0
- dade/marmalade/commands/__init__.py +1 -0
- dade/marmalade/commands/extract_dz.py +47 -0
- dade/marmalade/commands/extract_group.py +48 -0
- dade/marmalade/commands/utils.py +22 -0
- dade/marmalade/convert.py +148 -0
- dade/marmalade/derbh.py +339 -0
- dade/marmalade/font.py +126 -0
- dade/marmalade/hashstring.py +40 -0
- dade/marmalade/main.py +24 -0
- dade/marmalade/material.py +67 -0
- dade/marmalade/model.py +136 -0
- dade/marmalade/resgroup.py +167 -0
- dade/marmalade/test_utils.py +243 -0
- dade/marmalade/texture.py +76 -0
- dade/marmalade/typing.py +39 -0
- dade/marmalade/viewer.py +381 -0
- dade/misc/__init__.py +34 -0
- dade/misc/certificate.py +443 -0
- dade/misc/commands/__init__.py +9 -0
- dade/misc/commands/coredata.py +50 -0
- dade/misc/commands/macho.py +48 -0
- dade/misc/commands/sc_info.py +80 -0
- dade/misc/commands/strings.py +38 -0
- dade/misc/commands/utils.py +30 -0
- dade/misc/coredata.py +882 -0
- dade/misc/macho.py +356 -0
- dade/misc/main.py +28 -0
- dade/misc/sc_info.py +2350 -0
- dade/misc/strings.py +92 -0
- dade/misc/typing.py +73 -0
- dade/monopoly08/__init__.py +2 -0
- dade/monopoly08/__main__.py +6 -0
- dade/monopoly08/audio.py +999 -0
- dade/monopoly08/bigfile.py +142 -0
- dade/monopoly08/detect.py +99 -0
- dade/monopoly08/images.py +1389 -0
- dade/monopoly08/main.py +38 -0
- dade/monopoly08/meshes.py +537 -0
- dade/monopoly08/namehash.py +87 -0
- dade/monopoly08/packs.py +281 -0
- dade/monopoly08/pipeline.py +153 -0
- dade/monopoly08/refpack.py +134 -0
- dade/monopoly08/structured.py +1078 -0
- dade/monopoly08/typing.py +18 -0
- dade/py.typed +0 -0
- dade/rbplus/__init__.py +31 -0
- dade/rbplus/archive.py +161 -0
- dade/rbplus/chart.py +265 -0
- dade/rbplus/cipher.py +126 -0
- dade/rbplus/commands/__init__.py +8 -0
- dade/rbplus/commands/dump_chart.py +182 -0
- dade/rbplus/commands/extract_assets.py +73 -0
- dade/rbplus/commands/unpack.py +108 -0
- dade/rbplus/main.py +26 -0
- dade/rbplus/package.py +359 -0
- dade/rbplus/pipeline.py +512 -0
- dade/rbplus/render.py +982 -0
- dade/rbplus/typing.py +154 -0
- dade/rhythmin/__init__.py +43 -0
- dade/rhythmin/aep.py +656 -0
- dade/rhythmin/bfcodec.py +94 -0
- dade/rhythmin/chara.py +89 -0
- dade/rhythmin/commands/__init__.py +10 -0
- dade/rhythmin/commands/dump_chara.py +41 -0
- dade/rhythmin/commands/dump_idx.py +143 -0
- dade/rhythmin/commands/dump_map.py +56 -0
- dade/rhythmin/commands/dump_sheet.py +176 -0
- dade/rhythmin/commands/extract_dialogue.py +60 -0
- dade/rhythmin/commands/utils.py +44 -0
- dade/rhythmin/dialogue.py +332 -0
- dade/rhythmin/main.py +30 -0
- dade/rhythmin/render.py +15 -0
- dade/rhythmin/sheet.py +1198 -0
- dade/rhythmin/treasure_map.py +562 -0
- dade/thps2pc/__init__.py +28 -0
- dade/thps2pc/__main__.py +6 -0
- dade/thps2pc/analysis.py +143 -0
- dade/thps2pc/commands/__init__.py +1 -0
- dade/thps2pc/commands/convert_scene.py +144 -0
- dade/thps2pc/commands/decode_textures.py +87 -0
- dade/thps2pc/commands/dump_descriptors.py +36 -0
- dade/thps2pc/commands/psx_info.py +27 -0
- dade/thps2pc/commands/render.py +295 -0
- dade/thps2pc/commands/unpack_pkr.py +128 -0
- dade/thps2pc/commands/utils.py +161 -0
- dade/thps2pc/imagemagick.py +136 -0
- dade/thps2pc/main.py +36 -0
- dade/thps2pc/mesh.py +195 -0
- dade/thps2pc/pkr.py +346 -0
- dade/thps2pc/psx.py +414 -0
- dade/thps2pc/raster.py +215 -0
- dade/thps2pc/render.py +350 -0
- dade/thps2pc/test_utils.py +302 -0
- dade/thps2pc/textures.py +250 -0
- dade/thps2pc/typing.py +56 -0
- dade/xg2/__init__.py +23 -0
- dade/xg2/__main__.py +6 -0
- dade/xg2/albank.py +205 -0
- dade/xg2/alcseq.py +242 -0
- dade/xg2/archive.py +231 -0
- dade/xg2/bmc.py +88 -0
- dade/xg2/displaylist.py +257 -0
- dade/xg2/extract_pc.py +186 -0
- dade/xg2/extract_xg1.py +406 -0
- dade/xg2/extract_xg2.py +308 -0
- dade/xg2/fluidsynth.py +80 -0
- dade/xg2/images.py +253 -0
- dade/xg2/lzhuf.py +61 -0
- dade/xg2/main.py +347 -0
- dade/xg2/mfs.py +144 -0
- dade/xg2/models.py +117 -0
- dade/xg2/montage.py +111 -0
- dade/xg2/offsets.py +160 -0
- dade/xg2/rom.py +313 -0
- dade/xg2/smf.py +53 -0
- dade/xg2/soundfont.py +381 -0
- dade/xg2/typing.py +165 -0
- dade/xg2/vadpcm.py +169 -0
- dade/xg2/wav.py +103 -0
- dade-0.0.1.dist-info/METADATA +441 -0
- dade-0.0.1.dist-info/RECORD +289 -0
- dade-0.0.1.dist-info/WHEEL +4 -0
- dade-0.0.1.dist-info/entry_points.txt +2 -0
- dade-0.0.1.dist-info/licenses/LICENSE.txt +18 -0
dade/__init__.py
ADDED
dade/__main__.py
ADDED
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'
|
dade/bit192/__init__.py
ADDED
|
@@ -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')
|
dade/bit192/__main__.py
ADDED
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
|