Jump to content

Classic bug: Phaser spread bias: Difference between revisions

From Galactic Empire wiki
Link bug discovery transcript
 
(No difference)

Latest revision as of 14:24, 18 October 2026

Bug ID GE-BUG-023
Affected versions Classic 3.2e and published classic source
Status Verified mathematically and in source
Fixed in ge-next 0.3
Primary source GECMDS.C: firep() and firehp(); GEFUNCS.C: valpcnt() and pdamage(); GELIB.C: smallest()

Summary

Classic converts the phaser's intended firing direction and the direction to each potential target from floating-point angles to unsigned whole degrees by truncating them. Truncation always discards the fractional part instead of rounding to the nearest degree. Near the edge of the beam, this gives the positive-bearing side almost one additional degree of coverage compared with the negative-bearing side.

The phaser does not randomly wander to one side. The bias is deterministic: the same geometry produces the same hit or miss every time.

Direction and spread

The first argument to PHASER is a relative bearing from -180 through +180:

  • negative bearings are to port, or left;
  • positive bearings are to starboard, or right; and
  • zero is directly ahead.

The optional second argument is called both “spread” and “focus” in classic's interface. It accepts values from 0 through 5. If it is omitted, classic uses 1. Despite the name “focus,” increasing the value widens the beam while reducing its damage.

Classic first converts the requested relative direction into an absolute firing direction:

deg = (unsigned)normal(ptr->heading + (double)ptr->degrees);

It independently calculates the true direction from the firing ship to every candidate target and truncates that value as well:

heading = (unsigned)vector(&ptr->coord,&wptr->coord);

A target is inside the beam when the shortest angular distance between those two truncated integers is less than the selected spread plus PHABIAS:

#define PHABIAS 2

if (smallest(heading,deg) < ptr->percent + PHABIAS)
        /* target is in the beam */

smallest() correctly treats angles near 0 and 360 as adjacent. The defect occurs before that comparison, when each floating-point angle is truncated toward the lower whole degree.

Positive-versus-negative example

Consider a stationary ship heading 0 that fires:

PHASER 0 1

The firing direction is straight ahead. Spread 1 plus PHABIAS 2 produces this strict test:

angular_difference < 3

Now place otherwise identical targets at equal fractional bearings on either side of the centerline.

True relative bearing Angle used by classic smallest() result Outcome
+2.9 degrees 2 2 Hit, because 2 is less than 3
-2.9 degrees 357 after normalization and truncation 3 Miss, because 3 is not less than 3

The positive target is nearly three degrees to starboard but is pulled down into the accepted two-degree bucket. The corresponding negative target is represented near 360 degrees; truncating 357.1 to 357 pushes its complementary distance up to three degrees.

For an integral beam center and a threshold W, classic's effective coverage is approximately:

-(W - 1) degrees through just under +W degrees

With the default spread of 1, W is 3, so the beam covers roughly -2.0 through just under +3.0 degrees. The exact boundary changes when the ship's own heading has a fractional component, but truncation still quantizes the center and target toward lower absolute angles rather than treating the two sides evenly.

According to classic's own command documentation, positive relative bearings are starboard and negative bearings are port. The arithmetic therefore favors the positive/starboard edge in the straightforward heading-zero example.

Effect of the spread setting

The same one-sided boundary appears at every spread setting. The nominal integer comparison is:

Spread Test threshold Accepted integer differences
0 less than 2 0 or 1 degree
1 less than 3 0 through 2 degrees
2 less than 4 0 through 3 degrees
3 less than 5 0 through 4 degrees
4 less than 6 0 through 5 degrees
5 less than 7 0 through 6 degrees

Spread also feeds into pdamage(). Before range, phaser charge, phaser class, and target durability are applied, classic calculates a focus factor and squares it:

fd = 1 - ((double)foc / 11);
damage_factor = fd * fd;

The resulting factors are approximately 1.000, 0.826, 0.669, 0.529, 0.405, and 0.298 for spread values 0 through 5. A wider setting therefore makes a target easier to include in the beam but substantially reduces the damage. The truncation bug alters which targets receive that tradeoff; it does not change the damage formula itself.

Wraparound is not the cause

The example crosses the 0/360 boundary, but smallest() contains the intended complementary-angle logic:

a = abs(a1 - a2);
if (a > 180)
        return 360 - a;
return a;

The differing outcomes come from converting +2.9 to 2 and -2.9 to 357 before that function runs. The same lower-angle quantization exists away from north; the wraparound example merely makes the positive-versus-negative result easy to see.

Correction in ge-next

ge-next 0.3 rounds both angles to the nearest whole degree before comparing them:

deg = (unsigned)(normal(ptr->heading + (double)ptr->degrees) + 0.5);
heading = (unsigned)(vector(&ptr->coord,&wptr->coord) + 0.5);

Under the corrected calculation:

  • +2.9 rounds to 3 and misses;
  • -2.9 normalizes near 357.1, rounds to 357, and also misses;
  • +2.4 rounds to 2 and hits; and
  • -2.4 normalizes near 357.6, rounds to 358, and also hits.

The one-degree quantization remains, but its error is centered around each whole degree instead of always falling on the lower side. Standard phasers and hyper-phasers now use the same nearest-degree treatment.

This correction changes only edge-of-beam hit detection. ge-next's other phaser changes, including smoother floating-point damage, preservation of the half-step contribution from even phaser classes, and use of the ship damage factor, are separate corrections documented elsewhere.

Discovery transcript

Read the original shot observations and the initial interpretation.

Porting guidance

Keep angular values as floating point through the hit test when practical. Comparing the true shortest angular difference directly against a true beam half-width avoids one-degree buckets entirely. If integer angles are required for compatibility or performance, round both the beam center and target direction by the same rule; do not truncate them.

Tests should place targets at matching positive and negative fractional offsets around the beam center, including offsets near 0/360. Testing only integral bearings will not reveal this defect.