refactor: split into gameboy-rust workspace with core, demos, and desktop crates

Move the emulator into crates/gba-core, the original demo programs into
crates/gba-demos, and the minifb frontend into crates/desktop. The binary
is now gameboy-rust. Imports change from gba_rust to gba_core/gba_demos;
no behavior changes.

gba-core has no dependencies. Its integration tests use gba-demos as a
development dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
0xrsydn 2026-10-05 16:07:32 +07:00
commit f8a0676748
113 changed files with 304 additions and 282 deletions

View file

@ -1,351 +0,0 @@
use gba_rust::{
cpu::Cpu,
display::{
CYCLES_PER_FRAME as FRAME, CYCLES_PER_LINE as LINE, HBLANK_START as HB, VBLANK_START as VB,
},
dma::DMA_BASE,
io::*,
machine::Machine,
memory::{Memory, OAM_START as OAM, PALETTE_START as PAL, ROM_START, VRAM_START as VRAM},
video::{rgb555_to_rgb888 as rgb, Framebuffer, HEIGHT, WIDTH},
};
fn color(row: usize) -> u16 {
((row % 64 + 1) * 0x101) as u16
}
fn scene() -> Memory {
let mut m = Memory::new(vec![]).unwrap();
m.set_scanline_rendering(true);
m.write16(DISPCNT, 0x1140).unwrap();
m.write16(BG0CNT, 0x10c0).unwrap(); // 8bpp, mosaic, map block16.
for index in 0..64 {
m.write16(PAL + (index + 1) * 2, color(index as usize))
.unwrap();
m.write16(PAL + 0x200 + (index + 1) * 2, color(index as usize))
.unwrap();
}
// Eight text tiles, each with a distinct color per row. The map repeats vertically.
for y in 0..64 {
for x in (0..8).step_by(2) {
m.write16(VRAM + y * 8 + x, (y as u16 + 1) * 0x101).unwrap();
}
}
for y in 0..32 {
for x in 0..32 {
m.write16(VRAM + 0x8000 + (y * 32 + x) * 2, (y % 8) as u16)
.unwrap();
}
}
for index in 0..128 {
m.write16(OAM + index * 8, 0x200).unwrap();
}
m.write16(OAM, 0x3000).unwrap(); // Mosaic 8bpp sprite at (32,0), 16x16.
m.write16(OAM + 2, 0x4020).unwrap();
for y in 0..16 {
for x in (0..16).step_by(2) {
let tile = y / 8 * 2 + x / 8;
m.write16(
VRAM + 0x10000 + tile * 64 + y % 8 * 8 + x % 8,
(y as u16 + 1) * 0x101,
)
.unwrap();
}
}
m
}
fn bitmap() -> Memory {
let mut m = Memory::new(vec![]).unwrap();
m.set_scanline_rendering(true);
m.write16(DISPCNT, 0x403).unwrap();
m.write16(BG2CNT, 0x40).unwrap();
m.write16(BG2PA, 256).unwrap();
m.write16(BG2PD, 256).unwrap();
for y in 0..160 {
for x in 0..240 {
m.write16(VRAM + (y * 240 + x) * 2, color(y as usize))
.unwrap();
}
}
m
}
fn present(m: &Memory) -> Framebuffer {
let mut f = Framebuffer::default();
assert!(m.present_frame(&mut f).unwrap());
f
}
fn finish(m: &mut Memory) -> Framebuffer {
let p = m.display_position();
m.advance_cycles(VB - u32::from(p.scanline) * LINE - u32::from(p.line_cycle));
present(m)
}
fn at(f: &Framebuffer, x: usize, y: usize) -> u32 {
f.pixels()[y * WIDTH + x]
}
#[test]
fn all_static_heights_match_snapshots_with_independent_background_and_sprite_sizes() {
for height in 1..=16 {
let mut m = scene();
m.write16(MOSAIC, ((height - 1) << 4) | ((16 - height) << 12))
.unwrap();
let mut expected = Framebuffer::default();
m.render_frame(&mut expected).unwrap();
assert_eq!(
finish(&mut m).pixels(),
expected.pixels(),
"height={height}"
);
}
}
#[test]
fn growing_a_block_preserves_counter_instead_of_realigning_to_screen_y() {
let mut m = scene();
m.write16(MOSAIC, 0x30).unwrap(); // BG4, OBJ1.
m.advance_cycles(5 * LINE + HB);
m.write8(MOSAIC, 0x70).unwrap(); // BG8: phase1 remains phase1.
let f = finish(&mut m);
for y in 0..24 {
let source = if y < 4 {
0
} else if y < 12 {
4
} else if y < 20 {
12
} else {
20
};
assert_eq!(at(&f, 0, y), rgb(color(source)), "row={y}");
if y < 16 {
assert_eq!(at(&f, 32, y), rgb(color(y)));
}
}
let mut snapshot = Framebuffer::default();
m.render_frame(&mut snapshot).unwrap();
assert_eq!(at(&snapshot, 0, 8), rgb(color(8)));
assert_eq!(at(&f, 0, 8), rgb(color(4)));
assert_eq!(present(&m).pixels(), f.pixels());
}
#[test]
fn shrinking_below_current_phase_waits_for_four_bit_wrap() {
let mut m = scene();
m.write16(MOSAIC, 0x70).unwrap();
m.advance_cycles(6 * LINE);
m.write8(MOSAIC, 0x10).unwrap(); // Phase6 > new height2; no immediate reset.
m.advance_cycles(0);
let f = finish(&mut m);
for y in 0..24 {
let source = if y < 16 { 0 } else { y / 2 * 2 };
assert_eq!(at(&f, 0, y), rgb(color(source)), "row={y}");
}
}
#[test]
fn affine_mosaic_advances_by_current_height_when_counter_wraps_not_by_elapsed_rows() {
let mut m = bitmap();
m.write16(MOSAIC, 0x70).unwrap();
m.advance_cycles(6 * LINE + HB);
m.write8(MOSAIC, 0x10).unwrap();
let f = finish(&mut m);
for y in 0..24 {
let source = if y < 16 { 0 } else { 2 + (y - 16) / 2 * 2 };
assert_eq!(at(&f, 0, y), rgb(color(source)), "row={y}");
}
}
#[test]
fn affine_reference_writes_and_coefficients_do_not_reset_shared_mosaic_phase() {
let mut m = bitmap();
m.write16(MOSAIC, 0x70).unwrap();
m.advance_cycles(5 * LINE);
m.write32(BG2Y, 20 * 256).unwrap();
m.write16(BG2PD, 2 * 256).unwrap();
m.write8(MOSAIC, 0x10).unwrap();
let f = finish(&mut m);
for y in 0..20 {
let source = if y < 5 {
0
} else if y < 16 {
20
} else {
24 + (y - 16) / 2 * 4
};
assert_eq!(at(&f, 0, y), rgb(color(source)), "row={y}");
}
}
#[test]
fn mosaic_flags_and_display_enable_do_not_gate_counters() {
for disabled in [false, true] {
let mut m = scene();
m.write16(MOSAIC, 0x7070).unwrap();
m.write16(BG0CNT, 0x1080).unwrap();
m.write16(OAM, 0x2000).unwrap();
if disabled {
m.write16(DISPCNT, 0).unwrap();
}
m.advance_cycles(6 * LINE);
m.write16(MOSAIC, 0x1010).unwrap();
m.write16(BG0CNT, 0x10c0).unwrap();
m.write16(OAM, 0x3000).unwrap();
m.write16(DISPCNT, 0x1140).unwrap();
let f = finish(&mut m);
for y in 6..16 {
assert_eq!(at(&f, 0, y), rgb(color(0)));
// Row6 was prepared on row5, before the attribute/enable write.
let source = if y == 6 && !disabled { 6 } else { 0 };
assert_eq!(at(&f, 32, y), rgb(color(source)));
}
}
}
#[test]
fn forced_blank_does_not_pause_counters() {
let mut m = scene();
m.write16(MOSAIC, 0x7070).unwrap();
m.write16(DISPCNT, 0x11c0).unwrap();
m.advance_cycles(6 * LINE);
m.write16(MOSAIC, 0x1010).unwrap();
m.write16(DISPCNT, 0x1140).unwrap();
let f = finish(&mut m);
assert!(f.pixels()[..6 * WIDTH].iter().all(|&p| p == 0xffffff));
for y in 6..16 {
assert_eq!(at(&f, 0, y), rgb(color(0)));
}
}
#[test]
fn sprite_counter_is_independent_and_clamps_partial_first_blocks_before_transform() {
for affine in [false, true] {
let mut m = scene();
m.write16(OAM, if affine { 0x3103 } else { 0x3003 })
.unwrap(); // y3.
if affine {
m.write16(OAM + 6, 256).unwrap();
m.write16(OAM + 30, 256).unwrap();
}
m.write16(MOSAIC, 0x7030).unwrap(); // BG4, OBJ8.
m.advance_cycles(6 * LINE);
m.write8(MOSAIC + 1, 0x10).unwrap(); // OBJ2 only.
let f = finish(&mut m);
for y in 0..24 {
assert_eq!(at(&f, 0, y), rgb(color(y / 4 * 4)));
let source = if !(3..19).contains(&y) {
y / 4 * 4
} else if y < 16 {
0
} else if y < 18 {
13
} else {
15
};
assert_eq!(at(&f, 32, y), rgb(color(source)), "affine={affine} row={y}");
}
}
}
#[test]
fn object_window_ignores_live_mosaic_phase() {
let mut m = scene();
m.write16(DISPCNT, 0x9140).unwrap();
m.write16(WINOUT, 0x0100).unwrap(); // Show BG only inside OBJ window.
m.write16(BG0CNT, 0x1080).unwrap();
m.write16(OAM, 0x3800).unwrap(); // Mosaic-enabled window sprite.
for y in (1..16).step_by(2) {
for x in (0..16).step_by(2) {
let tile = y / 8 * 2 + x / 8;
m.write16(VRAM + 0x10000 + tile * 64 + y % 8 * 8 + x % 8, 0)
.unwrap();
}
}
m.write16(MOSAIC, 0x7000).unwrap();
m.advance_cycles(6 * LINE);
m.write16(MOSAIC, 0x1000).unwrap();
let f = finish(&mut m);
for y in 0..24 {
assert_eq!(
at(&f, 32, y),
if y < 16 && y % 2 == 0 {
rgb(color(y))
} else {
0
}
);
}
}
#[test]
fn vertical_hold_uses_live_palette_but_sprite_vram_changes_wait_for_preparation() {
let mut m = scene();
m.write16(MOSAIC, 0x7070).unwrap();
m.advance_cycles(3 * LINE + HB);
m.write16(PAL + 2, 31).unwrap();
m.write16(VRAM + 0x10000, 0x0202).unwrap();
let f = finish(&mut m);
assert_eq!(at(&f, 0, 3), rgb(color(0)));
assert_eq!(at(&f, 0, 4), rgb(31));
assert_eq!(at(&f, 32, 3), rgb(color(0)));
assert_eq!(at(&f, 32, 4), rgb(color(0))); // Prepared before row3 HBlank.
assert_eq!(at(&f, 32, 5), rgb(color(1)));
}
#[test]
fn hblank_dma_size_writes_preserve_phase_and_affect_next_line_transition() {
let mut m = scene();
m.write16(MOSAIC, 0x7070).unwrap();
for y in 0..160 {
m.write16(0x0200_0000 + y * 2, if y < 6 { 0x7070 } else { 0x3010 })
.unwrap();
}
m.write32(DMA_BASE, 0x0200_0000).unwrap();
m.write32(DMA_BASE + 4, MOSAIC).unwrap();
m.write32(DMA_BASE + 8, 0xa240_0001).unwrap();
m.write8(HALTCNT, 0).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), m);
machine.run_until_vblank(1000).unwrap();
let f = present(machine.memory());
// Independent recurrence, driven by the table value written after each row.
let (mut bg, mut obj) = (0usize, 0usize);
for y in 0..HEIGHT {
assert_eq!(at(&f, 0, y), rgb(color(y - bg)), "row={y}");
if y < 16 {
assert_eq!(at(&f, 32, y), rgb(color(y - obj)));
}
let (bg_height, obj_height) = if y < 6 { (8, 8) } else { (2, 4) };
bg = if bg + 1 == bg_height {
0
} else {
(bg + 1) % 16
};
obj = if obj + 1 == obj_height {
0
} else {
(obj + 1) % 16
};
}
assert!(machine.halted());
}
#[test]
fn vblank_resets_counters_and_capture_toggles_do_not_reset_them() {
let mut m = scene();
m.set_scanline_rendering(false);
m.write16(MOSAIC, 0x7070).unwrap();
m.advance_cycles(6 * LINE);
m.write16(MOSAIC, 0x1010).unwrap();
m.advance_cycles(VB - 6 * LINE);
m.write16(MOSAIC, 0x5050).unwrap(); // New size during VBlank.
m.advance_cycles(FRAME - VB);
m.set_scanline_rendering(true);
let f = finish(&mut m);
for y in 0..16 {
assert_eq!(at(&f, 0, y), rgb(color(y / 6 * 6)));
assert_eq!(at(&f, 32, y), rgb(color(y / 6 * 6)));
}
}