Skip to content

Instantly share code, notes, and snippets.

@TheExpertNoob
Created March 18, 2026 00:34
Show Gist options
  • Select an option

  • Save TheExpertNoob/57268bd6107f4481458087343a2e7e3f to your computer and use it in GitHub Desktop.

Select an option

Save TheExpertNoob/57268bd6107f4481458087343a2e7e3f to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""
nacp_tool.py — Nintendo Switch control.nacp parser / writer
Supports bidirectional conversion: NACP binary <-> commented JSON
Usage:
python nacp_tool.py decode control.nacp # -> stdout JSON
python nacp_tool.py decode control.nacp -o out.json
python nacp_tool.py encode input.json -o control.nacp
Reference: https://switchbrew.org/wiki/NACP (last checked 2026-02-20, rev 14311)
"""
import re
import struct
import json
import zlib
import argparse
import sys
from pathlib import Path
# ─────────────────────────────────────────────────────────────────────────────
# Constants (all offsets from Switchbrew)
# ─────────────────────────────────────────────────────────────────────────────
NACP_SIZE = 0x4000
# Language index → name mapping (Switchbrew: ApplicationTitle entry-index table)
# Index 15 = BrazilianPortuguese [10.1.0+]
# Index 16 = Polish [21.0.0+]
# Index 17 = Thai [21.0.0+]
LANGUAGES = [
"AmericanEnglish", # 0
"BritishEnglish", # 1
"Japanese", # 2
"French", # 3
"German", # 4
"LatinAmericanSpanish", # 5
"Spanish", # 6
"Italian", # 7
"Dutch", # 8
"CanadianFrench", # 9
"Portuguese", # 10
"Russian", # 11
"Korean", # 12
"TraditionalChinese", # 13
"SimplifiedChinese", # 14
"BrazilianPortuguese", # 15 [10.1.0+]
"Polish", # 16 [21.0.0+]
"Thai", # 17 [21.0.0+]
]
NUM_TITLE_ENTRIES = 18 # array is still 0x300 * 16 on-disk; indices 16-17 are in reserved space
# Rating organisation index → name (0x3040, 32 signed bytes)
RATING_ORGS = [
"CERO", # 0 Japan
"GRACGCRB", # 1 South Korea
"GSRMR", # 2 Taiwan / Hong Kong
"ESRB", # 3 North America
"ClassInd", # 4 Brazil
"USK", # 5 Germany
"PEGI", # 6 Europe
"CGSRR", # 7 China
"PEGIPortugal", # 8
"PEGIBBFC", # 9 UK BBFC
"Russia", # 10 RARS
"ACB", # 11 Australia
"OFLC", # 12 New Zealand
# 13-31 reserved / unnamed
]
NUM_RATING_ORGS = 32 # total slots on-disk
# ── Enum tables ───────────────────────────────────────────────────────────────
STARTUP_USER_ACCOUNT = {
0: "None",
1: "Required",
2: "RequiredWithNetworkServiceAccountAvailable",
}
USER_ACCOUNT_SWITCH_LOCK = {0: "Disable", 1: "Enable"}
AOC_REG_TYPE = {0: "AllOnLaunch", 1: "OnDemand"}
SCREENSHOT = {0: "Allow", 1: "Deny"}
VIDEO_CAPTURE = {
0: "Disable", # No recording
1: "Manual", # Game calls InitializeGamePlayRecording() and donates heap
2: "Enable", # AM calls BoostSystemMemoryResourceSize() automatically
}
DATA_LOSS_CONFIRMATION = {0: "None", 1: "Required"}
PLAY_LOG_POLICY = {0: "Open", 1: "LogOnly", 2: "None", 3: "Closed"}
LOGO_TYPE = {
0: "LicensedByNintendo",
1: "DistributedByNintendo",
2: "Nintendo",
}
LOGO_HANDLING = {0: "Auto", 1: "Manual"}
RUNTIME_AOC_INSTALL = {
0: "Deny",
1: "AllowAppend",
2: "AllowAppendButDontDownloadWhenUsingNetwork",
}
RUNTIME_PARAMETER_DELIVERY = {
0: "Always",
1: "AlwaysIfUserStateMatched",
2: "OnRestart",
}
APPROPRIATE_AGE_FOR_CHINA = {0: "None", 1: "Age8", 2: "Age12", 3: "Age16"}
CRASH_REPORT = {0: "Deny", 1: "Allow"}
HDCP = {0: "None", 1: "Required"}
PLAY_LOG_QUERY_CAPABILITY = {
0: "None",
1: "WhiteList",
2: "All",
}
REPAIR = {0: "None", 1: "SuppressGameCardAccess"}
TITLES_DATA_FORMAT = {
0: "Format0", # Uncompressed array of up to 16 ApplicationTitle entries
1: "Format1", # Deflate-compressed; first 2 bytes = DataSize, then data
}
APPARENT_PLATFORM = {
0: "Switch1", # S1 games (0x0)
1: "Switch2", # S2-only / Switch 2 Edition games (0x1)
}
JIT_CONFIGURATION_FLAG = {0: "None", 1: "Enabled"}
PLAY_REPORT_PERMISSION = {0: "None", 1: "TargetMarketing"}
CONTENTS_AVAILABILITY_TRANSITION_POLICY = {
0: "NoPolicy",
1: "Stable",
2: "Unstable",
}
APPLICATION_ERROR_CODE_PREFIX = {
0: "Default",
2: "Switch1Edition", # S1 / S2-Edition games built for 20.0.0+
3: "Switch2Only", # S2-only games
}
# ─────────────────────────────────────────────────────────────────────────────
# Enum helpers
# ─────────────────────────────────────────────────────────────────────────────
def _enum_name(mapping: dict, val: int) -> str:
return mapping.get(val, f"Unknown({val})")
def _enum_val(mapping: dict, name: str) -> int:
rev = {v: k for k, v in mapping.items()}
if name in rev:
return rev[name]
if name.startswith("Unknown(") and name.endswith(")"):
return int(name[8:-1])
raise ValueError(f"Unknown enum value: {name!r}")
# ─────────────────────────────────────────────────────────────────────────────
# String helpers
# ─────────────────────────────────────────────────────────────────────────────
def _cstr(data: bytes) -> str:
"""Decode a null-terminated UTF-8 byte string."""
try:
return data.split(b"\x00")[0].decode("utf-8")
except UnicodeDecodeError:
return data.split(b"\x00")[0].decode("latin-1")
def _cstr_encode(s: str, length: int) -> bytes:
"""Encode string to fixed-length null-padded bytes."""
encoded = s.encode("utf-8")
if len(encoded) > length:
raise ValueError(f"String too long ({len(encoded)} > {length}): {s!r}")
return encoded.ljust(length, b"\x00")
# ─────────────────────────────────────────────────────────────────────────────
# Decode (binary -> dict)
# ─────────────────────────────────────────────────────────────────────────────
def decode_nacp(data: bytes) -> dict:
if len(data) < NACP_SIZE:
raise ValueError(f"File too small: {len(data)} bytes (expected {NACP_SIZE})")
out = {}
# ── 0x0000 Title block (0x300 * 16 = 0x3000 bytes) ─────────────────────
# TitlesDataFormat at 0x3215 determines encoding; read that byte first.
titles_fmt_raw = data[0x3215]
titles_fmt = _enum_name(TITLES_DATA_FORMAT, titles_fmt_raw)
# We always store the format in the output so encode can reproduce it.
titles = []
if titles_fmt_raw == 1:
# Format1: Deflate-compressed title block.
# The decompressed size is variable — Nintendo currently uses 32 slots
# (0x6000 bytes) for Switch 2 titles, vs 16 (0x3000) for Switch 1.
# We derive the slot count from the decompressed length, not a constant.
data_size = struct.unpack_from("<H", data, 0x0)[0]
compressed = data[0x2: 0x2 + data_size]
raw_titles = zlib.decompress(compressed, -zlib.MAX_WBITS)
else:
raw_titles = data[0x0: 0x3000]
num_slots = len(raw_titles) // 0x300 # 16 for Format0, up to 32 for Format1
for i in range(num_slots):
base = i * 0x300
name = _cstr(raw_titles[base: base + 0x200])
publisher = _cstr(raw_titles[base + 0x200: base + 0x300])
if name or publisher:
lang = LANGUAGES[i] if i < len(LANGUAGES) else f"Lang{i}"
titles.append({"language": lang, "name": name, "publisher": publisher})
out["titles"] = titles
# ── 0x3000 ISBN ──────────────────────────────────────────────────────────
out["isbn"] = _cstr(data[0x3000: 0x3025])
# ── 0x3025 StartupUserAccount ────────────────────────────────────────────
out["startup_user_account"] = _enum_name(STARTUP_USER_ACCOUNT, data[0x3025])
# ── 0x3026 UserAccountSwitchLock ─────────────────────────────────────────
out["user_account_switch_lock"] = _enum_name(USER_ACCOUNT_SWITCH_LOCK, data[0x3026])
# ── 0x3027 AddOnContentRegistrationType ──────────────────────────────────
out["add_on_content_registration_type"] = _enum_name(AOC_REG_TYPE, data[0x3027])
# ── 0x3028 AttributeFlag (u32 bitmask) ──────────────────────────────────
out["attribute_flag"] = struct.unpack_from("<I", data, 0x3028)[0]
# ── 0x302C SupportedLanguageFlag (u32 bitmask, bit N = LANGUAGES[N]) ────
out["supported_language_flag"] = f"0x{struct.unpack_from('<I', data, 0x302C)[0]:08X}"
# ── 0x3030 ParentalControlFlag (u32 bitmask) ────────────────────────────
out["parental_control_flag"] = struct.unpack_from("<I", data, 0x3030)[0]
# ── 0x3034 Screenshot ────────────────────────────────────────────────────
out["screenshot"] = _enum_name(SCREENSHOT, data[0x3034])
# ── 0x3035 VideoCapture ──────────────────────────────────────────────────
out["video_capture"] = _enum_name(VIDEO_CAPTURE, data[0x3035])
# ── 0x3036 DataLossConfirmation ──────────────────────────────────────────
out["data_loss_confirmation"] = _enum_name(DATA_LOSS_CONFIRMATION, data[0x3036])
# ── 0x3037 PlayLogPolicy ─────────────────────────────────────────────────
out["play_log_policy"] = _enum_name(PLAY_LOG_POLICY, data[0x3037])
# ── 0x3038 PresenceGroupId (u64) ────────────────────────────────────────
out["presence_group_id"] = f"0x{struct.unpack_from('<Q', data, 0x3038)[0]:016X}"
# ── 0x3040 RatingAge (32 signed bytes, one per rating org) ─────────────
# Value -1 (0xFF) = no rating for this region.
# Nintendo sets ALL 32 slots to 0xFF by default, including the 19 reserved
# indices (13-31) which have no named organisation. We only expose the 13
# named orgs in the dict; the reserved slots are silently round-tripped as
# 0xFF on encode.
rating_age = {}
for idx, org in enumerate(RATING_ORGS):
rating_age[org] = struct.unpack_from("b", data, 0x3040 + idx)[0]
out["rating_age"] = rating_age
# ── 0x3060 DisplayVersion (null-terminated, 0x10 bytes) ─────────────────
out["display_version"] = _cstr(data[0x3060: 0x3070])
# ── 0x3070 AddOnContentBaseId (u64) ─────────────────────────────────────
out["add_on_content_base_id"] = f"0x{struct.unpack_from('<Q', data, 0x3070)[0]:016X}"
# ── 0x3078 SaveDataOwnerId (u64) ────────────────────────────────────────
out["save_data_owner_id"] = f"0x{struct.unpack_from('<Q', data, 0x3078)[0]:016X}"
# ── 0x3080 Save-data sizes (i64 each) ───────────────────────────────────
out["user_account_save_data_size"] = struct.unpack_from("<q", data, 0x3080)[0]
out["user_account_save_data_journal_size"] = struct.unpack_from("<q", data, 0x3088)[0]
out["device_save_data_size"] = struct.unpack_from("<q", data, 0x3090)[0]
out["device_save_data_journal_size"] = struct.unpack_from("<q", data, 0x3098)[0]
out["bcat_delivery_cache_storage_size"] = struct.unpack_from("<q", data, 0x30A0)[0]
# ── 0x30A8 ApplicationErrorCodeCategory (8-byte string) ─────────────────
out["application_error_code_category"] = _cstr(data[0x30A8: 0x30B0])
# ── 0x30B0 LocalCommunicationId (8 × u64) ───────────────────────────────
out["local_communication_ids"] = [
f"0x{struct.unpack_from('<Q', data, 0x30B0 + i * 8)[0]:016X}"
for i in range(8)
]
# ── 0x30F0 LogoType ──────────────────────────────────────────────────────
out["logo_type"] = _enum_name(LOGO_TYPE, data[0x30F0])
# ── 0x30F1 LogoHandling ──────────────────────────────────────────────────
out["logo_handling"] = _enum_name(LOGO_HANDLING, data[0x30F1])
# ── 0x30F2 RuntimeAddOnContentInstall ────────────────────────────────────
out["runtime_add_on_content_install"] = _enum_name(RUNTIME_AOC_INSTALL, data[0x30F2])
# ── 0x30F3 RuntimeParameterDelivery ──────────────────────────────────────
out["runtime_parameter_delivery"] = _enum_name(RUNTIME_PARAMETER_DELIVERY, data[0x30F3])
# ── 0x30F4 AppropriateAgeForChina ────────────────────────────────────────
out["appropriate_age_for_china"] = _enum_name(APPROPRIATE_AGE_FOR_CHINA, data[0x30F4])
# ── 0x30F5 Reserved ──────────────────────────────────────────────────────
# (was "undecided_parameter_75b8b" in earlier tooling — Switchbrew now marks Reserved)
# ── 0x30F6 CrashReport ───────────────────────────────────────────────────
out["crash_report"] = _enum_name(CRASH_REPORT, data[0x30F6])
# ── 0x30F7 Hdcp ──────────────────────────────────────────────────────────
out["hdcp"] = _enum_name(HDCP, data[0x30F7])
# ── 0x30F8 SeedForPseudoDeviceId (u64) ─────────────────────────────────
out["seed_for_pseudo_device_id"] = f"0x{struct.unpack_from('<Q', data, 0x30F8)[0]:016X}"
# ── 0x3100 BcatPassphrase (0x41 bytes, all-zero when unused) ────────────
out["bcat_passphrase"] = _cstr(data[0x3100: 0x3141])
# ── 0x3141 StartupUserAccountOption (u8 bitmask) ────────────────────────
out["startup_user_account_option"] = data[0x3141]
# ── 0x3142 ReservedForUserAccountSaveDataOperation (6 bytes, skip) ──────
# ── 0x3148 Save-data max sizes (i64 each) ───────────────────────────────
out["user_account_save_data_size_max"] = struct.unpack_from("<q", data, 0x3148)[0]
out["user_account_save_data_journal_size_max"] = struct.unpack_from("<q", data, 0x3150)[0]
out["device_save_data_size_max"] = struct.unpack_from("<q", data, 0x3158)[0]
out["device_save_data_journal_size_max"] = struct.unpack_from("<q", data, 0x3160)[0]
out["temporary_storage_size"] = struct.unpack_from("<q", data, 0x3168)[0]
out["cache_storage_size"] = struct.unpack_from("<q", data, 0x3170)[0]
out["cache_storage_journal_size"] = struct.unpack_from("<q", data, 0x3178)[0]
out["cache_storage_data_and_journal_size_max"] = struct.unpack_from("<q", data, 0x3180)[0]
# ── 0x3188 CacheStorageIndexMax (u16) ───────────────────────────────────
out["cache_storage_index_max"] = struct.unpack_from("<H", data, 0x3188)[0]
# ── 0x318A Reserved (1 byte) ────────────────────────────────────────────
# ── 0x318B RuntimeUpgrade (u8) ──────────────────────────────────────────
out["runtime_upgrade"] = data[0x318B]
# ── 0x318C SupportingLimitedApplicationLicenses (u32) ───────────────────
out["supporting_limited_licenses"] = struct.unpack_from("<I", data, 0x318C)[0]
# ── 0x3190 PlayLogQueryableApplicationId (16 × u64) ─────────────────────
out["play_log_queryable_application_ids"] = [
f"0x{struct.unpack_from('<Q', data, 0x3190 + i * 8)[0]:016X}"
for i in range(16)
]
# ── 0x3210 PlayLogQueryCapability ────────────────────────────────────────
out["play_log_query_capability"] = _enum_name(PLAY_LOG_QUERY_CAPABILITY, data[0x3210])
# ── 0x3211 RepairFlag (u8 bitmask) ──────────────────────────────────────
out["repair_flag"] = _enum_name(REPAIR, data[0x3211])
# ── 0x3212 ProgramIndex (u8) ────────────────────────────────────────────
out["program_index"] = data[0x3212]
# ── 0x3213 RequiredNetworkServiceLicenseOnLaunchFlag (u8) ───────────────
out["required_network_service_license_on_launch_flag"] = data[0x3213]
# ── 0x3214 ApplicationErrorCodePrefix [20.0.0+] ─────────────────────────
out["application_error_code_prefix"] = _enum_name(APPLICATION_ERROR_CODE_PREFIX, data[0x3214])
# ── 0x3215 TitlesDataFormat [21.0.0+] ───────────────────────────────────
out["titles_data_format"] = titles_fmt # already decoded above
# ── 0x3216 AcdIndex [20.0.0+] (u8) ────────────────────────────────────
out["acd_index"] = data[0x3216]
# ── 0x3217 ApparentPlatform [20.0.0+] ───────────────────────────────────
# 0 = Switch 1 game, 1 = Switch 2 / S2 Edition game
out["apparent_platform"] = _enum_name(APPARENT_PLATFORM, data[0x3217])
# ── 0x3218 NeighborDetectionClientConfiguration (0x198 bytes) ───────────
# SendGroupConfiguration (0x18): Id (u64) + Key (16 bytes)
# ReceivableGroupConfigurations: 16 × same struct
def _decode_ndgc(offset):
gid = struct.unpack_from("<Q", data, offset)[0]
key = data[offset + 0x8: offset + 0x18].hex()
return {"id": f"0x{gid:016X}", "key": key}
ndcc = {
"send": _decode_ndgc(0x3218),
"receive": [_decode_ndgc(0x3230 + i * 0x18) for i in range(16)],
}
out["neighbor_detection_client_configuration"] = ndcc
# ── 0x33B0 JitConfiguration (0x10 bytes) ────────────────────────────────
jit_flags = struct.unpack_from("<Q", data, 0x33B0)[0]
jit_memsz = struct.unpack_from("<Q", data, 0x33B8)[0]
out["jit_configuration"] = {
"flags": _enum_name(JIT_CONFIGURATION_FLAG, jit_flags),
"memory_size": jit_memsz,
}
# ── 0x33C0 RequiredAddOnContentsSetBinaryDescriptor (0x40 bytes) ─────────
out["required_add_on_contents_set_binary_descriptor"] = data[0x33C0: 0x3400].hex()
# ── 0x3400 PlayReportPermission (u8) ────────────────────────────────────
out["play_report_permission"] = _enum_name(PLAY_REPORT_PERMISSION, data[0x3400])
# ── 0x3401 CrashScreenshotForProd (u8) ──────────────────────────────────
out["crash_screenshot_for_prod"] = data[0x3401]
# ── 0x3402 CrashScreenshotForDev (u8) ───────────────────────────────────
out["crash_screenshot_for_dev"] = data[0x3402]
# ── 0x3403 ContentsAvailabilityTransitionPolicy (u8) ────────────────────
out["contents_availability_transition_policy"] = _enum_name(
CONTENTS_AVAILABILITY_TRANSITION_POLICY, data[0x3403]
)
# ── 0x3404 SupportedLanguageFlagForNxAddon [21.0.0+] (u32) ─────────────
out["supported_language_flag_for_nx_addon"] = f"0x{struct.unpack_from('<I', data, 0x3404)[0]:08X}"
# ── 0x3408 AccessibleLaunchRequiredVersion (8 × u64 ApplicationId) ──────
out["accessible_launch_required_version"] = [
f"0x{struct.unpack_from('<Q', data, 0x3408 + i * 8)[0]:016X}"
for i in range(8)
]
# ── 0x3448 ApplicationControlDataCondition [20.0.0+] (0x89 bytes) ──────
# Type: 8 × u8; Data: 8 × {Priority u8, reserved 7, AocIndex u16, reserved 6}; Count: u8
acdc_types = list(data[0x3448: 0x3450])
acdc_data = []
for i in range(8):
base = 0x3450 + i * 0x10
acdc_data.append({
"priority": data[base],
"aoc_index": struct.unpack_from("<H", data, base + 0x8)[0],
})
out["application_control_data_condition"] = {
"types": acdc_types,
"data": acdc_data,
"count": data[0x34D0],
}
# ── 0x34D1 InitialProgramIndex [20.0.0+] (u8) ─────────────────────────
out["initial_program_index"] = data[0x34D1]
# ── 0x34D2 Reserved (2 bytes) ───────────────────────────────────────────
# ── 0x34D4 AccessibleProgramIndexFlags [20.0.0+] (u32) ─────────────────
out["accessible_program_index_flags"] = struct.unpack_from("<I", data, 0x34D4)[0]
# ── 0x34D8 AlbumFileExport [20.0.0+] (u8) ─────────────────────────────
out["album_file_export"] = data[0x34D8]
# ── 0x34D9 Reserved (7 bytes) ───────────────────────────────────────────
# ── 0x34E0 SaveDataCertificateBytes [20.0.0+] (0x80 bytes) ─────────────
# First two bytes are 0x01 0x01 when initialised.
# For S2-only games the second byte is 0x04 instead.
# u8 at +0x11 is 0x00 for S1, 0x01 for S2-only.
out["save_data_certificate_bytes"] = data[0x34E0: 0x3560].hex()
# ── 0x3560 HasInGameVoiceChat [20.0.0+] (u8) ───────────────────────────
out["has_in_game_voice_chat"] = data[0x3560]
# ── 0x3561 Reserved (3 bytes) ───────────────────────────────────────────
# ── 0x3564 SupportedExtraAddOnContentFlag [20.0.0+] (u32) ──────────────
out["supported_extra_add_on_content_flag"] = struct.unpack_from("<I", data, 0x3564)[0]
# ── 0x3568 HasKaraokeFeature [21.0.0+] (u8) ────────────────────────────
out["has_karaoke_feature"] = data[0x3568]
# ── 0x3569 Reserved (0x697 bytes, skip) ────────────────────────────────
# ── 0x3C00 PlatformSpecificRegion [20.0.0+] (0x400 bytes) ──────────────
# Only initialised for S2-only games; store as hex for round-trip fidelity.
psr = data[0x3C00: 0x4000]
if any(b != 0 for b in psr):
out["platform_specific_region"] = psr.hex()
return out
# ─────────────────────────────────────────────────────────────────────────────
# Encode (dict -> binary)
# ─────────────────────────────────────────────────────────────────────────────
def encode_nacp(d: dict) -> bytes:
buf = bytearray(NACP_SIZE)
# ── Title block ───────────────────────────────────────────────────────────
titles_fmt_name = d.get("titles_data_format", "Format0")
titles_fmt_raw = _enum_val(TITLES_DATA_FORMAT, titles_fmt_name)
lang_index = {lang: i for i, lang in enumerate(LANGUAGES)}
# For Format1 (compressed) we need up to 32 slots; Format0 uses exactly 16.
num_slots = 32 if titles_fmt_raw == 1 else 16
raw_titles = bytearray(0x300 * num_slots)
for entry in d.get("titles", []):
i = lang_index.get(entry.get("language", ""), -1)
if i < 0 or i >= num_slots:
continue
base = i * 0x300
raw_titles[base: base + 0x200] = _cstr_encode(entry.get("name", ""), 0x200)
raw_titles[base + 0x200: base + 0x300] = _cstr_encode(entry.get("publisher", ""), 0x100)
if titles_fmt_raw == 1:
compressed = zlib.compress(bytes(raw_titles), level=9)[2:-4] # raw deflate, strip zlib header/adler
data_size = len(compressed)
struct.pack_into("<H", buf, 0x0, data_size)
buf[0x2: 0x2 + data_size] = compressed
else:
buf[0x0: 0x3000] = raw_titles
# ── Scalar fields ─────────────────────────────────────────────────────────
buf[0x3000: 0x3025] = _cstr_encode(d.get("isbn", ""), 0x25)
buf[0x3025] = _enum_val(STARTUP_USER_ACCOUNT, d.get("startup_user_account", "None"))
buf[0x3026] = _enum_val(USER_ACCOUNT_SWITCH_LOCK, d.get("user_account_switch_lock", "Disable"))
buf[0x3027] = _enum_val(AOC_REG_TYPE, d.get("add_on_content_registration_type","AllOnLaunch"))
struct.pack_into("<I", buf, 0x3028, d.get("attribute_flag", 0))
struct.pack_into("<I", buf, 0x302C, int(d.get("supported_language_flag", "0x0"), 16) if isinstance(d.get("supported_language_flag"), str) else d.get("supported_language_flag", 0))
struct.pack_into("<I", buf, 0x3030, d.get("parental_control_flag", 0))
buf[0x3034] = _enum_val(SCREENSHOT, d.get("screenshot", "Allow"))
buf[0x3035] = _enum_val(VIDEO_CAPTURE, d.get("video_capture", "Disable"))
buf[0x3036] = _enum_val(DATA_LOSS_CONFIRMATION, d.get("data_loss_confirmation","None"))
buf[0x3037] = _enum_val(PLAY_LOG_POLICY, d.get("play_log_policy", "Open"))
struct.pack_into("<Q", buf, 0x3038, int(d.get("presence_group_id", "0x0"), 16))
# Pre-fill all 32 rating slots with 0xFF (-1 signed = "not rated").
# Nintendo uses 0xFF for every slot that has no assigned rating,
# including all reserved/unnamed org indices (13-31).
# Only named org slots are then overwritten with actual values.
for idx in range(NUM_RATING_ORGS):
struct.pack_into("b", buf, 0x3040 + idx, -1)
for idx, org in enumerate(RATING_ORGS):
val = d.get("rating_age", {}).get(org, -1)
struct.pack_into("b", buf, 0x3040 + idx, int(val))
buf[0x3060: 0x3070] = _cstr_encode(d.get("display_version", ""), 0x10)
struct.pack_into("<Q", buf, 0x3070, int(d.get("add_on_content_base_id", "0x0"), 16))
struct.pack_into("<Q", buf, 0x3078, int(d.get("save_data_owner_id", "0x0"), 16))
struct.pack_into("<q", buf, 0x3080, d.get("user_account_save_data_size", 0))
struct.pack_into("<q", buf, 0x3088, d.get("user_account_save_data_journal_size", 0))
struct.pack_into("<q", buf, 0x3090, d.get("device_save_data_size", 0))
struct.pack_into("<q", buf, 0x3098, d.get("device_save_data_journal_size", 0))
struct.pack_into("<q", buf, 0x30A0, d.get("bcat_delivery_cache_storage_size", 0))
buf[0x30A8: 0x30B0] = _cstr_encode(d.get("application_error_code_category", ""), 8)
for i, lcid in enumerate((d.get("local_communication_ids", []) + ["0x0"] * 8)[:8]):
struct.pack_into("<Q", buf, 0x30B0 + i * 8, int(lcid, 16))
buf[0x30F0] = _enum_val(LOGO_TYPE, d.get("logo_type", "LicensedByNintendo"))
buf[0x30F1] = _enum_val(LOGO_HANDLING, d.get("logo_handling", "Auto"))
buf[0x30F2] = _enum_val(RUNTIME_AOC_INSTALL, d.get("runtime_add_on_content_install", "Deny"))
buf[0x30F3] = _enum_val(RUNTIME_PARAMETER_DELIVERY, d.get("runtime_parameter_delivery", "Always"))
buf[0x30F4] = _enum_val(APPROPRIATE_AGE_FOR_CHINA, d.get("appropriate_age_for_china", "None"))
# 0x30F5 = Reserved, leave zero
buf[0x30F6] = _enum_val(CRASH_REPORT, d.get("crash_report", "Deny"))
buf[0x30F7] = _enum_val(HDCP, d.get("hdcp", "None"))
struct.pack_into("<Q", buf, 0x30F8, int(d.get("seed_for_pseudo_device_id", "0x0"), 16))
buf[0x3100: 0x3141] = _cstr_encode(d.get("bcat_passphrase", ""), 0x41)
buf[0x3141] = d.get("startup_user_account_option", 0)
# 0x3142-0x3147 = ReservedForUserAccountSaveDataOperation, leave zero
struct.pack_into("<q", buf, 0x3148, d.get("user_account_save_data_size_max", 0))
struct.pack_into("<q", buf, 0x3150, d.get("user_account_save_data_journal_size_max", 0))
struct.pack_into("<q", buf, 0x3158, d.get("device_save_data_size_max", 0))
struct.pack_into("<q", buf, 0x3160, d.get("device_save_data_journal_size_max", 0))
struct.pack_into("<q", buf, 0x3168, d.get("temporary_storage_size", 0))
struct.pack_into("<q", buf, 0x3170, d.get("cache_storage_size", 0))
struct.pack_into("<q", buf, 0x3178, d.get("cache_storage_journal_size", 0))
struct.pack_into("<q", buf, 0x3180, d.get("cache_storage_data_and_journal_size_max", 0))
struct.pack_into("<H", buf, 0x3188, d.get("cache_storage_index_max", 0))
# 0x318A = Reserved
buf[0x318B] = d.get("runtime_upgrade", 0)
struct.pack_into("<I", buf, 0x318C, d.get("supporting_limited_licenses", 0))
for i, plid in enumerate((d.get("play_log_queryable_application_ids", []) + ["0x0"] * 16)[:16]):
struct.pack_into("<Q", buf, 0x3190 + i * 8, int(plid, 16))
buf[0x3210] = _enum_val(PLAY_LOG_QUERY_CAPABILITY, d.get("play_log_query_capability", "None"))
buf[0x3211] = _enum_val(REPAIR, d.get("repair_flag", "None"))
buf[0x3212] = d.get("program_index", 0)
buf[0x3213] = d.get("required_network_service_license_on_launch_flag", 0)
buf[0x3214] = _enum_val(APPLICATION_ERROR_CODE_PREFIX, d.get("application_error_code_prefix", "Default"))
buf[0x3215] = titles_fmt_raw
buf[0x3216] = d.get("acd_index", 0)
buf[0x3217] = _enum_val(APPARENT_PLATFORM, d.get("apparent_platform", "Switch1"))
# NeighborDetectionClientConfiguration
def _encode_ndgc(offset, cfg):
struct.pack_into("<Q", buf, offset, int(cfg.get("id", "0x0"), 16))
key_bytes = bytes.fromhex(cfg.get("key", "00" * 16))
buf[offset + 0x8: offset + 0x18] = key_bytes.ljust(16, b"\x00")[:16]
ndcc = d.get("neighbor_detection_client_configuration", {})
_encode_ndgc(0x3218, ndcc.get("send", {}))
for i, rcfg in enumerate((ndcc.get("receive", []) + [{}] * 16)[:16]):
_encode_ndgc(0x3230 + i * 0x18, rcfg)
# JitConfiguration
jit = d.get("jit_configuration", {})
struct.pack_into("<Q", buf, 0x33B0, _enum_val(JIT_CONFIGURATION_FLAG, jit.get("flags", "None")))
struct.pack_into("<Q", buf, 0x33B8, jit.get("memory_size", 0))
# RequiredAddOnContentsSetBinaryDescriptor
raocbd_hex = d.get("required_add_on_contents_set_binary_descriptor", "")
if raocbd_hex:
raw = bytes.fromhex(raocbd_hex)[:0x40]
buf[0x33C0: 0x33C0 + len(raw)] = raw
buf[0x3400] = _enum_val(PLAY_REPORT_PERMISSION, d.get("play_report_permission", "None"))
buf[0x3401] = d.get("crash_screenshot_for_prod", 0)
buf[0x3402] = d.get("crash_screenshot_for_dev", 0)
buf[0x3403] = _enum_val(CONTENTS_AVAILABILITY_TRANSITION_POLICY, d.get("contents_availability_transition_policy", "NoPolicy"))
struct.pack_into("<I", buf, 0x3404, int(d.get("supported_language_flag_for_nx_addon", "0x0"), 16) if isinstance(d.get("supported_language_flag_for_nx_addon"), str) else d.get("supported_language_flag_for_nx_addon", 0))
for i, aid in enumerate((d.get("accessible_launch_required_version", []) + ["0x0"] * 8)[:8]):
struct.pack_into("<Q", buf, 0x3408 + i * 8, int(aid, 16))
# ApplicationControlDataCondition
acdc = d.get("application_control_data_condition", {})
for i, t in enumerate((acdc.get("types", []) + [0] * 8)[:8]):
buf[0x3448 + i] = int(t)
for i, entry in enumerate((acdc.get("data", []) + [{}] * 8)[:8]):
base = 0x3450 + i * 0x10
buf[base] = entry.get("priority", 0)
struct.pack_into("<H", buf, base + 0x8, entry.get("aoc_index", 0))
buf[0x34D0] = acdc.get("count", 0)
buf[0x34D1] = d.get("initial_program_index", 0)
struct.pack_into("<I", buf, 0x34D4, d.get("accessible_program_index_flags", 0))
buf[0x34D8] = d.get("album_file_export", 0)
sdcb_hex = d.get("save_data_certificate_bytes", "")
if sdcb_hex:
raw = bytes.fromhex(sdcb_hex)[:0x80]
buf[0x34E0: 0x34E0 + len(raw)] = raw
buf[0x3560] = d.get("has_in_game_voice_chat", 0)
struct.pack_into("<I", buf, 0x3564, d.get("supported_extra_add_on_content_flag", 0))
buf[0x3568] = d.get("has_karaoke_feature", 0)
psr_hex = d.get("platform_specific_region", "")
if psr_hex:
raw = bytes.fromhex(psr_hex)[:0x400]
buf[0x3C00: 0x3C00 + len(raw)] = raw
return bytes(buf)
# ─────────────────────────────────────────────────────────────────────────────
# Commented JSON (JSON doesn't support comments natively; we use "//" lines)
# ─────────────────────────────────────────────────────────────────────────────
# Inline comment hints attached to specific keys.
# Format: key -> comment string appended after the value.
FIELD_COMMENTS = {
# ── Title block ───────────────────────────────────────────────────────────
"titles":
"Array of {language, name, publisher}. Only populated languages need entries.",
"titles_data_format":
"Format0 = uncompressed (Switch 1). Format1 = Deflate-compressed [21.0.0+].",
# ── Identity / version ────────────────────────────────────────────────────
"isbn": "Usually empty.",
"display_version": "Human-readable version string shown in the HOME menu.",
"add_on_content_base_id": "Title ID of the base application for DLC.",
"save_data_owner_id": "Title ID that owns the save data (usually same as application ID).",
"application_error_code_category": "8-char prefix used in error codes shown to users.",
"local_communication_ids": "Title IDs allowed to communicate over local play (up to 8).",
"presence_group_id": "Used for grouping online presence entries.",
# ── User account ──────────────────────────────────────────────────────────
"startup_user_account":
"None | Required | RequiredWithNetworkServiceAccountAvailable.",
"user_account_switch_lock": "Disable | Enable.",
"startup_user_account_option": "Bitmask. Bit 0 = IsOptional.",
# ── Save data sizes (bytes, signed 64-bit) ───────────────────────────────
"user_account_save_data_size": "Per-user save data size in bytes.",
"user_account_save_data_journal_size": "Per-user save journal size in bytes.",
"device_save_data_size": "Device-global save data size in bytes.",
"device_save_data_journal_size": "Device-global save journal size in bytes.",
"bcat_delivery_cache_storage_size": "BCAT cache size in bytes.",
"user_account_save_data_size_max": "Maximum allowed per-user save size.",
"user_account_save_data_journal_size_max": "Maximum allowed per-user journal size.",
"device_save_data_size_max": "Maximum allowed device save size.",
"device_save_data_journal_size_max": "Maximum allowed device journal size.",
"temporary_storage_size": "Temporary storage size in bytes.",
"cache_storage_size": "Cache storage size in bytes.",
"cache_storage_journal_size": "Cache storage journal size in bytes.",
"cache_storage_data_and_journal_size_max": "Combined cache data+journal ceiling.",
"cache_storage_index_max": "Maximum cache storage index (u16).",
# ── Flags / bitmasks ──────────────────────────────────────────────────────
"attribute_flag":
"Bitmask. Bit 0 = Demo, Bit 1 = RetailInteractiveDisplay.",
"supported_language_flag":
"Hex bitmask. Bit N corresponds to LANGUAGES[N] being supported (e.g. 0x00000001 = AmericanEnglish).",
"parental_control_flag":
"Bitmask. Bit 0 = FreeCommunication.",
"supporting_limited_licenses":
"Bitmask of limited-license types supported.",
# ── Rating ages ───────────────────────────────────────────────────────────
"rating_age":
"Age rating per region. -1 (0xFF) = not rated for that region — Nintendo's default for all slots. "
"Named orgs (indices 0-12): CERO, GRACGCRB, GSRMR, ESRB, ClassInd, USK, PEGI, CGSRR, "
"PEGIPortugal, PEGIBBFC, Russia, ACB, OFLC. "
"Reserved indices 13-31 are always encoded as 0xFF and are not exposed here.",
# ── Media / capture ───────────────────────────────────────────────────────
"screenshot": "Allow | Deny.",
"video_capture":
"Disable | Manual (game allocates heap) | Enable (AM allocates automatically).",
"logo_type":
"LicensedByNintendo | DistributedByNintendo | Nintendo.",
"logo_handling": "Auto | Manual.",
# ── Runtime behaviour ─────────────────────────────────────────────────────
"add_on_content_registration_type": "AllOnLaunch | OnDemand.",
"runtime_add_on_content_install":
"Deny | AllowAppend | AllowAppendButDontDownloadWhenUsingNetwork.",
"runtime_parameter_delivery":
"Always | AlwaysIfUserStateMatched | OnRestart.",
"data_loss_confirmation": "None | Required.",
"crash_report": "Deny | Allow.",
"hdcp": "None | Required.",
"runtime_upgrade": "u8 flag; non-zero = runtime upgrade allowed.",
"play_log_policy":
"Open | LogOnly | None | Closed. Controls pdm:ntfy reporting.",
"play_log_query_capability":
"None | WhiteList | All. Controls AM QueryApplicationPlayStatistics.",
"play_log_queryable_application_ids":
"Title IDs queryable via play log (used when capability = WhiteList).",
# ── Pseudo-device / BCAT ──────────────────────────────────────────────────
"seed_for_pseudo_device_id": "Salts the pseudo-device ID generated per title.",
"bcat_passphrase":
"32-char ASCII passphrase for BCAT content decryption. All-zero = unused.",
# ── Repair / misc ─────────────────────────────────────────────────────────
"repair_flag": "None | SuppressGameCardAccess.",
"program_index": "Index of this program within a multi-program application.",
"required_network_service_license_on_launch_flag": "u8 bitmask.",
# ── Switch 2 / firmware-versioned fields ──────────────────────────────────
"application_error_code_prefix":
"[20.0.0+] Default | Switch1Edition (0x2) | Switch2Only (0x3).",
"acd_index":
"[20.0.0+] Index into ApplicationControlDataCondition.",
"apparent_platform":
"[20.0.0+] Switch1 = S1 game (0x0). Switch2 = S2-only / Switch 2 Edition (0x1).",
"neighbor_detection_client_configuration":
"Send + 16 receive group configs for local-play neighbour detection.",
"jit_configuration":
"JIT flags (None | Enabled) and memory size in bytes.",
"required_add_on_contents_set_binary_descriptor":
"0x40-byte descriptor for required AOC set (hex string).",
"play_report_permission":
"None | TargetMarketing.",
"crash_screenshot_for_prod":
"u8. Non-zero = allow crash screenshot capture on production hardware.",
"crash_screenshot_for_dev":
"u8. Non-zero = allow crash screenshot capture on development hardware.",
"contents_availability_transition_policy":
"NoPolicy | Stable | Unstable.",
"supported_language_flag_for_nx_addon":
"[21.0.0+] Hex bitmask. Bit N = LANGUAGES[N] supported for NX add-on content.",
"accessible_launch_required_version":
"Array of up to 8 ApplicationIds whose version is required at launch.",
"application_control_data_condition":
"[20.0.0+] Condition data controlling ACD evaluation (types, data entries, count).",
"initial_program_index":
"[20.0.0+] Program index to launch initially in multi-program titles.",
"accessible_program_index_flags":
"[20.0.0+] Bitmask of accessible program indices.",
"album_file_export":
"[20.0.0+] u8. Controls album screenshot/video export permission.",
"save_data_certificate_bytes":
"[20.0.0+] 0x80-byte certificate blob (hex). "
"Bytes 0-1 = 0x01 0x01 when initialised; S2-only sets byte 1 to 0x04. "
"u8 at +0x11 is 0x01 for S2-only games.",
"has_in_game_voice_chat":
"[20.0.0+] u8. Non-zero = application uses in-game voice chat.",
"supported_extra_add_on_content_flag":
"[20.0.0+] u32 bitmask for extra AOC types.",
"has_karaoke_feature":
"[21.0.0+] u8. Non-zero = application has karaoke functionality.",
"platform_specific_region":
"[20.0.0+] 0x400-byte region (hex). Only set for S2-only games.",
"appropriate_age_for_china":
"None | Age8 | Age12 | Age16.",
}
# Section header comments inserted before certain keys.
SECTION_COMMENTS: dict[str, str] = {
"titles": "═══ Title / Publisher strings (0x0000–0x2FFF) ═══",
"isbn": "═══ Metadata block (0x3000–0x30EF) ═══",
"logo_type": "═══ Logo / capture (0x30F0–0x30F7) ═══",
"seed_for_pseudo_device_id": "═══ Pseudo-device / BCAT (0x30F8–0x3140) ═══",
"startup_user_account_option": "═══ Extended save-data options (0x3141–0x318F) ═══",
"play_log_queryable_application_ids": "═══ Play log query (0x3190–0x320F) ═══",
"play_log_query_capability": "═══ Flags (0x3210–0x3213) ═══",
"application_error_code_prefix": "═══ Firmware-versioned fields [20.0.0+] (0x3214+) ═══",
"neighbor_detection_client_configuration": "═══ Neighbour detection (0x3218–0x33AF) ═══",
"jit_configuration": "═══ JIT (0x33B0–0x33BF) ═══",
"required_add_on_contents_set_binary_descriptor": "═══ AOC descriptor (0x33C0–0x33FF) ═══",
"play_report_permission": "═══ Extended flags (0x3400–0x3407) ═══",
"accessible_launch_required_version": "═══ Accessible launch versions (0x3408–0x3447) ═══",
"application_control_data_condition": "═══ ACD condition [20.0.0+] (0x3448–0x34D3) ═══",
"initial_program_index": "═══ Multi-program fields [20.0.0+] (0x34D1–0x34D8) ═══",
"save_data_certificate_bytes": "═══ Save data certificate [20.0.0+] (0x34E0–0x355F) ═══",
"has_in_game_voice_chat": "═══ Switch 2 feature flags [20.0.0+] (0x3560+) ═══",
"platform_specific_region": "═══ Platform-specific region [20.0.0+] (0x3C00–0x3FFF) ═══",
}
def to_commented_json(d: dict, indent: int = 2) -> str:
"""
Serialize a NACP dict to JSON with // comment lines.
JSON does not allow comments officially, but most modern parsers
(jsonc, hjson, json5, Python's json after stripping) accept them.
strip_comments() below removes them before passing to json.loads().
"""
lines = ["{"]
keys = list(d.keys())
for ki, key in enumerate(keys):
val = d[key]
comma = "," if ki < len(keys) - 1 else ""
# Section header comment
if key in SECTION_COMMENTS:
if ki > 0:
lines.append("")
lines.append(f'{" " * indent}// {SECTION_COMMENTS[key]}')
# Inline field comment
field_comment = FIELD_COMMENTS.get(key)
# Serialise the value
serialised = json.dumps(val, ensure_ascii=False)
# For long arrays / dicts, pretty-print them indented
if isinstance(val, (dict, list)) and val:
pretty = json.dumps(val, indent=indent, ensure_ascii=False)
# Re-indent the pretty block to sit inside the outer object
inner_lines = pretty.splitlines()
inner_lines = [inner_lines[0]] + [
" " * indent + l for l in inner_lines[1:]
]
serialised = "\n".join(inner_lines)
json_key = json.dumps(key)
if field_comment:
lines.append(f'{" " * indent}// {field_comment}')
lines.append(f'{" " * indent}{json_key}: {serialised}{comma}')
lines.append("}")
return "\n".join(lines)
def strip_comments(text: str) -> str:
"""Remove // comment lines so the result is valid JSON."""
out = []
for line in text.splitlines():
stripped = line.lstrip()
if stripped.startswith("//"):
continue
out.append(line)
return "\n".join(out)
def from_commented_json(text: str) -> dict:
return json.loads(strip_comments(text))
# ─────────────────────────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Nintendo Switch control.nacp parser / writer",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s decode control.nacp # commented JSON to stdout
%(prog)s decode control.nacp -o control.json
%(prog)s encode control.json -o control.nacp
""")
sub = parser.add_subparsers(dest="command", required=True)
dec = sub.add_parser("decode", help="NACP binary -> commented JSON")
dec.add_argument("input", help="Path to control.nacp")
dec.add_argument("-o", "--output", help="Output file (default: stdout)")
dec.add_argument("--no-comments", action="store_true",
help="Emit plain JSON without // comments")
enc = sub.add_parser("encode", help="JSON -> NACP binary")
enc.add_argument("input", help="Path to .json file (comments are stripped automatically)")
enc.add_argument("-o", "--output", default="control.nacp",
help="Output .nacp path (default: control.nacp)")
args = parser.parse_args()
if args.command == "decode":
raw = Path(args.input).read_bytes()
d = decode_nacp(raw)
text = (json.dumps(d, indent=2, ensure_ascii=False)
if args.no_comments
else to_commented_json(d))
if args.output:
Path(args.output).write_text(text, encoding="utf-8")
print(f"Wrote {args.output}", file=sys.stderr)
else:
print(text)
elif args.command == "encode":
src = Path(args.input).read_text(encoding="utf-8")
d = from_commented_json(src)
nacp_bytes = encode_nacp(d)
Path(args.output).write_bytes(nacp_bytes)
print(f"Wrote {args.output} ({len(nacp_bytes)} bytes)", file=sys.stderr)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment