@8bitscript/nes 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 8BitScript contributors
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.
@@ -0,0 +1,212 @@
1
+ ; @8bitscript/nes — the CHR-ROM character set.
2
+ ;
3
+ ; The NES has no character ROM of its own. Every Commodore machine and the
4
+ ; Atari 8-bit ship their letters and digits in a ROM the video chip reads
5
+ ; directly, so `text.putChar` on those targets only ever writes a code
6
+ ; into screen memory. The NES PPU reads its 8x8 tile patterns from the
7
+ ; CARTRIDGE (CHR-ROM on NROM), so a cartridge that shows text has to bring
8
+ ; its own — this file is that ROM, linked into the .nes image through this
9
+ ; package's "8bitscript".native list (see package.json and docs/packages.md).
10
+ ;
11
+ ; Layout: pattern table 0 ($0000-$0FFF), 256 tiles of 16 bytes, laid out so
12
+ ; that TILE INDEX == ASCII CODE for every glyph here — space ($20), the
13
+ ; digits ($30-$39), A-Z ($41-$5A), and ! , - . : ? — so @8bitscript/nes's
14
+ ; `text.putChar(cell, code)` writes the ASCII code straight into the
15
+ ; nametable with no translation table, the same codes examples/borders'
16
+ ; Atari variant already uses. Codes in $20-$5F with no glyph drawn render
17
+ ; blank. Tile $80 is solid colour index 2 — the tile @8bitscript/nes lays
18
+ ; around the screen edge as the drawn "border" (see index.8bs). Reverse
19
+ ; video of $20-$5F lives at ASCII+128 ($A0-$DF), leaving $80 alone.
20
+ ; Everything else, including all of pattern table 1 ($1000-$1FFF), is blank.
21
+ ;
22
+ ; Tile format (nesdev.org/wiki/PPU_pattern_tables): 8 bytes of bitplane 0
23
+ ; then 8 bytes of bitplane 1, one byte per row, bit 7 the leftmost pixel.
24
+ ; A pixel's colour index is plane1:plane0, so a glyph with rows in plane 0
25
+ ; and zeros in plane 1 is drawn entirely in colour index 1 — palette entry
26
+ ; $3F01, the text colour screen.setColors() sets — and the solid tile, with zeros
27
+ ; in plane 0 and $FF in plane 1, is entirely colour index 2 ($3F02).
28
+ ; The binary literals below therefore ARE the glyph artwork: read each
29
+ ; `tile` line's eight rows top to bottom, 1 = lit.
30
+
31
+ .section .chr_rom,"a"
32
+
33
+ .macro tile r0, r1, r2, r3, r4, r5, r6, r7
34
+ .byte \r0, \r1, \r2, \r3, \r4, \r5, \r6, \r7
35
+ .byte 0, 0, 0, 0, 0, 0, 0, 0
36
+ .endm
37
+
38
+ ; Reverse of a glyph: plane 0 inverted, plane 1 still clear, so lit pixels
39
+ ; become the backdrop and the rest of the cell takes colour index 1 — the
40
+ ; text colour. A reverse space is therefore a solid block in that colour.
41
+ .macro itile r0, r1, r2, r3, r4, r5, r6, r7
42
+ .byte 0xff^\r0, 0xff^\r1, 0xff^\r2, 0xff^\r3, 0xff^\r4, 0xff^\r5, 0xff^\r6, 0xff^\r7
43
+ .byte 0, 0, 0, 0, 0, 0, 0, 0
44
+ .endm
45
+
46
+ .macro rfill
47
+ .byte 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
48
+ .byte 0, 0, 0, 0, 0, 0, 0, 0
49
+ .endm
50
+
51
+ ; $00-$20: control codes and space — 33 blank tiles.
52
+ .space 33 * 16
53
+
54
+ ; $21 '!'
55
+ tile 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00000000, 0b00000000, 0b00011000, 0b00000000
56
+
57
+ ; $22-$2B: " # $ % & ' ( ) * + — 10 blank tiles.
58
+ .space 10 * 16
59
+
60
+ ; $2C ','
61
+ tile 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b00011000, 0b00110000, 0b00000000
62
+ ; $2D '-'
63
+ tile 0b00000000, 0b00000000, 0b00000000, 0b01111110, 0b00000000, 0b00000000, 0b00000000, 0b00000000
64
+ ; $2E '.'
65
+ tile 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b00011000, 0b00000000
66
+
67
+ ; $2F '/' — blank.
68
+ .space 16
69
+
70
+ ; $30-$39 '0'-'9'
71
+ tile 0b00111100, 0b01100110, 0b01101110, 0b01110110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
72
+ tile 0b00011000, 0b00111000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b01111110, 0b00000000
73
+ tile 0b00111100, 0b01100110, 0b00000110, 0b00001100, 0b00110000, 0b01100000, 0b01111110, 0b00000000
74
+ tile 0b00111100, 0b01100110, 0b00000110, 0b00011100, 0b00000110, 0b01100110, 0b00111100, 0b00000000
75
+ tile 0b00001100, 0b00011100, 0b00111100, 0b01101100, 0b01111110, 0b00001100, 0b00001100, 0b00000000
76
+ tile 0b01111110, 0b01100000, 0b01111100, 0b00000110, 0b00000110, 0b01100110, 0b00111100, 0b00000000
77
+ tile 0b00111100, 0b01100000, 0b01111100, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
78
+ tile 0b01111110, 0b00000110, 0b00001100, 0b00011000, 0b00110000, 0b00110000, 0b00110000, 0b00000000
79
+ tile 0b00111100, 0b01100110, 0b01100110, 0b00111100, 0b01100110, 0b01100110, 0b00111100, 0b00000000
80
+ tile 0b00111100, 0b01100110, 0b01100110, 0b00111110, 0b00000110, 0b00001100, 0b00111000, 0b00000000
81
+
82
+ ; $3A ':'
83
+ tile 0b00000000, 0b00011000, 0b00011000, 0b00000000, 0b00011000, 0b00011000, 0b00000000, 0b00000000
84
+
85
+ ; $3B-$3E: ; < = > — 4 blank tiles.
86
+ .space 4 * 16
87
+
88
+ ; $3F '?'
89
+ tile 0b00111100, 0b01100110, 0b00000110, 0b00001100, 0b00011000, 0b00000000, 0b00011000, 0b00000000
90
+
91
+ ; $40 '@' — blank.
92
+ .space 16
93
+
94
+ ; $41-$5A 'A'-'Z'
95
+ tile 0b00011000, 0b00111100, 0b01100110, 0b01111110, 0b01100110, 0b01100110, 0b01100110, 0b00000000
96
+ tile 0b01111100, 0b01100110, 0b01100110, 0b01111100, 0b01100110, 0b01100110, 0b01111100, 0b00000000
97
+ tile 0b00111100, 0b01100110, 0b01100000, 0b01100000, 0b01100000, 0b01100110, 0b00111100, 0b00000000
98
+ tile 0b01111000, 0b01101100, 0b01100110, 0b01100110, 0b01100110, 0b01101100, 0b01111000, 0b00000000
99
+ tile 0b01111110, 0b01100000, 0b01100000, 0b01111000, 0b01100000, 0b01100000, 0b01111110, 0b00000000
100
+ tile 0b01111110, 0b01100000, 0b01100000, 0b01111000, 0b01100000, 0b01100000, 0b01100000, 0b00000000
101
+ tile 0b00111100, 0b01100110, 0b01100000, 0b01101110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
102
+ tile 0b01100110, 0b01100110, 0b01100110, 0b01111110, 0b01100110, 0b01100110, 0b01100110, 0b00000000
103
+ tile 0b00111100, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00111100, 0b00000000
104
+ tile 0b00011110, 0b00001100, 0b00001100, 0b00001100, 0b00001100, 0b01101100, 0b00111000, 0b00000000
105
+ tile 0b01100110, 0b01101100, 0b01111000, 0b01110000, 0b01111000, 0b01101100, 0b01100110, 0b00000000
106
+ tile 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01111110, 0b00000000
107
+ tile 0b01100011, 0b01110111, 0b01111111, 0b01101011, 0b01100011, 0b01100011, 0b01100011, 0b00000000
108
+ tile 0b01100110, 0b01110110, 0b01111110, 0b01111110, 0b01101110, 0b01100110, 0b01100110, 0b00000000
109
+ tile 0b00111100, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
110
+ tile 0b01111100, 0b01100110, 0b01100110, 0b01111100, 0b01100000, 0b01100000, 0b01100000, 0b00000000
111
+ tile 0b00111100, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00001110, 0b00000000
112
+ tile 0b01111100, 0b01100110, 0b01100110, 0b01111100, 0b01111000, 0b01101100, 0b01100110, 0b00000000
113
+ tile 0b00111100, 0b01100110, 0b01100000, 0b00111100, 0b00000110, 0b01100110, 0b00111100, 0b00000000
114
+ tile 0b01111110, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00000000
115
+ tile 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
116
+ tile 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00011000, 0b00000000
117
+ tile 0b01100011, 0b01100011, 0b01100011, 0b01101011, 0b01111111, 0b01110111, 0b01100011, 0b00000000
118
+ tile 0b01100110, 0b01100110, 0b00111100, 0b00011000, 0b00111100, 0b01100110, 0b01100110, 0b00000000
119
+ tile 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00011000, 0b00011000, 0b00011000, 0b00000000
120
+ tile 0b01111110, 0b00000110, 0b00001100, 0b00011000, 0b00110000, 0b01100000, 0b01111110, 0b00000000
121
+
122
+ ; $5B-$7F: [ \ ] ^ _ ` a-z { | } ~ DEL — 37 blank tiles (no lowercase yet).
123
+ .space 37 * 16
124
+
125
+ ; $80: the solid tile — every pixel colour index 2 (plane 1 set, plane 0
126
+ ; clear), so it takes whatever palette entry $3F02 holds: `border`.
127
+ .byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
128
+ .byte 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
129
+
130
+ ; $81-$9F: 31 blank tiles — a gap so reverse copies can sit at ASCII+128
131
+ ; without colliding with the border tile at $80.
132
+ .space 31 * 16
133
+
134
+ ; $A0-$DF: reverse video of $20-$5F. Tile index == ASCII + 128, the same
135
+ ; rule Commodore screen codes use, so `text.setReverse` is `code + 128`.
136
+ ; Inverted blanks (and the reverse space at $A0) are solid colour index 1.
137
+ ; $A0 reverse space
138
+ rfill
139
+ ; $A1 reverse '!'
140
+ itile 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00000000, 0b00000000, 0b00011000, 0b00000000
141
+ ; $A2-$AB: reverse of the $22-$2B blanks
142
+ .rept 10
143
+ rfill
144
+ .endr
145
+ ; $AC reverse ','
146
+ itile 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b00011000, 0b00110000, 0b00000000
147
+ ; $AD reverse '-'
148
+ itile 0b00000000, 0b00000000, 0b00000000, 0b01111110, 0b00000000, 0b00000000, 0b00000000, 0b00000000
149
+ ; $AE reverse '.'
150
+ itile 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b00011000, 0b00000000
151
+ ; $AF reverse of blank '/'
152
+ rfill
153
+ ; $B0-$B9 reverse '0'-'9'
154
+ itile 0b00111100, 0b01100110, 0b01101110, 0b01110110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
155
+ itile 0b00011000, 0b00111000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b01111110, 0b00000000
156
+ itile 0b00111100, 0b01100110, 0b00000110, 0b00001100, 0b00110000, 0b01100000, 0b01111110, 0b00000000
157
+ itile 0b00111100, 0b01100110, 0b00000110, 0b00011100, 0b00000110, 0b01100110, 0b00111100, 0b00000000
158
+ itile 0b00001100, 0b00011100, 0b00111100, 0b01101100, 0b01111110, 0b00001100, 0b00001100, 0b00000000
159
+ itile 0b01111110, 0b01100000, 0b01111100, 0b00000110, 0b00000110, 0b01100110, 0b00111100, 0b00000000
160
+ itile 0b00111100, 0b01100000, 0b01111100, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
161
+ itile 0b01111110, 0b00000110, 0b00001100, 0b00011000, 0b00110000, 0b00110000, 0b00110000, 0b00000000
162
+ itile 0b00111100, 0b01100110, 0b01100110, 0b00111100, 0b01100110, 0b01100110, 0b00111100, 0b00000000
163
+ itile 0b00111100, 0b01100110, 0b01100110, 0b00111110, 0b00000110, 0b00001100, 0b00111000, 0b00000000
164
+ ; $BA reverse ':'
165
+ itile 0b00000000, 0b00011000, 0b00011000, 0b00000000, 0b00011000, 0b00011000, 0b00000000, 0b00000000
166
+ ; $BB-$BE: reverse of the $3B-$3E blanks
167
+ .rept 4
168
+ rfill
169
+ .endr
170
+ ; $BF reverse '?'
171
+ itile 0b00111100, 0b01100110, 0b00000110, 0b00001100, 0b00011000, 0b00000000, 0b00011000, 0b00000000
172
+ ; $C0 reverse of blank '@'
173
+ rfill
174
+ ; $C1-$DA reverse 'A'-'Z'
175
+ itile 0b00011000, 0b00111100, 0b01100110, 0b01111110, 0b01100110, 0b01100110, 0b01100110, 0b00000000
176
+ itile 0b01111100, 0b01100110, 0b01100110, 0b01111100, 0b01100110, 0b01100110, 0b01111100, 0b00000000
177
+ itile 0b00111100, 0b01100110, 0b01100000, 0b01100000, 0b01100000, 0b01100110, 0b00111100, 0b00000000
178
+ itile 0b01111000, 0b01101100, 0b01100110, 0b01100110, 0b01100110, 0b01101100, 0b01111000, 0b00000000
179
+ itile 0b01111110, 0b01100000, 0b01100000, 0b01111000, 0b01100000, 0b01100000, 0b01111110, 0b00000000
180
+ itile 0b01111110, 0b01100000, 0b01100000, 0b01111000, 0b01100000, 0b01100000, 0b01100000, 0b00000000
181
+ itile 0b00111100, 0b01100110, 0b01100000, 0b01101110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
182
+ itile 0b01100110, 0b01100110, 0b01100110, 0b01111110, 0b01100110, 0b01100110, 0b01100110, 0b00000000
183
+ itile 0b00111100, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00111100, 0b00000000
184
+ itile 0b00011110, 0b00001100, 0b00001100, 0b00001100, 0b00001100, 0b01101100, 0b00111000, 0b00000000
185
+ itile 0b01100110, 0b01101100, 0b01111000, 0b01110000, 0b01111000, 0b01101100, 0b01100110, 0b00000000
186
+ itile 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01111110, 0b00000000
187
+ itile 0b01100011, 0b01110111, 0b01111111, 0b01101011, 0b01100011, 0b01100011, 0b01100011, 0b00000000
188
+ itile 0b01100110, 0b01110110, 0b01111110, 0b01111110, 0b01101110, 0b01100110, 0b01100110, 0b00000000
189
+ itile 0b00111100, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
190
+ itile 0b01111100, 0b01100110, 0b01100110, 0b01111100, 0b01100000, 0b01100000, 0b01100000, 0b00000000
191
+ itile 0b00111100, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00001110, 0b00000000
192
+ itile 0b01111100, 0b01100110, 0b01100110, 0b01111100, 0b01111000, 0b01101100, 0b01100110, 0b00000000
193
+ itile 0b00111100, 0b01100110, 0b01100000, 0b00111100, 0b00000110, 0b01100110, 0b00111100, 0b00000000
194
+ itile 0b01111110, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00000000
195
+ itile 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00000000
196
+ itile 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00011000, 0b00000000
197
+ itile 0b01100011, 0b01100011, 0b01100011, 0b01101011, 0b01111111, 0b01110111, 0b01100011, 0b00000000
198
+ itile 0b01100110, 0b01100110, 0b00111100, 0b00011000, 0b00111100, 0b01100110, 0b01100110, 0b00000000
199
+ itile 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00011000, 0b00011000, 0b00011000, 0b00000000
200
+ itile 0b01111110, 0b00000110, 0b00001100, 0b00011000, 0b00110000, 0b01100000, 0b01111110, 0b00000000
201
+ ; $DB-$DF: reverse of the $5B-$5F blanks
202
+ .rept 5
203
+ rfill
204
+ .endr
205
+
206
+ ; $E0-$FF: 32 blank tiles, completing pattern table 0 at exactly 4096 bytes.
207
+ .space 32 * 16
208
+
209
+ ; Pattern table 1 ($1000-$1FFF): unused, blank. Spelled out rather than left
210
+ ; to the linker's padding so the section is exactly the 8 KiB the iNES
211
+ ; header declares for NROM, whatever the linker script does with a short one.
212
+ .space 4096
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@8bitscript/nes",
3
+ "version": "0.1.0",
4
+ "description": "NES target support for 8BitScript: the hardware underneath the portable APIs.",
5
+ "license": "MIT",
6
+ "8bitscript": {
7
+ "entry": "./src/index.8bs",
8
+ "exports": {
9
+ "./screen": "./src/screen.8bs",
10
+ "./text": "./src/text.8bs",
11
+ "./pad": "./src/pad.8bs",
12
+ "./input": "./src/input.8bs",
13
+ "./pointer": "./src/pointer.8bs"
14
+ },
15
+ "native": [
16
+ "./native/6502/font.s"
17
+ ],
18
+ "hardware": {
19
+ "facts": {
20
+ "video.columns": 28,
21
+ "video.rows": 26,
22
+ "video.cellWidth": 8,
23
+ "video.cellHeight": 8,
24
+ "video.palette": 25,
25
+ "video.cellColors": 3,
26
+ "video.colorPerCell": false,
27
+ "video.glyphs": 0,
28
+ "video.blockWidth": 0,
29
+ "video.blockHeight": 0,
30
+ "video.bitmap": false,
31
+ "video.layers": 1,
32
+ "video.scroll": true,
33
+ "video.sprites": 64,
34
+ "video.spritesPerLine": 8,
35
+ "video.spriteWidth": 8,
36
+ "video.spriteHeight": 16,
37
+ "video.spriteColors": 3,
38
+ "video.frameRate": 60,
39
+ "audio.voices": 5,
40
+ "audio.noise": true,
41
+ "audio.envelope": true,
42
+ "audio.filter": false,
43
+ "audio.pcm": true,
44
+ "audio.volume": true,
45
+ "audio.entropy": false,
46
+ "input.keyboard": false,
47
+ "input.joysticks": 0,
48
+ "input.pads": 2,
49
+ "input.mouse": false,
50
+ "input.paddles": false,
51
+ "storage.save": false,
52
+ "memory.ram": 1536,
53
+ "memory.banked": false,
54
+ "memory.bankedKib": 0,
55
+ "storage.kib": 0
56
+ },
57
+ "options": {
58
+ "mapper": {
59
+ "label": "Cartridge board",
60
+ "default": "nrom",
61
+ "values": {
62
+ "nrom": {
63
+ "label": "NROM: 32K PRG, 8K CHR-ROM, no save",
64
+ "build": {
65
+ "driver": "mos-nes-nrom-clang",
66
+ "output": "nes"
67
+ },
68
+ "facts": {
69
+ "storage.save": false
70
+ }
71
+ }
72
+ }
73
+ }
74
+ },
75
+ "presets": {}
76
+ }
77
+ },
78
+ "files": [
79
+ "src",
80
+ "native"
81
+ ],
82
+ "publishConfig": {
83
+ "access": "public"
84
+ }
85
+ }
package/src/index.8bs ADDED
@@ -0,0 +1,185 @@
1
+ // @8bitscript/nes — NES target support: the hardware underneath.
2
+ //
3
+ // See ../AGENTS.md before extending this package: the NES PPU is tile/pattern
4
+ // oriented rather than a framebuffer, its 64-sprite budget is really an
5
+ // 8-per-scanline budget, and its mapper (only NROM is wired up today) is a
6
+ // build profile question, not a language one — mistakes easy to bake into
7
+ // comments or APIs here if copied from a generic "8-bit" mental model.
8
+ //
9
+ // The module entry point named by the "8bitscript".entry field in this
10
+ // package's package.json, resolved and linked for:
11
+ //
12
+ // import { setVramAddress, resetScroll } from "@8bitscript/nes";
13
+ //
14
+ // What a program usually wants sits one layer up, in this package's own
15
+ // implementations of the portable capability packages — `./src/screen.8bs`
16
+ // behind @8bitscript/screen and `./src/text.8bs` behind @8bitscript/text,
17
+ // named by the "8bitscript".exports map in package.json — which are built
18
+ // on what this file exports and on nothing else. Where a Commodore
19
+ // package's hardware surface is a set of registers, the NES's is a
20
+ // PROTOCOL: the PPU's memory is not CPU-addressable at all, so the two
21
+ // functions here are the port sequence every VRAM access on this machine
22
+ // has to perform, and the two rules that come with it.
23
+ //
24
+ // ---- VRAM through the PPU's port -----------------------------------------
25
+ //
26
+ // Screen memory is not a fixed CPU address: the nametable lives in the
27
+ // PPU's own 16 KiB address space, reached only through an indirect port —
28
+ // read PPUSTATUS ($2002) to reset the shared address/scroll write toggle,
29
+ // write PPUADDR ($2006) twice (high byte, then low) to latch a VRAM address,
30
+ // then write PPUDATA ($2007), which stores through it and auto-increments.
31
+ // Two rules that protocol brings, both of which the screen and text
32
+ // modules honour so callers need not:
33
+ //
34
+ // 1. Writing PPUADDR moves the PPU's internal address register, which is
35
+ // also what it scrolls from when rendering resumes — leave it pointing
36
+ // into the middle of the nametable and the next frame draws from
37
+ // there. Every text.putChar() and screen.setColors() ends by resetting
38
+ // the scroll (PPUCTRL $2000 = 0, then PPUSCROLL $2005 = 0 twice), so
39
+ // the picture stays anchored at the top-left. Invisible while every
40
+ // tile was blank; not once there is something on screen to shift.
41
+ //
42
+ // 2. While rendering is ON, the PPU owns VRAM outside vertical blank,
43
+ // and a write outside it lands wherever the PPU's address happens to
44
+ // be — a corrupted tile and a torn frame. Vertical blank is about
45
+ // 2270 CPU cycles, and a program cannot be asked to know that. So
46
+ // the text and palette writes do not go to the PPU when a program
47
+ // makes them: they go into the queue below, as runs of bytes at a
48
+ // VRAM address, and `nesVerticalBlank()` delivers the whole queue
49
+ // at the start of the next vertical blank — packages/backend-6502's
50
+ // frame runtime calls it right after every hardware frame edge
51
+ // (FRAME_SYNC.nes.frameHook), before waitFrame() returns. Delivery
52
+ // is a tight loop, about 15 cycles a byte — but that number alone
53
+ // is what let this queue overrun the blank in the first place: it
54
+ // prices the data bytes and forgets each run's own header (a
55
+ // setVramAddress() call plus its two-write PPUADDR protocol), which
56
+ // is not free. A HUD of a few wide rows is nearly all data bytes and
57
+ // pays that header cost once or twice; a grid of many narrow fields
58
+ // (2048's 4-character tile cells, three text.print() runs per tile)
59
+ // pays it on almost every run. QUEUE_SIZE was 128 — "about four
60
+ // 28-column rows," sized against the wide-row case — until 2048's
61
+ // 16-tile board (packages/2048, one run per tile row, ~19 runs to
62
+ // fill one queue) proved by measurement that a full 128-byte queue
63
+ // of small runs delivers a few cycles past the 2270-cycle blank,
64
+ // corrupting whichever tile was mid-flight when the PPU's own
65
+ // rendering resumed under it (FCEUX, `8bs run nes --screenshot`,
66
+ // 2026-09-07: 128 corrupted two tiles, every size 120 and under
67
+ // across both a two-tile and a full sixteen-tile board did not, 124
68
+ // did). 112 is the fixed value now: comfortable under the observed
69
+ // failure band, not the exact edge of it, since neither this
70
+ // project's timing model nor FCEUX's is being trusted to a single
71
+ // cycle. A future program with even smaller, more numerous runs
72
+ // than 2048's could still find a new floor; measure again with
73
+ // `--screenshot` before assuming this one holds. more than a full
74
+ // queue in one frame is delivered as it overflows, which may tear.
75
+ // screen.blank() switches rendering off and writes the nametable
76
+ // directly, delivering the queue first so nothing printed before
77
+ // the blank appears after it.
78
+ //
79
+ // Nametable and attribute contents are undefined at power-on (real
80
+ // hardware: garbage; FCEUX: zeros, which is why a program that forgot to
81
+ // clear would look fine in the emulator and wrong on a cartridge) — a
82
+ // program should clear the screen once before screen.setColors(), as
83
+ // examples/borders does; the attribute table is cleared by screen.8bs,
84
+ // since a program has no other way to reach it.
85
+ //
86
+ // PPU warm-up: NESdev's guidance is to wait for two vertical blanks after
87
+ // reset before writing the PPU at all, since the chip is not stable for
88
+ // roughly the first 29,658 CPU cycles. LLVM-MOS's NROM start-up code does
89
+ // exactly that before main() — verified by disassembling mos-platform/
90
+ // nes-nrom/lib/crt0.o: __early_init writes $2000/$2001/$4010 = 0, clears
91
+ // $2002, and spins on it until bit 7 sets; __late_init spins on it once
92
+ // more after RAM is initialised — so by the time a program's main() runs
93
+ // the PPU has seen two vblanks and is safe to write. (An earlier revision
94
+ // of this comment recorded that wait as a gap this target still had; it
95
+ // was never one.)
96
+
97
+ // PPUSTATUS read resets the PPUADDR/PPUSCROLL write toggle so the two
98
+ // PPUADDR writes land as a fresh address rather than as a stray second
99
+ // half of whatever came before.
100
+ export function setVramAddress(address: usmallint): void {
101
+ memory.read(0x2002);
102
+ memory.write(0x2006, address / 256);
103
+ memory.write(0x2006, address % 256);
104
+ }
105
+
106
+ // PPUCTRL 0: nametable $2000, PPUDATA increments by 1, background patterns
107
+ // from table 0 (where the font is), NMI off — this target polls PPUSTATUS
108
+ // rather than taking the interrupt. Then PPUSCROLL x = 0, y = 0.
109
+ export function resetScroll(): void {
110
+ memory.write(0x2000, 0x00);
111
+ memory.write(0x2005, 0x00);
112
+ memory.write(0x2005, 0x00);
113
+ }
114
+
115
+ // ---- the write queue -------------------------------------------------------
116
+ //
117
+ // Runs of bytes for the PPU, each a two-byte VRAM address (high, low), a
118
+ // count, and the bytes: `queueRun(address)` opens one, `queueByte(value)`
119
+ // adds to it, and `nesVerticalBlank()` writes them all out in order and
120
+ // resets the scroll. Rule 2 above says why.
121
+ const QUEUE_SIZE: utinyint = 112;
122
+ let queue: array<utinyint, QUEUE_SIZE>;
123
+ let queueEnd: utinyint = 0;
124
+ let runCount: utinyint = 0; // where the open run's count byte is
125
+ let runAddress: usmallint = 0; // the open run's VRAM address
126
+
127
+ export function queueRun(address: usmallint): void {
128
+ if (queueEnd > QUEUE_SIZE - 4) {
129
+ deliverAtVerticalBlank(); // no room for a header and a byte
130
+ }
131
+ queue[queueEnd] = address / 256;
132
+ queue[queueEnd + 1] = address % 256;
133
+ runCount = queueEnd + 2;
134
+ queue[runCount] = 0;
135
+ queueEnd = queueEnd + 3;
136
+ runAddress = address;
137
+ }
138
+
139
+ export function queueByte(value: utinyint): void {
140
+ if (queueEnd == QUEUE_SIZE) {
141
+ // Full: deliver what there is and reopen the run where it left off.
142
+ queueRun(runAddress + queue[runCount]);
143
+ }
144
+ queue[queueEnd] = value;
145
+ queueEnd++;
146
+ queue[runCount]++;
147
+ }
148
+
149
+ // The queue is full before the frame is over: wait for the next vertical
150
+ // blank and deliver it there. The program loses the rest of this frame —
151
+ // a HUD too big for one frame costs frames, it never costs the picture.
152
+ // Delivering mid-frame instead would corrupt it: PPUADDR is the PPU's
153
+ // own fetch position while it draws, and PPUDATA writes move it. The
154
+ // status read first clears a vertical-blank flag left over from earlier,
155
+ // so the loop waits for the edge and not for a stale flag.
156
+ function deliverAtVerticalBlank(): void {
157
+ memory.read(0x2002);
158
+ while (memory.read(0x2002) < 128) {
159
+ }
160
+ nesVerticalBlank();
161
+ }
162
+
163
+ // Every queued run to the PPU, then the scroll back to the top-left (each
164
+ // run's PPUADDR write moved it). Called by the frame runtime at the start
165
+ // of vertical blank; by deliverAtVerticalBlank() when the queue is full;
166
+ // by screen.blank() with rendering off.
167
+ export function nesVerticalBlank(): void {
168
+ let i: utinyint = 0;
169
+ while (i < queueEnd) {
170
+ setVramAddress(queue[i] * 256 + queue[i + 1]);
171
+ let n: utinyint = queue[i + 2];
172
+ i = i + 3;
173
+ // Indexed straight off `i`, one byte at a time: an `i + k` index
174
+ // made LLVM-MOS walk a pointer at 23 cycles a byte; this is 15.
175
+ while (n > 0) {
176
+ memory.write(0x2007, queue[i]);
177
+ i++;
178
+ n--;
179
+ }
180
+ }
181
+ if (queueEnd > 0) {
182
+ queueEnd = 0;
183
+ resetScroll();
184
+ }
185
+ }
package/src/input.8bs ADDED
@@ -0,0 +1,141 @@
1
+ // @8bitscript/nes/input — the NES behind @8bitscript/input.
2
+ //
3
+ // Named by "8bitscript".exports["./input"] in this package's package.json,
4
+ // and by "8bitscript".entry.nes in @8bitscript/input's, so a portable
5
+ // program writes
6
+ //
7
+ // import { input } from "@8bitscript/input";
8
+ //
9
+ // and gets this file on an NES. The pad itself is ./pad.8bs, which stays
10
+ // available to a program that wants both controllers or the buttons this
11
+ // file does not map.
12
+ //
13
+ // ---- what this machine has, and what it does not -------------------------
14
+ //
15
+ // This is the machine that makes the portable input surface worth having,
16
+ // because it has none of what the others have. **No keyboard** — the fact
17
+ // sheet says so (`input.keyboard` is false) and Studio reads that fact to
18
+ // decide the NES is a viewer. **No pointer** — there is no mouse for a
19
+ // stock NES, so `pointer()` is a constant false and everything that hangs
20
+ // off it folds away. What there is, is a D-pad, and a D-pad is exactly
21
+ // what a menu bar needs.
22
+ //
23
+ // So the mapping is the whole file:
24
+ //
25
+ // - **left / right / up / down** — the D-pad on controller 1.
26
+ // - **confirm** — A, or START. START is included because on a console
27
+ // the button that opens a menu is the one people press to commit to
28
+ // something in it, and A is the one they press by reflex.
29
+ // - **cancel** — B.
30
+ //
31
+ // Controller 2 is read by ./pad.8bs and ignored here: the portable surface
32
+ // describes one user driving one interface. A two-player program wants the
33
+ // pad layer directly.
34
+ //
35
+ // ---- edges, not levels ---------------------------------------------------
36
+ //
37
+ // Every answer here is **edge-triggered**: true on the one frame the press
38
+ // begins, false while it is held. Holding right does not race the highlight
39
+ // across the bar. `poll()` must be called exactly once a frame, right after
40
+ // waitFrame() — twice a frame and every press is seen once and swallowed.
41
+ import { pad, Pad } from "./pad.8bs";
42
+
43
+ // One bit per thing a program can ask about, so a frame's input is one
44
+ // byte and an edge is one AND. The same seven bits as every other
45
+ // machine's input layer; this one never sets BUTTON.
46
+ namespace Edge {
47
+ const LEFT: utinyint = 1;
48
+ const RIGHT: utinyint = 2;
49
+ const UP: utinyint = 4;
50
+ const DOWN: utinyint = 8;
51
+ const CONFIRM: utinyint = 16;
52
+ const CANCEL: utinyint = 32;
53
+ }
54
+
55
+ let held: utinyint = 0;
56
+ let before: utinyint = 0;
57
+ let began: utinyint = 0;
58
+
59
+ export namespace input {
60
+
61
+ // Nothing to set up on this machine: the pad needs no configuration
62
+ // and there is no pointer to give a ceiling to. Here so that a program
63
+ // written for nine machines calls the same thing on all of them.
64
+ function begin(): void {
65
+ }
66
+
67
+ // Strobe the pad and work out what began this frame. Once a frame,
68
+ // right after waitFrame().
69
+ function poll(): void {
70
+ pad.scan();
71
+
72
+ before = held;
73
+ held = 0;
74
+
75
+ // The D-pad's four bits and the pad's own bit layout differ, so
76
+ // this is a translation rather than a mask — and it is four
77
+ // compares rather than a table because a table would cost a byte
78
+ // of RAM per entry to save nothing.
79
+ let buttons: utinyint = pad.bits(Pad.PORT_1);
80
+ if ((buttons & Pad.LEFT) != 0) {
81
+ held = held | Edge.LEFT;
82
+ }
83
+ if ((buttons & Pad.RIGHT) != 0) {
84
+ held = held | Edge.RIGHT;
85
+ }
86
+ if ((buttons & Pad.UP) != 0) {
87
+ held = held | Edge.UP;
88
+ }
89
+ if ((buttons & Pad.DOWN) != 0) {
90
+ held = held | Edge.DOWN;
91
+ }
92
+ if ((buttons & Pad.A) != 0 || (buttons & Pad.START) != 0) {
93
+ held = held | Edge.CONFIRM;
94
+ }
95
+ if ((buttons & Pad.B) != 0) {
96
+ held = held | Edge.CANCEL;
97
+ }
98
+
99
+ began = held & (before ^ 0xFF);
100
+ }
101
+
102
+ function left(): bool {
103
+ return (began & Edge.LEFT) != 0;
104
+ }
105
+
106
+ function right(): bool {
107
+ return (began & Edge.RIGHT) != 0;
108
+ }
109
+
110
+ function up(): bool {
111
+ return (began & Edge.UP) != 0;
112
+ }
113
+
114
+ function down(): bool {
115
+ return (began & Edge.DOWN) != 0;
116
+ }
117
+
118
+ function confirm(): bool {
119
+ return (began & Edge.CONFIRM) != 0;
120
+ }
121
+
122
+ function cancel(): bool {
123
+ return (began & Edge.CANCEL) != 0;
124
+ }
125
+
126
+ // There is no pointer on an NES, and this is a constant — so a
127
+ // component's `if (input.pointer())` costs this machine nothing at
128
+ // all, which is the point of answering the question rather than
129
+ // leaving the call out.
130
+ function pointer(): bool {
131
+ return false;
132
+ }
133
+
134
+ function pointerCell(): usmallint {
135
+ return 0;
136
+ }
137
+
138
+ function pointerButton(): bool {
139
+ return false;
140
+ }
141
+ }
package/src/pad.8bs ADDED
@@ -0,0 +1,120 @@
1
+ // @8bitscript/nes/pad — the two controller ports, one snapshot a frame.
2
+ //
3
+ // Named by "8bitscript".exports["./pad"] in this package's package.json:
4
+ //
5
+ // import { pad, Pad } from "@8bitscript/nes/pad";
6
+ //
7
+ // pad.scan();
8
+ // if (pad.pressed(Pad.PORT_1, Pad.RIGHT)) { ... }
9
+ //
10
+ // Hardware-level, NES-only surface — the layer @8bitscript/nes/input sits
11
+ // on, the way ./text.8bs sits under @8bitscript/text. Importing it makes a
12
+ // program NES-specific. It keeps no history and decodes nothing; it
13
+ // answers "is this button down now", which is what a frame loop asks.
14
+ //
15
+ // ---- how a controller talks ----------------------------------------------
16
+ //
17
+ // A standard controller is an 8-bit parallel-to-serial shift register, not
18
+ // a set of addressable switches. Writing 1 then 0 to JOY1 ($4016) strobes
19
+ // *both* ports: the 1 loads each pad's eight buttons into its register,
20
+ // the 0 lets them be clocked out. After that, each read of $4016 returns
21
+ // pad 1's next button in bit 0 and each read of $4017 returns pad 2's, in
22
+ // this fixed order:
23
+ //
24
+ // A, B, SELECT, START, UP, DOWN, LEFT, RIGHT
25
+ //
26
+ // A 1 bit is a button held down. Only bit 0 carries a button — the upper
27
+ // bits are open bus on the NES and expansion-port data on a Famicom — so
28
+ // every read is masked. Eight reads of two ports is about 200 cycles of a
29
+ // 29780-cycle frame.
30
+ //
31
+ // `scan()` shifts each read into the top of a byte and shifts the byte
32
+ // down, so after eight rounds the first button read (A) has travelled to
33
+ // bit 0 and the last (RIGHT) sits at bit 7 — which is the `Pad.*` layout
34
+ // below, and why there is no bit table here the way the C64's keyboard
35
+ // needs one.
36
+ //
37
+ // ---- two things this file does not do, on purpose ------------------------
38
+ //
39
+ // **It does not re-read to defend against DMC corruption.** When the APU's
40
+ // sample channel fetches a byte it can steal a cycle from a controller
41
+ // read and drop or double-clock a bit — the reason commercial games read
42
+ // the pad twice and keep the reading that agrees. Nothing in 8BitScript
43
+ // drives the DMC channel today, so the defence would cost every program
44
+ // cycles to guard against a channel no program can turn on. **When sample
45
+ // playback arrives on this target, this is the file that has to grow the
46
+ // double read**, and that is the note to find here when it does.
47
+ //
48
+ // **It does not know about a four-player adapter, a Zapper, or a Power
49
+ // Pad.** Those change what the extra bits after the eighth read mean; this
50
+ // reads eight and stops.
51
+ //
52
+ // Written from the documented strobe-then-eight-reads protocol (NESdev's
53
+ // "Standard controller" and "Controller port registers"), which is the
54
+ // sequence every NES program has used since 1983. **Not yet confirmed on
55
+ // screen under a controller** — `--screenshot` builds and captures a
56
+ // frame but cannot press a button, so the button order below is the
57
+ // documented one and not a measured one. Anyone who runs
58
+ // examples/first-look/menubar under FCEUX with a pad should replace this
59
+ // paragraph with what they saw.
60
+
61
+ // The two ports. JOY2's *write* side is the APU's frame counter, which is
62
+ // why nothing here ever writes it — the strobe goes to JOY1 alone and
63
+ // latches both pads.
64
+ const JOY1: usmallint = 0x4016;
65
+ const JOY2: usmallint = 0x4017;
66
+
67
+ // The last scan: index 0 is port 1, index 1 is port 2; bits as in `Pad`.
68
+ let state: array<utinyint, 2>;
69
+
70
+ export namespace Pad {
71
+ const PORT_1: utinyint = 0;
72
+ const PORT_2: utinyint = 1;
73
+
74
+ // In the order the shift register clocks them out, which is the order
75
+ // the bits end up in after `scan()`.
76
+ const A: utinyint = 1;
77
+ const B: utinyint = 2;
78
+ const SELECT: utinyint = 4;
79
+ const START: utinyint = 8;
80
+ const UP: utinyint = 16;
81
+ const DOWN: utinyint = 32;
82
+ const LEFT: utinyint = 64;
83
+ const RIGHT: utinyint = 128;
84
+ }
85
+
86
+ export namespace pad {
87
+ // Strobe both pads and clock all sixteen buttons in. Once a frame,
88
+ // right after waitFrame().
89
+ function scan(): void {
90
+ memory.write(JOY1, 1);
91
+ memory.write(JOY1, 0);
92
+ // Both ports are clocked by the same eight reads, so they are read
93
+ // together rather than in two loops — one strobe, one pass.
94
+ let one: utinyint = 0;
95
+ let two: utinyint = 0;
96
+ for (let i: utinyint = 0; i < 8; i++) {
97
+ one = one >> 1;
98
+ if ((memory.read(JOY1) & 1) != 0) {
99
+ one = one | 128;
100
+ }
101
+ two = two >> 1;
102
+ if ((memory.read(JOY2) & 1) != 0) {
103
+ two = two | 128;
104
+ }
105
+ }
106
+ state[Pad.PORT_1] = one;
107
+ state[Pad.PORT_2] = two;
108
+ }
109
+
110
+ // The eight buttons of one port as `Pad.*` bits, 1 = held down.
111
+ function bits(port: utinyint): utinyint {
112
+ return state[port];
113
+ }
114
+
115
+ // Is this button — one `Pad.*` bit, or several or'd together — down in
116
+ // the last scan? Several bits ask "any of these".
117
+ function pressed(port: utinyint, button: utinyint): bool {
118
+ return (state[port] & button) != 0;
119
+ }
120
+ }
@@ -0,0 +1,39 @@
1
+ // @8bitscript/nes/pointer — the NES behind @8bitscript/pointer.
2
+ //
3
+ // Every call here does nothing and `DRAWS` is false. The PPU has 64
4
+ // sprites and could draw an arrow without difficulty, so this is not the
5
+ // drawing half that is missing — **it is that a stock NES has nothing to
6
+ // move one with.** @8bitscript/nes/input reports a D-pad and buttons and
7
+ // no pointer, because the console this repository builds for has two
8
+ // controller ports and a cartridge, and a pad is not a pointer: a cursor
9
+ // driven by a D-pad is a selection moving between items, which is what
10
+ // `menubar.next()` and `previous()` already are, and dressing that up as
11
+ // an arrow would be a worse interface, not a better one.
12
+ //
13
+ // The machine that *would* answer is a Zapper or a Power Pad, and neither
14
+ // is a mouse either. A Famicom mouse existed and the hardware option for
15
+ // one does not: packages/nes's catalog has no controller axis at all yet
16
+ // (its mapper is still hard-wired — see packages/nes/AGENTS.md), so
17
+ // fitting one would be a new option there first, then an input driver, and
18
+ // only then this file.
19
+ //
20
+ // It costs the NES nothing: `DRAWS` is a const and every function is
21
+ // empty, so a program's `if (pointer.DRAWS)` folds away and LLVM deletes
22
+ // the calls — which matters most here, where a program has 2 KiB of RAM
23
+ // and every byte of the cartridge is budgeted.
24
+ export namespace pointer {
25
+
26
+ const DRAWS: bool = false;
27
+
28
+ function begin(): void {
29
+ }
30
+
31
+ function setColor(color: utinyint): void {
32
+ }
33
+
34
+ function update(): void {
35
+ }
36
+
37
+ function hide(): void {
38
+ }
39
+ }
package/src/screen.8bs ADDED
@@ -0,0 +1,213 @@
1
+ // @8bitscript/nes/screen — the NES's implementation of @8bitscript/screen.
2
+ //
3
+ // Named by "8bitscript".exports["./screen"] in this package's package.json,
4
+ // and what
5
+ //
6
+ // import { screen, BorderColor, BackgroundColor } from "@8bitscript/screen";
7
+ //
8
+ // resolves to when the build is for the NES: @8bitscript/screen's entry is
9
+ // keyed by machine and delegates here. Built on the PPU port protocol
10
+ // @8bitscript/nes exports — read that file first for the two VRAM rules
11
+ // this one lives by. Every machine's screen.8bs exports this same surface,
12
+ // which is what lets a program import it from @8bitscript/screen and never
13
+ // name the hardware.
14
+ import { setVramAddress, resetScroll, queueRun, queueByte, nesVerticalBlank } from "./index.8bs";
15
+
16
+ // ---- what "border" means on a machine that has none --------------------
17
+ //
18
+ // The NES has no border-colour register. There is no separate border area
19
+ // for one to colour: the PPU's 256x240 picture IS the whole frame, edge to
20
+ // edge, and every one of those pixels comes from a background tile or a
21
+ // sprite (or, where neither covers it, the universal backdrop colour). VIC,
22
+ // VIC-II and GTIA all draw a picture smaller than the raster and paint the
23
+ // margin around it from a dedicated register; the PPU simply has no
24
+ // margin. So the border cannot be a register write here, the way it is on
25
+ // every other target.
26
+ //
27
+ // It is real anyway. This module DRAWS a border: on the first setColors()
28
+ // call it lays a two-tile-thick frame of a solid tile around the edge of
29
+ // the nametable (rows 0-1 and 28-29, columns 0-1 and 30-31), and `border`
30
+ // is the palette entry that solid tile takes its colour from. Two tiles
31
+ // thick, not one, because NTSC televisions — and FCEUX, whose default NTSC
32
+ // view drops the top and bottom 8 lines — overscan the outer tile row
33
+ // away; the inner row survives. The text grid a program draws on (see
34
+ // text.8bs) is the area INSIDE that frame: 28 columns by 26 rows, whose
35
+ // cell 0 is nametable row 2, column 2 — the top-left corner, against the
36
+ // border, which is exactly what cell 0 is on every other target. A program
37
+ // never addresses the frame's own tiles, and never needs to know they
38
+ // exist. The four values setColors() writes are, in the PPU's own terms:
39
+ //
40
+ // $3F00 universal backdrop `background`
41
+ // $3F01 colour index 1 $30 (white) — every glyph in the font
42
+ // $3F02 colour index 2 `border` — the solid frame tile
43
+ // $3F03 colour index 3 unused
44
+ //
45
+ // all in background palette 0, which every tile on screen uses (the
46
+ // attribute table is zeroed by the same first call, so no 32x32-pixel block
47
+ // picks another palette). Both colours are NES palette indices — $00-$3F,
48
+ // the 2C02's own colour numbers; $0F is black, $30 white, row $0x dark
49
+ // through row $3x pale — not RGB and not the Commodore/Atari values.
50
+ //
51
+ // The CPU cannot read the PPU's mask register back, so `renderingEnabled`
52
+ // below is this module's own record of having switched rendering on: it
53
+ // is what lets setColors() lay the frame exactly once, on the call that
54
+ // is by construction the last moment a large VRAM write is free.
55
+
56
+ let renderingEnabled: u8 = 0;
57
+
58
+ // `count` consecutive nametable cells from `address`, one PPUADDR set and
59
+ // then PPUDATA's auto-increment — the cheap way to write a run.
60
+ function fillRun(address: usmallint, count: usmallint, value: utinyint): void {
61
+ setVramAddress(address);
62
+ for (let i: usmallint = 0; i < count; i++) {
63
+ memory.write(0x2007, value);
64
+ }
65
+ }
66
+
67
+ // The drawn border: rows 0-1 and 28-29 in full, columns 0-1 and 30-31 of
68
+ // every row between, all tile $80 (solid colour index 2 — see font.s).
69
+ // Then the 64-byte attribute table at $23C0, zeroed: palette 0 everywhere.
70
+ function layFrame(): void {
71
+ fillRun(0x2000, 64, 0x80);
72
+ fillRun(0x2380, 64, 0x80);
73
+ for (let row: usmallint = 2; row < 28; row++) {
74
+ fillRun(0x2000 + row * 32, 2, 0x80);
75
+ fillRun(0x2000 + row * 32 + 30, 2, 0x80);
76
+ }
77
+ fillRun(0x23C0, 64, 0x00);
78
+ }
79
+
80
+ // Switch the picture on, the first time anything asks for one: the frame
81
+ // and attribute table go in with rendering off (a few hundred VRAM
82
+ // writes, no vblank budget to fit), the text palette entry is set, and
83
+ // rendering starts at the next vertical blank — switching it on mid-frame
84
+ // would draw the rest of that frame from wherever the PPU's address
85
+ // register sits. Every later call returns at once.
86
+ function showPicture(): void {
87
+ if (renderingEnabled == 1) {
88
+ return;
89
+ }
90
+ layFrame();
91
+ setVramAddress(0x3F01);
92
+ memory.write(0x2007, 0x30); // $3F01: text — white
93
+ resetScroll();
94
+ while (memory.read(0x2002) < 128) {
95
+ }
96
+ renderingEnabled = 1;
97
+ // PPUMASK: bit 3 shows the background, bit 1 shows it in the leftmost
98
+ // 8 pixels too — otherwise column 0, the outer edge of the frame, would
99
+ // be clipped to the backdrop colour. Sprites stay off.
100
+ memory.write(0x2001, 0x0A);
101
+ }
102
+
103
+ // One palette entry. With the picture on it goes through the write queue
104
+ // and lands at the next vertical blank, like text (see index.8bs); with
105
+ // the picture off the PPU is free and it is written now.
106
+ function setPalette(entry: usmallint, color: utinyint): void {
107
+ if (renderingEnabled == 1) {
108
+ queueRun(entry);
109
+ queueByte(color);
110
+ return;
111
+ }
112
+ setVramAddress(entry);
113
+ memory.write(0x2007, color);
114
+ resetScroll();
115
+ }
116
+
117
+ export namespace screen {
118
+ function setColors(border: u8, background: u8): void {
119
+ setPalette(0x3F00, background); // $3F00: the universal backdrop
120
+ setPalette(0x3F02, border); // $3F02: the frame tile
121
+ showPicture();
122
+ }
123
+
124
+ // `blank(border, background)`: every cell blank and both colours set —
125
+ // black when left off, or `BorderColor.KEEP` / `BackgroundColor.KEEP`
126
+ // to leave one as it is. `setBorder` and `setBackground` are one
127
+ // colour each, `setColors` the pair; `text.setColor` owns what the
128
+ // next print looks like.
129
+ function blank(border: utinyint = BorderColor.BLACK, background: utinyint = BackgroundColor.BLACK): void {
130
+ // The nametable can only be filled in bulk with the picture off.
131
+ // Before the first call that shows it, it already is; after, this
132
+ // switches it off, fills, and switches it back on at the next
133
+ // vertical blank — one dark frame, what every NES game does on a
134
+ // scene change. Cells are the 28x26 area inside the frame; tile 32
135
+ // is the blank, as putChar's space is.
136
+ let wasShowing: utinyint = renderingEnabled;
137
+ if (wasShowing == 1) {
138
+ memory.write(0x2001, 0x00);
139
+ renderingEnabled = 0; // the PPU is free until this switches it back on
140
+ }
141
+ nesVerticalBlank(); // whatever was printed before this, with the PPU free
142
+ if (border != BorderColor.KEEP) {
143
+ setPalette(0x3F02, border);
144
+ }
145
+ if (background != BackgroundColor.KEEP) {
146
+ setPalette(0x3F00, background);
147
+ }
148
+ for (let row: usmallint = 2; row < 28; row++) {
149
+ fillRun(0x2000 + row * 32 + 2, 28, 32);
150
+ }
151
+ resetScroll();
152
+ if (wasShowing == 1) {
153
+ while (memory.read(0x2002) < 128) {
154
+ }
155
+ renderingEnabled = 1;
156
+ memory.write(0x2001, 0x0A);
157
+ } else {
158
+ showPicture();
159
+ }
160
+ }
161
+
162
+ function setBackground(background: u8): void {
163
+ setPalette(0x3F00, background);
164
+ showPicture();
165
+ }
166
+
167
+ function setBorder(border: u8): void {
168
+ setPalette(0x3F02, border);
169
+ showPicture();
170
+ }
171
+ }
172
+
173
+ // ---- colour names ----------------------------------------------------------
174
+ //
175
+ // The same eight names every machine's screen.8bs exports — Black, White,
176
+ // Red, Cyan, Purple, Green, Blue, Yellow — so a program can write
177
+ // `screen.setColors(BorderColor.BLUE, BackgroundColor.BLACK)` through
178
+ // @8bitscript/screen and get blue on every target. The values are 2C02
179
+ // palette indices, by the hue columns of NESdev's palette chart ($x2 blue,
180
+ // $x4 purple, $x6 red, $x8 yellow, $xA green, $xC cyan; $0F black, $30
181
+ // white), at the mid-brightness row $1x where the hue is unmistakable,
182
+ // except cyan and yellow, whose $1x entries are too dark to read as their
183
+ // names and so come from row $2x. A program that wants a colour not named
184
+ // here passes its own index to setColors() directly. Not visually verified
185
+ // under FCEUX by this project; the chart is NESdev's.
186
+
187
+ export namespace BorderColor {
188
+ const BLACK: utinyint = 0x0F;
189
+ const WHITE: utinyint = 0x30;
190
+ const RED: utinyint = 0x16;
191
+ const CYAN: utinyint = 0x2C;
192
+ const PURPLE: utinyint = 0x14;
193
+ const GREEN: utinyint = 0x1A;
194
+ const BLUE: utinyint = 0x12;
195
+ const YELLOW: utinyint = 0x28;
196
+ // Not a colour: `blank(KEEP, ...)` leaves this one as it is. 255 is
197
+ // a value no register here takes.
198
+ const KEEP: utinyint = 255;
199
+ }
200
+
201
+ export namespace BackgroundColor {
202
+ const BLACK: utinyint = 0x0F;
203
+ const WHITE: utinyint = 0x30;
204
+ const RED: utinyint = 0x16;
205
+ const CYAN: utinyint = 0x2C;
206
+ const PURPLE: utinyint = 0x14;
207
+ const GREEN: utinyint = 0x1A;
208
+ const BLUE: utinyint = 0x12;
209
+ const YELLOW: utinyint = 0x28;
210
+ // Not a colour: `blank(KEEP, ...)` leaves this one as it is. 255 is
211
+ // a value no register here takes.
212
+ const KEEP: utinyint = 255;
213
+ }
package/src/text.8bs ADDED
@@ -0,0 +1,185 @@
1
+ // @8bitscript/nes/text — the NES's implementation of @8bitscript/text.
2
+ //
3
+ // Named by "8bitscript".exports["./text"] in this package's package.json,
4
+ // and what
5
+ //
6
+ // import { text } from "@8bitscript/text";
7
+ //
8
+ // resolves to when the build is for the NES. Built on the PPU port protocol
9
+ // @8bitscript/nes exports — read that file first for the two VRAM rules
10
+ // this one lives by. Every machine's text.8bs exports this same namespace —
11
+ // the same names, the same shapes, ASCII codes, and a cell 0 at the
12
+ // top-left corner inside the border — so a program that imports it from
13
+ // @8bitscript/text draws the same thing on every target.
14
+ import { queueRun, queueByte } from "./index.8bs";
15
+
16
+ // ---- text: the character set is ours to ship --------------------------
17
+ //
18
+ // A Commodore or Atari `text.putChar` writes one byte into screen memory
19
+ // and a character ROM in the machine turns it into pixels. The NES has no
20
+ // character ROM: the PPU fetches every 8x8 tile pattern from the cartridge,
21
+ // so a cartridge that shows text must carry its own. This package does —
22
+ // native/6502/font.s, an 8 KiB CHR-ROM listed in package.json's
23
+ // "8bitscript".native and linked into the .nes image by the 6502 backend
24
+ // (it rides along with every import of this package, its entry or any of
25
+ // its subpaths) — laid out so that tile index == ASCII code.
26
+ // `putChar(cell, 65)` therefore draws an 'A' with no translation table.
27
+ // Space, digits, A-Z and `! , - . : ?` have glyphs; other codes in $20-$5F
28
+ // are blank tiles. Reverse video of that range lives at ASCII+128 ($A0-$DF)
29
+ // so `text.setReverse` is the same `code + 128` as on the Commodores. Tile
30
+ // $80 is the solid frame, not a reverse copy, and +128 of a portable
31
+ // character never lands on it. That set — upper case only — is what every
32
+ // machine's putChar takes, precisely because it is what this font has: the
33
+ // portable character set is the smallest one any target can show.
34
+ //
35
+ // The grid is the area inside the frame screen.8bs draws (see there):
36
+ // 28 columns by 26 rows, 728 cells, cell 0 at nametable row 2, column 2,
37
+ // each row 32 bytes apart in the nametable — cell `n` is nametable address
38
+ // $2000 + (2 + n / 28) * 32 + 2 + n % 28. Nothing shows until
39
+ // screen.setColors() has switched rendering on; until then, writes here
40
+ // are free of any vblank budget (rule 2 in @8bitscript/nes), which is why
41
+ // a program clears and labels first and sets colours last. putColor exists
42
+ // so portable code still compiles and does nothing: the PPU has no
43
+ // per-cell colour — the nearest thing, the attribute table, chooses a
44
+ // palette per 32x32-pixel block, and every block here is palette 0.
45
+
46
+ // ---- print: strings and number fields ------------------------------------
47
+ //
48
+ // `text.print(cell, s)` writes a string's characters into consecutive
49
+ // cells from `cell`, and `text.printNumber(cell, value, width)` writes
50
+ // `value` as exactly `width` decimal digits, zero-padded and right-aligned,
51
+ // so a field on a HUD never shifts columns. Both draw in the current colour —
52
+ // white until `text.setColor(TextColor.CYAN)` changes it, and that one
53
+ // call then colours everything printed after it, on the machines that have
54
+ // per-cell colour. They are also the two
55
+ // functions the compiler's template layout targets: `text.print(0,
56
+ // \`TICK ${ticks:1}\`)` is laid out at compile time into these same calls
57
+ // (see packages/compiler/src/ir).
58
+ //
59
+ // Under them: `locate()` turns a cell into a
60
+ // nametable address without dividing, and a run of text lets the PPU step
61
+ // the address itself.
62
+ // `text.putChar` and `text.putColor` stay the one-cell pokes a caller can
63
+ // build anything from.
64
+ let currentColor: utinyint = 1; // white, until text.setColor() says otherwise
65
+ let currentReverse: bool = false; // until text.setReverse() says otherwise
66
+
67
+ // Reverse video is ASCII+128: the font ships inverted copies of $20-$5F
68
+ // at $A0-$DF. Tile $80 is the drawn border and is not a reverse copy, so
69
+ // the +128 of a portable character never lands on it. A reverse space is
70
+ // a solid block in the text colour.
71
+ function toTile(code: utinyint): utinyint {
72
+ if (currentReverse) {
73
+ return code + 128;
74
+ }
75
+ return code;
76
+ }
77
+
78
+ // The nametable address of a cell — row `cell / 28`, column the rest — and
79
+ // the column back, for a caller that walks on from there. Eight-bit
80
+ // arithmetic only, and no division or multiplication: LLVM-MOS links a
81
+ // 250-byte routine for a 16-bit divide, turns a counted-subtraction loop
82
+ // into one, and calls a 300-cycle multiply for `x * 147` — none of which
83
+ // fits the vertical blank this runs in. A quarter of the cell fits a byte
84
+ // (below 728, q < 182); q / 8 + q / 64 is q / 7 or one or two short, and
85
+ // what is left over says which. Verified for every cell. It opens a run
86
+ // in the package's write queue at that address (see index.8bs: nothing
87
+ // here touches the PPU; waitFrame() delivers the queue in vertical
88
+ // blank), and the run's bytes land in consecutive cells of the row.
89
+ function locate(cell: usmallint): utinyint {
90
+ let q: utinyint = cell / 4;
91
+ let row: utinyint = q / 8 + q / 64;
92
+ let rest: utinyint = q - row * 8 + row; // q - row * 7, without the multiply
93
+ if (rest >= 7) {
94
+ rest = rest - 7;
95
+ row++;
96
+ }
97
+ if (rest >= 7) {
98
+ rest = rest - 7;
99
+ row++;
100
+ }
101
+ let col: utinyint = rest * 4 + cell % 4;
102
+ queueRun(0x2042 + row * 32 + col);
103
+ return col;
104
+ }
105
+
106
+ // ---- digits -------------------------------------------------------------
107
+ //
108
+ // A number is written one place at a time, high to low, by subtracting the
109
+ // place value until it no longer fits: the 6502 has no divide instruction,
110
+ // and `value / 10` would link a 250-byte routine to do it. Places above the
111
+ // field are still taken off, so a field narrower than its number shows the
112
+ // low digits; places the number does not reach print as zeros.
113
+ const DIGIT_PLACES: array<usmallint, 5> = [10000, 1000, 100, 10, 1];
114
+
115
+ export namespace text {
116
+ const CELL_COUNT: usmallint = 728; // 28 columns x 26 rows, inside the frame
117
+ const COLUMNS: utinyint = 28; // cells per row, so cell = y * text.COLUMNS + x
118
+
119
+ function putChar(cell: usmallint, code: utinyint): void {
120
+ locate(cell);
121
+ queueByte(toTile(code));
122
+ }
123
+
124
+ function putColor(cell: usmallint, color: utinyint): void {
125
+ // Deliberately empty — see above.
126
+ }
127
+
128
+ function setColor(color: utinyint): void {
129
+ currentColor = color;
130
+ }
131
+
132
+ function setReverse(on: bool): void {
133
+ currentReverse = on;
134
+ }
135
+
136
+ function print(cell: usmallint, s: string): void {
137
+ let col: utinyint = locate(cell);
138
+ for (let i: utinyint = 0; i < s.length; i++) {
139
+ queueByte(toTile(s[i]));
140
+ col++;
141
+ if (col == text.COLUMNS) {
142
+ col = locate(cell + i + 1); // the next row is not the next address
143
+ }
144
+ }
145
+ }
146
+
147
+ function printNumber(cell: usmallint, value: usmallint, width: utinyint): void {
148
+ let col: utinyint = locate(cell);
149
+ let k: utinyint = width;
150
+ if (k < DIGIT_PLACES.length) {
151
+ k = DIGIT_PLACES.length;
152
+ }
153
+ while (k > 0) {
154
+ let digit: utinyint = 48; // '0'
155
+ if (k <= DIGIT_PLACES.length) {
156
+ while (value >= DIGIT_PLACES[DIGIT_PLACES.length - k]) {
157
+ value = value - DIGIT_PLACES[DIGIT_PLACES.length - k];
158
+ digit++;
159
+ }
160
+ }
161
+ if (k <= width) {
162
+ queueByte(toTile(digit));
163
+ col++;
164
+ if (col == text.COLUMNS) {
165
+ col = locate(cell + width - k + 1); // the next row is not the next address
166
+ }
167
+ }
168
+ k--;
169
+ }
170
+ }
171
+ }
172
+
173
+ // The colours `text.setColor()` takes. The NES text grid has no per-cell colour, so the
174
+ // names are here for a program that compiles everywhere and their values
175
+ // go nowhere.
176
+ export namespace TextColor {
177
+ const BLACK: utinyint = 0;
178
+ const WHITE: utinyint = 1;
179
+ const RED: utinyint = 2;
180
+ const CYAN: utinyint = 3;
181
+ const PURPLE: utinyint = 4;
182
+ const GREEN: utinyint = 5;
183
+ const BLUE: utinyint = 6;
184
+ const YELLOW: utinyint = 7;
185
+ }