omr2txt.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. #!/usr/bin/env python3
  2. from collections import defaultdict
  3. from gzip import GzipFile
  4. from math import modf
  5. from sys import stderr
  6. from xml.sax import make_parser
  7. from xml.sax.handler import feature_external_ges
  8. from xml.dom import pulldom
  9. cpuClock = 3579545
  10. vdpClock = cpuClock * 6
  11. masterClock = cpuClock * 960
  12. vdpTicksPerLine = 1368
  13. # The keys we're interested in: the letter to be used in the text file,
  14. # followed by the keyboard matrix location (row, column).
  15. # This should probably be made configurable at some point, but for now you
  16. # can edit them here.
  17. inputMap = {
  18. 'r': (8, 7), 'd': (8, 6), 'u': (8, 5), 'l': (8, 4), 's': (8, 0)
  19. }
  20. # Coleco:
  21. #inputMap = {
  22. # 'r': (0, 1), 'd': (0, 2), 'u': (0, 0), 'l': (0, 3), 's': (0, 6), 'o': (2, 1)
  23. # }
  24. inputMapReverse = dict((pos, name) for name, pos in inputMap.items())
  25. def readEvents(filename):
  26. '''Reads input events from an openMSX replay file (OMR).
  27. Returns a list of tuples consisting of a timestamp, keyboard row, press
  28. mask and release mask.
  29. '''
  30. print('reading replay:', filename, file=stderr)
  31. inputEvents = []
  32. def getText(node):
  33. return ''.join(
  34. child.data
  35. for child in node.childNodes
  36. if child.nodeType == child.TEXT_NODE
  37. )
  38. def parseItem():
  39. time = None
  40. row = None
  41. press = None
  42. release = None
  43. for event, node in xmlEvents:
  44. if event == pulldom.START_ELEMENT:
  45. doc.expandNode(node)
  46. if node.tagName == 'StateChange':
  47. assert time is None, time
  48. timeNode1, timeNode2 = node.getElementsByTagName('time')
  49. time = int(getText(timeNode2))
  50. elif node.tagName == 'row':
  51. assert row is None, row
  52. row = int(getText(node))
  53. elif node.tagName == 'press':
  54. assert press is None, press
  55. press = int(getText(node))
  56. elif node.tagName == 'release':
  57. assert release is None, release
  58. release = int(getText(node))
  59. else:
  60. pass #print(node.toxml())
  61. elif event == pulldom.END_ELEMENT:
  62. if node.tagName == 'item':
  63. break
  64. assert time is not None
  65. assert row is not None
  66. assert press is not None
  67. assert release is not None
  68. inputEvents.append((time, row, press, release))
  69. def parseEvents():
  70. for event, node in xmlEvents:
  71. if event == pulldom.START_ELEMENT:
  72. if node.tagName == 'item' \
  73. and node.getAttribute('type') == 'KeyMatrixState':
  74. parseItem()
  75. elif event == pulldom.END_ELEMENT:
  76. if node.tagName == 'events':
  77. break
  78. def parseTopLevel():
  79. for event, node in xmlEvents:
  80. if event == pulldom.START_ELEMENT:
  81. if node.tagName == 'events':
  82. parseEvents()
  83. parser = make_parser()
  84. parser.setFeature(feature_external_ges, False)
  85. with GzipFile(filename) as inp:
  86. doc = pulldom.parse(inp, parser)
  87. xmlEvents = iter(doc)
  88. parseTopLevel()
  89. print('read %d input events' % len(inputEvents), file=stderr)
  90. return inputEvents
  91. def combineEvents(inputEvents):
  92. '''Combines multiple events on the same keyboard row at the same timestamp
  93. into a single event.
  94. '''
  95. pendingTime = None
  96. def outputPending():
  97. if pendingTime is not None:
  98. for row in sorted(pressForRow.keys() | releaseForRow.keys()):
  99. press = pressForRow[row]
  100. release = releaseForRow[row]
  101. if press != 0 or release != 0:
  102. yield pendingTime, row, press, release
  103. for time, row, press, release in inputEvents:
  104. if time != pendingTime:
  105. assert pendingTime is None or time > pendingTime, time
  106. # Output previous event.
  107. yield from outputPending()
  108. # Start new event.
  109. pendingTime = time
  110. pressForRow = defaultdict(int)
  111. releaseForRow = defaultdict(int)
  112. pressForRow[row] = (pressForRow[row] | press) & ~release
  113. releaseForRow[row] = (releaseForRow[row] | release) & ~press
  114. else:
  115. yield from outputPending()
  116. def removeRedundantEvents(inputEvents):
  117. '''Remove (parts of) events that don't change the keyboard matrix state.
  118. '''
  119. matrix = [0] * 12
  120. for time, row, press, release in inputEvents:
  121. old = matrix[row]
  122. press &= ~old
  123. release &= old
  124. if press != 0 or release != 0:
  125. matrix[row] = (old | press) & ~release
  126. yield time, row, press, release
  127. def filterEvents(inputEvents, wantedKeys):
  128. '''Filter the given input events to contain only presses and releases of
  129. the given keys.
  130. The wantedKeys argument should be mapping from row to a column bitmask,
  131. where bits are set if we want to preserve changes in the corresponding
  132. position in the keyboard matrix.
  133. '''
  134. for time, row, press, release in inputEvents:
  135. mask = wantedKeys.get(row, 0)
  136. press &= mask
  137. release &= mask
  138. if press != 0 or release != 0:
  139. yield time, row, press, release
  140. def scaleTime(inputEvents, tickScale):
  141. '''Yields the given input events, with the time stamps divided by the
  142. given scale and rounded to the nearest integer.
  143. '''
  144. for time, row, press, release in inputEvents:
  145. yield int(time / tickScale + 0.5), row, press, release
  146. def checkAlignment(timestamps, alignment):
  147. '''Returns the number of timestamps from the given sequence that are
  148. close to multiples of the given alignment.
  149. '''
  150. aligned = 0
  151. for time in timestamps:
  152. offset = modf(time / alignment)[0]
  153. if offset < 0.001 or offset > 0.999:
  154. aligned += 1
  155. return aligned
  156. def detectTicksPerFrame(timestamps):
  157. '''Determine the most likely number of master clock ticks per frame,
  158. by looking at how well the timestamps align with frame boundaries when
  159. using the two frame timings that openMSX supports.
  160. This assumes that the frame timing is the same throughout the replay,
  161. which is not guaranteed in general, but will hopefully be the case for
  162. tool-assisted speedruns.
  163. '''
  164. ticksPerFrame50 = (masterClock * vdpTicksPerLine * 313) // vdpClock
  165. ticksPerFrame60 = (masterClock * vdpTicksPerLine * 262) // vdpClock
  166. timestamps = tuple(timestamps)
  167. aligned50 = checkAlignment(timestamps, ticksPerFrame50)
  168. aligned60 = checkAlignment(timestamps, ticksPerFrame60)
  169. if aligned50 > aligned60:
  170. print('event timestamps align with 50 fps timing: %.2f%%'
  171. % (100 * aligned50 / len(timestamps)), file=stderr)
  172. return ticksPerFrame50
  173. else:
  174. print('event timestamps align with 60 fps timing: %.2f%%'
  175. % (100 * aligned60 / len(timestamps)), file=stderr)
  176. return ticksPerFrame60
  177. def eventsToState(inputEvents):
  178. '''Yields pairs of a timestamp and a set of the active keys at that time.
  179. '''
  180. active = set()
  181. prevTime = None
  182. for time, row, press, release in inputEvents:
  183. assert (press & release) == 0, (press, release)
  184. if prevTime != time:
  185. if prevTime is not None:
  186. yield prevTime, frozenset(active)
  187. prevTime = time
  188. col = 0
  189. while press:
  190. if press & 1:
  191. active.add((row, col))
  192. col += 1
  193. press >>= 1
  194. col = 0
  195. while release:
  196. if release & 1:
  197. active.remove((row, col))
  198. col += 1
  199. release >>= 1
  200. if prevTime is not None:
  201. yield prevTime, frozenset(active)
  202. def formatState(active):
  203. return ' '.join(
  204. inputMapReverse[pos]
  205. for pos in sorted(active, key=lambda pos: (pos[0], -pos[1]))
  206. )
  207. def convert(inFilename, outFilename):
  208. wantedKeys = {}
  209. for name, (row, col) in inputMap.items():
  210. wantedKeys[row] = wantedKeys.get(row, 0) | (1 << col)
  211. inputEvents = list(removeRedundantEvents(
  212. filterEvents(combineEvents(readEvents(inFilename)), wantedKeys)
  213. ))
  214. print('after cleanup %d events remain' % len(inputEvents), file=stderr)
  215. if not inputEvents:
  216. print(
  217. "no events match; you should probably customize 'inputMap' "
  218. "at the top of this script", file=stderr
  219. )
  220. return
  221. ticksPerFrame = detectTicksPerFrame(evt[0] for evt in inputEvents)
  222. scaledEvents = list(removeRedundantEvents(
  223. combineEvents(scaleTime(inputEvents, ticksPerFrame))
  224. ))
  225. with open(outFilename, 'w') as out:
  226. print('writing output:', outFilename)
  227. print('= base', inFilename, file=out)
  228. print('= out', 'replay.omr', file=out)
  229. print('= scale', ticksPerFrame, file=out)
  230. for name, (row, col) in sorted(
  231. inputMap.items(), key=lambda item: (item[1][0], -item[1][1])
  232. ):
  233. print('= input', name, 'key', row, col, file=out)
  234. print(file=out)
  235. def printMilestone(frame, seconds):
  236. print('# frame %d (%d:%02d)' % ((frame,) + divmod(seconds, 60)),
  237. file=out)
  238. milestone = 10 # seconds
  239. prevFrame = 0
  240. prevSeconds = 0
  241. printMilestone(prevFrame, prevSeconds)
  242. prevActive = set()
  243. for frame, active in eventsToState(scaledEvents):
  244. delta = frame - prevFrame
  245. print(('%4d %s' % (delta, formatState(prevActive))).rstrip(),
  246. file=out)
  247. seconds = (frame * ticksPerFrame) // masterClock
  248. if seconds // milestone != prevSeconds // milestone:
  249. printMilestone(frame, seconds)
  250. prevFrame = frame
  251. prevSeconds = seconds
  252. prevActive = active
  253. printMilestone(prevFrame, prevSeconds)
  254. if __name__ == '__main__':
  255. from sys import argv
  256. if len(argv) != 2:
  257. print('Usage: omr2txt.py file1.omr', file=stderr)
  258. print('Converts an openMSX replay to a text file.', file=stderr)
  259. exit(2)
  260. else:
  261. inFilename = argv[1]
  262. if inFilename.endswith('.omr'):
  263. outFilename = inFilename[:-4] + '.txt'
  264. convert(inFilename, outFilename)
  265. else:
  266. print('File name does not end in ".omr":', inFilename)
  267. exit(2)