OXYGENHUB
◆ main openmsx / doc / internal / vdp-vram-timing / 2026-measurement-check.py
17.3 KiB · Python · last changed chore(): initial commit 1 month ago source blame history raw
e8555c89 Andy McCall 1
#!/usr/bin/env python3
2
"""Fit the openMSX command-engine timing model to the 2026 logic-analyzer set.
3
4
Usage:
5
    2026-measurement-check.py <measurement-repo>/part2/5.slots [--cpu] [--plain]
6
7
Reads the annotated access traces from
8
<https://github.com/m9710797/vdp-timing-measurements> and, for every
9
command-engine step (e.g. HMMM's write -> next read), reports which delays
10
would reproduce *every* observed access.
11
12
The model is openMSX's:
13
14
    next access = the first VRAM access slot at or after (previous access +
15
                  delta), where delta = 'command engine work' + 'request
16
                  arbitration latency'
17
18
with two refinements that the raw captures force (see
19
2026-measurement-analysis.md; vcd-slot-grid.py does the slot measurement):
20
21
  * the delay is counted in the VDP's memory cycles rather than VDP cycles.
22
    Two memory cycles per line are stretched by two cycles each in the
23
    horizontal blanking region -- the slot grid there runs
24
    ... 1316 1324 1334 1344 ... where every other gap is 8 -- and the engine's
25
    counter does not see the stretch.  '--plain' turns this off.
26
  * with sprite rendering active every delay is one cycle longer.
27
28
For one observed pair (previous access at p, next access at n) the model is
29
satisfied by every delta in [prevSlot(n) - p + 1, n - p] measured in that time
30
base; intersecting over all observations of a step gives its band.
31
32
Cycle numbers here are openMSX's, which are based on /RAS.  The .txt files of
33
the measurement repository are based on /CAS and are one higher.
34
"""
35
from __future__ import annotations
36
37
import argparse
38
import collections
39
import glob
40
import os
41
import re
42
import sys
43
44
TICKS = 1368                # VDP cycles per display line
45
DUMMY_CODE, DUMMY_ADDR = 'R..', 0x1FFFF   # the VDP's dummy read
46
CPU_CODES = ('R.r', 'W.w')  # CPU read / write
47
VRAM_LINE = 128             # bytes per VRAM line in screen 5
48
49
# --------------------------------------------------------------------------
50
# Access slot tables, exactly as in src/video/VDPAccessSlots.cc.
51
#
52
# Measured from /RAS in the raw captures: all 154 / 88 / 31 slots are confirmed
53
# to within 0.06 cycles.
54
#
55
# The .txt analyses in the measurement repository are /CAS based.  /CAS normally
56
# follows /RAS by one cycle, but for exactly two slots per table -- 1324 and
57
# 1334 here, 1322 and 1332 for sprites-off -- it follows by two, because those
58
# two memory cycles are stretched (see V() below).  So the CAS -> RAS conversion
59
# is not a uniform -1 for those two slots; RELABEL handles them.
60
# --------------------------------------------------------------------------
61
SLOTS_DISP_OFF = [
62
       0,    8,   16,   24,   32,   40,   48,   56,   64,   72,
63
      80,   88,   96,  104,  112,  120,  164,  172,  180,  188,
64
     196,  204,  212,  220,  228,  236,  244,  252,  260,  268,
65
     276,  292,  300,  308,  316,  324,  332,  340,  348,  356,
66
     364,  372,  380,  388,  396,  404,  420,  428,  436,  444,
67
     452,  460,  468,  476,  484,  492,  500,  508,  516,  524,
68
     532,  548,  556,  564,  572,  580,  588,  596,  604,  612,
69
     620,  628,  636,  644,  652,  660,  676,  684,  692,  700,
70
     708,  716,  724,  732,  740,  748,  756,  764,  772,  780,
71
     788,  804,  812,  820,  828,  836,  844,  852,  860,  868,
72
     876,  884,  892,  900,  908,  916,  932,  940,  948,  956,
73
     964,  972,  980,  988,  996, 1004, 1012, 1020, 1028, 1036,
74
    1044, 1060, 1068, 1076, 1084, 1092, 1100, 1108, 1116, 1124,
75
    1132, 1140, 1148, 1156, 1164, 1172, 1188, 1196, 1204, 1212,
76
    1220, 1228, 1268, 1276, 1284, 1292, 1300, 1308, 1316, 1324,
77
    1334, 1344, 1352, 1360,
78
]
79
SLOTS_SPR_OFF = [
80
       6,   14,   22,   30,   38,   46,   54,   62,   70,   78,
81
      86,   94,  102,  110,  118,  162,  170,  182,  188,  214,
82
     220,  246,  252,  278,  310,  316,  342,  348,  374,  380,
83
     406,  438,  444,  470,  476,  502,  508,  534,  566,  572,
84
     598,  604,  630,  636,  662,  694,  700,  726,  732,  758,
85
     764,  790,  822,  828,  854,  860,  886,  892,  918,  950,
86
     956,  982,  988, 1014, 1020, 1046, 1078, 1084, 1110, 1116,
87
    1142, 1148, 1174, 1206, 1212, 1266, 1274, 1282, 1290, 1298,
88
    1306, 1314, 1322, 1332, 1342, 1350, 1358, 1366,
89
]
90
SLOTS_SPR_ON = [
91
      28,   92,  162,  170,  188,  220,  252,  316,  348,  380,
92
     444,  476,  508,  572,  604,  636,  700,  732,  764,  828,
93
     860,  892,  956,  988, 1020, 1084, 1116, 1148, 1212, 1264,
94
    1330,
95
]
96
TABLES = {'dispOff': SLOTS_DISP_OFF, 'sprOff': SLOTS_SPR_OFF,
97
          'sprOn': SLOTS_SPR_ON}
98
99
# part2/README.md's proposed correction, for comparison (--published-slots)
100
PUBLISHED_TABLES = {
101
    'dispOff': [1325 if s == 1324 else 1335 if s == 1334 else s
102
                for s in SLOTS_DISP_OFF],
103
    'sprOff': [1323 if s == 1322 else 1333 if s == 1332 else s
104
               for s in SLOTS_SPR_OFF],
105
    'sprOn': SLOTS_SPR_ON,
106
}
107
108
109
def table(mode):
110
    return (PUBLISHED_TABLES if PUBLISHED[0] else TABLES)[mode]
111
112
# CAS -> RAS is -2 rather than -1 for these (after the uniform -1, subtract
113
# one more)
114
RELABEL = {'dispOff': {1325: 1324, 1335: 1334},
115
           'sprOff': {1323: 1322, 1333: 1332},
116
           'sprOn': {}}
117
118
# The VDP pads its line out to 1368 cycles by making a few memory cycles in the
119
# horizontal blanking region longer than the rest.  Measured from /RAS, and
120
# confirmed by the R#9 S1/S0 captures, where the padding disappears and the line
121
# is 1365 cycles: display off and sprites off get two cycles of 10 where the
122
# rest of that part of the line runs at 8, sprites on gets three cycles one
123
# cycle longer than the 14/6/10 pattern it would otherwise have.  Each entry is
124
# the cycle at which one stretch has completed; STRETCH_STEP is how many cycles
125
# each of them adds.
126
PAD = {'dispOff': ((1334, 2), (1344, 2)),
127
       'sprOff': ((1332, 2), (1342, 2)),
128
       'sprOn':  ((1330, 2), (1337, 1), (1348, 1))}
129
130
# VRAM accesses per pixel/byte step, and the role of each
131
ROLES = {
132
    'hmmv': ('W',),
133
    'lmmv': ('R', 'W'),
134
    'ymmm': ('R', 'W'),
135
    'hmmm': ('R', 'W'),
136
    'line': ('R', 'W'),
137
    'lmmm': ('Rs', 'Rd', 'W'),
138
}
139
140
# The values openMSX uses today, for comparison
141
OPENMSX = {
142
    ('hmmv', 'W->W'): 48,  ('hmmv', 'newline'): 104,
143
    ('lmmv', 'R->W'): 24,  ('lmmv', 'W->R'): 72,  ('lmmv', 'newline'): 134,
144
    ('ymmm', 'R->W'): 24,  ('ymmm', 'W->R'): 38,  ('ymmm', 'newline'): 104,
145
    ('hmmm', 'R->W'): 24,  ('hmmm', 'W->R'): 64,  ('hmmm', 'newline'): 128,
146
    ('lmmm', 'Rd->W'): 24, ('lmmm', 'Rs->Rd'): 32, ('lmmm', 'W->Rs'): 64,
147
    ('lmmm', 'newline'): 128,
148
    ('line', 'R->W'): 24,  ('line', 'W->R'): 88,  ('line', 'newline'): 120,
149
}
150
OPENMSX_SPR_ON = {('lmmm', 'Rs->Rd'): 48, ('hmmm', 'newline'): 134,
151
                  ('lmmm', 'newline'): 134}
152
153
# Best fit under the refined model: one value per step, +1 with sprites on,
154
# +1 when the previous access was in a slot only 6 cycles after another slot
155
FITTED = {
156
    ('hmmv', 'W->W'): 46,  ('hmmv', 'newline'): 104,
157
    ('lmmv', 'R->W'): 24,  ('lmmv', 'W->R'): 72,  ('lmmv', 'newline'): 130,
158
    ('ymmm', 'R->W'): 24,  ('ymmm', 'W->R'): 36,  ('ymmm', 'newline'): 104,
159
    ('hmmm', 'R->W'): 24,  ('hmmm', 'W->R'): 60,  ('hmmm', 'newline'): 128,
160
    ('lmmm', 'Rd->W'): 24, ('lmmm', 'Rs->Rd'): 32, ('lmmm', 'W->Rs'): 60,
161
    ('lmmm', 'newline'): 128,
162
    ('line', 'R->W'): 24,  ('line', 'W->R'): 84,  ('line', 'newline'): 120,
163
}
164
165
# Slots that follow another slot after only 6 cycles.  Only the sprites-off
166
# table has them, 25 of its 88.
167
TIGHT = {m: {s for s in t if (s - 6) in set(t)} for m, t in TABLES.items()}
168
169
# Cycles that sprite rendering adds to every command engine step.  The
170
# measurements admit 1 or 2 and cannot separate them; openMSX uses 1.
171
SPR_ON_EXTRA = 1
172
173
PLAIN = [False]     # --plain: VDP cycles, and no sprites-on adjustment
174
TIGHT_RULE = [True]  # the extra cycle after an access in a 6-cycle-pair slot
175
PUBLISHED = [False]  # --published-slots: use part2/README.md's slot correction
176
177
178
def V(mode, t):
179
    """The engine's own time: absolute cycle minus the stretch seen so far.
180
181
    Deliberately not periodic -- it loses 4 cycles per line -- which is fine
182
    because the engine's counter restarts at every access, so only intervals
183
    matter.
184
    """
185
    if PLAIN[0]:
186
        return t
187
    line, c = divmod(t, TICKS)
188
    pad = PAD[mode]
189
    return t - (sum(n for _, n in pad) * line
190
                + sum(n for a, n in pad if c >= a))
191
192
193
def meta(path):
194
    """scr5-<mode>-<command>-<cpu>[-<variant>...]-<n><suffix>.txt"""
195
    name = os.path.basename(path)
196
    if not name.endswith('.txt'):
197
        return None
198
    parts = name[:-4].split('-')
199
    if len(parts) < 5 or parts[0] != 'scr5' or parts[1] not in TABLES:
200
        return None
201
    if not re.fullmatch(r'\d+[a-z]?', parts[-1]):
202
        return None
203
    return {'mode': parts[1], 'cmd': parts[2], 'cpu': parts[3],
204
            'variant': parts[4:-1], 'name': name}
205
206
207
def parse(path):
208
    """[(absolute cycle, code, address)] in time order, RAS numbering.
209
210
    The files have one row per cycle-within-a-line and one column per display
211
    line, so the absolute CAS cycle is 1368 * column + row, and RAS is one
212
    lower.
213
    """
214
    rel = {} if PUBLISHED[0] else RELABEL[meta(path)['mode']]
215
    items = []
216
    with open(path) as f:
217
        for ln in f:
218
            if ':' not in ln:
219
                continue
220
            row, rest = ln.rstrip('\n').split(':', 1)
221
            t = int(row)
222
            col = 0
223
            while rest:
224
                cell = rest[2:13]
225
                if cell.strip():
226
                    line, c = divmod(TICKS * col + t - 1, TICKS)
227
                    items.append((line * TICKS + rel.get(c, c),
228
                                  cell[0:3], int(cell[6:11], 16)))
229
                rest = rest[13:]
230
                col += 1
231
    items.sort()
232
    return items
233
234
235
def transitions(path):
236
    """Yield (step, prev cycle, next cycle) for one trace."""
237
    info = meta(path)
238
    if info is None or info['cmd'] not in ROLES:
239
        return
240
    roles = ROLES[info['cmd']]
241
    acc = [a for a in parse(path) if a[1] not in CPU_CODES]
242
    # The dummy read shows up as an unclassified read of 0x1ffff, but in a few
243
    # YMMM captures the command's own source pointer walks through the top of
244
    # VRAM, so only drop it when nothing else in the trace is near.
245
    real = any(a[2] != DUMMY_ADDR and abs(a[2] - DUMMY_ADDR) < 0x400
246
               for a in acc)
247
    eng = [a for a in acc if real or (a[1], a[2]) != (DUMMY_CODE, DUMMY_ADDR)]
248
    writes = [i for i, a in enumerate(eng) if a[1][0] == 'W']
249
    groups = []
250
    for a, b in zip(writes, writes[1:]):
251
        grp = eng[a + 1:b + 1]
252
        if len(grp) == len(roles) and \
253
                tuple(x[1][0] for x in grp) == tuple(r[0] for r in roles):
254
            groups.append(grp)
255
    for gi, grp in enumerate(groups):
256
        for i in range(len(grp) - 1):
257
            yield f'{roles[i]}->{roles[i + 1]}', grp[i][0], grp[i + 1][0]
258
        if gi + 1 < len(groups):
259
            nxt = groups[gi + 1]
260
            if (nxt[-1][2] // VRAM_LINE) == (grp[-1][2] // VRAM_LINE):
261
                yield f'{roles[-1]}->{roles[0]}', grp[-1][0], nxt[0][0]
262
            elif 0 < nxt[-1][2] - grp[-1][2] <= 2 * VRAM_LINE:
263
                yield 'newline', grp[-1][0], nxt[0][0]
264
            # else: the command restarted, no useful constraint
265
266
267
def next_slot(mode, p, delta, taken=frozenset()):
268
    """First slot at least 'delta' engine cycles after p and not taken by the
269
    CPU (which has priority)."""
270
    tab = table(mode)
271
    line = p // TICKS
272
    for k in range(4):
273
        for s in tab:
274
            t = (line + k) * TICKS + s
275
            if t > p and V(mode, t) - V(mode, p) >= delta and t not in taken:
276
                return t
277
    raise AssertionError('no slot found')
278
279
280
def prev_slot(mode, n):
281
    tab = table(mode)
282
    line, off = divmod(n, TICKS)
283
    earlier = [s for s in tab if s < off]
284
    return (line * TICKS + earlier[-1] if earlier
285
            else (line - 1) * TICKS + tab[-1])
286
287
288
def delta_for(table, spron, key, mode, prev=None):
289
    """Delay for one command step.
290
291
    A step that starts from a slot which itself follows another slot after only
292
    6 cycles takes one cycle longer -- as if that memory cycle, squeezed against
293
    its predecessor, finished one cycle late.  Such slots exist only in the
294
    sprites-off table (25 of its 88), which is why the effect shows up in that
295
    mode alone.  1778 transitions in the 2026 set start from one; the rule is
296
    needed by 32 of them and, at +3 or more, contradicted by 1281.
297
    """
298
    if (TIGHT_RULE[0] and prev is not None and
299
            (prev % TICKS) in TIGHT[mode] and not PLAIN[0]):
300
        return delta_for(table, spron, key, mode) + 1
301
    if mode == 'sprOn':
302
        if key in spron:
303
            return spron[key]
304
        return table[key] + (0 if PLAIN[0] else SPR_ON_EXTRA)
305
    return table[key]
306
307
308
def bands(files):
309
    out = collections.defaultdict(lambda: (1, 400))
310
    n = collections.Counter()
311
    for f in files:
312
        mode = meta(f)['mode']
313
        cmd = meta(f)['cmd']
314
        for step, p, nx in transitions(f):
315
            key = (cmd, step, mode)
316
            lo = V(mode, prev_slot(mode, nx)) - V(mode, p) + 1
317
            hi = V(mode, nx) - V(mode, p)
318
            a, b = out[key]
319
            out[key] = (max(a, lo), min(b, hi))
320
            n[key] += 1
321
    return out, n
322
323
324
def count_misses(files, table, spron, cpu_traces=False, plain=None):
325
    saved = PLAIN[0]
326
    if plain is not None:
327
        PLAIN[0] = plain
328
    tot = bad = contended = 0
329
    per = collections.Counter()
330
    for f in files:
331
        info = meta(f)
332
        # The CPU takes slots away from the engine.  So does the dummy read
333
        # the VDP does when the CPU's request wins a 6-cycle-pair slot: it
334
        # cannot carry the CPU access, so that memory cycle is spent on a read
335
        # of 0x1ffff and the CPU's access happens in the next slot.
336
        taken = {a[0] for a in parse(f)
337
                 if a[1] in CPU_CODES
338
                 or ((a[1], a[2]) == (DUMMY_CODE, DUMMY_ADDR)
339
                     and (a[0] % TICKS) in TIGHT[info['mode']])} \
340
            if cpu_traces else frozenset()
341
        for step, p, nx in transitions(f):
342
            key = (info['cmd'], step)
343
            d = delta_for(table, spron, key, info['mode'], p)
344
            first = next_slot(info['mode'], p, d)
345
            got = next_slot(info['mode'], p, d, taken)
346
            tot += 1
347
            contended += (got != first)
348
            if got != nx:
349
                bad += 1
350
                per[key + (info['mode'],)] += 1
351
    PLAIN[0] = saved
352
    return tot, bad, contended, per
353
354
355
def main():
356
    ap = argparse.ArgumentParser(description=__doc__)
357
    ap.add_argument('slots_dir')
358
    ap.add_argument('--cpu', action='store_true',
359
                    help='use the rdCpu/wrCpu traces and test the arbitration')
360
    ap.add_argument('--plain', action='store_true',
361
                    help='no memory-cycle stretch and no sprites-on +1')
362
    ap.add_argument('--published-slots', action='store_true',
363
                    help="use part2/README.md's one-cycle slot correction")
364
    ap.add_argument('--no-tight', action='store_true',
365
                    help='drop the extra cycle after a 6-cycle-pair slot')
366
    args = ap.parse_args()
367
    PLAIN[0] = args.plain
368
    PUBLISHED[0] = args.published_slots
369
    TIGHT_RULE[0] = not args.no_tight
370
371
    files = [f for f in sorted(glob.glob(os.path.join(args.slots_dir,
372
                                                      'scr5-*.txt')))
373
             if meta(f) and '-wrong-' not in f]
374
    files = [f for f in files
375
             if (meta(f)['cpu'] != 'noCpu') == bool(args.cpu)]
376
    if not files:
377
        sys.exit(f'no traces found in {args.slots_dir}')
378
379
    if args.cpu:
380
        for label, tbl, spron, pl in (
381
                ('openMSX today', OPENMSX, OPENMSX_SPR_ON, True),
382
                ('fitted model', FITTED, {}, False)):
383
            tot, bad, cont, _ = count_misses(files, tbl, spron, True, pl)
384
            print(f'{len(files)} traces with CPU activity, {tot} engine '
385
                  f'transitions, the CPU took the slot the engine wanted in '
386
                  f'{100.0 * cont / tot:.1f}% of them')
387
            print(f'    {label:16}: {bad} mispredicted '
388
                  f'({100.0 * bad / tot:.3f}%)')
389
        return
390
391
    b, n = bands(files)
392
    print(f'{len(files)} traces, {sum(n.values())} engine-to-engine transitions'
393
          + ('   [--plain: VDP cycles]' if args.plain else ''))
394
    print()
395
    print(f'{"cmd":5} {"step":9} {"dispOff":>12} {"sprOff":>12} {"sprOn":>12}'
396
          f'   all modes')
397
    print('-' * 72)
398
    for cmd, step in sorted({(k[0], k[1]) for k in b}):
399
        cells, lo, hi = [], 1, 400
400
        for mode in ('dispOff', 'sprOff', 'sprOn'):
401
            k = (cmd, step, mode)
402
            if k not in b:
403
                cells.append('-')
404
                continue
405
            a, z = b[k]
406
            cells.append(f'[{a},{z}]' + ('!' if a > z else ''))
407
            if mode == 'sprOn' and not args.plain:
408
                a, z = a - SPR_ON_EXTRA, z - SPR_ON_EXTRA
409
            lo, hi = max(lo, a), min(hi, z)
410
        allb = f'[{lo},{hi}]' + ('   no single value' if lo > hi else '')
411
        print(f'{cmd:5} {step:9} ' + ' '.join(f'{c:>12}' for c in cells)
412
              + f'   {allb}')
413
    print()
414
    for label, tbl, spron, pl in (
415
            ('openMSX today', OPENMSX, OPENMSX_SPR_ON, True),
416
            ('fitted model', FITTED, {}, args.plain)):
417
        tot, bad, _, per = count_misses(files, tbl, spron, plain=pl)
418
        print(f'{label:16}: {bad} of {tot} mispredicted '
419
              f'({100.0 * bad / tot:.3f}%)')
420
        for k, v in sorted(per.items(), key=lambda kv: -kv[1])[:4]:
421
            print(f'    {k[0]:5} {k[1]:9} {k[2]:8} {v}')
422
423
424
if __name__ == '__main__':
425
    main()