Class: PSX::Emulator

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

#cdromObject (readonly)

Returns the value of attribute cdrom.



21
22
23
# File 'lib/psx.rb', line 21

def cdrom
  @cdrom
end

#controller_state_procObject

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

#cpuObject (readonly)

Returns the value of attribute cpu.



21
22
23
# File 'lib/psx.rb', line 21

def cpu
  @cpu
end

#dmaObject (readonly)

Returns the value of attribute dma.



21
22
23
# File 'lib/psx.rb', line 21

def dma
  @dma
end

#gpuObject (readonly)

Returns the value of attribute gpu.



21
22
23
# File 'lib/psx.rb', line 21

def gpu
  @gpu
end

#interruptsObject (readonly)

Returns the value of attribute interrupts.



21
22
23
# File 'lib/psx.rb', line 21

def interrupts
  @interrupts
end

#memoryObject (readonly)

Returns the value of attribute memory.



21
22
23
# File 'lib/psx.rb', line 21

def memory
  @memory
end

#sio0Object (readonly)

Returns the value of attribute sio0.



21
22
23
# File 'lib/psx.rb', line 21

def sio0
  @sio0
end

#timersObject (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.message}"
  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_foreverObject



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.message}"
      @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