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