mirror of
https://github.com/ihaveamac/custom-install.git
synced 2026-01-21 14:06:02 +00:00
Compare commits
202 Commits
af2bb12
...
f81734f293
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81734f293 | ||
|
|
c81601fec0 | ||
|
|
6c70ee5780 | ||
|
|
cf0fdcd9a1 | ||
|
|
9fba2ff88f | ||
|
|
4d8a6de163 | ||
|
|
69fc8bb39a | ||
|
|
a395c22aee | ||
|
|
baf7490de0 | ||
|
|
4b41703107 | ||
|
|
927ab5c669 | ||
|
|
d656b1793c | ||
|
|
50a7117aa9 | ||
|
|
a0234e9b53 | ||
|
|
ffcf536d58 | ||
|
|
17aebb3256 | ||
|
|
09dbf134f1 | ||
|
|
c61b2bf168 | ||
|
|
c276dc82bc | ||
|
|
e5725876e2 | ||
|
|
2d78e0bc32 | ||
|
|
9ab8236a78 | ||
|
|
1be4221186 | ||
|
|
6a770c40c0 | ||
|
|
da1a7393b0 | ||
|
|
6a5ca17a33 | ||
|
|
8f90387a80 | ||
|
|
83c6d07194 | ||
|
|
a1b3cb059e | ||
|
|
68f6bfbb2e | ||
|
|
d12684d8bf | ||
|
|
54ae8a504c | ||
|
|
d97e11e4ec | ||
|
|
c3448c388e | ||
|
|
4d7be0812e | ||
|
|
8c60eecec5 | ||
|
|
653569093d | ||
|
|
adccac9ee7 | ||
|
|
8629cbee8e | ||
|
|
740844e57a | ||
|
|
d847043045 | ||
|
|
217a508bf3 | ||
|
|
42ec2d760a | ||
|
|
938d8fd6aa | ||
|
|
ac0be9d61d | ||
|
|
d231e9c043 | ||
|
|
9c3c4ce5f9 | ||
|
|
6a324b9388 | ||
|
|
647e56cf05 | ||
|
|
643e4e4976 | ||
|
|
09ed0093df | ||
|
|
9b7346c919 | ||
|
|
38f5e2b0e6 | ||
|
|
f48e177604 | ||
|
|
4ca2c59b5a | ||
|
|
7a68b23365 | ||
|
|
1dec5175ea | ||
|
|
4d223ed931 | ||
|
|
46a0d985a7 | ||
|
|
37112682a0 | ||
|
|
9c777adf26 | ||
|
|
b3eae08f27 | ||
|
|
5f49493dfb | ||
|
|
fbc553f5c7 | ||
|
|
68d9026524 | ||
|
|
46ac9cd809 | ||
|
|
40a8d2d684 | ||
|
|
4ec5bce712 | ||
|
|
d27e181c40 | ||
|
|
8ed6ca54cc | ||
|
|
0dcaaedda7 | ||
|
|
f904049c06 | ||
|
|
7b121f5212 | ||
|
|
1b2b0d06db | ||
|
|
6623ffb439 | ||
|
|
4733997132 | ||
|
|
9fc509489f | ||
|
|
2636c5923c | ||
|
|
cfa46abea5 | ||
|
|
d91c567fc5 | ||
|
|
188be9b9d6 | ||
|
|
616f9031b2 | ||
|
|
b8bd9371dd | ||
|
|
b69dfb0a46 | ||
|
|
e0573809bb | ||
|
|
46ce6ab76c | ||
|
|
fcf47e0564 | ||
|
|
a529ecf760 | ||
|
|
793d923240 | ||
|
|
918111dedf | ||
|
|
47f22313b4 | ||
|
|
5d60715d94 | ||
|
|
aad1accca3 | ||
|
|
945b0a377b | ||
|
|
707b852db3 | ||
|
|
794eb8750f | ||
|
|
b34bba2543 | ||
|
|
40cfd955cc | ||
|
|
bbcfb6fef1 | ||
|
|
1e3e15c969 | ||
|
|
48f92579ce | ||
|
|
06f70e37dc | ||
|
|
44787ebc87 | ||
|
|
399bb97238 | ||
|
|
6da2ed3343 | ||
|
|
00202c473e | ||
|
|
c45c082bfb | ||
|
|
49fb0f832f | ||
|
|
a725d876de | ||
|
|
a26579ec69 | ||
|
|
4296bf3ea6 | ||
|
|
3006989fc6 | ||
|
|
19045d8b87 | ||
|
|
20a829904b | ||
|
|
0741e4b5eb | ||
|
|
bc7f20361c | ||
|
|
d176d1f0ee | ||
|
|
449ee90311 | ||
|
|
10c0d9a23a | ||
|
|
034458b3fc | ||
|
|
2dd6caf128 | ||
|
|
7ee6999725 | ||
|
|
609a0de18b | ||
|
|
8aa4fa4ddc | ||
|
|
bd9150ed66 | ||
|
|
cd86713d17 | ||
|
|
bea3c3c082 | ||
|
|
9fc04a490e | ||
|
|
7707a67048 | ||
|
|
88520570af | ||
|
|
53fd45790f | ||
|
|
56747d36eb | ||
|
|
4522c009c3 | ||
|
|
a58bfa4ae1 | ||
|
|
187e27fc95 | ||
|
|
ed7fc99ff1 | ||
|
|
3a5f554b58 | ||
|
|
ba5c5f19a7 | ||
|
|
c344ce3e7b | ||
|
|
13f706a0dc | ||
|
|
3c99c7a9d9 | ||
|
|
238b7400e0 | ||
|
|
2319819bfa | ||
|
|
cb52b38ea7 | ||
|
|
3dcee32145 | ||
|
|
647f21d32b | ||
|
|
58237a0ebe | ||
|
|
9f69a2195c | ||
|
|
91e0fa24ad | ||
|
|
b3365c47bd | ||
|
|
a515ca7e61 | ||
|
|
167a80ff11 | ||
|
|
272cc544cd | ||
|
|
443498d706 | ||
|
|
17404231d3 | ||
|
|
393fd03da1 | ||
|
|
26c21137ec | ||
|
|
9c1709922a | ||
|
|
43ae023000 | ||
|
|
e7c6ff7344 | ||
|
|
5c41f03784 | ||
|
|
4693935d87 | ||
|
|
11cbbcdf1e | ||
|
|
8f4b3d1134 | ||
|
|
d5a4cbd8f8 | ||
|
|
625f1f9db5 | ||
|
|
61b27f33ed | ||
|
|
13d0cbd796 | ||
|
|
14e5692cac | ||
|
|
b799e3af1a | ||
|
|
e8787a2d9a | ||
|
|
a08654160a | ||
|
|
6922c0d209 | ||
|
|
29e17bec6d | ||
|
|
8ee248c793 | ||
|
|
12d59cad5d | ||
|
|
ff196a667d | ||
|
|
f21b63f9dd | ||
|
|
c0e1d45054 | ||
|
|
fe7d62fbda | ||
|
|
0195ea75d4 | ||
|
|
100411266d | ||
|
|
08359130e2 | ||
|
|
2f0fc5d177 | ||
|
|
9955699045 | ||
|
|
e6bed5b77b | ||
|
|
39fb56ac79 | ||
|
|
48e1636588 | ||
|
|
8d3e09cc29 | ||
|
|
e28b227ee6 | ||
|
|
e4123e6590 | ||
|
|
106666b897 | ||
|
|
aa1c9219c6 | ||
|
|
9798fcb767 | ||
|
|
0421a7dedb | ||
|
|
74522dabad | ||
|
|
0fb0d65c68 | ||
|
|
78a3529770 | ||
|
|
00c0e81c26 | ||
|
|
59470c6b37 | ||
|
|
cd4cb6e6e5 | ||
|
|
86c4565295 |
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
.vscode/
|
||||
bin/linux/save3ds_fuse
|
||||
cstins/
|
||||
testing-class.py
|
||||
*.local
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
._*
|
||||
|
||||
# Python
|
||||
venv/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
*.whl
|
||||
|
||||
# JetBrains
|
||||
.idea/
|
||||
=======
|
||||
|
||||
/build/
|
||||
/dist/
|
||||
/custom-install-finalize.3dsx
|
||||
|
||||
result
|
||||
result-*
|
||||
18
.pylintrc
Normal file
18
.pylintrc
Normal file
@@ -0,0 +1,18 @@
|
||||
[TYPECHECK]
|
||||
ignored-classes=Events
|
||||
|
||||
[MASTER]
|
||||
disable=missing-docstring,
|
||||
invalid-name,
|
||||
line-too-long,
|
||||
bad-continuation,
|
||||
consider-using-enumerate,
|
||||
trailing-whitespace,
|
||||
wrong-import-order,
|
||||
subprocess-run-check,
|
||||
singleton-comparison,
|
||||
attribute-defined-outside-init,
|
||||
fixme,
|
||||
redefined-outer-name,
|
||||
multiple-statements,
|
||||
bare-except
|
||||
9
CONTRIBUTING.md
Normal file
9
CONTRIBUTING.md
Normal file
@@ -0,0 +1,9 @@
|
||||
## This is my personal project
|
||||
|
||||
I make this project in my free time whenever I feel like it. I make no promises about reading issues or pull requests on a timely basis, or that I will fix certain issues or merge pull requests (soon or ever).
|
||||
|
||||
If you are making a significant addition and you intend for it to be implemented in my repository, you should talk to me first, because putting it in my repo means I have to maintain it. Please keep in mind the above paragraph. Maybe keep your own fork if you need something.
|
||||
|
||||
## No AI-generated content
|
||||
|
||||
This project, like all my projects, employs a strict zero-tolarance policy against any content generated by artificial intelligence, for any reason. Do not use it for issues, pull requests, comments, or anything else. Any content found to be the result of generative AI will be deleted, and the user likely blocked.
|
||||
4
MANIFEST.in
Normal file
4
MANIFEST.in
Normal file
@@ -0,0 +1,4 @@
|
||||
recursive-include custominstall/bin/*
|
||||
include custominstall/title.db.gz
|
||||
include custominstall/TaskbarLib.tlb
|
||||
include custominstall/custom-install-finalize.3dsx
|
||||
73
README.md
73
README.md
@@ -1,15 +1,40 @@
|
||||
[]() 
|
||||
|
||||
# custom-install
|
||||
Experimental script to automate the process of a manual title install for Nintendo 3DS. Originally created late June 2019.
|
||||
Installs a title directly to an SD card for the Nintendo 3DS. Originally created late June 2019.
|
||||
|
||||
## Summary
|
||||
1. Dump boot9.bin and movable.sed from a 3DS system.
|
||||
2. Install pycryptodomex:
|
||||
* Windows: `py -3 -m pip install --user --upgrade pycryptodomex`
|
||||
* macOS/Linux: `python3 -m pip install --user --upgrade pycryptodomex`
|
||||
2. Run `custom-install.py` with boot9.bin, movable.sed, path to the SD root, and CIA files to install (see Usage section).
|
||||
3. Use custom-install-finalize on the 3DS system to finish the install.
|
||||
|
||||
### Windows standalone
|
||||
|
||||
1. [Dump boot9.bin and movable.sed](https://ihaveamac.github.io/dump.html) from a 3DS system.
|
||||
2. Download the [latest releases](https://github.com/ihaveamac/custom-install/releases).
|
||||
3. Extract and run ci-gui. Read `windows-quickstart.txt`.
|
||||
|
||||
### With installed Python
|
||||
|
||||
> [!NOTE]
|
||||
> Windows users: Enabling "Add Python 3.X to PATH" is **NOT** required! Python is installed with the `py` launcher by default.
|
||||
|
||||
1. [Dump boot9.bin and movable.sed](https://ihaveamac.github.io/dump.html) from a 3DS system.
|
||||
2. Install the packages:
|
||||
* Windows: `py -3 -m pip install --user --upgrade https://github.com/ihaveamac/custom-install/archive/refs/heads/python-package.zip`
|
||||
* macOS/Linux: `python3 -m pip install --user --upgrade https://github.com/ihaveamac/custom-install/archive/refs/heads/python-package.zip`
|
||||
|
||||
To run the GUI:
|
||||
* Windows: `py -3 -m custominstall.gui`
|
||||
* macOS/Linux: `python3 -m custominstall.gui`
|
||||
|
||||
To run the command line version:
|
||||
* Windows: `py -3 -m custominstall`
|
||||
* macOS/Linux: `python3 -m custominstall`
|
||||
|
||||
## Setup
|
||||
Linux users must build [wwylele/save3ds](https://github.com/wwylele/save3ds) and place `save3ds_fuse` in one of these places:
|
||||
* A directory in `PATH`
|
||||
* In `custominstall/bin/linux`
|
||||
* Set the environment variable `CUSTOM_INSTALL_SAVE3DS_PATH` to the `save3ds_fuse` binary
|
||||
|
||||
movable.sed is required and can be provided with `-m` or `--movable`.
|
||||
|
||||
boot9 is needed:
|
||||
@@ -22,26 +47,48 @@ boot9 is needed:
|
||||
|
||||
A [SeedDB](https://github.com/ihaveamac/3DS-rom-tools/wiki/SeedDB-list) is needed for newer games (2015+) that use seeds.
|
||||
SeedDB is checked in order of:
|
||||
* `--seeddb` argument (if set)
|
||||
* `-s` or `--seeddb` argument (if set)
|
||||
* `SEEDDB_PATH` environment variable (if set)
|
||||
* `%APPDATA%\3ds\seeddb.bin` (Windows-specific)
|
||||
* `~/Library/Application Support/3ds/seeddb.bin` (macOS-specific)
|
||||
* `~/.3ds/seeddb.bin`
|
||||
* `~/3ds/seeddb.bin`
|
||||
|
||||
## custom-install-finalize
|
||||
custom-install-finalize installs a ticket, plus a seed if required. This is required for the title to appear and function.
|
||||
|
||||
This can be built as most 3DS homebrew projects [with devkitARM](https://www.3dbrew.org/wiki/Setting_up_Development_Environment).
|
||||
|
||||
## Usage
|
||||
Use `-h` to view arguments.
|
||||
|
||||
Examples:
|
||||
```
|
||||
py -3 custom-install.py -b boot9.bin -m movable.sed --sd E:\ file.cia file2.cia
|
||||
python3 custom-install.py -b boot9.bin -m movable.sed --sd /Volumes/GM9SD file.cia file2.cia
|
||||
python3 custom-install.py -b boot9.bin -m movable.sed --sd /media/GM9SD file.cia file2.cia
|
||||
py -3 -m custominstall -b boot9.bin -m movable.sed --sd E:\ file.cia file2.cia
|
||||
python3 -m custominstall -b boot9.bin -m movable.sed --sd /Volumes/GM9SD file.cia file2.cia
|
||||
python3 -m custominstall -b boot9.bin -m movable.sed --sd /media/GM9SD file.cia file2.cia
|
||||
```
|
||||
|
||||
## License/Credits
|
||||
`pyctr/` is from [ninfs `d994c78`](https://github.com/ihaveamac/ninfs/tree/d994c78acf5ff3840df1ef5a6aabdc12ca98e806/ninfs/pyctr).
|
||||
## GUI
|
||||
A GUI is provided to make the process easier.
|
||||
|
||||
### GUI Setup
|
||||
Linux users may need to install a Tk package:
|
||||
- Ubuntu/Debian: `sudo apt install python3-tk`
|
||||
- Arch: `sudo pacman -S tk`
|
||||
|
||||
## Development
|
||||
|
||||
### Building Windows standalone
|
||||
|
||||
> [!WARNING]
|
||||
> This section is OUTDATED and currently does not work with the Python package setup.
|
||||
|
||||
## License/Credits
|
||||
[save3ds by wwylele](https://github.com/wwylele/save3ds) is used to interact with the Title Database (details in `bin/README`).
|
||||
|
||||
Thanks to @nek0bit for redesigning `custominstall.py` to work as a module, and for implementing an earlier GUI.
|
||||
|
||||
Thanks to @LyfeOnEdge from the [brewtools Discord](https://brewtools.dev) for designing the second version of the GUI. Special thanks to CrafterPika and archbox for testing.
|
||||
|
||||
Thanks to @BpyH64 for [researching how to generate the cmacs](https://github.com/d0k3/GodMode9/issues/340#issuecomment-487916606).
|
||||
|
||||
515
bin/Cargo.lock
generated
515
bin/Cargo.lock
generated
@@ -1,515 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"aes-soft 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"aesni 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-soft"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"byteorder 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"opaque-debug 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aesni"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"opaque-debug 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-padding 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"byte-tools 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"byteorder 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-cipher-trait"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"byte-tools 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byte-tools"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "byte_struct"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"byte_struct_derive 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byte_struct_derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"syn 0.15.39 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "cloudabi"
|
||||
version = "0.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"bitflags 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmac"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"crypto-mac 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"dbl 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-mac"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"subtle 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbl"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fake-simd"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "fuchsia-cprng"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "fuse"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"pkg-config 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"thread-scoped 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"typenum 1.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"unicode-width 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"byteorder 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"scopeguard 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "libsave3ds"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"byte_struct 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"cmac 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"lru 0.1.15 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"sha2 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"log 0.4.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"cfg-if 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"hashbrown 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "0.4.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "0.6.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"autocfg 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_chacha 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_hc 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_isaac 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_jitter 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_os 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_pcg 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_xorshift 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"autocfg 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_isaac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_jitter"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_os"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"fuchsia-cprng 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rdrand 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"autocfg 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rdrand"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.1.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "save3ds_fuse"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fuse 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"getopts 0.2.19 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"libsave3ds 0.1.0",
|
||||
"time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-buffer 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"digest 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"fake-simd 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"opaque-debug 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "0.15.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread-scoped"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.1.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[metadata]
|
||||
"checksum aes 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "54eb1d8fe354e5fc611daf4f2ea97dd45a765f4f1e4512306ec183ae2e8f20c9"
|
||||
"checksum aes-soft 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "cfd7e7ae3f9a1fb5c03b389fc6bb9a51400d0c13053f0dca698c832bfd893a0d"
|
||||
"checksum aesni 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2f70a6b5f971e473091ab7cfb5ffac6cde81666c4556751d8d5620ead8abf100"
|
||||
"checksum autocfg 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "0e49efa51329a5fd37e7c79db4621af617cd4e3e5bc224939808d076077077bf"
|
||||
"checksum bitflags 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3d155346769a6855b86399e9bc3814ab343cd3d62c7e985113d46a0ec3c281fd"
|
||||
"checksum block-buffer 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)" = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b"
|
||||
"checksum block-cipher-trait 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "1c924d49bd09e7c06003acda26cd9742e796e34282ec6c1189404dee0c1f4774"
|
||||
"checksum block-padding 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "6d4dc3af3ee2e12f3e5d224e5e1e3d73668abbeb69e566d361f7d5563a4fdf09"
|
||||
"checksum byte-tools 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7"
|
||||
"checksum byte_struct 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a11c85e60b49d9b5d16630266fc72d75172bd4af43e77cf390eec8ba9752a70d"
|
||||
"checksum byte_struct_derive 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7fb6eccde50afec044557d1f1b8776168b7040255390eefffb39fcfd1ab40b2e"
|
||||
"checksum byteorder 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a7c3dd8985a7111efc5c80b44e23ecdd8c007de8ade3b96595387e812b957cf5"
|
||||
"checksum cfg-if 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)" = "b486ce3ccf7ffd79fdeb678eac06a9e6c09fc88d33836340becb8fffe87c5e33"
|
||||
"checksum cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f"
|
||||
"checksum cmac 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "6f4a435124bcc292eba031f1f725d7abacdaf13cbf9f935450e8c45aa9e96cad"
|
||||
"checksum crypto-mac 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4434400df11d95d556bac068ddfedd482915eb18fe8bea89bc80b6e4b1c179e5"
|
||||
"checksum dbl 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "28dc203b75decc900220c4d9838e738d08413e663c26826ba92b669bed1d0795"
|
||||
"checksum digest 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5"
|
||||
"checksum fake-simd 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
|
||||
"checksum fuchsia-cprng 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
|
||||
"checksum fuse 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "80e57070510966bfef93662a81cb8aa2b1c7db0964354fa9921434f04b9e8660"
|
||||
"checksum generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)" = "c68f0274ae0e023facc3c97b2e00f076be70e254bc851d972503b328db79b2ec"
|
||||
"checksum getopts 0.2.19 (registry+https://github.com/rust-lang/crates.io-index)" = "72327b15c228bfe31f1390f93dd5e9279587f0463836393c9df719ce62a3e450"
|
||||
"checksum hashbrown 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "3bae29b6653b3412c2e71e9d486db9f9df5d701941d86683005efb9f2d28e3da"
|
||||
"checksum libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)" = "d44e80633f007889c7eff624b709ab43c92d708caad982295768a7b13ca3b5eb"
|
||||
"checksum log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b"
|
||||
"checksum log 0.4.7 (registry+https://github.com/rust-lang/crates.io-index)" = "c275b6ad54070ac2d665eef9197db647b32239c9d244bfb6f041a766d00da5b3"
|
||||
"checksum lru 0.1.15 (registry+https://github.com/rust-lang/crates.io-index)" = "276235bb6b60773280b44b65e93815de82da5b6279ef175004fca03f4d06770a"
|
||||
"checksum opaque-debug 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "93f5bb2e8e8dec81642920ccff6b61f1eb94fa3020c5a325c9851ff604152409"
|
||||
"checksum pkg-config 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)" = "676e8eb2b1b4c9043511a9b7bea0915320d7e502b0a079fb03f9635a5252b18c"
|
||||
"checksum proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)" = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759"
|
||||
"checksum quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)" = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1"
|
||||
"checksum rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)" = "6d71dacdc3c88c1fde3885a3be3fbab9f35724e6ce99467f7d9c5026132184ca"
|
||||
"checksum rand_chacha 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "556d3a1ca6600bfcbab7c7c91ccb085ac7fbbcd70e008a98742e7847f4f7bcef"
|
||||
"checksum rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
|
||||
"checksum rand_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d0e7a549d590831370895ab7ba4ea0c1b6b011d106b5ff2da6eee112615e6dc0"
|
||||
"checksum rand_hc 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7b40677c7be09ae76218dc623efbf7b18e34bced3f38883af07bb75630a21bc4"
|
||||
"checksum rand_isaac 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ded997c9d5f13925be2a6fd7e66bf1872597f759fd9dd93513dd7e92e5a5ee08"
|
||||
"checksum rand_jitter 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "1166d5c91dc97b88d1decc3285bb0a99ed84b05cfd0bc2341bdf2d43fc41e39b"
|
||||
"checksum rand_os 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "7b75f676a1e053fc562eafbb47838d67c84801e38fc1ba459e8f180deabd5071"
|
||||
"checksum rand_pcg 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "abf9b09b01790cfe0364f52bf32995ea3c39f4d2dd011eac241d2914146d0b44"
|
||||
"checksum rand_xorshift 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "cbf7e9e623549b0e21f6e97cf8ecf247c1a8fd2e8a992ae265314300b2455d5c"
|
||||
"checksum rdrand 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
|
||||
"checksum redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)" = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84"
|
||||
"checksum scopeguard 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "94258f53601af11e6a49f722422f6e3425c52b06245a5cf9bc09908b174f5e27"
|
||||
"checksum sha2 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7b4d8bfd0e469f417657573d8451fb33d16cfe0989359b93baf3a1ffc639543d"
|
||||
"checksum subtle 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2d67a5a62ba6e01cb2192ff309324cb4875d0c451d55fe2319433abe7a05a8ee"
|
||||
"checksum syn 0.15.39 (registry+https://github.com/rust-lang/crates.io-index)" = "b4d960b829a55e56db167e861ddb43602c003c7be0bee1d345021703fac2fb7c"
|
||||
"checksum thread-scoped 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "bcbb6aa301e5d3b0b5ef639c9a9c7e2f1c944f177b460c04dc24c69b1fa2bd99"
|
||||
"checksum time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)" = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f"
|
||||
"checksum typenum 1.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "612d636f949607bdf9b123b4a6f6d966dedf3ff669f7f045890d3a4a73948169"
|
||||
"checksum unicode-width 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "882386231c45df4700b275c7ff55b6f3698780a650026380e72dabe76fa46526"
|
||||
"checksum unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc"
|
||||
"checksum winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)" = "f10e386af2b13e47c89e7236a7a14a086791a2b88ebad6df9bf42040195cf770"
|
||||
"checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
"checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
@@ -1,4 +0,0 @@
|
||||
save3ds_fuse for win32 and darwin built with commit a9bd1468751200ccd2d5285dd91482847d6a22ed
|
||||
in repository https://github.com/wwylele/save3ds
|
||||
|
||||
linux binary must be provided by the user.
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,327 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of custom-install.py.
|
||||
#
|
||||
# Copyright (c) 2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from os import makedirs, scandir
|
||||
from os.path import dirname, join
|
||||
from random import randint
|
||||
from hashlib import sha256
|
||||
from sys import exit, platform
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import BinaryIO
|
||||
import subprocess
|
||||
|
||||
from pyctr.crypto import CryptoEngine, Keyslot
|
||||
from pyctr.types.cia import CIAReader, CIASection
|
||||
from pyctr.types.ncch import NCCHSection
|
||||
from pyctr.util import roundup
|
||||
|
||||
# used to run the save3ds_fuse binary next to the script
|
||||
script_dir: str = dirname(__file__)
|
||||
|
||||
# missing contents are replaced with 0xFFFFFFFF in the cmd file
|
||||
CMD_MISSING = b'\xff\xff\xff\xff'
|
||||
|
||||
# the size of each file and directory in a title's contents are rounded up to this
|
||||
TITLE_ALIGN_SIZE = 0x8000
|
||||
|
||||
# size to read at a time when copying files
|
||||
READ_SIZE = 0x200000
|
||||
|
||||
|
||||
def copy_with_progress(src: BinaryIO, dst: BinaryIO, size: int, path: str):
|
||||
left = size
|
||||
cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(path))
|
||||
while left > 0:
|
||||
to_read = min(READ_SIZE, left)
|
||||
data = cipher.encrypt(src.read(READ_SIZE))
|
||||
dst.write(data)
|
||||
left -= to_read
|
||||
total_read = size - left
|
||||
print(f' {(total_read / size) * 100:>5.1f}% {total_read / 1048576:>.1f} MiB / {size / 1048576:.1f} MiB',
|
||||
end='\r', flush=True)
|
||||
print()
|
||||
|
||||
|
||||
parser = ArgumentParser(description='Manually install a CIA to the SD card for a Nintendo 3DS system.')
|
||||
parser.add_argument('cia', help='CIA files', nargs='+')
|
||||
parser.add_argument('-m', '--movable', help='movable.sed file', required=True)
|
||||
parser.add_argument('-b', '--boot9', help='boot9 file')
|
||||
parser.add_argument('--sd', help='path to SD root')
|
||||
parser.add_argument('--skip-contents', help="don't add contents, only add title info entry", action='store_true')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# set up the crypto engine to encrypt contents as they are written to the SD
|
||||
crypto = CryptoEngine(boot9=args.boot9)
|
||||
crypto.setup_sd_key_from_file(args.movable)
|
||||
|
||||
# try to find the path to the SD card contents
|
||||
print('Finding path to install to...')
|
||||
sd_path = join(args.sd, 'Nintendo 3DS', crypto.id0.hex())
|
||||
id1s = []
|
||||
for d in scandir(sd_path):
|
||||
if d.is_dir() and len(d.name) == 32:
|
||||
try:
|
||||
id1_tmp = bytes.fromhex(d.name)
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
id1s.append(d.name)
|
||||
|
||||
# check the amount of id1 directories
|
||||
if len(id1s) > 1:
|
||||
exit(f'There are multiple id1 directories for id0 {crypto.id0.hex()}, please remove extra directories')
|
||||
elif len(id1s) == 0:
|
||||
exit(f'Could not find a suitable id1 directory for id0 {crypto.id0.hex()}')
|
||||
|
||||
sd_path = join(sd_path, id1s[0])
|
||||
|
||||
title_info_entries = {}
|
||||
# for use with a finalize program on the 3DS
|
||||
finalize_entries = []
|
||||
|
||||
for c in args.cia:
|
||||
# parse the cia
|
||||
print('Reading CIA...')
|
||||
cia = CIAReader(c)
|
||||
tid_parts = (cia.tmd.title_id[0:8], cia.tmd.title_id[8:16])
|
||||
|
||||
try:
|
||||
print(f'Installing {cia.contents[0].exefs.icon.get_app_title().short_desc}...')
|
||||
except:
|
||||
print('Installing...')
|
||||
|
||||
# this includes the sizes for all the files that would be in the title, plus each directory
|
||||
# except the separate directories for DLC contents, which don't count towards the size.
|
||||
# five "1"s are here for the tidlow and content directories, the cmd file and its directory, and the tmd file
|
||||
sizes = [1] * 5
|
||||
|
||||
if cia.tmd.save_size:
|
||||
# one for the data directory, one for the 00000001.sav file
|
||||
sizes.extend((1, cia.tmd.save_size))
|
||||
|
||||
for record in cia.content_info:
|
||||
sizes.append(record.size)
|
||||
|
||||
# this calculates the size to put in the Title Info Entry
|
||||
title_size = sum(roundup(x, TITLE_ALIGN_SIZE) for x in sizes)
|
||||
|
||||
# checks if this is dlc, which has some differences
|
||||
is_dlc = tid_parts[0] == '0004008c'
|
||||
|
||||
# this checks if it has a manual (index 1) and is not DLC
|
||||
has_manual = (not is_dlc) and (1 in cia.contents)
|
||||
|
||||
# this gets the extdata id from the extheader, stored in the storage info area
|
||||
try:
|
||||
with cia.contents[0].open_raw_section(NCCHSection.ExtendedHeader) as e:
|
||||
e.seek(0x200 + 0x30)
|
||||
extdata_id = e.read(8)
|
||||
except KeyError:
|
||||
# not an executable title
|
||||
extdata_id = b'\0' * 8
|
||||
|
||||
# cmd content id, starts with 1 for non-dlc contents
|
||||
cmd_id = len(cia.content_info) if is_dlc else 1
|
||||
cmd_filename = f'{cmd_id:08x}.cmd'
|
||||
|
||||
# get the title root where all the contents will be
|
||||
title_root = join(sd_path, 'title', *tid_parts)
|
||||
content_root = join(title_root, 'content')
|
||||
# generate the path used for the IV
|
||||
title_root_cmd = f'/title/{"/".join(tid_parts)}'
|
||||
content_root_cmd = title_root_cmd + '/content'
|
||||
|
||||
makedirs(join(content_root, 'cmd'), exist_ok=True)
|
||||
if cia.tmd.save_size:
|
||||
makedirs(join(title_root, 'data'), exist_ok=True)
|
||||
if is_dlc:
|
||||
# create the separate directories for every 256 contents
|
||||
for x in range(((len(cia.content_info) - 1) // 256) + 1):
|
||||
makedirs(join(content_root, f'{x:08x}'))
|
||||
|
||||
# maybe this will be changed in the future
|
||||
tmd_id = 0
|
||||
|
||||
tmd_filename = f'{tmd_id:08x}.tmd'
|
||||
|
||||
if not args.skip_contents:
|
||||
# write the tmd
|
||||
enc_path = content_root_cmd + '/' + tmd_filename
|
||||
print(f'Writing {enc_path}...')
|
||||
with cia.open_raw_section(CIASection.TitleMetadata) as s, open(join(content_root, tmd_filename), 'wb') as o:
|
||||
copy_with_progress(s, o, cia.sections[CIASection.TitleMetadata].size, enc_path)
|
||||
|
||||
# write each content
|
||||
for c in cia.content_info:
|
||||
content_filename = c.id + '.app'
|
||||
if is_dlc:
|
||||
dir_index = format((c.cindex // 256), '08x')
|
||||
enc_path = content_root_cmd + f'/{dir_index}/{content_filename}'
|
||||
out_path = join(content_root, dir_index, content_filename)
|
||||
else:
|
||||
enc_path = content_root_cmd + '/' + content_filename
|
||||
out_path = join(content_root, content_filename)
|
||||
print(f'Writing {enc_path}...')
|
||||
with cia.open_raw_section(c.cindex) as s, open(out_path, 'wb') as o:
|
||||
copy_with_progress(s, o, c.size, enc_path)
|
||||
|
||||
# generate a blank save
|
||||
if cia.tmd.save_size:
|
||||
enc_path = title_root_cmd + '/data/00000001.sav'
|
||||
out_path = join(title_root, 'data', '00000001.sav')
|
||||
cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(enc_path))
|
||||
# in a new save, the first 0x20 are all 00s. the rest can be random
|
||||
data = cipher.encrypt(b'\0' * 0x20)
|
||||
print(f'Generating blank save at {enc_path}...')
|
||||
with open(out_path, 'wb') as o:
|
||||
o.write(data)
|
||||
o.write(b'\0' * (cia.tmd.save_size - 0x20))
|
||||
|
||||
# generate and write cmd
|
||||
enc_path = content_root_cmd + '/cmd/' + cmd_filename
|
||||
out_path = join(content_root, 'cmd', cmd_filename)
|
||||
print(f'Generating {enc_path}')
|
||||
highest_index = 0
|
||||
content_ids = {}
|
||||
|
||||
for record in cia.content_info:
|
||||
highest_index = record.cindex
|
||||
with cia.open_raw_section(record.cindex) as s:
|
||||
s.seek(0x100)
|
||||
cmac_data = s.read(0x100)
|
||||
|
||||
id_bytes = bytes.fromhex(record.id)[::-1]
|
||||
cmac_data += record.cindex.to_bytes(4, 'little') + id_bytes
|
||||
|
||||
c = crypto.create_cmac_object(Keyslot.CMACSDNAND)
|
||||
c.update(sha256(cmac_data).digest())
|
||||
content_ids[record.cindex] = (id_bytes, c.digest())
|
||||
|
||||
# add content IDs up to the last one
|
||||
ids_by_index = [CMD_MISSING] * (highest_index + 1)
|
||||
installed_ids = []
|
||||
cmacs = []
|
||||
for x in range(len(ids_by_index)):
|
||||
try:
|
||||
info = content_ids[x]
|
||||
except KeyError:
|
||||
cmacs.append(bytes.fromhex('4D495353494E4720434F4E54454E5421'))
|
||||
else:
|
||||
ids_by_index[x] = info[0]
|
||||
cmacs.append(info[1])
|
||||
installed_ids.append(info[0])
|
||||
installed_ids.sort(key=lambda x: int.from_bytes(x, 'little'))
|
||||
|
||||
final = (cmd_id.to_bytes(4, 'little')
|
||||
+ len(ids_by_index).to_bytes(4, 'little')
|
||||
+ len(installed_ids).to_bytes(4, 'little')
|
||||
+ (1).to_bytes(4, 'little'))
|
||||
c = crypto.create_cmac_object(Keyslot.CMACSDNAND)
|
||||
c.update(final)
|
||||
final += c.digest()
|
||||
|
||||
final += b''.join(ids_by_index)
|
||||
final += b''.join(installed_ids)
|
||||
final += b''.join(cmacs)
|
||||
|
||||
cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(enc_path))
|
||||
print(f'Writing {enc_path}')
|
||||
with open(out_path, 'wb') as o:
|
||||
o.write(cipher.encrypt(final))
|
||||
|
||||
# this starts building the title info entry
|
||||
title_info_entry_data = [
|
||||
# title size
|
||||
title_size.to_bytes(8, 'little'),
|
||||
# title type, seems to usually be 0x40
|
||||
0x40.to_bytes(4, 'little'),
|
||||
# title version
|
||||
int(cia.tmd.title_version).to_bytes(2, 'little'),
|
||||
# ncch version
|
||||
cia.contents[0].version.to_bytes(2, 'little'),
|
||||
# flags_0, only checking if there is a manual
|
||||
(1 if has_manual else 0).to_bytes(4, 'little'),
|
||||
# tmd content id, always starting with 0
|
||||
(0).to_bytes(4, 'little'),
|
||||
# cmd content id
|
||||
cmd_id.to_bytes(4, 'little'),
|
||||
# flags_1, only checking save data
|
||||
(1 if cia.tmd.save_size else 0).to_bytes(4, 'little'),
|
||||
# extdataid low
|
||||
extdata_id[0:4],
|
||||
# reserved
|
||||
b'\0' * 4,
|
||||
# flags_2, only using a common value
|
||||
0x100000000.to_bytes(8, 'little'),
|
||||
# product code
|
||||
cia.contents[0].product_code.encode('ascii').ljust(0x10, b'\0'),
|
||||
# reserved
|
||||
b'\0' * 0x10,
|
||||
# unknown
|
||||
randint(0, 0xFFFFFFFF).to_bytes(4, 'little'),
|
||||
# reserved
|
||||
b'\0' * 0x2c
|
||||
]
|
||||
|
||||
print(title_info_entry_data)
|
||||
|
||||
title_info_entries[cia.tmd.title_id] = b''.join(title_info_entry_data)
|
||||
|
||||
with cia.open_raw_section(CIASection.Ticket) as t:
|
||||
ticket_data = t.read()
|
||||
|
||||
finalize_entry_data = [
|
||||
# title id
|
||||
bytes.fromhex(cia.tmd.title_id)[::-1],
|
||||
# common key index
|
||||
ticket_data[0x1F1:0x1F2],
|
||||
# has seed
|
||||
cia.contents[0].flags.uses_seed.to_bytes(1, 'little'),
|
||||
# magic
|
||||
b'TITLE\0',
|
||||
# encrypted titlekey
|
||||
ticket_data[0x1BF:0x1CF],
|
||||
# seed, if needed
|
||||
cia.contents[0].seed if cia.contents[0].flags.uses_seed else (b'\0' * 0x10)
|
||||
]
|
||||
|
||||
finalize_entries.append(b''.join(finalize_entry_data))
|
||||
|
||||
with open(join(args.sd, 'cifinish.bin'), 'wb') as o:
|
||||
# magic, version, title count
|
||||
o.write(b'CIFINISH' + (1).to_bytes(4, 'little') + len(finalize_entries).to_bytes(4, 'little'))
|
||||
|
||||
# add each entry to cifinish.bin
|
||||
for entry in finalize_entries:
|
||||
o.write(entry)
|
||||
|
||||
with TemporaryDirectory(suffix='-custom-install') as tempdir:
|
||||
# set up the common arguments for the two times we call save3ds_fuse
|
||||
save3ds_fuse_common_args = [
|
||||
join(script_dir, 'bin', platform, 'save3ds_fuse'),
|
||||
'-b', crypto.b9_path,
|
||||
'-m', args.movable,
|
||||
'--sd', args.sd,
|
||||
'--db', 'sdtitle',
|
||||
tempdir
|
||||
]
|
||||
|
||||
# extract the title database to add our own entry to
|
||||
print('Extracting Title Database...')
|
||||
subprocess.run(save3ds_fuse_common_args + ['-x'])
|
||||
|
||||
for title_id, entry in title_info_entries.items():
|
||||
# write the title info entry to the temp directory
|
||||
with open(join(tempdir, title_id), 'wb') as o:
|
||||
o.write(entry)
|
||||
|
||||
# import the directory, now including our title
|
||||
print('Importing into Title Database...')
|
||||
subprocess.run(save3ds_fuse_common_args + ['-i'])
|
||||
BIN
custominstall/TaskbarLib.tlb
Normal file
BIN
custominstall/TaskbarLib.tlb
Normal file
Binary file not shown.
10
custominstall/__init__.py
Normal file
10
custominstall/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# This file is a part of custom-install.
|
||||
#
|
||||
# Copyright (c) 2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
__author__ = 'ihaveahax'
|
||||
__copyright__ = 'Copyright (c) 2019 Ian Burgwin'
|
||||
__license__ = 'MIT'
|
||||
__version__ = '2.1'
|
||||
765
custominstall/__main__.py
Normal file
765
custominstall/__main__.py
Normal file
@@ -0,0 +1,765 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of custom-install.py.
|
||||
#
|
||||
# custom-install is copyright (c) 2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from enum import Enum
|
||||
from glob import glob
|
||||
import gzip
|
||||
from os import makedirs, rename, scandir, environ
|
||||
from os.path import dirname, join, isdir, isfile
|
||||
from random import randint
|
||||
from hashlib import sha256
|
||||
from pprint import pformat
|
||||
from shutil import copyfile, copy2, rmtree, which
|
||||
import sys
|
||||
from sys import platform, executable
|
||||
from tempfile import TemporaryDirectory
|
||||
from traceback import format_exception
|
||||
from typing import BinaryIO, TYPE_CHECKING
|
||||
import subprocess
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from os import PathLike
|
||||
from typing import List, Union, Tuple
|
||||
|
||||
from events import Events
|
||||
|
||||
from pyctr.crypto import CryptoEngine, Keyslot, load_seeddb, get_seed
|
||||
from pyctr.type.cdn import CDNReader, CDNError
|
||||
from pyctr.type.cia import CIAReader, CIAError
|
||||
from pyctr.type.ncch import NCCHSection
|
||||
from pyctr.type.tmd import TitleMetadataError
|
||||
from pyctr.util import roundup
|
||||
|
||||
from . import __version__
|
||||
|
||||
if platform == 'msys':
|
||||
platform = 'win32'
|
||||
|
||||
is_windows = platform == 'win32'
|
||||
|
||||
if is_windows:
|
||||
from ctypes import c_wchar_p, pointer, c_ulonglong, windll
|
||||
else:
|
||||
from os import statvfs
|
||||
|
||||
script_dir: str
|
||||
frozen = getattr(sys, 'frozen', False)
|
||||
if frozen:
|
||||
script_dir = dirname(executable)
|
||||
else:
|
||||
script_dir = dirname(__file__)
|
||||
|
||||
# used to run the save3ds_fuse binary next to the script
|
||||
if 'CUSTOM_INSTALL_SAVE3DS_PATH' in environ:
|
||||
save3ds_fuse_path = environ['CUSTOM_INSTALL_SAVE3DS_PATH']
|
||||
else:
|
||||
save3ds_fuse_name = 'save3ds_fuse'
|
||||
if is_windows:
|
||||
save3ds_fuse_name += '.exe'
|
||||
if frozen:
|
||||
save3ds_fuse_path = join(script_dir, 'bin', save3ds_fuse_name)
|
||||
else:
|
||||
save3ds_fuse_path = join(script_dir, 'bin', platform, save3ds_fuse_name)
|
||||
|
||||
if not isfile(save3ds_fuse_path):
|
||||
save3ds_fuse_path = which('save3ds_fuse')
|
||||
|
||||
# missing contents are replaced with 0xFFFFFFFF in the cmd file
|
||||
CMD_MISSING = b'\xff\xff\xff\xff'
|
||||
|
||||
# the size of each file and directory in a title's contents are rounded up to this
|
||||
TITLE_ALIGN_SIZE = 0x8000
|
||||
|
||||
# size to read at a time when copying files
|
||||
READ_SIZE = 0x200000
|
||||
|
||||
# version for cifinish.bin
|
||||
CIFINISH_VERSION = 3
|
||||
|
||||
|
||||
# Placeholder for SDPathErrors
|
||||
class SDPathError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidCIFinishError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InstallStatus(Enum):
|
||||
Waiting = 0
|
||||
Starting = 1
|
||||
Writing = 2
|
||||
Finishing = 3
|
||||
Done = 4
|
||||
Failed = 5
|
||||
|
||||
|
||||
def get_free_space(path: 'Union[PathLike, bytes, str]'):
|
||||
if is_windows:
|
||||
lpSectorsPerCluster = c_ulonglong(0)
|
||||
lpBytesPerSector = c_ulonglong(0)
|
||||
lpNumberOfFreeClusters = c_ulonglong(0)
|
||||
lpTotalNumberOfClusters = c_ulonglong(0)
|
||||
ret = windll.kernel32.GetDiskFreeSpaceW(c_wchar_p(path), pointer(lpSectorsPerCluster),
|
||||
pointer(lpBytesPerSector),
|
||||
pointer(lpNumberOfFreeClusters),
|
||||
pointer(lpTotalNumberOfClusters))
|
||||
if not ret:
|
||||
raise WindowsError
|
||||
free_blocks = lpNumberOfFreeClusters.value * lpSectorsPerCluster.value
|
||||
free_bytes = free_blocks * lpBytesPerSector.value
|
||||
else:
|
||||
stv = statvfs(path)
|
||||
free_bytes = stv.f_bavail * stv.f_frsize
|
||||
return free_bytes
|
||||
|
||||
|
||||
def load_cifinish(path: 'Union[PathLike, bytes, str]'):
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
header = f.read(0x10)
|
||||
if header[0:8] != b'CIFINISH':
|
||||
raise InvalidCIFinishError('CIFINISH magic not found')
|
||||
version = int.from_bytes(header[0x8:0xC], 'little')
|
||||
count = int.from_bytes(header[0xC:0x10], 'little')
|
||||
data = {}
|
||||
for _ in range(count):
|
||||
if version == 1:
|
||||
# ignoring the titlekey and common key index, since it's not useful in this scenario
|
||||
raw_entry = f.read(0x30)
|
||||
if len(raw_entry) != 0x30:
|
||||
raise InvalidCIFinishError(f'title entry is not 0x30 (version {version})')
|
||||
|
||||
title_magic = raw_entry[0xA:0x10]
|
||||
title_id = int.from_bytes(raw_entry[0:8], 'little')
|
||||
has_seed = raw_entry[0x9]
|
||||
seed = raw_entry[0x20:0x30]
|
||||
|
||||
elif version == 2:
|
||||
# this is assuming the "wrong" version created by an earlier version of this script
|
||||
# there wasn't a version of custom-install-finalize that really accepted this version
|
||||
raw_entry = f.read(0x20)
|
||||
if len(raw_entry) != 0x20:
|
||||
raise InvalidCIFinishError(f'title entry is not 0x20 (version {version})')
|
||||
|
||||
title_magic = raw_entry[0:6]
|
||||
title_id = int.from_bytes(raw_entry[0x6:0xE], 'little')
|
||||
has_seed = raw_entry[0xE]
|
||||
seed = raw_entry[0x10:0x20]
|
||||
|
||||
elif version == 3:
|
||||
raw_entry = f.read(0x20)
|
||||
if len(raw_entry) != 0x20:
|
||||
raise InvalidCIFinishError(f'title entry is not 0x20 (version {version})')
|
||||
|
||||
title_magic = raw_entry[0:6]
|
||||
title_id = int.from_bytes(raw_entry[0x8:0x10], 'little')
|
||||
has_seed = raw_entry[0x6]
|
||||
seed = raw_entry[0x10:0x20]
|
||||
|
||||
else:
|
||||
raise InvalidCIFinishError(f'unknown version {version}')
|
||||
|
||||
if title_magic == b'TITLE\0':
|
||||
data[title_id] = {'seed': seed if has_seed else None}
|
||||
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
# allow the caller to easily create a new database in the same place where an existing one would be updated
|
||||
return {}
|
||||
|
||||
|
||||
def save_cifinish(path: 'Union[PathLike, bytes, str]', data: dict):
|
||||
with open(path, 'wb') as out:
|
||||
entries = sorted(data.items())
|
||||
|
||||
out.write(b'CIFINISH')
|
||||
out.write(CIFINISH_VERSION.to_bytes(4, 'little'))
|
||||
out.write(len(entries).to_bytes(4, 'little'))
|
||||
|
||||
for tid, data in entries:
|
||||
finalize_entry_data = [
|
||||
# magic
|
||||
b'TITLE\0',
|
||||
# has seed
|
||||
bool(data['seed']).to_bytes(1, 'little'),
|
||||
# padding
|
||||
b'\0',
|
||||
# title id
|
||||
tid.to_bytes(8, 'little'),
|
||||
# seed, if needed
|
||||
(data['seed'] if data['seed'] else (b'\0' * 0x10))
|
||||
]
|
||||
|
||||
out.write(b''.join(finalize_entry_data))
|
||||
|
||||
|
||||
def get_install_size(title: 'Union[CIAReader, CDNReader]'):
|
||||
sizes = [1] * 5
|
||||
|
||||
if title.tmd.save_size:
|
||||
# one for the data directory, one for the 00000001.sav file
|
||||
sizes.extend((1, title.tmd.save_size))
|
||||
|
||||
for record in title.content_info:
|
||||
sizes.append(record.size)
|
||||
|
||||
# this calculates the size to put in the Title Info Entry
|
||||
title_size = sum(roundup(x, TITLE_ALIGN_SIZE) for x in sizes)
|
||||
|
||||
return title_size
|
||||
|
||||
|
||||
class CustomInstall:
|
||||
def __init__(self, *, movable, sd, cifinish_out=None, overwrite_saves=False, skip_contents=False,
|
||||
boot9=None, seeddb=None):
|
||||
self.event = Events()
|
||||
self.log_lines = [] # Stores all info messages for user to view
|
||||
|
||||
self.crypto = CryptoEngine(boot9=boot9)
|
||||
self.crypto.setup_sd_key_from_file(movable)
|
||||
self.seeddb = seeddb
|
||||
self.readers: 'List[Tuple[Union[CDNReader, CIAReader], Union[PathLike, bytes, str]]]' = []
|
||||
self.sd = sd
|
||||
self.skip_contents = skip_contents
|
||||
self.overwrite_saves = overwrite_saves
|
||||
self.cifinish_out = cifinish_out
|
||||
self.movable = movable
|
||||
|
||||
def copy_with_progress(self, src: BinaryIO, dst: BinaryIO, size: int, path: str, fire_event: bool = True):
|
||||
left = size
|
||||
cipher = self.crypto.create_ctr_cipher(Keyslot.SD, self.crypto.sd_path_to_iv(path))
|
||||
hasher = sha256()
|
||||
while left > 0:
|
||||
to_read = min(READ_SIZE, left)
|
||||
data = src.read(READ_SIZE)
|
||||
hasher.update(data)
|
||||
dst.write(cipher.encrypt(data))
|
||||
left -= to_read
|
||||
total_read = size - left
|
||||
if fire_event:
|
||||
self.event.update_percentage((total_read / size) * 100, total_read / 1048576, size / 1048576)
|
||||
|
||||
return hasher.digest()
|
||||
|
||||
@staticmethod
|
||||
def get_reader(path: 'Union[PathLike, bytes, str]'):
|
||||
if isdir(path):
|
||||
# try the default tmd file
|
||||
reader = CDNReader(join(path, 'tmd'))
|
||||
else:
|
||||
try:
|
||||
reader = CIAReader(path)
|
||||
except CIAError:
|
||||
# if there was an error with parsing the CIA header,
|
||||
# the file would be tried in CDNReader next (assuming it's a tmd)
|
||||
# any other error should be propagated to the caller
|
||||
reader = CDNReader(path)
|
||||
return reader
|
||||
|
||||
def prepare_titles(self, paths: 'List[PathLike]'):
|
||||
if self.seeddb:
|
||||
load_seeddb(self.seeddb)
|
||||
|
||||
readers = []
|
||||
for path in paths:
|
||||
self.log(f'Reading {path}')
|
||||
try:
|
||||
reader = self.get_reader(path)
|
||||
except (CIAError, CDNError, TitleMetadataError):
|
||||
self.log(f"Couldn't read {path}, likely corrupt or not a CIA or CDN title")
|
||||
continue
|
||||
if reader.tmd.title_id.startswith('00048'): # DSiWare
|
||||
self.log(f'Skipping {reader.tmd.title_id} - DSiWare is not supported')
|
||||
continue
|
||||
readers.append((reader, path))
|
||||
self.readers = readers
|
||||
|
||||
def check_size(self):
|
||||
total_size = 0
|
||||
for r, _ in self.readers:
|
||||
total_size += get_install_size(r)
|
||||
|
||||
free_space = get_free_space(self.sd)
|
||||
return total_size, free_space
|
||||
|
||||
def check_for_id0(self):
|
||||
sd_path = join(self.sd, 'Nintendo 3DS', self.crypto.id0.hex())
|
||||
return isdir(sd_path)
|
||||
|
||||
def start(self):
|
||||
if not (save3ds_fuse_path and isfile(save3ds_fuse_path)):
|
||||
self.log("Couldn't find " + save3ds_fuse_path, 2)
|
||||
return None, False, 0
|
||||
|
||||
crypto = self.crypto
|
||||
# TODO: Move a lot of these into their own methods
|
||||
self.log("Finding path to install to...")
|
||||
[sd_path, id1s] = self.get_sd_path()
|
||||
if len(id1s) > 1:
|
||||
raise SDPathError(f'There are multiple id1 directories for id0 {crypto.id0.hex()}, '
|
||||
f'please remove extra directories')
|
||||
elif len(id1s) == 0:
|
||||
raise SDPathError(f'Could not find a suitable id1 directory for id0 {crypto.id0.hex()}')
|
||||
id1 = id1s[0]
|
||||
sd_path = join(sd_path, id1)
|
||||
|
||||
if self.cifinish_out:
|
||||
cifinish_path = self.cifinish_out
|
||||
else:
|
||||
cifinish_path = join(self.sd, 'cifinish.bin')
|
||||
|
||||
try:
|
||||
cifinish_data = load_cifinish(cifinish_path)
|
||||
except InvalidCIFinishError as e:
|
||||
self.log(f'{type(e).__qualname__}: {e}')
|
||||
self.log(f'{cifinish_path} was corrupt!\n'
|
||||
f'This could mean an issue with the SD card or the filesystem. Please check it for errors.\n'
|
||||
f'It is also possible, though less likely, to be an issue with custom-install.\n'
|
||||
f'Exiting now to prevent possible issues. If you want to try again, delete cifinish.bin from the SD card and re-run custom-install.')
|
||||
return None, False, 0
|
||||
|
||||
db_path = join(sd_path, 'dbs')
|
||||
titledb_path = join(db_path, 'title.db')
|
||||
importdb_path = join(db_path, 'import.db')
|
||||
if not isfile(titledb_path):
|
||||
makedirs(db_path, exist_ok=True)
|
||||
with gzip.open(join(script_dir, 'title.db.gz')) as f:
|
||||
tdb = f.read()
|
||||
|
||||
self.log(f'Creating title.db...')
|
||||
with open(titledb_path, 'wb') as o:
|
||||
with self.crypto.create_ctr_io(Keyslot.SD, o, self.crypto.sd_path_to_iv('/dbs/title.db')) as e:
|
||||
e.write(tdb)
|
||||
|
||||
cmac = crypto.create_cmac_object(Keyslot.CMACSDNAND)
|
||||
cmac_data = [b'CTR-9DB0', 0x2.to_bytes(4, 'little'), tdb[0x100:0x200]]
|
||||
cmac.update(sha256(b''.join(cmac_data)).digest())
|
||||
|
||||
e.seek(0)
|
||||
e.write(cmac.digest())
|
||||
|
||||
self.log(f'Creating import.db...')
|
||||
with open(importdb_path, 'wb') as o:
|
||||
with self.crypto.create_ctr_io(Keyslot.SD, o, self.crypto.sd_path_to_iv('/dbs/import.db')) as e:
|
||||
e.write(tdb)
|
||||
|
||||
cmac = crypto.create_cmac_object(Keyslot.CMACSDNAND)
|
||||
cmac_data = [b'CTR-9DB0', 0x3.to_bytes(4, 'little'), tdb[0x100:0x200]]
|
||||
cmac.update(sha256(b''.join(cmac_data)).digest())
|
||||
|
||||
e.seek(0)
|
||||
e.write(cmac.digest())
|
||||
|
||||
del tdb
|
||||
|
||||
with TemporaryDirectory(suffix='-custom-install') as tempdir:
|
||||
# set up the common arguments for the two times we call save3ds_fuse
|
||||
save3ds_fuse_common_args = [
|
||||
save3ds_fuse_path,
|
||||
'-b', crypto.b9_path,
|
||||
'-m', self.movable,
|
||||
'--sd', self.sd,
|
||||
'--db', 'sdtitle',
|
||||
tempdir
|
||||
]
|
||||
|
||||
extra_kwargs = {}
|
||||
if is_windows:
|
||||
# hide console window
|
||||
extra_kwargs['creationflags'] = 0x08000000 # CREATE_NO_WINDOW
|
||||
|
||||
# extract the title database to add our own entry to
|
||||
self.log('Extracting Title Database...')
|
||||
out = subprocess.run(save3ds_fuse_common_args + ['-x'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
encoding='utf-8',
|
||||
**extra_kwargs)
|
||||
if out.returncode:
|
||||
for l in out.stdout.split('\n'):
|
||||
self.log(l)
|
||||
self.log('Command line:')
|
||||
for l in pformat(out.args).split('\n'):
|
||||
self.log(l)
|
||||
return None, False, 0
|
||||
|
||||
install_state = {'installed': [], 'failed': []}
|
||||
|
||||
# Now loop through all provided cia files
|
||||
for idx, info in enumerate(self.readers):
|
||||
cia, path = info
|
||||
|
||||
self.event.on_cia_start(idx)
|
||||
self.event.update_status(path, InstallStatus.Starting)
|
||||
|
||||
temp_title_root = join(self.sd, f'ci-install-temp-{cia.tmd.title_id}-{randint(0, 0xFFFFFFFF):08x}')
|
||||
makedirs(temp_title_root, exist_ok=True)
|
||||
|
||||
tid_parts = (cia.tmd.title_id[0:8], cia.tmd.title_id[8:16])
|
||||
|
||||
try:
|
||||
display_title = f'{cia.contents[0].exefs.icon.get_app_title().short_desc} - {cia.tmd.title_id}'
|
||||
except:
|
||||
display_title = cia.tmd.title_id
|
||||
self.log(f'Installing {display_title}...')
|
||||
|
||||
title_size = get_install_size(cia)
|
||||
|
||||
# checks if this is dlc, which has some differences
|
||||
is_dlc = tid_parts[0] == '0004008c'
|
||||
|
||||
# this checks if it has a manual (index 1) and is not DLC
|
||||
has_manual = (not is_dlc) and (1 in cia.contents)
|
||||
|
||||
# this gets the extdata id from the extheader, stored in the storage info area
|
||||
try:
|
||||
with cia.contents[0].open_raw_section(NCCHSection.ExtendedHeader) as e:
|
||||
e.seek(0x200 + 0x30)
|
||||
extdata_id = e.read(8)
|
||||
except KeyError:
|
||||
# not an executable title
|
||||
extdata_id = b'\0' * 8
|
||||
|
||||
# cmd content id, starts with 1 for non-dlc contents
|
||||
cmd_id = len(cia.content_info) if is_dlc else 1
|
||||
cmd_filename = f'{cmd_id:08x}.cmd'
|
||||
|
||||
# this is where the final directory will be moved
|
||||
tidhigh_root = join(sd_path, 'title', tid_parts[0])
|
||||
|
||||
# get the title root where all the contents will be
|
||||
title_root = join(sd_path, 'title', *tid_parts)
|
||||
content_root = join(title_root, 'content')
|
||||
# generate the path used for the IV
|
||||
title_root_cmd = f'/title/{"/".join(tid_parts)}'
|
||||
content_root_cmd = title_root_cmd + '/content'
|
||||
|
||||
temp_content_root = join(temp_title_root, 'content')
|
||||
|
||||
if not self.skip_contents:
|
||||
self.event.update_status(path, InstallStatus.Writing)
|
||||
makedirs(join(temp_content_root, 'cmd'), exist_ok=True)
|
||||
if cia.tmd.save_size:
|
||||
makedirs(join(temp_title_root, 'data'), exist_ok=True)
|
||||
if is_dlc:
|
||||
# create the separate directories for every 256 contents
|
||||
for x in range(((len(cia.content_info) - 1) // 256) + 1):
|
||||
makedirs(join(temp_content_root, f'{x:08x}'), exist_ok=True)
|
||||
|
||||
# maybe this will be changed in the future
|
||||
tmd_id = 0
|
||||
|
||||
tmd_filename = f'{tmd_id:08x}.tmd'
|
||||
|
||||
# write the tmd
|
||||
tmd_enc_path = content_root_cmd + '/' + tmd_filename
|
||||
self.log(f'Writing {tmd_enc_path}...')
|
||||
with open(join(temp_content_root, tmd_filename), 'wb') as o:
|
||||
with self.crypto.create_ctr_io(Keyslot.SD, o, self.crypto.sd_path_to_iv(tmd_enc_path)) as e:
|
||||
e.write(bytes(cia.tmd))
|
||||
|
||||
# in case the contents are corrupted
|
||||
do_continue = False
|
||||
# write each content
|
||||
for co in cia.content_info:
|
||||
content_filename = co.id + '.app'
|
||||
if is_dlc:
|
||||
dir_index = format((co.cindex // 256), '08x')
|
||||
content_enc_path = content_root_cmd + f'/{dir_index}/{content_filename}'
|
||||
content_out_path = join(temp_content_root, dir_index, content_filename)
|
||||
else:
|
||||
content_enc_path = content_root_cmd + '/' + content_filename
|
||||
content_out_path = join(temp_content_root, content_filename)
|
||||
self.log(f'Writing {content_enc_path}...')
|
||||
with cia.open_raw_section(co.cindex) as s, open(content_out_path, 'wb') as o:
|
||||
result_hash = self.copy_with_progress(s, o, co.size, content_enc_path)
|
||||
if result_hash != co.hash:
|
||||
self.log(f'WARNING: Hash does not match for {content_enc_path}!')
|
||||
install_state['failed'].append(display_title)
|
||||
rename(temp_title_root, temp_title_root + '-corrupted')
|
||||
do_continue = True
|
||||
self.event.update_status(path, InstallStatus.Failed)
|
||||
break
|
||||
|
||||
if do_continue:
|
||||
continue
|
||||
|
||||
# generate a blank save
|
||||
if cia.tmd.save_size:
|
||||
sav_enc_path = title_root_cmd + '/data/00000001.sav'
|
||||
tmp_sav_out_path = join(temp_title_root, 'data', '00000001.sav')
|
||||
sav_out_path = join(title_root, 'data', '00000001.sav')
|
||||
if self.overwrite_saves or not isfile(sav_out_path):
|
||||
cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(sav_enc_path))
|
||||
# in a new save, the first 0x20 are all 00s. the rest can be random
|
||||
data = cipher.encrypt(b'\0' * 0x20)
|
||||
self.log(f'Generating blank save at {sav_enc_path}...')
|
||||
with open(tmp_sav_out_path, 'wb') as o:
|
||||
o.write(data)
|
||||
o.write(b'\0' * (cia.tmd.save_size - 0x20))
|
||||
else:
|
||||
self.log(f'Copying original save file from {sav_enc_path}...')
|
||||
copy2(sav_out_path, tmp_sav_out_path)
|
||||
|
||||
# generate and write cmd
|
||||
cmd_enc_path = content_root_cmd + '/cmd/' + cmd_filename
|
||||
cmd_out_path = join(temp_content_root, 'cmd', cmd_filename)
|
||||
self.log(f'Generating {cmd_enc_path}')
|
||||
highest_index = 0
|
||||
content_ids = {}
|
||||
|
||||
for record in cia.content_info:
|
||||
highest_index = record.cindex
|
||||
with cia.open_raw_section(record.cindex) as s:
|
||||
s.seek(0x100)
|
||||
cmac_data = s.read(0x100)
|
||||
|
||||
id_bytes = bytes.fromhex(record.id)[::-1]
|
||||
cmac_data += record.cindex.to_bytes(4, 'little') + id_bytes
|
||||
|
||||
cmac_ncch = crypto.create_cmac_object(Keyslot.CMACSDNAND)
|
||||
cmac_ncch.update(sha256(cmac_data).digest())
|
||||
content_ids[record.cindex] = (id_bytes, cmac_ncch.digest())
|
||||
|
||||
# add content IDs up to the last one
|
||||
ids_by_index = [CMD_MISSING] * (highest_index + 1)
|
||||
installed_ids = []
|
||||
cmacs = []
|
||||
for x in range(len(ids_by_index)):
|
||||
try:
|
||||
info = content_ids[x]
|
||||
except KeyError:
|
||||
# "MISSING CONTENT!"
|
||||
# The 3DS does generate a cmac for missing contents, but I don't know how it works.
|
||||
# It doesn't matter anyway, the title seems to be fully functional.
|
||||
cmacs.append(bytes.fromhex('4D495353494E4720434F4E54454E5421'))
|
||||
else:
|
||||
ids_by_index[x] = info[0]
|
||||
cmacs.append(info[1])
|
||||
installed_ids.append(info[0])
|
||||
installed_ids.sort(key=lambda x: int.from_bytes(x, 'little'))
|
||||
|
||||
final = (cmd_id.to_bytes(4, 'little')
|
||||
+ len(ids_by_index).to_bytes(4, 'little')
|
||||
+ len(installed_ids).to_bytes(4, 'little')
|
||||
+ (1).to_bytes(4, 'little'))
|
||||
cmac_cmd_header = crypto.create_cmac_object(Keyslot.CMACSDNAND)
|
||||
cmac_cmd_header.update(final)
|
||||
final += cmac_cmd_header.digest()
|
||||
|
||||
final += b''.join(ids_by_index)
|
||||
final += b''.join(installed_ids)
|
||||
final += b''.join(cmacs)
|
||||
|
||||
cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(cmd_enc_path))
|
||||
self.log(f'Writing {cmd_enc_path}')
|
||||
with open(cmd_out_path, 'wb') as o:
|
||||
o.write(cipher.encrypt(final))
|
||||
|
||||
# this starts building the title info entry
|
||||
title_info_entry_data = [
|
||||
# title size
|
||||
title_size.to_bytes(8, 'little'),
|
||||
# title type, seems to usually be 0x40
|
||||
0x40.to_bytes(4, 'little'),
|
||||
# title version
|
||||
int(cia.tmd.title_version).to_bytes(2, 'little'),
|
||||
# ncch version
|
||||
cia.contents[0].version.to_bytes(2, 'little'),
|
||||
# flags_0, only checking if there is a manual
|
||||
(1 if has_manual else 0).to_bytes(4, 'little'),
|
||||
# tmd content id, always starting with 0
|
||||
(0).to_bytes(4, 'little'),
|
||||
# cmd content id
|
||||
cmd_id.to_bytes(4, 'little'),
|
||||
# flags_1, only checking save data
|
||||
(1 if cia.tmd.save_size else 0).to_bytes(4, 'little'),
|
||||
# extdataid low
|
||||
extdata_id[0:4],
|
||||
# reserved
|
||||
b'\0' * 4,
|
||||
# flags_2, only using a common value
|
||||
0x100000000.to_bytes(8, 'little'),
|
||||
# product code
|
||||
cia.contents[0].product_code.encode('ascii').ljust(0x10, b'\0'),
|
||||
# reserved
|
||||
b'\0' * 0x10,
|
||||
# unknown
|
||||
randint(0, 0xFFFFFFFF).to_bytes(4, 'little'),
|
||||
# reserved
|
||||
b'\0' * 0x2c
|
||||
]
|
||||
|
||||
self.event.update_status(path, InstallStatus.Finishing)
|
||||
if isdir(title_root):
|
||||
self.log(f'Removing original install at {title_root}...')
|
||||
rmtree(title_root)
|
||||
|
||||
makedirs(tidhigh_root, exist_ok=True)
|
||||
rename(temp_title_root, title_root)
|
||||
|
||||
cifinish_data[int(cia.tmd.title_id, 16)] = {'seed': (get_seed(cia.contents[0].program_id) if cia.contents[0].flags.uses_seed else None)}
|
||||
|
||||
# This is saved regardless if any titles were installed, so the file can be upgraded just in case.
|
||||
save_cifinish(cifinish_path, cifinish_data)
|
||||
|
||||
with open(join(tempdir, cia.tmd.title_id), 'wb') as o:
|
||||
o.write(b''.join(title_info_entry_data))
|
||||
|
||||
# import the directory, now including our title
|
||||
self.log('Importing into Title Database...')
|
||||
out = subprocess.run(save3ds_fuse_common_args + ['-i'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
encoding='utf-8',
|
||||
**extra_kwargs)
|
||||
if out.returncode:
|
||||
for l in out.stdout.split('\n'):
|
||||
self.log(l)
|
||||
self.log('Command line:')
|
||||
for l in pformat(out.args).split('\n'):
|
||||
self.log(l)
|
||||
install_state['failed'].append(display_title)
|
||||
self.event.update_status(path, InstallStatus.Failed)
|
||||
else:
|
||||
install_state['installed'].append(display_title)
|
||||
self.event.update_status(path, InstallStatus.Done)
|
||||
|
||||
copied = False
|
||||
# launchable applications, not DLC or update data
|
||||
application_count = len(glob(join(tempdir, '00040000*')))
|
||||
if install_state['installed']:
|
||||
if application_count >= 300:
|
||||
self.log(f'{application_count} installed applications were detected.', 1)
|
||||
self.log('The HOME Menu will only show 300 icons.', 1)
|
||||
self.log('Some applications (not updates or DLC) will need to be deleted.', 1)
|
||||
finalize_3dsx_orig_path = join(script_dir, 'custom-install-finalize.3dsx')
|
||||
hb_dir = join(self.sd, '3ds')
|
||||
finalize_3dsx_path = join(hb_dir, 'custom-install-finalize.3dsx')
|
||||
if isfile(finalize_3dsx_orig_path):
|
||||
self.log('Copying finalize program to ' + finalize_3dsx_path)
|
||||
makedirs(hb_dir, exist_ok=True)
|
||||
copyfile(finalize_3dsx_orig_path, finalize_3dsx_path)
|
||||
copied = True
|
||||
|
||||
self.log('FINAL STEP:')
|
||||
self.log('Run custom-install-finalize through homebrew launcher.')
|
||||
self.log('This will install a ticket and seed if required.')
|
||||
if copied:
|
||||
self.log('custom-install-finalize has been copied to the SD card.')
|
||||
|
||||
return install_state, copied, application_count
|
||||
|
||||
def get_sd_path(self):
|
||||
sd_path = join(self.sd, 'Nintendo 3DS', self.crypto.id0.hex())
|
||||
id1s = []
|
||||
for d in scandir(sd_path):
|
||||
if d.is_dir() and len(d.name) == 32:
|
||||
try:
|
||||
# check if the name can be converted to hex
|
||||
# I'm not sure what the 3DS does if there is a folder that is not a 32-char hex string.
|
||||
bytes.fromhex(d.name)
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
id1s.append(d.name)
|
||||
return [sd_path, id1s]
|
||||
|
||||
def log(self, message, mtype=0, errorname=None, end='\n'):
|
||||
"""Logs an Message with a type. Format is similar to python errors
|
||||
|
||||
There are 3 types of errors, indexed accordingly
|
||||
type 0 = Message
|
||||
type 1 = Warning
|
||||
type 2 = Error
|
||||
|
||||
optionally, errorname can be a custom name as a string to identify errors easily
|
||||
"""
|
||||
if errorname:
|
||||
errorname += ": "
|
||||
else:
|
||||
# No errorname provided
|
||||
errorname = ""
|
||||
types = [
|
||||
"", # Type 0
|
||||
"Warning: ", # Type 1
|
||||
"Error: " # Type 2
|
||||
]
|
||||
# Example: "Warning: UninformativeError: An error occured, try again.""
|
||||
msg_with_type = types[mtype] + errorname + str(message)
|
||||
self.log_lines.append(msg_with_type)
|
||||
self.event.on_log_msg(msg_with_type, end=end)
|
||||
return msg_with_type
|
||||
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser(description='Install a CIA to the SD card for a Nintendo 3DS system.')
|
||||
parser.add_argument('cia', help='CIA files', nargs='+')
|
||||
parser.add_argument('-m', '--movable', help='movable.sed file', required=True)
|
||||
parser.add_argument('-b', '--boot9', help='boot9 file')
|
||||
parser.add_argument('-s', '--seeddb', help='seeddb file')
|
||||
parser.add_argument('--sd', help='path to SD root', required=True)
|
||||
parser.add_argument('--skip-contents', help="don't add contents, only add title info entry", action='store_true')
|
||||
parser.add_argument('--overwrite-saves', help='overwrite existing save files', action='store_true')
|
||||
parser.add_argument('--cifinish-out', help='path for cifinish.bin file, defaults to (SD root)/cifinish.bin')
|
||||
|
||||
print(f'custom-install {__version__} - https://github.com/ihaveamac/custom-install')
|
||||
args = parser.parse_args()
|
||||
|
||||
installer = CustomInstall(boot9=args.boot9,
|
||||
seeddb=args.seeddb,
|
||||
movable=args.movable,
|
||||
sd=args.sd,
|
||||
overwrite_saves=args.overwrite_saves,
|
||||
cifinish_out=args.cifinish_out,
|
||||
skip_contents=(args.skip_contents or False))
|
||||
|
||||
def log_handle(msg, end='\n'):
|
||||
print(msg, end=end)
|
||||
|
||||
def percent_handle(total_percent, total_read, size):
|
||||
installer.log(f' {total_percent:>5.1f}% {total_read:>.1f} MiB / {size:.1f} MiB\r', end='')
|
||||
|
||||
def error(exc):
|
||||
for line in format_exception(*exc):
|
||||
for line2 in line.split('\n')[:-1]:
|
||||
installer.log(line2)
|
||||
|
||||
installer.event.on_log_msg += log_handle
|
||||
installer.event.update_percentage += percent_handle
|
||||
installer.event.on_error += error
|
||||
|
||||
if not installer.check_for_id0():
|
||||
installer.event.on_error(f'Could not find id0 directory {installer.crypto.id0.hex()} '
|
||||
f'inside Nintendo 3DS directory.')
|
||||
|
||||
installer.prepare_titles(args.cia)
|
||||
|
||||
if not args.skip_contents:
|
||||
total_size, free_space = installer.check_size()
|
||||
if total_size > free_space:
|
||||
installer.event.on_log_msg(f'Not enough free space.\n'
|
||||
f'Combined title install size: {total_size / (1024 * 1024):0.2f} MiB\n'
|
||||
f'Free space: {free_space / (1024 * 1024):0.2f} MiB')
|
||||
sys.exit(1)
|
||||
|
||||
result, copied_3dsx, application_count = installer.start()
|
||||
if result is False:
|
||||
# save3ds_fuse failed
|
||||
installer.log('NOTE: Once save3ds_fuse is fixed, run the same command again with --skip-contents')
|
||||
if application_count >= 300:
|
||||
installer.log(f'\n\nWarning: {application_count} installed applications were detected.\n'
|
||||
f'The HOME Menu will only show 300 icons.\n'
|
||||
f'Some applications (not updates or DLC) will need to be deleted.')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
617
custominstall/bin/Cargo.lock
generated
Normal file
617
custominstall/bin/Cargo.lock
generated
Normal file
@@ -0,0 +1,617 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"aes-soft 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"aesni 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"block-cipher 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-soft"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-cipher 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"byteorder 1.3.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"opaque-debug 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aesni"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-cipher 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"opaque-debug 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"const-random 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"libc 0.2.71 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"termion 1.5.5 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-padding 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"byte-tools 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"byteorder 1.3.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"generic-array 0.14.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-cipher"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"generic-array 0.14.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"byte-tools 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byte-tools"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "byte_struct"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"byte_struct_derive 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"generic-array 0.14.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byte_struct_derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"syn 0.15.44 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"num-integer 0.1.43 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"num-traits 0.2.12 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"time 0.1.43 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmac"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-cipher 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"crypto-mac 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"dbl 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"const-random-macro 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"proc-macro-hack 0.5.16 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"getrandom 0.1.14 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"proc-macro-hack 0.5.16 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-mac"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"generic-array 0.14.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"subtle 2.2.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbl"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"generic-array 0.14.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"generic-array 0.14.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fake-simd"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "fuse"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"libc 0.2.71 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"pkg-config 0.3.17 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"thread-scoped 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"time 0.1.43 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"typenum 1.12.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"version_check 0.9.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"unicode-width 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"libc 0.2.71 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"wasi 0.9.0+wasi-snapshot-preview1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"ahash 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"autocfg 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "libsave3ds"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"byte_struct 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"cmac 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"lru 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"sha2 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"hashbrown 0.6.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"num-traits 0.2.12 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numtoa"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-hack"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "0.4.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "0.6.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"getrandom 0.1.14 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"libc 0.2.71 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_chacha 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_core 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_hc 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"ppv-lite86 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_core 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"getrandom 0.1.14 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"rand_core 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.1.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "redox_termios"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "save3ds_fuse"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fuse 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"getopts 0.2.21 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"libc 0.2.71 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"libsave3ds 0.1.0",
|
||||
"stderrlog 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"time 0.1.43 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"block-buffer 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"digest 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"fake-simd 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"opaque-debug 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stderrlog"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"atty 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"chrono 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"termcolor 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"thread_local 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "0.15.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"winapi-util 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termion"
|
||||
version = "1.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"libc 0.2.71 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"numtoa 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"redox_termios 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread-scoped"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"lazy_static 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"unreachable 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.1.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"libc 0.2.71 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "unreachable"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "void"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.9.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[metadata]
|
||||
"checksum aes 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f7001367fde4c768a19d1029f0a8be5abd9308e1119846d5bd9ad26297b8faf5"
|
||||
"checksum aes-soft 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4925647ee64e5056cf231608957ce7c81e12d6d6e316b9ce1404778cc1d35fa7"
|
||||
"checksum aesni 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d050d39b0b7688b3a3254394c3e30a9d66c41dcf9b05b0e2dbdc623f6505d264"
|
||||
"checksum ahash 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)" = "6f33b5018f120946c1dcf279194f238a9f146725593ead1c08fa47ff22b0b5d3"
|
||||
"checksum atty 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)" = "9a7d5b8723950951411ee34d271d99dddcc2035a16ab25310ea2c8cfd4369652"
|
||||
"checksum autocfg 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2"
|
||||
"checksum autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d"
|
||||
"checksum block-buffer 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "dbcf92448676f82bb7a334c58bbce8b0d43580fb5362a9d608b18879d12a3d31"
|
||||
"checksum block-cipher 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "fa136449e765dc7faa244561ccae839c394048667929af599b5d931ebe7b7f10"
|
||||
"checksum block-padding 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5"
|
||||
"checksum byte-tools 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7"
|
||||
"checksum byte_struct 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3bde2e17424d6d3042b950f39de519dfd398c2e08adb1402d3fc10232a17564e"
|
||||
"checksum byte_struct_derive 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7fb6eccde50afec044557d1f1b8776168b7040255390eefffb39fcfd1ab40b2e"
|
||||
"checksum byteorder 1.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de"
|
||||
"checksum cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)" = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
|
||||
"checksum chrono 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)" = "80094f509cf8b5ae86a4966a39b3ff66cd7e2a3e594accec3743ff3fabeab5b2"
|
||||
"checksum cmac 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d9f8f8ba8b9640e29213f152015694e78208e601adf91c72b698460633b15715"
|
||||
"checksum const-random 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "2f1af9ac737b2dd2d577701e59fd09ba34822f6f2ebdb30a7647405d9e55e16a"
|
||||
"checksum const-random-macro 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "25e4c606eb459dd29f7c57b2e0879f2b6f14ee130918c2b78ccb58a9624e6c7a"
|
||||
"checksum crypto-mac 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab"
|
||||
"checksum dbl 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2735145c3b9ba15f2d7a3ae8cdafcbc8c98a7bef7f62afe9d08bd99fbf7130de"
|
||||
"checksum digest 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
|
||||
"checksum fake-simd 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
|
||||
"checksum fuse 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "80e57070510966bfef93662a81cb8aa2b1c7db0964354fa9921434f04b9e8660"
|
||||
"checksum generic-array 0.14.2 (registry+https://github.com/rust-lang/crates.io-index)" = "ac746a5f3bbfdadd6106868134545e684693d54d9d44f6e9588a7d54af0bf980"
|
||||
"checksum getopts 0.2.21 (registry+https://github.com/rust-lang/crates.io-index)" = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
|
||||
"checksum getrandom 0.1.14 (registry+https://github.com/rust-lang/crates.io-index)" = "7abc8dd8451921606d809ba32e95b6111925cd2906060d2dcc29c070220503eb"
|
||||
"checksum hashbrown 0.6.3 (registry+https://github.com/rust-lang/crates.io-index)" = "8e6073d0ca812575946eb5f35ff68dbe519907b25c42530389ff946dc84c6ead"
|
||||
"checksum lazy_static 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)" = "76f033c7ad61445c5b347c7382dd1237847eb1bce590fe50365dcb33d546be73"
|
||||
"checksum libc 0.2.71 (registry+https://github.com/rust-lang/crates.io-index)" = "9457b06509d27052635f90d6466700c65095fdf75409b3fbdd903e988b886f49"
|
||||
"checksum log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b"
|
||||
"checksum log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)" = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7"
|
||||
"checksum lru 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "28e0c685219cd60e49a2796bba7e4fe6523e10daca4fd721e84e7f905093d60c"
|
||||
"checksum num-integer 0.1.43 (registry+https://github.com/rust-lang/crates.io-index)" = "8d59457e662d541ba17869cf51cf177c0b5f0cbf476c66bdc90bf1edac4f875b"
|
||||
"checksum num-traits 0.2.12 (registry+https://github.com/rust-lang/crates.io-index)" = "ac267bcc07f48ee5f8935ab0d24f316fb722d7a1292e2913f0cc196b29ffd611"
|
||||
"checksum numtoa 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
|
||||
"checksum opaque-debug 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c"
|
||||
"checksum pkg-config 0.3.17 (registry+https://github.com/rust-lang/crates.io-index)" = "05da548ad6865900e60eaba7f589cc0783590a92e940c26953ff81ddbab2d677"
|
||||
"checksum ppv-lite86 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "237a5ed80e274dbc66f86bd59c1e25edc039660be53194b5fe0a482e0f2612ea"
|
||||
"checksum proc-macro-hack 0.5.16 (registry+https://github.com/rust-lang/crates.io-index)" = "7e0456befd48169b9f13ef0f0ad46d492cf9d2dbb918bcf38e01eed4ce3ec5e4"
|
||||
"checksum proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)" = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759"
|
||||
"checksum quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)" = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1"
|
||||
"checksum rand 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
|
||||
"checksum rand_chacha 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
|
||||
"checksum rand_core 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
|
||||
"checksum rand_hc 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
|
||||
"checksum redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)" = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84"
|
||||
"checksum redox_termios 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7e891cfe48e9100a70a3b6eb652fef28920c117d366339687bd5576160db0f76"
|
||||
"checksum sha2 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "72377440080fd008550fe9b441e854e43318db116f90181eef92e9ae9aedab48"
|
||||
"checksum stderrlog 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "32e5ee9b90a5452c570a0b0ac1c99ae9498db7e56e33d74366de7f2a7add7f25"
|
||||
"checksum subtle 2.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "502d53007c02d7605a05df1c1a73ee436952781653da5d0bf57ad608f66932c1"
|
||||
"checksum syn 0.15.44 (registry+https://github.com/rust-lang/crates.io-index)" = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5"
|
||||
"checksum termcolor 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bb6bfa289a4d7c5766392812c0a1f4c1ba45afa1ad47803c11e1f407d846d75f"
|
||||
"checksum termion 1.5.5 (registry+https://github.com/rust-lang/crates.io-index)" = "c22cec9d8978d906be5ac94bceb5a010d885c626c4c8855721a4dbd20e3ac905"
|
||||
"checksum thread-scoped 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "bcbb6aa301e5d3b0b5ef639c9a9c7e2f1c944f177b460c04dc24c69b1fa2bd99"
|
||||
"checksum thread_local 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "1697c4b57aeeb7a536b647165a2825faddffb1d3bad386d507709bd51a90bb14"
|
||||
"checksum time 0.1.43 (registry+https://github.com/rust-lang/crates.io-index)" = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438"
|
||||
"checksum typenum 1.12.0 (registry+https://github.com/rust-lang/crates.io-index)" = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33"
|
||||
"checksum unicode-width 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479"
|
||||
"checksum unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc"
|
||||
"checksum unreachable 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56"
|
||||
"checksum version_check 0.9.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed"
|
||||
"checksum void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
|
||||
"checksum wasi 0.9.0+wasi-snapshot-preview1 (registry+https://github.com/rust-lang/crates.io-index)" = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
|
||||
"checksum winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6"
|
||||
"checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
"checksum winapi-util 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
|
||||
"checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
11
custominstall/bin/README
Normal file
11
custominstall/bin/README
Normal file
@@ -0,0 +1,11 @@
|
||||
save3ds_fuse for win32 and darwin built with commit 568b0597b17da0c8cfbd345bab27176cd84bd883
|
||||
in repository https://github.com/wwylele/save3ds
|
||||
|
||||
win32 binary built on Windows 10, version 21H2 64-bit with `cargo build --release --target=i686-pc-windows-msvc`.
|
||||
|
||||
darwin binary built on macOS 12.2 with:
|
||||
* `cargo build --target=aarch64-apple-darwin --no-default-features --release`
|
||||
* `cargo build --target=x86_64-apple-darwin --no-default-features --release`
|
||||
* Then a universal binary is built: `lipo -create -output save3ds_fuse-universal2 target/aarch64-apple-darwin/release/save3ds_fuse target/x86_64-apple-darwin/release/save3ds_fuse`
|
||||
|
||||
linux binary must be provided by the user.
|
||||
BIN
custominstall/bin/darwin/save3ds_fuse
Executable file
BIN
custominstall/bin/darwin/save3ds_fuse
Executable file
Binary file not shown.
BIN
custominstall/bin/win32/save3ds_fuse.exe
Normal file
BIN
custominstall/bin/win32/save3ds_fuse.exe
Normal file
Binary file not shown.
BIN
custominstall/custom-install-finalize.3dsx
Normal file
BIN
custominstall/custom-install-finalize.3dsx
Normal file
Binary file not shown.
760
custominstall/gui.py
Normal file
760
custominstall/gui.py
Normal file
@@ -0,0 +1,760 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of custom-install.py.
|
||||
#
|
||||
# custom-install is copyright (c) 2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from os import environ, scandir
|
||||
from os.path import abspath, basename, dirname, join, isfile
|
||||
import sys
|
||||
from threading import Thread, Lock
|
||||
from time import strftime
|
||||
from traceback import format_exception
|
||||
import tkinter as tk
|
||||
import tkinter.ttk as ttk
|
||||
import tkinter.filedialog as fd
|
||||
import tkinter.messagebox as mb
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyctr.crypto import MissingSeedError, CryptoEngine, load_seeddb
|
||||
from pyctr.crypto.engine import b9_paths
|
||||
from pyctr.util import config_dirs
|
||||
from pyctr.type.cdn import CDNError
|
||||
from pyctr.type.cia import CIAError
|
||||
from pyctr.type.tmd import TitleMetadataError
|
||||
|
||||
from . import __version__
|
||||
from .__main__ import CustomInstall, load_cifinish, InvalidCIFinishError, InstallStatus, save3ds_fuse_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from os import PathLike
|
||||
from typing import Dict, List, Union
|
||||
|
||||
frozen = getattr(sys, 'frozen', None)
|
||||
is_windows = sys.platform == 'win32'
|
||||
taskbar = None
|
||||
if is_windows:
|
||||
if frozen:
|
||||
# attempt to fix loading tcl/tk when running from a path with non-latin characters
|
||||
tkinter_path = dirname(tk.__file__)
|
||||
tcl_path = join(tkinter_path, 'tcl8.6')
|
||||
environ['TCL_LIBRARY'] = 'lib/tkinter/tcl8.6'
|
||||
try:
|
||||
import comtypes.client as cc
|
||||
|
||||
tbl = cc.GetModule('TaskbarLib.tlb')
|
||||
|
||||
taskbar = cc.CreateObject('{56FDF344-FD6D-11D0-958A-006097C9A090}', interface=tbl.ITaskbarList3)
|
||||
taskbar.HrInit()
|
||||
except (ModuleNotFoundError, UnicodeEncodeError, AttributeError):
|
||||
pass
|
||||
|
||||
file_parent = dirname(abspath(__file__))
|
||||
|
||||
# automatically load boot9 if it's in the current directory
|
||||
b9_paths.insert(0, join(file_parent, 'boot9.bin'))
|
||||
b9_paths.insert(0, join(file_parent, 'boot9_prot.bin'))
|
||||
|
||||
seeddb_paths = [join(x, 'seeddb.bin') for x in config_dirs]
|
||||
try:
|
||||
seeddb_paths.insert(0, environ['SEEDDB_PATH'])
|
||||
except KeyError:
|
||||
pass
|
||||
# automatically load seeddb if it's in the current directory
|
||||
seeddb_paths.insert(0, join(file_parent, 'seeddb.bin'))
|
||||
|
||||
|
||||
def clamp(n, smallest, largest):
|
||||
return max(smallest, min(n, largest))
|
||||
|
||||
|
||||
def find_first_file(paths):
|
||||
for p in paths:
|
||||
if isfile(p):
|
||||
return p
|
||||
|
||||
|
||||
# find boot9, seeddb, and movable.sed to auto-select in the gui
|
||||
default_b9_path = find_first_file(b9_paths)
|
||||
default_seeddb_path = find_first_file(seeddb_paths)
|
||||
default_movable_sed_path = find_first_file([join(file_parent, 'movable.sed')])
|
||||
|
||||
if default_seeddb_path:
|
||||
load_seeddb(default_seeddb_path)
|
||||
|
||||
statuses = {
|
||||
InstallStatus.Waiting: 'Waiting',
|
||||
InstallStatus.Starting: 'Starting',
|
||||
InstallStatus.Writing: 'Writing',
|
||||
InstallStatus.Finishing: 'Finishing',
|
||||
InstallStatus.Done: 'Done',
|
||||
InstallStatus.Failed: 'Failed',
|
||||
}
|
||||
|
||||
|
||||
class ConsoleFrame(ttk.Frame):
|
||||
def __init__(self, parent: tk.BaseWidget = None, starting_lines: 'List[str]' = None):
|
||||
super().__init__(parent)
|
||||
self.parent = parent
|
||||
|
||||
self.rowconfigure(0, weight=1)
|
||||
self.columnconfigure(0, weight=1)
|
||||
|
||||
scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL)
|
||||
scrollbar.grid(row=0, column=1, sticky=tk.NSEW)
|
||||
|
||||
self.text = tk.Text(self, highlightthickness=0, wrap='word', yscrollcommand=scrollbar.set)
|
||||
self.text.grid(row=0, column=0, sticky=tk.NSEW)
|
||||
|
||||
scrollbar.config(command=self.text.yview)
|
||||
|
||||
if starting_lines:
|
||||
for l in starting_lines:
|
||||
self.text.insert(tk.END, l + '\n')
|
||||
|
||||
self.text.see(tk.END)
|
||||
self.text.configure(state=tk.DISABLED)
|
||||
|
||||
def log(self, *message, end='\n', sep=' '):
|
||||
self.text.configure(state=tk.NORMAL)
|
||||
self.text.insert(tk.END, sep.join(message) + end)
|
||||
self.text.see(tk.END)
|
||||
self.text.configure(state=tk.DISABLED)
|
||||
|
||||
|
||||
def simple_listbox_frame(parent, title: 'str', items: 'List[str]'):
|
||||
frame = ttk.LabelFrame(parent, text=title)
|
||||
frame.rowconfigure(0, weight=1)
|
||||
frame.columnconfigure(0, weight=1)
|
||||
|
||||
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL)
|
||||
scrollbar.grid(row=0, column=1, sticky=tk.NSEW)
|
||||
|
||||
box = tk.Listbox(frame, highlightthickness=0, yscrollcommand=scrollbar.set, selectmode=tk.EXTENDED)
|
||||
box.grid(row=0, column=0, sticky=tk.NSEW)
|
||||
scrollbar.config(command=box.yview)
|
||||
|
||||
box.insert(tk.END, *items)
|
||||
|
||||
box.config(height=clamp(len(items), 3, 10))
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
class TitleReadFailResults(tk.Toplevel):
|
||||
def __init__(self, parent: tk.Tk = None, *, failed: 'Dict[str, str]'):
|
||||
super().__init__(parent)
|
||||
self.parent = parent
|
||||
|
||||
self.wm_withdraw()
|
||||
self.wm_transient(self.parent)
|
||||
self.grab_set()
|
||||
self.wm_title('Failed to add titles')
|
||||
|
||||
self.rowconfigure(0, weight=1)
|
||||
self.columnconfigure(0, weight=1)
|
||||
|
||||
outer_container = ttk.Frame(self)
|
||||
outer_container.grid(sticky=tk.NSEW)
|
||||
outer_container.rowconfigure(0, weight=0)
|
||||
outer_container.rowconfigure(1, weight=1)
|
||||
outer_container.columnconfigure(0, weight=1)
|
||||
|
||||
message_label = ttk.Label(outer_container, text="Some titles couldn't be added.")
|
||||
message_label.grid(row=0, column=0, sticky=tk.NSEW, padx=10, pady=10)
|
||||
|
||||
treeview_frame = ttk.Frame(outer_container)
|
||||
treeview_frame.grid(row=1, column=0, sticky=tk.NSEW)
|
||||
treeview_frame.rowconfigure(0, weight=1)
|
||||
treeview_frame.columnconfigure(0, weight=1)
|
||||
|
||||
treeview_scrollbar = ttk.Scrollbar(treeview_frame, orient=tk.VERTICAL)
|
||||
treeview_scrollbar.grid(row=0, column=1, sticky=tk.NSEW)
|
||||
|
||||
treeview = ttk.Treeview(treeview_frame, yscrollcommand=treeview_scrollbar.set)
|
||||
treeview.grid(row=0, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
|
||||
treeview.configure(columns=('filepath', 'reason'), show='headings')
|
||||
|
||||
treeview.column('filepath', width=200, anchor=tk.W)
|
||||
treeview.heading('filepath', text='File path')
|
||||
treeview.column('reason', width=400, anchor=tk.W)
|
||||
treeview.heading('reason', text='Reason')
|
||||
|
||||
treeview_scrollbar.configure(command=treeview.yview)
|
||||
|
||||
for path, reason in failed.items():
|
||||
treeview.insert('', tk.END, text=path, iid=path, values=(basename(path), reason))
|
||||
|
||||
ok_frame = ttk.Frame(outer_container)
|
||||
ok_frame.grid(row=2, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
|
||||
ok_frame.rowconfigure(0, weight=1)
|
||||
ok_frame.columnconfigure(0, weight=1)
|
||||
|
||||
ok_button = ttk.Button(ok_frame, text='OK', command=self.destroy)
|
||||
ok_button.grid(row=0, column=0)
|
||||
|
||||
self.wm_deiconify()
|
||||
|
||||
|
||||
class InstallResults(tk.Toplevel):
|
||||
def __init__(self, parent: tk.Tk = None, *, install_state: 'Dict[str, List[str]]', copied_3dsx: bool,
|
||||
application_count: int):
|
||||
super().__init__(parent)
|
||||
self.parent = parent
|
||||
|
||||
self.wm_withdraw()
|
||||
self.wm_transient(self.parent)
|
||||
self.grab_set()
|
||||
self.wm_title('Install results')
|
||||
|
||||
self.rowconfigure(0, weight=1)
|
||||
self.columnconfigure(0, weight=1)
|
||||
|
||||
outer_container = ttk.Frame(self)
|
||||
outer_container.grid(sticky=tk.NSEW)
|
||||
outer_container.rowconfigure(0, weight=0)
|
||||
outer_container.columnconfigure(0, weight=1)
|
||||
|
||||
if install_state['failed'] and install_state['installed']:
|
||||
# some failed and some worked
|
||||
message = ('Some titles were installed, some failed. Please check the output for more details.\n'
|
||||
'The ones that were installed can be finished with custom-install-finalize.')
|
||||
elif install_state['failed'] and not install_state['installed']:
|
||||
# all failed
|
||||
message = 'All titles failed to install. Please check the output for more details.'
|
||||
elif install_state['installed'] and not install_state['failed']:
|
||||
# all worked
|
||||
message = 'All titles were installed.'
|
||||
else:
|
||||
message = 'Nothing was installed.'
|
||||
|
||||
if install_state['installed']:
|
||||
if copied_3dsx:
|
||||
message += '\n\ncustom-install-finalize has been copied to the SD card.'
|
||||
else:
|
||||
message += ('\n\nNote: custom-install-finalize was not copied.\n'
|
||||
'You can either manually copy the 3dsx to your SD card, or use GodMode9 to finish the install.')
|
||||
|
||||
if application_count >= 300:
|
||||
message += (f'\n\nWarning: {application_count} installed applications were detected.\n'
|
||||
f'The HOME Menu will only show 300 icons.\n'
|
||||
f'Some applications (not updates or DLC) will need to be deleted.')
|
||||
|
||||
message_label = ttk.Label(outer_container, text=message)
|
||||
message_label.grid(row=0, column=0, sticky=tk.NSEW, padx=10, pady=10)
|
||||
|
||||
if install_state['installed']:
|
||||
outer_container.rowconfigure(1, weight=1)
|
||||
frame = simple_listbox_frame(outer_container, 'Installed', install_state['installed'])
|
||||
frame.grid(row=1, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
|
||||
|
||||
if install_state['failed']:
|
||||
outer_container.rowconfigure(2, weight=1)
|
||||
frame = simple_listbox_frame(outer_container, 'Failed', install_state['failed'])
|
||||
frame.grid(row=2, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
|
||||
|
||||
ok_frame = ttk.Frame(outer_container)
|
||||
ok_frame.grid(row=3, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
|
||||
ok_frame.rowconfigure(0, weight=1)
|
||||
ok_frame.columnconfigure(0, weight=1)
|
||||
|
||||
ok_button = ttk.Button(ok_frame, text='OK', command=self.destroy)
|
||||
ok_button.grid(row=0, column=0)
|
||||
|
||||
self.wm_deiconify()
|
||||
|
||||
|
||||
class CustomInstallGUI(ttk.Frame):
|
||||
console = None
|
||||
b9_loaded = False
|
||||
|
||||
def __init__(self, parent: tk.Tk = None):
|
||||
super().__init__(parent)
|
||||
self.parent = parent
|
||||
|
||||
# readers to give to CustomInstall at the install
|
||||
self.readers = {}
|
||||
|
||||
self.lock = Lock()
|
||||
|
||||
self.log_messages = []
|
||||
|
||||
self.hwnd = None # will be set later
|
||||
|
||||
self.rowconfigure(2, weight=1)
|
||||
self.columnconfigure(0, weight=1)
|
||||
|
||||
if taskbar:
|
||||
# this is so progress can be shown in the taskbar
|
||||
def setup_tab():
|
||||
self.hwnd = int(parent.wm_frame(), 16)
|
||||
taskbar.ActivateTab(self.hwnd)
|
||||
|
||||
self.after(100, setup_tab)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# create file pickers for base files
|
||||
file_pickers = ttk.Frame(self)
|
||||
file_pickers.grid(row=0, column=0, sticky=tk.EW)
|
||||
file_pickers.columnconfigure(1, weight=1)
|
||||
|
||||
self.file_picker_textboxes = {}
|
||||
|
||||
def sd_callback():
|
||||
f = fd.askdirectory(parent=parent, title='Select SD root (the directory or drive that contains '
|
||||
'"Nintendo 3DS")', initialdir=file_parent, mustexist=True)
|
||||
if f:
|
||||
cifinish_path = join(f, 'cifinish.bin')
|
||||
try:
|
||||
load_cifinish(cifinish_path)
|
||||
except InvalidCIFinishError:
|
||||
self.show_error(f'{cifinish_path} was corrupt!\n\n'
|
||||
f'This could mean an issue with the SD card or the filesystem. Please check it for errors.\n'
|
||||
f'It is also possible, though less likely, to be an issue with custom-install.\n\n'
|
||||
f'Stopping now to prevent possible issues. If you want to try again, delete cifinish.bin from the SD card and re-run custom-install.')
|
||||
return
|
||||
|
||||
sd_selected.delete('1.0', tk.END)
|
||||
sd_selected.insert(tk.END, f)
|
||||
|
||||
for filename in ['boot9.bin', 'seeddb.bin', 'movable.sed']:
|
||||
path = auto_input_filename(self, f, filename)
|
||||
if filename == 'boot9.bin':
|
||||
self.check_b9_loaded()
|
||||
self.enable_buttons()
|
||||
if filename == 'seeddb.bin':
|
||||
load_seeddb(path)
|
||||
|
||||
|
||||
sd_type_label = ttk.Label(file_pickers, text='SD root')
|
||||
sd_type_label.grid(row=0, column=0)
|
||||
|
||||
sd_selected = tk.Text(file_pickers, wrap='none', height=1)
|
||||
sd_selected.grid(row=0, column=1, sticky=tk.EW)
|
||||
|
||||
sd_button = ttk.Button(file_pickers, text='...', command=sd_callback)
|
||||
sd_button.grid(row=0, column=2)
|
||||
|
||||
self.file_picker_textboxes['sd'] = sd_selected
|
||||
|
||||
def auto_input_filename(self, f, filename):
|
||||
sd_msed_path = find_first_file(
|
||||
[join(f, "gm9", "out", filename), join(f, "boot9strap", filename), join(f, filename)]
|
||||
)
|
||||
if sd_msed_path:
|
||||
self.log('Found ' + filename + ' on SD card at ' + sd_msed_path)
|
||||
if filename.endswith('bin'):
|
||||
filename = filename.split('.')[0]
|
||||
box = self.file_picker_textboxes[filename]
|
||||
box.delete('1.0', tk.END)
|
||||
box.insert(tk.END, sd_msed_path)
|
||||
return sd_msed_path
|
||||
# This feels so wrong.
|
||||
def create_required_file_picker(type_name, types, default, row, callback=lambda filename: None):
|
||||
def internal_callback():
|
||||
f = fd.askopenfilename(parent=parent, title='Select ' + type_name, filetypes=types,
|
||||
initialdir=file_parent)
|
||||
if f:
|
||||
selected.delete('1.0', tk.END)
|
||||
selected.insert(tk.END, f)
|
||||
callback(f)
|
||||
|
||||
type_label = ttk.Label(file_pickers, text=type_name)
|
||||
type_label.grid(row=row, column=0)
|
||||
|
||||
selected = tk.Text(file_pickers, wrap='none', height=1)
|
||||
selected.grid(row=row, column=1, sticky=tk.EW)
|
||||
if default:
|
||||
selected.insert(tk.END, default)
|
||||
|
||||
button = ttk.Button(file_pickers, text='...', command=internal_callback)
|
||||
button.grid(row=row, column=2)
|
||||
|
||||
self.file_picker_textboxes[type_name] = selected
|
||||
|
||||
def b9_callback(path: 'Union[PathLike, bytes, str]'):
|
||||
self.check_b9_loaded()
|
||||
self.enable_buttons()
|
||||
|
||||
def seeddb_callback(path: 'Union[PathLike, bytes, str]'):
|
||||
load_seeddb(path)
|
||||
|
||||
create_required_file_picker('boot9', [('boot9 file', '*.bin')], default_b9_path, 1, b9_callback)
|
||||
create_required_file_picker('seeddb', [('seeddb file', '*.bin')], default_seeddb_path, 2, seeddb_callback)
|
||||
create_required_file_picker('movable.sed', [('movable.sed file', '*.sed')], default_movable_sed_path, 3)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# create buttons to add cias
|
||||
titlelist_buttons = ttk.Frame(self)
|
||||
titlelist_buttons.grid(row=1, column=0)
|
||||
|
||||
def add_cias_callback():
|
||||
files = fd.askopenfilenames(parent=parent, title='Select CIA files', filetypes=[('CIA files', '*.cia')],
|
||||
initialdir=file_parent)
|
||||
results = {}
|
||||
for f in files:
|
||||
success, reason = self.add_cia(f)
|
||||
if not success:
|
||||
results[f] = reason
|
||||
|
||||
if results:
|
||||
title_read_fail_window = TitleReadFailResults(self.parent, failed=results)
|
||||
title_read_fail_window.focus()
|
||||
self.sort_treeview()
|
||||
|
||||
add_cias = ttk.Button(titlelist_buttons, text='Add CIAs', command=add_cias_callback)
|
||||
add_cias.grid(row=0, column=0)
|
||||
|
||||
def add_cdn_callback():
|
||||
d = fd.askdirectory(parent=parent, title='Select folder containing title contents in CDN format',
|
||||
initialdir=file_parent)
|
||||
if d:
|
||||
if isfile(join(d, 'tmd')):
|
||||
success, reason = self.add_cia(d)
|
||||
if not success:
|
||||
self.show_error(f"Couldn't add {basename(d)}: {reason}")
|
||||
else:
|
||||
self.sort_treeview()
|
||||
else:
|
||||
self.show_error('tmd file not found in the CDN directory:\n' + d)
|
||||
|
||||
add_cdn = ttk.Button(titlelist_buttons, text='Add CDN title folder', command=add_cdn_callback)
|
||||
add_cdn.grid(row=0, column=1)
|
||||
|
||||
def add_dirs_callback():
|
||||
d = fd.askdirectory(parent=parent, title='Select folder containing CIA files', initialdir=file_parent)
|
||||
if d:
|
||||
results = {}
|
||||
for f in scandir(d):
|
||||
if f.name.lower().endswith('.cia'):
|
||||
success, reason = self.add_cia(f.path)
|
||||
if not success:
|
||||
results[f] = reason
|
||||
|
||||
if results:
|
||||
title_read_fail_window = TitleReadFailResults(self.parent, failed=results)
|
||||
title_read_fail_window.focus()
|
||||
self.sort_treeview()
|
||||
|
||||
add_dirs = ttk.Button(titlelist_buttons, text='Add folder', command=add_dirs_callback)
|
||||
add_dirs.grid(row=0, column=2)
|
||||
|
||||
def remove_selected_callback():
|
||||
for entry in self.treeview.selection():
|
||||
self.remove_cia(entry)
|
||||
|
||||
remove_selected = ttk.Button(titlelist_buttons, text='Remove selected', command=remove_selected_callback)
|
||||
remove_selected.grid(row=0, column=3)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# create treeview
|
||||
treeview_frame = ttk.Frame(self)
|
||||
treeview_frame.grid(row=2, column=0, sticky=tk.NSEW)
|
||||
treeview_frame.rowconfigure(0, weight=1)
|
||||
treeview_frame.columnconfigure(0, weight=1)
|
||||
|
||||
treeview_scrollbar = ttk.Scrollbar(treeview_frame, orient=tk.VERTICAL)
|
||||
treeview_scrollbar.grid(row=0, column=1, sticky=tk.NSEW)
|
||||
|
||||
self.treeview = ttk.Treeview(treeview_frame, yscrollcommand=treeview_scrollbar.set)
|
||||
self.treeview.grid(row=0, column=0, sticky=tk.NSEW)
|
||||
self.treeview.configure(columns=('filepath', 'titleid', 'titlename', 'status'), show='headings')
|
||||
|
||||
self.treeview.column('filepath', width=200, anchor=tk.W)
|
||||
self.treeview.heading('filepath', text='File path')
|
||||
self.treeview.column('titleid', width=70, anchor=tk.W)
|
||||
self.treeview.heading('titleid', text='Title ID')
|
||||
self.treeview.column('titlename', width=150, anchor=tk.W)
|
||||
self.treeview.heading('titlename', text='Title name')
|
||||
self.treeview.column('status', width=20, anchor=tk.W)
|
||||
self.treeview.heading('status', text='Status')
|
||||
|
||||
treeview_scrollbar.configure(command=self.treeview.yview)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# create progressbar
|
||||
|
||||
self.progressbar = ttk.Progressbar(self, orient=tk.HORIZONTAL, mode='determinate')
|
||||
self.progressbar.grid(row=3, column=0, sticky=tk.NSEW)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# create start and console buttons
|
||||
|
||||
control_frame = ttk.Frame(self)
|
||||
control_frame.grid(row=4, column=0)
|
||||
|
||||
self.skip_contents_var = tk.IntVar()
|
||||
skip_contents_checkbox = ttk.Checkbutton(control_frame, text='Skip contents (only add to title database)',
|
||||
variable=self.skip_contents_var)
|
||||
skip_contents_checkbox.grid(row=0, column=0)
|
||||
|
||||
self.overwrite_saves_var = tk.IntVar()
|
||||
overwrite_saves_checkbox = ttk.Checkbutton(control_frame, text='Overwrite existing saves',
|
||||
variable=self.overwrite_saves_var)
|
||||
overwrite_saves_checkbox.grid(row=0, column=1)
|
||||
|
||||
show_console = ttk.Button(control_frame, text='Show console', command=self.open_console)
|
||||
show_console.grid(row=0, column=2)
|
||||
|
||||
start = ttk.Button(control_frame, text='Start install', command=self.start_install)
|
||||
start.grid(row=0, column=3)
|
||||
|
||||
self.status_label = ttk.Label(self, text='Waiting...')
|
||||
self.status_label.grid(row=5, column=0, sticky=tk.NSEW)
|
||||
|
||||
self.log(f'custom-install {__version__} - https://github.com/ihaveamac/custom-install', status=False)
|
||||
|
||||
if is_windows and not taskbar:
|
||||
self.log('Note: Could not load taskbar lib.')
|
||||
self.log('Note: Progress will not be shown in the Windows taskbar.')
|
||||
|
||||
self.log('Ready.')
|
||||
|
||||
self.require_boot9 = (add_cias, add_cdn, add_dirs, remove_selected, start)
|
||||
|
||||
self.disable_buttons()
|
||||
self.check_b9_loaded()
|
||||
self.enable_buttons()
|
||||
if not self.b9_loaded:
|
||||
self.log('Note: boot9 was not auto-detected. Please choose it before adding any titles.')
|
||||
|
||||
def sort_treeview(self):
|
||||
l = [(self.treeview.set(k, 'titlename'), k) for k in self.treeview.get_children()]
|
||||
# sort by title name
|
||||
l.sort(key=lambda x: x[0].lower())
|
||||
|
||||
for idx, pair in enumerate(l):
|
||||
self.treeview.move(pair[1], '', idx)
|
||||
|
||||
def check_b9_loaded(self):
|
||||
if not self.b9_loaded:
|
||||
boot9 = self.file_picker_textboxes['boot9'].get('1.0', tk.END).strip()
|
||||
try:
|
||||
tmp_crypto = CryptoEngine(boot9=boot9)
|
||||
self.b9_loaded = tmp_crypto.b9_keys_set
|
||||
except:
|
||||
return False
|
||||
return self.b9_loaded
|
||||
|
||||
def update_status(self, path: 'Union[PathLike, bytes, str]', status: InstallStatus):
|
||||
self.treeview.set(path, 'status', statuses[status])
|
||||
|
||||
def add_cia(self, path):
|
||||
if not self.check_b9_loaded():
|
||||
# this shouldn't happen
|
||||
return False, 'Please choose boot9 first'
|
||||
path = abspath(path)
|
||||
if path in self.readers:
|
||||
return False, 'File already in list'
|
||||
try:
|
||||
reader = CustomInstall.get_reader(path)
|
||||
except (CIAError, CDNError, TitleMetadataError):
|
||||
return False, 'Failed to read as a CIA or CDN title, probably corrupt'
|
||||
except MissingSeedError:
|
||||
return False, 'Latest seeddb.bin is required, check the README for details'
|
||||
except Exception as e:
|
||||
return False, f'Exception occurred: {type(e).__name__}: {e}'
|
||||
|
||||
if reader.tmd.title_id.startswith('00048'):
|
||||
return False, 'DSiWare is not supported'
|
||||
try:
|
||||
title_name = reader.contents[0].exefs.icon.get_app_title().short_desc
|
||||
except:
|
||||
title_name = '(No title)'
|
||||
self.treeview.insert('', tk.END, text=path, iid=path,
|
||||
values=(path, reader.tmd.title_id, title_name, statuses[InstallStatus.Waiting]))
|
||||
self.readers[path] = reader
|
||||
return True, ''
|
||||
|
||||
def remove_cia(self, path):
|
||||
self.treeview.delete(path)
|
||||
del self.readers[path]
|
||||
|
||||
def open_console(self):
|
||||
if self.console:
|
||||
self.console.parent.lift()
|
||||
self.console.focus()
|
||||
else:
|
||||
console_window = tk.Toplevel()
|
||||
console_window.title('custom-install Console')
|
||||
|
||||
self.console = ConsoleFrame(console_window, self.log_messages)
|
||||
self.console.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
def close():
|
||||
with self.lock:
|
||||
try:
|
||||
console_window.destroy()
|
||||
except:
|
||||
pass
|
||||
self.console = None
|
||||
|
||||
console_window.focus()
|
||||
|
||||
console_window.protocol('WM_DELETE_WINDOW', close)
|
||||
|
||||
def log(self, line, status=True):
|
||||
with self.lock:
|
||||
log_msg = f"{strftime('%H:%M:%S')} - {line}"
|
||||
self.log_messages.append(log_msg)
|
||||
if self.console:
|
||||
self.console.log(log_msg)
|
||||
|
||||
if status:
|
||||
self.status_label.config(text=line)
|
||||
|
||||
print(log_msg)
|
||||
|
||||
def show_error(self, message):
|
||||
mb.showerror('Error', message, parent=self.parent)
|
||||
|
||||
def ask_warning(self, message):
|
||||
return mb.askokcancel('Warning', message, parent=self.parent)
|
||||
|
||||
def show_info(self, message):
|
||||
mb.showinfo('Info', message, parent=self.parent)
|
||||
|
||||
def disable_buttons(self):
|
||||
for b in self.require_boot9:
|
||||
b.config(state=tk.DISABLED)
|
||||
for b in self.file_picker_textboxes.values():
|
||||
b.config(state=tk.DISABLED)
|
||||
|
||||
def enable_buttons(self):
|
||||
if self.b9_loaded:
|
||||
for b in self.require_boot9:
|
||||
b.config(state=tk.NORMAL)
|
||||
for b in self.file_picker_textboxes.values():
|
||||
b.config(state=tk.NORMAL)
|
||||
|
||||
def start_install(self):
|
||||
sd_root = self.file_picker_textboxes['sd'].get('1.0', tk.END).strip()
|
||||
seeddb = self.file_picker_textboxes['seeddb'].get('1.0', tk.END).strip()
|
||||
movable_sed = self.file_picker_textboxes['movable.sed'].get('1.0', tk.END).strip()
|
||||
|
||||
if not sd_root:
|
||||
self.show_error('SD root is not specified.')
|
||||
return
|
||||
if not movable_sed:
|
||||
self.show_error('movable.sed is not specified.')
|
||||
return
|
||||
|
||||
if not seeddb:
|
||||
if not self.ask_warning('seeddb was not specified. Titles that require it will fail to install.\n'
|
||||
'Continue?'):
|
||||
return
|
||||
|
||||
if not len(self.readers):
|
||||
self.show_error('There are no titles added to install.')
|
||||
return
|
||||
|
||||
for path in self.readers.keys():
|
||||
self.update_status(path, InstallStatus.Waiting)
|
||||
self.disable_buttons()
|
||||
|
||||
if taskbar:
|
||||
taskbar.SetProgressState(self.hwnd, tbl.TBPF_NORMAL)
|
||||
|
||||
installer = CustomInstall(movable=movable_sed,
|
||||
sd=sd_root,
|
||||
skip_contents=self.skip_contents_var.get() == 1,
|
||||
overwrite_saves=self.overwrite_saves_var.get() == 1)
|
||||
|
||||
if not installer.check_for_id0():
|
||||
self.show_error(f'id0 {installer.crypto.id0.hex()} was not found inside "Nintendo 3DS" on the SD card.\n'
|
||||
f'\n'
|
||||
f'Before using custom-install, you should use this SD card on the appropriate console.\n'
|
||||
f'\n'
|
||||
f'Otherwise, make sure the correct movable.sed is being used.')
|
||||
return
|
||||
|
||||
self.log('Starting install...')
|
||||
|
||||
# use the treeview which has been sorted alphabetically
|
||||
readers_final = []
|
||||
for k in self.treeview.get_children():
|
||||
filepath = self.treeview.set(k, 'filepath')
|
||||
readers_final.append((self.readers[filepath], filepath))
|
||||
|
||||
installer.readers = readers_final
|
||||
|
||||
finished_percent = 0
|
||||
max_percentage = 100 * len(self.readers)
|
||||
self.progressbar.config(maximum=max_percentage)
|
||||
|
||||
def ci_on_log_msg(message, *args, **kwargs):
|
||||
# ignoring end
|
||||
self.log(message)
|
||||
|
||||
def ci_update_percentage(total_percent, total_read, size):
|
||||
self.progressbar.config(value=total_percent + finished_percent)
|
||||
if taskbar:
|
||||
taskbar.SetProgressValue(self.hwnd, int(total_percent + finished_percent), max_percentage)
|
||||
|
||||
def ci_on_error(exc):
|
||||
if taskbar:
|
||||
taskbar.SetProgressState(self.hwnd, tbl.TBPF_ERROR)
|
||||
for line in format_exception(*exc):
|
||||
for line2 in line.split('\n')[:-1]:
|
||||
installer.log(line2)
|
||||
self.show_error('An error occurred during installation.')
|
||||
self.open_console()
|
||||
|
||||
def ci_on_cia_start(idx):
|
||||
nonlocal finished_percent
|
||||
finished_percent = idx * 100
|
||||
if taskbar:
|
||||
taskbar.SetProgressValue(self.hwnd, finished_percent, max_percentage)
|
||||
|
||||
installer.event.on_log_msg += ci_on_log_msg
|
||||
installer.event.update_percentage += ci_update_percentage
|
||||
installer.event.on_error += ci_on_error
|
||||
installer.event.on_cia_start += ci_on_cia_start
|
||||
installer.event.update_status += self.update_status
|
||||
|
||||
if self.skip_contents_var.get() != 1:
|
||||
total_size, free_space = installer.check_size()
|
||||
if total_size > free_space:
|
||||
self.show_error(f'Not enough free space.\n'
|
||||
f'Combined title install size: {total_size / (1024 * 1024):0.2f} MiB\n'
|
||||
f'Free space: {free_space / (1024 * 1024):0.2f} MiB')
|
||||
self.enable_buttons()
|
||||
return
|
||||
|
||||
def install():
|
||||
try:
|
||||
result, copied_3dsx, application_count = installer.start()
|
||||
if result:
|
||||
result_window = InstallResults(self.parent,
|
||||
install_state=result,
|
||||
copied_3dsx=copied_3dsx,
|
||||
application_count=application_count)
|
||||
result_window.focus()
|
||||
elif result is None:
|
||||
self.show_error("An error occurred when trying to run save3ds_fuse.\n"
|
||||
"Either title.db doesn't exist, or save3ds_fuse couldn't be run.")
|
||||
self.open_console()
|
||||
except:
|
||||
installer.event.on_error(sys.exc_info())
|
||||
finally:
|
||||
self.enable_buttons()
|
||||
|
||||
Thread(target=install).start()
|
||||
|
||||
|
||||
def main():
|
||||
if not (save3ds_fuse_path and isfile(save3ds_fuse_path)):
|
||||
mb.showerror('Error', "Couldn't find save3ds_fuse. Please place it PATH.")
|
||||
return
|
||||
|
||||
window = tk.Tk()
|
||||
window.title(f'custom-install {__version__}')
|
||||
frame = CustomInstallGUI(window)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
window.mainloop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
BIN
custominstall/title.db.gz
Normal file
BIN
custominstall/title.db.gz
Normal file
Binary file not shown.
34
default.nix
Normal file
34
default.nix
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
pkgs ? import <nixpkgs> { },
|
||||
# just so i can use the same pinned version as the flake...
|
||||
pyctr ? (
|
||||
let
|
||||
flakeLock = builtins.fromJSON (builtins.readFile ./flake.lock);
|
||||
pyctr-repo = import (builtins.fetchTarball (
|
||||
with flakeLock.nodes.pyctr.locked;
|
||||
{
|
||||
url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz";
|
||||
}
|
||||
)) { inherit pkgs; };
|
||||
in
|
||||
pyctr-repo.pyctr
|
||||
),
|
||||
save3ds ? (
|
||||
let
|
||||
flakeLock = builtins.fromJSON (builtins.readFile ./flake.lock);
|
||||
hax-nur-repo = import (builtins.fetchTarball (
|
||||
with flakeLock.nodes.hax-nur.locked;
|
||||
{
|
||||
url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz";
|
||||
}
|
||||
)) { inherit pkgs; };
|
||||
in
|
||||
hax-nur-repo.save3ds
|
||||
),
|
||||
}:
|
||||
|
||||
rec {
|
||||
custominstall = pkgs.python3Packages.callPackage ./package.nix {
|
||||
inherit pyctr save3ds;
|
||||
};
|
||||
}
|
||||
7
extras/windows-quickstart.txt
Normal file
7
extras/windows-quickstart.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Run ci-gui to bring up the custom-install gui.
|
||||
Select your SD card root, boot9, seeddb, and movable.sed files.
|
||||
In some cases these will be automatically selected for you.
|
||||
|
||||
Add the CIA files and click "Start install".
|
||||
|
||||
Once it's finished, start up the homebrew launcher and run custom-install-finalize to finish the process.
|
||||
@@ -54,7 +54,7 @@ CFLAGS := -g -Wall -O2 -mword-relocations \
|
||||
-fomit-frame-pointer -ffunction-sections \
|
||||
$(ARCH)
|
||||
|
||||
CFLAGS += $(INCLUDE) -DARM11 -D_3DS
|
||||
CFLAGS += $(INCLUDE) -D__3DS__
|
||||
|
||||
CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions -std=gnu++11
|
||||
|
||||
|
||||
82
finalize/flake.lock
generated
Normal file
82
finalize/flake.lock
generated
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devkitNix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1766539742,
|
||||
"narHash": "sha256-F6OeM2LrLo2n+Xg5XU4udQR/vuWWrDMKxXRzNXE2ClQ=",
|
||||
"owner": "bandithedoge",
|
||||
"repo": "devkitNix",
|
||||
"rev": "c97f9880737716085e78009cba6bf85ad104628b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "bandithedoge",
|
||||
"repo": "devkitNix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768032153,
|
||||
"narHash": "sha256-6kD1MdY9fsE6FgSwdnx29hdH2UcBKs3/+JJleMShuJg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devkitNix": "devkitNix",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
33
finalize/flake.nix
Normal file
33
finalize/flake.nix
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
devkitNix.url = "github:bandithedoge/devkitNix";
|
||||
devkitNix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, devkitNix }: let
|
||||
pkgs = import nixpkgs { system = "x86_64-linux"; overlays = [ devkitNix.overlays.default ]; };
|
||||
in {
|
||||
devShells.x86_64-linux = rec {
|
||||
custom-install-finalize = pkgs.mkShell.override { stdenv = pkgs.devkitNix.stdenvARM; } {};
|
||||
cif = custom-install-finalize;
|
||||
default = custom-install-finalize;
|
||||
};
|
||||
|
||||
packages.x86_64-linux = rec {
|
||||
custom-install-finalize = pkgs.devkitNix.stdenvARM.mkDerivation rec {
|
||||
name = "custom-install-finalize";
|
||||
src = builtins.path { path = ./.; name = name; };
|
||||
|
||||
makeFlags = [ "TARGET=${name}" ];
|
||||
|
||||
installPhase = ''
|
||||
mkdir $out
|
||||
cp ${name}.3dsx $out
|
||||
'';
|
||||
};
|
||||
cif = custom-install-finalize;
|
||||
default = custom-install-finalize;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <3ds.h>
|
||||
|
||||
#include "basetik_bin.h"
|
||||
|
||||
#define CIFINISH_PATH "/cifinish.bin"
|
||||
#define REQUIRED_VERSION 1
|
||||
|
||||
// 0x10
|
||||
struct finish_db_header {
|
||||
@@ -16,41 +16,210 @@ struct finish_db_header {
|
||||
};
|
||||
|
||||
// 0x30
|
||||
struct finish_db_entry {
|
||||
struct finish_db_entry_v1 {
|
||||
u64 title_id;
|
||||
u8 common_key_index;
|
||||
u8 common_key_index; // unused by this program
|
||||
bool has_seed;
|
||||
u8 magic[6]; // "TITLE" and a null byte
|
||||
u8 title_key[0x10];
|
||||
u8 title_key[0x10]; // unused by this program
|
||||
u8 seed[0x10];
|
||||
};
|
||||
|
||||
// 0x20
|
||||
// this one was accidential since I mixed up the order of the members in the script
|
||||
// and the finalize program, but a lot of users probably used the bad one so I need
|
||||
// to support this anyway.
|
||||
struct finish_db_entry_v2 {
|
||||
u8 magic[6]; // "TITLE" and a null byte
|
||||
u64 title_id;
|
||||
bool has_seed;
|
||||
u8 padding;
|
||||
u8 seed[0x10];
|
||||
} __attribute__((packed));
|
||||
|
||||
// 0x20
|
||||
struct finish_db_entry_v3 {
|
||||
u8 magic[6]; // "TITLE" and a null byte
|
||||
bool has_seed;
|
||||
u64 title_id;
|
||||
u8 seed[0x10];
|
||||
};
|
||||
|
||||
// 0x350
|
||||
struct ticket_dumb {
|
||||
u8 unused1[0x1BF];
|
||||
u8 title_key[0x10];
|
||||
u8 unused2[0xD];
|
||||
u8 unused1[0x1DC];
|
||||
u64 title_id_be;
|
||||
u8 unused3[0xD];
|
||||
u8 common_key_index;
|
||||
u8 unused4[0x15E];
|
||||
u8 unused2[0x16C];
|
||||
} __attribute__((packed));
|
||||
|
||||
// the 3 versions are put into this struct
|
||||
struct finish_db_entry_final {
|
||||
bool has_seed;
|
||||
u64 title_id;
|
||||
u8 seed[0x10];
|
||||
};
|
||||
|
||||
// from FBI:
|
||||
// https://github.com/Steveice10/FBI/blob/6e3a28e4b674e0d7a6f234b0419c530b358957db/source/core/http.c#L440-L453
|
||||
static Result FSUSER_AddSeed(u64 titleId, const void* seed) {
|
||||
u32 *cmdbuf = getThreadCommandBuffer();
|
||||
u32 *cmdbuf = getThreadCommandBuffer();
|
||||
|
||||
cmdbuf[0] = 0x087A0180;
|
||||
cmdbuf[1] = (u32) (titleId & 0xFFFFFFFF);
|
||||
cmdbuf[2] = (u32) (titleId >> 32);
|
||||
memcpy(&cmdbuf[3], seed, 16);
|
||||
cmdbuf[0] = 0x087A0180;
|
||||
cmdbuf[1] = (u32) (titleId & 0xFFFFFFFF);
|
||||
cmdbuf[2] = (u32) (titleId >> 32);
|
||||
memcpy(&cmdbuf[3], seed, 16);
|
||||
|
||||
Result ret = 0;
|
||||
if(R_FAILED(ret = svcSendSyncRequest(*fsGetSessionHandle()))) return ret;
|
||||
Result ret = 0;
|
||||
if(R_FAILED(ret = svcSendSyncRequest(*fsGetSessionHandle()))) return ret;
|
||||
|
||||
ret = cmdbuf[1];
|
||||
return ret;
|
||||
ret = cmdbuf[1];
|
||||
return ret;
|
||||
}
|
||||
|
||||
int load_cifinish(char* path, struct finish_db_entry_final **entries)
|
||||
{
|
||||
FILE *fp;
|
||||
struct finish_db_header header;
|
||||
|
||||
struct finish_db_entry_v1 v1;
|
||||
struct finish_db_entry_v2 v2;
|
||||
struct finish_db_entry_v3 v3;
|
||||
|
||||
struct finish_db_entry_final *tmp;
|
||||
|
||||
int i;
|
||||
size_t read;
|
||||
|
||||
printf("Reading %s...\n", path);
|
||||
fp = fopen(path, "rb");
|
||||
if (!fp)
|
||||
{
|
||||
printf("Failed to open file. Does it exist?\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
fread(&header, sizeof(header), 1, fp);
|
||||
|
||||
if (memcmp(header.magic, "CIFINISH", 8))
|
||||
{
|
||||
printf("CIFINISH magic not found.\n");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
printf("CIFINISH version: %lu\n", header.version);
|
||||
|
||||
if (header.version > 3)
|
||||
{
|
||||
printf("This version of custom-install-finalize is\n");
|
||||
printf(" too old. Please update to a new release.\n");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
*entries = calloc(header.title_count, sizeof(struct finish_db_entry_final));
|
||||
if (!*entries) {
|
||||
printf("Couldn't allocate memory.\n");
|
||||
printf("This should never happen.\n");
|
||||
goto fail;
|
||||
}
|
||||
tmp = *entries;
|
||||
|
||||
if (header.version == 1)
|
||||
{
|
||||
for (i = 0; i < header.title_count; i++)
|
||||
{
|
||||
read = fread(&v1, sizeof(v1), 1, fp);
|
||||
if (read != 1)
|
||||
{
|
||||
printf("Couldn't read a full entry.\n");
|
||||
printf(" Is the file corrupt?\n");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
if (memcmp(v1.magic, "TITLE", 6))
|
||||
{
|
||||
printf("Couldn't find TITLE magic for entry.\n");
|
||||
printf(" Is the file corrupt?\n");
|
||||
goto fail;
|
||||
}
|
||||
tmp[i].has_seed = v1.has_seed;
|
||||
tmp[i].title_id = v1.title_id;
|
||||
memcpy(tmp[i].seed, v1.seed, 16);
|
||||
}
|
||||
} else if (header.version == 2) {
|
||||
for (i = 0; i < header.title_count; i++)
|
||||
{
|
||||
read = fread(&v2, sizeof(v2), 1, fp);
|
||||
if (read != 1)
|
||||
{
|
||||
printf("Couldn't read a full entry.\n");
|
||||
printf(" Is the file corrupt?\n");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
if (memcmp(v2.magic, "TITLE", 6))
|
||||
{
|
||||
printf("Couldn't find TITLE magic for entry.\n");
|
||||
printf(" Is the file corrupt?\n");
|
||||
goto fail;
|
||||
}
|
||||
tmp[i].has_seed = v2.has_seed;
|
||||
tmp[i].title_id = v2.title_id;
|
||||
memcpy(tmp[i].seed, v2.seed, 16);
|
||||
}
|
||||
} else if (header.version == 3) {
|
||||
for (i = 0; i < header.title_count; i++)
|
||||
{
|
||||
read = fread(&v3, sizeof(v3), 1, fp);
|
||||
if (read != 1)
|
||||
{
|
||||
printf("Couldn't read a full entry.\n");
|
||||
printf(" Is the file corrupt?\n");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
if (memcmp(v3.magic, "TITLE", 6))
|
||||
{
|
||||
printf("Couldn't find TITLE magic for entry.\n");
|
||||
printf(" Is the file corrupt?\n");
|
||||
goto fail;
|
||||
}
|
||||
tmp[i].has_seed = v3.has_seed;
|
||||
tmp[i].title_id = v3.title_id;
|
||||
memcpy(tmp[i].seed, v3.seed, 16);
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
return header.title_count;
|
||||
|
||||
fail:
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
|
||||
Result check_title_exist(u64 title_id, u64 *ticket_ids, u32 ticket_ids_length, u64 *title_ids, u32 title_ids_length)
|
||||
{
|
||||
Result ret = -2;
|
||||
|
||||
for (u32 i = 0; i < ticket_ids_length; i++)
|
||||
{
|
||||
if (ticket_ids[i] == title_id)
|
||||
{
|
||||
ret++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (u32 i = 0; i < title_ids_length; i++)
|
||||
{
|
||||
if (title_ids[i] == title_id)
|
||||
{
|
||||
ret++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
void finalize_install(void)
|
||||
@@ -58,55 +227,79 @@ void finalize_install(void)
|
||||
Result res;
|
||||
Handle ticketHandle;
|
||||
struct ticket_dumb ticket_buf;
|
||||
FILE *fp;
|
||||
struct finish_db_entry_final *entries = NULL;
|
||||
int title_count;
|
||||
|
||||
struct finish_db_header header;
|
||||
struct finish_db_entry *entries;
|
||||
u32 titles_read;
|
||||
u32 tickets_read;
|
||||
|
||||
res = AM_GetTitleCount(MEDIATYPE_SD, &titles_read);
|
||||
|
||||
if (R_FAILED(res))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
res = AM_GetTicketCount(&tickets_read);
|
||||
|
||||
if (R_FAILED(res))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
u64 *installed_ticket_ids = malloc(sizeof(u64) * tickets_read );
|
||||
u64 *installed_title_ids = malloc(sizeof(u64) * titles_read );
|
||||
|
||||
res = AM_GetTitleList(&titles_read, MEDIATYPE_SD, titles_read, installed_title_ids);
|
||||
|
||||
if (R_FAILED(res))
|
||||
{
|
||||
goto exit;
|
||||
}
|
||||
|
||||
res = AM_GetTicketList(&tickets_read, tickets_read, 0, installed_ticket_ids);
|
||||
|
||||
if (R_FAILED(res))
|
||||
{
|
||||
goto exit;
|
||||
}
|
||||
|
||||
title_count = load_cifinish(CIFINISH_PATH, &entries);
|
||||
|
||||
if (title_count == -1)
|
||||
{
|
||||
goto exit;
|
||||
}
|
||||
else if (title_count == 0)
|
||||
{
|
||||
printf("No titles to finalize.\n");
|
||||
goto exit;
|
||||
}
|
||||
|
||||
memcpy(&ticket_buf, basetik_bin, basetik_bin_size);
|
||||
|
||||
printf("Reading %s...\n", CIFINISH_PATH);
|
||||
fp = fopen(CIFINISH_PATH, "rb");
|
||||
if (!fp)
|
||||
Result exist_res = 0;
|
||||
|
||||
for (int i = 0; i < title_count; ++i)
|
||||
{
|
||||
puts("Failed to open file.");
|
||||
return;
|
||||
}
|
||||
exist_res = check_title_exist(entries[i].title_id, installed_ticket_ids, tickets_read, installed_title_ids, titles_read);
|
||||
|
||||
fread(&header, sizeof(struct finish_db_header), 1, fp);
|
||||
|
||||
if (memcmp(header.magic, "CIFINISH", 8))
|
||||
{
|
||||
printf("CIFINISH magic not found in %s.\n", CIFINISH_PATH);
|
||||
fclose(fp);
|
||||
return;
|
||||
}
|
||||
|
||||
entries = calloc(header.title_count, sizeof(struct finish_db_entry));
|
||||
fread(entries, sizeof(struct finish_db_entry), header.title_count, fp);
|
||||
fclose(fp);
|
||||
|
||||
for (int i = 0; i < header.title_count; ++i)
|
||||
{
|
||||
// this includes the null byte
|
||||
if (memcmp(entries[i].magic, "TITLE", 6))
|
||||
if (R_SUCCEEDED(exist_res))
|
||||
{
|
||||
puts("Couldn't find TITLE magic for entry, skipping.");
|
||||
printf("No need to finalize %016llx, skipping...\n", entries[i].title_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
printf("Finalizing %016llx...\n", entries[i].title_id);
|
||||
|
||||
ticket_buf.title_id_be = __builtin_bswap64(entries[i].title_id);
|
||||
ticket_buf.common_key_index = entries[i].common_key_index;
|
||||
memcpy(ticket_buf.title_key, entries[i].title_key, 0x10);
|
||||
|
||||
res = AM_InstallTicketBegin(&ticketHandle);
|
||||
if (R_FAILED(res))
|
||||
{
|
||||
printf("Failed to begin ticket install: %08lx\n", res);
|
||||
AM_InstallTicketAbort(ticketHandle);
|
||||
free(entries);
|
||||
return;
|
||||
goto exit;
|
||||
}
|
||||
|
||||
res = FSFILE_Write(ticketHandle, NULL, 0, &ticket_buf, sizeof(struct ticket_dumb), 0);
|
||||
@@ -114,8 +307,7 @@ void finalize_install(void)
|
||||
{
|
||||
printf("Failed to write ticket: %08lx\n", res);
|
||||
AM_InstallTicketAbort(ticketHandle);
|
||||
free(entries);
|
||||
return;
|
||||
goto exit;
|
||||
}
|
||||
|
||||
res = AM_InstallTicketFinish(ticketHandle);
|
||||
@@ -123,8 +315,7 @@ void finalize_install(void)
|
||||
{
|
||||
printf("Failed to finish ticket install: %08lx\n", res);
|
||||
AM_InstallTicketAbort(ticketHandle);
|
||||
free(entries);
|
||||
return;
|
||||
goto exit;
|
||||
}
|
||||
|
||||
if (entries[i].has_seed)
|
||||
@@ -138,20 +329,30 @@ void finalize_install(void)
|
||||
}
|
||||
}
|
||||
|
||||
printf("Deleting %s...\n", CIFINISH_PATH);
|
||||
unlink(CIFINISH_PATH);
|
||||
|
||||
exit:
|
||||
|
||||
free(entries);
|
||||
free(installed_ticket_ids);
|
||||
free(installed_title_ids);
|
||||
return;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
amInit();
|
||||
sdmcInit();
|
||||
gfxInitDefault();
|
||||
consoleInit(GFX_TOP, NULL);
|
||||
|
||||
puts("custom-install-finalize v1.0");
|
||||
printf("custom-install-finalize v1.6\n");
|
||||
|
||||
finalize_install();
|
||||
puts("\nPress START or B to exit.");
|
||||
// print this at the end in case it gets pushed off the screen
|
||||
printf("\nRepository:\n");
|
||||
printf(" https://github.com/ihaveamac/custom-install\n");
|
||||
printf("\nPress START or B to exit.\n");
|
||||
|
||||
// Main loop
|
||||
while (aptMainLoop())
|
||||
@@ -167,7 +368,6 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
|
||||
gfxExit();
|
||||
sdmcExit();
|
||||
amExit();
|
||||
return 0;
|
||||
}
|
||||
|
||||
167
flake.lock
generated
Normal file
167
flake.lock
generated
Normal file
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devkitNix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"finalize",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1766539742,
|
||||
"narHash": "sha256-F6OeM2LrLo2n+Xg5XU4udQR/vuWWrDMKxXRzNXE2ClQ=",
|
||||
"owner": "bandithedoge",
|
||||
"repo": "devkitNix",
|
||||
"rev": "c97f9880737716085e78009cba6bf85ad104628b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "bandithedoge",
|
||||
"repo": "devkitNix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"finalize": {
|
||||
"inputs": {
|
||||
"devkitNix": "devkitNix",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1,
|
||||
"narHash": "sha256-BZgu7+/RV9Gy1xo/icz5kd2fKCa3Zow+Zz6MJWzpgMM=",
|
||||
"path": "finalize",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"path": "finalize",
|
||||
"type": "path"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"hax-nur": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768151313,
|
||||
"narHash": "sha256-qcMLsdACTlFHltziBAsS1r09cVZyp5fUR16//mIhLIs=",
|
||||
"owner": "ihaveamac",
|
||||
"repo": "nur-packages",
|
||||
"rev": "8ebcd637fd5cd8e673c8e01ed408bf206f9d4f9b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ihaveamac",
|
||||
"ref": "master",
|
||||
"repo": "nur-packages",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768032153,
|
||||
"narHash": "sha256-6kD1MdY9fsE6FgSwdnx29hdH2UcBKs3/+JJleMShuJg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pyctr": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763515957,
|
||||
"narHash": "sha256-S0qzooGQN5tkbIVgijVZ9umvBC1dYbdPN97tks5SbwE=",
|
||||
"owner": "ihaveamac",
|
||||
"repo": "pyctr",
|
||||
"rev": "eb8d4d06ce7339727d3f72b40f45ec3260336058",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ihaveamac",
|
||||
"ref": "master",
|
||||
"repo": "pyctr",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"finalize": "finalize",
|
||||
"hax-nur": "hax-nur",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"pyctr": "pyctr"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"hax-nur",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768031762,
|
||||
"narHash": "sha256-b2gJDJfi+TbA7Hu2sKip+1mWqya0GJaWrrXQjpbOVTU=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "0c445aa21b01fd1d4bb58927f7b268568af87b20",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
80
flake.nix
Normal file
80
flake.nix
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
description = "custominstall";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
pyctr.url = "github:ihaveamac/pyctr/master";
|
||||
pyctr.inputs.nixpkgs.follows = "nixpkgs";
|
||||
hax-nur.url = "github:ihaveamac/nur-packages/master";
|
||||
hax-nur.inputs.nixpkgs.follows = "nixpkgs";
|
||||
finalize.url = "path:finalize";
|
||||
finalize.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{
|
||||
self,
|
||||
nixpkgs,
|
||||
pyctr,
|
||||
hax-nur,
|
||||
finalize,
|
||||
}:
|
||||
let
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"i686-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
"aarch64-linux"
|
||||
"armv6l-linux"
|
||||
"armv7l-linux"
|
||||
];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
|
||||
in
|
||||
{
|
||||
legacyPackages = forAllSystems (
|
||||
system:
|
||||
(import ./default.nix {
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
pyctr = pyctr.packages.${system}.pyctr;
|
||||
save3ds = hax-nur.packages.${system}.save3ds;
|
||||
})
|
||||
// {
|
||||
default = self.legacyPackages.${system}.custominstall;
|
||||
}
|
||||
);
|
||||
packages = forAllSystems (
|
||||
system: nixpkgs.lib.filterAttrs (_: v: nixpkgs.lib.isDerivation v) self.legacyPackages.${system}
|
||||
);
|
||||
|
||||
apps = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
gui = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.custominstall}/bin/custominstall-gui";
|
||||
};
|
||||
}
|
||||
// (
|
||||
if system == "x86_64-linux" then
|
||||
# this only works on x86_64-linux due to devkitNix only working there
|
||||
{
|
||||
update-finalize = {
|
||||
type = "app";
|
||||
program =
|
||||
(pkgs.writeShellScript "update-finalize" ''
|
||||
set -x
|
||||
finalize=${inputs.finalize.packages.${system}.custom-install-finalize}/custom-install-finalize.3dsx
|
||||
cp --no-preserve=mode,ownership,timestamps $finalize custominstall/custom-install-finalize.3dsx
|
||||
'').outPath;
|
||||
};
|
||||
}
|
||||
else
|
||||
{ }
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
74
package.nix
Normal file
74
package.nix
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
python,
|
||||
callPackage,
|
||||
buildPythonApplication,
|
||||
fetchPypi,
|
||||
pyctr,
|
||||
pycryptodomex,
|
||||
pypng,
|
||||
tkinter,
|
||||
setuptools,
|
||||
events,
|
||||
stdenv,
|
||||
save3ds,
|
||||
|
||||
withGUI ? true,
|
||||
}:
|
||||
|
||||
let
|
||||
save3ds_no_fuse = save3ds.override { withFUSE = false; };
|
||||
in
|
||||
buildPythonApplication rec {
|
||||
pname = "custominstall";
|
||||
version = "2.1";
|
||||
pyproject = true;
|
||||
|
||||
src = builtins.path {
|
||||
path = ./.;
|
||||
name = "custominstall";
|
||||
filter =
|
||||
path: type:
|
||||
!(builtins.elem (baseNameOf path) [
|
||||
"build"
|
||||
"dist"
|
||||
"localtest"
|
||||
"__pycache__"
|
||||
"v"
|
||||
".git"
|
||||
"_build"
|
||||
"custominstall.egg-info"
|
||||
]);
|
||||
};
|
||||
|
||||
doCheck = false;
|
||||
|
||||
build-system = [ setuptools ];
|
||||
|
||||
propagatedBuildInputs =
|
||||
[
|
||||
pyctr
|
||||
pycryptodomex
|
||||
setuptools
|
||||
events
|
||||
]
|
||||
++ lib.optionals (withGUI) [
|
||||
tkinter
|
||||
];
|
||||
|
||||
makeWrapperArgs = [ "--set CUSTOM_INSTALL_SAVE3DS_PATH ${save3ds_no_fuse}/bin/save3ds_fuse" ];
|
||||
|
||||
preFixup = ''
|
||||
rm -r $out/lib/${python.libPrefix}/site-packages/custominstall/bin
|
||||
${lib.optionalString (!withGUI) "rm $out/bin/custominstall-gui"}
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "Installs a title directly to an SD card for the Nintendo 3DS";
|
||||
homepage = "https://github.com/ihaveamac/custom-install";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
mainProgram = "custominstall";
|
||||
};
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
# PyCTR
|
||||
|
||||
Python library to parse several Nintendo 3DS files.
|
||||
|
||||
The API is not stable and can significantly change at any point. (If anyone decides to use this, that is)
|
||||
|
||||
This will eventually be a separate repo...
|
||||
@@ -1,6 +0,0 @@
|
||||
from .types.cia import *
|
||||
from .types.exefs import *
|
||||
from .types.ncch import *
|
||||
from .types.romfs import *
|
||||
from .types.smdh import *
|
||||
from .types.tmd import *
|
||||
@@ -1,82 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from functools import wraps
|
||||
from io import BufferedIOBase
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# this is a lazy way to make type checkers stop complaining
|
||||
from typing import BinaryIO
|
||||
BufferedIOBase = BinaryIO
|
||||
|
||||
|
||||
class PyCTRError(Exception):
|
||||
"""Common base class for all PyCTR errors."""
|
||||
|
||||
|
||||
def _raise_if_closed(method):
|
||||
@wraps(method)
|
||||
def decorator(self: '_ReaderOpenFileBase', *args, **kwargs):
|
||||
if self._reader.closed:
|
||||
self.closed = True
|
||||
if self.closed:
|
||||
raise ValueError('I/O operation on closed file')
|
||||
return method(self, *args, **kwargs)
|
||||
return decorator
|
||||
|
||||
|
||||
class _ReaderOpenFileBase(BufferedIOBase):
|
||||
"""Base class for all open files for Reader classes."""
|
||||
|
||||
_seek = 0
|
||||
_info = None
|
||||
closed = False
|
||||
|
||||
def __init__(self, reader, path):
|
||||
self._reader = reader
|
||||
self._path = path
|
||||
|
||||
def __repr__(self):
|
||||
return f'<{type(self).__name__} path={self._path!r} info={self._info!r} reader={self._reader!r}>'
|
||||
|
||||
@_raise_if_closed
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if size == -1:
|
||||
size = self._info.size - self._seek
|
||||
data = self._reader.get_data(self._info, self._seek, size)
|
||||
self._seek += len(data)
|
||||
return data
|
||||
|
||||
read1 = read # probably make this act like read1 should, but this for now enables some other things to work
|
||||
|
||||
@_raise_if_closed
|
||||
def seek(self, seek: int, whence: int = 0) -> int:
|
||||
if whence == 0:
|
||||
if seek < 0:
|
||||
raise ValueError(f'negative seek value {seek}')
|
||||
self._seek = min(seek, self._info.size)
|
||||
elif whence == 1:
|
||||
self._seek = max(self._seek + seek, 0)
|
||||
elif whence == 2:
|
||||
self._seek = max(self._info.size + seek, 0)
|
||||
return self._seek
|
||||
|
||||
@_raise_if_closed
|
||||
def tell(self) -> int:
|
||||
return self._seek
|
||||
|
||||
@_raise_if_closed
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
@_raise_if_closed
|
||||
def writable(self) -> bool:
|
||||
return False
|
||||
|
||||
@_raise_if_closed
|
||||
def seekable(self) -> bool:
|
||||
return True
|
||||
598
pyctr/crypto.py
598
pyctr/crypto.py
@@ -1,598 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from enum import IntEnum
|
||||
from functools import wraps
|
||||
from hashlib import sha256
|
||||
from os import environ
|
||||
from os.path import getsize, join as pjoin
|
||||
from struct import pack, unpack
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Hash import CMAC
|
||||
from Cryptodome.Util import Counter
|
||||
|
||||
from .common import PyCTRError
|
||||
from .util import config_dirs, readbe, readle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# noinspection PyProtectedMember
|
||||
from Cryptodome.Cipher._mode_cbc import CbcMode
|
||||
# noinspection PyProtectedMember
|
||||
from Cryptodome.Cipher._mode_ctr import CtrMode
|
||||
# noinspection PyProtectedMember
|
||||
from Cryptodome.Cipher._mode_ecb import EcbMode
|
||||
from Cryptodome.Hash.CMAC import CMAC as CMACObject
|
||||
from typing import Dict, List, Union
|
||||
|
||||
__all__ = ['CryptoError', 'OTPLengthError', 'CorruptBootromError', 'KeyslotMissingError', 'TicketLengthError',
|
||||
'BootromNotFoundError', 'CorruptOTPError', 'Keyslot', 'CryptoEngine']
|
||||
|
||||
|
||||
class CryptoError(PyCTRError):
|
||||
"""Generic exception for cryptography operations."""
|
||||
|
||||
|
||||
class OTPLengthError(CryptoError):
|
||||
"""OTP is the wrong length."""
|
||||
|
||||
|
||||
class CorruptOTPError(CryptoError):
|
||||
"""OTP hash does not match."""
|
||||
|
||||
|
||||
class KeyslotMissingError(CryptoError):
|
||||
"""Normal key is not set up for the keyslot."""
|
||||
|
||||
|
||||
class BadMovableSedError(CryptoError):
|
||||
"""movable.sed provided is invalid."""
|
||||
|
||||
|
||||
class TicketLengthError(CryptoError):
|
||||
"""Ticket is too small."""
|
||||
def __init__(self, length):
|
||||
super().__init__(length)
|
||||
|
||||
def __str__(self):
|
||||
return f'0x350 expected, {self.args[0]:#x} given'
|
||||
|
||||
|
||||
# wonder if I'm doing this right...
|
||||
class BootromNotFoundError(CryptoError):
|
||||
"""ARM9 bootROM was not found. Main argument is a tuple of checked paths."""
|
||||
|
||||
|
||||
class CorruptBootromError(CryptoError):
|
||||
"""ARM9 bootROM hash does not match."""
|
||||
|
||||
|
||||
class Keyslot(IntEnum):
|
||||
TWLNAND = 0x03
|
||||
CTRNANDOld = 0x04
|
||||
CTRNANDNew = 0x05
|
||||
FIRM = 0x06
|
||||
AGB = 0x07
|
||||
|
||||
CMACNANDDB = 0x0B
|
||||
|
||||
NCCH93 = 0x18
|
||||
CMACCardSaveNew = 0x19
|
||||
CardSaveNew = 0x1A
|
||||
NCCH96 = 0x1B
|
||||
|
||||
CMACAGB = 0x24
|
||||
NCCH70 = 0x25
|
||||
|
||||
NCCH = 0x2C
|
||||
UDSLocalWAN = 0x2D
|
||||
StreetPass = 0x2E
|
||||
Save60 = 0x2F
|
||||
CMACSDNAND = 0x30
|
||||
|
||||
CMACCardSave = 0x33
|
||||
SD = 0x34
|
||||
|
||||
CardSave = 0x37
|
||||
BOSS = 0x38
|
||||
DownloadPlay = 0x39
|
||||
|
||||
DSiWareExport = 0x3A
|
||||
|
||||
CommonKey = 0x3D
|
||||
|
||||
# anything after 0x3F is custom to PyCTR
|
||||
DecryptedTitlekey = 0x40
|
||||
|
||||
|
||||
BOOT9_PROT_HASH = '7331f7edece3dd33f2ab4bd0b3a5d607229fd19212c10b734cedcaf78c1a7b98'
|
||||
|
||||
DEV_COMMON_KEY_0 = bytes.fromhex('55A3F872BDC80C555A654381139E153B')
|
||||
|
||||
common_key_y = (
|
||||
# eShop
|
||||
0xD07B337F9CA4385932A2E25723232EB9,
|
||||
# System
|
||||
0x0C767230F0998F1C46828202FAACBE4C,
|
||||
# Unknown
|
||||
0xC475CB3AB8C788BB575E12A10907B8A4,
|
||||
# Unknown
|
||||
0xE486EEE3D0C09C902F6686D4C06F649F,
|
||||
# Unknown
|
||||
0xED31BA9C04B067506C4497A35B7804FC,
|
||||
# Unknown
|
||||
0x5E66998AB4E8931606850FD7A16DD755
|
||||
)
|
||||
|
||||
base_key_x = {
|
||||
# New3DS 9.3 NCCH
|
||||
0x18: (0x82E9C9BEBFB8BDB875ECC0A07D474374, 0x304BF1468372EE64115EBD4093D84276),
|
||||
# New3DS 9.6 NCCH
|
||||
0x1B: (0x45AD04953992C7C893724A9A7BCE6182, 0x6C8B2944A0726035F941DFC018524FB6),
|
||||
# 7x NCCH
|
||||
0x25: (0xCEE7D8AB30C00DAE850EF5E382AC5AF3, 0x81907A4B6F1B47323A677974CE4AD71B),
|
||||
}
|
||||
|
||||
# global values to be copied to new CryptoEngine instances after the first one
|
||||
_b9_key_x: 'Dict[int, int]' = {}
|
||||
_b9_key_y: 'Dict[int, int]' = {}
|
||||
_b9_key_normal: 'Dict[int, bytes]' = {}
|
||||
_b9_extdata_otp: bytes = None
|
||||
_b9_extdata_keygen: bytes = None
|
||||
_b9_path: str = None
|
||||
_otp_key: bytes = None
|
||||
_otp_iv: bytes = None
|
||||
|
||||
b9_paths: 'List[str]' = []
|
||||
for p in config_dirs:
|
||||
b9_paths.append(pjoin(p, 'boot9.bin'))
|
||||
b9_paths.append(pjoin(p, 'boot9_prot.bin'))
|
||||
try:
|
||||
b9_paths.insert(0, environ['BOOT9_PATH'])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
def _requires_bootrom(method):
|
||||
@wraps(method)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if not self.b9_keys_set:
|
||||
raise KeyslotMissingError('bootrom is required to set up keys, see setup_keys_from_boot9')
|
||||
return method(self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
# used from http://www.falatic.com/index.php/108/python-and-bitwise-rotation
|
||||
# converted to def because pycodestyle complained to me
|
||||
def rol(val: int, r_bits: int, max_bits: int) -> int:
|
||||
return (val << r_bits % max_bits) & (2 ** max_bits - 1) |\
|
||||
((val & (2 ** max_bits - 1)) >> (max_bits - (r_bits % max_bits)))
|
||||
|
||||
|
||||
class _TWLCryptoWrapper:
|
||||
def __init__(self, cipher: 'CbcMode'):
|
||||
self._cipher = cipher
|
||||
|
||||
def encrypt(self, data: bytes) -> bytes:
|
||||
data_len = len(data)
|
||||
data_rev = bytearray(data_len)
|
||||
for i in range(0, data_len, 0x10):
|
||||
data_rev[i:i + 0x10] = data[i:i + 0x10][::-1]
|
||||
|
||||
data_out = bytearray(self._cipher.encrypt(bytes(data_rev)))
|
||||
|
||||
for i in range(0, data_len, 0x10):
|
||||
data_out[i:i + 0x10] = data_out[i:i + 0x10][::-1]
|
||||
return bytes(data_out[0:data_len])
|
||||
|
||||
decrypt = encrypt
|
||||
|
||||
|
||||
class CryptoEngine:
|
||||
"""Class for 3DS crypto operations, including encryption and key generation."""
|
||||
|
||||
b9_keys_set: bool = False
|
||||
b9_path: str = None
|
||||
|
||||
_b9_extdata_otp: bytes = None
|
||||
_b9_extdata_keygen: bytes = None
|
||||
|
||||
_otp_key: bytes = None
|
||||
_otp_iv: bytes = None
|
||||
|
||||
_id0: bytes = None
|
||||
|
||||
def __init__(self, boot9: str = None, dev: int = 0, setup_b9_keys: bool = True):
|
||||
self.key_x: Dict[int, int] = {}
|
||||
self.key_y: Dict[int, int] = {0x03: 0xE1A00005202DDD1DBD4DC4D30AB9DC76,
|
||||
0x05: 0x4D804F4E9990194613A204AC584460BE}
|
||||
self.key_normal: Dict[int, bytes] = {}
|
||||
|
||||
self.dev = dev
|
||||
|
||||
for keyslot, keys in base_key_x.items():
|
||||
self.key_x[keyslot] = keys[dev]
|
||||
|
||||
if setup_b9_keys:
|
||||
self.setup_keys_from_boot9_file(boot9)
|
||||
|
||||
@property
|
||||
@_requires_bootrom
|
||||
def b9_extdata_otp(self) -> bytes:
|
||||
return self._b9_extdata_otp
|
||||
|
||||
@property
|
||||
@_requires_bootrom
|
||||
def b9_extdata_keygen(self) -> bytes:
|
||||
return self._b9_extdata_keygen
|
||||
|
||||
@property
|
||||
@_requires_bootrom
|
||||
def otp_key(self) -> bytes:
|
||||
return self._otp_key
|
||||
|
||||
@property
|
||||
@_requires_bootrom
|
||||
def otp_iv(self) -> bytes:
|
||||
return self._otp_iv
|
||||
|
||||
@property
|
||||
def id0(self) -> bytes:
|
||||
if not self._id0:
|
||||
raise KeyslotMissingError('load a movable.sed with setup_sd_key')
|
||||
return self._id0
|
||||
|
||||
def create_cbc_cipher(self, keyslot: int, iv: bytes) -> 'CbcMode':
|
||||
"""Create AES-CBC cipher with the given keyslot."""
|
||||
try:
|
||||
key = self.key_normal[keyslot]
|
||||
except KeyError:
|
||||
raise KeyslotMissingError(f'normal key for keyslot 0x{keyslot:02x} is not set up')
|
||||
|
||||
return AES.new(key, AES.MODE_CBC, iv)
|
||||
|
||||
def create_ctr_cipher(self, keyslot: int, ctr: int) -> 'Union[CtrMode, _TWLCryptoWrapper]':
|
||||
"""
|
||||
Create AES-CTR cipher with the given keyslot.
|
||||
|
||||
Normal and DSi crypto will be automatically chosen depending on keyslot.
|
||||
"""
|
||||
try:
|
||||
key = self.key_normal[keyslot]
|
||||
except KeyError:
|
||||
raise KeyslotMissingError(f'normal key for keyslot 0x{keyslot:02x} is not set up')
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CTR, counter=Counter.new(128, initial_value=ctr))
|
||||
|
||||
if keyslot < 0x04:
|
||||
return _TWLCryptoWrapper(cipher)
|
||||
else:
|
||||
return cipher
|
||||
|
||||
def create_ecb_cipher(self, keyslot: int) -> 'EcbMode':
|
||||
"""Create AES-ECB cipher with the given keyslot."""
|
||||
try:
|
||||
key = self.key_normal[keyslot]
|
||||
except KeyError:
|
||||
raise KeyslotMissingError(f'normal key for keyslot 0x{keyslot:02x} is not set up')
|
||||
|
||||
return AES.new(key, AES.MODE_ECB)
|
||||
|
||||
def create_cmac_object(self, keyslot: int) -> 'CMACObject':
|
||||
"""Create a CMAC object with the given keyslot."""
|
||||
try:
|
||||
key = self.key_normal[keyslot]
|
||||
except KeyError:
|
||||
raise KeyslotMissingError(f'normal key for keyslot 0x{keyslot:02x} is not set up')
|
||||
|
||||
return CMAC.new(key, ciphermod=AES)
|
||||
|
||||
@staticmethod
|
||||
def sd_path_to_iv(path: str) -> int:
|
||||
# ensure the path is lowercase
|
||||
path = path.lower()
|
||||
|
||||
# SD Save Data Backup does a copy of the raw, encrypted file from the game's data directory
|
||||
# so we need to handle this and fake the path
|
||||
if path.startswith('/backup') and len(path) > 28:
|
||||
tid_upper = path[12:20]
|
||||
tid_lower = path[20:28]
|
||||
path = f'/title/{tid_upper}/{tid_lower}/data' + path[28:]
|
||||
|
||||
path_hash = sha256(path.encode('utf-16le') + b'\0\0').digest()
|
||||
hash_p1 = readbe(path_hash[0:16])
|
||||
hash_p2 = readbe(path_hash[16:32])
|
||||
return hash_p1 ^ hash_p2
|
||||
|
||||
def load_from_ticket(self, ticket: bytes):
|
||||
"""Load a titlekey from a ticket and set keyslot 0x40 to the decrypted titlekey."""
|
||||
ticket_len = len(ticket)
|
||||
# TODO: probably support other sig types which would be different lengths
|
||||
# unlikely to happen in practice, but I would still like to
|
||||
if ticket_len < 0x2AC:
|
||||
raise TicketLengthError(ticket_len)
|
||||
|
||||
titlekey_enc = ticket[0x1BF:0x1CF]
|
||||
title_id = ticket[0x1DC:0x1E4]
|
||||
common_key_index = ticket[0x1F1]
|
||||
|
||||
if self.dev and common_key_index == 0:
|
||||
self.set_normal_key(0x3D, DEV_COMMON_KEY_0)
|
||||
else:
|
||||
self.set_keyslot('y', 0x3D, common_key_y[common_key_index])
|
||||
|
||||
cipher = self.create_cbc_cipher(0x3D, title_id + (b'\0' * 8))
|
||||
self.set_normal_key(0x40, cipher.decrypt(titlekey_enc))
|
||||
|
||||
def set_keyslot(self, xy: str, keyslot: int, key: 'Union[int, bytes]'):
|
||||
"""Sets a keyslot to the specified key."""
|
||||
to_use = None
|
||||
if xy == 'x':
|
||||
to_use = self.key_x
|
||||
elif xy == 'y':
|
||||
to_use = self.key_y
|
||||
if isinstance(key, bytes):
|
||||
key = int.from_bytes(key, 'big' if keyslot > 0x03 else 'little')
|
||||
to_use[keyslot] = key
|
||||
try:
|
||||
self.key_normal[keyslot] = self.keygen(keyslot)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def set_normal_key(self, keyslot: int, key: bytes):
|
||||
self.key_normal[keyslot] = key
|
||||
|
||||
def keygen(self, keyslot: int) -> bytes:
|
||||
"""Generate a normal key based on the keyslot."""
|
||||
if keyslot < 0x04:
|
||||
# DSi
|
||||
return self.keygen_twl_manual(self.key_x[keyslot], self.key_y[keyslot])
|
||||
else:
|
||||
# 3DS
|
||||
return self.keygen_manual(self.key_x[keyslot], self.key_y[keyslot])
|
||||
|
||||
@staticmethod
|
||||
def keygen_manual(key_x: int, key_y: int) -> bytes:
|
||||
"""Generate a normal key using the 3DS AES keyscrambler."""
|
||||
return rol((rol(key_x, 2, 128) ^ key_y) + 0x1FF9E9AAC5FE0408024591DC5D52768A, 87, 128).to_bytes(0x10, 'big')
|
||||
|
||||
@staticmethod
|
||||
def keygen_twl_manual(key_x: int, key_y: int) -> bytes:
|
||||
"""Generate a normal key using the DSi AES keyscrambler."""
|
||||
# usually would convert to LE bytes in the end then flip with [::-1], but those just cancel out
|
||||
return rol((key_x ^ key_y) + 0xFFFEFB4E295902582A680F5F1A4F3E79, 42, 128).to_bytes(0x10, 'big')
|
||||
|
||||
def _copy_global_keys(self):
|
||||
self.key_x.update(_b9_key_x)
|
||||
self.key_y.update(_b9_key_y)
|
||||
self.key_normal.update(_b9_key_normal)
|
||||
self._otp_key = _otp_key
|
||||
self._otp_iv = _otp_iv
|
||||
self._b9_extdata_otp = _b9_extdata_otp
|
||||
self._b9_extdata_keygen = _b9_extdata_keygen
|
||||
|
||||
self.b9_keys_set = True
|
||||
|
||||
def setup_keys_from_boot9(self, b9: bytes):
|
||||
"""Set up certain keys from an ARM9 bootROM dump."""
|
||||
global _otp_key, _otp_iv, _b9_extdata_otp, _b9_extdata_keygen
|
||||
if self.b9_keys_set:
|
||||
return
|
||||
|
||||
if _b9_key_x:
|
||||
self._copy_global_keys()
|
||||
return
|
||||
|
||||
b9_len = len(b9)
|
||||
if b9_len != 0x8000:
|
||||
raise CorruptBootromError(f'wrong length: {b9_len}')
|
||||
|
||||
b9_hash_digest: str = sha256(b9).hexdigest()
|
||||
if b9_hash_digest != BOOT9_PROT_HASH:
|
||||
raise CorruptBootromError(f'expected: {BOOT9_PROT_HASH}; returned: {b9_hash_digest}')
|
||||
|
||||
keyblob_offset = 0x5860
|
||||
otp_key_offset = 0x56E0
|
||||
if self.dev:
|
||||
keyblob_offset += 0x400
|
||||
otp_key_offset += 0x20
|
||||
|
||||
_otp_key = b9[otp_key_offset:otp_key_offset + 0x10]
|
||||
_otp_iv = b9[otp_key_offset + 0x10:otp_key_offset + 0x20]
|
||||
|
||||
keyblob: bytes = b9[keyblob_offset:keyblob_offset + 0x400]
|
||||
|
||||
_b9_extdata_keygen = keyblob[0:0x200]
|
||||
_b9_extdata_otp = keyblob[0:0x24]
|
||||
|
||||
# Original NCCH key, UDS local-WLAN CCMP key, StreetPass key, 6.0 save key
|
||||
_b9_key_x[0x2C] = _b9_key_x[0x2D] = _b9_key_x[0x2E] = _b9_key_x[0x2F] = readbe(keyblob[0x170:0x180])
|
||||
|
||||
# SD/NAND AES-CMAC key, APT wrap key, Unknown, Gamecard savedata AES-CMAC
|
||||
_b9_key_x[0x30] = _b9_key_x[0x31] = _b9_key_x[0x32] = _b9_key_x[0x33] = readbe(keyblob[0x180:0x190])
|
||||
|
||||
# SD key (loaded from movable.sed), movable.sed key, Unknown (used by friends module),
|
||||
# Gamecard savedata actual key
|
||||
_b9_key_x[0x34] = _b9_key_x[0x35] = _b9_key_x[0x36] = _b9_key_x[0x37] = readbe(keyblob[0x190:0x1A0])
|
||||
|
||||
# BOSS key, Download Play key + actual NFC key for generating retail amiibo keys, CTR-CARD hardware-crypto seed
|
||||
# decryption key
|
||||
_b9_key_x[0x38] = _b9_key_x[0x39] = _b9_key_x[0x3A] = _b9_key_x[0x3B] = readbe(keyblob[0x1A0:0x1B0])
|
||||
|
||||
# Unused
|
||||
_b9_key_x[0x3C] = readbe(keyblob[0x1B0:0x1C0])
|
||||
|
||||
# Common key (titlekey crypto)
|
||||
_b9_key_x[0x3D] = readbe(keyblob[0x1C0:0x1D0])
|
||||
|
||||
# Unused
|
||||
_b9_key_x[0x3E] = readbe(keyblob[0x1D0:0x1E0])
|
||||
|
||||
# NAND partition keys
|
||||
_b9_key_y[0x04] = readbe(keyblob[0x1F0:0x200])
|
||||
# correct 0x05 KeyY not set by boot9.
|
||||
_b9_key_y[0x06] = readbe(keyblob[0x210:0x220])
|
||||
_b9_key_y[0x07] = readbe(keyblob[0x220:0x230])
|
||||
|
||||
# Unused, Unused, DSiWare export key, NAND dbs/movable.sed AES-CMAC key
|
||||
_b9_key_y[0x08] = readbe(keyblob[0x230:0x240])
|
||||
_b9_key_y[0x09] = readbe(keyblob[0x240:0x250])
|
||||
_b9_key_y[0x0A] = readbe(keyblob[0x250:0x260])
|
||||
_b9_key_y[0x0B] = readbe(keyblob[0x260:0x270])
|
||||
|
||||
_b9_key_normal[0x0D] = keyblob[0x270:0x280]
|
||||
|
||||
self._copy_global_keys()
|
||||
|
||||
def setup_keys_from_boot9_file(self, path: str = None):
|
||||
"""Set up certain keys from an ARM9 bootROM file."""
|
||||
global _b9_path
|
||||
if self.b9_keys_set:
|
||||
return
|
||||
|
||||
if _b9_key_x:
|
||||
self.b9_path = _b9_path
|
||||
self._copy_global_keys()
|
||||
return
|
||||
|
||||
paths = (path,) if path else b9_paths
|
||||
|
||||
for p in paths:
|
||||
try:
|
||||
b9_size = getsize(p)
|
||||
if b9_size in {0x8000, 0x10000}:
|
||||
with open(p, 'rb') as f:
|
||||
if b9_size == 0x10000:
|
||||
f.seek(0x8000)
|
||||
self.setup_keys_from_boot9(f.read(0x8000))
|
||||
_b9_path = p
|
||||
self.b9_path = p
|
||||
return
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
# if keys are not set...
|
||||
raise BootromNotFoundError(paths)
|
||||
|
||||
@_requires_bootrom
|
||||
def setup_keys_from_otp(self, otp: bytes):
|
||||
"""Set up console-unique keys from an OTP dump. Encrypted and decrypted are supported."""
|
||||
otp_len = len(otp)
|
||||
if otp_len != 0x100:
|
||||
raise OTPLengthError(otp_len)
|
||||
|
||||
cipher_otp = AES.new(self.otp_key, AES.MODE_CBC, self.otp_iv)
|
||||
if otp[0:4] == b'\x0f\xb0\xad\xde':
|
||||
# decrypted otp
|
||||
otp_enc: bytes = cipher_otp.encrypt(otp)
|
||||
otp_dec = otp
|
||||
else:
|
||||
# encrypted otp
|
||||
otp_enc = otp
|
||||
otp_dec: bytes = cipher_otp.decrypt(otp)
|
||||
|
||||
otp_hash: bytes = otp_dec[0xE0:0x100]
|
||||
otp_hash_digest: bytes = sha256(otp_dec[0:0xE0]).digest()
|
||||
if otp_hash_digest != otp_hash:
|
||||
raise CorruptOTPError(f'expected: {otp_hash.hex()}; result: {otp_hash_digest.hex()}')
|
||||
|
||||
otp_keysect_hash: bytes = sha256(otp_enc[0:0x90]).digest()
|
||||
|
||||
self.set_keyslot('x', 0x11, otp_keysect_hash[0:0x10])
|
||||
self.set_keyslot('y', 0x11, otp_keysect_hash[0:0x10])
|
||||
|
||||
# most otp code from https://github.com/Stary2001/3ds_tools/blob/master/three_ds/aesengine.py
|
||||
|
||||
twl_cid_lo, twl_cid_hi = readle(otp_dec[0x08:0xC]), readle(otp_dec[0xC:0x10])
|
||||
twl_cid_lo ^= 0xB358A6AF
|
||||
twl_cid_lo |= 0x80000000
|
||||
twl_cid_hi ^= 0x08C267B7
|
||||
twl_cid_lo = twl_cid_lo.to_bytes(4, 'little')
|
||||
twl_cid_hi = twl_cid_hi.to_bytes(4, 'little')
|
||||
self.set_keyslot('x', 0x03, twl_cid_lo + b'NINTENDO' + twl_cid_hi)
|
||||
|
||||
console_key_xy: bytes = sha256(otp_dec[0x90:0xAC] + self.b9_extdata_otp).digest()
|
||||
self.set_keyslot('x', 0x3F, console_key_xy[0:0x10])
|
||||
self.set_keyslot('y', 0x3F, console_key_xy[0x10:0x20])
|
||||
|
||||
extdata_off = 0
|
||||
|
||||
def gen(n: int) -> bytes:
|
||||
nonlocal extdata_off
|
||||
extdata_off += 36
|
||||
iv = self.b9_extdata_keygen[extdata_off:extdata_off+16]
|
||||
extdata_off += 16
|
||||
|
||||
data = self.create_cbc_cipher(0x3F, iv).encrypt(self.b9_extdata_keygen[extdata_off:extdata_off + 64])
|
||||
|
||||
extdata_off += n
|
||||
return data
|
||||
|
||||
a = gen(64)
|
||||
for i in range(0x4, 0x8):
|
||||
self.set_keyslot('x', i, a[0:16])
|
||||
|
||||
for i in range(0x8, 0xc):
|
||||
self.set_keyslot('x', i, a[16:32])
|
||||
|
||||
for i in range(0xc, 0x10):
|
||||
self.set_keyslot('x', i, a[32:48])
|
||||
|
||||
self.set_keyslot('x', 0x10, a[48:64])
|
||||
|
||||
b = gen(16)
|
||||
off = 0
|
||||
for i in range(0x14, 0x18):
|
||||
self.set_keyslot('x', i, b[off:off + 16])
|
||||
off += 16
|
||||
|
||||
c = gen(64)
|
||||
for i in range(0x18, 0x1c):
|
||||
self.set_keyslot('x', i, c[0:16])
|
||||
|
||||
for i in range(0x1c, 0x20):
|
||||
self.set_keyslot('x', i, c[16:32])
|
||||
|
||||
for i in range(0x20, 0x24):
|
||||
self.set_keyslot('x', i, c[32:48])
|
||||
|
||||
self.set_keyslot('x', 0x24, c[48:64])
|
||||
|
||||
d = gen(16)
|
||||
off = 0
|
||||
|
||||
for i in range(0x28, 0x2c):
|
||||
self.set_keyslot('x', i, d[off:off + 16])
|
||||
off += 16
|
||||
|
||||
@_requires_bootrom
|
||||
def setup_keys_from_otp_file(self, path: str):
|
||||
"""Set up console-unique keys from an OTP file. Encrypted and decrypted are supported."""
|
||||
with open(path, 'rb') as f:
|
||||
self.setup_keys_from_otp(f.read(0x100))
|
||||
|
||||
def setup_sd_key(self, data: bytes):
|
||||
"""Set up the SD key from movable.sed. Must be 0x10 (only key), 0x120 (no cmac), or 0x140 (with cmac)."""
|
||||
if len(data) == 0x10:
|
||||
key = data
|
||||
elif len(data) in {0x120, 0x140}:
|
||||
key = data[0x110:0x120]
|
||||
else:
|
||||
raise BadMovableSedError(f'invalid length ({len(data):#x}')
|
||||
|
||||
self.set_keyslot('y', Keyslot.SD, key)
|
||||
self.set_keyslot('y', Keyslot.CMACSDNAND, key)
|
||||
self.set_keyslot('y', Keyslot.DSiWareExport, key)
|
||||
|
||||
key_hash = sha256(key).digest()[0:16]
|
||||
hash_parts = unpack('<IIII', key_hash)
|
||||
self._id0 = pack('>IIII', *hash_parts)
|
||||
|
||||
def setup_sd_key_from_file(self, path: str):
|
||||
"""Set up the SD key from a movable.sed file."""
|
||||
with open(path, 'rb') as f:
|
||||
self.setup_sd_key(f.read(0x140))
|
||||
@@ -1,12 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
|
||||
class TitleReaderBase:
|
||||
|
||||
closed = False
|
||||
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from enum import IntEnum
|
||||
from io import BytesIO
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from ..common import PyCTRError, _ReaderOpenFileBase
|
||||
from ..crypto import CryptoEngine, Keyslot
|
||||
from ..types.ncch import NCCHReader
|
||||
from ..types.tmd import TitleMetadataReader
|
||||
from ..util import readle, roundup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import BinaryIO, Dict, Optional, Union
|
||||
|
||||
ALIGN_SIZE = 64
|
||||
|
||||
|
||||
class CIAError(PyCTRError):
|
||||
"""Generic error for CIA operations."""
|
||||
|
||||
|
||||
class InvalidCIAError(CIAError):
|
||||
"""Invalid CIA header exception."""
|
||||
|
||||
|
||||
class CIASection(IntEnum):
|
||||
# these values as negative, as positive ones are used for contents
|
||||
ArchiveHeader = -4
|
||||
CertificateChain = -3
|
||||
Ticket = -2
|
||||
TitleMetadata = -1
|
||||
Meta = -5
|
||||
|
||||
|
||||
class CIARegion(NamedTuple):
|
||||
section: 'Union[int, CIASection]'
|
||||
offset: int
|
||||
size: int
|
||||
iv: bytes # only used for encrypted sections
|
||||
|
||||
|
||||
class _CIASectionFile(_ReaderOpenFileBase):
|
||||
"""Provides a raw CIA section as a file-like object."""
|
||||
|
||||
def __init__(self, reader: 'CIAReader', path: 'CIASection'):
|
||||
super().__init__(reader, path)
|
||||
self._info = reader.sections[path]
|
||||
|
||||
|
||||
class CIAReader:
|
||||
"""Class for the 3DS CIA container."""
|
||||
|
||||
closed = False
|
||||
|
||||
def __init__(self, fp: 'Union[str, BinaryIO]', *, case_insensitive: bool = True, crypto: CryptoEngine = None,
|
||||
dev: bool = False, seeddb: str = None, load_contents: bool = True):
|
||||
if isinstance(fp, str):
|
||||
fp = open(fp, 'rb')
|
||||
|
||||
if crypto:
|
||||
self._crypto = crypto
|
||||
else:
|
||||
self._crypto = CryptoEngine(dev=dev)
|
||||
|
||||
# store the starting offset so the CIA can be read from any point in the base file
|
||||
self._start = fp.tell()
|
||||
self._fp = fp
|
||||
# store case-insensitivity for RomFSReader
|
||||
self._case_insensitive = case_insensitive
|
||||
# threading lock
|
||||
self._lock = Lock()
|
||||
|
||||
header = fp.read(0x20)
|
||||
|
||||
archive_header_size = readle(header[0x0:0x4])
|
||||
if archive_header_size != 0x2020:
|
||||
raise InvalidCIAError('Archive Header Size is not 0x2020')
|
||||
# in practice, the certificate chain is the same for all retail titles
|
||||
cert_chain_size = readle(header[0x8:0xC])
|
||||
# the ticket size usually never changes from 0x350
|
||||
# there is one ticket (without an associated title) that is smaller though
|
||||
ticket_size = readle(header[0xC:0x10])
|
||||
# tmd contains info about the contents of the title
|
||||
tmd_size = readle(header[0x10:0x14])
|
||||
# meta contains info such as the SMDH and Title ID dependency list
|
||||
meta_size = readle(header[0x14:0x18])
|
||||
# content size is the total size of the contents
|
||||
# I'm not sure what happens yet if one of the contents is not aligned to 0x40 bytes.
|
||||
content_size = readle(header[0x18:0x20])
|
||||
# the content index determines what contents are in the CIA
|
||||
# this is not stored as int, so it's faster to parse(?)
|
||||
content_index = fp.read(archive_header_size - 0x20)
|
||||
|
||||
active_contents = set()
|
||||
for idx, b in enumerate(content_index):
|
||||
offset = idx * 8
|
||||
curr = b
|
||||
for x in range(7, -1, -1):
|
||||
if curr & 1:
|
||||
active_contents.add(x + offset)
|
||||
curr >>= 1
|
||||
|
||||
# the header only stores sizes; offsets need to be calculated.
|
||||
# the sections are aligned to 64(0x40) bytes. for example, if something is 0x78,
|
||||
# it will take up 0x80, with the remaining 0x8 being padding.
|
||||
cert_chain_offset = roundup(archive_header_size, ALIGN_SIZE)
|
||||
ticket_offset = cert_chain_offset + roundup(cert_chain_size, ALIGN_SIZE)
|
||||
tmd_offset = ticket_offset + roundup(ticket_size, ALIGN_SIZE)
|
||||
content_offset = tmd_offset + roundup(tmd_size, ALIGN_SIZE)
|
||||
meta_offset = content_offset + roundup(content_size, ALIGN_SIZE)
|
||||
|
||||
# lazy method to get the total size
|
||||
self.total_size = meta_offset + meta_size
|
||||
|
||||
# this contains the location of each section, as well as the IV of encrypted ones
|
||||
self.sections: Dict[Union[int, CIASection], CIARegion] = {}
|
||||
|
||||
def add_region(section: 'Union[int, CIASection]', offset: int, size: int, iv: 'Optional[bytes]'):
|
||||
region = CIARegion(section=section, offset=offset, size=size, iv=iv)
|
||||
self.sections[section] = region
|
||||
|
||||
# add each part of the header
|
||||
add_region(CIASection.ArchiveHeader, 0, archive_header_size, None)
|
||||
add_region(CIASection.CertificateChain, cert_chain_offset, cert_chain_size, None)
|
||||
add_region(CIASection.Ticket, ticket_offset, ticket_size, None)
|
||||
add_region(CIASection.TitleMetadata, tmd_offset, tmd_size, None)
|
||||
if meta_size:
|
||||
add_region(CIASection.Meta, meta_offset, meta_size, None)
|
||||
|
||||
# this will load the titlekey to decrypt the contents
|
||||
self._fp.seek(self._start + ticket_offset)
|
||||
ticket = self._fp.read(ticket_size)
|
||||
self._crypto.load_from_ticket(ticket)
|
||||
|
||||
# the tmd describes the contents: ID, index, size, and hash
|
||||
self._fp.seek(self._start + tmd_offset)
|
||||
tmd_data = self._fp.read(tmd_size)
|
||||
self.tmd = TitleMetadataReader.load(BytesIO(tmd_data))
|
||||
|
||||
active_contents_tmd = set()
|
||||
self.content_info = []
|
||||
|
||||
# this does a first check to make sure there are no missing contents that are marked active in content_index
|
||||
for record in self.tmd.chunk_records:
|
||||
if record.cindex in active_contents:
|
||||
active_contents_tmd.add(record.cindex)
|
||||
self.content_info.append(record)
|
||||
|
||||
# if the result of this is not an empty set, it means there are contents enabled in content_index
|
||||
# that are not in the tmd, which is bad
|
||||
if active_contents ^ active_contents_tmd:
|
||||
raise InvalidCIAError('Missing active contents in the TMD')
|
||||
|
||||
self.contents = {}
|
||||
|
||||
# this goes through the contents and figures out their regions, then creates an NCCHReader
|
||||
curr_offset = content_offset
|
||||
for record in self.content_info:
|
||||
iv = None
|
||||
if record.type.encrypted:
|
||||
iv = record.cindex.to_bytes(2, 'big') + (b'\0' * 14)
|
||||
add_region(record.cindex, curr_offset, record.size, iv)
|
||||
if load_contents:
|
||||
# check if the content is a Nintendo DS ROM (SRL) first
|
||||
is_srl = record.cindex == 0 and self.tmd.title_id[3:5] == '48'
|
||||
if not is_srl:
|
||||
content_fp = self.open_raw_section(record.cindex)
|
||||
self.contents[record.cindex] = NCCHReader(content_fp, case_insensitive=case_insensitive,
|
||||
dev=dev, seeddb=seeddb)
|
||||
|
||||
curr_offset += record.size
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
try:
|
||||
self._fp.close()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
__del__ = close
|
||||
|
||||
def __repr__(self):
|
||||
info = [('title_id', self.tmd.title_id)]
|
||||
try:
|
||||
info.append(('title_name', repr(self.contents[0].exefs.icon.get_app_title().short_desc)))
|
||||
except KeyError:
|
||||
info.append(('title_name', 'unknown'))
|
||||
info.append(('content_count', len(self.contents)))
|
||||
info_final = " ".join(x + ": " + str(y) for x, y in info)
|
||||
return f'<{type(self).__name__} {info_final}>'
|
||||
|
||||
def open_raw_section(self, section: 'CIASection'):
|
||||
"""Open a raw CIA section for reading."""
|
||||
return _CIASectionFile(self, section)
|
||||
|
||||
def get_data(self, region: 'CIARegion', offset: int, size: int) -> bytes:
|
||||
if offset + size > region.size:
|
||||
# prevent reading past the region
|
||||
size = region.size - offset
|
||||
|
||||
with self._lock:
|
||||
if region.iv:
|
||||
real_size = size
|
||||
# if encrypted, the block needs to be decrypted first
|
||||
# CBC requires a full block (0x10 in this case). and the previous
|
||||
# block is used as the IV. so that's quite a bit to read if the
|
||||
# application requires just a few bytes.
|
||||
# thanks Stary2001 for help with random-access crypto
|
||||
before = offset % 16
|
||||
if size % 16 != 0:
|
||||
size = size + 16 - size % 16
|
||||
if offset - before == 0:
|
||||
iv = region.iv
|
||||
else:
|
||||
self._fp.seek(self._start + region.offset + offset - before - 0x10)
|
||||
iv = self._fp.read(0x10)
|
||||
# read to block size
|
||||
self._fp.seek(self._start + region.offset + offset - before)
|
||||
# adding x10 to the size fixes some kind of decryption bug I think. this needs more testing.
|
||||
return self._crypto.create_cbc_cipher(Keyslot.DecryptedTitlekey,
|
||||
iv).decrypt(self._fp.read(size + 0x10))[before:real_size + before]
|
||||
else:
|
||||
# no encryption
|
||||
self._fp.seek(self._start + region.offset + offset)
|
||||
return self._fp.read(size)
|
||||
@@ -1,308 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from hashlib import sha256
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from ..common import PyCTRError, _ReaderOpenFileBase
|
||||
from ..util import readle
|
||||
from ..types.smdh import SMDH, InvalidSMDHError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import BinaryIO, Dict, Union
|
||||
|
||||
__all__ = ['EXEFS_EMPTY_ENTRY', 'EXEFS_ENTRY_SIZE', 'EXEFS_ENTRY_COUNT', 'EXEFS_HEADER_SIZE', 'ExeFSError',
|
||||
'ExeFSFileNotFoundError', 'InvalidExeFSError', 'ExeFSNameError', 'BadOffsetError', 'CodeDecompressionError',
|
||||
'decompress_code', 'ExeFSReader']
|
||||
|
||||
EXEFS_ENTRY_SIZE = 0x10
|
||||
EXEFS_ENTRY_COUNT = 10
|
||||
EXEFS_EMPTY_ENTRY = b'\0' * EXEFS_ENTRY_SIZE
|
||||
EXEFS_HEADER_SIZE = 0x200
|
||||
|
||||
CODE_DECOMPRESSED_NAME = '.code-decompressed'
|
||||
|
||||
|
||||
class ExeFSError(PyCTRError):
|
||||
"""Generic exception for ExeFS operations."""
|
||||
|
||||
|
||||
class ExeFSFileNotFoundError(ExeFSError):
|
||||
"""File not found in the ExeFS."""
|
||||
|
||||
|
||||
class InvalidExeFSError(ExeFSError):
|
||||
"""Invalid ExeFS header."""
|
||||
|
||||
|
||||
class ExeFSNameError(InvalidExeFSError):
|
||||
"""Name could not be decoded, likely making the file not a valid ExeFS."""
|
||||
|
||||
def __str__(self):
|
||||
return f'could not decode from ascii: {self.args[0]!r}'
|
||||
|
||||
|
||||
class BadOffsetError(InvalidExeFSError):
|
||||
"""Offset is not a multiple of 0x200. This kind of ExeFS will not work on a 3DS."""
|
||||
|
||||
def __str__(self):
|
||||
return f'offset is not a multiple of 0x200: {self.args[0]:#x}'
|
||||
|
||||
|
||||
class CodeDecompressionError(ExeFSError):
|
||||
"""Exception when attempting to decompress ExeFS .code."""
|
||||
|
||||
|
||||
# lazy check
|
||||
CODE_MAX_SIZE = 0x2300000
|
||||
|
||||
|
||||
def decompress_code(code: bytes) -> bytes:
|
||||
# remade from C code, this could probably be done better
|
||||
# https://github.com/d0k3/GodMode9/blob/689f6f7cf4280bf15885cbbf848d8dce81def36b/arm9/source/game/codelzss.c#L25-L93
|
||||
off_size_comp = int.from_bytes(code[-8:-4], 'little')
|
||||
add_size = int.from_bytes(code[-4:], 'little')
|
||||
comp_start = 0
|
||||
code_len = len(code)
|
||||
|
||||
code_comp_size = off_size_comp & 0xFFFFFF
|
||||
code_comp_end = code_comp_size - ((off_size_comp >> 24) % 0xFF)
|
||||
code_dec_size = code_len + add_size
|
||||
|
||||
if code_len < 8:
|
||||
raise CodeDecompressionError('code_len < 8')
|
||||
if code_len > CODE_MAX_SIZE:
|
||||
raise CodeDecompressionError('code_len > CODE_MAX_SIZE')
|
||||
|
||||
if code_comp_size <= code_len:
|
||||
comp_start = code_len - code_comp_size
|
||||
|
||||
if code_comp_end < 0:
|
||||
raise CodeDecompressionError('code_comp_end < 0')
|
||||
if code_dec_size > CODE_MAX_SIZE:
|
||||
raise CodeDecompressionError('code_dec_size > CODE_MAX_SIZE')
|
||||
|
||||
dec = bytearray(code)
|
||||
dec.extend(b'\0' * add_size)
|
||||
|
||||
data_end = comp_start + code_dec_size
|
||||
ptr_in = comp_start + code_comp_end
|
||||
ptr_out = code_dec_size
|
||||
|
||||
while ptr_in > comp_start and ptr_out > comp_start:
|
||||
if ptr_out < ptr_in:
|
||||
raise CodeDecompressionError('ptr_out < ptr_in')
|
||||
|
||||
ptr_in -= 1
|
||||
ctrl_byte = dec[ptr_in]
|
||||
for i in range(7, -1, -1):
|
||||
if ptr_in <= comp_start or ptr_out <= comp_start:
|
||||
break
|
||||
|
||||
if (ctrl_byte >> i) & 1:
|
||||
ptr_in -= 2
|
||||
seg_code = int.from_bytes(dec[ptr_in:ptr_in + 2], 'little')
|
||||
if ptr_in < comp_start:
|
||||
raise CodeDecompressionError('ptr_in < comp_start')
|
||||
seg_off = (seg_code & 0x0FFF) + 2
|
||||
seg_len = ((seg_code >> 12) & 0xF) + 3
|
||||
|
||||
if ptr_out - seg_len < comp_start:
|
||||
raise CodeDecompressionError('ptr_out - seg_len < comp_start')
|
||||
if ptr_out + seg_off >= data_end:
|
||||
raise CodeDecompressionError('ptr_out + seg_off >= data_end')
|
||||
|
||||
c = 0
|
||||
while c < seg_len:
|
||||
byte = dec[ptr_out + seg_off]
|
||||
ptr_out -= 1
|
||||
dec[ptr_out] = byte
|
||||
c += 1
|
||||
else:
|
||||
if ptr_out == comp_start:
|
||||
raise CodeDecompressionError('ptr_out == comp_start')
|
||||
if ptr_in == comp_start:
|
||||
raise CodeDecompressionError('ptr_in == comp_start')
|
||||
|
||||
ptr_out -= 1
|
||||
ptr_in -= 1
|
||||
dec[ptr_out] = dec[ptr_in]
|
||||
|
||||
if ptr_in != comp_start:
|
||||
raise CodeDecompressionError('ptr_in != comp_start')
|
||||
if ptr_out != comp_start:
|
||||
raise CodeDecompressionError('ptr_out != comp_start')
|
||||
|
||||
return bytes(dec)
|
||||
|
||||
|
||||
class ExeFSEntry(NamedTuple):
|
||||
name: str
|
||||
offset: int
|
||||
size: int
|
||||
hash: bytes
|
||||
|
||||
|
||||
def _normalize_path(p: str):
|
||||
"""Fix a given path to work with ExeFS filenames."""
|
||||
if p.startswith('/'):
|
||||
p = p[1:]
|
||||
# while it is technically possible for an ExeFS entry to contain ".bin",
|
||||
# this would not happen in practice.
|
||||
# even so, normalization can be disabled by passing normalize=False to
|
||||
# ExeFSReader.open
|
||||
if p.lower().endswith('.bin'):
|
||||
p = p[:4]
|
||||
return p
|
||||
|
||||
|
||||
class _ExeFSOpenFile(_ReaderOpenFileBase):
|
||||
"""Class for open ExeFS file entries."""
|
||||
|
||||
def __init__(self, reader: 'ExeFSReader', path: str):
|
||||
super().__init__(reader, path)
|
||||
try:
|
||||
self._info = reader.entries[self._path]
|
||||
except KeyError:
|
||||
raise ExeFSFileNotFoundError(self._path)
|
||||
|
||||
|
||||
class ExeFSReader:
|
||||
"""
|
||||
Class to read the 3DS ExeFS container.
|
||||
|
||||
http://3dbrew.org/wiki/ExeFS
|
||||
"""
|
||||
|
||||
closed = False
|
||||
_code_dec = None
|
||||
icon: 'SMDH' = None
|
||||
|
||||
def __init__(self, fp: 'Union[str, BinaryIO]', *, _load_icon: bool = True):
|
||||
if isinstance(fp, str):
|
||||
fp = open(fp, 'rb')
|
||||
|
||||
# storing the starting offset lets it work from anywhere in the file
|
||||
self._start = fp.tell()
|
||||
self._fp = fp
|
||||
self._lock = Lock()
|
||||
|
||||
self.entries: 'Dict[str, ExeFSEntry]' = {}
|
||||
|
||||
header = fp.read(EXEFS_HEADER_SIZE)
|
||||
|
||||
# ExeFS entries can fit up to 10 names. hashes are stored in reverse order
|
||||
# (e.g. the first entry would have the hash at the very end - 0x1E0)
|
||||
for entry_n, hash_n in zip(range(0, EXEFS_ENTRY_COUNT * EXEFS_ENTRY_SIZE, EXEFS_ENTRY_SIZE),
|
||||
range(0x1E0, 0xA0, -0x20)):
|
||||
entry_raw = header[entry_n:entry_n + 0x10]
|
||||
entry_hash = header[hash_n:hash_n + 0x20]
|
||||
if entry_raw == EXEFS_EMPTY_ENTRY:
|
||||
continue
|
||||
|
||||
try:
|
||||
# ascii is used since only a-z would be used in practice
|
||||
name = entry_raw[0:8].rstrip(b'\0').decode('ascii')
|
||||
except UnicodeDecodeError:
|
||||
raise ExeFSNameError(entry_raw[0:8])
|
||||
|
||||
entry = ExeFSEntry(name=name,
|
||||
offset=readle(entry_raw[8:12]),
|
||||
size=readle(entry_raw[12:16]),
|
||||
hash=entry_hash)
|
||||
|
||||
# the 3DS fails to parse an ExeFS with an offset that isn't a multiple of 0x200
|
||||
# so we should do the same here
|
||||
if entry.offset % 0x200:
|
||||
raise BadOffsetError(entry.offset)
|
||||
|
||||
self.entries[name] = entry
|
||||
|
||||
# this sometimes needs to be loaded outside, since reading it here may cause encryption problems
|
||||
# when the NCCH has not fully initialized yet and needs to figure out what ExeFS regions need
|
||||
# to be decrypted with the Original NCCH key
|
||||
if _load_icon:
|
||||
self._load_icon()
|
||||
|
||||
def _load_icon(self):
|
||||
try:
|
||||
with self.open('icon') as f:
|
||||
self.icon = SMDH.load(f)
|
||||
except InvalidSMDHError:
|
||||
pass
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the amount of entries in the ExeFS."""
|
||||
return len(self.entries)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
try:
|
||||
self._fp.close()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
__del__ = close
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
def open(self, path: str, *, normalize: bool = True):
|
||||
"""Open a file in the ExeFS for reading."""
|
||||
if normalize:
|
||||
# remove beginning "/" and ending ".bin"
|
||||
path = _normalize_path(path)
|
||||
return _ExeFSOpenFile(self, path)
|
||||
|
||||
def get_data(self, info: ExeFSEntry, offset: int, size: int) -> bytes:
|
||||
if offset + size > info.size:
|
||||
size = info.size - offset
|
||||
with self._lock:
|
||||
if info.offset == -1:
|
||||
# return the decompressed code instead
|
||||
return self._code_dec[offset:offset + size]
|
||||
else:
|
||||
# data for ExeFS entries start relative to the end of the header
|
||||
self._fp.seek(self._start + EXEFS_HEADER_SIZE + info.offset + offset)
|
||||
return self._fp.read(size)
|
||||
|
||||
def decompress_code(self) -> bool:
|
||||
"""
|
||||
Decompress '.code' in the container. The result will be available as '.code-decompressed'.
|
||||
|
||||
The return value is if '.code' was actually decompressed.
|
||||
"""
|
||||
with self.open('.code') as f:
|
||||
code = f.read()
|
||||
|
||||
# if it's already decompressed, this would return the code unmodified
|
||||
code_dec = decompress_code(code)
|
||||
|
||||
decompressed = code_dec != code
|
||||
|
||||
if decompressed:
|
||||
code_dec_hash = sha256(code_dec)
|
||||
entry = ExeFSEntry(name=CODE_DECOMPRESSED_NAME,
|
||||
offset=-1,
|
||||
size=len(code_dec),
|
||||
hash=code_dec_hash.digest())
|
||||
self._code_dec = code_dec
|
||||
else:
|
||||
# if the code was already decompressed, don't store a second copy in memory
|
||||
code_entry = self.entries['.code']
|
||||
entry = ExeFSEntry(name=CODE_DECOMPRESSED_NAME,
|
||||
offset=code_entry.offset,
|
||||
size=code_entry.size,
|
||||
hash=code_entry.hash)
|
||||
|
||||
self.entries[CODE_DECOMPRESSED_NAME] = entry
|
||||
|
||||
# returns if the code was actually decompressed or not
|
||||
return decompressed
|
||||
@@ -1,9 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
|
||||
class ExtendedHeaderReader:
|
||||
def __init__(self):
|
||||
@@ -1,521 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from hashlib import sha256
|
||||
from enum import IntEnum
|
||||
from math import ceil
|
||||
from os import environ
|
||||
from os.path import join as pjoin
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from .exefs import ExeFSReader, EXEFS_HEADER_SIZE
|
||||
from .romfs import RomFSReader
|
||||
from ..common import PyCTRError, _ReaderOpenFileBase
|
||||
from ..util import config_dirs, readle, roundup
|
||||
from ..crypto import CryptoEngine, Keyslot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import BinaryIO, Dict, List, Optional, Tuple, Union
|
||||
|
||||
__all__ = ['NCCH_MEDIA_UNIT', 'NO_ENCRYPTION', 'EXEFS_NORMAL_CRYPTO_FILES', 'FIXED_SYSTEM_KEY', 'NCCHError',
|
||||
'InvalidNCCHError', 'NCCHSeedError', 'MissingSeedError', 'SeedDBNotFoundError', 'get_seed',
|
||||
'extra_cryptoflags', 'NCCHSection', 'NCCHRegion', 'NCCHFlags', 'NCCHReader']
|
||||
|
||||
|
||||
class NCCHError(PyCTRError):
|
||||
"""Generic exception for NCCH operations."""
|
||||
|
||||
|
||||
class InvalidNCCHError(NCCHError):
|
||||
"""Invalid NCCH header exception."""
|
||||
|
||||
|
||||
class NCCHSeedError(NCCHError):
|
||||
"""NCCH seed is not set up, or attempted to set up seed when seed crypto is not used."""
|
||||
|
||||
|
||||
class MissingSeedError(NCCHSeedError):
|
||||
"""Seed could not be found."""
|
||||
|
||||
|
||||
class SeedDBNotFoundError(NCCHSeedError):
|
||||
"""SeedDB was not found. Main argument is a tuple of checked paths."""
|
||||
|
||||
|
||||
def get_seed(f: 'BinaryIO', program_id: int) -> bytes:
|
||||
"""Get a seed in a seeddb.bin from an I/O stream."""
|
||||
# convert the Program ID to little-endian bytes, as the TID is stored in seeddb.bin this way
|
||||
tid_bytes = program_id.to_bytes(0x8, 'little')
|
||||
f.seek(0)
|
||||
# get the amount of seeds
|
||||
seed_count = readle(f.read(4))
|
||||
f.seek(0x10)
|
||||
for _ in range(seed_count):
|
||||
entry = f.read(0x20)
|
||||
if entry[0:8] == tid_bytes:
|
||||
return entry[0x8:0x18]
|
||||
raise NCCHSeedError(f'missing seed for {program_id:016X} from seeddb.bin')
|
||||
|
||||
|
||||
seeddb_paths = [pjoin(x, 'seeddb.bin') for x in config_dirs]
|
||||
try:
|
||||
# try to insert the path in the SEEDDB_PATH environment variable
|
||||
seeddb_paths.insert(0, environ['SEEDDB_PATH'])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# NCCH sections are stored in media units
|
||||
# for example, ExeFS may be stored in 13 media units, which is 0x1A00 bytes (13 * 0x200)
|
||||
NCCH_MEDIA_UNIT = 0x200
|
||||
# depending on the crypto_method flag, a different keyslot may be used for RomFS and parts of ExeFS.
|
||||
extra_cryptoflags = {0x00: Keyslot.NCCH, 0x01: Keyslot.NCCH70, 0x0A: Keyslot.NCCH93, 0x0B: Keyslot.NCCH96}
|
||||
|
||||
# if fixed_crypto_key is enabled, the normal key is normally all zeros.
|
||||
# however is (program_id & (0x10 << 32)) is true, this key is used instead.
|
||||
FIXED_SYSTEM_KEY = 0x527CE630A9CA305F3696F3CDE954194B
|
||||
|
||||
|
||||
# this is IntEnum to make generating the IV easier
|
||||
class NCCHSection(IntEnum):
|
||||
ExtendedHeader = 1
|
||||
ExeFS = 2
|
||||
RomFS = 3
|
||||
|
||||
# no crypto
|
||||
Header = 4
|
||||
Logo = 5
|
||||
Plain = 6
|
||||
|
||||
# special
|
||||
FullDecrypted = 7
|
||||
Raw = 8
|
||||
|
||||
|
||||
# these sections don't use encryption at all
|
||||
NO_ENCRYPTION = {NCCHSection.Header, NCCHSection.Logo, NCCHSection.Plain, NCCHSection.Raw}
|
||||
# the contents of these files in the ExeFS, plus the header, will always use the Original NCCH keyslot
|
||||
# therefore these regions need to be stored to check what keyslot is used to decrypt
|
||||
EXEFS_NORMAL_CRYPTO_FILES = {'icon', 'banner'}
|
||||
|
||||
|
||||
class NCCHRegion(NamedTuple):
|
||||
section: 'NCCHSection'
|
||||
offset: int
|
||||
size: int
|
||||
end: int # this is just offset + size, stored to avoid re-calculation later on
|
||||
# not all sections will actually use this (see NCCHSection), so some have a useless value
|
||||
iv: int
|
||||
|
||||
|
||||
class NCCHFlags(NamedTuple):
|
||||
# determines the extra keyslot used for RomFS and parts of ExeFS
|
||||
crypto_method: int
|
||||
# if this is a CXI (CTR Executable Image) or CFA (CTR File Archive)
|
||||
# in the raw flags, "Data" has to be set for it to be a CFA, while "Executable" is unset.
|
||||
executable: bool
|
||||
# if the content is encrypted using a fixed normal key.
|
||||
fixed_crypto_key: bool
|
||||
# if RomFS is to be ignored
|
||||
no_romfs: bool
|
||||
# if the NCCH has no encryption
|
||||
no_crypto: bool
|
||||
# if a seed must be loaded to load RomFS and parts of ExeFS
|
||||
uses_seed: bool
|
||||
|
||||
|
||||
class _NCCHSectionFile(_ReaderOpenFileBase):
|
||||
"""Provides a raw, decrypted NCCH section as a file-like object."""
|
||||
|
||||
def __init__(self, reader: 'NCCHReader', path: 'NCCHSection'):
|
||||
super().__init__(reader, path)
|
||||
self._info = reader.sections[path]
|
||||
|
||||
|
||||
class NCCHReader:
|
||||
"""Class for 3DS NCCH container."""
|
||||
|
||||
seed_set_up = False
|
||||
seed: 'Optional[bytes]' = None
|
||||
# this is the KeyY when generated using the seed
|
||||
_seeded_key_y = None
|
||||
closed = False
|
||||
|
||||
# this lists the ranges of the ExeFS to decrypt with Original NCCH (see load_sections)
|
||||
_exefs_keyslot_normal_range: 'List[Tuple[int, int]]'
|
||||
exefs: 'Optional[ExeFSReader]' = None
|
||||
romfs: 'Optional[RomFSReader]' = None
|
||||
|
||||
def __init__(self, fp: 'Union[str, BinaryIO]', *, case_insensitive: bool = True, crypto: CryptoEngine = None,
|
||||
dev: bool = False, seeddb: str = None, load_sections: bool = True):
|
||||
if isinstance(fp, str):
|
||||
fp = open(fp, 'rb')
|
||||
|
||||
if crypto:
|
||||
self._crypto = crypto
|
||||
else:
|
||||
self._crypto = CryptoEngine(dev=dev)
|
||||
|
||||
# store the starting offset so the NCCH can be read from any point in the base file
|
||||
self._start = fp.tell()
|
||||
self._fp = fp
|
||||
# store case-insensitivity for RomFSReader
|
||||
self._case_insensitive = case_insensitive
|
||||
# threaing lock
|
||||
self._lock = Lock()
|
||||
|
||||
header = fp.read(0x200)
|
||||
|
||||
# load the Key Y from the first 0x10 of the signature
|
||||
self._key_y = header[0x0:0x10]
|
||||
# store the ncch version
|
||||
self.version = readle(header[0x112:0x114])
|
||||
# get the total size of the NCCH container, and store it in bytes
|
||||
self.content_size = readle(header[0x104:0x108]) * NCCH_MEDIA_UNIT
|
||||
# get the Partition ID, which is used in the encryption
|
||||
# this is generally different for each content in a title, except for DLC
|
||||
self.partition_id = readle(header[0x108:0x110])
|
||||
# load the seed verify field, which is part of an sha256 hash to verify if
|
||||
# a seed is correct for this title
|
||||
self._seed_verify = header[0x114:0x118]
|
||||
# load the Product Code store it as a unicode string
|
||||
self.product_code = header[0x150:0x160].decode('ascii').strip('\0')
|
||||
# load the Program ID
|
||||
# this is the Title ID, and
|
||||
self.program_id = readle(header[0x118:0x120])
|
||||
# load the extheader size, but this code only uses it to determine if it exists
|
||||
extheader_size = readle(header[0x180:0x184])
|
||||
|
||||
# each section is stored with the section ID, then the region information (offset, size, IV)
|
||||
self.sections: 'Dict[NCCHSection, NCCHRegion]' = {}
|
||||
# same as above, but includes non-existant regions too, for the full-decrypted handler
|
||||
self._all_sections: 'Dict[NCCHSection, NCCHRegion]' = {}
|
||||
|
||||
def add_region(section: 'NCCHSection', starting_unit: int, units: int):
|
||||
offset = starting_unit * NCCH_MEDIA_UNIT
|
||||
size = units * NCCH_MEDIA_UNIT
|
||||
region = NCCHRegion(section=section,
|
||||
offset=offset,
|
||||
size=size,
|
||||
end=offset + size,
|
||||
iv=self.partition_id << 64 | (section << 56))
|
||||
self._all_sections[section] = region
|
||||
if units != 0: # only add existing regions
|
||||
self.sections[section] = region
|
||||
|
||||
# add the header as the first region
|
||||
add_region(NCCHSection.Header, 0, 1)
|
||||
|
||||
# add the full decrypted content, which when read, simulates a fully decrypted NCCH container
|
||||
add_region(NCCHSection.FullDecrypted, 0, self.content_size // NCCH_MEDIA_UNIT)
|
||||
# add the full raw content
|
||||
add_region(NCCHSection.Raw, 0, self.content_size // NCCH_MEDIA_UNIT)
|
||||
|
||||
# only care about the exheader if it's the expected size
|
||||
if extheader_size == 0x400:
|
||||
add_region(NCCHSection.ExtendedHeader, 1, 4)
|
||||
else:
|
||||
add_region(NCCHSection.ExtendedHeader, 0, 0)
|
||||
|
||||
# add the remaining NCCH regions
|
||||
# some of these may not exist, and won't be added if units (second value) is 0
|
||||
add_region(NCCHSection.Logo, readle(header[0x198:0x19C]), readle(header[0x19C:0x1A0]))
|
||||
add_region(NCCHSection.Plain, readle(header[0x190:0x194]), readle(header[0x194:0x198]))
|
||||
add_region(NCCHSection.ExeFS, readle(header[0x1A0:0x1A4]), readle(header[0x1A4:0x1A8]))
|
||||
add_region(NCCHSection.RomFS, readle(header[0x1B0:0x1B4]), readle(header[0x1B4:0x1B8]))
|
||||
|
||||
# parse flags
|
||||
flags_raw = header[0x188:0x190]
|
||||
self.flags = NCCHFlags(crypto_method=flags_raw[3], executable=bool(flags_raw[5] & 0x2),
|
||||
fixed_crypto_key=bool(flags_raw[7] & 0x1), no_romfs=bool(flags_raw[7] & 0x2),
|
||||
no_crypto=bool(flags_raw[7] & 0x4), uses_seed=bool(flags_raw[7] & 0x20))
|
||||
|
||||
# load the original (non-seeded) KeyY into the Original NCCH slot
|
||||
self._crypto.set_keyslot('y', Keyslot.NCCH, self.get_key_y(original=True))
|
||||
|
||||
# load the seed if needed
|
||||
if self.flags.uses_seed:
|
||||
self.load_seed_from_seeddb(seeddb)
|
||||
|
||||
# load the (seeded, if needed) key into the extra keyslot
|
||||
self._crypto.set_keyslot('y', self.extra_keyslot, self.get_key_y())
|
||||
|
||||
# load the sections using their specific readers
|
||||
if load_sections:
|
||||
self.load_sections()
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
try:
|
||||
self._fp.close()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
__del__ = close
|
||||
|
||||
def load_sections(self):
|
||||
"""Load the sections of the NCCH (Extended Header, ExeFS, and RomFS)."""
|
||||
|
||||
# try to load the ExeFS
|
||||
try:
|
||||
self._fp.seek(self._start + self.sections[NCCHSection.ExeFS].offset)
|
||||
except KeyError:
|
||||
pass # no ExeFS
|
||||
else:
|
||||
# this is to generate what regions should be decrypted with the Original NCCH keyslot
|
||||
# technically, it's not actually 0x200 chunks or units. the actual space of the file
|
||||
# is encrypted with the different key. for example, if .code is 0x300 bytes, that
|
||||
# means the first 0x300 are encrypted with the NCCH 7.x key, and the remaining
|
||||
# 0x100 uses Original NCCH. however this would be quite a pain to implement properly
|
||||
# with random access, so I only work with 0x200 chunks here. after all, the space
|
||||
# after the file is effectively unused. it makes no difference, except for
|
||||
# perfectionists who want it perfectly decrypted. GodMode9 does it properly I think,
|
||||
# if that is what you want. or you can fix the empty space yourself with a hex editor.
|
||||
self._exefs_keyslot_normal_range = [(0, 0x200)]
|
||||
exefs_fp = self.open_raw_section(NCCHSection.ExeFS)
|
||||
# load the RomFS reader
|
||||
self.exefs = ExeFSReader(exefs_fp, _load_icon=False)
|
||||
|
||||
for entry in self.exefs.entries.values():
|
||||
if entry.name in EXEFS_NORMAL_CRYPTO_FILES:
|
||||
# this will add the offset (relative to ExeFS start), with the size
|
||||
# rounded up to 0x200 chunks
|
||||
r = (entry.offset + EXEFS_HEADER_SIZE,
|
||||
entry.offset + EXEFS_HEADER_SIZE + roundup(entry.size, NCCH_MEDIA_UNIT))
|
||||
self._exefs_keyslot_normal_range.append(r)
|
||||
|
||||
self.exefs._load_icon()
|
||||
|
||||
# try to load RomFS
|
||||
if not self.flags.no_romfs:
|
||||
try:
|
||||
self._fp.seek(self._start + self.sections[NCCHSection.RomFS].offset)
|
||||
except KeyError:
|
||||
pass # no RomFS
|
||||
else:
|
||||
romfs_fp = self.open_raw_section(NCCHSection.RomFS)
|
||||
# load the RomFS reader
|
||||
self.romfs = RomFSReader(romfs_fp, case_insensitive=self._case_insensitive)
|
||||
|
||||
def open_raw_section(self, section: 'NCCHSection'):
|
||||
"""Open a raw NCCH section for reading."""
|
||||
return _NCCHSectionFile(self, section)
|
||||
|
||||
def get_key_y(self, original: bool = False) -> bytes:
|
||||
if original or not self.flags.uses_seed:
|
||||
return self._key_y
|
||||
if self.flags.uses_seed and not self.seed_set_up:
|
||||
raise MissingSeedError('NCCH uses seed crypto, but seed is not set up')
|
||||
else:
|
||||
return self._seeded_key_y
|
||||
|
||||
@property
|
||||
def extra_keyslot(self) -> int:
|
||||
return extra_cryptoflags[self.flags.crypto_method]
|
||||
|
||||
def check_for_extheader(self) -> bool:
|
||||
return NCCHSection.ExtendedHeader in self.sections
|
||||
|
||||
def setup_seed(self, seed: bytes):
|
||||
if not self.flags.uses_seed:
|
||||
raise NCCHSeedError('NCCH does not use seed crypto')
|
||||
seed_verify_hash = sha256(seed + self.program_id.to_bytes(0x8, 'little')).digest()
|
||||
if seed_verify_hash[0x0:0x4] != self._seed_verify:
|
||||
raise NCCHSeedError('given seed does not match with seed verify hash in header')
|
||||
self.seed = seed
|
||||
self._seeded_key_y = sha256(self._key_y + seed).digest()[0:16]
|
||||
self.seed_set_up = True
|
||||
|
||||
def load_seed_from_seeddb(self, path: str = None):
|
||||
if not self.flags.uses_seed:
|
||||
raise NCCHSeedError('NCCH does not use seed crypto')
|
||||
if path:
|
||||
# if a path was provided, use only that
|
||||
paths = (path,)
|
||||
else:
|
||||
# use the fixed set of paths
|
||||
paths = seeddb_paths
|
||||
for fn in paths:
|
||||
try:
|
||||
with open(fn, 'rb') as f:
|
||||
# try to load the seed from the file
|
||||
self.setup_seed(get_seed(f, self.program_id))
|
||||
return
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
# if keys are not set...
|
||||
raise InvalidNCCHError(paths)
|
||||
|
||||
def get_data(self, section: 'Union[NCCHRegion, NCCHSection]', offset: int, size: int) -> bytes:
|
||||
try:
|
||||
region = self._all_sections[section]
|
||||
except KeyError:
|
||||
region = section
|
||||
if offset + size > region.size:
|
||||
# prevent reading past the region
|
||||
size = region.size - offset
|
||||
|
||||
# the full-decrypted handler is done outside of the thread lock
|
||||
if region.section == NCCHSection.FullDecrypted:
|
||||
before = offset % 0x200
|
||||
aligned_offset = offset - before
|
||||
aligned_size = size + before
|
||||
|
||||
def do_thing(al_offset: int, al_size: int, cut_start: int, cut_end: int):
|
||||
# get the offset of the end of the last chunk
|
||||
end = al_offset + (ceil(al_size / 0x200) * 0x200)
|
||||
|
||||
# store the sections to read
|
||||
# dict is ordered by default in CPython since 3.6.0, and part of the language spec since 3.7.0
|
||||
to_read: Dict[Tuple[NCCHSection, int], List[int]] = {}
|
||||
|
||||
# get each section to a local variable for easier access
|
||||
header = self._all_sections[NCCHSection.Header]
|
||||
extheader = self._all_sections[NCCHSection.ExtendedHeader]
|
||||
logo = self._all_sections[NCCHSection.Logo]
|
||||
plain = self._all_sections[NCCHSection.Plain]
|
||||
exefs = self._all_sections[NCCHSection.ExeFS]
|
||||
romfs = self._all_sections[NCCHSection.RomFS]
|
||||
|
||||
last_region = False
|
||||
|
||||
# this is somewhat hardcoded for performance reasons. this may be optimized better later.
|
||||
for chunk_offset in range(al_offset, end, 0x200):
|
||||
# RomFS check first, since it might be faster
|
||||
if romfs.offset <= chunk_offset < romfs.end:
|
||||
region = (NCCHSection.RomFS, 0)
|
||||
curr_offset = romfs.offset
|
||||
|
||||
# ExeFS check second, since it might be faster
|
||||
elif exefs.offset <= chunk_offset < exefs.end:
|
||||
region = (NCCHSection.ExeFS, 0)
|
||||
curr_offset = exefs.offset
|
||||
|
||||
elif header.offset <= chunk_offset < header.end:
|
||||
region = (NCCHSection.Header, 0)
|
||||
curr_offset = header.offset
|
||||
|
||||
elif extheader.offset <= chunk_offset < extheader.end:
|
||||
region = (NCCHSection.ExtendedHeader, 0)
|
||||
curr_offset = extheader.offset
|
||||
|
||||
elif logo.offset <= chunk_offset < logo.end:
|
||||
region = (NCCHSection.Logo, 0)
|
||||
curr_offset = logo.offset
|
||||
|
||||
elif plain.offset <= chunk_offset < plain.end:
|
||||
region = (NCCHSection.Plain, 0)
|
||||
curr_offset = plain.offset
|
||||
|
||||
else:
|
||||
region = (NCCHSection.Raw, chunk_offset)
|
||||
curr_offset = 0
|
||||
|
||||
if region not in to_read:
|
||||
to_read[region] = [chunk_offset - curr_offset, 0]
|
||||
to_read[region][1] += 0x200
|
||||
last_region = region
|
||||
|
||||
is_start = True
|
||||
for region, info in to_read.items():
|
||||
new_data = self.get_data(region[0], info[0], info[1])
|
||||
if region[0] == NCCHSection.Header:
|
||||
# fix crypto flags
|
||||
ncch_array = bytearray(new_data)
|
||||
ncch_array[0x18B] = 0
|
||||
ncch_array[0x18F] = 4
|
||||
new_data = bytes(ncch_array)
|
||||
if is_start:
|
||||
new_data = new_data[cut_start:]
|
||||
is_start = False
|
||||
if region == last_region and cut_end != 0x200:
|
||||
new_data = new_data[:-cut_end]
|
||||
|
||||
yield new_data
|
||||
|
||||
return b''.join(do_thing(aligned_offset, aligned_size, before, 0x200 - ((size + before) % 0x200)))
|
||||
|
||||
with self._lock:
|
||||
# check if decryption is really needed
|
||||
if self.flags.no_crypto or region.section in NO_ENCRYPTION:
|
||||
self._fp.seek(self._start + region.offset + offset)
|
||||
return self._fp.read(size)
|
||||
|
||||
# thanks Stary2001 for help with random-access crypto
|
||||
|
||||
# if the region is ExeFS and extra crypto is being used, special handling is required
|
||||
# because different parts use different encryption methods
|
||||
if region.section == NCCHSection.ExeFS and self.flags.crypto_method != 0x00:
|
||||
# get the amount to cut off at the beginning
|
||||
before = offset % 0x200
|
||||
|
||||
# get the offset of the starting chunk
|
||||
aligned_offset = offset - before
|
||||
|
||||
# get the real offset of the starting chunk
|
||||
aligned_real_offset = self._start + region.offset + aligned_offset
|
||||
|
||||
# get the aligned total size of the requested size
|
||||
aligned_size = size + before
|
||||
self._fp.seek(aligned_real_offset)
|
||||
|
||||
def do_thing(al_offset: int, al_size: int, cut_start: int, cut_end: int):
|
||||
# get the offset of the end of the last chunk
|
||||
end = al_offset + (ceil(al_size / 0x200) * 0x200)
|
||||
|
||||
# get the offset to the last chunk
|
||||
last_chunk_offset = end - 0x200
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
for chunk in range(al_offset, end, 0x200):
|
||||
# generate the IV for this chunk
|
||||
iv = region.iv + (chunk >> 4)
|
||||
|
||||
# get the extra keyslot
|
||||
keyslot = self.extra_keyslot
|
||||
|
||||
for r in self._exefs_keyslot_normal_range:
|
||||
if r[0] <= self._fp.tell() - region.offset < r[1]:
|
||||
# if the chunk is within the "normal keyslot" ranges,
|
||||
# use the Original NCCH keyslot instead
|
||||
keyslot = Keyslot.NCCH
|
||||
|
||||
# decrypt the data
|
||||
out = self._crypto.create_ctr_cipher(keyslot, iv).decrypt(self._fp.read(0x200))
|
||||
if chunk == al_offset:
|
||||
# cut off the beginning if it's the first chunk
|
||||
out = out[cut_start:]
|
||||
if chunk == last_chunk_offset and cut_end != 0x200:
|
||||
# cut off the end of it's the last chunk
|
||||
out = out[:-cut_end]
|
||||
yield out
|
||||
|
||||
# join all the chunks into one bytes result and return it
|
||||
return b''.join(do_thing(aligned_offset, aligned_size, before, 0x200 - ((size + before) % 0x200)))
|
||||
else:
|
||||
# seek to the real offset of the section + the requested offset
|
||||
self._fp.seek(self._start + region.offset + offset)
|
||||
data = self._fp.read(size)
|
||||
|
||||
# choose the extra keyslot only for RomFS here
|
||||
# ExeFS needs special handling if a newer keyslot is used, therefore it's not checked here
|
||||
keyslot = self.extra_keyslot if region.section == NCCHSection.RomFS else Keyslot.NCCH
|
||||
|
||||
# get the amount of padding required at the beginning
|
||||
before = offset % 16
|
||||
|
||||
# pad the beginning of the data if needed (the ending part doesn't need padding)
|
||||
data = (b'\0' * before) + data
|
||||
|
||||
# decrypt the data, then cut off the padding
|
||||
return self._crypto.create_ctr_cipher(keyslot, region.iv + (offset >> 4)).decrypt(data)[before:]
|
||||
@@ -1,246 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from io import TextIOWrapper
|
||||
from threading import Lock
|
||||
from typing import overload, TYPE_CHECKING, NamedTuple
|
||||
|
||||
from ..common import PyCTRError, _ReaderOpenFileBase
|
||||
from ..util import readle, roundup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import BinaryIO, Optional, Tuple, Union
|
||||
|
||||
__all__ = ['IVFC_HEADER_SIZE', 'IVFC_ROMFS_MAGIC_NUM', 'ROMFS_LV3_HEADER_SIZE', 'RomFSError', 'InvalidIVFCError',
|
||||
'InvalidRomFSHeaderError', 'RomFSEntryError', 'RomFSFileNotFoundError', 'RomFSReader']
|
||||
|
||||
IVFC_HEADER_SIZE = 0x5C
|
||||
IVFC_ROMFS_MAGIC_NUM = 0x10000
|
||||
ROMFS_LV3_HEADER_SIZE = 0x28
|
||||
|
||||
|
||||
class RomFSError(PyCTRError):
|
||||
"""Generic exception for RomFS operations."""
|
||||
|
||||
|
||||
class InvalidIVFCError(RomFSError):
|
||||
"""Invalid IVFC header exception."""
|
||||
|
||||
|
||||
class InvalidRomFSHeaderError(RomFSError):
|
||||
"""Invalid RomFS Level 3 header."""
|
||||
|
||||
|
||||
class RomFSEntryError(RomFSError):
|
||||
"""Error with RomFS Directory or File entry."""
|
||||
|
||||
|
||||
class RomFSFileNotFoundError(RomFSEntryError):
|
||||
"""Invalid file path in RomFS Level 3."""
|
||||
|
||||
|
||||
class RomFSIsADirectoryError(RomFSEntryError):
|
||||
"""Attempted to open a directory as a file."""
|
||||
|
||||
|
||||
class RomFSRegion(NamedTuple):
|
||||
offset: int
|
||||
size: int
|
||||
|
||||
|
||||
class RomFSDirectoryEntry(NamedTuple):
|
||||
name: str
|
||||
type: str
|
||||
contents: 'Tuple[str, ...]'
|
||||
|
||||
|
||||
class RomFSFileEntry(NamedTuple):
|
||||
name: str
|
||||
type: str
|
||||
offset: int
|
||||
size: int
|
||||
|
||||
|
||||
class _RomFSOpenFile(_ReaderOpenFileBase):
|
||||
"""Class for open RomFS file entries."""
|
||||
|
||||
def __init__(self, reader: 'RomFSReader', path: str):
|
||||
super().__init__(reader, path)
|
||||
self._info: RomFSFileEntry = reader.get_info_from_path(path)
|
||||
if not isinstance(self._info, RomFSFileEntry):
|
||||
raise RomFSIsADirectoryError(path)
|
||||
|
||||
|
||||
class RomFSReader:
|
||||
"""
|
||||
Class for 3DS RomFS Level 3 partition.
|
||||
|
||||
https://www.3dbrew.org/wiki/RomFS
|
||||
"""
|
||||
|
||||
closed = False
|
||||
lv3_offset = 0
|
||||
data_offset = 0
|
||||
|
||||
def __init__(self, fp: 'Union[str, BinaryIO]', case_insensitive: bool = False):
|
||||
if isinstance(fp, str):
|
||||
fp = open(fp, 'rb')
|
||||
|
||||
self._start = fp.tell()
|
||||
self._fp = fp
|
||||
self.case_insensitive = case_insensitive
|
||||
self._lock = Lock()
|
||||
|
||||
lv3_offset = fp.tell()
|
||||
magic = fp.read(4)
|
||||
|
||||
# detect ivfc and get the lv3 offset
|
||||
if magic == b'IVFC':
|
||||
ivfc = magic + fp.read(0x54) # IVFC_HEADER_SIZE - 4
|
||||
ivfc_magic_num = readle(ivfc[0x4:0x8])
|
||||
if ivfc_magic_num != IVFC_ROMFS_MAGIC_NUM:
|
||||
raise InvalidIVFCError(f'IVFC magic number is invalid '
|
||||
f'({ivfc_magic_num:#X} instead of {IVFC_ROMFS_MAGIC_NUM:#X})')
|
||||
master_hash_size = readle(ivfc[0x8:0xC])
|
||||
lv3_block_size = readle(ivfc[0x4C:0x50])
|
||||
lv3_hash_block_size = 1 << lv3_block_size
|
||||
lv3_offset += roundup(0x60 + master_hash_size, lv3_hash_block_size)
|
||||
fp.seek(self._start + lv3_offset)
|
||||
magic = fp.read(4)
|
||||
self.lv3_offset = lv3_offset
|
||||
|
||||
lv3_header = magic + fp.read(0x24) # ROMFS_LV3_HEADER_SIZE - 4
|
||||
|
||||
# get offsets and sizes from lv3 header
|
||||
lv3_header_size = readle(magic)
|
||||
lv3_dirhash = RomFSRegion(offset=readle(lv3_header[0x4:0x8]), size=readle(lv3_header[0x8:0xC]))
|
||||
lv3_dirmeta = RomFSRegion(offset=readle(lv3_header[0xC:0x10]), size=readle(lv3_header[0x10:0x14]))
|
||||
lv3_filehash = RomFSRegion(offset=readle(lv3_header[0x14:0x18]), size=readle(lv3_header[0x18:0x1C]))
|
||||
lv3_filemeta = RomFSRegion(offset=readle(lv3_header[0x1C:0x20]), size=readle(lv3_header[0x20:0x24]))
|
||||
lv3_filedata_offset = readle(lv3_header[0x24:0x28])
|
||||
self.data_offset = lv3_offset + lv3_filedata_offset
|
||||
|
||||
# verify lv3 header
|
||||
if lv3_header_size != ROMFS_LV3_HEADER_SIZE:
|
||||
raise InvalidRomFSHeaderError('Length in RomFS Lv3 header is not 0x28')
|
||||
if lv3_dirhash.offset < lv3_header_size:
|
||||
raise InvalidRomFSHeaderError('Directory Hash offset is before the end of the Lv3 header')
|
||||
if lv3_dirmeta.offset < lv3_dirhash.offset + lv3_dirhash.size:
|
||||
raise InvalidRomFSHeaderError('Directory Metadata offset is before the end of the Directory Hash region')
|
||||
if lv3_filehash.offset < lv3_dirmeta.offset + lv3_dirmeta.size:
|
||||
raise InvalidRomFSHeaderError('File Hash offset is before the end of the Directory Metadata region')
|
||||
if lv3_filemeta.offset < lv3_filehash.offset + lv3_filehash.size:
|
||||
raise InvalidRomFSHeaderError('File Metadata offset is before the end of the File Hash region')
|
||||
if lv3_filedata_offset < lv3_filemeta.offset + lv3_filemeta.size:
|
||||
raise InvalidRomFSHeaderError('File Data offset is before the end of the File Metadata region')
|
||||
|
||||
# get entries from dirmeta and filemeta
|
||||
def iterate_dir(out: dict, raw: bytes, current_path: str):
|
||||
first_child_dir = readle(raw[0x8:0xC])
|
||||
first_file = readle(raw[0xC:0x10])
|
||||
|
||||
out['type'] = 'dir'
|
||||
out['contents'] = {}
|
||||
|
||||
# iterate through all child directories
|
||||
if first_child_dir != 0xFFFFFFFF:
|
||||
fp.seek(self._start + lv3_offset + lv3_dirmeta.offset + first_child_dir)
|
||||
while True:
|
||||
child_dir_meta = fp.read(0x18)
|
||||
next_sibling_dir = readle(child_dir_meta[0x4:0x8])
|
||||
child_dir_name = fp.read(readle(child_dir_meta[0x14:0x18])).decode('utf-16le')
|
||||
child_dir_name_meta = child_dir_name.lower() if case_insensitive else child_dir_name
|
||||
if child_dir_name_meta in out['contents']:
|
||||
print(f'WARNING: Dirname collision! {current_path}{child_dir_name}')
|
||||
out['contents'][child_dir_name_meta] = {'name': child_dir_name}
|
||||
|
||||
iterate_dir(out['contents'][child_dir_name_meta], child_dir_meta,
|
||||
f'{current_path}{child_dir_name}/')
|
||||
if next_sibling_dir == 0xFFFFFFFF:
|
||||
break
|
||||
fp.seek(self._start + lv3_offset + lv3_dirmeta.offset + next_sibling_dir)
|
||||
|
||||
if first_file != 0xFFFFFFFF:
|
||||
fp.seek(self._start + lv3_offset + lv3_filemeta.offset + first_file)
|
||||
while True:
|
||||
child_file_meta = fp.read(0x20)
|
||||
next_sibling_file = readle(child_file_meta[0x4:0x8])
|
||||
child_file_offset = readle(child_file_meta[0x8:0x10])
|
||||
child_file_size = readle(child_file_meta[0x10:0x18])
|
||||
child_file_name = fp.read(readle(child_file_meta[0x1C:0x20])).decode('utf-16le')
|
||||
child_file_name_meta = child_file_name.lower() if self.case_insensitive else child_file_name
|
||||
if child_file_name_meta in out['contents']:
|
||||
print(f'WARNING: Filename collision! {current_path}{child_file_name}')
|
||||
out['contents'][child_file_name_meta] = {'name': child_file_name, 'type': 'file',
|
||||
'offset': child_file_offset, 'size': child_file_size}
|
||||
|
||||
self.total_size += child_file_size
|
||||
if next_sibling_file == 0xFFFFFFFF:
|
||||
break
|
||||
fp.seek(self._start + lv3_offset + lv3_filemeta.offset + next_sibling_file)
|
||||
|
||||
self._tree_root = {'name': 'ROOT'}
|
||||
self.total_size = 0
|
||||
fp.seek(self._start + lv3_offset + lv3_dirmeta.offset)
|
||||
iterate_dir(self._tree_root, fp.read(0x18), '/')
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
try:
|
||||
self._fp.close()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
@overload
|
||||
def open(self, path: str, encoding: str, errors: 'Optional[str]' = None,
|
||||
newline: 'Optional[str]' = None) -> TextIOWrapper: ...
|
||||
|
||||
@overload
|
||||
def open(self, path: str, encoding: None = None, errors: 'Optional[str]' = None,
|
||||
newline: 'Optional[str]' = None) -> _RomFSOpenFile: ...
|
||||
|
||||
def open(self, path, encoding=None, errors=None, newline=None):
|
||||
"""Open a file in the RomFS for reading."""
|
||||
f = _RomFSOpenFile(self, path)
|
||||
if encoding is not None:
|
||||
f = TextIOWrapper(f, encoding, errors, newline)
|
||||
return f
|
||||
|
||||
__del__ = close
|
||||
|
||||
def get_info_from_path(self, path: str) -> 'Union[RomFSDirectoryEntry, RomFSFileEntry]':
|
||||
"""Get a directory or file entry"""
|
||||
curr = self._tree_root
|
||||
if self.case_insensitive:
|
||||
path = path.lower()
|
||||
if path[0] == '/':
|
||||
path = path[1:]
|
||||
for part in path.split('/'):
|
||||
if part == '':
|
||||
break
|
||||
try:
|
||||
# noinspection PyTypeChecker
|
||||
curr = curr['contents'][part]
|
||||
except KeyError:
|
||||
raise RomFSFileNotFoundError(path)
|
||||
if curr['type'] == 'dir':
|
||||
contents = (k['name'] for k in curr['contents'].values())
|
||||
return RomFSDirectoryEntry(name=curr['name'], type='dir', contents=(*contents,))
|
||||
elif curr['type'] == 'file':
|
||||
return RomFSFileEntry(name=curr['name'], type='file', offset=curr['offset'], size=curr['size'])
|
||||
|
||||
def get_data(self, info: RomFSFileEntry, offset: int, size: int) -> bytes:
|
||||
if offset + size > info.size:
|
||||
size = info.size - offset
|
||||
with self._lock:
|
||||
self._fp.seek(self._start + self.data_offset + info.offset + offset)
|
||||
return self._fp.read(size)
|
||||
@@ -1,111 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from ..common import PyCTRError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import BinaryIO, Dict, Mapping, Optional, Tuple, Union
|
||||
|
||||
SMDH_SIZE = 0x36C0
|
||||
|
||||
region_names = (
|
||||
'Japanese',
|
||||
'English',
|
||||
'French',
|
||||
'German',
|
||||
'Italian',
|
||||
'Spanish',
|
||||
'Simplified Chinese',
|
||||
'Korean',
|
||||
'Dutch',
|
||||
'Portuguese',
|
||||
'Russian',
|
||||
'Traditional Chinese',
|
||||
)
|
||||
|
||||
# the order of the SMDH names to check. the difference here is that English is put before Japanese.
|
||||
_region_order_check = (
|
||||
'English',
|
||||
'Japanese',
|
||||
'French',
|
||||
'German',
|
||||
'Italian',
|
||||
'Spanish',
|
||||
'Simplified Chinese',
|
||||
'Korean',
|
||||
'Dutch',
|
||||
'Portuguese',
|
||||
'Russian',
|
||||
'Traditional Chinese',
|
||||
)
|
||||
|
||||
|
||||
class SMDHError(PyCTRError):
|
||||
"""Generic exception for SMDH operations."""
|
||||
|
||||
|
||||
class InvalidSMDHError(SMDHError):
|
||||
"""Invalid SMDH contents."""
|
||||
|
||||
|
||||
class AppTitle(NamedTuple):
|
||||
short_desc: str
|
||||
long_desc: str
|
||||
publisher: str
|
||||
|
||||
|
||||
class SMDH:
|
||||
"""
|
||||
Class for 3DS SMDH. Icon data is currently not supported.
|
||||
|
||||
https://www.3dbrew.org/wiki/SMDH
|
||||
"""
|
||||
|
||||
# TODO: support other settings
|
||||
|
||||
def __init__(self, names: 'Dict[str, AppTitle]'):
|
||||
self.names: Mapping[str, AppTitle] = MappingProxyType({n: names.get(n, None) for n in region_names})
|
||||
|
||||
def __repr__(self):
|
||||
return f'<{type(self).__name__} title: {self.get_app_title().short_desc}>'
|
||||
|
||||
def get_app_title(self, language: 'Union[str, Tuple[str, ...]]' = _region_order_check) -> 'Optional[AppTitle]':
|
||||
if isinstance(language, str):
|
||||
language = (language,)
|
||||
|
||||
for l in language:
|
||||
apptitle = self.names[l]
|
||||
if apptitle:
|
||||
return apptitle
|
||||
|
||||
# if, for some reason, it fails to return...
|
||||
return AppTitle('unknown', 'unknown', 'unknown')
|
||||
|
||||
@classmethod
|
||||
def load(cls, fp: 'BinaryIO') -> 'SMDH':
|
||||
"""Load an SMDH from a file-like object."""
|
||||
smdh = fp.read(SMDH_SIZE)
|
||||
if len(smdh) != SMDH_SIZE:
|
||||
raise InvalidSMDHError(f'invalid size (expected: {SMDH_SIZE:#6x}, got: {len(smdh):#6x}')
|
||||
if smdh[0:4] != b'SMDH':
|
||||
raise InvalidSMDHError('SMDH magic not found')
|
||||
|
||||
app_structs = smdh[8:0x2008]
|
||||
names: Dict[str, AppTitle] = {}
|
||||
# due to region_names only being 12 elements, this will only process 12. the other 4 are unused.
|
||||
for app_title, region in zip((app_structs[x:x + 0x200] for x in range(0, 0x2000, 0x200)), region_names):
|
||||
names[region] = AppTitle(app_title[0:0x80].decode('utf-16le').strip('\0'),
|
||||
app_title[0x80:0x180].decode('utf-16le').strip('\0'),
|
||||
app_title[0x180:0x200].decode('utf-16le').strip('\0'))
|
||||
return cls(names)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, fn: str) -> 'SMDH':
|
||||
with open(fn, 'rb') as f:
|
||||
return cls.load(f)
|
||||
@@ -1,316 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
from hashlib import sha256
|
||||
from struct import pack
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from ..common import PyCTRError
|
||||
from ..util import readbe, readle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import BinaryIO, Iterable
|
||||
|
||||
__all__ = ['CHUNK_RECORD_SIZE', 'TitleMetadataError', 'InvalidSignatureTypeError', 'InvalidHashError',
|
||||
'ContentInfoRecord', 'ContentChunkRecord', 'ContentTypeFlags', 'TitleVersion', 'TitleMetadataReader']
|
||||
|
||||
CHUNK_RECORD_SIZE = 0x30
|
||||
|
||||
# sig-type: (sig-size, padding)
|
||||
signature_types = {
|
||||
# RSA_4096 SHA1 (unused on 3DS)
|
||||
0x00010000: (0x200, 0x3C),
|
||||
# RSA_2048 SHA1 (unused on 3DS)
|
||||
0x00010001: (0x100, 0x3C),
|
||||
# Elliptic Curve with SHA1 (unused on 3DS)
|
||||
0x00010002: (0x3C, 0x40),
|
||||
# RSA_4096 SHA256
|
||||
0x00010003: (0x200, 0x3C),
|
||||
# RSA_2048 SHA256
|
||||
0x00010004: (0x100, 0x3C),
|
||||
# ECDSA with SHA256
|
||||
0x00010005: (0x3C, 0x40),
|
||||
}
|
||||
|
||||
BLANK_SIG_PAIR = (0x00010004, b'\xFF' * signature_types[0x00010004][0])
|
||||
|
||||
|
||||
class TitleMetadataError(PyCTRError):
|
||||
"""Generic exception for TitleMetadata operations."""
|
||||
|
||||
|
||||
class InvalidTMDError(TitleMetadataError):
|
||||
"""Title Metadata is invalid."""
|
||||
|
||||
|
||||
class InvalidSignatureTypeError(InvalidTMDError):
|
||||
"""Invalid signature type was used."""
|
||||
|
||||
def __init__(self, sig_type):
|
||||
super().__init__(sig_type)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.args[0]:#010x}'
|
||||
|
||||
|
||||
class InvalidHashError(InvalidTMDError):
|
||||
"""Hash mismatch in the Title Metadata."""
|
||||
|
||||
|
||||
class InvalidInfoRecordError(InvalidHashError):
|
||||
"""Hash mismatch in the Content Info Records."""
|
||||
|
||||
def __init__(self, info_record):
|
||||
super().__init__(info_record)
|
||||
|
||||
def __str__(self):
|
||||
return f'Invalid info record: {self.args[0]}'
|
||||
|
||||
|
||||
class UnusualInfoRecordError(InvalidTMDError):
|
||||
"""Encountered Content Info Record that attempts to hash a Content Chunk Record that has already been hashed."""
|
||||
|
||||
def __init__(self, info_record, chunk_record):
|
||||
super().__init__(info_record, chunk_record)
|
||||
|
||||
def __str__(self):
|
||||
return f'Attempted to hash twice: {self.args[0]}, {self.args[1]}'
|
||||
|
||||
|
||||
class ContentTypeFlags(NamedTuple):
|
||||
encrypted: bool
|
||||
disc: bool
|
||||
cfm: bool
|
||||
optional: bool
|
||||
shared: bool
|
||||
|
||||
def __index__(self) -> int:
|
||||
return self.encrypted | (self.disc << 1) | (self.cfm << 2) | (self.optional << 14) | (self.shared << 15)
|
||||
|
||||
__int__ = __index__
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__int__().__format__(format_spec)
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, flags: int) -> 'ContentTypeFlags':
|
||||
# noinspection PyArgumentList
|
||||
return cls(bool(flags & 1), bool(flags & 2), bool(flags & 4), bool(flags & 0x4000), bool(flags & 0x8000))
|
||||
|
||||
|
||||
class ContentInfoRecord(NamedTuple):
|
||||
index_offset: int
|
||||
command_count: int
|
||||
hash: bytes
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return b''.join((self.index_offset.to_bytes(2, 'big'), self.command_count.to_bytes(2, 'big'), self.hash))
|
||||
|
||||
|
||||
class ContentChunkRecord(NamedTuple):
|
||||
id: str
|
||||
cindex: int
|
||||
type: ContentTypeFlags
|
||||
size: int
|
||||
hash: bytes
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return b''.join((bytes.fromhex(self.id), self.cindex.to_bytes(2, 'big'), int(self.type).to_bytes(2, 'big'),
|
||||
self.size.to_bytes(8, 'big'), self.hash))
|
||||
|
||||
|
||||
class TitleVersion(NamedTuple):
|
||||
major: int
|
||||
minor: int
|
||||
micro: int
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.major}.{self.minor}.{self.micro}'
|
||||
|
||||
def __index__(self) -> int:
|
||||
return (self.major << 10) | (self.minor << 4) | self.micro
|
||||
|
||||
__int__ = __index__
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__int__().__format__(format_spec)
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, ver: int) -> 'TitleVersion':
|
||||
# noinspection PyArgumentList
|
||||
return cls((ver >> 10) & 0x3F, (ver >> 4) & 0x3F, ver & 0xF)
|
||||
|
||||
|
||||
class TitleMetadataReader:
|
||||
"""
|
||||
Class for 3DS Title Metadata.
|
||||
|
||||
https://www.3dbrew.org/wiki/Title_metadata
|
||||
"""
|
||||
|
||||
__slots__ = ('title_id', 'save_size', 'srl_save_size', 'title_version', 'info_records',
|
||||
'chunk_records', 'content_count', 'signature', '_u_issuer', '_u_version', '_u_ca_crl_version',
|
||||
'_u_signer_crl_version', '_u_reserved1', '_u_system_version', '_u_title_type', '_u_group_id',
|
||||
'_u_reserved2', '_u_srl_flag', '_u_reserved3', '_u_access_rights', '_u_boot_count', '_u_padding')
|
||||
|
||||
# arguments prefixed with _u_ are values unused by the 3DS and/or are only kept around to generate the final tmd
|
||||
def __init__(self, *, title_id: str, save_size: int, srl_save_size: int, title_version: TitleVersion,
|
||||
info_records: 'Iterable[ContentInfoRecord]', chunk_records: 'Iterable[ContentChunkRecord]',
|
||||
signature=BLANK_SIG_PAIR, _u_issuer='Root-CA00000003-CP0000000b', _u_version=1, _u_ca_crl_version=0,
|
||||
_u_signer_crl_version=0, _u_reserved1=0, _u_system_version=b'\0' * 8, _u_title_type=b'\0\0\0@',
|
||||
_u_group_id=b'\0\0', _u_reserved2=b'\0\0\0\0', _u_srl_flag=0, _u_reserved3=b'\0' * 0x31,
|
||||
_u_access_rights=b'\0' * 4, _u_boot_count=b'\0\0', _u_padding=b'\0\0'):
|
||||
# TODO: add checks
|
||||
self.title_id = title_id.lower()
|
||||
self.save_size = save_size
|
||||
self.srl_save_size = srl_save_size
|
||||
self.title_version = title_version
|
||||
self.info_records = tuple(info_records)
|
||||
self.chunk_records = tuple(chunk_records)
|
||||
self.content_count = len(self.chunk_records)
|
||||
self.signature = signature # TODO: store this differently
|
||||
|
||||
# unused values
|
||||
self._u_issuer = _u_issuer
|
||||
self._u_version = _u_version
|
||||
self._u_ca_crl_version = _u_ca_crl_version
|
||||
self._u_signer_crl_version = _u_signer_crl_version
|
||||
self._u_reserved1 = _u_reserved1
|
||||
self._u_system_version = _u_system_version
|
||||
self._u_title_type = _u_title_type
|
||||
self._u_group_id = _u_group_id
|
||||
self._u_reserved2 = _u_reserved2
|
||||
self._u_srl_flag = _u_srl_flag
|
||||
self._u_reserved3 = _u_reserved3
|
||||
self._u_access_rights = _u_access_rights
|
||||
self._u_boot_count = _u_boot_count
|
||||
self._u_padding = _u_padding
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.title_id, self.save_size, self.srl_save_size, self.title_version,
|
||||
self.info_records, self.chunk_records))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f'<TitleMetadataReader title_id={self.title_id!r} title_version={self.title_version!r} '
|
||||
f'content_count={self.content_count!r}>')
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
sig_data = pack(f'>I {signature_types[self.signature[0]][0]}s {signature_types[self.signature[0]][1]}x',
|
||||
self.signature[0], self.signature[1])
|
||||
|
||||
info_records = b''.join(bytes(x) for x in self.info_records).ljust(0x900, b'\0')
|
||||
|
||||
header = pack('>64s b b b b 8s 8s 4s 2s I I 4s b 49s 4s H H 2s 2s 32s', self._u_issuer.encode('ascii'),
|
||||
self._u_version, self._u_ca_crl_version, self._u_signer_crl_version, self._u_reserved1,
|
||||
self._u_system_version, bytes.fromhex(self.title_id), self._u_title_type, self._u_group_id,
|
||||
self.save_size, self.srl_save_size, self._u_reserved2, self._u_srl_flag, self._u_reserved3,
|
||||
self._u_access_rights, self.title_version, self.content_count, self._u_boot_count,
|
||||
self._u_padding, sha256(info_records).digest())
|
||||
|
||||
chunk_records = b''.join(bytes(x) for x in self.chunk_records)
|
||||
|
||||
return sig_data + header + info_records + chunk_records
|
||||
|
||||
@classmethod
|
||||
def load(cls, fp: 'BinaryIO', verify_hashes: bool = True) -> 'TitleMetadataReader':
|
||||
"""Load a tmd from a file-like object."""
|
||||
sig_type = readbe(fp.read(4))
|
||||
try:
|
||||
sig_size, sig_padding = signature_types[sig_type]
|
||||
except KeyError:
|
||||
raise InvalidSignatureTypeError(sig_type)
|
||||
|
||||
signature = fp.read(sig_size)
|
||||
try:
|
||||
fp.seek(sig_padding, 1)
|
||||
except Exception:
|
||||
# most streams are probably seekable, but for some that aren't...
|
||||
fp.read(sig_padding)
|
||||
|
||||
header = fp.read(0xC4)
|
||||
if len(header) != 0xC4:
|
||||
raise InvalidTMDError('Header length is not 0xC4')
|
||||
|
||||
# only values that actually have a use are loaded here. (currently)
|
||||
# several fields in were left in from the Wii tmd and have no function on 3DS.
|
||||
title_id = header[0x4C:0x54].hex()
|
||||
save_size = readle(header[0x5A:0x5E])
|
||||
srl_save_size = readle(header[0x5E:0x62])
|
||||
title_version = TitleVersion.from_int(readbe(header[0x9C:0x9E]))
|
||||
content_count = readbe(header[0x9E:0xA0])
|
||||
|
||||
content_info_records_hash = header[0xA4:0xC4]
|
||||
|
||||
content_info_records_raw = fp.read(0x900)
|
||||
if len(content_info_records_raw) != 0x900:
|
||||
raise InvalidTMDError('Content info records length is not 0x900')
|
||||
|
||||
if verify_hashes:
|
||||
real_hash = sha256(content_info_records_raw)
|
||||
if content_info_records_hash != real_hash.digest():
|
||||
raise InvalidHashError('Content Info Records hash is invalid')
|
||||
|
||||
content_chunk_records_raw = fp.read(content_count * CHUNK_RECORD_SIZE)
|
||||
|
||||
chunk_records = []
|
||||
for cr_raw in (content_chunk_records_raw[i:i + CHUNK_RECORD_SIZE] for i in
|
||||
range(0, content_count * CHUNK_RECORD_SIZE, CHUNK_RECORD_SIZE)):
|
||||
chunk_records.append(ContentChunkRecord(id=cr_raw[0:4].hex(),
|
||||
cindex=readbe(cr_raw[4:6]),
|
||||
type=ContentTypeFlags.from_int(readbe(cr_raw[6:8])),
|
||||
size=readbe(cr_raw[8:16]),
|
||||
hash=cr_raw[16:48]))
|
||||
|
||||
info_records = []
|
||||
for ir_raw in (content_info_records_raw[i:i + 0x24] for i in range(0, 0x900, 0x24)):
|
||||
if ir_raw != b'\0' * 0x24:
|
||||
info_records.append(ContentInfoRecord(index_offset=readbe(ir_raw[0:2]),
|
||||
command_count=readbe(ir_raw[2:4]),
|
||||
hash=ir_raw[4:36]))
|
||||
|
||||
if verify_hashes:
|
||||
chunk_records_hashed = set()
|
||||
for ir in info_records:
|
||||
to_hash = []
|
||||
for cr in chunk_records[ir.index_offset:ir.index_offset + ir.command_count]:
|
||||
if cr in chunk_records_hashed:
|
||||
raise InvalidTMDError('attempting to hash chunk record twice')
|
||||
|
||||
chunk_records_hashed.add(cr)
|
||||
to_hash.append(cr)
|
||||
|
||||
hashed = sha256(b''.join(bytes(x) for x in to_hash))
|
||||
if hashed.digest() != ir.hash:
|
||||
raise InvalidInfoRecordError(ir)
|
||||
|
||||
# unused vales are loaded only for use when re-building the binary tmd
|
||||
u_issuer = header[0:0x40].decode('ascii').rstrip('\0')
|
||||
u_version = header[0x40]
|
||||
u_ca_crl_version = header[0x41]
|
||||
u_signer_crl_version = header[0x42]
|
||||
u_reserved1 = header[0x43]
|
||||
u_system_version = header[0x44:0x4C]
|
||||
u_title_type = header[0x54:0x58]
|
||||
u_group_id = header[0x58:0x5A]
|
||||
u_reserved2 = header[0x62:0x66]
|
||||
u_srl_flag = header[0x66] # is this one used for anything?
|
||||
u_reserved3 = header[0x67:0x98]
|
||||
u_access_rights = header[0x98:0x9C]
|
||||
u_boot_count = header[0xA0:0xA2]
|
||||
u_padding = header[0xA2:0xA4]
|
||||
|
||||
return cls(title_id=title_id, save_size=save_size, srl_save_size=srl_save_size, title_version=title_version,
|
||||
info_records=info_records, chunk_records=chunk_records, signature=(sig_type, signature),
|
||||
_u_issuer=u_issuer, _u_version=u_version, _u_ca_crl_version=u_ca_crl_version,
|
||||
_u_signer_crl_version=u_signer_crl_version, _u_reserved1=u_reserved1,
|
||||
_u_system_version=u_system_version, _u_title_type=u_title_type, _u_group_id=u_group_id,
|
||||
_u_reserved2=u_reserved2, _u_srl_flag=u_srl_flag, _u_reserved3=u_reserved3,
|
||||
_u_access_rights=u_access_rights, _u_boot_count=u_boot_count, _u_padding=u_padding)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, fn: str, *, verify_hashes: bool = True) -> 'TitleMetadataReader':
|
||||
with open(fn, 'rb') as f:
|
||||
return cls.load(f, verify_hashes=verify_hashes)
|
||||
@@ -1,41 +0,0 @@
|
||||
# This file is a part of ninfs.
|
||||
#
|
||||
# Copyright (c) 2017-2019 Ian Burgwin
|
||||
# This file is licensed under The MIT License (MIT).
|
||||
# You can find the full license text in LICENSE.md in the root of this project.
|
||||
|
||||
import os
|
||||
from math import ceil
|
||||
from sys import platform
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import List
|
||||
|
||||
__all__ = ['windows', 'macos', 'readle', 'readbe', 'roundup', 'config_dirs']
|
||||
|
||||
windows = platform in {'win32', 'cygwin'}
|
||||
macos = platform == 'darwin'
|
||||
|
||||
|
||||
def readle(b: bytes) -> int:
|
||||
"""Convert little-endian bytes to an int."""
|
||||
return int.from_bytes(b, 'little')
|
||||
|
||||
|
||||
def readbe(b: bytes) -> int:
|
||||
"""Convert big-endian bytes to an int."""
|
||||
return int.from_bytes(b, 'big')
|
||||
|
||||
|
||||
def roundup(offset: int, alignment: int) -> int:
|
||||
"""Round up a number to a provided alignment."""
|
||||
return int(ceil(offset / alignment) * alignment)
|
||||
|
||||
|
||||
_home = os.path.expanduser('~')
|
||||
config_dirs: 'List[str]' = [os.path.join(_home, '.3ds'), os.path.join(_home, '3ds')]
|
||||
if windows:
|
||||
config_dirs.insert(0, os.path.join(os.environ.get('APPDATA'), '3ds'))
|
||||
elif macos:
|
||||
config_dirs.insert(0, os.path.join(_home, 'Library', 'Application Support', '3ds'))
|
||||
48
pyproject.toml
Normal file
48
pyproject.toml
Normal file
@@ -0,0 +1,48 @@
|
||||
[build-system]
|
||||
requires = ["setuptools >= 61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "custominstall"
|
||||
description = "Installs a title directly to an SD card for the Nintendo 3DS"
|
||||
authors = [
|
||||
{ name = "Ian Burgwin", email = "ian@ianburgwin.net" },
|
||||
]
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
dynamic = ["version"]
|
||||
requires-python = ">= 3.8"
|
||||
classifiers = [
|
||||
"Topic :: Utilities",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"pyctr>=0.7.6,<0.9",
|
||||
"setuptools>=61.0.0",
|
||||
"events>=0.4",
|
||||
"comtypes>=1.4.12; os_name == 'nt'",
|
||||
]
|
||||
|
||||
[project.gui-scripts]
|
||||
custominstall-gui = "custominstall.gui:main"
|
||||
|
||||
[project.scripts]
|
||||
custominstall = "custominstall.__main__:main"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "custominstall.__version__"}
|
||||
|
||||
[tool.setuptools.packages]
|
||||
find = {namespaces = false}
|
||||
|
||||
# is it even possible to make these OS-specific with pyproject.toml?
|
||||
[tool.setuptools.package-data]
|
||||
custominstall = ["bin/darwin/save3ds_fuse", "bin/win32/save3ds_fuse.exe", "TaskbarLib.tlb", "title.db.gz", "custom-install-finalize.3dsx"]
|
||||
Reference in New Issue
Block a user