Merge "Add 2023 image streamer"
diff --git a/WORKSPACE b/WORKSPACE
index d6a50c7..64f2dee 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -26,6 +26,17 @@
 )
 
 http_archive(
+    name = "aspect_bazel_lib",
+    sha256 = "80897b673c2b506d21f861ae316689aa8abcc3e56947580a41bf9e68ff13af58",
+    strip_prefix = "bazel-lib-1.27.1",
+    url = "https://github.com/aspect-build/bazel-lib/releases/download/v1.27.1/bazel-lib-v1.27.1.tar.gz",
+)
+
+load("@aspect_bazel_lib//lib:repositories.bzl", "aspect_bazel_lib_dependencies")
+
+aspect_bazel_lib_dependencies()
+
+http_archive(
     name = "rules_python",
     patch_args = ["-p1"],
     patches = [
@@ -185,6 +196,10 @@
     "//debian:libtinfo5_arm64.bzl",
     libtinfo5_arm64_debs = "files",
 )
+load(
+    "//debian:xvfb_amd64.bzl",
+    xvfb_amd64_debs = "files",
+)
 load("//debian:packages.bzl", "generate_repositories_for_debs")
 
 generate_repositories_for_debs(rsync_debs)
@@ -237,6 +252,8 @@
 
 generate_repositories_for_debs(libtinfo5_arm64_debs)
 
+generate_repositories_for_debs(xvfb_amd64_debs)
+
 local_repository(
     name = "com_grail_bazel_toolchain",
     path = "third_party/bazel-toolchain",
@@ -750,9 +767,9 @@
     deps = ["@//third_party/allwpilib/wpimath"],
 )
 """,
-    sha256 = "d9d6c48df9318cf106237a44bf7ad95e4092618bc3ab731092e9b733cacb1ffc",
+    sha256 = "7ffc54bf40814a5c101ea3159af15215f15087298cfc2ae65826f987ccf65499",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenixpro/api-cpp/23.0.1/api-cpp-23.0.1-headers.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenixpro/api-cpp/23.0.5/api-cpp-23.0.5-headers.zip",
     ],
 )
 
@@ -774,9 +791,9 @@
     target_compatible_with = ['@//tools/platforms/hardware:roborio'],
 )
 """,
-    sha256 = "3d228fdf8565de5411a739fa2670d4ef5390acb15ceb4d3cbef8c76b5adc7682",
+    sha256 = "1e8a487cb538388de437d04985512533a9dea79e6c56ee0f319c5eb80260fcab",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenixpro/api-cpp/23.0.1/api-cpp-23.0.1-linuxathena.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenixpro/api-cpp/23.0.5/api-cpp-23.0.5-linuxathena.zip",
     ],
 )
 
@@ -789,9 +806,9 @@
     hdrs = glob(['ctre/**/*.h', 'ctre/**/*.hpp']),
 )
 """,
-    sha256 = "74d79bb3e739d9d6b87311656b0530aaefc211952cc647a3d57776a0cee9efce",
+    sha256 = "51c52dfce4c2491887a7b7380e2f17e93a4092b6ac9f60d716738447a8ebedd7",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenixpro/tools/23.0.1/tools-23.0.1-headers.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenixpro/tools/23.0.5/tools-23.0.5-headers.zip",
     ],
 )
 
@@ -813,9 +830,9 @@
     target_compatible_with = ['@//tools/platforms/hardware:roborio'],
 )
 """,
-    sha256 = "1791b35fdf76aa08ad120e4d689d9440bd386542f63f5c44e4047a06e2e05b9a",
+    sha256 = "9fb137321745c1eff63bdcfe486806afb46ede11ea4d4c59461320845698cc1e",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenixpro/tools/23.0.1/tools-23.0.1-linuxathena.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenixpro/tools/23.0.5/tools-23.0.5-linuxathena.zip",
     ],
 )
 
@@ -1213,6 +1230,16 @@
     urls = ["https://www.frc971.org/Build-Dependencies/libtinfo5_arm64.tar.gz"],
 )
 
+http_archive(
+    name = "xvfb_amd64",
+    build_file = "//third_party:xvfb/xvfb.BUILD",
+    patch_cmds = [
+        "unlink usr/bin/X11",
+    ],
+    sha256 = "a7491bf6c47ed0037992fa493f9c25af3ab00a695d706e1fdc122a8b798c0d7c",
+    urls = ["https://www.frc971.org/Build-Dependencies/xvfb_amd64.tar.gz"],
+)
+
 local_repository(
     name = "com_github_nlohmann_json",
     path = "third_party/com_github_nlohmann_json",
diff --git a/aos/BUILD b/aos/BUILD
index b8468bd..0792e67 100644
--- a/aos/BUILD
+++ b/aos/BUILD
@@ -411,9 +411,11 @@
     ],
     data = [
         ":json_to_flatbuffer_fbs_reflection_out",
+        ":json_to_flatbuffer_test_spaces.json",
     ],
     target_compatible_with = ["@platforms//os:linux"],
     deps = [
+        ":flatbuffer_merge",
         ":json_to_flatbuffer",
         ":json_to_flatbuffer_fbs",
         "//aos/testing:googletest",
diff --git a/aos/configuration.cc b/aos/configuration.cc
index 1d5e60a..ad0a489 100644
--- a/aos/configuration.cc
+++ b/aos/configuration.cc
@@ -1355,8 +1355,10 @@
     case LoggerConfig::LOCAL_LOGGER:
       return channel->source_node()->string_view() == node_name;
     case LoggerConfig::LOCAL_AND_REMOTE_LOGGER:
-      CHECK(channel->has_logger_nodes());
-      CHECK_GT(channel->logger_nodes()->size(), 0u);
+      CHECK(channel->has_logger_nodes())
+          << "Missing logger nodes on " << StrippedChannelToString(channel);
+      CHECK_GT(channel->logger_nodes()->size(), 0u)
+          << "Missing logger nodes on " << StrippedChannelToString(channel);
 
       if (channel->source_node()->string_view() == node_name) {
         return true;
@@ -1364,8 +1366,10 @@
 
       [[fallthrough]];
     case LoggerConfig::REMOTE_LOGGER:
-      CHECK(channel->has_logger_nodes());
-      CHECK_GT(channel->logger_nodes()->size(), 0u);
+      CHECK(channel->has_logger_nodes())
+          << "Missing logger nodes on " << StrippedChannelToString(channel);
+      CHECK_GT(channel->logger_nodes()->size(), 0u)
+          << "Missing logger nodes on " << StrippedChannelToString(channel);
       for (const flatbuffers::String *logger_node : *channel->logger_nodes()) {
         if (logger_node->string_view() == node_name) {
           return true;
diff --git a/aos/events/logging/timestamp_plot.cc b/aos/events/logging/timestamp_plot.cc
index 1239eb2..ddc8381 100644
--- a/aos/events/logging/timestamp_plot.cc
+++ b/aos/events/logging/timestamp_plot.cc
@@ -48,9 +48,13 @@
     std::string_view node1, std::string_view node2, bool flip) {
   std::vector<double> samplefile12_t;
   std::vector<double> samplefile12_o;
+  const std::string path = SampleFile(node1, node2);
 
-  const std::string file =
-      aos::util::ReadFileToStringOrDie(SampleFile(node1, node2));
+  if (!aos::util::PathExists(path)) {
+    return {};
+  }
+
+  const std::string file = aos::util::ReadFileToStringOrDie(path);
   bool first = true;
   std::vector<std::string_view> lines = absl::StrSplit(file, '\n');
   samplefile12_t.reserve(lines.size());
@@ -91,7 +95,8 @@
     for (size_t j = 0; j < i; ++j) {
       const std::string_view node1 = nodes[j];
       const std::string_view node2 = nodes[i];
-      if (aos::util::PathExists(SampleFile(node1, node2))) {
+      if (aos::util::PathExists(SampleFile(node1, node2)) ||
+          aos::util::PathExists(SampleFile(node2, node1))) {
         result.emplace_back(node1, node2);
         LOG(INFO) << "Found pairing " << node1 << ", " << node2;
       }
@@ -152,9 +157,13 @@
     std::string_view node1, std::string_view node2, bool flip) {
   std::vector<double> samplefile12_t;
   std::vector<double> samplefile12_o;
+  const std::string path = absl::StrCat("/tmp/timestamp_noncausal_", node1, "_", node2, ".csv");
 
-  const std::string file = aos::util::ReadFileToStringOrDie(
-      absl::StrCat("/tmp/timestamp_noncausal_", node1, "_", node2, ".csv"));
+  if (!aos::util::PathExists(path)) {
+    return {};
+  }
+
+  const std::string file = aos::util::ReadFileToStringOrDie(path);
   bool first = true;
   std::vector<std::string_view> lines = absl::StrSplit(file, '\n');
   samplefile12_t.reserve(lines.size());
@@ -306,9 +315,14 @@
   NodePlotter plotter;
 
   if (FLAGS_all) {
-    for (std::pair<std::string, std::string> ab : NodeConnections()) {
+    const std::vector<std::pair<std::string, std::string>> connections =
+        NodeConnections();
+    for (std::pair<std::string, std::string> ab : connections) {
       plotter.AddNodes(ab.first, ab.second);
     }
+    if (connections.size() == 0) {
+      LOG(WARNING) << "No connections found, is something wrong?";
+    }
   } else {
     CHECK_EQ(argc, 3);
 
diff --git a/aos/json_to_flatbuffer.h b/aos/json_to_flatbuffer.h
index d5e1039..6eab22a 100644
--- a/aos/json_to_flatbuffer.h
+++ b/aos/json_to_flatbuffer.h
@@ -109,10 +109,8 @@
 template <typename T>
 inline FlatbufferDetachedBuffer<T> JsonFileToFlatbuffer(
     const std::string_view path) {
-  std::ifstream t{std::string(path)};
-  std::istream_iterator<char> start(t), end;
-  std::string result(start, end);
-  return FlatbufferDetachedBuffer<T>(JsonToFlatbuffer<T>(result));
+  return FlatbufferDetachedBuffer<T>(
+      JsonToFlatbuffer<T>(util::ReadFileToStringOrDie(path)));
 }
 
 // Parses a file as a binary flatbuffer or dies.
diff --git a/aos/json_to_flatbuffer_test.cc b/aos/json_to_flatbuffer_test.cc
index 6772826..fddb431 100644
--- a/aos/json_to_flatbuffer_test.cc
+++ b/aos/json_to_flatbuffer_test.cc
@@ -1,5 +1,6 @@
 #include "aos/json_to_flatbuffer.h"
 
+#include "aos/flatbuffer_merge.h"
 #include "aos/json_to_flatbuffer_generated.h"
 #include "aos/testing/path.h"
 #include "flatbuffers/minireflect.h"
@@ -301,5 +302,20 @@
                                           ConfigurationTypeTable()));
 }
 
+TEST_F(JsonToFlatbufferTest, SpacedData) {
+  EXPECT_TRUE(CompareFlatBuffer(
+      FlatbufferDetachedBuffer<VectorOfStrings>(
+          JsonToFlatbuffer<VectorOfStrings>(R"json({
+	"str": [
+		"f o o",
+		"b a r",
+		"foo bar",
+		"bar foo"
+	]
+})json")),
+      JsonFileToFlatbuffer<VectorOfStrings>(
+          ArtifactPath("aos/json_to_flatbuffer_test_spaces.json"))));
+}
+
 }  // namespace testing
 }  // namespace aos
diff --git a/aos/json_to_flatbuffer_test_spaces.json b/aos/json_to_flatbuffer_test_spaces.json
new file mode 100644
index 0000000..c864037
--- /dev/null
+++ b/aos/json_to_flatbuffer_test_spaces.json
@@ -0,0 +1,8 @@
+{
+	"str": [
+		"f o o",
+		"b a r",
+		"foo bar",
+		"bar foo"
+	]
+}
diff --git a/aos/network/www/BUILD b/aos/network/www/BUILD
index 03f41a5..81b9e16 100644
--- a/aos/network/www/BUILD
+++ b/aos/network/www/BUILD
@@ -1,5 +1,4 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
 load("//aos:config.bzl", "aos_config")
 
 exports_files(["styles.css"])
@@ -13,7 +12,7 @@
     visibility = ["//visibility:public"],
 )
 
-ts_library(
+ts_project(
     name = "proxy",
     srcs = [
         "config_handler.ts",
@@ -31,7 +30,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "main",
     srcs = [
         "main.ts",
@@ -68,7 +67,7 @@
     visibility = ["//aos:__subpackages__"],
 )
 
-ts_library(
+ts_project(
     name = "reflection_test_main",
     srcs = [
         "reflection_test_main.ts",
@@ -83,7 +82,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "reflection_ts",
     srcs = ["reflection.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -96,7 +95,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "colors",
     srcs = [
         "colors.ts",
@@ -105,7 +104,7 @@
     visibility = ["//visibility:public"],
 )
 
-ts_library(
+ts_project(
     name = "plotter",
     srcs = [
         "plotter.ts",
@@ -115,7 +114,7 @@
     deps = [":colors"],
 )
 
-ts_library(
+ts_project(
     name = "aos_plotter",
     srcs = [
         "aos_plotter.ts",
@@ -134,7 +133,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "demo_plot",
     srcs = [
         "demo_plot.ts",
diff --git a/aos/network/www/aos_plotter.ts b/aos/network/www/aos_plotter.ts
index cd2e131..13bf343 100644
--- a/aos/network/www/aos_plotter.ts
+++ b/aos/network/www/aos_plotter.ts
@@ -23,11 +23,11 @@
 // The demo_plot.ts script has a basic example of using this library, with all
 // the required boilerplate, as well as some extra examples about how to
 // add axis labels and the such.
-import {Channel, Configuration} from 'org_frc971/aos/configuration_generated';
-import {Line, Plot, Point} from 'org_frc971/aos/network/www/plotter';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
-import {SubscriberRequest, ChannelRequest, TransferMethod} from 'org_frc971/aos/network/web_proxy_generated';
-import {Parser, Table} from 'org_frc971/aos/network/www/reflection'
+import {Channel, Configuration} from '../../configuration_generated';
+import {Line, Plot, Point} from './plotter';
+import {Connection} from './proxy';
+import {SubscriberRequest, ChannelRequest, TransferMethod} from '../web_proxy_generated';
+import {Parser, Table} from './reflection'
 import {Schema} from 'flatbuffers_reflection/reflection_generated';
 import {ByteBuffer} from 'flatbuffers';
 
diff --git a/aos/network/www/config_handler.ts b/aos/network/www/config_handler.ts
index a7c2149..fba7be3 100644
--- a/aos/network/www/config_handler.ts
+++ b/aos/network/www/config_handler.ts
@@ -1,6 +1,6 @@
 import {ByteBuffer} from 'flatbuffers';
-import {Configuration} from 'org_frc971/aos/configuration_generated';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
+import {Configuration} from '../../configuration_generated';
+import {Connection} from './proxy';
 
 import {Parser, Table} from './reflection'
 
diff --git a/aos/network/www/demo_plot.ts b/aos/network/www/demo_plot.ts
index ecd4da6..b586411 100644
--- a/aos/network/www/demo_plot.ts
+++ b/aos/network/www/demo_plot.ts
@@ -12,9 +12,9 @@
 // This example shows how to:
 // (a) Make use of the AosPlotter to plot a shmem message as a time-series.
 // (b) Define your own custom plot with whatever data you want.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {Plot, Point} from 'org_frc971/aos/network/www/plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
+import {AosPlotter} from './aos_plotter';
+import {Plot, Point} from './plotter';
+import * as proxy from './proxy';
 
 import Connection = proxy.Connection;
 
diff --git a/aos/network/www/ping_handler.ts b/aos/network/www/ping_handler.ts
index 60e7c4a..224e432 100644
--- a/aos/network/www/ping_handler.ts
+++ b/aos/network/www/ping_handler.ts
@@ -1,4 +1,4 @@
-import {Ping} from 'org_frc971/aos/events/ping_generated';
+import {Ping} from '../../events/ping_generated';
 import {ByteBuffer} from 'flatbuffers';
 
 export function HandlePing(data: Uint8Array) {
diff --git a/aos/network/www/plotter.ts b/aos/network/www/plotter.ts
index 17bffa3..536322f 100644
--- a/aos/network/www/plotter.ts
+++ b/aos/network/www/plotter.ts
@@ -1,4 +1,5 @@
-import * as Colors from 'org_frc971/aos/network/www/colors';
+import * as Colors from './colors';
+
 // Multiplies all the values in the provided array by scale.
 function scaleVec(vec: number[], scale: number): number[] {
   const scaled: number[] = [];
diff --git a/aos/network/www/proxy.ts b/aos/network/www/proxy.ts
index 0bda8b9..99a9a18 100644
--- a/aos/network/www/proxy.ts
+++ b/aos/network/www/proxy.ts
@@ -1,6 +1,6 @@
 import {Builder, ByteBuffer, Offset} from 'flatbuffers';
-import {Channel as ChannelFb, Configuration} from 'org_frc971/aos/configuration_generated';
-import {ChannelRequest as ChannelRequestFb, ChannelState, MessageHeader, Payload, SdpType, SubscriberRequest, TransferMethod, WebSocketIce, WebSocketMessage, WebSocketSdp} from 'org_frc971/aos/network/web_proxy_generated';
+import {Channel as ChannelFb, Configuration} from '../../configuration_generated';
+import {ChannelRequest as ChannelRequestFb, ChannelState, MessageHeader, Payload, SdpType, SubscriberRequest, TransferMethod, WebSocketIce, WebSocketMessage, WebSocketSdp} from '../web_proxy_generated';
 import {Schema} from 'flatbuffers_reflection/reflection_generated';
 
 // There is one handler for each DataChannel, it maintains the state of
diff --git a/aos/network/www/reflection_test_main.ts b/aos/network/www/reflection_test_main.ts
index 8fb041e..092c9c2 100644
--- a/aos/network/www/reflection_test_main.ts
+++ b/aos/network/www/reflection_test_main.ts
@@ -1,6 +1,6 @@
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {Configuration} from 'org_frc971/aos/configuration_generated'
-import {BaseType, Configuration as TestTable, FooStruct, Location, Map, VectorOfStrings, VectorOfVectorOfString} from 'org_frc971/aos/json_to_flatbuffer_generated'
+import {Configuration} from '../../configuration_generated'
+import {BaseType, Configuration as TestTable, FooStruct, Location, Map, VectorOfStrings, VectorOfVectorOfString} from '../../json_to_flatbuffer_generated'
 
 import {Connection} from './proxy';
 import {Parser, Table} from './reflection'
diff --git a/aos/starter/BUILD b/aos/starter/BUILD
index 5690bda..8a30408 100644
--- a/aos/starter/BUILD
+++ b/aos/starter/BUILD
@@ -1,6 +1,8 @@
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("//aos:config.bzl", "aos_config")
 
+exports_files(["roborio_irq_config.json"])
+
 # This target is everything which should get deployed to the robot.
 filegroup(
     name = "starter",
diff --git a/aos/starter/irq_affinity.cc b/aos/starter/irq_affinity.cc
index 3f32ec9..5c70e9e 100644
--- a/aos/starter/irq_affinity.cc
+++ b/aos/starter/irq_affinity.cc
@@ -14,6 +14,8 @@
 
 DEFINE_string(user, "",
               "Starter runs as though this user ran a SUID binary if set.");
+DEFINE_string(irq_config, "rockpi_config.json",
+              "File path of rockpi configuration");
 
 namespace aos {
 
@@ -265,117 +267,10 @@
   aos::FlatbufferDetachedBuffer<aos::Configuration> config =
       aos::configuration::ReadConfig(FLAGS_config);
 
-  // TODO(austin): File instead of hard-coded JSON.
   aos::FlatbufferDetachedBuffer<aos::starter::IrqAffinityConfig>
       irq_affinity_config =
-          aos::JsonToFlatbuffer<aos::starter::IrqAffinityConfig>(
-              R"json({
-  "irqs": [
-    {
-      "name": "ttyS2",
-      "affinity": [1]
-    },
-    {
-      "name": "dw-mci",
-      "affinity": [1]
-    },
-    {
-      "name": "mmc1",
-      "affinity": [1]
-    },
-    {
-      "name": "rkisp1",
-      "affinity": [2]
-    },
-    {
-      "name": "ff3c0000.i2c",
-      "affinity": [2]
-    },
-    {
-      "name": "ff3d0000.i2c",
-      "affinity": [2]
-    },
-    {
-      "name": "ff6e0000.dma-controller",
-      "affinity": [0]
-    },
-    {
-      "name": "ff1d0000.spi",
-      "affinity": [0]
-    },
-    {
-      "name": "eth0",
-      "affinity": [1]
-    }
-  ],
-  "kthreads": [
-    {
-      "name": "irq/*-ff940000.hdmi",
-      "scheduler": "SCHEDULER_OTHER"
-    },
-    {
-      "name": "irq/*-rockchip_usb2phy",
-      "scheduler": "SCHEDULER_OTHER"
-    },
-    {
-      "name": "irq/*-mmc0",
-      "scheduler": "SCHEDULER_OTHER"
-    },
-    {
-      "name": "irq/*-mmc1",
-      "scheduler": "SCHEDULER_OTHER"
-    },
-    {
-      "name": "irq/*-fe320000.mmc cd",
-      "scheduler": "SCHEDULER_OTHER"
-    },
-    {
-      "name": "irq/*-vc4 crtc",
-      "scheduler": "SCHEDULER_OTHER"
-    },
-    {
-      "name": "irq/*-rkisp1",
-      "scheduler": "SCHEDULER_FIFO",
-      "priority": 60,
-      "affinity": [2]
-    },
-    {
-      "name": "irq/*-ff3c0000.i2c",
-      "scheduler": "SCHEDULER_FIFO",
-      "priority": 51,
-      "affinity": [2]
-    },
-    {
-      "name": "irq/*-adis16505",
-      "scheduler": "SCHEDULER_FIFO",
-      "priority": 59,
-      "affinity": [0]
-    },
-    {
-      "name": "irq/*-ff6e0000.dma-controller",
-      "scheduler": "SCHEDULER_FIFO",
-      "priority": 59,
-      "affinity": [0]
-    },
-    {
-      "name": "spi0",
-      "scheduler": "SCHEDULER_FIFO",
-      "priority": 57,
-      "affinity": [0]
-    },
-    {
-      "name": "irq/*-eth0",
-      "scheduler": "SCHEDULER_FIFO",
-      "priority": 10,
-      "affinity": [1]
-    },
-    {
-      "name": "irq/*-rockchip_thermal",
-      "scheduler": "SCHEDULER_FIFO",
-      "priority": 1
-    }
-  ]
-})json");
+          aos::JsonFileToFlatbuffer<aos::starter::IrqAffinityConfig>(
+              FLAGS_irq_config);
 
   aos::ShmEventLoop shm_event_loop(&config.message());
 
diff --git a/aos/starter/roborio_irq_config.json b/aos/starter/roborio_irq_config.json
new file mode 100644
index 0000000..d6ea0db
--- /dev/null
+++ b/aos/starter/roborio_irq_config.json
@@ -0,0 +1,24 @@
+{
+  "irqs": [
+    {
+      "name": "e0002000.usb",
+      "affinity": [1]
+    },
+    {
+      "name": "mmc0",
+      "affinity": [0]
+    },
+    {
+      "name": "eth0",
+      "affinity": [0]
+    }
+  ],
+  "kthreads": [
+    {
+      "name": "irq/*-e0002000",
+      "scheduler": "SCHEDULER_FIFO",
+      "priority": 45,
+      "affinity": [1]
+    }
+  ]
+}
diff --git a/aos/starter/starter.sh b/aos/starter/starter.sh
index d4281c5..face963 100755
--- a/aos/starter/starter.sh
+++ b/aos/starter/starter.sh
@@ -13,8 +13,7 @@
 elif [[ "$(hostname)" == "pi-"* ]]; then
   # We have systemd configured to handle restarting, so just exec.
   export PATH="${PATH}:/home/pi/bin"
-  rm -rf /dev/shm/aos
-  exec starterd --user=pi
+  exec starterd --user=pi --purge_shm_base
 else
   ROBOT_CODE="${HOME}/bin"
 fi
@@ -22,6 +21,5 @@
 cd "${ROBOT_CODE}"
 export PATH="${PATH}:${ROBOT_CODE}"
 while true; do
-  rm -rf /dev/shm/aos
-  starterd 2>&1
+  starterd --purge_shm_base 2>&1
 done
diff --git a/aos/starter/starterd.cc b/aos/starter/starterd.cc
index 54f1bb4..19117de 100644
--- a/aos/starter/starterd.cc
+++ b/aos/starter/starterd.cc
@@ -2,15 +2,25 @@
 #include <sys/types.h>
 
 #include "aos/init.h"
+#include "aos/starter/starterd_lib.h"
+#include "aos/util/file.h"
 #include "gflags/gflags.h"
-#include "starterd_lib.h"
 
 DEFINE_string(config, "aos_config.json", "File path of aos configuration");
 DEFINE_string(user, "",
               "Starter runs as though this user ran a SUID binary if set.");
 
+DECLARE_string(shm_base);
+DEFINE_bool(purge_shm_base, false,
+            "If true, delete everything in --shm_base before starting.");
+
 int main(int argc, char **argv) {
   aos::InitGoogle(&argc, &argv);
+
+  if (FLAGS_purge_shm_base) {
+    aos::util::UnlinkRecursive(FLAGS_shm_base);
+  }
+
   if (!FLAGS_user.empty()) {
     uid_t uid;
     uid_t gid;
diff --git a/aos/util/error_counter.h b/aos/util/error_counter.h
index cf6cb7f..9fbe242 100644
--- a/aos/util/error_counter.h
+++ b/aos/util/error_counter.h
@@ -67,5 +67,40 @@
  private:
   flatbuffers::Vector<flatbuffers::Offset<Count>> *vector_ = nullptr;
 };
+
+// The ArrayErrorCounter serves the same purpose as the ErrorCounter class,
+// except that:
+// (a) It owns its own memory, rather than modifying a flatbuffer in-place.
+// (b) Because of this, the user has greater flexibility in choosing when to
+//     reset the error counters.
+template <typename Error, typename Count>
+class ArrayErrorCounter {
+ public:
+  static constexpr size_t kNumErrors = ErrorCounter<Error, Count>::kNumErrors;
+  ArrayErrorCounter() { ResetCounts(); }
+
+  flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<Count>>>
+  PopulateCounts(flatbuffers::FlatBufferBuilder *fbb) {
+    const flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<Count>>>
+        offset = ErrorCounter<Error, Count>::Initialize(fbb);
+    flatbuffers::Vector<flatbuffers::Offset<Count>> *vector =
+        flatbuffers::GetMutableTemporaryPointer(*fbb, offset);
+    for (size_t ii = 0; ii < kNumErrors; ++ii) {
+      vector->GetMutableObject(ii)->mutate_count(error_counts_.at(ii));
+    }
+    return offset;
+  }
+
+  void IncrementError(Error error) {
+    DCHECK_LT(static_cast<size_t>(error), error_counts_.size());
+    error_counts_.at(static_cast<size_t>(error))++;
+  }
+
+  // Sets all the error counts to zero.
+  void ResetCounts() { error_counts_.fill(0); }
+
+ private:
+  std::array<size_t, kNumErrors> error_counts_;
+};
 }  // namespace aos::util
 #endif  // AOS_UTIL_ERROR_COUNTER_H_
diff --git a/aos/util/error_counter_test.cc b/aos/util/error_counter_test.cc
index 2166cea..567d71d 100644
--- a/aos/util/error_counter_test.cc
+++ b/aos/util/error_counter_test.cc
@@ -34,4 +34,45 @@
   EXPECT_EQ(0u, message.message().error_counts()->Get(0)->count());
   EXPECT_EQ(0u, message.message().error_counts()->Get(1)->count());
 }
+
+// Tests the ArrayErrorCounter
+TEST(ErrorCounterTest, ARrayErrorCounter) {
+  ArrayErrorCounter<aos::timing::SendError, aos::timing::SendErrorCount>
+      counter;
+  flatbuffers::FlatBufferBuilder fbb;
+  fbb.ForceDefaults(true);
+  counter.IncrementError(aos::timing::SendError::MESSAGE_SENT_TOO_FAST);
+  counter.IncrementError(aos::timing::SendError::MESSAGE_SENT_TOO_FAST);
+  counter.IncrementError(aos::timing::SendError::INVALID_REDZONE);
+  {
+    const flatbuffers::Offset<
+        flatbuffers::Vector<flatbuffers::Offset<aos::timing::SendErrorCount>>>
+        counts_offset = counter.PopulateCounts(&fbb);
+    aos::timing::Sender::Builder builder(fbb);
+    builder.add_error_counts(counts_offset);
+    fbb.Finish(builder.Finish());
+    aos::FlatbufferDetachedBuffer<aos::timing::Sender> message = fbb.Release();
+    ASSERT_EQ(2u, message.message().error_counts()->size());
+    EXPECT_EQ(aos::timing::SendError::MESSAGE_SENT_TOO_FAST,
+              message.message().error_counts()->Get(0)->error());
+    EXPECT_EQ(2u, message.message().error_counts()->Get(0)->count());
+    EXPECT_EQ(aos::timing::SendError::INVALID_REDZONE,
+              message.message().error_counts()->Get(1)->error());
+    EXPECT_EQ(1u, message.message().error_counts()->Get(1)->count());
+  }
+
+  counter.ResetCounts();
+  {
+    const flatbuffers::Offset<
+        flatbuffers::Vector<flatbuffers::Offset<aos::timing::SendErrorCount>>>
+        counts_offset = counter.PopulateCounts(&fbb);
+    aos::timing::Sender::Builder builder(fbb);
+    builder.add_error_counts(counts_offset);
+    fbb.Finish(builder.Finish());
+    aos::FlatbufferDetachedBuffer<aos::timing::Sender> message = fbb.Release();
+    ASSERT_EQ(2u, message.message().error_counts()->size());
+    EXPECT_EQ(0u, message.message().error_counts()->Get(0)->count());
+    EXPECT_EQ(0u, message.message().error_counts()->Get(1)->count());
+  }
+}
 }  // namespace aos::util::testing
diff --git a/build_tests/BUILD b/build_tests/BUILD
index ea05c0b..bfdd76e 100644
--- a/build_tests/BUILD
+++ b/build_tests/BUILD
@@ -1,10 +1,9 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_google_protobuf//:protobuf.bzl", "cc_proto_library")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_go_library", "flatbuffer_py_library")
 load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
 load("//tools/build_rules:apache.bzl", "apache_wrapper")
 load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("@npm//@bazel/concatjs:index.bzl", "karma_web_test_suite")
 load("//tools/build_rules:autocxx.bzl", "autocxx_library")
 
 cc_test(
@@ -161,28 +160,11 @@
     deps = [":hello_lib"],
 )
 
-ts_library(
+ts_project(
     name = "build_tests_ts",
     srcs = ["basic.ts"],
 )
 
-ts_library(
-    name = "ts_test",
-    testonly = True,
-    srcs = ["basic_test.ts"],
-    deps = [
-        ":build_tests_ts",
-        "@npm//@types/jasmine",
-    ],
-)
-
-karma_web_test_suite(
-    name = "karma",
-    testonly = True,
-    target_compatible_with = ["@platforms//os:linux"],
-    deps = [":ts_test"],
-)
-
 rust_library(
     name = "rust_in_cc_rs",
     srcs = ["rust_in_cc.rs"],
diff --git a/debian/BUILD b/debian/BUILD
index ca6d134..e40bf8f 100644
--- a/debian/BUILD
+++ b/debian/BUILD
@@ -82,6 +82,10 @@
     ":libtinfo5_arm64.bzl",
     libtinfo5_arm64_debs = "files",
 )
+load(
+    ":xvfb_amd64.bzl",
+    xvfb_amd64_debs = "files",
+)
 load(":packages.bzl", "download_packages", "generate_deb_tarball")
 
 package(default_visibility = ["//visibility:public"])
@@ -423,6 +427,22 @@
     target_compatible_with = ["@platforms//os:linux"],
 )
 
+download_packages(
+    name = "download_xvfb_packages",
+    excludes = [
+        "libglx-mesa0",
+    ],
+    packages = [
+        "xvfb",
+    ],
+)
+
+generate_deb_tarball(
+    name = "xvfb_amd64",
+    files = xvfb_amd64_debs,
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
 exports_files([
     "ssh_wrapper.sh",
     "curl.BUILD",
diff --git a/debian/xvfb_amd64.bzl b/debian/xvfb_amd64.bzl
new file mode 100644
index 0000000..faa9046
--- /dev/null
+++ b/debian/xvfb_amd64.bzl
@@ -0,0 +1,33 @@
+files = {
+    "libaudit-common_3.0-2_all.deb": "0d52f4826a57aea13cea1a85bfae354024c7b2f7b95e39cd1ce225e4db27d0f6",
+    "libaudit1_3.0-2_amd64.deb": "e3aa1383e387dc077a1176f7f3cbfdbc084bcc270a8938f598d5cb119773b268",
+    "libbrotli1_1.0.9-2+b2_amd64.deb": "65ca7d8b03e9dac09c5d544a89dd52d1aeb74f6a19583d32e4ff5f0c77624c24",
+    "libcap-ng0_0.7.9-2.2+b1_amd64.deb": "d34e29769b8ef23e9b9920814afb7905b8ee749db0814e6a8d937ccc4f309830",
+    "libfontenc1_1.1.4-1_amd64.deb": "1d0aa6ea16a34a8de1ea170360c4cb699f3239aeddb292df2d2c4eb6e835de4b",
+    "libfreetype6_2.10.4+dfsg-1+deb11u1_amd64.deb": "b21cfdd12adf6cac4af320c2485fb62a8a5edc6f9768bc2288fd686f4fa6dfdf",
+    "libgcrypt20_1.8.7-6_amd64.deb": "7a2e0eef8e0c37f03f3a5fcf7102a2e3dc70ba987f696ab71949f9abf36f35ef",
+    "libgl1_1.3.2-1_amd64.deb": "f300f9610b5f05f1ce566c4095f1bf2170e512ac5d201c40d895b8fce29dec98",
+    "libglvnd0_1.3.2-1_amd64.deb": "52a4464d181949f5ed8f7e55cca67ba2739f019e93fcfa9d14e8d65efe98fffc",
+    "libglx0_1.3.2-1_amd64.deb": "cb642200f7e28e6dbb4075110a0b441880eeec35c8a00a2198c59c53309e5e17",
+    "libgpg-error0_1.38-2_amd64.deb": "16a507fb20cc58b5a524a0dc254a9cb1df02e1ce758a2d8abde0bc4a3c9b7c26",
+    "libice6_1.0.10-1_amd64.deb": "452796e565c9d42386bd59990000ae9c37d85e142e00ee2b14df0787e2bbf970",
+    "liblz4-1_1.9.3-2_amd64.deb": "79ac6e9ca19c483f2e8effcc3401d723dd9dbb3a4ae324714de802adb21a8117",
+    "libpixman-1-0_0.40.0-1.1~deb11u1_amd64.deb": "1d0b392aec96fc3dc9c9cffa1241f4abfa7be0282f3451fce72492a934477c3e",
+    "libpng16-16_1.6.37-3_amd64.deb": "7d5336af395d1f658d0e66d74d0e1f4c632028750e7e04314d1a650e0317f3d6",
+    "libsm6_1.2.3-1_amd64.deb": "22a420890489023346f30fecef14ea900a0788e7bf959ef826aabb83944fccfb",
+    "libsystemd0_247.3-7+deb11u1_amd64.deb": "0bce44fd32e9fa18b68cb89f4010939b9984b9782db2d1985b041fc96e9a02b8",
+    "libunwind8_1.3.2-2_amd64.deb": "a8cc1181a479375aeb603cfe748cc19dc3a700a47ffdcb09fa025fe02b0c73bf",
+    "libuuid1_2.36.1-8+deb11u1_amd64.deb": "31250af4dd3b7d1519326a9a6764d1466a93d8f498cf6545058761ebc38b2823",
+    "libxaw7_1.0.13-1.1_amd64.deb": "fa30777b3c23421d18ed8cd817df7b4dd29d4532d2af00f9d6d449f4ad00a6d4",
+    "libxfont2_2.0.4-1_amd64.deb": "db5b67a3fc9f3a6adef313cb977a5a2480d751424cca49b286799b4bb98d8694",
+    "libxkbfile1_1.1.0-1_amd64.deb": "7c58d9986f918b71568ad83dbb6f4ab22c185f243461d41acee920cc5e13d347",
+    "libxmu6_1.1.2-2+b3_amd64.deb": "912a1bfb3416f18193824a4ffc5fe8a3a6e6781d9f8e50e26400dd36a7ca5bd0",
+    "libxpm4_3.5.12-1_amd64.deb": "49e64f0923cdecb2aaf6c93f176c25f63b841da2a501651ae23070f998967aa7",
+    "libxt6_1.2.0-1_amd64.deb": "1b2014704c8fb393aa9797da7c6de248f2bbd89eec8dee07bfecd7f2f85cff4d",
+    "lsb-base_11.1.0_all.deb": "89ed6332074d827a65305f9a51e591dff20641d61ff5e11f4e1822a9987e96fe",
+    "x11-common_7.7+22_all.deb": "5d1c3287826f60c3a82158b803b9c0489b8aad845ca23a53a982eba3dbb82aa3",
+    "x11-xkb-utils_7.7+5_amd64.deb": "fffdfdaec5533c33ddfdf25657e95e62bfd0348f375d0594d1727630059d1d3d",
+    "xkb-data_2.29-2_all.deb": "9122cccc67e6b3c3aef2fa9c50ef9d793a12f951c76698a02b1f4ceb9e3634e5",
+    "xserver-common_1.20.11-1+deb11u5_all.deb": "2cf7af2824d158159e2088f667c789ee4b6a5f1f6f776cbe4f867fa1ae5679b5",
+    "xvfb_1.20.11-1+deb11u5_amd64.deb": "3cefb98b07557a68ef0aa521fe34c7ef5755ea6f872141b05d68981433158f6e",
+}
diff --git a/frc971/analysis/BUILD b/frc971/analysis/BUILD
index c1ae252..36c450a 100644
--- a/frc971/analysis/BUILD
+++ b/frc971/analysis/BUILD
@@ -1,5 +1,4 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
 load("//aos:config.bzl", "aos_config")
@@ -33,7 +32,7 @@
     deps = ["//aos:configuration_fbs_python"],
 )
 
-ts_library(
+ts_project(
     name = "plot_index",
     srcs = ["plot_index.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -127,7 +126,7 @@
     target_compatible_with = ["@platforms//os:linux"],
 )
 
-ts_library(
+ts_project(
     name = "plot_data_utils",
     srcs = ["plot_data_utils.ts"],
     visibility = ["//visibility:public"],
diff --git a/frc971/analysis/cpp_plot/BUILD b/frc971/analysis/cpp_plot/BUILD
index d6e74c3..7f34afe 100644
--- a/frc971/analysis/cpp_plot/BUILD
+++ b/frc971/analysis/cpp_plot/BUILD
@@ -1,9 +1,8 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
 
 package(default_visibility = ["//visibility:public"])
 
-ts_library(
+ts_project(
     name = "cpp_plot",
     srcs = ["cpp_plot.ts"],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/frc971/analysis/cpp_plot/cpp_plot.ts b/frc971/analysis/cpp_plot/cpp_plot.ts
index a704e89..08e2b1c 100644
--- a/frc971/analysis/cpp_plot/cpp_plot.ts
+++ b/frc971/analysis/cpp_plot/cpp_plot.ts
@@ -1,7 +1,7 @@
 // Plotter for the C++ in-process plotter.
-import {Configuration} from 'org_frc971/aos/configuration_generated';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
-import {plotData} from 'org_frc971/frc971/analysis/plot_data_utils';
+import {Configuration} from '../../../aos/configuration_generated';
+import {Connection} from '../../../aos/network/www/proxy';
+import {plotData} from '../plot_data_utils';
 
 const rootDiv = document.createElement('div');
 rootDiv.classList.add('aos_cpp_plot');
diff --git a/frc971/analysis/in_process_plotter.cc b/frc971/analysis/in_process_plotter.cc
index 33d999e..b327c54 100644
--- a/frc971/analysis/in_process_plotter.cc
+++ b/frc971/analysis/in_process_plotter.cc
@@ -80,7 +80,7 @@
 
 void Plotter::AddLine(const std::vector<double> &x,
                       const std::vector<double> &y, LineOptions options) {
-  CHECK_EQ(x.size(), y.size());
+  CHECK_EQ(x.size(), y.size()) << ": " << options.label;
   CHECK(!position_.IsNull())
       << "You must call AddFigure() before calling AddLine().";
 
diff --git a/frc971/analysis/plot_data_utils.ts b/frc971/analysis/plot_data_utils.ts
index 1362a09..25131fb 100644
--- a/frc971/analysis/plot_data_utils.ts
+++ b/frc971/analysis/plot_data_utils.ts
@@ -1,10 +1,10 @@
 // Provides a plot which handles plotting the plot defined by a
 // frc971.analysis.Plot message.
-import {Plot as PlotFb} from 'org_frc971/frc971/analysis/plot_data_generated';
-import {MessageHandler, TimestampedMessage} from 'org_frc971/aos/network/www/aos_plotter';
+import {Plot as PlotFb} from './plot_data_generated';
+import {MessageHandler, TimestampedMessage} from '../../aos/network/www/aos_plotter';
 import {ByteBuffer} from 'flatbuffers';
-import {Plot, Point} from 'org_frc971/aos/network/www/plotter';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
+import {Plot, Point} from '../../aos/network/www/plotter';
+import {Connection} from '../../aos/network/www/proxy';
 import {Schema} from 'flatbuffers_reflection/reflection_generated';
 
 export function plotData(conn: Connection, parentDiv: Element) {
diff --git a/frc971/analysis/plot_index.ts b/frc971/analysis/plot_index.ts
index af9bd73..ab00b99 100644
--- a/frc971/analysis/plot_index.ts
+++ b/frc971/analysis/plot_index.ts
@@ -20,41 +20,41 @@
 // each robot year, and may even end up allowing plots to be specified solely
 // using JSON rather than requiring people to write a script just to create
 // a plot.
-import {Configuration} from 'org_frc971/aos/configuration_generated';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
-import {plotImu} from 'org_frc971/frc971/wpilib/imu_plotter';
-import {plotDrivetrain} from 'org_frc971/frc971/control_loops/drivetrain/drivetrain_plotter';
-import {plotSpline} from 'org_frc971/frc971/control_loops/drivetrain/spline_plotter';
-import {plotDownEstimator} from 'org_frc971/frc971/control_loops/drivetrain/down_estimator_plotter';
+import {Configuration} from '../../aos/configuration_generated';
+import {Connection} from '../../aos/network/www/proxy';
+import {plotImu} from '../wpilib/imu_plotter';
+import {plotDrivetrain} from '../control_loops/drivetrain/drivetrain_plotter';
+import {plotSpline} from '../control_loops/drivetrain/spline_plotter';
+import {plotDownEstimator} from '../control_loops/drivetrain/down_estimator_plotter';
 import {plotRobotState} from
-    'org_frc971/frc971/control_loops/drivetrain/robot_state_plotter'
+    '../control_loops/drivetrain/robot_state_plotter'
 import {plotFinisher as plot2020Finisher} from
-    'org_frc971/y2020/control_loops/superstructure/finisher_plotter'
+    '../../y2020/control_loops/superstructure/finisher_plotter'
 import {plotTurret as plot2020Turret} from
-    'org_frc971/y2020/control_loops/superstructure/turret_plotter'
+    '../../y2020/control_loops/superstructure/turret_plotter'
 import {plotLocalizer as plot2020Localizer} from
-    'org_frc971/y2020/control_loops/drivetrain/localizer_plotter'
+    '../../y2020/control_loops/drivetrain/localizer_plotter'
 import {plotAccelerator as plot2020Accelerator} from
-    'org_frc971/y2020/control_loops/superstructure/accelerator_plotter'
+    '../../y2020/control_loops/superstructure/accelerator_plotter'
 import {plotHood as plot2020Hood} from
-    'org_frc971/y2020/control_loops/superstructure/hood_plotter'
+    '../../y2020/control_loops/superstructure/hood_plotter'
 import {plotSuperstructure as plot2021Superstructure} from
-    'org_frc971/y2021_bot3/control_loops/superstructure/superstructure_plotter';
+    '../../y2021_bot3/control_loops/superstructure/superstructure_plotter';
 import {plotTurret as plot2022Turret} from
-    'org_frc971/y2022/control_loops/superstructure/turret_plotter'
+    '../../y2022/control_loops/superstructure/turret_plotter'
 import {plotSuperstructure as plot2022Superstructure} from
-    'org_frc971/y2022/control_loops/superstructure/superstructure_plotter'
+    '../../y2022/control_loops/superstructure/superstructure_plotter'
 import {plotCatapult as plot2022Catapult} from
-    'org_frc971/y2022/control_loops/superstructure/catapult_plotter'
+    '../../y2022/control_loops/superstructure/catapult_plotter'
 import {plotIntakeFront as plot2022IntakeFront, plotIntakeBack as plot2022IntakeBack} from
-    'org_frc971/y2022/control_loops/superstructure/intake_plotter'
+    '../../y2022/control_loops/superstructure/intake_plotter'
 import {plotClimber as plot2022Climber} from
-    'org_frc971/y2022/control_loops/superstructure/climber_plotter'
+    '../../y2022/control_loops/superstructure/climber_plotter'
 import {plotLocalizer as plot2022Localizer} from
-    'org_frc971/y2022/localizer/localizer_plotter'
+    '../../y2022/localizer/localizer_plotter'
 import {plotVision as plot2022Vision} from
-    'org_frc971/y2022/vision/vision_plotter'
-import {plotDemo} from 'org_frc971/aos/network/www/demo_plot';
+    '../../y2022/vision/vision_plotter'
+import {plotDemo} from '../../aos/network/www/demo_plot';
 
 const rootDiv = document.createElement('div');
 rootDiv.style.width = '100%';
diff --git a/frc971/constants/BUILD b/frc971/constants/BUILD
index c17dc31..3a7e73a 100644
--- a/frc971/constants/BUILD
+++ b/frc971/constants/BUILD
@@ -45,6 +45,7 @@
     ],
     data = [
         "//frc971/constants/testdata:aos_config",
+        "//frc971/constants/testdata:syntax_error.json",
         "//frc971/constants/testdata:test_constants.json",
     ],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/frc971/constants/constants_sender_test.cc b/frc971/constants/constants_sender_test.cc
index 1441767..512cb68 100644
--- a/frc971/constants/constants_sender_test.cc
+++ b/frc971/constants/constants_sender_test.cc
@@ -113,11 +113,11 @@
       ({
         ConstantSender<testdata::ConstantsData, testdata::ConstantsList>
             test_syntax(constants_sender_event_loop_.get(),
-                        "frc971/constants/testdata/syntaxerror.json", 971,
+                        "frc971/constants/testdata/syntax_error.json", 971,
                         "/constants");
         event_loop_factory_.RunFor(std::chrono::seconds(1));
       }),
-      "Error on line 0");
+      "Invalid field name");
 }
 
 }  // namespace testing
diff --git a/frc971/constants/testdata/BUILD b/frc971/constants/testdata/BUILD
index 04cf8d3..a20264c 100644
--- a/frc971/constants/testdata/BUILD
+++ b/frc971/constants/testdata/BUILD
@@ -1,7 +1,10 @@
 load("//aos:config.bzl", "aos_config")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 
-exports_files(["test_constants.json"])
+exports_files([
+    "test_constants.json",
+    "syntax_error.json",
+])
 
 flatbuffer_cc_library(
     name = "constants_list_fbs",
diff --git a/frc971/control_loops/BUILD b/frc971/control_loops/BUILD
index ee53c4a..a62b45f 100644
--- a/frc971/control_loops/BUILD
+++ b/frc971/control_loops/BUILD
@@ -354,6 +354,9 @@
         "fixed_quadrature.h",
     ],
     target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        "@org_tuxfamily_eigen//:eigen",
+    ],
 )
 
 cc_test(
diff --git a/frc971/control_loops/double_jointed_arm/BUILD b/frc971/control_loops/double_jointed_arm/BUILD
new file mode 100644
index 0000000..c7fcefc
--- /dev/null
+++ b/frc971/control_loops/double_jointed_arm/BUILD
@@ -0,0 +1,121 @@
+cc_library(
+    name = "trajectory",
+    srcs = [
+        "trajectory.cc",
+    ],
+    hdrs = [
+        "trajectory.h",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":dynamics",
+        "//aos/logging",
+        "//frc971/control_loops:dlqr",
+        "//frc971/control_loops:jacobian",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_test(
+    name = "trajectory_test",
+    srcs = [
+        "trajectory_test.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":demo_path",
+        ":dynamics",
+        ":ekf",
+        ":test_constants",
+        ":trajectory",
+        "//aos/testing:googletest",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_library(
+    name = "dynamics",
+    srcs = [
+        "dynamics.cc",
+    ],
+    hdrs = [
+        "dynamics.h",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//frc971/control_loops:runge_kutta",
+        "@com_github_gflags_gflags//:gflags",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_library(
+    name = "demo_path",
+    srcs = [
+        "demo_path.cc",
+    ],
+    hdrs = ["demo_path.h"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [":trajectory"],
+)
+
+cc_test(
+    name = "dynamics_test",
+    srcs = [
+        "dynamics_test.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":dynamics",
+        ":test_constants",
+        "//aos/testing:googletest",
+    ],
+)
+
+cc_library(
+    name = "ekf",
+    srcs = [
+        "ekf.cc",
+    ],
+    hdrs = [
+        "ekf.h",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":dynamics",
+        "//frc971/control_loops:jacobian",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_library(
+    name = "graph",
+    srcs = ["graph.cc"],
+    hdrs = ["graph.h"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+)
+
+cc_test(
+    name = "graph_test",
+    srcs = ["graph_test.cc"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":graph",
+        "//aos/testing:googletest",
+    ],
+)
+
+cc_library(
+    name = "test_constants",
+    hdrs = ["test_constants.h"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":dynamics",
+    ],
+)
diff --git a/y2018/control_loops/superstructure/arm/demo_path.cc b/frc971/control_loops/double_jointed_arm/demo_path.cc
similarity index 98%
rename from y2018/control_loops/superstructure/arm/demo_path.cc
rename to frc971/control_loops/double_jointed_arm/demo_path.cc
index 03856c9..8d549d1 100644
--- a/y2018/control_loops/superstructure/arm/demo_path.cc
+++ b/frc971/control_loops/double_jointed_arm/demo_path.cc
@@ -1,12 +1,11 @@
-#include "y2018/control_loops/superstructure/arm/demo_path.h"
+#include "frc971/control_loops/double_jointed_arm/demo_path.h"
 
 #include <array>
 #include <initializer_list>
 #include <memory>
 
-namespace y2018 {
+namespace frc971 {
 namespace control_loops {
-namespace superstructure {
 namespace arm {
 
 ::std::vector<::std::array<double, 6>> FlipPath(
@@ -25,7 +24,6 @@
   return result;
 }
 
-
 ::std::unique_ptr<Path> MakeDemoPath() {
   return ::std::unique_ptr<Path>(new Path(FlipPath(
       {{{1.3583511559969876, 0.99753029519739866, 0.63708920330895369,
@@ -213,6 +211,5 @@
 }
 
 }  // namespace arm
-}  // namespace superstructure
 }  // namespace control_loops
-}  // namespace y2018
+}  // namespace frc971
diff --git a/frc971/control_loops/double_jointed_arm/demo_path.h b/frc971/control_loops/double_jointed_arm/demo_path.h
new file mode 100644
index 0000000..9a9d393
--- /dev/null
+++ b/frc971/control_loops/double_jointed_arm/demo_path.h
@@ -0,0 +1,19 @@
+#ifndef FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_DEMO_PATH_H_
+#define FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_DEMO_PATH_H_
+
+#include <memory>
+
+#include "frc971/control_loops/double_jointed_arm/trajectory.h"
+
+namespace frc971 {
+namespace control_loops {
+namespace arm {
+
+::std::unique_ptr<Path> MakeDemoPath();
+::std::unique_ptr<Path> MakeReversedDemoPath();
+
+}  // namespace arm
+}  // namespace control_loops
+}  // namespace frc971
+
+#endif  // FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_DEMO_PATH_H_
diff --git a/frc971/control_loops/double_jointed_arm/dynamics.cc b/frc971/control_loops/double_jointed_arm/dynamics.cc
new file mode 100644
index 0000000..2842ad4
--- /dev/null
+++ b/frc971/control_loops/double_jointed_arm/dynamics.cc
@@ -0,0 +1,37 @@
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+
+DEFINE_bool(gravity, true, "If true, enable gravity.");
+
+namespace frc971 {
+namespace control_loops {
+namespace arm {
+
+Dynamics::Dynamics(ArmConstants arm_constants)
+    : arm_constants_(arm_constants),
+
+      K3_((::Eigen::Matrix<double, 2, 2>() << arm_constants_.g0 *
+                                                  arm_constants_.Kt /
+                                                  arm_constants_.resistance,
+           0.0, 0.0,
+           arm_constants_.g1 * arm_constants_.num_distal_motors *
+               arm_constants_.Kt / arm_constants_.resistance)
+              .finished()),
+
+      K3_inverse_(K3_.inverse()),
+      K4_((::Eigen::Matrix<double, 2, 2>()
+               << arm_constants_.g0 * arm_constants_.g0 * arm_constants_.Kt /
+                      (arm_constants_.Kv * arm_constants_.resistance),
+           0.0, 0.0,
+           arm_constants_.g1 * arm_constants_.g1 * arm_constants_.Kt *
+               arm_constants_.num_distal_motors /
+               (arm_constants_.Kv * arm_constants_.resistance))
+              .finished()),
+      alpha_(arm_constants_.j0 +
+             arm_constants_.r0 * arm_constants_.r0 * arm_constants_.m0 +
+             arm_constants_.l0 * arm_constants_.l0 * arm_constants_.m1),
+      beta_(arm_constants_.l0 * arm_constants_.r1 * arm_constants_.m1),
+      gamma_(arm_constants_.j1 +
+             arm_constants_.r1 * arm_constants_.r1 * arm_constants_.m1) {}
+}  // namespace arm
+}  // namespace control_loops
+}  // namespace frc971
diff --git a/frc971/control_loops/double_jointed_arm/dynamics.h b/frc971/control_loops/double_jointed_arm/dynamics.h
new file mode 100644
index 0000000..d4de1ed
--- /dev/null
+++ b/frc971/control_loops/double_jointed_arm/dynamics.h
@@ -0,0 +1,248 @@
+#ifndef FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_DYNAMICS_H_
+#define FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_DYNAMICS_H_
+
+#include "Eigen/Dense"
+#include "frc971/control_loops/runge_kutta.h"
+#include "gflags/gflags.h"
+
+DECLARE_bool(gravity);
+
+namespace frc971 {
+namespace control_loops {
+namespace arm {
+
+struct ArmConstants {
+  // Below, 0 refers to the proximal joint, and 1 refers to the distal joint.
+  // Length of the joints in meters.
+  double l0;
+  double l1;
+
+  // Mass of the joints in kilograms.
+  double m0;
+  double m1;
+
+  // Moment of inertia of the joints in kg m^2
+  double j0;
+  double j1;
+
+  // Radius of the center of mass of the joints in meters.
+  double r0;
+  double r1;
+
+  // Gear ratios for the two joints.
+  double g0;
+  double g1;
+
+  // motor constants.
+  double efficiency_tweak;
+  double stall_torque;
+  double free_speed;
+  double stall_current;
+  double resistance;
+  double Kv;
+  double Kt;
+
+  // Number of motors on the distal joint.
+  double num_distal_motors;
+};
+
+// This class captures the dynamics of our system.  It doesn't actually need to
+// store state yet, so everything can be constexpr and/or static.
+//
+// 0, 0 is straight up.
+class Dynamics {
+ public:
+  Dynamics(ArmConstants arm_constants);
+  // Generates K1-2 for the arm ODE.
+  // K1 * d^2 theta / dt^2 + K2 * d theta / dt = K3 * V - K4 * d theta/dt
+  // These matricies are missing the velocity factor for K2[1, 0], and K2[0, 1].
+  // You probbaly want MatriciesForState.
+  void NormilizedMatriciesForState(
+      const ::Eigen::Matrix<double, 4, 1> &X,
+      ::Eigen::Matrix<double, 2, 2> *K1_result,
+      ::Eigen::Matrix<double, 2, 2> *K2_result) const {
+    const double angle = X(0, 0) - X(2, 0);
+    const double s = ::std::sin(angle);
+    const double c = ::std::cos(angle);
+    *K1_result << alpha_, c * beta_, c * beta_, gamma_;
+    *K2_result << 0.0, s * beta_, -s * beta_, 0.0;
+  }
+
+  // Generates K1-2 for the arm ODE.
+  // K1 * d^2 theta / dt^2 + K2 * d theta / dt = K3 * V - K4 * d theta/dt
+  void MatriciesForState(const ::Eigen::Matrix<double, 4, 1> &X,
+                         ::Eigen::Matrix<double, 2, 2> *K1_result,
+                         ::Eigen::Matrix<double, 2, 2> *K2_result) const {
+    NormilizedMatriciesForState(X, K1_result, K2_result);
+    (*K2_result)(1, 0) *= X(1, 0);
+    (*K2_result)(0, 1) *= X(3, 0);
+  }
+
+  // Calculates the joint torques as a function of the state and command.
+  const ::Eigen::Matrix<double, 2, 1> TorqueFromCommand(
+      const ::Eigen::Matrix<double, 4, 1> &X,
+      const ::Eigen::Matrix<double, 2, 1> &U) {
+    const ::Eigen::Matrix<double, 2, 1> velocity =
+        (::Eigen::Matrix<double, 2, 1>() << X(1, 0), X(3, 0)).finished();
+
+    return K3_ * U - K4_ * velocity;
+  }
+
+  const ::Eigen::Matrix<double, 2, 1> CurrentFromTorque(
+      const ::Eigen::Matrix<double, 2, 1> &torque) {
+    return ::Eigen::DiagonalMatrix<double, 2>(
+               1.0 / (arm_constants_.Kt * arm_constants_.g0),
+               1.0 / (arm_constants_.Kt * arm_constants_.g1 *
+                      arm_constants_.num_distal_motors)) *
+           torque;
+  }
+
+  const ::Eigen::Matrix<double, 2, 1> CurrentFromCommand(
+      const ::Eigen::Matrix<double, 4, 1> &X,
+      const ::Eigen::Matrix<double, 2, 1> &U) {
+    return CurrentFromTorque(TorqueFromCommand(X, U));
+  }
+
+  // Computes the two joint torques given the state and the external force in
+  // x, y.
+  const ::Eigen::Matrix<double, 2, 1> TorqueFromForce(
+      const ::Eigen::Matrix<double, 4, 1> &X,
+      const ::Eigen::Matrix<double, 2, 1> &F) {
+    const ::Eigen::Matrix<double, 2, 1> L0(std::sin(X(0)) * arm_constants_.l0,
+                                           std::cos(X(0)) * arm_constants_.l0);
+    const ::Eigen::Matrix<double, 2, 1> L1(std::sin(X(2)) * arm_constants_.l1,
+                                           std::cos(X(2)) * arm_constants_.l1);
+
+    const Eigen::Matrix<double, 2, 1> Fn1 =
+        F - L0.normalized().dot(F) * L0.normalized();
+
+    const double torque1 = L0.x() * Fn1.y() - L0.y() * Fn1.x();
+    const double torque2 = L1.x() * F.y() - L1.y() * F.x();
+
+    return ::Eigen::Matrix<double, 2, 1>(torque1, torque2);
+  }
+
+  // TODO(austin): We may want a way to provide K1 and K2 to save CPU cycles.
+
+  // Calculates the acceleration given the current state and control input.
+  const ::Eigen::Matrix<double, 4, 1> Acceleration(
+      const ::Eigen::Matrix<double, 4, 1> &X,
+      const ::Eigen::Matrix<double, 2, 1> &U) const {
+    ::Eigen::Matrix<double, 2, 2> K1;
+    ::Eigen::Matrix<double, 2, 2> K2;
+
+    MatriciesForState(X, &K1, &K2);
+
+    const ::Eigen::Matrix<double, 2, 1> velocity =
+        (::Eigen::Matrix<double, 2, 1>() << X(1, 0), X(3, 0)).finished();
+
+    const ::Eigen::Matrix<double, 2, 1> torque = K3_ * U - K4_ * velocity;
+    const ::Eigen::Matrix<double, 2, 1> gravity_torque = GravityTorque(X);
+
+    const ::Eigen::Matrix<double, 2, 1> accel =
+        K1.inverse() * (torque + gravity_torque - K2 * velocity);
+
+    return (::Eigen::Matrix<double, 4, 1>() << X(1, 0), accel(0, 0), X(3, 0),
+            accel(1, 0))
+        .finished();
+  }
+
+  // Calculates the acceleration given the current augmented kalman filter state
+  // and control input.
+  const ::Eigen::Matrix<double, 6, 1> EKFAcceleration(
+      const ::Eigen::Matrix<double, 6, 1> &X,
+      const ::Eigen::Matrix<double, 2, 1> &U) const {
+    ::Eigen::Matrix<double, 2, 2> K1;
+    ::Eigen::Matrix<double, 2, 2> K2;
+
+    MatriciesForState(X.block<4, 1>(0, 0), &K1, &K2);
+
+    const ::Eigen::Matrix<double, 2, 1> velocity =
+        (::Eigen::Matrix<double, 2, 1>() << X(1, 0), X(3, 0)).finished();
+
+    const ::Eigen::Matrix<double, 2, 1> torque =
+        K3_ *
+            (U +
+             (::Eigen::Matrix<double, 2, 1>() << X(4, 0), X(5, 0)).finished()) -
+        K4_ * velocity;
+    const ::Eigen::Matrix<double, 2, 1> gravity_torque =
+        GravityTorque(X.block<4, 1>(0, 0));
+
+    const ::Eigen::Matrix<double, 2, 1> accel =
+        K1.inverse() * (torque + gravity_torque - K2 * velocity);
+
+    return (::Eigen::Matrix<double, 6, 1>() << X(1, 0), accel(0, 0), X(3, 0),
+            accel(1, 0), 0.0, 0.0)
+        .finished();
+  }
+
+  // Calculates the voltage required to follow the trajectory.  This requires
+  // knowing the current state, desired angular velocity and acceleration.
+  const ::Eigen::Matrix<double, 2, 1> FF_U(
+      const ::Eigen::Matrix<double, 4, 1> &X,
+      const ::Eigen::Matrix<double, 2, 1> &omega_t,
+      const ::Eigen::Matrix<double, 2, 1> &alpha_t) const {
+    ::Eigen::Matrix<double, 2, 2> K1;
+    ::Eigen::Matrix<double, 2, 2> K2;
+
+    MatriciesForState(X, &K1, &K2);
+
+    const ::Eigen::Matrix<double, 2, 1> gravity_torque = GravityTorque(X);
+
+    return K3_inverse_ *
+           (K1 * alpha_t + K2 * omega_t + K4_ * omega_t - gravity_torque);
+  }
+
+  const ::Eigen::Matrix<double, 2, 1> GravityTorque(
+      const ::Eigen::Matrix<double, 4, 1> &X) const {
+    const double accel_due_to_gravity = 9.8 * arm_constants_.efficiency_tweak;
+    return (::Eigen::Matrix<double, 2, 1>()
+                << (arm_constants_.r0 * arm_constants_.m0 +
+                    arm_constants_.l0 * arm_constants_.m1) *
+                       ::std::sin(X(0)) * accel_due_to_gravity,
+            arm_constants_.r1 * arm_constants_.m1 * ::std::sin(X(2)) *
+                accel_due_to_gravity)
+               .finished() *
+           (FLAGS_gravity ? 1.0 : 0.0);
+  }
+
+  const ::Eigen::Matrix<double, 4, 1> UnboundedDiscreteDynamics(
+      const ::Eigen::Matrix<double, 4, 1> &X,
+      const ::Eigen::Matrix<double, 2, 1> &U, double dt) const {
+    return ::frc971::control_loops::RungeKuttaU(
+        [this](const auto &X, const auto &U) { return Acceleration(X, U); }, X,
+        U, dt);
+  }
+
+  const ::Eigen::Matrix<double, 6, 1> UnboundedEKFDiscreteDynamics(
+      const ::Eigen::Matrix<double, 6, 1> &X,
+      const ::Eigen::Matrix<double, 2, 1> &U, double dt) const {
+    return ::frc971::control_loops::RungeKuttaU(
+        [this](const auto &X, const auto &U) { return EKFAcceleration(X, U); },
+        X, U, dt);
+  }
+
+  const ArmConstants arm_constants_;
+
+  // K3, K4 matricies described above.
+  const ::Eigen::Matrix<double, 2, 2> &K3() const { return K3_; }
+  const ::Eigen::Matrix<double, 2, 2> &K3_inverse() const {
+    return K3_inverse_;
+  }
+  const ::Eigen::Matrix<double, 2, 2> &K4() const { return K4_; }
+
+ private:
+  const ::Eigen::Matrix<double, 2, 2> K3_;
+  const ::Eigen::Matrix<double, 2, 2> K3_inverse_;
+  const ::Eigen::Matrix<double, 2, 2> K4_;
+
+  const double alpha_;
+  const double beta_;
+  const double gamma_;
+};
+
+}  // namespace arm
+}  // namespace control_loops
+}  // namespace frc971
+
+#endif  // FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_DYNAMICS_H_
diff --git a/frc971/control_loops/double_jointed_arm/dynamics_test.cc b/frc971/control_loops/double_jointed_arm/dynamics_test.cc
new file mode 100644
index 0000000..6e12f75
--- /dev/null
+++ b/frc971/control_loops/double_jointed_arm/dynamics_test.cc
@@ -0,0 +1,53 @@
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/test_constants.h"
+
+#include "gtest/gtest.h"
+
+namespace frc971 {
+namespace control_loops {
+namespace arm {
+namespace testing {
+
+// Tests that zero inputs result in no acceleration and no motion.
+// This isn't all that rigerous, but it's a good start.
+TEST(DynamicsTest, Acceleration) {
+  Dynamics dynamics(kArmConstants);
+
+  EXPECT_TRUE(dynamics
+                  .Acceleration(::Eigen::Matrix<double, 4, 1>::Zero(),
+                                ::Eigen::Matrix<double, 2, 1>::Zero())
+                  .isApprox(::Eigen::Matrix<double, 4, 1>::Zero()));
+
+  EXPECT_TRUE(
+      dynamics
+          .UnboundedDiscreteDynamics(::Eigen::Matrix<double, 4, 1>::Zero(),
+                                     ::Eigen::Matrix<double, 2, 1>::Zero(), 0.1)
+          .isApprox(::Eigen::Matrix<double, 4, 1>::Zero()));
+
+  const ::Eigen::Matrix<double, 4, 1> X =
+      (::Eigen::Matrix<double, 4, 1>() << M_PI / 2.0, 0.0, 0.0, 0.0).finished();
+
+  ::std::cout << dynamics.FF_U(X, ::Eigen::Matrix<double, 2, 1>::Zero(),
+                               ::Eigen::Matrix<double, 2, 1>::Zero())
+              << ::std::endl;
+
+  ::std::cout << dynamics.UnboundedDiscreteDynamics(
+                     X,
+                     dynamics.FF_U(X, ::Eigen::Matrix<double, 2, 1>::Zero(),
+                                   ::Eigen::Matrix<double, 2, 1>::Zero()),
+                     0.01)
+              << ::std::endl;
+
+  EXPECT_TRUE(dynamics
+                  .UnboundedDiscreteDynamics(
+                      X,
+                      dynamics.FF_U(X, ::Eigen::Matrix<double, 2, 1>::Zero(),
+                                    ::Eigen::Matrix<double, 2, 1>::Zero()),
+                      0.01)
+                  .isApprox(X));
+}
+
+}  // namespace testing
+}  // namespace arm
+}  // namespace control_loops
+}  // namespace frc971
diff --git a/y2018/control_loops/superstructure/arm/ekf.cc b/frc971/control_loops/double_jointed_arm/ekf.cc
similarity index 87%
rename from y2018/control_loops/superstructure/arm/ekf.cc
rename to frc971/control_loops/double_jointed_arm/ekf.cc
index 48a71f4..a70e4a6 100644
--- a/y2018/control_loops/superstructure/arm/ekf.cc
+++ b/frc971/control_loops/double_jointed_arm/ekf.cc
@@ -1,19 +1,18 @@
-#include "y2018/control_loops/superstructure/arm/ekf.h"
+#include "frc971/control_loops/double_jointed_arm/ekf.h"
 
-#include "Eigen/Dense"
 #include <iostream>
 
+#include "Eigen/Dense"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
 #include "frc971/control_loops/jacobian.h"
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
 
 DEFINE_double(proximal_voltage_error_uncertainty, 8.0,
               "Proximal joint voltage error uncertainty.");
 DEFINE_double(distal_voltage_error_uncertainty, 2.0,
               "Distal joint voltage error uncertainty.");
 
-namespace y2018 {
+namespace frc971 {
 namespace control_loops {
-namespace superstructure {
 namespace arm {
 
 namespace {
@@ -29,7 +28,7 @@
         .asDiagonal());
 }  // namespace
 
-EKF::EKF() {
+EKF::EKF(const Dynamics *dynamics) : dynamics_(dynamics) {
   X_hat_.setZero();
   Q_covariance =
       ((::Eigen::DiagonalMatrix<double, 6>().diagonal() << ::std::pow(0.1, 2),
@@ -69,9 +68,12 @@
 void EKF::Predict(const ::Eigen::Matrix<double, 2, 1> &U, double dt) {
   const ::Eigen::Matrix<double, 6, 6> A =
       ::frc971::control_loops::NumericalJacobianX<6, 2>(
-          Dynamics::UnboundedEKFDiscreteDynamics, X_hat_, U, dt);
+          [this](const auto &X_hat_, const auto &U, double dt) {
+            return dynamics_->UnboundedEKFDiscreteDynamics(X_hat_, U, dt);
+          },
+          X_hat_, U, dt);
 
-  X_hat_ = Dynamics::UnboundedEKFDiscreteDynamics(X_hat_, U, dt);
+  X_hat_ = dynamics_->UnboundedEKFDiscreteDynamics(X_hat_, U, dt);
   P_ = A * P_ * A.transpose() + Q_covariance;
 }
 
@@ -83,8 +85,8 @@
           .asDiagonal());
   // H is the jacobian of the h(x) measurement prediction function
   const ::Eigen::Matrix<double, 2, 6> H_jacobian =
-      (::Eigen::Matrix<double, 2, 6>() << 1.0, 0.0, 0.0, 0.0, 0.0, 0.0,
-      0.0, 0.0, 1.0, 0.0, 0.0, 0.0)
+      (::Eigen::Matrix<double, 2, 6>() << 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
+       0.0, 1.0, 0.0, 0.0, 0.0)
           .finished();
 
   // Update step Measurement residual error of proximal and distal joint
@@ -106,6 +108,5 @@
 }
 
 }  // namespace arm
-}  // namespace superstructure
 }  // namespace control_loops
-}  // namespace y2018
+}  // namespace frc971
diff --git a/y2018/control_loops/superstructure/arm/ekf.h b/frc971/control_loops/double_jointed_arm/ekf.h
similarity index 81%
rename from y2018/control_loops/superstructure/arm/ekf.h
rename to frc971/control_loops/double_jointed_arm/ekf.h
index 9ae8b47..d969e82 100644
--- a/y2018/control_loops/superstructure/arm/ekf.h
+++ b/frc971/control_loops/double_jointed_arm/ekf.h
@@ -1,11 +1,11 @@
-#ifndef Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_EKF_H_
-#define Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_EKF_H_
+#ifndef FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_EKF_H_
+#define FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_EKF_H_
 
 #include "Eigen/Dense"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
 
-namespace y2018 {
+namespace frc971 {
 namespace control_loops {
-namespace superstructure {
 namespace arm {
 
 // An extended kalman filter for the Arm.
@@ -13,7 +13,7 @@
 //   [theta0, omega0, theta1, omega1, voltage error0, voltage error1]
 class EKF {
  public:
-  EKF();
+  EKF(const Dynamics *dynamics);
 
   // Resets the internal state back to X.  Resets the torque disturbance to 0.
   void Reset(const ::Eigen::Matrix<double, 4, 1> &X);
@@ -43,11 +43,12 @@
   ::Eigen::Matrix<double, 6, 6> P_half_converged_;
   ::Eigen::Matrix<double, 6, 6> P_converged_;
   ::Eigen::Matrix<double, 6, 6> P_reset_;
+
+  const Dynamics *dynamics_;
 };
 
 }  // namespace arm
-}  // namespace superstructure
 }  // namespace control_loops
-}  // namespace y2018
+}  // namespace frc971
 
-#endif  // Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_EKF_H_
+#endif  // FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_EKF_H_
diff --git a/y2018/control_loops/superstructure/arm/graph.cc b/frc971/control_loops/double_jointed_arm/graph.cc
similarity index 92%
rename from y2018/control_loops/superstructure/arm/graph.cc
rename to frc971/control_loops/double_jointed_arm/graph.cc
index ad6f982..b5a70ab 100644
--- a/y2018/control_loops/superstructure/arm/graph.cc
+++ b/frc971/control_loops/double_jointed_arm/graph.cc
@@ -1,11 +1,10 @@
-#include "y2018/control_loops/superstructure/arm/graph.h"
+#include "frc971/control_loops/double_jointed_arm/graph.h"
 
 #include <algorithm>
 #include <cassert>
 
-namespace y2018 {
+namespace frc971 {
 namespace control_loops {
-namespace superstructure {
 namespace arm {
 
 SearchGraph::SearchGraph(size_t num_vertexes, std::initializer_list<Edge> edges)
@@ -68,6 +67,5 @@
 }
 
 }  // namespace arm
-}  // namespace superstructure
 }  // namespace control_loops
-}  // namespace y2018
+}  // namespace frc971
diff --git a/y2018/control_loops/superstructure/arm/graph.h b/frc971/control_loops/double_jointed_arm/graph.h
similarity index 94%
rename from y2018/control_loops/superstructure/arm/graph.h
rename to frc971/control_loops/double_jointed_arm/graph.h
index 2300d6a..06d396c 100644
--- a/y2018/control_loops/superstructure/arm/graph.h
+++ b/frc971/control_loops/double_jointed_arm/graph.h
@@ -1,5 +1,5 @@
-#ifndef Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_GRAPH_H_
-#define Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_GRAPH_H_
+#ifndef FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_GRAPH_H_
+#define FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_GRAPH_H_
 
 #include <algorithm>
 #include <cassert>
@@ -8,9 +8,8 @@
 #include <memory>
 #include <vector>
 
-namespace y2018 {
+namespace frc971 {
 namespace control_loops {
-namespace superstructure {
 namespace arm {
 
 // Grr... normal priority queues don't allow modifying the node cost.
@@ -183,8 +182,7 @@
 };
 
 }  // namespace arm
-}  // namespace superstructure
 }  // namespace control_loops
-}  // namespace y2018
+}  // namespace frc971
 
-#endif  // Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_GRAPH_H_
+#endif  // FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_GRAPH_H_
diff --git a/y2018/control_loops/superstructure/arm/graph_test.cc b/frc971/control_loops/double_jointed_arm/graph_test.cc
similarity index 90%
rename from y2018/control_loops/superstructure/arm/graph_test.cc
rename to frc971/control_loops/double_jointed_arm/graph_test.cc
index 8f246a5..7b14ced 100644
--- a/y2018/control_loops/superstructure/arm/graph_test.cc
+++ b/frc971/control_loops/double_jointed_arm/graph_test.cc
@@ -1,10 +1,9 @@
-#include "y2018/control_loops/superstructure/arm/graph.h"
+#include "frc971/control_loops/double_jointed_arm/graph.h"
 
 #include "gtest/gtest.h"
 
-namespace y2018 {
+namespace frc971 {
 namespace control_loops {
-namespace superstructure {
 namespace arm {
 namespace testing {
 
@@ -68,6 +67,5 @@
 
 }  // namespace testing
 }  // namespace arm
-}  // namespace superstructure
 }  // namespace control_loops
-}  // namespace y2018
+}  // namespace frc971
diff --git a/frc971/control_loops/double_jointed_arm/test_constants.h b/frc971/control_loops/double_jointed_arm/test_constants.h
new file mode 100644
index 0000000..4885d14
--- /dev/null
+++ b/frc971/control_loops/double_jointed_arm/test_constants.h
@@ -0,0 +1,53 @@
+#ifndef FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_TEST_CONSTANTS_H_
+#define FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_TEST_CONSTANTS_H_
+
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+
+namespace frc971 {
+namespace control_loops {
+namespace arm {
+namespace testing {
+
+constexpr double kEfficiencyTweak = 0.95;
+constexpr double kStallTorque = 1.41 * kEfficiencyTweak;
+constexpr double kFreeSpeed = (5840.0 / 60.0) * 2.0 * M_PI;
+constexpr double kStallCurrent = 89.0;
+
+constexpr ArmConstants kArmConstants = {
+    .l0 = 46.25 * 0.0254,
+    .l1 = 41.80 * 0.0254,
+    .m0 = 9.34 / 2.2,
+    .m1 = 9.77 / 2.2,
+
+    // Moment of inertia of the joints in kg m^2
+    .j0 = 2957.05 * 0.0002932545454545454,
+    .j1 = 2824.70 * 0.0002932545454545454,
+
+    // Radius of the center of mass of the joints in meters.
+    .r0 = 21.64 * 0.0254,
+    .r1 = 26.70 * 0.0254,
+
+    // Gear ratios for the two joints.
+    .g0 = 140.0,
+    .g1 = 90.0,
+
+    // MiniCIM motor constants.
+    .efficiency_tweak = kEfficiencyTweak,
+    .stall_torque = kStallTorque,
+    .free_speed = kFreeSpeed,
+    .stall_current = kStallCurrent,
+    .resistance = 12.0 / kStallCurrent,
+    .Kv = kFreeSpeed / 12.0,
+    .Kt = kStallTorque / kStallCurrent,
+
+    // Number of motors on the distal joint.
+    .num_distal_motors = 2.0,
+};
+
+} // namespace testing
+} // namespace arm
+} // namespace control_loops
+} // namespace frc971
+
+#endif // FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_TEST_CONSTANTS_H_
+
diff --git a/y2018/control_loops/superstructure/arm/trajectory.cc b/frc971/control_loops/double_jointed_arm/trajectory.cc
similarity index 92%
rename from y2018/control_loops/superstructure/arm/trajectory.cc
rename to frc971/control_loops/double_jointed_arm/trajectory.cc
index c6e09dd..a67216b 100644
--- a/y2018/control_loops/superstructure/arm/trajectory.cc
+++ b/frc971/control_loops/double_jointed_arm/trajectory.cc
@@ -1,20 +1,19 @@
-#include "y2018/control_loops/superstructure/arm/trajectory.h"
+#include "frc971/control_loops/double_jointed_arm/trajectory.h"
 
 #include "Eigen/Dense"
 #include "aos/logging/logging.h"
 #include "frc971/control_loops/dlqr.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
 #include "frc971/control_loops/jacobian.h"
 #include "gflags/gflags.h"
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
 
 DEFINE_double(lqr_proximal_pos, 0.15, "Position LQR gain");
 DEFINE_double(lqr_proximal_vel, 4.0, "Velocity LQR gain");
 DEFINE_double(lqr_distal_pos, 0.20, "Position LQR gain");
 DEFINE_double(lqr_distal_vel, 4.0, "Velocity LQR gain");
 
-namespace y2018 {
+namespace frc971 {
 namespace control_loops {
-namespace superstructure {
 namespace arm {
 
 Path Path::Reversed(const Path &p) {
@@ -106,9 +105,9 @@
     ::Eigen::Matrix<double, 2, 2> K2;
 
     const ::Eigen::Matrix<double, 2, 1> gravity_volts =
-        Dynamics::K3_inverse * Dynamics::GravityTorque(X);
+        dynamics_->K3_inverse() * dynamics_->GravityTorque(X);
 
-    Dynamics::NormilizedMatriciesForState(X, &K1, &K2);
+    dynamics_->NormilizedMatriciesForState(X, &K1, &K2);
 
     const ::Eigen::Matrix<double, 2, 2> omega_square =
         (::Eigen::Matrix<double, 2, 2>() << omega(0, 0), 0.0, 0.0, omega(1, 0))
@@ -123,18 +122,18 @@
         ::std::sqrt(1.0 / ::std::max(0.001, (alpha_unitizer * alpha).norm()));
 
     const ::Eigen::Matrix<double, 2, 1> vk1 =
-        Dynamics::K3_inverse * (K1 * alpha + K2 * omega_square * omega);
+        dynamics_->K3_inverse() * (K1 * alpha + K2 * omega_square * omega);
     const ::Eigen::Matrix<double, 2, 1> vk2 =
-        Dynamics::K3_inverse * Dynamics::K4 * omega;
+        dynamics_->K3_inverse() * dynamics_->K4() * omega;
 
     // Loop through all the various vmin, plan_vmax combinations.
     for (const double c : {-plan_vmax, plan_vmax}) {
       // Also loop through saturating theta0 and theta1
       for (const ::std::tuple<double, double, double> &abgravity :
            {::std::tuple<double, double, double>{vk1(0), vk2(0),
-                                                gravity_volts(0)},
+                                                 gravity_volts(0)},
             ::std::tuple<double, double, double>{vk1(1), vk2(1),
-                                                gravity_volts(1)}}) {
+                                                 gravity_volts(1)}}) {
         const double a = ::std::get<0>(abgravity);
         const double b = ::std::get<1>(abgravity);
         const double gravity = ::std::get<2>(abgravity);
@@ -178,19 +177,19 @@
   ::Eigen::Matrix<double, 2, 2> K1;
   ::Eigen::Matrix<double, 2, 2> K2;
 
-  Dynamics::NormilizedMatriciesForState(X, &K1, &K2);
+  dynamics_->NormilizedMatriciesForState(X, &K1, &K2);
 
   const ::Eigen::Matrix<double, 2, 2> omega_square =
       (::Eigen::Matrix<double, 2, 2>() << omega(0, 0), 0.0, 0.0, omega(1, 0))
           .finished();
 
   const ::Eigen::Matrix<double, 2, 1> k_constant =
-      Dynamics::K3_inverse *
+      dynamics_->K3_inverse() *
       ((K1 * alpha + K2 * omega_square * omega) * goal_velocity *
            goal_velocity +
-       Dynamics::K4 * omega * goal_velocity - Dynamics::GravityTorque(X));
+       dynamics_->K4() * omega * goal_velocity - dynamics_->GravityTorque(X));
   const ::Eigen::Matrix<double, 2, 1> k_scalar =
-      Dynamics::K3_inverse * K1 * omega;
+      dynamics_->K3_inverse() * K1 * omega;
 
   const double constraint_goal_acceleration =
       ::std::sqrt(
@@ -236,19 +235,19 @@
   ::Eigen::Matrix<double, 2, 2> K1;
   ::Eigen::Matrix<double, 2, 2> K2;
 
-  Dynamics::NormilizedMatriciesForState(X, &K1, &K2);
+  dynamics_->NormilizedMatriciesForState(X, &K1, &K2);
 
   const ::Eigen::Matrix<double, 2, 2> omega_square =
       (::Eigen::Matrix<double, 2, 2>() << omega(0, 0), 0.0, 0.0, omega(1, 0))
           .finished();
 
   const ::Eigen::Matrix<double, 2, 1> k_constant =
-      Dynamics::K3_inverse *
+      dynamics_->K3_inverse() *
       ((K1 * alpha + K2 * omega_square * omega) * goal_velocity *
            goal_velocity +
-       Dynamics::K4 * omega * goal_velocity - Dynamics::GravityTorque(X));
+       dynamics_->K4() * omega * goal_velocity - dynamics_->GravityTorque(X));
   const ::Eigen::Matrix<double, 2, 1> k_scalar =
-      Dynamics::K3_inverse * K1 * omega;
+      dynamics_->K3_inverse() * K1 * omega;
 
   const double constraint_goal_acceleration =
       ::std::sqrt(
@@ -390,12 +389,22 @@
           .finished()
           .asDiagonal();
 
+  const auto x_blocked = X.block<4, 1>(0, 0);
+
   const ::Eigen::Matrix<double, 4, 4> final_A =
       ::frc971::control_loops::NumericalJacobianX<4, 2>(
-          Dynamics::UnboundedDiscreteDynamics, X.block<4, 1>(0, 0), U, 0.00505);
+          [this](const auto &x_blocked, const auto &U, double dt) {
+            return this->dynamics_->UnboundedDiscreteDynamics(
+                x_blocked, U, dt);
+          },
+          x_blocked, U, 0.00505);
+
   const ::Eigen::Matrix<double, 4, 2> final_B =
       ::frc971::control_loops::NumericalJacobianU<4, 2>(
-          Dynamics::UnboundedDiscreteDynamics, X.block<4, 1>(0, 0), U, 0.00505);
+          [this](const auto &x_blocked, const auto &U, double dt) {
+            return this->dynamics_->UnboundedDiscreteDynamics(x_blocked, U, dt);
+          },
+          x_blocked, U, 0.00505);
 
   ::Eigen::Matrix<double, 4, 4> S;
   ::Eigen::Matrix<double, 2, 4> sub_K;
@@ -441,7 +450,7 @@
                         *saturation_goal_acceleration);
   const ::Eigen::Matrix<double, 6, 1> R = trajectory.R(theta_t, omega_t);
   const ::Eigen::Matrix<double, 2, 1> U_ff =
-      Dynamics::FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t);
+      dynamics_->FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t);
 
   *U = U_ff + K * (R - X);
 }
@@ -494,11 +503,11 @@
       U_unsaturated_.setZero();
     } else {
       const ::Eigen::Matrix<double, 6, 1> R =
-          trajectory_->R(theta_, ::Eigen::Matrix<double, 2, 1>::Zero());
+          Trajectory::R(theta_, ::Eigen::Matrix<double, 2, 1>::Zero());
 
-      U_ff_ = Dynamics::FF_U(X.block<4, 1>(0, 0),
-                             ::Eigen::Matrix<double, 2, 1>::Zero(),
-                             ::Eigen::Matrix<double, 2, 1>::Zero());
+      U_ff_ = dynamics_->FF_U(
+          X.block<4, 1>(0, 0), ::Eigen::Matrix<double, 2, 1>::Zero(),
+          ::Eigen::Matrix<double, 2, 1>::Zero());
       const ::Eigen::Matrix<double, 2, 6> K = K_at_state(X, U_ff_);
       U_ = U_unsaturated_ = U_ff_ + K * (R - X);
 
@@ -558,7 +567,7 @@
 
   const ::Eigen::Matrix<double, 6, 1> R = trajectory_->R(theta_t, omega_t);
 
-  U_ff_ = Dynamics::FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t);
+  U_ff_ = dynamics_->FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t);
 
   const ::Eigen::Matrix<double, 2, 6> K = K_at_state(X, U_ff_);
   U_ = U_unsaturated_ = U_ff_ + K * (R - X);
@@ -613,6 +622,5 @@
 }
 
 }  // namespace arm
-}  // namespace superstructure
 }  // namespace control_loops
-}  // namespace y2018
+}  // namespace frc971
diff --git a/y2018/control_loops/superstructure/arm/trajectory.h b/frc971/control_loops/double_jointed_arm/trajectory.h
similarity index 93%
rename from y2018/control_loops/superstructure/arm/trajectory.h
rename to frc971/control_loops/double_jointed_arm/trajectory.h
index a3a4246..a194a03 100644
--- a/y2018/control_loops/superstructure/arm/trajectory.h
+++ b/frc971/control_loops/double_jointed_arm/trajectory.h
@@ -1,5 +1,5 @@
-#ifndef Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_TRAJECTORY_H_
-#define Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_TRAJECTORY_H_
+#ifndef FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_TRAJECTORY_H_
+#define FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_TRAJECTORY_H_
 
 #include <array>
 #include <initializer_list>
@@ -7,10 +7,10 @@
 #include <vector>
 
 #include "Eigen/Dense"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
 
-namespace y2018 {
+namespace frc971 {
 namespace control_loops {
-namespace superstructure {
 namespace arm {
 
 // This class represents a path in theta0, theta1 space.  It also returns the
@@ -90,8 +90,10 @@
  public:
   // Constructs a trajectory (but doesn't calculate it) given a path and a step
   // size.
-  Trajectory(::std::unique_ptr<const Path> path, double gridsize)
-      : path_(::std::move(path)),
+  Trajectory(const Dynamics *dynamics, ::std::unique_ptr<const Path> path,
+             double gridsize)
+      : dynamics_(dynamics),
+        path_(::std::move(path)),
         num_plan_points_(
             static_cast<size_t>(::std::ceil(path_->length() / gridsize) + 1)),
         step_size_(path_->length() /
@@ -220,7 +222,8 @@
   double GetDAcceleration(double distance) const {
     return GetDAcceleration(distance, max_dvelocity_);
   }
-  double GetDAcceleration(double distance, const ::std::vector<double> &plan) const {
+  double GetDAcceleration(double distance,
+                          const ::std::vector<double> &plan) const {
     ::std::pair<size_t, size_t> indices = IndicesForDistance(distance);
     const double v0 = plan[indices.first];
     const double v1 = plan[indices.second];
@@ -276,6 +279,7 @@
 
   const Path &path() const { return *path_; }
 
+
  private:
   friend class testing::TrajectoryTest_IndicesForDistanceTest_Test;
 
@@ -298,6 +302,8 @@
     return ::std::pair<size_t, size_t>(lower_index, lower_index + 1);
   }
 
+  const Dynamics *dynamics_;
+
   // The path to follow.
   ::std::unique_ptr<const Path> path_;
   // The number of points in the plan.
@@ -315,14 +321,16 @@
 // This class tracks the current goal along trajectories and paths.
 class TrajectoryFollower {
  public:
-  TrajectoryFollower(const ::Eigen::Matrix<double, 2, 1> &theta)
-      : trajectory_(nullptr), theta_(theta) {
+  TrajectoryFollower(const Dynamics *dynamics,
+                     const ::Eigen::Matrix<double, 2, 1> &theta)
+      : dynamics_(dynamics), trajectory_(nullptr), theta_(theta) {
     omega_.setZero();
     last_K_.setZero();
     Reset();
   }
 
-  TrajectoryFollower(Trajectory *const trajectory) : trajectory_(trajectory) {
+  TrajectoryFollower(const Dynamics *dynamics, Trajectory *const trajectory)
+      : dynamics_(dynamics), trajectory_(trajectory) {
     last_K_.setZero();
     Reset();
   }
@@ -405,6 +413,7 @@
   int failed_solutions() const { return failed_solutions_; }
 
  private:
+  const Dynamics *dynamics_;
   // The trajectory plan.
   const Trajectory *trajectory_ = nullptr;
 
@@ -431,8 +440,7 @@
 };
 
 }  // namespace arm
-}  // namespace superstructure
 }  // namespace control_loops
-}  // namespace y2018
+}  // namespace frc971
 
-#endif  // Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_TRAJECTORY_H_
+#endif  // FRC971_CONTROL_LOOPS_DOUBLE_JOINTED_ARM_TRAJECTORY_H_
diff --git a/y2018/control_loops/superstructure/arm/trajectory_test.cc b/frc971/control_loops/double_jointed_arm/trajectory_test.cc
similarity index 86%
rename from y2018/control_loops/superstructure/arm/trajectory_test.cc
rename to frc971/control_loops/double_jointed_arm/trajectory_test.cc
index b91c5a4..e700282 100644
--- a/y2018/control_loops/superstructure/arm/trajectory_test.cc
+++ b/frc971/control_loops/double_jointed_arm/trajectory_test.cc
@@ -1,13 +1,13 @@
-#include "y2018/control_loops/superstructure/arm/trajectory.h"
+#include "frc971/control_loops/double_jointed_arm/trajectory.h"
+#include "frc971/control_loops/double_jointed_arm/test_constants.h"
 
+#include "frc971/control_loops/double_jointed_arm/demo_path.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/ekf.h"
 #include "gtest/gtest.h"
-#include "y2018/control_loops/superstructure/arm/demo_path.h"
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
-#include "y2018/control_loops/superstructure/arm/ekf.h"
 
-namespace y2018 {
+namespace frc971 {
 namespace control_loops {
-namespace superstructure {
 namespace arm {
 namespace testing {
 
@@ -75,20 +75,22 @@
       (::Eigen::Matrix<double, 2, 1>() << 3.0, 0.0).finished()));
 }
 
-
-// Tests that we can compute the indices of the plan for a given distance correctly.
+// Tests that we can compute the indices of the plan for a given distance
+// correctly.
 TEST(TrajectoryTest, IndicesForDistanceTest) {
   // Start with a stupid simple plan.
   Path p({{{0.0, 0.0, 1.0, 0.0, 0.0, 0.0}},
           {{1.0, 0.0, 1.0, 0.0, 0.0, 0.0}},
           {{2.0, 0.0, 1.0, 0.0, 0.0, 0.0}},
           {{3.0, 0.0, 1.0, 0.0, 0.0, 0.0}}});
-  Trajectory t(::std::unique_ptr<Path>(new Path(p)), 0.1);
+  Dynamics dynamics(kArmConstants);
+  Trajectory t(&dynamics, ::std::unique_ptr<Path>(new Path(p)), 0.1);
 
   // 0 - 3.0 every 0.1 should be 31 points.
   EXPECT_EQ(t.num_plan_points(), 31);
 
-  // Verify that something centered in a grid cell returns the points on either side.
+  // Verify that something centered in a grid cell returns the points on either
+  // side.
   EXPECT_EQ(::std::make_pair(static_cast<size_t>(0), static_cast<size_t>(1)),
             t.IndicesForDistance(0.05));
   EXPECT_EQ(::std::make_pair(static_cast<size_t>(1), static_cast<size_t>(2)),
@@ -145,17 +147,21 @@
   EXPECT_NEAR(path->length(), reversed_path->length(), 1e-6);
 
   for (double d = 0; d < path->length(); d += 0.01) {
-    EXPECT_TRUE(path->Theta(d).isApprox(reversed_path->Theta(path->length() - d)));
-    EXPECT_TRUE(path->Omega(d).isApprox(-reversed_path->Omega(path->length() - d)));
-    EXPECT_TRUE(path->Alpha(d).isApprox(reversed_path->Alpha(path->length() - d)));
+    EXPECT_TRUE(
+        path->Theta(d).isApprox(reversed_path->Theta(path->length() - d)));
+    EXPECT_TRUE(
+        path->Omega(d).isApprox(-reversed_path->Omega(path->length() - d)));
+    EXPECT_TRUE(
+        path->Alpha(d).isApprox(reversed_path->Alpha(path->length() - d)));
   }
 }
 
 // Tests that we can follow a path.  Look at :trajectory_plot if you want to see
 // the path.
 TEST(TrajectoryTest, RunTrajectory) {
+  Dynamics dynamics(kArmConstants);
   ::std::unique_ptr<Path> path = MakeDemoPath();
-  Trajectory trajectory(::std::move(path), 0.001);
+  Trajectory trajectory(&dynamics, ::std::move(path), 0.001);
 
   constexpr double kAlpha0Max = 40.0;
   constexpr double kAlpha1Max = 60.0;
@@ -174,16 +180,17 @@
     X << theta_t(0), 0.0, theta_t(1), 0.0;
   }
 
-  EKF arm_ekf;
+  EKF arm_ekf(&dynamics);
   arm_ekf.Reset(X);
 
-  TrajectoryFollower follower(&trajectory);
+  TrajectoryFollower follower(&dynamics, &trajectory);
   constexpr double sim_dt = 0.00505;
   while (t < 1.0) {
     arm_ekf.Correct((::Eigen::Matrix<double, 2, 1>() << X(0), X(2)).finished(),
                     sim_dt);
     follower.Update(arm_ekf.X_hat(), false, sim_dt, vmax, 12.0);
-    X = Dynamics::UnboundedDiscreteDynamics(X, follower.U(), sim_dt);
+
+    X = dynamics.UnboundedDiscreteDynamics(X, follower.U(), sim_dt);
     arm_ekf.Predict(follower.U(), sim_dt);
     t += sim_dt;
   }
@@ -204,6 +211,5 @@
 
 }  // namespace testing
 }  // namespace arm
-}  // namespace superstructure
 }  // namespace control_loops
-}  // namespace y2018
+}  // namespace frc971
diff --git a/frc971/control_loops/drivetrain/BUILD b/frc971/control_loops/drivetrain/BUILD
index 13c8ade..86c1ec0 100644
--- a/frc971/control_loops/drivetrain/BUILD
+++ b/frc971/control_loops/drivetrain/BUILD
@@ -1,9 +1,9 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
 load("//aos:config.bzl", "aos_config")
 load("//tools/build_rules:select.bzl", "cpu_select")
 load("//aos:flatbuffers.bzl", "cc_static_flatbuffer")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -786,7 +786,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "down_estimator_plotter",
     srcs = ["down_estimator_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -798,7 +798,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "spline_plotter",
     srcs = ["spline_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -809,7 +809,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "drivetrain_plotter",
     srcs = ["drivetrain_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -821,7 +821,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "robot_state_plotter",
     srcs = ["robot_state_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/frc971/control_loops/drivetrain/down_estimator_plotter.ts b/frc971/control_loops/drivetrain/down_estimator_plotter.ts
index 178ab0b..126336d 100644
--- a/frc971/control_loops/drivetrain/down_estimator_plotter.ts
+++ b/frc971/control_loops/drivetrain/down_estimator_plotter.ts
@@ -1,8 +1,8 @@
 // Provides a basic plot for debugging IMU-related issues on a robot.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {ImuMessageHandler} from 'org_frc971/frc971/wpilib/imu_plot_utils';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import {ImuMessageHandler} from '../../../frc971/wpilib/imu_plot_utils';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/frc971/control_loops/drivetrain/drivetrain_plotter.ts b/frc971/control_loops/drivetrain/drivetrain_plotter.ts
index 4b59dc0..5610e97 100644
--- a/frc971/control_loops/drivetrain/drivetrain_plotter.ts
+++ b/frc971/control_loops/drivetrain/drivetrain_plotter.ts
@@ -1,8 +1,8 @@
 // Provides a plot for debugging drivetrain-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {ImuMessageHandler} from 'org_frc971/frc971/wpilib/imu_plot_utils';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import {ImuMessageHandler} from '../../../frc971/wpilib/imu_plot_utils';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/frc971/control_loops/drivetrain/localization/BUILD b/frc971/control_loops/drivetrain/localization/BUILD
new file mode 100644
index 0000000..06c8d57
--- /dev/null
+++ b/frc971/control_loops/drivetrain/localization/BUILD
@@ -0,0 +1,66 @@
+load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
+load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
+
+cc_library(
+    name = "utils",
+    srcs = ["utils.cc"],
+    hdrs = ["utils.h"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//aos/events:event_loop",
+        "//aos/network:message_bridge_server_fbs",
+        "//frc971/control_loops/drivetrain:drivetrain_output_fbs",
+        "//frc971/input:joystick_state_fbs",
+        "//frc971/vision:calibration_fbs",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_library(
+    name = "puppet_localizer",
+    srcs = ["puppet_localizer.cc"],
+    hdrs = ["puppet_localizer.h"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//aos/events:event_loop",
+        "//aos/network:message_bridge_server_fbs",
+        "//frc971/control_loops/drivetrain:hybrid_ekf",
+        "//frc971/control_loops/drivetrain:localizer",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
+    ],
+)
+
+cc_test(
+    name = "puppet_localizer_test",
+    srcs = ["puppet_localizer_test.cc"],
+    data = ["//y2022/control_loops/drivetrain:simulation_config"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":puppet_localizer",
+        "//aos/events:simulated_event_loop",
+        "//aos/events/logging:log_writer",
+        "//aos/network:team_number",
+        "//frc971/control_loops:control_loop_test",
+        "//frc971/control_loops:team_number_test_environment",
+        "//frc971/control_loops/drivetrain:drivetrain_lib",
+        "//frc971/control_loops/drivetrain:drivetrain_test_lib",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
+        "//y2022/control_loops/drivetrain:drivetrain_base",
+    ],
+)
+
+flatbuffer_cc_library(
+    name = "localizer_output_fbs",
+    srcs = [
+        "localizer_output.fbs",
+    ],
+    gen_reflections = True,
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+)
+
+flatbuffer_ts_library(
+    name = "localizer_output_ts_fbs",
+    srcs = ["localizer_output.fbs"],
+    visibility = ["//visibility:public"],
+)
diff --git a/y2022/localizer/localizer_output.fbs b/frc971/control_loops/drivetrain/localization/localizer_output.fbs
similarity index 100%
rename from y2022/localizer/localizer_output.fbs
rename to frc971/control_loops/drivetrain/localization/localizer_output.fbs
diff --git a/y2022/control_loops/drivetrain/localizer.cc b/frc971/control_loops/drivetrain/localization/puppet_localizer.cc
similarity index 82%
rename from y2022/control_loops/drivetrain/localizer.cc
rename to frc971/control_loops/drivetrain/localization/puppet_localizer.cc
index 0aa4456..3125793 100644
--- a/y2022/control_loops/drivetrain/localizer.cc
+++ b/frc971/control_loops/drivetrain/localization/puppet_localizer.cc
@@ -1,10 +1,10 @@
-#include "y2022/control_loops/drivetrain/localizer.h"
+#include "frc971/control_loops/drivetrain/localization/puppet_localizer.h"
 
-namespace y2022 {
+namespace frc971 {
 namespace control_loops {
 namespace drivetrain {
 
-Localizer::Localizer(
+PuppetLocalizer::PuppetLocalizer(
     aos::EventLoop *event_loop,
     const frc971::control_loops::drivetrain::DrivetrainConfig<double>
         &dt_config)
@@ -15,8 +15,6 @@
       localizer_output_fetcher_(
           event_loop_->MakeFetcher<frc971::controls::LocalizerOutput>(
               "/localizer")),
-      joystick_state_fetcher_(
-          event_loop_->MakeFetcher<aos::JoystickState>("/aos")),
       clock_offset_fetcher_(
           event_loop_->MakeFetcher<aos::message_bridge::ServerStatistics>(
               "/aos")) {
@@ -30,7 +28,7 @@
   target_selector_.set_has_target(false);
 }
 
-void Localizer::Reset(
+void PuppetLocalizer::Reset(
     aos::monotonic_clock::time_point t,
     const frc971::control_loops::drivetrain::HybridEkf<double>::State &state) {
   // Go through and clear out all of the fetchers so that we don't get behind.
@@ -38,19 +36,12 @@
   ekf_.ResetInitialState(t, state.cast<float>(), ekf_.P());
 }
 
-void Localizer::Update(const Eigen::Matrix<double, 2, 1> &U,
+void PuppetLocalizer::Update(const Eigen::Matrix<double, 2, 1> &U,
                        aos::monotonic_clock::time_point now,
                        double left_encoder, double right_encoder,
                        double gyro_rate, const Eigen::Vector3d &accel) {
   ekf_.UpdateEncodersAndGyro(left_encoder, right_encoder, gyro_rate,
                              U.cast<float>(), accel.cast<float>(), now);
-  joystick_state_fetcher_.Fetch();
-  if (joystick_state_fetcher_.get() != nullptr &&
-      joystick_state_fetcher_->autonomous()) {
-    // TODO(james): This is an inelegant way to avoid having the localizer mess
-    // up splines. Do better.
-    // return;
-  }
   if (localizer_output_fetcher_.Fetch()) {
     clock_offset_fetcher_.Fetch();
     bool message_bridge_connected = true;
@@ -79,8 +70,6 @@
         std::chrono::nanoseconds(
             localizer_output_fetcher_->monotonic_timestamp_ns()) -
         monotonic_offset);
-    // TODO: Finish implementing simple x/y/theta updater with state_at_capture.
-    // TODO: Implement turret/camera processing logic on pi side.
     std::optional<State> state_at_capture =
         ekf_.LastStateBeforeTime(capture_time);
     if (!state_at_capture.has_value()) {
@@ -104,4 +93,4 @@
 
 }  // namespace drivetrain
 }  // namespace control_loops
-}  // namespace y2022
+}  // namespace frc971
diff --git a/y2022/control_loops/drivetrain/localizer.h b/frc971/control_loops/drivetrain/localization/puppet_localizer.h
similarity index 78%
rename from y2022/control_loops/drivetrain/localizer.h
rename to frc971/control_loops/drivetrain/localization/puppet_localizer.h
index 77b29eb..4f8f4f3 100644
--- a/y2022/control_loops/drivetrain/localizer.h
+++ b/frc971/control_loops/drivetrain/localization/puppet_localizer.h
@@ -1,5 +1,5 @@
-#ifndef Y2022_CONTROL_LOOPS_DRIVETRAIN_LOCALIZER_H_
-#define Y2022_CONTROL_LOOPS_DRIVETRAIN_LOCALIZER_H_
+#ifndef FRC971_CONTROL_LOOPS_DRIVETRAIN_LOCALIZATION_PUPPET_LOCALIZER_H_
+#define FRC971_CONTROL_LOOPS_DRIVETRAIN_LOCALIZATION_PUPPET_LOCALIZER_H_
 
 #include <string_view>
 
@@ -7,20 +7,20 @@
 #include "aos/network/message_bridge_server_generated.h"
 #include "frc971/control_loops/drivetrain/hybrid_ekf.h"
 #include "frc971/control_loops/drivetrain/localizer.h"
-#include "frc971/input/joystick_state_generated.h"
-#include "y2022/localizer/localizer_output_generated.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
 
-namespace y2022 {
+namespace frc971 {
 namespace control_loops {
 namespace drivetrain {
 
-// This class handles the localization for the 2022 robot. Rather than actually
-// doing any work on the roborio, we farm all the localization out to a
+// This class handles the localization for the 2022/2023 robots. Rather than
+// actually doing any work on the roborio, we farm all the localization out to a
 // raspberry pi and it then sends out LocalizerOutput messages that we treat as
-// measurement updates. See //y2022/localizer.
-// TODO(james): Needs tests. Should refactor out some of the code from the 2020
-// localizer test.
-class Localizer : public frc971::control_loops::drivetrain::LocalizerInterface {
+// measurement updates. See //y202*/localizer.
+// TODO(james): Needs more tests. Should refactor out some of the code from the
+// 2020 localizer test.
+class PuppetLocalizer
+    : public frc971::control_loops::drivetrain::LocalizerInterface {
  public:
   typedef frc971::control_loops::TypedPose<float> Pose;
   typedef frc971::control_loops::drivetrain::HybridEkf<float> HybridEkf;
@@ -29,9 +29,10 @@
   typedef typename HybridEkf::StateSquare StateSquare;
   typedef typename HybridEkf::Input Input;
   typedef typename HybridEkf::Output Output;
-  Localizer(aos::EventLoop *event_loop,
-            const frc971::control_loops::drivetrain::DrivetrainConfig<double>
-                &dt_config);
+  PuppetLocalizer(
+      aos::EventLoop *event_loop,
+      const frc971::control_loops::drivetrain::DrivetrainConfig<double>
+          &dt_config);
   frc971::control_loops::drivetrain::HybridEkf<double>::State Xhat()
       const override {
     return ekf_.X_hat().cast<double>();
@@ -93,7 +94,6 @@
   HybridEkf::ExpectedObservationAllocator<Corrector> observations_;
 
   aos::Fetcher<frc971::controls::LocalizerOutput> localizer_output_fetcher_;
-  aos::Fetcher<aos::JoystickState> joystick_state_fetcher_;
   aos::Fetcher<aos::message_bridge::ServerStatistics> clock_offset_fetcher_;
 
   // Target selector to allow us to satisfy the LocalizerInterface requirements.
@@ -102,6 +102,6 @@
 
 }  // namespace drivetrain
 }  // namespace control_loops
-}  // namespace y2022
+}  // namespace frc971
 
-#endif  // Y2022_CONTROL_LOOPS_DRIVETRAIN_LOCALIZER_H_
+#endif  // FRC971_CONTROL_LOOPS_DRIVETRAIN_LOCALIZATION_PUPPET_LOCALIZER_H_
diff --git a/y2022/control_loops/drivetrain/localizer_test.cc b/frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc
similarity index 92%
rename from y2022/control_loops/drivetrain/localizer_test.cc
rename to frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc
index 77f3988..d64c419 100644
--- a/y2022/control_loops/drivetrain/localizer_test.cc
+++ b/frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc
@@ -1,4 +1,4 @@
-#include "y2022/control_loops/drivetrain/localizer.h"
+#include "frc971/control_loops/drivetrain/localization/puppet_localizer.h"
 
 #include <queue>
 
@@ -8,17 +8,17 @@
 #include "aos/network/testing_time_converter.h"
 #include "frc971/control_loops/control_loop_test.h"
 #include "frc971/control_loops/drivetrain/drivetrain.h"
-#include "frc971/control_loops/drivetrain/drivetrain_test_lib.h"
 #include "frc971/control_loops/team_number_test_environment.h"
 #include "gtest/gtest.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
+#include "frc971/control_loops/drivetrain/drivetrain_test_lib.h"
 #include "y2022/control_loops/drivetrain/drivetrain_base.h"
-#include "y2022/localizer/localizer_output_generated.h"
 
 DEFINE_string(output_folder, "",
               "If set, logs all channels to the provided logfile.");
 DECLARE_bool(die_on_malloc);
 
-namespace y2022 {
+namespace frc971 {
 namespace control_loops {
 namespace drivetrain {
 namespace testing {
@@ -29,7 +29,8 @@
 
 namespace {
 DrivetrainConfig<double> GetTest2022DrivetrainConfig() {
-  DrivetrainConfig<double> config = GetDrivetrainConfig();
+  DrivetrainConfig<double> config =
+      y2022::control_loops::drivetrain::GetDrivetrainConfig();
   return config;
 }
 }  // namespace
@@ -39,11 +40,13 @@
 using frc971::control_loops::drivetrain::DrivetrainLoop;
 using frc971::control_loops::drivetrain::testing::DrivetrainSimulation;
 
+
 // TODO(james): Make it so this actually tests the full system of the localizer.
 class LocalizedDrivetrainTest : public frc971::testing::ControlLoopTest {
  protected:
-  // We must use the 2022 drivetrain config so that we don't have to deal
-  // with shifting:
+  // We must use the 2022 drivetrain config so that we actually have a multi-nde
+  // config with a LocalizerOutput message.
+  // TODO(james): Refactor this test to be year-agnostic.
   LocalizedDrivetrainTest()
       : frc971::testing::ControlLoopTest(
             aos::configuration::ReadConfig(
@@ -108,7 +111,8 @@
   void SetStartingPosition(const Eigen::Matrix<double, 3, 1> &xytheta) {
     *drivetrain_plant_.mutable_state() << xytheta.x(), xytheta.y(),
         xytheta(2, 0), 0.0, 0.0;
-    Eigen::Matrix<double, Localizer::HybridEkf::kNStates, 1> localizer_state;
+    Eigen::Matrix<double, PuppetLocalizer::HybridEkf::kNStates, 1>
+        localizer_state;
     localizer_state.setZero();
     localizer_state.block<3, 1>(0, 0) = xytheta;
     localizer_.Reset(monotonic_now(), localizer_state);
@@ -169,7 +173,7 @@
   std::unique_ptr<aos::EventLoop> drivetrain_event_loop_;
   const frc971::control_loops::drivetrain::DrivetrainConfig<double> dt_config_;
 
-  Localizer localizer_;
+  PuppetLocalizer localizer_;
   DrivetrainLoop drivetrain_;
 
   std::unique_ptr<aos::EventLoop> drivetrain_plant_event_loop_;
@@ -208,4 +212,4 @@
 }  // namespace testing
 }  // namespace drivetrain
 }  // namespace control_loops
-}  // namespace y2022
+}  // namespace frc971
diff --git a/frc971/control_loops/drivetrain/localization/utils.cc b/frc971/control_loops/drivetrain/localization/utils.cc
new file mode 100644
index 0000000..ff027d0
--- /dev/null
+++ b/frc971/control_loops/drivetrain/localization/utils.cc
@@ -0,0 +1,72 @@
+#include "frc971/control_loops/drivetrain/localization/utils.h"
+
+namespace frc971::control_loops::drivetrain {
+
+LocalizationUtils::LocalizationUtils(aos::EventLoop *event_loop)
+    : output_fetcher_(event_loop->MakeFetcher<Output>("/drivetrain")),
+      clock_offset_fetcher_(
+          event_loop->MakeFetcher<aos::message_bridge::ServerStatistics>(
+              "/aos")),
+      joystick_state_fetcher_(
+          event_loop->MakeFetcher<aos::JoystickState>("/roborio/aos")) {}
+
+Eigen::Vector2d LocalizationUtils::VoltageOrZero(
+    aos::monotonic_clock::time_point now) {
+  output_fetcher_.Fetch();
+  // Determine if the robot is likely to be disabled currently.
+  const bool disabled = (output_fetcher_.get() == nullptr) ||
+                        (output_fetcher_.context().monotonic_event_time +
+                             std::chrono::milliseconds(10) <
+                         now);
+  return disabled ? Eigen::Vector2d::Zero()
+                  : Eigen::Vector2d{output_fetcher_->left_voltage(),
+                                    output_fetcher_->right_voltage()};
+}
+
+bool LocalizationUtils::MaybeInAutonomous() {
+  joystick_state_fetcher_.Fetch();
+  return (joystick_state_fetcher_.get() != nullptr)
+             ? joystick_state_fetcher_->autonomous()
+             : true;
+}
+
+std::optional<aos::monotonic_clock::duration> LocalizationUtils::ClockOffset(
+    std::string_view node) {
+  std::optional<aos::monotonic_clock::duration> monotonic_offset;
+  clock_offset_fetcher_.Fetch();
+  if (clock_offset_fetcher_.get() != nullptr) {
+    for (const auto connection : *clock_offset_fetcher_->connections()) {
+      if (connection->has_node() && connection->node()->has_name() &&
+          connection->node()->name()->string_view() == node) {
+        if (connection->has_monotonic_offset()) {
+          monotonic_offset =
+              std::chrono::nanoseconds(connection->monotonic_offset());
+        } else {
+          // If we don't have a monotonic offset, that means we aren't
+          // connected.
+          return std::nullopt;
+        }
+        break;
+      }
+    }
+  }
+  CHECK(monotonic_offset.has_value());
+  return monotonic_offset;
+}
+
+// Technically, this should be able to do a single memcpy, but the extra
+// verbosity here seems appropriate.
+Eigen::Matrix<double, 4, 4> FlatbufferToTransformationMatrix(
+    const frc971::vision::calibration::TransformationMatrix &flatbuffer) {
+  CHECK_EQ(16u, CHECK_NOTNULL(flatbuffer.data())->size());
+  Eigen::Matrix<double, 4, 4> result;
+  result.setIdentity();
+  for (int row = 0; row < 4; ++row) {
+    for (int col = 0; col < 4; ++col) {
+      result(row, col) = (*flatbuffer.data())[row * 4 + col];
+    }
+  }
+  return result;
+}
+
+}  // namespace frc971::control_loops::drivetrain
diff --git a/frc971/control_loops/drivetrain/localization/utils.h b/frc971/control_loops/drivetrain/localization/utils.h
new file mode 100644
index 0000000..26242f9
--- /dev/null
+++ b/frc971/control_loops/drivetrain/localization/utils.h
@@ -0,0 +1,51 @@
+#ifndef FRC971_CONTROL_LOOPS_DRIVETRAIN_LOCALIZATION_UTILS_H_
+#define FRC971_CONTROL_LOOPS_DRIVETRAIN_LOCALIZATION_UTILS_H_
+#include <Eigen/Dense>
+
+#include "aos/events/event_loop.h"
+#include "aos/network/message_bridge_server_generated.h"
+#include "frc971/control_loops/drivetrain/drivetrain_output_generated.h"
+#include "frc971/input/joystick_state_generated.h"
+#include "frc971/vision/calibration_generated.h"
+
+namespace frc971::control_loops::drivetrain {
+// This class provides a variety of checks that have generally proved useful for
+// the localizer but which have no clear place to live otherwise.
+// Specifically, it tracks:
+// * Drivetrain voltages, including checks for whether the Output message
+//   has timed out.
+// * Offsets between monotonic clocks on different devices.
+// * Whether we are in autonomous mode.
+class LocalizationUtils {
+ public:
+  LocalizationUtils(aos::EventLoop *event_loop);
+
+  // Returns the latest drivetrain output voltage, or zero if no output is
+  // available (which happens when the robot is disabled; when the robot is
+  // disabled, the voltage is functionally zero). Return value will be
+  // [left_voltage, right_voltage]
+  Eigen::Vector2d VoltageOrZero(aos::monotonic_clock::time_point now);
+
+  // Returns true if either there is no JoystickState message available or if
+  // we are currently in autonomous mode.
+  bool MaybeInAutonomous();
+
+  // Returns the offset between our node and the specified node (or nullopt if
+  // no offset is available). The sign of this will be such that the time on
+  // the remote node = time on our node + ClockOffset().
+  std::optional<aos::monotonic_clock::duration> ClockOffset(
+      std::string_view node);
+
+ private:
+  aos::Fetcher<frc971::control_loops::drivetrain::Output> output_fetcher_;
+  aos::Fetcher<aos::message_bridge::ServerStatistics> clock_offset_fetcher_;
+  aos::Fetcher<aos::JoystickState> joystick_state_fetcher_;
+};
+
+// Converts a flatbuffer TransformationMatrix to an Eigen matrix.
+Eigen::Matrix<double, 4, 4> FlatbufferToTransformationMatrix(
+    const frc971::vision::calibration::TransformationMatrix &flatbuffer);
+
+}  // namespace frc971::control_loops::drivetrain
+
+#endif  // FRC971_CONTROL_LOOPS_DRIVETRAIN_LOCALIZATION_UTILS_H_
diff --git a/frc971/control_loops/drivetrain/robot_state_plotter.ts b/frc971/control_loops/drivetrain/robot_state_plotter.ts
index 2ce8001..8cffd76 100644
--- a/frc971/control_loops/drivetrain/robot_state_plotter.ts
+++ b/frc971/control_loops/drivetrain/robot_state_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/frc971/control_loops/drivetrain/spline_plotter.ts b/frc971/control_loops/drivetrain/spline_plotter.ts
index c39afd5..67e9fc7 100644
--- a/frc971/control_loops/drivetrain/spline_plotter.ts
+++ b/frc971/control_loops/drivetrain/spline_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging drivetrain-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
+import * as proxy from '../../../aos/network/www/proxy';
 
 import Connection = proxy.Connection;
 
diff --git a/frc971/control_loops/drivetrain/spline_test.cc b/frc971/control_loops/drivetrain/spline_test.cc
index ca4d128..015ec6a 100644
--- a/frc971/control_loops/drivetrain/spline_test.cc
+++ b/frc971/control_loops/drivetrain/spline_test.cc
@@ -129,11 +129,13 @@
     plotter_->AddLine(alphas_plot, idy_plot, "Integrated dY");
     plotter_->XLabel("Spline Alpha");
     plotter_->YLabel("X/Y (m), dX, dY (m / alpha)");
+    plotter_->Publish();
 
     plotter_->AddFigure("X/Y Plot of Spline Path");
     plotter_->AddLine(x_plot, y_plot, "spline");
     plotter_->XLabel("X (m)");
     plotter_->YLabel("Y (m)");
+    plotter_->Publish();
   }
 }
 
diff --git a/frc971/control_loops/fixed_quadrature.h b/frc971/control_loops/fixed_quadrature.h
index 572c235..22d4984 100644
--- a/frc971/control_loops/fixed_quadrature.h
+++ b/frc971/control_loops/fixed_quadrature.h
@@ -1,6 +1,7 @@
 #ifndef FRC971_CONTROL_LOOPS_FIXED_QUADRATURE_H_
 #define FRC971_CONTROL_LOOPS_FIXED_QUADRATURE_H_
 
+#include <Eigen/Dense>
 #include <array>
 
 namespace frc971 {
@@ -9,8 +10,8 @@
 // Implements Gaussian Quadrature integration (5th order).  fn is the function to
 // integrate.  It must take 1 argument of type T.  The integration is between a
 // and b.
-template <typename F, typename T>
-T GaussianQuadrature5(const F &fn, T a, T b) {
+template <typename T, typename F>
+double GaussianQuadrature5(const F &fn, T a, T b) {
   // Pulled from Python.
   // numpy.set_printoptions(precision=20)
   // scipy.special.p_roots(5)
@@ -31,6 +32,30 @@
   return answer;
 }
 
+template <size_t N, typename F>
+Eigen::Matrix<double, N, 1> MatrixGaussianQuadrature5(const F &fn, double a,
+                                                      double b) {
+  // Pulled from Python.
+  // numpy.set_printoptions(precision=20)
+  // scipy.special.p_roots(5)
+  const ::std::array<double, 5> x{{
+      -9.06179845938663630633e-01, -5.38469310105682885670e-01,
+      3.24607628916367383789e-17, 5.38469310105683218737e-01,
+      9.06179845938663408589e-01}};
+
+  const ::std::array<double, 5> w{{
+      0.23692688505618844652, 0.4786286704993669705, 0.56888888888888811124,
+      0.47862867049936674846, 0.23692688505618875183}};
+
+  Eigen::Matrix<double, N, 1> answer;
+  answer.setZero();
+  for (int i = 0; i < 5; ++i) {
+    const double y = (b - a) * (x[i] + 1) / 2.0 + a;
+    answer += (b - a) / 2.0 * w[i] * fn(y);
+  }
+  return answer;
+}
+
 }  // namespace control_loops
 }  // namespace frc971
 
diff --git a/frc971/control_loops/fixed_quadrature_test.cc b/frc971/control_loops/fixed_quadrature_test.cc
index f842519..0615031 100644
--- a/frc971/control_loops/fixed_quadrature_test.cc
+++ b/frc971/control_loops/fixed_quadrature_test.cc
@@ -16,6 +16,18 @@
   EXPECT_NEAR(y1, ::std::sin(0.5), 1e-15);
 }
 
+// Tests that integrating y = [cos(x), sin(x)] works.
+TEST(GaussianQuadratureTest, MatrixCos) {
+  Eigen::Matrix<double, 2, 1> y1 = MatrixGaussianQuadrature5<2>(
+      [](double x) {
+        return Eigen::Matrix<double, 2, 1>(std::cos(x), std::sin(x));
+      },
+      0.0, 0.5);
+
+  EXPECT_TRUE(y1.isApprox(Eigen::Matrix<double, 2, 1>(
+      ::std::sin(0.5), -std::cos(0.5) + std::cos(0))));
+}
+
 }  // namespace testing
 }  // namespace control_loops
 }  // namespace frc971
diff --git a/frc971/control_loops/python/BUILD b/frc971/control_loops/python/BUILD
index 7b46439..c547833 100644
--- a/frc971/control_loops/python/BUILD
+++ b/frc971/control_loops/python/BUILD
@@ -29,6 +29,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -77,6 +78,7 @@
         ":controls",
         ":python_init",
         "@pip//matplotlib",
+        "@pip//pygobject",
     ],
 )
 
@@ -90,6 +92,7 @@
         ":controls",
         ":python_init",
         "@pip//matplotlib",
+        "@pip//pygobject",
     ],
 )
 
@@ -132,6 +135,7 @@
         ":python_init",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
     ],
 )
 
@@ -153,6 +157,7 @@
         "//aos/util:py_trapezoid_profile",
         "//frc971/control_loops:python_init",
         "@pip//matplotlib",
+        "@pip//pygobject",
     ],
 )
 
@@ -166,6 +171,7 @@
         "//aos/util:py_trapezoid_profile",
         "//frc971/control_loops:python_init",
         "@pip//matplotlib",
+        "@pip//pygobject",
     ],
 )
 
diff --git a/frc971/control_loops/python/angular_system.py b/frc971/control_loops/python/angular_system.py
index 6772e71..c6ffb50 100755
--- a/frc971/control_loops/python/angular_system.py
+++ b/frc971/control_loops/python/angular_system.py
@@ -120,11 +120,15 @@
 
         self.R = numpy.matrix([[(self.params.kalman_r_position**2.0)]])
 
-        self.KalmanGain, self.Q_steady = controls.kalman(A=self.A,
-                                                         B=self.B,
-                                                         C=self.C,
-                                                         Q=self.Q,
-                                                         R=self.R)
+        # From testing, these continuous Q and R's appear to be good approximations of Q and R.
+        self.Q_continuous = self.Q / self.dt
+        self.R_continuous = self.R * self.dt
+
+        self.KalmanGain, self.P_steady_state = controls.kalman(A=self.A,
+                                                               B=self.B,
+                                                               C=self.C,
+                                                               Q=self.Q,
+                                                               R=self.R)
 
         glog.debug('Kal %s', repr(self.KalmanGain))
 
@@ -165,11 +169,15 @@
 
         self.R = numpy.matrix([[(self.params.kalman_r_position**2.0)]])
 
-        self.KalmanGain, self.Q_steady = controls.kalman(A=self.A,
-                                                         B=self.B,
-                                                         C=self.C,
-                                                         Q=self.Q,
-                                                         R=self.R)
+        # From testing, these continuous Q and R's appear to be good approximations of Q and R.
+        self.Q_continuous = self.Q / self.dt
+        self.R_continuous = self.R * self.dt
+
+        self.KalmanGain, self.P_steady_state = controls.kalman(A=self.A,
+                                                               B=self.B,
+                                                               C=self.C,
+                                                               Q=self.Q,
+                                                               R=self.R)
 
         self.K_unaugmented = self.K
         self.K = numpy.matrix(numpy.zeros((1, 3)))
@@ -409,7 +417,12 @@
             max_acceleration=max_acceleration)
 
 
-def WriteAngularSystem(params, plant_files, controller_files, year_namespaces):
+def WriteAngularSystem(params,
+                       plant_files,
+                       controller_files,
+                       year_namespaces,
+                       plant_type='StateFeedbackPlant',
+                       observer_type='StateFeedbackObserver'):
     """Writes out the constants for a angular system to a file.
 
     Args:
@@ -440,7 +453,9 @@
 
     loop_writer = control_loop.ControlLoopWriter(name,
                                                  angular_systems,
-                                                 namespaces=year_namespaces)
+                                                 namespaces=year_namespaces,
+                                                 plant_type=plant_type,
+                                                 observer_type=observer_type)
     loop_writer.AddConstant(
         control_loop.Constant('kOutputRatio', '%f', angular_systems[0].G))
     loop_writer.AddConstant(
@@ -451,5 +466,7 @@
     integral_loop_writer = control_loop.ControlLoopWriter(
         'Integral' + name,
         integral_angular_systems,
-        namespaces=year_namespaces)
+        namespaces=year_namespaces,
+        plant_type=plant_type,
+        observer_type=observer_type)
     integral_loop_writer.Write(controller_files[0], controller_files[1])
diff --git a/frc971/control_loops/runge_kutta.h b/frc971/control_loops/runge_kutta.h
index 963f463..7fa3f3e 100644
--- a/frc971/control_loops/runge_kutta.h
+++ b/frc971/control_loops/runge_kutta.h
@@ -31,8 +31,8 @@
   return X;
 }
 
-// Implements Runge Kutta integration (4th order).  This integrates dy/dt = fn(t,
-// y).  It must have the call signature of fn(double t, T y).  The
+// Implements Runge Kutta integration (4th order).  This integrates dy/dt =
+// fn(t, y).  It must have the call signature of fn(double t, T y).  The
 // integration starts at an initial value y, and integrates for dt.
 template <typename F, typename T>
 T RungeKutta(const F &fn, T y, double t, double dt) {
@@ -45,6 +45,15 @@
   return y + (k1 + 2.0 * k2 + 2.0 * k3 + k4) / 6.0;
 }
 
+template <typename F, typename T>
+T RungeKuttaSteps(const F &fn, T X, double t, double dt, int steps) {
+  dt = dt / steps;
+  for (int i = 0; i < steps; ++i) {
+    X = RungeKutta(fn, X, t + dt * i, dt);
+  }
+  return X;
+}
+
 // Implements Runge Kutta integration (4th order).  fn is the function to
 // integrate.  It must take 1 argument of type T.  The integration starts at an
 // initial value X, and integrates for dt.
diff --git a/frc971/control_loops/runge_kutta_test.cc b/frc971/control_loops/runge_kutta_test.cc
index 615f82c..e07c469 100644
--- a/frc971/control_loops/runge_kutta_test.cc
+++ b/frc971/control_loops/runge_kutta_test.cc
@@ -9,7 +9,7 @@
 // Tests that integrating dx/dt = e^x works.
 TEST(RungeKuttaTest, Exponential) {
   ::Eigen::Matrix<double, 1, 1> y0;
-  y0(0, 0) = 0.0;
+  y0(0, 0) = 1.0;
 
   ::Eigen::Matrix<double, 1, 1> y1 = RungeKutta(
       [](::Eigen::Matrix<double, 1, 1> x) {
@@ -18,7 +18,22 @@
         return y;
       },
       y0, 0.1);
-  EXPECT_NEAR(y1(0, 0), ::std::exp(0.1) - ::std::exp(0), 1e-3);
+  EXPECT_NEAR(y1(0, 0), -std::log(std::exp(-1.0) - 0.1), 1e-5);
+}
+
+// Now do it with sub steps.
+TEST(RungeKuttaTest, ExponentialSteps) {
+  ::Eigen::Matrix<double, 1, 1> y0;
+  y0(0, 0) = 1.0;
+
+  ::Eigen::Matrix<double, 1, 1> y1 = RungeKuttaSteps(
+      [](::Eigen::Matrix<double, 1, 1> x) {
+        ::Eigen::Matrix<double, 1, 1> y;
+        y(0, 0) = ::std::exp(x(0, 0));
+        return y;
+      },
+      y0, 0.1, 10);
+  EXPECT_NEAR(y1(0, 0), -std::log(std::exp(-1.0) - 0.1), 1e-8);
 }
 
 // Tests that integrating dx/dt = e^x works when we provide a U.
@@ -63,6 +78,20 @@
   EXPECT_NEAR(y1(0, 0), RungeKuttaTimeVaryingSolution(6.0)(0, 0), 1e-3);
 }
 
+// Now do it with a ton of sub steps.
+TEST(RungeKuttaTest, RungeKuttaTimeVaryingSteps) {
+  ::Eigen::Matrix<double, 1, 1> y0 = RungeKuttaTimeVaryingSolution(5.0);
+
+  ::Eigen::Matrix<double, 1, 1> y1 = RungeKuttaSteps(
+      [](double t, ::Eigen::Matrix<double, 1, 1> x) {
+        return (::Eigen::Matrix<double, 1, 1>()
+                << x(0, 0) * (2.0 / (::std::exp(t) + 1.0) - 1.0))
+            .finished();
+      },
+      y0, 5.0, 1.0, 10);
+  EXPECT_NEAR(y1(0, 0), RungeKuttaTimeVaryingSolution(6.0)(0, 0), 1e-7);
+}
+
 }  // namespace testing
 }  // namespace control_loops
 }  // namespace frc971
diff --git a/frc971/image_streamer/www/BUILD b/frc971/image_streamer/www/BUILD
index 908c5b4..f808a73 100644
--- a/frc971/image_streamer/www/BUILD
+++ b/frc971/image_streamer/www/BUILD
@@ -1,5 +1,4 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
 load("//frc971/downloader:downloader.bzl", "aos_downloader_dir")
 
 package(default_visibility = ["//visibility:public"])
@@ -12,7 +11,7 @@
     ]),
 )
 
-ts_library(
+ts_project(
     name = "proxy",
     srcs = [
         "proxy.ts",
@@ -23,7 +22,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "main",
     srcs = [
         "main.ts",
diff --git a/frc971/image_streamer/www/proxy.ts b/frc971/image_streamer/www/proxy.ts
index bfe8135..517d203 100644
--- a/frc971/image_streamer/www/proxy.ts
+++ b/frc971/image_streamer/www/proxy.ts
@@ -1,5 +1,5 @@
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {Payload, SdpType, WebSocketIce, WebSocketMessage, WebSocketSdp} from 'org_frc971/aos/network/web_proxy_generated';
+import {Payload, SdpType, WebSocketIce, WebSocketMessage, WebSocketSdp} from '../../../aos/network/web_proxy_generated';
 
 // Port 9 is used to indicate an active (outgoing) TCP connection. The server
 // would send a corresponding candidate with the actual TCP port it is
diff --git a/frc971/imu_reader/BUILD b/frc971/imu_reader/BUILD
index 7d424c4..4825900 100644
--- a/frc971/imu_reader/BUILD
+++ b/frc971/imu_reader/BUILD
@@ -1,3 +1,6 @@
+load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
+load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
+
 cc_library(
     name = "imu",
     srcs = [
@@ -14,8 +17,38 @@
         "//aos/util:crc32",
         "//frc971/wpilib:imu_batch_fbs",
         "//frc971/wpilib:imu_fbs",
-        "//y2022:constants",
         "@com_github_google_glog//:glog",
         "@com_google_absl//absl/types:span",
     ],
 )
+
+flatbuffer_cc_library(
+    name = "imu_failures_fbs",
+    srcs = [
+        "imu_failures.fbs",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+flatbuffer_ts_library(
+    name = "imu_failures_ts_fbs",
+    srcs = [
+        "imu_failures.fbs",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+cc_library(
+    name = "imu_watcher",
+    srcs = ["imu_watcher.cc"],
+    hdrs = ["imu_watcher.h"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":imu_failures_fbs",
+        "//aos/events:event_loop",
+        "//frc971/control_loops/drivetrain:drivetrain_config",
+        "//frc971/wpilib:imu_batch_fbs",
+        "//frc971/zeroing:imu_zeroer",
+        "//frc971/zeroing:wrap",
+    ],
+)
diff --git a/frc971/imu_reader/imu_failures.fbs b/frc971/imu_reader/imu_failures.fbs
new file mode 100644
index 0000000..e6d9d85
--- /dev/null
+++ b/frc971/imu_reader/imu_failures.fbs
@@ -0,0 +1,16 @@
+namespace frc971.controls;
+
+// Counters to track how many times different errors have occurred.
+table ImuFailures {
+  // Count of total number of checksum mismatches between the IMU and the
+  // pico itself.
+  imu_to_pico_checksum_mismatch:uint (id: 0);
+  // Count of total number of checksum mismatches between the pico board
+  // and the raspberry pi.
+  pico_to_pi_checksum_mismatch:uint (id: 1);
+  // Total number of dropped/missed messages.
+  missed_messages:uint (id: 2);
+  // Total number of messages dropped for any other conditions that can fault
+  // the zeroer (e.g., diagnostic failures in the IMU).
+  other_zeroing_faults:uint (id: 3);
+}
diff --git a/frc971/imu_reader/imu_watcher.cc b/frc971/imu_reader/imu_watcher.cc
new file mode 100644
index 0000000..0e0d656
--- /dev/null
+++ b/frc971/imu_reader/imu_watcher.cc
@@ -0,0 +1,117 @@
+#include "frc971/imu_reader/imu_watcher.h"
+
+#include "frc971/wpilib/imu_batch_generated.h"
+
+namespace frc971::controls {
+namespace {
+// Return the amount of distance that the drivetrain can travel before the
+// encoders will wrap. Necessary because the pico only sends over the encoders
+// in 16-bit counters, which will wrap relatively readily.
+double EncoderWrapDistance(double drivetrain_distance_per_encoder_tick) {
+  return drivetrain_distance_per_encoder_tick * (1 << 16);
+}
+}  // namespace
+ImuWatcher::ImuWatcher(
+    aos::EventLoop *event_loop,
+    const control_loops::drivetrain::DrivetrainConfig<double> &dt_config,
+    const double drivetrain_distance_per_encoder_tick,
+    std::function<
+        void(aos::monotonic_clock::time_point, aos::monotonic_clock::time_point,
+             std::optional<Eigen::Vector2d>, Eigen::Vector3d, Eigen::Vector3d)>
+        callback)
+    : dt_config_(dt_config),
+      callback_(std::move(callback)),
+      zeroer_(zeroing::ImuZeroer::FaultBehavior::kTemporary),
+      left_encoder_(
+          -EncoderWrapDistance(drivetrain_distance_per_encoder_tick) / 2.0,
+          EncoderWrapDistance(drivetrain_distance_per_encoder_tick)),
+      right_encoder_(
+          -EncoderWrapDistance(drivetrain_distance_per_encoder_tick) / 2.0,
+          EncoderWrapDistance(drivetrain_distance_per_encoder_tick)) {
+  event_loop->MakeWatcher("/localizer", [this, event_loop](
+                                            const IMUValuesBatch &values) {
+    CHECK(values.has_readings());
+    for (const IMUValues *value : *values.readings()) {
+      zeroer_.InsertAndProcessMeasurement(*value);
+      if (zeroer_.Faulted()) {
+        if (value->checksum_failed()) {
+          imu_fault_tracker_.pico_to_pi_checksum_mismatch++;
+        } else if (value->previous_reading_diag_stat()->checksum_mismatch()) {
+          imu_fault_tracker_.imu_to_pico_checksum_mismatch++;
+        } else {
+          imu_fault_tracker_.other_zeroing_faults++;
+        }
+      } else {
+        if (!first_valid_data_counter_.has_value()) {
+          first_valid_data_counter_ = value->data_counter();
+        }
+      }
+      if (first_valid_data_counter_.has_value()) {
+        total_imu_messages_received_++;
+        // Only update when we have good checksums, since the data counter
+        // could get corrupted.
+        if (!zeroer_.Faulted()) {
+          if (value->data_counter() < last_data_counter_) {
+            data_counter_offset_ += 1 << 16;
+          }
+          imu_fault_tracker_.missed_messages =
+              (1 + value->data_counter() + data_counter_offset_ -
+               first_valid_data_counter_.value()) -
+              total_imu_messages_received_;
+          last_data_counter_ = value->data_counter();
+        }
+      }
+      // Set encoders to nullopt if we are faulted at all (faults may include
+      // checksum mismatches).
+      const std::optional<Eigen::Vector2d> encoders =
+          zeroer_.Faulted()
+              ? std::nullopt
+              : std::make_optional(Eigen::Vector2d{
+                    left_encoder_.Unwrap(value->left_encoder()),
+                    right_encoder_.Unwrap(value->right_encoder())});
+      {
+        // If we can't trust the imu reading, just naively increment the
+        // pico timestamp.
+        const aos::monotonic_clock::time_point pico_timestamp =
+            zeroer_.Faulted()
+                ? (last_pico_timestamp_.has_value()
+                       ? last_pico_timestamp_.value() + kNominalDt
+                       : aos::monotonic_clock::epoch())
+                : aos::monotonic_clock::time_point(
+                      std::chrono::microseconds(value->pico_timestamp_us()));
+        // TODO(james): If we get large enough drift off of the pico,
+        // actually do something about it.
+        if (!pico_offset_.has_value()) {
+          pico_offset_ =
+              event_loop->context().monotonic_event_time - pico_timestamp;
+          last_pico_timestamp_ = pico_timestamp;
+        }
+        if (pico_timestamp < last_pico_timestamp_) {
+          pico_offset_.value() += std::chrono::microseconds(1ULL << 32);
+        }
+        const aos::monotonic_clock::time_point sample_timestamp =
+            pico_offset_.value() + pico_timestamp;
+        pico_offset_error_ =
+            event_loop->context().monotonic_event_time - sample_timestamp;
+        const bool zeroed = zeroer_.Zeroed();
+
+        // When not zeroed, we aim to approximate zero acceleration by doing a
+        // zero-order hold on the gyro and setting the accelerometer readings to
+        // gravity.
+        callback_(sample_timestamp,
+                  aos::monotonic_clock::time_point(std::chrono::nanoseconds(
+                      value->monotonic_timestamp_ns())),
+                  encoders, zeroed ? zeroer_.ZeroedGyro().value() : last_gyro_,
+                  zeroed ? zeroer_.ZeroedAccel().value()
+                         : dt_config_.imu_transform.transpose() *
+                               Eigen::Vector3d::UnitZ());
+
+        if (zeroed) {
+          last_gyro_ = zeroer_.ZeroedGyro().value();
+        }
+        last_pico_timestamp_ = pico_timestamp;
+      }
+    }
+  });
+}
+}  // namespace frc971::controls
diff --git a/frc971/imu_reader/imu_watcher.h b/frc971/imu_reader/imu_watcher.h
new file mode 100644
index 0000000..8867266
--- /dev/null
+++ b/frc971/imu_reader/imu_watcher.h
@@ -0,0 +1,104 @@
+#ifndef FRC971_IMU_READER_IMU_WATCHER_H_
+#define FRC971_IMU_READER_IMU_WATCHER_H_
+
+#include "aos/events/event_loop.h"
+#include "frc971/control_loops/drivetrain/drivetrain_config.h"
+#include "frc971/imu_reader/imu_failures_generated.h"
+#include "frc971/zeroing/imu_zeroer.h"
+#include "frc971/zeroing/wrap.h"
+
+namespace frc971::controls {
+// This class handles listening to an IMUValuesBatch channel sourced off of our
+// ADIS16505 pico board and calling the user-specified callback with the
+// relevant data. This intermediary is used to unwrap encoder readings, check
+// for checksum mismatches, zero the gyro/accelerometer, and translate
+// timestamps between devices.
+// TODO(james): Get unit tests for this class specifically written (we already
+// have tests for the code that exercises this).
+class ImuWatcher {
+ public:
+  // Expected frequency of messages from the pico-based IMU.
+  static constexpr std::chrono::microseconds kNominalDt{500};
+
+  // The callback specified by the user will take:
+  // sample_time_pico: The pico-based timestamp corresponding to the measurement
+  //   time. This will be offset by roughly pico_offset_error from the pi's
+  //   monotonic clock.
+  // sample_time_pi: Timestamp from the kernel for when the pi observed the
+  //   relevant measurement.
+  // encoders: Current encoder values, [left, right]. nullopt if we have faults.
+  // gyro: Current gyro readings, in the raw IMU axes (i.e., these must be
+  //   rotated by dt_config.imu_transform before being used). Suitable
+  //   for input to the down estimator.
+  // accel: Current accelerometer readings, in the raw IMU axes (i.e., these
+  //   must be rotated by dt_config.imu_transform before being used). Suitable
+  //   for input to the down estimator.
+  ImuWatcher(
+      aos::EventLoop *event_loop,
+      const control_loops::drivetrain::DrivetrainConfig<double> &dt_config,
+      double drivetrain_distance_per_encoder_tick,
+      std::function<void(
+          aos::monotonic_clock::time_point, aos::monotonic_clock::time_point,
+          std::optional<Eigen::Vector2d>, Eigen::Vector3d, Eigen::Vector3d)>
+          callback);
+
+  const zeroing::ImuZeroer &zeroer() const { return zeroer_; }
+
+  flatbuffers::Offset<ImuFailures> PopulateImuFailures(
+      flatbuffers::FlatBufferBuilder *fbb) const {
+    return ImuFailures::Pack(*fbb, &imu_fault_tracker_);
+  }
+
+  // t = pico_offset + pico_timestamp.
+  // Note that this can drift over sufficiently long time periods!
+  std::optional<std::chrono::nanoseconds> pico_offset() const {
+    return pico_offset_;
+  }
+  // pico_offset_error = actual_time - (pico_offset + pico_timestamp)
+  // If the pico clock and pi clock are exactly in sync, this will always be
+  // zero.
+  aos::monotonic_clock::duration pico_offset_error() const {
+    return pico_offset_error_;
+  }
+
+ private:
+  const control_loops::drivetrain::DrivetrainConfig<double> dt_config_;
+  std::function<void(
+      aos::monotonic_clock::time_point, aos::monotonic_clock::time_point,
+      std::optional<Eigen::Vector2d>, Eigen::Vector3d, Eigen::Vector3d)>
+      callback_;
+
+  // Last observed pico measurement. Used to track IMU staleness.
+  std::optional<aos::monotonic_clock::time_point> last_pico_timestamp_;
+  // Estimate of the drift between the pi and pico clocks. See
+  // pico_offset_error() for definition.
+  aos::monotonic_clock::duration pico_offset_error_;
+  // Raw offset between the pico and pi clocks. Gets updated to compensate for
+  // wrapping in the pico timestamp.
+  std::optional<std::chrono::nanoseconds> pico_offset_;
+
+  zeroing::ImuZeroer zeroer_;
+
+  ImuFailuresT imu_fault_tracker_;
+  // The first observed data counter. This is used to help us track dropped
+  // messages.
+  std::optional<size_t> first_valid_data_counter_;
+  size_t total_imu_messages_received_ = 0;
+  // added to the current read data counter to allow the data counter to
+  // increase monotonically. Will be a multiple of 2 ** 16.
+  size_t data_counter_offset_ = 0;
+  // PRevious data counter value (data_counter_offset_ not included).
+  int last_data_counter_ = 0;
+
+  // Unwrappers for the left and right encoders (necessary because the pico only
+  // sends out 16-bit encoder counts).
+  zeroing::UnwrapSensor left_encoder_;
+  zeroing::UnwrapSensor right_encoder_;
+
+  // When we lose IMU readings (e.g., due to checksum mismatches), we perform a
+  // zero-order hold on gyro readings; in order to do this, store the most
+  // recent gyro readings.
+  Eigen::Vector3d last_gyro_ = Eigen::Vector3d::Zero();
+};
+}  // namespace frc971::controls
+#endif  // FRC971_IMU_READER_IMU_WATCHER_H_
diff --git a/frc971/raspi/rootfs/make_sd.sh b/frc971/raspi/rootfs/make_sd.sh
index 4c50a6b..01f94f8 100755
--- a/frc971/raspi/rootfs/make_sd.sh
+++ b/frc971/raspi/rootfs/make_sd.sh
@@ -4,7 +4,7 @@
 
 # Disk image to use for creating SD card
 # NOTE: You MUST run modify_rootfs.sh on this image BEFORE running make_sd.sh
-ORIG_IMAGE="2022-01-28-raspios-bullseye-arm64-lite.img"
+ORIG_IMAGE="arm64_bullseye_debian.img"
 IMAGE=`echo ${ORIG_IMAGE} | sed s/.img/-frc-mods.img/`
 DEVICE="/dev/sda"
 
diff --git a/frc971/rockpi/BUILD b/frc971/rockpi/BUILD
new file mode 100644
index 0000000..f6d1840
--- /dev/null
+++ b/frc971/rockpi/BUILD
@@ -0,0 +1 @@
+exports_files(["rockpi_config.json"])
diff --git a/frc971/rockpi/build_rootfs.sh b/frc971/rockpi/build_rootfs.sh
index 2e16419..bb8b84a 100755
--- a/frc971/rockpi/build_rootfs.sh
+++ b/frc971/rockpi/build_rootfs.sh
@@ -195,7 +195,7 @@
 
 target "apt-get -y install -t bullseye-backports bpfcc-tools"
 
-target "apt-get install -y sudo openssh-server python3 bash-completion git v4l-utils cpufrequtils pmount rsync vim-nox chrony libopencv-calib3d4.5 libopencv-contrib4.5 libopencv-core4.5 libopencv-features2d4.5 libopencv-flann4.5 libopencv-highgui4.5 libopencv-imgcodecs4.5 libopencv-imgproc4.5 libopencv-ml4.5 libopencv-objdetect4.5 libopencv-photo4.5 libopencv-shape4.5 libopencv-stitching4.5 libopencv-superres4.5 libopencv-video4.5 libopencv-videoio4.5 libopencv-videostab4.5 libopencv-viz4.5 libnice10 pmount libnice-dev feh libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 libgstreamer-plugins-bad1.0-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-nice usbutils locales trace-cmd clinfo jq strace sysstat"
+target "apt-get install -y sudo openssh-server python3 bash-completion git v4l-utils cpufrequtils pmount rsync vim-nox chrony libopencv-calib3d4.5 libopencv-contrib4.5 libopencv-core4.5 libopencv-features2d4.5 libopencv-flann4.5 libopencv-highgui4.5 libopencv-imgcodecs4.5 libopencv-imgproc4.5 libopencv-ml4.5 libopencv-objdetect4.5 libopencv-photo4.5 libopencv-shape4.5 libopencv-stitching4.5 libopencv-superres4.5 libopencv-video4.5 libopencv-videoio4.5 libopencv-videostab4.5 libopencv-viz4.5 libnice10 pmount libnice-dev feh libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 libgstreamer-plugins-bad1.0-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-nice usbutils locales trace-cmd clinfo jq strace sysstat lm-sensors"
 target "cd /tmp && wget https://software.frc971.org/Build-Dependencies/libmali-midgard-t86x-r14p0-x11_1.9-1_arm64.deb && sudo dpkg -i libmali-midgard-t86x-r14p0-x11_1.9-1_arm64.deb && rm libmali-midgard-t86x-r14p0-x11_1.9-1_arm64.deb"
 
 target "apt-get clean"
@@ -229,6 +229,7 @@
 copyfile root.root 644 etc/udev/rules.d/99-usb-mount.rules
 copyfile root.root 644 etc/udev/rules.d/99-adis16505.rules
 copyfile root.root 644 etc/udev/rules.d/99-mali.rules
+copyfile root.root 644 etc/chrony/chrony.conf
 
 target "apt-get update"
 
diff --git a/frc971/rockpi/contents/etc/chrony/chrony.conf b/frc971/rockpi/contents/etc/chrony/chrony.conf
new file mode 100644
index 0000000..6e03143
--- /dev/null
+++ b/frc971/rockpi/contents/etc/chrony/chrony.conf
@@ -0,0 +1,59 @@
+# Welcome to the chrony configuration file. See chrony.conf(5) for more
+# information about usable directives.
+
+# Include configuration files found in /etc/chrony/conf.d.
+confdir /etc/chrony/conf.d
+
+# Use Debian vendor zone.
+pool 2.debian.pool.ntp.org iburst
+
+# Use time sources from DHCP.
+sourcedir /run/chrony-dhcp
+
+# Use NTP sources found in /etc/chrony/sources.d.
+sourcedir /etc/chrony/sources.d
+
+# This directive specify the location of the file containing ID/key pairs for
+# NTP authentication.
+keyfile /etc/chrony/chrony.keys
+
+# This directive specify the file into which chronyd will store the rate
+# information.
+driftfile /var/lib/chrony/chrony.drift
+
+# Save NTS keys and cookies.
+ntsdumpdir /var/lib/chrony
+
+# Uncomment the following line to turn logging on.
+#log tracking measurements statistics
+
+# Log files location.
+logdir /var/log/chrony
+
+# Stop bad estimates upsetting machine clock.
+maxupdateskew 100.0
+
+# This directive enables kernel synchronisation (every 11 minutes) of the
+# real-time clock. Note that it can’t be used along with the 'rtcfile' directive.
+rtcsync
+
+# Always step the time if it's more than 4ms off.
+makestep 0.004 -1
+
+# The next 2 settings are used to control how much slewing of the clock chrony
+# is allowed to do. The total will be the sum of both and we have a hard limit
+# at 500ppm coming from AOS.
+
+# The maximum slewing allowed to correct an offset with the reference clock.
+# Note that this value doesn't include corrections due to frequency errors
+# (drift).
+maxslewrate 200
+
+# Maximum frequency error (drift) of the clock. Chrony will try to compensate
+# for drift and this setting limits how large that correction can be.
+maxdrift 100
+
+# Get TAI-UTC offset and leap seconds from the system tz database.
+# This directive must be commented out when using time sources serving
+# leap-smeared time.
+leapsectz right/UTC
diff --git a/frc971/rockpi/contents/etc/fstab b/frc971/rockpi/contents/etc/fstab
index cf62c9f..c148c2d 100644
--- a/frc971/rockpi/contents/etc/fstab
+++ b/frc971/rockpi/contents/etc/fstab
@@ -1,2 +1,2 @@
-/dev/mmcblk0p1  /boot  auto  defaults 0  2
 /dev/mmcblk0p2  /  auto  errors=remount-ro  0  0
+tmpfs /dev/shm tmpfs rw,nosuid,nodev,size=90% 0 0
diff --git a/frc971/rockpi/contents/root/bin/change_hostname.sh b/frc971/rockpi/contents/root/bin/change_hostname.sh
index a784bb1..43e6528 100755
--- a/frc971/rockpi/contents/root/bin/change_hostname.sh
+++ b/frc971/rockpi/contents/root/bin/change_hostname.sh
@@ -40,23 +40,10 @@
   done
 fi
 
-# Put corret team number in roborio's address, or add it if missing
+# Put correct team number in roborio's address, or add it if missing
 if grep '^10\.[0-9]*\.[0-9]*\.2\s*roborio$' /etc/hosts >/dev/null;
 then
   sed -i "s/^10\.[0-9]*\.[0-9]*\(\.2\s*roborio\)$/${IP_BASE}\1/" /etc/hosts
 else
   echo -e "${IP_BASE}.2\troborio" >> /etc/hosts
 fi
-
-# Put corret team number in imu's address, or add it if missing
-if grep '^10\.[0-9]*\.[0-9]*\.105\s.*\s*imu$' /etc/hosts >/dev/null;
-then
-  sed -i "s/^10\.[0-9]*\.[0-9]*\(\.[0-9]*\s*pi-\)[0-9]*\(-[0-9] pi5 imu\)$/${IP_BASE}\1${TEAM_NUMBER}\2/" /etc/hosts
-else
-  if grep '^10\.[0-9]*\.[0-9]*\.105\s*pi-[0-9]*-[0-9]*\s*pi5$' /etc/hosts
-  then
-    sed -i "s/^10\.[0-9]*\.[0-9]*\(\.[0-9]*\s*pi-\)[0-9]*\(-[0-9] pi5\)$/${IP_BASE}\1${TEAM_NUMBER}\2 imu/" /etc/hosts
-  else
-    echo -e "${IP_BASE}.105\tpi-${TEAM_NUMBER}-5 pi5 imu" >> /etc/hosts
-  fi
-fi
diff --git a/frc971/rockpi/rockpi_config.json b/frc971/rockpi/rockpi_config.json
new file mode 100644
index 0000000..d4cbaae
--- /dev/null
+++ b/frc971/rockpi/rockpi_config.json
@@ -0,0 +1,107 @@
+{
+  "irqs": [
+    {
+      "name": "ttyS2",
+      "affinity": [1]
+    },
+    {
+      "name": "dw-mci",
+      "affinity": [1]
+    },
+    {
+      "name": "mmc1",
+      "affinity": [1]
+    },
+    {
+      "name": "rkisp1",
+      "affinity": [2]
+    },
+    {
+      "name": "ff3c0000.i2c",
+      "affinity": [2]
+    },
+    {
+      "name": "ff3d0000.i2c",
+      "affinity": [2]
+    },
+    {
+      "name": "ff6e0000.dma-controller",
+      "affinity": [0]
+    },
+    {
+      "name": "ff1d0000.spi",
+      "affinity": [0]
+    },
+    {
+      "name": "eth0",
+      "affinity": [1]
+    }
+  ],
+  "kthreads": [
+    {
+      "name": "irq/*-ff940000.hdmi",
+      "scheduler": "SCHEDULER_OTHER"
+    },
+    {
+      "name": "irq/*-rockchip_usb2phy",
+      "scheduler": "SCHEDULER_OTHER"
+    },
+    {
+      "name": "irq/*-mmc0",
+      "scheduler": "SCHEDULER_OTHER"
+    },
+    {
+      "name": "irq/*-mmc1",
+      "scheduler": "SCHEDULER_OTHER"
+    },
+    {
+      "name": "irq/*-fe320000.mmc cd",
+      "scheduler": "SCHEDULER_OTHER"
+    },
+    {
+      "name": "irq/*-vc4 crtc",
+      "scheduler": "SCHEDULER_OTHER"
+    },
+    {
+      "name": "irq/*-rkisp1",
+      "scheduler": "SCHEDULER_FIFO",
+      "priority": 60,
+      "affinity": [2]
+    },
+    {
+      "name": "irq/*-ff3c0000.i2c",
+      "scheduler": "SCHEDULER_FIFO",
+      "priority": 51,
+      "affinity": [2]
+    },
+    {
+      "name": "irq/*-adis16505",
+      "scheduler": "SCHEDULER_FIFO",
+      "priority": 59,
+      "affinity": [0]
+    },
+    {
+      "name": "irq/*-ff6e0000.dma-controller",
+      "scheduler": "SCHEDULER_FIFO",
+      "priority": 59,
+      "affinity": [0]
+    },
+    {
+      "name": "spi0",
+      "scheduler": "SCHEDULER_FIFO",
+      "priority": 57,
+      "affinity": [0]
+    },
+    {
+      "name": "irq/*-eth0",
+      "scheduler": "SCHEDULER_FIFO",
+      "priority": 10,
+      "affinity": [1]
+    },
+    {
+      "name": "irq/*-rockchip_thermal",
+      "scheduler": "SCHEDULER_FIFO",
+      "priority": 1
+    }
+  ]
+}
diff --git a/frc971/vision/calibration_accumulator.cc b/frc971/vision/calibration_accumulator.cc
index 711ed7d..f01af3c 100644
--- a/frc971/vision/calibration_accumulator.cc
+++ b/frc971/vision/calibration_accumulator.cc
@@ -8,11 +8,11 @@
 #include "aos/events/simulated_event_loop.h"
 #include "aos/network/team_number.h"
 #include "aos/time/time.h"
+#include "external/com_github_foxglove_schemas/CompressedImage_schema.h"
+#include "external/com_github_foxglove_schemas/ImageAnnotations_schema.h"
 #include "frc971/control_loops/quaternion_utils.h"
 #include "frc971/vision/charuco_lib.h"
 #include "frc971/wpilib/imu_batch_generated.h"
-#include "external/com_github_foxglove_schemas/ImageAnnotations_schema.h"
-#include "external/com_github_foxglove_schemas/CompressedImage_schema.h"
 
 DEFINE_bool(display_undistorted, false,
             "If true, display the undistorted image.");
@@ -116,10 +116,10 @@
 CalibrationFoxgloveVisualizer::CalibrationFoxgloveVisualizer(
     aos::EventLoop *event_loop)
     : event_loop_(event_loop),
-      image_converter_(event_loop_, "/camera", "/visualization",
+      image_converter_(event_loop_, "/camera", "/camera",
                        ImageCompression::kJpeg),
       annotations_sender_(
-          event_loop_->MakeSender<foxglove::ImageAnnotations>("/visualization")) {}
+          event_loop_->MakeSender<foxglove::ImageAnnotations>("/camera")) {}
 
 aos::FlatbufferDetachedBuffer<aos::Configuration>
 CalibrationFoxgloveVisualizer::AddVisualizationChannels(
diff --git a/frc971/vision/extrinsics_calibration.cc b/frc971/vision/extrinsics_calibration.cc
index d563fb0..daa371e 100644
--- a/frc971/vision/extrinsics_calibration.cc
+++ b/frc971/vision/extrinsics_calibration.cc
@@ -488,38 +488,6 @@
     vis_robot.SetCameraParameters(camera_mat);
     vis_robot.SetDistortionCoefficients(dist_coeffs);
 
-    /*
-    // Draw an initial visualization
-    Eigen::Vector3d T_world_imu_vec =
-        calibration_parameters.initial_state.block<3, 1>(0, 0);
-    Eigen::Translation3d T_world_imu(T_world_imu_vec);
-    Eigen::Affine3d H_world_imu =
-        T_world_imu * calibration_parameters.initial_orientation;
-
-    vis_robot.DrawFrameAxes(H_world_imu, "imu");
-
-    Eigen::Quaterniond R_imu_pivot(calibration_parameters.pivot_to_imu);
-    Eigen::Translation3d T_imu_pivot(
-        calibration_parameters.pivot_to_imu_translation);
-    Eigen::Affine3d H_imu_pivot = T_imu_pivot * R_imu_pivot;
-    Eigen::Affine3d H_world_pivot = H_world_imu * H_imu_pivot;
-    vis_robot.DrawFrameAxes(H_world_pivot, "pivot");
-
-    Eigen::Affine3d H_imupivot_camerapivot(
-        Eigen::AngleAxisd(1.5, Eigen::Vector3d::UnitZ()));
-    Eigen::Quaterniond R_camera_pivot(calibration_parameters.pivot_to_camera);
-    Eigen::Translation3d T_camera_pivot(
-        calibration_parameters.pivot_to_camera_translation);
-    Eigen::Affine3d H_camera_pivot = T_camera_pivot * R_camera_pivot;
-    Eigen::Affine3d H_world_camera = H_world_imu * H_imu_pivot *
-                                     H_imupivot_camerapivot *
-                                     H_camera_pivot.inverse();
-    vis_robot.DrawFrameAxes(H_world_camera, "camera");
-
-    cv::imshow("Original poses", image_mat);
-    cv::waitKey();
-    */
-
     uint current_state_index = 0;
     uint current_turret_index = 0;
     for (uint i = 0; i < camera_times_.size() - 1; i++) {
@@ -623,11 +591,6 @@
 
       cv::imshow("Live", image_mat);
       cv::waitKey(50);
-
-      if (i % 200 == 0) {
-        LOG(INFO) << "Pausing at step " << i;
-        cv::waitKey();
-      }
     }
     LOG(INFO) << "Finished visualizing robot.  Press any key to continue";
     cv::waitKey();
@@ -849,11 +812,11 @@
           trans_error_scale * filter.errorpz(i);
     }
 
-    LOG(INFO) << "Cost function calc took "
-              << chrono::duration<double>(aos::monotonic_clock::now() -
-                                          start_time)
-                     .count()
-              << " seconds";
+    VLOG(2) << "Cost function calc took "
+            << chrono::duration<double>(aos::monotonic_clock::now() -
+                                        start_time)
+                   .count()
+            << " seconds";
 
     return true;
   }
@@ -898,7 +861,7 @@
         calibration_parameters->accelerometer_bias.data());
   }
 
-  {
+  if (calibration_parameters->has_pivot) {
     // The turret's Z rotation is redundant with the camera's mounting z
     // rotation since it's along the rotation axis.
     ceres::CostFunction *turret_z_cost_function =
@@ -922,6 +885,17 @@
         calibration_parameters->pivot_to_imu_translation.data());
   }
 
+  {
+    // The board rotation in z is a bit arbitrary, so hoping to limit it to
+    // increase repeatability
+    ceres::CostFunction *board_z_cost_function =
+        new ceres::AutoDiffCostFunction<PenalizeQuaternionZ, 1, 4>(
+            new PenalizeQuaternionZ());
+    problem.AddResidualBlock(
+        board_z_cost_function, nullptr,
+        calibration_parameters->board_to_world.coeffs().data());
+  }
+
   problem.SetParameterization(
       calibration_parameters->initial_orientation.coeffs().data(),
       quaternion_local_parameterization);
@@ -952,9 +926,9 @@
   // Run the solver!
   ceres::Solver::Options options;
   options.minimizer_progress_to_stdout = true;
-  options.gradient_tolerance = 1e-12;
+  options.gradient_tolerance = 1e-6;
   options.function_tolerance = 1e-6;
-  options.parameter_tolerance = 1e-12;
+  options.parameter_tolerance = 1e-6;
   ceres::Solver::Summary summary;
   Solve(options, &problem, &summary);
   LOG(INFO) << summary.FullReport();
diff --git a/frc971/vision/target_map.fbs b/frc971/vision/target_map.fbs
index cee2b07..ed87895 100644
--- a/frc971/vision/target_map.fbs
+++ b/frc971/vision/target_map.fbs
@@ -18,7 +18,7 @@
   // AprilTag ID of this target
   id:uint64 (id: 0);
 
-  // Pose of target relative to either the field origin or robot.
+  // Pose of target relative to either the field origin or camera.
   // To get the pose of the target, do:
   // Translation3d(position.x(), position.y(), position.z()) *
   // Quaterniond(orientation.w(), orientation.x(), orientation.y(), orientation.z())
@@ -29,7 +29,7 @@
 // Map of all target poses on a field.
 // There are two possible uses for this:
 // 1. Static april tag poses on the field solved for by TargetMapper.
-// 2. List of detected april poses relative to the robot.
+// 2. List of detected april poses relative to the camera.
 table TargetMap {
   target_poses:[TargetPoseFbs] (id: 0);
 
diff --git a/frc971/wpilib/BUILD b/frc971/wpilib/BUILD
index eb0571c..ca3dd0c 100644
--- a/frc971/wpilib/BUILD
+++ b/frc971/wpilib/BUILD
@@ -1,4 +1,4 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
 load("//aos:config.bzl", "aos_config")
@@ -469,7 +469,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "imu_plot_utils",
     srcs = ["imu_plot_utils.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -484,7 +484,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "imu_plotter",
     srcs = ["imu_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/frc971/wpilib/imu_plot_utils.ts b/frc971/wpilib/imu_plot_utils.ts
index c4b516b..a56fd9b 100644
--- a/frc971/wpilib/imu_plot_utils.ts
+++ b/frc971/wpilib/imu_plot_utils.ts
@@ -1,9 +1,9 @@
 // This script provides a basic utility for de-batching the IMUValues
 // message. See imu_plotter.ts for usage.
-import {IMUValuesBatch} from 'org_frc971/frc971/wpilib/imu_batch_generated';
-import {MessageHandler, TimestampedMessage} from 'org_frc971/aos/network/www/aos_plotter';
-import {Point} from 'org_frc971/aos/network/www/plotter';
-import {Table} from 'org_frc971/aos/network/www/reflection';
+import {IMUValuesBatch} from './imu_batch_generated';
+import {MessageHandler, TimestampedMessage} from '../../aos/network/www/aos_plotter';
+import {Point} from '../../aos/network/www/plotter';
+import {Table} from '../../aos/network/www/reflection';
 import {ByteBuffer} from 'flatbuffers';
 import {Schema} from 'flatbuffers_reflection/reflection_generated';
 
diff --git a/frc971/wpilib/imu_plotter.ts b/frc971/wpilib/imu_plotter.ts
index 6768e1c..91edd7c 100644
--- a/frc971/wpilib/imu_plotter.ts
+++ b/frc971/wpilib/imu_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a basic plot for debugging IMU-related issues on a robot.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {ImuMessageHandler} from 'org_frc971/frc971/wpilib/imu_plot_utils';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
+import {AosPlotter} from '../../aos/network/www/aos_plotter';
+import {ImuMessageHandler} from '../../frc971/wpilib/imu_plot_utils';
+import * as proxy from '../../aos/network/www/proxy';
 
 import Connection = proxy.Connection;
 
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..65a160f
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,4373 @@
+lockfileVersion: 5.4
+
+importers:
+
+  .:
+    specifiers:
+      '@angular/animations': 15.0.1
+      '@angular/cli': 15.0.1
+      '@angular/common': 15.0.1
+      '@angular/compiler': 15.0.1
+      '@angular/compiler-cli': 15.0.1
+      '@angular/core': 15.0.1
+      '@angular/forms': 15.0.1
+      '@angular/platform-browser': 15.0.1
+      '@babel/cli': ^7.6.0
+      '@babel/core': ^7.6.0
+      '@rollup/plugin-node-resolve': 13.1.3
+      '@types/flatbuffers': 1.10.0
+      '@types/jasmine': 3.10.3
+      '@types/node': 17.0.21
+      cypress: 12.3.0
+      html-insert-assets: 0.14.3
+      jasmine: 3.10.0
+      karma: 6.4.1
+      karma-chrome-launcher: 3.1.0
+      karma-firefox-launcher: 2.1.2
+      karma-jasmine: 4.0.1
+      karma-requirejs: 1.1.0
+      karma-sourcemap-loader: 0.3.8
+      prettier: 2.6.1
+      protractor: 7.0.0
+      requirejs: 2.3.6
+      rollup: 2.66.1
+      rxjs: 7.5.7
+      terser: 5.10.0
+      typescript: 4.8.4
+      zone.js: ^0.11.4
+    devDependencies:
+      '@angular/animations': 15.0.1_@angular+core@15.0.1
+      '@angular/cli': 15.0.1
+      '@angular/common': 15.0.1_gc4fl5hkkh62bvf2jldebe7kfi
+      '@angular/compiler': 15.0.1_@angular+core@15.0.1
+      '@angular/compiler-cli': 15.0.1_cjgqlygpi5ntpb3clzn7pzsmpy
+      '@angular/core': 15.0.1_rxjs@7.5.7+zone.js@0.11.8
+      '@angular/forms': 15.0.1_lshev2zdnaa2h7gkxro3dgbuau
+      '@angular/platform-browser': 15.0.1_elnjnvrny24npcy6jcashrycoy
+      '@babel/cli': 7.20.7_@babel+core@7.20.12
+      '@babel/core': 7.20.12
+      '@rollup/plugin-node-resolve': 13.1.3_rollup@2.66.1
+      '@types/flatbuffers': 1.10.0
+      '@types/jasmine': 3.10.3
+      '@types/node': 17.0.21
+      cypress: 12.3.0
+      html-insert-assets: 0.14.3
+      jasmine: 3.10.0
+      karma: 6.4.1
+      karma-chrome-launcher: 3.1.0
+      karma-firefox-launcher: 2.1.2
+      karma-jasmine: 4.0.1_karma@6.4.1
+      karma-requirejs: 1.1.0_rexopxsgq4andhwzqrad3qy2ka
+      karma-sourcemap-loader: 0.3.8
+      prettier: 2.6.1
+      protractor: 7.0.0
+      requirejs: 2.3.6
+      rollup: 2.66.1
+      rxjs: 7.5.7
+      terser: 5.10.0
+      typescript: 4.8.4
+      zone.js: 0.11.8
+
+packages:
+
+  /@ampproject/remapping/2.2.0:
+    resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==}
+    engines: {node: '>=6.0.0'}
+    dependencies:
+      '@jridgewell/gen-mapping': 0.1.1
+      '@jridgewell/trace-mapping': 0.3.17
+    dev: true
+
+  /@angular-devkit/architect/0.1500.1:
+    resolution: {integrity: sha512-HoGMdUB9z1brPq3f0m3la6N0ODBarH5LjZN+5KyIMdXgJJN5y+gs2H6yCPQfJT56fqtp/cckxOYcLAFTf45Tcg==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+    dependencies:
+      '@angular-devkit/core': 15.0.1
+      rxjs: 6.6.7
+    transitivePeerDependencies:
+      - chokidar
+    dev: true
+
+  /@angular-devkit/core/15.0.1:
+    resolution: {integrity: sha512-Q8sF561Wf53ufdrKWvsqebbD5EjJpdHaPjg5nAHYwPtwD1ciG7oL55cQFs0LYqy9Ux6k34NimodhH3QgXYYPFQ==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+    peerDependencies:
+      chokidar: ^3.5.2
+    peerDependenciesMeta:
+      chokidar:
+        optional: true
+    dependencies:
+      ajv: 8.11.0
+      ajv-formats: 2.1.1
+      jsonc-parser: 3.2.0
+      rxjs: 6.6.7
+      source-map: 0.7.4
+    dev: true
+
+  /@angular-devkit/schematics/15.0.1:
+    resolution: {integrity: sha512-DS9t+xl1lOphYkdz17FwRO0LUs5IYBpyqr3O8SqrXESOhVUXlbcEhVtVeQiYxfeQZVRPWVR64Tf6E6ELXcGLYw==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+    dependencies:
+      '@angular-devkit/core': 15.0.1
+      jsonc-parser: 3.2.0
+      magic-string: 0.26.7
+      ora: 5.4.1
+      rxjs: 6.6.7
+    transitivePeerDependencies:
+      - chokidar
+    dev: true
+
+  /@angular/animations/15.0.1_@angular+core@15.0.1:
+    resolution: {integrity: sha512-GfxqpRcoRfQNS1pVA+PadcgCGJSFag07jFJIQUHX3HZkI/4PyXGn/7ptgebN3tBjy+ASk4PBOQP/ntGbrr55zw==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0}
+    peerDependencies:
+      '@angular/core': 15.0.1
+    dependencies:
+      '@angular/core': 15.0.1_rxjs@7.5.7+zone.js@0.11.8
+      tslib: 2.4.1
+    dev: true
+
+  /@angular/cli/15.0.1:
+    resolution: {integrity: sha512-ntwJxtzGuHl07eb56x8WM6tQ3YhBKCP61o8WoHBrOBEFNm9rEV9C2webMIWYVFAa0iG1pmDq6U5Qc7WFPM9rtg==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+    hasBin: true
+    dependencies:
+      '@angular-devkit/architect': 0.1500.1
+      '@angular-devkit/core': 15.0.1
+      '@angular-devkit/schematics': 15.0.1
+      '@schematics/angular': 15.0.1
+      '@yarnpkg/lockfile': 1.1.0
+      ansi-colors: 4.1.3
+      ini: 3.0.1
+      inquirer: 8.2.4
+      jsonc-parser: 3.2.0
+      npm-package-arg: 9.1.2
+      npm-pick-manifest: 8.0.1
+      open: 8.4.0
+      ora: 5.4.1
+      pacote: 15.0.6
+      resolve: 1.22.1
+      semver: 7.3.8
+      symbol-observable: 4.0.0
+      yargs: 17.6.2
+    transitivePeerDependencies:
+      - bluebird
+      - chokidar
+      - supports-color
+    dev: true
+
+  /@angular/common/15.0.1_gc4fl5hkkh62bvf2jldebe7kfi:
+    resolution: {integrity: sha512-XRD1Dj2aINyp5yYueCuwLU1y84z+ZFXeO84oNfwIu0unHszuo02iIzrV+yCm/ATwt6qUkIbe6xhZNjUorZecyA==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0}
+    peerDependencies:
+      '@angular/core': 15.0.1
+      rxjs: ^6.5.3 || ^7.4.0
+    dependencies:
+      '@angular/core': 15.0.1_rxjs@7.5.7+zone.js@0.11.8
+      rxjs: 7.5.7
+      tslib: 2.4.1
+    dev: true
+
+  /@angular/compiler-cli/15.0.1_cjgqlygpi5ntpb3clzn7pzsmpy:
+    resolution: {integrity: sha512-M2VsKBw8dQMC5p3PmpM+EBZAZ9Qk/rGX+aIHYBGzsgGFqYMEcz6Nxrj4v6I3Hta7tW7QEVXf883rXiWxHlwtbw==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0}
+    hasBin: true
+    peerDependencies:
+      '@angular/compiler': 15.0.1
+      typescript: '>=4.8.2 <4.9'
+    dependencies:
+      '@angular/compiler': 15.0.1_@angular+core@15.0.1
+      '@babel/core': 7.20.12
+      chokidar: 3.5.3
+      convert-source-map: 1.9.0
+      dependency-graph: 0.11.0
+      magic-string: 0.26.7
+      reflect-metadata: 0.1.13
+      semver: 7.3.8
+      sourcemap-codec: 1.4.8
+      tslib: 2.4.1
+      typescript: 4.8.4
+      yargs: 17.6.2
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@angular/compiler/15.0.1_@angular+core@15.0.1:
+    resolution: {integrity: sha512-4talkxip79XPfoj69qgY8VXV1KIBKOyZCRWHhNVqMdECyw/fceVWN4r8kDL0qOTBh1CKmhoQFXQilr9g7nFatA==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0}
+    peerDependencies:
+      '@angular/core': 15.0.1
+    peerDependenciesMeta:
+      '@angular/core':
+        optional: true
+    dependencies:
+      '@angular/core': 15.0.1_rxjs@7.5.7+zone.js@0.11.8
+      tslib: 2.4.1
+    dev: true
+
+  /@angular/core/15.0.1_rxjs@7.5.7+zone.js@0.11.8:
+    resolution: {integrity: sha512-idaKf9hhguyGn/yj5KMHIUEvW4PpeYcwlRUSoEskQC1799BsXwJyV0AwZ67GH1ltnAj34gbhMhDedcCLdhOffA==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0}
+    peerDependencies:
+      rxjs: ^6.5.3 || ^7.4.0
+      zone.js: ~0.11.4 || ~0.12.0
+    dependencies:
+      rxjs: 7.5.7
+      tslib: 2.4.1
+      zone.js: 0.11.8
+    dev: true
+
+  /@angular/forms/15.0.1:
+    resolution: {integrity: sha512-gNj/fY7B7swczWI3jpJK4904W0WHCrYviZB8m97P4MkcxdMfQezp4VoRsj+vIkKGtUPUWje3uIjzqodhJlxIJA==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0}
+    peerDependencies:
+      '@angular/common': 15.0.1
+      '@angular/core': 15.0.1
+      '@angular/platform-browser': 15.0.1
+      rxjs: ^6.5.3 || ^7.4.0
+    dependencies:
+      tslib: 2.4.1
+    dev: false
+
+  /@angular/forms/15.0.1_lshev2zdnaa2h7gkxro3dgbuau:
+    resolution: {integrity: sha512-gNj/fY7B7swczWI3jpJK4904W0WHCrYviZB8m97P4MkcxdMfQezp4VoRsj+vIkKGtUPUWje3uIjzqodhJlxIJA==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0}
+    peerDependencies:
+      '@angular/common': 15.0.1
+      '@angular/core': 15.0.1
+      '@angular/platform-browser': 15.0.1
+      rxjs: ^6.5.3 || ^7.4.0
+    dependencies:
+      '@angular/common': 15.0.1_gc4fl5hkkh62bvf2jldebe7kfi
+      '@angular/core': 15.0.1_rxjs@7.5.7+zone.js@0.11.8
+      '@angular/platform-browser': 15.0.1_elnjnvrny24npcy6jcashrycoy
+      rxjs: 7.5.7
+      tslib: 2.4.1
+    dev: true
+
+  /@angular/platform-browser/15.0.1_elnjnvrny24npcy6jcashrycoy:
+    resolution: {integrity: sha512-fH0EfRgbQC0ql8V1ZWVfF75H9lSjT2T6uGfR8cBdRAO/RWwWgx/TfFsjdWAZtjuKRZnKY3wRQ/yVYeQarC3n0Q==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0}
+    peerDependencies:
+      '@angular/animations': 15.0.1
+      '@angular/common': 15.0.1
+      '@angular/core': 15.0.1
+    peerDependenciesMeta:
+      '@angular/animations':
+        optional: true
+    dependencies:
+      '@angular/animations': 15.0.1_@angular+core@15.0.1
+      '@angular/common': 15.0.1_gc4fl5hkkh62bvf2jldebe7kfi
+      '@angular/core': 15.0.1_rxjs@7.5.7+zone.js@0.11.8
+      tslib: 2.4.1
+    dev: true
+
+  /@babel/cli/7.20.7_@babel+core@7.20.12:
+    resolution: {integrity: sha512-WylgcELHB66WwQqItxNILsMlaTd8/SO6SgTTjMp4uCI7P4QyH1r3nqgFmO3BfM4AtfniHgFMH3EpYFj/zynBkQ==}
+    engines: {node: '>=6.9.0'}
+    hasBin: true
+    peerDependencies:
+      '@babel/core': ^7.0.0-0
+    dependencies:
+      '@babel/core': 7.20.12
+      '@jridgewell/trace-mapping': 0.3.17
+      commander: 4.1.1
+      convert-source-map: 1.9.0
+      fs-readdir-recursive: 1.1.0
+      glob: 7.2.3
+      make-dir: 2.1.0
+      slash: 2.0.0
+    optionalDependencies:
+      '@nicolo-ribaudo/chokidar-2': 2.1.8-no-fsevents.3
+      chokidar: 3.5.3
+    dev: true
+
+  /@babel/code-frame/7.18.6:
+    resolution: {integrity: sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/highlight': 7.18.6
+    dev: true
+
+  /@babel/compat-data/7.20.10:
+    resolution: {integrity: sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==}
+    engines: {node: '>=6.9.0'}
+    dev: true
+
+  /@babel/core/7.20.12:
+    resolution: {integrity: sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@ampproject/remapping': 2.2.0
+      '@babel/code-frame': 7.18.6
+      '@babel/generator': 7.20.7
+      '@babel/helper-compilation-targets': 7.20.7_@babel+core@7.20.12
+      '@babel/helper-module-transforms': 7.20.11
+      '@babel/helpers': 7.20.7
+      '@babel/parser': 7.20.7
+      '@babel/template': 7.20.7
+      '@babel/traverse': 7.20.12
+      '@babel/types': 7.20.7
+      convert-source-map: 1.9.0
+      debug: 4.3.4
+      gensync: 1.0.0-beta.2
+      json5: 2.2.3
+      semver: 6.3.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@babel/generator/7.20.7:
+    resolution: {integrity: sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/types': 7.20.7
+      '@jridgewell/gen-mapping': 0.3.2
+      jsesc: 2.5.2
+    dev: true
+
+  /@babel/helper-compilation-targets/7.20.7_@babel+core@7.20.12:
+    resolution: {integrity: sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0
+    dependencies:
+      '@babel/compat-data': 7.20.10
+      '@babel/core': 7.20.12
+      '@babel/helper-validator-option': 7.18.6
+      browserslist: 4.21.4
+      lru-cache: 5.1.1
+      semver: 6.3.0
+    dev: true
+
+  /@babel/helper-environment-visitor/7.18.9:
+    resolution: {integrity: sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==}
+    engines: {node: '>=6.9.0'}
+    dev: true
+
+  /@babel/helper-function-name/7.19.0:
+    resolution: {integrity: sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/template': 7.20.7
+      '@babel/types': 7.20.7
+    dev: true
+
+  /@babel/helper-hoist-variables/7.18.6:
+    resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/types': 7.20.7
+    dev: true
+
+  /@babel/helper-module-imports/7.18.6:
+    resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/types': 7.20.7
+    dev: true
+
+  /@babel/helper-module-transforms/7.20.11:
+    resolution: {integrity: sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/helper-environment-visitor': 7.18.9
+      '@babel/helper-module-imports': 7.18.6
+      '@babel/helper-simple-access': 7.20.2
+      '@babel/helper-split-export-declaration': 7.18.6
+      '@babel/helper-validator-identifier': 7.19.1
+      '@babel/template': 7.20.7
+      '@babel/traverse': 7.20.12
+      '@babel/types': 7.20.7
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@babel/helper-simple-access/7.20.2:
+    resolution: {integrity: sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/types': 7.20.7
+    dev: true
+
+  /@babel/helper-split-export-declaration/7.18.6:
+    resolution: {integrity: sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/types': 7.20.7
+    dev: true
+
+  /@babel/helper-string-parser/7.19.4:
+    resolution: {integrity: sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==}
+    engines: {node: '>=6.9.0'}
+    dev: true
+
+  /@babel/helper-validator-identifier/7.19.1:
+    resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==}
+    engines: {node: '>=6.9.0'}
+    dev: true
+
+  /@babel/helper-validator-option/7.18.6:
+    resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==}
+    engines: {node: '>=6.9.0'}
+    dev: true
+
+  /@babel/helpers/7.20.7:
+    resolution: {integrity: sha512-PBPjs5BppzsGaxHQCDKnZ6Gd9s6xl8bBCluz3vEInLGRJmnZan4F6BYCeqtyXqkk4W5IlPmjK4JlOuZkpJ3xZA==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/template': 7.20.7
+      '@babel/traverse': 7.20.12
+      '@babel/types': 7.20.7
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@babel/highlight/7.18.6:
+    resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/helper-validator-identifier': 7.19.1
+      chalk: 2.4.2
+      js-tokens: 4.0.0
+    dev: true
+
+  /@babel/parser/7.20.7:
+    resolution: {integrity: sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+    dependencies:
+      '@babel/types': 7.20.7
+    dev: true
+
+  /@babel/template/7.20.7:
+    resolution: {integrity: sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/code-frame': 7.18.6
+      '@babel/parser': 7.20.7
+      '@babel/types': 7.20.7
+    dev: true
+
+  /@babel/traverse/7.20.12:
+    resolution: {integrity: sha512-MsIbFN0u+raeja38qboyF8TIT7K0BFzz/Yd/77ta4MsUsmP2RAnidIlwq7d5HFQrH/OZJecGV6B71C4zAgpoSQ==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/code-frame': 7.18.6
+      '@babel/generator': 7.20.7
+      '@babel/helper-environment-visitor': 7.18.9
+      '@babel/helper-function-name': 7.19.0
+      '@babel/helper-hoist-variables': 7.18.6
+      '@babel/helper-split-export-declaration': 7.18.6
+      '@babel/parser': 7.20.7
+      '@babel/types': 7.20.7
+      debug: 4.3.4
+      globals: 11.12.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@babel/types/7.20.7:
+    resolution: {integrity: sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      '@babel/helper-string-parser': 7.19.4
+      '@babel/helper-validator-identifier': 7.19.1
+      to-fast-properties: 2.0.0
+    dev: true
+
+  /@colors/colors/1.5.0:
+    resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
+    engines: {node: '>=0.1.90'}
+    dev: true
+
+  /@cypress/request/2.88.11:
+    resolution: {integrity: sha512-M83/wfQ1EkspjkE2lNWNV5ui2Cv7UCv1swW1DqljahbzLVWltcsexQh8jYtuS/vzFXP+HySntGM83ZXA9fn17w==}
+    engines: {node: '>= 6'}
+    dependencies:
+      aws-sign2: 0.7.0
+      aws4: 1.12.0
+      caseless: 0.12.0
+      combined-stream: 1.0.8
+      extend: 3.0.2
+      forever-agent: 0.6.1
+      form-data: 2.3.3
+      http-signature: 1.3.6
+      is-typedarray: 1.0.0
+      isstream: 0.1.2
+      json-stringify-safe: 5.0.1
+      mime-types: 2.1.35
+      performance-now: 2.1.0
+      qs: 6.10.4
+      safe-buffer: 5.2.1
+      tough-cookie: 2.5.0
+      tunnel-agent: 0.6.0
+      uuid: 8.3.2
+    dev: true
+
+  /@cypress/xvfb/1.2.4_supports-color@8.1.1:
+    resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==}
+    dependencies:
+      debug: 3.2.7_supports-color@8.1.1
+      lodash.once: 4.1.1
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@gar/promisify/1.1.3:
+    resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
+    dev: true
+
+  /@jridgewell/gen-mapping/0.1.1:
+    resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==}
+    engines: {node: '>=6.0.0'}
+    dependencies:
+      '@jridgewell/set-array': 1.1.2
+      '@jridgewell/sourcemap-codec': 1.4.14
+    dev: true
+
+  /@jridgewell/gen-mapping/0.3.2:
+    resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==}
+    engines: {node: '>=6.0.0'}
+    dependencies:
+      '@jridgewell/set-array': 1.1.2
+      '@jridgewell/sourcemap-codec': 1.4.14
+      '@jridgewell/trace-mapping': 0.3.17
+    dev: true
+
+  /@jridgewell/resolve-uri/3.1.0:
+    resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==}
+    engines: {node: '>=6.0.0'}
+    dev: true
+
+  /@jridgewell/set-array/1.1.2:
+    resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==}
+    engines: {node: '>=6.0.0'}
+    dev: true
+
+  /@jridgewell/sourcemap-codec/1.4.14:
+    resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==}
+    dev: true
+
+  /@jridgewell/trace-mapping/0.3.17:
+    resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==}
+    dependencies:
+      '@jridgewell/resolve-uri': 3.1.0
+      '@jridgewell/sourcemap-codec': 1.4.14
+    dev: true
+
+  /@nicolo-ribaudo/chokidar-2/2.1.8-no-fsevents.3:
+    resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==}
+    requiresBuild: true
+    dev: true
+    optional: true
+
+  /@npmcli/fs/2.1.2:
+    resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      '@gar/promisify': 1.1.3
+      semver: 7.3.8
+    dev: true
+
+  /@npmcli/fs/3.1.0:
+    resolution: {integrity: sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      semver: 7.3.8
+    dev: true
+
+  /@npmcli/git/4.0.3:
+    resolution: {integrity: sha512-8cXNkDIbnXPVbhXMmQ7/bklCAjtmPaXfI9aEM4iH+xSuEHINLMHhlfESvVwdqmHJRJkR48vNJTSUvoF6GRPSFA==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      '@npmcli/promise-spawn': 6.0.2
+      lru-cache: 7.14.1
+      mkdirp: 1.0.4
+      npm-pick-manifest: 8.0.1
+      proc-log: 3.0.0
+      promise-inflight: 1.0.1
+      promise-retry: 2.0.1
+      semver: 7.3.8
+      which: 3.0.0
+    transitivePeerDependencies:
+      - bluebird
+    dev: true
+
+  /@npmcli/installed-package-contents/2.0.1:
+    resolution: {integrity: sha512-GIykAFdOVK31Q1/zAtT5MbxqQL2vyl9mvFJv+OGu01zxbhL3p0xc8gJjdNGX1mWmUT43aEKVO2L6V/2j4TOsAA==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    hasBin: true
+    dependencies:
+      npm-bundled: 3.0.0
+      npm-normalize-package-bin: 3.0.0
+    dev: true
+
+  /@npmcli/move-file/2.0.1:
+    resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    deprecated: This functionality has been moved to @npmcli/fs
+    dependencies:
+      mkdirp: 1.0.4
+      rimraf: 3.0.2
+    dev: true
+
+  /@npmcli/node-gyp/3.0.0:
+    resolution: {integrity: sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dev: true
+
+  /@npmcli/promise-spawn/6.0.2:
+    resolution: {integrity: sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      which: 3.0.0
+    dev: true
+
+  /@npmcli/run-script/6.0.0:
+    resolution: {integrity: sha512-ql+AbRur1TeOdl1FY+RAwGW9fcr4ZwiVKabdvm93mujGREVuVLbdkXRJDrkTXSdCjaxYydr1wlA2v67jxWG5BQ==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      '@npmcli/node-gyp': 3.0.0
+      '@npmcli/promise-spawn': 6.0.2
+      node-gyp: 9.3.1
+      read-package-json-fast: 3.0.2
+      which: 3.0.0
+    transitivePeerDependencies:
+      - bluebird
+      - supports-color
+    dev: true
+
+  /@rollup/plugin-node-resolve/13.1.3_rollup@2.66.1:
+    resolution: {integrity: sha512-BdxNk+LtmElRo5d06MGY4zoepyrXX1tkzX2hrnPEZ53k78GuOMWLqmJDGIIOPwVRIFZrLQOo+Yr6KtCuLIA0AQ==}
+    engines: {node: '>= 10.0.0'}
+    peerDependencies:
+      rollup: ^2.42.0
+    dependencies:
+      '@rollup/pluginutils': 3.1.0_rollup@2.66.1
+      '@types/resolve': 1.17.1
+      builtin-modules: 3.3.0
+      deepmerge: 4.2.2
+      is-module: 1.0.0
+      resolve: 1.22.1
+      rollup: 2.66.1
+    dev: true
+
+  /@rollup/pluginutils/3.1.0_rollup@2.66.1:
+    resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
+    engines: {node: '>= 8.0.0'}
+    peerDependencies:
+      rollup: ^1.20.0||^2.0.0
+    dependencies:
+      '@types/estree': 0.0.39
+      estree-walker: 1.0.1
+      picomatch: 2.3.1
+      rollup: 2.66.1
+    dev: true
+
+  /@schematics/angular/15.0.1:
+    resolution: {integrity: sha512-UGiQ4IwdLWdQwlWVgbAM5B6G4VdzVOn0yS1PkOtTt0hvAkszriu7uyaH2Qh8aFSTvNAIg/l7/6grI/UGj8iDaw==}
+    engines: {node: ^14.20.0 || ^16.13.0 || >=18.10.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+    dependencies:
+      '@angular-devkit/core': 15.0.1
+      '@angular-devkit/schematics': 15.0.1
+      jsonc-parser: 3.2.0
+    transitivePeerDependencies:
+      - chokidar
+    dev: true
+
+  /@socket.io/component-emitter/3.1.0:
+    resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
+    dev: true
+
+  /@tootallnate/once/2.0.0:
+    resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
+    engines: {node: '>= 10'}
+    dev: true
+
+  /@types/cookie/0.4.1:
+    resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
+    dev: true
+
+  /@types/cors/2.8.13:
+    resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==}
+    dependencies:
+      '@types/node': 17.0.21
+    dev: true
+
+  /@types/estree/0.0.39:
+    resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
+    dev: true
+
+  /@types/flatbuffers/1.10.0:
+    resolution: {integrity: sha512-7btbphLrKvo5yl/5CC2OCxUSMx1wV1wvGT1qDXkSt7yi00/YW7E8k6qzXqJHsp+WU0eoG7r6MTQQXI9lIvd0qA==}
+    dev: true
+
+  /@types/jasmine/3.10.3:
+    resolution: {integrity: sha512-SWyMrjgdAUHNQmutvDcKablrJhkDLy4wunTme8oYLjKp41GnHGxMRXr2MQMvy/qy8H3LdzwQk9gH4hZ6T++H8g==}
+    dev: true
+
+  /@types/node/14.18.36:
+    resolution: {integrity: sha512-FXKWbsJ6a1hIrRxv+FoukuHnGTgEzKYGi7kilfMae96AL9UNkPFNWJEEYWzdRI9ooIkbr4AKldyuSTLql06vLQ==}
+    dev: true
+
+  /@types/node/17.0.21:
+    resolution: {integrity: sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==}
+    dev: true
+
+  /@types/q/0.0.32:
+    resolution: {integrity: sha512-qYi3YV9inU/REEfxwVcGZzbS3KG/Xs90lv0Pr+lDtuVjBPGd1A+eciXzVSaRvLify132BfcvhvEjeVahrUl0Ug==}
+    dev: true
+
+  /@types/resolve/1.17.1:
+    resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==}
+    dependencies:
+      '@types/node': 17.0.21
+    dev: true
+
+  /@types/selenium-webdriver/3.0.20:
+    resolution: {integrity: sha512-6d8Q5fqS9DWOXEhMDiF6/2FjyHdmP/jSTAUyeQR7QwrFeNmYyzmvGxD5aLIHL445HjWgibs0eAig+KPnbaesXA==}
+    dev: true
+
+  /@types/sinonjs__fake-timers/8.1.1:
+    resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==}
+    dev: true
+
+  /@types/sizzle/2.3.3:
+    resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==}
+    dev: true
+
+  /@types/yauzl/2.10.0:
+    resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==}
+    requiresBuild: true
+    dependencies:
+      '@types/node': 17.0.21
+    dev: true
+    optional: true
+
+  /@yarnpkg/lockfile/1.1.0:
+    resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==}
+    dev: true
+
+  /abbrev/1.1.1:
+    resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
+    dev: true
+
+  /accepts/1.3.8:
+    resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+    engines: {node: '>= 0.6'}
+    dependencies:
+      mime-types: 2.1.35
+      negotiator: 0.6.3
+    dev: true
+
+  /acorn/8.8.1:
+    resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==}
+    engines: {node: '>=0.4.0'}
+    hasBin: true
+    dev: true
+
+  /adm-zip/0.4.16:
+    resolution: {integrity: sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==}
+    engines: {node: '>=0.3.0'}
+    dev: true
+
+  /agent-base/4.3.0:
+    resolution: {integrity: sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==}
+    engines: {node: '>= 4.0.0'}
+    dependencies:
+      es6-promisify: 5.0.0
+    dev: true
+
+  /agent-base/6.0.2:
+    resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+    engines: {node: '>= 6.0.0'}
+    dependencies:
+      debug: 4.3.4
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /agentkeepalive/4.2.1:
+    resolution: {integrity: sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA==}
+    engines: {node: '>= 8.0.0'}
+    dependencies:
+      debug: 4.3.4
+      depd: 1.1.2
+      humanize-ms: 1.2.1
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /aggregate-error/3.1.0:
+    resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
+    engines: {node: '>=8'}
+    dependencies:
+      clean-stack: 2.2.0
+      indent-string: 4.0.0
+    dev: true
+
+  /ajv-formats/2.1.1:
+    resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
+    peerDependenciesMeta:
+      ajv:
+        optional: true
+    dependencies:
+      ajv: 8.11.0
+    dev: true
+
+  /ajv/6.12.6:
+    resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+    dependencies:
+      fast-deep-equal: 3.1.3
+      fast-json-stable-stringify: 2.1.0
+      json-schema-traverse: 0.4.1
+      uri-js: 4.4.1
+    dev: true
+
+  /ajv/8.11.0:
+    resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==}
+    dependencies:
+      fast-deep-equal: 3.1.3
+      json-schema-traverse: 1.0.0
+      require-from-string: 2.0.2
+      uri-js: 4.4.1
+    dev: true
+
+  /ansi-colors/4.1.3:
+    resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /ansi-escapes/4.3.2:
+    resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
+    engines: {node: '>=8'}
+    dependencies:
+      type-fest: 0.21.3
+    dev: true
+
+  /ansi-regex/2.1.1:
+    resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /ansi-regex/5.0.1:
+    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /ansi-styles/2.2.1:
+    resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /ansi-styles/3.2.1:
+    resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
+    engines: {node: '>=4'}
+    dependencies:
+      color-convert: 1.9.3
+    dev: true
+
+  /ansi-styles/4.3.0:
+    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+    engines: {node: '>=8'}
+    dependencies:
+      color-convert: 2.0.1
+    dev: true
+
+  /anymatch/3.1.3:
+    resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+    engines: {node: '>= 8'}
+    dependencies:
+      normalize-path: 3.0.0
+      picomatch: 2.3.1
+    dev: true
+
+  /aproba/2.0.0:
+    resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
+    dev: true
+
+  /arch/2.2.0:
+    resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==}
+    dev: true
+
+  /are-we-there-yet/3.0.1:
+    resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      delegates: 1.0.0
+      readable-stream: 3.6.0
+    dev: true
+
+  /array-union/1.0.2:
+    resolution: {integrity: sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      array-uniq: 1.0.3
+    dev: true
+
+  /array-uniq/1.0.3:
+    resolution: {integrity: sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /arrify/1.0.1:
+    resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /asn1/0.2.6:
+    resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
+    dependencies:
+      safer-buffer: 2.1.2
+    dev: true
+
+  /assert-plus/1.0.0:
+    resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
+    engines: {node: '>=0.8'}
+    dev: true
+
+  /astral-regex/2.0.0:
+    resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /async/3.2.4:
+    resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==}
+    dev: true
+
+  /asynckit/0.4.0:
+    resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+    dev: true
+
+  /at-least-node/1.0.0:
+    resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
+    engines: {node: '>= 4.0.0'}
+    dev: true
+
+  /aws-sign2/0.7.0:
+    resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==}
+    dev: true
+
+  /aws4/1.12.0:
+    resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==}
+    dev: true
+
+  /balanced-match/1.0.2:
+    resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+    dev: true
+
+  /base64-js/1.5.1:
+    resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+    dev: true
+
+  /base64id/2.0.0:
+    resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
+    engines: {node: ^4.5.0 || >= 5.9}
+    dev: true
+
+  /bcrypt-pbkdf/1.0.2:
+    resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
+    dependencies:
+      tweetnacl: 0.14.5
+    dev: true
+
+  /binary-extensions/2.2.0:
+    resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /bl/4.1.0:
+    resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+    dependencies:
+      buffer: 5.7.1
+      inherits: 2.0.4
+      readable-stream: 3.6.0
+    dev: true
+
+  /blob-util/2.0.2:
+    resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==}
+    dev: true
+
+  /blocking-proxy/1.0.1:
+    resolution: {integrity: sha512-KE8NFMZr3mN2E0HcvCgRtX7DjhiIQrwle+nSVJVC/yqFb9+xznHl2ZcoBp2L9qzkI4t4cBFJ1efXF8Dwi132RA==}
+    engines: {node: '>=6.9.x'}
+    hasBin: true
+    dependencies:
+      minimist: 1.2.7
+    dev: true
+
+  /bluebird/3.7.2:
+    resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
+    dev: true
+
+  /body-parser/1.20.1:
+    resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+    dependencies:
+      bytes: 3.1.2
+      content-type: 1.0.4
+      debug: 2.6.9
+      depd: 2.0.0
+      destroy: 1.2.0
+      http-errors: 2.0.0
+      iconv-lite: 0.4.24
+      on-finished: 2.4.1
+      qs: 6.11.0
+      raw-body: 2.5.1
+      type-is: 1.6.18
+      unpipe: 1.0.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /brace-expansion/1.1.11:
+    resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
+    dependencies:
+      balanced-match: 1.0.2
+      concat-map: 0.0.1
+    dev: true
+
+  /brace-expansion/2.0.1:
+    resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
+    dependencies:
+      balanced-match: 1.0.2
+    dev: true
+
+  /braces/3.0.2:
+    resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
+    engines: {node: '>=8'}
+    dependencies:
+      fill-range: 7.0.1
+    dev: true
+
+  /browserslist/4.21.4:
+    resolution: {integrity: sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==}
+    engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+    hasBin: true
+    dependencies:
+      caniuse-lite: 1.0.30001445
+      electron-to-chromium: 1.4.284
+      node-releases: 2.0.8
+      update-browserslist-db: 1.0.10_browserslist@4.21.4
+    dev: true
+
+  /browserstack/1.6.1:
+    resolution: {integrity: sha512-GxtFjpIaKdbAyzHfFDKixKO8IBT7wR3NjbzrGc78nNs/Ciys9wU3/nBtsqsWv5nDSrdI5tz0peKuzCPuNXNUiw==}
+    dependencies:
+      https-proxy-agent: 2.2.4
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /buffer-crc32/0.2.13:
+    resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+    dev: true
+
+  /buffer-from/1.1.2:
+    resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+    dev: true
+
+  /buffer/5.7.1:
+    resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+    dependencies:
+      base64-js: 1.5.1
+      ieee754: 1.2.1
+    dev: true
+
+  /builtin-modules/3.3.0:
+    resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /builtins/5.0.1:
+    resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==}
+    dependencies:
+      semver: 7.3.8
+    dev: true
+
+  /bytes/3.1.2:
+    resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+    engines: {node: '>= 0.8'}
+    dev: true
+
+  /cacache/16.1.3:
+    resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      '@npmcli/fs': 2.1.2
+      '@npmcli/move-file': 2.0.1
+      chownr: 2.0.0
+      fs-minipass: 2.1.0
+      glob: 8.1.0
+      infer-owner: 1.0.4
+      lru-cache: 7.14.1
+      minipass: 3.3.6
+      minipass-collect: 1.0.2
+      minipass-flush: 1.0.5
+      minipass-pipeline: 1.2.4
+      mkdirp: 1.0.4
+      p-map: 4.0.0
+      promise-inflight: 1.0.1
+      rimraf: 3.0.2
+      ssri: 9.0.1
+      tar: 6.1.13
+      unique-filename: 2.0.1
+    transitivePeerDependencies:
+      - bluebird
+    dev: true
+
+  /cacache/17.0.4:
+    resolution: {integrity: sha512-Z/nL3gU+zTUjz5pCA5vVjYM8pmaw2kxM7JEiE0fv3w77Wj+sFbi70CrBruUWH0uNcEdvLDixFpgA2JM4F4DBjA==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      '@npmcli/fs': 3.1.0
+      fs-minipass: 3.0.0
+      glob: 8.1.0
+      lru-cache: 7.14.1
+      minipass: 4.0.0
+      minipass-collect: 1.0.2
+      minipass-flush: 1.0.5
+      minipass-pipeline: 1.2.4
+      p-map: 4.0.0
+      promise-inflight: 1.0.1
+      ssri: 10.0.1
+      tar: 6.1.13
+      unique-filename: 3.0.0
+    transitivePeerDependencies:
+      - bluebird
+    dev: true
+
+  /cachedir/2.3.0:
+    resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /call-bind/1.0.2:
+    resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
+    dependencies:
+      function-bind: 1.1.1
+      get-intrinsic: 1.1.3
+    dev: true
+
+  /camelcase/5.3.1:
+    resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /caniuse-lite/1.0.30001445:
+    resolution: {integrity: sha512-8sdQIdMztYmzfTMO6KfLny878Ln9c2M0fc7EH60IjlP4Dc4PiCy7K2Vl3ITmWgOyPgVQKa5x+UP/KqFsxj4mBg==}
+    dev: true
+
+  /caseless/0.12.0:
+    resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
+    dev: true
+
+  /chalk/1.1.3:
+    resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      ansi-styles: 2.2.1
+      escape-string-regexp: 1.0.5
+      has-ansi: 2.0.0
+      strip-ansi: 3.0.1
+      supports-color: 2.0.0
+    dev: true
+
+  /chalk/2.4.2:
+    resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
+    engines: {node: '>=4'}
+    dependencies:
+      ansi-styles: 3.2.1
+      escape-string-regexp: 1.0.5
+      supports-color: 5.5.0
+    dev: true
+
+  /chalk/4.1.2:
+    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+    engines: {node: '>=10'}
+    dependencies:
+      ansi-styles: 4.3.0
+      supports-color: 7.2.0
+    dev: true
+
+  /chardet/0.7.0:
+    resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
+    dev: true
+
+  /check-more-types/2.24.0:
+    resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==}
+    engines: {node: '>= 0.8.0'}
+    dev: true
+
+  /chokidar/3.5.3:
+    resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
+    engines: {node: '>= 8.10.0'}
+    dependencies:
+      anymatch: 3.1.3
+      braces: 3.0.2
+      glob-parent: 5.1.2
+      is-binary-path: 2.1.0
+      is-glob: 4.0.3
+      normalize-path: 3.0.0
+      readdirp: 3.6.0
+    optionalDependencies:
+      fsevents: 2.3.2
+    dev: true
+
+  /chownr/2.0.0:
+    resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
+    engines: {node: '>=10'}
+    dev: true
+
+  /ci-info/3.7.1:
+    resolution: {integrity: sha512-4jYS4MOAaCIStSRwiuxc4B8MYhIe676yO1sYGzARnjXkWpmzZMMYxY6zu8WYWDhSuth5zhrQ1rhNSibyyvv4/w==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /clean-stack/2.2.0:
+    resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /cli-cursor/3.1.0:
+    resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
+    engines: {node: '>=8'}
+    dependencies:
+      restore-cursor: 3.1.0
+    dev: true
+
+  /cli-spinners/2.7.0:
+    resolution: {integrity: sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /cli-table3/0.6.3:
+    resolution: {integrity: sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==}
+    engines: {node: 10.* || >= 12.*}
+    dependencies:
+      string-width: 4.2.3
+    optionalDependencies:
+      '@colors/colors': 1.5.0
+    dev: true
+
+  /cli-truncate/2.1.0:
+    resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
+    engines: {node: '>=8'}
+    dependencies:
+      slice-ansi: 3.0.0
+      string-width: 4.2.3
+    dev: true
+
+  /cli-width/3.0.0:
+    resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==}
+    engines: {node: '>= 10'}
+    dev: true
+
+  /cliui/6.0.0:
+    resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 6.2.0
+    dev: true
+
+  /cliui/7.0.4:
+    resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 7.0.0
+    dev: true
+
+  /cliui/8.0.1:
+    resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 7.0.0
+    dev: true
+
+  /clone/1.0.4:
+    resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
+    engines: {node: '>=0.8'}
+    dev: true
+
+  /color-convert/1.9.3:
+    resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
+    dependencies:
+      color-name: 1.1.3
+    dev: true
+
+  /color-convert/2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+    dependencies:
+      color-name: 1.1.4
+    dev: true
+
+  /color-name/1.1.3:
+    resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
+    dev: true
+
+  /color-name/1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+    dev: true
+
+  /color-support/1.1.3:
+    resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
+    hasBin: true
+    dev: true
+
+  /colorette/2.0.19:
+    resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
+    dev: true
+
+  /combined-stream/1.0.8:
+    resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      delayed-stream: 1.0.0
+    dev: true
+
+  /commander/2.20.3:
+    resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
+    dev: true
+
+  /commander/4.1.1:
+    resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+    engines: {node: '>= 6'}
+    dev: true
+
+  /commander/5.1.0:
+    resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
+    engines: {node: '>= 6'}
+    dev: true
+
+  /common-tags/1.8.2:
+    resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==}
+    engines: {node: '>=4.0.0'}
+    dev: true
+
+  /concat-map/0.0.1:
+    resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+    dev: true
+
+  /connect/3.7.0:
+    resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==}
+    engines: {node: '>= 0.10.0'}
+    dependencies:
+      debug: 2.6.9
+      finalhandler: 1.1.2
+      parseurl: 1.3.3
+      utils-merge: 1.0.1
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /console-control-strings/1.1.0:
+    resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
+    dev: true
+
+  /content-type/1.0.4:
+    resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==}
+    engines: {node: '>= 0.6'}
+    dev: true
+
+  /convert-source-map/1.9.0:
+    resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
+    dev: true
+
+  /cookie/0.4.2:
+    resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
+    engines: {node: '>= 0.6'}
+    dev: true
+
+  /core-util-is/1.0.2:
+    resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
+    dev: true
+
+  /core-util-is/1.0.3:
+    resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+    dev: true
+
+  /cors/2.8.5:
+    resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
+    engines: {node: '>= 0.10'}
+    dependencies:
+      object-assign: 4.1.1
+      vary: 1.1.2
+    dev: true
+
+  /cross-spawn/7.0.3:
+    resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
+    engines: {node: '>= 8'}
+    dependencies:
+      path-key: 3.1.1
+      shebang-command: 2.0.0
+      which: 2.0.2
+    dev: true
+
+  /custom-event/1.0.1:
+    resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==}
+    dev: true
+
+  /cypress/12.3.0:
+    resolution: {integrity: sha512-ZQNebibi6NBt51TRxRMYKeFvIiQZ01t50HSy7z/JMgRVqBUey3cdjog5MYEbzG6Ktti5ckDt1tfcC47lmFwXkw==}
+    engines: {node: ^14.0.0 || ^16.0.0 || >=18.0.0}
+    hasBin: true
+    requiresBuild: true
+    dependencies:
+      '@cypress/request': 2.88.11
+      '@cypress/xvfb': 1.2.4_supports-color@8.1.1
+      '@types/node': 14.18.36
+      '@types/sinonjs__fake-timers': 8.1.1
+      '@types/sizzle': 2.3.3
+      arch: 2.2.0
+      blob-util: 2.0.2
+      bluebird: 3.7.2
+      buffer: 5.7.1
+      cachedir: 2.3.0
+      chalk: 4.1.2
+      check-more-types: 2.24.0
+      cli-cursor: 3.1.0
+      cli-table3: 0.6.3
+      commander: 5.1.0
+      common-tags: 1.8.2
+      dayjs: 1.11.7
+      debug: 4.3.4_supports-color@8.1.1
+      enquirer: 2.3.6
+      eventemitter2: 6.4.7
+      execa: 4.1.0
+      executable: 4.1.1
+      extract-zip: 2.0.1_supports-color@8.1.1
+      figures: 3.2.0
+      fs-extra: 9.1.0
+      getos: 3.2.1
+      is-ci: 3.0.1
+      is-installed-globally: 0.4.0
+      lazy-ass: 1.6.0
+      listr2: 3.14.0_enquirer@2.3.6
+      lodash: 4.17.21
+      log-symbols: 4.1.0
+      minimist: 1.2.7
+      ospath: 1.2.2
+      pretty-bytes: 5.6.0
+      proxy-from-env: 1.0.0
+      request-progress: 3.0.0
+      semver: 7.3.8
+      supports-color: 8.1.1
+      tmp: 0.2.1
+      untildify: 4.0.0
+      yauzl: 2.10.0
+    dev: true
+
+  /dashdash/1.14.1:
+    resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
+    engines: {node: '>=0.10'}
+    dependencies:
+      assert-plus: 1.0.0
+    dev: true
+
+  /date-format/4.0.14:
+    resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==}
+    engines: {node: '>=4.0'}
+    dev: true
+
+  /dayjs/1.11.7:
+    resolution: {integrity: sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==}
+    dev: true
+
+  /debug/2.6.9:
+    resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+    dependencies:
+      ms: 2.0.0
+    dev: true
+
+  /debug/3.2.7:
+    resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+    dependencies:
+      ms: 2.1.3
+    dev: true
+
+  /debug/3.2.7_supports-color@8.1.1:
+    resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+    dependencies:
+      ms: 2.1.3
+      supports-color: 8.1.1
+    dev: true
+
+  /debug/4.3.4:
+    resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+    dependencies:
+      ms: 2.1.2
+    dev: true
+
+  /debug/4.3.4_supports-color@8.1.1:
+    resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+    dependencies:
+      ms: 2.1.2
+      supports-color: 8.1.1
+    dev: true
+
+  /decamelize/1.2.0:
+    resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /deepmerge/4.2.2:
+    resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /defaults/1.0.4:
+    resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
+    dependencies:
+      clone: 1.0.4
+    dev: true
+
+  /define-lazy-prop/2.0.0:
+    resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /del/2.2.2:
+    resolution: {integrity: sha512-Z4fzpbIRjOu7lO5jCETSWoqUDVe0IPOlfugBsF6suen2LKDlVb4QZpKEM9P+buNJ4KI1eN7I083w/pbKUpsrWQ==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      globby: 5.0.0
+      is-path-cwd: 1.0.0
+      is-path-in-cwd: 1.0.1
+      object-assign: 4.1.1
+      pify: 2.3.0
+      pinkie-promise: 2.0.1
+      rimraf: 2.7.1
+    dev: true
+
+  /delayed-stream/1.0.0:
+    resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+    engines: {node: '>=0.4.0'}
+    dev: true
+
+  /delegates/1.0.0:
+    resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
+    dev: true
+
+  /depd/1.1.2:
+    resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
+    engines: {node: '>= 0.6'}
+    dev: true
+
+  /depd/2.0.0:
+    resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+    engines: {node: '>= 0.8'}
+    dev: true
+
+  /dependency-graph/0.11.0:
+    resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==}
+    engines: {node: '>= 0.6.0'}
+    dev: true
+
+  /destroy/1.2.0:
+    resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+    dev: true
+
+  /di/0.0.1:
+    resolution: {integrity: sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==}
+    dev: true
+
+  /dom-serialize/2.2.1:
+    resolution: {integrity: sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==}
+    dependencies:
+      custom-event: 1.0.1
+      ent: 2.2.0
+      extend: 3.0.2
+      void-elements: 2.0.1
+    dev: true
+
+  /ecc-jsbn/0.1.2:
+    resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==}
+    dependencies:
+      jsbn: 0.1.1
+      safer-buffer: 2.1.2
+    dev: true
+
+  /ee-first/1.1.1:
+    resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+    dev: true
+
+  /electron-to-chromium/1.4.284:
+    resolution: {integrity: sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==}
+    dev: true
+
+  /emoji-regex/8.0.0:
+    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+    dev: true
+
+  /encodeurl/1.0.2:
+    resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
+    engines: {node: '>= 0.8'}
+    dev: true
+
+  /encoding/0.1.13:
+    resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
+    requiresBuild: true
+    dependencies:
+      iconv-lite: 0.6.3
+    dev: true
+    optional: true
+
+  /end-of-stream/1.4.4:
+    resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
+    dependencies:
+      once: 1.4.0
+    dev: true
+
+  /engine.io-parser/5.0.6:
+    resolution: {integrity: sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw==}
+    engines: {node: '>=10.0.0'}
+    dev: true
+
+  /engine.io/6.2.1:
+    resolution: {integrity: sha512-ECceEFcAaNRybd3lsGQKas3ZlMVjN3cyWwMP25D2i0zWfyiytVbTpRPa34qrr+FHddtpBVOmq4H/DCv1O0lZRA==}
+    engines: {node: '>=10.0.0'}
+    dependencies:
+      '@types/cookie': 0.4.1
+      '@types/cors': 2.8.13
+      '@types/node': 17.0.21
+      accepts: 1.3.8
+      base64id: 2.0.0
+      cookie: 0.4.2
+      cors: 2.8.5
+      debug: 4.3.4
+      engine.io-parser: 5.0.6
+      ws: 8.2.3
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+    dev: true
+
+  /enquirer/2.3.6:
+    resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==}
+    engines: {node: '>=8.6'}
+    dependencies:
+      ansi-colors: 4.1.3
+    dev: true
+
+  /ent/2.2.0:
+    resolution: {integrity: sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==}
+    dev: true
+
+  /env-paths/2.2.1:
+    resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /err-code/2.0.3:
+    resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
+    dev: true
+
+  /es6-promise/4.2.8:
+    resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==}
+    dev: true
+
+  /es6-promisify/5.0.0:
+    resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==}
+    dependencies:
+      es6-promise: 4.2.8
+    dev: true
+
+  /escalade/3.1.1:
+    resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /escape-html/1.0.3:
+    resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+    dev: true
+
+  /escape-string-regexp/1.0.5:
+    resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
+    engines: {node: '>=0.8.0'}
+    dev: true
+
+  /estree-walker/1.0.1:
+    resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==}
+    dev: true
+
+  /eventemitter2/6.4.7:
+    resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
+    dev: true
+
+  /eventemitter3/4.0.7:
+    resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+    dev: true
+
+  /execa/4.1.0:
+    resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
+    engines: {node: '>=10'}
+    dependencies:
+      cross-spawn: 7.0.3
+      get-stream: 5.2.0
+      human-signals: 1.1.1
+      is-stream: 2.0.1
+      merge-stream: 2.0.0
+      npm-run-path: 4.0.1
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+      strip-final-newline: 2.0.0
+    dev: true
+
+  /executable/4.1.1:
+    resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
+    engines: {node: '>=4'}
+    dependencies:
+      pify: 2.3.0
+    dev: true
+
+  /exit/0.1.2:
+    resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
+    engines: {node: '>= 0.8.0'}
+    dev: true
+
+  /extend/3.0.2:
+    resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+    dev: true
+
+  /external-editor/3.1.0:
+    resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
+    engines: {node: '>=4'}
+    dependencies:
+      chardet: 0.7.0
+      iconv-lite: 0.4.24
+      tmp: 0.0.33
+    dev: true
+
+  /extract-zip/2.0.1_supports-color@8.1.1:
+    resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
+    engines: {node: '>= 10.17.0'}
+    hasBin: true
+    dependencies:
+      debug: 4.3.4_supports-color@8.1.1
+      get-stream: 5.2.0
+      yauzl: 2.10.0
+    optionalDependencies:
+      '@types/yauzl': 2.10.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /extsprintf/1.3.0:
+    resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
+    engines: {'0': node >=0.6.0}
+    dev: true
+
+  /fast-deep-equal/3.1.3:
+    resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+    dev: true
+
+  /fast-json-stable-stringify/2.1.0:
+    resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+    dev: true
+
+  /fd-slicer/1.1.0:
+    resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+    dependencies:
+      pend: 1.2.0
+    dev: true
+
+  /figures/3.2.0:
+    resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
+    engines: {node: '>=8'}
+    dependencies:
+      escape-string-regexp: 1.0.5
+    dev: true
+
+  /fill-range/7.0.1:
+    resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
+    engines: {node: '>=8'}
+    dependencies:
+      to-regex-range: 5.0.1
+    dev: true
+
+  /finalhandler/1.1.2:
+    resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      debug: 2.6.9
+      encodeurl: 1.0.2
+      escape-html: 1.0.3
+      on-finished: 2.3.0
+      parseurl: 1.3.3
+      statuses: 1.5.0
+      unpipe: 1.0.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /find-up/4.1.0:
+    resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+    engines: {node: '>=8'}
+    dependencies:
+      locate-path: 5.0.0
+      path-exists: 4.0.0
+    dev: true
+
+  /flatted/3.2.7:
+    resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==}
+    dev: true
+
+  /follow-redirects/1.15.2:
+    resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
+    engines: {node: '>=4.0'}
+    peerDependencies:
+      debug: '*'
+    peerDependenciesMeta:
+      debug:
+        optional: true
+    dev: true
+
+  /forever-agent/0.6.1:
+    resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
+    dev: true
+
+  /form-data/2.3.3:
+    resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
+    engines: {node: '>= 0.12'}
+    dependencies:
+      asynckit: 0.4.0
+      combined-stream: 1.0.8
+      mime-types: 2.1.35
+    dev: true
+
+  /fs-extra/8.1.0:
+    resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
+    engines: {node: '>=6 <7 || >=8'}
+    dependencies:
+      graceful-fs: 4.2.10
+      jsonfile: 4.0.0
+      universalify: 0.1.2
+    dev: true
+
+  /fs-extra/9.1.0:
+    resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
+    engines: {node: '>=10'}
+    dependencies:
+      at-least-node: 1.0.0
+      graceful-fs: 4.2.10
+      jsonfile: 6.1.0
+      universalify: 2.0.0
+    dev: true
+
+  /fs-minipass/2.1.0:
+    resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
+    engines: {node: '>= 8'}
+    dependencies:
+      minipass: 3.3.6
+    dev: true
+
+  /fs-minipass/3.0.0:
+    resolution: {integrity: sha512-EUojgQaSPy6sxcqcZgQv6TVF6jiKvurji3AxhAivs/Ep4O1UpS8TusaxpybfFHZ2skRhLqzk6WR8nqNYIMMDeA==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      minipass: 4.0.0
+    dev: true
+
+  /fs-readdir-recursive/1.1.0:
+    resolution: {integrity: sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==}
+    dev: true
+
+  /fs.realpath/1.0.0:
+    resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+    dev: true
+
+  /fsevents/2.3.2:
+    resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+    requiresBuild: true
+    dev: true
+    optional: true
+
+  /function-bind/1.1.1:
+    resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
+    dev: true
+
+  /gauge/4.0.4:
+    resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      aproba: 2.0.0
+      color-support: 1.1.3
+      console-control-strings: 1.1.0
+      has-unicode: 2.0.1
+      signal-exit: 3.0.7
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wide-align: 1.1.5
+    dev: true
+
+  /gensync/1.0.0-beta.2:
+    resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+    engines: {node: '>=6.9.0'}
+    dev: true
+
+  /get-caller-file/2.0.5:
+    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+    engines: {node: 6.* || 8.* || >= 10.*}
+    dev: true
+
+  /get-intrinsic/1.1.3:
+    resolution: {integrity: sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==}
+    dependencies:
+      function-bind: 1.1.1
+      has: 1.0.3
+      has-symbols: 1.0.3
+    dev: true
+
+  /get-stream/5.2.0:
+    resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
+    engines: {node: '>=8'}
+    dependencies:
+      pump: 3.0.0
+    dev: true
+
+  /getos/3.2.1:
+    resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==}
+    dependencies:
+      async: 3.2.4
+    dev: true
+
+  /getpass/0.1.7:
+    resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==}
+    dependencies:
+      assert-plus: 1.0.0
+    dev: true
+
+  /glob-parent/5.1.2:
+    resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+    engines: {node: '>= 6'}
+    dependencies:
+      is-glob: 4.0.3
+    dev: true
+
+  /glob/7.2.3:
+    resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+    dependencies:
+      fs.realpath: 1.0.0
+      inflight: 1.0.6
+      inherits: 2.0.4
+      minimatch: 3.1.2
+      once: 1.4.0
+      path-is-absolute: 1.0.1
+    dev: true
+
+  /glob/8.1.0:
+    resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      fs.realpath: 1.0.0
+      inflight: 1.0.6
+      inherits: 2.0.4
+      minimatch: 5.1.4
+      once: 1.4.0
+    dev: true
+
+  /global-dirs/3.0.1:
+    resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==}
+    engines: {node: '>=10'}
+    dependencies:
+      ini: 2.0.0
+    dev: true
+
+  /globals/11.12.0:
+    resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
+    engines: {node: '>=4'}
+    dev: true
+
+  /globby/5.0.0:
+    resolution: {integrity: sha512-HJRTIH2EeH44ka+LWig+EqT2ONSYpVlNfx6pyd592/VF1TbfljJ7elwie7oSwcViLGqOdWocSdu2txwBF9bjmQ==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      array-union: 1.0.2
+      arrify: 1.0.1
+      glob: 7.2.3
+      object-assign: 4.1.1
+      pify: 2.3.0
+      pinkie-promise: 2.0.1
+    dev: true
+
+  /graceful-fs/4.2.10:
+    resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
+    dev: true
+
+  /har-schema/2.0.0:
+    resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==}
+    engines: {node: '>=4'}
+    dev: true
+
+  /har-validator/5.1.5:
+    resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==}
+    engines: {node: '>=6'}
+    deprecated: this library is no longer supported
+    dependencies:
+      ajv: 6.12.6
+      har-schema: 2.0.0
+    dev: true
+
+  /has-ansi/2.0.0:
+    resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      ansi-regex: 2.1.1
+    dev: true
+
+  /has-flag/3.0.0:
+    resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
+    engines: {node: '>=4'}
+    dev: true
+
+  /has-flag/4.0.0:
+    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /has-symbols/1.0.3:
+    resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
+    engines: {node: '>= 0.4'}
+    dev: true
+
+  /has-unicode/2.0.1:
+    resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
+    dev: true
+
+  /has/1.0.3:
+    resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
+    engines: {node: '>= 0.4.0'}
+    dependencies:
+      function-bind: 1.1.1
+    dev: true
+
+  /hosted-git-info/5.2.1:
+    resolution: {integrity: sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      lru-cache: 7.14.1
+    dev: true
+
+  /hosted-git-info/6.1.1:
+    resolution: {integrity: sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      lru-cache: 7.14.1
+    dev: true
+
+  /html-insert-assets/0.14.3:
+    resolution: {integrity: sha512-4st+C8j3KFwzo8nZE8g7lgzuF+8l6+0WxhXKszV0+siYtbP4WZCHO4U2DVnW/9PJ4PSQYUuz/u92pXByDzZdJg==}
+    hasBin: true
+    dependencies:
+      mkdirp: 1.0.4
+      parse5: 6.0.1
+    dev: true
+
+  /http-cache-semantics/4.1.0:
+    resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==}
+    dev: true
+
+  /http-errors/2.0.0:
+    resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      depd: 2.0.0
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 2.0.1
+      toidentifier: 1.0.1
+    dev: true
+
+  /http-proxy-agent/5.0.0:
+    resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
+    engines: {node: '>= 6'}
+    dependencies:
+      '@tootallnate/once': 2.0.0
+      agent-base: 6.0.2
+      debug: 4.3.4
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /http-proxy/1.18.1:
+    resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
+    engines: {node: '>=8.0.0'}
+    dependencies:
+      eventemitter3: 4.0.7
+      follow-redirects: 1.15.2
+      requires-port: 1.0.0
+    transitivePeerDependencies:
+      - debug
+    dev: true
+
+  /http-signature/1.2.0:
+    resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==}
+    engines: {node: '>=0.8', npm: '>=1.3.7'}
+    dependencies:
+      assert-plus: 1.0.0
+      jsprim: 1.4.2
+      sshpk: 1.17.0
+    dev: true
+
+  /http-signature/1.3.6:
+    resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==}
+    engines: {node: '>=0.10'}
+    dependencies:
+      assert-plus: 1.0.0
+      jsprim: 2.0.2
+      sshpk: 1.17.0
+    dev: true
+
+  /https-proxy-agent/2.2.4:
+    resolution: {integrity: sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==}
+    engines: {node: '>= 4.5.0'}
+    dependencies:
+      agent-base: 4.3.0
+      debug: 3.2.7
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /https-proxy-agent/5.0.1:
+    resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+    engines: {node: '>= 6'}
+    dependencies:
+      agent-base: 6.0.2
+      debug: 4.3.4
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /human-signals/1.1.1:
+    resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
+    engines: {node: '>=8.12.0'}
+    dev: true
+
+  /humanize-ms/1.2.1:
+    resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
+    dependencies:
+      ms: 2.1.3
+    dev: true
+
+  /iconv-lite/0.4.24:
+    resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      safer-buffer: 2.1.2
+    dev: true
+
+  /iconv-lite/0.6.3:
+    resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      safer-buffer: 2.1.2
+    dev: true
+    optional: true
+
+  /ieee754/1.2.1:
+    resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+    dev: true
+
+  /ignore-walk/6.0.0:
+    resolution: {integrity: sha512-bTf9UWe/UP1yxG3QUrj/KOvEhTAUWPcv+WvbFZ28LcqznXabp7Xu6o9y1JEC18+oqODuS7VhTpekV5XvFwsxJg==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      minimatch: 5.1.4
+    dev: true
+
+  /immediate/3.0.6:
+    resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+    dev: true
+
+  /imurmurhash/0.1.4:
+    resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+    engines: {node: '>=0.8.19'}
+    dev: true
+
+  /indent-string/4.0.0:
+    resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /infer-owner/1.0.4:
+    resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==}
+    dev: true
+
+  /inflight/1.0.6:
+    resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+    dependencies:
+      once: 1.4.0
+      wrappy: 1.0.2
+    dev: true
+
+  /inherits/2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+    dev: true
+
+  /ini/1.3.8:
+    resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+    dev: true
+
+  /ini/2.0.0:
+    resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==}
+    engines: {node: '>=10'}
+    dev: true
+
+  /ini/3.0.1:
+    resolution: {integrity: sha512-it4HyVAUTKBc6m8e1iXWvXSTdndF7HbdN713+kvLrymxTaU4AUBWrJ4vEooP+V7fexnVD3LKcBshjGGPefSMUQ==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dev: true
+
+  /inquirer/8.2.4:
+    resolution: {integrity: sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==}
+    engines: {node: '>=12.0.0'}
+    dependencies:
+      ansi-escapes: 4.3.2
+      chalk: 4.1.2
+      cli-cursor: 3.1.0
+      cli-width: 3.0.0
+      external-editor: 3.1.0
+      figures: 3.2.0
+      lodash: 4.17.21
+      mute-stream: 0.0.8
+      ora: 5.4.1
+      run-async: 2.4.1
+      rxjs: 7.5.7
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      through: 2.3.8
+      wrap-ansi: 7.0.0
+    dev: true
+
+  /ip/2.0.0:
+    resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==}
+    dev: true
+
+  /is-binary-path/2.1.0:
+    resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+    engines: {node: '>=8'}
+    dependencies:
+      binary-extensions: 2.2.0
+    dev: true
+
+  /is-ci/3.0.1:
+    resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==}
+    hasBin: true
+    dependencies:
+      ci-info: 3.7.1
+    dev: true
+
+  /is-core-module/2.11.0:
+    resolution: {integrity: sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==}
+    dependencies:
+      has: 1.0.3
+    dev: true
+
+  /is-docker/2.2.1:
+    resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
+    engines: {node: '>=8'}
+    hasBin: true
+    dev: true
+
+  /is-extglob/2.1.1:
+    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /is-fullwidth-code-point/3.0.0:
+    resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /is-glob/4.0.3:
+    resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      is-extglob: 2.1.1
+    dev: true
+
+  /is-installed-globally/0.4.0:
+    resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==}
+    engines: {node: '>=10'}
+    dependencies:
+      global-dirs: 3.0.1
+      is-path-inside: 3.0.3
+    dev: true
+
+  /is-interactive/1.0.0:
+    resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /is-lambda/1.0.1:
+    resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==}
+    dev: true
+
+  /is-module/1.0.0:
+    resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+    dev: true
+
+  /is-number/7.0.0:
+    resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+    engines: {node: '>=0.12.0'}
+    dev: true
+
+  /is-path-cwd/1.0.0:
+    resolution: {integrity: sha512-cnS56eR9SPAscL77ik76ATVqoPARTqPIVkMDVxRaWH06zT+6+CzIroYRJ0VVvm0Z1zfAvxvz9i/D3Ppjaqt5Nw==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /is-path-in-cwd/1.0.1:
+    resolution: {integrity: sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      is-path-inside: 1.0.1
+    dev: true
+
+  /is-path-inside/1.0.1:
+    resolution: {integrity: sha512-qhsCR/Esx4U4hg/9I19OVUAJkGWtjRYHMRgUMZE2TDdj+Ag+kttZanLupfddNyglzz50cUlmWzUaI37GDfNx/g==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      path-is-inside: 1.0.2
+    dev: true
+
+  /is-path-inside/3.0.3:
+    resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /is-stream/2.0.1:
+    resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /is-typedarray/1.0.0:
+    resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==}
+    dev: true
+
+  /is-unicode-supported/0.1.0:
+    resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
+    engines: {node: '>=10'}
+    dev: true
+
+  /is-wsl/2.2.0:
+    resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
+    engines: {node: '>=8'}
+    dependencies:
+      is-docker: 2.2.1
+    dev: true
+
+  /isarray/1.0.0:
+    resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+    dev: true
+
+  /isbinaryfile/4.0.10:
+    resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
+    engines: {node: '>= 8.0.0'}
+    dev: true
+
+  /isexe/2.0.0:
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+    dev: true
+
+  /isstream/0.1.2:
+    resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}
+    dev: true
+
+  /jasmine-core/2.8.0:
+    resolution: {integrity: sha512-SNkOkS+/jMZvLhuSx1fjhcNWUC/KG6oVyFUGkSBEr9n1axSNduWU8GlI7suaHXr4yxjet6KjrUZxUTE5WzzWwQ==}
+    dev: true
+
+  /jasmine-core/3.10.1:
+    resolution: {integrity: sha512-ooZWSDVAdh79Rrj4/nnfklL3NQVra0BcuhcuWoAwwi+znLDoUeH87AFfeX8s+YeYi6xlv5nveRyaA1v7CintfA==}
+    dev: true
+
+  /jasmine-core/3.99.1:
+    resolution: {integrity: sha512-Hu1dmuoGcZ7AfyynN3LsfruwMbxMALMka+YtZeGoLuDEySVmVAPaonkNoBRIw/ectu8b9tVQCJNgp4a4knp+tg==}
+    dev: true
+
+  /jasmine/2.8.0:
+    resolution: {integrity: sha512-KbdGQTf5jbZgltoHs31XGiChAPumMSY64OZMWLNYnEnMfG5uwGBhffePwuskexjT+/Jea/gU3qAU8344hNohSw==}
+    hasBin: true
+    dependencies:
+      exit: 0.1.2
+      glob: 7.2.3
+      jasmine-core: 2.8.0
+    dev: true
+
+  /jasmine/3.10.0:
+    resolution: {integrity: sha512-2Y42VsC+3CQCTzTwJezOvji4qLORmKIE0kwowWC+934Krn6ZXNQYljiwK5st9V3PVx96BSiDYXSB60VVah3IlQ==}
+    hasBin: true
+    dependencies:
+      glob: 7.2.3
+      jasmine-core: 3.10.1
+    dev: true
+
+  /jasminewd2/2.2.0:
+    resolution: {integrity: sha512-Rn0nZe4rfDhzA63Al3ZGh0E+JTmM6ESZYXJGKuqKGZObsAB9fwXPD03GjtIEvJBDOhN94T5MzbwZSqzFHSQPzg==}
+    engines: {node: '>= 6.9.x'}
+    dev: true
+
+  /js-tokens/4.0.0:
+    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+    dev: true
+
+  /jsbn/0.1.1:
+    resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
+    dev: true
+
+  /jsesc/2.5.2:
+    resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==}
+    engines: {node: '>=4'}
+    hasBin: true
+    dev: true
+
+  /json-parse-even-better-errors/3.0.0:
+    resolution: {integrity: sha512-iZbGHafX/59r39gPwVPRBGw0QQKnA7tte5pSMrhWOW7swGsVvVTjmfyAV9pNqk8YGT7tRCdxRu8uzcgZwoDooA==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dev: true
+
+  /json-schema-traverse/0.4.1:
+    resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+    dev: true
+
+  /json-schema-traverse/1.0.0:
+    resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+    dev: true
+
+  /json-schema/0.4.0:
+    resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
+    dev: true
+
+  /json-stringify-safe/5.0.1:
+    resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
+    dev: true
+
+  /json5/2.2.3:
+    resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+    engines: {node: '>=6'}
+    hasBin: true
+    dev: true
+
+  /jsonc-parser/3.2.0:
+    resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
+    dev: true
+
+  /jsonfile/4.0.0:
+    resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
+    optionalDependencies:
+      graceful-fs: 4.2.10
+    dev: true
+
+  /jsonfile/6.1.0:
+    resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
+    dependencies:
+      universalify: 2.0.0
+    optionalDependencies:
+      graceful-fs: 4.2.10
+    dev: true
+
+  /jsonparse/1.3.1:
+    resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
+    engines: {'0': node >= 0.2.0}
+    dev: true
+
+  /jsprim/1.4.2:
+    resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==}
+    engines: {node: '>=0.6.0'}
+    dependencies:
+      assert-plus: 1.0.0
+      extsprintf: 1.3.0
+      json-schema: 0.4.0
+      verror: 1.10.0
+    dev: true
+
+  /jsprim/2.0.2:
+    resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==}
+    engines: {'0': node >=0.6.0}
+    dependencies:
+      assert-plus: 1.0.0
+      extsprintf: 1.3.0
+      json-schema: 0.4.0
+      verror: 1.10.0
+    dev: true
+
+  /jszip/3.10.1:
+    resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+    dependencies:
+      lie: 3.3.0
+      pako: 1.0.11
+      readable-stream: 2.3.7
+      setimmediate: 1.0.5
+    dev: true
+
+  /karma-chrome-launcher/3.1.0:
+    resolution: {integrity: sha512-3dPs/n7vgz1rxxtynpzZTvb9y/GIaW8xjAwcIGttLbycqoFtI7yo1NGnQi6oFTherRE+GIhCAHZC4vEqWGhNvg==}
+    dependencies:
+      which: 1.3.1
+    dev: true
+
+  /karma-firefox-launcher/2.1.2:
+    resolution: {integrity: sha512-VV9xDQU1QIboTrjtGVD4NCfzIH7n01ZXqy/qpBhnOeGVOkG5JYPEm8kuSd7psHE6WouZaQ9Ool92g8LFweSNMA==}
+    dependencies:
+      is-wsl: 2.2.0
+      which: 2.0.2
+    dev: true
+
+  /karma-jasmine/4.0.1_karma@6.4.1:
+    resolution: {integrity: sha512-h8XDAhTiZjJKzfkoO1laMH+zfNlra+dEQHUAjpn5JV1zCPtOIVWGQjLBrqhnzQa/hrU2XrZwSyBa6XjEBzfXzw==}
+    engines: {node: '>= 10'}
+    peerDependencies:
+      karma: '*'
+    dependencies:
+      jasmine-core: 3.99.1
+      karma: 6.4.1
+    dev: true
+
+  /karma-requirejs/1.1.0_rexopxsgq4andhwzqrad3qy2ka:
+    resolution: {integrity: sha512-MHTOYKdwwJBkvYid0TaYvBzOnFH3TDtzo6ie5E4o9SaUSXXsfMRLa/whUz6efVIgTxj1xnKYasNn/XwEgJeB/Q==}
+    peerDependencies:
+      karma: '>=0.9'
+      requirejs: ^2.1.0
+    dependencies:
+      karma: 6.4.1
+      requirejs: 2.3.6
+    dev: true
+
+  /karma-sourcemap-loader/0.3.8:
+    resolution: {integrity: sha512-zorxyAakYZuBcHRJE+vbrK2o2JXLFWK8VVjiT/6P+ltLBUGUvqTEkUiQ119MGdOrK7mrmxXHZF1/pfT6GgIZ6g==}
+    dependencies:
+      graceful-fs: 4.2.10
+    dev: true
+
+  /karma/6.4.1:
+    resolution: {integrity: sha512-Cj57NKOskK7wtFWSlMvZf459iX+kpYIPXmkNUzP2WAFcA7nhr/ALn5R7sw3w+1udFDcpMx/tuB8d5amgm3ijaA==}
+    engines: {node: '>= 10'}
+    hasBin: true
+    dependencies:
+      '@colors/colors': 1.5.0
+      body-parser: 1.20.1
+      braces: 3.0.2
+      chokidar: 3.5.3
+      connect: 3.7.0
+      di: 0.0.1
+      dom-serialize: 2.2.1
+      glob: 7.2.3
+      graceful-fs: 4.2.10
+      http-proxy: 1.18.1
+      isbinaryfile: 4.0.10
+      lodash: 4.17.21
+      log4js: 6.7.1
+      mime: 2.6.0
+      minimatch: 3.1.2
+      mkdirp: 0.5.6
+      qjobs: 1.2.0
+      range-parser: 1.2.1
+      rimraf: 3.0.2
+      socket.io: 4.5.4
+      source-map: 0.6.1
+      tmp: 0.2.1
+      ua-parser-js: 0.7.32
+      yargs: 16.2.0
+    transitivePeerDependencies:
+      - bufferutil
+      - debug
+      - supports-color
+      - utf-8-validate
+    dev: true
+
+  /lazy-ass/1.6.0:
+    resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==}
+    engines: {node: '> 0.8'}
+    dev: true
+
+  /lie/3.3.0:
+    resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+    dependencies:
+      immediate: 3.0.6
+    dev: true
+
+  /listr2/3.14.0_enquirer@2.3.6:
+    resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      enquirer: '>= 2.3.0 < 3'
+    peerDependenciesMeta:
+      enquirer:
+        optional: true
+    dependencies:
+      cli-truncate: 2.1.0
+      colorette: 2.0.19
+      enquirer: 2.3.6
+      log-update: 4.0.0
+      p-map: 4.0.0
+      rfdc: 1.3.0
+      rxjs: 7.5.7
+      through: 2.3.8
+      wrap-ansi: 7.0.0
+    dev: true
+
+  /locate-path/5.0.0:
+    resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+    engines: {node: '>=8'}
+    dependencies:
+      p-locate: 4.1.0
+    dev: true
+
+  /lodash.once/4.1.1:
+    resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+    dev: true
+
+  /lodash/4.17.21:
+    resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+    dev: true
+
+  /log-symbols/4.1.0:
+    resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
+    engines: {node: '>=10'}
+    dependencies:
+      chalk: 4.1.2
+      is-unicode-supported: 0.1.0
+    dev: true
+
+  /log-update/4.0.0:
+    resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==}
+    engines: {node: '>=10'}
+    dependencies:
+      ansi-escapes: 4.3.2
+      cli-cursor: 3.1.0
+      slice-ansi: 4.0.0
+      wrap-ansi: 6.2.0
+    dev: true
+
+  /log4js/6.7.1:
+    resolution: {integrity: sha512-lzbd0Eq1HRdWM2abSD7mk6YIVY0AogGJzb/z+lqzRk+8+XJP+M6L1MS5FUSc3jjGru4dbKjEMJmqlsoYYpuivQ==}
+    engines: {node: '>=8.0'}
+    dependencies:
+      date-format: 4.0.14
+      debug: 4.3.4
+      flatted: 3.2.7
+      rfdc: 1.3.0
+      streamroller: 3.1.4
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /lru-cache/5.1.1:
+    resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+    dependencies:
+      yallist: 3.1.1
+    dev: true
+
+  /lru-cache/6.0.0:
+    resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
+    engines: {node: '>=10'}
+    dependencies:
+      yallist: 4.0.0
+    dev: true
+
+  /lru-cache/7.14.1:
+    resolution: {integrity: sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==}
+    engines: {node: '>=12'}
+    dev: true
+
+  /magic-string/0.26.7:
+    resolution: {integrity: sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==}
+    engines: {node: '>=12'}
+    dependencies:
+      sourcemap-codec: 1.4.8
+    dev: true
+
+  /make-dir/2.1.0:
+    resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
+    engines: {node: '>=6'}
+    dependencies:
+      pify: 4.0.1
+      semver: 5.7.1
+    dev: true
+
+  /make-fetch-happen/10.2.1:
+    resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      agentkeepalive: 4.2.1
+      cacache: 16.1.3
+      http-cache-semantics: 4.1.0
+      http-proxy-agent: 5.0.0
+      https-proxy-agent: 5.0.1
+      is-lambda: 1.0.1
+      lru-cache: 7.14.1
+      minipass: 3.3.6
+      minipass-collect: 1.0.2
+      minipass-fetch: 2.1.2
+      minipass-flush: 1.0.5
+      minipass-pipeline: 1.2.4
+      negotiator: 0.6.3
+      promise-retry: 2.0.1
+      socks-proxy-agent: 7.0.0
+      ssri: 9.0.1
+    transitivePeerDependencies:
+      - bluebird
+      - supports-color
+    dev: true
+
+  /make-fetch-happen/11.0.2:
+    resolution: {integrity: sha512-5n/Pq41w/uZghpdlXAY5kIM85RgJThtTH/NYBRAZ9VUOBWV90USaQjwGrw76fZP3Lj5hl/VZjpVvOaRBMoL/2w==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      agentkeepalive: 4.2.1
+      cacache: 17.0.4
+      http-cache-semantics: 4.1.0
+      http-proxy-agent: 5.0.0
+      https-proxy-agent: 5.0.1
+      is-lambda: 1.0.1
+      lru-cache: 7.14.1
+      minipass: 4.0.0
+      minipass-collect: 1.0.2
+      minipass-fetch: 3.0.1
+      minipass-flush: 1.0.5
+      minipass-pipeline: 1.2.4
+      negotiator: 0.6.3
+      promise-retry: 2.0.1
+      socks-proxy-agent: 7.0.0
+      ssri: 10.0.1
+    transitivePeerDependencies:
+      - bluebird
+      - supports-color
+    dev: true
+
+  /media-typer/0.3.0:
+    resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
+    engines: {node: '>= 0.6'}
+    dev: true
+
+  /merge-stream/2.0.0:
+    resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
+    dev: true
+
+  /mime-db/1.52.0:
+    resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+    engines: {node: '>= 0.6'}
+    dev: true
+
+  /mime-types/2.1.35:
+    resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+    engines: {node: '>= 0.6'}
+    dependencies:
+      mime-db: 1.52.0
+    dev: true
+
+  /mime/2.6.0:
+    resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
+    engines: {node: '>=4.0.0'}
+    hasBin: true
+    dev: true
+
+  /mimic-fn/2.1.0:
+    resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /minimatch/3.1.2:
+    resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+    dependencies:
+      brace-expansion: 1.1.11
+    dev: true
+
+  /minimatch/5.1.4:
+    resolution: {integrity: sha512-U0iNYXt9wALljzfnGkhFSy5sAC6/SCR3JrHrlsdJz4kF8MvhTRQNiC59iUi1iqsitV7abrNAJWElVL9pdnoUgw==}
+    engines: {node: '>=10'}
+    dependencies:
+      brace-expansion: 2.0.1
+    dev: true
+
+  /minimist/1.2.7:
+    resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==}
+    dev: true
+
+  /minipass-collect/1.0.2:
+    resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
+    engines: {node: '>= 8'}
+    dependencies:
+      minipass: 3.3.6
+    dev: true
+
+  /minipass-fetch/2.1.2:
+    resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      minipass: 3.3.6
+      minipass-sized: 1.0.3
+      minizlib: 2.1.2
+    optionalDependencies:
+      encoding: 0.1.13
+    dev: true
+
+  /minipass-fetch/3.0.1:
+    resolution: {integrity: sha512-t9/wowtf7DYkwz8cfMSt0rMwiyNIBXf5CKZ3S5ZMqRqMYT0oLTp0x1WorMI9WTwvaPg21r1JbFxJMum8JrLGfw==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      minipass: 4.0.0
+      minipass-sized: 1.0.3
+      minizlib: 2.1.2
+    optionalDependencies:
+      encoding: 0.1.13
+    dev: true
+
+  /minipass-flush/1.0.5:
+    resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==}
+    engines: {node: '>= 8'}
+    dependencies:
+      minipass: 3.3.6
+    dev: true
+
+  /minipass-json-stream/1.0.1:
+    resolution: {integrity: sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==}
+    dependencies:
+      jsonparse: 1.3.1
+      minipass: 3.3.6
+    dev: true
+
+  /minipass-pipeline/1.2.4:
+    resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==}
+    engines: {node: '>=8'}
+    dependencies:
+      minipass: 3.3.6
+    dev: true
+
+  /minipass-sized/1.0.3:
+    resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==}
+    engines: {node: '>=8'}
+    dependencies:
+      minipass: 3.3.6
+    dev: true
+
+  /minipass/3.3.6:
+    resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
+    engines: {node: '>=8'}
+    dependencies:
+      yallist: 4.0.0
+    dev: true
+
+  /minipass/4.0.0:
+    resolution: {integrity: sha512-g2Uuh2jEKoht+zvO6vJqXmYpflPqzRBT+Th2h01DKh5z7wbY/AZ2gCQ78cP70YoHPyFdY30YBV5WxgLOEwOykw==}
+    engines: {node: '>=8'}
+    dependencies:
+      yallist: 4.0.0
+    dev: true
+
+  /minizlib/2.1.2:
+    resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
+    engines: {node: '>= 8'}
+    dependencies:
+      minipass: 3.3.6
+      yallist: 4.0.0
+    dev: true
+
+  /mkdirp/0.5.6:
+    resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+    hasBin: true
+    dependencies:
+      minimist: 1.2.7
+    dev: true
+
+  /mkdirp/1.0.4:
+    resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
+    engines: {node: '>=10'}
+    hasBin: true
+    dev: true
+
+  /ms/2.0.0:
+    resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
+    dev: true
+
+  /ms/2.1.2:
+    resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
+    dev: true
+
+  /ms/2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+    dev: true
+
+  /mute-stream/0.0.8:
+    resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
+    dev: true
+
+  /negotiator/0.6.3:
+    resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
+    engines: {node: '>= 0.6'}
+    dev: true
+
+  /node-gyp/9.3.1:
+    resolution: {integrity: sha512-4Q16ZCqq3g8awk6UplT7AuxQ35XN4R/yf/+wSAwcBUAjg7l58RTactWaP8fIDTi0FzI7YcVLujwExakZlfWkXg==}
+    engines: {node: ^12.13 || ^14.13 || >=16}
+    hasBin: true
+    dependencies:
+      env-paths: 2.2.1
+      glob: 7.2.3
+      graceful-fs: 4.2.10
+      make-fetch-happen: 10.2.1
+      nopt: 6.0.0
+      npmlog: 6.0.2
+      rimraf: 3.0.2
+      semver: 7.3.8
+      tar: 6.1.13
+      which: 2.0.2
+    transitivePeerDependencies:
+      - bluebird
+      - supports-color
+    dev: true
+
+  /node-releases/2.0.8:
+    resolution: {integrity: sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==}
+    dev: true
+
+  /nopt/6.0.0:
+    resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    hasBin: true
+    dependencies:
+      abbrev: 1.1.1
+    dev: true
+
+  /normalize-package-data/5.0.0:
+    resolution: {integrity: sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      hosted-git-info: 6.1.1
+      is-core-module: 2.11.0
+      semver: 7.3.8
+      validate-npm-package-license: 3.0.4
+    dev: true
+
+  /normalize-path/3.0.0:
+    resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /npm-bundled/3.0.0:
+    resolution: {integrity: sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      npm-normalize-package-bin: 3.0.0
+    dev: true
+
+  /npm-install-checks/6.0.0:
+    resolution: {integrity: sha512-SBU9oFglRVZnfElwAtF14NivyulDqF1VKqqwNsFW9HDcbHMAPHpRSsVFgKuwFGq/hVvWZExz62Th0kvxn/XE7Q==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      semver: 7.3.8
+    dev: true
+
+  /npm-normalize-package-bin/3.0.0:
+    resolution: {integrity: sha512-g+DPQSkusnk7HYXr75NtzkIP4+N81i3RPsGFidF3DzHd9MT9wWngmqoeg/fnHFz5MNdtG4w03s+QnhewSLTT2Q==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dev: true
+
+  /npm-package-arg/10.1.0:
+    resolution: {integrity: sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      hosted-git-info: 6.1.1
+      proc-log: 3.0.0
+      semver: 7.3.8
+      validate-npm-package-name: 5.0.0
+    dev: true
+
+  /npm-package-arg/9.1.2:
+    resolution: {integrity: sha512-pzd9rLEx4TfNJkovvlBSLGhq31gGu2QDexFPWT19yCDh0JgnRhlBLNo5759N0AJmBk+kQ9Y/hXoLnlgFD+ukmg==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      hosted-git-info: 5.2.1
+      proc-log: 2.0.1
+      semver: 7.3.8
+      validate-npm-package-name: 4.0.0
+    dev: true
+
+  /npm-packlist/7.0.4:
+    resolution: {integrity: sha512-d6RGEuRrNS5/N84iglPivjaJPxhDbZmlbTwTDX2IbcRHG5bZCdtysYMhwiPvcF4GisXHGn7xsxv+GQ7T/02M5Q==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      ignore-walk: 6.0.0
+    dev: true
+
+  /npm-pick-manifest/8.0.1:
+    resolution: {integrity: sha512-mRtvlBjTsJvfCCdmPtiu2bdlx8d/KXtF7yNXNWe7G0Z36qWA9Ny5zXsI2PfBZEv7SXgoxTmNaTzGSbbzDZChoA==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      npm-install-checks: 6.0.0
+      npm-normalize-package-bin: 3.0.0
+      npm-package-arg: 10.1.0
+      semver: 7.3.8
+    dev: true
+
+  /npm-registry-fetch/14.0.3:
+    resolution: {integrity: sha512-YaeRbVNpnWvsGOjX2wk5s85XJ7l1qQBGAp724h8e2CZFFhMSuw9enom7K1mWVUtvXO1uUSFIAPofQK0pPN0ZcA==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      make-fetch-happen: 11.0.2
+      minipass: 4.0.0
+      minipass-fetch: 3.0.1
+      minipass-json-stream: 1.0.1
+      minizlib: 2.1.2
+      npm-package-arg: 10.1.0
+      proc-log: 3.0.0
+    transitivePeerDependencies:
+      - bluebird
+      - supports-color
+    dev: true
+
+  /npm-run-path/4.0.1:
+    resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
+    engines: {node: '>=8'}
+    dependencies:
+      path-key: 3.1.1
+    dev: true
+
+  /npmlog/6.0.2:
+    resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      are-we-there-yet: 3.0.1
+      console-control-strings: 1.1.0
+      gauge: 4.0.4
+      set-blocking: 2.0.0
+    dev: true
+
+  /oauth-sign/0.9.0:
+    resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
+    dev: true
+
+  /object-assign/4.1.1:
+    resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /object-inspect/1.12.3:
+    resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
+    dev: true
+
+  /on-finished/2.3.0:
+    resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      ee-first: 1.1.1
+    dev: true
+
+  /on-finished/2.4.1:
+    resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      ee-first: 1.1.1
+    dev: true
+
+  /once/1.4.0:
+    resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+    dependencies:
+      wrappy: 1.0.2
+    dev: true
+
+  /onetime/5.1.2:
+    resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+    engines: {node: '>=6'}
+    dependencies:
+      mimic-fn: 2.1.0
+    dev: true
+
+  /open/8.4.0:
+    resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==}
+    engines: {node: '>=12'}
+    dependencies:
+      define-lazy-prop: 2.0.0
+      is-docker: 2.2.1
+      is-wsl: 2.2.0
+    dev: true
+
+  /ora/5.4.1:
+    resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
+    engines: {node: '>=10'}
+    dependencies:
+      bl: 4.1.0
+      chalk: 4.1.2
+      cli-cursor: 3.1.0
+      cli-spinners: 2.7.0
+      is-interactive: 1.0.0
+      is-unicode-supported: 0.1.0
+      log-symbols: 4.1.0
+      strip-ansi: 6.0.1
+      wcwidth: 1.0.1
+    dev: true
+
+  /os-tmpdir/1.0.2:
+    resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /ospath/1.2.2:
+    resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
+    dev: true
+
+  /p-limit/2.3.0:
+    resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+    engines: {node: '>=6'}
+    dependencies:
+      p-try: 2.2.0
+    dev: true
+
+  /p-locate/4.1.0:
+    resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+    engines: {node: '>=8'}
+    dependencies:
+      p-limit: 2.3.0
+    dev: true
+
+  /p-map/4.0.0:
+    resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
+    engines: {node: '>=10'}
+    dependencies:
+      aggregate-error: 3.1.0
+    dev: true
+
+  /p-try/2.2.0:
+    resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /pacote/15.0.6:
+    resolution: {integrity: sha512-dQwcz/sME7QIL+cdrw/jftQfMMXxSo17i2kJ/gnhBhUvvBAsxoBu1lw9B5IzCH/Ce8CvEkG/QYZ6txzKfn0bTw==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    hasBin: true
+    dependencies:
+      '@npmcli/git': 4.0.3
+      '@npmcli/installed-package-contents': 2.0.1
+      '@npmcli/promise-spawn': 6.0.2
+      '@npmcli/run-script': 6.0.0
+      cacache: 17.0.4
+      fs-minipass: 2.1.0
+      minipass: 3.3.6
+      npm-package-arg: 10.1.0
+      npm-packlist: 7.0.4
+      npm-pick-manifest: 8.0.1
+      npm-registry-fetch: 14.0.3
+      proc-log: 3.0.0
+      promise-retry: 2.0.1
+      read-package-json: 6.0.0
+      read-package-json-fast: 3.0.2
+      ssri: 10.0.1
+      tar: 6.1.13
+    transitivePeerDependencies:
+      - bluebird
+      - supports-color
+    dev: true
+
+  /pako/1.0.11:
+    resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+    dev: true
+
+  /parse5/6.0.1:
+    resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
+    dev: true
+
+  /parseurl/1.3.3:
+    resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+    engines: {node: '>= 0.8'}
+    dev: true
+
+  /path-exists/4.0.0:
+    resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /path-is-absolute/1.0.1:
+    resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /path-is-inside/1.0.2:
+    resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==}
+    dev: true
+
+  /path-key/3.1.1:
+    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /path-parse/1.0.7:
+    resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+    dev: true
+
+  /pend/1.2.0:
+    resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+    dev: true
+
+  /performance-now/2.1.0:
+    resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
+    dev: true
+
+  /picocolors/1.0.0:
+    resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
+    dev: true
+
+  /picomatch/2.3.1:
+    resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+    engines: {node: '>=8.6'}
+    dev: true
+
+  /pify/2.3.0:
+    resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /pify/4.0.1:
+    resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /pinkie-promise/2.0.1:
+    resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      pinkie: 2.0.4
+    dev: true
+
+  /pinkie/2.0.4:
+    resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /prettier/2.6.1:
+    resolution: {integrity: sha512-8UVbTBYGwN37Bs9LERmxCPjdvPxlEowx2urIL6urHzdb3SDq4B/Z6xLFCblrSnE4iKWcS6ziJ3aOYrc1kz/E2A==}
+    engines: {node: '>=10.13.0'}
+    hasBin: true
+    dev: true
+
+  /pretty-bytes/5.6.0:
+    resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /proc-log/2.0.1:
+    resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dev: true
+
+  /proc-log/3.0.0:
+    resolution: {integrity: sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dev: true
+
+  /process-nextick-args/2.0.1:
+    resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+    dev: true
+
+  /promise-inflight/1.0.1:
+    resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==}
+    peerDependencies:
+      bluebird: '*'
+    peerDependenciesMeta:
+      bluebird:
+        optional: true
+    dev: true
+
+  /promise-retry/2.0.1:
+    resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
+    engines: {node: '>=10'}
+    dependencies:
+      err-code: 2.0.3
+      retry: 0.12.0
+    dev: true
+
+  /protractor/7.0.0:
+    resolution: {integrity: sha512-UqkFjivi4GcvUQYzqGYNe0mLzfn5jiLmO8w9nMhQoJRLhy2grJonpga2IWhI6yJO30LibWXJJtA4MOIZD2GgZw==}
+    engines: {node: '>=10.13.x'}
+    deprecated: We have news to share - Protractor is deprecated and will reach end-of-life by Summer 2023. To learn more and find out about other options please refer to this post on the Angular blog. Thank you for using and contributing to Protractor. https://goo.gle/state-of-e2e-in-angular
+    hasBin: true
+    dependencies:
+      '@types/q': 0.0.32
+      '@types/selenium-webdriver': 3.0.20
+      blocking-proxy: 1.0.1
+      browserstack: 1.6.1
+      chalk: 1.1.3
+      glob: 7.2.3
+      jasmine: 2.8.0
+      jasminewd2: 2.2.0
+      q: 1.4.1
+      saucelabs: 1.5.0
+      selenium-webdriver: 3.6.0
+      source-map-support: 0.4.18
+      webdriver-js-extender: 2.1.0
+      webdriver-manager: 12.1.8
+      yargs: 15.4.1
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /proxy-from-env/1.0.0:
+    resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==}
+    dev: true
+
+  /psl/1.9.0:
+    resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
+    dev: true
+
+  /pump/3.0.0:
+    resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
+    dependencies:
+      end-of-stream: 1.4.4
+      once: 1.4.0
+    dev: true
+
+  /punycode/2.2.0:
+    resolution: {integrity: sha512-LN6QV1IJ9ZhxWTNdktaPClrNfp8xdSAYS0Zk2ddX7XsXZAxckMHPCBcHRo0cTcEIgYPRiGEkmji3Idkh2yFtYw==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /q/1.4.1:
+    resolution: {integrity: sha512-/CdEdaw49VZVmyIDGUQKDDT53c7qBkO6g5CefWz91Ae+l4+cRtcDYwMTXh6me4O8TMldeGHG3N2Bl84V78Ywbg==}
+    engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
+    dev: true
+
+  /qjobs/1.2.0:
+    resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==}
+    engines: {node: '>=0.9'}
+    dev: true
+
+  /qs/6.10.4:
+    resolution: {integrity: sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==}
+    engines: {node: '>=0.6'}
+    dependencies:
+      side-channel: 1.0.4
+    dev: true
+
+  /qs/6.11.0:
+    resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
+    engines: {node: '>=0.6'}
+    dependencies:
+      side-channel: 1.0.4
+    dev: true
+
+  /qs/6.5.3:
+    resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==}
+    engines: {node: '>=0.6'}
+    dev: true
+
+  /range-parser/1.2.1:
+    resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+    engines: {node: '>= 0.6'}
+    dev: true
+
+  /raw-body/2.5.1:
+    resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      bytes: 3.1.2
+      http-errors: 2.0.0
+      iconv-lite: 0.4.24
+      unpipe: 1.0.0
+    dev: true
+
+  /read-package-json-fast/3.0.2:
+    resolution: {integrity: sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      json-parse-even-better-errors: 3.0.0
+      npm-normalize-package-bin: 3.0.0
+    dev: true
+
+  /read-package-json/6.0.0:
+    resolution: {integrity: sha512-b/9jxWJ8EwogJPpv99ma+QwtqB7FSl3+V6UXS7Aaay8/5VwMY50oIFooY1UKXMWpfNCM6T/PoGqa5GD1g9xf9w==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      glob: 8.1.0
+      json-parse-even-better-errors: 3.0.0
+      normalize-package-data: 5.0.0
+      npm-normalize-package-bin: 3.0.0
+    dev: true
+
+  /readable-stream/2.3.7:
+    resolution: {integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==}
+    dependencies:
+      core-util-is: 1.0.3
+      inherits: 2.0.4
+      isarray: 1.0.0
+      process-nextick-args: 2.0.1
+      safe-buffer: 5.1.2
+      string_decoder: 1.1.1
+      util-deprecate: 1.0.2
+    dev: true
+
+  /readable-stream/3.6.0:
+    resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==}
+    engines: {node: '>= 6'}
+    dependencies:
+      inherits: 2.0.4
+      string_decoder: 1.3.0
+      util-deprecate: 1.0.2
+    dev: true
+
+  /readdirp/3.6.0:
+    resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+    engines: {node: '>=8.10.0'}
+    dependencies:
+      picomatch: 2.3.1
+    dev: true
+
+  /reflect-metadata/0.1.13:
+    resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
+    dev: true
+
+  /request-progress/3.0.0:
+    resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
+    dependencies:
+      throttleit: 1.0.0
+    dev: true
+
+  /request/2.88.2:
+    resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==}
+    engines: {node: '>= 6'}
+    deprecated: request has been deprecated, see https://github.com/request/request/issues/3142
+    dependencies:
+      aws-sign2: 0.7.0
+      aws4: 1.12.0
+      caseless: 0.12.0
+      combined-stream: 1.0.8
+      extend: 3.0.2
+      forever-agent: 0.6.1
+      form-data: 2.3.3
+      har-validator: 5.1.5
+      http-signature: 1.2.0
+      is-typedarray: 1.0.0
+      isstream: 0.1.2
+      json-stringify-safe: 5.0.1
+      mime-types: 2.1.35
+      oauth-sign: 0.9.0
+      performance-now: 2.1.0
+      qs: 6.5.3
+      safe-buffer: 5.2.1
+      tough-cookie: 2.5.0
+      tunnel-agent: 0.6.0
+      uuid: 3.4.0
+    dev: true
+
+  /require-directory/2.1.1:
+    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /require-from-string/2.0.2:
+    resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /require-main-filename/2.0.0:
+    resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+    dev: true
+
+  /requirejs/2.3.6:
+    resolution: {integrity: sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==}
+    engines: {node: '>=0.4.0'}
+    hasBin: true
+    dev: true
+
+  /requires-port/1.0.0:
+    resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+    dev: true
+
+  /resolve/1.22.1:
+    resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==}
+    hasBin: true
+    dependencies:
+      is-core-module: 2.11.0
+      path-parse: 1.0.7
+      supports-preserve-symlinks-flag: 1.0.0
+    dev: true
+
+  /restore-cursor/3.1.0:
+    resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
+    engines: {node: '>=8'}
+    dependencies:
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+    dev: true
+
+  /retry/0.12.0:
+    resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
+    engines: {node: '>= 4'}
+    dev: true
+
+  /rfdc/1.3.0:
+    resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==}
+    dev: true
+
+  /rimraf/2.7.1:
+    resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
+    hasBin: true
+    dependencies:
+      glob: 7.2.3
+    dev: true
+
+  /rimraf/3.0.2:
+    resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
+    hasBin: true
+    dependencies:
+      glob: 7.2.3
+    dev: true
+
+  /rollup/2.66.1:
+    resolution: {integrity: sha512-crSgLhSkLMnKr4s9iZ/1qJCplgAgrRY+igWv8KhG/AjKOJ0YX/WpmANyn8oxrw+zenF3BXWDLa7Xl/QZISH+7w==}
+    engines: {node: '>=10.0.0'}
+    hasBin: true
+    optionalDependencies:
+      fsevents: 2.3.2
+    dev: true
+
+  /run-async/2.4.1:
+    resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
+    engines: {node: '>=0.12.0'}
+    dev: true
+
+  /rxjs/6.6.7:
+    resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==}
+    engines: {npm: '>=2.0.0'}
+    dependencies:
+      tslib: 1.14.1
+    dev: true
+
+  /rxjs/7.5.7:
+    resolution: {integrity: sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==}
+    dependencies:
+      tslib: 2.4.1
+    dev: true
+
+  /safe-buffer/5.1.2:
+    resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+    dev: true
+
+  /safe-buffer/5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+    dev: true
+
+  /safer-buffer/2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+    dev: true
+
+  /saucelabs/1.5.0:
+    resolution: {integrity: sha512-jlX3FGdWvYf4Q3LFfFWS1QvPg3IGCGWxIc8QBFdPTbpTJnt/v17FHXYVAn7C8sHf1yUXo2c7yIM0isDryfYtHQ==}
+    dependencies:
+      https-proxy-agent: 2.2.4
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /sax/1.2.4:
+    resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
+    dev: true
+
+  /selenium-webdriver/3.6.0:
+    resolution: {integrity: sha512-WH7Aldse+2P5bbFBO4Gle/nuQOdVwpHMTL6raL3uuBj/vPG07k6uzt3aiahu352ONBr5xXh0hDlM3LhtXPOC4Q==}
+    engines: {node: '>= 6.9.0'}
+    dependencies:
+      jszip: 3.10.1
+      rimraf: 2.7.1
+      tmp: 0.0.30
+      xml2js: 0.4.23
+    dev: true
+
+  /semver/5.7.1:
+    resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
+    hasBin: true
+    dev: true
+
+  /semver/6.3.0:
+    resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
+    hasBin: true
+    dev: true
+
+  /semver/7.3.8:
+    resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==}
+    engines: {node: '>=10'}
+    hasBin: true
+    dependencies:
+      lru-cache: 6.0.0
+    dev: true
+
+  /set-blocking/2.0.0:
+    resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+    dev: true
+
+  /setimmediate/1.0.5:
+    resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+    dev: true
+
+  /setprototypeof/1.2.0:
+    resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+    dev: true
+
+  /shebang-command/2.0.0:
+    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+    engines: {node: '>=8'}
+    dependencies:
+      shebang-regex: 3.0.0
+    dev: true
+
+  /shebang-regex/3.0.0:
+    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /side-channel/1.0.4:
+    resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
+    dependencies:
+      call-bind: 1.0.2
+      get-intrinsic: 1.1.3
+      object-inspect: 1.12.3
+    dev: true
+
+  /signal-exit/3.0.7:
+    resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+    dev: true
+
+  /slash/2.0.0:
+    resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /slice-ansi/3.0.0:
+    resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==}
+    engines: {node: '>=8'}
+    dependencies:
+      ansi-styles: 4.3.0
+      astral-regex: 2.0.0
+      is-fullwidth-code-point: 3.0.0
+    dev: true
+
+  /slice-ansi/4.0.0:
+    resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
+    engines: {node: '>=10'}
+    dependencies:
+      ansi-styles: 4.3.0
+      astral-regex: 2.0.0
+      is-fullwidth-code-point: 3.0.0
+    dev: true
+
+  /smart-buffer/4.2.0:
+    resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
+    engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
+    dev: true
+
+  /socket.io-adapter/2.4.0:
+    resolution: {integrity: sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg==}
+    dev: true
+
+  /socket.io-parser/4.2.1:
+    resolution: {integrity: sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==}
+    engines: {node: '>=10.0.0'}
+    dependencies:
+      '@socket.io/component-emitter': 3.1.0
+      debug: 4.3.4
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /socket.io/4.5.4:
+    resolution: {integrity: sha512-m3GC94iK9MfIEeIBfbhJs5BqFibMtkRk8ZpKwG2QwxV0m/eEhPIV4ara6XCF1LWNAus7z58RodiZlAH71U3EhQ==}
+    engines: {node: '>=10.0.0'}
+    dependencies:
+      accepts: 1.3.8
+      base64id: 2.0.0
+      debug: 4.3.4
+      engine.io: 6.2.1
+      socket.io-adapter: 2.4.0
+      socket.io-parser: 4.2.1
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+    dev: true
+
+  /socks-proxy-agent/7.0.0:
+    resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==}
+    engines: {node: '>= 10'}
+    dependencies:
+      agent-base: 6.0.2
+      debug: 4.3.4
+      socks: 2.7.1
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /socks/2.7.1:
+    resolution: {integrity: sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==}
+    engines: {node: '>= 10.13.0', npm: '>= 3.0.0'}
+    dependencies:
+      ip: 2.0.0
+      smart-buffer: 4.2.0
+    dev: true
+
+  /source-map-support/0.4.18:
+    resolution: {integrity: sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==}
+    dependencies:
+      source-map: 0.5.7
+    dev: true
+
+  /source-map-support/0.5.21:
+    resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+    dependencies:
+      buffer-from: 1.1.2
+      source-map: 0.6.1
+    dev: true
+
+  /source-map/0.5.7:
+    resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /source-map/0.6.1:
+    resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /source-map/0.7.4:
+    resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
+    engines: {node: '>= 8'}
+    dev: true
+
+  /sourcemap-codec/1.4.8:
+    resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
+    deprecated: Please use @jridgewell/sourcemap-codec instead
+    dev: true
+
+  /spdx-correct/3.1.1:
+    resolution: {integrity: sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==}
+    dependencies:
+      spdx-expression-parse: 3.0.1
+      spdx-license-ids: 3.0.12
+    dev: true
+
+  /spdx-exceptions/2.3.0:
+    resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==}
+    dev: true
+
+  /spdx-expression-parse/3.0.1:
+    resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==}
+    dependencies:
+      spdx-exceptions: 2.3.0
+      spdx-license-ids: 3.0.12
+    dev: true
+
+  /spdx-license-ids/3.0.12:
+    resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==}
+    dev: true
+
+  /sshpk/1.17.0:
+    resolution: {integrity: sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==}
+    engines: {node: '>=0.10.0'}
+    hasBin: true
+    dependencies:
+      asn1: 0.2.6
+      assert-plus: 1.0.0
+      bcrypt-pbkdf: 1.0.2
+      dashdash: 1.14.1
+      ecc-jsbn: 0.1.2
+      getpass: 0.1.7
+      jsbn: 0.1.1
+      safer-buffer: 2.1.2
+      tweetnacl: 0.14.5
+    dev: true
+
+  /ssri/10.0.1:
+    resolution: {integrity: sha512-WVy6di9DlPOeBWEjMScpNipeSX2jIZBGEn5Uuo8Q7aIuFEuDX0pw8RxcOjlD1TWP4obi24ki7m/13+nFpcbXrw==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      minipass: 4.0.0
+    dev: true
+
+  /ssri/9.0.1:
+    resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      minipass: 3.3.6
+    dev: true
+
+  /statuses/1.5.0:
+    resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
+    engines: {node: '>= 0.6'}
+    dev: true
+
+  /statuses/2.0.1:
+    resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
+    engines: {node: '>= 0.8'}
+    dev: true
+
+  /streamroller/3.1.4:
+    resolution: {integrity: sha512-Ha1Ccw2/N5C/IF8Do6zgNe8F3jQo8MPBnMBGvX0QjNv/I97BcNRzK6/mzOpZHHK7DjMLTI3c7Xw7Y1KvdChkvw==}
+    engines: {node: '>=8.0'}
+    dependencies:
+      date-format: 4.0.14
+      debug: 4.3.4
+      fs-extra: 8.1.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /string-width/4.2.3:
+    resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+    engines: {node: '>=8'}
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 6.0.1
+    dev: true
+
+  /string_decoder/1.1.1:
+    resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+    dependencies:
+      safe-buffer: 5.1.2
+    dev: true
+
+  /string_decoder/1.3.0:
+    resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+    dependencies:
+      safe-buffer: 5.2.1
+    dev: true
+
+  /strip-ansi/3.0.1:
+    resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      ansi-regex: 2.1.1
+    dev: true
+
+  /strip-ansi/6.0.1:
+    resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+    engines: {node: '>=8'}
+    dependencies:
+      ansi-regex: 5.0.1
+    dev: true
+
+  /strip-final-newline/2.0.0:
+    resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
+    engines: {node: '>=6'}
+    dev: true
+
+  /supports-color/2.0.0:
+    resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==}
+    engines: {node: '>=0.8.0'}
+    dev: true
+
+  /supports-color/5.5.0:
+    resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
+    engines: {node: '>=4'}
+    dependencies:
+      has-flag: 3.0.0
+    dev: true
+
+  /supports-color/7.2.0:
+    resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+    engines: {node: '>=8'}
+    dependencies:
+      has-flag: 4.0.0
+    dev: true
+
+  /supports-color/8.1.1:
+    resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
+    engines: {node: '>=10'}
+    dependencies:
+      has-flag: 4.0.0
+    dev: true
+
+  /supports-preserve-symlinks-flag/1.0.0:
+    resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+    engines: {node: '>= 0.4'}
+    dev: true
+
+  /symbol-observable/4.0.0:
+    resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
+    engines: {node: '>=0.10'}
+    dev: true
+
+  /tar/6.1.13:
+    resolution: {integrity: sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==}
+    engines: {node: '>=10'}
+    dependencies:
+      chownr: 2.0.0
+      fs-minipass: 2.1.0
+      minipass: 4.0.0
+      minizlib: 2.1.2
+      mkdirp: 1.0.4
+      yallist: 4.0.0
+    dev: true
+
+  /terser/5.10.0:
+    resolution: {integrity: sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==}
+    engines: {node: '>=10'}
+    hasBin: true
+    peerDependenciesMeta:
+      acorn:
+        optional: true
+    dependencies:
+      acorn: 8.8.1
+      commander: 2.20.3
+      source-map: 0.7.4
+      source-map-support: 0.5.21
+    dev: true
+
+  /throttleit/1.0.0:
+    resolution: {integrity: sha512-rkTVqu6IjfQ/6+uNuuc3sZek4CEYxTJom3IktzgdSxcZqdARuebbA/f4QmAxMQIxqq9ZLEUkSYqvuk1I6VKq4g==}
+    dev: true
+
+  /through/2.3.8:
+    resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
+    dev: true
+
+  /tmp/0.0.30:
+    resolution: {integrity: sha512-HXdTB7lvMwcb55XFfrTM8CPr/IYREk4hVBFaQ4b/6nInrluSL86hfHm7vu0luYKCfyBZp2trCjpc8caC3vVM3w==}
+    engines: {node: '>=0.4.0'}
+    dependencies:
+      os-tmpdir: 1.0.2
+    dev: true
+
+  /tmp/0.0.33:
+    resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
+    engines: {node: '>=0.6.0'}
+    dependencies:
+      os-tmpdir: 1.0.2
+    dev: true
+
+  /tmp/0.2.1:
+    resolution: {integrity: sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==}
+    engines: {node: '>=8.17.0'}
+    dependencies:
+      rimraf: 3.0.2
+    dev: true
+
+  /to-fast-properties/2.0.0:
+    resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
+    engines: {node: '>=4'}
+    dev: true
+
+  /to-regex-range/5.0.1:
+    resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+    engines: {node: '>=8.0'}
+    dependencies:
+      is-number: 7.0.0
+    dev: true
+
+  /toidentifier/1.0.1:
+    resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+    engines: {node: '>=0.6'}
+    dev: true
+
+  /tough-cookie/2.5.0:
+    resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==}
+    engines: {node: '>=0.8'}
+    dependencies:
+      psl: 1.9.0
+      punycode: 2.2.0
+    dev: true
+
+  /tslib/1.14.1:
+    resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+    dev: true
+
+  /tslib/2.4.1:
+    resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==}
+
+  /tunnel-agent/0.6.0:
+    resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
+    dependencies:
+      safe-buffer: 5.2.1
+    dev: true
+
+  /tweetnacl/0.14.5:
+    resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
+    dev: true
+
+  /type-fest/0.21.3:
+    resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
+    engines: {node: '>=10'}
+    dev: true
+
+  /type-is/1.6.18:
+    resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
+    engines: {node: '>= 0.6'}
+    dependencies:
+      media-typer: 0.3.0
+      mime-types: 2.1.35
+    dev: true
+
+  /typescript/4.8.4:
+    resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==}
+    engines: {node: '>=4.2.0'}
+    hasBin: true
+    dev: true
+
+  /ua-parser-js/0.7.32:
+    resolution: {integrity: sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw==}
+    dev: true
+
+  /unique-filename/2.0.1:
+    resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      unique-slug: 3.0.0
+    dev: true
+
+  /unique-filename/3.0.0:
+    resolution: {integrity: sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      unique-slug: 4.0.0
+    dev: true
+
+  /unique-slug/3.0.0:
+    resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      imurmurhash: 0.1.4
+    dev: true
+
+  /unique-slug/4.0.0:
+    resolution: {integrity: sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      imurmurhash: 0.1.4
+    dev: true
+
+  /universalify/0.1.2:
+    resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
+    engines: {node: '>= 4.0.0'}
+    dev: true
+
+  /universalify/2.0.0:
+    resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
+    engines: {node: '>= 10.0.0'}
+    dev: true
+
+  /unpipe/1.0.0:
+    resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+    engines: {node: '>= 0.8'}
+    dev: true
+
+  /untildify/4.0.0:
+    resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
+    engines: {node: '>=8'}
+    dev: true
+
+  /update-browserslist-db/1.0.10_browserslist@4.21.4:
+    resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==}
+    hasBin: true
+    peerDependencies:
+      browserslist: '>= 4.21.0'
+    dependencies:
+      browserslist: 4.21.4
+      escalade: 3.1.1
+      picocolors: 1.0.0
+    dev: true
+
+  /uri-js/4.4.1:
+    resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+    dependencies:
+      punycode: 2.2.0
+    dev: true
+
+  /util-deprecate/1.0.2:
+    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+    dev: true
+
+  /utils-merge/1.0.1:
+    resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
+    engines: {node: '>= 0.4.0'}
+    dev: true
+
+  /uuid/3.4.0:
+    resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==}
+    deprecated: Please upgrade  to version 7 or higher.  Older versions may use Math.random() in certain circumstances, which is known to be problematic.  See https://v8.dev/blog/math-random for details.
+    hasBin: true
+    dev: true
+
+  /uuid/8.3.2:
+    resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+    hasBin: true
+    dev: true
+
+  /validate-npm-package-license/3.0.4:
+    resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
+    dependencies:
+      spdx-correct: 3.1.1
+      spdx-expression-parse: 3.0.1
+    dev: true
+
+  /validate-npm-package-name/4.0.0:
+    resolution: {integrity: sha512-mzR0L8ZDktZjpX4OB46KT+56MAhl4EIazWP/+G/HPGuvfdaqg4YsCdtOm6U9+LOFyYDoh4dpnpxZRB9MQQns5Q==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+    dependencies:
+      builtins: 5.0.1
+    dev: true
+
+  /validate-npm-package-name/5.0.0:
+    resolution: {integrity: sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    dependencies:
+      builtins: 5.0.1
+    dev: true
+
+  /vary/1.1.2:
+    resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+    engines: {node: '>= 0.8'}
+    dev: true
+
+  /verror/1.10.0:
+    resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
+    engines: {'0': node >=0.6.0}
+    dependencies:
+      assert-plus: 1.0.0
+      core-util-is: 1.0.2
+      extsprintf: 1.3.0
+    dev: true
+
+  /void-elements/2.0.1:
+    resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /wcwidth/1.0.1:
+    resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
+    dependencies:
+      defaults: 1.0.4
+    dev: true
+
+  /webdriver-js-extender/2.1.0:
+    resolution: {integrity: sha512-lcUKrjbBfCK6MNsh7xaY2UAUmZwe+/ib03AjVOpFobX4O7+83BUveSrLfU0Qsyb1DaKJdQRbuU+kM9aZ6QUhiQ==}
+    engines: {node: '>=6.9.x'}
+    dependencies:
+      '@types/selenium-webdriver': 3.0.20
+      selenium-webdriver: 3.6.0
+    dev: true
+
+  /webdriver-manager/12.1.8:
+    resolution: {integrity: sha512-qJR36SXG2VwKugPcdwhaqcLQOD7r8P2Xiv9sfNbfZrKBnX243iAkOueX1yAmeNgIKhJ3YAT/F2gq6IiEZzahsg==}
+    engines: {node: '>=6.9.x'}
+    hasBin: true
+    dependencies:
+      adm-zip: 0.4.16
+      chalk: 1.1.3
+      del: 2.2.2
+      glob: 7.2.3
+      ini: 1.3.8
+      minimist: 1.2.7
+      q: 1.4.1
+      request: 2.88.2
+      rimraf: 2.7.1
+      semver: 5.7.1
+      xml2js: 0.4.23
+    dev: true
+
+  /which-module/2.0.0:
+    resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==}
+    dev: true
+
+  /which/1.3.1:
+    resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
+    hasBin: true
+    dependencies:
+      isexe: 2.0.0
+    dev: true
+
+  /which/2.0.2:
+    resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+    engines: {node: '>= 8'}
+    hasBin: true
+    dependencies:
+      isexe: 2.0.0
+    dev: true
+
+  /which/3.0.0:
+    resolution: {integrity: sha512-nla//68K9NU6yRiwDY/Q8aU6siKlSs64aEC7+IV56QoAuyQT2ovsJcgGYGyqMOmI/CGN1BOR6mM5EN0FBO+zyQ==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+    hasBin: true
+    dependencies:
+      isexe: 2.0.0
+    dev: true
+
+  /wide-align/1.1.5:
+    resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
+    dependencies:
+      string-width: 4.2.3
+    dev: true
+
+  /wrap-ansi/6.2.0:
+    resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+    engines: {node: '>=8'}
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+    dev: true
+
+  /wrap-ansi/7.0.0:
+    resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+    engines: {node: '>=10'}
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+    dev: true
+
+  /wrappy/1.0.2:
+    resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+    dev: true
+
+  /ws/8.2.3:
+    resolution: {integrity: sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: ^5.0.2
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+    dev: true
+
+  /xml2js/0.4.23:
+    resolution: {integrity: sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==}
+    engines: {node: '>=4.0.0'}
+    dependencies:
+      sax: 1.2.4
+      xmlbuilder: 11.0.1
+    dev: true
+
+  /xmlbuilder/11.0.1:
+    resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
+    engines: {node: '>=4.0'}
+    dev: true
+
+  /y18n/4.0.3:
+    resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+    dev: true
+
+  /y18n/5.0.8:
+    resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+    engines: {node: '>=10'}
+    dev: true
+
+  /yallist/3.1.1:
+    resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+    dev: true
+
+  /yallist/4.0.0:
+    resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+    dev: true
+
+  /yargs-parser/18.1.3:
+    resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+    engines: {node: '>=6'}
+    dependencies:
+      camelcase: 5.3.1
+      decamelize: 1.2.0
+    dev: true
+
+  /yargs-parser/20.2.9:
+    resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
+    engines: {node: '>=10'}
+    dev: true
+
+  /yargs-parser/21.1.1:
+    resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+    engines: {node: '>=12'}
+    dev: true
+
+  /yargs/15.4.1:
+    resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+    engines: {node: '>=8'}
+    dependencies:
+      cliui: 6.0.0
+      decamelize: 1.2.0
+      find-up: 4.1.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      require-main-filename: 2.0.0
+      set-blocking: 2.0.0
+      string-width: 4.2.3
+      which-module: 2.0.0
+      y18n: 4.0.3
+      yargs-parser: 18.1.3
+    dev: true
+
+  /yargs/16.2.0:
+    resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
+    engines: {node: '>=10'}
+    dependencies:
+      cliui: 7.0.4
+      escalade: 3.1.1
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      string-width: 4.2.3
+      y18n: 5.0.8
+      yargs-parser: 20.2.9
+    dev: true
+
+  /yargs/17.6.2:
+    resolution: {integrity: sha512-1/9UrdHjDZc0eOU0HxOHoS78C69UD3JRMvzlJ7S79S2nTaWRA/whGCTV8o9e/N/1Va9YIV7Q4sOxD8VV4pCWOw==}
+    engines: {node: '>=12'}
+    dependencies:
+      cliui: 8.0.1
+      escalade: 3.1.1
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      string-width: 4.2.3
+      y18n: 5.0.8
+      yargs-parser: 21.1.1
+    dev: true
+
+  /yauzl/2.10.0:
+    resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+    dependencies:
+      buffer-crc32: 0.2.13
+      fd-slicer: 1.1.0
+    dev: true
+
+  /zone.js/0.11.8:
+    resolution: {integrity: sha512-82bctBg2hKcEJ21humWIkXRlLBBmrc3nN7DFh5LGGhcyycO2S7FN8NmdvlcKaGFDNVL4/9kFLmwmInTavdJERA==}
+    dependencies:
+      tslib: 2.4.1
+    dev: true
diff --git a/scouting/BUILD b/scouting/BUILD
index f58a157..ae121e6 100644
--- a/scouting/BUILD
+++ b/scouting/BUILD
@@ -11,6 +11,9 @@
         "//scouting/www:static_files",
     ],
     visibility = ["//visibility:public"],
+    deps = [
+        "@bazel_tools//tools/bash/runfiles",
+    ],
 )
 
 protractor_ts_test(
diff --git a/scouting/db/db.go b/scouting/db/db.go
index 8f043de..940d96e 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -3,7 +3,6 @@
 import (
 	"errors"
 	"fmt"
-
 	"gorm.io/driver/postgres"
 	"gorm.io/gorm"
 	"gorm.io/gorm/clause"
@@ -14,13 +13,13 @@
 	*gorm.DB
 }
 
-type Match struct {
-	// TODO(phil): Rework this be be one team per row.
-	// Makes queries much simpler.
-	MatchNumber            int32  `gorm:"primaryKey"`
-	SetNumber              int32  `gorm:"primaryKey"`
-	CompLevel              string `gorm:"primaryKey"`
-	R1, R2, R3, B1, B2, B3 int32
+type TeamMatch struct {
+	MatchNumber      int32  `gorm:"primaryKey"`
+	SetNumber        int32  `gorm:"primaryKey"`
+	CompLevel        string `gorm:"primaryKey"`
+	Alliance         string `gorm:"primaryKey"` // "R" or "B"
+	AlliancePosition int32  `gorm:"primaryKey"` // 1, 2, or 3
+	TeamNumber       int32
 }
 
 type Shift struct {
@@ -141,7 +140,7 @@
 		return nil, errors.New(fmt.Sprint("Failed to connect to postgres: ", err))
 	}
 
-	err = database.AutoMigrate(&Match{}, &Shift{}, &Stats{}, &Stats2023{}, &Action{}, &NotesData{}, &Ranking{}, &DriverRankingData{})
+	err = database.AutoMigrate(&TeamMatch{}, &Shift{}, &Stats{}, &Stats2023{}, &Action{}, &NotesData{}, &Ranking{}, &DriverRankingData{})
 	if err != nil {
 		database.Delete()
 		return nil, errors.New(fmt.Sprint("Failed to create/migrate tables: ", err))
@@ -162,7 +161,7 @@
 	database.DB.Logger = database.DB.Logger.LogMode(logger.Info)
 }
 
-func (database *Database) AddToMatch(m Match) error {
+func (database *Database) AddToMatch(m TeamMatch) error {
 	result := database.Clauses(clause.OnConflict{
 		UpdateAll: true,
 	}).Create(&m)
@@ -240,8 +239,8 @@
 	return result.Error
 }
 
-func (database *Database) ReturnMatches() ([]Match, error) {
-	var matches []Match
+func (database *Database) ReturnMatches() ([]TeamMatch, error) {
+	var matches []TeamMatch
 	result := database.Find(&matches)
 	return matches, result.Error
 }
@@ -304,18 +303,18 @@
 	return rankins, result.Error
 }
 
-func (database *Database) queryMatches(teamNumber_ int32) ([]Match, error) {
-	var matches []Match
+func (database *Database) queryMatches(teamNumber_ int32) ([]TeamMatch, error) {
+	var matches []TeamMatch
 	result := database.
-		Where("r1 = $1 OR r2 = $1 OR r3 = $1 OR b1 = $1 OR b2 = $1 OR b3 = $1", teamNumber_).
+		Where("team_number = $1", teamNumber_).
 		Find(&matches)
 	return matches, result.Error
 }
 
-func (database *Database) QueryMatchesString(teamNumber_ string) ([]Match, error) {
-	var matches []Match
+func (database *Database) QueryMatchesString(teamNumber_ string) ([]TeamMatch, error) {
+	var matches []TeamMatch
 	result := database.
-		Where("r1 = $1 OR r2 = $1 OR r3 = $1 OR b1 = $1 OR b2 = $1 OR b3 = $1", teamNumber_).
+		Where("team_number = $1", teamNumber_).
 		Find(&matches)
 	return matches, result.Error
 }
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index 9e85651..ec5e776 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -71,17 +71,37 @@
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
 
-	correct := []Match{
-		Match{
-			MatchNumber: 7,
-			SetNumber:   1,
-			CompLevel:   "quals",
-			R1:          9999, R2: 1000, R3: 777, B1: 0000, B2: 4321, B3: 1234,
+	correct := []TeamMatch{
+		TeamMatch{
+			MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 1, TeamNumber: 9999,
+		},
+		TeamMatch{
+			MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 2, TeamNumber: 1000,
+		},
+		TeamMatch{
+			MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 3, TeamNumber: 777,
+		},
+		TeamMatch{
+			MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 1, TeamNumber: 0000,
+		},
+		TeamMatch{
+			MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 2, TeamNumber: 4321,
+		},
+		TeamMatch{
+			MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 3, TeamNumber: 1234,
 		},
 	}
 
-	err := fixture.db.AddToMatch(correct[0])
-	check(t, err, "Failed to add match data")
+	for _, match := range correct {
+		err := fixture.db.AddToMatch(match)
+		check(t, err, "Failed to add match data")
+	}
 
 	got, err := fixture.db.ReturnMatches()
 	check(t, err, "Failed ReturnMatches()")
@@ -179,15 +199,28 @@
 			Comment: "final comment", CollectedBy: "beth",
 		},
 	}
+	matches := []TeamMatch{
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 1, TeamNumber: 1236},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 2, TeamNumber: 1001},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 3, TeamNumber: 777},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 1, TeamNumber: 1000},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 2, TeamNumber: 4321},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 3, TeamNumber: 1234},
+	}
 
-	err := fixture.db.AddToMatch(Match{
-		MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
-		R1: 1236, R2: 1001, R3: 777, B1: 1000, B2: 4321, B3: 1234,
-	})
-	check(t, err, "Failed to add match")
+	for _, match := range matches {
+		err := fixture.db.AddToMatch(match)
+		check(t, err, "Failed to add match")
+	}
 
 	for i := 0; i < len(correct); i++ {
-		err = fixture.db.AddToStats(correct[i])
+		err := fixture.db.AddToStats(correct[i])
 		check(t, err, "Failed to add stats to DB")
 	}
 
@@ -214,14 +247,28 @@
 		Comment: "this is a comment", CollectedBy: "josh",
 	}
 
-	err := fixture.db.AddToMatch(Match{
-		MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
-		R1: 1236, R2: 1001, R3: 777, B1: 1000, B2: 4321, B3: 1234,
-	})
-	check(t, err, "Failed to add match")
+	matches := []TeamMatch{
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 1, TeamNumber: 1236},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 2, TeamNumber: 1001},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 3, TeamNumber: 777},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 1, TeamNumber: 1000},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 2, TeamNumber: 4321},
+		TeamMatch{MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 3, TeamNumber: 1234},
+	}
+
+	for _, match := range matches {
+		err := fixture.db.AddToMatch(match)
+		check(t, err, "Failed to add match")
+	}
 
 	// Add stats. This should succeed.
-	err = fixture.db.AddToStats(stats)
+	err := fixture.db.AddToStats(stats)
 	check(t, err, "Failed to add stats to DB")
 
 	// Try again. It should fail this time.
@@ -323,13 +370,28 @@
 		},
 	}
 
-	err := fixture.db.AddToMatch(Match{
-		MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
-		R1: 1235, R2: 1234, R3: 1233, B1: 1232, B2: 1231, B3: 1239})
-	check(t, err, "Failed to add match")
+	matches := []TeamMatch{
+		TeamMatch{MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 1, TeamNumber: 1235},
+		TeamMatch{MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 2, TeamNumber: 1234},
+		TeamMatch{MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 3, TeamNumber: 1233},
+		TeamMatch{MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 1, TeamNumber: 1232},
+		TeamMatch{MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 2, TeamNumber: 1231},
+		TeamMatch{MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 3, TeamNumber: 1239},
+	}
+
+	for _, match := range matches {
+		err := fixture.db.AddToMatch(match)
+		check(t, err, "Failed to add match")
+	}
 
 	for i := 0; i < len(testDatabase); i++ {
-		err = fixture.db.AddToStats(testDatabase[i])
+		err := fixture.db.AddToStats(testDatabase[i])
 		check(t, err, fmt.Sprint("Failed to add stats ", i))
 	}
 
@@ -404,27 +466,17 @@
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
 
-	correct := []Match{
-		Match{
-			MatchNumber: 2, SetNumber: 1, CompLevel: "quals",
-			R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149,
-		},
-		Match{
-			MatchNumber: 3, SetNumber: 1, CompLevel: "quals",
-			R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262,
-		},
-		Match{
-			MatchNumber: 4, SetNumber: 1, CompLevel: "quals",
-			R1: 251, R2: 169, R3: 286, B1: 653, B2: 538, B3: 149,
-		},
-		Match{
-			MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
-			R1: 198, R2: 1421, R3: 538, B1: 26, B2: 448, B3: 262,
-		},
-		Match{
-			MatchNumber: 6, SetNumber: 1, CompLevel: "quals",
-			R1: 251, R2: 188, R3: 286, B1: 555, B2: 538, B3: 149,
-		},
+	correct := []TeamMatch{
+		TeamMatch{
+			MatchNumber: 8, SetNumber: 1, CompLevel: "quals", Alliance: "R", AlliancePosition: 1, TeamNumber: 6835},
+		TeamMatch{
+			MatchNumber: 8, SetNumber: 1, CompLevel: "quals", Alliance: "R", AlliancePosition: 2, TeamNumber: 4834},
+		TeamMatch{
+			MatchNumber: 9, SetNumber: 1, CompLevel: "quals", Alliance: "B", AlliancePosition: 3, TeamNumber: 9824},
+		TeamMatch{
+			MatchNumber: 7, SetNumber: 2, CompLevel: "quals", Alliance: "B", AlliancePosition: 1, TeamNumber: 3732},
+		TeamMatch{
+			MatchNumber: 8, SetNumber: 1, CompLevel: "quals", Alliance: "B", AlliancePosition: 1, TeamNumber: 3732},
 	}
 
 	for i := 0; i < len(correct); i++ {
@@ -444,19 +496,13 @@
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
 
-	testDatabase := []Match{
-		Match{
-			MatchNumber: 1, SetNumber: 1, CompLevel: "quals",
-			R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149,
-		},
-		Match{
-			MatchNumber: 2, SetNumber: 1, CompLevel: "quals",
-			R1: 198, R2: 135, R3: 777, B1: 999, B2: 434, B3: 698,
-		},
-		Match{
-			MatchNumber: 1, SetNumber: 1, CompLevel: "quals",
-			R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262,
-		},
+	testDatabase := []TeamMatch{
+		TeamMatch{
+			MatchNumber: 9, SetNumber: 1, CompLevel: "quals", Alliance: "B", AlliancePosition: 3, TeamNumber: 4464},
+		TeamMatch{
+			MatchNumber: 8, SetNumber: 1, CompLevel: "quals", Alliance: "R", AlliancePosition: 2, TeamNumber: 2352},
+		TeamMatch{
+			MatchNumber: 9, SetNumber: 1, CompLevel: "quals", Alliance: "B", AlliancePosition: 3, TeamNumber: 6321},
 	}
 
 	for i := 0; i < len(testDatabase); i++ {
@@ -464,15 +510,11 @@
 		check(t, err, fmt.Sprint("Failed to add match", i))
 	}
 
-	correct := []Match{
-		Match{
-			MatchNumber: 2, SetNumber: 1, CompLevel: "quals",
-			R1: 198, R2: 135, R3: 777, B1: 999, B2: 434, B3: 698,
-		},
-		Match{
-			MatchNumber: 1, SetNumber: 1, CompLevel: "quals",
-			R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262,
-		},
+	correct := []TeamMatch{
+		TeamMatch{
+			MatchNumber: 8, SetNumber: 1, CompLevel: "quals", Alliance: "R", AlliancePosition: 2, TeamNumber: 2352},
+		TeamMatch{
+			MatchNumber: 9, SetNumber: 1, CompLevel: "quals", Alliance: "B", AlliancePosition: 3, TeamNumber: 6321},
 	}
 
 	got, err := fixture.db.ReturnMatches()
@@ -605,13 +647,28 @@
 		},
 	}
 
-	err := fixture.db.AddToMatch(Match{
-		MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
-		R1: 1235, R2: 1236, R3: 1237, B1: 1238, B2: 1239, B3: 1233})
-	check(t, err, "Failed to add match")
+	matches := []TeamMatch{
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 1, TeamNumber: 1235},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 2, TeamNumber: 1236},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 3, TeamNumber: 1237},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 1, TeamNumber: 1238},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 2, TeamNumber: 1239},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 3, TeamNumber: 1233},
+	}
+
+	for _, match := range matches {
+		err := fixture.db.AddToMatch(match)
+		check(t, err, "Failed to add match")
+	}
 
 	for i := 0; i < len(correct); i++ {
-		err = fixture.db.AddToStats(correct[i])
+		err := fixture.db.AddToStats(correct[i])
 		check(t, err, fmt.Sprint("Failed to add stats ", i))
 	}
 
@@ -653,13 +710,28 @@
 		},
 	}
 
-	err := fixture.db.AddToMatch(Match{
-		MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
-		R1: 1235, R2: 1236, R3: 1237, B1: 1238, B2: 1239, B3: 1233})
-	check(t, err, "Failed to add match")
+	matches := []TeamMatch{
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 1, TeamNumber: 1235},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 2, TeamNumber: 1236},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 3, TeamNumber: 1237},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 1, TeamNumber: 1238},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 2, TeamNumber: 1239},
+		TeamMatch{MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 3, TeamNumber: 1233},
+	}
+
+	for _, match := range matches {
+		err := fixture.db.AddToMatch(match)
+		check(t, err, "Failed to add match")
+	}
 
 	for i := 0; i < len(correct); i++ {
-		err = fixture.db.AddAction(correct[i])
+		err := fixture.db.AddAction(correct[i])
 		check(t, err, fmt.Sprint("Failed to add to actions ", i))
 	}
 
diff --git a/scouting/scouting.sh b/scouting/scouting.sh
index 669cf22..30e2989 100755
--- a/scouting/scouting.sh
+++ b/scouting/scouting.sh
@@ -2,7 +2,20 @@
 
 # This script runs the webserver and asks it to host all the web pages.
 
+# --- begin runfiles.bash initialization v2 ---
+# Copy-pasted from the Bazel Bash runfiles library v2.
+set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$0.runfiles/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
+# --- end runfiles.bash initialization v2 ---
+
+runfiles_export_envvars
+
 exec \
-    scouting/webserver/webserver_/webserver \
-    -directory scouting/www/ \
+    "${RUNFILES_DIR}"/org_frc971/scouting/webserver/webserver_/webserver \
+    -directory "${RUNFILES_DIR}"/org_frc971/scouting/www/ \
     "$@"
diff --git a/scouting/scouting_test.ts b/scouting/scouting_test.ts
index 8623fa1..cbeffc1 100644
--- a/scouting/scouting_test.ts
+++ b/scouting/scouting_test.ts
@@ -327,7 +327,7 @@
     // Navigate to Notes Page.
     await loadPage();
     await element(by.cssContainingText('.nav-link', 'Notes')).click();
-    expect(await element(by.id('page-title')).getText()).toEqual('Notes');
+    expect(await getHeadingText()).toEqual('Notes');
 
     // Add first team.
     await setTextboxByIdTo('team_number_notes', '1234');
@@ -336,7 +336,7 @@
     // Add note and select keyword for first team.
     expect(await element(by.id('team-key-1')).getText()).toEqual('1234');
     await element(by.id('text-input-1')).sendKeys('Good Driving');
-    await element(by.id('Good Driving_0')).click();
+    await element(by.id('good_driving_0')).click();
 
     // Navigate to add team selection and add another team.
     await element(by.id('add-team-button')).click();
@@ -346,7 +346,7 @@
     // Add note and select keyword for second team.
     expect(await element(by.id('team-key-2')).getText()).toEqual('1235');
     await element(by.id('text-input-2')).sendKeys('Bad Driving');
-    await element(by.id('Bad Driving_1')).click();
+    await element(by.id('bad_driving_1')).click();
 
     // Submit Notes.
     await element(by.buttonText('Submit')).click();
@@ -359,7 +359,7 @@
     // Navigate to Notes Page.
     await loadPage();
     await element(by.cssContainingText('.nav-link', 'Notes')).click();
-    expect(await element(by.id('page-title')).getText()).toEqual('Notes');
+    expect(await getHeadingText()).toEqual('Notes');
 
     // Add first team.
     await setTextboxByIdTo('team_number_notes', '1234');
@@ -395,9 +395,7 @@
     // Navigate to Driver Ranking Page.
     await loadPage();
     await element(by.cssContainingText('.nav-link', 'Driver Ranking')).click();
-    expect(await element(by.id('page-title')).getText()).toEqual(
-      'Driver Ranking'
-    );
+    expect(await getHeadingText()).toEqual('Driver Ranking');
 
     // Input match and team numbers.
     await setTextboxByIdTo('match_number_selection', '11');
diff --git a/scouting/testing/BUILD b/scouting/testing/BUILD
index 7e26763..07c02f2 100644
--- a/scouting/testing/BUILD
+++ b/scouting/testing/BUILD
@@ -10,4 +10,7 @@
         "//scouting/scraping:test_data",
     ],
     visibility = ["//visibility:public"],
+    deps = [
+        "@rules_python//python/runfiles",
+    ],
 )
diff --git a/scouting/testing/scouting_test_servers.py b/scouting/testing/scouting_test_servers.py
index 9df23c1..d1e4e32 100644
--- a/scouting/testing/scouting_test_servers.py
+++ b/scouting/testing/scouting_test_servers.py
@@ -18,6 +18,10 @@
 import time
 from typing import List
 
+from rules_python.python.runfiles import runfiles
+
+RUNFILES = runfiles.Create()
+
 
 def wait_for_server(port: int):
     """Waits for the server at the specified port to respond to TCP connections."""
@@ -59,15 +63,22 @@
     tba_api_dir = tmpdir / "api" / "v3" / "event" / f"{year}{event_code}"
     tba_api_dir.mkdir(parents=True, exist_ok=True)
     (tba_api_dir / "matches").write_text(
-        Path(f"scouting/scraping/test_data/{year}_{event_code}.json").
-        read_text())
+        Path(
+            RUNFILES.Rlocation(
+                f"org_frc971/scouting/scraping/test_data/{year}_{event_code}.json"
+            )).read_text())
 
 
 class Runner:
     """Helps manage the services we need for testing the scouting app."""
 
-    def start(self, port: int):
-        """Starts the services needed for testing the scouting app."""
+    def start(self, port: int, notify_fd: int = 0):
+        """Starts the services needed for testing the scouting app.
+
+        if notify_fd is set to a non-zero value, the string "READY" is written
+        to that file descriptor once everything is set up.
+        """
+
         self.tmpdir = Path(os.environ["TEST_TMPDIR"]) / "servers"
         self.tmpdir.mkdir(exist_ok=True)
 
@@ -76,12 +87,15 @@
 
         # The database needs to be running and addressable before the scouting
         # webserver can start.
-        self.testdb_server = subprocess.Popen(
-            ["scouting/db/testdb_server/testdb_server_/testdb_server"])
+        self.testdb_server = subprocess.Popen([
+            RUNFILES.Rlocation(
+                "org_frc971/scouting/db/testdb_server/testdb_server_/testdb_server"
+            )
+        ])
         wait_for_server(5432)
 
         self.webserver = subprocess.Popen([
-            "scouting/scouting",
+            RUNFILES.Rlocation("org_frc971/scouting/scouting"),
             f"--port={port}",
             f"--db_config={db_config}",
             f"--tba_config={tba_config}",
@@ -99,6 +113,10 @@
         wait_for_server(7000)
         wait_for_server(port)
 
+        if notify_fd:
+            with os.fdopen(notify_fd, "w") as file:
+                file.write("READY")
+
     def stop(self):
         """Stops the services needed for testing the scouting app."""
         servers = (self.webserver, self.testdb_server, self.fake_tba_api)
@@ -127,10 +145,17 @@
     parser.add_argument("--port",
                         type=int,
                         help="The port for the actual web server.")
+    parser.add_argument(
+        "--notify_fd",
+        type=int,
+        default=0,
+        help=("If non-zero, indicates a file descriptor to which 'READY' is "
+              "written when everything has started up."),
+    )
     args = parser.parse_args(argv[1:])
 
     runner = Runner()
-    runner.start(args.port)
+    runner.start(args.port, args.notify_fd)
 
     # Wait until we're asked to shut down via CTRL-C or SIGTERM.
     signal.signal(signal.SIGINT, discard_signal)
diff --git a/scouting/webserver/requests/messages/submit_actions.fbs b/scouting/webserver/requests/messages/submit_actions.fbs
index 9d9efa4..863e2fc 100644
--- a/scouting/webserver/requests/messages/submit_actions.fbs
+++ b/scouting/webserver/requests/messages/submit_actions.fbs
@@ -31,6 +31,8 @@
 }
 
 table EndMatchAction {
+    docked:bool (id:0);
+    engaged:bool (id:1);
 }
 
 union ActionType {
diff --git a/scouting/webserver/requests/requests.go b/scouting/webserver/requests/requests.go
index 99b5459..d56f380 100644
--- a/scouting/webserver/requests/requests.go
+++ b/scouting/webserver/requests/requests.go
@@ -7,6 +7,7 @@
 	"io"
 	"log"
 	"net/http"
+	"sort"
 	"strconv"
 	"strings"
 
@@ -69,10 +70,10 @@
 // The interface we expect the database abstraction to conform to.
 // We use an interface here because it makes unit testing easier.
 type Database interface {
-	AddToMatch(db.Match) error
+	AddToMatch(db.TeamMatch) error
 	AddToShift(db.Shift) error
 	AddToStats(db.Stats) error
-	ReturnMatches() ([]db.Match, error)
+	ReturnMatches() ([]db.TeamMatch, error)
 	ReturnAllNotes() ([]db.NotesData, error)
 	ReturnAllDriverRankings() ([]db.DriverRankingData, error)
 	ReturnAllShifts() ([]db.Shift, error)
@@ -212,6 +213,15 @@
 	db Database
 }
 
+func findIndexInList(list []string, comp_level string) (int, error) {
+	for index, value := range list {
+		if value == comp_level {
+			return index, nil
+		}
+	}
+	return -1, errors.New(fmt.Sprint("Failed to find comp level ", comp_level, " in list ", list))
+}
+
 func (handler requestAllMatchesHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 	requestBytes, err := io.ReadAll(req.Body)
 	if err != nil {
@@ -230,19 +240,121 @@
 		return
 	}
 
-	var response RequestAllMatchesResponseT
+	// Change structure of match objects in the database(1 per team) to
+	// the old match structure(1 per match) that the webserver uses.
+	type Key struct {
+		MatchNumber int32
+		SetNumber   int32
+		CompLevel   string
+	}
+
+	assembledMatches := map[Key]request_all_matches_response.MatchT{}
+
 	for _, match := range matches {
-		response.MatchList = append(response.MatchList, &request_all_matches_response.MatchT{
-			MatchNumber: match.MatchNumber,
-			SetNumber:   match.SetNumber,
-			CompLevel:   match.CompLevel,
-			R1:          match.R1,
-			R2:          match.R2,
-			R3:          match.R3,
-			B1:          match.B1,
-			B2:          match.B2,
-			B3:          match.B3,
-		})
+		key := Key{match.MatchNumber, match.SetNumber, match.CompLevel}
+		entry, ok := assembledMatches[key]
+		if !ok {
+			entry = request_all_matches_response.MatchT{
+				MatchNumber: match.MatchNumber,
+				SetNumber:   match.SetNumber,
+				CompLevel:   match.CompLevel,
+			}
+		}
+		switch match.Alliance {
+		case "R":
+			switch match.AlliancePosition {
+			case 1:
+				entry.R1 = match.TeamNumber
+			case 2:
+				entry.R2 = match.TeamNumber
+			case 3:
+				entry.R3 = match.TeamNumber
+			default:
+				respondWithError(w, http.StatusInternalServerError, fmt.Sprint("Unknown red position ", strconv.Itoa(int(match.AlliancePosition)), " in match ", strconv.Itoa(int(match.MatchNumber))))
+				return
+			}
+		case "B":
+			switch match.AlliancePosition {
+			case 1:
+				entry.B1 = match.TeamNumber
+			case 2:
+				entry.B2 = match.TeamNumber
+			case 3:
+				entry.B3 = match.TeamNumber
+			default:
+				respondWithError(w, http.StatusInternalServerError, fmt.Sprint("Unknown blue position ", strconv.Itoa(int(match.AlliancePosition)), " in match ", strconv.Itoa(int(match.MatchNumber))))
+				return
+			}
+		default:
+			respondWithError(w, http.StatusInternalServerError, fmt.Sprint("Unknown alliance ", match.Alliance, " in match ", strconv.Itoa(int(match.AlliancePosition))))
+			return
+		}
+		assembledMatches[key] = entry
+	}
+
+	var response RequestAllMatchesResponseT
+	for _, match := range assembledMatches {
+		copied_match := match
+		response.MatchList = append(response.MatchList, &copied_match)
+	}
+
+	var MATCH_TYPE_ORDERING = []string{"qm", "ef", "qf", "sf", "f"}
+
+	err = nil
+	sort.Slice(response.MatchList, func(i, j int) bool {
+		if err != nil {
+			return false
+		}
+		a := response.MatchList[i]
+		b := response.MatchList[j]
+
+		aMatchTypeIndex, err2 := findIndexInList(MATCH_TYPE_ORDERING, a.CompLevel)
+		if err2 != nil {
+			err = errors.New(fmt.Sprint("Comp level ", a.CompLevel, " not found in sorting list ", MATCH_TYPE_ORDERING, " : ", err2))
+			return false
+		}
+		bMatchTypeIndex, err2 := findIndexInList(MATCH_TYPE_ORDERING, b.CompLevel)
+		if err2 != nil {
+			err = errors.New(fmt.Sprint("Comp level ", b.CompLevel, " not found in sorting list ", MATCH_TYPE_ORDERING, " : ", err2))
+			return false
+		}
+
+		if aMatchTypeIndex < bMatchTypeIndex {
+			return true
+		}
+		if aMatchTypeIndex > bMatchTypeIndex {
+			return false
+		}
+
+		// Then sort by match number. E.g. in semi finals, all match 1 rounds
+		// are done first. Then come match 2 rounds. And then, if necessary,
+		// the match 3 rounds.
+		aMatchNumber := a.MatchNumber
+		bMatchNumber := b.MatchNumber
+		if aMatchNumber < bMatchNumber {
+			return true
+		}
+		if aMatchNumber > bMatchNumber {
+			return false
+		}
+		// Lastly, sort by set number. I.e. Semi Final 1 Match 1 happens first.
+		// Then comes Semi Final 2 Match 1. Then comes Semi Final 1 Match 2. Then
+		// Semi Final 2 Match 2.
+		aSetNumber := a.SetNumber
+		bSetNumber := b.SetNumber
+		if aSetNumber < bSetNumber {
+			return true
+		}
+		if aSetNumber > bSetNumber {
+			return false
+		}
+		return true
+	})
+
+	if err != nil {
+		// check if error happened during sorting and notify webpage if that
+		respondWithError(w, http.StatusInternalServerError, fmt.Sprint(err))
+		return
 	}
 
 	builder := flatbuffers.NewBuilder(50 * 1024)
@@ -400,22 +512,48 @@
 				"TheBlueAlliance data for match %d is malformed: %v", match.MatchNumber, err))
 			return
 		}
-		// Add the match to the database.
-		err = handler.db.AddToMatch(db.Match{
-			MatchNumber: int32(match.MatchNumber),
-			SetNumber:   int32(match.SetNumber),
-			CompLevel:   match.CompLevel,
-			R1:          red[0],
-			R2:          red[1],
-			R3:          red[2],
-			B1:          blue[0],
-			B2:          blue[1],
-			B3:          blue[2],
-		})
-		if err != nil {
-			respondWithError(w, http.StatusInternalServerError, fmt.Sprintf(
-				"Failed to add match %d to the database: %v", match.MatchNumber, err))
-			return
+
+		team_matches := []db.TeamMatch{
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "R", AlliancePosition: 1, TeamNumber: red[0],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "R", AlliancePosition: 2, TeamNumber: red[1],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "R", AlliancePosition: 3, TeamNumber: red[2],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "B", AlliancePosition: 1, TeamNumber: blue[0],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "B", AlliancePosition: 2, TeamNumber: blue[1],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "B", AlliancePosition: 3, TeamNumber: blue[2],
+			},
+		}
+
+		for _, match := range team_matches {
+			// Iterate through matches to check they can be added to database.
+			err = handler.db.AddToMatch(match)
+			if err != nil {
+				respondWithError(w, http.StatusInternalServerError, fmt.Sprintf(
+					"Failed to add team %d from match %d to the database: %v", match.TeamNumber, match.MatchNumber, err))
+				return
+			}
 		}
 	}
 
diff --git a/scouting/webserver/requests/requests_test.go b/scouting/webserver/requests/requests_test.go
index 9775eae..27a45e6 100644
--- a/scouting/webserver/requests/requests_test.go
+++ b/scouting/webserver/requests/requests_test.go
@@ -125,18 +125,78 @@
 // Validates that we can request the full match list.
 func TestRequestAllMatches(t *testing.T) {
 	db := MockDatabase{
-		matches: []db.Match{
+		matches: []db.TeamMatch{
 			{
-				MatchNumber: 1, SetNumber: 1, CompLevel: "qual",
-				R1: 5, R2: 42, R3: 600, B1: 971, B2: 400, B3: 200,
+				MatchNumber: 1, SetNumber: 1, CompLevel: "qm",
+				Alliance: "R", AlliancePosition: 1, TeamNumber: 5,
 			},
 			{
-				MatchNumber: 2, SetNumber: 1, CompLevel: "qual",
-				R1: 6, R2: 43, R3: 601, B1: 972, B2: 401, B3: 201,
+				MatchNumber: 1, SetNumber: 1, CompLevel: "qm",
+				Alliance: "R", AlliancePosition: 2, TeamNumber: 42,
 			},
 			{
-				MatchNumber: 3, SetNumber: 1, CompLevel: "qual",
-				R1: 7, R2: 44, R3: 602, B1: 973, B2: 402, B3: 202,
+				MatchNumber: 1, SetNumber: 1, CompLevel: "qm",
+				Alliance: "R", AlliancePosition: 3, TeamNumber: 600,
+			},
+			{
+				MatchNumber: 1, SetNumber: 1, CompLevel: "qm",
+				Alliance: "B", AlliancePosition: 1, TeamNumber: 971,
+			},
+			{
+				MatchNumber: 1, SetNumber: 1, CompLevel: "qm",
+				Alliance: "B", AlliancePosition: 2, TeamNumber: 400,
+			},
+			{
+				MatchNumber: 1, SetNumber: 1, CompLevel: "qm",
+				Alliance: "B", AlliancePosition: 3, TeamNumber: 200,
+			},
+			{
+				MatchNumber: 2, SetNumber: 1, CompLevel: "qm",
+				Alliance: "R", AlliancePosition: 1, TeamNumber: 6,
+			},
+			{
+				MatchNumber: 2, SetNumber: 1, CompLevel: "qm",
+				Alliance: "R", AlliancePosition: 2, TeamNumber: 43,
+			},
+			{
+				MatchNumber: 2, SetNumber: 1, CompLevel: "qm",
+				Alliance: "R", AlliancePosition: 3, TeamNumber: 601,
+			},
+			{
+				MatchNumber: 2, SetNumber: 1, CompLevel: "qm",
+				Alliance: "B", AlliancePosition: 1, TeamNumber: 972,
+			},
+			{
+				MatchNumber: 2, SetNumber: 1, CompLevel: "qm",
+				Alliance: "B", AlliancePosition: 2, TeamNumber: 401,
+			},
+			{
+				MatchNumber: 2, SetNumber: 1, CompLevel: "qm",
+				Alliance: "B", AlliancePosition: 3, TeamNumber: 201,
+			},
+			{
+				MatchNumber: 3, SetNumber: 1, CompLevel: "qm",
+				Alliance: "R", AlliancePosition: 1, TeamNumber: 7,
+			},
+			{
+				MatchNumber: 3, SetNumber: 1, CompLevel: "qm",
+				Alliance: "R", AlliancePosition: 2, TeamNumber: 44,
+			},
+			{
+				MatchNumber: 3, SetNumber: 1, CompLevel: "qm",
+				Alliance: "R", AlliancePosition: 3, TeamNumber: 602,
+			},
+			{
+				MatchNumber: 3, SetNumber: 1, CompLevel: "qm",
+				Alliance: "B", AlliancePosition: 1, TeamNumber: 973,
+			},
+			{
+				MatchNumber: 3, SetNumber: 1, CompLevel: "qm",
+				Alliance: "B", AlliancePosition: 2, TeamNumber: 402,
+			},
+			{
+				MatchNumber: 3, SetNumber: 1, CompLevel: "qm",
+				Alliance: "B", AlliancePosition: 3, TeamNumber: 202,
 			},
 		},
 	}
@@ -158,15 +218,15 @@
 			// MatchNumber, SetNumber, CompLevel
 			// R1, R2, R3, B1, B2, B3
 			{
-				1, 1, "qual",
+				1, 1, "qm",
 				5, 42, 600, 971, 400, 200,
 			},
 			{
-				2, 1, "qual",
+				2, 1, "qm",
 				6, 43, 601, 972, 401, 201,
 			},
 			{
-				3, 1, "qual",
+				3, 1, "qm",
 				7, 44, 602, 973, 402, 202,
 			},
 		},
@@ -496,19 +556,33 @@
 	}
 
 	// Make sure that the data made it into the database.
-	expectedMatches := []db.Match{
+	expectedMatches := []db.TeamMatch{
 		{
-			MatchNumber: 1,
-			SetNumber:   2,
-			CompLevel:   "qual",
-			R1:          100,
-			R2:          200,
-			R3:          300,
-			B1:          101,
-			B2:          201,
-			B3:          301,
+			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
+			Alliance: "R", AlliancePosition: 1, TeamNumber: 100,
+		},
+		{
+			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
+			Alliance: "R", AlliancePosition: 2, TeamNumber: 200,
+		},
+		{
+			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
+			Alliance: "R", AlliancePosition: 3, TeamNumber: 300,
+		},
+		{
+			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
+			Alliance: "B", AlliancePosition: 1, TeamNumber: 101,
+		},
+		{
+			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
+			Alliance: "B", AlliancePosition: 2, TeamNumber: 201,
+		},
+		{
+			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
+			Alliance: "B", AlliancePosition: 3, TeamNumber: 301,
 		},
 	}
+
 	if !reflect.DeepEqual(expectedMatches, database.matches) {
 		t.Fatal("Expected ", expectedMatches, ", but got ", database.matches)
 	}
@@ -677,14 +751,14 @@
 // needed for your tests.
 
 type MockDatabase struct {
-	matches        []db.Match
+	matches        []db.TeamMatch
 	stats          []db.Stats
 	notes          []db.NotesData
 	shiftSchedule  []db.Shift
 	driver_ranking []db.DriverRankingData
 }
 
-func (database *MockDatabase) AddToMatch(match db.Match) error {
+func (database *MockDatabase) AddToMatch(match db.TeamMatch) error {
 	database.matches = append(database.matches, match)
 	return nil
 }
@@ -694,7 +768,7 @@
 	return nil
 }
 
-func (database *MockDatabase) ReturnMatches() ([]db.Match, error) {
+func (database *MockDatabase) ReturnMatches() ([]db.TeamMatch, error) {
 	return database.matches, nil
 }
 
diff --git a/scouting/www/counter_button/package.json b/scouting/www/counter_button/package.json
new file mode 100644
index 0000000..61eb83b
--- /dev/null
+++ b/scouting/www/counter_button/package.json
@@ -0,0 +1,4 @@
+{
+    "name": "@org_frc971/scouting/www/counter_button",
+    "private": true
+}
diff --git a/scouting/www/driver_ranking/driver_ranking.component.ts b/scouting/www/driver_ranking/driver_ranking.component.ts
index aadb3b0..b251938 100644
--- a/scouting/www/driver_ranking/driver_ranking.component.ts
+++ b/scouting/www/driver_ranking/driver_ranking.component.ts
@@ -1,7 +1,7 @@
 import {Component, OnInit} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {SubmitDriverRanking} from 'org_frc971/scouting/webserver/requests/messages/submit_driver_ranking_generated';
-import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {SubmitDriverRanking} from '../../webserver/requests/messages/submit_driver_ranking_generated';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
 
 // TeamSelection: Display form to input which
 // teams to rank and the match number.
diff --git a/scouting/www/driver_ranking/driver_ranking.ng.html b/scouting/www/driver_ranking/driver_ranking.ng.html
index 452359c..1fa1bc8 100644
--- a/scouting/www/driver_ranking/driver_ranking.ng.html
+++ b/scouting/www/driver_ranking/driver_ranking.ng.html
@@ -1,4 +1,6 @@
-<h2 id="page-title">Driver Ranking</h2>
+<div class="header">
+  <h2>Driver Ranking</h2>
+</div>
 
 <ng-container [ngSwitch]="section">
   <div *ngSwitchCase="'TeamSelection'">
diff --git a/scouting/www/driver_ranking/package.json b/scouting/www/driver_ranking/package.json
new file mode 100644
index 0000000..06472cf
--- /dev/null
+++ b/scouting/www/driver_ranking/package.json
@@ -0,0 +1,7 @@
+{
+    "name": "@org_frc971/scouting/www/driver_ranking",
+    "private": true,
+    "dependencies": {
+        "@angular/forms": "15.0.1"
+    }
+}
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 330ab47..01c9fff 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -9,12 +9,12 @@
 } from '@angular/core';
 import {FormsModule} from '@angular/forms';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
 import {
   ClimbLevel,
   SubmitDataScouting,
-} from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_generated';
-import {SubmitDataScoutingResponse} from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_response_generated';
+} from '../../webserver/requests/messages/submit_data_scouting_generated';
+import {SubmitDataScoutingResponse} from '../../webserver/requests/messages/submit_data_scouting_response_generated';
 
 type Section =
   | 'Team Selection'
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index 38e272e..3322c01 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -5,7 +5,7 @@
 import {CounterButtonModule} from '../counter_button/counter_button.module';
 import {EntryComponent} from './entry.component';
 
-import {ClimbLevel} from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_generated';
+import {ClimbLevel} from '../../webserver/requests/messages/submit_data_scouting_generated';
 
 @Pipe({name: 'levelToString'})
 export class LevelToStringPipe implements PipeTransform {
diff --git a/scouting/www/entry/package.json b/scouting/www/entry/package.json
new file mode 100644
index 0000000..5ecf57a
--- /dev/null
+++ b/scouting/www/entry/package.json
@@ -0,0 +1,8 @@
+{
+    "name": "@org_frc971/scouting/www/entry",
+    "private": true,
+    "dependencies": {
+        "@org_frc971/scouting/www/counter_button": "workspace:*",
+        "@angular/forms": "15.0.1"
+    }
+}
diff --git a/scouting/www/import_match_list/import_match_list.component.ts b/scouting/www/import_match_list/import_match_list.component.ts
index a3fe458..0e292ae 100644
--- a/scouting/www/import_match_list/import_match_list.component.ts
+++ b/scouting/www/import_match_list/import_match_list.component.ts
@@ -1,9 +1,9 @@
 import {Component, OnInit} from '@angular/core';
 
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
-import {RefreshMatchListResponse} from 'org_frc971/scouting/webserver/requests/messages/refresh_match_list_response_generated';
-import {RefreshMatchList} from 'org_frc971/scouting/webserver/requests/messages/refresh_match_list_generated';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {RefreshMatchListResponse} from '../../webserver/requests/messages/refresh_match_list_response_generated';
+import {RefreshMatchList} from '../../webserver/requests/messages/refresh_match_list_generated';
 
 @Component({
   selector: 'app-import-match-list',
diff --git a/scouting/www/import_match_list/package.json b/scouting/www/import_match_list/package.json
new file mode 100644
index 0000000..d80b0dc
--- /dev/null
+++ b/scouting/www/import_match_list/package.json
@@ -0,0 +1,7 @@
+{
+    "name": "@org_frc971/scouting/www/import_match_list",
+    "private": true,
+    "dependencies": {
+        "@angular/forms": "15.0.1"
+    }
+}
diff --git a/scouting/www/match_list/match_list.component.ts b/scouting/www/match_list/match_list.component.ts
index ab54e3e..c50cffd 100644
--- a/scouting/www/match_list/match_list.component.ts
+++ b/scouting/www/match_list/match_list.component.ts
@@ -1,11 +1,11 @@
 import {Component, EventEmitter, OnInit, Output} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
-import {RequestAllMatches} from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_generated';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {RequestAllMatches} from '../../webserver/requests/messages/request_all_matches_generated';
 import {
   Match,
   RequestAllMatchesResponse,
-} from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
+} from '../../webserver/requests/messages/request_all_matches_response_generated';
 
 import {MatchListRequestor} from '../rpc/match_list_requestor';
 
diff --git a/scouting/www/match_list/package.json b/scouting/www/match_list/package.json
new file mode 100644
index 0000000..3a02094
--- /dev/null
+++ b/scouting/www/match_list/package.json
@@ -0,0 +1,8 @@
+{
+    "name": "@org_frc971/scouting/www/match_list",
+    "private": true,
+    "dependencies": {
+        "@org_frc971/scouting/www/rpc": "workspace:*",
+        "@angular/forms": "15.0.1"
+    }
+}
diff --git a/scouting/www/notes/notes.component.ts b/scouting/www/notes/notes.component.ts
index f503e2d..177ad44 100644
--- a/scouting/www/notes/notes.component.ts
+++ b/scouting/www/notes/notes.component.ts
@@ -1,13 +1,13 @@
 import {Component, HostListener} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
-import {RequestNotesForTeam} from 'org_frc971/scouting/webserver/requests/messages/request_notes_for_team_generated';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {RequestNotesForTeam} from '../../webserver/requests/messages/request_notes_for_team_generated';
 import {
   Note as NoteFb,
   RequestNotesForTeamResponse,
-} from 'org_frc971/scouting/webserver/requests/messages/request_notes_for_team_response_generated';
-import {SubmitNotes} from 'org_frc971/scouting/webserver/requests/messages/submit_notes_generated';
-import {SubmitNotesResponse} from 'org_frc971/scouting/webserver/requests/messages/submit_notes_response_generated';
+} from '../../webserver/requests/messages/request_notes_for_team_response_generated';
+import {SubmitNotes} from '../../webserver/requests/messages/submit_notes_generated';
+import {SubmitNotesResponse} from '../../webserver/requests/messages/submit_notes_response_generated';
 
 /*
 For new games, the keywords being used will likely need to be updated.
@@ -175,4 +175,8 @@
     this.errorMessage = '';
     this.section = 'TeamSelection';
   }
+
+  labelToId(label: String): String {
+    return label.replaceAll(' ', '_').toLowerCase();
+  }
 }
diff --git a/scouting/www/notes/notes.ng.html b/scouting/www/notes/notes.ng.html
index b51a7ce..cb5e8ef 100644
--- a/scouting/www/notes/notes.ng.html
+++ b/scouting/www/notes/notes.ng.html
@@ -1,4 +1,6 @@
-<h2 id="page-title">Notes</h2>
+<div class="header">
+  <h2>Notes</h2>
+</div>
 
 <ng-container [ngSwitch]="section">
   <div *ngSwitchCase="'TeamSelection'">
@@ -58,7 +60,7 @@
                 class="form-check-input"
                 [(ngModel)]="newData[i]['keywordsData'][key]"
                 type="checkbox"
-                id="{{KEYWORD_CHECKBOX_LABELS[key]}}_{{i}}"
+                id="{{labelToId(KEYWORD_CHECKBOX_LABELS[key])}}_{{i}}"
                 name="{{KEYWORD_CHECKBOX_LABELS[key]}}"
               />
               <label
@@ -81,7 +83,7 @@
                 class="form-check-input"
                 [(ngModel)]="newData[i]['keywordsData'][key]"
                 type="checkbox"
-                id="{{KEYWORD_CHECKBOX_LABELS[key]}}"
+                id="{{labelToId(KEYWORD_CHECKBOX_LABELS[key])}}"
                 name="{{KEYWORD_CHECKBOX_LABELS[key]}}"
               />
               <label
diff --git a/scouting/www/notes/package.json b/scouting/www/notes/package.json
new file mode 100644
index 0000000..8cbeb94
--- /dev/null
+++ b/scouting/www/notes/package.json
@@ -0,0 +1,7 @@
+{
+    "name": "@org_frc971/scouting/www/notes",
+    "private": true,
+    "dependencies": {
+        "@angular/forms": "15.0.1"
+    }
+}
diff --git a/scouting/www/package.json b/scouting/www/package.json
new file mode 100644
index 0000000..3ab0035
--- /dev/null
+++ b/scouting/www/package.json
@@ -0,0 +1,4 @@
+{
+    "private": true,
+    "dependencies": {}
+}
diff --git a/scouting/www/rpc/match_list_requestor.ts b/scouting/www/rpc/match_list_requestor.ts
index f97b1ca..fa2dcbd 100644
--- a/scouting/www/rpc/match_list_requestor.ts
+++ b/scouting/www/rpc/match_list_requestor.ts
@@ -1,11 +1,11 @@
 import {Injectable} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
-import {RequestAllMatches} from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_generated';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {RequestAllMatches} from '../../webserver/requests/messages/request_all_matches_generated';
 import {
   Match,
   RequestAllMatchesResponse,
-} from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
+} from '../../webserver/requests/messages/request_all_matches_response_generated';
 
 const MATCH_TYPE_ORDERING = ['qm', 'ef', 'qf', 'sf', 'f'];
 
diff --git a/scouting/www/rpc/package.json b/scouting/www/rpc/package.json
new file mode 100644
index 0000000..6d1369a
--- /dev/null
+++ b/scouting/www/rpc/package.json
@@ -0,0 +1,4 @@
+{
+    "name": "@org_frc971/scouting/www/rpc",
+    "private": true
+}
diff --git a/scouting/www/rpc/view_data_requestor.ts b/scouting/www/rpc/view_data_requestor.ts
index 13fa788..d41bf43 100644
--- a/scouting/www/rpc/view_data_requestor.ts
+++ b/scouting/www/rpc/view_data_requestor.ts
@@ -1,21 +1,21 @@
 import {Injectable} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
-import {RequestAllNotes} from 'org_frc971/scouting/webserver/requests/messages/request_all_notes_generated';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {RequestAllNotes} from '../../webserver/requests/messages/request_all_notes_generated';
 import {
   Note,
   RequestAllNotesResponse,
-} from 'org_frc971/scouting/webserver/requests/messages/request_all_notes_response_generated';
-import {RequestAllDriverRankings} from 'org_frc971/scouting/webserver/requests/messages/request_all_driver_rankings_generated';
+} from '../../webserver/requests/messages/request_all_notes_response_generated';
+import {RequestAllDriverRankings} from '../../webserver/requests/messages/request_all_driver_rankings_generated';
 import {
   Ranking,
   RequestAllDriverRankingsResponse,
-} from 'org_frc971/scouting/webserver/requests/messages/request_all_driver_rankings_response_generated';
-import {RequestDataScouting} from 'org_frc971/scouting/webserver/requests/messages/request_data_scouting_generated';
+} from '../../webserver/requests/messages/request_all_driver_rankings_response_generated';
+import {RequestDataScouting} from '../../webserver/requests/messages/request_data_scouting_generated';
 import {
   Stats,
   RequestDataScoutingResponse,
-} from 'org_frc971/scouting/webserver/requests/messages/request_data_scouting_response_generated';
+} from '../../webserver/requests/messages/request_data_scouting_response_generated';
 
 @Injectable({providedIn: 'root'})
 export class ViewDataRequestor {
diff --git a/scouting/www/shift_schedule/package.json b/scouting/www/shift_schedule/package.json
new file mode 100644
index 0000000..1d71623
--- /dev/null
+++ b/scouting/www/shift_schedule/package.json
@@ -0,0 +1,7 @@
+{
+    "name": "@org_frc971/scouting/www/shift_schedule",
+    "private": true,
+    "dependencies": {
+        "@angular/forms": "15.0.1"
+    }
+}
diff --git a/scouting/www/shift_schedule/shift_schedule.component.ts b/scouting/www/shift_schedule/shift_schedule.component.ts
index 2f22653..074eb97 100644
--- a/scouting/www/shift_schedule/shift_schedule.component.ts
+++ b/scouting/www/shift_schedule/shift_schedule.component.ts
@@ -1,6 +1,6 @@
 import {Component, OnInit} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
 
 @Component({
   selector: 'shift-schedule',
diff --git a/scouting/www/view/view.component.ts b/scouting/www/view/view.component.ts
index 30f5914..593b0f4 100644
--- a/scouting/www/view/view.component.ts
+++ b/scouting/www/view/view.component.ts
@@ -2,15 +2,15 @@
 import {
   Ranking,
   RequestAllDriverRankingsResponse,
-} from 'org_frc971/scouting/webserver/requests/messages/request_all_driver_rankings_response_generated';
+} from '../../webserver/requests/messages/request_all_driver_rankings_response_generated';
 import {
   Stats,
   RequestDataScoutingResponse,
-} from 'org_frc971/scouting/webserver/requests/messages/request_data_scouting_response_generated';
+} from '../../webserver/requests/messages/request_data_scouting_response_generated';
 import {
   Note,
   RequestAllNotesResponse,
-} from 'org_frc971/scouting/webserver/requests/messages/request_all_notes_response_generated';
+} from '../../webserver/requests/messages/request_all_notes_response_generated';
 
 import {ViewDataRequestor} from '../rpc/view_data_requestor';
 
diff --git a/third_party/xvfb/xvfb.BUILD b/third_party/xvfb/xvfb.BUILD
new file mode 100644
index 0000000..91332ff
--- /dev/null
+++ b/third_party/xvfb/xvfb.BUILD
@@ -0,0 +1,43 @@
+load("@bazel_skylib//rules:write_file.bzl", "write_file")
+
+write_file(
+    name = "generate_wrapper",
+    out = "wrapped_bin/Xvfb.sh",
+    content = ["""\
+#!/bin/bash
+
+# --- begin runfiles.bash initialization v2 ---
+# Copy-pasted from the Bazel Bash runfiles library v2.
+set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$0.runfiles/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
+# --- end runfiles.bash initialization v2 ---
+
+
+runfiles_export_envvars
+
+export LD_LIBRARY_PATH="${RUNFILES_DIR}/xvfb_amd64/usr/lib/x86_64-linux-gnu"
+export LD_LIBRARY_PATH+=":${RUNFILES_DIR}/xvfb_amd64/lib/x86_64-linux-gnu"
+
+exec "${RUNFILES_DIR}/xvfb_amd64/usr/bin/Xvfb" "$@"
+"""],
+    is_executable = True,
+)
+
+sh_binary(
+    name = "wrapped_bin/Xvfb",
+    srcs = ["wrapped_bin/Xvfb.sh"],
+    deps = [
+        "@bazel_tools//tools/bash/runfiles",
+    ],
+    data = glob([
+        "usr/lib/**/*",
+        "lib/**/*",
+        "usr/bin/*",
+    ]),
+    visibility = ["//visibility:public"],
+)
diff --git a/tools/build_rules/js.bzl b/tools/build_rules/js.bzl
index 5510266..d334764 100644
--- a/tools/build_rules/js.bzl
+++ b/tools/build_rules/js.bzl
@@ -3,7 +3,15 @@
 load("@npm//@bazel/terser:index.bzl", "terser_minified")
 load("@bazel_skylib//lib:paths.bzl", "paths")
 load("@npm//@bazel/protractor:index.bzl", "protractor_web_test_suite")
-load("@npm//@bazel/typescript:index.bzl", "ts_project")
+load("@npm//@bazel/typescript:index.bzl", upstream_ts_library = "ts_library", upstream_ts_project = "ts_project")
+
+def ts_project(**kwargs):
+    """A trivial wrapper to prepare for the rules_js migration.
+
+    The intent is to change his macro to wrap the new rules_js ts_project
+    implementation.
+    """
+    upstream_ts_library(**kwargs)
 
 def rollup_bundle(name, deps, visibility = None, **kwargs):
     """Calls the upstream rollup_bundle() and exposes a .min.js file.
@@ -72,7 +80,7 @@
     See the documentation for more information:
     https://bazelbuild.github.io/rules_nodejs/Protractor.html#protractor_web_test_suite
     """
-    ts_project(
+    upstream_ts_project(
         name = name + "__lib",
         srcs = srcs,
         testonly = 1,
diff --git a/tsconfig.json b/tsconfig.json
index aed3439..16fe795 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,8 +3,8 @@
     "experimentalDecorators": true,
     "strict": false,
     "noImplicitAny": false,
-    "target": "es6",
-    "lib": ["es6", "dom", "dom.iterable"],
+    "target": "es2021",
+    "lib": ["es2021", "dom", "dom.iterable"],
     "moduleResolution": "node"
   },
   "bazelOptions": {
diff --git a/y2014/control_loops/python/BUILD b/y2014/control_loops/python/BUILD
index 5704405..3bfba96 100644
--- a/y2014/control_loops/python/BUILD
+++ b/y2014/control_loops/python/BUILD
@@ -60,6 +60,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -76,6 +77,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -92,6 +94,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
diff --git a/y2016/control_loops/python/BUILD b/y2016/control_loops/python/BUILD
index ad3fbe9..6b54950 100644
--- a/y2016/control_loops/python/BUILD
+++ b/y2016/control_loops/python/BUILD
@@ -61,6 +61,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -94,6 +95,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -111,6 +113,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -140,6 +143,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -157,6 +161,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
diff --git a/y2017/control_loops/python/BUILD b/y2017/control_loops/python/BUILD
index 209bd70..a7b5624 100644
--- a/y2017/control_loops/python/BUILD
+++ b/y2017/control_loops/python/BUILD
@@ -58,6 +58,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -106,6 +107,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -123,6 +125,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
@@ -168,6 +171,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
diff --git a/y2018/BUILD b/y2018/BUILD
index ac59493..8feaac4 100644
--- a/y2018/BUILD
+++ b/y2018/BUILD
@@ -57,9 +57,10 @@
         "//aos/network:team_number",
         "//aos/stl_mutex",
         "//frc971:constants",
+        "//frc971/control_loops/double_jointed_arm:dynamics",
         "//frc971/shooter_interpolation:interpolation",
         "//y2018/control_loops/drivetrain:polydrivetrain_plants",
-        "//y2018/control_loops/superstructure/arm:dynamics",
+        "//y2018/control_loops/superstructure/arm:arm_constants",
         "//y2018/control_loops/superstructure/intake:intake_plants",
         "@com_github_google_glog//:glog",
     ],
diff --git a/y2018/constants.h b/y2018/constants.h
index 16f1f8c..6adcb8a 100644
--- a/y2018/constants.h
+++ b/y2018/constants.h
@@ -6,7 +6,8 @@
 
 #include "frc971/constants.h"
 #include "y2018/control_loops/drivetrain/drivetrain_dog_motor_plant.h"
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "y2018/control_loops/superstructure/arm/arm_constants.h"
 #include "y2018/control_loops/superstructure/intake/intake_plant.h"
 
 namespace y2018 {
@@ -49,8 +50,8 @@
     return (12.0 / 60.0) * (18.0 / 84.0);
   }
   static constexpr double kMaxProximalEncoderPulsesPerSecond() {
-    return control_loops::superstructure::arm::Dynamics::kFreeSpeed /
-           (2.0 * M_PI) / control_loops::superstructure::arm::Dynamics::kG1 /
+    return control_loops::superstructure::arm::kArmConstants.free_speed /
+           (2.0 * M_PI) / control_loops::superstructure::arm::kArmConstants.g0 /
            kProximalEncoderRatio() * kProximalEncoderCountsPerRevolution();
   }
   static constexpr double kProximalPotRatio() { return (12.0 / 60.0); }
@@ -58,8 +59,8 @@
   static constexpr double kDistalEncoderCountsPerRevolution() { return 4096.0; }
   static constexpr double kDistalEncoderRatio() { return (12.0 / 60.0); }
   static constexpr double kMaxDistalEncoderPulsesPerSecond() {
-    return control_loops::superstructure::arm::Dynamics::kFreeSpeed /
-           (2.0 * M_PI) / control_loops::superstructure::arm::Dynamics::kG2 /
+    return control_loops::superstructure::arm::kArmConstants.free_speed /
+           (2.0 * M_PI) / control_loops::superstructure::arm::kArmConstants.g1 /
            kDistalEncoderRatio() * kProximalEncoderCountsPerRevolution();
   }
   static constexpr double kDistalPotRatio() {
diff --git a/y2018/control_loops/python/BUILD b/y2018/control_loops/python/BUILD
index 35f7433..fcedf40 100644
--- a/y2018/control_loops/python/BUILD
+++ b/y2018/control_loops/python/BUILD
@@ -94,6 +94,7 @@
         "//frc971/control_loops/python:controls",
         "@pip//glog",
         "@pip//matplotlib",
+        "@pip//pygobject",
         "@pip//python_gflags",
     ],
 )
diff --git a/y2018/control_loops/python/graph_codegen.py b/y2018/control_loops/python/graph_codegen.py
index e7f8ce1..be267de 100644
--- a/y2018/control_loops/python/graph_codegen.py
+++ b/y2018/control_loops/python/graph_codegen.py
@@ -27,12 +27,12 @@
     cc_file.append("                             %s," % (alpha_unitizer))
     if reverse:
         cc_file.append(
-            "                             Trajectory(Path::Reversed(%s()), 0.005));"
+            "                             Trajectory(dynamics, Path::Reversed(%s()), 0.005));"
             % (path_function_name(str(name))))
     else:
         cc_file.append(
-            "                             Trajectory(%s(), 0.005));" %
-            (path_function_name(str(name))))
+            "                             Trajectory(dynamics, %s(), 0.005));"
+            % (path_function_name(str(name))))
 
     start_index = None
     end_index = None
@@ -68,9 +68,14 @@
     cc_file.append("#include <memory>")
     cc_file.append("")
     cc_file.append(
-        "#include \"y2018/control_loops/superstructure/arm/trajectory.h\"")
+        "#include \"frc971/control_loops/double_jointed_arm/trajectory.h\"")
     cc_file.append(
-        "#include \"y2018/control_loops/superstructure/arm/graph.h\"")
+        "#include \"frc971/control_loops/double_jointed_arm/graph.h\"")
+
+    cc_file.append("using frc971::control_loops::arm::Trajectory;")
+    cc_file.append("using frc971::control_loops::arm::Path;")
+    cc_file.append("using frc971::control_loops::arm::SearchGraph;")
+
     cc_file.append("")
     cc_file.append("namespace y2018 {")
     cc_file.append("namespace control_loops {")
@@ -86,15 +91,21 @@
     h_file.append("#include <memory>")
     h_file.append("")
     h_file.append(
-        "#include \"y2018/control_loops/superstructure/arm/trajectory.h\"")
+        "#include \"frc971/control_loops/double_jointed_arm/trajectory.h\"")
     h_file.append(
-        "#include \"y2018/control_loops/superstructure/arm/graph.h\"")
+        "#include \"frc971/control_loops/double_jointed_arm/graph.h\"")
+    h_file.append(
+        "#include \"frc971/control_loops/double_jointed_arm/dynamics.h\"")
     h_file.append("")
     h_file.append("namespace y2018 {")
     h_file.append("namespace control_loops {")
     h_file.append("namespace superstructure {")
     h_file.append("namespace arm {")
 
+    h_file.append("using frc971::control_loops::arm::Trajectory;")
+    h_file.append("using frc971::control_loops::arm::Path;")
+    h_file.append("using frc971::control_loops::arm::SearchGraph;")
+
     h_file.append("")
     h_file.append("struct TrajectoryAndParams {")
     h_file.append("  TrajectoryAndParams(double new_vmax,")
@@ -182,11 +193,13 @@
     h_file.append("")
     h_file.append("// Builds a search graph.")
     h_file.append("SearchGraph MakeSearchGraph("
+                  "const frc971::control_loops::arm::Dynamics *dynamics, "
                   "::std::vector<TrajectoryAndParams> *trajectories,")
     h_file.append("                            "
                   "const ::Eigen::Matrix<double, 2, 2> &alpha_unitizer,")
     h_file.append("                            double vmax);")
     cc_file.append("SearchGraph MakeSearchGraph("
+                   "const frc971::control_loops::arm::Dynamics *dynamics, "
                    "::std::vector<TrajectoryAndParams> *trajectories,")
     cc_file.append("                            "
                    "const ::Eigen::Matrix<double, 2, 2> &alpha_unitizer,")
diff --git a/y2018/control_loops/superstructure/BUILD b/y2018/control_loops/superstructure/BUILD
index 567810d..a3cb808 100644
--- a/y2018/control_loops/superstructure/BUILD
+++ b/y2018/control_loops/superstructure/BUILD
@@ -94,6 +94,7 @@
         "//frc971/control_loops:control_loop_test",
         "//frc971/control_loops:position_sensor_sim",
         "//frc971/control_loops:team_number_test_environment",
+        "//y2018/control_loops/superstructure/arm:arm_constants",
         "//y2018/control_loops/superstructure/intake:intake_plants",
     ],
 )
diff --git a/y2018/control_loops/superstructure/arm/BUILD b/y2018/control_loops/superstructure/arm/BUILD
index f90cc92..9f969ff 100644
--- a/y2018/control_loops/superstructure/arm/BUILD
+++ b/y2018/control_loops/superstructure/arm/BUILD
@@ -1,128 +1,4 @@
 cc_library(
-    name = "trajectory",
-    srcs = [
-        "trajectory.cc",
-    ],
-    hdrs = [
-        "trajectory.h",
-    ],
-    target_compatible_with = ["@platforms//os:linux"],
-    visibility = ["//visibility:public"],
-    deps = [
-        ":dynamics",
-        "//aos/logging",
-        "//frc971/control_loops:dlqr",
-        "//frc971/control_loops:jacobian",
-        "@org_tuxfamily_eigen//:eigen",
-    ],
-)
-
-cc_test(
-    name = "trajectory_test",
-    srcs = [
-        "trajectory_test.cc",
-    ],
-    target_compatible_with = ["@platforms//os:linux"],
-    deps = [
-        ":demo_path",
-        ":dynamics",
-        ":ekf",
-        ":trajectory",
-        "//aos/testing:googletest",
-        "@org_tuxfamily_eigen//:eigen",
-    ],
-)
-
-cc_library(
-    name = "dynamics",
-    srcs = [
-        "dynamics.cc",
-    ],
-    hdrs = [
-        "dynamics.h",
-    ],
-    target_compatible_with = ["@platforms//os:linux"],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//frc971/control_loops:runge_kutta",
-        "@com_github_gflags_gflags//:gflags",
-        "@org_tuxfamily_eigen//:eigen",
-    ],
-)
-
-cc_library(
-    name = "demo_path",
-    srcs = [
-        "demo_path.cc",
-    ],
-    hdrs = ["demo_path.h"],
-    target_compatible_with = ["@platforms//os:linux"],
-    deps = [":trajectory"],
-)
-
-cc_test(
-    name = "dynamics_test",
-    srcs = [
-        "dynamics_test.cc",
-    ],
-    target_compatible_with = ["@platforms//os:linux"],
-    deps = [
-        ":dynamics",
-        "//aos/testing:googletest",
-    ],
-)
-
-cc_binary(
-    name = "trajectory_plot",
-    srcs = [
-        "trajectory_plot.cc",
-    ],
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    deps = [
-        ":ekf",
-        ":generated_graph",
-        ":trajectory",
-        "//frc971/analysis:in_process_plotter",
-        "@com_github_gflags_gflags//:gflags",
-        "@org_tuxfamily_eigen//:eigen",
-    ],
-)
-
-cc_library(
-    name = "ekf",
-    srcs = [
-        "ekf.cc",
-    ],
-    hdrs = [
-        "ekf.h",
-    ],
-    target_compatible_with = ["@platforms//os:linux"],
-    visibility = ["//visibility:public"],
-    deps = [
-        ":dynamics",
-        "//frc971/control_loops:jacobian",
-        "@org_tuxfamily_eigen//:eigen",
-    ],
-)
-
-cc_library(
-    name = "graph",
-    srcs = ["graph.cc"],
-    hdrs = ["graph.h"],
-    target_compatible_with = ["@platforms//os:linux"],
-)
-
-cc_test(
-    name = "graph_test",
-    srcs = ["graph_test.cc"],
-    target_compatible_with = ["@platforms//os:linux"],
-    deps = [
-        ":graph",
-        "//aos/testing:googletest",
-    ],
-)
-
-cc_library(
     name = "arm",
     srcs = [
         "arm.cc",
@@ -133,15 +9,16 @@
     target_compatible_with = ["@platforms//os:linux"],
     visibility = ["//visibility:public"],
     deps = [
-        ":demo_path",
-        ":ekf",
         ":generated_graph",
-        ":graph",
-        ":trajectory",
+        "//frc971/control_loops/double_jointed_arm:demo_path",
+        "//frc971/control_loops/double_jointed_arm:ekf",
+        "//frc971/control_loops/double_jointed_arm:graph",
+        "//frc971/control_loops/double_jointed_arm:trajectory",
         "//frc971/zeroing",
         "//y2018:constants",
         "//y2018/control_loops/superstructure:superstructure_position_fbs",
         "//y2018/control_loops/superstructure:superstructure_status_fbs",
+        "//y2018/control_loops/superstructure/arm:arm_constants",
     ],
 )
 
@@ -170,7 +47,35 @@
     target_compatible_with = ["@platforms//os:linux"],
     visibility = ["//visibility:public"],
     deps = [
-        ":graph",
-        ":trajectory",
+        ":arm_constants",
+        "//frc971/control_loops/double_jointed_arm:graph",
+        "//frc971/control_loops/double_jointed_arm:trajectory",
+    ],
+)
+
+cc_library(
+    name = "arm_constants",
+    hdrs = ["arm_constants.h"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//frc971/control_loops/double_jointed_arm:dynamics",
+    ],
+)
+
+cc_binary(
+    name = "trajectory_plot",
+    srcs = [
+        "trajectory_plot.cc",
+    ],
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    deps = [
+        ":arm_constants",
+        ":generated_graph",
+        "//frc971/analysis:in_process_plotter",
+        "//frc971/control_loops/double_jointed_arm:ekf",
+        "//frc971/control_loops/double_jointed_arm:trajectory",
+        "@com_github_gflags_gflags//:gflags",
+        "@org_tuxfamily_eigen//:eigen",
     ],
 )
diff --git a/y2018/control_loops/superstructure/arm/arm.cc b/y2018/control_loops/superstructure/arm/arm.cc
index 1c29157..ab5deea 100644
--- a/y2018/control_loops/superstructure/arm/arm.cc
+++ b/y2018/control_loops/superstructure/arm/arm.cc
@@ -5,8 +5,9 @@
 
 #include "aos/logging/logging.h"
 #include "y2018/constants.h"
-#include "y2018/control_loops/superstructure/arm/demo_path.h"
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/demo_path.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "y2018/control_loops/superstructure/arm/arm_constants.h"
 #include "y2018/control_loops/superstructure/arm/generated_graph.h"
 
 namespace y2018 {
@@ -29,9 +30,12 @@
       alpha_unitizer_((::Eigen::Matrix<double, 2, 2>() << 1.0 / kAlpha0Max(),
                        0.0, 0.0, 1.0 / kAlpha1Max())
                           .finished()),
-      search_graph_(MakeSearchGraph(&trajectories_, alpha_unitizer_, kVMax())),
+      dynamics_(kArmConstants),
+      search_graph_(MakeSearchGraph(&dynamics_, &trajectories_, alpha_unitizer_,
+                                    kVMax())),
+      arm_ekf_(&dynamics_),
       // Go to the start of the first trajectory.
-      follower_(ReadyAboveBoxPoint()),
+      follower_(&dynamics_, ReadyAboveBoxPoint()),
       points_(PointList()) {
   int i = 0;
   for (const auto &trajectory : trajectories_) {
diff --git a/y2018/control_loops/superstructure/arm/arm.h b/y2018/control_loops/superstructure/arm/arm.h
index 7ceb001..51d6c4d 100644
--- a/y2018/control_loops/superstructure/arm/arm.h
+++ b/y2018/control_loops/superstructure/arm/arm.h
@@ -4,14 +4,17 @@
 #include "aos/time/time.h"
 #include "frc971/zeroing/zeroing.h"
 #include "y2018/constants.h"
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
-#include "y2018/control_loops/superstructure/arm/ekf.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/ekf.h"
 #include "y2018/control_loops/superstructure/arm/generated_graph.h"
-#include "y2018/control_loops/superstructure/arm/graph.h"
-#include "y2018/control_loops/superstructure/arm/trajectory.h"
+#include "frc971/control_loops/double_jointed_arm/graph.h"
+#include "frc971/control_loops/double_jointed_arm/trajectory.h"
 #include "y2018/control_loops/superstructure/superstructure_position_generated.h"
 #include "y2018/control_loops/superstructure/superstructure_status_generated.h"
 
+using frc971::control_loops::arm::TrajectoryFollower;
+using frc971::control_loops::arm::EKF;
+
 namespace y2018 {
 namespace control_loops {
 namespace superstructure {
@@ -114,6 +117,8 @@
 
   double vmax_ = kVMax();
 
+  frc971::control_loops::arm::Dynamics dynamics_;
+
   ::std::vector<TrajectoryAndParams> trajectories_;
   SearchGraph search_graph_;
 
diff --git a/y2018/control_loops/superstructure/arm/arm_constants.h b/y2018/control_loops/superstructure/arm/arm_constants.h
new file mode 100644
index 0000000..1697a8e
--- /dev/null
+++ b/y2018/control_loops/superstructure/arm/arm_constants.h
@@ -0,0 +1,53 @@
+#ifndef Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_ARM_CONSTANTS_H_
+#define Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_ARM_CONSTANTS_H_
+
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+
+namespace y2018 {
+namespace control_loops {
+namespace superstructure {
+namespace arm {
+
+constexpr double kEfficiencyTweak = 0.95;
+constexpr double kStallTorque = 1.41 * kEfficiencyTweak;
+constexpr double kFreeSpeed = (5840.0 / 60.0) * 2.0 * M_PI;
+constexpr double kStallCurrent = 89.0;
+
+constexpr ::frc971::control_loops::arm::ArmConstants kArmConstants = {
+    .l0 = 46.25 * 0.0254,
+    .l1 = 41.80 * 0.0254,
+    .m0 = 9.34 / 2.2,
+    .m1 = 9.77 / 2.2,
+
+    // Moment of inertia of the joints in kg m^2
+    .j0 = 2957.05 * 0.0002932545454545454,
+    .j1 = 2824.70 * 0.0002932545454545454,
+
+    // Radius of the center of mass of the joints in meters.
+    .r0 = 21.64 * 0.0254,
+    .r1 = 26.70 * 0.0254,
+
+    // Gear ratios for the two joints.
+    .g0 = 140.0,
+    .g1 = 90.0,
+
+    // MiniCIM motor constants.
+    .efficiency_tweak = kEfficiencyTweak,
+    .stall_torque = kStallTorque,
+    .free_speed = kFreeSpeed,
+    .stall_current = kStallCurrent,
+    .resistance = 12.0 / kStallCurrent,
+    .Kv = kFreeSpeed / 12.0,
+    .Kt = kStallTorque / kStallCurrent,
+
+    // Number of motors on the distal joint.
+    .num_distal_motors = 2.0,
+};
+
+
+} // namespace arm
+} // namespace superstructure
+} // namespace control_loops
+} // namespace y2018
+
+#endif // Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_ARM_CONSTANTS_H_
diff --git a/y2018/control_loops/superstructure/arm/demo_path.h b/y2018/control_loops/superstructure/arm/demo_path.h
deleted file mode 100644
index 0c18103..0000000
--- a/y2018/control_loops/superstructure/arm/demo_path.h
+++ /dev/null
@@ -1,21 +0,0 @@
-#ifndef Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_DEMO_PATH_H_
-#define Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_DEMO_PATH_H_
-
-#include <memory>
-
-#include "y2018/control_loops/superstructure/arm/trajectory.h"
-
-namespace y2018 {
-namespace control_loops {
-namespace superstructure {
-namespace arm {
-
-::std::unique_ptr<Path> MakeDemoPath();
-::std::unique_ptr<Path> MakeReversedDemoPath();
-
-}  // namespace arm
-}  // namespace superstructure
-}  // namespace control_loops
-}  // namespace y2018
-
-#endif  // Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_DEMO_PATH_H_
diff --git a/y2018/control_loops/superstructure/arm/dynamics.cc b/y2018/control_loops/superstructure/arm/dynamics.cc
deleted file mode 100644
index 02a2861..0000000
--- a/y2018/control_loops/superstructure/arm/dynamics.cc
+++ /dev/null
@@ -1,35 +0,0 @@
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
-
-DEFINE_bool(gravity, true, "If true, enable gravity.");
-
-namespace y2018 {
-namespace control_loops {
-namespace superstructure {
-namespace arm {
-
-const ::Eigen::Matrix<double, 2, 2> Dynamics::K3 =
-    (::Eigen::Matrix<double, 2, 2>()
-         << Dynamics::kG1 * Dynamics::Kt / Dynamics::kResistance,
-     0.0, 0.0, Dynamics::kG2 *Dynamics::kNumDistalMotors *Dynamics::Kt /
-                   Dynamics::kResistance)
-        .finished();
-
-const ::Eigen::Matrix<double, 2, 2> Dynamics::K3_inverse = K3.inverse();
-
-const ::Eigen::Matrix<double, 2, 2> Dynamics::K4 =
-    (::Eigen::Matrix<double, 2, 2>()
-         << Dynamics::kG1 * Dynamics::kG1 * Dynamics::Kt /
-                (Dynamics::Kv * Dynamics::kResistance),
-     0.0, 0.0,
-     Dynamics::kG2 *Dynamics::kG2 *Dynamics::Kt *Dynamics::kNumDistalMotors /
-         (Dynamics::Kv * Dynamics::kResistance))
-        .finished();
-
-constexpr double Dynamics::kAlpha;
-constexpr double Dynamics::kBeta;
-constexpr double Dynamics::kGamma;
-
-}  // namespace arm
-}  // namespace superstructure
-}  // namespace control_loops
-}  // namespace y2018
diff --git a/y2018/control_loops/superstructure/arm/dynamics.h b/y2018/control_loops/superstructure/arm/dynamics.h
deleted file mode 100644
index b1ebc97..0000000
--- a/y2018/control_loops/superstructure/arm/dynamics.h
+++ /dev/null
@@ -1,189 +0,0 @@
-#ifndef Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_DYNAMICS_H_
-#define Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_DYNAMICS_H_
-
-#include "Eigen/Dense"
-
-#include "frc971/control_loops/runge_kutta.h"
-#include "gflags/gflags.h"
-
-DECLARE_bool(gravity);
-
-namespace y2018 {
-namespace control_loops {
-namespace superstructure {
-namespace arm {
-
-// This class captures the dynamics of our system.  It doesn't actually need to
-// store state yet, so everything can be constexpr and/or static.
-class Dynamics {
- public:
-  // Below, 1 refers to the proximal joint, and 2 refers to the distal joint.
-  // Length of the joints in meters.
-  static constexpr double kL1 = 46.25 * 0.0254;
-  static constexpr double kL2 = 41.80 * 0.0254;
-
-  // Mass of the joints in kilograms.
-  static constexpr double kM1 = 9.34 / 2.2;
-  static constexpr double kM2 = 9.77 / 2.2;
-
-  // Moment of inertia of the joints in kg m^2
-  static constexpr double kJ1 = 2957.05 * 0.0002932545454545454;
-  static constexpr double kJ2 = 2824.70 * 0.0002932545454545454;
-
-  // Radius of the center of mass of the joints in meters.
-  static constexpr double r1 = 21.64 * 0.0254;
-  static constexpr double r2 = 26.70 * 0.0254;
-
-  // Gear ratios for the two joints.
-  static constexpr double kG1 = 140.0;
-  static constexpr double kG2 = 90.0;
-
-  // MiniCIM motor constants.
-  static constexpr double kEfficiencyTweak = 0.95;
-  static constexpr double kStallTorque = 1.41 * kEfficiencyTweak;
-  static constexpr double kFreeSpeed = (5840.0 / 60.0) * 2.0 * M_PI;
-  static constexpr double kStallCurrent = 89.0;
-  static constexpr double kResistance = 12.0 / kStallCurrent;
-  static constexpr double Kv = kFreeSpeed / 12.0;
-  static constexpr double Kt = kStallTorque / kStallCurrent;
-
-  // Number of motors on the distal joint.
-  static constexpr double kNumDistalMotors = 2.0;
-
-  static constexpr double kAlpha = kJ1 + r1 * r1 * kM1 + kL1 * kL1 * kM2;
-  static constexpr double kBeta = kL1 * r2 * kM2;
-  static constexpr double kGamma = kJ2 + r2 * r2 * kM2;
-
-  // K3, K4 matricies described below.
-  static const ::Eigen::Matrix<double, 2, 2> K3;
-  static const ::Eigen::Matrix<double, 2, 2> K3_inverse;
-  static const ::Eigen::Matrix<double, 2, 2> K4;
-
-  // Generates K1-2 for the arm ODE.
-  // K1 * d^2 theta / dt^2 + K2 * d theta / dt = K3 * V - K4 * d theta/dt
-  // These matricies are missing the velocity factor for K2[1, 0], and K2[0, 1].
-  // You probbaly want MatriciesForState.
-  static void NormilizedMatriciesForState(
-      const ::Eigen::Matrix<double, 4, 1> &X,
-      ::Eigen::Matrix<double, 2, 2> *K1_result,
-      ::Eigen::Matrix<double, 2, 2> *K2_result) {
-    const double angle = X(0, 0) - X(2, 0);
-    const double s = ::std::sin(angle);
-    const double c = ::std::cos(angle);
-    *K1_result << kAlpha, c * kBeta, c * kBeta, kGamma;
-    *K2_result << 0.0, s * kBeta, -s * kBeta, 0.0;
-  }
-
-  // Generates K1-2 for the arm ODE.
-  // K1 * d^2 theta / dt^2 + K2 * d theta / dt = K3 * V - K4 * d theta/dt
-  static void MatriciesForState(const ::Eigen::Matrix<double, 4, 1> &X,
-                                ::Eigen::Matrix<double, 2, 2> *K1_result,
-                                ::Eigen::Matrix<double, 2, 2> *K2_result) {
-    NormilizedMatriciesForState(X, K1_result, K2_result);
-    (*K2_result)(1, 0) *= X(1, 0);
-    (*K2_result)(0, 1) *= X(3, 0);
-  }
-
-  // TODO(austin): We may want a way to provide K1 and K2 to save CPU cycles.
-
-  // Calculates the acceleration given the current state and control input.
-  static const ::Eigen::Matrix<double, 4, 1> Acceleration(
-      const ::Eigen::Matrix<double, 4, 1> &X,
-      const ::Eigen::Matrix<double, 2, 1> &U) {
-    ::Eigen::Matrix<double, 2, 2> K1;
-    ::Eigen::Matrix<double, 2, 2> K2;
-
-    MatriciesForState(X, &K1, &K2);
-
-    const ::Eigen::Matrix<double, 2, 1> velocity =
-        (::Eigen::Matrix<double, 2, 1>() << X(1, 0), X(3, 0)).finished();
-
-    const ::Eigen::Matrix<double, 2, 1> torque = K3 * U - K4 * velocity;
-    const ::Eigen::Matrix<double, 2, 1> gravity_torque = GravityTorque(X);
-
-    const ::Eigen::Matrix<double, 2, 1> accel =
-        K1.inverse() * (torque + gravity_torque - K2 * velocity);
-
-    return (::Eigen::Matrix<double, 4, 1>() << X(1, 0), accel(0, 0), X(3, 0),
-            accel(1, 0))
-        .finished();
-  }
-
-  // Calculates the acceleration given the current augmented kalman filter state
-  // and control input.
-  static const ::Eigen::Matrix<double, 6, 1> EKFAcceleration(
-      const ::Eigen::Matrix<double, 6, 1> &X,
-      const ::Eigen::Matrix<double, 2, 1> &U) {
-    ::Eigen::Matrix<double, 2, 2> K1;
-    ::Eigen::Matrix<double, 2, 2> K2;
-
-    MatriciesForState(X.block<4, 1>(0, 0), &K1, &K2);
-
-    const ::Eigen::Matrix<double, 2, 1> velocity =
-        (::Eigen::Matrix<double, 2, 1>() << X(1, 0), X(3, 0)).finished();
-
-    const ::Eigen::Matrix<double, 2, 1> torque =
-        K3 *
-            (U +
-             (::Eigen::Matrix<double, 2, 1>() << X(4, 0), X(5, 0)).finished()) -
-        K4 * velocity;
-    const ::Eigen::Matrix<double, 2, 1> gravity_torque =
-        GravityTorque(X.block<4, 1>(0, 0));
-
-    const ::Eigen::Matrix<double, 2, 1> accel =
-        K1.inverse() * (torque + gravity_torque - K2 * velocity);
-
-    return (::Eigen::Matrix<double, 6, 1>() << X(1, 0), accel(0, 0), X(3, 0),
-            accel(1, 0), 0.0, 0.0)
-        .finished();
-  }
-
-  // Calculates the voltage required to follow the trajectory.  This requires
-  // knowing the current state, desired angular velocity and acceleration.
-  static const ::Eigen::Matrix<double, 2, 1> FF_U(
-      const ::Eigen::Matrix<double, 4, 1> &X,
-      const ::Eigen::Matrix<double, 2, 1> &omega_t,
-      const ::Eigen::Matrix<double, 2, 1> &alpha_t) {
-    ::Eigen::Matrix<double, 2, 2> K1;
-    ::Eigen::Matrix<double, 2, 2> K2;
-
-    MatriciesForState(X, &K1, &K2);
-
-    const ::Eigen::Matrix<double, 2, 1> gravity_torque = GravityTorque(X);
-
-    return K3_inverse *
-           (K1 * alpha_t + K2 * omega_t + K4 * omega_t - gravity_torque);
-  }
-
-  static ::Eigen::Matrix<double, 2, 1> GravityTorque(
-      const ::Eigen::Matrix<double, 4, 1> &X) {
-    constexpr double kAccelDueToGravity = 9.8 * kEfficiencyTweak;
-    return (::Eigen::Matrix<double, 2, 1>() << (r1 * kM1 + kL1 * kM2) *
-                                                   ::std::sin(X(0)) *
-                                                   kAccelDueToGravity,
-            r2 * kM2 * ::std::sin(X(2)) * kAccelDueToGravity)
-               .finished() *
-           (FLAGS_gravity ? 1.0 : 0.0);
-  }
-
-  static const ::Eigen::Matrix<double, 4, 1> UnboundedDiscreteDynamics(
-      const ::Eigen::Matrix<double, 4, 1> &X,
-      const ::Eigen::Matrix<double, 2, 1> &U, double dt) {
-    return ::frc971::control_loops::RungeKuttaU(Dynamics::Acceleration, X, U,
-                                                dt);
-  }
-
-  static const ::Eigen::Matrix<double, 6, 1> UnboundedEKFDiscreteDynamics(
-      const ::Eigen::Matrix<double, 6, 1> &X,
-      const ::Eigen::Matrix<double, 2, 1> &U, double dt) {
-    return ::frc971::control_loops::RungeKuttaU(Dynamics::EKFAcceleration, X, U,
-                                                dt);
-  }
-};
-
-}  // namespace arm
-}  // namespace superstructure
-}  // namespace control_loops
-}  // namespace y2018
-
-#endif  // Y2018_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_DYNAMICS_H_
diff --git a/y2018/control_loops/superstructure/arm/dynamics_test.cc b/y2018/control_loops/superstructure/arm/dynamics_test.cc
deleted file mode 100644
index 23e5adc..0000000
--- a/y2018/control_loops/superstructure/arm/dynamics_test.cc
+++ /dev/null
@@ -1,52 +0,0 @@
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
-
-#include "gtest/gtest.h"
-
-namespace y2018 {
-namespace control_loops {
-namespace superstructure {
-namespace arm {
-namespace testing {
-
-// Tests that zero inputs result in no acceleration and no motion.
-// This isn't all that rigerous, but it's a good start.
-TEST(DynamicsTest, Acceleration) {
-  Dynamics dynamics;
-
-  EXPECT_TRUE(dynamics
-                  .Acceleration(::Eigen::Matrix<double, 4, 1>::Zero(),
-                                ::Eigen::Matrix<double, 2, 1>::Zero())
-                  .isApprox(::Eigen::Matrix<double, 4, 1>::Zero()));
-
-  EXPECT_TRUE(dynamics
-                  .UnboundedDiscreteDynamics(
-                      ::Eigen::Matrix<double, 4, 1>::Zero(),
-                      ::Eigen::Matrix<double, 2, 1>::Zero(), 0.1)
-                  .isApprox(::Eigen::Matrix<double, 4, 1>::Zero()));
-
-  const ::Eigen::Matrix<double, 4, 1> X =
-      (::Eigen::Matrix<double, 4, 1>() << M_PI / 2.0, 0.0, 0.0, 0.0).finished();
-
-  ::std::cout << dynamics.FF_U(X, ::Eigen::Matrix<double, 2, 1>::Zero(),
-                               ::Eigen::Matrix<double, 2, 1>::Zero())
-              << ::std::endl;
-
-  ::std::cout << dynamics.UnboundedDiscreteDynamics(
-                     X, dynamics.FF_U(X, ::Eigen::Matrix<double, 2, 1>::Zero(),
-                                      ::Eigen::Matrix<double, 2, 1>::Zero()),
-                     0.01)
-              << ::std::endl;
-
-  EXPECT_TRUE(dynamics
-                  .UnboundedDiscreteDynamics(
-                      X, dynamics.FF_U(X, ::Eigen::Matrix<double, 2, 1>::Zero(),
-                                       ::Eigen::Matrix<double, 2, 1>::Zero()),
-                      0.01)
-                  .isApprox(X));
-}
-
-}  // namespace testing
-}  // namespace arm
-}  // namespace superstructure
-}  // namespace control_loops
-}  // namespace y2018
diff --git a/y2018/control_loops/superstructure/arm/trajectory_plot.cc b/y2018/control_loops/superstructure/arm/trajectory_plot.cc
index 10b39bc..0cee664 100644
--- a/y2018/control_loops/superstructure/arm/trajectory_plot.cc
+++ b/y2018/control_loops/superstructure/arm/trajectory_plot.cc
@@ -1,10 +1,11 @@
 #include "aos/init.h"
 #include "frc971/analysis/in_process_plotter.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/ekf.h"
+#include "frc971/control_loops/double_jointed_arm/trajectory.h"
 #include "gflags/gflags.h"
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
-#include "y2018/control_loops/superstructure/arm/ekf.h"
+#include "y2018/control_loops/superstructure/arm/arm_constants.h"
 #include "y2018/control_loops/superstructure/arm/generated_graph.h"
-#include "y2018/control_loops/superstructure/arm/trajectory.h"
 
 DEFINE_bool(forwards, true, "If true, run the forwards simulation.");
 DEFINE_bool(plot, true, "If true, plot");
@@ -16,10 +17,12 @@
 namespace arm {
 
 void Main() {
-  Trajectory trajectory(FLAGS_forwards
-                            ? MakeNeutralToFrontHighPath()
-                            : Path::Reversed(MakeNeutralToFrontHighPath()),
-                        0.001);
+  frc971::control_loops::arm::Dynamics dynamics(kArmConstants);
+  frc971::control_loops::arm::Trajectory trajectory(
+      &dynamics,
+      FLAGS_forwards ? MakeNeutralToFrontHighPath()
+                     : Path::Reversed(MakeNeutralToFrontHighPath()),
+      0.001);
 
   constexpr double kAlpha0Max = 30.0;
   constexpr double kAlpha1Max = 50.0;
@@ -96,7 +99,7 @@
 
     const ::Eigen::Matrix<double, 6, 1> R = trajectory.R(theta_t, omega_t);
     const ::Eigen::Matrix<double, 2, 1> U =
-        Dynamics::FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t)
+        dynamics.FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t)
             .array()
             .max(-20)
             .min(20);
@@ -118,7 +121,7 @@
 
     const ::Eigen::Matrix<double, 6, 1> R = trajectory.R(theta_t, omega_t);
     const ::Eigen::Matrix<double, 2, 1> U =
-        Dynamics::FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t)
+        dynamics.FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t)
             .array()
             .max(-20)
             .min(20);
@@ -140,7 +143,7 @@
 
     const ::Eigen::Matrix<double, 6, 1> R = trajectory.R(theta_t, omega_t);
     const ::Eigen::Matrix<double, 2, 1> U =
-        Dynamics::FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t)
+        dynamics.FF_U(R.block<4, 1>(0, 0), omega_t, alpha_t)
             .array()
             .max(-20)
             .min(20);
@@ -156,7 +159,8 @@
     X << theta_t(0), 0.0, theta_t(1), 0.0;
   }
 
-  TrajectoryFollower follower(&trajectory);
+  frc971::control_loops::arm::TrajectoryFollower follower(&dynamics,
+                                                          &trajectory);
 
   ::std::vector<double> t_array;
   ::std::vector<double> theta0_goal_t_array;
@@ -187,7 +191,7 @@
   ::std::vector<double> torque0_hat_t_array;
   ::std::vector<double> torque1_hat_t_array;
 
-  EKF arm_ekf;
+  frc971::control_loops::arm::EKF arm_ekf(&dynamics);
   arm_ekf.Reset(X);
   ::std::cout << "Reset P: " << arm_ekf.P_reset() << ::std::endl;
   ::std::cout << "Stabilized P: " << arm_ekf.P_half_converged() << ::std::endl;
@@ -236,9 +240,9 @@
     actual_U(0) += 1.0;
 
     const ::Eigen::Matrix<double, 4, 1> xdot =
-        Dynamics::Acceleration(X, actual_U);
+        dynamics.Acceleration(X, actual_U);
 
-    X = Dynamics::UnboundedDiscreteDynamics(X, actual_U, sim_dt);
+    X = dynamics.UnboundedDiscreteDynamics(X, actual_U, sim_dt);
     arm_ekf.Predict(follower.U(), sim_dt);
 
     alpha0_t_array.push_back(xdot(1));
@@ -270,10 +274,14 @@
     plotter.AddLine(distance_array, alpha0_array, "alpha0");
     plotter.AddLine(distance_array, alpha1_array, "alpha1");
 
-    plotter.AddLine(integrated_distance, integrated_theta0_array, "integrated theta0");
-    plotter.AddLine(integrated_distance, integrated_theta1_array, "integrated theta1");
-    plotter.AddLine(integrated_distance, integrated_omega0_array, "integrated omega0");
-    plotter.AddLine(integrated_distance, integrated_omega1_array, "integrated omega1");
+    plotter.AddLine(integrated_distance, integrated_theta0_array,
+                    "integrated theta0");
+    plotter.AddLine(integrated_distance, integrated_theta1_array,
+                    "integrated theta1");
+    plotter.AddLine(integrated_distance, integrated_omega0_array,
+                    "integrated omega0");
+    plotter.AddLine(integrated_distance, integrated_omega1_array,
+                    "integrated omega1");
     plotter.Publish();
 
     plotter.AddFigure();
diff --git a/y2018/control_loops/superstructure/superstructure_lib_test.cc b/y2018/control_loops/superstructure/superstructure_lib_test.cc
index 285c8c3..54342c5 100644
--- a/y2018/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2018/control_loops/superstructure/superstructure_lib_test.cc
@@ -8,7 +8,8 @@
 #include "frc971/control_loops/team_number_test_environment.h"
 #include "gtest/gtest.h"
 #include "y2018/constants.h"
-#include "y2018/control_loops/superstructure/arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "y2018/control_loops/superstructure/arm/arm_constants.h"
 #include "y2018/control_loops/superstructure/arm/generated_graph.h"
 #include "y2018/control_loops/superstructure/intake/intake_plant.h"
 #include "y2018/control_loops/superstructure/superstructure.h"
@@ -129,7 +130,8 @@
                               constants::Values::kProximalEncoderRatio()),
         distal_zeroing_constants_(distal_zeroing_constants),
         distal_pot_encoder_(M_PI * 2.0 *
-                            constants::Values::kDistalEncoderRatio()) {
+                            constants::Values::kDistalEncoderRatio()),
+        dynamics_(arm::kArmConstants) {
     X_.setZero();
   }
 
@@ -174,7 +176,7 @@
     AOS_CHECK_LE(::std::abs(U(1)), voltage_check);
 
     if (release_arm_brake) {
-      X_ = arm::Dynamics::UnboundedDiscreteDynamics(X_, U, 0.00505);
+      X_ = dynamics_.UnboundedDiscreteDynamics(X_, U, 0.00505);
     } else {
       // Well, the brake shouldn't stop both joints, but this will get the tests
       // to pass.
@@ -197,6 +199,8 @@
   const ::frc971::constants::PotAndAbsoluteEncoderZeroingConstants
       distal_zeroing_constants_;
   PositionSensorSimulator distal_pot_encoder_;
+
+  ::frc971::control_loops::arm::Dynamics dynamics_;
 };
 
 class SuperstructureSimulation {
diff --git a/y2019/vision/server/BUILD b/y2019/vision/server/BUILD
index 93172b4..b0b7674 100644
--- a/y2019/vision/server/BUILD
+++ b/y2019/vision/server/BUILD
@@ -1,9 +1,9 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("//aos/seasocks:gen_embedded.bzl", "gen_embedded")
 load("@com_google_protobuf//:protobuf.bzl", "cc_proto_library")
 load("//frc971/downloader:downloader.bzl", "aos_downloader_dir")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
-ts_library(
+ts_project(
     name = "demo",
     srcs = [
         "demo.ts",
diff --git a/y2019/vision/server/www/BUILD b/y2019/vision/server/www/BUILD
index 7c40eaa..2e8be80 100644
--- a/y2019/vision/server/www/BUILD
+++ b/y2019/vision/server/www/BUILD
@@ -1,5 +1,4 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -10,7 +9,7 @@
     ]),
 )
 
-ts_library(
+ts_project(
     name = "visualizer",
     srcs = glob([
         "*.ts",
diff --git a/y2020/control_loops/drivetrain/BUILD b/y2020/control_loops/drivetrain/BUILD
index 0455d0f..fa153f0 100644
--- a/y2020/control_loops/drivetrain/BUILD
+++ b/y2020/control_loops/drivetrain/BUILD
@@ -1,7 +1,7 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
 load("//aos:config.bzl", "aos_config")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
 genrule(
     name = "genrule_drivetrain",
@@ -216,7 +216,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "localizer_plotter",
     srcs = ["localizer_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/y2020/control_loops/drivetrain/localizer_plotter.ts b/y2020/control_loops/drivetrain/localizer_plotter.ts
index b2120ef..03450ef 100644
--- a/y2020/control_loops/drivetrain/localizer_plotter.ts
+++ b/y2020/control_loops/drivetrain/localizer_plotter.ts
@@ -1,10 +1,10 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
-import {MessageHandler, TimestampedMessage} from 'org_frc971/aos/network/www/aos_plotter';
-import {Point} from 'org_frc971/aos/network/www/plotter';
-import {Table} from 'org_frc971/aos/network/www/reflection';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import {Connection} from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
+import {MessageHandler, TimestampedMessage} from '../../../aos/network/www/aos_plotter';
+import {Point} from '../../../aos/network/www/plotter';
+import {Table} from '../../../aos/network/www/reflection';
 import {ByteBuffer} from 'flatbuffers';
 import {Schema} from 'flatbuffers_reflection/reflection_generated';
 
diff --git a/y2020/control_loops/python/BUILD b/y2020/control_loops/python/BUILD
index 8a1f737..3833536 100644
--- a/y2020/control_loops/python/BUILD
+++ b/y2020/control_loops/python/BUILD
@@ -122,6 +122,7 @@
     deps = [
         "//frc971/control_loops/python:controls",
         "@pip//matplotlib",
+        "@pip//pygobject",
     ],
 )
 
diff --git a/y2020/control_loops/superstructure/BUILD b/y2020/control_loops/superstructure/BUILD
index d3f303f..d1f20a2 100644
--- a/y2020/control_loops/superstructure/BUILD
+++ b/y2020/control_loops/superstructure/BUILD
@@ -1,6 +1,6 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -136,7 +136,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "turret_plotter",
     srcs = ["turret_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -152,7 +152,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "finisher_plotter",
     srcs = ["finisher_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -163,7 +163,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "accelerator_plotter",
     srcs = ["accelerator_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -174,7 +174,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "hood_plotter",
     srcs = ["hood_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/y2020/control_loops/superstructure/accelerator_plotter.ts b/y2020/control_loops/superstructure/accelerator_plotter.ts
index e1bc5bc..a5014dc 100644
--- a/y2020/control_loops/superstructure/accelerator_plotter.ts
+++ b/y2020/control_loops/superstructure/accelerator_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, BLACK} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, BLACK} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/y2020/control_loops/superstructure/finisher_plotter.ts b/y2020/control_loops/superstructure/finisher_plotter.ts
index f2127f5..9799e2c 100644
--- a/y2020/control_loops/superstructure/finisher_plotter.ts
+++ b/y2020/control_loops/superstructure/finisher_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, BLACK} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, BLACK} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/y2020/control_loops/superstructure/hood_plotter.ts b/y2020/control_loops/superstructure/hood_plotter.ts
index 55b1ba8..4102464 100644
--- a/y2020/control_loops/superstructure/hood_plotter.ts
+++ b/y2020/control_loops/superstructure/hood_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/y2020/control_loops/superstructure/turret_plotter.ts b/y2020/control_loops/superstructure/turret_plotter.ts
index 5cb15c6..4de5e1d 100644
--- a/y2020/control_loops/superstructure/turret_plotter.ts
+++ b/y2020/control_loops/superstructure/turret_plotter.ts
@@ -1,11 +1,11 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import * as configuration from 'org_frc971/aos/configuration_generated';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
-import {MessageHandler, TimestampedMessage} from 'org_frc971/aos/network/www/aos_plotter';
-import {Point} from 'org_frc971/aos/network/www/plotter';
-import {Table} from 'org_frc971/aos/network/www/reflection';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import * as configuration from '../../../aos/configuration_generated';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
+import {MessageHandler, TimestampedMessage} from '../../../aos/network/www/aos_plotter';
+import {Point} from '../../../aos/network/www/plotter';
+import {Table} from '../../../aos/network/www/reflection';
 import {ByteBuffer} from 'flatbuffers';
 import {Schema} from 'flatbuffers_reflection/reflection_generated';
 
diff --git a/y2020/www/BUILD b/y2020/www/BUILD
index 8467994..0c05900 100644
--- a/y2020/www/BUILD
+++ b/y2020/www/BUILD
@@ -1,8 +1,7 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
 load("//frc971/downloader:downloader.bzl", "aos_downloader_dir")
 
-ts_library(
+ts_project(
     name = "camera_main",
     srcs = [
         "camera_main.ts",
@@ -21,7 +20,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "field_main",
     srcs = [
         "constants.ts",
diff --git a/y2020/www/camera_main.ts b/y2020/www/camera_main.ts
index 58e5bed..2c47cc5 100644
--- a/y2020/www/camera_main.ts
+++ b/y2020/www/camera_main.ts
@@ -1,7 +1,7 @@
-import {Connection} from 'org_frc971/aos/network/www/proxy';
+import {Connection} from '../../aos/network/www/proxy';
 
 import {ImageHandler} from './image_handler';
-import {ConfigHandler} from 'org_frc971/aos/network/www/config_handler';
+import {ConfigHandler} from '../../aos/network/www/config_handler';
 
 const conn = new Connection();
 
diff --git a/y2020/www/field_main.ts b/y2020/www/field_main.ts
index 7e2e392..d71a45e 100644
--- a/y2020/www/field_main.ts
+++ b/y2020/www/field_main.ts
@@ -1,4 +1,4 @@
-import {Connection} from 'org_frc971/aos/network/www/proxy';
+import {Connection} from '../../aos/network/www/proxy';
 
 import {FieldHandler} from './field_handler';
 
diff --git a/y2020/www/image_handler.ts b/y2020/www/image_handler.ts
index 856ee8b..07cee10 100644
--- a/y2020/www/image_handler.ts
+++ b/y2020/www/image_handler.ts
@@ -1,8 +1,8 @@
-import {Channel, Configuration} from 'org_frc971/aos/configuration_generated';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
+import {Channel, Configuration} from '../../aos/configuration_generated';
+import {Connection} from '../../aos/network/www/proxy';
 import {ByteBuffer} from 'flatbuffers';
-import {ImageMatchResult, Feature} from 'org_frc971/y2020/vision/sift/sift_generated'
-import {CameraImage} from 'org_frc971/frc971/vision/vision_generated';
+import {ImageMatchResult, Feature} from '../vision/sift/sift_generated'
+import {CameraImage} from '../../frc971/vision/vision_generated';
 
 export class ImageHandler {
   private canvas = document.createElement('canvas');
diff --git a/y2021_bot3/control_loops/superstructure/BUILD b/y2021_bot3/control_loops/superstructure/BUILD
index f5b5b3b..be18cbe 100644
--- a/y2021_bot3/control_loops/superstructure/BUILD
+++ b/y2021_bot3/control_loops/superstructure/BUILD
@@ -1,5 +1,5 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -104,7 +104,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "superstructure_plotter",
     srcs = ["superstructure_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/y2021_bot3/control_loops/superstructure/superstructure_plotter.ts b/y2021_bot3/control_loops/superstructure/superstructure_plotter.ts
index f453d64..4fb397d 100644
--- a/y2021_bot3/control_loops/superstructure/superstructure_plotter.ts
+++ b/y2021_bot3/control_loops/superstructure/superstructure_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/y2022/BUILD b/y2022/BUILD
index e978f7a..1a04c2e 100644
--- a/y2022/BUILD
+++ b/y2022/BUILD
@@ -102,7 +102,7 @@
             "//aos/network:timestamp_fbs",
             "//aos/network:remote_message_fbs",
             "//frc971/vision:vision_fbs",
-            "//y2022/localizer:localizer_output_fbs",
+            "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
             "//frc971/vision:calibration_fbs",
             "//y2022/vision:target_estimate_fbs",
             "//y2022/vision:ball_color_fbs",
@@ -132,7 +132,7 @@
         "//aos/network:timestamp_fbs",
         "//aos/network:remote_message_fbs",
         "//y2022/localizer:localizer_status_fbs",
-        "//y2022/localizer:localizer_output_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
         "//y2022/localizer:localizer_visualization_fbs",
     ],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/y2022/control_loops/drivetrain/BUILD b/y2022/control_loops/drivetrain/BUILD
index 18855a6..148a998 100644
--- a/y2022/control_loops/drivetrain/BUILD
+++ b/y2022/control_loops/drivetrain/BUILD
@@ -71,19 +71,6 @@
     ],
 )
 
-cc_library(
-    name = "localizer",
-    srcs = ["localizer.cc"],
-    hdrs = ["localizer.h"],
-    deps = [
-        "//aos/events:event_loop",
-        "//aos/network:message_bridge_server_fbs",
-        "//frc971/control_loops/drivetrain:hybrid_ekf",
-        "//frc971/control_loops/drivetrain:localizer",
-        "//y2022/localizer:localizer_output_fbs",
-    ],
-)
-
 cc_binary(
     name = "drivetrain",
     srcs = [
@@ -93,10 +80,10 @@
     visibility = ["//visibility:public"],
     deps = [
         ":drivetrain_base",
-        ":localizer",
         "//aos:init",
         "//aos/events:shm_event_loop",
         "//frc971/control_loops/drivetrain:drivetrain_lib",
+        "//frc971/control_loops/drivetrain/localization:puppet_localizer",
     ],
 )
 
@@ -111,25 +98,6 @@
     ],
 )
 
-cc_test(
-    name = "localizer_test",
-    srcs = ["localizer_test.cc"],
-    data = [":simulation_config"],
-    target_compatible_with = ["@platforms//os:linux"],
-    deps = [
-        ":drivetrain_base",
-        ":localizer",
-        "//aos/events:simulated_event_loop",
-        "//aos/events/logging:log_writer",
-        "//aos/network:team_number",
-        "//frc971/control_loops:control_loop_test",
-        "//frc971/control_loops:team_number_test_environment",
-        "//frc971/control_loops/drivetrain:drivetrain_lib",
-        "//frc971/control_loops/drivetrain:drivetrain_test_lib",
-        "//y2022/localizer:localizer_output_fbs",
-    ],
-)
-
 cc_binary(
     name = "trajectory_generator",
     srcs = [
diff --git a/y2022/control_loops/drivetrain/drivetrain_main.cc b/y2022/control_loops/drivetrain/drivetrain_main.cc
index a422eaa..6e02cc7 100644
--- a/y2022/control_loops/drivetrain/drivetrain_main.cc
+++ b/y2022/control_loops/drivetrain/drivetrain_main.cc
@@ -3,8 +3,8 @@
 #include "aos/events/shm_event_loop.h"
 #include "aos/init.h"
 #include "frc971/control_loops/drivetrain/drivetrain.h"
+#include "frc971/control_loops/drivetrain/localization/puppet_localizer.h"
 #include "y2022/control_loops/drivetrain/drivetrain_base.h"
-#include "y2022/control_loops/drivetrain/localizer.h"
 
 using ::frc971::control_loops::drivetrain::DrivetrainLoop;
 
@@ -15,10 +15,11 @@
       aos::configuration::ReadConfig("aos_config.json");
 
   aos::ShmEventLoop event_loop(&config.message());
-  std::unique_ptr<::y2022::control_loops::drivetrain::Localizer> localizer =
-      std::make_unique<y2022::control_loops::drivetrain::Localizer>(
-          &event_loop,
-          y2022::control_loops::drivetrain::GetDrivetrainConfig());
+  std::unique_ptr<::frc971::control_loops::drivetrain::PuppetLocalizer>
+      localizer =
+          std::make_unique<frc971::control_loops::drivetrain::PuppetLocalizer>(
+              &event_loop,
+              y2022::control_loops::drivetrain::GetDrivetrainConfig());
   std::unique_ptr<DrivetrainLoop> drivetrain = std::make_unique<DrivetrainLoop>(
       y2022::control_loops::drivetrain::GetDrivetrainConfig(), &event_loop,
       localizer.get());
diff --git a/y2022/control_loops/superstructure/BUILD b/y2022/control_loops/superstructure/BUILD
index 3bc1297..e0db64c 100644
--- a/y2022/control_loops/superstructure/BUILD
+++ b/y2022/control_loops/superstructure/BUILD
@@ -1,6 +1,6 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -193,14 +193,14 @@
         "//frc971/control_loops:control_loops_fbs",
         "//frc971/control_loops:profiled_subsystem_fbs",
         "//frc971/control_loops/drivetrain:drivetrain_output_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
         "//frc971/queues:gyro_fbs",
         "//third_party:phoenix",
         "//third_party:wpilib",
-        "//y2022/localizer:localizer_output_fbs",
     ],
 )
 
-ts_library(
+ts_project(
     name = "superstructure_plotter",
     srcs = ["superstructure_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -211,7 +211,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "catapult_plotter",
     srcs = ["catapult_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -222,7 +222,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "intake_plotter",
     srcs = ["intake_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -233,7 +233,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "turret_plotter",
     srcs = ["turret_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -244,7 +244,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "climber_plotter",
     srcs = ["climber_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/y2022/control_loops/superstructure/catapult_plotter.ts b/y2022/control_loops/superstructure/catapult_plotter.ts
index 90db40b..579aeff 100644
--- a/y2022/control_loops/superstructure/catapult_plotter.ts
+++ b/y2022/control_loops/superstructure/catapult_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, ORANGE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, ORANGE} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/y2022/control_loops/superstructure/climber_plotter.ts b/y2022/control_loops/superstructure/climber_plotter.ts
index e24a993..127eb5a 100644
--- a/y2022/control_loops/superstructure/climber_plotter.ts
+++ b/y2022/control_loops/superstructure/climber_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, ORANGE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, ORANGE} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/y2022/control_loops/superstructure/intake_plotter.ts b/y2022/control_loops/superstructure/intake_plotter.ts
index 8904d8b..d66964d 100644
--- a/y2022/control_loops/superstructure/intake_plotter.ts
+++ b/y2022/control_loops/superstructure/intake_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, ORANGE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, ORANGE} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/y2022/control_loops/superstructure/led_indicator.h b/y2022/control_loops/superstructure/led_indicator.h
index c058254..71bc73b 100644
--- a/y2022/control_loops/superstructure/led_indicator.h
+++ b/y2022/control_loops/superstructure/led_indicator.h
@@ -8,11 +8,11 @@
 #include "frc971/control_loops/control_loop.h"
 #include "frc971/control_loops/control_loops_generated.h"
 #include "frc971/control_loops/drivetrain/drivetrain_output_generated.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
 #include "frc971/control_loops/profiled_subsystem_generated.h"
 #include "frc971/queues/gyro_generated.h"
 #include "y2022/control_loops/superstructure/superstructure_output_generated.h"
 #include "y2022/control_loops/superstructure/superstructure_status_generated.h"
-#include "y2022/localizer/localizer_output_generated.h"
 
 namespace y2022::control_loops::superstructure {
 
diff --git a/y2022/control_loops/superstructure/superstructure_plotter.ts b/y2022/control_loops/superstructure/superstructure_plotter.ts
index 8590d7b..782353e 100644
--- a/y2022/control_loops/superstructure/superstructure_plotter.ts
+++ b/y2022/control_loops/superstructure/superstructure_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
+import * as proxy from '../../../aos/network/www/proxy';
 
 import Connection = proxy.Connection;
 
diff --git a/y2022/control_loops/superstructure/turret_plotter.ts b/y2022/control_loops/superstructure/turret_plotter.ts
index 6753880..c0b5b6f 100644
--- a/y2022/control_loops/superstructure/turret_plotter.ts
+++ b/y2022/control_loops/superstructure/turret_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, ORANGE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import * as proxy from '../../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE, ORANGE} from '../../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/y2022/localizer/BUILD b/y2022/localizer/BUILD
index 2a0a9fe..dd0fb67 100644
--- a/y2022/localizer/BUILD
+++ b/y2022/localizer/BUILD
@@ -1,9 +1,9 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
 load("//aos:flatbuffers.bzl", "cc_static_flatbuffer")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
-ts_library(
+ts_project(
     name = "localizer_plotter",
     srcs = ["localizer_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -17,33 +17,18 @@
 )
 
 flatbuffer_cc_library(
-    name = "localizer_output_fbs",
-    srcs = [
-        "localizer_output.fbs",
-    ],
-    gen_reflections = True,
-    target_compatible_with = ["@platforms//os:linux"],
-    visibility = ["//visibility:public"],
-)
-
-flatbuffer_ts_library(
-    name = "localizer_output_ts_fbs",
-    srcs = ["localizer_output.fbs"],
-    visibility = ["//visibility:public"],
-)
-
-flatbuffer_cc_library(
     name = "localizer_status_fbs",
     srcs = [
         "localizer_status.fbs",
     ],
     gen_reflections = True,
-    includes = [
-        "//frc971/control_loops:control_loops_fbs_includes",
-        "//frc971/control_loops/drivetrain:drivetrain_status_fbs_includes",
-    ],
     target_compatible_with = ["@platforms//os:linux"],
     visibility = ["//visibility:public"],
+    deps = [
+        "//frc971/control_loops:control_loops_fbs",
+        "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
+        "//frc971/imu_reader:imu_failures_fbs",
+    ],
 )
 
 flatbuffer_ts_library(
@@ -53,6 +38,7 @@
     deps = [
         "//frc971/control_loops:control_loops_ts_fbs",
         "//frc971/control_loops/drivetrain:drivetrain_status_ts_fbs",
+        "//frc971/imu_reader:imu_failures_ts_fbs",
     ],
 )
 
@@ -92,7 +78,6 @@
     hdrs = ["localizer.h"],
     visibility = ["//visibility:public"],
     deps = [
-        ":localizer_output_fbs",
         ":localizer_status_fbs",
         ":localizer_visualization_fbs",
         "//aos/containers:ring_buffer",
@@ -106,12 +91,13 @@
         "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
         "//frc971/control_loops/drivetrain:improved_down_estimator",
         "//frc971/control_loops/drivetrain:localizer_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
+        "//frc971/control_loops/drivetrain/localization:utils",
+        "//frc971/imu_reader:imu_watcher",
         "//frc971/input:joystick_state_fbs",
         "//frc971/vision:calibration_fbs",
         "//frc971/wpilib:imu_batch_fbs",
         "//frc971/wpilib:imu_fbs",
-        "//frc971/zeroing:imu_zeroer",
-        "//frc971/zeroing:wrap",
         "//y2022:constants",
         "//y2022/control_loops/superstructure:superstructure_status_fbs",
         "//y2022/vision:target_estimate_fbs",
diff --git a/y2022/localizer/localizer.cc b/y2022/localizer/localizer.cc
index 4b0588c..ec81573 100644
--- a/y2022/localizer/localizer.cc
+++ b/y2022/localizer/localizer.cc
@@ -12,8 +12,7 @@
 
 namespace {
 constexpr double kG = 9.80665;
-constexpr std::chrono::microseconds kNominalDt(500);
-
+static constexpr std::chrono::microseconds kNominalDt = ImuWatcher::kNominalDt;
 // Field position of the target (the 2022 target is conveniently in the middle
 // of the field....).
 constexpr double kVisionTargetX = 0.0;
@@ -480,21 +479,6 @@
 }
 
 namespace {
-// Converts a flatbuffer TransformationMatrix to an Eigen matrix. Technically,
-// this should be able to do a single memcpy, but the extra verbosity here seems
-// appropriate.
-Eigen::Matrix<double, 4, 4> FlatbufferToTransformationMatrix(
-    const frc971::vision::calibration::TransformationMatrix &flatbuffer) {
-  CHECK_EQ(16u, CHECK_NOTNULL(flatbuffer.data())->size());
-  Eigen::Matrix<double, 4, 4> result;
-  result.setIdentity();
-  for (int row = 0; row < 4; ++row) {
-    for (int col = 0; col < 4; ++col) {
-      result(row, col) = (*flatbuffer.data())[row * 4 + col];
-    }
-  }
-  return result;
-}
 
 // Node names of the pis to listen for cameras from.
 constexpr std::array<std::string_view, ModelBasedLocalizer::kNumPis> kPisToUse{
@@ -522,7 +506,8 @@
   }
   CHECK(calibration->has_fixed_extrinsics());
   const Eigen::Matrix<double, 4, 4> fixed_extrinsics =
-      FlatbufferToTransformationMatrix(*calibration->fixed_extrinsics());
+      control_loops::drivetrain::FlatbufferToTransformationMatrix(
+          *calibration->fixed_extrinsics());
 
   // Calculate the pose of the camera relative to the robot origin.
   Eigen::Matrix<double, 4, 4> H_robot_camera = fixed_extrinsics;
@@ -531,7 +516,8 @@
         H_robot_camera *
         frc971::control_loops::TransformationMatrixForYaw<double>(
             state.turret_position) *
-        FlatbufferToTransformationMatrix(*calibration->turret_extrinsics());
+        control_loops::drivetrain::FlatbufferToTransformationMatrix(
+            *calibration->turret_extrinsics());
   }
   return H_robot_camera;
 }
@@ -863,38 +849,29 @@
   }
 }
 
-namespace {
-// Period at which the encoder readings from the IMU board wrap.
-static double DrivetrainWrapPeriod() {
-  return y2022::constants::Values::DrivetrainEncoderToMeters(1 << 16);
-}
-}  // namespace
-
 EventLoopLocalizer::EventLoopLocalizer(
     aos::EventLoop *event_loop,
     const control_loops::drivetrain::DrivetrainConfig<double> &dt_config)
     : event_loop_(event_loop),
-      dt_config_(dt_config),
       model_based_(dt_config),
       status_sender_(event_loop_->MakeSender<LocalizerStatus>("/localizer")),
       output_sender_(event_loop_->MakeSender<LocalizerOutput>("/localizer")),
       visualization_sender_(
           event_loop_->MakeSender<LocalizerVisualization>("/localizer")),
-      output_fetcher_(
-          event_loop_->MakeFetcher<frc971::control_loops::drivetrain::Output>(
-              "/drivetrain")),
-      clock_offset_fetcher_(
-          event_loop_->MakeFetcher<aos::message_bridge::ServerStatistics>(
-              "/aos")),
       superstructure_fetcher_(
           event_loop_
               ->MakeFetcher<y2022::control_loops::superstructure::Status>(
                   "/superstructure")),
-      joystick_state_fetcher_(
-          event_loop_->MakeFetcher<aos::JoystickState>("/roborio/aos")),
-      zeroer_(zeroing::ImuZeroer::FaultBehavior::kTemporary),
-      left_encoder_(-DrivetrainWrapPeriod() / 2.0, DrivetrainWrapPeriod()),
-      right_encoder_(-DrivetrainWrapPeriod() / 2.0, DrivetrainWrapPeriod()) {
+      imu_watcher_(event_loop, dt_config,
+                   y2022::constants::Values::DrivetrainEncoderToMeters(1),
+                   [this](aos::monotonic_clock::time_point sample_time_pico,
+                          aos::monotonic_clock::time_point sample_time_pi,
+                          std::optional<Eigen::Vector2d> encoders,
+                          Eigen::Vector3d gyro, Eigen::Vector3d accel) {
+                     HandleImu(sample_time_pico, sample_time_pi, encoders, gyro,
+                               accel);
+                   }),
+      utils_(event_loop) {
   event_loop->SetRuntimeRealtimePriority(10);
   event_loop_->MakeWatcher(
       "/drivetrain",
@@ -932,10 +909,7 @@
             absl::StrCat("/", kPisToUse[camera_index], "/camera"));
   }
   aos::TimerHandler *estimate_timer = event_loop_->AddTimer([this]() {
-      joystick_state_fetcher_.Fetch();
-      const bool maybe_in_auto = (joystick_state_fetcher_.get() != nullptr)
-                                     ? joystick_state_fetcher_->autonomous()
-                                     : true;
+      const bool maybe_in_auto = utils_.MaybeInAutonomous();
       model_based_.set_use_aggressive_image_corrections(!maybe_in_auto);
       for (size_t camera_index = 0; camera_index < kPisToUse.size();
            ++camera_index) {
@@ -949,8 +923,10 @@
         }
         if (target_estimate_fetchers_[camera_index].Fetch()) {
           const std::optional<aos::monotonic_clock::duration> monotonic_offset =
-              ClockOffset(kPisToUse[camera_index]);
+              utils_.ClockOffset(kPisToUse[camera_index]);
           if (!monotonic_offset.has_value()) {
+            model_based_.TallyRejection(
+                RejectionReason::MESSAGE_BRIDGE_DISCONNECTED);
             continue;
           }
           // TODO(james): Get timestamp from message contents.
@@ -965,7 +941,7 @@
             model_based_.TallyRejection(RejectionReason::IMAGE_FROM_FUTURE);
             continue;
           }
-          capture_time -= pico_offset_error_;
+          capture_time -= imu_watcher_.pico_offset_error();
           model_based_.HandleImageMatch(
               capture_time, target_estimate_fetchers_[camera_index].get(),
               camera_index);
@@ -976,179 +952,74 @@
     estimate_timer->Setup(event_loop_->monotonic_now(),
                           std::chrono::milliseconds(100));
   });
-  event_loop_->MakeWatcher(
-      "/localizer", [this](const frc971::IMUValuesBatch &values) {
-        CHECK(values.has_readings());
-        output_fetcher_.Fetch();
-        for (const IMUValues *value : *values.readings()) {
-          zeroer_.InsertAndProcessMeasurement(*value);
-          if (zeroer_.Faulted()) {
-            if (value->checksum_failed()) {
-              imu_fault_tracker_.pico_to_pi_checksum_mismatch++;
-            } else if (value->previous_reading_diag_stat()->checksum_mismatch()) {
-              imu_fault_tracker_.imu_to_pico_checksum_mismatch++;
-            } else {
-              imu_fault_tracker_.other_zeroing_faults++;
-            }
-          } else {
-            if (!first_valid_data_counter_.has_value()) {
-              first_valid_data_counter_ = value->data_counter();
-            }
-          }
-          if (first_valid_data_counter_.has_value()) {
-            total_imu_messages_received_++;
-            // Only update when we have good checksums, since the data counter
-            // could get corrupted.
-            if (!zeroer_.Faulted()) {
-              if (value->data_counter() < last_data_counter_) {
-                data_counter_offset_ += 1 << 16;
-              }
-              imu_fault_tracker_.missed_messages =
-                  (1 + value->data_counter() + data_counter_offset_ -
-                   first_valid_data_counter_.value()) -
-                  total_imu_messages_received_;
-              last_data_counter_ = value->data_counter();
-            }
-          }
-          const std::optional<Eigen::Vector2d> encoders =
-              zeroer_.Faulted()
-                  ? std::nullopt
-                  : std::make_optional(Eigen::Vector2d{
-                        left_encoder_.Unwrap(value->left_encoder()),
-                        right_encoder_.Unwrap(value->right_encoder())});
-          {
-            // If we can't trust the imu reading, just naively increment the
-            // pico timestamp.
-            const aos::monotonic_clock::time_point pico_timestamp =
-                zeroer_.Faulted()
-                    ? (last_pico_timestamp_.has_value()
-                           ? last_pico_timestamp_.value() + kNominalDt
-                           : aos::monotonic_clock::epoch())
-                    : aos::monotonic_clock::time_point(
-                          std::chrono::microseconds(
-                              value->pico_timestamp_us()));
-            // TODO(james): If we get large enough drift off of the pico,
-            // actually do something about it.
-            if (!pico_offset_.has_value()) {
-              pico_offset_ =
-                  event_loop_->context().monotonic_event_time - pico_timestamp;
-              last_pico_timestamp_ = pico_timestamp;
-            }
-            if (pico_timestamp < last_pico_timestamp_) {
-              pico_offset_.value() += std::chrono::microseconds(1ULL << 32);
-            }
-            const aos::monotonic_clock::time_point sample_timestamp =
-                pico_offset_.value() + pico_timestamp;
-            pico_offset_error_ =
-                event_loop_->context().monotonic_event_time - sample_timestamp;
-            const bool disabled =
-                (output_fetcher_.get() == nullptr) ||
-                (output_fetcher_.context().monotonic_event_time +
-                     std::chrono::milliseconds(10) <
-                 event_loop_->context().monotonic_event_time);
-            const bool zeroed = zeroer_.Zeroed();
-            // For gyros, use the most recent gyro reading if we aren't zeroed,
-            // to avoid missing integration cycles.
-            model_based_.HandleImu(
-                sample_timestamp,
-                zeroed ? zeroer_.ZeroedGyro().value() : last_gyro_,
-                zeroed ? zeroer_.ZeroedAccel().value()
-                       : dt_config_.imu_transform.transpose() *
-                             Eigen::Vector3d::UnitZ(),
-                encoders,
-                disabled ? Eigen::Vector2d::Zero()
-                         : Eigen::Vector2d{output_fetcher_->left_voltage(),
-                                           output_fetcher_->right_voltage()});
-            last_pico_timestamp_ = pico_timestamp;
-
-            if (zeroed) {
-              last_gyro_ = zeroer_.ZeroedGyro().value();
-            }
-          }
-          {
-            auto builder = status_sender_.MakeBuilder();
-            const flatbuffers::Offset<ModelBasedStatus> model_based_status =
-                model_based_.PopulateStatus(builder.fbb());
-            const flatbuffers::Offset<control_loops::drivetrain::ImuZeroerState>
-                zeroer_status = zeroer_.PopulateStatus(builder.fbb());
-            const flatbuffers::Offset<ImuFailures> imu_failures =
-                ImuFailures::Pack(*builder.fbb(), &imu_fault_tracker_);
-            LocalizerStatus::Builder status_builder =
-                builder.MakeBuilder<LocalizerStatus>();
-            status_builder.add_model_based(model_based_status);
-            status_builder.add_zeroed(zeroer_.Zeroed());
-            status_builder.add_faulted_zero(zeroer_.Faulted());
-            status_builder.add_zeroing(zeroer_status);
-            status_builder.add_imu_failures(imu_failures);
-            if (encoders.has_value()) {
-              status_builder.add_left_encoder(encoders.value()(0));
-              status_builder.add_right_encoder(encoders.value()(1));
-            }
-            if (pico_offset_.has_value()) {
-              status_builder.add_pico_offset_ns(pico_offset_.value().count());
-              status_builder.add_pico_offset_error_ns(
-                  pico_offset_error_.count());
-            }
-            builder.CheckOk(builder.Send(status_builder.Finish()));
-          }
-          if (last_output_send_ + std::chrono::milliseconds(5) <
-              event_loop_->context().monotonic_event_time) {
-            auto builder = output_sender_.MakeBuilder();
-
-            const auto led_outputs_offset =
-                builder.fbb()->CreateVector(model_based_.led_outputs().data(),
-                                            model_based_.led_outputs().size());
-
-            LocalizerOutput::Builder output_builder =
-                builder.MakeBuilder<LocalizerOutput>();
-            // TODO(james): Should we bother to try to estimate time offsets for
-            // the pico?
-            output_builder.add_monotonic_timestamp_ns(
-                value->monotonic_timestamp_ns());
-            output_builder.add_x(model_based_.xytheta()(0));
-            output_builder.add_y(model_based_.xytheta()(1));
-            output_builder.add_theta(model_based_.xytheta()(2));
-            output_builder.add_zeroed(zeroer_.Zeroed());
-            output_builder.add_image_accepted_count(model_based_.total_accepted());
-            const Eigen::Quaterniond &orientation = model_based_.orientation();
-            Quaternion quaternion;
-            quaternion.mutate_x(orientation.x());
-            quaternion.mutate_y(orientation.y());
-            quaternion.mutate_z(orientation.z());
-            quaternion.mutate_w(orientation.w());
-            output_builder.add_orientation(&quaternion);
-            output_builder.add_led_outputs(led_outputs_offset);
-            builder.CheckOk(builder.Send(output_builder.Finish()));
-            last_output_send_ = event_loop_->monotonic_now();
-          }
-        }
-      });
 }
 
-std::optional<aos::monotonic_clock::duration> EventLoopLocalizer::ClockOffset(
-    std::string_view pi) {
-  std::optional<aos::monotonic_clock::duration> monotonic_offset;
-  clock_offset_fetcher_.Fetch();
-  if (clock_offset_fetcher_.get() != nullptr) {
-    for (const auto connection : *clock_offset_fetcher_->connections()) {
-      if (connection->has_node() && connection->node()->has_name() &&
-          connection->node()->name()->string_view() == pi) {
-        if (connection->has_monotonic_offset()) {
-          monotonic_offset =
-              std::chrono::nanoseconds(connection->monotonic_offset());
-        } else {
-          // If we don't have a monotonic offset, that means we aren't
-          // connected.
-          model_based_.TallyRejection(
-              RejectionReason::MESSAGE_BRIDGE_DISCONNECTED);
-          return std::nullopt;
-        }
-        break;
-      }
+void EventLoopLocalizer::HandleImu(
+    aos::monotonic_clock::time_point sample_time_pico,
+    aos::monotonic_clock::time_point sample_time_pi,
+    std::optional<Eigen::Vector2d> encoders, Eigen::Vector3d gyro,
+    Eigen::Vector3d accel) {
+
+  model_based_.HandleImu(
+      sample_time_pico, gyro, accel, encoders,
+      utils_.VoltageOrZero(event_loop_->context().monotonic_event_time));
+
+  {
+    auto builder = status_sender_.MakeBuilder();
+    const flatbuffers::Offset<ModelBasedStatus> model_based_status =
+        model_based_.PopulateStatus(builder.fbb());
+    const flatbuffers::Offset<control_loops::drivetrain::ImuZeroerState>
+        zeroer_status = imu_watcher_.zeroer().PopulateStatus(builder.fbb());
+    const flatbuffers::Offset<ImuFailures> imu_failures =
+        imu_watcher_.PopulateImuFailures(builder.fbb());
+    LocalizerStatus::Builder status_builder =
+        builder.MakeBuilder<LocalizerStatus>();
+    status_builder.add_model_based(model_based_status);
+    status_builder.add_zeroed(imu_watcher_.zeroer().Zeroed());
+    status_builder.add_faulted_zero(imu_watcher_.zeroer().Faulted());
+    status_builder.add_zeroing(zeroer_status);
+    status_builder.add_imu_failures(imu_failures);
+    if (encoders.has_value()) {
+      status_builder.add_left_encoder(encoders.value()(0));
+      status_builder.add_right_encoder(encoders.value()(1));
     }
+    if (imu_watcher_.pico_offset().has_value()) {
+      status_builder.add_pico_offset_ns(
+          imu_watcher_.pico_offset().value().count());
+      status_builder.add_pico_offset_error_ns(
+          imu_watcher_.pico_offset_error().count());
+    }
+    builder.CheckOk(builder.Send(status_builder.Finish()));
   }
-  CHECK(monotonic_offset.has_value());
-  return monotonic_offset;
+  if (last_output_send_ + std::chrono::milliseconds(5) <
+      event_loop_->context().monotonic_event_time) {
+    auto builder = output_sender_.MakeBuilder();
+
+    const auto led_outputs_offset = builder.fbb()->CreateVector(
+        model_based_.led_outputs().data(), model_based_.led_outputs().size());
+
+    LocalizerOutput::Builder output_builder =
+        builder.MakeBuilder<LocalizerOutput>();
+    output_builder.add_monotonic_timestamp_ns(
+        std::chrono::duration_cast<std::chrono::nanoseconds>(
+            sample_time_pi.time_since_epoch())
+            .count());
+    output_builder.add_x(model_based_.xytheta()(0));
+    output_builder.add_y(model_based_.xytheta()(1));
+    output_builder.add_theta(model_based_.xytheta()(2));
+    output_builder.add_zeroed(imu_watcher_.zeroer().Zeroed());
+    output_builder.add_image_accepted_count(model_based_.total_accepted());
+    const Eigen::Quaterniond &orientation = model_based_.orientation();
+    Quaternion quaternion;
+    quaternion.mutate_x(orientation.x());
+    quaternion.mutate_y(orientation.y());
+    quaternion.mutate_z(orientation.z());
+    quaternion.mutate_w(orientation.w());
+    output_builder.add_orientation(&quaternion);
+    output_builder.add_led_outputs(led_outputs_offset);
+    builder.CheckOk(builder.Send(output_builder.Finish()));
+    last_output_send_ = event_loop_->monotonic_now();
+  }
 }
 
 }  // namespace frc971::controls
diff --git a/y2022/localizer/localizer.h b/y2022/localizer/localizer.h
index fc15e9f..59ad75c 100644
--- a/y2022/localizer/localizer.h
+++ b/y2022/localizer/localizer.h
@@ -9,13 +9,15 @@
 #include "aos/network/message_bridge_server_generated.h"
 #include "aos/time/time.h"
 #include "frc971/control_loops/drivetrain/drivetrain_output_generated.h"
-#include "frc971/input/joystick_state_generated.h"
 #include "frc971/control_loops/drivetrain/improved_down_estimator.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
+#include "frc971/control_loops/drivetrain/localization/utils.h"
 #include "frc971/control_loops/drivetrain/localizer_generated.h"
+#include "frc971/imu_reader/imu_watcher.h"
+#include "frc971/input/joystick_state_generated.h"
 #include "frc971/zeroing/imu_zeroer.h"
 #include "frc971/zeroing/wrap.h"
 #include "y2022/control_loops/superstructure/superstructure_status_generated.h"
-#include "y2022/localizer/localizer_output_generated.h"
 #include "y2022/localizer/localizer_status_generated.h"
 #include "y2022/localizer/localizer_visualization_generated.h"
 #include "y2022/vision/target_estimate_generated.h"
@@ -58,10 +60,6 @@
 // until the branches stop diverging--this will indicate that the model
 // matches the accelerometer readings again, and so we will swap back to
 // the model-based state.
-//
-// TODO:
-// * Implement paying attention to camera readings.
-// * Tune for ADIS16505/real robot.
 class ModelBasedLocalizer {
  public:
   static constexpr size_t kNumPis = 4;
@@ -329,43 +327,27 @@
   ModelBasedLocalizer *localizer() { return &model_based_; }
 
  private:
-  std::optional<aos::monotonic_clock::duration> ClockOffset(
-      std::string_view pi);
+  void HandleImu(aos::monotonic_clock::time_point sample_time_pico,
+                 aos::monotonic_clock::time_point sample_time_pi,
+                 std::optional<Eigen::Vector2d> encoders, Eigen::Vector3d gyro,
+                 Eigen::Vector3d accel);
   aos::EventLoop *event_loop_;
-  const control_loops::drivetrain::DrivetrainConfig<double> &dt_config_;
   ModelBasedLocalizer model_based_;
   aos::Sender<LocalizerStatus> status_sender_;
   aos::Sender<LocalizerOutput> output_sender_;
   aos::Sender<LocalizerVisualization> visualization_sender_;
-  aos::Fetcher<frc971::control_loops::drivetrain::Output> output_fetcher_;
-  aos::Fetcher<aos::message_bridge::ServerStatistics> clock_offset_fetcher_;
   std::array<aos::Fetcher<y2022::vision::TargetEstimate>,
              ModelBasedLocalizer::kNumPis>
       target_estimate_fetchers_;
   aos::Fetcher<y2022::control_loops::superstructure::Status>
       superstructure_fetcher_;
-  aos::Fetcher<aos::JoystickState> joystick_state_fetcher_;
-  zeroing::ImuZeroer zeroer_;
   aos::monotonic_clock::time_point last_output_send_ =
       aos::monotonic_clock::min_time;
   aos::monotonic_clock::time_point last_visualization_send_ =
       aos::monotonic_clock::min_time;
-  std::optional<aos::monotonic_clock::time_point> last_pico_timestamp_;
-  aos::monotonic_clock::duration pico_offset_error_;
-  // t = pico_offset_ + pico_timestamp.
-  // Note that this can drift over sufficiently long time periods!
-  std::optional<std::chrono::nanoseconds> pico_offset_;
 
-  ImuFailuresT imu_fault_tracker_;
-  std::optional<size_t> first_valid_data_counter_;
-  size_t total_imu_messages_received_ = 0;
-  size_t data_counter_offset_ = 0;
-  int last_data_counter_ = 0;
-
-  Eigen::Vector3d last_gyro_ = Eigen::Vector3d::Zero();
-
-  zeroing::UnwrapSensor left_encoder_;
-  zeroing::UnwrapSensor right_encoder_;
+  ImuWatcher imu_watcher_;
+  control_loops::drivetrain::LocalizationUtils utils_;
 };
 }  // namespace frc971::controls
 #endif  // Y2022_LOCALIZER_LOCALIZER_H_
diff --git a/y2022/localizer/localizer_plotter.ts b/y2022/localizer/localizer_plotter.ts
index bcd8894..8344eb8 100644
--- a/y2022/localizer/localizer_plotter.ts
+++ b/y2022/localizer/localizer_plotter.ts
@@ -1,8 +1,8 @@
 // Provides a plot for debugging drivetrain-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {ImuMessageHandler} from 'org_frc971/frc971/wpilib/imu_plot_utils';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
+import {AosPlotter} from '../../aos/network/www/aos_plotter';
+import {ImuMessageHandler} from '../../frc971/wpilib/imu_plot_utils';
+import * as proxy from '../../aos/network/www/proxy';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../aos/network/www/colors';
 
 import Connection = proxy.Connection;
 
diff --git a/y2022/localizer/localizer_status.fbs b/y2022/localizer/localizer_status.fbs
index 05cfc73..9e5081d 100644
--- a/y2022/localizer/localizer_status.fbs
+++ b/y2022/localizer/localizer_status.fbs
@@ -1,4 +1,5 @@
 include "frc971/control_loops/drivetrain/drivetrain_status.fbs";
+include "frc971/imu_reader/imu_failures.fbs";
 
 namespace frc971.controls;
 
@@ -92,13 +93,6 @@
   statistics:CumulativeStatistics (id: 18);
 }
 
-table ImuFailures {
-  imu_to_pico_checksum_mismatch:uint (id: 0);
-  pico_to_pi_checksum_mismatch:uint (id: 1);
-  missed_messages:uint (id: 2);
-  other_zeroing_faults:uint (id: 3);
-}
-
 table LocalizerStatus {
   model_based:ModelBasedStatus (id: 0);
   // Whether the IMU is zeroed or not.
diff --git a/y2022/vision/BUILD b/y2022/vision/BUILD
index 31644e4..99fb95d 100644
--- a/y2022/vision/BUILD
+++ b/y2022/vision/BUILD
@@ -1,8 +1,8 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
-ts_library(
+ts_project(
     name = "vision_plotter",
     srcs = ["vision_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
@@ -98,11 +98,11 @@
         "//aos/events:event_loop",
         "//aos/events:shm_event_loop",
         "//aos/network:team_number",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
         "//frc971/vision:calibration_fbs",
         "//frc971/vision:v4l2_reader",
         "//frc971/vision:vision_fbs",
         "//third_party:opencv",
-        "//y2022/localizer:localizer_output_fbs",
     ],
 )
 
diff --git a/y2022/vision/camera_reader.h b/y2022/vision/camera_reader.h
index c2d4f5f..7f07704 100644
--- a/y2022/vision/camera_reader.h
+++ b/y2022/vision/camera_reader.h
@@ -11,10 +11,10 @@
 #include "aos/events/shm_event_loop.h"
 #include "aos/flatbuffer_merge.h"
 #include "aos/network/team_number.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
 #include "frc971/vision/calibration_generated.h"
 #include "frc971/vision/v4l2_reader.h"
 #include "frc971/vision/vision_generated.h"
-#include "y2022/localizer/localizer_output_generated.h"
 #include "y2022/vision/calibration_data.h"
 #include "y2022/vision/gpio.h"
 #include "y2022/vision/target_estimate_generated.h"
diff --git a/y2022/vision/vision_plotter.ts b/y2022/vision/vision_plotter.ts
index adbf900..d557a36 100644
--- a/y2022/vision/vision_plotter.ts
+++ b/y2022/vision/vision_plotter.ts
@@ -1,11 +1,11 @@
 import {ByteBuffer} from 'flatbuffers';
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {MessageHandler, TimestampedMessage} from 'org_frc971/aos/network/www/aos_plotter';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
-import {Table} from 'org_frc971/aos/network/www/reflection';
+import {AosPlotter} from '../../aos/network/www/aos_plotter';
+import {MessageHandler, TimestampedMessage} from '../../aos/network/www/aos_plotter';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../aos/network/www/colors';
+import {Connection} from '../../aos/network/www/proxy';
+import {Table} from '../../aos/network/www/reflection';
 import {Schema} from 'flatbuffers_reflection/reflection_generated';
-import {TargetEstimate} from 'org_frc971/y2022/vision/target_estimate_generated';
+import {TargetEstimate} from './target_estimate_generated';
 
 
 const TIME = AosPlotter.TIME;
diff --git a/y2022/www/BUILD b/y2022/www/BUILD
index 957045d..e806404 100644
--- a/y2022/www/BUILD
+++ b/y2022/www/BUILD
@@ -1,5 +1,4 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
 load("//frc971/downloader:downloader.bzl", "aos_downloader_dir")
 
 filegroup(
@@ -12,7 +11,7 @@
     visibility = ["//visibility:public"],
 )
 
-ts_library(
+ts_project(
     name = "field_main",
     srcs = [
         "constants.ts",
@@ -25,8 +24,8 @@
         "//aos/network:web_proxy_ts_fbs",
         "//aos/network/www:proxy",
         "//frc971/control_loops/drivetrain:drivetrain_status_ts_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_ts_fbs",
         "//y2022/control_loops/superstructure:superstructure_status_ts_fbs",
-        "//y2022/localizer:localizer_output_ts_fbs",
         "//y2022/localizer:localizer_status_ts_fbs",
         "//y2022/localizer:localizer_visualization_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
diff --git a/y2022/www/field_handler.ts b/y2022/www/field_handler.ts
index ec25607..d20637f 100644
--- a/y2022/www/field_handler.ts
+++ b/y2022/www/field_handler.ts
@@ -1,7 +1,7 @@
 import {ByteBuffer} from 'flatbuffers';
 import {Connection} from '../../aos/network/www/proxy';
 import {IntakeState, Status as SuperstructureStatus, SuperstructureState} from '../control_loops/superstructure/superstructure_status_generated'
-import {LocalizerOutput} from '../localizer/localizer_output_generated';
+import {LocalizerOutput} from '../../frc971/control_loops/drivetrain/localization/localizer_output_generated';
 import {RejectionReason} from '../localizer/localizer_status_generated';
 import {Status as DrivetrainStatus} from '../../frc971/control_loops/drivetrain/drivetrain_status_generated';
 import {LocalizerVisualization, TargetEstimateDebug} from '../localizer/localizer_visualization_generated';
diff --git a/y2022/www/field_main.ts b/y2022/www/field_main.ts
index 7e2e392..d71a45e 100644
--- a/y2022/www/field_main.ts
+++ b/y2022/www/field_main.ts
@@ -1,4 +1,4 @@
-import {Connection} from 'org_frc971/aos/network/www/proxy';
+import {Connection} from '../../aos/network/www/proxy';
 
 import {FieldHandler} from './field_handler';
 
diff --git a/y2023/BUILD b/y2023/BUILD
index f5a3d57..1c4ef71 100644
--- a/y2023/BUILD
+++ b/y2023/BUILD
@@ -3,9 +3,41 @@
 load("//tools/build_rules:template.bzl", "jinja2_template")
 
 robot_downloader(
+    binaries = [
+        "//aos/network:web_proxy_main",
+        "//aos/events/logging:log_cat",
+    ],
+    data = [
+        ":aos_config",
+        "//aos/starter:roborio_irq_config.json",
+        "@ctre_phoenix_api_cpp_athena//:shared_libraries",
+        "@ctre_phoenix_cci_athena//:shared_libraries",
+        "@ctre_phoenixpro_api_cpp_athena//:shared_libraries",
+        "@ctre_phoenixpro_tools_athena//:shared_libraries",
+    ],
+    dirs = [
+        "//y2023/www:www_files",
+    ],
+    start_binaries = [
+        "//aos/events/logging:logger_main",
+        "//aos/network:web_proxy_main",
+        "//aos/starter:irq_affinity",
+        ":joystick_reader",
+        ":wpilib_interface",
+        "//aos/network:message_bridge_client",
+        "//aos/network:message_bridge_server",
+        "//y2023/control_loops/drivetrain:drivetrain",
+        "//y2023/control_loops/drivetrain:trajectory_generator",
+        "//y2023/control_loops/superstructure:superstructure",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
+robot_downloader(
     name = "pi_download",
     binaries = [
         "//frc971/vision:intrinsics_calibration",
+        "//aos/starter:irq_affinity",
         "//aos/util:foxglove_websocket",
         "//y2023/vision:viewer",
         "//y2023/vision:aprilrobotics",
@@ -19,6 +51,7 @@
     ],
     data = [
         ":aos_config",
+        "//frc971/rockpi:rockpi_config.json",
         "//y2023/constants:constants.json",
         "//y2023/vision:image_streamer_start",
         "//y2023/www:www_files",
@@ -73,7 +106,7 @@
             "//aos/network:timestamp_fbs",
             "//aos/network:remote_message_fbs",
             "//y2023/constants:constants_fbs",
-            "//y2022/localizer:localizer_output_fbs",
+            "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
             "//frc971/vision:calibration_fbs",
             "//frc971/vision:target_map_fbs",
             "//frc971/vision:vision_fbs",
@@ -106,7 +139,7 @@
         "//aos/network:timestamp_fbs",
         "//aos/network:remote_message_fbs",
         "//y2022/localizer:localizer_status_fbs",
-        "//y2022/localizer:localizer_output_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
         "//y2022/localizer:localizer_visualization_fbs",
         "//frc971/vision:target_map_fbs",
     ],
@@ -151,6 +184,7 @@
         "//aos/network:timestamp_fbs",
         "//y2019/control_loops/drivetrain:target_selector_fbs",
         "//y2023/control_loops/superstructure:superstructure_goal_fbs",
+        "//y2023/control_loops/drivetrain:drivetrain_can_position_fbs",
         "//y2023/control_loops/superstructure:superstructure_output_fbs",
         "//y2023/control_loops/superstructure:superstructure_position_fbs",
         "//y2023/control_loops/superstructure:superstructure_status_fbs",
@@ -192,6 +226,9 @@
         "//frc971/control_loops:static_zeroing_single_dof_profiled_subsystem",
         "//frc971/shooter_interpolation:interpolation",
         "//y2023/control_loops/drivetrain:polydrivetrain_plants",
+        "//y2023/control_loops/superstructure/arm:arm_constants",
+        "//y2023/control_loops/superstructure/roll:roll_plants",
+        "//y2023/control_loops/superstructure/wrist:wrist_plants",
         "@com_github_google_glog//:glog",
         "@com_google_absl//absl/base",
     ],
@@ -207,6 +244,7 @@
         ":constants",
         "//aos:init",
         "//aos:math",
+        "//aos/containers:sized_array",
         "//aos/events:shm_event_loop",
         "//aos/logging",
         "//aos/stl_mutex",
@@ -233,7 +271,9 @@
         "//frc971/wpilib:wpilib_interface",
         "//frc971/wpilib:wpilib_robot_base",
         "//third_party:phoenix",
+        "//third_party:phoenixpro",
         "//third_party:wpilib",
+        "//y2023/control_loops/drivetrain:drivetrain_can_position_fbs",
         "//y2023/control_loops/superstructure:superstructure_output_fbs",
         "//y2023/control_loops/superstructure:superstructure_position_fbs",
     ],
@@ -258,6 +298,7 @@
         "//y2023/control_loops/drivetrain:drivetrain_base",
         "//y2023/control_loops/superstructure:superstructure_goal_fbs",
         "//y2023/control_loops/superstructure:superstructure_status_fbs",
+        "//y2023/control_loops/superstructure/arm:generated_graph",
     ],
 )
 
diff --git a/y2023/constants.cc b/y2023/constants.cc
index 843af2b..e2e8522 100644
--- a/y2023/constants.cc
+++ b/y2023/constants.cc
@@ -11,33 +11,124 @@
 #include "aos/mutex/mutex.h"
 #include "aos/network/team_number.h"
 #include "glog/logging.h"
+#include "y2023/control_loops/superstructure/roll/integral_roll_plant.h"
+#include "y2023/control_loops/superstructure/wrist/integral_wrist_plant.h"
 
 namespace y2023 {
 namespace constants {
 
-const int Values::kZeroingSampleSize;
-
 Values MakeValues(uint16_t team) {
   LOG(INFO) << "creating a Constants for team: " << team;
 
   Values r;
+  auto *const arm_proximal = &r.arm_proximal;
+  auto *const arm_distal = &r.arm_distal;
+  auto *const wrist = &r.wrist;
+  auto *const roll_joint = &r.roll_joint;
+
+  arm_proximal->zeroing.average_filter_size = Values::kZeroingSampleSize;
+  arm_proximal->zeroing.one_revolution_distance =
+      M_PI * 2.0 * constants::Values::kProximalEncoderRatio();
+  arm_proximal->zeroing.zeroing_threshold = 0.0005;
+  arm_proximal->zeroing.moving_buffer_size = 20;
+  arm_proximal->zeroing.allowable_encoder_error = 0.9;
+
+  arm_distal->zeroing.average_filter_size = Values::kZeroingSampleSize;
+  arm_distal->zeroing.one_revolution_distance =
+      M_PI * 2.0 * constants::Values::kDistalEncoderRatio();
+  arm_distal->zeroing.zeroing_threshold = 0.0005;
+  arm_distal->zeroing.moving_buffer_size = 20;
+  arm_distal->zeroing.allowable_encoder_error = 0.9;
+
+  roll_joint->zeroing.average_filter_size = Values::kZeroingSampleSize;
+  roll_joint->zeroing.one_revolution_distance =
+      M_PI * 2.0 * constants::Values::kRollJointEncoderRatio();
+  roll_joint->zeroing.zeroing_threshold = 0.0005;
+  roll_joint->zeroing.moving_buffer_size = 20;
+  roll_joint->zeroing.allowable_encoder_error = 0.9;
+
+  wrist->zeroing_voltage = 3.0;
+  wrist->operating_voltage = 12.0;
+  wrist->zeroing_profile_params = {0.5, 3.0};
+  wrist->default_profile_params = {6.0, 30.0};
+  wrist->range = Values::kWristRange();
+  wrist->make_integral_loop =
+      control_loops::superstructure::wrist::MakeIntegralWristLoop;
+  wrist->zeroing_constants.average_filter_size = Values::kZeroingSampleSize;
+  wrist->zeroing_constants.one_revolution_distance =
+      M_PI * 2.0 * constants::Values::kWristEncoderRatio();
+  wrist->zeroing_constants.zeroing_threshold = 0.0005;
+  wrist->zeroing_constants.moving_buffer_size = 20;
+  wrist->zeroing_constants.allowable_encoder_error = 0.9;
+  wrist->zeroing_constants.middle_position = Values::kWristRange().middle();
 
   switch (team) {
     // A set of constants for tests.
     case 1:
+      arm_proximal->zeroing.measured_absolute_position = 0.0;
+      arm_proximal->potentiometer_offset = 0.0;
+
+      arm_distal->zeroing.measured_absolute_position = 0.0;
+      arm_distal->potentiometer_offset = 0.0;
+
+      roll_joint->zeroing.measured_absolute_position = 0.0;
+      roll_joint->potentiometer_offset = 0.0;
+
+      wrist->zeroing_constants.measured_absolute_position = 0.0;
+
       break;
 
     case kCompTeamNumber:
+      arm_proximal->zeroing.measured_absolute_position = 0.908708932890508;
+      arm_proximal->potentiometer_offset = 0.931355973012855 + 8.6743197253382;
+
+      arm_distal->zeroing.measured_absolute_position = 0.560320674747198;
+      arm_distal->potentiometer_offset = 0.436664933370656 + 0.49457213779426 +
+                                         6.78213223139724 - 0.0220711555235029 -
+                                         0.0162945074111813;
+
+      roll_joint->zeroing.measured_absolute_position = 0.779367181558787;
+      roll_joint->potentiometer_offset =
+          3.87038557084874 - 0.0241774522172967 + 0.0711345168020632 -
+          0.866186131631967 - 0.0256788357596952 + 0.18101759154572017 -
+          0.0208958996127179;
+
+      wrist->zeroing_constants.measured_absolute_position = 0.0;
+
       break;
 
     case kPracticeTeamNumber:
+      arm_proximal->zeroing.measured_absolute_position = 0.0;
+      arm_proximal->potentiometer_offset = 0.0;
+
+      arm_distal->zeroing.measured_absolute_position = 0.0;
+      arm_distal->potentiometer_offset = 0.0;
+
+      roll_joint->zeroing.measured_absolute_position = 0.0;
+      roll_joint->potentiometer_offset = 0.0;
+
+      wrist->zeroing_constants.measured_absolute_position = 0.0;
+
       break;
 
     case kCodingRobotTeamNumber:
+      arm_proximal->zeroing.measured_absolute_position = 0.0;
+      arm_proximal->potentiometer_offset = 0.0;
+
+      arm_distal->zeroing.measured_absolute_position = 0.0;
+      arm_distal->potentiometer_offset = 0.0;
+
+      roll_joint->zeroing.measured_absolute_position = 0.0;
+      roll_joint->potentiometer_offset = 0.0;
+
+      wrist->zeroing_constants.measured_absolute_position = 0.0;
+
       break;
 
     default:
       LOG(FATAL) << "unknown team: " << team;
+
+      // TODO(milind): add pot range checks once we add ranges
   }
 
   return r;
diff --git a/y2023/constants.h b/y2023/constants.h
index 03a6cae..598a7d3 100644
--- a/y2023/constants.h
+++ b/y2023/constants.h
@@ -9,6 +9,9 @@
 #include "frc971/control_loops/pose.h"
 #include "frc971/control_loops/static_zeroing_single_dof_profiled_subsystem.h"
 #include "y2023/control_loops/drivetrain/drivetrain_dog_motor_plant.h"
+#include "y2023/control_loops/superstructure/arm/arm_constants.h"
+#include "y2023/control_loops/superstructure/roll/roll_plant.h"
+#include "y2023/control_loops/superstructure/wrist/wrist_plant.h"
 
 namespace y2023 {
 namespace constants {
@@ -20,6 +23,10 @@
 struct Values {
   static const int kZeroingSampleSize = 200;
 
+  static const int kDrivetrainWriterPriority = 35;
+  static const int kDrivetrainTxPriority = 36;
+  static const int kDrivetrainRxPriority = 36;
+
   static constexpr double kDrivetrainCyclesPerRevolution() { return 512.0; }
   static constexpr double kDrivetrainEncoderCountsPerRevolution() {
     return kDrivetrainCyclesPerRevolution() * 4;
@@ -32,6 +39,9 @@
            kDrivetrainEncoderCountsPerRevolution();
   }
 
+  static constexpr double kDrivetrainSupplyCurrentLimit() { return 35.0; }
+  static constexpr double kDrivetrainStatorCurrentLimit() { return 40.0; }
+
   static double DrivetrainEncoderToMeters(int32_t in) {
     return ((static_cast<double>(in) /
              kDrivetrainEncoderCountsPerRevolution()) *
@@ -39,6 +49,100 @@
            kDrivetrainEncoderRatio() * control_loops::drivetrain::kWheelRadius;
   }
 
+  static double DrivetrainCANEncoderToMeters(double rotations) {
+    return (rotations * (2.0 * M_PI)) *
+           control_loops::drivetrain::kHighOutputRatio *
+           control_loops::drivetrain::kWheelRadius;
+  }
+  static constexpr double kProximalEncoderCountsPerRevolution() {
+    return 4096.0;
+  }
+  static constexpr double kProximalEncoderRatio() { return (15.0 / 95.0); }
+  static constexpr double kMaxProximalEncoderPulsesPerSecond() {
+    return control_loops::superstructure::arm::kArmConstants.free_speed /
+           (2.0 * M_PI) / control_loops::superstructure::arm::kArmConstants.g0 /
+           kProximalEncoderRatio() * kProximalEncoderCountsPerRevolution();
+  }
+  static constexpr double kProximalPotRatio() {
+    return (36.0 / 24.0) * (15.0 / 95.0);
+  }
+
+  static constexpr double kProximalPotRadiansPerVolt() {
+    return kProximalPotRatio() * (10.0 /*turns*/ / 5.0 /*volts*/) *
+           (2 * M_PI /*radians*/);
+  }
+
+  static constexpr double kDistalEncoderCountsPerRevolution() { return 4096.0; }
+  static constexpr double kDistalEncoderRatio() { return (15.0 / 95.0); }
+  static constexpr double kMaxDistalEncoderPulsesPerSecond() {
+    return control_loops::superstructure::arm::kArmConstants.free_speed /
+           (2.0 * M_PI) / control_loops::superstructure::arm::kArmConstants.g1 /
+           kDistalEncoderRatio() * kProximalEncoderCountsPerRevolution();
+  }
+  static constexpr double kDistalPotRatio() {
+    return (36.0 / 24.0) * (15.0 / 95.0);
+  }
+
+  static constexpr double kDistalPotRadiansPerVolt() {
+    return kDistalPotRatio() * (10.0 /*turns*/ / 5.0 /*volts*/) *
+           (2 * M_PI /*radians*/);
+  }
+
+  // Roll joint
+  static constexpr double kRollJointEncoderCountsPerRevolution() {
+    return 4096.0;
+  }
+
+  static constexpr double kRollJointEncoderRatio() { return (18.0 / 48.0); }
+
+  static constexpr double kRollJointPotRatio() { return (18.0 / 48.0); }
+
+  static constexpr double kRollJointPotRadiansPerVolt() {
+    return kRollJointPotRatio() * (3.0 /*turns*/ / 5.0 /*volts*/) *
+           (2 * M_PI /*radians*/);
+  }
+
+  static constexpr double kMaxRollJointEncoderPulsesPerSecond() {
+    return control_loops::superstructure::roll::kFreeSpeed / (2.0 * M_PI) *
+           control_loops::superstructure::roll::kOutputRatio /
+           kRollJointEncoderRatio() * kRollJointEncoderCountsPerRevolution();
+  }
+
+  static constexpr ::frc971::constants::Range kRollJointRange() {
+    return ::frc971::constants::Range{
+        -1.05,  // Back Hard
+        1.44,   // Front Hard
+        -0.89,  // Back Soft
+        1.26    // Front Soft
+    };
+  }
+
+  // Wrist
+  static constexpr double kWristEncoderCountsPerRevolution() { return 4096.0; }
+
+  static constexpr double kWristEncoderRatio() {
+    return (24.0 / 36.0) * (36.0 / 60.0);
+  }
+
+  static constexpr double kMaxWristEncoderPulsesPerSecond() {
+    return control_loops::superstructure::wrist::kFreeSpeed / (2.0 * M_PI) *
+           control_loops::superstructure::wrist::kOutputRatio /
+           kWristEncoderRatio() * kWristEncoderCountsPerRevolution();
+  }
+
+  static constexpr ::frc971::constants::Range kWristRange() {
+    return ::frc971::constants::Range{
+        -1.05,  // Back Hard
+        1.44,   // Front Hard
+        -0.89,  // Back Soft
+        1.26    // Front Soft
+    };
+  }
+
+  // Rollers
+  static constexpr double kRollerSupplyCurrentLimit() { return 30.0; }
+  static constexpr double kRollerStatorCurrentLimit() { return 60.0; }
+
   struct PotConstants {
     ::frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemParams<
         ::frc971::zeroing::RelativeEncoderZeroingEstimator>
@@ -52,6 +156,19 @@
         subsystem_params;
     double potentiometer_offset;
   };
+
+  struct ArmJointConstants {
+    ::frc971::constants::PotAndAbsoluteEncoderZeroingConstants zeroing;
+    double potentiometer_offset;
+  };
+
+  ArmJointConstants arm_proximal;
+  ArmJointConstants arm_distal;
+  ArmJointConstants roll_joint;
+
+  ::frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemParams<
+      ::frc971::zeroing::AbsoluteEncoderZeroingEstimator>
+      wrist;
 };
 
 // Creates and returns a Values instance for the constants.
diff --git a/y2023/constants/7971.json b/y2023/constants/7971.json
index fcc58a3..d9bb4a0 100644
--- a/y2023/constants/7971.json
+++ b/y2023/constants/7971.json
@@ -1,17 +1,17 @@
 {
   "cameras": [
     {
-      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-7971-1_cam-23-01_2013-01-18_09-15-19.438113048.json' %}
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-7971-1_cam-23-01_ext_2023-02-19.json' %}
     },
     {
-      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-7971-2_cam-23-02_2013-01-18_09-02-49.498335208.json' %}
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-7971-2_cam-23-02_ext_2023-02-19.json' %}
     },
     {
-      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-7971-3_cam-23-03_2013-01-18_09-39-27.378322133.json' %}
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-7971-3_cam-23-03_ext_2023-02-19.json' %}
     },
     {
-      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-7971-4_cam-23-04_2013-01-18_09-39-42.039874176.json' %}
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-7971-4_cam-23-04_ext_2023-02-19.json' %}
     }
   ],
   "target_map": {% include 'y2023/vision/maps/target_map.json' %}
-}
\ No newline at end of file
+}
diff --git a/y2023/constants/971.json b/y2023/constants/971.json
new file mode 100644
index 0000000..9ac8c8b
--- /dev/null
+++ b/y2023/constants/971.json
@@ -0,0 +1,17 @@
+{
+  "cameras": [
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_ext_2023-02-22.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_ext_2023-02-22.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_ext_2023-02-22.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_ext_2023-02-22.json' %}
+    }
+  ],
+  "target_map": {% include 'y2023/vision/maps/target_map.json' %}
+}
diff --git a/y2023/constants/9971.json b/y2023/constants/9971.json
new file mode 100644
index 0000000..6d5cbf9
--- /dev/null
+++ b/y2023/constants/9971.json
@@ -0,0 +1,17 @@
+{
+  "cameras": [
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-4_cam-23-09_2013-01-18_09-02-59.650270322.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-3_cam-23-10_2013-01-18_10-02-40.377380613.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-2_cam-23-11_2013-01-18_10-01-30.177115986.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_2013-01-18_09-27-45.150551614.json' %}
+    }
+  ],
+  "target_map": {% include 'y2023/vision/maps/target_map.json' %}
+}
diff --git a/y2023/constants/BUILD b/y2023/constants/BUILD
index 9022cf7..09e57c7 100644
--- a/y2023/constants/BUILD
+++ b/y2023/constants/BUILD
@@ -20,6 +20,8 @@
     src = "constants.jinja2.json",
     includes = [
         "7971.json",
+        "971.json",
+        "9971.json",
         "//y2023/vision/calib_files",
         "//y2023/vision/maps",
     ],
diff --git a/y2023/constants/constants.jinja2.json b/y2023/constants/constants.jinja2.json
index 6a8cde9..4fb9df7 100644
--- a/y2023/constants/constants.jinja2.json
+++ b/y2023/constants/constants.jinja2.json
@@ -6,11 +6,11 @@
     },
     {
       "team": 971,
-      "data": {}
+      "data": {% include 'y2023/constants/971.json' %}
     },
     {
       "team": 9971,
-      "data": {}
+      "data": {% include 'y2023/constants/9971.json' %}
     }
   ]
 }
diff --git a/y2023/control_loops/drivetrain/BUILD b/y2023/control_loops/drivetrain/BUILD
index 8a06d00..ac00e2b 100644
--- a/y2023/control_loops/drivetrain/BUILD
+++ b/y2023/control_loops/drivetrain/BUILD
@@ -1,4 +1,5 @@
 load("//aos:config.bzl", "aos_config")
+load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 
 genrule(
     name = "genrule_drivetrain",
@@ -111,3 +112,12 @@
         "//frc971/control_loops/drivetrain:trajectory_generator",
     ],
 )
+
+flatbuffer_cc_library(
+    name = "drivetrain_can_position_fbs",
+    srcs = [
+        "drivetrain_can_position.fbs",
+    ],
+    gen_reflections = 1,
+    visibility = ["//visibility:public"],
+)
diff --git a/y2023/control_loops/drivetrain/drivetrain_can_position.fbs b/y2023/control_loops/drivetrain/drivetrain_can_position.fbs
new file mode 100644
index 0000000..10d7c9a
--- /dev/null
+++ b/y2023/control_loops/drivetrain/drivetrain_can_position.fbs
@@ -0,0 +1,39 @@
+namespace y2023.control_loops.drivetrain;
+
+table CANFalcon {
+  // The CAN id of the falcon
+  id:int (id: 0);
+
+  // In Amps
+  supply_current:float (id: 1);
+
+  // In Amps
+  // Stator current where positive current means torque is applied in
+  // the motor's forward direction as determined by its Inverted setting.
+  torque_current:float (id: 2);
+
+  // In Volts
+  supply_voltage:float (id: 3);
+
+  // In degrees Celsius
+  device_temp:float (id: 4);
+
+  // In meters traveled on the drivetrain
+  position:float (id: 5);
+}
+
+// CAN readings from the CAN sensor reader loop
+table CANPosition {
+  falcons: [CANFalcon] (id: 0);
+
+  // The timestamp of the measurement on the canivore clock in nanoseconds
+  // This will have less jitter than the
+  // timestamp of the message being sent out.
+  timestamp:int64 (id: 1);
+
+  // The ctre::phoenix::StatusCode of the measurement
+  // Should be OK = 0
+  status:int (id: 2);
+}
+
+root_type CANPosition;
diff --git a/y2023/control_loops/drivetrain/drivetrain_main.cc b/y2023/control_loops/drivetrain/drivetrain_main.cc
index cf8aa8a..0817b3d 100644
--- a/y2023/control_loops/drivetrain/drivetrain_main.cc
+++ b/y2023/control_loops/drivetrain/drivetrain_main.cc
@@ -19,6 +19,9 @@
           std::make_unique<::frc971::control_loops::drivetrain::DeadReckonEkf>(
               &event_loop,
               ::y2023::control_loops::drivetrain::GetDrivetrainConfig());
+  std::unique_ptr<DrivetrainLoop> drivetrain = std::make_unique<DrivetrainLoop>(
+      y2023::control_loops::drivetrain::GetDrivetrainConfig(), &event_loop,
+      localizer.get());
 
   event_loop.Run();
 
diff --git a/y2023/control_loops/python/BUILD b/y2023/control_loops/python/BUILD
index 9b1746a..561bb77 100644
--- a/y2023/control_loops/python/BUILD
+++ b/y2023/control_loops/python/BUILD
@@ -44,12 +44,30 @@
         ":python_init",
         "//frc971/control_loops/python:basic_window",
         "//frc971/control_loops/python:color",
+        "@pip//matplotlib",
         "@pip//numpy",
         "@pip//pygobject",
         "@pip//shapely",
     ],
 )
 
+py_binary(
+    name = "graph_codegen",
+    srcs = [
+        "graph_codegen.py",
+        "graph_paths.py",
+        "graph_tools.py",
+    ],
+    legacy_create_init = False,
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":python_init",
+        "//frc971/control_loops/python:basic_window",
+        "//frc971/control_loops/python:color",
+        "@pip//numpy",
+    ],
+)
+
 py_library(
     name = "polydrivetrain_lib",
     srcs = [
@@ -74,3 +92,51 @@
     visibility = ["//visibility:public"],
     deps = ["//y2023/control_loops:python_init"],
 )
+
+py_binary(
+    name = "roll",
+    srcs = [
+        "roll.py",
+    ],
+    legacy_create_init = False,
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    deps = [
+        ":python_init",
+        "//frc971/control_loops/python:angular_system",
+        "//frc971/control_loops/python:controls",
+        "@pip//glog",
+        "@pip//python_gflags",
+    ],
+)
+
+py_binary(
+    name = "wrist",
+    srcs = [
+        "wrist.py",
+    ],
+    legacy_create_init = False,
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    deps = [
+        ":python_init",
+        "//frc971/control_loops/python:angular_system",
+        "//frc971/control_loops/python:controls",
+        "@pip//glog",
+        "@pip//python_gflags",
+    ],
+)
+
+py_binary(
+    name = "turret",
+    srcs = [
+        "turret.py",
+    ],
+    legacy_create_init = False,
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    deps = [
+        ":python_init",
+        "//frc971/control_loops/python:angular_system",
+        "//frc971/control_loops/python:controls",
+        "@pip//glog",
+        "@pip//python_gflags",
+    ],
+)
diff --git a/y2023/control_loops/python/graph_codegen.py b/y2023/control_loops/python/graph_codegen.py
new file mode 100644
index 0000000..054a32d
--- /dev/null
+++ b/y2023/control_loops/python/graph_codegen.py
@@ -0,0 +1,293 @@
+from __future__ import print_function
+import sys
+import numpy as np
+import y2023.control_loops.python.graph_paths as graph_paths
+
+
+def index_function_name(name):
+    return "%sIndex" % name
+
+
+def path_function_name(name):
+    return "Make%sPath" % name
+
+
+def add_edge(cc_file, name, segment, index, reverse):
+    segment.VerifyPoints()
+
+    cc_file.append("  // Adding edge %d" % index)
+    vmax = "vmax"
+    if segment.vmax:
+        vmax = "::std::min(vmax, %f)" % segment.vmax
+
+    alpha_unitizer = "alpha_unitizer"
+    if segment.alpha_unitizer is not None:
+        alpha_unitizer = "(::Eigen::Matrix<double, 3, 3>() << %f, %f, %f, %f, %f, %f, %f, %f, %f).finished()" % (
+            segment.alpha_unitizer[0, 0],
+            segment.alpha_unitizer[0, 1],
+            segment.alpha_unitizer[0, 2],
+            segment.alpha_unitizer[1, 0],
+            segment.alpha_unitizer[1, 1],
+            segment.alpha_unitizer[1, 2],
+            segment.alpha_unitizer[2, 0],
+            segment.alpha_unitizer[2, 1],
+            segment.alpha_unitizer[2, 2],
+        )
+    cc_file.append("  trajectories->emplace_back(%s," % (vmax))
+    cc_file.append("                             %s," % (alpha_unitizer))
+    if reverse:
+        cc_file.append(
+            "                             Trajectory(dynamics, &hybrid_roll_joint_loop->plant(), Path::Reversed(%s()), 0.005));"
+            % (path_function_name(str(name))))
+    else:
+        cc_file.append(
+            "                             Trajectory(dynamics, &hybrid_roll_joint_loop->plant(), %s(), 0.005));"
+            % (path_function_name(str(name))))
+
+    start_index = None
+    end_index = None
+    for point, name in graph_paths.points:
+        if (point[:2] == segment.start
+            ).all() and point[2] == segment.alpha_rolls[0][1]:
+            start_index = name
+        if (point[:2] == segment.end
+            ).all() and point[2] == segment.alpha_rolls[-1][1]:
+            end_index = name
+
+    if reverse:
+        start_index, end_index = end_index, start_index
+
+    cc_file.append(
+        "  edges.push_back(SearchGraph::Edge{%s(), %s()," %
+        (index_function_name(start_index), index_function_name(end_index)))
+    cc_file.append(
+        "                     (trajectories->back().trajectory.path().length() + 0.2)});"
+    )
+
+    # TODO(austin): Allow different vmaxes for different paths.
+    cc_file.append("  trajectories->back().trajectory.OptimizeTrajectory(")
+    cc_file.append("      trajectories->back().alpha_unitizer,")
+    cc_file.append("      trajectories->back().vmax);")
+    cc_file.append("")
+
+
+def main(argv):
+    cc_file = []
+    cc_file.append("#include <memory>")
+    cc_file.append("")
+    cc_file.append(
+        "#include \"frc971/control_loops/double_jointed_arm/graph.h\"")
+    cc_file.append(
+        "#include \"y2023/control_loops/superstructure/arm/generated_graph.h\""
+    )
+    cc_file.append(
+        "#include \"y2023/control_loops/superstructure/arm/trajectory.h\"")
+    cc_file.append(
+        "#include \"y2023/control_loops/superstructure/roll/integral_hybrid_roll_plant.h\""
+    )
+    cc_file.append("")
+
+    cc_file.append("using frc971::control_loops::arm::SearchGraph;")
+    cc_file.append(
+        "using y2023::control_loops::superstructure::arm::Trajectory;")
+    cc_file.append("using y2023::control_loops::superstructure::arm::Path;")
+    cc_file.append("using y2023::control_loops::superstructure::arm::NSpline;")
+    cc_file.append(
+        "using y2023::control_loops::superstructure::arm::CosSpline;")
+
+    cc_file.append("")
+    cc_file.append("namespace y2023 {")
+    cc_file.append("namespace control_loops {")
+    cc_file.append("namespace superstructure {")
+    cc_file.append("namespace arm {")
+
+    h_file = []
+    h_file.append(
+        "#ifndef Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_GENERATED_GRAPH_H_")
+    h_file.append(
+        "#define Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_GENERATED_GRAPH_H_")
+    h_file.append("")
+    h_file.append("#include <memory>")
+    h_file.append("")
+    h_file.append(
+        "#include \"frc971/control_loops/double_jointed_arm/graph.h\"")
+    h_file.append(
+        "#include \"y2023/control_loops/superstructure/arm/arm_constants.h\"")
+    h_file.append(
+        "#include \"y2023/control_loops/superstructure/arm/trajectory.h\"")
+
+    h_file.append("")
+    h_file.append("namespace y2023 {")
+    h_file.append("namespace control_loops {")
+    h_file.append("namespace superstructure {")
+    h_file.append("namespace arm {")
+
+    h_file.append("using frc971::control_loops::arm::SearchGraph;")
+    h_file.append(
+        "using y2023::control_loops::superstructure::arm::Trajectory;")
+    h_file.append("using y2023::control_loops::superstructure::arm::Path;")
+    h_file.append(
+        "using y2023::control_loops::superstructure::arm::kArmConstants;")
+
+    h_file.append("")
+    h_file.append("struct TrajectoryAndParams {")
+    h_file.append("  TrajectoryAndParams(double new_vmax,")
+    h_file.append(
+        "                      const ::Eigen::Matrix<double, 3, 3> &new_alpha_unitizer,"
+    )
+    h_file.append("                      Trajectory &&new_trajectory)")
+    h_file.append("      : vmax(new_vmax),")
+    h_file.append("        alpha_unitizer(new_alpha_unitizer),")
+    h_file.append("        trajectory(::std::move(new_trajectory)) {}")
+    h_file.append("  double vmax;")
+    h_file.append("  ::Eigen::Matrix<double, 3, 3> alpha_unitizer;")
+    h_file.append("  Trajectory trajectory;")
+    h_file.append("};")
+    h_file.append("")
+
+    # Now dump out the vertices and associated constexpr vertex name functions.
+    for index, point in enumerate(graph_paths.points):
+        h_file.append("")
+        h_file.append("constexpr uint32_t %s() { return %d; }" %
+                      (index_function_name(point[1]), index))
+        h_file.append("inline ::Eigen::Matrix<double, 3, 1> %sPoint() {" %
+                      point[1])
+        h_file.append(
+            "  return (::Eigen::Matrix<double, 3, 1>() << %f, %f, %f).finished();"
+            % (point[0][0], point[0][1], point[0][2]))
+        h_file.append("}")
+
+    front_points = [
+        index_function_name(point[1]) + "()"
+        for point in graph_paths.front_points
+    ]
+    h_file.append("")
+    h_file.append("constexpr ::std::array<uint32_t, %d> FrontPoints() {" %
+                  len(front_points))
+    h_file.append("  return ::std::array<uint32_t, %d>{{%s}};" %
+                  (len(front_points), ", ".join(front_points)))
+    h_file.append("}")
+
+    back_points = [
+        index_function_name(point[1]) + "()"
+        for point in graph_paths.back_points
+    ]
+    h_file.append("")
+    h_file.append("constexpr ::std::array<uint32_t, %d> BackPoints() {" %
+                  len(back_points))
+    h_file.append("  return ::std::array<uint32_t, %d>{{%s}};" %
+                  (len(back_points), ", ".join(back_points)))
+    h_file.append("}")
+
+    # Add the Make*Path functions.
+    h_file.append("")
+    cc_file.append("")
+    for name, segment in list(enumerate(graph_paths.unnamed_segments)) + [
+        (x.name, x) for x in graph_paths.named_segments
+    ]:
+        h_file.append("::std::unique_ptr<Path> %s();" %
+                      path_function_name(name))
+        cc_file.append("::std::unique_ptr<Path> %s() {" %
+                       path_function_name(name))
+        cc_file.append("  return ::std::unique_ptr<Path>(new Path(CosSpline{")
+        cc_file.append("      NSpline<4, 2>((Eigen::Matrix<double, 2, 4>() <<")
+        points = [
+            segment.start, segment.control1, segment.control2, segment.end
+        ]
+        cc_file.append("             " +
+                       " ".join(["%.12f," % (p[0]) for p in points]))
+        cc_file.append("             " +
+                       ", ".join(["%.12f" % (p[1]) for p in points]))
+
+        cc_file.append("      ).finished()), {")
+        for alpha, roll in segment.alpha_rolls:
+            cc_file.append(
+                "       CosSpline::AlphaTheta{.alpha = %.12f, .theta = %.12f},"
+                % (alpha, roll))
+        cc_file.append("  }}));")
+        cc_file.append("}")
+        cc_file.append("")
+
+    # Matrix of nodes
+    h_file.append("::std::vector<::Eigen::Matrix<double, 3, 1>> PointList();")
+
+    cc_file.append(
+        "::std::vector<::Eigen::Matrix<double, 3, 1>> PointList() {")
+    cc_file.append("  ::std::vector<::Eigen::Matrix<double, 3, 1>> points;")
+    for point in graph_paths.points:
+        cc_file.append(
+            "  points.push_back((::Eigen::Matrix<double, 3, 1>() << %.12s, %.12s, %.12s).finished());"
+            % (point[0][0], point[0][1], point[0][2]))
+    cc_file.append("  return points;")
+    cc_file.append("}")
+
+    # Now create the MakeSearchGraph function.
+    h_file.append("")
+    h_file.append("// Builds a search graph.")
+    h_file.append("SearchGraph MakeSearchGraph("
+                  "const frc971::control_loops::arm::Dynamics *dynamics, "
+                  "::std::vector<TrajectoryAndParams> *trajectories,")
+    h_file.append(
+        "                            const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer,"
+    )
+    h_file.append("                            double vmax,")
+    h_file.append(
+        "const StateFeedbackLoop<3, 1, 1, double, StateFeedbackHybridPlant<3, 1, 1>, HybridKalman<3, 1, 1>> *hybrid_roll_joint_loop);"
+    )
+    cc_file.append("")
+    cc_file.append("SearchGraph MakeSearchGraph(")
+    cc_file.append("    const frc971::control_loops::arm::Dynamics *dynamics,")
+    cc_file.append("    std::vector<TrajectoryAndParams> *trajectories,")
+    cc_file.append(
+        "    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double vmax,"
+    )
+    cc_file.append(
+        "    const StateFeedbackLoop<3, 1, 1, double, StateFeedbackHybridPlant<3, 1, 1>,"
+    )
+    cc_file.append(
+        "                            HybridKalman<3, 1, 1>> *hybrid_roll_joint_loop) {"
+    )
+    cc_file.append("  ::std::vector<SearchGraph::Edge> edges;")
+
+    index = 0
+    segments_and_names = list(enumerate(graph_paths.unnamed_segments)) + [
+        (x.name, x) for x in graph_paths.named_segments
+    ]
+
+    for name, segment in segments_and_names:
+        add_edge(cc_file, name, segment, index, False)
+        index += 1
+        add_edge(cc_file, name, segment, index, True)
+        index += 1
+
+    cc_file.append("  return SearchGraph(%d, ::std::move(edges));" %
+                   len(graph_paths.points))
+    cc_file.append("}")
+
+    h_file.append("")
+    h_file.append("}  // namespace arm")
+    h_file.append("}  // namespace superstructure")
+    h_file.append("}  // namespace control_loops")
+    h_file.append("}  // namespace y2023")
+    h_file.append("")
+    h_file.append(
+        "#endif  // Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_GENERATED_GRAPH_H_")
+
+    cc_file.append("}  // namespace arm")
+    cc_file.append("}  // namespace superstructure")
+    cc_file.append("}  // namespace control_loops")
+    cc_file.append("}  // namespace y2023")
+
+    if len(argv) == 3:
+        with open(argv[1], "w") as hfd:
+            hfd.write("\n".join(h_file))
+
+        with open(argv[2], "w") as ccfd:
+            ccfd.write("\n".join(cc_file))
+    else:
+        print("\n".join(h_file))
+        print("\n".join(cc_file))
+
+
+if __name__ == '__main__':
+    main(sys.argv)
diff --git a/y2023/control_loops/python/graph_edit.py b/y2023/control_loops/python/graph_edit.py
index 448103b..b9e29d0 100644
--- a/y2023/control_loops/python/graph_edit.py
+++ b/y2023/control_loops/python/graph_edit.py
@@ -6,21 +6,36 @@
 from frc971.control_loops.python.color import Color, palette
 import random
 import gi
-import numpy
+import numpy as np
 
 gi.require_version('Gtk', '3.0')
 from gi.repository import Gdk, Gtk
 import cairo
-from graph_tools import XYSegment, AngleSegment, to_theta, to_xy, alpha_blend, draw_lines
-from graph_tools import back_to_xy_loop, subdivide_theta, to_theta_loop, px
-from graph_tools import l1, l2, joint_center
-import graph_paths
+from y2023.control_loops.python.graph_tools import to_theta, to_xy, alpha_blend, shift_angles
+from y2023.control_loops.python.graph_tools import l1, l2, joint_center
+from y2023.control_loops.python.graph_tools import DRIVER_CAM_POINTS
+from y2023.control_loops.python import graph_paths
 
-from frc971.control_loops.python.basic_window import quit_main_loop, set_color
+from frc971.control_loops.python.basic_window import quit_main_loop, set_color, OverrideMatrix, identity
 
 import shapely
 from shapely.geometry import Polygon
 
+import matplotlib.pyplot as plt
+
+
+def px(cr):
+    return OverrideMatrix(cr, identity)
+
+
+# Draw lines to cr + stroke.
+def draw_lines(cr, lines):
+    cr.move_to(lines[0][0], lines[0][1])
+    for pt in lines[1:]:
+        cr.line_to(pt[0], pt[1])
+    with px(cr):
+        cr.stroke()
+
 
 def draw_px_cross(cr, length_px):
     """Draws a cross with fixed dimensions in pixel space."""
@@ -42,39 +57,13 @@
 
 # Find the highest y position that intersects the vertical line defined by x.
 def inter_y(x):
-    return numpy.sqrt((l2 + l1)**2 -
-                      (x - joint_center[0])**2) + joint_center[1]
-
-
-# This is the x position where the inner (hyperextension) circle intersects the horizontal line
-derr = numpy.sqrt((l1 - l2)**2 - (joint_center[1] - 0.3048)**2)
+    return np.sqrt((l2 + l1)**2 - (x - joint_center[0])**2) + joint_center[1]
 
 
 # Define min and max l1 angles based on vertical constraints.
 def get_angle(boundary):
-    h = numpy.sqrt((l1)**2 - (boundary - joint_center[0])**2) + joint_center[1]
-    return numpy.arctan2(h, boundary - joint_center[0])
-
-
-# left hand side lines
-lines1 = [
-    (-0.826135, inter_y(-0.826135)),
-    (-0.826135, 0.1397),
-    (-23.025 * 0.0254, 0.1397),
-    (-23.025 * 0.0254, 0.3048),
-    (joint_center[0] - derr, 0.3048),
-]
-
-# right hand side lines
-lines2 = [(joint_center[0] + derr, 0.3048), (0.422275, 0.3048),
-          (0.422275, 0.1397), (0.826135, 0.1397),
-          (0.826135, inter_y(0.826135))]
-
-t1_min = get_angle((32.525 - 4.0) * 0.0254)
-t2_min = -7.0 / 4.0 * numpy.pi
-
-t1_max = get_angle((-32.525 + 4.0) * 0.0254)
-t2_max = numpy.pi * 3.0 / 4.0
+    h = np.sqrt((l1)**2 - (boundary - joint_center[0])**2) + joint_center[1]
+    return np.arctan2(h, boundary - joint_center[0])
 
 
 # Rotate a rasterized loop such that it aligns to when the parameters loop
@@ -83,7 +72,7 @@
     for pt_i in range(1, len(points)):
         pt = points[pt_i]
         delta = last_pt[1] - pt[1]
-        if abs(delta) > numpy.pi:
+        if abs(delta) > np.pi:
             return points[pt_i:] + points[:pt_i]
         last_pt = pt
     return points
@@ -94,39 +83,14 @@
     return [(x, y + dy) for x, y in points]
 
 
-lines1_theta_part = rotate_to_jump_point(to_theta_loop(lines1, 0))
-lines2_theta_part = rotate_to_jump_point(to_theta_loop(lines2))
-
-# Some hacks here to make a single polygon by shifting to get an extra copy of the contraints.
-lines1_theta = y_shift(lines1_theta_part, -numpy.pi * 2) + lines1_theta_part + \
-    y_shift(lines1_theta_part, numpy.pi * 2)
-lines2_theta = y_shift(lines2_theta_part, numpy.pi * 2) + lines2_theta_part + \
-    y_shift(lines2_theta_part, -numpy.pi * 2)
-
-lines_theta = lines1_theta + lines2_theta
-
-p1 = Polygon(lines_theta)
-
-p2 = Polygon([(t1_min, t2_min), (t1_max, t2_min), (t1_max, t2_max),
-              (t1_min, t2_max)])
-
-# Fully computed theta constrints.
-lines_theta = list(p1.intersection(p2).exterior.coords)
-
-lines1_theta_back = back_to_xy_loop(lines1_theta)
-lines2_theta_back = back_to_xy_loop(lines2_theta)
-
-lines_theta_back = back_to_xy_loop(lines_theta)
-
-
 # Get the closest point to a line from a test pt.
 def get_closest(prev, cur, pt):
     dx_ang = (cur[0] - prev[0])
     dy_ang = (cur[1] - prev[1])
 
-    d = numpy.sqrt(dx_ang**2 + dy_ang**2)
+    d = np.sqrt(dx_ang**2 + dy_ang**2)
     if (d < 0.000001):
-        return prev, numpy.sqrt((prev[0] - pt[0])**2 + (prev[1] - pt[1])**2)
+        return prev, np.sqrt((prev[0] - pt[0])**2 + (prev[1] - pt[1])**2)
 
     pdx = -dy_ang / d
     pdy = dx_ang / d
@@ -137,9 +101,9 @@
     alpha = (dx_ang * dpx + dy_ang * dpy) / d / d
 
     if (alpha < 0):
-        return prev, numpy.sqrt((prev[0] - pt[0])**2 + (prev[1] - pt[1])**2)
+        return prev, np.sqrt((prev[0] - pt[0])**2 + (prev[1] - pt[1])**2)
     elif (alpha > 1):
-        return cur, numpy.sqrt((cur[0] - pt[0])**2 + (cur[1] - pt[1])**2)
+        return cur, np.sqrt((cur[0] - pt[0])**2 + (cur[1] - pt[1])**2)
     else:
         return (alpha_blend(prev[0], cur[0], alpha), alpha_blend(prev[1], cur[1], alpha)), \
             abs(dpx * pdx + dpy * pdy)
@@ -158,10 +122,10 @@
 
 
 # Create a GTK+ widget on which we will draw using Cairo
-class Silly(basic_window.BaseWindow):
+class ArmUi(basic_window.BaseWindow):
 
     def __init__(self):
-        super(Silly, self).__init__()
+        super(ArmUi, self).__init__()
 
         self.window = Gtk.Window()
         self.window.set_title("DrawingArea")
@@ -172,6 +136,7 @@
                                | Gdk.EventMask.SCROLL_MASK
                                | Gdk.EventMask.KEY_PRESS_MASK)
         self.method_connect("key-press-event", self.do_key_press)
+        self.method_connect("motion-notify-event", self.do_motion)
         self.method_connect("button-press-event",
                             self._do_button_press_internal)
         self.method_connect("configure-event", self._do_configure)
@@ -181,8 +146,8 @@
         self.theta_version = False
         self.reinit_extents()
 
-        self.last_pos = (numpy.pi / 2.0, 1.0)
-        self.circular_index_select = -1
+        self.last_pos = to_xy(*graph_paths.neutral[:2])
+        self.circular_index_select = 1
 
         # Extra stuff for drawing lines.
         self.segments = []
@@ -191,6 +156,12 @@
         self.spline_edit = 0
         self.edit_control1 = True
 
+        self.roll_joint_thetas = None
+        self.roll_joint_point = None
+        self.fig = plt.figure()
+        self.ax = self.fig.add_subplot(111)
+        plt.show(block=False)
+
     def do_key_press(self, event):
         pass
 
@@ -223,10 +194,10 @@
 
     def reinit_extents(self):
         if self.theta_version:
-            self.extents_x_min = -numpy.pi * 2
-            self.extents_x_max = numpy.pi * 2
-            self.extents_y_min = -numpy.pi * 2
-            self.extents_y_max = numpy.pi * 2
+            self.extents_x_min = -np.pi * 2
+            self.extents_x_max = np.pi * 2
+            self.extents_y_min = -np.pi * 2
+            self.extents_y_max = np.pi * 2
         else:
             self.extents_x_min = -40.0 * 0.0254
             self.extents_x_max = 40.0 * 0.0254
@@ -257,19 +228,96 @@
         if self.theta_version:
             # Draw a filled white rectangle.
             set_color(cr, palette["WHITE"])
-            cr.rectangle(-numpy.pi, -numpy.pi, numpy.pi * 2.0, numpy.pi * 2.0)
+            cr.rectangle(-np.pi, -np.pi, np.pi * 2.0, np.pi * 2.0)
             cr.fill()
 
             set_color(cr, palette["BLUE"])
             for i in range(-6, 6):
-                cr.move_to(-40, -40 + i * numpy.pi)
-                cr.line_to(40, 40 + i * numpy.pi)
+                cr.move_to(-40, -40 + i * np.pi)
+                cr.line_to(40, 40 + i * np.pi)
             with px(cr):
                 cr.stroke()
 
-            set_color(cr, Color(0.5, 0.5, 1.0))
-            draw_lines(cr, lines_theta)
+            set_color(cr, Color(0.0, 1.0, 0.2))
+            cr.move_to(self.last_pos[0], self.last_pos[1])
+            draw_px_cross(cr, 5)
 
+        else:
+            # Draw a filled white rectangle.
+            set_color(cr, palette["WHITE"])
+            cr.rectangle(-2.0, -2.0, 4.0, 4.0)
+            cr.fill()
+
+            # Draw top of drivetrain (including bumpers)
+            DRIVETRAIN_X = -0.490
+            DRIVETRAIN_Y = 0.184
+            DRIVETRAIN_WIDTH = 0.980
+            set_color(cr, palette["BLUE"])
+            cr.move_to(DRIVETRAIN_X, DRIVETRAIN_Y)
+            cr.line_to(DRIVETRAIN_X + DRIVETRAIN_WIDTH, DRIVETRAIN_Y)
+            with px(cr):
+                cr.stroke()
+
+            # Draw joint center
+            JOINT_CENTER_RADIUS = 0.173 / 2
+            cr.arc(joint_center[0], joint_center[1], JOINT_CENTER_RADIUS, 0,
+                   2.0 * np.pi)
+            with px(cr):
+                cr.stroke()
+
+            JOINT_TOWER_X = -0.252
+            JOINT_TOWER_Y = DRIVETRAIN_Y
+            JOINT_TOWER_WIDTH = 0.098
+            JOINT_TOWER_HEIGHT = 0.864
+            cr.rectangle(JOINT_TOWER_X, JOINT_TOWER_Y, JOINT_TOWER_WIDTH,
+                         JOINT_TOWER_HEIGHT)
+            with px(cr):
+                cr.stroke()
+
+            # Draw driver cam
+            cr.set_source_rgba(1, 0, 0, 0.5)
+            DRIVER_CAM_X = DRIVER_CAM_POINTS[0][0]
+            DRIVER_CAM_Y = DRIVER_CAM_POINTS[0][1]
+            DRIVER_CAM_WIDTH = DRIVER_CAM_POINTS[-1][0] - DRIVER_CAM_POINTS[0][
+                0]
+            DRIVER_CAM_HEIGHT = DRIVER_CAM_POINTS[-1][1] - DRIVER_CAM_POINTS[
+                0][1]
+            cr.rectangle(DRIVER_CAM_X, DRIVER_CAM_Y, DRIVER_CAM_WIDTH,
+                         DRIVER_CAM_HEIGHT)
+            with px(cr):
+                cr.fill()
+
+            # Draw max radius
+            set_color(cr, palette["BLUE"])
+            cr.arc(joint_center[0], joint_center[1], l2 + l1, 0, 2.0 * np.pi)
+            with px(cr):
+                cr.stroke()
+            cr.arc(joint_center[0], joint_center[1], l1 - l2, 0, 2.0 * np.pi)
+            with px(cr):
+                cr.stroke()
+
+            set_color(cr, Color(0.5, 1.0, 1))
+
+        if not self.theta_version:
+            theta1, theta2 = to_theta(self.last_pos,
+                                      self.circular_index_select)
+            x, y = joint_center[0], joint_center[1]
+            cr.move_to(x, y)
+
+            x += np.cos(theta1) * l1
+            y += np.sin(theta1) * l1
+            cr.line_to(x, y)
+            x += np.cos(theta2) * l2
+            y += np.sin(theta2) * l2
+            cr.line_to(x, y)
+            with px(cr):
+                cr.stroke()
+
+            cr.move_to(self.last_pos[0], self.last_pos[1])
+            set_color(cr, Color(0.0, 1.0, 0.2))
+            draw_px_cross(cr, 20)
+
+        if self.theta_version:
             set_color(cr, Color(0.0, 1.0, 0.2))
             cr.move_to(self.last_pos[0], self.last_pos[1])
             draw_px_cross(cr, 5)
@@ -279,69 +327,6 @@
             set_color(cr, palette["CYAN"])
             cr.move_to(c_pt[0], c_pt[1])
             draw_px_cross(cr, 5)
-        else:
-            # Draw a filled white rectangle.
-            set_color(cr, palette["WHITE"])
-            cr.rectangle(-2.0, -2.0, 4.0, 4.0)
-            cr.fill()
-
-            set_color(cr, palette["BLUE"])
-            cr.arc(joint_center[0], joint_center[1], l2 + l1, 0,
-                   2.0 * numpy.pi)
-            with px(cr):
-                cr.stroke()
-            cr.arc(joint_center[0], joint_center[1], l1 - l2, 0,
-                   2.0 * numpy.pi)
-            with px(cr):
-                cr.stroke()
-
-            set_color(cr, Color(0.5, 1.0, 1.0))
-            draw_lines(cr, lines1)
-            draw_lines(cr, lines2)
-
-            def get_circular_index(pt):
-                theta1, theta2 = pt
-                circular_index = int(numpy.floor((theta2 - theta1) / numpy.pi))
-                return circular_index
-
-            set_color(cr, palette["BLUE"])
-            lines = subdivide_theta(lines_theta)
-            o_circular_index = circular_index = get_circular_index(lines[0])
-            p_xy = to_xy(lines[0][0], lines[0][1])
-            if circular_index == self.circular_index_select:
-                cr.move_to(p_xy[0] + circular_index * 0, p_xy[1])
-            for pt in lines[1:]:
-                p_xy = to_xy(pt[0], pt[1])
-                circular_index = get_circular_index(pt)
-                if o_circular_index == self.circular_index_select:
-                    cr.line_to(p_xy[0] + o_circular_index * 0, p_xy[1])
-                if circular_index != o_circular_index:
-                    o_circular_index = circular_index
-                    with px(cr):
-                        cr.stroke()
-                    if circular_index == self.circular_index_select:
-                        cr.move_to(p_xy[0] + circular_index * 0, p_xy[1])
-
-            with px(cr):
-                cr.stroke()
-
-            theta1, theta2 = to_theta(self.last_pos,
-                                      self.circular_index_select)
-            x, y = joint_center[0], joint_center[1]
-            cr.move_to(x, y)
-
-            x += numpy.cos(theta1) * l1
-            y += numpy.sin(theta1) * l1
-            cr.line_to(x, y)
-            x += numpy.cos(theta2) * l2
-            y += numpy.sin(theta2) * l2
-            cr.line_to(x, y)
-            with px(cr):
-                cr.stroke()
-
-            cr.move_to(self.last_pos[0], self.last_pos[1])
-            set_color(cr, Color(0.0, 1.0, 0.2))
-            draw_px_cross(cr, 20)
 
         set_color(cr, Color(0.0, 0.5, 1.0))
         for segment in self.segments:
@@ -353,25 +338,60 @@
                 cr.stroke()
 
         set_color(cr, Color(0.0, 1.0, 0.5))
-        segment = self.current_seg()
-        if segment:
-            print(segment)
-            segment.DrawTo(cr, self.theta_version)
-            with px(cr):
-                cr.stroke()
+
+        # Create the roll joint plot
+        if self.roll_joint_thetas:
+            self.ax.clear()
+            self.ax.plot(*self.roll_joint_thetas)
+            if self.roll_joint_point:
+                self.ax.scatter([self.roll_joint_point[0]],
+                                [self.roll_joint_point[1]],
+                                s=10,
+                                c="red")
+            plt.title("Roll Joint Angle")
+            plt.xlabel("t (0 to 1)")
+            plt.ylabel("theta (rad)")
+
+            self.fig.canvas.draw()
 
     def cur_pt_in_theta(self):
-        if self.theta_version: return numpy.asarray(self.last_pos)
-        return to_theta(self.last_pos, self.circular_index_select)
+        if self.theta_version: return self.last_pos
+        return to_theta(self.last_pos,
+                        self.circular_index_select,
+                        cross_point=-np.pi,
+                        die=False)
 
-    # Current segment based on which mode the drawing system is in.
-    def current_seg(self):
-        if self.prev_segment_pt is not None and (self.prev_segment_pt.any() and
-                                                 self.now_segment_pt.any()):
-            if self.theta_version:
-                return AngleSegment(self.prev_segment_pt, self.now_segment_pt)
-            else:
-                return XYSegment(self.prev_segment_pt, self.now_segment_pt)
+    def do_motion(self, event):
+        o_x = event.x
+        o_y = event.y
+        x = event.x - self.window_shape[0] / 2
+        y = self.window_shape[1] / 2 - event.y
+        scale = self.get_current_scale()
+        event.x = x / scale + self.center[0]
+        event.y = y / scale + self.center[1]
+
+        for segment in self.segments:
+            self.roll_joint_thetas = segment.roll_joint_thetas()
+
+            hovered_t = segment.intersection(event)
+            if hovered_t:
+                min_diff = np.inf
+                closest_t = None
+                closest_theta = None
+                for i in range(len(self.roll_joint_thetas[0])):
+                    t = self.roll_joint_thetas[0][i]
+                    diff = abs(t - hovered_t)
+                    if diff < min_diff:
+                        min_diff = diff
+                        closest_t = t
+                        closest_theta = self.roll_joint_thetas[1][i]
+                self.roll_joint_point = (closest_t, closest_theta)
+                break
+
+        event.x = o_x
+        event.y = o_y
+
+        self.redraw()
 
     def do_key_press(self, event):
         keyval = Gdk.keyval_to_lower(event.keyval)
@@ -387,11 +407,6 @@
             # Decrement which arm solution we render
             self.circular_index_select -= 1
             print(self.circular_index_select)
-        elif keyval == Gdk.KEY_w:
-            # Add this segment to the segment list.
-            segment = self.current_seg()
-            if segment: self.segments.append(segment)
-            self.prev_segment_pt = self.now_segment_pt
 
         elif keyval == Gdk.KEY_r:
             self.prev_segment_pt = self.now_segment_pt
@@ -423,7 +438,7 @@
                 theta1, theta2 = self.last_pos
                 data = to_xy(theta1, theta2)
                 self.circular_index_select = int(
-                    numpy.floor((theta2 - theta1) / numpy.pi))
+                    np.floor((theta2 - theta1) / np.pi))
                 self.last_pos = (data[0], data[1])
             else:
                 self.last_pos = self.cur_pt_in_theta()
@@ -449,8 +464,14 @@
         self.redraw()
 
     def do_button_press(self, event):
+        last_pos = self.last_pos
         self.last_pos = (event.x, event.y)
-        self.now_segment_pt = self.cur_pt_in_theta()
+        pt_theta = self.cur_pt_in_theta()
+        if pt_theta is None:
+            self.last_pos = last_pos
+            return
+
+        self.now_segment_pt = np.array(shift_angles(pt_theta))
 
         if self.edit_control1:
             self.segments[0].control1 = self.now_segment_pt
@@ -463,14 +484,14 @@
                   (self.last_pos[0], self.last_pos[1],
                    self.circular_index_select))
 
-        print('c1: numpy.array([%f, %f])' %
+        print('c1: np.array([%f, %f])' %
               (self.segments[0].control1[0], self.segments[0].control1[1]))
-        print('c2: numpy.array([%f, %f])' %
+        print('c2: np.array([%f, %f])' %
               (self.segments[0].control2[0], self.segments[0].control2[1]))
 
         self.redraw()
 
 
-silly = Silly()
-silly.segments = graph_paths.segments
+arm_ui = ArmUi()
+arm_ui.segments = graph_paths.segments
 basic_window.RunApp()
diff --git a/y2023/control_loops/python/graph_paths.py b/y2023/control_loops/python/graph_paths.py
index da0ad4f..87ced83 100644
--- a/y2023/control_loops/python/graph_paths.py
+++ b/y2023/control_loops/python/graph_paths.py
@@ -1,26 +1,42 @@
-import numpy
+import numpy as np
 
-from graph_tools import *
+from y2023.control_loops.python.graph_tools import *
 
-neutral = to_theta_with_circular_index(-0.2, 0.33, circular_index=-1)
-zero = to_theta_with_circular_index(0.0, 0.0, circular_index=-1)
+neutral = to_theta_with_circular_index_and_roll(joint_center[0],
+                                                joint_center[1] + l2 - l1,
+                                                0.0,
+                                                circular_index=1)
+neutral_to_pickup_1 = np.array([2.396694, 0.508020])
+neutral_to_pickup_2 = np.array([2.874513, 0.933160])
+pickup_pos = to_theta_with_circular_index_and_roll(0.6,
+                                                   0.4,
+                                                   np.pi / 2.0,
+                                                   circular_index=0)
 
-neutral_to_cone_1 = to_theta_with_circular_index(0.0, 0.7, circular_index=-1)
-neutral_to_cone_2 = to_theta_with_circular_index(0.2, 0.5, circular_index=-1)
-cone_pos = to_theta_with_circular_index(1.0, 0.4, circular_index=-1)
+neutral_to_pickup_control_alpha_rolls = [(0.33, 0.0), (.95, np.pi / 2.0)]
 
-neutral_to_cone_perch_pos_1 = to_theta_with_circular_index(0.4,
-                                                           1.0,
-                                                           circular_index=-1)
-neutral_to_cone_perch_pos_2 = to_theta_with_circular_index(0.7,
-                                                           1.5,
-                                                           circular_index=-1)
-cone_perch_pos = to_theta_with_circular_index(1.0, 2.0, circular_index=-1)
+neutral_to_score_1 = np.array([0.994244, -1.417442])
+neutral_to_score_2 = np.array([1.711325, -0.679748])
 
-segments = [
-    ThetaSplineSegment(neutral, neutral_to_cone_1, neutral_to_cone_2, cone_pos,
-                       "NeutralToCone"),
-    ThetaSplineSegment(neutral, neutral_to_cone_perch_pos_1,
-                       neutral_to_cone_perch_pos_2, cone_perch_pos,
-                       "NeutralToPerchedCone"),
+score_pos = to_theta_with_circular_index_and_roll(-1.0,
+                                                  1.2,
+                                                  np.pi / 2.0,
+                                                  circular_index=0)
+neutral_to_score_control_alpha_rolls = [(0.40, 0.0), (.95, np.pi / 2.0)]
+
+# TODO(Max): Add real paths for arm.
+points = [(neutral, "NeutralPos"), (pickup_pos, "PickupPos"),
+          (score_pos, "ScorePos")]
+front_points = []
+back_points = []
+unnamed_segments = []
+named_segments = [
+    ThetaSplineSegment("NeutralToPickup", neutral, neutral_to_pickup_1,
+                       neutral_to_pickup_2, pickup_pos,
+                       neutral_to_pickup_control_alpha_rolls),
+    ThetaSplineSegment("NeutralToScore", neutral, neutral_to_score_1,
+                       neutral_to_score_2, score_pos,
+                       neutral_to_score_control_alpha_rolls),
 ]
+
+segments = named_segments + unnamed_segments
diff --git a/y2023/control_loops/python/graph_tools.py b/y2023/control_loops/python/graph_tools.py
index 3ddfb37..fef1083 100644
--- a/y2023/control_loops/python/graph_tools.py
+++ b/y2023/control_loops/python/graph_tools.py
@@ -1,23 +1,29 @@
-import numpy
-import cairo
-
-from frc971.control_loops.python.basic_window import OverrideMatrix, identity
+import abc
+import numpy as np
+import sys
+import traceback
 
 # joint_center in x-y space.
-joint_center = (-0.299, 0.299)
+IN_TO_M = 0.0254
+joint_center = (-0.203, 0.787)
 
 # Joint distances (l1 = "proximal", l2 = "distal")
-l1 = 46.25 * 0.0254
-l2 = 43.75 * 0.0254
+l1 = 20.0 * IN_TO_M
+l2 = 31.5 * IN_TO_M
 
 max_dist = 0.01
-max_dist_theta = numpy.pi / 64
+max_dist_theta = np.pi / 64
 xy_end_circle_size = 0.01
 theta_end_circle_size = 0.07
 
 
-def px(cr):
-    return OverrideMatrix(cr, identity)
+# Shift the angle between the convention used for input/output and the convention we use for some computations here
+def shift_angle(theta):
+    return np.pi / 2 - theta
+
+
+def shift_angles(thetas):
+    return [shift_angle(theta) for theta in thetas]
 
 
 # Convert from x-y coordinates to theta coordinates.
@@ -25,43 +31,202 @@
 # where circular_index is the circular index, or the position in the
 # "hyperextension" zones. "cross_point" allows shifting the place where
 # it rounds the result so that it draws nicer (no other functional differences).
-def to_theta(pt, circular_index, cross_point=-numpy.pi):
+def to_theta(pt, circular_index, cross_point=-np.pi, die=True):
     orient = (circular_index % 2) == 0
     x = pt[0]
     y = pt[1]
     x -= joint_center[0]
     y -= joint_center[1]
-    l3 = numpy.hypot(x, y)
-    t3 = numpy.arctan2(y, x)
-    theta1 = numpy.arccos((l1**2 + l3**2 - l2**2) / (2 * l1 * l3))
+    l3 = np.hypot(x, y)
+    t3 = np.arctan2(y, x)
+    theta1 = np.arccos((l1**2 + l3**2 - l2**2) / (2 * l1 * l3))
+    if np.isnan(theta1):
+        print(("Couldn't fit triangle to %f, %f, %f" % (l1, l2, l3)))
+        if die:
+            traceback.print_stack()
+            sys.exit(1)
+        return None
 
     if orient:
         theta1 = -theta1
     theta1 += t3
-    theta1 = (theta1 - cross_point) % (2 * numpy.pi) + cross_point
-    theta2 = numpy.arctan2(y - l1 * numpy.sin(theta1),
-                           x - l1 * numpy.cos(theta1))
-    return numpy.array((theta1, theta2))
+    theta1 = (theta1 - cross_point) % (2 * np.pi) + cross_point
+    theta2 = np.arctan2(y - l1 * np.sin(theta1), x - l1 * np.cos(theta1))
+    return np.array((theta1, theta2))
 
 
 # Simple trig to go back from theta1, theta2 to x-y
 def to_xy(theta1, theta2):
-    x = numpy.cos(theta1) * l1 + numpy.cos(theta2) * l2 + joint_center[0]
-    y = numpy.sin(theta1) * l1 + numpy.sin(theta2) * l2 + joint_center[1]
-    orient = ((theta2 - theta1) % (2.0 * numpy.pi)) < numpy.pi
+    x = np.cos(theta1) * l1 + np.cos(theta2) * l2 + joint_center[0]
+    y = np.sin(theta1) * l1 + np.sin(theta2) * l2 + joint_center[1]
+    orient = ((theta2 - theta1) % (2.0 * np.pi)) < np.pi
     return (x, y, orient)
 
 
+END_EFFECTOR_X_LEN = (-1.0 * IN_TO_M, 10.425 * IN_TO_M)
+END_EFFECTOR_Y_LEN = (-4.875 * IN_TO_M, 7.325 * IN_TO_M)
+END_EFFECTOR_Z_LEN = (-11.0 * IN_TO_M, 11.0 * IN_TO_M)
+
+
+def abs_sum(l):
+    result = 0
+    for e in l:
+        result += abs(e)
+    return result
+
+
+def affine_3d(R, T):
+    H = np.eye(4)
+    H[:3, 3] = T
+    H[:3, :3] = R
+    return H
+
+
+# Simple trig to go back from theta1, theta2, and theta3 to
+# the 8 corners on the roll joint x-y-z
+def to_end_effector_points(theta1, theta2, theta3):
+    x, y, _ = to_xy(theta1, theta2)
+    # Homogeneous end effector points relative to the end_effector
+    # ee = end effector
+    endpoints_ee = []
+    for i in range(2):
+        for j in range(2):
+            for k in range(2):
+                endpoints_ee.append(
+                    np.array((END_EFFECTOR_X_LEN[i], END_EFFECTOR_Y_LEN[j],
+                              END_EFFECTOR_Z_LEN[k], 1.0)))
+
+    # Only roll.
+    # rj = roll joint
+    roll = theta3
+    T_rj_ee = np.zeros(3)
+    R_rj_ee = np.array([[1.0, 0.0, 0.0], [0.0,
+                                          np.cos(roll), -np.sin(roll)],
+                        [0.0, np.sin(roll), np.cos(roll)]])
+    H_rj_ee = affine_3d(R_rj_ee, T_rj_ee)
+
+    # Roll joint pose relative to the origin
+    # o = origin
+    T_o_rj = np.array((x, y, 0))
+    # Only yaw
+    yaw = theta1 + theta2
+    R_o_rj = [[np.cos(yaw), -np.sin(yaw), 0.0],
+              [np.sin(yaw), np.cos(yaw), 0.0], [0.0, 0.0, 1.0]]
+    H_o_rj = affine_3d(R_o_rj, T_o_rj)
+
+    # Now compute the pose of the end effector relative to the origin
+    H_o_ee = H_o_rj @ H_rj_ee
+
+    # Get the translation from these transforms
+    endpoints_o = [(H_o_ee @ endpoint_ee)[:3] for endpoint_ee in endpoints_ee]
+
+    diagonal_distance = np.linalg.norm(
+        np.array(endpoints_o[0]) - np.array(endpoints_o[-1]))
+    actual_diagonal_distance = np.linalg.norm(
+        np.array((abs_sum(END_EFFECTOR_X_LEN), abs_sum(END_EFFECTOR_Y_LEN),
+                  abs_sum(END_EFFECTOR_Z_LEN))))
+    assert abs(diagonal_distance - actual_diagonal_distance) < 1e-5
+
+    return np.array(endpoints_o)
+
+
+# Returns all permutations of rectangle points given two opposite corners.
+# x is the two x values, y is the two y values, z is the two z values
+def rect_points(x, y, z):
+    points = []
+    for i in range(2):
+        for j in range(2):
+            for k in range(2):
+                points.append((x[i], y[j], z[k]))
+    return np.array(points)
+
+
+DRIVER_CAM_Z_OFFSET = 3.225 * IN_TO_M
+DRIVER_CAM_POINTS = rect_points(
+    (-5.126 * IN_TO_M + joint_center[0], 0.393 * IN_TO_M + joint_center[0]),
+    (5.125 * IN_TO_M + joint_center[1], 17.375 * IN_TO_M + joint_center[1]),
+    (-8.475 * IN_TO_M - DRIVER_CAM_Z_OFFSET,
+     -4.350 * IN_TO_M - DRIVER_CAM_Z_OFFSET))
+
+
+def compute_face_normals(points):
+    # Return the normal vectors of all the faces
+    normals = []
+    for i in range(points.shape[0]):
+        v1 = points[i]
+        v2 = points[(i + 1) % points.shape[0]]
+        normal = np.cross(v1, v2)
+        normals.append(normal)
+    return np.array(normals)
+
+
+def project_points_onto_axis(points, axis):
+    projections = np.dot(points, axis)
+    return np.min(projections), np.max(projections)
+
+
+def roll_joint_collision(theta1, theta2, theta3):
+    theta1 = shift_angle(theta1)
+    theta2 = shift_angle(theta2)
+    theta3 = shift_angle(theta3)
+
+    end_effector_points = to_end_effector_points(theta1, theta2, theta3)
+
+    assert len(end_effector_points) == 8 and len(end_effector_points[0]) == 3
+    assert len(DRIVER_CAM_POINTS) == 8 and len(DRIVER_CAM_POINTS[0]) == 3
+
+    # Use the Separating Axis Theorem to check for collision
+    end_effector_normals = compute_face_normals(end_effector_points)
+    driver_cam_normals = compute_face_normals(DRIVER_CAM_POINTS)
+
+    collision = True
+    # Check for separating axes
+    for normal in np.concatenate((end_effector_normals, driver_cam_normals)):
+        min_ee, max_ee = project_points_onto_axis(end_effector_points, normal)
+        min_dc, max_dc = project_points_onto_axis(DRIVER_CAM_POINTS, normal)
+        if max_ee < min_dc or min_ee > max_dc:
+            # Separating axis found, rectangles don't intersect
+            collision = False
+            break
+
+    return collision
+
+
+# Delta limit means theta2 - theta1.
+# The limit for the proximal and distal is relative,
+# so define constraints for this delta.
+UPPER_DELTA_LIMIT = 0.0
+LOWER_DELTA_LIMIT = -1.9 * np.pi
+
+# TODO(milind): put actual proximal limits
+UPPER_PROXIMAL_LIMIT = np.pi * 1.5
+LOWER_PROXIMAL_LIMIT = -np.pi
+
+UPPER_DISTAL_LIMIT = 0.75 * np.pi
+LOWER_DISTAL_LIMIT = -0.75 * np.pi
+
+UPPER_ROLL_JOINT_LIMIT = 0.75 * np.pi
+LOWER_ROLL_JOINT_LIMIT = -0.75 * np.pi
+
+
+def arm_past_limit(theta1, theta2, theta3):
+    delta = theta2 - theta1
+    return delta > UPPER_DELTA_LIMIT or delta < LOWER_DELTA_LIMIT or \
+            theta1 > UPPER_PROXIMAL_LIMIT or theta1 < LOWER_PROXIMAL_LIMIT or \
+            theta2 > UPPER_DISTAL_LIMIT or theta2 < LOWER_DISTAL_LIMIT or \
+            theta3 > UPPER_ROLL_JOINT_LIMIT or theta3 < LOWER_ROLL_JOINT_LIMIT
+
+
 def get_circular_index(theta):
-    return int(numpy.floor((theta[1] - theta[0]) / numpy.pi))
+    return int(np.floor((theta[1] - theta[0]) / np.pi))
 
 
 def get_xy(theta):
-    theta1 = theta[0]
-    theta2 = theta[1]
-    x = numpy.cos(theta1) * l1 + numpy.cos(theta2) * l2 + joint_center[0]
-    y = numpy.sin(theta1) * l1 + numpy.sin(theta2) * l2 + joint_center[1]
-    return numpy.array((x, y))
+    theta1 = shift_angle(theta[0])
+    theta2 = shift_angle(theta[1])
+    x = np.cos(theta1) * l1 + np.cos(theta2) * l2 + joint_center[0]
+    y = np.sin(theta1) * l1 + np.sin(theta2) * l2 + joint_center[1]
+    return np.array((x, y))
 
 
 # Subdivide in theta space.
@@ -77,29 +242,23 @@
     return out
 
 
-# subdivide in xy space.
-def subdivide_xy(lines, max_dist=max_dist):
-    out = []
-    last_pt = lines[0]
-    out.append(last_pt)
-    for n_pt in lines[1:]:
-        for pt in subdivide(last_pt, n_pt, max_dist):
-            out.append(pt)
-        last_pt = n_pt
-
-    return out
-
-
 def to_theta_with_ci(pt, circular_index):
-    return to_theta_with_circular_index(pt[0], pt[1], circular_index)
+    return (to_theta_with_circular_index(pt[0], pt[1], circular_index))
 
 
 # to_theta, but distinguishes between
 def to_theta_with_circular_index(x, y, circular_index):
     theta1, theta2 = to_theta((x, y), circular_index)
-    n_circular_index = int(numpy.floor((theta2 - theta1) / numpy.pi))
-    theta2 = theta2 + ((circular_index - n_circular_index)) * numpy.pi
-    return numpy.array((theta1, theta2))
+    n_circular_index = int(np.floor((theta2 - theta1) / np.pi))
+    theta2 = theta2 + ((circular_index - n_circular_index)) * np.pi
+    return np.array((shift_angle(theta1), shift_angle(theta2)))
+
+
+# to_theta, but distinguishes between
+def to_theta_with_circular_index_and_roll(x, y, roll, circular_index):
+    theta12 = to_theta_with_circular_index(x, y, circular_index)
+    theta3 = roll
+    return np.array((theta12[0], theta12[1], theta3))
 
 
 # alpha is in [0, 1] and is the weight to merge a and b.
@@ -115,83 +274,24 @@
 
 def normalize(v):
     """Normalize a vector while handling 0 length vectors."""
-    norm = numpy.linalg.norm(v)
+    norm = np.linalg.norm(v)
     if norm == 0:
         return v
     return v / norm
 
 
-# CI is circular index and allows selecting between all the stats that map
-# to the same x-y state (by giving them an integer index).
-# This will compute approximate first and second derivatives with respect
-# to path length.
-def to_theta_with_circular_index_and_derivs(x, y, dx, dy,
-                                            circular_index_select):
-    a = to_theta_with_circular_index(x, y, circular_index_select)
-    b = to_theta_with_circular_index(x + dx * 0.0001, y + dy * 0.0001,
-                                     circular_index_select)
-    c = to_theta_with_circular_index(x - dx * 0.0001, y - dy * 0.0001,
-                                     circular_index_select)
-    d1 = normalize(b - a)
-    d2 = normalize(c - a)
-    accel = (d1 + d2) / numpy.linalg.norm(a - b)
-    return (a[0], a[1], d1[0], d1[1], accel[0], accel[1])
-
-
-def to_theta_with_ci_and_derivs(p_prev, p, p_next, c_i_select):
-    a = to_theta(p, c_i_select)
-    b = to_theta(p_next, c_i_select)
-    c = to_theta(p_prev, c_i_select)
-    d1 = normalize(b - a)
-    d2 = normalize(c - a)
-    accel = (d1 + d2) / numpy.linalg.norm(a - b)
-    return (a[0], a[1], d1[0], d1[1], accel[0], accel[1])
-
-
 # Generic subdivision algorithm.
 def subdivide(p1, p2, max_dist):
     dx = p2[0] - p1[0]
     dy = p2[1] - p1[1]
-    dist = numpy.sqrt(dx**2 + dy**2)
-    n = int(numpy.ceil(dist / max_dist))
+    dist = np.sqrt(dx**2 + dy**2)
+    n = int(np.ceil(dist / max_dist))
     return [(alpha_blend(p1[0], p2[0],
                          float(i) / n), alpha_blend(p1[1], p2[1],
                                                     float(i) / n))
             for i in range(1, n + 1)]
 
 
-# convert from an xy space loop into a theta loop.
-# All segements are expected go from one "hyper-extension" boundary
-# to another, thus we must go backwards over the "loop" to get a loop in
-# x-y space.
-def to_theta_loop(lines, cross_point=-numpy.pi):
-    out = []
-    last_pt = lines[0]
-    for n_pt in lines[1:]:
-        for pt in subdivide(last_pt, n_pt, max_dist):
-            out.append(to_theta(pt, 0, cross_point))
-        last_pt = n_pt
-    for n_pt in reversed(lines[:-1]):
-        for pt in subdivide(last_pt, n_pt, max_dist):
-            out.append(to_theta(pt, 1, cross_point))
-        last_pt = n_pt
-    return out
-
-
-# Convert a loop (list of line segments) into
-# The name incorrectly suggests that it is cyclic.
-def back_to_xy_loop(lines):
-    out = []
-    last_pt = lines[0]
-    out.append(to_xy(last_pt[0], last_pt[1]))
-    for n_pt in lines[1:]:
-        for pt in subdivide(last_pt, n_pt, max_dist_theta):
-            out.append(to_xy(pt[0], pt[1]))
-        last_pt = n_pt
-
-    return out
-
-
 def spline_eval(start, control1, control2, end, alpha):
     a = alpha_blend(start, control1, alpha)
     b = alpha_blend(control1, control2, alpha)
@@ -200,261 +300,181 @@
                        alpha)
 
 
-def subdivide_spline(start, control1, control2, end):
+SPLINE_SUBDIVISIONS = 100
+
+
+def subdivide_multistep():
     # TODO: pick N based on spline parameters? or otherwise change it to be more evenly spaced?
-    n = 100
-    for i in range(0, n + 1):
-        yield i / float(n)
+    for i in range(0, SPLINE_SUBDIVISIONS + 1):
+        yield i / float(SPLINE_SUBDIVISIONS)
 
 
-def get_derivs(t_prev, t, t_next):
-    c, a, b = t_prev, t, t_next
-    d1 = normalize(b - a)
-    d2 = normalize(c - a)
-    accel = (d1 + d2) / numpy.linalg.norm(a - b)
-    return (a[0], a[1], d1[0], d1[1], accel[0], accel[1])
+def get_proximal_distal_derivs(t_prev, t, t_next):
+    d_prev = normalize(t - t_prev)
+    d_next = normalize(t_next - t)
+    accel = (d_next - d_prev) / np.linalg.norm(t - t_next)
+    return (ThetaPoint(t[0], d_next[0],
+                       accel[0]), ThetaPoint(t[1], d_next[1], accel[1]))
 
 
-# Draw lines to cr + stroke.
+def get_roll_joint_theta(theta_i, theta_f, t):
+    # Fit a theta(t) = (1 - cos(pi*t)) / 2,
+    # so that theta(0) = theta_i, and theta(1) = theta_f
+    offset = theta_i
+    scalar = (theta_f - theta_i) / 2.0
+    freq = np.pi
+    theta_curve = lambda t: scalar * (1 - np.cos(freq * t)) + offset
+
+    return theta_curve(t)
+
+
+def get_roll_joint_theta_multistep(alpha_rolls, alpha):
+    # Figure out which segment in the motion we're in
+    theta_i = None
+    theta_f = None
+    t = None
+
+    for i in range(len(alpha_rolls) - 1):
+        # Find the alpha segment we're in
+        if alpha_rolls[i][0] <= alpha <= alpha_rolls[i + 1][0]:
+            theta_i = alpha_rolls[i][1]
+            theta_f = alpha_rolls[i + 1][1]
+
+            total_dalpha = alpha_rolls[-1][0] - alpha_rolls[0][0]
+            assert total_dalpha == 1.0
+            dalpha = alpha_rolls[i + 1][0] - alpha_rolls[i][0]
+            t = (alpha - alpha_rolls[i][0]) * (total_dalpha / dalpha)
+            break
+    assert theta_i is not None
+    assert theta_f is not None
+    assert t is not None
+
+    return get_roll_joint_theta(theta_i, theta_f, t)
+
+
+# Draw a list of lines to a cairo context.
 def draw_lines(cr, lines):
     cr.move_to(lines[0][0], lines[0][1])
     for pt in lines[1:]:
         cr.line_to(pt[0], pt[1])
-    with px(cr):
-        cr.stroke()
 
 
-# Segment in angle space.
-class AngleSegment:
+class Path(abc.ABC):
 
-    def __init__(self, start, end, name=None, alpha_unitizer=None, vmax=None):
-        """Creates an angle segment.
-
-        Args:
-          start: (double, double),  The start of the segment in theta1, theta2
-              coordinates in radians
-          end: (double, double),  The end of the segment in theta1, theta2
-              coordinates in radians
-        """
-        self.start = start
-        self.end = end
+    def __init__(self, name):
         self.name = name
-        self.alpha_unitizer = alpha_unitizer
-        self.vmax = vmax
 
-    def __repr__(self):
-        return "AngleSegment(%s, %s)" % (repr(self.start), repr(self.end))
+    @abc.abstractmethod
+    def DoToThetaPoints(self):
+        pass
+
+    @abc.abstractmethod
+    def DoDrawTo(self):
+        pass
+
+    @abc.abstractmethod
+    def roll_joint_thetas(self):
+        pass
+
+    @abc.abstractmethod
+    def intersection(self, event):
+        pass
+
+    def roll_joint_collision(self, points, verbose=False):
+        for point in points:
+            if roll_joint_collision(*point):
+                if verbose:
+                    print("Roll joint collision for path %s in point %s" %
+                          (self.name, point))
+                return True
+        return False
+
+    def arm_past_limit(self, points, verbose=True):
+        for point in points:
+            if arm_past_limit(*point):
+                if verbose:
+                    print("Arm past limit for path %s in point %s" %
+                          (self.name, point))
+                return True
+        return False
 
     def DrawTo(self, cr, theta_version):
-        if theta_version:
-            cr.move_to(self.start[0], self.start[1] + theta_end_circle_size)
-            cr.arc(self.start[0], self.start[1], theta_end_circle_size, 0,
-                   2.0 * numpy.pi)
-            cr.move_to(self.end[0], self.end[1] + theta_end_circle_size)
-            cr.arc(self.end[0], self.end[1], theta_end_circle_size, 0,
-                   2.0 * numpy.pi)
-            cr.move_to(self.start[0], self.start[1])
-            cr.line_to(self.end[0], self.end[1])
-        else:
-            start_xy = to_xy(self.start[0], self.start[1])
-            end_xy = to_xy(self.end[0], self.end[1])
-            draw_lines(cr, back_to_xy_loop([self.start, self.end]))
-            cr.move_to(start_xy[0] + xy_end_circle_size, start_xy[1])
-            cr.arc(start_xy[0], start_xy[1], xy_end_circle_size, 0,
-                   2.0 * numpy.pi)
-            cr.move_to(end_xy[0] + xy_end_circle_size, end_xy[1])
-            cr.arc(end_xy[0], end_xy[1], xy_end_circle_size, 0, 2.0 * numpy.pi)
+        points = self.DoToThetaPoints()
+        if self.roll_joint_collision(points):
+            # Draw the spline red
+            cr.set_source_rgb(1.0, 0.0, 0.0)
+        elif self.arm_past_limit(points):
+            # Draw the spline orange
+            cr.set_source_rgb(1.0, 0.5, 0.0)
 
-    def ToThetaPoints(self):
-        dx = self.end[0] - self.start[0]
-        dy = self.end[1] - self.start[1]
-        mag = numpy.hypot(dx, dy)
-        dx /= mag
-        dy /= mag
+        self.DoDrawTo(cr, theta_version)
 
-        return [(self.start[0], self.start[1], dx, dy, 0.0, 0.0),
-                (self.end[0], self.end[1], dx, dy, 0.0, 0.0)]
+    def VerifyPoints(self):
+        points = self.DoToThetaPoints()
+        if self.roll_joint_collision(points, verbose=True) or \
+           self.arm_past_limit(points, verbose=True):
+            sys.exit(1)
 
 
-class XYSegment:
-    """Straight line in XY space."""
+class SplineSegmentBase(Path):
 
-    def __init__(self, start, end, name=None, alpha_unitizer=None, vmax=None):
-        """Creates an XY segment.
+    def __init__(self, name):
+        super().__init__(name)
 
-        Args:
-          start: (double, double),  The start of the segment in theta1, theta2
-              coordinates in radians
-          end: (double, double),  The end of the segment in theta1, theta2
-              coordinates in radians
-        """
-        self.start = start
-        self.end = end
-        self.name = name
-        self.alpha_unitizer = alpha_unitizer
-        self.vmax = vmax
+    @abc.abstractmethod
+    # Returns (start, control1, control2, end), each in the form
+    # (theta1, theta2, theta3)
+    def get_controls_theta(self):
+        pass
 
-    def __repr__(self):
-        return "XYSegment(%s, %s)" % (repr(self.start), repr(self.end))
-
-    def DrawTo(self, cr, theta_version):
-        if theta_version:
-            theta1, theta2 = self.start
-            circular_index_select = int(
-                numpy.floor((self.start[1] - self.start[0]) / numpy.pi))
-            start = get_xy(self.start)
-            end = get_xy(self.end)
-
-            ln = [(start[0], start[1]), (end[0], end[1])]
-            draw_lines(cr, [
-                to_theta_with_circular_index(x, y, circular_index_select)
-                for x, y in subdivide_xy(ln)
-            ])
-            cr.move_to(self.start[0] + theta_end_circle_size, self.start[1])
-            cr.arc(self.start[0], self.start[1], theta_end_circle_size, 0,
-                   2.0 * numpy.pi)
-            cr.move_to(self.end[0] + theta_end_circle_size, self.end[1])
-            cr.arc(self.end[0], self.end[1], theta_end_circle_size, 0,
-                   2.0 * numpy.pi)
-        else:
-            start = get_xy(self.start)
-            end = get_xy(self.end)
-            cr.move_to(start[0], start[1])
-            cr.line_to(end[0], end[1])
-            cr.move_to(start[0] + xy_end_circle_size, start[1])
-            cr.arc(start[0], start[1], xy_end_circle_size, 0, 2.0 * numpy.pi)
-            cr.move_to(end[0] + xy_end_circle_size, end[1])
-            cr.arc(end[0], end[1], xy_end_circle_size, 0, 2.0 * numpy.pi)
-
-    def ToThetaPoints(self):
-        """ Converts to points in theta space via to_theta_with_circular_index_and_derivs"""
-        theta1, theta2 = self.start
-        circular_index_select = int(
-            numpy.floor((self.start[1] - self.start[0]) / numpy.pi))
-        start = get_xy(self.start)
-        end = get_xy(self.end)
-
-        ln = [(start[0], start[1]), (end[0], end[1])]
-
-        dx = end[0] - start[0]
-        dy = end[1] - start[1]
-        mag = numpy.hypot(dx, dy)
-        dx /= mag
-        dy /= mag
-
-        return [
-            to_theta_with_circular_index_and_derivs(x, y, dx, dy,
-                                                    circular_index_select)
-            for x, y in subdivide_xy(ln, 0.01)
-        ]
+    def intersection(self, event):
+        start, control1, control2, end = self.get_controls_theta()
+        for alpha in subdivide_multistep():
+            x, y = get_xy(spline_eval(start, control1, control2, end, alpha))
+            spline_point = np.array([x, y])
+            hovered_point = np.array([event.x, event.y])
+            if np.linalg.norm(hovered_point - spline_point) < 0.03:
+                return alpha
+        return None
 
 
-class SplineSegment:
+class ThetaSplineSegment(SplineSegmentBase):
 
+    # start and end are [theta1, theta2, theta3].
+    # controls are just [theta1, theta2].
+    # control_alpha_rolls are a list of [alpha, roll]
     def __init__(self,
+                 name,
                  start,
                  control1,
                  control2,
                  end,
-                 name=None,
+                 control_alpha_rolls=[],
                  alpha_unitizer=None,
                  vmax=None):
-        self.start = start
+        super().__init__(name)
+        self.start = start[:2]
         self.control1 = control1
         self.control2 = control2
-        self.end = end
-        self.name = name
+        self.end = end[:2]
+        # There will always be roll at alpha = 0 and 1
+        self.alpha_rolls = [[0.0, start[2]]
+                            ] + control_alpha_rolls + [[1.0, end[2]]]
         self.alpha_unitizer = alpha_unitizer
         self.vmax = vmax
 
     def __repr__(self):
-        return "SplineSegment(%s, %s, %s, %s)" % (repr(
+        return "ThetaSplineSegment(%s, %s, %s, %s)" % (repr(
             self.start), repr(self.control1), repr(
                 self.control2), repr(self.end))
 
-    def DrawTo(self, cr, theta_version):
-        if theta_version:
-            c_i_select = get_circular_index(self.start)
-            start = get_xy(self.start)
-            control1 = get_xy(self.control1)
-            control2 = get_xy(self.control2)
-            end = get_xy(self.end)
-
-            draw_lines(cr, [
-                to_theta(spline_eval(start, control1, control2, end, alpha),
-                         c_i_select)
-                for alpha in subdivide_spline(start, control1, control2, end)
-            ])
-            cr.move_to(self.start[0] + theta_end_circle_size, self.start[1])
-            cr.arc(self.start[0], self.start[1], theta_end_circle_size, 0,
-                   2.0 * numpy.pi)
-            cr.move_to(self.end[0] + theta_end_circle_size, self.end[1])
-            cr.arc(self.end[0], self.end[1], theta_end_circle_size, 0,
-                   2.0 * numpy.pi)
-        else:
-            start = get_xy(self.start)
-            control1 = get_xy(self.control1)
-            control2 = get_xy(self.control2)
-            end = get_xy(self.end)
-
-            draw_lines(cr, [
-                spline_eval(start, control1, control2, end, alpha)
-                for alpha in subdivide_spline(start, control1, control2, end)
-            ])
-
-            cr.move_to(start[0] + xy_end_circle_size, start[1])
-            cr.arc(start[0], start[1], xy_end_circle_size, 0, 2.0 * numpy.pi)
-            cr.move_to(end[0] + xy_end_circle_size, end[1])
-            cr.arc(end[0], end[1], xy_end_circle_size, 0, 2.0 * numpy.pi)
-
-    def ToThetaPoints(self):
-        t1, t2 = self.start
-        c_i_select = get_circular_index(self.start)
-        start = get_xy(self.start)
-        control1 = get_xy(self.control1)
-        control2 = get_xy(self.control2)
-        end = get_xy(self.end)
-
-        return [
-            to_theta_with_ci_and_derivs(
-                spline_eval(start, control1, control2, end, alpha - 0.00001),
-                spline_eval(start, control1, control2, end, alpha),
-                spline_eval(start, control1, control2, end, alpha + 0.00001),
-                c_i_select)
-            for alpha in subdivide_spline(start, control1, control2, end)
-        ]
-
-
-class ThetaSplineSegment:
-
-    def __init__(self,
-                 start,
-                 control1,
-                 control2,
-                 end,
-                 name=None,
-                 alpha_unitizer=None,
-                 vmax=None):
-        self.start = start
-        self.control1 = control1
-        self.control2 = control2
-        self.end = end
-        self.name = name
-        self.alpha_unitizer = alpha_unitizer
-        self.vmax = vmax
-
-    def __repr__(self):
-        return "ThetaSplineSegment(%s, %s, &s, %s)" % (repr(
-            self.start), repr(self.control1), repr(
-                self.control2), repr(self.end))
-
-    def DrawTo(self, cr, theta_version):
+    def DoDrawTo(self, cr, theta_version):
         if (theta_version):
             draw_lines(cr, [
-                spline_eval(self.start, self.control1, self.control2, self.end,
-                            alpha)
-                for alpha in subdivide_spline(self.start, self.control1,
-                                              self.control2, self.end)
+                shift_angles(
+                    spline_eval(self.start, self.control1, self.control2,
+                                self.end, alpha))
+                for alpha in subdivide_multistep()
             ])
         else:
             start = get_xy(self.start)
@@ -464,70 +484,34 @@
                 get_xy(
                     spline_eval(self.start, self.control1, self.control2,
                                 self.end, alpha))
-                for alpha in subdivide_spline(self.start, self.control1,
-                                              self.control2, self.end)
+                for alpha in subdivide_multistep()
             ])
 
             cr.move_to(start[0] + xy_end_circle_size, start[1])
-            cr.arc(start[0], start[1], xy_end_circle_size, 0, 2.0 * numpy.pi)
+            cr.arc(start[0], start[1], xy_end_circle_size, 0, 2.0 * np.pi)
             cr.move_to(end[0] + xy_end_circle_size, end[1])
-            cr.arc(end[0], end[1], xy_end_circle_size, 0, 2.0 * numpy.pi)
+            cr.arc(end[0], end[1], xy_end_circle_size, 0, 2.0 * np.pi)
 
-    def ToThetaPoints(self):
-        return [
-            get_derivs(
-                spline_eval(self.start, self.control1, self.control2, self.end,
-                            alpha - 0.00001),
-                spline_eval(self.start, self.control1, self.control2, self.end,
-                            alpha),
-                spline_eval(self.start, self.control1, self.control2, self.end,
-                            alpha + 0.00001))
-            for alpha in subdivide_spline(self.start, self.control1,
-                                          self.control2, self.end)
-        ]
+    def DoToThetaPoints(self):
+        points = []
+        for alpha in subdivide_multistep():
+            proximal, distal = spline_eval(self.start, self.control1,
+                                           self.control2, self.end, alpha)
+            roll_joint = get_roll_joint_theta_multistep(
+                self.alpha_rolls, alpha)
+            points.append((proximal, distal, roll_joint))
 
+        return points
 
-def expand_points(points, max_distance):
-    """Expands a list of points to be at most max_distance apart
+    def get_controls_theta(self):
+        return (self.start, self.control1, self.control2, self.end)
 
-    Generates the paths to connect the new points to the closest input points,
-    and the paths connecting the points.
-
-    Args:
-      points, list of tuple of point, name, The points to start with and fill
-          in.
-      max_distance, float, The max distance between two points when expanding
-          the graph.
-
-    Return:
-      points, edges
-    """
-    result_points = [points[0]]
-    result_paths = []
-    for point, name in points[1:]:
-        previous_point = result_points[-1][0]
-        previous_point_xy = get_xy(previous_point)
-        circular_index = get_circular_index(previous_point)
-
-        point_xy = get_xy(point)
-        norm = numpy.linalg.norm(point_xy - previous_point_xy)
-        num_points = int(numpy.ceil(norm / max_distance))
-        last_iteration_point = previous_point
-        for subindex in range(1, num_points):
-            subpoint = to_theta(alpha_blend(previous_point_xy, point_xy,
-                                            float(subindex) / num_points),
-                                circular_index=circular_index)
-            result_points.append(
-                (subpoint, '%s%dof%d' % (name, subindex, num_points)))
-            result_paths.append(
-                XYSegment(last_iteration_point, subpoint, vmax=6.0))
-            if (last_iteration_point != previous_point).any():
-                result_paths.append(XYSegment(previous_point, subpoint))
-            if subindex == num_points - 1:
-                result_paths.append(XYSegment(subpoint, point, vmax=6.0))
-            else:
-                result_paths.append(XYSegment(subpoint, point))
-            last_iteration_point = subpoint
-        result_points.append((point, name))
-
-    return result_points, result_paths
+    def roll_joint_thetas(self):
+        ts = []
+        thetas = []
+        for alpha in subdivide_multistep():
+            roll_joint = get_roll_joint_theta_multistep(
+                self.alpha_rolls, alpha)
+            thetas.append(roll_joint)
+            ts.append(alpha)
+        return ts, thetas
diff --git a/y2023/control_loops/python/roll.py b/y2023/control_loops/python/roll.py
new file mode 100644
index 0000000..ce0fac0
--- /dev/null
+++ b/y2023/control_loops/python/roll.py
@@ -0,0 +1,68 @@
+#!/usr/bin/python3
+
+from aos.util.trapezoid_profile import TrapezoidProfile
+from frc971.control_loops.python import control_loop
+from frc971.control_loops.python import angular_system
+from frc971.control_loops.python import controls
+import numpy
+import sys
+from matplotlib import pylab
+import gflags
+import glog
+
+FLAGS = gflags.FLAGS
+
+try:
+    gflags.DEFINE_bool('plot', False, 'If true, plot the loop response.')
+except gflags.DuplicateFlagError:
+    pass
+
+gflags.DEFINE_bool('hybrid', False, 'If true, make it hybrid.')
+
+kRoll = angular_system.AngularSystemParams(
+    name='Roll',
+    motor=control_loop.BAG(),
+    G=18.0 / 48.0 * 1.0 / 36.0,
+    # 598.006 in^2 lb
+    J=0.175,
+    q_pos=0.40,
+    q_vel=20.0,
+    kalman_q_pos=0.12,
+    kalman_q_vel=2.0,
+    kalman_q_voltage=4.0,
+    kalman_r_position=0.05,
+    radius=13 * 0.0254)
+
+
+def main(argv):
+    if FLAGS.plot:
+        R = numpy.matrix([[numpy.pi / 2.0], [0.0]])
+        angular_system.PlotKick(kRoll, R)
+        angular_system.PlotMotion(kRoll, R)
+        return
+
+    # Write the generated constants out to a file.
+    if len(argv) != 5:
+        glog.fatal(
+            'Expected .h file name and .cc file name for the intake and integral intake.'
+        )
+    else:
+        namespaces = ['y2023', 'control_loops', 'superstructure', 'roll']
+        if FLAGS.hybrid:
+            kRoll.name = 'HybridRoll'
+            angular_system.WriteAngularSystem(
+                kRoll,
+                argv[1:3],
+                argv[3:5],
+                namespaces,
+                plant_type='StateFeedbackHybridPlant',
+                observer_type='HybridKalman')
+        else:
+            angular_system.WriteAngularSystem(kRoll, argv[1:3], argv[3:5],
+                                              namespaces)
+
+
+if __name__ == '__main__':
+    argv = FLAGS(sys.argv)
+    glog.init()
+    sys.exit(main(argv))
diff --git a/y2023/control_loops/python/turret.py b/y2023/control_loops/python/turret.py
new file mode 100644
index 0000000..4e8f117
--- /dev/null
+++ b/y2023/control_loops/python/turret.py
@@ -0,0 +1,54 @@
+#!/usr/bin/python3
+
+from aos.util.trapezoid_profile import TrapezoidProfile
+from frc971.control_loops.python import control_loop
+from frc971.control_loops.python import angular_system
+from frc971.control_loops.python import controls
+import numpy
+import sys
+from matplotlib import pylab
+import gflags
+import glog
+
+FLAGS = gflags.FLAGS
+
+try:
+    gflags.DEFINE_bool('plot', False, 'If true, plot the loop response.')
+except gflags.DuplicateFlagError:
+    pass
+
+kTurret = angular_system.AngularSystemParams(name='Turret',
+                                             motor=control_loop.Falcon(),
+                                             G=0.01,
+                                             J=3.1,
+                                             q_pos=0.40,
+                                             q_vel=20.0,
+                                             kalman_q_pos=0.12,
+                                             kalman_q_vel=2.0,
+                                             kalman_q_voltage=4.0,
+                                             kalman_r_position=0.05,
+                                             radius=25 * 0.0254)
+
+
+def main(argv):
+    if FLAGS.plot:
+        R = numpy.matrix([[numpy.pi / 2.0], [0.0]])
+        angular_system.PlotKick(kTurret, R)
+        angular_system.PlotMotion(kTurret, R)
+        return
+
+    # Write the generated constants out to a file.
+    if len(argv) != 5:
+        glog.fatal(
+            'Expected .h file name and .cc file name for the wrist and integral wrist.'
+        )
+    else:
+        namespaces = ['y2023', 'control_loops', 'superstructure', 'turret']
+        angular_system.WriteAngularSystem(kTurret, argv[1:3], argv[3:5],
+                                          namespaces)
+
+
+if __name__ == '__main__':
+    argv = FLAGS(sys.argv)
+    glog.init()
+    sys.exit(main(argv))
diff --git a/y2023/control_loops/python/wrist.py b/y2023/control_loops/python/wrist.py
new file mode 100644
index 0000000..1970c5b
--- /dev/null
+++ b/y2023/control_loops/python/wrist.py
@@ -0,0 +1,57 @@
+#!/usr/bin/python3
+
+from aos.util.trapezoid_profile import TrapezoidProfile
+from frc971.control_loops.python import control_loop
+from frc971.control_loops.python import angular_system
+from frc971.control_loops.python import controls
+import numpy
+import sys
+from matplotlib import pylab
+import gflags
+import glog
+
+FLAGS = gflags.FLAGS
+
+try:
+    gflags.DEFINE_bool('plot', False, 'If true, plot the loop response.')
+except gflags.DuplicateFlagError:
+    pass
+
+kWrist = angular_system.AngularSystemParams(
+    name='Wrist',
+    motor=control_loop.BAG(),
+    G=(6.0 / 48.0) * (20.0 / 100.0) * (24.0 / 36.0) * (36.0 / 60.0),
+    # Use parallel axis theorem to get the moment of inertia around
+    # the joint (I = I_cm + mh^2 = 0.001877 + 0.8332 * 0.0407162^2)
+    J=0.003258,
+    q_pos=0.40,
+    q_vel=20.0,
+    kalman_q_pos=0.12,
+    kalman_q_vel=2.0,
+    kalman_q_voltage=4.0,
+    kalman_r_position=0.05,
+    radius=5.71 * 0.0254)
+
+
+def main(argv):
+    if FLAGS.plot:
+        R = numpy.matrix([[numpy.pi / 2.0], [0.0]])
+        angular_system.PlotKick(kWrist, R)
+        angular_system.PlotMotion(kWrist, R)
+        return
+
+    # Write the generated constants out to a file.
+    if len(argv) != 5:
+        glog.fatal(
+            'Expected .h file name and .cc file name for the wrist and integral wrist.'
+        )
+    else:
+        namespaces = ['y2023', 'control_loops', 'superstructure', 'wrist']
+        angular_system.WriteAngularSystem(kWrist, argv[1:3], argv[3:5],
+                                          namespaces)
+
+
+if __name__ == '__main__':
+    argv = FLAGS(sys.argv)
+    glog.init()
+    sys.exit(main(argv))
diff --git a/y2023/control_loops/superstructure/BUILD b/y2023/control_loops/superstructure/BUILD
index 5246b3e..52254e4 100644
--- a/y2023/control_loops/superstructure/BUILD
+++ b/y2023/control_loops/superstructure/BUILD
@@ -1,6 +1,6 @@
+load("//tools/build_rules:js.bzl", "ts_project")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -68,7 +68,6 @@
         "superstructure.h",
     ],
     deps = [
-        # ":collision_avoidance_lib",
         ":superstructure_goal_fbs",
         ":superstructure_output_fbs",
         ":superstructure_position_fbs",
@@ -78,6 +77,7 @@
         "//frc971/control_loops:control_loop",
         "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
         "//y2023:constants",
+        "//y2023/control_loops/superstructure/arm",
     ],
 )
 
@@ -117,6 +117,7 @@
         "//frc971/control_loops:subsystem_simulator",
         "//frc971/control_loops:team_number_test_environment",
         "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
+        "//y2023/control_loops/superstructure/roll:roll_plants",
     ],
 )
 
@@ -133,7 +134,7 @@
     ],
 )
 
-ts_library(
+ts_project(
     name = "superstructure_plotter",
     srcs = ["superstructure_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/y2023/control_loops/superstructure/arm/BUILD b/y2023/control_loops/superstructure/arm/BUILD
new file mode 100644
index 0000000..ce226d1
--- /dev/null
+++ b/y2023/control_loops/superstructure/arm/BUILD
@@ -0,0 +1,129 @@
+cc_library(
+    name = "arm",
+    srcs = [
+        "arm.cc",
+    ],
+    hdrs = [
+        "arm.h",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":generated_graph",
+        "//frc971/control_loops/double_jointed_arm:ekf",
+        "//frc971/control_loops/double_jointed_arm:graph",
+        "//frc971/zeroing",
+        "//y2023:constants",
+        "//y2023/control_loops/superstructure:superstructure_position_fbs",
+        "//y2023/control_loops/superstructure:superstructure_status_fbs",
+        "//y2023/control_loops/superstructure/arm:arm_constants",
+        "//y2023/control_loops/superstructure/arm:trajectory",
+        "//y2023/control_loops/superstructure/roll:roll_plants",
+    ],
+)
+
+genrule(
+    name = "generated_graph_genrule",
+    outs = [
+        "generated_graph.h",
+        "generated_graph.cc",
+    ],
+    cmd = "$(location //y2023/control_loops/python:graph_codegen) $(OUTS)",
+    target_compatible_with = ["@platforms//os:linux"],
+    tools = [
+        "//y2023/control_loops/python:graph_codegen",
+    ],
+)
+
+cc_library(
+    name = "generated_graph",
+    srcs = [
+        "generated_graph.cc",
+    ],
+    hdrs = ["generated_graph.h"],
+    copts = [
+        "-O1",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":arm_constants",
+        "//frc971/control_loops/double_jointed_arm:graph",
+        "//y2023/control_loops/superstructure/arm:trajectory",
+        "//y2023/control_loops/superstructure/roll:roll_plants",
+    ],
+)
+
+cc_library(
+    name = "arm_constants",
+    hdrs = ["arm_constants.h"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//frc971/control_loops/double_jointed_arm:dynamics",
+    ],
+)
+
+cc_binary(
+    name = "arm_design",
+    srcs = [
+        "arm_design.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":arm_constants",
+        "//aos:init",
+        "//frc971/analysis:in_process_plotter",
+        "//frc971/control_loops:dlqr",
+        "//frc971/control_loops:jacobian",
+        "//frc971/control_loops/double_jointed_arm:dynamics",
+    ],
+)
+
+cc_library(
+    name = "trajectory",
+    srcs = ["trajectory.cc"],
+    hdrs = ["trajectory.h"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        "//frc971/control_loops:binomial",
+        "//frc971/control_loops:dlqr",
+        "//frc971/control_loops:fixed_quadrature",
+        "//frc971/control_loops:hybrid_state_feedback_loop",
+        "//frc971/control_loops/double_jointed_arm:dynamics",
+        "//frc971/control_loops/double_jointed_arm:ekf",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_test(
+    name = "trajectory_test",
+    srcs = ["trajectory_test.cc"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":arm_constants",
+        ":trajectory",
+        "//aos/testing:googletest",
+        "//y2023/control_loops/superstructure/roll:roll_plants",
+    ],
+)
+
+cc_binary(
+    name = "trajectory_plot",
+    srcs = [
+        "trajectory_plot.cc",
+    ],
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    deps = [
+        ":arm_constants",
+        ":generated_graph",
+        ":trajectory",
+        "//frc971/analysis:in_process_plotter",
+        "//frc971/control_loops:binomial",
+        "//frc971/control_loops:fixed_quadrature",
+        "//frc971/control_loops/double_jointed_arm:ekf",
+        "//y2023/control_loops/superstructure/roll:roll_plants",
+        "@com_github_gflags_gflags//:gflags",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
diff --git a/y2023/control_loops/superstructure/arm/arm.cc b/y2023/control_loops/superstructure/arm/arm.cc
new file mode 100644
index 0000000..5417dc8
--- /dev/null
+++ b/y2023/control_loops/superstructure/arm/arm.cc
@@ -0,0 +1,322 @@
+#include "y2023/control_loops/superstructure/arm/arm.h"
+
+#include "y2023/control_loops/superstructure/roll/integral_hybrid_roll_plant.h"
+#include "y2023/control_loops/superstructure/roll/integral_roll_plant.h"
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+namespace arm {
+namespace {
+
+namespace chrono = ::std::chrono;
+using ::aos::monotonic_clock;
+
+constexpr int kMaxBrownoutCount = 4;
+
+}  // namespace
+
+Arm::Arm(std::shared_ptr<const constants::Values> values)
+    : values_(values),
+      state_(ArmState::UNINITIALIZED),
+      proximal_zeroing_estimator_(values_->arm_proximal.zeroing),
+      distal_zeroing_estimator_(values_->arm_distal.zeroing),
+      roll_joint_zeroing_estimator_(values_->roll_joint.zeroing),
+      proximal_offset_(0.0),
+      distal_offset_(0.0),
+      roll_joint_offset_(0.0),
+      alpha_unitizer_((::Eigen::DiagonalMatrix<double, 3>().diagonal()
+                           << (1.0 / kAlpha0Max()),
+                       (1.0 / kAlpha1Max()), (1.0 / kAlpha2Max()))
+                          .finished()),
+      dynamics_(kArmConstants),
+      close_enough_for_full_power_(false),
+      brownout_count_(0),
+      roll_joint_loop_(roll::MakeIntegralRollLoop()),
+      hybrid_roll_joint_loop_(roll::MakeIntegralHybridRollLoop()),
+      arm_ekf_(&dynamics_),
+      search_graph_(MakeSearchGraph(&dynamics_, &trajectories_, alpha_unitizer_,
+                                    kVMax(), &hybrid_roll_joint_loop_)),
+      // Go to the start of the first trajectory.
+      follower_(&dynamics_, &hybrid_roll_joint_loop_, NeutralPosPoint()),
+      points_(PointList()),
+      current_node_(0) {
+  int i = 0;
+  for (const auto &trajectory : trajectories_) {
+    AOS_LOG(INFO, "trajectory length for edge node %d: %f\n", i,
+            trajectory.trajectory.path().length());
+    ++i;
+  }
+}
+
+void Arm::Reset() { state_ = ArmState::UNINITIALIZED; }
+
+flatbuffers::Offset<superstructure::ArmStatus> Arm::Iterate(
+    const ::aos::monotonic_clock::time_point /*monotonic_now*/,
+    const uint32_t *unsafe_goal, const superstructure::ArmPosition *position,
+    bool trajectory_override, double *proximal_output, double *distal_output,
+    double *roll_joint_output, bool /*intake*/, bool /*spit*/,
+    flatbuffers::FlatBufferBuilder *fbb) {
+  const bool outputs_disabled =
+      ((proximal_output == nullptr) || (distal_output == nullptr) ||
+       (roll_joint_output == nullptr));
+  if (outputs_disabled) {
+    ++brownout_count_;
+  } else {
+    brownout_count_ = 0;
+  }
+
+  // TODO(milind): should we default to the closest position?
+  uint32_t filtered_goal = arm::NeutralPosIndex();
+  if (unsafe_goal != nullptr) {
+    filtered_goal = *unsafe_goal;
+  }
+
+  ::Eigen::Matrix<double, 2, 1> Y_arm;
+  Y_arm << position->proximal()->encoder() + proximal_offset_,
+      position->distal()->encoder() + distal_offset_;
+  ::Eigen::Matrix<double, 1, 1> Y_roll_joint;
+  Y_roll_joint << position->roll_joint()->encoder() + roll_joint_offset_;
+
+  proximal_zeroing_estimator_.UpdateEstimate(*position->proximal());
+  distal_zeroing_estimator_.UpdateEstimate(*position->distal());
+  roll_joint_zeroing_estimator_.UpdateEstimate(*position->roll_joint());
+
+  if (proximal_output != nullptr) {
+    *proximal_output = 0.0;
+  }
+  if (distal_output != nullptr) {
+    *distal_output = 0.0;
+  }
+  if (roll_joint_output != nullptr) {
+    *roll_joint_output = 0.0;
+  }
+
+  arm_ekf_.Correct(Y_arm, kDt());
+  roll_joint_loop_.Correct(Y_roll_joint);
+
+  if (::std::abs(arm_ekf_.X_hat(0) - follower_.theta(0)) <= 0.05 &&
+      ::std::abs(arm_ekf_.X_hat(2) - follower_.theta(1)) <= 0.05 &&
+      ::std::abs(roll_joint_loop_.X_hat(0) - follower_.theta(2)) <= 0.05) {
+    close_enough_for_full_power_ = true;
+  }
+  if (::std::abs(arm_ekf_.X_hat(0) - follower_.theta(0)) >= 1.10 ||
+      ::std::abs(arm_ekf_.X_hat(2) - follower_.theta(1)) >= 1.10 ||
+      ::std::abs(roll_joint_loop_.X_hat(0) - follower_.theta(2)) >= 0.50) {
+    close_enough_for_full_power_ = false;
+  }
+
+  switch (state_) {
+    case ArmState::UNINITIALIZED:
+      // Wait in the uninitialized state until the intake is initialized.
+      AOS_LOG(DEBUG, "Uninitialized, waiting for intake\n");
+      state_ = ArmState::ZEROING;
+      proximal_zeroing_estimator_.Reset();
+      distal_zeroing_estimator_.Reset();
+      roll_joint_zeroing_estimator_.Reset();
+      break;
+
+    case ArmState::ZEROING:
+      // Zero by not moving.
+      if (zeroed()) {
+        state_ = ArmState::DISABLED;
+
+        proximal_offset_ = proximal_zeroing_estimator_.offset();
+        distal_offset_ = distal_zeroing_estimator_.offset();
+        roll_joint_offset_ = roll_joint_zeroing_estimator_.offset();
+
+        Y_arm << position->proximal()->encoder() + proximal_offset_,
+            position->distal()->encoder() + distal_offset_;
+        Y_roll_joint << position->roll_joint()->encoder() + roll_joint_offset_;
+
+        // TODO(austin): Offset ekf rather than reset it.  Since we aren't
+        // moving at this point, it's pretty safe to do this.
+        ::Eigen::Matrix<double, 4, 1> X_arm;
+        X_arm << Y_arm(0), 0.0, Y_arm(1), 0.0;
+        arm_ekf_.Reset(X_arm);
+
+        ::Eigen::Matrix<double, 3, 1> X_roll_joint;
+        X_roll_joint << Y_roll_joint(0), 0.0, 0.0;
+        roll_joint_loop_.mutable_X_hat() = X_roll_joint;
+      } else {
+        break;
+      }
+      [[fallthrough]];
+
+    case ArmState::DISABLED: {
+      follower_.SwitchTrajectory(nullptr);
+      close_enough_for_full_power_ = false;
+
+      const ::Eigen::Matrix<double, 3, 1> current_theta =
+          (::Eigen::Matrix<double, 3, 1>() << arm_ekf_.X_hat(0),
+           arm_ekf_.X_hat(2), roll_joint_loop_.X_hat(0))
+              .finished();
+      uint32_t best_index = 0;
+      double best_distance = (points_[0] - current_theta).norm();
+      uint32_t current_index = 0;
+      for (const ::Eigen::Matrix<double, 3, 1> &point : points_) {
+        const double new_distance = (point - current_theta).norm();
+        if (new_distance < best_distance) {
+          best_distance = new_distance;
+          best_index = current_index;
+        }
+        ++current_index;
+      }
+      follower_.set_theta(points_[best_index]);
+      current_node_ = best_index;
+
+      if (!outputs_disabled) {
+        state_ = ArmState::GOTO_PATH;
+      } else {
+        break;
+      }
+    }
+      [[fallthrough]];
+
+    case ArmState::GOTO_PATH:
+      if (outputs_disabled) {
+        state_ = ArmState::DISABLED;
+      } else if (trajectory_override) {
+        follower_.SwitchTrajectory(nullptr);
+        current_node_ = filtered_goal;
+        follower_.set_theta(points_[current_node_]);
+        state_ = ArmState::GOTO_PATH;
+      } else if (close_enough_for_full_power_) {
+        state_ = ArmState::RUNNING;
+      }
+      break;
+
+    case ArmState::RUNNING:
+      // ESTOP if we hit the hard limits.
+      // TODO(austin): Pick some sane limits.
+      if (proximal_zeroing_estimator_.error() ||
+          distal_zeroing_estimator_.error() ||
+          roll_joint_zeroing_estimator_.error()) {
+        AOS_LOG(ERROR, "Zeroing error ESTOP\n");
+        state_ = ArmState::ESTOP;
+      } else if (outputs_disabled && brownout_count_ > kMaxBrownoutCount) {
+        state_ = ArmState::DISABLED;
+      } else if (trajectory_override) {
+        follower_.SwitchTrajectory(nullptr);
+        current_node_ = filtered_goal;
+        follower_.set_theta(points_[current_node_]);
+        state_ = ArmState::GOTO_PATH;
+      }
+      break;
+
+    case ArmState::ESTOP:
+      AOS_LOG(ERROR, "Estop\n");
+      break;
+  }
+
+  const bool disable = outputs_disabled || (state_ != ArmState::RUNNING &&
+                                            state_ != ArmState::GOTO_PATH);
+  if (disable) {
+    close_enough_for_full_power_ = false;
+  }
+
+  if (state_ == ArmState::RUNNING && unsafe_goal != nullptr) {
+    if (current_node_ != filtered_goal) {
+      AOS_LOG(INFO, "Goal is different\n");
+      if (filtered_goal >= search_graph_.num_vertexes()) {
+        AOS_LOG(ERROR, "goal node out of range ESTOP\n");
+        state_ = ArmState::ESTOP;
+      } else if (follower_.path_distance_to_go() > 1e-3) {
+        // Still on the old path segment.  Can't change yet.
+      } else {
+        search_graph_.SetGoal(filtered_goal);
+
+        size_t min_edge = 0;
+        double min_cost = ::std::numeric_limits<double>::infinity();
+        for (const SearchGraph::HalfEdge &edge :
+             search_graph_.Neighbors(current_node_)) {
+          const double cost = search_graph_.GetCostToGoal(edge.dest);
+          if (cost < min_cost) {
+            min_edge = edge.edge_id;
+            min_cost = cost;
+          }
+        }
+        // Ok, now we know which edge we are on.  Figure out the path and
+        // trajectory.
+        const SearchGraph::Edge &next_edge = search_graph_.edges()[min_edge];
+        AOS_LOG(INFO, "Switching from node %d to %d along edge %d\n",
+                static_cast<int>(current_node_),
+                static_cast<int>(next_edge.end), static_cast<int>(min_edge));
+        vmax_ = trajectories_[min_edge].vmax;
+        follower_.SwitchTrajectory(&trajectories_[min_edge].trajectory);
+        current_node_ = next_edge.end;
+      }
+    }
+  }
+
+  const double max_operating_voltage =
+      close_enough_for_full_power_
+          ? kOperatingVoltage()
+          : (state_ == ArmState::GOTO_PATH ? kGotoPathVMax() : kPathlessVMax());
+  ::Eigen::Matrix<double, 9, 1> X_hat;
+  X_hat.block<6, 1>(0, 0) = arm_ekf_.X_hat();
+  X_hat.block<3, 1>(6, 0) = roll_joint_loop_.X_hat();
+
+  follower_.Update(X_hat, disable, kDt(), vmax_, max_operating_voltage);
+  AOS_LOG(INFO, "Max voltage: %f\n", max_operating_voltage);
+
+  arm_ekf_.Predict(follower_.U().head<2>(), kDt());
+  roll_joint_loop_.UpdateObserver(follower_.U().tail<1>(), kDtDuration());
+
+  flatbuffers::Offset<frc971::PotAndAbsoluteEncoderEstimatorState>
+      proximal_estimator_state_offset =
+          proximal_zeroing_estimator_.GetEstimatorState(fbb);
+  flatbuffers::Offset<frc971::PotAndAbsoluteEncoderEstimatorState>
+      distal_estimator_state_offset =
+          distal_zeroing_estimator_.GetEstimatorState(fbb);
+  flatbuffers::Offset<frc971::PotAndAbsoluteEncoderEstimatorState>
+      roll_joint_estimator_state_offset =
+          roll_joint_zeroing_estimator_.GetEstimatorState(fbb);
+
+  superstructure::ArmStatus::Builder status_builder(*fbb);
+  status_builder.add_proximal_estimator_state(proximal_estimator_state_offset);
+  status_builder.add_distal_estimator_state(distal_estimator_state_offset);
+  status_builder.add_roll_joint_estimator_state(
+      roll_joint_estimator_state_offset);
+
+  status_builder.add_goal_theta0(follower_.theta(0));
+  status_builder.add_goal_theta1(follower_.theta(1));
+  status_builder.add_goal_theta2(follower_.theta(2));
+  status_builder.add_goal_omega0(follower_.omega(0));
+  status_builder.add_goal_omega1(follower_.omega(1));
+  status_builder.add_goal_omega2(follower_.omega(2));
+
+  status_builder.add_theta0(arm_ekf_.X_hat(0));
+  status_builder.add_theta1(arm_ekf_.X_hat(2));
+  status_builder.add_theta2(roll_joint_loop_.X_hat(0));
+  status_builder.add_omega0(arm_ekf_.X_hat(1));
+  status_builder.add_omega1(arm_ekf_.X_hat(3));
+  status_builder.add_omega2(roll_joint_loop_.X_hat(1));
+  status_builder.add_voltage_error0(arm_ekf_.X_hat(4));
+  status_builder.add_voltage_error1(arm_ekf_.X_hat(5));
+  status_builder.add_voltage_error2(roll_joint_loop_.X_hat(2));
+
+  if (!disable) {
+    *proximal_output = ::std::max(
+        -kOperatingVoltage(), ::std::min(kOperatingVoltage(), follower_.U(0)));
+    *distal_output = ::std::max(
+        -kOperatingVoltage(), ::std::min(kOperatingVoltage(), follower_.U(1)));
+    *roll_joint_output = ::std::max(
+        -kOperatingVoltage(), ::std::min(kOperatingVoltage(), follower_.U(2)));
+  }
+
+  status_builder.add_path_distance_to_go(follower_.path_distance_to_go());
+  status_builder.add_current_node(current_node_);
+
+  status_builder.add_zeroed(zeroed());
+  status_builder.add_estopped(estopped());
+  status_builder.add_state(state_);
+  status_builder.add_failed_solutions(follower_.failed_solutions());
+
+  return status_builder.Finish();
+}
+
+}  // namespace arm
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2023
diff --git a/y2023/control_loops/superstructure/arm/arm.h b/y2023/control_loops/superstructure/arm/arm.h
new file mode 100644
index 0000000..9cda7fc
--- /dev/null
+++ b/y2023/control_loops/superstructure/arm/arm.h
@@ -0,0 +1,126 @@
+#ifndef Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_ARM_H_
+#define Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_ARM_H_
+
+#include "aos/time/time.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/ekf.h"
+#include "frc971/control_loops/double_jointed_arm/graph.h"
+#include "frc971/zeroing/zeroing.h"
+#include "y2023/constants.h"
+#include "y2023/control_loops/superstructure/arm/generated_graph.h"
+#include "y2023/control_loops/superstructure/arm/trajectory.h"
+#include "y2023/control_loops/superstructure/superstructure_position_generated.h"
+#include "y2023/control_loops/superstructure/superstructure_status_generated.h"
+
+using frc971::control_loops::arm::EKF;
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+namespace arm {
+
+class Arm {
+ public:
+  Arm(std::shared_ptr<const constants::Values> values);
+
+  // if true, tune down all the constants for testing.
+  static constexpr bool kGrannyMode() { return false; }
+
+  // the operating voltage.
+  static constexpr double kOperatingVoltage() {
+    return kGrannyMode() ? 5.0 : 12.0;
+  }
+  static constexpr double kDt() { return 0.00505; }
+  static constexpr std::chrono::nanoseconds kDtDuration() {
+    return std::chrono::duration_cast<std::chrono::nanoseconds>(
+        std::chrono::duration<double>(kDt()));
+  }
+  static constexpr double kAlpha0Max() { return kGrannyMode() ? 5.0 : 15.0; }
+  static constexpr double kAlpha1Max() { return kGrannyMode() ? 5.0 : 15.0; }
+  static constexpr double kAlpha2Max() { return kGrannyMode() ? 5.0 : 15.0; }
+
+  static constexpr double kVMax() { return kGrannyMode() ? 5.0 : 11.5; }
+  static constexpr double kPathlessVMax() { return 5.0; }
+  static constexpr double kGotoPathVMax() { return 6.0; }
+
+  flatbuffers::Offset<superstructure::ArmStatus> Iterate(
+      const ::aos::monotonic_clock::time_point /*monotonic_now*/,
+      const uint32_t *unsafe_goal, const superstructure::ArmPosition *position,
+      bool trajectory_override, double *proximal_output, double *distal_output,
+      double *roll_joint_output, bool /*intake*/, bool /*spit*/,
+      flatbuffers::FlatBufferBuilder *fbb);
+
+  void Reset();
+
+  ArmState state() const { return state_; }
+
+  bool estopped() const { return state_ == ArmState::ESTOP; }
+  bool zeroed() const {
+    return (proximal_zeroing_estimator_.zeroed() &&
+            distal_zeroing_estimator_.zeroed() &&
+            roll_joint_zeroing_estimator_.zeroed());
+  }
+
+  uint32_t current_node() const { return current_node_; }
+
+  double path_distance_to_go() { return follower_.path_distance_to_go(); }
+
+ private:
+  bool AtState(uint32_t state) const { return current_node_ == state; }
+  bool NearEnd(double threshold = 0.03) const {
+    return ::std::abs(arm_ekf_.X_hat(0) - follower_.theta(0)) <= threshold &&
+           ::std::abs(arm_ekf_.X_hat(2) - follower_.theta(1)) <= threshold &&
+           follower_.path_distance_to_go() < 1e-3;
+  }
+
+  std::shared_ptr<const constants::Values> values_;
+
+  ArmState state_;
+
+  ::frc971::zeroing::PotAndAbsoluteEncoderZeroingEstimator
+      proximal_zeroing_estimator_;
+  ::frc971::zeroing::PotAndAbsoluteEncoderZeroingEstimator
+      distal_zeroing_estimator_;
+  ::frc971::zeroing::PotAndAbsoluteEncoderZeroingEstimator
+      roll_joint_zeroing_estimator_;
+
+  double proximal_offset_;
+  double distal_offset_;
+  double roll_joint_offset_;
+
+  const ::Eigen::DiagonalMatrix<double, 3> alpha_unitizer_;
+
+  double vmax_ = kVMax();
+
+  frc971::control_loops::arm::Dynamics dynamics_;
+
+  ::std::vector<TrajectoryAndParams> trajectories_;
+
+  bool close_enough_for_full_power_;
+
+  size_t brownout_count_;
+
+  StateFeedbackLoop<3, 1, 1, double, StateFeedbackPlant<3, 1, 1>,
+                    StateFeedbackObserver<3, 1, 1>>
+      roll_joint_loop_;
+  const StateFeedbackLoop<3, 1, 1, double, StateFeedbackHybridPlant<3, 1, 1>,
+                          HybridKalman<3, 1, 1>>
+      hybrid_roll_joint_loop_;
+  EKF arm_ekf_;
+  SearchGraph search_graph_;
+  TrajectoryFollower follower_;
+
+  const ::std::vector<::Eigen::Matrix<double, 3, 1>> points_;
+
+  // Start at the 0th index.
+  uint32_t current_node_;
+
+  EIGEN_MAKE_ALIGNED_OPERATOR_NEW;
+};
+
+}  // namespace arm
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2023
+
+#endif  // Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_ARM_H_
diff --git a/y2023/control_loops/superstructure/arm/arm_constants.h b/y2023/control_loops/superstructure/arm/arm_constants.h
new file mode 100644
index 0000000..6151027
--- /dev/null
+++ b/y2023/control_loops/superstructure/arm/arm_constants.h
@@ -0,0 +1,53 @@
+#ifndef Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_ARM_CONSTANTS_H_
+#define Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_ARM_CONSTANTS_H_
+
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+namespace arm {
+
+constexpr double kEfficiencyTweak = 0.95;
+constexpr double kStallTorque = 4.69 * kEfficiencyTweak;
+constexpr double kFreeSpeed = (6380.0 / 60.0) * 2.0 * M_PI;
+constexpr double kStallCurrent = 257.0;
+
+constexpr ::frc971::control_loops::arm::ArmConstants kArmConstants = {
+    .l0 = 20 * 0.0254,
+    .l1 = 38 * 0.0254,
+
+    .m0 = 4.5 / 2.2,
+    .m1 = 15.5 / 2.2,
+
+    // Moment of inertia of the joints in kg m^2
+    .j0 = 0.092,
+    .j1 = 1.6,
+
+    // Radius of the center of mass of the joints in meters.
+    .r0 = 4.5 * 0.0254,
+    .r1 = 20 * 0.0254,
+
+    // Gear ratios for the two joints.
+    .g0 = 55.0,
+    .g1 = 106.0,
+
+    // Falcon motor constants.
+    .efficiency_tweak = kEfficiencyTweak,
+    .stall_torque = kStallTorque,
+    .free_speed = kFreeSpeed,
+    .stall_current = kStallCurrent,
+    .resistance = 12.0 / kStallCurrent,
+    .Kv = kFreeSpeed / 12.0,
+    .Kt = kStallTorque / kStallCurrent,
+
+    // Number of motors on the distal joint.
+    .num_distal_motors = 1.0,
+};
+
+}  // namespace arm
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2023
+
+#endif  // Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_ARM_CONSTANTS_H_
diff --git a/y2023/control_loops/superstructure/arm/arm_design.cc b/y2023/control_loops/superstructure/arm/arm_design.cc
new file mode 100644
index 0000000..99ffa2c
--- /dev/null
+++ b/y2023/control_loops/superstructure/arm/arm_design.cc
@@ -0,0 +1,193 @@
+#include "aos/init.h"
+#include "frc971/analysis/in_process_plotter.h"
+#include "frc971/control_loops/dlqr.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/jacobian.h"
+#include "y2023/control_loops/superstructure/arm/arm_constants.h"
+
+DEFINE_double(lqr_proximal_pos, 0.15, "Position LQR gain");
+DEFINE_double(lqr_proximal_vel, 4.0, "Velocity LQR gain");
+DEFINE_double(lqr_distal_pos, 0.20, "Position LQR gain");
+DEFINE_double(lqr_distal_vel, 4.0, "Velocity LQR gain");
+DEFINE_double(fx, 0.0, "X force");
+DEFINE_double(fy, 0.0, "y force");
+
+DEFINE_double(start0, 0.0, "starting position on proximal");
+DEFINE_double(start1, 0.0, "starting position on distal");
+DEFINE_double(goal0, 0.0, "goal position on proximal");
+DEFINE_double(goal1, 0.0, "goal position on distal");
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+namespace arm {
+
+int Main() {
+  frc971::analysis::Plotter plotter;
+
+  frc971::control_loops::arm::Dynamics dynamics(kArmConstants);
+
+  ::Eigen::Matrix<double, 4, 1> X = ::Eigen::Matrix<double, 4, 1>::Zero();
+  ::Eigen::Matrix<double, 4, 1> goal = ::Eigen::Matrix<double, 4, 1>::Zero();
+  goal(0, 0) = FLAGS_goal0;
+  goal(1, 0) = 0;
+  goal(2, 0) = FLAGS_goal1;
+  goal(3, 0) = 0;
+
+  X(0, 0) = FLAGS_start0;
+  X(1, 0) = 0;
+  X(2, 0) = FLAGS_start1;
+  X(3, 0) = 0;
+  ::Eigen::Matrix<double, 2, 1> U = ::Eigen::Matrix<double, 2, 1>::Zero();
+
+  constexpr double kDt = 0.00505;
+
+  std::vector<double> t;
+  std::vector<double> x0;
+  std::vector<double> x1;
+  std::vector<double> x2;
+  std::vector<double> x3;
+  std::vector<double> u0;
+  std::vector<double> u1;
+
+  std::vector<double> current0;
+  std::vector<double> current1;
+  std::vector<double> torque0;
+  std::vector<double> torque1;
+
+  const double kProximalPosLQR = FLAGS_lqr_proximal_pos;
+  const double kProximalVelLQR = FLAGS_lqr_proximal_vel;
+  const double kDistalPosLQR = FLAGS_lqr_distal_pos;
+  const double kDistalVelLQR = FLAGS_lqr_distal_vel;
+  const ::Eigen::DiagonalMatrix<double, 4> Q =
+      (::Eigen::DiagonalMatrix<double, 4>().diagonal()
+           << 1.0 / ::std::pow(kProximalPosLQR, 2),
+       1.0 / ::std::pow(kProximalVelLQR, 2), 1.0 / ::std::pow(kDistalPosLQR, 2),
+       1.0 / ::std::pow(kDistalVelLQR, 2))
+          .finished()
+          .asDiagonal();
+
+  const ::Eigen::DiagonalMatrix<double, 2> R =
+      (::Eigen::DiagonalMatrix<double, 2>().diagonal()
+           << 1.0 / ::std::pow(12.0, 2),
+       1.0 / ::std::pow(12.0, 2))
+          .finished()
+          .asDiagonal();
+
+  {
+    const ::Eigen::Matrix<double, 2, 1> torque =
+        dynamics
+            .TorqueFromForce(X,
+                             ::Eigen::Matrix<double, 2, 1>(FLAGS_fx, FLAGS_fy))
+            .transpose();
+    LOG(INFO) << "Torque (N m): " << torque.transpose();
+    const ::Eigen::Matrix<double, 2, 1> current =
+        dynamics.CurrentFromTorque(torque);
+
+    LOG(INFO) << "Current (Amps): " << current.transpose();
+
+    ::Eigen::Matrix<double, 2, 1> battery_current;
+    battery_current(0) =
+        current(0) * current(0) * kArmConstants.resistance / 12.0;
+    battery_current(1) =
+        current(1) * current(1) * kArmConstants.resistance / 12.0;
+
+    LOG(INFO) << "Battery current (Amps): " << battery_current.transpose();
+  }
+
+  ::Eigen::Matrix<double, 2, 4> last_K = ::Eigen::Matrix<double, 2, 4>::Zero();
+  for (int i = 0; i < 400; ++i) {
+    t.push_back(i * kDt);
+    x0.push_back(X(0));
+    x1.push_back(X(1));
+    x2.push_back(X(2));
+    x3.push_back(X(3));
+
+    const auto x_blocked = X.block<4, 1>(0, 0);
+
+    const ::Eigen::Matrix<double, 4, 4> final_A =
+        ::frc971::control_loops::NumericalJacobianX<4, 2>(
+            [dynamics](const auto &x_blocked, const auto &U, double kDt) {
+              return dynamics.UnboundedDiscreteDynamics(x_blocked, U, kDt);
+            },
+            x_blocked, U, 0.00505);
+    const ::Eigen::Matrix<double, 4, 2> final_B =
+        ::frc971::control_loops::NumericalJacobianU<4, 2>(
+            [dynamics](const auto &x_blocked, const auto &U, double kDt) {
+              return dynamics.UnboundedDiscreteDynamics(x_blocked, U, kDt);
+            },
+            x_blocked, U, 0.00505);
+
+    ::Eigen::Matrix<double, 2, 1> U_ff =
+        dynamics.FF_U(x_blocked, ::Eigen::Matrix<double, 2, 1>::Zero(),
+                      ::Eigen::Matrix<double, 2, 1>::Zero());
+
+    ::Eigen::Matrix<double, 4, 4> S;
+    ::Eigen::Matrix<double, 2, 4> K;
+    if (::frc971::controls::dlqr<4, 2>(final_A, final_B, Q, R, &K, &S) == 0) {
+      ::Eigen::EigenSolver<::Eigen::Matrix<double, 4, 4>> eigensolver(
+          final_A - final_B * K);
+
+      last_K = K;
+    } else {
+      LOG(INFO) << "Failed to solve for K at " << i;
+    }
+    U = U_ff + last_K * (goal - X);
+    if (std::abs(U(0)) > 12.0) {
+      U /= std::abs(U(0)) / 12.0;
+    }
+    if (std::abs(U(1)) > 12.0) {
+      U /= std::abs(U(1)) / 12.0;
+    }
+
+    const ::Eigen::Matrix<double, 2, 1> torque =
+        dynamics.TorqueFromCommand(X, U);
+    const ::Eigen::Matrix<double, 2, 1> current_per_motor =
+        dynamics.CurrentFromCommand(X, U);
+
+    current0.push_back(current_per_motor(0));
+    current1.push_back(current_per_motor(1));
+    torque0.push_back(torque(0));
+    torque1.push_back(torque(1));
+
+    u0.push_back(U(0));
+    u1.push_back(U(1));
+
+    X = dynamics.UnboundedDiscreteDynamics(X, U, kDt);
+  }
+
+  plotter.Title("Arm motion");
+  plotter.AddFigure("State");
+  plotter.AddLine(t, x0, "X 0");
+  plotter.AddLine(t, x1, "X 1");
+  plotter.AddLine(t, x2, "X 2");
+  plotter.AddLine(t, x3, "X 3");
+
+  plotter.AddLine(t, u0, "U 0");
+  plotter.AddLine(t, u1, "U 1");
+  plotter.Publish();
+
+  plotter.AddFigure("Command");
+  plotter.AddLine(t, u0, "U 0");
+  plotter.AddLine(t, u1, "U 1");
+
+  plotter.AddLine(t, current0, "current 0");
+  plotter.AddLine(t, current1, "current 1");
+  plotter.AddLine(t, torque0, "torque 0");
+  plotter.AddLine(t, torque1, "torque 1");
+  plotter.Publish();
+
+  plotter.Spin();
+
+  return 0;
+}
+
+}  // namespace arm
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2023
+
+int main(int argc, char **argv) {
+  ::aos::InitGoogle(&argc, &argv);
+  return y2023::control_loops::superstructure::arm::Main();
+}
diff --git a/y2023/control_loops/superstructure/arm/trajectory.cc b/y2023/control_loops/superstructure/arm/trajectory.cc
new file mode 100644
index 0000000..565f497
--- /dev/null
+++ b/y2023/control_loops/superstructure/arm/trajectory.cc
@@ -0,0 +1,959 @@
+#include "y2023/control_loops/superstructure/arm/trajectory.h"
+
+#include "Eigen/Dense"
+#include "aos/logging/logging.h"
+#include "frc971/control_loops/dlqr.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/jacobian.h"
+#include "frc971/control_loops/runge_kutta.h"
+#include "gflags/gflags.h"
+
+DEFINE_double(lqr_proximal_pos, 0.5, "Position LQR gain");
+DEFINE_double(lqr_proximal_vel, 5, "Velocity LQR gain");
+DEFINE_double(lqr_distal_pos, 0.5, "Position LQR gain");
+DEFINE_double(lqr_distal_vel, 5, "Velocity LQR gain");
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+namespace arm {
+
+::Eigen::Matrix<double, 3, 1> CosSpline::Theta(double alpha) const {
+  ::Eigen::Matrix<double, 3, 1> result;
+  result.block<2, 1>(0, 0) = spline_.Theta(alpha);
+
+  const std::pair<AlphaTheta, AlphaTheta> roll_points = RollPoints(alpha);
+  const double alpha_percent =
+      (alpha - roll_points.first.alpha) /
+      (roll_points.second.alpha - roll_points.first.alpha);
+
+  result(2) = (0.5 - 0.5 * std::cos(alpha_percent * M_PI)) *
+                  (roll_points.second.theta - roll_points.first.theta) +
+              roll_points.first.theta;
+  return result;
+}
+
+::Eigen::Matrix<double, 3, 1> CosSpline::Omega(double alpha) const {
+  ::Eigen::Matrix<double, 3, 1> result;
+  result.block<2, 1>(0, 0) = spline_.Omega(alpha);
+
+  const std::pair<AlphaTheta, AlphaTheta> roll_points = RollPoints(alpha);
+  const double dalpha = (roll_points.second.alpha - roll_points.first.alpha);
+  const double alpha_percent = (alpha - roll_points.first.alpha) / dalpha;
+
+  result(2) = 0.5 * std::sin(alpha_percent * M_PI) *
+              (roll_points.second.theta - roll_points.first.theta) * M_PI /
+              dalpha;
+  return result;
+}
+
+::Eigen::Matrix<double, 3, 1> CosSpline::Alpha(double alpha) const {
+  ::Eigen::Matrix<double, 3, 1> result;
+  result.block<2, 1>(0, 0) = spline_.Alpha(alpha);
+
+  const std::pair<AlphaTheta, AlphaTheta> roll_points = RollPoints(alpha);
+  const double dalpha = (roll_points.second.alpha - roll_points.first.alpha);
+  const double alpha_percent = (alpha - roll_points.first.alpha) / dalpha;
+
+  result(2) = 0.5 * std::cos(alpha_percent * M_PI) *
+              (roll_points.second.theta - roll_points.first.theta) * M_PI *
+              M_PI / dalpha / dalpha;
+  return result;
+}
+
+std::pair<CosSpline::AlphaTheta, CosSpline::AlphaTheta> CosSpline::RollPoints(
+    double alpha) const {
+  if (alpha <= 0.0) {
+    return std::make_pair(roll_[0], roll_[1]);
+  }
+  if (alpha >= 1.0) {
+    return std::make_pair(roll_[roll_.size() - 2], roll_[roll_.size() - 1]);
+  }
+
+  // Find the distance right below our number using a binary search.
+  size_t after = ::std::distance(
+      roll_.begin(), ::std::lower_bound(roll_.begin(), roll_.end(), alpha,
+                                        [](AlphaTheta at, double alpha) {
+                                          return at.alpha < alpha;
+                                        }));
+  DCHECK_GT(after, 0u);
+  size_t before = after - 1;
+  DCHECK_LT(before, roll_.size());
+  return std::make_pair(roll_[before], roll_[after]);
+}
+
+CosSpline CosSpline::Reversed() const {
+  std::vector<AlphaTheta> new_roll(roll_.size());
+  for (size_t i = 0; i < roll_.size(); ++i) {
+    new_roll[roll_.size() - 1 - i] = {1.0 - roll_[i].alpha, roll_[i].theta};
+  }
+
+  Eigen::Matrix<double, 2, 4> new_control_points;
+  new_control_points = spline_.control_points().rowwise().reverse();
+  return CosSpline(NSpline<4, 2>(new_control_points), std::move(new_roll));
+}
+
+::Eigen::Matrix<double, 3, 1> Path::Theta(double distance) const {
+  const double alpha = DistanceToAlpha(distance);
+  return spline_.Theta(alpha);
+}
+
+::Eigen::Matrix<double, 3, 1> Path::Omega(double distance) const {
+  const double alpha = DistanceToAlpha(distance);
+  return spline_.Omega(alpha).normalized();
+}
+
+::Eigen::Matrix<double, 3, 1> Path::Alpha(double distance) const {
+  const double alpha = DistanceToAlpha(distance);
+  const ::Eigen::Matrix<double, 3, 1> dspline_point = spline_.Omega(alpha);
+  const ::Eigen::Matrix<double, 3, 1> ddspline_point = spline_.Alpha(alpha);
+
+  const double squared_norm = dspline_point.squaredNorm();
+
+  return ddspline_point / squared_norm -
+         dspline_point * (dspline_point.transpose() * ddspline_point) /
+             ::std::pow(squared_norm, 2);
+}
+
+std::vector<float> Path::BuildDistances(size_t num_alpha) {
+  num_alpha = num_alpha ? num_alpha : 100;
+  std::vector<float> distances;
+  distances.push_back(0.0);
+
+  const double dalpha = 1.0 / static_cast<double>(num_alpha - 1);
+  double last_alpha = 0.0;
+  for (size_t i = 1; i < num_alpha; ++i) {
+    const double alpha = dalpha * i;
+    distances.push_back(
+        distances.back() +
+        GaussianQuadrature5(
+            [this](double alpha) { return this->spline_.Omega(alpha).norm(); },
+            last_alpha, alpha));
+    last_alpha = alpha;
+  }
+  return distances;
+}
+
+double Path::DistanceToAlpha(double distance) const {
+  if (distance <= 0.0) {
+    return 0.0;
+  }
+  if (distance >= length()) {
+    return 1.0;
+  }
+
+  // Find the distance right below our number using a binary search.
+  size_t after = ::std::distance(
+      distances().begin(),
+      ::std::lower_bound(distances().begin(), distances().end(), distance));
+  size_t before = after - 1;
+  const double distance_step_size =
+      (1.0 / static_cast<double>(distances().size() - 1));
+
+  const double alpha = (distance - distances()[before]) /
+                           (distances()[after] - distances()[before]) *
+                           distance_step_size +
+                       static_cast<double>(before) * distance_step_size;
+  CHECK_GT(alpha, 0.0);
+  CHECK_LT(alpha, 1.0);
+  return alpha;
+}
+
+Path Path::Reversed() const { return Path(spline_.Reversed()); }
+
+::std::unique_ptr<Path> Path::Reversed(::std::unique_ptr<Path> p) {
+  return ::std::make_unique<Path>(p->Reversed());
+}
+
+double Trajectory::MaxCurvatureSpeed(
+    double goal_distance, const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer,
+    double plan_vmax) {
+  // TODO(austin): We are looking up the index in the path 3 times here.
+  const ::Eigen::Matrix<double, 3, 1> theta = path().Theta(goal_distance);
+  const ::Eigen::Matrix<double, 3, 1> omega = path().Omega(goal_distance);
+  const ::Eigen::Matrix<double, 3, 1> alpha = path().Alpha(goal_distance);
+
+  const ::Eigen::Matrix<double, 4, 1> X =
+      (::Eigen::Matrix<double, 4, 1>() << theta(0, 0), 0.0, theta(1, 0), 0.0)
+          .finished();
+
+  ::Eigen::Matrix<double, 2, 2> K1;
+  ::Eigen::Matrix<double, 2, 2> K2;
+
+  const ::Eigen::Matrix<double, 2, 1> gravity_volts =
+      dynamics_->K3_inverse() * dynamics_->GravityTorque(X);
+
+  dynamics_->NormilizedMatriciesForState(X, &K1, &K2);
+  const ::Eigen::Matrix<double, 2, 2> omega_square =
+      (::Eigen::Matrix<double, 2, 2>() << omega(0, 0), 0.0, 0.0, omega(1, 0))
+          .finished();
+
+  // Here, we can say that
+  //   d^2/dt^2 theta = d^2/dd^2 theta(d) * (d d/dt)^2
+  // Normalize so that the max accel is 1, and take magnitudes. This
+  // gives us the max velocity we can be at each point due to
+  // curvature.
+  double min_good_ddot =
+      ::std::sqrt(1.0 / ::std::max(0.001, (alpha_unitizer * alpha).norm()));
+
+  // Now, solve for the max speed we can follow the path without hitting the
+  // vmax limit.  This makes it such that we can always hold vmax and follow
+  // the path.  This helps when there are cases where holding constant speed
+  // into a reducing radius turn will saturate our voltage limit. Technically,
+  // there are cases where faster paths exist, but they are a lot harder to
+  // solve due to the nonlinearities.
+  const ::Eigen::Matrix<double, 2, 1> vk1 =
+      dynamics_->K3_inverse() * (K1 * alpha.block<2, 1>(0, 0) +
+                                 K2 * omega_square * omega.block<2, 1>(0, 0));
+  const ::Eigen::Matrix<double, 2, 1> vk2 =
+      dynamics_->K3_inverse() * dynamics_->K4() * omega.block<2, 1>(0, 0);
+
+  // Loop through all the various vmin, plan_vmax combinations.
+  for (const double c : {-plan_vmax, plan_vmax}) {
+    // Also loop through saturating theta0 and theta1
+    for (const ::std::tuple<double, double, double> &abgravity :
+         {::std::tuple<double, double, double>{vk1(0), vk2(0),
+                                               gravity_volts(0)},
+          ::std::tuple<double, double, double>{vk1(1), vk2(1),
+                                               gravity_volts(1)}}) {
+      const double a = ::std::get<0>(abgravity);
+      const double b = ::std::get<1>(abgravity);
+      const double gravity = ::std::get<2>(abgravity);
+      const double sqrt_number = b * b - 4.0 * a * (c - gravity);
+
+      // Throw out imaginary solutions to the quadratic.
+      if (sqrt_number > 0) {
+        const double sqrt_result = ::std::sqrt(sqrt_number);
+        const double ddot1 = (-b + sqrt_result) / (2.0 * a);
+        const double ddot2 = (-b - sqrt_result) / (2.0 * a);
+        // Loop through both solutions.
+        for (const double ddot : {ddot1, ddot2}) {
+          const ::Eigen::Matrix<double, 2, 1> U =
+              vk1 * ddot * ddot + vk2 * ddot - gravity_volts;
+
+          // Finally, make sure the velocity is positive and valid.
+          if ((U.array().abs() <= plan_vmax + 1e-6).all() && ddot > 0.0) {
+            min_good_ddot = ::std::min(min_good_ddot, ddot);
+          }
+        }
+      }
+    }
+  }
+
+  return min_good_ddot;
+}
+
+::std::vector<double> Trajectory::CurvatureOptimizationPass(
+    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax) {
+  ::std::vector<double> max_dvelocity_unfiltered;
+  max_dvelocity_unfiltered.reserve(num_plan_points_);
+  for (size_t i = 0; i < num_plan_points_; ++i) {
+    const double distance = DistanceForIndex(i);
+
+    max_dvelocity_unfiltered.push_back(
+        MaxCurvatureSpeed(distance, alpha_unitizer, plan_vmax));
+  }
+  return max_dvelocity_unfiltered;
+}
+
+double Trajectory::FeasableForwardsAcceleration(
+    double goal_distance, double goal_velocity, double /*plan_vmax*/,
+    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer) const {
+  const ::Eigen::Matrix<double, 3, 1> omega = path().Omega(goal_distance);
+  const ::Eigen::Matrix<double, 3, 1> alpha = path().Alpha(goal_distance);
+
+  return ::std::sqrt(
+             ::std::max(0.0, 1.0 - ::std::pow((alpha_unitizer * alpha).norm() *
+                                                  goal_velocity * goal_velocity,
+                                              2))) /
+         (alpha_unitizer * omega).norm();
+}
+
+double Trajectory::FeasableForwardsVoltage(
+    double goal_distance, double goal_velocity, double plan_vmax,
+    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer) const {
+  // TODO(austin): We are looking up the index in the path 3 times here.
+  const ::Eigen::Matrix<double, 3, 1> theta = path().Theta(goal_distance);
+  const ::Eigen::Matrix<double, 3, 1> omega = path().Omega(goal_distance);
+  const ::Eigen::Matrix<double, 3, 1> alpha = path().Alpha(goal_distance);
+
+  const ::Eigen::Matrix<double, 4, 1> arm_X =
+      (::Eigen::Matrix<double, 4, 1>() << theta(0, 0), 0.0, theta(1, 0), 0.0)
+          .finished();
+
+  ::Eigen::Matrix<double, 2, 2> K1;
+  ::Eigen::Matrix<double, 2, 2> K2_normalized;
+
+  dynamics_->NormilizedMatriciesForState(arm_X, &K1, &K2_normalized);
+
+  const ::Eigen::Matrix<double, 2, 2> omega_square =
+      (::Eigen::Matrix<double, 2, 2>() << omega(0, 0), 0.0, 0.0, omega(1, 0))
+          .finished();
+
+  // The derivitives of f(d) wrt t are:
+  // theta = f(d)
+  // dtheta/dt = d/dd f(d) * dd/dt
+  // d^2 theta/dt^2 = d^2/dd^2 f(d) * (dd/dt)^2 + d^2d/dt^2 * d/dd f(d)
+  //
+  // And, our arm dynamics are:
+  // K1 d^2 theta/dt^2 + K2_normalized * diag(dtheta/dt) * dtheta/dt = k3 * V -
+  // K4 * dtheta/dt
+  //
+  // And our roll dynamics are:
+  //
+  // d^2 theta2/dt^2 = A[1, 1] d theta2/dt + B[1, 0] * V
+  //
+  // Plug the derivitives in to our dynamics, solve for d^2d/dt^2, and ship it!
+  //
+  // We want things in the form V = k_constant + k_scalar * d^2d/dt^2
+
+  const ::Eigen::Matrix<double, 3, 1> k_constant =
+      (::Eigen::Matrix<double, 3, 1>()
+           << (dynamics_->K3_inverse() *
+               ((K1 * alpha.block<2, 1>(0, 0) +
+                 K2_normalized * omega_square * omega.block<2, 1>(0, 0)) *
+                    goal_velocity * goal_velocity +
+                dynamics_->K4() * omega.block<2, 1>(0, 0) * goal_velocity -
+                dynamics_->GravityTorque(arm_X))),
+       ((alpha(2, 0) * goal_velocity * goal_velocity -
+         roll_->coefficients().A_continuous(1, 1) * omega(2, 0) *
+             goal_velocity) /
+        roll_->coefficients().B_continuous(1, 0)))
+          .finished();
+
+  const ::Eigen::Matrix<double, 3, 1> k_scalar =
+      (::Eigen::Matrix<double, 3, 1>()
+           << dynamics_->K3_inverse() * K1 * omega.block<2, 1>(0, 0),
+       (omega(2, 0) / roll_->coefficients().B_continuous(1, 0)))
+          .finished();
+
+  const double constraint_goal_acceleration =
+      ::std::sqrt(
+          ::std::max(0.0, 1.0 - ::std::pow((alpha_unitizer * alpha).norm() *
+                                               goal_velocity * goal_velocity,
+                                           2))) /
+      (alpha_unitizer * omega).norm();
+
+  double min_goal_acceleration = ::std::numeric_limits<double>::infinity();
+  double max_goal_acceleration = -::std::numeric_limits<double>::infinity();
+  for (double c : {-plan_vmax, plan_vmax}) {
+    for (const ::std::pair<double, double> &ab :
+         {::std::pair<double, double>{k_constant(0, 0), k_scalar(0, 0)},
+          ::std::pair<double, double>{k_constant(1, 0), k_scalar(1, 0)},
+          ::std::pair<double, double>{k_constant(2, 0), k_scalar(2, 0)}}) {
+      const double a = ab.first;
+      const double b = ab.second;
+      const double voltage_accel = (c - a) / b;
+      const ::Eigen::Matrix<double, 3, 1> U =
+          k_constant + k_scalar * voltage_accel;
+
+      VLOG(2) << "Trying, U is " << U.transpose() << ", accel "
+              << voltage_accel;
+      if ((U.array().abs() <= plan_vmax + 1e-6).all()) {
+        min_goal_acceleration = std::min(voltage_accel, min_goal_acceleration);
+        max_goal_acceleration = std::max(voltage_accel, max_goal_acceleration);
+      }
+    }
+  }
+  if (min_goal_acceleration == ::std::numeric_limits<double>::infinity() ||
+      max_goal_acceleration == -::std::numeric_limits<double>::infinity()) {
+    // TODO(austin): The math above doesn't always give a valid solution.
+    // This happens when things get pretty ill-conditioned.  Figure out what to
+    // do about it.
+    VLOG(1)
+        << "Failed to find a valid accel at distance " << goal_distance
+        << ", voltage "
+        << (k_constant + k_scalar * constraint_goal_acceleration).transpose()
+        << ", accel " << constraint_goal_acceleration << " vs vmax "
+        << plan_vmax;
+    return constraint_goal_acceleration;
+  }
+
+  const double goal_acceleration =
+      std::abs(max_goal_acceleration - constraint_goal_acceleration) <
+              std::abs(min_goal_acceleration - constraint_goal_acceleration)
+          ? max_goal_acceleration
+          : min_goal_acceleration;
+
+  if (min_goal_acceleration < constraint_goal_acceleration &&
+      constraint_goal_acceleration < max_goal_acceleration) {
+    VLOG(2)
+        << "Solved valid accel at distance " << goal_distance << ", voltage "
+        << (k_constant + k_scalar * constraint_goal_acceleration).transpose()
+        << ", accel " << constraint_goal_acceleration
+        << ", overall accel limited, U limit is " << goal_acceleration
+        << " with U of "
+        << (k_constant + k_scalar * goal_acceleration).transpose();
+
+    if (!((k_constant + k_scalar * constraint_goal_acceleration)
+              .array()
+              .abs() <= plan_vmax + 1e-6)
+             .all()) {
+      LOG(FATAL) << "Accel in range, but constraint voltage out of range.";
+    }
+    return constraint_goal_acceleration;
+  }
+
+  VLOG(2) << "Solved valid accel at distance " << goal_distance << ", voltage "
+          << (k_constant + k_scalar * goal_acceleration).transpose()
+          << ", decel limits [" << min_goal_acceleration << ", "
+          << max_goal_acceleration << "], picked " << goal_acceleration
+          << " voltage limited, constraint accel "
+          << constraint_goal_acceleration << " constraint voltage "
+          << (k_constant + k_scalar * constraint_goal_acceleration).transpose();
+
+  return goal_acceleration;
+}
+
+double Trajectory::FeasableBackwardsAcceleration(
+    double goal_distance, double goal_velocity, double /*plan_vmax*/,
+    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer) const {
+  const ::Eigen::Matrix<double, 3, 1> omega = path().Omega(goal_distance);
+  const ::Eigen::Matrix<double, 3, 1> alpha = path().Alpha(goal_distance);
+
+  return ::std::sqrt(
+             ::std::max(0.0, 1.0 - ::std::pow(((alpha_unitizer * alpha).norm() *
+                                               goal_velocity * goal_velocity),
+                                              2))) /
+         (alpha_unitizer * omega).norm();
+}
+
+double Trajectory::FeasableBackwardsVoltage(
+    double goal_distance, double goal_velocity, double plan_vmax,
+    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer) const {
+  const ::Eigen::Matrix<double, 3, 1> theta = path().Theta(goal_distance);
+  const ::Eigen::Matrix<double, 3, 1> omega = path().Omega(goal_distance);
+  const ::Eigen::Matrix<double, 3, 1> alpha = path().Alpha(goal_distance);
+  const ::Eigen::Matrix<double, 4, 1> arm_X =
+      (::Eigen::Matrix<double, 4, 1>() << theta(0, 0), 0.0, theta(1, 0), 0.0)
+          .finished();
+  ::Eigen::Matrix<double, 2, 2> K1;
+  ::Eigen::Matrix<double, 2, 2> K2_normalized;
+
+  dynamics_->NormilizedMatriciesForState(arm_X, &K1, &K2_normalized);
+
+  const ::Eigen::Matrix<double, 2, 2> omega_square =
+      (::Eigen::Matrix<double, 2, 2>() << omega(0, 0), 0.0, 0.0, omega(1, 0))
+          .finished();
+
+  const ::Eigen::Matrix<double, 3, 1> k_constant =
+      (::Eigen::Matrix<double, 3, 1>()
+           << dynamics_->K3_inverse() *
+                  ((K1 * alpha.block<2, 1>(0, 0) +
+                    K2_normalized * omega_square * omega.block<2, 1>(0, 0)) *
+                       goal_velocity * goal_velocity +
+                   dynamics_->K4() * omega.block<2, 1>(0, 0) * goal_velocity -
+                   dynamics_->GravityTorque(arm_X)),
+       ((alpha(2, 0) * goal_velocity * goal_velocity -
+         roll_->coefficients().A_continuous(1, 1) * omega(2, 0) *
+             goal_velocity) /
+        roll_->coefficients().B_continuous(1, 0)))
+          .finished();
+  const ::Eigen::Matrix<double, 3, 1> k_scalar =
+      (::Eigen::Matrix<double, 3, 1>()
+           << dynamics_->K3_inverse() * K1 * omega.block<2, 1>(0, 0),
+       (omega(2, 0) / roll_->coefficients().B_continuous(1, 0)))
+          .finished();
+
+  const double constraint_goal_acceleration =
+      ::std::sqrt(
+          ::std::max(0.0, 1.0 - ::std::pow(((alpha_unitizer * alpha).norm() *
+                                            goal_velocity * goal_velocity),
+                                           2))) /
+      (alpha_unitizer * omega).norm();
+
+  double min_goal_acceleration = ::std::numeric_limits<double>::infinity();
+  double max_goal_acceleration = -::std::numeric_limits<double>::infinity();
+  for (double c : {-plan_vmax, plan_vmax}) {
+    for (const ::std::pair<double, double> &ab :
+         {::std::pair<double, double>{k_constant(0, 0), k_scalar(0, 0)},
+          ::std::pair<double, double>{k_constant(1, 0), k_scalar(1, 0)},
+          ::std::pair<double, double>{k_constant(2, 0), k_scalar(2, 0)}}) {
+      const double a = ab.first;
+      const double b = ab.second;
+      // This time, we are doing the other pass.  So, find all the
+      // decelerations (and flip them) to find the prior velocity.
+      const double voltage_accel = (c - a) / b;
+
+      const ::Eigen::Matrix<double, 3, 1> U =
+          k_constant + k_scalar * voltage_accel;
+
+      // TODO(austin): This doesn't always give a valid solution.  It really
+      // should.  Figure out why.
+      VLOG(2) << "Trying, U is " << U.transpose() << ", accel "
+              << voltage_accel;
+      if ((U.array().abs() <= plan_vmax + 1e-6).all()) {
+        min_goal_acceleration = std::min(-voltage_accel, min_goal_acceleration);
+        max_goal_acceleration = std::max(-voltage_accel, max_goal_acceleration);
+      }
+    }
+  }
+
+  if (min_goal_acceleration == ::std::numeric_limits<double>::infinity() ||
+      max_goal_acceleration == -::std::numeric_limits<double>::infinity()) {
+    VLOG(1)
+        << "Failed to find a valid decel at distance " << goal_distance
+        << ", voltage "
+        << (k_constant + k_scalar * constraint_goal_acceleration).transpose()
+        << ", accel " << constraint_goal_acceleration << "  vs vmax "
+        << plan_vmax;
+    return constraint_goal_acceleration;
+  }
+
+  if (min_goal_acceleration < constraint_goal_acceleration &&
+      constraint_goal_acceleration < max_goal_acceleration) {
+    VLOG(2)
+        << "Solved valid decel at distance " << goal_distance << ", voltage "
+        << (k_constant - k_scalar * constraint_goal_acceleration).transpose()
+        << ", decel " << min_goal_acceleration
+        << " <= " << constraint_goal_acceleration
+        << " <= " << max_goal_acceleration << ", accel limited";
+
+    if (!((k_constant - k_scalar * constraint_goal_acceleration)
+              .array()
+              .abs() <= plan_vmax + 1e-6)
+             .all()) {
+      LOG(FATAL) << "Accel in range, but constraint voltage out of range.";
+    }
+
+    return constraint_goal_acceleration;
+  }
+
+  const double goal_acceleration =
+      std::abs(max_goal_acceleration - constraint_goal_acceleration) <
+              std::abs(min_goal_acceleration - constraint_goal_acceleration)
+          ? max_goal_acceleration
+          : min_goal_acceleration;
+
+  VLOG(2) << "Solved valid decel at distance " << goal_distance << ", voltage "
+          << (k_constant - k_scalar * goal_acceleration).transpose()
+          << ", decel limits [" << min_goal_acceleration << ", "
+          << max_goal_acceleration << "], picked " << goal_acceleration
+          << " voltage limited, constraint accel "
+          << constraint_goal_acceleration << " constraint voltage "
+          << (k_constant - k_scalar * constraint_goal_acceleration).transpose();
+
+  return goal_acceleration;
+}
+
+template <typename F>
+double IntegrateAccelForDistance(const F &fn, double v, double x, double dx) {
+  // Use a trick from
+  // https://www.johndcook.com/blog/2012/02/21/care-and-treatment-of-singularities/
+  const double a0 = fn(x, v);
+
+  return (frc971::control_loops::RungeKuttaSteps(
+              [&fn, &a0](double t, double y) {
+                // Since we know that a0 == a(0) and that they are asymtotically
+                // the same at 0, we know that the limit is 0 at 0.  This is
+                // true because when starting from a stop, under sane
+                // accelerations, we can assume that we will start with a
+                // constant acceleration.  So, hard-code it.
+                if (std::abs(y) < 1e-6) {
+                  return 0.0;
+                }
+                return (fn(t, y) - a0) / y;
+              },
+              v, x, dx, 10) -
+          v) +
+         std::sqrt(2.0 * a0 * dx + v * v);
+}
+
+::std::vector<double> Trajectory::BackwardsOptimizationAccelerationPass(
+    const ::std::vector<double> &max_dvelocity,
+    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax) {
+  ::std::vector<double> max_dvelocity_back_pass = max_dvelocity;
+
+  // Now, iterate over the list of velocities and constrain the acceleration.
+  for (int i = num_plan_points_ - 2; i >= 0; --i) {
+    const double previous_velocity = max_dvelocity_back_pass[i + 1];
+    const double previous_distance = DistanceForIndex(i + 1);
+
+    // Now, integrate with respect to distance (not time like normal).
+    const double integrated_velocity = IntegrateAccelForDistance(
+        [&](double x, double v) {
+          return FeasableBackwardsAcceleration(x, v, plan_vmax, alpha_unitizer);
+        },
+        previous_velocity, previous_distance, step_size_);
+    max_dvelocity_back_pass[i] =
+        ::std::min(integrated_velocity, max_dvelocity_back_pass[i]);
+  }
+
+  return max_dvelocity_back_pass;
+}
+
+::std::vector<double> Trajectory::BackwardsOptimizationVoltagePass(
+    const ::std::vector<double> &max_dvelocity,
+    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax) {
+  ::std::vector<double> max_dvelocity_back_pass = max_dvelocity;
+
+  // Now, iterate over the list of velocities and constrain the acceleration.
+  for (int i = num_plan_points_ - 2; i >= 0; --i) {
+    const double previous_velocity = max_dvelocity_back_pass[i + 1];
+    const double previous_distance = DistanceForIndex(i + 1);
+
+    // Now, integrate with respect to distance (not time like normal).
+    const double integrated_velocity = IntegrateAccelForDistance(
+        [&](double x, double v) {
+          return FeasableBackwardsVoltage(x, v, plan_vmax, alpha_unitizer);
+        },
+        previous_velocity, previous_distance, step_size_);
+    max_dvelocity_back_pass[i] =
+        ::std::min(integrated_velocity, max_dvelocity_back_pass[i]);
+  }
+
+  return max_dvelocity_back_pass;
+}
+
+::std::vector<double> Trajectory::ForwardsOptimizationAccelerationPass(
+    const ::std::vector<double> &max_dvelocity,
+    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax) {
+  ::std::vector<double> max_dvelocity_forwards_pass = max_dvelocity;
+  // Now, iterate over the list of velocities and constrain the acceleration.
+  for (size_t i = 1; i < num_plan_points_; ++i) {
+    const double previous_velocity = max_dvelocity_forwards_pass[i - 1];
+    const double previous_distance = DistanceForIndex(i - 1);
+
+    // Now, integrate with respect to distance (not time like normal).
+    const double integrated_velocity = IntegrateAccelForDistance(
+        [&](double x, double v) {
+          return FeasableForwardsAcceleration(x, v, plan_vmax, alpha_unitizer);
+        },
+        previous_velocity, previous_distance, step_size_);
+
+    max_dvelocity_forwards_pass[i] =
+        ::std::min(integrated_velocity, max_dvelocity_forwards_pass[i]);
+  }
+
+  return max_dvelocity_forwards_pass;
+}
+
+::std::vector<double> Trajectory::ForwardsOptimizationVoltagePass(
+    const ::std::vector<double> &max_dvelocity,
+    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax) {
+  ::std::vector<double> max_dvelocity_forwards_pass = max_dvelocity;
+  // Now, iterate over the list of velocities and constrain the acceleration.
+  for (size_t i = 1; i < num_plan_points_; ++i) {
+    const double previous_velocity = max_dvelocity_forwards_pass[i - 1];
+    const double previous_distance = DistanceForIndex(i - 1);
+
+    // Now, integrate with respect to distance (not time like normal).
+    const double integrated_velocity = IntegrateAccelForDistance(
+        [&](double x, double v) {
+          return FeasableForwardsVoltage(x, v, plan_vmax, alpha_unitizer);
+        },
+        previous_velocity, previous_distance, step_size_);
+
+    max_dvelocity_forwards_pass[i] =
+        ::std::min(integrated_velocity, max_dvelocity_forwards_pass[i]);
+  }
+
+  return max_dvelocity_forwards_pass;
+}
+
+void TrajectoryFollower::Reset() {
+  next_goal_ = last_goal_ = goal_ = ::Eigen::Matrix<double, 2, 1>::Zero();
+  U_unsaturated_ = U_ff_ = U_ = ::Eigen::Matrix<double, 3, 1>::Zero();
+  goal_acceleration_ = 0.0;
+  saturation_fraction_along_path_ = 0.0;
+  omega_.setZero();
+  if (trajectory_ != nullptr) {
+    set_theta(trajectory_->ThetaT(goal_(0)));
+  }
+}
+
+::Eigen::Matrix<double, 2, 6> TrajectoryFollower::ArmK_at_state(
+    const Eigen::Ref<const ::Eigen::Matrix<double, 6, 1>> arm_X,
+    const Eigen::Ref<const ::Eigen::Matrix<double, 2, 1>> arm_U) {
+  const double kProximalPos = FLAGS_lqr_proximal_pos;
+  const double kProximalVel = FLAGS_lqr_proximal_vel;
+  const double kDistalPos = FLAGS_lqr_distal_pos;
+  const double kDistalVel = FLAGS_lqr_distal_vel;
+  const ::Eigen::DiagonalMatrix<double, 4> Q =
+      (::Eigen::DiagonalMatrix<double, 4>().diagonal()
+           << 1.0 / ::std::pow(kProximalPos, 2),
+       1.0 / ::std::pow(kProximalVel, 2), 1.0 / ::std::pow(kDistalPos, 2),
+       1.0 / ::std::pow(kDistalVel, 2))
+          .finished()
+          .asDiagonal();
+
+  const ::Eigen::DiagonalMatrix<double, 2> R =
+      (::Eigen::DiagonalMatrix<double, 2>().diagonal()
+           << 1.0 / ::std::pow(12.0, 2),
+       1.0 / ::std::pow(12.0, 2))
+          .finished()
+          .asDiagonal();
+
+  const auto x_blocked = arm_X.block<4, 1>(0, 0);
+
+  const ::Eigen::Matrix<double, 4, 4> final_A =
+      ::frc971::control_loops::NumericalJacobianX<4, 2>(
+          [this](const auto &x_blocked, const auto &U, double dt) {
+            return this->dynamics_->UnboundedDiscreteDynamics(x_blocked, U, dt);
+          },
+          x_blocked, arm_U, 0.00505);
+
+  const ::Eigen::Matrix<double, 4, 2> final_B =
+      ::frc971::control_loops::NumericalJacobianU<4, 2>(
+          [this](const auto &x_blocked, const auto &U, double dt) {
+            return this->dynamics_->UnboundedDiscreteDynamics(x_blocked, U, dt);
+          },
+          x_blocked, arm_U, 0.00505);
+
+  ::Eigen::Matrix<double, 4, 4> S;
+  ::Eigen::Matrix<double, 2, 4> sub_K;
+  if (::frc971::controls::dlqr<4, 2>(final_A, final_B, Q, R, &sub_K, &S) == 0) {
+    if (VLOG_IS_ON(1)) {
+      ::Eigen::EigenSolver<::Eigen::Matrix<double, 4, 4>> eigensolver(
+          final_A - final_B * sub_K);
+      LOG(INFO) << eigensolver.eigenvalues().transpose();
+      LOG(INFO) << sub_K;
+    }
+
+    ::Eigen::Matrix<double, 2, 6> K;
+    K.setZero();
+    K.block<2, 4>(0, 0) = sub_K;
+    K(0, 4) = 1.0;
+    K(1, 5) = 1.0;
+    failed_solutions_ = 0;
+    last_K_ = K;
+  } else {
+    ++failed_solutions_;
+  }
+  return last_K_;
+}
+
+void TrajectoryFollower::USaturationSearch(
+    double goal_distance, double last_goal_distance, double goal_velocity,
+    double last_goal_velocity, double saturation_fraction_along_path,
+    const ::Eigen::Matrix<double, 2, 6> &arm_K,
+    const ::Eigen::Matrix<double, 9, 1> &X, const Trajectory &trajectory,
+    ::Eigen::Matrix<double, 3, 1> *U, double *saturation_goal_velocity,
+    double *saturation_goal_acceleration) {
+  double saturation_goal_distance =
+      ((goal_distance - last_goal_distance) * saturation_fraction_along_path +
+       last_goal_distance);
+
+  const ::Eigen::Matrix<double, 3, 1> theta_t =
+      trajectory.ThetaT(saturation_goal_distance);
+  *saturation_goal_velocity = trajectory.InterpolateVelocity(
+      saturation_goal_distance, last_goal_distance, goal_distance,
+      last_goal_velocity, goal_velocity);
+  *saturation_goal_acceleration = trajectory.InterpolateAcceleration(
+      last_goal_distance, goal_distance, last_goal_velocity, goal_velocity);
+  const ::Eigen::Matrix<double, 3, 1> omega_t =
+      trajectory.OmegaT(saturation_goal_distance, *saturation_goal_velocity);
+  const ::Eigen::Matrix<double, 3, 1> alpha_t =
+      trajectory.AlphaT(saturation_goal_distance, *saturation_goal_velocity,
+                        *saturation_goal_acceleration);
+  const ::Eigen::Matrix<double, 9, 1> R = trajectory.R(theta_t, omega_t);
+
+  const ::Eigen::Matrix<double, 3, 1> U_ff = ComputeFF_U(R, omega_t, alpha_t);
+
+  *U = U_ff;
+  U->block<2, 1>(0, 0) += arm_K * (R.block<6, 1>(0, 0) - X.block<6, 1>(0, 0));
+  U->block<1, 1>(2, 0) +=
+      roll_->controller().K() * (R.block<3, 1>(6, 0) - X.block<3, 1>(6, 0));
+}
+
+::Eigen::Matrix<double, 2, 1> TrajectoryFollower::PlanNextGoal(
+    const ::Eigen::Matrix<double, 2, 1> &goal, double plan_vmax, double dt) {
+  // Figure out where we would be if we were to accelerate as fast as
+  // our constraints allow.
+  ::Eigen::Matrix<double, 2, 1> next_goal = ::frc971::control_loops::RungeKutta(
+      [this, &plan_vmax](const ::Eigen::Matrix<double, 2, 1> &X) {
+        return (::Eigen::Matrix<double, 2, 1>() << X(1),
+                trajectory_->FeasableForwardsVoltage(
+                    X(0), X(1), plan_vmax, trajectory_->alpha_unitizer()))
+            .finished();
+      },
+      goal, dt);
+
+  // Min that with the backwards pass velocity in case the backwards pass
+  // wants us to slow down.
+  const double next_trajectory_velocity =
+      trajectory_->GetDVelocity(next_goal(0));
+  if (next_trajectory_velocity < next_goal(1)) {
+    next_goal(1) = next_trajectory_velocity;
+    // And then recompute how far to go with our new trajectory in mind.
+    double goal_acceleration = trajectory_->InterpolateAcceleration(
+        goal(0), next_goal(0), goal(1), next_goal(1));
+    next_goal(0) = (goal(0) + goal(1) * dt + 0.5 * dt * dt * goal_acceleration);
+    next_goal(1) = trajectory_->GetDVelocity(next_goal(0));
+  }
+  return next_goal;
+}
+
+Eigen::Matrix<double, 3, 1> TrajectoryFollower::ComputeFF_U(
+    const Eigen::Matrix<double, 9, 1> &X,
+    const Eigen::Matrix<double, 3, 1> &omega_t,
+    const Eigen::Matrix<double, 3, 1> &alpha_t) const {
+  Eigen::Matrix<double, 3, 1> result;
+  result.block<2, 1>(0, 0) =
+      dynamics_->FF_U(X.block<4, 1>(0, 0), omega_t.block<2, 1>(0, 0),
+                      alpha_t.block<2, 1>(0, 0));
+  result(2, 0) =
+      (alpha_t(2, 0) -
+       roll_->plant().coefficients().A_continuous(1, 1) * omega_t(2, 0)) /
+      roll_->plant().coefficients().B_continuous(1, 0);
+
+  return result;
+}
+
+void TrajectoryFollower::Update(const ::Eigen::Matrix<double, 9, 1> &X,
+                                bool disabled, double dt, double plan_vmax,
+                                double voltage_limit) {
+  // TODO(austin): Separate voltage limit for shoulder for no path.
+  last_goal_ = goal_;
+
+  if (!has_path()) {
+    // No path, so go to the last theta (no velocity).
+    last_goal_.setZero();
+    next_goal_.setZero();
+    goal_.setZero();
+    goal_acceleration_ = 0.0;
+    saturation_fraction_along_path_ = 0.0;
+
+    if (disabled) {
+      U_ff_.setZero();
+      U_.setZero();
+      U_unsaturated_.setZero();
+    } else {
+      const ::Eigen::Matrix<double, 9, 1> R =
+          Trajectory::R(theta_, ::Eigen::Matrix<double, 3, 1>::Zero());
+
+      U_ff_ = ComputeFF_U(X, ::Eigen::Matrix<double, 3, 1>::Zero(),
+                          ::Eigen::Matrix<double, 3, 1>::Zero());
+
+      const ::Eigen::Matrix<double, 2, 6> arm_K =
+          ArmK_at_state(X.block<6, 1>(0, 0), U_ff_.block<2, 1>(0, 0));
+
+      U_unsaturated_.block<2, 1>(0, 0) =
+          U_ff_.block<2, 1>(0, 0) +
+          arm_K * (R.block<6, 1>(0, 0) - X.block<6, 1>(0, 0));
+      U_unsaturated_(2, 0) =
+          U_ff_(2, 0) +
+          roll_->controller().K() * (R.block<3, 1>(6, 0) - X.block<3, 1>(6, 0));
+
+      U_ = U_unsaturated_;
+
+      U_ = U_.array().max(-voltage_limit).min(voltage_limit);
+    }
+    return;
+  }
+
+  if (disabled) {
+    // If we are disabled, it's likely for a bit of time.  So, lets freeze
+    // ourselves on the path (accept the previous motion, but then zero out the
+    // velocity).  Set all outputs to 0 as well.
+    next_goal_(1) = 0.0;
+    goal_ = next_goal_;
+    goal_acceleration_ = 0.0;
+    U_unsaturated_.setZero();
+    U_.setZero();
+    U_ff_.setZero();
+    saturation_fraction_along_path_ = 1.0;
+    theta_ = trajectory_->ThetaT(goal_(0));
+    omega_.setZero();
+    return;
+  }
+
+  // To avoid exposing the new goals before the outer code has a chance to
+  // querry the internal state, move to the new goals here.
+  goal_ = next_goal_;
+
+  if (::std::abs(goal_(0) - path()->length()) < 1e-2) {
+    // If we go backwards along the path near the goal, snap us to the end
+    // point or we'll never actually finish.
+    if (goal_acceleration_ * dt + goal_(1) < 0.0 ||
+        goal_(0) > path()->length()) {
+      goal_(0) = path()->length();
+      goal_(1) = 0.0;
+    }
+  }
+
+  if (goal_(0) == path()->length()) {
+    next_goal_(0) = goal_(0);
+    next_goal_(1) = 0.0;
+    goal_acceleration_ = 0.0;
+  } else {
+    // Figure out where we would be if we were to accelerate as fast as
+    // our constraints allow.
+    next_goal_ = PlanNextGoal(goal_, plan_vmax, dt);
+
+    goal_acceleration_ = trajectory_->InterpolateAcceleration(
+        goal_(0), next_goal_(0), goal_(1), next_goal_(1));
+  }
+
+  const ::Eigen::Matrix<double, 3, 1> theta_t = trajectory_->ThetaT(goal_(0));
+  const ::Eigen::Matrix<double, 3, 1> omega_t =
+      trajectory_->OmegaT(goal_(0), goal_(1));
+  const ::Eigen::Matrix<double, 3, 1> alpha_t =
+      trajectory_->AlphaT(goal_(0), goal_(1), goal_acceleration_);
+
+  const ::Eigen::Matrix<double, 9, 1> R = Trajectory::R(theta_t, omega_t);
+
+  U_ff_ = ComputeFF_U(R, omega_t, alpha_t);
+
+  const ::Eigen::Matrix<double, 2, 6> arm_K =
+      ArmK_at_state(X.block<6, 1>(0, 0), U_ff_.block<2, 1>(0, 0));
+  U_unsaturated_ = U_ff_;
+  U_unsaturated_.block<2, 1>(0, 0) +=
+      arm_K * (R.block<6, 1>(0, 0) - X.block<6, 1>(0, 0));
+  U_unsaturated_.block<1, 1>(2, 0) +=
+      roll_->controller().K() * (R.block<3, 1>(6, 0) - X.block<3, 1>(6, 0));
+  U_ = U_unsaturated_;
+
+  // Ok, now we know if we are staturated or not.  If we are, time to search
+  // between here and our previous goal either until we find a state where we
+  // aren't saturated, or we are really close to our starting point.
+  saturation_fraction_along_path_ = 1.0;
+  if ((U_.array().abs() > voltage_limit).any()) {
+    // Saturated.  Let's do a binary search.
+    double step_size;
+    if ((goal_(0) - last_goal_(0)) < 1e-8) {
+      // print "Not bothering to move"
+      // Avoid the divide by 0 when interpolating.  Just don't move since we
+      // are saturated.
+      saturation_fraction_along_path_ = 0.0;
+      step_size = 0.0;
+    } else {
+      saturation_fraction_along_path_ = 0.5;
+      step_size = 0.5;
+    }
+
+    // Pull us back to the previous point until we aren't saturated anymore.
+    double saturation_goal_velocity;
+    double saturation_goal_acceleration;
+    while (step_size > 0.01) {
+      USaturationSearch(goal_(0), last_goal_(0), goal_(1), last_goal_(1),
+                        saturation_fraction_along_path_, arm_K, X, *trajectory_,
+                        &U_, &saturation_goal_velocity,
+                        &saturation_goal_acceleration);
+      step_size = step_size * 0.5;
+      if ((U_.array().abs() > voltage_limit).any()) {
+        saturation_fraction_along_path_ -= step_size;
+      } else {
+        saturation_fraction_along_path_ += step_size;
+      }
+    }
+
+    goal_(0) = ((goal_(0) - last_goal_(0)) * saturation_fraction_along_path_ +
+                last_goal_(0));
+    goal_(1) = saturation_goal_velocity;
+
+    next_goal_ = PlanNextGoal(goal_, plan_vmax, dt);
+
+    goal_acceleration_ = trajectory_->InterpolateAcceleration(
+        goal_(0), next_goal_(0), goal_(1), next_goal_(1));
+
+    U_ = U_.array().max(-voltage_limit).min(voltage_limit);
+  }
+  theta_ = trajectory_->ThetaT(goal_(0));
+  omega_ = trajectory_->OmegaT(goal_(0), goal_(1));
+}
+
+}  // namespace arm
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2023
diff --git a/y2023/control_loops/superstructure/arm/trajectory.h b/y2023/control_loops/superstructure/arm/trajectory.h
new file mode 100644
index 0000000..17ec3aa
--- /dev/null
+++ b/y2023/control_loops/superstructure/arm/trajectory.h
@@ -0,0 +1,614 @@
+#ifndef Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_TRAJECTORY_H_
+#define Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_TRAJECTORY_H_
+
+#include <array>
+#include <initializer_list>
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "Eigen/Dense"
+#include "frc971/control_loops/binomial.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/fixed_quadrature.h"
+#include "frc971/control_loops/hybrid_state_feedback_loop.h"
+#include "frc971/control_loops/state_feedback_loop.h"
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+namespace arm {
+
+using frc971::control_loops::Binomial;
+using frc971::control_loops::GaussianQuadrature5;
+
+template <int N, int M>
+class NSpline {
+ public:
+  EIGEN_MAKE_ALIGNED_OPERATOR_NEW
+
+  // Computes the characteristic matrix of a given spline order. This is an
+  // upper triangular matrix rather than lower because our splines are
+  // represented as rows rather than columns.
+  // Each row represents the impact of each point with increasing powers of
+  // alpha. Row i, column j contains the effect point i has with the j'th power
+  // of alpha.
+  static ::Eigen::Matrix<double, N, N> SplineMatrix() {
+    ::Eigen::Matrix<double, N, N> matrix =
+        ::Eigen::Matrix<double, N, N>::Zero();
+
+    for (int i = 0; i < N; ++i) {
+      // Binomial(N - 1, i) * (1 - t) ^ (N - i - 1) * (t ^ i) * P[i]
+      const double binomial = Binomial(N - 1, i);
+      const int one_minus_t_power = N - i - 1;
+
+      // Then iterate over the powers of t and add the pieces to the matrix.
+      for (int j = i; j < N; ++j) {
+        // j is the power of t we are placing in the matrix.
+        // k is the power of t in the (1 - t) expression that we need to
+        // evaluate.
+        const int k = j - i;
+        const double tscalar =
+            binomial * Binomial(one_minus_t_power, k) * ::std::pow(-1.0, k);
+        matrix(i, j) = tscalar;
+      }
+    }
+    return matrix;
+  }
+
+  // Computes the matrix to multiply by [1, a, a^2, ...] to evaluate the spline.
+  template <int D>
+  static ::Eigen::Matrix<double, M, N - D> SplinePolynomial(
+      const ::Eigen::Matrix<double, M, N> &control_points) {
+    // We use rows for the spline, so the multiplication looks "backwards"
+    ::Eigen::Matrix<double, M, N> polynomial = control_points * SplineMatrix();
+
+    // Now, compute the derivative requested.
+    for (int i = D; i < N; ++i) {
+      // Start with t^i, and multiply i, i-1, i-2 until you have done this
+      // Derivative times.
+      double scalar = 1.0;
+      for (int j = i; j > i - D; --j) {
+        scalar *= j;
+      }
+      polynomial.template block<M, 1>(0, i) =
+          polynomial.template block<M, 1>(0, i) * scalar;
+    }
+    return polynomial.template block<M, N - D>(0, D);
+  }
+
+  // Computes an order K-1 polynomial matrix in alpha.  [1, alpha, alpha^2, ...]
+  template <int K>
+  static ::Eigen::Matrix<double, K, 1> AlphaPolynomial(double alpha) {
+    alpha = std::min(1.0, std::max(0.0, alpha));
+    ::Eigen::Matrix<double, K, 1> polynomial =
+        ::Eigen::Matrix<double, K, 1>::Zero();
+    polynomial(0) = 1.0;
+    for (int i = 1; i < K; ++i) {
+      polynomial(i) = polynomial(i - 1) * alpha;
+    }
+    return polynomial;
+  }
+
+  // Constructs a spline.  control_points is a matrix of start, control1,
+  // control2, ..., end.
+  NSpline(::Eigen::Matrix<double, M, N> control_points)
+      : control_points_(control_points),
+        spline_polynomial_(SplinePolynomial<0>(control_points_)),
+        dspline_polynomial_(SplinePolynomial<1>(control_points_)),
+        ddspline_polynomial_(SplinePolynomial<2>(control_points_)) {}
+
+  // Returns the xy coordiate of the spline for a given alpha.
+  ::Eigen::Matrix<double, M, 1> Theta(double alpha) const {
+    return spline_polynomial_ * AlphaPolynomial<N>(alpha);
+  }
+
+  // Returns the dspline/dalpha for a given alpha.
+  ::Eigen::Matrix<double, M, 1> Omega(double alpha) const {
+    return dspline_polynomial_ * AlphaPolynomial<N - 1>(alpha);
+  }
+
+  // Returns the d^2spline/dalpha^2 for a given alpha.
+  ::Eigen::Matrix<double, M, 1> Alpha(double alpha) const {
+    return ddspline_polynomial_ * AlphaPolynomial<N - 2>(alpha);
+  }
+
+  const ::Eigen::Matrix<double, M, N> &control_points() const {
+    return control_points_;
+  }
+
+ private:
+  const ::Eigen::Matrix<double, M, N> control_points_;
+
+  // Each of these polynomials gets multiplied by [x^(n-1), x^(n-2), ..., x, 1]
+  // depending on the size of the polynomial.
+  const ::Eigen::Matrix<double, M, N> spline_polynomial_;
+  const ::Eigen::Matrix<double, M, N - 1> dspline_polynomial_;
+  const ::Eigen::Matrix<double, M, N - 2> ddspline_polynomial_;
+};
+
+// Add a cos wave to the bottom of the 2d spline to handle the roll.
+class CosSpline {
+ public:
+  // Struct defining pairs of alphas and thetas.
+  struct AlphaTheta {
+    double alpha;
+    double theta;
+  };
+
+  CosSpline(NSpline<4, 2> spline, std::vector<AlphaTheta> roll)
+      : spline_(spline), roll_(std::move(roll)) {
+    CHECK_GE(roll_.size(), 2u);
+    CHECK_EQ(roll_[0].alpha, 0.0);
+    CHECK_EQ(roll_[roll_.size() - 1].alpha, 1.0);
+  }
+
+  // Returns the xy coordiate of the spline for a given alpha.
+  ::Eigen::Matrix<double, 3, 1> Theta(double alpha) const;
+
+  // Returns the dspline/dalpha for a given alpha.
+  ::Eigen::Matrix<double, 3, 1> Omega(double alpha) const;
+
+  // Returns the d^2spline/dalpha^2 for a given alpha.
+  ::Eigen::Matrix<double, 3, 1> Alpha(double alpha) const;
+
+  CosSpline Reversed() const;
+
+ private:
+  NSpline<4, 2> spline_;
+  const std::vector<AlphaTheta> roll_;
+
+  // Returns the two control points for the roll for an alpha.
+  std::pair<AlphaTheta, AlphaTheta> RollPoints(double alpha) const;
+};
+
+// Class to hold a spline as a function of distance.
+class Path {
+ public:
+  EIGEN_MAKE_ALIGNED_OPERATOR_NEW
+
+  Path(const CosSpline &spline, int num_alpha = 100)
+      : spline_(spline), distances_(BuildDistances(num_alpha)) {}
+
+  virtual ~Path() {}
+
+  // Returns a point on the spline as a function of distance.
+  ::Eigen::Matrix<double, 3, 1> Theta(double distance) const;
+
+  // Returns the velocity as a function of distance.
+  ::Eigen::Matrix<double, 3, 1> Omega(double distance) const;
+
+  // Returns the acceleration as a function of distance.
+  ::Eigen::Matrix<double, 3, 1> Alpha(double distance) const;
+
+  // Returns the length of the path in meters.
+  double length() const { return distances().back(); }
+
+  const absl::Span<const float> distances() const { return distances_; }
+
+  Path Reversed() const;
+  static ::std::unique_ptr<Path> Reversed(::std::unique_ptr<Path> p);
+
+ private:
+  std::vector<float> BuildDistances(size_t num_alpha);
+
+  // Computes alpha for a distance
+  double DistanceToAlpha(double distance) const;
+
+  const CosSpline spline_;
+  // An interpolation table of distances evenly distributed in alpha.
+  const ::std::vector<float> distances_;
+};
+
+namespace testing {
+class TrajectoryTest_IndicesForDistanceTest_Test;
+}  // namespace testing
+
+// A trajectory is a path and a set of velocities as a function of distance.
+class Trajectory {
+ public:
+  // Constructs a trajectory (but doesn't calculate it) given a path and a step
+  // size.
+  Trajectory(const frc971::control_loops::arm::Dynamics *dynamics,
+             const StateFeedbackHybridPlant<3, 1, 1> *roll,
+             ::std::unique_ptr<const Path> path, double gridsize)
+      : dynamics_(dynamics),
+        roll_(roll),
+        path_(::std::move(path)),
+        num_plan_points_(
+            static_cast<size_t>(::std::ceil(path_->length() / gridsize) + 1)),
+        step_size_(path_->length() /
+                   static_cast<double>(num_plan_points_ - 1)) {
+    alpha_unitizer_.setZero();
+  }
+
+  // Optimizes the trajectory.  The path will adhere to the constraints that
+  // || angular acceleration * alpha_unitizer || < 1, and the applied voltage <
+  // plan_vmax.
+  void OptimizeTrajectory(const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer,
+                          double plan_vmax) {
+    if (path_ == nullptr) {
+      abort();
+    }
+    alpha_unitizer_ = alpha_unitizer;
+
+    max_dvelocity_unfiltered_ =
+        CurvatureOptimizationPass(alpha_unitizer, plan_vmax);
+
+    // We need to start and end the trajectory with 0 velocity.
+    max_dvelocity_unfiltered_[0] = 0.0;
+    max_dvelocity_unfiltered_[max_dvelocity_unfiltered_.size() - 1] = 0.0;
+
+    max_dvelocity_backwards_accel_ = BackwardsOptimizationAccelerationPass(
+        max_dvelocity_unfiltered_, alpha_unitizer, plan_vmax);
+    max_dvelocity_forwards_accel_ = ForwardsOptimizationAccelerationPass(
+        max_dvelocity_backwards_accel_, alpha_unitizer, plan_vmax);
+
+    max_dvelocity_backwards_voltage_ = BackwardsOptimizationVoltagePass(
+        max_dvelocity_forwards_accel_, alpha_unitizer, plan_vmax);
+
+    max_dvelocity_forwards_voltage_ = ForwardsOptimizationVoltagePass(
+        max_dvelocity_backwards_voltage_, alpha_unitizer, plan_vmax);
+  }
+
+  // Returns an array of the distances used in the plan.  The starting point
+  // (0.0), and end point (path->length()) are included in the array.
+  ::std::vector<double> DistanceArray() const {
+    ::std::vector<double> result;
+    result.reserve(num_plan_points_);
+    for (size_t i = 0; i < num_plan_points_; ++i) {
+      result.push_back(DistanceForIndex(i));
+    }
+    return result;
+  }
+
+  // Computes the maximum velocity that we can follow the path while adhering to
+  // the constraints that || angular acceleration * alpha_unitizer || < 1, and
+  // the applied voltage < plan_vmax.  Returns the velocities.
+  ::std::vector<double> CurvatureOptimizationPass(
+      const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax);
+
+  double MaxCurvatureSpeed(double goal_distance,
+                           const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer,
+                           double plan_vmax);
+
+  // Computes the maximum forwards feasable acceleration at a given position
+  // while adhering to the plan_vmax and alpha_unitizer constraints.
+  // This gives us the maximum path distance acceleration (d^2d/dt^2) for any
+  // initial position and velocity for the forwards path.
+  double FeasableForwardsAcceleration(
+      double goal_distance, double goal_velocity, double plan_vmax,
+      const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer) const;
+  double FeasableForwardsVoltage(
+      double goal_distance, double goal_velocity, double plan_vmax,
+      const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer) const;
+
+  // Computes the maximum backwards feasable acceleration at a given position
+  // while adhering to the plan_vmax and alpha_unitizer constraints.
+  // This gives us the maximum path distance acceleration (d^2d/dt^2) for any
+  // initial position and velocity for the backwards path.
+  // Note: positive acceleration means speed up while going in the negative
+  // direction on the path.
+  double FeasableBackwardsAcceleration(
+      double goal_distance, double goal_velocity, double plan_vmax,
+      const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer) const;
+  double FeasableBackwardsVoltage(
+      double goal_distance, double goal_velocity, double plan_vmax,
+      const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer) const;
+
+  // Executes the backwards path optimization pass.
+  ::std::vector<double> BackwardsOptimizationAccelerationPass(
+      const ::std::vector<double> &max_dvelocity,
+      const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax);
+  ::std::vector<double> BackwardsOptimizationVoltagePass(
+      const ::std::vector<double> &max_dvelocity,
+      const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax);
+
+  // Executes the forwards path optimization pass.
+  ::std::vector<double> ForwardsOptimizationAccelerationPass(
+      const ::std::vector<double> &max_dvelocity,
+      const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax);
+  ::std::vector<double> ForwardsOptimizationVoltagePass(
+      const ::std::vector<double> &max_dvelocity,
+      const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double plan_vmax);
+
+  // Returns the number of points used for the plan.
+  size_t num_plan_points() const { return num_plan_points_; }
+
+  // Returns the curvature only velocity plan.
+  const ::std::vector<double> &max_dvelocity_unfiltered() const {
+    return max_dvelocity_unfiltered_;
+  }
+  // Returns the backwards pass + curvature plan.
+  const ::std::vector<double> &max_dvelocity_backward_accel() const {
+    return max_dvelocity_backwards_accel_;
+  }
+  const ::std::vector<double> &max_dvelocity_backward_voltage() const {
+    return max_dvelocity_backwards_voltage_;
+  }
+  // Returns the full plan.  This isn't useful at runtime since we don't want to
+  // be tied to a specific acceleration plan when we hit saturation.
+  const ::std::vector<double> &max_dvelocity_forwards_accel() const {
+    return max_dvelocity_forwards_accel_;
+  }
+  const ::std::vector<double> &max_dvelocity_forwards_voltage() const {
+    return max_dvelocity_forwards_voltage_;
+  }
+
+  // Returns the interpolated velocity at the distance d along the path.  The
+  // math assumes constant acceleration from point (d0, v0), to point (d1, v1),
+  // and that we are at point d between the two.
+  static double InterpolateVelocity(double d, double d0, double d1, double v0,
+                                    double v1) {
+    // We don't support negative velocities.  Report 0 velocity in that case.
+    // TODO(austin): Verify this doesn't show up in the real world.
+    if (v0 < 0 || v1 < 0) {
+      return 0.0;
+    }
+    if (d <= d0) {
+      return v0;
+    }
+    if (d >= d1) {
+      return v1;
+    }
+    return ::std::sqrt(v0 * v0 + (v1 * v1 - v0 * v0) * (d - d0) / (d1 - d0));
+  }
+
+  // Computes the path distance velocity of the plan as a function of the
+  // distance.
+  double GetDVelocity(double distance) const {
+    return GetDVelocity(distance, max_dvelocity_forwards_voltage_);
+  }
+  double GetDVelocity(double d, const ::std::vector<double> &plan) const {
+    ::std::pair<size_t, size_t> indices = IndicesForDistance(d);
+    const double v0 = plan[indices.first];
+    const double v1 = plan[indices.second];
+    const double d0 = DistanceForIndex(indices.first);
+    const double d1 = DistanceForIndex(indices.second);
+    return InterpolateVelocity(d, d0, d1, v0, v1);
+  }
+
+  // Computes the path distance acceleration of the plan as a function of the
+  // distance.
+  double GetDAcceleration(double distance) const {
+    return GetDAcceleration(distance, max_dvelocity_forwards_voltage_);
+  }
+  double GetDAcceleration(double distance,
+                          const ::std::vector<double> &plan) const {
+    ::std::pair<size_t, size_t> indices = IndicesForDistance(distance);
+    const double v0 = plan[indices.first];
+    const double v1 = plan[indices.second];
+    const double d0 = DistanceForIndex(indices.first);
+    const double d1 = DistanceForIndex(indices.second);
+    return InterpolateAcceleration(d0, d1, v0, v1);
+  }
+
+  // Returns the acceleration along the path segment assuming constant
+  // acceleration.
+  static double InterpolateAcceleration(double d0, double d1, double v0,
+                                        double v1) {
+    return 0.5 * (::std::pow(v1, 2) - ::std::pow(v0, 2)) / (d1 - d0);
+  }
+
+  ::Eigen::Matrix<double, 3, 1> ThetaT(double d) const {
+    return path_->Theta(d);
+  }
+
+  // Returns d theta/dt at a specified path distance and velocity.
+  ::Eigen::Matrix<double, 3, 1> OmegaT(double distance, double velocity) const {
+    if (distance > path_->length() || distance < 0.0) {
+      return ::Eigen::Matrix<double, 3, 1>::Zero();
+    } else {
+      return path_->Omega(distance) * velocity;
+    }
+  }
+
+  // Returns d^2 theta/dt^2 at a specified path distance, velocity and
+  // acceleration.
+  ::Eigen::Matrix<double, 3, 1> AlphaT(double distance, double velocity,
+                                       double acceleration) const {
+    if (distance > path_->length() || distance < 0.0) {
+      return ::Eigen::Matrix<double, 3, 1>::Zero();
+    } else {
+      return path_->Alpha(distance) * ::std::pow(velocity, 2) +
+             path_->Omega(distance) * acceleration;
+    }
+  }
+
+  // Converts a theta and omega vector to a full state vector.
+  static ::Eigen::Matrix<double, 9, 1> R(
+      ::Eigen::Matrix<double, 3, 1> theta_t,
+      ::Eigen::Matrix<double, 3, 1> omega_t) {
+    return (::Eigen::Matrix<double, 9, 1>() << theta_t(0, 0), omega_t(0, 0),
+            theta_t(1, 0), omega_t(1, 0), 0.0, 0.0, theta_t(2, 0),
+            omega_t(2, 0), 0.0)
+        .finished();
+  }
+
+  const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer() const {
+    return alpha_unitizer_;
+  }
+
+  const Path &path() const { return *path_; }
+
+ private:
+  friend class testing::TrajectoryTest_IndicesForDistanceTest_Test;
+
+  // Returns the distance along the path for a specific index in the plan.
+  double DistanceForIndex(size_t index) const {
+    return static_cast<double>(index) * step_size_;
+  }
+
+  // Returns the before and after indices for a specific distance in the plan.
+  ::std::pair<size_t, size_t> IndicesForDistance(double distance) const {
+    const double path_length = path_->length();
+    if (distance <= 0.0) {
+      return ::std::pair<size_t, size_t>(0, 1);
+    }
+    const size_t lower_index =
+        ::std::min(static_cast<size_t>(num_plan_points_ - 2),
+                   static_cast<size_t>(::std::floor((num_plan_points_ - 1) *
+                                                    distance / path_length)));
+
+    return ::std::pair<size_t, size_t>(lower_index, lower_index + 1);
+  }
+
+  const frc971::control_loops::arm::Dynamics *dynamics_;
+  const StateFeedbackHybridPlant<3, 1, 1> *roll_;
+
+  // The path to follow.
+  ::std::unique_ptr<const Path> path_;
+  // The number of points in the plan.
+  const size_t num_plan_points_;
+  // A cached version of the step size since we need this a *lot*.
+  const double step_size_;
+
+  ::std::vector<double> max_dvelocity_unfiltered_;
+  ::std::vector<double> max_dvelocity_backwards_voltage_;
+  ::std::vector<double> max_dvelocity_backwards_accel_;
+  ::std::vector<double> max_dvelocity_forwards_accel_;
+  ::std::vector<double> max_dvelocity_forwards_voltage_;
+
+  ::Eigen::Matrix<double, 3, 3> alpha_unitizer_;
+};
+
+// This class tracks the current goal along trajectories and paths.
+class TrajectoryFollower {
+ public:
+  TrajectoryFollower(const frc971::control_loops::arm::Dynamics *dynamics,
+                     const StateFeedbackLoop<3, 1, 1, double,
+                                             StateFeedbackHybridPlant<3, 1, 1>,
+                                             HybridKalman<3, 1, 1>> *roll,
+                     const ::Eigen::Matrix<double, 3, 1> &theta)
+      : dynamics_(dynamics), roll_(roll), trajectory_(nullptr), theta_(theta) {
+    omega_.setZero();
+    last_K_.setZero();
+    Reset();
+  }
+
+  TrajectoryFollower(const frc971::control_loops::arm::Dynamics *dynamics,
+                     const StateFeedbackLoop<3, 1, 1, double,
+                                             StateFeedbackHybridPlant<3, 1, 1>,
+                                             HybridKalman<3, 1, 1>> *roll,
+                     Trajectory *const trajectory)
+      : dynamics_(dynamics), roll_(roll), trajectory_(trajectory) {
+    last_K_.setZero();
+    Reset();
+  }
+
+  bool has_path() const { return trajectory_ != nullptr; }
+
+  void set_theta(const ::Eigen::Matrix<double, 3, 1> &theta) { theta_ = theta; }
+
+  // Returns the goal distance along the path.
+  const ::Eigen::Matrix<double, 2, 1> &goal() const { return goal_; }
+  double goal(int i) const { return goal_(i); }
+
+  // Starts over at the beginning of the path.
+  void Reset();
+
+  // Switches paths and starts at the beginning of the path.
+  void SwitchTrajectory(const Trajectory *trajectory) {
+    trajectory_ = trajectory;
+    Reset();
+  }
+
+  // Returns the controller gain at the provided state.
+  ::Eigen::Matrix<double, 2, 6> ArmK_at_state(
+      const Eigen::Ref<const ::Eigen::Matrix<double, 6, 1>> arm_X,
+      const Eigen::Ref<const ::Eigen::Matrix<double, 2, 1>> arm_U);
+
+  // Returns the voltage, velocity and acceleration if we were to be partially
+  // along the path.
+  void USaturationSearch(double goal_distance, double last_goal_distance,
+                         double goal_velocity, double last_goal_velocity,
+                         double saturation_fraction_along_path,
+                         const ::Eigen::Matrix<double, 2, 6> &K,
+                         const ::Eigen::Matrix<double, 9, 1> &X,
+                         const Trajectory &trajectory,
+                         ::Eigen::Matrix<double, 3, 1> *U,
+                         double *saturation_goal_velocity,
+                         double *saturation_goal_acceleration);
+
+  // Returns the next goal given a planning plan_vmax and timestep.  This
+  // ignores the backwards pass.
+  ::Eigen::Matrix<double, 2, 1> PlanNextGoal(
+      const ::Eigen::Matrix<double, 2, 1> &goal, double plan_vmax, double dt);
+
+  // Plans the next cycle and updates the internal state for consumption.
+  void Update(const ::Eigen::Matrix<double, 9, 1> &X, bool disabled, double dt,
+              double plan_vmax, double voltage_limit);
+
+  // Returns the goal acceleration for this cycle.
+  double goal_acceleration() const { return goal_acceleration_; }
+
+  // Returns U(s) for this cycle.
+  const ::Eigen::Matrix<double, 3, 1> &U() const { return U_; }
+  double U(int i) const { return U_(i); }
+  const ::Eigen::Matrix<double, 3, 1> &U_unsaturated() const {
+    return U_unsaturated_;
+  }
+  const ::Eigen::Matrix<double, 3, 1> &U_ff() const { return U_ff_; }
+
+  double saturation_fraction_along_path() const {
+    return saturation_fraction_along_path_;
+  }
+
+  const ::Eigen::Matrix<double, 3, 1> &theta() const { return theta_; }
+  double theta(int i) const { return theta_(i); }
+  const ::Eigen::Matrix<double, 3, 1> &omega() const { return omega_; }
+  double omega(int i) const { return omega_(i); }
+
+  // Distance left on the path before we get to the end of the path.
+  double path_distance_to_go() const {
+    if (has_path()) {
+      return ::std::max(0.0, path()->length() - goal_(0));
+    } else {
+      return 0.0;
+    }
+  }
+
+  const Path *path() const { return &trajectory_->path(); }
+
+  int failed_solutions() const { return failed_solutions_; }
+
+  Eigen::Matrix<double, 3, 1> ComputeFF_U(
+      const Eigen::Matrix<double, 9, 1> &X,
+      const Eigen::Matrix<double, 3, 1> &omega_t,
+      const Eigen::Matrix<double, 3, 1> &alpha_t) const;
+
+ private:
+  const frc971::control_loops::arm::Dynamics *dynamics_;
+  const StateFeedbackLoop<3, 1, 1, double, StateFeedbackHybridPlant<3, 1, 1>,
+                          HybridKalman<3, 1, 1>> *roll_;
+  // The trajectory plan.
+  const Trajectory *trajectory_ = nullptr;
+
+  // The current goal.
+  ::Eigen::Matrix<double, 2, 1> goal_;
+  // The previously executed goal.
+  ::Eigen::Matrix<double, 2, 1> last_goal_;
+  // The goal to use next cycle.  We always plan 1 cycle ahead.
+  ::Eigen::Matrix<double, 2, 1> next_goal_;
+
+  ::Eigen::Matrix<double, 3, 1> U_;
+  ::Eigen::Matrix<double, 3, 1> U_unsaturated_;
+  ::Eigen::Matrix<double, 3, 1> U_ff_;
+  double goal_acceleration_ = 0.0;
+
+  double saturation_fraction_along_path_ = 1.0;
+
+  // Holds the last valid goal position for when we loose our path.
+  ::Eigen::Matrix<double, 3, 1> theta_;
+  ::Eigen::Matrix<double, 3, 1> omega_;
+
+  ::Eigen::Matrix<double, 2, 6> last_K_;
+  int failed_solutions_ = 0;
+};
+
+}  // namespace arm
+}  // namespace control_loops
+}  // namespace frc971
+}  // namespace y2023
+
+#endif  // Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_ARM_TRAJECTORY_H_
diff --git a/y2023/control_loops/superstructure/arm/trajectory_plot.cc b/y2023/control_loops/superstructure/arm/trajectory_plot.cc
new file mode 100644
index 0000000..c56ced9
--- /dev/null
+++ b/y2023/control_loops/superstructure/arm/trajectory_plot.cc
@@ -0,0 +1,543 @@
+#include "aos/init.h"
+#include "frc971/analysis/in_process_plotter.h"
+#include "frc971/control_loops/binomial.h"
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/ekf.h"
+#include "frc971/control_loops/fixed_quadrature.h"
+#include "gflags/gflags.h"
+#include "y2023/control_loops/superstructure/arm/arm_constants.h"
+#include "y2023/control_loops/superstructure/arm/generated_graph.h"
+#include "y2023/control_loops/superstructure/arm/trajectory.h"
+#include "y2023/control_loops/superstructure/roll/integral_hybrid_roll_plant.h"
+#include "y2023/control_loops/superstructure/roll/integral_roll_plant.h"
+
+DEFINE_bool(forwards, true, "If true, run the forwards simulation.");
+DEFINE_bool(plot, true, "If true, plot");
+DEFINE_bool(plot_thetas, true, "If true, plot the angles");
+
+DEFINE_double(alpha0_max, 20.0, "Max acceleration on joint 0.");
+DEFINE_double(alpha1_max, 30.0, "Max acceleration on joint 1.");
+DEFINE_double(alpha2_max, 60.0, "Max acceleration on joint 2.");
+DEFINE_double(vmax_plan, 10.0, "Max voltage to plan.");
+DEFINE_double(vmax_battery, 12.0, "Max battery voltage.");
+DEFINE_double(time, 2.0, "Simulation time.");
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+namespace arm {
+using frc971::control_loops::MatrixGaussianQuadrature5;
+
+void Main() {
+  frc971::control_loops::arm::Dynamics dynamics(kArmConstants);
+  StateFeedbackLoop<3, 1, 1, double, StateFeedbackHybridPlant<3, 1, 1>,
+                    HybridKalman<3, 1, 1>>
+      hybrid_roll = superstructure::roll::MakeIntegralHybridRollLoop();
+
+  Eigen::Matrix<double, 2, 4> spline_params;
+
+  spline_params << 0.30426338, 0.42813912, 0.64902386, 0.55127045, -1.73611082,
+      -1.64478944, -1.04763868, -0.82624244;
+  LOG(INFO) << "Spline " << spline_params;
+  NSpline<4, 2> spline(spline_params);
+  CosSpline cos_spline(spline,
+                       {{0.0, 0.1}, {0.3, 0.1}, {0.7, 0.2}, {1.0, 0.2}});
+  Path distance_spline(cos_spline, 100);
+
+  Trajectory trajectory(&dynamics, &hybrid_roll.plant(),
+                        std::make_unique<Path>(cos_spline), 0.001);
+
+  constexpr double sim_dt = 0.00505;
+
+  LOG(INFO) << "Planning with kAlpha0Max=" << FLAGS_alpha0_max
+            << ", kAlpha1Max=" << FLAGS_alpha1_max
+            << ", kAlpha2Max=" << FLAGS_alpha2_max;
+
+  const ::Eigen::DiagonalMatrix<double, 3> alpha_unitizer(
+      (::Eigen::DiagonalMatrix<double, 3>().diagonal() << (1.0 / FLAGS_alpha0_max),
+       (1.0 / FLAGS_alpha1_max), (1.0 / FLAGS_alpha2_max))
+          .finished());
+  trajectory.OptimizeTrajectory(alpha_unitizer, FLAGS_vmax_plan);
+
+  const ::std::vector<double> distance_array = trajectory.DistanceArray();
+
+  ::std::vector<double> theta0_array;
+  ::std::vector<double> theta1_array;
+  ::std::vector<double> theta2_array;
+  ::std::vector<double> omega0_array;
+  ::std::vector<double> omega1_array;
+  ::std::vector<double> omega2_array;
+  ::std::vector<double> alpha0_array;
+  ::std::vector<double> alpha1_array;
+  ::std::vector<double> alpha2_array;
+
+  ::std::vector<double> integrated_distance;
+  ::std::vector<double> integrated_theta0_array;
+  ::std::vector<double> integrated_theta1_array;
+  ::std::vector<double> integrated_theta2_array;
+  ::std::vector<double> integrated_omega0_array;
+  ::std::vector<double> integrated_omega1_array;
+  ::std::vector<double> integrated_omega2_array;
+
+  ::Eigen::Matrix<double, 3, 1> integrated_theta = distance_spline.Theta(0.0);
+  ::Eigen::Matrix<double, 3, 1> integrated_omega = distance_spline.Omega(0.0);
+
+  // Plot the splines and their integrals to check consistency.
+  for (const double d : distance_array) {
+    const ::Eigen::Matrix<double, 3, 1> theta = distance_spline.Theta(d);
+    const ::Eigen::Matrix<double, 3, 1> omega = distance_spline.Omega(d);
+    const ::Eigen::Matrix<double, 3, 1> alpha = distance_spline.Alpha(d);
+    theta0_array.push_back(theta(0, 0));
+    theta1_array.push_back(theta(1, 0));
+    theta2_array.push_back(theta(2, 0));
+    omega0_array.push_back(omega(0, 0));
+    omega1_array.push_back(omega(1, 0));
+    omega2_array.push_back(omega(2, 0));
+    alpha0_array.push_back(alpha(0, 0));
+    alpha1_array.push_back(alpha(1, 0));
+    alpha2_array.push_back(alpha(2, 0));
+  }
+
+  const double dd = distance_spline.length() / 1000.0;
+  for (double d = 0; d <= distance_spline.length(); d += dd) {
+    integrated_distance.push_back(d);
+    integrated_omega0_array.push_back(integrated_omega(0));
+    integrated_omega1_array.push_back(integrated_omega(1));
+    integrated_omega2_array.push_back(integrated_omega(2));
+    integrated_theta0_array.push_back(integrated_theta(0));
+    integrated_theta1_array.push_back(integrated_theta(1));
+    integrated_theta2_array.push_back(integrated_theta(2));
+
+    integrated_theta += MatrixGaussianQuadrature5<3>(
+        [&](double distance) { return distance_spline.Omega(distance); }, d,
+        d + dd);
+    integrated_omega += MatrixGaussianQuadrature5<3>(
+        [&](double distance) { return distance_spline.Alpha(distance); }, d,
+        d + dd);
+  }
+
+  // Next step: see what U is as a function of distance for all the passes.
+  ::std::vector<double> Uff0_distance_array_curvature;
+  ::std::vector<double> Uff1_distance_array_curvature;
+  ::std::vector<double> Uff2_distance_array_curvature;
+  ::std::vector<double> Uff0_distance_array_backwards_accel_only;
+  ::std::vector<double> Uff1_distance_array_backwards_accel_only;
+  ::std::vector<double> Uff2_distance_array_backwards_accel_only;
+  ::std::vector<double> Uff0_distance_array_forwards_accel_only;
+  ::std::vector<double> Uff1_distance_array_forwards_accel_only;
+  ::std::vector<double> Uff2_distance_array_forwards_accel_only;
+  ::std::vector<double> Uff0_distance_array_backwards_voltage_only;
+  ::std::vector<double> Uff1_distance_array_backwards_voltage_only;
+  ::std::vector<double> Uff2_distance_array_backwards_voltage_only;
+  ::std::vector<double> Uff0_distance_array_forwards_voltage_only;
+  ::std::vector<double> Uff1_distance_array_forwards_voltage_only;
+  ::std::vector<double> Uff2_distance_array_forwards_voltage_only;
+
+  TrajectoryFollower follower(&dynamics, &hybrid_roll, &trajectory);
+
+  for (const double distance : distance_array) {
+    const ::Eigen::Matrix<double, 3, 1> theta_t = trajectory.ThetaT(distance);
+
+    {
+      const double goal_velocity = trajectory.GetDVelocity(
+          distance, trajectory.max_dvelocity_unfiltered());
+      const double goal_acceleration = trajectory.GetDAcceleration(
+          distance, trajectory.max_dvelocity_unfiltered());
+      const ::Eigen::Matrix<double, 3, 1> omega_t =
+          trajectory.OmegaT(distance, goal_velocity);
+      const ::Eigen::Matrix<double, 3, 1> alpha_t =
+          trajectory.AlphaT(distance, goal_velocity, goal_acceleration);
+
+      const ::Eigen::Matrix<double, 9, 1> R = trajectory.R(theta_t, omega_t);
+      const ::Eigen::Matrix<double, 3, 1> U =
+          follower.ComputeFF_U(R, omega_t, alpha_t).array().max(-20).min(20);
+
+      Uff0_distance_array_curvature.push_back(U(0));
+      Uff1_distance_array_curvature.push_back(U(1));
+      Uff2_distance_array_curvature.push_back(U(2));
+    }
+    {
+      const double goal_velocity = trajectory.GetDVelocity(
+          distance, trajectory.max_dvelocity_backward_accel());
+      const double goal_acceleration = trajectory.GetDAcceleration(
+          distance, trajectory.max_dvelocity_backward_accel());
+      const ::Eigen::Matrix<double, 3, 1> omega_t =
+          trajectory.OmegaT(distance, goal_velocity);
+      const ::Eigen::Matrix<double, 3, 1> alpha_t =
+          trajectory.AlphaT(distance, goal_velocity, goal_acceleration);
+
+      const ::Eigen::Matrix<double, 9, 1> R = trajectory.R(theta_t, omega_t);
+      const ::Eigen::Matrix<double, 3, 1> U =
+          follower.ComputeFF_U(R, omega_t, alpha_t).array().max(-20).min(20);
+
+      Uff0_distance_array_backwards_accel_only.push_back(U(0));
+      Uff1_distance_array_backwards_accel_only.push_back(U(1));
+      Uff2_distance_array_backwards_accel_only.push_back(U(2));
+    }
+    {
+      const double goal_velocity = trajectory.GetDVelocity(
+          distance, trajectory.max_dvelocity_forwards_accel());
+      const double goal_acceleration = trajectory.GetDAcceleration(
+          distance, trajectory.max_dvelocity_forwards_accel());
+      const ::Eigen::Matrix<double, 3, 1> omega_t =
+          trajectory.OmegaT(distance, goal_velocity);
+      const ::Eigen::Matrix<double, 3, 1> alpha_t =
+          trajectory.AlphaT(distance, goal_velocity, goal_acceleration);
+
+      const ::Eigen::Matrix<double, 9, 1> R = trajectory.R(theta_t, omega_t);
+      const ::Eigen::Matrix<double, 3, 1> U =
+          follower.ComputeFF_U(R, omega_t, alpha_t).array().max(-20).min(20);
+
+      Uff0_distance_array_forwards_accel_only.push_back(U(0));
+      Uff1_distance_array_forwards_accel_only.push_back(U(1));
+      Uff2_distance_array_forwards_accel_only.push_back(U(2));
+    }
+    {
+      const double goal_velocity = trajectory.GetDVelocity(
+          distance, trajectory.max_dvelocity_backward_voltage());
+      const double goal_acceleration = trajectory.GetDAcceleration(
+          distance, trajectory.max_dvelocity_backward_voltage());
+      const ::Eigen::Matrix<double, 3, 1> omega_t =
+          trajectory.OmegaT(distance, goal_velocity);
+      const ::Eigen::Matrix<double, 3, 1> alpha_t =
+          trajectory.AlphaT(distance, goal_velocity, goal_acceleration);
+
+      const ::Eigen::Matrix<double, 9, 1> R = trajectory.R(theta_t, omega_t);
+      const ::Eigen::Matrix<double, 3, 1> U =
+          follower.ComputeFF_U(R, omega_t, alpha_t).array().max(-20).min(20);
+
+      Uff0_distance_array_backwards_voltage_only.push_back(U(0));
+      Uff1_distance_array_backwards_voltage_only.push_back(U(1));
+      Uff2_distance_array_backwards_voltage_only.push_back(U(2));
+    }
+    {
+      const double goal_velocity = trajectory.GetDVelocity(
+          distance, trajectory.max_dvelocity_forwards_voltage());
+      const double goal_acceleration = trajectory.GetDAcceleration(
+          distance, trajectory.max_dvelocity_forwards_voltage());
+      const ::Eigen::Matrix<double, 3, 1> omega_t =
+          trajectory.OmegaT(distance, goal_velocity);
+      const ::Eigen::Matrix<double, 3, 1> alpha_t =
+          trajectory.AlphaT(distance, goal_velocity, goal_acceleration);
+
+      const ::Eigen::Matrix<double, 9, 1> R = trajectory.R(theta_t, omega_t);
+      const ::Eigen::Matrix<double, 3, 1> U =
+          follower.ComputeFF_U(R, omega_t, alpha_t).array().max(-20).min(20);
+
+      Uff0_distance_array_forwards_voltage_only.push_back(U(0));
+      Uff1_distance_array_forwards_voltage_only.push_back(U(1));
+      Uff2_distance_array_forwards_voltage_only.push_back(U(2));
+    }
+  }
+
+  double t = 0;
+  ::Eigen::Matrix<double, 4, 1> arm_X;
+  ::Eigen::Matrix<double, 2, 1> roll_X;
+  {
+    ::Eigen::Matrix<double, 3, 1> theta_t = trajectory.ThetaT(0.0);
+    arm_X << theta_t(0), 0.0, theta_t(1), 0.0;
+    roll_X << theta_t(2), 0.0;
+  }
+
+  ::std::vector<double> t_array;
+  ::std::vector<double> theta0_goal_t_array;
+  ::std::vector<double> theta1_goal_t_array;
+  ::std::vector<double> theta2_goal_t_array;
+  ::std::vector<double> omega0_goal_t_array;
+  ::std::vector<double> omega1_goal_t_array;
+  ::std::vector<double> omega2_goal_t_array;
+  ::std::vector<double> alpha0_goal_t_array;
+  ::std::vector<double> alpha1_goal_t_array;
+  ::std::vector<double> alpha2_goal_t_array;
+  ::std::vector<double> theta0_t_array;
+  ::std::vector<double> omega0_t_array;
+  ::std::vector<double> theta1_t_array;
+  ::std::vector<double> omega1_t_array;
+  ::std::vector<double> theta2_t_array;
+  ::std::vector<double> omega2_t_array;
+  ::std::vector<double> distance_t_array;
+  ::std::vector<double> velocity_t_array;
+  ::std::vector<double> acceleration_t_array;
+  ::std::vector<double> u0_unsaturated_array;
+  ::std::vector<double> u1_unsaturated_array;
+  ::std::vector<double> u2_unsaturated_array;
+  ::std::vector<double> alpha0_t_array;
+  ::std::vector<double> alpha1_t_array;
+  ::std::vector<double> alpha2_t_array;
+  ::std::vector<double> uff0_array;
+  ::std::vector<double> uff1_array;
+  ::std::vector<double> uff2_array;
+  ::std::vector<double> u0_array;
+  ::std::vector<double> u1_array;
+  ::std::vector<double> u2_array;
+  ::std::vector<double> theta0_hat_t_array;
+  ::std::vector<double> omega0_hat_t_array;
+  ::std::vector<double> theta1_hat_t_array;
+  ::std::vector<double> omega1_hat_t_array;
+  ::std::vector<double> theta2_hat_t_array;
+  ::std::vector<double> omega2_hat_t_array;
+  ::std::vector<double> torque0_hat_t_array;
+  ::std::vector<double> torque1_hat_t_array;
+  ::std::vector<double> torque2_hat_t_array;
+
+  // Now follow the trajectory.
+  frc971::control_loops::arm::EKF arm_ekf(&dynamics);
+  arm_ekf.Reset(arm_X);
+  StateFeedbackLoop<3, 1, 1, double, StateFeedbackPlant<3, 1, 1>,
+                    StateFeedbackObserver<3, 1, 1>>
+      roll = superstructure::roll::MakeIntegralRollLoop();
+  roll.mutable_X_hat().setZero();
+  roll.mutable_X_hat().block<2, 1>(0, 0) = roll_X;
+
+  ::std::cout << "Reset P: " << arm_ekf.P_reset() << ::std::endl;
+  ::std::cout << "Stabilized P: " << arm_ekf.P_half_converged() << ::std::endl;
+  ::std::cout << "Really stabilized P: " << arm_ekf.P_converged()
+              << ::std::endl;
+
+  while (t < FLAGS_time) {
+    t_array.push_back(t);
+    arm_ekf.Correct(
+        (::Eigen::Matrix<double, 2, 1>() << arm_X(0), arm_X(2)).finished(),
+        sim_dt);
+    roll.Correct((::Eigen::Matrix<double, 1, 1>() << roll_X(0)).finished());
+    follower.Update(
+        (Eigen::Matrix<double, 9, 1>() << arm_ekf.X_hat(), roll.X_hat())
+            .finished(),
+        false, sim_dt, FLAGS_vmax_plan, FLAGS_vmax_battery);
+
+    const ::Eigen::Matrix<double, 3, 1> theta_t =
+        trajectory.ThetaT(follower.goal()(0));
+    const ::Eigen::Matrix<double, 3, 1> omega_t =
+        trajectory.OmegaT(follower.goal()(0), follower.goal()(1));
+    const ::Eigen::Matrix<double, 3, 1> alpha_t = trajectory.AlphaT(
+        follower.goal()(0), follower.goal()(1), follower.goal_acceleration());
+
+    theta0_goal_t_array.push_back(theta_t(0));
+    theta1_goal_t_array.push_back(theta_t(1));
+    theta2_goal_t_array.push_back(theta_t(2));
+    omega0_goal_t_array.push_back(omega_t(0));
+    omega1_goal_t_array.push_back(omega_t(1));
+    omega2_goal_t_array.push_back(omega_t(2));
+    alpha0_goal_t_array.push_back(alpha_t(0));
+    alpha1_goal_t_array.push_back(alpha_t(1));
+    alpha2_goal_t_array.push_back(alpha_t(2));
+    theta0_t_array.push_back(arm_X(0));
+    omega0_t_array.push_back(arm_X(1));
+    theta1_t_array.push_back(arm_X(2));
+    omega1_t_array.push_back(arm_X(3));
+    theta2_t_array.push_back(roll_X(0));
+    omega2_t_array.push_back(roll_X(1));
+    theta0_hat_t_array.push_back(arm_ekf.X_hat(0));
+    omega0_hat_t_array.push_back(arm_ekf.X_hat(1));
+    torque0_hat_t_array.push_back(arm_ekf.X_hat(4));
+    theta1_hat_t_array.push_back(arm_ekf.X_hat(2));
+    omega1_hat_t_array.push_back(arm_ekf.X_hat(3));
+    torque1_hat_t_array.push_back(arm_ekf.X_hat(5));
+
+    theta2_hat_t_array.push_back(roll.X_hat(0));
+    omega2_hat_t_array.push_back(roll.X_hat(1));
+    torque2_hat_t_array.push_back(roll.X_hat(2));
+
+    distance_t_array.push_back(follower.goal()(0));
+    velocity_t_array.push_back(follower.goal()(1));
+    acceleration_t_array.push_back(follower.goal_acceleration());
+
+    u0_unsaturated_array.push_back(follower.U_unsaturated()(0));
+    u1_unsaturated_array.push_back(follower.U_unsaturated()(1));
+    u2_unsaturated_array.push_back(follower.U_unsaturated()(2));
+
+    ::Eigen::Matrix<double, 3, 1> actual_U = follower.U();
+    // Add in a disturbance force to see how well the arm learns it.
+    // actual_U(0) += 1.0;
+
+    const ::Eigen::Matrix<double, 4, 1> arm_xdot =
+        dynamics.Acceleration(arm_X, actual_U.block<2, 1>(0, 0));
+    const ::Eigen::Matrix<double, 2, 1> roll_xdot =
+        hybrid_roll.plant().coefficients().A_continuous.block<2, 2>(0, 0) *
+            roll_X +
+        hybrid_roll.plant().coefficients().B_continuous.block<2, 1>(0, 0) *
+            actual_U.block<1, 1>(2, 0);
+
+    arm_X = dynamics.UnboundedDiscreteDynamics(
+        arm_X, actual_U.block<2, 1>(0, 0), sim_dt);
+    arm_ekf.Predict(follower.U().block<2, 1>(0, 0), sim_dt);
+    roll_X =
+        roll.plant()
+            .Update((Eigen::Matrix<double, 3, 1>() << roll_X, 0.0).finished(),
+                    follower.U().block<1, 1>(2, 0))
+            .block<2, 1>(0, 0);
+    roll.UpdateObserver(follower.U().block<1, 1>(2, 0),
+                        std::chrono::duration_cast<std::chrono::nanoseconds>(
+                            std::chrono::duration<double>(sim_dt)));
+
+    alpha0_t_array.push_back(arm_xdot(1));
+    alpha1_t_array.push_back(arm_xdot(3));
+    alpha2_t_array.push_back(roll_xdot(1));
+
+    uff0_array.push_back(follower.U_ff()(0));
+    uff1_array.push_back(follower.U_ff()(1));
+    uff2_array.push_back(follower.U_ff()(2));
+    u0_array.push_back(follower.U()(0));
+    u1_array.push_back(follower.U()(1));
+    u2_array.push_back(follower.U()(2));
+
+    t += sim_dt;
+  }
+
+  if (FLAGS_plot) {
+    frc971::analysis::Plotter plotter;
+
+    plotter.AddFigure();
+    plotter.Title("Input spline");
+    plotter.AddLine(distance_array, theta0_array, "theta0");
+    plotter.AddLine(distance_array, theta1_array, "theta1");
+    plotter.AddLine(distance_array, theta2_array, "theta2");
+    plotter.AddLine(distance_array, omega0_array, "omega0");
+    plotter.AddLine(distance_array, omega1_array, "omega1");
+    plotter.AddLine(distance_array, omega2_array, "omega2");
+    plotter.AddLine(distance_array, alpha0_array, "alpha0");
+    plotter.AddLine(distance_array, alpha1_array, "alpha1");
+    plotter.AddLine(distance_array, alpha2_array, "alpha2");
+
+    plotter.AddLine(integrated_distance, integrated_theta0_array,
+                    "integrated theta0");
+    plotter.AddLine(integrated_distance, integrated_theta1_array,
+                    "integrated theta1");
+    plotter.AddLine(integrated_distance, integrated_theta2_array,
+                    "integrated theta2");
+    plotter.AddLine(integrated_distance, integrated_omega0_array,
+                    "integrated omega0");
+    plotter.AddLine(integrated_distance, integrated_omega1_array,
+                    "integrated omega1");
+    plotter.AddLine(integrated_distance, integrated_omega2_array,
+                    "integrated omega2");
+    plotter.Publish();
+
+    plotter.AddFigure();
+    plotter.Title("Trajectory");
+    plotter.AddLine(theta0_array, theta1_array, "desired path");
+    plotter.AddLine(theta0_t_array, theta1_t_array, "actual path");
+    plotter.Publish();
+
+    plotter.AddFigure();
+    plotter.Title("Solver passes");
+    plotter.AddLine(distance_array, trajectory.max_dvelocity_unfiltered(),
+                    "pass0");
+    plotter.AddLine(distance_array, trajectory.max_dvelocity_backward_accel(),
+                    "passb accel");
+    plotter.AddLine(distance_array, trajectory.max_dvelocity_forwards_accel(),
+                    "passf accel");
+    plotter.AddLine(distance_array, trajectory.max_dvelocity_backward_voltage(),
+                    "passb voltage");
+    plotter.AddLine(distance_array, trajectory.max_dvelocity_forwards_voltage(),
+                    "passf voltage");
+    plotter.Publish();
+
+    plotter.AddFigure();
+    plotter.Title("Time Goals");
+    plotter.AddLine(t_array, alpha0_goal_t_array, "alpha0_t_goal");
+    plotter.AddLine(t_array, alpha0_t_array, "alpha0_t");
+    plotter.AddLine(t_array, alpha1_goal_t_array, "alpha1_t_goal");
+    plotter.AddLine(t_array, alpha1_t_array, "alpha1_t");
+    plotter.AddLine(t_array, alpha2_goal_t_array, "alpha2_t_goal");
+    plotter.AddLine(t_array, alpha2_t_array, "alpha2_t");
+    plotter.AddLine(t_array, distance_t_array, "distance_t");
+    plotter.AddLine(t_array, velocity_t_array, "velocity_t");
+    plotter.AddLine(t_array, acceleration_t_array, "acceleration_t");
+    plotter.Publish();
+
+    plotter.AddFigure();
+    plotter.Title("Angular Velocities");
+    plotter.AddLine(t_array, omega0_goal_t_array, "omega0_t_goal");
+    plotter.AddLine(t_array, omega0_t_array, "omega0_t");
+    plotter.AddLine(t_array, omega0_hat_t_array, "omega0_hat_t");
+
+    plotter.AddLine(t_array, omega1_goal_t_array, "omega1_t_goal");
+    plotter.AddLine(t_array, omega1_t_array, "omega1_t");
+    plotter.AddLine(t_array, omega1_hat_t_array, "omega1_hat_t");
+
+    plotter.AddLine(t_array, omega2_goal_t_array, "omega2_t_goal");
+    plotter.AddLine(t_array, omega2_t_array, "omega2_t");
+    plotter.AddLine(t_array, omega2_hat_t_array, "omega2_hat_t");
+    plotter.Publish();
+
+    plotter.AddFigure();
+    plotter.Title("Voltages");
+    plotter.AddLine(t_array, u0_unsaturated_array, "u0_full");
+    plotter.AddLine(t_array, u0_array, "u0");
+    plotter.AddLine(t_array, uff0_array, "uff0");
+    plotter.AddLine(t_array, u1_unsaturated_array, "u1_full");
+    plotter.AddLine(t_array, u1_array, "u1");
+    plotter.AddLine(t_array, uff1_array, "uff1");
+    plotter.AddLine(t_array, u2_unsaturated_array, "u2_full");
+    plotter.AddLine(t_array, u2_array, "u2");
+    plotter.AddLine(t_array, uff2_array, "uff2");
+    plotter.AddLine(t_array, torque0_hat_t_array, "torque0_hat");
+    plotter.AddLine(t_array, torque1_hat_t_array, "torque1_hat");
+    plotter.AddLine(t_array, torque2_hat_t_array, "torque2_hat");
+    plotter.Publish();
+
+    if (FLAGS_plot_thetas) {
+      plotter.AddFigure();
+      plotter.Title("Angles");
+      plotter.AddLine(t_array, theta0_goal_t_array, "theta0_t_goal");
+      plotter.AddLine(t_array, theta0_t_array, "theta0_t");
+      plotter.AddLine(t_array, theta0_hat_t_array, "theta0_hat_t");
+      plotter.AddLine(t_array, theta1_goal_t_array, "theta1_t_goal");
+      plotter.AddLine(t_array, theta1_t_array, "theta1_t");
+      plotter.AddLine(t_array, theta1_hat_t_array, "theta1_hat_t");
+      plotter.AddLine(t_array, theta2_goal_t_array, "theta2_t_goal");
+      plotter.AddLine(t_array, theta2_t_array, "theta2_t");
+      plotter.AddLine(t_array, theta2_hat_t_array, "theta2_hat_t");
+      plotter.Publish();
+    }
+
+    plotter.AddFigure();
+    plotter.Title("ff for distance");
+    plotter.AddLine(distance_array, Uff0_distance_array_forwards_voltage_only,
+                    "ff0");
+    plotter.AddLine(distance_array, Uff1_distance_array_forwards_voltage_only,
+                    "ff1");
+    plotter.AddLine(distance_array, Uff2_distance_array_forwards_voltage_only,
+                    "ff2");
+
+    plotter.AddLine(distance_array, Uff0_distance_array_backwards_voltage_only,
+                    "ff0_back voltage");
+    plotter.AddLine(distance_array, Uff1_distance_array_backwards_voltage_only,
+                    "ff1_back voltage");
+    plotter.AddLine(distance_array, Uff2_distance_array_backwards_voltage_only,
+                    "ff2_back voltage");
+
+    plotter.AddLine(distance_array, Uff0_distance_array_forwards_accel_only,
+                    "ff0_forward accel");
+    plotter.AddLine(distance_array, Uff1_distance_array_forwards_accel_only,
+                    "ff1_forward accel");
+    plotter.AddLine(distance_array, Uff2_distance_array_forwards_accel_only,
+                    "ff2_forward accel");
+
+    plotter.AddLine(distance_array, Uff0_distance_array_backwards_accel_only,
+                    "ff0_back accel");
+    plotter.AddLine(distance_array, Uff1_distance_array_backwards_accel_only,
+                    "ff1_back accel");
+    plotter.AddLine(distance_array, Uff2_distance_array_backwards_accel_only,
+                    "ff2_back accel");
+
+    plotter.AddLine(distance_array, Uff0_distance_array_curvature, "ff0_curve");
+    plotter.AddLine(distance_array, Uff1_distance_array_curvature, "ff1_curve");
+    plotter.AddLine(distance_array, Uff2_distance_array_curvature, "ff2_curve");
+
+    plotter.Publish();
+    plotter.Spin();
+  }
+}
+
+}  // namespace arm
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2023
+
+int main(int argc, char **argv) {
+  ::aos::InitGoogle(&argc, &argv);
+  ::y2023::control_loops::superstructure::arm::Main();
+  return 0;
+}
diff --git a/y2023/control_loops/superstructure/arm/trajectory_test.cc b/y2023/control_loops/superstructure/arm/trajectory_test.cc
new file mode 100644
index 0000000..bcf338c
--- /dev/null
+++ b/y2023/control_loops/superstructure/arm/trajectory_test.cc
@@ -0,0 +1,232 @@
+#include "y2023/control_loops/superstructure/arm/trajectory.h"
+
+#include "frc971/control_loops/double_jointed_arm/dynamics.h"
+#include "frc971/control_loops/double_jointed_arm/ekf.h"
+#include "gtest/gtest.h"
+#include "y2023/control_loops/superstructure/arm/arm_constants.h"
+#include "y2023/control_loops/superstructure/roll/integral_hybrid_roll_plant.h"
+#include "y2023/control_loops/superstructure/roll/integral_roll_plant.h"
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+namespace arm {
+namespace testing {
+
+using frc971::control_loops::MatrixGaussianQuadrature5;
+using frc971::control_loops::arm::Dynamics;
+using frc971::control_loops::arm::EKF;
+
+// Tests that we can pull out values along the path.
+TEST(TrajectoryTest, Theta) {
+  Eigen::Matrix<double, 2, 4> spline_params;
+  spline_params << 0.3, 0.4, 0.6, 0.5, -1.7, -1.6, -1.4, -1.2;
+
+  NSpline<4, 2> spline(spline_params);
+  CosSpline cos_spline(spline,
+                       {{0.0, 0.1}, {0.3, 0.1}, {0.7, 0.2}, {1.0, 0.2}});
+
+  EXPECT_TRUE(cos_spline.Theta(-1.0).isApprox(
+      (Eigen::Matrix<double, 3, 1>() << 0.3, -1.7, 0.1).finished()))
+      << cos_spline.Theta(-1.0).transpose();
+
+  EXPECT_TRUE(cos_spline.Theta(0.0).isApprox(
+      (Eigen::Matrix<double, 3, 1>() << 0.3, -1.7, 0.1).finished()))
+      << cos_spline.Theta(0.0).transpose();
+
+  EXPECT_TRUE(cos_spline.Theta(0.3).isApprox(
+      (Eigen::Matrix<double, 3, 1>() << 0.4062, -1.5857, 0.1).finished()))
+      << cos_spline.Theta(0.3).transpose();
+
+  EXPECT_TRUE(cos_spline.Theta(0.5).isApprox(
+      (Eigen::Matrix<double, 3, 1>() << 0.475, -1.4875, 0.15).finished()))
+      << cos_spline.Theta(0.5).transpose();
+
+  EXPECT_TRUE(cos_spline.Theta(0.7).isApprox(
+      (Eigen::Matrix<double, 3, 1>() << 0.5198, -1.3773, 0.2).finished()))
+      << cos_spline.Theta(0.7).transpose();
+
+  EXPECT_TRUE(cos_spline.Theta(1.0).isApprox(
+      (Eigen::Matrix<double, 3, 1>() << 0.5, -1.2, 0.2).finished()))
+      << cos_spline.Theta(1.0).transpose();
+
+  EXPECT_TRUE(cos_spline.Theta(2.0).isApprox(
+      (Eigen::Matrix<double, 3, 1>() << 0.5, -1.2, 0.2).finished()))
+      << cos_spline.Theta(2.0).transpose();
+}
+
+// Tests that the integral of alpha and omega matches the functions they were
+// differentiated from on Path.
+TEST(TrajectoryTest, IntegrateAccel) {
+  Eigen::Matrix<double, 2, 4> spline_params;
+  spline_params << 0.3, 0.4, 0.6, 0.5, -1.7, -1.6, -1.4, -1.2;
+  NSpline<4, 2> spline(spline_params);
+  CosSpline cos_spline(spline,
+                       {{0.0, 0.1}, {0.3, 0.1}, {0.7, 0.2}, {1.0, 0.2}});
+  Path distance_spline(cos_spline, 100);
+
+  Eigen::Matrix<double, 3, 1> integrated_theta = distance_spline.Theta(0.0);
+  Eigen::Matrix<double, 3, 1> integrated_omega = distance_spline.Omega(0.0);
+
+  constexpr size_t kSlices = 1000;
+  for (size_t i = 0; i < kSlices; ++i) {
+    const double d = i * distance_spline.length() / kSlices;
+    const double next_d = (i + 1) * distance_spline.length() / kSlices;
+
+    integrated_theta += MatrixGaussianQuadrature5<3>(
+        [&](double distance) { return distance_spline.Omega(distance); }, d,
+        next_d);
+    integrated_omega += MatrixGaussianQuadrature5<3>(
+        [&](double distance) { return distance_spline.Alpha(distance); }, d,
+        next_d);
+
+    EXPECT_TRUE(integrated_theta.isApprox(distance_spline.Theta(next_d), 1e-3))
+        << ": Got " << integrated_theta.transpose() << ", expected "
+        << distance_spline.Theta(next_d).transpose();
+    EXPECT_TRUE(integrated_omega.isApprox(distance_spline.Omega(next_d), 1e-3))
+        << ": Got " << integrated_omega.transpose() << ", expected "
+        << distance_spline.Omega(next_d).transpose();
+  }
+}
+
+// Tests that we can correctly interpolate velocities between two points
+TEST(TrajectoryTest, InterpolateVelocity) {
+  // x = 0.5 * a * t^2
+  // v = a * t
+  // a = 2.0
+  EXPECT_EQ(0.0, Trajectory::InterpolateVelocity(0.0, 0.0, 1.0, 0.0, 2.0));
+  EXPECT_EQ(2.0, Trajectory::InterpolateVelocity(1.0, 0.0, 1.0, 0.0, 2.0));
+  EXPECT_EQ(0.0, Trajectory::InterpolateVelocity(-1.0, 0.0, 1.0, 0.0, 2.0));
+  EXPECT_EQ(2.0, Trajectory::InterpolateVelocity(20.0, 0.0, 1.0, 0.0, 2.0));
+}
+
+// Tests that we can correctly interpolate velocities between two points
+TEST(TrajectoryTest, InterpolateAcceleration) {
+  // x = 0.5 * a * t^2
+  // v = a * t
+  // a = 2.0
+  EXPECT_EQ(2.0, Trajectory::InterpolateAcceleration(0.0, 1.0, 0.0, 2.0));
+}
+
+std::unique_ptr<Path> MakeDemoPath() {
+  Eigen::Matrix<double, 2, 4> spline_params;
+  spline_params << 0.3, 0.4, 0.6, 0.5, -1.7, -1.6, -1.4, -1.2;
+  NSpline<4, 2> spline(spline_params);
+  CosSpline cos_spline(spline,
+                       {{0.0, 0.1}, {0.3, 0.1}, {0.7, 0.2}, {1.0, 0.2}});
+  return std::make_unique<Path>(cos_spline, 100);
+}
+
+// Tests that we can correctly interpolate velocities between two points
+TEST(TrajectoryTest, ReversedPath) {
+  // Tests that a reversed path is actually reversed.
+  ::std::unique_ptr<Path> path = MakeDemoPath();
+  ::std::unique_ptr<Path> reversed_path = Path::Reversed(MakeDemoPath());
+
+  EXPECT_NEAR(path->length(), reversed_path->length(), 1e-6);
+
+  for (double d = 0; d < path->length(); d += 0.01) {
+    EXPECT_LT(
+        (path->Theta(d) - reversed_path->Theta(path->length() - d)).norm(),
+        1e-5);
+    EXPECT_LT(
+        (path->Omega(d) + reversed_path->Omega(path->length() - d)).norm(),
+        1e-5);
+    EXPECT_LT(
+        (path->Alpha(d) - reversed_path->Alpha(path->length() - d)).norm(),
+        1e-5);
+  }
+}
+
+// Tests that we can follow a path.  Look at :trajectory_plot if you want to see
+// the path.
+TEST(TrajectoryTest, RunTrajectory) {
+  Dynamics dynamics(kArmConstants);
+  StateFeedbackLoop<3, 1, 1, double, StateFeedbackHybridPlant<3, 1, 1>,
+                    HybridKalman<3, 1, 1>>
+      hybrid_roll = y2023::control_loops::superstructure::roll::
+          MakeIntegralHybridRollLoop();
+  ::std::unique_ptr<Path> path = MakeDemoPath();
+  Trajectory trajectory(&dynamics, &hybrid_roll.plant(), ::std::move(path),
+                        0.001);
+
+  constexpr double kAlpha0Max = 40.0;
+  constexpr double kAlpha1Max = 60.0;
+  constexpr double kAlpha2Max = 60.0;
+  constexpr double vmax = 11.95;
+
+  const ::Eigen::DiagonalMatrix<double, 3> alpha_unitizer(
+      (::Eigen::DiagonalMatrix<double, 3>().diagonal() << (1.0 / kAlpha0Max),
+       (1.0 / kAlpha1Max), (1.0 / kAlpha2Max))
+          .finished());
+  trajectory.OptimizeTrajectory(alpha_unitizer, vmax);
+
+  double t = 0;
+  ::Eigen::Matrix<double, 4, 1> arm_X;
+  ::Eigen::Matrix<double, 2, 1> roll_X;
+  {
+    ::Eigen::Matrix<double, 3, 1> theta_t = trajectory.ThetaT(0.0);
+    arm_X << theta_t(0), 0.0, theta_t(1), 0.0;
+    roll_X << theta_t(2), 0.0;
+  }
+
+  EKF arm_ekf(&dynamics);
+  arm_ekf.Reset(arm_X);
+  StateFeedbackLoop<3, 1, 1, double, StateFeedbackPlant<3, 1, 1>,
+                    StateFeedbackObserver<3, 1, 1>>
+      roll = y2023::control_loops::superstructure::roll::MakeIntegralRollLoop();
+  roll.mutable_X_hat().setZero();
+  roll.mutable_X_hat().block<2, 1>(0, 0) = roll_X;
+
+  TrajectoryFollower follower(&dynamics, &hybrid_roll, &trajectory);
+  constexpr double sim_dt = 0.00505;
+  while (t < 1.0) {
+    arm_ekf.Correct(
+        (::Eigen::Matrix<double, 2, 1>() << arm_X(0), arm_X(2)).finished(),
+        sim_dt);
+    roll.Correct((::Eigen::Matrix<double, 1, 1>() << roll_X(0)).finished());
+    follower.Update(
+        (Eigen::Matrix<double, 9, 1>() << arm_ekf.X_hat(), roll.X_hat())
+            .finished(),
+        false, sim_dt, vmax, 12.0);
+
+    arm_X = dynamics.UnboundedDiscreteDynamics(
+        arm_X, follower.U().block<2, 1>(0, 0), sim_dt);
+    arm_ekf.Predict(follower.U().block<2, 1>(0, 0), sim_dt);
+
+    roll_X =
+        roll.plant()
+            .Update((Eigen::Matrix<double, 3, 1>() << roll_X, 0.0).finished(),
+                    follower.U().block<1, 1>(2, 0))
+            .block<2, 1>(0, 0);
+    roll.UpdateObserver(follower.U().block<1, 1>(2, 0),
+                        std::chrono::duration_cast<std::chrono::nanoseconds>(
+                            std::chrono::duration<double>(sim_dt)));
+    t += sim_dt;
+  }
+
+  ::Eigen::Matrix<double, 4, 1> final_arm_X;
+  ::Eigen::Matrix<double, 2, 1> final_roll_X;
+  ::Eigen::Matrix<double, 3, 1> final_theta_t =
+      trajectory.ThetaT(trajectory.path().length());
+  final_arm_X << final_theta_t(0), 0.0, final_theta_t(1), 0.0;
+  final_roll_X << final_theta_t(2), 0.0;
+
+  // Verify that we got to the end.
+  EXPECT_TRUE(arm_X.isApprox(final_arm_X, 0.01))
+      << ": X is " << arm_X.transpose() << " final_X is "
+      << final_arm_X.transpose();
+  EXPECT_TRUE(roll_X.isApprox(final_roll_X, 0.01))
+      << ": X is " << roll_X.transpose() << " final_X is "
+      << final_roll_X.transpose();
+
+  // Verify that our goal is at the end.
+  EXPECT_TRUE(
+      final_theta_t.isApprox(trajectory.path().Theta(follower.goal(0))));
+}
+
+}  // namespace testing
+}  // namespace arm
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2023
diff --git a/y2023/control_loops/superstructure/roll/BUILD b/y2023/control_loops/superstructure/roll/BUILD
new file mode 100644
index 0000000..f1cca65
--- /dev/null
+++ b/y2023/control_loops/superstructure/roll/BUILD
@@ -0,0 +1,53 @@
+package(default_visibility = ["//y2023:__subpackages__"])
+
+genrule(
+    name = "genrule_roll",
+    outs = [
+        "roll_plant.h",
+        "roll_plant.cc",
+        "integral_roll_plant.h",
+        "integral_roll_plant.cc",
+    ],
+    cmd = "$(location //y2023/control_loops/python:roll) $(OUTS)",
+    target_compatible_with = ["@platforms//os:linux"],
+    tools = [
+        "//y2023/control_loops/python:roll",
+    ],
+)
+
+genrule(
+    name = "genrule_hybrid_roll",
+    outs = [
+        "hybrid_roll_plant.h",
+        "hybrid_roll_plant.cc",
+        "integral_hybrid_roll_plant.h",
+        "integral_hybrid_roll_plant.cc",
+    ],
+    cmd = "$(location //y2023/control_loops/python:roll) --hybrid $(OUTS)",
+    target_compatible_with = ["@platforms//os:linux"],
+    tools = [
+        "//y2023/control_loops/python:roll",
+    ],
+)
+
+cc_library(
+    name = "roll_plants",
+    srcs = [
+        "hybrid_roll_plant.cc",
+        "integral_hybrid_roll_plant.cc",
+        "integral_roll_plant.cc",
+        "roll_plant.cc",
+    ],
+    hdrs = [
+        "hybrid_roll_plant.h",
+        "integral_hybrid_roll_plant.h",
+        "integral_roll_plant.h",
+        "roll_plant.h",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//frc971/control_loops:hybrid_state_feedback_loop",
+        "//frc971/control_loops:state_feedback_loop",
+    ],
+)
diff --git a/y2023/control_loops/superstructure/superstructure.cc b/y2023/control_loops/superstructure/superstructure.cc
index 1125e21..1ad2625 100644
--- a/y2023/control_loops/superstructure/superstructure.cc
+++ b/y2023/control_loops/superstructure/superstructure.cc
@@ -12,6 +12,8 @@
 namespace control_loops {
 namespace superstructure {
 
+using ::aos::monotonic_clock;
+
 using frc971::control_loops::AbsoluteEncoderProfiledJointStatus;
 using frc971::control_loops::PotAndAbsoluteEncoderProfiledJointStatus;
 using frc971::control_loops::RelativeEncoderProfiledJointStatus;
@@ -26,19 +28,19 @@
           event_loop->MakeFetcher<frc971::control_loops::drivetrain::Status>(
               "/drivetrain")),
       joystick_state_fetcher_(
-          event_loop->MakeFetcher<aos::JoystickState>("/aos")) {
-  (void)values;
-}
+          event_loop->MakeFetcher<aos::JoystickState>("/aos")),
+      arm_(values_) {}
 
 void Superstructure::RunIteration(const Goal *unsafe_goal,
                                   const Position *position,
                                   aos::Sender<Output>::Builder *output,
                                   aos::Sender<Status>::Builder *status) {
-  (void)unsafe_goal;
-  (void)position;
+  const monotonic_clock::time_point timestamp =
+      event_loop()->context().monotonic_event_time;
 
   if (WasReset()) {
     AOS_LOG(ERROR, "WPILib reset, restarting\n");
+    arm_.Reset();
   }
 
   OutputT output_struct;
@@ -47,11 +49,32 @@
     alliance_ = joystick_state_fetcher_->alliance();
   }
   drivetrain_status_fetcher_.Fetch();
-  output->CheckOk(output->Send(Output::Pack(*output->fbb(), &output_struct)));
-  
+
+  const uint32_t arm_goal_position =
+      unsafe_goal != nullptr ? unsafe_goal->arm_goal_position() : 0u;
+
+  flatbuffers::Offset<superstructure::ArmStatus> arm_status_offset =
+      arm_.Iterate(
+          timestamp, unsafe_goal != nullptr ? &(arm_goal_position) : nullptr,
+          position->arm(),
+          unsafe_goal != nullptr ? unsafe_goal->trajectory_override() : false,
+          output != nullptr ? &output_struct.proximal_voltage : nullptr,
+          output != nullptr ? &output_struct.distal_voltage : nullptr,
+          output != nullptr ? &output_struct.roll_joint_voltage : nullptr,
+          unsafe_goal != nullptr ? unsafe_goal->intake() : false,
+          unsafe_goal != nullptr ? unsafe_goal->spit() : false,
+
+          status->fbb());
+
+  if (output) {
+    output->CheckOk(output->Send(Output::Pack(*output->fbb(), &output_struct)));
+  }
+
   Status::Builder status_builder = status->MakeBuilder<Status>();
   status_builder.add_zeroed(true);
   status_builder.add_estopped(false);
+  status_builder.add_arm(arm_status_offset);
+
   (void)status->Send(status_builder.Finish());
 }
 
diff --git a/y2023/control_loops/superstructure/superstructure.h b/y2023/control_loops/superstructure/superstructure.h
index 0740de3..2d0fc43 100644
--- a/y2023/control_loops/superstructure/superstructure.h
+++ b/y2023/control_loops/superstructure/superstructure.h
@@ -5,6 +5,7 @@
 #include "frc971/control_loops/control_loop.h"
 #include "frc971/control_loops/drivetrain/drivetrain_status_generated.h"
 #include "y2023/constants.h"
+#include "y2023/control_loops/superstructure/arm/arm.h"
 #include "y2023/control_loops/superstructure/superstructure_goal_generated.h"
 #include "y2023/control_loops/superstructure/superstructure_output_generated.h"
 #include "y2023/control_loops/superstructure/superstructure_position_generated.h"
@@ -45,6 +46,8 @@
       drivetrain_status_fetcher_;
   aos::Fetcher<aos::JoystickState> joystick_state_fetcher_;
 
+  arm::Arm arm_;
+
   aos::Alliance alliance_ = aos::Alliance::kInvalid;
 
   DISALLOW_COPY_AND_ASSIGN(Superstructure);
diff --git a/y2023/control_loops/superstructure/superstructure_goal.fbs b/y2023/control_loops/superstructure/superstructure_goal.fbs
index 47ce7b2..936cbee 100644
--- a/y2023/control_loops/superstructure/superstructure_goal.fbs
+++ b/y2023/control_loops/superstructure/superstructure_goal.fbs
@@ -8,13 +8,16 @@
     // Controls distal, proximal, and roll joints
     arm_goal_position:uint32 (id: 0);
 
-    wrist:frc971.control_loops.StaticZeroingSingleDOFProfiledSubsystemGoal (id: 1);
+    // Overrides the current path to go to the next path
+    trajectory_override:bool (id: 1);
+
+    wrist:frc971.control_loops.StaticZeroingSingleDOFProfiledSubsystemGoal (id: 2);
 
     // If this is true, the rollers should intake.
-    intake:bool (id: 2);
+    intake:bool (id: 3);
 
     // If this is true, the rollers should spit.
-    spit:bool (id: 3);
+    spit:bool (id: 4);
 }
 
 
diff --git a/y2023/control_loops/superstructure/superstructure_lib_test.cc b/y2023/control_loops/superstructure/superstructure_lib_test.cc
index 0b9fb7d..f3f0471 100644
--- a/y2023/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2023/control_loops/superstructure/superstructure_lib_test.cc
@@ -9,6 +9,7 @@
 #include "frc971/control_loops/team_number_test_environment.h"
 #include "gtest/gtest.h"
 #include "y2023/control_loops/drivetrain/drivetrain_dog_motor_plant.h"
+#include "y2023/control_loops/superstructure/roll/integral_roll_plant.h"
 #include "y2023/control_loops/superstructure/superstructure.h"
 
 DEFINE_string(output_folder, "",
@@ -18,6 +19,9 @@
 namespace control_loops {
 namespace superstructure {
 namespace testing {
+namespace {
+constexpr double kNoiseScalar = 0.01;
+}  // namespace
 namespace chrono = std::chrono;
 
 using ::aos::monotonic_clock;
@@ -40,6 +44,120 @@
     frc971::control_loops::RelativeEncoderProfiledJointStatus,
     RelativeEncoderSubsystem::State, constants::Values::PotConstants>;
 
+class ArmSimulation {
+ public:
+  explicit ArmSimulation(
+      const ::frc971::constants::PotAndAbsoluteEncoderZeroingConstants
+          &proximal_zeroing_constants,
+      const ::frc971::constants::PotAndAbsoluteEncoderZeroingConstants
+          &distal_zeroing_constants,
+      const ::frc971::constants::PotAndAbsoluteEncoderZeroingConstants
+          &roll_joint_zeroing_constants,
+      std::chrono::nanoseconds dt)
+      : proximal_zeroing_constants_(proximal_zeroing_constants),
+        proximal_pot_encoder_(M_PI * 2.0 *
+                              constants::Values::kProximalEncoderRatio()),
+        distal_zeroing_constants_(distal_zeroing_constants),
+        distal_pot_encoder_(M_PI * 2.0 *
+                            constants::Values::kDistalEncoderRatio()),
+        roll_joint_zeroing_constants_(roll_joint_zeroing_constants),
+        roll_joint_pot_encoder_(M_PI * 2.0 *
+                                constants::Values::kDistalEncoderRatio()),
+        roll_joint_loop_(roll::MakeIntegralRollLoop()),
+        dynamics_(arm::kArmConstants),
+        dt_(dt) {
+    X_.setZero();
+    roll_joint_loop_.Reset();
+  }
+
+  void InitializePosition(::Eigen::Matrix<double, 3, 1> position) {
+    X_ << position(0), 0.0, position(1), 0.0;
+
+    proximal_pot_encoder_.Initialize(
+        X_(0), kNoiseScalar, 0.0,
+        proximal_zeroing_constants_.measured_absolute_position);
+    distal_pot_encoder_.Initialize(
+        X_(2), kNoiseScalar, 0.0,
+        distal_zeroing_constants_.measured_absolute_position);
+
+    Eigen::Matrix<double, 3, 1> X_roll_joint;
+    X_roll_joint << position(2), 0.0, 0.0;
+    roll_joint_loop_.mutable_X_hat() = X_roll_joint;
+    roll_joint_pot_encoder_.Initialize(
+        X_roll_joint(0), kNoiseScalar, 0.0,
+        roll_joint_zeroing_constants_.measured_absolute_position);
+  }
+
+  flatbuffers::Offset<ArmPosition> GetSensorValues(
+      flatbuffers::FlatBufferBuilder *fbb) {
+    frc971::PotAndAbsolutePosition::Builder proximal_builder(*fbb);
+    flatbuffers::Offset<frc971::PotAndAbsolutePosition> proximal_offset =
+        proximal_pot_encoder_.GetSensorValues(&proximal_builder);
+
+    frc971::PotAndAbsolutePosition::Builder distal_builder(*fbb);
+    flatbuffers::Offset<frc971::PotAndAbsolutePosition> distal_offset =
+        distal_pot_encoder_.GetSensorValues(&distal_builder);
+
+    frc971::PotAndAbsolutePosition::Builder roll_joint_builder(*fbb);
+    flatbuffers::Offset<frc971::PotAndAbsolutePosition> roll_joint_offset =
+        roll_joint_pot_encoder_.GetSensorValues(&roll_joint_builder);
+
+    ArmPosition::Builder arm_position_builder(*fbb);
+    arm_position_builder.add_proximal(proximal_offset);
+    arm_position_builder.add_distal(distal_offset);
+    arm_position_builder.add_roll_joint(roll_joint_offset);
+
+    return arm_position_builder.Finish();
+  }
+
+  double proximal_position() const { return X_(0, 0); }
+  double proximal_velocity() const { return X_(1, 0); }
+  double distal_position() const { return X_(2, 0); }
+  double distal_velocity() const { return X_(3, 0); }
+  double roll_joint_position() const { return roll_joint_loop_.X_hat(0, 0); }
+  double roll_joint_velocity() const { return roll_joint_loop_.X_hat(1, 0); }
+
+  void Simulate(::Eigen::Matrix<double, 3, 1> U) {
+    constexpr double voltage_check =
+        superstructure::arm::Arm::kOperatingVoltage();
+
+    AOS_CHECK_LE(::std::abs(U(0)), voltage_check);
+    AOS_CHECK_LE(::std::abs(U(1)), voltage_check);
+    AOS_CHECK_LE(::std::abs(U(2)), voltage_check);
+
+    X_ = dynamics_.UnboundedDiscreteDynamics(
+        X_, U.head<2>(),
+        std::chrono::duration_cast<std::chrono::duration<double>>(dt_).count());
+    roll_joint_loop_.UpdateObserver(U.tail<1>(), dt_);
+
+    // TODO(austin): Estop on grose out of bounds.
+    proximal_pot_encoder_.MoveTo(X_(0));
+    distal_pot_encoder_.MoveTo(X_(2));
+    roll_joint_pot_encoder_.MoveTo(roll_joint_loop_.X_hat(0));
+  }
+
+ private:
+  ::Eigen::Matrix<double, 4, 1> X_;
+
+  const ::frc971::constants::PotAndAbsoluteEncoderZeroingConstants
+      proximal_zeroing_constants_;
+  PositionSensorSimulator proximal_pot_encoder_;
+
+  const ::frc971::constants::PotAndAbsoluteEncoderZeroingConstants
+      distal_zeroing_constants_;
+  PositionSensorSimulator distal_pot_encoder_;
+
+  const ::frc971::constants::PotAndAbsoluteEncoderZeroingConstants
+      roll_joint_zeroing_constants_;
+  PositionSensorSimulator roll_joint_pot_encoder_;
+  StateFeedbackLoop<3, 1, 1, double, StateFeedbackPlant<3, 1, 1>,
+                    StateFeedbackObserver<3, 1, 1>>
+      roll_joint_loop_;
+
+  ::frc971::control_loops::arm::Dynamics dynamics_;
+
+  std::chrono::nanoseconds dt_;
+};
 // Class which simulates the superstructure and sends out queue messages with
 // the position.
 class SuperstructureSimulation {
@@ -49,21 +167,28 @@
                            chrono::nanoseconds dt)
       : event_loop_(event_loop),
         dt_(dt),
+        arm_(values->arm_proximal.zeroing, values->arm_distal.zeroing,
+             values->roll_joint.zeroing, dt_),
         superstructure_position_sender_(
             event_loop_->MakeSender<Position>("/superstructure")),
         superstructure_status_fetcher_(
             event_loop_->MakeFetcher<Status>("/superstructure")),
         superstructure_output_fetcher_(
             event_loop_->MakeFetcher<Output>("/superstructure")) {
-    (void)values;
-    (void)dt_;
-
+    InitializeArmPosition(arm::NeutralPosPoint());
     phased_loop_handle_ = event_loop_->AddPhasedLoop(
         [this](int) {
           // Skip this the first time.
           if (!first_) {
             EXPECT_TRUE(superstructure_output_fetcher_.Fetch());
             EXPECT_TRUE(superstructure_status_fetcher_.Fetch());
+
+            arm_.Simulate(
+                (::Eigen::Matrix<double, 3, 1>()
+                     << superstructure_output_fetcher_->proximal_voltage(),
+                 superstructure_output_fetcher_->distal_voltage(),
+                 superstructure_output_fetcher_->roll_joint_voltage())
+                    .finished());
           }
           first_ = false;
           SendPositionMessage();
@@ -71,13 +196,22 @@
         dt);
   }
 
+  void InitializeArmPosition(::Eigen::Matrix<double, 3, 1> position) {
+    arm_.InitializePosition(position);
+  }
+
+  const ArmSimulation &arm() const { return arm_; }
+
   // Sends a queue message with the position of the superstructure.
   void SendPositionMessage() {
     ::aos::Sender<Position>::Builder builder =
         superstructure_position_sender_.MakeBuilder();
 
-    Position::Builder position_builder = builder.MakeBuilder<Position>();
+    flatbuffers::Offset<ArmPosition> arm_offset =
+        arm_.GetSensorValues(builder.fbb());
 
+    Position::Builder position_builder = builder.MakeBuilder<Position>();
+    position_builder.add_arm(arm_offset);
     CHECK_EQ(builder.Send(position_builder.Finish()),
              aos::RawSender::Error::kOk);
   }
@@ -87,6 +221,8 @@
   const chrono::nanoseconds dt_;
   ::aos::PhasedLoopHandler *phased_loop_handle_ = nullptr;
 
+  ArmSimulation arm_;
+
   ::aos::Sender<Position> superstructure_position_sender_;
   ::aos::Fetcher<Status> superstructure_status_fetcher_;
   ::aos::Fetcher<Output> superstructure_output_fetcher_;
@@ -122,8 +258,8 @@
             test_event_loop_->MakeSender<DrivetrainStatus>("/drivetrain")),
         superstructure_plant_event_loop_(MakeEventLoop("plant", roborio_)),
         superstructure_plant_(superstructure_plant_event_loop_.get(), values_,
-                              dt()) {
-    (void)values_;
+                              dt()),
+        points_(arm::PointList()) {
     set_team_id(frc971::control_loops::testing::kTeamNumber);
 
     SetEnabled(true);
@@ -139,6 +275,52 @@
   void VerifyNearGoal() {
     superstructure_goal_fetcher_.Fetch();
     superstructure_status_fetcher_.Fetch();
+
+    ASSERT_TRUE(superstructure_goal_fetcher_.get() != nullptr) << ": No goal";
+    ASSERT_TRUE(superstructure_status_fetcher_.get() != nullptr)
+        << ": No status";
+
+    constexpr double kEpsTheta = 0.01;
+    constexpr double kEpsOmega = 0.01;
+
+    // Check that the status had the right goal
+    ASSERT_NEAR(points_[superstructure_goal_fetcher_->arm_goal_position()](0),
+                superstructure_status_fetcher_->arm()->goal_theta0(),
+                kEpsTheta);
+    ASSERT_NEAR(points_[superstructure_goal_fetcher_->arm_goal_position()](1),
+                superstructure_status_fetcher_->arm()->goal_theta1(),
+                kEpsTheta);
+    ASSERT_NEAR(points_[superstructure_goal_fetcher_->arm_goal_position()](2),
+                superstructure_status_fetcher_->arm()->goal_theta2(),
+                kEpsTheta);
+
+    // Check that the status met the goal
+    EXPECT_NEAR(superstructure_status_fetcher_->arm()->goal_theta0(),
+                superstructure_status_fetcher_->arm()->theta0(), kEpsTheta);
+    EXPECT_NEAR(superstructure_status_fetcher_->arm()->goal_theta1(),
+                superstructure_status_fetcher_->arm()->theta1(), kEpsTheta);
+    EXPECT_NEAR(superstructure_status_fetcher_->arm()->goal_theta2(),
+                superstructure_status_fetcher_->arm()->theta2(), kEpsTheta);
+    EXPECT_NEAR(superstructure_status_fetcher_->arm()->goal_omega0(),
+                superstructure_status_fetcher_->arm()->omega0(), kEpsOmega);
+    EXPECT_NEAR(superstructure_status_fetcher_->arm()->goal_omega1(),
+                superstructure_status_fetcher_->arm()->omega1(), kEpsOmega);
+    EXPECT_NEAR(superstructure_status_fetcher_->arm()->goal_omega2(),
+                superstructure_status_fetcher_->arm()->omega2(), kEpsOmega);
+
+    // Check that our simulator matches the status
+    EXPECT_NEAR(superstructure_plant_.arm().proximal_position(),
+                superstructure_status_fetcher_->arm()->theta0(), kEpsTheta);
+    EXPECT_NEAR(superstructure_plant_.arm().distal_position(),
+                superstructure_status_fetcher_->arm()->theta1(), kEpsTheta);
+    EXPECT_NEAR(superstructure_plant_.arm().roll_joint_position(),
+                superstructure_status_fetcher_->arm()->theta2(), kEpsTheta);
+    EXPECT_NEAR(superstructure_plant_.arm().proximal_velocity(),
+                superstructure_status_fetcher_->arm()->omega0(), kEpsOmega);
+    EXPECT_NEAR(superstructure_plant_.arm().distal_velocity(),
+                superstructure_status_fetcher_->arm()->omega1(), kEpsOmega);
+    EXPECT_NEAR(superstructure_plant_.arm().roll_joint_velocity(),
+                superstructure_status_fetcher_->arm()->omega2(), kEpsOmega);
   }
 
   void CheckIfZeroed() {
@@ -202,6 +384,8 @@
 
   std::unique_ptr<aos::EventLoop> logger_event_loop_;
   std::unique_ptr<aos::logger::Logger> logger_;
+
+  const ::std::vector<::Eigen::Matrix<double, 3, 1>> points_;
 };  // namespace testing
 
 // Tests that the superstructure does nothing when the goal is to remain
@@ -233,6 +417,8 @@
     auto builder = superstructure_goal_sender_.MakeBuilder();
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
+    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
 
@@ -270,7 +456,6 @@
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
 
-  // Intake needs over 9 seconds to reach the goal
   // TODO(Milo): Make this a sane time
   RunFor(chrono::seconds(20));
   VerifyNearGoal();
@@ -289,6 +474,75 @@
   CheckIfZeroed();
 }
 
+// Tests that we don't freak out without a goal.
+TEST_F(SuperstructureTest, ArmSimpleGoal) {
+  SetEnabled(true);
+  RunFor(chrono::seconds(20));
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_arm_goal_position(arm::PickupPosIndex());
+    ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
+  }
+
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+  EXPECT_EQ(ArmState::RUNNING, superstructure_status_fetcher_->arm()->state());
+}
+
+// Tests that we can can execute a move.
+TEST_F(SuperstructureTest, ArmMoveAndMoveBack) {
+  SetEnabled(true);
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
+  }
+
+  RunFor(chrono::seconds(10));
+
+  VerifyNearGoal();
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_arm_goal_position(arm::PickupPosIndex());
+    ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
+  }
+
+  RunFor(chrono::seconds(10));
+  VerifyNearGoal();
+}
+
+// Tests that we can can execute a move which moves through multiple nodes.
+TEST_F(SuperstructureTest, ArmMultistepMove) {
+  SetEnabled(true);
+  superstructure_plant_.InitializeArmPosition(arm::NeutralPosPoint());
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_arm_goal_position(arm::PickupPosIndex());
+    ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
+  }
+
+  RunFor(chrono::seconds(10));
+
+  VerifyNearGoal();
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_arm_goal_position(arm::ScorePosIndex());
+    ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
+  }
+
+  RunFor(chrono::seconds(10));
+  VerifyNearGoal();
+}
+
 }  // namespace testing
 }  // namespace superstructure
 }  // namespace control_loops
diff --git a/y2023/control_loops/superstructure/superstructure_plotter.ts b/y2023/control_loops/superstructure/superstructure_plotter.ts
index ae20119..2e32445 100644
--- a/y2023/control_loops/superstructure/superstructure_plotter.ts
+++ b/y2023/control_loops/superstructure/superstructure_plotter.ts
@@ -1,7 +1,7 @@
 // Provides a plot for debugging robot state-related issues.
-import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
-import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
+import {AosPlotter} from '../../../aos/network/www/aos_plotter';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from '../../../aos/network/www/colors';
+import * as proxy from '../../../aos/network/www/proxy';
 
 import Connection = proxy.Connection;
 
diff --git a/y2023/control_loops/superstructure/superstructure_position.fbs b/y2023/control_loops/superstructure/superstructure_position.fbs
index dc3679d..bd3d607 100644
--- a/y2023/control_loops/superstructure/superstructure_position.fbs
+++ b/y2023/control_loops/superstructure/superstructure_position.fbs
@@ -2,22 +2,28 @@
 
 namespace y2023.control_loops.superstructure;
 
+table ArmPosition {
+  // Values of the encoder and potentiometer at the base of the proximal
+  // (connected to drivebase) arm in radians.
+  // Zero is upwards, positive is a forwards rotation
+  proximal:frc971.PotAndAbsolutePosition (id: 0);
+
+  // Values of the encoder and potentiometer at the base of the distal
+  // (connected to proximal) arm in radians.
+  // Zero is straight up, positive is a forwards rotation
+  distal:frc971.PotAndAbsolutePosition (id: 1);
+
+  // Zero for roll joint is up vertically
+  // Positive position would be rotated counterclockwise relative to the robot
+  roll_joint:frc971.PotAndAbsolutePosition (id: 2);
+}
+
 table Position {
-    // Zero for proximal is facing parallel to base of robot
-    // Positive position would be rotated upwards
-    proximal:frc971.PotAndAbsolutePosition (id: 0);
-
-    // Zero for distal is facing parallel relative to the shoulder
-    // Positive position would be rotated upwards
-    distal:frc971.PotAndAbsolutePosition (id: 1);
-
-    // Zero for roll joint is up vertically
-    // Positive position would be rotated counterclockwise relative to the robot
-    roll_joint:frc971.PotAndAbsolutePosition (id: 2);
+    arm:ArmPosition (id: 0);
 
     // Zero for wrist is facing staright outward.
     // Positive position would be upwards
-    wrist:frc971.PotAndAbsolutePosition (id: 3);
+    wrist:frc971.AbsolutePosition (id: 1);
 }
 
 root_type Position;
diff --git a/y2023/control_loops/superstructure/superstructure_status.fbs b/y2023/control_loops/superstructure/superstructure_status.fbs
index ff09317..db86687 100644
--- a/y2023/control_loops/superstructure/superstructure_status.fbs
+++ b/y2023/control_loops/superstructure/superstructure_status.fbs
@@ -3,6 +3,59 @@
 
 namespace y2023.control_loops.superstructure;
 
+enum ArmState : ubyte {
+    UNINITIALIZED = 0,
+    ZEROING = 1,
+    DISABLED = 2,
+    GOTO_PATH = 3,
+    RUNNING = 4,
+    ESTOP = 5,
+}
+
+table ArmStatus {
+  // State of the estimators.
+  proximal_estimator_state:frc971.PotAndAbsoluteEncoderEstimatorState (id: 0);
+  distal_estimator_state:frc971.PotAndAbsoluteEncoderEstimatorState (id: 1);
+  roll_joint_estimator_state:frc971.PotAndAbsoluteEncoderEstimatorState (id: 18);
+
+  // The node we are currently going to.
+  current_node:uint32 (id: 2);
+  // Distance (in radians) to the end of the path.
+  path_distance_to_go:float (id: 3);
+  // Goal position and velocity (radians)
+  goal_theta0:float (id: 4);
+  goal_theta1:float (id: 5);
+  goal_theta2:float (id: 19);
+  goal_omega0:float (id: 6);
+  goal_omega1:float (id: 7);
+  goal_omega2:float (id: 20);
+
+  // Current position and velocity (radians)
+  theta0:float (id: 8);
+  theta1:float (id: 9);
+  theta2:float (id: 21);
+
+  omega0:float (id: 10);
+  omega1:float (id: 11);
+  omega2:float (id: 22);
+
+  // Estimated voltage error for the two joints.
+  voltage_error0:float (id: 12);
+  voltage_error1:float (id: 13);
+  voltage_error2:float (id: 23);
+
+  // True if we are zeroed.
+  zeroed:bool (id: 14);
+
+  // True if the arm is zeroed.
+  estopped:bool (id: 15);
+
+  // The current state machine state.
+  state:ArmState (id: 16);
+
+  // The number of times the LQR solver failed.
+  failed_solutions:uint32 (id: 17);
+}
 
 table Status {
   // All subsystems know their location.
@@ -11,13 +64,9 @@
   // If true, we have aborted. This is the or of all subsystem estops.
   estopped:bool (id: 1);
 
-  proximal:frc971.PotAndAbsoluteEncoderEstimatorState (id: 2);
+  arm:ArmStatus (id: 2);
 
-  distal:frc971.PotAndAbsoluteEncoderEstimatorState (id: 3);
-
-  roll_joint:frc971.PotAndAbsoluteEncoderEstimatorState (id: 4);
-
-  wrist:frc971.control_loops.PotAndAbsoluteEncoderProfiledJointStatus (id: 5);
+  wrist:frc971.control_loops.AbsoluteEncoderProfiledJointStatus (id: 3);
 }
 
 root_type Status;
diff --git a/y2023/control_loops/superstructure/wrist/BUILD b/y2023/control_loops/superstructure/wrist/BUILD
new file mode 100644
index 0000000..694d95f
--- /dev/null
+++ b/y2023/control_loops/superstructure/wrist/BUILD
@@ -0,0 +1,34 @@
+package(default_visibility = ["//y2023:__subpackages__"])
+
+genrule(
+    name = "genrule_wrist",
+    outs = [
+        "wrist_plant.h",
+        "wrist_plant.cc",
+        "integral_wrist_plant.h",
+        "integral_wrist_plant.cc",
+    ],
+    cmd = "$(location //y2023/control_loops/python:wrist) $(OUTS)",
+    target_compatible_with = ["@platforms//os:linux"],
+    tools = [
+        "//y2023/control_loops/python:wrist",
+    ],
+)
+
+cc_library(
+    name = "wrist_plants",
+    srcs = [
+        "integral_wrist_plant.cc",
+        "wrist_plant.cc",
+    ],
+    hdrs = [
+        "integral_wrist_plant.h",
+        "wrist_plant.h",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//frc971/control_loops:hybrid_state_feedback_loop",
+        "//frc971/control_loops:state_feedback_loop",
+    ],
+)
diff --git a/y2023/joystick_reader.cc b/y2023/joystick_reader.cc
index a1a42f4..3caaa41 100644
--- a/y2023/joystick_reader.cc
+++ b/y2023/joystick_reader.cc
@@ -19,6 +19,7 @@
 #include "frc971/zeroing/wrap.h"
 #include "y2023/constants.h"
 #include "y2023/control_loops/drivetrain/drivetrain_base.h"
+#include "y2023/control_loops/superstructure/arm/generated_graph.h"
 #include "y2023/control_loops/superstructure/superstructure_goal_generated.h"
 #include "y2023/control_loops/superstructure/superstructure_status_generated.h"
 
@@ -34,7 +35,13 @@
 namespace input {
 namespace joysticks {
 
+// TODO(milind): add correct locations
+const ButtonLocation kIntake(3, 3);
+const ButtonLocation kScore(3, 3);
+const ButtonLocation kSpit(3, 3);
+
 namespace superstructure = y2023::control_loops::superstructure;
+namespace arm = superstructure::arm;
 
 class Reader : public ::frc971::input::ActionJoystickInput {
  public:
@@ -59,14 +66,28 @@
       return;
     }
 
-    (void)data;
-    // TODO(Xander): Use driverstaion data to provide instructions.
+    bool intake = false;
+    bool spit = false;
+
+    // TODO(milind): add more actions and paths
+    if (data.IsPressed(kIntake)) {
+      intake = true;
+      arm_goal_position_ = arm::PickupPosIndex();
+    } else if (data.IsPressed(kSpit)) {
+      spit = true;
+      arm_goal_position_ = arm::PickupPosIndex();
+    } else if (data.IsPressed(kScore)) {
+      arm_goal_position_ = arm::ScorePosIndex();
+    }
+
     {
       auto builder = superstructure_goal_sender_.MakeBuilder();
 
       superstructure::Goal::Builder superstructure_goal_builder =
           builder.MakeBuilder<superstructure::Goal>();
-
+      superstructure_goal_builder.add_arm_goal_position(arm_goal_position_);
+      superstructure_goal_builder.add_intake(intake);
+      superstructure_goal_builder.add_spit(spit);
       if (builder.Send(superstructure_goal_builder.Finish()) !=
           aos::RawSender::Error::kOk) {
         AOS_LOG(ERROR, "Sending superstructure goal failed.\n");
@@ -78,6 +99,8 @@
   ::aos::Sender<superstructure::Goal> superstructure_goal_sender_;
 
   ::aos::Fetcher<superstructure::Status> superstructure_status_fetcher_;
+
+  uint32_t arm_goal_position_;
 };
 
 }  // namespace joysticks
diff --git a/y2023/vision/BUILD b/y2023/vision/BUILD
index bcae14a..419217f 100644
--- a/y2023/vision/BUILD
+++ b/y2023/vision/BUILD
@@ -30,8 +30,11 @@
         "//aos:init",
         "//aos:json_to_flatbuffer",
         "//aos/events:shm_event_loop",
+        "//frc971/constants:constants_sender_lib",
         "//frc971/vision:vision_fbs",
         "//third_party:opencv",
+        "//y2023/vision:april_debug_fbs",
+        "//y2023/vision:vision_util",
         "@com_google_absl//absl/strings",
     ],
 )
diff --git a/y2023/vision/aprilrobotics.h b/y2023/vision/aprilrobotics.h
index 4477856..66b0aac 100644
--- a/y2023/vision/aprilrobotics.h
+++ b/y2023/vision/aprilrobotics.h
@@ -38,7 +38,7 @@
   std::vector<std::pair<apriltag_detection_t, apriltag_pose_t>> DetectTags(
       cv::Mat image);
 
-  const cv::Mat extrinsics() const { return extrinsics_; }
+  const std::optional<cv::Mat> extrinsics() const { return extrinsics_; }
   const cv::Mat intrinsics() const { return intrinsics_; }
   const cv::Mat dist_coeffs() const { return dist_coeffs_; }
 
@@ -57,7 +57,7 @@
   const frc971::vision::calibration::CameraCalibration *calibration_;
   cv::Mat intrinsics_;
   cv::Mat projection_matrix_;
-  cv::Mat extrinsics_;
+  std::optional<cv::Mat> extrinsics_;
   cv::Mat dist_coeffs_;
 
   aos::Ftrace ftrace_;
diff --git a/y2023/vision/calib_files/calibration_pi-2023-base-calib.json b/y2023/vision/calib_files/calibration_pi-2023-base-calib.json
index 747dcb7..d75e5ca 100755
--- a/y2023/vision/calib_files/calibration_pi-2023-base-calib.json
+++ b/y2023/vision/calib_files/calibration_pi-2023-base-calib.json
@@ -19,26 +19,6 @@
   -0.000112,
   -0.076989
  ],
-  "fixed_extrinsics": {
-    "data": [
-      -1,
-      -1.57586107256918e-16,
-      5.0158596452676243e-17,
-      -0.15239999999999998,
-      1.3147519464173305e-16,
-      -0.5735764363510459,
-      0.8191520442889919,
-      -0.2032,
-      -1.0031719290535249e-16,
-      0.8191520442889919,
-      0.5735764363510459,
-      0.0127,
-      0,
-      0,
-      0,
-      1
-    ]
-  },
  "calibration_timestamp": 1358500519438113048,
- "camera_id": "23-01"
+ "camera_id": "23-00"
 }
diff --git a/y2023/vision/calib_files/calibration_pi-7971-1_cam-23-01_ext_2023-02-19.json b/y2023/vision/calib_files/calibration_pi-7971-1_cam-23-01_ext_2023-02-19.json
new file mode 100644
index 0000000..3b1f3ee
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-7971-1_cam-23-01_ext_2023-02-19.json
@@ -0,0 +1 @@
+{ "node_name": "pi1", "team_number": 7971, "intrinsics": [ 893.759521, 0.0, 645.470764, 0.0, 893.222351, 388.150269, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ -0.999824, -0.017701, -0.006142, 0.155002, 0.006183, -0.002255, -0.999978, -0.11871, 0.017687, -0.999841, 0.002364, 0.002099, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.44902, 0.248409, -0.000537, -0.000112, -0.076989 ], "calibration_timestamp": 1358500519438113048, "camera_id": "23-01" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-7971-2_cam-23-02_ext_2023-02-19.json b/y2023/vision/calib_files/calibration_pi-7971-2_cam-23-02_ext_2023-02-19.json
new file mode 100644
index 0000000..addacf2
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-7971-2_cam-23-02_ext_2023-02-19.json
@@ -0,0 +1 @@
+{ "node_name": "pi2", "team_number": 7971, "intrinsics": [ 895.543945, 0.0, 645.250122, 0.0, 895.308838, 354.297241, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ -0.513422, -0.00069, 0.858136, 0.132248, -0.858026, -0.015658, -0.513368, -0.002601, 0.013791, -0.999877, 0.007447, -0.001246, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.455658, 0.272167, 0.000796, -0.000206, -0.0975 ], "calibration_timestamp": 1358499769498335208, "camera_id": "23-02" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-7971-3_cam-23-03_ext_2023-02-19.json b/y2023/vision/calib_files/calibration_pi-7971-3_cam-23-03_ext_2023-02-19.json
new file mode 100644
index 0000000..e2a5817
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-7971-3_cam-23-03_ext_2023-02-19.json
@@ -0,0 +1 @@
+{ "node_name": "pi3", "team_number": 7971, "intrinsics": [ 892.089172, 0.0, 648.780701, 0.0, 892.362854, 342.340668, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ 0.496784, 0.01435, 0.867756, 0.146101, -0.867856, 0.00168, 0.496813, 0.028557, 0.005671, -0.999896, 0.013288, -0.01621, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.451178, 0.258187, 0.001071, 0.000017, -0.085526 ], "calibration_timestamp": 1358501967378322133, "camera_id": "23-03" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-7971-4_cam-23-04_ext_2023-02-19.json b/y2023/vision/calib_files/calibration_pi-7971-4_cam-23-04_ext_2023-02-19.json
new file mode 100644
index 0000000..a59f74f
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-7971-4_cam-23-04_ext_2023-02-19.json
@@ -0,0 +1 @@
+{ "node_name": "pi4", "team_number": 7971, "intrinsics": [ 890.071899, 0.0, 620.69519, 0.0, 890.307434, 365.158844, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ 0.999146, 0.018411, 0.036996, 0.186144, -0.036693, -0.016555, 0.999189, 0.153146, 0.019009, -0.999693, -0.015866, 0.010067, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.449088, 0.25594, 0.000415, 0.000142, -0.084656 ], "calibration_timestamp": 1358501982039874176, "camera_id": "23-04" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-1_cam-23-01_2013-01-18_08-56-17.194305339.json b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-01_2013-01-18_08-56-17.194305339.json
index 1f30579..c9d960a 100755
--- a/y2023/vision/calib_files/calibration_pi-971-1_cam-23-01_2013-01-18_08-56-17.194305339.json
+++ b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-01_2013-01-18_08-56-17.194305339.json
@@ -19,24 +19,6 @@
   0.008949,
   -0.079813
  ],
- "fixed_extrinsics": [
-  1.0,
-  0.0,
-  0.0,
-  0.0,
-  0.0,
-  1.0,
-  0.0,
-  0.0,
-  0.0,
-  0.0,
-  1.0,
-  0.0,
-  0.0,
-  0.0,
-  0.0,
-  1.0
- ],
  "calibration_timestamp": 1358499377194305339,
  "camera_id": "23-01"
 }
diff --git a/y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_2013-01-18_09-38-22.915096335.json b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_2013-01-18_09-38-22.915096335.json
new file mode 100755
index 0000000..3afee2e
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_2013-01-18_09-38-22.915096335.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi1",
+ "team_number": 971,
+ "intrinsics": [
+  890.980713,
+  0.0,
+  619.298645,
+  0.0,
+  890.668762,
+  364.009766,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.449172,
+  0.252318,
+  0.000881,
+  -0.000615,
+  -0.082208
+ ],
+ "calibration_timestamp": 1358501902915096335,
+ "camera_id": "23-05"
+}
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_ext_2023-02-22.json b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_ext_2023-02-22.json
new file mode 100644
index 0000000..b21da40
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_ext_2023-02-22.json
@@ -0,0 +1 @@
+{ "node_name": "pi1", "team_number": 971, "intrinsics": [ 890.980713, 0.0, 619.298645, 0.0, 890.668762, 364.009766, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ -0.487722, 0.222354, 0.844207, 0.025116, 0.864934, -0.008067, 0.501821, -0.246003, 0.118392, 0.974933, -0.188387, 0.532497, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.449172, 0.252318, 0.000881, -0.000615, -0.082208 ], "calibration_timestamp": 1358501902915096335, "camera_id": "23-05" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-1_cam-23-99_000000.json b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-99_000000.json
new file mode 100644
index 0000000..4b41658
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-99_000000.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi1",
+ "team_number": 971,
+ "intrinsics": [
+  893.759521,
+  0.0,
+  645.470764,
+  0.0,
+  893.222351,
+  388.150269,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.44902,
+  0.248409,
+  -0.000537,
+  -0.000112,
+  -0.076989
+ ],
+ "calibration_timestamp": 1358500519438113048,
+ "camera_id": "23-99"
+}
diff --git a/y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_2013-01-18_09-32-06.409252911.json b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_2013-01-18_09-32-06.409252911.json
new file mode 100755
index 0000000..47f1d30
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_2013-01-18_09-32-06.409252911.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi2",
+ "team_number": 971,
+ "intrinsics": [
+  893.242981,
+  0.0,
+  639.796692,
+  0.0,
+  892.498718,
+  354.109344,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.451751,
+  0.252422,
+  0.000531,
+  0.000079,
+  -0.079369
+ ],
+ "calibration_timestamp": 1358501526409252911,
+ "camera_id": "23-06"
+}
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_ext_2023-02-22.json b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_ext_2023-02-22.json
new file mode 100644
index 0000000..4759fda
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_ext_2023-02-22.json
@@ -0,0 +1 @@
+{ "node_name": "pi2", "team_number": 971, "intrinsics": [ 893.242981, 0.0, 639.796692, 0.0, 892.498718, 354.109344, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ 0.852213, 0.227336, 0.471224, 0.220072, 0.485092, -0.005909, -0.874443, -0.175232, -0.196008, 0.973799, -0.115315, 0.61409, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.451751, 0.252422, 0.000531, 0.000079, -0.079369 ], "calibration_timestamp": 1358501526409252911, "camera_id": "23-06" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-2_cam-23-11_2013-01-18_10-01-30.177115986.json b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-11_2013-01-18_10-01-30.177115986.json
new file mode 100755
index 0000000..306bea1
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-11_2013-01-18_10-01-30.177115986.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi3",
+ "team_number": 971,
+ "intrinsics": [
+  891.026001,
+  0.0,
+  620.086731,
+  0.0,
+  890.566895,
+  385.035126,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.448299,
+  0.250123,
+  -0.00042,
+  -0.000127,
+  -0.078433
+ ],
+ "calibration_timestamp": 1358503290177115986,
+ "camera_id": "23-11"
+}
diff --git a/y2023/vision/calib_files/calibration_pi-971-2_cam-23-99_000000.json b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-99_000000.json
new file mode 100644
index 0000000..9257aee
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-99_000000.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi2",
+ "team_number": 971,
+ "intrinsics": [
+  893.759521,
+  0.0,
+  645.470764,
+  0.0,
+  893.222351,
+  388.150269,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.44902,
+  0.248409,
+  -0.000537,
+  -0.000112,
+  -0.076989
+ ],
+ "calibration_timestamp": 1358500519438113048,
+ "camera_id": "23-99"
+}
diff --git a/y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_2013-01-18_09-11-56.768663953.json b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_2013-01-18_09-11-56.768663953.json
new file mode 100755
index 0000000..fc78ef8
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_2013-01-18_09-11-56.768663953.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi3",
+ "team_number": 971,
+ "intrinsics": [
+  892.627869,
+  0.0,
+  629.289978,
+  0.0,
+  891.73761,
+  373.299896,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.454901,
+  0.266778,
+  -0.000316,
+  -0.000469,
+  -0.091357
+ ],
+ "calibration_timestamp": 1358500316768663953,
+ "camera_id": "23-07"
+}
diff --git a/y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_ext_2023-02-22.json b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_ext_2023-02-22.json
new file mode 100644
index 0000000..8c86d20
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_ext_2023-02-22.json
@@ -0,0 +1 @@
+{ "node_name": "pi3", "team_number": 971, "intrinsics": [ 892.627869, 0.0, 629.289978, 0.0, 891.73761, 373.299896, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ 0.492232, -0.163335, -0.855002, 0.020122, -0.866067, 0.006706, -0.499883, -0.174518, 0.087382, 0.986548, -0.138158, 0.645307, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.454901, 0.266778, -0.000316, -0.000469, -0.091357 ], "calibration_timestamp": 1358500316768663953, "camera_id": "23-07" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-3_cam-23-10_2013-01-18_10-02-40.377380613.json b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-10_2013-01-18_10-02-40.377380613.json
new file mode 100755
index 0000000..393beef
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-10_2013-01-18_10-02-40.377380613.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi2",
+ "team_number": 971,
+ "intrinsics": [
+  894.002502,
+  0.0,
+  636.431335,
+  0.0,
+  893.723816,
+  377.069672,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.446659,
+  0.244189,
+  0.000632,
+  0.000171,
+  -0.074849
+ ],
+ "calibration_timestamp": 1358503360377380613,
+ "camera_id": "23-10"
+}
diff --git a/y2023/vision/calib_files/calibration_pi-971-3_cam-23-99_000000.json b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-99_000000.json
new file mode 100644
index 0000000..03b2687
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-99_000000.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi3",
+ "team_number": 971,
+ "intrinsics": [
+  893.759521,
+  0.0,
+  645.470764,
+  0.0,
+  893.222351,
+  388.150269,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.44902,
+  0.248409,
+  -0.000537,
+  -0.000112,
+  -0.076989
+ ],
+ "calibration_timestamp": 1358500519438113048,
+ "camera_id": "23-99"
+}
diff --git a/y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_2013-01-18_09-27-45.150551614.json b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_2013-01-18_09-27-45.150551614.json
new file mode 100755
index 0000000..5b92f75
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_2013-01-18_09-27-45.150551614.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi4",
+ "team_number": 971,
+ "intrinsics": [
+  891.88385,
+  0.0,
+  642.268616,
+  0.0,
+  890.626465,
+  353.272919,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.448426,
+  0.246817,
+  0.000002,
+  0.000948,
+  -0.076717
+ ],
+ "calibration_timestamp": 1358501265150551614,
+ "camera_id": "23-08"
+}
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_ext_2023-02-22.json b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_ext_2023-02-22.json
new file mode 100644
index 0000000..92ab69c
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_ext_2023-02-22.json
@@ -0,0 +1 @@
+{ "node_name": "pi4", "team_number": 971, "intrinsics": [ 891.88385, 0.0, 642.268616, 0.0, 890.626465, 353.272919, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ -0.865915, -0.186983, -0.463928, -0.014873, -0.473362, 0.006652, 0.880843, -0.215738, -0.161617, 0.982341, -0.094271, 0.676433, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.448426, 0.246817, 0.000002, 0.000948, -0.076717 ], "calibration_timestamp": 1358501265150551614, "camera_id": "23-08" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-4_cam-23-09_2013-01-18_09-02-59.650270322.json b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-09_2013-01-18_09-02-59.650270322.json
new file mode 100755
index 0000000..e119c43
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-09_2013-01-18_09-02-59.650270322.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi1",
+ "team_number": 971,
+ "intrinsics": [
+  893.617798,
+  0.0,
+  612.44397,
+  0.0,
+  893.193115,
+  375.196381,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.443805,
+  0.238734,
+  0.000133,
+  0.000448,
+  -0.071068
+ ],
+ "calibration_timestamp": 1358499779650270322,
+ "camera_id": "23-09"
+}
diff --git a/y2023/vision/calib_files/calibration_pi-971-4_cam-23-99_000000.json b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-99_000000.json
new file mode 100644
index 0000000..4a0b632
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-99_000000.json
@@ -0,0 +1,24 @@
+{
+ "node_name": "pi4",
+ "team_number": 971,
+ "intrinsics": [
+  893.759521,
+  0.0,
+  645.470764,
+  0.0,
+  893.222351,
+  388.150269,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  -0.44902,
+  0.248409,
+  -0.000537,
+  -0.000112,
+  -0.076989
+ ],
+ "calibration_timestamp": 1358500519438113048,
+ "camera_id": "23-99"
+}
diff --git a/y2023/vision/calibrate_extrinsics.cc b/y2023/vision/calibrate_extrinsics.cc
index eab8724..10e1639 100644
--- a/y2023/vision/calibrate_extrinsics.cc
+++ b/y2023/vision/calibrate_extrinsics.cc
@@ -7,8 +7,8 @@
 #include "aos/network/team_number.h"
 #include "aos/time/time.h"
 #include "aos/util/file.h"
-#include "frc971/control_loops/quaternion_utils.h"
 #include "frc971/constants/constants_sender_lib.h"
+#include "frc971/control_loops/quaternion_utils.h"
 #include "frc971/vision/extrinsics_calibration.h"
 #include "frc971/vision/vision_generated.h"
 #include "frc971/wpilib/imu_batch_generated.h"
@@ -17,7 +17,7 @@
 
 DEFINE_string(pi, "pi-7971-2", "Pi name to calibrate.");
 DEFINE_bool(plot, false, "Whether to plot the resulting data.");
-DEFINE_string(target_type, "charuco",
+DEFINE_string(target_type, "charuco_diamond",
               "Type of target: aruco|charuco|charuco_diamond");
 DEFINE_string(image_channel, "/camera", "Channel to listen for images on");
 DEFINE_string(output_logs, "/tmp/calibration/",
@@ -51,16 +51,17 @@
       TargetType target_type = TargetTypeFromString(FLAGS_target_type);
       std::unique_ptr<aos::EventLoop> constants_event_loop =
           factory->MakeEventLoop("constants_fetcher", pi_event_loop->node());
-      frc971::constants::ConstantsFetcher<y2023::Constants> constants_fetcher(
-          constants_event_loop.get());
-      *intrinsics =
-          FLAGS_base_intrinsics.empty()
-              ? aos::RecursiveCopyFlatBuffer(
-                    y2023::vision::FindCameraCalibration(
-                        constants_fetcher.constants(),
-                        pi_event_loop->node()->name()->string_view()))
-              : aos::JsonFileToFlatbuffer<calibration::CameraCalibration>(
-                    FLAGS_base_intrinsics);
+      if (FLAGS_base_intrinsics.empty()) {
+        frc971::constants::ConstantsFetcher<y2023::Constants> constants_fetcher(
+            constants_event_loop.get());
+        *intrinsics =
+            aos::RecursiveCopyFlatBuffer(y2023::vision::FindCameraCalibration(
+                constants_fetcher.constants(),
+                pi_event_loop->node()->name()->string_view()));
+      } else {
+        *intrinsics = aos::JsonFileToFlatbuffer<calibration::CameraCalibration>(
+            FLAGS_base_intrinsics);
+      }
       extractor = std::make_unique<Calibration>(
           factory, pi_event_loop.get(), imu_event_loop.get(), FLAGS_pi,
           &intrinsics->message(), target_type, FLAGS_image_channel, data);
@@ -80,6 +81,14 @@
     // Now, accumulate all the data into the data object.
     aos::logger::LogReader reader(
         aos::logger::SortParts(aos::logger::FindLogs(argc, argv)));
+    reader.RemapLoggedChannel<foxglove::CompressedImage>("/pi1/camera");
+    reader.RemapLoggedChannel<foxglove::CompressedImage>("/pi2/camera");
+    reader.RemapLoggedChannel<foxglove::CompressedImage>("/pi3/camera");
+    reader.RemapLoggedChannel<foxglove::CompressedImage>("/pi4/camera");
+    reader.RemapLoggedChannel<foxglove::ImageAnnotations>("/pi1/camera");
+    reader.RemapLoggedChannel<foxglove::ImageAnnotations>("/pi2/camera");
+    reader.RemapLoggedChannel<foxglove::ImageAnnotations>("/pi3/camera");
+    reader.RemapLoggedChannel<foxglove::ImageAnnotations>("/pi4/camera");
 
     aos::SimulatedEventLoopFactory factory(reader.configuration());
     reader.RegisterWithoutStarting(&factory);
@@ -122,29 +131,40 @@
   CHECK(data.camera_samples_size() > 0) << "Didn't get any camera observations";
 
   // And now we have it, we can start processing it.
-  const Eigen::Quaternion<double> nominal_initial_orientation(
-      frc971::controls::ToQuaternionFromRotationVector(
-          Eigen::Vector3d(0.0, 0.0, M_PI)));
-  const Eigen::Quaternion<double> nominal_pivot_to_camera(
+  // NOTE: For y2023, with no turret, pivot == imu
+
+  // Define the mapping that takes imu frame (with z up) to camera frame (with z
+  // pointing out)
+  const Eigen::Quaterniond R_precam_cam(
+      Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitX()) *
+      Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitZ()));
+  // Set up initial conditions for the pis that are reasonable
+  Eigen::Quaternion<double> nominal_initial_orientation(
+      Eigen::AngleAxisd(0.0, Eigen::Vector3d::UnitZ()));
+  Eigen::Quaternion<double> nominal_pivot_to_camera(
+      Eigen::AngleAxisd(0.5 * M_PI, Eigen::Vector3d::UnitX()) *
+      Eigen::AngleAxisd(-0.75 * M_PI, Eigen::Vector3d::UnitY()));
+  Eigen::Vector3d nominal_pivot_to_camera_translation(8.0, 8.0, 0.0);
+  Eigen::Quaternion<double> nominal_board_to_world(
       Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitX()));
-  const Eigen::Quaternion<double> nominal_board_to_world(
-      Eigen::AngleAxisd(0.5 * M_PI, Eigen::Vector3d::UnitX()));
   Eigen::Matrix<double, 6, 1> nominal_initial_state =
       Eigen::Matrix<double, 6, 1>::Zero();
-  // Set x value to 0.5 m (center view on the board)
-  // nominal_initial_state(0, 0) = 0.5;
-  // Set y value to -1 m (approx distance from imu to board/world)
-  nominal_initial_state(1, 0) = -1.0;
+  // Set y value to -1 m (approx distance from imu to the board/world)
+  nominal_initial_state(1, 0) = 1.0;
 
   CalibrationParameters calibration_parameters;
   calibration_parameters.initial_orientation = nominal_initial_orientation;
   calibration_parameters.pivot_to_camera = nominal_pivot_to_camera;
+  calibration_parameters.pivot_to_camera_translation =
+      nominal_pivot_to_camera_translation;
+  // Board to world rotation
   calibration_parameters.board_to_world = nominal_board_to_world;
+  // Initial imu location (and velocity)
   calibration_parameters.initial_state = nominal_initial_state;
   calibration_parameters.has_pivot = false;
 
-  // Show the inverse of pivot_to_camera, since camera_to_pivot tells where the
-  // camera is with respect to the pivot frame
+  // Show the inverse of pivot_to_camera, since camera_to_pivot tells where
+  // the camera is with respect to the pivot frame
   const Eigen::Affine3d nominal_affine_pivot_to_camera =
       Eigen::Translation3d(calibration_parameters.pivot_to_camera_translation) *
       nominal_pivot_to_camera;
@@ -156,22 +176,20 @@
   LOG(INFO) << "Initial Conditions for solver.  Assumes:\n"
             << "1) board origin is same as world, but rotated pi/2 about "
                "x-axis, so z points out\n"
-            << "2) pivot origin matches imu origin\n"
+            << "2) pivot origin matches imu origin (since there's no turret)\n"
             << "3) camera is offset from pivot (depends on which camera)";
 
-  LOG(INFO)
-      << "Nominal initial_orientation of imu w.r.t. world (angle-axis vector): "
-      << frc971::controls::ToRotationVectorFromQuaternion(
-             nominal_initial_orientation)
-             .transpose();
+  LOG(INFO) << "Nominal initial_orientation of imu w.r.t. world "
+               "(angle-axis vector): "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   nominal_initial_orientation)
+                   .transpose();
   LOG(INFO) << "Nominal initial_state: \n"
             << "Position: "
             << nominal_initial_state.block<3, 1>(0, 0).transpose() << "\n"
             << "Velocity: "
             << nominal_initial_state.block<3, 1>(3, 0).transpose();
-  // TODO<Jim>: Might be nice to take out the rotation component that maps into
-  // camera image coordinates (with x right, y down, z forward)
-  LOG(INFO) << "Nominal camera_to_pivot (angle-axis vector): "
+  LOG(INFO) << "Nominal camera_to_pivot rotation (angle-axis vector): "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    nominal_camera_to_pivot_rotation)
                    .transpose();
@@ -188,17 +206,20 @@
 
   if (!FLAGS_output_calibration.empty()) {
     aos::WriteFlatbufferToJson(FLAGS_output_calibration, merged_calibration);
+  } else {
+    LOG(WARNING) << "Calibration filename not provided, so not saving it";
   }
 
   LOG(INFO) << "RESULTS OF CALIBRATION SOLVER:";
   std::cout << aos::FlatbufferToJson(&merged_calibration.message())
             << std::endl;
+
   LOG(INFO) << "initial_orientation of imu w.r.t. world (angle-axis vector): "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    calibration_parameters.initial_orientation)
                    .transpose();
   LOG(INFO)
-      << "initial_state: \n"
+      << "initial_state (imu): \n"
       << "Position: "
       << calibration_parameters.initial_state.block<3, 1>(0, 0).transpose()
       << "\n"
@@ -208,15 +229,17 @@
   const Eigen::Affine3d affine_pivot_to_camera =
       Eigen::Translation3d(calibration_parameters.pivot_to_camera_translation) *
       calibration_parameters.pivot_to_camera;
+  const Eigen::Affine3d affine_camera_to_pivot =
+      affine_pivot_to_camera.inverse();
   const Eigen::Quaterniond camera_to_pivot_rotation(
-      affine_pivot_to_camera.inverse().rotation());
+      affine_camera_to_pivot.rotation());
   const Eigen::Vector3d camera_to_pivot_translation(
-      affine_pivot_to_camera.inverse().translation());
-  LOG(INFO) << "camera to pivot (angle-axis vec): "
+      affine_camera_to_pivot.translation());
+  LOG(INFO) << "camera to pivot(imu) rotation (angle-axis vec): "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    camera_to_pivot_rotation)
                    .transpose();
-  LOG(INFO) << "camera to pivot translation: "
+  LOG(INFO) << "camera to pivot(imu) translation: "
             << camera_to_pivot_translation.transpose();
   LOG(INFO) << "board_to_world (rotation) "
             << frc971::controls::ToRotationVectorFromQuaternion(
@@ -227,12 +250,24 @@
   LOG(INFO) << "gyro_bias " << calibration_parameters.gyro_bias.transpose();
   LOG(INFO) << "gravity " << 9.81 * calibration_parameters.gravity_scalar;
 
-  LOG(INFO) << "pivot_to_camera change "
+  LOG(INFO) << "Checking delta from nominal (initial condition) to solved "
+               "values:";
+  LOG(INFO) << "nominal_pivot_to_camera rotation: "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   R_precam_cam * nominal_pivot_to_camera)
+                   .transpose();
+  LOG(INFO) << "solved pivot_to_camera rotation: "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   R_precam_cam * calibration_parameters.pivot_to_camera)
+                   .transpose();
+
+  LOG(INFO) << "pivot_to_camera rotation delta (zero if the IC's match the "
+               "solved value) "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    calibration_parameters.pivot_to_camera *
                    nominal_pivot_to_camera.inverse())
                    .transpose();
-  LOG(INFO) << "board_to_world delta "
+  LOG(INFO) << "board_to_world rotation change "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    calibration_parameters.board_to_world *
                    nominal_board_to_world.inverse())
diff --git a/y2023/vision/target_mapping.cc b/y2023/vision/target_mapping.cc
index 921c172..419bd38 100644
--- a/y2023/vision/target_mapping.cc
+++ b/y2023/vision/target_mapping.cc
@@ -86,7 +86,7 @@
   auto detector_ptr = std::make_unique<AprilRoboticsDetector>(
       detection_event_loop, kImageChannel);
   // Get the camera extrinsics
-  cv::Mat extrinsics_cv = detector_ptr->extrinsics();
+  cv::Mat extrinsics_cv = detector_ptr->extrinsics().value();
   Eigen::Matrix4d extrinsics_matrix;
   cv::cv2eigen(extrinsics_cv, extrinsics_matrix);
   const auto extrinsics = Eigen::Affine3d(extrinsics_matrix);
diff --git a/y2023/vision/viewer.cc b/y2023/vision/viewer.cc
index 68495b1..7877a57 100644
--- a/y2023/vision/viewer.cc
+++ b/y2023/vision/viewer.cc
@@ -6,7 +6,12 @@
 #include "aos/init.h"
 #include "aos/json_to_flatbuffer.h"
 #include "aos/time/time.h"
+#include "frc971/constants/constants_sender_lib.h"
 #include "frc971/vision/vision_generated.h"
+#include "opencv2/calib3d.hpp"
+#include "opencv2/imgproc.hpp"
+#include "y2023/vision/april_debug_generated.h"
+#include "y2023/vision/vision_util.h"
 
 DEFINE_string(config, "aos_config.json", "Path to the config file to use.");
 DEFINE_string(channel, "/camera", "Channel name for the image.");
@@ -16,23 +21,32 @@
 
 DEFINE_int32(rate, 100, "Time in milliseconds to wait between images");
 
-namespace frc971 {
+namespace y2023 {
 namespace vision {
 namespace {
 
-aos::Fetcher<CameraImage> image_fetcher;
-bool DisplayLoop() {
+using frc971::vision::CameraImage;
+
+bool DisplayLoop(const cv::Mat intrinsics, const cv::Mat dist_coeffs,
+                 aos::Fetcher<CameraImage> *image_fetcher,
+                 aos::Fetcher<AprilDebug> *april_debug_fetcher) {
   const CameraImage *image;
+  std::optional<const AprilDebug *> april_debug = std::nullopt;
 
   // Read next image
-  if (!image_fetcher.Fetch()) {
+  if (!image_fetcher->Fetch()) {
     VLOG(2) << "Couldn't fetch next image";
     return true;
   }
-
-  image = image_fetcher.get();
+  image = image_fetcher->get();
   CHECK(image != nullptr) << "Couldn't read image";
 
+  if (april_debug_fetcher->Fetch()) {
+    april_debug = april_debug_fetcher->get();
+  } else {
+    VLOG(2) << "Couldn't fetch next target map";
+  }
+
   // Create color image:
   cv::Mat image_color_mat(cv::Size(image->cols(), image->rows()), CV_8UC2,
                           (void *)image->data()->data());
@@ -41,7 +55,8 @@
 
   if (!FLAGS_capture.empty()) {
     if (absl::EndsWith(FLAGS_capture, ".bfbs")) {
-      aos::WriteFlatbufferToFile(FLAGS_capture, image_fetcher.CopyFlatBuffer());
+      aos::WriteFlatbufferToFile(FLAGS_capture,
+                                 image_fetcher->CopyFlatBuffer());
     } else {
       cv::imwrite(FLAGS_capture, bgr_image);
     }
@@ -49,7 +64,21 @@
     return false;
   }
 
-  cv::imshow("Display", bgr_image);
+  cv::Mat undistorted_image;
+  cv::undistort(bgr_image, undistorted_image, intrinsics, dist_coeffs);
+
+  if (april_debug.has_value() && april_debug.value()->corners()->size() > 0) {
+    for (const auto *corners : *april_debug.value()->corners()) {
+      std::vector<cv::Point> points;
+      for (const auto *point_fbs : *corners->points()) {
+        points.emplace_back(point_fbs->x(), point_fbs->y());
+      }
+      cv::polylines(undistorted_image, points, true, cv::Scalar(255, 0, 0), 10);
+    }
+  }
+
+  cv::imshow("Display", undistorted_image);
+
   int keystroke = cv::waitKey(1);
   if ((keystroke & 0xFF) == static_cast<int>('c')) {
     // Convert again, to get clean image
@@ -68,14 +97,26 @@
   aos::FlatbufferDetachedBuffer<aos::Configuration> config =
       aos::configuration::ReadConfig(FLAGS_config);
 
+  frc971::constants::WaitForConstants<Constants>(&config.message());
+
   aos::ShmEventLoop event_loop(&config.message());
 
-  image_fetcher = event_loop.MakeFetcher<CameraImage>(FLAGS_channel);
+  frc971::constants::ConstantsFetcher<Constants> constants_fetcher(&event_loop);
+  const auto *calibration_data = FindCameraCalibration(
+      constants_fetcher.constants(), event_loop.node()->name()->string_view());
+  const cv::Mat intrinsics = CameraIntrinsics(calibration_data);
+  const cv::Mat dist_coeffs = CameraDistCoeffs(calibration_data);
+
+  aos::Fetcher<CameraImage> image_fetcher =
+      event_loop.MakeFetcher<CameraImage>(FLAGS_channel);
+  aos::Fetcher<AprilDebug> april_debug_fetcher =
+      event_loop.MakeFetcher<AprilDebug>("/camera");
 
   // Run the display loop
   event_loop.AddPhasedLoop(
-      [&event_loop](int) {
-        if (!DisplayLoop()) {
+      [&](int) {
+        if (!DisplayLoop(intrinsics, dist_coeffs, &image_fetcher,
+                         &april_debug_fetcher)) {
           LOG(INFO) << "Calling event_loop Exit";
           event_loop.Exit();
         };
@@ -89,9 +130,9 @@
 
 }  // namespace
 }  // namespace vision
-}  // namespace frc971
+}  // namespace y2023
 
 int main(int argc, char **argv) {
   aos::InitGoogle(&argc, &argv);
-  frc971::vision::ViewerMain();
+  y2023::vision::ViewerMain();
 }
diff --git a/y2023/vision/vision_util.cc b/y2023/vision/vision_util.cc
index f4937e5..ca5ad89 100644
--- a/y2023/vision/vision_util.cc
+++ b/y2023/vision/vision_util.cc
@@ -18,11 +18,15 @@
   LOG(FATAL) << ": Failed to find camera calibration for " << node_name;
 }
 
-cv::Mat CameraExtrinsics(
+std::optional<cv::Mat> CameraExtrinsics(
     const frc971::vision::calibration::CameraCalibration *camera_calibration) {
   CHECK(!camera_calibration->has_turret_extrinsics())
       << "No turret on 2023 robot";
 
+  if (!camera_calibration->has_fixed_extrinsics()) {
+    return std::nullopt;
+  }
+  CHECK(camera_calibration->fixed_extrinsics()->has_data());
   cv::Mat result(4, 4, CV_32F,
                  const_cast<void *>(static_cast<const void *>(
                      camera_calibration->fixed_extrinsics()->data()->data())));
diff --git a/y2023/vision/vision_util.h b/y2023/vision/vision_util.h
index ce1a69d..79f5c92 100644
--- a/y2023/vision/vision_util.h
+++ b/y2023/vision/vision_util.h
@@ -10,7 +10,7 @@
 const frc971::vision::calibration::CameraCalibration *FindCameraCalibration(
     const y2023::Constants &calibration_data, std::string_view node_name);
 
-cv::Mat CameraExtrinsics(
+std::optional<cv::Mat> CameraExtrinsics(
     const frc971::vision::calibration::CameraCalibration *camera_calibration);
 
 cv::Mat CameraIntrinsics(
diff --git a/y2023/wpilib_interface.cc b/y2023/wpilib_interface.cc
index 77ff5af..3ccb036 100644
--- a/y2023/wpilib_interface.cc
+++ b/y2023/wpilib_interface.cc
@@ -23,6 +23,7 @@
 #undef ERROR
 
 #include "aos/commonmath.h"
+#include "aos/containers/sized_array.h"
 #include "aos/events/event_loop.h"
 #include "aos/events/shm_event_loop.h"
 #include "aos/init.h"
@@ -32,8 +33,10 @@
 #include "aos/util/log_interval.h"
 #include "aos/util/phased_loop.h"
 #include "aos/util/wrapping_counter.h"
+#include "ctre/phoenix/cci/Diagnostics_CCI.h"
 #include "ctre/phoenix/motorcontrol/can/TalonFX.h"
 #include "ctre/phoenix/motorcontrol/can/TalonSRX.h"
+#include "ctre/phoenixpro/TalonFX.hpp"
 #include "frc971/autonomous/auto_mode_generated.h"
 #include "frc971/control_loops/drivetrain/drivetrain_position_generated.h"
 #include "frc971/input/robot_state_generated.h"
@@ -51,12 +54,18 @@
 #include "frc971/wpilib/sensor_reader.h"
 #include "frc971/wpilib/wpilib_robot_base.h"
 #include "y2023/constants.h"
+#include "y2023/control_loops/drivetrain/drivetrain_can_position_generated.h"
 #include "y2023/control_loops/superstructure/superstructure_output_generated.h"
 #include "y2023/control_loops/superstructure/superstructure_position_generated.h"
 
+DEFINE_bool(ctre_diag_server, false,
+            "If true, enable the diagnostics server for interacting with "
+            "devices on the CAN bus using Phoenix Tuner");
+
 using ::aos::monotonic_clock;
 using ::y2023::constants::Values;
 namespace superstructure = ::y2023::control_loops::superstructure;
+namespace drivetrain = ::y2023::control_loops::drivetrain;
 namespace chrono = ::std::chrono;
 using std::make_unique;
 
@@ -77,12 +86,27 @@
          control_loops::drivetrain::kWheelRadius;
 }
 
-constexpr double kMaxFastEncoderPulsesPerSecond =
-    /*std::max({*/ Values::kMaxDrivetrainEncoderPulsesPerSecond() /*,
-                  Values::kMaxIntakeEncoderPulsesPerSecond()})*/
-    ;
-/*static_assert(kMaxFastEncoderPulsesPerSecond <= 1300000,
-            "fast encoders are too fast");*/
+double proximal_pot_translate(double voltage) {
+  return voltage * Values::kProximalPotRadiansPerVolt();
+}
+
+double distal_pot_translate(double voltage) {
+  return voltage * Values::kDistalPotRadiansPerVolt();
+}
+
+double roll_joint_pot_translate(double voltage) {
+  return voltage * Values::kRollJointPotRadiansPerVolt();
+}
+
+constexpr double kMaxFastEncoderPulsesPerSecond = std::max({
+    Values::kMaxDrivetrainEncoderPulsesPerSecond(),
+    Values::kMaxProximalEncoderPulsesPerSecond(),
+    Values::kMaxDistalEncoderPulsesPerSecond(),
+    Values::kMaxRollJointEncoderPulsesPerSecond(),
+    Values::kMaxWristEncoderPulsesPerSecond(),
+});
+static_assert(kMaxFastEncoderPulsesPerSecond <= 1300000,
+              "fast encoders are too fast");
 
 }  // namespace
 
@@ -122,9 +146,59 @@
     autonomous_modes_.at(i) = ::std::move(sensor);
   }
 
+  void set_heading_input(::std::unique_ptr<frc::DigitalInput> sensor) {
+    imu_heading_input_ = ::std::move(sensor);
+    imu_heading_reader_.set_input(imu_heading_input_.get());
+  }
+
+  void set_yaw_rate_input(::std::unique_ptr<frc::DigitalInput> sensor) {
+    imu_yaw_rate_input_ = ::std::move(sensor);
+    imu_yaw_rate_reader_.set_input(imu_yaw_rate_input_.get());
+  }
+
   void RunIteration() override {
     superstructure_reading_->Set(true);
-    { auto builder = superstructure_position_sender_.MakeBuilder(); }
+    {
+      auto builder = superstructure_position_sender_.MakeBuilder();
+      frc971::PotAndAbsolutePositionT proximal;
+      CopyPosition(proximal_encoder_, &proximal,
+                   Values::kProximalEncoderCountsPerRevolution(),
+                   Values::kProximalEncoderRatio(), proximal_pot_translate,
+                   true, values_->arm_proximal.potentiometer_offset);
+      frc971::PotAndAbsolutePositionT distal;
+      CopyPosition(distal_encoder_, &distal,
+                   Values::kDistalEncoderCountsPerRevolution(),
+                   Values::kDistalEncoderRatio(), distal_pot_translate, true,
+                   values_->arm_distal.potentiometer_offset);
+      frc971::PotAndAbsolutePositionT roll_joint;
+      CopyPosition(roll_joint_encoder_, &roll_joint,
+                   Values::kRollJointEncoderCountsPerRevolution(),
+                   Values::kRollJointEncoderRatio(), roll_joint_pot_translate,
+                   true, values_->roll_joint.potentiometer_offset);
+      frc971::AbsolutePositionT wrist;
+      CopyPosition(wrist_encoder_, &wrist,
+                   Values::kWristEncoderCountsPerRevolution(),
+                   Values::kWristEncoderRatio(), false);
+
+      flatbuffers::Offset<frc971::PotAndAbsolutePosition> proximal_offset =
+          frc971::PotAndAbsolutePosition::Pack(*builder.fbb(), &proximal);
+      flatbuffers::Offset<frc971::PotAndAbsolutePosition> distal_offset =
+          frc971::PotAndAbsolutePosition::Pack(*builder.fbb(), &distal);
+      flatbuffers::Offset<frc971::PotAndAbsolutePosition> roll_joint_offset =
+          frc971::PotAndAbsolutePosition::Pack(*builder.fbb(), &roll_joint);
+      flatbuffers::Offset<superstructure::ArmPosition> arm_offset =
+          superstructure::CreateArmPosition(*builder.fbb(), proximal_offset,
+                                            distal_offset, roll_joint_offset);
+      flatbuffers::Offset<frc971::AbsolutePosition> wrist_offset =
+          frc971::AbsolutePosition::Pack(*builder.fbb(), &wrist);
+
+      superstructure::Position::Builder position_builder =
+          builder.MakeBuilder<superstructure::Position>();
+
+      position_builder.add_arm(arm_offset);
+      position_builder.add_wrist(wrist_offset);
+      builder.CheckOk(builder.Send(position_builder.Finish()));
+    }
 
     {
       auto builder = drivetrain_position_sender_.MakeBuilder();
@@ -209,6 +283,61 @@
     superstructure_reading_ = superstructure_reading;
   }
 
+  void set_proximal_encoder(::std::unique_ptr<frc::Encoder> encoder) {
+    fast_encoder_filter_.Add(encoder.get());
+    proximal_encoder_.set_encoder(::std::move(encoder));
+  }
+
+  void set_proximal_absolute_pwm(
+      ::std::unique_ptr<frc::DigitalInput> absolute_pwm) {
+    proximal_encoder_.set_absolute_pwm(::std::move(absolute_pwm));
+  }
+
+  void set_proximal_potentiometer(
+      ::std::unique_ptr<frc::AnalogInput> potentiometer) {
+    proximal_encoder_.set_potentiometer(::std::move(potentiometer));
+  }
+
+  void set_distal_encoder(::std::unique_ptr<frc::Encoder> encoder) {
+    fast_encoder_filter_.Add(encoder.get());
+    distal_encoder_.set_encoder(::std::move(encoder));
+  }
+
+  void set_distal_absolute_pwm(
+      ::std::unique_ptr<frc::DigitalInput> absolute_pwm) {
+    distal_encoder_.set_absolute_pwm(::std::move(absolute_pwm));
+  }
+
+  void set_distal_potentiometer(
+      ::std::unique_ptr<frc::AnalogInput> potentiometer) {
+    distal_encoder_.set_potentiometer(::std::move(potentiometer));
+  }
+
+  void set_roll_joint_encoder(::std::unique_ptr<frc::Encoder> encoder) {
+    fast_encoder_filter_.Add(encoder.get());
+    roll_joint_encoder_.set_encoder(::std::move(encoder));
+  }
+
+  void set_roll_joint_absolute_pwm(
+      ::std::unique_ptr<frc::DigitalInput> absolute_pwm) {
+    roll_joint_encoder_.set_absolute_pwm(::std::move(absolute_pwm));
+  }
+
+  void set_roll_joint_potentiometer(
+      ::std::unique_ptr<frc::AnalogInput> potentiometer) {
+    roll_joint_encoder_.set_potentiometer(::std::move(potentiometer));
+  }
+
+  void set_wrist_encoder(::std::unique_ptr<frc::Encoder> encoder) {
+    fast_encoder_filter_.Add(encoder.get());
+    wrist_encoder_.set_encoder(::std::move(encoder));
+  }
+
+  void set_wrist_absolute_pwm(
+      ::std::unique_ptr<frc::DigitalInput> absolute_pwm) {
+    wrist_encoder_.set_absolute_pwm(::std::move(absolute_pwm));
+  }
+
  private:
   std::shared_ptr<const Values> values_;
 
@@ -220,7 +349,13 @@
 
   std::array<std::unique_ptr<frc::DigitalInput>, 2> autonomous_modes_;
 
+  std::unique_ptr<frc::DigitalInput> imu_heading_input_, imu_yaw_rate_input_;
+
   frc971::wpilib::DMAPulseWidthReader imu_heading_reader_, imu_yaw_rate_reader_;
+
+  frc971::wpilib::AbsoluteEncoderAndPotentiometer proximal_encoder_,
+      distal_encoder_, roll_joint_encoder_;
+  frc971::wpilib::AbsoluteEncoder wrist_encoder_;
 };
 
 class SuperstructureWriter
@@ -237,10 +372,55 @@
     superstructure_reading_ = superstructure_reading;
   }
 
- private:
-  void Stop() override { AOS_LOG(WARNING, "Superstructure output too old.\n"); }
+  void set_proximal_falcon(::std::unique_ptr<::frc::TalonFX> t) {
+    proximal_falcon_ = ::std::move(t);
+  }
 
-  void Write(const superstructure::Output &output) override { (void)output; }
+  void set_distal_falcon(::std::unique_ptr<::frc::TalonFX> t) {
+    distal_falcon_ = ::std::move(t);
+  }
+
+  void set_roll_joint_victor(::std::unique_ptr<::frc::VictorSP> t) {
+    roll_joint_victor_ = ::std::move(t);
+  }
+
+  void set_wrist_victor(::std::unique_ptr<::frc::VictorSP> t) {
+    wrist_victor_ = ::std::move(t);
+  }
+
+  void set_roller_falcon(
+      ::std::unique_ptr<::ctre::phoenix::motorcontrol::can::TalonFX> t) {
+    roller_falcon_ = ::std::move(t);
+    roller_falcon_->ConfigSupplyCurrentLimit(
+        {true, Values::kRollerSupplyCurrentLimit(),
+         Values::kRollerSupplyCurrentLimit(), 0});
+    roller_falcon_->ConfigStatorCurrentLimit(
+        {true, Values::kRollerStatorCurrentLimit(),
+         Values::kRollerStatorCurrentLimit(), 0});
+  }
+
+ private:
+  void Stop() override {
+    AOS_LOG(WARNING, "Superstructure output too old.\n");
+    proximal_falcon_->SetDisabled();
+    distal_falcon_->SetDisabled();
+    roll_joint_victor_->SetDisabled();
+    wrist_victor_->SetDisabled();
+    if (roller_falcon_) {
+      roller_falcon_->Set(ctre::phoenix::motorcontrol::ControlMode::Disabled,
+                          0);
+    }
+  }
+
+  void Write(const superstructure::Output &output) override {
+    WritePwm(output.proximal_voltage(), proximal_falcon_.get());
+    WritePwm(output.distal_voltage(), distal_falcon_.get());
+    WritePwm(-output.roll_joint_voltage(), roll_joint_victor_.get());
+    WritePwm(output.wrist_voltage(), wrist_victor_.get());
+    if (roller_falcon_) {
+      WriteCan(output.roller_voltage(), roller_falcon_.get());
+    }
+  }
 
   static void WriteCan(const double voltage,
                        ::ctre::phoenix::motorcontrol::can::TalonFX *falcon) {
@@ -254,8 +434,333 @@
     motor->SetSpeed(std::clamp(voltage, -kMaxBringupPower, kMaxBringupPower) /
                     12.0);
   }
+
+  ::std::unique_ptr<::frc::TalonFX> proximal_falcon_, distal_falcon_;
+  ::std::unique_ptr<::frc::VictorSP> roll_joint_victor_, wrist_victor_;
+  ::std::shared_ptr<::ctre::phoenix::motorcontrol::can::TalonFX> roller_falcon_;
 };
 
+static constexpr int kCANFalconCount = 6;
+static constexpr int kCANSignalsCount = 4;
+static constexpr units::frequency::hertz_t kCANUpdateFreqHz = 200_Hz;
+
+class Falcon {
+ public:
+  Falcon(int device_id, std::string canbus,
+         aos::SizedArray<ctre::phoenixpro::BaseStatusSignalValue *,
+                         kCANFalconCount * kCANSignalsCount> *signals)
+      : talon_(device_id, canbus),
+        device_id_(device_id),
+        device_temp_(talon_.GetDeviceTemp()),
+        supply_voltage_(talon_.GetSupplyVoltage()),
+        supply_current_(talon_.GetSupplyCurrent()),
+        torque_current_(talon_.GetTorqueCurrent()),
+        position_(talon_.GetPosition()) {
+    // device temp is not timesynced so don't add it to the list of signals
+    device_temp_.SetUpdateFrequency(kCANUpdateFreqHz);
+
+    CHECK_EQ(kCANSignalsCount, 4);
+    CHECK_NOTNULL(signals);
+    CHECK_LE(signals->size() + 4u, signals->capacity());
+
+    supply_voltage_.SetUpdateFrequency(kCANUpdateFreqHz);
+    signals->push_back(&supply_voltage_);
+
+    supply_current_.SetUpdateFrequency(kCANUpdateFreqHz);
+    signals->push_back(&supply_current_);
+
+    torque_current_.SetUpdateFrequency(kCANUpdateFreqHz);
+    signals->push_back(&torque_current_);
+
+    position_.SetUpdateFrequency(kCANUpdateFreqHz);
+    signals->push_back(&position_);
+  }
+
+  void WriteConfigs(ctre::phoenixpro::signals::InvertedValue invert) {
+    inverted_ = invert;
+
+    ctre::phoenixpro::configs::CurrentLimitsConfigs current_limits;
+    current_limits.StatorCurrentLimit =
+        constants::Values::kDrivetrainStatorCurrentLimit();
+    current_limits.StatorCurrentLimitEnable = true;
+    current_limits.SupplyCurrentLimit =
+        constants::Values::kDrivetrainSupplyCurrentLimit();
+    current_limits.SupplyCurrentLimitEnable = true;
+
+    ctre::phoenixpro::configs::MotorOutputConfigs output_configs;
+    output_configs.NeutralMode =
+        ctre::phoenixpro::signals::NeutralModeValue::Brake;
+    output_configs.DutyCycleNeutralDeadband = 0;
+
+    output_configs.Inverted = inverted_;
+
+    ctre::phoenixpro::configs::TalonFXConfiguration configuration;
+    configuration.CurrentLimits = current_limits;
+    configuration.MotorOutput = output_configs;
+
+    ctre::phoenix::StatusCode status =
+        talon_.GetConfigurator().Apply(configuration);
+    if (!status.IsOK()) {
+      AOS_LOG(ERROR, "Failed to set falcon configuration: %s: %s",
+              status.GetName(), status.GetDescription());
+    }
+  }
+
+  ctre::phoenixpro::hardware::TalonFX *talon() { return &talon_; }
+
+  flatbuffers::Offset<drivetrain::CANFalcon> WritePosition(
+      flatbuffers::FlatBufferBuilder *fbb) {
+    drivetrain::CANFalcon::Builder builder(*fbb);
+    builder.add_id(device_id_);
+    builder.add_device_temp(device_temp_.GetValue().value());
+    builder.add_supply_voltage(supply_voltage_.GetValue().value());
+    builder.add_supply_current(supply_current_.GetValue().value());
+    builder.add_torque_current(torque_current_.GetValue().value());
+
+    double invert =
+        (inverted_ ==
+                 ctre::phoenixpro::signals::InvertedValue::Clockwise_Positive
+             ? 1
+             : -1);
+
+    builder.add_position(constants::Values::DrivetrainCANEncoderToMeters(
+                             position_.GetValue().value()) *
+                         invert);
+
+    return builder.Finish();
+  }
+
+  // returns the monotonic timestamp of the latest timesynced reading in the
+  // timebase of the the syncronized CAN bus clock.
+  int64_t GetTimestamp() {
+    std::chrono::nanoseconds latest_timestamp =
+        torque_current_.GetTimestamp().GetTime();
+
+    return latest_timestamp.count();
+  }
+
+  void RefreshNontimesyncedSignals() { device_temp_.Refresh(); };
+
+ private:
+  ctre::phoenixpro::hardware::TalonFX talon_;
+  int device_id_;
+
+  ctre::phoenixpro::signals::InvertedValue inverted_;
+
+  ctre::phoenixpro::StatusSignalValue<units::temperature::celsius_t>
+      device_temp_;
+  ctre::phoenixpro::StatusSignalValue<units::voltage::volt_t> supply_voltage_;
+  ctre::phoenixpro::StatusSignalValue<units::current::ampere_t> supply_current_,
+      torque_current_;
+  ctre::phoenixpro::StatusSignalValue<units::angle::turn_t> position_;
+};
+
+class CANSensorReader {
+ public:
+  CANSensorReader(aos::EventLoop *event_loop)
+      : event_loop_(event_loop),
+        can_position_sender_(
+            event_loop->MakeSender<drivetrain::CANPosition>("/drivetrain")) {
+    event_loop->SetRuntimeRealtimePriority(35);
+    timer_handler_ = event_loop->AddTimer([this]() { Loop(); });
+    timer_handler_->set_name("CANSensorReader Loop");
+
+    event_loop->OnRun([this]() {
+      timer_handler_->Setup(event_loop_->monotonic_now(), 1 / kCANUpdateFreqHz);
+    });
+  }
+
+  aos::SizedArray<ctre::phoenixpro::BaseStatusSignalValue *,
+                  kCANFalconCount * kCANSignalsCount>
+      *get_signals_registry() {
+    return &signals_;
+  };
+
+  void set_falcons(std::shared_ptr<Falcon> right_front,
+                   std::shared_ptr<Falcon> right_back,
+                   std::shared_ptr<Falcon> right_under,
+                   std::shared_ptr<Falcon> left_front,
+                   std::shared_ptr<Falcon> left_back,
+                   std::shared_ptr<Falcon> left_under) {
+    right_front_ = std::move(right_front);
+    right_back_ = std::move(right_back);
+    right_under_ = std::move(right_under);
+    left_front_ = std::move(left_front);
+    left_back_ = std::move(left_back);
+    left_under_ = std::move(left_under);
+  }
+
+ private:
+  void Loop() {
+    CHECK_EQ(signals_.size(), 24u);
+    ctre::phoenix::StatusCode status =
+        ctre::phoenixpro::BaseStatusSignalValue::WaitForAll(
+            2000_ms, {signals_[0],  signals_[1],  signals_[2],  signals_[3],
+                      signals_[4],  signals_[5],  signals_[6],  signals_[7],
+                      signals_[8],  signals_[9],  signals_[10], signals_[11],
+                      signals_[12], signals_[13], signals_[14], signals_[15],
+                      signals_[16], signals_[17], signals_[18], signals_[19],
+                      signals_[20], signals_[21], signals_[22], signals_[23]});
+
+    if (!status.IsOK()) {
+      AOS_LOG(ERROR, "Failed to read signals from falcons: %s: %s",
+              status.GetName(), status.GetDescription());
+    }
+
+    auto builder = can_position_sender_.MakeBuilder();
+
+    for (auto falcon : {right_front_, right_back_, right_under_, left_front_,
+                        left_back_, left_under_}) {
+      falcon->RefreshNontimesyncedSignals();
+    }
+
+    aos::SizedArray<flatbuffers::Offset<drivetrain::CANFalcon>, kCANFalconCount>
+        falcons;
+
+    for (auto falcon : {right_front_, right_back_, right_under_, left_front_,
+                        left_back_, left_under_}) {
+      falcons.push_back(falcon->WritePosition(builder.fbb()));
+    }
+
+    auto falcons_list =
+        builder.fbb()->CreateVector<flatbuffers::Offset<drivetrain::CANFalcon>>(
+            falcons);
+
+    drivetrain::CANPosition::Builder can_position_builder =
+        builder.MakeBuilder<drivetrain::CANPosition>();
+
+    can_position_builder.add_falcons(falcons_list);
+    can_position_builder.add_timestamp(right_front_->GetTimestamp());
+    can_position_builder.add_status(static_cast<int>(status));
+
+    builder.CheckOk(builder.Send(can_position_builder.Finish()));
+  }
+
+  aos::EventLoop *event_loop_;
+
+  aos::SizedArray<ctre::phoenixpro::BaseStatusSignalValue *,
+                  kCANFalconCount * kCANSignalsCount>
+      signals_;
+  aos::Sender<drivetrain::CANPosition> can_position_sender_;
+
+  std::shared_ptr<Falcon> right_front_, right_back_, right_under_, left_front_,
+      left_back_, left_under_;
+
+  // Pointer to the timer handler used to modify the wakeup.
+  ::aos::TimerHandler *timer_handler_;
+};
+
+class DrivetrainWriter : public ::frc971::wpilib::LoopOutputHandler<
+                             ::frc971::control_loops::drivetrain::Output> {
+ public:
+  DrivetrainWriter(::aos::EventLoop *event_loop)
+      : ::frc971::wpilib::LoopOutputHandler<
+            ::frc971::control_loops::drivetrain::Output>(event_loop,
+                                                         "/drivetrain") {
+    event_loop->SetRuntimeRealtimePriority(
+        constants::Values::kDrivetrainWriterPriority);
+
+    if (!FLAGS_ctre_diag_server) {
+      c_Phoenix_Diagnostics_SetSecondsToStart(-1);
+      c_Phoenix_Diagnostics_Dispose();
+    }
+
+    ctre::phoenix::platform::can::CANComm_SetRxSchedPriority(
+        constants::Values::kDrivetrainRxPriority, true, "Drivetrain Bus");
+    ctre::phoenix::platform::can::CANComm_SetTxSchedPriority(
+        constants::Values::kDrivetrainTxPriority, true, "Drivetrain Bus");
+
+    event_loop->OnRun([this]() { WriteConfigs(); });
+  }
+
+  void set_falcons(std::shared_ptr<Falcon> right_front,
+                   std::shared_ptr<Falcon> right_back,
+                   std::shared_ptr<Falcon> right_under,
+                   std::shared_ptr<Falcon> left_front,
+                   std::shared_ptr<Falcon> left_back,
+                   std::shared_ptr<Falcon> left_under) {
+    right_front_ = std::move(right_front);
+    right_back_ = std::move(right_back);
+    right_under_ = std::move(right_under);
+    left_front_ = std::move(left_front);
+    left_back_ = std::move(left_back);
+    left_under_ = std::move(left_under);
+  }
+
+  void set_right_inverted(ctre::phoenixpro::signals::InvertedValue invert) {
+    right_inverted_ = invert;
+  }
+
+  void set_left_inverted(ctre::phoenixpro::signals::InvertedValue invert) {
+    left_inverted_ = invert;
+  }
+
+ private:
+  void WriteConfigs() {
+    for (auto falcon :
+         {right_front_.get(), right_back_.get(), right_under_.get()}) {
+      falcon->WriteConfigs(right_inverted_);
+    }
+
+    for (auto falcon :
+         {left_front_.get(), left_back_.get(), left_under_.get()}) {
+      falcon->WriteConfigs(left_inverted_);
+    }
+  }
+
+  void Write(
+      const ::frc971::control_loops::drivetrain::Output &output) override {
+    ctre::phoenixpro::controls::DutyCycleOut left_control(
+        SafeSpeed(output.left_voltage()));
+    left_control.UpdateFreqHz = 0_Hz;
+    left_control.EnableFOC = true;
+
+    ctre::phoenixpro::controls::DutyCycleOut right_control(
+        SafeSpeed(output.right_voltage()));
+    right_control.UpdateFreqHz = 0_Hz;
+    right_control.EnableFOC = true;
+
+    for (auto falcon :
+         {left_front_.get(), left_back_.get(), left_under_.get()}) {
+      ctre::phoenix::StatusCode status =
+          falcon->talon()->SetControl(left_control);
+
+      if (!status.IsOK()) {
+        AOS_LOG(ERROR, "Failed to write control to falcon: %s: %s",
+                status.GetName(), status.GetDescription());
+      }
+    }
+
+    for (auto falcon :
+         {right_front_.get(), right_back_.get(), right_under_.get()}) {
+      ctre::phoenix::StatusCode status =
+          falcon->talon()->SetControl(right_control);
+
+      if (!status.IsOK()) {
+        AOS_LOG(ERROR, "Failed to write control to falcon: %s: %s",
+                status.GetName(), status.GetDescription());
+      }
+    }
+  }
+
+  void Stop() override {
+    AOS_LOG(WARNING, "drivetrain output too old\n");
+    ctre::phoenixpro::controls::NeutralOut stop_command;
+    for (auto falcon :
+         {right_front_.get(), right_back_.get(), right_under_.get(),
+          left_front_.get(), left_back_.get(), left_under_.get()}) {
+      falcon->talon()->SetControl(stop_command);
+    }
+  }
+
+  double SafeSpeed(double voltage) {
+    return (::aos::Clip(voltage, -kMaxBringupPower, kMaxBringupPower) / 12.0);
+  }
+
+  ctre::phoenixpro::signals::InvertedValue left_inverted_, right_inverted_;
+  std::shared_ptr<Falcon> right_front_, right_back_, right_under_, left_front_,
+      left_back_, left_under_;
+};
 
 class WPILibRobot : public ::frc971::wpilib::WPILibRobotBase {
  public:
@@ -272,6 +777,11 @@
         aos::configuration::ReadConfig("aos_config.json");
 
     // Thread 1.
+    ::aos::ShmEventLoop joystick_sender_event_loop(&config.message());
+    ::frc971::wpilib::JoystickSender joystick_sender(
+        &joystick_sender_event_loop);
+    AddLoop(&joystick_sender_event_loop);
+
     // Thread 2.
     ::aos::ShmEventLoop pdp_fetcher_event_loop(&config.message());
     ::frc971::wpilib::PDPFetcher pdp_fetcher(&pdp_fetcher_event_loop);
@@ -287,15 +797,72 @@
     sensor_reader.set_drivetrain_left_encoder(make_encoder(1));
     sensor_reader.set_drivetrain_right_encoder(make_encoder(0));
     sensor_reader.set_superstructure_reading(superstructure_reading);
+    sensor_reader.set_heading_input(make_unique<frc::DigitalInput>(1));
+    sensor_reader.set_yaw_rate_input(make_unique<frc::DigitalInput>(0));
+
+    sensor_reader.set_proximal_encoder(make_encoder(3));
+    sensor_reader.set_proximal_absolute_pwm(make_unique<frc::DigitalInput>(3));
+    sensor_reader.set_proximal_potentiometer(make_unique<frc::AnalogInput>(3));
+
+    sensor_reader.set_distal_encoder(make_encoder(2));
+    sensor_reader.set_distal_absolute_pwm(make_unique<frc::DigitalInput>(2));
+    sensor_reader.set_distal_potentiometer(make_unique<frc::AnalogInput>(2));
+
+    sensor_reader.set_roll_joint_encoder(make_encoder(5));
+    sensor_reader.set_roll_joint_absolute_pwm(
+        make_unique<frc::DigitalInput>(5));
+    sensor_reader.set_roll_joint_potentiometer(
+        make_unique<frc::AnalogInput>(5));
+
+    sensor_reader.set_wrist_encoder(make_encoder(4));
+    sensor_reader.set_wrist_absolute_pwm(make_unique<frc::DigitalInput>(4));
 
     AddLoop(&sensor_reader_event_loop);
 
     // Thread 4.
-    ::aos::ShmEventLoop output_event_loop(&config.message());
-    ::frc971::wpilib::DrivetrainWriter drivetrain_writer(&output_event_loop);
+    ::aos::ShmEventLoop can_sensor_reader_event_loop(&config.message());
+    CANSensorReader can_sensor_reader(&can_sensor_reader_event_loop);
 
+    std::shared_ptr<Falcon> right_front = std::make_shared<Falcon>(
+        1, "Drivetrain Bus", can_sensor_reader.get_signals_registry());
+    std::shared_ptr<Falcon> right_back = std::make_shared<Falcon>(
+        2, "Drivetrain Bus", can_sensor_reader.get_signals_registry());
+    std::shared_ptr<Falcon> right_under = std::make_shared<Falcon>(
+        3, "Drivetrain Bus", can_sensor_reader.get_signals_registry());
+    std::shared_ptr<Falcon> left_front = std::make_shared<Falcon>(
+        4, "Drivetrain Bus", can_sensor_reader.get_signals_registry());
+    std::shared_ptr<Falcon> left_back = std::make_shared<Falcon>(
+        5, "Drivetrain Bus", can_sensor_reader.get_signals_registry());
+    std::shared_ptr<Falcon> left_under = std::make_shared<Falcon>(
+        6, "Drivetrain Bus", can_sensor_reader.get_signals_registry());
+
+    can_sensor_reader.set_falcons(right_front, right_back, right_under,
+                                  left_front, left_back, left_under);
+
+    AddLoop(&can_sensor_reader_event_loop);
+
+    // Thread 5.
+    ::aos::ShmEventLoop output_event_loop(&config.message());
+    DrivetrainWriter drivetrain_writer(&output_event_loop);
+
+    drivetrain_writer.set_falcons(right_front, right_back, right_under,
+                                  left_front, left_back, left_under);
+    drivetrain_writer.set_right_inverted(
+        ctre::phoenixpro::signals::InvertedValue::Clockwise_Positive);
+    drivetrain_writer.set_left_inverted(
+        ctre::phoenixpro::signals::InvertedValue::CounterClockwise_Positive);
     SuperstructureWriter superstructure_writer(&output_event_loop);
 
+    superstructure_writer.set_proximal_falcon(make_unique<::frc::TalonFX>(1));
+    superstructure_writer.set_distal_falcon(make_unique<::frc::TalonFX>(0));
+
+    superstructure_writer.set_roll_joint_victor(
+        make_unique<::frc::VictorSP>(3));
+    superstructure_writer.set_wrist_victor(make_unique<::frc::VictorSP>(2));
+
+    superstructure_writer.set_roller_falcon(
+        make_unique<::ctre::phoenix::motorcontrol::can::TalonFX>(0));
+
     superstructure_writer.set_superstructure_reading(superstructure_reading);
 
     AddLoop(&output_event_loop);
diff --git a/y2023/www/BUILD b/y2023/www/BUILD
index 5dae3c6..539fa6d 100644
--- a/y2023/www/BUILD
+++ b/y2023/www/BUILD
@@ -1,5 +1,4 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
 load("//frc971/downloader:downloader.bzl", "aos_downloader_dir")
 
 filegroup(
@@ -12,7 +11,7 @@
     visibility = ["//visibility:public"],
 )
 
-ts_library(
+ts_project(
     name = "field_main",
     srcs = [
         "constants.ts",
diff --git a/y2023/www/field_main.ts b/y2023/www/field_main.ts
index 7e2e392..d71a45e 100644
--- a/y2023/www/field_main.ts
+++ b/y2023/www/field_main.ts
@@ -1,4 +1,4 @@
-import {Connection} from 'org_frc971/aos/network/www/proxy';
+import {Connection} from '../../aos/network/www/proxy';
 
 import {FieldHandler} from './field_handler';
 
diff --git a/y2023/y2023.json b/y2023/y2023.json
index cdac47c..d5f9462 100644
--- a/y2023/y2023.json
+++ b/y2023/y2023.json
@@ -1,5 +1,5 @@
 {
-  "channel_storage_duration": 8000000000,
+  "channel_storage_duration": 10000000000,
   "maps": [
     {
       "match": {
diff --git a/y2023/y2023_imu.json b/y2023/y2023_imu.json
index 6940479..b521e07 100644
--- a/y2023/y2023_imu.json
+++ b/y2023/y2023_imu.json
@@ -392,12 +392,12 @@
   "nodes": [
     {
       "name": "imu",
-      "hostname": "imu",
+      "hostname": "pi6",
       "hostnames": [
-        "pi-971-5",
-        "pi-7971-5",
-        "pi-8971-5",
-        "pi-9971-5"
+        "pi-971-6",
+        "pi-7971-6",
+        "pi-8971-6",
+        "pi-9971-6"
       ],
       "port": 9971
     },
diff --git a/y2023/y2023_logger.json b/y2023/y2023_logger.json
index c73379d..5386a1d 100644
--- a/y2023/y2023_logger.json
+++ b/y2023/y2023_logger.json
@@ -368,13 +368,12 @@
     {
       "name": "/logger/camera",
       "type": "frc971.vision.CameraImage",
-      "logger": "NOT_LOGGED",
       "source_node": "logger",
-      "frequency": 100,
-      "max_size": 2600000,
+      "frequency": 40,
+      "max_size": 1843456,
       "num_readers": 4,
       "read_method": "PIN",
-      "num_senders": 1
+      "num_senders": 18
     },
     {
       "name": "/logger/camera/downsized",
@@ -443,30 +442,23 @@
   ],
   "applications": [
     {
-      "name": "logger_message_bridge_client",
-      "autostart": false,
-      "executable_name": "message_bridge_client.sh",
-      "args": [
-        "--rmem=8388608",
-        "--rt_priority=16"
-      ],
+      "name": "message_bridge_client",
+      "executable_name": "message_bridge_client",
+      "user": "pi",
       "nodes": [
         "logger"
       ]
     },
     {
-      "name": "logger_message_bridge_server",
+      "name": "message_bridge_server",
       "executable_name": "message_bridge_server",
-      "autostart": false,
-      "args": [
-        "--rt_priority=16"
-      ],
+      "user": "pi",
       "nodes": [
         "logger"
       ]
     },
     {
-      "name": "camera_reader",
+      "name": "logger_camera_reader",
       "executable_name": "camera_reader",
       "args": ["--enable_ftrace", "--send_downsized_images"],
       "nodes": [
@@ -508,14 +500,11 @@
   "nodes": [
     {
       "name": "logger",
-      "hostname": "pi6",
+      "hostname": "pi5",
       "hostnames": [
-        "pi-971-6",
-        "pi-9971-6",
-        "pi-7971-6",
-        "ASchuh-T480s",
-        "tarvalon",
-        "aschuh-3950x"
+        "pi-971-5",
+        "pi-9971-5",
+        "pi-7971-5"
       ],
       "port": 9971
     },
diff --git a/y2023/y2023_roborio.json b/y2023/y2023_roborio.json
index 572aadc..09873dc 100644
--- a/y2023/y2023_roborio.json
+++ b/y2023/y2023_roborio.json
@@ -36,7 +36,7 @@
       "type": "aos.message_bridge.RemoteMessage",
       "source_node": "roborio",
       "logger": "NOT_LOGGED",
-      "frequency": 200,
+      "frequency": 300,
       "num_senders": 2,
       "max_size": 200
     },
@@ -45,7 +45,7 @@
       "type": "aos.message_bridge.RemoteMessage",
       "source_node": "roborio",
       "logger": "NOT_LOGGED",
-      "frequency": 200,
+      "frequency": 300,
       "num_senders": 2,
       "max_size": 200
     },
@@ -53,7 +53,7 @@
       "name": "/roborio/aos",
       "type": "aos.RobotState",
       "source_node": "roborio",
-      "frequency": 200
+      "frequency": 250
     },
     {
       "name": "/roborio/aos",
@@ -61,7 +61,7 @@
       "source_node": "roborio",
       "frequency": 50,
       "num_senders": 20,
-      "max_size": 4096
+      "max_size": 8192
     },
     {
       "name": "/roborio/aos",
@@ -160,7 +160,7 @@
     {
       "name": "/roborio/aos/remote_timestamps/logger/roborio/aos/aos-message_bridge-Timestamp",
       "type": "aos.message_bridge.RemoteMessage",
-      "frequency": 200,
+      "frequency": 300,
       "source_node": "roborio"
     },
     {
@@ -259,7 +259,7 @@
       "name": "/superstructure",
       "type": "y2023.control_loops.superstructure.Goal",
       "source_node": "roborio",
-      "frequency": 200,
+      "frequency": 250,
       "max_size": 512
     },
     {
@@ -273,7 +273,7 @@
       "name": "/superstructure",
       "type": "y2023.control_loops.superstructure.Output",
       "source_node": "roborio",
-      "frequency": 200,
+      "frequency": 250,
       "num_senders": 2,
       "max_size": 224
     },
@@ -281,22 +281,30 @@
       "name": "/superstructure",
       "type": "y2023.control_loops.superstructure.Position",
       "source_node": "roborio",
-      "frequency": 200,
+      "frequency": 250,
       "num_senders": 2,
       "max_size": 448
     },
     {
       "name": "/drivetrain",
+      "type": "y2023.control_loops.drivetrain.CANPosition",
+      "source_node": "roborio",
+      "frequency": 220,
+      "num_senders": 2,
+      "max_size": 400
+    },
+    {
+      "name": "/drivetrain",
       "type": "frc971.sensors.GyroReading",
       "source_node": "roborio",
-      "frequency": 200,
+      "frequency": 250,
       "num_senders": 2
     },
     {
       "name": "/drivetrain",
       "type": "frc971.sensors.Uid",
       "source_node": "roborio",
-      "frequency": 200,
+      "frequency": 250,
       "num_senders": 2
     },
     {
@@ -321,7 +329,7 @@
       "type": "frc971.control_loops.drivetrain.Goal",
       "source_node": "roborio",
       "max_size": 224,
-      "frequency": 200
+      "frequency": 250
     },
     {
       "name": "/drivetrain",
@@ -375,7 +383,7 @@
       "name": "/drivetrain",
       "type": "frc971.control_loops.drivetrain.LocalizerControl",
       "source_node": "roborio",
-      "frequency": 200,
+      "frequency": 250,
       "max_size": 96,
       "logger": "LOCAL_AND_REMOTE_LOGGER",
       "logger_nodes": [
@@ -421,7 +429,7 @@
       "name": "/autonomous",
       "type": "frc971.autonomous.AutonomousMode",
       "source_node": "roborio",
-      "frequency": 200
+      "frequency": 250
     },
     {
       "name": "/roborio/aos",
@@ -468,6 +476,16 @@
       ]
     },
     {
+      "name": "roborio_irq_affinity",
+      "executable_name": "irq_affinity",
+      "args": [
+        "--irq_config=/home/admin/bin/roborio_irq_config.json"
+      ],
+      "nodes": [
+        "roborio"
+      ]
+    },
+    {
       "name": "joystick_reader",
       "executable_name": "joystick_reader",
       "nodes": [
@@ -490,7 +508,7 @@
       ]
     },
     {
-      "name": "web_proxy",
+      "name": "roborio_web_proxy",
       "executable_name": "web_proxy_main",
       "args": ["--min_ice_port=5800", "--max_ice_port=5810"],
       "nodes": [
@@ -506,7 +524,7 @@
       ]
     },
     {
-      "name": "message_bridge_server",
+      "name": "roborio_message_bridge_server",
       "executable_name": "message_bridge_server",
       "args": ["--rt_priority=16"],
       "nodes": [
@@ -516,13 +534,13 @@
     {
       "name": "logger",
       "executable_name": "logger_main",
-      "args": ["--snappy_compress"],
+      "args": ["--snappy_compress", "--logging_folder=/home/admin/logs/"],
       "nodes": [
         "roborio"
       ]
     },
     {
-      "name": "constants_sender",
+      "name": "constants_sender_roborio",
       "autorestart": false,
       "nodes": [
         "roborio"