Jump to content

Classic bug: Planet record-limit failure

From Galactic Empire wiki
Bug ID GE-BUG-012
Affected versions Classic 3.2e and published classic source
Status Verified in source and observed in a full production database
Fixed in ge-next 0.3
Primary source GEMAIN.C: gesdb(), getplanetdat(); GEPLANET.C: getsector(), xgetsector(), getplanet()

Summary

Classic Galactic Empire can create incomplete sectors when the shared sector/planet/wormhole database reaches MAXPLREC. The sector generator continues building an in-memory sector after one or more database insertions have been refused, and none of the creation callers are told which records were actually saved.

The resulting sector header may advertise planets or wormholes whose child records do not exist. Later code assumes those records exist and can display an unrelated planet left in the global planet workspace. Players observed these as phantom planets: the sector scan plotted several different planets, but scanning each numbered planet repeatedly displayed the same bearing, distance, environment, and resources. Some reported distances were millions of units. In one test, the phantom planet later changed into Zygor because the periodic planet updater had loaded Zygor into the shared workspace.

The same partial-creation problem can produce one-way wormholes. A saved wormhole can lead to a destination sector or return wormhole that could not be inserted after the record cap was reached.

What MAXPLREC limits

MAXPLREC is presented to the sysop as the maximum size of MBMGEPLT.DAT in KiB. Classic multiplies the configured value by two because every logical record is 512 bytes. The default value of 32,767 therefore becomes a code-level limit of 65,534 records, although the classic configuration text describes it as holding 65,535.

The count includes every record in the shared file, not just planets:

  • one sector-header record for every generated sector;
  • one child record for every planet; and
  • one child record for every wormhole.

A sector containing four planets consequently needs five records. Wormhole creation can require still more because the game also creates or updates the destination sector and attempts to install a return wormhole there.

The Btrieve file on disk can be larger than the configured payload limit due to indexes and other file overhead. The production database involved in the original investigation was 35,716,096 bytes even though the configured MAXPLREC represented approximately 32 MiB of 512-byte records.

Classic creation sequence

When an unexplored sector is scanned, xgetsector() constructs the entire sector in global memory before and during a series of separate database writes:

  1. Randomly decide how many planets the sector will contain and whether any slots are wormholes.
  2. Generate each object's coordinates in the in-memory sector.ptab[] table.
  3. Insert the sector-header record, including numplan and the complete object table.
  4. Build and insert each planet or wormhole as a separate child record.
  5. For each new wormhole, create or load its destination sector and add the reciprocal wormhole when a slot is available.

The record-limit check is not performed on this operation as a whole. Instead, classic performs cntrbtv() inside the low-level gesdb(GEADD) case before every individual insert. If the current count is below max_plrec, that one record is inserted. If the count has reached the cap, the insert is skipped and only an informational Max Sect Reached message is logged.

The result is not propagated to xgetsector(). The caller proceeds through all remaining creation steps as though every insertion succeeded.

Partial sectors near the limit

Suppose the database has room for two more records and the generator chooses a sector with four planets:

  1. The sector header is inserted. It permanently says that planet slots 1 through 4 exist and stores four plotted coordinates.
  2. Planet 1 is inserted into the final available slot.
  3. Inserts for planets 2, 3, and 4 are silently refused by the MAXPLREC check.

The database now contains a structurally incomplete sector. A later sector scan reads the valid header and plots all four entries from sector.ptab[]. A planet scan requests the separate child record. Only planet 1 can be loaded; the other three keys do not exist.

This explains an important visual clue from the live failure: SCAN SECTOR could show planets in distinct positions while SCAN PLANET 1, SCAN PLANET 2, and so forth reported identical data. The map was using the planned coordinates saved in the sector header, while the detailed scans were trying and failing to load the missing child records.

If the database is already full before generation begins, none of the new records are saved. The freshly generated sector can still exist temporarily in global memory for the current command, so it may appear real until another operation replaces that workspace. A later scan can generate a different temporary sector for the same coordinates because no persistent sector header was ever written.

Why missing planets become other planets

Classic uses one global planet structure as its shared planet record workspace. getplanet() asks Btrieve to load the requested key into that structure, but on failure it only logs an error. It does not clear planet, returns the same value on success and failure, and its callers continue by setting plptr to that same workspace.

The missing planet therefore appears to have the contents of whichever real planet was loaded most recently. Possible sources include:

  • an earlier numbered planet scan;
  • another planet-related command;
  • another user's activity; or
  • the background planetary updater.

This produced several observed forms of the bug:

  • every phantom planet in a sector displayed the same bearing and distance;
  • all showed the same environment, resources, population, and stockpiles;
  • bearings and distances changed when another real planet replaced the shared workspace;
  • distances reached millions of units because the stale record belonged to a completely different sector; and
  • after the updater loaded Zygor, a missing planet scan displayed Zygor's name, ownership, and supplies.

The behavior is not merely cosmetic. getplanetdat() reports success as long as the requested number appears in the sector header, even when the child lookup failed. Many classic commands then read or manipulate plptr. Depending on the command and the last record in memory, code intended for the phantom object can inspect or act on an unrelated real planet.

One-way wormholes

Wormholes expose a second partial-write path. The source sector and its wormhole child can be inserted while space remains, but the database may hit the cap before the destination sector and reciprocal wormhole are saved.

The source wormhole remains usable because its record contains destination coordinates. Traveling through it moves the ship to those coordinates, but the destination can load as an empty, unsaved sector with no return wormhole. During reproduction, logs showed failures while creating the two destination sectors selected for wormholes; entering one of the surviving source wormholes deposited the player in an empty sector.

This is distinct from classic's intentional behavior when an already-full destination sector has no free object slot. Here, the return path is absent because the database cap interrupted creation.

Existing sectors

Sectors and child records that were completely saved before the database filled continue to load normally. During the original incident, previously explored sectors retained their correct planet positions and details. The failure affects newly generated sectors and any sectors left partially written as the record count crosses the cap.

Normal updates to existing records are not blocked by MAXPLREC; the defect is specifically in creation and in the handling of missing child records.

ge-next's initial fix

The first ge-next fix, committed during 0.3 development, moved a record-count check to the start of xgetsector(). If an ordinary requested sector did not exist and the database was already at MAXPLREC, ge-next cleared the in-memory sector, filled in the requested coordinates, set numplan to zero, and returned it as an empty sector without trying to save anything.

This fixed the most common case: once the database was full, scanning another unexplored location no longer generated a temporary table of phantom planets. It was nevertheless incomplete. If the database was merely near the limit, creation could still begin successfully. The low-level per-record cap could then be reached between the sector header and its later planet or wormhole records. The first fix also prevented a wormhole destination from being completed if the source-sector work consumed the last nominal slots.

Testing immediately demonstrated both remaining cases: a four-object sector whose fourth child did not exist, and saved source wormholes whose destination sectors could not be created.

Complete creation fix

The follow-up ge-next fix treats MAXPLREC as a soft boundary between complete sector-creation operations rather than a hard boundary between individual record inserts.

It made two coordinated changes:

  • The low-level cap was removed from gesdb(GEADD). Once sector creation has started, all sector, planet, and wormhole records required by that operation may be inserted.
  • The start-of-sector guard remains for ordinary exploration, but wormhole destination creation bypasses it so the reciprocal side of a wormhole can be completed.

The database may therefore exceed the configured target by the records needed to finish the sector and any wormhole destinations already in progress. Afterward, the next attempt to generate an unrelated unexplored sector sees that the count is at or above the limit and returns a consistent empty sector. A small, bounded overshoot is preferable to permanently saving a header that references nonexistent children or a wormhole with no generated destination.

Later defensive hardening

Before the final ge-next 0.3 release, planet lookup was also hardened so a missing record is safe regardless of its cause:

  • getplanet() clears the global planet workspace before lookup;
  • it returns FALSE when the requested key does not exist and TRUE only after a real record is loaded;
  • getplanetdat() propagates that result instead of treating the sector header as proof that the child exists; and
  • scans, orbit administration, transfers, trade, beacons, planetary weapons, and other callers stop their operation when the load fails.

This defense prevents stale Zygor data, another player's last planet, or any other previous workspace contents from masquerading as a missing planet even if a database becomes inconsistent for some reason unrelated to MAXPLREC.

Discovery transcript

Read the Discord discussion and tests that uncovered this bug.

Porting guidance

Sector generation should be atomic at the logical level. A port should use a transaction where available, or reserve enough capacity before writing the sector header and all of its children. The header must never advertise an object until that object's backing record is guaranteed to exist.

Wormholes require the same rule across both ends: either create a valid destination and reciprocal link, or do not expose the source wormhole.

Every record-loading function should also:

  • initialize or invalidate its destination before lookup;
  • return an explicit success/failure result;
  • require callers to check that result; and
  • avoid shared stale state being interpreted as newly loaded data.

Silently refusing one write in the middle of a multi-record object is not a safe implementation of a database-size limit.