Jump to content

Classic bug: Axis-aligned bearing errors

From Galactic Empire wiki
Bug ID GE-BUG-003
Affected versions Classic 3.2e and published classic source
Status Verified in source
Fixed in ge-next 0.3
Primary source GELIB.C: cbearing(), vector(), angleb(), anglec()

Summary

Classic can calculate incorrect cardinal headings and bearings when two objects share exactly the same X or Y coordinate. Its workaround mutates the source object's real coordinates on every bearing calculation.

Symptoms

Ships or objects aligned exactly north, south, east, or west can produce an incorrect bearing or heading. Repeated bearing calculations also introduce a tiny cumulative coordinate drift.

Why an axis causes trouble

Classic's vector() first chooses a quadrant, then calls either angleb() or anglec(). Those helpers reconstruct an angle with the law of cosines. In simplified form, their divisors include either the absolute X difference or the absolute Y difference:

angleb divisor = 2 * distance * abs(delta-X)
anglec divisor = 2 * distance * abs(delta-Y)

If two objects have the same X coordinate, delta-X is zero. If they have the same Y coordinate, delta-Y is zero. Depending on which quadrant branch is selected, the helper may therefore need to divide by zero. Classic avoids performing that division, but substitutes an angle of zero. The quadrant code then treats that zero as a real result and can turn it into the wrong cardinal direction.

For example, place the observing ship at (0,0) and its target directly east at (1,0). The true absolute heading is 90 degrees. With an exact horizontal alignment, anglec() has a zero Y side and falls back to zero. Its quadrant adjustment calculates 180 - 0, incorrectly producing 180 degrees. Exact vertical alignment creates the corresponding problem in angleb().

Acknowledged in the original source

The classic source explicitly recognizes that its fallback is wrong. In anglec(), immediately before assigning zero when the divisor would be zero, MBM left this comment:

/* this should not be 0 - figure out what rad(360) is. */
angle = 0;

The structurally similar zero fallback in angleb() has no matching comment, but has the same axis problem. Substituting 360 degrees would not be a general repair either: the required angle depends on the axis, direction, and surrounding quadrant adjustment. The correct solution must identify the cardinal case or use a function that resolves the vector's quadrant.

cbearing() also retains three commented-out diagnostic blocks that logged the raw vector result, the normalized result, and the final signed -180-to-180 result. Together with the explicit “should not be 0” comment and the unexplained coordinate nudge, these show that the bearing pipeline's edge cases were known and investigated, but not fully corrected in the published classic source.

Classic's coordinate nudge

The published classic source tries to avoid exact alignment before calling vector():

ptr1->xcoord += .000001;
ptr1->ycoord += .000001;

Moving the source point a tiny distance off both axes prevents either difference from being exactly zero, so the law-of-cosines calculation usually returns a direction extremely close to the correct cardinal heading.

“Extremely close” is not always identical after classic converts the result to an integer. For a ship heading north, a target due west can become a relative bearing just greater than -90, while a target due south can become just greater than -180. Many callers add 0.5 and then truncate toward zero, which can display those values as -89 and -179. The nudge therefore replaces the worst axis failure with an approximation, not an exact solution.

The fatal flaw is that ptr1 points to the real coordinate record; it is not a temporary copy. Every call permanently adds 0.000001 to both X and Y. GE stores coordinates in sector units, with one unit representing 10,000 displayed distance units. One bearing calculation therefore moves the source object by 0.01 displayed unit on each axis. One hundred calls move it by one displayed unit on each axis, or about 1.41 units diagonally.

A scan can calculate bearings for several objects, and some scan paths also calculate the reverse bearing used as another ship's relative heading. The nudge can consequently occur several times during one scan and may move both the scanner and scanned objects. Repeating scans accumulates the changes in the same positive-X, positive-Y direction. Over enough calls, a nominally stationary ship can drift far enough to alter displayed distance, proximity, gravity, orbit, or even sector-boundary behavior.

Correction

ge-next 0.3 removes the coordinate mutation. Its vector() first tests the true X and Y differences against a very small epsilon. Horizontal and vertical vectors return the exact appropriate cardinal heading: 0 north, 90 east, 180 south, or 270 west. Coincident points return 0.

For all other vectors, ge-next calculates the true X and Y deltas and the hypotenuse. It obtains the north-based angle with one acos() calculation, then uses the sign of the X delta to select the eastern or western half of the compass. It also clamps the argument to acos() into the valid range -1 through 1. That prevents ordinary floating-point rounding from producing a value such as 1.0000000001 and causing a domain error. This fixes the geometry without requiring atan2() and without modifying either object.

The correction was committed during development as “math library fixes and optimization” in ge-next 0.3-pre3. Version 0.3 was the first public release containing it.

The atan2() alternative

A conventional port can solve the same problem with atan2(). Unlike atan(y/x) or the classic law-of-cosines helpers, atan2(y,x) accepts the two components separately. It therefore does not divide one coordinate difference by the other, handles a zero X or Y component, and selects the proper quadrant itself.

GE's compass is rotated from the usual mathematical convention and its Y coordinate increases toward the south. Given dx = target.x - source.x and dy = target.y - source.y, an equivalent starting point is:

absolute = radtodeg(atan2(dx, -dy));

The result must then be normalized into GE's 0-through-359 absolute heading and converted to the signed relative bearing expected by cbearing(). Coincident points should still be handled explicitly rather than relying on a library's treatment of atan2(0,0).

Compatibility notes

Ports should use a conventional atan2-style solution or an equivalent non-mutating calculation, while preserving GE's heading orientation and signed relative-bearing convention.

Verification

Verified with cardinal-coordinate test cases and source comparison.