diff --git a/src/term/escape/decode.rs b/src/term/escape/decode.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9ce4e1ba725f8170d112bc2ab94a07e46b25deb6
--- /dev/null
+++ b/src/term/escape/decode.rs
@@ -0,0 +1,108 @@
+use crate::video::framebuffer::Color;
+
+/// foreground color can be changed like this: "\x1B[38;2;<r>;<g>;<b>m"
+/// background color can be changed like this: "\x1B[48;2;<r>;<g>;<b>m"
+///
+/// THESE ARE NON STANDARD ESCAPE SEQUENCES
+pub struct EscapeDecoder {
+    buf: [u8; LONGEST_ESCAPE],
+    len: u8,
+}
+
+pub enum DecodedPart {
+    Byte(u8),
+
+    /// Null terminated
+    Bytes([u8; LONGEST_ESCAPE]),
+
+    FgColor(Color),
+    BgColor(Color),
+    Reset,
+
+    None,
+}
+
+//
+
+impl EscapeDecoder {
+    pub const fn new() -> Self {
+        Self {
+            buf: [0; LONGEST_ESCAPE],
+            len: 0,
+        }
+    }
+
+    pub fn next(&mut self, byte: u8) -> DecodedPart {
+        match (self.len, byte) {
+            (0, b'\x1B') => {
+                self.len += 1;
+                self.buf[0 as usize] = byte;
+                DecodedPart::None
+            }
+            (0, _) => DecodedPart::Byte(byte),
+            (1, b'[') => {
+                self.len += 1;
+                self.buf[1 as usize] = byte;
+                DecodedPart::None
+            }
+            (i, b'm') => {
+                self.len += 1;
+                self.buf[i as usize] = byte;
+
+                // crate::qemu::_print(format_args_nl!(
+                //     "seq part: {:?}",
+                //     core::str::from_utf8(&self.buf[..self.len as usize])
+                // ));
+
+                let result = match self.buf[..self.len as usize] {
+                    [b'\x1B', b'[', b'3', b'8', b';', b'2', b';', ref rgb @ .., b'm'] => {
+                        Self::parse_rgb_part(rgb).map(DecodedPart::FgColor)
+                    }
+                    [b'\x1B', b'[', b'4', b'8', b';', b'2', b';', ref rgb @ .., b'm'] => {
+                        Self::parse_rgb_part(rgb).map(DecodedPart::BgColor)
+                    }
+                    [b'\x1B', b'[', b'm'] => Some(DecodedPart::Reset),
+                    _ => None,
+                };
+
+                if let Some(result) = result {
+                    self.clear();
+                    result
+                } else {
+                    self.clear()
+                }
+            }
+            (i @ LONGEST_ESCAPE_PREV_U8.., _) => {
+                self.len += 1;
+                self.buf[i as usize] = byte;
+                self.clear()
+            }
+            (i, _) => {
+                self.len += 1;
+                self.buf[i as usize] = byte;
+                DecodedPart::None
+            }
+        }
+    }
+
+    pub fn clear(&mut self) -> DecodedPart {
+        self.len = 0;
+        DecodedPart::Bytes(core::mem::take(&mut self.buf))
+    }
+
+    fn parse_rgb_part(rgb: &[u8]) -> Option<Color> {
+        let mut iter = rgb.split(|c| *c == b';');
+        let r = core::str::from_utf8(iter.next()?).ok()?.parse().ok()?;
+        let g = core::str::from_utf8(iter.next()?).ok()?.parse().ok()?;
+        let b = core::str::from_utf8(iter.next()?).ok()?.parse().ok()?;
+        Some(Color::new(r, g, b))
+    }
+}
+
+//
+
+// longest supported: "\x1B[48;2;255;255;255m"
+const LONGEST_ESCAPE: usize = "\x1B[48;2;255;255;255m".len();
+const LONGEST_ESCAPE_PREV: usize = LONGEST_ESCAPE - 1;
+const LONGEST_ESCAPE_U8: u8 = LONGEST_ESCAPE as u8;
+const LONGEST_ESCAPE_PREV_U8: u8 = LONGEST_ESCAPE as u8 - 1;
diff --git a/src/term/escape/encode.rs b/src/term/escape/encode.rs
new file mode 100644
index 0000000000000000000000000000000000000000..23af24261bd95c788950161d491b21cd69f44ded
--- /dev/null
+++ b/src/term/escape/encode.rs
@@ -0,0 +1,62 @@
+use core::fmt;
+
+//
+
+pub trait EscapeEncoder {
+    fn with_escape_code<'a>(&'a self, code: &'a str) -> EncodedPart<'a, Self> {
+        EncodedPart { code, data: self }
+    }
+
+    fn red(&self) -> EncodedPart<Self> {
+        self.with_escape_code("\x1B[38;2;255;0;0m")
+    }
+
+    fn green(&self) -> EncodedPart<Self> {
+        self.with_escape_code("\x1B[38;2;0;255;0m")
+    }
+
+    fn blue(&self) -> EncodedPart<Self> {
+        self.with_escape_code("\x1B[38;2;0;0;255m")
+    }
+
+    fn cyan(&self) -> EncodedPart<Self> {
+        self.with_escape_code("\x1B[38;2;0;255;255m")
+    }
+
+    fn magenta(&self) -> EncodedPart<Self> {
+        self.with_escape_code("\x1B[38;2;255;0;255m")
+    }
+
+    fn yellow(&self) -> EncodedPart<Self> {
+        self.with_escape_code("\x1B[38;2;255;255;0m")
+    }
+}
+
+pub struct EncodedPart<'a, T: ?Sized> {
+    code: &'a str,
+    data: &'a T,
+}
+
+//
+
+impl EscapeEncoder for &str {}
+
+impl<'a, T> fmt::Display for EncodedPart<'a, T>
+where
+    T: fmt::Display,
+{
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}{}\x1B[m", self.code, self.data)
+    }
+}
+
+impl<'a, T> fmt::Debug for EncodedPart<'a, T>
+where
+    T: fmt::Debug,
+{
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.code)?;
+        self.data.fmt(f)?;
+        write!(f, "\x1B[m")
+    }
+}
diff --git a/src/term/escape/mod.rs b/src/term/escape/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1c80d24e06bb2a3618069e70a80a2295a7f1127a
--- /dev/null
+++ b/src/term/escape/mod.rs
@@ -0,0 +1,2 @@
+pub mod decode;
+pub mod encode;
diff --git a/src/term/mod.rs b/src/term/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..46e0349586b866e827594e0b15d82c586038882d
--- /dev/null
+++ b/src/term/mod.rs
@@ -0,0 +1 @@
+pub mod escape;
diff --git a/src/video/font.bmp b/src/video/font.bmp
new file mode 100644
index 0000000000000000000000000000000000000000..d80f083c72b977d9d74b6382872c77f1c010d486
Binary files /dev/null and b/src/video/font.bmp differ
diff --git a/src/video/logger.rs b/src/video/logger.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e1ef1a8300e80dba6404c463c86c8abff76518c1
--- /dev/null
+++ b/src/video/logger.rs
@@ -0,0 +1,137 @@
+use crate::term::escape::decode::{DecodedPart, EscapeDecoder};
+
+use super::{
+    font::FONT,
+    framebuffer::{get_fbo, Color, Framebuffer, FBO},
+};
+use core::fmt::{self, Arguments, Write};
+use spin::{Lazy, Mutex, MutexGuard, Once};
+
+//
+
+pub fn _print(args: Arguments) {
+    _ = WRITER.lock().write_fmt(args)
+}
+
+//
+
+static WRITER: Mutex<Writer> = Mutex::new(Writer::new());
+
+//
+
+struct Writer {
+    cursor: [u16; 2],
+    fg_color: Color,
+    bg_color: Color,
+
+    escapes: EscapeDecoder,
+}
+
+//
+
+impl Writer {
+    pub fn write_bytes(&mut self, bytes: &[u8]) {
+        for byte in bytes {
+            self.write_byte(*byte)
+        }
+    }
+
+    pub fn write_byte(&mut self, byte: u8) {
+        match self.escapes.next(byte) {
+            DecodedPart::Byte(b'\n') => {
+                if let Some(mut fbo) = get_fbo() {
+                    self.new_line(1, &mut fbo)
+                }
+            }
+            DecodedPart::Byte(b'\t') => {
+                self.cursor[0] = (self.cursor[0] / 4 + 1) * 4;
+            }
+
+            DecodedPart::Byte(byte) => self.write_byte_raw(byte),
+            DecodedPart::Bytes(bytes) => bytes
+                .into_iter()
+                .take_while(|b| *b != 0)
+                .for_each(|byte| self.write_byte_raw(byte)),
+
+            DecodedPart::FgColor(color) => self.fg_color = color,
+            DecodedPart::BgColor(color) => self.bg_color = color,
+            DecodedPart::Reset => {
+                self.fg_color = Self::FG_COLOR;
+                self.bg_color = Self::BG_COLOR;
+            }
+
+            DecodedPart::None => {}
+        }
+    }
+
+    pub fn write_byte_raw(&mut self, byte: u8) {
+        if let Some(mut fbo) = get_fbo() {
+            let size = Self::size(&mut fbo);
+            if size[0] == 0 || size[1] == 0 {
+                return;
+            }
+
+            self._write_byte_raw(byte, &mut fbo);
+        }
+    }
+
+    const FG_COLOR: Color = Color::from_hex("#bbbbbb");
+    const BG_COLOR: Color = Color::from_hex("#000000");
+
+    const fn new() -> Self {
+        Self {
+            cursor: [0; 2],
+            fg_color: Self::FG_COLOR,
+            bg_color: Self::BG_COLOR,
+
+            escapes: EscapeDecoder::new(),
+        }
+    }
+
+    fn _write_byte_raw(&mut self, byte: u8, fbo: &mut MutexGuard<Framebuffer>) {
+        let (map, is_double) = FONT[byte as usize];
+
+        // insert a new line if the next character would be off screen
+        if self.cursor[0] + if is_double { 1 } else { 0 } >= Self::size(fbo)[0] {
+            self.new_line(8, fbo);
+        }
+
+        let (x, y) = (self.cursor[0] as usize * 8, self.cursor[1] as usize * 16);
+        self.cursor[0] += if is_double { 2 } else { 1 };
+
+        for (yd, row) in map.into_iter().enumerate() {
+            for xd in 0..if is_double { 16 } else { 8 } {
+                fbo.set(
+                    x + xd,
+                    y + yd,
+                    if (row & 1 << xd) != 0 {
+                        self.fg_color
+                    } else {
+                        self.bg_color
+                    },
+                );
+            }
+        }
+    }
+
+    fn new_line(&mut self, count: u16, fbo: &mut MutexGuard<Framebuffer>) {
+        self.cursor[0] = 0;
+        self.cursor[1] += 1;
+        if self.cursor[1] >= Self::size(fbo)[1] {
+            let scroll_count = count.min(self.cursor[1]);
+            self.cursor[1] -= scroll_count;
+            fbo.scroll(16 * scroll_count as usize);
+        }
+    }
+
+    fn size(fbo: &mut MutexGuard<Framebuffer>) -> [u16; 2] {
+        [(fbo.width / 16) as _, (fbo.height / 16) as _]
+    }
+}
+
+impl fmt::Write for Writer {
+    fn write_str(&mut self, s: &str) -> fmt::Result {
+        self.write_bytes(s.as_bytes());
+        Ok(())
+    }
+}