One bit too heavy: a Project Zomboid bug that lived in the 24th decimal place


A player reported something small and specific: one particular pot, filled with one particular liquid, could not be put into an oven. Every other pot went in. The same pot with a different liquid went in. An empty oven made no difference.

The obvious theories were all wrong, and the real answer turned out to be a single bit of floating-point rounding. It’s worth writing up, because the class of bug generalises far beyond one mod.

The symptom

Four variants of the same large cooking pot, identical in every way that seemed to matter. Three of them could be dragged into an oven. The fourth — the one holding motor oil — was refused, silently. No error, no message, just a drop that didn’t take.

First hypothesis: weight. Ovens have a capacity, the pot is heavy, maybe it’s over the limit. But all four pots declare the same weight in their item scripts, and three of them fit. Dead end.

Second hypothesis: ovens only accept cookable things, and maybe motor oil isn’t tagged as cookable. This one felt right, and it’s what most people would guess. It’s also wrong — the oven’s container has no type filter at all. It checks two things: is the item allowed (always yes, for a plain oven), and is there room.

So: there’s room, the weights are identical, and yet one pot doesn’t fit.

The actual cause

The item declared a leftover nutrition field — a thirst value, left over from an earlier pass that stripped those fields from its three sibling items and missed this one.

That field does nothing you’d expect. But its presence changes which branch the engine takes when it computes the item’s runtime weight. With no nutrition fields, the weight is taken as a literal. With a thirst value present, the engine computes it instead:

usedDelta = currentThirst * 100 / scriptThirst
weight    = (fullWeight - emptyWeight) * usedDelta + emptyWeight

For a full pot, currentThirst * 100 / scriptThirst should be exactly 1.0, and the whole expression should collapse to the declared weight. In double precision, it does.

The game runs this in 32-bit float. And the instance value is stored as script / 100, which for this particular number is not exactly representable:

stored thirst      -0.60000002384185791016
* 100              -60.000003814697265625
/ script (-60)       1.0000001192092895508     <-- not 1.0
(15 - 5) * that + 5 15.000000953674316406     <-- one ULP over 15

The oven’s capacity is 15. The check is contents + itemWeight <= capacity. So:

  • 0 + 15.0 ≤ 15 → fits
  • 0 + 15.000000953674316 ≤ 15 → does not fit

The pot was one unit in the last place too heavy. Not one gram — one bit.

Why it was only that one item

Because the round trip (x / 100) * 100 / x is only lossy for some values of x. Across the whole plausible range of integer nutrition values, only a handful fail to round-trip exactly. The mod happened to use one of them.

Every other nutrition value in the codebase round-trips cleanly. So the same latent defect existed on other items and never surfaced, because their arithmetic happened to land on the nose. That’s the part worth internalising: the bug wasn’t “this item is wrong”, it was “this item’s number is unlucky.”

Two bugs, one field

The same stray field had a second effect nobody had connected to the first. Because the item now looked partly like food, it also advertised a thirst value in its tooltip — and, on sibling items of a different underlying type, would have offered a “Drink” option.

Drinking a pot of motor oil was, mercifully, only cosmetic here. But it’s the same root cause presenting in two completely unrelated subsystems: one visible and silly, one invisible and functional. The visible one is what a player notices and reports. The invisible one is what actually breaks the game.

If you’re triaging, that asymmetry is a gift: the silly cosmetic symptom is often the cheapest observable handle on a serious one. The player who said “it shows a thirst value, take that as you will” handed over the entire diagnosis without knowing it.

The other one: counted, but never consumed

A second finding the same week, same flavour.

A crafting recipe listed a liquid container as an input. The recipe worked — the output appeared. But the container was never removed from the player’s inventory. Craft again, and again, and you get infinite output from one container.

The mechanism: for an input that is a real fluid container, the engine’s availability check counts it (so the recipe correctly refuses when you don’t have one), but the consumption pass treats it as a fluid source rather than an item to destroy — and without an explicit flag telling it to consume by item count, it removes nothing.

Availability and consumption disagreeing about what an input is — that’s the shape.

This one had a nice epilogue. A feature had been built on this pattern weeks earlier, misbehaved in a way nobody could explain, and was reverted with a note saying don’t retry this shape. The note was wrong. The shape was fine; one flag was missing. Once the mechanism was understood, the feature came back in an afternoon.

And when it did, three further defects turned up in that original reverted version that the first attempt had never noticed — including one where a container was filled to its full capacity instead of the amount actually poured, which closed into an unlimited-resource loop. Adding the missing flag alone would have shipped all three.

What I’d take away

Identical-looking siblings are worth diffing field by field. Three items were cleaned up and the fourth was missed. Nothing about the file made that visible; it took a line-by-line comparison of four blocks that “obviously” matched. A one-directional check — does this have what it needs? — will never find a straggler that has something extra.

“Confirmed unfixable” has a shelf life. A comment in the source said a related check was impossible, and it was accurate when written. An unrelated change two days later made it false, and nobody revisited it. Comments asserting impossibility should carry a date and a reason, and get re-tested when the surrounding assumptions move.

Reproduce before you theorise, and prefer a diff to a hypothesis. Both of the good theories here — weight limits, cookability filters — were plausible, cheap to believe, and wrong. The thing that cracked it was reading what the engine actually computes, one line at a time.

Floating point is still floating point, even in a game about zombies. If a game engine compares a computed value against a hard threshold, and you can influence the inputs to that computation, you can land one bit on the wrong side and produce a bug with no visible cause.


Leave a Reply

Your email address will not be published. Required fields are marked *