Internal scripting
The internal scripting exploit is an exploit within VVVVVV's scripting engine that allows for internal scripting commands to be used, thus giving the user more control than with regular simplified scripting. These commands are the actual direct commands in the game engine itself, and all simplified scripts are translated to internal commands under the hood. Internal scripting is a superset of simplified scripting.
There are two ways to use internal commands: the say(-1) exploit and the load script exploit. The former produces cutscene bars while the latter does not, but the latter requires two scripts while the former does not.
Nowadays, most of the work normally needed to use these exploits can be automated by the internal scripting modes in Ved, but this article will detail exactly how and why they work.
say(-1) method
This is the first exploit discovered, found by FIQ in 2012.
Usage
Usage of the exploit looks like the following. For clarity, gray lines are just the "glue", the rest are the actual internal commands you want to execute.
say(-1) text(1,0,0,4) say(10) text(gray,-500,-500,3) This is the first block of internal scripting! I'm going to play a sound effect. speak endtext delay(30) playef(9) delay(30) text(1,0,0,4) say(9) squeak(player) text(cyan,0,0,1) Wow! That was cool! position(player,above) speak endtext endcutscene untilbars loadscript(stop)
When run, this will play a terminal squeak (despite there being no squeak(terminal)
in the script), followed by a gray text box, then a sound effect, then Viridian's squeak with a cyan text box created above the player.
The basic format of a script using this exploit can be described as follows:
say(-1) text(1,0,0,4) say(N) <N-1 lines of internal commands> text(1,0,0,4) say(N) <N-1 lines of internal commands> text(1,0,0,4)
...
say(N) <N-1 lines of internal commands> text(1,0,0,4) say(N) <N-1 lines of internal commands> loadscript(stop)
The script starts with say(-1)
and text(1,0,0,4)
, followed by a series of say
blocks. The last line of each say
block is always text(1,0,0,4)
, except for the very last say
block, which ends with loadscript(stop)
.
This method will by default produce an extra squeak (depending on if you used the color argument of say(-1)
or not, or if you used reply(-1)
instead), and depending on the circumstances, it may be unwanted. This squeak can be removed by simply putting squeak(off)
at the top of the script. However, this removes an extra line of parser output per block (see Explanation below), so all text(1,0,0,4)
glue lines need to be replaced with text(1,0,0,3)
.
Any negative number of any magnitude can replace -1 in say(-1)
, but it has to be negative. All say()
s can be replaced with reply()
s. The optional color argument to say()
doesn't matter. The first three arguments of each text()
command (if it is a gray "glue" line) don't matter, only that the last argument is 4. However, by convention, the first argument is 1 and the second and third arguments are 0.
In 2.0, the maximum amount of lines you can have in a say()
command is 5. Starting from 2.1, this limit was raised to 50. When using this exploit (or the other one), you cannot have a text()
command and its associated speak
or speak_active
span over multiple blocks; both the text box and its speak
/speak_active
must be in the same block of internal scripting.
Explanation
There are two script parsers in VVVVVV - the custom script parser (scriptclass::load()
in 2.2 and below, scriptclass::loadcustom()
starting from 2.3) and the script runner (scriptclass::run()
). The former reads simplified scripts and translates them into internal commands, while the latter reads internal commands and executes them accordingly.
When the above script is fed through the custom parser, this is the result. For clarity, gray lines are again "glue" lines.
cutscene() untilbars() squeak(terminal) text(gray,0,114,-1) text(1,0,0,4) customposition(center) speak_active squeak(terminal) text(gray,0,114,10) text(gray,-500,-500,3) This is the first block of internal scripting! I'm going to play a sound effect. speak endtext delay(30) playef(9) delay(30) text(1,0,0,4) customposition(center) speak_active squeak(terminal) text(gray,0,114,9) squeak(player) text(cyan,0,0,1) Wow! That was cool! position(player,above) speak endtext endcutscene untilbars loadscript(stop) customposition(center) speak_active endcutscene() untilbars()
Let's compare the original script and parsed script, along with the reasons for why the parser wrote each line:
Original line | Parser output | Reason |
---|---|---|
cutscene() untilbars() |
An all-lowercase say() or reply() command was found in the script, or a say or reply command with capital letters was found but it had an argument separator.
| |
say(-1) |
squeak(terminal) text(gray,0,114,-1) |
This is the start of a text box, or at least the parser thinks it is. There were no squeak(off) commands beforehand, so a squeak gets added. Interestingly enough, the x and y being 0 and 114 is always hardcoded if you're using the gray text box.
|
text(1,0,0,4) |
(same) | The parser thinks this is the content of the text box. It only takes in 1 line, as -1 is not in the range 0..50 inclusive. |
customposition(center) speak_active |
This is the end of the text box. | |
say(10) |
squeak(terminal) text(gray,0,114,10) |
This is the start of another text box. No squeak(off) was found before this, so a squeak gets added again.
|
text(gray,-500,-500,3) This is the first block of internal scripting! I'm going to play a sound effect. speak endtext delay(30) playef(9) delay(30) text(1,0,0,4) |
(same) | These are the contents of the 10-line text box. |
customposition(center) speak_active |
This is the end of the 10-line text box. | |
say(9) |
squeak(terminal) text(gray,0,114,9) |
This is the start of another text box. No squeak(off) was found before this, so a squeak gets added again.
|
squeak(player) text(cyan,0,0,1) Wow! That was cool! position(player,above) speak endtext endcutscene untilbars loadscript(stop) |
(same) | This is the content of the 9-line text box. |
customposition(center) speak_active |
This is the end of the text box. | |
endcutscene() untilbars() |
We've reached the end of the script, and have inserted cutscene bars at the beginning of it. |
The main thing to take note here is the say(-1)
. When the custom parser encounters a say()
command, it will only read the number of lines given if it is between 0 and 50 (0 and 5 in 2.0). If the number given isn't in that range, then it will default to taking in 1 line.
However, that's not the end of it. The custom parser then translates the say(-1)
to text(gray,0,114,-1)
. When the script finally gets run, the text()
command has no such range restriction, so it will happily follow the order to take in -1 lines. This ends up being equivalent to taking in 0 lines, because its for-loop conditional ends up evaluating to 0 < -1
and never executes.
In this way, the internal command text(1,0,0,4)
is smuggled in and executed. This text()
ends up taking in the next four lines (customposition(center)
, speak_active
, squeak(terminal)
, and text(gray,0,114,10)
in as a text box, meaning they're essentially overwritten as they are no longer able to be executed. This then paves the way for the start of the internal script to be executed accordingly.
The same trick with text(1,0,0,4)
is used at the end of each block to override the intervening four lines, which in the case of the lines after the first block are customposition(center)
, speak_active
, squeak(terminal)
, and text(gray,0,114,9)
.
The last block uses loadscript(stop)
to avoid the extra speak_active
at the end of the script, as otherwise an extra unwanted text box would be created onscreen. loadscript(stop)
stops the script by loading a non-existent script; since the main game stop
script doesn't exist, the script runner sees there are no commands loaded, and stops running the script. However, it is also equally valid to use text(1,0,0,2)
instead, as that will skip over the customposition(center)
and speak_active
lines, and as a bonus, automatically do the endcutscene()
and untilbars()
without you having to supply them yourself. But it is, however, convention to use loadscript(stop)
.
In effect, the actual script executed in the end, with all the overwritten lines removed, is the following. The first three lines were not originally in the lines given in the custom script.
cutscene() untilbars() squeak(terminal) text(gray,-500,-500,3) This is the first block of internal scripting! I'm going to play a sound effect. speak endtext delay(30) playef(9) delay(30) squeak(player) text(cyan,0,0,1) Wow! That was cool! position(player,above) speak endtext endcutscene untilbars
Load script method
This is the second exploit discovered. It was accidentally found by FIQ in 2012, when he was messing around with scripting.
Usage
Explanation
Mixing internal and simplified
While it may seem like a script can only be either "internal" or "simplified", it is possible (but not recommended) to switch between simplified and internal scripting in a single script.
This is not recommended for a couple of reasons:
- All internal scripting is a superset of simplified scripting, therefore all simplified commands can trivially be translated to internal commands. Once you are in internal scripting, there is no gain from switching back to simplified.
- In terms of number of lines used, the custom script parser is a bit inefficient in some places with its translations of simplified to internal. If you wish to maintain compatibility with the pre-2.3 500 parser output script lines limit, then you would be better off sticking to internal scripting instead.
While you can transition from load script or say(-1) internal scripting to simplified scripting, you can only use the say(-1) method to jump back in to internal scripting.