OXYGENHUB
◆ main openmsx / doc / internal / vdp-vram-timing / vcd-slot-grid.py
12.4 KiB · Python · last changed chore(): initial commit 1 month ago source blame history raw
1
#!/usr/bin/env python3
2
"""Measure the VDP's VRAM access-slot grid from the raw logic-analyzer captures.
3
4
Usage:
5
    vcd-slot-grid.py <measurement-repo>/part2 [mode] [max-captures]
6
7
The published analysis in the measurement repository derives access times by
8
interpolating between the eight DRAM refresh accesses of a line.  This script
9
does not need that: /RAS pulses once per potential access slot whether or not
10
the VDP uses the slot, and its pattern repeats every display line, so the RAS
11
edges themselves are a time base with ~166 anchors per line instead of 8.
12
13
  1. Line period: exactly one gap per line is larger than all the others, so
14
     the sample distance between successive occurrences of that gap is the line
15
     period.  Fitted over the whole capture.
16
  2. Numbering: the refresh accesses are 128 cycles apart with the row address
17
     incrementing by one, and between the last of one line and the first of the
18
     next there are 472 cycles, so the chain has a unique start.  That one sits
19
     at cycle 284 in openMSX's numbering -- the slot openMSX omits between 276
20
     and 292.
21
  3. Each slot is then averaged over every line of every capture.  Sampling
22
     jitter is +-1 sample = +-0.27 cycles per edge, so a few hundred edges put
23
     the mean well inside a tenth of a cycle.
24
25
Cycle numbers printed are openMSX's, which are based on /RAS.  The .txt files
26
in the measurement repository are based on /CAS and are one higher.
27
"""
28
from __future__ import annotations
29
30
import collections
31
import glob
32
import os
33
import statistics
34
import sys
35
36
TICKS = 1368
37
SAMPLE = 125          # 100 ps units per sample at a nominal 80 MHz
38
REFRESH_CYCLE = 284   # openMSX numbering
39
SIGNALS = {'!': 'A0', '"': 'A1', '#': 'A2', '$': 'A3', '%': 'A4', '&': 'A5',
40
           "'": 'A6', '(': 'A7', ')': 'RAS', '*': 'CAS0', '+': 'CAS1',
41
           ',': 'RW', '-': 'VDS', '.': 'IRQ', '/': 'CSW', '0': 'CSR'}
42
43
# src/video/VDPAccessSlots.cc
44
OPENMSX = {
45
    'dispOff': [
46
           0,    8,   16,   24,   32,   40,   48,   56,   64,   72,
47
          80,   88,   96,  104,  112,  120,  164,  172,  180,  188,
48
         196,  204,  212,  220,  228,  236,  244,  252,  260,  268,
49
         276,  292,  300,  308,  316,  324,  332,  340,  348,  356,
50
         364,  372,  380,  388,  396,  404,  420,  428,  436,  444,
51
         452,  460,  468,  476,  484,  492,  500,  508,  516,  524,
52
         532,  548,  556,  564,  572,  580,  588,  596,  604,  612,
53
         620,  628,  636,  644,  652,  660,  676,  684,  692,  700,
54
         708,  716,  724,  732,  740,  748,  756,  764,  772,  780,
55
         788,  804,  812,  820,  828,  836,  844,  852,  860,  868,
56
         876,  884,  892,  900,  908,  916,  932,  940,  948,  956,
57
         964,  972,  980,  988,  996, 1004, 1012, 1020, 1028, 1036,
58
        1044, 1060, 1068, 1076, 1084, 1092, 1100, 1108, 1116, 1124,
59
        1132, 1140, 1148, 1156, 1164, 1172, 1188, 1196, 1204, 1212,
60
        1220, 1228, 1268, 1276, 1284, 1292, 1300, 1308, 1316, 1324,
61
        1334, 1344, 1352, 1360],
62
    'sprOff': [
63
           6,   14,   22,   30,   38,   46,   54,   62,   70,   78,
64
          86,   94,  102,  110,  118,  162,  170,  182,  188,  214,
65
         220,  246,  252,  278,  310,  316,  342,  348,  374,  380,
66
         406,  438,  444,  470,  476,  502,  508,  534,  566,  572,
67
         598,  604,  630,  636,  662,  694,  700,  726,  732,  758,
68
         764,  790,  822,  828,  854,  860,  886,  892,  918,  950,
69
         956,  982,  988, 1014, 1020, 1046, 1078, 1084, 1110, 1116,
70
        1142, 1148, 1174, 1206, 1212, 1266, 1274, 1282, 1290, 1298,
71
        1306, 1314, 1322, 1332, 1342, 1350, 1358, 1366],
72
    'sprOn': [
73
          28,   92,  162,  170,  188,  220,  252,  316,  348,  380,
74
         444,  476,  508,  572,  604,  636,  700,  732,  764,  828,
75
         860,  892,  956,  988, 1020, 1084, 1116, 1148, 1212, 1264,
76
        1330],
77
}
78
79
80
# --------------------------------------------------------------------------
81
# .vcd decoding
82
# --------------------------------------------------------------------------
83
def parse_vcd(path):
84
    state = {n: None for n in SIGNALS.values()}
85
    out = []
86
    body = False
87
    with open(path) as f:
88
        for ln in f:
89
            ln = ln.strip()
90
            if not body:
91
                body = ln == '$enddefinitions $end'
92
                continue
93
            if not ln.startswith('#'):
94
                continue
95
            parts = ln.split()
96
            for p in parts[1:]:
97
                state[SIGNALS[p[1:]]] = int(p[0])
98
            out.append((int(parts[0][1:]) // SAMPLE, dict(state)))
99
    return out
100
101
102
def ras_edges(trace):
103
    out, prev = [], None
104
    for t, s in trace:
105
        if prev == 1 and s['RAS'] == 0:
106
            out.append(t)
107
        prev = s['RAS']
108
    return out
109
110
111
def accesses(trace):
112
    """(cas_sample, ras_sample, address, is_read) per VRAM access.
113
114
    Row address on A7..A0 at the falling /RAS, column address at the falling
115
    /CAS0 or /CAS1; which of the two also selects the 64kB bank.
116
    """
117
    out = []
118
    ras_t = row = None
119
    prev = {'RAS': None, 'CAS0': None, 'CAS1': None}
120
    for t, s in trace:
121
        if prev['RAS'] == 1 and s['RAS'] == 0:
122
            ras_t, row = t, sum(s[f'A{i}'] << i for i in range(8))
123
        for bank, cas in ((0, 'CAS0'), (1, 'CAS1')):
124
            if prev[cas] == 1 and s[cas] == 0 and row is not None:
125
                col = sum(s[f'A{i}'] << i for i in range(8))
126
                out.append((t, ras_t, (bank << 16) | (row << 8) | col,
127
                            s['RW'] == 1))
128
        for k in prev:
129
            prev[k] = s[k]
130
    return out
131
132
133
# --------------------------------------------------------------------------
134
# grid fit
135
# --------------------------------------------------------------------------
136
def geometry(edges):
137
    """(line period in samples, marks) from the unique largest gap per line."""
138
    gaps = [b - a for a, b in zip(edges, edges[1:])]
139
    if not gaps:
140
        return None
141
    mx = max(gaps)
142
    marks = [i + 1 for i, g in enumerate(gaps) if g > mx * 0.9]
143
    if len(marks) < 3:
144
        return None
145
    return (edges[marks[-1]] - edges[marks[0]]) / (len(marks) - 1), marks
146
147
148
def refresh_chain_start(acc, cyc, strict=True):
149
    """RAS sample of the first refresh access of a line, or None.
150
151
    Refresh accesses are 128 cycles apart with the row address incrementing by
152
    one and the bank alternating; the gap before the first of a line is 472.
153
154
    Row and bank alone are not enough: in screen 5 a bitmap fetch can look like
155
    the next link of the chain, and a single false link moves the anchor by a
156
    refresh cell or by a whole line.  Refresh accesses are single /CAS, where a
157
    bitmap fetch is a burst, which rules those out.  (The column address is
158
    *not* constant along the chain -- it carries when the row counter wraps --
159
    so it cannot be used as a test.)  A handful of captures have no chain that
160
    passes, and fall back to the loose rule.
161
    """
162
    per = collections.Counter(a[1] for a in acc)
163
    ts = sorted(acc, key=lambda a: a[1])
164
    ref = set()
165
    for i, a in enumerate(ts):
166
        if strict and per[a[1]] != 1:
167
            continue
168
        for b in ts[i + 1:]:
169
            d = (b[1] - a[1]) * cyc
170
            if d > 131:
171
                break
172
            if strict and per[b[1]] != 1:
173
                continue
174
            if abs(d - 128) < 2.5 and \
175
               ((b[2] >> 8) & 0xFF) == (((a[2] >> 8) & 0xFF) + 1) & 0xFF and \
176
               (b[2] >> 16) != (a[2] >> 16):
177
                ref.add(a[1])
178
                ref.add(b[1])
179
    r = sorted(ref)
180
    starts = [t for i, t in enumerate(r)
181
              if i and (t - r[i - 1]) * cyc > 200]
182
    if not starts and strict:
183
        return refresh_chain_start(acc, cyc, strict=False)
184
    return (starts[0], ref) if starts else (None, ref)
185
186
187
def measure(vcd_dir, mode, limit=None):
188
    paths = sorted(glob.glob(os.path.join(vcd_dir, f'scr5-{mode}-*.vcd')))
189
    if limit:
190
        paths = paths[:limit]
191
    hist, periods, used = [], [], 0
192
    for p in paths:
193
        trace = parse_vcd(p)
194
        edges = ras_edges(trace)
195
        g = geometry(edges)
196
        if g is None:
197
            continue
198
        period, _ = g
199
        cyc = TICKS / period
200
        anchor, _ = refresh_chain_start(accesses(trace), cyc)
201
        if anchor is None:
202
            continue
203
        periods.append(period)
204
        used += 1
205
        hist.extend(((e - anchor) * cyc + REFRESH_CYCLE) % TICKS for e in edges)
206
    if not used:
207
        return None
208
    return hist, used, len(paths), statistics.fmean(periods)
209
210
211
def slots_near(hist, target, window=2.0):
212
    """Mean position of the RAS edges near 'target', unwrapped around it."""
213
    v = []
214
    for x in hist:
215
        d = ((x - target + TICKS / 2) % TICKS) - TICKS / 2
216
        if abs(d) < window:
217
            v.append(target + d)
218
    if not v:
219
        return None
220
    return statistics.fmean(v), (statistics.stdev(v) if len(v) > 1 else 0.0), len(v)
221
222
223
def cluster(hist, width=3.0):
224
    hist = sorted(hist)
225
    groups, cur = [], [hist[0]]
226
    for x in hist[1:]:
227
        if x - cur[-1] < width:
228
            cur.append(x)
229
        else:
230
            groups.append(cur)
231
            cur = [x]
232
    groups.append(cur)
233
    if groups[0][0] + TICKS - groups[-1][-1] < width:
234
        groups[0] = [x - TICKS for x in groups.pop()] + groups[0]
235
    return sorted((statistics.fmean(g) % TICKS, len(g)) for g in groups)
236
237
238
# --------------------------------------------------------------------------
239
def check_published(txt_dir, mode, hist, tol=0.5):
240
    """How many published access positions do not sit on a measured slot?
241
242
    A position is accepted when the mean of the RAS edges around it is within
243
    'tol' cycles of it; being one cycle off is a mismatch.
244
    """
245
    on_grid = {}
246
247
    def ok(c):
248
        if c not in on_grid:
249
            m = slots_near(hist, c, window=2.5)
250
            on_grid[c] = m is not None and abs(m[0] - c) <= tol
251
        return on_grid[c]
252
253
    bad, tot = collections.Counter(), 0
254
    for path in sorted(glob.glob(os.path.join(txt_dir, f'scr5-{mode}-*.txt'))):
255
        with open(path) as f:
256
            for ln in f:
257
                if ':' not in ln:
258
                    continue
259
                t = int(ln[:ln.index(':')])
260
                rest = ln[ln.index(':') + 1:]
261
                col = 0
262
                while rest:
263
                    if rest[2:13].strip():
264
                        tot += 1
265
                        c = (TICKS * col + t - 1) % TICKS   # CAS -> RAS
266
                        if not ok(c):
267
                            bad[c] += 1
268
                    rest = rest[13:]
269
                    col += 1
270
    return tot, bad
271
272
273
def main():
274
    if len(sys.argv) < 2:
275
        sys.exit(__doc__)
276
    part2 = sys.argv[1]
277
    modes = [sys.argv[2]] if len(sys.argv) > 2 else ['dispOff', 'sprOff', 'sprOn']
278
    limit = int(sys.argv[3]) if len(sys.argv) > 3 else 40
279
    for mode in modes:
280
        r = measure(os.path.join(part2, '1.vcd'), mode, limit)
281
        if r is None:
282
            print(f'{mode}: no usable captures')
283
            continue
284
        hist, used, tot, period = r
285
        print(f'=== {mode}: {used}/{tot} captures, line period {period:.3f} '
286
              f'samples, {len(hist)} RAS edges ===')
287
        cl = cluster(hist)
288
        med = statistics.median([n for _, n in cl])
289
        cl = [c for c in cl if c[1] > med * 0.15]
290
        pos = [round(c[0]) % TICKS for c in cl]
291
        gaps = collections.Counter((pos[(i + 1) % len(pos)] - p) % TICKS
292
                                  for i, p in enumerate(pos))
293
        print(f'  {len(pos)} slots per line; gaps ' +
294
              ' '.join(f'{k}x{v}' for k, v in sorted(gaps.items())) +
295
              f'  (sum {sum(k * v for k, v in gaps.items())})')
296
        # every openMSX slot, checked individually
297
        worst = (0, None)
298
        for s in OPENMSX[mode]:
299
            m = slots_near(hist, s)
300
            if m is None:
301
                print(f'  !! openMSX slot {s} has no RAS edge within 2 cycles')
302
                continue
303
            d = abs(m[0] - s)
304
            if d > worst[0]:
305
                worst = (d, (s, m))
306
        print(f'  all {len(OPENMSX[mode])} openMSX slots confirmed; worst '
307
              f'deviation {worst[0]:.3f} cycles'
308
              + (f' (slot {worst[1][0]}: measured {worst[1][1][0]:.3f}, '
309
                 f'sd {worst[1][1][1]:.2f}, n {worst[1][1][2]})'
310
                 if worst[1] else ''))
311
        n, bad = check_published(os.path.join(part2, '5.slots'), mode, hist)
312
        nb = sum(bad.values())
313
        print(f'  published .txt accesses off the measured grid: {nb} of {n} '
314
              f'({100.0 * nb / max(n, 1):.2f}%)')
315
        for c, k in bad.most_common(6):
316
            print(f'     RAS {c} (CAS {c + 1}) x{k}')
317
318
319
if __name__ == '__main__':
320
    main()