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.
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° evenThat 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
O
.
.
+
.
.
O
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.
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.
On the flat crank a bank alone is as even as the whole engine. Nothing limps, so nothing burbles. Same block, same heads, same cam.
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.
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:
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 }; }
inline Crankshaft flat_plane_crank() { return Crankshaft{ 90.0_deg, { 45.0_deg, 225.0_deg, 45.0_deg, 225.0_deg }, {{ { 0, Bank::right, 0 }, { 1, Bank::right, 0 }, { 2, Bank::right, 1 }, { 3, Bank::right, 1 }, { 0, Bank::left, 0 }, { 1, Bank::left, 0 }, { 2, Bank::left, 1 }, { 3, Bank::left, 1 }, }}, 0.16 // lighter: a flat crank needs less // counterweight, having less to cancel }; }
One short function works out the rest:
// 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
---- everything above was specified. everything below came out. ----
firing order 1-5-2-6-3-7-4-8
right bank cyl 1 2 3 4 fires at 180 - 180 - 180 - 180
left bank cyl 5 6 7 8 fires at 180 - 180 - 180 - 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.
// 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:
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:
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
A crank that cannot exist is refused:
// 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.
// 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:
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:
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.