#!/usr/bin/env python3 """Inspect installed SDK topology without copying game data into the source tree. Layouts come from Sunrise/src/state/activity_sdk/format.h (version 37). The JSON report is local generated evidence, not a distributable mission asset. """ import argparse import collections import hashlib import json import mmap from pathlib import Path import struct SECTIONS = { "strings": (0, 1), "scenarios": (2, 48), "bubbles": (3, 40), "states": (4, 64), "objects": (5, 52), "occurrences": (6, 56), "slots": (7, 80), "actors": (12, 68), "squads": (16, 52), "members": (17, 44), } SQUAD_FLAGS = { 1: "source_descriptor_exact", 2: "spawner_rule_edge_exact", 4: "scenario_occurrence_exact", 8: "all_points_exact", 16: "member_count_valid", 32: "candidate_counts_invariant_complete", } class Pack: def __init__(self, path): with open(path, "rb") as source: self.data = mmap.mmap(source.fileno(), 0, access=mmap.ACCESS_READ) try: self.validate() except Exception: self.data.close() raise def validate(self): data = self.data if len(data) < 848 or data[:8] != b"SRSDKP01": raise ValueError("not a runtime activity SDK pack") version, header, size = struct.unpack_from(" size): raise ValueError("SDK section exceeds file bounds") for name, (index, expected) in SECTIONS.items(): if self.sections[index][2] != expected: raise ValueError(f"unexpected {name} stride") self.build_id = "sha256:" + data[56:88].hex() def close(self): self.data.close() def rows(self, name): index, _ = SECTIONS[name] offset, count, stride = self.sections[index] if stride % 4: raise ValueError("section is not composed of u32 fields") return [struct.unpack_from("<" + "I" * (stride // 4), self.data, offset + i * stride) for i in range(count)] def text(self, row, index=0): start, length = row[index:index + 2] bank, count, _ = self.sections[0] if start + length > count: raise ValueError("string exceeds SDK string bank") return self.data[bank + start:bank + start + length].decode("utf-8") def inspect(self, tag): scenarios = self.rows("scenarios") matches = [i for i, row in enumerate(scenarios) if row[0] == tag] if len(matches) != 1: raise ValueError("scenario tag must resolve exactly once") scenario_index = matches[0] bubbles, states = self.rows("bubbles"), self.rows("states") objects, slots = self.rows("objects"), self.rows("slots") occurrences = [row for row in self.rows("occurrences") if row[8] == scenario_index] squad_rows = [row for row in self.rows("squads") if row[2] == scenario_index] members, actors = self.rows("members"), self.rows("actors") squads_by_slot = collections.defaultdict(list) for row in squad_rows: selected = members[row[9]:row[9] + row[10]] definition_ready = row[7] & 63 == 63 and 1 <= row[10] <= 15 profiles = set() actors_exact = len(selected) == row[10] for member in selected: if member[10] >= 0x80000000: actors_exact = False elif member[10] > 0: if not member[6] & 1 or member[5] >= len(actors): actors_exact = False else: profiles.add(actors[member[5]][-1]) profile_ready = actors_exact and len(profiles) == 1 squads_by_slot[row[4]].append({ "id": self.text(row), "flags": row[7], "member_count": row[10], "anchor_count": row[12], "occurrence_index": row[8], "spawner_tag": f"{row[5]:08x}", "rule_tag": f"{row[6]:08x}", "definition_ready": definition_ready, "spawn_profile_ready": profile_ready, "runnable": definition_ready and profile_ready, "missing_flags": [name for bit, name in SQUAD_FLAGS.items() if not row[7] & bit], }) mission_objects = {row[11] for row in occurrences} mission_slots = {i: row for i, row in enumerate(slots) if row[8] in mission_objects} state_reports = [] for index, row in enumerate(states): if row[6] != scenario_index: continue owned = {occ[11] for occ in occurrences if occ[10] == index} entries = [] for slot_index, slot in mission_slots.items(): if slot[8] not in owned: continue entries.append({"id": self.text(slot), "name": self.text(slot, 2), "type": slot[10], "object_tag": f"{objects[slot[8]][2]:08x}", "squads": squads_by_slot.get(slot_index, [])}) state_reports.append({ "id": self.text(row), "bubble": self.text(bubbles[row[7]], 2), "region_index": row[10] + row[8], "map_bubble_index": row[11], "registry_tag": f"{row[15]:08x}", "slots": entries, }) squad_slots = {i: row for i, row in mission_slots.items() if row[10] == 1} missing = [self.text(row) for i, row in squad_slots.items() if i not in squads_by_slot] runnable = sum(q["runnable"] for group in squads_by_slot.values() for q in group) return { "scenario": f"{tag:08x}", "name": self.text(scenarios[scenario_index], 4), "sdk_build_id": self.build_id, "counts": {"states": len(state_reports), "slots": len(mission_slots), "squad_sensors": len(squad_slots), "squad_definitions": len(squad_rows), "runnable_squads": runnable, "sensors_without_squad_definition": len(missing)}, "readiness_scope": "static definition and default actor-profile gates; live leases, placement transport and AI require an in-game test", "sensors_without_squad_definition": missing, "states": state_reports, } def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("pack", type=Path, help="installed Sunrise/activity_sdk.pack") parser.add_argument("--scenario", type=lambda value: int(value, 16), default=0x80B3C09E) parser.add_argument("--output", type=Path, required=True, help="local generated JSON path") parser.add_argument("--shard", type=Path, help="matching generated-world scenario pack") args = parser.parse_args() pack = Pack(args.pack) try: report = pack.inspect(args.scenario) finally: pack.close() if args.shard: report["world_shard"] = inspect_shard(args.shard, args.scenario) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, indent=2) + "\n") print(json.dumps(report["counts"], indent=2)) def inspect_shard(path, scenario): """Check v13 shard identity and report whether authored squad contexts were exported.""" data = Path(path).read_bytes() if len(data) < 664 or data[:8] != b"SRGWSHRD": raise ValueError("not a generated-world scenario shard") version, header, size, tag, count = struct.unpack_from(" size): raise ValueError("scenario shard section exceeds file bounds") names = ("config_contexts", "placement_contexts", "point_contexts", "point_placement_matches", "edge_contexts") return {"source_fingerprint": data[40:72].hex(), "authored_squad_context_counts": {name: sections[30 + i][1] for i, name in enumerate(names)}} if __name__ == "__main__": main()