AI Wrote Raw Binary Instead of Code. Here’s What Happened (WebAssembly)

2 views

What if AI did not write code at all? No JavaScript, no Python, just the raw bytes the machine runs. “Skip the code and generate the binary directly” is an idea that keeps going around, so I tested it for real with a local model on my own computer, with an automated harness that validates, runs and tests every answer. Every result below is a real run, including the many failures.

This tutorial walks through the full experiment: how a WebAssembly module is laid out byte by byte, how to build a test harness in Node.js 22 that asks Ollama (qwen2.5:7b, Q4_K_M, CPU only) for answers in three formats, how to validate them with WebAssembly.validate and wabt, and what the scoreboard says about AI and binary.

⚡ TL;DR – A website cannot be pure binary: browsers run text (HTML, CSS, JavaScript). WebAssembly is the closest real thing. – Raw hex .wasm bytes from a 7B local model: 0/15 valid. The 8-byte header was perfect every time; a length field broke at byte 10. – WebAssembly text (WAT): 6/15 passed, 4 only after renaming old instruction names. – Plain JavaScript: 13/15 passed; the 2 failures imported a made-up package. – Better idea: let AI write code and let compilers produce the binary.

Before you start

  • Node.js 22 (built-in fetch, WebAssembly, ES modules).
  • Ollama with ollama pull qwen2.5:7b. Any machine works; the video ran on CPU only.
  • wabt (WebAssembly Binary Toolkit). The npm package wabt exposes parseWat from JavaScript.
  • Basic familiarity with hex and with ES modules.

Step 1: Understand the honest catch

Browsers do not run binary websites. They parse text: HTML, CSS and JavaScript. So “a website written in pure binary” is impossible today. The one binary format browsers do execute is WebAssembly (.wasm). The experiment therefore becomes: the AI writes a wasm module as raw hex bytes with no source code at all, and a tiny HTML page loads and runs it.

Step 2: Read a real wasm module byte by byte

Here is a hand-written add function, 41 bytes in total:

text
00 61 73 6d             ; magic "\0asm"
01 00 00 00             ; version 1
01 07 01 60 02 7f 7f 01 7f    ; type section: (i32, i32) -> i32
03 02 01 00             ; function section: 1 function of type 0
07 07 01 03 61 64 64 00 00    ; export section: "add" -> func 0
0a 09 01 07 00 20 00 20 01 6a 0b ; code: local.get 0, local.get 1, i32.add, end

Why this is hard for a language model: every section starts with its own length in bytes, encoded as LEB128. Get one count wrong and every byte after it is read as something else. There are no comments, no names, and no partial credit. A language model predicts the next token; it does not count bytes.

Sanity-check the harness with the known-good bytes first:

js
// sanity.mjs
const hex = "0061736d0100000001070160027f7f017f030201000707010361646400000a09010700200020016a0b";
const bytes = Uint8Array.from(hex.match(/../g), h => parseInt(h, 16));
console.log("valid:", WebAssembly.validate(bytes));
const { instance } = await WebAssembly.instantiate(bytes);
console.log("add(2,3) =", instance.exports.add(2, 3)); // 5

Valid, and add(2, 3) returns 5. The harness works.

Step 3: Define the tasks

Three small jobs, each with an automatic test:

js
// tasks.mjs
export const tasks = {
  add: {
    desc: "export a function add(a, b) that returns a + b for 32-bit integers",
    test: e => e.add(2, 3) === 5 && e.add(-7, 10) === 3,
  },
  counter: {
    desc: "export a function next() that returns 1, then 2, then 3 on successive calls",
    test: e => e.next() === 1 && e.next() === 2 && e.next() === 3,
  },
  draw: {
    desc: "export memory and a function draw() that fills the first 256 RGBA pixels with opaque red",
    test: e => { e.draw(); const m = new Uint8Array((e.memory || e.mem).buffer);
      for (let i = 0; i < 256 * 4; i += 4)
        if (m[i] !== 255 || m[i+1] !== 0 || m[i+2] !== 0 || m[i+3] !== 255) return false;
      return true; },
  },
};

Step 4: Ask the model in three formats

js
// ask.mjs
const PROMPTS = {
  hex: t => `Write a WebAssembly module that will ${t}. Output only the bytes as hex, starting with the wasm header. No source code, no explanation.`,
  wat: t => `Write a WebAssembly text (WAT) module that will ${t}. Output only the WAT.`,
  js:  t => `Write a plain JavaScript ES module that will ${t}. Output only the code.`,
};
export async function ask(format, task) {
  const r = await fetch("http://localhost:11434/api/generate", {
    method: "POST",
    body: JSON.stringify({ model: "qwen2.5:7b", stream: false,
      prompt: PROMPTS[format](task), options: { temperature: 0.7, num_predict: 1024 } }),
  });
  return (await r.json()).response;
}

Step 5: Check every answer automatically

js
// check.mjs
import wabtInit from "wabt";
const wabt = await wabtInit();

function hexToBytes(s) {
  const pairs = s.match(/\b[0-9a-fA-F]{2}\b/g) || [];
  return Uint8Array.from(pairs, h => parseInt(h, 16));
}
function watToBytes(src) {
  try { return { bytes: wabt.parseWat("m.wat", src).toBinary({}).buffer, renamed: false }; }
  catch {
    // second chance: pre-2019 names such as get_local -> local.get
    const fixed = src.replace(/get_local/g, "local.get").replace(/set_local/g, "local.set")
                     .replace(/get_global/g, "global.get").replace(/set_global/g, "global.set");
    return { bytes: wabt.parseWat("m.wat", fixed).toBinary({}).buffer, renamed: true };
  }
}
export async function check(format, answer, test, n) {
  if (format === "js") {
    const url = "data:text/javascript;base64," + Buffer.from(answer + `\n//${n}`).toString("base64");
    return test(await import(url));
  }
  const { bytes } = format === "hex" ? { bytes: hexToBytes(answer) } : watToBytes(answer);
  if (!WebAssembly.validate(bytes)) return "invalid";
  const { instance } = await WebAssembly.instantiate(bytes);
  return test(instance.exports);
}

Note the //${n} comment appended to each JavaScript data URL. That line exists because of a real bug in my harness, covered below.

Step 6: Run five attempts per cell

js
// run.mjs
import { writeFile } from "node:fs/promises";
import { tasks } from "./tasks.mjs"; import { ask } from "./ask.mjs"; import { check } from "./check.mjs";
let n = 0;
for (const format of ["hex", "wat", "js"]) for (const [name, t] of Object.entries(tasks)) {
  let pass = 0;
  for (let i = 0; i < 5; i++) {
    const a = await ask(format, t.desc);
    await writeFile(`out/${format}-${name}-${i}.txt`, a);
    let ok; try { ok = await check(format, a, t.test, n++); } catch { ok = false; }
    if (ok === true) pass++;
  }
  console.log(format, name, `${pass}/5`);
}

Step 7: Read the raw-binary results

Raw hex, all three tasks: 0/5, 0/5, 0/5. Every answer started with a perfect header, 00 61 73 6d 01 00 00 00. Then the validator reported an error at byte 10: the section length. After that the model repeated zeros and small numbers until it hit the token limit.

One pixel answer looked convincingly like binary. It had the right header and the right “shape”, but it was the shape of a wasm file, not a wasm file. The model learned what these bytes look like, not what they mean. Loaded in the browser test page, draw.wasm was simply invalid: nothing to draw.

Step 8: Compare text formats

WAT (same model, same jobs): add 5/5 (3 of them used old names like get_local and passed only after the rename), counter 1/5, pixels 0/5. Text helps, but WebAssembly is still a niche language for a 7B model.

JavaScript: add 5/5, counter 5/5, pixels 3/5. The counter first showed 1/5, and that was my bug, not the model’s. Identical answers produced identical data URLs, so Node reused the cached module and the counter kept counting from where the previous attempt stopped. Making each URL unique fixed it. The harness can lie too, so test the tester. The two pixel failures were real: the model imported Uint8Array from a package called Uint8Array. That type is built into JavaScript; the package is invented.

Step 9: Draw pixels the right way

How do you actually get pixels from WebAssembly? Write it as text and let the compiler produce the binary:

wat
(module
  (memory (export "memory") 1)
  (func (export "draw") (local $i i32)
    (loop $l
      (i32.store (local.get $i) (i32.const 0xff0000ff)) ;; bytes: ff 00 00 ff = R G B A
      (local.set $i (i32.add (local.get $i) (i32.const 4)))
      (br_if $l (i32.lt_u (local.get $i) (i32.const 1024))))))

wabt turns that into 75 bytes. The loader page fetches it, validates it, calls draw(), and copies memory into a canvas:

html
<canvas id="c" width="16" height="16" style="width:256px;image-rendering:pixelated"></canvas>
<script type="module">
  const bytes = new Uint8Array(await (await fetch("draw.wasm")).arrayBuffer());
  if (!WebAssembly.validate(bytes)) { document.body.append("Invalid wasm"); }
  else {
    const { instance } = await WebAssembly.instantiate(bytes);
    instance.exports.draw();
    const px = new Uint8ClampedArray(instance.exports.memory.buffer, 0, 16 * 16 * 4);
    document.getElementById("c").getContext("2d").putImageData(new ImageData(px, 16, 16), 0, 0);
  }
</script>

The result is a 16 by 16 red square drawn by 75 bytes of WebAssembly, made from text by a compiler rather than by an AI typing bytes.

The scoreboard

Format Passed Notes
Raw hex bytes 0/15 Perfect header, broken length at byte 10
WAT 6/15 4 needed old-name rename
JavaScript 13/15 2 imported a made-up package

That is one small local model with five tries each. A bigger cloud model would likely do better on text, but the gap tells the story.

Why binary is so hard for LLMs

  • Models learn from text, and there is very little raw wasm on the internet compared with source code.
  • Binary formats need exact byte counts (LEB128 lengths); one slip corrupts everything after it.
  • Even when binary output works, you cannot read it, review it or fix it.
  • Compilers already turn text into correct binary every single time.

Troubleshooting

  • WebAssembly.validate returns false on hand-written hex: recount each section’s length byte; it must cover exactly the bytes that follow in that section.
  • wabt rejects model WAT with “unexpected token get_local”: the model used pre-2019 names. Rename to local.get and friends, and record it as a partial pass.
  • JavaScript results drift between attempts: dynamic import() caches by URL. Make every data URL unique.
  • “Cannot find package” errors: the model invented a dependency. Treat it as a failure; that is a real-world risk.
  • Canvas stays blank: check that the module exports memory and that you read pixels at the offset the module writes to.

Best practices

  • Validate before you run: WebAssembly.validate is cheap and catches structural garbage.
  • Instantiate untrusted modules with no imports so they cannot reach anything outside their own memory.
  • Save every raw answer to disk so you can inspect failures, not just count them.
  • Sanity-check the harness with known-good input before trusting any score.
  • Let models write readable source; let toolchains emit binaries.

FAQ

Can a website be pure binary?

No. Browsers parse HTML, CSS and JavaScript as text. WebAssembly runs inside a page, but something textual still loads it.

Would a larger model write valid wasm bytes?

Possibly better on WAT and JavaScript. Raw bytes remain hard because of exact length fields and the scarcity of training data, which is what this series goes on to tackle.

Is WebAssembly faster to generate than JavaScript?

Not for an LLM. On this test it was far less reliable, and the speed benefit of wasm comes at runtime, not at generation time.

What is LEB128?

A variable-length integer encoding used for sizes and indexes in wasm. Small numbers take one byte; larger ones spill into more bytes, which makes counting even harder for a model.

Key takeaways

  • A local 7B model writing raw .wasm bytes went 0/15; the same model in JavaScript went 13/15.
  • The model reproduced the header perfectly but could not keep section lengths consistent.
  • Your test harness can be wrong too: a cached data URL faked a counter failure.
  • Code is not what slows AI down; it is the interface that makes AI output reviewable and fixable.
  • Let AI write code, and let compilers produce the binary.

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.

Subscribe free →


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.