Created
March 18, 2026 00:34
-
-
Save TheExpertNoob/57268bd6107f4481458087343a2e7e3f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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