OXYGENHUB
◆ main enhanced-x16-emulator / testbench / test_dap.py
31.5 KiB · Python · last changed chore(): initial commit 25 days ago source blame history raw
1
import socket, json, time, sys, base64
2
3
# The port is an argument so a run can dodge whatever else on the machine has
4
# already claimed 9009 -- a stray emulator or an editor's DAP client connecting
5
# to this one will otherwise hijack the session halfway through the suite.
6
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 9009
7
8
9
def send_dap(sock, msg):
10
    body = json.dumps(msg)
11
    header = "Content-Length: %d\r\n\r\n" % len(body)
12
    sock.sendall((header + body).encode())
13
14
def recv_dap(sock, timeout=3):
15
    sock.settimeout(timeout)
16
    data = b''
17
    results = []
18
    try:
19
        while True:
20
            chunk = sock.recv(4096)
21
            if not chunk:
22
                break
23
            data += chunk
24
            while b'\r\n\r\n' in data:
25
                header_end = data.index(b'\r\n\r\n')
26
                header = data[:header_end].decode()
27
                for line in header.split('\r\n'):
28
                    if line.startswith('Content-Length:'):
29
                        cl = int(line.split(':')[1].strip())
30
                        break
31
                body_start = header_end + 4
32
                if len(data) >= body_start + cl:
33
                    body = data[body_start:body_start + cl].decode()
34
                    data = data[body_start + cl:]
35
                    results.append(json.loads(body))
36
                else:
37
                    break
38
    except socket.timeout:
39
        pass
40
    return results
41
42
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
43
try:
44
    sock.connect(('127.0.0.1', PORT))
45
except ConnectionRefusedError:
46
    print("ERROR: Cannot connect to DAP server on port %d. Is emulator running with -debugport?" % PORT)
47
    sys.exit(1)
48
print("Connected to DAP server on port %d" % PORT)
49
50
passed = 0
51
failed = 0
52
53
def check(name, msgs, expect_type=None, expect_cmd=None, expect_success=True):
54
    global passed, failed
55
    if not msgs:
56
        print("  FAIL %s: no response" % name)
57
        failed += 1
58
        return None
59
    for m in msgs:
60
        mtype = m.get("type", "")
61
        mcmd = m.get("command", m.get("event", ""))
62
        ok = True
63
        if expect_type and mtype != expect_type:
64
            ok = False
65
        if expect_cmd and mcmd != expect_cmd:
66
            ok = False
67
        if expect_type == "response" and expect_success is not None:
68
            if m.get("success") != expect_success:
69
                ok = False
70
        if ok:
71
            print("  PASS %s" % name)
72
            passed += 1
73
            return m
74
    print("  FAIL %s: unexpected response: %s" % (name, json.dumps(msgs[0])[:100]))
75
    failed += 1
76
    return msgs[0]
77
78
# 1. Initialize
79
send_dap(sock, {"seq": 1, "type": "request", "command": "initialize", "arguments": {"adapterID": "test"}})
80
time.sleep(0.5)
81
msgs = recv_dap(sock, 1)
82
m = check("initialize response", [m for m in msgs if m.get("type") == "response"], "response", "initialize")
83
if m and m.get("body", {}).get("supportsConfigurationDoneRequest"):
84
    print("       Capabilities OK")
85
check("initialized event", [m for m in msgs if m.get("event") == "initialized"], "event", "initialized")
86
87
# 2. Launch
88
send_dap(sock, {"seq": 2, "type": "request", "command": "launch", "arguments": {"stopOnEntry": True}})
89
time.sleep(0.5)
90
msgs = recv_dap(sock, 1)
91
check("launch response", [m for m in msgs if m.get("type") == "response"], "response", "launch")
92
check("thread event", [m for m in msgs if m.get("event") == "thread"], "event", "thread")
93
94
# 3. ConfigurationDone
95
send_dap(sock, {"seq": 3, "type": "request", "command": "configurationDone", "arguments": {}})
96
time.sleep(0.5)
97
msgs = recv_dap(sock, 1)
98
check("configDone response", [m for m in msgs if m.get("type") == "response"], "response", "configurationDone")
99
check("stopped event (entry)", [m for m in msgs if m.get("event") == "stopped"], "event", "stopped")
100
101
# 4. Threads
102
send_dap(sock, {"seq": 4, "type": "request", "command": "threads"})
103
time.sleep(0.3)
104
msgs = recv_dap(sock, 1)
105
m = check("threads response", msgs, "response", "threads")
106
if m:
107
    threads = m.get("body", {}).get("threads", [])
108
    if len(threads) == 1 and threads[0].get("name") == "6502 CPU":
109
        print("       Thread: %s (id=%d)" % (threads[0]["name"], threads[0]["id"]))
110
111
# 5. StackTrace
112
send_dap(sock, {"seq": 5, "type": "request", "command": "stackTrace", "arguments": {"threadId": 1}})
113
time.sleep(0.3)
114
msgs = recv_dap(sock, 1)
115
m = check("stackTrace response", msgs, "response", "stackTrace")
116
if m:
117
    frames = m.get("body", {}).get("stackFrames", [])
118
    if frames:
119
        print("       Frame: %s" % frames[0].get("name", "?"))
120
121
# 6. Scopes
122
send_dap(sock, {"seq": 6, "type": "request", "command": "scopes", "arguments": {"frameId": 1}})
123
time.sleep(0.3)
124
msgs = recv_dap(sock, 1)
125
m = check("scopes response", msgs, "response", "scopes")
126
if m:
127
    scopes = m.get("body", {}).get("scopes", [])
128
    print("       Scopes: %s" % ", ".join(s["name"] for s in scopes))
129
130
# 7. Variables (registers)
131
send_dap(sock, {"seq": 7, "type": "request", "command": "variables", "arguments": {"variablesReference": 1}})
132
time.sleep(0.3)
133
msgs = recv_dap(sock, 1)
134
m = check("variables (registers)", msgs, "response", "variables")
135
if m:
136
    vlist = m.get("body", {}).get("variables", [])
137
    for v in vlist[:3]:
138
        print("       %s = %s" % (v["name"], v["value"]))
139
140
# 8. StepIn
141
send_dap(sock, {"seq": 8, "type": "request", "command": "stepIn", "arguments": {"threadId": 1}})
142
time.sleep(0.5)
143
msgs = recv_dap(sock, 1)
144
check("stepIn response", [m for m in msgs if m.get("type") == "response"], "response", "stepIn")
145
check("stopped event (step)", [m for m in msgs if m.get("event") == "stopped"], "event", "stopped")
146
147
# 9. Evaluate
148
send_dap(sock, {"seq": 9, "type": "request", "command": "evaluate", "arguments": {"expression": "PC"}})
149
time.sleep(0.3)
150
msgs = recv_dap(sock, 1)
151
m = check("evaluate (PC)", msgs, "response", "evaluate")
152
if m:
153
    print("       PC = %s" % m.get("body", {}).get("result", "?"))
154
155
# 10. Evaluate memory
156
send_dap(sock, {"seq": 10, "type": "request", "command": "evaluate", "arguments": {"expression": "$0000"}})
157
time.sleep(0.3)
158
msgs = recv_dap(sock, 1)
159
m = check("evaluate ($0000)", msgs, "response", "evaluate")
160
if m:
161
    print("       [$0000] = %s" % m.get("body", {}).get("result", "?"))
162
163
# 11. setExceptionBreakpoints (VS always sends this)
164
send_dap(sock, {"seq": 11, "type": "request", "command": "setExceptionBreakpoints", "arguments": {"filters": []}})
165
time.sleep(0.3)
166
msgs = recv_dap(sock, 1)
167
check("setExceptionBreakpoints", msgs, "response", "setExceptionBreakpoints")
168
169
# 12. Pipelined requests in one TCP segment.
170
#
171
# A client sends its configuration burst without waiting for each response, so
172
# several framed messages routinely arrive in a single read. The framing used to
173
# NUL-terminate each body in place, which overwrote the first byte of the next
174
# message; the connection then stopped dispatching entirely. Nothing above can
175
# catch that, because every request here is sent on its own with a sleep after
176
# it.
177
body_a = json.dumps({"seq": 90, "type": "request", "command": "threads"})
178
body_b = json.dumps({"seq": 91, "type": "request", "command": "evaluate",
179
                     "arguments": {"expression": "PC"}})
180
blob = ("Content-Length: %d\r\n\r\n%s" % (len(body_a), body_a) +
181
        "Content-Length: %d\r\n\r\n%s" % (len(body_b), body_b))
182
sock.sendall(blob.encode())
183
time.sleep(0.5)
184
msgs = recv_dap(sock, 2)
185
seqs = sorted(m.get("request_seq") for m in msgs if m.get("type") == "response")
186
if seqs == [90, 91]:
187
    print("  PASS both pipelined requests answered")
188
    passed += 1
189
else:
190
    print("  FAIL both pipelined requests answered: got %r" % (seqs,))
191
    failed += 1
192
193
# A third request after the burst proves the buffer is still usable.
194
send_dap(sock, {"seq": 92, "type": "request", "command": "threads"})
195
time.sleep(0.3)
196
msgs = recv_dap(sock, 1)
197
check("connection still live after pipelining", msgs, "response", "threads")
198
199
# 13. x16/joystick: hold, read back, release.
200
#
201
# The only way a script can drive a game that reads the SNES ports. The state is
202
# level-based rather than edge-based -- the button list is the complete held set
203
# -- so what matters is that a press survives being read, that a button left off
204
# the list is released, and that the port hands itself back when the session is
205
# done with it. None of that is visible without a running emulator, because the
206
# port is only enabled and only latched by the machine.
207
208
def joystick(seq, args, pause=0.3):
209
    send_dap(sock, {"seq": seq, "type": "request", "command": "x16/joystick",
210
                    "arguments": args})
211
    time.sleep(pause)
212
    for m in recv_dap(sock, 1):
213
        if m.get("type") == "response" and m.get("command") == "x16/joystick":
214
            return m
215
    return None
216
217
218
def check_joystick(name, resp, want):
219
    global passed, failed
220
    if resp is None or not resp.get("success"):
221
        print("  FAIL %s: no successful response" % name)
222
        failed += 1
223
        return None
224
    body = resp.get("body", {})
225
    for key, expected in want.items():
226
        got = body.get(key)
227
        if key == "buttons":
228
            got = sorted(got or [])
229
            expected = sorted(expected)
230
        if got != expected:
231
            print("  FAIL %s: %s was %r, expected %r" % (name, key, got, expected))
232
            failed += 1
233
            return body
234
    print("  PASS %s" % name)
235
    passed += 1
236
    return body
237
238
239
# Active low, so a held button is a cleared bit: A is bit 0, and the latch forces
240
# bits 12-15 high.
241
check_joystick("x16/joystick holds a button",
242
               joystick(93, {"index": 0, "buttons": ["a"]}),
243
               {"index": 0, "mask": 0xFFFE, "buttons": ["a"], "enabled": True,
244
                "slotEnabled": True})
245
246
# Driving a port must not need -joy1..-joy4, or headless automation could not
247
# use it at all.
248
check_joystick("x16/joystick enables the port it drives",
249
               joystick(94, {"index": 2, "buttons": ["left", "b"]}),
250
               {"index": 2, "slotEnabled": True, "enabled": True,
251
                "buttons": ["left", "b"]})
252
253
# No buttons and no mask is a query: it must report the held state without
254
# disturbing it, which is what lets a test assert what it just pressed.
255
check_joystick("x16/joystick queries without changing state",
256
               joystick(95, {"index": 0}),
257
               {"index": 0, "mask": 0xFFFE, "buttons": ["a"], "enabled": True})
258
259
# The list is the whole held set, so a button left off it is released.
260
check_joystick("x16/joystick releases buttons left off the list",
261
               joystick(96, {"index": 0, "buttons": ["start"]}),
262
               {"index": 0, "buttons": ["start"], "enabled": True})
263
264
# A raw mask replays a captured state verbatim.
265
check_joystick("x16/joystick accepts a raw mask",
266
               joystick(97, {"index": 1, "mask": 0xFFFF & ~(1 << 4)}),
267
               {"index": 1, "buttons": ["up"], "enabled": True})
268
269
check_joystick("x16/joystick releases the port",
270
               joystick(98, {"index": 0, "enabled": False}),
271
               {"index": 0, "mask": 0xFFFF, "buttons": [], "enabled": False})
272
273
# enabled:true on its own connects a controller with nothing held. That is not
274
# the same as an empty port -- a driven port runs its shift register out, so the
275
# KERNAL sees a controller -- and it is what a game waiting for one needs.
276
check_joystick("x16/joystick connects a controller with nothing held",
277
               joystick(99, {"index": 0, "enabled": True}),
278
               {"index": 0, "mask": 0xFFFF, "buttons": [], "enabled": True,
279
                "slotEnabled": True})
280
281
# Leave the machine as it was found, so anything running after this is not
282
# holding a direction it never asked for.
283
for _slot in (0, 1, 2):
284
    joystick(100 + _slot, {"index": _slot, "enabled": False})
285
286
m = joystick(102, {"index": 9, "buttons": ["a"]})
287
if m is not None and not m.get("success"):
288
    print("  PASS x16/joystick rejects a port that does not exist")
289
    passed += 1
290
else:
291
    print("  FAIL x16/joystick rejects a port that does not exist")
292
    failed += 1
293
294
m = joystick(103, {"index": 0, "buttons": ["turbo"]})
295
if m is not None and not m.get("success"):
296
    print("  PASS x16/joystick rejects an unknown button")
297
    passed += 1
298
else:
299
    print("  FAIL x16/joystick rejects an unknown button")
300
    failed += 1
301
302
# 14. readMemory/writeMemory against VRAM.
303
#
304
# VRAM is not in the CPU map -- the CPU reaches it only through VERA's data
305
# port, one auto-incrementing byte at a time -- so a "vram:" reference is the
306
# only way a DAP client can read or write it. Nothing here is checkable from the
307
# unit tests: it needs a real VERA with a real framebuffer behind it.
308
309
def read_mem(seq, ref, count):
310
    send_dap(sock, {"seq": seq, "type": "request", "command": "readMemory",
311
                    "arguments": {"memoryReference": ref, "count": count}})
312
    time.sleep(0.3)
313
    for m in recv_dap(sock, 1):
314
        if m.get("type") == "response" and m.get("command") == "readMemory":
315
            return m
316
    return None
317
318
319
def write_mem(seq, ref, data):
320
    send_dap(sock, {"seq": seq, "type": "request", "command": "writeMemory",
321
                    "arguments": {"memoryReference": ref,
322
                                  "data": base64.b64encode(bytes(data)).decode()}})
323
    time.sleep(0.3)
324
    for m in recv_dap(sock, 1):
325
        if m.get("type") == "response" and m.get("command") == "writeMemory":
326
            return m
327
    return None
328
329
330
def mem_bytes(resp):
331
    if not resp or not resp.get("success"):
332
        return None
333
    return base64.b64decode(resp.get("body", {}).get("data", ""))
334
335
336
def expect(name, cond):
337
    global passed, failed
338
    if cond:
339
        print("  PASS %s" % name)
340
        passed += 1
341
    else:
342
        print("  FAIL %s" % name)
343
        failed += 1
344
345
346
# The same number means different memory in each space, which is the whole
347
# reason the prefix has to exist.
348
cpu = mem_bytes(read_mem(110, "0x1B000", 16))
349
vram = mem_bytes(read_mem(111, "vram:1B000", 16))
350
expect("readMemory reads VRAM", vram is not None and len(vram) == 16)
351
expect("VRAM and the same CPU address are different memory", cpu is not None and vram != cpu)
352
353
# The echoed address has to come back in the space it was read from, or paging
354
# through VRAM by feeding it back would silently land in the CPU map.
355
r = read_mem(112, "vram:1B000", 4)
356
expect("the echoed address keeps the vram: prefix",
357
       r is not None and r.get("body", {}).get("address", "").lower().startswith("vram:"))
358
359
# A round trip through the write path. $1F9C0 is in the sprite attribute area,
360
# well clear of the text screen the READY prompt is sitting on.
361
scratch = "vram:1F9C0"
362
before = mem_bytes(read_mem(113, scratch, 4))
363
write_mem(114, scratch, [0xDE, 0xAD, 0xBE, 0xEF])
364
after = mem_bytes(read_mem(115, scratch, 4))
365
expect("writeMemory writes VRAM", after == b"\xde\xad\xbe\xef")
366
if before is not None:
367
    write_mem(116, scratch, list(before))
368
    expect("the scratch bytes restore", mem_bytes(read_mem(117, scratch, 4)) == before)
369
370
# $ and 0x have to mean the same address, and an offset has to apply in the
371
# space the reference named.
372
a = mem_bytes(read_mem(118, "vram:$1B000", 8))
373
b = mem_bytes(read_mem(119, "vram:0x1B000", 8))
374
expect("vram: accepts $ and 0x alike", a is not None and a == b == vram[:8])
375
376
# The trap this replaced: strtoul returned 0 for anything it could not read, so
377
# a typo answered success with the bytes at CPU address 0 and nothing in the
378
# response to say it was the wrong memory.
379
for bad in ("vram:zzz", "notanaddress", "vram:"):
380
    r = read_mem(120, bad, 4)
381
    expect("readMemory rejects %r instead of reading address 0" % bad,
382
           r is not None and not r.get("success"))
383
384
r = read_mem(121, "$C000", 4)
385
expect("readMemory accepts a $-prefixed CPU address", r is not None and r.get("success"))
386
387
# 15b. x16/perfStats and x16/perfConfig: the guest performance budget.
388
#
389
# The arithmetic is pinned by tests/test_perf_budget.c, which drives the module
390
# directly. What can only be checked here is that the surface is wired to a real
391
# machine: that the frame period it reports is the one VERA is actually scanning
392
# at, and that a target frame rate means what the header says it means.
393
394
395
def perf(seq, args=None):
396
    send_dap(sock, {"seq": seq, "type": "request", "command": "x16/perfConfig",
397
                    "arguments": args if args is not None else {}})
398
    time.sleep(0.3)
399
    for m in recv_dap(sock, 1):
400
        if m.get("command") == "x16/perfConfig":
401
            return m
402
    return None
403
404
405
# Frames only complete while the machine runs, and the stepping tests above left
406
# it stopped. Nothing here means anything against a paused CPU.
407
send_dap(sock, {"seq": 129, "type": "request", "command": "continue",
408
                "arguments": {"threadId": 1}})
409
time.sleep(0.5)
410
recv_dap(sock, 1)
411
412
r = perf(130, {"enabled": True, "targetFps": 60, "idleMode": "auto", "reset": True})
413
expect("x16/perfConfig arms profiling", r is not None and r.get("success"))
414
budget = r["body"]["budget"] if r and r.get("success") else {}
415
expect("profiling reports itself enabled", budget.get("enabled") is True)
416
417
# 8 MHz over a VGA frame: 8e6 * (800*525) / 25e6. The emulator derives this from
418
# the video timing rather than hardcoding it, so a wrong answer here means the
419
# budget is being measured against a frame the machine is not scanning.
420
expect("a VGA frame at 8 MHz is 134400 cycles", budget.get("cyclesPerFrame") == 134400)
421
expect("which is 256 cycles per scanline", budget.get("cyclesPerScanline") == 256)
422
expect("the 60 fps budget is one whole frame",
423
       budget.get("budgetCycles") == 134400 and budget.get("framesPerBudget") == 1)
424
425
# A lower target buys more frames, not longer ones.
426
r = perf(131, {"targetFps": 30})
427
b30 = r["body"]["budget"] if r and r.get("success") else {}
428
expect("30 fps spans two vsyncs", b30.get("framesPerBudget") == 2)
429
expect("and doubles the budget", b30.get("budgetCycles") == 268800)
430
expect("without changing the frame period", b30.get("cyclesPerFrame") == 134400)
431
432
# A target faster than the machine scans is the other question -- "must this fit
433
# in half a frame?" -- and gets a fractional budget rather than being rounded
434
# up to a whole one.
435
r = perf(132, {"targetFps": 120})
436
b120 = r["body"]["budget"] if r and r.get("success") else {}
437
expect("a sub-frame target gets a fraction of a frame",
438
       b120.get("budgetCycles", 0) < 134400 * 0.6)
439
440
perf(133, {"targetFps": 60})
441
442
time.sleep(1.0)  # let some frames accumulate
443
send_dap(sock, {"seq": 134, "type": "request", "command": "x16/perfStats",
444
                "arguments": {"windows": [1, 0], "includeFrames": 3}})
445
time.sleep(0.5)
446
stats = None
447
for m in recv_dap(sock, 2):
448
    if m.get("command") == "x16/perfStats":
449
        stats = m
450
expect("x16/perfStats responds", stats is not None and stats.get("success"))
451
452
if stats and stats.get("success"):
453
    body = stats["body"]
454
    cur = body.get("current", {})
455
    expect("a completed frame is reported", bool(cur))
456
    expect("the frame holds a frame's worth of cycles",
457
           130000 <= cur.get("totalCycles", 0) <= 140000)
458
    # The invariant the whole classification rests on.
459
    expect("work and idle account for every cycle",
460
           cur.get("workCycles", 0) + cur.get("idleCycles", -1) == cur.get("totalCycles"))
461
462
    wins = body.get("windows", [])
463
    expect("both requested windows came back", len(wins) == 2)
464
    if wins:
465
        bw = wins[0].get("budgetWork", {})
466
        expect("percentiles are ordered",
467
               bw.get("min", 0) <= bw.get("p50", 0) <= bw.get("p95", 0) <= bw.get("max", 0))
468
        expect("the window reports overrun accounting",
469
               "overruns" in wins[0] and "overrunPct" in wins[0])
470
    frames = body.get("frames", [])
471
    expect("raw frame samples came back", len(frames) == 3)
472
    if len(frames) >= 2:
473
        expect("raw frames are oldest first", frames[0]["frame"] < frames[-1]["frame"])
474
475
# Zones are attributed by address, so one drawn over ROM must collect something.
476
r = perf(135, {"reset": True,
477
               "zones": [{"name": "kernal", "start": 0xC000, "end": 0xFFFF}]})
478
expect("zones can be declared over DAP", r is not None and r.get("success"))
479
time.sleep(0.8)
480
send_dap(sock, {"seq": 136, "type": "request", "command": "x16/perfStats",
481
                "arguments": {"windows": [1]}})
482
time.sleep(0.5)
483
for m in recv_dap(sock, 2):
484
    if m.get("command") == "x16/perfStats":
485
        zones = m["body"].get("zones", [])
486
        expect("the declared zone came back", len(zones) == 1)
487
        if zones:
488
            expect("the zone is named as declared", zones[0].get("name") == "kernal")
489
            expect("and has window statistics", "window30s" in zones[0])
490
491
# Leave the machine as we found it: profiling costs it something per instruction.
492
r = perf(137, {"enabled": False, "zones": []})
493
expect("profiling can be switched back off",
494
       r is not None and r["body"]["budget"].get("enabled") is False)
495
496
# 15c. VERA bandwidth: the other half of "what is eating my frame?".
497
#
498
# The arithmetic is pinned by tests/test_vera_bandwidth.c, which drives the
499
# model directly against the RTL's decode tables. What can only be checked here
500
# is that it is wired to a real machine and to a real display mode -- a booted
501
# ROM sits at the BASIC prompt in 1bpp text mode on layer 1, so there are
502
# numbers that MUST appear and bounds they cannot leave.
503
send_dap(sock, {"seq": 138, "type": "request", "command": "x16/perfConfig",
504
                "arguments": {"enabled": True, "reset": True}})
505
time.sleep(1.0)  # let several frames land
506
send_dap(sock, {"seq": 139, "type": "request", "command": "x16/perfStats",
507
                "arguments": {"windows": [1]}})
508
time.sleep(0.5)
509
bwstats = None
510
for m in recv_dap(sock, 2):
511
    if m.get("command") == "x16/perfStats":
512
        bwstats = m
513
514
expect("x16/perfStats carries a bandwidth object",
515
       bwstats is not None and "bandwidth" in bwstats.get("body", {}))
516
517
if bwstats and bwstats.get("success"):
518
    bwb = bwstats["body"]["bandwidth"]
519
520
    # The bus geometry, quoted from the RTL. If these drift, every figure built
521
    # on them is wrong, and the unit tests would not notice a bad constant
522
    # reaching the wire.
523
    expect("a scanline is 800 clocks", bwb.get("lineClocks") == 800)
524
    expect("a VRAM access is four bytes", bwb.get("bytesPerAccess") == 4)
525
    expect("and costs two clocks", bwb.get("accessClocks") == 2)
526
    expect("VGA scans 525 lines", bwb.get("scanlinesPerFrame") == 525)
527
528
    expect("bandwidth accounting reports itself enabled", bwb.get("enabled") is True)
529
    expect("a frame has been accounted", bwb.get("linesActive", 0) > 0)
530
531
    # The BASIC prompt is a text-mode screen on layer 1, so layer 1 must be
532
    # fetching and the peak line must be a real number of clocks.
533
    layers = bwb.get("layers", [])
534
    expect("both layers are reported", len(layers) == 2)
535
    if len(layers) == 2:
536
        expect("layer 1 is fetching at the BASIC prompt",
537
               layers[1].get("fetches", 0) > 0)
538
        expect("its bytes are its fetches times four",
539
               layers[1].get("bytes") == layers[1].get("fetches", 0) * 4)
540
        # 1bpp 8px tiles: 40 map + 80 data = 120 a line, 121-123 when scrolled.
541
        # Checked as a range because hscroll and the scroll tile move it.
542
        peak = layers[1].get("peakFetches", 0)
543
        expect("a text-mode line fetches about 120 words", 118 <= peak <= 126)
544
545
    # The headline. It must be inside the window a scanline actually has --
546
    # a text screen is nowhere near filling the bus.
547
    peak_clocks = bwb.get("peakLineClocks", 0)
548
    expect("the peak scanline is a real cost", peak_clocks > 0)
549
    expect("and fits inside the scanline", peak_clocks <= 800)
550
    expect("so no line is over budget", bwb.get("linesOverBudget", -1) == 0)
551
    expect("percentile and mean are ordered",
552
           bwb.get("meanLineClocks", 0) <= bwb.get("p95LineClocks", 0) <= peak_clocks)
553
    expect("sprites are left the rest of the scanline",
554
           bwb.get("spriteBusHeadroomClocksAtPeak", -1) == 800 - peak_clocks)
555
    # Every clock in this object is bus occupancy, not sprite render time. The
556
    # two differ by the sprite renderer's duty cycle, and a client that
557
    # subtracted one from the other would be wrong by about fivefold.
558
    expect("and the clock unit is declared", bwb.get("clockUnits") == "bus")
559
560
    port = bwb.get("port", {})
561
    expect("the data port is reported", isinstance(port, dict) and "writeBytes" in port)
562
563
# The data port is the part a program controls, and the debugger is NOT a
564
# program. Two different paths have to be kept off the books, and only the
565
# first is directly observable from here:
566
#
567
#   * writeMemory with a "vram:" reference reaches VRAM through
568
#     video_space_write(), around the $9F23 path entirely. Checked below.
569
#   * writeMemory to the CPU address $9F23 goes through real_write6502() with
570
#     debugOn set, which raises video_set_debug_write() so the accounting
571
#     declines it. That one cannot be pinned reliably from out here -- under
572
#     -warp the frames roll far faster than requests arrive, so a burst of
573
#     debugger pokes never lands in one frame whether it is charged or not.
574
#     video.c carries the guard and the comment instead.
575
#
576
# If either were charged, opening a memory view would move the numbers the
577
# developer is trying to read.
578
send_dap(sock, {"seq": 140, "type": "request", "command": "x16/perfConfig",
579
                "arguments": {"reset": True}})
580
time.sleep(0.3)
581
recv_dap(sock, 1)
582
send_dap(sock, {"seq": 141, "type": "request", "command": "writeMemory",
583
                "arguments": {"memoryReference": "vram:1B000",
584
                              "data": base64.b64encode(bytes(64)).decode()}})
585
time.sleep(0.5)
586
recv_dap(sock, 1)
587
588
# Sampling has to be generous here, and it is worth saying why. The bandwidth
589
# figures are per-frame, and a machine sitting at the BASIC prompt touches VRAM
590
# only when the cursor blinks -- measured at about 8 frames in 120. Under -warp
591
# thousands of emulated frames pass between two requests, so each reply shows an
592
# essentially random frame. One sample would be a coin toss; a hundred makes
593
# missing the blink altogether vanishingly unlikely.
594
#
595
# Requests are pipelined in batches rather than sent one at a time because
596
# recv_dap() blocks for its whole timeout, so a request-reply-request loop would
597
# spend a minute here to gather what this gathers in seconds.
598
debugger_charged = False
599
guest_port_bytes = 0
600
seq = 142
601
for _ in range(5):
602
    for _ in range(20):
603
        send_dap(sock, {"seq": seq, "type": "request", "command": "x16/perfStats",
604
                        "arguments": {"windows": [1], "includeZones": False}})
605
        seq += 1
606
        time.sleep(0.03)
607
    for m in recv_dap(sock, 1):
608
        if m.get("command") != "x16/perfStats":
609
            continue
610
        p = m.get("body", {}).get("bandwidth", {}).get("port", {})
611
        guest_port_bytes = max(guest_port_bytes,
612
                               p.get("writeBytes", 0) + p.get("readBytes", 0))
613
        # 64 bytes landing in one frame could only be the DAP write above.
614
        if p.get("writeBytes", 0) >= 64:
615
            debugger_charged = True
616
617
expect("a debugger VRAM write is not charged to the guest", not debugger_charged)
618
expect("but the guest's own data-port traffic is", guest_port_bytes > 0)
619
620
# Leave the machine as we found it.
621
perf(seq, {"enabled": False})
622
623
624
#
625
# The core records who wanted each breakpoint, so a client disconnecting clears
626
# its own and leaves everything else armed. Reconstructing that from outside the
627
# core is what this replaced, and every version of it deleted somebody's
628
# breakpoints. Nothing below can be checked from the unit tests: it needs a real
629
# session, a real teardown, and a real reconnect.
630
#
631
# Start the emulator with `-bp <addr>` to get the strongest form of this -- the
632
# case where the surviving breakpoint is the user's, set before any client
633
# existed.
634
635
def evaluate(s, expr, pause=0.3):
636
    global _eval_seq
637
    _eval_seq += 1
638
    send_dap(s, {"seq": 500 + _eval_seq, "type": "request", "command": "evaluate",
639
                 "arguments": {"expression": expr}})
640
    time.sleep(pause)
641
    for m in recv_dap(s, 1):
642
        if m.get("type") == "response" and m.get("command") == "evaluate":
643
            return m.get("body", {}).get("result", "")
644
    return ""
645
646
def bp_addrs(s):
647
    # "N breakpoints: $1234 $5678" -> ["$1234", "$5678"]
648
    out = evaluate(s, "bp_list")
649
    return [t for t in out.split() if t.startswith("$")]
650
651
_eval_seq = 0
652
653
baseline = bp_addrs(sock)
654
print("       breakpoints before this session: %s" % (" ".join(baseline) or "(none)"))
655
656
# A console breakpoint at an address nothing else is using.
657
scratch = "3F00"
658
evaluate(sock, "bp_add " + scratch)
659
if ("$" + scratch) in bp_addrs(sock):
660
    print("  PASS console bp_add arms a breakpoint")
661
    passed += 1
662
else:
663
    print("  FAIL console bp_add arms a breakpoint")
664
    failed += 1
665
666
# The interesting one: claim an address the user already had. Both owners now
667
# want it, and the session going away must not take it with them.
668
shared = baseline[0][1:] if baseline else None
669
if shared:
670
    evaluate(sock, "bp_add " + shared)
671
672
send_dap(sock, {"seq": 13, "type": "request", "command": "disconnect",
673
                "arguments": {"terminateDebuggee": False}})
674
time.sleep(0.3)
675
msgs = recv_dap(sock, 1)
676
check("disconnect response", msgs, "response", "disconnect")
677
sock.close()
678
time.sleep(0.5)
679
680
after_session = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
681
after_session.connect(('127.0.0.1', PORT))
682
send_dap(after_session, {"seq": 1, "type": "request", "command": "initialize",
683
                         "arguments": {"adapterID": "test"}})
684
time.sleep(0.5)
685
recv_dap(after_session, 1)
686
687
remaining = bp_addrs(after_session)
688
print("       breakpoints after teardown: %s" % (" ".join(remaining) or "(none)"))
689
690
if ("$" + scratch) not in remaining:
691
    print("  PASS the session's own breakpoint went with it")
692
    passed += 1
693
else:
694
    print("  FAIL the session's own breakpoint went with it: still armed, and now "
695
          "owned by nobody")
696
    failed += 1
697
698
if not baseline:
699
    print("  SKIP a pre-existing breakpoint survives a session "
700
          "(start the emulator with -bp to check this)")
701
elif all(a in remaining for a in baseline):
702
    print("  PASS every pre-existing breakpoint survived the session")
703
    passed += 1
704
else:
705
    print("  FAIL every pre-existing breakpoint survived the session: had %s, left %s"
706
          % (" ".join(baseline), " ".join(remaining) or "(none)"))
707
    failed += 1
708
709
after_session.close()
710
time.sleep(0.3)
711
712
# 14. Disconnect
713
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
714
sock.connect(('127.0.0.1', PORT))
715
send_dap(sock, {"seq": 1, "type": "request", "command": "initialize",
716
                "arguments": {"adapterID": "test"}})
717
time.sleep(0.4)
718
recv_dap(sock, 1)
719
send_dap(sock, {"seq": 12, "type": "request", "command": "disconnect", "arguments": {"terminateDebuggee": False}})
720
time.sleep(0.3)
721
msgs = recv_dap(sock, 1)
722
check("disconnect response", msgs, "response", "disconnect")
723
724
sock.close()
725
726
# 15. An oversized frame must not poison the receive buffer for later clients.
727
#
728
# A body bigger than the buffer drops the connection -- there is no way to
729
# resynchronise a stream we cannot hold. The header was left sitting in the
730
# buffer though, so the next client was accepted, had that stale header
731
# re-parsed before it had written a byte, and was dropped on connect. For the
732
# life of the process.
733
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
734
probe.connect(('127.0.0.1', PORT))
735
probe.sendall(b"Content-Length: 100000\r\n\r\n")
736
time.sleep(0.5)
737
probe.close()
738
time.sleep(0.5)
739
740
try:
741
    after = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
742
    after.connect(('127.0.0.1', PORT))
743
    time.sleep(0.5)          # let a poll run before we send anything
744
    send_dap(after, {"seq": 1, "type": "request", "command": "initialize",
745
                     "arguments": {"adapterID": "test"}})
746
    time.sleep(0.5)
747
    msgs = recv_dap(after, 2)
748
    check("server still usable after an oversized frame", msgs, "response", "initialize")
749
    after.close()
750
except OSError as e:
751
    print("  FAIL server still usable after an oversized frame: %s" % e)
752
    failed += 1
753
754
print("\n%d passed, %d failed" % (passed, failed))
755
sys.exit(0 if failed == 0 else 1)