| e8555c89 Andy McCall |
1 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 177 |
|
| 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 |
|
| 206 |
reRecordCount = doc.find('replay/reRecordCount') |
| 207 |
if reRecordCount is not None: |
| 208 |
reRecordCount.text = '0' |
| 209 |
|
| 210 |
|
| 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]) |