diff --git a/crates/livesplit-hotkey/src/key_code.rs b/crates/livesplit-hotkey/src/key_code.rs
index 36c0e9934..edf1b09a2 100644
--- a/crates/livesplit-hotkey/src/key_code.rs
+++ b/crates/livesplit-hotkey/src/key_code.rs
@@ -1,3 +1,6 @@
+use alloc::borrow::Cow;
+#[cfg(not(feature = "std"))]
+use alloc::string::String;
 use core::str::FromStr;
 
 // Based on
@@ -261,6 +264,20 @@ pub enum KeyCode {
     ZoomToggle,
 }
 
+#[derive(Debug, Eq, PartialEq, Hash, Copy, Clone, serde::Serialize, serde::Deserialize)]
+pub enum KeyCodeClass {
+    WritingSystem,
+    Functional,
+    ControlPad,
+    ArrowPad,
+    Numpad,
+    Function,
+    Media,
+    Legacy,
+    Gamepad,
+    NonStandard,
+}
+
 impl KeyCode {
     /// Resolve the KeyCode according to the standard US layout.
     pub fn as_str(self) -> &'static str {
@@ -482,6 +499,88 @@ impl KeyCode {
             ZoomToggle => "Zoom Toggle",
         }
     }
+
+    pub fn classify(self) -> KeyCodeClass {
+        use self::KeyCode::*;
+        match self {
+            // Writing System Keys
+            Backquote | Backslash | Backspace | BracketLeft | BracketRight | Comma | Digit0
+            | Digit1 | Digit2 | Digit3 | Digit4 | Digit5 | Digit6 | Digit7 | Digit8 | Digit9
+            | Equal | IntlBackslash | IntlRo | IntlYen | KeyA | KeyB | KeyC | KeyD | KeyE
+            | KeyF | KeyG | KeyH | KeyI | KeyJ | KeyK | KeyL | KeyM | KeyN | KeyO | KeyP | KeyQ
+            | KeyR | KeyS | KeyT | KeyU | KeyV | KeyW | KeyX | KeyY | KeyZ | Minus | Period
+            | Quote | Semicolon | Slash => KeyCodeClass::WritingSystem,
+
+            // Functional Keys
+            AltLeft | AltRight | CapsLock | ContextMenu | ControlLeft | ControlRight | Enter
+            | MetaLeft | MetaRight | ShiftLeft | ShiftRight | Space | Tab | Convert | KanaMode
+            | Lang1 | Lang2 | Lang3 | Lang4 | Lang5 | NonConvert => KeyCodeClass::Functional,
+
+            // Control Pad Section
+            Delete | End | Help | Home | Insert | PageDown | PageUp => KeyCodeClass::ControlPad,
+
+            // Arrow Pad Section
+            ArrowDown | ArrowLeft | ArrowRight | ArrowUp => KeyCodeClass::ArrowPad,
+
+            // Numpad Section
+            NumLock | Numpad0 | Numpad1 | Numpad2 | Numpad3 | Numpad4 | Numpad5 | Numpad6
+            | Numpad7 | Numpad8 | Numpad9 | NumpadAdd | NumpadBackspace | NumpadClear
+            | NumpadClearEntry | NumpadComma | NumpadDecimal | NumpadDivide | NumpadEnter
+            | NumpadEqual | NumpadHash | NumpadMemoryAdd | NumpadMemoryClear
+            | NumpadMemoryRecall | NumpadMemoryStore | NumpadMemorySubtract | NumpadMultiply
+            | NumpadParenLeft | NumpadParenRight | NumpadStar | NumpadSubtract => {
+                KeyCodeClass::Numpad
+            }
+
+            // Function Section
+            Escape | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10 | F11 | F12 | F13 | F14
+            | F15 | F16 | F17 | F18 | F19 | F20 | F21 | F22 | F23 | F24 | Fn | FnLock
+            | PrintScreen | ScrollLock | Pause => KeyCodeClass::Function,
+
+            // Media Keys
+            BrowserBack | BrowserFavorites | BrowserForward | BrowserHome | BrowserRefresh
+            | BrowserSearch | BrowserStop | Eject | LaunchApp1 | LaunchApp2 | LaunchMail
+            | MediaPlayPause | MediaSelect | MediaStop | MediaTrackNext | MediaTrackPrevious
+            | Power | Sleep | AudioVolumeDown | AudioVolumeMute | AudioVolumeUp | WakeUp => {
+                KeyCodeClass::Media
+            }
+
+            // Legacy, Non-Standard and Special Keys
+            Again | Copy | Cut | Find | Open | Paste | Props | Select | Undo => {
+                KeyCodeClass::Legacy
+            }
+
+            // Gamepad Keys
+            Gamepad0 | Gamepad1 | Gamepad2 | Gamepad3 | Gamepad4 | Gamepad5 | Gamepad6
+            | Gamepad7 | Gamepad8 | Gamepad9 | Gamepad10 | Gamepad11 | Gamepad12 | Gamepad13
+            | Gamepad14 | Gamepad15 | Gamepad16 | Gamepad17 | Gamepad18 | Gamepad19 => {
+                KeyCodeClass::Gamepad
+            }
+
+            // Browser specific Keys
+            BrightnessDown | BrightnessUp | DisplayToggleIntExt | KeyboardLayoutSelect
+            | LaunchAssistant | LaunchControlPanel | LaunchScreenSaver | MailForward
+            | MailReply | MailSend | MediaFastForward | MediaPause | MediaPlay | MediaRecord
+            | MediaRewind | PrivacyScreenToggle | SelectTask | ShowAllWindows | ZoomToggle => {
+                KeyCodeClass::NonStandard
+            }
+        }
+    }
+
+    pub fn resolve(self) -> Cow<'static, str> {
+        let class = self.classify();
+        if class == KeyCodeClass::WritingSystem {
+            if let Some(resolved) = crate::platform::try_resolve(self) {
+                let uppercase = if resolved != "ß" {
+                    resolved.to_uppercase()
+                } else {
+                    resolved
+                };
+                return uppercase.into();
+            }
+        }
+        self.as_str().into()
+    }
 }
 
 impl FromStr for KeyCode {
diff --git a/crates/livesplit-hotkey/src/lib.rs b/crates/livesplit-hotkey/src/lib.rs
index 7df742411..42fffef0c 100644
--- a/crates/livesplit-hotkey/src/lib.rs
+++ b/crates/livesplit-hotkey/src/lib.rs
@@ -2,37 +2,39 @@
 #![recursion_limit = "1024"]
 #![cfg_attr(not(feature = "std"), no_std)]
 
+extern crate alloc;
+
 cfg_if::cfg_if! {
     if #[cfg(not(feature = "std"))] {
         mod other;
-        pub use self::other::*;
+        use self::other as platform;
     } else if #[cfg(windows)] {
         mod windows;
-        pub use self::windows::*;
+        use self::windows as platform;
     } else if #[cfg(target_os = "linux")] {
         mod linux;
-        pub use self::linux::*;
+        use self::linux as platform;
     } else if #[cfg(target_os = "macos")] {
         mod macos;
-        pub use self::macos::*;
+        use self::macos as platform;
     } else if #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] {
         cfg_if::cfg_if! {
             if #[cfg(feature = "wasm-web")] {
                 mod wasm_web;
-                pub use self::wasm_web::*;
+                use self::wasm_web as platform;
             } else {
                 mod wasm_unknown;
-                pub use self::wasm_unknown::*;
+                use self::wasm_unknown as platform;
             }
         }
     } else {
         mod other;
-        pub use self::other::*;
+        use self::other as platform;
     }
 }
 
 mod key_code;
-pub use self::key_code::*;
+pub use self::{key_code::*, platform::*};
 
 #[cfg(test)]
 mod tests {
@@ -56,4 +58,21 @@ mod tests {
         thread::sleep(Duration::from_secs(5));
         hook.unregister(KeyCode::Numpad1).unwrap();
     }
+
+    #[test]
+    fn resolve() {
+        // Based on German keyboard layout.
+        println!("ß: {}", KeyCode::Minus.resolve());
+        println!("ü: {}", KeyCode::BracketLeft.resolve());
+        println!("#: {}", KeyCode::Backslash.resolve());
+        println!("+: {}", KeyCode::BracketRight.resolve());
+        println!("z: {}", KeyCode::KeyY.resolve());
+        println!("^: {}", KeyCode::Backquote.resolve());
+        println!("<: {}", KeyCode::IntlBackslash.resolve());
+        println!("Yen: {}", KeyCode::IntlYen.resolve());
+        println!("Enter: {}", KeyCode::Enter.resolve());
+        println!("Space: {}", KeyCode::Space.resolve());
+        println!("Tab: {}", KeyCode::Tab.resolve());
+        println!("Numpad0: {}", KeyCode::Numpad0.resolve());
+    }
 }
diff --git a/crates/livesplit-hotkey/src/linux/mod.rs b/crates/livesplit-hotkey/src/linux/mod.rs
index c44e833b1..281a2210f 100644
--- a/crates/livesplit-hotkey/src/linux/mod.rs
+++ b/crates/livesplit-hotkey/src/linux/mod.rs
@@ -421,3 +421,7 @@ impl Hook {
         future.value().ok_or(Error::ThreadStopped)?
     }
 }
+
+pub(crate) fn try_resolve(_key_code: KeyCode) -> Option<String> {
+    None
+}
diff --git a/crates/livesplit-hotkey/src/macos/mod.rs b/crates/livesplit-hotkey/src/macos/mod.rs
index 98f7e1e6a..f3d8826c0 100644
--- a/crates/livesplit-hotkey/src/macos/mod.rs
+++ b/crates/livesplit-hotkey/src/macos/mod.rs
@@ -292,3 +292,7 @@ unsafe extern "C" fn callback(
     }
     event
 }
+
+pub(crate) fn try_resolve(_key_code: KeyCode) -> Option<String> {
+    None
+}
diff --git a/crates/livesplit-hotkey/src/other/mod.rs b/crates/livesplit-hotkey/src/other/mod.rs
index d07ae8bff..463b19a68 100644
--- a/crates/livesplit-hotkey/src/other/mod.rs
+++ b/crates/livesplit-hotkey/src/other/mod.rs
@@ -1,4 +1,5 @@
 use crate::KeyCode;
+use alloc::string::String;
 
 #[derive(Debug, snafu::Snafu)]
 pub enum Error {}
@@ -23,3 +24,7 @@ impl Hook {
         Ok(())
     }
 }
+
+pub(crate) fn try_resolve(_key_code: KeyCode) -> Option<String> {
+    None
+}
diff --git a/crates/livesplit-hotkey/src/wasm_unknown/mod.rs b/crates/livesplit-hotkey/src/wasm_unknown/mod.rs
index 321685b12..b258e68b5 100644
--- a/crates/livesplit-hotkey/src/wasm_unknown/mod.rs
+++ b/crates/livesplit-hotkey/src/wasm_unknown/mod.rs
@@ -91,3 +91,7 @@ impl Hook {
         }
     }
 }
+
+pub(crate) fn try_resolve(_key_code: KeyCode) -> Option<String> {
+    None
+}
diff --git a/crates/livesplit-hotkey/src/wasm_web/mod.rs b/crates/livesplit-hotkey/src/wasm_web/mod.rs
index b876ae4b4..1c729b348 100644
--- a/crates/livesplit-hotkey/src/wasm_web/mod.rs
+++ b/crates/livesplit-hotkey/src/wasm_web/mod.rs
@@ -3,7 +3,6 @@ use wasm_bindgen::{prelude::*, JsCast};
 use web_sys::{window, Gamepad, GamepadButton, KeyboardEvent};
 
 use std::{
-    array,
     cell::Cell,
     collections::hash_map::{Entry, HashMap},
     sync::{Arc, Mutex},
@@ -82,10 +81,7 @@ impl Hook {
         }) as Box<dyn FnMut(KeyboardEvent)>);
 
         window
-            .add_event_listener_with_callback(
-                "keydown",
-                keyboard_callback.as_ref().unchecked_ref(),
-            )
+            .add_event_listener_with_callback("keydown", keyboard_callback.as_ref().unchecked_ref())
             .map_err(|_| Error::FailedToCreateHook)?;
 
         let hotkey_map = hotkeys.clone();
@@ -162,3 +158,7 @@ impl Hook {
         }
     }
 }
+
+pub(crate) fn try_resolve(_key_code: KeyCode) -> Option<String> {
+    None
+}
diff --git a/crates/livesplit-hotkey/src/windows/mod.rs b/crates/livesplit-hotkey/src/windows/mod.rs
index a9f729193..a39998011 100644
--- a/crates/livesplit-hotkey/src/windows/mod.rs
+++ b/crates/livesplit-hotkey/src/windows/mod.rs
@@ -20,8 +20,9 @@ use winapi::{
         libloaderapi::GetModuleHandleW,
         processthreadsapi::GetCurrentThreadId,
         winuser::{
-            CallNextHookEx, GetMessageW, PostThreadMessageW, SetWindowsHookExW,
-            UnhookWindowsHookEx, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, WH_KEYBOARD_LL, WM_KEYDOWN,
+            CallNextHookEx, GetMessageW, MapVirtualKeyW, PostThreadMessageW, SetWindowsHookExW,
+            UnhookWindowsHookEx, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MAPVK_VK_TO_CHAR,
+            MAPVK_VSC_TO_VK_EX, WH_KEYBOARD_LL, WM_KEYDOWN,
         },
     },
 };
@@ -346,3 +347,78 @@ impl Hook {
         }
     }
 }
+
+pub(crate) fn try_resolve(key_code: KeyCode) -> Option<String> {
+    use self::KeyCode::*;
+    let scan_code = match key_code {
+        Backquote => 0x0029,
+        Backslash => 0x002B,
+        Backspace => 0x000E,
+        BracketLeft => 0x001A,
+        BracketRight => 0x001B,
+        Comma => 0x0033,
+        Digit1 => 0x0002,
+        Digit2 => 0x0003,
+        Digit3 => 0x0004,
+        Digit4 => 0x0005,
+        Digit5 => 0x0006,
+        Digit6 => 0x0007,
+        Digit7 => 0x0008,
+        Digit8 => 0x0009,
+        Digit9 => 0x000A,
+        Digit0 => 0x000B,
+        Equal => 0x000D,
+        IntlBackslash => 0x0056,
+        IntlRo => 0x0073,
+        IntlYen => 0x007D,
+        KeyA => 0x001E,
+        KeyB => 0x0030,
+        KeyC => 0x002E,
+        KeyD => 0x0020,
+        KeyE => 0x0012,
+        KeyF => 0x0021,
+        KeyG => 0x0022,
+        KeyH => 0x0023,
+        KeyI => 0x0017,
+        KeyJ => 0x0024,
+        KeyK => 0x0025,
+        KeyL => 0x0026,
+        KeyM => 0x0032,
+        KeyN => 0x0031,
+        KeyO => 0x0018,
+        KeyP => 0x0019,
+        KeyQ => 0x0010,
+        KeyR => 0x0013,
+        KeyS => 0x001F,
+        KeyT => 0x0014,
+        KeyU => 0x0016,
+        KeyV => 0x002F,
+        KeyW => 0x0011,
+        KeyX => 0x002D,
+        KeyY => 0x0015,
+        KeyZ => 0x002C,
+        Minus => 0x000C,
+        Period => 0x0034,
+        Quote => 0x0028,
+        Semicolon => 0x0027,
+        Slash => 0x0035,
+        _ => return None,
+    };
+
+    let virtual_key_code = unsafe { MapVirtualKeyW(scan_code, MAPVK_VSC_TO_VK_EX) };
+    if virtual_key_code == 0 {
+        return None;
+    }
+
+    let mapped_char = unsafe { MapVirtualKeyW(virtual_key_code, MAPVK_VK_TO_CHAR) };
+    if mapped_char == 0 {
+        return None;
+    }
+
+    // Dead keys (diacritics) are indicated by setting the top bit of the return
+    // value.
+    const TOP_BIT_MASK: u32 = u32::MAX >> 1;
+    let char = mapped_char & TOP_BIT_MASK;
+
+    Some(char::from_u32(char)?.to_string())
+}