Classic bug: Cash and counter wraparound
| Bug ID | GE-BUG-007 |
|---|---|
| Affected versions | Classic 3.2e and published classic source |
| Status | Verified in source |
| Fixed in | ge-next |
| Primary source | Economy, transfer, production, reward, and administrator paths |
Summary
Classic stores many game quantities in fixed-width unsigned fields but does not consistently check additions before changing them. Cash, taxes, population, planetary stock, and ship inventory can therefore wrap from their maximum value to a small value. Classic also commonly displays these unsigned values as signed longs, so they can appear negative well before the actual unsigned overflow occurs.
Symptoms
- A cash, item, tax, score, population, or production counter can suddenly appear negative after crossing 2,147,483,647.
- Receiving additional money or goods near 4,294,967,295 can reduce the stored quantity to zero or another very small positive number.
- An operation that interprets the unsigned field as signed may erase it as soon as its high bit is set, even though the field has not actually overflowed.
- Production, transfers, purchases, rewards, and administrator grants can create totals outside the range expected by later capacity and affordability checks.
- The same stored value can appear negative in one report and positive in code that compares it as unsigned, producing behavior that seems to contradict the display.
Cause
The affected persistent fields are generally declared as 32-bit unsigned long values. Examples include user cash and scores, every ship inventory count, each planet item's quantity and amount sold, and planetary cash and tax. Their actual numeric range is 0 through 4,294,967,295.
Classic often updates these fields with an unchecked expression such as:
destination += amount;
The overflow occurs in the unsigned destination. C unsigned arithmetic is performed modulo 232 on these platforms; it does not report an error. Adding one to 4,294,967,295 therefore stores 0. Adding 10 to 4,294,967,290 stores 4:
4,294,967,290 + 10 = 4,294,967,300 4,294,967,300 modulo 4,294,967,296 = 4
Representative unchecked classic paths include transferring cargo down to a planet, adding purchases to ship inventory and planetary sales counters, adding money to planetary or user cash, accumulating planetary tax and production, distributing combat rewards, and granting cash with the SYS command. Multiplication can overflow before an addition as well: an item quantity multiplied by its price may already be invalid before either side of the transaction is changed.
Signed display of an unsigned value
Classic frequently passes these unsigned long fields to sprintf() using %ld, the format for a signed long. Examples include REPORT INVENTORY, REPORT ACCOUNT, and the planetary administration display. The stored bits are unchanged, but the formatter interprets the high bit as a sign bit.
This creates a visible discontinuity at 2,147,483,648, not at the true unsigned wrap point:
| Stored unsigned value | Displayed with %ld
|
|---|---|
| 2,147,483,647 | 2,147,483,647 |
| 2,147,483,648 | -2,147,483,648 |
| 4,000,000,000 | -294,967,296 |
| 4,294,967,295 | -1 |
| 0, after the next unsigned increment | 0 |
Thus a negative display does not by itself mean that the unsigned counter has wrapped. It means the counter has crossed the signed-long boundary. The real wrap occurs only after 4,294,967,295.
The signed interpretation nevertheless affects gameplay in some paths. Classic contains checks such as if ((long)waruptr->cash < 0) that reset cash to zero. Once cash reaches 2,147,483,648, that cast becomes negative and the game may erase a balance that remains perfectly valid as an unsigned value. Other paths compare the same field as unsigned, so the game can alternately treat the value as a very large quantity, a negative number, or invalid data depending on which routine uses it.
Correction
ge-next treats 4,294,967,295 as the explicit maximum for fields that retain the 32-bit unsigned long representation. Before adding amount, code tests the equivalent of:
if (destination > 4,294,967,295 - amount)
/* cap or reject without overflowing */
Subtraction verifies that the source contains the requested amount, and quantity-by-price calculations verify that the quantity is no greater than the maximum divided by the price before multiplying. Signed command input is validated before conversion to an unsigned quantity.
The appropriate result depends on the operation. Some naturally accumulating values saturate at the maximum. Transactions that cannot be represented are rejected or limited to the remaining room so that their source and destination remain consistent. Important audited paths include buying, selling, cargo transfers, new equipment, combat rewards, tax collection, planetary production, and SYS grants.
Displays use %lu for unsigned-long values. This does not prevent overflow by itself, but it ensures that every valid stored value from 0 through 4,294,967,295 is shown as the nonnegative quantity the game actually stores. ge-next also removed signed-negative tests that incorrectly treated the upper half of that range as corrupt.
Porting guidance
Using a wider host integer reduces how often the problem appears but does not define valid game values or preserve the on-disk format by itself. Ports should give persistent fields explicit-width unsigned types, enforce an intentional per-field maximum, and perform checked addition, subtraction, and multiplication before committing a transaction. Format specifiers must match the actual argument types; merely changing %ld to a wider signed format leaves the arithmetic bug intact.
Home · Play · Reference · Versions & history · Sysop · Development · Classic bugs