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/gpu.rb ADDED
@@ -0,0 +1,1116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PSX
4
+ # Graphics Processing Unit
5
+ # Handles 2D/3D rendering to VRAM
6
+ class GPU
7
+ # VRAM dimensions
8
+ VRAM_WIDTH = 1024
9
+ VRAM_HEIGHT = 512
10
+
11
+ # Status register bits
12
+ STAT_TEXTURE_PAGE_X = 0x0000_000F # Texture page X base (N*64)
13
+ STAT_TEXTURE_PAGE_Y = 0x0000_0010 # Texture page Y base (N*256)
14
+ STAT_SEMI_TRANSPARENCY = 0x0000_0060 # Semi-transparency mode
15
+ STAT_TEXTURE_DEPTH = 0x0000_0180 # Texture color depth
16
+ STAT_DITHER = 0x0000_0200 # Dither enabled
17
+ STAT_DRAW_TO_DISPLAY = 0x0000_0400 # Drawing to display area allowed
18
+ STAT_SET_MASK_BIT = 0x0000_0800 # Set mask bit when drawing
19
+ STAT_DRAW_PIXELS = 0x0000_1000 # Draw pixels (0=always, 1=not to masked)
20
+ STAT_INTERLACE_FIELD = 0x0000_2000 # Interlace field
21
+ STAT_REVERSE_FLAG = 0x0000_4000 # Reverse flag
22
+ STAT_TEXTURE_DISABLE = 0x0000_8000 # Texture disable
23
+ STAT_HORIZONTAL_RES2 = 0x0001_0000 # Horizontal resolution 2
24
+ STAT_HORIZONTAL_RES1 = 0x0006_0000 # Horizontal resolution 1
25
+ STAT_VERTICAL_RES = 0x0008_0000 # Vertical resolution (0=240, 1=480)
26
+ STAT_VIDEO_MODE = 0x0010_0000 # Video mode (0=NTSC, 1=PAL)
27
+ STAT_COLOR_DEPTH = 0x0020_0000 # Display color depth (0=15bit, 1=24bit)
28
+ STAT_VERTICAL_INTERLACE = 0x0040_0000 # Vertical interlace
29
+ STAT_DISPLAY_ENABLE = 0x0080_0000 # Display enable (0=enabled, 1=disabled)
30
+ STAT_IRQ = 0x0100_0000 # IRQ flag
31
+ STAT_DMA_REQUEST = 0x0200_0000 # DMA request
32
+ STAT_CMD_READY = 0x0400_0000 # Ready for command
33
+ STAT_VRAM_TO_CPU_READY = 0x0800_0000 # Ready for VRAM to CPU transfer
34
+ STAT_DMA_READY = 0x1000_0000 # Ready for DMA
35
+ STAT_DMA_DIRECTION = 0x6000_0000 # DMA direction
36
+ STAT_DRAWING_ODD = 0x8000_0000 # Drawing odd lines (interlace)
37
+
38
+ # GP0 command types
39
+ CMD_NOP = 0x00
40
+ CMD_CLEAR_CACHE = 0x01
41
+ CMD_FILL_RECT = 0x02
42
+ CMD_POLY_BASE = 0x20 # 0x20-0x3F polygons
43
+ CMD_LINE_BASE = 0x40 # 0x40-0x5F lines
44
+ CMD_RECT_BASE = 0x60 # 0x60-0x7F rectangles
45
+ CMD_COPY_VRAM_VRAM = 0x80
46
+ CMD_COPY_CPU_VRAM = 0xA0
47
+ CMD_COPY_VRAM_CPU = 0xC0
48
+ CMD_ENV_BASE = 0xE0 # 0xE0-0xEF environment commands
49
+
50
+ attr_reader :vram
51
+
52
+ def initialize(interrupts: nil)
53
+ @interrupts = interrupts
54
+
55
+ # VRAM: 1024x512 16-bit pixels
56
+ @vram = Array.new(VRAM_WIDTH * VRAM_HEIGHT, 0)
57
+
58
+ # Status register
59
+ @status = STAT_CMD_READY | STAT_DMA_READY | STAT_VRAM_TO_CPU_READY
60
+
61
+ # Display settings
62
+ @display_enabled = false
63
+ @display_start_x = 0
64
+ @display_start_y = 0
65
+ @display_h_start = 0x200
66
+ @display_h_end = 0xC00
67
+ @display_v_start = 0x10
68
+ @display_v_end = 0x100
69
+ @video_mode = :ntsc
70
+ @horizontal_res = 320
71
+ @vertical_res = 240
72
+ @color_depth_24 = false
73
+ @interlaced = false
74
+
75
+ # Drawing area
76
+ @draw_area_left = 0
77
+ @draw_area_top = 0
78
+ @draw_area_right = 0
79
+ @draw_area_bottom = 0
80
+ @draw_offset_x = 0
81
+ @draw_offset_y = 0
82
+
83
+ # Texture settings
84
+ @texture_page_x = 0
85
+ @texture_page_y = 0
86
+ @texture_depth = 0 # 0=4bit, 1=8bit, 2=15bit
87
+ @semi_transparency = 0
88
+ @texture_window_mask_x = 0
89
+ @texture_window_mask_y = 0
90
+ @texture_window_offset_x = 0
91
+ @texture_window_offset_y = 0
92
+ @texture_disable_allow = false
93
+
94
+ # Mask settings
95
+ @set_mask_bit = false
96
+ @check_mask_bit = false
97
+
98
+ # Command buffer for multi-word commands
99
+ @cmd_buffer = []
100
+ @cmd_remaining = 0
101
+ @current_cmd = 0
102
+
103
+ # VRAM transfer state
104
+ @vram_transfer_x = 0
105
+ @vram_transfer_y = 0
106
+ @vram_transfer_start_x = 0 # Starting X for line wrap detection
107
+ @vram_transfer_width = 0
108
+ @vram_transfer_height = 0
109
+ @vram_transfer_count = 0
110
+ @vram_transfer_mode = nil # :cpu_to_vram or :vram_to_cpu
111
+ @vram_read_buffer = []
112
+
113
+ # Framebuffer caching
114
+ @framebuffer_dirty = true
115
+ @framebuffer_cache = nil
116
+
117
+ # DMA direction
118
+ @dma_direction = 0
119
+
120
+ # Interlace field (toggled by VBlank)
121
+ @odd_field = false
122
+ end
123
+
124
+ # Called by emulator on VBlank to toggle interlace field
125
+ def vblank
126
+ @odd_field = !@odd_field
127
+ end
128
+
129
+ def status
130
+ stat = @status
131
+
132
+ # Update dynamic bits
133
+ stat &= ~STAT_DISPLAY_ENABLE
134
+ stat |= STAT_DISPLAY_ENABLE unless @display_enabled
135
+
136
+ stat &= ~STAT_DMA_DIRECTION
137
+ stat |= (@dma_direction << 29) & STAT_DMA_DIRECTION
138
+
139
+ # Bit 31: Drawing odd lines in interlace mode
140
+ stat &= ~STAT_DRAWING_ODD
141
+ stat |= STAT_DRAWING_ODD if @odd_field
142
+
143
+ stat
144
+ end
145
+
146
+ def read_data
147
+ if @vram_transfer_mode == :vram_to_cpu && !@vram_read_buffer.empty?
148
+ @vram_read_buffer.shift
149
+ else
150
+ 0
151
+ end
152
+ end
153
+
154
+ # GP0 - Rendering commands and VRAM access
155
+ def gp0(value)
156
+ if @vram_transfer_mode == :cpu_to_vram
157
+ vram_write_data(value)
158
+ return
159
+ end
160
+
161
+ if @cmd_remaining > 0
162
+ @cmd_buffer << value
163
+ @cmd_remaining -= 1
164
+
165
+ if @cmd_remaining == 0
166
+ execute_gp0_command
167
+ end
168
+ return
169
+ end
170
+
171
+ cmd = (value >> 24) & 0xFF
172
+ @current_cmd = cmd
173
+ @cmd_buffer = [value]
174
+
175
+ case cmd
176
+ when CMD_NOP
177
+ # Do nothing
178
+ when CMD_CLEAR_CACHE
179
+ # Clear texture cache - ignore for now
180
+ when CMD_FILL_RECT
181
+ @cmd_remaining = 2
182
+ when 0x20..0x3F
183
+ # Polygons
184
+ @cmd_remaining = polygon_word_count(cmd) - 1
185
+ when 0x40..0x5F
186
+ # Lines
187
+ @cmd_remaining = line_word_count(cmd) - 1
188
+ when 0x60..0x7F
189
+ # Rectangles
190
+ @cmd_remaining = rectangle_word_count(cmd) - 1
191
+ when CMD_COPY_VRAM_VRAM
192
+ @cmd_remaining = 3
193
+ when CMD_COPY_CPU_VRAM
194
+ @cmd_remaining = 2
195
+ when CMD_COPY_VRAM_CPU
196
+ @cmd_remaining = 2
197
+ when 0xE1
198
+ gp0_draw_mode(value)
199
+ when 0xE2
200
+ gp0_texture_window(value)
201
+ when 0xE3
202
+ gp0_draw_area_top_left(value)
203
+ when 0xE4
204
+ gp0_draw_area_bottom_right(value)
205
+ when 0xE5
206
+ gp0_draw_offset(value)
207
+ when 0xE6
208
+ gp0_mask_settings(value)
209
+ else
210
+ # Unknown command - ignore
211
+ end
212
+
213
+ execute_gp0_command if @cmd_remaining == 0 && @cmd_buffer.length > 0
214
+ end
215
+
216
+ # GP1 - Display control
217
+ def gp1(value)
218
+ cmd = (value >> 24) & 0xFF
219
+
220
+ case cmd
221
+ when 0x00
222
+ gp1_reset
223
+ when 0x01
224
+ gp1_reset_command_buffer
225
+ when 0x02
226
+ gp1_acknowledge_irq
227
+ when 0x03
228
+ gp1_display_enable(value)
229
+ when 0x04
230
+ gp1_dma_direction(value)
231
+ when 0x05
232
+ gp1_display_start(value)
233
+ when 0x06
234
+ gp1_horizontal_range(value)
235
+ when 0x07
236
+ gp1_vertical_range(value)
237
+ when 0x08
238
+ gp1_display_mode(value)
239
+ when 0x09
240
+ gp1_texture_disable(value)
241
+ when 0x10..0x1F
242
+ gp1_gpu_info(value)
243
+ end
244
+ end
245
+
246
+ def gp1_texture_disable(value)
247
+ @texture_disable_allow = (value & 1) != 0
248
+ end
249
+
250
+ # Get current framebuffer as RGBA packed binary string for display
251
+ # Returns hash with :width, :height, :rgba (binary string)
252
+ # Uses caching to avoid regenerating unchanged frames
253
+ def framebuffer
254
+ # Return cached framebuffer if VRAM hasn't changed
255
+ if !@framebuffer_dirty && @framebuffer_cache &&
256
+ @framebuffer_cache[:width] == @horizontal_res &&
257
+ @framebuffer_cache[:height] == @vertical_res
258
+ return @framebuffer_cache
259
+ end
260
+
261
+ width = @horizontal_res
262
+ height = @vertical_res
263
+ num_pixels = width * height
264
+
265
+ # Build RGBA array and pack once (faster than building string)
266
+ rgba_arr = Array.new(num_pixels * 4)
267
+ dst_i = 0
268
+
269
+ height.times do |y|
270
+ vram_row = (@display_start_y + y) * VRAM_WIDTH + @display_start_x
271
+ width.times do |x|
272
+ color16 = @vram[vram_row + x] || 0
273
+
274
+ # Convert 15-bit to 24-bit RGB
275
+ rgba_arr[dst_i] = (color16 & 0x001F) << 3 # R
276
+ rgba_arr[dst_i + 1] = (color16 & 0x03E0) >> 2 # G
277
+ rgba_arr[dst_i + 2] = (color16 & 0x7C00) >> 7 # B
278
+ rgba_arr[dst_i + 3] = 255 # A
279
+ dst_i += 4
280
+ end
281
+ end
282
+
283
+ @framebuffer_dirty = false
284
+ @framebuffer_cache = { width: width, height: height, rgba: rgba_arr.pack("C*") }
285
+ end
286
+
287
+ # Mark framebuffer as needing regeneration (call when VRAM is modified)
288
+ def mark_dirty
289
+ @framebuffer_dirty = true
290
+ end
291
+
292
+ private
293
+
294
+ # GP0 command execution
295
+ def execute_gp0_command
296
+ case @current_cmd
297
+ when CMD_FILL_RECT
298
+ mark_dirty
299
+ gp0_fill_rect
300
+ when 0x20..0x3F
301
+ mark_dirty
302
+ gp0_polygon
303
+ when 0x40..0x5F
304
+ mark_dirty
305
+ gp0_line
306
+ when 0x60..0x7F
307
+ mark_dirty
308
+ gp0_rectangle
309
+ when CMD_COPY_VRAM_VRAM
310
+ mark_dirty
311
+ gp0_copy_vram_vram
312
+ when CMD_COPY_CPU_VRAM
313
+ mark_dirty
314
+ gp0_copy_cpu_vram
315
+ when CMD_COPY_VRAM_CPU
316
+ gp0_copy_vram_cpu # Reading from VRAM doesn't modify it
317
+ end
318
+
319
+ @cmd_buffer = []
320
+ @current_cmd = 0
321
+ end
322
+
323
+ # Word counts for multi-word commands
324
+ def polygon_word_count(cmd)
325
+ # Bit 0: Gouraud (adds colors for each vertex)
326
+ # Bit 2: Textured (adds UV + clut for each vertex)
327
+ # Bit 3: Quad (4 vertices instead of 3)
328
+ gouraud = (cmd & 0x10) != 0
329
+ textured = (cmd & 0x04) != 0
330
+ quad = (cmd & 0x08) != 0
331
+
332
+ vertices = quad ? 4 : 3
333
+ words = 1 # Command + color
334
+
335
+ if textured
336
+ # Each vertex has position + texcoord
337
+ words += vertices * 2
338
+ words += 1 if gouraud # Extra colors if gouraud
339
+ words += (vertices - 1) if gouraud # Colors for other vertices
340
+ else
341
+ words += vertices # Just positions
342
+ words += (vertices - 1) if gouraud # Colors for other vertices
343
+ end
344
+
345
+ words
346
+ end
347
+
348
+ def line_word_count(cmd)
349
+ # Bit 3: Polyline
350
+ # Bit 4: Gouraud
351
+ gouraud = (cmd & 0x10) != 0
352
+ polyline = (cmd & 0x08) != 0
353
+
354
+ if polyline
355
+ # Polyline - variable length, terminated by 0x5555_5555 or 0x5000_5000
356
+ # For now, just handle 2-point lines
357
+ gouraud ? 4 : 3
358
+ else
359
+ gouraud ? 4 : 3
360
+ end
361
+ end
362
+
363
+ def rectangle_word_count(cmd)
364
+ # Bits 3-4: Size (0=variable, 1=1x1, 2=8x8, 3=16x16)
365
+ # Bit 2: Textured
366
+ size = (cmd >> 3) & 0x3
367
+ textured = (cmd & 0x04) != 0
368
+
369
+ words = 2 # Command+color, position
370
+ words += 1 if textured # Texcoord
371
+ words += 1 if size == 0 # Variable size needs dimensions
372
+
373
+ words
374
+ end
375
+
376
+ # GP0 Drawing commands
377
+ def gp0_fill_rect
378
+ color = @cmd_buffer[0] & 0x00FF_FFFF
379
+ pos = @cmd_buffer[1]
380
+ size = @cmd_buffer[2]
381
+
382
+ x = pos & 0x3F0 # Aligned to 16 pixels
383
+ y = (pos >> 16) & 0x1FF
384
+ w = ((size & 0x3FF) + 0xF) & ~0xF # Round up to 16
385
+ h = (size >> 16) & 0x1FF
386
+
387
+ r = color & 0xFF
388
+ g = (color >> 8) & 0xFF
389
+ b = (color >> 16) & 0xFF
390
+ pixel = rgb_to_vram(r, g, b)
391
+
392
+ h.times do |dy|
393
+ w.times do |dx|
394
+ vx = (x + dx) % VRAM_WIDTH
395
+ vy = (y + dy) % VRAM_HEIGHT
396
+ @vram[vy * VRAM_WIDTH + vx] = pixel
397
+ end
398
+ end
399
+ end
400
+
401
+ def gp0_polygon
402
+ cmd = @current_cmd
403
+ gouraud = (cmd & 0x10) != 0
404
+ textured = (cmd & 0x04) != 0
405
+ quad = (cmd & 0x08) != 0
406
+ semi_transparent = (cmd & 0x02) != 0
407
+ raw_texture = (cmd & 0x01) != 0
408
+
409
+ vertices = quad ? 4 : 3
410
+
411
+ # Parse vertices
412
+ points = []
413
+ colors = []
414
+ texcoords = []
415
+
416
+ idx = 0
417
+ vertices.times do |v|
418
+ # First word always has color (or just first vertex if not gouraud)
419
+ if v == 0 || gouraud
420
+ c = @cmd_buffer[idx]
421
+ colors << { r: c & 0xFF, g: (c >> 8) & 0xFF, b: (c >> 16) & 0xFF }
422
+ idx += 1
423
+ else
424
+ colors << colors[0]
425
+ end
426
+
427
+ # Position
428
+ pos = @cmd_buffer[idx]
429
+ x = (pos & 0xFFFF)
430
+ x = x - 0x10000 if x >= 0x8000 # Sign extend
431
+ y = (pos >> 16) & 0xFFFF
432
+ y = y - 0x10000 if y >= 0x8000
433
+ points << { x: x + @draw_offset_x, y: y + @draw_offset_y }
434
+ idx += 1
435
+
436
+ # Texcoord
437
+ if textured
438
+ tc = @cmd_buffer[idx]
439
+ texcoords << { u: tc & 0xFF, v: (tc >> 8) & 0xFF, clut: (tc >> 16) & 0xFFFF }
440
+ idx += 1
441
+ end
442
+ end
443
+
444
+ # Draw triangles
445
+ if textured
446
+ # Get CLUT from first texcoord (bits 16-31)
447
+ clut = texcoords[0][:clut]
448
+
449
+ # Get texture page from second texcoord (bits 16-31)
450
+ # This overrides the global texture page settings for this primitive
451
+ tpage = texcoords[1][:clut] # Reusing :clut field, it's actually tpage for vertex 1
452
+ tex_page_x = (tpage & 0x0F) * 64
453
+ tex_page_y = ((tpage >> 4) & 0x01) * 256
454
+ tex_depth = (tpage >> 7) & 0x03
455
+
456
+ # A textured polygon's tpage parameter also rewrites GPUSTAT bits 0-8
457
+ # (and, when GP1 09 allowed it, bit 15 from tpage bit 11). Bits 9-10
458
+ # remain whatever GP0 E1 set them to (verified by ps1-tests gpu/gp0-e1).
459
+ @status = (@status & ~0x01FF) | (tpage & 0x01FF)
460
+ tex_disable = (@texture_disable_allow && (tpage & (1 << 11)) != 0) ? 0x8000 : 0
461
+ @status = (@status & ~0x8000) | tex_disable
462
+
463
+ if quad
464
+ draw_textured_triangle(points[0], points[1], points[2],
465
+ texcoords[0], texcoords[1], texcoords[2],
466
+ colors[0], clut, tex_page_x, tex_page_y, tex_depth, raw_texture)
467
+ draw_textured_triangle(points[1], points[2], points[3],
468
+ texcoords[1], texcoords[2], texcoords[3],
469
+ colors[1], clut, tex_page_x, tex_page_y, tex_depth, raw_texture)
470
+ else
471
+ draw_textured_triangle(points[0], points[1], points[2],
472
+ texcoords[0], texcoords[1], texcoords[2],
473
+ colors[0], clut, tex_page_x, tex_page_y, tex_depth, raw_texture)
474
+ end
475
+ else
476
+ if quad
477
+ draw_triangle(points[0], points[1], points[2], colors[0], colors[1], colors[2], gouraud)
478
+ draw_triangle(points[1], points[2], points[3], colors[1], colors[2], colors[3], gouraud)
479
+ else
480
+ draw_triangle(points[0], points[1], points[2], colors[0], colors[1], colors[2], gouraud)
481
+ end
482
+ end
483
+ end
484
+
485
+ def gp0_line
486
+ cmd = @current_cmd
487
+ gouraud = (cmd & 0x10) != 0
488
+
489
+ c0 = @cmd_buffer[0]
490
+ color0 = { r: c0 & 0xFF, g: (c0 >> 8) & 0xFF, b: (c0 >> 16) & 0xFF }
491
+
492
+ pos0 = @cmd_buffer[1]
493
+ x0 = (pos0 & 0xFFFF)
494
+ x0 = x0 - 0x10000 if x0 >= 0x8000
495
+ y0 = (pos0 >> 16) & 0xFFFF
496
+ y0 = y0 - 0x10000 if y0 >= 0x8000
497
+
498
+ if gouraud
499
+ c1 = @cmd_buffer[2]
500
+ color1 = { r: c1 & 0xFF, g: (c1 >> 8) & 0xFF, b: (c1 >> 16) & 0xFF }
501
+ pos1 = @cmd_buffer[3]
502
+ else
503
+ color1 = color0
504
+ pos1 = @cmd_buffer[2]
505
+ end
506
+
507
+ x1 = (pos1 & 0xFFFF)
508
+ x1 = x1 - 0x10000 if x1 >= 0x8000
509
+ y1 = (pos1 >> 16) & 0xFFFF
510
+ y1 = y1 - 0x10000 if y1 >= 0x8000
511
+
512
+ draw_line(
513
+ x0 + @draw_offset_x, y0 + @draw_offset_y,
514
+ x1 + @draw_offset_x, y1 + @draw_offset_y,
515
+ color0, color1, gouraud
516
+ )
517
+ end
518
+
519
+ def gp0_rectangle
520
+ cmd = @current_cmd
521
+ textured = (cmd & 0x04) != 0
522
+ raw_texture = (cmd & 0x01) != 0
523
+ size_mode = (cmd >> 3) & 0x3
524
+
525
+ c = @cmd_buffer[0]
526
+ color = { r: c & 0xFF, g: (c >> 8) & 0xFF, b: (c >> 16) & 0xFF }
527
+
528
+ pos = @cmd_buffer[1]
529
+ x = (pos & 0xFFFF)
530
+ x = x - 0x10000 if x >= 0x8000
531
+ y = (pos >> 16) & 0xFFFF
532
+ y = y - 0x10000 if y >= 0x8000
533
+ x += @draw_offset_x
534
+ y += @draw_offset_y
535
+
536
+ idx = 2
537
+ tex_u = 0
538
+ tex_v = 0
539
+ clut_x = 0
540
+ clut_y = 0
541
+
542
+ if textured
543
+ tc = @cmd_buffer[idx]
544
+ tex_u = tc & 0xFF
545
+ tex_v = (tc >> 8) & 0xFF
546
+ clut = (tc >> 16) & 0xFFFF
547
+ clut_x = (clut & 0x3F) * 16
548
+ clut_y = (clut >> 6) & 0x1FF
549
+ idx += 1
550
+ end
551
+
552
+ case size_mode
553
+ when 0 # Variable
554
+ dims = @cmd_buffer[idx]
555
+ w = dims & 0xFFFF
556
+ h = (dims >> 16) & 0xFFFF
557
+ when 1 # 1x1
558
+ w = 1
559
+ h = 1
560
+ when 2 # 8x8
561
+ w = 8
562
+ h = 8
563
+ when 3 # 16x16
564
+ w = 16
565
+ h = 16
566
+ end
567
+
568
+ if textured
569
+ draw_textured_rect(x, y, w, h, tex_u, tex_v, clut_x, clut_y, color, raw_texture)
570
+ else
571
+ draw_rect(x, y, w, h, color)
572
+ end
573
+ end
574
+
575
+ def gp0_copy_vram_vram
576
+ src = @cmd_buffer[1]
577
+ dst = @cmd_buffer[2]
578
+ size = @cmd_buffer[3]
579
+
580
+ src_x = src & 0x3FF
581
+ src_y = (src >> 16) & 0x1FF
582
+ dst_x = dst & 0x3FF
583
+ dst_y = (dst >> 16) & 0x1FF
584
+ w = (((size & 0x3FF) - 1) & 0x3FF) + 1
585
+ h = ((((size >> 16) & 0x1FF) - 1) & 0x1FF) + 1
586
+
587
+ h.times do |dy|
588
+ w.times do |dx|
589
+ sx = (src_x + dx) % VRAM_WIDTH
590
+ sy = (src_y + dy) % VRAM_HEIGHT
591
+ dx2 = (dst_x + dx) % VRAM_WIDTH
592
+ dy2 = (dst_y + dy) % VRAM_HEIGHT
593
+ @vram[dy2 * VRAM_WIDTH + dx2] = @vram[sy * VRAM_WIDTH + sx]
594
+ end
595
+ end
596
+ end
597
+
598
+ def gp0_copy_cpu_vram
599
+ pos = @cmd_buffer[1]
600
+ size = @cmd_buffer[2]
601
+
602
+ @vram_transfer_x = pos & 0x3FF
603
+ @vram_transfer_y = (pos >> 16) & 0x1FF
604
+ @vram_transfer_start_x = @vram_transfer_x # Remember starting X for line wrap
605
+ w = (((size & 0xFFFF) - 1) & 0x3FF) + 1
606
+ h = ((((size >> 16) & 0xFFFF) - 1) & 0x1FF) + 1
607
+ @vram_transfer_width = w
608
+ @vram_transfer_height = h
609
+ @vram_transfer_count = ((w * h + 1) & ~1) / 2 # Words to transfer
610
+ @vram_transfer_mode = :cpu_to_vram
611
+
612
+ @status &= ~STAT_CMD_READY
613
+ end
614
+
615
+ def gp0_copy_vram_cpu
616
+ pos = @cmd_buffer[1]
617
+ size = @cmd_buffer[2]
618
+
619
+ x = pos & 0x3FF
620
+ y = (pos >> 16) & 0x1FF
621
+ w = (((size & 0xFFFF) - 1) & 0x3FF) + 1
622
+ h = ((((size >> 16) & 0xFFFF) - 1) & 0x1FF) + 1
623
+
624
+ # Read VRAM into buffer
625
+ @vram_read_buffer = []
626
+ h.times do |dy|
627
+ row_start = ((y + dy) % VRAM_HEIGHT) * VRAM_WIDTH
628
+ x_pos = x
629
+ (w / 2.0).ceil.times do
630
+ p0 = @vram[row_start + (x_pos % VRAM_WIDTH)]
631
+ x_pos += 1
632
+ p1 = @vram[row_start + (x_pos % VRAM_WIDTH)]
633
+ x_pos += 1
634
+ @vram_read_buffer << ((p1 << 16) | p0)
635
+ end
636
+ end
637
+
638
+ @vram_transfer_mode = :vram_to_cpu
639
+ @status |= STAT_VRAM_TO_CPU_READY
640
+ end
641
+
642
+ def vram_write_data(value)
643
+ return if @vram_transfer_count <= 0
644
+
645
+ vram_write_pixel(value & 0xFFFF)
646
+ vram_write_pixel((value >> 16) & 0xFFFF)
647
+
648
+ @vram_transfer_count -= 1
649
+
650
+ if @vram_transfer_count <= 0
651
+ @vram_transfer_mode = nil
652
+ @status |= STAT_CMD_READY
653
+ end
654
+ end
655
+
656
+ def vram_write_pixel(pixel)
657
+ idx = @vram_transfer_y * VRAM_WIDTH + (@vram_transfer_x % VRAM_WIDTH)
658
+
659
+ # Mask bit settings apply to CPU-to-VRAM blits (verified by
660
+ # ps1-tests gpu/mask-bit).
661
+ unless @check_mask_bit && (@vram[idx] & 0x8000) != 0
662
+ @vram[idx] = pixel | (@set_mask_bit ? 0x8000 : 0)
663
+ end
664
+
665
+ @vram_transfer_x += 1
666
+ if @vram_transfer_x >= @vram_transfer_start_x + @vram_transfer_width
667
+ @vram_transfer_x = @vram_transfer_start_x
668
+ @vram_transfer_y = (@vram_transfer_y + 1) % VRAM_HEIGHT
669
+ end
670
+ end
671
+
672
+ # GP0 Environment commands
673
+ def gp0_draw_mode(value)
674
+ @texture_page_x = (value & 0x0F) * 64
675
+ @texture_page_y = ((value >> 4) & 0x01) * 256
676
+ @semi_transparency = (value >> 5) & 0x03
677
+ @texture_depth = (value >> 7) & 0x03
678
+
679
+ @status = (@status & ~0x07FF) | (value & 0x07FF)
680
+
681
+ # GPUSTAT bit 15 (Texture Disable) comes from E1 bit 11, but only when
682
+ # GP1 09 ("Allow Texture Disable") has been set. With allow=false an E1
683
+ # write force-clears bit 15 (verified by ps1-tests gpu/gp0-e1).
684
+ bit15 = (@texture_disable_allow && (value & (1 << 11)) != 0) ? 0x8000 : 0
685
+ @status = (@status & ~0x8000) | bit15
686
+ end
687
+
688
+ def gp0_texture_window(value)
689
+ # Store raw values (in 8-texel units), don't pre-multiply
690
+ @texture_window_mask_x = value & 0x1F
691
+ @texture_window_mask_y = (value >> 5) & 0x1F
692
+ @texture_window_offset_x = (value >> 10) & 0x1F
693
+ @texture_window_offset_y = (value >> 15) & 0x1F
694
+ end
695
+
696
+ def gp0_draw_area_top_left(value)
697
+ @draw_area_left = value & 0x3FF
698
+ @draw_area_top = (value >> 10) & 0x1FF
699
+ end
700
+
701
+ def gp0_draw_area_bottom_right(value)
702
+ @draw_area_right = value & 0x3FF
703
+ @draw_area_bottom = (value >> 10) & 0x1FF
704
+ end
705
+
706
+ def gp0_draw_offset(value)
707
+ x = value & 0x7FF
708
+ y = (value >> 11) & 0x7FF
709
+ @draw_offset_x = x >= 0x400 ? x - 0x800 : x
710
+ @draw_offset_y = y >= 0x400 ? y - 0x800 : y
711
+ end
712
+
713
+ def gp0_mask_settings(value)
714
+ @set_mask_bit = (value & 0x01) != 0
715
+ @check_mask_bit = (value & 0x02) != 0
716
+
717
+ @status = (@status & ~0x1800) | ((value & 0x03) << 11)
718
+ end
719
+
720
+ # GP1 commands
721
+ def gp1_reset
722
+ @status = STAT_CMD_READY | STAT_DMA_READY | STAT_VRAM_TO_CPU_READY | STAT_DISPLAY_ENABLE
723
+ @display_enabled = false
724
+ @dma_direction = 0
725
+ @display_start_x = 0
726
+ @display_start_y = 0
727
+ @display_h_start = 0x200
728
+ @display_h_end = 0xC00
729
+ @display_v_start = 0x10
730
+ @display_v_end = 0x100
731
+ @draw_area_left = 0
732
+ @draw_area_top = 0
733
+ @draw_area_right = 0
734
+ @draw_area_bottom = 0
735
+ @draw_offset_x = 0
736
+ @draw_offset_y = 0
737
+ @cmd_buffer = []
738
+ @cmd_remaining = 0
739
+ @vram_transfer_mode = nil
740
+ end
741
+
742
+ def gp1_reset_command_buffer
743
+ @cmd_buffer = []
744
+ @cmd_remaining = 0
745
+ @vram_transfer_mode = nil
746
+ @status |= STAT_CMD_READY
747
+ end
748
+
749
+ def gp1_acknowledge_irq
750
+ @status &= ~STAT_IRQ
751
+ end
752
+
753
+ def gp1_display_enable(value)
754
+ @display_enabled = (value & 0x01) == 0
755
+ end
756
+
757
+ def gp1_dma_direction(value)
758
+ @dma_direction = value & 0x03
759
+ end
760
+
761
+ def gp1_display_start(value)
762
+ @display_start_x = value & 0x3FE # 10 bits, even
763
+ @display_start_y = (value >> 10) & 0x1FF
764
+ end
765
+
766
+ def gp1_horizontal_range(value)
767
+ @display_h_start = value & 0xFFF
768
+ @display_h_end = (value >> 12) & 0xFFF
769
+ end
770
+
771
+ def gp1_vertical_range(value)
772
+ @display_v_start = value & 0x3FF
773
+ @display_v_end = (value >> 10) & 0x3FF
774
+ end
775
+
776
+ def gp1_display_mode(value)
777
+ hr1 = value & 0x03
778
+ @vertical_res = (value & 0x04) != 0 ? 480 : 240
779
+ @video_mode = (value & 0x08) != 0 ? :pal : :ntsc
780
+ @color_depth_24 = (value & 0x10) != 0
781
+ @interlaced = (value & 0x20) != 0
782
+ hr2 = (value & 0x40) != 0 ? 1 : 0
783
+
784
+ @horizontal_res = case hr1
785
+ when 0 then hr2 == 1 ? 368 : 256
786
+ when 1 then 320
787
+ when 2 then 512
788
+ when 3 then 640
789
+ else 320
790
+ end
791
+
792
+ # Update status
793
+ @status = (@status & ~0x007F_0000) |
794
+ ((hr1 << 17) & STAT_HORIZONTAL_RES1) |
795
+ ((hr2 << 16) & STAT_HORIZONTAL_RES2) |
796
+ ((@vertical_res == 480 ? 1 : 0) << 19) |
797
+ ((@video_mode == :pal ? 1 : 0) << 20) |
798
+ ((@color_depth_24 ? 1 : 0) << 21) |
799
+ ((@interlaced ? 1 : 0) << 22)
800
+ end
801
+
802
+ def gp1_gpu_info(value)
803
+ # GPU info - returns requested information
804
+ # For now just acknowledge
805
+ end
806
+
807
+ # Software rendering
808
+ def draw_pixel(x, y, r, g, b)
809
+ return if x < @draw_area_left || x > @draw_area_right
810
+ return if y < @draw_area_top || y > @draw_area_bottom
811
+ return if x < 0 || x >= VRAM_WIDTH || y < 0 || y >= VRAM_HEIGHT
812
+
813
+ idx = y * VRAM_WIDTH + x
814
+
815
+ if @check_mask_bit && (@vram[idx] & 0x8000) != 0
816
+ return # Masked pixel
817
+ end
818
+
819
+ pixel = rgb_to_vram(r, g, b)
820
+ pixel |= 0x8000 if @set_mask_bit
821
+
822
+ @vram[idx] = pixel
823
+ end
824
+
825
+ def rgb_to_vram(r, g, b)
826
+ ((r >> 3) & 0x1F) |
827
+ (((g >> 3) & 0x1F) << 5) |
828
+ (((b >> 3) & 0x1F) << 10)
829
+ end
830
+
831
+ def draw_rect(x, y, w, h, color)
832
+ h.times do |dy|
833
+ w.times do |dx|
834
+ draw_pixel(x + dx, y + dy, color[:r], color[:g], color[:b])
835
+ end
836
+ end
837
+ end
838
+
839
+ def draw_textured_rect(x, y, w, h, tex_u, tex_v, clut_x, clut_y, base_color, raw_texture)
840
+ h.times do |dy|
841
+ w.times do |dx|
842
+ # Calculate texture coordinates
843
+ u = (tex_u + dx) & 0xFF
844
+ v = (tex_v + dy) & 0xFF
845
+
846
+ # Sample texture
847
+ texel = sample_texture(u, v, clut_x, clut_y, @texture_page_x, @texture_page_y, @texture_depth)
848
+
849
+ # Skip transparent texels
850
+ next if texel_transparent?(texel)
851
+
852
+ # Convert to RGB
853
+ tex_color = vram_to_rgb(texel)
854
+
855
+ if raw_texture
856
+ # Raw texture - use texture color directly
857
+ draw_pixel(x + dx, y + dy, tex_color[:r], tex_color[:g], tex_color[:b])
858
+ else
859
+ # Modulate with base color
860
+ r = ((tex_color[:r] * base_color[:r]) >> 7).clamp(0, 255)
861
+ g = ((tex_color[:g] * base_color[:g]) >> 7).clamp(0, 255)
862
+ b = ((tex_color[:b] * base_color[:b]) >> 7).clamp(0, 255)
863
+ draw_pixel(x + dx, y + dy, r, g, b)
864
+ end
865
+ end
866
+ end
867
+ end
868
+
869
+ def draw_line(x0, y0, x1, y1, c0, c1, gouraud)
870
+ # Bresenham's line algorithm with optional color interpolation
871
+ dx = (x1 - x0).abs
872
+ dy = -(y1 - y0).abs
873
+ sx = x0 < x1 ? 1 : -1
874
+ sy = y0 < y1 ? 1 : -1
875
+ err = dx + dy
876
+
877
+ steps = [dx.abs, dy.abs].max
878
+ steps = 1 if steps == 0
879
+
880
+ loop do
881
+ t = steps > 0 ? (gouraud ? ((x0 - x0).abs + (y0 - y0).abs).to_f / steps : 0) : 0
882
+ if gouraud && steps > 0
883
+ progress = ((x0 - x0).abs + (y0 - y0).abs).to_f / steps
884
+ r = (c0[:r] + (c1[:r] - c0[:r]) * progress).to_i
885
+ g = (c0[:g] + (c1[:g] - c0[:g]) * progress).to_i
886
+ b = (c0[:b] + (c1[:b] - c0[:b]) * progress).to_i
887
+ else
888
+ r, g, b = c0[:r], c0[:g], c0[:b]
889
+ end
890
+
891
+ draw_pixel(x0, y0, r, g, b)
892
+
893
+ break if x0 == x1 && y0 == y1
894
+
895
+ e2 = 2 * err
896
+ if e2 >= dy
897
+ err += dy
898
+ x0 += sx
899
+ end
900
+ if e2 <= dx
901
+ err += dx
902
+ y0 += sy
903
+ end
904
+ end
905
+ end
906
+
907
+ def draw_triangle(p0, p1, p2, c0, c1, c2, gouraud)
908
+ # Simple scanline triangle rasterization
909
+ # Sort vertices by Y
910
+ verts = [[p0, c0], [p1, c1], [p2, c2]].sort_by { |v, _| v[:y] }
911
+ v0, col0 = verts[0]
912
+ v1, col1 = verts[1]
913
+ v2, col2 = verts[2]
914
+
915
+ return if v2[:y] == v0[:y] # Degenerate triangle
916
+
917
+ # Flat color if not gouraud
918
+ unless gouraud
919
+ col1 = col0
920
+ col2 = col0
921
+ end
922
+
923
+ # Rasterize
924
+ (v0[:y].to_i..v2[:y].to_i).each do |y|
925
+ next if y < @draw_area_top || y > @draw_area_bottom
926
+
927
+ # Find X bounds for this scanline
928
+ if y < v1[:y]
929
+ # Upper part of triangle
930
+ next if v1[:y] == v0[:y]
931
+ t1 = (y - v0[:y]).to_f / (v1[:y] - v0[:y])
932
+ x1 = v0[:x] + (v1[:x] - v0[:x]) * t1
933
+ c_left = interp_color(col0, col1, t1)
934
+ else
935
+ # Lower part of triangle
936
+ next if v2[:y] == v1[:y]
937
+ t1 = (y - v1[:y]).to_f / (v2[:y] - v1[:y])
938
+ x1 = v1[:x] + (v2[:x] - v1[:x]) * t1
939
+ c_left = interp_color(col1, col2, t1)
940
+ end
941
+
942
+ t2 = (y - v0[:y]).to_f / (v2[:y] - v0[:y])
943
+ x2 = v0[:x] + (v2[:x] - v0[:x]) * t2
944
+ c_right = interp_color(col0, col2, t2)
945
+
946
+ # Ensure x1 < x2
947
+ if x1 > x2
948
+ x1, x2 = x2, x1
949
+ c_left, c_right = c_right, c_left
950
+ end
951
+
952
+ # Draw scanline
953
+ x_start = [x1.to_i, @draw_area_left].max
954
+ x_end = [x2.to_i, @draw_area_right].min
955
+
956
+ (x_start..x_end).each do |x|
957
+ if gouraud && x2 != x1
958
+ t = (x - x1) / (x2 - x1)
959
+ color = interp_color(c_left, c_right, t)
960
+ else
961
+ color = c_left
962
+ end
963
+ draw_pixel(x, y, color[:r], color[:g], color[:b])
964
+ end
965
+ end
966
+ end
967
+
968
+ def interp_color(c0, c1, t)
969
+ {
970
+ r: (c0[:r] + (c1[:r] - c0[:r]) * t).to_i.clamp(0, 255),
971
+ g: (c0[:g] + (c1[:g] - c0[:g]) * t).to_i.clamp(0, 255),
972
+ b: (c0[:b] + (c1[:b] - c0[:b]) * t).to_i.clamp(0, 255)
973
+ }
974
+ end
975
+
976
+ # Sample a texel from VRAM
977
+ # Returns 15-bit color value or nil if transparent
978
+ def sample_texture(u, v, clut_x, clut_y, tex_page_x, tex_page_y, tex_depth)
979
+ # Apply texture window wrapping
980
+ # Formula: texcoord = (texcoord AND NOT(Mask*8)) OR ((Offset AND Mask)*8)
981
+ u = (u & ~(@texture_window_mask_x * 8)) | ((@texture_window_offset_x & @texture_window_mask_x) * 8)
982
+ v = (v & ~(@texture_window_mask_y * 8)) | ((@texture_window_offset_y & @texture_window_mask_y) * 8)
983
+
984
+ case tex_depth
985
+ when 0 # 4-bit CLUT
986
+ # Each 16-bit VRAM word holds 4 texels
987
+ texel_x = tex_page_x + (u / 4)
988
+ texel_y = tex_page_y + v
989
+ word = @vram[(texel_y % VRAM_HEIGHT) * VRAM_WIDTH + (texel_x % VRAM_WIDTH)] || 0
990
+
991
+ # Extract 4-bit index based on u position
992
+ shift = (u & 3) * 4
993
+ index = (word >> shift) & 0x0F
994
+
995
+ # Look up in CLUT
996
+ clut_addr = clut_y * VRAM_WIDTH + clut_x + index
997
+ @vram[clut_addr] || 0
998
+
999
+ when 1 # 8-bit CLUT
1000
+ # Each 16-bit VRAM word holds 2 texels
1001
+ texel_x = tex_page_x + (u / 2)
1002
+ texel_y = tex_page_y + v
1003
+ word = @vram[(texel_y % VRAM_HEIGHT) * VRAM_WIDTH + (texel_x % VRAM_WIDTH)] || 0
1004
+
1005
+ # Extract 8-bit index
1006
+ shift = (u & 1) * 8
1007
+ index = (word >> shift) & 0xFF
1008
+
1009
+ # Look up in CLUT
1010
+ clut_addr = clut_y * VRAM_WIDTH + clut_x + index
1011
+ @vram[clut_addr] || 0
1012
+
1013
+ when 2 # 15-bit direct
1014
+ texel_x = tex_page_x + u
1015
+ texel_y = tex_page_y + v
1016
+ @vram[(texel_y % VRAM_HEIGHT) * VRAM_WIDTH + (texel_x % VRAM_WIDTH)] || 0
1017
+
1018
+ else
1019
+ 0
1020
+ end
1021
+ end
1022
+
1023
+ # Convert 15-bit VRAM color to RGB hash
1024
+ def vram_to_rgb(color16)
1025
+ {
1026
+ r: (color16 & 0x001F) << 3,
1027
+ g: (color16 & 0x03E0) >> 2,
1028
+ b: (color16 & 0x7C00) >> 7
1029
+ }
1030
+ end
1031
+
1032
+ # Check if texel is transparent (bit 15 = 0 and color = 0)
1033
+ def texel_transparent?(color16)
1034
+ color16 == 0
1035
+ end
1036
+
1037
+ # Draw textured triangle with UV interpolation
1038
+ def draw_textured_triangle(p0, p1, p2, t0, t1, t2, base_color, clut, tex_page_x, tex_page_y, tex_depth, raw_texture)
1039
+ # Extract CLUT position
1040
+ clut_x = (clut & 0x3F) * 16
1041
+ clut_y = (clut >> 6) & 0x1FF
1042
+
1043
+ # Sort vertices by Y, keeping UVs in sync
1044
+ verts = [[p0, t0], [p1, t1], [p2, t2]].sort_by { |v, _| v[:y] }
1045
+ v0, uv0 = verts[0]
1046
+ v1, uv1 = verts[1]
1047
+ v2, uv2 = verts[2]
1048
+
1049
+ return if v2[:y] == v0[:y] # Degenerate triangle
1050
+
1051
+ # Rasterize with UV interpolation
1052
+ (v0[:y].to_i..v2[:y].to_i).each do |y|
1053
+ next if y < @draw_area_top || y > @draw_area_bottom
1054
+
1055
+ # Find X bounds and interpolate UVs for this scanline
1056
+ if y < v1[:y]
1057
+ next if v1[:y] == v0[:y]
1058
+ t1_interp = (y - v0[:y]).to_f / (v1[:y] - v0[:y])
1059
+ x1 = v0[:x] + (v1[:x] - v0[:x]) * t1_interp
1060
+ u1 = uv0[:u] + (uv1[:u] - uv0[:u]) * t1_interp
1061
+ v1_tex = uv0[:v] + (uv1[:v] - uv0[:v]) * t1_interp
1062
+ else
1063
+ next if v2[:y] == v1[:y]
1064
+ t1_interp = (y - v1[:y]).to_f / (v2[:y] - v1[:y])
1065
+ x1 = v1[:x] + (v2[:x] - v1[:x]) * t1_interp
1066
+ u1 = uv1[:u] + (uv2[:u] - uv1[:u]) * t1_interp
1067
+ v1_tex = uv1[:v] + (uv2[:v] - uv1[:v]) * t1_interp
1068
+ end
1069
+
1070
+ t2_interp = (y - v0[:y]).to_f / (v2[:y] - v0[:y])
1071
+ x2 = v0[:x] + (v2[:x] - v0[:x]) * t2_interp
1072
+ u2 = uv0[:u] + (uv2[:u] - uv0[:u]) * t2_interp
1073
+ v2_tex = uv0[:v] + (uv2[:v] - uv0[:v]) * t2_interp
1074
+
1075
+ # Ensure x1 < x2
1076
+ if x1 > x2
1077
+ x1, x2 = x2, x1
1078
+ u1, u2 = u2, u1
1079
+ v1_tex, v2_tex = v2_tex, v1_tex
1080
+ end
1081
+
1082
+ # Draw scanline with texture sampling
1083
+ x_start = [x1.to_i, @draw_area_left].max
1084
+ x_end = [x2.to_i, @draw_area_right].min
1085
+ x_span = x2 - x1
1086
+
1087
+ (x_start..x_end).each do |x|
1088
+ # Interpolate UV
1089
+ t = x_span > 0 ? (x - x1) / x_span : 0
1090
+ u = (u1 + (u2 - u1) * t).to_i & 0xFF
1091
+ v_coord = (v1_tex + (v2_tex - v1_tex) * t).to_i & 0xFF
1092
+
1093
+ # Sample texture
1094
+ texel = sample_texture(u, v_coord, clut_x, clut_y, tex_page_x, tex_page_y, tex_depth)
1095
+
1096
+ # Skip transparent texels
1097
+ next if texel_transparent?(texel)
1098
+
1099
+ # Convert to RGB
1100
+ tex_color = vram_to_rgb(texel)
1101
+
1102
+ if raw_texture
1103
+ # Raw texture - use texture color directly
1104
+ draw_pixel(x, y, tex_color[:r], tex_color[:g], tex_color[:b])
1105
+ else
1106
+ # Modulate with base color
1107
+ r = ((tex_color[:r] * base_color[:r]) >> 7).clamp(0, 255)
1108
+ g = ((tex_color[:g] * base_color[:g]) >> 7).clamp(0, 255)
1109
+ b = ((tex_color[:b] * base_color[:b]) >> 7).clamp(0, 255)
1110
+ draw_pixel(x, y, r, g, b)
1111
+ end
1112
+ end
1113
+ end
1114
+ end
1115
+ end
1116
+ end