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