Эмулятор Theseus теперь умеет превращать .exe-файлы в WebAssembly — то есть запускать старые виндовые программы прямо в браузере. Сейчас там полно багов (например, «Сапёр» падает, если выиграть), но работает.
Сама x86-эмуляция перекомпилируется с другим CPU-таргетом — это одно из главных преимуществ такого подхода. Скомпилированный код почти не зависит от окружения (кроме точки входа main). Сложности были с настройкой Cargo под странные требования. Win32-часть пришлось абстрагировать через Host API — один раз для SDL, один раз для веба.
Главная головная боль — блокировки. В браузере нельзя блокировать основной поток. В retrowin32 эмулятор был асинхронным: шагнул пару инструкций — вернул управление. Это заставляло делать асинхронными даже MoveWindow (он синхронно шлёт сообщения), ломало стектрейсы и усложняло отладку.
В Theseus решили сделать всё синхронно, а потоки реализовать настоящими ОС-потоками. Карта исходного кода на вызовы функций — стектрейсы идут и по программе, и по эмулятору. Производительность тоже выиграла: компьютер быстрее выполняет простые вложенные вызовы, чем асинхронные переключения.
Но на вебе блокироваться на главном потоке нельзя. Выход — использовать web workers. Эмулятор работает в воркере, он может там блокироваться. Когда нужно что-то от браузера, воркер шлёт сообщение на главный поток через postMessage, а сам встаёт на atomic wait. Главный поток обрабатывает запрос, будит воркер через Atomics.notify.
Для этого используется разделяемая память (shared memory). Воркер передаёт адрес локальной переменной — пока главный поток её не запишет, воркер ждёт. Так можно передавать и буферы с пикселями (без копирования), и дожидаться событий.
Ограничение: главный поток не может передать воркеру объекты браузера — только данные через разделяемую память. Объекты передаются только через postMessage по событийному циклу.
Код для главного потока пришлось писать на TypeScript, а не на Rust/wasm. Причина: главный поток не может использовать разделяемую память для malloc — нужны синхронизации, а браузерные API для работы с shared memory проще вызывать из TypeScript. К тому же воркеры не имеют доступа к DOM — это естественная граница. TypeScript даёт удобную отладку (в отличии от wasm).
Rust под wasm с shared memory — ещё сыроват. Для воркеров пришлось пересобирать стандартную библиотеку с поддержкой атомиков (только nightly).
Главный минус — сериализация сложных объектов. Автор пока не нашёл удобного механизма, но присматривается к библиотеке rkyv.
Общий вывод: писать app’ы на wasm можно, но до удобства нативных сборок ещё далеко. В Figma делали так же — нативный fallback, и это хороший паттерн. Rust с shared memory — ещё очень ранний подход («используй nightly, чтобы перекомпилировать stdlib» — не айс).