diff --git a/README.md b/README.md index ad8ebd2..6e5d87a 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,11 @@ actually worked, then giving them new data without replacing their identity. - The guide covers all 54 Japanese broadcast areas and 376 terrestrial services. - Area-coded requests receive compact regional payloads; Gunma currently has - 11 stations and 397 programs. + 11 stations and 3,475 programs across the channel's full eight-day window. - Current titles, times, genres, and Japanese program descriptions are packed into their native TV no Tomo records. +- The original genre-search table is populated with 12 Japanese categories, + and the date carousel receives eight consecutive broadcast days. - The original activation, query, popularity, and synchronization CGI calls receive the response contracts expected by the channel. - A daily job collects, validates, packs, independently checks, and atomically @@ -89,15 +91,16 @@ latest build validated: - 54 broadcast areas - 376 stations -- 14,871 programs -- 12,897 program descriptions +- 123,803 programs across eight broadcast days +- 104,665 program descriptions +- 12 native genre-search categories - every header-to-EPG station key - every EPG-to-string record index - every regional native payload -The next work is focused on in-app polish, multi-day guide rollover, -popularity synchronization, easier local server setup, Wii Mail, and adapters -for more Japanese WC24 channels. +The next work is focused on visual testing of the completed genre and date +flows, popularity synchronization, easier local server setup, Wii Mail, and +adapters for more Japanese WC24 channels. ## Repository layout @@ -118,10 +121,10 @@ build is: py -3 tools\update_hbnj_daily.py ``` -That command collects all regions into private staging, validates the complete -guide, creates native HDPK payloads, independently parses them, and publishes -only after every check passes. A failed build leaves the previous live guide -untouched. +That command collects eight days for all regions into private staging, +validates the complete guide and native size ceilings, creates HDPK payloads, +independently parses them, and publishes only after every check passes. A +failed build leaves the previous live guide untouched. The shared command-line tools can audit WC24 state, inspect the local account, validate channel manifests, provision tasks, and run the replacement server: diff --git a/channels/hbnj/README.md b/channels/hbnj/README.md index 6639647..b997601 100644 --- a/channels/hbnj/README.md +++ b/channels/hbnj/README.md @@ -4,10 +4,13 @@ This adapter targets the untouched Japanese v512 TV no Tomo WAD with title ID `0001000148424e4a` (`HBNJ`). On first-run, HBNJ natively creates a 4 MiB `wc24dl.vff`, `wc24pubk.mod`, and a -`header.bin` download task in slot 10. The adapter adopts that native slot and -redirects it to the replacement service. EPG and string payload fixtures remain -available to the adapter, but they are not provisioned as speculative tasks; -their native registration sequence must be observed first. +`header.bin` download task in slot 10. After setup, the channel replaces that +bootstrap entry with native `epg.bin` and `str.bin` tasks in slots 10 and 11. +The two manifests model those observed phases separately, and the adapter +adopts the channel-created records instead of inventing speculative tasks. +The active adapter adds a JWC24-managed daily `header.bin` refresh in free slot +12. This keeps station, area, and genre metadata current after first-run setup +without replacing either native guide task. `prune_duplicate_tasks` removes only same-title, same-filename duplicates when the manifest adopts a task at a different canonical slot. This migrates the @@ -29,18 +32,23 @@ served. schema audit, native packing, and independent binary audit in a private staging directory. It atomically publishes to `generated/current` only after every stage succeeds, so a failed upstream collection leaves the previous guide live. -It also builds and validates one EPG per native area ID under +It collects the channel's full eight-day date window, builds and validates one +EPG/string pair per native area ID under `generated/current/areas/`. Requests such as `/1016/epg.bin` resolve to the -matching regional payload, with the national package retained as a fallback. +matching regional payload. Unknown numeric area IDs fail closed instead of +receiving the oversized national package. Each regional pair is checked against +the Nintendo LZ10 24-bit limit and the channel's 4 MiB VFF capacity before +publication. The collector also maps Bangumi's CSS genre categories to TV no Tomo's -one-based ARIB genre IDs. It preserves program descriptions in the private -guide JSON and packs them into the first text pointer of each native `str.bin` -record. Every EPG detail record carries a validated one-based index into that -table; the optional second text pointer remains empty. - -The task layout is based on confirmed reverse engineering from the retired -workspace. It is not considered production-compatible until a clean WAD launch -requests these slots and imports all three payloads without a scene bypass. +one-based ARIB genre IDs. The native header contains the 12 Japanese labels +used by the original genre-search screen. Program descriptions are preserved +in the private guide JSON and packed into the first text pointer of each native +`str.bin` record. Every EPG detail record carries a validated one-based index +into that table; the optional second text pointer remains empty. + +The task layout and binary structures are based on confirmed reverse +engineering and clean-WAD observation. A clean v512 WAD has imported all three +payload types through the original WC24 path without a scene bypass. Channel-specific CGI endpoints (`activate.cgi`, `query.cgi`, `popularity.cgi`, and `/bin*`) are served by the adapter with the native `X-RESULT` success diff --git a/channels/hbnj/channel.json b/channels/hbnj/channel.json index 9916b5c..ab0f8f9 100644 --- a/channels/hbnj/channel.json +++ b/channels/hbnj/channel.json @@ -30,14 +30,19 @@ "mode": "adopt", "compression": "nintendo-lz10", "envelope": "wc24-aes-ofb" - } - ], - "assets": [ + }, { + "slot": 12, "filename": "header.bin", + "route": "/header.bin", "payload": "generated/current/header.hdpk", + "refresh_minutes": 1440, + "retry_minutes": 5, + "unsigned": false, + "mode": "create", "compression": "nintendo-lz10", "envelope": "wc24-aes-ofb" } - ] + ], + "assets": [] } diff --git a/jwc24/server.py b/jwc24/server.py index f0ffdf5..f5b835e 100644 --- a/jwc24/server.py +++ b/jwc24/server.py @@ -138,8 +138,13 @@ def do_GET(self) -> None: # noqa: N802 / route_parts[0] / item.payload.name ) - if area_payload.is_file(): - payload_path = area_payload + if not area_payload.is_file(): + # Never fall back to the national package for an + # unknown numeric area. Its station count exceeds the + # channel's 24-station native model capacity. + self.send_error(HTTPStatus.NOT_FOUND) + return + payload_path = area_payload body = payload_path.read_bytes() if item.compression == "nintendo-lz10": body = _nintendo_lz10_literal(body) diff --git a/tools/collect_hbnj_all.py b/tools/collect_hbnj_all.py index 02e0419..2d85e1e 100644 --- a/tools/collect_hbnj_all.py +++ b/tools/collect_hbnj_all.py @@ -4,17 +4,78 @@ import re import sys import time -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from collect_hbnj_region import atomic_json, fetch, parse_region from jwc24.hbnj_regions import PREFECTURES, broadcast_area_count +def merge_duplicate_program( + previous: dict[str, object], + current: dict[str, object], +) -> dict[str, object]: + comparable_previous = { + key: value for key, value in previous.items() if key != "source_program_id" + } + comparable_current = { + key: value for key, value in current.items() if key != "source_program_id" + } + if comparable_previous != comparable_current: + raise ValueError(f"program {current['id']} changed across broadcast pages") + # Bangumi sometimes exposes the real program ID on one side of the 05:00 + # broadcast-day boundary and the placeholder -1 on the other. + if previous.get("source_program_id") == "-1" and current.get("source_program_id") != "-1": + return current + return previous + + +def collect_with_retry( + *, + group_id: int, + broadcast_date: str, + area_id: int, + area_name: str, + prefecture_raw: int, + retries: int, + retry_delay: float, +) -> tuple[str, dict[str, object]]: + for attempt in range(1, retries + 1): + try: + source_url, source = fetch(group_id, broadcast_date) + return source_url, parse_region( + source, + group_id=group_id, + area_id=area_id, + area_name=area_name, + prefecture_raw=prefecture_raw, + source_url=source_url, + ) + except Exception as error: + if attempt == retries: + raise + wait = retry_delay * (2 ** (attempt - 1)) + print( + f" attempt {attempt}/{retries} failed: " + f"{type(error).__name__}: {error}; retrying in {wait:g}s", + file=sys.stderr, + flush=True, + ) + if wait: + time.sleep(wait) + raise AssertionError("retry loop ended without a result") + + def main() -> int: parser = argparse.ArgumentParser(description="Collect all 54 HBNJ broadcast areas strictly.") parser.add_argument("--date", required=True, help="Broadcast date in YYYYMMDD form") parser.add_argument("--out", type=Path, required=True) + parser.add_argument( + "--days", + type=int, + default=8, + help="Consecutive broadcast days to collect (TV no Tomo displays eight)", + ) parser.add_argument("--delay", type=float, default=0.5, help="Delay between region requests") parser.add_argument( "--retries", @@ -33,6 +94,8 @@ def main() -> int: raise SystemExit("--date must use YYYYMMDD") if args.delay < 0: raise SystemExit("--delay cannot be negative") + if not 1 <= args.days <= 8: + raise SystemExit("--days must be between 1 and 8") if args.retries < 1: raise SystemExit("--retries must be at least 1") if args.retry_delay < 0: @@ -42,55 +105,97 @@ def main() -> int: channels: list[dict[str, object]] = [] programs: list[dict[str, object]] = [] sources: list[dict[str, object]] = [] + first_date = datetime.strptime(args.date, "%Y%m%d") + broadcast_dates = [ + (first_date + timedelta(days=offset)).strftime("%Y%m%d") + for offset in range(args.days) + ] area_id = 1001 for prefecture_raw, prefecture_name, regions in PREFECTURES: for group_id, region_name in regions: area_name = region_name if len(regions) > 1 else prefecture_name - print( - f"[{area_id - 1000:02d}/{broadcast_area_count()}] " - f"group={group_id} area={area_name}", - flush=True, - ) - for attempt in range(1, args.retries + 1): - try: - source_url, source = fetch(group_id, args.date) - region = parse_region( - source, - group_id=group_id, - area_id=area_id, - area_name=area_name, - prefecture_raw=prefecture_raw, - source_url=source_url, + area: dict[str, object] | None = None + canonical_channels: list[dict[str, object]] | None = None + canonical_id_by_service: dict[str, int] = {} + area_programs: dict[int, dict[str, object]] = {} + source_urls: list[str] = [] + daily_program_counts: list[int] = [] + for day_number, broadcast_date in enumerate(broadcast_dates, start=1): + print( + f"[{area_id - 1000:02d}/{broadcast_area_count()} " + f"day {day_number}/{args.days}] group={group_id} " + f"date={broadcast_date} area={area_name}", + flush=True, + ) + source_url, region = collect_with_retry( + group_id=group_id, + broadcast_date=broadcast_date, + area_id=area_id, + area_name=area_name, + prefecture_raw=prefecture_raw, + retries=args.retries, + retry_delay=args.retry_delay, + ) + region_channels = list(region["channels"]) + service_by_region_channel = { + int(channel["id"]): str(channel["service_id"]) + for channel in region_channels + } + if canonical_channels is None: + area = dict(region["area"]) + canonical_channels = region_channels + canonical_id_by_service = { + str(channel["service_id"]): int(channel["id"]) + for channel in canonical_channels + } + elif set(service_by_region_channel.values()) != set(canonical_id_by_service): + raise ValueError( + f"area {area_id} service lineup changed on {broadcast_date}" ) - break - except Exception as error: - if attempt == args.retries: - raise - wait = args.retry_delay * (2 ** (attempt - 1)) - print( - f" attempt {attempt}/{args.retries} failed: " - f"{type(error).__name__}: {error}; retrying in {wait:g}s", - file=sys.stderr, - flush=True, + + for program in region["programs"]: + normalized = dict(program) + service_id = service_by_region_channel[int(program["channel_id"])] + normalized["channel_id"] = canonical_id_by_service[service_id] + program_id = int(normalized["id"]) + previous = area_programs.get(program_id) + area_programs[program_id] = ( + normalized + if previous is None + else merge_duplicate_program(previous, normalized) ) - if wait: - time.sleep(wait) - area = dict(region["area"]) + source_urls.append(source_url) + daily_program_counts.append(len(region["programs"])) + if args.delay and not ( + area_id == 1000 + broadcast_area_count() + and day_number == args.days + ): + time.sleep(args.delay) + + assert area is not None and canonical_channels is not None + merged_programs = sorted( + area_programs.values(), + key=lambda program: ( + int(program["channel_id"]), + str(program["start"]), + int(program["id"]), + ), + ) areas.append(area) - channels.extend(region["channels"]) - programs.extend(region["programs"]) + channels.extend(canonical_channels) + programs.extend(merged_programs) sources.append( { "area_id": area_id, "group_id": group_id, - "source_url": source_url, - "channels": len(region["channels"]), - "programs": len(region["programs"]), + "source_urls": source_urls, + "broadcast_dates": broadcast_dates, + "daily_program_counts": daily_program_counts, + "channels": len(canonical_channels), + "programs": len(merged_programs), } ) area_id += 1 - if args.delay: - time.sleep(args.delay) channel_ids = [int(channel["id"]) for channel in channels] program_ids = [int(program["id"]) for program in programs] @@ -106,6 +211,8 @@ def main() -> int: "format": "jwc24_hbnj_guide_v1", "source": "bangumi.org", "broadcast_date": args.date, + "broadcast_end_date": broadcast_dates[-1], + "days": args.days, "collected_at": datetime.now(timezone.utc).isoformat(), "areas": areas, "channels": channels, diff --git a/tools/pack_hbnj_guide.py b/tools/pack_hbnj_guide.py index a9eaea0..7d9dc07 100644 --- a/tools/pack_hbnj_guide.py +++ b/tools/pack_hbnj_guide.py @@ -11,6 +11,20 @@ ROOT_NAME = b"main\0" WII_EPOCH = datetime(2000, 1, 1) TERRESTRIAL_DIGITAL = 9 +GENRE_NAMES = ( + "ニュース/報道", + "スポーツ", + "情報/ワイドショー", + "ドラマ", + "音楽", + "バラエティ", + "映画", + "アニメ/特撮", + "ドキュメンタリー/教養", + "劇場/公演", + "趣味/教育", + "福祉", +) def align(value: int, amount: int = 4) -> int: @@ -100,6 +114,8 @@ def make_header(document: dict, channel_by_id: dict[int, dict], keys: dict[int, member_tables.append(cursor) cursor += len(area["channel_ids"]) * 0x0C + genre_table = cursor + cursor += len(GENRE_NAMES) * 8 station_names = [] for channel in channels: encoded = cstr(str(channel["name"])) @@ -112,6 +128,11 @@ def make_header(document: dict, channel_by_id: dict[int, dict], keys: dict[int, encoded = cstr(str(area["name"])) area_names.append((cursor, encoded)) cursor += len(encoded) + genre_names = [] + for name in GENRE_NAMES: + encoded = cstr(name) + genre_names.append((cursor, encoded)) + cursor += len(encoded) data = bytearray(cursor) relocs: set[int] = set() @@ -144,7 +165,18 @@ def make_header(document: dict, channel_by_id: dict[int, dict], keys: dict[int, put_u16(data, member + 6, member_index + 1) put_u32(data, member + 8, 1) - for offset, encoded in station_names + area_names: + # Genre records are ordered exactly like the one-based genre IDs stored in + # EPG details. Their main positions are zero-based; this broad-category + # table has one sub-entry (position zero) under each main category. + put_u32(data, 0x44, len(GENRE_NAMES)) + put_u32(data, 0x48, genre_table, relocs) + for index, (text_offset, _) in enumerate(genre_names): + entry = genre_table + index * 8 + data[entry] = index + data[entry + 1] = 0 + put_u32(data, entry + 4, text_offset, relocs) + + for offset, encoded in station_names + area_names + genre_names: data[offset:offset + len(encoded)] = encoded data[station_aux:station_aux + len(cstr("station"))] = cstr("station") return make_hdpk(data, relocs) diff --git a/tools/run_hbnj_daily.ps1 b/tools/run_hbnj_daily.ps1 index 6da0086..d3264b6 100644 --- a/tools/run_hbnj_daily.ps1 +++ b/tools/run_hbnj_daily.ps1 @@ -7,6 +7,9 @@ $log = Join-Path $logDirectory "hbnj-update-$stamp.log" Push-Location $workspace try { + $utf8 = New-Object System.Text.UTF8Encoding($false) + [Console]::OutputEncoding = $utf8 + $OutputEncoding = $utf8 $env:PYTHONIOENCODING = "utf-8" # Windows PowerShell promotes native stderr to an ErrorRecord. This Python # installation emits a harmless prefix warning on stderr, so temporarily diff --git a/tools/update_hbnj_daily.py b/tools/update_hbnj_daily.py index 79443b7..d812b81 100644 --- a/tools/update_hbnj_daily.py +++ b/tools/update_hbnj_daily.py @@ -55,7 +55,10 @@ def main() -> int: "--date", help="Broadcast date in YYYYMMDD form (default: current date in Japan)", ) - parser.add_argument("--delay", type=float, default=0.5) + # Eight days across 54 areas is 432 requests. A short courtesy delay keeps + # the scheduled build comfortably inside its 20-minute execution window. + parser.add_argument("--delay", type=float, default=0.1) + parser.add_argument("--days", type=int, default=8) parser.add_argument("--retries", type=int, default=3) parser.add_argument("--retry-delay", type=float, default=1.0) args = parser.parse_args() @@ -78,6 +81,8 @@ def main() -> int: str(guide), "--delay", str(args.delay), + "--days", + str(args.days), "--retries", str(args.retries), "--retry-delay", diff --git a/tools/validate_hbnj_area_payloads.py b/tools/validate_hbnj_area_payloads.py index 39b4aa2..ca651d1 100644 --- a/tools/validate_hbnj_area_payloads.py +++ b/tools/validate_hbnj_area_payloads.py @@ -6,6 +6,15 @@ from validate_hbnj_payloads import parse_hdpk, read_text, u32 +LZ10_MAX_INPUT = 0xFFFFFF +VFF_CAPACITY = 4 * 1024 * 1024 + + +def literal_lz10_size(raw_size: int) -> int: + if not 0 < raw_size <= LZ10_MAX_INPUT: + raise ValueError(f"payload cannot be represented by Nintendo LZ10: {raw_size} bytes") + return 4 + raw_size + (raw_size + 7) // 8 + def main() -> int: parser = argparse.ArgumentParser(description="Validate all area-specific native HBNJ EPGs.") @@ -13,6 +22,8 @@ def main() -> int: parser.add_argument("area_dir", type=Path) args = parser.parse_args() guide = json.loads(args.guide.read_text(encoding="utf-8")) + header_path = args.area_dir.parent / "header.hdpk" + header_size = header_path.stat().st_size channel_order = [int(channel["id"]) for channel in guide["channels"]] key_by_channel = { channel_id: (9 << 16) | index @@ -77,12 +88,22 @@ def main() -> int: raise ValueError(f"area {area_id}: string record count differs from programs") if string_positions != set(range(1, string_count + 1)): raise ValueError(f"area {area_id}: incomplete string-table positions") - total_bytes += epg_path.stat().st_size + epg_size = epg_path.stat().st_size + string_size = string_path.stat().st_size + wc24_download_bytes = literal_lz10_size(epg_size) + literal_lz10_size(string_size) + vff_payload_bytes = header_size + wc24_download_bytes + if vff_payload_bytes >= VFF_CAPACITY: + raise ValueError( + f"area {area_id}: native payloads exceed the 4 MiB VFF capacity " + f"before filesystem overhead ({vff_payload_bytes} bytes)" + ) + total_bytes += epg_size + string_size print( f"area {area_id}: valid stations={station_count} " - f"programs={len(actual_program_ids)} bytes={epg_path.stat().st_size}" + f"programs={len(actual_program_ids)} raw={epg_size + string_size} " + f"wc24={wc24_download_bytes} vff_payload={vff_payload_bytes}" ) - print(f"valid area payloads={len(guide['areas'])} total_epg_bytes={total_bytes}") + print(f"valid area payloads={len(guide['areas'])} total_raw_bytes={total_bytes}") return 0 diff --git a/tools/validate_hbnj_guide.py b/tools/validate_hbnj_guide.py index 6fc751b..4edb7a0 100644 --- a/tools/validate_hbnj_guide.py +++ b/tools/validate_hbnj_guide.py @@ -4,7 +4,7 @@ import hashlib import json from collections import Counter, defaultdict -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path @@ -23,6 +23,20 @@ def main() -> int: fail("unexpected guide format") if document.get("status") != "ok": fail("guide status is not ok") + days = document.get("days", 1) + if not isinstance(days, int) or not 1 <= days <= 8: + fail(f"guide days must be between 1 and 8, got {days!r}") + try: + first_broadcast_date = datetime.strptime(document["broadcast_date"], "%Y%m%d") + except (KeyError, TypeError, ValueError) as error: + fail(f"invalid broadcast_date: {error}") + broadcast_dates = { + (first_broadcast_date + timedelta(days=offset)).strftime("%Y%m%d") + for offset in range(days) + } + expected_end_date = (first_broadcast_date + timedelta(days=days - 1)).strftime("%Y%m%d") + if document.get("broadcast_end_date", document["broadcast_date"]) != expected_end_date: + fail("broadcast_end_date does not match the requested guide window") areas = document.get("areas") channels = document.get("channels") @@ -58,6 +72,7 @@ def main() -> int: fail("area channel lists do not partition the channel table exactly") programs_by_channel: dict[int, list[tuple[datetime, datetime]]] = defaultdict(list) + dates_by_channel: dict[int, set[str]] = defaultdict(set) cross_midnight = 0 genre_counts: Counter[int] = Counter() descriptions = 0 @@ -88,11 +103,20 @@ def main() -> int: ) descriptions += bool(description) programs_by_channel[channel_id].append((start, end)) + dates_by_channel[channel_id].add(start.strftime("%Y%m%d")) empty_channels = set(channel_ids) - programs_by_channel.keys() if empty_channels: fail(f"channels without programs: {sorted(empty_channels)[:5]}") for channel_id, windows in programs_by_channel.items(): + missing_dates = broadcast_dates - dates_by_channel[channel_id] + if missing_dates: + fail( + f"channel {channel_id} has no program starts on broadcast dates " + f"{sorted(missing_dates)}" + ) + if len(windows) > 768: + fail(f"channel {channel_id} exceeds native 768-program capacity") windows.sort() for previous, current in zip(windows, windows[1:]): if current[0] < previous[1]: @@ -114,13 +138,22 @@ def main() -> int: source_program_total = sum(source["programs"] for source in sources) if source_channel_total != len(channels) or source_program_total != len(programs): fail("source totals do not match the aggregate tables") + if days > 1: + expected_dates = sorted(broadcast_dates) + for source in sources: + if source.get("broadcast_dates") != expected_dates: + fail(f"source {source.get('area_id')} has an incomplete date window") + if len(source.get("source_urls", [])) != days: + fail(f"source {source.get('area_id')} has incomplete source URLs") + if len(source.get("daily_program_counts", [])) != days: + fail(f"source {source.get('area_id')} has incomplete daily counts") duplicate_names = sum( count - 1 for count in Counter(channel["name"] for channel in channels).values() if count > 1 ) print( - f"valid: areas={len(areas)} channels={len(channels)} " + f"valid: days={days} areas={len(areas)} channels={len(channels)} " f"programs={len(programs)} cross_midnight={cross_midnight} " f"repeated_names_across_areas={duplicate_names} " f"descriptions={descriptions} genres={dict(sorted(genre_counts.items()))}" diff --git a/tools/validate_hbnj_payloads.py b/tools/validate_hbnj_payloads.py index f226390..f3fb32b 100644 --- a/tools/validate_hbnj_payloads.py +++ b/tools/validate_hbnj_payloads.py @@ -5,6 +5,21 @@ import struct from pathlib import Path +EXPECTED_GENRE_NAMES = ( + "ニュース/報道", + "スポーツ", + "情報/ワイドショー", + "ドラマ", + "音楽", + "バラエティ", + "映画", + "アニメ/特撮", + "ドキュメンタリー/教養", + "劇場/公演", + "趣味/教育", + "福祉", +) + def u16(data: bytes | bytearray, offset: int) -> int: return struct.unpack_from(">H", data, offset)[0] @@ -102,6 +117,17 @@ def main() -> int: if area_count != len(guide["areas"]) or memberships != expected_memberships: raise ValueError("area table does not match guide") + genre_count = u32(header, 0x44) + genre_table = u32(header, 0x48) + if genre_count != len(EXPECTED_GENRE_NAMES) or not genre_table: + raise ValueError("header genre table count/pointer is invalid") + for index, expected_name in enumerate(EXPECTED_GENRE_NAMES): + entry = genre_table + index * 8 + if header[entry] != index or header[entry + 1] != 0: + raise ValueError("header genre positions are invalid") + if read_text(header, u32(header, entry + 4)) != expected_name: + raise ValueError("header genre label differs from canonical table") + epg_station_count = u32(epg, 0x1C) epg_station_table = u32(epg, 0x20) epg_keys = [] @@ -146,7 +172,7 @@ def main() -> int: print( f"valid native HBNJ payloads: stations={station_count} areas={area_count} " - f"memberships={memberships} programs={len(program_ids)} " + f"memberships={memberships} genres={genre_count} programs={len(program_ids)} " f"range={u32(epg, 0x10)}..{u32(epg, 0x14)} root=main status=1" ) return 0