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

@ -0,0 +1,172 @@
use super::*;
#[test]
fn alpha_uses_five_bit_channels_clamps_coefficients_and_saturates_sums() {
let mut m = setup();
m.write16(BLDCNT, 0x0241).unwrap(); // BG0 with BG1.
for (coefficients, expected) in [
(0, 0),
(0x0808, 0x01ef),
(0x0010, 31),
(0x1000, 0x3e0),
(0x1f1f, 0x3ff),
] {
m.write16(BLDALPHA, coefficients).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(expected));
}
m.write16(PAL + 2, 0x7fff).unwrap();
m.write16(PAL + 4, 0x7fff).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x7fff));
m.write16(PAL + 2, 1).unwrap();
m.write16(PAL + 4, 1).unwrap();
m.write16(BLDALPHA, 0x0808).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(1)); // Sum fractional terms before rounding.
}
#[test]
fn alpha_requires_top_first_target_and_immediate_second_target() {
let mut m = setup();
m.write16(BLDALPHA, 0x0808).unwrap();
for control in [0x0441, 0x0242, 0x0142, 0x2041, 0x0240] {
m.write16(BLDCNT, control).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(31), "control={control:x}");
}
m.write16(BLDCNT, 0x0441).unwrap();
m.write16(VRAM + 0x4000, 0).unwrap(); // Transparent BG1 exposes BG2.
assert_eq!(pixel(&m, 0, 0), rgb(0x3c0f));
assert_eq!(pixel(&m, 4, 0), rgb(31));
}
#[test]
fn windows_remove_layers_before_selecting_blend_targets() {
let mut m = setup();
m.write16(BLDALPHA, 0x0808).unwrap();
m.write16(BLDCNT, 0x0441).unwrap();
window(&mut m, 0x0008, 0x0008, 0x25, 0x3f); // Hide BG1 only inside.
assert_eq!(pixel(&m, 0, 0), rgb(0x3c0f));
assert_eq!(pixel(&m, 8, 0), rgb(31));
}
#[test]
fn brightness_uses_selected_top_layer_and_clamped_write_only_coefficient() {
let mut m = setup();
m.write16(PAL + 2, 0x4210).unwrap();
for (control, coefficient, expected) in [
(0x81, 0, 0x4210),
(0x81, 8, 0x5ef7),
(0x81, 31, 0x7fff),
(0xc1, 8, 0x2108),
(0xc1, 16, 0),
(0xc1, 31, 0),
(0x82, 16, 0x4210),
(0x01, 16, 0x4210),
] {
m.write16(BLDCNT, control).unwrap();
m.write32(BLDY, coefficient).unwrap();
assert_eq!(m.read16(BLDY).unwrap(), 0);
assert_eq!(pixel(&m, 0, 0), rgb(expected));
}
m.write16(BLDCNT, 0x81).unwrap();
m.write8(BLDY, 8).unwrap();
m.write8(BLDY + 1, 0xff).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x5ef7));
}
#[test]
fn backdrop_is_a_target_but_cannot_blend_with_itself() {
let mut m = setup();
m.write16(DISPCNT, 0).unwrap();
m.write16(BLDCNT, 0x2060).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x4210));
m.write16(BLDCNT, 0xa0).unwrap();
m.write16(BLDY, 16).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x7fff));
m.write16(DISPCNT, 0x100).unwrap();
m.write16(BLDCNT, 0x2041).unwrap();
m.write16(BLDALPHA, 0x1000).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x4210)); // Backdrop is not pre-brightened.
}
#[test]
fn object_can_be_first_or_second_target_but_never_blends_with_another_object() {
let mut m = setup();
m.write16(DISPCNT, 0x1100).unwrap();
object(&mut m, 0, 0, 0, 0);
object(&mut m, 1, 0, 0, 0xc00); // Lower priority; never a second OBJ target.
m.write16(BLDALPHA, 0x0808).unwrap();
m.write16(BLDCNT, 0x0150).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x3dff)); // White OBJ + red BG0.
m.write16(BLDCNT, 0x1050).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x7fff)); // No OBJ-to-OBJ blend.
object(&mut m, 0, 0, 0, 0x400); // OBJ0 below BG0; later OBJ still excluded.
m.write16(BLDCNT, 0x1041).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x3dff));
}
#[test]
fn semi_transparent_obj_overrides_mode_and_first_target_selection() {
let mut m = setup();
m.write16(DISPCNT, 0x1100).unwrap();
object(&mut m, 0, 0x400, 0, 0);
m.write16(BLDALPHA, 0x0808).unwrap();
m.write16(BLDY, 16).unwrap();
for mode in 0..4 {
m.write16(BLDCNT, 0x100 | (mode << 6)).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x3dff));
}
window(&mut m, 0x0004, 0x0008, 0x11, 0x31);
assert_eq!(pixel(&m, 0, 0), rgb(0x7fff)); // Window can disable forced alpha.
assert_eq!(pixel(&m, 4, 0), rgb(0x3dff));
}
#[test]
fn semi_transparent_obj_falls_back_to_selected_brightness_without_second_target() {
let mut m = setup();
m.write16(DISPCNT, 0x1100).unwrap();
object(&mut m, 0, 0x400, 0, 0);
m.write16(BLDY, 16).unwrap();
for (control, expected) in [(0xc0, 0x7fff), (0xd0, 0), (0x10, 0x7fff), (0x1d0, 0)] {
m.write16(BLDCNT, control).unwrap();
// Last case has a second target; zero alpha coefficients produce black.
assert_eq!(pixel(&m, 0, 0), rgb(expected));
}
m.write16(BLDALPHA, 0x0808).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x3dff)); // Alpha takes precedence over brightness.
}
#[test]
fn color_effects_work_in_affine_and_all_bitmap_modes() {
for mode in 1..=5 {
let mut m = setup();
m.write16(DISPCNT, 0x400 | mode).unwrap();
m.write16(BG2CNT, 0).unwrap();
m.write16(BG2PA, 256).unwrap();
m.write16(BG2PD, 256).unwrap();
if mode <= 2 {
m.write16(BG2CNT, 0x1000).unwrap(); // Byte map at 0x8000, tile0 at zero.
m.write16(VRAM + 0x8000, 0).unwrap();
m.write16(VRAM, 0x0101).unwrap();
} else {
m.write16(VRAM, if mode == 4 { 0x0101 } else { 31 })
.unwrap();
}
m.write16(BLDCNT, 0x2044).unwrap();
m.write16(BLDALPHA, 0x1000).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x4210), "mode={mode}");
window(&mut m, 0x0001, 0x0001, 4, 0x24);
assert_eq!(pixel(&m, 0, 0), rgb(31));
}
}
#[test]
fn green_swap_follows_effects_and_forced_blank_bypasses_every_effect() {
let mut m = setup();
m.write16(PAL + 2, 0x3e0).unwrap();
m.write16(BLDCNT, 0xc1).unwrap();
m.write16(BLDY, 16).unwrap();
window(&mut m, 0x0001, 0x0001, 1, 0x21);
m.write16(GREENSWAP, 1).unwrap();
assert_eq!(&render(&m).pixels()[..2], &[0, 0xff00]);
m.write16(DISPCNT, 0xffff).unwrap();
assert!(render(&m).pixels().iter().all(|&p| p == 0xffffff));
}

View file

@ -0,0 +1,166 @@
use super::*;
use gba_core::input::{Button, Buttons};
use gba_demos::{
effects_demo::{EffectsDemo, EFFECTS_STATE},
tile_demo::TileDemo,
};
#[test]
fn cpu_demo_moves_wraps_cancels_resets_and_updates_effect_registers_during_vblank() {
let mut demo = EffectsDemo::new().unwrap();
let mut frame = Framebuffer::default();
assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0);
let mut expected_x = 0u32;
let mut expected_y = 0u32;
let sequence = [
Buttons::default(),
Buttons::default()
.with(Button::Left, true)
.with(Button::Up, true),
Buttons::default()
.with(Button::Right, true)
.with(Button::Down, true),
Buttons::default()
.with(Button::Right, true)
.with(Button::Left, true),
Buttons::default().with(Button::A, true),
Buttons::default().with(Button::B, true),
Buttons::default()
.with(Button::A, true)
.with(Button::B, true),
Buttons::default()
.with(Button::Start, true)
.with(Button::Right, true),
Buttons::default(),
];
for (i, buttons) in sequence.into_iter().enumerate() {
let before = demo.machine().cycles();
demo.frame(buttons, &mut frame).unwrap();
let memory = demo.machine().memory();
// Explicit expected movements keep this independent of the ARM program.
if i == 1 {
expected_x = 510;
expected_y = 510;
}
if i == 2 || i == 7 {
expected_x = 0;
expected_y = 0;
}
assert_eq!(memory.read32(EFFECTS_STATE).unwrap(), i as u32 + 1);
assert_eq!(memory.read32(EFFECTS_STATE + 4).unwrap(), expected_x);
assert_eq!(memory.read32(EFFECTS_STATE + 8).unwrap(), expected_y);
assert_eq!(memory.read16(DISPCNT).unwrap(), 0x3340);
assert_eq!(memory.read16(WININ).unwrap(), 0x1f);
assert_eq!(memory.read16(WINOUT).unwrap(), 0x3f);
assert_eq!(memory.read16(BLDALPHA).unwrap(), 0x808);
assert_eq!(
memory.read16(BLDCNT).unwrap(),
match i {
4 | 6 => 0x251,
5 => 0xff,
_ => 0xbf,
}
);
assert_eq!(memory.display_position().scanline, 160);
assert!(demo.machine().cycles() > before);
// The center is in WIN0 and therefore unchanged by any effect.
assert_eq!(frame.pixels()[80 * WIDTH + 120], rgb(0x7fff));
}
}
#[test]
fn cpu_brightness_demo_matches_tile_scene_inside_and_outside_moving_window() {
let mut demo = EffectsDemo::new().unwrap();
let mut base = TileDemo::new().unwrap();
let mut actual = Framebuffer::default();
let mut original = Framebuffer::default();
// Include wrapped scroll, affine objects, and brightness changes.
for (index, buttons) in [
Buttons::default()
.with(Button::Left, true)
.with(Button::Up, true),
Buttons::default()
.with(Button::B, true)
.with(Button::L, true),
Buttons::default()
.with(Button::B, true)
.with(Button::R, true),
Buttons::default().with(Button::Start, true),
]
.into_iter()
.enumerate()
{
demo.frame(buttons, &mut actual).unwrap();
base.frame(buttons, &mut original).unwrap();
let (left, top) = if index < 3 { (62, 38) } else { (64, 40) };
for y in 0..HEIGHT {
for x in 0..WIDTH {
let old = original.pixels()[y * WIDTH + x];
// Recover the exact five-bit channels from RGB888.
let channels = [(old >> 19) & 31, (old >> 11) & 31, (old >> 3) & 31];
let mut expected = 0u16;
for (channel, shift) in channels.into_iter().zip([0, 5, 10]) {
let changed = if (left..left + 112).contains(&x) && (top..top + 80).contains(&y)
{
channel
} else if index == 1 || index == 2 {
channel - channel / 2
} else {
channel + (31 - channel) / 2
};
expected |= (changed as u16) << shift;
}
assert_eq!(
actual.pixels()[y * WIDTH + x],
rgb(expected),
"frame={index} pixel={x},{y}"
);
}
}
}
}
#[test]
fn cpu_window_demo_retains_active_region_when_bottom_moves_beyond_vcount_range() {
let mut demo = EffectsDemo::new().unwrap();
let mut frame = Framebuffer::default();
for index in 0..54 {
demo.frame(Buttons::default().with(Button::Down, true), &mut frame)
.unwrap();
// x=80 avoids foreground crosses and sprites. It stays inside WIN0's X bounds.
let sy = 2 * (index + 1);
let raw: u16 =
[0x260, 0x3a0, 0x7d20, 0x7e80][((80 / 32 + sy / 32) % 2) * 2 + (80 / 2 + sy / 2) % 2];
let expected = if index == 53 {
// New bottom=228 cannot match any line. Last frame's active flag survives.
raw
} else {
[0, 5, 10].into_iter().fold(0, |value, shift| {
let channel = (raw >> shift) & 31;
value | ((channel + (31 - channel) / 2) << shift)
})
};
assert_eq!(frame.pixels()[80], rgb(expected), "frame={index}");
}
let mut snapshot = Framebuffer::default();
demo.machine().memory().render_frame(&mut snapshot).unwrap();
assert_ne!(snapshot.pixels()[80], frame.pixels()[80]);
}
#[test]
fn dma_word_write_updates_selection_and_coefficients_together() {
use gba_core::{
cpu::Cpu,
dma::DMA_BASE,
machine::{Machine, StepKind},
};
let mut m = setup();
m.write32(0x0200_0000, 0x0808_0241).unwrap();
m.write32(DMA_BASE + 36, 0x0200_0000).unwrap();
m.write32(DMA_BASE + 40, BLDCNT).unwrap();
m.write32(DMA_BASE + 44, 0x8400_0001).unwrap();
let mut machine = Machine::new(Cpu::new(0x0300_0000), m);
assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 3 });
assert_eq!(machine.memory().read32(BLDCNT).unwrap(), 0x0808_0241);
assert_eq!(pixel(machine.memory(), 0, 0), rgb(0x1ef));
}

View file

@ -0,0 +1,176 @@
use super::*;
#[test]
fn register_masks_byte_merges_and_write_only_reads() {
let mut m = setup();
for (address, mask) in [
(WININ, 0x3f3f),
(WINOUT, 0x3f3f),
(BLDCNT, 0x3fff),
(BLDALPHA, 0x1f1f),
] {
assert_eq!(m.read16(address).unwrap(), 0);
m.write16(address, 0xffff).unwrap();
assert_eq!(m.read16(address).unwrap(), mask);
m.write8(address, 0).unwrap();
assert_eq!(m.read16(address).unwrap(), mask & 0xff00);
m.write8(address + 1, 0).unwrap();
assert_eq!(m.read16(address).unwrap(), 0);
}
for address in [WIN0H, WIN1H, WIN0V, WIN1V, BLDY] {
m.write16(address, 0xffff).unwrap();
assert_eq!(m.read16(address).unwrap(), 0);
}
m.write32(BLDY, u32::MAX).unwrap();
assert_eq!(m.read32(BLDY).unwrap(), 0);
assert_eq!(
m.read8(0x0400_0058),
Err(MemoryError::Unmapped(0x0400_0058))
);
assert_eq!(
m.read8(0x0401_0048),
Err(MemoryError::Unmapped(0x0401_0048))
);
assert_eq!(
m.write16(WININ + 1, 1),
Err(MemoryError::Unaligned(WININ + 1))
);
}
#[test]
fn no_enabled_windows_ignore_zero_masks_but_apply_effects() {
let mut m = setup();
m.write16(BLDCNT, 0xc1).unwrap();
m.write16(BLDY, 16).unwrap();
assert_eq!(pixel(&m, 0, 0), 0);
m.write16(DISPCNT, 0x2700).unwrap(); // Empty WIN0; outside mask is zero.
assert_eq!(pixel(&m, 0, 0), rgb(0x4210));
}
#[test]
fn bounds_are_half_open_and_byte_writes_preserve_other_bound() {
let mut m = setup();
window(&mut m, 0, 0, 1, 2);
m.write8(WIN0H, 30).unwrap();
m.write8(WIN0H + 1, 10).unwrap();
m.write8(WIN0V, 40).unwrap();
m.write8(WIN0V + 1, 20).unwrap();
let f = render(&m);
for y in 0..HEIGHT {
for x in 0..WIDTH {
let inside = (10..30).contains(&x) && (20..40).contains(&y);
assert_eq!(
f.pixels()[y * WIDTH + x],
rgb(if inside { 31 } else { 0x3e0 })
);
}
}
}
#[test]
fn inverted_bounds_wrap_equal_bounds_are_empty_and_offscreen_bounds_clip() {
let mut m = setup();
for (h, v) in [
(0xdc14, 0x960a),
(0xfafa, 0x00ff),
(0xf005, 0xa005),
(0x00ff, 0x00ff),
] {
window(&mut m, h, v, 1, 2);
let f = render(&m);
for y in 0..HEIGHT {
for x in 0..WIDTH {
// Independent modular-distance definition of a half-open interval.
let inside = (x as u8).wrapping_sub((h >> 8) as u8)
< (h as u8).wrapping_sub((h >> 8) as u8)
&& (y as u8).wrapping_sub((v >> 8) as u8)
< (v as u8).wrapping_sub((v >> 8) as u8);
assert_eq!(
f.pixels()[y * WIDTH + x],
rgb(if inside { 31 } else { 0x3e0 })
);
}
}
}
}
#[test]
fn win0_overrides_win1_without_combining_masks() {
let mut m = setup();
window(&mut m, 0x0a1e, 0x0a1e, 0x0200, 4); // WIN0 hides all; WIN1 selects BG1.
m.write16(WIN1H, 0x1428).unwrap();
m.write16(WIN1V, 0x1428).unwrap();
m.write16(DISPCNT, 0x6700).unwrap();
assert_eq!(pixel(&m, 25, 25), rgb(0x4210));
assert_eq!(pixel(&m, 35, 35), rgb(0x03e0));
assert_eq!(pixel(&m, 45, 45), rgb(0x7c00));
m.write16(DISPCNT, 0x6500).unwrap(); // Window cannot enable a globally disabled BG.
assert_eq!(pixel(&m, 35, 35), rgb(0x4210));
}
#[test]
fn window_effect_bit_is_independent_of_layer_bits_and_backdrop_is_always_visible() {
let mut m = setup();
window(&mut m, 0x0008, 0x0008, 1, 0x21);
m.write16(BLDCNT, 0xc1).unwrap();
m.write16(BLDY, 16).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(31));
assert_eq!(pixel(&m, 8, 0), 0);
m.write16(WINOUT, 0x20).unwrap();
m.write16(BLDCNT, 0xa0).unwrap(); // Brighten backdrop.
assert_eq!(pixel(&m, 8, 0), rgb(0x7fff));
}
#[test]
fn object_window_uses_nonzero_texels_not_color_priority_or_visible_obj_mask() {
let mut m = setup();
m.write16(DISPCNT, 0x9700).unwrap();
m.write16(WINOUT, 0x0102).unwrap(); // OBJ region BG0 only, outside BG1 only.
object(&mut m, 0, 0x800, 0, 0xfc00); // Palette15, priority3 are irrelevant.
m.write16(VRAM + 0x10000, 0x1010).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(0x3e0));
assert_eq!(pixel(&m, 1, 0), rgb(31));
assert_eq!(pixel(&m, 8, 0), rgb(0x3e0));
m.write16(DISPCNT, 0x8700).unwrap(); // OBJ master enable is also required.
assert_eq!(pixel(&m, 1, 0), rgb(0x3e0));
m.write16(DISPCNT, 0x1700).unwrap(); // Window OBJ never becomes a visible sprite.
assert_eq!(pixel(&m, 1, 0), rgb(31));
}
#[test]
fn visible_objects_do_not_occlude_object_window_and_rectangles_take_precedence() {
let mut m = setup();
m.write16(DISPCNT, 0x9700).unwrap();
m.write16(WINOUT, 0x0102).unwrap();
object(&mut m, 0, 0, 0, 0); // Occupies the same pixels as the later window OBJ.
object(&mut m, 127, 0x800, 0, 0);
assert_eq!(pixel(&m, 0, 0), rgb(31)); // OBJ mask hides color OBJ, not window coverage.
window(&mut m, 0x0002, 0x0008, 4, 0x0102);
assert_eq!(pixel(&m, 0, 0), rgb(0x7c00));
assert_eq!(pixel(&m, 2, 0), rgb(31));
m.write16(WIN1H, 0x0004).unwrap();
m.write16(WIN1V, 0x0008).unwrap();
m.write16(WININ, 0x0204).unwrap();
m.write16(DISPCNT, 0xf700).unwrap();
assert_eq!(pixel(&m, 2, 0), rgb(0x3e0));
assert_eq!(pixel(&m, 4, 0), rgb(31));
}
#[test]
fn object_window_supports_regular_flips_and_affine_sampling() {
let mut m = setup();
m.write16(DISPCNT, 0x9700).unwrap();
m.write16(WINOUT, 0x0102).unwrap();
for offset in (0..32).step_by(2) {
m.write16(VRAM + 0x10000 + offset, 0).unwrap();
}
m.write16(VRAM + 0x10000, 1).unwrap();
object(&mut m, 0, 0x800, 0x1000, 0);
assert_eq!(pixel(&m, 7, 0), rgb(31));
assert_eq!(pixel(&m, 0, 0), rgb(0x3e0));
object(&mut m, 0, 0x900, 0, 0);
m.write16(OAM + 6, 256).unwrap();
m.write16(OAM + 30, 256).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb(31));
assert_eq!(pixel(&m, 1, 0), rgb(0x3e0));
}