Jump to content

Classic bug: Planet troop anti-fighter probability

From Galactic Empire wiki
Bug ID GE-BUG-037
Affected versions Classic 3.2e and ge-next through 0.3.2 on both architectures; not reproduced by Elwynor's 32-bit classic port
Status Verified in source; corrected
Fixed in ge-next 0.3.3; Elwynor's port already evaluated the expression as the apparent intended 60 percent because gernd() returns USHORT
Primary source Troop anti-fighter test in attack_fig(); return type of gernd()

Summary

During a fighter attack on a planet, a sufficiently large defending troop force can fire on the attacking fighters. The condition appears intended to give the troops a three-in-five, or 60 percent, chance to fire. In classic's 16-bit build and in both ge-next architectures, the expression remains unsigned and subtraction underflows for one of the two outcomes that should fail. The event therefore occurs four times out of five, or 80 percent of the time. Elwynor's 32-bit port uses a narrower return type that is promoted to signed int, so that port evaluates the same source expression as 60 percent.

This does not mean that planetary troops destroy 80 percent of the attacking fighters. The 80 percent figure is the probability that the troop-defense event is activated. When it activates, a separate configurable calculation determines what fraction of the attacking fighters is lost.

Where the test occurs

The ATTACK command can send either troops or fighters against a planet. Fighter assaults are processed by attack_fig(). The routine initially places the number launched in left1; left2 holds the fighters defending the planet.

Before resolving combat between the two fighter forces, it checks whether planetary troops get an opportunity to fire:

/* If there are more than 500 troops - shoot down some fighters */
if (left1 > 0 && plptr->items[I_TROOPS].qty > 500 &&
    (gernd() % 5 - 1) > 0)
{
    prfmsg(ATTACKF8);
    r = rndm(plattrf1) + .05;
    kill1 += (unsigned long)((double)left1 * r);
}

The first two conditions simply require at least one attacking fighter and more than 500 troops on the planet. The bug is entirely in the final random expression.

The threshold is fixed: 500 troops never activate this defense, while 501 do. Once the threshold is crossed, the number of troops does not increase either the chance to fire or the percentage of fighters they can destroy.

Apparent intended probability

gernd() % 5 produces one of five residues:

0, 1, 2, 3, or 4

If those values were treated as signed integers, subtracting one would produce:

-1, 0, 1, 2, or 3

The final comparison to 0 would fail for -1 and 0, and pass for 1, 2, and 3. That is three successful outcomes out of five: 60 percent.

Unsigned underflow

The classic declaration and definition of gernd() return unsigned int:

unsigned int gernd()
{
    return rand();
}

The result of the remainder operation is therefore unsigned. Under C's usual integer conversions, the subtraction is also performed as unsigned arithmetic. When the random residue is zero, the expression does not become signed -1. It wraps to the largest value representable by unsigned int.

On the 16-bit build, the five cases are:

gernd() % 5 Result after unsigned - 1 > 0 Troops fire?
0 65,535 true Yes
1 0 false No
2 1 true Yes
3 2 true Yes
4 3 true Yes

Four outcomes pass, making the actual probability 80 percent in classic's 16-bit build.

Different behavior in the 32-bit ports

Whether the bug survives a 32-bit port depends on the return type chosen for gernd(). Elwynor's classic port and ge-next32 made different choices.

Elwynor's port declares and defines the function as:

USHORT gernd(VOID)
{
    return ((USHORT)rand());
}

In the MajorBBS 10/Windows environment, USHORT is a 16-bit unsigned short, while int is a signed 32-bit type. A signed 32-bit int can represent every possible USHORT value, so C's integer promotions convert the returned value to signed int before evaluating % 5 - 1. The zero residue therefore becomes signed -1 and fails the comparison. Elwynor's port gets the apparent intended three passing residues and a 60-percent event rate, even though the expression itself was not rewritten.

Through version 0.3.2, ge-next32 instead retained this declaration:

unsigned int gernd(void);

With MSVC, that is a 32-bit unsigned int. It is not promoted to signed int, because signed int cannot represent its full range. The subtraction remains unsigned, and a zero residue wraps to 4,294,967,295. That value is greater than zero, so ge-next32 has the same four passing residues and 80-percent behavior as classic 16-bit GE.

This is an example where widening the compiler's native int does not by itself determine compatibility. Elwynor's explicit 16-bit return type changes the expression through integer promotion; ge-next32's native unsigned return type preserves the underflow with a wider wrapped value.

How many fighters are affected

When the event occurs, PLATTRF1 controls the upper portion of the troop kill ratio. The configured percentage is converted to a fraction when the game starts, and attack_fig() calculates:

r = rndm(plattrf1) + .05;
kill1 += (unsigned long)((double)left1 * r);

With the classic default PLATTRF1 value of 18, the troop event selects approximately 5 through 23 percent of the attacking force. The result is truncated to a whole number of fighters and is added to any losses caused by the planet's defending fighters.

For a simplified example, suppose 100 fighters repeatedly attack a planet with more than 500 troops and at least one defending fighter:

  • When troop fire activates, it accounts for roughly 5 to 23 attacking fighters, averaging about 14 before integer effects.
  • A signed three-in-five test would activate on about 60 of every 100 attacks.
  • The actual unsigned test activates on about 80 of every 100 attacks.

The expected troop-fire contribution therefore rises from roughly 0.60 x 14 = 8.4 fighters per attack to 0.80 x 14 = 11.2. That is a 20-percentage-point increase in event frequency and roughly a one-third increase in the average losses attributable to this troop-defense check. Losses caused by defending fighters are separate and can make the final total larger.

Gameplay consequences

Planetary troops are substantially more reliable against fighter raids than the expression's apparent three-in-five design suggests. This favors defended planets and makes fighter assaults more costly, especially over repeated attacks. The effect is easy to mistake for ordinary random variation because both the activation and casualty percentage are randomized.

The bug is independent of the quality of the planet, the number of troops beyond the 500 threshold, and the ratio of attacking fighters to troops. Those values do not participate in this particular probability test.

Correction in ge-next 0.3.3

ge-next 0.3.3 states the intended three-in-five probability directly in both the 16-bit and 32-bit codebases, without an intermediate subtraction:

gernd() % 5 >= 2

The residues 0 and 1 then fail while 2, 3, and 4 pass, regardless of integer width. This reduces the frequency of troop anti-fighter fire from 80 to 60 percent and intentionally corrects the established classic combat behavior.

ge-next32 0.3.3 also changes gernd() from native 32-bit unsigned int to USHORT, matching Elwynor's port and the 16-bit width of classic's original unsigned int return value:

USHORT FUNC gernd(void)
{
    return (USHORT)rand();
}

MSVC's rand() returns values no greater than 32,767, so the narrower return type does not discard any possible random value. It does restore the integer-promotion behavior used by Elwynor's MajorBBS 10 port. The probability test was nevertheless rewritten explicitly in ge-next32 as well, so its correctness no longer depends on that subtle promotion rule.