Useful addresses
| Addr | Size | What |
|---|
| 004C | 2 bytes | Camera X (screen position) |
| 004E | 2 bytes | Camera Y |
| 005A | 1 byte | Volatile controller-based RNG |
| 005B | 2 bytes | Bit shifting RNG (big endian) |
| 005D | 2 bytes | Frame timer |
| 00C6 | 2 bytes | Fuel remaining |
| 00C8 | 2 bytes | Weapon power remaining |
| 0374 | 1 byte | Player facing |
| 0336 | 1 byte | Player X speed direction (0=right, 1=left) |
| 029B(lo) 02BA(hi) | 2 bytes | Player X speed |
| 0200(lo) 021F(hi) | 2 bytes | Player X pixel position |
| 0197 | 1 byte | Player X sub-pixel |
| 0355 | 1 byte | Player Y speed direction (0=up, 1=down) |
| 02D9(lo) 02F8(hi) | 2 bytes | Player Y speed |
| 025D(lo) 027C(hi) | 2 bytes | Player Y pixel position |
| 023E | 1 byte | Player Y sub-pixel |
Movement
| What | Max p/f | Up | Down | Left | Right |
|---|
| Thrust | 3.0 | Angles 24 to 31, 0 to 8 | Angles 8 to 24 | Angles 17 to 31 | Angles 1 to 15 |
| Gravity | 3.0 | Inverted gravity | Normal gravity | | |
| Object swing | 4.0 | | | | |
The pod's maximum speed is 4.0 pixels per frame. This can only be done by tethering an object and having it pull you along. If your thrusters or the gravity attempt to accelerate the pod in the same axis, it will instantly reduce the speed to a maximum of 3.0 if the pod is traveling faster.
Thrust
Thrusting enforces a maximum speed of 3.0 pixels/frame.
The default thrusters accelerate an unburdened pod at 16, when facing straight into some direction. Carrying an object will cut this acceleration by varying amounts depending on object mass.
Thrusting straight right (direction 8) or straight left (direction 24) will count some thrusting force upward, and will prevent reaching
Basic thrusting with default thrust or Double Strength Thrusters take 2 fuel per frame, or 16 fuel when using boosters. Efficient Engines reduce the costs to 1 or 8 fuel per frame, respectively.
Gravity
Gravity enforces a maximum downward (or upward, if reverse) speed of 3.0 pixels/frame. Even zero gravity is counted as downward with zero acceleration, and will enforce the downward maximum.
Gravity usually pulls the pod down at a constant rate, depending on the planet. There may be areas on a planet submerged under a fluid of sorts. The only affect of entering one such fluid is a change in the acceleration of gravity. There's still no friction.
Anti-Gravity completely shuts off gravity when active. Downward acceleration, speed maximum, and tethered object will not be affected by gravity with this item in use.
Angles
The pod can face 32 different directions.
Straight left (24) or straight right (8) is still counted as a thrust upwards of usually zero acceleration. If you desire to travel faster than 3.0 pixels/frame going up, avoid thrusting in these directions.
Straight down (16) or straight up (0) is not counted as thrusting either left or right. There will be no interference for horizontal momentum that exceeds 3.0 pixels/frame.
Tethered objects
Tethered objects are impacted by gravity in an intuitive way. By accelerating in the opposite direction of the motion of a tethered object, player actions can also help increase the tethering speed. The faster the tethering speed and the heavier the object, the more it influences/accelerates the player's speed. Generally speaking, this is again done in an intuitive way. But please note that there are oddities in how the game calculates the resulting pod speed. For example, continuously thrusting in the opposite direction of the movement of the tethered object will not necessarily result in generating the most speed. Some thrusts have more impact for the speed than others. And some thrusts even have a negative impact on the speed, even though they intuitively should instead be contributing. The underlying mechanics have not been investigated in further details, but are important to take into account when TASing, even if it's only in the form of playing around with inputs by "trial-and-error".
So for example, when travelling to the right, a tethered object will accelerate the player when moving in front of the pod (doesn't matter if the object is moving clockwise or anti-clockwise around the pod), up to a speed of 4p/f. Once the tethered object crosses the vertical line of the pod to the left side, it will start to decelerate the player. If the object comes to rest, it will no longer impact the pod's speed, even if it's off-center with respect to the pod. This speed-up mechanic is significantly more efficient when traveling to the left than when traveling to the right. The reason for this has not been investigated.
Vertical motion is impacted by tethered objects in a similar way as horizontal motion. The main difference being that the gravity fields of the planets prevent the pod from speeding up above 3p/f (not applicable to large wormholes!). This limitation can be bypassed by activating the "anti-gravity" upgrade. To speed up for example downward movement, place the tethered object above the horizontal centerline (or below in an inverted gravity field), the higher the better, and let it freely fall down. When it's about to pass the centerline, ensure the pod's y-speed is already 3p/f and activate anti-gravity. While the object swings below the pod, it will accelerate the pod up to a vertical speed of 4p/f.
The RNG
| 005A | 1 byte | Volatile controller-based RNG |
|---|
| 005B | 2 bytes | Bit shifting RNG (big endian) |
|---|
The value at address 005A is affected by the following:
| 0018 | Player 1 immediate - Exact mirror of controller state |
|---|
| 0019 | Player 1 pressed - Bits are set if pressed now, but not held before |
|---|
| 001A | Player 2 immediate |
|---|
| 001B | Player 2 pressed |
|---|
| 005D | Frame timer low-byte |
|---|
| 005E | Frame timer high-byte |
|---|
| 005F | Always zero? |
|---|
| 0060 | Looks like address 005D MOD 8 |
|---|
The thing that matters is the fact 005A is affected by direct controller input. This includes Player 2, which has no effect anywhere else in the game. By using Player 2, it is possible to gain very strong control over how the RNG works.
The values at address 005B and 005C are left-shifted every frame. The bit shifted into 005C is an XOR of bits 0x40 and 0x08 from 005A, although it has been observed to, on rare occasion, shift in an opposite of this bit. The bit shifted out of 005C is shifted into 005B, and the bit shifted out of 005B is thrown away.
Since 005B and 005C are affected by what happens with 005A, and 005A is affected by controller input, this means you should have complete control over the RNG.
During game play, you have control every other frame, but the RNG runs every frame. As the RNG "ticks" twice for every frame you have control, this makes it somewhat difficult to set the RNG perfectly.
All three RNG addresses are used for various random events.
Final boss
| 038B-038F | Eye aperture states |
|---|
| 0419-041D | Eye timers |
|---|
| 0439-043D | Eye opening sequences |
|---|
| 0458-045C | Eye health |
|---|
| 0460-0464 | Eye states |
|---|
The top eye can be despawned during the auto-scroller, well before the actual boss fight begins. This can be seen by monitoring the eye state address $0461. At the time of writing, the best description for what happens is found here:
Forum/Posts/367718 . There have been no reports of additional eyes being despawned (or other eyes than the one at the top). The bot 'Jaffar', created by TASer 'eien86', was able to quickly find solutions that despawned the top eye, but did not find any other variants.
When the window gets "close enough" to the boss, the eye timers get initialized one-by-one to 0 and will then start ticking up every 8 frames. This appears to be programmed in a set pattern, meaning the start order of the eye openings is always the same. Note that small variations have been observed, so there appears to be a little room for manipulation in the timing of the first eye openings, but this has not been investigated in detail.
When an eye timer reaches 0x20, the game will branch at instruction $C08C to the piece of code for the eye opening sequence. The sequence is split in 8 segments, each consisting of 8 frames. The first and last consist of the eye being half-open (and not vulnerable). When the eye opening sequence begins, the game will also re-initialize the timer for the next phase. This is done by comparing one of the RNG-addresses and 0x1F. The result is written to the timer and becomes its new start value. The eyes with index 0, 2 and 4 use $5B for this and the eyes with index 1 and 3 use $5A. Since the RNG-addresses can be manipulated (see section above), the player has control over all eye openings at any time, except for the first cycle as explained above.
Wall Clipping
Various wall clips have been discovered with a wide range of difficulties and requirements. Below are the currently known types of wall clips.
- Bump Clip: Works in narrow angled eastward walls, where shielding frame perfectly allows the pod to bump into the top wall and clip through the bottom wall.
- Clean Clip: Works in wide angled westward walls, where shielding frame perfectly allows the pod to bypass collision entirely
- Wedge Clip: Works in narrow angled walls with at least one wall being horizontal, where the pod will slow clip through the wall over time. Some wall geometries require unshielding to initiate the clip, but most can be done without a shield at all
- MDR Clip: Works in a much wider selection of walls, but requires a glitched enemy. See the dedicated MDR Clip section below
CPU Overloading
CPU overloading is a strategy that players can implement to cause unintended behavior by the game code. The CPU on an NTCS NES has a maximum of 29780.67 cycles per frame, with each code instruction consuming a handful of these cycles. Solar Jetman is a very CPU intensive game at times, and this limit gets maxed out quite easily. When that happens, a CPU interrupt occurs that immediately stops what the code is doing and gets ready to start the next frame from the top. This allows the player to stop the code at a crucial time to intentionally cause a glitch to occur due to RAM not being in an intended state after the interrupt. While this actually has practical applications for RTA runners, this can be abused heavily in a TAS. The number of active entities is the key tool that players can leverage to control CPU usage, such as bullets and enemies. The debugger in FCEUX is a great tool for this because you can set a breakpoint at the start of the window, the end of the window, and at the interrupt handler and use the CPU cycle counter to know how far away you are from hitting the interrupt at the right time. This allows Currently, all use cases for this glitch lie in the collision handling portion of the game logic since that consumes most of the CPU time. Below outlines the known applications for CPU overloading.
Warp Duplication
On planet 1, there is a warp that is intended to take the player to planet 7. This is triggered by killing a special enemy within a specific time frame which spawns the warp. It has been known for a while that there is a glitch that can cause this warp to be duplicated, but it was later revealed that CPU overloading was the root cause of this glitch.
When a bullet makes contact with the warp enemy, a code branch executes to handle the collision. Instruction $89E6 is responsible for initializing the warp in the persistent entity array in RAM. Instruction $897B is responsible for clearing the warp enemy from the active entity array. If an interrupt is triggered between these two instructions then the warp will have spawned in and the enemy will continue to be spawned in, thus allowing the enemy to interact with the bullet a second time so that the warp is duplicated.
The planet that the glitched warp transports the player to is determined by its index in the persistent entity array. Planet 1 starts with 6 persistent entities, the intended warp is 7, meaning the glitched warp will transport the player to planet 8. If this glitch is repeated enough times in a row, then a warp directly to planet 12 will be created.
Notes:
- This array has a maximum size of 26. Glitched warps after index 13 will start to transport the player to the warpship part rooms, with the last slot being the warpship part room for planet 13. While this seems extremely useful, most of these warps will spawn the player directly on top of the warp back to the corresponding planet as this type of warp does not grant the player a warp cooldown like the intended warp to the warpship part rooms does.
- If a glitched warp is created when the array is already maxed out, this does actually overflow and start writing data to unintended locations. This does not actually create warps that can be interacted with, however. This does warrant a future investigation to see if any other glitches can be created due to this overflow.
- The game can only have 4 warps active at once (this space is also reserved for fuel cells and other similar entities, which can cause the fuel cell to be despawned temporarily). These types of entities have a peculiar behavior when there are too many close together in that they attempt to move back to their "home". You may have seen this before if you have moved many carryable entities into one area, and one of them will start to move back. This explains why the glitched warp will be moving. The warp that moves first is inconsistent, and not fully understood at this time.
- This also works for the warp on planet 4.
MDR Clip
MDR clipping is a special variation of wall clipping, first seen in a Twitch stream by MickeyDadRad where he managed to get pushed out of bounds through a wall by a sphere enemy. This occurred by complete accident and an insane amount of luck. If this footage had never been captured, this glitch may never have been demystified.
The sphere enemies are unique in that they can push the player around without bouncing away like other enemies. If a player is near a wall when this happens, then the sphere enemy can push the player several frames in a row. Normally this causes the enemy to die very quickly, since it takes damage every time it comes into contact with the player. However, abusing CPU overloading allows us to get around this. Instruction $8902 is responsible for updating the sphere enemy's health when hit by a bullet. Instruction $8936 is responsible for clearing the sphere enemy from the active entity array if it's health is zero. By performing an interrupt when its health reaches zero, the enemy's health underflows to 255. This allows the sphere enemy to survive long enough to push the player all the way through a wall.
The player can freely move once out of bounds and can go back in bounds easily because wall collision is only one-way, although this usually cause the player to bump into the wall which deals some damage.
Notes
- The shield is required for this glitch, otherwise the player would die before being pushed through the wall.
- This glitch works better with a wide angled corner compared to a shallow angle corner.
- The sphere enemy must be moving nearly directly towards the player horizontally or vertically to push the player far enough in a single frame.
- While not impossible, this glitch tends to work better on vertical walls rather than horizontal floors or ceilings. This could be due to gravity affecting vertical velocity, but this is not confirmed.
- This also causes the sprite of the enemy to render incorrectly until damaged again because it's sprite depends on how much health it has. For some planets this means it turns invisible, but for others it could look like a different enemy.
Boss Eye Despawn
One of the boss eyes can be despawned if an interrupt occurs during a meteor being destroyed by a bullet. This definitely needs more investigation as it only works with certain types of meteors, but essentially the local entity for the meteor has a index of it's persistent object, which is responsible for knowing when and where to spawn it. When the meteor is killed, it also clears out its persistent object at that index. But if an interrupt occurs that prevents the local entity from despawning, it can be "killed" again and clear out the persistent object at whatever index is there now, which can be a value of 01 where the top boss eyes is stored. This index has been observed to be 02 at times, which represents another boss eye. However, there is not a known CPU interrupt that can be triggered to despawn a second eye at this time.
The Tether Glitch
This glitch allows the pod to tether an empty entity and entities that are not intended to be carried. It is comparable to the popular stale reference manipulation glitch in Ocarina of Time.
Setup
The trigger for this glitch is a CPU interrupt during the collision handler for the pod and a dropped item from destroying a saucer forcer or a space snake enemy. The interrupt must occur after the entity data that represents the score value of the item is cleared from memory, but before the entity itself is cleared from memory. In this state, the item is now treated as a item that can be carried. One quirk of this glitch is that it requires at least 1 player bullet entity slot to be open out of the 8 available, because the score indicator takes up this slot and the code branch we are targeting for this interrupt only occurs when the score indicator is displayed. A fairly consistent setup has 7 player bullets spawned in and 2-3 enemies spawned in, but as with other glitches involving CPU interrupts there is unpredictable variance that may need to be accounted for. The next stage occurs when the dropped item despawns, or is collected a second time from shooting it with a bullet. The tether does not know that the item has been cleared from memory and remains tethering nothing. The next entity that loads into the same slot in memory that the item took up will then be teleported into the tether and be carried by the pod.
Applications
By loading an entity into the empty slot that the tether points to, important objects can be reached sooner than usual or even through walls because it will warp into the tether as soon as the object is loaded off screen.
Dropping off the item dropped from a saucer forcer can grant a large sum of money. This is known as the lottery glitch. The entity data in memory that is used for other dropped off items to determine the point value is normally used as a timer for the dropped item to keep track of when it should despawn. This means that a different value is rewarded for each frame it could be dropped off. There is not a direct correlation between value and frame count, as the read value is intended to be an index into a table to lookup the value and out of bounds values lead to unpredictable results.
In a pinch, this glitch can be used to gain item tether momentum in place of another item that may be more out of the way than spawning in a saucer forcer or space snake enemy.
On some levels, attempting to perform the tether glitch on a star dropped by a space snake enemy instead treats the star like a wormhole to the part room. It does still require the player to have the necessary fuel cells collected.
Arbitrary Code Execution
ACE has been solved in Solar Jetman and can be utilized in a TAS. This section outlines all the details necessary to set it up.
Corrupted Warp State
The key functionality that we are exploiting here has to do with how the game saves and loads state as warps are performed. There are different types of warps that update the game state in slightly different ways. Usually when warping to or from a warpship part room, the map ID of the previous area is saved along with the enemy states for that planet. This type of warp has a transition sequence that plays where a bunch of stars appear on the screen. This does not happen when taking a special warp, such as the warp from planet 1 to 7, or the warp from planet 4 to 11. In this case, the map ID of the previous area is not updated. The map loading sequences differ depending on the map ID of the current area and the map ID of the previous area. These IDs are compared to #0C to check if they are normal planet areas or warpship part room areas. If somehow both the current ID and the previous ID represent warpship part rooms, then corruption occurs due to a part of RAM being used in inconsistent ways. To be specific, the location in RAM where part of the persistent enemy states for the previous area are erroneously read and saved into the persistent object table, which is then spawned into the map and attempted to be updated.
Enemy Handler Function Address Tables
The function that updates a game object depends on the ID of the object. This ID is used as an offset to look up the high ($0E:AA4C) and low ($0E:AA17) bytes of the address of the code to update the object. If the ID of the object is larger than the lookup table, then addresses can be jumped to that were not at all intended. This gives us a set of addresses that could potentially be executed, some of them even occurring in RAM. The value that the object ID is set to comes from another lookup table for objects ($04:86CD), with the offset used being a byte of saved enemy state located in RAM at $0636. This is a value we can control.
Persistent Enemy Compression
When warping, the enemy states of the current map are compressed by a factor of 8. The code iterates through the persistent enemy table in RAM starting at $0460, and compares each byte in the table to #01. This is because when an enemy is killed, its value in this table gets updated from its ID to #00. A value of #00 gets converted to a 0 bit while any other value becomes a 1 bit, and the few bytes that are generated from the table are stored in RAM starting at $0633. This table is supposed to be used when warping back to the normal planet area from a warpship part room to not initialize any enemies that have already been defeated. So to control the critical value at $0636, all we need to do is defeat a specific set of enemies stored in addresses $0478 to $047F to produce any byte we want.
RAM Execution
To summarize:
- Defeating specific enemies zeroes out values in address range $0478 to $047F
- Values in address range $0478 to $047F are converted to a single byte based on zero/non-zero values and stored in $0636
- $0636 is erroneously read as an offset to initialize a persistent object from ROM address $04:86CD and stored in $0513
- $0513 is read to spawn in an object into a map and stored in $0322
- $0322 is read and used as an offset to lookup the address for the update handler sequence at ROM addresses $0E:AA17 for the low byte and $0E:AA4C for the high byte
- The resulting address is then jumped to
This gives us a real but limited way to control where this jump ends up. The restrictions are the addresses available outside of the normal table range for object handler addresses and the object IDs available outside normal range in the object table.
Out of the limited options, $00C6 is the best because it is actually the only option we have any further control over without risk of corruption.
Health Bytes
$C6-$C7 represent the current health, while $C8-$C9 represent the max health. We have nearly full control over the 2 bytes of health as using boost for one frame subtracts one from the health value. The only restriction is that the high byte cannot exceed #E0. We do not have control over the max health bytes, but since $C8 is always #00 we are able to setup a jump instruction to another location in the zero page that we have more than 2 bytes of control. The best option turns out to be the 3 bytes of RNG, located at $5A-$5C. This means we need our health bytes set to #4C and #5A create instruction JMP $005A (4C 5A 00).
RNG Bytes
Effectively we have full control over RNG bytes $5A-$5C. $5A is affected by player 1 and 2 inputs and the frame count. Player 2 is especially important because those inputs do not affect other gameplay at all. Each frame, a bit from $5A is left shifted through $5C and into $5B giving us full control over these 3 bytes by performing input manipulations for 16 frames leading up to the critical frame. With 3 bytes fully at our disposal, we can jump to anywhere in RAM that we have even more control over. There are two good options, each with 8 straight bytes of complete control over. We will use $0241-$0248 which represent the y-subpixel positions of the player bullets. The RNG values we manipulate will produce instruction JMP $0241 (4C 41 2A).
Bullet Position Bytes
The RAM table that represents local objects has 8 slots for player bullets, which comes with position tables. Even when a bullet despawns, it's position remains stored in RAM. Additionally, subpixel positions are not cleared when a new bullet is spawned. This is actually a very critical detail because the stars that appear when warping actually consume the same bullet slots in RAM, but do not change the subpixel positions at all. The pixel positions are affected, however, which is why subpixel positions are being utilized instead.
While both x-axis and y-axis subpixel positions are controllable, the y-axis is chosen because it is easier to implement in a run. This is due to the player's x-axis velocity typically being maxed out. When at max speed, the subpixel position of that axis will not change as a bullet is shot and moves. Rather than adjust x-axis velocity and potentially slowing down the player we will use the y-axis subpixel positions of the player bullets.
These RAM values are only viable for ACE because this game actually represents position to the 1/256th of a pixel, meaning any byte in the #00-#FF range can be set as the subpixel value. This means we have absolute control of 8 straight bytes worth of instructions.
Initiating the Credits
If initiating the credits was as simple as jumping to somewhere, then this could have been done during RNG manipulation. There are two factors that make this not possible.
This ROM has multiple PRG banks. The one that is currently loaded does not contain the code to initiate the credits, which is located in bank #00 at address $AC20.
Jumping straight to the code that initiates the credits does not work immediately. There is one state byte that needs set first, otherwise the game will get stuck in a loop. Address $0075 needs to be set to #00.
Luckily, the accumulator at this stage is set to #00, meaning we can perfectly fit the instructions to initiate the credits in 8 bytes as such:
- Store A in the zero-page (2 bytes)
STA $75 (85 75)
- Store A at $FFCB to change the PRG bank to #00 (3 bytes)
STA $FFCB (8D CB FF)
- Jump to the code that initiates the credits (3 bytes)
JMP $AC20 (4C 20 AC)
Executing these instructions immediately starts the credits, surprisingly with no corrupted visuals at all.
The Route
To set up the corrupted warp state, we must perform the following steps:
- Warp from planet 1 to planet 4 part room by glitching the planet 7 warp many times
- Warp to planet 4 from the planet 4 part room, setting the previous map ID to planet 4 part room
- Warp from planet 4 to any part room by glitching the planet 11 warp many times, which critically does not update the previous map ID
- Interact with the warp in the part room, this initiates the corruption because both the current map ID and the previous map ID are greater than #0C
One other note here - While warping to planet 1 part room is possible as well and could be a lot faster due to the size of planet 1, this is not viable because there are not enough enemies on planet 1 and thus have no control over the corrupted byte.