diff --git a/web-ui/arduino-emulator.html b/web-ui/arduino-emulator.html
index 1063abb..c91fb10 100644
--- a/web-ui/arduino-emulator.html
+++ b/web-ui/arduino-emulator.html
@@ -52,6 +52,12 @@
.lcd-off .lcd-cell { color:#0d1a05; background:#0d1a05; }
.lcd-off .lcd-wrap { filter:brightness(.4); }
+ /* SSD1306 OLED */
+ .oled-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid #1e1e36; }
+ .oled-section-title { font-size:10px; color:var(--muted); margin-bottom:8px; font-weight:600; letter-spacing:.08em; }
+ .oled-wrap { background:#000; border:1px solid #333; border-radius:4px; padding:4px; display:inline-block; }
+ #oled-canvas { display:block; image-rendering:pixelated; width:256px; height:128px; }
+
/* Pin grid */
.pins-section { padding:10px 14px; flex:1; overflow-y:auto; }
.pins-header { font-size:10px; color:var(--muted); margin-bottom:8px; font-weight:600; letter-spacing:.08em; }
@@ -161,6 +167,12 @@
+
+
LCD ●
@@ -389,16 +401,171 @@ function lcdOnI2CByte(byte) {
}
}
+// ── SSD1306 OLED emulator ─────────────────────────────────────────────────────
+// I2C addresses: 0x3C (SA0=0) or 0x3D (SA0=1)
+const OLED_ADDRS = [0x3C, 0x3D];
+const oled = {
+ gddram: new Uint8Array(128 * 8), // 128 cols × 8 pages (each page = 8 rows)
+ displayOn: false, inverse: false,
+ addrMode: 0x02, // 0=horizontal, 1=vertical, 2=page (default)
+ col: 0, page: 0,
+ colStart: 0, colEnd: 127,
+ pageStart: 0, pageEnd: 7,
+ // I2C parse state
+ _ctrlByte: null, _active: false,
+};
+
+let oledCtx = null;
+let oledDirty = false;
+
+function oledReset() {
+ oled.gddram.fill(0);
+ oled.displayOn = false; oled.inverse = false; oled.addrMode = 0x02;
+ oled.col = 0; oled.page = 0;
+ oled.colStart = 0; oled.colEnd = 127;
+ oled.pageStart = 0; oled.pageEnd = 7;
+ oled._ctrlByte = null; oled._active = false;
+ document.getElementById('oled-section').style.display = 'none';
+ oledDirty = false;
+}
+
+function oledFlushCanvas() {
+ if (!oledCtx || !oledDirty) return;
+ oledDirty = false;
+ const img = oledCtx.createImageData(128, 64);
+ const fg = oled.inverse ? 0 : 255;
+ const bg = oled.inverse ? 255 : 0;
+ for (let page = 0; page < 8; page++) {
+ for (let col = 0; col < 128; col++) {
+ const byte = oled.gddram[page * 128 + col];
+ for (let bit = 0; bit < 8; bit++) {
+ const row = page * 8 + bit;
+ const idx = (row * 128 + col) * 4;
+ const on = (byte >> bit) & 1;
+ img.data[idx] = on ? fg : bg;
+ img.data[idx + 1] = on ? (oled.inverse ? 0 : 0x62) : bg; // slight blue tint when on
+ img.data[idx + 2] = on ? (oled.inverse ? 0 : 0xff) : bg;
+ img.data[idx + 3] = 255;
+ }
+ }
+ }
+ oledCtx.putImageData(img, 0, 0);
+}
+
+function oledWriteData(byte) {
+ oled.gddram[oled.page * 128 + oled.col] = byte;
+ oledDirty = true;
+ if (oled.addrMode === 0x00) { // horizontal
+ oled.col++;
+ if (oled.col > oled.colEnd) {
+ oled.col = oled.colStart;
+ oled.page++;
+ if (oled.page > oled.pageEnd) oled.page = oled.pageStart;
+ }
+ } else if (oled.addrMode === 0x01) { // vertical
+ oled.page++;
+ if (oled.page > oled.pageEnd) {
+ oled.page = oled.pageStart;
+ oled.col++;
+ if (oled.col > oled.colEnd) oled.col = oled.colStart;
+ }
+ } else { // page
+ oled.col = (oled.col + 1) & 0x7F;
+ }
+}
+
+function oledCommand(cmd, stream) {
+ // single-byte commands
+ if (cmd === 0xAE) { oled.displayOn = false; return 0; }
+ if (cmd === 0xAF) {
+ oled.displayOn = true;
+ document.getElementById('oled-section').style.display = 'block';
+ oled._active = true;
+ return 0;
+ }
+ if (cmd === 0xA6) { oled.inverse = false; oledDirty = true; return 0; }
+ if (cmd === 0xA7) { oled.inverse = true; oledDirty = true; return 0; }
+ if (cmd === 0x2E || cmd === 0x2F) return 0; // scroll stop/start — ignore
+ if ((cmd & 0xF0) === 0xB0) { oled.page = cmd & 0x07; return 0; } // set page (page mode)
+ if ((cmd & 0xF0) === 0x00) { oled.col = (oled.col & 0xF0) | (cmd & 0x0F); return 0; } // col low
+ if ((cmd & 0xF0) === 0x10) { oled.col = (oled.col & 0x0F) | ((cmd & 0x0F) << 4); return 0; } // col high
+ // two-byte commands (consume next byte from stream)
+ if (cmd === 0x81) return 1; // contrast — skip 1
+ if (cmd === 0xD5 || cmd === 0xD9 || cmd === 0xDA || cmd === 0xDB || cmd === 0xD3 || cmd === 0xA8) return 1;
+ if (cmd === 0x8D) return 1; // charge pump
+ // three-byte commands
+ if (cmd === 0x20) { // addressing mode
+ if (stream.length >= 1) { oled.addrMode = stream[0] & 0x03; return 1; }
+ return 1;
+ }
+ if (cmd === 0x21) { // set column address
+ if (stream.length >= 2) { oled.colStart = stream[0]; oled.colEnd = stream[1]; oled.col = oled.colStart; return 2; }
+ return 2;
+ }
+ if (cmd === 0x22) { // set page address
+ if (stream.length >= 2) { oled.pageStart = stream[0]; oled.pageEnd = stream[1]; oled.page = oled.pageStart; return 2; }
+ return 2;
+ }
+ if (cmd === 0x40) { oled.col = 0; oled.page = 0; return 0; } // display start line
+ return 0;
+}
+
+// I2C receive state for SSD1306
+let _oledI2cBuf = [];
+let _oledI2cMode = null; // null=await control byte, 'cmd'=command stream, 'data'=data stream
+let _oledCmdPending = null; // [cmd, bytesLeft]
+
+function oledOnI2CByte(byte) {
+ if (_oledI2cMode === null) {
+ // Control byte: Co=bit7, D/C#=bit6
+ // 0x00 = stream of commands, 0x40 = stream of data, 0x80 = single cmd, 0xC0 = single data
+ const co = (byte >> 7) & 1;
+ const dc = (byte >> 6) & 1;
+ _oledI2cMode = dc ? 'data' : 'cmd';
+ _oledCmdPending = null;
+ return;
+ }
+ if (_oledI2cMode === 'data') {
+ oledWriteData(byte);
+ return;
+ }
+ // command mode
+ if (_oledCmdPending) {
+ _oledCmdPending.args.push(byte);
+ _oledCmdPending.left--;
+ if (_oledCmdPending.left === 0) {
+ oledCommand(_oledCmdPending.cmd, _oledCmdPending.args);
+ _oledCmdPending = null;
+ }
+ return;
+ }
+ const skip = oledCommand(byte, []);
+ if (skip > 0) _oledCmdPending = { cmd: byte, left: skip, args: [] };
+}
+
+function oledOnI2CStart() {
+ _oledI2cMode = null;
+ _oledCmdPending = null;
+}
+
function setupTWI(cpu) {
if (!AVRTWI || !twiConfig) return;
try {
const twi = new AVRTWI(cpu, twiConfig, 16e6);
+ let currentSlave = null;
twi.eventHandler = {
start(restart) { return true; },
connectToSlave(address, write) {
- return LCD_ADDRS.includes(address >> 1);
+ const addr7 = address >> 1;
+ if (LCD_ADDRS.includes(addr7)) { currentSlave = 'lcd'; return true; }
+ if (OLED_ADDRS.includes(addr7)) { currentSlave = 'oled'; oledOnI2CStart(); return true; }
+ currentSlave = null; return false;
+ },
+ writeByte(byte) {
+ if (currentSlave === 'lcd') lcdOnI2CByte(byte);
+ if (currentSlave === 'oled') oledOnI2CByte(byte);
+ return true;
},
- writeByte(byte) { lcdOnI2CByte(byte); return true; },
readByte() { return 0xFF; },
};
} catch(e) {
@@ -535,6 +702,8 @@ function initEmulator(hex) {
totalCycles = 0;
lcdReset();
+ oledReset();
+ oledCtx = document.getElementById('oled-canvas').getContext('2d');
buildPinUI();
let lastD13 = -1;
@@ -564,6 +733,7 @@ function runFrame() {
}
totalCycles = cpu.cycles;
updatePinUI();
+ oledFlushCanvas();
const simSec = (totalCycles / 16e6).toFixed(2);
document.getElementById('footer-cycles').textContent = `${Math.round(totalCycles).toLocaleString()} cycles (${simSec}s)`;
rafId = requestAnimationFrame(runFrame);