windsor

Source on GitHub

A Ford 302 V8, written in C++23, for the sound.

500 Hz
idlecruisepulloverrun
crankshaft 0:00

Nine seconds of an engine that does not exist. Press play, then fit the other crankshaft while it runs. The spectrogram is the scrub bar.

record.cpp · lines 3–12

This file contains no acoustics. Every sample it writes was computed by exhaust.hpp out of gas leaving eight cylinders at moments decided by crankshaft.hpp, and all this does is hold the throttle, drain the two banks, add them together and put a RIFF header on the front.

That is the claim the whole project is making, so it is worth stating in the place where it is cashed: nothing here knows what a V8 sounds like. There is no sample, no oscillator, no filter tuned by ear, no table of harmonics. There is a crankshaft with its throws at 90°, and the noise is what that implies.

I The argument

A Mustang burbles. A Ferrari shrieks. Both are 90° V8s, and I wanted to know why they sound nothing alike: not well enough to describe it, well enough to point at the part. And to find out whether that sound falls out of the geometry, or has to be put there by hand.

The usual answer is that the American engine fires unevenly. It does not. Both fire once every 90° of rotation, eight times a cycle, perfectly spaced. At the flywheel the two keep identical time.

crankshaft.hpp · lines 32–39

But an engine does not exhale through its flywheel. It exhales through two exhaust manifolds, one per bank, and the manifold does not hear the engine — it hears its own four cylinders. Ask each bank what it heard:

CROSS-PLANE bank:   90° — 180° — 270° — 180°       lopsided
FLAT-PLANE  bank:  180° — 180° — 180° — 180°       even

That is the entire difference. That is the burble and that is the shriek.

The part is the crankshaft. Seen from the nose, its four throws make either a cross or a line.

                   O
                   .
                   .
             O.....+.....O
                   .
                   .
                   O
The program’s own drawing of the crank that is fitted now.

Here are two cycles of it, slowed until they can be counted: the whole engine, and then what each manifold gets of it. Listen to the engine, then to one bank alone.

listen to

The engine ticks evenly. Alone, a bank coughs twice in quick succession, waits three quarters of a turn, and coughs again. Now fit the flat crank and listen to one bank again.

What one tailpipe looks like

The picture at the top of the page is the recording from one tailpipe: time runs left to right, pitch runs upward to 500 Hz, and each rising line is a harmonic of the engine’s speed. Here are both cranks, computed in your browser from the same two files you have been listening to.

cross-plane500 Hz
flat-plane500 Hz

The flat crank draws a clean ladder. The cross draws the same ladder with more rungs between. The program measures the difference:

THE THESIS
  half-order energy is the signature of a pulse train that repeats
  every two revolutions instead of one. one bank, at idle:
  cross-plane bank, half-order share          0.6983          at least 0.3    ok
  flat-plane bank, half-order share           0.0009409       0 to 0.1        ok
  the cross-plane bank carries far more of it 742x                            ok
  that ratio is the entire project, and it came out of four throw angles

It falls out.

II The method

This is not a simulator that happens to be readable. It is a piece of writing that happens to run, and it keeps a few rules.

Derive, never declare

A firing order is not an input. You forge a crank, you grind a cam, and the order is whatever falls out. So the source specifies the forging, and nothing downstream of it:

windsor.hpp · lines 88–105
inline Crankshaft cross_plane_crank() {
    return Crankshaft{
        90.0_deg,
        { 45.0_deg, 315.0_deg, 135.0_deg, 225.0_deg },
        {{
        //  cylinder    journal   bank          revolution
            /* 1 */     { 0,      Bank::right,  0 },
            /* 2 */     { 1,      Bank::right,  0 },
            /* 3 */     { 2,      Bank::right,  1 },
            /* 4 */     { 3,      Bank::right,  0 },
            /* 5 */     { 0,      Bank::left,   0 },
            /* 6 */     { 1,      Bank::left,   1 },
            /* 7 */     { 2,      Bank::left,   1 },
            /* 8 */     { 3,      Bank::left,   1 },
        }},
        0.20                       // kg·m², crank + damper + flywheel + clutch
    };
}

One short function works out the rest:

crankshaft.hpp · lines 170–177
// The number cast into the intake manifold. Derived, never declared.
std::array<int, cylinder_count> firing_order() const {
    std::array<int, cylinder_count> order{};
    for (int i = 0; i < cylinder_count; ++i) order[i] = i + 1;
    std::sort(order.begin(), order.end(),
              [&](int a, int b) { return tdc_[a - 1] < tdc_[b - 1]; });
    return order;
}
  ---- everything above was specified. everything below came out. ----

  firing order         1-5-4-2-6-3-7-8
  right bank   cyl 1 4 2 3   fires at   180 -  90 - 180 - 270
  left  bank   cyl 5 6 7 8   fires at   270 - 180 -  90 - 180

1-5-4-2-6-3-7-8 is cast into the intake manifold of every 302 Ford built in 1968. It was never typed in.

A bore is not a stroke

The domain is in the type system, and the compiler is a machinist checking the work. A specification reads the way it is stamped on the part. Inches exist at that surface and nowhere inside.

geometry.hpp · lines 42–47
// Named parameters. A cylinder is quoted as bore × stroke and you should not
// be able to swap them by accident.
struct Bore             { double m;     };  // cylinder diameter
struct Stroke           { double m;     };  // full travel, TDC to BDC
struct RodLength        { double m;     };  // centre to centre
struct CompressionRatio { double ratio; };  // swept+clearance over clearance

The inch is converted at compile time, and costs nothing when the engine runs:

si.hpp · line 62
consteval double operator""_in (long double v) { return double(v) * 0.0254;   }

So the short block reads like the page of the manual it was taken from:

windsor.hpp · lines 28–44
inline constexpr Geometry short_block() {
    return Geometry{
        Bore{4.000_in},            // standard, before any overbore
        Stroke{3.000_in},
        RodLength{5.090_in},       // the C8OE rod. NOT the 289's, which is
                                   // 5.155 in: Ford shortened it by 0.065 when
                                   // the stroke grew from 2.870 to 3.000,
                                   // because half of that 0.130 of extra
                                   // stroke has to come out of the rod or the
                                   // piston comes out of the top of the block
        CompressionRatio{9.5}      // 1968 2V, on leaded regular. The chamber
                                   // itself is only 53.5 cc; the rest of the
                                   // 72.7 cc of clearance volume is head
                                   // gasket, deck clearance and the dish in
                                   // the piston crown
    };
}

And the displacement is worked out, not quoted:

  displacement         301.6 cu in             4942 cc
Ford rounded up.

A crank that cannot exist is refused:

crankshaft.hpp · lines 262–282
// A crank that cannot exist should not compile into an engine that runs.
void validate() const {
    std::array<int, journal_count> per_journal{};
    for (const Rod& r : rods_) {
        if (r.journal < 0 || r.journal >= journal_count)
            throw std::invalid_argument{"rod hung on a journal that is not on this crank"};
        if (r.revolution != 0 && r.revolution != 1)
            throw std::invalid_argument{"a four-stroke cylinder fires on revolution 0 or 1"};
        ++per_journal[r.journal];
    }
    for (int n : per_journal)
        if (n != 2)
            throw std::invalid_argument{"every rod journal carries exactly two rods"};

    // No two cylinders may want the same instant: an engine that fires two
    // cylinders at once has thrown a rod through the block by lunchtime.
    for (int a = 0; a < cylinder_count; ++a)
        for (int b = a + 1; b < cylinder_count; ++b)
            if (std::abs(tdc_[a] - tdc_[b]) < 1e-9)
                throw std::invalid_argument{"two cylinders share a firing angle"};
}

Comments carry the argument

Every header opens by saying why the part exists and what it is arguing with. The code says what. The comments say why.

geometry.hpp · lines 104–114
// dV/dθ — the rate the chamber opens, per radian of crank. Positive on the
// way down. This is what turns pressure into torque: the work a gas does on
// the crank in a small rotation is p·dV, so the instantaneous torque a
// cylinder makes is p·(dV/dθ). The whole engine is that one product,
// integrated, eight times over.
double dV_dtheta(double theta) const {
    const double a = crank_radius_, l = rod_;
    const double sin_t = std::sin(theta), cos_t = std::cos(theta);
    const double root  = std::sqrt(l * l - a * a * sin_t * sin_t);
    return piston_area_ * a * sin_t * (1.0 + a * cos_t / root);
}

The standard library, and nothing else

No graphics, no audio library, no build system beyond make. Every dependency is a bet that something else will still build in fifteen years. A WAV file is 44 bytes of header and then the numbers, so it is written by hand:

wav.hpp · lines 62–70
tag("RIFF");  u32(36 + data_bytes);  tag("WAVE");
tag("fmt ");  u32(16);
u16(1);                                   // PCM, uncompressed
u16(channels);
u32(static_cast<std::uint32_t>(sample_rate));
u32(byte_rate);
u16(channels * 2);                               // block align
u16(16);                                         // bits per sample
tag("data");  u32(data_bytes);

III The instruments

The engine is a sealed mechanism that turns and gets hot, and knows nothing about being observed. Six instruments are bolted to it afterward, and none of them may reach into the physics to make its own job easier.

    windsor spec        what was built, and what falls out of it
    windsor dyno        put it on a water brake and sweep it
    windsor run         watch it idle
    windsor card        let it draw its own picture, as Watt did
    windsor record      stand behind it with two microphones
    windsor verify      check every number this project quotes

Five of the six print something and stop. The sixth does not: windsor run is a gauge cluster wired to the sensors, redrawn thirty times a second while the engine idles in front of you. It is the one instrument a page cannot quote, so the page runs it instead — eight bores, each piston drawn where the crank-slider puts it, and the firing order marching around them.


Nothing there is a loop of frames. Every piston is placed by the same equation the linkage is, and the order they fire in is worked out from the forging on the way past — fit the flat crank and watch the march change, because that is the only thing that changed. At idle this happens eleven times a second; here it is slowed to one cycle every three, which is the only liberty taken.

The indicator card

Cylinder pressure against volume, for one cycle of one cylinder: the engine drawing its own picture, as Watt’s did. Wide open, and then throttled, to the same scale.

  WIDE OPEN THROTTLE
   59.4 |#####
   42.5 |.   #####
   30.4 |.       ######
   21.7 |..           #######
   15.5 | ...               ########
   11.1 |   ....                   ###########
    8.0 |      .....                         #############
    5.7 |          ......                                ##########
    4.1 |               .......                                   #.
    2.9 |                     .........                            .
    2.1 |                             ...........                ...
    1.5 |                          ............ ..................
    1.1 |...........................................................
    0.8 |  .......
    0.5 |
    0.4 |
    0.3 |
    0.2 |
    bar +------------------------------------------------------------
         TDC                        volume                        BDC

         gross 11.99 bar   pumping -0.08 bar   net 11.90 bar   peak 54.0 bar

  THROTTLE CLOSED
   59.4 |
   42.5 |
   30.4 |
   21.7 |
   15.5 |
   11.1 |
    8.0 |.##
    5.7 |. ###
    4.1 | .  #####
    2.9 | ...    #####
    2.1 |   ..       ######
    1.5 |   ................................
    1.1 |....  .....             ##########.........................
    0.8 |.         ......                 ############             .
    0.5 |..             .......                      ###############
    0.4 | ..                  ........
    0.3 |  ..                        ...........
    0.2 |   ........................................................
    bar +------------------------------------------------------------
         TDC                        volume                        BDC

         gross  0.97 bar   pumping -0.93 bar   net  0.03 bar   peak 7.3 bar

  # power   . exhaust   . intake   . compression

  Throttled, the engine spends 96% of everything it makes on
  breathing. Wide open it spends 1%. That is the whole cost of
  controlling a petrol engine by making it difficult to breathe, and it
  is why a diesel, which throttles on fuel alone, is more efficient at
  part load and barely more efficient at full.

The dynamometer

  lb-ft                                                                 hp
   325 |       **********                                           |  225
   305 |********        **********           ooooooooooooooooooooooo|  211
   284 |                         ******###oooo                      |  197
   264 |                          oooooo ********                   |  183
   244 |                      ooooo             *******             |  169
   223 |                  ooooo                       *******       |  155
   203 |               oooo                                 ******  |  141
   183 |            oooo                                         ***|  127
   162 |         oooo                                               |  112
   142 |      oooo                                                  |   98
   122 |    ooo                                                     |   84
   102 | oooo                                                       |   70
    81 |oo                                                          |   56
    61 |                                                            |   42
    41 |                                                            |   28
    20 |                                                            |   14
        ------------------------------------------------------------
        10    15    20    25    30    35   40    45    50    55    60  rpm/100

       * torque    o power    # both

  peak torque   307 lb-ft at 1985 rpm
  peak power    206 hp    at 4994 rpm

  Ford rated the 1968 302-2V at 210 hp / 4400 and 295 lb-ft / 2400, gross.
  Nothing here was fitted to those numbers.

Close, and wrong in a way that can be named: the curve is too broad, with torque peaking about 400 rpm low and power 600 high. The missing part is intake runner tuning.

The inspection sheet

Every number the project quotes is checked there, each against something outside the project: a derivative against finite differences, a cylinder head against a flow bench, a firing order against the casting, the thesis against a Fourier transform.

  63 checks, all of them true.

IV What is not in it

An unstated simplification is a lie, and a stated one is a design decision. The two largest pieces of work in this project are not in it. A nonlinear gas solver, verified against Sod’s shock tube to one part in 10⁵, made header length worth 14% of torque, and collapsed the difference between the two crankshafts, which is the one measurement the project exists to make. It was taken out.

Intake runner tuning was built three times and shipped none, and the third attempt ends like this:

induction.hpp · lines 88–91

AND IT STILL COULD NOT BE HELD AT 1000 TO 1500 RPM, at any controller gain from 0.6 to 12 N·m per rpm, on a fresh engine per point, with the load lagged or not. I do not know why. That is the next thing to find out, and saying so is better than the explanation I would have to invent to avoid it.

A limitation nobody attempted is a guess. A limitation somebody built, measured and reverted is a result, and both are written down.

What all of this left behind is an engine that turns over in a terminal and cannot move anything, burning fuel that does not exist, and doing all of that correctly.