How the game reads the keyboard and logs activity during gameplay
It's not unusual for 8-bit computers to come with built-in keyboard routines that are not really suitable for gaming. That's hardly surprising; after all, the main purpose of the keyboard is to allow you to type in programs from magazines, right? As opposed to conquering evil futuristic robots, I mean. And for typing you don't need to worry about timings or processing too many panicky key presses at the same time.
For some games, though, we need to keep track of key presses in a more sophisticated manner. Elite is an extreme example, as it goes the whole hog and completely ignores the operating system, choosing instead to read key presses directly from the keyboard matrix and into a key logger (see the Elite deep dive on the key logger for details). Other games, like Aviator and Revs, don't need to support such a multitude of simultaneous key presses as Elite, so they content themselves with simply reading from the keyboard using operating system calls whenever they need to know what's being pressed.
The Sentinel inhabits the middle ground. It uses the exact same ScanKeyboard routine as Revs to read the keyboard, but it stores the results in a key logger, like Elite. In this article, we'll take a look at how this works.
Populating the key logger
-------------------------
The key logger is at the heart of keyboard support in The Sentinel. The logger itself is a four-byte table at keyLogger that contains information about which keys have been pressed, grouped into four distinct entries (with one byte for each entry).
The key logger entries fall into four categories:
- Entry 0 is for sideways movement keys (pan left, pan right)
- Entry 1 is for action keys (absorb, transfer, create, hyperspace, U-turn)
- Entry 2 is for vertical movement keys (pan up, pan down)
- Entry 3 is for utility keys (volume control, pause, unpause)
The logger is populated by the ScanForGameKeys routine. This routine works its way through a specified set of game keys and sets the key logger according to whether those keys are being pressed. The full set of game keys is defined in the gameKeys table, and the routine is called with a value in Y that defines the offset within gameKeys from where we start the scan, with the scan working through the keys from that point back to the start of the table.
The ScanForGameKeys routine does this as follows:
- Reset all four bytes in the key logger to be empty, which is denoted by bit 7 being set (so all four entries are set to %10000000).
- Work through the gameKeys table, starting from the index specified in Y, and working backwards to the start of the table. The gameKeys table contains internal key numbers for all the keys used in the game, so for each internal key number we do the following:
- Call the ScanKeyboard to read the keyboard and check whether the internal key number from gameKeys is being pressed.
- If it is, look up the details for this key from the keyLoggerConfig table to see how we should store this key in the logger, and store the key press.
- Set the N flag on whether a pan key is being pressed, so we can branch with BMI or BPL on returning, depending on whether any pan keys are being pressed.
The keyLoggerConfig table defines two things for each game key: the entry number in the key logger in which we should store this key press (which ranges from 0 to 3), and the value that should be stored in that key logger entry if the key is being pressed.
Here are the details for each logger entry, with the keys in that entry being shown with higher priority keys first. In the event that two keys for the same logger entry are being pressed at the same time, the higher priority key will be recorded in the key logger and the lower priority key will be ignored.
| Logger entry | Value | Key | Action |
|---|---|---|---|
| 0 | 1 | S | Pan left |
| 0 | 0 | D | Pan right |
| 1 | 32 | A | Absorb |
| 1 | 33 | Q | Transfer |
| 1 | 0 | R | Create robot |
| 1 | 2 | T | Create tree |
| 1 | 3 | B | Create boulder |
| 1 | 34 | H | Hyperspace |
| 1 | 35 | U | U-turn |
| 2 | 2 | L | Pan up |
| 2 | 3 | , | Pan down |
| 3 | 0 | 7 | Volume down |
| 3 | 1 | 8 | Volume up |
| 3 | 2 | COPY | Pause |
| 3 | 3 | DELETE | Unpause |
So if "S" is being pressed to pan the screen left, then key logger entry 0 will be set to 1, while pressing "Q" to initiate a transfer will set key logger entry 1 to 33.
Using the key logger
--------------------
The ScanForGameKeys routine described above is called from three places, each of which populates the key logger:
- The interrupt handler at IRQHandler calls the CheckForKeyPresses routine to check for key presses, which in turn calls the ScanForGameKeys routine to populate the logger. This is only done when a game is in progress and not paused, and the game is not currently focusing on a key action such as drawing a landscape pan.
- When the game is paused, the interrupt handler calls ScanForGameKeys to check for the pause and volume keys, and processes them accordingly.
- The CheckForSamePanKey routine calls ScanForGameKeys with Y = 3 to update the logger for just the four pan keys, so it can check whether a pan key is still being held down.
CheckForKeyPresses is the core keyboard handling routine, and it uses a combination of calls to ScanForGameKeys and ScanKeyboard to process the correct range of key presses for the current game state. It implements a couple of interesting features:
- Debounce is implemented for the SPACE key by recording the key state in the spaceKeyDebounce variable. This means that holding down SPACE will not flip the sights on and off repeatedly, but instead will wait for the key to be released first.
- The pan keys are checked and if the crosshair sights are visible, then inertia is applied to the movement, so the sights start off by moving slowly before increasing in speed if the same movement key is held down.
The crosshair inertia is worth describing in more detail. It is implemented by setting a bit pattern in the sightsInitialMoves variable, to control the initial movement of the sights when a pan key is pressed and held down.
This bit pattern is initialised to %01101011 when a new movement key is pressed and the sights are visible. The pattern is then shifted left by one place on each call to the CheckForKeyPresses routine, while the movement key is held down. This shifts a zero into bit 0, and the rule is that we only move the sights when a zero is shifted out of bit 7.
This means that when we start moving the sights, they move like this, with each step happening on one call of the interrupt handler:
- 0 = Move
- 1 = Pause
- 1 = Pause
- 0 = Move
- 1 = Pause
- 0 = Move
- 1 = Pause
- 1 = Pause
And then we move on every subsequent shift, as by now all eight bits of sightsInitialMoves are clear.
This means the sights move more slowly at the start, with a slight judder, before speeding up fully after eight steps, so this process applies a bit of inertia to the initial movement of the sights.
The input buffer
----------------
Although it is separate from the key logger, it's worth noting the input buffer, which is used to store the landscape number and secret code as entered by the player on the main title screen. The input buffer lives at inputBuffer in the main variable workspace, and input is captured and stored here by calling the ReadNumber routine.
The input buffer is not only used for storing the player's input, but it's also used to check that the entered secret code matches the generated secret code for the landscape. To make things more confusing for those of us wanting to follow along, the key presses are stored in the input buffer using a descending stack that is shuffled up in memory as it grows, with new input being pushed into the low end of inputBuffer, and any existing content in the buffer moving up in memory.
This means that digits at the low end of the stack (i.e. those that were typed last) are of lower significance than those that are higher in memory (i.e. those that were typed first), so the contents are effectively little-endian, just like the 6502 processor. This low-to-high layout does make it easier to convert the four ASCII characters in-place into a two-byte binary coded decimal landscape number, a process that's done by the StringToNumber routine; but it also means that if the player presses the DELETE key, then we need to delete the most recently entered character at address inputBuffer and shuffle the rest of the stack down in memory to close up the gap, which makes the ReadNumber routine a bit more involved than it would otherwise be.
I'm not entirely sure why it's built in this more complicated direction. Perhaps it's just another obfuscation to go along with the many others described in the deep dive on anti-cracker checks? Maybe...