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.
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}')
@@ -0,0 +1,3 @@
1
+ from nx_title_id import main
2
+
3
+ main()