nx-title-id 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nx_title_id-1.0.0/.gitignore +0 -0
- nx_title_id-1.0.0/CHANGELOG.md +7 -0
- nx_title_id-1.0.0/LICENSE +21 -0
- nx_title_id-1.0.0/PKG-INFO +54 -0
- nx_title_id-1.0.0/README.md +39 -0
- nx_title_id-1.0.0/pyproject.toml +41 -0
- nx_title_id-1.0.0/src/nx_title_id/__init__.py +287 -0
- nx_title_id-1.0.0/src/nx_title_id/__main__.py +3 -0
|
File without changes
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com), adheres to [Semantic Versioning](https://semver.org), and uses [Conventional Commit](https://www.conventionalcommits.org) syntax.
|
|
6
|
+
|
|
7
|
+
## Unreleased
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Ryan Parman <ryan@ryanparman.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nx-title-id
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Print the title ID of the Nintendo Switch game packages (.nsp, .nsz, .xci, .xcz) in a folder
|
|
5
|
+
Author-email: Ryan Parman <ryan@ryanparman.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: nintendo-switch,nsp,nsz,nx,nx-title-id,xci
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.14
|
|
13
|
+
Requires-Dist: pycryptodome<5,>=4.2
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# nx-title-id
|
|
17
|
+
|
|
18
|
+
Prints the title ID of the Nintendo Switch game whose packages (`.nsp`, `.nsz`, `.xci`, `.xcz`) are in a folder.
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
uvx nx-title-id <folder> [-k prod.keys]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Base games, updates and DLC in the same folder all count as their base game, so a folder with one game prints one line:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
$ uvx nx-title-id ~/Games/Super\ Mario\ Odyssey
|
|
28
|
+
0100000000010000
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## How the title ID is found
|
|
32
|
+
|
|
33
|
+
For each package, in order:
|
|
34
|
+
|
|
35
|
+
1. **The ticket's name.** A package's ticket is named after its rights ID (`<32 hex digits>.tik`), whose first 16 digits are the title ID. This wins over the file name, since tags in file names can be wrong.
|
|
36
|
+
2. **A `[title ID]` tag in the file name**, such as `Game [0100000000010000][v0].nsp`.
|
|
37
|
+
3. **The meta NCA.** For a package with neither, the NCA headers are decrypted with `header_key` from `prod.keys`, and the lowest title ID among the meta NCAs is used. The keys file is `-k`, else `~/.switch/prod.keys` when it exists; it is read only when a package needs it, and no key value is printed.
|
|
38
|
+
|
|
39
|
+
Steps 1 and 2 use only the Python standard library. Step 3 uses [pycryptodome](https://pypi.org/project/pycryptodome/) for AES.
|
|
40
|
+
|
|
41
|
+
Cartridge images (`.xci`, `.xcz`) seldom carry a ticket, so they usually need a tag or the keys. Cartridge dumps with the 0x1000-byte key area at the start, and NCA0 headers from very early titles, are not read.
|
|
42
|
+
|
|
43
|
+
## Exit status
|
|
44
|
+
|
|
45
|
+
`0` when at least one title ID was printed. Non-zero, with a message, when the path is not a folder, no package has a readable title ID, the keys file is missing or has no `header_key`. When the folder holds more than one game, every title ID is printed and a warning goes to stderr.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
uv run nx-title-id <folder> # run from this folder
|
|
51
|
+
uv run tryke test # run the tests
|
|
52
|
+
uv build # sdist and wheel into dist/
|
|
53
|
+
uv publish # upload to PyPI
|
|
54
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# nx-title-id
|
|
2
|
+
|
|
3
|
+
Prints the title ID of the Nintendo Switch game whose packages (`.nsp`, `.nsz`, `.xci`, `.xcz`) are in a folder.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
uvx nx-title-id <folder> [-k prod.keys]
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Base games, updates and DLC in the same folder all count as their base game, so a folder with one game prints one line:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
$ uvx nx-title-id ~/Games/Super\ Mario\ Odyssey
|
|
13
|
+
0100000000010000
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## How the title ID is found
|
|
17
|
+
|
|
18
|
+
For each package, in order:
|
|
19
|
+
|
|
20
|
+
1. **The ticket's name.** A package's ticket is named after its rights ID (`<32 hex digits>.tik`), whose first 16 digits are the title ID. This wins over the file name, since tags in file names can be wrong.
|
|
21
|
+
2. **A `[title ID]` tag in the file name**, such as `Game [0100000000010000][v0].nsp`.
|
|
22
|
+
3. **The meta NCA.** For a package with neither, the NCA headers are decrypted with `header_key` from `prod.keys`, and the lowest title ID among the meta NCAs is used. The keys file is `-k`, else `~/.switch/prod.keys` when it exists; it is read only when a package needs it, and no key value is printed.
|
|
23
|
+
|
|
24
|
+
Steps 1 and 2 use only the Python standard library. Step 3 uses [pycryptodome](https://pypi.org/project/pycryptodome/) for AES.
|
|
25
|
+
|
|
26
|
+
Cartridge images (`.xci`, `.xcz`) seldom carry a ticket, so they usually need a tag or the keys. Cartridge dumps with the 0x1000-byte key area at the start, and NCA0 headers from very early titles, are not read.
|
|
27
|
+
|
|
28
|
+
## Exit status
|
|
29
|
+
|
|
30
|
+
`0` when at least one title ID was printed. Non-zero, with a message, when the path is not a folder, no package has a readable title ID, the keys file is missing or has no `header_key`. When the folder holds more than one game, every title ID is printed and a warning goes to stderr.
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
uv run nx-title-id <folder> # run from this folder
|
|
36
|
+
uv run tryke test # run the tests
|
|
37
|
+
uv build # sdist and wheel into dist/
|
|
38
|
+
uv publish # upload to PyPI
|
|
39
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nx-title-id"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Print the title ID of the Nintendo Switch game packages (.nsp, .nsz, .xci, .xcz) in a folder"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{email = "ryan@ryanparman.com", name = "Ryan Parman"}]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pycryptodome>=4.2,<5", # AES for the meta NCA fallback; imported only when a package needs it
|
|
12
|
+
]
|
|
13
|
+
keywords = ["nintendo-switch", "nsp", "nsz", "nx", "nx-title-id", "xci"]
|
|
14
|
+
classifiers = ["Environment :: Console", "Operating System :: OS Independent", "Programming Language :: Python :: 3"]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
nx-title-id = "nx_title_id:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling>=1.27,<2"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.version]
|
|
24
|
+
path = "src/nx_title_id/__init__.py"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/nx_title_id"]
|
|
28
|
+
|
|
29
|
+
# Keep repo tooling/config out of the source distribution.
|
|
30
|
+
[tool.hatch.build.targets.sdist]
|
|
31
|
+
include = ["/CHANGELOG.md", "/LICENSE", "/README.md", "/src"]
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"cryptography==50.0.2", # reference AES-XTS to check the hand-rolled one in the tests
|
|
36
|
+
"tryke==0.0.30",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Resolve `from nx_title_id import ...` in tests/ to src/ for Tryke's import graph.
|
|
40
|
+
[tool.tryke]
|
|
41
|
+
src = [".", "src"]
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Print the application (base game) title ID of the Nintendo Switch game packages in a folder.
|
|
3
|
+
|
|
4
|
+
Packages: .nsp and .nsz (a PFS0 archive), .xci and .xcz (a cartridge image: the card header's root HFS0, whose "secure"
|
|
5
|
+
partition holds the game's files). Each package's title ID comes from, in order:
|
|
6
|
+
|
|
7
|
+
1. A ticket's name inside the package (<rights ID>.tik, whose first 16 hex
|
|
8
|
+
digits are the title ID);
|
|
9
|
+
|
|
10
|
+
2. A [title ID] tag in the file name;
|
|
11
|
+
|
|
12
|
+
3. The package's meta NCAs: their headers decrypted with prod.keys header_key
|
|
13
|
+
(AES-128-XTS), the lowest title ID among them (the base games, before its
|
|
14
|
+
updates or DLCs).
|
|
15
|
+
|
|
16
|
+
Updates (…800) and DLC (base + 0x1000 + index) count as their base game. Key
|
|
17
|
+
values are never printed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import re
|
|
22
|
+
import struct
|
|
23
|
+
import sys
|
|
24
|
+
from io import BufferedReader
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__version__ = '1.0.0'
|
|
29
|
+
|
|
30
|
+
EXTENSIONS = ('.nsp', '.nsz', '.xci', '.xcz')
|
|
31
|
+
DEFAULT_KEYS: Path = Path.home() / '.switch' / 'prod.keys'
|
|
32
|
+
META = 1 # NCA content type: 0 program, 1 meta, 2 control, 3 manual, 4 data, 5 public data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MalformedPackageError(Exception):
|
|
36
|
+
"""
|
|
37
|
+
A package whose headers are truncated or inconsistent. Not a ValueError,
|
|
38
|
+
so main() can skip the one package instead of treating it as a keys error.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def partition(f: BufferedReader, at: int, magic: bytes, entry_size: int):
|
|
43
|
+
"""
|
|
44
|
+
The entries of a PFS0 or HFS0 partition header at offset `at`: [(name,
|
|
45
|
+
absolute data offset)], or [] when the magic is wrong. Both start with
|
|
46
|
+
magic, entry count, string table size, reserved; each entry starts with data
|
|
47
|
+
offset (u64), size (u64), name offset (u32).
|
|
48
|
+
"""
|
|
49
|
+
f.seek(at)
|
|
50
|
+
head: bytes = f.read(16)
|
|
51
|
+
|
|
52
|
+
if len(head) < 16 or head[:4] != magic:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
count, strings_size = struct.unpack_from('<II', head, 4)
|
|
56
|
+
entries, strings = f.read(count * entry_size), f.read(strings_size)
|
|
57
|
+
|
|
58
|
+
if len(entries) < count * entry_size or len(strings) < strings_size:
|
|
59
|
+
raise MalformedPackageError(f'truncated {magic.decode()} header at {at:#x}')
|
|
60
|
+
|
|
61
|
+
data = at + 16 + count * entry_size + strings_size # entry data offsets are relative to the end of the header
|
|
62
|
+
out: list[Any] = []
|
|
63
|
+
|
|
64
|
+
for i in range(count):
|
|
65
|
+
offset, _size, name_at = struct.unpack_from('<QQI', entries, i * entry_size)
|
|
66
|
+
end: int = strings.find(b'\0', name_at)
|
|
67
|
+
|
|
68
|
+
if end < 0:
|
|
69
|
+
raise MalformedPackageError(f'unterminated file name in {magic.decode()} header at {at:#x}')
|
|
70
|
+
|
|
71
|
+
name: str = strings[name_at:end].decode(errors='replace')
|
|
72
|
+
out.append((name, data + offset))
|
|
73
|
+
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def package_files(f: BufferedReader):
|
|
78
|
+
"""
|
|
79
|
+
The files inside an open package: an NSP/NSZ's PFS0, or an XCI/XCZ's
|
|
80
|
+
secure partition. [(name, absolute offset)], [] when it is neither.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
f.seek(0)
|
|
84
|
+
head: bytes = f.read(0x140)
|
|
85
|
+
|
|
86
|
+
if head[:4] == b'PFS0':
|
|
87
|
+
return partition(f, 0, b'PFS0', 0x18)
|
|
88
|
+
|
|
89
|
+
if head[0x100:0x104] == b'HEAD': # card header: the root HFS0's offset at 0x130
|
|
90
|
+
if len(head) < 0x138:
|
|
91
|
+
raise MalformedPackageError('truncated card header')
|
|
92
|
+
|
|
93
|
+
root = struct.unpack_from('<Q', head, 0x130)[0]
|
|
94
|
+
|
|
95
|
+
for name, at in partition(f, root, b'HFS0', 0x40):
|
|
96
|
+
if name == 'secure':
|
|
97
|
+
return partition(f, at, b'HFS0', 0x40)
|
|
98
|
+
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_package(path) -> bool:
|
|
103
|
+
with open(path, 'rb') as f:
|
|
104
|
+
head: bytes = f.read(0x104)
|
|
105
|
+
|
|
106
|
+
return head[:4] == b'PFS0' or head[0x100:0x104] == b'HEAD'
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---- meta NCA fallback ----
|
|
110
|
+
def read_header_key(keys_path) -> bytes:
|
|
111
|
+
"""
|
|
112
|
+
header_key (32 bytes: the XTS data key, then the tweak key) from a prod.keys
|
|
113
|
+
file.
|
|
114
|
+
"""
|
|
115
|
+
for line in Path(keys_path).read_text(errors='replace').splitlines():
|
|
116
|
+
m: re.Match[str] | None = re.fullmatch(r'\s*header_key\s*=\s*([0-9A-Fa-f]{64})\s*', line)
|
|
117
|
+
|
|
118
|
+
if m:
|
|
119
|
+
return bytes.fromhex(m.group(1))
|
|
120
|
+
|
|
121
|
+
raise ValueError(f'{keys_path} has no header_key')
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def xts_decrypt_sector(header_key, data: bytes, sector: int) -> bytes:
|
|
125
|
+
"""
|
|
126
|
+
AES-128-XTS decryption of one 0x200-byte sector with Nintendo's tweak: the
|
|
127
|
+
sector number in big endian (standard XTS uses little endian). pycryptodome
|
|
128
|
+
has no XTS mode, so it is built on AES-ECB.
|
|
129
|
+
"""
|
|
130
|
+
from Crypto.Cipher import (
|
|
131
|
+
AES, # imported here: packages with a ticket or a tag never need it
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
data_key, tweak_key = AES.new(header_key[:16], AES.MODE_ECB), AES.new(header_key[16:], AES.MODE_ECB)
|
|
135
|
+
t: int = int.from_bytes(tweak_key.encrypt(sector.to_bytes(16, 'big')), 'little')
|
|
136
|
+
out = bytearray()
|
|
137
|
+
|
|
138
|
+
for i in range(0, len(data), 16):
|
|
139
|
+
tb: bytes = t.to_bytes(16, 'little')
|
|
140
|
+
block = bytes(a ^ b for a, b in zip(data[i : i + 16], tb))
|
|
141
|
+
out += bytes(a ^ b for a, b in zip(data_key.decrypt(block), tb))
|
|
142
|
+
t <<= 1 # multiply the tweak by x in GF(2^128)
|
|
143
|
+
|
|
144
|
+
if t >> 128:
|
|
145
|
+
t ^= (1 << 128) | 0x87
|
|
146
|
+
|
|
147
|
+
return bytes(out)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def meta_title_of(path, header_key):
|
|
151
|
+
"""
|
|
152
|
+
The lowest title ID among the package's meta NCAs, or None when it has none.
|
|
153
|
+
NCA header: sector 0 holds the signatures; sector 1 (0x200) starts with the
|
|
154
|
+
magic (NCA3/NCA2), then distribution type (u8), content type (u8) and, at
|
|
155
|
+
0x210, the title ID (u64). An NSZ/XCZ's compressed .ncz keeps that header
|
|
156
|
+
as it is.
|
|
157
|
+
"""
|
|
158
|
+
found = None
|
|
159
|
+
with open(path, 'rb') as f:
|
|
160
|
+
for name, at in package_files(f):
|
|
161
|
+
if not name.lower().endswith(('.nca', '.ncz')):
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
f.seek(at + 0x200)
|
|
165
|
+
sector: bytes = f.read(0x200)
|
|
166
|
+
|
|
167
|
+
if len(sector) < 0x200:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
h: bytes = xts_decrypt_sector(header_key, sector, 1)
|
|
171
|
+
|
|
172
|
+
if h[:4] not in (b'NCA3', b'NCA2') or h[5] != META:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
tid = struct.unpack_from('<Q', h, 0x10)[0]
|
|
176
|
+
found = tid if found is None else min(found, tid)
|
|
177
|
+
|
|
178
|
+
return found
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def title_of(path, header_key=None):
|
|
182
|
+
"""
|
|
183
|
+
The title ID of one package: its ticket's name, else a [title ID] tag in its
|
|
184
|
+
name, else (with header_key, a function returning the key) its meta NCAs.
|
|
185
|
+
None when none of them says.
|
|
186
|
+
"""
|
|
187
|
+
with open(path, 'rb') as f:
|
|
188
|
+
names: list[Any] = [name for name, _ in package_files(f)]
|
|
189
|
+
|
|
190
|
+
for name in names: # the ticket first: a file name's tag can be wrong
|
|
191
|
+
if re.fullmatch(r'[0-9A-Fa-f]{32}\.tik', name):
|
|
192
|
+
return int(name[:16], 16)
|
|
193
|
+
|
|
194
|
+
m: re.Match[str] | None = re.search(r'\[([0-9A-Fa-f]{16})\]', path.name)
|
|
195
|
+
|
|
196
|
+
if m:
|
|
197
|
+
return int(m.group(1), 16)
|
|
198
|
+
|
|
199
|
+
return meta_title_of(path, header_key()) if header_key else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def application_of(tid):
|
|
203
|
+
"""
|
|
204
|
+
The base game's title ID for a base, update or DLC title ID.
|
|
205
|
+
"""
|
|
206
|
+
low = tid & 0xFFF
|
|
207
|
+
|
|
208
|
+
if low == 0:
|
|
209
|
+
return tid # base game
|
|
210
|
+
|
|
211
|
+
if low == 0x800:
|
|
212
|
+
return tid & ~0xFFF # update
|
|
213
|
+
|
|
214
|
+
return (tid & ~0xFFF) - 0x1000 # DLC
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main(argv=None) -> None:
|
|
218
|
+
parser = argparse.ArgumentParser(
|
|
219
|
+
prog='nx-title-id',
|
|
220
|
+
description='Print the title ID of the game whose packages (.nsp, .nsz, .xci, .xcz) are in a folder.',
|
|
221
|
+
)
|
|
222
|
+
parser.add_argument('folder', type=Path, help='folder holding the game packages')
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
'-k',
|
|
225
|
+
'--keys',
|
|
226
|
+
type=Path,
|
|
227
|
+
help=f'''
|
|
228
|
+
prod.keys, for packages with neither a ticket nor a [title ID] in their
|
|
229
|
+
name (default: {DEFAULT_KEYS} when it exists)
|
|
230
|
+
''',
|
|
231
|
+
)
|
|
232
|
+
parser.add_argument('-V', '--version', action='version', version=f'%(prog)s {__version__}')
|
|
233
|
+
args: argparse.Namespace = parser.parse_args(argv)
|
|
234
|
+
|
|
235
|
+
if not args.folder.is_dir():
|
|
236
|
+
sys.exit(f'not a folder: {args.folder}')
|
|
237
|
+
|
|
238
|
+
keys: Path | Any | None = args.keys or (DEFAULT_KEYS if DEFAULT_KEYS.is_file() else None)
|
|
239
|
+
|
|
240
|
+
if keys and not keys.is_file():
|
|
241
|
+
sys.exit(f'no such keys file: {keys}')
|
|
242
|
+
|
|
243
|
+
cache: list[Any] = []
|
|
244
|
+
|
|
245
|
+
def header_key(): # read once, and only when a package needs the meta NCA
|
|
246
|
+
if not cache:
|
|
247
|
+
cache.append(read_header_key(keys))
|
|
248
|
+
|
|
249
|
+
return cache[0]
|
|
250
|
+
|
|
251
|
+
apps: set[Any] = set()
|
|
252
|
+
|
|
253
|
+
for pkg in sorted(p for p in args.folder.iterdir() if p.suffix.lower() in EXTENSIONS and p.is_file()):
|
|
254
|
+
if pkg.name.startswith('._') or pkg.stat().st_size == 0: # macOS AppleDouble files, empty downloads
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
if not is_package(pkg): # without this, any file with a package's extension and a tag would count
|
|
258
|
+
print(f'skipping {pkg.name}: neither a PFS0 (NSP / NSZ) nor a cartridge image (XCI / XCZ)', file=sys.stderr)
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
tid = title_of(pkg, header_key if keys else None)
|
|
263
|
+
except MalformedPackageError as e: # one bad package shouldn't stop the others
|
|
264
|
+
print(f'skipping {pkg.name}: {e}', file=sys.stderr)
|
|
265
|
+
continue
|
|
266
|
+
except ValueError as e: # prod.keys without a header_key
|
|
267
|
+
sys.exit(str(e))
|
|
268
|
+
|
|
269
|
+
if tid is None:
|
|
270
|
+
why = (
|
|
271
|
+
'no ticket, no [title ID] in its name and no readable meta NCA'
|
|
272
|
+
if keys
|
|
273
|
+
else 'no ticket and no [title ID] in its name (give -k prod.keys to read its meta NCA)'
|
|
274
|
+
)
|
|
275
|
+
print(f'skipping {pkg.name}: {why}', file=sys.stderr)
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
apps.add(application_of(tid))
|
|
279
|
+
|
|
280
|
+
if not apps:
|
|
281
|
+
sys.exit(f'no package with a readable title ID in {args.folder}')
|
|
282
|
+
|
|
283
|
+
if len(apps) > 1:
|
|
284
|
+
print('more than one game in the folder:', file=sys.stderr)
|
|
285
|
+
|
|
286
|
+
for app in sorted(apps):
|
|
287
|
+
print(f'{app:016X}')
|