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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a7a1e5da6315c41ee938269c787e81c13431d1bcb6cd97b5122ec612fa8d0c6a
4
+ data.tar.gz: f16bb2c40428dedd7fa234ef19f250682e2b171f743688ef49595edac095d51a
5
+ SHA512:
6
+ metadata.gz: b4701b76ec82805ea3ff3a474cfb7c6cc2e42d518c88cc9e81983134171d0360feb6e69863da282102bfadf3ecc76f5a9249b0e66af76d70e2347e6de8284fc4
7
+ data.tar.gz: c27d4922fcb211752bb804913a9a2cc5ce5f033b65cf79efc1233bf616702f7a4652d1668f14df131a081929ca930c87eba7e595d23acb56f69599fb169d2824
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-05-15
4
+
5
+ Initial release.
6
+
7
+ - MIPS R3000A CPU with proper delay-slot / exception EPC handling.
8
+ - COP0 and GTE (coprocessor 2) coverage sufficient for the BIOS boot path.
9
+ - Software-rasteriser GPU covering most GP0/GP1 commands, textures (4/8/15
10
+ bit + CLUT), semi-transparency, mask bit.
11
+ - DMA channels: OTC, GPU (block & linked-list), SPU, CDROM.
12
+ - Interrupt controller, root counters, CD-ROM stub, SPU stub, SIO0 digital
13
+ pad (slot 1).
14
+ - Boots the SCPH1001 BIOS into the Memory Card / CD-ROM shell menu.
15
+ - `psx` SDL-backed CLI front-end.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Chris Hasiński
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # PSX
2
+
3
+ [![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](spec/)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ A work-in-progress PlayStation 1 emulator written in pure Ruby.
7
+
8
+ It boots the SCPH1001 BIOS into the **Memory Card / CD-ROM shell** and can
9
+ run a chunk of the [JaCzekanski/ps1-tests](https://github.com/JaCzekanski/ps1-tests)
10
+ suite. There is no audio, the CD-ROM is a stub (no disc image loading yet),
11
+ and the GPU is a software rasteriser with rough texture sampling — so don't
12
+ expect to play games. Think of it as an executable spec for the PS1.
13
+
14
+ ![Memory Card menu rendered by the BIOS](docs/shell_menu.png)
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ gem install psx
20
+ ```
21
+
22
+ You will also need SDL2 on your system (the gem depends on `ruby-sdl2`):
23
+
24
+ - macOS: `brew install sdl2`
25
+ - Debian / Ubuntu: `sudo apt install libsdl2-dev`
26
+ - Arch: `sudo pacman -S sdl2`
27
+
28
+ ## Usage
29
+
30
+ You must supply your own BIOS ROM (typically `SCPH1001.BIN`, 512 KB). PSX
31
+ does not ship one — BIOS images are copyrighted.
32
+
33
+ ```sh
34
+ psx path/to/SCPH1001.BIN
35
+ ```
36
+
37
+ Run `psx --help` for the option list. Keyboard controls (slot 1 digital pad):
38
+
39
+ | Key | Button |
40
+ | -------------- | -------- |
41
+ | Arrow keys | D-pad |
42
+ | Z | Cross |
43
+ | X | Circle |
44
+ | A | Square |
45
+ | S | Triangle |
46
+ | Enter | Start |
47
+ | Space | Select |
48
+ | Q / W | L1 / R1 |
49
+ | E / R | L2 / R2 |
50
+ | Escape | Quit |
51
+
52
+ The BIOS shell idles on the Sony logo until you press Triangle, which opens
53
+ the Memory Card / CD-ROM menu.
54
+
55
+ ## Programmatic use
56
+
57
+ ```ruby
58
+ require "psx"
59
+
60
+ emu = PSX::Emulator.new("SCPH1001.BIN")
61
+ emu.run(steps: 300_000_000) # boot to shell
62
+ emu.controller_state_proc = -> { 0xEFFF } # press Triangle
63
+ emu.run(steps: 30_000_000)
64
+ emu.save_screenshot("menu.ppm")
65
+ ```
66
+
67
+ The emulator exposes the major components individually: `emu.cpu`,
68
+ `emu.memory`, `emu.gpu`, `emu.dma`, `emu.cdrom`, `emu.sio0`, `emu.interrupts`,
69
+ `emu.timers`.
70
+
71
+ ## Development
72
+
73
+ ```sh
74
+ git clone https://github.com/khasinski/psx
75
+ cd psx
76
+ bundle install
77
+ bundle exec rake test
78
+ ```
79
+
80
+ The `bin/` directory contains development tools that are *not* shipped with
81
+ the gem:
82
+
83
+ | Script | Purpose |
84
+ | ----------------- | ----------------------------------------------------------------------- |
85
+ | `bin/psx-ruby` | Headless boot of the BIOS (no SDL window). |
86
+ | `bin/psx-test` | Run a PS-EXE on top of the BIOS, diff against ps1-tests reference logs. |
87
+ | `bin/psx-smoke` | Boot the BIOS and dump PPM screenshots at intervals. |
88
+ | `bin/psx-trace` | PC histogram + I_STAT/I_MASK summary at checkpoints. |
89
+ | `bin/psx-disasm` | Disassemble a range of physical addresses after N cycles of boot. |
90
+ | `bin/psx-biostrace` | Tally A/B/C jump-table calls; dump the last N calls. |
91
+ | `bin/psx-puts-trace` | Capture the kernel's debug TTY (`puts2`, `printf`, …). |
92
+ | `bin/psx-memwatch`| Log reads/writes to specific addresses with the PC that did them. |
93
+ | `bin/psx-dumpmem` | Hex-dump a range of memory after N cycles of boot. |
94
+
95
+ ### Running ps1-tests
96
+
97
+ The `bin/psx-test` runner expects a local checkout of
98
+ [JaCzekanski/ps1-tests](https://github.com/JaCzekanski/ps1-tests):
99
+
100
+ ```sh
101
+ git clone https://github.com/JaCzekanski/ps1-tests.git .tests
102
+ bundle exec ruby bin/psx-test -e .tests/cpu/cop/psx.log .tests/cpu/cop/cop.exe
103
+ ```
104
+
105
+ ## Status
106
+
107
+ What works:
108
+
109
+ - MIPS R3000A CPU with delay slots, exceptions, COP0
110
+ - GTE (passes the `gte/test-all` shape, several coverage gaps)
111
+ - GPU: GP0 polygons / lines / rectangles, textured / shaded / semi-transparent
112
+ primitives, CPU↔VRAM blits, mask bit. Software rasteriser, no PGXP.
113
+ - DMA channels for OTC, GPU (block / linked list), SPU (stub), CDROM (stub)
114
+ - Interrupt controller, root counters
115
+ - CD-ROM stub (responds to BIOS probe; no disc image support)
116
+ - SIO0 digital pad (slot 1)
117
+ - SPU stub (mirrors SPUCNT → SPUSTAT; no actual audio synthesis)
118
+ - Boots SCPH1001 into the Memory Card menu
119
+
120
+ What doesn't:
121
+
122
+ - No CD-ROM image loading — can't boot real games
123
+ - No SPU audio synthesis (no sound)
124
+ - Several ps1-tests fail (CD-ROM timing, code-in-IO bus errors)
125
+ - Texture sampling has visible glitches on the shell UI
126
+
127
+ ## License
128
+
129
+ MIT. See [LICENSE](LICENSE).
data/exe/psx ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PlayStation 1 emulator — graphical front-end. Loads a BIOS image and runs
5
+ # the emulator with an SDL window for display + keyboard controller input.
6
+
7
+ RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable)
8
+
9
+ require "psx"
10
+
11
+ def usage
12
+ warn <<~USAGE
13
+ PSX — PlayStation 1 emulator (#{PSX::VERSION})
14
+
15
+ Usage: psx [options] <bios.bin>
16
+
17
+ Options:
18
+ -f, --fps N Target FPS (default: 60)
19
+ -n, --no-frameskip Disable frame skipping
20
+ -v, --version Print version and exit
21
+ -h, --help Show this help
22
+
23
+ Controls (keyboard, slot 1 digital pad):
24
+ Arrow keys D-pad
25
+ Z Cross (confirm)
26
+ X Circle (cancel)
27
+ A Square
28
+ S Triangle
29
+ Enter Start
30
+ Space Select
31
+ Q / W L1 / R1
32
+ E / R L2 / R2
33
+ Escape Quit
34
+
35
+ A BIOS ROM (e.g. SCPH1001.BIN, 512 KB) is required and must be provided
36
+ by the user; PSX-Ruby does not ship one.
37
+ USAGE
38
+ exit 1
39
+ end
40
+
41
+ target_fps = 60
42
+ frameskip = true
43
+ bios_path = nil
44
+
45
+ args = ARGV.dup
46
+ while (arg = args.shift)
47
+ case arg
48
+ when "-f", "--fps"
49
+ target_fps = args.shift&.to_i
50
+ usage if target_fps.nil? || target_fps <= 0
51
+ when "-n", "--no-frameskip"
52
+ frameskip = false
53
+ when "-v", "--version"
54
+ puts PSX::VERSION
55
+ exit 0
56
+ when "-h", "--help"
57
+ usage
58
+ else
59
+ bios_path = arg
60
+ end
61
+ end
62
+
63
+ usage unless bios_path
64
+
65
+ unless File.exist?(bios_path)
66
+ warn "Error: BIOS file not found: #{bios_path}"
67
+ exit 1
68
+ end
69
+
70
+ puts "PSX-Ruby #{PSX::VERSION}"
71
+ puts "Loading BIOS: #{bios_path}"
72
+ puts "Target FPS: #{target_fps}"
73
+ puts "Frame skip: #{frameskip ? 'enabled' : 'disabled'}"
74
+ puts "YJIT: #{defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'enabled' : 'disabled'}"
75
+ puts ""
76
+
77
+ begin
78
+ emu = PSX::Emulator.new(bios_path)
79
+ emu.run_with_display(target_fps: target_fps, frameskip: frameskip)
80
+ rescue Interrupt
81
+ puts "\nInterrupted by user"
82
+ rescue => e
83
+ warn "\nError: #{e.message}"
84
+ warn e.backtrace.first(5).join("\n")
85
+ exit 1
86
+ end
data/lib/psx/bios.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PSX
4
+ class BIOS
5
+ SIZE = 512 * 1024 # 512 KB
6
+
7
+ def initialize(path)
8
+ @data = File.binread(path)
9
+
10
+ if @data.bytesize != SIZE
11
+ raise ArgumentError, "Invalid BIOS size: expected #{SIZE} bytes, got #{@data.bytesize}"
12
+ end
13
+
14
+ # Pre-compute 32-bit word array for fast read32 access
15
+ # This trades memory for speed (512KB -> 512KB + 128K integers)
16
+ @words = Array.new(SIZE / 4) do |i|
17
+ offset = i * 4
18
+ @data.getbyte(offset) |
19
+ (@data.getbyte(offset + 1) << 8) |
20
+ (@data.getbyte(offset + 2) << 16) |
21
+ (@data.getbyte(offset + 3) << 24)
22
+ end
23
+ end
24
+
25
+ def read8(offset)
26
+ @data.getbyte(offset)
27
+ end
28
+
29
+ def read16(offset)
30
+ @data.getbyte(offset) | (@data.getbyte(offset + 1) << 8)
31
+ end
32
+
33
+ def read32(offset)
34
+ # Fast path: aligned access from pre-computed array
35
+ if (offset & 3) == 0
36
+ @words[offset >> 2]
37
+ else
38
+ # Unaligned access (rare)
39
+ @data.getbyte(offset) |
40
+ (@data.getbyte(offset + 1) << 8) |
41
+ (@data.getbyte(offset + 2) << 16) |
42
+ (@data.getbyte(offset + 3) << 24)
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/psx/cdrom.rb ADDED
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PSX
4
+ # Minimal CD-ROM controller stub.
5
+ #
6
+ # Enough to convince the BIOS that there's a CD subsystem present with no
7
+ # disc inserted, so it proceeds to the shell screen. Handles the commands
8
+ # the BIOS issues during boot: GetStat, Init, GetID, Setmode, Test, etc.
9
+ #
10
+ # We don't model timing accurately. Responses are queued and delivered one
11
+ # per tick (called from the emulator's frame loop), respecting that the
12
+ # previous IRQ must be acknowledged and the previous response FIFO drained
13
+ # before the next response shows up.
14
+ class CDROM
15
+ # Status register (0x1F801800 read) bits
16
+ STAT_INDEX_MASK = 0x03
17
+ STAT_ADPCM_BUSY = 0x04
18
+ STAT_PARAMETER_FIFO_EMPTY = 0x08
19
+ STAT_PARAMETER_FIFO_NOT_FULL = 0x10
20
+ STAT_RESPONSE_FIFO_NOT_EMPTY = 0x20
21
+ STAT_DATA_FIFO_NOT_EMPTY = 0x40
22
+ STAT_BUSY = 0x80
23
+
24
+ # "stat" byte returned in command responses.
25
+ # Bit 4 = shell open (we say closed); bit 0 = error.
26
+ # We default to 0x02 (motor on, no error, no disc seek done) for no-disc.
27
+ DEFAULT_STAT = 0x02
28
+
29
+ def initialize(interrupts:)
30
+ @interrupts = interrupts
31
+ reset
32
+ end
33
+
34
+ def reset
35
+ @index = 0
36
+ @parameters = []
37
+ @response = []
38
+ @irq_enable = 0
39
+ @irq_flags = 0 # bits 0-2 = pending INT type (0..7)
40
+ @pending = [] # [[int_type, [bytes...]], ...]
41
+ @stat = DEFAULT_STAT
42
+ end
43
+
44
+ # --- Bus interface ------------------------------------------------------
45
+
46
+ def read8(reg)
47
+ case reg & 3
48
+ when 0 then status
49
+ when 1
50
+ # Response FIFO
51
+ v = @response.shift || 0
52
+ v & 0xFF
53
+ when 2
54
+ # Data FIFO (no data, just return 0)
55
+ 0
56
+ when 3
57
+ case @index
58
+ when 0, 2 then @irq_enable | 0xE0
59
+ when 1, 3 then @irq_flags | 0xE0
60
+ end
61
+ end
62
+ end
63
+
64
+ def write8(reg, value)
65
+ v = value & 0xFF
66
+ case reg & 3
67
+ when 0
68
+ @index = v & 3
69
+ when 1
70
+ case @index
71
+ when 0 then execute_command(v)
72
+ # other indices: audio map / right CD audio routing — ignore
73
+ end
74
+ when 2
75
+ case @index
76
+ when 0
77
+ @parameters.push(v) if @parameters.size < 16
78
+ when 1
79
+ @irq_enable = v & 0x1F
80
+ # 2/3 = audio routing — ignore
81
+ end
82
+ when 3
83
+ case @index
84
+ when 0
85
+ # Request register: bit 7 BFRD enables data buffer read; bit 5 SMEN.
86
+ # Both unused by our stub.
87
+ when 1
88
+ # Ack IRQ flags and optionally reset parameter FIFO (bit 6).
89
+ @irq_flags &= ~(v & 0x1F)
90
+ @parameters.clear if (v & 0x40) != 0
91
+ # 2/3 = audio volume apply — ignore
92
+ end
93
+ end
94
+ end
95
+
96
+ # Advance one step in the response queue. Call this periodically (e.g.
97
+ # once per VBlank). One delivery per call: a response only appears once
98
+ # the previous one has been acknowledged.
99
+ def tick
100
+ return if @pending.empty?
101
+ return if @irq_flags != 0 # previous IRQ not yet acknowledged
102
+ return if !@response.empty? # previous response not yet drained
103
+
104
+ type, data = @pending.shift
105
+ @response.concat(data)
106
+ @irq_flags = type & 0x07
107
+ @interrupts.request(Interrupts::IRQ_CDROM) if (@irq_enable & @irq_flags) != 0
108
+ end
109
+
110
+ private
111
+
112
+ def status
113
+ s = @index & STAT_INDEX_MASK
114
+ s |= STAT_PARAMETER_FIFO_EMPTY if @parameters.empty?
115
+ s |= STAT_PARAMETER_FIFO_NOT_FULL if @parameters.size < 16
116
+ s |= STAT_RESPONSE_FIFO_NOT_EMPTY unless @response.empty?
117
+ s
118
+ end
119
+
120
+ def execute_command(cmd)
121
+ # Drain any leftover response bytes from a prior command. Real hardware
122
+ # has an 8-deep response FIFO that the BIOS may not fully read after an
123
+ # error (e.g. INT5 on GetID with no disc returns 8 bytes but the BIOS
124
+ # only consumes a couple). Without this, our `tick` stays blocked
125
+ # forever waiting for `@response.empty?` and no further commands fire.
126
+ @response.clear
127
+ case cmd
128
+ when 0x01 # Getstat
129
+ queue(3, [@stat])
130
+ when 0x02 # Setloc (MM, SS, FF)
131
+ @parameters.clear
132
+ queue(3, [@stat])
133
+ when 0x06 # ReadN
134
+ queue(3, [@stat])
135
+ queue(5, [0x11, 0x80]) # no disc -> error
136
+ when 0x07 # MotorOn
137
+ queue(3, [@stat])
138
+ queue(2, [@stat])
139
+ when 0x08 # Stop
140
+ queue(3, [@stat])
141
+ queue(2, [@stat])
142
+ when 0x09 # Pause
143
+ queue(3, [@stat])
144
+ queue(2, [@stat])
145
+ when 0x0A # Init
146
+ queue(3, [@stat])
147
+ queue(2, [@stat])
148
+ when 0x0B # Mute
149
+ queue(3, [@stat])
150
+ when 0x0C # Demute
151
+ queue(3, [@stat])
152
+ when 0x0D # Setfilter
153
+ queue(3, [@stat])
154
+ when 0x0E # Setmode
155
+ queue(3, [@stat])
156
+ when 0x0F # Getparam
157
+ queue(3, [@stat, 0x00, 0x00, 0x00])
158
+ when 0x10 # GetlocL
159
+ queue(3, [0, 0, 0, 0, 0, 0, 0, 0])
160
+ when 0x11 # GetlocP
161
+ queue(3, [1, 1, 0, 0, 0, 0, 0, 0])
162
+ when 0x13 # GetTN (number of tracks)
163
+ queue(3, [@stat, 0x01, 0x01])
164
+ when 0x14 # GetTD
165
+ queue(3, [@stat, 0x00, 0x02])
166
+ when 0x15 # SeekL
167
+ queue(3, [@stat])
168
+ queue(2, [@stat])
169
+ when 0x16 # SeekP
170
+ queue(3, [@stat])
171
+ queue(2, [@stat])
172
+ when 0x19 # Test
173
+ sub = @parameters.shift
174
+ case sub
175
+ when 0x20 # Get BIOS date/version
176
+ queue(3, [0x94, 0x09, 0x19, 0xC0])
177
+ else
178
+ queue(3, [@stat])
179
+ end
180
+ when 0x1A # GetID
181
+ # No-disc response per nocash spec: INT3(stat) then
182
+ # INT5(11h, 80h, 00h, 00h, 00h, 00h, 00h, 00h). 0x80 in byte 1 is
183
+ # the "no disc / not ready" error code.
184
+ queue(3, [@stat])
185
+ queue(5, [0x11, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
186
+ when 0x1B # ReadS
187
+ queue(3, [@stat])
188
+ queue(5, [0x11, 0x80])
189
+ when 0x1E # ReadTOC
190
+ queue(3, [@stat])
191
+ queue(2, [@stat])
192
+ else
193
+ queue(3, [@stat]) # default ack to keep BIOS moving
194
+ end
195
+ @parameters.clear unless cmd == 0x19 # Test consumes its own params
196
+ end
197
+
198
+ def queue(int_type, data)
199
+ @pending.push([int_type, data])
200
+ end
201
+ end
202
+ end
data/lib/psx/cop0.rb ADDED
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PSX
4
+ # Coprocessor 0 - System Control
5
+ # Handles exceptions, interrupts, and memory management
6
+ class COP0
7
+ # Register indices
8
+ BPC = 3 # Breakpoint on execute
9
+ BDA = 5 # Breakpoint on data access
10
+ JUMPDEST = 6 # Jump destination (for branch delay debugging)
11
+ DCIC = 7 # Breakpoint control
12
+ BADVADDR = 8 # Bad virtual address
13
+ BDAM = 9 # Data access breakpoint mask
14
+ BPCM = 11 # Execute breakpoint mask
15
+ SR = 12 # Status register
16
+ CAUSE = 13 # Exception cause
17
+ EPC = 14 # Exception program counter
18
+ PRID = 15 # Processor ID
19
+
20
+ # Status register bits
21
+ SR_IEC = 0x01 # Interrupt Enable Current
22
+ SR_KUC = 0x02 # Kernel/User Mode Current (0=kernel)
23
+ SR_IEP = 0x04 # Interrupt Enable Previous
24
+ SR_KUP = 0x08 # Kernel/User Mode Previous
25
+ SR_IEO = 0x10 # Interrupt Enable Old
26
+ SR_KUO = 0x20 # Kernel/User Mode Old
27
+ SR_IM = 0xFF00 # Interrupt Mask (bits 8-15)
28
+ SR_ISC = 0x1_0000 # Isolate Cache
29
+ SR_SWC = 0x2_0000 # Swap Caches
30
+ SR_PZ = 0x4_0000 # Parity Zero
31
+ SR_CM = 0x8_0000 # Cache Miss
32
+ SR_PE = 0x10_0000 # Parity Error
33
+ SR_TS = 0x20_0000 # TLB Shutdown
34
+ SR_BEV = 0x40_0000 # Boot Exception Vectors
35
+ SR_RE = 0x200_0000 # Reverse Endianness
36
+ SR_CU0 = 0x1000_0000 # COP0 Enable
37
+ SR_CU1 = 0x2000_0000 # COP1 Enable (no FPU on PSX, but enable bit exists)
38
+ SR_CU2 = 0x4000_0000 # COP2 (GTE) Enable
39
+ SR_CU3 = 0x8000_0000 # COP3 Enable
40
+
41
+ # Exception codes (for CAUSE register bits 2-6)
42
+ EXC_INT = 0x00 # Interrupt
43
+ EXC_MOD = 0x01 # TLB modification
44
+ EXC_TLBL = 0x02 # TLB load
45
+ EXC_TLBS = 0x03 # TLB store
46
+ EXC_ADEL = 0x04 # Address error (load/fetch)
47
+ EXC_ADES = 0x05 # Address error (store)
48
+ EXC_IBE = 0x06 # Bus error (instruction fetch)
49
+ EXC_DBE = 0x07 # Bus error (data load/store)
50
+ EXC_SYS = 0x08 # Syscall
51
+ EXC_BP = 0x09 # Breakpoint
52
+ EXC_RI = 0x0A # Reserved instruction
53
+ EXC_CPU = 0x0B # Coprocessor unusable
54
+ EXC_OV = 0x0C # Arithmetic overflow
55
+
56
+ # Interrupt bits in CAUSE (bits 8-15, matches SR mask)
57
+ IRQ_SOFTWARE0 = 0x0100
58
+ IRQ_SOFTWARE1 = 0x0200
59
+ IRQ_HARDWARE = 0x0400 # External hardware interrupt (active when I_STAT & I_MASK != 0)
60
+
61
+ attr_reader :regs
62
+
63
+ def initialize
64
+ @regs = Array.new(32, 0)
65
+ @regs[PRID] = 0x0000_0002 # R3000A processor ID
66
+ end
67
+
68
+ def read(reg)
69
+ @regs[reg]
70
+ end
71
+
72
+ def write(reg, value)
73
+ case reg
74
+ when SR
75
+ # Most bits are writable
76
+ @regs[SR] = value & 0xF4C7_9C3F
77
+ when CAUSE
78
+ # Only software interrupt bits (8-9) are writable
79
+ @regs[CAUSE] = (value & 0x0300) | (@regs[CAUSE] & ~0x0300)
80
+ when PRID
81
+ # Read-only
82
+ else
83
+ @regs[reg] = value
84
+ end
85
+ end
86
+
87
+ def sr
88
+ @regs[SR]
89
+ end
90
+
91
+ def sr=(value)
92
+ write(SR, value)
93
+ end
94
+
95
+ def cause
96
+ @regs[CAUSE]
97
+ end
98
+
99
+ def epc
100
+ @regs[EPC]
101
+ end
102
+
103
+ def cache_isolated?
104
+ (@regs[SR] & SR_ISC) != 0
105
+ end
106
+
107
+ def bev?
108
+ (@regs[SR] & SR_BEV) != 0
109
+ end
110
+
111
+ def interrupts_enabled?
112
+ (@regs[SR] & SR_IEC) != 0
113
+ end
114
+
115
+ def interrupt_pending?
116
+ # Check if any unmasked interrupt is pending
117
+ return false unless interrupts_enabled?
118
+
119
+ mask = (@regs[SR] >> 8) & 0xFF
120
+ pending = (@regs[CAUSE] >> 8) & 0xFF
121
+ (mask & pending) != 0
122
+ end
123
+
124
+ def set_hardware_irq(active)
125
+ if active
126
+ @regs[CAUSE] |= IRQ_HARDWARE
127
+ else
128
+ @regs[CAUSE] &= ~IRQ_HARDWARE
129
+ end
130
+ end
131
+
132
+ # Enter exception handler. Returns the exception vector address.
133
+ # `coprocessor` is the COP number (0-3) reported in CAUSE.CE for
134
+ # Coprocessor-Unusable exceptions.
135
+ def enter_exception(code, pc, in_delay_slot: false, bad_addr: nil, coprocessor: nil)
136
+ @regs[EPC] = in_delay_slot ? pc - 4 : pc
137
+ @regs[BADVADDR] = bad_addr if bad_addr
138
+
139
+ # CAUSE: preserve interrupt-pending bits, set ExcCode, BD, and CE.
140
+ ce = coprocessor ? ((coprocessor & 0x3) << 28) : 0
141
+ @regs[CAUSE] = (@regs[CAUSE] & 0x0000_FF00) |
142
+ (code << 2) |
143
+ ce |
144
+ (in_delay_slot ? 0x8000_0000 : 0)
145
+
146
+ # Shift IE/KU stack: current -> previous -> old.
147
+ mode = @regs[SR] & 0x3F
148
+ @regs[SR] = (@regs[SR] & ~0x3F) | ((mode << 2) & 0x3F)
149
+
150
+ bev? ? 0xBFC0_0180 : 0x8000_0080
151
+ end
152
+
153
+ # Return from exception (RFE instruction)
154
+ def return_from_exception
155
+ # Shift interrupt enable/kernel mode bits back
156
+ # Old -> Previous -> Current
157
+ mode = @regs[SR] & 0x3F
158
+ @regs[SR] = (@regs[SR] & ~0x0F) | ((mode >> 2) & 0x0F)
159
+ end
160
+ end
161
+ end