inspect_mission_sdk.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. #!/usr/bin/env python3
  2. """Inspect installed SDK topology without copying game data into the source tree.
  3. Layouts come from Sunrise/src/state/activity_sdk/format.h (version 37).
  4. The JSON report is local generated evidence, not a distributable mission asset.
  5. """
  6. import argparse
  7. import collections
  8. import hashlib
  9. import json
  10. import mmap
  11. from pathlib import Path
  12. import struct
  13. SECTIONS = {
  14. "strings": (0, 1), "scenarios": (2, 48), "bubbles": (3, 40),
  15. "states": (4, 64), "objects": (5, 52), "occurrences": (6, 56),
  16. "slots": (7, 80), "actors": (12, 68), "squads": (16, 52), "members": (17, 44),
  17. }
  18. SQUAD_FLAGS = {
  19. 1: "source_descriptor_exact", 2: "spawner_rule_edge_exact",
  20. 4: "scenario_occurrence_exact", 8: "all_points_exact",
  21. 16: "member_count_valid", 32: "candidate_counts_invariant_complete",
  22. }
  23. class Pack:
  24. def __init__(self, path):
  25. with open(path, "rb") as source:
  26. self.data = mmap.mmap(source.fileno(), 0, access=mmap.ACCESS_READ)
  27. try:
  28. self.validate()
  29. except Exception:
  30. self.data.close()
  31. raise
  32. def validate(self):
  33. data = self.data
  34. if len(data) < 848 or data[:8] != b"SRSDKP01":
  35. raise ValueError("not a runtime activity SDK pack")
  36. version, header, size = struct.unpack_from("<IIQ", data, 8)
  37. if version != 37 or header != 848 or size != len(data):
  38. raise ValueError("unsupported SDK version/header or truncated file")
  39. if struct.unpack_from("<I", data, 152)[0] != 43:
  40. raise ValueError("unexpected SDK section count")
  41. payload = memoryview(data)[header:]
  42. digest = hashlib.sha256(payload).digest()
  43. payload.release()
  44. if digest != data[24:56]:
  45. raise ValueError("SDK payload checksum mismatch")
  46. self.sections = [struct.unpack_from("<QII", data, 160 + i * 16)
  47. for i in range(43)]
  48. for offset, count, stride in self.sections:
  49. if count and (offset < header or stride == 0 or offset + count * stride > size):
  50. raise ValueError("SDK section exceeds file bounds")
  51. for name, (index, expected) in SECTIONS.items():
  52. if self.sections[index][2] != expected:
  53. raise ValueError(f"unexpected {name} stride")
  54. self.build_id = "sha256:" + data[56:88].hex()
  55. def close(self):
  56. self.data.close()
  57. def rows(self, name):
  58. index, _ = SECTIONS[name]
  59. offset, count, stride = self.sections[index]
  60. if stride % 4:
  61. raise ValueError("section is not composed of u32 fields")
  62. return [struct.unpack_from("<" + "I" * (stride // 4), self.data, offset + i * stride)
  63. for i in range(count)]
  64. def text(self, row, index=0):
  65. start, length = row[index:index + 2]
  66. bank, count, _ = self.sections[0]
  67. if start + length > count:
  68. raise ValueError("string exceeds SDK string bank")
  69. return self.data[bank + start:bank + start + length].decode("utf-8")
  70. def inspect(self, tag):
  71. scenarios = self.rows("scenarios")
  72. matches = [i for i, row in enumerate(scenarios) if row[0] == tag]
  73. if len(matches) != 1:
  74. raise ValueError("scenario tag must resolve exactly once")
  75. scenario_index = matches[0]
  76. bubbles, states = self.rows("bubbles"), self.rows("states")
  77. objects, slots = self.rows("objects"), self.rows("slots")
  78. occurrences = [row for row in self.rows("occurrences") if row[8] == scenario_index]
  79. squad_rows = [row for row in self.rows("squads") if row[2] == scenario_index]
  80. members, actors = self.rows("members"), self.rows("actors")
  81. squads_by_slot = collections.defaultdict(list)
  82. for row in squad_rows:
  83. selected = members[row[9]:row[9] + row[10]]
  84. definition_ready = row[7] & 63 == 63 and 1 <= row[10] <= 15
  85. profiles = set()
  86. actors_exact = len(selected) == row[10]
  87. for member in selected:
  88. if member[10] >= 0x80000000:
  89. actors_exact = False
  90. elif member[10] > 0:
  91. if not member[6] & 1 or member[5] >= len(actors):
  92. actors_exact = False
  93. else:
  94. profiles.add(actors[member[5]][-1])
  95. profile_ready = actors_exact and len(profiles) == 1
  96. squads_by_slot[row[4]].append({
  97. "id": self.text(row), "flags": row[7], "member_count": row[10],
  98. "anchor_count": row[12], "occurrence_index": row[8],
  99. "spawner_tag": f"{row[5]:08x}", "rule_tag": f"{row[6]:08x}",
  100. "definition_ready": definition_ready, "spawn_profile_ready": profile_ready,
  101. "runnable": definition_ready and profile_ready,
  102. "missing_flags": [name for bit, name in SQUAD_FLAGS.items() if not row[7] & bit],
  103. })
  104. mission_objects = {row[11] for row in occurrences}
  105. mission_slots = {i: row for i, row in enumerate(slots) if row[8] in mission_objects}
  106. state_reports = []
  107. for index, row in enumerate(states):
  108. if row[6] != scenario_index:
  109. continue
  110. owned = {occ[11] for occ in occurrences if occ[10] == index}
  111. entries = []
  112. for slot_index, slot in mission_slots.items():
  113. if slot[8] not in owned:
  114. continue
  115. entries.append({"id": self.text(slot), "name": self.text(slot, 2),
  116. "type": slot[10], "object_tag": f"{objects[slot[8]][2]:08x}",
  117. "squads": squads_by_slot.get(slot_index, [])})
  118. state_reports.append({
  119. "id": self.text(row), "bubble": self.text(bubbles[row[7]], 2),
  120. "region_index": row[10] + row[8], "map_bubble_index": row[11],
  121. "registry_tag": f"{row[15]:08x}", "slots": entries,
  122. })
  123. squad_slots = {i: row for i, row in mission_slots.items() if row[10] == 1}
  124. missing = [self.text(row) for i, row in squad_slots.items() if i not in squads_by_slot]
  125. runnable = sum(q["runnable"] for group in squads_by_slot.values() for q in group)
  126. return {
  127. "scenario": f"{tag:08x}", "name": self.text(scenarios[scenario_index], 4),
  128. "sdk_build_id": self.build_id,
  129. "counts": {"states": len(state_reports), "slots": len(mission_slots),
  130. "squad_sensors": len(squad_slots), "squad_definitions": len(squad_rows),
  131. "runnable_squads": runnable, "sensors_without_squad_definition": len(missing)},
  132. "readiness_scope": "static definition and default actor-profile gates; live leases, placement transport and AI require an in-game test",
  133. "sensors_without_squad_definition": missing, "states": state_reports,
  134. }
  135. def main():
  136. parser = argparse.ArgumentParser(description=__doc__)
  137. parser.add_argument("pack", type=Path, help="installed Sunrise/activity_sdk.pack")
  138. parser.add_argument("--scenario", type=lambda value: int(value, 16), default=0x80B3C09E)
  139. parser.add_argument("--output", type=Path, required=True, help="local generated JSON path")
  140. parser.add_argument("--shard", type=Path, help="matching generated-world scenario pack")
  141. args = parser.parse_args()
  142. pack = Pack(args.pack)
  143. try:
  144. report = pack.inspect(args.scenario)
  145. finally:
  146. pack.close()
  147. if args.shard:
  148. report["world_shard"] = inspect_shard(args.shard, args.scenario)
  149. args.output.parent.mkdir(parents=True, exist_ok=True)
  150. args.output.write_text(json.dumps(report, indent=2) + "\n")
  151. print(json.dumps(report["counts"], indent=2))
  152. def inspect_shard(path, scenario):
  153. """Check v13 shard identity and report whether authored squad contexts were exported."""
  154. data = Path(path).read_bytes()
  155. if len(data) < 664 or data[:8] != b"SRGWSHRD":
  156. raise ValueError("not a generated-world scenario shard")
  157. version, header, size, tag, count = struct.unpack_from("<IIQII", data, 8)
  158. if (version, header, size, tag, count) != (13, 664, len(data), scenario, 35):
  159. raise ValueError("unsupported or mismatched scenario shard")
  160. if hashlib.sha256(data[header:]).digest() != data[72:104]:
  161. raise ValueError("scenario shard payload checksum mismatch")
  162. sections = [struct.unpack_from("<QII", data, 104 + i * 16) for i in range(count)]
  163. for offset, rows, stride in sections:
  164. if rows and (offset < header or stride == 0 or offset + rows * stride > size):
  165. raise ValueError("scenario shard section exceeds file bounds")
  166. names = ("config_contexts", "placement_contexts", "point_contexts",
  167. "point_placement_matches", "edge_contexts")
  168. return {"source_fingerprint": data[40:72].hex(),
  169. "authored_squad_context_counts": {name: sections[30 + i][1]
  170. for i, name in enumerate(names)}}
  171. if __name__ == "__main__":
  172. main()