Эмулятор в браузере: кадр в canvas, ввод и звук
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Эмулятор в браузере: кадр в canvas, ввод и звук
Два прошлых урока были про устройство (что такое WASM) и про инструменты (мост через wasm-bindgen и почему наши движки идут без него). Сегодня собираем всё это вживую: берём наш эмулятор NES и
bcpu, уже скомпилированные вwasm32, и запускаем прямо на странице. Тот самый Rust, что проходит nestest в нативных тестах, сейчас крутится у тебя в браузере, рисует кадр вcanvasи слушает клавиатуру. И главное: JavaScript тут не исполняет ни одного опкода. Он грузит модуль, дёргает кадр и забирает картинку из линейной памяти. Вся работа в Rust. Виджет внизу урока это и есть результат, к которому мы идём.
Четыре обязанности хоста
Когда движок уехал в WASM, у JavaScript остаётся ровно четыре роли, и ни одной больше. Загрузить модуль и образ игры. Двигать время: попросить движок посчитать кадр. Читать результат: кадр, аудио, состояние процессора из линейной памяти. Показать: нарисовать кадр в canvas, отправить звук в динамики, обновить отладчик. Вся логика эмуляции (6502, PPU, мапперы, тайминг) живёт в Rust и наружу не торчит. Хост это тонкая оболочка вокруг плотного ядра, ровно та же граница механизма и интерфейса, что ты проводил в HAL.
Загрузка: модуль и образ игры
Сборка .wasm уже лежит готовым файлом в public/wasm/nes.wasm, рядом с bcpu.wasm и netgame.wasm. Грузим его без всякого wasm-bindgen, голым браузерным API, с запасным путём на случай неверного MIME:
async function instantiate(): Promise<WebAssembly.Instance> {
// instantiateStreaming требует MIME application/wasm. Если сервер отдал не его,
// падаем на ручную загрузку через arrayBuffer.
try {
const { instance } = await WebAssembly.instantiateStreaming(fetch(WASM_URL), {});
return instance;
} catch {
const response = await fetch(WASM_URL);
const bytes = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, {});
return instance;
}
}
Второй аргумент instantiate это объект импортов, и он пустой {}: наш движок ничего не просит у хоста, он только считает и работает со своей памятью. А вот образ игры (файл .nes) приходит снаружи, и его надо положить в линейную память движка. Тот же приём «спроси указатель, запиши байты»:
bootRom: (bytes) => {
const ptr = call('nes_rom_ptr', bytes.length);
// Свежий взгляд на буфер: после resize память могла вырасти и отвязаться.
new Uint8Array(raw.memory.buffer, ptr, bytes.length).set(bytes);
return call('nes_boot') === 1;
},
Rust выделяет место под ROM и возвращает смещение, JS пишет туда байты картриджа через окно поверх памяти, и nes_boot разбирает заголовок iNES. Обрати внимание на комментарий: после роста памяти буфер мог отвязаться, поэтому окно строят заново перед каждым доступом, а не держат в переменной. Это прямое следствие правила про Uint8Array::view из прошлого урока, только со стороны JS.
Кадровый цикл: посчитать, прочитать, нарисовать
Сердце виджета это цикл на requestAnimationFrame. Браузер зовёт его примерно 60 раз в секунду, и на каждый зов мы просим движок посчитать ровно один кадр, забираем картинку и кладём её на canvas:
const loop = () => {
const eng = engine();
if (eng && running()) {
eng.runFrame(); // Rust считает целый кадр: CPU, PPU, APU
draw(eng); // забрать пиксели и показать
refresh(eng); // обновить отладчик
}
raf = requestAnimationFrame(loop);
};
Один вызов nes_run_frame это целый кадр работы 6502 и PPU внутри Rust: десятки тысяч тактов, весь конвейер рендера фона и спрайтов. Наружу отдаётся готовый кадр, и забор пикселей это самое интересное место с точки зрения прошлого урока:
framebuffer: () => {
const ptr = call('nes_framebuffer_ptr');
const width = call('nes_frame_width');
const height = call('nes_frame_height');
// Окно поверх линейной памяти, без копии. RGBA по 4 байта на пиксель.
return new Uint8ClampedArray(raw.memory.buffer, ptr, width * height * 4);
},
// Снять кадр из wasm и нарисовать его в canvas.
const draw = (eng: Engine) => {
if (!ctx || !image) return;
image.data.set(eng.framebuffer()); // 256 * 240 * 4 байта
ctx.putImageData(image, 0, 0);
};
Кадр это 256 * 240 * 4 байта (RGBA), и читается он нулевым копированием: framebuffer() отдаёт окно прямо поверх линейной памяти, а единственная копия это image.data.set(...) в буфер canvas (он своим буфером владеет сам, тут копия неизбежна). На шестидесяти кадрах в секунду каждая лишняя копия четверти мегабайта была бы заметна, поэтому окно, а не срез с копированием.
Чтение состояния: отладчик поверх памяти
Эмулятор это не только картинка. Виджет показывает живой отладчик: регистры, флаги, дизассемблер вокруг PC. Регистры приходят как обычные числа, по функции на каждый, а дизассемблер через тот самый текстовый буфер из урока 67:
disasm: (addr) => {
const length = call('nes_disasm', addr); // Rust пишет строку в буфер, вернёт длину инструкции
const ptr = call('nes_text_ptr');
const count = call('nes_text_len');
const text = decoder.decode(new Uint8Array(raw.memory.buffer, ptr, count));
return { text, length };
},
Rust дизассемблирует инструкцию в свой буфер, JS читает байты и декодирует UTF-8. Никаких строк через границу, только числа и память. Из этого виджет строит панель из четырнадцати строк вокруг текущего PC, подсвечивая исполняемую, и так у тебя в браузере настоящий пошаговый отладчик 6502.
Ввод: клавиатура в числа
Геймпад это тоже числа. Браузер ловит нажатие клавиши, виджет переводит код клавиши в номер кнопки и передаёт движку:
// Раскладка клавиатуры на кнопки геймпада 1.
const KEYMAP: Record<string, number> = {
KeyZ: BUTTON.A,
KeyX: BUTTON.B,
Enter: BUTTON.Start,
ArrowUp: BUTTON.Up,
// ...
};
const handleKey = (pressed: boolean) => (event: KeyboardEvent) => {
const button = KEYMAP[event.code];
if (button === undefined) return;
event.preventDefault();
engine()?.setButton(0, button, pressed); // pad, button, нажата ли
};
setButton(0, button, pressed) это три числа: номер геймпада, номер кнопки, состояние. Движок держит у себя регистр контроллера, а Rust на следующем кадре прочитает его как настоящий NES читает порт $4016. Порядок номеров кнопок в KEYMAP совпадает с порядком enum Button в Rust: договорённость на числах, и обе стороны её держат.
Звук: самплы наружу и Web Audio
Со звуком есть одна важная разница против кадра. Аудио движок отдаёт копией, а не окном:
audio: () => {
const count = call('nes_audio_len');
const ptr = call('nes_audio_ptr');
// Копия наружу: исходный буфер перепишется на следующем кадре.
return new Float32Array(raw.memory.buffer, ptr, count).slice();
},
Почему копия (.slice()), а не окно, как у кадра? Потому что кадр мы рисуем сразу же, а самплы уезжают в звуковую подсистему и проигрываются с задержкой, уже после того, как Rust перезапишет буфер следующим кадром. Окно к тому моменту показывало бы чужие данные. Правило из прошлого урока работает и тут: потребляешь сразу, бери окно; откладываешь на потом, копируй. Дальше самплы кладут в Web Audio: на каждый кадр заводят AudioBuffer, заливают в него Float32Array через copyToChannel и проигрывают встык. Сам виджет ниже сейчас показывает картинку и отладчик без звука, но самплы движок считает, и пайплайн ровно такой.
Один приём, три движка
Всё, что мы разобрали, не про NES конкретно. bcpu живёт в браузере точно так же: своя сборка public/wasm/bcpu.wasm, своя тонкая склейка bcpu-engine.ts с тем же instantiateStreaming, и пошаговый виджет процессора, где ты жмёшь «шаг» и видишь, как меняются регистры. И netcode-симулятор из прошлого блока это netgame.wasm по той же схеме. Три разных Rust-проекта, один и тот же приём хоста: загрузи, дёрни шаг или кадр, прочитай состояние из линейной памяти, покажи. Меняется набор экспортированных функций (nes_*, bcpu_*, netgame_*), не меняется идея.
Вот он, живьём. Это examples/our-nes, собранный в wasm32, грузит nestest и даёт пошаговый отладчик. Стрелки и Z/X/Enter это геймпад:
Что унести из урока
Когда движок уехал в WASM, у JavaScript остаётся четыре роли: загрузить, двигать время, читать состояние, показывать. Ни одного опкода JS не исполняет, вся эмуляция в Rust. Модуль грузится голым instantiateStreaming с пустым объектом импортов, а образ игры кладётся в линейную память через окно по указателю, причём окно строят заново перед каждым доступом, потому что рост памяти отвязывает старый буфер. Кадровый цикл сидит на requestAnimationFrame: один runFrame это целый кадр работы CPU и PPU внутри Rust, а наружу отдаётся готовая картинка, которую читают нулевым копированием (окно поверх памяти) и кладут на canvas единственной копией в putImageData. Состояние отладчика и дизассемблер приходят теми же числами и текстовым буфером, ввод уходит в движок тремя числами. Аудио, в отличие от кадра, копируют через .slice(), потому что самплы переживают следующий прогон, и проигрывают через Web Audio. Один и тот же приём хоста обслуживает bcpu, NES и netcode, меняется лишь список функций. Дальше: где WASM выигрывает, а где нет.