Раздел 23 · Rust

Эмулятор в браузере: кадр в canvas, ввод и звук

senior~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Эмулятор в браузере: кадр в 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 выигрывает, а где нет.

Домашка