Раздел 23 · Rust

Капстоун: переиспользуемый движок виджетов и крючок для Web3

senior~35 мин

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

Капстоун: переиспользуемый движок виджетов и крючок для Web3

Мы прошли весь путь: что такое WASM, мост в JS, эмулятор в браузере и его цена. Теперь отойдём на шаг и заметим то, что было перед глазами весь блок. Каждый раз, заводя движок в браузер (bcpu, NES, netcode), мы писали почти одинаковую обвязку: загрузи модуль, положи данные в память, дёрни шаг, прочитай состояние по указателю, покажи. Три разных Rust-проекта, один и тот же силуэт. Сегодня мы этот силуэт назовём, превратим в переиспользуемый контракт, на котором держатся все виджеты курса, и оставим крючок: на этом же движке поедут узел блокчейна и мини-EVM из раздела про Web3. Это финал раздела 23, и заодно мост в следующий.

Три движка, один силуэт

Посмотри на экспортируемые функции трёх наших движков рядом, и узор проступит сам:

// bcpu (examples/our-cpu)
bcpu_clear();           bcpu_load(addr, byte);   bcpu_step();
bcpu_reg(i);  bcpu_pc();  bcpu_flags();  bcpu_text_ptr();

// NES (examples/our-nes)
nes_boot();             nes_rom_ptr(len);        nes_run_frame();  nes_step();
nes_set_button(p, b, v);  nes_framebuffer_ptr();  nes_pc();  nes_text_ptr();

// netcode (examples/our-netgame)
netgame_init(seed);     netgame_set_input(btn, aim);  netgame_step();
netgame_server_fb_ptr();  netgame_peer_rtt(i);  netgame_scoreboard_ptr();

Имена разные, глаголы одни и те же. Везде есть: создать свежую машину, загрузить в неё данные снаружи, передать ввод, продвинуть время на шаг или кадр, прочитать состояние (числа напрямую, буферы и текст по указателю). Это не совпадение, это естественная форма любого движка за границей WASM. Назовём её.

Контракт из пяти глаголов

Сведём узор в единый контракт из пяти глаголов. Это не код, который можно расшарить через границу (там только числа), а договорённость о форме, которой следует каждый движок:

  1. init / clear: свежее состояние машины с нуля.
  2. load: положить данные снаружи (ROM, образ, сид) в линейную память по указателю.
  3. input: передать ввод числами (кнопка, ползунок канала, прицел).
  4. step / frame: продвинуть время на одну единицу.
  5. read: прочитать состояние: числа напрямую, буферы и текст по указателю.

Узнаёшь? Это ровно дисциплина HAL из проекта про процессор: стабильный интерфейс, за которым свободно меняется реализация. Только теперь интерфейс проходит через границу WASM, а реализаций три (скоро будет больше). На стороне Rust контракт это тонкий модуль wasm.rs в каждом крейте, плоские extern "C" обёртки над уже готовым ядром. Ядро движок не знает про браузер вовсе.

JS-сторона: один загрузчик на всех

Раз форма общая, обвязку на JS можно написать один раз. Сейчас bcpu-engine.ts и nes-engine.ts повторяют друг друга: оба инстанцируют модуль, оба заводят call(name, ...args), оба читают текст из буфера. Вынесем общее в переиспользуемый загрузчик, а движок-специфичное оставим тонкой надстройкой:

// Переиспользуемое ядро загрузчика: общее для bcpu, NES, netcode и будущих движков.
export type WasmEngine = {
  call: (name: string, ...args: number[]) => number;
  // Окно поверх линейной памяти. Берётся заново перед каждым чтением: после роста
  // памяти старый буфер отвязывается (урок 69).
  viewBytes: (ptr: number, len: number) => Uint8Array;
  readText: (ptrFn: string, lenFn: string) => string;
};

export async function loadWasmEngine(url: string): Promise<WasmEngine> {
  const { instance } = await WebAssembly.instantiateStreaming(fetch(url), {});
  const raw = instance.exports as Record<string, CallableFunction> & {
    memory: WebAssembly.Memory;
  };
  const decoder = new TextDecoder();
  const call = (name: string, ...args: number[]): number =>
    (raw[name] as (...values: number[]) => number)(...args);

  return {
    call,
    viewBytes: (ptr, len) => new Uint8Array(raw.memory.buffer, ptr, len),
    readText: (ptrFn, lenFn) =>
      decoder.decode(new Uint8Array(raw.memory.buffer, call(ptrFn), call(lenFn))),
  };
}

Теперь nes-engine.ts это просто отображение глаголов NES на этот загрузчик (runFrame: () => call('nes_run_frame') и так далее), а не копия загрузки и декодирования. То же ядро обслуживает любой движок, который следует контракту. Один раз написать правило про окна и рост памяти, и больше не ошибаться в нём в каждом виджете.

Весь третий проект в браузере

Контракт держит и самый сложный наш движок, сетевую игру. netgame.wasm следует той же пятёрке: netgame_init(seed) это init, netgame_set_link и netgame_set_input это input, netgame_step это шаг, а указатели на два кадра арены и таблицу очков это read. Весь netcode (предсказание, реконсиляция, надёжность поверх UDP, симулятор плохого канала) крутится в Rust прямо в браузере, без единого байта бэкенда. Слева правда сервера, справа мир глазами клиента, ползунки дёргают LinkConfig:

Тот же Rust проходит нативные тесты с golden-трейсами, крутится в терминальном TUI и живёт вот этим виджетом. Одна реализация, три поверхности, и виджет здесь ещё один потребитель контракта.

Крючок для Web3

А теперь главное про будущее, ради чего капстоун и существует. Контракту всё равно, что считает движок. Пять глаголов одинаково ложатся на эмулятор, на сетевую игру и на что угодно ещё, что умеет шагать и отдавать состояние. В разделе про Web3 это сыграет дважды.

Узел блокчейна (эталон examples/our-chain) экспортирует тот же силуэт: chain_init это init, chain_add_tx это load, chain_mine это step, а указатели на заголовок блока и состояние это read. Менти будет майнить блок, видеть рост сложности и реорганизацию форка прямо в браузере. Мини-EVM (эталон examples/our-evm) тоже: загрузи байткод, шагай по опкодам, читай стек, память и расход газа. Опкод-степпер EVM это тот же шаговый режим, что у bcpu, просто машина другая. Новая тема приносит лишь новое ядро и новые имена, а обвязка и скелет виджета переиспользуются один в один. Движок виджетов готов, и он переживёт раздел.

Что ты построил за раздел 23

Капстоун это повод оглянуться. Семьдесят один урок назад ты настраивал rustup. Между тем и этим уроком ты прошёл владение как модель ресурсов, трейты и систему типов, биты и машинный уровень, глубокий core с unsafe и вариантностью, макросы, и собрал три системных проекта: эмулятор учебного процессора с HAL, полноценный эмулятор NES и сетевой игровой движок со своим протоколом. Потом разобрал async как машину состояний, атомики на уровне книги Мары Бос и наконец увёл весь этот Rust в браузер через WebAssembly. Один язык, от первой программы до движка, который читает дизассемблер, не боится атомиков и крутится живьём на странице. Это и был план: не выучить синтаксис, а стать системным инженером, которому видно до самого низа.

Дальше раздел про Web3 ставит на эту базу всю криптоэкономику: свой блокчейн, чужие виртуальные машины, DeFi и HFT на Rust. Но фундамент уже под ногами, и движок виджетов поедет с тобой.

Что унести из урока

Все три наших движка (bcpu, NES, netcode) экспортируют один и тот же силуэт, и это не совпадение, а естественная форма движка за границей WASM. Силуэт сводится к контракту из пяти глаголов: init, load, input, step, read, причём это договорённость о форме, а не общий код, потому что через границу ходят только числа. Раз форма общая, JS-обвязку пишут один раз (загрузчик с call, окнами поверх памяти и чтением текста), а движок-специфичное остаётся тонкой надстройкой, отображением глаголов на этот загрузчик. На этом контракте весь netcode крутится в браузере без бэкенда, и на нём же поедут узел блокчейна и мини-EVM из раздела Web3: контракту всё равно, что внутри движка, поэтому новая тема приносит лишь новое ядро, а не новую обвязку. Раздел 23 на этом закрыт: один язык довёл тебя от первой программы до системного инженера. Дальше Web3 на этом фундаменте.

Домашка