A buck converter controller in C, identical to its JavaScript law to the bit

Measured on 2026-09-25 in real runs through plimsoll's docker tier (container). A buck converter is the power supply that turns a battery or bus voltage into the lower voltage a chip runs on, by switching the input on for a fraction of each cycle (the duty cycle); the controller that picks that fraction is firmware, usually written in C. The caller sent two files: controller.c, such a controller, and controller.js, a Node shim with no arithmetic of its own, the same one the cart-pole C controller runs under. The run's first step compiled the C to WebAssembly inside the sandbox, with no network. The later steps handed the module to the runner baked into the image, which drove a simulated converter (the plant, itself compiled to WebAssembly) through the controller one 10 microsecond tick at a time for 20 ms and recorded every tick. The SHA-256 of that record is the fingerprint.

4 of 4
scenarios where the C port's record is byte-identical to the JavaScript law's
1583 bytes
controller.wasm, the same SHA-256 from two separate compiles
0.888 to 0.914
regulation score (fraction of ticks from 2 ms on within 0.1 V of 5 V); the floor is 0.6
5.10 V, 5.00 A
highest output voltage and inductor current in any scenario; the limits are 6 V and 10 A

Scenario s1: 12 V in, a 4 ohm load whose resistance halves, doubling its current, at 10 ms

The C port's record, every tick. The JavaScript law's record of the same scenario is the same bytes, so drawing it too would cover this line exactly. Move the pointer across the chart to read the values at any tick.

The same trace as a table, every millisecond
time (ms)output voltage (V)inductor current (A)duty cycle
00.00000.00000.0000
11.58310.56400.1360
23.25000.97900.2749
34.91671.39570.4138
45.00021.24830.4167
55.00001.25000.4167
65.00001.25000.4167
75.00001.25000.4167
85.00001.25000.4167
95.00001.25000.4167
104.88981.25440.4378
114.98802.50430.4156
125.00032.50000.4167
135.00002.50000.4167
145.00002.50000.4167
155.00002.50000.4167
165.00002.50000.4167
175.00002.50000.4167
185.00002.50000.4167
195.00002.50000.4167

Why the duty cycle ends where it started

Before the step the duty cycle holds at 0.4167 and the inductor carries 1.25 A. The step doubles the current the load draws, so the controller raises the duty cycle for a moment (to 0.4749 at most) to speed the inductor current up. Once the current reaches 2.50 A, the duty cycle settles at 0.4167: where it started. The reason is the inductor. The voltage across it is the duty cycle times the input voltage minus the output voltage, and that voltage does one thing: it changes the inductor's current. Holding any current steady, small or large, takes zero volts across the inductor, so the duty cycle that holds 5 V is 5 V divided by the input voltage, 5 / 12 = 0.4167, whatever the load draws. The load decides how much current flows; the duty cycle only has to change while that current is changing.

This is exact here because the simulated converter has no resistance in its inductor or switches. In real hardware those resistances drop a little voltage that grows with the current, so the duty cycle settles slightly higher under the heavier load.

For engineers: in the plant's own terms the load halves. Its parameter is the load resistance, which drops from 4 to 2 ohm at 10 ms; at 5 V that is a 100 percent load step, 1.25 A to 2.50 A. A lower resistance is a heavier load, which is why the rest of this page says the current doubles rather than that the load halves.

Every scenario

The controller is not told the input voltage, the load, or when the load steps; it sees only the output voltage and the inductor current. Three runs covered each scenario: the C build twice and the JavaScript law once.

scenarioinputload, then when its resistance halves (current doubles)scorepeak Vpeak |A|fingerprint (C run 1)C run 2 and JavaScript (the run writes no page unless both match)
s112 V4 ohm, 10 ms0.9085.0622.5059bcc0a3a963d11853ba0bd1a7bc3850a00d55115b6a2ee3de2cdd51ad69006f7both identical
s210 V8 ohm, 6 ms0.9145.1041.340ef91a39f0afe20aab5b6cc32aa41414f44afa9f0a60ba1c9e7f910ccca92db75both identical
s316 V2 ohm, 12 ms0.8885.0145.000b39ccc8ac760e1d8fe5a2b738eefac644854b2e7ca87c15bb6c23acf919a1a39both identical
s414 V4 ohm, 15 ms0.9085.0582.504e040e81c7729ede8daf4348e8768db0ff5179c58975a664f695a968d86e66a56both identical

Why the two languages agree to the bit here, and did not for the cart-pole

The C is a line-for-line port of the JavaScript law: every expression keeps its operand order, and the build passes -ffp-contract=off, so the compiler never fuses a multiply and an add into one step with a different rounding. What is left is a sequence of IEEE 754 double additions, multiplications, divisions and comparisons, which both languages must compute identically. The cart-pole controller also calls cos, and there the C module's cosine (wasi-libc's, compiled into it) and V8's Math.cos differ in the last bit on about one input in a hundred, which is enough to change its fingerprint. This law calls no library function, so there is nothing left that could differ, and the runs confirm it.

Build step, run inside the sandbox: clang --target=wasm32-wasip1 -mexec-model=reactor -O2 -ffp-contract=off -Wall -Werror -o controller.wasm controller.c (compile 53 ms). controller.wasm SHA-256: 16109c964dd1925bfeded590cb9854b07f26e8287f5f9d28e2aaefc56094f61d. It imports nothing: the shim gives it an empty import object and refuses any module that asks for more.

controller.c (what ran)

// A buck converter controller in C, compiled to WebAssembly inside the sandbox and
// run against the plant /models/buck.wasm: an averaged synchronous buck
// converter (100 uH, 100 uF) that must hold 5 V from an input of 10 to 16 V while
// the load resistance halves partway through the run, doubling the current it draws.
//
// The law is average current-mode control, the usual structure of converter
// firmware. An inner loop makes the inductor current follow a reference (fast: the
// inductor answers the duty cycle in tens of microseconds), and an outer
// proportional-integral voltage loop sets that reference, clamped so the inductor
// is never asked for more than 6 A. The voltage target ramps from 0 to 5 V over the
// first 3 ms, so a cold start is a controlled charge, not an inrush. Both loops
// have anti-windup: an integrator stops accumulating while its output is clamped.
// Current feedback is what damps the LC resonance a voltage-only loop rings on.
//
// The contract with controller.js, the Node shim the runner spawns: the module exports
// one function, control, called once per 10 us tick with the output voltage, the
// inductor current and the tick index, returning the duty cycle in [0, 1]. State
// between ticks lives in globals, because a module instance lives for the whole
// run. The module imports nothing.
//
// This is a line-for-line port of reference.js, the same law in JavaScript. Every
// expression keeps the JavaScript operand order and the build passes
// -ffp-contract=off, so no multiply and add are fused; the law calls no library
// function at all. Each operation is therefore the same IEEE 754 double operation
// in both languages, and the two trajectories are identical to the bit.

static const double ref = 5, h = 1e-5, ramp = 0.003, iMax = 6;
static const double kpv = 1.0, kiv = 5000, kci = 0.2, kii = 2000;
static double t = 0, vInt = 0, dInt = 0;

__attribute__((export_name("control")))
double control(double v, double i, int k) {
  (void)k; // the law keeps its own clock, t, as reference.js does
  const double target = t < ramp ? ref * (t / ramp) : ref;
  const double e = target - v;
  double iRef = kpv * e + kiv * vInt;
  if (iRef > -2 && iRef < iMax) vInt += e * h;
  if (iRef > iMax) iRef = iMax;
  if (iRef < -2) iRef = -2;
  const double ei = iRef - i;
  double u = kci * ei + dInt;
  if (u >= 0 && u <= 1) dInt += kii * ei * h;
  if (u < 0) u = 0;
  if (u > 1) u = 1;
  t += h;
  return u;
}

reference.js (the JavaScript law)

// The accepted controller: average current-mode control. An inner loop makes the
// inductor current follow a reference (fast: the inductor answers the duty in
// tens of microseconds), and an outer proportional-integral voltage loop sets
// that reference, clamped so the inductor can never be asked for more than 6 A.
// The reference voltage ramps from 0 to 5 V over the first 3 ms so a cold start
// is a controlled charge, not an inrush. Both loops have anti-windup. Current
// feedback is what damps the LC resonance that a voltage-only loop rings on.
import { createInterface } from 'node:readline';
const ref = 5, h = 1e-5, ramp = 0.003, iMax = 6;
const kpv = 1.0, kiv = 5000, kci = 0.2, kii = 2000;
let t = 0, vInt = 0, dInt = 0;
const rl = createInterface({ input: process.stdin });
rl.on('line', (line) => {
  const [v, i] = line.split('|')[0].trim().split(' ').map(Number);
  const target = t < ramp ? ref * (t / ramp) : ref;
  const e = target - v;
  let iRef = kpv * e + kiv * vInt;
  if (iRef > -2 && iRef < iMax) vInt += e * h;
  if (iRef > iMax) iRef = iMax;
  if (iRef < -2) iRef = -2;
  const ei = iRef - i;
  let u = kci * ei + dInt;
  if (u >= 0 && u <= 1) dInt += kii * ei * h;
  if (u < 0) u = 0;
  if (u > 1) u = 1;
  t += h;
  process.stdout.write(u + '\n');
});

What this does not show

Source and how to rerun: examples/wasm-buck on GitHub ↗. The draft-versus-accepted replay of the same plant: buck converter replay.