diff --git a/webv4/lib/pages.js b/webv4/lib/pages.js
index dc7e89d13c16b17b18bbbf6096483e3edcd96819..f7b41f93978c2a802005175b4161a8f61e6ebe68 100644
--- a/webv4/lib/pages.js
+++ b/webv4/lib/pages.js
@@ -263,5 +263,7 @@ function writePage(page) {
 	var ini = getWebCtrl(pp.replace(file_getname(page), ''));
 	if ((typeof ini === "boolean" && !ini) || webCtrlTest(ini, page)) {
 		write(getPage(page));
-	}
+	} else {
+        write('<div class="alert alert-danger"><h3>You do not have access to this page</h3></div>'); // TODOX Translate
+    }
 }
diff --git a/webv4/pages/More/002-nodespy.xjs b/webv4/pages/More/002-nodespy.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..692a3c8a1cbbe3d91964b321a8a3aa643631bbbb
--- /dev/null
+++ b/webv4/pages/More/002-nodespy.xjs
@@ -0,0 +1,346 @@
+<!--NO_SIDEBAR:Node Spy-->
+
+<?xjs
+    // Ensure the user is a sysop to avoid normal users from spying
+    if (!user.is_sysop) {
+?>
+        <div class="alert alert-danger"><h3>You must be a SysOp to view the node spy</h3></div> <!-- TODOX Translate -->
+<?xjs    
+        exit();
+    }
+
+    // Read mqtt settings
+    var f = new File(system.ctrl_dir + 'main.ini');
+    f.open("r");
+    var broker_addr = f.iniGetValue('MQTT', 'Broker_addr', 'localhost');
+    var broker_enabled = f.iniGetValue('MQTT', 'Enabled', false);
+    var broker_password = f.iniGetValue('MQTT', 'Password', '');
+    var broker_username = f.iniGetValue('MQTT', 'Username', '');
+    var broker_ws_port = settings.mqtt_ws_port;
+    var broker_wss_port = settings.mqtt_wss_port;
+    
+    // Abort if the mqtt broker is not enabled
+    if (!broker_enabled) {
+?>
+        <div class="alert alert-danger"><h3>You must enable the MQTT broker in SCFG -> Networks -> MQTT before you can use the node spy</h3></div> <!-- TODOX Translate -->
+<?xjs    
+        exit();
+    }
+    
+    // Abort if no ws(s) port set
+    if (!broker_ws_port || !broker_wss_port) {
+?>
+        <div class="alert alert-danger"><h3>You must specify the MQTT broker's WS (mqtt_ws_port) and WSS (mqtt_wss_port) ports in the [web] section of <?xjs write(system.ctrl_dir + 'modopts.ini'); ?> before you can use the node spy</h3></div> <!-- TODOX Translate -->
+<?xjs    
+        exit();
+    }
+
+    // Load fTelnet-related files
+    load(settings.web_lib + 'ftelnet.js');
+    load('ftelnethelper.js');
+?>
+
+<style>
+    .fTelnetStatusBar { display : none !important; }                       /* Don't show the status bar, we only want to use fTelnet for display purposes not connect purposes */
+    div.active { background-color: #ddd; padding-bottom: 15px; }           /* Highlight the div that has keyboard focus */
+    label { font-weight: normal !important; margin-bottom: 0 !important; } /* Sanely format the label elements */
+    
+    /* Introduce a col-xl-6 to display two columns on large screens -- allows seeing all 4 of my nodes at once */
+    .col-xl-6 {
+        position: relative;
+        min-height: 1px;
+        padding-right: 15px;
+        padding-left: 15px;
+    }
+    @media (min-width: 1600px) {
+        .col-xl-6 { 
+            float: left;
+            width: 50%; 
+        }
+    }
+</style>
+
+<h1 align="center">Node Spy</h1>
+
+<!-- A hidden-by-default div with a link to access the MQTT server in the browser, to see if the cert is trusted -->
+<div id="HttpsMqttDiv" style="display: none;">
+    <!-- TODOX Translate -->
+    Looks like your browser is having troubles connecting to the MQTT server over WSS.  A few common causes:
+    <ul>
+        <li>The MQTT broker is offline</li>
+        <li>The SSL cert has expired</li>
+        <li>You're connecting to the broker via a hostname not found in the SSL cert</li>
+        <li>You're connecting to the broker via an IP address</li>
+    </ul>
+    You can click the link below to try accessing the MQTT broker in a new tab/window, which might shed some light on what is going wrong, and the in the case of an
+    untrusted cert, allow you to accept it (eg clicking Proceed in Chrome):<br />
+    <a href="" id="HttpsMqttLink" target="_blank">Access the MQTT server</a>
+</div>
+
+<!-- Multiple fTelnet instances (one per node) will be added to this wrapper div -->
+<div id="ClientsWrapper"></div>
+
+<script id="fTelnetScript" src="<?xjs write(get_url()); ?>"></script>
+<script src="https://unpkg.com/mqtt@5.0.2/dist/mqtt.min.js"></script>
+<script src="./js/utf8_cp437.js"></script>
+<script>
+    var charsets = [];
+    var fTelnetControls = [];
+    var mqttClient;
+    
+    // Check if we're targeting a specific node
+    const urlParams = new URLSearchParams(window.location.search);
+    const targetNode = parseInt(urlParams.get('node'), 10);
+
+    // Connect to the mqtt broker and subscribe to some topics    
+    function connect_to_mqtt_broker() {
+        const options = {
+          keepalive: 60,
+          clientId: '<?xjs write(system.qwk_id); ?>' + Math.random().toString(16).substr(2, 8),
+          username: '<?xjs write(broker_username); ?>',
+          password: '<?xjs write(broker_password); ?>',
+          protocolId: 'MQTT',
+          protocolVersion: 4,
+          clean: true,
+          reconnectPeriod: 5000,
+          connectTimeout: 30 * 1000,
+          will: {
+            topic: 'WillMsg',
+            payload: 'Connection Closed abnormally..!',
+            qos: 0,
+            retain: false
+          },
+        }
+
+        // Build the host string to connect to based on whether ws:// or wss:// is required
+        var protocol = location.protocol === 'https:' ? 'wss' : 'ws';
+        var port = location.protocol === 'https:' ? <?xjs write(broker_wss_port); ?> : <?xjs write(broker_ws_port); ?>;
+        const host = protocol + '://<?xjs write(broker_addr) ?>:' + port + '/mqtt';
+
+        // Set the debugging link to a modified version of the host string
+        $('#HttpsMqttLink').attr('href', host.replace('wss://', 'https://'));
+
+        // Connect to the mqtt broker
+        log_ftelnet('Connecting to mqtt broker (' + host + ')');
+        mqttClient = mqtt.connect(host, options);
+
+        // Subscribe to topics on connect
+        mqttClient.on('connect', () => {
+          log_ftelnet('Connected');
+          $('#HttpsMqttDiv').hide();
+
+          for (var node in fTelnetControls) {
+            mqttClient.subscribe('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '', { qos: 2 });
+            mqttClient.subscribe('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/output', { qos: 2 });
+            mqttClient.subscribe('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/terminal', { qos: 2 });
+          }
+        });
+
+        // Display an error when connection fails
+        mqttClient.on('error', (err) => {
+          log_ftelnet('Connection error: ', err);
+          mqttClient.end();
+        });
+        
+        // Handle messages for subscribed topics
+        mqttClient.on('message', (topic, message, packet) => {
+            var match = topic.match(/node[\/](\d*)([\/](output|terminal))?/);
+            var node = match[1];
+            var messageType = match[2];
+            
+            switch (messageType) {
+                case undefined:
+                    // message contains the node's current activity (ie waiting for caller, or user is running an external, etc)
+                    $('#Status' + node).html(message.toString());
+                    break;
+              
+                case '/output':
+                    // message contains the raw output being sent to the user, so write it to the fTelnet instance
+                    var buf = new Uint8Array(message);
+
+                    var str = '';
+                    for (var i = 0; i < message.length; i++) {
+                        str += String.fromCharCode(buf[i]);
+                    }
+
+                    // UTF-8 decode, if necessary
+                    if (charsets[node] === 'UTF-8') {
+                        str = utf8_cp437(str);
+                    }
+
+                    fTelnetControls[node]._Ansi.Write(str);
+                    break;
+              
+                case '/terminal':
+                    // message contains tab-separated terminal information, most importantly the column and row count as the first
+                    // and second elements, so resize fTelnet and tell it to reload the best font based on the new size.
+                    var arr = message.toString().split('\t');
+                    fTelnetControls[node]._Crt.SetScreenSize(parseInt(arr[0], 10), parseInt(arr[1], 10));
+                    fTelnetControls[node]._Crt.SetFont(fTelnetControls[node]._Crt.Font.Name); 
+                    charsets[node] = arr[4];
+                    // TODOX When arr[4] is 'CBM-ASCII' things get messy -- did I add support for PETSCII to fTelnet?
+                    break;
+            }
+        });
+        
+        // Display a message when the connection drops and the client is attempting to reconnect
+        // Also show the debugging message if https is being used, because the sysop may need to resolve issues with their ssl cert
+        mqttClient.on('reconnect', () => {
+            log_ftelnet('Reconnecting...');
+          
+            if (location.protocol === 'https:') {
+                $('#HttpsMqttDiv').show();
+            }
+        });
+    }
+    
+    // Create an fTelnet instance for each node
+    function create_ftelnet_instances() {
+        // Loop through the node range
+        for (var node = 1; node <= <?xjs write(system.nodes) ?>; node++) {
+            if (!targetNode || (targetNode === node)) {
+                // Build a node-specific link
+                var nodeUrl = location.href;
+                if (!nodeUrl.includes("&node=")) {
+                  nodeUrl += '&node=' + node;
+                }
+                
+                // Build a div containing the node status, keyboard checkbox, and fTelnet instance
+                $('#ClientsWrapper').append(`
+                  <div id="Client${node}" class="${targetNode ? '' : 'col-xl-6'}">
+                    <h4 style="text-align: center;">
+                      <a href="${nodeUrl}">Node ${node}</a> - 
+                      <span id="Status${node}">Status Unknown</span> &nbsp; &nbsp; 
+                      <span style="white-space: nowrap;">
+                        <input type="checkbox" class="keyboard-input" id="chkKeyboard${node}" value="${node}" /> 
+                        <label for="chkKeyboard${node}">Enable Keyboard Input</label>
+                      </span>
+                    </h4>
+                    <div id="fTelnetContainer${node}" class="fTelnetContainer"></div>
+                  </div>
+                `);
+
+                // And initialize a new fTelnet instance
+                var Options = new fTelnetOptions();
+                Options.AllowModernScrollback = false;
+                Options.BareLFtoCRLF = false;
+                Options.BitsPerSecond = 57600;
+                Options.ConnectionType = 'telnet';
+                Options.Emulation = 'ansi-bbs';
+                Options.Enter = '\r';
+                Options.Font = 'CP437';
+                Options.ForceWss = false;
+                Options.Hostname = 'unused';
+                Options.LocalEcho = false;
+                Options.Port = 11235;
+                Options.ScreenColumns = 80;
+                Options.ScreenRows = 25;
+                Options.SplashScreen = ' ';
+                fTelnetControls[node] = new fTelnetClient('fTelnetContainer' + node, Options);
+                charsets[node] = 'CP437';
+            }
+        }
+    }
+
+    // Setup the keyboard handler
+    function init_keyboard_handler() {
+        // Handle clicks on the keyboard checkboxes
+        $('.keyboard-input').click(function() {
+            var selectedNode = this.value;
+            
+            // Highlight the new selected div, and un-highlight+un-check the other divs and checkboxes
+            for (var node in fTelnetControls) {
+                if ((node === selectedNode) && this.checked) {
+                    $('#Client' + node).addClass('active');
+                } else {
+                    $('#Client' + node).removeClass('active');
+                    $('.keyboard-input[value=' + node + ']').removeAttr('checked');
+                }
+            }
+        });
+        
+        // Handle keydown event, which is where control keys and special keys are handled
+        // Code is copy/pasted (with slight modifications) from fTelnet
+        window.addEventListener('keydown', function (ke) {
+            var node = $('.keyboard-input:checked').attr('value');
+            if (node) {
+                var keyString = '';
+            
+                if (ke.ctrlKey) {
+                    // Handle control + letter keys
+                    if ((ke.keyCode >= 65) && (ke.keyCode <= 90)) {
+                        keyString = String.fromCharCode(ke.keyCode - 64);
+                    } else if ((ke.keyCode >= 97) && (ke.keyCode <= 122)) {
+                        keyString = String.fromCharCode(ke.keyCode - 96);
+                    }
+                } else {
+                    switch (ke.keyCode) {
+                        // Handle special keys
+                        case KeyboardKeys.BACKSPACE: keyString = '\b'; break;
+                        case KeyboardKeys.DELETE: keyString = '\x7F'; break;
+                        case KeyboardKeys.DOWN: keyString = '\x1B[B'; break;
+                        case KeyboardKeys.END: keyString = '\x1B[K'; break;
+                        case KeyboardKeys.ENTER: keyString = '\r\n'; break;
+                        case KeyboardKeys.ESCAPE: keyString = '\x1B'; break;
+                        case KeyboardKeys.F1: keyString = '\x1BOP'; break;
+                        case KeyboardKeys.F2: keyString = '\x1BOQ'; break;
+                        case KeyboardKeys.F3: keyString = '\x1BOR'; break;
+                        case KeyboardKeys.F4: keyString = '\x1BOS'; break;
+                        case KeyboardKeys.F5: keyString = '\x1BOt'; break;
+                        case KeyboardKeys.F6: keyString = '\x1B[17~'; break;
+                        case KeyboardKeys.F7: keyString = '\x1B[18~'; break;
+                        case KeyboardKeys.F8: keyString = '\x1B[19~'; break;
+                        case KeyboardKeys.F9: keyString = '\x1B[20~'; break;
+                        case KeyboardKeys.F10: keyString = '\x1B[21~'; break;
+                        case KeyboardKeys.F11: keyString = '\x1B[23~'; break;
+                        case KeyboardKeys.F12: keyString = '\x1B[24~'; break;
+                        case KeyboardKeys.HOME: keyString = '\x1B[H'; break;
+                        case KeyboardKeys.INSERT: keyString = '\x1B@'; break;
+                        case KeyboardKeys.LEFT: keyString = '\x1B[D'; break;
+                        case KeyboardKeys.PAGE_DOWN: keyString = '\x1B[U'; break;
+                        case KeyboardKeys.PAGE_UP: keyString = '\x1B[V'; break;
+                        case KeyboardKeys.RIGHT: keyString = '\x1B[C'; break;
+                        case KeyboardKeys.SPACE: keyString = ' '; break;
+                        case KeyboardKeys.TAB: keyString = '\t'; break;
+                        case KeyboardKeys.UP: keyString = '\x1B[A'; break;
+                    }
+                }
+
+                // If we have a keyString, then publish it so sbbs sees it as input
+                if (keyString) {
+                    mqttClient.publish('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/input', keyString, { qos: 1, retain: false });
+                }
+
+                // If we have a keyString, or CTRL is being held, prevent further handling (so the keypress event won't be hit next)
+                if ((keyString) || (ke.ctrlKey)) {
+                    ke.preventDefault();
+                }
+            }
+        });
+        
+        // Handle keypress event, which is where standard keys are handled
+        // Code is copy/pasted (with slight modifications) from fTelnet
+        window.addEventListener('keypress', function (ke) {
+            var node = $('.keyboard-input:checked').attr('value');
+            if (node) {
+                if (ke.charCode >= 33) {
+                    mqttClient.publish('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/input', String.fromCharCode(ke.charCode), { qos: 1, retain: false });
+                }
+            }
+        });
+    }
+    
+    // Log a message to each of the fTelnet instances
+    function log_ftelnet(message) {
+        for (var node in fTelnetControls) {
+            fTelnetControls[node]._Crt.WriteLn(message);
+        }
+    }
+
+    // Add an onload handler to setup the node spy
+    window.addEventListener('load', (event) => {
+        create_ftelnet_instances();
+        init_keyboard_handler();
+        connect_to_mqtt_broker();
+    });
+</script>
\ No newline at end of file
diff --git a/webv4/pages/More/webctrl.ini b/webv4/pages/More/webctrl.ini
index b5b2b7570adf3bb2f253bd4c2a85395e2aabfcfa..1db5cd48ef4d1bb234f879901dadf4da26d7388c 100644
--- a/webv4/pages/More/webctrl.ini
+++ b/webv4/pages/More/webctrl.ini
@@ -1,6 +1,9 @@
 AccessRequirements = level 90
 Authorization = Digest
 
+[*nodespy.xjs]
+AccessRequirements = level 90
+
 [*userlist.xjs]
 AccessRequirements = LEVEL 50 AND REST NOT G
 
diff --git a/webv4/root/js/utf8_cp437.js b/webv4/root/js/utf8_cp437.js
new file mode 100644
index 0000000000000000000000000000000000000000..0341b8cd99fe7be54416eda77acc44da2de2ca55
--- /dev/null
+++ b/webv4/root/js/utf8_cp437.js
@@ -0,0 +1,511 @@
+// Copied from here, which was last updated on April 7, 2023:
+// https://gitlab.synchro.net/main/sbbs/-/blob/master/exec/load/unicode_cp437.js
+function unicode_cp437(uc)
+{
+    switch(uc) {
+        case 0x00A0: return String.fromCharCode(0x00FF);
+        case 0x00A1: return String.fromCharCode(0x00AD);
+        case 0x00A2: return String.fromCharCode(0x009B);
+        case 0x00A3: return String.fromCharCode(0x009C);
+        case 0x00A5: return String.fromCharCode(0x009D);
+        case 0x00A6: return String.fromCharCode(0x007C);
+        case 0x00A8: return String.fromCharCode(0x0022);
+        case 0x00AA: return String.fromCharCode(0x00A6);
+        case 0x00AB: return String.fromCharCode(0x00AE);
+        case 0x00AC: return String.fromCharCode(0x00AA);
+        case 0x00AD: return String.fromCharCode(0x002D);
+        case 0x00B0: return String.fromCharCode(0x00F8);
+        case 0x00B1: return String.fromCharCode(0x00F1);
+        case 0x00B2: return String.fromCharCode(0x00FD);
+        case 0x00B4: return String.fromCharCode(0x0027);
+        case 0x00B5: return String.fromCharCode(0x00E6);
+        case 0x00B6: return String.fromCharCode(0x0050);
+        case 0x00B7: return String.fromCharCode(0x00FA);
+        case 0x00B8: return String.fromCharCode(0x002C);
+        case 0x00BA: return String.fromCharCode(0x00A7);
+        case 0x00BB: return String.fromCharCode(0x00AF);
+        case 0x00BC: return String.fromCharCode(0x00AC);
+        case 0x00BD: return String.fromCharCode(0x00AB);
+        case 0x00BF: return String.fromCharCode(0x00A8);
+        case 0x00C4: return String.fromCharCode(0x008E);
+        case 0x00C5: return String.fromCharCode(0x008F);
+        case 0x00C6: return String.fromCharCode(0x0092);
+        case 0x00C7: return String.fromCharCode(0x0080);
+        case 0x00C9: return String.fromCharCode(0x0090);
+        case 0x00D0: return String.fromCharCode(0x0044);
+        case 0x00D1: return String.fromCharCode(0x00A5);
+        case 0x00D6: return String.fromCharCode(0x0099);
+        case 0x00D7: return String.fromCharCode(0x0078);
+        case 0x00D8: return String.fromCharCode(0x004F);
+        case 0x00DC: return String.fromCharCode(0x009A);
+        case 0x00DF: return String.fromCharCode(0x00E1);
+        case 0x00E0: return String.fromCharCode(0x0085);
+        case 0x00E1: return String.fromCharCode(0x00A0);
+        case 0x00E2: return String.fromCharCode(0x0083);
+        case 0x00E4: return String.fromCharCode(0x0084);
+        case 0x00E5: return String.fromCharCode(0x0086);
+        case 0x00E6: return String.fromCharCode(0x0091);
+        case 0x00E7: return String.fromCharCode(0x0087);
+        case 0x00E8: return String.fromCharCode(0x008A);
+        case 0x00E9: return String.fromCharCode(0x0082);
+        case 0x00EA: return String.fromCharCode(0x0088);
+        case 0x00EB: return String.fromCharCode(0x0089);
+        case 0x00EC: return String.fromCharCode(0x008D);
+        case 0x00ED: return String.fromCharCode(0x00A1);
+        case 0x00EE: return String.fromCharCode(0x008C);
+        case 0x00EF: return String.fromCharCode(0x008B);
+        case 0x00F0: return String.fromCharCode(0x0064);
+        case 0x00F1: return String.fromCharCode(0x00A4);
+        case 0x00F2: return String.fromCharCode(0x0095);
+        case 0x00F3: return String.fromCharCode(0x00A2);
+        case 0x00F4: return String.fromCharCode(0x0093);
+        case 0x00F6: return String.fromCharCode(0x0094);
+        case 0x00F7: return String.fromCharCode(0x00F6);
+        case 0x00F8: return String.fromCharCode(0x006F);
+        case 0x00F9: return String.fromCharCode(0x0097);
+        case 0x00FA: return String.fromCharCode(0x00A3);
+        case 0x00FB: return String.fromCharCode(0x0096);
+        case 0x00FC: return String.fromCharCode(0x0081);
+        case 0x00FF: return String.fromCharCode(0x0098);
+        case 0x0100: return String.fromCharCode(0x0041);
+        case 0x0101: return String.fromCharCode(0x0061);
+        case 0x0102: return String.fromCharCode(0x0041);
+        case 0x0103: return String.fromCharCode(0x0061);
+        case 0x0104: return String.fromCharCode(0x0041);
+        case 0x0105: return String.fromCharCode(0x0061);
+        case 0x010A: return String.fromCharCode(0x0043);
+        case 0x010B: return String.fromCharCode(0x0063);
+        case 0x010C: return String.fromCharCode(0x0043);
+        case 0x010D: return String.fromCharCode(0x0063);
+        case 0x010E: return String.fromCharCode(0x0044);
+        case 0x010F: return String.fromCharCode(0x0064);
+        case 0x0110: return String.fromCharCode(0x0044);
+        case 0x0111: return String.fromCharCode(0x0064);
+        case 0x0112: return String.fromCharCode(0x0045);
+        case 0x0113: return String.fromCharCode(0x0065);
+        case 0x0114: return String.fromCharCode(0x0045);
+        case 0x0115: return String.fromCharCode(0x0065);
+        case 0x0116: return String.fromCharCode(0x0045);
+        case 0x0117: return String.fromCharCode(0x0065);
+        case 0x0118: return String.fromCharCode(0x0045);
+        case 0x0119: return String.fromCharCode(0x0065);
+        case 0x011A: return String.fromCharCode(0x0045);
+        case 0x011B: return String.fromCharCode(0x0065);
+        case 0x011E: return String.fromCharCode(0x0047);
+        case 0x011F: return String.fromCharCode(0x0067);
+        case 0x0120: return String.fromCharCode(0x0047);
+        case 0x0121: return String.fromCharCode(0x0067);
+        case 0x0122: return String.fromCharCode(0x0047);
+        case 0x0123: return String.fromCharCode(0x0067);
+        case 0x0126: return String.fromCharCode(0x0048);
+        case 0x0127: return String.fromCharCode(0x0068);
+        case 0x012A: return String.fromCharCode(0x0049);
+        case 0x012B: return String.fromCharCode(0x0069);
+        case 0x012C: return String.fromCharCode(0x0049);
+        case 0x012D: return String.fromCharCode(0x0069);
+        case 0x012E: return String.fromCharCode(0x0049);
+        case 0x012F: return String.fromCharCode(0x0069);
+        case 0x0130: return String.fromCharCode(0x0049);
+        case 0x0131: return String.fromCharCode(0x0069);
+        case 0x0136: return String.fromCharCode(0x004B);
+        case 0x0137: return String.fromCharCode(0x006B);
+        case 0x0139: return String.fromCharCode(0x004C);
+        case 0x013A: return String.fromCharCode(0x006C);
+        case 0x013B: return String.fromCharCode(0x004C);
+        case 0x013C: return String.fromCharCode(0x006C);
+        case 0x013D: return String.fromCharCode(0x004C);
+        case 0x013E: return String.fromCharCode(0x006C);
+        case 0x013F: return String.fromCharCode(0x004C);
+        case 0x0140: return String.fromCharCode(0x006C);
+        case 0x0141: return String.fromCharCode(0x004C);
+        case 0x0142: return String.fromCharCode(0x006C);
+        case 0x0145: return String.fromCharCode(0x004E);
+        case 0x0146: return String.fromCharCode(0x006E);
+        case 0x0147: return String.fromCharCode(0x004E);
+        case 0x0148: return String.fromCharCode(0x006E);
+        case 0x014C: return String.fromCharCode(0x004F);
+        case 0x014D: return String.fromCharCode(0x006F);
+        case 0x014E: return String.fromCharCode(0x004F);
+        case 0x014F: return String.fromCharCode(0x006F);
+        case 0x0156: return String.fromCharCode(0x0052);
+        case 0x0157: return String.fromCharCode(0x0072);
+        case 0x0158: return String.fromCharCode(0x0052);
+        case 0x0159: return String.fromCharCode(0x0072);
+        case 0x015E: return String.fromCharCode(0x0053);
+        case 0x015F: return String.fromCharCode(0x0073);
+        case 0x0160: return String.fromCharCode(0x0053);
+        case 0x0161: return String.fromCharCode(0x0073);
+        case 0x0162: return String.fromCharCode(0x0054);
+        case 0x0163: return String.fromCharCode(0x0074);
+        case 0x0164: return String.fromCharCode(0x0054);
+        case 0x0165: return String.fromCharCode(0x0074);
+        case 0x0166: return String.fromCharCode(0x0054);
+        case 0x0167: return String.fromCharCode(0x0074);
+        case 0x016A: return String.fromCharCode(0x0055);
+        case 0x016B: return String.fromCharCode(0x0075);
+        case 0x016C: return String.fromCharCode(0x0055);
+        case 0x016D: return String.fromCharCode(0x0075);
+        case 0x016E: return String.fromCharCode(0x0055);
+        case 0x016F: return String.fromCharCode(0x0075);
+        case 0x0172: return String.fromCharCode(0x0055);
+        case 0x0173: return String.fromCharCode(0x0075);
+        case 0x017B: return String.fromCharCode(0x005A);
+        case 0x017C: return String.fromCharCode(0x007A);
+        case 0x017D: return String.fromCharCode(0x005A);
+        case 0x017E: return String.fromCharCode(0x007A);
+        case 0x017F: return String.fromCharCode(0x0073);
+        case 0x0192: return String.fromCharCode(0x009F);
+        case 0x0218: return String.fromCharCode(0x0053);
+        case 0x0219: return String.fromCharCode(0x0073);
+        case 0x021A: return String.fromCharCode(0x0054);
+        case 0x021B: return String.fromCharCode(0x0074);
+        case 0x02B9: return String.fromCharCode(0x0027);
+        case 0x02BB: return String.fromCharCode(0x0027);
+        case 0x02BC: return String.fromCharCode(0x0027);
+        case 0x02BD: return String.fromCharCode(0x0027);
+        case 0x02C6: return String.fromCharCode(0x005E);
+        case 0x02C8: return String.fromCharCode(0x0027);
+        case 0x02CA: return String.fromCharCode(0x0027);
+        case 0x02CB: return String.fromCharCode(0x0060);
+        case 0x02CD: return String.fromCharCode(0x005F);
+        case 0x02DC: return String.fromCharCode(0x007E);
+        case 0x02DD: return String.fromCharCode(0x0022);
+        case 0x0393: return String.fromCharCode(0x00E2);
+        case 0x0398: return String.fromCharCode(0x00E9);
+        case 0x03A3: return String.fromCharCode(0x00E4);
+        case 0x03A6: return String.fromCharCode(0x00E8);
+        case 0x03A9: return String.fromCharCode(0x00EA);
+        case 0x03B1: return String.fromCharCode(0x00E0);
+        case 0x03B4: return String.fromCharCode(0x00EB);
+        case 0x03B5: return String.fromCharCode(0x00EE);
+        case 0x03C0: return String.fromCharCode(0x00E3);
+        case 0x03C3: return String.fromCharCode(0x00E5);
+        case 0x03C4: return String.fromCharCode(0x00E7);
+        case 0x03C6: return String.fromCharCode(0x00ED);
+        case 0x03D5: return String.fromCharCode(0x00ED);
+        case 0x03D6: return String.fromCharCode(0x00E3);
+        case 0x03F4: return String.fromCharCode(0x00E9);
+        case 0x03F5: return String.fromCharCode(0x00EE);
+        case 0x03F9: return String.fromCharCode(0x00E4);
+        case 0x1E02: return String.fromCharCode(0x0042);
+        case 0x1E03: return String.fromCharCode(0x0062);
+        case 0x1E0A: return String.fromCharCode(0x0044);
+        case 0x1E0B: return String.fromCharCode(0x0064);
+        case 0x1E1E: return String.fromCharCode(0x0046);
+        case 0x1E1F: return String.fromCharCode(0x0066);
+        case 0x1E40: return String.fromCharCode(0x004D);
+        case 0x1E41: return String.fromCharCode(0x006D);
+        case 0x1E56: return String.fromCharCode(0x0050);
+        case 0x1E57: return String.fromCharCode(0x0070);
+        case 0x1E60: return String.fromCharCode(0x0053);
+        case 0x1E61: return String.fromCharCode(0x0073);
+        case 0x1E6A: return String.fromCharCode(0x0054);
+        case 0x1E6B: return String.fromCharCode(0x0074);
+        case 0x2002: return String.fromCharCode(0x0020);
+        case 0x2003: return String.fromCharCode(0x0020);
+        case 0x2004: return String.fromCharCode(0x0020);
+        case 0x2005: return String.fromCharCode(0x0020);
+        case 0x2006: return String.fromCharCode(0x0020);
+        case 0x2008: return String.fromCharCode(0x0020);
+        case 0x2009: return String.fromCharCode(0x0020);
+        case 0x200A: return String.fromCharCode(0x0020);
+        case 0x2010: return String.fromCharCode(0x002D);
+        case 0x2011: return String.fromCharCode(0x002D);
+        case 0x2012: return String.fromCharCode(0x002D);
+        case 0x2013: return String.fromCharCode(0x002D);
+        case 0x2014: return String.fromCharCode(0x002D);
+        case 0x2015: return String.fromCharCode(0x002D);
+        case 0x2018: return String.fromCharCode(0x0060);
+        case 0x2019: return String.fromCharCode(0x0027);
+        case 0x201A: return String.fromCharCode(0x0027);
+        case 0x201B: return String.fromCharCode(0x0027);
+        case 0x201C: return String.fromCharCode(0x0022);
+        case 0x201D: return String.fromCharCode(0x0022);
+        case 0x201E: return String.fromCharCode(0x0022);
+        case 0x201F: return String.fromCharCode(0x0022);
+        case 0x2020: return String.fromCharCode(0x002B);
+        case 0x2022: return String.fromCharCode(0x006F);
+        case 0x2024: return String.fromCharCode(0x002E);
+        case 0x2026: return "...";
+        case 0x2032: return String.fromCharCode(0x0027);
+        case 0x2039: return String.fromCharCode(0x003C);
+        case 0x203A: return String.fromCharCode(0x003E);
+        case 0x2044: return String.fromCharCode(0x002F);
+        case 0x207F: return String.fromCharCode(0x00FC);
+        case 0x20A7: return String.fromCharCode(0x009E);
+        case 0x2102: return String.fromCharCode(0x0043);
+        case 0x210A: return String.fromCharCode(0x0067);
+        case 0x210B: return String.fromCharCode(0x0048);
+        case 0x210C: return String.fromCharCode(0x0048);
+        case 0x210D: return String.fromCharCode(0x0048);
+        case 0x210E: return String.fromCharCode(0x0068);
+        case 0x210F: return String.fromCharCode(0x0068);
+        case 0x2110: return String.fromCharCode(0x0049);
+        case 0x2111: return String.fromCharCode(0x0049);
+        case 0x2112: return String.fromCharCode(0x004C);
+        case 0x2113: return String.fromCharCode(0x006C);
+        case 0x2115: return String.fromCharCode(0x004E);
+        case 0x2119: return String.fromCharCode(0x0050);
+        case 0x211A: return String.fromCharCode(0x0051);
+        case 0x211B: return String.fromCharCode(0x0052);
+        case 0x211C: return String.fromCharCode(0x0052);
+        case 0x211D: return String.fromCharCode(0x0052);
+        case 0x2124: return String.fromCharCode(0x005A);
+        case 0x2128: return String.fromCharCode(0x005A);
+        case 0x212C: return String.fromCharCode(0x0042);
+        case 0x212D: return String.fromCharCode(0x0043);
+        case 0x212E: return String.fromCharCode(0x0065);
+        case 0x212F: return String.fromCharCode(0x0065);
+        case 0x2130: return String.fromCharCode(0x0045);
+        case 0x2131: return String.fromCharCode(0x0046);
+        case 0x2133: return String.fromCharCode(0x004D);
+        case 0x2134: return String.fromCharCode(0x006F);
+        case 0x2139: return String.fromCharCode(0x0069);
+        case 0x213E: return String.fromCharCode(0x00E2);
+        case 0x2145: return String.fromCharCode(0x0044);
+        case 0x2146: return String.fromCharCode(0x0064);
+        case 0x2147: return String.fromCharCode(0x0065);
+        case 0x2148: return String.fromCharCode(0x0069);
+        case 0x2149: return String.fromCharCode(0x006A);
+        case 0x2160: return String.fromCharCode(0x0049);
+        case 0x2164: return String.fromCharCode(0x0056);
+        case 0x2169: return String.fromCharCode(0x0058);
+        case 0x216C: return String.fromCharCode(0x004C);
+        case 0x216D: return String.fromCharCode(0x0043);
+        case 0x216E: return String.fromCharCode(0x0044);
+        case 0x216F: return String.fromCharCode(0x004D);
+        case 0x2170: return String.fromCharCode(0x0069);
+        case 0x2174: return String.fromCharCode(0x0076);
+        case 0x2179: return String.fromCharCode(0x0078);
+        case 0x217C: return String.fromCharCode(0x006C);
+        case 0x217D: return String.fromCharCode(0x0063);
+        case 0x217E: return String.fromCharCode(0x0064);
+        case 0x217F: return String.fromCharCode(0x006D);
+        case 0x2191: return String.fromCharCode(0x005E);
+        case 0x2193: return String.fromCharCode(0x0056);
+        case 0x2212: return String.fromCharCode(0x002D);
+        case 0x2215: return String.fromCharCode(0x002F);
+        case 0x2216: return String.fromCharCode(0x005C);
+        case 0x2217: return String.fromCharCode(0x002A);
+        case 0x2219: return String.fromCharCode(0x00F9);
+        case 0x221A: return String.fromCharCode(0x00FB);
+        case 0x221E: return String.fromCharCode(0x00EC);
+        case 0x2223: return String.fromCharCode(0x007C);
+        case 0x2229: return String.fromCharCode(0x00EF);
+        case 0x2236: return String.fromCharCode(0x003A);
+        case 0x223C: return String.fromCharCode(0x007E);
+        case 0x2248: return String.fromCharCode(0x00F7);
+        case 0x2261: return String.fromCharCode(0x00F0);
+        case 0x2264: return String.fromCharCode(0x00F3);
+        case 0x2265: return String.fromCharCode(0x00F2);
+        case 0x22C5: return String.fromCharCode(0x00FA);
+        case 0x2310: return String.fromCharCode(0x00A9);
+        case 0x2320: return String.fromCharCode(0x00F4);
+        case 0x2321: return String.fromCharCode(0x00F5);
+        case 0x2500: return String.fromCharCode(0x00C4);
+        case 0x2502: return String.fromCharCode(0x00B3);
+        case 0x250C: return String.fromCharCode(0x00DA);
+        case 0x2510: return String.fromCharCode(0x00BF);
+        case 0x2514: return String.fromCharCode(0x00C0);
+        case 0x2518: return String.fromCharCode(0x00D9);
+        case 0x251C: return String.fromCharCode(0x00C3);
+        case 0x2524: return String.fromCharCode(0x00B4);
+        case 0x252C: return String.fromCharCode(0x00C2);
+        case 0x2534: return String.fromCharCode(0x00C1);
+        case 0x253C: return String.fromCharCode(0x00C5);
+        case 0x2550: return String.fromCharCode(0x00CD);
+        case 0x2551: return String.fromCharCode(0x00BA);
+        case 0x2552: return String.fromCharCode(0x00D5);
+        case 0x2553: return String.fromCharCode(0x00D6);
+        case 0x2554: return String.fromCharCode(0x00C9);
+        case 0x2555: return String.fromCharCode(0x00B8);
+        case 0x2556: return String.fromCharCode(0x00B7);
+        case 0x2557: return String.fromCharCode(0x00BB);
+        case 0x2558: return String.fromCharCode(0x00D4);
+        case 0x2559: return String.fromCharCode(0x00D3);
+        case 0x255A: return String.fromCharCode(0x00C8);
+        case 0x255B: return String.fromCharCode(0x00BE);
+        case 0x255C: return String.fromCharCode(0x00BD);
+        case 0x255D: return String.fromCharCode(0x00BC);
+        case 0x255E: return String.fromCharCode(0x00C6);
+        case 0x255F: return String.fromCharCode(0x00C7);
+        case 0x2560: return String.fromCharCode(0x00CC);
+        case 0x2561: return String.fromCharCode(0x00B5);
+        case 0x2562: return String.fromCharCode(0x00B6);
+        case 0x2563: return String.fromCharCode(0x00B9);
+        case 0x2564: return String.fromCharCode(0x00D1);
+        case 0x2565: return String.fromCharCode(0x00D2);
+        case 0x2566: return String.fromCharCode(0x00CB);
+        case 0x2567: return String.fromCharCode(0x00CF);
+        case 0x2568: return String.fromCharCode(0x00D0);
+        case 0x2569: return String.fromCharCode(0x00CA);
+        case 0x256A: return String.fromCharCode(0x00D8);
+        case 0x256B: return String.fromCharCode(0x00D7);
+        case 0x256C: return String.fromCharCode(0x00CE);
+        case 0x2580: return String.fromCharCode(0x00DF);
+        case 0x2584: return String.fromCharCode(0x00DC);
+        case 0x2588: return String.fromCharCode(0x00DB);
+        case 0x258C: return String.fromCharCode(0x00DD);
+        case 0x2590: return String.fromCharCode(0x00DE);
+        case 0x2591: return String.fromCharCode(0x00B0);
+        case 0x2592: return String.fromCharCode(0x00B1);
+        case 0x2593: return String.fromCharCode(0x00B2);
+        case 0x25A0: return String.fromCharCode(0x00FE);
+        case 0x25E6: return String.fromCharCode(0x006F);
+        case 0x3000: return String.fromCharCode(0x0020);
+        case 0x30A0: return String.fromCharCode(0x003D);
+        case 0xFB29: return String.fromCharCode(0x002B);
+        case 0xFE4D: return String.fromCharCode(0x005F);
+        case 0xFE4E: return String.fromCharCode(0x005F);
+        case 0xFE4F: return String.fromCharCode(0x005F);
+        case 0xFE50: return String.fromCharCode(0x002C);
+        case 0xFE52: return String.fromCharCode(0x002E);
+        case 0xFE54: return String.fromCharCode(0x003B);
+        case 0xFE55: return String.fromCharCode(0x003A);
+        case 0xFE56: return String.fromCharCode(0x003F);
+        case 0xFE57: return String.fromCharCode(0x0021);
+        case 0xFE58: return String.fromCharCode(0x002D);
+        case 0xFE59: return String.fromCharCode(0x0028);
+        case 0xFE5A: return String.fromCharCode(0x0029);
+        case 0xFE5B: return String.fromCharCode(0x007B);
+        case 0xFE5C: return String.fromCharCode(0x007D);
+        case 0xFE5F: return String.fromCharCode(0x0023);
+        case 0xFE60: return String.fromCharCode(0x0026);
+        case 0xFE61: return String.fromCharCode(0x002A);
+        case 0xFE62: return String.fromCharCode(0x002B);
+        case 0xFE63: return String.fromCharCode(0x002D);
+        case 0xFE64: return String.fromCharCode(0x003C);
+        case 0xFE65: return String.fromCharCode(0x003E);
+        case 0xFE66: return String.fromCharCode(0x003D);
+        case 0xFE68: return String.fromCharCode(0x005C);
+        case 0xFE69: return String.fromCharCode(0x0024);
+        case 0xFE6A: return String.fromCharCode(0x0025);
+        case 0xFE6B: return String.fromCharCode(0x0040);
+        case 0xFF01: return String.fromCharCode(0x0021);
+        case 0xFF02: return String.fromCharCode(0x0022);
+        case 0xFF03: return String.fromCharCode(0x0023);
+        case 0xFF04: return String.fromCharCode(0x0024);
+        case 0xFF05: return String.fromCharCode(0x0025);
+        case 0xFF06: return String.fromCharCode(0x0026);
+        case 0xFF07: return String.fromCharCode(0x0027);
+        case 0xFF08: return String.fromCharCode(0x0028);
+        case 0xFF09: return String.fromCharCode(0x0029);
+        case 0xFF0A: return String.fromCharCode(0x002A);
+        case 0xFF0B: return String.fromCharCode(0x002B);
+        case 0xFF0C: return String.fromCharCode(0x002C);
+        case 0xFF0D: return String.fromCharCode(0x002D);
+        case 0xFF0E: return String.fromCharCode(0x002E);
+        case 0xFF0F: return String.fromCharCode(0x002F);
+        case 0xFF10: return String.fromCharCode(0x0030);
+        case 0xFF11: return String.fromCharCode(0x0031);
+        case 0xFF12: return String.fromCharCode(0x0032);
+        case 0xFF13: return String.fromCharCode(0x0033);
+        case 0xFF14: return String.fromCharCode(0x0034);
+        case 0xFF15: return String.fromCharCode(0x0035);
+        case 0xFF16: return String.fromCharCode(0x0036);
+        case 0xFF17: return String.fromCharCode(0x0037);
+        case 0xFF18: return String.fromCharCode(0x0038);
+        case 0xFF19: return String.fromCharCode(0x0039);
+        case 0xFF1A: return String.fromCharCode(0x003A);
+        case 0xFF1B: return String.fromCharCode(0x003B);
+        case 0xFF1C: return String.fromCharCode(0x003C);
+        case 0xFF1D: return String.fromCharCode(0x003D);
+        case 0xFF1E: return String.fromCharCode(0x003E);
+        case 0xFF1F: return String.fromCharCode(0x003F);
+        case 0xFF20: return String.fromCharCode(0x0040);
+        case 0xFF21: return String.fromCharCode(0x0041);
+        case 0xFF22: return String.fromCharCode(0x0042);
+        case 0xFF23: return String.fromCharCode(0x0043);
+        case 0xFF24: return String.fromCharCode(0x0044);
+        case 0xFF25: return String.fromCharCode(0x0045);
+        case 0xFF26: return String.fromCharCode(0x0046);
+        case 0xFF27: return String.fromCharCode(0x0047);
+        case 0xFF28: return String.fromCharCode(0x0048);
+        case 0xFF29: return String.fromCharCode(0x0049);
+        case 0xFF2A: return String.fromCharCode(0x004A);
+        case 0xFF2B: return String.fromCharCode(0x004B);
+        case 0xFF2C: return String.fromCharCode(0x004C);
+        case 0xFF2D: return String.fromCharCode(0x004D);
+        case 0xFF2E: return String.fromCharCode(0x004E);
+        case 0xFF2F: return String.fromCharCode(0x004F);
+        case 0xFF30: return String.fromCharCode(0x0050);
+        case 0xFF31: return String.fromCharCode(0x0051);
+        case 0xFF32: return String.fromCharCode(0x0052);
+        case 0xFF33: return String.fromCharCode(0x0053);
+        case 0xFF34: return String.fromCharCode(0x0054);
+        case 0xFF35: return String.fromCharCode(0x0055);
+        case 0xFF36: return String.fromCharCode(0x0056);
+        case 0xFF37: return String.fromCharCode(0x0057);
+        case 0xFF38: return String.fromCharCode(0x0058);
+        case 0xFF39: return String.fromCharCode(0x0059);
+        case 0xFF3A: return String.fromCharCode(0x005A);
+        case 0xFF3B: return String.fromCharCode(0x005B);
+        case 0xFF3C: return String.fromCharCode(0x005C);
+        case 0xFF3D: return String.fromCharCode(0x005D);
+        case 0xFF3E: return String.fromCharCode(0x005E);
+        case 0xFF3F: return String.fromCharCode(0x005F);
+        case 0xFF40: return String.fromCharCode(0x0060);
+        case 0xFF41: return String.fromCharCode(0x0061);
+        case 0xFF42: return String.fromCharCode(0x0062);
+        case 0xFF43: return String.fromCharCode(0x0063);
+        case 0xFF44: return String.fromCharCode(0x0064);
+        case 0xFF45: return String.fromCharCode(0x0065);
+        case 0xFF46: return String.fromCharCode(0x0066);
+        case 0xFF47: return String.fromCharCode(0x0067);
+        case 0xFF48: return String.fromCharCode(0x0068);
+        case 0xFF49: return String.fromCharCode(0x0069);
+        case 0xFF4A: return String.fromCharCode(0x006A);
+        case 0xFF4B: return String.fromCharCode(0x006B);
+        case 0xFF4C: return String.fromCharCode(0x006C);
+        case 0xFF4D: return String.fromCharCode(0x006D);
+        case 0xFF4E: return String.fromCharCode(0x006E);
+        case 0xFF4F: return String.fromCharCode(0x006F);
+        case 0xFF50: return String.fromCharCode(0x0070);
+        case 0xFF51: return String.fromCharCode(0x0071);
+        case 0xFF52: return String.fromCharCode(0x0072);
+        case 0xFF53: return String.fromCharCode(0x0073);
+        case 0xFF54: return String.fromCharCode(0x0074);
+        case 0xFF55: return String.fromCharCode(0x0075);
+        case 0xFF56: return String.fromCharCode(0x0076);
+        case 0xFF57: return String.fromCharCode(0x0077);
+        case 0xFF58: return String.fromCharCode(0x0078);
+        case 0xFF59: return String.fromCharCode(0x0079);
+        case 0xFF5A: return String.fromCharCode(0x007A);
+        case 0xFF5B: return String.fromCharCode(0x007B);
+        case 0xFF5C: return String.fromCharCode(0x007C);
+        case 0xFF5D: return String.fromCharCode(0x007D);
+        case 0xFF5E: return String.fromCharCode(0x007E);
+        case 0xFFE0: return String.fromCharCode(0x009B);
+        case 0xFFE1: return String.fromCharCode(0x009C);
+        case 0xFFE2: return String.fromCharCode(0x00AA);
+        case 0xFFE4: return String.fromCharCode(0x007C);
+        case 0xFFE5: return String.fromCharCode(0x009D);
+        case 0xFFE8: return String.fromCharCode(0x00B3);
+        case 0xFFEA: return String.fromCharCode(0x005E);
+        case 0xFFEC: return String.fromCharCode(0x0056);
+        case 0xFFED: return String.fromCharCode(0x00FE);
+        default:
+            return String.fromCharCode(0x00A8); // Inverted question mark
+    }
+}
+
+// Copied from here, which was last updated on Aug 16, 2020
+// https://gitlab.synchro.net/main/sbbs/-/blob/master/exec/load/utf8_cp437.js
+function utf8_cp437(uni)
+{
+    return uni.replace(/[\xc0-\xfd][\x80-\xbf]+/g, function(ch) {
+        var i;
+        var uc = ch.charCodeAt(0);
+        for (i=7; i>0; i--) {
+            if ((uc & 1<<i) == 0)
+                break;
+            uc &= ~(1<<i);
+        }
+        for (i=1; i<ch.length; i++) {
+            uc <<= 6;
+            uc |= ch.charCodeAt(i) & 0x3f;
+        }
+
+        return unicode_cp437(uc);
+    });
+}