diff --git a/frontend/src/__tests__/pong-emulation.test.ts b/frontend/src/__tests__/pong-emulation.test.ts new file mode 100644 index 0000000..c649ade --- /dev/null +++ b/frontend/src/__tests__/pong-emulation.test.ts @@ -0,0 +1,332 @@ +/** + * pong-emulation.test.ts + * + * Full end-to-end test of the pong Wokwi example: + * + * 1. Compile pong.ino with arduino-cli (arduino:avr:nano) + * 2. Load the resulting .hex into AVRSimulator + * 3. Attach a VirtualSSD1306 to the I2C bus (address 0x3C) + * 4. Run ~1 second of simulated time (16 000 000 cycles) + * 5. Simulate UP/DOWN button presses (D2 / D3, active-LOW INPUT_PULLUP) + * 6. Assert the SSD1306 display has received pixel data + * 7. Print an ASCII-art snapshot of the display to the console + * + * Requirements: + * - arduino-cli in PATH + * - "arduino:avr" core installed + * - Adafruit SSD1306 + Adafruit GFX libraries installed + * + * The test compiles pong once, caches the hex in a temp file, and reuses it. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { spawnSync } from 'child_process'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, readdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; + +import { AVRSimulator } from '../simulation/AVRSimulator'; +import { PinManager } from '../simulation/PinManager'; +import type { I2CDevice } from '../simulation/I2CBusManager'; + +// ─── ImageData polyfill (Node / Vitest) ────────────────────────────────────── + +if (typeof globalThis.ImageData === 'undefined') { + class ImageDataPoly { + readonly width: number; + readonly height: number; + readonly data: Uint8ClampedArray; + constructor(w: number, h: number) { + this.width = w; + this.height = h; + this.data = new Uint8ClampedArray(w * h * 4); + } + } + (globalThis as any).ImageData = ImageDataPoly; +} + +// ─── RAF stub ──────────────────────────────────────────────────────────────── + +vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1); +vi.stubGlobal('cancelAnimationFrame', vi.fn()); + +// ─── Paths ──────────────────────────────────────────────────────────────────── + +const PONG_INO = resolve(__dirname, '../../../example_zip/extracted/pong/pong.ino'); + +// ─── Compile helper ────────────────────────────────────────────────────────── + +const HEX_CACHE = join(tmpdir(), 'velxio-pong-nano.hex'); + +function compileSketch(): string { + // Return cached hex if we already compiled + if (existsSync(HEX_CACHE)) { + console.log('[compile] Using cached hex:', HEX_CACHE); + return readFileSync(HEX_CACHE, 'utf-8'); + } + + console.log('[compile] Compiling pong.ino for Arduino Nano…'); + + // arduino-cli requires: folder name === sketch name (pong/pong.ino) + // Create a temp PARENT, then a "pong" subdirectory inside it + const workDir = mkdtempSync(join(tmpdir(), 'velxio-pong-')); + const sketchDir = join(workDir, 'pong'); + mkdirSync(sketchDir); + writeFileSync(join(sketchDir, 'pong.ino'), readFileSync(PONG_INO, 'utf-8')); + + const buildDir = join(workDir, 'build'); + mkdirSync(buildDir); + + const result = spawnSync( + 'arduino-cli', + [ + 'compile', + '--fqbn', 'arduino:avr:nano:cpu=atmega328old', + '--build-path', buildDir, + sketchDir, + ], + { encoding: 'utf-8', timeout: 120_000 } + ); + + if (result.status !== 0) { + console.error('[compile] stdout:', result.stdout); + console.error('[compile] stderr:', result.stderr); + throw new Error(`arduino-cli failed (exit ${result.status}): ${result.stderr}`); + } + + // Find the .hex file in buildDir (prefer pong.ino.hex, not the bootloader variant) + let hexPath: string | null = null; + for (const f of ['pong.ino.hex', 'sketch.ino.hex', 'pong.hex']) { + const p = join(buildDir, f); + if (existsSync(p)) { hexPath = p; break; } + } + if (!hexPath) { + const files = readdirSync(buildDir, { recursive: true }) as string[]; + const found = files.find((f) => typeof f === 'string' && f.endsWith('.hex') && !f.includes('bootloader')); + if (!found) throw new Error('No .hex found in build output'); + hexPath = join(buildDir, found); + } + + const hex = readFileSync(hexPath, 'utf-8'); + writeFileSync(HEX_CACHE, hex); // cache for next run + rmSync(workDir, { recursive: true }); // cleanup + console.log('[compile] Done. Hex size:', hex.length, 'chars'); + return hex; +} + +// ─── Minimal VirtualSSD1306 (standalone — no DOM/PartRegistry needed) ──────── + +class HeadlessSSD1306 implements I2CDevice { + address = 0x3C; + buffer = new Uint8Array(128 * 8); // GDDRAM 1-bit + renderCount = 0; + + private ctrlByte = true; + private isData = false; + private col = 0; page = 0; + private colStart = 0; colEnd = 127; + private pageStart = 0; pageEnd = 7; + private memMode = 0; + private cmdBuf: number[] = []; + private cmdWant = 0; + + private static cmdParams(cmd: number): number { + if ([0x20, 0x81, 0x8D, 0xA8, 0xD3, 0xD5, 0xD8, 0xD9, 0xDA, 0xDB].includes(cmd)) return 1; + if (cmd === 0x21 || cmd === 0x22) return 2; + return 0; + } + + writeByte(value: number): boolean { + if (this.ctrlByte) { + this.isData = (value & 0x40) !== 0; + this.ctrlByte = false; + this.cmdBuf = []; + this.cmdWant = 0; + return true; + } + if (this.isData) { + this.buffer[this.page * 128 + this.col] = value; + this.advance(); + return true; + } + if (this.cmdWant > 0) { + this.cmdBuf.push(value); this.cmdWant--; + if (this.cmdWant === 0) this.applyCmd(); + return true; + } + this.cmdBuf = [value]; + this.cmdWant = HeadlessSSD1306.cmdParams(value); + if (this.cmdWant === 0) this.applyCmd(); + return true; + } + + private applyCmd(): void { + const [cmd, p1, p2] = this.cmdBuf; + if (cmd === 0x20) { this.memMode = p1 & 0x03; } + else if (cmd === 0x21) { this.colStart = p1 & 0x7F; this.colEnd = p2 & 0x7F; this.col = this.colStart; } + else if (cmd === 0x22) { this.pageStart = p1 & 0x07; this.pageEnd = p2 & 0x07; this.page = this.pageStart; } + } + + private advance(): void { + if (this.memMode === 0) { + if (++this.col > this.colEnd) { this.col = this.colStart; if (++this.page > this.pageEnd) this.page = this.pageStart; } + } else if (this.memMode === 1) { + if (++this.page > this.pageEnd) { this.page = this.pageStart; if (++this.col > this.colEnd) this.col = this.colStart; } + } else { + if (++this.col > this.colEnd) this.col = this.colStart; + } + } + + readByte(): number { return 0xFF; } + + stop(): void { + this.ctrlByte = true; + this.renderCount++; + } + + /** True if any pixel in the buffer is set */ + hasPixels(): boolean { + return this.buffer.some((b) => b !== 0); + } + + /** Count of lit pixels across all 128×64 = 8192 pixels */ + litPixelCount(): number { + let n = 0; + for (let i = 0; i < this.buffer.length; i++) { + let b = this.buffer[i]; + while (b) { n += b & 1; b >>= 1; } + } + return n; + } + + /** Render the GDDRAM as ASCII art (# = lit, space = off) */ + toAsciiArt(): string { + const lines: string[] = []; + for (let row = 0; row < 64; row += 2) { // 2 rows per line to keep compact + let line = ''; + for (let col = 0; col < 128; col++) { + const page = Math.floor(row / 8); + const bit = row % 8; + const lit = (this.buffer[page * 128 + col] >> bit) & 1; + line += lit ? '█' : ' '; + } + lines.push(line); + } + return lines.join('\n'); + } +} + +// ─── Run N cycles ───────────────────────────────────────────────────────────── + +function runCycles(sim: AVRSimulator, cycles: number): void { + for (let i = 0; i < cycles; i++) sim.step(); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('Pong emulation — full end-to-end', () => { + let hexContent: string; + let sim: AVRSimulator; + let oled: HeadlessSSD1306; + + beforeAll(() => { + // Compile (or load cached) + hexContent = compileSketch(); + }); + + afterAll(() => { + try { sim?.stop(); } catch { /* ignore */ } + vi.unstubAllGlobals(); + }); + + it('🔧 compiles pong.ino successfully', () => { + expect(hexContent).toBeTruthy(); + expect(hexContent).toContain(':'); // valid Intel HEX starts with ':' + console.log('[hex] First line:', hexContent.split('\n')[0]); + }); + + it('🖥️ boots: SSD1306 init commands fire in the first 2M cycles', () => { + const pm = new PinManager(); + sim = new AVRSimulator(pm); + sim.loadHex(hexContent); + + oled = new HeadlessSSD1306(); + sim.i2cBus!.addDevice(oled); + + // 2 million cycles ≈ 125ms simulated. + // The SSD1306 init sequence + first display.display() (Adafruit splash) + // completes in this window — gives us ~20-50 I2C STOP events. + runCycles(sim, 2_000_000); + + console.log(`[boot] I2C STOP count after 2M cycles: ${oled.renderCount}`); + expect(oled.renderCount).toBeGreaterThan(20); // init commands alone = ~25+ + }); + + it('🎮 game loop starts after pong\'s 2-second setup wait (~35M more cycles)', () => { + // pong.ino setup(): while(millis() - start < 2000) { } — pure spinning. + // At 16MHz: 2000ms × 16,000 cycles/ms = 32,000,000 cycles to wait through. + // After that, game loop runs: ball updated every 16ms, paddle every 64ms. + const rendersBeforeWait = oled.renderCount; + + runCycles(sim, 35_000_000); // clears 2s spin + a few hundred ms of gameplay + + const newRenders = oled.renderCount - rendersBeforeWait; + console.log(`[game] New renders after passing 2s wait: ${newRenders}`); + console.log(`[game] Total I2C STOP count: ${oled.renderCount}`); + console.log(`[game] Lit pixels: ${oled.litPixelCount()} / 8192`); + + // After the spin loop, setup calls display.display() once more (the court), + // and the game loop starts calling display.display() every 16–64ms. + expect(newRenders).toBeGreaterThan(0); + expect(oled.hasPixels()).toBe(true); + }); + + it('📺 display shows game content ≥50 lit pixels', () => { + console.log(`[display] Lit pixels: ${oled.litPixelCount()} / 8192`); + expect(oled.litPixelCount()).toBeGreaterThan(50); + }); + + it('🕹️ UP button press (D2 LOW, INPUT_PULLUP) updates display', () => { + const pixelsBefore = oled.litPixelCount(); + const rendersBefore = oled.renderCount; + + // Press UP button (D2 = pin 2, active LOW INPUT_PULLUP). + // Run 3M cycles while held (~188ms simulated = ~11 paddle updates @ 64ms each). + sim.setPinState(2, false); // pressed + runCycles(sim, 3_000_000); + sim.setPinState(2, true); // released + runCycles(sim, 500_000); + + const pixelsAfter = oled.litPixelCount(); + console.log(`[UP btn] pixels before: ${pixelsBefore} → after: ${pixelsAfter}`); + console.log(`[UP btn] new renders: ${oled.renderCount - rendersBefore}`); + + // After a button press, the paddle moves and the frame is re-drawn + expect(oled.renderCount).toBeGreaterThan(rendersBefore); + }); + + it('🕹️ DOWN button press (D3 LOW) also triggers display updates', () => { + const rendersBefore = oled.renderCount; + + sim.setPinState(3, false); // DOWN pressed + runCycles(sim, 3_000_000); + sim.setPinState(3, true); // released + runCycles(sim, 500_000); + + console.log(`[DOWN btn] new renders: ${oled.renderCount - rendersBefore}`); + expect(oled.renderCount).toBeGreaterThan(rendersBefore); + }); + + it('🖼️ prints final display frame as ASCII art', () => { + // Run a bit more so we get a fresh full frame + runCycles(sim, 2_000_000); + + const art = oled.toAsciiArt(); + console.log('\n── Pong display snapshot (128×32 ASCII) ──'); + console.log(art); + console.log('──────────────────────────────────────────'); + console.log(`Total lit pixels: ${oled.litPixelCount()}`); + + expect(art.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index 934df43..7e4d0be 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -203,13 +203,34 @@ export class AVRSimulator { this.running = true; console.log('Starting AVR simulation...'); + // ATmega328p @ 16MHz + const CPU_HZ = 16_000_000; + const CYCLES_PER_MS = CPU_HZ / 1000; + + // Cap: never execute more than 50ms worth of cycles in one frame. + // This prevents a runaway burst when the tab was backgrounded and + // then becomes visible again (browser may deliver a huge delta). + const MAX_DELTA_MS = 50; + + let lastTimestamp = 0; let frameCount = 0; - const execute = (_timestamp: number) => { + + const execute = (timestamp: number) => { if (!this.running || !this.cpu) return; - // ATmega328p @ 16MHz = 16M cycles/sec - // At 60fps: 16,000,000 / 60 ≈ 267,000 cycles per frame - const cyclesPerFrame = Math.floor(267000 * this.speed); + // First frame: just record the timestamp and yield + if (lastTimestamp === 0) { + lastTimestamp = timestamp; + this.animationFrame = requestAnimationFrame(execute); + return; + } + + // Clamp delta so we never overshoot after a paused/backgrounded tab + const rawDelta = timestamp - lastTimestamp; + const deltaMs = Math.min(rawDelta, MAX_DELTA_MS); + lastTimestamp = timestamp; + + const cyclesPerFrame = Math.floor(CYCLES_PER_MS * deltaMs * this.speed); try { for (let i = 0; i < cyclesPerFrame; i++) {