txt2omr.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. #!/usr/bin/env python3
  2. from collections import defaultdict
  3. from datetime import datetime
  4. from gzip import GzipFile
  5. from io import TextIOWrapper
  6. from sys import stderr
  7. from xml.etree.ElementTree import SubElement, parse as parseXML
  8. import platform
  9. cpuClock = 3579545
  10. masterClock = cpuClock * 960
  11. def readStates(topFilename):
  12. openFilenames = []
  13. base = None
  14. out = None
  15. scale = 1
  16. inputMap = {}
  17. inputStates = []
  18. time = 0
  19. def handleProcessingInstruction(words):
  20. if not words:
  21. raise ValueError('Missing processing instruction')
  22. elif words[0] == 'base':
  23. if len(words) != 2:
  24. raise ValueError(
  25. 'Processing instruction "base" expects 1 argument, '
  26. 'got %d' % (len(words) - 1))
  27. nonlocal base
  28. if base is not None:
  29. raise ValueError('Attempt to change base file name')
  30. base = words[1]
  31. elif words[0] == 'out':
  32. if len(words) != 2:
  33. raise ValueError(
  34. 'Processing instruction "out" expects 1 argument, '
  35. 'got %d' % (len(words) - 1))
  36. nonlocal out
  37. if out is not None:
  38. raise ValueError('Attempt to change output file name')
  39. out = words[1]
  40. elif words[0] == 'scale':
  41. if len(words) != 2:
  42. raise ValueError(
  43. 'Processing instruction "scale" expects 1 argument, '
  44. 'got %d' % (len(words) - 1))
  45. nonlocal scale
  46. scale = int(words[1])
  47. print('time scale: %.2f frames per second' % (masterClock / scale))
  48. elif words[0] == 'input':
  49. if len(words) != 5:
  50. raise ValueError(
  51. 'Processing instruction "input" expects 4 arguments, '
  52. 'got %d' % (len(words) - 1))
  53. name, inpType, rowStr, colStr = words[1:]
  54. if inpType != 'key':
  55. raise ValueError('Unknown input type "%s"' % inpType)
  56. row = int(rowStr)
  57. col = int(colStr)
  58. inputMap[name] = (row, col)
  59. elif words[0] == 'include':
  60. if len(words) != 2:
  61. raise ValueError(
  62. 'Processing instruction "include" expects 1 argument, '
  63. 'got %d' % (len(words) - 1))
  64. readFile(words[1])
  65. else:
  66. raise ValueError('Unknown processing instruction: %s' % words[0])
  67. def handleState(words):
  68. frames = int(words[0])
  69. try:
  70. inputs = [inputMap[name] for name in words[1:]]
  71. except KeyError as ex:
  72. raise ValueError('Undefined input: %s' % ex)
  73. nonlocal time
  74. inputStates.append((time, inputs))
  75. time += frames * scale
  76. return frames
  77. def readFile(filename):
  78. if filename in openFilenames:
  79. raise ValueError('Circular include: %s' % filename)
  80. localStateCount = 0
  81. localFrameCount = 0
  82. startTime = time
  83. openFilenames.append(filename)
  84. try:
  85. with open(filename, 'r') as stream:
  86. for lineNr, line in enumerate(stream, 1):
  87. try:
  88. line = line.strip()
  89. if not line or line[0] == '#':
  90. # Ignore empty line or comment.
  91. continue
  92. if line[0] == '=':
  93. handleProcessingInstruction(line[1:].split())
  94. else:
  95. localFrameCount += handleState(line.split())
  96. localStateCount += 1
  97. except ValueError:
  98. role = 'In' if filename == openFilenames[-1] \
  99. else 'included from'
  100. print('%s "%s" line %d,' % (role, filename, lineNr),
  101. file=stderr)
  102. raise
  103. except OSError as ex:
  104. print('Failed to open input file "%s",' % filename, file=stderr)
  105. raise ValueError(str(ex)) from ex
  106. del openFilenames[-1]
  107. localTime = time - startTime
  108. print('%-17s %5d states, %6d frames, %7.2f seconds' % (
  109. filename + ':', localStateCount, localFrameCount,
  110. localTime / masterClock
  111. ), file=stderr)
  112. readFile(topFilename)
  113. inputStates.append((time, []))
  114. if base is None:
  115. raise ValueError('Base file not defined')
  116. return base, out, inputStates
  117. def statesToEvents(inputStates):
  118. active = {}
  119. for time, state in inputStates:
  120. stateByRow = defaultdict(int)
  121. for row, col in state:
  122. stateByRow[row] |= 1 << col
  123. for row in sorted(active.keys() | stateByRow.keys()):
  124. old = active.get(row, 0)
  125. new = stateByRow.get(row, 0)
  126. press = new & ~old
  127. release = ~new & old
  128. if press != 0 or release != 0:
  129. yield time, row, press, release
  130. active[row] = new
  131. def replaceEvents(inp, out, inputEvents):
  132. doc = parseXML(inp)
  133. # Set the serialization date to now.
  134. rootElem = doc.getroot()
  135. rootElem.attrib['date_time'] = \
  136. datetime.now().strftime('%a %b %d %H:%M:%S %Y')
  137. rootElem.attrib['openmsx_version'] = 'txt2omr'
  138. rootElem.attrib['platform'] = platform.system().lower()
  139. # Remove snapshots except the one at timestamp 0.
  140. snapshots = doc.find('replay/snapshots')
  141. if snapshots is None:
  142. print('Base replay lacks snapshots', file=stderr)
  143. else:
  144. seenInitialSnapshot = False
  145. for snapshot in snapshots.findall('item'):
  146. timeElem = snapshot.find('scheduler/currentTime/time')
  147. time = int(timeElem.text)
  148. if time == 0:
  149. seenInitialSnapshot = True
  150. else:
  151. snapshots.remove(snapshot)
  152. if not seenInitialSnapshot:
  153. print('No snapshot found with timestamp 0', file=stderr)
  154. # Replace event log.
  155. eventsElem = doc.find('replay/events')
  156. if eventsElem is None:
  157. print('No events tag found; cannot insert events', file=stderr)
  158. else:
  159. tail = eventsElem.tail
  160. eventsElem.clear()
  161. eventsElem.text = '\n'
  162. eventsElem.tail = tail
  163. # IDs must be unique for the entire document. We look for the highest
  164. # in-use ID and generate new IDs counting up from there.
  165. baseID = max(
  166. int(elem.attrib['id'])
  167. for elem in doc.iterfind('.//*[@id]')
  168. ) + 1
  169. def createEvent(i, time):
  170. itemElem = SubElement(eventsElem, 'item',
  171. id=str(baseID + i), type='KeyMatrixState')
  172. itemElem.tail = '\n'
  173. stateChangeElem = SubElement(itemElem, 'StateChange')
  174. timeElem1 = SubElement(stateChangeElem, 'time')
  175. timeElem2 = SubElement(timeElem1, 'time')
  176. timeElem2.text = str(time)
  177. return itemElem
  178. for i, (time, row, press, release) in enumerate(inputEvents):
  179. itemElem = createEvent(i, time)
  180. SubElement(itemElem, 'row').text = str(row)
  181. SubElement(itemElem, 'press').text = str(press)
  182. SubElement(itemElem, 'release').text = str(release)
  183. endTime = inputEvents[-1][0] if inputEvents else 0
  184. createEvent(len(inputEvents), endTime).attrib['type'] = 'EndLog'
  185. # Reset re-record count.
  186. reRecordCount = doc.find('replay/reRecordCount')
  187. if reRecordCount is not None:
  188. reRecordCount.text = '0'
  189. # Reset the current time.
  190. currentTime = doc.find('replay/currentTime/time')
  191. if currentTime is not None:
  192. currentTime.text = '0'
  193. out.write(b'<?xml version="1.0" ?>\n')
  194. out.write(b"<!DOCTYPE openmsx-serialize SYSTEM 'openmsx-serialize.dtd'>\n")
  195. doc.write(out, encoding='utf-8', xml_declaration=False)
  196. def convert(inFilename):
  197. try:
  198. base, outFilename, inputStates = readStates(inFilename)
  199. except ValueError as ex:
  200. print('ERROR: %s' % ex, file=stderr)
  201. exit(1)
  202. inputEvents = list(statesToEvents(inputStates))
  203. def createOutput(inp):
  204. if outFilename is None:
  205. print('No output file name set', file=stderr)
  206. else:
  207. try:
  208. with GzipFile(outFilename, 'wb') as out:
  209. replaceEvents(inp, out, inputEvents)
  210. except OSError as ex:
  211. print('Failed to open output replay:', ex)
  212. exit(1)
  213. else:
  214. print('wrote output replay:', outFilename)
  215. try:
  216. if base.endswith('.omr'):
  217. with GzipFile(base, 'rb') as inp:
  218. createOutput(inp)
  219. elif base.endswith('.xml'):
  220. with open(base, 'rb') as inp:
  221. createOutput(inp)
  222. else:
  223. print('Unknown base file type in "%s" '
  224. '(".xml" and ".omr" are supported)'
  225. % base, file=stderr)
  226. exit(1)
  227. except OSError as ex:
  228. print('Failed to open base replay:', ex, file=stderr)
  229. exit(1)
  230. if __name__ == '__main__':
  231. from sys import argv
  232. if len(argv) != 2:
  233. print('Usage: txt2omr.py replay.txt', file=stderr)
  234. print('Converts the text version of an openMSX replay to an OMR file.',
  235. file=stderr)
  236. exit(2)
  237. else:
  238. convert(argv[1])