Files
onionHEN/scripts/analyze_rnps_dump.py
Hexwell c4e4fb10e9 feat(shellui): complete firmware dump compatibility
Add HomeUI and Settings profiles for every usable firmware dump in Sony Dumps, including the early legacy bundles.

Expand the offline verifiers to cover archived dumps and document the final 48-version compatibility matrix.
2026-08-15 14:32:58 +08:00

800 lines
27 KiB
Python
Executable File

#!/usr/bin/env python3
"""Extract and validate RNPS/HBC dump fingerprints used by OnionHEN profiles."""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
from typing import Any
HERMES_MAGIC = bytes([0xC6, 0x1F, 0xBC, 0x03, 0xC1, 0x03, 0x19, 0x1F])
LEGACY_BUNDLE_MAGIC = bytes([0xE5, 0xD1, 0x0B, 0xFB])
PLAIN_JS_PREFIX = b"/*! For license information"
RNPS_MAGIC = b"RNPSHEDR"
RNPS_PAYLOAD_OFFSET_FIELD = 0x1C
RNPS_FALLBACK_PAYLOAD_OFFSET = 0xB20
HBC_VERSION_OFFSET = 0x08
HBC_SOURCE_HASH_OFFSET = 0x0C
HBC_SOURCE_HASH_SIZE = 20
HBC_FILE_LENGTH_OFFSET = 0x20
KNOWN_HOMEUI_PROFILES = [
{
"name": "9.00 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1846E0,
"source_hash": "587635687e0a190e38425232c39092888da5adbe",
},
{
# 9.4 and 9.6 NPXS40002 dumps are byte-identical.
"name": "9.4/9.6 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1A3B54,
"source_hash": "81452e48a6937d9f1c13308ce6958f8f9f0c6938",
},
{
"name": "10.01 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B3318,
"source_hash": "ae256655beaeea9e752e23256bbdddf1af254aea",
},
{
# 10.2 NPXS40002 is byte-identical to the 10.4/10.6 dump.
"name": "10.4/10.6 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B3BCC,
"source_hash": "2cac5cc444ba0473ea8ee632a7942f281482a68a",
},
{
"name": "11.0 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B3010,
"source_hash": "e21110895e8fb6c85f49db972d51fc101bb8fc52",
},
{
# 11.2 NPXS40002 is byte-identical to the 11.4/11.6 dump.
"name": "11.4/11.6 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B2CC8,
"source_hash": "f321f83f9143035f5d97ee5ad98ceb75133c890e",
},
{
# 12.4 and 12.6 NPXS40002 are byte-identical to the 12.7 dump.
"name": "12.7 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B73BC,
"source_hash": "9dd2dc47c6024843f685af80ae9273e6a075337d",
},
{
# 12.0 and 12.02 NPXS40002 dumps are byte-identical to 12.20.
"name": "12.20 NPXS40002 HomeUI",
"hbc_version": 89,
"file_length": 0x1B70E4,
"source_hash": "d9aa3ec2fcf7cc0bb0a7fe6362079c494948cf5e",
},
]
KNOWN_LEGACY_HOMEUI_PROFILES = [
{
# 2.30 and 2.50 NPXS40002 are byte-identical.
"name": "2.30/2.50 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x1631E0,
"file_size": 0x163D00,
"sha256": "3a50628e07431ae4eadfbda45cfc6882a4558c6f548382bc6bab81d769cccf4b",
},
{
# 3.00, 3.10, 3.20 and 3.21 NPXS40002 are byte-identical.
"name": "3.00/3.10/3.20/3.21 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x150130,
"file_size": 0x150C50,
"sha256": "21c91c7044c0d36ee26827260e956db085096fd93264f6d5cb5c0255fc8dcf6f",
},
{
# 4.00, 4.02, 4.03, 4.50 and 4.51 NPXS40002 are byte-identical.
"name": "4.00/4.02/4.03/4.50/4.51 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x152990,
"file_size": 0x1534B0,
"sha256": "6db944372cfe8b7d50328ed4bd47c8cae6917821fb30f20004e7f85c673fe00a",
},
{
# 5.00 and 5.02 NPXS40002 are byte-identical.
"name": "5.00/5.02 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x17A690,
"file_size": 0x17B1B0,
"sha256": "a0be894ce20f2769f43715b0ac75c135d9d0811d6d26d061d311671505b3d66f",
},
{
# 5.10 and 5.50 NPXS40002 are byte-identical.
"name": "5.10/5.50 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x185C30,
"file_size": 0x186750,
"sha256": "bfa53c6bd1fd4c468ebf7ee44955db0cf8286f116a8ab1870b756de0efbfc5fb",
},
{
# 6.00 and 6.02 NPXS40002 are byte-identical.
"name": "6.00/6.02 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x185D00,
"file_size": 0x186820,
"sha256": "b376f7ead9140636beac99354a7d958fc18ea4da40580dda590927be936e0a18",
},
{
# 7.40 and 7.61 NPXS40002 are byte-identical.
"name": "7.40/7.61 NPXS40002 legacy HomeUI",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x19DC10,
"file_size": 0x19E730,
"sha256": "1f884701ec9a490b8149f9873ead915fc2d2d5ee95f55363beff39cc5872e93e",
},
]
KNOWN_PLAIN_JS_HOMEUI_PROFILES = [
{
"name": "8.00 NPXS40002 plain-JS HomeUI",
"payload_size": 0x158070,
"file_size": 0x158B90,
"sha256": "00ac38a2d7b7e11a3910865d206b6256593e0ff94f8d7c9d233ab983b25dac89",
},
{
"name": "8.40 NPXS40002 plain-JS HomeUI",
"payload_size": 0x158070,
"file_size": 0x158B90,
"sha256": "a57611dbd0e56ca80747d2862f5a9f651a4b3c1504dca9b8799cfeda718e22e2",
},
]
KNOWN_LEGACY_SETTINGS_PROFILES = [
{
# 3.00, 3.10, 3.20 and 3.21 NPXS40008 are byte-identical.
"name": "3.00/3.10/3.20/3.21 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x457210,
"file_size": 0x457D30,
"sha256": "a26b4097da235802f13a43f7b89fe9403a8c175e9f843b9b795f91bfc1403654",
},
{
# 4.00, 4.02 and 4.03 NPXS40008 are byte-identical.
"name": "4.00/4.02/4.03 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x483280,
"file_size": 0x483DB0,
"sha256": "3b8f1725ec1c0afe87cc72ecebc29b6f7239d03bd7397a621b7a314455d12bb7",
},
{
# 4.50 and 4.51 NPXS40008 are byte-identical.
"name": "4.50/4.51 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x483FC0,
"file_size": 0x484AF0,
"sha256": "8e016beed7584283c9719c49662fb5e1071083dc1e3dd8988e1b9fe9fd45b032",
},
{
# 5.00 and 5.02 NPXS40008 are byte-identical.
"name": "5.00/5.02 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x4B8770,
"file_size": 0x4B92A0,
"sha256": "814f7bf3b09a4532d5ad4c6b3600f2c5457e2a2edef04c6b61741e6af03a678f",
},
{
"name": "5.10 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x4B8AC0,
"file_size": 0x4B95F0,
"sha256": "f6fd5f1aba8de0ee3f56e821c69dcea795dec461998db6082a4f58a33ba88ad7",
},
{
"name": "5.50 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x4B9E50,
"file_size": 0x4BA980,
"sha256": "34d137c8e403d53e1dc09ad7c8b0a88634555f2e41508e3106e1bb315745f311",
},
{
# 6.00 and 6.02 NPXS40008 are byte-identical.
"name": "6.00/6.02 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x5524A0,
"file_size": 0x552FD0,
"sha256": "2acc1cfc8421c6cb24c25ad29b8433040f79043a068dd941cbf29e2e7daabc0d",
},
{
"name": "6.50 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x555280,
"file_size": 0x555DB0,
"sha256": "77ce1eeb90ab4c2f113b5af7b37a57f4cf62be76b5eb662a7540718cf3ea3daf",
},
{
# 7.00, 7.01 and 7.01.01 NPXS40008 are byte-identical.
"name": "7.00/7.01/7.01.01 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x5E7540,
"file_size": 0x5E8070,
"sha256": "69ddf29f8041ef013e628a449423a1e7d7b401a7ee68e417605e9eb4478f1b5c",
},
{
"name": "7.20 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x5E7940,
"file_size": 0x5E8470,
"sha256": "513da7906b016fd849750112ba50a110fa6ec3de4fb0ee9631007951d575361d",
},
{
# 7.40 and 7.61 NPXS40008 are byte-identical.
"name": "7.40/7.61 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x5E9D20,
"file_size": 0x5EA850,
"sha256": "469f723359ec07ab6ab72089328401301eb6f32dbc4a6776bf1421c7195ce853",
},
{
"name": "8.00 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x64BB80,
"file_size": 0x64C6B0,
"sha256": "a3c76ecf8e3e29cd373fb831f73498c7864d55c5adb6d8f7fc2f48032d58d6ee",
},
{
"name": "8.40 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x654AF0,
"file_size": 0x655620,
"sha256": "5fe90813275c75b280e424cc9c10139b380a9b223fe8c5712b7df86329553711",
},
{
"name": "8.60 NPXS40008 Settings",
"route": "standard",
"payload_magic": LEGACY_BUNDLE_MAGIC.hex(),
"payload_size": 0x6561E0,
"file_size": 0x656D10,
"sha256": "57d59334d99a101e92bbb470c1c271c7838f3b7a21ffebcf237a487cdc6665c4",
},
]
KNOWN_SETTINGS_PROFILES = [
{
"name": "9.00 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4B1934,
"source_hash": "72188b52b12bad6af90c90a848b7fd76e5af102d",
},
{
"name": "9.40 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4BA2C0,
"source_hash": "4f1ae4b6786cc96646e14eec923d09c3c031e980",
},
{
"name": "9.60 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4BA1F0,
"source_hash": "59506d7b5c595196b21661264bc9c1b487d21b51",
},
{
"name": "10.01 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4DDA8C,
"source_hash": "ad6cf2d6f8974ccd34b14e69bb6e340e8dec5dc5",
},
{
# 10.2 and 10.4 dumps share this Settings bundle fingerprint.
"name": "10.4 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4E089C,
"source_hash": "abb8fdf5a894ce6fd1e99381d0866b33f279c7b9",
},
{
"name": "10.6 NPXS40008 Settings",
"route": "standard",
"file_length": 0x4E0954,
"source_hash": "31651a188d49b23b7635afa449395e0fbd9f682a",
},
{
"name": "11.0 NPXS40008 Settings",
"route": "old",
"file_length": 0x4FA540,
"source_hash": "1824c9fb562e31eef651bb3874c1c73f7f6e24b0",
},
{
"name": "11.2 NPXS40008 Settings",
"route": "old",
"file_length": 0x4F45B8,
"source_hash": "d03462a912c4b5b8db4a98d044b9d488a2dffc7a",
},
{
"name": "11.4 NPXS40008 Settings",
"route": "old",
"file_length": 0x4F45C4,
"source_hash": "a7b731571f84b6cdaf7c4227a980ba5ee20004a8",
},
{
"name": "11.6 NPXS40008 Settings",
"route": "old",
"file_length": 0x4F4BFC,
"source_hash": "92566124b6cfe0b0a7c812fc8a3bbfcf32ac4683",
},
{
# 12.0 and 12.02 dumps share this Settings bundle fingerprint.
"name": "12.02 NPXS40008 Settings",
"route": "old",
"file_length": 0x4E7BEC,
"source_hash": "fc7c4f15af42929e1d52420c2d174944b4a88043",
},
{
"name": "12.6 NPXS40008 Settings",
"route": "old",
"file_length": 0x4E9028,
"source_hash": "75747bb5fa7e3a4e22d557882f5281e4d1f12959",
},
{
"name": "12.7 NPXS40008 Settings",
"route": "old",
"file_length": 0x4E9048,
"source_hash": "445da8bcba93da165473d3da491d9b13f96316cd",
},
{
# 12.4 and 12.20 dumps share this Settings bundle fingerprint.
"name": "12.20 NPXS40008 Settings",
"route": "old",
"file_length": 0x4E8E54,
"source_hash": "5d4461858b0a38fc6e7b086dbdfdab619515908e",
},
]
TRACKED_STRINGS = [
b"NPXS40002",
b"ApplicationErrorEventTrigger",
b"pshomeui:navigateToHome",
b"download_error",
b"homeui ApplicationErrorEvent test",
b"Trigger AppError",
b"Fps",
b"pssettings:play",
b"debug_settings_old",
b"debug_settings",
b"icon_setting",
]
def read_u32le(data: bytes, offset: int) -> int | None:
if offset < 0 or offset + 4 > len(data):
return None
return int.from_bytes(data[offset : offset + 4], "little")
def find_all(data: bytes, needle: bytes) -> list[int]:
offsets: list[int] = []
start = 0
while True:
offset = data.find(needle, start)
if offset < 0:
return offsets
offsets.append(offset)
start = offset + 1
def locate_hbc(data: bytes) -> tuple[int, str] | tuple[None, str]:
if data.startswith(HERMES_MAGIC):
return 0, "direct"
if data.startswith(RNPS_MAGIC):
declared = read_u32le(data, RNPS_PAYLOAD_OFFSET_FIELD)
if declared is not None and 0 < declared < len(data):
if data[declared : declared + len(HERMES_MAGIC)] == HERMES_MAGIC:
return declared, "rnps-declared"
if data[
RNPS_FALLBACK_PAYLOAD_OFFSET : RNPS_FALLBACK_PAYLOAD_OFFSET
+ len(HERMES_MAGIC)
] == HERMES_MAGIC:
return RNPS_FALLBACK_PAYLOAD_OFFSET, "rnps-fallback"
offset = data.find(HERMES_MAGIC)
if offset >= 0:
return offset, "scan"
return None, "missing"
def locate_legacy_bundle(data: bytes) -> tuple[int, str] | tuple[None, str]:
"""Locate the pre-Hermes RNPS JavaScript payload used by 4.x HomeUI."""
if data.startswith(LEGACY_BUNDLE_MAGIC):
return 0, "direct-legacy"
if data.startswith(RNPS_MAGIC):
declared = read_u32le(data, RNPS_PAYLOAD_OFFSET_FIELD)
candidates = []
if declared is not None and 0 < declared < len(data):
candidates.append((declared, "rnps-declared-legacy"))
candidates.append((RNPS_FALLBACK_PAYLOAD_OFFSET, "rnps-fallback-legacy"))
for offset, location in candidates:
if (
data[offset : offset + len(LEGACY_BUNDLE_MAGIC)]
== LEGACY_BUNDLE_MAGIC
):
return offset, location
return None, "missing"
def locate_plain_js_bundle(data: bytes) -> tuple[int, str] | tuple[None, str]:
if data.startswith(PLAIN_JS_PREFIX):
return 0, "direct-plain-js"
if data.startswith(RNPS_MAGIC):
declared = read_u32le(data, RNPS_PAYLOAD_OFFSET_FIELD)
candidates = []
if declared is not None and 0 < declared < len(data):
candidates.append((declared, "rnps-declared-plain-js"))
candidates.append((RNPS_FALLBACK_PAYLOAD_OFFSET, "rnps-fallback-plain-js"))
for offset, location in candidates:
if data[offset : offset + len(PLAIN_JS_PREFIX)] == PLAIN_JS_PREFIX:
return offset, location
return None, "missing"
def match_legacy_profile(
data: bytes,
payload_offset: int,
profiles: list[dict[str, Any]] = KNOWN_LEGACY_HOMEUI_PROFILES,
) -> dict[str, Any] | None:
payload = data[payload_offset:]
digest = hashlib.sha256(data).hexdigest()
for profile in profiles:
if len(data) != profile["file_size"]:
continue
if len(payload) != profile["payload_size"]:
continue
if payload[:4].hex() != profile["payload_magic"]:
continue
if digest != profile["sha256"]:
continue
return profile
return None
def match_plain_js_profile(data: bytes, payload_offset: int) -> dict[str, Any] | None:
payload = data[payload_offset:]
digest = hashlib.sha256(data).hexdigest()
for profile in KNOWN_PLAIN_JS_HOMEUI_PROFILES:
if len(data) != profile["file_size"]:
continue
if len(payload) != profile["payload_size"]:
continue
if not payload.startswith(PLAIN_JS_PREFIX):
continue
if digest != profile["sha256"]:
continue
return profile
return None
def match_profile(
profiles: list[dict[str, Any]],
hbc_version: int | None,
file_length: int | None,
source_hash: str,
) -> dict[str, Any] | None:
for profile in profiles:
if profile["file_length"] != file_length:
continue
if profile["source_hash"] != source_hash:
continue
expected_version = profile.get("hbc_version")
if expected_version is not None and expected_version != hbc_version:
continue
return profile
return None
def infer_settings_route(hbc: bytes) -> str:
if b"debug_settings_old" in hbc:
return "old"
if b"debug_settings" in hbc:
return "standard"
return "unknown"
def analyze_file(path: Path, app_id: str) -> dict[str, Any]:
data = path.read_bytes()
hbc_offset, hbc_location = locate_hbc(data)
result: dict[str, Any] = {
"app_id": app_id,
"path": str(path),
"size": path.stat().st_size,
"hbc_offset": hbc_offset,
"hbc_location": hbc_location,
}
if app_id in ("NPXS40002", "NPXS40008"):
legacy_offset, legacy_location = locate_legacy_bundle(data)
if legacy_offset is not None:
payload = data[legacy_offset:]
legacy_profiles = (
KNOWN_LEGACY_HOMEUI_PROFILES
if app_id == "NPXS40002"
else KNOWN_LEGACY_SETTINGS_PROFILES
)
profile = match_legacy_profile(data, legacy_offset, legacy_profiles)
result.update(
{
"legacy_offset": legacy_offset,
"legacy_location": legacy_location,
"legacy_payload_size": len(payload),
"legacy_magic": payload[:4].hex(),
"legacy_sha256": hashlib.sha256(data).hexdigest(),
"profile": profile["name"] if profile else None,
"supported": profile is not None,
"strings": {},
}
)
if app_id == "NPXS40008":
route = infer_settings_route(payload)
result["settings_route"] = route
result["profile_route"] = profile.get("route") if profile else None
result["route_matches_profile"] = bool(
profile and profile.get("route") == route
)
for needle in TRACKED_STRINGS:
offsets = find_all(payload, needle)
result["strings"][needle.decode("ascii")] = {
"count": len(offsets),
"first_offsets": [f"0x{offset:x}" for offset in offsets[:8]],
}
return result
if app_id == "NPXS40002":
plain_js_offset, plain_js_location = locate_plain_js_bundle(data)
if plain_js_offset is not None:
payload = data[plain_js_offset:]
profile = match_plain_js_profile(data, plain_js_offset)
result.update(
{
"plain_js_offset": plain_js_offset,
"plain_js_location": plain_js_location,
"plain_js_payload_size": len(payload),
"plain_js_sha256": hashlib.sha256(data).hexdigest(),
"profile": profile["name"] if profile else None,
"supported": profile is not None,
"strings": {},
}
)
for needle in TRACKED_STRINGS:
offsets = find_all(payload, needle)
result["strings"][needle.decode("ascii")] = {
"count": len(offsets),
"first_offsets": [f"0x{offset:x}" for offset in offsets[:8]],
}
return result
if hbc_offset is None:
result["supported"] = False
result["error"] = "HBC magic not found"
return result
hbc = data[hbc_offset:]
hbc_version = read_u32le(hbc, HBC_VERSION_OFFSET)
file_length = read_u32le(hbc, HBC_FILE_LENGTH_OFFSET)
source_hash = hbc[
HBC_SOURCE_HASH_OFFSET : HBC_SOURCE_HASH_OFFSET + HBC_SOURCE_HASH_SIZE
].hex()
profile_table = (
KNOWN_HOMEUI_PROFILES if app_id == "NPXS40002" else KNOWN_SETTINGS_PROFILES
)
profile = match_profile(profile_table, hbc_version, file_length, source_hash)
result.update(
{
"hbc_version": hbc_version,
"hbc_file_length": file_length,
"hbc_file_length_hex": f"0x{file_length:x}" if file_length else None,
"source_hash": source_hash,
"profile": profile["name"] if profile else None,
"supported": profile is not None,
"strings": {},
}
)
strings: dict[str, Any] = {}
for needle in TRACKED_STRINGS:
offsets = find_all(hbc, needle)
strings[needle.decode("ascii")] = {
"count": len(offsets),
"first_offsets": [f"0x{offset:x}" for offset in offsets[:8]],
}
result["strings"] = strings
if app_id == "NPXS40008":
route = infer_settings_route(hbc)
result["settings_route"] = route
result["profile_route"] = profile.get("route") if profile else None
result["route_matches_profile"] = bool(
profile and profile.get("route") == route
)
return result
def analyze_dump(dump_dir: Path) -> dict[str, Any]:
apps: list[dict[str, Any]] = []
for app_id in ("NPXS40002", "NPXS40008"):
path = dump_dir / f"{app_id}.bin"
if path.exists():
apps.append(analyze_file(path, app_id))
return {
"dump_dir": str(dump_dir),
"apps": apps,
}
def validation_errors(report: dict[str, Any]) -> list[str]:
errors: list[str] = []
apps = report["apps"]
if not apps:
errors.append("no NPXS40002.bin or NPXS40008.bin found")
return errors
for app in apps:
if not app.get("supported"):
errors.append(f"{app['app_id']} has no matching known profile")
continue
if app["app_id"] == "NPXS40008" and not app.get("route_matches_profile"):
errors.append(
f"{app['app_id']} route {app.get('settings_route')} does not "
f"match profile route {app.get('profile_route')}"
)
return errors
def print_text(report: dict[str, Any], errors: list[str]) -> None:
print(f"Dump: {report['dump_dir']}")
for app in report["apps"]:
print(f"\n{app['app_id']}: {app['path']}")
print(f" file_size: 0x{app['size']:x} ({app['size']})")
if app.get("legacy_offset") is not None:
print(
" legacy RNPS bundle: "
f"offset=0x{app['legacy_offset']:x} "
f"location={app['legacy_location']} "
f"magic={app['legacy_magic']} "
f"payload_size=0x{app['legacy_payload_size']:x} "
f"sha256={app['legacy_sha256']}"
)
print(
" profile: "
f"{app['profile'] if app['profile'] else 'unsupported'} "
f"(supported={'yes' if app['supported'] else 'no'})"
)
if app["app_id"] == "NPXS40008":
print(
" settings_route: "
f"{app.get('settings_route')} "
f"(profile={app.get('profile_route')})"
)
print(" strings:")
for name, detail in app["strings"].items():
if detail["count"] == 0:
continue
offsets = ", ".join(detail["first_offsets"])
print(f" {name}: count={detail['count']} first={offsets}")
continue
if app.get("plain_js_offset") is not None:
print(
" plain-JS RNPS bundle: "
f"offset=0x{app['plain_js_offset']:x} "
f"location={app['plain_js_location']} "
f"payload_size=0x{app['plain_js_payload_size']:x} "
f"sha256={app['plain_js_sha256']}"
)
print(
" profile: "
f"{app['profile'] if app['profile'] else 'unsupported'} "
f"(supported={'yes' if app['supported'] else 'no'})"
)
print(" strings:")
for name, detail in app["strings"].items():
if detail["count"] == 0:
continue
offsets = ", ".join(detail["first_offsets"])
print(f" {name}: count={detail['count']} first={offsets}")
continue
if app.get("hbc_offset") is None:
print(f" hbc: missing ({app.get('error')})")
continue
print(
" hbc: "
f"offset=0x{app['hbc_offset']:x} "
f"location={app['hbc_location']} "
f"version={app['hbc_version']} "
f"file_length={app['hbc_file_length_hex']} "
f"source_hash={app['source_hash']}"
)
print(
" profile: "
f"{app['profile'] if app['profile'] else 'unsupported'} "
f"(supported={'yes' if app['supported'] else 'no'})"
)
if app["app_id"] == "NPXS40008":
print(
" settings_route: "
f"{app.get('settings_route')} "
f"(profile={app.get('profile_route')})"
)
print(" strings:")
for name, detail in app["strings"].items():
if detail["count"] == 0:
continue
offsets = ", ".join(detail["first_offsets"])
print(f" {name}: count={detail['count']} first={offsets}")
if errors:
print("\nValidation: FAIL")
for error in errors:
print(f" - {error}")
else:
print("\nValidation: PASS")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description="Extract RNPS/HBC fingerprints and validate known profiles."
)
parser.add_argument("dump_dir", type=Path, help="Directory containing NPXS*.bin")
parser.add_argument("--json", action="store_true", help="Emit machine JSON")
parser.add_argument(
"--allow-unsupported",
action="store_true",
help="Return success even when a profile is unknown",
)
args = parser.parse_args(argv)
if not args.dump_dir.is_dir():
print(f"error: not a directory: {args.dump_dir}", file=sys.stderr)
return 2
report = analyze_dump(args.dump_dir)
errors = validation_errors(report)
if args.json:
print(json.dumps({**report, "validation_errors": errors}, indent=2))
else:
print_text(report, errors)
if errors and not args.allow_unsupported:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))