The simulator: a pretend Control Hub and Driver Hub
For: anyone who wants to try robot code without a robot. You need: a laptop with Android Studio installed (it brings Java and the Android SDK), and the ftc-helper-lib folder.
The simulator runs your real OpMode code on a laptop. The motors, servos, switches, turn sensor (IMU), webcam and the robot on the field are pretend, with simple physics. You drive it from a web page.
- Good for: checking buttons and screens, testing the finders and camera code, learning the controls, and catching crashes before a meeting.
- Not for: exact speeds, real shots, real camera pictures.
flowchart LR
P["Browser page<br/>Driver Hub + gamepads + hardware panel"] <--> S["Simulator (Java on the laptop)<br/>your OpMode + pretend hardware + physics"]
C["Real controller<br/>(USB or Bluetooth)"] --> P
K["Keyboard"] --> P
Ph["Phone on the same WiFi<br/>(touch gamepad)"] --> S
Start it
Section titled “Start it”First time on this laptop? Do the laptop setup just below first.
-
Open a terminal in the
ftc-helper-libfolder (in Android Studio: the Terminal tab at the bottom, thencd ftc-helper-libif your robot project holds it). Why: the commands only work from that folder. -
Run:
Terminal window ./gradlew :sim:runWhy:
./gradlewruns Gradle, the tool that builds the code.:sim:runis the job to do: therunjob of thesim(simulator) part. (Gradle calls a job a task.) It builds the code and starts the simulator. -
Open http://localhost:8780 in a browser. Why: that page is your Driver Hub, gamepads and field.
Press Ctrl+C in the terminal to stop it.
What you see: the Driver Hub on the left, the field in 3D in the middle with the gamepads under it, and the Control Hub’s panels on the right. Try it: pick an OpMode with the TeleOp button, press INIT, then START (the same big button), and drive with the keyboard (set gamepad 1’s list to “Keyboard”; left stick W A S D, right stick the arrows). The page explains every part.
It works on Windows, macOS and Linux, and on a Chromebook with Linux turned on.
Laptop setup (once)
Section titled “Laptop setup (once)”The library is its own Gradle build, separate from your robot project’s. So it needs these, even when the robot project already builds:
- Java. The commands need
JAVA_HOME(where Java is). Android Studio has its own Java, in itsjbrfolder: Mac/Applications/Android Studio.app/Contents/jbr/Contents/Home, WindowsC:\Program Files\Android\Android Studio\jbr. Check the folder exists on your computer, then set it in the terminal (it lasts until you close that terminal): Macexport JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home", Windows PowerShell$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr". No such folder? Look in Android Studio: Settings > Build, Execution, Deployment > Build Tools > Gradle. The Gradle JDK line names the Java Android Studio uses, with its folder: use that folder. On a Mac,/usr/libexec/java_home -Vin the Terminal also lists the Javas on the laptop. Still none? Install a JDK 17 or 21 (a JDK is Java with the tools that build code), for example Eclipse Temurin from adoptium.net, and use its folder. You do not need the Java from java.com. - The Android SDK. If the first command stops with
SDK location not found, make a fileftc-helper-lib/local.propertieswith one line: Macsdk.dir=/Users/<you>/Library/Android/sdk, Windowssdk.dir=C\:\\Users\\<you>\\AppData\\Local\\Android\\Sdk(your user name in place of<you>). Why: your robot project’s ownlocal.propertiesdoes not count; the library is its own build. (Android Studio shows the SDK path in Settings > Languages & Frameworks > Android SDK.) - Windows: type
gradlew.bat(or.\gradlewin PowerShell) instead of./gradlew. - Normal warnings you can ignore:
source value 8 is obsolete(andtarget value 8),SDK processing. This version only understands SDK XML versions up to 3,Deprecated Gradle features were used,[Incubating] Problems report. - A quick build check:
./gradlew :sim:fromRobot -PteamCode=../TeamCodebuilds your TeamCode first, so it also tells you on a laptop, with no Control Hub, whether your code builds.
Options
Section titled “Options”A -P word after the command is an option: -Pname=value gives the command a setting (a few, like
-Plan, are just on or off and need no value).
./gradlew :sim:run -Probot=sim/robots/my-robot.properties -Plan-Probot uses another robot file: the text file that describes the robot to the simulator (its
size, parts, motors and shapes; a team’s is simulator/robot.properties in the robot project, see
The robot file). -Plan lets phones and tablets on the same WiFi join (Windows asks
about the firewall the first time). With -Plan, anyone on that WiFi can open the page and run
OpModes in the simulator: use it on a team or home network, not open school WiFi.
Every simulator command, from the ftc-helper-lib folder (-PteamCode= points at your robot project’s
TeamCode; with none, it finds ../TeamCode when the library sits in a robot project):
| Command | Does | Options |
|---|---|---|
./gradlew :sim:run |
the simulator page | -Probot=FILE (default: your simulator/robot.properties, else the library’s example), -Plan (phones on the WiFi), -Pport=8781 (another port, to run two at once; default 8780), -PteamCode=DIR |
./gradlew :sim:scenario -Pfile=a.sim |
runs test scripts (below) | -Pfile=a.sim,b.sim (a comma list), -Probot=, -PteamCode= |
./gradlew :sim:fromRobot |
writes the robot file from your Robot.java |
-ProbotClass= (which class), -Pout=FILE, -Pfield=NAME (default biobuzz-2026) |
./gradlew :sim:fromConfig -Pconfig=FILE.xml |
a first robot file from a robot configuration | -Pout=FILE, -Pfield=NAME |
./gradlew :sim:newRobot -Pconfig=FILE.xml |
the five files (Robot.java, …) from a configuration | -PteamCode=DIR, -Ppackage= (default org.firstinspires.ftc.teamcode) |
./gradlew :sim:fit |
fits Characterizer logs (below) | -Plog=, -Probot=, -Pwrite, -Pforce |
The page
Section titled “The page”The page fits one laptop screen. On a narrow screen the parts stack and the page scrolls.
- Left: the Driver Hub, drawn like the Driver Station app. It shows the robot battery, gamepad 1 and 2 (lit while used), the Auto and TeleOp buttons (each lists the OpModes of that kind), one big button that is INIT, then START (play), then STOP, the match time, and the telemetry.
- Middle: the field in 3D, with the gamepads under it. The on-screen gamepads look like a real controller (Xbox 360 / Logitech layout, with the button names the SDK uses). A pressed button gets a white ring. “Hide” on the gamepads gives the field more room when you drive with the keyboard, a real controller or a phone.
- Right: the Control Hub, in panels: Robot (the field from above, pushing the robot, size and pieces, battery), Motors, Servos & switches, Cameras. Each of its buttons shows or hides a panel. The panels stack in that order, and this browser remembers your choice for the next run.
Driver Hub (left): choose an OpMode, then INIT, START, STOP, like the real one. The screen shows
the telemetry about as wide as a real Driver Hub. OpModes marked “(hidden on robot)” have @Disabled:
the simulator shows them anyway. Autonomous stops by itself after 30 s (the box turns it off).
An error in the OpMode shows in red, with the line where it happened.
Gamepads (middle): each gamepad has its own source:
| Source | How |
|---|---|
| On-screen | click or touch the buttons; drag the sticks; slide the triggers. Sticky (the box under the gamepads): a click holds a button until the next click and sticks stay where you leave them, so you can hold two things at once with one mouse. |
| Keyboard | left stick W A S D, right stick arrows, D-pad T F G H, A/B/X/Y K L J I, LB/RB Q E, LT/RT Z C, BACK Backspace, START Enter, GUIDE M |
| Controller | plug in a USB or Bluetooth controller, choose it, press any of its buttons once (a browser rule). Xbox, PS4, PS5 everywhere; Logitech F310 on Windows (on a Mac set its back switch to “D”). Chrome and Edge work best. |
| Phone / other page | start with -Plan, open http://<computer address>:8780/?phone=2 on the phone (the page shows the address): the phone becomes gamepad 2 (?phone=1: gamepad 1). Set that gamepad here to “Phone / other page”; if you forget, the page says another page is using it. |
If both gamepads are set to Keyboard, the same keys work both. A browser tab in the background sends gamepad updates only once a second, and after 2.5 s without news the simulator lets go of every button (like a pulled cable): keep the simulator tab visible while you drive.
The GUIDE (PS) button often does not reach the browser (Windows and macOS keep it): use the on-screen GUIDE, the M key, or hold START for 1 second (the finders accept that too).
Control Hub (right): the field from above (the drivers are at the bottom), “push by hand” buttons (for the Drive Tuner’s push test), every motor (power, count, speed, current; “STOP” when it is at a hard stop), every servo (where it is and where it is going), the switches (click to hold one pressed), the cameras and the tags they see, and the battery voltage (lower it to see a weaker robot).
Field in 3D (middle, above the gamepads; at the bottom on a narrow screen): the field and the robot in 3D. Drag to turn the view, scroll to zoom, right-drag (or two fingers) to slide. View buttons: Drivers, Audience, From above, Follow robot (behind the robot; turning the view by hand ends it). Drivers and From above are from your alliance’s wall: the alliance the running program uses (autonomous’s INIT menu, or TeleOp’s choice); red until a program says.
Where the robot starts. In INIT the robot is put where the program says: an autonomous that names its
start spot (s.start(note, x, y, heading), or startSpotIn() in an EasyAuto) puts it there for the
alliance chosen in its menu; a TeleOp with no autonomous before it puts it at the robot file’s start for
RED, or that spot’s mirror image for BLUE, once the drivers choose the alliance (X / B). After an
autonomous, TeleOp leaves it where autonomous ended. Move it by hand: while no program runs (or in
INIT), drag the robot on the field map (Control Hub, Robot tab) and turn it with the mouse wheel over it,
or in the 3D view: double-click the robot, then drag it to move it or drag its yellow ring to turn it
(double-click again to stop). Under the map, the spot is shown as an autonomous start spot from your
alliance’s wall, ready for s.start("...", x, y, heading). The robot is a box from the robot file
(robot.lengthIn x widthIn x chassisHeightIn) with a white arrow at the front; it turns red when it bumps into
something. Its shapes (below) move with their motors and servos. Rollers and wheels have dark
stripes, so you see them turn and which way; a roller glows green while it takes pieces in and
orange while it pushes them out, a flywheel or other spinner glows yellow while it turns, and a
passive part (moved by gravity or springs) blinks yellow while it moves. The field is built from the field file’s shapes (tiles, walls, tape, solid and overhead
parts) with each AprilTag as a square with its ID. If the robot file names a 3D model of the field
(field.model), the page shows that model instead (with the tag squares on top); the “Field CAD model”
box switches between them. Each camera shows as a cone (webcam yellow, Limelight green); “Camera
view” shows a camera’s picture in the corner (pick the camera when there are two). The 3D library (three.js, MIT license) comes with the simulator, so the page
works without the internet. Untick “Show” on a slow computer.
The robot file
Section titled “The robot file”Quick start: an AI coding agent can write most of it for you from your robot configuration, TeamCode and a tape measure: see the robot-file skill.
The robot file is a plain text file of name = value lines (a “properties” file). Text after # is a
note for people. It describes the pretend robot: every device with the SAME name as the robot
configuration, the motors and servos from the parts catalog, the robot’s size, where it starts, and the
season’s field. The library’s example is sim/robots/starter.properties; the four sellers’ BIOBUZZ starter bots are in sim/robots/starter-bot-*.properties (starter-bots.md); a team keeps its own in its
robot project as simulator/robot.properties (used automatically, see below).
field = biobuzz-2026 # the season's field (sim/fields/biobuzz-2026.json)field.model = fields/biobuzz-2026.glb # optional: the field's 3D model for the 3D view (path from this file)robot.lengthIn = 18 # size, for bumping into field elementsrobot.widthIn = 18robot.heightIn = 18 # the chassis box with no shapes, and which overhead field parts it bumps (with shapes, the shapes decide the size rules)robot.tallestIn = 12 # optional: a part the shapes do not draw reaches this high (size rules count it)start.x = -56 # where it starts (inches from the field center; y away from the drivers)start.y = -61.55 # BIOBUZZ G304: touching the wall, outside the LOADING ZONEstart.heading = 0 # degrees, counterclockwise; 0 = facing away from the driversdrive.type = mecanumdrive.motors = front_left, front_right, back_left, back_rightdrive.part = gobilda-5203-312 # from the parts catalog: speed, encoder counts, currentdrive.wheelDiameterIn = 3.90 # the REAL wheel (the Drive Tuner should find it)drive.gearRatio = 1 # motor turns per wheel turn (default 1)drive.strafeEfficiency = 0.85 # mecanum: how much of a strafe really happens (default 0.85)imu = imumotor.lift = lift, part=gobilda-5203-312, min=0, max=2750, gravity=0.06motor.flywheel = flywheel, part=gobilda-5203-6000, feeder=feeder, shotDrop=0.15 # feeder: a motor, CR servo or servoservo.claw = 0.5, part=gobilda-2000-torquecamera.front = name=Webcam 1, forward=7, up=12, pitch=20 # see "Cameras" belowtouch.lift_bottom = lift, below=20Every setting
Section titled “Every setting”The example above shows the common ones. All of them, with their defaults (a missing line takes the default):
| Line | Default | Means |
|---|---|---|
field = NAME |
biobuzz-2026 |
the field file in sim/fields/ |
field.model = FILE |
none | the field’s 3D model for the 3D view (path from the robot file) |
parts = FILE.json |
none | your own parts catalog, added to the library’s (parts catalog) |
battery = V |
12.8 | battery volts at the start (the page’s slider: 11 to 13.5) |
robot.lengthIn, robot.widthIn |
18 | the footprint, for bumping into field parts |
robot.heightIn |
18 | the chassis box with no shapes, and which overhead field parts it bumps |
robot.chassisHeightIn |
4 with shapes, else heightIn |
the height of the chassis box |
robot.tallestIn |
none | a part the shapes do not draw reaches this high (the size rules count it) |
drive.type |
none |
mecanum, tank (2 or 4 motors) or none (a test rig) |
drive.motors = a, b, ... |
none | the wheel motors |
drive.part |
none | the wheels’ motor from the catalog |
drive.wheelDiameterIn |
3.78 | wheel size |
drive.trackWidthIn, drive.wheelBaseIn |
15, 13 | left-right and front-back wheel spacing |
drive.mirrored |
left |
which side’s motors are mounted mirrored (turn the other way for the same power) |
drive.strafeEfficiency |
0.85 | how much of a strafe really happens (mecanum) |
drive.kS, drive.accelSec, drive.turnDegPerCount |
0, 0.15, worked out | from the Characterizer (below) |
imu = NAME |
none | the IMU’s name |
odometry = KIND NAMES |
none | pretend odometry from the robot’s true motion: pinpoint NAME, otos NAME, threeWheels L, R, P or twoWheels PAR, PERP (odometry) |
odometry.offsetsMm = X, Y |
0, 0 | Pinpoint: where the pods sit (X pod mm left, Y pod mm forward); sets the raw pod counts the Odometry Tuner’s Spin test reads |
odometry.mountIn = F, L, H |
OTOS: where it sits (written from Robot.java); the pretend OTOS reports the centre’s pose, so it does not change the simulation | |
odometry.trackWidthIn |
12 | three dead wheels: left to right pod, inches |
odometry.parallelLeftIn, odometry.perpForwardIn |
0, 0 | dead wheels: the forward pod’s inches left (two wheels), the sideways pod’s inches forward |
odometry.ticksPerIn |
505.3 | dead wheels: pod counts per inch (goBILDA 4-bar pods) |
odometry.reversed = names |
none | these pods count backward (as wired; the code’s reversed(...) turns them back): dead-wheel port names, or x/y for a Pinpoint’s pods |
odometry.scaleError |
0 | noise: 0.01 = it measures 1% too far (wheel slip, a wrong pod size) |
odometry.driftDegPerMin |
0 | noise: the heading creeps this many degrees per minute |
motor.NAME = KIND, ... |
a motor: free, lift or flywheel, then part=, ticksPerRev= (catalog, else 537.7 or 28 for a flywheel), rpm= or maxTicksPerSec= (catalog, else 2780), min=, max= (counts), gravity= (0), kS= (0), accelSec=, coastSec=, feeder= (a motor, CR servo or servo that feeds a flywheel), shotDrop= (0.15: how much a shot slows the wheel) |
|
servo.NAME = START, ... |
start 0.5 | a servo: part= (catalog), or rangeDeg= and secPer60= for one not in the catalog |
servo.speed |
1.0 | positions per second for a servo with no part |
crservo.NAME = |
a continuous servo (the value is not used) | |
touch.NAME = MOTOR, below=N |
below 20 | a switch pressed while that motor is within N counts of its min (reads getState() false when pressed) |
camera.NAME = ... |
a webcam or Limelight (cameras) | |
shape.NAME = TYPE, ... |
a part to draw and check (robot shapes). Sizes when size= is left out: slide 2x2x12, arm 10x2x2, turret 8x8x2, claw 3x1x3, roller 2x12x2, flywheel 4x2x4 |
|
collect.NAME = shape=a+b, motor=M, holds=N |
reach=0.75, holds=4 |
takes pieces in (game pieces); crservo= instead of motor=, inward=- |
shoot.NAME = shape=S, ... |
angle=40, share=0.5 |
shoots pieces: spin=, speed= (a fixed speed, in/s), table= (a shot table, path from the robot file), flywheel= (which flywheel, when there are two) |
grab.NAME = shape=S, servo=V, closedBelow=X |
reach=0.5 |
a claw that holds a piece (closedAbove= for one that closes toward 1) |
start.x, start.y, start.heading |
where the robot starts | |
preload = on/off |
on | the field file’s preload in the robot at the start |
physics = engine/simple |
engine | the physics engine, or the old “a bump stops the move” rule |
A flywheel’s rim speed comes from its flywheel shape: the diameter is the larger of the shape’s
forward and up sizes.
A robot file from Robot.java
Section titled “A robot file from Robot.java”A team that writes its robot in five files (tutorial 22) gets its robot file from the code:
./gradlew :sim:fromRobot -PteamCode=../TeamCode [-ProbotClass=org.firstinspires.ftc.teamcode.Robot] [-Pout=...]It writes simulator/robot.properties (or -Pout) from Robot.java and Tunables.java: every device
name, the drive (type, motors, wheel, which side the code reverses: drive.mirrored), the lift’s and
turret’s stops, the claw’s open and closed positions, the odometry lines, one shape per module, and the collect.,
shoot. and grab. lines. Each line it writes ends with # from Robot.java and is rewritten every
time; every other line is yours and is kept, even for the same setting (a measured shape.lift wins).
Lines for parts no longer in the code are removed. It stops, writing nothing, when the file describes
a device differently from the code (motor.claw in the file, a claw servo in the code) or names
other drive motors. Sizes and places come from Robot.java when it gives them (chassisMm, each part’s
looks(...): Shapes); otherwise the generated shapes are guesses
marked TODO until someone measures them (or the robot-file skill fills them in).
./gradlew :sim:newRobot -Pconfig=config.xml -PteamCode=../TeamCode writes the five files themselves
from the robot configuration (robot module).
Robot shapes
Section titled “Robot shapes”The robot can be drawn as simple shapes: the chassis box plus parts from a short menu, each moved by its motor or servo. The 3D view draws them, and the simulator checks the size rules with them: the 18 in start cube, and 18 x 24 in and 29 in tall (R105) now and at full reach (every moving part tried over its whole range). The Control Hub panel shows the sizes in green or red.
robot.chassisHeightIn = 4# shape.NAME = TYPE, on=PARENT, at=forward,left,up, size=forward x left-right x up, then what moves itshape.lift = slide, at=-7,0,4, size=2x8x8, axis=up, motor=lift, ticksPerIn=344shape.arm = arm, on=lift, at=0,0,8, size=9x1.5x1.5, axis=left, motor=arm, gear=3.5, start=-90shape.wrist = arm, on=arm, at=9,0,0, size=2.5x1.5x1.5, servo=wrist, pos0=-90, pos1=90shape.claw = claw, on=wrist, at=2.5,0,-1.5, size=2x0.5x3, servo=claw, pos0=0, pos1=3shape.intake = roller, at=8,0,1.5, size=2x14x2, motor=intakeshape.flywheel = flywheel, at=-1,0,4, size=4x2x4, motor=flywheelAny shape can also take tilt=DEG (mounted at an angle on its parent, turned about the left-right line,
front up +) and a roller reversed=yes (it turns the other way from its motor, through gears) or soft=yes
(soft tubing or flaps that bend: game pieces touch it, the field does not stop it; it still counts for the
size rules).
| Type | What it is | Where at points |
Moves by |
|---|---|---|---|
box |
a part that does not move | the middle of its bottom | nothing |
slide |
a lift or extension: a fixed stage and a moving stage along axis (up, down, forward, back, left, right); size= is ONE stage |
the bottom of the fixed stage | motor: ticksPerIn= or spoolIn= (spool diameter, one-stage only: a cascade’s top moves faster than its string); servo: pos0=/pos1= inches |
arm |
turns about a hinge; axis is the hinge line (left, the default: + raises the front; 0 = level forward, +90 up, -90 down) |
the hinge; the box reaches forward from it | motor: ticksPerDeg=, or gear= (motor turns per arm turn: counts per degree = the motor’s counts per turn x gear / 360), and start= degrees at the bottom stop; servo: pos0=/pos1= degrees |
turret |
turns about up (+ = left) | the middle of its bottom | like arm |
claw |
two fingers: size= palm depth x finger thickness x finger length; the fingers point up from the palm (in the claw’s frame) and open left-right |
the middle of the palm | servo: pos0=/pos1= the gap in inches |
roller |
an intake roller (a cylinder along axis, default across the robot) |
its center | spins with its motor= or crservo= |
flywheel |
a launcher wheel | its center | spins with its motor= |
Notes:
passive=yes(arm or slide): gravity or springs move it, not the code (a floating intake, a sprung flywheel mount). It is drawn atstart=; the size rules try its whole range (minDeg=/maxDeg=ortravel=).drop=SERVO>0.6(or<) withfallsTo=: once that servo passes the position, it falls to that end and stays (an intake folded up at the start and pushed over at START); a new match puts it back.Robot.javawrites these withShape.passive()andShape.dropsWhen(...)(robot module).on=puts a part on another (default: the chassis). A part on a slide rides on its moving stage (at=0,0,<stage length>is the moving stage’s top); a part on an arm turns with it (at=from the hinge, soat=9,0,0is the tip of a 9 in arm).- Directions are the robot’s: forward, left, up.
at=is in inches from the parent’s mounting point. - The range of motion comes from the motor’s hard stops (
min=/max=onmotor.NAME), the servo’spos0..pos1, ortravel=(slide) /minDeg=,maxDeg=(arm, turret). The size check at full reach uses it: a real robot needs hard stops there too (inspectors check the biggest it can get). - With
physics = engine(the default) the shapes’ boxes bump into the field and push game pieces too. - Mistakes stop the simulator with the line’s name: an unknown parent, motor or servo; a loop; a moving
part with no range of motion (give the motor
min=andmax=, ortravel=/minDeg=maxDeg=); a word with no=after the first one (at=-7,0,4is fine: a list of numbers). - A
#after a space starts a note;color=#ff0000is a color, not a note. - Nothing may go below the tiles: the panel and
expect size floor okshow it (a part there would hit the floor on a real robot, so the shapes or the hard stops are wrong).
Scenario checks: expect size start ok (fits the 18 in cube now), expect size match ok (18 x 24 x 29
now), expect size most ok (every pose at full reach), expect size floor ok (nothing below the tiles
now); fail instead of ok checks the opposite; print size; print cad (see CAD models below).
How “at full reach” is checked: every combination of the moving parts’ important values is tried. A slide or claw is biggest at an end of its range; a hinge (arm, turret) is tried at both ends, at each multiple of 90 degrees, and every few degrees between (5 or finer with a few moving parts; coarser with many, to stay under 50,000 poses). The worst pose is shown. It is a careful check, not a proof: leave a little margin, and measure the real robot at its hard stops.
CAD models of the parts
Section titled “CAD models of the parts”A team with CAD (Onshape, Fusion, SolidWorks…) can draw its real parts in place of the boxes. The size
rules then use the CAD’s shape too. Physics and game pieces keep using the boxes (size=), so build
the shapes first.
1. Put the CAD robot in its start pose. That is the robot as the simulator shows it when it opens:
each lift and arm at its start= value (0 if you wrote none; with a motor whose min= is not 0, check
the 3D view), each servo part at the position on its servo.NAME line. A part that is somewhere else in
your CAD gets cadPose= (below).
2. Make the robot’s centre on the floor easy to find. The easy way: in the assembly, place the
drive frame so the CAD origin is the middle of the drive frame, at the bottom of the wheels (where they
touch the tiles). Then cad.origin = 0,0,0. Otherwise measure that point from the origin (Onshape:
Measure tool; Fusion: Inspect > Measure) and write it in CAD units.
3. Export each group of parts that move together as one STL file, in the assembly’s positions. The chassis and everything fixed to it is one group; the lift’s moving stage and what rides on it, another; and so on. A coarse export is fine.
- Onshape: in the Assembly tab (never a Part Studio: a Part Studio export puts parts where they
were drawn, not where the assembly put them), select every part of the group, right-click > Export,
format STL (
model=also takes .obj), untick “Export unique parts as individual files”. Pick the units in that window. - Fusion: hide every part that is not in the group, right-click the top component > Save As Mesh, STL, one file. (Exporting one body on its own can move it to the origin.) Note the unit it uses.
- Already have one file per part? List them:
model=cad/rail-l.stl; cad/rail-r.stl; cad/carriage.stl.
4. Name the files in the robot file (next to it, or in a folder under it):
cad.units = mm # the unit in the export window: mm, cm, m or in (required)cad.axes = y,-x,z # the CAD axes that point the robot's forward, left and up (required)cad.origin = 0,0,0 # the robot's centre on the floor, in CAD units (default 0,0,0)robot.model = cad/chassis.stl # drawn in place of the chassis boxshape.lift = slide, ..., model=cad/lift-stage.stl, baseModel=cad/lift-base.stlshape.arm = arm, ..., model=cad/arm.stlcad.axes: look at the CAD’s axis triad. Onshape and SolidWorks have up = z (if the robot’s front points along +y:y,-x,z). Fusion usually has up = y (front along +x:x,-z,y). A minus sign means the other way. A choice that would draw the robot mirrored is refused.model=rides the moving part: a slide’s moving stage, an arm or turret, a claw’s palm (its fingers stay boxes, so the gap shows), a roller (it spins), a box.baseModel=is a slide’s fixed stage.cadPose=on a shape: the value (inches or degrees) it is at in the CAD, when that is not the start.- A roller’s or flywheel’s model must hold only the parts that spin (wheels, hubs, shaft). The size rules count a spinning model as the whole circle it sweeps, so a servo bracket in it would make the robot look bigger than it is.
- A fixed
boxshape that therobot.modelCAD already draws (a tower, a hood) can go: the CAD now decides the size there. Keep it only if it should bump into things on its own.
5. Check it. print cad in a scenario prints the settings in words, then each model next to the
box it covers: its size, and how far its middle is from the box’s. “FAR from its box” means
cad.origin, cad.axes or the shape’s at= is wrong (for robot.model, almost always cad.origin);
“25 times smaller” means cad.units is wrong. Then make each size= close to its CAD size: the boxes
are what bump into things. Look at the 3D view too.
- Up to 12,000,000 triangles in all can be read. A model with more than 300,000 is simplified for the
3D view (small details like bolt threads blur;
print cadsays so). The size rules use each model’s outside (its convex hull) plus a small margin (about 1/200 of the part’s size), so they never call a robot smaller than its CAD. A roller’s model counts as everything it reaches while it spins. - CAD from a seller (a kit’s STEP or Onshape files) belongs to the seller: keep it in your team’s repository, not in a public one, unless the seller’s terms allow it.
Game pieces
Section titled “Game pieces”With the physics engine, the field file’s game pieces are on the field (BIOBUZZ: the 4 POLLEN in each GARDEN; POLLEN 2.8 in, NECTAR 3.6 in from manual 9.8; 25 g and 41 g from AndyMark’s am-5851/am-5852 pages, not the manual), they roll and bounce, the robot pushes them, and the FLOWER tops are goals (the opening’s centre and height measured in the FLOWER CAD). The robot file says which robot shapes handle them:
# take in a piece within 0.75 in of the intake shape while motor "intake" turns inward (power above 0.2;# inward=- if negative power takes in; crservo= instead of motor=); it holds 4. Several shapes with +# (shape=intake+left_spinner+right_spinner): a piece comes in at any of themcollect.intake = shape=intake, motor=intake, holds=4# each flywheel shot (motor.flywheel = flywheel, feeder=feeder) sends a held piece off this shape.# The feeder is a motor or CR servo (a piece every 0.3 s while it runs), or a servo that pushes# (an indexer: it feeds while it is more than 0.15 away from where it starts). The shot leaves# 45 degrees up from the shape's forward (angle= defaults to 40), at half the wheel's rim speedshoot.launcher = shape=flywheel, angle=45, share=0.5# the claw holds a piece in its fingers while servo "claw" is below 0.3, and drops it when it opens# (a claw that closes toward 1: closedAbove=0.5 instead)grab.claw = shape=claw, servo=claw, closedBelow=0.3A robot with a collect. or shoot. line starts with the field file’s preload inside it (BIOBUZZ: 4
POLLEN; holds=4 is the game’s rule: no more than 4 at a time); a robot with only a grab. line gets it
on the tiles touching its back; a robot with none of the three gets none. With a
shoot. line, a shot needs a piece: no piece, no shot. A piece that comes into a goal (a FLOWER top)
scores and is taken off. “Back to start” puts the pieces back. The Control Hub panel shows what the
robot holds, what it shot and what scored; the 3D view and the field from above draw the pieces.
The match starts with every piece the manual stages on the field (10.3.1), placed from the official field
CAD: 4 POLLEN in each GARDEN (loose on the tiles), 4 POLLEN in each FLOWER and 3 NECTAR in each upward
CELL. A FLOWER is solid above its bottom opening (the Retrieval Opening, 9-99 mm up, open toward the
field and both sides), so the POLLEN above the opening hold their places (the field file’s stacks).
The bottom POLLEN sits in the opening as a real piece: it stays still until the robot or another piece
comes near, and a part that reaches in can knock it out. An intake running inward (or a closed claw) at
the opening takes it. Either way, the next one drops into its place about 0.12 s later. The robot rolls
over the FLOWER’s thin floor plate (0.35 in); pieces rest on it.
Rollers turn the pieces they touch. A roller or spinner (any roller or flywheel shape) drags a
piece along with its surface, at its real surface speed, as far as the grip allows. So spinners can pull
a piece in, or fling one away, even when the robot cannot take it (when it is full).
The HIVEs tip (manual 9.6, 10.5.1). Each CELL is an open box (its floor, back, sides and roof, from
the field CAD: about 20 x 14 in open, 12 in deep), so a shot can go in, roll down to the back and stay.
The 3 staged NECTAR rest in each up CELL. When the pieces that have come to rest in a HIVE’s up CELL
(slow for 0.3 s) weigh 187 g or more (the team’s field tests: no tip at about 175 g, a tip at about
199-200 g; the 3 NECTAR are 123 g, so the 3rd POLLEN tips it), the HIVE turns 60 degrees: the other CELL
comes up, the pieces in the CELL that went down fall out, and the 8 AprilTags on its CELLs move with it
(cameras see them where they are now). In a match only LAUNCHED pieces may tip it (10.5.1, G417), and a
robot may not catch what falls out until it has touched something else (G409); the simulator does not
referee either. The page
shows the pieces in each up CELL (they score if they are still there at the end) and the tips. A piece
that goes over a wall is taken off and counted (“left the field”). print pieces lists it all;
scenario checks: expect cell "Red" >= 2, expect tips "Red" >= 1, expect left >= 1.
The 3D view shows what the robot holds in its top-left corner: a dot in each piece’s colour, an empty dot
for each free place (its holds=), and the count (“In the robot: 3 / 4 · 3 POLLEN”).
On the page (Control Hub, Robot tab): Back to start puts the robot on its start spot and every piece
where the match starts it; Reset field puts the pieces back and leaves the robot where it is;
Preloaded (ticked by default) gives the robot the preload now and at every reset; untick it for an
empty robot. In the robot file preload = off starts it empty; in a scenario set preload on|off and
set field start.
Scenario lines: set piece POLLEN X Y [Z], expect stored >= 1, expect launched >= 1,
expect scored "FLOWER" >= 1 (goals whose name has the text; "all" = every goal), `expect cell “Red”
= 1
,expect tips “Red” >= 1,expect left >= 1,print pieces`.
Not yet: the alliance areas’ NECTAR (the human player’s, and the one a tip lets them enter, G426); the HIVE’s arm between its CELLs (a piece there passes through); a HIVE turning slowly (it tips at once, and the tip is not judged at the damper, 10.5.1); points and GARDEN scoring; a held piece’s weight on the arm; robot parts that stop when they hit a piece. How far a ball rolls on the tiles is a guess (about 2 m from 1 m/s) until someone times a real one.
How shots fly, and shot tables
Section titled “How shots fly, and shot tables”A shot leaves the launcher shape at angle= (degrees up from its forward; default 40) and share= of the wheel’s rim speed
(default 0.5: a guess), with spin= (1 = backspin as if the ball rolled along a still hood, -1 topspin, 0 none).
In flight the air slows it (drag, Cd 0.5: assumed, not measured for POLLEN or NECTAR; a piece type can set
dragCd in the field file) and spin lifts or drops it (the Magnus force, lift coefficient = radius x spin / speed,
at most 0.5).
Shot tables replace the guesses with how the launcher really throws: shoot.NAME = shape=flywheel, table=launcher-table.json. For each piece and wheel speed the table gives the likely exit speed, angle, spin and
exit point and how much they scatter; each shot picks its numbers from that spread (so shots scatter like real
ones), and a share of feeds that do not become shots drop out. Make one with the launcher program
(tools/launcher/): it cuts your robot’s CAD along the ball’s path, runs many shots through it (the ball as a soft
disc squeezed against the spinning wheel and the hood), tries the numbers the CAD cannot give over ranges (how soft
the balls are, how much the wheel and hood grip, where the ball comes in), and writes the table. It is still a
model: film a few real shots (a phone’s slow motion, next to a tape measure) and compare.
Real numbers: the Characterizer
Section titled “Real numbers: the Characterizer”The simulator moves each motor by the same “feedforward” model a real motor follows: power (at 12.8 V) = kS (to start moving) + speed / maxTicksPerSec + accelSec / maxTicksPerSec x speeding-up (+ gravity for a lift). Without measurements it uses the parts catalog’s top speed and typical values. To make the simulated robot move like YOURS:
- Run the Characterizer on the real robot (a finder: finders): Drive, Turn, each lift/arm and flywheel, 2-3 times each, on a charged battery.
- Copy its logs from the Control Hub (
/sdcard/FIRST/ftc-helper/characterizer/*.csv, with the REV Hardware Client or Android Studio’s Device Explorer) into a folder on the laptop. - From
ftc-helper-lib:./gradlew :sim:fit -Plog=path/to/that/folderprints the numbers; add-Pwriteto put them into the robot file (other settings and notes stay; the old file is kept asrobot.properties.bak). Check it and commit. A poor fit (r2 below 0.9) or a number that makes no sense (kS below 0) is reported and left out;-Pforceuses it anyway. It writes only to your team’s robot file (simulator/robot.properties, or-Probot=); with no-Probotand no team file it uses the library’s example and refuses to write. It writes only to motors already in the file (add a motor first, with its kind, part and limits).
Or copy the lines the Characterizer shows on the Driver Hub. The settings it fills in:
| Setting | Means |
|---|---|
drive.kS, drive.maxTicksPerSec, drive.accelSec |
the wheels: power to start moving, speed per unit of power (counts/s), seconds to reach ~63% of a new speed |
drive.turnDegPerCount |
degrees the robot turns per wheel count of turning ((left - right) / 2): wheels slip when a robot turns |
motor.NAME = ..., kS=, maxTicksPerSec=, accelSec=, gravity= |
a lift or arm (gravity = power that holds it) |
motor.NAME = ..., kS=, maxTicksPerSec=, accelSec=, coastSec= |
a flywheel (coastSec: seconds to lose ~63% of its speed with power off) |
maxTicksPerSec is the speed gained per unit of power: at full power the speed is (1 - kS) x this.
Run in the simulator, the Characterizer writes its logs in sim/build/sim-data/characterizer, and
./gradlew :sim:fit (no -Plog) reads those: a test (SimCharacterizerTest) gives the simulated robot
known numbers and checks the fit finds them again.
The parts catalog
Section titled “The parts catalog”Every part, with its numbers and links: parts-catalog.md. It also shows how to
use a part that is not in the catalog (numbers in the robot file, or the team’s own parts file with
parts = parts.json).
sim/parts/parts.json lists the common FTC motors and servos with their real part numbers and the
numbers from the seller’s page (and the date they were checked): goBILDA Yellow Jacket (5203 and 5204,
every ratio), REV HD Hex (bare, UltraPlanetary combinations, spur gearboxes) and Core Hex, AndyMark
NeveRest, goBILDA 2000 series servos, REV Smart Robot Servos and Axon servos. A robot file names a part
by its id (part=gobilda-5203-312); the simulator takes the top speed, encoder counts per turn and
current (motors) or speed and range (servos) from it. Numbers the seller does not publish are left
empty, with a note. Real ratios are used (an UltraPlanetary “5:1” is 5.23:1). Servo speeds use the
seller’s row closest to 5 V (a Control Hub servo port gives about 5 V; the headline number is often 6 V).
Motor kinds: free (just spins), lift (hard stops, gravity), flywheel (coasts; while its feeder
(a motor or a CR servo) runs, a piece goes through every 0.3 s and slows it down). ticksPerRev= sets a motor’s encoder
counts per turn (default 537.7; flywheel 28). On a robot, setPower in RUN_USING_ENCODER aims for the
top speed of the motor TYPE chosen in the configuration; here it is the robot file’s maxTicksPerSec. The wheel motors are on the left/right sides of the robot like a
real one, with one side’s motors mounted mirrored, so motor directions in your code matter here too.
That side is the left unless drive.mirrored = right: write the side your code reverses to drive
straight (goBILDA’s starter bot reverses the right one), or the other side if your code drives forward
with negative power. Check it: the drive stick forward must drive the robot forward.
Your own robot code
Section titled “Your own robot code”When the library is a submodule of a robot project (ftc-helper-lib/ next to TeamCode/), run the
simulator from the ftc-helper-lib folder: it builds that project’s TeamCode too, lists only its
OpModes, and uses simulator/robot.properties from the robot project. Iterative OpModes
(init()/loop()) and LinearOpModes both run.
- Another TeamCode folder:
-PteamCode=<folder>(the TeamCode module: it must havesrc/main/java); the library’s own examples instead:-PteamCode=none. - TeamCode uses other libraries (Road Runner, Pedro Pathing, FTC Dashboard…)? Put their Gradle lines in
simulator/sim-dependencies.gradlein the robot project, for example:
repositories { maven { url = 'https://maven.brott.dev/' } }dependencies { testImplementation 'com.acmerobotics.dashboard:dashboard:0.4.16' }Scenario files: test without a browser
Section titled “Scenario files: test without a browser”A scenario file (.sim) is a short script: it drives the simulator like a person and checks what happens. Anyone can run
one, and every scenario also runs as an automatic test (the library’s in sim/scenarios/, a team’s in
simulator/scenarios/):
./gradlew :sim:scenario -Pfile=../simulator/scenarios/main-teleop.sim# lines starting with # are notesrobot ../robots/starter-bot-rev.properties # optional: run this scenario with this robot file (path from # the scenario's folder) instead of the usual oneinit "Main TeleOp" # INIT an OpMode (waits for its first screen)start # STARTpress g1.a # press and let go (held until the OpMode surely saw it)hold g1.left_stick_y -1 # set a stick or button and keep itrelease g1.left_stick_y # let go (release g1.all / release all)wait 0.5 # secondsloops 3 # wait for 3 OpMode loopspush 24 0 2 # roll the robot by hand: forward in/s, right in/s, secondsset battery 12.0set touch lift_bottom on # hold a switch pressed (off = let go)set pose -56 -61.55 0 # put the robot somewhere (x y heading)set autotimer off # no 30 s autonomous stopexpect screen has "Ready" # or: screen not "..."expect value "Lift" > 500 # the number on the screen line that starts with Liftexpect time < 25 # seconds since START (e.g. how long an autonomous took)expect touch lift_bottom pressed # or: openexpect error none # or: error has "..."expect phase RUNNING # IDLE | INIT | RUNNING | STOPPEDexpect motor lift power > 0.1 # power | ticks | velocity | amps; > < >= <= = (= VALUE TOLERANCE)expect servo claw target = 0.53 0.01 # target | actualexpect shape intake_arm < 0 # a shape's angle (arm, turret), inches (slide) or gap (claw)expect robot y > -30 # x | y | heading | imu | shotsexpect bumped none # or: bumped "Red HIVE"expect camera "Webcam 1" sees 31 # a tag id in the picture; or: sees none | sees "RED SCORING" (webcam only: # a Limelight reports single tags; LimelightTags makes the groups)expect size most ok # size start|match|most ok|fail (see "Robot shapes")expect stored >= 1 # game pieces in the robot; also: launched, scored, cell, tips, left (see "Game pieces")set piece POLLEN 30 -20 # put a game piece on the field (x y [z])expect within 2 motor lift ticks > 500 # wait up to 2 s for it to become trueexpect within 30 phase STOPPED # e.g. wait for an autonomous to finishstopprint screen # also: print motors | servos | cameras | size | pieces | robot | cad (not counted as checks)A note after # is ignored (outside quotes). A misspelled check fails with a message, never passes.
Button presses wait for the OpMode’s loops, counted by its screen updates (or by loop() calls in an
iterative OpMode); an OpMode that never sends telemetry cannot be counted, so each step then waits 3 s
and the report says so.
Gamepad names: g1./g2. plus a b x y lb rb lt rt back start guide ls rs, dpad_up (or up),
left_stick_x (or lx), right_trigger (or rt) and the other SDK names. The report shows each line
as ok or FAIL with the value it saw; the command fails if any check failed.
Button names in a scenario are not the Java names. A scenario uses the FTC SDK’s names (or the
short names); Controls.java uses the library’s names. The ones that differ:
In Controls.java (Java) |
In a scenario |
|---|---|
g2.dpadUp, dpadDown, dpadLeft, dpadRight |
g2.dpad_up (or g2.up), dpad_down (down), dpad_left (left), dpad_right (right) |
leftStickButton, rightStickButton |
left_stick_button (or ls), right_stick_button (or rs) |
leftStickX, leftStickY, rightStickX, rightStickY |
left_stick_x (lx), left_stick_y (ly), right_stick_x (rx), right_stick_y (ry) |
lb, rb |
lb, rb (or left_bumper, right_bumper) |
lt, rt |
lt, rt (or left_trigger, right_trigger); a value from 0 to 1 |
a b x y back start |
the same |
So g2.dpadLeft.press(...) in Java is press g2.dpad_left in a scenario.
Check a show(...) line. A show("Intake on", ...) line is on the screen as Label : value
(the label, a space, a colon, a space, the value). Check it with its words:
expect screen has "Intake on : true"expect value "Intake on" = true does not work: expect value compares numbers only, and the
scenario stops with "true" is not a number. To check words on the screen (true/false, a name), use: expect screen has "Label : true".
Fields: one file per season
Section titled “Fields: one file per season”sim/fields/<season>.json holds a season’s field: solid elements (the robot bumps into them), floor
zones and tape lines, things overhead, and the AprilTags with their position and the way they face. The
robot file picks one (field = biobuzz-2026). The page draws it; the robot turns red and the panel names
the element when it bumps into one. A start position that overlaps a wall or an element is refused when
the robot file loads. A new season is a new file: the BIOBUZZ file is made from the official
field CAD by a script that is not in this library yet (ask the maintainer); the same kind of script works for the next CAD.
Physics. The robot moves with a physics engine (ode4j, pure Java): the wheels say how fast it wants to go, and the drive pushes the robot’s body toward that speed as hard as the wheels’ grip allows. Walls and the field’s solid and overhead parts are solid shapes (each part’s footprint, from its lowest to its highest point): the robot stops at them or slides along them, and its parts (the robot shapes) hit them too: a tall lift can hit the HIVE’s overhead frame. When the body is blocked the wheels keep turning (they slip), so wheel encoders keep counting while it pushes, like a real robot: an encoder-driven autonomous can “arrive” while pinned. Robot file settings:
robot.massKg = 15 # the robot's mass (default 15)drive.grip = 1.0 # how hard the wheels can push, as a share of the robot's weight (about 1 on tiles)physics = engine # the default; "simple" = the old rule: a bump stops the whole move, no slidingThe body follows the wheels a little behind (its grip takes about 0.02 s to catch up), so an
encoder-driven autonomous ends within about half an inch of where physics = simple would put it.
Limits: the robot is held flat (it never tips or climbs); its parts are solid boxes on it and do not stop their own motors (an arm swinging into the HIVE pushes the robot, it does not stall the arm); field parts are simple prisms (a FLOWER is solid all the way up).
Game pieces in the field file: pieceTypes (name, diameterIn, massG, color, bounce), pieces
(where each starts: type, x, y, z), goals (name, x, y, the opening’s height z, radiusIn) and
preload (how many of each type a robot starts with).
Coordinates: inches from the field center, +x to the right and +y away from the drivers (drawn at the bottom), z up from the tiles; headings and tag facing in degrees, counterclockwise from +x for tags.
A field’s 3D model (for the 3D view) is a .glb file in the same frame, in inches, z up. It is not in
the library: models made from FIRST’s field CAD belong in the team’s own robot project
(simulator/fields/), named by field.model in the robot file. Without one, the 3D view builds the field
from the field file. The BIOBUZZ model is made by a script (export_sim_field_glb.py) that is not in this library yet (ask the maintainer)
from the field CAD export.
Cameras
Section titled “Cameras”A webcam in the robot file lets your real camera code run: WebcamTags, or the SDK’s own
VisionPortal and AprilTagProcessor (the simulator has pretend copies of the SDK’s camera classes,
made by the SDK’s own builders).
# name = the name in the robot configuration. Where it sits: inches forward/left/up from the robot's# center on the floor; yaw (+ = turned left) and pitch (+ = tilted up) in degrees. hfov = how wide it# sees, in degrees (Logitech C920 at 640x480: about 70). Defaults: 0, 0, 10, 0, 0, 70.4, 640, 480.camera.front = name=Webcam 1, forward=7, left=0, up=12, yaw=0, pitch=20, hfov=70.4, width=640, height=480What it does: the camera opens about 1 s after the OpMode makes it, then sends 30 pictures a second.
For each picture it works out which of the field file’s AprilTags are in view: inside the picture,
facing the camera (within 60 degrees), and big enough to find at the OpMode’s decimation (about 6.5
pixels across per step of decimation, which matches the FTC docs’ ranges). The tags then become the
SDK’s own detections, the way the SDK makes them: a tag in one of the tag library’s clusters gives a
cluster detection (BIOBUZZ: the SDK 12 library has one cluster per CELL group of 4 tags, “RED SCORING”
and so on); any other tag gives a single-tag detection (without a pose if the library does not know
it). ftcPose uses the SDK’s own formulas, so range, bearing (positive = left) and elevation mean the
same as on the robot.
On the page, the Control Hub panel lists each camera with the tags in its picture and what was found. The 3D view draws its view as a cone on the robot (webcam yellow, Limelight green), and “Camera view” shows the picture from the camera (the tags it sees turn green).
Limelight 3A: the same settings with limelight. (hfov default 54.5, its spec sheet).
limelight.ll = name=limelight, forward=6, left=-3, up=14, yaw=0, pitch=25The hardware map gives your code a Limelight3A (the SDK’s own class, with its network part replaced),
so LimelightTags and direct getLatestResult() code work. About 0.5 s after start() it sends 50
results a second, made as the same JSON a real Limelight sends and read by the SDK’s own LLResult:
isValid(), tx/ty/ta (the biggest tag), and one FiducialResult per tag (a Limelight does not
group tags into clusters) with tx (+ = right), ty (+ = up), ta, pixel position and
getTargetPoseCameraSpace() (metres; x right, y down, z out of the lens). Every pipeline finds
AprilTags; pipelineSwitch changes the index it reports. Not simulated: botpose / MegaTag (no field
map), robot-space poses, Python, color and neural pipelines, snapshots.
Limits: the poses are exact (no noise, blur or lag beyond the Limelight’s reported latency); field
parts do not block the view; tags slanted more than 60 degrees are not found; the webcam’s robotPose
is always null; only the AprilTag finder gets webcam pictures (other VisionProcessors get init() and
nothing more); phone cameras are not simulated; the resolution comes from the robot file, not from
setCameraResolution.
Not simulated
Section titled “Not simulated”Phone cameras, and colour and distance sensors. The Shot and Aim tuners work without a
camera when createCamera() returns null. The physics is a model: real numbers (gains, speeds, shot
tables) must still be found on the robot.
Differences to know: the screen here updates as fast as the OpMode sends (a real Driver Station
redraws about 4 times a second, so a flickering line looks steadier there). STOP works like on a
robot: a sleep() ends at once, and an OpMode that ignores STOP for 3 s is cut off from the hardware
(restart the simulator to run another).
Automatic tests
Section titled “Automatic tests”sim/src/test/.../FinderSafetyTest.java runs whole OpModes with scripted button presses, on every
push. It checks, for example, that help stops the motors, that a stick held through help does not
count, that the Drive Tuner’s push test finds the real wheel size, and that every example starts and
stops with all motors off. Its first run found a real bug: every finder crashed at INIT in v0.7.2.
./gradlew :sim:testDebugUnitTest