Introduction
It's official, ACE is possible Solar Jetman! This TAS is the product of an investigation I conducted after accidentally crashing the game when testing out variations of the warp duplication glitch, where the game was actually jumping to an unintended place in the code. The explanation is very technical, and I am happy to answer questions as best as I am able.
Arbitrary Code Execution
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.
Strategies
This section outlines the strategies utilized throughout the run. For more details, please reference the
game resources page.
Warp Duplication
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 4's warpship part room will be created.
Setting Up Corrupted State
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 thing to 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.
Wall Clipping
This TAS features a different kind of wall clips than what was used in the TAS without ACE, which require glitching a sphere enemy. These clips are a new discovery requiring a frame perfect shield activation before colliding with certain wall geometries. This helps us navigate through the maps much faster.
RNG Manipulation
RNG manipulation is heavy used throughout the run, primarily by using the inputs for player two. Although there is no other use for a second controller, it still influences the game's RNG based randomness. This allows us to nearly perfectly control the RNG state without affecting the inputs for the controller also controlling the player.
Route Summary
Planet 1
The shield is obtained to perform wall clips, which is what I do immediately to make my way quickly to the warp while also performing an essential wall bump. By shooting a bullet after bumping the wall while I have backwards momentum, the momentum is transferred into the bullet to make it much slower. This allows it to interact with the warp enemy more times than usual as it passes over the warp enemy's hitbox, creating many warps but not quite enough. I still have to shoot another bullet at the warp enemy to get the last couple duplications. RNG manips are used to spawn in a couple sphere enemies to help overload the CPU to get the glitch, and to help bump the pod efficiently. The fastest way to get the warp we want to be spawned in is to unload all the warps by moving back to the left and respawning them such that the warp we want is in the 1st index so that we interact with it first. Bullets are fired throughout this planet to adjust subpixel positions.
Planet 4 Part Room
Some bullets are fired to adjust subpixel positions as I travel to the warp.
Planet 4
The first order of business is to duplicate the warp to planet 11 before it's timer runs out, which only needs to be done once. Then I perform a wall clip to access the bottom area faster. But before heading down there I need to kill 2 turret enemies to set up part of the compressed byte I want. The red planet is used to push me back down faster. Next I navigate to the two turret enemies I need to kill in the underwater section. I actually kill 3 here because one of them is blocking the line of sight to the other one, but that enemy state is not stored in the same byte that I am controlling so it does not affect the outcome of the ACE setup. Lastly, I clip out of bounds once more to get back to the warp we duplicated quickly. Bullets are fired throughout this planet to adjust subpixel positions.
Planet 11 Part Room
While I am warping back from the part room, I perform some RNG manipulation for the 16 frames leading up to the critical frame to set the RNG values properly.
Timing
Criteria
For a more precise timing, the following RAM address is referenced:
0x003B - Current Planet Indicator (Planet 1 = #00, Planet 4 = #03)
And the following criteria:
- Start - All the frames from power-up until the start of the first planet.
- Planet 1 - Starts on the frame pressing START on the "START GAME" menu.
- Planet 4 - Starts on the frame the RAM address 0x003B equals #03
- End - The last frame of input
SPLIT || START | COUNT
_ _ _ _ _ _||_ _ _ _|_ _ _ _
Start || 0 | 114
Planet 1 || 114 | 2940
Planet 4 || 3054 | 4065
End || 7119 | -
Software & Hardware
ROM
- Solar Jetman - Hunt for the Golden Warpship (USA).nes
- SHA1: CAA4D1AB710BD766F8505EF24F5702DAC6E988AF
- MD5: 45757BDF0D1A0E9DE8F9590FB692DA55
Emulator
Challenger: Movie file updated with
31 frames trimmed near the end, as the latest input frame required for the ACE setup occurs at the exact point where the game does the screen transition moments before the credits plays successfully.
Challenger: It's pretty awesome to see this game getting deeper research to the point where the game not only skips more planets but also discover a way to enter walls, later improving a faster way without the need to use enemies and finally, an ACE setup to skip the last (and important) section of the whole game.
Brillant job on every way and including the details about those new discoveries in the submission text! Accepting for Publication as a new category.