ppu.rsannotatedppu.rssource659 lines · 23.8 KB · raw

Game Boy PPU (picture processing unit)

3mod debug;
4mod fifo;
5mod registers;
7use crate::HardwareMode;
8use crate::cgb::{CgbRegisters, CpuSpeed};
9use crate::dma::DmaUnit;
10use crate::interrupts::InterruptRegisters;
11use crate::ppu::fifo::PixelFifo;
12use crate::ppu::registers::{CgbPaletteRam, Registers};
13use crate::sm83::InterruptType;
14use bincode::{Decode, Encode};
15use jgenesis_common::boxedarray::BoxedWordArray;
16use jgenesis_common::frontend::FrameSize;
17use jgenesis_common::num::GetBit;
18use std::ops::Range;
19
20const SCREEN_WIDTH: usize = 160;
21const SCREEN_HEIGHT: usize = 144;
22
23pub const FRAME_BUFFER_LEN: usize = SCREEN_WIDTH * SCREEN_HEIGHT;
24
25pub const FRAME_SIZE: FrameSize =
26    FrameSize { width: SCREEN_WIDTH as u32, height: SCREEN_HEIGHT as u32 };

144 rendered lines + 10 VBlank lines

29pub const LINES_PER_FRAME: u8 = 154;
30pub const DOTS_PER_LINE: u16 = 456;
31const OAM_SCAN_DOTS: u16 = 80;
33const MAX_SPRITES_PER_LINE: usize = 10;
34
35const VRAM_LEN: usize = 16 * 1024;
36const OAM_LEN: usize = 160;
37
38type Vram = [u8; VRAM_LEN];
39type Oam = [u8; OAM_LEN];
40
41pub type PpuFrameBuffer = [u16; FRAME_BUFFER_LEN];
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
44pub enum PpuMode {
45    // Mode 1
46    VBlank,
47    // Mode 0
48    HBlank,
49    // Mode 2
50    ScanningOam,
51    // Glitched mode 2 that occurs after re-enabling the PPU
52    ScanningOamGlitched,
53    // Mode 3
54    Rendering,
55}
56
57impl PpuMode {
58    fn to_bits(self) -> u8 {
59        match self {
60            Self::HBlank => 0,
61            Self::VBlank => 1,
62            Self::ScanningOam | Self::ScanningOamGlitched => 2,
63            Self::Rendering => 3,
64        }
65    }
66
67    fn is_scanning_oam(self) -> bool {
68        matches!(self, Self::ScanningOam | Self::ScanningOamGlitched)
69    }
70}
71
72#[derive(Debug, Clone, Encode, Decode)]
73struct State {
74    scanline: u8,
75    dot: u16,
76    mode: PpuMode,
77    prev_stat_interrupt_line: bool,
78    stat_interrupt_pending: bool,
79    previously_enabled: bool,
80    // LY=LYC bit in STAT does not change while PPU is disabled, per:
81    // https://gbdev.gg8.se/wiki/articles/Tricky-to-emulate_games
82    frozen_ly_lyc_bit: bool,
83    skip_next_frame: bool,
84    frame_complete: bool,
85    powered_off_dots: u32,
86}
87
88impl State {
89    fn new() -> Self {
90        Self {
91            scanline: 0,
92            dot: 0,
93            mode: PpuMode::ScanningOam,
94            prev_stat_interrupt_line: false,
95            stat_interrupt_pending: false,
96            previously_enabled: true,
97            frozen_ly_lyc_bit: false,
98            skip_next_frame: true,
99            frame_complete: false,
100            powered_off_dots: 0,
101        }
102    }
103
104    fn ly(&self) -> u8 {
105        if self.scanline == LINES_PER_FRAME - 1 && self.dot >= 4 {
106            // LY=0 starts 4 dots into the final scanline. Kirby's Dream Land 2 and Wario Land 2 depend on this for
107            // minor top-of-screen effects
108            0
109        } else {
110            self.scanline
111        }
112    }
113
114    fn ly_for_compare(&self, cpu_speed: CpuSpeed) -> u8 {
115        // This handles two edge cases for LY=LYC interrupts:
116        //
117        // 1. HBlank interrupts should not block LY=LYC interrupts; Ken Griffey Jr.'s Slugfest
118        // depends on this
119        //
120        // 2. When LYC=0, the LY=LYC interrupt should not trigger before dot 9 in single speed
121        // or before dot 13 in CGB double speed. The demo Mental Respirator depends on this for the
122        // "gin & tonic trick" effect
123        match self.scanline {
124            0 => 0,
125            line @ 1..=152 => {
126                if self.dot != 0 {
127                    line
128                } else {
129                    line - 1
130                }
131            }
132            153 => match (self.dot, cpu_speed) {
133                (0, _) => 152,
134                (1..=8, _) | (9..=12, CpuSpeed::Double) => 153,
135                _ => 0,
136            },
137            _ => panic!("Invalid scanline in state: {}", self.scanline),
138        }
139    }
140}
141
142#[derive(Debug, Clone, Copy, Encode, Decode)]
143struct SpriteData {
144    oam_index: u8,
145    x: u8,
146    y: u8,
147    tile_number: u8,
148    vram_bank: u8,
149    palette: u8,
150    horizontal_flip: bool,
151    vertical_flip: bool,
152    low_priority: bool,
153}
154
155#[derive(Debug, Clone, Encode, Decode)]
156pub struct Ppu {
157    hardware_mode: HardwareMode,
158    frame_buffer: BoxedWordArray<FRAME_BUFFER_LEN>,
159    vram: Box<Vram>,
160    oam: Box<Oam>,
161    registers: Registers,
162    bg_palette_ram: CgbPaletteRam,
163    sprite_palette_ram: CgbPaletteRam,
164    state: State,
165    sprite_buffer: Vec<SpriteData>,
166    fifo: PixelFifo,
167}
168
169impl Ppu {
170    pub fn new(hardware_mode: HardwareMode, rom: &[u8], boot_rom_present: bool) -> Self {
171        let mut vram = vec![0; VRAM_LEN];
172
173        if !boot_rom_present {
174            initialize_vram(hardware_mode, rom, &mut vram);
175        }
176
177        Self {
178            hardware_mode,
179            frame_buffer: BoxedWordArray::new(),
180            vram: vram.into_boxed_slice().try_into().unwrap(),
181            oam: vec![0; OAM_LEN].into_boxed_slice().try_into().unwrap(),
182            registers: Registers::new(boot_rom_present),
183            bg_palette_ram: CgbPaletteRam::new_bg(),
184            sprite_palette_ram: CgbPaletteRam::new_obj(),
185            state: State::new(),
186            sprite_buffer: Vec::with_capacity(MAX_SPRITES_PER_LINE),
187            fifo: PixelFifo::new(hardware_mode),
188        }
189    }
190
191    pub fn tick_dot(
192        &mut self,
193        cgb_registers: CgbRegisters,
194        dma_unit: &DmaUnit,
195        interrupt_registers: &mut InterruptRegisters,
196    ) {
197        if !self.registers.ppu_enabled {
198            if self.state.previously_enabled {
199                // Disabling PPU freezes the LY=LYC bit until it's re-enabled, per:
200                // https://gbdev.gg8.se/wiki/articles/Tricky-to-emulate_games
201                self.state.frozen_ly_lyc_bit = self.state.scanline == self.registers.ly_compare;
202
203                // Disabling the PPU moves it to line 0 + mode 0 and clears the display
204                self.state.scanline = 0;
205                self.state.dot = 0;
206                self.state.mode = PpuMode::HBlank;
207
208                self.sprite_buffer.clear();
209                self.fifo.reset_window_state();
210                self.fifo.start_new_line(0, &self.registers, &[]);
211
212                self.state.previously_enabled = false;
213                self.state.stat_interrupt_pending = false;
214                self.state.prev_stat_interrupt_line = false;
215
216                self.state.powered_off_dots = 0;
217            }
218
219            self.state.powered_off_dots += 1;
220            if self.state.powered_off_dots == u32::from(LINES_PER_FRAME) * u32::from(DOTS_PER_LINE)
221            {
222                // Force a blank frame render if the PPU is powered off for a full frame's worth of cycles
223                self.clear_frame_buffer();
224                self.state.frame_complete = true;
225                self.state.powered_off_dots = 0;
226            }
227
228            // Unlike TV-based systems, the PPU does not process at all when display is disabled
229            return;
230        } else if !self.state.previously_enabled {
231            self.state.previously_enabled = true;
232
233            // Restarting the PPU at dot 4 instead of 0 fixes graphical glitches in GBVideoPlayer2
234            self.state.dot = 4;
235
236            // When the PPU is re-enabled, the next frame is not displayed
237            self.state.skip_next_frame = true;
238
239            self.state.mode = PpuMode::ScanningOamGlitched;
240        }
241
242        // STAT interrupts don't seem to fire during the first 4 dots of line 0
243        if self.state.stat_interrupt_pending && (self.state.scanline != 0 || self.state.dot >= 4) {
244            log::trace!(
245                "Generating STAT interrupt at line {} dot {}",
246                self.state.scanline,
247                self.state.dot
248            );
249
250            interrupt_registers.set_flag(InterruptType::LcdStatus);
251            self.state.stat_interrupt_pending = false;
252        }
253
254        if self.state.mode == PpuMode::Rendering {
255            let frame_buffer = (!self.state.skip_next_frame).then_some(self.frame_buffer.as_mut());
256            self.fifo.tick(
257                &self.vram,
258                &self.registers,
259                cgb_registers,
260                &self.bg_palette_ram,
261                &self.sprite_palette_ram,
262                frame_buffer,
263            );
264            if self.fifo.done_with_line() {
265                log::trace!(
266                    "Pixel FIFO finished line {} after dot {}",
267                    self.state.scanline,
268                    self.state.dot
269                );
270                self.state.mode = PpuMode::HBlank;
271            }
272        }
273
274        self.state.dot += 1;
275        if self.state.dot == DOTS_PER_LINE {
276            // Check the window Y condition again before moving to the next line.
277            // The fairylake.gb test ROM depends on this because it enables the window during mode 3
278            // with WY==LY
279            self.fifo.check_window_y(self.state.scanline, &self.registers);
280
281            self.state.dot = 0;
282            self.state.scanline += 1;
283            if self.state.scanline == LINES_PER_FRAME {
284                self.state.scanline = 0;
285                self.fifo.reset_window_state();
286            }
287
288            if self.state.scanline < SCREEN_HEIGHT as u8 {
289                self.state.mode = PpuMode::ScanningOam;
290
291                self.sprite_buffer.clear();
292
293                // PPU cannot read OAM while an OAM DMA is in progress
294                // TODO does anything depend on partial OAM scan when an OAM DMA finishes during mode 2?
295                if !dma_unit.oam_dma_in_progress() {
296                    scan_oam(
297                        self.hardware_mode,
298                        cgb_registers.dmg_compatibility,
299                        self.state.scanline,
300                        self.registers.double_height_sprites,
301                        &self.oam,
302                        &mut self.sprite_buffer,
303                    );
304                }
305            } else {
306                self.state.mode = PpuMode::VBlank;
307            }
308        } else if self.state.scanline < SCREEN_HEIGHT as u8 && self.state.dot == OAM_SCAN_DOTS {
309            self.fifo.start_new_line(self.state.scanline, &self.registers, &self.sprite_buffer);
310            self.state.mode = PpuMode::Rendering;
311        }
312
313        // TODO timing
314        if self.state.scanline == SCREEN_HEIGHT as u8 && self.state.dot == 1 {
315            interrupt_registers.set_flag(InterruptType::VBlank);
316            if self.state.skip_next_frame {
317                self.state.skip_next_frame = false;
318            } else {
319                self.state.frame_complete = true;
320            }
321
322            // Obscure behavior: If the mode 2 STAT interrupt is enabled, it will trigger a STAT
323            // interrupt on line 144 around the same time that VBlank starts.
324            //
325            // GB Video Player (https://github.com/LIJI32/GBVideoPlayer) depends on this because
326            // it uses the Mode 2 STAT interrupt and expects it to trigger 145 times per frame, not 144
327            if !self.state.prev_stat_interrupt_line && self.registers.mode_2_interrupt_enabled {
328                interrupt_registers.set_flag(InterruptType::LcdStatus);
329            }
330        }
331
332        let stat_interrupt_line = self.stat_interrupt_line(cgb_registers.speed);
333        if !self.state.prev_stat_interrupt_line && stat_interrupt_line {
334            self.state.stat_interrupt_pending = true;
335            log::trace!(
336                "Setting STAT pending: LY={}, LYC={}, mode={:?}",
337                self.state.ly(),
338                self.registers.ly_compare,
339                self.state.mode
340            );
341        }
342        self.state.prev_stat_interrupt_line = stat_interrupt_line;
343    }
344
345    fn clear_frame_buffer(&mut self) {
346        log::trace!("Clearing PPU frame buffer");
347
348        // Disabling display makes the entire display white, which is color 0 on DMG
349        // and color 31/31/31 ($7FFF) on CGB
350        let fill_color = match self.hardware_mode {
351            HardwareMode::Dmg => 0,
352            HardwareMode::Cgb => 0b11111_11111_11111,
353        };
354        self.frame_buffer.fill(fill_color);
355
356        // Signal that the frame should be displayed
357        self.state.frame_complete = true;
358    }
359
360    fn stat_interrupt_line(&self, cpu_speed: CpuSpeed) -> bool {
361        let lyc_interrupt_enabled = self.registers.lyc_interrupt_enabled;
362        let mode_2_interrupt_enabled = self.registers.mode_2_interrupt_enabled;
363        let mode_1_interrupt_enabled = self.registers.mode_1_interrupt_enabled;
364        let mode_0_interrupt_enabled = self.registers.mode_0_interrupt_enabled;
365
366        (lyc_interrupt_enabled && self.state.ly_for_compare(cpu_speed) == self.registers.ly_compare)
367            || (mode_2_interrupt_enabled && self.state.mode.is_scanning_oam())
368            || (mode_1_interrupt_enabled && self.state.mode == PpuMode::VBlank)
369            || (mode_0_interrupt_enabled && self.state.mode == PpuMode::HBlank)
370    }
371
372    pub fn frame_buffer(&self) -> &PpuFrameBuffer {
373        &self.frame_buffer
374    }
375
376    pub fn frame_complete(&self) -> bool {
377        self.state.frame_complete
378    }
379
380    pub fn clear_frame_complete(&mut self) {
381        self.state.frame_complete = false;
382    }
383
384    pub fn read_vram(&self, address: u16) -> u8 {
385        if self.cpu_can_access_vram() {
386            let vram_addr = map_vram_address(address, self.registers.vram_bank);
387            self.vram[vram_addr as usize]
388        } else {
389            0xFF
390        }
391    }
392
393    pub fn write_vram(&mut self, address: u16, value: u8) {
394        if self.cpu_can_access_vram() {
395            let vram_addr = map_vram_address(address, self.registers.vram_bank);
396            self.vram[vram_addr as usize] = value;
397        }
398    }
399
400    pub fn read_oam(&self, address: u16) -> u8 {
401        if self.cpu_can_access_oam() { self.oam[(address & 0xFF) as usize] } else { 0xFF }
402    }
403
404    pub fn write_oam(&mut self, address: u16, value: u8) {
405        if self.cpu_can_access_oam() {
406            self.oam[(address & 0xFF) as usize] = value;
407        }
408    }
409
410    // OAM DMA can write to OAM at any time, even during Modes 2 and 3
411    pub fn write_oam_for_dma(&mut self, address: u16, value: u8) {
412        self.oam[(address & 0xFF) as usize] = value;
413    }
414
415    fn cpu_can_access_oam(&self) -> bool {
416        !matches!(self.state.mode, PpuMode::ScanningOam | PpuMode::Rendering)
417    }
418
419    fn cpu_can_access_vram(&self) -> bool {
420        // Allow access even during mode 3 if dot <= 84.
421        // Because of how the CPU and PPU are executed, a write at dot == 80 would have occurred
422        // on dot 78 (single-speed) or dot 79 (double-speed) on actual hardware and would not have
423        // been blocked.
424        // Allowing writes on dots 81-84 (probably 81-83 in actual hardware) is a hack to fix
425        // what seems to be a timing issue elsewhere, possibly interrupt-related. The Stunt Race FX
426        // demo depends on allowing these through
427        self.state.mode != PpuMode::Rendering || self.state.dot <= OAM_SCAN_DOTS + 4
428    }
429
430    pub fn mode(&self) -> PpuMode {
431        self.state.mode
432    }
433
434    pub fn read_register(&self, address: u16, cgb_registers: CgbRegisters) -> u8 {
435        match address & 0xFF {
436            0x40 => self.registers.read_lcdc(),
437            0x41 => self.registers.read_stat(&self.state, cgb_registers.speed),
438            0x42 => self.registers.bg_y_scroll,
439            0x43 => self.registers.bg_x_scroll,
440            // LY: Line number
441            0x44 => self.state.ly(),
442            0x45 => self.registers.ly_compare,
443            0x47 => self.registers.read_bgp(),
444            0x48 => self.registers.read_obp0(),
445            0x49 => self.registers.read_obp1(),
446            0x4A => self.registers.window_y,
447            0x4B => self.registers.window_x,
448            0x4F => self.registers.read_vbk(),
449            0x68 => self.bg_palette_ram.read_data_port_address(),
450            0x69 => self.bg_palette_ram.read_data_port(self.cpu_can_access_vram()),
451            0x6A => self.sprite_palette_ram.read_data_port_address(),
452            0x6B => self.sprite_palette_ram.read_data_port(self.cpu_can_access_vram()),
453            _ => {
454                log::warn!("PPU register read {address:04X}");
455                0xFF
456            }
457        }
458    }
459
460    pub fn write_register(
461        &mut self,
462        address: u16,
463        value: u8,
464        speed: CpuSpeed,
465        interrupt_registers: &mut InterruptRegisters,
466    ) {
467        log::trace!(
468            "PPU register write on line {} dot {}: {address:04X} set to {value:02X}",
469            self.state.scanline,
470            self.state.dot
471        );
472
473        match address & 0xFF {
474            0x40 => self.registers.write_lcdc(value),
475            0x41 => self.write_stat(value, interrupt_registers),
476            0x42 => self.registers.write_scy(value),
477            0x43 => self.registers.write_scx(value),
478            // LY, not writable
479            0x44 => {}
480            0x45 => self.write_lyc(value, speed),
481            0x47 => self.registers.write_bgp(value),
482            0x48 => self.registers.write_obp0(value),
483            0x49 => self.registers.write_obp1(value),
484            0x4A => self.registers.write_wy(value),
485            0x4B => self.registers.write_wx(value),
486            0x4F => self.registers.write_vbk(value),
487            0x68 => self.bg_palette_ram.write_data_port_address(value),
488            0x69 => self.bg_palette_ram.write_data_port(value, self.cpu_can_access_vram()),
489            0x6A => self.sprite_palette_ram.write_data_port_address(value),
490            0x6B => self.sprite_palette_ram.write_data_port(value, self.cpu_can_access_vram()),
491            _ => log::warn!("PPU register write {address:04X} {value:02X}"),
492        }
493    }
494
495    fn write_stat(&mut self, value: u8, interrupt_registers: &mut InterruptRegisters) {
496        if self.hardware_mode == HardwareMode::Dmg && self.registers.ppu_enabled {
497            // DMG STAT bug: If STAT is written while any of the 4 STAT conditions are true, the
498            // hardware behaves as if all 4 STAT interrupts are enabled for a single M-cycle.
499            // Road Rash (GB version) and Zerd no Densetsu depend on this
500            let dmg_stat_bug_triggered = self.state.mode != PpuMode::Rendering
501                || self.state.ly_for_compare(CpuSpeed::Normal) == self.registers.ly_compare;
502
503            if dmg_stat_bug_triggered {
504                // It seems that the DMG STAT bug does not trigger if HBlank interrupts were previously
505                // enabled and the current mode is 2 (OAM scan). This doesn't really make sense, but
506                // this fixes Initial D Gaiden and doesn't break Road Rash or Zerd no Densetsu
507                let suppress_oam_interrupts = self.registers.mode_0_interrupt_enabled
508                    && self.state.mode == PpuMode::ScanningOam;
509                let bugged_stat_write = !(u8::from(suppress_oam_interrupts) << 5);
510
511                self.registers.write_stat(bugged_stat_write);
512
513                let stat_interrupt_line = self.stat_interrupt_line(CpuSpeed::Normal);
514                if !self.state.prev_stat_interrupt_line && stat_interrupt_line {
515                    interrupt_registers.set_flag(InterruptType::LcdStatus);
516                }
517                self.state.prev_stat_interrupt_line = stat_interrupt_line;
518            }
519        }
520
521        self.registers.write_stat(value);
522    }
523
524    fn write_lyc(&mut self, value: u8, speed: CpuSpeed) {
525        self.registers.write_lyc(value);
526
527        // If changing LYC would cause the STAT interrupt line to go from high to low, immediately
528        // pull it low.
529        // This fixes graphical glitches in SQRKZ, where it sometimes changes LYC from 141 to 142
530        // on line=142 dot=0, and the LY=LYC STAT interrupt should trigger almost immediately.
531        // TODO timing around the LY=LYC interrupt is iffy in general - improve this
532        if value == self.state.scanline && self.state.dot == 0 {
533            let stat_interrupt_line = self.stat_interrupt_line(speed);
534            self.state.prev_stat_interrupt_line &= stat_interrupt_line;
535        }
536    }
537}
538
539fn map_vram_address(address: u16, vram_bank: u8) -> u16 {
540    (u16::from(vram_bank) << 13) | (address & 0x1FFF)
541}
542
543fn scan_oam(
544    hardware_mode: HardwareMode,
545    cgb_dmg_compatibility: bool,
546    scanline: u8,
547    double_height_sprites: bool,
548    oam: &Oam,
549    sprite_buffer: &mut Vec<SpriteData>,
550) {
551    let sprite_height = if double_height_sprites { 16 } else { 8 };
552
553    for oam_idx in 0..OAM_LEN / 4 {
554        let oam_addr = 4 * oam_idx;
555
556        let y = oam[oam_addr];
557
558        // Check if sprite overlaps current line
559        let sprite_top = i16::from(y) - 16;
560        let sprite_bottom = sprite_top + sprite_height;
561        if !(sprite_top..sprite_bottom).contains(&scanline.into()) {
562            continue;
563        }
564
565        let x = oam[oam_addr + 1];
566        let tile_number = oam[oam_addr + 2];
567
568        let attributes = oam[oam_addr + 3];
569        let horizontal_flip = attributes.bit(5);
570        let vertical_flip = attributes.bit(6);
571        let low_priority = attributes.bit(7);
572
573        // VRAM bank is only valid in CGB mode, and palette is read from different bits
574        let (vram_bank, palette) = match (hardware_mode, cgb_dmg_compatibility) {
575            (HardwareMode::Dmg, _) | (HardwareMode::Cgb, true) => (0, attributes.bit(4).into()),
576            (HardwareMode::Cgb, false) => (attributes.bit(3).into(), attributes & 0x07),
577        };
578
579        sprite_buffer.push(SpriteData {
580            oam_index: oam_idx as u8,
581            x,
582            y,
583            tile_number,
584            vram_bank,
585            palette,
586            horizontal_flip,
587            vertical_flip,
588            low_priority,
589        });
590        if sprite_buffer.len() == MAX_SPRITES_PER_LINE {
591            break;
592        }
593    }
594
595    sprite_buffer.sort_by(|a, b| a.x.cmp(&b.x).then(a.oam_index.cmp(&b.oam_index)));
596}
597
598const NINTENDO_LOGO_ADDR: Range<usize> = 0x0104..0x0134;
599const LOGO_TILE_DATA_ADDR: usize = 0x0010;
600
601const TRADEMARK_TILE_DATA_ADDR: usize = 0x0190;
602const TRADEMARK_SYMBOL: [u8; 16] = [
603    0x3C, 0x00, 0x42, 0x00, 0xB9, 0x00, 0xA5, 0x00, 0xB9, 0x00, 0xA5, 0x00, 0x42, 0x00, 0x3C, 0x00,
604];

Initialize VRAM the way that the DMG boot ROM would. The Nintendo logo is copied out of the cartridge header. Some games depend on this by assuming that VRAM initially contains the Nintendo logo and a trademark symbol, e.g. X for its intro animation

610fn initialize_vram(hardware_mode: HardwareMode, rom: &[u8], vram: &mut [u8]) {
611    if hardware_mode != HardwareMode::Dmg {
612        // Only write the logo to VRAM on DMG
613        return;
614    }
615
616    if rom.len() < NINTENDO_LOGO_ADDR.end {
617        // Invalid ROM; don't try to initialize VRAM
618        return;
619    }
620
621    // Write logo to tile data area
622    let logo = &rom[NINTENDO_LOGO_ADDR];
623    for (i, logo_byte) in logo.iter().copied().enumerate() {
624        for nibble_idx in 0..2 {
625            let nibble = logo_byte >> (4 * (1 - nibble_idx));
626
627            // Duplicate pixels horizontally
628            let vram_byte = ((nibble & 8) << 4)
629                | ((nibble & 8) << 3)
630                | ((nibble & 4) << 3)
631                | ((nibble & 4) << 2)
632                | ((nibble & 2) << 2)
633                | ((nibble & 2) << 1)
634                | ((nibble & 1) << 1)
635                | (nibble & 1);
636
637            // Duplicate pixels vertically
638            let vram_addr = LOGO_TILE_DATA_ADDR + 4 * (2 * i + nibble_idx);
639            vram[vram_addr] = vram_byte;
640            vram[vram_addr + 2] = vram_byte;
641        }
642    }
643
644    // Write trademark to tile data area
645    vram[TRADEMARK_TILE_DATA_ADDR..TRADEMARK_TILE_DATA_ADDR + TRADEMARK_SYMBOL.len()]
646        .copy_from_slice(&TRADEMARK_SYMBOL);
647
648    // Populate tile map
649    // The upscaled logo is 12x2 tiles and should be centered, ranging from (X=4, Y=8) to (X=16, Y=10)
650    for tile_row in 0..2 {
651        for tile_col in 0..12 {
652            let vram_addr = 0x1800 + (8 + tile_row) * 32 + (4 + tile_col);
653            vram[vram_addr] = (1 + (12 * tile_row) + tile_col) as u8;
654        }
655    }
656
657    // Trademark symbol should be in the top row just to the right of the logo, at (X=16, Y=8)
658    vram[0x1800 + 8 * 32 + 16] = (TRADEMARK_TILE_DATA_ADDR / 16) as u8;
659}