Classic bug: Stale scantab and lock state
| Bug ID | GE-BUG-020 |
|---|---|
| Affected versions | Classic 3.2e and published classic source |
| Status | Verified in source and multiplayer testing |
| Fixed in | Question-mark and scan-table overflow paths fixed by ge-next 0.3; live-slot cap present since ge-next 0.2 |
| Primary source | GECMDS.C: update_scantab(), pick_letter(), scan_sh(), and lockon(); GEFUNCS.C: shpltr(), findships(), and selectship(); GEMAIN.C and GEMAIN.H: allocation and limits |
Summary
Classic does not derive a ship letter directly from the ship. Each player has a private, temporary scan table that maps up to 15 detected ship slots to the letters A through Z. Event messages call shpltr() to translate a ship slot through the receiving player's current table.
If that ship is not in the table, shpltr() returns ?. Classic does not refresh the table before giving up. This can put a question mark into combat messages even when the ship is presently close enough and visible enough to be detected.
The same channel-based table is also involved in a separate stale-lock problem: letters and @ locks can survive changes in the player occupying a reusable ship slot. This page first distinguishes the many reasons for the visible question mark from that related identity problem.
What a scan letter represents
NOSCANTAB is 15. Every player therefore has 15 SHIPTAB entries. An active entry contains:
- a live ship-array slot number;
- the letter assigned to that slot for this particular observer;
- its distance, bearing, heading, and speed at the last rebuild; and
- a flag indicating that the entry is active.
The table is rebuilt by SCAN RANGE and SCAN SECTOR. Classic considers active ships in the observer's scanner range, rejects fully cloaked ships, sorts the candidates by distance, and retains the closest 15. It attempts to preserve a surviving ship slot's old letter so that letters do not change on every scan. Newly included ships receive unused letters.
Letters are therefore local and temporary. Ship A to one player need not be Ship A to another, and a ship can exist without being present in a particular player's table.
The question-mark fallback
Classic's complete letter lookup is effectively:
for (i = 0; i < NOSCANTAB; ++i) {
if (scantab[viewer].ship[i].shipno == ship)
return scantab[viewer].ship[i].letter;
}
return '?';
It searches the table exactly as it was left by the viewer's last qualifying scan. It does not ask whether the ship is detectable now and does not call update_scantab() before returning ?.
Where the question mark appears
Several classic messages insert the result of shpltr() with a %c field:
| Event | Who performs the lookup | Typical visible result |
|---|---|---|
| A torpedo is launched | The targeted ship looks up the attacker | Incoming torpedo from ship ?
|
| A missile is launched | The targeted ship looks up the attacker | Incoming missile from ship ?
|
| A standard phaser hit is deflected | The shielded target looks up the attacker | Phaser hit from Ship ?
|
| Fire control locks or attempts to lock | The attacker or target looks up the other ship, depending on the message | Ship ? has a fire control scanner locked on us, or a failed-lock message naming Ship ?
|
| A torpedo or missile reaches its target | The projectile owner looks up the target | our torpedo has hit ship ? or our hyper-missile has hit ship ?
|
An unshielded standard-phaser hit names the attacking commander rather than using shpltr(), so that message does not exhibit this particular question mark. The shield-deflection message does use a scan letter and can.
Why the lookup can fail
The receiver has not scanned
Entering the game does not populate a useful current scan table. Until the player performs a scan that calls update_scantab(), most other ship slots are absent. An immediate attack or lock warning can consequently name the source as Ship ?.
The ship arrived after the last scan
A player may have a valid table, but it is only a snapshot. If another ship enters scanner range, enters the sector, or becomes uncloaked afterward, it is not added until another table-rebuilding scan. Events from that newly detectable ship can use ? in the meantime.
The ship was outside range during the last scan
The ship may since have moved into range, but the old table still lacks it. Classic's event lookup does not notice the movement. Even without movement, scanner ranges are not necessarily symmetrical: an attacker in a class with a longer scanner range can target a ship whose shorter-range scanners cannot identify the attacker.
In that asymmetric case, refreshing the target's table would still not produce a letter. The question mark truthfully means that the source is not detectable by that ship, rather than merely that its table is old.
The source was fully cloaked
update_scantab() excludes ships whose cloak state is 10 or higher. A cloaked source is therefore absent from a freshly rebuilt table as well as an old one. Depending on the action and cloak-transition timing, a message can need a source letter before the receiving player has rebuilt the table after the source becomes visible.
More than 15 ships are detectable
Although letters A through Z exist, the table has only 15 entries. The rebuild sorts ships by distance and keeps the closest 15. A sixteenth detectable ship has no letter for that observer. Classic's fallback is therefore unavoidable for events involving an omitted ship until the set or ordering changes.
This is the remaining case described by ge-next's later comment as “too many ships,” although range and concealment can also keep a ship out of the table.
A projectile arrives after conditions changed
Torpedoes and missiles travel for multiple updates. The target had to be selectable when the weapon was launched, but it can move, cloak, leave scanner range, or fall out of the closest 15 before impact. If the attacker rebuilds the table during that interval, the target can disappear from it. The later impact confirmation then reports Ship ?.
The requested ship is the viewer's own ship
update_scantab() deliberately excludes the observer. Any unusual path that asks a player's table for that same player's ship slot cannot find it and returns ?. Normal targeted weapons prevent self-targeting, but stale state or self-owned hazards can expose related edge cases.
Question marks caused by scan-table corruption
The cases above leave a structurally valid table that simply does not contain the requested ship. Classic also has paths that corrupt the table or feed it an invalid letter. A resulting ? no longer reliably means “unknown ship.”
Bug 22: the owned-ship list overruns SCANTAB
Classic reuses scantab[usrnum] as temporary storage when a player enters the game with multiple persistent ships. findships() walks every ship record owned by that player and writes its persistent ship number into the next scan-table entry:
sptr = &scantab[usrnum];
found = 0;
do {
if (ship_belongs_to_player) {
sptr->ship[found].shipno = warsptr->shipno;
++found;
}
} while (qnxbtv());
There is no found < NOSCANTAB condition. The configuration permits MAXSHIPS values as high as 50, while NOSCANTAB is 15. The default maximum of eight hides the defect, but an administrator can select a supported configuration that makes the overwrite inevitable.
The scan tables are allocated as one contiguous array. In ordinary C array layout, writing scantab[u].ship[15] is the same location as scantab[u + 1].ship[0]. The consequences scale with the fleet:
| Owned ship being listed | Memory receiving the out-of-range ship number |
|---|---|
| 1 through 15 | The current player's 15 selection entries |
| 16 through 30 | The following player's entire scan table |
| 31 through 45 | The next player's entire scan table |
| 46 through 50 | The first five entries of a third table |
If the current player owns the last allocated scan table, ship 16 immediately writes beyond the complete scan-table allocation instead.
Only the shipno member is replaced. The neighboring player's old flag, letter, distance, bearing, heading, and speed remain. The entry can therefore claim that an old letter belongs to an unrelated persistent ship number. Before that user scans again:
- a formerly valid live ship mapping can disappear and make
shpltr()return?; - a message can assign an old letter to the wrong live ship slot;
- a scan can dereference the corrupted number as though it were a live ship-array index; and
- a later rebuild can preserve the surviving letter under the wrong slot number.
This explains why Bug 22 can affect a different simultaneous player who did nothing except remain online. The owner of the oversized fleet performs the write; the next user's scan table receives it.
Classic's selectship() compounds the problem by accepting every nonnegative numeric selection without checking it against 15. A selection of 16 reads the first entry in the next user's table as though it belonged to the current player's ship list. That path is an out-of-range read rather than the write that originally corrupts the table.
The 300-slot letter workspace can return garbage
update_scantab() preserves old letters in a local array declared as:
char lettab[300];
Classic allows NUMSHIPS to be configured as high as 500, then adds the BBS terminal count to obtain nships. While saving old letters, the routine checks that a prior ship number is below 300. While assigning a preserved letter to each newly detected ship, however, it performs an unchecked read:
tmp.ship[i].letter = lettab[othusn];
For a live ship-array slot of 300 or higher, this reads beyond lettab on the stack. A zero byte happens to trigger normal new letter assignment. Any nonzero byte is treated as though it were a preserved letter. If that byte happens to be ASCII ?, an active ship can appear as ? directly in a scan map or full scan listing. Other bytes can produce duplicate letters, punctuation, control characters, or otherwise malformed scan output.
This differs from the ordinary shpltr() fallback: the corrupted question mark is stored in an active table entry rather than returned because the requested ship was absent.
Invalid nonzero letters are preserved
Classic's pick_letter() assigns a new A-through-Z letter only when an active entry's letter is zero. It does not validate an existing nonzero letter. A question mark or other garbage character introduced through memory corruption can therefore survive subsequent table construction for as long as the affected slot continues to be detected.
The initial channel-zero edge case
The scan-table allocation is initially cleared to all zero bytes. Before its first rebuild, all 15 nominally empty entries therefore look to classic's shpltr() as though they contain ship slot 0 with letter value zero. Because classic does not check the entry's active flag, a lookup for ship slot 0 can return the NUL character instead of reaching the final ? fallback.
This is not another displayed question mark; it is a related initialization error that inserts a non-printing NUL where a ship identifier was expected. Its exact visible effect depends on the formatter and output path. Lookups for other ship slots still fall through to ?.
SCAN SHIP translates the failure into prose
When one player scans another, classic notifies the target. It first compares the distance with the target's own scanner range:
- If the scanner is beyond the target's range, the target receives the “unknown source beyond our range” message and a bearing.
- If the scanner is within the target's range but
shpltr()returns?, the target receives the separate “unknown source” message and a bearing. - If the lookup succeeds, the target is told the scanner's ship letter and ship name.
Thus a failed lookup does not print a literal question mark in this path, but it causes the same visible loss of identity. The second case is particularly misleading when the source is currently detectable and is missing only because the target has not refreshed its scan table.
Correction in ge-next
ge-next 0.3 changed shpltr() into an on-demand lookup:
letter = find_active_scan_entry(viewer, ship);
if (letter == '?') {
update_scantab(viewer_ship, viewer);
letter = find_active_scan_entry(viewer, ship);
}
The real implementation performs the two searches explicitly. Both searches also require the entry's active flag, preventing an empty zero-initialized entry from masquerading as ship slot 0. The rebuild preserves only valid letters A through Z rather than carrying an invalid placeholder forward.
If a ship is now detectable and fits among the closest 15, the refreshed table assigns it a letter and the pending message uses that letter. The obsolete within-range “unknown source” branch of SCAN SHIP was removed for the same reason: an in-range visible scanner can now be resolved on demand.
ge-next still returns ? when a fresh table legitimately cannot contain the ship, including scan-range, concealment, nebula, and 15-entry capacity cases. The correction removes stale-snapshot question marks; it does not pretend that every ship is identifiable.
ge-next 0.3 also rewrote the persistent ship-selection display as pages of at most 15 entries. findships() stops writing at NOSCANTAB, clears the bounded page buffer before reuse, and lets the player request the previous or next page. selectship() rejects numeric selections outside the current 15-entry page. This fixes the Bug 22 cross-user overwrite and out-of-range selection read.
The rebuilt scan table also retains an old letter only when it is between A and Z. A corrupted ? or other nonzero character is converted to zero and receives a valid unused letter on the next rebuild.
ge-next retains the 300-byte lettab workspace, but ge-next 0.2 introduced a hard limit of 125 total live ship-array slots. It logs an error and caps nships when the configured terminal and NPC totals would exceed that limit. Every valid ge-next slot is therefore safely below 300, so the classic unchecked read cannot be reached through a supported live slot.
A new port should still size the workspace from its authoritative ship-slot limit instead of relying on two unrelated numeric constants. If it permits 300 or more slots, it must enlarge or replace the preservation table.
Separate stale lock and identity issue
The classic table stores reusable live ship-array slots rather than stable ship identities. A preserved letter or @ lock can therefore outlive the player who originally occupied the slot. If another caller later reuses that slot, old state can appear to identify the new ship. Lock consumers also differ in how they validate range, presence, concealment, and self-locks.
That problem produces stale or incorrect identities rather than only question marks and requires separate treatment from the refresh behavior above.
Porting guidance
Treat a scan letter as a temporary presentation mapping, not as object identity. Before using one in asynchronous output, refresh or validate the view against current range and concealment rules. Check that entries are active, and distinguish “stale snapshot” from “currently undetectable” and “table capacity exceeded.”
Any routine that borrows SCANTAB for unrelated temporary storage must obey NOSCANTAB and clear the bounded entries before use. Prefer a dedicated selection buffer when practical. Size auxiliary arrays from the same authoritative ship-slot limit used by iteration; a checked write followed by an unchecked read is not a bound.
Persistent locks should use a stable object identifier plus an existence or generation check. Reusable connection or array slots are not stable identity.
Home · Play · Reference · Versions & history · Sysop · Development · Classic bugs