OXYGENHUB
◆ main enhanced-x16-emulator / tests / fetch_pt.py
5.1 KiB · Python · last changed chore(): initial commit 25 days ago source blame history raw
1
#!/usr/bin/env python3
2
"""Fetch SingleStepTests suites and convert them for test_processor_tests.
3
4
Not committed: roughly a gigabyte of JSON per 65C02 variant and three for the
5
65C816, one file per opcode. Takes a fixed prefix of each instead.
6
7
Prefix, not a random sample: the baseline records exact per-opcode counts, so
8
every machine must convert the same cases.
9
10
    python tests/fetch_pt.py                    # 100 cases/opcode, wdc65c02
11
    python tests/fetch_pt.py --cases 10000      # everything
12
    python tests/fetch_pt.py --variant 816-emu  # 65816, emulation mode
13
"""
14
15
import argparse
16
import json
17
import pathlib
18
import sys
19
import time
20
import urllib.error
21
import urllib.request
22
23
import pt_convert
24
25
RAW = "https://raw.githubusercontent.com/SingleStepTests/{repo}/main/{sub}/{name}.json"
26
27
VARIANTS = {
28
    # wdc65c02 is the superset: the 32 Rockwell bit instructions we implement
29
    # plus WAI and STP. The rockwell and synertek suites are subsets of it.
30
    "wdc65c02": {
31
        "repo": "65x02", "sub": "wdc65c02/v1", "suffix": "",
32
        "cpu": pt_convert.CPU_65C02,
33
    },
34
    # The 65C816 ships two files per opcode. Emulation mode is all E=1 with 8-bit
35
    # registers; native mixes the four M/X width combinations within one file.
36
    "816-emu": {
37
        "repo": "65816", "sub": "v1", "suffix": ".e",
38
        "cpu": pt_convert.CPU_65816,
39
    },
40
    "816-native": {
41
        "repo": "65816", "sub": "v1", "suffix": ".n",
42
        "cpu": pt_convert.CPU_65816,
43
    },
44
}
45
46
ATTEMPTS = 4
47
48
49
def fetch_opcode(variant: str, op: int, dest: pathlib.Path) -> bool:
50
    spec = VARIANTS[variant]
51
    name = f"{op:02x}{spec['suffix']}"
52
    out = dest / f"{name}.json"
53
    if out.exists() and out.stat().st_size > 0:
54
        return True
55
    url = RAW.format(repo=spec["repo"], sub=spec["sub"], name=name)
56
    # Hundreds of requests per run, so a transient failure is likely rather
57
    # than exceptional. Retry anything that is not a definite 404.
58
    last = None
59
    for attempt in range(ATTEMPTS):
60
        try:
61
            with urllib.request.urlopen(url, timeout=60) as r:
62
                data = r.read()
63
            out.write_bytes(data)
64
            return True
65
        except urllib.error.HTTPError as e:
66
            if e.code == 404:
67
                return False
68
            last = e
69
        except Exception as e:
70
            last = e
71
        if attempt < ATTEMPTS - 1:
72
            time.sleep(2 ** attempt)
73
    raise SystemExit(f"${op:02X}: giving up after {ATTEMPTS} attempts: {last}")
74
75
76
def load_cases(path: pathlib.Path):
77
    """Cases in one opcode's file, or [] if it holds none.
78
79
    $CB (WAI) and $DB (STP) are empty in the 65C02 suite: they stop the
80
    processor, so there is no state after one instruction.
81
    """
82
    if path.stat().st_size == 0:
83
        return []
84
    try:
85
        return json.loads(path.read_text(encoding="utf-8"))
86
    except json.JSONDecodeError:
87
        path.unlink()
88
        raise SystemExit(f"{path.name} is not valid JSON; deleted, run again")
89
90
91
def main() -> int:
92
    ap = argparse.ArgumentParser()
93
    ap.add_argument("--variant", default="wdc65c02", choices=sorted(VARIANTS))
94
    ap.add_argument(
95
        "--cases",
96
        type=int,
97
        default=100,
98
        help="cases per opcode, taken as a prefix (default 100)",
99
    )
100
    ap.add_argument(
101
        "--opcodes",
102
        default="",
103
        help="comma-separated hex opcodes; default is all 256",
104
    )
105
    ap.add_argument(
106
        "--force",
107
        action="store_true",
108
        help="re-fetch and re-convert even if the fixture already exists",
109
    )
110
    args = ap.parse_args()
111
112
    spec = VARIANTS[args.variant]
113
    here = pathlib.Path(__file__).resolve().parent
114
    out = here / "pt" / f"{args.variant}.bin"
115
116
    # The fixture is a megabyte or so and the JSON it came from is gigabytes,
117
    # so CI caches only the fixture. Nothing to do when the cache hit.
118
    if out.exists() and out.stat().st_size > 0 and not args.force:
119
        print(f"{out} already present ({out.stat().st_size / 1e6:.1f} MB); "
120
              f"pass --force to rebuild")
121
        return 0
122
123
    dest = here / "pt" / args.variant
124
    dest.mkdir(parents=True, exist_ok=True)
125
126
    if args.opcodes:
127
        wanted = [int(x, 16) for x in args.opcodes.split(",")]
128
    else:
129
        wanted = list(range(256))
130
131
    have = []
132
    for op in wanted:
133
        sys.stdout.write(f"\rfetching ${op:02X}...")
134
        sys.stdout.flush()
135
        if fetch_opcode(args.variant, op, dest):
136
            have.append(op)
137
    print(f"\r{len(have)} opcodes available          ")
138
139
    cases = []
140
    empty = []
141
    short = []
142
    for op in have:
143
        raw = load_cases(dest / f"{op:02x}{spec['suffix']}.json")
144
        if not raw:
145
            empty.append(op)
146
            continue
147
        if len(raw) < args.cases:
148
            short.append((op, len(raw)))
149
        cases.extend((op, spec["cpu"], c) for c in raw[: args.cases])
150
151
    if empty:
152
        print("no cases: " + " ".join(f"${o:02X}" for o in empty))
153
    for op, n in short:
154
        print(f"  ${op:02X}: only {n} cases, wanted {args.cases}")
155
156
    pt_convert.write_fixture(out, cases)
157
    print(f"wrote {out} -- {len(cases)} cases, {out.stat().st_size / 1e6:.1f} MB")
158
    return 0
159
160
161
if __name__ == "__main__":
162
    raise SystemExit(main())