rubyboy 1.3.2 → 1.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/docs/favicon.png +0 -0
- data/docs/index.html +75 -0
- data/docs/index.js +113 -0
- data/docs/logo-light-23.svg +1 -0
- data/docs/ogp.png +0 -0
- data/docs/styles.css +245 -0
- data/docs/worker.js +142 -0
- data/exe/rubyboy-wasm +26 -0
- data/lib/executor.rb +36 -0
- data/lib/rubyboy/apu_wasm.rb +118 -0
- data/lib/rubyboy/emulator_wasm.rb +43 -0
- data/lib/rubyboy/ppu_wasm.rb +312 -0
- data/lib/rubyboy/version.rb +1 -1
- data/resource/logo/rubyboy.png +0 -0
- metadata +16 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 12916ff3bf0b3e855d767980218887793bfb1206675487d9aeaac191897e8c73
|
4
|
+
data.tar.gz: f8fb9559338f9d0804f05906af939dcf2f735df729a30da6becf5db710934ba4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 84b0aa4f4731980e90f176a9c03a1aef3b84cdd2ee3ac9f5ead0b0ae286405b582ed5e78fc86169208ba3d6ec620b70d1d68703a77251ae6579675eecdbd6d42
|
7
|
+
data.tar.gz: 1ecb47bb5111749e58c24e289275fb4f587f994aec722ed933f555b01d84ee198c0a7c0a65b18fb94e8ef434ea3390bfb5c7382c0e5383fd9496d4ad8f5bb070
|
data/CHANGELOG.md
CHANGED
data/docs/favicon.png
ADDED
Binary file
|
data/docs/index.html
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<title>RUBY BOY</title>
|
5
|
+
<meta charset="utf-8"/>
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
7
|
+
<meta name="author" content="sacckey" />
|
8
|
+
<meta name="description" content="Game Boy emulator written in Ruby, running on ruby.wasm" />
|
9
|
+
<meta property="og:url" content="https://sacckey.github.io/rubyboy/" />
|
10
|
+
<meta property="og:title" content="RUBY BOY" />
|
11
|
+
<meta property="og:description" content="Game Boy emulator written in Ruby, running on ruby.wasm" />
|
12
|
+
<meta property="og:image" content="https://sacckey.github.io/rubyboy/ogp.png" />
|
13
|
+
<meta name="twitter:card" content="summary_large_image" />
|
14
|
+
<meta name="twitter:site" content="@sacckey" />
|
15
|
+
<meta name="twitter:creator" content="@sacckey" />
|
16
|
+
<link rel="icon" href="./favicon.png">
|
17
|
+
<link rel="stylesheet" href="./styles.css">
|
18
|
+
<script src="./index.js" defer></script>
|
19
|
+
</head>
|
20
|
+
<body>
|
21
|
+
<div class="main-content">
|
22
|
+
<div class="gameboy">
|
23
|
+
<div class="screen-container">
|
24
|
+
<canvas id="canvas" width="320" height="288"></canvas>
|
25
|
+
</div>
|
26
|
+
<div class="controls">
|
27
|
+
<div class="d-pad">
|
28
|
+
<button class="d-pad-button" id="d-pad-left" title="Left" data-code="KeyA"></button>
|
29
|
+
<button class="d-pad-button" id="d-pad-right" title="Right" data-code="KeyD"></button>
|
30
|
+
<button class="d-pad-button" id="d-pad-up" title="Up" data-code="KeyW"></button>
|
31
|
+
<button class="d-pad-button" id="d-pad-down" title="Down" data-code="KeyS"></button>
|
32
|
+
</div>
|
33
|
+
<div class="buttons">
|
34
|
+
<button class="action-button" id="button-a" title="A" data-code="KeyK"></button>
|
35
|
+
<button class="action-button" id="button-b" title="B" data-code="KeyJ"></button>
|
36
|
+
</div>
|
37
|
+
</div>
|
38
|
+
<div class="start-select">
|
39
|
+
<button class="start-select-button" title="Select" data-code="KeyU"></button>
|
40
|
+
<button class="start-select-button" title="Start" data-code="KeyI"></button>
|
41
|
+
</div>
|
42
|
+
<img src="./logo-light-23.svg" alt="Ruby Boy">
|
43
|
+
</div>
|
44
|
+
<div class="control-panel">
|
45
|
+
<section id="rom-select">
|
46
|
+
<h3>ROM Select</h3>
|
47
|
+
<select id="rom-select-box" class="rom-select-box" disabled >
|
48
|
+
<option value="tobu.gb">Tobu Tobu Girl</option>
|
49
|
+
<option value="bgbtest.gb">BGB Test</option>
|
50
|
+
</select>
|
51
|
+
<label id="rom-upload-button" class="rom-upload-button disabled">
|
52
|
+
<input type="file" id="rom-input" accept=".gb" disabled />
|
53
|
+
Upload
|
54
|
+
</label>
|
55
|
+
</section>
|
56
|
+
<section id="controls">
|
57
|
+
<h3>Controls</h3>
|
58
|
+
<div>
|
59
|
+
<kbd>w</kbd> <kbd>a</kbd> <kbd>s</kbd> <kbd>d</kbd>: ↑ ← ↓ →
|
60
|
+
</div>
|
61
|
+
<div>
|
62
|
+
<kbd>k</kbd> <kbd>j</kbd> <kbd>i</kbd> <kbd>u</kbd>: A B Start Select
|
63
|
+
</div>
|
64
|
+
</section>
|
65
|
+
<section id="performance">
|
66
|
+
<h3>Performance</h3>
|
67
|
+
<p>FPS: <span id="fps-display">0</span></p>
|
68
|
+
</section>
|
69
|
+
</div>
|
70
|
+
</div>
|
71
|
+
<footer class="footer">
|
72
|
+
<p>© 2024 sacckey | <a href="https://github.com/sacckey/rubyboy" target="_blank" rel="noopener noreferrer">GitHub</a></p>
|
73
|
+
</footer>
|
74
|
+
</body>
|
75
|
+
</html>
|
data/docs/index.js
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
const worker = new Worker('worker.js', { type: 'module' });
|
2
|
+
|
3
|
+
const SCALE = 2;
|
4
|
+
const canvas = document.getElementById('canvas');
|
5
|
+
const canvasContext = canvas.getContext('2d');
|
6
|
+
canvasContext.scale(SCALE, SCALE);
|
7
|
+
const tmpCanvas = document.createElement('canvas');
|
8
|
+
const tmpCanvasContext = tmpCanvas.getContext('2d');
|
9
|
+
tmpCanvas.width = canvas.width;
|
10
|
+
tmpCanvas.height = canvas.height;
|
11
|
+
|
12
|
+
// Display "LOADING..."
|
13
|
+
(() => {
|
14
|
+
const str = `
|
15
|
+
10000 01110 01110 11110 01110 10001 01110 00000 00000 00000
|
16
|
+
10000 10001 10001 10001 00100 11001 10000 00000 00000 00000
|
17
|
+
10000 10001 10001 10001 00100 10101 10011 00000 00000 00000
|
18
|
+
10000 10001 11111 10001 00100 10011 10001 01100 01100 01100
|
19
|
+
11111 01110 10001 11110 01110 10001 01110 01100 01100 01100
|
20
|
+
`;
|
21
|
+
const dotSize = 2;
|
22
|
+
const rows = str.trim().split('\n')
|
23
|
+
const xSpacing = canvas.width / (2 * SCALE) - rows[0].length * dotSize / 2;
|
24
|
+
const ySpacing = canvas.height / (2 * SCALE) - rows.length * dotSize / 2;
|
25
|
+
canvasContext.fillStyle = 'white';
|
26
|
+
|
27
|
+
rows.forEach((row, y) => {
|
28
|
+
[...row.trim()].forEach((char, x) => {
|
29
|
+
if (char === '1') {
|
30
|
+
canvasContext.fillRect(
|
31
|
+
x * dotSize + xSpacing,
|
32
|
+
y * dotSize + ySpacing,
|
33
|
+
dotSize,
|
34
|
+
dotSize
|
35
|
+
);
|
36
|
+
}
|
37
|
+
});
|
38
|
+
});
|
39
|
+
})();
|
40
|
+
|
41
|
+
document.addEventListener('keydown', (event) => {
|
42
|
+
worker.postMessage({ type: 'keydown', code: event.code });
|
43
|
+
});
|
44
|
+
document.addEventListener('keyup', (event) => {
|
45
|
+
worker.postMessage({ type: 'keyup', code: event.code });
|
46
|
+
});
|
47
|
+
|
48
|
+
const handleButtonPress = (event) => {
|
49
|
+
event.preventDefault();
|
50
|
+
worker.postMessage({ type: 'keydown', code: event.target.dataset.code });
|
51
|
+
}
|
52
|
+
const handleButtonRelease = (event) => {
|
53
|
+
event.preventDefault();
|
54
|
+
worker.postMessage({ type: 'keyup', code: event.target.dataset.code });
|
55
|
+
}
|
56
|
+
const buttons = document.querySelectorAll('.d-pad-button, .action-button, .start-select-button');
|
57
|
+
buttons.forEach(button => {
|
58
|
+
button.addEventListener('mousedown', handleButtonPress);
|
59
|
+
button.addEventListener('mouseup', handleButtonRelease);
|
60
|
+
button.addEventListener('touchstart', handleButtonPress);
|
61
|
+
button.addEventListener('touchend', handleButtonRelease);
|
62
|
+
});
|
63
|
+
|
64
|
+
const romInput = document.getElementById('rom-input');
|
65
|
+
romInput.addEventListener('change', (event) => {
|
66
|
+
const file = event.target.files[0];
|
67
|
+
if (file) {
|
68
|
+
const reader = new FileReader();
|
69
|
+
|
70
|
+
reader.onload = (e) => {
|
71
|
+
const romData = e.target.result;
|
72
|
+
worker.postMessage({ type: 'loadROM', data: romData }, [romData]);
|
73
|
+
};
|
74
|
+
|
75
|
+
reader.readAsArrayBuffer(file);
|
76
|
+
}
|
77
|
+
});
|
78
|
+
|
79
|
+
const romSelectBox = document.getElementById('rom-select-box');
|
80
|
+
romSelectBox.addEventListener('change', (event) => {
|
81
|
+
worker.postMessage({ type: 'loadPreInstalledRom', romName: event.target.value });
|
82
|
+
});
|
83
|
+
|
84
|
+
const times = [];
|
85
|
+
const fpsDisplay = document.getElementById('fps-display');
|
86
|
+
worker.onmessage = (event) => {
|
87
|
+
if (event.data.type === 'pixelData') {
|
88
|
+
const pixelData = new Uint8ClampedArray(event.data.data);
|
89
|
+
const imageData = new ImageData(pixelData, 160, 144);
|
90
|
+
tmpCanvasContext.putImageData(imageData, 0, 0);
|
91
|
+
canvasContext.drawImage(tmpCanvas, 0, 0);
|
92
|
+
|
93
|
+
const now = performance.now();
|
94
|
+
while (times.length > 0 && times[0] <= now - 1000) {
|
95
|
+
times.shift();
|
96
|
+
}
|
97
|
+
times.push(now);
|
98
|
+
fpsDisplay.innerText = times.length.toString();
|
99
|
+
}
|
100
|
+
|
101
|
+
if (event.data.type === 'initialized') {
|
102
|
+
romSelectBox.disabled = false;
|
103
|
+
romInput.disabled = false;
|
104
|
+
document.getElementById('rom-upload-button').classList.remove('disabled');
|
105
|
+
worker.postMessage({ type: 'startRubyboy' });
|
106
|
+
}
|
107
|
+
|
108
|
+
if (event.data.type === 'error') {
|
109
|
+
console.error('Error from Worker:', event.data.message);
|
110
|
+
}
|
111
|
+
};
|
112
|
+
|
113
|
+
worker.postMessage({ type: 'initRubyboy' });
|
@@ -0,0 +1 @@
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="18" viewBox="0 0 108 18"><path fill="#fff" d="M5.84 10.68L5.00 10.68L4.23 16.94L0.51 16.94L2.55 0.18L7.64 0.18Q9.18 0.18 10.26 0.51Q11.35 0.84 12.04 1.41Q12.73 1.99 13.05 2.78Q13.36 3.56 13.36 4.48L13.36 4.48Q13.36 5.42 13.12 6.24Q12.87 7.07 12.40 7.77Q11.93 8.46 11.24 9.00Q10.56 9.53 9.68 9.88L9.68 9.88Q10.03 10.07 10.33 10.35Q10.64 10.63 10.81 11.04L10.81 11.04L13.25 16.94L9.87 16.94Q9.40 16.94 9.07 16.76Q8.75 16.57 8.63 16.23L8.63 16.23L6.81 11.27Q6.68 10.95 6.47 10.82Q6.26 10.68 5.84 10.68L5.84 10.68ZM7.29 2.98L5.96 2.98L5.32 8.10L6.69 8.10Q7.50 8.10 8.06 7.85Q8.63 7.60 8.98 7.19Q9.33 6.77 9.48 6.22Q9.64 5.67 9.64 5.06L9.64 5.06Q9.64 4.59 9.50 4.20Q9.36 3.82 9.07 3.55Q8.77 3.28 8.33 3.13Q7.89 2.98 7.29 2.98L7.29 2.98ZM20.79 14.00L20.79 14.00Q21.49 14.00 22.07 13.73Q22.64 13.45 23.08 12.95Q23.52 12.45 23.80 11.74Q24.09 11.03 24.20 10.14L24.20 10.14L25.41 0.18L29.16 0.18L27.95 10.14Q27.75 11.67 27.13 12.95Q26.52 14.24 25.54 15.16Q24.55 16.09 23.25 16.61Q21.95 17.12 20.40 17.12L20.40 17.12Q19.00 17.12 17.90 16.70Q16.80 16.27 16.05 15.50Q15.29 14.72 14.90 13.64Q14.51 12.56 14.51 11.26L14.51 11.26Q14.51 10.72 14.58 10.14L14.58 10.14L15.78 0.18L19.53 0.18L18.31 10.14Q18.29 10.35 18.27 10.55Q18.26 10.75 18.26 10.95L18.26 10.95Q18.26 12.37 18.91 13.18Q19.56 14.00 20.79 14.00ZM35.53 16.94L29.28 16.94L31.35 0.18L37.10 0.18Q38.56 0.18 39.60 0.46Q40.64 0.74 41.30 1.25Q41.96 1.76 42.27 2.48Q42.57 3.20 42.57 4.07L42.57 4.07Q42.57 4.76 42.39 5.40Q42.22 6.04 41.83 6.58Q41.45 7.12 40.84 7.56Q40.23 8.00 39.36 8.30L39.36 8.30Q40.72 8.67 41.37 9.45Q42.02 10.22 42.02 11.41L42.02 11.41Q42.02 12.57 41.58 13.58Q41.14 14.58 40.30 15.33Q39.46 16.08 38.25 16.51Q37.05 16.94 35.53 16.94L35.53 16.94ZM36.32 9.79L33.90 9.79L33.37 14.12L35.83 14.12Q36.52 14.12 37.00 13.92Q37.47 13.72 37.76 13.36Q38.05 12.99 38.19 12.51Q38.32 12.03 38.32 11.45L38.32 11.45Q38.32 11.07 38.22 10.76Q38.12 10.45 37.89 10.24Q37.65 10.03 37.27 9.91Q36.88 9.79 36.32 9.79L36.32 9.79ZM34.73 2.98L34.20 7.30L35.99 7.30Q36.63 7.30 37.13 7.18Q37.64 7.05 38.00 6.74Q38.36 6.44 38.56 5.93Q38.75 5.43 38.75 4.68L38.75 4.68Q38.75 3.75 38.27 3.36Q37.78 2.98 36.75 2.98L36.75 2.98L34.73 2.98ZM58.72 0.18L51.85 10.52L51.06 16.94L47.31 16.94L48.10 10.56L43.79 0.18L47.13 0.18Q47.61 0.18 47.88 0.41Q48.15 0.63 48.28 1.00L48.28 1.00L49.89 6.07Q50.05 6.58 50.18 7.05Q50.31 7.52 50.40 7.96L50.40 7.96Q50.62 7.52 50.89 7.06Q51.15 6.59 51.45 6.07L51.45 6.07L54.30 1.00Q54.48 0.69 54.81 0.44Q55.14 0.18 55.61 0.18L55.61 0.18L58.72 0.18ZM68.31 16.94L62.05 16.94L64.12 0.18L69.87 0.18Q71.33 0.18 72.38 0.46Q73.42 0.74 74.08 1.25Q74.74 1.76 75.04 2.48Q75.35 3.20 75.35 4.07L75.35 4.07Q75.35 4.76 75.17 5.40Q74.99 6.04 74.61 6.58Q74.22 7.12 73.61 7.56Q73.00 8.00 72.14 8.30L72.14 8.30Q73.50 8.67 74.15 9.45Q74.80 10.22 74.80 11.41L74.80 11.41Q74.80 12.57 74.35 13.58Q73.91 14.58 73.07 15.33Q72.23 16.08 71.03 16.51Q69.83 16.94 68.31 16.94L68.31 16.94ZM69.09 9.79L66.68 9.79L66.15 14.12L68.61 14.12Q69.30 14.12 69.77 13.92Q70.24 13.72 70.54 13.36Q70.83 12.99 70.96 12.51Q71.09 12.03 71.09 11.45L71.09 11.45Q71.09 11.07 71.00 10.76Q70.90 10.45 70.66 10.24Q70.43 10.03 70.04 9.91Q69.66 9.79 69.09 9.79L69.09 9.79ZM67.50 2.98L66.98 7.30L68.77 7.30Q69.40 7.30 69.91 7.18Q70.41 7.05 70.78 6.74Q71.14 6.44 71.33 5.93Q71.53 5.43 71.53 4.68L71.53 4.68Q71.53 3.75 71.04 3.36Q70.55 2.98 69.53 2.98L69.53 2.98L67.50 2.98ZM92.32 7.49L92.32 7.49Q92.32 8.84 92.01 10.09Q91.69 11.33 91.11 12.40Q90.53 13.47 89.71 14.34Q88.88 15.21 87.86 15.83Q86.84 16.44 85.64 16.78Q84.44 17.12 83.12 17.12L83.12 17.12Q81.47 17.12 80.15 16.54Q78.83 15.96 77.91 14.96Q76.99 13.95 76.50 12.59Q76.02 11.22 76.02 9.65L76.02 9.65Q76.02 8.29 76.33 7.04Q76.65 5.80 77.23 4.73Q77.81 3.66 78.64 2.78Q79.47 1.91 80.49 1.29Q81.51 0.68 82.71 0.34Q83.90-0.00 85.24-0.00L85.24-0.00Q86.88-0.00 88.20 0.58Q89.52 1.16 90.43 2.17Q91.34 3.19 91.83 4.55Q92.32 5.92 92.32 7.49ZM88.48 7.58L88.48 7.58Q88.48 6.54 88.23 5.70Q87.99 4.85 87.51 4.25Q87.03 3.65 86.34 3.32Q85.65 2.99 84.78 2.99L84.78 2.99Q83.63 2.99 82.72 3.47Q81.81 3.94 81.17 4.81Q80.53 5.67 80.20 6.88Q79.86 8.10 79.86 9.57L79.86 9.57Q79.86 10.60 80.10 11.44Q80.34 12.28 80.80 12.88Q81.27 13.48 81.96 13.81Q82.65 14.13 83.54 14.13L83.54 14.13Q84.69 14.13 85.60 13.66Q86.51 13.19 87.15 12.33Q87.79 11.47 88.14 10.26Q88.48 9.05 88.48 7.58ZM107.41 0.18L100.54 10.52L99.75 16.94L96.00 16.94L96.80 10.56L92.48 0.18L95.82 0.18Q96.30 0.18 96.57 0.41Q96.84 0.63 96.97 1.00L96.97 1.00L98.58 6.07Q98.74 6.58 98.87 7.05Q99.00 7.52 99.10 7.96L99.10 7.96Q99.31 7.52 99.58 7.06Q99.84 6.59 100.14 6.07L100.14 6.07L102.99 1.00Q103.17 0.69 103.50 0.44Q103.83 0.18 104.30 0.18L104.30 0.18L107.41 0.18Z"></path></svg>
|
data/docs/ogp.png
ADDED
Binary file
|
data/docs/styles.css
ADDED
@@ -0,0 +1,245 @@
|
|
1
|
+
body {
|
2
|
+
background-color: #f0d0ce;
|
3
|
+
display: flex;
|
4
|
+
margin: 0;
|
5
|
+
font-family: 'Helvetica', 'Arial', sans-serif;
|
6
|
+
flex-direction: column;
|
7
|
+
min-height: 100vh;
|
8
|
+
overflow-x: hidden;
|
9
|
+
}
|
10
|
+
|
11
|
+
.main-content {
|
12
|
+
display: flex;
|
13
|
+
flex-direction: column;
|
14
|
+
align-items: center;
|
15
|
+
padding: 20px;
|
16
|
+
flex-grow: 1;
|
17
|
+
width: 100%;
|
18
|
+
box-sizing: border-box;
|
19
|
+
}
|
20
|
+
|
21
|
+
.gameboy {
|
22
|
+
background-color: #cc342d;
|
23
|
+
border-radius: 10px 10px 70px 10px;
|
24
|
+
padding: 25px 20px 40px;
|
25
|
+
box-shadow: 5px 5px 15px rgba(0,0,0,0.3);
|
26
|
+
display: flex;
|
27
|
+
flex-direction: column;
|
28
|
+
align-items: center;
|
29
|
+
width: 100%;
|
30
|
+
max-width: 380px;
|
31
|
+
justify-content: flex-start;
|
32
|
+
margin-bottom: 20px;
|
33
|
+
}
|
34
|
+
|
35
|
+
.screen-container {
|
36
|
+
background-color: #f0d0ce;
|
37
|
+
border-radius: 10px;
|
38
|
+
padding: 20px;
|
39
|
+
margin-bottom: 80px;
|
40
|
+
width: 100%;
|
41
|
+
box-sizing: border-box;
|
42
|
+
}
|
43
|
+
|
44
|
+
#canvas {
|
45
|
+
background-color: #881e1a;
|
46
|
+
display: block;
|
47
|
+
width: 100%;
|
48
|
+
height: auto;
|
49
|
+
}
|
50
|
+
|
51
|
+
.controls {
|
52
|
+
display: flex;
|
53
|
+
justify-content: space-between;
|
54
|
+
width: 100%;
|
55
|
+
margin-bottom: 60px;
|
56
|
+
}
|
57
|
+
|
58
|
+
.d-pad {
|
59
|
+
width: 100px;
|
60
|
+
height: 100px;
|
61
|
+
position: relative;
|
62
|
+
background-color: #881e1a;
|
63
|
+
border-radius: 50%;
|
64
|
+
}
|
65
|
+
|
66
|
+
.d-pad-button {
|
67
|
+
position: absolute;
|
68
|
+
background-color: #550e0c;
|
69
|
+
border: none;
|
70
|
+
cursor: pointer;
|
71
|
+
}
|
72
|
+
|
73
|
+
#d-pad-left, #d-pad-right {
|
74
|
+
width: 30px;
|
75
|
+
height: 30px;
|
76
|
+
top: 35px;
|
77
|
+
}
|
78
|
+
|
79
|
+
#d-pad-left {
|
80
|
+
left: 5px;
|
81
|
+
}
|
82
|
+
|
83
|
+
#d-pad-right {
|
84
|
+
right: 5px;
|
85
|
+
}
|
86
|
+
|
87
|
+
#d-pad-up, #d-pad-down {
|
88
|
+
width: 30px;
|
89
|
+
height: 30px;
|
90
|
+
left: 35px;
|
91
|
+
}
|
92
|
+
|
93
|
+
#d-pad-up {
|
94
|
+
top: 5px;
|
95
|
+
}
|
96
|
+
|
97
|
+
#d-pad-down {
|
98
|
+
bottom: 5px;
|
99
|
+
}
|
100
|
+
|
101
|
+
.buttons {
|
102
|
+
background-color: #881e1a;
|
103
|
+
border-radius: 50%;
|
104
|
+
width: 100px;
|
105
|
+
height: 100px;
|
106
|
+
position: relative;
|
107
|
+
}
|
108
|
+
|
109
|
+
.action-button {
|
110
|
+
position: absolute;
|
111
|
+
background-color: #ff6b66;
|
112
|
+
border: none;
|
113
|
+
border-radius: 50%;
|
114
|
+
width: 35px;
|
115
|
+
height: 35px;
|
116
|
+
cursor: pointer;
|
117
|
+
color: #881e1a;
|
118
|
+
}
|
119
|
+
|
120
|
+
#button-a {
|
121
|
+
top: 15px;
|
122
|
+
right: 15px;
|
123
|
+
}
|
124
|
+
|
125
|
+
#button-b {
|
126
|
+
bottom: 15px;
|
127
|
+
left: 15px;
|
128
|
+
}
|
129
|
+
|
130
|
+
.start-select {
|
131
|
+
display: flex;
|
132
|
+
justify-content: center;
|
133
|
+
margin-bottom: 50px;
|
134
|
+
}
|
135
|
+
|
136
|
+
.start-select button {
|
137
|
+
background-color: #881e1a;
|
138
|
+
border: none;
|
139
|
+
width: 50px;
|
140
|
+
height: 15px;
|
141
|
+
margin: 0 15px;
|
142
|
+
transform: rotate(-25deg);
|
143
|
+
cursor: pointer;
|
144
|
+
}
|
145
|
+
|
146
|
+
.control-panel {
|
147
|
+
width: 100%;
|
148
|
+
max-width: 500px;
|
149
|
+
background-color: #ffffff;
|
150
|
+
border-radius: 10px;
|
151
|
+
padding: 20px;
|
152
|
+
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
153
|
+
box-sizing: border-box;
|
154
|
+
}
|
155
|
+
|
156
|
+
.control-panel section {
|
157
|
+
margin-bottom: 20px;
|
158
|
+
}
|
159
|
+
|
160
|
+
.rom-upload-button {
|
161
|
+
display: inline-block;
|
162
|
+
padding: 10px 20px;
|
163
|
+
background-color: #cc342d;
|
164
|
+
color: white;
|
165
|
+
border: none;
|
166
|
+
border-radius: 4px;
|
167
|
+
font-family: Arial, sans-serif;
|
168
|
+
cursor: pointer;
|
169
|
+
transition: all 0.3s ease;
|
170
|
+
margin-top: 10px;
|
171
|
+
margin-left: 0;
|
172
|
+
width: 100%;
|
173
|
+
box-sizing: border-box;
|
174
|
+
text-align: center;
|
175
|
+
}
|
176
|
+
|
177
|
+
.rom-upload-button.disabled {
|
178
|
+
background-color: #cccccc;
|
179
|
+
color: #666666;
|
180
|
+
cursor: not-allowed;
|
181
|
+
opacity: 0.7;
|
182
|
+
}
|
183
|
+
|
184
|
+
.rom-upload-button input[type="file"] {
|
185
|
+
display: none;
|
186
|
+
}
|
187
|
+
|
188
|
+
.rom-select-box {
|
189
|
+
width: 100%;
|
190
|
+
margin-bottom: 10px;
|
191
|
+
}
|
192
|
+
|
193
|
+
.footer {
|
194
|
+
background-color: #cc342d;
|
195
|
+
color: white;
|
196
|
+
text-align: center;
|
197
|
+
width: 100%;
|
198
|
+
box-sizing: border-box;
|
199
|
+
margin-top: 20px;
|
200
|
+
}
|
201
|
+
|
202
|
+
kbd {
|
203
|
+
background-color: #eee;
|
204
|
+
border-radius: 3px;
|
205
|
+
border: 1px solid #b4b4b4;
|
206
|
+
box-shadow:
|
207
|
+
0 1px 1px rgba(0, 0, 0, 0.2),
|
208
|
+
0 2px 0 0 rgba(255, 255, 255, 0.7) inset;
|
209
|
+
color: #333;
|
210
|
+
display: inline-block;
|
211
|
+
font-size: 0.85em;
|
212
|
+
line-height: 1;
|
213
|
+
padding: 2px 4px;
|
214
|
+
white-space: nowrap;
|
215
|
+
font-family: Courier;
|
216
|
+
}
|
217
|
+
|
218
|
+
@media (min-width: 900px) {
|
219
|
+
.main-content {
|
220
|
+
flex-direction: row;
|
221
|
+
justify-content: center;
|
222
|
+
align-items: flex-start;
|
223
|
+
padding: 50px;
|
224
|
+
}
|
225
|
+
|
226
|
+
.gameboy {
|
227
|
+
margin-right: 50px;
|
228
|
+
margin-bottom: 0;
|
229
|
+
}
|
230
|
+
|
231
|
+
.control-panel {
|
232
|
+
flex: 1;
|
233
|
+
}
|
234
|
+
|
235
|
+
.rom-upload-button {
|
236
|
+
width: auto;
|
237
|
+
margin-left: 20px;
|
238
|
+
margin-top: 0;
|
239
|
+
}
|
240
|
+
|
241
|
+
.rom-select-box {
|
242
|
+
width: auto;
|
243
|
+
margin-bottom: 0;
|
244
|
+
}
|
245
|
+
}
|
data/docs/worker.js
ADDED
@@ -0,0 +1,142 @@
|
|
1
|
+
import { RubyVM } from 'https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.6.2/+esm';
|
2
|
+
import { Directory, File, OpenDirectory, OpenFile, PreopenDirectory, WASI } from 'https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/+esm';
|
3
|
+
|
4
|
+
const DIRECTION_KEY_MASKS = {
|
5
|
+
'KeyD': 0b0001, // Right
|
6
|
+
'KeyA': 0b0010, // Left
|
7
|
+
'KeyW': 0b0100, // Up
|
8
|
+
'KeyS': 0b1000 // Down
|
9
|
+
};
|
10
|
+
|
11
|
+
const ACTION_KEY_MASKS = {
|
12
|
+
'KeyK': 0b0001, // A
|
13
|
+
'KeyJ': 0b0010, // B
|
14
|
+
'KeyU': 0b0100, // Select
|
15
|
+
'KeyI': 0b1000 // Start
|
16
|
+
};
|
17
|
+
|
18
|
+
class Rubyboy {
|
19
|
+
constructor() {
|
20
|
+
this.wasmUrl = 'https://proxy.sacckey.dev/rubyboy.wasm';
|
21
|
+
|
22
|
+
const rootContents = new Map();
|
23
|
+
rootContents.set('RUBYBOY_TMP', new Directory(new Map()));
|
24
|
+
this.rootFs = rootContents;
|
25
|
+
|
26
|
+
const args = ['ruby.wasm', '-e_=0'];
|
27
|
+
this.wasi = new WASI(args, [], [
|
28
|
+
new OpenFile(new File([])), // stdin
|
29
|
+
new OpenFile(new File([])), // stdout
|
30
|
+
new OpenFile(new File([])), // stderr
|
31
|
+
new PreopenDirectory('/', rootContents)
|
32
|
+
], {
|
33
|
+
debug: false
|
34
|
+
});
|
35
|
+
|
36
|
+
this.directionKey = 0b1111;
|
37
|
+
this.actionKey = 0b1111;
|
38
|
+
}
|
39
|
+
|
40
|
+
async init() {
|
41
|
+
let response = await fetch('./rubyboy.wasm');
|
42
|
+
if (!response.ok) {
|
43
|
+
response = await fetch(this.wasmUrl);
|
44
|
+
}
|
45
|
+
|
46
|
+
const buffer = await response.arrayBuffer();
|
47
|
+
const imports = {
|
48
|
+
wasi_snapshot_preview1: this.wasi.wasiImport,
|
49
|
+
};
|
50
|
+
const vm = new RubyVM();
|
51
|
+
vm.addToImports(imports);
|
52
|
+
|
53
|
+
const { instance } = await WebAssembly.instantiate(buffer, imports);
|
54
|
+
await vm.setInstance(instance);
|
55
|
+
this.wasi.initialize(instance);
|
56
|
+
vm.initialize();
|
57
|
+
|
58
|
+
vm.eval(`
|
59
|
+
require 'js'
|
60
|
+
require_relative 'lib/executor'
|
61
|
+
|
62
|
+
$executor = Executor.new
|
63
|
+
`);
|
64
|
+
|
65
|
+
this.vm = vm;
|
66
|
+
}
|
67
|
+
|
68
|
+
sendPixelData() {
|
69
|
+
this.vm.eval(`$executor.exec(${this.directionKey}, ${this.actionKey})`);
|
70
|
+
|
71
|
+
const tmpDir = this.rootFs.get('RUBYBOY_TMP');
|
72
|
+
const op = new OpenDirectory(tmpDir);
|
73
|
+
const result = op.path_lookup('video.data', 0);
|
74
|
+
const file = result.inode_obj;
|
75
|
+
const bytes = file.data;
|
76
|
+
|
77
|
+
postMessage({ type: 'pixelData', data: bytes.buffer }, [bytes.buffer]);
|
78
|
+
}
|
79
|
+
|
80
|
+
emulationLoop() {
|
81
|
+
this.sendPixelData();
|
82
|
+
setTimeout(this.emulationLoop.bind(this), 0);
|
83
|
+
}
|
84
|
+
}
|
85
|
+
|
86
|
+
const rubyboy = new Rubyboy();
|
87
|
+
|
88
|
+
self.addEventListener('message', async (event) => {
|
89
|
+
if (event.data.type === 'initRubyboy') {
|
90
|
+
try {
|
91
|
+
await rubyboy.init();
|
92
|
+
postMessage({ type: 'initialized', message: 'ok' });
|
93
|
+
} catch (error) {
|
94
|
+
postMessage({ type: 'error', message: error.message });
|
95
|
+
}
|
96
|
+
}
|
97
|
+
|
98
|
+
if (event.data.type === 'startRubyboy') {
|
99
|
+
try {
|
100
|
+
rubyboy.emulationLoop();
|
101
|
+
} catch (error) {
|
102
|
+
postMessage({ type: 'error', message: error.message });
|
103
|
+
}
|
104
|
+
}
|
105
|
+
|
106
|
+
if (event.data.type === 'keydown' || event.data.type === 'keyup') {
|
107
|
+
const code = event.data.code;
|
108
|
+
const directionKeyMask = DIRECTION_KEY_MASKS[code];
|
109
|
+
const actionKeyMask = ACTION_KEY_MASKS[code];
|
110
|
+
|
111
|
+
if (directionKeyMask) {
|
112
|
+
if (event.data.type === 'keydown') {
|
113
|
+
rubyboy.directionKey &= ~directionKeyMask;
|
114
|
+
} else {
|
115
|
+
rubyboy.directionKey |= directionKeyMask;
|
116
|
+
}
|
117
|
+
}
|
118
|
+
|
119
|
+
if (actionKeyMask) {
|
120
|
+
if (event.data.type === 'keydown') {
|
121
|
+
rubyboy.actionKey &= ~actionKeyMask;
|
122
|
+
} else {
|
123
|
+
rubyboy.actionKey |= actionKeyMask;
|
124
|
+
}
|
125
|
+
}
|
126
|
+
}
|
127
|
+
|
128
|
+
if (event.data.type === 'loadROM') {
|
129
|
+
const romFile = new File(new Uint8Array(event.data.data));
|
130
|
+
const tmpDir = rubyboy.rootFs.get('RUBYBOY_TMP');
|
131
|
+
tmpDir.contents.set('rom.data', romFile);
|
132
|
+
rubyboy.vm.eval(`
|
133
|
+
$executor.read_rom_from_virtual_fs
|
134
|
+
`);
|
135
|
+
}
|
136
|
+
|
137
|
+
if (event.data.type === 'loadPreInstalledRom') {
|
138
|
+
rubyboy.vm.eval(`
|
139
|
+
$executor.read_pre_installed_rom("${event.data.romName}")
|
140
|
+
`);
|
141
|
+
}
|
142
|
+
});
|
data/exe/rubyboy-wasm
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
case ARGV[0]
|
5
|
+
when 'build'
|
6
|
+
command = %w[build --ruby-version 3.3 -o ./docs/ruby-js.wasm]
|
7
|
+
when 'pack'
|
8
|
+
command = %w[pack ./docs/ruby-js.wasm --dir ./lib::/lib -o ./docs/rubyboy.wasm]
|
9
|
+
else
|
10
|
+
puts "Invalid argument. Use 'build' or 'pack'."
|
11
|
+
exit 1
|
12
|
+
end
|
13
|
+
|
14
|
+
ENV['BUNDLE_ONLY'] = 'wasm'
|
15
|
+
|
16
|
+
require 'bundler/setup'
|
17
|
+
require 'ruby_wasm'
|
18
|
+
require 'ruby_wasm/cli'
|
19
|
+
|
20
|
+
# Exclude all gems except the 'js' gem for packaging
|
21
|
+
definition = Bundler.definition
|
22
|
+
excluded_gems = definition.resolve.materialize(definition.requested_dependencies).map(&:name)
|
23
|
+
excluded_gems -= %w[js]
|
24
|
+
RubyWasm::Packager::EXCLUDED_GEMS.concat(excluded_gems)
|
25
|
+
|
26
|
+
RubyWasm::CLI.new(stdout: $stdout, stderr: $stderr).run(command)
|
data/lib/executor.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'js'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
require_relative 'rubyboy/emulator_wasm'
|
7
|
+
|
8
|
+
class Executor
|
9
|
+
ALLOWED_ROMS = ['tobu.gb', 'bgbtest.gb'].freeze
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
rom_data = File.open('lib/roms/tobu.gb', 'r') { _1.read.bytes }
|
13
|
+
@emulator = Rubyboy::EmulatorWasm.new(rom_data)
|
14
|
+
end
|
15
|
+
|
16
|
+
def exec(direction_key = 0b1111, action_key = 0b1111)
|
17
|
+
bin = @emulator.step(direction_key, action_key).pack('C*')
|
18
|
+
File.binwrite(File.join('/RUBYBOY_TMP', 'video.data'), bin)
|
19
|
+
end
|
20
|
+
|
21
|
+
def read_rom_from_virtual_fs
|
22
|
+
rom_path = '/RUBYBOY_TMP/rom.data'
|
23
|
+
raise "ROM file not found in virtual filesystem at #{rom_path}" unless File.exist?(rom_path)
|
24
|
+
|
25
|
+
rom_data = File.open(rom_path, 'rb') { |file| file.read.bytes }
|
26
|
+
@emulator = Rubyboy::EmulatorWasm.new(rom_data)
|
27
|
+
end
|
28
|
+
|
29
|
+
def read_pre_installed_rom(rom_name)
|
30
|
+
raise 'ROM not found in allowed ROMs' unless ALLOWED_ROMS.include?(rom_name)
|
31
|
+
|
32
|
+
rom_path = File.join('lib/roms', rom_name)
|
33
|
+
rom_data = File.open(rom_path, 'r') { _1.read.bytes }
|
34
|
+
@emulator = Rubyboy::EmulatorWasm.new(rom_data)
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# require_relative 'audio'
|
4
|
+
require_relative 'apu_channels/channel1'
|
5
|
+
require_relative 'apu_channels/channel2'
|
6
|
+
require_relative 'apu_channels/channel3'
|
7
|
+
require_relative 'apu_channels/channel4'
|
8
|
+
|
9
|
+
module Rubyboy
|
10
|
+
class ApuWasm
|
11
|
+
def initialize
|
12
|
+
@audio = nil
|
13
|
+
@nr50 = 0
|
14
|
+
@nr51 = 0
|
15
|
+
@cycles = 0
|
16
|
+
@sampling_cycles = 0
|
17
|
+
@fs = 0
|
18
|
+
@samples = Array.new(1024, 0.0)
|
19
|
+
@sample_idx = 0
|
20
|
+
@channel1 = ApuChannels::Channel1.new
|
21
|
+
@channel2 = ApuChannels::Channel2.new
|
22
|
+
@channel3 = ApuChannels::Channel3.new
|
23
|
+
@channel4 = ApuChannels::Channel4.new
|
24
|
+
end
|
25
|
+
|
26
|
+
def step(cycles)
|
27
|
+
@cycles += cycles
|
28
|
+
@sampling_cycles += cycles
|
29
|
+
|
30
|
+
@channel1.step(cycles)
|
31
|
+
@channel2.step(cycles)
|
32
|
+
@channel3.step(cycles)
|
33
|
+
@channel4.step(cycles)
|
34
|
+
|
35
|
+
if @cycles >= 0x2000
|
36
|
+
@cycles -= 0x2000
|
37
|
+
|
38
|
+
@channel1.step_fs(@fs)
|
39
|
+
@channel2.step_fs(@fs)
|
40
|
+
@channel3.step_fs(@fs)
|
41
|
+
@channel4.step_fs(@fs)
|
42
|
+
|
43
|
+
@fs = (@fs + 1) % 8
|
44
|
+
end
|
45
|
+
|
46
|
+
if @sampling_cycles >= 87
|
47
|
+
@sampling_cycles -= 87
|
48
|
+
|
49
|
+
left_sample = (
|
50
|
+
@nr51[7] * @channel4.dac_output +
|
51
|
+
@nr51[6] * @channel3.dac_output +
|
52
|
+
@nr51[5] * @channel2.dac_output +
|
53
|
+
@nr51[4] * @channel1.dac_output
|
54
|
+
) / 4.0
|
55
|
+
|
56
|
+
right_sample = (
|
57
|
+
@nr51[3] * @channel4.dac_output +
|
58
|
+
@nr51[2] * @channel3.dac_output +
|
59
|
+
@nr51[1] * @channel2.dac_output +
|
60
|
+
@nr51[0] * @channel1.dac_output
|
61
|
+
) / 4.0
|
62
|
+
|
63
|
+
raise "#{@nr51} #{@channel4.dac_output}, #{@channel3.dac_output}, #{@channel2.dac_output},#{@channel1.dac_output}" if left_sample.abs > 1.0 || right_sample.abs > 1.0
|
64
|
+
|
65
|
+
@samples[@sample_idx * 2] = (@nr50[4..6] / 7.0) * left_sample / 8.0
|
66
|
+
@samples[@sample_idx * 2 + 1] = (@nr50[0..2] / 7.0) * right_sample / 8.0
|
67
|
+
@sample_idx += 1
|
68
|
+
end
|
69
|
+
|
70
|
+
return if @sample_idx < 512
|
71
|
+
|
72
|
+
@sample_idx = 0
|
73
|
+
@audio.queue(@samples)
|
74
|
+
end
|
75
|
+
|
76
|
+
def read_byte(addr)
|
77
|
+
case addr
|
78
|
+
when 0xff10..0xff14 then @channel1.read_nr1x(addr - 0xff10)
|
79
|
+
when 0xff15..0xff19 then @channel2.read_nr2x(addr - 0xff15)
|
80
|
+
when 0xff1a..0xff1e then @channel3.read_nr3x(addr - 0xff1a)
|
81
|
+
when 0xff1f..0xff23 then @channel4.read_nr4x(addr - 0xff1f)
|
82
|
+
when 0xff24 then @nr50
|
83
|
+
when 0xff25 then @nr51
|
84
|
+
when 0xff26 then (@channel1.enabled ? 0x01 : 0x00) | (@channel2.enabled ? 0x02 : 0x00) | (@channel3.enabled ? 0x04 : 0x00) | (@channel4.enabled ? 0x08 : 0x00) | 0x70 | (@enabled ? 0x80 : 0x00)
|
85
|
+
when 0xff30..0xff3f then @channel3.wave_ram[(addr - 0xff30)]
|
86
|
+
else raise "Invalid APU read at #{addr.to_s(16)}"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def write_byte(addr, val)
|
91
|
+
return if !@enabled && ![0xff11, 0xff16, 0xff1b, 0xff20, 0xff26].include?(addr) && !(0xff30..0xff3f).include?(addr)
|
92
|
+
|
93
|
+
val &= 0x3f if !@enabled && [0xff11, 0xff16, 0xff1b, 0xff20].include?(addr)
|
94
|
+
|
95
|
+
case addr
|
96
|
+
when 0xff10..0xff14 then @channel1.write_nr1x(addr - 0xff10, val)
|
97
|
+
when 0xff15..0xff19 then @channel2.write_nr2x(addr - 0xff15, val)
|
98
|
+
when 0xff1a..0xff1e then @channel3.write_nr3x(addr - 0xff1a, val)
|
99
|
+
when 0xff1f..0xff23 then @channel4.write_nr4x(addr - 0xff1f, val)
|
100
|
+
when 0xff24 then @nr50 = val
|
101
|
+
when 0xff25 then @nr51 = val
|
102
|
+
when 0xff26
|
103
|
+
flg = val & 0x80 > 0
|
104
|
+
if !flg && @enabled
|
105
|
+
(0xff10..0xff25).each { |a| write_byte(a, 0) }
|
106
|
+
elsif flg && !@enabled
|
107
|
+
@fs = 0
|
108
|
+
@channel1.wave_duty_position = 0
|
109
|
+
@channel2.wave_duty_position = 0
|
110
|
+
@channel3.wave_duty_position = 0
|
111
|
+
end
|
112
|
+
@enabled = flg
|
113
|
+
when 0xff30..0xff3f then @channel3.wave_ram[(addr - 0xff30)] = val
|
114
|
+
else raise "Invalid APU write at #{addr.to_s(16)}"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'apu_wasm'
|
4
|
+
require_relative 'bus'
|
5
|
+
require_relative 'cpu'
|
6
|
+
require_relative 'emulator'
|
7
|
+
require_relative 'ppu_wasm'
|
8
|
+
require_relative 'rom'
|
9
|
+
require_relative 'ram'
|
10
|
+
require_relative 'timer'
|
11
|
+
require_relative 'joypad'
|
12
|
+
require_relative 'interrupt'
|
13
|
+
require_relative 'cartridge/factory'
|
14
|
+
|
15
|
+
module Rubyboy
|
16
|
+
class EmulatorWasm
|
17
|
+
CPU_CLOCK_HZ = 4_194_304
|
18
|
+
CYCLE_NANOSEC = 1_000_000_000 / CPU_CLOCK_HZ
|
19
|
+
|
20
|
+
def initialize(rom_data)
|
21
|
+
rom = Rom.new(rom_data)
|
22
|
+
ram = Ram.new
|
23
|
+
mbc = Cartridge::Factory.create(rom, ram)
|
24
|
+
interrupt = Interrupt.new
|
25
|
+
@ppu = PpuWasm.new(interrupt)
|
26
|
+
@timer = Timer.new(interrupt)
|
27
|
+
@joypad = Joypad.new(interrupt)
|
28
|
+
@apu = ApuWasm.new
|
29
|
+
@bus = Bus.new(@ppu, rom, ram, mbc, @timer, interrupt, @joypad, @apu)
|
30
|
+
@cpu = Cpu.new(@bus, interrupt)
|
31
|
+
end
|
32
|
+
|
33
|
+
def step(direction_key, action_key)
|
34
|
+
@joypad.direction_button(direction_key)
|
35
|
+
@joypad.action_button(action_key)
|
36
|
+
loop do
|
37
|
+
cycles = @cpu.exec
|
38
|
+
@timer.step(cycles)
|
39
|
+
return @ppu.buffer if @ppu.step(cycles)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,312 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rubyboy
|
4
|
+
class PpuWasm
|
5
|
+
attr_reader :buffer
|
6
|
+
|
7
|
+
MODE = {
|
8
|
+
hblank: 0,
|
9
|
+
vblank: 1,
|
10
|
+
oam_scan: 2,
|
11
|
+
drawing: 3
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
LCDC = {
|
15
|
+
bg_window_enable: 0,
|
16
|
+
sprite_enable: 1,
|
17
|
+
sprite_size: 2,
|
18
|
+
bg_tile_map_area: 3,
|
19
|
+
bg_window_tile_data_area: 4,
|
20
|
+
window_enable: 5,
|
21
|
+
window_tile_map_area: 6,
|
22
|
+
lcd_ppu_enable: 7
|
23
|
+
}.freeze
|
24
|
+
|
25
|
+
STAT = {
|
26
|
+
ly_eq_lyc: 2,
|
27
|
+
hblank: 3,
|
28
|
+
vblank: 4,
|
29
|
+
oam_scan: 5,
|
30
|
+
lyc: 6
|
31
|
+
}.freeze
|
32
|
+
|
33
|
+
SPRITE_FLAGS = {
|
34
|
+
bank: 3,
|
35
|
+
dmg_palette: 4,
|
36
|
+
x_flip: 5,
|
37
|
+
y_flip: 6,
|
38
|
+
priority: 7
|
39
|
+
}.freeze
|
40
|
+
|
41
|
+
LCD_WIDTH = 160
|
42
|
+
LCD_HEIGHT = 144
|
43
|
+
|
44
|
+
OAM_SCAN_CYCLES = 80
|
45
|
+
DRAWING_CYCLES = 172
|
46
|
+
HBLANK_CYCLES = 204
|
47
|
+
ONE_LINE_CYCLES = OAM_SCAN_CYCLES + DRAWING_CYCLES + HBLANK_CYCLES
|
48
|
+
|
49
|
+
def initialize(interrupt)
|
50
|
+
@mode = MODE[:oam_scan]
|
51
|
+
@lcdc = 0x91
|
52
|
+
@stat = 0x00
|
53
|
+
@scy = 0x00
|
54
|
+
@scx = 0x00
|
55
|
+
@ly = 0x00
|
56
|
+
@lyc = 0x00
|
57
|
+
@obp0 = 0x00
|
58
|
+
@obp1 = 0x00
|
59
|
+
@wy = 0x00
|
60
|
+
@wx = 0x00
|
61
|
+
@bgp = 0x00
|
62
|
+
@vram = Array.new(0x2000, 0x00)
|
63
|
+
@oam = Array.new(0xa0, 0x00)
|
64
|
+
@wly = 0x00
|
65
|
+
@cycles = 0
|
66
|
+
@interrupt = interrupt
|
67
|
+
@buffer = Array.new(144 * 160 * 4, 0xff)
|
68
|
+
@bg_pixels = Array.new(LCD_WIDTH, 0x00)
|
69
|
+
end
|
70
|
+
|
71
|
+
def read_byte(addr)
|
72
|
+
case addr
|
73
|
+
when 0x8000..0x9fff
|
74
|
+
@mode == MODE[:drawing] ? 0xff : @vram[addr - 0x8000]
|
75
|
+
when 0xfe00..0xfe9f
|
76
|
+
@mode == MODE[:oam_scan] || @mode == MODE[:drawing] ? 0xff : @oam[addr - 0xfe00]
|
77
|
+
when 0xff40
|
78
|
+
@lcdc
|
79
|
+
when 0xff41
|
80
|
+
@stat | 0x80 | @mode
|
81
|
+
when 0xff42
|
82
|
+
@scy
|
83
|
+
when 0xff43
|
84
|
+
@scx
|
85
|
+
when 0xff44
|
86
|
+
@ly
|
87
|
+
when 0xff45
|
88
|
+
@lyc
|
89
|
+
when 0xff47
|
90
|
+
@bgp
|
91
|
+
when 0xff48
|
92
|
+
@obp0
|
93
|
+
when 0xff49
|
94
|
+
@obp1
|
95
|
+
when 0xff4a
|
96
|
+
@wy
|
97
|
+
when 0xff4b
|
98
|
+
@wx
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def write_byte(addr, value)
|
103
|
+
case addr
|
104
|
+
when 0x8000..0x9fff
|
105
|
+
@vram[addr - 0x8000] = value if @mode != MODE[:drawing]
|
106
|
+
when 0xfe00..0xfe9f
|
107
|
+
@oam[addr - 0xfe00] = value if @mode != MODE[:oam_scan] && @mode != MODE[:drawing]
|
108
|
+
when 0xff40
|
109
|
+
@lcdc = value
|
110
|
+
when 0xff41
|
111
|
+
@stat = value & 0x78
|
112
|
+
when 0xff42
|
113
|
+
@scy = value
|
114
|
+
when 0xff43
|
115
|
+
@scx = value
|
116
|
+
when 0xff44
|
117
|
+
# ly is read only
|
118
|
+
when 0xff45
|
119
|
+
@lyc = value
|
120
|
+
when 0xff47
|
121
|
+
@bgp = value
|
122
|
+
when 0xff48
|
123
|
+
@obp0 = value
|
124
|
+
when 0xff49
|
125
|
+
@obp1 = value
|
126
|
+
when 0xff4a
|
127
|
+
@wy = value
|
128
|
+
when 0xff4b
|
129
|
+
@wx = value
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def step(cycles)
|
134
|
+
return false if @lcdc[LCDC[:lcd_ppu_enable]] == 0
|
135
|
+
|
136
|
+
res = false
|
137
|
+
@cycles += cycles
|
138
|
+
|
139
|
+
case @mode
|
140
|
+
when MODE[:oam_scan]
|
141
|
+
if @cycles >= OAM_SCAN_CYCLES
|
142
|
+
@cycles -= OAM_SCAN_CYCLES
|
143
|
+
@mode = MODE[:drawing]
|
144
|
+
end
|
145
|
+
when MODE[:drawing]
|
146
|
+
if @cycles >= DRAWING_CYCLES
|
147
|
+
render_bg
|
148
|
+
render_window
|
149
|
+
render_sprites
|
150
|
+
@cycles -= DRAWING_CYCLES
|
151
|
+
@mode = MODE[:hblank]
|
152
|
+
@interrupt.request(:lcd) if @stat[STAT[:hblank]] == 1
|
153
|
+
end
|
154
|
+
when MODE[:hblank]
|
155
|
+
if @cycles >= HBLANK_CYCLES
|
156
|
+
@cycles -= HBLANK_CYCLES
|
157
|
+
@ly += 1
|
158
|
+
handle_ly_eq_lyc
|
159
|
+
|
160
|
+
if @ly == LCD_HEIGHT
|
161
|
+
@mode = MODE[:vblank]
|
162
|
+
@interrupt.request(:vblank)
|
163
|
+
@interrupt.request(:lcd) if @stat[STAT[:vblank]] == 1
|
164
|
+
else
|
165
|
+
@mode = MODE[:oam_scan]
|
166
|
+
@interrupt.request(:lcd) if @stat[STAT[:oam_scan]] == 1
|
167
|
+
end
|
168
|
+
end
|
169
|
+
when MODE[:vblank]
|
170
|
+
if @cycles >= ONE_LINE_CYCLES
|
171
|
+
@cycles -= ONE_LINE_CYCLES
|
172
|
+
@ly += 1
|
173
|
+
handle_ly_eq_lyc
|
174
|
+
|
175
|
+
if @ly == 154
|
176
|
+
@ly = 0
|
177
|
+
@wly = 0
|
178
|
+
handle_ly_eq_lyc
|
179
|
+
@mode = MODE[:oam_scan]
|
180
|
+
@interrupt.request(:lcd) if @stat[STAT[:oam_scan]] == 1
|
181
|
+
res = true
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
res
|
187
|
+
end
|
188
|
+
|
189
|
+
def render_bg
|
190
|
+
return if @lcdc[LCDC[:bg_window_enable]] == 0
|
191
|
+
|
192
|
+
y = (@ly + @scy) % 256
|
193
|
+
tile_map_addr = @lcdc[LCDC[:bg_tile_map_area]] == 0 ? 0x1800 : 0x1c00
|
194
|
+
tile_map_addr += (y / 8) * 32
|
195
|
+
LCD_WIDTH.times do |i|
|
196
|
+
x = (i + @scx) % 256
|
197
|
+
tile_index = get_tile_index(tile_map_addr + (x / 8))
|
198
|
+
pixel = get_pixel(tile_index << 4, 7 - (x % 8), (y % 8) * 2)
|
199
|
+
color = get_color(@bgp, pixel)
|
200
|
+
base = @ly * LCD_WIDTH * 4 + i * 4
|
201
|
+
@buffer[base] = color
|
202
|
+
@buffer[base + 1] = color
|
203
|
+
@buffer[base + 2] = color
|
204
|
+
@bg_pixels[i] = pixel
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
def render_window
|
209
|
+
return if @lcdc[LCDC[:bg_window_enable]] == 0 || @lcdc[LCDC[:window_enable]] == 0 || @ly < @wy
|
210
|
+
|
211
|
+
rendered = false
|
212
|
+
y = @wly
|
213
|
+
tile_map_addr = @lcdc[LCDC[:window_tile_map_area]] == 0 ? 0x1800 : 0x1c00
|
214
|
+
tile_map_addr += (y / 8) * 32
|
215
|
+
LCD_WIDTH.times do |i|
|
216
|
+
next if i < @wx - 7
|
217
|
+
|
218
|
+
rendered = true
|
219
|
+
x = i - (@wx - 7)
|
220
|
+
tile_index = get_tile_index(tile_map_addr + (x / 8))
|
221
|
+
pixel = get_pixel(tile_index << 4, 7 - (x % 8), (y % 8) * 2)
|
222
|
+
color = get_color(@bgp, pixel)
|
223
|
+
base = @ly * LCD_WIDTH * 4 + i * 4
|
224
|
+
@buffer[base] = color
|
225
|
+
@buffer[base + 1] = color
|
226
|
+
@buffer[base + 2] = color
|
227
|
+
@bg_pixels[i] = pixel
|
228
|
+
end
|
229
|
+
@wly += 1 if rendered
|
230
|
+
end
|
231
|
+
|
232
|
+
def render_sprites
|
233
|
+
return if @lcdc[LCDC[:sprite_enable]] == 0
|
234
|
+
|
235
|
+
sprite_height = @lcdc[LCDC[:sprite_size]] == 0 ? 8 : 16
|
236
|
+
sprites = []
|
237
|
+
cnt = 0
|
238
|
+
|
239
|
+
@oam.each_slice(4) do |y, x, tile_index, flags|
|
240
|
+
y = (y - 16) % 256
|
241
|
+
x = (x - 8) % 256
|
242
|
+
next if y > @ly || y + sprite_height <= @ly
|
243
|
+
|
244
|
+
sprites << { y:, x:, tile_index:, flags: }
|
245
|
+
cnt += 1
|
246
|
+
break if cnt == 10
|
247
|
+
end
|
248
|
+
sprites = sprites.sort_by.with_index { |sprite, i| [-sprite[:x], -i] }
|
249
|
+
|
250
|
+
sprites.each do |sprite|
|
251
|
+
flags = sprite[:flags]
|
252
|
+
pallet = flags[SPRITE_FLAGS[:dmg_palette]] == 0 ? @obp0 : @obp1
|
253
|
+
tile_index = sprite[:tile_index]
|
254
|
+
tile_index &= 0xfe if sprite_height == 16
|
255
|
+
y = (@ly - sprite[:y]) % 256
|
256
|
+
y = sprite_height - y - 1 if flags[SPRITE_FLAGS[:y_flip]] == 1
|
257
|
+
tile_index = (tile_index + 1) % 256 if y >= 8
|
258
|
+
y %= 8
|
259
|
+
|
260
|
+
8.times do |x|
|
261
|
+
x_flipped = flags[SPRITE_FLAGS[:x_flip]] == 1 ? 7 - x : x
|
262
|
+
|
263
|
+
pixel = get_pixel(tile_index << 4, 7 - x_flipped, (y % 8) * 2)
|
264
|
+
i = (sprite[:x] + x) % 256
|
265
|
+
|
266
|
+
next if pixel == 0 || i >= LCD_WIDTH
|
267
|
+
next if flags[SPRITE_FLAGS[:priority]] == 1 && @bg_pixels[i] != 0
|
268
|
+
|
269
|
+
color = get_color(pallet, pixel)
|
270
|
+
base = @ly * LCD_WIDTH * 4 + i * 4
|
271
|
+
@buffer[base] = color
|
272
|
+
@buffer[base + 1] = color
|
273
|
+
@buffer[base + 2] = color
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
private
|
279
|
+
|
280
|
+
def get_tile_index(tile_map_addr)
|
281
|
+
tile_index = @vram[tile_map_addr]
|
282
|
+
@lcdc[LCDC[:bg_window_tile_data_area]] == 0 ? to_signed_byte(tile_index) + 256 : tile_index
|
283
|
+
end
|
284
|
+
|
285
|
+
def get_pixel(tile_index, c, r)
|
286
|
+
@vram[tile_index + r][c] + (@vram[tile_index + r + 1][c] << 1)
|
287
|
+
end
|
288
|
+
|
289
|
+
def get_color(pallet, pixel)
|
290
|
+
case (pallet >> (pixel * 2)) & 0b11
|
291
|
+
when 0 then 0xff
|
292
|
+
when 1 then 0xaa
|
293
|
+
when 2 then 0x55
|
294
|
+
when 3 then 0x00
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def to_signed_byte(byte)
|
299
|
+
byte &= 0xff
|
300
|
+
byte > 127 ? byte - 256 : byte
|
301
|
+
end
|
302
|
+
|
303
|
+
def handle_ly_eq_lyc
|
304
|
+
if @ly == @lyc
|
305
|
+
@stat |= 0x04
|
306
|
+
@interrupt.request(:lcd) if @stat[STAT[:lyc]] == 1
|
307
|
+
else
|
308
|
+
@stat &= 0xfb
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
data/lib/rubyboy/version.rb
CHANGED
Binary file
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rubyboy
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- sacckey
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-09-29 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: ffi
|
@@ -35,6 +35,7 @@ email:
|
|
35
35
|
executables:
|
36
36
|
- rubyboy
|
37
37
|
- rubyboy-bench
|
38
|
+
- rubyboy-wasm
|
38
39
|
extensions: []
|
39
40
|
extra_rdoc_files: []
|
40
41
|
files:
|
@@ -45,9 +46,18 @@ files:
|
|
45
46
|
- LICENSE.txt
|
46
47
|
- README.md
|
47
48
|
- Rakefile
|
49
|
+
- docs/favicon.png
|
50
|
+
- docs/index.html
|
51
|
+
- docs/index.js
|
52
|
+
- docs/logo-light-23.svg
|
53
|
+
- docs/ogp.png
|
54
|
+
- docs/styles.css
|
55
|
+
- docs/worker.js
|
48
56
|
- exe/rubyboy
|
49
57
|
- exe/rubyboy-bench
|
58
|
+
- exe/rubyboy-wasm
|
50
59
|
- lib/bench.rb
|
60
|
+
- lib/executor.rb
|
51
61
|
- lib/opcodes.json
|
52
62
|
- lib/roms/bgbtest.gb
|
53
63
|
- lib/roms/cpu_instrs/cpu_instrs.gb
|
@@ -74,6 +84,7 @@ files:
|
|
74
84
|
- lib/rubyboy/apu_channels/channel2.rb
|
75
85
|
- lib/rubyboy/apu_channels/channel3.rb
|
76
86
|
- lib/rubyboy/apu_channels/channel4.rb
|
87
|
+
- lib/rubyboy/apu_wasm.rb
|
77
88
|
- lib/rubyboy/audio.rb
|
78
89
|
- lib/rubyboy/bus.rb
|
79
90
|
- lib/rubyboy/cartridge/factory.rb
|
@@ -81,10 +92,12 @@ files:
|
|
81
92
|
- lib/rubyboy/cartridge/nombc.rb
|
82
93
|
- lib/rubyboy/cpu.rb
|
83
94
|
- lib/rubyboy/emulator.rb
|
95
|
+
- lib/rubyboy/emulator_wasm.rb
|
84
96
|
- lib/rubyboy/interrupt.rb
|
85
97
|
- lib/rubyboy/joypad.rb
|
86
98
|
- lib/rubyboy/lcd.rb
|
87
99
|
- lib/rubyboy/ppu.rb
|
100
|
+
- lib/rubyboy/ppu_wasm.rb
|
88
101
|
- lib/rubyboy/ram.rb
|
89
102
|
- lib/rubyboy/raylib/audio.rb
|
90
103
|
- lib/rubyboy/raylib/lcd.rb
|
@@ -96,6 +109,7 @@ files:
|
|
96
109
|
- lib/rubyboy/version.rb
|
97
110
|
- resource/logo/logo.png
|
98
111
|
- resource/logo/logo.svg
|
112
|
+
- resource/logo/rubyboy.png
|
99
113
|
- resource/screenshots/pokemon.png
|
100
114
|
- resource/screenshots/puyopuyo.png
|
101
115
|
- sig/rubyboy.rbs
|