| e8555c89 Andy McCall |
1 |
|
| 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 |
|
| 18 |
|
| 19 |
|
| 20 |
|
| 21 |
inputMap = { |
| 22 |
'r': (8, 7), 'd': (8, 6), 'u': (8, 5), 'l': (8, 4), 's': (8, 0) |
| 23 |
} |
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
inputMapReverse = dict((pos, name) for name, pos in inputMap.items()) |
| 29 |
|
| 30 |
def readEvents(filename): |
| 31 |
|
| 32 |
|
| 33 |
|
| 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 |
| 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 |
|
| 108 |
|
| 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 |
|
| 125 |
yield from outputPending() |
| 126 |
|
| 127 |
|
| 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 |
|
| 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 |
|
| 151 |
|
| 152 |
|
| 153 |
|
| 154 |
|
| 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 |
|
| 165 |
|
| 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 |
|
| 172 |
|
| 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 |
|
| 183 |
|
| 184 |
|
| 185 |
|
| 186 |
|
| 187 |
|
| 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 |
|
| 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 |
| 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) |