ppu.rsannotatedppu.rssource659 lines · 23.8 KB · raw
1//! Game Boy PPU (picture processing unit)
2
3mod debug;
4mod fifo;
5mod registers;
6
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 };
27
28// 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;
32
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];
605
606// Initialize VRAM the way that the DMG boot ROM would. The Nintendo logo is copied out of the
607// cartridge header.
608// Some games depend on this by assuming that VRAM initially contains the Nintendo logo and a
609// 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}