Index: emu/all.h
===================================================================
--- emu/all.h	(revision a1fd5d571c38a0848da857b71718221bd8d48eff)
+++ emu/all.h	(revision bb4fd0c90d58bb5c8ecd61934d580a4bbdb5e842)
@@ -20,4 +20,5 @@
 #include <stdint.h>
 #include <stdio.h>
+#include <string.h>
 #include <unistd.h>
 
@@ -45,4 +46,7 @@
 #define ARRAY_COUNT(_a) (int32_t)(sizeof (_a) / sizeof (_a)[0])
 
+#define WIN_W (1520 * 2 / 3)
+#define WIN_H (950 * 2 / 3)
+
 extern int32_t sdl_verbose;
 extern int32_t cpu_verbose;
@@ -57,4 +61,7 @@
 extern int32_t led_verbose;
 extern int32_t kbd_verbose;
+
+extern SDL_Window *sdl_win;
+extern SDL_Renderer *sdl_ren;
 
 extern void sdl_init(void);
@@ -93,4 +100,7 @@
 extern void ser_write(uint32_t off, int32_t sz, uint32_t val);
 
+extern void ser_text(SDL_TextInputEvent *ev);
+extern void ser_key(SDL_KeyboardEvent *ev);
+
 extern void mid_init(void);
 extern void mid_quit(void);
Index: emu/cpu.c
===================================================================
--- emu/cpu.c	(revision a1fd5d571c38a0848da857b71718221bd8d48eff)
+++ emu/cpu.c	(revision bb4fd0c90d58bb5c8ecd61934d580a4bbdb5e842)
@@ -92,8 +92,17 @@
 static void hw_init(void)
 {
-	inf("initializing hardware");
+	inf("starting hardware");
 
 	for (int32_t i = 0; i < ARRAY_COUNT(hw_map); ++i) {
 		hw_map[i].init();
+	}
+}
+
+static void hw_quit(void)
+{
+	inf("halting hardware");
+
+	for (int32_t i = 0; i < ARRAY_COUNT(hw_map); ++i) {
+		hw_map[i].quit();
 	}
 }
@@ -611,4 +620,5 @@
 
 	bool run = true;
+	bool down = false;
 
 	while (run) {
@@ -621,6 +631,31 @@
 
 		while (SDL_PollEvent(&ev) > 0) {
-			if (ev.type == SDL_QUIT) {
+			// Work around duplicate key-down events on Linux.
+
+			if (ev.type == SDL_KEYDOWN) {
+				if (down) {
+					continue;
+				}
+
+				down = true;
+			}
+			else if (ev.type == SDL_KEYUP) {
+				down = false;
+			}
+
+			if (ev.type == SDL_QUIT ||
+					(ev.type == SDL_KEYDOWN && ev.key.keysym.sym == SDLK_ESCAPE)) {
 				run = false;
+				continue;
+			}
+
+			if (ev.type == SDL_TEXTINPUT) {
+				ser_text(&ev.text);
+				continue;
+			}
+
+			if (ev.type == SDL_KEYDOWN) {
+				ser_key(&ev.key);
+				continue;
 			}
 		}
@@ -632,3 +667,4 @@
 
 	inf("leaving CPU loop");
-}
+	hw_quit();
+}
Index: emu/sdl.c
===================================================================
--- emu/sdl.c	(revision a1fd5d571c38a0848da857b71718221bd8d48eff)
+++ emu/sdl.c	(revision bb4fd0c90d58bb5c8ecd61934d580a4bbdb5e842)
@@ -24,4 +24,7 @@
 #define ver3(...) _ver(sdl_verbose, 2, __VA_ARGS__)
 
+SDL_Window *sdl_win;
+SDL_Renderer *sdl_ren;
+
 void sdl_init(void)
 {
@@ -36,8 +39,27 @@
 		fail("TTF_Init() failed: %s", TTF_GetError());
 	}
+
+	SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "1");
+
+	sdl_win = SDL_CreateWindow("Emu", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
+			WIN_W, WIN_H, 0);
+
+	if (sdl_win == NULL) {
+		fail("SDL_CreateWindow() failed: %s", SDL_GetError());
+	}
+
+	sdl_ren = SDL_CreateRenderer(sdl_win, -1, 0);
+
+	if (sdl_ren == NULL) {
+		fail("SDL_CreateRenderer() failed: %s", SDL_GetError());
+	}
+
+	SDL_StartTextInput();
 }
 
 void sdl_quit(void)
 {
+	SDL_DestroyRenderer(sdl_ren);
+	SDL_DestroyWindow(sdl_win);
 	TTF_Quit();
 	SDL_Quit();
Index: emu/ser.c
===================================================================
--- emu/ser.c	(revision a1fd5d571c38a0848da857b71718221bd8d48eff)
+++ emu/ser.c	(revision bb4fd0c90d58bb5c8ecd61934d580a4bbdb5e842)
@@ -22,9 +22,212 @@
 #define ver3(...) _ver(ser_verbose, 2, __VA_ARGS__)
 
+#define CON_W 80
+#define CON_H 25
+
+#define CON_BGR 0x00000000
+#define CON_CUR 0x00e87000
+#define CON_FGR ((SDL_Color){ .r = 255, .b = 255, .g = 255, .a = 255 })
+
+#define CON_FONT "ttf/vera-sans-mono.ttf"
+
 int32_t ser_verbose = 0;
 
+static uint8_t mem[CON_H][CON_W + 1];
+
+static TTF_Font *fon;
+static int32_t fon_w, fon_h;
+
+static int32_t sur_w, sur_h;
+static SDL_Surface *sur;
+
+static int32_t cur_x = 0, cur_y = 0;
+
+static void update(void)
+{
+	if (SDL_FillRect(sur, NULL, CON_BGR) < 0) {
+		fail("SDL_FillRect() failed: %s", SDL_GetError());
+	}
+
+	if (SDL_FillRect(sur, &(SDL_Rect){
+		.x = cur_x * fon_w,
+		.y = cur_y * fon_h,
+		.w = fon_w,
+		.h = fon_h
+	}, CON_CUR) < 0) {
+		fail("SDL_FillRect() failed: %s", SDL_GetError());
+	}
+
+	for (int32_t y = 0; y < CON_H; ++y) {
+		SDL_Surface *lin = TTF_RenderText_Blended(fon, (char *)mem[y], CON_FGR);
+
+		if (lin == NULL) {
+			fail("TTF_RenderText_Blended() failed: %s", TTF_GetError());
+		}
+
+		if (SDL_BlitSurface(lin, NULL, sur, &(SDL_Rect){
+			.x = 0,
+			.y = y * fon_h,
+			.w = CON_W * fon_w,
+			.h = fon_h
+		})) {
+			fail("SDL_BlitSurface() failed: %s", SDL_GetError());
+		}
+
+		SDL_FreeSurface(lin);
+	}
+
+	SDL_Texture *tex = SDL_CreateTextureFromSurface(sdl_ren, sur);
+
+	if (tex == NULL) {
+		fail("SDL_CreateTextureFromSurface() failed: %s", SDL_GetError());
+	}
+
+	if (SDL_RenderCopy(sdl_ren, tex, NULL, NULL) < 0) {
+		fail("SDL_RenderCopy() failed: %s", SDL_GetError());
+	}
+
+	SDL_DestroyTexture(tex);
+	SDL_RenderPresent(sdl_ren);
+}
+
+static void scroll(void)
+{
+	memmove(mem, mem + 1, (CON_H - 1) * (CON_W + 1));
+	memset(mem + (CON_H - 1), ' ', CON_W);
+}
+
+static void forw(void)
+{
+	if (cur_x < CON_W - 1) {
+		++cur_x;
+		return;
+	}
+
+	if (cur_y == CON_H - 1) {
+		cur_x = 0;
+		scroll();
+		return;
+	}
+
+	cur_x = 0;
+	++cur_y;
+}
+
+static void back(void)
+{
+	if (cur_x > 0) {
+		--cur_x;
+		return;
+	}
+
+	if (cur_y == 0) {
+		return;
+	}
+
+	cur_x = CON_W - 1;
+	--cur_y;
+}
+
+static void down(void)
+{
+	if (cur_y < CON_H - 1) {
+		++cur_y;
+		return;
+	}
+
+	scroll();
+}
+
+static void echo(uint8_t c)
+{
+	switch (c) {
+	case '\r':
+		cur_x = 0;
+		break;
+
+	case '\n':
+		down();
+		break;
+
+	case '\b':
+		back();
+		break;
+
+	default:
+		if (c < 32) {
+			echo('^');
+			echo((uint8_t)(c + '@'));
+			return;
+		}
+
+		mem[cur_y][cur_x] = c;
+		forw();
+		break;
+	}
+
+	update();
+}
+
+void ser_key(SDL_KeyboardEvent *ev)
+{
+	switch (ev->keysym.sym) {
+	case SDLK_BACKSPACE:
+		echo('\b');
+		break;
+
+	case SDLK_RETURN:
+		echo('\r');
+		echo('\n');
+		break;
+	}
+}
+
+void ser_text(SDL_TextInputEvent *ev)
+{
+	for (int32_t i = 0; ev->text[i] != 0; ++i) {
+		echo((uint8_t)ev->text[i]);
+	}
+}
+
 void ser_init(void)
 {
 	ver("ser init");
+
+	SDL_RWops *ops = SDL_RWFromFile(CON_FONT, "rb");
+
+	if (ops == NULL) {
+		fail("error while opening font file " CON_FONT ": %s", SDL_GetError());
+	}
+
+	fon = TTF_OpenFontRW(ops, 1, 32);
+
+	if (fon == NULL) {
+		fail("error while loading font file " CON_FONT ": %s", TTF_GetError());
+	}
+
+	fon_h = TTF_FontLineSkip(fon);
+
+	if (TTF_GlyphMetrics(fon, 'X', NULL, NULL, NULL, NULL, &fon_w) < 0) {
+		fail("error while measuring font width: %s", TTF_GetError());
+	}
+
+	sur_w = CON_W * fon_w;
+	sur_h = CON_H * fon_h;
+
+	sur = SDL_CreateRGBSurface(0, sur_w, sur_h, 32, 0, 0, 0, 0);
+
+	if (sur == NULL) {
+		fail("SDL_CreateRGBSurface() failed: %s", SDL_GetError());
+	}
+
+	for (int32_t y = 0; y < CON_H; ++y) {
+		for (int32_t x = 0; x < CON_W; ++x) {
+			mem[y][x] = ' ';
+		}
+
+		mem[y][CON_W] = 0;
+	}
+
+	update();
 }
 
@@ -32,4 +235,7 @@
 {
 	ver("ser quit");
+
+	SDL_FreeSurface(sur);
+	TTF_CloseFont(fon);
 }
 
Index: misc/buchla.supp
===================================================================
--- misc/buchla.supp	(revision a1fd5d571c38a0848da857b71718221bd8d48eff)
+++ misc/buchla.supp	(revision bb4fd0c90d58bb5c8ecd61934d580a4bbdb5e842)
@@ -27,2 +27,8 @@
 }
 
+{
+	sdl_init
+	Memcheck:Leak
+	...
+	fun:sdl_init
+}
