Class: PSX::Emulator
- Inherits:
-
Object
- Object
- PSX::Emulator
- Defined in:
- lib/psx.rb
Constant Summary collapse
- CPU_FREQ =
Timing constants
33_868_800- CYCLES_PER_FRAME =
33.8688 MHz
CPU_FREQ / 60
- CYCLES_PER_SCANLINE =
~560K cycles per frame at 60Hz
CYCLES_PER_FRAME / 263
Instance Attribute Summary collapse
-
#cdrom ⇒ Object
readonly
Returns the value of attribute cdrom.
-
#controller_state_proc ⇒ Object
Returns the value of attribute controller_state_proc.
-
#cpu ⇒ Object
readonly
Returns the value of attribute cpu.
-
#dma ⇒ Object
readonly
Returns the value of attribute dma.
-
#gpu ⇒ Object
readonly
Returns the value of attribute gpu.
-
#interrupts ⇒ Object
readonly
Returns the value of attribute interrupts.
-
#memory ⇒ Object
readonly
Returns the value of attribute memory.
-
#sio0 ⇒ Object
readonly
Returns the value of attribute sio0.
-
#timers ⇒ Object
readonly
Returns the value of attribute timers.
Instance Method Summary collapse
-
#ascii_screenshot(width: 80) ⇒ Object
Save framebuffer as ASCII art (for terminal).
-
#initialize(bios_path) ⇒ Emulator
constructor
NTSC has 263 scanlines.
-
#load_psexe(path) ⇒ Object
Load a PS-EXE executable into RAM and prepare the CPU to run it.
- #run(steps: nil, debug: false) ⇒ Object
- #run_debug(steps) ⇒ Object
-
#run_fast(steps) ⇒ Object
Fast path for Ruby CPU: known number of steps, no debug.
- #run_forever ⇒ Object
-
#run_frames(frames) ⇒ Object
Run for specified number of frames.
-
#run_with_display(target_fps: 60, frameskip: true) ⇒ Object
Run with graphical display (threaded: emulation in background).
-
#save_screenshot(filename) ⇒ Object
Save framebuffer as PPM image.
Constructor Details
#initialize(bios_path) ⇒ Emulator
NTSC has 263 scanlines
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'lib/psx.rb', line 29 def initialize(bios_path) bios = BIOS.new(bios_path) ram = RAM.new @interrupts = Interrupts.new @spu = SPU.new @dma = DMA.new(interrupts: @interrupts, spu: @spu) @gpu = GPU.new(interrupts: @interrupts) @timers = Timers.new(interrupts: @interrupts) @cdrom = CDROM.new(interrupts: @interrupts) @controller_state_proc = -> { 0xFFFF } @sio0 = SIO0.new(interrupts: @interrupts, controller_state: -> { @controller_state_proc.call }) @memory = Memory.new( bios: bios, ram: ram, interrupts: @interrupts, dma: @dma, timers: @timers, cdrom: @cdrom, sio0: @sio0, spu: @spu ) @memory.gpu = @gpu @cpu = CPU.new(@memory, interrupts: @interrupts) @cycle_count = 0 @frame_count = 0 end |
Instance Attribute Details
#cdrom ⇒ Object (readonly)
Returns the value of attribute cdrom.
21 22 23 |
# File 'lib/psx.rb', line 21 def cdrom @cdrom end |
#controller_state_proc ⇒ Object
Returns the value of attribute controller_state_proc.
22 23 24 |
# File 'lib/psx.rb', line 22 def controller_state_proc @controller_state_proc end |
#cpu ⇒ Object (readonly)
Returns the value of attribute cpu.
21 22 23 |
# File 'lib/psx.rb', line 21 def cpu @cpu end |
#dma ⇒ Object (readonly)
Returns the value of attribute dma.
21 22 23 |
# File 'lib/psx.rb', line 21 def dma @dma end |
#gpu ⇒ Object (readonly)
Returns the value of attribute gpu.
21 22 23 |
# File 'lib/psx.rb', line 21 def gpu @gpu end |
#interrupts ⇒ Object (readonly)
Returns the value of attribute interrupts.
21 22 23 |
# File 'lib/psx.rb', line 21 def interrupts @interrupts end |
#memory ⇒ Object (readonly)
Returns the value of attribute memory.
21 22 23 |
# File 'lib/psx.rb', line 21 def memory @memory end |
#sio0 ⇒ Object (readonly)
Returns the value of attribute sio0.
21 22 23 |
# File 'lib/psx.rb', line 21 def sio0 @sio0 end |
#timers ⇒ Object (readonly)
Returns the value of attribute timers.
21 22 23 |
# File 'lib/psx.rb', line 21 def timers @timers end |
Instance Method Details
#ascii_screenshot(width: 80) ⇒ Object
Save framebuffer as ASCII art (for terminal)
304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 |
# File 'lib/psx.rb', line 304 def ascii_screenshot(width: 80) fb = @gpu.framebuffer rgba = fb[:rgba] scale_x = fb[:width].to_f / width height = (fb[:height] / scale_x / 2).to_i # /2 because terminal chars are ~2x tall chars = " .:-=+*#%@" lines = [] height.times do |y| line = +"" # Unfrozen string width.times do |x| src_x = (x * scale_x).to_i src_y = (y * scale_x * 2).to_i # RGBA format: 4 bytes per pixel idx = (src_y * fb[:width] + src_x) * 4 r = rgba.getbyte(idx) || 0 g = rgba.getbyte(idx + 1) || 0 b = rgba.getbyte(idx + 2) || 0 brightness = (r + g + b) / 3.0 / 255.0 char_idx = (brightness * (chars.length - 1)).to_i line << chars[char_idx] end lines << line end lines.join("\n") end |
#load_psexe(path) ⇒ Object
Load a PS-EXE executable into RAM and prepare the CPU to run it. Standard PSX EXE format: 2048-byte header (magic “PS-X EXE”), followed by the text/data section that must be copied to dest_addr. After load the CPU is positioned at the executable’s entry point with GP and SP set per the header (or the conventional defaults if zero).
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/psx.rb', line 240 def load_psexe(path) data = File.binread(path) raise "PS-EXE smaller than header: #{data.bytesize} bytes" if data.bytesize < 0x800 magic = data.byteslice(0, 8) raise "Not a PS-EXE (magic=#{magic.inspect})" unless magic == "PS-X EXE" initial_pc = data.byteslice(0x10, 4).unpack1("V") initial_gp = data.byteslice(0x14, 4).unpack1("V") dest_addr = data.byteslice(0x18, 4).unpack1("V") file_size = data.byteslice(0x1C, 4).unpack1("V") memfill_start = data.byteslice(0x20, 4).unpack1("V") memfill_size = data.byteslice(0x24, 4).unpack1("V") stack_addr = data.byteslice(0x28, 4).unpack1("V") _stack_size = data.byteslice(0x2C, 4).unpack1("V") payload = data.byteslice(0x800, file_size) || "" payload.each_byte.with_index do |b, i| @memory.write8((dest_addr + i) & 0xFFFF_FFFF, b) end if memfill_size.positive? memfill_size.times do |i| @memory.write8((memfill_start + i) & 0xFFFF_FFFF, 0) end end sp = stack_addr.zero? ? 0x801F_FFF0 : stack_addr @cpu.regs[28] = initial_gp & 0xFFFF_FFFF @cpu.regs[29] = sp & 0xFFFF_FFFF @cpu.regs[30] = sp & 0xFFFF_FFFF @cpu.regs[31] = 0 # return-to-zero on exit @cpu.pc = initial_pc { entry: initial_pc, gp: initial_gp, sp: sp, dest: dest_addr, size: file_size, memfill: [memfill_start, memfill_size] } end |
#run(steps: nil, debug: false) ⇒ Object
51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/psx.rb', line 51 def run(steps: nil, debug: false) if debug run_debug(steps) elsif steps run_fast(steps) else run_forever end rescue CPU::ExecutionError => e puts "Execution error: #{e.}" puts @cpu.dump_registers raise end |
#run_debug(steps) ⇒ Object
115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/psx.rb', line 115 def run_debug(steps) count = 0 loop do puts @cpu.disassemble_current puts @cpu.dump_registers if count % 10 == 0 @cpu.step tick_devices count += 1 break if steps && count >= steps end end |
#run_fast(steps) ⇒ Object
Fast path for Ruby CPU: known number of steps, no debug
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
# File 'lib/psx.rb', line 66 def run_fast(steps) cpu = @cpu cycle_count = @cycle_count frame_count = @frame_count timers = @timers interrupts = @interrupts gpu = @gpu remaining = steps sio0 = @sio0 dma = @dma tick_threshold = 64 while remaining > 0 remaining -= 1 cpu.step # Inlined tick_devices. cpu.step_cycles is the effective cycle cost # of the instruction we just ran (1 for ALU, 2 for loads). Using it # instead of a constant 1 keeps the VBlank period in line with the # BIOS' own timing expectations, so VSync waits don't time out. cycles = cpu.step_cycles cycle_count += cycles if cycle_count >= tick_threshold tick_threshold = cycle_count + 64 timers.tick(64) sio0.tick(64) dma.tick_cycles(64) end if cycle_count >= CYCLES_PER_FRAME cycle_count = 0 tick_threshold = 64 frame_count += 1 interrupts.request(Interrupts::IRQ_VBLANK) gpu.vblank @cdrom.tick end end @cycle_count = cycle_count @frame_count = frame_count end |
#run_forever ⇒ Object
108 109 110 111 112 113 |
# File 'lib/psx.rb', line 108 def run_forever loop do @cpu.step tick_devices end end |
#run_frames(frames) ⇒ Object
Run for specified number of frames
128 129 130 131 132 133 134 |
# File 'lib/psx.rb', line 128 def run_frames(frames) frames.times do |f| run(steps: CYCLES_PER_FRAME) @interrupts.request(Interrupts::IRQ_VBLANK) @frame_count += 1 end end |
#run_with_display(target_fps: 60, frameskip: true) ⇒ Object
Run with graphical display (threaded: emulation in background)
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 |
# File 'lib/psx.rb', line 137 def run_with_display(target_fps: 60, frameskip: true) display = Display.new(title: "PSX-Ruby - Loading BIOS...") @controller_state_proc = -> { display.controller_state } render_interval = 1.0 / target_fps # Run many cycles between renders for speed cycles_per_chunk = 100_000 # Smaller chunks for better responsiveness puts "Starting emulation with display (threaded)..." puts "Note: BIOS takes ~40 seconds to show Sony logo" puts "Controls: Arrow keys=D-pad, Z=Cross, X=Circle, A=Square, S=Triangle" puts " Enter=Start, Space=Select, Q/W=L1/R1, Escape=Quit" puts "" # Shared state @emu_mutex = Mutex.new @quit_flag = false @emu_error = nil @total_cycles = 0 # Emulation thread emu_thread = Thread.new do loop do break if @quit_flag begin @emu_mutex.synchronize do run(steps: cycles_per_chunk) @total_cycles += cycles_per_chunk end rescue CPU::ExecutionError => e @emu_error = e break end # Small yield to let main thread get lock Thread.pass if @total_cycles % 500_000 == 0 end end # Main thread: SDL events and rendering last_render = Time.now last_status = Time.now last_status_cycles = 0 loop do # Poll SDL events (must be on main thread on macOS) display.poll_events if display.quit_requested? @quit_flag = true break end # Check for emulation errors if @emu_error puts "CPU Error: #{@emu_error.}" @emu_mutex.synchronize { puts @cpu.dump_registers } @quit_flag = true break end # Render at target FPS. Important: do NOT toggle @gpu.vblank here. # The emulator's run loop is the authoritative driver of vblank # (CYCLES_PER_FRAME boundaries). Toggling here at wall-clock 60 Hz on # top of that confuses BIOS-side vsync counters and trips # SystemErrorUnresolvedException. now = Time.now elapsed = now - last_render if elapsed >= render_interval @emu_mutex.synchronize do @frame_count += 1 display.update(@gpu.framebuffer) end last_render = now else # Sleep for remaining time to hit target FPS sleep_time = render_interval - elapsed sleep(sleep_time * 0.8) if sleep_time > 0.001 # Don't oversleep end # Periodic status line so progress is visible from the console. if now - last_status >= 2.0 delta = @total_cycles - last_status_cycles ips = delta.to_f / (now - last_status) printf "frames=%5d cycles=%10d %.1f Mips PC=%08X\n", @frame_count, @total_cycles, ips / 1_000_000.0, @cpu.pc $stdout.flush last_status = now last_status_cycles = @total_cycles end end # Wait for emulation thread to finish emu_thread.join(1.0) # Wait up to 1 second display.close puts "\nEmulation ended after #{@frame_count} frames (#{@total_cycles} cycles)" end |
#save_screenshot(filename) ⇒ Object
Save framebuffer as PPM image
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
# File 'lib/psx.rb', line 280 def save_screenshot(filename) fb = @gpu.framebuffer rgba = fb[:rgba] width = fb[:width] height = fb[:height] # Extract RGB from RGBA (skip alpha bytes) rgb_data = String.new(capacity: width * height * 3) i = 0 (width * height).times do rgb_data << rgba.getbyte(i).chr << rgba.getbyte(i + 1).chr << rgba.getbyte(i + 2).chr i += 4 end File.open(filename, "wb") do |f| f.puts "P6" f.puts "#{width} #{height}" f.puts "255" f.write rgb_data end puts "Saved screenshot to #{filename} (#{width}x#{height})" end |