mirror of
https://github.com/FranLMSP/rmg-001.git
synced 2024-11-27 11:31:33 +00:00
Compare commits
No commits in common. "f90900a4c101e7c37d8ab173385cecde3af2e859" and "6ad3f9f29b30e516c39364776c55cc4d2d74e49d" have entirely different histories.
f90900a4c1
...
6ad3f9f29b
@ -6,12 +6,13 @@ This is just a fun project I'm making for learning and practice purposes. If you
|
|||||||
Any help or suggestion is welcome!
|
Any help or suggestion is welcome!
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
- [x] CPU implementation
|
- [x] CPU Registers implementation
|
||||||
|
- [x] CPU Instructions implementations
|
||||||
- [ ] Interrupts
|
- [ ] Interrupts
|
||||||
- [ ] Timing
|
- [ ] Timing
|
||||||
- [ ] PPU implementations
|
- [ ] PPU implementations
|
||||||
- [ ] Gameboy boot ROM
|
- [ ] Gameboy boot ROM
|
||||||
- [x] Render the pixels
|
- [ ] Render the pixels
|
||||||
- [ ] Gameboy Color compatibility
|
- [ ] Gameboy Color compatibility
|
||||||
- [ ] Sound
|
- [ ] Sound
|
||||||
- [ ] Web Assembly support (because this is a Rust project and it has to support Web Assembly)
|
- [ ] Web Assembly support (because this is a Rust project and it has to support Web Assembly)
|
||||||
|
41
src/bus.rs
41
src/bus.rs
@ -1,12 +1,5 @@
|
|||||||
use crate::utils::{
|
use crate::utils::{join_bytes};
|
||||||
get_bit,
|
|
||||||
set_bit,
|
|
||||||
BitIndex,
|
|
||||||
join_bytes
|
|
||||||
};
|
|
||||||
use crate::rom::ROM;
|
use crate::rom::ROM;
|
||||||
use crate::ppu::{PPU, LCDStatus, LCDStatusModeFlag};
|
|
||||||
use crate::cpu::{InterruptFlag};
|
|
||||||
|
|
||||||
pub struct AddressRange {
|
pub struct AddressRange {
|
||||||
begin: u16,
|
begin: u16,
|
||||||
@ -39,7 +32,6 @@ pub const NOT_USABLE: AddressRange = AddressRange{begin: 0xFEA0,
|
|||||||
pub const IO_REGISTERS: AddressRange = AddressRange{begin: 0xFF00, end: 0xFF7F};
|
pub const IO_REGISTERS: AddressRange = AddressRange{begin: 0xFF00, end: 0xFF7F};
|
||||||
pub const HIGH_RAM: AddressRange = AddressRange{begin: 0xFF80, end: 0xFFFE};
|
pub const HIGH_RAM: AddressRange = AddressRange{begin: 0xFF80, end: 0xFFFE};
|
||||||
pub const INTERRUPT_ENABLE_REGISTER: AddressRange = AddressRange{begin: 0xFFFF, end: 0xFFFF};
|
pub const INTERRUPT_ENABLE_REGISTER: AddressRange = AddressRange{begin: 0xFFFF, end: 0xFFFF};
|
||||||
pub const INTERRUPT_FLAG_ADDRESS: u16 = 0xFF0F;
|
|
||||||
|
|
||||||
pub struct Bus {
|
pub struct Bus {
|
||||||
game_rom: ROM,
|
game_rom: ROM,
|
||||||
@ -48,8 +40,7 @@ pub struct Bus {
|
|||||||
|
|
||||||
impl Bus {
|
impl Bus {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let game_rom = match ROM::load_file("ignore/tetris.gb".to_string()) {
|
let game_rom = match ROM::load_file("ignore/dmg-acid2.gb".to_string()) {
|
||||||
// let game_rom = match ROM::load_file("roms/cpu_instrs.gb".to_string()) {
|
|
||||||
// let game_rom = match ROM::load_file("roms/cpu_instrs_individual/01-special.gb".to_string()) {
|
// let game_rom = match ROM::load_file("roms/cpu_instrs_individual/01-special.gb".to_string()) {
|
||||||
// let game_rom = match ROM::load_file("roms/cpu_instrs_individual/03-op sp,hl.gb".to_string()) {
|
// let game_rom = match ROM::load_file("roms/cpu_instrs_individual/03-op sp,hl.gb".to_string()) {
|
||||||
// let game_rom = match ROM::load_file("roms/cpu_instrs_individual/04-op r,imm.gb".to_string()) {
|
// let game_rom = match ROM::load_file("roms/cpu_instrs_individual/04-op r,imm.gb".to_string()) {
|
||||||
@ -73,12 +64,12 @@ impl Bus {
|
|||||||
pub fn read(&self, address: u16) -> u8 {
|
pub fn read(&self, address: u16) -> u8 {
|
||||||
if BANK_ZERO.in_range(address) || BANK_SWITCHABLE.in_range(address) {
|
if BANK_ZERO.in_range(address) || BANK_SWITCHABLE.in_range(address) {
|
||||||
return self.game_rom.read(address);
|
return self.game_rom.read(address);
|
||||||
} else if VIDEO_RAM.in_range(address) {
|
|
||||||
if PPU::get_lcd_status(self, LCDStatus::ModeFlag(LCDStatusModeFlag::TransferringToLCD)) {
|
|
||||||
return 0xFF
|
|
||||||
}
|
|
||||||
} else if IO_REGISTERS.in_range(address) {
|
} else if IO_REGISTERS.in_range(address) {
|
||||||
return self.data[address as usize];
|
return match address {
|
||||||
|
0xFF44 => 0x90,
|
||||||
|
0xFF4D => 0xFF,
|
||||||
|
_ => self.data[address as usize],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.data[address as usize]
|
self.data[address as usize]
|
||||||
}
|
}
|
||||||
@ -103,13 +94,8 @@ impl Bus {
|
|||||||
} else if ECHO_RAM.in_range(address) {
|
} else if ECHO_RAM.in_range(address) {
|
||||||
self.data[address as usize] = data;
|
self.data[address as usize] = data;
|
||||||
self.data[(WORK_RAM_1.begin() + (address - ECHO_RAM.begin())) as usize] = data; // Copy to the working RAM
|
self.data[(WORK_RAM_1.begin() + (address - ECHO_RAM.begin())) as usize] = data; // Copy to the working RAM
|
||||||
} else if VIDEO_RAM.in_range(address) {
|
|
||||||
//if !PPU::get_lcd_status(self, LCDStatus::ModeFlag(LCDStatusModeFlag::TransferringToLCD)) {
|
|
||||||
self.data[address as usize] = data;
|
|
||||||
// }
|
|
||||||
} else {
|
|
||||||
self.data[address as usize] = data;
|
|
||||||
}
|
}
|
||||||
|
self.data[address as usize] = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_16bit(&mut self, address: u16, data: u16) {
|
pub fn write_16bit(&mut self, address: u16, data: u16) {
|
||||||
@ -117,15 +103,4 @@ impl Bus {
|
|||||||
self.write(address, bytes[0]);
|
self.write(address, bytes[0]);
|
||||||
self.write(address.wrapping_add(1), bytes[1]);
|
self.write(address.wrapping_add(1), bytes[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_flag(&mut self, flag: InterruptFlag, val: bool) {
|
|
||||||
let byte = self.read(INTERRUPT_FLAG_ADDRESS);
|
|
||||||
self.write(INTERRUPT_FLAG_ADDRESS, match flag {
|
|
||||||
InterruptFlag::VBlank => set_bit(byte, val, BitIndex::I0),
|
|
||||||
InterruptFlag::LCDSTAT => set_bit(byte, val, BitIndex::I1),
|
|
||||||
InterruptFlag::Timer => set_bit(byte, val, BitIndex::I2),
|
|
||||||
InterruptFlag::Serial => set_bit(byte, val, BitIndex::I3),
|
|
||||||
InterruptFlag::Joypad => set_bit(byte, val, BitIndex::I4),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -806,6 +806,8 @@ pub enum Opcode {
|
|||||||
PrefixCB(Box<Opcode>),
|
PrefixCB(Box<Opcode>),
|
||||||
IllegalInstruction,
|
IllegalInstruction,
|
||||||
}
|
}
|
||||||
|
// Frequency un Hz
|
||||||
|
const FREQUENCY: f64 = 4194.304;
|
||||||
|
|
||||||
// Store cycles in M
|
// Store cycles in M
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
@ -20,8 +20,8 @@ impl Emulator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(&mut self, frame: &mut [u8]) {
|
pub fn draw(&mut self, frame: &mut [u8]) {
|
||||||
// self.ppu.draw_background(&mut self.bus);
|
self.ppu.draw_background(&self.bus);
|
||||||
let ppu_frame = self.ppu.get_rgba_frame();
|
let ppu_frame = self.ppu.get_rgba_frame(&self.bus);
|
||||||
for (i, pixel) in frame.chunks_exact_mut(4).enumerate() {
|
for (i, pixel) in frame.chunks_exact_mut(4).enumerate() {
|
||||||
pixel.copy_from_slice(&ppu_frame[i]);
|
pixel.copy_from_slice(&ppu_frame[i]);
|
||||||
}
|
}
|
||||||
@ -30,7 +30,6 @@ impl Emulator {
|
|||||||
pub fn run(&mut self, cpu_cycles: Cycles) {
|
pub fn run(&mut self, cpu_cycles: Cycles) {
|
||||||
self.cpu.reset_cycles();
|
self.cpu.reset_cycles();
|
||||||
while self.cpu.get_cycles().0 <= cpu_cycles.0 {
|
while self.cpu.get_cycles().0 <= cpu_cycles.0 {
|
||||||
self.ppu.do_cycle(&mut self.bus);
|
|
||||||
self.cpu.run(&mut self.bus);
|
self.cpu.run(&mut self.bus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
137
src/ppu.rs
137
src/ppu.rs
@ -5,7 +5,8 @@ use crate::utils::{
|
|||||||
to_bit_index,
|
to_bit_index,
|
||||||
};
|
};
|
||||||
use crate::bus::{Bus, AddressRange, BANK_ZERO, VIDEO_RAM};
|
use crate::bus::{Bus, AddressRange, BANK_ZERO, VIDEO_RAM};
|
||||||
use crate::cpu::{Cycles, InterruptFlag};
|
use crate::cpu::{Cycles};
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
enum Pixel {
|
enum Pixel {
|
||||||
@ -45,10 +46,8 @@ pub enum LCDStatus {
|
|||||||
ModeFlag(LCDStatusModeFlag),
|
ModeFlag(LCDStatusModeFlag),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const LCD_WIDTH: u32 = 160;
|
pub const WIDTH: u32 = 160;
|
||||||
pub const LCD_HEIGHT: u32 = 144;
|
pub const HEIGHT: u32 = 144;
|
||||||
pub const WIDTH: u32 = LCD_WIDTH;
|
|
||||||
pub const HEIGHT: u32 = LCD_HEIGHT;
|
|
||||||
pub const FRAME_BUFFER_LENGTH: u32 = WIDTH * HEIGHT;
|
pub const FRAME_BUFFER_LENGTH: u32 = WIDTH * HEIGHT;
|
||||||
|
|
||||||
const LCD_CONTROL_ADDRESS: u16 = 0xFF40;
|
const LCD_CONTROL_ADDRESS: u16 = 0xFF40;
|
||||||
@ -75,69 +74,10 @@ impl PPU {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
cycles: Cycles(0),
|
cycles: Cycles(0),
|
||||||
rgba_frame: [[0xFF, 0xFF, 0xFF, 0]; FRAME_BUFFER_LENGTH as usize],
|
rgba_frame: [[0, 0, 0xFF, 0]; FRAME_BUFFER_LENGTH as usize],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_cycles(&mut self) {
|
|
||||||
self.cycles.0 = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn increment_cycles(&mut self, cycles: Cycles) {
|
|
||||||
self.cycles.0 += cycles.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn do_cycle(&mut self, bus: &mut Bus) {
|
|
||||||
// Mode 1 Vertical blank
|
|
||||||
if PPU::get_lcd_y(bus) >= 144 {
|
|
||||||
if PPU::get_lcd_y(bus) == 144 {
|
|
||||||
bus.set_flag(InterruptFlag::VBlank, true);
|
|
||||||
PPU::set_lcd_status(bus, LCDStatus::ModeFlag(LCDStatusModeFlag::VBlank), true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if self.cycles.0 == 0 {
|
|
||||||
// Mode 2 OAM scan
|
|
||||||
PPU::set_lcd_status(bus, LCDStatus::ModeFlag(LCDStatusModeFlag::SearchingOAM), true);
|
|
||||||
} else if self.cycles.0 == 80 + 1 {
|
|
||||||
// Mode 3 drawing pixel line. This could also last 289 cycles
|
|
||||||
bus.set_flag(InterruptFlag::LCDSTAT, true);
|
|
||||||
self.draw_line(bus);
|
|
||||||
PPU::set_lcd_status(bus, LCDStatus::ModeFlag(LCDStatusModeFlag::TransferringToLCD), true);
|
|
||||||
} else if self.cycles.0 == 80 + 172 + 1 {
|
|
||||||
// Mode 0 Horizontal blank. This could last 87 or 204 cycles depending on the mode 3
|
|
||||||
bus.set_flag(InterruptFlag::LCDSTAT, true);
|
|
||||||
PPU::set_lcd_status(bus, LCDStatus::ModeFlag(LCDStatusModeFlag::HBlank), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let lyc_compare = PPU::get_lcd_y(bus) == bus.read(LCD_Y_COMPARE_ADDRESS);
|
|
||||||
PPU::set_lcd_status(bus, LCDStatus::LYCInterrupt, lyc_compare);
|
|
||||||
if lyc_compare {
|
|
||||||
bus.set_flag(InterruptFlag::LCDSTAT, true);
|
|
||||||
}
|
|
||||||
self.increment_cycles(Cycles(1));
|
|
||||||
|
|
||||||
// Horizontal scan completed
|
|
||||||
if self.cycles.0 > 456 {
|
|
||||||
self.reset_cycles();
|
|
||||||
|
|
||||||
PPU::set_lcd_y(bus, PPU::get_lcd_y(bus) + 1);
|
|
||||||
|
|
||||||
// Frame completed
|
|
||||||
if PPU::get_lcd_y(bus) > 153 {
|
|
||||||
PPU::set_lcd_y(bus, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_lcd_y(bus: &Bus) -> u8 {
|
|
||||||
bus.read(LCD_Y_ADDRESS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_lcd_y(bus: &mut Bus, val: u8) {
|
|
||||||
bus.write(LCD_Y_ADDRESS, val);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_scroll_x(bus: &Bus) -> u8 {
|
fn get_scroll_x(bus: &Bus) -> u8 {
|
||||||
bus.read(SCROLL_X_ADDRESS)
|
bus.read(SCROLL_X_ADDRESS)
|
||||||
}
|
}
|
||||||
@ -154,7 +94,7 @@ impl PPU {
|
|||||||
bus.write(SCROLL_Y_ADDRESS, val);
|
bus.write(SCROLL_Y_ADDRESS, val);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_lcd_control(bus: &Bus, control: LCDControl) -> bool {
|
fn get_lcd_control(bus: &Bus, control: LCDControl) -> bool {
|
||||||
let byte = bus.read(LCD_CONTROL_ADDRESS);
|
let byte = bus.read(LCD_CONTROL_ADDRESS);
|
||||||
match control {
|
match control {
|
||||||
LCDControl::DisplayEnable => get_bit(byte, BitIndex::I7),
|
LCDControl::DisplayEnable => get_bit(byte, BitIndex::I7),
|
||||||
@ -183,7 +123,7 @@ impl PPU {
|
|||||||
bus.write(LCD_CONTROL_ADDRESS, byte);
|
bus.write(LCD_CONTROL_ADDRESS, byte);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_lcd_status(bus: &Bus, status: LCDStatus) -> bool {
|
fn get_lcd_status(bus: &Bus, status: LCDStatus) -> bool {
|
||||||
let byte = bus.read(LCD_STATUS_ADDRESS);
|
let byte = bus.read(LCD_STATUS_ADDRESS);
|
||||||
match status {
|
match status {
|
||||||
LCDStatus::LYCInterrupt => get_bit(byte, BitIndex::I6),
|
LCDStatus::LYCInterrupt => get_bit(byte, BitIndex::I6),
|
||||||
@ -218,35 +158,6 @@ impl PPU {
|
|||||||
bus.write(LCD_STATUS_ADDRESS, byte);
|
bus.write(LCD_STATUS_ADDRESS, byte);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw_line(&mut self, bus: &Bus) {
|
|
||||||
let lcd_y = PPU::get_lcd_y(bus);
|
|
||||||
if lcd_y as u32 >= LCD_HEIGHT {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut lcd_x: u8 = 0;
|
|
||||||
while (lcd_x as u32) < LCD_WIDTH {
|
|
||||||
let y = lcd_y.wrapping_add(PPU::get_scroll_y(bus));
|
|
||||||
let x = lcd_x.wrapping_add(PPU::get_scroll_x(bus));
|
|
||||||
let index_x = x as u16 / 8;
|
|
||||||
let index_y = (y as u16 / 8) * 32;
|
|
||||||
let index = index_x + index_y;
|
|
||||||
let tile_line = (y).rem_euclid(8) * 2;
|
|
||||||
let tile_number = bus.read(0x9800 + index as u16) as u16;
|
|
||||||
let addr = 0x8000 + tile_line as u16 + (tile_number * 16);
|
|
||||||
|
|
||||||
let tile_byte_1 = bus.read(addr);
|
|
||||||
let tile_byte_2 = bus.read(addr + 1);
|
|
||||||
|
|
||||||
let pixels = PPU::get_byte_pixels(tile_byte_1, tile_byte_2);
|
|
||||||
|
|
||||||
for pixel in pixels {
|
|
||||||
let idx = lcd_x as usize + (lcd_y as usize * LCD_WIDTH as usize);
|
|
||||||
self.rgba_frame[idx] = PPU::get_rgba(pixel);
|
|
||||||
lcd_x += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_pixel(two_bit_pixel: u8) -> Pixel {
|
fn get_pixel(two_bit_pixel: u8) -> Pixel {
|
||||||
match two_bit_pixel {
|
match two_bit_pixel {
|
||||||
0x00 => Pixel::White,
|
0x00 => Pixel::White,
|
||||||
@ -266,6 +177,38 @@ impl PPU {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn draw_background(&mut self, bus: &Bus) {
|
||||||
|
let mut idx = 0;
|
||||||
|
// let mut tile_line: u16 = 0;
|
||||||
|
let mut lcd_y: u8 = 0;
|
||||||
|
while lcd_y < 144 {
|
||||||
|
let mut lcd_x: u8 = 0;
|
||||||
|
while lcd_x < 160 {
|
||||||
|
let y = lcd_y.wrapping_add(PPU::get_scroll_y(bus));
|
||||||
|
let x = lcd_x.wrapping_add(PPU::get_scroll_x(bus));
|
||||||
|
let index_x = (x as u16 / 8);
|
||||||
|
let index_y = (y as u16 / 8) * 32;
|
||||||
|
let index = index_x + index_y;
|
||||||
|
let tile_line = (y).rem_euclid(8) * 2;
|
||||||
|
let index_byte = (bus.read(0x9800 + index as u16) as u16) * 16;
|
||||||
|
|
||||||
|
let tile_byte_1 = bus.read(0x8000 + tile_line as u16 + index_byte);
|
||||||
|
let tile_byte_2 = bus.read(0x8000 + tile_line as u16 + index_byte + 1);
|
||||||
|
|
||||||
|
let pixels = PPU::get_byte_pixels(tile_byte_1, tile_byte_2);
|
||||||
|
|
||||||
|
for pixel in pixels {
|
||||||
|
self.rgba_frame[idx] = PPU::get_rgba(pixel);
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lcd_x += 8;
|
||||||
|
}
|
||||||
|
lcd_y += 1;
|
||||||
|
// tile_line += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_byte_pixels(byte1: u8, byte2: u8) -> [Pixel; 8] {
|
fn get_byte_pixels(byte1: u8, byte2: u8) -> [Pixel; 8] {
|
||||||
let mut pixels: [Pixel; 8] = [Pixel::White; 8];
|
let mut pixels: [Pixel; 8] = [Pixel::White; 8];
|
||||||
pixels[0] = PPU::get_pixel(((get_bit(byte1, BitIndex::I7) as u8) << 1) | (get_bit(byte2, BitIndex::I7) as u8));
|
pixels[0] = PPU::get_pixel(((get_bit(byte1, BitIndex::I7) as u8) << 1) | (get_bit(byte2, BitIndex::I7) as u8));
|
||||||
@ -279,7 +222,7 @@ impl PPU {
|
|||||||
pixels
|
pixels
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_rgba_frame(&self) -> &[[u8; 4]; FRAME_BUFFER_LENGTH as usize] {
|
pub fn get_rgba_frame(&self, bus: &Bus) -> &[[u8; 4]; FRAME_BUFFER_LENGTH as usize] {
|
||||||
&self.rgba_frame
|
&self.rgba_frame
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,7 @@ pub fn start_eventloop() {
|
|||||||
|
|
||||||
let mut emulator = Emulator::new();
|
let mut emulator = Emulator::new();
|
||||||
|
|
||||||
|
let mut count: usize = 0;
|
||||||
event_loop.run(move |event, _, control_flow| {
|
event_loop.run(move |event, _, control_flow| {
|
||||||
// Handle input events
|
// Handle input events
|
||||||
if input.update(&event) {
|
if input.update(&event) {
|
||||||
@ -65,7 +66,7 @@ pub fn start_eventloop() {
|
|||||||
emulator.run(Cycles(70224));
|
emulator.run(Cycles(70224));
|
||||||
emulator.draw(pixels.get_frame());
|
emulator.draw(pixels.get_frame());
|
||||||
|
|
||||||
// thread::sleep(time::Duration::from_millis(14));
|
thread::sleep(time::Duration::from_millis(14));
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
},
|
},
|
||||||
Event::RedrawRequested(_) => {
|
Event::RedrawRequested(_) => {
|
||||||
|
Loading…
Reference in New Issue
Block a user