psx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/psx.rb ADDED
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "psx/version"
4
+ require_relative "psx/bios"
5
+ require_relative "psx/ram"
6
+ require_relative "psx/cop0"
7
+ require_relative "psx/interrupts"
8
+ require_relative "psx/dma"
9
+ require_relative "psx/gpu"
10
+ require_relative "psx/timers"
11
+ require_relative "psx/cdrom"
12
+ require_relative "psx/sio0"
13
+ require_relative "psx/spu"
14
+ require_relative "psx/memory"
15
+ require_relative "psx/cpu"
16
+ require_relative "psx/disasm"
17
+ require_relative "psx/display"
18
+
19
+ module PSX
20
+ class Emulator
21
+ attr_reader :cpu, :memory, :interrupts, :dma, :gpu, :timers, :cdrom, :sio0
22
+ attr_accessor :controller_state_proc
23
+
24
+ # Timing constants
25
+ CPU_FREQ = 33_868_800 # 33.8688 MHz
26
+ CYCLES_PER_FRAME = CPU_FREQ / 60 # ~560K cycles per frame at 60Hz
27
+ CYCLES_PER_SCANLINE = CYCLES_PER_FRAME / 263 # NTSC has 263 scanlines
28
+
29
+ def initialize(bios_path)
30
+ bios = BIOS.new(bios_path)
31
+ ram = RAM.new
32
+ @interrupts = Interrupts.new
33
+ @spu = SPU.new
34
+ @dma = DMA.new(interrupts: @interrupts, spu: @spu)
35
+ @gpu = GPU.new(interrupts: @interrupts)
36
+ @timers = Timers.new(interrupts: @interrupts)
37
+ @cdrom = CDROM.new(interrupts: @interrupts)
38
+ @controller_state_proc = -> { 0xFFFF }
39
+ @sio0 = SIO0.new(interrupts: @interrupts, controller_state: -> { @controller_state_proc.call })
40
+ @memory = Memory.new(
41
+ bios: bios, ram: ram, interrupts: @interrupts,
42
+ dma: @dma, timers: @timers, cdrom: @cdrom, sio0: @sio0, spu: @spu
43
+ )
44
+ @memory.gpu = @gpu
45
+ @cpu = CPU.new(@memory, interrupts: @interrupts)
46
+
47
+ @cycle_count = 0
48
+ @frame_count = 0
49
+ end
50
+
51
+ def run(steps: nil, debug: false)
52
+ if debug
53
+ run_debug(steps)
54
+ elsif steps
55
+ run_fast(steps)
56
+ else
57
+ run_forever
58
+ end
59
+ rescue CPU::ExecutionError => e
60
+ puts "Execution error: #{e.message}"
61
+ puts @cpu.dump_registers
62
+ raise
63
+ end
64
+
65
+ # Fast path for Ruby CPU: known number of steps, no debug
66
+ def run_fast(steps)
67
+ cpu = @cpu
68
+ cycle_count = @cycle_count
69
+ frame_count = @frame_count
70
+ timers = @timers
71
+ interrupts = @interrupts
72
+ gpu = @gpu
73
+ remaining = steps
74
+
75
+ sio0 = @sio0
76
+ dma = @dma
77
+ tick_threshold = 64
78
+ while remaining > 0
79
+ remaining -= 1
80
+ cpu.step
81
+
82
+ # Inlined tick_devices. cpu.step_cycles is the effective cycle cost
83
+ # of the instruction we just ran (1 for ALU, 2 for loads). Using it
84
+ # instead of a constant 1 keeps the VBlank period in line with the
85
+ # BIOS' own timing expectations, so VSync waits don't time out.
86
+ cycles = cpu.step_cycles
87
+ cycle_count += cycles
88
+ if cycle_count >= tick_threshold
89
+ tick_threshold = cycle_count + 64
90
+ timers.tick(64)
91
+ sio0.tick(64)
92
+ dma.tick_cycles(64)
93
+ end
94
+ if cycle_count >= CYCLES_PER_FRAME
95
+ cycle_count = 0
96
+ tick_threshold = 64
97
+ frame_count += 1
98
+ interrupts.request(Interrupts::IRQ_VBLANK)
99
+ gpu.vblank
100
+ @cdrom.tick
101
+ end
102
+ end
103
+
104
+ @cycle_count = cycle_count
105
+ @frame_count = frame_count
106
+ end
107
+
108
+ def run_forever
109
+ loop do
110
+ @cpu.step
111
+ tick_devices
112
+ end
113
+ end
114
+
115
+ def run_debug(steps)
116
+ count = 0
117
+ loop do
118
+ puts @cpu.disassemble_current
119
+ puts @cpu.dump_registers if count % 10 == 0
120
+ @cpu.step
121
+ tick_devices
122
+ count += 1
123
+ break if steps && count >= steps
124
+ end
125
+ end
126
+
127
+ # Run for specified number of frames
128
+ def run_frames(frames)
129
+ frames.times do |f|
130
+ run(steps: CYCLES_PER_FRAME)
131
+ @interrupts.request(Interrupts::IRQ_VBLANK)
132
+ @frame_count += 1
133
+ end
134
+ end
135
+
136
+ # Run with graphical display (threaded: emulation in background)
137
+ def run_with_display(target_fps: 60, frameskip: true)
138
+ display = Display.new(title: "PSX-Ruby - Loading BIOS...")
139
+ @controller_state_proc = -> { display.controller_state }
140
+ render_interval = 1.0 / target_fps
141
+
142
+ # Run many cycles between renders for speed
143
+ cycles_per_chunk = 100_000 # Smaller chunks for better responsiveness
144
+
145
+ puts "Starting emulation with display (threaded)..."
146
+ puts "Note: BIOS takes ~40 seconds to show Sony logo"
147
+ puts "Controls: Arrow keys=D-pad, Z=Cross, X=Circle, A=Square, S=Triangle"
148
+ puts " Enter=Start, Space=Select, Q/W=L1/R1, Escape=Quit"
149
+ puts ""
150
+
151
+ # Shared state
152
+ @emu_mutex = Mutex.new
153
+ @quit_flag = false
154
+ @emu_error = nil
155
+ @total_cycles = 0
156
+
157
+ # Emulation thread
158
+ emu_thread = Thread.new do
159
+ loop do
160
+ break if @quit_flag
161
+
162
+ begin
163
+ @emu_mutex.synchronize do
164
+ run(steps: cycles_per_chunk)
165
+ @total_cycles += cycles_per_chunk
166
+ end
167
+ rescue CPU::ExecutionError => e
168
+ @emu_error = e
169
+ break
170
+ end
171
+
172
+ # Small yield to let main thread get lock
173
+ Thread.pass if @total_cycles % 500_000 == 0
174
+ end
175
+ end
176
+
177
+ # Main thread: SDL events and rendering
178
+ last_render = Time.now
179
+ last_status = Time.now
180
+ last_status_cycles = 0
181
+ loop do
182
+ # Poll SDL events (must be on main thread on macOS)
183
+ display.poll_events
184
+ if display.quit_requested?
185
+ @quit_flag = true
186
+ break
187
+ end
188
+
189
+ # Check for emulation errors
190
+ if @emu_error
191
+ puts "CPU Error: #{@emu_error.message}"
192
+ @emu_mutex.synchronize { puts @cpu.dump_registers }
193
+ @quit_flag = true
194
+ break
195
+ end
196
+
197
+ # Render at target FPS. Important: do NOT toggle @gpu.vblank here.
198
+ # The emulator's run loop is the authoritative driver of vblank
199
+ # (CYCLES_PER_FRAME boundaries). Toggling here at wall-clock 60 Hz on
200
+ # top of that confuses BIOS-side vsync counters and trips
201
+ # SystemErrorUnresolvedException.
202
+ now = Time.now
203
+ elapsed = now - last_render
204
+ if elapsed >= render_interval
205
+ @emu_mutex.synchronize do
206
+ @frame_count += 1
207
+ display.update(@gpu.framebuffer)
208
+ end
209
+ last_render = now
210
+ else
211
+ # Sleep for remaining time to hit target FPS
212
+ sleep_time = render_interval - elapsed
213
+ sleep(sleep_time * 0.8) if sleep_time > 0.001 # Don't oversleep
214
+ end
215
+
216
+ # Periodic status line so progress is visible from the console.
217
+ if now - last_status >= 2.0
218
+ delta = @total_cycles - last_status_cycles
219
+ ips = delta.to_f / (now - last_status)
220
+ printf "frames=%5d cycles=%10d %.1f Mips PC=%08X\n",
221
+ @frame_count, @total_cycles, ips / 1_000_000.0, @cpu.pc
222
+ $stdout.flush
223
+ last_status = now
224
+ last_status_cycles = @total_cycles
225
+ end
226
+ end
227
+
228
+ # Wait for emulation thread to finish
229
+ emu_thread.join(1.0) # Wait up to 1 second
230
+
231
+ display.close
232
+ puts "\nEmulation ended after #{@frame_count} frames (#{@total_cycles} cycles)"
233
+ end
234
+
235
+ # Load a PS-EXE executable into RAM and prepare the CPU to run it.
236
+ # Standard PSX EXE format: 2048-byte header (magic "PS-X EXE"), followed
237
+ # by the text/data section that must be copied to dest_addr. After load
238
+ # the CPU is positioned at the executable's entry point with GP and SP
239
+ # set per the header (or the conventional defaults if zero).
240
+ def load_psexe(path)
241
+ data = File.binread(path)
242
+ raise "PS-EXE smaller than header: #{data.bytesize} bytes" if data.bytesize < 0x800
243
+ magic = data.byteslice(0, 8)
244
+ raise "Not a PS-EXE (magic=#{magic.inspect})" unless magic == "PS-X EXE"
245
+
246
+ initial_pc = data.byteslice(0x10, 4).unpack1("V")
247
+ initial_gp = data.byteslice(0x14, 4).unpack1("V")
248
+ dest_addr = data.byteslice(0x18, 4).unpack1("V")
249
+ file_size = data.byteslice(0x1C, 4).unpack1("V")
250
+ memfill_start = data.byteslice(0x20, 4).unpack1("V")
251
+ memfill_size = data.byteslice(0x24, 4).unpack1("V")
252
+ stack_addr = data.byteslice(0x28, 4).unpack1("V")
253
+ _stack_size = data.byteslice(0x2C, 4).unpack1("V")
254
+
255
+ payload = data.byteslice(0x800, file_size) || ""
256
+ payload.each_byte.with_index do |b, i|
257
+ @memory.write8((dest_addr + i) & 0xFFFF_FFFF, b)
258
+ end
259
+
260
+ if memfill_size.positive?
261
+ memfill_size.times do |i|
262
+ @memory.write8((memfill_start + i) & 0xFFFF_FFFF, 0)
263
+ end
264
+ end
265
+
266
+ sp = stack_addr.zero? ? 0x801F_FFF0 : stack_addr
267
+ @cpu.regs[28] = initial_gp & 0xFFFF_FFFF
268
+ @cpu.regs[29] = sp & 0xFFFF_FFFF
269
+ @cpu.regs[30] = sp & 0xFFFF_FFFF
270
+ @cpu.regs[31] = 0 # return-to-zero on exit
271
+ @cpu.pc = initial_pc
272
+
273
+ {
274
+ entry: initial_pc, gp: initial_gp, sp: sp, dest: dest_addr,
275
+ size: file_size, memfill: [memfill_start, memfill_size]
276
+ }
277
+ end
278
+
279
+ # Save framebuffer as PPM image
280
+ def save_screenshot(filename)
281
+ fb = @gpu.framebuffer
282
+ rgba = fb[:rgba]
283
+ width = fb[:width]
284
+ height = fb[:height]
285
+
286
+ # Extract RGB from RGBA (skip alpha bytes)
287
+ rgb_data = String.new(capacity: width * height * 3)
288
+ i = 0
289
+ (width * height).times do
290
+ rgb_data << rgba.getbyte(i).chr << rgba.getbyte(i + 1).chr << rgba.getbyte(i + 2).chr
291
+ i += 4
292
+ end
293
+
294
+ File.open(filename, "wb") do |f|
295
+ f.puts "P6"
296
+ f.puts "#{width} #{height}"
297
+ f.puts "255"
298
+ f.write rgb_data
299
+ end
300
+ puts "Saved screenshot to #{filename} (#{width}x#{height})"
301
+ end
302
+
303
+ # Save framebuffer as ASCII art (for terminal)
304
+ def ascii_screenshot(width: 80)
305
+ fb = @gpu.framebuffer
306
+ rgba = fb[:rgba]
307
+ scale_x = fb[:width].to_f / width
308
+ height = (fb[:height] / scale_x / 2).to_i # /2 because terminal chars are ~2x tall
309
+
310
+ chars = " .:-=+*#%@"
311
+
312
+ lines = []
313
+ height.times do |y|
314
+ line = +"" # Unfrozen string
315
+ width.times do |x|
316
+ src_x = (x * scale_x).to_i
317
+ src_y = (y * scale_x * 2).to_i
318
+ # RGBA format: 4 bytes per pixel
319
+ idx = (src_y * fb[:width] + src_x) * 4
320
+ r = rgba.getbyte(idx) || 0
321
+ g = rgba.getbyte(idx + 1) || 0
322
+ b = rgba.getbyte(idx + 2) || 0
323
+ brightness = (r + g + b) / 3.0 / 255.0
324
+ char_idx = (brightness * (chars.length - 1)).to_i
325
+ line << chars[char_idx]
326
+ end
327
+ lines << line
328
+ end
329
+ lines.join("\n")
330
+ end
331
+
332
+ private
333
+
334
+ def tick_devices
335
+ @cycle_count += 1
336
+
337
+ # Tick timers less frequently for performance
338
+ if @cycle_count % 64 == 0
339
+ @timers.tick(64)
340
+ @sio0.tick(64)
341
+ @dma.tick_cycles(64)
342
+ end
343
+
344
+ # VBlank every frame
345
+ if @cycle_count >= CYCLES_PER_FRAME
346
+ @cycle_count = 0
347
+ @frame_count += 1
348
+ @interrupts.request(Interrupts::IRQ_VBLANK)
349
+ @gpu.vblank # Toggle interlace field
350
+ @cdrom.tick
351
+ end
352
+ end
353
+ end
354
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: psx
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chris Hasiński
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby-sdl2
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13'
54
+ description: |
55
+ A work-in-progress PlayStation 1 emulator written entirely in Ruby.
56
+ Implements the MIPS R3000A CPU, GTE, GPU (software rasteriser), DMA,
57
+ interrupts, timers, CD-ROM stub, SIO0 controller, and a minimal SPU —
58
+ enough to boot the SCPH1001 BIOS into the Memory Card / CD-ROM shell.
59
+ Ships an SDL2-backed front-end via the `psx` command. A BIOS image is
60
+ not included and must be supplied by the user.
61
+ email:
62
+ - krzysztof.hasinski@gmail.com
63
+ executables:
64
+ - psx
65
+ extensions: []
66
+ extra_rdoc_files: []
67
+ files:
68
+ - CHANGELOG.md
69
+ - LICENSE
70
+ - README.md
71
+ - exe/psx
72
+ - lib/psx.rb
73
+ - lib/psx/bios.rb
74
+ - lib/psx/cdrom.rb
75
+ - lib/psx/cop0.rb
76
+ - lib/psx/cpu.rb
77
+ - lib/psx/disasm.rb
78
+ - lib/psx/display.rb
79
+ - lib/psx/dma.rb
80
+ - lib/psx/gpu.rb
81
+ - lib/psx/gte.rb
82
+ - lib/psx/interrupts.rb
83
+ - lib/psx/memory.rb
84
+ - lib/psx/ram.rb
85
+ - lib/psx/sio0.rb
86
+ - lib/psx/spu.rb
87
+ - lib/psx/timers.rb
88
+ - lib/psx/version.rb
89
+ homepage: https://github.com/khasinski/psx
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ source_code_uri: https://github.com/khasinski/psx
94
+ bug_tracker_uri: https://github.com/khasinski/psx/issues
95
+ changelog_uri: https://github.com/khasinski/psx/blob/main/CHANGELOG.md
96
+ rubygems_mfa_required: 'true'
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 3.2.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 4.0.6
112
+ specification_version: 4
113
+ summary: PlayStation 1 emulator written in pure Ruby
114
+ test_files: []