PlayStation: EXE Sideloading and the TTY

Two of the most useful things for a new PlayStation emulator to implement are EXE sideloading and TTY support. Supporting EXE sideloading makes it possible to load programs before any CD-ROM functionality is implemented, and supporting TTY output makes it possible to see debug output from the BIOS and from test programs.

Memory Map

First, a quick overview of the PS1 memory map, because you need to implement the basics to get anything to boot.

The majority of the memory map can be accessed through one of three memory regions:

  • $00000000-$1FFFFFFF: First 512MB of kuseg (cached, always accessible)
  • $80000000-$9FFFFFFF: kseg0 (cached, accessible in kernel mode)
  • $A0000000-$BFFFFFFF: kseg1 (uncached, accessible in kernel mode)

kseg0 is the most commonly accessed region, both by the BIOS and by PS1 software.

Assuming the access is through one of the above three regions, only the lowest 29 bits of the address are used for memory mapping. The core of the memory map looks like this:

  • $00000000-$001FFFFF: Main RAM (2MB)
  • $1F800000-$1F8003FF: Scratchpad RAM (1KB)
    • Scratchpad is apparently accessible only through kuseg and kseg0, not kseg1
  • $1F801000-$1F801FFF: Memory-mapped I/O ports
  • $1FC00000-$1FC7FFFF: BIOS ROM (512KB)

While kseg2 ($C0000000-$FFFFFFFF) is mostly unmapped, note that $FFFE0130 maps to a CPU cache control register that the BIOS occasionally accesses.

The most significant omissions here are the 3 expansion regions, but an emulator can mostly ignore these:

  • $1F000000-$1F7FFFFF is Expansion Region 1; if all reads from these addresses return 0, the BIOS will assume that no expansion device is connected
  • $1F802000-$1F803FFF is Expansion Region 2; the BIOS frequently writes to $1F802041, but these writes are apparently only intended to light up POST LEDs on an external device, and an emulator can ignore them
  • $1FA00000-$1FBFFFFF is Expansion Region 3; the BIOS never accesses these addresses

Beyond that, main RAM is mirrored at $00200000-$007FFFFF by default, and the BIOS ROM can be optionally mirrored at $1FC80000-$1FFFFFFF. Both of these mirrorings are controlled via memory control I/O registers, but the vast majority of software only accesses main RAM and BIOS ROM through the standard address ranges.

TTY

I’ll cover TTY output first because it’s very simple to implement.

The BIOS kernel has a number of functions for printing ASCII text to a TTY device, but they all end up calling one of the two putchar(char) functions internally. putchar() is normally a no-op unless software has explicitly enabled TTY output via the KernelRedirect(ttyflag) kernel function, but that doesn’t matter for an emulator - the emulator can detect when putchar() gets called and read the character that the kernel would have printed if TTY output was enabled.

PS1 software calls kernel functions by setting the function number in the R9 register and then jumping to RAM address $0000A0 (A functions), $0000B0 (B functions), or $0000C0 (C functions). For a single-parameter function, the parameter is passed via the R4 register. It’s technically valid to jump to the function address in any of the 3 memory regions (kuseg/kseg0/kseg1), so the emulator should specifically check for pc & 0x1FFFFFFF == 0x000000A0 (and the equivalent for B and C functions).

putchar() is invoked by either A($3C) or B($3D), which means jumping to $A0 with R9=$3C or jumping to $B0 with R9=$3D. If the emulator sees that happen, it can assume that R4 contains an ASCII character to be printed and print it. For example:

1
2
3
4
5
6
7
8
9
fn check_for_tty_output(registers: &Registers) {
    let pc = registers.pc & 0x1FFFFFFF;
    if (pc == 0xA0 && registers.gpr[9] == 0x3C) || (pc == 0xB0 && registers.gpr[9] == 0x3D) {
        // "as u8 as char" is the incantation to make Rust interpret the lowest byte of
        // a u32 value as an ASCII character
        let ch = registers.gpr[4] as u8 as char;
        print!("{ch}");
    }
}

You could also write the character into a buffer if you want to do something other than just print TTY output to stdout.

Here is the first TTY output from the SCPH1001 BIOS:


PS-X Realtime Kernel Ver.2.5

EXE Sideloading

Running an EXE in a PS1 emulator is not quite as straightforward as just starting execution at the right place. PS1 EXEs typically require that the BIOS kernel has been initialized, but the BIOS won’t automatically start running an EXE that just happens to be sitting in emulated RAM, at least not in the way you’d want it to. There’s a way to make the BIOS automatically load EXEs using Expansion Region 1, but the EXE load time is not ideal - the only two options are either too early (before the kernel is initialized) or too late (after the BIOS has already shown some graphical output and done some other stuff). So how does an emulator run an EXE?

The simplest way is to take advantage of how the BIOS boots. The BIOS first performs basic hardware initialization, then it copies some kernel code from BIOS ROM into the first 64KB of RAM, then it fully initializes the kernel, and then it starts executing the shell which is what shows the startup animation and attempts to load whatever disc is in the drive (or shows a main menu if there is no disc).

The trick to EXE sideloading is to detect when the BIOS starts to run the shell, stop execution, initialize the EXE in RAM, and then hand over control to the EXE. It just so happens that every single BIOS version copies the shell into $030000 in RAM and then begins shell execution by jumping to $80030000, so an emulator can sideload the EXE as soon as it sees PC=$80030000.

So, begin execution at the reset vector $BFC00000 as normal, let the BIOS run until it jumps to $80030000, sideload the EXE, then continue execution normally after that.

Format

psx-spx describes the PS1 EXE format here. The EXE begins with a 2048-byte header describing how to initialize it, and the remaining bytes contain code and data that need to be copied into PS1 RAM before execution begins.

The important parts of the header are:

  • $10-$13: Initial PC value (often $80010000)
  • $14-$17: Initial R28 value (often 0)
  • $18-$1B: RAM address where the EXE bytes should be copied (often $80010000)
  • $1C-$1F: EXE size in multiples of 2KB, excluding the 2KB header
  • $30-$33: Initial R29 and R30 value (often $801FFFF0)
    • If 0, the emulator should leave these registers untouched (although this is unlikely in any EXE that you’d want to sideload)

So, EXE sideloading might look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
pub struct Emulator {
    cpu: R3000,
    main_ram: Box<[u8]>,
    ...
}

impl Emulator {
    pub fn tick(&mut self) {
        // implementation omitted
    }

    pub fn sideload_exe(&mut self, exe: &[u8]) {
        // Wait for the BIOS to jump to the shell
        while self.cpu.registers.pc != 0x80030000 {
            // Tick must be instruction-by-instruction to avoid possibly missing the $80030000 jump
            self.tick();
        }

        // Parse EXE header
        let initial_pc = u32::from_le_bytes(exe[0x10..0x14].try_into().unwrap());
        let initial_r28 = u32::from_le_bytes(exe[0x14..0x18].try_into().unwrap());
        let exe_ram_addr = u32::from_le_bytes(exe[0x18..0x1C].try_into().unwrap()) & 0x1FFFFF;
        let exe_size_2kb = u32::from_le_bytes(exe[0x1C..0x20].try_into().unwrap());
        let initial_sp = u32::from_le_bytes(exe[0x30..0x34].try_into().unwrap());

        // Copy EXE code/data into PS1 RAM
        let exe_size = exe_size_2kb * 2048;
        self.main_ram[exe_ram_addr as usize..(exe_ram_addr + exe_size) as usize]
            .copy_from_slice(&exe[2048..2048 + exe_size as usize]);

        // Set initial register values
        self.cpu.registers.gpr[28] = initial_r28;
        if initial_sp != 0 {
            self.cpu.registers.gpr[29] = initial_sp;
            self.cpu.registers.gpr[30] = initial_sp;
        }

        // Jump to the EXE entry point; execution can continue normally after this
        self.cpu.registers.pc = initial_pc;
    }
}

That’s pretty much it! For reference, with the SCPH1001 BIOS, this is all of the TTY output that it should print before it jumps to $80030000:


PS-X Realtime Kernel Ver.2.5
Copyright 1993,1994 (C) Sony Computer Entertainment Inc.
KERNEL SETUP!

Configuration : EvCB   0x10        TCB 0x04

If you’re running Amidog’s psxtest_cpu.exe, its first TTY output should look something like this:

args: 0
PSX is NTSC with CPU 00000002, GPU 00000002 and SPU 00000000
TEXT  : 0x80010000 to 0x8001f12c (0x0000f12c /      61740 bytes)
RODATA: 0x8001f12c to 0x80101cd0 (0x000e2ba4 /     928676 bytes)
DATA  : 0x80101cd0 to 0x80106a34 (0x00004d64 /      19812 bytes)
BSS   : 0x80106a34 to 0x80118610 (0x00011bdc /      72668 bytes)
TOTAL : 0x80010000 to 0x80118610 (0x00108610 /    1082896 bytes)
STACK : 0x80118610 to 0x801efff0 (0x000d79e0 /     883168 bytes)
WORK  : 0x801f0000 to 0x80200000 (0x00010000 /      65536 bytes)
FREE  : 0x80118610 to 0x801efff0 (0x000d79e0 /     883168 bytes)
Running tests
Running CPU tests
Running CPU LOG test
Running mthi_mfhi test
Done
updatedupdated2024-03-252024-03-25