On the previous entry in the franchasie, Castlevania: Aria of Sorrow (GBA), Soma Cruz had to fight the darkness within himself not to become Dark Lord. In this game, we can get a glimpse of what that would have entailed through the Julius Mode: An alternate story in which Soma's friends join forces to defeat Soma after he gives in to the darkness and becomes Dark Lord. In this movie, Soma decides to take matters into his own hands and defeat himself as Dark Lord once and for all.
By means of memory corruption, one can change the final boss of the game to be Soma, allowing for a Soma vs Soma confrontation. This run aims to beat the final boss of Julius Mode, Soma (and Dracula), playing as Soma, as fast as possible. Luckily, this also triggers the good ending of the game; thus, this movie also completes the game as a byproduct of its main objective. The game is completed in 07:15.58.
- Emulator used: desmume-0.9.9, with Advanced Bus-Level Timing OFF
- Custom objective
- Uses zipping glitch and memory corruption
- Manipulates luck
- Takes damage to save time
Table of contents
About the run
The famous zipping glitch has been featured in all published TAS movies of this game that aim at fastest completion time. Zipping out of bounds causes memory corruption, which can result in very extreme effects being applied to the game, such as the ones that allow for completing the game in less than four minutes. Another effect that can be applied to the game through memory corruption is turning on (or off) Julius Mode, without changing the playing character. When the game is in Julius Mode, the final boss of the game gets changed from Menace to a two-phase Soma/Dracula boss. This entire run revolves around memory corruption to turn on Julius Mode and finish the game as quickly as possible.
From a general perspective, the run follows these steps:
- Progress the game until the first memory corruption (this step is the very same as in the fastest published TAS)
- Perform first memory corruption, suspend to get back in bounds
- Perform second memory corruption (on another location), turn on Julius Mode
- Warp to The Abyss and defeat the final boss Soma.
About memory corruption and zipping
Memory corruption occurs in this game when certain Soma's coordinates take unintended values. The castle map (viewable in the upper screen) is represented as a bitmap internally, in which each one of the squares is represented as a single bit: 0 means an invisible square, 1 means a colored square. On each frame, the game uses Soma's square coordinates to color/fill the corresponding square (i.e. set to 1 a specific bit in memory). Soma's square coordinates are 1 byte values, meaning they take values in the range 0-255, and overflow/underflow accordingly. By getting and moving out of bounds, Soma's square coordinates will compute to memory addresses corresponding to other significant game variables, instead of to the castle map. This is popularly used to get all the souls and equipment of the game (including those featured in Julius Mode but not in the regular mode), but it can be used for much more. For example, it can be used to activate unvisited Warp Rooms, and setting game flags (e.g. skipping the final boss), as demonstrated in the fastest published TAS of this game. Every [X, Y] square coordinate pair corresponds to a specific corruption [memory address, bit set] pair. Multiple square coordinate pairs can correspond to the same [memory address, bit set] pair (but not the other way around). There is no known way of corrupting the memory to clear bits, only to set them.
- Square coordinates
- Pair of values [X, Y], ranging from [0, 0] to [255, 255] computed from Soma's position that determine where he should be on the map, and are used to properly color/fill in the squares in the Castle's map. They are key for memory corruption in this game, as any feasible corruption corresponds to one or more pairs of square coordinates.
The most widely used form of memory corruption involves zipping. Zipping is when Soma gets stuck into a wall (namely by means of special attack animation cancelation, such as the Succubus glitch, Level Up glitch, and Magic Seal glitch), and the game moves him upwards very fast, trying to get him back in bounds. When in this stuck state, the player might only move Soma to the right by sliding. Any other control of Soma is lost. The quick form of movement when zipping allows Soma to corrupt many addresses in little time. The main way of getting back in bounds involves sliding some distance to the right, waiting for the Y position of Soma to underflow (lower coordinates represent higher positions in the game space) and reach the Y position of the floor of the room again, and then walking left towards the room until the camera fixes itself. This is quite a slow process: It takes around 4 minutes and 30 seconds, plus the time it takes to walk to the room again (which is itself dependant on how much Soma has moved to the right). If one is careful not to (incorrectly) corrupt the addresses corresponding to the "position for resuming from suspend" and to "disable suspend", it is possible to get back in bounds much faster by suspending and resuming the game.
- Zipping
- State in which Soma gets out of bounds by getting inside a wall's collision. While in this state, Soma constantly moves upwards very fast, and can only move by sliding to the right. Zipping might result in memory corruption.
Nevertheless, aside from the long wait to get back in bounds when suspending is not possible, zipping presents some additional problems. First, as the only form of horizontal movement is sliding, the player cannot precisely control the horizontal travel distance per movement. As a consequence, Soma can only visit certain X square coordinates for a very short period of time. Therefore, certain [X, Y] square coordinates might be inaccessible per zip. For other X square coordinates, Soma can stay indefinitely in them as long as he does not move horizontally. As the game automatically moves Soma in the Y square coordinate (and it underflows from 0 to 255), for those X square coordinates, all Y coordinates can be visited. I call the latter X square coordinates stable coordinates or free coordinates, and the former transient coordinates. Another problem with zipping is that, although all Y coordinates can be visited many times, the order (and speed) in which they are visited is out of the control of the player, which is inconvenient in certain situations.
- Transient coordinates
- When zipping, values for the X square coordinate that can only be traversed while sliding and for a short period of time.
- Stable or free cordinates
- When zipping, values for the X square coordinate that are not transient coordinates. Soma can stay in these for long periods of time.
For the record, when zipping, Soma moves -1 Y square coordinate per 3 frames. The sliding movement lasts for 41 frames (including input), and moves Soma an average of 2.5 X square coordinates; which translates to 1 X square coordinate per 16.4 frames when sliding.
Nevertheless, zipping is not required to perform memory corruption. Just getting out of bounds is. This is significant, since there are a few rooms in the game that do not present walls at the limits of the room area (i.e. collision that extends infinitely out of the room), but rather walls before the limits of the room area, and empty space in between (in which Soma is never supposed to get). By getting into this empty space with any of the special attack glitches, one can get out of bounds without losing control of Soma. This allows going directly into the square coordinates corresponding to the desired corruption, from any desired direction, and avoiding memory addresses with undesired effects (e.g. game crashes or soft locks). I call this out of bounds method controlled oob, as opposed to zipping. Two examples of rooms that allow for controlled oob are shown in the screenshots below.
- Controlled OoB/oob
- Form of out-of-bounds traversal that, unlike zipping, does not cause the player to lose any control of Soma's movement.
Controlled oob comes with its fair share of problems, too. For starters, most of the very few rooms that allow for controlled oob do not allow for getting back in bounds, aside from suspending and resuming the game. To the best of my knowledge, the only exception to this is the room in Demon Guest House 3 rooms to the right of the Warp Room; as Soma can get back in bounds by using the bat transformation and moving on the left side. That is demonstrated in this video by romscout.
Another pretty inconvenient problem with controlled oob is that movement is considerably slower than with zipping. It takes Soma around 171 frames to change to a neighboring X coordinate, or around 53 frames if using the Black Panther ability. This is, respectively, more than 10 and 3 times as slow as sliding when zipping. Concerning the Y coordinates, Soma falls through a specific Y coordinate into the next one in around 49 frames; more than 16 times as slow as zipping. Therefore, moving from one interesting [X, Y] square coordinate to the next takes more time in controlled oob than with zipping (assuming the movement is feasible with zipping).
While suspending and resuming the game seems like the fastest option to get back in bounds both for zipping and controlled oob, it is worth noting that this is not an option when Julius Mode is turned on, since the menu gets disabled. In the context of this run, routing how to get back in bounds requires special attention.
Regardless of whether zipping or in controlled oob, the game sometimes makes Soma not visit certain square coordinate pairs. For example, the game might not change from [X, Y + 1] to [X, Y] when zipping over certain [X, Y] square coordinate. Instead, it will stay at [X, Y + 1] on the area corresponding to [X , Y], and move to [X, Y - 1] when it corresponds. This seems to be related to Soma's global position, as underflowing Y by decreasing its value by 256 would cause Soma to do visit [X, Y] as it regularly behaves. This is unfortunate, since it means that the only way to visit certain square coordinates is by traveling 256 square coordinates more in any direction. Let's call these kind of coordinates ghost coordinates.
- Ghost coordinates
- Square coordinate pairs that, for some reason, are skipped over and cannot be reached directly by Soma while out of bounds. To reach a ghost coordinates, any of its X or Y coordinates should be increased or decreased by 256 (effectively over/underflowing it).
In [1364] DS Castlevania: Dawn of Sorrow by gocha in 08:20.12, gocha provided the following Lua script to view what [memory address, bit set] pair is being written each frame:
-- Address view for memory writing with zipping
-- Open the memory viewer to see what's actually going on.
if not emu then
error("This script runs under DeSmuME.")
end
if not bit then
require("bit")
end
function cvdosPosToMapFlag(x, y)
x, y = x % 256, y % 256
local xl, xh = x % 16, math.floor(x / 16) % 16
local i = (y * 16) + (xh * 46 * 16) + xl
local pos = 0x20F6E34 + math.floor(i / 8)
local mask = math.pow(2, math.floor(i % 8))
return pos, mask
end
gui.register(function()
local x = memory.readbyte(0x0210F018)
local y = memory.readbyte(0x0210F014)
local i = (y * 16) + x
local pos, mask = cvdosPosToMapFlag(x, y)
agg.text(140, 5, string.format("%08X:%02x", pos, mask))
agg.text(140, 24, string.format("[%04X-%04X]", cvdosPosToMapFlag(x - (x % 0x10), 0) % 0x10000, cvdosPosToMapFlag(bit.bor(x, 0x0f), 255) % 0x10000))
agg.text(140, 43, string.format("[%04X-%04X]", cvdosPosToMapFlag(x - (x % 0x10) + 0x10, 0) % 0x10000, cvdosPosToMapFlag(bit.bor(x, 0x0f) + 0x10, 255) % 0x10000))
agg.text(140, 62, string.format("(%03d/%X,%03d)", x, x % 16, y))
end)
For routing purposes, it is interesting to perform the opposite operation: Given a memory address to corrupt and a (set of) bits that produce the desired effect when set, to get the [X, Y] square coordinates that produce such corruption. I developed a Python function which does that. (gocha's function is also translated to Python for completeness):
'''
gocha's cvdosPosToMapFlag Lua function translated to Python.
'''
def posToMapFlag(x: int, y: int) -> tuple[int, int]:
x, y = x % 256, y % 256
xl, xh = x % 16, x // 16 % 16
i = y * 16 + xh * 46 * 16 + xl
pos = 0x20F6E34 + i // 8
mask = 1 << i % 8
return pos, mask
'''
Function that performs the opposite operation to posToMapFlag().
[in] addr: CV DoS memory address to corrupt.
[in] mask: bits that produce the desired corruption.
E.g., 0x7 means that bits 0x1, 0x2 OR 0x4 produce the desired corruption.
[out] list containing all (X, Y) square coordinate pairs that cause the desired corruption.
'''
def mapFlagToPos(addr: int, mask: int) -> list[tuple[int, int]]:
pos_list = []
valid_masks = []
i = 0
while mask > 0:
if mask & 1 == 1:
valid_masks.append(i)
mask >>= 1
i += 1
for maskbit in valid_masks:
i = (addr - 0x20F6E34) * 8 + maskbit
xl = i % 16
for y in range(256):
for xh in range(16):
if y * 16 + xh * 46 * 16 + xl == i:
pos_list.append((xh * 16 + xl, y))
pos_list.sort()
return pos_list
In-depth route dissection
Now that we know how the tools to achieve our goal work, it is time to talk about how we achieve it. The chosen route performs memory corruption twice: The first time, by zipping, to acquire everything required to perform the second memory corruption, and to activate the warp to the Abyss. The second time, by controlled oob, to activate Julius Mode. Let's break the route down section by section:
Progress the game until the first zipping
This section is exactly the same showcased in the fastest published TAS of this game, by mtbRc, up to the memory corruption section of that movie. The goal of that movie up to that specific point is getting to perform memory corruption as fast as possible; and, thus, it also fits perfectly for the first section of this run.
To sum up, in this section, heavy luck manipulation is performed to obtain the Axe weapon (required to get out of bounds) and Axe Armor's soul (to defeat Flying Armor as quickly as possible). Also, the game is saved, which is required to suspend the game later on.
First memory corruption: zipping
The objectives set for this first memory corruption are the following:
- Acquire everything needed for the second corruption
- Acquire everything needed to go to the final boss
The reason why in this corruption everything needed to go to the final boss is also acquired, instead of in the second corruption, is because, as explained earlier, movement while zipping is considerably faster than with controlled oob. Overall, it was concluded that performing that part of the corruption on this first corruption was faster.
The specified objectives are translated into the following list of specific game elements to acquire:
- Bat Form bullet soul (i.e. Alucard's Bat Form ability)
- Black Panther soul
- Hippogryph ability
- Demon Guest House warp
- Cinquedea, Cutall, or Alucard's Sword, to get into controlled oob in Demon Guest House
- Abyss warp
- Optimal "position for resuming from suspend"
Obtaining Bat Form instead of Bat Company is optimal since using Black Panther as guardian soul is desired for considerably faster movement during the rest of the run. Using Bat Company would require also obtaining, setting, and using the Doppelganger ability, which is overall slower.
All Cinquedea, Cutall and Alucard's Sword work effectively the same for the purposes of the run: Using the Magic Seal glitch to get controlled oob. One might think that Alucard's Sword would be slower as it performs two hits during its special attack instead of one. Nevertheless, that is not the case, as performing the Magic Seal glitch with optimal timing cancels the special attack movement of all those weapons at the very same time (no hit is actually performed). At first, I intended to obtain Alucard's Sword for entertainment purposes. However, obtaining Cinquedea during the corruption is overall faster, and hence it ultimately became the chosen weapon.
Obtaining the Malphas ability is not mandatory; nevertheless it makes the rest of the run a little more convenient and faster. As it is in the way of acquiring the other things on the list (i.e., it doesn't waste any time), it is also obtained. Hippogryph is (practically[1]) mandatory, as it is required for moving in controlled oob.
The corruption of the "position for resuming from suspend" is similar to that performed in mtbRc's movie. It allows for resuming the game in the room next to Lost Village's Warp Room, instead of on a room higher on the map.
To route this memory corruption, I developed a Python function that models how Soma moves while zipping, and estimates the distance between two different points (square coordinates). The distance is (ironically) measured in frames; therefore the function estimates how much time it would take to go from one point to the other. The function also takes into account that the sliding movement lasts for 41 frames and Soma cannot move again until the sliding movement is completed. Thus, it does not exactly compute the distance between two points, but rather the time it takes to move from one point in such a way that Soma passes through a second point, and is able to move again. The function also estimates the coordinates at which Soma will end such movement (i.e. the point from which Soma can begin moving again). I say estimates because modelling the exact way Soma moves while zipping is quite more complex, as there are some corner cases (such as ghost coordinates) that are rather difficult to account for. However and in my experience, I would say the function produces a good enough estimate. Here is the function(s):
'''
Returns whether a X square coordinate is free/stable (True) or transient (False).
[in] x: The X square coordinate.
[out] Boolean representing if the provided X square coordinate is free.
'''
def free_x(x: int) -> bool:
rem = x % 10
return rem == 2 or rem == 4 or rem == 7 or rem == 9
'''
Computes the distance travelled when moving from one point in such a way that passes through a second point.
It also computes the final position of the movement that begins at the first point and passes through the second point.
The final position might not be the second point, if the second point is not free/stable.
Thus, the returned distance might be greater than the distance between the two points, if the second point is not free/stable.
This function assumes Soma is zipping in the usual Lost Village zipping spot.
[in] p1: point (X, Y) from which the movement begins. Must be free.
[in] p2: point (X, Y) to pass through.
[out] The distance, measured in estimated frames, travelled on the movement that begins at p1 and passes through p2 optimally.
[out] The end position of the movement, which will not be p2 if p2 is not free.
NOTE: The returned values are estimates, and should always be checked in-game.
Sometimes the game does not work as expected and modeled here.
'''
def distance(p1: tuple[int, int], p2: tuple[int, int]) -> tuple[int, tuple[int, int]]:
x1, y1 = p1
x2, y2 = p2
cost = 0
# It is called "cost" instead of "distance" because it is measured in frames.
# I.e., it is the minimum amount of frames required to move from p1 to p2 and finish the movement.
# Cannot go to the left:
if x2 < x1:
return 0x7fff, (0, 0)
# Check that x1 is free:
if not free_x(x1):
print(f"WARNING: origin point {p1} is not free.")
# Compute the real end position after movement that passes through p2:
if not free_x(x2):
rem = x2 % 10
if rem == 0 or rem == 5:
x2 += 2 # Soma will travel this many X coordinates more
y2 -= (32 + 8) // 3
# In the time it takes to reach the next free X coordinate and be able to move,
# Y will be decreased so many times.
elif rem == 1 or rem == 6:
x2 += 1 # Soma will travel this many X coordinates more
y2 -= (16 + 8) // 3
# In the time it takes to reach the next free X coordinate and be able to move,
# Y will be decreased so many times.
elif rem == 3 or rem == 8:
x2 += 1 # Soma will travel this many X coordinates more
y2 -= (16 + 16) // 3
# In the time it takes to reach the next free X coordinate and be able to move,
# Y will be decreased so many times.
# All the values used in this previous if-block have been deduced empirically.
# Calculate how many frames it takes to move on the X coordinate:
while x1 != x2:
rem = x1 % 10
if rem == 4 or rem == 9:
x1 += 3
else:
x1 += 2
cost += 41
# Adjust the Y coordinate:
# Compute how it has changed during the movement on the X axis.
y1 -= (cost // 3)
# Adjust the Y coordinate so that the destination coordinate is below the current coodinate:
while y2 > y1:
y1 += 256
# Calculate how many frames it takes to move to the desired Y coordinate:
cost += (y1 - y2) * 3
return cost, (x2, y2)
I also attempted to develop a function that would return the fastest order of elements for a given set of desired elements to acquire. (In other words, a function that found the fastest memory corruption route given the [memory address, bit set] pairs to corrupt). However, that is a non-trivial problem, and all my approaches only found local optimal solutions. Those approaches did not take into consideration that the fastest route is not the one in which the next element is the fastest of the remining, but rather the one that gets to the last element the fastest (it is not the same; for more insight read this Wikipedia page). Modelling that other approach[2] is considerably more difficult, and I am not sure if it would be computationally feasible in this specific scenario. All in all, I eventually concluded that developing a program that would return the theoretical global optimal route, if feasible at all, would require too much effort for the objective in mind; especially considering that the theoretical optimal route might not be the practical optimal route, due to the corner cases talked about in previous paragraphs. What I ended up doing was manually checking and considering the routes computed by my (non-optimal) program under different requirements, and chosing the one that, when tested, seemed fastest.
The final route performed for this memory corruption is the following:
- Starting position [7, 228]
- Bat Form [42, 253]
- Hippogryph [50, 226]*
- "Resume from suspend" position corruption [66, 61]
- Demon Guest House warp [81, 212]*
- Malphas [84, 133]
- Cinquedea [84, 241]
- Abyss warp [91, 212]*
- Black Panther [92, 149]
(An asterisk denotes the square coordinates are transient coordinates.)
It is worth noting that the starting position is not the position at which Soma begins zipping; but a position in which Soma is located some frames later, after sliding once at a specific position. This delay and slide are required not to incorrectly corrupt the "Resume from suspend" position.
Unfortunately, the most convenient square coordinates for activating the Abyss warp, [91, 212] and [107, 166], were both ghost coordinates when reached as fast as possible. On the other hand, the square coordinates reached in mtbRc's movie to activate the Abyss warp, [123, 120], couldn't be reached fast enough in this route, due to the increased number of requirements along the way. The only practical solution found is to wait until the Y square coordinate underflows back to the desired value; that is, wait for it to be decreased 256 more times, so that the target coordinates no longer are ghost coordinates. This wait should be done in an X square coordinate that does not additionally corrupt the "Resume from suspend" position, such as X=84. The wait takes around 13 additional seconds. No route was found that avoided this need for an additional underflow, did not additionally corrupt the "Resume from suspend" position, and reached all the other coordinates fast enough.
Also, I deliberately chose not to perform any corruption that involved going to an X square coordinate greater or equal to 96. That's because the range of addresses corrupted by zipping up from that point includes "dangerous" addresses that could harm the integrity of the rest of the run (for example, permanently disabling Julius Mode, making the game unbeatable, or modifying the controls).
Right after acquiring the last desired element (Black Panther soul), equipment changes are made, and the game is suspended and immediately resumed to get back in bounds. Then, Soma warps[3] to the Demon Guest House to start the second memory corruption.
This first memory corruption takes approximately 4578 frames (depending on how it is timed), which is 76.3 seconds / 1 minute, 16.3 seconds.
[1] Hippogryph can be substituted by any bat transformation. Nevertheless, the bat form is considerably slower both in horizontal and vertical movement when compared to using Black Panther and Hippogryph; and it also consumes considerably more MP. Thus, controlled oob using only bat form is unfeasible without unlimtied MP (Chaos Ring), and impractical in any scenario.
[2] The most general approach I thought of would consist of modelling the zip area as a directed graph in which the nodes would be each square coordinate pair, and the cost of traversing from one node to the other would be the amount of frames it takes to go from the first to the second coordinates. Then, find the lowest cost path that traverses a subset of nodes from a larger set of nodes. (As some different square coordinates correspond to the same or equivalent corruption effects on the game, the amount of nodes that produce the desired corruptions is larger than the minimum amount of nodes needed to be traversed for the desired effects to take place.) This approach seems very complex, both in development effort and computationally. Another, probably simpler, approach would be to design an optimization or dynamic programming model, in which the last corruption is decided first, and then the order of the rest of the corruptions is attempted to fit into the route. Nevertheless, I am not well-versed in designing that kind of models. It should go without saying, but brute-forcing the route is not feasible in a reasonable amount of time, due to the sheer number of possible combinations.
[3] As it was the case in other TAS movies for this game that aimed for fastest completion, making room in the memory corruption route to corrupt the address that skips the Warp Room message would have taken more time than it would have saved.
Second memory corruption: controlled oob
In this second memory corruption, performed in the Demon Guest House with controlled oob, the entire route is constructed around turning Julius Mode on as fast as possible. To that end, the closest square coordinates that turn on Julius Mode are determined. The distance between two points in controlled oob can be computed using the following Python function:
'''
Computes the distance between two points (square coordinate pairs) in controlled oob.
As Soma's speed in X and Y are independent of each other, the total distance
is equal to the maximum of the independent X and Y distances.
This function also measures distances in frames.
[in] p1: One of the points.
[in] p2: The other points.
[out] The distance between the points, in frames.
'''
def controlled_oob_distance(p1: tuple[int, int], p2: tuple[int, int]) -> int:
x1, y1 = p1
x2, y2 = p2
# Account for the possibility of over/underflowing the coordinates:
dx = min(abs(x2 - x1), abs(x2 - x1 - 256))
dy = min(abs(y2 - y1), abs(x2 - x1 - 256))
# Traversing one X square coordinate takes 53 frames with Black Panther.
# Falling through one Y square coordinate takes 49 frames.
return max(dx * 53, dy * 49)
The controlled oob is started at square coordinates [15, 12], which makes the closest coordinates to turn on Julius Mode [184, 23]. Unfortunately, those coordinates are ghost coordinates, and therefore the second closest coordinates are used, [168, 69].
Moving towards those coordinates means Soma must travel left (eventually underflowing its X square coordinate from 0 to 255) instead of right, as it did while zipping. This has a curious consequence: Soma will start corrupting the latest corruptible addresses, instead of the first ones. To the best of my knowledge, corruption on these addresses has never been shown before, as zipping to those address ranges usually corrupts addresses that cause the game to crash (which are avoidable in controlled oob). While testing this route, it was found that Soma's stats' addresses were found along the way to the Julius Mode coordinate. That broadened the interest on this controlled oob route, since the run could now showcase more interesting memory corruption effects (as will be described in the following paragraphs).
Three significant things are corrupted in this controlled oob section, in the following order:
- Soma's maximum MP and current MP
- Julius Mode
- Soma's attack (ATK) stat
Without any MP-regeneration boosting equipment (i.e. Treant Soul, Rune Ring or Chaos Ring), Black Panther consumes more MP than Soma regenerates, resulting in a net loss of MP while moving. Since it is necessary to use a special attack to enter controlled oob, the MP Soma has after entering controlled oob would normally not be enough to get to the Julius Mode coordinates and go back in bounds without stopping using Black Panther. That could be solved by routing in the acquisition of any of the aforementioned MP-regeneration boosting equipment into the first memory corruption[4]. Nevertheless, that is not necessary, since Soma can just corrupt the memory addresses corresponding to its maximum MP and current MP, to get enough MP for the whole trip. Bear in mind that the game checks each frame if the current MP is greater than the maximum MP, and decreases it correspondingly if it is. Thus, if one desires to increase the amount of MP Soma has, it is first necessary to corrupt the maximum MP before the current MP. Luckily for the run, we can modify Soma's MP to a large enough quantity to satisfy everything we require to do while at the same time getting closer to the Julius Mode coordinates, without wasting any time.
The Julius Mode corruption requires a bit of care: Writing any value other than 1 to the Julius Mode address will permanently disable Julius Mode. Thus, one must be careful while moving towards the desired coordinates, as other nearby coordinates permanently disable Julius Mode. As soon as Julius Mode is activated, Soma turns back and starts the trip back to bounds.
While traveling back to bounds, Soma corrupts his attack stat so as to OHKO (one-hit KO) both Soma (1100 HP) and Dracula (5000 HP). All of Soma's stats are signed 2-byte values. An attack value of 0x7A00 is enough to OHKO Dracula with any weapon. Nevertheless, the square coordinates corresponding to the corruption of the bit 0x4000 of the attack stat are ghost coordinates; therefore, the maximum attack achievable is 0x6FFF. Luckily, that value is enough to OHKO Dracula with the Cinquedea.
Since all of Soma's stats are signed values, corrupting the bit 0x8000 of any of them would result in a negative stat. For the attack stat, that would cause Soma to do insignificant amounts of damage with any weapon[5].
It is worth noting that, in the movie, I switch the upper screen from the map to Soma's stats for a few seconds, so that the viewer can see the memory corruption on the attack stat take effect in real time.
After corrupting Soma's attack to the desired value, Soma moves directly towards the location to get back in bounds. Luckily, corrupting Soma's attack stat does not waste any time either, and Soma can get back in bounds without stopping its horizontal movement at any moment.
This second memory corruption takes approximately 10806 frames (depending on how it is timed), which is 180.1 seconds / 3 minutes, 0.1 seconds. The main reason behind why this memory corruption is considerably slower than the first one despite performing less corruptions of interest is the slower movement in controlled oob when compared to zipping, as explained in a previous section.
[4] My tests conclude that the fastest MP-regeneration boosting equipment to obtain during the zipping memory corruption would be Chaos Ring, which would have to be obtained after Black Panther.
[5] As a fun fact, during this run, Soma's defense stat gets (non-intentionally) set to a negative value, and any enemy attack would kill Soma in one hit. As I didn't intend to get hit during the rest of the run, that is unnoticeable.
The fight against Soma/Dracula
Getting into the boss fight itself is somewhat trivial and lacks special interest, since the Abyss' Warp Room is pretty close to the final boss' room.
"Soma", "Dark Lord Soma", "Dracula", or "Somacula", is a two-phase boss intended as final boss for the Julius Mode of the game. As such, its attacks and patterns were designed to pose an interesting fight when playing as Julius, Yoko and Alucard. When fighting as Soma, some of the boss' magic is lost due to the wider range of attacks available. Soma is able to make more powerful and safer attacks than the other playable characters, thus making the fight considerably easier. Not that it matters for this run anyway; since, after corrupting Soma's attack to ridiculous levels of power, Soma just 2-hit KOs the boss, with one hit per phase. To put this in context, the first phase (Soma / Dark Lord Soma) has 1100 HP, while the second phase (Dracula) has 5000 HP.
Unlike with the playable characters from Julius Mode, the player does not lose control of Soma when entering the boss' room until Soma gets close enough to the boss. This allows Soma to get closer to the boss than intended before its control is gained back, by jump-kicking from enough height and horizontal distance while using the Black Panther ability. By getting closer to the boss in this manner, one can hit him[6] on the very first frame possible, even before it teleports, thus effectively 0-cycling the first phase.
For the second phase, a single hit from the Cinquedea would also OHKO Dracula. To spice things up, and since there is a wait until Dracula can be hit in which the player does not lose control of Soma, I make Soma OHKO Dracula with the weakest ko-ing jump-kick possible.
After Dracula is defeated, the Good Ending cutscene plays out, and the game ends. Ironically, this ending unlocks Julius Mode.
NOTE: By having the Soma Status + Enemy Entry upper screen, an additional frame of lag is generated when transitioning from the boss fight to the ending cutscene. In other words, this movie could be one frame faster, by not switching the upper screen earlier on Demon Guest House. Nevertheless, I consider that time loss admissible, since it enables showcasing Soma's and Dracula's enemy entries; which is in-game content not usually seen on a regular playthrough (not even on Julius Mode). Note that, after warping to the Abyss, the upper screen can no longer be switched, so the lag frame cannot be avoided by switching the screen after defeating the boss.
[6] When hitting an enemy for more than 9999 damage, the damage number will not be displayed. Hence why no damage number appears when defeating Soma's first phase.
Memory addresses of interest
Here are the memory addresses I watched while making this movie, which include the corrupted memory locations:
Address | Size | Description |
---|---|---|
0x020F7257 | 1 byte | Julius Mode if == 1; Julius Mode permanently disabled if > 1 |
0x020F7018 | 4 bytes | X position for resuming from suspend |
0x020F701C | 4 bytes | Y position for resuming from suspend |
0x020F70E7 | 1 byte | Bat Form bullet soul (4 least significant bits) |
0x020F70EB | 1 byte | Bat Company soul (4 least significant bits) |
0x020F70EB | 1 byte | Black Panther soul (4 most significant bits) |
0x020F7101 | 1 byte | Treant soul (4 most significant bits) |
0x020F710A | 1 byte | Malphas ability (4 most significant bits) |
0x020F710B | 1 byte | Doppelganger ability (4 least significant bits) |
0x020F710C | 1 byte | Hippogryph ability (4 least significant bits) |
0x020F71A8 | 2 bytes | Warp Rooms activated; 0x2 == Demon Guest House; 0x800 == Abyss |
0x020F71E2 | 1 byte | Cutall (4 least significant bits) |
0x020F71E2 | 1 byte | Cinquedea (4 most significant bits) |
0x020F71EE | 1 byte | Alucard's Sword (4 least significant bits) |
0x020F7224 | 1 byte | Rune Ring (4 most significant bits) |
0x020F7226 | 1 byte | Chaos Ring (4 least significant bits) |
0x020F7228 | 2 bytes | Control config (2048 == default) |
0x020F7256 | 1 byte | Disables opening the menu |
0x020F7259 | 1 byte | Hard mode if different than 0 |
0x020F7370 | 2 bytes | ATK stat |
0x020F7410 | 2 bytes | Current HP |
0x020F7412 | 2 bytes | Max HP |
0x020F7414 | 2 bytes | Current MP |
0x020F7416 | 2 bytes | Max MP |
0x020CAA40 | 4 bytes | X position |
0x020CAA44 | 4 bytes | Y position |
0x020CA95C | 4 bytes | Next X position |
0x020CA960 | 4 bytes | Next Y position |
0x020CA968 | 4 bytes | X velocity |
0x020CA96C | 4 bytes | Y velocity |
0x020CA9F3 | 1 byte | Soma invulnerability |
0x020F2A88 | 1 byte | Magic Seal step |
0x020F2A8C | 1 byte | Magic Seal substep |
0x020D36A8 | 2 bytes | Flying Armor HP |
0x020D2448 | 2 bytes | Soma (boss) HP |
0x020D26E8 | 2 bytes | Dracula HP |
All bullet, guardian and enchantment souls can also be activated by corrupting the addresses 0x40 bytes greater than the ones shown in the table. That is, there are two memory addresses per soul that manage the amount of souls of that kind Soma possesses. Corrupting any one of those two addresses gives Soma the corresponding soul. For example, Black Panther can be obtained from corrupting memory address 0x020F70EB or 0x020F70EB + 0x40 = 0x020F712B. These "duplicated" addresses are not shown in the table for brevity reasons.
About the emulator used
At first I began development of this movie using BizHawk (with MelonDS core), since it is the recommended emulator. Nevertheless, emulator differences between BizHawk and DeSmuME result in BizHawk presenting more lag, some minor message-related tricks not working, and overall different, unrelated RNG when first taking control of Soma. As stated previously, up to the first memory manipulation/zip, the route and objectives of this movie are the same as the fastest published TAS of this game, by mtbRc. I spent multiple days trying to replicate the efficiency of mtbRc's inputs up to that point on a BizHawk movie. But, no matter what, all efforts resulted in slower runs even when not counting lag frames (mainly because of behaviour derived from the different RNG). Not pleased with the slower run, knowing there would never be any significan differences between this run and mtbRc's up to that point, and doubting I would be able to make any improvements to mtbRc's inputs, I decided to restart the movie and use mtbRc's input file (thus why he is credited as co-author). Consequently, I had to also use the same emulator.
About the ROM used and ROM filename
The ROM filename specified in the submission, Castlevania - Dawn of Sorrow (USA).nds, has deliberatedly been kept the same as the one used in mtbRc's movie for consistency reasons and to signify that a ROM compatible with the baseline movie has been used (since it is needed for that movie to sync in the first place, and thus this movie too). However, as far as I know, the ROM used is a perfect dump (
[!]
) of the game. If that is the case, to whoever it concerns: Feel free to change the ROM filename to clarify that it is indeed a perfect dump. The SHA-1 checksum of the ROM used is 47530ff87e608f88105a314fdf36dc385f8dec94
.
Additional Lua scripts
Here are some additional Lua scripts I developed for the run, that ultimately were unused for the final movie.
gocha's cvdosPosToMapFlag script adapted to run on BizHawk:
-- Address view for memory writing with zipping
-- Open the memory viewer to see what's actually going on.
if not emu then
error("This script runs under BizHawk.")
end
function cvdosPosToMapFlag(x, y)
x, y = x % 256, y % 256
local xl, xh = x % 16, math.floor(x / 16) % 16
local i = (y * 16) + (xh * 46 * 16) + xl
local pos = 0x20F6E34 + math.floor(i / 8)
local mask = 1 << math.floor(i % 8)
return pos, mask
end
while true do
local x = memory.read_u8(0x0210F018)
local y = memory.read_u8(0x0210F014)
local i = (y * 16) + x
local pos, mask = cvdosPosToMapFlag(x, y)
gui.drawText(140, 5, string.format("%08X:%02x", pos, mask))
gui.drawText(140, 24, string.format("[%04X-%04X]", cvdosPosToMapFlag(x - (x % 0x10), 0) % 0x10000, cvdosPosToMapFlag(x | 0x0f, 255) % 0x10000))
gui.drawText(140, 43, string.format("[%04X-%04X]", cvdosPosToMapFlag(x - (x % 0x10) + 0x10, 0) % 0x10000, cvdosPosToMapFlag(x | 0x0f + 0x10, 255) % 0x10000))
gui.drawText(140, 62, string.format("(%03d/%X,%03d)", x, x % 16, y))
emu.frameadvance()
end
gocha's RNG simulator Lua script adapted to run on BizHawk:
-- Castlevania: Dawn of Sorrow - RNG simulator
-- This script runs on BizHawk
if not bit then
require("bit")
end
-- pure 32-bit multiplier
function mul32(a, b)
-- separate the value into two 8-bit values to prevent type casting
local x, y, z = {}, {}, {}
x[1] = a & 0xff
x[2] = (a >> 8) & 0xff
x[3] = (a >> 16) & 0xff
x[4] = (a >> 24) & 0xff
y[1] = b & 0xff
y[2] = (b >> 8) & 0xff
y[3] = (b >> 16) & 0xff
y[4] = (b >> 24) & 0xff
-- calculate for each bytes
local v, c
v = x[1] * y[1]
z[1], c = (v & 0xff), (v >> 8)
v = c + x[2] * y[1] + x[1] * y[2]
z[2], c = (v & 0xff), (v >> 8)
v = c + x[3] * y[1] + x[2] * y[2] + x[1] * y[3]
z[3], c = (v & 0xff), (v >> 8)
v = c + x[4] * y[1] + x[3] * y[2] + x[2] * y[3] + x[1] * y[4]
z[4], c = (v & 0xff), (v >> 8)
v = c + x[4] * y[2] + x[3] * y[3] + x[2] * y[4]
z[5], c = (v & 0xff), (v >> 8)
v = c + x[4] * y[3] + x[3] * y[4]
z[6], c = (v & 0xff), (v >> 8)
v = c + x[4] * y[4]
z[7], z[8] = (v & 0xff), (v >> 8)
-- compose them and return it
return (z[1] | (z[2] << 8) | (z[3] << 16) | (z[4] << 24)),
(z[5] | (z[6] << 8) | (z[7] << 16) | (z[8] << 24))
end
--[ DoS RNG simulator ] --------------------------------------------------------
local DoS_RN = 0
function DoS_Random()
DoS_RN = (mul32(bit.arshift(DoS_RN, 8), 0x3243f6ad) + 0x1b0cb175) & 0xffffffff
return DoS_RN
end
function DoS_RandomSeed(seed)
DoS_RN = seed
end
function DoS_RandomLast()
return DoS_RN
end
--------------------------------------------------------------------------------
if not emu then
error("This script runs under BizHawk.")
end
local RNG_NumAdvanced = -1
local RAM = { RNG = 0x020c07e4 }
local RNG_Previous = memory.read_u32_le(RAM.RNG)
while true do
local searchMax = 300
RNG_NumAdvanced = -1
DoS_RandomSeed(RNG_Previous)
for i = 0, searchMax do
if DoS_RandomLast() == memory.read_u32_le(RAM.RNG) then
RNG_NumAdvanced = i
break
end
DoS_Random()
end
RNG_Previous = memory.read_u32_le(RAM.RNG)
DoS_RandomSeed(memory.read_u32_le(RAM.RNG))
rng = DoS_Random()
prev_rng = RNG_Previous
gui.drawText(116, 5, string.format("NEXT: %08X", rng))
gui.drawText(116, 21, "ADVANCED: " .. ((RNG_NumAdvanced == -1) and "???" or tostring(RNG_NumAdvanced)))
emu.frameadvance()
end
Submitter's additional comments and thoughts
Here I would like to write some stuff not exactly related to the description of the movie itself, but that I consider relevant enough to mention.
Entertainment value
Let's begin by talking about the elephant in the room. I am fully aware that this is a 07:15.58 long movie in which 01:16.3 + 03:00.1 = 04:16.4 (58.86%) of the movie is spent performing memory corruption. And, unfortunately, Dawn of Sorrow's memory corruption is not quite exactly entertaining. It is not flashy like it might be in some other games. It is not as boring as watching paint dry, but it might be as close as it gets for a Castlevania game, considering what has been previously done: It involves long sections of apparent waiting in which the playable character is not even on screen. I tried reducing the length of the memory corruption sections as much as I could (thus why the double memory corruption route), and also to make the viewer understand what was happening at some specific moments (thus why I switched to the Soma status screen when corrupting his attack stat), but one can only go so far with a such a mechanic. The fact that the final battle, which is the main highlight of the movie, literally ends in just two hits may make matters worse in that regard.
For that reason, even though I consider the concept explored in this movie to be quite interesting on paper, I would understand any judging decision made about the movie derived from its entertainment quality.
Nevertheless, one thing that has to be considered is that, with everything that is known so far about this game (and that has been known for quite some years now), any run that aims for the same objective as this run will most likely require using this kind of memory corruption. It seems to be inevitable.
About the inception and submission of this movie
I theorized the first version of this movie some years ago. I even uploaded a LOTAD (low-optimized Tool Assisted Demonstration) about it to YouTube. Back then, I discarded the idea of submitting a movie about it to tasvideos.org, mainly because of the memory corruption entertainment concerns discussed on the previous section, and because I did not consider a movie with such an objective would be published.
However, earlier this year, the movie for Aria of Sorrow "Julius mode, beat Chaos" was published, which aims for an objective similar to this movie's (beating a final boss with a character that is not supposed to be able to fight it). Even though that movie is considerably more entertaining than this one (that's the way I see it, at least), knowing that a movie similar in objective had been published encouraged me to rework the concept and develop a new movie to be submitted. And here we are.
In comparison with my previously-developed LOTAD, this movie is an improvement of around 2 minutes and 47 seconds. The main improvement comes from the inclusion of the controlled oob second memory corruption, which both reduces the amount of time spent on memory corruption and significantly shortens the fight against Soma/Dracula. The LOTAD spans approximately 6 minutes and 22 seconds from beginning to end of its memory corruption section; this movie spans 4 minutes and 30 seconds from the beginning of its first memory corruption section to the end of its second memory corruption section. This clearly shows that the route that includes two corruption sections, one with zipping and one with controlled oob, is faster.
Considerations about the movie's objectives
I did not investigate whether the Soma/Dracula fight can be skipped, as it is the case for the Menace fight. I did not intend to skip it, nor consider it at any moment, since a movie with the objective of "beating Soma" that entirely skips the Soma fight is rather pointless in my opinion.
After finding out that Soma's attack stat could be corrupted, I considered not corrupting it so as to feature a longer, more entertaining fight against Soma/Dracula. I eventually concluded that any restrictions I set on the fight would have been completely arbitrary, since I would have still used resources obtained from memory corruption (weapons and souls) to fight Soma anyway. The only difference from memory corrupting the strongest build into the inventory (highest DPS weapon + highest strength increasing souls and equipment) and memory corrupting Soma's attack stat to be higher is the extent to which you can make Soma stronger, which is way higher for the latter. I did not find any reasonable justification to limit the amount of increase of power/strength to any specific value. I ultimately decided that the most reasonable course of action was to go for speed, and to allow everything corruption-wise, as long as it did not interfere with the goal of the movie (as explained in the previous paragraph).
Concerning the branch name for movies with this objective, I chose to use the one that most closely resembles Aria of Sorrow's "Julius mode, beat Chaos" branch name. However, I would consider any of the following valid:
- Soma mode, beat Soma/Dracula
- Soma, beat Soma/Dracula
- Soma vs Soma/Dracula
Also (and I'll write this here because I don't know where else to put it), using the ideas showcased in this run, a "Julius mode, beat Menace" run could be developed. However, I don't think that would be as interesting; and it would require a much lengthier memory corruption section, since no character in Julius Mode has access to any ability similar to Black Panther.
Co-authorship
The conceptual development of this movie was an independent effort made by me (Bolu); in the meaning that any decision made related to this movie from its conception to its submission was thought/decided exclusively by me. The movie file itself is a modification of mtbRc's movie and reuses many of its inputs, thus making mtbRc a co-author, as stated in the Movie Rules (which I consider very reasonable and appropriate).
However, as far as I know, mtbRc has no knowledge of the existence or development of this movie so far (date of submission). Since the Movie Rules also state that
above all else, respect the wishes of authors, I wrote mtbRc via the site's PMs some days before submitting the movie to tell him about the movie, and to ask him about his opinion about it and its co-authorship attribution. He has not replied yet, and I cannot possibly know when he will, since it has been a long time since his last login (1/22/2022 at the time of writing).
I have chosen to keep mtbRc as co-author "by default", until he expresses his opinion on the matter. I consider that to be the most appropriate course of action for the time being.
Everything expressed in this submission text reflects my (Bolu's) and only my thoughts and opinions; and every time singular first-person pronouns (I/me/my/mine/myself) are used, they reference me (Bolu), and nobody else.
Acknowledgements and special thanks
- mtbRc: For his incredibly polished Dawn of Sorrow TAS, which was the baseline for this movie, and this movie reuses inputs from.
- gocha: For his initial insight on how memory corruption works, and the memory addresses and lua scripts he provided with his submission.
- hellagels: For developing his Julius mode, beat Chaos Aria of Sorrow movie, which inspired and encouraged me to finally develop and submit this movie.
If you've read this far, I would also like to thank you very much for your patience. This is some massive wall of text. I hope nobody finds its extension inappropriate; I just wanted to be thorough hahaha.
Suggested screenshot
Here's one final screenshot!
I suggest that screenshot to be used as thumbnail for the movie, since it shows both Somas staring at each other before the big conforntation. It is frame 23078 of the movie. Preceding and following frames might also be interesting candidates, as they are slight variations of that screenshot.
arkiandruski: Claiming for judging.
arkiandruski: First, I really like the idea behind the goal of this run. It's great to have fun with little goofy goals, and Soma fighting himself is a great example of one of those goals. Accepting to Playground. This kind of run fits great there.