verify_ember_movie_native.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. """Offline ABI verification against a mapped/decrypted image, never an on-disk encrypted EXE.
  2. Usage: python3 tests/verify_ember_movie_native.py path/to/game_image.bin [packages-directory]
  3. """
  4. import re
  5. import struct
  6. import sys
  7. from pathlib import Path
  8. repo = Path(__file__).resolve().parents[1]
  9. data = Path(sys.argv[1]).read_bytes()
  10. def signature(file, name):
  11. source = (repo / file).read_text()
  12. pattern = re.search(r'constexpr auto ' + name + r'\s*=\s*signature<signature_length\("([^"]+)"\)', source)[1]
  13. regex = b''.join(b'.' if token == '?' else re.escape(bytes([int(token, 16)])) for token in pattern.split())
  14. matches = [m.start() for m in re.finditer(regex, data, re.S)]
  15. assert len(matches) == 1, (name, matches)
  16. return matches[0]
  17. def target(base, offset, expected):
  18. assert data[base + offset] == 0xE8
  19. value = base + offset + 5 + struct.unpack_from('<i', data, base + offset + 1)[0]
  20. assert value == expected, (hex(base), hex(offset), hex(value), hex(expected))
  21. path = 'Sunrise/src/client/hooks/ember_movies/resources.cpp'
  22. orbit = 'Sunrise/src/client/hooks/ember_movies/orbit_return.cpp'
  23. return_path = signature(orbit, 'returnSig')
  24. assert return_path == 0xE19D50
  25. for offset, expected in [(0x1E,0xB48550),(0x2F,0xE35820),(0x5B,0xBF84B0),
  26. (0x65,0xC06330),(0x6A,0xBF95D0),(0x7E,0xBFB1F0),(0x86,0xBF97D0)]:
  27. target(return_path,offset,expected)
  28. assert signature(orbit,'activitySig') == 0xC294B0
  29. assert signature(orbit,'lifetimeSig') == 0x4FFD10
  30. assert signature(orbit,'stepSig') == 0xE1B4D0
  31. target(0xE1D619,0,0xE35820)
  32. target(0xE1D619,0x15,0xE1B4D0)
  33. assert data[0xE1D626:0xE1D62B] == bytes.fromhex('ba 1c 00 00 00')
  34. # Native UI return selection: initialize, construct default orbit, clear/select/commit.
  35. for site, expected in [(0x157727F,0xBF84B0),(0x157728C,0xC06330),
  36. (0x1577291,0xBF95D0),(0x15772A8,0xBFB1F0),(0x15772B4,0xBF97D0)]:
  37. target(site,0,expected)
  38. assert data[0xBF84E4:0xBF84EB] == bytes.fromhex('c6 83 18 01 00 00 00')
  39. print('Native orbit selection/commit, lifetime reader and deferred cleanup ABI verified.')
  40. load = signature(path, 'loadSig')
  41. end = signature(path, 'endSig')
  42. assert load == 0xB46E10 and end == 0xB44020
  43. surface = signature(path, 'surfaceSig')
  44. assert surface == 0x1202B00
  45. assert surface + 13 + struct.unpack_from('<i', data, surface + 9)[0] == 0x2E800B0
  46. target(0xB5F4C5, 9, surface) # native world activation publishes the surface registration stacks
  47. target(0x1184660, 0x37, 0x1202C20) # renderer fetches selected surface definitions
  48. assert data[0x116A070:0x116A077] == bytes.fromhex('48 83 38 00 0f 95 c0') # missing surface => skip GPU upload
  49. # Captured a47adbd exception: C0000005, RIP 1204163, RAX=0, RBX=80BCA021,
  50. # RDX=4. The registration callback reads the unloaded definition's slot directly.
  51. assert data[0x1204160:0x1204167] == bytes.fromhex('48 2b c1 48 0f be 08')
  52. # Native type-19 definitions need 16 bytes, not their eight-byte package size.
  53. assert struct.unpack_from('<I',data,0x1202488+18*4)[0] == 0x1202478
  54. assert data[0x1202478:0x120247F] == bytes.fromhex('ba 10 00 00 00 8b c2')
  55. # The type-19 raw buffer callback fills definition+8; the renderer reads that pointer.
  56. assert struct.unpack_from('<I',data,0x12045D0+18*4)[0] == 0x1204581
  57. assert data[0x12045BF:0x12045C6] == bytes.fromhex('48 2b c1 4c 89 40 08')
  58. assert data[0x4A6340:0x4A6345] == bytes.fromhex('48 8b 41 08 c3')
  59. ui = signature('Sunrise/src/client/hooks/bootflow/ember_movie_ui.cpp', 'sig')
  60. assert ui == 0xE1CD60
  61. # Native selector's false D8 branch is cinematic LOADING, not ready playback.
  62. target(0xE2EB75,0,0xC4B760)
  63. target(0xE2EB8E,0,0xC4C070)
  64. assert data[0xE2EB97:0xE2EB9C] == bytes.fromhex('bb 22 00 00 00')
  65. target(0xC4C185,0,0x1871FA0)
  66. assert data[0x1871FAB:0x1871FB3] == bytes.fromhex('48 8b 94 02 d8 00 00 00')
  67. assert struct.unpack_from('<Q',data,0x1CDB4E0)[0] == 0x140DDB0F0
  68. assert struct.unpack_from('<Q',data,0x1CDB4E0-0x48+0xD8)[0] == 0x140DD1D10
  69. assert data[0xDD1D1C:0xDD1D23] == bytes.fromhex('32 c0 48 83 c4 28 c3')
  70. # Both cinematic choices map to category 4; that alone did not identify playback.
  71. for state in (0x21,0x22):
  72. case=data[0xE2EA70+state+1]
  73. assert struct.unpack_from('<I',data,0xE2EA48+4*case)[0] == 0xE2EA2A
  74. assert data[0xE2EA2A:0xE2EA30] == bytes.fromhex('b8 04 00 00 00 c3')
  75. target(ui,0x9B,0xE2E9F0)
  76. target(0xE1CE0F,0,0x1340F00)
  77. # Neither UI drawing layer is intercepted; the renderer remains native.
  78. target(0x132B890, 0x353, 0x1278FF0) # native movie command is queued before either UI layer
  79. target(0x132B890, 0x3DF, 0x132BD80)
  80. target(0x132B890, 0x40C, 0x132BD80)
  81. # Follow the actual state -> window enum -> authored window name mapping.
  82. def fnv1(name):
  83. value=0x811C9DC5
  84. for byte in name.encode(): value=((value*0x1000193)&0xFFFFFFFF)^byte
  85. return value
  86. for state, enum, branch, name in [(0x21,26,0x13125D0,'cinematic_overlay'),
  87. (0x22,29,0x131258E,'loading')]:
  88. assert struct.unpack_from('<I',data,0x131260C+4*(state+1))[0] == branch
  89. assert data[branch] == 0xB8 and struct.unpack_from('<I',data,branch+1)[0] == enum
  90. window=struct.unpack_from('<I',data,0x131284C+4*enum)[0]
  91. assert data[window:window+2] == bytes.fromhex('8b 05')
  92. name_at=window+6+struct.unpack_from('<i',data,window+2)[0]
  93. assert struct.unpack_from('<I',data,name_at)[0] == fnv1(name)
  94. target(0x1317031,0,0x1312540)
  95. target(0x1317366,0,0x13126E0)
  96. # The UI explicitly excludes loading state 22h from its cinematic-overlay branch.
  97. assert data[0x1317094:0x131709A] == bytes.fromhex('41 83 fc 22 74 05')
  98. assert fnv1('subtitle_overlay') == 0x7737E414
  99. print('Native UI 21h -> cinematic_overlay; 22h -> loading verified; UI layers preserved.')
  100. # A playback overlay does not remove the separate gameplay HUD. Filter only
  101. # its full-window submission, before 13D9060 can replay the cached subtree.
  102. hud = 'Sunrise/src/client/hooks/bootflow/ember_movie_hud.cpp'
  103. window_call = signature(hud, 'windowDrawSig')
  104. widget_draw = signature(hud, 'widgetSig')
  105. assert window_call == 0x132C1B2 and widget_draw == 0x13D9060
  106. target(window_call, 11, widget_draw)
  107. assert window_call + 16 == 0x132C1C2
  108. # The caller passes the full window at intrusive-list node minus 600h.
  109. assert data[0x132C0F9:0x132C100] == bytes.fromhex('48 8d b3 00 fa ff ff')
  110. assert data[0x132C1BA:0x132C1BD] == bytes.fromhex('48 8b ce')
  111. # Child submissions share this callee but have a different caller and only a
  112. # widget-sized allocation. They must pass through without accessing +310.
  113. target(0x13D91FA, 0, widget_draw)
  114. # Native HUD creation selects "hud" (or an equipment override), then assigns
  115. # the semantic role 18. The role setter writes window+310, not state enum +410.
  116. assert struct.unpack_from('<I', data, 0x1FE2060)[0] == fnv1('hud')
  117. assert data[0x131778E:0x1317796] == bytes.fromhex('ba 12 00 00 00 48 8b c8')
  118. target(0x1317796, 0, 0x13165C0)
  119. assert data[0x13165DA:0x13165E0] == bytes.fromhex('89 90 10 03 00 00')
  120. print('Native gameplay HUD role 18 and root-only cached draw boundary verified.')
  121. for offset, expected in [(0x96, 0x4294D0), (0xD1, 0x423EF0), (0x14C, 0x4312D0), (0x157, 0x435AA0)]:
  122. target(load, offset, expected)
  123. for offset, expected in [(0x85, 0x42C650), (0x9F, 0x425310)]:
  124. target(end, offset, expected)
  125. assert data[end + 0x2E:end + 0x31] == bytes.fromhex('48 8B 05')
  126. assert end + 0x35 + struct.unpack_from('<i', data, end + 0x31)[0] == 0x2439C70
  127. # Native tag classifier, including the semantic distinction missed by the old test:
  128. # ordinary tag -> kind 1; shared type-16 tag (type_info & F000 == 2000) -> kind 2.
  129. assert data[0x42694F:0x42696F] == bytes.fromhex(
  130. '8b 45 04 8b cb 48 89 7c 24 30 25 00 f0 00 00 33 ff 3d 00 20 00 00 40 0f 94 c7 45 33 c0 8d 57 01')
  131. target(0x426920, 0x4F, 0x433050)
  132. # Kind 2 is routed to root+10; ordinary metadata belongs in root+20.
  133. assert data[0x4313CD:0x4313E2] == bytes.fromhex(
  134. '83 3f 02 b9 10 00 00 00 8b 57 04 41 b8 20 00 00 00 44 0f 44 c1')
  135. # For stream type_info & 30000 == 10000, the load job maps offset|patch and
  136. # size|C0000000 directly. It bypasses the ordinary allocation/read branch.
  137. assert data[0x3592C6:0x3592EB] == bytes.fromhex(
  138. '8b c3 c1 e8 10 83 e0 03 83 f8 01 75 2f 41 0f b7 4d 20 41 81 cf 00 00 00 c0 8b 55 50 45 8b c7 48 0b d1 8b 4d 48')
  139. target(0x3591B0, 0x13B, 0x351D00)
  140. target(0x41A160, 0x16, 0x3597C0) # native video I/O opens this mapped package/patch
  141. target(0x41A160, 0x2C, 0x357DA0) # then obtains offset and byte length
  142. movie = 'Sunrise/src/client/hooks/ember_movies/ember_movies.cpp'
  143. start, stop, busy = (signature(movie, name) for name in ('startSig', 'stopSig', 'busySig'))
  144. for offset, expected in [(0x72, 0x41B040), (0x7A, 0x41A3C0), (0x8E, 0x41CD20)]:
  145. target(start, offset, expected)
  146. for offset, expected in [(0x18, 0x41D0C0), (0x25, 0x41A980)]:
  147. target(stop, offset, expected)
  148. assert busy == 0x41B420
  149. target(busy, 0x48, 0x41AB70)
  150. if len(sys.argv) > 2:
  151. # Read only container metadata, without unpacking data or starting the game.
  152. latest = {}
  153. for path in Path(sys.argv[2]).glob('*.pkg'):
  154. with path.open('rb') as stream:
  155. header = stream.read(0x170)
  156. package = struct.unpack_from('<H', header, 4)[0]
  157. version = (struct.unpack_from('<Q', header, 0x10)[0],
  158. struct.unpack_from('<I', header, 0x1C)[0],
  159. struct.unpack_from('<H', header, 0x20)[0])
  160. if package not in latest or version > latest[package][0]:
  161. latest[package] = version, path, header
  162. for tag, expected in [(0x80BCA001, 0x80808495), (0x80BCA003, 0x80808495),
  163. (0x80BCA000, 0x80808499), (0x80BCA002, 0x80808499),
  164. (0x80B9EB33, 0x80809A88), (0x80B9EB34, 0x80809A88),
  165. (0x80BCA032, 0x80806B8F),
  166. (0x80BCA022, 0x80806B91), (0x80BCA025, 0x80806B91),
  167. (0x80BCA028, 0x80806B91), (0x80BCA02B, 0x80806B91),
  168. (0x80BCA02E, 0x80806B91), (0x80BCA031, 0x80806B91),
  169. (0x80BCA021, 0x80BCA020), (0x80BCA024, 0x80BCA023),
  170. (0x80BCA026, 0x80BCA027), (0x80BCA029, 0x80BCA02A),
  171. (0x80BCA02C, 0x80BCA02D), (0x80BCA02F, 0x80BCA030),
  172. (0x80BCA020, 0x80BCA021), (0x80BCA023, 0x80BCA024),
  173. (0x80BCA027, 0x80BCA026), (0x80BCA02A, 0x80BCA029),
  174. (0x80BCA02D, 0x80BCA02C), (0x80BCA030, 0x80BCA02F),
  175. (0x80BCA034, 0xFFFFFFFF), (0x80C7C000, 0xFFFFFFFF)]:
  176. # Tag package IDs include the bank: 80BCAxxx belongs to package 01E5.
  177. package = (tag >> 13) & 0x3FF
  178. _, path, header = latest[package]
  179. table = (struct.unpack_from('<I', header, 0x110)[0] + 0x60 if header[0x1A] == 1
  180. else struct.unpack_from('<I', header, 0xB8)[0])
  181. with path.open('rb') as stream:
  182. stream.seek(table + (tag & 0x1FFF) * 16)
  183. reference, type_info, _ = struct.unpack('<IIQ', stream.read(16))
  184. assert reference == expected, (hex(tag), hex(reference), path)
  185. assert type_info & 0xF000 != 0x2000, (hex(tag), hex(type_info))
  186. if expected == 0xFFFFFFFF:
  187. assert (type_info & 0x30000) == 0x10000 and (type_info >> 6) & 0x3F == 24
  188. if 0x80BCA020 <= expected <= 0x80BCA030:
  189. definitions = {0x80BCA021,0x80BCA024,0x80BCA026,0x80BCA029,0x80BCA02C,0x80BCA02F}
  190. assert (type_info & 0x3FFFF) == (0x44FB if tag in definitions else 0x254FB)
  191. print('Movie metadata, streams, surface containers, definitions and raw buffers all use kind 1.')
  192. tags = repo / 'build/first-encounter-audit/tags'
  193. catalog = (tags / '80BCA032.bin').read_bytes()
  194. for i, (container, definition) in enumerate(zip(
  195. [0x80BCA022,0x80BCA025,0x80BCA028,0x80BCA02B,0x80BCA02E,0x80BCA031],
  196. [0x80BCA021,0x80BCA024,0x80BCA026,0x80BCA029,0x80BCA02C,0x80BCA02F])):
  197. assert struct.unpack_from('<I', catalog, 0x38 + 16*i)[0] == container
  198. assert (tags / f'{container:08X}.bin').read_bytes() == struct.pack('<I', definition)
  199. assert (tags / f'{definition:08X}.bin').read_bytes()[0] == i + 1
  200. print('Six authored Y/U/V definitions map to the renderer slots 1..6.')
  201. attach = signature('Sunrise/src/client/hooks/bootflow/ember_sunburn.cpp', 'sig')
  202. assert attach == 0x9F2760
  203. # Native attach dereferences the runtime relative template, then passes it to the child factory.
  204. target(attach, 0x68, 0x32BBD0)
  205. target(attach, 0x78, 0x56DE00)
  206. assert data[0x4AE000:0x4AE002] == bytes.fromhex('8B 09') # factory reads resource at request+0
  207. print('Native resource kind selection, request lifecycle, movie playback and sunburn attachment ABI verified.')