rsemu 0.0.2

A multiplatform emulator in pure Rust, built bottom-up on a generic framework.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! Whole-machine conformance runners for the Game Boy.
//!
//! `ROADMAP.md` §0: *accuracy is measured, never asserted*. The SM83's own
//! runner (`cpu::sm83::conformance`) measures the processor on a hand-built
//! minimal bus; this one runs the **shipped machine** — `machines/gameboy.machine`,
//! realized through the catalog exactly as `rsemu run gameboy` would — because
//! Gekkio's acceptance suite is not a CPU suite. Nearly every ROM in it waits
//! for a real `LY` to reach 144 before it starts, and most of them measure the
//! timer, the LCD or the OAM DMA against the processor. A runner without those
//! devices does not fail those tests; it hangs before them.
//!
//! # Running
//!
//! ```text
//! scripts/fetch-testdata.sh gameboy
//! RSEMU_CONFORMANCE=1 cargo test --release --all-features gb::conformance -- --nocapture
//! ```
//!
//! | Variable | Points at |
//! | --- | --- |
//! | `RSEMU_GB_BLARGG_DIR` | a directory of blargg `.gb` ROMs, searched recursively |
//! | `RSEMU_GB_MOONEYE_DIR` | `Gekkio/mooneye-test-suite`'s built acceptance ROMs |
//! | `RSEMU_GB_FRAMES` | how many emulated frames a ROM may run for (default 4000) |
//!
//! Without the gate, or without a corpus, every runner prints why it is doing
//! nothing and passes. `cargo test` offline stays green; that is a rule
//! (CLAUDE.md, Testing), not a convenience.
//!
//! # How a result is read out
//!
//! There is deliberately no route from a `dyn Device` to a `GbSerial` — the core
//! keeps `Any` out of the supertrait chain on purpose — so the runners read
//! results the way `ROADMAP.md` §4.5 already promises anyone can: out of the
//! device's own **snapshot chunk**. Calling [`Device::save`] on one device is
//! cheap (the CPU's chunk is forty bytes), and doing it this way doubles as a
//! check that the chunk really is the architectural state.
//!
//! **Blargg** writes its verdict to the serial port a character at a time, which
//! is exactly why it can be run headless.
//!
//! **Mooneye** writes its verdict into the register file: `B`,`C`,`D`,`E`,`H`,`L`
//! = 3, 5, 8, 13, 21, 34 on success, and `$42` in all six on failure, before an
//! `LD B,B` software breakpoint. The register pattern is what this runner looks
//! for, since it survives whatever the ROM does next.

use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use std::path::{Path, PathBuf};

use crate::core::state::{MachineShape, Migrations, Source, StateReader, StateWriter};
use crate::machine::{Machine, catalog};

/// The master gate.
const GATE: &str = "RSEMU_CONFORMANCE";

/// Overrides the corpus root. Defaults to `<repo>/testdata`.
const TESTDATA: &str = "RSEMU_TESTDATA";

/// How many emulated frames a ROM may run for before the runner gives up.
const DEFAULT_FRAMES: u64 = 4000;

/// How many scheduler quanta to run between checks of the result.
///
/// A quantum here is bounded by whichever lazily-advanced device has the nearest
/// event, which on this machine is a mode change on the LCD — a few hundred
/// crystal periods. A few thousand of them is well under a frame.
const QUANTA_PER_CHECK: u32 = 2048;

fn enabled() -> bool {
    matches!(
        std::env::var(GATE).as_deref(),
        Ok("1") | Ok("true") | Ok("yes")
    )
}

fn frame_limit() -> u64 {
    std::env::var("RSEMU_GB_FRAMES")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(DEFAULT_FRAMES)
}

fn testdata_root() -> PathBuf {
    match std::env::var_os(TESTDATA) {
        Some(dir) => PathBuf::from(dir),
        None => PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata"),
    }
}

/// The directory a suite's ROMs live in, or the reason there is nothing to run.
fn corpus(var: &str, default: &str, fetch: &str) -> Option<PathBuf> {
    if !enabled() {
        println!("SKIP {default}: set {GATE}=1 to run conformance suites");
        return None;
    }
    let dir = match std::env::var_os(var) {
        Some(d) => PathBuf::from(d),
        None => testdata_root().join(default),
    };
    if !dir.is_dir() {
        println!("SKIP {default}: corpus not found at {}", dir.display());
        println!("      fetch it with: {fetch}");
        return None;
    }
    Some(dir)
}

/// Every `.gb` file under `dir`, sorted, so a run is reproducible.
fn roms(dir: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    collect(dir, &mut out);
    out.sort();
    out
}

fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect(&path, out);
        } else if path.extension().is_some_and(|e| e == "gb") {
            out.push(path);
        }
    }
}

fn label(root: &Path, rom: &Path) -> String {
    rom.strip_prefix(root)
        .unwrap_or(rom)
        .to_string_lossy()
        .to_string()
}

// ---------------------------------------------------------------------------
// Reading a device's state without downcasting it
// ---------------------------------------------------------------------------

/// One device's snapshot chunk, as bytes.
///
/// `ROADMAP.md` §4.5's promise made useful: the chunk *is* the architectural
/// state, so anything that wants to observe a device from outside can read it
/// there rather than reaching for a downcast the core deliberately does not
/// offer.
fn chunk_of(machine: &Machine, path: &str) -> Option<Vec<u8>> {
    let entry = machine.device(path)?;
    let class = entry.class();
    let mut writer = StateWriter::new(MachineShape::new());
    {
        let mut chunk = writer.chunk(path, class.name, class.version).ok()?;
        entry.device().save(&mut chunk).ok()?;
    }
    let bytes = writer.to_vec().ok()?;
    let reader = StateReader::new(&bytes).ok()?;
    let chunk = reader
        .load(path, class.name, class.version, &Migrations::new())
        .ok()?;
    Some(chunk.into_data())
}

/// The six registers mooneye reports its verdict in.
fn verdict_registers(machine: &Machine) -> Option<[u8; 6]> {
    let data = chunk_of(machine, "cpu")?;
    // `cpu.sm83` writes A, F, B, C, D, E, H, L in that order.
    let mut r = crate::core::state::ChunkReader::new(&data);
    let mut byte = || r.read_u8().ok();
    let _a = byte()?;
    let _f = byte()?;
    Some([byte()?, byte()?, byte()?, byte()?, byte()?, byte()?])
}

/// Everything the serial port has sent.
fn serial_transcript(machine: &Machine) -> Option<String> {
    let data = chunk_of(machine, "link")?;
    let mut r = crate::core::state::ChunkReader::new(&data);
    // `gb.serial` writes SB, SC, the remaining clocks, its tick, then the
    // transcript.
    let _sb = r.read_u8().ok()?;
    let _sc = r.read_u8().ok()?;
    let _remaining = r.read_u64().ok()?;
    let _tick = r.read_u64().ok()?;
    let bytes = r.read_bytes().ok()?;
    Some(bytes.iter().map(|b| *b as char).collect())
}

/// How many frames the LCD controller has finished.
///
/// Read out of its chunk for the same reason as everything else here. The
/// framebuffer is the first three length-prefixed byte arrays; the frame counter
/// follows the register bytes.
fn frames(machine: &Machine) -> Option<u64> {
    let data = chunk_of(machine, "ppu")?;
    let mut r = crate::core::state::ChunkReader::new(&data);
    let _vram = r.read_bytes().ok()?;
    let _oam = r.read_bytes().ok()?;
    let _fb = r.read_bytes().ok()?;
    for _ in 0..14 {
        r.read_u8().ok()?;
    }
    let _window_active = r.read_bool().ok()?;
    let _dot = r.read_u64().ok()?;
    let _dots = r.read_u64().ok()?;
    r.read_u64().ok()
}

// ---------------------------------------------------------------------------
// The runner
// ---------------------------------------------------------------------------

/// Build the shipped Game Boy around `rom` and run it.
fn machine_for(rom: &[u8]) -> Result<Machine, String> {
    catalog::build_catalog("gameboy", &[("cart", rom)]).map_err(|e| e.to_string())
}

/// Roughly how many frames' worth of emulated time one batch of
/// [`QUANTA_PER_CHECK`] quanta covers when the LCD is switched **off**.
///
/// With the LCD running, a quantum ends at whichever lazily-advanced device has
/// the nearest event, which is a mode boundary — about 440 a frame, so a batch
/// is several frames. With the LCD off the controller has no events at all and
/// only the divider's do, at one every 256 crystal periods; a batch is then
/// about an eighth of a frame's worth of time.
const BATCHES_PER_FRAME_LCD_OFF: u64 = 8;

/// Run until `stop` says so or the budget runs out.
///
/// Returns whether `stop` fired.
///
/// **Two limits, not one.** The obvious budget is emulated frames, and it is the
/// one worth stating — but a ROM that switches the LCD off stops the frame
/// counter dead, and several of Gekkio's do exactly that. A budget that counted
/// only frames would then never expire and the runner would hang rather than
/// report a timeout. So the batch count is bounded too, generously enough that
/// it never binds while the LCD is running.
fn run(machine: &mut Machine, limit_frames: u64, mut stop: impl FnMut(&Machine) -> bool) -> bool {
    let start = frames(machine).unwrap_or(0);
    let max_batches = limit_frames
        .saturating_mul(BATCHES_PER_FRAME_LCD_OFF)
        .max(1);
    for _ in 0..max_batches {
        for _ in 0..QUANTA_PER_CHECK {
            if machine.run_quantum().is_err() {
                return stop(machine);
            }
        }
        if stop(machine) {
            return true;
        }
        if frames(machine).unwrap_or(u64::MAX).saturating_sub(start) >= limit_frames {
            return false;
        }
    }
    false
}

// ---------------------------------------------------------------------------
// blargg
// ---------------------------------------------------------------------------

/// The known-failures ledger for the whole-machine blargg run.
///
/// `ROADMAP.md` §0 asks every core to ship a ledger that *only ever shrinks*,
/// and this is it. One entry, and the reason is not a Game Boy bug:
///
/// **`instr_timing`** passes 12/12 against the SM83 on its own
/// (`cpu::sm83::conformance`), where the timer is advanced in step with the
/// processor. On the assembled machine it fails, and the cause is the
/// intra-quantum staleness `ROADMAP.md` §4.2 already records as outstanding: a
/// [`LazyHandle`](crate::core::sched::LazyHandle) catches a device up to the
/// tick the *scheduler* last published, which is the start of the current
/// quantum, and an instruction cannot be stopped part-way, so a CPU overruns
/// each quantum by up to five machine cycles and reads a timer that is that far
/// behind. `instr_timing` measures the timer against single instructions, so a
/// bias of a few cycles is exactly what it detects.
///
/// Fixing it means letting a runnable report progress *as* it runs — a change to
/// `core::sched::Runnable`, which phase 4 is explicitly not the place to make.
/// The entry comes out when that lands.
const BLARGG_LEDGER: &[&str] = &["instr_timing.gb"];

#[test]
fn blargg_on_the_shipped_machine() {
    let Some(dir) = corpus(
        "RSEMU_GB_BLARGG_DIR",
        "gb-blargg",
        "scripts/fetch-testdata.sh gb-blargg",
    ) else {
        return;
    };
    let roms = roms(&dir);
    if roms.is_empty() {
        println!("SKIP gb-blargg: no .gb files under {}", dir.display());
        return;
    }
    let limit = frame_limit();
    let mut passed = 0usize;
    let mut failures = Vec::new();
    for rom in &roms {
        let name = label(&dir, rom);
        let Ok(bytes) = std::fs::read(rom) else {
            failures.push(format!("{name}: unreadable"));
            continue;
        };
        let mut machine = match machine_for(&bytes) {
            Ok(m) => m,
            Err(e) => {
                println!("  FAIL {name}: {e}");
                failures.push(name);
                continue;
            }
        };
        machine.reset(crate::core::device::ResetKind::Cold);
        run(&mut machine, limit, |m| {
            let text = serial_transcript(m).unwrap_or_default();
            text.contains("Passed") || text.contains("Failed")
        });
        let text = serial_transcript(&machine).unwrap_or_default();
        if text.contains("Passed") {
            passed += 1;
            println!("  pass {name}");
        } else if text.contains("Failed") {
            let ledgered = BLARGG_LEDGER.iter().any(|l| name.ends_with(l));
            let mark = if ledgered { "LDGR" } else { "FAIL" };
            println!("  {mark} {name}: {}", text.trim().replace('\n', " / "));
            if !ledgered {
                failures.push(name);
            }
        } else {
            println!(
                "  ???? {name}: no verdict in {limit} frames ({})",
                text.trim().replace('\n', " / ")
            );
            failures.push(name);
        }
    }
    println!(
        "blargg (whole machine): {passed}/{} ROMs passed, {} ledgered",
        roms.len(),
        BLARGG_LEDGER.len()
    );
    assert!(
        failures.is_empty(),
        "blargg failures: {}",
        failures.join(", ")
    );
}

// ---------------------------------------------------------------------------
// mooneye
// ---------------------------------------------------------------------------

/// The register pattern Gekkio's suite sets on success: the Fibonacci numbers
/// 3, 5, 8, 13, 21, 34 in `B`, `C`, `D`, `E`, `H`, `L`.
const MOONEYE_PASS: [u8; 6] = [3, 5, 8, 13, 21, 34];

/// The pattern it sets on failure: `$42` in all six.
const MOONEYE_FAIL: [u8; 6] = [0x42; 6];

/// Which of Gekkio's ROMs target a DMG at all.
///
/// The suite ships variants for several models, named by suffix: `-dmgABC` and
/// `-GS` include the DMG, while `-dmg0`, `-mgb`, `-sgb`, `-sgb2` and `-S` are
/// other consoles and would fail on hardware too. Running them and counting the
/// failures would be measuring the wrong thing.
fn targets_dmg(name: &str) -> bool {
    let stem = name.rsplit('/').next().unwrap_or(name);
    let stem = stem.strip_suffix(".gb").unwrap_or(stem);
    match stem.rsplit_once('-') {
        Some((_, suffix)) => matches!(suffix, "dmgABC" | "dmgABCmgb" | "GS"),
        None => true,
    }
}

#[test]
fn mooneye_acceptance_on_the_shipped_machine() {
    let Some(dir) = corpus(
        "RSEMU_GB_MOONEYE_DIR",
        "gb-mooneye",
        "scripts/fetch-testdata.sh gb-mooneye",
    ) else {
        return;
    };
    let all = roms(&dir);
    let roms: Vec<_> = all
        .into_iter()
        .filter(|p| targets_dmg(&label(&dir, p)))
        .collect();
    if roms.is_empty() {
        println!("SKIP gb-mooneye: no DMG .gb files under {}", dir.display());
        return;
    }
    let limit = frame_limit();
    let mut passed = 0usize;
    let mut failed = Vec::new();
    for rom in &roms {
        let name = label(&dir, rom);
        let Ok(bytes) = std::fs::read(rom) else {
            failed.push(format!("{name}: unreadable"));
            continue;
        };
        let mut machine = match machine_for(&bytes) {
            Ok(m) => m,
            Err(e) => {
                println!("  FAIL {name}: {e}");
                failed.push(name);
                continue;
            }
        };
        machine.reset(crate::core::device::ResetKind::Cold);
        run(&mut machine, limit, |m| {
            matches!(
                verdict_registers(m),
                Some(MOONEYE_PASS) | Some(MOONEYE_FAIL)
            )
        });
        match verdict_registers(&machine) {
            Some(MOONEYE_PASS) => {
                passed += 1;
                println!("  pass {name}");
            }
            Some(MOONEYE_FAIL) => {
                println!("  FAIL {name}");
                failed.push(name);
            }
            Some(regs) => {
                println!(
                    "  TIME {name}: no verdict in {limit} frames \
                     (B={:02x} C={:02x} D={:02x} E={:02x} H={:02x} L={:02x})",
                    regs[0], regs[1], regs[2], regs[3], regs[4], regs[5]
                );
                failed.push(name);
            }
            None => {
                println!("  ???? {name}: could not read the register file");
                failed.push(name);
            }
        }
    }
    println!(
        "mooneye acceptance (DMG subset): {passed}/{} ROMs passed",
        roms.len()
    );
    println!("  {} still failing", failed.len());
    // Not an assertion, deliberately. `ROADMAP.md` §0 asks for a *measured*
    // number and a ledger that only shrinks; the suite covers behaviours this
    // machine does not claim (the pixel FIFO above all), and asserting on it
    // would make the number a pass/fail rather than a measurement.
}

// ---------------------------------------------------------------------------
// Ungated: proof that the harness itself works
// ---------------------------------------------------------------------------

#[test]
fn the_harness_reads_a_synthetic_roms_serial_output() {
    // Not gated, so a skip above really means "no corpus" rather than "the
    // runner is broken". A tiny ROM that sends `Hi` and then loops.
    let program: &[u8] = &[
        0x3e, b'H', // LD A,'H'
        0xe0, 0x01, // LDH ($ff01),A
        0x3e, 0x81, // LD A,$81
        0xe0, 0x02, // LDH ($ff02),A
        0x3e, b'i', // LD A,'i'
        0xe0, 0x01, // LDH ($ff01),A
        0x3e, 0x81, // LD A,$81
        0xe0, 0x02, // LDH ($ff02),A
        0x18, 0xfe, // JR -2
    ];
    let rom = super::cart::synthetic_image(2, 0x00, 0x00, program);
    let mut machine = machine_for(&rom).expect("the shipped machine builds");
    machine.reset(crate::core::device::ResetKind::Cold);
    let done = run(&mut machine, 4, |m| {
        serial_transcript(m).unwrap_or_default() == "Hi"
    });
    assert!(done, "the transcript never reached `Hi`");
}

#[test]
fn the_harness_reads_the_register_file_and_the_frame_counter() {
    // `LD B,3 ; LD C,5 ; ... ; JR -2` — the mooneye success pattern, written by
    // hand so that the *decoder* is what is under test rather than a ROM.
    let program: &[u8] = &[
        0x06, 3, // LD B,3
        0x0e, 5, // LD C,5
        0x16, 8, // LD D,8
        0x1e, 13, // LD E,13
        0x26, 21, // LD H,21
        0x2e, 34, // LD L,34
        0x18, 0xfe, // JR -2
    ];
    let rom = super::cart::synthetic_image(2, 0x00, 0x00, program);
    let mut machine = machine_for(&rom).expect("the shipped machine builds");
    machine.reset(crate::core::device::ResetKind::Cold);
    let done = run(&mut machine, 4, |m| {
        verdict_registers(m) == Some(MOONEYE_PASS)
    });
    assert!(done, "the register pattern was never seen");
    // And the LCD really did run while that happened.
    assert!(frames(&machine).is_some(), "the frame counter decodes");
}

#[test]
fn only_the_dmg_variants_of_the_suite_are_selected() {
    assert!(targets_dmg("acceptance/timer/tim00.gb"));
    assert!(targets_dmg("acceptance/boot_regs-dmgABC.gb"));
    assert!(targets_dmg("acceptance/di_timing-GS.gb"));
    assert!(!targets_dmg("acceptance/boot_regs-sgb.gb"));
    assert!(!targets_dmg("acceptance/boot_div-dmg0.gb"));
    assert!(!targets_dmg("acceptance/boot_hwio-S.gb"));
}