diff --git a/docs/badge/firmware-development.rst b/docs/badge/firmware-development.rst
index 65579de0eede0d11049c08ea03ebbb9a45bdae2c..f3338b31e5e9ae525c45e28074c82afa7fce21ae 100644
--- a/docs/badge/firmware-development.rst
+++ b/docs/badge/firmware-development.rst
@@ -53,7 +53,7 @@ For running the simulator, you'll need Python 3 with pygame and wasmer:
 ::
 
 	$ python3 -m venv venv
-	$ venv/bin/pip install pygame requests
+	$ venv/bin/pip install pygame requests pymad
         $ venv/bin/pip install wasmer wasmer-compiler-cranelift
 
 .. warning::
diff --git a/docs/badge/programming.rst b/docs/badge/programming.rst
index 57b99928f7588070727ed9146f494fdf6c1f5297..d504edcf1528d3c536f4d7595b2096ac63739e32 100644
--- a/docs/badge/programming.rst
+++ b/docs/badge/programming.rst
@@ -735,9 +735,8 @@ Currently the simulator supports the display, LEDs, the buttons, accelerometer
 (in 2D) and some static input values from the gyroscope, temperature sensor and
 pressure sensor.
 
-It does **not** support any audio API, and in fact currently doesn't even stub
-out the relevant API methods, so it will crash when attempting to run any Music
-app. It also does not support positional captouch APIs.
+It does **not** support most of the audio APIs. It also does not support
+positional captouch APIs.
 
 To set the simulator up, clone the repository and prepare a Python virtual
 environment with the required packages:
@@ -747,7 +746,7 @@ environment with the required packages:
     $ git clone https://git.flow3r.garden/flow3r/flow3r-firmware
     $ cd flow3r-firmware
     $ python3 -m venv venv
-    $ venv/bin/pip install pygame requests
+    $ venv/bin/pip install pygame requests pymad
     $ venv/bin/pip install wasmer wasmer-compiler-cranelift
 
 .. warning::
diff --git a/python_payload/apps/appearance/__init__.py b/python_payload/apps/appearance/__init__.py
index 979081fd6d2c8c5c016500a254d4ec0bce0a4c1f..b00fe99e0b44e850ffdf494d9d63f5ef167cdf4d 100644
--- a/python_payload/apps/appearance/__init__.py
+++ b/python_payload/apps/appearance/__init__.py
@@ -4,6 +4,7 @@ from st3m import settings
 import leds
 import sys_display
 from st3m.ui import colours, led_patterns
+from ctx import Context
 
 
 class App(Application):
diff --git a/python_payload/apps/graphics_mode/__init__.py b/python_payload/apps/graphics_mode/__init__.py
index 1de216533ba85cc2284098c37b189856e0af32b3..39da6c0fec1b846780d928601ce81ec9d17f46c5 100644
--- a/python_payload/apps/graphics_mode/__init__.py
+++ b/python_payload/apps/graphics_mode/__init__.py
@@ -1,5 +1,6 @@
 from st3m.application import Application
 import math, random, sys_display
+from ctx import Context
 
 
 class App(Application):
diff --git a/python_payload/apps/mandelbrot/__init__.py b/python_payload/apps/mandelbrot/__init__.py
index 0de97fb48b3be42019035e12ba80d1e51e9c0c81..d969755240f115f34d380e4adc61036924b3208d 100644
--- a/python_payload/apps/mandelbrot/__init__.py
+++ b/python_payload/apps/mandelbrot/__init__.py
@@ -1,5 +1,6 @@
 from st3m.application import Application
 import sys_display, math, random
+from ctx import Context
 
 
 class App(Application):
@@ -36,7 +37,7 @@ class App(Application):
             if y < height:
                 zy = y * (self.yb - self.ya) / (height - 1) + self.ya
                 inners = 0
-                for x in range(width / 2):
+                for x in range(width // 2):
                     zx = x * (self.xb - self.xa) / (width - 1) + self.xa
                     z = zy + zx * 1j
                     c = z
diff --git a/python_payload/apps/w1f1/k3yboard.py b/python_payload/apps/w1f1/k3yboard.py
index c5333d1b783af816c258f27f67b0cba91a0bddae..a04a342012988139bfa7e781199ab8c5c57b581b 100644
--- a/python_payload/apps/w1f1/k3yboard.py
+++ b/python_payload/apps/w1f1/k3yboard.py
@@ -6,7 +6,7 @@ from ctx import Context
 import st3m.run
 from st3m import Responder, InputState
 from st3m.application import Application, ApplicationContext
-from st3m.goose import ABCBase, Enum
+from st3m.goose import ABCBase, Enum, Optional
 from st3m.ui.view import BaseView, ViewManager
 from st3m.utils import tau
 
@@ -57,7 +57,7 @@ class TextInputModel(Model):
     def input_right(self) -> str:
         return self._input_right
 
-    def move_cursor(self, direction: self.CursorDirection) -> None:
+    def move_cursor(self, direction: "TextInputModel.CursorDirection") -> None:
         """
         Moves the cursor one step in specified direction. Any pending input will be committed beforehand.
         """
diff --git a/python_payload/apps/wurzelitzer/__init__.py b/python_payload/apps/wurzelitzer/__init__.py
index 3d6d207acb64d865ae6ecbea875ced2527c9fa08..63e8168e830b441aa029ce4293fd3534e8f1f24a 100644
--- a/python_payload/apps/wurzelitzer/__init__.py
+++ b/python_payload/apps/wurzelitzer/__init__.py
@@ -1,5 +1,6 @@
-import st3m.run, st3m.wifi, media, os, ctx
+import st3m.run, st3m.wifi, media, uos, ctx
 from st3m.application import Application
+from st3m.utils import sd_card_plugged
 
 RADIOSTATIONS = [
     "http://radio-paralax.de:8000/",
@@ -16,14 +17,15 @@ class App(Application):
     def __init__(self, app_ctx):
         super().__init__(app_ctx)
         self._streams = RADIOSTATIONS.copy()
-        for entry in os.ilistdir("/sd/"):
-            if entry[1] == 0x8000:
-                if (
-                    entry[0].endswith(".mp3")
-                    or entry[0].endswith(".mod")
-                    or entry[0].endswith(".mpg")
-                ):
-                    self._streams.append("/sd/" + entry[0])
+        if sd_card_plugged():
+            for entry in uos.ilistdir("/sd/"):
+                if entry[1] == 0x8000:
+                    if (
+                        entry[0].endswith(".mp3")
+                        or entry[0].endswith(".mod")
+                        or entry[0].endswith(".mpg")
+                    ):
+                        self._streams.append("/sd/" + entry[0])
         if len(self._streams) > len(RADIOSTATIONS):
             # skip radio stations, they are available by going back
             self._stream_no = len(RADIOSTATIONS)
diff --git a/python_payload/st3m/application.py b/python_payload/st3m/application.py
index 38c1550b2fc25681f92d50c73b646f6f1ef3705d..6d213c1a5e5c60de5a0bd8cae726dec8c069299f 100644
--- a/python_payload/st3m/application.py
+++ b/python_payload/st3m/application.py
@@ -8,6 +8,7 @@ from st3m.input import InputState
 import st3m.wifi
 from st3m.goose import Optional, List, Dict
 from st3m.logging import Log
+from st3m.utils import is_simulator
 from st3m import settings
 from ctx import Context
 from st3m.ui import led_patterns
@@ -179,11 +180,13 @@ class BundleMetadata:
         containing_path = os.path.dirname(self.path)
         package_name = os.path.basename(self.path)
 
-        if sys.path[1].endswith("python_payload"):
+        if is_simulator():
             # We are in the simulator. Hack around to get this to work.
             prefix = "/flash/sys"
-            assert containing_path.startswith(prefix)
-            containing_path = containing_path.replace(prefix, sys.path[1])
+            if containing_path.startswith(prefix):
+                containing_path = containing_path.replace(prefix, sys.path[1])
+            else:
+                containing_path = containing_path.replace("/flash", "/tmp/flow3r-sim")
 
         new_sys_path = old_sys_path + [containing_path]
         self._sys_path_set(new_sys_path)
@@ -322,6 +325,7 @@ class MenuItemAppLaunch(MenuItem):
                 self._instance = self._bundle.load()
             except BundleLoadException as e:
                 log.error(f"Could not load {self.label()}: {e}")
+                sys.print_exception(e)
                 err = LoadErrorView(e)
                 vm.push(err)
                 return
diff --git a/python_payload/st3m/run.py b/python_payload/st3m/run.py
index dc319b71716d850938cbaa657b09a8435c2baaa2..149847203a5993cb8660680b3049b061d88100ba 100644
--- a/python_payload/st3m/run.py
+++ b/python_payload/st3m/run.py
@@ -22,6 +22,7 @@ from st3m.about import About
 from st3m import settings_menu as settings, logging, processors, wifi
 from st3m.ui import led_patterns
 import st3m.wifi
+import st3m.utils
 
 import captouch, audio, leds, gc, sys_buttons, sys_display, sys_mode, media, bl00mbox
 import os
@@ -182,6 +183,9 @@ def run_app(klass, bundle_path=None):
 
 
 def _yeet_local_changes() -> None:
+    if st3m.utils.is_simulator():
+        # not implemented in simulator
+        return
     os.remove("/flash/sys/.sys-installed")
     machine.reset()
 
diff --git a/python_payload/st3m/ui/elements/overlays.py b/python_payload/st3m/ui/elements/overlays.py
index c95b6e6932c44c2eb43e32356833eb83e07eef05..0651baf49cedd5d312c81c39798b7f3f007a145d 100644
--- a/python_payload/st3m/ui/elements/overlays.py
+++ b/python_payload/st3m/ui/elements/overlays.py
@@ -195,6 +195,7 @@ class Compositor(Responder):
                 self._last_clip.copy(self._clip_rect)
             self._last_enabled = []
         else:
+            self._last_fps_string = ""
             self._clip_rect.clear()
             for i in range(len(self._enabled)):
                 redraw = (
diff --git a/python_payload/st3m/utils.py b/python_payload/st3m/utils.py
index f6747b5058e1ea7b5991ea20547f2ec8c3fca53d..f16295eb23ccd960bfcf2d090b254f8b3dd3e090 100644
--- a/python_payload/st3m/utils.py
+++ b/python_payload/st3m/utils.py
@@ -1,5 +1,6 @@
 import math
 import os
+import sys_kernel
 
 try:
     import inspect
@@ -146,4 +147,8 @@ def sd_card_plugged() -> bool:
         return False
 
 
+def is_simulator() -> bool:
+    return sys_kernel.hardware_version() == "simulator"
+
+
 tau = math.pi * 2
diff --git a/sim/fakes/_sim.py b/sim/fakes/_sim.py
index ead1033d92e8ab4afd1514b3f1da170ecc8e83c9..0881f3136b1f2ec7160f25884dd1c16597bf8776 100644
--- a/sim/fakes/_sim.py
+++ b/sim/fakes/_sim.py
@@ -25,6 +25,23 @@ SCREENSHOT = False
 SCREENSHOT_DELAY = 5
 
 
+def path_replace(p):
+    simpath = "/tmp/flow3r-sim"
+    projectpath = os.path.dirname(
+        os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
+    )
+    if p.startswith("/flash/sys"):
+        p = p[len("/flash/sys") :]
+        p = projectpath + "/python_payload" + p
+        return p
+    if p.startswith("/flash"):
+        p = p[len("/flash") :]
+        p = simpath + p
+        return p
+
+    return p
+
+
 class Input:
     """
     Input implements an input overlay (for petals or buttons) that can be
@@ -83,6 +100,8 @@ class Input:
         if ev.type == pygame.QUIT:
             pygame.quit()
             sys.exit()
+        if ev.type == pygame.USEREVENT:
+            _sim.render_gui_lazy()
 
         if prev_hover != self._mouse_hover:
             return True
@@ -512,7 +531,7 @@ class Simulation:
         self._render_oled(self._oled_surface, fb)
 
     def set_led_rgb(self, ix, r, g, b):
-        self.led_state_buf[ix] = (r, g, b)
+        self.led_state_buf[ix] = (r * 255, g * 255, b * 255)
 
     def leds_update(self):
         for i, s in enumerate(_sim.led_state_buf):
@@ -527,11 +546,23 @@ _sim = Simulation()
 class FramebufferManager:
     def __init__(self):
         self._free = []
-        for _ in range(2):
-            fb, c = ctx._wasm.ctx_new_for_framebuffer(240, 240)
+
+        # Significant difference between on-device Ctx and simulation Ctx: we
+        # render to a BRGA8 (24bpp color + 8bpp alpha) buffer instead of 16bpp
+        # RGB565 like the device does. This allows us to directly blit the ctx
+        # framebuffer into pygame's surfaces, which is a _huge_ speed benefit
+        # (difference between ~10FPS and 500+FPS!).
+
+        for _ in range(1):
+            fb, c = ctx._wasm.ctx_new_for_framebuffer(240, 240, 240 * 4, ctx.RGBA8)
             ctx._wasm.ctx_apply_transform(c, 1, 0, 120, 0, 1, 120, 0, 0, 1)
             self._free.append((fb, c))
 
+        self._overlay = ctx._wasm.ctx_new_for_framebuffer(240, 240, 240 * 4, ctx.RGBA8)
+        ctx._wasm.ctx_apply_transform(self._overlay[1], 1, 0, 120, 0, 1, 120, 0, 0, 1)
+
+        self._output = ctx._wasm.ctx_new_for_framebuffer(240, 240, 240 * 4, ctx.BGRA8)
+
     def get(self):
         if len(self._free) == 0:
             return None, None
@@ -543,8 +574,42 @@ class FramebufferManager:
     def put(self, fb, ctx):
         self._free.append((fb, ctx))
 
+    def get_overlay(self):
+        return self._overlay
+
+    def get_output(self, fbp):
+        return self._output
+
+    def draw(self, fb):
+        ctx._wasm.ctx_define_texture(
+            self._output[1], "!fb", 240, 240, 240 * 4, ctx.RGBA8, fb, 0
+        )
+        ctx._wasm.ctx_parse(self._output[1], "compositingMode copy")
+        ctx._wasm.ctx_draw_texture(self._output[1], "!fb", 0, 0, 240, 240)
+
+        if overlay_clip[2] and overlay_clip[3]:
+            ctx._wasm.ctx_define_texture(
+                self._output[1],
+                "!overlay",
+                240,
+                240,
+                240 * 4,
+                ctx.RGBA8,
+                self._overlay[0],
+                0,
+            )
+            ctx._wasm.ctx_parse(self._output[1], "compositingMode sourceOver")
+            ctx._wasm.ctx_draw_texture(self._output[1], "!overlay", 0, 0, 240, 240)
+
 
 fbm = FramebufferManager()
+overlay_ctxs = []
+overlay_clip = (0, 0, 240, 240)
+
+
+def set_overlay_clip(x, y, x2, y2):
+    global overlay_clip
+    overlay_clip = (x, y, x2 - x, y2 - y)
 
 
 def get_ctx():
@@ -554,19 +619,32 @@ def get_ctx():
 
 def get_overlay_ctx():
     dctx = ctx._wasm.ctx_new_drawlist(240, 240)
+    overlay_ctxs.append(dctx)
     return ctx.Context(dctx)
 
 
 def display_update(subctx):
     _sim.process_events()
+
+    if subctx._ctx in overlay_ctxs:
+        overlay_ctxs.remove(subctx._ctx)
+        fbp, c = fbm.get_overlay()
+        ctx._wasm.ctx_render_ctx(subctx._ctx, c)
+        ctx._wasm.ctx_destroy(subctx._ctx)
+        return
+
     fbp, c = fbm.get()
+
     if fbp is None:
         return
 
     ctx._wasm.ctx_render_ctx(subctx._ctx, c)
     ctx._wasm.ctx_destroy(subctx._ctx)
 
-    fb = ctx._wasm._i.exports.memory.uint8_view(fbp)
+    fbm.draw(fbp)
+
+    fb = ctx._wasm._i.exports.memory.uint8_view(fbm.get_output(fbp)[0])
+
     _sim.render_display(fb)
     _sim.render_gui_now()
 
diff --git a/sim/fakes/audio.py b/sim/fakes/audio.py
index 1bcae08891e46314834c1499a25f0b332702f310..254ef1040aa284f1d82bf60f6becd3e48ac45b75 100644
--- a/sim/fakes/audio.py
+++ b/sim/fakes/audio.py
@@ -1,6 +1,12 @@
 _volume = 0
 _muted = False
 
+INPUT_SOURCE_NONE = None
+INPUT_SOURCE_AUTO = None
+INPUT_SOURCE_HEADSET_MIC = None
+INPUT_SOURCE_LINE_IN = None
+INPUT_SOURCE_ONBOARD_MIC = None
+
 
 def set_volume_dB(v: float) -> None:
     global _volume
@@ -13,7 +19,7 @@ def get_volume_dB() -> float:
 
 
 def get_volume_relative() -> float:
-    return 0
+    return (_volume + 47) / (47 + 14)
 
 
 def headphones_set_volume_dB(v: float) -> None:
@@ -125,3 +131,35 @@ def line_in_get_allowed() -> bool:
 
 def onboard_mic_to_speaker_get_allowed() -> bool:
     return False
+
+
+def input_thru_set_source(source):
+    pass
+
+
+def input_thru_get_source():
+    return None
+
+
+def input_engines_get_source_avail(source):
+    return False
+
+
+def headset_mic_get_allowed():
+    return False
+
+
+def input_engines_get_source():
+    return None
+
+
+def input_thru_get_mute():
+    return False
+
+
+def input_thru_set_mute(mute):
+    pass
+
+
+def input_engines_set_source(source):
+    pass
diff --git a/sim/fakes/bl00mbox.py b/sim/fakes/bl00mbox.py
index a7f292e72284f2240f79138afb07738bd7b8765b..92bedec458c12b623f115564b0f7240b7fbdd35e 100644
--- a/sim/fakes/bl00mbox.py
+++ b/sim/fakes/bl00mbox.py
@@ -1,41 +1,110 @@
-class tinysynth:
-    def __init__(self, a):
-        pass
+import pygame
+from _sim import path_replace
 
-    def decay_ms(self, a):
-        pass
 
-    def decay(self, a):
+class _mock(list):
+    def __init__(self, *args, **kwargs):
         pass
 
-    def waveform(self, a):
-        pass
+    def __getattr__(self, attr):
+        if attr in ["tone", "value"]:
+            return 0
+        if attr in ["trigger_state"]:
+            return lambda *args: 0
+        return _mock()
 
-    def tone(self, note):
-        pass
+    def __call__(self, *args, **kwargs):
+        return _mock()
 
-    def start(self):
-        pass
 
-    def sustain(self, a):
+class Channel:
+    def __init__(self, id):
         pass
 
-    def attack_ms(self, a):
-        pass
+    def new(self, a, *args, **kwargs):
+        return a(self, *args, **kwargs)
 
-    def release_ms(self, a):
+    def clear(self):
         pass
 
-    def volume(self, a):
-        pass
+    mixer = None
+    channel_num = 0
+    volume = 8000
 
-    def stop(self):
-        pass
 
+class _patches(_mock):
+    class sampler(_mock):
+        class Signals(_mock):
+            class Trigger(_mock):
+                def __init__(self, sampler):
+                    self._sampler = sampler
 
-class Channel:
-    def __init__(self, id):
-        pass
+                def start(self):
+                    self._sampler._sound.set_volume(
+                        self._sampler._channel.volume / 32767
+                    )
+                    self._sampler._sound.play()
 
-    def clear(self):
-        pass
+            def __init__(self, sampler):
+                self._sampler = sampler
+                self._trigger = patches.sampler.Signals.Trigger(sampler)
+
+            @property
+            def trigger(self):
+                return self._trigger
+
+            @trigger.setter
+            def trigger(self, val):
+                pass
+
+        def _convert_filename(self, filename):
+            if filename.startswith("/flash/") or filename.startswith("/sd/"):
+                return filename
+            elif filename.startswith("/"):
+                return "/flash/" + filename
+            else:
+                return "/flash/sys/samples/" + filename
+
+        def __init__(self, channel, path):
+            if type(path) == int:
+                self._signals = _mock()
+                return
+            self._sound = pygame.mixer.Sound(path_replace(self._convert_filename(path)))
+            self._signals = patches.sampler.Signals(self)
+            self._channel = channel
+
+        @property
+        def signals(self):
+            return self._signals
+
+
+class _helpers(_mock):
+    def sct_to_note_name(self, sct):
+        sct = sct - 18367 + 100
+        octave = ((sct + 9 * 200) // 2400) + 4
+        tones = ["A", "Bb", "B", "C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab"]
+        tone = tones[(sct // 200) % 12]
+        return tone + str(octave)
+
+    def note_name_to_sct(self, name):
+        tones = ["A", "Bb", "B", "C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab"]
+        semitones = tones.index(name[0])
+        if semitones > 2:
+            semitones -= 12
+        if name[1] == "b":
+            octave = int(name[2:])
+            semitones -= 1
+        elif name[1] == "#":
+            octave = int(name[2:])
+            semitones += 1
+        else:
+            octave = int(name[1:])
+        return 18367 + (octave - 4) * 2400 + (200 * semitones)
+
+    def sct_to_freq(sct):
+        return 440 * 2 ** ((sct - 18367) / 2400)
+
+
+plugins = _mock()
+patches = _patches()
+helpers = _helpers()
diff --git a/sim/fakes/ctx.py b/sim/fakes/ctx.py
index 925271793f7113c333034b43daec7040721ab1a6..608cef0dd03331caf136a8d7b0c63f7796fd0035 100644
--- a/sim/fakes/ctx.py
+++ b/sim/fakes/ctx.py
@@ -46,20 +46,14 @@ class Wasm:
         self._i.exports.ctx_parse(ctx, p)
         self.free(p)
 
-    def ctx_new_for_framebuffer(self, width, height):
+    def ctx_new_for_framebuffer(self, width, height, stride, format):
         """
         Call ctx_new_for_framebuffer, but also first allocate the underlying
         framebuffer and return it alongside the Ctx*.
         """
-        fb = self.malloc(width * height * 4)
-        # Significant difference between on-device Ctx and simulation Ctx: we
-        # render to a BRGA8 (24bpp color + 8bpp alpha) buffer instead of 16bpp
-        # RGB565 like the device does. This allows us to directly blit the ctx
-        # framebuffer into pygame's surfaces, which is a _huge_ speed benefit
-        # (difference between ~10FPS and 500+FPS!).
-        BRGA8 = 5
+        fb = self.malloc(stride * height)
         return fb, self._i.exports.ctx_new_for_framebuffer(
-            fb, width, height, width * 4, BRGA8
+            fb, width, height, stride, format
         )
 
     def ctx_new_drawlist(self, width, height):
@@ -69,6 +63,29 @@ class Wasm:
         args = [float(a) for a in args]
         return self._i.exports.ctx_apply_transform(ctx, *args)
 
+    def ctx_define_texture(self, ctx, eid, *args):
+        s = eid.encode("utf-8")
+        slen = len(s) + 1
+        p = self.malloc(slen)
+        mem = self._i.exports.memory.uint8_view(p)
+        mem[0 : slen - 1] = s
+        mem[slen - 1] = 0
+        res = self._i.exports.ctx_define_texture(ctx, p, *args)
+        self.free(p)
+        return res
+
+    def ctx_draw_texture(self, ctx, eid, *args):
+        s = eid.encode("utf-8")
+        slen = len(s) + 1
+        p = self.malloc(slen)
+        mem = self._i.exports.memory.uint8_view(p)
+        mem[0 : slen - 1] = s
+        mem[slen - 1] = 0
+        args = [float(a) for a in args]
+        res = self._i.exports.ctx_draw_texture(ctx, p, *args)
+        self.free(p)
+        return res
+
     def ctx_text_width(self, ctx, text):
         s = text.encode("utf-8")
         slen = len(s) + 1
@@ -80,15 +97,55 @@ class Wasm:
         self.free(p)
         return res
 
+    def ctx_x(self, ctx):
+        return self._i.exports.ctx_x(ctx)
+
+    def ctx_y(self, ctx):
+        return self._i.exports.ctx_y(ctx)
+
+    def ctx_logo(self, ctx, *args):
+        args = [float(a) for a in args]
+        return self._i.exports.ctx_logo(ctx, *args)
+
     def ctx_destroy(self, ctx):
         return self._i.exports.ctx_destroy(ctx)
 
     def ctx_render_ctx(self, ctx, dctx):
         return self._i.exports.ctx_render_ctx(ctx, dctx)
 
+    def stbi_load_from_memory(self, buf):
+        p = self.malloc(len(buf))
+        mem = self._i.exports.memory.uint8_view(p)
+        mem[0 : len(buf)] = buf
+        wh = self.malloc(4 * 3)
+        res = self._i.exports.stbi_load_from_memory(p, len(buf), wh, wh + 4, wh + 8, 4)
+        whmem = self._i.exports.memory.uint32_view(wh // 4)
+        r = (res, whmem[0], whmem[1], whmem[2])
+        self.free(p)
+        self.free(wh)
+
+        res, w, h, c = r
+        b = self._i.exports.memory.uint8_view(res)
+        if c == 3:
+            return r
+        for j in range(h):
+            for i in range(w):
+                b[i * 4 + j * w * 4 + 0] = int(
+                    b[i * 4 + j * w * 4 + 0] * b[i * 4 + j * w * 4 + 3] / 255
+                )
+                b[i * 4 + j * w * 4 + 1] = int(
+                    b[i * 4 + j * w * 4 + 1] * b[i * 4 + j * w * 4 + 3] / 255
+                )
+                b[i * 4 + j * w * 4 + 2] = int(
+                    b[i * 4 + j * w * 4 + 2] * b[i * 4 + j * w * 4 + 3] / 255
+                )
+        return r
+
 
 _wasm = Wasm()
 
+_img_cache = {}
+
 
 class Context:
     """
@@ -106,9 +163,13 @@ class Context:
     END = "end"
     MIDDLE = "middle"
     BEVEL = "bevel"
+    NONE = "none"
+    COPY = "copy"
 
     def __init__(self, _ctx):
         self._ctx = _ctx
+        self._font_size = 0
+        self._line_width = 0
 
     @property
     def image_smoothing(self):
@@ -116,7 +177,7 @@ class Context:
 
     @image_smoothing.setter
     def image_smoothing(self, v):
-        self._emit(f"imageSmoothing 0")
+        self._emit(f"imageSmoothing {v}")
 
     @property
     def text_align(self):
@@ -134,12 +195,21 @@ class Context:
     def text_baseline(self, v):
         self._emit(f"textBaseline {v}")
 
+    @property
+    def compositing_mode(self):
+        return Context.NONE
+
+    @compositing_mode.setter
+    def compositing_mode(self, v):
+        self._emit(f"compositingMode {v}")
+
     @property
     def line_width(self):
-        return None
+        return self._line_width
 
     @line_width.setter
     def line_width(self, v):
+        self._line_width = v
         self._emit(f"lineWidth {v:.3f}")
 
     @property
@@ -152,10 +222,11 @@ class Context:
 
     @property
     def font_size(self):
-        return None
+        return self._font_size
 
     @font_size.setter
     def font_size(self, v):
+        self._font_size = v
         self._emit(f"fontSize {v:.3f}")
 
     @property
@@ -166,23 +237,43 @@ class Context:
     def global_alpha(self, v):
         self._emit(f"globalAlpha {v:.3f}")
 
+    @property
+    def x(self):
+        return _wasm.ctx_x(self._ctx)
+
+    @property
+    def y(self):
+        return _wasm.ctx_y(self._ctx)
+
     def _emit(self, text):
         _wasm.ctx_parse(self._ctx, text)
 
+    def logo(self, x, y, dim):
+        _wasm.ctx_logo(self._ctx, x, y, dim)
+        return self
+
     def move_to(self, x, y):
-        self._emit(f"moveTo {int(x)} {int(y)}")
+        self._emit(f"moveTo {x:.3f} {y:.3f}")
         return self
 
     def curve_to(self, a, b, c, d, e, f):
-        self._emit(f"curveTo {int(a)} {int(b)} {int(c)} {int(d)}")
+        self._emit(f"curveTo {a:.3f} {b:.3f} {c:.3f} {d:.3f} {e:.3f} {f:.3f}")
+        return self
+
+    def quad_to(self, a, b, c, d):
+        self._emit(f"quadTo {a:.3f} {b:.3f} {c:.3f} {d:.3f}")
         return self
 
     def rel_move_to(self, x, y):
-        self._emit(f"relMoveTo {int(x)} {int(y)}")
+        self._emit(f"relMoveTo {x:.3f} {y:.3f}")
         return self
 
     def rel_curve_to(self, a, b, c, d, e, f):
-        self._emit(f"relCurveTo {int(a)} {int(b)} {int(c)} {int(d)}")
+        self._emit(f"relCurveTo {a:.3f} {b:.3f} {c:.3f} {d:.3f} {e:.3f} {f:.3f}")
+        return self
+
+    def rel_quad_to(self, a, b, c, d):
+        self._emit(f"relQuadTo {a:.3f} {b:.3f} {c:.3f} {d:.3f}")
         return self
 
     def close_path(self):
@@ -190,7 +281,7 @@ class Context:
         return self
 
     def translate(self, x, y):
-        self._emit(f"translate {int(x)} {int(y)}")
+        self._emit(f"translate {x:.3f} {y:.3f}")
         return self
 
     def scale(self, x, y):
@@ -198,7 +289,11 @@ class Context:
         return self
 
     def line_to(self, x, y):
-        self._emit(f"lineTo {int(x)} {int(y)}")
+        self._emit(f"lineTo {x:.3f} {y:.3f}")
+        return self
+
+    def rel_line_to(self, x, y):
+        self._emit(f"relLineTo {x:.3f} {y:.3f}")
         return self
 
     def rotate(self, v):
@@ -236,21 +331,19 @@ class Context:
 
     def round_rectangle(self, x, y, width, height, radius):
         self._emit(
-            f"roundRectangle {int(x)} {int(y)} {int(width)} {int(height)} {radius}"
+            f"roundRectangle {x:.3f} {y:.3f} {width:.3f} {height:.3f} {radius:.3f}"
         )
         return self
 
-    def image(self, path, x, y, width, height):
-        # TODO: replace with base64 encoded, decoded version of image
-        self._emit(f"save")
-        self._emit(f"rectangle {x} {y} {width} {height}")
-        self._emit(f"rgba 0.5 0.5 0.5 0.5")
-        self._emit(f"fill")
-        self._emit(f"rectangle {x} {y} {width} {height}")
-        self._emit(f"gray 1.0")
-        self._emit(f"lineWidth 1")
-        self._emit(f"stroke")
-        self._emit(f"restore")
+    def image(self, path, x, y, w, h):
+        if not path in _img_cache:
+            buf = open(path, "rb").read()
+            _img_cache[path] = _wasm.stbi_load_from_memory(buf)
+        img, width, height, components = _img_cache[path]
+        _wasm.ctx_define_texture(
+            self._ctx, path, width, height, width * components, RGBA8, img, 0
+        )
+        _wasm.ctx_draw_texture(self._ctx, path, x, y, w, h)
         return self
 
     def rectangle(self, x, y, width, height):
@@ -279,7 +372,12 @@ class Context:
         )
         return self
 
-    def add_stop(self, pos, red, green, blue, alpha):
+    def linear_gradient(self, x0, y0, x1, y1):
+        self._emit(f"linearGradient {x0:.3f} {y0:.3f} {x1:.3f} {y1:.3f}")
+        return self
+
+    def add_stop(self, pos, color, alpha):
+        red, green, blue = color
         if red > 1.0 or green > 1.0 or blue > 1.0:
             red /= 255.0
             green /= 255.0
@@ -302,9 +400,13 @@ class Context:
         )
         return self
 
+    def begin_path(self):
+        self._emit(f"beginPath")
+        return self
+
     def arc(self, x, y, radius, arc_from, arc_to, direction):
         self._emit(
-            f"arc {int(x)} {int(y)} {int(radius)} {arc_from:.4f} {arc_to:.4f} {1 if direction else 0}"
+            f"arc {x:.3f} {y:.3f} {radius:.3f} {arc_from:.4f} {arc_to:.4f} {1 if direction else 0}"
         )
         return self
 
@@ -312,7 +414,8 @@ class Context:
         return _wasm.ctx_text_width(self._ctx, text)
 
     def clip(self):
-        return
+        self._emit(f"clip")
+        return self
 
     def get_font_name(self, i):
         return [
@@ -339,3 +442,8 @@ class Context:
         self.line_to(-130, 130)
         self.line_to(-130, 0)
         return self
+
+
+RGBA8 = 4
+BGRA8 = 5
+RGB565_BYTESWAPPED = 7
diff --git a/sim/fakes/gzip.py b/sim/fakes/gzip.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/sim/fakes/leds.py b/sim/fakes/leds.py
index 8f5f837ed685045715a6fb2f903c5dc7ea208859..c650049c5d6fa1099ded219702ff60680c57e345 100644
--- a/sim/fakes/leds.py
+++ b/sim/fakes/leds.py
@@ -1,16 +1,21 @@
 from _sim import _sim
+from sys_colors import hsv_to_rgb
+from math import tau
 
 import pygame
 
 
 def set_rgb(ix, r, g, b):
-    if r > 255:
-        r = 255
-    if g > 255:
-        g = 255
-    if b > 255:
-        b = 255
-    _sim.set_led_rgb(ix, r, g, b)
+    if r > 1:
+        r /= 255
+    if g > 1:
+        g /= 255
+    if b > 1:
+        b /= 255
+    r = min(1.0, max(0.0, r))
+    g = min(1.0, max(0.0, g))
+    b = min(1.0, max(0.0, b))
+    _sim.set_led_rgb(ix, pow(r, 1 / 2.2), pow(g, 1 / 2.2), pow(b, 1 / 2.2))
 
 
 def get_rgb(ix):
@@ -27,30 +32,25 @@ def set_all_rgb(r, g, b):
 
 
 def set_hsv(ix, h, s, v):
-    color = pygame.Color(0)
-    h = int(h)
-    h = h % 360
-    color.hsva = (h, s * 100, v * 100, 1.0)
-    r, g, b = color.r, color.g, color.b
-    r *= 255
-    if r > 255:
-        r = 255
-    g *= 255
-    if g > 255:
-        g = 255
-    b *= 255
-    if b > 255:
-        b = 255
-    _sim.set_led_rgb(ix, r, g, b)
+    set_rgb(ix, *hsv_to_rgb(h / 360 * tau, s, v))
+
+
+def set_all_hsv(h, s, v):
+    for i in range(40):
+        set_hsv(i, h, s, v)
 
 
 def set_slew_rate(b: int):
     pass  # Better a no-op than not implemented at all.
 
 
+def get_slew_rate():
+    return 255
+
+
 def update():
     _sim.leds_update()
-    _sim.render_gui_lazy()
+    pygame.event.post(pygame.event.Event(pygame.USEREVENT, {}))
 
 
 def set_auto_update(b: int):
diff --git a/sim/fakes/machine.py b/sim/fakes/machine.py
index 34d8eb7610ca7b9f6de23e7189c2b911d2b8b775..6f649a4f1c7afd7b824f22ab37d183e6a15ac9fd 100644
--- a/sim/fakes/machine.py
+++ b/sim/fakes/machine.py
@@ -1,3 +1,4 @@
+import os
 import sys
 
 
@@ -22,9 +23,17 @@ class ADC:
         return 3.8e6 / 2
 
 
+class I2C:
+    def __init__(self, chan, freq=None):
+        pass
+
+    def scan(self):
+        return []
+
+
 def reset():
     print("beep boop i have reset")
-    sys.exit(0)
+    os.execv(sys.executable, ["python"] + sys.argv)
 
 
 def disk_mode_flash():
diff --git a/sim/fakes/media.py b/sim/fakes/media.py
index 165690168bab09f1ad7003745149900a3217323b..4e2eb625e9d495a88a2e0c4f1ca6da57cadc6704 100644
--- a/sim/fakes/media.py
+++ b/sim/fakes/media.py
@@ -1,15 +1,57 @@
+import pygame
+from _sim import path_replace
+
+try:
+    import mad
+except ImportError:
+    mad = None
+
+_loaded = False
+_duration = 0
+
+
 def stop():
     """
     Stops media playback, frees resources.
     """
-    pass
+    global _loaded
+    _loaded = False
+    pygame.mixer.music.stop()
+    pygame.mixer.music.unload()
 
 
-def load(path):
+def load(path, paused=False):
     """
     Load path
     """
-    pass
+    global _loaded, _duration
+    if path.startswith(("http://", "https://")):
+        return
+    pygame.mixer.music.load(path_replace(path))
+    pygame.mixer.music.play()
+    if mad:
+        _duration = mad.MadFile(path_replace(path)).total_time()
+    if paused:
+        pygame.mixer.music.pause()
+    _loaded = True
+
+
+def play():
+    if not _loaded:
+        return
+    pygame.mixer.music.unpause()
+
+
+def pause():
+    if not _loaded:
+        return
+    pygame.mixer.music.pause()
+
+
+def is_playing():
+    if not _loaded:
+        return False
+    return pygame.mixer.music.get_busy()
 
 
 def draw(ctx):
@@ -24,3 +66,47 @@ def think(delta_ms):
     Process ms amounts of media, queuing PCM data and preparing for draw()
     """
     pass
+
+
+def set_volume(vol):
+    if not _loaded:
+        return
+    pygame.mixer.music.set_volume(vol)
+
+
+def get_volume():
+    if not _loaded:
+        return 1.0
+    return pygame.mixer.music.get_volume()
+
+
+def get_position():
+    if not _loaded:
+        return 0
+    pos = pygame.mixer.music.get_pos() / 1000
+    if pos < 0:
+        pos = get_duration()
+    return pos
+
+
+def get_time():
+    return get_position()
+
+
+def seek(pos):
+    if not _loaded:
+        return
+    pygame.mixer.music.play()
+    pygame.mixer.music.rewind()
+    if mad:
+        pygame.mixer.music.set_pos(pos * get_duration())
+
+
+def get_duration():
+    if not mad:
+        return 99999
+    return _duration / 1000
+
+
+def is_visual():
+    return False
diff --git a/sim/fakes/network.py b/sim/fakes/network.py
index b0f7197f8010ecc5460dd977ac109acc5398060a..894b585b249fb989fc59326f66796ddbb87fa616 100644
--- a/sim/fakes/network.py
+++ b/sim/fakes/network.py
@@ -1,4 +1,5 @@
 STA_IF = 1
+STAT_CONNECTING = 0
 
 
 def hostname(hostname: str) -> None:
@@ -9,7 +10,7 @@ class WLAN:
     def __init__(self, mode):
         pass
 
-    def active(self, active):
+    def active(self, active=True):
         return True
 
     def scan(self):
@@ -18,5 +19,14 @@ class WLAN:
     def connect(self, ssid, key=None):
         pass
 
+    def disconnect(self):
+        pass
+
     def isconnected(self):
         return True
+
+    def status(self, mode=None):
+        return STAT_CONNECTING
+
+    def config(self, a):
+        return None
diff --git a/sim/fakes/sys_colors.py b/sim/fakes/sys_colors.py
index 6bed2077143c2c04aa33279861dc05147171cf30..e45b9a6959623ec7dcd9ffa01d7d7b7d0f73779c 100644
--- a/sim/fakes/sys_colors.py
+++ b/sim/fakes/sys_colors.py
@@ -1,15 +1,18 @@
 from typing import Tuple
+import pygame
+from math import tau
 
 
 def hsv_to_rgb(h: float, s: float, v: float) -> Tuple[float, float, float]:
-    """
-    Not implemented in sim.
-    """
-    return (0.0, 0.0, 0.0)
+    color = pygame.Color(0)
+    color.hsva = ((h % tau) / tau * 360, s * 100, v * 100, 100)
+    return color.normalize()[:3]
 
 
 def rgb_to_hsv(r: float, g: float, b: float) -> Tuple[float, float, float]:
-    """
-    Not implemented in sim.
-    """
-    return (0.0, 0.0, 0.0)
+    r = min(1, max(0, r))
+    g = min(1, max(0, g))
+    b = min(1, max(0, b))
+    color = pygame.Color(r, g, b)
+    h, s, v, a = color.hsva
+    return (h / 360 * tau, s / 100, v / 100)
diff --git a/sim/fakes/sys_display.py b/sim/fakes/sys_display.py
index a3e201677349b3b726a6ff2dd60c77f272991384..acac485d2af60555c04e7ea215d1fe05fa31d060 100644
--- a/sim/fakes/sys_display.py
+++ b/sim/fakes/sys_display.py
@@ -9,27 +9,67 @@ def pipe_available():
     return True
 
 
-def overlay_clip(x0, y0, x1, y1):
+def get_mode():
+    return osd
+
+
+def set_mode(no):
     pass
 
 
-def get_mode():
-    return 0
+def set_default_mode(no):
+    pass
 
 
-def set_mode(no):
+def set_palette(pal):
     pass
 
 
+def fb(mode):
+    return (bytearray(240 * 240 * 4), 240, 240, 240 * 4)
+
+
+def fps():
+    return 60.0
+
+
 update = _sim.display_update
 get_ctx = _sim.get_ctx
 get_overlay_ctx = _sim.get_overlay_ctx
+overlay_clip = _sim.set_overlay_clip
 osd = 256
 
 
 def ctx(foo):
+    if foo == osd:
+        return _sim.get_overlay_ctx()
     return _sim.get_ctx()
 
 
 def set_backlight(a):
     pass
+
+
+def fbconfig(a, b, c, d):
+    pass
+
+
+default = 0
+rgb332 = 0
+sepia = 0
+cool = 0
+low_latency = 0
+direct_ctx = 0
+lock = 0
+EXPERIMENTAL_think_per_draw = 0
+smart_redraw = 0
+x2 = 0
+x3 = 0
+x4 = 0
+bpp1 = 0
+bpp2 = 0
+bpp4 = 0
+bpp8 = 0
+bpp16 = 0
+bpp24 = 0
+palette = 0
diff --git a/sim/fakes/sys_kernel.py b/sim/fakes/sys_kernel.py
index e134708eb7558f023e2da8e942b49e424a38cc1e..632c8e59b69b1e8e3887b3fa1f269dcebf192e64 100644
--- a/sim/fakes/sys_kernel.py
+++ b/sim/fakes/sys_kernel.py
@@ -35,3 +35,20 @@ def i2c_scan():
 
 def battery_charging():
     return True
+
+
+def firmware_version():
+    return "0.0.0"
+
+
+def hardware_version():
+    return "simulator"
+
+
+class FakeSchedulerSnapshot:
+    def __init__(self):
+        self.tasks = []
+
+
+def scheduler_snapshot():
+    return FakeSchedulerSnapshot()
diff --git a/sim/fakes/sys_scope.py b/sim/fakes/sys_scope.py
index fb446bef77e81c6ac8332aa1edd2365667329541..2802761d56d74735ddc6d795a5afd25477828949 100644
--- a/sim/fakes/sys_scope.py
+++ b/sim/fakes/sys_scope.py
@@ -1,2 +1,5 @@
+from st3m.goose import Optional
+
+
 def get_buffer_x() -> Optional[memoryview]:
-    return None
+    return memoryview(b"")
diff --git a/sim/fakes/urequests.py b/sim/fakes/urequests.py
index 86e6520f3c845ae6ce3111b16ae7604229341fd6..7e8c80ebb97bf06db1679bd1e22164d6158e355c 100644
--- a/sim/fakes/urequests.py
+++ b/sim/fakes/urequests.py
@@ -1,2 +1,19 @@
-from typing import Any
-from requests import Response, request, head, get, post, put, patch, delete
+import requests
+
+
+def mkmock(fn):
+    def mocked(*args, **kwargs):
+        # print(fn, *args, *kwargs)
+        return fn(*args, stream=True)
+
+    return mocked
+
+
+request = mkmock(requests.request)
+head = mkmock(requests.head)
+get = mkmock(requests.get)
+post = mkmock(requests.post)
+put = mkmock(requests.put)
+patch = mkmock(requests.patch)
+delete = mkmock(requests.delete)
+Response = requests.Response
diff --git a/sim/fakes/utarfile.py b/sim/fakes/utarfile.py
index d3dca7651196ee3f2a2de661ac6bc54e8c44cfa4..706b1b0bf0025bde97bbf409c2c66b0c583d71bd 100644
--- a/sim/fakes/utarfile.py
+++ b/sim/fakes/utarfile.py
@@ -1,8 +1,8 @@
 from typing import Any
 import tarfile
 
-DIRTYPE = "dir"
-REGTYPE = "file"
+DIRTYPE = b"5"
+REGTYPE = b"0"
 
 TarInfo = tarfile.TarInfo
 TarFile = tarfile.TarFile
diff --git a/sim/requirements.txt b/sim/requirements.txt
index 42e7cd3c2d45a0c1bec60289f1cefa8ac9269dbd..c82ae356d0f03d2e8ddb6c99ad2682c44b5eb22c 100644
--- a/sim/requirements.txt
+++ b/sim/requirements.txt
@@ -1,4 +1,5 @@
 pygame
 requests
+pymad
 wasmer
 wasmer-compiler-cranelift
diff --git a/sim/run.py b/sim/run.py
index e9436f5eba8c487ffe24e6586fc8f09860baa178..8f26fe483bec67ea9c110ad504632f1cc8ae451f 100755
--- a/sim/run.py
+++ b/sim/run.py
@@ -9,6 +9,7 @@ import os
 import sys
 import builtins
 import argparse
+import traceback
 
 
 projectpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
@@ -16,6 +17,7 @@ projectpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
 import random
 import pygame
 import cmath
+import gzip
 import wasmer
 import wasmer_compiler_cranelift
 
@@ -29,6 +31,10 @@ try:
 except ImportError:
     print("Warning: `requests` is missing so no `urequests` mock will exist")
 
+try:
+    import mad
+except ImportError:
+    print("Warning: `mad` is missing, MP3 support in `media` mock will be limited")
 
 sys_path_orig = sys.path
 
@@ -107,6 +113,8 @@ def _mkmock(fun):
 
 os.listdir = _mkmock(os.listdir)
 os.stat = _mkmock(os.stat)
+os.statvfs = _mkmock(os.statvfs)
+os.mkdir = _mkmock(os.mkdir)
 builtins.open = _mkmock(builtins.open)
 
 orig_stat = os.stat
@@ -121,6 +129,9 @@ def _stat(path):
 os.stat = _stat
 
 
+sys.print_exception = lambda x: print(traceback.format_exc())
+
+
 def sim_main():
     parser = argparse.ArgumentParser()
     parser.add_argument(
diff --git a/sim/wasm/build.sh b/sim/wasm/build.sh
index 8055e671292427304a0621a3d28b7122cc249f0b..43af74969ddd3b6d8afc67a4cdf25b8031b1dbe0 100755
--- a/sim/wasm/build.sh
+++ b/sim/wasm/build.sh
@@ -18,7 +18,7 @@ emcc ctx.c \
     -I ../../components/ctx/ \
     -I ../../components/ctx/fonts/ \
     -D SIMULATOR \
-    -s EXPORTED_FUNCTIONS=_ctx_new_for_framebuffer,_ctx_new_drawlist,_ctx_parse,_ctx_apply_transform,_ctx_text_width,_ctx_render_ctx,_ctx_destroy,_malloc,_free \
+    -s EXPORTED_FUNCTIONS=_ctx_new_for_framebuffer,_ctx_new_drawlist,_ctx_parse,_ctx_apply_transform,_ctx_text_width,_ctx_x,_ctx_y,_ctx_render_ctx,_ctx_logo,_ctx_define_texture,_ctx_draw_texture,_ctx_destroy,_stbi_load_from_memory,_malloc,_free \
     --no-entry -flto -O3 \
     -o ctx.wasm
 
diff --git a/sim/wasm/ctx.wasm b/sim/wasm/ctx.wasm
index 460f6fd617d0926b45c0b341d86fe8e607863c29..a295a5a8d6612e044da65e2653c1afa92ea677a2 100755
Binary files a/sim/wasm/ctx.wasm and b/sim/wasm/ctx.wasm differ