From 28d40587f035ef9cc204fa83ce010f0c6e79d902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 18 Jul 2024 16:07:17 +0200 Subject: [PATCH] Add basic sprite drawing (#8) * Implement PUSH2 opcode * Implement DEO2 opcode * Implement Sprite DEO --- coco-core/src/lib.rs | 69 +++++++++++++++++++++++++++++++++-- coco-core/src/opcodes.rs | 2 + coco-core/src/stack.rs | 19 ++++++++++ coco-ui/index.html | 1 + coco-ui/index.js | 2 +- coco-ui/roms/sprite.rom | Bin 0 -> 54 bytes coco-ui/styles.css | 11 ++++-- coco-vm/README.md | 41 ++++++++++++++++++++- coco-vm/src/video.rs | 54 ++++++++++++++++++++++++++- coco-vm/tests/vm_cpu_test.rs | 17 +++++++++ docs/sprite_screenshot.png | Bin 0 -> 7484 bytes 11 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 coco-ui/roms/sprite.rom create mode 100644 docs/sprite_screenshot.png diff --git a/coco-core/src/lib.rs b/coco-core/src/lib.rs index 8b6e233..b08fc7c 100644 --- a/coco-core/src/lib.rs +++ b/coco-core/src/lib.rs @@ -59,8 +59,10 @@ impl Cpu { let op = self.read_byte(); match op { opcodes::BRK => break, - opcodes::PUSH => self.op_push(), opcodes::DEO => self.op_deo(machine), + opcodes::DEO2 => self.op_deo2(machine), + opcodes::PUSH => self.op_push(), + opcodes::PUSH2 => self.op_push2(), _ => {} } } @@ -74,6 +76,12 @@ impl Cpu { &mut self.devices[(D::BASE as usize)..(D::BASE as usize + 0x10)] } + /// Returns a chunk of memory + #[inline] + pub fn ram_peek_byte(&self, addr: u16) -> u8 { + self.ram[addr as usize] + } + /// Returns the current value for the program counter (PC) pub fn pc(&self) -> u16 { self.pc @@ -86,12 +94,27 @@ impl Cpu { res } + #[inline] + fn read_short(&mut self) -> u16 { + let hi = self.ram[self.pc as usize]; + let lo = self.ram[self.pc.wrapping_add(1) as usize]; + self.pc = self.pc.wrapping_add(2); + + u16::from_be_bytes([hi, lo]) + } + #[inline] fn op_push(&mut self) { let value = self.read_byte(); self.stack.push_byte(value); } + #[inline] + fn op_push2(&mut self) { + let value = self.read_short(); + self.stack.push_short(value); + } + #[inline] fn op_deo(&mut self, machine: &mut impl Machine) { let target = self.stack.pop_byte(); @@ -103,6 +126,20 @@ impl Cpu { // callback for I/O machine.deo(self, target); } + + #[inline] + fn op_deo2(&mut self, machine: &mut impl Machine) { + let target = self.stack.pop_byte(); + + // write short value to device port + let value = self.stack.pop_short(); + let [hi, lo] = value.to_be_bytes(); + self.devices[target as usize] = hi; + self.devices[target.wrapping_add(1) as usize] = lo; + + // callback for I/0 + machine.deo(self, target); + } } impl fmt::Display for Cpu { @@ -153,7 +190,7 @@ mod tests { } #[test] - pub fn run_wraps_pc_at_the_end_of_ram() { + fn run_wraps_pc_at_the_end_of_ram() { let mut rom = zeroed_memory(); rom[rom.len() - 1] = 0x01; let mut cpu = Cpu::new(&rom); @@ -165,7 +202,7 @@ mod tests { } #[test] - pub fn push_opcode() { + fn push_opcode() { let rom = rom_from(&[PUSH, 0xab, BRK]); let mut cpu = Cpu::new(&rom); @@ -176,7 +213,18 @@ mod tests { } #[test] - pub fn deo_opcode() { + fn push2_opcode() { + let rom = rom_from(&[PUSH2, 0xab, 0xcd, BRK]); + let mut cpu = Cpu::new(&rom); + + let pc = cpu.run(0x100, &mut AnyMachine {}); + assert_eq!(pc, 0x104); + assert_eq!(cpu.stack.len(), 2); + assert_eq!(cpu.stack.short_at(0), 0xabcd); + } + + #[test] + fn deo_opcode() { let rom = rom_from(&[PUSH, 0xab, PUSH, 0x02, DEO, BRK]); let mut cpu = Cpu::new(&rom); @@ -186,4 +234,17 @@ mod tests { assert_eq!(cpu.devices[0x02], 0xab); // TODO: check AnyMachine.deo has been called with 0xab as target arg } + + #[test] + fn deo2_opcode() { + let rom = rom_from(&[PUSH2, 0xab, 0xcd, PUSH, 0x00, DEO2, BRK]); + let mut cpu = Cpu::new(&rom); + + let pc = cpu.run(0x100, &mut AnyMachine {}); + assert_eq!(pc, 0x107); + assert_eq!(cpu.stack.len(), 0); + assert_eq!(cpu.devices[0x00], 0xab); + assert_eq!(cpu.devices[0x01], 0xcd); + // TODO: check AnyMachine.deo has been called with 0xab as target arg + } } diff --git a/coco-core/src/opcodes.rs b/coco-core/src/opcodes.rs index 4ba5123..bc3ac74 100644 --- a/coco-core/src/opcodes.rs +++ b/coco-core/src/opcodes.rs @@ -1,3 +1,5 @@ pub const BRK: u8 = 0x00; pub const DEO: u8 = 0x17; +pub const DEO2: u8 = 0x37; pub const PUSH: u8 = 0x80; +pub const PUSH2: u8 = 0xa0; diff --git a/coco-core/src/stack.rs b/coco-core/src/stack.rs index 87f9911..60abd01 100644 --- a/coco-core/src/stack.rs +++ b/coco-core/src/stack.rs @@ -23,15 +23,34 @@ impl Stack { self.data[self.index as usize] = x; } + pub fn push_short(&mut self, x: u16) { + let [hi, lo] = x.to_be_bytes(); + self.push_byte(hi); + self.push_byte(lo); + } + pub fn pop_byte(&mut self) -> u8 { let res = self.data[self.index as usize]; self.index = self.index.wrapping_sub(1); res } + pub fn pop_short(&mut self) -> u16 { + let lo = self.pop_byte(); + let hi = self.pop_byte(); + u16::from_be_bytes([hi, lo]) + } + pub fn byte_at(&self, i: u8) -> u8 { return self.data[i as usize]; } + + pub fn short_at(&self, i: u8) -> u16 { + let hi = self.data[i as usize]; + let lo = self.data[i.wrapping_add(1) as usize]; + + u16::from_be_bytes([hi, lo]) + } } impl fmt::Display for Stack { diff --git a/coco-ui/index.html b/coco-ui/index.html index 3b461f8..625c6ec 100644 --- a/coco-ui/index.html +++ b/coco-ui/index.html @@ -19,6 +19,7 @@

👻-8

+
diff --git a/coco-ui/index.js b/coco-ui/index.js index 65736b9..82b0e8a 100644 --- a/coco-ui/index.js +++ b/coco-ui/index.js @@ -51,7 +51,7 @@ async function main() { const _ = await initWasm("./vendor/coco_ui_bg.wasm"); const romSelector = document.querySelector("#coco-rom-selector"); - const defaultRom = "pixel_fill.rom"; + const defaultRom = "sprite.rom"; setupRomSelector(romSelector, defaultRom); setupControls(); diff --git a/coco-ui/roms/sprite.rom b/coco-ui/roms/sprite.rom new file mode 100644 index 0000000000000000000000000000000000000000..7259b1a7d4456997c29dcbc33f09fc4e13134b75 GIT binary patch literal 54 zcmZpOX%G@`aA^=0U%)8VAYtCX&>$twz))V!z+PTnkS<0x12x0x1asprite 0x13y0x1b-- 0x14pixel0x1c-- - 0x15read0x1d-- + 0x15--0x1d-- 0x16--0x1e-- 0x17--0x1f-- @@ -79,3 +79,42 @@ PUSH 00 PUSH 12 DEO # x = 0x00 PUSH 00 PUSH 13 DEO # y = 0x00 PUSH 30 PUSH 13 DEO # fills the foreground with transparent color ``` + +The **`sprite` port** is used to draw sprites (or tiles). A sprite a 8x8 pixel image, with 4 bits per pixel. Writing to this port will take the sprite addressed by the **`address` port** paint it at the coordinates set by the **`x` and `y` ports**. + + + + + + + + + + + + + + + + + + + +
76543210
flip xflip y1bpplayercolor
+ +> **TODO**: `flip x`, `flip y`, `1bpp` and `color` are currently unimplemented as per 2024-07-18. + +Sprite example: + +``` +00777700 +07777770 +67177176 +7f7777f7 +77111177 +77728777 +76777767 +76077067 +``` + +![Sprite screenshot](../docs/sprite_screenshot.png) diff --git a/coco-vm/src/video.rs b/coco-vm/src/video.rs index 9d8dc37..6fc4a95 100644 --- a/coco-vm/src/video.rs +++ b/coco-vm/src/video.rs @@ -16,6 +16,8 @@ impl VideoPorts { const X: u8 = 0x02; const Y: u8 = 0x03; const PIXEL: u8 = 0x04; + const ADDRESS: u8 = 0x08; + const SPRITE: u8 = 0x0a; } pub const SCREEN_WIDTH: u8 = 192; @@ -60,12 +62,20 @@ impl VideoDevice { } #[inline] - fn xy(&self, ports: &mut [u8]) -> (u8, u8) { + fn xy(&self, ports: &[u8]) -> (u8, u8) { let x = cmp::min(ports[VideoPorts::X as usize], (SCREEN_WIDTH - 1) as u8); let y = cmp::min(ports[VideoPorts::Y as usize], (SCREEN_HEIGHT - 1) as u8); (x, y) } + #[inline] + fn address(&self, ports: &[u8]) -> u16 { + let hi = ports[VideoPorts::ADDRESS as usize]; + let lo = ports[VideoPorts::ADDRESS.wrapping_add(1) as usize]; + + u16::from_be_bytes([hi, lo]) + } + fn deo_pixel(&mut self, cpu: &mut Cpu) { self.is_dirty = true; @@ -105,6 +115,44 @@ impl VideoDevice { self.layer(layer)[i] = color; } + fn deo_sprite(&mut self, cpu: &mut Cpu) { + self.is_dirty = true; + let ports = cpu.device_page::(); + let sprite_port = ports[VideoPorts::SPRITE as usize]; + + let (x, y) = self.xy(ports); + let addr = self.address(ports); + let sprite_data = self.sprite_data(addr, cpu); + let layer = (sprite_port & 0b0001_0000) >> 4; + + for spr_y in 0..8 { + for spr_x in 0..8 { + let spr_pixel = sprite_data[spr_y as usize * 8 + spr_x as usize]; + let _x = x + spr_x; + let _y = y + spr_y; + + if _x >= SCREEN_WIDTH || _y >= SCREEN_HEIGHT { + continue; + } + self.put_pixel(_x, _y, spr_pixel, layer); + } + } + } + + fn sprite_data(&self, base_addr: u16, cpu: &Cpu) -> [Pixel; 64] { + let mut addr = base_addr; + let mut res = [0x00; 64]; + for row in 0..8 as usize { + for chunk in 0..4 as usize { + let pixel_data = cpu.ram_peek_byte(addr.wrapping_add(chunk as u16)); + res[row * 8 + chunk * 2 + 0] = (0b1111_0000 & pixel_data) >> 4; + res[row * 8 + chunk * 2 + 1] = 0b0000_1111 & pixel_data; + } + addr = addr.wrapping_add(4); + } + res + } + #[inline] fn layer(&mut self, i: u8) -> &mut VideoBuffer { &mut self.layers[i as usize] @@ -117,6 +165,10 @@ impl Device for VideoDevice { VideoPorts::X => {} VideoPorts::Y => {} VideoPorts::PIXEL => self.deo_pixel(cpu), + VideoPorts::ADDRESS => {} + VideoPorts::SPRITE => { + self.deo_sprite(cpu); + } _ => {} } } diff --git a/coco-vm/tests/vm_cpu_test.rs b/coco-vm/tests/vm_cpu_test.rs index 7db5b96..3d986d7 100644 --- a/coco-vm/tests/vm_cpu_test.rs +++ b/coco-vm/tests/vm_cpu_test.rs @@ -113,3 +113,20 @@ fn test_deo_video_pixel_fill_with_flip() { [0x00; VIDEO_BUFFER_LEN - IDX] ); } + +#[test] +fn test_deo_sprite() { + let rom = [ + PUSH2, 0x01, 0x0c, PUSH, 0x18, DEO2, PUSH, 0x00, PUSH, 0x1a, DEO, BRK, 0x11, 0x11, 0x11, + 0x11, 0x10, 0x00, 0x00, 0x01, 0x10, 0x00, 0x00, 0x01, 0x10, 0x00, 0x00, 0x01, 0x10, 0x00, + 0x00, 0x01, 0x10, 0x00, 0x00, 0x01, 0x10, 0x00, 0x00, 0x01, 0x11, 0x11, 0x11, 0x11, + ]; + let mut cpu = Cpu::new(&rom); + let mut vm = Vm::new(); + + let _ = vm.on_reset(&mut cpu); + let buffer = vm.pixels(); + // println!("{:?}", buffer); + + assert_eq!(buffer[0..8], [0x01; 8]); +} diff --git a/docs/sprite_screenshot.png b/docs/sprite_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..5272cd4535ee5e3d6cf7ac9b6177456f758123c4 GIT binary patch literal 7484 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 literal 0 HcmV?d00001