
Hacker News · Mar 2, 2026 · Collected from RSS
Article URL: https://deadlime.hu/en/2026/02/22/computer-generated-dream-world/ Comments URL: https://news.ycombinator.com/item?id=47213866 Points: 32 # Comments: 0
Virtual reality for a 286 processor Nagy Krisztián 22 February 2026 What is "real"? How do you define "real"? If you're talking about what you can feel, what you can smell, taste, and see... then "real" is simply electrical signals interpreted by your brain. — Morpheus, The Matrix If the processor is the brain of the computer, could it also be part of some kind of virtual reality? Simulated memory, software-defined peripherals, artificially generated interrupts. My first computer was a 286 with 1 MB of RAM and a 50 MB HDD (if I remember correctly). So I decided to pick up a 286 processor and try to simulate the rest of the computer around it. Or at least make it to boot up and run some simple assembly code. Two years ago, I ordered two (that's how many came in a package) Harris 80C286-12 processors. My memories are a bit hazy, but I believe the C in its name is important because these are the types that are less sensitive to clock accuracy (the 12 at the end means it likes to run at 12 MHz), and can even be stepped manually. At first, I wasn't too successful with it, and the project ended up in a drawer. Then this year, I picked it up again and tried to figure out where things went wrong. Wiring up The processor fits into a PLCC-68 socket. The pins of the socket are not suitable for plugging in jumper wires directly, so the socket was mounted onto an adapter PCB with jumper-compatible headers. The pinout of both the chip and the socket is included in the datasheet, but the adapter PCB complicates things a bit, so I created a small conversion table to make my life easier. The table also helped identify the various inputs and outputs, which would later be useful when connecting to the Raspberry Pi. As you can see, no fewer than 57 pins are required, which is more than the Pi can provide. The MCP23S17 IO expander came to the rescue. While it wouldn't allow us to drive the processor at the breakneck speed of the supported 12 MHz, fortunately, that's not our goal. The chip contains 16 IO pins, so we'll need four of them. Although each pin can individually be configured as input or output, I tried to group them logically. The expander has side A and side B, each with 8 pins, and the final result looked like this: ┌───┬──┬───┐ ┤ └──┘ ├ ┤ ├ ┤ FLAG ├ ERROR ┤ ├ BUSY ┤ ADDR:100 ├ INTR READY ┤ ├ NMI RESET ┤B A├ PEREQ CLK ┤ ├ HOLD └──────────┘ ┌───┬──┬───┐ HLDA ┤ └──┘ ├ A23 COD/INTA ┤ ├ A22 M/IO ┤ MISC ├ A21 LOCK ┤ ├ A20 BHE ┤ ADDR:011 ├ A19 S1 ┤ ├ A18 S0 ┤B A├ A17 PEACK ┤ ├ A16 └──────────┘ ┌───┬──┬───┐ A8 ┤ └──┘ ├ A7 A9 ┤ ├ A6 A10 ┤ ADDR ├ A5 A11 ┤ ├ A4 A12 ┤ ADDR:010 ├ A3 A13 ┤ ├ A2 A14 ┤B A├ A1 A15 ┤ ├ A0 └──────────┘ ┌───┬──┬───┐ D8 ┤ └──┘ ├ D7 D9 ┤ ├ D6 D10 ┤ DATA ├ D5 D11 ┤ ├ D4 D12 ┤ ADDR:001 ├ D3 D13 ┤ ├ D2 D14 ┤B A├ D1 D15 ┤ ├ D0 └──────────┘ The Pi communicates with the expanders over SPI. Several solutions exist for this. I chose the one where all chips are active simultaneously, and the Pi is sending them messages by their hardware address. The RESET pin (wired with the purple cable) does not need to be controlled by the Pi in this case, but during one of the debugging sessions, I tried it in the hopes that it would help, and it remained that way. Now we just need to connect everything with a truckload of jumper wires, and we could move on to programming. IO Expansion We only need a relatively small portion of the MCP23S17’s capabilities. We just have to configure the direction of the IO pins and read/write the relevant registers. Configuration is done by modifying register values. First, we need to enable the use of hardware addressing. By default, all chips have the address 000, so if we send a register modification to that address (setting the HAEN bit in the IOCON register), hardware addressing will be enabled simultaneously on all four chips. After a few hours (days) of head-scratching, it turned out that this alone is not necessarily sufficient for proper operation. We also need to send the same message to the configured hardware address itself to enable hardware addressing (rather odd, I know). So if, for example, we set the hardware address to 101, we must resend the original register modification message previously sent to 000 to 101 as well. Now that hardware addressing is sorted out, we need to set the IODIRA and IODIRB registers of each chip to the appropriate direction. Because of our grouping, we can configure an entire side at once for reading (11111111) or writing (00000000). Further details can be found in the chip's datasheet. Originally, I started working with a Pi Zero, but eventually settled on a Pi Pico running MicroPython. To manage the expander chips, I created the following small class: class MCP23S17: IODIRA = 0x00 IODIRB = 0x01 IOCON = 0x0B GPIOA = 0x12 GPIOB = 0x13 def __init__(self, address, spi, cs): self.__address = address self.__spi = spi self.__cs = cs def init(self): self.__writeRegister(0b01000000, self.IOCON, 0b00001000) self.writeRegister(self.IOCON, 0b00001000) def writeRegister(self, reg, value): self.__writeRegister(self.__address, reg, value) def readRegister(self, reg): tx = bytearray([self.__address | 1, reg, 0]) rx = bytearray(3) self.__cs.value(0) self.__spi.write_readinto(tx, rx) self.__cs.value(1) return rx[2] def __writeRegister(self, address, reg, value): self.__cs.value(0) self.__spi.write(bytes([address, reg, value])) self.__cs.value(1) In init, you can clearly see that we set the value of the IOCON register twice. We can use the class as follows to communicate with the processor: spi = SPI(0, baudrate=1000000, sck=Pin(2), mosi=Pin(3), miso=Pin(4)) cs = Pin(5, mode=Pin.OUT, value=1) rst = Pin(6, mode=Pin.OUT, value=0) chip_data = MCP23S17(0b01000010, spi, cs) chip_addr = MCP23S17(0b01000100, spi, cs) chip_misc = MCP23S17(0b01000110, spi, cs) chip_flag = MCP23S17(0b01001000, spi, cs) rst.value(1) chip_data.init() chip_addr.init() chip_misc.init() chip_flag.init() chip_data.writeRegister(MCP23S17.IODIRA, 0xff) chip_data.writeRegister(MCP23S17.IODIRB, 0xff) chip_addr.writeRegister(MCP23S17.IODIRA, 0xff) chip_addr.writeRegister(MCP23S17.IODIRB, 0xff) chip_misc.writeRegister(MCP23S17.IODIRA, 0xff) chip_misc.writeRegister(MCP23S17.IODIRB, 0xff) chip_flag.writeRegister(MCP23S17.IODIRA, 0x00) chip_flag.writeRegister(MCP23S17.IODIRB, 0x00) At first, I missed the init calls here and was surprised when nothing worked. Most of the pins are configured for reading; only the flags need to be set to writing. The Initial State Before we can do anything, we need to RESET the processor. For this, the RESET flag must be held active for at least 16 clock cycles, and switching it on and off must be synchronized with the clock flag. First, I created a few constants for the flags to make life easier: # chip_flag GPIOA FLAG_ERROR = 0x20 FLAG_BUSY = 0x10 FLAG_INTR = 0x08 FLAG_NMI = 0x04 FLAG_PEREQ = 0x02 FLAG_HOLD = 0x01 # chip_flag GPIOB FLAG_CLK = 0x80 FLAG_RESET = 0x40 FLAG_READY = 0x20 # chip_misc GPIOB FLAG_PEACK = 0x80 FLAG_S0 = 0x40 FLAG_S1 = 0x20 FLAG_BHE = 0x10 FLAG_LOCK = 0x08 FLAG_M_IO = 0x04 FLAG_COD_INTA = 0x02 FLAG_HLDA = 0x01 It's worth comparing this with the earlier MCP23S17 pin mapping. We treat each group of 8 pins as 8 bits / 1 byte of data. For example, in the byte from the 'misc' chip's GPIOB side, the HLDA flag is the least significant bit, while PEACK is the most significant. PEACK ↓ 10100111 ↑ HLDA With the flags in place, we can perform the RESET: for i in range(17): chip_flag.writeRegister(MCP23S17.GPIOB, FLAG_CLK | FLAG_RESET) time.sleep(0.001) chip_flag.writeRegister(MCP23S17.GPIOB, FLAG_RESET) time.sleep(0.001) The sleep intervals were chosen more or less arbitrarily; we don't have to adhere to any strict timing. During RESET, the processor must enter a defined state. We can verify this with the following piece of code: data = chip_addr.readRegister(MCP23S17.GPIOA) print('A7-0: ' + str(bin(data))) data = chip_addr.readRegister(MCP23S17.GPIOB) print('A15-8: ' + str(bin(data))) data = chip_misc.readRegister(MCP23S17.GPIOA) print('A23-16: ' + str(bin(data))) data = chip_misc.readRegister(MCP23S17.GPIOB) print('PEACK, S0, S1, BHE, LOCK, M/IO, COD/INTA, HLDA: ' + str(bin(data))) The values we expect to see look like this: A7-0: 0b11111111 A15-8: 0b11111111 A23-16: 0b11111111 PEACK, S0, S1, BHE, LOCK, M/IO, COD/INTA, HLDA: 0b11111000 Strangely enough, I was greeted with the following instead: A7-0: 0b11111111 A15-8: 0b11111000 A23-16: 0b11111111 PEACK, S0, S1, BHE, LOCK, M/IO, COD/INTA, HLDA: 0b11111000 It was hard not to notice that the values in the second and fourth lines were identical. I checked all the connections, disassembled everything, debugged with LEDs to ensure the values I wrote were going to the right places, replaced the chip assigned to the A15-8 pins, swapped the processor for the spare, reread the code a thousand times, but nothing helped. Then I found that hardware addressing trick mentioned earlier with the MCP23S17, and everything started to work like magic. The point is, if everything went well, we can release the RESET flag, and the boot process can begin. chip_flag.writeRegister(MCP23S17.GPIOB, FLAG_CLK | FLAG_RESET) time.sleep(0.001) chip_flag.writeRegister(MCP23S17.GPIOB, 0) time.sleep(0.001) Initialization After this, within 50 clock cycles, the processor must begin to read the first instruction to execute from address 0xFFFFF0. The COD/INTA, M/IO, S0, and S1 flags determine what the processor intends to do. COD/INTA M/IO S0 S1 Bus cycle 0 0 0 0 Interrupt acknowledge 0 1 0 0 halt / shutdown 0 1 0 1 Memory data read 0 1 1 0 Memory data write 1 0 0 1 I/O read 1 0 1 0 I/O write 1 1 0 1 Memory instruction read I left out the less interesting ones from the table; they can be viewed in the datasheet. For our small test, we'll only need these four: halt / shutdown memory data write memory data read memory instruction read So we start sending clock signals and wait until we reach the first 'Memory instruction read': cycle = 1 while True: pr