Overview
vexide internal documentation includes information for first time contributors, technical details about features we want to implement, technical explanations of already implemented features, current priorities, and more.
These docs are mainly for the vexide library, but the old pros-rs docs have been archived.
If you were looking for vexide user documentation, you should go to the main docs page.
Project info
vexide, our namesake library, is the successor to pros-rs. Instead of linking with and creating bindings for a library like PROS, vexide implements everything on its own. Every line of code in a vexide program is open source and written in Rust.
The major technical difference between vexide and pros-rs is that vexide directly calls VEXos jumptable functions without going through libv5rt
's wrapper functions.
This allows us to implement everything ourself instead of linking to libpros
.
vexide is also different from other Brain libraries in that it doesn't use an RTOS. vexide uses Rust async/await for multitasking.
vexide programs are incredibly small for not being hot/cold linked and have higher stack and heap sizes than any other library.
Contributing
This chapter aims to give tips to first time vexide contributors. If you want to contribute to vexide, make sure you join the discord server! For info on contributing guidelines, please read CONTRIBUTING.md in the vexide repository.
Note
All development on pros-rs has stopped. We are now solely developing and maintaining vexide.
Project structure
Because vexide is split into multiple crates and multiple repositories, finding the part of vexide you want to work on can be a little overwhelming. To hopefully make it easier to find what you are looking for, here is a simple graph of the project structure:
- vexide
- packages
- vexide
- vexide-async
- vexide-core
- vexide-devices
- vexide-macro
- vexide-math
- vexide-panic
- vexide-startup
- packages
- pros-rs
- cargo-pros (vexide build tooling with the
cargo pros
command) - pros-simulator
- packages
- pros-simulator (WASM simulator backend)
- pros-simulator-interface
- pros-simulator-server (Standalone wrapper over the wasm backend)
- packages
- vex-v5-sim (WIP QEMU simulator backend)
- internal-documentation (This website)
- website (Our main page)
Technical Implementations
vexide is an incredibly low level library and has many implementations that are very technical. This chapter aims to make our code easier to understand.
Startup
vexide produces working binaries without linking to any external libraries. This means that we have to write all of the code that gets run during startup. Luckily program startup is fairly simple, its just a pain to set up.
The boot process for programs works as such:
- VEXos loads the program into memory at 0x3800000
- VEXos reads the cold header and changes program behavior accordingly.
- VEXos starts program execution at 0x3800020 (the address just after the cold header)
- vexide zeroes the entire BSS section and sets the stack pointer to the bottom of the stack
- vexide initializes the heap allocator
- vexide starts the async executor and the users main function inside it
Explanations
The structure of a user program looks like this:
In order to explain the startup code, I will be going over every one of these six steps and how they are implemented.
Steps 1-3
Before we can even get to step one we need to get a program building correctly.
The vexide-startup crate contains a v5.ld file that tells clang how to link vexide programs correctly.
It tells the linker where cold memory is located and the locations of all of the sections necessary for running programs.
The only parts of the linker script that are important to understand for the first three steps are in the .text
section.
Specifically the .boot
and .cold_magic
sections are important.
.boot
is located at 0x3800020 and is the entrypoint to the program.
.cold_magic
is located at the very start of cold memory (0x3800000) and it stores information that VEXos uses to change the behavior of the program.
In vexide, we call this "cold_magic" the cold header because it is a more fitting name.
The name cold_magic
originates from PROS, but it exists in vexcode under the name VCodeSig
.
The first four bytes of the cold header must be the string XVX5
encoded in ascii. The rest of the 32 bytes are used for various program options.
For more detailed information, look at the docs for the ColdHeader
struct in vexide.
After this build process, steps 1-3 work as intended.
Step 4
This step is the first, and only, time that assembly is required. Yay!
vexide sets the stack pointer with this inline assembly:
ldr sp, =__stack_start
This instruction loads the value of =__stack_start
into sp
(the stack pointer register).
The symbol __stack_start
is defined by the v5.ld linker script and the name is pretty self-explanatory.
The next task is to zero the BSS section. vexide uses a loop that increments a pointer to achieve this effect.
#![allow(unused)] fn main() { unsafe { let mut bss_start = addr_of_mut!(__bss_start); while bss_start < addr_of_mut!(__bss_end) { core::ptr::write_volatile(bss_start, 0); bss_start = bss_start.offset(1); } } }
You may think this seems horribly unsafe, and you would be right under normal circumstances; however, at this point nothing has set any values in the BSS section and it must be zeroed to avoid corruption.
Step 5-6
Steps five and six are pretty simple because at this point we can run any code that doesn't use the heap. In fact, we use these newfound capabilities to do the incredibly important task of printing our banner. π³οΈβπ
Currently, we use Talc for our heap allocator. We pass it a range of memory from the start of the heap defined in the linker script all the way to the end. Our linker script allocates the largest amount of memory that it can for the heap so vexide programs have the most memory of any V5 Brain library.
Spinning up the executor is straightforward. We just call vexide_async::block_on
on the users main function.
And with that, we have a booting program!
Critical Section
vexide has a critical section implementation that is used to ensure that synchonization types are safe from interrupts.
We use the critical_section
so that libraries that require a critical section implementation can easily access it.
When building for WASM, the critical section implementation is replaced with no-ops because the critical section is implemented using ARM assembly.
Implementation
When entering the critical section we want to disable all IRQ interrupts. What this means is that nothing can interrupt the flow of our code and mess with memory that needed to be untouched.
The first step that vexide takes when entering the critical section is checking if interrupts are already disabled.
#![allow(unused)] fn main() { let mut cpsr: u32; asm!("mrs {0}, cpsr", out(reg) cpsr); let masked = (cpsr & 0b10000000) == 0b10000000; }
This block of code moves the value of the cspr
register into the cspr variable and then checks if the 7th bit is a 1.
If it is 1, IRQ interrupts are masked.
It is important to know if interrupts are masked because nested critical sections shouldn't re-enable interrupts if an inner section is exited.
Next, we actually disable interrupts.
// Disable IRQs
cpsid i
// Synchronization barriers
dsb
isb
The synchronization barriers shown here are taken from the PROS FreeRTOS implementation.
When exiting the critical section, we first check if interrupts were disabled when the critical section was entered. This is where checking the value of cspr register comes into play. We run this assembly if the interrupts were disabled. It re-enables IRQ interrupts
// Re-enable IRQs
cpsie i
// Synchronization barriers
dsb
isb
Again the sychronization barriers are taken from PROS.
VEX SDK Tech Notes
The VEX V5's SDK is largely undocumented, making it difficult to develop software like vexide. This section includes notes on how to use various portions of the public SDK and is intended as a resource for developers creating software that targets the V5.
Display SDK Notes
The Vex V5 SDK contains functions for drawing to the V5βs 480 Γ 272 pixel LCD screen. The origin point of the display is at the top left, and the point (479, 271)
is at the bottom right.
Code Signature
Several publicly released code signature options affect the operation of the display.
V5_SIG_OPTIONS_INDG
(1 << 0
): Inverts the background color to pure white.V5_SIG_OPTIONS_THDG
(1 << 2
): If VEXos is using the Light theme, inverts the background color to pure white.
If both options are enabled at once, the themed option will win and the background will stay black if the V5 is using the Dark theme.
Foreground and background colors
Foreground and background colors can be set using vexDisplayForegroundColor
and vexDisplayBackgroundColor
. These colors will then be used for all future display calls unless changed again. In all programs, the foreground starts off-white (#c0c0ff
). By default, the background starts black (#000000
) but this isnβt guaranteed and can be changed using the programβs code signature. Colors are u32
s in the format 0x00RRGGBB
where R
, G
, and B
are the red, green, and blue components.
Erasing
vexDisplayErase
acts similarly to vexDisplayRectFill
but uses the background color instead and always fills the entire screen. As with other drawing-related functions, portions of the screen that are outside the clip region will not be modified.
Scrolling
vexDisplayScroll*
-form methods move a region of the screen nLine
pixels upwards, without affecting portions of the screen outside the specified scroll region. Since nLine
is a signed integer, a negative value will move the pixels in the region downwards instead. Pixels that move outside the region being scrolled are discarded, and any portions of the region that no longer have a value after the operation are set to the background color.
The vexDisplayScroll
function scrolls the region defined by all the pixels whose y-axis coordinate is within the range [nStartLine, 272)
. Consider a brain with the following image displayed on the screen:
Running vexDisplayScroll
with nStartLines
set to 150
and nLines
set to 50
would result in the following:
The operation does not edit the top nStartLine
(150) pixels because they are outside the scroll region. The pixels inside the scroll region are moved up by nLines
(50) pixels (and discarded if they would modify a portion of the screen outside said scroll region). The operation leaves nLines
(50) pixels of empty space at the bottom, which is subsequently filled with the background color (in this case it was set to a shade of red).
vexDisplayScrollRect
works similarly, but allows you to specify a more detailed scroll region. The parameters passed to the function are inclusive, so the points you passed will be included inside the scroll region. However, it appears to be somewhat bugged at the time of writing: it overwrites one too many lines, setting the bottommost row of scroll data to the background color.
Copying image buffers
vexDisplayCopyRect
is used to copy many pixels to the display at once from a pixel buffer outside the display. Each u32 element in the buffer is considered a pixel and is parsed in the same format used by vexDisplayForegroundColor
. The function allows you to specify the region to write to, the pointer to your image buffer, and the stride (or the number of u32 pixels in your buffer per 1 row).
Rectangles
The parameters of vexDisplayRect*
-form methods are inclusive, meaning that the coordinate pairs you specify are inside the rectangle that is created. Thus, the area of a rectangle created with this set of SDK functions is (1 + x2 - x1) * (1 + y2 - y1)
pixels.
Circles
Circles are not antialiased.
Text
Text can be displayed using one of the many printf
-style functions in the Display SDK. Most of them have 2 variants: line-based, and coordinate-based. The origin of coordinate-based text is its top-left corner. Line-based text functions are equivalent to calling their coordinate-based form with the x-coordinate 0
and the y-coordinate 34 + (20 * nLineNumber)
.
Transparent text can be drawn using vexDisplayPrintf
with its bOpaque
parameter set to 0
.
Image reading
Using the SDK, images can be parsed from the BMP and PNG container formats to their raw pixel buffers for use with vexDisplayCopyRect
. vexImage*
functions require the caller to allocate a data buffer that the parsed pixel data will be stored in and then pass it as an out-parameter. Thus, the caller must commit to a maximum image size and pre-allocate a buffer that could hold that largest possible image before calling interacting with this portion of the SDK. The maxh
and maxw
parameters used by these SDK functions must, when multiplied, equal the number of u32
pixels that could fit in the data buffer.
The v5_image
struct
The v5_image
struct is used as an out-parameter by the vexImage*
family of SDK functions.
Its fields consist of:
data
: This field must be set before the read operation as a pointer to the pre-allocated pixel buffer. After an image read operation, said imageβs pixels are written to the location specified by this field.width
andheight
: These will be set by the SDK by the end of a successful image read operation and are the definitive width and height of the image that was loaded. In contrast tomaxh
andmaxw
, these convey the actual number of pixels written rather than the maximum capacity of the pixel buffer.p
: This field is only set by the SDK after avexImageBmpRead
call and appears to point to the first pixel of the second row in the pixel buffer.
BMP images
BMP-formatted images can be read using the vexImageBmpRead
function. It returns 0
on failure and 1
on success.
BMP reading safety
ibuf
must be null, OR the buffer it points to must begin with a well-formed BMP image. Because the function does not take a buffer length argument, the only way it could know the size of the image is by relying on the header ofibuf
being valid.oBuf
must point to an initializedv5_image
struct or null.(*oBuf).data
must point to a mutable allocated image buffer that is at leastmaxw * maxh * 4
bytes long or be null.
BMP reading postconditions
- If the image read operation failed, the function returns
0
. - If the image read operation was successful, the function returns
1
andoBuf
is updated as described in Thev5_image
struct.
PNG images
PNG-formatted images can be read using the vexImagePngRead
function. It returns 0
on failure and 1
on success.
PNG reading safety
ibuf
must be null, OR point to a buffer of at least lengthibuflen
oBuf
must point to an initializedv5_image
struct or null.(*oBuf).data
must point to a mutable allocated image buffer that is at leastmaxw * maxh * 4
bytes long or be null.
PNG reading postconditions
- If the image read operation failed, the function returns
0
. - If the image read operation was successful, the function returns
1
andoBuf
is updated as described in Thev5_image
struct.
Clip regions
The clip region is the region of the screen that the current VEX thread can modify. It can be updated using the vexDisplayClipRegionSet
function, or changed for a different thread using vexDisplayClipRegionSetWithIndex
. In that case, the index
parameter must a 0
-indexed thread ID.
Double buffering
By default, LCD updates are written directly to the display. In cases where the display is updating quickly (such as in a loop), this can cause flickering issues due to the lack of VSync. Calling vexDisplayRender
every frame resolves this issue by enabling double-buffer mode and only updating the display when it is called. Later calling vexDisplayDoubleBufferDisable
will return the display to its direct mode.
When calling vexDisplayRender
, enabling the bVsyncWait
parameter will sleep the current thread until the screen is ready to refresh, and enabling the bRunScheduler
parameter will run background tasks while waiting.
Font and text size
The functions vexDisplayTextSize
and vexDisplayFontNamedSet
do not appear to affect font or text size in a way that is accessible to the user.
Hard To Implement Features
There are many features that we would love to have in vexide, but don't have implemented. Often these features are not implemented because they are very technical or too hard to implement. This chapter goes over these features and describes what we know about them, why we haven't implemented them yet, why we want them in the first place, and possibly more depending on the feature.
Hot/Cold Linking
Hot/Cold linking is a feature of VEXos that allows for a user program to be split into two files, named the hot and cold binaries. The purpose of splitting your binary up into two files is that the code in the libraries you use, PROS, pros-rs, vexide, VEXCode, etc. can be uploaded once in the cold binary and never again. User code ends up in the hot binary. The hot binary is the only binary that is uploaded when you make changes. When working with a large project, hot/cold linking can save a huge amount of time spent uploading. A PROS V3 project can take over 30 seconds longer to upload when using a monolith binary. Currently vexide only supports monolith style linking which puts all code into one binary.
Stack Unwinding
When a Rust program encounters an unrecoverable error, it panic!()
s. The behavior of a panic is intentionally not defined in Rust, and may vary based on the platform and strategy.
In general, Rust defines two "panic strategies":
abort
: After printing a stacktrace (defined by a#[panic_handler]
), the program will exit immediately. No memory cleanup is performed. This form of panic is truly unrecoverable.unwind
: Rather than exiting immediately, the program's stack memory will be unwound, allowing for a more graceful exit. Every active struct instance'sDrop
implementation will be called, and panics created outside of the main thread can be caught and handled.
no_std
and Panics: The current approach.
Panic behavior is platform-dependent. On a more "traditional" platform target, we have the luxury of an operating system with I/O utilities and a well-defined allocator. On an embedded target such as armv7a-VEXos-eabi
(the platform target defined by vexide
), Rust takes no assumptions and leaves the panic implementation up to us.
In order to build a barebones Rust program on bare metal, we must define a panic handler:
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
loop {}
}
In this (extremely barebones) example, if panic!()
were to be called, the panic handler would simply spinlock the CPU indefinitely. If this were an enviornment where libc
was available (PROS has access to newlib libc) we could call libc::exit(1)
.
vexide currently defines a basic panic handler under the assumption of the abort
strategy:
- It prints a panic message over stdout through serial.
- It optionally displays a brief error message on the brain screen.
- Finally, it exits the user program using PROS' wrapper over over the
vexSystemExitRequest()
SDK call.
The panic implementation does not perform stack unwinding or any cleanup, as armv7a-VEXos-eabi
's panic_strategy is currently abort
.
Unwinding on ARM
As it turns out, it might be possible to support "panic_strategy": "unwind"
on our bare metal ARM target. PROS links against libgcc
, which defines primitives for dealing with exception handling, including the parts needed to implement an unwinding panic handler in Rust. Unfortunately, stack unwinding has a very complicated and advanced implementation. The best we have to go off of is the libpanic
implementation over libgcc
used internally by rustc. This piece of code is a pretty obscure part of Rust's runtime. It's not well documented and is supposedly reverse-engineered from other language implementations.
Essentially, in order to implement an unwinding panic, we must first override the eh_personality
language item which is called by Rust internally when panicking using unwind
. The #[eh_personality]
routine is then responsible for hooking into libgcc's ARM unwinding functions for actually unwinding the stack. These functions are actually part of the Itanium C++ Exception Handling ABI.
In a previous attempt to reimplement unwinding panics, that item looked something like this:
#![feature(lang_items)]
#[lang = "eh_personality"]
#[no_mangle]
unsafe extern "C" fn rust_eh_personality(
state: _Unwind_State,
exception_object: *mut _Unwind_Exception,
context: *mut _Unwind_Context,
) -> _Unwind_Reason_Code {
...
}
PROS Additionally has its own unwinding exception handler.
Other Alternatives
The unwinding
crate provides pure-rust implementations of exception handlers that we could use in place of a libgcc implementation. Unfortunately, ARM support isn't available in this crate yet.
VEX Simulators
VEX V5 simulators are useful for debugging user programs without access to a real robot. This chapter describes the vexide/pros-rs ecosystem of simulator-related applications and how to write compatible software.
Components of a Simulator
The following components are the most essential parts of a VEX simulator. Applications might implement one or more of these, or fill a supporting role not listed.
Code Execution Backend (the "Simulator")
The code executor is probably the most important part of the simulator. Its job is to interpret user programs along with inputs like joystick controls and peripherals state in order to decide what the robot would do in real life (serial output, peripherals output, warnings, panics, etc). It might be implemented using a virtual machine, using a WebAssembly engine, or through dynamic library loading.
There are several code executors for VEX simulators, in varying stages of completion. Here are a few of the ones I know about:
-
This code executor, physics engine, and frontend combo uses a modified version of the PROS kernel to compile and run the robot code as a native executable. It is not currently being maintained.
-
vexide/pros-simulator: Run PROS robot code without the need for real VEX V5 hardware.
This code executor compiles vexide robot code to WebAssembly and runs it in a sandbox. It, as well as vexide, are not currently being maintained.
-
vexide/vex-v5-sim: A simulator for the VEX V5 Brain. AKA The Vimulator.
A unique, QEMU-based code executor, this project aims to accurately emulate a VEX V5 brain.
-
There is a proof-of-concept WebAssembly code executor for Vexide.
Physics Backend
This is a piece of software that decides robot and peripheral state (including velocity, temperature, etc.). It tells the code execution backend about the world state so that the simulated robot code can react to it.
Frontend
The simulator frontend is usually the only part the user sees. It combines information from the physics backend and output from the code executor to show and overview of the simulation's progress. It also sends user input (e.g. joystick/touch) to the code executor to act accordingly.
-
A cross-platform Tauri application, it uses pros-simulator's frontend interface to start and manage a compatible simulator.
Vexide Simulator Protocol
The Vexide Simulator Protocol enables communication between VEX robot simulators and user-facing frontends using a JSON-based protocol.
Specification
This specification details the Vexide Simulator Protocol, used by tools in the vexide ecosystem to achieve interoperability between simulators and their frontend. It is primarily intended to inform library developers creating software intended to be compatible with tools utilizing the Protocol.
Requirements
The keywords βMUSTβ, βMUST NOTβ, βREQUIREDβ, βSHALLβ, βSHALL NOTβ, βSHOULDβ, βSHOULD NOTβ, βRECOMMENDEDβ, βMAYβ, and βOPTIONALβ in this section are to be interpreted as defined in RFC 2119.
Overall Operation
Vexide Simulator Protocol (the "Protocol") is a newline-delimited JSON-based protocol intended to facilitate communication between robot simulators and frontends like GUIs. All communication MUST be encoded in the JSON Lines format.
Implementors of the Protocol SHALL recognize two unique roles in a given session: the Code Execution Backend (the "Simulator" or "Backend") and the Frontend. A Vexide Simulator Protocol session begins with the creation of an I/O stream between the Simulator and the Frontend. Implementors SHOULD support sessions over the standard streams of the Simulator using its standard input and standard output and MAY support other methods such as Unix domain sockets or Transmission Control Protocol (TCP) streams.
Throughout a given session, the Code Execution Backend and the Frontend send JSON messages over the underlying stream. Messages sent by the Backend are referred to as Events because they are used to notify the Frontend of changes in the simulator world. Messages sent by the Frontend are referred to as Commands because they are used to control the behavior of the Simulator.
Standard Stream Considerations
When using standard streams to communicate, data sent by the Backend over its standard error stream MUST be considered unstructured logs by the Frontend and SHOULD be made accessible to users. This requirement is made to aid the discoverability of Simulator error messages and improve the troubleshooting experience.
Data Type Format
Events and commands sent using the Protocol contain various JSON data types analogous to Rust type values. Implementors of the Protocol MUST decode and encode data types to a format compatible with the Serde library configured to use externally tagged enums.
Specific Commands and Events
The specific commands and events that implementors may use are currently defined in the Commands and Events sections, respectively.
Protocol Timeline
A session begins with the Frontend sending a Handshake
command containing the maximum Protocol version it is compatible with (the current version is 1
), as well as an array of string IDs containing an unspecified list of extensions (i.e. deviations from the specification) that it is compatible with. If the Simulator is not compatible with the protocol version sent by the Frontend, it SHOULD immediately close the stream, ending the session. To continue the session, it MUST send a Handshake
event containing the protocol version and extensions that will be used henceforth. The protocol version MUST be less than or equal to the one sent by the Frontend, and the extensions array MUST only contain values that the Frontend indicated it was compatible with. Unknown fields in the handshake SHALL be ignored by all implementations.
The handshake is finished when both the Frontend and Backend have sent Handshake
messages. Events and commands other than Handshake
MUST NOT be sent until the handshake has finished.
After the handshake has finished, the Frontend MUST send ConfigureDevice
commands for each peripheral that is already configured to make them available to the robot code. The Frontend SHOULD also send a CompetitionMode
command containing the desired starting competition mode, as well as any other commands necessary to set the Simulator to the desired starting state.
If the Frontend does not send a CompetitionMode
command, the Backend SHALL default to a competition mode with the following fields:
enabled
:true
connected
:false
mode
:Driver
is_competition
:false
Before continuing the session, the Backend MUST handle setup commands sent by the Frontend and at any point send a Ready
event when it is capable of immediately beginning code execution. To continue the simulation, the Frontend MUST wait for the Backend's Ready
event, and then send the StartExecution
commands. The Backend SHOULD ignore StartExecution
commands received before it has signaled it is ready.
After receiving the StartExecution
command, the Backend SHALL take the steps necessary to begin executing robot code and SHOULD send events as necessary to notify the Frontend of changes to the state of the simulation.
If the robot code exits for any reason, the Simulator MUST send an Exited
event, followed by ending the session by closing the underlying stream. If the code exited due to a fatal error, the Simulator SHOULD precede its Exited
event with an Error
-level Log
event containing a concise explanation of what went wrong. If the Simulator ends the session after the handshake by closing the underlying stream without first sending an Exited
event, the Frontend SHOULD handle the situation as an internal error in the Simulator.
Code Signatures
Backends SHOULD send a VCodeSig
event containing the robot program's Code Signature (i.e. "Cold Header") as soon as possible after the handshake has finished. Code signatures MUST be encoded in the Base64 format.
Here is the output of hexdump -C
on a code signature commonly used by vexide programs:
00000000 58 56 58 35 02 00 00 00 00 00 00 00 00 00 00 00 |XVX5............|
00000010 00 00 00 00 00 00 00 00 |........|
00000018
This code signature would be encoded by a compatible Simulator as the following Base64-encoded JSON string literal:
"WFZYNQIAAAAAAAAAAAAAAAAAAAAAAAAA"
Logging
Backends MAY send human-readable Log
events at any time after the handshake is completed. Frontends SHOULD make these logs available to users and MAY store them for later retrieval. Frontends MAY also implement filtering to reduce clutter caused by log levels that tend to be more verbose.
Example Session
This example session is provided as a resource to help developers imagine one of the possibilities of a valid exchange using the Protocol. Multi-line JSON, as well as comments preceded by a double-slash (//
), are not valid in a true implementation and are used to aid the reader in understanding.
// The Frontend begins the session by sending a handshake.
// Frontend:
{ "Handshake": { "version": 1, "extensions": [] } }
// The Backend agrees on using version 1 without extensions.
// Backend:
{ "Handshake": { "version": 1, "extensions": [] } }
//
// The Frontend begins configuring devices.
// Frontend:
{ "ConfigureDevice": {
"port": 1,
"device": { "Motor": {
"physical_gearset": "Red",
"moment_of_inertia": 1.0
} }
} }
{ "ConfigureDevice": {
"port": 2,
"device": { "Motor": {
"physical_gearset": "Green",
"moment_of_inertia": 1.0
} }
} }
// Meanwhile, the Backend has finished loading the robot program's code signature.
// Backend:
{ "VCodeSig": "WFZYNQIAAAAAAAAAAAAAAAAAAAAAAAAA" }
// The Frontend continues setup.
// Frontend:
{ "ConfigureDevice": {
"port": 3,
"device": { "Motor": {
"physical_gearset": "Red",
"moment_of_inertia": 2.0
} }
} }
{ "CompetitionMode": {
"enabled": true,
"mode": "Driver",
"connected": true,
"is_competition": false
} }
// The Frontend has finished configuring devices and is now waiting for the
// Backend to be ready.
//
// The Backend is now ready to execute the robot code.
// Backend:
"Ready"
//
// The user has indicated the robot code should start.
// Frontend:
"StartExecution"
// The robot program has sent a line over the serial port.
// Backend:
{ "Serial": {
"channel": 1,
"data": "SGVsbG8gV29ybGQhCg=="
} }
// The robot program is moving a motor.
// Backend:
{ "DeviceUpdate": {
"port": 1,
"status": { "Motor": {
"velocity": 1.0,
"reversed": false,
"power_draw": 1.0,
"torque_output": 1.0,
"flags": 0,
"position": 2.5,
"target_position": null,
"voltage": 5.0,
"gearset": "Red",
"brake_mode": "Brake"
} }
} }
// The user has changed the competition mode.
// Frontend:
{ "CompetitionMode": {
"enabled": false,
"mode": "Driver",
"connected": true,
"is_competition": false
} }
//
// The robot code has exited.
// Backend:
"Exited"
Communication
The code executor and frontend talk over a stream in newline-delimited JSON format.
The backend sends Event
s which represent a change in simulator state.
These are used by the frontend to correctly display the state of the simulated program.
The frontend sends Commands
to the code executor to control the robot code environment, simulating changes in robot hardware (like controller input and LCD touch events) or competition phase.
When using Standard I/O to communicate, data sent by the simulator engine over stderr
must be considered unstructured logs and should be accessible to the user.
Events
Events are sent from the simulator backend to the frontend to describe simulator state changes.
Handshake
: Specifies the version of the Protocol used, as well as extensions that the Backend is compatible with. Fields:- version: Nonzero integer. Compatible implementations MUST set this to the value
1
. - extensions: String array
- version: Nonzero integer. Compatible implementations MUST set this to the value
ScreenDraw
: Draw something on the screen.ScreenDraw
fields:- command: DrawCommand
- color:
Color
- background:
Color
ScreenClear
: Fill the entire screen with one color.ScreenClear
fields:- color:
Color
- color:
ScreenDoubleBufferMode
: Set the double-buffer mode of the screen. When double buffer mode is enabled draw calls should be stored in a intermediate buffer and flushed to the screen only once aScreenRender
event is sent.ScreenDoubleBufferMode
fields:- enable: boolean
ScreenRender
: Flush the screens double buffer if screen double buffering is enabled.VCodeSig
: The cold header ("Code Signature") of the robot program, encoded as a base-64 string.Ready
: The backend is ready to start executing user code.Exited
: The backend has stopped exiting user code and will be terminating.Serial
: Data that has been flushed from the serial FIFO buffer.Serial
fields:- channel: integer
- data: Base64-encoded string
DeviceUpdate
: State regarding an ADI or Smart device has changed.DeviceUpdate
fields:- status: DeviceStatus
- port: Port
Battery
: New statistics about the current state of the robot battery are ready. Fields:- voltage: The output voltage of the battery. Float value in Volts.
- current: The current draw of the battery. Float value in Amps.
- capacity: The remaining energy capacity of the battery. Float value in Watt-Hours.
RobotPose
: The physically simulated robot has moved.RobotPose
fields:- x: float
- y: float
RobotState
: The state of the physically simulated robot has changed. Implementation is TBD.Log
: Log a message. This is not to be used in place of serial. This is purely for messages from the backend itself.Log
fields:- level: LogLevel
- message: UTF8 encoded string.
VEXLinkConnect
: Tell the, currently nonexistent, VEXLink server to open a VEXLink connection.VEXLinkConnect
fields:VEXLinkDisconnect
: Tell the VEXLink server that the VEXLink connection has been terminated. Fields:- port: SmartPort.
Commands
Commands are sent from the frontend to the backend and signal for specific actions to be performed.
Handshake
: Specifies the version of the Protocol used, as well as extensions that the Frontend is compatible with. Fields:- version: Nonzero integer. Compatible implementations MUST set this to the value
1
. - extensions: String array
- version: Nonzero integer. Compatible implementations MUST set this to the value
Touch
: Touch a point on the screen. Only one touch can be registered on the Brain display.Touch
fields:- pos: Point
- event: TouchEvent
ControllerUpdate
: Updates the current state of the controller. A ControllerUpdate enum.USD
: Mount or unmount a directory as the V5's SD Card. Robot programs will be able to read and write to this directory. Fields:- root: string (path to sd card's root), or null to unmount
VEXLinkOpened
: The VEXLink server has successfully opened a connection. Fields:VEXLinkClosed
: The VEXLink server has closed a VEXLink connection. Fields:- port: SmartPort.
CompetitionMode
: Update the competition mode. Fields:- enabled: boolean
- connected: boolean
- mode: CompMode
- is_competition: boolean
ConfigureDevice
: Configure a port as a specified device. Fields:AdiInput
: Set the input voltage for an ADI port. Implementation is TBD. Fields:- port: AdiPort
- voltage: float
StartExecution
: Start executing user code. This should be treated as a no-op by the backend until it sends aReady
Event
.SetBatteryCapacity
: Updates the remaining battery capacity to a new Watt-Hours value. If this command is not sent, the simulator must default to a value of 14 Watt-Hours, the maximum capacity of the VEX V5 battery. This value may be used by the simulator to help calculate the voltage of the battery. Fields:- capacity: float in Watt-Hours
Data types
Several different enums and structs are used for communication to and from the backend.
All enum datatypes are encoded using externally tagged representation; see Serde enum-representations.
DrawCommand
An enum with these variants:
Fill
: Fill a shape on the screen. Fields:- shape: Shape
Stroke
: Draw the outline of a shape on the screen. Fields:- shape: Shape
CopyBuffer
: Draw a pixel buffer to the screen with a given stride and start and ending coordinates.CopyBuffer
fields:
Shape
An enum that describes a shape to be drawn on the screen. Shape has these variants:
Rectangle
: Rectangles are drawn starting at the top left coordinate and extending to the bottom right coordinate.Rectangle
fields:Circle
:Circle
s are drawn with a coordinate at the center of the circle and a radius. This variant has these fields:- center: Point
- radius: integer
Pixel
: Draw a single pixel at a coordinate.Pixel
fields:- pos: Point
Point
A struct that stores a pixel coordinate.
The origin is at the top left of the screen.
Point
fields:
- x: integer
- y: integer
DeviceStatus
An enum representing the state of a device.
Every time the state of a device on any port changes, this enum will be sent.
This type should be considered "non-exhaustive" and variants may be added without causing a breaking change.
DeviceStatus
variants:
Motor
: The state of the motor has changed.Motor
fields:- velocity: float in radians per second.
- reversed: boolean
- power_draw: float in Watts
- torque_output: float in Nm
- flags: A 32-bit integer bitfield containing the motor flags that will be provided to the robot code. VEX V5 uses this to signal motor faults. Implementors should set this to
0
. - position: float in radians
- target_position: optional float in radians
- voltage: float in Volts.
- gearset:
MotorGearSet
- brake_mode:
MotorBrakeMode
MotorGearSet
Represents the gearset of a smart motor device. Variants:
Red
: 36-1 ratioGreen
: 18-1 ratioBlue
: 6-1 ratio
MotorBrakeMode
Represents the brake mode of a smart motor device. Variants:
Coast
Brake
Hold
MotorFlags
TBD
LinkMode
An enum representing the type of VEXLink connection.
LinkMode
variants:
Manager
Worker
TouchEvent
An enum representing how a touch on the Brain display has changed.
TouchEvent
variants:
Released
: The screen is no longer being pressed.Pressed
: A new touch has been registered on the screen or the screen has been held but not long enough to start sendingHeld
events.Held
: Sent in place ofPressed
events once a timeout has been reached. I cannot find the exact timeout for this, but it can easily be tested with a simple program.
Port
An enum containing a Smart port or ADI port.
Port
variants:
SmartPort
An integer with the constraints 0 β€ integer β€ 20.
AdiPort
An integer with the constraints 0 β€ integer β€ 7.
CompMode
An enum representing the current phase of competition. Variants:
Auto
Driver
Color
A struct that represents an rgb8 color.
Color
fields:
- r: 8 bit integer
- g: 8 bit integer
- b: 8 bit integer
LogLevel
An enum representing the importance of a log message. Variants:
Trace
: jumptable calls and other verbose messagesInfo
: non-critical informational messagesWarn
: possible issues or errors that do not directly affect the simulationError
: issues and errors that degrade the simulation
ControllerUpdate
An enum representing a method of accessing a controller's status. If the server recieves a ControllerUpdate command while in a disabled or autonomous mode, it must store the state recieved even though the robot code cannot access it yet. Variants:
Raw
: ControllerState. If this variant is recieved, the server must stop updating the controller's status using a previously recieved UUID and only provide the robot code with the most recently recieved raw state.UUID
: string, the UUID of a hardware gamepad such that it can be accessed via SDL2. If this variant is recieved, the server must discard any previously recievedRaw
data and continue updating its internal representation of the controller using the specified UUID even if no moreControllerUpdate
commands are sent.
ControllerState
The current state of a controller's inputs. Fields:
axis1
: integer with range of[-127, 127]
axis2
: integer with range of[-127, 127]
axis3
: integer with range of[-127, 127]
axis4
: integer with range of[-127, 127]
button_l1
: booleanbutton_l2
: booleanbutton_r1
: booleanbutton_r2
: booleanbutton_up
: booleanbutton_down
: booleanbutton_left
: booleanbutton_right
: booleanbutton_x
: booleanbutton_b
: booleanbutton_y
: booleanbutton_a
: booleanbutton_sel
: booleanbattery_level
: integer with range of[0, battery_capacity]
button_all
: booleanflags
: A 32-bit integer bitfield containing the controller flags that will be provided to the robot code. Implementors should set this to0
.battery_capacity
: positive integer
Device
An enum of the configurations for a peripheral device. This type should be considered "non-exhaustive" and variants may be added without causing a breaking change. Variants:
Motor
: The configuration for a motor peripheral. Fields:physical_gearset
:MotorGearSet
moment_of_inertia
: float in kilogram meters squared. Controls the acceleration of the motor when it applies a set amount of torque.
Hydrozoa
Hydrozoa is the Java/Kotlin/WASM runtime for VEX V5.
How Hydrozoa runs a program
Step 1: Language build tools
Hydrozoa relies on a language's own build tools to create a WebAssembly (WASM) binary that it can load when the user starts their program. We chose WASM because of its wide language support - everything from Rust to JavaScript can be compiled to it. The VEX brain can't run WASM bytecode out of the box, so we include a separate interpreter which does.
In the case of Java, the language was designed to target the Java Virtual Machine (JVM) rather than WASM. This means programs need to use an alternative compiler. When using the Hydrozoa Gradle plugin, the TeaVM compiler's setup & invocation is handled automatically while running the build
or upload
tasks.
Step 2: Uploading
Hydrozoa programs are bigger than C++ or Rust programs because they include a full interpreter and its bindings to the VEX SDK, not to mention the actual user code itself. Thus, the upload process is split into two files: the runtime and the user program.
The runtime contains everything that usually doesn't need to be reuploaded when a program changes, which includes the interpreter, VEX startup code, bindings to the VEX SDK, and the TeaVM support library. It currently exists on the brain as a single libhydrozoa.bin
file, although this is subject to change.
In contrast, the user program contains WASM-formatted data that the runtime is expected to load. While there is only one Hydrozoa runtime per brain, there can be multiple user programs in different program slots. While uploading a program with the hydrozoa
CLI, these upload progress for the runtime is marked with lib
and the progress of the user program is marked with bin
.
There tend to be very limited size constraints when uploading to the brain. The wasm-opt
tool from Binaryen can help programmers meet these requirements, shrinking the program when running it like so:
wasm-opt -Oz -o optimized.wasm robot.wasm
This is not run automatically during a Gradle upload because then Binaryen would need to be installed manually by the user.
Step 3: Startup
Hydrozoa uses a version of vexide-startup which has a modified linker script to support VEXos file linking. When the user presses the Run button,
- VEXos loads the runtime to
0x03800000
and the user program to0x07800000
. - It then begins execution at
0x03800020
, which is handled by vexide-startup. - After vexide-startup passes execution to the runtime, Hydrozoa reads the program from memory, initializes the interpreter (which is currently wasm3), and begins execution.
User program format
VEXos does not include the length of a file when linking it, so the CLI prepends the user program with an integer equal to the program's length. The runtime knows about this format and uses it to retrieve the linked WASM file as a Rust &[u8]
slice.
0x07800000
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββ
βEnd of ββProgramββWASM user program β
βRuntime ββHeader ββ(length: value of β
β ββ ββprogram header). β
β ββ(u32) ββ β
β ββ ββ β
β ββ ββ β
β ββ ββ β
β ββ ββ β
ββββββββββββββββββββββββββββββββββββββββββ
β²
β
Step 4: Execution
A robot program is only useful if it can do something with its hardware, so Hydrozoa's runtime provides functions which WASM programs can import and use in order to access peripherals.
The "vex" module
Functions imported from the "vex"
module are used to access VEX peripherals and control the VEX robot. They are generally direct recreations of VEX SDK functions from the vex-sdk
Rust crate and are likely similar to those defined in libv5rts.a
.
For example, the following Rust program could theoretically be compiled to WASM, and when run under Hydrozoa, would draw a square to the VEX's display and then immediately exit:
#[link(wasm_import_module = "vex")]
unsafe extern "C" {
fn vexDisplayForegroundColor(col: u32);
fn vexDisplayRectFill(x1: i32, y1: i32, x2: i32, y2: i32);
}
#[unsafe(no_mangle)]
extern "C" fn start() {
unsafe {
vexDisplayForegroundColor(0xFFFFFF);
vexDisplayRectFill(20, 20, 120, 120);
}
}
While most signatures exported from the "vex"
module are one-to-one with their vex-sdk
counterpart, ones that deal in pointers have been modified for the unique runtime environment. For example, the functions that take a V5_DeviceT
now take a u32
. If the WASM program attempts to cast this number to a pointer and dereference it, the operation will fail, because WASM is executed in a memory sandbox.
WASM programs must periodically call the vexTasksRun
function from the "vex"
module in order to update sensors, perform basic serial I/O, and ensure the correct operation of the runtime.
#[link(wasm_import_module = "vex")]
unsafe extern "C" {
fn vexDeviceGetByIndex(index: u32) -> u32;
fn vexDeviceMotorVoltageSet(device: u32, voltage: i32);
fn vexTasksRun();
}
#[unsafe(no_mangle)]
extern "C" fn start() {
unsafe {
// Get the motor on smart port 1
let device_handle: u32 = vexDeviceGetByIndex(0);
// Set it to 12 volts (full power)
vexDeviceMotorVoltageSet(device_handle, 12 * 1000);
// Loop so that the program doesn't exit
loop {
vexTasksRun();
}
}
}
pros-rs
This chapter is for archived docs specifically related to pros-rs.
Technical Implementations
Some features of pros-rs required technical or hard to follow implementations. In order to make them easier to understand, this chapter aims to explain the more difficult ones.
Task Local Storage
Pros-rs uses a custom made TLS (Task Local Storage) implementation to allow for arbitrary TLS entries. FreeRTOS TLS has a maximum number of entries. It is configurable, but the default is only 5. Because the FreeRTOS TLS api is horribly unsafe and the maximum number of entries is 5, we use a custom implementation based off of the FreeRTOS api.
The custom implementation works by placing a pointer to a pros-rs TLS type in the FreeRTOS TLS.
Pros-rs puts this always pointer in slot 0 of tls so that we can easily find it.
Here is a flow chart that goes over the basic idea of the implementation:
In order to guarantee that every LocalKey
has a unique index into the pros-rs TLS that works on every task,
a static atomic u32 is used as a semaphore.
Every time a LocalKey
is created it will get the current value of the next index semaphore and increment it by one.
the index that it got from the semaphore is guaranteed to be unique on all threads because the semaphore is static and atomic.
Drawbacks
Unfortunately, because of the fundamental design of this implementation, in order to access any value in TLS you must go through two pointers.
Also when a task is destroyed for any reason, all entries in pros-rs TLS are leaked and cannot be freed.
The leaking issue could be fixed by using Box::from_raw
and dropping the returned value when the TLS struct is dropped,
but that hasn't been implemented just yet.
Task Spawning
The FreeRTOS task spawning API in PROS works as follows:
- It takes a C calling convention function pointer to a function with one argument of type
void*
. - It takes a
void*
that should be passed to the given function. - It takes general information about the task. This includes stack size and priority.
You can pass the pros-rs task spawning API any type implementing FnOnce() + Send + 'static
and general info about the task.
This is significantly better because it means that you can use closures
and rust calling convention function pointers.
You can avoid code like this:
#![allow(unused)] fn main() { unsafe extern "C" fn simple_task(args: *mut core::ffi::c_void) { let args: Box<String> = unsafe { Box::from_raw(args as *mut _) }; loop { println!("{}", args); } } }
and instead write code like this:
#![allow(unused)] fn main() { let message = "Hi"; let simple_task = move || { loop { println!("{}", message); } }; }
Internally, pros-rs is taking your functions,1
putting them on the heap, and then passing them through the arguments pointer
to a pros-rs extern "C"
function that calls your function in the newly created task.
The majority takes place in TaskEntrypoint
,
specifically the TaskEntrypoint::cast_and_call_external
function.
Drawbacks
The biggest drawback of this implementation is that the functions passed by the user need to be copied to the heap. If you pass a closure that closes over 100MB of data, pros-rs will copy 100MB of data to the heap just to spawn your task. Honestly speaking this is a tiny slowdown and it would take a pretty huge performance issue to outweigh the ease of use that this implementation brings.
Hard To Implement Features
There are many features that we would love to have in pros-rs, but don't have implemented. Often these features are not implemented because they are very technical or too hard to implement. This chapter goes over these features and describes what we know about them, why we haven't implemented them yet, why we want them in the first place, and possibly more depending on the feature.
GCC independence
The pros-rs build process (the armv7a-VEXos-eabi
target and cargo-pros
) depends on the Arm GNU toolchain (arm-none-eabi-gcc
)
for linking and transforming output ELF executables
produced by Rust into binaries that can be run on a brain.
This dependency is less than ideal because the Arm GNU toolchain is BIG.
Uncompressed, it's about 1.7GiB which is pretty abysmal.
Ideally we would be using only LLVM binutils
and cargo-binutils
as most users will already have these installed if they are using Rust.
Attempts have been made at using only llvm for linking, stripping, and objcopy
ing
(see PR #84).
Ultimately all attempts have failed because of a libgcc
dependent unwinding implementation in PROS
and strange, likely linker related, memory access violation issues in FreeRTOS task switching code.
It could still technically be done if we manually patched libpros
'
unwinding implementation to be independent of libgcc
and figured out what what caused the memory access violations,
however this would be difficult and would require a huge amount of testing.
If you are up to the the task of getting a pros-rs project running on a brain without arm-none-eabi-gcc
or libgcc,
please take a shot at it. Getting this working would be amazing.
TLDR
In order to not depend on GCC or libgcc
we need to:
- patch
libpros
with a custom unwinding solution - get programs linked with LLVM tools to not segfault
- strip everything related to
libgcc
fromlibpros
- update the
V5
linker scripts to work with LLVM
PR #84 does a couple of these things, so check it out if you want to try getting this working.