Squashed 'third_party/allwpilib/' changes from 83f1860047..f1a82828fe

f1a82828fe [wpiutil] Add DataLog and DataLogManager Stop() (#5860)
2a04e12c6f [apriltag] AprilTagFieldLayout: Add accessors for origin and field dimensions (#5869)
33e0089afb Cleanup usages of std::function<void(void)> (#5864)
d06fa633d5 [build] Fix protobuf generation when building with make (#5867)
049732afb8 [cscore] Make camera connection logging clearer (#5866)
87f7c19f90 [wpimath] Make InterpolatingDoubleTreeMap constructor public (#5865)
6b53ef47cf [wpimath] Don't recreate TrapezoidProfile in ProfiledPIDController calculate() (#5863)
8a3a268ae6 [commands] Add finallyDo with zero-arg lambda (#5862)
1c35d42cd0 [wpilib] Pop diagnostic for deprecated function use (#5859)
ddc8db6c26 [wpimath] Add feedforward constant constructor to ElevatorSim (#5823)
c6aff2c431 [upstream_utils] Update to LLVM 17.0.4 (#5855)
a9c5b18a39 [build] Update OpenCV to 2024-4.8.0-2 (#5854)
9540b6922d [hal] Add CAN IDs for AndyMark and Vivid Hosting (#5852)
83a7d33c47 [glass] Improve display of protobuf/struct type strings (#5850)
a4a8ad9c75 [commands] Make Java SelectCommand generic (#5849)
9eecf2a456 [build] Add CMake option to build Java sources jars (#5768)
9536a311cb [wpilib] Add support for the PS5 DualSense controller (#5257)
8d5e6737fc [wpilibc] SolenoidSim: Add virtual destructor (#5848)
07e13d60a2 [ntcore] Fix write_impl (#5847)
1713386869 [wpiutil] ProtobufMessageDatabase: Fix out-of-order Add() rebuild (#5845)
35472f5fc9 [ntcore] Fix a use-after-free in client close (#5844)
ed168b522c [ntcore] Disable buf pool when asan is enabled (#5843)
3e7ba2cc6f [wpinet] WebSocket: Fix write behavior (#5841)
80c47da237 [sim] Disable the robot program when DS disconnects (#5818)
abe1cec90c [wpilib] Update Usage Reporting ResourceType from NI Libraries (#5842)
cdf981abba [glass] Fix position of data type in NT view (#5840)
04dcd80adb [build] Publish unit tests for examples (#5838)
49920234ac [build] Fix checkstyle rules to allow Windows paths (#5839)
366b715942 [wpilib] Fix SendableChooser test (#5835)
3ba501f947 [commands] Java: Fix CommandXboxController.leftTrigger() parameter order (#5831)
ec569a58ef [wpimath] Make KalmanTypeFilter interface public (#5830)
b91317fd36 [wpiutil] DataLog.addSchema(): Don't add into a set view (#5829)
2ab4fcbc24 [wpiutil] ProtobufMessageDatabase: Clear messages first (#5827)
98c14f1692 [wpimath] Add EKF/UKF u-y-R correct overload (#5832)
60bcdeded9 [ci] Disable java in sanitizer builds (#5833)
c87f8fd538 [commands] Add DeferredCommand (#5566)
ad80eb3a0b [ci] Update actions for comment-command (#5824)
c7d6ad5a0b [ntcore] WebSocketConnection: Use weak capture (#5822)
8a8e220792 [simgui] Add 'Invalid' option for AllianceStation (#5820)
cfc6a47f76 [sim] DS plugin: Fix off-by-one error when setting alliance station (#5819)
8efa586ace [ntcore] Don't check type string on publishing an entry (#5816)
23ea188e60 [glass] Add protobuf decode error log message (#5812)
928e87b4f4 [build] Add combined test meta-task (#5813)
63ef585d4b [wpiutil] Fix compilation of MathExtras.h on Windows with /sdl (#5809)
b03a7668f9 [build] Windows CMake/vcpkg fixes (#5807)
3f08bcde54 [hal] Fix HAL AllianceStation on rio (#5811)
196d963dc4 [ntcore] Fix off-by-one error in stream write (#5810)
f4cbcbc984 Fix typos (NFC) (#5804)
ec0f7fefb0 [myrobot] Update the myRobot JRE (#5805)
3d618bdbfd [wpiutil] Fix Java struct array unpacking (#5801)
1fa7445667 [ntcore] Check for valid client in incoming text and binary (#5799)
269b9647da [ci] Update JDK for combine step (#5794)
bee32f080e [docs] Add wpiunits to JavaDocs (#5793)
25dad5a531 [wpinet] TCPConnector_parallel: Don't use thread_local (#5791)
4a93581f1a [build] cmake: use default library type for libglassnt, libglass, wpigui, and imgui (#5797)
abb2857e03 [wpilib] Counter: Fix default distance per pulse, add distance and rate to C++ (#5796)
b14a61e1c0 [readme] Add link to QuickBuffers release page (#5795)
cf54d9ccb7 [wpiutil, ntcore] Add structured data support (#5391)
ecb7cfa9ef [wpimath] Add Exponential motion profile (#5720)
7c6fe56cf2 [ntcore] Fix crash on disconnect (#5788)
85147bf69e [wpinet] WebSocketSerializer: Fix UB (#5787)
244163acad [wpinet] uv::Stream::TryWrite(): Return 0 on EAGAIN (#5784)
820728503d [hal] Remove extra semicolon in RoboRioData (#5786)
45f307d87e [upstream_utils] Upgrade to LLVM 17.0.3 (#5785)
4ce4d63efc [wpilibj] Fix RobotBase.isSimulation() (#5783)
579007ceb3 [commands] Add requirements parameter to Commands.idle() (#5774)
3f3a169149 [wpilib] Make physics sim setState() functions public (#5779)
7501e4ac88 [wpilib] Close sim device in ADIS IMUs (#5776)
99630d2e78 [wpimath] Upgrade to EJML 0.43.1 (#5778)
02cbbc997d [wpimath] Make Vector-Vector binary operators return Vector (#5772)
ed93889e17 [examples] Fix typo in TimesliceRobot example name (#5773)
da70e4c262 [docs] Add jinja2 to CMake prerequisites (#5771)
e814595ea7 [wpimath] Add ChassisSpeeds.fromRobotRelativeSpeeds() (#5744)
f98c943445 [wpimath] LinearSystemId: Add DCMotorSystem overload (#5770)
b3eb64b0f7 [wpiutil] ct_string: Use inline namespace for literals (#5767)
7d9ba256c2 Revert "[build] Add CMake option to build Java source jars (#5756)" (#5766)
1f6492e3d8 [sysid] Update JSON library usage (#5765)
638f04f626 [wpiutil] Add protobuf to thirdparty sources (#5746)
210255bfff [wpiutil] Update json to 3.11.2 (#5680)
896772c750 [wpimath] Add DCMotor functions for Kraken X60 and Neo Vortex (#5759)
fd427f6c82 [wpimath] Fix hardcoded module count in SwerveDriveKinematics.resetHeading() (#5762)
c0b4c6cce6 [wpimath] Add overloads for Transform2d and Transform3d (#5757)
9a0aafd8ab [examples] Make swerve examples multiply desired module speeds by cosine of heading error (#5758)
1c724884ca [build] Add CMake option to build Java source jars (#5756)
5b0db6b93e [ci] Forward CI as well (#5755)
f8cbbbac12 [ci] Take 2 on passing GITHUB_REF (#5754)
b9944be09c [ci] Pass GITHUB_REF to docker container (#5753)
de5e4eda6c [build] Update apriltag, libssh, googletest for 2024 (#5752)
227e660e20 [upstream_utils] Upgrade to LLVM 17.0.2 (#5750)
36f94c9f21 [commands,romi,xrp] Add frcYear to vendordep (#5747)
741d166457 [glass] NT view: enhance array support (#5732)
1d23513945 [ntcore] Fix string array value comparison (#5745)
ff1849052e [commands] Make command scheduling order consistent (#5470)
58e8474368 [build] Disable armsimulation unit test (#5739)
fb07b0da49 [examples] Add XRP C++ Examples and Templates (#5743)
81893ad73d Run wpiformat with clang-format 17 (#5740)
faa1e665ba [wpimath] Add ElevatorFeedforward.calculate(currentV, nextV) overload (#5715)
a789632052 [build] Update to native utils 2024.3.1 (#5738)
8f60ab5182 [build] Update OpenCV to 2024-4.8.0-1 (#5737)
33243f982b [wpimath] Expand Quaternion class with additional operators (#5600)
420f2f7c80 [ntcore] Add RTT-only subprotocol (#5731)
2b63e35ded [ntcore] Fix moving outgoing queue to new period (#5735)
be939cb636 [ntcore] Fix notification of SetDefaultEntryValue (#5733)
69a54de202 [build] Update enterprise plugin (#5730)
fef03a3ff5 [commands] Clean up C++ includes after Requirements was added (#5719)
8b7c6852cf [ntcore] Networking improvements (#5659)
1d19e09ca9 [wpiutil] Set WPI_{UN}IGNORE_DEPRECATED to empty when all else fails (#5728)
58141d6eb5 [wpilib] Make BooleanEvent more consistent (#5436)
6576d9b474 [wpilib] SendableChooser: implement Sendable instead of NTSendable (#5718)
a4030c670f [build] Update to gradle 8.4, enable win arm builds (#5727)
0960f11eba [wpinet] Revert removal of uv_clock_gettime() (#5723)
cb1bd0a3be [wpiutil] Get more precise system time on Windows (#5722)
4831277ffe [wpigui] Fix loading a maximized window on second monitor (#5721)
3eb372c25a [wpiutil] SendableBuilder: Add PublishConst methods (#5158)
1fec8596a4 [ci] Fix -dirty version (#5716)
f7e47d03f3 [build] Remove unnecessary CMake config installs (#5714)
a331ed2374 [sysid] Add SysId (#5672)
8d2cbfce16 [wpiutil] DataLog: Stop logging if insufficient free space (#5699)
48facb9cef [ntcoreffi] Add DataLogManager (#5702)
aecbcb08fc [ntcore] Correctly start DataLog for existing publishers (#5703)
5e295dfbda [wpiutil] DataLog: Limit total buffer allocation (#5700)
c7c7e05d9d [ci] Unbreak combiner (#5698)
c92bad52cb [wpilib] DataLogManager: Use system time valid function (#5697)
d404af5f24 [wpilib] RobotController: Add isSystemTimeValid() (#5696)
e56f1a3632 [ci] Run combine but skip all steps (#5695)
8f5bcad244 [ci] Use sccache for cmake builds (#5692)
703dedc4a6 [ci] Upgrade get-cmake action to fix node12 deprecation warning (#5694)
c69a0d7504 [ci] Don't run example unit test that segfaults (#5693)
66358d103e Add menu items for online docs to GUI tools (#5689)
4be8384a76 [ci] Disable combine on PR builds (#5691)
90288f06a6 [ci] Fix Gradle disk space issues (#5688)
9e9583412e [wpigui] Make wpi::gui::OpenURL() fork the process first (#5687)
d4fcd80b7b [ci] Gradle: Use container only for build step (#5684)
7b70e66772 [outlineviewer] Fix thirdparty library include sorting (#5683)
5f651df5d5 [build] Clean up Gradle configs (#5685)
65b26738d5 Add CMakeSettings.json to gitignore (#5682)
d0305951ad Fix GitHub inline warnings (#5681)
e8d4a20331 [build][cmake] Fix windows tests and re-enable CI tests (#5674)
2b58bbde0b [xrp] Add Reflectance sensor and rangefinder classes (#5673)
dd5612fbee [json] Add forward definition header (#5676)
eab44534c3 [wpimath] Remove unused SmallString include (#5677)
5ab54ff760 Replace wpi::raw_istream with wpi::MemoryBuffer (#5675)
1b6ec5a95d [wpiutil] Upgrade to LLVM 17.0.1 (#5482)
07a0d22fe6 [build] Build examples in CMake CI (#5667)
97021f074a [build] Upgrade imgui and implot (#5668)
87ce1e3761 [build] Fix wpilibNewCommands CMake install (#5671)
6ef94de9b5 [wpimath] Add tests for ArmFeedforward and ElevatorFeedforward (#5663)
c395b29fb4 [wpinet] Add WebSocket::TrySendFrames() (#5607)
c4643ba047 [romi/xrp] Fix version typo in vendordep json (#5664)
51dcb8b55a [examples] Make Romi/XRP Examples use appropriate vendordeps (#5665)
daf7702007 [build] Test each example in a new environment (#5662)
e67df8c180 [wpilib] Const-qualify EncoderSim getters (#5660)
7be290147c [wpiutil] Refactor SpanMatcher and TestPrinters from ntcore (#5658)
9fe258427a [commands] Add proxy factory to Commands (#5603)
633c5a8a22 [commands] Add C++ Requirements struct (#5504)
b265a68eea [commands] Add interruptor parameter to onCommandInterrupt callbacks (#5461)
e93c233d60 [ntcore] Compute Value memory size when creating value (#5657)
5383589f99 [wpinet] uv::Request: Return shared_ptr from Release() (#5656)
40b552be4a [wpinet] uv::Stream: Return error from TryWrite() (#5655)
202a75fe08 [wpinet] RequestImpl: Avoid infinite loop in shared_from_this() (#5654)
8896515eb7 [wpinet] uv::Buffer: Add bytes() accessor (#5653)
ae59a2fba2 [wpinet] uv::Error: Change default error to 0 (#5652)
3b51ecc35b [wpiutil] SpanExtras: Add take_back and take_front (#5651)
17f1062885 Replace std::snprintf() with wpi::format_to_n_c_str() (#5645)
bb39900353 [romi/xrp] Add Romi and XRP Vendordeps (#5644)
cb99517838 [build] cmake: Use default install location on windows for dlls (#5580)
25b0622d4c [build] Add Windows CMake CI (#5516)
34e7849605 Add warning to development builds instructions (NFC) (#5646)
e9e611c9d8 [cameraserver] Remove CameraServer.SetSize() (#5650)
94f58cc536 [wpilib] Remove Compressor.Enabled() (#5649)
4da5aee88a [wpimath] Remove SlewRateLimiter 2 argument constructor (#5648)
2e3ddf5502 Update versions in development builds instructions to 2024 (#5647)
19a8850fb1 [examples] Add TimesliceRobot templates (#3683)
9047682202 [sim] Add XRP-specific plugin (#5631)
575348b81c [wpilib] Use IsSimulation() consistently (#3534)
12e2043b77 [wpilib] Clean up Notifier (#5630)
4bac4dd0f4 [wpimath] Move PIDController from frc2 to frc namespace (#5640)
494cfd78c1 [wpiutil] Fix deprecation warning in LLVM for C++23 (#5642)
43a727e868 [apriltag] Make loadAprilTagFieldLayout throw an unchecked exception instead (#5629)
ad4b017321 [ci] Use Ninja for faster builds (#5626)
4f2114d6f5 Fix warnings from GCC 13 release build (#5637)
e7e927fe26 [build] Also compress debug info for CMake RelWithDebInfo build type (#5638)
205a40c895 [build] Specify zlib for debug info compression (#5636)
707444f000 [apriltag] Suppress -Wtype-limits warning in asserts from GCC 13 (#5635)
3b79cb6ed3 [commands] Revert SubsystemBase deprecation/removal (#5634)
bc7f23a632 [build] Compress Linux debug info (#5633)
57b2d6f254 [build] Update to image 2024 v1.0 (#5625)
339ef1ea39 [wpilib] DataLogManager: Warn user if logging to RoboRIO 1 internal storage (#5617)
7a9a901a73 [build] Fix cmake config files (#5624)
298f8a6e33 [wpilib] Add Mechanism2d tests and make Java impl match C++ (#5527)
d7ef817bae [apriltag] Update apriltag library (#5619)
c3fb31fd0e [docs] Switch to Java 17 api docs (#5613)
bd64f81cf9 [build] Run Google tests in release mode in CI (#5615)
66e6bd81ea [wpimath] Cleanup wpimath/algorithms.md (NFC) (#5621)
4fa56fd884 [build] Add missing find_dependency call (#5623)
f63d958995 [build] Update to native utils 2024.2.0 (#5601)
a9ab08f48b [wpimath] Rename ChassisSpeeds.fromDiscreteSpeeds() to discretize() (#5616)
8e05983a4a [wpimath] Add math docs to plant inversion feedforward internals (NFC) (#5618)
3a33ce918b [ntcore] Add missing StringMap include (#5620)
a6157f184d [wpiutil] timestamp: Add ShutdownNowRio (#5610)
e9f612f581 [build] Guard policy setting for CMake versions below 3.24 (#5612)
1a6df6fec6 [wpimath] Fix DARE Q decomposition (#5611)
9b3f7fb548 [build] Exclude IntelliJ folders from spotless XML (#5602)
814f18c7f5 [wpimath] Fix computation of C for DARE (A, C) detectability check (#5609)
ac23f92451 [hal] Add GetTeamNumber (#5596)
a750bee54d [wpimath] Use std::norm() in IsStabilizable() (#5599)
8e2465f8a0 [wpimath] Add arithmetic functions to wheel speeds classes (#5465)
10d4f5b5df [wpimath] Clean up notation in DARE precondition docs (#5595)
b2dd59450b [hal] Fix unfinished/incorrect GetCPUTemp functions (#5598)
99f66b1e24 [wpimath] Replace frc/EigenCore.h typedefs with Eigen's where possible (#5597)
383289bc4b [build] Make custom CMake macros use lowercase (#5594)
45e7720ec1 [build] Add error message when downloading files in CMake (#5593)
4e0d785356 [wpimath] ChassisSpeeds: document that values aren't relative to the robot (NFC) (#5551)
3c04580a57 [commands] ProxyCommand: Use inner command name in unique_ptr constructor (#5570)
cf19102c4a [commands] SelectCommand: Fix leakage and multiple composition bug (#5571)
171375f440 [ntcoreffi] Link to NI libraries (#5589)
89add5d05b Disable flaky tests (#5591)
a8d4b162ab [ntcore] Remove RPC manual tests (#5590)
39a73b5b58 [commands] C++: Add CommandPtr supplier constructor to ProxyCommand (#5572)
36d514eae7 [commands] Refactor C++ ScheduleCommand to use SmallSet (#5568)
52297ffe29 [commands] Add idle command (#5555)
67043a8eeb [wpimath] Add angular jerk unit (#5582)
51b0fb1492 [wpimath] Fix incorrect header inclusion in angular_acceleration.h (#5587)
b7657a8e28 [wpimath] Split WPIMathJNI into logical chunks (#5552)
ea17f90f87 [build] Fix tool builds with multiple arm platforms installed (#5586)
f1d7b05723 [wpimath] Clean up unit formatter (#5584)
d7264ff597 Replace wpi::errs() usage with fmtlib (#5560)
ab3bf39e0e [wpiutil] Upgrade to fmt 10.1.1 (#5585)
165ebe4c79 Upgrade to fmt 10.1.0 (#5326)
8e2a7fd306 Include thirdparty libraries with angle brackets (#5578)
e322ab8e46 [wpimath] Fix docs for DARE ABQRN stabilizability check (NFC) (#5579)
360fb835f4 [upstream_utils] Handle edge case in filename matches (#5576)
9d86624c00 [build] Fix CMake configure warnings (#5577)
969979d6c7 [wpiutil] Update to foonathan memory 0.7-3 (#5573)
0d2d989e84 [wpimath] Update to gcem 1.17.0 (#5575)
cf86af7166 [wpiutil] Update to mpack 1.1.1 (#5574)
a0c029a35b [commands] Fix dangling SelectCommand documentation (NFC) (#5567)
349141b91b [upstream_utils] Document adding a patch (NFC) (#5432)
7889b35b67 [wpimath] Add RamseteController comparison to LTV controller docs (NFC) (#5559)
b3ef536677 [build] Ignore nt/sim json files in spotless (#5565)
ed895815b5 [build] Compile Java with UTF-8 encoding (#5564)
2e4ad35e36 [wpiutil] jni_util: Add JSpan and CriticalJSpan (#5554)
8f3d6a1d4b [wpimath] Remove discretizeAQTaylor() (#5562)
7c20fa1b18 [wpimath] Refactor DARE tests to reduce RAM usage at compile time (#5557)
89e738262c [ntcore] Limit buffer pool size to 64KB per connection (#5485)
96f7fa662e Upgrade Maven dependencies (#5553)
7a2d336d52 [wpinet] Leak multicast handles during windows shutdown (#5550)
f9e2757d8f [wpimath] Use JDoubleArrayRef in all JNI functions (#5546)
0cf6e37dc1 [wpimath] Make LTV controller constructors use faster DARE solver (#5543)
6953a303b3 [build] Fix the windows build with fmt (#5544)
7a37e3a496 [wpimath] Correct Rotation3d::RotateBy doc comment (NFC) (#5541)
186b409e16 [wpimath] Remove internal Eigen header include (#5539)
03764dfe93 [wpimath] Add static matrix support to DARE solver (#5536)
394cfeadbd [wpimath] Use SDA algorithm instead of SSCA for DARE solver (#5526)
a4b7fde767 [wpilib] Add mechanism specific SetState overloads to physics sims (#5534)
8121566258 [wpimath] Fix CoordinateSystem.convert() Transform3d overload (#5532)
b542e01a0b [glass] Fix array crash when clearing existing workspace (#5535)
e2e1b763b2 [wpigui] Fix PFD file dialogs not closing after window closing (#5530)
86d7bbc4e4 [examples] Add Java Examples and Templates for the XRP (#5529)
e8b5d44752 [wpimath] Make Java Quaternion use doubles instead of Vector (#5525)
38c198fa64 [myRobot] Add apriltags to myRobot build (#5528)
00450c3548 [wpimath] Upgrade to EJML 0.42 (#5531)
faf3cecd83 [wpimath] Don't copy Matrix and underlying storage in VecBuilder (#5524)
6b896a38dc [build] Don't enforce WITH_FLAT_INSTALL with MSVC (part 2) (#5517)
c01814b80e [wpiutil] Add C API for DataLog (#5509)
b5bd0771eb [wpimath] Document extrinsic vs intrinsic rotations (NFC) (#5508)
84ed8aec05 [build] Don't enforce WITH_FLAT_INSTALL with MSVC (#5515)
999f677d8c [ntcoreffi] Add WPI_Impl_SetupNowRio to exported symbols (#5510)
338f37d302 Fix header sorting of libssh (#5507)
75cbd9d6d0 [glass] Add background color selector to glass plots (#5506)
e2c190487b [examples] Add flywheel bang-bang controller example (#4071)
c52dad609e [wpinet] WebSocket: Send pong in response to ping (#5498)
e2d17a24a6 [hal] Expose power rail disable and cpu temp functionality (#5477)
3ad5d2e42d [hal,wpiutil] Use HMB for FPGA Timestamps (#5499)
b46a872494 [ntcore] Remove pImpl from implementation (#5480)
d8c59ccc71 [wpimath] Add tests for MathUtil clamp() and interpolate() (#5501)
0552c8621d [glass,ov] Improve Glass and OutlineViewer title bar message (#5502)
90e37a129f [wpiutil,wpimath] Add generic InterpolatingTreeMap (#5372)
d83a6edc20 [wpilib] Update GetMatchTime docs and units (#5232)
6db2c42966 [wpimath] Trajectory: Throw on empty lists of States (#5497)
21439b606c [wpimath] Disallow LTV controller max velocities above 15 m/s (#5495)
7496e0d208 [ntcore] Value: More efficiently store arrays (#5484)
0c93aded8a [wpimath] Change kinematics.ToTwist2d(end - start) to kinematics.ToTwist2d(start, end) (#5493)
815a8403e5 [wpimath] Give infeasible trajectory constraints a better exception message (#5492)
35a8b129d9 [wpimath] Add RotateBy() function to pose classes (#5491)
26d6e68c8f [upstream_utils] Add GCEM to CI (#5483)
6aa469ae45 [wpilib] Document how to create LinearSystem object for physics sim classes (NFC) (#5488)
a01b6467d3 [wpimath] Link to docs on LQR and KF tolerances (#5486)
d814f1d123 [wpimath] Fix copy-paste error from Pose2d docs (NFC) (#5490)
98f074b072 [wpimath] Add folder prefix to geometry includes (#5489)
e9858c10e9 [glass] Add tooltips for NT settings (#5476)
12dda24f06 [examples] Fix C robot template not correctly looping (#5474)
fc75d31755 [apriltag] Update apriltaglib (#5475)
a95994fff6 [wpiutil] timestamp: Call FPGA functions directly (#5235)
2ba8fbb6f4 [wpimath] Improve documentation for SwerveModulePosition::operator- (#5468)
b8cdf97621 [build] Prepare for Windows arm64 builds (#5390)
552f4b76b5 [wpimath] Add FOC-enabled Falcon constants to the DCMotor class (#5469)
1938251436 [examples] Add Feedforward to ElevatorProfiledPid (#5300)
873c2a6c10 [examples] Update ElevatorTrapezoidProfile example (#5466)
99b88be4f3 [wpilib] Reduce usage of NTSendable (#5434)
d125711023 [hal] Fix Java REVPH faults bitfield (take 2) (#5464)
c3fab7f1f2 [ntcore] Don't update timestamp when value is unchanged (#5356)
5ec7f18bdc [wpilib] EventLoop docs: Remove BooleanEvent references (NFC) (#5463)
c065ae1fcf [wpiunits] Add subproject for a Java typesafe unit system (#5371)
44acca7c00 [wpiutil] Add ClassPreloader (#5365)
88b11832ec [hal] Fix Java REVPH faults bitfield (#5148)
fb57d82e52 [ntcore] Enhance Java raw value support
3a6e40a44b [wpiutil] Enhance DataLog Java raw value support
8dae5af271 [wpiutil] Add compile-time string utilities (ct_string) (#5462)
fc56f8049a [wpilib] DriverStation: Change alliance station to use optional (#5229)
ef155438bd [build] Consume libuv via cmake config instead of via pkg-config (#5438)
86e91e6724 [wpimath] Refactor TrapezoidProfile API (#5457)
72a4543493 [wpilib] DutyCycleEncoderSim: Expand API (#5443)
657338715d [wpimath] Add ChassisSpeeds method to fix drifting during compound swerve drive maneuvers (#5425)
1af224c21b Add missing <functional> includes (#5459)
0b91ca6d5a [wpilib] SendableChooser: Add onChange listener (#5458)
6f7cdd460e [wpimath] Pose3d: Switch to JNI for exp and log (#5444)
c69e34c80c [wpimath] ChassisSpeeds: Add arithmetic functions (#5293)
335e7dd89d [wpilib] Simulation: Add ctor parameter to set starting state of mechanism sims (#5288)
14f30752ab [wpilib] Deprecate Accelerometer and Gyro interfaces (#5445)
70b60e3a74 [commands] Trigger: Fix method names in requireNonNullParam (#5454)
593767c8c7 [wpimath] Improve Euler angle calculations in gimbal lock (#5437)
daf022d3da [build] Make devImplementation inherit from implementation (#5450)
9b8d90b852 [examples] Convert the unitless joystick inputs to actual physical units (#5451)
1f6428ab63 [ntcore] Fix undefined comparison behavior when array is empty (#5448)
17eb9161cd Update code owners for removal of old commands (#5447)
3c4b58ae1e [wpinet] Upgrade to libuv 1.46.0 (#5446)
aaea85ff16 [commands] Merge CommandBase into Command and SubsystemBase into Subsystem (#5392)
7ac932996a [ci] Use PAT for workflow dispatch (#5442)
efe1987e8b [ci] Trigger pages repo workflow (#5441)
828bc5276f [wpiutil] Upgrade to LLVM 16.0.6 (#5435)
701df9eb87 [ci] Change documentation publish to single-commit (#5440)
e5452e3f69 [wpiutil] Add WPICleaner and an example how to use it (#4850)
7a099cb02a [commands] Remove deprecated classes and functions (#5409)
b250a03944 [wpilib] Add function to wait for DS Connection (#5230)
a6463ed761 [wpiutil] Fix unused variable warning in release build (#5430)
f031513470 [ntcore] NetworkTable::GetSubTables(): Remove duplicates (#5076)
f8e74e2f7c [hal] Unify PWM simulation Speed, Position, and Raw (#5277)
fd5699b240 Remove references to Drake (#5427)
e2d385d80a [build] cmake: Respect USE_SYSTEM_FMTLIB (#5429)
d37f990ce3 [hal] Fix HAL Relay/Main doc module (NFC) (#5422)
a7a8b874ac [docs] Expand HAL_ENUM in doxygen docs (#5421)
3a61deedde [wpimath] Rotation2d: Only use gcem::hypot when constexpr evaluated (#5419)
96145de7db [examples] Fix formatting (NFC) (#5420)
fffe6a7b9a [examples] Improve Pneumatics example coverage in Solenoid and RapidReactCmdBot examples (#4998)
6b5817836d [wpimath] Add tolerance for some tests (#5416)
3233883f3e [cscore] Fix warnings on macos arm (#5415)
c4fc21838f [commands] Add ConditionalCommand getInterruptionBehavior (#5161)
89fc51f0d4 Add tests for SendableChooser and Command Sendable functionality (#5179)
663bf25aaf [docs] Generate docs for symbols in __cplusplus (#5412)
fe32127ea8 [command] Clean up Command doc comments (NFC) (#5321)
c1a01569b4 [wpilib][hal] PWM Raw using microseconds (#5283)
1fca519fb4 [wpiutil] Remove remnants of ghc fs and tcb_span libraries (#5411)
90602cc135 [github] Update issue template to collect more project info (#5090)
34412ac57e [build] Exclude files in bin from Spotless (#5410)
61aa60f0e3 [wpilib] Add robot callback that is called when the DS is initially connected (#5231)
ebae341a91 [commands] Add test for subsystem registration and periodic (#5408)
5d3a133f9f Remove spaces in NOLINT comments (#5407)
3a0e484691 [wpimath] Fix clang-tidy warnings (#5403)
eb3810c765 [wpiutil] Fix clang-tidy warnings (#5406)
c4dc697192 [hal] WS Simulation: Add message filtering capability (#5395)
0eccc3f247 [ntcore] Fix clang-tidy warnings (#5405)
f4dda4bac0 [hal] Add javadocs for JNI (NFC) (#5298)
1c20c69793 [cscore] Fix clang-tidy warnings (#5404)
1501607e48 [commands] Fix clang-tidy warnings (#5402)
991f4b0f62 [wpimath] PIDController: Add IZone (#5315)
f5b0d1484b [wpimath] Add isNear method to MathUtil (#5353)
2ce248f66c [hal] Fix clang-tidy warnings (#5401)
5fc4aee2d2 [wpimath] SwerveDriveKinematics: Rename currentChassisSpeed to desiredChassisSpeed (#5393)
50b90ceb54 [wpimath] SwerveDriveKinematics: Add reset method (#5398)
316cd2a453 [commands] Notify DriverStationSim in CommandTestBaseWithParam (#5400)
d4ea5fa902 [cscore] VideoMode: Add equals override (Java) (#5397)
d6bd72d738 [wpimath] ProfiledPIDController: Add getConstraints (#5399)
25ad5017a9 [wpimath] Refactor kinematics, odometry, and pose estimator (#5355)
5c2addda0f [doc] Add missing pneumatics docs (NFC) (#5389)
c3e04a6ea2 Fix loading tests on macos 12 (#5388)
d5ed9fb859 [wpimath] Create separate archive with just units headers (#5383)
901ab693d4 [wpimath] Use UtilityClassTest for more utility classes (#5384)
9d53231b01 [wpilib] DataLogManager: Add warning for low storage space (#5364)
d466933963 [wpiutil] Group doxygen into MPack module (#5380)
652d1c44e3 [wpiutil] Upgrade to macOS 12 to remove concept shims (#5379)
6414be0e5d [wpimath] Group units doxygen modules (#5382)
7ab5800487 [wpiutil] Fix docs typo in SmallVector (#5381)
59905ea721 Replace WPI_DEPRECATED() macro with [[deprecated]] attribute (#5373)
753cb49a5e [ntcore] Fix doxygen module in generated C types (NFC) (#5374)
1c00a52b67 [hal] Expose CAN timestamp base clock (#5357)
91cbcea841 Replace SFINAE with concepts (#5361)
d57d1a4598 [wpimath] Remove unnecessary template argument from unit formatter (#5367)
5acc5e22aa [wpimath] Only compute eigenvalues with EigenSolvers (#5369)
d3c9316a97 extend shuffleboard test timeout (#5377)
1ea868081a [ci] Fix /format command (#5376)
5fac18ff4a Update formatting to clang-format 16 (#5370)
a94a998002 [wpimath] Generalize Eigen formatter (#5360)
125f6ea101 [wpimath] Make SwerveDriveKinematics::ToChassisSpeeds() take const-ref argument (#5363)
51066a5a8a [wpimath] Move unit formatters into units library (#5358)
282c032b60 [wpilibc] Add unit-aware Joystick.GetDirection() (#5319)
073d19cb69 [build] Fix CMake warning (#5359)
01490fc77b [wpiutil] DataLog: Add documentation for append methods (NFC) (#5348)
c9b612c986 [wpilibcExamples] Make C++ state-space elevator KF and LQR match Java (#5346)
eed1e6e3cb [wpimath] Replace DiscretizeAQTaylor() with DiscretizeAQ() (#5344)
c976f40364 [readme] Document how to run examples in simulation (#5340)
4d28bdc19e [ci] Update Github Pages deploy action parameters (#5343)
e0f851871f [ci] Fix github pages deploy version (#5342)
063c8cbedc Run wpiformat (NFC) (#5341)
96e41c0447 [ci] Update deploy and sshagent actions (#5338)
fd294bdd71 [build] Fix compilation with GCC 13 (#5322)
d223e4040b [dlt] Add delete without download functionality (#5329)
abc19bcb43 [upstream_utils] Zero out commit hashes and show 40 digits in index hashes (#5336)
e909f2e687 [build] Update gradle cache repo name (#5334)
52bd5b972d [wpimath] Rewrite DARE solver (#5328)
3876a2523a [wpimath] Remove unused MatrixImpl() function (#5330)
c82fcb1975 [wpiutil] Add reflection based cleanup helper (#4919)
15ba95df7e [wpiutil] Use std::filesystem (#4941)
77c2124fc5 [wpimath] Remove Eigen's custom STL types (#4945)
27fb47ab10 [glass] Field2D: Embed standard field images (#5159)
102e4f2566 [wpilib] Remove deprecated and broken SPI methods (#5249)
463a90f1df [wpilib, hal] Add function to read the RSL state (#5312)
7a90475eec [wpilib] Update RobotBase documentation (NFC) (#5320)
218cfea16b [wpilib] DutyCycleEncoder: Fix reset behavior (#5287)
91392823ff [build] Update to gradle 8.1 (#5303)
258b7cc48b [wpilibj] Filesystem.getDeployDirectory(): Strip JNI path from user.dir (#5317)
26cc43bee1 [wpilib] Add documentation to SPI mode enum (NFC) (#5324)
ac4da9b1cb [hal] Add HAL docs for Addressable LED (NFC) (#5304)
21d4244cf7 [wpimath] Fix DCMotor docs (NFC) (#5309)
1dff81bea7 [hal] Miscellaneous HAL doc fixes (NFC) (#5306)
7ce75574bf [wpimath] Upgrade to Drake v1.15.0 (#5310)
576bd646ae [hal] Add CANManufacturer for Redux Robotics (#5305)
ee3b4621e5 [commands] Add onlyWhile and onlyIf (#5291)
40ca094686 [commands] Fix RepeatCommand calling end() twice (#5261)
9cbeb841f5 [rtns] Match imaging tool capitalization (#5265)
a63d06ff77 [examples] Add constants to java gearsbot example (#5248)
b6c43322a3 [wpilibc] XboxController: Add return tag to docs (NFC) (#5246)
5162d0001c [hal] Fix and document addressable LED timings (#5272)
90fabe9651 [wpilibj] Use method references in drive class initSendable() (#5251)
24828afd11 [wpimath] Fix desaturateWheelSpeeds to account for negative speeds (#5269)
e099948a77 [wpimath] Clean up rank notation in docs (NFC) (#5274)
fd2d8cb9c1 [hal] Use std::log2() for base-2 logarithm (#5278)
ba8c64bcff [wpimath] Fix misspelled Javadoc parameters in pose estimators (NFC) (#5292)
f53c6813d5 [wpimath] Patch Eigen warnings (#5290)
663703d370 [gitattributes] Mark json files as lf text files (#5256)
aa34aacf6e [wpilib] Shuffleboard: Keep duplicates on SelectTab() (#5198)
63512bbbb8 [wpimath] Fix potential divide-by-zero in RKDP (#5242)
9227b2166e [wpilibj] DriverStation: Fix joystick data logs (#5240)
fbf92e9190 [wpinet] ParallelTcpConnector: don't connect to duplicate addresses (#5169)
2108a61362 [ntcore] NT4 client: close timed-out connections (#5175)
0a66479693 [ntcore] Optimize scan of outgoing messages (#5227)
b510c17ef6 [hal] Fix RobotController.getComments() mishandling quotes inside the comments string (#5197)
e7a7eb2e93 [commands] WaitCommand: Remove subclass doc note (NFC) (#5200)
a465f2d8f0 [examples] Shuffleboard: Correct parameter order (#5204)
a3364422fa LICENSE.md: Bump year to 2023 (#5195)
df3242a40a [wpimath] Fix NaN in C++ MakeCostMatrix() that takes an array (#5194)
00abb8c1e0 [commands] RamseteCommand: default-initialize m_prevSpeeds (#5188)
c886273fd7 [wpilibj] DutyCycleEncoder.setDistancePerRotation(): fix simulation (#5147)
53b5fd2ace [ntcore] Use int64 for datalog type string (#5186)
56b758320f [wpilib] DataLogManager: increase time for datetime to be valid (#5185)
08f298e4cd [wpimath] Fix Pose3d log returning Twist3d NaN for theta between 1E-8 and 1E-7 (#5168)
6d0c5b19db [commands] CommandScheduler.isComposed: Remove incorrect throws clause (NFC) (#5183)
0d22cf5ff7 [wpilib] Fix enableLiveWindowInTest crashing in disabled (#5173)
32ec5b3f75 [wpilib] Add isTestEnabled and minor docs cleanup (#5172)
e5c4c6b1a7 [wpimath] Fix invalid iterator access in TimeInterpolatableBuffer (#5138)
099d048d9e [wpimath] Fix Pose3d log returning Twist3d NaN for theta between 1E-9 and 1E-8 (#5143)
4af84a1c12 Fix Typos (NFC) (#5137)
ce3686b80d [wpimath] Check LTV controller max velocity precondition (#5142)
4b0eecaee0 [commands] Subsystem: Add default command removal method (#5064)
edf4ded412 [wpilib] PH: Revert to 5V rail being fixed 5V (#5122)
4c46b6aff9 [wpilibc] Fix DataLogManager crash on exit in sim (#5125)
490ca4a68a [wpilibc] Fix XboxController::GetBackButton doc (NFC) (#5131)
cbb5b0b802 [hal] Simulation: Fix REV PH solenoids 8+ (#5132)
bb7053d9ee [hal] Fix HAL_GetRuntimeType being slow on the roboRIO (#5130)
9efed9a533 Update .clang-format to c++20 (#5121)
dbbfe1aed2 [wpilib] Use PH voltage to calc Analog pressure switch threshold (#5115)
de65a135c3 [wpilib] DutyCycleEncoderSim: Add channel number constructor (#5118)
3e9788cdff [docs] Strip path from generated NT docs (#5119)
ecb072724d [ntcore] Client::Disconnect(): actually close connection (#5113)
0d462a4561 [glass] NT view: Change string/string array to quoted (#5111)
ba37986561 [ntcore] NetworkClient::Disconnect: Add null check (#5112)
25ab9cda92 [glass,ov] Provide menu item to create topic from root (#5110)
2f6251d4a6 [glass] Set default value when publishing new topic (#5109)
e9a7bed988 [wpimath] Add timestamp getter to MathShared (#5091)
9cc14bbb43 [ntcore] Add stress test to dev executable (#5107)
8068369542 [wpinet] uv: Stop creating handles when closing loop (#5102)
805c837a42 [ntcore] Fix use-after-free in server (#5101)
fd18577ba0 [commands] Improve documentation of addRequirements (NFC) (#5103)
74dea9f05e [wpimath] Fix exception for empty pose buffer in pose estimators (#5106)
9eef79d638 [wpilib] PneumaticHub: Document range of enableCompressorAnalog (NFC) (#5099)
843574a810 [ntcore] Use wpi::Now instead of loop time for transmit time
226ef35212 [wpinet] WebSocket: Reduce server send frame overhead
b30664d630 [ntcore] Reduce initial connection overhead
804e5ce236 [examples] MecanumDrive: Fix axis comment in C++ example (NFC) (#5096)
49af88f2bb [examples] ArmSimulation: Fix flaky test (#5093)
d56314f866 [wpiutil] Disable mock time on the Rio (#5092)
43975ac7cc [examples] ArmSimulation, ElevatorSimulation: Extract mechanism to class (#5052)
5483464158 [examples, templates] Improve descriptions (NFC) (#5051)
785e7dd85c [wpilibc] SendableChooser: static_assert copy- and default-constructibility (#5078)
e57ded8c39 [ntcore] Improve disconnect error reporting (#5085)
01f0394419 [wpinet] Revert WebSocket: When Close() is called, call closed immediately (#5084)
59be120982 [wpimath] Fix Pose3d exp()/log() and add rotation vector constructor to Rotation3d (#5072)
37f065032f [wpilib] Refactor TimedRobot tests (#5068)
22a170bee7 [wpilib] Add Notifier test (#5070)
2f310a748c [wpimath] Fix DCMotor.getSpeed() (#5061)
b43ec87f57 [wpilib] ElevatorSim: Fix WouldHitLimit methods (#5057)
19267bef0c [ntcore] Output warning on property set on unpublished topic (#5059)
84cbd48d84 [ntcore] Handle excludeSelf on SetDefault (#5058)
1f35750865 [cameraserver] Add GetInstance() to all functions (#5054)
8230fc631d [wpilib] Revert throw on nonexistent SimDevice name in SimDeviceSim (#5053)
b879a6f8c6 [wpinet] WebSocket: When Close() is called, call closed immediately (#5047)
49459d3e45 [ntcore] Change wire timeout to fixed 1 second (#5048)
4079eabe9b [wpimath] Discard stale pose estimates (#5045)
fe5d226a19 [glass] Fix option for debug-level NT logging (#5049)
b7535252c2 [ntcore] Don't leak buffers in rare WS shutdown case (#5046)
b61ac6db33 [ntcore] Add client disconnect function (#5022)
7b828ce84f [wpimath] Add nearest to Pose2d and Translation2d (#4882)
08a536291b [examples] Improvements to Elevator Simulation Example (#4937)
193a10d020 [wpigui] Limit frame rate to 120 fps by default (#5030)
7867bbde0e [wpilib] Clarify DS functions provided by FMS (NFC) (#5043)
fa7c01b598 [glass] Add option for debug-level NT logging (#5007)
2b81610248 [wpiutil] Add msgpack to datalog Python example (#5032)
a4a369b8da CONTRIBUTING.md: Add unicodeit CLI to math docs guidelines (#5031)
d991f6e435 [wpilib] Throw on nonexistent SimDevice name in SimDeviceSim constructor (#5041)
a27a047ae8 [hal] Check for null in getSimDeviceName JNI (#5038)
2f96cae31a [examples] Hatchbots: Add telemetry (#5011)
83ef8f9658 [simulation] GUI: Fix buffer overflow in joystick axes copy (#5036)
4054893669 [commands] Fix C++ Select() factory (#5024)
f75acd11ce [commands] Use Timer.restart() (#5023)
8bf67b1b33 [wpimath] PIDController::Calculate(double, double): update setpoint flag (#5021)
49bb1358d8 [wpiutil] MemoryBuffer: Fix GetMemoryBufferForStream (#5017)
9c4c07c0f9 [wpiutil] Remove NDEBUG check for debug-level logging (#5018)
1a47cc2e86 [ntcore] Use full handle when subscribing (#5013)
7cd30cffbc Ignore networktables.json (#5006)
92aecab2ef [commands] Command controllers are not subclasses (NFC) (#5000)
8785bba080 [ntcore] Special-case default timestamps (#5003)
9e5b7b8040 [ntcore] Handle topicsonly followed by value subscribe (#4991)
917906530a [wpilib] Add Timer::Restart() (#4963)
00aa66e4fd [wpimath] Remove extraneous assignments from DiscretizeAB() (#4967)
893320544a [examples] C++ RamseteCommand: Fix units (#4954)
b95d0e060d [wpilib] XboxController: Fix docs discrepancy (NFC) (#4993)
008232b43c [ntcore] Write empty persistent file if none found (#4996)
522be348f4 [examples] Rewrite tags (NFC) (#4961)
d48a83dee2 [wpimath] Update Wikipedia links for quaternion to Euler angle conversion (NFC) (#4995)
504fa22143 [wpimath] Workaround intellisense Eigen issue (#4992)
b2b25bf09f [commands] Fix docs inconsistency for toggleOnFalse(Command) (NFC) (#4978)
ce3dc4eb3b [hal] Properly use control word that is in sync with DS data (#4989)
1ea48caa7d [wpilib] Fix C++ ADXRS450 and Java SPI gyro defs (#4988)
fb101925a7 [build] Include wpimathjni in commands binaries (#4981)
657951f6dd [starter] Add a process starter for use by the installer for launching tools (#4931)
a60ca9d71c [examples] Update AprilTag field load API usage (#4975)
f8a45f1558 [wpimath] Remove print statements from tests (#4977)
ecba8b99a8 [examples] Fix swapped arguments in MecanumControllerCommand example (#4976)
e95e88fdf9 [examples] Add comment to drivedistanceoffboard example (#4877)
371d15dec3 [examples] Add Computer Vision Pose Estimation and Latency Compensation Example (#4901)
cb9b8938af [sim] Enable docking in the GUI (#4960)
3b084ecbe0 [apriltag] AprilTagFieldLayout: Improve API shape for loading builtin JSONs (#4949)
27ba096ea1 [wpilib] Fix MOI calculation error in SingleJointedArmSim (#4968)
42c997a3c4 [wpimath] Fix Pose3d exponential and clean up Pose3d logarithm (#4970)
5f1a025f27 [wpilibj] Fix typo in MecanumDrive docs (NFC) (#4969)
0ebf79b54c [wpimath] Fix typo in Pose3d::Exp() docs (NFC) (#4966)
a8c465f3fb [wpimath] HolonomicDriveController: Add getters for the controllers (#4948)
a7b1ab683d [wpilibc] Add unit test for fast deconstruction of GenericHID (#4953)
bd6479dc29 [build] Add Spotless for JSON (#4956)
5cb0340a8c [hal, wpilib] Load joystick values upon code initialization (#4950)
ab0e8c37a7 [readme] Update build requirements (NFC) (#4947)
b74ac1c645 [build] Add apriltag to C++ cmake example builds (#4944)
cf1a411acf [examples] Add example programs for AprilTags detection (#4932)
1e05b21ab5 [wpimath] Fix PID atSetpoint to not return true prematurely (#4906)
e5a6197633 [wpimath] Fix SwerveDriveKinematics not initializing a new array each time (#4942)
039edcc23f [ntcore] Queue current value on subscriber creation (#4938)
f7f19207e0 [wpimath] Allow multiple vision measurements from same timestamp (#4917)
befd12911c [commands] Delete UB-causing rvalue variants of CommandPtr methods (#4923)
34519de60a [commands] Fix spacing in command composition exception (#4924)
dc4355c031 [hal] Add handle constructor and name getters for sim devices (#4925)
53d8d33bca [hal, wpilibj] Add missing distance per pulse functions to EncoderSim (#4928)
530ae40614 [apriltag] Explain what April tag poses represent (NFC) (#4930)
79f565191e [examples] DigitalCommunication, I2CCommunication: Add tests (#4865)
2cd9be413f [wpilib, examples] Cleanup PotentiometerPID, Ultrasonic, UltrasonicPID examples (#4893)
babb0c1fcf [apriltag] Add 2023 field layout JSON (#4912)
330ba45f9c [wpimath] Fix swerve kinematics util classes equals function (#4907)
51272ef6b3 [fieldImages] Add 2023 field (#4915)
0d105ab771 [commands] Deduplicate command test utils (#4897)
cf4235ea36 [wpiutil] Guard MSVC pragma in SymbolExports.h (#4911)
2d4b7b9147 [build] Update opencv version in opencv.gradle (#4909)
aec6f3d506 [ntcore] Fix client flush behavior (#4903)
bfe346c76a [build] Fix cmake java resources (#4898)

Change-Id: Ia1dd90fe42c6cd5df281b8a5b710e136f54355f4
git-subtree-dir: third_party/allwpilib
git-subtree-split: f1a82828fed8950f9a3f1586c44327027627a0c8
Signed-off-by: James Kuszmaul <jabukuszmaul+collab@gmail.com>
diff --git a/ntcore/.styleguide b/ntcore/.styleguide
index ef9cbb7..1808bb5 100644
--- a/ntcore/.styleguide
+++ b/ntcore/.styleguide
@@ -28,6 +28,7 @@
 
 includeOtherLibs {
   ^fmt/
+  ^gtest/
   ^support/
   ^wpi/
   ^wpinet/
diff --git a/ntcore/CMakeLists.txt b/ntcore/CMakeLists.txt
index cd4df28..5216eda 100644
--- a/ntcore/CMakeLists.txt
+++ b/ntcore/CMakeLists.txt
@@ -33,7 +33,7 @@
 
 set_property(TARGET ntcore PROPERTY FOLDER "libraries")
 
-install(TARGETS ntcore EXPORT ntcore DESTINATION "${main_lib_dest}")
+install(TARGETS ntcore EXPORT ntcore)
 install(DIRECTORY src/main/native/include/ DESTINATION "${include_dest}/ntcore")
 install(DIRECTORY ${WPILIB_BINARY_DIR}/ntcore/generated/main/native/include/ DESTINATION "${include_dest}/ntcore")
 
@@ -54,6 +54,11 @@
     include(UseJava)
     set(CMAKE_JAVA_COMPILE_FLAGS "-encoding" "UTF8" "-Xlint:unchecked")
 
+    file(GLOB QUICKBUF_JAR
+        ${WPILIB_BINARY_DIR}/wpiutil/thirdparty/quickbuf/*.jar)
+
+    set(CMAKE_JAVA_INCLUDE_PATH wpimath.jar ${QUICKBUF_JAR})
+
     file(GLOB ntcore_jni_src
         src/main/native/cpp/jni/*.cpp
         ${WPILIB_BINARY_DIR}/ntcore/generated/main/native/cpp/jni/*.cpp)
@@ -61,12 +66,7 @@
     file(GLOB_RECURSE JAVA_SOURCES src/main/java/*.java ${WPILIB_BINARY_DIR}/ntcore/generated/*.java)
     set(CMAKE_JNI_TARGET true)
 
-    if(${CMAKE_VERSION} VERSION_LESS "3.11.0")
-        set(CMAKE_JAVA_COMPILE_FLAGS "-h" "${CMAKE_CURRENT_BINARY_DIR}/jniheaders")
-        add_jar(ntcore_jar ${JAVA_SOURCES} INCLUDE_JARS wpiutil_jar OUTPUT_NAME ntcore)
-    else()
-        add_jar(ntcore_jar ${JAVA_SOURCES} INCLUDE_JARS wpiutil_jar OUTPUT_NAME ntcore GENERATE_NATIVE_HEADERS ntcore_jni_headers)
-    endif()
+    add_jar(ntcore_jar ${JAVA_SOURCES} INCLUDE_JARS wpiutil_jar OUTPUT_NAME ntcore GENERATE_NATIVE_HEADERS ntcore_jni_headers)
 
     get_property(NTCORE_JAR_FILE TARGET ntcore_jar PROPERTY JAR_FILE)
     install(FILES ${NTCORE_JAR_FILE} DESTINATION "${java_lib_dest}")
@@ -83,23 +83,33 @@
         install(TARGETS ntcorejni RUNTIME DESTINATION "${jni_lib_dest}" COMPONENT Runtime)
     endif()
 
-    if(${CMAKE_VERSION} VERSION_LESS "3.11.0")
-        target_include_directories(ntcorejni PRIVATE ${JNI_INCLUDE_DIRS})
-        target_include_directories(ntcorejni PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/jniheaders")
-    else()
-        target_link_libraries(ntcorejni PRIVATE ntcore_jni_headers)
-    endif()
+    target_link_libraries(ntcorejni PRIVATE ntcore_jni_headers)
     add_dependencies(ntcorejni ntcore_jar)
 
-    install(TARGETS ntcorejni EXPORT ntcorejni DESTINATION "${main_lib_dest}")
+    install(TARGETS ntcorejni EXPORT ntcorejni)
 
 endif()
 
+if (WITH_JAVA_SOURCE)
+    find_package(Java REQUIRED)
+    include(UseJava)
+    file(GLOB NTCORE_SOURCES src/main/java/edu/wpi/first/networktables/*.java ${WPILIB_BINARY_DIR}/ntcore/generated/*.java)
+    add_jar(ntcore_src_jar
+    RESOURCES NAMESPACE "edu/wpi/first/networktables" ${NTCORE_SOURCES}
+    OUTPUT_NAME ntcore-sources)
+
+    get_property(NTCORE_SRC_JAR_FILE TARGET ntcore_src_jar PROPERTY JAR_FILE)
+    install(FILES ${NTCORE_SRC_JAR_FILE} DESTINATION "${java_lib_dest}")
+
+    set_property(TARGET ntcore_src_jar PROPERTY FOLDER "java")
+endif()
+
 add_executable(ntcoredev src/dev/native/cpp/main.cpp)
+wpilib_target_warnings(ntcoredev)
 target_link_libraries(ntcoredev ntcore)
 
 if (WITH_TESTS)
     wpilib_add_test(ntcore src/test/native/cpp)
     target_include_directories(ntcore_test PRIVATE src/main/native/cpp)
-    target_link_libraries(ntcore_test ntcore gmock_main)
+    target_link_libraries(ntcore_test ntcore gmock_main wpiutil_testlib)
 endif()
diff --git a/ntcore/doc/networktables4.adoc b/ntcore/doc/networktables4.adoc
index 5384ebf..da157e5 100644
--- a/ntcore/doc/networktables4.adoc
+++ b/ntcore/doc/networktables4.adoc
@@ -1,16 +1,37 @@
-= Network Tables Protocol Specification, Version 4.0
+= Network Tables Protocol Specification, Version 4.1
 WPILib Developers <wpilib@wpi.edu>
-Protocol Revision 4.0, 2/14/2021
+Protocol Revision 4.1, 10/1/2023
 :toc:
 :toc-placement: preamble
 :sectanchors:
 
 A pub/sub WebSockets protocol based on NetworkTables concepts.
 
-[[motivation]]
-== Motivation
+[[motivation4.1]]
+== Motivation for Version 4.1
 
-Currently in NetworkTables there is no way to synchronize user value updates and NT update sweeps, and if user value updates occur more frequently than NT update sweeps, the intermediate values are lost.  This prevents NetworkTables from being a viable transport layer for seeing all value changes (e.g. for plotting) at rates higher than the NetworkTables update rate (e.g. for capturing high frequency PID changes).  While custom code can work around the second issue, it is more difficult to work around the first issue (unless full timestamps are also sent).
+While NetworkTables 4.0 made a large number of improvements to the 3.0 protocol, a few weaknesses have been discovered in "real world" use:
+
+* Keep alives are not required. This can result in very long timeframes before a disconnect is detected.
+* Periodic synchronization of timestamps is impacted by high variability of round trip time measurements on a stream connection shared with other data (due to network queueing in adverse network connections), resulting in values being "too old" even if actually more recent due to a change in base time
+* Disconnect loops can be caused by large amounts of data values being sent in response to a "subscribe all" type of message (e.g. subscribe with empty or `$` prefix), resulting in data transmission being blocked for an excessive amount of time
+* Publishing operations are not clearly subscriber-driven; the information is available via metatopics but not automatically sent to clients when clients publish
+
+Version 4.1 makes the following key changes to address these weaknesses:
+
+* Mandate the server and client send periodic WebSockets PING messages and track PONG responses
+* Recommend that timestamp synchronization occur immediately following connection establishment and prior to any other control messages
+* Recommend text and binary combining into a single WebSockets frame be limited to the network MTU (unless necessary to transport the message)
+* Recommend WebSockets fragmentation be used on large frames to enable rapid handling of PING messages
+* Add an option for topics to be marked transient (in which case no last value is retained by the server or sent to clients on initial subscription)
+* Recommend clients subscribe to the `$sub$<topic>` meta-topic for each topic published by the client, and use this information to control what value updates are sent over the network to the server
+
+Version 4.1 uses a different WebSockets subprotocol string than version 4.0, so it is easy for both clients and servers to simultaneously support both versions 4.0 and 4.1. Due to WebSockets implementation bugs in version 4.0, version 4.1 implementations must not send WebSockets PING messages on version 4.0 connections.
+
+[[motivation]]
+== Motivation for Version 4.0
+
+In <<networktables3,NetworkTables 3.0>>, there is no way to synchronize user value updates and NT update sweeps, and if user value updates occur more frequently than NT update sweeps, the intermediate values are lost.  This prevents NetworkTables from being a viable transport layer for seeing all value changes (e.g. for plotting) at rates higher than the NetworkTables update rate (e.g. for capturing high frequency PID changes).  While custom code can work around the second issue, it is more difficult to work around the first issue (unless full timestamps are also sent).
 
 Adding built-in support for capturing and communicating all timestamped data value changes with minimal additional user code changes will make it much easier for inexperienced teams to get high resolution, accurate data to dashboard displays with the minimal possible bandwidth and airtime usage.  Assuming the dashboard performs record and playback of NT updates, this also meets the desire to provide teams a robust data capture and playback mechanism.
 
@@ -67,6 +88,15 @@
 
 Due to the fact there can be multiple publishers for a single topic and unpredictable network delays / clock drift, there is no global total order for timestamps either globally or on a per-topic basis.  While single publishers for real-time data will be the norm, and in that case the timestamps will usually be in order, applications that use timestamps need to be able to handle out-of-order timestamps.
 
+[[aliveness]]
+=== Connection Aliveness Checking
+
+With a version 4.1 connection, both the client and the server should send periodic WebSockets PING messages and look for a PONG response within a reasonable period of time. On version 4.0 connections, or if this is not possible (e.g. the underlying WebSockets implementation does not have the ability to send PING messages), the client should use timestamp messages for aliveness testing. If no response is received after an appropriate amount of time, the client or server shall disconnect the WebSockets connection and try to re-establish a new connection.
+
+As the WebSockets protocol allows PONG responses to be sent in the middle of another message stream, WebSockets PING messages are preferred, as this allows for a shorter timeout period that is not dependent on the size of the transmitted messages. Sending a ping every 200 ms with a timeout of 1 second is recommended in this case.
+
+If using timestamp messages for aliveness checking on the primary connection, the client should use a timeout long enough to account for the largest expected message size (as the server can only respond after such a message has been completely transmitted). Sending a ping every 1 second with a timeout of 3 seconds is recommended in this case. If provided by the server, the <<rtt-subprotocol>> can be used in addition to the primary connection for aliveness testing with a shorter timeout.
+
 [[reconnection]]
 === Caching and Reconnection Handling
 
@@ -127,10 +157,12 @@
 
 Clients are responsible for keeping server connections established (e.g. via retries when a connection is lost).  Topic IDs must be treated as connection-specific; if the connection to the server is lost, the client is responsible for sending new <<msg-publish,`publish`>> and <<msg-subscribe,`subscribe`>> messages as required for the application when a new connection is established, and not using old topic IDs, but rather waiting for new <<msg-announce,`announce`>> messages to be received.
 
-Except for offline-published values with timestamps of 0, the client shall not send any other published values to the server until its clock is synchronized with the server per the <<timestamps>> section.
+Except for offline-published values with timestamps of 0, the client shall not send any other published values to the server until its clock is synchronized with the server per the <<timestamps>> section. Clients should measure RTT prior to sending any control messages (to avoid other traffic disrupting the measurement).
 
 Clients may publish a value at any time following clock synchronization.  Clients may subscribe to meta-topics to determine whether or not to publish a value change (e.g. based on whether there are any subscribers, or based on specific <<sub-options>>).
 
+Clients should subscribe to the `$sub$<topic>` meta topic for each topic published and use this metadata to determine how frequently to send updates to the network. However, this is not required--clients may choose to ignore this and send updates at any time.
+
 [[meta-topics]]
 === Server-Published Meta Topics
 
@@ -300,10 +332,22 @@
 
 Servers shall support a resource name of `/nt/<name>`, where `<name>` is an arbitrary string representing the client name.  The client name does not need to be unique; multiple connections to the same name are allowed; the server shall ensure the name is unique (for the purposes of meta-topics) by appending a '@' and a unique number (if necessary).  To support this, the name provided by the client should not contain an embedded '@'.  Clients should provide a way to specify the resource name (in particular, the client name portion).
 
-Both clients and servers shall support/use subprotocol `networktables.first.wpi.edu` for this protocol. Clients and servers shall terminate the connection in accordance with the WebSocket protocol unless both sides support this subprotocol.
+Both clients and servers should support/use subprotocol `v4.1.networktables.first.wpi.edu` (for version 4.1) and `networktables.first.wpi.edu` (for version 4.0). Version 4.1 should be preferred, with version 4.0 as a fallback, using standard WebSockets subprotocol negotiation. Clients and servers shall terminate the connection in accordance with the WebSocket protocol unless both sides support a common subprotocol.
 
 The unsecure standard server port number shall be 5810, the secure standard port number shall be 5811.
 
+[[fragmentation]]
+=== Fragmentation
+
+Combining multiple text or binary messages into a single WebSockets frame should be limited such that the WebSockets frame does not exceed the MTU unless otherwise required to fit the total binary data size.
+
+Client and server implementations should fragment WebSockets messages to roughly the network MTU in order to facilitate rapid handling of PING and PONG messages.
+
+[[rtt-subprotocol]]
+=== RTT Subprotocol
+
+Servers should provide subprotocol `rtt.networktables.first.wpi.edu` for RTT-only messages. This subprotocol provides a separate channel that can be used for RTT messages to avoid delays caused by other value transmissions. Clients that cannot send WebSocket PING messages are recommended to use this subprotocol (if available) for aliveness testing. Connections using this subprotocol do not appear in the client connections list. No text frames are used; only <<binary-frames>> with Topic ID of -1 (RTT measurement) should be sent by the client and responded to by the server.
+
 [[data-types]]
 == Supported Data Types
 
diff --git a/ntcore/manualTests/native/rpc_local.cpp b/ntcore/manualTests/native/rpc_local.cpp
deleted file mode 100644
index 2879c33..0000000
--- a/ntcore/manualTests/native/rpc_local.cpp
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#include <chrono>
-#include <climits>
-#include <cstdio>
-#include <thread>
-
-#include <support/json.h>
-
-#include "ntcore.h"
-
-void callback1(const nt::RpcAnswer& answer) {
-  wpi::json params;
-  try {
-    params = wpi::json::from_cbor(answer.params);
-  } catch (wpi::json::parse_error err) {
-    std::fputs("could not decode params?\n", stderr);
-    return;
-  }
-  if (!params.is_number()) {
-    std::fputs("did not get number\n", stderr);
-    return;
-  }
-  double val = params.get<double>();
-  std::fprintf(stderr, "called with %g\n", val);
-
-  answer.PostResponse(wpi::json::to_cbor(val + 1.2));
-}
-
-int main() {
-  auto inst = nt::GetDefaultInstance();
-  nt::AddLogger(
-      inst,
-      [](const nt::LogMessage& msg) {
-        std::fputs(msg.message.c_str(), stderr);
-        std::fputc('\n', stderr);
-      },
-      0, UINT_MAX);
-
-  nt::StartServer(inst, "rpc_local.ini", "", 10000);
-  auto entry = nt::GetEntry(inst, "func1");
-  nt::CreateRpc(entry, nt::StringRef("", 1), callback1);
-  std::fputs("calling rpc\n", stderr);
-  unsigned int call1_uid = nt::CallRpc(entry, wpi::json::to_cbor(2.0));
-  std::string call1_result_str;
-  std::fputs("waiting for rpc result\n", stderr);
-  nt::GetRpcResult(entry, call1_uid, &call1_result_str);
-  wpi::json call1_result;
-  try {
-    call1_result = wpi::json::from_cbor(call1_result_str);
-  } catch (wpi::json::parse_error err) {
-    std::fputs("could not decode result?\n", stderr);
-    return 1;
-  }
-  if (!call1_result.is_number()) {
-    std::fputs("result is not number?\n", stderr);
-    return 1;
-  }
-  std::fprintf(stderr, "got %g\n", call1_result.get<double>());
-
-  return 0;
-}
diff --git a/ntcore/manualTests/native/rpc_speed.cpp b/ntcore/manualTests/native/rpc_speed.cpp
deleted file mode 100644
index 6f9dbbf..0000000
--- a/ntcore/manualTests/native/rpc_speed.cpp
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#include <chrono>
-#include <climits>
-#include <thread>
-
-#include <fmt/core.h>
-#include <support/json.h>
-
-#include "ntcore.h"
-
-void callback1(const nt::RpcAnswer& answer) {
-  wpi::json params;
-  try {
-    params = wpi::json::from_cbor(answer.params);
-  } catch (wpi::json::parse_error err) {
-    fmt::print(stderr, "could not decode params?\n");
-    return;
-  }
-  if (!params.is_number()) {
-    fmt::print(stderr, "did not get number\n");
-    return;
-  }
-  double val = params.get<double>();
-  answer.PostResponse(wpi::json::to_cbor(val + 1.2));
-}
-
-int main() {
-  auto inst = nt::GetDefaultInstance();
-  nt::StartServer(inst, "rpc_speed.ini", "", 10000);
-  auto entry = nt::GetEntry(inst, "func1");
-  nt::CreateRpc(entry, nt::StringRef("", 1), callback1);
-  std::string call1_result_str;
-
-  auto start2 = std::chrono::high_resolution_clock::now();
-  auto start = nt::Now();
-  for (int i = 0; i < 10000; ++i) {
-    unsigned int call1_uid = nt::CallRpc(entry, wpi::json::to_cbor(i));
-    nt::GetRpcResult(entry, call1_uid, &call1_result_str);
-    wpi::json call1_result;
-    try {
-      call1_result = wpi::json::from_cbor(call1_result_str);
-    } catch (wpi::json::parse_error err) {
-      fmt::print(stderr, "could not decode result?\n");
-      return 1;
-    }
-    if (!call1_result.is_number()) {
-      fmt::print(stderr, "result is not number?\n");
-      return 1;
-    }
-  }
-  auto end2 = std::chrono::high_resolution_clock::now();
-  auto end = nt::Now();
-  fmt::print(stderr, "nt::Now start={} end={}\n", start, end);
-  fmt::print(stderr, "std::chrono start={} end={}\n",
-             std::chrono::duration_cast<std::chrono::nanoseconds>(
-                 start2.time_since_epoch())
-                 .count(),
-             std::chrono::duration_cast<std::chrono::nanoseconds>(
-                 end2.time_since_epoch())
-                 .count());
-  fmt::print(stderr, "time/call = %g us\n", (end - start) / 10.0 / 10000.0);
-  std::chrono::duration<double, std::micro> diff = end2 - start2;
-  fmt::print(stderr, "time/call = {} us\n", diff.count() / 10000.0);
-}
diff --git a/ntcore/ntcore-config.cmake.in b/ntcore/ntcore-config.cmake.in
index 17006a5..0a85f8b 100644
--- a/ntcore/ntcore-config.cmake.in
+++ b/ntcore/ntcore-config.cmake.in
@@ -1,6 +1,7 @@
 include(CMakeFindDependencyMacro)
 @FILENAME_DEP_REPLACE@
 @WPIUTIL_DEP_REPLACE@
+@WPINET_DEP_REPLACE@
 
 @FILENAME_DEP_REPLACE@
 include(${SELF_DIR}/ntcore.cmake)
diff --git a/ntcore/src/dev/native/cpp/main.cpp b/ntcore/src/dev/native/cpp/main.cpp
index fac64e7..6e43fdb 100644
--- a/ntcore/src/dev/native/cpp/main.cpp
+++ b/ntcore/src/dev/native/cpp/main.cpp
@@ -3,26 +3,42 @@
 // the WPILib BSD license file in the root directory of this project.
 
 #include <algorithm>
+#include <array>
 #include <chrono>
 #include <cmath>
 #include <cstdlib>
 #include <numeric>
+#include <random>
 #include <string_view>
 #include <thread>
 
 #include <fmt/format.h>
 #include <wpi/Synchronization.h>
+#include <wpi/timestamp.h>
 
 #include "ntcore.h"
 #include "ntcore_cpp.h"
 
 void bench();
+void bench2();
+void stress();
 
 int main(int argc, char* argv[]) {
+  wpi::impl::SetupNowRio();
+
   if (argc == 2 && std::string_view{argv[1]} == "bench") {
     bench();
     return EXIT_SUCCESS;
   }
+  if (argc == 2 && std::string_view{argv[1]} == "bench2") {
+    bench2();
+    return EXIT_SUCCESS;
+  }
+  if (argc == 2 && std::string_view{argv[1]} == "stress") {
+    stress();
+    return EXIT_SUCCESS;
+  }
+
   auto myValue = nt::GetEntry(nt::GetDefaultInstance(), "MyValue");
 
   nt::SetEntryValue(myValue, nt::Value::MakeString("Hello World"));
@@ -106,3 +122,145 @@
   fmt::print("-- Flush --\n");
   PrintTimes(flushTimes);
 }
+
+void bench2() {
+  // set up instances
+  auto client1 = nt::CreateInstance();
+  auto client2 = nt::CreateInstance();
+  auto server = nt::CreateInstance();
+
+  // connect client and server
+  nt::StartServer(server, "bench2.json", "127.0.0.1", 10001, 10000);
+  nt::StartClient4(client1, "client1");
+  nt::StartClient3(client2, "client2");
+  nt::SetServer(client1, "127.0.0.1", 10000);
+  nt::SetServer(client2, "127.0.0.1", 10001);
+
+  using namespace std::chrono_literals;
+  std::this_thread::sleep_for(1s);
+
+  // add "typical" set of subscribers on client and server
+  nt::SubscribeMultiple(client1, {{std::string_view{}}});
+  nt::SubscribeMultiple(client2, {{std::string_view{}}});
+  nt::SubscribeMultiple(server, {{std::string_view{}}});
+
+  // create 1000 entries
+  std::array<NT_Entry, 1000> pubs;
+  for (int i = 0; i < 1000; ++i) {
+    pubs[i] = nt::GetEntry(
+        nt::GetTopic(server,
+                     fmt::format("/some/long/name/with/lots/of/slashes/{}", i)),
+        NT_DOUBLE_ARRAY, "double[]");
+  }
+
+  // warm up
+  for (int i = 1; i <= 100; ++i) {
+    for (auto pub : pubs) {
+      double vals[3] = {i * 0.01, i * 0.02, i * 0.03};
+      nt::SetDoubleArray(pub, vals);
+    }
+    nt::FlushLocal(server);
+    std::this_thread::sleep_for(0.02s);
+  }
+
+  std::vector<int64_t> flushTimes;
+  flushTimes.reserve(1001);
+
+  std::vector<int64_t> times;
+  times.reserve(1001);
+
+  // benchmark
+  auto start = std::chrono::high_resolution_clock::now();
+  int64_t now = nt::Now();
+  for (int i = 1; i <= 1000; ++i) {
+    for (auto pub : pubs) {
+      double vals[3] = {i * 0.01, i * 0.02, i * 0.03};
+      nt::SetDoubleArray(pub, vals);
+    }
+    int64_t prev = now;
+    now = nt::Now();
+    times.emplace_back(now - prev);
+    nt::FlushLocal(server);
+    nt::Flush(server);
+    flushTimes.emplace_back(nt::Now() - now);
+    std::this_thread::sleep_for(0.02s);
+    now = nt::Now();
+  }
+  auto stop = std::chrono::high_resolution_clock::now();
+
+  fmt::print("total time: {}us\n",
+             std::chrono::duration_cast<std::chrono::microseconds>(stop - start)
+                 .count());
+  PrintTimes(times);
+  fmt::print("-- Flush --\n");
+  PrintTimes(flushTimes);
+}
+
+static std::random_device r;
+static std::mt19937 gen(r());
+static std::uniform_real_distribution<double> dist;
+
+void stress() {
+  auto server = nt::CreateInstance();
+  nt::StartServer(server, "stress.json", "127.0.0.1", 0, 10000);
+  nt::SubscribeMultiple(server, {{std::string_view{}}});
+
+  using namespace std::chrono_literals;
+
+  for (int count = 0; count < 10; ++count) {
+    std::thread{[] {
+      auto client = nt::CreateInstance();
+      nt::SubscribeMultiple(client, {{std::string_view{}}});
+      for (int i = 0; i < 300; ++i) {
+        // sleep a random amount of time
+        std::this_thread::sleep_for(0.1s * dist(gen));
+
+        // connect
+        nt::StartClient4(client, "client");
+        nt::SetServer(client, "127.0.0.1", 10000);
+
+        // sleep a random amount of time
+        std::this_thread::sleep_for(0.1s * dist(gen));
+
+        // disconnect
+        nt::StopClient(client);
+      }
+      nt::DestroyInstance(client);
+    }}.detach();
+
+    std::thread{[server, count] {
+      for (int n = 0; n < 300; ++n) {
+        // sleep a random amount of time
+        std::this_thread::sleep_for(0.01s * dist(gen));
+
+        // create publishers
+        NT_Publisher pub[30];
+        for (int i = 0; i < 30; ++i) {
+          pub[i] =
+              nt::Publish(nt::GetTopic(server, fmt::format("{}_{}", count, i)),
+                          NT_DOUBLE, "double", {});
+        }
+
+        // publish values
+        for (int i = 0; i < 200; ++i) {
+          // sleep a random amount of time between each value set
+          std::this_thread::sleep_for(0.001s * dist(gen));
+          for (int i = 0; i < 30; ++i) {
+            nt::SetDouble(pub[i], dist(gen));
+          }
+          nt::FlushLocal(server);
+        }
+
+        // sleep a random amount of time
+        std::this_thread::sleep_for(0.1s * dist(gen));
+
+        // remove publishers
+        for (int i = 0; i < 30; ++i) {
+          nt::Unpublish(pub[i]);
+        }
+      }
+    }}.detach();
+  }
+
+  std::this_thread::sleep_for(100s);
+}
diff --git a/ntcore/src/generate/cpp/jni/types_jni.cpp.jinja b/ntcore/src/generate/cpp/jni/types_jni.cpp.jinja
index a105278..29cf570 100644
--- a/ntcore/src/generate/cpp/jni/types_jni.cpp.jinja
+++ b/ntcore/src/generate/cpp/jni/types_jni.cpp.jinja
@@ -22,6 +22,8 @@
 static JClass {{ t.jni.jtype }}Cls;
 {%- endif %}
 {%- endfor %}
+static JException illegalArgEx;
+static JException indexOobEx;
 static JException nullPointerEx;
 
 static const JClassInit classes[] = {
@@ -36,6 +38,8 @@
 };
 
 static const JExceptionInit exceptions[] = {
+    {"java/lang/IllegalArgumentException", &illegalArgEx},
+    {"java/lang/IndexOutOfBoundsException", &indexOobEx},
     {"java/lang/NullPointerException", &nullPointerEx},
 };
 
@@ -65,12 +69,15 @@
   for (auto& c : classes) {
     c.cls->free(env);
   }
+  for (auto& c : exceptions) {
+    c.cls->free(env);
+  }
 }
 
 }  // namespace nt
 
 static std::vector<int> FromJavaBooleanArray(JNIEnv* env, jbooleanArray jarr) {
-  CriticalJBooleanArrayRef ref{env, jarr};
+  CriticalJSpan<const jboolean> ref{env, jarr};
   if (!ref) {
     return {};
   }
@@ -185,9 +192,67 @@
 {
   return {{ t.jni.ToJavaArray }}(env, nt::ReadQueueValues{{ t.TypeName }}(subentry));
 }
+{% if t.TypeName == "Raw" %}
+/*
+ * Class:     edu_wpi_first_networktables_NetworkTablesJNI
+ * Method:    setRaw
+ * Signature: (IJ[BII)Z
+ */
+JNIEXPORT jboolean JNICALL
+Java_edu_wpi_first_networktables_NetworkTablesJNI_setRaw
+  (JNIEnv* env, jclass, jint entry, jlong time, jbyteArray value, jint start, jint len)
+{
+  if (!value) {
+    nullPointerEx.Throw(env, "value is null");
+    return false;
+  }
+  if (start < 0) {
+    indexOobEx.Throw(env, "start must be >= 0");
+    return false;
+  }
+  if (len < 0) {
+    indexOobEx.Throw(env, "len must be >= 0");
+    return false;
+  }
+  CriticalJSpan<const jbyte> cvalue{env, value};
+  if (static_cast<unsigned int>(start + len) > cvalue.size()) {
+    indexOobEx.Throw(env, "start + len must be smaller than array length");
+    return false;
+  }
+  return nt::SetRaw(entry, cvalue.uarray().subspan(start, len), time);
+}
 
 /*
  * Class:     edu_wpi_first_networktables_NetworkTablesJNI
+ * Method:    setRawBuffer
+ * Signature: (IJLjava/nio/ByteBuffer;II)Z
+ */
+JNIEXPORT jboolean JNICALL
+Java_edu_wpi_first_networktables_NetworkTablesJNI_setRawBuffer
+  (JNIEnv* env, jclass, jint entry, jlong time, jobject value, jint start, jint len)
+{
+  if (!value) {
+    nullPointerEx.Throw(env, "value is null");
+    return false;
+  }
+  if (start < 0) {
+    indexOobEx.Throw(env, "start must be >= 0");
+    return false;
+  }
+  if (len < 0) {
+    indexOobEx.Throw(env, "len must be >= 0");
+    return false;
+  }
+  JSpan<const jbyte> cvalue{env, value, static_cast<size_t>(start + len)};
+  if (!cvalue) {
+    illegalArgEx.Throw(env, "value must be a native ByteBuffer");
+    return false;
+  }
+  return nt::SetRaw(entry, cvalue.uarray().subspan(start, len), time);
+}
+{% else %}
+/*
+ * Class:     edu_wpi_first_networktables_NetworkTablesJNI
  * Method:    set{{ t.TypeName }}
  * Signature: (IJ{{ t.jni.jtypestr }})Z
  */
@@ -203,7 +268,7 @@
 {%- endif %}
   return nt::Set{{ t.TypeName }}(entry, {{ t.jni.FromJavaBegin }}value{{ t.jni.FromJavaEnd }}, time);
 }
-
+{% endif %}
 /*
  * Class:     edu_wpi_first_networktables_NetworkTablesJNI
  * Method:    get{{ t.TypeName }}
@@ -223,9 +288,67 @@
   return nt::Get{{ t.TypeName }}(entry, defaultValue);
 {%- endif %}
 }
+{% if t.TypeName == "Raw" %}
+/*
+ * Class:     edu_wpi_first_networktables_NetworkTablesJNI
+ * Method:    setDefaultRaw
+ * Signature: (IJ[BII)Z
+ */
+JNIEXPORT jboolean JNICALL
+Java_edu_wpi_first_networktables_NetworkTablesJNI_setDefaultRaw
+  (JNIEnv* env, jclass, jint entry, jlong, jbyteArray defaultValue, jint start, jint len)
+{
+  if (!defaultValue) {
+    nullPointerEx.Throw(env, "value is null");
+    return false;
+  }
+  if (start < 0) {
+    indexOobEx.Throw(env, "start must be >= 0");
+    return false;
+  }
+  if (len < 0) {
+    indexOobEx.Throw(env, "len must be >= 0");
+    return false;
+  }
+  CriticalJSpan<const jbyte> cvalue{env, defaultValue};
+  if (static_cast<unsigned int>(start + len) > cvalue.size()) {
+    indexOobEx.Throw(env, "start + len must be smaller than array length");
+    return false;
+  }
+  return nt::SetDefaultRaw(entry, cvalue.uarray().subspan(start, len));
+}
 
 /*
  * Class:     edu_wpi_first_networktables_NetworkTablesJNI
+ * Method:    setDefaultRawBuffer
+ * Signature: (IJLjava/nio/ByteBuffer;II)Z
+ */
+JNIEXPORT jboolean JNICALL
+Java_edu_wpi_first_networktables_NetworkTablesJNI_setDefaultRawBuffer
+  (JNIEnv* env, jclass, jint entry, jlong, jobject defaultValue, jint start, jint len)
+{
+  if (!defaultValue) {
+    nullPointerEx.Throw(env, "value is null");
+    return false;
+  }
+  if (start < 0) {
+    indexOobEx.Throw(env, "start must be >= 0");
+    return false;
+  }
+  if (len < 0) {
+    indexOobEx.Throw(env, "len must be >= 0");
+    return false;
+  }
+  JSpan<const jbyte> cvalue{env, defaultValue, static_cast<size_t>(start + len)};
+  if (!cvalue) {
+    illegalArgEx.Throw(env, "value must be a native ByteBuffer");
+    return false;
+  }
+  return nt::SetDefaultRaw(entry, cvalue.uarray().subspan(start, len));
+}
+{% else %}
+/*
+ * Class:     edu_wpi_first_networktables_NetworkTablesJNI
  * Method:    setDefault{{ t.TypeName }}
  * Signature: (IJ{{ t.jni.jtypestr }})Z
  */
@@ -241,5 +364,6 @@
 {%- endif %}
   return nt::SetDefault{{ t.TypeName }}(entry, {{ t.jni.FromJavaBegin }}defaultValue{{ t.jni.FromJavaEnd }});
 }
+{% endif %}
 {% endfor %}
 }  // extern "C"
diff --git a/ntcore/src/generate/cpp/ntcore_cpp_types.cpp.jinja b/ntcore/src/generate/cpp/ntcore_cpp_types.cpp.jinja
index ba8bca7..cdaf27f 100644
--- a/ntcore/src/generate/cpp/ntcore_cpp_types.cpp.jinja
+++ b/ntcore/src/generate/cpp/ntcore_cpp_types.cpp.jinja
@@ -7,69 +7,124 @@
 #include "Handle.h"
 #include "InstanceImpl.h"
 
+namespace {
+template <nt::ValidType T>
+struct ValuesType {
+  using Vector =
+      std::vector<typename nt::TypeInfo<std::remove_cvref_t<T>>::Value>;
+};
+
+template <>
+struct ValuesType<bool> {
+  using Vector = std::vector<int>;
+};
+}  // namespace
+
 namespace nt {
-{% for t in types %}
-bool Set{{ t.TypeName }}(NT_Handle pubentry, {{ t.cpp.ParamType }} value, int64_t time) {
+
+template <ValidType T>
+static inline bool Set(NT_Handle pubentry, typename TypeInfo<T>::View value,
+                       int64_t time) {
   if (auto ii = InstanceImpl::Get(Handle{pubentry}.GetInst())) {
-    return ii->localStorage.SetEntryValue(pubentry,
-        Value::Make{{ t.TypeName }}(value, time == 0 ? Now() : time));
+    return ii->localStorage.SetEntryValue(
+        pubentry, MakeValue<T>(value, time == 0 ? Now() : time));
   } else {
     return {};
   }
 }
 
-bool SetDefault{{ t.TypeName }}(NT_Handle pubentry, {{ t.cpp.ParamType }} defaultValue) {
+template <ValidType T>
+static inline bool SetDefault(NT_Handle pubentry,
+                              typename TypeInfo<T>::View defaultValue) {
   if (auto ii = InstanceImpl::Get(Handle{pubentry}.GetInst())) {
     return ii->localStorage.SetDefaultEntryValue(pubentry,
-        Value::Make{{ t.TypeName }}(defaultValue, 1));
+                                                 MakeValue<T>(defaultValue, 1));
   } else {
     return {};
   }
 }
 
-{{ t.cpp.ValueType }} Get{{ t.TypeName }}(NT_Handle subentry, {{ t.cpp.ParamType }} defaultValue) {
-  return GetAtomic{{ t.TypeName }}(subentry, defaultValue).value;
-}
-
-Timestamped{{ t.TypeName }} GetAtomic{{ t.TypeName }}(NT_Handle subentry, {{ t.cpp.ParamType }} defaultValue) {
+template <ValidType T>
+static inline Timestamped<typename TypeInfo<T>::Value> GetAtomic(
+    NT_Handle subentry, typename TypeInfo<T>::View defaultValue) {
   if (auto ii = InstanceImpl::Get(Handle{subentry}.GetInst())) {
-    return ii->localStorage.GetAtomic{{ t.TypeName }}(subentry, defaultValue);
+    return ii->localStorage.GetAtomic<T>(subentry, defaultValue);
   } else {
     return {};
   }
 }
 
-std::vector<Timestamped{{ t.TypeName }}> ReadQueue{{ t.TypeName }}(NT_Handle subentry) {
+template <ValidType T>
+inline Timestamped<typename TypeInfo<T>::SmallRet> GetAtomic(
+    NT_Handle subentry,
+    wpi::SmallVectorImpl<typename TypeInfo<T>::SmallElem>& buf,
+    typename TypeInfo<T>::View defaultValue) {
   if (auto ii = InstanceImpl::Get(Handle{subentry}.GetInst())) {
-    return ii->localStorage.ReadQueue{{ t.TypeName }}(subentry);
+    return ii->localStorage.GetAtomic<T>(subentry, buf, defaultValue);
   } else {
     return {};
   }
 }
 
-std::vector<{% if t.cpp.ValueType == "bool" %}int{% else %}{{ t.cpp.ValueType }}{% endif %}> ReadQueueValues{{ t.TypeName }}(NT_Handle subentry) {
-  std::vector<{% if t.cpp.ValueType == "bool" %}int{% else %}{{ t.cpp.ValueType }}{% endif %}> rv;
-  auto arr = ReadQueue{{ t.TypeName }}(subentry);
+template <typename T>
+static inline std::vector<Timestamped<typename TypeInfo<T>::Value>> ReadQueue(
+    NT_Handle subentry) {
+  if (auto ii = InstanceImpl::Get(Handle{subentry}.GetInst())) {
+    return ii->localStorage.ReadQueue<T>(subentry);
+  } else {
+    return {};
+  }
+}
+
+template <typename T>
+static inline typename ValuesType<T>::Vector ReadQueueValues(
+    NT_Handle subentry) {
+  typename ValuesType<T>::Vector rv;
+  auto arr = ReadQueue<T>(subentry);
   rv.reserve(arr.size());
   for (auto&& elem : arr) {
     rv.emplace_back(std::move(elem.value));
   }
   return rv;
 }
+{% for t in types %}
+bool Set{{ t.TypeName }}(NT_Handle pubentry, {{ t.cpp.ParamType }} value, int64_t time) {
+  return Set<{{ t.cpp.TemplateType }}>(pubentry, value, time);
+}
+
+bool SetDefault{{ t.TypeName }}(NT_Handle pubentry, {{ t.cpp.ParamType }} defaultValue) {
+  return SetDefault<{{ t.cpp.TemplateType }}>(pubentry, defaultValue);
+}
+
+{{ t.cpp.ValueType }} Get{{ t.TypeName }}(NT_Handle subentry, {{ t.cpp.ParamType }} defaultValue) {
+  return GetAtomic<{{ t.cpp.TemplateType }}>(subentry, defaultValue).value;
+}
+
+Timestamped{{ t.TypeName }} GetAtomic{{ t.TypeName }}(
+    NT_Handle subentry, {{ t.cpp.ParamType }} defaultValue) {
+  return GetAtomic<{{ t.cpp.TemplateType }}>(subentry, defaultValue);
+}
+
+std::vector<Timestamped{{ t.TypeName }}> ReadQueue{{ t.TypeName }}(NT_Handle subentry) {
+  return ReadQueue<{{ t.cpp.TemplateType }}>(subentry);
+}
+
+std::vector<{% if t.cpp.ValueType == "bool" %}int{% else %}{{ t.cpp.ValueType }}{% endif %}> ReadQueueValues{{ t.TypeName }}(NT_Handle subentry) {
+  return ReadQueueValues<{{ t.cpp.TemplateType }}>(subentry);
+}
 {% if t.cpp.SmallRetType and t.cpp.SmallElemType %}
-{{ t.cpp.SmallRetType }} Get{{ t.TypeName }}(NT_Handle subentry, wpi::SmallVectorImpl<{{ t.cpp.SmallElemType }}>& buf, {{ t.cpp.ParamType }} defaultValue) {
-  return GetAtomic{{ t.TypeName }}(subentry, buf, defaultValue).value;
+{{ t.cpp.SmallRetType }} Get{{ t.TypeName }}(
+    NT_Handle subentry,
+    wpi::SmallVectorImpl<{{ t.cpp.SmallElemType }}>& buf,
+    {{ t.cpp.ParamType }} defaultValue) {
+  return GetAtomic<{{ t.cpp.TemplateType }}>(subentry, buf, defaultValue).value;
 }
 
 Timestamped{{ t.TypeName }}View GetAtomic{{ t.TypeName }}(
     NT_Handle subentry,
     wpi::SmallVectorImpl<{{ t.cpp.SmallElemType }}>& buf,
     {{ t.cpp.ParamType }} defaultValue) {
-  if (auto ii = InstanceImpl::Get(Handle{subentry}.GetInst())) {
-    return ii->localStorage.GetAtomic{{ t.TypeName }}(subentry, buf, defaultValue);
-  } else {
-    return {};
-  }
+  return GetAtomic<{{ t.cpp.TemplateType }}>(subentry, buf, defaultValue);
 }
 {% endif %}
 {% endfor %}
diff --git a/ntcore/src/generate/include/networktables/Topic.h.jinja b/ntcore/src/generate/include/networktables/Topic.h.jinja
index 84e80ec..ec2a915 100644
--- a/ntcore/src/generate/include/networktables/Topic.h.jinja
+++ b/ntcore/src/generate/include/networktables/Topic.h.jinja
@@ -11,12 +11,13 @@
 #include <string_view>
 #include <vector>
 
+#include <wpi/json_fwd.h>
+
 #include "networktables/Topic.h"
 
 namespace wpi {
 template <typename T>
 class SmallVectorImpl;
-class json;
 }  // namespace wpi
 
 namespace nt {
diff --git a/ntcore/src/generate/include/ntcore_c_types.h.jinja b/ntcore/src/generate/include/ntcore_c_types.h.jinja
index d5b2448..83cc807 100644
--- a/ntcore/src/generate/include/ntcore_c_types.h.jinja
+++ b/ntcore/src/generate/include/ntcore_c_types.h.jinja
@@ -15,7 +15,7 @@
 {% for t in types %}
 /**
  * Timestamped {{ t.TypeName }}.
- * @ingroup ntcore_c_handle_api
+ * @ingroup ntcore_c_api
  */
 struct NT_Timestamped{{ t.TypeName }} {
   /**
@@ -42,7 +42,7 @@
 
 /**
  * @defgroup ntcore_{{ t.TypeName }}_cfunc {{ t.TypeName }} Functions
- * @ingroup ntcore_c_handle_api
+ * @ingroup ntcore_c_api
  * @{
  */
 
diff --git a/ntcore/src/generate/include/ntcore_cpp_types.h.jinja b/ntcore/src/generate/include/ntcore_cpp_types.h.jinja
index e987186..df919de 100644
--- a/ntcore/src/generate/include/ntcore_cpp_types.h.jinja
+++ b/ntcore/src/generate/include/ntcore_cpp_types.h.jinja
@@ -20,56 +20,43 @@
 }  // namespace wpi
 
 namespace nt {
+/**
+ * Timestamped value.
+ * @ingroup ntcore_cpp_handle_api
+ */
+template <typename T>
+struct Timestamped {
+  Timestamped() = default;
+  Timestamped(int64_t time, int64_t serverTime, T value)
+    : time{time}, serverTime{serverTime}, value{std::move(value)} {}
+
+  /**
+   * Time in local time base.
+   */
+  int64_t time = 0;
+
+  /**
+   * Time in server time base.  May be 0 or 1 for locally set values.
+   */
+  int64_t serverTime = 0;
+
+  /**
+   * Value.
+   */
+  T value = {};
+};
 {% for t in types %}
 /**
  * Timestamped {{ t.TypeName }}.
  * @ingroup ntcore_cpp_handle_api
  */
-struct Timestamped{{ t.TypeName }} {
-  Timestamped{{ t.TypeName }}() = default;
-  Timestamped{{ t.TypeName }}(int64_t time, int64_t serverTime, {{ t.cpp.ValueType }} value)
-    : time{time}, serverTime{serverTime}, value{std::move(value)} {}
-
-  /**
-   * Time in local time base.
-   */
-  int64_t time = 0;
-
-  /**
-   * Time in server time base.  May be 0 or 1 for locally set values.
-   */
-  int64_t serverTime = 0;
-
-  /**
-   * Value.
-   */
-  {{ t.cpp.ValueType }} value = {};
-};
+using Timestamped{{ t.TypeName }} = Timestamped<{{ t.cpp.ValueType }}>;
 {% if t.cpp.SmallRetType %}
 /**
  * Timestamped {{ t.TypeName }} view (for SmallVector-taking functions).
  * @ingroup ntcore_cpp_handle_api
  */
-struct Timestamped{{ t.TypeName }}View {
-  Timestamped{{ t.TypeName }}View() = default;
-  Timestamped{{ t.TypeName }}View(int64_t time, int64_t serverTime, {{ t.cpp.SmallRetType }} value)
-    : time{time}, serverTime{serverTime}, value{std::move(value)} {}
-
-  /**
-   * Time in local time base.
-   */
-  int64_t time = 0;
-
-  /**
-   * Time in server time base.  May be 0 or 1 for locally set values.
-   */
-  int64_t serverTime = 0;
-
-  /**
-   * Value.
-   */
-  {{ t.cpp.SmallRetType }} value = {};
-};
+using Timestamped{{ t.TypeName }}View = Timestamped<{{ t.cpp.SmallRetType }}>;
 {% endif %}
 /**
  * @defgroup ntcore_{{ t.TypeName }}_func {{ t.TypeName }} Functions
diff --git a/ntcore/src/generate/java/EntryImpl.java.jinja b/ntcore/src/generate/java/EntryImpl.java.jinja
index 43b31e4..b7432a7 100644
--- a/ntcore/src/generate/java/EntryImpl.java.jinja
+++ b/ntcore/src/generate/java/EntryImpl.java.jinja
@@ -3,7 +3,9 @@
 // the WPILib BSD license file in the root directory of this project.
 
 package edu.wpi.first.networktables;
-
+{% if TypeName == "Raw" %}
+import java.nio.ByteBuffer;
+{% endif %}
 /** NetworkTables {{ TypeName }} implementation. */
 @SuppressWarnings("PMD.ArrayIsStoredDirectly")
 final class {{ TypeName }}EntryImpl extends EntryBase implements {{ TypeName }}Entry {
@@ -54,8 +56,28 @@
   public {{ java.ValueType }}[] readQueueValues() {
     return NetworkTablesJNI.readQueueValues{{ TypeName }}(m_handle);
   }
+{% if TypeName == "Raw" %}
+  @Override
+  public void set(byte[] value, int start, int len, long time) {
+    NetworkTablesJNI.setRaw(m_handle, time, value, start, len);
+  }
 
   @Override
+  public void set(ByteBuffer value, int start, int len, long time) {
+    NetworkTablesJNI.setRaw(m_handle, time, value, start, len);
+  }
+
+  @Override
+  public void setDefault(byte[] value, int start, int len) {
+    NetworkTablesJNI.setDefaultRaw(m_handle, 0, value, start, len);
+  }
+
+  @Override
+  public void setDefault(ByteBuffer value, int start, int len) {
+    NetworkTablesJNI.setDefaultRaw(m_handle, 0, value, start, len);
+  }
+{% else %}
+  @Override
   public void set({{ java.ValueType }} value, long time) {
     NetworkTablesJNI.set{{ TypeName }}(m_handle, time, value);
   }
@@ -64,7 +86,7 @@
   public void setDefault({{ java.ValueType }} value) {
     NetworkTablesJNI.setDefault{{ TypeName }}(m_handle, 0, value);
   }
-
+{% endif %}
   @Override
   public void unpublish() {
     NetworkTablesJNI.unpublish(m_handle);
diff --git a/ntcore/src/generate/java/GenericEntryImpl.java.jinja b/ntcore/src/generate/java/GenericEntryImpl.java.jinja
index e4296d7..29666bb 100644
--- a/ntcore/src/generate/java/GenericEntryImpl.java.jinja
+++ b/ntcore/src/generate/java/GenericEntryImpl.java.jinja
@@ -4,6 +4,8 @@
 
 package edu.wpi.first.networktables;
 
+import java.nio.ByteBuffer;
+
 /** NetworkTables generic implementation. */
 final class GenericEntryImpl extends EntryBase implements GenericEntry {
   /**
@@ -140,6 +142,33 @@
     }
   }
 {% for t in types %}
+{% if t.TypeName == "Raw" %}
+  /**
+   * Sets the entry's value.
+   *
+   * @param value the value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   * @return False if the entry exists with a different type
+   */
+  @Override
+  public boolean setRaw(byte[] value, int start, int len, long time) {
+    return NetworkTablesJNI.setRaw(m_handle, time, value, start, len);
+  }
+
+  /**
+   * Sets the entry's value.
+   *
+   * @param value the value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   * @return False if the entry exists with a different type
+   */
+  @Override
+  public boolean setRaw(ByteBuffer value, int start, int len, long time) {
+    return NetworkTablesJNI.setRaw(m_handle, time, value, start, len);
+  }
+{% else %}
   /**
    * Sets the entry's value.
    *
@@ -150,6 +179,7 @@
   public boolean set{{ t.TypeName }}({{ t.java.ValueType }} value, long time) {
     return NetworkTablesJNI.set{{ t.TypeName }}(m_handle, time, value);
   }
+{% endif -%}
 {% if t.java.WrapValueType %}
   /**
    * Sets the entry's value.
@@ -265,6 +295,33 @@
     }
   }
 {% for t in types %}
+{% if t.TypeName == "Raw" %}
+  /**
+   * Sets the entry's value if it does not exist.
+   *
+   * @param defaultValue the default value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   * @return False if the entry exists with a different type
+   */
+  @Override
+  public boolean setDefaultRaw(byte[] defaultValue, int start, int len) {
+    return NetworkTablesJNI.setDefaultRaw(m_handle, 0, defaultValue, start, len);
+  }
+
+  /**
+   * Sets the entry's value if it does not exist.
+   *
+   * @param defaultValue the default value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   * @return False if the entry exists with a different type
+   */
+  @Override
+  public boolean setDefaultRaw(ByteBuffer defaultValue, int start, int len) {
+    return NetworkTablesJNI.setDefaultRaw(m_handle, 0, defaultValue, start, len);
+  }
+{% else %}
   /**
    * Sets the entry's value if it does not exist.
    *
@@ -275,6 +332,7 @@
   public boolean setDefault{{ t.TypeName }}({{ t.java.ValueType }} defaultValue) {
     return NetworkTablesJNI.setDefault{{ t.TypeName }}(m_handle, 0, defaultValue);
   }
+{% endif -%}
 {% if t.java.WrapValueType %}
   /**
    * Sets the entry's value if it does not exist.
diff --git a/ntcore/src/generate/java/GenericPublisher.java.jinja b/ntcore/src/generate/java/GenericPublisher.java.jinja
index d747f17..881aba6 100644
--- a/ntcore/src/generate/java/GenericPublisher.java.jinja
+++ b/ntcore/src/generate/java/GenericPublisher.java.jinja
@@ -4,6 +4,7 @@
 
 package edu.wpi.first.networktables;
 
+import java.nio.ByteBuffer;
 import java.util.function.Consumer;
 
 /** NetworkTables generic publisher. */
@@ -54,6 +55,16 @@
   default boolean set{{ t.TypeName }}({{ t.java.ValueType }} value) {
     return set{{ t.TypeName }}(value, 0);
   }
+{% if t.TypeName == "Raw" %}
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @return False if the topic already exists with a different type
+   */
+  default boolean setRaw(ByteBuffer value) {
+    return setRaw(value, 0);
+  }
 
   /**
    * Publish a new value.
@@ -62,7 +73,77 @@
    * @param time timestamp; 0 indicates current NT time should be used
    * @return False if the topic already exists with a different type
    */
+  default boolean setRaw(byte[] value, long time) {
+    return setRaw(value, 0, value.length, time);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish; will send from value.position() to value.limit()
+   * @param time timestamp; 0 indicates current NT time should be used
+   * @return False if the topic already exists with a different type
+   */
+  default boolean setRaw(ByteBuffer value, long time) {
+    int pos = value.position();
+    return setRaw(value, pos, value.limit() - pos, time);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   * @return False if the topic already exists with a different type
+   */
+  default boolean setRaw(byte[] value, int start, int len) {
+    return setRaw(value, start, len, 0);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   * @param time timestamp; 0 indicates current NT time should be used
+   * @return False if the topic already exists with a different type
+   */
+  boolean setRaw(byte[] value, int start, int len, long time);
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   * @return False if the topic already exists with a different type
+   */
+  default boolean setRaw(ByteBuffer value, int start, int len) {
+    return setRaw(value, start, len, 0);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   * @param time timestamp; 0 indicates current NT time should be used
+   * @return False if the topic already exists with a different type
+   */
+  boolean setRaw(ByteBuffer value, int start, int len, long time);
+{% else %}
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param time timestamp; 0 indicates current NT time should be used
+   * @return False if the topic already exists with a different type
+   */
   boolean set{{ t.TypeName }}({{ t.java.ValueType }} value, long time);
+{% endif -%}
 {% if t.java.WrapValueType %}
   /**
    * Publish a new value.
@@ -101,6 +182,49 @@
    */
   boolean setDefaultValue(Object defaultValue);
 {% for t in types %}
+{% if t.TypeName == "Raw" %}
+  /**
+   * Sets the entry's value if it does not exist.
+   *
+   * @param defaultValue the default value to set
+   * @return False if the entry exists with a different type
+   */
+  default boolean setDefaultRaw(byte[] defaultValue) {
+    return setDefaultRaw(defaultValue, 0, defaultValue.length);
+  }
+
+  /**
+   * Sets the entry's value if it does not exist.
+   *
+   * @param defaultValue the default value to set; will send from defaultValue.position() to
+   *                     defaultValue.limit()
+   * @return False if the entry exists with a different type
+   */
+  default boolean setDefaultRaw(ByteBuffer defaultValue) {
+    int pos = defaultValue.position();
+    return setDefaultRaw(defaultValue, pos, defaultValue.limit() - pos);
+  }
+
+  /**
+   * Sets the entry's value if it does not exist.
+   *
+   * @param defaultValue the default value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   * @return False if the entry exists with a different type
+   */
+  boolean setDefaultRaw(byte[] defaultValue, int start, int len);
+
+  /**
+   * Sets the entry's value if it does not exist.
+   *
+   * @param defaultValue the default value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   * @return False if the entry exists with a different type
+   */
+  boolean setDefaultRaw(ByteBuffer defaultValue, int start, int len);
+{% else %}
   /**
    * Sets the entry's value if it does not exist.
    *
@@ -108,6 +232,7 @@
    * @return False if the entry exists with a different type
    */
   boolean setDefault{{ t.TypeName }}({{ t.java.ValueType }} defaultValue);
+{% endif -%}
 {% if t.java.WrapValueType %}
   boolean setDefault{{ t.TypeName }}({{ t.java.WrapValueType }} defaultValue);
 {% endif -%}
diff --git a/ntcore/src/generate/java/NetworkTableEntry.java.jinja b/ntcore/src/generate/java/NetworkTableEntry.java.jinja
index 783ec6f..7ee7d1f 100644
--- a/ntcore/src/generate/java/NetworkTableEntry.java.jinja
+++ b/ntcore/src/generate/java/NetworkTableEntry.java.jinja
@@ -4,6 +4,8 @@
 
 package edu.wpi.first.networktables;
 
+import java.nio.ByteBuffer;
+
 /**
  * NetworkTables Entry.
  *
@@ -310,6 +312,42 @@
   public boolean setDefault{{ t.TypeName }}({{ t.java.ValueType }} defaultValue) {
     return NetworkTablesJNI.setDefault{{ t.TypeName }}(m_handle, 0, defaultValue);
   }
+{% if t.TypeName == "Raw" %}
+  /**
+   * Sets the entry's value if it does not exist.
+   *
+   * @param defaultValue the default value to set; will send from defaultValue.position() to
+   *                     defaultValue.capacity()
+   * @return False if the entry exists with a different type
+   */
+  public boolean setDefaultRaw(ByteBuffer defaultValue) {
+    return NetworkTablesJNI.setDefaultRaw(m_handle, 0, defaultValue);
+  }
+
+  /**
+   * Sets the entry's value if it does not exist.
+   *
+   * @param defaultValue the default value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   * @return False if the entry exists with a different type
+   */
+  public boolean setDefaultRaw(byte[] defaultValue, int start, int len) {
+    return NetworkTablesJNI.setDefaultRaw(m_handle, 0, defaultValue, start, len);
+  }
+
+  /**
+   * Sets the entry's value if it does not exist.
+   *
+   * @param defaultValue the default value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   * @return False if the entry exists with a different type
+   */
+  public boolean setDefaultRaw(ByteBuffer defaultValue, int start, int len) {
+    return NetworkTablesJNI.setDefaultRaw(m_handle, 0, defaultValue, start, len);
+  }
+{% endif -%}
 {% if t.java.WrapValueType %}
   /**
    * Sets the entry's value if it does not exist.
@@ -427,6 +465,41 @@
   public boolean set{{ t.TypeName }}({{ t.java.ValueType }} value) {
     return NetworkTablesJNI.set{{ t.TypeName }}(m_handle, 0, value);
   }
+{% if t.TypeName == "Raw" %}
+  /**
+   * Sets the entry's value.
+   *
+   * @param value the value to set; will send from value.position() to value.capacity()
+   * @return False if the entry exists with a different type
+   */
+  public boolean setRaw(ByteBuffer value) {
+    return NetworkTablesJNI.setRaw(m_handle, 0, value);
+  }
+
+  /**
+   * Sets the entry's value.
+   *
+   * @param value the value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   * @return False if the entry exists with a different type
+   */
+  public boolean setRaw(byte[] value, int start, int len) {
+    return NetworkTablesJNI.setRaw(m_handle, 0, value, start, len);
+  }
+
+  /**
+   * Sets the entry's value.
+   *
+   * @param value the value to set
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   * @return False if the entry exists with a different type
+   */
+  public boolean setRaw(ByteBuffer value, int start, int len) {
+    return NetworkTablesJNI.setRaw(m_handle, 0, value, start, len);
+  }
+{% endif -%}
 {% if t.java.WrapValueType %}
   /**
    * Sets the entry's value.
diff --git a/ntcore/src/generate/java/NetworkTableInstance.java.jinja b/ntcore/src/generate/java/NetworkTableInstance.java.jinja
index ba5e33f..9df129d 100644
--- a/ntcore/src/generate/java/NetworkTableInstance.java.jinja
+++ b/ntcore/src/generate/java/NetworkTableInstance.java.jinja
@@ -7,16 +7,22 @@
 import edu.wpi.first.util.WPIUtilJNI;
 import edu.wpi.first.util.concurrent.Event;
 import edu.wpi.first.util.datalog.DataLog;
+import edu.wpi.first.util.protobuf.Protobuf;
+import edu.wpi.first.util.struct.Struct;
+import java.nio.charset.StandardCharsets;
 import java.util.EnumSet;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.OptionalLong;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.Condition;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.function.Consumer;
+import us.hebi.quickbuf.ProtoMessage;
 
 /**
  * NetworkTables Instance.
@@ -86,6 +92,7 @@
   public synchronized void close() {
     if (m_owned && m_handle != 0) {
       m_listeners.close();
+      m_schemas.forEach((k, v) -> v.close());
       NetworkTablesJNI.destroyInstance(m_handle);
       m_handle = 0;
     }
@@ -176,15 +183,119 @@
       handle = topic.getHandle();
     }
 
-    topic = new {{ t.TypeName }}Topic(this, handle);
-    m_topics.put(name, topic);
+    {{ t.TypeName }}Topic wrapTopic = new {{ t.TypeName }}Topic(this, handle);
+    m_topics.put(name, wrapTopic);
 
     // also cache by handle
-    m_topicsByHandle.put(handle, topic);
+    m_topicsByHandle.put(handle, wrapTopic);
 
-    return ({{ t.TypeName }}Topic) topic;
+    return wrapTopic;
   }
 {% endfor %}
+
+  /**
+   * Get protobuf-encoded value topic.
+   *
+   * @param <T> value class (inferred from proto)
+   * @param <MessageType> protobuf message type (inferred from proto)
+   * @param name topic name
+   * @param proto protobuf serialization implementation
+   * @return ProtobufTopic
+   */
+  public <T, MessageType extends ProtoMessage<?>>
+      ProtobufTopic<T> getProtobufTopic(String name, Protobuf<T, MessageType> proto) {
+    Topic topic = m_topics.get(name);
+    if (topic instanceof ProtobufTopic<?>
+        && ((ProtobufTopic<?>) topic).getProto().equals(proto)) {
+      @SuppressWarnings("unchecked")
+      ProtobufTopic<T> wrapTopic = (ProtobufTopic<T>) topic;
+      return wrapTopic;
+    }
+
+    int handle;
+    if (topic == null) {
+      handle = NetworkTablesJNI.getTopic(m_handle, name);
+    } else {
+      handle = topic.getHandle();
+    }
+
+    ProtobufTopic<T> wrapTopic = ProtobufTopic.wrap(this, handle, proto);
+    m_topics.put(name, wrapTopic);
+
+    // also cache by handle
+    m_topicsByHandle.put(handle, wrapTopic);
+
+    return wrapTopic;
+  }
+
+  /**
+   * Get struct-encoded value topic.
+   *
+   * @param <T> value class (inferred from struct)
+   * @param name topic name
+   * @param struct struct serialization implementation
+   * @return StructTopic
+   */
+  public <T>
+      StructTopic<T> getStructTopic(String name, Struct<T> struct) {
+    Topic topic = m_topics.get(name);
+    if (topic instanceof StructTopic<?>
+        && ((StructTopic<?>) topic).getStruct().equals(struct)) {
+      @SuppressWarnings("unchecked")
+      StructTopic<T> wrapTopic = (StructTopic<T>) topic;
+      return wrapTopic;
+    }
+
+    int handle;
+    if (topic == null) {
+      handle = NetworkTablesJNI.getTopic(m_handle, name);
+    } else {
+      handle = topic.getHandle();
+    }
+
+    StructTopic<T> wrapTopic = StructTopic.wrap(this, handle, struct);
+    m_topics.put(name, wrapTopic);
+
+    // also cache by handle
+    m_topicsByHandle.put(handle, wrapTopic);
+
+    return wrapTopic;
+  }
+
+  /**
+   * Get struct-encoded value array topic.
+   *
+   * @param <T> value class (inferred from struct)
+   * @param name topic name
+   * @param struct struct serialization implementation
+   * @return StructArrayTopic
+   */
+  public <T>
+      StructArrayTopic<T> getStructArrayTopic(String name, Struct<T> struct) {
+    Topic topic = m_topics.get(name);
+    if (topic instanceof StructArrayTopic<?>
+        && ((StructArrayTopic<?>) topic).getStruct().equals(struct)) {
+      @SuppressWarnings("unchecked")
+      StructArrayTopic<T> wrapTopic = (StructArrayTopic<T>) topic;
+      return wrapTopic;
+    }
+
+    int handle;
+    if (topic == null) {
+      handle = NetworkTablesJNI.getTopic(m_handle, name);
+    } else {
+      handle = topic.getHandle();
+    }
+
+    StructArrayTopic<T> wrapTopic = StructArrayTopic.wrap(this, handle, struct);
+    m_topics.put(name, wrapTopic);
+
+    // also cache by handle
+    m_topicsByHandle.put(handle, wrapTopic);
+
+    return wrapTopic;
+  }
+
   private Topic[] topicHandlesToTopics(int[] handles) {
     Topic[] topics = new Topic[handles.length];
     for (int i = 0; i < handles.length; i++) {
@@ -553,7 +664,7 @@
     }
   }
 
-  private ListenerStorage m_listeners = new ListenerStorage(this);
+  private final ListenerStorage m_listeners = new ListenerStorage(this);
 
   /**
    * Remove a connection listener.
@@ -912,6 +1023,14 @@
   }
 
   /**
+   * Disconnects the client if it's running and connected. This will automatically start
+   * reconnection attempts to the current server list.
+   */
+  public void disconnect() {
+    NetworkTablesJNI.disconnect(m_handle);
+  }
+
+  /**
    * Starts requesting server address from Driver Station. This connects to the Driver Station
    * running on localhost to obtain the server IP address, and connects with the default port.
    */
@@ -1042,6 +1161,78 @@
     return m_listeners.addLogger(minLevel, maxLevel, func);
   }
 
+  /**
+   * Returns whether there is a data schema already registered with the given name that this
+   * instance has published. This does NOT perform a check as to whether the schema has already
+   * been published by another node on the network.
+   *
+   * @param name Name (the string passed as the data type for topics using this schema)
+   * @return True if schema already registered
+   */
+  public boolean hasSchema(String name) {
+    return m_schemas.containsKey("/.schema/" + name);
+  }
+
+  /**
+   * Registers a data schema. Data schemas provide information for how a certain data type string
+   * can be decoded. The type string of a data schema indicates the type of the schema itself (e.g.
+   * "protobuf" for protobuf schemas, "struct" for struct schemas, etc). In NetworkTables, schemas
+   * are published just like normal topics, with the name being generated from the provided name:
+   * "/.schema/name". Duplicate calls to this function with the same name are silently ignored.
+   *
+   * @param name Name (the string passed as the data type for topics using this schema)
+   * @param type Type of schema (e.g. "protobuf", "struct", etc)
+   * @param schema Schema data
+   */
+  public void addSchema(String name, String type, byte[] schema) {
+    m_schemas.computeIfAbsent("/.schema/" + name, k -> {
+      RawPublisher pub = getRawTopic(k).publishEx(type, "{\"retained\":true}");
+      pub.setDefault(schema);
+      return pub;
+    });
+  }
+
+  /**
+   * Registers a data schema. Data schemas provide information for how a certain data type string
+   * can be decoded. The type string of a data schema indicates the type of the schema itself (e.g.
+   * "protobuf" for protobuf schemas, "struct" for struct schemas, etc). In NetworkTables, schemas
+   * are published just like normal topics, with the name being generated from the provided name:
+   * "/.schema/name". Duplicate calls to this function with the same name are silently ignored.
+   *
+   * @param name Name (the string passed as the data type for topics using this schema)
+   * @param type Type of schema (e.g. "protobuf", "struct", etc)
+   * @param schema Schema data
+   */
+  public void addSchema(String name, String type, String schema) {
+    m_schemas.computeIfAbsent("/.schema/" + name, k -> {
+      RawPublisher pub = getRawTopic(k).publishEx(type, "{\"retained\":true}");
+      pub.setDefault(StandardCharsets.UTF_8.encode(schema));
+      return pub;
+    });
+  }
+
+  /**
+   * Registers a protobuf schema. Duplicate calls to this function with the same name are silently
+   * ignored.
+   *
+   * @param proto protobuf serialization object
+   */
+  public void addSchema(Protobuf<?, ?> proto) {
+    proto.forEachDescriptor(
+        this::hasSchema,
+        (typeString, schema) -> addSchema(typeString, "proto:FileDescriptorProto", schema));
+  }
+
+  /**
+   * Registers a struct schema. Duplicate calls to this function with the same name are silently
+   * ignored.
+   *
+   * @param struct struct serialization object
+   */
+  public void addSchema(Struct<?> struct) {
+    addSchemaImpl(struct, new HashSet<>());
+  }
+
   @Override
   public boolean equals(Object other) {
     if (other == this) {
@@ -1059,6 +1250,22 @@
     return m_handle;
   }
 
+  private void addSchemaImpl(Struct<?> struct, Set<String> seen) {
+    String typeString = struct.getTypeString();
+    if (hasSchema(typeString)) {
+      return;
+    }
+    if (!seen.add(typeString)) {
+      throw new UnsupportedOperationException(typeString + ": circular reference with " + seen);
+    }
+    addSchema(typeString, "structschema", struct.getSchema());
+    for (Struct<?> inner : struct.getNested()) {
+      addSchemaImpl(inner, seen);
+    }
+    seen.remove(typeString);
+  }
+
   private boolean m_owned;
   private int m_handle;
+  private final ConcurrentMap<String, RawPublisher> m_schemas = new ConcurrentHashMap<>();
 }
diff --git a/ntcore/src/generate/java/NetworkTablesJNI.java.jinja b/ntcore/src/generate/java/NetworkTablesJNI.java.jinja
index 2f119fe..6ff9c16 100644
--- a/ntcore/src/generate/java/NetworkTablesJNI.java.jinja
+++ b/ntcore/src/generate/java/NetworkTablesJNI.java.jinja
@@ -7,6 +7,7 @@
 import edu.wpi.first.util.RuntimeLoader;
 import edu.wpi.first.util.datalog.DataLog;
 import java.io.IOException;
+import java.nio.ByteBuffer;
 import java.util.EnumSet;
 import java.util.OptionalLong;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -182,12 +183,77 @@
   public static native Timestamped{{ t.TypeName }}[] readQueue{{ t.TypeName }}(int subentry);
 
   public static native {{ t.java.ValueType }}[] readQueueValues{{ t.TypeName }}(int subentry);
+{% if t.TypeName == "Raw" %}
+  public static boolean setRaw(int entry, long time, byte[] value) {
+    return setRaw(entry, time, value, 0, value.length);
+  }
 
+  public static native boolean setRaw(int entry, long time, byte[] value, int start, int len);
+
+  public static boolean setRaw(int entry, long time, ByteBuffer value) {
+    int pos = value.position();
+    return setRaw(entry, time, value, pos, value.capacity() - pos);
+  }
+
+  public static boolean setRaw(int entry, long time, ByteBuffer value, int start, int len) {
+    if (value.isDirect()) {
+      if (start < 0) {
+        throw new IndexOutOfBoundsException("start must be >= 0");
+      }
+      if (len < 0) {
+        throw new IndexOutOfBoundsException("len must be >= 0");
+      }
+      if ((start + len) > value.capacity()) {
+        throw new IndexOutOfBoundsException("start + len must be smaller than buffer capacity");
+      }
+      return setRawBuffer(entry, time, value, start, len);
+    } else if (value.hasArray()) {
+      return setRaw(entry, time, value.array(), value.arrayOffset() + start, len);
+    } else {
+      throw new UnsupportedOperationException("ByteBuffer must be direct or have a backing array");
+    }
+  }
+
+  private static native boolean setRawBuffer(int entry, long time, ByteBuffer value, int start, int len);
+{% else %}
   public static native boolean set{{ t.TypeName }}(int entry, long time, {{ t.java.ValueType }} value);
-
+{% endif %}
   public static native {{ t.java.ValueType }} get{{ t.TypeName }}(int entry, {{ t.java.ValueType }} defaultValue);
+{% if t.TypeName == "Raw" %}
+  public static boolean setDefaultRaw(int entry, long time, byte[] defaultValue) {
+    return setDefaultRaw(entry, time, defaultValue, 0, defaultValue.length);
+  }
 
+  public static native boolean setDefaultRaw(int entry, long time, byte[] defaultValue, int start, int len);
+
+  public static boolean setDefaultRaw(int entry, long time, ByteBuffer defaultValue) {
+    int pos = defaultValue.position();
+    return setDefaultRaw(entry, time, defaultValue, pos, defaultValue.limit() - pos);
+  }
+
+  public static boolean setDefaultRaw(int entry, long time, ByteBuffer defaultValue, int start, int len) {
+    if (defaultValue.isDirect()) {
+      if (start < 0) {
+        throw new IndexOutOfBoundsException("start must be >= 0");
+      }
+      if (len < 0) {
+        throw new IndexOutOfBoundsException("len must be >= 0");
+      }
+      if ((start + len) > defaultValue.capacity()) {
+        throw new IndexOutOfBoundsException("start + len must be smaller than buffer capacity");
+      }
+      return setDefaultRawBuffer(entry, time, defaultValue, start, len);
+    } else if (defaultValue.hasArray()) {
+      return setDefaultRaw(entry, time, defaultValue.array(), defaultValue.arrayOffset() + start, len);
+    } else {
+      throw new UnsupportedOperationException("ByteBuffer must be direct or have a backing array");
+    }
+  }
+
+  private static native boolean setDefaultRawBuffer(int entry, long time, ByteBuffer defaultValue, int start, int len);
+{% else %}
   public static native boolean setDefault{{ t.TypeName }}(int entry, long time, {{ t.java.ValueType }} defaultValue);
+{% endif %}
 {% endfor %}
   public static native NetworkTableValue[] readQueueValue(int subentry);
 
@@ -251,6 +317,8 @@
 
   public static native void setServerTeam(int inst, int team, int port);
 
+  public static native void disconnect(int inst);
+
   public static native void startDSClient(int inst, int port);
 
   public static native void stopDSClient(int inst);
diff --git a/ntcore/src/generate/java/Publisher.java.jinja b/ntcore/src/generate/java/Publisher.java.jinja
index 19ead36..a403d91 100644
--- a/ntcore/src/generate/java/Publisher.java.jinja
+++ b/ntcore/src/generate/java/Publisher.java.jinja
@@ -4,6 +4,9 @@
 
 package edu.wpi.first.networktables;
 
+{% if TypeName == "Raw" %}
+import java.nio.ByteBuffer;
+{% endif -%}
 import {{ java.ConsumerFunctionPackage|default('java.util.function') }}.{{ java.FunctionTypePrefix }}Consumer;
 
 /** NetworkTables {{ TypeName }} publisher. */
@@ -25,6 +28,124 @@
     set(value, 0);
   }
 
+{% if TypeName == "Raw" %}
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  default void set(byte[] value, long time) {
+    set(value, 0, value.length, time);
+  }
+
+  /**
+   * Publish a new value using current NT time.
+   *
+   * @param value value to publish
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   */
+  default void set(byte[] value, int start, int len) {
+    set(value, start, len, 0);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  void set(byte[] value, int start, int len, long time);
+
+  /**
+   * Publish a new value using current NT time.
+   *
+   * @param value value to publish; will send from value.position() to value.limit()
+   */
+  default void set(ByteBuffer value) {
+    set(value, 0);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish; will send from value.position() to value.limit()
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  default void set(ByteBuffer value, long time) {
+    int pos = value.position();
+    set(value, pos, value.limit() - pos, time);
+  }
+
+  /**
+   * Publish a new value using current NT time.
+   *
+   * @param value value to publish
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   */
+  default void set(ByteBuffer value, int start, int len) {
+    set(value, start, len, 0);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  void set(ByteBuffer value, int start, int len, long time);
+
+  /**
+   * Publish a default value.
+   * On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   */
+  default void setDefault(byte[] value) {
+    setDefault(value, 0, value.length);
+  }
+
+  /**
+   * Publish a default value.
+   * On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.length - start)
+   */
+  void setDefault(byte[] value, int start, int len);
+
+  /**
+   * Publish a default value.
+   * On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value; will send from value.position() to value.limit()
+   */
+  default void setDefault(ByteBuffer value) {
+    int pos = value.position();
+    setDefault(value, pos, value.limit() - pos);
+  }
+
+  /**
+   * Publish a default value.
+   * On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   * @param start Start position of data (in buffer)
+   * @param len Length of data (must be less than or equal to value.capacity() - start)
+   */
+  void setDefault(ByteBuffer value, int start, int len);
+{% else %}
   /**
    * Publish a new value.
    *
@@ -41,7 +162,7 @@
    * @param value value
    */
   void setDefault({{ java.ValueType }} value);
-
+{% endif %}
   @Override
   default void accept({{ java.ValueType }} value) {
     set(value);
diff --git a/ntcore/src/generate/types.json b/ntcore/src/generate/types.json
index 31a71b3..3f9f252 100644
--- a/ntcore/src/generate/types.json
+++ b/ntcore/src/generate/types.json
@@ -1,366 +1,377 @@
 [
-    {
-        "TypeName": "Boolean",
-        "TypeString": "\"boolean\"",
-        "c": {
-            "ValueType": "NT_Bool",
-            "ParamType": "NT_Bool"
-        },
-        "cpp": {
-            "ValueType": "bool",
-            "ParamType": "bool",
-            "TYPE_NAME": "BOOLEAN"
-        },
-        "java": {
-            "ValueType": "boolean",
-            "EmptyValue": "false",
-            "ConsumerFunctionPackage": "edu.wpi.first.util.function",
-            "FunctionTypePrefix": "Boolean",
-            "ToWrapObject": "Boolean.valueOf",
-            "FromStorageBegin": "(Boolean) "
-        },
-        "jni": {
-            "jtype": "jboolean",
-            "jtypestr": "Z",
-            "JavaObject": false,
-            "FromJavaBegin": "",
-            "FromJavaEnd": " != JNI_FALSE",
-            "ToJavaBegin": "static_cast<jboolean>(",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJBooleanArray"
-        }
+  {
+    "TypeName": "Boolean",
+    "TypeString": "\"boolean\"",
+    "c": {
+      "ValueType": "NT_Bool",
+      "ParamType": "NT_Bool"
     },
-    {
-        "TypeName": "Integer",
-        "TypeString": "\"int\"",
-        "c": {
-            "ValueType": "int64_t",
-            "ParamType": "int64_t"
-        },
-        "cpp": {
-            "ValueType": "int64_t",
-            "ParamType": "int64_t",
-            "TYPE_NAME": "INTEGER"
-        },
-        "java": {
-            "ValueType": "long",
-            "EmptyValue": "0",
-            "FunctionTypePrefix": "Long",
-            "ToWrapObject": "Long.valueOf",
-            "FromStorageBegin": "((Number) ",
-            "FromStorageEnd": ").longValue()"
-        },
-        "jni": {
-            "jtype": "jlong",
-            "jtypestr": "J",
-            "JavaObject": false,
-            "FromJavaBegin": "",
-            "FromJavaEnd": "",
-            "ToJavaBegin": "static_cast<jlong>(",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJLongArray"
-        }
+    "cpp": {
+      "ValueType": "bool",
+      "ParamType": "bool",
+      "TemplateType": "bool",
+      "TYPE_NAME": "BOOLEAN"
     },
-    {
-        "TypeName": "Float",
-        "TypeString": "\"float\"",
-        "c": {
-            "ValueType": "float",
-            "ParamType": "float"
-        },
-        "cpp": {
-            "ValueType": "float",
-            "ParamType": "float",
-            "TYPE_NAME": "FLOAT"
-        },
-        "java": {
-            "ValueType": "float",
-            "EmptyValue": "0",
-            "ConsumerFunctionPackage": "edu.wpi.first.util.function",
-            "SupplierFunctionPackage": "edu.wpi.first.util.function",
-            "FunctionTypePrefix": "Float",
-            "ToWrapObject": "Float.valueOf",
-            "FromStorageBegin": "((Number) ",
-            "FromStorageEnd": ").floatValue()"
-        },
-        "jni": {
-            "jtype": "jfloat",
-            "jtypestr": "F",
-            "JavaObject": false,
-            "FromJavaBegin": "",
-            "FromJavaEnd": "",
-            "ToJavaBegin": "static_cast<jfloat>(",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJFloatArray"
-        }
+    "java": {
+      "ValueType": "boolean",
+      "EmptyValue": "false",
+      "ConsumerFunctionPackage": "edu.wpi.first.util.function",
+      "FunctionTypePrefix": "Boolean",
+      "ToWrapObject": "Boolean.valueOf",
+      "FromStorageBegin": "(Boolean) "
     },
-    {
-        "TypeName": "Double",
-        "TypeString": "\"double\"",
-        "c": {
-            "ValueType": "double",
-            "ParamType": "double"
-        },
-        "cpp": {
-            "ValueType": "double",
-            "ParamType": "double",
-            "TYPE_NAME": "DOUBLE"
-        },
-        "java": {
-            "ValueType": "double",
-            "EmptyValue": "0",
-            "FunctionTypePrefix": "Double",
-            "ToWrapObject": "Double.valueOf",
-            "FromStorageBegin": "((Number) ",
-            "FromStorageEnd": ").doubleValue()"
-        },
-        "jni": {
-            "jtype": "jdouble",
-            "jtypestr": "D",
-            "JavaObject": false,
-            "FromJavaBegin": "",
-            "FromJavaEnd": "",
-            "ToJavaBegin": "static_cast<jdouble>(",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJDoubleArray"
-        }
-    },
-    {
-        "TypeName": "String",
-        "TypeString": "\"string\"",
-        "c": {
-            "ValueType": "char*",
-            "ParamType": "const char*",
-            "IsArray": true
-        },
-        "cpp": {
-            "ValueType": "std::string",
-            "ParamType": "std::string_view",
-            "TYPE_NAME": "STRING",
-            "INCLUDES": "#include <string>\n#include <string_view>\n#include <utility>",
-            "SmallRetType": "std::string_view",
-            "SmallElemType": "char"
-        },
-        "java": {
-            "ValueType": "String",
-            "EmptyValue": "\"\"",
-            "FunctionTypeSuffix": "<String>",
-            "FromStorageBegin": "(String) "
-        },
-        "jni": {
-            "jtype": "jstring",
-            "jtypestr": "Ljava/lang/String;",
-            "JavaObject": true,
-            "FromJavaBegin": "JStringRef{env, ",
-            "FromJavaEnd": "}",
-            "ToJavaBegin": "MakeJString(env, ",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJStringArray"
-        }
-    },
-    {
-        "TypeName": "Raw",
-        "c": {
-            "ValueType": "uint8_t*",
-            "ParamType": "const uint8_t*",
-            "IsArray": true
-        },
-        "cpp": {
-            "ValueType": "std::vector<uint8_t>",
-            "ParamType": "std::span<const uint8_t>",
-            "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
-            "TYPE_NAME": "RAW",
-            "INCLUDES": "#include <utility>",
-            "SmallRetType": "std::span<uint8_t>",
-            "SmallElemType": "uint8_t"
-        },
-        "java": {
-            "ValueType": "byte[]",
-            "EmptyValue": "new byte[] {}",
-            "FunctionTypeSuffix": "<byte[]>",
-            "FromStorageBegin": "(byte[]) "
-        },
-        "jni": {
-            "jtype": "jbyteArray",
-            "jtypestr": "[B",
-            "JavaObject": true,
-            "FromJavaBegin": "CriticalJByteArrayRef{env, ",
-            "FromJavaEnd": "}.uarray()",
-            "ToJavaBegin": "MakeJByteArray(env, ",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJObjectArray"
-        }
-    },
-    {
-        "TypeName": "BooleanArray",
-        "TypeString": "\"boolean[]\"",
-        "c": {
-            "ValueType": "NT_Bool*",
-            "ParamType": "const NT_Bool*",
-            "IsArray": true
-        },
-        "cpp": {
-            "ValueType": "std::vector<int>",
-            "ParamType": "std::span<const int>",
-            "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
-            "TYPE_NAME": "BOOLEAN_ARRAY",
-            "INCLUDES": "#include <utility>",
-            "SmallRetType": "std::span<int>",
-            "SmallElemType": "int"
-        },
-        "java": {
-            "ValueType": "boolean[]",
-            "WrapValueType": "Boolean[]",
-            "EmptyValue": "new boolean[] {}",
-            "FunctionTypeSuffix": "<boolean[]>",
-            "FromStorageBegin": "(boolean[]) "
-        },
-        "jni": {
-            "jtype": "jbooleanArray",
-            "jtypestr": "[Z",
-            "JavaObject": true,
-            "FromJavaBegin": "FromJavaBooleanArray(env, ",
-            "FromJavaEnd": ")",
-            "ToJavaBegin": "MakeJBooleanArray(env, ",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJObjectArray"
-        }
-    },
-    {
-        "TypeName": "IntegerArray",
-        "TypeString": "\"int[]\"",
-        "c": {
-            "ValueType": "int64_t*",
-            "ParamType": "const int64_t*",
-            "IsArray": true
-        },
-        "cpp": {
-            "ValueType": "std::vector<int64_t>",
-            "ParamType": "std::span<const int64_t>",
-            "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
-            "TYPE_NAME": "INTEGER_ARRAY",
-            "INCLUDES": "#include <utility>",
-            "SmallRetType": "std::span<int64_t>",
-            "SmallElemType": "int64_t"
-        },
-        "java": {
-            "ValueType": "long[]",
-            "WrapValueType": "Long[]",
-            "EmptyValue": "new long[] {}",
-            "FunctionTypeSuffix": "<long[]>",
-            "FromStorageBegin": "(long[]) "
-        },
-        "jni": {
-            "jtype": "jlongArray",
-            "jtypestr": "[J",
-            "JavaObject": true,
-            "FromJavaBegin": "CriticalJLongArrayRef{env, ",
-            "FromJavaEnd": "}",
-            "ToJavaBegin": "MakeJLongArray(env, ",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJObjectArray"
-        }
-    },
-    {
-        "TypeName": "FloatArray",
-        "TypeString": "\"float[]\"",
-        "c": {
-            "ValueType": "float*",
-            "ParamType": "const float*",
-            "IsArray": true
-        },
-        "cpp": {
-            "ValueType": "std::vector<float>",
-            "ParamType": "std::span<const float>",
-            "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
-            "TYPE_NAME": "FLOAT_ARRAY",
-            "INCLUDES": "#include <utility>",
-            "SmallRetType": "std::span<float>",
-            "SmallElemType": "float"
-        },
-        "java": {
-            "ValueType": "float[]",
-            "WrapValueType": "Float[]",
-            "EmptyValue": "new float[] {}",
-            "FunctionTypeSuffix": "<float[]>",
-            "FromStorageBegin": "(float[]) "
-        },
-        "jni": {
-            "jtype": "jfloatArray",
-            "jtypestr": "[F",
-            "JavaObject": true,
-            "FromJavaBegin": "CriticalJFloatArrayRef{env, ",
-            "FromJavaEnd": "}",
-            "ToJavaBegin": "MakeJFloatArray(env, ",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJObjectArray"
-        }
-    },
-    {
-        "TypeName": "DoubleArray",
-        "TypeString": "\"double[]\"",
-        "c": {
-            "ValueType": "double*",
-            "ParamType": "const double*",
-            "IsArray": true
-        },
-        "cpp": {
-            "ValueType": "std::vector<double>",
-            "ParamType": "std::span<const double>",
-            "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
-            "TYPE_NAME": "DOUBLE_ARRAY",
-            "INCLUDES": "#include <utility>",
-            "SmallRetType": "std::span<double>",
-            "SmallElemType": "double"
-        },
-        "java": {
-            "ValueType": "double[]",
-            "WrapValueType": "Double[]",
-            "EmptyValue": "new double[] {}",
-            "FunctionTypeSuffix": "<double[]>",
-            "FromStorageBegin": "(double[]) "
-        },
-        "jni": {
-            "jtype": "jdoubleArray",
-            "jtypestr": "[D",
-            "JavaObject": true,
-            "FromJavaBegin": "CriticalJDoubleArrayRef{env, ",
-            "FromJavaEnd": "}",
-            "ToJavaBegin": "MakeJDoubleArray(env, ",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJObjectArray"
-        }
-    },
-    {
-        "TypeName": "StringArray",
-        "TypeString": "\"string[]\"",
-        "c": {
-            "ValueType": "struct NT_String*",
-            "ParamType": "const struct NT_String*",
-            "IsArray": true
-        },
-        "cpp": {
-            "ValueType": "std::vector<std::string>",
-            "ParamType": "std::span<const std::string>",
-            "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
-            "TYPE_NAME": "STRING_ARRAY",
-            "INCLUDES": "#include <utility>"
-        },
-        "java": {
-            "ValueType": "String[]",
-            "EmptyValue": "new String[] {}",
-            "FunctionTypeSuffix": "<String[]>",
-            "FromStorageBegin": "(String[]) "
-        },
-        "jni": {
-            "jtype": "jobjectArray",
-            "jtypestr": "[Ljava/lang/Object;",
-            "JavaObject": true,
-            "FromJavaBegin": "FromJavaStringArray(env, ",
-            "FromJavaEnd": ")",
-            "ToJavaBegin": "MakeJStringArray(env, ",
-            "ToJavaEnd": ")",
-            "ToJavaArray": "MakeJObjectArray"
-        }
+    "jni": {
+      "jtype": "jboolean",
+      "jtypestr": "Z",
+      "JavaObject": false,
+      "FromJavaBegin": "",
+      "FromJavaEnd": " != JNI_FALSE",
+      "ToJavaBegin": "static_cast<jboolean>(",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJBooleanArray"
     }
+  },
+  {
+    "TypeName": "Integer",
+    "TypeString": "\"int\"",
+    "c": {
+      "ValueType": "int64_t",
+      "ParamType": "int64_t"
+    },
+    "cpp": {
+      "ValueType": "int64_t",
+      "ParamType": "int64_t",
+      "TemplateType": "int64_t",
+      "TYPE_NAME": "INTEGER"
+    },
+    "java": {
+      "ValueType": "long",
+      "EmptyValue": "0",
+      "FunctionTypePrefix": "Long",
+      "ToWrapObject": "Long.valueOf",
+      "FromStorageBegin": "((Number) ",
+      "FromStorageEnd": ").longValue()"
+    },
+    "jni": {
+      "jtype": "jlong",
+      "jtypestr": "J",
+      "JavaObject": false,
+      "FromJavaBegin": "",
+      "FromJavaEnd": "",
+      "ToJavaBegin": "static_cast<jlong>(",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJLongArray"
+    }
+  },
+  {
+    "TypeName": "Float",
+    "TypeString": "\"float\"",
+    "c": {
+      "ValueType": "float",
+      "ParamType": "float"
+    },
+    "cpp": {
+      "ValueType": "float",
+      "ParamType": "float",
+      "TemplateType": "float",
+      "TYPE_NAME": "FLOAT"
+    },
+    "java": {
+      "ValueType": "float",
+      "EmptyValue": "0",
+      "ConsumerFunctionPackage": "edu.wpi.first.util.function",
+      "SupplierFunctionPackage": "edu.wpi.first.util.function",
+      "FunctionTypePrefix": "Float",
+      "ToWrapObject": "Float.valueOf",
+      "FromStorageBegin": "((Number) ",
+      "FromStorageEnd": ").floatValue()"
+    },
+    "jni": {
+      "jtype": "jfloat",
+      "jtypestr": "F",
+      "JavaObject": false,
+      "FromJavaBegin": "",
+      "FromJavaEnd": "",
+      "ToJavaBegin": "static_cast<jfloat>(",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJFloatArray"
+    }
+  },
+  {
+    "TypeName": "Double",
+    "TypeString": "\"double\"",
+    "c": {
+      "ValueType": "double",
+      "ParamType": "double"
+    },
+    "cpp": {
+      "ValueType": "double",
+      "ParamType": "double",
+      "TemplateType": "double",
+      "TYPE_NAME": "DOUBLE"
+    },
+    "java": {
+      "ValueType": "double",
+      "EmptyValue": "0",
+      "FunctionTypePrefix": "Double",
+      "ToWrapObject": "Double.valueOf",
+      "FromStorageBegin": "((Number) ",
+      "FromStorageEnd": ").doubleValue()"
+    },
+    "jni": {
+      "jtype": "jdouble",
+      "jtypestr": "D",
+      "JavaObject": false,
+      "FromJavaBegin": "",
+      "FromJavaEnd": "",
+      "ToJavaBegin": "static_cast<jdouble>(",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJDoubleArray"
+    }
+  },
+  {
+    "TypeName": "String",
+    "TypeString": "\"string\"",
+    "c": {
+      "ValueType": "char*",
+      "ParamType": "const char*",
+      "IsArray": true
+    },
+    "cpp": {
+      "ValueType": "std::string",
+      "ParamType": "std::string_view",
+      "TemplateType": "std::string",
+      "TYPE_NAME": "STRING",
+      "INCLUDES": "#include <string>\n#include <string_view>\n#include <utility>",
+      "SmallRetType": "std::string_view",
+      "SmallElemType": "char"
+    },
+    "java": {
+      "ValueType": "String",
+      "EmptyValue": "\"\"",
+      "FunctionTypeSuffix": "<String>",
+      "FromStorageBegin": "(String) "
+    },
+    "jni": {
+      "jtype": "jstring",
+      "jtypestr": "Ljava/lang/String;",
+      "JavaObject": true,
+      "FromJavaBegin": "JStringRef{env, ",
+      "FromJavaEnd": "}",
+      "ToJavaBegin": "MakeJString(env, ",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJStringArray"
+    }
+  },
+  {
+    "TypeName": "Raw",
+    "c": {
+      "ValueType": "uint8_t*",
+      "ParamType": "const uint8_t*",
+      "IsArray": true
+    },
+    "cpp": {
+      "ValueType": "std::vector<uint8_t>",
+      "ParamType": "std::span<const uint8_t>",
+      "TemplateType": "uint8_t[]",
+      "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
+      "TYPE_NAME": "RAW",
+      "INCLUDES": "#include <utility>",
+      "SmallRetType": "std::span<uint8_t>",
+      "SmallElemType": "uint8_t"
+    },
+    "java": {
+      "ValueType": "byte[]",
+      "EmptyValue": "new byte[] {}",
+      "FunctionTypeSuffix": "<byte[]>",
+      "FromStorageBegin": "(byte[]) "
+    },
+    "jni": {
+      "jtype": "jbyteArray",
+      "jtypestr": "[B",
+      "JavaObject": true,
+      "FromJavaBegin": "CriticalJSpan<const jbyte>{env, ",
+      "FromJavaEnd": "}.uarray()",
+      "ToJavaBegin": "MakeJByteArray(env, ",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJObjectArray"
+    }
+  },
+  {
+    "TypeName": "BooleanArray",
+    "TypeString": "\"boolean[]\"",
+    "c": {
+      "ValueType": "NT_Bool*",
+      "ParamType": "const NT_Bool*",
+      "IsArray": true
+    },
+    "cpp": {
+      "ValueType": "std::vector<int>",
+      "ParamType": "std::span<const int>",
+      "TemplateType": "bool[]",
+      "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
+      "TYPE_NAME": "BOOLEAN_ARRAY",
+      "INCLUDES": "#include <utility>",
+      "SmallRetType": "std::span<int>",
+      "SmallElemType": "int"
+    },
+    "java": {
+      "ValueType": "boolean[]",
+      "WrapValueType": "Boolean[]",
+      "EmptyValue": "new boolean[] {}",
+      "FunctionTypeSuffix": "<boolean[]>",
+      "FromStorageBegin": "(boolean[]) "
+    },
+    "jni": {
+      "jtype": "jbooleanArray",
+      "jtypestr": "[Z",
+      "JavaObject": true,
+      "FromJavaBegin": "FromJavaBooleanArray(env, ",
+      "FromJavaEnd": ")",
+      "ToJavaBegin": "MakeJBooleanArray(env, ",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJObjectArray"
+    }
+  },
+  {
+    "TypeName": "IntegerArray",
+    "TypeString": "\"int[]\"",
+    "c": {
+      "ValueType": "int64_t*",
+      "ParamType": "const int64_t*",
+      "IsArray": true
+    },
+    "cpp": {
+      "ValueType": "std::vector<int64_t>",
+      "ParamType": "std::span<const int64_t>",
+      "TemplateType": "int64_t[]",
+      "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
+      "TYPE_NAME": "INTEGER_ARRAY",
+      "INCLUDES": "#include <utility>",
+      "SmallRetType": "std::span<int64_t>",
+      "SmallElemType": "int64_t"
+    },
+    "java": {
+      "ValueType": "long[]",
+      "WrapValueType": "Long[]",
+      "EmptyValue": "new long[] {}",
+      "FunctionTypeSuffix": "<long[]>",
+      "FromStorageBegin": "(long[]) "
+    },
+    "jni": {
+      "jtype": "jlongArray",
+      "jtypestr": "[J",
+      "JavaObject": true,
+      "FromJavaBegin": "CriticalJSpan<const jlong>{env, ",
+      "FromJavaEnd": "}",
+      "ToJavaBegin": "MakeJLongArray(env, ",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJObjectArray"
+    }
+  },
+  {
+    "TypeName": "FloatArray",
+    "TypeString": "\"float[]\"",
+    "c": {
+      "ValueType": "float*",
+      "ParamType": "const float*",
+      "IsArray": true
+    },
+    "cpp": {
+      "ValueType": "std::vector<float>",
+      "ParamType": "std::span<const float>",
+      "TemplateType": "float[]",
+      "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
+      "TYPE_NAME": "FLOAT_ARRAY",
+      "INCLUDES": "#include <utility>",
+      "SmallRetType": "std::span<float>",
+      "SmallElemType": "float"
+    },
+    "java": {
+      "ValueType": "float[]",
+      "WrapValueType": "Float[]",
+      "EmptyValue": "new float[] {}",
+      "FunctionTypeSuffix": "<float[]>",
+      "FromStorageBegin": "(float[]) "
+    },
+    "jni": {
+      "jtype": "jfloatArray",
+      "jtypestr": "[F",
+      "JavaObject": true,
+      "FromJavaBegin": "CriticalJSpan<const jfloat>{env, ",
+      "FromJavaEnd": "}",
+      "ToJavaBegin": "MakeJFloatArray(env, ",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJObjectArray"
+    }
+  },
+  {
+    "TypeName": "DoubleArray",
+    "TypeString": "\"double[]\"",
+    "c": {
+      "ValueType": "double*",
+      "ParamType": "const double*",
+      "IsArray": true
+    },
+    "cpp": {
+      "ValueType": "std::vector<double>",
+      "ParamType": "std::span<const double>",
+      "TemplateType": "double[]",
+      "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
+      "TYPE_NAME": "DOUBLE_ARRAY",
+      "INCLUDES": "#include <utility>",
+      "SmallRetType": "std::span<double>",
+      "SmallElemType": "double"
+    },
+    "java": {
+      "ValueType": "double[]",
+      "WrapValueType": "Double[]",
+      "EmptyValue": "new double[] {}",
+      "FunctionTypeSuffix": "<double[]>",
+      "FromStorageBegin": "(double[]) "
+    },
+    "jni": {
+      "jtype": "jdoubleArray",
+      "jtypestr": "[D",
+      "JavaObject": true,
+      "FromJavaBegin": "CriticalJSpan<const jdouble>{env, ",
+      "FromJavaEnd": "}",
+      "ToJavaBegin": "MakeJDoubleArray(env, ",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJObjectArray"
+    }
+  },
+  {
+    "TypeName": "StringArray",
+    "TypeString": "\"string[]\"",
+    "c": {
+      "ValueType": "struct NT_String*",
+      "ParamType": "const struct NT_String*",
+      "IsArray": true
+    },
+    "cpp": {
+      "ValueType": "std::vector<std::string>",
+      "ParamType": "std::span<const std::string>",
+      "TemplateType": "std::string[]",
+      "DefaultValueCopy": "defaultValue.begin(), defaultValue.end()",
+      "TYPE_NAME": "STRING_ARRAY",
+      "INCLUDES": "#include <utility>"
+    },
+    "java": {
+      "ValueType": "String[]",
+      "EmptyValue": "new String[] {}",
+      "FunctionTypeSuffix": "<String[]>",
+      "FromStorageBegin": "(String[]) "
+    },
+    "jni": {
+      "jtype": "jobjectArray",
+      "jtypestr": "[Ljava/lang/Object;",
+      "JavaObject": true,
+      "FromJavaBegin": "FromJavaStringArray(env, ",
+      "FromJavaEnd": ")",
+      "ToJavaBegin": "MakeJStringArray(env, ",
+      "ToJavaEnd": ")",
+      "ToJavaArray": "MakeJObjectArray"
+    }
+  }
 ]
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTable.java b/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTable.java
index cc38239..3c65938 100644
--- a/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTable.java
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTable.java
@@ -4,6 +4,8 @@
 
 package edu.wpi.first.networktables;
 
+import edu.wpi.first.util.protobuf.Protobuf;
+import edu.wpi.first.util.struct.Struct;
 import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashSet;
@@ -13,8 +15,10 @@
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.function.Consumer;
+import us.hebi.quickbuf.ProtoMessage;
 
 /** A network table that knows its subtable path. */
+@SuppressWarnings("PMD.CouplingBetweenObjects")
 public final class NetworkTable {
   /** The path separator for sub-tables and keys. */
   public static final char PATH_SEPARATOR = '/';
@@ -250,6 +254,44 @@
     return m_inst.getStringArrayTopic(m_pathWithSep + name);
   }
 
+  /**
+   * Get protobuf-encoded value topic.
+   *
+   * @param <T> value class (inferred from proto)
+   * @param <MessageType> protobuf message type (inferred from proto)
+   * @param name topic name
+   * @param proto protobuf serialization implementation
+   * @return ProtobufTopic
+   */
+  public <T, MessageType extends ProtoMessage<?>> ProtobufTopic<T> getProtobufTopic(
+      String name, Protobuf<T, MessageType> proto) {
+    return m_inst.getProtobufTopic(m_pathWithSep + name, proto);
+  }
+
+  /**
+   * Get struct-encoded value topic.
+   *
+   * @param <T> value class (inferred from struct)
+   * @param name topic name
+   * @param struct struct serialization implementation
+   * @return StructTopic
+   */
+  public <T> StructTopic<T> getStructTopic(String name, Struct<T> struct) {
+    return m_inst.getStructTopic(m_pathWithSep + name, struct);
+  }
+
+  /**
+   * Get struct-encoded value array topic.
+   *
+   * @param <T> value class (inferred from struct)
+   * @param name topic name
+   * @param struct struct serialization implementation
+   * @return StructTopic
+   */
+  public <T> StructArrayTopic<T> getStructArrayTopic(String name, Struct<T> struct) {
+    return m_inst.getStructArrayTopic(m_pathWithSep + name, struct);
+  }
+
   private final ConcurrentMap<String, NetworkTableEntry> m_entries = new ConcurrentHashMap<>();
 
   /**
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTableListener.java b/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTableListener.java
index 2568738..c34cd58 100644
--- a/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTableListener.java
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTableListener.java
@@ -113,7 +113,7 @@
    * Create a time synchronization listener.
    *
    * @param inst instance
-   * @param immediateNotify notify listener of current time synchonization value
+   * @param immediateNotify notify listener of current time synchronization value
    * @param listener listener function
    * @return Listener
    */
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufEntry.java b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufEntry.java
new file mode 100644
index 0000000..3f82c7a
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufEntry.java
@@ -0,0 +1,17 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+/**
+ * NetworkTables protobuf-encoded value entry.
+ *
+ * <p>Unlike NetworkTableEntry, the entry goes away when close() is called.
+ *
+ * @param <T> value class
+ */
+public interface ProtobufEntry<T> extends ProtobufSubscriber<T>, ProtobufPublisher<T> {
+  /** Stops publishing the entry if it's published. */
+  void unpublish();
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufEntryImpl.java b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufEntryImpl.java
new file mode 100644
index 0000000..b4359ea
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufEntryImpl.java
@@ -0,0 +1,209 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import edu.wpi.first.util.protobuf.ProtobufBuffer;
+import java.io.IOException;
+import java.lang.reflect.Array;
+import java.nio.ByteBuffer;
+
+/**
+ * NetworkTables protobuf-encoded value implementation.
+ *
+ * @param <T> value class
+ */
+final class ProtobufEntryImpl<T> extends EntryBase implements ProtobufEntry<T> {
+  /**
+   * Constructor.
+   *
+   * @param topic Topic
+   * @param handle Native handle
+   * @param defaultValue Default value for get()
+   */
+  ProtobufEntryImpl(
+      ProtobufTopic<T> topic,
+      ProtobufBuffer<T, ?> buf,
+      int handle,
+      T defaultValue,
+      boolean schemaPublished) {
+    super(handle);
+    m_topic = topic;
+    m_defaultValue = defaultValue;
+    m_buf = buf;
+    m_schemaPublished = schemaPublished;
+  }
+
+  @Override
+  public ProtobufTopic<T> getTopic() {
+    return m_topic;
+  }
+
+  @Override
+  public T get() {
+    return fromRaw(NetworkTablesJNI.getRaw(m_handle, m_emptyRaw), m_defaultValue);
+  }
+
+  @Override
+  public T get(T defaultValue) {
+    return fromRaw(NetworkTablesJNI.getRaw(m_handle, m_emptyRaw), defaultValue);
+  }
+
+  @Override
+  public boolean getInto(T out) {
+    byte[] raw = NetworkTablesJNI.getRaw(m_handle, m_emptyRaw);
+    if (raw.length == 0) {
+      return false;
+    }
+    try {
+      synchronized (m_buf) {
+        m_buf.readInto(out, raw);
+        return true;
+      }
+    } catch (IOException e) {
+      // ignored
+    }
+    return false;
+  }
+
+  @Override
+  public TimestampedObject<T> getAtomic() {
+    return fromRaw(NetworkTablesJNI.getAtomicRaw(m_handle, m_emptyRaw), m_defaultValue);
+  }
+
+  @Override
+  public TimestampedObject<T> getAtomic(T defaultValue) {
+    return fromRaw(NetworkTablesJNI.getAtomicRaw(m_handle, m_emptyRaw), defaultValue);
+  }
+
+  @Override
+  public TimestampedObject<T>[] readQueue() {
+    TimestampedRaw[] raw = NetworkTablesJNI.readQueueRaw(m_handle);
+    @SuppressWarnings("unchecked")
+    TimestampedObject<T>[] arr = (TimestampedObject<T>[]) new TimestampedObject<?>[raw.length];
+    int err = 0;
+    for (int i = 0; i < raw.length; i++) {
+      arr[i] = fromRaw(raw[i], null);
+      if (arr[i].value == null) {
+        err++;
+      }
+    }
+
+    // discard bad values
+    if (err > 0) {
+      @SuppressWarnings("unchecked")
+      TimestampedObject<T>[] newArr =
+          (TimestampedObject<T>[]) new TimestampedObject<?>[raw.length - err];
+      int i = 0;
+      for (TimestampedObject<T> e : arr) {
+        if (e.value != null) {
+          arr[i] = e;
+          i++;
+        }
+      }
+      arr = newArr;
+    }
+
+    return arr;
+  }
+
+  @Override
+  public T[] readQueueValues() {
+    byte[][] raw = NetworkTablesJNI.readQueueValuesRaw(m_handle);
+    @SuppressWarnings("unchecked")
+    T[] arr = (T[]) Array.newInstance(m_topic.getProto().getTypeClass(), raw.length);
+    int err = 0;
+    for (int i = 0; i < raw.length; i++) {
+      arr[i] = fromRaw(raw[i], null);
+      if (arr[i] == null) {
+        err++;
+      }
+    }
+
+    // discard bad values
+    if (err > 0) {
+      @SuppressWarnings("unchecked")
+      T[] newArr = (T[]) Array.newInstance(m_topic.getProto().getTypeClass(), raw.length - err);
+      int i = 0;
+      for (T e : arr) {
+        if (e != null) {
+          arr[i] = e;
+          i++;
+        }
+      }
+      arr = newArr;
+    }
+
+    return arr;
+  }
+
+  @Override
+  public void set(T value, long time) {
+    try {
+      synchronized (m_buf) {
+        if (!m_schemaPublished) {
+          m_schemaPublished = true;
+          m_topic.getInstance().addSchema(m_buf.getProto());
+        }
+        ByteBuffer bb = m_buf.write(value);
+        NetworkTablesJNI.setRaw(m_handle, time, bb, 0, bb.position());
+      }
+    } catch (IOException e) {
+      // ignore
+    }
+  }
+
+  @Override
+  public void setDefault(T value) {
+    try {
+      synchronized (m_buf) {
+        if (!m_schemaPublished) {
+          m_schemaPublished = true;
+          m_topic.getInstance().addSchema(m_buf.getProto());
+        }
+        ByteBuffer bb = m_buf.write(value);
+        NetworkTablesJNI.setDefaultRaw(m_handle, 0, bb, 0, bb.position());
+      }
+    } catch (IOException e) {
+      // ignore
+    }
+  }
+
+  @Override
+  public void unpublish() {
+    NetworkTablesJNI.unpublish(m_handle);
+  }
+
+  private T fromRaw(byte[] raw, T defaultValue) {
+    if (raw.length == 0) {
+      return defaultValue;
+    }
+    try {
+      synchronized (m_buf) {
+        return m_buf.read(raw);
+      }
+    } catch (IOException e) {
+      return defaultValue;
+    }
+  }
+
+  private TimestampedObject<T> fromRaw(TimestampedRaw raw, T defaultValue) {
+    if (raw.value.length == 0) {
+      return new TimestampedObject<T>(0, 0, defaultValue);
+    }
+    try {
+      synchronized (m_buf) {
+        return new TimestampedObject<T>(raw.timestamp, raw.serverTime, m_buf.read(raw.value));
+      }
+    } catch (IOException e) {
+      return new TimestampedObject<T>(0, 0, defaultValue);
+    }
+  }
+
+  private final ProtobufTopic<T> m_topic;
+  private final T m_defaultValue;
+  private final ProtobufBuffer<T, ?> m_buf;
+  private boolean m_schemaPublished;
+  private static final byte[] m_emptyRaw = new byte[] {};
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufPublisher.java b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufPublisher.java
new file mode 100644
index 0000000..ab22b86
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufPublisher.java
@@ -0,0 +1,52 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import java.util.function.Consumer;
+
+/**
+ * NetworkTables protobuf-encoded value publisher.
+ *
+ * @param <T> value class
+ */
+public interface ProtobufPublisher<T> extends Publisher, Consumer<T> {
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  @Override
+  ProtobufTopic<T> getTopic();
+
+  /**
+   * Publish a new value using current NT time.
+   *
+   * @param value value to publish
+   */
+  default void set(T value) {
+    set(value, 0);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  void set(T value, long time);
+
+  /**
+   * Publish a default value. On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   */
+  void setDefault(T value);
+
+  @Override
+  default void accept(T value) {
+    set(value);
+  }
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufSubscriber.java b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufSubscriber.java
new file mode 100644
index 0000000..2236ce9
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufSubscriber.java
@@ -0,0 +1,94 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import java.util.function.Supplier;
+
+/**
+ * NetworkTables protobuf-encoded value subscriber.
+ *
+ * @param <T> value class
+ */
+@SuppressWarnings("PMD.MissingOverride")
+public interface ProtobufSubscriber<T> extends Subscriber, Supplier<T> {
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  @Override
+  ProtobufTopic<T> getTopic();
+
+  /**
+   * Get the last published value. If no value has been published or the value cannot be unpacked,
+   * returns the stored default value.
+   *
+   * @return value
+   */
+  T get();
+
+  /**
+   * Get the last published value. If no value has been published or the value cannot be unpacked,
+   * returns the passed defaultValue.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return value
+   */
+  T get(T defaultValue);
+
+  /**
+   * Get the last published value, replacing the contents in place of an existing object. If no
+   * value has been published or the value cannot be unpacked, does not replace the contents and
+   * returns false. This function will not work (will throw UnsupportedOperationException) unless T
+   * is mutable (and the implementation of Struct implements unpackInto).
+   *
+   * <p>Note: due to Java language limitations, it's not possible to validate at compile time that
+   * the out parameter is mutable.
+   *
+   * @param out object to replace contents of; must be mutable
+   * @return true if successful
+   * @throws UnsupportedOperationException if T is immutable
+   */
+  boolean getInto(T out);
+
+  /**
+   * Get the last published value along with its timestamp. If no value has been published or the
+   * value cannot be unpacked, returns the stored default value and a timestamp of 0.
+   *
+   * @return timestamped value
+   */
+  TimestampedObject<T> getAtomic();
+
+  /**
+   * Get the last published value along with its timestamp. If no value has been published or the
+   * value cannot be unpacked, returns the passed defaultValue and a timestamp of 0.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return timestamped value
+   */
+  TimestampedObject<T> getAtomic(T defaultValue);
+
+  /**
+   * Get an array of all valid value changes since the last call to readQueue. Also provides a
+   * timestamp for each value. Values that cannot be unpacked are dropped.
+   *
+   * <p>The "poll storage" subscribe option can be used to set the queue depth.
+   *
+   * @return Array of timestamped values; empty array if no valid new changes have been published
+   *     since the previous call.
+   */
+  TimestampedObject<T>[] readQueue();
+
+  /**
+   * Get an array of all valid value changes since the last call to readQueue. Values that cannot be
+   * unpacked are dropped.
+   *
+   * <p>The "poll storage" subscribe option can be used to set the queue depth.
+   *
+   * @return Array of values; empty array if no valid new changes have been published since the
+   *     previous call.
+   */
+  T[] readQueueValues();
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufTopic.java b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufTopic.java
new file mode 100644
index 0000000..c3dad13
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/ProtobufTopic.java
@@ -0,0 +1,178 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import edu.wpi.first.util.protobuf.Protobuf;
+import edu.wpi.first.util.protobuf.ProtobufBuffer;
+
+/**
+ * NetworkTables protobuf-encoded value topic.
+ *
+ * @param <T> value class
+ */
+public final class ProtobufTopic<T> extends Topic {
+  private ProtobufTopic(Topic topic, Protobuf<T, ?> proto) {
+    super(topic.m_inst, topic.m_handle);
+    m_proto = proto;
+  }
+
+  private ProtobufTopic(NetworkTableInstance inst, int handle, Protobuf<T, ?> proto) {
+    super(inst, handle);
+    m_proto = proto;
+  }
+
+  /**
+   * Create a ProtobufTopic from a generic topic.
+   *
+   * @param <T> value class (inferred from proto)
+   * @param topic generic topic
+   * @param proto protobuf serialization implementation
+   * @return ProtobufTopic for value class
+   */
+  public static <T> ProtobufTopic<T> wrap(Topic topic, Protobuf<T, ?> proto) {
+    return new ProtobufTopic<T>(topic, proto);
+  }
+
+  /**
+   * Create a ProtobufTopic from a native handle; generally NetworkTableInstance.getProtobufTopic()
+   * should be used instead.
+   *
+   * @param <T> value class (inferred from proto)
+   * @param inst Instance
+   * @param handle Native handle
+   * @param proto protobuf serialization implementation
+   * @return ProtobufTopic for value class
+   */
+  public static <T> ProtobufTopic<T> wrap(
+      NetworkTableInstance inst, int handle, Protobuf<T, ?> proto) {
+    return new ProtobufTopic<T>(inst, handle, proto);
+  }
+
+  /**
+   * Create a new subscriber to the topic.
+   *
+   * <p>The subscriber is only active as long as the returned object is not closed.
+   *
+   * <p>Subscribers that do not match the published data type do not return any values. To determine
+   * if the data type matches, use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a getter function
+   * @param options subscribe options
+   * @return subscriber
+   */
+  public ProtobufSubscriber<T> subscribe(T defaultValue, PubSubOption... options) {
+    return new ProtobufEntryImpl<T>(
+        this,
+        ProtobufBuffer.create(m_proto),
+        NetworkTablesJNI.subscribe(
+            m_handle, NetworkTableType.kRaw.getValue(), m_proto.getTypeString(), options),
+        defaultValue,
+        false);
+  }
+
+  /**
+   * Create a new publisher to the topic.
+   *
+   * <p>The publisher is only active as long as the returned object is not closed.
+   *
+   * <p>It is not possible to publish two different data types to the same topic. Conflicts between
+   * publishers are typically resolved by the server on a first-come, first-served basis. Any
+   * published values that do not match the topic's data type are dropped (ignored). To determine if
+   * the data type matches, use the appropriate Topic functions.
+   *
+   * @param options publish options
+   * @return publisher
+   */
+  public ProtobufPublisher<T> publish(PubSubOption... options) {
+    m_inst.addSchema(m_proto);
+    return new ProtobufEntryImpl<T>(
+        this,
+        ProtobufBuffer.create(m_proto),
+        NetworkTablesJNI.publish(
+            m_handle, NetworkTableType.kRaw.getValue(), m_proto.getTypeString(), options),
+        null,
+        true);
+  }
+
+  /**
+   * Create a new publisher to the topic, with type string and initial properties.
+   *
+   * <p>The publisher is only active as long as the returned object is not closed.
+   *
+   * <p>It is not possible to publish two different data types to the same topic. Conflicts between
+   * publishers are typically resolved by the server on a first-come, first-served basis. Any
+   * published values that do not match the topic's data type are dropped (ignored). To determine if
+   * the data type matches, use the appropriate Topic functions.
+   *
+   * @param properties JSON properties
+   * @param options publish options
+   * @return publisher
+   * @throws IllegalArgumentException if properties is not a JSON object
+   */
+  public ProtobufPublisher<T> publishEx(String properties, PubSubOption... options) {
+    m_inst.addSchema(m_proto);
+    return new ProtobufEntryImpl<T>(
+        this,
+        ProtobufBuffer.create(m_proto),
+        NetworkTablesJNI.publishEx(
+            m_handle,
+            NetworkTableType.kRaw.getValue(),
+            m_proto.getTypeString(),
+            properties,
+            options),
+        null,
+        true);
+  }
+
+  /**
+   * Create a new entry for the topic.
+   *
+   * <p>Entries act as a combination of a subscriber and a weak publisher. The subscriber is active
+   * as long as the entry is not closed. The publisher is created when the entry is first written
+   * to, and remains active until either unpublish() is called or the entry is closed.
+   *
+   * <p>It is not possible to use two different data types with the same topic. Conflicts between
+   * publishers are typically resolved by the server on a first-come, first-served basis. Any
+   * published values that do not match the topic's data type are dropped (ignored), and the entry
+   * will show no new values if the data type does not match. To determine if the data type matches,
+   * use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a getter function
+   * @param options publish and/or subscribe options
+   * @return entry
+   */
+  public ProtobufEntry<T> getEntry(T defaultValue, PubSubOption... options) {
+    return new ProtobufEntryImpl<T>(
+        this,
+        ProtobufBuffer.create(m_proto),
+        NetworkTablesJNI.getEntry(
+            m_handle, NetworkTableType.kRaw.getValue(), m_proto.getTypeString(), options),
+        defaultValue,
+        false);
+  }
+
+  public Protobuf<T, ?> getProto() {
+    return m_proto;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == this) {
+      return true;
+    }
+    if (!(other instanceof ProtobufTopic)) {
+      return false;
+    }
+
+    return super.equals(other) && m_proto == ((ProtobufTopic<?>) other).m_proto;
+  }
+
+  @Override
+  public int hashCode() {
+    return super.hashCode() ^ m_proto.hashCode();
+  }
+
+  private final Protobuf<T, ?> m_proto;
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayEntry.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayEntry.java
new file mode 100644
index 0000000..4cc9e85
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayEntry.java
@@ -0,0 +1,17 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+/**
+ * NetworkTables struct-encoded array value entry.
+ *
+ * <p>Unlike NetworkTableEntry, the entry goes away when close() is called.
+ *
+ * @param <T> value class
+ */
+public interface StructArrayEntry<T> extends StructArraySubscriber<T>, StructArrayPublisher<T> {
+  /** Stops publishing the entry if it's published. */
+  void unpublish();
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayEntryImpl.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayEntryImpl.java
new file mode 100644
index 0000000..4e8a4a0
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayEntryImpl.java
@@ -0,0 +1,197 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import edu.wpi.first.util.struct.StructBuffer;
+import java.lang.reflect.Array;
+import java.nio.ByteBuffer;
+
+/**
+ * NetworkTables struct-encoded value implementation.
+ *
+ * @param <T> value class
+ */
+@SuppressWarnings("PMD.ArrayIsStoredDirectly")
+final class StructArrayEntryImpl<T> extends EntryBase implements StructArrayEntry<T> {
+  /**
+   * Constructor.
+   *
+   * @param topic Topic
+   * @param handle Native handle
+   * @param defaultValue Default value for get()
+   */
+  StructArrayEntryImpl(
+      StructArrayTopic<T> topic,
+      StructBuffer<T> buf,
+      int handle,
+      T[] defaultValue,
+      boolean schemaPublished) {
+    super(handle);
+    m_topic = topic;
+    m_defaultValue = defaultValue;
+    m_buf = buf;
+    m_schemaPublished = schemaPublished;
+  }
+
+  @Override
+  public StructArrayTopic<T> getTopic() {
+    return m_topic;
+  }
+
+  @Override
+  public T[] get() {
+    return fromRaw(NetworkTablesJNI.getRaw(m_handle, m_emptyRaw), m_defaultValue);
+  }
+
+  @Override
+  public T[] get(T[] defaultValue) {
+    return fromRaw(NetworkTablesJNI.getRaw(m_handle, m_emptyRaw), defaultValue);
+  }
+
+  @Override
+  public TimestampedObject<T[]> getAtomic() {
+    return fromRaw(NetworkTablesJNI.getAtomicRaw(m_handle, m_emptyRaw), m_defaultValue);
+  }
+
+  @Override
+  public TimestampedObject<T[]> getAtomic(T[] defaultValue) {
+    return fromRaw(NetworkTablesJNI.getAtomicRaw(m_handle, m_emptyRaw), defaultValue);
+  }
+
+  @Override
+  public TimestampedObject<T[]>[] readQueue() {
+    TimestampedRaw[] raw = NetworkTablesJNI.readQueueRaw(m_handle);
+    @SuppressWarnings("unchecked")
+    TimestampedObject<T[]>[] arr = (TimestampedObject<T[]>[]) new TimestampedObject<?>[raw.length];
+    int err = 0;
+    for (int i = 0; i < raw.length; i++) {
+      arr[i] = fromRaw(raw[i], null);
+      if (arr[i].value == null) {
+        err++;
+      }
+    }
+
+    // discard bad values
+    if (err > 0) {
+      @SuppressWarnings("unchecked")
+      TimestampedObject<T[]>[] newArr =
+          (TimestampedObject<T[]>[]) new TimestampedObject<?>[raw.length - err];
+      int i = 0;
+      for (TimestampedObject<T[]> e : arr) {
+        if (e.value != null) {
+          arr[i] = e;
+          i++;
+        }
+      }
+      arr = newArr;
+    }
+
+    return arr;
+  }
+
+  @Override
+  public T[][] readQueueValues() {
+    byte[][] raw = NetworkTablesJNI.readQueueValuesRaw(m_handle);
+    @SuppressWarnings("unchecked")
+    T[][] arr = (T[][]) Array.newInstance(Array.class, raw.length);
+    int err = 0;
+    for (int i = 0; i < raw.length; i++) {
+      arr[i] = fromRaw(raw[i], null);
+      if (arr[i] == null) {
+        err++;
+      }
+    }
+
+    // discard bad values
+    if (err > 0) {
+      @SuppressWarnings("unchecked")
+      T[][] newArr = (T[][]) Array.newInstance(Array.class, raw.length - err);
+      int i = 0;
+      for (T[] e : arr) {
+        if (e != null) {
+          arr[i] = e;
+          i++;
+        }
+      }
+      arr = newArr;
+    }
+
+    return arr;
+  }
+
+  @SuppressWarnings("PMD.AvoidCatchingGenericException")
+  @Override
+  public void set(T[] value, long time) {
+    try {
+      synchronized (m_buf) {
+        if (!m_schemaPublished) {
+          m_schemaPublished = true;
+          m_topic.getInstance().addSchema(m_buf.getStruct());
+        }
+        ByteBuffer bb = m_buf.writeArray(value);
+        NetworkTablesJNI.setRaw(m_handle, time, bb, 0, bb.position());
+      }
+    } catch (RuntimeException e) {
+      // ignore
+    }
+  }
+
+  @SuppressWarnings("PMD.AvoidCatchingGenericException")
+  @Override
+  public void setDefault(T[] value) {
+    try {
+      synchronized (m_buf) {
+        if (!m_schemaPublished) {
+          m_schemaPublished = true;
+          m_topic.getInstance().addSchema(m_buf.getStruct());
+        }
+        ByteBuffer bb = m_buf.writeArray(value);
+        NetworkTablesJNI.setDefaultRaw(m_handle, 0, bb, 0, bb.position());
+      }
+    } catch (RuntimeException e) {
+      // ignore
+    }
+  }
+
+  @Override
+  public void unpublish() {
+    NetworkTablesJNI.unpublish(m_handle);
+  }
+
+  @SuppressWarnings("PMD.AvoidCatchingGenericException")
+  private T[] fromRaw(byte[] raw, T[] defaultValue) {
+    if (raw.length == 0) {
+      return defaultValue;
+    }
+    try {
+      synchronized (m_buf) {
+        return m_buf.readArray(raw);
+      }
+    } catch (RuntimeException e) {
+      return defaultValue;
+    }
+  }
+
+  @SuppressWarnings("PMD.AvoidCatchingGenericException")
+  private TimestampedObject<T[]> fromRaw(TimestampedRaw raw, T[] defaultValue) {
+    if (raw.value.length == 0) {
+      return new TimestampedObject<T[]>(0, 0, defaultValue);
+    }
+    try {
+      synchronized (m_buf) {
+        return new TimestampedObject<T[]>(
+            raw.timestamp, raw.serverTime, m_buf.readArray(raw.value));
+      }
+    } catch (RuntimeException e) {
+      return new TimestampedObject<T[]>(0, 0, defaultValue);
+    }
+  }
+
+  private final StructArrayTopic<T> m_topic;
+  private final T[] m_defaultValue;
+  private final StructBuffer<T> m_buf;
+  private boolean m_schemaPublished;
+  private static final byte[] m_emptyRaw = new byte[] {};
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayPublisher.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayPublisher.java
new file mode 100644
index 0000000..aa291e9
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayPublisher.java
@@ -0,0 +1,52 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import java.util.function.Consumer;
+
+/**
+ * NetworkTables struct-encoded array value publisher.
+ *
+ * @param <T> value class
+ */
+public interface StructArrayPublisher<T> extends Publisher, Consumer<T[]> {
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  @Override
+  StructArrayTopic<T> getTopic();
+
+  /**
+   * Publish a new value using current NT time.
+   *
+   * @param value value to publish
+   */
+  default void set(T[] value) {
+    set(value, 0);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  void set(T[] value, long time);
+
+  /**
+   * Publish a default value. On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   */
+  void setDefault(T[] value);
+
+  @Override
+  default void accept(T[] value) {
+    set(value);
+  }
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructArraySubscriber.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructArraySubscriber.java
new file mode 100644
index 0000000..b4755e6
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructArraySubscriber.java
@@ -0,0 +1,79 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import java.util.function.Supplier;
+
+/**
+ * NetworkTables struct-encoded array value subscriber.
+ *
+ * @param <T> value class
+ */
+@SuppressWarnings("PMD.MissingOverride")
+public interface StructArraySubscriber<T> extends Subscriber, Supplier<T[]> {
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  @Override
+  StructArrayTopic<T> getTopic();
+
+  /**
+   * Get the last published value. If no value has been published or the value cannot be unpacked,
+   * returns the stored default value.
+   *
+   * @return value
+   */
+  T[] get();
+
+  /**
+   * Get the last published value. If no value has been published or the value cannot be unpacked,
+   * returns the passed defaultValue.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return value
+   */
+  T[] get(T[] defaultValue);
+
+  /**
+   * Get the last published value along with its timestamp. If no value has been published or the
+   * value cannot be unpacked, returns the stored default value and a timestamp of 0.
+   *
+   * @return timestamped value
+   */
+  TimestampedObject<T[]> getAtomic();
+
+  /**
+   * Get the last published value along with its timestamp. If no value has been published or the
+   * value cannot be unpacked, returns the passed defaultValue and a timestamp of 0.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return timestamped value
+   */
+  TimestampedObject<T[]> getAtomic(T[] defaultValue);
+
+  /**
+   * Get an array of all valid value changes since the last call to readQueue. Also provides a
+   * timestamp for each value. Values that cannot be unpacked are dropped.
+   *
+   * <p>The "poll storage" subscribe option can be used to set the queue depth.
+   *
+   * @return Array of timestamped values; empty array if no valid new changes have been published
+   *     since the previous call.
+   */
+  TimestampedObject<T[]>[] readQueue();
+
+  /**
+   * Get an array of all valid value changes since the last call to readQueue. Values that cannot be
+   * unpacked are dropped.
+   *
+   * <p>The "poll storage" subscribe option can be used to set the queue depth.
+   *
+   * @return Array of values; empty array if no valid new changes have been published since the
+   *     previous call.
+   */
+  T[][] readQueueValues();
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayTopic.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayTopic.java
new file mode 100644
index 0000000..247501b
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructArrayTopic.java
@@ -0,0 +1,178 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import edu.wpi.first.util.struct.Struct;
+import edu.wpi.first.util.struct.StructBuffer;
+
+/**
+ * NetworkTables struct-encoded array value topic.
+ *
+ * @param <T> value class
+ */
+public final class StructArrayTopic<T> extends Topic {
+  private StructArrayTopic(Topic topic, Struct<T> struct) {
+    super(topic.m_inst, topic.m_handle);
+    m_struct = struct;
+  }
+
+  private StructArrayTopic(NetworkTableInstance inst, int handle, Struct<T> struct) {
+    super(inst, handle);
+    m_struct = struct;
+  }
+
+  /**
+   * Create a StructArrayTopic from a generic topic.
+   *
+   * @param <T> value class (inferred from struct)
+   * @param topic generic topic
+   * @param struct struct serialization implementation
+   * @return StructArrayTopic for value class
+   */
+  public static <T> StructArrayTopic<T> wrap(Topic topic, Struct<T> struct) {
+    return new StructArrayTopic<T>(topic, struct);
+  }
+
+  /**
+   * Create a StructArrayTopic from a native handle; generally
+   * NetworkTableInstance.getStructArrayTopic() should be used instead.
+   *
+   * @param <T> value class (inferred from struct)
+   * @param inst Instance
+   * @param handle Native handle
+   * @param struct struct serialization implementation
+   * @return StructArrayTopic for value class
+   */
+  public static <T> StructArrayTopic<T> wrap(
+      NetworkTableInstance inst, int handle, Struct<T> struct) {
+    return new StructArrayTopic<T>(inst, handle, struct);
+  }
+
+  /**
+   * Create a new subscriber to the topic.
+   *
+   * <p>The subscriber is only active as long as the returned object is not closed.
+   *
+   * <p>Subscribers that do not match the published data type do not return any values. To determine
+   * if the data type matches, use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a getter function
+   * @param options subscribe options
+   * @return subscriber
+   */
+  public StructArraySubscriber<T> subscribe(T[] defaultValue, PubSubOption... options) {
+    return new StructArrayEntryImpl<T>(
+        this,
+        StructBuffer.create(m_struct),
+        NetworkTablesJNI.subscribe(
+            m_handle, NetworkTableType.kRaw.getValue(), m_struct.getTypeString() + "[]", options),
+        defaultValue,
+        false);
+  }
+
+  /**
+   * Create a new publisher to the topic.
+   *
+   * <p>The publisher is only active as long as the returned object is not closed.
+   *
+   * <p>It is not possible to publish two different data types to the same topic. Conflicts between
+   * publishers are typically resolved by the server on a first-come, first-served basis. Any
+   * published values that do not match the topic's data type are dropped (ignored). To determine if
+   * the data type matches, use the appropriate Topic functions.
+   *
+   * @param options publish options
+   * @return publisher
+   */
+  public StructArrayPublisher<T> publish(PubSubOption... options) {
+    m_inst.addSchema(m_struct);
+    return new StructArrayEntryImpl<T>(
+        this,
+        StructBuffer.create(m_struct),
+        NetworkTablesJNI.publish(
+            m_handle, NetworkTableType.kRaw.getValue(), m_struct.getTypeString() + "[]", options),
+        null,
+        true);
+  }
+
+  /**
+   * Create a new publisher to the topic, with type string and initial properties.
+   *
+   * <p>The publisher is only active as long as the returned object is not closed.
+   *
+   * <p>It is not possible to publish two different data types to the same topic. Conflicts between
+   * publishers are typically resolved by the server on a first-come, first-served basis. Any
+   * published values that do not match the topic's data type are dropped (ignored). To determine if
+   * the data type matches, use the appropriate Topic functions.
+   *
+   * @param properties JSON properties
+   * @param options publish options
+   * @return publisher
+   * @throws IllegalArgumentException if properties is not a JSON object
+   */
+  public StructArrayPublisher<T> publishEx(String properties, PubSubOption... options) {
+    m_inst.addSchema(m_struct);
+    return new StructArrayEntryImpl<T>(
+        this,
+        StructBuffer.create(m_struct),
+        NetworkTablesJNI.publishEx(
+            m_handle,
+            NetworkTableType.kRaw.getValue(),
+            m_struct.getTypeString() + "[]",
+            properties,
+            options),
+        null,
+        true);
+  }
+
+  /**
+   * Create a new entry for the topic.
+   *
+   * <p>Entries act as a combination of a subscriber and a weak publisher. The subscriber is active
+   * as long as the entry is not closed. The publisher is created when the entry is first written
+   * to, and remains active until either unpublish() is called or the entry is closed.
+   *
+   * <p>It is not possible to use two different data types with the same topic. Conflicts between
+   * publishers are typically resolved by the server on a first-come, first-served basis. Any
+   * published values that do not match the topic's data type are dropped (ignored), and the entry
+   * will show no new values if the data type does not match. To determine if the data type matches,
+   * use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a getter function
+   * @param options publish and/or subscribe options
+   * @return entry
+   */
+  public StructArrayEntry<T> getEntry(T[] defaultValue, PubSubOption... options) {
+    return new StructArrayEntryImpl<T>(
+        this,
+        StructBuffer.create(m_struct),
+        NetworkTablesJNI.getEntry(
+            m_handle, NetworkTableType.kRaw.getValue(), m_struct.getTypeString() + "[]", options),
+        defaultValue,
+        false);
+  }
+
+  public Struct<T> getStruct() {
+    return m_struct;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == this) {
+      return true;
+    }
+    if (!(other instanceof StructArrayTopic)) {
+      return false;
+    }
+
+    return super.equals(other) && m_struct == ((StructArrayTopic<?>) other).m_struct;
+  }
+
+  @Override
+  public int hashCode() {
+    return super.hashCode() ^ m_struct.hashCode();
+  }
+
+  private final Struct<T> m_struct;
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructEntry.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructEntry.java
new file mode 100644
index 0000000..e687fdb
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructEntry.java
@@ -0,0 +1,17 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+/**
+ * NetworkTables struct-encoded value entry.
+ *
+ * <p>Unlike NetworkTableEntry, the entry goes away when close() is called.
+ *
+ * @param <T> value class
+ */
+public interface StructEntry<T> extends StructSubscriber<T>, StructPublisher<T> {
+  /** Stops publishing the entry if it's published. */
+  void unpublish();
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructEntryImpl.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructEntryImpl.java
new file mode 100644
index 0000000..bd02d27
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructEntryImpl.java
@@ -0,0 +1,207 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import edu.wpi.first.util.struct.StructBuffer;
+import java.lang.reflect.Array;
+import java.nio.ByteBuffer;
+
+/**
+ * NetworkTables struct-encoded value implementation.
+ *
+ * @param <T> value class
+ */
+final class StructEntryImpl<T> extends EntryBase implements StructEntry<T> {
+  /**
+   * Constructor.
+   *
+   * @param topic Topic
+   * @param handle Native handle
+   * @param defaultValue Default value for get()
+   */
+  StructEntryImpl(
+      StructTopic<T> topic,
+      StructBuffer<T> buf,
+      int handle,
+      T defaultValue,
+      boolean schemaPublished) {
+    super(handle);
+    m_topic = topic;
+    m_defaultValue = defaultValue;
+    m_buf = buf;
+    m_schemaPublished = schemaPublished;
+  }
+
+  @Override
+  public StructTopic<T> getTopic() {
+    return m_topic;
+  }
+
+  @Override
+  public T get() {
+    return fromRaw(NetworkTablesJNI.getRaw(m_handle, m_emptyRaw), m_defaultValue);
+  }
+
+  @Override
+  public T get(T defaultValue) {
+    return fromRaw(NetworkTablesJNI.getRaw(m_handle, m_emptyRaw), defaultValue);
+  }
+
+  @Override
+  public boolean getInto(T out) {
+    byte[] raw = NetworkTablesJNI.getRaw(m_handle, m_emptyRaw);
+    if (raw.length == 0) {
+      return false;
+    }
+    synchronized (m_buf) {
+      m_buf.readInto(out, raw);
+      return true;
+    }
+  }
+
+  @Override
+  public TimestampedObject<T> getAtomic() {
+    return fromRaw(NetworkTablesJNI.getAtomicRaw(m_handle, m_emptyRaw), m_defaultValue);
+  }
+
+  @Override
+  public TimestampedObject<T> getAtomic(T defaultValue) {
+    return fromRaw(NetworkTablesJNI.getAtomicRaw(m_handle, m_emptyRaw), defaultValue);
+  }
+
+  @Override
+  public TimestampedObject<T>[] readQueue() {
+    TimestampedRaw[] raw = NetworkTablesJNI.readQueueRaw(m_handle);
+    @SuppressWarnings("unchecked")
+    TimestampedObject<T>[] arr = (TimestampedObject<T>[]) new TimestampedObject<?>[raw.length];
+    int err = 0;
+    for (int i = 0; i < raw.length; i++) {
+      arr[i] = fromRaw(raw[i], null);
+      if (arr[i].value == null) {
+        err++;
+      }
+    }
+
+    // discard bad values
+    if (err > 0) {
+      @SuppressWarnings("unchecked")
+      TimestampedObject<T>[] newArr =
+          (TimestampedObject<T>[]) new TimestampedObject<?>[raw.length - err];
+      int i = 0;
+      for (TimestampedObject<T> e : arr) {
+        if (e.value != null) {
+          arr[i] = e;
+          i++;
+        }
+      }
+      arr = newArr;
+    }
+
+    return arr;
+  }
+
+  @Override
+  public T[] readQueueValues() {
+    byte[][] raw = NetworkTablesJNI.readQueueValuesRaw(m_handle);
+    @SuppressWarnings("unchecked")
+    T[] arr = (T[]) Array.newInstance(m_topic.getStruct().getTypeClass(), raw.length);
+    int err = 0;
+    for (int i = 0; i < raw.length; i++) {
+      arr[i] = fromRaw(raw[i], null);
+      if (arr[i] == null) {
+        err++;
+      }
+    }
+
+    // discard bad values
+    if (err > 0) {
+      @SuppressWarnings("unchecked")
+      T[] newArr = (T[]) Array.newInstance(m_topic.getStruct().getTypeClass(), raw.length - err);
+      int i = 0;
+      for (T e : arr) {
+        if (e != null) {
+          arr[i] = e;
+          i++;
+        }
+      }
+      arr = newArr;
+    }
+
+    return arr;
+  }
+
+  @SuppressWarnings("PMD.AvoidCatchingGenericException")
+  @Override
+  public void set(T value, long time) {
+    try {
+      synchronized (m_buf) {
+        if (!m_schemaPublished) {
+          m_schemaPublished = true;
+          m_topic.getInstance().addSchema(m_buf.getStruct());
+        }
+        ByteBuffer bb = m_buf.write(value);
+        NetworkTablesJNI.setRaw(m_handle, time, bb, 0, bb.position());
+      }
+    } catch (RuntimeException e) {
+      // ignore
+    }
+  }
+
+  @SuppressWarnings("PMD.AvoidCatchingGenericException")
+  @Override
+  public void setDefault(T value) {
+    try {
+      synchronized (m_buf) {
+        if (!m_schemaPublished) {
+          m_schemaPublished = true;
+          m_topic.getInstance().addSchema(m_buf.getStruct());
+        }
+        ByteBuffer bb = m_buf.write(value);
+        NetworkTablesJNI.setDefaultRaw(m_handle, 0, bb, 0, bb.position());
+      }
+    } catch (RuntimeException e) {
+      // ignore
+    }
+  }
+
+  @Override
+  public void unpublish() {
+    NetworkTablesJNI.unpublish(m_handle);
+  }
+
+  @SuppressWarnings("PMD.AvoidCatchingGenericException")
+  private T fromRaw(byte[] raw, T defaultValue) {
+    if (raw.length == 0) {
+      return defaultValue;
+    }
+    try {
+      synchronized (m_buf) {
+        return m_buf.read(raw);
+      }
+    } catch (RuntimeException e) {
+      return defaultValue;
+    }
+  }
+
+  @SuppressWarnings("PMD.AvoidCatchingGenericException")
+  private TimestampedObject<T> fromRaw(TimestampedRaw raw, T defaultValue) {
+    if (raw.value.length == 0) {
+      return new TimestampedObject<T>(0, 0, defaultValue);
+    }
+    try {
+      synchronized (m_buf) {
+        return new TimestampedObject<T>(raw.timestamp, raw.serverTime, m_buf.read(raw.value));
+      }
+    } catch (RuntimeException e) {
+      return new TimestampedObject<T>(0, 0, defaultValue);
+    }
+  }
+
+  private final StructTopic<T> m_topic;
+  private final T m_defaultValue;
+  private final StructBuffer<T> m_buf;
+  private boolean m_schemaPublished;
+  private static final byte[] m_emptyRaw = new byte[] {};
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructPublisher.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructPublisher.java
new file mode 100644
index 0000000..ec766e4
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructPublisher.java
@@ -0,0 +1,52 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import java.util.function.Consumer;
+
+/**
+ * NetworkTables struct-encoded value publisher.
+ *
+ * @param <T> value class
+ */
+public interface StructPublisher<T> extends Publisher, Consumer<T> {
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  @Override
+  StructTopic<T> getTopic();
+
+  /**
+   * Publish a new value using current NT time.
+   *
+   * @param value value to publish
+   */
+  default void set(T value) {
+    set(value, 0);
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  void set(T value, long time);
+
+  /**
+   * Publish a default value. On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   */
+  void setDefault(T value);
+
+  @Override
+  default void accept(T value) {
+    set(value);
+  }
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructSubscriber.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructSubscriber.java
new file mode 100644
index 0000000..b537b5c
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructSubscriber.java
@@ -0,0 +1,94 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import java.util.function.Supplier;
+
+/**
+ * NetworkTables struct-encoded value subscriber.
+ *
+ * @param <T> value class
+ */
+@SuppressWarnings("PMD.MissingOverride")
+public interface StructSubscriber<T> extends Subscriber, Supplier<T> {
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  @Override
+  StructTopic<T> getTopic();
+
+  /**
+   * Get the last published value. If no value has been published or the value cannot be unpacked,
+   * returns the stored default value.
+   *
+   * @return value
+   */
+  T get();
+
+  /**
+   * Get the last published value. If no value has been published or the value cannot be unpacked,
+   * returns the passed defaultValue.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return value
+   */
+  T get(T defaultValue);
+
+  /**
+   * Get the last published value, replacing the contents in place of an existing object. If no
+   * value has been published or the value cannot be unpacked, does not replace the contents and
+   * returns false. This function will not work (will throw UnsupportedOperationException) unless T
+   * is mutable (and the implementation of Struct implements unpackInto).
+   *
+   * <p>Note: due to Java language limitations, it's not possible to validate at compile time that
+   * the out parameter is mutable.
+   *
+   * @param out object to replace contents of; must be mutable
+   * @return true if successful, false if no value has been published
+   * @throws UnsupportedOperationException if T is immutable
+   */
+  boolean getInto(T out);
+
+  /**
+   * Get the last published value along with its timestamp. If no value has been published or the
+   * value cannot be unpacked, returns the stored default value and a timestamp of 0.
+   *
+   * @return timestamped value
+   */
+  TimestampedObject<T> getAtomic();
+
+  /**
+   * Get the last published value along with its timestamp If no value has been published or the
+   * value cannot be unpacked, returns the passed defaultValue and a timestamp of 0.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return timestamped value
+   */
+  TimestampedObject<T> getAtomic(T defaultValue);
+
+  /**
+   * Get an array of all valid value changes since the last call to readQueue. Also provides a
+   * timestamp for each value. Values that cannot be unpacked are dropped.
+   *
+   * <p>The "poll storage" subscribe option can be used to set the queue depth.
+   *
+   * @return Array of timestamped values; empty array if no valid new changes have been published
+   *     since the previous call.
+   */
+  TimestampedObject<T>[] readQueue();
+
+  /**
+   * Get an array of all value changes since the last call to readQueue. Values that cannot be
+   * unpacked are dropped.
+   *
+   * <p>The "poll storage" subscribe option can be used to set the queue depth.
+   *
+   * @return Array of values; empty array if no valid new changes have been published since the
+   *     previous call.
+   */
+  T[] readQueueValues();
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/StructTopic.java b/ntcore/src/main/java/edu/wpi/first/networktables/StructTopic.java
new file mode 100644
index 0000000..b1ff026
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/StructTopic.java
@@ -0,0 +1,177 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import edu.wpi.first.util.struct.Struct;
+import edu.wpi.first.util.struct.StructBuffer;
+
+/**
+ * NetworkTables struct-encoded value topic.
+ *
+ * @param <T> value class
+ */
+public final class StructTopic<T> extends Topic {
+  private StructTopic(Topic topic, Struct<T> struct) {
+    super(topic.m_inst, topic.m_handle);
+    m_struct = struct;
+  }
+
+  private StructTopic(NetworkTableInstance inst, int handle, Struct<T> struct) {
+    super(inst, handle);
+    m_struct = struct;
+  }
+
+  /**
+   * Create a StructTopic from a generic topic.
+   *
+   * @param <T> value class (inferred from struct)
+   * @param topic generic topic
+   * @param struct struct serialization implementation
+   * @return StructTopic for value class
+   */
+  public static <T> StructTopic<T> wrap(Topic topic, Struct<T> struct) {
+    return new StructTopic<T>(topic, struct);
+  }
+
+  /**
+   * Create a StructTopic from a native handle; generally NetworkTableInstance.getStructTopic()
+   * should be used instead.
+   *
+   * @param <T> value class (inferred from struct)
+   * @param inst Instance
+   * @param handle Native handle
+   * @param struct struct serialization implementation
+   * @return StructTopic for value class
+   */
+  public static <T> StructTopic<T> wrap(NetworkTableInstance inst, int handle, Struct<T> struct) {
+    return new StructTopic<T>(inst, handle, struct);
+  }
+
+  /**
+   * Create a new subscriber to the topic.
+   *
+   * <p>The subscriber is only active as long as the returned object is not closed.
+   *
+   * <p>Subscribers that do not match the published data type do not return any values. To determine
+   * if the data type matches, use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a getter function
+   * @param options subscribe options
+   * @return subscriber
+   */
+  public StructSubscriber<T> subscribe(T defaultValue, PubSubOption... options) {
+    return new StructEntryImpl<T>(
+        this,
+        StructBuffer.create(m_struct),
+        NetworkTablesJNI.subscribe(
+            m_handle, NetworkTableType.kRaw.getValue(), m_struct.getTypeString(), options),
+        defaultValue,
+        false);
+  }
+
+  /**
+   * Create a new publisher to the topic.
+   *
+   * <p>The publisher is only active as long as the returned object is not closed.
+   *
+   * <p>It is not possible to publish two different data types to the same topic. Conflicts between
+   * publishers are typically resolved by the server on a first-come, first-served basis. Any
+   * published values that do not match the topic's data type are dropped (ignored). To determine if
+   * the data type matches, use the appropriate Topic functions.
+   *
+   * @param options publish options
+   * @return publisher
+   */
+  public StructPublisher<T> publish(PubSubOption... options) {
+    m_inst.addSchema(m_struct);
+    return new StructEntryImpl<T>(
+        this,
+        StructBuffer.create(m_struct),
+        NetworkTablesJNI.publish(
+            m_handle, NetworkTableType.kRaw.getValue(), m_struct.getTypeString(), options),
+        null,
+        true);
+  }
+
+  /**
+   * Create a new publisher to the topic, with type string and initial properties.
+   *
+   * <p>The publisher is only active as long as the returned object is not closed.
+   *
+   * <p>It is not possible to publish two different data types to the same topic. Conflicts between
+   * publishers are typically resolved by the server on a first-come, first-served basis. Any
+   * published values that do not match the topic's data type are dropped (ignored). To determine if
+   * the data type matches, use the appropriate Topic functions.
+   *
+   * @param properties JSON properties
+   * @param options publish options
+   * @return publisher
+   * @throws IllegalArgumentException if properties is not a JSON object
+   */
+  public StructPublisher<T> publishEx(String properties, PubSubOption... options) {
+    m_inst.addSchema(m_struct);
+    return new StructEntryImpl<T>(
+        this,
+        StructBuffer.create(m_struct),
+        NetworkTablesJNI.publishEx(
+            m_handle,
+            NetworkTableType.kRaw.getValue(),
+            m_struct.getTypeString(),
+            properties,
+            options),
+        null,
+        true);
+  }
+
+  /**
+   * Create a new entry for the topic.
+   *
+   * <p>Entries act as a combination of a subscriber and a weak publisher. The subscriber is active
+   * as long as the entry is not closed. The publisher is created when the entry is first written
+   * to, and remains active until either unpublish() is called or the entry is closed.
+   *
+   * <p>It is not possible to use two different data types with the same topic. Conflicts between
+   * publishers are typically resolved by the server on a first-come, first-served basis. Any
+   * published values that do not match the topic's data type are dropped (ignored), and the entry
+   * will show no new values if the data type does not match. To determine if the data type matches,
+   * use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a getter function
+   * @param options publish and/or subscribe options
+   * @return entry
+   */
+  public StructEntry<T> getEntry(T defaultValue, PubSubOption... options) {
+    return new StructEntryImpl<T>(
+        this,
+        StructBuffer.create(m_struct),
+        NetworkTablesJNI.getEntry(
+            m_handle, NetworkTableType.kRaw.getValue(), m_struct.getTypeString(), options),
+        defaultValue,
+        false);
+  }
+
+  public Struct<T> getStruct() {
+    return m_struct;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == this) {
+      return true;
+    }
+    if (!(other instanceof StructTopic)) {
+      return false;
+    }
+
+    return super.equals(other) && m_struct == ((StructTopic<?>) other).m_struct;
+  }
+
+  @Override
+  public int hashCode() {
+    return super.hashCode() ^ m_struct.hashCode();
+  }
+
+  private final Struct<T> m_struct;
+}
diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/TimestampedObject.java b/ntcore/src/main/java/edu/wpi/first/networktables/TimestampedObject.java
new file mode 100644
index 0000000..37c1544
--- /dev/null
+++ b/ntcore/src/main/java/edu/wpi/first/networktables/TimestampedObject.java
@@ -0,0 +1,33 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+/** NetworkTables timestamped object. */
+public final class TimestampedObject<T> {
+  /**
+   * Create a timestamped value.
+   *
+   * @param timestamp timestamp in local time base
+   * @param serverTime timestamp in server time base
+   * @param value value
+   */
+  public TimestampedObject(long timestamp, long serverTime, T value) {
+    this.timestamp = timestamp;
+    this.serverTime = serverTime;
+    this.value = value;
+  }
+
+  /** Timestamp in local time base. */
+  @SuppressWarnings("MemberName")
+  public final long timestamp;
+
+  /** Timestamp in server time base. May be 0 or 1 for locally set values. */
+  @SuppressWarnings("MemberName")
+  public final long serverTime;
+
+  /** Value. */
+  @SuppressWarnings("MemberName")
+  public final T value;
+}
diff --git a/ntcore/src/main/native/cpp/ConnectionList.cpp b/ntcore/src/main/native/cpp/ConnectionList.cpp
index 4781f52..8dfca21 100644
--- a/ntcore/src/main/native/cpp/ConnectionList.cpp
+++ b/ntcore/src/main/native/cpp/ConnectionList.cpp
@@ -5,7 +5,7 @@
 #include "ConnectionList.h"
 
 #include <wpi/SmallVector.h>
-#include <wpi/json_serializer.h>
+#include <wpi/json.h>
 #include <wpi/raw_ostream.h>
 
 #include "IListenerStorage.h"
diff --git a/ntcore/src/main/native/cpp/INetworkClient.h b/ntcore/src/main/native/cpp/INetworkClient.h
index 986f43a..e9523c8 100644
--- a/ntcore/src/main/native/cpp/INetworkClient.h
+++ b/ntcore/src/main/native/cpp/INetworkClient.h
@@ -18,6 +18,7 @@
 
   virtual void SetServers(
       std::span<const std::pair<std::string, unsigned int>> servers) = 0;
+  virtual void Disconnect() = 0;
 
   virtual void StartDSClient(unsigned int port) = 0;
   virtual void StopDSClient() = 0;
diff --git a/ntcore/src/main/native/cpp/ListenerStorage.cpp b/ntcore/src/main/native/cpp/ListenerStorage.cpp
index fdbf03b..4270d0b 100644
--- a/ntcore/src/main/native/cpp/ListenerStorage.cpp
+++ b/ntcore/src/main/native/cpp/ListenerStorage.cpp
@@ -6,25 +6,12 @@
 
 #include <algorithm>
 
-#include <wpi/DenseMap.h>
 #include <wpi/SmallVector.h>
 
 #include "ntcore_c.h"
 
 using namespace nt;
 
-class ListenerStorage::Thread final : public wpi::SafeThreadEvent {
- public:
-  explicit Thread(NT_ListenerPoller poller) : m_poller{poller} {}
-
-  void Main() final;
-
-  NT_ListenerPoller m_poller;
-  wpi::DenseMap<NT_Listener, ListenerCallback> m_callbacks;
-  wpi::Event m_waitQueueWakeup;
-  wpi::Event m_waitQueueWaiter;
-};
-
 void ListenerStorage::Thread::Main() {
   while (m_active) {
     WPI_Handle signaledBuf[3];
@@ -55,10 +42,6 @@
   }
 }
 
-ListenerStorage::ListenerStorage(int inst) : m_inst{inst} {}
-
-ListenerStorage::~ListenerStorage() = default;
-
 void ListenerStorage::Activate(NT_Listener listenerHandle, unsigned int mask,
                                FinishEventFunc finishEvent) {
   std::scoped_lock lock{m_mutex};
diff --git a/ntcore/src/main/native/cpp/ListenerStorage.h b/ntcore/src/main/native/cpp/ListenerStorage.h
index b291a12..49dc8ce 100644
--- a/ntcore/src/main/native/cpp/ListenerStorage.h
+++ b/ntcore/src/main/native/cpp/ListenerStorage.h
@@ -11,6 +11,7 @@
 #include <utility>
 #include <vector>
 
+#include <wpi/DenseMap.h>
 #include <wpi/SafeThread.h>
 #include <wpi/SmallVector.h>
 #include <wpi/Synchronization.h>
@@ -19,16 +20,16 @@
 #include "Handle.h"
 #include "HandleMap.h"
 #include "IListenerStorage.h"
+#include "VectorSet.h"
 #include "ntcore_cpp.h"
 
 namespace nt {
 
 class ListenerStorage final : public IListenerStorage {
  public:
-  explicit ListenerStorage(int inst);
+  explicit ListenerStorage(int inst) : m_inst{inst} {}
   ListenerStorage(const ListenerStorage&) = delete;
   ListenerStorage& operator=(const ListenerStorage&) = delete;
-  ~ListenerStorage() final;
 
   // IListenerStorage interface
   void Activate(NT_Listener listenerHandle, unsigned int mask,
@@ -50,14 +51,16 @@
   NT_ListenerPoller CreateListenerPoller();
 
   // returns listener handle and mask for each listener that was destroyed
-  [[nodiscard]] std::vector<std::pair<NT_Listener, unsigned int>>
-  DestroyListenerPoller(NT_ListenerPoller pollerHandle);
+  [[nodiscard]]
+  std::vector<std::pair<NT_Listener, unsigned int>> DestroyListenerPoller(
+      NT_ListenerPoller pollerHandle);
 
   std::vector<Event> ReadListenerQueue(NT_ListenerPoller pollerHandle);
 
   // returns listener handle and mask for each listener that was destroyed
-  [[nodiscard]] std::vector<std::pair<NT_Listener, unsigned int>>
-  RemoveListener(NT_Listener listenerHandle);
+  [[nodiscard]]
+  std::vector<std::pair<NT_Listener, unsigned int>> RemoveListener(
+      NT_Listener listenerHandle);
 
   bool WaitForListenerQueue(double timeout);
 
@@ -95,21 +98,23 @@
   };
   HandleMap<ListenerData, 8> m_listeners;
 
-  // Utility wrapper for making a set-like vector
-  template <typename T>
-  class VectorSet : public std::vector<T> {
-   public:
-    void Add(T value) { this->push_back(value); }
-    void Remove(T value) { std::erase(*this, value); }
-  };
-
   VectorSet<ListenerData*> m_connListeners;
   VectorSet<ListenerData*> m_topicListeners;
   VectorSet<ListenerData*> m_valueListeners;
   VectorSet<ListenerData*> m_logListeners;
   VectorSet<ListenerData*> m_timeSyncListeners;
 
-  class Thread;
+  class Thread final : public wpi::SafeThreadEvent {
+   public:
+    explicit Thread(NT_ListenerPoller poller) : m_poller{poller} {}
+
+    void Main() final;
+
+    NT_ListenerPoller m_poller;
+    wpi::DenseMap<NT_Listener, ListenerCallback> m_callbacks;
+    wpi::Event m_waitQueueWakeup;
+    wpi::Event m_waitQueueWaiter;
+  };
   wpi::SafeThreadOwner<Thread> m_thread;
 };
 
diff --git a/ntcore/src/main/native/cpp/LocalStorage.cpp b/ntcore/src/main/native/cpp/LocalStorage.cpp
index db8e065..0377f4f 100644
--- a/ntcore/src/main/native/cpp/LocalStorage.cpp
+++ b/ntcore/src/main/native/cpp/LocalStorage.cpp
@@ -7,22 +7,15 @@
 #include <algorithm>
 
 #include <wpi/DataLog.h>
+#include <wpi/SmallString.h>
 #include <wpi/StringExtras.h>
-#include <wpi/StringMap.h>
-#include <wpi/Synchronization.h>
-#include <wpi/UidVector.h>
-#include <wpi/circular_buffer.h>
 #include <wpi/json.h>
 
-#include "Handle.h"
-#include "HandleMap.h"
 #include "IListenerStorage.h"
 #include "Log.h"
-#include "PubSubOptions.h"
 #include "Types_internal.h"
 #include "Value_internal.h"
 #include "networktables/NetworkTableValue.h"
-#include "ntcore_c.h"
 
 using namespace nt;
 
@@ -32,177 +25,18 @@
 static constexpr size_t kMaxMultiSubscribers = 512;
 static constexpr size_t kMaxListeners = 512;
 
-namespace {
-
-static constexpr bool IsSpecial(std::string_view name) {
-  return name.empty() ? false : name.front() == '$';
-}
-
 static constexpr bool PrefixMatch(std::string_view name,
                                   std::string_view prefix, bool special) {
   return (!special || !prefix.empty()) && wpi::starts_with(name, prefix);
 }
 
-// Utility wrapper for making a set-like vector
-template <typename T>
-class VectorSet : public std::vector<T> {
- public:
-  void Add(T value) { this->push_back(value); }
-  void Remove(T value) { std::erase(*this, value); }
-};
+std::string LocalStorage::DataLoggerEntry::MakeMetadata(
+    std::string_view properties) {
+  return fmt::format("{{\"properties\":{},\"source\":\"NT\"}}", properties);
+}
 
-struct EntryData;
-struct PublisherData;
-struct SubscriberData;
-struct MultiSubscriberData;
-
-struct DataLoggerEntry {
-  DataLoggerEntry(wpi::log::DataLog& log, int entry, NT_DataLogger logger)
-      : log{&log}, entry{entry}, logger{logger} {}
-
-  static std::string MakeMetadata(std::string_view properties) {
-    return fmt::format("{{\"properties\":{},\"source\":\"NT\"}}", properties);
-  }
-
-  void Append(const Value& v);
-
-  wpi::log::DataLog* log;
-  int entry;
-  NT_DataLogger logger;
-};
-
-struct TopicData {
-  static constexpr auto kType = Handle::kTopic;
-
-  TopicData(NT_Topic handle, std::string_view name)
-      : handle{handle}, name{name}, special{IsSpecial(name)} {}
-
-  bool Exists() const { return onNetwork || !localPublishers.empty(); }
-
-  TopicInfo GetTopicInfo() const;
-
-  // invariants
-  wpi::SignalObject<NT_Topic> handle;
-  std::string name;
-  bool special;
-
-  Value lastValue;  // also stores timestamp
-  Value lastValueNetwork;
-  NT_Type type{NT_UNASSIGNED};
-  std::string typeStr;
-  unsigned int flags{0};            // for NT3 APIs
-  std::string propertiesStr{"{}"};  // cached string for GetTopicInfo() et al
-  wpi::json properties = wpi::json::object();
-  NT_Entry entry{0};  // cached entry for GetEntry()
-
-  bool onNetwork{false};  // true if there are any remote publishers
-
-  wpi::SmallVector<DataLoggerEntry, 1> datalogs;
-  NT_Type datalogType{NT_UNASSIGNED};
-
-  VectorSet<PublisherData*> localPublishers;
-  VectorSet<SubscriberData*> localSubscribers;
-  VectorSet<MultiSubscriberData*> multiSubscribers;
-  VectorSet<EntryData*> entries;
-  VectorSet<NT_Listener> listeners;
-};
-
-struct PubSubConfig : public PubSubOptionsImpl {
-  PubSubConfig() = default;
-  PubSubConfig(NT_Type type, std::string_view typeStr,
-               const PubSubOptions& options)
-      : PubSubOptionsImpl{options}, type{type}, typeStr{typeStr} {
-    prefixMatch = false;
-  }
-
-  NT_Type type{NT_UNASSIGNED};
-  std::string typeStr;
-};
-
-struct PublisherData {
-  static constexpr auto kType = Handle::kPublisher;
-
-  PublisherData(NT_Publisher handle, TopicData* topic, PubSubConfig config)
-      : handle{handle}, topic{topic}, config{std::move(config)} {}
-
-  void UpdateActive();
-
-  // invariants
-  wpi::SignalObject<NT_Publisher> handle;
-  TopicData* topic;
-  PubSubConfig config;
-
-  // whether or not the publisher should actually publish values
-  bool active{false};
-};
-
-struct SubscriberData {
-  static constexpr auto kType = Handle::kSubscriber;
-
-  SubscriberData(NT_Subscriber handle, TopicData* topic, PubSubConfig config)
-      : handle{handle},
-        topic{topic},
-        config{std::move(config)},
-        pollStorage{config.pollStorage} {}
-
-  void UpdateActive();
-
-  // invariants
-  wpi::SignalObject<NT_Subscriber> handle;
-  TopicData* topic;
-  PubSubConfig config;
-
-  // whether or not the subscriber should actually receive values
-  bool active{false};
-
-  // polling storage
-  wpi::circular_buffer<Value> pollStorage;
-
-  // value listeners
-  VectorSet<NT_Listener> valueListeners;
-};
-
-struct EntryData {
-  static constexpr auto kType = Handle::kEntry;
-
-  EntryData(NT_Entry handle, SubscriberData* subscriber)
-      : handle{handle}, topic{subscriber->topic}, subscriber{subscriber} {}
-
-  // invariants
-  wpi::SignalObject<NT_Entry> handle;
-  TopicData* topic;
-  SubscriberData* subscriber;
-
-  // the publisher (created on demand)
-  PublisherData* publisher{nullptr};
-};
-
-struct MultiSubscriberData {
-  static constexpr auto kType = Handle::kMultiSubscriber;
-
-  MultiSubscriberData(NT_MultiSubscriber handle,
-                      std::span<const std::string_view> prefixes,
-                      const PubSubOptionsImpl& options)
-      : handle{handle}, options{options} {
-    this->options.prefixMatch = true;
-    this->prefixes.reserve(prefixes.size());
-    for (auto&& prefix : prefixes) {
-      this->prefixes.emplace_back(prefix);
-    }
-  }
-
-  bool Matches(std::string_view name, bool special);
-
-  // invariants
-  wpi::SignalObject<NT_MultiSubscriber> handle;
-  std::vector<std::string> prefixes;
-  PubSubOptionsImpl options;
-
-  // value listeners
-  VectorSet<NT_Listener> valueListeners;
-};
-
-bool MultiSubscriberData::Matches(std::string_view name, bool special) {
+bool LocalStorage::MultiSubscriberData::Matches(std::string_view name,
+                                                bool special) {
   for (auto&& prefix : prefixes) {
     if (PrefixMatch(name, prefix, special)) {
       return true;
@@ -211,155 +45,14 @@
   return false;
 }
 
-struct ListenerData {
-  ListenerData(NT_Listener handle, SubscriberData* subscriber,
-               unsigned int eventMask, bool subscriberOwned)
-      : handle{handle},
-        eventMask{eventMask},
-        subscriber{subscriber},
-        subscriberOwned{subscriberOwned} {}
-  ListenerData(NT_Listener handle, MultiSubscriberData* subscriber,
-               unsigned int eventMask, bool subscriberOwned)
-      : handle{handle},
-        eventMask{eventMask},
-        multiSubscriber{subscriber},
-        subscriberOwned{subscriberOwned} {}
+int LocalStorage::DataLoggerData::Start(TopicData* topic, int64_t time) {
+  return log.Start(fmt::format("{}{}", logPrefix,
+                               wpi::drop_front(topic->name, prefix.size())),
+                   topic->typeStr == "int" ? "int64" : topic->typeStr,
+                   DataLoggerEntry::MakeMetadata(topic->propertiesStr), time);
+}
 
-  NT_Listener handle;
-  unsigned int eventMask;
-  SubscriberData* subscriber{nullptr};
-  MultiSubscriberData* multiSubscriber{nullptr};
-  bool subscriberOwned;
-};
-
-struct DataLoggerData {
-  static constexpr auto kType = Handle::kDataLogger;
-
-  DataLoggerData(NT_DataLogger handle, wpi::log::DataLog& log,
-                 std::string_view prefix, std::string_view logPrefix)
-      : handle{handle}, log{log}, prefix{prefix}, logPrefix{logPrefix} {}
-
-  int Start(TopicData* topic, int64_t time) {
-    return log.Start(fmt::format("{}{}", logPrefix,
-                                 wpi::drop_front(topic->name, prefix.size())),
-                     topic->typeStr,
-                     DataLoggerEntry::MakeMetadata(topic->propertiesStr), time);
-  }
-
-  NT_DataLogger handle;
-  wpi::log::DataLog& log;
-  std::string prefix;
-  std::string logPrefix;
-};
-
-struct LSImpl {
-  LSImpl(int inst, IListenerStorage& listenerStorage, wpi::Logger& logger)
-      : m_inst{inst}, m_listenerStorage{listenerStorage}, m_logger{logger} {}
-
-  int m_inst;
-  IListenerStorage& m_listenerStorage;
-  wpi::Logger& m_logger;
-  net::NetworkInterface* m_network{nullptr};
-
-  // handle mappings
-  HandleMap<TopicData, 16> m_topics;
-  HandleMap<PublisherData, 16> m_publishers;
-  HandleMap<SubscriberData, 16> m_subscribers;
-  HandleMap<EntryData, 16> m_entries;
-  HandleMap<MultiSubscriberData, 16> m_multiSubscribers;
-  HandleMap<DataLoggerData, 16> m_dataloggers;
-
-  // name mappings
-  wpi::StringMap<TopicData*> m_nameTopics;
-
-  // listeners
-  wpi::DenseMap<NT_Listener, std::unique_ptr<ListenerData>> m_listeners;
-
-  // string-based listeners
-  VectorSet<ListenerData*> m_topicPrefixListeners;
-
-  // topic functions
-  void NotifyTopic(TopicData* topic, unsigned int eventFlags);
-
-  void CheckReset(TopicData* topic);
-
-  bool SetValue(TopicData* topic, const Value& value, unsigned int eventFlags,
-                bool isDuplicate, const PublisherData* publisher);
-  void NotifyValue(TopicData* topic, unsigned int eventFlags, bool isDuplicate,
-                   const PublisherData* publisher);
-
-  void SetFlags(TopicData* topic, unsigned int flags);
-  void SetPersistent(TopicData* topic, bool value);
-  void SetRetained(TopicData* topic, bool value);
-  void SetProperties(TopicData* topic, const wpi::json& update,
-                     bool sendNetwork);
-  void PropertiesUpdated(TopicData* topic, const wpi::json& update,
-                         unsigned int eventFlags, bool sendNetwork,
-                         bool updateFlags = true);
-
-  void RefreshPubSubActive(TopicData* topic, bool warnOnSubMismatch);
-
-  void NetworkAnnounce(TopicData* topic, std::string_view typeStr,
-                       const wpi::json& properties, NT_Publisher pubHandle);
-  void RemoveNetworkPublisher(TopicData* topic);
-  void NetworkPropertiesUpdate(TopicData* topic, const wpi::json& update,
-                               bool ack);
-
-  PublisherData* AddLocalPublisher(TopicData* topic,
-                                   const wpi::json& properties,
-                                   const PubSubConfig& options);
-  std::unique_ptr<PublisherData> RemoveLocalPublisher(NT_Publisher pubHandle);
-
-  SubscriberData* AddLocalSubscriber(TopicData* topic,
-                                     const PubSubConfig& options);
-  std::unique_ptr<SubscriberData> RemoveLocalSubscriber(
-      NT_Subscriber subHandle);
-
-  EntryData* AddEntry(SubscriberData* subscriber);
-  std::unique_ptr<EntryData> RemoveEntry(NT_Entry entryHandle);
-
-  MultiSubscriberData* AddMultiSubscriber(
-      std::span<const std::string_view> prefixes, const PubSubOptions& options);
-  std::unique_ptr<MultiSubscriberData> RemoveMultiSubscriber(
-      NT_MultiSubscriber subHandle);
-
-  void AddListenerImpl(NT_Listener listenerHandle, TopicData* topic,
-                       unsigned int eventMask);
-  void AddListenerImpl(NT_Listener listenerHandle, SubscriberData* subscriber,
-                       unsigned int eventMask, NT_Handle subentryHandle,
-                       bool subscriberOwned);
-  void AddListenerImpl(NT_Listener listenerHandle,
-                       MultiSubscriberData* subscriber, unsigned int eventMask,
-                       bool subscriberOwned);
-  void AddListenerImpl(NT_Listener listenerHandle,
-                       std::span<const std::string_view> prefixes,
-                       unsigned int eventMask);
-
-  void AddListener(NT_Listener listenerHandle,
-                   std::span<const std::string_view> prefixes,
-                   unsigned int mask);
-  void AddListener(NT_Listener listenerHandle, NT_Handle handle,
-                   unsigned int mask);
-  void RemoveListener(NT_Listener listenerHandle, unsigned int mask);
-
-  TopicData* GetOrCreateTopic(std::string_view name);
-  TopicData* GetTopic(NT_Handle handle);
-  SubscriberData* GetSubEntry(NT_Handle subentryHandle);
-  PublisherData* PublishEntry(EntryData* entry, NT_Type type);
-  Value* GetSubEntryValue(NT_Handle subentryHandle);
-
-  bool PublishLocalValue(PublisherData* publisher, const Value& value,
-                         bool force = false);
-
-  bool SetEntryValue(NT_Handle pubentryHandle, const Value& value);
-  bool SetDefaultEntryValue(NT_Handle pubsubentryHandle, const Value& value);
-
-  void RemoveSubEntry(NT_Handle subentryHandle);
-};
-
-}  // namespace
-
-void DataLoggerEntry::Append(const Value& v) {
+void LocalStorage::DataLoggerEntry::Append(const Value& v) {
   auto time = v.time();
   switch (v.type()) {
     case NT_BOOLEAN:
@@ -404,7 +97,7 @@
   }
 }
 
-TopicInfo TopicData::GetTopicInfo() const {
+TopicInfo LocalStorage::TopicData::GetTopicInfo() const {
   TopicInfo info;
   info.topic = handle;
   info.name = name;
@@ -414,19 +107,8 @@
   return info;
 }
 
-void PublisherData::UpdateActive() {
-  active = config.type == topic->type && config.typeStr == topic->typeStr;
-}
-
-void SubscriberData::UpdateActive() {
-  // for subscribers, unassigned is a wildcard
-  // also allow numerically compatible subscribers
-  active = config.type == NT_UNASSIGNED ||
-           (config.type == topic->type && config.typeStr == topic->typeStr) ||
-           IsNumericCompatible(config.type, topic->type);
-}
-
-void LSImpl::NotifyTopic(TopicData* topic, unsigned int eventFlags) {
+void LocalStorage::Impl::NotifyTopic(TopicData* topic,
+                                     unsigned int eventFlags) {
   DEBUG4("NotifyTopic({}, {})", topic->name, eventFlags);
   auto topicInfo = topic->GetTopicInfo();
   if (!topic->listeners.empty()) {
@@ -478,12 +160,13 @@
   }
 }
 
-void LSImpl::CheckReset(TopicData* topic) {
+void LocalStorage::Impl::CheckReset(TopicData* topic) {
   if (topic->Exists()) {
     return;
   }
   topic->lastValue = {};
   topic->lastValueNetwork = {};
+  topic->lastValueFromNetwork = false;
   topic->type = NT_UNASSIGNED;
   topic->typeStr.clear();
   topic->flags = 0;
@@ -491,30 +174,37 @@
   topic->propertiesStr = "{}";
 }
 
-bool LSImpl::SetValue(TopicData* topic, const Value& value,
-                      unsigned int eventFlags, bool isDuplicate,
-                      const PublisherData* publisher) {
+bool LocalStorage::Impl::SetValue(TopicData* topic, const Value& value,
+                                  unsigned int eventFlags, bool isDuplicate,
+                                  bool suppressIfDuplicate,
+                                  const PublisherData* publisher) {
   DEBUG4("SetValue({}, {}, {}, {})", topic->name, value.time(), eventFlags,
          isDuplicate);
   if (topic->type != NT_UNASSIGNED && topic->type != value.type()) {
     return false;
   }
-  if (!topic->lastValue || value.time() >= topic->lastValue.time()) {
+  if (!topic->lastValue || topic->lastValue.time() == 0 ||
+      value.time() >= topic->lastValue.time()) {
     // TODO: notify option even if older value
-    topic->type = value.type();
-    topic->lastValue = value;
-    NotifyValue(topic, eventFlags, isDuplicate, publisher);
-  }
-  if (!isDuplicate && topic->datalogType == value.type()) {
-    for (auto&& datalog : topic->datalogs) {
-      datalog.Append(value);
+    if (!(suppressIfDuplicate && isDuplicate)) {
+      topic->type = value.type();
+      topic->lastValue = value;
+      topic->lastValueFromNetwork = false;
+      NotifyValue(topic, eventFlags, isDuplicate, publisher);
+      if (topic->datalogType == value.type()) {
+        for (auto&& datalog : topic->datalogs) {
+          datalog.Append(value);
+        }
+      }
     }
   }
+
   return true;
 }
 
-void LSImpl::NotifyValue(TopicData* topic, unsigned int eventFlags,
-                         bool isDuplicate, const PublisherData* publisher) {
+void LocalStorage::Impl::NotifyValue(TopicData* topic, unsigned int eventFlags,
+                                     bool isDuplicate,
+                                     const PublisherData* publisher) {
   bool isNetwork = (eventFlags & NT_EVENT_VALUE_REMOTE) != 0;
   for (auto&& subscriber : topic->localSubscribers) {
     if (subscriber->active &&
@@ -543,7 +233,7 @@
   }
 }
 
-void LSImpl::SetFlags(TopicData* topic, unsigned int flags) {
+void LocalStorage::Impl::SetFlags(TopicData* topic, unsigned int flags) {
   wpi::json update = wpi::json::object();
   if ((flags & NT_PERSISTENT) != 0) {
     topic->properties["persistent"] = true;
@@ -565,7 +255,7 @@
   }
 }
 
-void LSImpl::SetPersistent(TopicData* topic, bool value) {
+void LocalStorage::Impl::SetPersistent(TopicData* topic, bool value) {
   wpi::json update = wpi::json::object();
   if (value) {
     topic->flags |= NT_PERSISTENT;
@@ -579,7 +269,7 @@
   PropertiesUpdated(topic, update, NT_EVENT_NONE, true, false);
 }
 
-void LSImpl::SetRetained(TopicData* topic, bool value) {
+void LocalStorage::Impl::SetRetained(TopicData* topic, bool value) {
   wpi::json update = wpi::json::object();
   if (value) {
     topic->flags |= NT_RETAINED;
@@ -593,8 +283,9 @@
   PropertiesUpdated(topic, update, NT_EVENT_NONE, true, false);
 }
 
-void LSImpl::SetProperties(TopicData* topic, const wpi::json& update,
-                           bool sendNetwork) {
+void LocalStorage::Impl::SetProperties(TopicData* topic,
+                                       const wpi::json& update,
+                                       bool sendNetwork) {
   if (!update.is_object()) {
     return;
   }
@@ -609,9 +300,10 @@
   PropertiesUpdated(topic, update, NT_EVENT_NONE, sendNetwork);
 }
 
-void LSImpl::PropertiesUpdated(TopicData* topic, const wpi::json& update,
-                               unsigned int eventFlags, bool sendNetwork,
-                               bool updateFlags) {
+void LocalStorage::Impl::PropertiesUpdated(TopicData* topic,
+                                           const wpi::json& update,
+                                           unsigned int eventFlags,
+                                           bool sendNetwork, bool updateFlags) {
   DEBUG4("PropertiesUpdated({}, {}, {}, {}, {})", topic->name, update.dump(),
          eventFlags, sendNetwork, updateFlags);
   if (updateFlags) {
@@ -646,7 +338,8 @@
   }
 }
 
-void LSImpl::RefreshPubSubActive(TopicData* topic, bool warnOnSubMismatch) {
+void LocalStorage::Impl::RefreshPubSubActive(TopicData* topic,
+                                             bool warnOnSubMismatch) {
   for (auto&& publisher : topic->localPublishers) {
     publisher->UpdateActive();
   }
@@ -662,9 +355,10 @@
   }
 }
 
-void LSImpl::NetworkAnnounce(TopicData* topic, std::string_view typeStr,
-                             const wpi::json& properties,
-                             NT_Publisher pubHandle) {
+void LocalStorage::Impl::NetworkAnnounce(TopicData* topic,
+                                         std::string_view typeStr,
+                                         const wpi::json& properties,
+                                         NT_Publisher pubHandle) {
   DEBUG4("LS NetworkAnnounce({}, {}, {}, {})", topic->name, typeStr,
          properties.dump(), pubHandle);
   if (pubHandle != 0) {
@@ -716,8 +410,9 @@
   }
 }
 
-void LSImpl::RemoveNetworkPublisher(TopicData* topic) {
-  DEBUG4("LS RemoveNetworkPublisher({}, {})", topic->handle, topic->name);
+void LocalStorage::Impl::RemoveNetworkPublisher(TopicData* topic) {
+  DEBUG4("LS RemoveNetworkPublisher({}, {})", topic->handle.GetHandle(),
+         topic->name);
   // this acts as an unpublish
   bool didExist = topic->Exists();
   topic->onNetwork = false;
@@ -746,8 +441,9 @@
   }
 }
 
-void LSImpl::NetworkPropertiesUpdate(TopicData* topic, const wpi::json& update,
-                                     bool ack) {
+void LocalStorage::Impl::NetworkPropertiesUpdate(TopicData* topic,
+                                                 const wpi::json& update,
+                                                 bool ack) {
   DEBUG4("NetworkPropertiesUpdate({},{})", topic->name, ack);
   if (ack) {
     return;  // ignore acks
@@ -755,9 +451,8 @@
   SetProperties(topic, update, false);
 }
 
-PublisherData* LSImpl::AddLocalPublisher(TopicData* topic,
-                                         const wpi::json& properties,
-                                         const PubSubConfig& config) {
+LocalStorage::PublisherData* LocalStorage::Impl::AddLocalPublisher(
+    TopicData* topic, const wpi::json& properties, const PubSubConfig& config) {
   bool didExist = topic->Exists();
   auto publisher = m_publishers.Add(m_inst, topic, config);
   topic->localPublishers.Add(publisher);
@@ -775,8 +470,7 @@
     } else if (properties.is_object()) {
       topic->properties = properties;
     } else {
-      WARNING("ignoring non-object properties when publishing '{}'",
-              topic->name);
+      WARN("ignoring non-object properties when publishing '{}'", topic->name);
       topic->properties = wpi::json::object();
     }
 
@@ -804,8 +498,8 @@
   return publisher;
 }
 
-std::unique_ptr<PublisherData> LSImpl::RemoveLocalPublisher(
-    NT_Publisher pubHandle) {
+std::unique_ptr<LocalStorage::PublisherData>
+LocalStorage::Impl::RemoveLocalPublisher(NT_Publisher pubHandle) {
   auto publisher = m_publishers.Remove(pubHandle);
   if (publisher) {
     auto topic = publisher->topic;
@@ -840,8 +534,8 @@
   return publisher;
 }
 
-SubscriberData* LSImpl::AddLocalSubscriber(TopicData* topic,
-                                           const PubSubConfig& config) {
+LocalStorage::SubscriberData* LocalStorage::Impl::AddLocalSubscriber(
+    TopicData* topic, const PubSubConfig& config) {
   DEBUG4("AddLocalSubscriber({})", topic->name);
   auto subscriber = m_subscribers.Add(m_inst, topic, config);
   topic->localSubscribers.Add(subscriber);
@@ -858,11 +552,22 @@
     DEBUG4("-> NetworkSubscribe({})", topic->name);
     m_network->Subscribe(subscriber->handle, {{topic->name}}, config);
   }
+
+  // queue current value
+  if (subscriber->active) {
+    if (!topic->lastValueFromNetwork && !config.disableLocal) {
+      subscriber->pollStorage.emplace_back(topic->lastValue);
+      subscriber->handle.Set();
+    } else if (topic->lastValueFromNetwork && !config.disableRemote) {
+      subscriber->pollStorage.emplace_back(topic->lastValueNetwork);
+      subscriber->handle.Set();
+    }
+  }
   return subscriber;
 }
 
-std::unique_ptr<SubscriberData> LSImpl::RemoveLocalSubscriber(
-    NT_Subscriber subHandle) {
+std::unique_ptr<LocalStorage::SubscriberData>
+LocalStorage::Impl::RemoveLocalSubscriber(NT_Subscriber subHandle) {
   auto subscriber = m_subscribers.Remove(subHandle);
   if (subscriber) {
     auto topic = subscriber->topic;
@@ -879,13 +584,15 @@
   return subscriber;
 }
 
-EntryData* LSImpl::AddEntry(SubscriberData* subscriber) {
+LocalStorage::EntryData* LocalStorage::Impl::AddEntry(
+    SubscriberData* subscriber) {
   auto entry = m_entries.Add(m_inst, subscriber);
   subscriber->topic->entries.Add(entry);
   return entry;
 }
 
-std::unique_ptr<EntryData> LSImpl::RemoveEntry(NT_Entry entryHandle) {
+std::unique_ptr<LocalStorage::EntryData> LocalStorage::Impl::RemoveEntry(
+    NT_Entry entryHandle) {
   auto entry = m_entries.Remove(entryHandle);
   if (entry) {
     entry->topic->entries.Remove(entry.get());
@@ -893,8 +600,9 @@
   return entry;
 }
 
-MultiSubscriberData* LSImpl::AddMultiSubscriber(
+LocalStorage::MultiSubscriberData* LocalStorage::Impl::AddMultiSubscriber(
     std::span<const std::string_view> prefixes, const PubSubOptions& options) {
+  DEBUG4("AddMultiSubscriber({})", fmt::join(prefixes, ","));
   auto subscriber = m_multiSubscribers.Add(m_inst, prefixes, options);
   // subscribe to any already existing topics
   for (auto&& topic : m_topics) {
@@ -906,14 +614,15 @@
     }
   }
   if (m_network) {
+    DEBUG4("-> NetworkSubscribe");
     m_network->Subscribe(subscriber->handle, subscriber->prefixes,
                          subscriber->options);
   }
   return subscriber;
 }
 
-std::unique_ptr<MultiSubscriberData> LSImpl::RemoveMultiSubscriber(
-    NT_MultiSubscriber subHandle) {
+std::unique_ptr<LocalStorage::MultiSubscriberData>
+LocalStorage::Impl::RemoveMultiSubscriber(NT_MultiSubscriber subHandle) {
   auto subscriber = m_multiSubscribers.Remove(subHandle);
   if (subscriber) {
     for (auto&& topic : m_topics) {
@@ -931,11 +640,11 @@
   return subscriber;
 }
 
-void LSImpl::AddListenerImpl(NT_Listener listenerHandle, TopicData* topic,
-                             unsigned int eventMask) {
+void LocalStorage::Impl::AddListenerImpl(NT_Listener listenerHandle,
+                                         TopicData* topic,
+                                         unsigned int eventMask) {
   if (topic->localSubscribers.size() >= kMaxSubscribers) {
-    ERROR(
-        "reached maximum number of subscribers to '{}', ignoring listener add",
+    ERR("reached maximum number of subscribers to '{}', ignoring listener add",
         topic->name);
     return;
   }
@@ -946,9 +655,11 @@
   AddListenerImpl(listenerHandle, sub, eventMask, sub->handle, true);
 }
 
-void LSImpl::AddListenerImpl(NT_Listener listenerHandle,
-                             SubscriberData* subscriber, unsigned int eventMask,
-                             NT_Handle subentryHandle, bool subscriberOwned) {
+void LocalStorage::Impl::AddListenerImpl(NT_Listener listenerHandle,
+                                         SubscriberData* subscriber,
+                                         unsigned int eventMask,
+                                         NT_Handle subentryHandle,
+                                         bool subscriberOwned) {
   m_listeners.try_emplace(listenerHandle, std::make_unique<ListenerData>(
                                               listenerHandle, subscriber,
                                               eventMask, subscriberOwned));
@@ -957,8 +668,8 @@
 
   if ((eventMask & NT_EVENT_TOPIC) != 0) {
     if (topic->listeners.size() >= kMaxListeners) {
-      ERROR("reached maximum number of listeners to '{}', not adding listener",
-            topic->name);
+      ERR("reached maximum number of listeners to '{}', not adding listener",
+          topic->name);
       return;
     }
 
@@ -979,8 +690,8 @@
 
   if ((eventMask & NT_EVENT_VALUE_ALL) != 0) {
     if (subscriber->valueListeners.size() >= kMaxListeners) {
-      ERROR("reached maximum number of listeners to '{}', not adding listener",
-            topic->name);
+      ERR("reached maximum number of listeners to '{}', not adding listener",
+          topic->name);
       return;
     }
     m_listenerStorage.Activate(
@@ -1004,9 +715,10 @@
   }
 }
 
-void LSImpl::AddListenerImpl(NT_Listener listenerHandle,
-                             MultiSubscriberData* subscriber,
-                             unsigned int eventMask, bool subscriberOwned) {
+void LocalStorage::Impl::AddListenerImpl(NT_Listener listenerHandle,
+                                         MultiSubscriberData* subscriber,
+                                         unsigned int eventMask,
+                                         bool subscriberOwned) {
   auto listener =
       m_listeners
           .try_emplace(listenerHandle, std::make_unique<ListenerData>(
@@ -1028,7 +740,7 @@
 
   if ((eventMask & NT_EVENT_TOPIC) != 0) {
     if (m_topicPrefixListeners.size() >= kMaxListeners) {
-      ERROR("reached maximum number of listeners, not adding listener");
+      ERR("reached maximum number of listeners, not adding listener");
       return;
     }
 
@@ -1054,7 +766,7 @@
 
   if ((eventMask & NT_EVENT_VALUE_ALL) != 0) {
     if (subscriber->valueListeners.size() >= kMaxListeners) {
-      ERROR("reached maximum number of listeners, not adding listener");
+      ERR("reached maximum number of listeners, not adding listener");
       return;
     }
 
@@ -1084,61 +796,8 @@
   }
 }
 
-void LSImpl::AddListener(NT_Listener listenerHandle,
-                         std::span<const std::string_view> prefixes,
-                         unsigned int eventMask) {
-  if (m_multiSubscribers.size() >= kMaxMultiSubscribers) {
-    ERROR("reached maximum number of multi-subscribers, not adding listener");
-    return;
-  }
-  // subscribe to make sure topic updates are received
-  auto sub = AddMultiSubscriber(
-      prefixes, {.topicsOnly = (eventMask & NT_EVENT_VALUE_ALL) == 0});
-  AddListenerImpl(listenerHandle, sub, eventMask, true);
-}
-
-void LSImpl::AddListener(NT_Listener listenerHandle, NT_Handle handle,
-                         unsigned int mask) {
-  if (auto topic = m_topics.Get(handle)) {
-    AddListenerImpl(listenerHandle, topic, mask);
-  } else if (auto sub = m_multiSubscribers.Get(handle)) {
-    AddListenerImpl(listenerHandle, sub, mask, false);
-  } else if (auto sub = m_subscribers.Get(handle)) {
-    AddListenerImpl(listenerHandle, sub, mask, sub->handle, false);
-  } else if (auto entry = m_entries.Get(handle)) {
-    AddListenerImpl(listenerHandle, entry->subscriber, mask, entry->handle,
-                    false);
-  }
-}
-
-void LSImpl::RemoveListener(NT_Listener listenerHandle, unsigned int mask) {
-  auto listenerIt = m_listeners.find(listenerHandle);
-  if (listenerIt == m_listeners.end()) {
-    return;
-  }
-  auto listener = std::move(listenerIt->getSecond());
-  m_listeners.erase(listenerIt);
-  if (!listener) {
-    return;
-  }
-
-  m_topicPrefixListeners.Remove(listener.get());
-  if (listener->subscriber) {
-    listener->subscriber->valueListeners.Remove(listenerHandle);
-    listener->subscriber->topic->listeners.Remove(listenerHandle);
-    if (listener->subscriberOwned) {
-      RemoveLocalSubscriber(listener->subscriber->handle);
-    }
-  }
-  if (listener->multiSubscriber) {
-    listener->multiSubscriber->valueListeners.Remove(listenerHandle);
-    if (listener->subscriberOwned) {
-      RemoveMultiSubscriber(listener->multiSubscriber->handle);
-    }
-  }
-}
-
-TopicData* LSImpl::GetOrCreateTopic(std::string_view name) {
+LocalStorage::TopicData* LocalStorage::Impl::GetOrCreateTopic(
+    std::string_view name) {
   auto& topic = m_nameTopics[name];
   // create if it does not already exist
   if (!topic) {
@@ -1153,7 +812,7 @@
   return topic;
 }
 
-TopicData* LSImpl::GetTopic(NT_Handle handle) {
+LocalStorage::TopicData* LocalStorage::Impl::GetTopic(NT_Handle handle) {
   switch (Handle{handle}.GetType()) {
     case Handle::kEntry: {
       if (auto entry = m_entries.Get(handle)) {
@@ -1181,7 +840,8 @@
   return {};
 }
 
-SubscriberData* LSImpl::GetSubEntry(NT_Handle subentryHandle) {
+LocalStorage::SubscriberData* LocalStorage::Impl::GetSubEntry(
+    NT_Handle subentryHandle) {
   Handle h{subentryHandle};
   if (h.IsType(Handle::kSubscriber)) {
     return m_subscribers.Get(subentryHandle);
@@ -1193,39 +853,36 @@
   }
 }
 
-PublisherData* LSImpl::PublishEntry(EntryData* entry, NT_Type type) {
+LocalStorage::PublisherData* LocalStorage::Impl::PublishEntry(EntryData* entry,
+                                                              NT_Type type) {
   if (entry->publisher) {
     return entry->publisher;
   }
-  auto typeStr = TypeToString(type);
   if (entry->subscriber->config.type == NT_UNASSIGNED) {
+    auto typeStr = TypeToString(type);
     entry->subscriber->config.type = type;
     entry->subscriber->config.typeStr = typeStr;
-  } else if (entry->subscriber->config.type != type ||
-             entry->subscriber->config.typeStr != typeStr) {
+  } else if (entry->subscriber->config.type != type) {
     if (!IsNumericCompatible(type, entry->subscriber->config.type)) {
       // don't allow dynamically changing the type of an entry
-      ERROR("cannot publish entry {} as type {}, previously subscribed as {}",
-            entry->topic->name, typeStr, entry->subscriber->config.typeStr);
+      auto typeStr = TypeToString(type);
+      ERR("cannot publish entry {} as type {}, previously subscribed as {}",
+          entry->topic->name, typeStr, entry->subscriber->config.typeStr);
       return nullptr;
     }
   }
   // create publisher
   entry->publisher = AddLocalPublisher(entry->topic, wpi::json::object(),
                                        entry->subscriber->config);
+  // exclude publisher if requested
+  if (entry->subscriber->config.excludeSelf) {
+    entry->subscriber->config.excludePublisher = entry->publisher->handle;
+  }
   return entry->publisher;
 }
 
-Value* LSImpl::GetSubEntryValue(NT_Handle subentryHandle) {
-  if (auto subscriber = GetSubEntry(subentryHandle)) {
-    return &subscriber->topic->lastValue;
-  } else {
-    return nullptr;
-  }
-}
-
-bool LSImpl::PublishLocalValue(PublisherData* publisher, const Value& value,
-                               bool force) {
+bool LocalStorage::Impl::PublishLocalValue(PublisherData* publisher,
+                                           const Value& value, bool force) {
   if (!value) {
     return false;
   }
@@ -1238,26 +895,28 @@
     return false;
   }
   if (publisher->active) {
-    bool isDuplicate, isNetworkDuplicate;
+    bool isDuplicate, isNetworkDuplicate, suppressDuplicates;
     if (force || publisher->config.keepDuplicates) {
-      isDuplicate = false;
+      suppressDuplicates = false;
       isNetworkDuplicate = false;
     } else {
-      isDuplicate = (publisher->topic->lastValue == value);
+      suppressDuplicates = true;
       isNetworkDuplicate = (publisher->topic->lastValueNetwork == value);
     }
+    isDuplicate = (publisher->topic->lastValue == value);
     if (!isNetworkDuplicate && m_network) {
       publisher->topic->lastValueNetwork = value;
       m_network->SetValue(publisher->handle, value);
     }
     return SetValue(publisher->topic, value, NT_EVENT_VALUE_LOCAL, isDuplicate,
-                    publisher);
+                    suppressDuplicates, publisher);
   } else {
     return false;
   }
 }
 
-bool LSImpl::SetEntryValue(NT_Handle pubentryHandle, const Value& value) {
+bool LocalStorage::Impl::SetEntryValue(NT_Handle pubentryHandle,
+                                       const Value& value) {
   if (!value) {
     return false;
   }
@@ -1265,9 +924,6 @@
   if (!publisher) {
     if (auto entry = m_entries.Get(pubentryHandle)) {
       publisher = PublishEntry(entry, value.type());
-      if (entry->subscriber->config.excludeSelf) {
-        entry->subscriber->config.excludePublisher = publisher->handle;
-      }
     }
     if (!publisher) {
       return false;
@@ -1276,8 +932,8 @@
   return PublishLocalValue(publisher, value);
 }
 
-bool LSImpl::SetDefaultEntryValue(NT_Handle pubsubentryHandle,
-                                  const Value& value) {
+bool LocalStorage::Impl::SetDefaultEntryValue(NT_Handle pubsubentryHandle,
+                                              const Value& value) {
   DEBUG4("SetDefaultEntryValue({}, {})", pubsubentryHandle,
          static_cast<int>(value.type()));
   if (!value) {
@@ -1299,17 +955,20 @@
       if (topic->type == NT_UNASSIGNED) {
         topic->type = value.type();
       }
+      Value newValue;
       if (topic->type == value.type()) {
-        topic->lastValue = value;
+        newValue = value;
       } else if (IsNumericCompatible(topic->type, value.type())) {
-        topic->lastValue = ConvertNumericValue(value, topic->type);
+        newValue = ConvertNumericValue(value, topic->type);
       } else {
         return true;
       }
-      topic->lastValue.SetTime(0);
-      topic->lastValue.SetServerTime(0);
+      newValue.SetTime(0);
+      newValue.SetServerTime(0);
       if (publisher) {
-        PublishLocalValue(publisher, topic->lastValue, true);
+        PublishLocalValue(publisher, newValue, true);
+      } else {
+        topic->lastValue = newValue;
       }
       return true;
     }
@@ -1317,7 +976,7 @@
   return false;
 }
 
-void LSImpl::RemoveSubEntry(NT_Handle subentryHandle) {
+void LocalStorage::Impl::RemoveSubEntry(NT_Handle subentryHandle) {
   Handle h{subentryHandle};
   if (h.IsType(Handle::kSubscriber)) {
     RemoveLocalSubscriber(subentryHandle);
@@ -1333,15 +992,9 @@
   }
 }
 
-class LocalStorage::Impl : public LSImpl {
- public:
-  Impl(int inst, IListenerStorage& listenerStorage, wpi::Logger& logger)
-      : LSImpl{inst, listenerStorage, logger} {}
-};
-
-LocalStorage::LocalStorage(int inst, IListenerStorage& listenerStorage,
-                           wpi::Logger& logger)
-    : m_impl{std::make_unique<Impl>(inst, listenerStorage, logger)} {}
+LocalStorage::Impl::Impl(int inst, IListenerStorage& listenerStorage,
+                         wpi::Logger& logger)
+    : m_inst{inst}, m_listenerStorage{listenerStorage}, m_logger{logger} {}
 
 LocalStorage::~LocalStorage() = default;
 
@@ -1350,43 +1003,48 @@
                                        const wpi::json& properties,
                                        NT_Publisher pubHandle) {
   std::scoped_lock lock{m_mutex};
-  auto topic = m_impl->GetOrCreateTopic(name);
-  m_impl->NetworkAnnounce(topic, typeStr, properties, pubHandle);
+  auto topic = m_impl.GetOrCreateTopic(name);
+  m_impl.NetworkAnnounce(topic, typeStr, properties, pubHandle);
   return topic->handle;
 }
 
 void LocalStorage::NetworkUnannounce(std::string_view name) {
   std::scoped_lock lock{m_mutex};
-  auto topic = m_impl->GetOrCreateTopic(name);
-  m_impl->RemoveNetworkPublisher(topic);
+  auto topic = m_impl.GetOrCreateTopic(name);
+  m_impl.RemoveNetworkPublisher(topic);
 }
 
 void LocalStorage::NetworkPropertiesUpdate(std::string_view name,
                                            const wpi::json& update, bool ack) {
   std::scoped_lock lock{m_mutex};
-  auto it = m_impl->m_nameTopics.find(name);
-  if (it != m_impl->m_nameTopics.end()) {
-    m_impl->NetworkPropertiesUpdate(it->second, update, ack);
+  auto it = m_impl.m_nameTopics.find(name);
+  if (it != m_impl.m_nameTopics.end()) {
+    m_impl.NetworkPropertiesUpdate(it->second, update, ack);
   }
 }
 
 void LocalStorage::NetworkSetValue(NT_Topic topicHandle, const Value& value) {
   std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    if (m_impl->SetValue(topic, value, NT_EVENT_VALUE_REMOTE,
-                         value == topic->lastValue, nullptr)) {
+  if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+    if (m_impl.SetValue(topic, value, NT_EVENT_VALUE_REMOTE,
+                        value == topic->lastValue, false, nullptr)) {
       topic->lastValueNetwork = value;
+      topic->lastValueFromNetwork = true;
     }
   }
 }
 
 void LocalStorage::StartNetwork(net::NetworkInterface* network) {
-  WPI_DEBUG4(m_impl->m_logger, "StartNetwork()");
   std::scoped_lock lock{m_mutex};
-  m_impl->m_network = network;
+  m_impl.StartNetwork(network);
+}
+
+void LocalStorage::Impl::StartNetwork(net::NetworkInterface* network) {
+  DEBUG4("StartNetwork()");
+  m_network = network;
   // publish all active publishers to the network and send last values
   // only send value once per topic
-  for (auto&& topic : m_impl->m_topics) {
+  for (auto&& topic : m_topics) {
     PublisherData* anyPublisher = nullptr;
     for (auto&& publisher : topic->localPublishers) {
       if (publisher->active) {
@@ -1399,23 +1057,23 @@
       network->SetValue(anyPublisher->handle, topic->lastValue);
     }
   }
-  for (auto&& subscriber : m_impl->m_subscribers) {
+  for (auto&& subscriber : m_subscribers) {
     network->Subscribe(subscriber->handle, {{subscriber->topic->name}},
                        subscriber->config);
   }
-  for (auto&& subscriber : m_impl->m_multiSubscribers) {
+  for (auto&& subscriber : m_multiSubscribers) {
     network->Subscribe(subscriber->handle, subscriber->prefixes,
                        subscriber->options);
   }
 }
 
 void LocalStorage::ClearNetwork() {
-  WPI_DEBUG4(m_impl->m_logger, "ClearNetwork()");
+  WPI_DEBUG4(m_impl.m_logger, "ClearNetwork()");
   std::scoped_lock lock{m_mutex};
-  m_impl->m_network = nullptr;
+  m_impl.m_network = nullptr;
   // treat as an unannounce all from the network side
-  for (auto&& topic : m_impl->m_topics) {
-    m_impl->RemoveNetworkPublisher(topic.get());
+  for (auto&& topic : m_impl.m_topics) {
+    m_impl.RemoveNetworkPublisher(topic.get());
   }
 }
 
@@ -1466,7 +1124,7 @@
                                               unsigned int types) {
   std::scoped_lock lock(m_mutex);
   std::vector<NT_Topic> rv;
-  ForEachTopic(m_impl->m_topics, prefix, types,
+  ForEachTopic(m_impl.m_topics, prefix, types,
                [&](TopicData& topic) { rv.push_back(topic.handle); });
   return rv;
 }
@@ -1475,7 +1133,7 @@
     std::string_view prefix, std::span<const std::string_view> types) {
   std::scoped_lock lock(m_mutex);
   std::vector<NT_Topic> rv;
-  ForEachTopic(m_impl->m_topics, prefix, types,
+  ForEachTopic(m_impl.m_topics, prefix, types,
                [&](TopicData& topic) { rv.push_back(topic.handle); });
   return rv;
 }
@@ -1484,7 +1142,7 @@
                                                   unsigned int types) {
   std::scoped_lock lock(m_mutex);
   std::vector<TopicInfo> rv;
-  ForEachTopic(m_impl->m_topics, prefix, types, [&](TopicData& topic) {
+  ForEachTopic(m_impl.m_topics, prefix, types, [&](TopicData& topic) {
     rv.emplace_back(topic.GetTopicInfo());
   });
   return rv;
@@ -1494,99 +1152,16 @@
     std::string_view prefix, std::span<const std::string_view> types) {
   std::scoped_lock lock(m_mutex);
   std::vector<TopicInfo> rv;
-  ForEachTopic(m_impl->m_topics, prefix, types, [&](TopicData& topic) {
+  ForEachTopic(m_impl.m_topics, prefix, types, [&](TopicData& topic) {
     rv.emplace_back(topic.GetTopicInfo());
   });
   return rv;
 }
 
-NT_Topic LocalStorage::GetTopic(std::string_view name) {
-  if (name.empty()) {
-    return {};
-  }
-  std::scoped_lock lock{m_mutex};
-  return m_impl->GetOrCreateTopic(name)->handle;
-}
-
-std::string LocalStorage::GetTopicName(NT_Topic topicHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    return topic->name;
-  } else {
-    return {};
-  }
-}
-
-NT_Type LocalStorage::GetTopicType(NT_Topic topicHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    return topic->type;
-  } else {
-    return {};
-  }
-}
-
-std::string LocalStorage::GetTopicTypeString(NT_Topic topicHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    return topic->typeStr;
-  } else {
-    return {};
-  }
-}
-
-void LocalStorage::SetTopicPersistent(NT_Topic topicHandle, bool value) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    m_impl->SetPersistent(topic, value);
-  }
-}
-
-bool LocalStorage::GetTopicPersistent(NT_Topic topicHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    return (topic->flags & NT_PERSISTENT) != 0;
-  } else {
-    return false;
-  }
-}
-
-void LocalStorage::SetTopicRetained(NT_Topic topicHandle, bool value) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    m_impl->SetRetained(topic, value);
-  }
-}
-
-bool LocalStorage::GetTopicRetained(NT_Topic topicHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    return (topic->flags & NT_RETAINED) != 0;
-  } else {
-    return false;
-  }
-}
-
-bool LocalStorage::GetTopicExists(NT_Handle handle) {
-  std::scoped_lock lock{m_mutex};
-  TopicData* topic = m_impl->GetTopic(handle);
-  return topic && topic->Exists();
-}
-
-wpi::json LocalStorage::GetTopicProperty(NT_Topic topicHandle,
-                                         std::string_view name) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    return topic->properties.value(name, wpi::json{});
-  } else {
-    return {};
-  }
-}
-
 void LocalStorage::SetTopicProperty(NT_Topic topicHandle, std::string_view name,
                                     const wpi::json& value) {
   std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
+  if (auto topic = m_impl.m_topics.Get(topicHandle)) {
     if (value.is_null()) {
       topic->properties.erase(name);
     } else {
@@ -1594,27 +1169,18 @@
     }
     wpi::json update = wpi::json::object();
     update[name] = value;
-    m_impl->PropertiesUpdated(topic, update, NT_EVENT_NONE, true);
+    m_impl.PropertiesUpdated(topic, update, NT_EVENT_NONE, true);
   }
 }
 
 void LocalStorage::DeleteTopicProperty(NT_Topic topicHandle,
                                        std::string_view name) {
   std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
+  if (auto topic = m_impl.m_topics.Get(topicHandle)) {
     topic->properties.erase(name);
     wpi::json update = wpi::json::object();
     update[name] = wpi::json();
-    m_impl->PropertiesUpdated(topic, update, NT_EVENT_NONE, true);
-  }
-}
-
-wpi::json LocalStorage::GetTopicProperties(NT_Topic topicHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    return topic->properties;
-  } else {
-    return wpi::json::object();
+    m_impl.PropertiesUpdated(topic, update, NT_EVENT_NONE, true);
   }
 }
 
@@ -1624,67 +1190,48 @@
     return false;
   }
   std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    m_impl->SetProperties(topic, update, true);
+  if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+    m_impl.SetProperties(topic, update, true);
     return true;
   } else {
     return {};
   }
 }
 
-TopicInfo LocalStorage::GetTopicInfo(NT_Topic topicHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->m_topics.Get(topicHandle)) {
-    return topic->GetTopicInfo();
-  } else {
-    return {};
-  }
-}
-
 NT_Subscriber LocalStorage::Subscribe(NT_Topic topicHandle, NT_Type type,
                                       std::string_view typeStr,
                                       const PubSubOptions& options) {
   std::scoped_lock lock{m_mutex};
 
   // Get the topic
-  auto* topic = m_impl->m_topics.Get(topicHandle);
+  auto* topic = m_impl.m_topics.Get(topicHandle);
   if (!topic) {
     return 0;
   }
 
   if (topic->localSubscribers.size() >= kMaxSubscribers) {
-    WPI_ERROR(m_impl->m_logger,
+    WPI_ERROR(m_impl.m_logger,
               "reached maximum number of subscribers to '{}', not subscribing",
               topic->name);
     return 0;
   }
 
   // Create subscriber
-  return m_impl->AddLocalSubscriber(topic, PubSubConfig{type, typeStr, options})
+  return m_impl.AddLocalSubscriber(topic, PubSubConfig{type, typeStr, options})
       ->handle;
 }
 
-void LocalStorage::Unsubscribe(NT_Subscriber subHandle) {
-  std::scoped_lock lock{m_mutex};
-  m_impl->RemoveSubEntry(subHandle);
-}
-
 NT_MultiSubscriber LocalStorage::SubscribeMultiple(
     std::span<const std::string_view> prefixes, const PubSubOptions& options) {
   std::scoped_lock lock{m_mutex};
 
-  if (m_impl->m_multiSubscribers.size() >= kMaxMultiSubscribers) {
-    WPI_ERROR(m_impl->m_logger,
+  if (m_impl.m_multiSubscribers.size() >= kMaxMultiSubscribers) {
+    WPI_ERROR(m_impl.m_logger,
               "reached maximum number of multi-subscribers, not subscribing");
     return 0;
   }
 
-  return m_impl->AddMultiSubscriber(prefixes, options)->handle;
-}
-
-void LocalStorage::UnsubscribeMultiple(NT_MultiSubscriber subHandle) {
-  std::scoped_lock lock{m_mutex};
-  m_impl->RemoveMultiSubscriber(subHandle);
+  return m_impl.AddMultiSubscriber(prefixes, options)->handle;
 }
 
 NT_Publisher LocalStorage::Publish(NT_Topic topicHandle, NT_Type type,
@@ -1694,31 +1241,31 @@
   std::scoped_lock lock{m_mutex};
 
   // Get the topic
-  auto* topic = m_impl->m_topics.Get(topicHandle);
+  auto* topic = m_impl.m_topics.Get(topicHandle);
   if (!topic) {
-    WPI_ERROR(m_impl->m_logger, "trying to publish invalid topic handle ({})",
+    WPI_ERROR(m_impl.m_logger, "trying to publish invalid topic handle ({})",
               topicHandle);
     return 0;
   }
 
   if (type == NT_UNASSIGNED || typeStr.empty()) {
     WPI_ERROR(
-        m_impl->m_logger,
+        m_impl.m_logger,
         "cannot publish '{}' with an unassigned type or empty type string",
         topic->name);
     return 0;
   }
 
   if (topic->localPublishers.size() >= kMaxPublishers) {
-    WPI_ERROR(m_impl->m_logger,
+    WPI_ERROR(m_impl.m_logger,
               "reached maximum number of publishers to '{}', not publishing",
               topic->name);
     return 0;
   }
 
   return m_impl
-      ->AddLocalPublisher(topic, properties,
-                          PubSubConfig{type, typeStr, options})
+      .AddLocalPublisher(topic, properties,
+                         PubSubConfig{type, typeStr, options})
       ->handle;
 }
 
@@ -1726,10 +1273,10 @@
   std::scoped_lock lock{m_mutex};
 
   if (Handle{pubentryHandle}.IsType(Handle::kPublisher)) {
-    m_impl->RemoveLocalPublisher(pubentryHandle);
-  } else if (auto entry = m_impl->m_entries.Get(pubentryHandle)) {
+    m_impl.RemoveLocalPublisher(pubentryHandle);
+  } else if (auto entry = m_impl.m_entries.Get(pubentryHandle)) {
     if (entry->publisher) {
-      m_impl->RemoveLocalPublisher(entry->publisher->handle);
+      m_impl.RemoveLocalPublisher(entry->publisher->handle);
       entry->publisher = nullptr;
     }
   } else {
@@ -1744,14 +1291,14 @@
   std::scoped_lock lock{m_mutex};
 
   // Get the topic
-  auto* topic = m_impl->m_topics.Get(topicHandle);
+  auto* topic = m_impl.m_topics.Get(topicHandle);
   if (!topic) {
     return 0;
   }
 
   if (topic->localSubscribers.size() >= kMaxSubscribers) {
     WPI_ERROR(
-        m_impl->m_logger,
+        m_impl.m_logger,
         "reached maximum number of subscribers to '{}', not creating entry",
         topic->name);
     return 0;
@@ -1759,15 +1306,10 @@
 
   // Create subscriber
   auto subscriber =
-      m_impl->AddLocalSubscriber(topic, PubSubConfig{type, typeStr, options});
+      m_impl.AddLocalSubscriber(topic, PubSubConfig{type, typeStr, options});
 
   // Create entry
-  return m_impl->AddEntry(subscriber)->handle;
-}
-
-void LocalStorage::ReleaseEntry(NT_Entry entryHandle) {
-  std::scoped_lock lock{m_mutex};
-  m_impl->RemoveSubEntry(entryHandle);
+  return m_impl.AddEntry(subscriber)->handle;
 }
 
 void LocalStorage::Release(NT_Handle pubsubentryHandle) {
@@ -1789,324 +1331,9 @@
   }
 }
 
-NT_Topic LocalStorage::GetTopicFromHandle(NT_Handle pubsubentryHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto topic = m_impl->GetTopic(pubsubentryHandle)) {
-    return topic->handle;
-  } else {
-    return {};
-  }
-}
-
-bool LocalStorage::SetEntryValue(NT_Handle pubentryHandle, const Value& value) {
-  std::scoped_lock lock{m_mutex};
-  return m_impl->SetEntryValue(pubentryHandle, value);
-}
-
-bool LocalStorage::SetDefaultEntryValue(NT_Handle pubsubentryHandle,
-                                        const Value& value) {
-  std::scoped_lock lock{m_mutex};
-  return m_impl->SetDefaultEntryValue(pubsubentryHandle, value);
-}
-
-TimestampedBoolean LocalStorage::GetAtomicBoolean(NT_Handle subentryHandle,
-                                                  bool defaultValue) {
-  std::scoped_lock lock{m_mutex};
-  Value* value = m_impl->GetSubEntryValue(subentryHandle);
-  if (value && value->type() == NT_BOOLEAN) {
-    return {value->time(), value->server_time(), value->GetBoolean()};
-  } else {
-    return {0, 0, defaultValue};
-  }
-}
-
-TimestampedString LocalStorage::GetAtomicString(NT_Handle subentryHandle,
-                                                std::string_view defaultValue) {
-  std::scoped_lock lock{m_mutex};
-  Value* value = m_impl->GetSubEntryValue(subentryHandle);
-  if (value && value->type() == NT_STRING) {
-    return {value->time(), value->server_time(),
-            std::string{value->GetString()}};
-  } else {
-    return {0, 0, std::string{defaultValue}};
-  }
-}
-
-TimestampedStringView LocalStorage::GetAtomicString(
-    NT_Handle subentryHandle, wpi::SmallVectorImpl<char>& buf,
-    std::string_view defaultValue) {
-  std::scoped_lock lock{m_mutex};
-  Value* value = m_impl->GetSubEntryValue(subentryHandle);
-  if (value && value->type() == NT_STRING) {
-    auto str = value->GetString();
-    buf.assign(str.begin(), str.end());
-    return {value->time(), value->server_time(), {buf.data(), buf.size()}};
-  } else {
-    return {0, 0, defaultValue};
-  }
-}
-
-template <typename T, typename U>
-static T GetAtomicNumber(Value* value, U defaultValue) {
-  if (value && value->type() == NT_INTEGER) {
-    return {value->time(), value->server_time(),
-            static_cast<U>(value->GetInteger())};
-  } else if (value && value->type() == NT_FLOAT) {
-    return {value->time(), value->server_time(),
-            static_cast<U>(value->GetFloat())};
-  } else if (value && value->type() == NT_DOUBLE) {
-    return {value->time(), value->server_time(),
-            static_cast<U>(value->GetDouble())};
-  } else {
-    return {0, 0, defaultValue};
-  }
-}
-
-template <typename T, typename U>
-static T GetAtomicNumberArray(Value* value, std::span<const U> defaultValue) {
-  if (value && value->type() == NT_INTEGER_ARRAY) {
-    auto arr = value->GetIntegerArray();
-    return {value->time(), value->server_time(), {arr.begin(), arr.end()}};
-  } else if (value && value->type() == NT_FLOAT_ARRAY) {
-    auto arr = value->GetFloatArray();
-    return {value->time(), value->server_time(), {arr.begin(), arr.end()}};
-  } else if (value && value->type() == NT_DOUBLE_ARRAY) {
-    auto arr = value->GetDoubleArray();
-    return {value->time(), value->server_time(), {arr.begin(), arr.end()}};
-  } else {
-    return {0, 0, {defaultValue.begin(), defaultValue.end()}};
-  }
-}
-
-template <typename T, typename U>
-static T GetAtomicNumberArray(Value* value, wpi::SmallVectorImpl<U>& buf,
-                              std::span<const U> defaultValue) {
-  if (value && value->type() == NT_INTEGER_ARRAY) {
-    auto str = value->GetIntegerArray();
-    buf.assign(str.begin(), str.end());
-    return {value->time(), value->server_time(), {buf.data(), buf.size()}};
-  } else if (value && value->type() == NT_FLOAT_ARRAY) {
-    auto str = value->GetFloatArray();
-    buf.assign(str.begin(), str.end());
-    return {value->time(), value->server_time(), {buf.data(), buf.size()}};
-  } else if (value && value->type() == NT_DOUBLE_ARRAY) {
-    auto str = value->GetDoubleArray();
-    buf.assign(str.begin(), str.end());
-    return {value->time(), value->server_time(), {buf.data(), buf.size()}};
-  } else {
-    buf.assign(defaultValue.begin(), defaultValue.end());
-    return {0, 0, {buf.data(), buf.size()}};
-  }
-}
-
-#define GET_ATOMIC_NUMBER(Name, dtype)                                  \
-  Timestamped##Name LocalStorage::GetAtomic##Name(NT_Handle subentry,   \
-                                                  dtype defaultValue) { \
-    std::scoped_lock lock{m_mutex};                                     \
-    return GetAtomicNumber<Timestamped##Name>(                          \
-        m_impl->GetSubEntryValue(subentry), defaultValue);              \
-  }                                                                     \
-                                                                        \
-  Timestamped##Name##Array LocalStorage::GetAtomic##Name##Array(        \
-      NT_Handle subentry, std::span<const dtype> defaultValue) {        \
-    std::scoped_lock lock{m_mutex};                                     \
-    return GetAtomicNumberArray<Timestamped##Name##Array>(              \
-        m_impl->GetSubEntryValue(subentry), defaultValue);              \
-  }                                                                     \
-                                                                        \
-  Timestamped##Name##ArrayView LocalStorage::GetAtomic##Name##Array(    \
-      NT_Handle subentry, wpi::SmallVectorImpl<dtype>& buf,             \
-      std::span<const dtype> defaultValue) {                            \
-    std::scoped_lock lock{m_mutex};                                     \
-    return GetAtomicNumberArray<Timestamped##Name##ArrayView>(          \
-        m_impl->GetSubEntryValue(subentry), buf, defaultValue);         \
-  }
-
-GET_ATOMIC_NUMBER(Integer, int64_t)
-GET_ATOMIC_NUMBER(Float, float)
-GET_ATOMIC_NUMBER(Double, double)
-
-#define GET_ATOMIC_ARRAY(Name, dtype)                                         \
-  Timestamped##Name LocalStorage::GetAtomic##Name(                            \
-      NT_Handle subentry, std::span<const dtype> defaultValue) {              \
-    std::scoped_lock lock{m_mutex};                                           \
-    Value* value = m_impl->GetSubEntryValue(subentry);                        \
-    if (value && value->Is##Name()) {                                         \
-      auto arr = value->Get##Name();                                          \
-      return {value->time(), value->server_time(), {arr.begin(), arr.end()}}; \
-    } else {                                                                  \
-      return {0, 0, {defaultValue.begin(), defaultValue.end()}};              \
-    }                                                                         \
-  }
-
-GET_ATOMIC_ARRAY(Raw, uint8_t)
-GET_ATOMIC_ARRAY(BooleanArray, int)
-GET_ATOMIC_ARRAY(StringArray, std::string)
-
-#define GET_ATOMIC_SMALL_ARRAY(Name, dtype)                                   \
-  Timestamped##Name##View LocalStorage::GetAtomic##Name(                      \
-      NT_Handle subentry, wpi::SmallVectorImpl<dtype>& buf,                   \
-      std::span<const dtype> defaultValue) {                                  \
-    std::scoped_lock lock{m_mutex};                                           \
-    Value* value = m_impl->GetSubEntryValue(subentry);                        \
-    if (value && value->Is##Name()) {                                         \
-      auto str = value->Get##Name();                                          \
-      buf.assign(str.begin(), str.end());                                     \
-      return {value->time(), value->server_time(), {buf.data(), buf.size()}}; \
-    } else {                                                                  \
-      buf.assign(defaultValue.begin(), defaultValue.end());                   \
-      return {0, 0, {buf.data(), buf.size()}};                                \
-    }                                                                         \
-  }
-
-GET_ATOMIC_SMALL_ARRAY(Raw, uint8_t)
-GET_ATOMIC_SMALL_ARRAY(BooleanArray, int)
-
-std::vector<Value> LocalStorage::ReadQueueValue(NT_Handle subentry) {
-  std::scoped_lock lock{m_mutex};
-  auto subscriber = m_impl->GetSubEntry(subentry);
-  if (!subscriber) {
-    return {};
-  }
-  std::vector<Value> rv;
-  rv.reserve(subscriber->pollStorage.size());
-  for (auto&& val : subscriber->pollStorage) {
-    rv.emplace_back(std::move(val));
-  }
-  subscriber->pollStorage.reset();
-  return rv;
-}
-
-std::vector<TimestampedBoolean> LocalStorage::ReadQueueBoolean(
-    NT_Handle subentry) {
-  std::scoped_lock lock{m_mutex};
-  auto subscriber = m_impl->GetSubEntry(subentry);
-  if (!subscriber) {
-    return {};
-  }
-  std::vector<TimestampedBoolean> rv;
-  rv.reserve(subscriber->pollStorage.size());
-  for (auto&& val : subscriber->pollStorage) {
-    if (val.IsBoolean()) {
-      rv.emplace_back(val.time(), val.server_time(), val.GetBoolean());
-    }
-  }
-  subscriber->pollStorage.reset();
-  return rv;
-}
-
-std::vector<TimestampedString> LocalStorage::ReadQueueString(
-    NT_Handle subentry) {
-  std::scoped_lock lock{m_mutex};
-  auto subscriber = m_impl->GetSubEntry(subentry);
-  if (!subscriber) {
-    return {};
-  }
-  std::vector<TimestampedString> rv;
-  rv.reserve(subscriber->pollStorage.size());
-  for (auto&& val : subscriber->pollStorage) {
-    if (val.IsString()) {
-      rv.emplace_back(val.time(), val.server_time(),
-                      std::string{val.GetString()});
-    }
-  }
-  subscriber->pollStorage.reset();
-  return rv;
-}
-
-#define READ_QUEUE_ARRAY(Name)                                         \
-  std::vector<Timestamped##Name> LocalStorage::ReadQueue##Name(        \
-      NT_Handle subentry) {                                            \
-    std::scoped_lock lock{m_mutex};                                    \
-    auto subscriber = m_impl->GetSubEntry(subentry);                   \
-    if (!subscriber) {                                                 \
-      return {};                                                       \
-    }                                                                  \
-    std::vector<Timestamped##Name> rv;                                 \
-    rv.reserve(subscriber->pollStorage.size());                        \
-    for (auto&& val : subscriber->pollStorage) {                       \
-      if (val.Is##Name()) {                                            \
-        auto arr = val.Get##Name();                                    \
-        rv.emplace_back(Timestamped##Name{                             \
-            val.time(), val.server_time(), {arr.begin(), arr.end()}}); \
-      }                                                                \
-    }                                                                  \
-    subscriber->pollStorage.reset();                                   \
-    return rv;                                                         \
-  }
-
-READ_QUEUE_ARRAY(Raw)
-READ_QUEUE_ARRAY(BooleanArray)
-READ_QUEUE_ARRAY(StringArray)
-
-template <typename T>
-static std::vector<T> ReadQueueNumber(SubscriberData* subscriber) {
-  if (!subscriber) {
-    return {};
-  }
-  std::vector<T> rv;
-  rv.reserve(subscriber->pollStorage.size());
-  for (auto&& val : subscriber->pollStorage) {
-    auto ts = val.time();
-    auto sts = val.server_time();
-    if (val.IsInteger()) {
-      rv.emplace_back(T(ts, sts, val.GetInteger()));
-    } else if (val.IsFloat()) {
-      rv.emplace_back(T(ts, sts, val.GetFloat()));
-    } else if (val.IsDouble()) {
-      rv.emplace_back(T(ts, sts, val.GetDouble()));
-    }
-  }
-  subscriber->pollStorage.reset();
-  return rv;
-}
-
-template <typename T>
-static std::vector<T> ReadQueueNumberArray(SubscriberData* subscriber) {
-  if (!subscriber) {
-    return {};
-  }
-  std::vector<T> rv;
-  rv.reserve(subscriber->pollStorage.size());
-  for (auto&& val : subscriber->pollStorage) {
-    auto ts = val.time();
-    auto sts = val.server_time();
-    if (val.IsIntegerArray()) {
-      auto arr = val.GetIntegerArray();
-      rv.emplace_back(T{ts, sts, {arr.begin(), arr.end()}});
-    } else if (val.IsFloatArray()) {
-      auto arr = val.GetFloatArray();
-      rv.emplace_back(T{ts, sts, {arr.begin(), arr.end()}});
-    } else if (val.IsDoubleArray()) {
-      auto arr = val.GetDoubleArray();
-      rv.emplace_back(T{ts, sts, {arr.begin(), arr.end()}});
-    }
-  }
-  subscriber->pollStorage.reset();
-  return rv;
-}
-
-#define READ_QUEUE_NUMBER(Name)                                               \
-  std::vector<Timestamped##Name> LocalStorage::ReadQueue##Name(               \
-      NT_Handle subentry) {                                                   \
-    std::scoped_lock lock{m_mutex};                                           \
-    return ReadQueueNumber<Timestamped##Name>(m_impl->GetSubEntry(subentry)); \
-  }                                                                           \
-                                                                              \
-  std::vector<Timestamped##Name##Array> LocalStorage::ReadQueue##Name##Array( \
-      NT_Handle subentry) {                                                   \
-    std::scoped_lock lock{m_mutex};                                           \
-    return ReadQueueNumberArray<Timestamped##Name##Array>(                    \
-        m_impl->GetSubEntry(subentry));                                       \
-  }
-
-READ_QUEUE_NUMBER(Integer)
-READ_QUEUE_NUMBER(Float)
-READ_QUEUE_NUMBER(Double)
-
 Value LocalStorage::GetEntryValue(NT_Handle subentryHandle) {
   std::scoped_lock lock{m_mutex};
-  if (auto subscriber = m_impl->GetSubEntry(subentryHandle)) {
+  if (auto subscriber = m_impl.GetSubEntry(subentryHandle)) {
     if (subscriber->config.type == NT_UNASSIGNED ||
         !subscriber->topic->lastValue ||
         subscriber->config.type == subscriber->topic->lastValue.type()) {
@@ -2120,22 +1347,6 @@
   return {};
 }
 
-void LocalStorage::SetEntryFlags(NT_Entry entryHandle, unsigned int flags) {
-  std::scoped_lock lock{m_mutex};
-  if (auto entry = m_impl->m_entries.Get(entryHandle)) {
-    m_impl->SetFlags(entry->subscriber->topic, flags);
-  }
-}
-
-unsigned int LocalStorage::GetEntryFlags(NT_Entry entryHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto entry = m_impl->m_entries.Get(entryHandle)) {
-    return entry->subscriber->topic->flags;
-  } else {
-    return 0;
-  }
-}
-
 NT_Entry LocalStorage::GetEntry(std::string_view name) {
   if (name.empty()) {
     return {};
@@ -2144,72 +1355,87 @@
   std::scoped_lock lock{m_mutex};
 
   // Get the topic data
-  auto* topic = m_impl->GetOrCreateTopic(name);
+  auto* topic = m_impl.GetOrCreateTopic(name);
 
   if (topic->entry == 0) {
     if (topic->localSubscribers.size() >= kMaxSubscribers) {
       WPI_ERROR(
-          m_impl->m_logger,
+          m_impl.m_logger,
           "reached maximum number of subscribers to '{}', not creating entry",
           topic->name);
       return 0;
     }
 
     // Create subscriber
-    auto* subscriber = m_impl->AddLocalSubscriber(topic, {});
+    auto* subscriber = m_impl.AddLocalSubscriber(topic, {});
 
     // Create entry
-    topic->entry = m_impl->AddEntry(subscriber)->handle;
+    topic->entry = m_impl.AddEntry(subscriber)->handle;
   }
 
   return topic->entry;
 }
 
-std::string LocalStorage::GetEntryName(NT_Handle subentryHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto subscriber = m_impl->GetSubEntry(subentryHandle)) {
-    return subscriber->topic->name;
-  } else {
-    return {};
-  }
-}
-
-NT_Type LocalStorage::GetEntryType(NT_Handle subentryHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto subscriber = m_impl->GetSubEntry(subentryHandle)) {
-    return subscriber->topic->type;
-  } else {
-    return {};
-  }
-}
-
-int64_t LocalStorage::GetEntryLastChange(NT_Handle subentryHandle) {
-  std::scoped_lock lock{m_mutex};
-  if (auto subscriber = m_impl->GetSubEntry(subentryHandle)) {
-    return subscriber->topic->lastValue.time();
-  } else {
-    return 0;
-  }
-}
-
-void LocalStorage::AddListener(NT_Listener listener,
+void LocalStorage::AddListener(NT_Listener listenerHandle,
                                std::span<const std::string_view> prefixes,
                                unsigned int mask) {
   mask &= (NT_EVENT_TOPIC | NT_EVENT_VALUE_ALL | NT_EVENT_IMMEDIATE);
   std::scoped_lock lock{m_mutex};
-  m_impl->AddListener(listener, prefixes, mask);
+  if (m_impl.m_multiSubscribers.size() >= kMaxMultiSubscribers) {
+    WPI_ERROR(
+        m_impl.m_logger,
+        "reached maximum number of multi-subscribers, not adding listener");
+    return;
+  }
+  // subscribe to make sure topic updates are received
+  auto sub = m_impl.AddMultiSubscriber(
+      prefixes, {.topicsOnly = (mask & NT_EVENT_VALUE_ALL) == 0});
+  m_impl.AddListenerImpl(listenerHandle, sub, mask, true);
 }
 
-void LocalStorage::AddListener(NT_Listener listener, NT_Handle handle,
+void LocalStorage::AddListener(NT_Listener listenerHandle, NT_Handle handle,
                                unsigned int mask) {
   mask &= (NT_EVENT_TOPIC | NT_EVENT_VALUE_ALL | NT_EVENT_IMMEDIATE);
   std::scoped_lock lock{m_mutex};
-  m_impl->AddListener(listener, handle, mask);
+  if (auto topic = m_impl.m_topics.Get(handle)) {
+    m_impl.AddListenerImpl(listenerHandle, topic, mask);
+  } else if (auto sub = m_impl.m_multiSubscribers.Get(handle)) {
+    m_impl.AddListenerImpl(listenerHandle, sub, mask, false);
+  } else if (auto sub = m_impl.m_subscribers.Get(handle)) {
+    m_impl.AddListenerImpl(listenerHandle, sub, mask, sub->handle, false);
+  } else if (auto entry = m_impl.m_entries.Get(handle)) {
+    m_impl.AddListenerImpl(listenerHandle, entry->subscriber, mask,
+                           entry->handle, false);
+  }
 }
 
-void LocalStorage::RemoveListener(NT_Listener listener, unsigned int mask) {
+void LocalStorage::RemoveListener(NT_Listener listenerHandle,
+                                  unsigned int mask) {
   std::scoped_lock lock{m_mutex};
-  m_impl->RemoveListener(listener, mask);
+  auto listenerIt = m_impl.m_listeners.find(listenerHandle);
+  if (listenerIt == m_impl.m_listeners.end()) {
+    return;
+  }
+  auto listener = std::move(listenerIt->getSecond());
+  m_impl.m_listeners.erase(listenerIt);
+  if (!listener) {
+    return;
+  }
+
+  m_impl.m_topicPrefixListeners.Remove(listener.get());
+  if (listener->subscriber) {
+    listener->subscriber->valueListeners.Remove(listenerHandle);
+    listener->subscriber->topic->listeners.Remove(listenerHandle);
+    if (listener->subscriberOwned) {
+      m_impl.RemoveLocalSubscriber(listener->subscriber->handle);
+    }
+  }
+  if (listener->multiSubscriber) {
+    listener->multiSubscriber->valueListeners.Remove(listenerHandle);
+    if (listener->subscriberOwned) {
+      m_impl.RemoveMultiSubscriber(listener->multiSubscriber->handle);
+    }
+  }
 }
 
 NT_DataLogger LocalStorage::StartDataLog(wpi::log::DataLog& log,
@@ -2217,24 +1443,23 @@
                                          std::string_view logPrefix) {
   std::scoped_lock lock{m_mutex};
   auto datalogger =
-      m_impl->m_dataloggers.Add(m_impl->m_inst, log, prefix, logPrefix);
+      m_impl.m_dataloggers.Add(m_impl.m_inst, log, prefix, logPrefix);
 
   // start logging any matching topics
   auto now = nt::Now();
-  for (auto&& topic : m_impl->m_topics) {
+  for (auto&& topic : m_impl.m_topics) {
     if (!wpi::starts_with(topic->name, prefix) ||
         topic->type == NT_UNASSIGNED || topic->typeStr.empty()) {
       continue;
     }
     topic->datalogs.emplace_back(log, datalogger->Start(topic.get(), now),
                                  datalogger->handle);
+    topic->datalogType = topic->type;
 
     // log current value, if any
-    if (!topic->lastValue) {
-      continue;
+    if (topic->lastValue) {
+      topic->datalogs.back().Append(topic->lastValue);
     }
-    topic->datalogType = topic->type;
-    topic->datalogs.back().Append(topic->lastValue);
   }
 
   return datalogger->handle;
@@ -2242,10 +1467,10 @@
 
 void LocalStorage::StopDataLog(NT_DataLogger logger) {
   std::scoped_lock lock{m_mutex};
-  if (auto datalogger = m_impl->m_dataloggers.Remove(logger)) {
+  if (auto datalogger = m_impl.m_dataloggers.Remove(logger)) {
     // finish any active entries
     auto now = Now();
-    for (auto&& topic : m_impl->m_topics) {
+    for (auto&& topic : m_impl.m_topics) {
       auto it =
           std::find_if(topic->datalogs.begin(), topic->datalogs.end(),
                        [&](const auto& elem) { return elem.logger == logger; });
@@ -2257,8 +1482,51 @@
   }
 }
 
+bool LocalStorage::HasSchema(std::string_view name) {
+  std::scoped_lock lock{m_mutex};
+  wpi::SmallString<128> fullName{"/.schema/"};
+  fullName += name;
+  auto it = m_impl.m_schemas.find(fullName);
+  return it != m_impl.m_schemas.end();
+}
+
+void LocalStorage::AddSchema(std::string_view name, std::string_view type,
+                             std::span<const uint8_t> schema) {
+  std::scoped_lock lock{m_mutex};
+  wpi::SmallString<128> fullName{"/.schema/"};
+  fullName += name;
+  auto& pubHandle = m_impl.m_schemas[fullName];
+  if (pubHandle != 0) {
+    return;
+  }
+
+  auto topic = m_impl.GetOrCreateTopic(fullName);
+
+  if (topic->localPublishers.size() >= kMaxPublishers) {
+    WPI_ERROR(m_impl.m_logger,
+              "reached maximum number of publishers to '{}', not publishing",
+              topic->name);
+    return;
+  }
+
+  pubHandle = m_impl
+                  .AddLocalPublisher(topic, {{"retained", true}},
+                                     PubSubConfig{NT_RAW, type, {}})
+                  ->handle;
+
+  m_impl.SetDefaultEntryValue(pubHandle, Value::MakeRaw(schema));
+}
+
 void LocalStorage::Reset() {
   std::scoped_lock lock{m_mutex};
-  m_impl = std::make_unique<Impl>(m_impl->m_inst, m_impl->m_listenerStorage,
-                                  m_impl->m_logger);
+  m_impl.m_network = nullptr;
+  m_impl.m_topics.clear();
+  m_impl.m_publishers.clear();
+  m_impl.m_subscribers.clear();
+  m_impl.m_entries.clear();
+  m_impl.m_multiSubscribers.clear();
+  m_impl.m_dataloggers.clear();
+  m_impl.m_nameTopics.clear();
+  m_impl.m_listeners.clear();
+  m_impl.m_topicPrefixListeners.clear();
 }
diff --git a/ntcore/src/main/native/cpp/LocalStorage.h b/ntcore/src/main/native/cpp/LocalStorage.h
index a93adb0..af2b4de 100644
--- a/ntcore/src/main/native/cpp/LocalStorage.h
+++ b/ntcore/src/main/native/cpp/LocalStorage.h
@@ -14,8 +14,18 @@
 #include <utility>
 #include <vector>
 
+#include <wpi/DenseMap.h>
+#include <wpi/StringMap.h>
+#include <wpi/Synchronization.h>
+#include <wpi/json.h>
 #include <wpi/mutex.h>
 
+#include "Handle.h"
+#include "HandleMap.h"
+#include "PubSubOptions.h"
+#include "Types_internal.h"
+#include "ValueCircularBuffer.h"
+#include "VectorSet.h"
 #include "net/NetworkInterface.h"
 #include "ntcore_cpp.h"
 
@@ -29,8 +39,8 @@
 
 class LocalStorage final : public net::ILocalStorage {
  public:
-  LocalStorage(int inst, IListenerStorage& listenerStorage,
-               wpi::Logger& logger);
+  LocalStorage(int inst, IListenerStorage& listenerStorage, wpi::Logger& logger)
+      : m_impl{inst, listenerStorage, logger} {}
   LocalStorage(const LocalStorage&) = delete;
   LocalStorage& operator=(const LocalStorage&) = delete;
   ~LocalStorage() final;
@@ -59,47 +69,129 @@
   std::vector<TopicInfo> GetTopicInfo(std::string_view prefix,
                                       std::span<const std::string_view> types);
 
-  NT_Topic GetTopic(std::string_view name);
+  NT_Topic GetTopic(std::string_view name) {
+    if (name.empty()) {
+      return {};
+    }
+    std::scoped_lock lock{m_mutex};
+    return m_impl.GetOrCreateTopic(name)->handle;
+  }
 
-  std::string GetTopicName(NT_Topic topic);
+  std::string GetTopicName(NT_Topic topicHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      return topic->name;
+    } else {
+      return {};
+    }
+  }
 
-  NT_Type GetTopicType(NT_Topic topic);
+  NT_Type GetTopicType(NT_Topic topicHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      return topic->type;
+    } else {
+      return {};
+    }
+  }
 
-  std::string GetTopicTypeString(NT_Topic topic);
+  std::string GetTopicTypeString(NT_Topic topicHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      return topic->typeStr;
+    } else {
+      return {};
+    }
+  }
 
-  void SetTopicPersistent(NT_Topic topic, bool value);
+  void SetTopicPersistent(NT_Topic topicHandle, bool value) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      m_impl.SetPersistent(topic, value);
+    }
+  }
 
-  bool GetTopicPersistent(NT_Topic topic);
+  bool GetTopicPersistent(NT_Topic topicHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      return (topic->flags & NT_PERSISTENT) != 0;
+    } else {
+      return false;
+    }
+  }
 
-  void SetTopicRetained(NT_Topic topic, bool value);
+  void SetTopicRetained(NT_Topic topicHandle, bool value) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      m_impl.SetRetained(topic, value);
+    }
+  }
 
-  bool GetTopicRetained(NT_Topic topic);
+  bool GetTopicRetained(NT_Topic topicHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      return (topic->flags & NT_RETAINED) != 0;
+    } else {
+      return false;
+    }
+  }
 
-  bool GetTopicExists(NT_Handle handle);
+  bool GetTopicExists(NT_Handle handle) {
+    std::scoped_lock lock{m_mutex};
+    TopicData* topic = m_impl.GetTopic(handle);
+    return topic && topic->Exists();
+  }
 
-  wpi::json GetTopicProperty(NT_Topic topic, std::string_view name);
+  wpi::json GetTopicProperty(NT_Topic topicHandle, std::string_view name) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      return topic->properties.value(name, wpi::json{});
+    } else {
+      return {};
+    }
+  }
 
   void SetTopicProperty(NT_Topic topic, std::string_view name,
                         const wpi::json& value);
 
   void DeleteTopicProperty(NT_Topic topic, std::string_view name);
 
-  wpi::json GetTopicProperties(NT_Topic topic);
+  wpi::json GetTopicProperties(NT_Topic topicHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      return topic->properties;
+    } else {
+      return wpi::json::object();
+    }
+  }
 
   bool SetTopicProperties(NT_Topic topic, const wpi::json& update);
 
-  TopicInfo GetTopicInfo(NT_Topic topic);
+  TopicInfo GetTopicInfo(NT_Topic topicHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.m_topics.Get(topicHandle)) {
+      return topic->GetTopicInfo();
+    } else {
+      return {};
+    }
+  }
 
   NT_Subscriber Subscribe(NT_Topic topic, NT_Type type,
                           std::string_view typeStr,
                           const PubSubOptions& options);
 
-  void Unsubscribe(NT_Subscriber sub);
+  void Unsubscribe(NT_Subscriber subHandle) {
+    std::scoped_lock lock{m_mutex};
+    m_impl.RemoveSubEntry(subHandle);
+  }
 
   NT_MultiSubscriber SubscribeMultiple(
       std::span<const std::string_view> prefixes, const PubSubOptions& options);
 
-  void UnsubscribeMultiple(NT_MultiSubscriber subHandle);
+  void UnsubscribeMultiple(NT_MultiSubscriber subHandle) {
+    std::scoped_lock lock{m_mutex};
+    m_impl.RemoveMultiSubscriber(subHandle);
+  }
 
   NT_Publisher Publish(NT_Topic topic, NT_Type type, std::string_view typeStr,
                        const wpi::json& properties,
@@ -110,84 +202,106 @@
   NT_Entry GetEntry(NT_Topic topic, NT_Type type, std::string_view typeStr,
                     const PubSubOptions& options);
 
-  void ReleaseEntry(NT_Entry entry);
+  void ReleaseEntry(NT_Entry entryHandle) {
+    std::scoped_lock lock{m_mutex};
+    m_impl.RemoveSubEntry(entryHandle);
+  }
 
   void Release(NT_Handle pubsubentry);
 
-  NT_Topic GetTopicFromHandle(NT_Handle pubsubentry);
+  NT_Topic GetTopicFromHandle(NT_Handle pubsubentryHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto topic = m_impl.GetTopic(pubsubentryHandle)) {
+      return topic->handle;
+    } else {
+      return {};
+    }
+  }
 
-  bool SetEntryValue(NT_Handle pubentry, const Value& value);
+  bool SetEntryValue(NT_Handle pubentryHandle, const Value& value) {
+    std::scoped_lock lock{m_mutex};
+    return m_impl.SetEntryValue(pubentryHandle, value);
+  }
 
-  bool SetDefaultEntryValue(NT_Handle pubsubentry, const Value& value);
+  bool SetDefaultEntryValue(NT_Handle pubsubentryHandle, const Value& value) {
+    std::scoped_lock lock{m_mutex};
+    return m_impl.SetDefaultEntryValue(pubsubentryHandle, value);
+  }
 
-  TimestampedBoolean GetAtomicBoolean(NT_Handle subentry, bool defaultValue);
-  TimestampedInteger GetAtomicInteger(NT_Handle subentry, int64_t defaultValue);
-  TimestampedFloat GetAtomicFloat(NT_Handle subentry, float defaultValue);
-  TimestampedDouble GetAtomicDouble(NT_Handle subentry, double defaultValue);
-  TimestampedString GetAtomicString(NT_Handle subentry,
-                                    std::string_view defaultValue);
-  TimestampedRaw GetAtomicRaw(NT_Handle subentry,
-                              std::span<const uint8_t> defaultValue);
-  TimestampedBooleanArray GetAtomicBooleanArray(
-      NT_Handle subentry, std::span<const int> defaultValue);
-  TimestampedIntegerArray GetAtomicIntegerArray(
-      NT_Handle subentry, std::span<const int64_t> defaultValue);
-  TimestampedFloatArray GetAtomicFloatArray(
-      NT_Handle subentry, std::span<const float> defaultValue);
-  TimestampedDoubleArray GetAtomicDoubleArray(
-      NT_Handle subentry, std::span<const double> defaultValue);
-  TimestampedStringArray GetAtomicStringArray(
-      NT_Handle subentry, std::span<const std::string> defaultValue);
+  template <ValidType T>
+  Timestamped<typename TypeInfo<T>::Value> GetAtomic(
+      NT_Handle subentry, typename TypeInfo<T>::View defaultValue);
 
-  TimestampedStringView GetAtomicString(NT_Handle subentry,
-                                        wpi::SmallVectorImpl<char>& buf,
-                                        std::string_view defaultValue);
-  TimestampedRawView GetAtomicRaw(NT_Handle subentry,
-                                  wpi::SmallVectorImpl<uint8_t>& buf,
-                                  std::span<const uint8_t> defaultValue);
-  TimestampedBooleanArrayView GetAtomicBooleanArray(
-      NT_Handle subentry, wpi::SmallVectorImpl<int>& buf,
-      std::span<const int> defaultValue);
-  TimestampedIntegerArrayView GetAtomicIntegerArray(
-      NT_Handle subentry, wpi::SmallVectorImpl<int64_t>& buf,
-      std::span<const int64_t> defaultValue);
-  TimestampedFloatArrayView GetAtomicFloatArray(
-      NT_Handle subentry, wpi::SmallVectorImpl<float>& buf,
-      std::span<const float> defaultValue);
-  TimestampedDoubleArrayView GetAtomicDoubleArray(
-      NT_Handle subentry, wpi::SmallVectorImpl<double>& buf,
-      std::span<const double> defaultValue);
+  template <SmallArrayType T>
+  Timestamped<typename TypeInfo<T>::SmallRet> GetAtomic(
+      NT_Handle subentry,
+      wpi::SmallVectorImpl<typename TypeInfo<T>::SmallElem>& buf,
+      typename TypeInfo<T>::View defaultValue);
 
-  std::vector<Value> ReadQueueValue(NT_Handle subentry);
+  std::vector<Value> ReadQueueValue(NT_Handle subentry) {
+    std::scoped_lock lock{m_mutex};
+    auto subscriber = m_impl.GetSubEntry(subentry);
+    if (!subscriber) {
+      return {};
+    }
+    return subscriber->pollStorage.ReadValue();
+  }
 
-  std::vector<TimestampedBoolean> ReadQueueBoolean(NT_Handle subentry);
-  std::vector<TimestampedInteger> ReadQueueInteger(NT_Handle subentry);
-  std::vector<TimestampedFloat> ReadQueueFloat(NT_Handle subentry);
-  std::vector<TimestampedDouble> ReadQueueDouble(NT_Handle subentry);
-  std::vector<TimestampedString> ReadQueueString(NT_Handle subentry);
-  std::vector<TimestampedRaw> ReadQueueRaw(NT_Handle subentry);
-  std::vector<TimestampedBooleanArray> ReadQueueBooleanArray(
+  template <ValidType T>
+  std::vector<Timestamped<typename TypeInfo<T>::Value>> ReadQueue(
       NT_Handle subentry);
-  std::vector<TimestampedIntegerArray> ReadQueueIntegerArray(
-      NT_Handle subentry);
-  std::vector<TimestampedFloatArray> ReadQueueFloatArray(NT_Handle subentry);
-  std::vector<TimestampedDoubleArray> ReadQueueDoubleArray(NT_Handle subentry);
-  std::vector<TimestampedStringArray> ReadQueueStringArray(NT_Handle subentry);
 
   //
   // Backwards compatible user functions
   //
 
   Value GetEntryValue(NT_Handle subentry);
-  void SetEntryFlags(NT_Entry entry, unsigned int flags);
-  unsigned int GetEntryFlags(NT_Entry entry);
+
+  void SetEntryFlags(NT_Entry entryHandle, unsigned int flags) {
+    std::scoped_lock lock{m_mutex};
+    if (auto entry = m_impl.m_entries.Get(entryHandle)) {
+      m_impl.SetFlags(entry->subscriber->topic, flags);
+    }
+  }
+
+  unsigned int GetEntryFlags(NT_Entry entryHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto entry = m_impl.m_entries.Get(entryHandle)) {
+      return entry->subscriber->topic->flags;
+    } else {
+      return 0;
+    }
+  }
 
   // Index-only
   NT_Entry GetEntry(std::string_view name);
 
-  std::string GetEntryName(NT_Entry entry);
-  NT_Type GetEntryType(NT_Entry entry);
-  int64_t GetEntryLastChange(NT_Entry entry);
+  std::string GetEntryName(NT_Entry subentryHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto subscriber = m_impl.GetSubEntry(subentryHandle)) {
+      return subscriber->topic->name;
+    } else {
+      return {};
+    }
+  }
+
+  NT_Type GetEntryType(NT_Entry subentryHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto subscriber = m_impl.GetSubEntry(subentryHandle)) {
+      return subscriber->topic->type;
+    } else {
+      return {};
+    }
+  }
+
+  int64_t GetEntryLastChange(NT_Entry subentryHandle) {
+    std::scoped_lock lock{m_mutex};
+    if (auto subscriber = m_impl.GetSubEntry(subentryHandle)) {
+      return subscriber->topic->lastValue.time();
+    } else {
+      return 0;
+    }
+  }
 
   //
   // Listener functions
@@ -207,13 +321,365 @@
                              std::string_view logPrefix);
   void StopDataLog(NT_DataLogger logger);
 
+  //
+  // Schema functions
+  //
+  bool HasSchema(std::string_view name);
+  void AddSchema(std::string_view name, std::string_view type,
+                 std::span<const uint8_t> schema);
+
   void Reset();
 
  private:
-  class Impl;
-  std::unique_ptr<Impl> m_impl;
+  static constexpr bool IsSpecial(std::string_view name) {
+    return name.empty() ? false : name.front() == '$';
+  }
+
+  struct EntryData;
+  struct PublisherData;
+  struct SubscriberData;
+  struct MultiSubscriberData;
+
+  struct DataLoggerEntry {
+    DataLoggerEntry(wpi::log::DataLog& log, int entry, NT_DataLogger logger)
+        : log{&log}, entry{entry}, logger{logger} {}
+
+    static std::string MakeMetadata(std::string_view properties);
+
+    void Append(const Value& v);
+
+    wpi::log::DataLog* log;
+    int entry;
+    NT_DataLogger logger;
+  };
+
+  struct TopicData {
+    static constexpr auto kType = Handle::kTopic;
+
+    TopicData(NT_Topic handle, std::string_view name)
+        : handle{handle}, name{name}, special{IsSpecial(name)} {}
+
+    bool Exists() const { return onNetwork || !localPublishers.empty(); }
+
+    TopicInfo GetTopicInfo() const;
+
+    // invariants
+    wpi::SignalObject<NT_Topic> handle;
+    std::string name;
+    bool special;
+
+    Value lastValue;  // also stores timestamp
+    Value lastValueNetwork;
+    NT_Type type{NT_UNASSIGNED};
+    std::string typeStr;
+    unsigned int flags{0};            // for NT3 APIs
+    std::string propertiesStr{"{}"};  // cached string for GetTopicInfo() et al
+    wpi::json properties = wpi::json::object();
+    NT_Entry entry{0};  // cached entry for GetEntry()
+
+    bool onNetwork{false};  // true if there are any remote publishers
+    bool lastValueFromNetwork{false};
+
+    wpi::SmallVector<DataLoggerEntry, 1> datalogs;
+    NT_Type datalogType{NT_UNASSIGNED};
+
+    VectorSet<PublisherData*> localPublishers;
+    VectorSet<SubscriberData*> localSubscribers;
+    VectorSet<MultiSubscriberData*> multiSubscribers;
+    VectorSet<EntryData*> entries;
+    VectorSet<NT_Listener> listeners;
+  };
+
+  struct PubSubConfig : public PubSubOptionsImpl {
+    PubSubConfig() = default;
+    PubSubConfig(NT_Type type, std::string_view typeStr,
+                 const PubSubOptions& options)
+        : PubSubOptionsImpl{options}, type{type}, typeStr{typeStr} {
+      prefixMatch = false;
+    }
+
+    NT_Type type{NT_UNASSIGNED};
+    std::string typeStr;
+  };
+
+  struct PublisherData {
+    static constexpr auto kType = Handle::kPublisher;
+
+    PublisherData(NT_Publisher handle, TopicData* topic, PubSubConfig config)
+        : handle{handle}, topic{topic}, config{std::move(config)} {}
+
+    void UpdateActive() {
+      active = config.type == topic->type && config.typeStr == topic->typeStr;
+    }
+
+    // invariants
+    wpi::SignalObject<NT_Publisher> handle;
+    TopicData* topic;
+    PubSubConfig config;
+
+    // whether or not the publisher should actually publish values
+    bool active{false};
+  };
+
+  struct SubscriberData {
+    static constexpr auto kType = Handle::kSubscriber;
+
+    SubscriberData(NT_Subscriber handle, TopicData* topic, PubSubConfig config)
+        : handle{handle},
+          topic{topic},
+          config{std::move(config)},
+          pollStorage{config.pollStorage} {}
+
+    void UpdateActive() {
+      // for subscribers, unassigned is a wildcard
+      // also allow numerically compatible subscribers
+      active =
+          config.type == NT_UNASSIGNED ||
+          (config.type == topic->type && config.typeStr == topic->typeStr) ||
+          IsNumericCompatible(config.type, topic->type);
+    }
+
+    // invariants
+    wpi::SignalObject<NT_Subscriber> handle;
+    TopicData* topic;
+    PubSubConfig config;
+
+    // whether or not the subscriber should actually receive values
+    bool active{false};
+
+    // polling storage
+    ValueCircularBuffer pollStorage;
+
+    // value listeners
+    VectorSet<NT_Listener> valueListeners;
+  };
+
+  struct EntryData {
+    static constexpr auto kType = Handle::kEntry;
+
+    EntryData(NT_Entry handle, SubscriberData* subscriber)
+        : handle{handle}, topic{subscriber->topic}, subscriber{subscriber} {}
+
+    // invariants
+    wpi::SignalObject<NT_Entry> handle;
+    TopicData* topic;
+    SubscriberData* subscriber;
+
+    // the publisher (created on demand)
+    PublisherData* publisher{nullptr};
+  };
+
+  struct MultiSubscriberData {
+    static constexpr auto kType = Handle::kMultiSubscriber;
+
+    MultiSubscriberData(NT_MultiSubscriber handle,
+                        std::span<const std::string_view> prefixes,
+                        const PubSubOptionsImpl& options)
+        : handle{handle}, options{options} {
+      this->options.prefixMatch = true;
+      this->prefixes.reserve(prefixes.size());
+      for (auto&& prefix : prefixes) {
+        this->prefixes.emplace_back(prefix);
+      }
+    }
+
+    bool Matches(std::string_view name, bool special);
+
+    // invariants
+    wpi::SignalObject<NT_MultiSubscriber> handle;
+    std::vector<std::string> prefixes;
+    PubSubOptionsImpl options;
+
+    // value listeners
+    VectorSet<NT_Listener> valueListeners;
+  };
+
+  struct ListenerData {
+    ListenerData(NT_Listener handle, SubscriberData* subscriber,
+                 unsigned int eventMask, bool subscriberOwned)
+        : handle{handle},
+          eventMask{eventMask},
+          subscriber{subscriber},
+          subscriberOwned{subscriberOwned} {}
+    ListenerData(NT_Listener handle, MultiSubscriberData* subscriber,
+                 unsigned int eventMask, bool subscriberOwned)
+        : handle{handle},
+          eventMask{eventMask},
+          multiSubscriber{subscriber},
+          subscriberOwned{subscriberOwned} {}
+
+    NT_Listener handle;
+    unsigned int eventMask;
+    SubscriberData* subscriber{nullptr};
+    MultiSubscriberData* multiSubscriber{nullptr};
+    bool subscriberOwned;
+  };
+
+  struct DataLoggerData {
+    static constexpr auto kType = Handle::kDataLogger;
+
+    DataLoggerData(NT_DataLogger handle, wpi::log::DataLog& log,
+                   std::string_view prefix, std::string_view logPrefix)
+        : handle{handle}, log{log}, prefix{prefix}, logPrefix{logPrefix} {}
+
+    int Start(TopicData* topic, int64_t time);
+
+    NT_DataLogger handle;
+    wpi::log::DataLog& log;
+    std::string prefix;
+    std::string logPrefix;
+  };
+
+  // inner struct to protect against accidentally deadlocking on the mutex
+  struct Impl {
+    Impl(int inst, IListenerStorage& listenerStorage, wpi::Logger& logger);
+
+    int m_inst;
+    IListenerStorage& m_listenerStorage;
+    wpi::Logger& m_logger;
+    net::NetworkInterface* m_network{nullptr};
+
+    // handle mappings
+    HandleMap<TopicData, 16> m_topics;
+    HandleMap<PublisherData, 16> m_publishers;
+    HandleMap<SubscriberData, 16> m_subscribers;
+    HandleMap<EntryData, 16> m_entries;
+    HandleMap<MultiSubscriberData, 16> m_multiSubscribers;
+    HandleMap<DataLoggerData, 16> m_dataloggers;
+
+    // name mappings
+    wpi::StringMap<TopicData*> m_nameTopics;
+
+    // listeners
+    wpi::DenseMap<NT_Listener, std::unique_ptr<ListenerData>> m_listeners;
+
+    // string-based listeners
+    VectorSet<ListenerData*> m_topicPrefixListeners;
+
+    // schema publishers
+    wpi::StringMap<NT_Publisher> m_schemas;
+
+    // topic functions
+    void NotifyTopic(TopicData* topic, unsigned int eventFlags);
+
+    void CheckReset(TopicData* topic);
+
+    bool SetValue(TopicData* topic, const Value& value, unsigned int eventFlags,
+                  bool isDuplicate, bool suppressIfDuplicate,
+                  const PublisherData* publisher);
+    void NotifyValue(TopicData* topic, unsigned int eventFlags,
+                     bool isDuplicate, const PublisherData* publisher);
+
+    void SetFlags(TopicData* topic, unsigned int flags);
+    void SetPersistent(TopicData* topic, bool value);
+    void SetRetained(TopicData* topic, bool value);
+    void SetProperties(TopicData* topic, const wpi::json& update,
+                       bool sendNetwork);
+    void PropertiesUpdated(TopicData* topic, const wpi::json& update,
+                           unsigned int eventFlags, bool sendNetwork,
+                           bool updateFlags = true);
+
+    void RefreshPubSubActive(TopicData* topic, bool warnOnSubMismatch);
+
+    void NetworkAnnounce(TopicData* topic, std::string_view typeStr,
+                         const wpi::json& properties, NT_Publisher pubHandle);
+    void RemoveNetworkPublisher(TopicData* topic);
+    void NetworkPropertiesUpdate(TopicData* topic, const wpi::json& update,
+                                 bool ack);
+    void StartNetwork(net::NetworkInterface* network);
+
+    PublisherData* AddLocalPublisher(TopicData* topic,
+                                     const wpi::json& properties,
+                                     const PubSubConfig& options);
+    std::unique_ptr<PublisherData> RemoveLocalPublisher(NT_Publisher pubHandle);
+
+    SubscriberData* AddLocalSubscriber(TopicData* topic,
+                                       const PubSubConfig& options);
+    std::unique_ptr<SubscriberData> RemoveLocalSubscriber(
+        NT_Subscriber subHandle);
+
+    EntryData* AddEntry(SubscriberData* subscriber);
+    std::unique_ptr<EntryData> RemoveEntry(NT_Entry entryHandle);
+
+    MultiSubscriberData* AddMultiSubscriber(
+        std::span<const std::string_view> prefixes,
+        const PubSubOptions& options);
+    std::unique_ptr<MultiSubscriberData> RemoveMultiSubscriber(
+        NT_MultiSubscriber subHandle);
+
+    void AddListenerImpl(NT_Listener listenerHandle, TopicData* topic,
+                         unsigned int eventMask);
+    void AddListenerImpl(NT_Listener listenerHandle, SubscriberData* subscriber,
+                         unsigned int eventMask, NT_Handle subentryHandle,
+                         bool subscriberOwned);
+    void AddListenerImpl(NT_Listener listenerHandle,
+                         MultiSubscriberData* subscriber,
+                         unsigned int eventMask, bool subscriberOwned);
+    void AddListenerImpl(NT_Listener listenerHandle,
+                         std::span<const std::string_view> prefixes,
+                         unsigned int eventMask);
+
+    TopicData* GetOrCreateTopic(std::string_view name);
+    TopicData* GetTopic(NT_Handle handle);
+    SubscriberData* GetSubEntry(NT_Handle subentryHandle);
+    PublisherData* PublishEntry(EntryData* entry, NT_Type type);
+
+    Value* GetSubEntryValue(NT_Handle subentryHandle) {
+      if (auto subscriber = GetSubEntry(subentryHandle)) {
+        return &subscriber->topic->lastValue;
+      } else {
+        return nullptr;
+      }
+    }
+
+    bool PublishLocalValue(PublisherData* publisher, const Value& value,
+                           bool force = false);
+
+    bool SetEntryValue(NT_Handle pubentryHandle, const Value& value);
+    bool SetDefaultEntryValue(NT_Handle pubsubentryHandle, const Value& value);
+
+    void RemoveSubEntry(NT_Handle subentryHandle);
+  };
 
   wpi::mutex m_mutex;
+  Impl m_impl;
 };
 
+template <ValidType T>
+Timestamped<typename TypeInfo<T>::Value> LocalStorage::GetAtomic(
+    NT_Handle subentry, typename TypeInfo<T>::View defaultValue) {
+  std::scoped_lock lock{m_mutex};
+  Value* value = m_impl.GetSubEntryValue(subentry);
+  if (value && (IsNumericConvertibleTo<T>(*value) || IsType<T>(*value))) {
+    return GetTimestamped<T, true>(*value);
+  } else {
+    return {0, 0, CopyValue<T>(defaultValue)};
+  }
+}
+
+template <SmallArrayType T>
+Timestamped<typename TypeInfo<T>::SmallRet> LocalStorage::GetAtomic(
+    NT_Handle subentry,
+    wpi::SmallVectorImpl<typename TypeInfo<T>::SmallElem>& buf,
+    typename TypeInfo<T>::View defaultValue) {
+  std::scoped_lock lock{m_mutex};
+  Value* value = m_impl.GetSubEntryValue(subentry);
+  if (value && (IsNumericConvertibleTo<T>(*value) || IsType<T>(*value))) {
+    return GetTimestamped<T, true>(*value, buf);
+  } else {
+    return {0, 0, CopyValue<T>(defaultValue, buf)};
+  }
+}
+
+template <ValidType T>
+std::vector<Timestamped<typename TypeInfo<T>::Value>> LocalStorage::ReadQueue(
+    NT_Handle subentry) {
+  std::scoped_lock lock{m_mutex};
+  auto subscriber = m_impl.GetSubEntry(subentry);
+  if (!subscriber) {
+    return {};
+  }
+  return subscriber->pollStorage.Read<T>();
+}
+
 }  // namespace nt
diff --git a/ntcore/src/main/native/cpp/Log.h b/ntcore/src/main/native/cpp/Log.h
index 7e052f9..ef9743b 100644
--- a/ntcore/src/main/native/cpp/Log.h
+++ b/ntcore/src/main/native/cpp/Log.h
@@ -9,10 +9,8 @@
 #define LOG(level, format, ...) \
   WPI_LOG(m_logger, level, format __VA_OPT__(, ) __VA_ARGS__)
 
-#undef ERROR
-#define ERROR(format, ...) \
-  WPI_ERROR(m_logger, format __VA_OPT__(, ) __VA_ARGS__)
-#define WARNING(format, ...) \
+#define ERR(format, ...) WPI_ERROR(m_logger, format __VA_OPT__(, ) __VA_ARGS__)
+#define WARN(format, ...) \
   WPI_WARNING(m_logger, format __VA_OPT__(, ) __VA_ARGS__)
 #define INFO(format, ...) WPI_INFO(m_logger, format __VA_OPT__(, ) __VA_ARGS__)
 
diff --git a/ntcore/src/main/native/cpp/NetworkClient.cpp b/ntcore/src/main/native/cpp/NetworkClient.cpp
index a329fb9..7634be9 100644
--- a/ntcore/src/main/native/cpp/NetworkClient.cpp
+++ b/ntcore/src/main/native/cpp/NetworkClient.cpp
@@ -13,25 +13,13 @@
 #include <fmt/format.h>
 #include <wpi/SmallString.h>
 #include <wpi/StringExtras.h>
-#include <wpinet/DsClient.h>
-#include <wpinet/EventLoopRunner.h>
 #include <wpinet/HttpUtil.h>
-#include <wpinet/ParallelTcpConnector.h>
-#include <wpinet/WebSocket.h>
-#include <wpinet/uv/Async.h>
 #include <wpinet/uv/Loop.h>
 #include <wpinet/uv/Tcp.h>
-#include <wpinet/uv/Timer.h>
 #include <wpinet/uv/util.h>
 
 #include "IConnectionList.h"
 #include "Log.h"
-#include "net/ClientImpl.h"
-#include "net/Message.h"
-#include "net/NetworkLoopQueue.h"
-#include "net/WebSocketConnection.h"
-#include "net3/ClientImpl3.h"
-#include "net3/UvStreamConnection3.h"
 
 using namespace nt;
 namespace uv = wpi::uv;
@@ -41,94 +29,10 @@
 // use a larger max message size for websockets
 static constexpr size_t kMaxMessageSize = 2 * 1024 * 1024;
 
-namespace {
-
-class NCImpl {
- public:
-  NCImpl(int inst, std::string_view id, net::ILocalStorage& localStorage,
-         IConnectionList& connList, wpi::Logger& logger);
-  virtual ~NCImpl() = default;
-
-  // user-facing functions
-  void SetServers(std::span<const std::pair<std::string, unsigned int>> servers,
-                  unsigned int defaultPort);
-  void StartDSClient(unsigned int port);
-  void StopDSClient();
-
-  virtual void TcpConnected(uv::Tcp& tcp) = 0;
-  virtual void Disconnect(std::string_view reason);
-
-  // invariants
-  int m_inst;
-  net::ILocalStorage& m_localStorage;
-  IConnectionList& m_connList;
-  wpi::Logger& m_logger;
-  std::string m_id;
-
-  // used only from loop
-  std::shared_ptr<wpi::ParallelTcpConnector> m_parallelConnect;
-  std::shared_ptr<uv::Timer> m_readLocalTimer;
-  std::shared_ptr<uv::Timer> m_sendValuesTimer;
-  std::shared_ptr<uv::Async<>> m_flushLocal;
-  std::shared_ptr<uv::Async<>> m_flush;
-
-  std::vector<net::ClientMessage> m_localMsgs;
-
-  std::vector<std::pair<std::string, unsigned int>> m_servers;
-
-  std::pair<std::string, unsigned int> m_dsClientServer{"", 0};
-  std::shared_ptr<wpi::DsClient> m_dsClient;
-
-  // shared with user
-  std::atomic<uv::Async<>*> m_flushLocalAtomic{nullptr};
-  std::atomic<uv::Async<>*> m_flushAtomic{nullptr};
-
-  net::NetworkLoopQueue m_localQueue;
-
-  int m_connHandle = 0;
-
-  wpi::EventLoopRunner m_loopRunner;
-  uv::Loop& m_loop;
-};
-
-class NCImpl3 : public NCImpl {
- public:
-  NCImpl3(int inst, std::string_view id, net::ILocalStorage& localStorage,
-          IConnectionList& connList, wpi::Logger& logger);
-  ~NCImpl3() override;
-
-  void HandleLocal();
-  void TcpConnected(uv::Tcp& tcp) final;
-  void Disconnect(std::string_view reason) override;
-
-  std::shared_ptr<net3::UvStreamConnection3> m_wire;
-  std::shared_ptr<net3::ClientImpl3> m_clientImpl;
-};
-
-class NCImpl4 : public NCImpl {
- public:
-  NCImpl4(
-      int inst, std::string_view id, net::ILocalStorage& localStorage,
-      IConnectionList& connList, wpi::Logger& logger,
-      std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
-          timeSyncUpdated);
-  ~NCImpl4() override;
-
-  void HandleLocal();
-  void TcpConnected(uv::Tcp& tcp) final;
-  void WsConnected(wpi::WebSocket& ws, uv::Tcp& tcp);
-  void Disconnect(std::string_view reason) override;
-
-  std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
-      m_timeSyncUpdated;
-  std::shared_ptr<net::WebSocketConnection> m_wire;
-  std::unique_ptr<net::ClientImpl> m_clientImpl;
-};
-
-}  // namespace
-
-NCImpl::NCImpl(int inst, std::string_view id, net::ILocalStorage& localStorage,
-               IConnectionList& connList, wpi::Logger& logger)
+NetworkClientBase::NetworkClientBase(int inst, std::string_view id,
+                                     net::ILocalStorage& localStorage,
+                                     IConnectionList& connList,
+                                     wpi::Logger& logger)
     : m_inst{inst},
       m_localStorage{localStorage},
       m_connList{connList},
@@ -141,7 +45,62 @@
   INFO("starting network client");
 }
 
-void NCImpl::SetServers(
+NetworkClientBase::~NetworkClientBase() {
+  m_localStorage.ClearNetwork();
+  m_connList.ClearConnections();
+}
+
+void NetworkClientBase::Disconnect() {
+  m_loopRunner.ExecAsync(
+      [this](auto&) { ForceDisconnect("requested by application"); });
+}
+
+void NetworkClientBase::StartDSClient(unsigned int port) {
+  m_loopRunner.ExecAsync([=, this](uv::Loop& loop) {
+    if (m_dsClient) {
+      return;
+    }
+    m_dsClientServer.second = port == 0 ? NT_DEFAULT_PORT4 : port;
+    m_dsClient = wpi::DsClient::Create(m_loop, m_logger);
+    if (m_dsClient) {
+      m_dsClient->setIp.connect([this](std::string_view ip) {
+        m_dsClientServer.first = ip;
+        if (m_parallelConnect) {
+          m_parallelConnect->SetServers({{m_dsClientServer}});
+        }
+      });
+      m_dsClient->clearIp.connect([this] {
+        m_dsClientServer.first.clear();
+        if (m_parallelConnect) {
+          m_parallelConnect->SetServers(m_servers);
+        }
+      });
+    }
+  });
+}
+
+void NetworkClientBase::StopDSClient() {
+  m_loopRunner.ExecAsync([this](uv::Loop& loop) {
+    if (m_dsClient) {
+      m_dsClient->Close();
+      m_dsClient.reset();
+    }
+  });
+}
+
+void NetworkClientBase::FlushLocal() {
+  if (auto async = m_flushLocalAtomic.load(std::memory_order_relaxed)) {
+    async->UnsafeSend();
+  }
+}
+
+void NetworkClientBase::Flush() {
+  if (auto async = m_flushAtomic.load(std::memory_order_relaxed)) {
+    async->UnsafeSend();
+  }
+}
+
+void NetworkClientBase::DoSetServers(
     std::span<const std::pair<std::string, unsigned int>> servers,
     unsigned int defaultPort) {
   std::vector<std::pair<std::string, unsigned int>> serversCopy;
@@ -155,87 +114,73 @@
       [this, servers = std::move(serversCopy)](uv::Loop&) mutable {
         m_servers = std::move(servers);
         if (m_dsClientServer.first.empty()) {
-          m_parallelConnect->SetServers(m_servers);
+          if (m_parallelConnect) {
+            m_parallelConnect->SetServers(m_servers);
+          }
         }
       });
 }
 
-void NCImpl::StartDSClient(unsigned int port) {
-  m_loopRunner.ExecAsync([=, this](uv::Loop& loop) {
-    if (m_dsClient) {
-      return;
-    }
-    m_dsClientServer.second = port == 0 ? NT_DEFAULT_PORT4 : port;
-    m_dsClient = wpi::DsClient::Create(m_loop, m_logger);
-    m_dsClient->setIp.connect([this](std::string_view ip) {
-      m_dsClientServer.first = ip;
-      m_parallelConnect->SetServers({{m_dsClientServer}});
-    });
-    m_dsClient->clearIp.connect([this] {
-      m_dsClientServer.first.clear();
-      m_parallelConnect->SetServers(m_servers);
-    });
-  });
-}
-
-void NCImpl::StopDSClient() {
-  m_loopRunner.ExecAsync([this](uv::Loop& loop) {
-    if (m_dsClient) {
-      m_dsClient->Close();
-      m_dsClient.reset();
-    }
-  });
-}
-
-void NCImpl::Disconnect(std::string_view reason) {
+void NetworkClientBase::DoDisconnect(std::string_view reason) {
   if (m_readLocalTimer) {
     m_readLocalTimer->Stop();
   }
-  m_sendValuesTimer->Stop();
+  if (m_sendOutgoingTimer) {
+    m_sendOutgoingTimer->Stop();
+  }
   m_localStorage.ClearNetwork();
   m_localQueue.ClearQueue();
   m_connList.RemoveConnection(m_connHandle);
   m_connHandle = 0;
 
   // start trying to connect again
-  uv::Timer::SingleShot(m_loop, kReconnectRate,
-                        [this] { m_parallelConnect->Disconnected(); });
+  uv::Timer::SingleShot(m_loop, kReconnectRate, [this] {
+    if (m_parallelConnect) {
+      m_parallelConnect->Disconnected();
+    }
+  });
 }
 
-NCImpl3::NCImpl3(int inst, std::string_view id,
-                 net::ILocalStorage& localStorage, IConnectionList& connList,
-                 wpi::Logger& logger)
-    : NCImpl{inst, id, localStorage, connList, logger} {
+NetworkClient3::NetworkClient3(int inst, std::string_view id,
+                               net::ILocalStorage& localStorage,
+                               IConnectionList& connList, wpi::Logger& logger)
+    : NetworkClientBase{inst, id, localStorage, connList, logger} {
   m_loopRunner.ExecAsync([this](uv::Loop& loop) {
     m_parallelConnect = wpi::ParallelTcpConnector::Create(
         loop, kReconnectRate, m_logger,
         [this](uv::Tcp& tcp) { TcpConnected(tcp); });
 
-    m_sendValuesTimer = uv::Timer::Create(loop);
-    m_sendValuesTimer->timeout.connect([this] {
-      if (m_clientImpl) {
-        HandleLocal();
-        m_clientImpl->SendPeriodic(m_loop.Now().count());
-      }
-    });
+    m_sendOutgoingTimer = uv::Timer::Create(loop);
+    if (m_sendOutgoingTimer) {
+      m_sendOutgoingTimer->timeout.connect([this] {
+        if (m_clientImpl) {
+          HandleLocal();
+          m_clientImpl->SendPeriodic(m_loop.Now().count(), false);
+        }
+      });
+    }
 
     // set up flush async
     m_flush = uv::Async<>::Create(m_loop);
-    m_flush->wakeup.connect([this] {
-      if (m_clientImpl) {
-        HandleLocal();
-        m_clientImpl->SendPeriodic(m_loop.Now().count());
-      }
-    });
+    if (m_flush) {
+      m_flush->wakeup.connect([this] {
+        if (m_clientImpl) {
+          HandleLocal();
+          m_clientImpl->SendPeriodic(m_loop.Now().count(), true);
+        }
+      });
+    }
     m_flushAtomic = m_flush.get();
 
     m_flushLocal = uv::Async<>::Create(m_loop);
-    m_flushLocal->wakeup.connect([this] { HandleLocal(); });
+    if (m_flushLocal) {
+      m_flushLocal->wakeup.connect([this] { HandleLocal(); });
+    }
     m_flushLocalAtomic = m_flushLocal.get();
   });
 }
 
-NCImpl3::~NCImpl3() {
+NetworkClient3::~NetworkClient3() {
   // must explicitly destroy these on loop
   m_loopRunner.ExecSync([&](auto&) {
     m_clientImpl.reset();
@@ -245,14 +190,14 @@
   m_loopRunner.Stop();
 }
 
-void NCImpl3::HandleLocal() {
+void NetworkClient3::HandleLocal() {
   m_localQueue.ReadQueue(&m_localMsgs);
   if (m_clientImpl) {
     m_clientImpl->HandleLocal(m_localMsgs);
   }
 }
 
-void NCImpl3::TcpConnected(uv::Tcp& tcp) {
+void NetworkClient3::TcpConnected(uv::Tcp& tcp) {
   tcp.SetNoDelay(true);
 
   // create as shared_ptr and capture in lambda because there may be multiple
@@ -261,8 +206,10 @@
   auto clientImpl = std::make_shared<net3::ClientImpl3>(
       m_loop.Now().count(), m_inst, *wire, m_logger, [this](uint32_t repeatMs) {
         DEBUG4("Setting periodic timer to {}", repeatMs);
-        m_sendValuesTimer->Start(uv::Timer::Time{repeatMs},
-                                 uv::Timer::Time{repeatMs});
+        if (m_sendOutgoingTimer) {
+          m_sendOutgoingTimer->Start(uv::Timer::Time{repeatMs},
+                                     uv::Timer::Time{repeatMs});
+        }
       });
   clientImpl->Start(
       m_id, [this, wire,
@@ -276,7 +223,9 @@
           return;
         }
 
-        m_parallelConnect->Succeeded(tcp);
+        if (m_parallelConnect) {
+          m_parallelConnect->Succeeded(tcp);
+        }
 
         m_wire = std::move(wire);
         m_clientImpl = std::move(clientImpl);
@@ -293,19 +242,23 @@
         tcp.error.connect([this, &tcp](uv::Error err) {
           DEBUG3("NT3 TCP error {}", err.str());
           if (!tcp.IsLoopClosing()) {
-            Disconnect(err.str());
+            // we could be in the middle of sending data, so defer disconnect
+            uv::Timer::SingleShot(m_loop, uv::Timer::Time{0},
+                                  [this, reason = std::string{err.str()}] {
+                                    DoDisconnect(reason);
+                                  });
           }
         });
         tcp.end.connect([this, &tcp] {
           DEBUG3("NT3 TCP read ended");
           if (!tcp.IsLoopClosing()) {
-            Disconnect("remote end closed connection");
+            DoDisconnect("remote end closed connection");
           }
         });
         tcp.closed.connect([this, &tcp] {
           DEBUG3("NT3 TCP connection closed");
           if (!tcp.IsLoopClosing()) {
-            Disconnect(m_wire->GetDisconnectReason());
+            DoDisconnect(m_wire ? m_wire->GetDisconnectReason() : "unknown");
           }
         });
 
@@ -323,19 +276,25 @@
   tcp.StartRead();
 }
 
-void NCImpl3::Disconnect(std::string_view reason) {
+void NetworkClient3::ForceDisconnect(std::string_view reason) {
+  if (m_wire) {
+    m_wire->Disconnect(reason);
+  }
+}
+
+void NetworkClient3::DoDisconnect(std::string_view reason) {
   INFO("DISCONNECTED NT3 connection: {}", reason);
   m_clientImpl.reset();
   m_wire.reset();
-  NCImpl::Disconnect(reason);
+  NetworkClientBase::DoDisconnect(reason);
 }
 
-NCImpl4::NCImpl4(
+NetworkClient::NetworkClient(
     int inst, std::string_view id, net::ILocalStorage& localStorage,
     IConnectionList& connList, wpi::Logger& logger,
     std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
         timeSyncUpdated)
-    : NCImpl{inst, id, localStorage, connList, logger},
+    : NetworkClientBase{inst, id, localStorage, connList, logger},
       m_timeSyncUpdated{std::move(timeSyncUpdated)} {
   m_loopRunner.ExecAsync([this](uv::Loop& loop) {
     m_parallelConnect = wpi::ParallelTcpConnector::Create(
@@ -343,39 +302,47 @@
         [this](uv::Tcp& tcp) { TcpConnected(tcp); });
 
     m_readLocalTimer = uv::Timer::Create(loop);
-    m_readLocalTimer->timeout.connect([this] {
-      if (m_clientImpl) {
-        HandleLocal();
-        m_clientImpl->SendControl(m_loop.Now().count());
-      }
-    });
-    m_readLocalTimer->Start(uv::Timer::Time{100}, uv::Timer::Time{100});
+    if (m_readLocalTimer) {
+      m_readLocalTimer->timeout.connect([this] {
+        if (m_clientImpl) {
+          HandleLocal();
+          m_clientImpl->SendOutgoing(m_loop.Now().count(), false);
+        }
+      });
+      m_readLocalTimer->Start(uv::Timer::Time{100}, uv::Timer::Time{100});
+    }
 
-    m_sendValuesTimer = uv::Timer::Create(loop);
-    m_sendValuesTimer->timeout.connect([this] {
-      if (m_clientImpl) {
-        HandleLocal();
-        m_clientImpl->SendValues(m_loop.Now().count());
-      }
-    });
+    m_sendOutgoingTimer = uv::Timer::Create(loop);
+    if (m_sendOutgoingTimer) {
+      m_sendOutgoingTimer->timeout.connect([this] {
+        if (m_clientImpl) {
+          HandleLocal();
+          m_clientImpl->SendOutgoing(m_loop.Now().count(), false);
+        }
+      });
+    }
 
     // set up flush async
     m_flush = uv::Async<>::Create(m_loop);
-    m_flush->wakeup.connect([this] {
-      if (m_clientImpl) {
-        HandleLocal();
-        m_clientImpl->SendValues(m_loop.Now().count());
-      }
-    });
+    if (m_flush) {
+      m_flush->wakeup.connect([this] {
+        if (m_clientImpl) {
+          HandleLocal();
+          m_clientImpl->SendOutgoing(m_loop.Now().count(), true);
+        }
+      });
+    }
     m_flushAtomic = m_flush.get();
 
     m_flushLocal = uv::Async<>::Create(m_loop);
-    m_flushLocal->wakeup.connect([this] { HandleLocal(); });
+    if (m_flushLocal) {
+      m_flushLocal->wakeup.connect([this] { HandleLocal(); });
+    }
     m_flushLocalAtomic = m_flushLocal.get();
   });
 }
 
-NCImpl4::~NCImpl4() {
+NetworkClient::~NetworkClient() {
   // must explicitly destroy these on loop
   m_loopRunner.ExecSync([&](auto&) {
     m_clientImpl.reset();
@@ -385,14 +352,14 @@
   m_loopRunner.Stop();
 }
 
-void NCImpl4::HandleLocal() {
+void NetworkClient::HandleLocal() {
   m_localQueue.ReadQueue(&m_localMsgs);
   if (m_clientImpl) {
     m_clientImpl->HandleLocal(std::move(m_localMsgs));
   }
 }
 
-void NCImpl4::TcpConnected(uv::Tcp& tcp) {
+void NetworkClient::TcpConnected(uv::Tcp& tcp) {
   tcp.SetNoDelay(true);
   // Start the WS client
   if (m_logger.min_level() >= wpi::WPI_LOG_DEBUG4) {
@@ -406,34 +373,43 @@
   wpi::SmallString<128> idBuf;
   auto ws = wpi::WebSocket::CreateClient(
       tcp, fmt::format("/nt/{}", wpi::EscapeURI(m_id, idBuf)), "",
-      {{"networktables.first.wpi.edu"}}, options);
+      {"v4.1.networktables.first.wpi.edu", "networktables.first.wpi.edu"},
+      options);
   ws->SetMaxMessageSize(kMaxMessageSize);
-  ws->open.connect([this, &tcp, ws = ws.get()](std::string_view) {
+  ws->open.connect([this, &tcp, ws = ws.get()](std::string_view protocol) {
     if (m_connList.IsConnected()) {
       ws->Terminate(1006, "no longer needed");
       return;
     }
-    WsConnected(*ws, tcp);
+    WsConnected(*ws, tcp, protocol);
   });
 }
 
-void NCImpl4::WsConnected(wpi::WebSocket& ws, uv::Tcp& tcp) {
-  m_parallelConnect->Succeeded(tcp);
+void NetworkClient::WsConnected(wpi::WebSocket& ws, uv::Tcp& tcp,
+                                std::string_view protocol) {
+  if (m_parallelConnect) {
+    m_parallelConnect->Succeeded(tcp);
+  }
 
   ConnectionInfo connInfo;
   uv::AddrToName(tcp.GetPeer(), &connInfo.remote_ip, &connInfo.remote_port);
-  connInfo.protocol_version = 0x0400;
+  connInfo.protocol_version =
+      protocol == "v4.1.networktables.first.wpi.edu" ? 0x0401 : 0x0400;
 
   INFO("CONNECTED NT4 to {} port {}", connInfo.remote_ip, connInfo.remote_port);
   m_connHandle = m_connList.AddConnection(connInfo);
 
-  m_wire = std::make_shared<net::WebSocketConnection>(ws);
+  m_wire =
+      std::make_shared<net::WebSocketConnection>(ws, connInfo.protocol_version);
+  m_wire->Start();
   m_clientImpl = std::make_unique<net::ClientImpl>(
       m_loop.Now().count(), m_inst, *m_wire, m_logger, m_timeSyncUpdated,
       [this](uint32_t repeatMs) {
         DEBUG4("Setting periodic timer to {}", repeatMs);
-        m_sendValuesTimer->Start(uv::Timer::Time{repeatMs},
-                                 uv::Timer::Time{repeatMs});
+        if (m_sendOutgoingTimer) {
+          m_sendOutgoingTimer->Start(uv::Timer::Time{repeatMs},
+                                     uv::Timer::Time{repeatMs});
+        }
       });
   m_clientImpl->SetLocal(&m_localStorage);
   m_localStorage.StartNetwork(&m_localQueue);
@@ -441,7 +417,14 @@
   m_clientImpl->SendInitial();
   ws.closed.connect([this, &ws](uint16_t, std::string_view reason) {
     if (!ws.GetStream().IsLoopClosing()) {
-      Disconnect(reason);
+      // we could be in the middle of sending data, so defer disconnect
+      // capture a shared_ptr copy of ws to make sure it doesn't get destroyed
+      // until after DoDisconnect returns
+      uv::Timer::SingleShot(
+          m_loop, uv::Timer::Time{0},
+          [this, reason = std::string{reason}, keepws = ws.shared_from_this()] {
+            DoDisconnect(reason);
+          });
     }
   });
   ws.text.connect([this](std::string_view data, bool) {
@@ -451,107 +434,26 @@
   });
   ws.binary.connect([this](std::span<const uint8_t> data, bool) {
     if (m_clientImpl) {
-      m_clientImpl->ProcessIncomingBinary(data);
+      m_clientImpl->ProcessIncomingBinary(m_loop.Now().count(), data);
     }
   });
 }
 
-void NCImpl4::Disconnect(std::string_view reason) {
-  INFO("DISCONNECTED NT4 connection: {}", reason);
+void NetworkClient::ForceDisconnect(std::string_view reason) {
+  if (m_wire) {
+    m_wire->Disconnect(reason);
+  }
+}
+
+void NetworkClient::DoDisconnect(std::string_view reason) {
+  std::string realReason;
+  if (m_wire) {
+    realReason = m_wire->GetDisconnectReason();
+  }
+  INFO("DISCONNECTED NT4 connection: {}",
+       realReason.empty() ? reason : realReason);
   m_clientImpl.reset();
   m_wire.reset();
-  NCImpl::Disconnect(reason);
+  NetworkClientBase::DoDisconnect(reason);
   m_timeSyncUpdated(0, 0, false);
 }
-
-class NetworkClient::Impl final : public NCImpl4 {
- public:
-  Impl(int inst, std::string_view id, net::ILocalStorage& localStorage,
-       IConnectionList& connList, wpi::Logger& logger,
-       std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
-           timeSyncUpdated)
-      : NCImpl4{inst,     id,     localStorage,
-                connList, logger, std::move(timeSyncUpdated)} {}
-};
-
-NetworkClient::NetworkClient(
-    int inst, std::string_view id, net::ILocalStorage& localStorage,
-    IConnectionList& connList, wpi::Logger& logger,
-    std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
-        timeSyncUpdated)
-    : m_impl{std::make_unique<Impl>(inst, id, localStorage, connList, logger,
-                                    std::move(timeSyncUpdated))} {}
-
-NetworkClient::~NetworkClient() {
-  m_impl->m_localStorage.ClearNetwork();
-  m_impl->m_connList.ClearConnections();
-}
-
-void NetworkClient::SetServers(
-    std::span<const std::pair<std::string, unsigned int>> servers) {
-  m_impl->SetServers(servers, NT_DEFAULT_PORT4);
-}
-
-void NetworkClient::StartDSClient(unsigned int port) {
-  m_impl->StartDSClient(port);
-}
-
-void NetworkClient::StopDSClient() {
-  m_impl->StopDSClient();
-}
-
-void NetworkClient::FlushLocal() {
-  m_impl->m_loopRunner.ExecAsync([this](uv::Loop&) { m_impl->HandleLocal(); });
-}
-
-void NetworkClient::Flush() {
-  m_impl->m_loopRunner.ExecAsync([this](uv::Loop&) {
-    m_impl->HandleLocal();
-    if (m_impl->m_clientImpl) {
-      m_impl->m_clientImpl->SendValues(m_impl->m_loop.Now().count());
-    }
-  });
-}
-
-class NetworkClient3::Impl final : public NCImpl3 {
- public:
-  Impl(int inst, std::string_view id, net::ILocalStorage& localStorage,
-       IConnectionList& connList, wpi::Logger& logger)
-      : NCImpl3{inst, id, localStorage, connList, logger} {}
-};
-
-NetworkClient3::NetworkClient3(int inst, std::string_view id,
-                               net::ILocalStorage& localStorage,
-                               IConnectionList& connList, wpi::Logger& logger)
-    : m_impl{std::make_unique<Impl>(inst, id, localStorage, connList, logger)} {
-}
-
-NetworkClient3::~NetworkClient3() {
-  m_impl->m_localStorage.ClearNetwork();
-  m_impl->m_connList.ClearConnections();
-}
-
-void NetworkClient3::SetServers(
-    std::span<const std::pair<std::string, unsigned int>> servers) {
-  m_impl->SetServers(servers, NT_DEFAULT_PORT3);
-}
-
-void NetworkClient3::StartDSClient(unsigned int port) {
-  m_impl->StartDSClient(port);
-}
-
-void NetworkClient3::StopDSClient() {
-  m_impl->StopDSClient();
-}
-
-void NetworkClient3::FlushLocal() {
-  if (auto async = m_impl->m_flushLocalAtomic.load(std::memory_order_relaxed)) {
-    async->UnsafeSend();
-  }
-}
-
-void NetworkClient3::Flush() {
-  if (auto async = m_impl->m_flushAtomic.load(std::memory_order_relaxed)) {
-    async->UnsafeSend();
-  }
-}
diff --git a/ntcore/src/main/native/cpp/NetworkClient.h b/ntcore/src/main/native/cpp/NetworkClient.h
index 34bf379..73e1134 100644
--- a/ntcore/src/main/native/cpp/NetworkClient.h
+++ b/ntcore/src/main/native/cpp/NetworkClient.h
@@ -4,6 +4,7 @@
 
 #pragma once
 
+#include <atomic>
 #include <functional>
 #include <memory>
 #include <optional>
@@ -11,8 +12,22 @@
 #include <string>
 #include <string_view>
 #include <utility>
+#include <vector>
+
+#include <wpinet/DsClient.h>
+#include <wpinet/EventLoopRunner.h>
+#include <wpinet/ParallelTcpConnector.h>
+#include <wpinet/WebSocket.h>
+#include <wpinet/uv/Async.h>
+#include <wpinet/uv/Timer.h>
 
 #include "INetworkClient.h"
+#include "net/ClientImpl.h"
+#include "net/Message.h"
+#include "net/NetworkLoopQueue.h"
+#include "net/WebSocketConnection.h"
+#include "net3/ClientImpl3.h"
+#include "net3/UvStreamConnection3.h"
 #include "ntcore_cpp.h"
 
 namespace wpi {
@@ -27,7 +42,86 @@
 
 class IConnectionList;
 
-class NetworkClient final : public INetworkClient {
+class NetworkClientBase : public INetworkClient {
+ public:
+  NetworkClientBase(int inst, std::string_view id,
+                    net::ILocalStorage& localStorage, IConnectionList& connList,
+                    wpi::Logger& logger);
+  ~NetworkClientBase() override;
+
+  void Disconnect() override;
+
+  void StartDSClient(unsigned int port) override;
+  void StopDSClient() override;
+
+  void FlushLocal() override;
+  void Flush() override;
+
+ protected:
+  void DoSetServers(
+      std::span<const std::pair<std::string, unsigned int>> servers,
+      unsigned int defaultPort);
+
+  virtual void TcpConnected(wpi::uv::Tcp& tcp) = 0;
+  virtual void ForceDisconnect(std::string_view reason) = 0;
+  virtual void DoDisconnect(std::string_view reason);
+
+  // invariants
+  int m_inst;
+  net::ILocalStorage& m_localStorage;
+  IConnectionList& m_connList;
+  wpi::Logger& m_logger;
+  std::string m_id;
+
+  // used only from loop
+  std::shared_ptr<wpi::ParallelTcpConnector> m_parallelConnect;
+  std::shared_ptr<wpi::uv::Timer> m_readLocalTimer;
+  std::shared_ptr<wpi::uv::Timer> m_sendOutgoingTimer;
+  std::shared_ptr<wpi::uv::Async<>> m_flushLocal;
+  std::shared_ptr<wpi::uv::Async<>> m_flush;
+
+  std::vector<net::ClientMessage> m_localMsgs;
+
+  std::vector<std::pair<std::string, unsigned int>> m_servers;
+
+  std::pair<std::string, unsigned int> m_dsClientServer{"", 0};
+  std::shared_ptr<wpi::DsClient> m_dsClient;
+
+  // shared with user
+  std::atomic<wpi::uv::Async<>*> m_flushLocalAtomic{nullptr};
+  std::atomic<wpi::uv::Async<>*> m_flushAtomic{nullptr};
+
+  net::NetworkLoopQueue m_localQueue;
+
+  int m_connHandle = 0;
+
+  wpi::EventLoopRunner m_loopRunner;
+  wpi::uv::Loop& m_loop;
+};
+
+class NetworkClient3 final : public NetworkClientBase {
+ public:
+  NetworkClient3(int inst, std::string_view id,
+                 net::ILocalStorage& localStorage, IConnectionList& connList,
+                 wpi::Logger& logger);
+  ~NetworkClient3() final;
+
+  void SetServers(
+      std::span<const std::pair<std::string, unsigned int>> servers) final {
+    DoSetServers(servers, NT_DEFAULT_PORT3);
+  }
+
+ private:
+  void HandleLocal();
+  void TcpConnected(wpi::uv::Tcp& tcp) final;
+  void ForceDisconnect(std::string_view reason) override;
+  void DoDisconnect(std::string_view reason) override;
+
+  std::shared_ptr<net3::UvStreamConnection3> m_wire;
+  std::shared_ptr<net3::ClientImpl3> m_clientImpl;
+};
+
+class NetworkClient final : public NetworkClientBase {
  public:
   NetworkClient(
       int inst, std::string_view id, net::ILocalStorage& localStorage,
@@ -37,38 +131,22 @@
   ~NetworkClient() final;
 
   void SetServers(
-      std::span<const std::pair<std::string, unsigned int>> servers) final;
-
-  void StartDSClient(unsigned int port) final;
-  void StopDSClient() final;
-
-  void FlushLocal() final;
-  void Flush() final;
+      std::span<const std::pair<std::string, unsigned int>> servers) final {
+    DoSetServers(servers, NT_DEFAULT_PORT4);
+  }
 
  private:
-  class Impl;
-  std::unique_ptr<Impl> m_impl;
-};
+  void HandleLocal();
+  void TcpConnected(wpi::uv::Tcp& tcp) final;
+  void WsConnected(wpi::WebSocket& ws, wpi::uv::Tcp& tcp,
+                   std::string_view protocol);
+  void ForceDisconnect(std::string_view reason) override;
+  void DoDisconnect(std::string_view reason) override;
 
-class NetworkClient3 final : public INetworkClient {
- public:
-  NetworkClient3(int inst, std::string_view id,
-                 net::ILocalStorage& localStorage, IConnectionList& connList,
-                 wpi::Logger& logger);
-  ~NetworkClient3() final;
-
-  void SetServers(
-      std::span<const std::pair<std::string, unsigned int>> servers) final;
-
-  void StartDSClient(unsigned int port) final;
-  void StopDSClient() final;
-
-  void FlushLocal() final;
-  void Flush() final;
-
- private:
-  class Impl;
-  std::unique_ptr<Impl> m_impl;
+  std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
+      m_timeSyncUpdated;
+  std::shared_ptr<net::WebSocketConnection> m_wire;
+  std::unique_ptr<net::ClientImpl> m_clientImpl;
 };
 
 }  // namespace nt
diff --git a/ntcore/src/main/native/cpp/NetworkServer.cpp b/ntcore/src/main/native/cpp/NetworkServer.cpp
index 0086d24..9062c7f 100644
--- a/ntcore/src/main/native/cpp/NetworkServer.cpp
+++ b/ntcore/src/main/native/cpp/NetworkServer.cpp
@@ -11,17 +11,16 @@
 #include <system_error>
 #include <vector>
 
+#include <wpi/MemoryBuffer.h>
 #include <wpi/SmallString.h>
 #include <wpi/StringExtras.h>
 #include <wpi/fs.h>
 #include <wpi/mutex.h>
-#include <wpi/raw_istream.h>
 #include <wpi/raw_ostream.h>
-#include <wpinet/EventLoopRunner.h>
+#include <wpi/timestamp.h>
 #include <wpinet/HttpUtil.h>
 #include <wpinet/HttpWebSocketServerConnection.h>
 #include <wpinet/UrlParser.h>
-#include <wpinet/uv/Async.h>
 #include <wpinet/uv/Tcp.h>
 #include <wpinet/uv/Work.h>
 #include <wpinet/uv/util.h>
@@ -29,10 +28,9 @@
 #include "IConnectionList.h"
 #include "InstanceImpl.h"
 #include "Log.h"
-#include "net/Message.h"
-#include "net/NetworkLoopQueue.h"
-#include "net/ServerImpl.h"
 #include "net/WebSocketConnection.h"
+#include "net/WireDecoder.h"
+#include "net/WireEncoder.h"
 #include "net3/UvStreamConnection3.h"
 
 using namespace nt;
@@ -41,14 +39,10 @@
 // use a larger max message size for websockets
 static constexpr size_t kMaxMessageSize = 2 * 1024 * 1024;
 
-namespace {
-
-class NSImpl;
-
-class ServerConnection {
+class NetworkServer::ServerConnection {
  public:
-  ServerConnection(NSImpl& server, std::string_view addr, unsigned int port,
-                   wpi::Logger& logger)
+  ServerConnection(NetworkServer& server, std::string_view addr,
+                   unsigned int port, wpi::Logger& logger)
       : m_server{server},
         m_connInfo{fmt::format("{}:{}", addr, port)},
         m_logger{logger} {
@@ -59,29 +53,42 @@
   int GetClientId() const { return m_clientId; }
 
  protected:
-  void SetupPeriodicTimer();
-  void UpdatePeriodicTimer(uint32_t repeatMs);
+  void SetupOutgoingTimer();
+  void UpdateOutgoingTimer(uint32_t repeatMs);
   void ConnectionClosed();
 
-  NSImpl& m_server;
+  NetworkServer& m_server;
   ConnectionInfo m_info;
   std::string m_connInfo;
   wpi::Logger& m_logger;
   int m_clientId;
 
  private:
-  std::shared_ptr<uv::Timer> m_sendValuesTimer;
+  std::shared_ptr<uv::Timer> m_outgoingTimer;
 };
 
-class ServerConnection4 final
+class NetworkServer::ServerConnection3 : public ServerConnection {
+ public:
+  ServerConnection3(std::shared_ptr<uv::Stream> stream, NetworkServer& server,
+                    std::string_view addr, unsigned int port,
+                    wpi::Logger& logger);
+
+ private:
+  std::shared_ptr<net3::UvStreamConnection3> m_wire;
+};
+
+class NetworkServer::ServerConnection4 final
     : public ServerConnection,
       public wpi::HttpWebSocketServerConnection<ServerConnection4> {
  public:
-  ServerConnection4(std::shared_ptr<uv::Stream> stream, NSImpl& server,
+  ServerConnection4(std::shared_ptr<uv::Stream> stream, NetworkServer& server,
                     std::string_view addr, unsigned int port,
                     wpi::Logger& logger)
       : ServerConnection{server, addr, port, logger},
-        HttpWebSocketServerConnection(stream, {"networktables.first.wpi.edu"}) {
+        HttpWebSocketServerConnection(
+            stream,
+            {"v4.1.networktables.first.wpi.edu", "networktables.first.wpi.edu",
+             "rtt.networktables.first.wpi.edu"}) {
     m_info.protocol_version = 0x0400;
   }
 
@@ -92,97 +99,82 @@
   std::shared_ptr<net::WebSocketConnection> m_wire;
 };
 
-class ServerConnection3 : public ServerConnection {
- public:
-  ServerConnection3(std::shared_ptr<uv::Stream> stream, NSImpl& server,
-                    std::string_view addr, unsigned int port,
-                    wpi::Logger& logger);
-
- private:
-  std::shared_ptr<net3::UvStreamConnection3> m_wire;
-};
-
-class NSImpl {
- public:
-  NSImpl(std::string_view persistFilename, std::string_view listenAddress,
-         unsigned int port3, unsigned int port4,
-         net::ILocalStorage& localStorage, IConnectionList& connList,
-         wpi::Logger& logger, std::function<void()> initDone);
-  ~NSImpl();
-
-  void HandleLocal();
-  void LoadPersistent();
-  void SavePersistent(std::string_view filename, std::string_view data);
-  void Init();
-  void AddConnection(ServerConnection* conn, const ConnectionInfo& info);
-  void RemoveConnection(ServerConnection* conn);
-
-  net::ILocalStorage& m_localStorage;
-  IConnectionList& m_connList;
-  wpi::Logger& m_logger;
-  std::function<void()> m_initDone;
-  std::string m_persistentData;
-  std::string m_persistentFilename;
-  std::string m_listenAddress;
-  unsigned int m_port3;
-  unsigned int m_port4;
-
-  // used only from loop
-  std::shared_ptr<uv::Timer> m_readLocalTimer;
-  std::shared_ptr<uv::Timer> m_savePersistentTimer;
-  std::shared_ptr<uv::Async<>> m_flushLocal;
-  std::shared_ptr<uv::Async<>> m_flush;
-  bool m_shutdown = false;
-
-  std::vector<net::ClientMessage> m_localMsgs;
-
-  net::ServerImpl m_serverImpl;
-
-  // shared with user (must be atomic or mutex-protected)
-  std::atomic<uv::Async<>*> m_flushLocalAtomic{nullptr};
-  std::atomic<uv::Async<>*> m_flushAtomic{nullptr};
-  mutable wpi::mutex m_mutex;
-  struct Connection {
-    ServerConnection* conn;
-    int connHandle;
-  };
-  std::vector<Connection> m_connections;
-
-  net::NetworkLoopQueue m_localQueue;
-
-  wpi::EventLoopRunner m_loopRunner;
-  wpi::uv::Loop& m_loop;
-};
-
-}  // namespace
-
-void ServerConnection::SetupPeriodicTimer() {
-  m_sendValuesTimer = uv::Timer::Create(m_server.m_loop);
-  m_sendValuesTimer->timeout.connect([this] {
+void NetworkServer::ServerConnection::SetupOutgoingTimer() {
+  m_outgoingTimer = uv::Timer::Create(m_server.m_loop);
+  m_outgoingTimer->timeout.connect([this] {
     m_server.HandleLocal();
-    m_server.m_serverImpl.SendValues(m_clientId, m_server.m_loop.Now().count());
+    m_server.m_serverImpl.SendOutgoing(m_clientId,
+                                       m_server.m_loop.Now().count());
   });
 }
 
-void ServerConnection::UpdatePeriodicTimer(uint32_t repeatMs) {
+void NetworkServer::ServerConnection::UpdateOutgoingTimer(uint32_t repeatMs) {
+  DEBUG4("Setting periodic timer to {}", repeatMs);
   if (repeatMs == UINT32_MAX) {
-    m_sendValuesTimer->Stop();
+    m_outgoingTimer->Stop();
   } else {
-    m_sendValuesTimer->Start(uv::Timer::Time{repeatMs},
-                             uv::Timer::Time{repeatMs});
+    m_outgoingTimer->Start(uv::Timer::Time{repeatMs},
+                           uv::Timer::Time{repeatMs});
   }
 }
 
-void ServerConnection::ConnectionClosed() {
+void NetworkServer::ServerConnection::ConnectionClosed() {
   // don't call back into m_server if it's being destroyed
-  if (!m_sendValuesTimer->IsLoopClosing()) {
+  if (!m_outgoingTimer->IsLoopClosing()) {
     m_server.m_serverImpl.RemoveClient(m_clientId);
     m_server.RemoveConnection(this);
   }
-  m_sendValuesTimer->Close();
+  m_outgoingTimer->Close();
 }
 
-void ServerConnection4::ProcessRequest() {
+NetworkServer::ServerConnection3::ServerConnection3(
+    std::shared_ptr<uv::Stream> stream, NetworkServer& server,
+    std::string_view addr, unsigned int port, wpi::Logger& logger)
+    : ServerConnection{server, addr, port, logger},
+      m_wire{std::make_shared<net3::UvStreamConnection3>(*stream)} {
+  m_info.remote_ip = addr;
+  m_info.remote_port = port;
+
+  // TODO: set local flag appropriately
+  m_clientId = m_server.m_serverImpl.AddClient3(
+      m_connInfo, false, *m_wire,
+      [this](std::string_view name, uint16_t proto) {
+        m_info.remote_id = name;
+        m_info.protocol_version = proto;
+        m_server.AddConnection(this, m_info);
+        INFO("CONNECTED NT3 client '{}' (from {})", name, m_connInfo);
+      },
+      [this](uint32_t repeatMs) { UpdateOutgoingTimer(repeatMs); });
+
+  stream->error.connect([this](uv::Error err) {
+    if (!m_wire->GetDisconnectReason().empty()) {
+      return;
+    }
+    m_wire->Disconnect(fmt::format("stream error: {}", err.name()));
+    m_wire->GetStream().Shutdown([this] { m_wire->GetStream().Close(); });
+  });
+  stream->end.connect([this] {
+    if (!m_wire->GetDisconnectReason().empty()) {
+      return;
+    }
+    m_wire->Disconnect("remote end closed connection");
+    m_wire->GetStream().Shutdown([this] { m_wire->GetStream().Close(); });
+  });
+  stream->closed.connect([this] {
+    INFO("DISCONNECTED NT3 client '{}' (from {}): {}", m_info.remote_id,
+         m_connInfo, m_wire->GetDisconnectReason());
+    ConnectionClosed();
+  });
+  stream->data.connect([this](uv::Buffer& buf, size_t size) {
+    m_server.m_serverImpl.ProcessIncomingBinary(
+        m_clientId, {reinterpret_cast<const uint8_t*>(buf.base), size});
+  });
+  stream->StartRead();
+
+  SetupOutgoingTimer();
+}
+
+void NetworkServer::ServerConnection4::ProcessRequest() {
   DEBUG1("HTTP request: '{}'", m_request.GetUrl());
   wpi::UrlParser url{m_request.GetUrl(),
                      m_request.GetMethod() == wpi::HTTP_CONNECT};
@@ -219,7 +211,7 @@
   }
 }
 
-void ServerConnection4::ProcessWsUpgrade() {
+void NetworkServer::ServerConnection4::ProcessWsUpgrade() {
   // get name from URL
   wpi::UrlParser url{m_request.GetUrl(), false};
   std::string_view path;
@@ -244,19 +236,55 @@
 
   m_websocket->SetMaxMessageSize(kMaxMessageSize);
 
-  m_websocket->open.connect([this, name = std::string{name}](std::string_view) {
-    m_wire = std::make_shared<net::WebSocketConnection>(*m_websocket);
+  m_websocket->open.connect([this, name = std::string{name}](
+                                std::string_view protocol) {
+    m_info.protocol_version =
+        protocol == "v4.1.networktables.first.wpi.edu" ? 0x0401 : 0x0400;
+    m_wire = std::make_shared<net::WebSocketConnection>(
+        *m_websocket, m_info.protocol_version);
+
+    if (protocol == "rtt.networktables.first.wpi.edu") {
+      INFO("CONNECTED RTT client (from {})", m_connInfo);
+      m_websocket->binary.connect([this](std::span<const uint8_t> data, bool) {
+        while (!data.empty()) {
+          // decode message
+          int64_t pubuid;
+          Value value;
+          std::string error;
+          if (!net::WireDecodeBinary(&data, &pubuid, &value, &error, 0)) {
+            m_wire->Disconnect(fmt::format("binary decode error: {}", error));
+            break;
+          }
+
+          // respond to RTT ping
+          if (pubuid == -1) {
+            m_wire->SendBinary([&](auto& os) {
+              net::WireEncodeBinary(os, -1, wpi::Now(), value);
+            });
+          }
+        }
+      });
+      m_websocket->closed.connect([this](uint16_t, std::string_view reason) {
+        auto realReason = m_wire->GetDisconnectReason();
+        INFO("DISCONNECTED RTT client (from {}): {}", m_connInfo,
+             realReason.empty() ? reason : realReason);
+      });
+      return;
+    }
+
     // TODO: set local flag appropriately
     std::string dedupName;
     std::tie(dedupName, m_clientId) = m_server.m_serverImpl.AddClient(
         name, m_connInfo, false, *m_wire,
-        [this](uint32_t repeatMs) { UpdatePeriodicTimer(repeatMs); });
+        [this](uint32_t repeatMs) { UpdateOutgoingTimer(repeatMs); });
     INFO("CONNECTED NT4 client '{}' (from {})", dedupName, m_connInfo);
     m_info.remote_id = dedupName;
     m_server.AddConnection(this, m_info);
+    m_wire->Start();
     m_websocket->closed.connect([this](uint16_t, std::string_view reason) {
+      auto realReason = m_wire->GetDisconnectReason();
       INFO("DISCONNECTED NT4 client '{}' (from {}): {}", m_info.remote_id,
-           m_connInfo, reason);
+           m_connInfo, realReason.empty() ? reason : realReason);
       ConnectionClosed();
     });
     m_websocket->text.connect([this](std::string_view data, bool) {
@@ -266,62 +294,16 @@
       m_server.m_serverImpl.ProcessIncomingBinary(m_clientId, data);
     });
 
-    SetupPeriodicTimer();
+    SetupOutgoingTimer();
   });
 }
 
-ServerConnection3::ServerConnection3(std::shared_ptr<uv::Stream> stream,
-                                     NSImpl& server, std::string_view addr,
-                                     unsigned int port, wpi::Logger& logger)
-    : ServerConnection{server, addr, port, logger},
-      m_wire{std::make_shared<net3::UvStreamConnection3>(*stream)} {
-  m_info.remote_ip = addr;
-  m_info.remote_port = port;
-
-  // TODO: set local flag appropriately
-  m_clientId = m_server.m_serverImpl.AddClient3(
-      m_connInfo, false, *m_wire,
-      [this](std::string_view name, uint16_t proto) {
-        m_info.remote_id = name;
-        m_info.protocol_version = proto;
-        m_server.AddConnection(this, m_info);
-        INFO("CONNECTED NT3 client '{}' (from {})", name, m_connInfo);
-      },
-      [this](uint32_t repeatMs) { UpdatePeriodicTimer(repeatMs); });
-
-  stream->error.connect([this](uv::Error err) {
-    if (!m_wire->GetDisconnectReason().empty()) {
-      return;
-    }
-    m_wire->Disconnect(fmt::format("stream error: {}", err.name()));
-    m_wire->GetStream().Shutdown([this] { m_wire->GetStream().Close(); });
-  });
-  stream->end.connect([this] {
-    if (!m_wire->GetDisconnectReason().empty()) {
-      return;
-    }
-    m_wire->Disconnect("remote end closed connection");
-    m_wire->GetStream().Shutdown([this] { m_wire->GetStream().Close(); });
-  });
-  stream->closed.connect([this] {
-    INFO("DISCONNECTED NT3 client '{}' (from {}): {}", m_info.remote_id,
-         m_connInfo, m_wire->GetDisconnectReason());
-    ConnectionClosed();
-  });
-  stream->data.connect([this](uv::Buffer& buf, size_t size) {
-    m_server.m_serverImpl.ProcessIncomingBinary(
-        m_clientId, {reinterpret_cast<const uint8_t*>(buf.base), size});
-  });
-  stream->StartRead();
-
-  SetupPeriodicTimer();
-}
-
-NSImpl::NSImpl(std::string_view persistentFilename,
-               std::string_view listenAddress, unsigned int port3,
-               unsigned int port4, net::ILocalStorage& localStorage,
-               IConnectionList& connList, wpi::Logger& logger,
-               std::function<void()> initDone)
+NetworkServer::NetworkServer(std::string_view persistentFilename,
+                             std::string_view listenAddress, unsigned int port3,
+                             unsigned int port4,
+                             net::ILocalStorage& localStorage,
+                             IConnectionList& connList, wpi::Logger& logger,
+                             std::function<void()> initDone)
     : m_localStorage{localStorage},
       m_connList{connList},
       m_logger{logger},
@@ -346,33 +328,52 @@
   });
 }
 
-NSImpl::~NSImpl() {
+NetworkServer::~NetworkServer() {
   m_loopRunner.ExecAsync([this](uv::Loop&) { m_shutdown = true; });
+  m_localStorage.ClearNetwork();
+  m_connList.ClearConnections();
 }
 
-void NSImpl::HandleLocal() {
+void NetworkServer::FlushLocal() {
+  if (auto async = m_flushLocalAtomic.load(std::memory_order_relaxed)) {
+    async->UnsafeSend();
+  }
+}
+
+void NetworkServer::Flush() {
+  if (auto async = m_flushAtomic.load(std::memory_order_relaxed)) {
+    async->UnsafeSend();
+  }
+}
+
+void NetworkServer::HandleLocal() {
   m_localQueue.ReadQueue(&m_localMsgs);
   m_serverImpl.HandleLocal(m_localMsgs);
 }
 
-void NSImpl::LoadPersistent() {
+void NetworkServer::LoadPersistent() {
   std::error_code ec;
-  auto size = fs::file_size(m_persistentFilename, ec);
-  wpi::raw_fd_istream is{m_persistentFilename, ec};
-  if (ec.value() != 0) {
-    INFO("could not open persistent file '{}': {}", m_persistentFilename,
-         ec.message());
+  std::unique_ptr<wpi::MemoryBuffer> fileBuffer =
+      wpi::MemoryBuffer::GetFile(m_persistentFilename, ec);
+  if (fileBuffer == nullptr || ec.value() != 0) {
+    INFO(
+        "could not open persistent file '{}': {} "
+        "(this can be ignored if you aren't expecting persistent values)",
+        m_persistentFilename, ec.message());
+    // try to write an empty file so it doesn't happen again
+    wpi::raw_fd_ostream os{m_persistentFilename, ec, fs::F_Text};
+    if (ec.value() == 0) {
+      os << "[]\n";
+      os.close();
+    }
     return;
   }
-  is.readinto(m_persistentData, size);
+  m_persistentData = std::string{fileBuffer->begin(), fileBuffer->end()};
   DEBUG4("read data: {}", m_persistentData);
-  if (is.has_error()) {
-    WARNING("error reading persistent file");
-    return;
-  }
 }
 
-void NSImpl::SavePersistent(std::string_view filename, std::string_view data) {
+void NetworkServer::SavePersistent(std::string_view filename,
+                                   std::string_view data) {
   // write to temporary file
   auto tmp = fmt::format("{}.tmp", filename);
   std::error_code ec;
@@ -400,47 +401,55 @@
   }
 }
 
-void NSImpl::Init() {
+void NetworkServer::Init() {
   if (m_shutdown) {
     return;
   }
   auto errs = m_serverImpl.LoadPersistent(m_persistentData);
   if (!errs.empty()) {
-    WARNING("error reading persistent file: {}", errs);
+    WARN("error reading persistent file: {}", errs);
   }
 
   // set up timers
   m_readLocalTimer = uv::Timer::Create(m_loop);
-  m_readLocalTimer->timeout.connect([this] {
-    HandleLocal();
-    m_serverImpl.SendControl(m_loop.Now().count());
-  });
-  m_readLocalTimer->Start(uv::Timer::Time{100}, uv::Timer::Time{100});
+  if (m_readLocalTimer) {
+    m_readLocalTimer->timeout.connect([this] {
+      HandleLocal();
+      m_serverImpl.SendAllOutgoing(m_loop.Now().count(), false);
+    });
+    m_readLocalTimer->Start(uv::Timer::Time{100}, uv::Timer::Time{100});
+  }
 
   m_savePersistentTimer = uv::Timer::Create(m_loop);
-  m_savePersistentTimer->timeout.connect([this] {
-    if (m_serverImpl.PersistentChanged()) {
-      uv::QueueWork(
-          m_loop,
-          [this, fn = m_persistentFilename,
-           data = m_serverImpl.DumpPersistent()] { SavePersistent(fn, data); },
-          nullptr);
-    }
-  });
-  m_savePersistentTimer->Start(uv::Timer::Time{1000}, uv::Timer::Time{1000});
+  if (m_savePersistentTimer) {
+    m_savePersistentTimer->timeout.connect([this] {
+      if (m_serverImpl.PersistentChanged()) {
+        uv::QueueWork(
+            m_loop,
+            [this, fn = m_persistentFilename,
+             data = m_serverImpl.DumpPersistent()] {
+              SavePersistent(fn, data);
+            },
+            nullptr);
+      }
+    });
+    m_savePersistentTimer->Start(uv::Timer::Time{1000}, uv::Timer::Time{1000});
+  }
 
   // set up flush async
   m_flush = uv::Async<>::Create(m_loop);
-  m_flush->wakeup.connect([this] {
-    HandleLocal();
-    for (auto&& conn : m_connections) {
-      m_serverImpl.SendValues(conn.conn->GetClientId(), m_loop.Now().count());
-    }
-  });
+  if (m_flush) {
+    m_flush->wakeup.connect([this] {
+      HandleLocal();
+      m_serverImpl.SendAllOutgoing(m_loop.Now().count(), true);
+    });
+  }
   m_flushAtomic = m_flush.get();
 
   m_flushLocal = uv::Async<>::Create(m_loop);
-  m_flushLocal->wakeup.connect([this] { HandleLocal(); });
+  if (m_flushLocal) {
+    m_flushLocal->wakeup.connect([this] { HandleLocal(); });
+  }
   m_flushLocalAtomic = m_flushLocal.get();
 
   INFO("Listening on NT3 port {}, NT4 port {}", m_port3, m_port4);
@@ -516,13 +525,14 @@
   }
 }
 
-void NSImpl::AddConnection(ServerConnection* conn, const ConnectionInfo& info) {
+void NetworkServer::AddConnection(ServerConnection* conn,
+                                  const ConnectionInfo& info) {
   std::scoped_lock lock{m_mutex};
   m_connections.emplace_back(Connection{conn, m_connList.AddConnection(info)});
   m_serverImpl.ConnectionsChanged(m_connList.GetConnections());
 }
 
-void NSImpl::RemoveConnection(ServerConnection* conn) {
+void NetworkServer::RemoveConnection(ServerConnection* conn) {
   std::scoped_lock lock{m_mutex};
   auto it = std::find_if(m_connections.begin(), m_connections.end(),
                          [=](auto&& c) { return c.conn == conn; });
@@ -532,40 +542,3 @@
     m_serverImpl.ConnectionsChanged(m_connList.GetConnections());
   }
 }
-
-class NetworkServer::Impl final : public NSImpl {
- public:
-  Impl(std::string_view persistFilename, std::string_view listenAddress,
-       unsigned int port3, unsigned int port4, net::ILocalStorage& localStorage,
-       IConnectionList& connList, wpi::Logger& logger,
-       std::function<void()> initDone)
-      : NSImpl{persistFilename, listenAddress, port3,  port4,
-               localStorage,    connList,      logger, std::move(initDone)} {}
-};
-
-NetworkServer::NetworkServer(std::string_view persistFilename,
-                             std::string_view listenAddress, unsigned int port3,
-                             unsigned int port4,
-                             net::ILocalStorage& localStorage,
-                             IConnectionList& connList, wpi::Logger& logger,
-                             std::function<void()> initDone)
-    : m_impl{std::make_unique<Impl>(persistFilename, listenAddress, port3,
-                                    port4, localStorage, connList, logger,
-                                    std::move(initDone))} {}
-
-NetworkServer::~NetworkServer() {
-  m_impl->m_localStorage.ClearNetwork();
-  m_impl->m_connList.ClearConnections();
-}
-
-void NetworkServer::FlushLocal() {
-  if (auto async = m_impl->m_flushLocalAtomic.load(std::memory_order_relaxed)) {
-    async->UnsafeSend();
-  }
-}
-
-void NetworkServer::Flush() {
-  if (auto async = m_impl->m_flushAtomic.load(std::memory_order_relaxed)) {
-    async->UnsafeSend();
-  }
-}
diff --git a/ntcore/src/main/native/cpp/NetworkServer.h b/ntcore/src/main/native/cpp/NetworkServer.h
index b70c968..3f5a094 100644
--- a/ntcore/src/main/native/cpp/NetworkServer.h
+++ b/ntcore/src/main/native/cpp/NetworkServer.h
@@ -4,10 +4,20 @@
 
 #pragma once
 
+#include <atomic>
 #include <functional>
 #include <memory>
+#include <string>
 #include <string_view>
+#include <vector>
 
+#include <wpinet/EventLoopRunner.h>
+#include <wpinet/uv/Async.h>
+#include <wpinet/uv/Timer.h>
+
+#include "net/Message.h"
+#include "net/NetworkLoopQueue.h"
+#include "net/ServerImpl.h"
 #include "ntcore_cpp.h"
 
 namespace wpi {
@@ -35,8 +45,52 @@
   void Flush();
 
  private:
-  class Impl;
-  std::unique_ptr<Impl> m_impl;
+  class ServerConnection;
+  class ServerConnection3;
+  class ServerConnection4;
+
+  void HandleLocal();
+  void LoadPersistent();
+  void SavePersistent(std::string_view filename, std::string_view data);
+  void Init();
+  void AddConnection(ServerConnection* conn, const ConnectionInfo& info);
+  void RemoveConnection(ServerConnection* conn);
+
+  net::ILocalStorage& m_localStorage;
+  IConnectionList& m_connList;
+  wpi::Logger& m_logger;
+  std::function<void()> m_initDone;
+  std::string m_persistentData;
+  std::string m_persistentFilename;
+  std::string m_listenAddress;
+  unsigned int m_port3;
+  unsigned int m_port4;
+
+  // used only from loop
+  std::shared_ptr<wpi::uv::Timer> m_readLocalTimer;
+  std::shared_ptr<wpi::uv::Timer> m_savePersistentTimer;
+  std::shared_ptr<wpi::uv::Async<>> m_flushLocal;
+  std::shared_ptr<wpi::uv::Async<>> m_flush;
+  bool m_shutdown = false;
+
+  std::vector<net::ClientMessage> m_localMsgs;
+
+  net::ServerImpl m_serverImpl;
+
+  // shared with user (must be atomic or mutex-protected)
+  std::atomic<wpi::uv::Async<>*> m_flushLocalAtomic{nullptr};
+  std::atomic<wpi::uv::Async<>*> m_flushAtomic{nullptr};
+  mutable wpi::mutex m_mutex;
+  struct Connection {
+    ServerConnection* conn;
+    int connHandle;
+  };
+  std::vector<Connection> m_connections;
+
+  net::NetworkLoopQueue m_localQueue;
+
+  wpi::EventLoopRunner m_loopRunner;
+  wpi::uv::Loop& m_loop;
 };
 
 }  // namespace nt
diff --git a/ntcore/src/main/native/cpp/Value.cpp b/ntcore/src/main/native/cpp/Value.cpp
index 240447f..09ba775 100644
--- a/ntcore/src/main/native/cpp/Value.cpp
+++ b/ntcore/src/main/native/cpp/Value.cpp
@@ -4,7 +4,9 @@
 
 #include <stdint.h>
 
+#include <algorithm>
 #include <cstring>
+#include <numeric>
 #include <span>
 
 #include <wpi/MemAlloc.h>
@@ -27,10 +29,32 @@
     InitNtStrings();
   }
   void InitNtStrings();
+  size_t EstimateSize() const {
+    return sizeof(StringArrayStorage) +
+           strings.capacity() * sizeof(std::string) +
+           ntStrings.capacity() * sizeof(NT_String) +
+           std::accumulate(strings.begin(), strings.end(), 0,
+                           [](const auto& sum, const auto& val) {
+                             return sum + val.capacity();
+                           });
+  }
 
   std::vector<std::string> strings;
   std::vector<NT_String> ntStrings;
 };
+
+template <typename T>
+inline std::shared_ptr<T[]> AllocateArray(size_t nelem) {
+#if __cpp_lib_shared_ptr_arrays >= 201707L
+#if __cpp_lib_smart_ptr_for_overwrite >= 202002L
+  return std::make_shared_for_overwrite<T[]>(nelem);
+#else
+  return std::make_shared<T[]>(nelem);
+#endif
+#else
+  return std::shared_ptr<T[]>{new T[nelem]};
+#endif
+}
 }  // namespace
 
 void StringArrayStorage::InitNtStrings() {
@@ -46,12 +70,13 @@
   m_val.type = NT_UNASSIGNED;
   m_val.last_change = 0;
   m_val.server_time = 0;
+  m_size = 0;
 }
 
-Value::Value(NT_Type type, int64_t time, const private_init&)
-    : Value{type, time == 0 ? nt::Now() : time, 1, private_init{}} {}
+Value::Value(NT_Type type, size_t size, int64_t time, const private_init&)
+    : Value{type, size, time == 0 ? nt::Now() : time, 1, private_init{}} {}
 
-Value::Value(NT_Type type, int64_t time, int64_t serverTime,
+Value::Value(NT_Type type, size_t size, int64_t time, int64_t serverTime,
              const private_init&) {
   m_val.type = type;
   m_val.last_change = time;
@@ -67,30 +92,31 @@
   } else if (m_val.type == NT_STRING_ARRAY) {
     m_val.data.arr_string.arr = nullptr;
   }
+  m_size = size;
 }
 
 Value Value::MakeBooleanArray(std::span<const bool> value, int64_t time) {
-  Value val{NT_BOOLEAN_ARRAY, time, private_init{}};
-  auto data = std::make_shared<std::vector<int>>(value.begin(), value.end());
-  // data->reserve(value.size());
-  // std::copy(value.begin(), value.end(), *data);
-  val.m_val.data.arr_boolean.arr = data->data();
-  val.m_val.data.arr_boolean.size = data->size();
+  Value val{NT_BOOLEAN_ARRAY, value.size() * sizeof(int), time, private_init{}};
+  auto data = AllocateArray<int>(value.size());
+  std::copy(value.begin(), value.end(), data.get());
+  val.m_val.data.arr_boolean.arr = data.get();
+  val.m_val.data.arr_boolean.size = value.size();
   val.m_storage = std::move(data);
   return val;
 }
 
 Value Value::MakeBooleanArray(std::span<const int> value, int64_t time) {
-  Value val{NT_BOOLEAN_ARRAY, time, private_init{}};
-  auto data = std::make_shared<std::vector<int>>(value.begin(), value.end());
-  val.m_val.data.arr_boolean.arr = data->data();
-  val.m_val.data.arr_boolean.size = data->size();
+  Value val{NT_BOOLEAN_ARRAY, value.size() * sizeof(int), time, private_init{}};
+  auto data = AllocateArray<int>(value.size());
+  std::copy(value.begin(), value.end(), data.get());
+  val.m_val.data.arr_boolean.arr = data.get();
+  val.m_val.data.arr_boolean.size = value.size();
   val.m_storage = std::move(data);
   return val;
 }
 
 Value Value::MakeBooleanArray(std::vector<int>&& value, int64_t time) {
-  Value val{NT_BOOLEAN_ARRAY, time, private_init{}};
+  Value val{NT_BOOLEAN_ARRAY, value.size() * sizeof(int), time, private_init{}};
   auto data = std::make_shared<std::vector<int>>(std::move(value));
   val.m_val.data.arr_boolean.arr = data->data();
   val.m_val.data.arr_boolean.size = data->size();
@@ -99,17 +125,19 @@
 }
 
 Value Value::MakeIntegerArray(std::span<const int64_t> value, int64_t time) {
-  Value val{NT_INTEGER_ARRAY, time, private_init{}};
-  auto data =
-      std::make_shared<std::vector<int64_t>>(value.begin(), value.end());
-  val.m_val.data.arr_int.arr = data->data();
-  val.m_val.data.arr_int.size = data->size();
+  Value val{NT_INTEGER_ARRAY, value.size() * sizeof(int64_t), time,
+            private_init{}};
+  auto data = AllocateArray<int64_t>(value.size());
+  std::copy(value.begin(), value.end(), data.get());
+  val.m_val.data.arr_int.arr = data.get();
+  val.m_val.data.arr_int.size = value.size();
   val.m_storage = std::move(data);
   return val;
 }
 
 Value Value::MakeIntegerArray(std::vector<int64_t>&& value, int64_t time) {
-  Value val{NT_INTEGER_ARRAY, time, private_init{}};
+  Value val{NT_INTEGER_ARRAY, value.size() * sizeof(int64_t), time,
+            private_init{}};
   auto data = std::make_shared<std::vector<int64_t>>(std::move(value));
   val.m_val.data.arr_int.arr = data->data();
   val.m_val.data.arr_int.size = data->size();
@@ -118,16 +146,17 @@
 }
 
 Value Value::MakeFloatArray(std::span<const float> value, int64_t time) {
-  Value val{NT_FLOAT_ARRAY, time, private_init{}};
-  auto data = std::make_shared<std::vector<float>>(value.begin(), value.end());
-  val.m_val.data.arr_float.arr = data->data();
-  val.m_val.data.arr_float.size = data->size();
+  Value val{NT_FLOAT_ARRAY, value.size() * sizeof(float), time, private_init{}};
+  auto data = AllocateArray<float>(value.size());
+  std::copy(value.begin(), value.end(), data.get());
+  val.m_val.data.arr_float.arr = data.get();
+  val.m_val.data.arr_float.size = value.size();
   val.m_storage = std::move(data);
   return val;
 }
 
 Value Value::MakeFloatArray(std::vector<float>&& value, int64_t time) {
-  Value val{NT_FLOAT_ARRAY, time, private_init{}};
+  Value val{NT_FLOAT_ARRAY, value.size() * sizeof(float), time, private_init{}};
   auto data = std::make_shared<std::vector<float>>(std::move(value));
   val.m_val.data.arr_float.arr = data->data();
   val.m_val.data.arr_float.size = data->size();
@@ -136,16 +165,19 @@
 }
 
 Value Value::MakeDoubleArray(std::span<const double> value, int64_t time) {
-  Value val{NT_DOUBLE_ARRAY, time, private_init{}};
-  auto data = std::make_shared<std::vector<double>>(value.begin(), value.end());
-  val.m_val.data.arr_double.arr = data->data();
-  val.m_val.data.arr_double.size = data->size();
+  Value val{NT_DOUBLE_ARRAY, value.size() * sizeof(double), time,
+            private_init{}};
+  auto data = AllocateArray<double>(value.size());
+  std::copy(value.begin(), value.end(), data.get());
+  val.m_val.data.arr_double.arr = data.get();
+  val.m_val.data.arr_double.size = value.size();
   val.m_storage = std::move(data);
   return val;
 }
 
 Value Value::MakeDoubleArray(std::vector<double>&& value, int64_t time) {
-  Value val{NT_DOUBLE_ARRAY, time, private_init{}};
+  Value val{NT_DOUBLE_ARRAY, value.size() * sizeof(double), time,
+            private_init{}};
   auto data = std::make_shared<std::vector<double>>(std::move(value));
   val.m_val.data.arr_double.arr = data->data();
   val.m_val.data.arr_double.size = data->size();
@@ -154,8 +186,8 @@
 }
 
 Value Value::MakeStringArray(std::span<const std::string> value, int64_t time) {
-  Value val{NT_STRING_ARRAY, time, private_init{}};
   auto data = std::make_shared<StringArrayStorage>(value);
+  Value val{NT_STRING_ARRAY, data->EstimateSize(), time, private_init{}};
   val.m_val.data.arr_string.arr = data->ntStrings.data();
   val.m_val.data.arr_string.size = data->ntStrings.size();
   val.m_storage = std::move(data);
@@ -163,8 +195,8 @@
 }
 
 Value Value::MakeStringArray(std::vector<std::string>&& value, int64_t time) {
-  Value val{NT_STRING_ARRAY, time, private_init{}};
   auto data = std::make_shared<StringArrayStorage>(std::move(value));
+  Value val{NT_STRING_ARRAY, data->EstimateSize(), time, private_init{}};
   val.m_val.data.arr_string.arr = data->ntStrings.data();
   val.m_val.data.arr_string.size = data->ntStrings.size();
   val.m_storage = std::move(data);
@@ -317,6 +349,9 @@
       if (lhs.m_val.data.arr_boolean.size != rhs.m_val.data.arr_boolean.size) {
         return false;
       }
+      if (lhs.m_val.data.arr_boolean.size == 0) {
+        return true;
+      }
       return std::memcmp(lhs.m_val.data.arr_boolean.arr,
                          rhs.m_val.data.arr_boolean.arr,
                          lhs.m_val.data.arr_boolean.size *
@@ -325,6 +360,9 @@
       if (lhs.m_val.data.arr_int.size != rhs.m_val.data.arr_int.size) {
         return false;
       }
+      if (lhs.m_val.data.arr_int.size == 0) {
+        return true;
+      }
       return std::memcmp(lhs.m_val.data.arr_int.arr, rhs.m_val.data.arr_int.arr,
                          lhs.m_val.data.arr_int.size *
                              sizeof(lhs.m_val.data.arr_int.arr[0])) == 0;
@@ -332,6 +370,9 @@
       if (lhs.m_val.data.arr_float.size != rhs.m_val.data.arr_float.size) {
         return false;
       }
+      if (lhs.m_val.data.arr_float.size == 0) {
+        return true;
+      }
       return std::memcmp(lhs.m_val.data.arr_float.arr,
                          rhs.m_val.data.arr_float.arr,
                          lhs.m_val.data.arr_float.size *
@@ -340,11 +381,20 @@
       if (lhs.m_val.data.arr_double.size != rhs.m_val.data.arr_double.size) {
         return false;
       }
+      if (lhs.m_val.data.arr_double.size == 0) {
+        return true;
+      }
       return std::memcmp(lhs.m_val.data.arr_double.arr,
                          rhs.m_val.data.arr_double.arr,
                          lhs.m_val.data.arr_double.size *
                              sizeof(lhs.m_val.data.arr_double.arr[0])) == 0;
     case NT_STRING_ARRAY:
+      if (lhs.m_val.data.arr_string.size != rhs.m_val.data.arr_string.size) {
+        return false;
+      }
+      if (lhs.m_val.data.arr_string.size == 0) {
+        return true;
+      }
       return static_cast<StringArrayStorage*>(lhs.m_storage.get())->strings ==
              static_cast<StringArrayStorage*>(rhs.m_storage.get())->strings;
     default:
diff --git a/ntcore/src/main/native/cpp/ValueCircularBuffer.cpp b/ntcore/src/main/native/cpp/ValueCircularBuffer.cpp
new file mode 100644
index 0000000..f611a33
--- /dev/null
+++ b/ntcore/src/main/native/cpp/ValueCircularBuffer.cpp
@@ -0,0 +1,17 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "ValueCircularBuffer.h"
+
+using namespace nt;
+
+std::vector<Value> ValueCircularBuffer::ReadValue() {
+  std::vector<Value> rv;
+  rv.reserve(m_storage.size());
+  for (auto&& val : m_storage) {
+    rv.emplace_back(std::move(val));
+  }
+  m_storage.reset();
+  return rv;
+}
diff --git a/ntcore/src/main/native/cpp/ValueCircularBuffer.h b/ntcore/src/main/native/cpp/ValueCircularBuffer.h
new file mode 100644
index 0000000..b80b5db
--- /dev/null
+++ b/ntcore/src/main/native/cpp/ValueCircularBuffer.h
@@ -0,0 +1,49 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <utility>
+#include <vector>
+
+#include <wpi/circular_buffer.h>
+
+#include "Value_internal.h"
+#include "networktables/NetworkTableValue.h"
+#include "ntcore_cpp_types.h"
+
+namespace nt {
+
+class ValueCircularBuffer {
+ public:
+  explicit ValueCircularBuffer(size_t size) : m_storage{size} {}
+
+  template <class... Args>
+  void emplace_back(Args&&... args) {
+    m_storage.emplace_back(std::forward<Args...>(args...));
+  }
+
+  std::vector<Value> ReadValue();
+  template <ValidType T>
+  std::vector<Timestamped<typename TypeInfo<T>::Value>> Read();
+
+ private:
+  wpi::circular_buffer<Value> m_storage;
+};
+
+template <ValidType T>
+std::vector<Timestamped<typename TypeInfo<T>::Value>>
+ValueCircularBuffer::Read() {
+  std::vector<Timestamped<typename TypeInfo<T>::Value>> rv;
+  rv.reserve(m_storage.size());
+  for (auto&& val : m_storage) {
+    if (IsNumericConvertibleTo<T>(val) || IsType<T>(val)) {
+      rv.emplace_back(GetTimestamped<T, true>(val));
+    }
+  }
+  m_storage.reset();
+  return rv;
+}
+
+}  // namespace nt
diff --git a/ntcore/src/main/native/cpp/Value_internal.cpp b/ntcore/src/main/native/cpp/Value_internal.cpp
index 2003d31..c947bdc 100644
--- a/ntcore/src/main/native/cpp/Value_internal.cpp
+++ b/ntcore/src/main/native/cpp/Value_internal.cpp
@@ -26,19 +26,19 @@
       return newval;
     }
     case NT_INTEGER_ARRAY: {
-      Value newval = Value::MakeIntegerArray(GetNumericArrayAs<int64_t>(value),
-                                             value.time());
+      Value newval = Value::MakeIntegerArray(
+          GetNumericArrayAs<int64_t[]>(value), value.time());
       newval.SetServerTime(value.server_time());
       return newval;
     }
     case NT_FLOAT_ARRAY: {
-      Value newval =
-          Value::MakeFloatArray(GetNumericArrayAs<float>(value), value.time());
+      Value newval = Value::MakeFloatArray(GetNumericArrayAs<float[]>(value),
+                                           value.time());
       newval.SetServerTime(value.server_time());
       return newval;
     }
     case NT_DOUBLE_ARRAY: {
-      Value newval = Value::MakeDoubleArray(GetNumericArrayAs<double>(value),
+      Value newval = Value::MakeDoubleArray(GetNumericArrayAs<double[]>(value),
                                             value.time());
       newval.SetServerTime(value.server_time());
       return newval;
diff --git a/ntcore/src/main/native/cpp/Value_internal.h b/ntcore/src/main/native/cpp/Value_internal.h
index 03532ac..8f2c8fb 100644
--- a/ntcore/src/main/native/cpp/Value_internal.h
+++ b/ntcore/src/main/native/cpp/Value_internal.h
@@ -4,20 +4,413 @@
 
 #pragma once
 
+#include <concepts>
 #include <cstring>
 #include <memory>
+#include <span>
 #include <string>
 #include <string_view>
+#include <type_traits>
 #include <vector>
 
 #include <wpi/MemAlloc.h>
 
 #include "networktables/NetworkTableValue.h"
 #include "ntcore_c.h"
+#include "ntcore_cpp_types.h"
 
 namespace nt {
 
-class Value;
+template <typename T>
+struct TypeInfo {};
+
+template <>
+struct TypeInfo<bool> {
+  static constexpr NT_Type kType = NT_BOOLEAN;
+
+  using Value = bool;
+  using View = bool;
+};
+
+template <>
+struct TypeInfo<int64_t> {
+  static constexpr NT_Type kType = NT_INTEGER;
+
+  using Value = int64_t;
+  using View = int64_t;
+};
+
+template <>
+struct TypeInfo<float> {
+  static constexpr NT_Type kType = NT_FLOAT;
+
+  using Value = float;
+  using View = float;
+};
+
+template <>
+struct TypeInfo<double> {
+  static constexpr NT_Type kType = NT_DOUBLE;
+
+  using Value = double;
+  using View = double;
+};
+
+template <>
+struct TypeInfo<std::string> {
+  static constexpr NT_Type kType = NT_STRING;
+
+  using Value = std::string;
+  using View = std::string_view;
+
+  using SmallRet = std::string_view;
+  using SmallElem = char;
+};
+
+template <>
+struct TypeInfo<std::string_view> : public TypeInfo<std::string> {};
+
+template <>
+struct TypeInfo<uint8_t[]> {
+  static constexpr NT_Type kType = NT_RAW;
+
+  using Value = std::vector<uint8_t>;
+  using View = std::span<const uint8_t>;
+
+  using SmallRet = std::span<uint8_t>;
+  using SmallElem = uint8_t;
+};
+
+template <>
+struct TypeInfo<std::vector<uint8_t>> : public TypeInfo<uint8_t[]> {};
+template <>
+struct TypeInfo<std::span<const uint8_t>> : public TypeInfo<uint8_t[]> {};
+
+template <>
+struct TypeInfo<bool[]> {
+  static constexpr NT_Type kType = NT_BOOLEAN_ARRAY;
+  using ElementType = bool;
+
+  using Value = std::vector<int>;
+  using View = std::span<const int>;
+
+  using SmallRet = std::span<int>;
+  using SmallElem = int;
+};
+
+template <>
+struct TypeInfo<int64_t[]> {
+  static constexpr NT_Type kType = NT_INTEGER_ARRAY;
+  using ElementType = int64_t;
+
+  using Value = std::vector<int64_t>;
+  using View = std::span<const int64_t>;
+
+  using SmallRet = std::span<int64_t>;
+  using SmallElem = int64_t;
+};
+
+template <>
+struct TypeInfo<std::vector<int64_t>> : public TypeInfo<int64_t[]> {};
+template <>
+struct TypeInfo<std::span<const int64_t>> : public TypeInfo<int64_t[]> {};
+
+template <>
+struct TypeInfo<float[]> {
+  static constexpr NT_Type kType = NT_FLOAT_ARRAY;
+  using ElementType = float;
+
+  using Value = std::vector<float>;
+  using View = std::span<const float>;
+
+  using SmallRet = std::span<float>;
+  using SmallElem = float;
+};
+
+template <>
+struct TypeInfo<std::vector<float>> : public TypeInfo<float[]> {};
+template <>
+struct TypeInfo<std::span<const float>> : public TypeInfo<float[]> {};
+
+template <>
+struct TypeInfo<double[]> {
+  static constexpr NT_Type kType = NT_DOUBLE_ARRAY;
+  using ElementType = double;
+
+  using Value = std::vector<double>;
+  using View = std::span<const double>;
+
+  using SmallRet = std::span<double>;
+  using SmallElem = double;
+};
+
+template <>
+struct TypeInfo<std::vector<double>> : public TypeInfo<double[]> {};
+template <>
+struct TypeInfo<std::span<const double>> : public TypeInfo<double[]> {};
+
+template <>
+struct TypeInfo<std::string[]> {
+  static constexpr NT_Type kType = NT_STRING_ARRAY;
+  using ElementType = std::string;
+
+  using Value = std::vector<std::string>;
+  using View = std::span<const std::string>;
+};
+
+template <>
+struct TypeInfo<std::vector<std::string>> : public TypeInfo<std::string[]> {};
+template <>
+struct TypeInfo<std::span<const std::string>> : public TypeInfo<std::string[]> {
+};
+
+template <typename T>
+concept ValidType = requires {
+  { TypeInfo<std::remove_cvref_t<T>>::kType } -> std::convertible_to<NT_Type>;
+  typename TypeInfo<std::remove_cvref_t<T>>::Value;
+  typename TypeInfo<std::remove_cvref_t<T>>::View;
+};
+
+static_assert(ValidType<bool>);
+static_assert(!ValidType<uint8_t>);
+static_assert(ValidType<uint8_t[]>);
+static_assert(ValidType<std::vector<uint8_t>>);
+
+template <ValidType T, NT_Type type>
+constexpr bool IsNTType = TypeInfo<std::remove_cvref_t<T>>::kType == type;
+
+static_assert(IsNTType<bool, NT_BOOLEAN>);
+static_assert(!IsNTType<bool, NT_DOUBLE>);
+
+template <typename T>
+concept ArrayType =
+    requires { typename TypeInfo<std::remove_cvref_t<T>>::ElementType; };
+
+static_assert(ArrayType<std::string[]>);
+static_assert(!ArrayType<uint8_t[]>);
+
+template <typename T>
+concept SmallArrayType = requires {
+  typename TypeInfo<std::remove_cvref_t<T>>::SmallRet;
+  typename TypeInfo<std::remove_cvref_t<T>>::SmallElem;
+};
+
+static_assert(SmallArrayType<float[]>);
+static_assert(!SmallArrayType<std::string[]>);
+
+template <typename T>
+concept NumericType =
+    IsNTType<T, NT_INTEGER> || IsNTType<T, NT_FLOAT> || IsNTType<T, NT_DOUBLE>;
+
+static_assert(NumericType<int64_t>);
+static_assert(NumericType<float>);
+static_assert(NumericType<double>);
+static_assert(!NumericType<bool>);
+static_assert(!NumericType<std::string>);
+static_assert(!NumericType<int64_t[]>);
+static_assert(!NumericType<double[]>);
+static_assert(!NumericType<bool[]>);
+static_assert(!NumericType<uint8_t[]>);
+
+template <typename T>
+concept NumericArrayType =
+    ArrayType<T> &&
+    NumericType<typename TypeInfo<std::remove_cvref_t<T>>::ElementType>;
+
+static_assert(NumericArrayType<int64_t[]>);
+static_assert(NumericArrayType<float[]>);
+static_assert(NumericArrayType<double[]>);
+static_assert(!NumericArrayType<bool[]>);
+
+template <NumericType T>
+inline typename TypeInfo<T>::Value GetNumericAs(const Value& value) {
+  if (value.IsInteger()) {
+    return static_cast<typename TypeInfo<T>::Value>(value.GetInteger());
+  } else if (value.IsFloat()) {
+    return static_cast<typename TypeInfo<T>::Value>(value.GetFloat());
+  } else if (value.IsDouble()) {
+    return static_cast<typename TypeInfo<T>::Value>(value.GetDouble());
+  } else {
+    return {};
+  }
+}
+
+template <NumericArrayType T>
+typename TypeInfo<T>::Value GetNumericArrayAs(const Value& value) {
+  if (value.IsIntegerArray()) {
+    auto arr = value.GetIntegerArray();
+    return {arr.begin(), arr.end()};
+  } else if (value.IsFloatArray()) {
+    auto arr = value.GetFloatArray();
+    return {arr.begin(), arr.end()};
+  } else if (value.IsDoubleArray()) {
+    auto arr = value.GetDoubleArray();
+    return {arr.begin(), arr.end()};
+  } else {
+    return {};
+  }
+}
+
+template <ValidType T>
+inline bool IsType(const Value& value) {
+  return value.type() == TypeInfo<T>::kType;
+}
+
+template <ValidType T>
+inline bool IsNumericConvertibleTo(const Value& value) {
+  if constexpr (NumericType<T>) {
+    return value.IsInteger() || value.IsFloat() || value.IsDouble();
+  } else if constexpr (NumericArrayType<T>) {
+    return value.IsIntegerArray() || value.IsFloatArray() ||
+           value.IsDoubleArray();
+  } else {
+    return false;
+  }
+}
+
+template <ValidType T>
+inline typename TypeInfo<T>::View GetValueView(const Value& value) {
+  if constexpr (IsNTType<T, NT_BOOLEAN>) {
+    return value.GetBoolean();
+  } else if constexpr (IsNTType<T, NT_INTEGER>) {
+    return value.GetInteger();
+  } else if constexpr (IsNTType<T, NT_FLOAT>) {
+    return value.GetFloat();
+  } else if constexpr (IsNTType<T, NT_DOUBLE>) {
+    return value.GetDouble();
+  } else if constexpr (IsNTType<T, NT_STRING>) {
+    return value.GetString();
+  } else if constexpr (IsNTType<T, NT_RAW>) {
+    return value.GetRaw();
+  } else if constexpr (IsNTType<T, NT_BOOLEAN_ARRAY>) {
+    return value.GetBooleanArray();
+  } else if constexpr (IsNTType<T, NT_INTEGER_ARRAY>) {
+    return value.GetIntegerArray();
+  } else if constexpr (IsNTType<T, NT_FLOAT_ARRAY>) {
+    return value.GetFloatArray();
+  } else if constexpr (IsNTType<T, NT_DOUBLE_ARRAY>) {
+    return value.GetDoubleArray();
+  } else if constexpr (IsNTType<T, NT_STRING_ARRAY>) {
+    return value.GetStringArray();
+  }
+}
+
+template <ValidType T>
+inline Value MakeValue(typename TypeInfo<T>::View value, int64_t time) {
+  if constexpr (IsNTType<T, NT_BOOLEAN>) {
+    return Value::MakeBoolean(value, time);
+  } else if constexpr (IsNTType<T, NT_INTEGER>) {
+    return Value::MakeInteger(value, time);
+  } else if constexpr (IsNTType<T, NT_FLOAT>) {
+    return Value::MakeFloat(value, time);
+  } else if constexpr (IsNTType<T, NT_DOUBLE>) {
+    return Value::MakeDouble(value, time);
+  } else if constexpr (IsNTType<T, NT_STRING>) {
+    return Value::MakeString(value, time);
+  } else if constexpr (IsNTType<T, NT_RAW>) {
+    return Value::MakeRaw(value, time);
+  } else if constexpr (IsNTType<T, NT_BOOLEAN_ARRAY>) {
+    return Value::MakeBooleanArray(value, time);
+  } else if constexpr (IsNTType<T, NT_INTEGER_ARRAY>) {
+    return Value::MakeIntegerArray(value, time);
+  } else if constexpr (IsNTType<T, NT_FLOAT_ARRAY>) {
+    return Value::MakeFloatArray(value, time);
+  } else if constexpr (IsNTType<T, NT_DOUBLE_ARRAY>) {
+    return Value::MakeDoubleArray(value, time);
+  } else if constexpr (IsNTType<T, NT_STRING_ARRAY>) {
+    return Value::MakeStringArray(value, time);
+  }
+}
+
+template <ValidType T>
+  requires ArrayType<T> || IsNTType<T, NT_STRING> || IsNTType<T, NT_RAW>
+inline Value MakeValue(typename TypeInfo<T>::Value&& value, int64_t time) {
+  if constexpr (IsNTType<T, NT_STRING>) {
+    return Value::MakeString(value, time);
+  } else if constexpr (IsNTType<T, NT_RAW>) {
+    return Value::MakeRaw(value, time);
+  } else if constexpr (IsNTType<T, NT_BOOLEAN_ARRAY>) {
+    return Value::MakeBooleanArray(value, time);
+  } else if constexpr (IsNTType<T, NT_INTEGER_ARRAY>) {
+    return Value::MakeIntegerArray(value, time);
+  } else if constexpr (IsNTType<T, NT_FLOAT_ARRAY>) {
+    return Value::MakeFloatArray(value, time);
+  } else if constexpr (IsNTType<T, NT_DOUBLE_ARRAY>) {
+    return Value::MakeDoubleArray(value, time);
+  } else if constexpr (IsNTType<T, NT_STRING_ARRAY>) {
+    return Value::MakeStringArray(value, time);
+  }
+}
+
+template <ValidType T>
+inline typename TypeInfo<T>::Value CopyValue(typename TypeInfo<T>::View value) {
+  if constexpr (ArrayType<T> || IsNTType<T, NT_RAW>) {
+    return {value.begin(), value.end()};
+  } else if constexpr (IsNTType<T, NT_STRING>) {
+    return std::string{value};
+  } else {
+    return value;
+  }
+}
+
+template <SmallArrayType T>
+inline typename TypeInfo<T>::SmallRet CopyValue(
+    typename TypeInfo<T>::View arr,
+    wpi::SmallVectorImpl<typename TypeInfo<T>::SmallElem>& buf) {
+  buf.assign(arr.begin(), arr.end());
+  return {buf.data(), buf.size()};
+}
+
+template <ValidType T, bool ConvertNumeric>
+inline typename TypeInfo<T>::Value GetValueCopy(const Value& value) {
+  if constexpr (ConvertNumeric && NumericType<T>) {
+    return GetNumericAs<T>(value);
+  } else if constexpr (ConvertNumeric && NumericArrayType<T>) {
+    return GetNumericArrayAs<T>(value);
+  } else {
+    return CopyValue<T>(GetValueView<T>(value));
+  }
+}
+
+template <SmallArrayType T, bool ConvertNumeric>
+inline typename TypeInfo<T>::SmallRet GetValueCopy(
+    const Value& value,
+    wpi::SmallVectorImpl<typename TypeInfo<T>::SmallElem>& buf) {
+  if constexpr (ConvertNumeric && NumericArrayType<T>) {
+    if (value.IsIntegerArray()) {
+      auto arr = value.GetIntegerArray();
+      buf.assign(arr.begin(), arr.end());
+      return {buf.data(), buf.size()};
+    } else if (value.IsFloatArray()) {
+      auto arr = value.GetFloatArray();
+      buf.assign(arr.begin(), arr.end());
+      return {buf.data(), buf.size()};
+    } else if (value.IsDoubleArray()) {
+      auto arr = value.GetDoubleArray();
+      buf.assign(arr.begin(), arr.end());
+      return {buf.data(), buf.size()};
+    } else {
+      return {};
+    }
+  } else {
+    return CopyValue<T>(GetValueView<T>(value), buf);
+  }
+}
+
+template <ValidType T, bool ConvertNumeric>
+inline Timestamped<typename TypeInfo<T>::Value> GetTimestamped(
+    const Value& value) {
+  return {value.time(), value.server_time(),
+          GetValueCopy<T, ConvertNumeric>(value)};
+}
+
+template <SmallArrayType T, bool ConvertNumeric>
+inline Timestamped<typename TypeInfo<T>::SmallRet> GetTimestamped(
+    const Value& value,
+    wpi::SmallVectorImpl<typename TypeInfo<T>::SmallElem>& buf) {
+  return {value.time(), value.server_time(),
+          GetValueCopy<T, ConvertNumeric>(value, buf)};
+}
 
 template <typename T>
 inline void ConvertToC(const T& in, T* out) {
@@ -57,35 +450,6 @@
   return out;
 }
 
-template <typename T>
-T GetNumericAs(const Value& value) {
-  if (value.IsInteger()) {
-    return static_cast<T>(value.GetInteger());
-  } else if (value.IsFloat()) {
-    return static_cast<T>(value.GetFloat());
-  } else if (value.IsDouble()) {
-    return static_cast<T>(value.GetDouble());
-  } else {
-    return {};
-  }
-}
-
-template <typename T>
-std::vector<T> GetNumericArrayAs(const Value& value) {
-  if (value.IsIntegerArray()) {
-    auto arr = value.GetIntegerArray();
-    return {arr.begin(), arr.end()};
-  } else if (value.IsFloatArray()) {
-    auto arr = value.GetFloatArray();
-    return {arr.begin(), arr.end()};
-  } else if (value.IsDoubleArray()) {
-    auto arr = value.GetDoubleArray();
-    return {arr.begin(), arr.end()};
-  } else {
-    return {};
-  }
-}
-
 Value ConvertNumericValue(const Value& value, NT_Type type);
 
 }  // namespace nt
diff --git a/ntcore/src/main/native/cpp/VectorSet.h b/ntcore/src/main/native/cpp/VectorSet.h
new file mode 100644
index 0000000..9e13490
--- /dev/null
+++ b/ntcore/src/main/native/cpp/VectorSet.h
@@ -0,0 +1,21 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <vector>
+
+namespace nt {
+
+// Utility wrapper for making a set-like vector
+template <typename T>
+class VectorSet : public std::vector<T> {
+ public:
+  using iterator = typename std::vector<T>::iterator;
+  void Add(T value) { this->push_back(value); }
+  // returns true if element was present
+  bool Remove(T value) { return std::erase(*this, value) != 0; }
+};
+
+}  // namespace nt
diff --git a/ntcore/src/main/native/cpp/jni/NetworkTablesJNI.cpp b/ntcore/src/main/native/cpp/jni/NetworkTablesJNI.cpp
index b868604..14b5df1 100644
--- a/ntcore/src/main/native/cpp/jni/NetworkTablesJNI.cpp
+++ b/ntcore/src/main/native/cpp/jni/NetworkTablesJNI.cpp
@@ -31,7 +31,6 @@
 //
 
 // Used for callback.
-static JavaVM* jvm = nullptr;
 static JClass booleanCls;
 static JClass connectionInfoCls;
 static JClass doubleCls;
@@ -72,8 +71,6 @@
 extern "C" {
 
 JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
-  jvm = vm;
-
   JNIEnv* env;
   if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
     return JNI_ERR;
@@ -114,7 +111,6 @@
     c.cls->free(env);
   }
   nt::JNI_UnloadTypes(env);
-  jvm = nullptr;
 }
 
 }  // extern "C"
@@ -723,7 +719,7 @@
 {
   wpi::json j;
   try {
-    j = wpi::json::parse(JStringRef{env, value});
+    j = wpi::json::parse(std::string_view{JStringRef{env, value}});
   } catch (wpi::json::parse_error& err) {
     illegalArgEx.Throw(
         env, fmt::format("could not parse value JSON: {}", err.what()));
@@ -767,7 +763,7 @@
 {
   wpi::json j;
   try {
-    j = wpi::json::parse(JStringRef{env, properties});
+    j = wpi::json::parse(std::string_view{JStringRef{env, properties}});
   } catch (wpi::json::parse_error& err) {
     illegalArgEx.Throw(
         env, fmt::format("could not parse properties JSON: {}", err.what()));
@@ -832,7 +828,7 @@
 {
   wpi::json j;
   try {
-    j = wpi::json::parse(JStringRef{env, properties});
+    j = wpi::json::parse(std::string_view{JStringRef{env, properties}});
   } catch (wpi::json::parse_error& err) {
     illegalArgEx.Throw(
         env, fmt::format("could not parse properties JSON: {}", err.what()));
@@ -1262,7 +1258,7 @@
                        "serverNames and ports arrays must be the same size");
     return;
   }
-  jint* portInts = env->GetIntArrayElements(ports, nullptr);
+  JSpan<const jint> portInts{env, ports};
   if (!portInts) {
     return;
   }
@@ -1282,7 +1278,6 @@
     servers.emplace_back(
         std::make_pair(std::string_view{names.back()}, portInts[i]));
   }
-  env->ReleaseIntArrayElements(ports, portInts, JNI_ABORT);
   nt::SetServer(inst, servers);
 }
 
@@ -1300,6 +1295,18 @@
 
 /*
  * Class:     edu_wpi_first_networktables_NetworkTablesJNI
+ * Method:    disconnect
+ * Signature: (I)V
+ */
+JNIEXPORT void JNICALL
+Java_edu_wpi_first_networktables_NetworkTablesJNI_disconnect
+  (JNIEnv* env, jclass, jint inst)
+{
+  nt::Disconnect(inst);
+}
+
+/*
+ * Class:     edu_wpi_first_networktables_NetworkTablesJNI
  * Method:    startDSClient
  * Signature: (II)V
  */
diff --git a/ntcore/src/main/native/cpp/net/ClientImpl.cpp b/ntcore/src/main/native/cpp/net/ClientImpl.cpp
index 2efbb29..309b01a 100644
--- a/ntcore/src/main/native/cpp/net/ClientImpl.cpp
+++ b/ntcore/src/main/native/cpp/net/ClientImpl.cpp
@@ -10,7 +10,6 @@
 #include <variant>
 
 #include <fmt/format.h>
-#include <wpi/DenseMap.h>
 #include <wpi/Logger.h>
 #include <wpi/raw_ostream.h>
 #include <wpi/timestamp.h>
@@ -19,94 +18,15 @@
 #include "Log.h"
 #include "Message.h"
 #include "NetworkInterface.h"
-#include "PubSubOptions.h"
 #include "WireConnection.h"
-#include "WireDecoder.h"
 #include "WireEncoder.h"
+#include "net/NetworkOutgoingQueue.h"
 #include "networktables/NetworkTableValue.h"
 
 using namespace nt;
 using namespace nt::net;
 
-static constexpr uint32_t kMinPeriodMs = 5;
-
-// maximum number of times the wire can be not ready to send another
-// transmission before we close the connection
-static constexpr int kWireMaxNotReady = 10;
-
-namespace {
-
-struct PublisherData {
-  NT_Publisher handle;
-  PubSubOptionsImpl options;
-  // in options as double, but copy here as integer; rounded to the nearest
-  // 10 ms
-  uint32_t periodMs;
-  uint64_t nextSendMs{0};
-  std::vector<Value> outValues;  // outgoing values
-};
-
-class CImpl : public ServerMessageHandler {
- public:
-  CImpl(uint64_t curTimeMs, int inst, WireConnection& wire, wpi::Logger& logger,
-        std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
-            timeSyncUpdated,
-        std::function<void(uint32_t repeatMs)> setPeriodic);
-
-  void ProcessIncomingBinary(std::span<const uint8_t> data);
-  void HandleLocal(std::vector<ClientMessage>&& msgs);
-  bool SendControl(uint64_t curTimeMs);
-  void SendValues(uint64_t curTimeMs);
-  void SendInitialValues();
-  bool CheckNetworkReady();
-
-  // ServerMessageHandler interface
-  void ServerAnnounce(std::string_view name, int64_t id,
-                      std::string_view typeStr, const wpi::json& properties,
-                      std::optional<int64_t> pubuid) final;
-  void ServerUnannounce(std::string_view name, int64_t id) final;
-  void ServerPropertiesUpdate(std::string_view name, const wpi::json& update,
-                              bool ack) final;
-
-  void Publish(NT_Publisher pubHandle, NT_Topic topicHandle,
-               std::string_view name, std::string_view typeStr,
-               const wpi::json& properties, const PubSubOptionsImpl& options);
-  bool Unpublish(NT_Publisher pubHandle, NT_Topic topicHandle);
-  void SetValue(NT_Publisher pubHandle, const Value& value);
-
-  int m_inst;
-  WireConnection& m_wire;
-  wpi::Logger& m_logger;
-  LocalInterface* m_local{nullptr};
-  std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
-      m_timeSyncUpdated;
-  std::function<void(uint32_t repeatMs)> m_setPeriodic;
-
-  // indexed by publisher index
-  std::vector<std::unique_ptr<PublisherData>> m_publishers;
-
-  // indexed by server-provided topic id
-  wpi::DenseMap<int64_t, NT_Topic> m_topicMap;
-
-  // timestamp handling
-  static constexpr uint32_t kPingIntervalMs = 3000;
-  uint64_t m_nextPingTimeMs{0};
-  uint32_t m_rtt2Us{UINT32_MAX};
-  bool m_haveTimeOffset{false};
-  int64_t m_serverTimeOffsetUs{0};
-
-  // periodic sweep handling
-  uint32_t m_periodMs{kPingIntervalMs + 10};
-  uint64_t m_lastSendMs{0};
-  int m_notReadyCount{0};
-
-  // outgoing queue
-  std::vector<ClientMessage> m_outgoing;
-};
-
-}  // namespace
-
-CImpl::CImpl(
+ClientImpl::ClientImpl(
     uint64_t curTimeMs, int inst, WireConnection& wire, wpi::Logger& logger,
     std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
         timeSyncUpdated,
@@ -116,17 +36,21 @@
       m_logger{logger},
       m_timeSyncUpdated{std::move(timeSyncUpdated)},
       m_setPeriodic{std::move(setPeriodic)},
-      m_nextPingTimeMs{curTimeMs + kPingIntervalMs} {
+      m_ping{wire},
+      m_nextPingTimeMs{curTimeMs + (wire.GetVersion() >= 0x0401
+                                        ? NetworkPing::kPingIntervalMs
+                                        : kRttIntervalMs)},
+      m_outgoing{wire, false} {
   // immediately send RTT ping
-  auto out = m_wire.SendBinary();
   auto now = wpi::Now();
   DEBUG4("Sending initial RTT ping {}", now);
-  WireEncodeBinary(out.Add(), -1, 0, Value::MakeInteger(now));
-  m_wire.Flush();
+  m_wire.SendBinary(
+      [&](auto& os) { WireEncodeBinary(os, -1, 0, Value::MakeInteger(now)); });
   m_setPeriodic(m_periodMs);
 }
 
-void CImpl::ProcessIncomingBinary(std::span<const uint8_t> data) {
+void ClientImpl::ProcessIncomingBinary(uint64_t curTimeMs,
+                                       std::span<const uint8_t> data) {
   for (;;) {
     if (data.empty()) {
       break;
@@ -136,29 +60,34 @@
     int64_t id;
     Value value;
     std::string error;
-    if (!WireDecodeBinary(&data, &id, &value, &error, -m_serverTimeOffsetUs)) {
-      ERROR("binary decode error: {}", error);
+    if (!WireDecodeBinary(&data, &id, &value, &error,
+                          -m_outgoing.GetTimeOffset())) {
+      ERR("binary decode error: {}", error);
       break;  // FIXME
     }
     DEBUG4("BinaryMessage({})", id);
 
-    // handle RTT ping response
-    if (id == -1) {
+    // handle RTT ping response (only use first one)
+    if (!m_haveTimeOffset && id == -1) {
       if (!value.IsInteger()) {
-        WARNING("RTT ping response with non-integer type {}",
-                static_cast<int>(value.type()));
+        WARN("RTT ping response with non-integer type {}",
+             static_cast<int>(value.type()));
         continue;
       }
       DEBUG4("RTT ping response time {} value {}", value.time(),
              value.GetInteger());
+      if (m_wire.GetVersion() < 0x0401) {
+        m_pongTimeMs = curTimeMs;
+      }
       int64_t now = wpi::Now();
       int64_t rtt2 = (now - value.GetInteger()) / 2;
       if (rtt2 < m_rtt2Us) {
         m_rtt2Us = rtt2;
-        m_serverTimeOffsetUs = value.server_time() + rtt2 - now;
-        DEBUG3("Time offset: {}", m_serverTimeOffsetUs);
+        int64_t serverTimeOffsetUs = value.server_time() + rtt2 - now;
+        DEBUG3("Time offset: {}", serverTimeOffsetUs);
+        m_outgoing.SetTimeOffset(serverTimeOffsetUs);
         m_haveTimeOffset = true;
-        m_timeSyncUpdated(m_serverTimeOffsetUs, m_rtt2Us, true);
+        m_timeSyncUpdated(serverTimeOffsetUs, m_rtt2Us, true);
       }
       continue;
     }
@@ -166,7 +95,7 @@
     // otherwise it's a value message, get the local topic handle for it
     auto topicIt = m_topicMap.find(id);
     if (topicIt == m_topicMap.end()) {
-      WARNING("received unknown id {}", id);
+      WARN("received unknown id {}", id);
       continue;
     }
 
@@ -177,152 +106,77 @@
   }
 }
 
-void CImpl::HandleLocal(std::vector<ClientMessage>&& msgs) {
+void ClientImpl::HandleLocal(std::vector<ClientMessage>&& msgs) {
   DEBUG4("HandleLocal()");
   for (auto&& elem : msgs) {
     // common case is value
     if (auto msg = std::get_if<ClientValueMsg>(&elem.contents)) {
       SetValue(msg->pubHandle, msg->value);
-      // setvalue puts on individual publish outgoing queues
     } else if (auto msg = std::get_if<PublishMsg>(&elem.contents)) {
       Publish(msg->pubHandle, msg->topicHandle, msg->name, msg->typeStr,
               msg->properties, msg->options);
-      m_outgoing.emplace_back(std::move(elem));
+      m_outgoing.SendMessage(msg->pubHandle, std::move(elem));
     } else if (auto msg = std::get_if<UnpublishMsg>(&elem.contents)) {
       if (Unpublish(msg->pubHandle, msg->topicHandle)) {
-        m_outgoing.emplace_back(std::move(elem));
+        m_outgoing.SendMessage(msg->pubHandle, std::move(elem));
       }
     } else {
-      m_outgoing.emplace_back(std::move(elem));
+      m_outgoing.SendMessage(0, std::move(elem));
     }
   }
 }
 
-bool CImpl::SendControl(uint64_t curTimeMs) {
-  DEBUG4("SendControl({})", curTimeMs);
+void ClientImpl::SendOutgoing(uint64_t curTimeMs, bool flush) {
+  DEBUG4("SendOutgoing({}, {})", curTimeMs, flush);
 
-  // rate limit sends
-  if (curTimeMs < (m_lastSendMs + kMinPeriodMs)) {
-    return false;
-  }
-
-  // start a timestamp RTT ping if it's time to do one
-  if (curTimeMs >= m_nextPingTimeMs) {
-    if (!CheckNetworkReady()) {
-      return false;
+  if (m_wire.GetVersion() >= 0x0401) {
+    // Use WS pings
+    if (!m_ping.Send(curTimeMs)) {
+      return;
     }
-    auto now = wpi::Now();
-    DEBUG4("Sending RTT ping {}", now);
-    WireEncodeBinary(m_wire.SendBinary().Add(), -1, 0, Value::MakeInteger(now));
-    // drift isn't critical here, so just go from current time
-    m_nextPingTimeMs = curTimeMs + kPingIntervalMs;
-  }
-
-  if (!m_outgoing.empty()) {
-    if (!CheckNetworkReady()) {
-      return false;
-    }
-    auto writer = m_wire.SendText();
-    for (auto&& msg : m_outgoing) {
-      auto& stream = writer.Add();
-      if (!WireEncodeText(stream, msg)) {
-        // shouldn't happen, but just in case...
-        stream << "{}";
+  } else {
+    // Use RTT pings; it's unsafe to use WS pings due to bugs in WS message
+    // fragmentation in earlier NT4 implementations
+    if (curTimeMs >= m_nextPingTimeMs) {
+      // if we didn't receive a response to our last ping, disconnect
+      if (m_nextPingTimeMs != 0 && m_pongTimeMs == 0) {
+        m_wire.Disconnect("connection timed out");
+        return;
       }
+
+      auto now = wpi::Now();
+      DEBUG4("Sending RTT ping {}", now);
+      m_wire.SendBinary([&](auto& os) {
+        WireEncodeBinary(os, -1, 0, Value::MakeInteger(now));
+      });
+      // drift isn't critical here, so just go from current time
+      m_nextPingTimeMs = curTimeMs + kRttIntervalMs;
+      m_pongTimeMs = 0;
     }
-    m_outgoing.resize(0);
   }
 
-  m_lastSendMs = curTimeMs;
-  return true;
-}
-
-void CImpl::SendValues(uint64_t curTimeMs) {
-  DEBUG4("SendValues({})", curTimeMs);
-
-  // can't send value updates until we have a RTT
+  // wait until we have a RTT measurement before sending messages
   if (!m_haveTimeOffset) {
     return;
   }
 
-  // ensure all control messages are sent ahead of value updates
-  if (!SendControl(curTimeMs)) {
-    return;
-  }
-
-  // send any pending updates due to be sent
-  bool checkedNetwork = false;
-  auto writer = m_wire.SendBinary();
-  for (auto&& pub : m_publishers) {
-    if (pub && !pub->outValues.empty() && curTimeMs >= pub->nextSendMs) {
-      for (auto&& val : pub->outValues) {
-        if (!checkedNetwork) {
-          if (!CheckNetworkReady()) {
-            return;
-          }
-          checkedNetwork = true;
-        }
-        DEBUG4("Sending {} value time={} server_time={} st_off={}", pub->handle,
-               val.time(), val.server_time(), m_serverTimeOffsetUs);
-        int64_t time = val.time();
-        if (time != 0) {
-          time += m_serverTimeOffsetUs;
-        }
-        WireEncodeBinary(writer.Add(), Handle{pub->handle}.GetIndex(), time,
-                         val);
-      }
-      pub->outValues.resize(0);
-      pub->nextSendMs = curTimeMs + pub->periodMs;
-    }
-  }
+  m_outgoing.SendOutgoing(curTimeMs, flush);
 }
 
-void CImpl::SendInitialValues() {
-  DEBUG4("SendInitialValues()");
-
-  // ensure all control messages are sent ahead of value updates
-  if (!SendControl(0)) {
-    return;
+void ClientImpl::UpdatePeriodic() {
+  if (m_periodMs < kMinPeriodMs) {
+    m_periodMs = kMinPeriodMs;
   }
-
-  // only send time=0 values (as we don't have a RTT yet)
-  auto writer = m_wire.SendBinary();
-  for (auto&& pub : m_publishers) {
-    if (pub && !pub->outValues.empty()) {
-      bool sent = false;
-      for (auto&& val : pub->outValues) {
-        if (val.server_time() == 0) {
-          DEBUG4("Sending {} value time={} server_time={}", pub->handle,
-                 val.time(), val.server_time());
-          WireEncodeBinary(writer.Add(), Handle{pub->handle}.GetIndex(), 0,
-                           val);
-          sent = true;
-        }
-      }
-      if (sent) {
-        std::erase_if(pub->outValues,
-                      [](const auto& v) { return v.server_time() == 0; });
-      }
-    }
+  if (m_periodMs > kMaxPeriodMs) {
+    m_periodMs = kMaxPeriodMs;
   }
+  m_setPeriodic(m_periodMs);
 }
 
-bool CImpl::CheckNetworkReady() {
-  if (!m_wire.Ready()) {
-    ++m_notReadyCount;
-    if (m_notReadyCount > kWireMaxNotReady) {
-      m_wire.Disconnect("transmit stalled");
-    }
-    return false;
-  }
-  m_notReadyCount = 0;
-  return true;
-}
-
-void CImpl::Publish(NT_Publisher pubHandle, NT_Topic topicHandle,
-                    std::string_view name, std::string_view typeStr,
-                    const wpi::json& properties,
-                    const PubSubOptionsImpl& options) {
+void ClientImpl::Publish(NT_Publisher pubHandle, NT_Topic topicHandle,
+                         std::string_view name, std::string_view typeStr,
+                         const wpi::json& properties,
+                         const PubSubOptionsImpl& options) {
   unsigned int index = Handle{pubHandle}.GetIndex();
   if (index >= m_publishers.size()) {
     m_publishers.resize(index + 1);
@@ -337,74 +191,53 @@
   if (publisher->periodMs < kMinPeriodMs) {
     publisher->periodMs = kMinPeriodMs;
   }
+  m_outgoing.SetPeriod(pubHandle, publisher->periodMs);
 
   // update period
-  m_periodMs = std::gcd(m_periodMs, publisher->periodMs);
-  if (m_periodMs < kMinPeriodMs) {
-    m_periodMs = kMinPeriodMs;
-  }
-  m_setPeriodic(m_periodMs);
+  m_periodMs = UpdatePeriodCalc(m_periodMs, publisher->periodMs);
+  UpdatePeriodic();
 }
 
-bool CImpl::Unpublish(NT_Publisher pubHandle, NT_Topic topicHandle) {
+bool ClientImpl::Unpublish(NT_Publisher pubHandle, NT_Topic topicHandle) {
   unsigned int index = Handle{pubHandle}.GetIndex();
   if (index >= m_publishers.size()) {
     return false;
   }
   bool doSend = true;
-  if (m_publishers[index]) {
-    // Look through outgoing queue to see if the publish hasn't been sent yet;
-    // if it hasn't, delete it and don't send the server a message.
-    // The outgoing queue doesn't contain values; those are deleted with the
-    // publisher object.
-    auto it = std::find_if(
-        m_outgoing.begin(), m_outgoing.end(), [&](const auto& elem) {
-          if (auto msg = std::get_if<PublishMsg>(&elem.contents)) {
-            return msg->pubHandle == pubHandle;
-          }
-          return false;
-        });
-    if (it != m_outgoing.end()) {
-      m_outgoing.erase(it);
-      doSend = false;
-    }
-  }
   m_publishers[index].reset();
 
   // loop over all publishers to update period
-  m_periodMs = kPingIntervalMs + 10;
+  m_periodMs = kMaxPeriodMs;
   for (auto&& pub : m_publishers) {
     if (pub) {
       m_periodMs = std::gcd(m_periodMs, pub->periodMs);
     }
   }
-  if (m_periodMs < kMinPeriodMs) {
-    m_periodMs = kMinPeriodMs;
-  }
-  m_setPeriodic(m_periodMs);
+  UpdatePeriodic();
+
+  // remove from outgoing handle map
+  m_outgoing.EraseHandle(pubHandle);
 
   return doSend;
 }
 
-void CImpl::SetValue(NT_Publisher pubHandle, const Value& value) {
-  DEBUG4("SetValue({}, time={}, server_time={}, st_off={})", pubHandle,
-         value.time(), value.server_time(), m_serverTimeOffsetUs);
+void ClientImpl::SetValue(NT_Publisher pubHandle, const Value& value) {
+  DEBUG4("SetValue({}, time={}, server_time={})", pubHandle, value.time(),
+         value.server_time());
   unsigned int index = Handle{pubHandle}.GetIndex();
   if (index >= m_publishers.size() || !m_publishers[index]) {
     return;
   }
   auto& publisher = *m_publishers[index];
-  if (publisher.outValues.empty() || publisher.options.sendAll) {
-    publisher.outValues.emplace_back(value);
-  } else {
-    publisher.outValues.back() = value;
-  }
+  m_outgoing.SendValue(
+      pubHandle, value,
+      publisher.options.sendAll ? ValueSendMode::kAll : ValueSendMode::kNormal);
 }
 
-void CImpl::ServerAnnounce(std::string_view name, int64_t id,
-                           std::string_view typeStr,
-                           const wpi::json& properties,
-                           std::optional<int64_t> pubuid) {
+void ClientImpl::ServerAnnounce(std::string_view name, int64_t id,
+                                std::string_view typeStr,
+                                const wpi::json& properties,
+                                std::optional<int64_t> pubuid) {
   DEBUG4("ServerAnnounce({}, {}, {})", name, id, typeStr);
   assert(m_local);
   NT_Publisher pubHandle{0};
@@ -415,75 +248,25 @@
       m_local->NetworkAnnounce(name, typeStr, properties, pubHandle);
 }
 
-void CImpl::ServerUnannounce(std::string_view name, int64_t id) {
+void ClientImpl::ServerUnannounce(std::string_view name, int64_t id) {
   DEBUG4("ServerUnannounce({}, {})", name, id);
   assert(m_local);
   m_local->NetworkUnannounce(name);
   m_topicMap.erase(id);
 }
 
-void CImpl::ServerPropertiesUpdate(std::string_view name,
-                                   const wpi::json& update, bool ack) {
+void ClientImpl::ServerPropertiesUpdate(std::string_view name,
+                                        const wpi::json& update, bool ack) {
   DEBUG4("ServerProperties({}, {}, {})", name, update.dump(), ack);
   assert(m_local);
   m_local->NetworkPropertiesUpdate(name, update, ack);
 }
 
-class ClientImpl::Impl final : public CImpl {
- public:
-  Impl(uint64_t curTimeMs, int inst, WireConnection& wire, wpi::Logger& logger,
-       std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
-           timeSyncUpdated,
-       std::function<void(uint32_t repeatMs)> setPeriodic)
-      : CImpl{curTimeMs,
-              inst,
-              wire,
-              logger,
-              std::move(timeSyncUpdated),
-              std::move(setPeriodic)} {}
-};
-
-ClientImpl::ClientImpl(
-    uint64_t curTimeMs, int inst, WireConnection& wire, wpi::Logger& logger,
-    std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
-        timeSyncUpdated,
-    std::function<void(uint32_t repeatMs)> setPeriodic)
-    : m_impl{std::make_unique<Impl>(curTimeMs, inst, wire, logger,
-                                    std::move(timeSyncUpdated),
-                                    std::move(setPeriodic))} {}
-
-ClientImpl::~ClientImpl() = default;
-
 void ClientImpl::ProcessIncomingText(std::string_view data) {
-  if (!m_impl->m_local) {
+  if (!m_local) {
     return;
   }
-  WireDecodeText(data, *m_impl, m_impl->m_logger);
+  WireDecodeText(data, *this, m_logger);
 }
 
-void ClientImpl::ProcessIncomingBinary(std::span<const uint8_t> data) {
-  m_impl->ProcessIncomingBinary(data);
-}
-
-void ClientImpl::HandleLocal(std::vector<ClientMessage>&& msgs) {
-  m_impl->HandleLocal(std::move(msgs));
-}
-
-void ClientImpl::SendControl(uint64_t curTimeMs) {
-  m_impl->SendControl(curTimeMs);
-  m_impl->m_wire.Flush();
-}
-
-void ClientImpl::SendValues(uint64_t curTimeMs) {
-  m_impl->SendValues(curTimeMs);
-  m_impl->m_wire.Flush();
-}
-
-void ClientImpl::SetLocal(LocalInterface* local) {
-  m_impl->m_local = local;
-}
-
-void ClientImpl::SendInitial() {
-  m_impl->SendInitialValues();
-  m_impl->m_wire.Flush();
-}
+void ClientImpl::SendInitial() {}
diff --git a/ntcore/src/main/native/cpp/net/ClientImpl.h b/ntcore/src/main/native/cpp/net/ClientImpl.h
index 0e7fd4a..b72ce38 100644
--- a/ntcore/src/main/native/cpp/net/ClientImpl.h
+++ b/ntcore/src/main/native/cpp/net/ClientImpl.h
@@ -13,8 +13,14 @@
 #include <string_view>
 #include <vector>
 
+#include <wpi/DenseMap.h>
+
 #include "NetworkInterface.h"
+#include "NetworkOutgoingQueue.h"
+#include "NetworkPing.h"
+#include "PubSubOptions.h"
 #include "WireConnection.h"
+#include "WireDecoder.h"
 
 namespace wpi {
 class Logger;
@@ -30,28 +36,79 @@
 struct ClientMessage;
 class WireConnection;
 
-class ClientImpl {
+class ClientImpl final : private ServerMessageHandler {
  public:
   ClientImpl(
       uint64_t curTimeMs, int inst, WireConnection& wire, wpi::Logger& logger,
       std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
           timeSyncUpdated,
       std::function<void(uint32_t repeatMs)> setPeriodic);
-  ~ClientImpl();
 
   void ProcessIncomingText(std::string_view data);
-  void ProcessIncomingBinary(std::span<const uint8_t> data);
+  void ProcessIncomingBinary(uint64_t curTimeMs, std::span<const uint8_t> data);
   void HandleLocal(std::vector<ClientMessage>&& msgs);
 
-  void SendControl(uint64_t curTimeMs);
-  void SendValues(uint64_t curTimeMs);
+  void SendOutgoing(uint64_t curTimeMs, bool flush);
 
-  void SetLocal(LocalInterface* local);
+  void SetLocal(LocalInterface* local) { m_local = local; }
   void SendInitial();
 
  private:
-  class Impl;
-  std::unique_ptr<Impl> m_impl;
+  struct PublisherData {
+    NT_Publisher handle;
+    PubSubOptionsImpl options;
+    // in options as double, but copy here as integer; rounded to the nearest
+    // 10 ms
+    uint32_t periodMs;
+  };
+
+  void UpdatePeriodic();
+
+  // ServerMessageHandler interface
+  void ServerAnnounce(std::string_view name, int64_t id,
+                      std::string_view typeStr, const wpi::json& properties,
+                      std::optional<int64_t> pubuid) final;
+  void ServerUnannounce(std::string_view name, int64_t id) final;
+  void ServerPropertiesUpdate(std::string_view name, const wpi::json& update,
+                              bool ack) final;
+
+  void Publish(NT_Publisher pubHandle, NT_Topic topicHandle,
+               std::string_view name, std::string_view typeStr,
+               const wpi::json& properties, const PubSubOptionsImpl& options);
+  bool Unpublish(NT_Publisher pubHandle, NT_Topic topicHandle);
+  void SetValue(NT_Publisher pubHandle, const Value& value);
+
+  int m_inst;
+  WireConnection& m_wire;
+  wpi::Logger& m_logger;
+  LocalInterface* m_local{nullptr};
+  std::function<void(int64_t serverTimeOffset, int64_t rtt2, bool valid)>
+      m_timeSyncUpdated;
+  std::function<void(uint32_t repeatMs)> m_setPeriodic;
+
+  // indexed by publisher index
+  std::vector<std::unique_ptr<PublisherData>> m_publishers;
+
+  // indexed by server-provided topic id
+  wpi::DenseMap<int64_t, NT_Topic> m_topicMap;
+
+  // ping
+  NetworkPing m_ping;
+
+  // timestamp handling
+  static constexpr uint32_t kRttIntervalMs = 3000;
+  uint64_t m_nextPingTimeMs{0};
+  uint64_t m_pongTimeMs{0};
+  uint32_t m_rtt2Us{UINT32_MAX};
+  bool m_haveTimeOffset{false};
+
+  // periodic sweep handling
+  static constexpr uint32_t kMinPeriodMs = 5;
+  static constexpr uint32_t kMaxPeriodMs = NetworkPing::kPingIntervalMs;
+  uint32_t m_periodMs{kMaxPeriodMs};
+
+  // outgoing queue
+  NetworkOutgoingQueue<ClientMessage> m_outgoing;
 };
 
 }  // namespace nt::net
diff --git a/ntcore/src/main/native/cpp/net/Message.h b/ntcore/src/main/native/cpp/net/Message.h
index a95c5e8..d2a02b0 100644
--- a/ntcore/src/main/native/cpp/net/Message.h
+++ b/ntcore/src/main/native/cpp/net/Message.h
@@ -17,6 +17,11 @@
 
 namespace nt::net {
 
+#if __GNUC__ >= 13
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wmaybe-uninitialized"
+#endif
+
 struct PublishMsg {
   static constexpr std::string_view kMethodStr = "publish";
   NT_Publisher pubHandle{0};
@@ -57,10 +62,15 @@
   Value value;
 };
 
+#if __GNUC__ >= 13
+#pragma GCC diagnostic pop
+#endif
+
 struct ClientMessage {
   using Contents =
       std::variant<std::monostate, PublishMsg, UnpublishMsg, SetPropertiesMsg,
                    SubscribeMsg, UnsubscribeMsg, ClientValueMsg>;
+  using ValueMsg = ClientValueMsg;
   Contents contents;
 };
 
@@ -94,6 +104,7 @@
 struct ServerMessage {
   using Contents = std::variant<std::monostate, AnnounceMsg, UnannounceMsg,
                                 PropertiesUpdateMsg, ServerValueMsg>;
+  using ValueMsg = ServerValueMsg;
   Contents contents;
 };
 
diff --git a/ntcore/src/main/native/cpp/net/NetworkInterface.h b/ntcore/src/main/native/cpp/net/NetworkInterface.h
index 3b2e7dd..4c9be54 100644
--- a/ntcore/src/main/native/cpp/net/NetworkInterface.h
+++ b/ntcore/src/main/native/cpp/net/NetworkInterface.h
@@ -8,11 +8,9 @@
 #include <string>
 #include <string_view>
 
-#include "ntcore_cpp.h"
+#include <wpi/json_fwd.h>
 
-namespace wpi {
-class json;
-}  // namespace wpi
+#include "ntcore_cpp.h"
 
 namespace nt {
 class PubSubOptionsImpl;
diff --git a/ntcore/src/main/native/cpp/net/NetworkLoopQueue.cpp b/ntcore/src/main/native/cpp/net/NetworkLoopQueue.cpp
index 7c58c65..944524a 100644
--- a/ntcore/src/main/native/cpp/net/NetworkLoopQueue.cpp
+++ b/ntcore/src/main/native/cpp/net/NetworkLoopQueue.cpp
@@ -12,37 +12,7 @@
 
 void NetworkLoopQueue::SetValue(NT_Publisher pubHandle, const Value& value) {
   std::scoped_lock lock{m_mutex};
-  switch (value.type()) {
-    case NT_STRING:
-      m_size += value.GetString().size();  // imperfect but good enough
-      break;
-    case NT_RAW:
-      m_size += value.GetRaw().size_bytes();
-      break;
-    case NT_BOOLEAN_ARRAY:
-      m_size += value.GetBooleanArray().size_bytes();
-      break;
-    case NT_INTEGER_ARRAY:
-      m_size += value.GetIntegerArray().size_bytes();
-      break;
-    case NT_FLOAT_ARRAY:
-      m_size += value.GetFloatArray().size_bytes();
-      break;
-    case NT_DOUBLE_ARRAY:
-      m_size += value.GetDoubleArray().size_bytes();
-      break;
-    case NT_STRING_ARRAY: {
-      auto arr = value.GetStringArray();
-      m_size += arr.size_bytes();
-      for (auto&& s : arr) {
-        m_size += s.capacity();
-      }
-      break;
-    }
-    default:
-      break;
-  }
-  m_size += sizeof(ClientMessage);
+  m_size += sizeof(ClientMessage) + value.size();
   if (m_size > kMaxSize) {
     if (!m_sizeErrored) {
       WPI_ERROR(m_logger, "NT: dropping value set due to memory limits");
diff --git a/ntcore/src/main/native/cpp/net/NetworkOutgoingQueue.h b/ntcore/src/main/native/cpp/net/NetworkOutgoingQueue.h
new file mode 100644
index 0000000..81c2b2e
--- /dev/null
+++ b/ntcore/src/main/native/cpp/net/NetworkOutgoingQueue.h
@@ -0,0 +1,342 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <stdint.h>
+
+#include <algorithm>
+#include <concepts>
+#include <numeric>
+#include <optional>
+#include <span>
+#include <utility>
+#include <vector>
+
+#include <wpi/DenseMap.h>
+
+#include "Handle.h"
+#include "Message.h"
+#include "WireConnection.h"
+#include "WireEncoder.h"
+#include "networktables/NetworkTableValue.h"
+#include "ntcore_c.h"
+
+namespace nt::net {
+
+static constexpr uint32_t kMinPeriodMs = 5;
+
+inline uint32_t UpdatePeriodCalc(uint32_t period, uint32_t aPeriod) {
+  uint32_t newPeriod;
+  if (period == UINT32_MAX) {
+    newPeriod = aPeriod;
+  } else {
+    newPeriod = std::gcd(period, aPeriod);
+  }
+  if (newPeriod < kMinPeriodMs) {
+    return kMinPeriodMs;
+  }
+  return newPeriod;
+}
+
+template <typename T, typename F>
+uint32_t CalculatePeriod(const T& container, F&& getPeriod) {
+  uint32_t period = UINT32_MAX;
+  for (auto&& item : container) {
+    if (period == UINT32_MAX) {
+      period = getPeriod(item);
+    } else {
+      period = std::gcd(period, getPeriod(item));
+    }
+  }
+  if (period < kMinPeriodMs) {
+    return kMinPeriodMs;
+  }
+  return period;
+}
+
+template <typename MessageType>
+concept NetworkMessage =
+    std::same_as<typename MessageType::ValueMsg, ServerValueMsg> ||
+    std::same_as<typename MessageType::ValueMsg, ClientValueMsg>;
+
+enum class ValueSendMode { kDisabled = 0, kAll, kNormal, kImm };
+
+template <NetworkMessage MessageType>
+class NetworkOutgoingQueue {
+ public:
+  NetworkOutgoingQueue(WireConnection& wire, bool local)
+      : m_wire{wire}, m_local{local} {
+    m_queues.emplace_back(100);  // default queue is 100 ms period
+  }
+
+  void SetPeriod(NT_Handle handle, uint32_t periodMs);
+
+  void EraseHandle(NT_Handle handle) { m_handleMap.erase(handle); }
+
+  template <typename T>
+  void SendMessage(NT_Handle handle, T&& msg) {
+    m_queues[m_handleMap[handle].queueIndex].Append(handle,
+                                                    std::forward<T>(msg));
+    m_totalSize += sizeof(Message);
+  }
+
+  void SendValue(NT_Handle handle, const Value& value, ValueSendMode mode);
+
+  void SendOutgoing(uint64_t curTimeMs, bool flush);
+
+  void SetTimeOffset(int64_t offsetUs) { m_timeOffsetUs = offsetUs; }
+  int64_t GetTimeOffset() const { return m_timeOffsetUs; }
+
+ public:
+  WireConnection& m_wire;
+
+ private:
+  using ValueMsg = typename MessageType::ValueMsg;
+
+  void EncodeValue(wpi::raw_ostream& os, NT_Handle handle, const Value& value);
+
+  struct Message {
+    Message() = default;
+    template <typename T>
+    Message(T&& msg, NT_Handle handle)
+        : msg{std::forward<T>(msg)}, handle{handle} {}
+
+    MessageType msg;
+    NT_Handle handle;
+  };
+
+  struct Queue {
+    explicit Queue(uint32_t periodMs) : periodMs{periodMs} {}
+    template <typename T>
+    void Append(NT_Handle handle, T&& msg) {
+      msgs.emplace_back(std::forward<T>(msg), handle);
+    }
+    std::vector<Message> msgs;
+    uint64_t nextSendMs = 0;
+    uint32_t periodMs;
+  };
+
+  std::vector<Queue> m_queues;
+
+  struct HandleInfo {
+    unsigned int queueIndex = 0;
+    int valuePos = -1;  // -1 if not in queue
+  };
+  wpi::DenseMap<NT_Handle, HandleInfo> m_handleMap;
+  size_t m_totalSize{0};
+  uint64_t m_lastSendMs{0};
+  int64_t m_timeOffsetUs{0};
+  unsigned int m_lastSetPeriodQueueIndex = 0;
+  unsigned int m_lastSetPeriod = 100;
+  bool m_local;
+
+  // maximum total size of outgoing queues in bytes (approximate)
+  static constexpr size_t kOutgoingLimit = 1024 * 1024;
+};
+
+template <NetworkMessage MessageType>
+void NetworkOutgoingQueue<MessageType>::SetPeriod(NT_Handle handle,
+                                                  uint32_t periodMs) {
+  // it's quite common to set a lot of things in a row with the same period
+  unsigned int queueIndex;
+  if (m_lastSetPeriod == periodMs) {
+    queueIndex = m_lastSetPeriodQueueIndex;
+  } else {
+    // find and possibly create queue for this period
+    auto it =
+        std::find_if(m_queues.begin(), m_queues.end(),
+                     [&](const auto& q) { return q.periodMs == periodMs; });
+    if (it == m_queues.end()) {
+      queueIndex = m_queues.size();
+      m_queues.emplace_back(periodMs);
+    } else {
+      queueIndex = it - m_queues.begin();
+    }
+    m_lastSetPeriodQueueIndex = queueIndex;
+    m_lastSetPeriod = periodMs;
+  }
+
+  // map the handle to the queue
+  auto [infoIt, created] = m_handleMap.try_emplace(handle);
+  if (!created && infoIt->getSecond().queueIndex != queueIndex) {
+    // need to move any items from old queue to new queue
+    auto& oldMsgs = m_queues[infoIt->getSecond().queueIndex].msgs;
+    auto it = std::stable_partition(
+        oldMsgs.begin(), oldMsgs.end(),
+        [&](const auto& e) { return e.handle != handle; });
+    auto& newMsgs = m_queues[queueIndex].msgs;
+    for (auto i = it, end = oldMsgs.end(); i != end; ++i) {
+      newMsgs.emplace_back(std::move(*i));
+    }
+    oldMsgs.erase(it, oldMsgs.end());
+  }
+
+  infoIt->getSecond().queueIndex = queueIndex;
+}
+
+template <NetworkMessage MessageType>
+void NetworkOutgoingQueue<MessageType>::SendValue(NT_Handle handle,
+                                                  const Value& value,
+                                                  ValueSendMode mode) {
+  if (m_local) {
+    mode = ValueSendMode::kImm;  // always send local immediately
+  }
+  // backpressure by stopping sending all if the buffer is too full
+  if (mode == ValueSendMode::kAll && m_totalSize >= kOutgoingLimit) {
+    mode = ValueSendMode::kNormal;
+  }
+  switch (mode) {
+    case ValueSendMode::kDisabled:  // do nothing
+      break;
+    case ValueSendMode::kImm:  // send immediately
+      m_wire.SendBinary([&](auto& os) { EncodeValue(os, handle, value); });
+      break;
+    case ValueSendMode::kAll: {  // append to outgoing
+      auto& info = m_handleMap[handle];
+      auto& queue = m_queues[info.queueIndex];
+      info.valuePos = queue.msgs.size();
+      queue.Append(handle, ValueMsg{handle, value});
+      m_totalSize += sizeof(Message) + value.size();
+      break;
+    }
+    case ValueSendMode::kNormal: {
+      // replace, or append if not present
+      auto& info = m_handleMap[handle];
+      auto& queue = m_queues[info.queueIndex];
+      if (info.valuePos != -1 &&
+          static_cast<unsigned int>(info.valuePos) < queue.msgs.size()) {
+        auto& elem = queue.msgs[info.valuePos];
+        if (auto m = std::get_if<ValueMsg>(&elem.msg.contents)) {
+          // double-check handle, and only replace if timestamp newer
+          if (elem.handle == handle &&
+              (m->value.time() == 0 || value.time() >= m->value.time())) {
+            int delta = value.size() - m->value.size();
+            m->value = value;
+            m_totalSize += delta;
+            return;
+          }
+        }
+      }
+      info.valuePos = queue.msgs.size();
+      queue.Append(handle, ValueMsg{handle, value});
+      m_totalSize += sizeof(Message) + value.size();
+      break;
+    }
+  }
+}
+
+template <NetworkMessage MessageType>
+void NetworkOutgoingQueue<MessageType>::SendOutgoing(uint64_t curTimeMs,
+                                                     bool flush) {
+  if (m_totalSize == 0) {
+    return;  // nothing to do
+  }
+
+  // rate limit frequency of transmissions
+  if (curTimeMs < (m_lastSendMs + kMinPeriodMs)) {
+    return;
+  }
+
+  if (!m_wire.Ready()) {
+    return;  // don't bother, still sending the last batch
+  }
+
+  // what queues are ready to send?
+  wpi::SmallVector<unsigned int, 16> queues;
+  for (unsigned int i = 0; i < m_queues.size(); ++i) {
+    if (!m_queues[i].msgs.empty() &&
+        (flush || curTimeMs >= m_queues[i].nextSendMs)) {
+      queues.emplace_back(i);
+    }
+  }
+  if (queues.empty()) {
+    return;  // nothing needs to be sent yet
+  }
+
+  // Sort transmission order by what queue has been waiting the longest time.
+  // XXX: byte-weighted fair queueing might be better, but is much more complex
+  // to implement.
+  std::sort(queues.begin(), queues.end(), [&](const auto& a, const auto& b) {
+    return m_queues[a].nextSendMs < m_queues[b].nextSendMs;
+  });
+
+  for (unsigned int queueIndex : queues) {
+    auto& queue = m_queues[queueIndex];
+    auto& msgs = queue.msgs;
+    auto it = msgs.begin();
+    auto end = msgs.end();
+    int unsent = 0;
+    for (; it != end && unsent == 0; ++it) {
+      if (auto m = std::get_if<ValueMsg>(&it->msg.contents)) {
+        unsent = m_wire.WriteBinary(
+            [&](auto& os) { EncodeValue(os, it->handle, m->value); });
+      } else {
+        unsent = m_wire.WriteText([&](auto& os) {
+          if (!WireEncodeText(os, it->msg)) {
+            os << "{}";
+          }
+        });
+      }
+    }
+    if (unsent < 0) {
+      return;  // error
+    }
+    if (unsent == 0) {
+      // finish writing any partial buffers
+      unsent = m_wire.Flush();
+      if (unsent < 0) {
+        return;  // error
+      }
+    }
+    int delta = it - msgs.begin() - unsent;
+    for (auto&& msg : std::span{msgs}.subspan(0, delta)) {
+      if (auto m = std::get_if<ValueMsg>(&msg.msg.contents)) {
+        m_totalSize -= sizeof(Message) + m->value.size();
+      } else {
+        m_totalSize -= sizeof(Message);
+      }
+    }
+    msgs.erase(msgs.begin(), it - unsent);
+    for (auto&& kv : m_handleMap) {
+      auto& info = kv.getSecond();
+      if (info.queueIndex == queueIndex) {
+        if (info.valuePos < delta) {
+          info.valuePos = -1;
+        } else {
+          info.valuePos -= delta;
+        }
+      }
+    }
+
+    // try to stay on periodic timing, unless it's falling behind current time
+    if (unsent == 0) {
+      queue.nextSendMs += queue.periodMs;
+      if (queue.nextSendMs < curTimeMs) {
+        queue.nextSendMs = curTimeMs + queue.periodMs;
+      }
+    }
+  }
+
+  m_lastSendMs = curTimeMs;
+}
+
+template <NetworkMessage MessageType>
+void NetworkOutgoingQueue<MessageType>::EncodeValue(wpi::raw_ostream& os,
+                                                    NT_Handle handle,
+                                                    const Value& value) {
+  int64_t time = value.time();
+  if constexpr (std::same_as<ValueMsg, ClientValueMsg>) {
+    if (time != 0) {
+      time += m_timeOffsetUs;
+      // make sure resultant time isn't exactly 0
+      if (time == 0) {
+        time = 1;
+      }
+    }
+  }
+  WireEncodeBinary(os, Handle{handle}.GetIndex(), time, value);
+}
+
+}  // namespace nt::net
diff --git a/ntcore/src/main/native/cpp/net/NetworkPing.cpp b/ntcore/src/main/native/cpp/net/NetworkPing.cpp
new file mode 100644
index 0000000..fdbd26c
--- /dev/null
+++ b/ntcore/src/main/native/cpp/net/NetworkPing.cpp
@@ -0,0 +1,30 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "NetworkPing.h"
+
+#include "WireConnection.h"
+
+using namespace nt::net;
+
+bool NetworkPing::Send(uint64_t curTimeMs) {
+  if (curTimeMs < m_nextPingTimeMs) {
+    return true;
+  }
+  // if we didn't receive a timely response to our last ping, disconnect
+  uint64_t lastPing = m_wire.GetLastPingResponse();
+  // DEBUG4("WS ping: lastPing={} curTime={} pongTimeMs={}\n", lastPing,
+  //        curTimeMs, m_pongTimeMs);
+  if (lastPing == 0) {
+    lastPing = m_pongTimeMs;
+  }
+  if (m_pongTimeMs != 0 && curTimeMs > (lastPing + kPingTimeoutMs)) {
+    m_wire.Disconnect("connection timed out");
+    return false;
+  }
+  m_wire.SendPing(curTimeMs);
+  m_nextPingTimeMs = curTimeMs + kPingIntervalMs;
+  m_pongTimeMs = curTimeMs;
+  return true;
+}
diff --git a/ntcore/src/main/native/cpp/net/NetworkPing.h b/ntcore/src/main/native/cpp/net/NetworkPing.h
new file mode 100644
index 0000000..304e01f
--- /dev/null
+++ b/ntcore/src/main/native/cpp/net/NetworkPing.h
@@ -0,0 +1,28 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <stdint.h>
+
+namespace nt::net {
+
+class WireConnection;
+
+class NetworkPing {
+ public:
+  static constexpr uint32_t kPingIntervalMs = 200;
+  static constexpr uint32_t kPingTimeoutMs = 1000;
+
+  explicit NetworkPing(WireConnection& wire) : m_wire{wire} {}
+
+  bool Send(uint64_t curTimeMs);
+
+ private:
+  WireConnection& m_wire;
+  uint64_t m_nextPingTimeMs{0};
+  uint64_t m_pongTimeMs{0};
+};
+
+}  // namespace nt::net
diff --git a/ntcore/src/main/native/cpp/net/ServerImpl.cpp b/ntcore/src/main/native/cpp/net/ServerImpl.cpp
index d9b1c0c..1119809 100644
--- a/ntcore/src/main/native/cpp/net/ServerImpl.cpp
+++ b/ntcore/src/main/native/cpp/net/ServerImpl.cpp
@@ -14,30 +14,19 @@
 #include <vector>
 
 #include <wpi/Base64.h>
-#include <wpi/DenseMap.h>
 #include <wpi/MessagePack.h>
 #include <wpi/SmallVector.h>
 #include <wpi/StringExtras.h>
-#include <wpi/StringMap.h>
-#include <wpi/UidVector.h>
 #include <wpi/json.h>
-#include <wpi/json_serializer.h>
 #include <wpi/raw_ostream.h>
 #include <wpi/timestamp.h>
 
 #include "IConnectionList.h"
 #include "Log.h"
-#include "Message.h"
 #include "NetworkInterface.h"
-#include "PubSubOptions.h"
 #include "Types_internal.h"
-#include "WireConnection.h"
-#include "WireDecoder.h"
-#include "WireEncoder.h"
-#include "net3/Message3.h"
-#include "net3/SequenceNumber.h"
+#include "net/WireEncoder.h"
 #include "net3/WireConnection3.h"
-#include "net3/WireDecoder3.h"
 #include "net3/WireEncoder3.h"
 #include "networktables/NetworkTableValue.h"
 #include "ntcore_c.h"
@@ -46,405 +35,11 @@
 using namespace nt::net;
 using namespace mpack;
 
-static constexpr uint32_t kMinPeriodMs = 5;
-
-// maximum number of times the wire can be not ready to send another
+// maximum amount of time the wire can be not ready to send another
 // transmission before we close the connection
-static constexpr int kWireMaxNotReady = 10;
+static constexpr uint32_t kWireMaxNotReadyUs = 1000000;
 
 namespace {
-
-// Utility wrapper for making a set-like vector
-template <typename T>
-class VectorSet : public std::vector<T> {
- public:
-  using iterator = typename std::vector<T>::iterator;
-  void Add(T value) { this->push_back(value); }
-  // returns true if element was present
-  bool Remove(T value) {
-    auto removeIt = std::remove(this->begin(), this->end(), value);
-    if (removeIt == this->end()) {
-      return false;
-    }
-    this->erase(removeIt, this->end());
-    return true;
-  }
-};
-
-struct PublisherData;
-struct SubscriberData;
-struct TopicData;
-class SImpl;
-
-class ClientData {
- public:
-  ClientData(std::string_view originalName, std::string_view name,
-             std::string_view connInfo, bool local,
-             ServerImpl::SetPeriodicFunc setPeriodic, SImpl& server, int id,
-             wpi::Logger& logger)
-      : m_originalName{originalName},
-        m_name{name},
-        m_connInfo{connInfo},
-        m_local{local},
-        m_setPeriodic{std::move(setPeriodic)},
-        m_server{server},
-        m_id{id},
-        m_logger{logger} {}
-  virtual ~ClientData() = default;
-
-  virtual void ProcessIncomingText(std::string_view data) = 0;
-  virtual void ProcessIncomingBinary(std::span<const uint8_t> data) = 0;
-
-  enum SendMode { kSendDisabled = 0, kSendAll, kSendNormal, kSendImmNoFlush };
-
-  virtual void SendValue(TopicData* topic, const Value& value,
-                         SendMode mode) = 0;
-  virtual void SendAnnounce(TopicData* topic,
-                            std::optional<int64_t> pubuid) = 0;
-  virtual void SendUnannounce(TopicData* topic) = 0;
-  virtual void SendPropertiesUpdate(TopicData* topic, const wpi::json& update,
-                                    bool ack) = 0;
-  virtual void SendOutgoing(uint64_t curTimeMs) = 0;
-  virtual void Flush() = 0;
-
-  void UpdateMetaClientPub();
-  void UpdateMetaClientSub();
-
-  std::span<SubscriberData*> GetSubscribers(
-      std::string_view name, bool special,
-      wpi::SmallVectorImpl<SubscriberData*>& buf);
-
-  std::string_view GetOriginalName() const { return m_originalName; }
-  std::string_view GetName() const { return m_name; }
-  int GetId() const { return m_id; }
-
- protected:
-  std::string m_originalName;
-  std::string m_name;
-  std::string m_connInfo;
-  bool m_local;  // local to machine
-  ServerImpl::SetPeriodicFunc m_setPeriodic;
-  // TODO: make this per-topic?
-  uint32_t m_periodMs{UINT32_MAX};
-  uint64_t m_lastSendMs{0};
-  SImpl& m_server;
-  int m_id;
-
-  wpi::Logger& m_logger;
-
-  wpi::DenseMap<int64_t, std::unique_ptr<PublisherData>> m_publishers;
-  wpi::DenseMap<int64_t, std::unique_ptr<SubscriberData>> m_subscribers;
-
- public:
-  // meta topics
-  TopicData* m_metaPub = nullptr;
-  TopicData* m_metaSub = nullptr;
-};
-
-class ClientData4Base : public ClientData, protected ClientMessageHandler {
- public:
-  ClientData4Base(std::string_view originalName, std::string_view name,
-                  std::string_view connInfo, bool local,
-                  ServerImpl::SetPeriodicFunc setPeriodic, SImpl& server,
-                  int id, wpi::Logger& logger)
-      : ClientData{originalName, name,   connInfo, local,
-                   setPeriodic,  server, id,       logger} {}
-
- protected:
-  // ClientMessageHandler interface
-  void ClientPublish(int64_t pubuid, std::string_view name,
-                     std::string_view typeStr,
-                     const wpi::json& properties) final;
-  void ClientUnpublish(int64_t pubuid) final;
-  void ClientSetProperties(std::string_view name,
-                           const wpi::json& update) final;
-  void ClientSubscribe(int64_t subuid, std::span<const std::string> topicNames,
-                       const PubSubOptionsImpl& options) final;
-  void ClientUnsubscribe(int64_t subuid) final;
-
-  void ClientSetValue(int64_t pubuid, const Value& value);
-
-  wpi::DenseMap<TopicData*, bool> m_announceSent;
-};
-
-class ClientDataLocal final : public ClientData4Base {
- public:
-  ClientDataLocal(SImpl& server, int id, wpi::Logger& logger)
-      : ClientData4Base{"", "", "", true, [](uint32_t) {}, server, id, logger} {
-  }
-
-  void ProcessIncomingText(std::string_view data) final {}
-  void ProcessIncomingBinary(std::span<const uint8_t> data) final {}
-
-  void SendValue(TopicData* topic, const Value& value, SendMode mode) final;
-  void SendAnnounce(TopicData* topic, std::optional<int64_t> pubuid) final;
-  void SendUnannounce(TopicData* topic) final;
-  void SendPropertiesUpdate(TopicData* topic, const wpi::json& update,
-                            bool ack) final;
-  void SendOutgoing(uint64_t curTimeMs) final {}
-  void Flush() final {}
-
-  void HandleLocal(std::span<const ClientMessage> msgs);
-};
-
-class ClientData4 final : public ClientData4Base {
- public:
-  ClientData4(std::string_view originalName, std::string_view name,
-              std::string_view connInfo, bool local, WireConnection& wire,
-              ServerImpl::SetPeriodicFunc setPeriodic, SImpl& server, int id,
-              wpi::Logger& logger)
-      : ClientData4Base{originalName, name,   connInfo, local,
-                        setPeriodic,  server, id,       logger},
-        m_wire{wire} {}
-
-  void ProcessIncomingText(std::string_view data) final;
-  void ProcessIncomingBinary(std::span<const uint8_t> data) final;
-
-  void SendValue(TopicData* topic, const Value& value, SendMode mode) final;
-  void SendAnnounce(TopicData* topic, std::optional<int64_t> pubuid) final;
-  void SendUnannounce(TopicData* topic) final;
-  void SendPropertiesUpdate(TopicData* topic, const wpi::json& update,
-                            bool ack) final;
-  void SendOutgoing(uint64_t curTimeMs) final;
-
-  void Flush() final;
-
- public:
-  WireConnection& m_wire;
-
- private:
-  std::vector<ServerMessage> m_outgoing;
-  int m_notReadyCount{0};
-
-  bool WriteBinary(int64_t id, int64_t time, const Value& value) {
-    return WireEncodeBinary(SendBinary().Add(), id, time, value);
-  }
-
-  TextWriter& SendText() {
-    m_outBinary.reset();  // ensure proper interleaving of text and binary
-    if (!m_outText) {
-      m_outText = m_wire.SendText();
-    }
-    return *m_outText;
-  }
-
-  BinaryWriter& SendBinary() {
-    m_outText.reset();  // ensure proper interleaving of text and binary
-    if (!m_outBinary) {
-      m_outBinary = m_wire.SendBinary();
-    }
-    return *m_outBinary;
-  }
-
-  // valid when we are actively writing to this client
-  std::optional<TextWriter> m_outText;
-  std::optional<BinaryWriter> m_outBinary;
-};
-
-class ClientData3 final : public ClientData, private net3::MessageHandler3 {
- public:
-  ClientData3(std::string_view connInfo, bool local,
-              net3::WireConnection3& wire, ServerImpl::Connected3Func connected,
-              ServerImpl::SetPeriodicFunc setPeriodic, SImpl& server, int id,
-              wpi::Logger& logger)
-      : ClientData{"", "", connInfo, local, setPeriodic, server, id, logger},
-        m_connected{std::move(connected)},
-        m_wire{wire},
-        m_decoder{*this} {}
-
-  void ProcessIncomingText(std::string_view data) final {}
-  void ProcessIncomingBinary(std::span<const uint8_t> data) final;
-
-  void SendValue(TopicData* topic, const Value& value, SendMode mode) final;
-  void SendAnnounce(TopicData* topic, std::optional<int64_t> pubuid) final;
-  void SendUnannounce(TopicData* topic) final;
-  void SendPropertiesUpdate(TopicData* topic, const wpi::json& update,
-                            bool ack) final;
-  void SendOutgoing(uint64_t curTimeMs) final;
-
-  void Flush() final { m_wire.Flush(); }
-
- private:
-  // MessageHandler3 interface
-  void KeepAlive() final;
-  void ServerHelloDone() final;
-  void ClientHelloDone() final;
-  void ClearEntries() final;
-  void ProtoUnsup(unsigned int proto_rev) final;
-  void ClientHello(std::string_view self_id, unsigned int proto_rev) final;
-  void ServerHello(unsigned int flags, std::string_view self_id) final;
-  void EntryAssign(std::string_view name, unsigned int id, unsigned int seq_num,
-                   const Value& value, unsigned int flags) final;
-  void EntryUpdate(unsigned int id, unsigned int seq_num,
-                   const Value& value) final;
-  void FlagsUpdate(unsigned int id, unsigned int flags) final;
-  void EntryDelete(unsigned int id) final;
-  void ExecuteRpc(unsigned int id, unsigned int uid,
-                  std::span<const uint8_t> params) final {}
-  void RpcResponse(unsigned int id, unsigned int uid,
-                   std::span<const uint8_t> result) final {}
-
-  ServerImpl::Connected3Func m_connected;
-  net3::WireConnection3& m_wire;
-
-  enum State { kStateInitial, kStateServerHelloComplete, kStateRunning };
-  State m_state{kStateInitial};
-  net3::WireDecoder3 m_decoder;
-
-  std::vector<net3::Message3> m_outgoing;
-  int64_t m_nextPubUid{1};
-  int m_notReadyCount{0};
-
-  struct TopicData3 {
-    explicit TopicData3(TopicData* topic) { UpdateFlags(topic); }
-
-    unsigned int flags{0};
-    net3::SequenceNumber seqNum;
-    bool sentAssign{false};
-    bool published{false};
-    int64_t pubuid{0};
-
-    bool UpdateFlags(TopicData* topic);
-  };
-  wpi::DenseMap<TopicData*, TopicData3> m_topics3;
-  TopicData3* GetTopic3(TopicData* topic) {
-    return &m_topics3.try_emplace(topic, topic).first->second;
-  }
-};
-
-struct TopicData {
-  TopicData(std::string_view name, std::string_view typeStr)
-      : name{name}, typeStr{typeStr} {}
-  TopicData(std::string_view name, std::string_view typeStr,
-            wpi::json properties)
-      : name{name}, typeStr{typeStr}, properties(std::move(properties)) {
-    RefreshProperties();
-  }
-
-  bool IsPublished() const {
-    return persistent || retained || !publishers.empty();
-  }
-
-  // returns true if properties changed
-  bool SetProperties(const wpi::json& update);
-  void RefreshProperties();
-  bool SetFlags(unsigned int flags_);
-
-  std::string name;
-  unsigned int id;
-  Value lastValue;
-  ClientData* lastValueClient = nullptr;
-  std::string typeStr;
-  wpi::json properties = wpi::json::object();
-  bool persistent{false};
-  bool retained{false};
-  bool special{false};
-  NT_Topic localHandle{0};
-
-  VectorSet<PublisherData*> publishers;
-  VectorSet<SubscriberData*> subscribers;
-
-  // meta topics
-  TopicData* metaPub = nullptr;
-  TopicData* metaSub = nullptr;
-};
-
-struct PublisherData {
-  PublisherData(ClientData* client, TopicData* topic, int64_t pubuid)
-      : client{client}, topic{topic}, pubuid{pubuid} {}
-
-  ClientData* client;
-  TopicData* topic;
-  int64_t pubuid;
-};
-
-struct SubscriberData {
-  SubscriberData(ClientData* client, std::span<const std::string> topicNames,
-                 int64_t subuid, const PubSubOptionsImpl& options)
-      : client{client},
-        topicNames{topicNames.begin(), topicNames.end()},
-        subuid{subuid},
-        options{options},
-        periodMs(std::lround(options.periodicMs / 10.0) * 10) {
-    if (periodMs < kMinPeriodMs) {
-      periodMs = kMinPeriodMs;
-    }
-  }
-
-  void Update(std::span<const std::string> topicNames_,
-              const PubSubOptionsImpl& options_) {
-    topicNames = {topicNames_.begin(), topicNames_.end()};
-    options = options_;
-    periodMs = std::lround(options_.periodicMs / 10.0) * 10;
-    if (periodMs < kMinPeriodMs) {
-      periodMs = kMinPeriodMs;
-    }
-  }
-
-  bool Matches(std::string_view name, bool special);
-
-  ClientData* client;
-  std::vector<std::string> topicNames;
-  int64_t subuid;
-  PubSubOptionsImpl options;
-  // in options as double, but copy here as integer; rounded to the nearest
-  // 10 ms
-  uint32_t periodMs;
-};
-
-class SImpl {
- public:
-  explicit SImpl(wpi::Logger& logger);
-
-  wpi::Logger& m_logger;
-  LocalInterface* m_local{nullptr};
-  bool m_controlReady{false};
-
-  ClientDataLocal* m_localClient;
-  std::vector<std::unique_ptr<ClientData>> m_clients;
-  wpi::UidVector<std::unique_ptr<TopicData>, 16> m_topics;
-  wpi::StringMap<TopicData*> m_nameTopics;
-  bool m_persistentChanged{false};
-
-  // global meta topics (other meta topics are linked to from the specific
-  // client or topic)
-  TopicData* m_metaClients;
-
-  // ServerImpl interface
-  std::pair<std::string, int> AddClient(
-      std::string_view name, std::string_view connInfo, bool local,
-      WireConnection& wire, ServerImpl::SetPeriodicFunc setPeriodic);
-  int AddClient3(std::string_view connInfo, bool local,
-                 net3::WireConnection3& wire,
-                 ServerImpl::Connected3Func connected,
-                 ServerImpl::SetPeriodicFunc setPeriodic);
-  void RemoveClient(int clientId);
-
-  bool PersistentChanged();
-  void DumpPersistent(wpi::raw_ostream& os);
-  std::string LoadPersistent(std::string_view in);
-
-  // helper functions
-  TopicData* CreateTopic(ClientData* client, std::string_view name,
-                         std::string_view typeStr, const wpi::json& properties,
-                         bool special = false);
-  TopicData* CreateMetaTopic(std::string_view name);
-  void DeleteTopic(TopicData* topic);
-  void SetProperties(ClientData* client, TopicData* topic,
-                     const wpi::json& update);
-  void SetFlags(ClientData* client, TopicData* topic, unsigned int flags);
-  void SetValue(ClientData* client, TopicData* topic, const Value& value);
-
-  // update meta topic values from data structures
-  void UpdateMetaClients(const std::vector<ConnectionInfo>& conns);
-  void UpdateMetaTopicPub(TopicData* topic);
-  void UpdateMetaTopicSub(TopicData* topic);
-
- private:
-  void PropertiesChanged(ClientData* client, TopicData* topic,
-                         const wpi::json& update);
-};
-
 struct Writer : public mpack_writer_t {
   Writer() {
     mpack_writer_init(this, buf, sizeof(buf));
@@ -486,19 +81,86 @@
   mpack_finish_map(&w);
 }
 
-void ClientData::UpdateMetaClientPub() {
+void ServerImpl::PublisherData::UpdateMeta() {
+  {
+    Writer w;
+    mpack_start_map(&w, 2);
+    mpack_write_str(&w, "uid");
+    mpack_write_int(&w, pubuid);
+    mpack_write_str(&w, "topic");
+    mpack_write_str(&w, topic->name);
+    mpack_finish_map(&w);
+    if (mpack_writer_destroy(&w) == mpack_ok) {
+      metaClient = std::move(w.bytes);
+    }
+  }
+  {
+    Writer w;
+    mpack_start_map(&w, 2);
+    mpack_write_str(&w, "client");
+    if (client) {
+      mpack_write_str(&w, client->GetName());
+    } else {
+      mpack_write_str(&w, "");
+    }
+    mpack_write_str(&w, "pubuid");
+    mpack_write_int(&w, pubuid);
+    mpack_finish_map(&w);
+    if (mpack_writer_destroy(&w) == mpack_ok) {
+      metaTopic = std::move(w.bytes);
+    }
+  }
+}
+
+void ServerImpl::SubscriberData::UpdateMeta() {
+  {
+    Writer w;
+    mpack_start_map(&w, 3);
+    mpack_write_str(&w, "uid");
+    mpack_write_int(&w, subuid);
+    mpack_write_str(&w, "topics");
+    mpack_start_array(&w, topicNames.size());
+    for (auto&& name : topicNames) {
+      mpack_write_str(&w, name);
+    }
+    mpack_finish_array(&w);
+    mpack_write_str(&w, "options");
+    WriteOptions(w, options);
+    mpack_finish_map(&w);
+    if (mpack_writer_destroy(&w) == mpack_ok) {
+      metaClient = std::move(w.bytes);
+    }
+  }
+  {
+    Writer w;
+    mpack_start_map(&w, 3);
+    mpack_write_str(&w, "client");
+    if (client) {
+      mpack_write_str(&w, client->GetName());
+    } else {
+      mpack_write_str(&w, "");
+    }
+    mpack_write_str(&w, "subuid");
+    mpack_write_int(&w, subuid);
+    mpack_write_str(&w, "options");
+    WriteOptions(w, options);
+    mpack_finish_map(&w);
+    if (mpack_writer_destroy(&w) == mpack_ok) {
+      metaTopic = std::move(w.bytes);
+    }
+  }
+}
+
+void ServerImpl::ClientData::UpdateMetaClientPub() {
   if (!m_metaPub) {
     return;
   }
   Writer w;
   mpack_start_array(&w, m_publishers.size());
   for (auto&& pub : m_publishers) {
-    mpack_start_map(&w, 2);
-    mpack_write_str(&w, "uid");
-    mpack_write_int(&w, pub.first);
-    mpack_write_str(&w, "topic");
-    mpack_write_str(&w, pub.second->topic->name);
-    mpack_finish_map(&w);
+    mpack_write_object_bytes(
+        &w, reinterpret_cast<const char*>(pub.second->metaClient.data()),
+        pub.second->metaClient.size());
   }
   mpack_finish_array(&w);
   if (mpack_writer_destroy(&w) == mpack_ok) {
@@ -506,25 +168,16 @@
   }
 }
 
-void ClientData::UpdateMetaClientSub() {
+void ServerImpl::ClientData::UpdateMetaClientSub() {
   if (!m_metaSub) {
     return;
   }
   Writer w;
   mpack_start_array(&w, m_subscribers.size());
   for (auto&& sub : m_subscribers) {
-    mpack_start_map(&w, 3);
-    mpack_write_str(&w, "uid");
-    mpack_write_int(&w, sub.first);
-    mpack_write_str(&w, "topics");
-    mpack_start_array(&w, sub.second->topicNames.size());
-    for (auto&& name : sub.second->topicNames) {
-      mpack_write_str(&w, name);
-    }
-    mpack_finish_array(&w);
-    mpack_write_str(&w, "options");
-    WriteOptions(w, sub.second->options);
-    mpack_finish_map(&w);
+    mpack_write_object_bytes(
+        &w, reinterpret_cast<const char*>(sub.second->metaClient.data()),
+        sub.second->metaClient.size());
   }
   mpack_finish_array(&w);
   if (mpack_writer_destroy(&w) == mpack_ok) {
@@ -532,7 +185,7 @@
   }
 }
 
-std::span<SubscriberData*> ClientData::GetSubscribers(
+std::span<ServerImpl::SubscriberData*> ServerImpl::ClientData::GetSubscribers(
     std::string_view name, bool special,
     wpi::SmallVectorImpl<SubscriberData*>& buf) {
   buf.resize(0);
@@ -545,9 +198,10 @@
   return {buf.data(), buf.size()};
 }
 
-void ClientData4Base::ClientPublish(int64_t pubuid, std::string_view name,
-                                    std::string_view typeStr,
-                                    const wpi::json& properties) {
+void ServerImpl::ClientData4Base::ClientPublish(int64_t pubuid,
+                                                std::string_view name,
+                                                std::string_view typeStr,
+                                                const wpi::json& properties) {
   DEBUG3("ClientPublish({}, {}, {}, {})", m_id, name, pubuid, typeStr);
   auto topic = m_server.CreateTopic(this, name, typeStr, properties);
 
@@ -555,22 +209,21 @@
   auto [publisherIt, isNew] = m_publishers.try_emplace(
       pubuid, std::make_unique<PublisherData>(this, topic, pubuid));
   if (!isNew) {
-    WARNING("client {} duplicate publish of pubuid {}", m_id, pubuid);
+    WARN("client {} duplicate publish of pubuid {}", m_id, pubuid);
   }
 
   // add publisher to topic
-  topic->publishers.Add(publisherIt->getSecond().get());
+  topic->AddPublisher(this, publisherIt->getSecond().get());
 
   // update meta data
   m_server.UpdateMetaTopicPub(topic);
-  UpdateMetaClientPub();
 
   // respond with announce with pubuid to client
   DEBUG4("client {}: announce {} pubuid {}", m_id, topic->name, pubuid);
   SendAnnounce(topic, pubuid);
 }
 
-void ClientData4Base::ClientUnpublish(int64_t pubuid) {
+void ServerImpl::ClientData4Base::ClientUnpublish(int64_t pubuid) {
   DEBUG3("ClientUnpublish({}, {})", m_id, pubuid);
   auto publisherIt = m_publishers.find(pubuid);
   if (publisherIt == m_publishers.end()) {
@@ -580,14 +233,13 @@
   auto topic = publisher->topic;
 
   // remove publisher from topic
-  topic->publishers.Remove(publisher);
+  topic->RemovePublisher(this, publisher);
 
   // remove publisher from client
   m_publishers.erase(publisherIt);
 
   // update meta data
   m_server.UpdateMetaTopicPub(topic);
-  UpdateMetaClientPub();
 
   // delete topic if no longer published
   if (!topic->IsPublished()) {
@@ -595,27 +247,30 @@
   }
 }
 
-void ClientData4Base::ClientSetProperties(std::string_view name,
-                                          const wpi::json& update) {
+void ServerImpl::ClientData4Base::ClientSetProperties(std::string_view name,
+                                                      const wpi::json& update) {
   DEBUG4("ClientSetProperties({}, {}, {})", m_id, name, update.dump());
   auto topicIt = m_server.m_nameTopics.find(name);
   if (topicIt == m_server.m_nameTopics.end() ||
       !topicIt->second->IsPublished()) {
-    DEBUG3("ignored SetProperties from {} on non-existent topic '{}'", m_id,
-           name);
+    WARN(
+        "server ignoring SetProperties({}) from client {} on unpublished topic "
+        "'{}'; publish or set a value first",
+        update.dump(), m_id, name);
     return;  // nothing to do
   }
   auto topic = topicIt->second;
   if (topic->special) {
-    DEBUG3("ignored SetProperties from {} on meta topic '{}'", m_id, name);
+    WARN("server ignoring SetProperties({}) from client {} on meta topic '{}'",
+         update.dump(), m_id, name);
     return;  // nothing to do
   }
   m_server.SetProperties(nullptr, topic, update);
 }
 
-void ClientData4Base::ClientSubscribe(int64_t subuid,
-                                      std::span<const std::string> topicNames,
-                                      const PubSubOptionsImpl& options) {
+void ServerImpl::ClientData4Base::ClientSubscribe(
+    int64_t subuid, std::span<const std::string> topicNames,
+    const PubSubOptionsImpl& options) {
   DEBUG4("ClientSubscribe({}, ({}), {})", m_id, fmt::join(topicNames, ","),
          subuid);
   auto& sub = m_subscribers[subuid];
@@ -636,63 +291,62 @@
 
   // update periodic sender (if not local)
   if (!m_local) {
-    if (m_periodMs == UINT32_MAX) {
-      m_periodMs = sub->periodMs;
-    } else {
-      m_periodMs = std::gcd(m_periodMs, sub->periodMs);
-    }
-    if (m_periodMs < kMinPeriodMs) {
-      m_periodMs = kMinPeriodMs;
-    }
+    m_periodMs = UpdatePeriodCalc(m_periodMs, sub->periodMs);
     m_setPeriodic(m_periodMs);
   }
 
   // see if this immediately subscribes to any topics
+  // for transmit efficiency, we want to batch announcements and values, so
+  // send announcements in first loop and remember what we want to send in
+  // second loop.
+  std::vector<TopicData*> dataToSend;
+  dataToSend.reserve(m_server.m_topics.size());
   for (auto&& topic : m_server.m_topics) {
-    bool removed = false;
-    if (replace) {
-      removed = topic->subscribers.Remove(sub.get());
-    }
+    auto tcdIt = topic->clients.find(this);
+    bool removed = tcdIt != topic->clients.end() && replace &&
+                   tcdIt->second.subscribers.erase(sub.get());
 
     // is client already subscribed?
-    bool wasSubscribed = false;
-    for (auto subscriber : topic->subscribers) {
-      if (subscriber->client == this) {
-        wasSubscribed = true;
-        break;
-      }
-    }
+    bool wasSubscribed =
+        tcdIt != topic->clients.end() && !tcdIt->second.subscribers.empty();
+    bool wasSubscribedValue =
+        wasSubscribed ? tcdIt->second.sendMode != ValueSendMode::kDisabled
+                      : false;
 
     bool added = false;
     if (sub->Matches(topic->name, topic->special)) {
-      topic->subscribers.Add(sub.get());
+      if (tcdIt == topic->clients.end()) {
+        tcdIt = topic->clients.try_emplace(this).first;
+      }
+      tcdIt->second.AddSubscriber(sub.get());
       added = true;
     }
 
     if (added ^ removed) {
+      UpdatePeriod(tcdIt->second, topic.get());
       m_server.UpdateMetaTopicSub(topic.get());
     }
 
-    if (!wasSubscribed && added && !removed) {
-      // announce topic to client
+    // announce topic to client if not previously announced
+    if (added && !removed && !wasSubscribed) {
       DEBUG4("client {}: announce {}", m_id, topic->name);
       SendAnnounce(topic.get(), std::nullopt);
+    }
 
-      // send last value
-      if (!sub->options.topicsOnly && topic->lastValue) {
-        DEBUG4("send last value for {} to client {}", topic->name, m_id);
-        SendValue(topic.get(), topic->lastValue, kSendAll);
-      }
+    // send last value
+    if (added && !sub->options.topicsOnly && !wasSubscribedValue &&
+        topic->lastValue) {
+      dataToSend.emplace_back(topic.get());
     }
   }
 
-  // update meta data
-  UpdateMetaClientSub();
-
-  Flush();
+  for (auto topic : dataToSend) {
+    DEBUG4("send last value for {} to client {}", topic->name, m_id);
+    SendValue(topic, topic->lastValue, ValueSendMode::kAll);
+  }
 }
 
-void ClientData4Base::ClientUnsubscribe(int64_t subuid) {
+void ServerImpl::ClientData4Base::ClientUnsubscribe(int64_t subuid) {
   DEBUG3("ClientUnsubscribe({}, {})", m_id, subuid);
   auto subIt = m_subscribers.find(subuid);
   if (subIt == m_subscribers.end() || !subIt->getSecond()) {
@@ -702,50 +356,48 @@
 
   // remove from topics
   for (auto&& topic : m_server.m_topics) {
-    if (topic->subscribers.Remove(sub)) {
-      m_server.UpdateMetaTopicSub(topic.get());
+    auto tcdIt = topic->clients.find(this);
+    if (tcdIt != topic->clients.end()) {
+      if (tcdIt->second.subscribers.erase(sub)) {
+        UpdatePeriod(tcdIt->second, topic.get());
+        m_server.UpdateMetaTopicSub(topic.get());
+      }
     }
   }
 
   // delete it from client (future value sets will be ignored)
   m_subscribers.erase(subIt);
-  UpdateMetaClientSub();
 
-  // loop over all publishers to update period
-  m_periodMs = UINT32_MAX;
-  for (auto&& sub : m_subscribers) {
-    if (m_periodMs == UINT32_MAX) {
-      m_periodMs = sub.getSecond()->periodMs;
-    } else {
-      m_periodMs = std::gcd(m_periodMs, sub.getSecond()->periodMs);
-    }
+  // loop over all subscribers to update period
+  if (!m_local) {
+    m_periodMs = CalculatePeriod(
+        m_subscribers, [](auto& x) { return x.getSecond()->periodMs; });
+    m_setPeriodic(m_periodMs);
   }
-  if (m_periodMs < kMinPeriodMs) {
-    m_periodMs = kMinPeriodMs;
-  }
-  m_setPeriodic(m_periodMs);
 }
 
-void ClientData4Base::ClientSetValue(int64_t pubuid, const Value& value) {
+void ServerImpl::ClientData4Base::ClientSetValue(int64_t pubuid,
+                                                 const Value& value) {
   DEBUG4("ClientSetValue({}, {})", m_id, pubuid);
   auto publisherIt = m_publishers.find(pubuid);
   if (publisherIt == m_publishers.end()) {
-    WARNING("unrecognized client {} pubuid {}, ignoring set", m_id, pubuid);
+    WARN("unrecognized client {} pubuid {}, ignoring set", m_id, pubuid);
     return;  // ignore unrecognized pubuids
   }
   auto topic = publisherIt->getSecond().get()->topic;
   m_server.SetValue(this, topic, value);
 }
 
-void ClientDataLocal::SendValue(TopicData* topic, const Value& value,
-                                SendMode mode) {
+void ServerImpl::ClientDataLocal::SendValue(TopicData* topic,
+                                            const Value& value,
+                                            ValueSendMode mode) {
   if (m_server.m_local) {
     m_server.m_local->NetworkSetValue(topic->localHandle, value);
   }
 }
 
-void ClientDataLocal::SendAnnounce(TopicData* topic,
-                                   std::optional<int64_t> pubuid) {
+void ServerImpl::ClientDataLocal::SendAnnounce(TopicData* topic,
+                                               std::optional<int64_t> pubuid) {
   if (m_server.m_local) {
     auto& sent = m_announceSent[topic];
     if (sent) {
@@ -758,7 +410,7 @@
   }
 }
 
-void ClientDataLocal::SendUnannounce(TopicData* topic) {
+void ServerImpl::ClientDataLocal::SendUnannounce(TopicData* topic) {
   if (m_server.m_local) {
     auto& sent = m_announceSent[topic];
     if (!sent) {
@@ -769,8 +421,9 @@
   }
 }
 
-void ClientDataLocal::SendPropertiesUpdate(TopicData* topic,
-                                           const wpi::json& update, bool ack) {
+void ServerImpl::ClientDataLocal::SendPropertiesUpdate(TopicData* topic,
+                                                       const wpi::json& update,
+                                                       bool ack) {
   if (m_server.m_local) {
     if (!m_announceSent.lookup(topic)) {
       return;
@@ -779,32 +432,52 @@
   }
 }
 
-void ClientDataLocal::HandleLocal(std::span<const ClientMessage> msgs) {
+void ServerImpl::ClientDataLocal::HandleLocal(
+    std::span<const ClientMessage> msgs) {
   DEBUG4("HandleLocal()");
+  if (msgs.empty()) {
+    return;
+  }
   // just map as a normal client into client=0 calls
+  bool updatepub = false;
+  bool updatesub = false;
   for (const auto& elem : msgs) {  // NOLINT
     // common case is value, so check that first
     if (auto msg = std::get_if<ClientValueMsg>(&elem.contents)) {
       ClientSetValue(msg->pubHandle, msg->value);
     } else if (auto msg = std::get_if<PublishMsg>(&elem.contents)) {
       ClientPublish(msg->pubHandle, msg->name, msg->typeStr, msg->properties);
+      updatepub = true;
     } else if (auto msg = std::get_if<UnpublishMsg>(&elem.contents)) {
       ClientUnpublish(msg->pubHandle);
+      updatepub = true;
     } else if (auto msg = std::get_if<SetPropertiesMsg>(&elem.contents)) {
       ClientSetProperties(msg->name, msg->update);
     } else if (auto msg = std::get_if<SubscribeMsg>(&elem.contents)) {
       ClientSubscribe(msg->subHandle, msg->topicNames, msg->options);
+      updatesub = true;
     } else if (auto msg = std::get_if<UnsubscribeMsg>(&elem.contents)) {
       ClientUnsubscribe(msg->subHandle);
+      updatesub = true;
     }
   }
+  if (updatepub) {
+    UpdateMetaClientPub();
+  }
+  if (updatesub) {
+    UpdateMetaClientSub();
+  }
 }
 
-void ClientData4::ProcessIncomingText(std::string_view data) {
-  WireDecodeText(data, *this, m_logger);
+void ServerImpl::ClientData4::ProcessIncomingText(std::string_view data) {
+  if (WireDecodeText(data, *this, m_logger)) {
+    UpdateMetaClientPub();
+    UpdateMetaClientSub();
+  }
 }
 
-void ClientData4::ProcessIncomingBinary(std::span<const uint8_t> data) {
+void ServerImpl::ClientData4::ProcessIncomingBinary(
+    std::span<const uint8_t> data) {
   for (;;) {
     if (data.empty()) {
       break;
@@ -823,11 +496,8 @@
     if (pubuid == -1) {
       auto now = wpi::Now();
       DEBUG4("RTT ping from {}, responding with time={}", m_id, now);
-      {
-        auto out = m_wire.SendBinary();
-        WireEncodeBinary(out.Add(), -1, now, value);
-      }
-      m_wire.Flush();
+      m_wire.SendBinary(
+          [&](auto& os) { WireEncodeBinary(os, -1, now, value); });
       continue;
     }
 
@@ -836,46 +506,13 @@
   }
 }
 
-void ClientData4::SendValue(TopicData* topic, const Value& value,
-                            SendMode mode) {
-  if (m_local) {
-    mode = ClientData::kSendImmNoFlush;  // always send local immediately
-  }
-  switch (mode) {
-    case ClientData::kSendDisabled:  // do nothing
-      break;
-    case ClientData::kSendImmNoFlush:  // send immediately
-      WriteBinary(topic->id, value.time(), value);
-      if (m_local) {
-        Flush();
-      }
-      break;
-    case ClientData::kSendAll:  // append to outgoing
-      m_outgoing.emplace_back(ServerMessage{ServerValueMsg{topic->id, value}});
-      break;
-    case ClientData::kSendNormal: {
-      // scan outgoing and replace, or append if not present
-      bool found = false;
-      for (auto&& msg : m_outgoing) {
-        if (auto m = std::get_if<ServerValueMsg>(&msg.contents)) {
-          if (m->topic == topic->id) {
-            m->value = value;
-            found = true;
-            break;
-          }
-        }
-      }
-      if (!found) {
-        m_outgoing.emplace_back(
-            ServerMessage{ServerValueMsg{topic->id, value}});
-      }
-      break;
-    }
-  }
+void ServerImpl::ClientData4::SendValue(TopicData* topic, const Value& value,
+                                        ValueSendMode mode) {
+  m_outgoing.SendValue(topic->GetIdHandle(), value, mode);
 }
 
-void ClientData4::SendAnnounce(TopicData* topic,
-                               std::optional<int64_t> pubuid) {
+void ServerImpl::ClientData4::SendAnnounce(TopicData* topic,
+                                           std::optional<int64_t> pubuid) {
   auto& sent = m_announceSent[topic];
   if (sent) {
     return;
@@ -883,17 +520,24 @@
   sent = true;
 
   if (m_local) {
-    WireEncodeAnnounce(SendText().Add(), topic->name, topic->id, topic->typeStr,
-                       topic->properties, pubuid);
-    Flush();
-  } else {
-    m_outgoing.emplace_back(ServerMessage{AnnounceMsg{
-        topic->name, topic->id, topic->typeStr, pubuid, topic->properties}});
-    m_server.m_controlReady = true;
+    int unsent = m_wire.WriteText([&](auto& os) {
+      WireEncodeAnnounce(os, topic->name, topic->id, topic->typeStr,
+                         topic->properties, pubuid);
+    });
+    if (unsent < 0) {
+      return;  // error
+    }
+    if (unsent == 0 && m_wire.Flush() == 0) {
+      return;
+    }
   }
+  m_outgoing.SendMessage(topic->GetIdHandle(),
+                         AnnounceMsg{topic->name, topic->id, topic->typeStr,
+                                     pubuid, topic->properties});
+  m_server.m_controlReady = true;
 }
 
-void ClientData4::SendUnannounce(TopicData* topic) {
+void ServerImpl::ClientData4::SendUnannounce(TopicData* topic) {
   auto& sent = m_announceSent[topic];
   if (!sent) {
     return;
@@ -901,95 +545,91 @@
   sent = false;
 
   if (m_local) {
-    WireEncodeUnannounce(SendText().Add(), topic->name, topic->id);
-    Flush();
-  } else {
-    m_outgoing.emplace_back(
-        ServerMessage{UnannounceMsg{topic->name, topic->id}});
-    m_server.m_controlReady = true;
+    int unsent = m_wire.WriteText(
+        [&](auto& os) { WireEncodeUnannounce(os, topic->name, topic->id); });
+    if (unsent < 0) {
+      return;  // error
+    }
+    if (unsent == 0 && m_wire.Flush() == 0) {
+      return;
+    }
   }
+  m_outgoing.SendMessage(topic->GetIdHandle(),
+                         UnannounceMsg{topic->name, topic->id});
+  m_outgoing.EraseHandle(topic->GetIdHandle());
+  m_server.m_controlReady = true;
 }
 
-void ClientData4::SendPropertiesUpdate(TopicData* topic,
-                                       const wpi::json& update, bool ack) {
+void ServerImpl::ClientData4::SendPropertiesUpdate(TopicData* topic,
+                                                   const wpi::json& update,
+                                                   bool ack) {
   if (!m_announceSent.lookup(topic)) {
     return;
   }
 
   if (m_local) {
-    WireEncodePropertiesUpdate(SendText().Add(), topic->name, update, ack);
-    Flush();
-  } else {
-    m_outgoing.emplace_back(
-        ServerMessage{PropertiesUpdateMsg{topic->name, update, ack}});
-    m_server.m_controlReady = true;
-  }
-}
-
-void ClientData4::SendOutgoing(uint64_t curTimeMs) {
-  if (m_outgoing.empty()) {
-    return;  // nothing to do
-  }
-
-  // rate limit frequency of transmissions
-  if (curTimeMs < (m_lastSendMs + kMinPeriodMs)) {
-    return;
-  }
-
-  if (!m_wire.Ready()) {
-    ++m_notReadyCount;
-    if (m_notReadyCount > kWireMaxNotReady) {
-      m_wire.Disconnect("transmit stalled");
+    int unsent = m_wire.WriteText([&](auto& os) {
+      WireEncodePropertiesUpdate(os, topic->name, update, ack);
+    });
+    if (unsent < 0) {
+      return;  // error
     }
-    return;
-  }
-  m_notReadyCount = 0;
-
-  for (auto&& msg : m_outgoing) {
-    if (auto m = std::get_if<ServerValueMsg>(&msg.contents)) {
-      WriteBinary(m->topic, m->value.time(), m->value);
-    } else {
-      WireEncodeText(SendText().Add(), msg);
+    if (unsent == 0 && m_wire.Flush() == 0) {
+      return;
     }
   }
-  m_outgoing.resize(0);
-  m_lastSendMs = curTimeMs;
+  m_outgoing.SendMessage(topic->GetIdHandle(),
+                         PropertiesUpdateMsg{topic->name, update, ack});
+  m_server.m_controlReady = true;
 }
 
-void ClientData4::Flush() {
-  m_outText.reset();
-  m_outBinary.reset();
-  m_wire.Flush();
+void ServerImpl::ClientData4::SendOutgoing(uint64_t curTimeMs, bool flush) {
+  if (m_wire.GetVersion() >= 0x0401) {
+    if (!m_ping.Send(curTimeMs)) {
+      return;
+    }
+  }
+  m_outgoing.SendOutgoing(curTimeMs, flush);
 }
 
-bool ClientData3::TopicData3::UpdateFlags(TopicData* topic) {
+void ServerImpl::ClientData4::UpdatePeriod(TopicData::TopicClientData& tcd,
+                                           TopicData* topic) {
+  uint32_t period =
+      CalculatePeriod(tcd.subscribers, [](auto& x) { return x->periodMs; });
+  DEBUG4("updating {} period to {} ms", topic->name, period);
+  m_outgoing.SetPeriod(topic->GetIdHandle(), period);
+}
+
+bool ServerImpl::ClientData3::TopicData3::UpdateFlags(TopicData* topic) {
   unsigned int newFlags = topic->persistent ? NT_PERSISTENT : 0;
   bool updated = flags != newFlags;
   flags = newFlags;
   return updated;
 }
 
-void ClientData3::ProcessIncomingBinary(std::span<const uint8_t> data) {
+void ServerImpl::ClientData3::ProcessIncomingBinary(
+    std::span<const uint8_t> data) {
   if (!m_decoder.Execute(&data)) {
     m_wire.Disconnect(m_decoder.GetError());
   }
 }
 
-void ClientData3::SendValue(TopicData* topic, const Value& value,
-                            SendMode mode) {
+void ServerImpl::ClientData3::SendValue(TopicData* topic, const Value& value,
+                                        ValueSendMode mode) {
   if (m_state != kStateRunning) {
-    if (mode == kSendImmNoFlush) {
-      mode = kSendAll;
+    if (mode == ValueSendMode::kImm) {
+      mode = ValueSendMode::kAll;
     }
   } else if (m_local) {
-    mode = ClientData::kSendImmNoFlush;  // always send local immediately
+    mode = ValueSendMode::kImm;  // always send local immediately
   }
   TopicData3* topic3 = GetTopic3(topic);
+  bool added = false;
 
   switch (mode) {
-    case ClientData::kSendDisabled:  // do nothing
+    case ValueSendMode::kDisabled:  // do nothing
       break;
-    case ClientData::kSendImmNoFlush:  // send immediately and flush
+    case ValueSendMode::kImm:  // send immediately
       ++topic3->seqNum;
       if (topic3->sentAssign) {
         net3::WireEncodeEntryUpdate(m_wire.Send().stream(), topic->id,
@@ -1004,25 +644,27 @@
         Flush();
       }
       break;
-    case ClientData::kSendNormal: {
-      // scan outgoing and replace, or append if not present
-      bool found = false;
-      for (auto&& msg : m_outgoing) {
+    case ValueSendMode::kNormal: {
+      // replace, or append if not present
+      wpi::DenseMap<NT_Topic, size_t>::iterator it;
+      std::tie(it, added) =
+          m_outgoingValueMap.try_emplace(topic->id, m_outgoing.size());
+      if (!added && it->second < m_outgoing.size()) {
+        auto& msg = m_outgoing[it->second];
         if (msg.Is(net3::Message3::kEntryUpdate) ||
             msg.Is(net3::Message3::kEntryAssign)) {
-          if (msg.id() == topic->id) {
+          if (msg.id() == topic->id) {  // should always be true
             msg.SetValue(value);
-            found = true;
             break;
           }
         }
       }
-      if (found) {
-        break;
-      }
     }
       // fallthrough
-    case ClientData::kSendAll:  // append to outgoing
+    case ValueSendMode::kAll:  // append to outgoing
+      if (!added) {
+        m_outgoingValueMap[topic->id] = m_outgoing.size();
+      }
       ++topic3->seqNum;
       if (topic3->sentAssign) {
         m_outgoing.emplace_back(net3::Message3::EntryUpdate(
@@ -1037,8 +679,8 @@
   }
 }
 
-void ClientData3::SendAnnounce(TopicData* topic,
-                               std::optional<int64_t> pubuid) {
+void ServerImpl::ClientData3::SendAnnounce(TopicData* topic,
+                                           std::optional<int64_t> pubuid) {
   // ignore if we've not yet built the subscriber
   if (m_subscribers.empty()) {
     return;
@@ -1046,7 +688,7 @@
 
   // subscribe to all non-special topics
   if (!topic->special) {
-    topic->subscribers.Add(m_subscribers[0].get());
+    topic->clients[this].AddSubscriber(m_subscribers[0].get());
     m_server.UpdateMetaTopicSub(topic);
   }
 
@@ -1054,7 +696,7 @@
   // will get sent when the first value is sent (by SendValue).
 }
 
-void ClientData3::SendUnannounce(TopicData* topic) {
+void ServerImpl::ClientData3::SendUnannounce(TopicData* topic) {
   auto it = m_topics3.find(topic);
   if (it == m_topics3.end()) {
     return;  // never sent to client
@@ -1074,8 +716,9 @@
   }
 }
 
-void ClientData3::SendPropertiesUpdate(TopicData* topic,
-                                       const wpi::json& update, bool ack) {
+void ServerImpl::ClientData3::SendPropertiesUpdate(TopicData* topic,
+                                                   const wpi::json& update,
+                                                   bool ack) {
   if (ack) {
     return;  // we don't ack in NT3
   }
@@ -1099,7 +742,7 @@
   }
 }
 
-void ClientData3::SendOutgoing(uint64_t curTimeMs) {
+void ServerImpl::ClientData3::SendOutgoing(uint64_t curTimeMs, bool flush) {
   if (m_outgoing.empty() || m_state != kStateRunning) {
     return;  // nothing to do
   }
@@ -1110,23 +753,25 @@
   }
 
   if (!m_wire.Ready()) {
-    ++m_notReadyCount;
-    if (m_notReadyCount > kWireMaxNotReady) {
+    uint64_t lastFlushTime = m_wire.GetLastFlushTime();
+    uint64_t now = wpi::Now();
+    if (lastFlushTime != 0 && now > (lastFlushTime + kWireMaxNotReadyUs)) {
       m_wire.Disconnect("transmit stalled");
     }
     return;
   }
-  m_notReadyCount = 0;
 
   auto out = m_wire.Send();
   for (auto&& msg : m_outgoing) {
     net3::WireEncode(out.stream(), msg);
   }
+  m_wire.Flush();
   m_outgoing.resize(0);
+  m_outgoingValueMap.clear();
   m_lastSendMs = curTimeMs;
 }
 
-void ClientData3::KeepAlive() {
+void ServerImpl::ClientData3::KeepAlive() {
   DEBUG4("KeepAlive({})", m_id);
   if (m_state != kStateRunning) {
     m_decoder.SetError("received unexpected KeepAlive message");
@@ -1135,12 +780,12 @@
   // ignore
 }
 
-void ClientData3::ServerHelloDone() {
+void ServerImpl::ClientData3::ServerHelloDone() {
   DEBUG4("ServerHelloDone({})", m_id);
   m_decoder.SetError("received unexpected ServerHelloDone message");
 }
 
-void ClientData3::ClientHelloDone() {
+void ServerImpl::ClientData3::ClientHelloDone() {
   DEBUG4("ClientHelloDone({})", m_id);
   if (m_state != kStateServerHelloComplete) {
     m_decoder.SetError("received unexpected ClientHelloDone message");
@@ -1149,7 +794,7 @@
   m_state = kStateRunning;
 }
 
-void ClientData3::ClearEntries() {
+void ServerImpl::ClientData3::ClearEntries() {
   DEBUG4("ClearEntries({})", m_id);
   if (m_state != kStateRunning) {
     m_decoder.SetError("received unexpected ClearEntries message");
@@ -1168,7 +813,7 @@
       auto publisherIt = m_publishers.find(topic3it.second.pubuid);
       if (publisherIt != m_publishers.end()) {
         // remove publisher from topic
-        topic->publishers.Remove(publisherIt->second.get());
+        topic->RemovePublisher(this, publisherIt->second.get());
 
         // remove publisher from client
         m_publishers.erase(publisherIt);
@@ -1184,13 +829,13 @@
   }
 }
 
-void ClientData3::ProtoUnsup(unsigned int proto_rev) {
+void ServerImpl::ClientData3::ProtoUnsup(unsigned int proto_rev) {
   DEBUG4("ProtoUnsup({})", m_id);
   m_decoder.SetError("received unexpected ProtoUnsup message");
 }
 
-void ClientData3::ClientHello(std::string_view self_id,
-                              unsigned int proto_rev) {
+void ServerImpl::ClientData3::ClientHello(std::string_view self_id,
+                                          unsigned int proto_rev) {
   DEBUG4("ClientHello({}, '{}', {:04x})", m_id, self_id, proto_rev);
   if (m_state != kStateInitial) {
     m_decoder.SetError("received unexpected ClientHello message");
@@ -1219,10 +864,7 @@
   options.prefixMatch = true;
   sub = std::make_unique<SubscriberData>(
       this, std::span<const std::string>{{prefix}}, 0, options);
-  m_periodMs = std::gcd(m_periodMs, sub->periodMs);
-  if (m_periodMs < kMinPeriodMs) {
-    m_periodMs = kMinPeriodMs;
-  }
+  m_periodMs = UpdatePeriodCalc(m_periodMs, sub->periodMs);
   m_setPeriodic(m_periodMs);
 
   {
@@ -1233,7 +875,7 @@
           topic->lastValue) {
         DEBUG4("client {}: initial announce of '{}' (id {})", m_id, topic->name,
                topic->id);
-        topic->subscribers.Add(sub.get());
+        topic->clients[this].AddSubscriber(sub.get());
         m_server.UpdateMetaTopicSub(topic.get());
 
         TopicData3* topic3 = GetTopic3(topic.get());
@@ -1254,14 +896,16 @@
   UpdateMetaClientSub();
 }
 
-void ClientData3::ServerHello(unsigned int flags, std::string_view self_id) {
+void ServerImpl::ClientData3::ServerHello(unsigned int flags,
+                                          std::string_view self_id) {
   DEBUG4("ServerHello({}, {}, {})", m_id, flags, self_id);
   m_decoder.SetError("received unexpected ServerHello message");
 }
 
-void ClientData3::EntryAssign(std::string_view name, unsigned int id,
-                              unsigned int seq_num, const Value& value,
-                              unsigned int flags) {
+void ServerImpl::ClientData3::EntryAssign(std::string_view name,
+                                          unsigned int id, unsigned int seq_num,
+                                          const Value& value,
+                                          unsigned int flags) {
   DEBUG4("EntryAssign({}, {}, {}, {}, {})", m_id, id, seq_num,
          static_cast<int>(value.type()), flags);
   if (id != 0xffff) {
@@ -1281,7 +925,7 @@
   auto topic = m_server.CreateTopic(this, name, typeStr, properties);
   TopicData3* topic3 = GetTopic3(topic);
   if (topic3->published || topic3->sentAssign) {
-    WARNING("ignorning client {} duplicate publish of '{}'", m_id, name);
+    WARN("ignoring client {} duplicate publish of '{}'", m_id, name);
     return;
   }
   ++topic3->seqNum;
@@ -1298,7 +942,7 @@
   }
 
   // add publisher to topic
-  topic->publishers.Add(publisherIt->getSecond().get());
+  topic->AddPublisher(this, publisherIt->getSecond().get());
 
   // update meta data
   m_server.UpdateMetaTopicPub(topic);
@@ -1318,8 +962,8 @@
   }
 }
 
-void ClientData3::EntryUpdate(unsigned int id, unsigned int seq_num,
-                              const Value& value) {
+void ServerImpl::ClientData3::EntryUpdate(unsigned int id, unsigned int seq_num,
+                                          const Value& value) {
   DEBUG4("EntryUpdate({}, {}, {}, {})", m_id, id, seq_num,
          static_cast<int>(value.type()));
   if (m_state != kStateRunning) {
@@ -1348,7 +992,7 @@
         std::make_unique<PublisherData>(this, topic, topic3->pubuid));
     if (isNew) {
       // add publisher to topic
-      topic->publishers.Add(publisherIt->getSecond().get());
+      topic->AddPublisher(this, publisherIt->getSecond().get());
 
       // update meta data
       m_server.UpdateMetaTopicPub(topic);
@@ -1360,7 +1004,7 @@
   m_server.SetValue(this, topic, value);
 }
 
-void ClientData3::FlagsUpdate(unsigned int id, unsigned int flags) {
+void ServerImpl::ClientData3::FlagsUpdate(unsigned int id, unsigned int flags) {
   DEBUG4("FlagsUpdate({}, {}, {})", m_id, id, flags);
   if (m_state != kStateRunning) {
     m_decoder.SetError("received unexpected FlagsUpdate message");
@@ -1382,7 +1026,7 @@
   m_server.SetFlags(this, topic, flags);
 }
 
-void ClientData3::EntryDelete(unsigned int id) {
+void ServerImpl::ClientData3::EntryDelete(unsigned int id) {
   DEBUG4("EntryDelete({}, {})", m_id, id);
   if (m_state != kStateRunning) {
     m_decoder.SetError("received unexpected EntryDelete message");
@@ -1413,7 +1057,7 @@
       auto publisherIt = m_publishers.find(topic3it->second.pubuid);
       if (publisherIt != m_publishers.end()) {
         // remove publisher from topic
-        topic->publishers.Remove(publisherIt->second.get());
+        topic->RemovePublisher(this, publisherIt->second.get());
 
         // remove publisher from client
         m_publishers.erase(publisherIt);
@@ -1429,7 +1073,7 @@
   m_server.SetProperties(this, topic, {{"retained", false}});
 }
 
-bool TopicData::SetProperties(const wpi::json& update) {
+bool ServerImpl::TopicData::SetProperties(const wpi::json& update) {
   if (!update.is_object()) {
     return false;
   }
@@ -1448,7 +1092,7 @@
   return updated;
 }
 
-void TopicData::RefreshProperties() {
+void ServerImpl::TopicData::RefreshProperties() {
   persistent = false;
   retained = false;
 
@@ -1467,7 +1111,7 @@
   }
 }
 
-bool TopicData::SetFlags(unsigned int flags_) {
+bool ServerImpl::TopicData::SetFlags(unsigned int flags_) {
   bool updated;
   if ((flags_ & NT_PERSISTENT) != 0) {
     updated = !persistent;
@@ -1481,7 +1125,7 @@
   return updated;
 }
 
-bool SubscriberData::Matches(std::string_view name, bool special) {
+bool ServerImpl::SubscriberData::Matches(std::string_view name, bool special) {
   for (auto&& topicName : topicNames) {
     if ((!options.prefixMatch && name == topicName) ||
         (options.prefixMatch && (!special || !topicName.empty()) &&
@@ -1492,48 +1136,38 @@
   return false;
 }
 
-SImpl::SImpl(wpi::Logger& logger) : m_logger{logger} {
+ServerImpl::ServerImpl(wpi::Logger& logger) : m_logger{logger} {
   // local is client 0
   m_clients.emplace_back(std::make_unique<ClientDataLocal>(*this, 0, logger));
   m_localClient = static_cast<ClientDataLocal*>(m_clients.back().get());
 }
 
-std::pair<std::string, int> SImpl::AddClient(
+std::pair<std::string, int> ServerImpl::AddClient(
     std::string_view name, std::string_view connInfo, bool local,
     WireConnection& wire, ServerImpl::SetPeriodicFunc setPeriodic) {
-  // strip anything after @ in the name
-  name = wpi::split(name, '@').first;
   if (name.empty()) {
     name = "NT4";
   }
   size_t index = m_clients.size();
-  // find an empty slot and check for duplicates
+  // find an empty slot
   // just do a linear search as number of clients is typically small (<10)
-  int duplicateName = 0;
   for (size_t i = 0, end = index; i < end; ++i) {
-    auto& clientData = m_clients[i];
-    if (clientData && clientData->GetOriginalName() == name) {
-      ++duplicateName;
-    } else if (!clientData && index == end) {
+    if (!m_clients[i]) {
       index = i;
+      break;
     }
   }
   if (index == m_clients.size()) {
     m_clients.emplace_back();
   }
 
-  // if duplicate name, de-duplicate
-  std::string dedupName;
-  if (duplicateName > 0) {
-    dedupName = fmt::format("{}@{}", name, duplicateName);
-  } else {
-    dedupName = name;
-  }
+  // ensure name is unique by suffixing index
+  std::string dedupName = fmt::format("{}@{}", name, index);
 
   auto& clientData = m_clients[index];
-  clientData = std::make_unique<ClientData4>(name, dedupName, connInfo, local,
-                                             wire, std::move(setPeriodic),
-                                             *this, index, m_logger);
+  clientData = std::make_unique<ClientData4>(dedupName, connInfo, local, wire,
+                                             std::move(setPeriodic), *this,
+                                             index, m_logger);
 
   // create client meta topics
   clientData->m_metaPub =
@@ -1545,16 +1179,14 @@
   clientData->UpdateMetaClientPub();
   clientData->UpdateMetaClientSub();
 
-  wire.Flush();
-
   DEBUG3("AddClient('{}', '{}') -> {}", name, connInfo, index);
   return {std::move(dedupName), index};
 }
 
-int SImpl::AddClient3(std::string_view connInfo, bool local,
-                      net3::WireConnection3& wire,
-                      ServerImpl::Connected3Func connected,
-                      ServerImpl::SetPeriodicFunc setPeriodic) {
+int ServerImpl::AddClient3(std::string_view connInfo, bool local,
+                           net3::WireConnection3& wire,
+                           ServerImpl::Connected3Func connected,
+                           ServerImpl::SetPeriodicFunc setPeriodic) {
   size_t index = m_clients.size();
   // find an empty slot; we can't check for duplicates until we get a hello.
   // just do a linear search as number of clients is typically small (<10)
@@ -1576,24 +1208,21 @@
   return index;
 }
 
-void SImpl::RemoveClient(int clientId) {
+void ServerImpl::RemoveClient(int clientId) {
   DEBUG3("RemoveClient({})", clientId);
   auto& client = m_clients[clientId];
 
   // remove all publishers and subscribers for this client
   wpi::SmallVector<TopicData*, 16> toDelete;
   for (auto&& topic : m_topics) {
-    auto pubRemove =
-        std::remove_if(topic->publishers.begin(), topic->publishers.end(),
-                       [&](auto&& e) { return e->client == client.get(); });
-    bool pubChanged = pubRemove != topic->publishers.end();
-    topic->publishers.erase(pubRemove, topic->publishers.end());
-
-    auto subRemove =
-        std::remove_if(topic->subscribers.begin(), topic->subscribers.end(),
-                       [&](auto&& e) { return e->client == client.get(); });
-    bool subChanged = subRemove != topic->subscribers.end();
-    topic->subscribers.erase(subRemove, topic->subscribers.end());
+    bool pubChanged = false;
+    bool subChanged = false;
+    auto tcdIt = topic->clients.find(client.get());
+    if (tcdIt != topic->clients.end()) {
+      pubChanged = !tcdIt->second.publishers.empty();
+      subChanged = !tcdIt->second.subscribers.empty();
+      topic->clients.erase(tcdIt);
+    }
 
     if (!topic->IsPublished()) {
       toDelete.push_back(topic.get());
@@ -1618,7 +1247,7 @@
   client.reset();
 }
 
-bool SImpl::PersistentChanged() {
+bool ServerImpl::PersistentChanged() {
   bool rv = m_persistentChanged;
   m_persistentChanged = false;
   return rv;
@@ -1736,7 +1365,7 @@
   }
 }
 
-void SImpl::DumpPersistent(wpi::raw_ostream& os) {
+void ServerImpl::DumpPersistent(wpi::raw_ostream& os) {
   wpi::json::serializer s{os, ' ', 16};
   os << "[\n";
   bool first = true;
@@ -1776,7 +1405,7 @@
   return val;
 }
 
-std::string SImpl::LoadPersistent(std::string_view in) {
+std::string ServerImpl::LoadPersistent(std::string_view in) {
   if (in.empty()) {
     return {};
   }
@@ -1997,15 +1626,17 @@
   return allerrors;
 }
 
-TopicData* SImpl::CreateTopic(ClientData* client, std::string_view name,
-                              std::string_view typeStr,
-                              const wpi::json& properties, bool special) {
+ServerImpl::TopicData* ServerImpl::CreateTopic(ClientData* client,
+                                               std::string_view name,
+                                               std::string_view typeStr,
+                                               const wpi::json& properties,
+                                               bool special) {
   auto& topic = m_nameTopics[name];
   if (topic) {
     if (typeStr != topic->typeStr) {
       if (client) {
-        WARNING("client {} publish '{}' conflicting type '{}' (currently '{}')",
-                client->GetName(), name, typeStr, topic->typeStr);
+        WARN("client {} publish '{}' conflicting type '{}' (currently '{}')",
+             client->GetName(), name, typeStr, topic->typeStr);
       }
     }
   } else {
@@ -2025,15 +1656,17 @@
       wpi::SmallVector<SubscriberData*, 16> subscribersBuf;
       auto subscribers =
           aClient->GetSubscribers(name, topic->special, subscribersBuf);
-      for (auto subscriber : subscribers) {
-        topic->subscribers.Add(subscriber);
-      }
 
       // don't announce to this client if no subscribers
       if (subscribers.empty()) {
         continue;
       }
 
+      auto& tcd = topic->clients[aClient.get()];
+      for (auto subscriber : subscribers) {
+        tcd.AddSubscriber(subscriber);
+      }
+
       if (aClient.get() == client) {
         continue;  // don't announce to requesting client again
       }
@@ -2054,11 +1687,11 @@
   return topic;
 }
 
-TopicData* SImpl::CreateMetaTopic(std::string_view name) {
+ServerImpl::TopicData* ServerImpl::CreateMetaTopic(std::string_view name) {
   return CreateTopic(nullptr, name, "msgpack", {{"retained", true}}, true);
 }
 
-void SImpl::DeleteTopic(TopicData* topic) {
+void ServerImpl::DeleteTopic(TopicData* topic) {
   if (!topic) {
     return;
   }
@@ -2072,17 +1705,9 @@
   }
 
   // unannounce to all subscribers
-  wpi::SmallVector<bool, 16> clients;
-  clients.resize(m_clients.size());
-  for (auto&& sub : topic->subscribers) {
-    clients[sub->client->GetId()] = true;
-  }
-  for (size_t i = 0, iend = clients.size(); i < iend; ++i) {
-    if (!clients[i]) {
-      continue;
-    }
-    if (auto aClient = m_clients[i].get()) {
-      aClient->SendUnannounce(topic);
+  for (auto&& tcd : topic->clients) {
+    if (!tcd.second.subscribers.empty()) {
+      tcd.first->SendUnannounce(topic);
     }
   }
 
@@ -2091,8 +1716,8 @@
   m_topics.erase(topic->id);
 }
 
-void SImpl::SetProperties(ClientData* client, TopicData* topic,
-                          const wpi::json& update) {
+void ServerImpl::SetProperties(ClientData* client, TopicData* topic,
+                               const wpi::json& update) {
   DEBUG4("SetProperties({}, {}, {})", client ? client->GetId() : -1,
          topic->name, update.dump());
   bool wasPersistent = topic->persistent;
@@ -2105,7 +1730,8 @@
   }
 }
 
-void SImpl::SetFlags(ClientData* client, TopicData* topic, unsigned int flags) {
+void ServerImpl::SetFlags(ClientData* client, TopicData* topic,
+                          unsigned int flags) {
   bool wasPersistent = topic->persistent;
   if (topic->SetFlags(flags)) {
     // update persistentChanged flag
@@ -2122,10 +1748,11 @@
   }
 }
 
-void SImpl::SetValue(ClientData* client, TopicData* topic, const Value& value) {
+void ServerImpl::SetValue(ClientData* client, TopicData* topic,
+                          const Value& value) {
   // update retained value if from same client or timestamp newer
   if (!topic->lastValue || topic->lastValueClient == client ||
-      value.time() >= topic->lastValue.time()) {
+      topic->lastValue.time() == 0 || value.time() >= topic->lastValue.time()) {
     DEBUG4("updating '{}' last value (time was {} is {})", topic->name,
            topic->lastValue.time(), value.time());
     topic->lastValue = value;
@@ -2137,37 +1764,14 @@
     }
   }
 
-  // propagate to subscribers; as each client may have multiple subscribers,
-  // but we only want to send the value once, first map to clients and then
-  // take action based on union of subscriptions
-
-  // indexed by clientId
-  wpi::SmallVector<ClientData::SendMode, 16> toSend;
-  toSend.resize(m_clients.size());
-
-  for (auto&& subscriber : topic->subscribers) {
-    int id = subscriber->client->GetId();
-    if (subscriber->options.topicsOnly) {
-      continue;
-    } else if (subscriber->options.sendAll) {
-      toSend[id] = ClientData::kSendAll;
-    } else if (toSend[id] != ClientData::kSendAll) {
-      toSend[id] = ClientData::kSendNormal;
-    }
-  }
-
-  for (size_t i = 0, iend = toSend.size(); i < iend; ++i) {
-    auto aClient = m_clients[i].get();
-    if (!aClient || client == aClient) {
-      continue;  // don't echo back
-    }
-    if (toSend[i] != ClientData::kSendDisabled) {
-      aClient->SendValue(topic, value, toSend[i]);
+  for (auto&& tcd : topic->clients) {
+    if (tcd.second.sendMode != ValueSendMode::kDisabled) {
+      tcd.first->SendValue(topic, value, tcd.second.sendMode);
     }
   }
 }
 
-void SImpl::UpdateMetaClients(const std::vector<ConnectionInfo>& conns) {
+void ServerImpl::UpdateMetaClients(const std::vector<ConnectionInfo>& conns) {
   Writer w;
   mpack_start_array(&w, conns.size());
   for (auto&& conn : conns) {
@@ -2188,23 +1792,22 @@
   }
 }
 
-void SImpl::UpdateMetaTopicPub(TopicData* topic) {
+void ServerImpl::UpdateMetaTopicPub(TopicData* topic) {
   if (!topic->metaPub) {
     return;
   }
   Writer w;
-  mpack_start_array(&w, topic->publishers.size());
-  for (auto&& pub : topic->publishers) {
-    mpack_start_map(&w, 2);
-    mpack_write_str(&w, "client");
-    if (pub->client) {
-      mpack_write_str(&w, pub->client->GetName());
-    } else {
-      mpack_write_str(&w, "");
+  uint32_t count = 0;
+  for (auto&& tcd : topic->clients) {
+    count += tcd.second.publishers.size();
+  }
+  mpack_start_array(&w, count);
+  for (auto&& tcd : topic->clients) {
+    for (auto&& pub : tcd.second.publishers) {
+      mpack_write_object_bytes(
+          &w, reinterpret_cast<const char*>(pub->metaTopic.data()),
+          pub->metaTopic.size());
     }
-    mpack_write_str(&w, "pubuid");
-    mpack_write_int(&w, pub->pubuid);
-    mpack_finish_map(&w);
   }
   mpack_finish_array(&w);
   if (mpack_writer_destroy(&w) == mpack_ok) {
@@ -2212,25 +1815,22 @@
   }
 }
 
-void SImpl::UpdateMetaTopicSub(TopicData* topic) {
+void ServerImpl::UpdateMetaTopicSub(TopicData* topic) {
   if (!topic->metaSub) {
     return;
   }
   Writer w;
-  mpack_start_array(&w, topic->subscribers.size());
-  for (auto&& sub : topic->subscribers) {
-    mpack_start_map(&w, 3);
-    mpack_write_str(&w, "client");
-    if (sub->client) {
-      mpack_write_str(&w, sub->client->GetName());
-    } else {
-      mpack_write_str(&w, "");
+  uint32_t count = 0;
+  for (auto&& tcd : topic->clients) {
+    count += tcd.second.subscribers.size();
+  }
+  mpack_start_array(&w, count);
+  for (auto&& tcd : topic->clients) {
+    for (auto&& sub : tcd.second.subscribers) {
+      mpack_write_object_bytes(
+          &w, reinterpret_cast<const char*>(sub->metaTopic.data()),
+          sub->metaTopic.size());
     }
-    mpack_write_str(&w, "subuid");
-    mpack_write_int(&w, sub->subuid);
-    mpack_write_str(&w, "options");
-    WriteOptions(w, sub->options);
-    mpack_finish_map(&w);
   }
   mpack_finish_array(&w);
   if (mpack_writer_destroy(&w) == mpack_ok) {
@@ -2238,126 +1838,75 @@
   }
 }
 
-void SImpl::PropertiesChanged(ClientData* client, TopicData* topic,
-                              const wpi::json& update) {
+void ServerImpl::PropertiesChanged(ClientData* client, TopicData* topic,
+                                   const wpi::json& update) {
   // removing some properties can result in the topic being unpublished
   if (!topic->IsPublished()) {
     DeleteTopic(topic);
   } else {
     // send updated announcement to all subscribers
-    wpi::SmallVector<bool, 16> clients;
-    clients.resize(m_clients.size());
-    for (auto&& sub : topic->subscribers) {
-      clients[sub->client->GetId()] = true;
-    }
-    for (size_t i = 0, iend = clients.size(); i < iend; ++i) {
-      if (!clients[i]) {
-        continue;
-      }
-      if (auto aClient = m_clients[i].get()) {
-        aClient->SendPropertiesUpdate(topic, update, aClient == client);
-      }
+    for (auto&& tcd : topic->clients) {
+      tcd.first->SendPropertiesUpdate(topic, update, tcd.first == client);
     }
   }
 }
 
-class ServerImpl::Impl final : public SImpl {
- public:
-  explicit Impl(wpi::Logger& logger) : SImpl{logger} {}
-};
-
-ServerImpl::ServerImpl(wpi::Logger& logger)
-    : m_impl{std::make_unique<Impl>(logger)} {}
-
-ServerImpl::~ServerImpl() = default;
-
-void ServerImpl::SendControl(uint64_t curTimeMs) {
-  if (!m_impl->m_controlReady) {
-    return;
-  }
-  m_impl->m_controlReady = false;
-
-  for (auto&& client : m_impl->m_clients) {
+void ServerImpl::SendAllOutgoing(uint64_t curTimeMs, bool flush) {
+  for (auto&& client : m_clients) {
     if (client) {
-      // to ensure ordering, just send everything
-      client->SendOutgoing(curTimeMs);
-      client->Flush();
+      client->SendOutgoing(curTimeMs, flush);
     }
   }
 }
 
-void ServerImpl::SendValues(int clientId, uint64_t curTimeMs) {
-  auto client = m_impl->m_clients[clientId].get();
-  client->SendOutgoing(curTimeMs);
-  client->Flush();
+void ServerImpl::SendOutgoing(int clientId, uint64_t curTimeMs) {
+  if (auto client = m_clients[clientId].get()) {
+    client->SendOutgoing(curTimeMs, false);
+  }
 }
 
 void ServerImpl::HandleLocal(std::span<const ClientMessage> msgs) {
   // just map as a normal client into client=0 calls
-  m_impl->m_localClient->HandleLocal(msgs);
+  m_localClient->HandleLocal(msgs);
 }
 
 void ServerImpl::SetLocal(LocalInterface* local) {
-  WPI_DEBUG4(m_impl->m_logger, "SetLocal()");
-  m_impl->m_local = local;
+  DEBUG4("SetLocal()");
+  m_local = local;
 
   // create server meta topics
-  m_impl->m_metaClients = m_impl->CreateMetaTopic("$clients");
+  m_metaClients = CreateMetaTopic("$clients");
 
   // create local client meta topics
-  m_impl->m_localClient->m_metaPub = m_impl->CreateMetaTopic("$serverpub");
-  m_impl->m_localClient->m_metaSub = m_impl->CreateMetaTopic("$serversub");
+  m_localClient->m_metaPub = CreateMetaTopic("$serverpub");
+  m_localClient->m_metaSub = CreateMetaTopic("$serversub");
 
   // update meta topics
-  m_impl->m_localClient->UpdateMetaClientPub();
-  m_impl->m_localClient->UpdateMetaClientSub();
+  m_localClient->UpdateMetaClientPub();
+  m_localClient->UpdateMetaClientSub();
 }
 
 void ServerImpl::ProcessIncomingText(int clientId, std::string_view data) {
-  m_impl->m_clients[clientId]->ProcessIncomingText(data);
+  if (auto client = m_clients[clientId].get()) {
+    client->ProcessIncomingText(data);
+  }
 }
 
 void ServerImpl::ProcessIncomingBinary(int clientId,
                                        std::span<const uint8_t> data) {
-  m_impl->m_clients[clientId]->ProcessIncomingBinary(data);
-}
-
-std::pair<std::string, int> ServerImpl::AddClient(std::string_view name,
-                                                  std::string_view connInfo,
-                                                  bool local,
-                                                  WireConnection& wire,
-                                                  SetPeriodicFunc setPeriodic) {
-  return m_impl->AddClient(name, connInfo, local, wire, std::move(setPeriodic));
-}
-
-int ServerImpl::AddClient3(std::string_view connInfo, bool local,
-                           net3::WireConnection3& wire,
-                           Connected3Func connected,
-                           SetPeriodicFunc setPeriodic) {
-  return m_impl->AddClient3(connInfo, local, wire, std::move(connected),
-                            std::move(setPeriodic));
-}
-
-void ServerImpl::RemoveClient(int clientId) {
-  m_impl->RemoveClient(clientId);
+  if (auto client = m_clients[clientId].get()) {
+    client->ProcessIncomingBinary(data);
+  }
 }
 
 void ServerImpl::ConnectionsChanged(const std::vector<ConnectionInfo>& conns) {
-  m_impl->UpdateMetaClients(conns);
-}
-
-bool ServerImpl::PersistentChanged() {
-  return m_impl->PersistentChanged();
+  UpdateMetaClients(conns);
 }
 
 std::string ServerImpl::DumpPersistent() {
   std::string rv;
   wpi::raw_string_ostream os{rv};
-  m_impl->DumpPersistent(os);
+  DumpPersistent(os);
   os.flush();
   return rv;
 }
-
-std::string ServerImpl::LoadPersistent(std::string_view in) {
-  return m_impl->LoadPersistent(in);
-}
diff --git a/ntcore/src/main/native/cpp/net/ServerImpl.h b/ntcore/src/main/native/cpp/net/ServerImpl.h
index 86607e9..3ea5a82 100644
--- a/ntcore/src/main/native/cpp/net/ServerImpl.h
+++ b/ntcore/src/main/native/cpp/net/ServerImpl.h
@@ -6,6 +6,7 @@
 
 #include <stdint.h>
 
+#include <cmath>
 #include <functional>
 #include <memory>
 #include <span>
@@ -14,11 +15,33 @@
 #include <utility>
 #include <vector>
 
+#include <wpi/DenseMap.h>
+#include <wpi/SmallPtrSet.h>
+#include <wpi/StringMap.h>
+#include <wpi/UidVector.h>
+#include <wpi/json.h>
+
+#include "Handle.h"
+#include "Log.h"
+#include "Message.h"
 #include "NetworkInterface.h"
+#include "NetworkOutgoingQueue.h"
+#include "NetworkPing.h"
+#include "PubSubOptions.h"
+#include "VectorSet.h"
+#include "WireConnection.h"
+#include "WireDecoder.h"
+#include "WireEncoder.h"
+#include "net3/Message3.h"
+#include "net3/SequenceNumber.h"
 #include "net3/WireConnection3.h"
+#include "net3/WireDecoder3.h"
 
 namespace wpi {
 class Logger;
+template <typename T>
+class SmallVectorImpl;
+class raw_ostream;
 }  // namespace wpi
 
 namespace nt::net3 {
@@ -38,10 +61,9 @@
       std::function<void(std::string_view name, uint16_t proto)>;
 
   explicit ServerImpl(wpi::Logger& logger);
-  ~ServerImpl();
 
-  void SendControl(uint64_t curTimeMs);
-  void SendValues(int clientId, uint64_t curTimeMs);
+  void SendAllOutgoing(uint64_t curTimeMs, bool flush);
+  void SendOutgoing(int clientId, uint64_t curTimeMs);
 
   void HandleLocal(std::span<const ClientMessage> msgs);
   void SetLocal(LocalInterface* local);
@@ -69,8 +91,385 @@
   std::string LoadPersistent(std::string_view in);
 
  private:
-  class Impl;
-  std::unique_ptr<Impl> m_impl;
+  static constexpr uint32_t kMinPeriodMs = 5;
+
+  class ClientData;
+  struct PublisherData;
+  struct SubscriberData;
+
+  struct TopicData {
+    TopicData(std::string_view name, std::string_view typeStr)
+        : name{name}, typeStr{typeStr} {}
+    TopicData(std::string_view name, std::string_view typeStr,
+              wpi::json properties)
+        : name{name}, typeStr{typeStr}, properties(std::move(properties)) {
+      RefreshProperties();
+    }
+
+    bool IsPublished() const {
+      return persistent || retained || publisherCount != 0;
+    }
+
+    // returns true if properties changed
+    bool SetProperties(const wpi::json& update);
+    void RefreshProperties();
+    bool SetFlags(unsigned int flags_);
+
+    NT_Handle GetIdHandle() const { return Handle(0, id, Handle::kTopic); }
+
+    std::string name;
+    unsigned int id;
+    Value lastValue;
+    ClientData* lastValueClient = nullptr;
+    std::string typeStr;
+    wpi::json properties = wpi::json::object();
+    unsigned int publisherCount{0};
+    bool persistent{false};
+    bool retained{false};
+    bool special{false};
+    NT_Topic localHandle{0};
+
+    void AddPublisher(ClientData* client, PublisherData* pub) {
+      if (clients[client].publishers.insert(pub).second) {
+        ++publisherCount;
+      }
+    }
+
+    void RemovePublisher(ClientData* client, PublisherData* pub) {
+      if (clients[client].publishers.erase(pub)) {
+        --publisherCount;
+      }
+    }
+
+    struct TopicClientData {
+      wpi::SmallPtrSet<PublisherData*, 2> publishers;
+      wpi::SmallPtrSet<SubscriberData*, 2> subscribers;
+      ValueSendMode sendMode = ValueSendMode::kDisabled;
+
+      bool AddSubscriber(SubscriberData* sub) {
+        bool added = subscribers.insert(sub).second;
+        if (!sub->options.topicsOnly && sendMode == ValueSendMode::kDisabled) {
+          sendMode = ValueSendMode::kNormal;
+        } else if (sub->options.sendAll) {
+          sendMode = ValueSendMode::kAll;
+        }
+        return added;
+      }
+    };
+    wpi::SmallDenseMap<ClientData*, TopicClientData, 4> clients;
+
+    // meta topics
+    TopicData* metaPub = nullptr;
+    TopicData* metaSub = nullptr;
+  };
+
+  class ClientData {
+   public:
+    ClientData(std::string_view name, std::string_view connInfo, bool local,
+               ServerImpl::SetPeriodicFunc setPeriodic, ServerImpl& server,
+               int id, wpi::Logger& logger)
+        : m_name{name},
+          m_connInfo{connInfo},
+          m_local{local},
+          m_setPeriodic{std::move(setPeriodic)},
+          m_server{server},
+          m_id{id},
+          m_logger{logger} {}
+    virtual ~ClientData() = default;
+
+    virtual void ProcessIncomingText(std::string_view data) = 0;
+    virtual void ProcessIncomingBinary(std::span<const uint8_t> data) = 0;
+
+    virtual void SendValue(TopicData* topic, const Value& value,
+                           ValueSendMode mode) = 0;
+    virtual void SendAnnounce(TopicData* topic,
+                              std::optional<int64_t> pubuid) = 0;
+    virtual void SendUnannounce(TopicData* topic) = 0;
+    virtual void SendPropertiesUpdate(TopicData* topic, const wpi::json& update,
+                                      bool ack) = 0;
+    virtual void SendOutgoing(uint64_t curTimeMs, bool flush) = 0;
+    virtual void Flush() = 0;
+
+    void UpdateMetaClientPub();
+    void UpdateMetaClientSub();
+
+    std::span<SubscriberData*> GetSubscribers(
+        std::string_view name, bool special,
+        wpi::SmallVectorImpl<SubscriberData*>& buf);
+
+    std::string_view GetName() const { return m_name; }
+    int GetId() const { return m_id; }
+
+   protected:
+    virtual void UpdatePeriodic(TopicData* topic) {}
+
+    std::string m_name;
+    std::string m_connInfo;
+    bool m_local;  // local to machine
+    ServerImpl::SetPeriodicFunc m_setPeriodic;
+    // TODO: make this per-topic?
+    uint32_t m_periodMs{UINT32_MAX};
+    ServerImpl& m_server;
+    int m_id;
+
+    wpi::Logger& m_logger;
+
+    wpi::DenseMap<int64_t, std::unique_ptr<PublisherData>> m_publishers;
+    wpi::DenseMap<int64_t, std::unique_ptr<SubscriberData>> m_subscribers;
+
+   public:
+    // meta topics
+    TopicData* m_metaPub = nullptr;
+    TopicData* m_metaSub = nullptr;
+  };
+
+  class ClientData4Base : public ClientData, protected ClientMessageHandler {
+   public:
+    ClientData4Base(std::string_view name, std::string_view connInfo,
+                    bool local, ServerImpl::SetPeriodicFunc setPeriodic,
+                    ServerImpl& server, int id, wpi::Logger& logger)
+        : ClientData{name, connInfo, local, setPeriodic, server, id, logger} {}
+
+   protected:
+    // ClientMessageHandler interface
+    void ClientPublish(int64_t pubuid, std::string_view name,
+                       std::string_view typeStr,
+                       const wpi::json& properties) final;
+    void ClientUnpublish(int64_t pubuid) final;
+    void ClientSetProperties(std::string_view name,
+                             const wpi::json& update) final;
+    void ClientSubscribe(int64_t subuid,
+                         std::span<const std::string> topicNames,
+                         const PubSubOptionsImpl& options) final;
+    void ClientUnsubscribe(int64_t subuid) final;
+
+    void ClientSetValue(int64_t pubuid, const Value& value);
+
+    virtual void UpdatePeriod(TopicData::TopicClientData& tcd,
+                              TopicData* topic) {}
+
+    wpi::DenseMap<TopicData*, bool> m_announceSent;
+  };
+
+  class ClientDataLocal final : public ClientData4Base {
+   public:
+    ClientDataLocal(ServerImpl& server, int id, wpi::Logger& logger)
+        : ClientData4Base{"", "", true, [](uint32_t) {}, server, id, logger} {}
+
+    void ProcessIncomingText(std::string_view data) final {}
+    void ProcessIncomingBinary(std::span<const uint8_t> data) final {}
+
+    void SendValue(TopicData* topic, const Value& value,
+                   ValueSendMode mode) final;
+    void SendAnnounce(TopicData* topic, std::optional<int64_t> pubuid) final;
+    void SendUnannounce(TopicData* topic) final;
+    void SendPropertiesUpdate(TopicData* topic, const wpi::json& update,
+                              bool ack) final;
+    void SendOutgoing(uint64_t curTimeMs, bool flush) final {}
+    void Flush() final {}
+
+    void HandleLocal(std::span<const ClientMessage> msgs);
+  };
+
+  class ClientData4 final : public ClientData4Base {
+   public:
+    ClientData4(std::string_view name, std::string_view connInfo, bool local,
+                WireConnection& wire, ServerImpl::SetPeriodicFunc setPeriodic,
+                ServerImpl& server, int id, wpi::Logger& logger)
+        : ClientData4Base{name,   connInfo, local, setPeriodic,
+                          server, id,       logger},
+          m_wire{wire},
+          m_ping{wire},
+          m_outgoing{wire, local} {}
+
+    void ProcessIncomingText(std::string_view data) final;
+    void ProcessIncomingBinary(std::span<const uint8_t> data) final;
+
+    void SendValue(TopicData* topic, const Value& value,
+                   ValueSendMode mode) final;
+    void SendAnnounce(TopicData* topic, std::optional<int64_t> pubuid) final;
+    void SendUnannounce(TopicData* topic) final;
+    void SendPropertiesUpdate(TopicData* topic, const wpi::json& update,
+                              bool ack) final;
+    void SendOutgoing(uint64_t curTimeMs, bool flush) final;
+
+    void Flush() final {}
+
+    void UpdatePeriod(TopicData::TopicClientData& tcd, TopicData* topic) final;
+
+   public:
+    WireConnection& m_wire;
+
+   private:
+    NetworkPing m_ping;
+    NetworkOutgoingQueue<ServerMessage> m_outgoing;
+  };
+
+  class ClientData3 final : public ClientData, private net3::MessageHandler3 {
+   public:
+    ClientData3(std::string_view connInfo, bool local,
+                net3::WireConnection3& wire,
+                ServerImpl::Connected3Func connected,
+                ServerImpl::SetPeriodicFunc setPeriodic, ServerImpl& server,
+                int id, wpi::Logger& logger)
+        : ClientData{"", connInfo, local, setPeriodic, server, id, logger},
+          m_connected{std::move(connected)},
+          m_wire{wire},
+          m_decoder{*this} {}
+
+    void ProcessIncomingText(std::string_view data) final {}
+    void ProcessIncomingBinary(std::span<const uint8_t> data) final;
+
+    void SendValue(TopicData* topic, const Value& value,
+                   ValueSendMode mode) final;
+    void SendAnnounce(TopicData* topic, std::optional<int64_t> pubuid) final;
+    void SendUnannounce(TopicData* topic) final;
+    void SendPropertiesUpdate(TopicData* topic, const wpi::json& update,
+                              bool ack) final;
+    void SendOutgoing(uint64_t curTimeMs, bool flush) final;
+
+    void Flush() final { m_wire.Flush(); }
+
+   private:
+    // MessageHandler3 interface
+    void KeepAlive() final;
+    void ServerHelloDone() final;
+    void ClientHelloDone() final;
+    void ClearEntries() final;
+    void ProtoUnsup(unsigned int proto_rev) final;
+    void ClientHello(std::string_view self_id, unsigned int proto_rev) final;
+    void ServerHello(unsigned int flags, std::string_view self_id) final;
+    void EntryAssign(std::string_view name, unsigned int id,
+                     unsigned int seq_num, const Value& value,
+                     unsigned int flags) final;
+    void EntryUpdate(unsigned int id, unsigned int seq_num,
+                     const Value& value) final;
+    void FlagsUpdate(unsigned int id, unsigned int flags) final;
+    void EntryDelete(unsigned int id) final;
+    void ExecuteRpc(unsigned int id, unsigned int uid,
+                    std::span<const uint8_t> params) final {}
+    void RpcResponse(unsigned int id, unsigned int uid,
+                     std::span<const uint8_t> result) final {}
+
+    ServerImpl::Connected3Func m_connected;
+    net3::WireConnection3& m_wire;
+
+    enum State { kStateInitial, kStateServerHelloComplete, kStateRunning };
+    State m_state{kStateInitial};
+    net3::WireDecoder3 m_decoder;
+
+    std::vector<net3::Message3> m_outgoing;
+    wpi::DenseMap<NT_Topic, size_t> m_outgoingValueMap;
+    int64_t m_nextPubUid{1};
+    uint64_t m_lastSendMs{0};
+
+    struct TopicData3 {
+      explicit TopicData3(TopicData* topic) { UpdateFlags(topic); }
+
+      unsigned int flags{0};
+      net3::SequenceNumber seqNum;
+      bool sentAssign{false};
+      bool published{false};
+      int64_t pubuid{0};
+
+      bool UpdateFlags(TopicData* topic);
+    };
+    wpi::DenseMap<TopicData*, TopicData3> m_topics3;
+    TopicData3* GetTopic3(TopicData* topic) {
+      return &m_topics3.try_emplace(topic, topic).first->second;
+    }
+  };
+
+  struct PublisherData {
+    PublisherData(ClientData* client, TopicData* topic, int64_t pubuid)
+        : client{client}, topic{topic}, pubuid{pubuid} {
+      UpdateMeta();
+    }
+
+    void UpdateMeta();
+
+    ClientData* client;
+    TopicData* topic;
+    int64_t pubuid;
+    std::vector<uint8_t> metaClient;
+    std::vector<uint8_t> metaTopic;
+  };
+
+  struct SubscriberData {
+    SubscriberData(ClientData* client, std::span<const std::string> topicNames,
+                   int64_t subuid, const PubSubOptionsImpl& options)
+        : client{client},
+          topicNames{topicNames.begin(), topicNames.end()},
+          subuid{subuid},
+          options{options},
+          periodMs(std::lround(options.periodicMs / 10.0) * 10) {
+      UpdateMeta();
+      if (periodMs < kMinPeriodMs) {
+        periodMs = kMinPeriodMs;
+      }
+    }
+
+    void Update(std::span<const std::string> topicNames_,
+                const PubSubOptionsImpl& options_) {
+      topicNames = {topicNames_.begin(), topicNames_.end()};
+      options = options_;
+      UpdateMeta();
+      periodMs = std::lround(options_.periodicMs / 10.0) * 10;
+      if (periodMs < kMinPeriodMs) {
+        periodMs = kMinPeriodMs;
+      }
+    }
+
+    bool Matches(std::string_view name, bool special);
+
+    void UpdateMeta();
+
+    ClientData* client;
+    std::vector<std::string> topicNames;
+    int64_t subuid;
+    PubSubOptionsImpl options;
+    std::vector<uint8_t> metaClient;
+    std::vector<uint8_t> metaTopic;
+    wpi::DenseMap<TopicData*, bool> topics;
+    // in options as double, but copy here as integer; rounded to the nearest
+    // 10 ms
+    uint32_t periodMs;
+  };
+
+  wpi::Logger& m_logger;
+  LocalInterface* m_local{nullptr};
+  bool m_controlReady{false};
+
+  ClientDataLocal* m_localClient;
+  std::vector<std::unique_ptr<ClientData>> m_clients;
+  wpi::UidVector<std::unique_ptr<TopicData>, 16> m_topics;
+  wpi::StringMap<TopicData*> m_nameTopics;
+  bool m_persistentChanged{false};
+
+  // global meta topics (other meta topics are linked to from the specific
+  // client or topic)
+  TopicData* m_metaClients;
+
+  void DumpPersistent(wpi::raw_ostream& os);
+
+  // helper functions
+  TopicData* CreateTopic(ClientData* client, std::string_view name,
+                         std::string_view typeStr, const wpi::json& properties,
+                         bool special = false);
+  TopicData* CreateMetaTopic(std::string_view name);
+  void DeleteTopic(TopicData* topic);
+  void SetProperties(ClientData* client, TopicData* topic,
+                     const wpi::json& update);
+  void SetFlags(ClientData* client, TopicData* topic, unsigned int flags);
+  void SetValue(ClientData* client, TopicData* topic, const Value& value);
+
+  // update meta topic values from data structures
+  void UpdateMetaClients(const std::vector<ConnectionInfo>& conns);
+  void UpdateMetaTopicPub(TopicData* topic);
+  void UpdateMetaTopicSub(TopicData* topic);
+
+  void PropertiesChanged(ClientData* client, TopicData* topic,
+                         const wpi::json& update);
 };
 
 }  // namespace nt::net
diff --git a/ntcore/src/main/native/cpp/net/WebSocketConnection.cpp b/ntcore/src/main/native/cpp/net/WebSocketConnection.cpp
index d3d192f..dcfbabe 100644
--- a/ntcore/src/main/native/cpp/net/WebSocketConnection.cpp
+++ b/ntcore/src/main/native/cpp/net/WebSocketConnection.cpp
@@ -4,116 +4,256 @@
 
 #include "WebSocketConnection.h"
 
+#include <algorithm>
 #include <span>
 
+#include <wpi/Endian.h>
 #include <wpi/SpanExtras.h>
+#include <wpi/raw_ostream.h>
+#include <wpi/timestamp.h>
 #include <wpinet/WebSocket.h>
+#include <wpinet/raw_uv_ostream.h>
 
 using namespace nt;
 using namespace nt::net;
 
-static constexpr size_t kAllocSize = 4096;
-static constexpr size_t kTextFrameRolloverSize = 4096;
-static constexpr size_t kBinaryFrameRolloverSize = 8192;
+// MTU - assume Ethernet, IPv6, TCP; does not include WS frame header (max 10)
+static constexpr size_t kMTU = 1500 - 40 - 20;
+static constexpr size_t kAllocSize = kMTU - 10;
+// leave enough room for a "typical" message size so we don't create lots of
+// fragmented frames
+static constexpr size_t kNewFrameThresholdBytes = kAllocSize - 50;
+static constexpr size_t kFlushThresholdFrames = 32;
+static constexpr size_t kFlushThresholdBytes = 16384;
+static constexpr size_t kMaxPoolSize = 32;
 
-WebSocketConnection::WebSocketConnection(wpi::WebSocket& ws)
-    : m_ws{ws},
-      m_text_os{m_text_buffers, [this] { return AllocBuf(); }},
-      m_binary_os{m_binary_buffers, [this] { return AllocBuf(); }} {}
+class WebSocketConnection::Stream final : public wpi::raw_ostream {
+ public:
+  explicit Stream(WebSocketConnection& conn) : m_conn{conn} {
+    auto& buf = conn.m_bufs.back();
+    SetBuffer(buf.base, kAllocSize);
+    SetNumBytesInBuffer(buf.len);
+  }
+
+  ~Stream() final {
+    m_disableAlloc = true;
+    flush();
+  }
+
+ private:
+  size_t preferred_buffer_size() const final { return 0; }
+  void write_impl(const char* data, size_t len) final;
+  uint64_t current_pos() const final { return m_conn.m_framePos; }
+
+  WebSocketConnection& m_conn;
+  bool m_disableAlloc = false;
+};
+
+void WebSocketConnection::Stream::write_impl(const char* data, size_t len) {
+  if (data == m_conn.m_bufs.back().base) {
+    // flush_nonempty() case
+    m_conn.m_bufs.back().len = len;
+    if (!m_disableAlloc) {
+      m_conn.m_frames.back().opcode &= ~wpi::WebSocket::kFlagFin;
+      m_conn.StartFrame(wpi::WebSocket::Frame::kFragment);
+      SetBuffer(m_conn.m_bufs.back().base, kAllocSize);
+    }
+    return;
+  }
+
+  bool updateBuffer = false;
+  while (len > 0) {
+    auto& buf = m_conn.m_bufs.back();
+    assert(buf.len <= kAllocSize);
+    size_t amt = (std::min)(static_cast<int>(kAllocSize - buf.len),
+                            static_cast<int>(len));
+    if (amt > 0) {
+      std::memcpy(buf.base + buf.len, data, amt);
+      buf.len += amt;
+      m_conn.m_framePos += amt;
+      m_conn.m_written += amt;
+      data += amt;
+      len -= amt;
+    }
+    if (buf.len >= kAllocSize && (len > 0 || !m_disableAlloc)) {
+      // fragment the current frame and start a new one
+      m_conn.m_frames.back().opcode &= ~wpi::WebSocket::kFlagFin;
+      m_conn.StartFrame(wpi::WebSocket::Frame::kFragment);
+      updateBuffer = true;
+    }
+  }
+
+  if (updateBuffer) {
+    SetBuffer(m_conn.m_bufs.back().base, kAllocSize);
+  }
+}
+
+WebSocketConnection::WebSocketConnection(wpi::WebSocket& ws,
+                                         unsigned int version)
+    : m_ws{ws}, m_version{version} {}
 
 WebSocketConnection::~WebSocketConnection() {
+  for (auto&& buf : m_bufs) {
+    buf.Deallocate();
+  }
   for (auto&& buf : m_buf_pool) {
     buf.Deallocate();
   }
 }
 
-void WebSocketConnection::Flush() {
-  FinishSendText();
-  FinishSendBinary();
-  if (m_frames.empty()) {
-    return;
+void WebSocketConnection::Start() {
+  m_ws.pong.connect([selfweak = weak_from_this()](auto data) {
+    if (data.size() != 8) {
+      return;
+    }
+    if (auto self = selfweak.lock()) {
+      self->m_lastPingResponse =
+          wpi::support::endian::read64<wpi::support::native>(data.data());
+    }
+  });
+}
+
+void WebSocketConnection::SendPing(uint64_t time) {
+  auto buf = AllocBuf();
+  buf.len = 8;
+  wpi::support::endian::write64<wpi::support::native>(buf.base, time);
+  m_ws.SendPing({buf}, [selfweak = weak_from_this()](auto bufs, auto err) {
+    if (auto self = selfweak.lock()) {
+      self->m_err = err;
+      self->ReleaseBufs(bufs);
+    } else {
+      for (auto&& buf : bufs) {
+        buf.Deallocate();
+      }
+    }
+  });
+}
+
+void WebSocketConnection::StartFrame(uint8_t opcode) {
+  m_frames.emplace_back(opcode, m_bufs.size(), m_bufs.size() + 1);
+  m_bufs.emplace_back(AllocBuf());
+  m_bufs.back().len = 0;
+}
+
+void WebSocketConnection::FinishText() {
+  assert(!m_bufs.empty());
+  auto& buf = m_bufs.back();
+  assert(buf.len < (kAllocSize + 1));  // safe because we alloc one more byte
+  buf.base[buf.len++] = ']';
+}
+
+int WebSocketConnection::Write(
+    State kind, wpi::function_ref<void(wpi::raw_ostream& os)> writer) {
+  bool first = false;
+  if (m_state != kind ||
+      (m_state == kind && m_framePos >= kNewFrameThresholdBytes)) {
+    // start a new frame
+    if (m_state == kText) {
+      FinishText();
+    }
+    m_state = kind;
+    if (!m_frames.empty()) {
+      m_frames.back().opcode |= wpi::WebSocket::kFlagFin;
+    }
+    StartFrame(m_state == kText ? wpi::WebSocket::Frame::kText
+                                : wpi::WebSocket::Frame::kBinary);
+    m_framePos = 0;
+    first = true;
   }
+  {
+    Stream os{*this};
+    if (kind == kText) {
+      os << (first ? '[' : ',');
+    }
+    writer(os);
+  }
+  ++m_frames.back().count;
+  if (m_frames.size() > kFlushThresholdFrames ||
+      m_written >= kFlushThresholdBytes) {
+    return Flush();
+  }
+  return 0;
+}
+
+int WebSocketConnection::Flush() {
+  m_lastFlushTime = wpi::Now();
+  if (m_state == kEmpty) {
+    return 0;
+  }
+  if (m_state == kText) {
+    FinishText();
+  }
+  m_state = kEmpty;
+  m_written = 0;
+
+  if (m_frames.empty()) {
+    return 0;
+  }
+  m_frames.back().opcode |= wpi::WebSocket::kFlagFin;
 
   // convert internal frames into WS frames
   m_ws_frames.clear();
   m_ws_frames.reserve(m_frames.size());
   for (auto&& frame : m_frames) {
-    m_ws_frames.emplace_back(frame.opcode,
-                             std::span{frame.bufs->begin() + frame.start,
-                                       frame.bufs->begin() + frame.end});
+    m_ws_frames.emplace_back(
+        frame.opcode,
+        std::span{m_bufs}.subspan(frame.start, frame.end - frame.start));
   }
 
-  ++m_sendsActive;
-  m_ws.SendFrames(m_ws_frames, [selfweak = weak_from_this()](auto bufs, auto) {
+  auto unsentFrames = m_ws.TrySendFrames(
+      m_ws_frames, [selfweak = weak_from_this()](auto bufs, auto err) {
+        if (auto self = selfweak.lock()) {
+          self->m_err = err;
+          self->ReleaseBufs(bufs);
+        } else {
+          for (auto&& buf : bufs) {
+            buf.Deallocate();
+          }
+        }
+      });
+  m_ws_frames.clear();
+  if (m_err) {
+    m_frames.clear();
+    m_bufs.clear();
+    return m_err.code();
+  }
+
+  int count = 0;
+  for (auto&& frame :
+       wpi::take_back(std::span{m_frames}, unsentFrames.size())) {
+    count += frame.count;
+  }
+  m_frames.clear();
+  m_bufs.clear();
+  return count;
+}
+
+void WebSocketConnection::Send(
+    uint8_t opcode, wpi::function_ref<void(wpi::raw_ostream& os)> writer) {
+  wpi::SmallVector<wpi::uv::Buffer, 4> bufs;
+  wpi::raw_uv_ostream os{bufs, [this] { return AllocBuf(); }};
+  if (opcode == wpi::WebSocket::Frame::kText) {
+    os << '[';
+  }
+  writer(os);
+  if (opcode == wpi::WebSocket::Frame::kText) {
+    os << ']';
+  }
+  wpi::WebSocket::Frame frame{opcode, os.bufs()};
+  m_ws.SendFrames({{frame}}, [selfweak = weak_from_this()](auto bufs, auto) {
     if (auto self = selfweak.lock()) {
-      self->m_buf_pool.insert(self->m_buf_pool.end(), bufs.begin(), bufs.end());
-      if (self->m_sendsActive > 0) {
-        --self->m_sendsActive;
+      self->ReleaseBufs(bufs);
+    } else {
+      for (auto&& buf : bufs) {
+        buf.Deallocate();
       }
     }
   });
-  m_frames.clear();
-  m_text_buffers.clear();
-  m_binary_buffers.clear();
-  m_text_pos = 0;
-  m_binary_pos = 0;
 }
 
 void WebSocketConnection::Disconnect(std::string_view reason) {
-  m_ws.Close(1005, reason);
-}
-
-void WebSocketConnection::StartSendText() {
-  // limit amount per single frame
-  size_t total = 0;
-  for (size_t i = m_text_pos; i < m_text_buffers.size(); ++i) {
-    total += m_text_buffers[i].len;
-  }
-  if (total >= kTextFrameRolloverSize) {
-    FinishSendText();
-  }
-
-  if (m_in_text) {
-    m_text_os << ',';
-  } else {
-    m_text_os << '[';
-    m_in_text = true;
-  }
-}
-
-void WebSocketConnection::FinishSendText() {
-  if (m_in_text) {
-    m_text_os << ']';
-    m_in_text = false;
-  }
-  if (m_text_pos >= m_text_buffers.size()) {
-    return;
-  }
-  m_frames.emplace_back(wpi::WebSocket::Frame::kText, &m_text_buffers,
-                        m_text_pos, m_text_buffers.size());
-  m_text_pos = m_text_buffers.size();
-  m_text_os.reset();
-}
-
-void WebSocketConnection::StartSendBinary() {
-  // limit amount per single frame
-  size_t total = 0;
-  for (size_t i = m_binary_pos; i < m_binary_buffers.size(); ++i) {
-    total += m_binary_buffers[i].len;
-  }
-  if (total >= kBinaryFrameRolloverSize) {
-    FinishSendBinary();
-  }
-}
-
-void WebSocketConnection::FinishSendBinary() {
-  if (m_binary_pos >= m_binary_buffers.size()) {
-    return;
-  }
-  m_frames.emplace_back(wpi::WebSocket::Frame::kBinary, &m_binary_buffers,
-                        m_binary_pos, m_binary_buffers.size());
-  m_binary_pos = m_binary_buffers.size();
-  m_binary_os.reset();
+  m_reason = reason;
+  m_ws.Fail(1005, reason);
 }
 
 wpi::uv::Buffer WebSocketConnection::AllocBuf() {
@@ -122,5 +262,17 @@
     m_buf_pool.pop_back();
     return buf;
   }
-  return wpi::uv::Buffer::Allocate(kAllocSize);
+  return wpi::uv::Buffer::Allocate(kAllocSize + 1);  // leave space for ']'
+}
+
+void WebSocketConnection::ReleaseBufs(std::span<wpi::uv::Buffer> bufs) {
+#ifdef __SANITIZE_ADDRESS__
+  size_t numToPool = 0;
+#else
+  size_t numToPool = (std::min)(bufs.size(), kMaxPoolSize - m_buf_pool.size());
+  m_buf_pool.insert(m_buf_pool.end(), bufs.begin(), bufs.begin() + numToPool);
+#endif
+  for (auto&& buf : bufs.subspan(numToPool)) {
+    buf.Deallocate();
+  }
 }
diff --git a/ntcore/src/main/native/cpp/net/WebSocketConnection.h b/ntcore/src/main/native/cpp/net/WebSocketConnection.h
index ba15215..4398451 100644
--- a/ntcore/src/main/native/cpp/net/WebSocketConnection.h
+++ b/ntcore/src/main/native/cpp/net/WebSocketConnection.h
@@ -5,11 +5,12 @@
 #pragma once
 
 #include <memory>
+#include <string>
+#include <string_view>
 #include <vector>
 
-#include <wpi/SmallVector.h>
+#include <wpi/function_ref.h>
 #include <wpinet/WebSocket.h>
-#include <wpinet/raw_uv_ostream.h>
 #include <wpinet/uv/Buffer.h>
 
 #include "WireConnection.h"
@@ -20,50 +21,79 @@
     : public WireConnection,
       public std::enable_shared_from_this<WebSocketConnection> {
  public:
-  explicit WebSocketConnection(wpi::WebSocket& ws);
+  WebSocketConnection(wpi::WebSocket& ws, unsigned int version);
   ~WebSocketConnection() override;
   WebSocketConnection(const WebSocketConnection&) = delete;
   WebSocketConnection& operator=(const WebSocketConnection&) = delete;
 
-  bool Ready() const final { return m_sendsActive == 0; }
+  void Start();
 
-  TextWriter SendText() final { return {m_text_os, *this}; }
-  BinaryWriter SendBinary() final { return {m_binary_os, *this}; }
+  unsigned int GetVersion() const final { return m_version; }
 
-  void Flush() final;
+  void SendPing(uint64_t time) final;
+
+  bool Ready() const final { return !m_ws.IsWriteInProgress(); }
+
+  int WriteText(wpi::function_ref<void(wpi::raw_ostream& os)> writer) final {
+    return Write(kText, writer);
+  }
+  int WriteBinary(wpi::function_ref<void(wpi::raw_ostream& os)> writer) final {
+    return Write(kBinary, writer);
+  }
+  int Flush() final;
+
+  void SendText(wpi::function_ref<void(wpi::raw_ostream& os)> writer) final {
+    Send(wpi::WebSocket::Frame::kText, writer);
+  }
+  void SendBinary(wpi::function_ref<void(wpi::raw_ostream& os)> writer) final {
+    Send(wpi::WebSocket::Frame::kBinary, writer);
+  }
+
+  uint64_t GetLastFlushTime() const final { return m_lastFlushTime; }
+
+  uint64_t GetLastPingResponse() const final { return m_lastPingResponse; }
 
   void Disconnect(std::string_view reason) final;
 
- private:
-  void StartSendText() final;
-  void FinishSendText() final;
-  void StartSendBinary() final;
-  void FinishSendBinary() final;
+  std::string_view GetDisconnectReason() const { return m_reason; }
 
+ private:
+  enum State { kEmpty, kText, kBinary };
+
+  int Write(State kind, wpi::function_ref<void(wpi::raw_ostream& os)> writer);
+  void Send(uint8_t opcode,
+            wpi::function_ref<void(wpi::raw_ostream& os)> writer);
+
+  void StartFrame(uint8_t opcode);
+  void FinishText();
   wpi::uv::Buffer AllocBuf();
+  void ReleaseBufs(std::span<wpi::uv::Buffer> bufs);
 
   wpi::WebSocket& m_ws;
+
+  class Stream;
+
   // Can't use WS frames directly as span could have dangling pointers
   struct Frame {
-    Frame(uint8_t opcode, wpi::SmallVectorImpl<wpi::uv::Buffer>* bufs,
-          size_t start, size_t end)
-        : opcode{opcode}, bufs{bufs}, start{start}, end{end} {}
-    uint8_t opcode;
-    wpi::SmallVectorImpl<wpi::uv::Buffer>* bufs;
+    Frame(uint8_t opcode, size_t start, size_t end)
+        : start{start}, end{end}, opcode{opcode} {}
     size_t start;
     size_t end;
+    unsigned int count = 0;
+    uint8_t opcode;
   };
-  std::vector<Frame> m_frames;
   std::vector<wpi::WebSocket::Frame> m_ws_frames;  // to reduce allocs
-  wpi::SmallVector<wpi::uv::Buffer, 4> m_text_buffers;
-  wpi::SmallVector<wpi::uv::Buffer, 4> m_binary_buffers;
+  std::vector<Frame> m_frames;
+  std::vector<wpi::uv::Buffer> m_bufs;
   std::vector<wpi::uv::Buffer> m_buf_pool;
-  wpi::raw_uv_ostream m_text_os;
-  wpi::raw_uv_ostream m_binary_os;
-  size_t m_text_pos = 0;
-  size_t m_binary_pos = 0;
-  bool m_in_text = false;
-  int m_sendsActive = 0;
+  size_t m_framePos = 0;
+  size_t m_written = 0;
+  wpi::uv::Error m_err;
+  State m_state = kEmpty;
+  std::string m_reason;
+  uint64_t m_lastFlushTime = 0;
+  uint64_t m_lastPingResponse = 0;
+  unsigned int m_version;
 };
 
 }  // namespace nt::net
diff --git a/ntcore/src/main/native/cpp/net/WireConnection.h b/ntcore/src/main/native/cpp/net/WireConnection.h
index 2a79a12..2c876cc 100644
--- a/ntcore/src/main/native/cpp/net/WireConnection.h
+++ b/ntcore/src/main/native/cpp/net/WireConnection.h
@@ -8,103 +8,53 @@
 
 #include <string_view>
 
-#include <wpi/raw_ostream.h>
+#include <wpi/function_ref.h>
+
+namespace wpi {
+class raw_ostream;
+}  // namespace wpi
 
 namespace nt::net {
 
-class BinaryWriter;
-class TextWriter;
-
 class WireConnection {
-  friend class TextWriter;
-  friend class BinaryWriter;
-
  public:
   virtual ~WireConnection() = default;
 
+  virtual unsigned int GetVersion() const = 0;
+
+  virtual void SendPing(uint64_t time) = 0;
+
   virtual bool Ready() const = 0;
 
-  virtual TextWriter SendText() = 0;
+  // These return <0 on error, 0 on success. On buffer full, a positive number
+  // is is returned indicating the number of previous messages (including this
+  // call) that were NOT sent, e.g. 1 if just this call to WriteText or
+  // WriteBinary was not sent, 2 if the this call and the *previous* call were
+  // not sent.
+  [[nodiscard]]
+  virtual int WriteText(
+      wpi::function_ref<void(wpi::raw_ostream& os)> writer) = 0;
+  [[nodiscard]]
+  virtual int WriteBinary(
+      wpi::function_ref<void(wpi::raw_ostream& os)> writer) = 0;
 
-  virtual BinaryWriter SendBinary() = 0;
+  // Flushes any pending buffers. Return value equivalent to
+  // WriteText/WriteBinary (e.g. 1 means the last WriteX call was not sent).
+  [[nodiscard]]
+  virtual int Flush() = 0;
 
-  virtual void Flush() = 0;
+  // These immediately send the data even if the buffer is full.
+  virtual void SendText(
+      wpi::function_ref<void(wpi::raw_ostream& os)> writer) = 0;
+  virtual void SendBinary(
+      wpi::function_ref<void(wpi::raw_ostream& os)> writer) = 0;
+
+  virtual uint64_t GetLastFlushTime() const = 0;  // in microseconds
+
+  // Gets the timestamp of the last ping we got a reply to
+  virtual uint64_t GetLastPingResponse() const = 0;  // in microseconds
 
   virtual void Disconnect(std::string_view reason) = 0;
-
- protected:
-  virtual void StartSendText() = 0;
-  virtual void FinishSendText() = 0;
-  virtual void StartSendBinary() = 0;
-  virtual void FinishSendBinary() = 0;
-};
-
-class TextWriter {
- public:
-  TextWriter(wpi::raw_ostream& os, WireConnection& wire)
-      : m_os{&os}, m_wire{&wire} {}
-  TextWriter(const TextWriter&) = delete;
-  TextWriter(TextWriter&& rhs) : m_os{rhs.m_os}, m_wire{rhs.m_wire} {
-    rhs.m_os = nullptr;
-    rhs.m_wire = nullptr;
-  }
-  TextWriter& operator=(const TextWriter&) = delete;
-  TextWriter& operator=(TextWriter&& rhs) {
-    m_os = rhs.m_os;
-    m_wire = rhs.m_wire;
-    rhs.m_os = nullptr;
-    rhs.m_wire = nullptr;
-    return *this;
-  }
-  ~TextWriter() {
-    if (m_os) {
-      m_wire->FinishSendText();
-    }
-  }
-
-  wpi::raw_ostream& Add() {
-    m_wire->StartSendText();
-    return *m_os;
-  }
-  WireConnection& wire() { return *m_wire; }
-
- private:
-  wpi::raw_ostream* m_os;
-  WireConnection* m_wire;
-};
-
-class BinaryWriter {
- public:
-  BinaryWriter(wpi::raw_ostream& os, WireConnection& wire)
-      : m_os{&os}, m_wire{&wire} {}
-  BinaryWriter(const BinaryWriter&) = delete;
-  BinaryWriter(BinaryWriter&& rhs) : m_os{rhs.m_os}, m_wire{rhs.m_wire} {
-    rhs.m_os = nullptr;
-    rhs.m_wire = nullptr;
-  }
-  BinaryWriter& operator=(const BinaryWriter&) = delete;
-  BinaryWriter& operator=(BinaryWriter&& rhs) {
-    m_os = rhs.m_os;
-    m_wire = rhs.m_wire;
-    rhs.m_os = nullptr;
-    rhs.m_wire = nullptr;
-    return *this;
-  }
-  ~BinaryWriter() {
-    if (m_wire) {
-      m_wire->FinishSendBinary();
-    }
-  }
-
-  wpi::raw_ostream& Add() {
-    m_wire->StartSendBinary();
-    return *m_os;
-  }
-  WireConnection& wire() { return *m_wire; }
-
- private:
-  wpi::raw_ostream* m_os;
-  WireConnection* m_wire;
 };
 
 }  // namespace nt::net
diff --git a/ntcore/src/main/native/cpp/net/WireDecoder.cpp b/ntcore/src/main/native/cpp/net/WireDecoder.cpp
index e6474b2..536a62f 100644
--- a/ntcore/src/main/native/cpp/net/WireDecoder.cpp
+++ b/ntcore/src/main/native/cpp/net/WireDecoder.cpp
@@ -5,6 +5,7 @@
 #include "WireDecoder.h"
 
 #include <algorithm>
+#include <concepts>
 
 #include <fmt/format.h>
 #include <wpi/Logger.h>
@@ -104,25 +105,24 @@
 #endif
 
 template <typename T>
-static void WireDecodeTextImpl(std::string_view in, T& out,
+  requires(std::same_as<T, ClientMessageHandler> ||
+           std::same_as<T, ServerMessageHandler>)
+static bool WireDecodeTextImpl(std::string_view in, T& out,
                                wpi::Logger& logger) {
-  static_assert(std::is_same_v<T, ClientMessageHandler> ||
-                    std::is_same_v<T, ServerMessageHandler>,
-                "T must be ClientMessageHandler or ServerMessageHandler");
-
   wpi::json j;
   try {
     j = wpi::json::parse(in);
   } catch (wpi::json::parse_error& err) {
     WPI_WARNING(logger, "could not decode JSON message: {}", err.what());
-    return;
+    return false;
   }
 
   if (!j.is_array()) {
     WPI_WARNING(logger, "expected JSON array at top level");
-    return;
+    return false;
   }
 
+  bool rv = false;
   int i = -1;
   for (auto&& jmsg : j) {
     ++i;
@@ -150,7 +150,7 @@
         goto err;
       }
 
-      if constexpr (std::is_same_v<T, ClientMessageHandler>) {
+      if constexpr (std::same_as<T, ClientMessageHandler>) {
         if (*method == PublishMsg::kMethodStr) {
           // name
           auto name = ObjGetString(*params, "name", &error);
@@ -188,6 +188,7 @@
 
           // complete
           out.ClientPublish(pubuid, *name, *typeStr, *properties);
+          rv = true;
         } else if (*method == UnpublishMsg::kMethodStr) {
           // pubuid
           int64_t pubuid;
@@ -197,6 +198,7 @@
 
           // complete
           out.ClientUnpublish(pubuid);
+          rv = true;
         } else if (*method == SetPropertiesMsg::kMethodStr) {
           // name
           auto name = ObjGetString(*params, "name", &error);
@@ -289,6 +291,7 @@
 
           // complete
           out.ClientSubscribe(subuid, topicNames, options);
+          rv = true;
         } else if (*method == UnsubscribeMsg::kMethodStr) {
           // subuid
           int64_t subuid;
@@ -298,11 +301,12 @@
 
           // complete
           out.ClientUnsubscribe(subuid);
+          rv = true;
         } else {
           error = fmt::format("unrecognized method '{}'", *method);
           goto err;
         }
-      } else if constexpr (std::is_same_v<T, ServerMessageHandler>) {
+      } else if constexpr (std::same_as<T, ServerMessageHandler>) {
         if (*method == AnnounceMsg::kMethodStr) {
           // name
           auto name = ObjGetString(*params, "name", &error);
@@ -405,15 +409,17 @@
   err:
     WPI_WARNING(logger, "{}: {}", i, error);
   }
+
+  return rv;
 }
 
 #ifdef __clang__
 #pragma clang diagnostic pop
 #endif
 
-void nt::net::WireDecodeText(std::string_view in, ClientMessageHandler& out,
+bool nt::net::WireDecodeText(std::string_view in, ClientMessageHandler& out,
                              wpi::Logger& logger) {
-  ::WireDecodeTextImpl(in, out, logger);
+  return ::WireDecodeTextImpl(in, out, logger);
 }
 
 void nt::net::WireDecodeText(std::string_view in, ServerMessageHandler& out,
diff --git a/ntcore/src/main/native/cpp/net/WireDecoder.h b/ntcore/src/main/native/cpp/net/WireDecoder.h
index 128dff2..b4f7b4e 100644
--- a/ntcore/src/main/native/cpp/net/WireDecoder.h
+++ b/ntcore/src/main/native/cpp/net/WireDecoder.h
@@ -11,9 +11,10 @@
 #include <string>
 #include <string_view>
 
+#include <wpi/json_fwd.h>
+
 namespace wpi {
 class Logger;
-class json;
 }  // namespace wpi
 
 namespace nt {
@@ -51,7 +52,8 @@
                                       const wpi::json& update, bool ack) = 0;
 };
 
-void WireDecodeText(std::string_view in, ClientMessageHandler& out,
+// return true if client pub/sub metadata needs updating
+bool WireDecodeText(std::string_view in, ClientMessageHandler& out,
                     wpi::Logger& logger);
 void WireDecodeText(std::string_view in, ServerMessageHandler& out,
                     wpi::Logger& logger);
diff --git a/ntcore/src/main/native/cpp/net/WireEncoder.cpp b/ntcore/src/main/native/cpp/net/WireEncoder.cpp
index 143fe0d..42de91c 100644
--- a/ntcore/src/main/native/cpp/net/WireEncoder.cpp
+++ b/ntcore/src/main/native/cpp/net/WireEncoder.cpp
@@ -6,7 +6,7 @@
 
 #include <optional>
 
-#include <wpi/json_serializer.h>
+#include <wpi/json.h>
 #include <wpi/mpack.h>
 #include <wpi/raw_ostream.h>
 
@@ -142,10 +142,9 @@
   } else if (auto m = std::get_if<SetPropertiesMsg>(&msg.contents)) {
     WireEncodeSetProperties(os, m->name, m->update);
   } else if (auto m = std::get_if<SubscribeMsg>(&msg.contents)) {
-    WireEncodeSubscribe(os, Handle{m->subHandle}.GetIndex(), m->topicNames,
-                        m->options);
+    WireEncodeSubscribe(os, m->subHandle, m->topicNames, m->options);
   } else if (auto m = std::get_if<UnsubscribeMsg>(&msg.contents)) {
-    WireEncodeUnsubscribe(os, Handle{m->subHandle}.GetIndex());
+    WireEncodeUnsubscribe(os, m->subHandle);
   } else {
     return false;
   }
diff --git a/ntcore/src/main/native/cpp/net/WireEncoder.h b/ntcore/src/main/native/cpp/net/WireEncoder.h
index d0a04cb..00d8e12 100644
--- a/ntcore/src/main/native/cpp/net/WireEncoder.h
+++ b/ntcore/src/main/native/cpp/net/WireEncoder.h
@@ -9,8 +9,9 @@
 #include <string>
 #include <string_view>
 
+#include <wpi/json_fwd.h>
+
 namespace wpi {
-class json;
 class raw_ostream;
 }  // namespace wpi
 
diff --git a/ntcore/src/main/native/cpp/net3/ClientImpl3.cpp b/ntcore/src/main/native/cpp/net3/ClientImpl3.cpp
index 0783865..a65dd89 100644
--- a/ntcore/src/main/native/cpp/net3/ClientImpl3.cpp
+++ b/ntcore/src/main/native/cpp/net3/ClientImpl3.cpp
@@ -12,17 +12,13 @@
 #include <fmt/format.h>
 #include <wpi/DenseMap.h>
 #include <wpi/StringMap.h>
-#include <wpi/json.h>
+#include <wpi/timestamp.h>
 
 #include "Handle.h"
 #include "Log.h"
 #include "Types_internal.h"
 #include "net/Message.h"
 #include "net/NetworkInterface.h"
-#include "net3/Message3.h"
-#include "net3/SequenceNumber.h"
-#include "net3/WireConnection3.h"
-#include "net3/WireDecoder3.h"
 #include "net3/WireEncoder3.h"
 #include "networktables/NetworkTableValue.h"
 
@@ -31,150 +27,11 @@
 
 static constexpr uint32_t kMinPeriodMs = 5;
 
-// maximum number of times the wire can be not ready to send another
+// maximum amount of time the wire can be not ready to send another
 // transmission before we close the connection
-static constexpr int kWireMaxNotReady = 10;
+static constexpr uint32_t kWireMaxNotReadyUs = 1000000;
 
-namespace {
-
-struct Entry;
-
-struct PublisherData {
-  explicit PublisherData(Entry* entry) : entry{entry} {}
-
-  Entry* entry;
-  NT_Publisher handle;
-  PubSubOptionsImpl options;
-  // in options as double, but copy here as integer; rounded to the nearest
-  // 10 ms
-  uint32_t periodMs;
-  uint64_t nextSendMs{0};
-  std::vector<Value> outValues;  // outgoing values
-};
-
-// data for each entry
-struct Entry {
-  explicit Entry(std::string_view name_) : name(name_) {}
-  bool IsPersistent() const { return (flags & NT_PERSISTENT) != 0; }
-  wpi::json SetFlags(unsigned int flags_);
-
-  std::string name;
-
-  std::string typeStr;
-  NT_Type type{NT_UNASSIGNED};
-
-  wpi::json properties = wpi::json::object();
-
-  // The current value and flags
-  Value value;
-  unsigned int flags{0};
-
-  // Unique ID used in network messages; this is 0xffff until assigned
-  // by the server.
-  unsigned int id{0xffff};
-
-  // Sequence number for update resolution
-  SequenceNumber seqNum;
-
-  // Local topic handle
-  NT_Topic topic{0};
-
-  // Local publishers
-  std::vector<PublisherData*> publishers;
-};
-
-class CImpl : public MessageHandler3 {
- public:
-  CImpl(uint64_t curTimeMs, int inst, WireConnection3& wire,
-        wpi::Logger& logger,
-        std::function<void(uint32_t repeatMs)> setPeriodic);
-
-  void ProcessIncoming(std::span<const uint8_t> data);
-  void HandleLocal(std::span<const net::ClientMessage> msgs);
-  void SendPeriodic(uint64_t curTimeMs, bool initial);
-  void SendValue(Writer& out, Entry* entry, const Value& value);
-  bool CheckNetworkReady();
-
-  // Outgoing handlers
-  void Publish(NT_Publisher pubHandle, NT_Topic topicHandle,
-               std::string_view name, std::string_view typeStr,
-               const wpi::json& properties, const PubSubOptionsImpl& options);
-  void Unpublish(NT_Publisher pubHandle, NT_Topic topicHandle);
-  void SetProperties(NT_Topic topicHandle, std::string_view name,
-                     const wpi::json& update);
-  void SetValue(NT_Publisher pubHandle, const Value& value);
-
-  // MessageHandler interface
-  void KeepAlive() final;
-  void ServerHelloDone() final;
-  void ClientHelloDone() final;
-  void ClearEntries() final;
-  void ProtoUnsup(unsigned int proto_rev) final;
-  void ClientHello(std::string_view self_id, unsigned int proto_rev) final;
-  void ServerHello(unsigned int flags, std::string_view self_id) final;
-  void EntryAssign(std::string_view name, unsigned int id, unsigned int seq_num,
-                   const Value& value, unsigned int flags) final;
-  void EntryUpdate(unsigned int id, unsigned int seq_num,
-                   const Value& value) final;
-  void FlagsUpdate(unsigned int id, unsigned int flags) final;
-  void EntryDelete(unsigned int id) final;
-  void ExecuteRpc(unsigned int id, unsigned int uid,
-                  std::span<const uint8_t> params) final {}
-  void RpcResponse(unsigned int id, unsigned int uid,
-                   std::span<const uint8_t> result) final {}
-
-  enum State {
-    kStateInitial,
-    kStateHelloSent,
-    kStateInitialAssignments,
-    kStateRunning
-  };
-
-  int m_inst;
-  WireConnection3& m_wire;
-  wpi::Logger& m_logger;
-  net::LocalInterface* m_local{nullptr};
-  std::function<void(uint32_t repeatMs)> m_setPeriodic;
-  uint64_t m_initTimeMs;
-
-  // periodic sweep handling
-  static constexpr uint32_t kKeepAliveIntervalMs = 1000;
-  uint32_t m_periodMs{kKeepAliveIntervalMs + 10};
-  uint64_t m_lastSendMs{0};
-  uint64_t m_nextKeepAliveTimeMs;
-  int m_notReadyCount{0};
-
-  // indexed by publisher index
-  std::vector<std::unique_ptr<PublisherData>> m_publishers;
-
-  State m_state{kStateInitial};
-  WireDecoder3 m_decoder;
-  std::string m_remoteId;
-  std::function<void()> m_handshakeSucceeded;
-
-  std::vector<std::pair<unsigned int, unsigned int>> m_outgoingFlags;
-
-  using NameMap = wpi::StringMap<std::unique_ptr<Entry>>;
-  using IdMap = std::vector<Entry*>;
-
-  NameMap m_nameMap;
-  IdMap m_idMap;
-
-  Entry* GetOrNewEntry(std::string_view name) {
-    auto& entry = m_nameMap[name];
-    if (!entry) {
-      entry = std::make_unique<Entry>(name);
-    }
-    return entry.get();
-  }
-  Entry* LookupId(unsigned int id) {
-    return id < m_idMap.size() ? m_idMap[id] : nullptr;
-  }
-};
-
-}  // namespace
-
-wpi::json Entry::SetFlags(unsigned int flags_) {
+wpi::json ClientImpl3::Entry::SetFlags(unsigned int flags_) {
   bool wasPersistent = IsPersistent();
   flags = flags_;
   bool isPersistent = IsPersistent();
@@ -189,25 +46,28 @@
   }
 }
 
-CImpl::CImpl(uint64_t curTimeMs, int inst, WireConnection3& wire,
-             wpi::Logger& logger,
-             std::function<void(uint32_t repeatMs)> setPeriodic)
-    : m_inst{inst},
-      m_wire{wire},
+ClientImpl3::ClientImpl3(uint64_t curTimeMs, int inst, WireConnection3& wire,
+                         wpi::Logger& logger,
+                         std::function<void(uint32_t repeatMs)> setPeriodic)
+    : m_wire{wire},
       m_logger{logger},
       m_setPeriodic{std::move(setPeriodic)},
       m_initTimeMs{curTimeMs},
       m_nextKeepAliveTimeMs{curTimeMs + kKeepAliveIntervalMs},
       m_decoder{*this} {}
 
-void CImpl::ProcessIncoming(std::span<const uint8_t> data) {
+ClientImpl3::~ClientImpl3() {
+  DEBUG4("NT3 ClientImpl destroyed");
+}
+
+void ClientImpl3::ProcessIncoming(std::span<const uint8_t> data) {
   DEBUG4("received {} bytes", data.size());
   if (!m_decoder.Execute(&data)) {
     m_wire.Disconnect(m_decoder.GetError());
   }
 }
 
-void CImpl::HandleLocal(std::span<const net::ClientMessage> msgs) {
+void ClientImpl3::HandleLocal(std::span<const net::ClientMessage> msgs) {
   for (const auto& elem : msgs) {  // NOLINT
     // common case is value
     if (auto msg = std::get_if<net::ClientValueMsg>(&elem.contents)) {
@@ -223,7 +83,7 @@
   }
 }
 
-void CImpl::SendPeriodic(uint64_t curTimeMs, bool initial) {
+void ClientImpl3::DoSendPeriodic(uint64_t curTimeMs, bool initial, bool flush) {
   DEBUG4("SendPeriodic({})", curTimeMs);
 
   // rate limit sends
@@ -233,9 +93,9 @@
 
   auto out = m_wire.Send();
 
-  // send keep-alives
+  // send keep-alive
   if (curTimeMs >= m_nextKeepAliveTimeMs) {
-    if (!CheckNetworkReady()) {
+    if (!CheckNetworkReady(curTimeMs)) {
       return;
     }
     DEBUG4("Sending keep alive");
@@ -246,7 +106,7 @@
 
   // send any stored-up flags updates
   if (!m_outgoingFlags.empty()) {
-    if (!CheckNetworkReady()) {
+    if (!CheckNetworkReady(curTimeMs)) {
       return;
     }
     for (auto&& p : m_outgoingFlags) {
@@ -258,9 +118,10 @@
   // send any pending updates due to be sent
   bool checkedNetwork = false;
   for (auto&& pub : m_publishers) {
-    if (pub && !pub->outValues.empty() && curTimeMs >= pub->nextSendMs) {
+    if (pub && !pub->outValues.empty() &&
+        (flush || curTimeMs >= pub->nextSendMs)) {
       if (!checkedNetwork) {
-        if (!CheckNetworkReady()) {
+        if (!CheckNetworkReady(curTimeMs)) {
           return;
         }
         checkedNetwork = true;
@@ -282,7 +143,7 @@
   m_lastSendMs = curTimeMs;
 }
 
-void CImpl::SendValue(Writer& out, Entry* entry, const Value& value) {
+void ClientImpl3::SendValue(Writer& out, Entry* entry, const Value& value) {
   DEBUG4("sending value for '{}', seqnum {}", entry->name,
          entry->seqNum.value());
 
@@ -301,22 +162,22 @@
   }
 }
 
-bool CImpl::CheckNetworkReady() {
+bool ClientImpl3::CheckNetworkReady(uint64_t curTimeMs) {
   if (!m_wire.Ready()) {
-    ++m_notReadyCount;
-    if (m_notReadyCount > kWireMaxNotReady) {
+    uint64_t lastFlushTime = m_wire.GetLastFlushTime();
+    uint64_t now = wpi::Now();
+    if (lastFlushTime != 0 && now > (lastFlushTime + kWireMaxNotReadyUs)) {
       m_wire.Disconnect("transmit stalled");
     }
     return false;
   }
-  m_notReadyCount = 0;
   return true;
 }
 
-void CImpl::Publish(NT_Publisher pubHandle, NT_Topic topicHandle,
-                    std::string_view name, std::string_view typeStr,
-                    const wpi::json& properties,
-                    const PubSubOptionsImpl& options) {
+void ClientImpl3::Publish(NT_Publisher pubHandle, NT_Topic topicHandle,
+                          std::string_view name, std::string_view typeStr,
+                          const wpi::json& properties,
+                          const PubSubOptionsImpl& options) {
   DEBUG4("Publish('{}', '{}')", name, typeStr);
   unsigned int index = Handle{pubHandle}.GetIndex();
   if (index >= m_publishers.size()) {
@@ -341,7 +202,7 @@
   m_setPeriodic(m_periodMs);
 }
 
-void CImpl::Unpublish(NT_Publisher pubHandle, NT_Topic topicHandle) {
+void ClientImpl3::Unpublish(NT_Publisher pubHandle, NT_Topic topicHandle) {
   DEBUG4("Unpublish({}, {})", pubHandle, topicHandle);
   unsigned int index = Handle{pubHandle}.GetIndex();
   if (index >= m_publishers.size()) {
@@ -364,8 +225,8 @@
   m_setPeriodic(m_periodMs);
 }
 
-void CImpl::SetProperties(NT_Topic topicHandle, std::string_view name,
-                          const wpi::json& update) {
+void ClientImpl3::SetProperties(NT_Topic topicHandle, std::string_view name,
+                                const wpi::json& update) {
   DEBUG4("SetProperties({}, {}, {})", topicHandle, name, update.dump());
   auto entry = GetOrNewEntry(name);
   bool updated = false;
@@ -387,7 +248,7 @@
   }
 }
 
-void CImpl::SetValue(NT_Publisher pubHandle, const Value& value) {
+void ClientImpl3::SetValue(NT_Publisher pubHandle, const Value& value) {
   DEBUG4("SetValue({})", pubHandle);
   unsigned int index = Handle{pubHandle}.GetIndex();
   assert(index < m_publishers.size() && m_publishers[index]);
@@ -403,7 +264,7 @@
   }
 }
 
-void CImpl::KeepAlive() {
+void ClientImpl3::KeepAlive() {
   DEBUG4("KeepAlive()");
   if (m_state != kStateRunning && m_state != kStateInitialAssignments) {
     m_decoder.SetError("received unexpected KeepAlive message");
@@ -412,7 +273,7 @@
   // ignore
 }
 
-void CImpl::ServerHelloDone() {
+void ClientImpl3::ServerHelloDone() {
   DEBUG4("ServerHelloDone()");
   if (m_state != kStateInitialAssignments) {
     m_decoder.SetError("received unexpected ServerHelloDone message");
@@ -420,28 +281,29 @@
   }
 
   // send initial assignments
-  SendPeriodic(m_initTimeMs, true);
+  DoSendPeriodic(m_initTimeMs, true, true);
 
   m_state = kStateRunning;
   m_setPeriodic(m_periodMs);
 }
 
-void CImpl::ClientHelloDone() {
+void ClientImpl3::ClientHelloDone() {
   DEBUG4("ClientHelloDone()");
   m_decoder.SetError("received unexpected ClientHelloDone message");
 }
 
-void CImpl::ProtoUnsup(unsigned int proto_rev) {
+void ClientImpl3::ProtoUnsup(unsigned int proto_rev) {
   DEBUG4("ProtoUnsup({})", proto_rev);
   m_decoder.SetError(fmt::format("received ProtoUnsup(version={})", proto_rev));
 }
 
-void CImpl::ClientHello(std::string_view self_id, unsigned int proto_rev) {
+void ClientImpl3::ClientHello(std::string_view self_id,
+                              unsigned int proto_rev) {
   DEBUG4("ClientHello({}, {})", self_id, proto_rev);
   m_decoder.SetError("received unexpected ClientHello message");
 }
 
-void CImpl::ServerHello(unsigned int flags, std::string_view self_id) {
+void ClientImpl3::ServerHello(unsigned int flags, std::string_view self_id) {
   DEBUG4("ServerHello({}, {})", flags, self_id);
   if (m_state != kStateHelloSent) {
     m_decoder.SetError("received unexpected ServerHello message");
@@ -453,9 +315,9 @@
   m_handshakeSucceeded = nullptr;  // no longer required
 }
 
-void CImpl::EntryAssign(std::string_view name, unsigned int id,
-                        unsigned int seq_num, const Value& value,
-                        unsigned int flags) {
+void ClientImpl3::EntryAssign(std::string_view name, unsigned int id,
+                              unsigned int seq_num, const Value& value,
+                              unsigned int flags) {
   DEBUG4("EntryAssign({}, {}, {}, value, {})", name, id, seq_num, flags);
   if (m_state != kStateInitialAssignments && m_state != kStateRunning) {
     m_decoder.SetError("received unexpected EntryAssign message");
@@ -512,8 +374,8 @@
   }
 }
 
-void CImpl::EntryUpdate(unsigned int id, unsigned int seq_num,
-                        const Value& value) {
+void ClientImpl3::EntryUpdate(unsigned int id, unsigned int seq_num,
+                              const Value& value) {
   DEBUG4("EntryUpdate({}, {}, value)", id, seq_num);
   if (m_state != kStateRunning) {
     m_decoder.SetError("received EntryUpdate message before ServerHelloDone");
@@ -527,7 +389,7 @@
   }
 }
 
-void CImpl::FlagsUpdate(unsigned int id, unsigned int flags) {
+void ClientImpl3::FlagsUpdate(unsigned int id, unsigned int flags) {
   DEBUG4("FlagsUpdate({}, {})", id, flags);
   if (m_state != kStateRunning) {
     m_decoder.SetError("received FlagsUpdate message before ServerHelloDone");
@@ -547,7 +409,7 @@
       m_outgoingFlags.end());
 }
 
-void CImpl::EntryDelete(unsigned int id) {
+void ClientImpl3::EntryDelete(unsigned int id) {
   DEBUG4("EntryDelete({})", id);
   if (m_state != kStateRunning) {
     m_decoder.SetError("received EntryDelete message before ServerHelloDone");
@@ -572,7 +434,7 @@
       m_outgoingFlags.end());
 }
 
-void CImpl::ClearEntries() {
+void ClientImpl3::ClearEntries() {
   DEBUG4("ClearEntries()");
   if (m_state != kStateRunning) {
     m_decoder.SetError("received ClearEntries message before ServerHelloDone");
@@ -596,47 +458,14 @@
   m_outgoingFlags.resize(0);
 }
 
-class ClientImpl3::Impl final : public CImpl {
- public:
-  Impl(uint64_t curTimeMs, int inst, WireConnection3& wire, wpi::Logger& logger,
-       std::function<void(uint32_t repeatMs)> setPeriodic)
-      : CImpl{curTimeMs, inst, wire, logger, std::move(setPeriodic)} {}
-};
-
-ClientImpl3::ClientImpl3(uint64_t curTimeMs, int inst, WireConnection3& wire,
-                         wpi::Logger& logger,
-                         std::function<void(uint32_t repeatMs)> setPeriodic)
-    : m_impl{std::make_unique<Impl>(curTimeMs, inst, wire, logger,
-                                    std::move(setPeriodic))} {}
-
-ClientImpl3::~ClientImpl3() {
-  WPI_DEBUG4(m_impl->m_logger, "NT3 ClientImpl destroyed");
-}
-
 void ClientImpl3::Start(std::string_view selfId,
                         std::function<void()> succeeded) {
-  if (m_impl->m_state != CImpl::kStateInitial) {
+  if (m_state != kStateInitial) {
     return;
   }
-  m_impl->m_handshakeSucceeded = std::move(succeeded);
-  auto writer = m_impl->m_wire.Send();
+  m_handshakeSucceeded = std::move(succeeded);
+  auto writer = m_wire.Send();
   WireEncodeClientHello(writer.stream(), selfId, 0x0300);
-  m_impl->m_wire.Flush();
-  m_impl->m_state = CImpl::kStateHelloSent;
-}
-
-void ClientImpl3::ProcessIncoming(std::span<const uint8_t> data) {
-  m_impl->ProcessIncoming(data);
-}
-
-void ClientImpl3::HandleLocal(std::span<const net::ClientMessage> msgs) {
-  m_impl->HandleLocal(msgs);
-}
-
-void ClientImpl3::SendPeriodic(uint64_t curTimeMs) {
-  m_impl->SendPeriodic(curTimeMs, false);
-}
-
-void ClientImpl3::SetLocal(net::LocalInterface* local) {
-  m_impl->m_local = local;
+  m_wire.Flush();
+  m_state = kStateHelloSent;
 }
diff --git a/ntcore/src/main/native/cpp/net3/ClientImpl3.h b/ntcore/src/main/native/cpp/net3/ClientImpl3.h
index 484ea3d..49489c5 100644
--- a/ntcore/src/main/native/cpp/net3/ClientImpl3.h
+++ b/ntcore/src/main/native/cpp/net3/ClientImpl3.h
@@ -11,8 +11,18 @@
 #include <span>
 #include <string>
 #include <string_view>
+#include <utility>
+#include <vector>
 
+#include <wpi/StringMap.h>
+#include <wpi/json.h>
+
+#include "PubSubOptions.h"
 #include "net/NetworkInterface.h"
+#include "net3/Message3.h"
+#include "net3/SequenceNumber.h"
+#include "net3/WireConnection3.h"
+#include "net3/WireDecoder3.h"
 
 namespace wpi {
 class Logger;
@@ -27,24 +37,147 @@
 
 class WireConnection3;
 
-class ClientImpl3 {
+class ClientImpl3 final : private MessageHandler3 {
  public:
   explicit ClientImpl3(uint64_t curTimeMs, int inst, WireConnection3& wire,
                        wpi::Logger& logger,
                        std::function<void(uint32_t repeatMs)> setPeriodic);
-  ~ClientImpl3();
+  ~ClientImpl3() final;
 
   void Start(std::string_view selfId, std::function<void()> succeeded);
   void ProcessIncoming(std::span<const uint8_t> data);
   void HandleLocal(std::span<const net::ClientMessage> msgs);
 
-  void SendPeriodic(uint64_t curTimeMs);
+  void SendPeriodic(uint64_t curTimeMs, bool flush) {
+    DoSendPeriodic(curTimeMs, false, flush);
+  }
 
-  void SetLocal(net::LocalInterface* local);
+  void SetLocal(net::LocalInterface* local) { m_local = local; }
 
  private:
-  class Impl;
-  std::unique_ptr<Impl> m_impl;
+  struct Entry;
+
+  struct PublisherData {
+    explicit PublisherData(Entry* entry) : entry{entry} {}
+
+    Entry* entry;
+    NT_Publisher handle;
+    PubSubOptionsImpl options;
+    // in options as double, but copy here as integer; rounded to the nearest
+    // 10 ms
+    uint32_t periodMs;
+    uint64_t nextSendMs{0};
+    std::vector<Value> outValues;  // outgoing values
+  };
+
+  // data for each entry
+  struct Entry {
+    explicit Entry(std::string_view name_) : name(name_) {}
+    bool IsPersistent() const { return (flags & NT_PERSISTENT) != 0; }
+    wpi::json SetFlags(unsigned int flags_);
+
+    std::string name;
+
+    std::string typeStr;
+    NT_Type type{NT_UNASSIGNED};
+
+    wpi::json properties = wpi::json::object();
+
+    // The current value and flags
+    Value value;
+    unsigned int flags{0};
+
+    // Unique ID used in network messages; this is 0xffff until assigned
+    // by the server.
+    unsigned int id{0xffff};
+
+    // Sequence number for update resolution
+    SequenceNumber seqNum;
+
+    // Local topic handle
+    NT_Topic topic{0};
+
+    // Local publishers
+    std::vector<PublisherData*> publishers;
+  };
+
+  void DoSendPeriodic(uint64_t curTimeMs, bool initial, bool flush);
+  void SendValue(Writer& out, Entry* entry, const Value& value);
+  bool CheckNetworkReady(uint64_t curTimeMs);
+
+  // Outgoing handlers
+  void Publish(NT_Publisher pubHandle, NT_Topic topicHandle,
+               std::string_view name, std::string_view typeStr,
+               const wpi::json& properties, const PubSubOptionsImpl& options);
+  void Unpublish(NT_Publisher pubHandle, NT_Topic topicHandle);
+  void SetProperties(NT_Topic topicHandle, std::string_view name,
+                     const wpi::json& update);
+  void SetValue(NT_Publisher pubHandle, const Value& value);
+
+  // MessageHandler interface
+  void KeepAlive() final;
+  void ServerHelloDone() final;
+  void ClientHelloDone() final;
+  void ClearEntries() final;
+  void ProtoUnsup(unsigned int proto_rev) final;
+  void ClientHello(std::string_view self_id, unsigned int proto_rev) final;
+  void ServerHello(unsigned int flags, std::string_view self_id) final;
+  void EntryAssign(std::string_view name, unsigned int id, unsigned int seq_num,
+                   const Value& value, unsigned int flags) final;
+  void EntryUpdate(unsigned int id, unsigned int seq_num,
+                   const Value& value) final;
+  void FlagsUpdate(unsigned int id, unsigned int flags) final;
+  void EntryDelete(unsigned int id) final;
+  void ExecuteRpc(unsigned int id, unsigned int uid,
+                  std::span<const uint8_t> params) final {}
+  void RpcResponse(unsigned int id, unsigned int uid,
+                   std::span<const uint8_t> result) final {}
+
+  enum State {
+    kStateInitial,
+    kStateHelloSent,
+    kStateInitialAssignments,
+    kStateRunning
+  };
+
+  WireConnection3& m_wire;
+  wpi::Logger& m_logger;
+  net::LocalInterface* m_local{nullptr};
+  std::function<void(uint32_t repeatMs)> m_setPeriodic;
+  uint64_t m_initTimeMs;
+
+  // periodic sweep handling
+  static constexpr uint32_t kKeepAliveIntervalMs = 1000;
+  uint32_t m_periodMs{kKeepAliveIntervalMs + 10};
+  uint64_t m_lastSendMs{0};
+  uint64_t m_nextKeepAliveTimeMs;
+
+  // indexed by publisher index
+  std::vector<std::unique_ptr<PublisherData>> m_publishers;
+
+  State m_state{kStateInitial};
+  WireDecoder3 m_decoder;
+  std::string m_remoteId;
+  std::function<void()> m_handshakeSucceeded;
+
+  std::vector<std::pair<unsigned int, unsigned int>> m_outgoingFlags;
+
+  using NameMap = wpi::StringMap<std::unique_ptr<Entry>>;
+  using IdMap = std::vector<Entry*>;
+
+  NameMap m_nameMap;
+  IdMap m_idMap;
+
+  Entry* GetOrNewEntry(std::string_view name) {
+    auto& entry = m_nameMap[name];
+    if (!entry) {
+      entry = std::make_unique<Entry>(name);
+    }
+    return entry.get();
+  }
+  Entry* LookupId(unsigned int id) {
+    return id < m_idMap.size() ? m_idMap[id] : nullptr;
+  }
 };
 
 }  // namespace nt::net3
diff --git a/ntcore/src/main/native/cpp/net3/UvStreamConnection3.cpp b/ntcore/src/main/native/cpp/net3/UvStreamConnection3.cpp
index 93af700..efc4534 100644
--- a/ntcore/src/main/native/cpp/net3/UvStreamConnection3.cpp
+++ b/ntcore/src/main/native/cpp/net3/UvStreamConnection3.cpp
@@ -4,11 +4,14 @@
 
 #include "UvStreamConnection3.h"
 
+#include <wpi/timestamp.h>
 #include <wpinet/uv/Stream.h>
 
 using namespace nt;
 using namespace nt::net3;
 
+static constexpr size_t kMaxPoolSize = 16;
+
 UvStreamConnection3::UvStreamConnection3(wpi::uv::Stream& stream)
     : m_stream{stream}, m_os{m_buffers, [this] { return AllocBuf(); }} {}
 
@@ -25,7 +28,17 @@
   ++m_sendsActive;
   m_stream.Write(m_buffers, [selfweak = weak_from_this()](auto bufs, auto) {
     if (auto self = selfweak.lock()) {
-      self->m_buf_pool.insert(self->m_buf_pool.end(), bufs.begin(), bufs.end());
+#ifdef __SANITIZE_ADDRESS__
+      size_t numToPool = 0;
+#else
+      size_t numToPool =
+          (std::min)(bufs.size(), kMaxPoolSize - self->m_buf_pool.size());
+      self->m_buf_pool.insert(self->m_buf_pool.end(), bufs.begin(),
+                              bufs.begin() + numToPool);
+#endif
+      for (auto&& buf : bufs.subspan(numToPool)) {
+        buf.Deallocate();
+      }
       if (self->m_sendsActive > 0) {
         --self->m_sendsActive;
       }
@@ -33,6 +46,7 @@
   });
   m_buffers.clear();
   m_os.reset();
+  m_lastFlushTime = wpi::Now();
 }
 
 void UvStreamConnection3::Disconnect(std::string_view reason) {
diff --git a/ntcore/src/main/native/cpp/net3/UvStreamConnection3.h b/ntcore/src/main/native/cpp/net3/UvStreamConnection3.h
index f94c4a3..35eef02 100644
--- a/ntcore/src/main/native/cpp/net3/UvStreamConnection3.h
+++ b/ntcore/src/main/native/cpp/net3/UvStreamConnection3.h
@@ -38,6 +38,8 @@
 
   void Flush() final;
 
+  uint64_t GetLastFlushTime() const final { return m_lastFlushTime; }
+
   void Disconnect(std::string_view reason) final;
 
   std::string_view GetDisconnectReason() const { return m_reason; }
@@ -54,6 +56,7 @@
   std::vector<wpi::uv::Buffer> m_buf_pool;
   wpi::raw_uv_ostream m_os;
   std::string m_reason;
+  uint64_t m_lastFlushTime = 0;
   int m_sendsActive = 0;
 };
 
diff --git a/ntcore/src/main/native/cpp/net3/WireConnection3.h b/ntcore/src/main/native/cpp/net3/WireConnection3.h
index 85453d7..0bb26f6 100644
--- a/ntcore/src/main/native/cpp/net3/WireConnection3.h
+++ b/ntcore/src/main/native/cpp/net3/WireConnection3.h
@@ -4,6 +4,8 @@
 
 #pragma once
 
+#include <stdint.h>
+
 #include <string_view>
 
 namespace wpi {
@@ -26,6 +28,8 @@
 
   virtual void Flush() = 0;
 
+  virtual uint64_t GetLastFlushTime() const = 0;  // in microseconds
+
   virtual void Disconnect(std::string_view reason) = 0;
 
  protected:
diff --git a/ntcore/src/main/native/cpp/net3/WireDecoder3.cpp b/ntcore/src/main/native/cpp/net3/WireDecoder3.cpp
index ea08358..5118027 100644
--- a/ntcore/src/main/native/cpp/net3/WireDecoder3.cpp
+++ b/ntcore/src/main/native/cpp/net3/WireDecoder3.cpp
@@ -5,147 +5,24 @@
 #include "WireDecoder3.h"
 
 #include <algorithm>
-#include <optional>
 #include <string>
-#include <vector>
 
 #include <fmt/format.h>
 #include <wpi/MathExtras.h>
 #include <wpi/SpanExtras.h>
-#include <wpi/leb128.h>
 
 #include "Message3.h"
 
 using namespace nt;
 using namespace nt::net3;
 
-namespace {
-
-class SimpleValueReader {
- public:
-  std::optional<uint16_t> Read16(std::span<const uint8_t>* in);
-  std::optional<uint32_t> Read32(std::span<const uint8_t>* in);
-  std::optional<uint64_t> Read64(std::span<const uint8_t>* in);
-  std::optional<double> ReadDouble(std::span<const uint8_t>* in);
-
- private:
-  uint64_t m_value = 0;
-  int m_count = 0;
-};
-
-struct StringReader {
-  void SetLen(uint64_t len_) {
-    len = len_;
-    buf.clear();
-  }
-
-  std::optional<uint64_t> len;
-  std::string buf;
-};
-
-struct RawReader {
-  void SetLen(uint64_t len_) {
-    len = len_;
-    buf.clear();
-  }
-
-  std::optional<uint64_t> len;
-  std::vector<uint8_t> buf;
-};
-
-struct ValueReader {
-  ValueReader() = default;
-  explicit ValueReader(NT_Type type_) : type{type_} {}
-
-  void SetSize(uint32_t size_) {
-    haveSize = true;
-    size = size_;
-    ints.clear();
-    doubles.clear();
-    strings.clear();
-  }
-
-  NT_Type type = NT_UNASSIGNED;
-  bool haveSize = false;
-  uint32_t size = 0;
-  std::vector<int> ints;
-  std::vector<double> doubles;
-  std::vector<std::string> strings;
-};
-
-struct WDImpl {
-  explicit WDImpl(MessageHandler3& out) : m_out{out} {}
-
-  MessageHandler3& m_out;
-
-  // primary (message) decode state
-  enum {
-    kStart,
-    kClientHello_1ProtoRev,
-    kClientHello_2Id,
-    kProtoUnsup_1ProtoRev,
-    kServerHello_1Flags,
-    kServerHello_2Id,
-    kEntryAssign_1Name,
-    kEntryAssign_2Type,
-    kEntryAssign_3Id,
-    kEntryAssign_4SeqNum,
-    kEntryAssign_5Flags,
-    kEntryAssign_6Value,
-    kEntryUpdate_1Id,
-    kEntryUpdate_2SeqNum,
-    kEntryUpdate_3Type,
-    kEntryUpdate_4Value,
-    kFlagsUpdate_1Id,
-    kFlagsUpdate_2Flags,
-    kEntryDelete_1Id,
-    kClearEntries_1Magic,
-    kExecuteRpc_1Id,
-    kExecuteRpc_2Uid,
-    kExecuteRpc_3Params,
-    kRpcResponse_1Id,
-    kRpcResponse_2Uid,
-    kRpcResponse_3Result,
-    kError
-  } m_state = kStart;
-
-  // detail decoders
-  wpi::Uleb128Reader m_ulebReader;
-  SimpleValueReader m_simpleReader;
-  StringReader m_stringReader;
-  RawReader m_rawReader;
-  ValueReader m_valueReader;
-
-  std::string m_error;
-
-  std::string m_str;
-  unsigned int m_id{0};  // also used for proto_rev
-  unsigned int m_flags{0};
-  unsigned int m_seq_num_uid{0};
-
-  void Execute(std::span<const uint8_t>* in);
-
-  std::nullopt_t EmitError(std::string_view msg) {
-    m_state = kError;
-    m_error = msg;
-    return std::nullopt;
-  }
-
-  std::optional<std::string> ReadString(std::span<const uint8_t>* in);
-  std::optional<std::vector<uint8_t>> ReadRaw(std::span<const uint8_t>* in);
-  std::optional<NT_Type> ReadType(std::span<const uint8_t>* in);
-  std::optional<Value> ReadValue(std::span<const uint8_t>* in);
-};
-
-}  // namespace
-
 static uint8_t Read8(std::span<const uint8_t>* in) {
   uint8_t val = in->front();
   *in = wpi::drop_front(*in);
   return val;
 }
 
-std::optional<uint16_t> SimpleValueReader::Read16(
+std::optional<uint16_t> WireDecoder3::SimpleValueReader::Read16(
     std::span<const uint8_t>* in) {
   while (!in->empty()) {
     m_value <<= 8;
@@ -161,7 +38,7 @@
   return std::nullopt;
 }
 
-std::optional<uint32_t> SimpleValueReader::Read32(
+std::optional<uint32_t> WireDecoder3::SimpleValueReader::Read32(
     std::span<const uint8_t>* in) {
   while (!in->empty()) {
     m_value <<= 8;
@@ -177,7 +54,7 @@
   return std::nullopt;
 }
 
-std::optional<uint64_t> SimpleValueReader::Read64(
+std::optional<uint64_t> WireDecoder3::SimpleValueReader::Read64(
     std::span<const uint8_t>* in) {
   while (!in->empty()) {
     m_value <<= 8;
@@ -193,16 +70,16 @@
   return std::nullopt;
 }
 
-std::optional<double> SimpleValueReader::ReadDouble(
+std::optional<double> WireDecoder3::SimpleValueReader::ReadDouble(
     std::span<const uint8_t>* in) {
   if (auto val = Read64(in)) {
-    return wpi::BitsToDouble(val.value());
+    return wpi::bit_cast<double>(val.value());
   } else {
     return std::nullopt;
   }
 }
 
-void WDImpl::Execute(std::span<const uint8_t>* in) {
+void WireDecoder3::DoExecute(std::span<const uint8_t>* in) {
   while (!in->empty()) {
     switch (m_state) {
       case kStart: {
@@ -417,7 +294,8 @@
   }
 }
 
-std::optional<std::string> WDImpl::ReadString(std::span<const uint8_t>* in) {
+std::optional<std::string> WireDecoder3::ReadString(
+    std::span<const uint8_t>* in) {
   // string length
   if (!m_stringReader.len) {
     if (auto val = m_ulebReader.ReadOne(in)) {
@@ -442,7 +320,7 @@
   return std::nullopt;
 }
 
-std::optional<std::vector<uint8_t>> WDImpl::ReadRaw(
+std::optional<std::vector<uint8_t>> WireDecoder3::ReadRaw(
     std::span<const uint8_t>* in) {
   // string length
   if (!m_rawReader.len) {
@@ -468,7 +346,7 @@
   return std::nullopt;
 }
 
-std::optional<NT_Type> WDImpl::ReadType(std::span<const uint8_t>* in) {
+std::optional<NT_Type> WireDecoder3::ReadType(std::span<const uint8_t>* in) {
   // Convert from byte value to enum
   switch (Read8(in)) {
     case Message3::kBoolean:
@@ -492,7 +370,7 @@
   }
 }
 
-std::optional<Value> WDImpl::ReadValue(std::span<const uint8_t>* in) {
+std::optional<Value> WireDecoder3::ReadValue(std::span<const uint8_t>* in) {
   while (!in->empty()) {
     switch (m_valueReader.type) {
       case NT_BOOLEAN:
@@ -577,24 +455,3 @@
   }
   return std::nullopt;
 }
-
-struct WireDecoder3::Impl : public WDImpl {
-  explicit Impl(MessageHandler3& out) : WDImpl{out} {}
-};
-
-WireDecoder3::WireDecoder3(MessageHandler3& out) : m_impl{new Impl{out}} {}
-
-WireDecoder3::~WireDecoder3() = default;
-
-bool WireDecoder3::Execute(std::span<const uint8_t>* in) {
-  m_impl->Execute(in);
-  return m_impl->m_state != Impl::kError;
-}
-
-void WireDecoder3::SetError(std::string_view message) {
-  m_impl->EmitError(message);
-}
-
-std::string WireDecoder3::GetError() const {
-  return m_impl->m_error;
-}
diff --git a/ntcore/src/main/native/cpp/net3/WireDecoder3.h b/ntcore/src/main/native/cpp/net3/WireDecoder3.h
index e877833..48064f7 100644
--- a/ntcore/src/main/native/cpp/net3/WireDecoder3.h
+++ b/ntcore/src/main/native/cpp/net3/WireDecoder3.h
@@ -7,8 +7,14 @@
 #include <stdint.h>
 
 #include <memory>
+#include <optional>
 #include <span>
 #include <string>
+#include <vector>
+
+#include <wpi/leb128.h>
+
+#include "ntcore_c.h"
 
 namespace nt {
 class Value;
@@ -18,6 +24,8 @@
 
 class MessageHandler3 {
  public:
+  virtual ~MessageHandler3() = default;
+
   virtual void KeepAlive() = 0;
   virtual void ServerHelloDone() = 0;
   virtual void ClientHelloDone() = 0;
@@ -42,8 +50,7 @@
 /* Decodes NT3 protocol into native representation. */
 class WireDecoder3 {
  public:
-  explicit WireDecoder3(MessageHandler3& out);
-  ~WireDecoder3();
+  explicit WireDecoder3(MessageHandler3& out) : m_out{out} {}
 
   /**
    * Executes the decoder.  All input data will be consumed unless an error
@@ -51,14 +58,126 @@
    * @param in input data (updated during parse)
    * @return false if error occurred
    */
-  bool Execute(std::span<const uint8_t>* in);
+  bool Execute(std::span<const uint8_t>* in) {
+    DoExecute(in);
+    return m_state != kError;
+  }
 
-  void SetError(std::string_view message);
-  std::string GetError() const;
+  void SetError(std::string_view message) { EmitError(message); }
+  std::string GetError() const { return m_error; }
 
  private:
-  struct Impl;
-  std::unique_ptr<Impl> m_impl;
+  class SimpleValueReader {
+   public:
+    std::optional<uint16_t> Read16(std::span<const uint8_t>* in);
+    std::optional<uint32_t> Read32(std::span<const uint8_t>* in);
+    std::optional<uint64_t> Read64(std::span<const uint8_t>* in);
+    std::optional<double> ReadDouble(std::span<const uint8_t>* in);
+
+   private:
+    uint64_t m_value = 0;
+    int m_count = 0;
+  };
+
+  struct StringReader {
+    void SetLen(uint64_t len_) {
+      len = len_;
+      buf.clear();
+    }
+
+    std::optional<uint64_t> len;
+    std::string buf;
+  };
+
+  struct RawReader {
+    void SetLen(uint64_t len_) {
+      len = len_;
+      buf.clear();
+    }
+
+    std::optional<uint64_t> len;
+    std::vector<uint8_t> buf;
+  };
+
+  struct ValueReader {
+    ValueReader() = default;
+    explicit ValueReader(NT_Type type_) : type{type_} {}
+
+    void SetSize(uint32_t size_) {
+      haveSize = true;
+      size = size_;
+      ints.clear();
+      doubles.clear();
+      strings.clear();
+    }
+
+    NT_Type type = NT_UNASSIGNED;
+    bool haveSize = false;
+    uint32_t size = 0;
+    std::vector<int> ints;
+    std::vector<double> doubles;
+    std::vector<std::string> strings;
+  };
+
+  MessageHandler3& m_out;
+
+  // primary (message) decode state
+  enum {
+    kStart,
+    kClientHello_1ProtoRev,
+    kClientHello_2Id,
+    kProtoUnsup_1ProtoRev,
+    kServerHello_1Flags,
+    kServerHello_2Id,
+    kEntryAssign_1Name,
+    kEntryAssign_2Type,
+    kEntryAssign_3Id,
+    kEntryAssign_4SeqNum,
+    kEntryAssign_5Flags,
+    kEntryAssign_6Value,
+    kEntryUpdate_1Id,
+    kEntryUpdate_2SeqNum,
+    kEntryUpdate_3Type,
+    kEntryUpdate_4Value,
+    kFlagsUpdate_1Id,
+    kFlagsUpdate_2Flags,
+    kEntryDelete_1Id,
+    kClearEntries_1Magic,
+    kExecuteRpc_1Id,
+    kExecuteRpc_2Uid,
+    kExecuteRpc_3Params,
+    kRpcResponse_1Id,
+    kRpcResponse_2Uid,
+    kRpcResponse_3Result,
+    kError
+  } m_state = kStart;
+
+  // detail decoders
+  wpi::Uleb128Reader m_ulebReader;
+  SimpleValueReader m_simpleReader;
+  StringReader m_stringReader;
+  RawReader m_rawReader;
+  ValueReader m_valueReader;
+
+  std::string m_error;
+
+  std::string m_str;
+  unsigned int m_id{0};  // also used for proto_rev
+  unsigned int m_flags{0};
+  unsigned int m_seq_num_uid{0};
+
+  void DoExecute(std::span<const uint8_t>* in);
+
+  std::nullopt_t EmitError(std::string_view msg) {
+    m_state = kError;
+    m_error = msg;
+    return std::nullopt;
+  }
+
+  std::optional<std::string> ReadString(std::span<const uint8_t>* in);
+  std::optional<std::vector<uint8_t>> ReadRaw(std::span<const uint8_t>* in);
+  std::optional<NT_Type> ReadType(std::span<const uint8_t>* in);
+  std::optional<Value> ReadValue(std::span<const uint8_t>* in);
 };
 
 }  // namespace nt::net3
diff --git a/ntcore/src/main/native/cpp/net3/WireEncoder3.cpp b/ntcore/src/main/native/cpp/net3/WireEncoder3.cpp
index 6bf3435..d1e4c78 100644
--- a/ntcore/src/main/native/cpp/net3/WireEncoder3.cpp
+++ b/ntcore/src/main/native/cpp/net3/WireEncoder3.cpp
@@ -32,7 +32,7 @@
 
 static void WriteDouble(wpi::raw_ostream& os, double val) {
   // The highest performance way to do this, albeit non-portable.
-  uint64_t v = wpi::DoubleToBits(val);
+  uint64_t v = wpi::bit_cast<uint64_t>(val);
   os << std::span<const uint8_t>{{static_cast<uint8_t>((v >> 56) & 0xff),
                                   static_cast<uint8_t>((v >> 48) & 0xff),
                                   static_cast<uint8_t>((v >> 40) & 0xff),
diff --git a/ntcore/src/main/native/cpp/networktables/NetworkTable.cpp b/ntcore/src/main/native/cpp/networktables/NetworkTable.cpp
index 1f6a760..77b726a 100644
--- a/ntcore/src/main/native/cpp/networktables/NetworkTable.cpp
+++ b/ntcore/src/main/native/cpp/networktables/NetworkTable.cpp
@@ -231,8 +231,14 @@
     if (end_subtable == std::string_view::npos) {
       continue;
     }
-    keys.emplace_back(wpi::substr(relative_key, 0, end_subtable));
+    auto subTable = wpi::substr(relative_key, 0, end_subtable);
+    if (keys.empty() || keys.back() != subTable) {
+      keys.emplace_back(subTable);
+    }
   }
+  // remove duplicates
+  std::sort(keys.begin(), keys.end());
+  keys.erase(std::unique(keys.begin(), keys.end()), keys.end());
   return keys;
 }
 
diff --git a/ntcore/src/main/native/cpp/networktables/Topic.cpp b/ntcore/src/main/native/cpp/networktables/Topic.cpp
index e3f6ac0..1188667 100644
--- a/ntcore/src/main/native/cpp/networktables/Topic.cpp
+++ b/ntcore/src/main/native/cpp/networktables/Topic.cpp
@@ -7,9 +7,14 @@
 #include <wpi/json.h>
 
 #include "networktables/GenericEntry.h"
+#include "networktables/NetworkTableInstance.h"
 
 using namespace nt;
 
+NetworkTableInstance Topic::GetInstance() const {
+  return NetworkTableInstance{GetInstanceFromHandle(m_handle)};
+}
+
 wpi::json Topic::GetProperty(std::string_view name) const {
   return ::nt::GetTopicProperty(m_handle, name);
 }
diff --git a/ntcore/src/main/native/cpp/ntcore_c.cpp b/ntcore/src/main/native/cpp/ntcore_c.cpp
index 151e0f1..b432664 100644
--- a/ntcore/src/main/native/cpp/ntcore_c.cpp
+++ b/ntcore/src/main/native/cpp/ntcore_c.cpp
@@ -537,6 +537,10 @@
   nt::SetServerTeam(inst, team, port);
 }
 
+void NT_Disconnect(NT_Inst inst) {
+  nt::Disconnect(inst);
+}
+
 void NT_StartDSClient(NT_Inst inst, unsigned int port) {
   nt::StartDSClient(inst, port);
 }
@@ -584,6 +588,27 @@
   nt::SetNow(timestamp);
 }
 
+NT_DataLogger NT_StartEntryDataLog(NT_Inst inst, struct WPI_DataLog* log,
+                                   const char* prefix, const char* logPrefix) {
+  return nt::StartEntryDataLog(inst, *reinterpret_cast<wpi::log::DataLog*>(log),
+                               prefix, logPrefix);
+}
+
+void NT_StopEntryDataLog(NT_DataLogger logger) {
+  nt::StopEntryDataLog(logger);
+}
+
+NT_ConnectionDataLogger NT_StartConnectionDataLog(NT_Inst inst,
+                                                  struct WPI_DataLog* log,
+                                                  const char* name) {
+  return nt::StartConnectionDataLog(
+      inst, *reinterpret_cast<wpi::log::DataLog*>(log), name);
+}
+
+void NT_StopConnectionDataLog(NT_ConnectionDataLogger logger) {
+  nt::StopConnectionDataLog(logger);
+}
+
 NT_Listener NT_AddLogger(NT_Inst inst, unsigned int min_level,
                          unsigned int max_level, void* data,
                          NT_ListenerCallback func) {
@@ -600,6 +625,15 @@
   return nt::AddPolledLogger(poller, min_level, max_level);
 }
 
+NT_Bool NT_HasSchema(NT_Inst inst, const char* name) {
+  return nt::HasSchema(inst, name);
+}
+
+void NT_AddSchema(NT_Inst inst, const char* name, const char* type,
+                  const uint8_t* schema, size_t schemaSize) {
+  nt::AddSchema(inst, name, type, {schema, schemaSize});
+}
+
 void NT_DisposeValue(NT_Value* value) {
   switch (value->type) {
     case NT_UNASSIGNED:
diff --git a/ntcore/src/main/native/cpp/ntcore_cpp.cpp b/ntcore/src/main/native/cpp/ntcore_cpp.cpp
index cfb5af2..4b48f05 100644
--- a/ntcore/src/main/native/cpp/ntcore_cpp.cpp
+++ b/ntcore/src/main/native/cpp/ntcore_cpp.cpp
@@ -685,6 +685,14 @@
   }
 }
 
+void Disconnect(NT_Inst inst) {
+  if (auto ii = InstanceImpl::GetTyped(inst, Handle::kInstance)) {
+    if (auto client = ii->GetClient()) {
+      client->Disconnect();
+    }
+  }
+}
+
 void StartDSClient(NT_Inst inst, unsigned int port) {
   if (auto ii = InstanceImpl::GetTyped(inst, Handle::kInstance)) {
     if (auto client = ii->GetClient()) {
@@ -774,4 +782,19 @@
   }
 }
 
+bool HasSchema(NT_Inst inst, std::string_view name) {
+  if (auto ii = InstanceImpl::GetTyped(inst, Handle::kInstance)) {
+    return ii->localStorage.HasSchema(name);
+  } else {
+    return false;
+  }
+}
+
+void AddSchema(NT_Inst inst, std::string_view name, std::string_view type,
+               std::span<const uint8_t> schema) {
+  if (auto ii = InstanceImpl::GetTyped(inst, Handle::kInstance)) {
+    ii->localStorage.AddSchema(name, type, schema);
+  }
+}
+
 }  // namespace nt
diff --git a/ntcore/src/main/native/include/networktables/NetworkTable.h b/ntcore/src/main/native/include/networktables/NetworkTable.h
index d34f54b..e03ea9e 100644
--- a/ntcore/src/main/native/include/networktables/NetworkTable.h
+++ b/ntcore/src/main/native/include/networktables/NetworkTable.h
@@ -14,8 +14,11 @@
 
 #include <wpi/StringMap.h>
 #include <wpi/mutex.h>
+#include <wpi/protobuf/Protobuf.h>
+#include <wpi/struct/Struct.h>
 
 #include "networktables/NetworkTableEntry.h"
+#include "networktables/Topic.h"
 #include "ntcore_c.h"
 
 namespace nt {
@@ -29,9 +32,15 @@
 class IntegerArrayTopic;
 class IntegerTopic;
 class NetworkTableInstance;
+template <wpi::ProtobufSerializable T>
+class ProtobufTopic;
 class RawTopic;
 class StringArrayTopic;
 class StringTopic;
+template <wpi::StructSerializable T>
+class StructArrayTopic;
+template <wpi::StructSerializable T>
+class StructTopic;
 class Topic;
 
 /**
@@ -221,6 +230,39 @@
   StringArrayTopic GetStringArrayTopic(std::string_view name) const;
 
   /**
+   * Gets a protobuf serialized value topic.
+   *
+   * @param name topic name
+   * @return Topic
+   */
+  template <wpi::ProtobufSerializable T>
+  ProtobufTopic<T> GetProtobufTopic(std::string_view name) const {
+    return ProtobufTopic<T>{GetTopic(name)};
+  }
+
+  /**
+   * Gets a raw struct serialized value topic.
+   *
+   * @param name topic name
+   * @return Topic
+   */
+  template <wpi::StructSerializable T>
+  StructTopic<T> GetStructTopic(std::string_view name) const {
+    return StructTopic<T>{GetTopic(name)};
+  }
+
+  /**
+   * Gets a raw struct serialized array topic.
+   *
+   * @param name topic name
+   * @return Topic
+   */
+  template <wpi::StructSerializable T>
+  StructArrayTopic<T> GetStructArrayTopic(std::string_view name) const {
+    return StructArrayTopic<T>{GetTopic(name)};
+  }
+
+  /**
    * Returns the table at the specified key. If there is no table at the
    * specified key, it will create a new table
    *
diff --git a/ntcore/src/main/native/include/networktables/NetworkTableEntry.h b/ntcore/src/main/native/include/networktables/NetworkTableEntry.h
index b9d509a..31fe9a6 100644
--- a/ntcore/src/main/native/include/networktables/NetworkTableEntry.h
+++ b/ntcore/src/main/native/include/networktables/NetworkTableEntry.h
@@ -13,8 +13,6 @@
 #include <string_view>
 #include <vector>
 
-#include <wpi/deprecated.h>
-
 #include "networktables/NetworkTableType.h"
 #include "networktables/NetworkTableValue.h"
 #include "ntcore_c.h"
@@ -101,7 +99,7 @@
    * @return the flags (bitmask)
    * @deprecated Use IsPersistent() or topic properties instead
    */
-  WPI_DEPRECATED("Use IsPersistent() or topic properties instead")
+  [[deprecated("Use IsPersistent() or topic properties instead")]]
   unsigned int GetFlags() const;
 
   /**
@@ -468,7 +466,7 @@
    * @param flags the flags to set (bitmask)
    * @deprecated Use SetPersistent() or topic properties instead
    */
-  WPI_DEPRECATED("Use SetPersistent() or topic properties instead")
+  [[deprecated("Use SetPersistent() or topic properties instead")]]
   void SetFlags(unsigned int flags);
 
   /**
@@ -477,7 +475,7 @@
    * @param flags the flags to clear (bitmask)
    * @deprecated Use SetPersistent() or topic properties instead
    */
-  WPI_DEPRECATED("Use SetPersistent() or topic properties instead")
+  [[deprecated("Use SetPersistent() or topic properties instead")]]
   void ClearFlags(unsigned int flags);
 
   /**
@@ -506,7 +504,7 @@
    * Deletes the entry.
    * @deprecated Use Unpublish() instead.
    */
-  WPI_DEPRECATED("Use Unpublish() instead")
+  [[deprecated("Use Unpublish() instead")]]
   void Delete();
 
   /**
diff --git a/ntcore/src/main/native/include/networktables/NetworkTableInstance.h b/ntcore/src/main/native/include/networktables/NetworkTableInstance.h
index fabc634..06e2cd6 100644
--- a/ntcore/src/main/native/include/networktables/NetworkTableInstance.h
+++ b/ntcore/src/main/native/include/networktables/NetworkTableInstance.h
@@ -13,6 +13,9 @@
 #include <utility>
 #include <vector>
 
+#include <wpi/protobuf/Protobuf.h>
+#include <wpi/struct/Struct.h>
+
 #include "networktables/NetworkTable.h"
 #include "networktables/NetworkTableEntry.h"
 #include "ntcore_c.h"
@@ -29,9 +32,15 @@
 class IntegerArrayTopic;
 class IntegerTopic;
 class MultiSubscriber;
+template <wpi::ProtobufSerializable T>
+class ProtobufTopic;
 class RawTopic;
 class StringArrayTopic;
 class StringTopic;
+template <wpi::StructSerializable T>
+class StructArrayTopic;
+template <wpi::StructSerializable T>
+class StructTopic;
 class Subscriber;
 class Topic;
 
@@ -239,6 +248,33 @@
   StringArrayTopic GetStringArrayTopic(std::string_view name) const;
 
   /**
+   * Gets a protobuf serialized value topic.
+   *
+   * @param name topic name
+   * @return Topic
+   */
+  template <wpi::ProtobufSerializable T>
+  ProtobufTopic<T> GetProtobufTopic(std::string_view name) const;
+
+  /**
+   * Gets a raw struct serialized value topic.
+   *
+   * @param name topic name
+   * @return Topic
+   */
+  template <wpi::StructSerializable T>
+  StructTopic<T> GetStructTopic(std::string_view name) const;
+
+  /**
+   * Gets a raw struct serialized array topic.
+   *
+   * @param name topic name
+   * @return Topic
+   */
+  template <wpi::StructSerializable T>
+  StructArrayTopic<T> GetStructArrayTopic(std::string_view name) const;
+
+  /**
    * Get Published Topics.
    *
    * Returns an array of topics.
@@ -587,6 +623,12 @@
   void SetServerTeam(unsigned int team, unsigned int port = 0);
 
   /**
+   * Disconnects the client if it's running and connected. This will
+   * automatically start reconnection attempts to the current server list.
+   */
+  void Disconnect();
+
+  /**
    * Starts requesting server address from Driver Station.
    * This connects to the Driver Station running on localhost to obtain the
    * server IP address.
@@ -713,6 +755,75 @@
   /** @} */
 
   /**
+   * @{
+   * @name Schema Functions
+   */
+
+  /**
+   * Returns whether there is a data schema already registered with the given
+   * name. This does NOT perform a check as to whether the schema has already
+   * been published by another node on the network.
+   *
+   * @param name Name (the string passed as the data type for topics using this
+   *             schema)
+   * @return True if schema already registered
+   */
+  bool HasSchema(std::string_view name) const;
+
+  /**
+   * Registers a data schema.  Data schemas provide information for how a
+   * certain data type string can be decoded.  The type string of a data schema
+   * indicates the type of the schema itself (e.g. "protobuf" for protobuf
+   * schemas, "struct" for struct schemas, etc). In NetworkTables, schemas are
+   * published just like normal topics, with the name being generated from the
+   * provided name: "/.schema/<name>".  Duplicate calls to this function with
+   * the same name are silently ignored.
+   *
+   * @param name Name (the string passed as the data type for topics using this
+   *             schema)
+   * @param type Type of schema (e.g. "protobuf", "struct", etc)
+   * @param schema Schema data
+   */
+  void AddSchema(std::string_view name, std::string_view type,
+                 std::span<const uint8_t> schema);
+
+  /**
+   * Registers a data schema.  Data schemas provide information for how a
+   * certain data type string can be decoded.  The type string of a data schema
+   * indicates the type of the schema itself (e.g. "protobuf" for protobuf
+   * schemas, "struct" for struct schemas, etc). In NetworkTables, schemas are
+   * published just like normal topics, with the name being generated from the
+   * provided name: "/.schema/<name>".  Duplicate calls to this function with
+   * the same name are silently ignored.
+   *
+   * @param name Name (the string passed as the data type for topics using this
+   *             schema)
+   * @param type Type of schema (e.g. "protobuf", "struct", etc)
+   * @param schema Schema data
+   */
+  void AddSchema(std::string_view name, std::string_view type,
+                 std::string_view schema);
+
+  /**
+   * Registers a protobuf schema. Duplicate calls to this function with the same
+   * name are silently ignored.
+   *
+   * @tparam T protobuf serializable type
+   * @param msg protobuf message
+   */
+  template <wpi::ProtobufSerializable T>
+  void AddProtobufSchema(wpi::ProtobufMessage<T>& msg);
+
+  /**
+   * Registers a struct schema. Duplicate calls to this function with the same
+   * name are silently ignored.
+   *
+   * @param T struct serializable type
+   */
+  template <wpi::StructSerializable T>
+  void AddStructSchema();
+
+  /**
    * Equality operator.  Returns true if both instances refer to the same
    * native handle.
    */
diff --git a/ntcore/src/main/native/include/networktables/NetworkTableInstance.inc b/ntcore/src/main/native/include/networktables/NetworkTableInstance.inc
index 9b712eb..fdd517e 100644
--- a/ntcore/src/main/native/include/networktables/NetworkTableInstance.inc
+++ b/ntcore/src/main/native/include/networktables/NetworkTableInstance.inc
@@ -38,6 +38,24 @@
   return m_handle;
 }
 
+template <wpi::ProtobufSerializable T>
+inline ProtobufTopic<T> NetworkTableInstance::GetProtobufTopic(
+    std::string_view name) const {
+  return ProtobufTopic<T>{GetTopic(name)};
+}
+
+template <wpi::StructSerializable T>
+inline StructTopic<T> NetworkTableInstance::GetStructTopic(
+    std::string_view name) const {
+  return StructTopic<T>{GetTopic(name)};
+}
+
+template <wpi::StructSerializable T>
+inline StructArrayTopic<T> NetworkTableInstance::GetStructArrayTopic(
+    std::string_view name) const {
+  return StructArrayTopic<T>{GetTopic(name)};
+}
+
 inline std::vector<Topic> NetworkTableInstance::GetTopics() {
   auto handles = ::nt::GetTopics(m_handle, "", 0);
   return {handles.begin(), handles.end()};
@@ -163,6 +181,10 @@
   ::nt::SetServerTeam(m_handle, team, port);
 }
 
+inline void NetworkTableInstance::Disconnect() {
+  ::nt::Disconnect(m_handle);
+}
+
 inline void NetworkTableInstance::StartDSClient(unsigned int port) {
   ::nt::StartDSClient(m_handle, port);
 }
@@ -219,4 +241,36 @@
   return ::nt::AddLogger(m_handle, min_level, max_level, std::move(func));
 }
 
+inline bool NetworkTableInstance::HasSchema(std::string_view name) const {
+  return ::nt::HasSchema(m_handle, name);
+}
+
+inline void NetworkTableInstance::AddSchema(std::string_view name,
+                                            std::string_view type,
+                                            std::span<const uint8_t> schema) {
+  ::nt::AddSchema(m_handle, name, type, schema);
+}
+
+inline void NetworkTableInstance::AddSchema(std::string_view name,
+                                            std::string_view type,
+                                            std::string_view schema) {
+  ::nt::AddSchema(m_handle, name, type, schema);
+}
+
+template <wpi::ProtobufSerializable T>
+void NetworkTableInstance::AddProtobufSchema(wpi::ProtobufMessage<T>& msg) {
+  msg.ForEachProtobufDescriptor(
+      [this](auto typeString) { return HasSchema(typeString); },
+      [this](auto typeString, auto schema) {
+        AddSchema(typeString, "proto:FileDescriptorProto", schema);
+      });
+}
+
+template <wpi::StructSerializable T>
+void NetworkTableInstance::AddStructSchema() {
+  wpi::ForEachStructSchema<T>([this](auto typeString, auto schema) {
+    AddSchema(typeString, "structschema", schema);
+  });
+}
+
 }  // namespace nt
diff --git a/ntcore/src/main/native/include/networktables/NetworkTableValue.h b/ntcore/src/main/native/include/networktables/NetworkTableValue.h
index 673a833..80cee51 100644
--- a/ntcore/src/main/native/include/networktables/NetworkTableValue.h
+++ b/ntcore/src/main/native/include/networktables/NetworkTableValue.h
@@ -7,12 +7,12 @@
 #include <stdint.h>
 
 #include <cassert>
+#include <concepts>
 #include <initializer_list>
 #include <memory>
 #include <span>
 #include <string>
 #include <string_view>
-#include <type_traits>
 #include <utility>
 #include <vector>
 
@@ -20,6 +20,11 @@
 
 namespace nt {
 
+#if __GNUC__ >= 13
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wmaybe-uninitialized"
+#endif
+
 /**
  * A network table entry value.
  * @ingroup ntcore_cpp_api
@@ -29,8 +34,9 @@
 
  public:
   Value();
-  Value(NT_Type type, int64_t time, const private_init&);
-  Value(NT_Type type, int64_t time, int64_t serverTime, const private_init&);
+  Value(NT_Type type, size_t size, int64_t time, const private_init&);
+  Value(NT_Type type, size_t size, int64_t time, int64_t serverTime,
+        const private_init&);
 
   explicit operator bool() const { return m_val.type != NT_UNASSIGNED; }
 
@@ -63,6 +69,15 @@
   int64_t time() const { return m_val.last_change; }
 
   /**
+   * Get the approximate in-memory size of the value in bytes. This is zero for
+   * values that do not require additional memory beyond the memory of the Value
+   * itself.
+   *
+   * @return The size in bytes.
+   */
+  size_t size() const { return m_size; }
+
+  /**
    * Set the local creation time of the value.
    *
    * @param time The time.
@@ -305,7 +320,7 @@
    * @return The entry value
    */
   static Value MakeBoolean(bool value, int64_t time = 0) {
-    Value val{NT_BOOLEAN, time, private_init{}};
+    Value val{NT_BOOLEAN, 0, time, private_init{}};
     val.m_val.data.v_boolean = value;
     return val;
   }
@@ -319,7 +334,7 @@
    * @return The entry value
    */
   static Value MakeInteger(int64_t value, int64_t time = 0) {
-    Value val{NT_INTEGER, time, private_init{}};
+    Value val{NT_INTEGER, 0, time, private_init{}};
     val.m_val.data.v_int = value;
     return val;
   }
@@ -333,7 +348,7 @@
    * @return The entry value
    */
   static Value MakeFloat(float value, int64_t time = 0) {
-    Value val{NT_FLOAT, time, private_init{}};
+    Value val{NT_FLOAT, 0, time, private_init{}};
     val.m_val.data.v_float = value;
     return val;
   }
@@ -347,7 +362,7 @@
    * @return The entry value
    */
   static Value MakeDouble(double value, int64_t time = 0) {
-    Value val{NT_DOUBLE, time, private_init{}};
+    Value val{NT_DOUBLE, 0, time, private_init{}};
     val.m_val.data.v_double = value;
     return val;
   }
@@ -361,8 +376,8 @@
    * @return The entry value
    */
   static Value MakeString(std::string_view value, int64_t time = 0) {
-    Value val{NT_STRING, time, private_init{}};
     auto data = std::make_shared<std::string>(value);
+    Value val{NT_STRING, data->capacity(), time, private_init{}};
     val.m_val.data.v_string.str = const_cast<char*>(data->c_str());
     val.m_val.data.v_string.len = data->size();
     val.m_storage = std::move(data);
@@ -377,11 +392,10 @@
    *             time)
    * @return The entry value
    */
-  template <typename T,
-            typename std::enable_if<std::is_same<T, std::string>::value>::type>
+  template <std::same_as<std::string> T>
   static Value MakeString(T&& value, int64_t time = 0) {
-    Value val{NT_STRING, time, private_init{}};
-    auto data = std::make_shared<std::string>(std::forward(value));
+    auto data = std::make_shared<std::string>(std::forward<T>(value));
+    Value val{NT_STRING, data->capacity(), time, private_init{}};
     val.m_val.data.v_string.str = const_cast<char*>(data->c_str());
     val.m_val.data.v_string.len = data->size();
     val.m_storage = std::move(data);
@@ -397,9 +411,9 @@
    * @return The entry value
    */
   static Value MakeRaw(std::span<const uint8_t> value, int64_t time = 0) {
-    Value val{NT_RAW, time, private_init{}};
     auto data =
         std::make_shared<std::vector<uint8_t>>(value.begin(), value.end());
+    Value val{NT_RAW, data->capacity(), time, private_init{}};
     val.m_val.data.v_raw.data = const_cast<uint8_t*>(data->data());
     val.m_val.data.v_raw.size = data->size();
     val.m_storage = std::move(data);
@@ -414,11 +428,10 @@
    *             time)
    * @return The entry value
    */
-  template <typename T, typename std::enable_if<
-                            std::is_same<T, std::vector<uint8_t>>::value>::type>
+  template <std::same_as<std::vector<uint8_t>> T>
   static Value MakeRaw(T&& value, int64_t time = 0) {
-    Value val{NT_RAW, time, private_init{}};
-    auto data = std::make_shared<std::vector<uint8_t>>(std::forward(value));
+    auto data = std::make_shared<std::vector<uint8_t>>(std::forward<T>(value));
+    Value val{NT_RAW, data->capacity(), time, private_init{}};
     val.m_val.data.v_raw.data = const_cast<uint8_t*>(data->data());
     val.m_val.data.v_raw.size = data->size();
     val.m_storage = std::move(data);
@@ -631,10 +644,15 @@
   friend bool operator==(const Value& lhs, const Value& rhs);
 
  private:
-  NT_Value m_val;
+  NT_Value m_val = {};
   std::shared_ptr<void> m_storage;
+  size_t m_size = 0;
 };
 
+#if __GNUC__ >= 13
+#pragma GCC diagnostic pop
+#endif
+
 bool operator==(const Value& lhs, const Value& rhs);
 
 /**
diff --git a/ntcore/src/main/native/include/networktables/ProtobufTopic.h b/ntcore/src/main/native/include/networktables/ProtobufTopic.h
new file mode 100644
index 0000000..4c30bf7
--- /dev/null
+++ b/ntcore/src/main/native/include/networktables/ProtobufTopic.h
@@ -0,0 +1,474 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <stdint.h>
+
+#include <atomic>
+#include <concepts>
+#include <span>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include <wpi/SmallVector.h>
+#include <wpi/mutex.h>
+#include <wpi/protobuf/Protobuf.h>
+
+#include "networktables/NetworkTableInstance.h"
+#include "networktables/Topic.h"
+#include "ntcore_cpp.h"
+
+namespace wpi {
+class json;
+}  // namespace wpi
+
+namespace nt {
+
+template <wpi::ProtobufSerializable T>
+class ProtobufTopic;
+
+/**
+ * NetworkTables protobuf-encoded value subscriber.
+ */
+template <wpi::ProtobufSerializable T>
+class ProtobufSubscriber : public Subscriber {
+ public:
+  using TopicType = ProtobufTopic<T>;
+  using ValueType = T;
+  using ParamType = const T&;
+  using TimestampedValueType = Timestamped<T>;
+
+  ProtobufSubscriber() = default;
+
+  /**
+   * Construct from a subscriber handle; recommended to use
+   * ProtobufTopic::Subscribe() instead.
+   *
+   * @param handle Native handle
+   * @param msg Protobuf message
+   * @param defaultValue Default value
+   */
+  ProtobufSubscriber(NT_Subscriber handle, wpi::ProtobufMessage<T> msg,
+                     T defaultValue)
+      : Subscriber{handle},
+        m_msg{std::move(msg)},
+        m_defaultValue{std::move(defaultValue)} {}
+
+  ProtobufSubscriber(const ProtobufSubscriber&) = delete;
+  ProtobufSubscriber& operator=(const ProtobufSubscriber&) = delete;
+
+  ProtobufSubscriber(ProtobufSubscriber&& rhs)
+      : Subscriber{std::move(rhs)},
+        m_msg{std::move(rhs.m_msg)},
+        m_defaultValue{std::move(rhs.defaultValue)} {}
+
+  ProtobufSubscriber& operator=(ProtobufSubscriber&& rhs) {
+    Subscriber::operator=(std::move(rhs));
+    m_msg = std::move(rhs.m_msg);
+    m_defaultValue = std::move(rhs.defaultValue);
+    return *this;
+  }
+
+  /**
+   * Get the last published value.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * stored default value.
+   *
+   * @return value
+   */
+  ValueType Get() const { return Get(m_defaultValue); }
+
+  /**
+   * Get the last published value.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * passed defaultValue.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return value
+   */
+  ValueType Get(const T& defaultValue) const {
+    return GetAtomic(defaultValue).value;
+  }
+
+  /**
+   * Get the last published value, replacing the contents in place of an
+   * existing object. If no value has been published or the value cannot be
+   * unpacked, does not replace the contents and returns false.
+   *
+   * @param[out] out object to replace contents of
+   * @return true if successful
+   */
+  bool GetInto(T* out) {
+    wpi::SmallVector<uint8_t, 128> buf;
+    TimestampedRawView view = ::nt::GetAtomicRaw(m_subHandle, buf, {});
+    if (view.value.empty()) {
+      return false;
+    } else {
+      std::scoped_lock lock{m_mutex};
+      return m_msg.UnpackInto(out, view.value);
+    }
+  }
+
+  /**
+   * Get the last published value along with its timestamp
+   * If no value has been published or the value cannot be unpacked, returns the
+   * stored default value and a timestamp of 0.
+   *
+   * @return timestamped value
+   */
+  TimestampedValueType GetAtomic() const { return GetAtomic(m_defaultValue); }
+
+  /**
+   * Get the last published value along with its timestamp.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * passed defaultValue and a timestamp of 0.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return timestamped value
+   */
+  TimestampedValueType GetAtomic(const T& defaultValue) const {
+    wpi::SmallVector<uint8_t, 128> buf;
+    TimestampedRawView view = ::nt::GetAtomicRaw(m_subHandle, buf, {});
+    if (!view.value.empty()) {
+      std::scoped_lock lock{m_mutex};
+      if (auto optval = m_msg.Unpack(view.value)) {
+        return {view.time, view.serverTime, *optval};
+      }
+    }
+    return {0, 0, defaultValue};
+  }
+
+  /**
+   * Get an array of all valid value changes since the last call to ReadQueue.
+   * Also provides a timestamp for each value. Values that cannot be unpacked
+   * are dropped.
+   *
+   * @note The "poll storage" subscribe option can be used to set the queue
+   *     depth.
+   *
+   * @return Array of timestamped values; empty array if no valid new changes
+   *     have been published since the previous call.
+   */
+  std::vector<TimestampedValueType> ReadQueue() {
+    auto raw = ::nt::ReadQueueRaw(m_subHandle);
+    std::vector<TimestampedValueType> rv;
+    rv.reserve(raw.size());
+    std::scoped_lock lock{m_mutex};
+    for (auto&& r : raw) {
+      if (auto optval = m_msg.Unpack(r.value)) {
+        rv.emplace_back(r.time, r.serverTime, *optval);
+      }
+    }
+    return rv;
+  }
+
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  TopicType GetTopic() const {
+    return ProtobufTopic<T>{::nt::GetTopicFromHandle(m_subHandle)};
+  }
+
+ private:
+  wpi::mutex m_mutex;
+  wpi::ProtobufMessage<T> m_msg;
+  ValueType m_defaultValue;
+};
+
+/**
+ * NetworkTables protobuf-encoded value publisher.
+ */
+template <wpi::ProtobufSerializable T>
+class ProtobufPublisher : public Publisher {
+ public:
+  using TopicType = ProtobufTopic<T>;
+  using ValueType = T;
+  using ParamType = const T&;
+
+  using TimestampedValueType = Timestamped<T>;
+
+  ProtobufPublisher() = default;
+
+  /**
+   * Construct from a publisher handle; recommended to use
+   * ProtobufTopic::Publish() instead.
+   *
+   * @param handle Native handle
+   * @param msg Protobuf message
+   */
+  explicit ProtobufPublisher(NT_Publisher handle, wpi::ProtobufMessage<T> msg)
+      : Publisher{handle}, m_msg{std::move(msg)} {}
+
+  ProtobufPublisher(const ProtobufPublisher&) = delete;
+  ProtobufPublisher& operator=(const ProtobufPublisher&) = delete;
+
+  ProtobufPublisher(ProtobufPublisher&& rhs)
+      : Publisher{std::move(rhs)},
+        m_msg{std::move(rhs.m_msg)},
+        m_schemaPublished{rhs.m_schemaPublished} {}
+
+  ProtobufPublisher& operator=(ProtobufPublisher&& rhs) {
+    Publisher::operator=(std::move(rhs));
+    m_msg = std::move(rhs.m_msg);
+    m_schemaPublished.clear();
+    if (rhs.m_schemaPublished.test()) {
+      m_schemaPublished.test_and_set();
+    }
+    return *this;
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  void Set(const T& value, int64_t time = 0) {
+    wpi::SmallVector<uint8_t, 128> buf;
+    {
+      std::scoped_lock lock{m_mutex};
+      if (!m_schemaPublished.test_and_set()) {
+        GetTopic().GetInstance().template AddProtobufSchema<T>(m_msg);
+      }
+      m_msg.Pack(buf, value);
+    }
+    ::nt::SetRaw(m_pubHandle, buf, time);
+  }
+
+  /**
+   * Publish a default value.
+   * On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   */
+  void SetDefault(const T& value) {
+    wpi::SmallVector<uint8_t, 128> buf;
+    {
+      std::scoped_lock lock{m_mutex};
+      if (!m_schemaPublished.test_and_set()) {
+        GetTopic().GetInstance().template AddProtobufSchema<T>(m_msg);
+      }
+      m_msg.Pack(buf, value);
+    }
+    ::nt::SetDefaultRaw(m_pubHandle, buf);
+  }
+
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  TopicType GetTopic() const {
+    return ProtobufTopic<T>{::nt::GetTopicFromHandle(m_pubHandle)};
+  }
+
+ private:
+  wpi::mutex m_mutex;
+  wpi::ProtobufMessage<T> m_msg;
+  std::atomic_flag m_schemaPublished = ATOMIC_FLAG_INIT;
+};
+
+/**
+ * NetworkTables protobuf-encoded value entry.
+ *
+ * @note Unlike NetworkTableEntry, the entry goes away when this is destroyed.
+ */
+template <wpi::ProtobufSerializable T>
+class ProtobufEntry final : public ProtobufSubscriber<T>,
+                            public ProtobufPublisher<T> {
+ public:
+  using SubscriberType = ProtobufSubscriber<T>;
+  using PublisherType = ProtobufPublisher<T>;
+  using TopicType = ProtobufTopic<T>;
+  using ValueType = T;
+  using ParamType = const T&;
+
+  using TimestampedValueType = Timestamped<T>;
+
+  ProtobufEntry() = default;
+
+  /**
+   * Construct from an entry handle; recommended to use
+   * ProtobufTopic::GetEntry() instead.
+   *
+   * @param handle Native handle
+   * @param msg Protobuf message
+   * @param defaultValue Default value
+   */
+  ProtobufEntry(NT_Entry handle, wpi::ProtobufMessage<T> msg, T defaultValue)
+      : ProtobufSubscriber<T>{handle, std::move(msg), std::move(defaultValue)},
+        ProtobufPublisher<T>{handle, {}} {}
+
+  /**
+   * Determines if the native handle is valid.
+   *
+   * @return True if the native handle is valid, false otherwise.
+   */
+  explicit operator bool() const { return this->m_subHandle != 0; }
+
+  /**
+   * Gets the native handle for the entry.
+   *
+   * @return Native handle
+   */
+  NT_Entry GetHandle() const { return this->m_subHandle; }
+
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  TopicType GetTopic() const {
+    return ProtobufTopic<T>{::nt::GetTopicFromHandle(this->m_subHandle)};
+  }
+
+  /**
+   * Stops publishing the entry if it's published.
+   */
+  void Unpublish() { ::nt::Unpublish(this->m_pubHandle); }
+};
+
+/**
+ * NetworkTables protobuf-encoded value topic.
+ */
+template <wpi::ProtobufSerializable T>
+class ProtobufTopic final : public Topic {
+ public:
+  using SubscriberType = ProtobufSubscriber<T>;
+  using PublisherType = ProtobufPublisher<T>;
+  using EntryType = ProtobufEntry<T>;
+  using ValueType = T;
+  using ParamType = const T&;
+  using TimestampedValueType = Timestamped<T>;
+
+  ProtobufTopic() = default;
+
+  /**
+   * Construct from a topic handle; recommended to use
+   * NetworkTableInstance::GetProtobufTopic() instead.
+   *
+   * @param handle Native handle
+   */
+  explicit ProtobufTopic(NT_Topic handle) : Topic{handle} {}
+
+  /**
+   * Construct from a generic topic.
+   *
+   * @param topic Topic
+   */
+  explicit ProtobufTopic(Topic topic) : Topic{topic} {}
+
+  /**
+   * Create a new subscriber to the topic.
+   *
+   * <p>The subscriber is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note Subscribers that do not match the published data type do not return
+   *     any values. To determine if the data type matches, use the appropriate
+   *     Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a
+   *        getter function
+   * @param options subscribe options
+   * @return subscriber
+   */
+  [[nodiscard]]
+  SubscriberType Subscribe(
+      T defaultValue, const PubSubOptions& options = kDefaultPubSubOptions) {
+    wpi::ProtobufMessage<T> msg;
+    auto typeString = msg.GetTypeString();
+    return ProtobufSubscriber<T>{
+        ::nt::Subscribe(m_handle, NT_RAW, typeString, options), std::move(msg),
+        std::move(defaultValue)};
+  }
+
+  /**
+   * Create a new publisher to the topic.
+   *
+   * The publisher is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note It is not possible to publish two different data types to the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored). To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param options publish options
+   * @return publisher
+   */
+  [[nodiscard]]
+  PublisherType Publish(const PubSubOptions& options = kDefaultPubSubOptions) {
+    wpi::ProtobufMessage<T> msg;
+    auto typeString = msg.GetTypeString();
+    return ProtobufPublisher<T>{
+        ::nt::Publish(m_handle, NT_RAW, typeString, options), std::move(msg)};
+  }
+
+  /**
+   * Create a new publisher to the topic, with type string and initial
+   * properties.
+   *
+   * The publisher is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note It is not possible to publish two different data types to the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored). To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param properties JSON properties
+   * @param options publish options
+   * @return publisher
+   */
+  [[nodiscard]]
+  PublisherType PublishEx(
+      const wpi::json& properties,
+      const PubSubOptions& options = kDefaultPubSubOptions) {
+    wpi::ProtobufMessage<T> msg;
+    auto typeString = msg.GetTypeString();
+    return ProtobufPublisher<T>{
+        ::nt::PublishEx(m_handle, NT_RAW, typeString, properties, options),
+        std::move(msg)};
+  }
+
+  /**
+   * Create a new entry for the topic.
+   *
+   * Entries act as a combination of a subscriber and a weak publisher. The
+   * subscriber is active as long as the entry is not destroyed. The publisher
+   * is created when the entry is first written to, and remains active until
+   * either Unpublish() is called or the entry is destroyed.
+   *
+   * @note It is not possible to use two different data types with the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored), and the entry
+   *     will show no new values if the data type does not match. To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a
+   *        getter function
+   * @param options publish and/or subscribe options
+   * @return entry
+   */
+  [[nodiscard]]
+  EntryType GetEntry(T defaultValue,
+                     const PubSubOptions& options = kDefaultPubSubOptions) {
+    wpi::ProtobufMessage<T> msg;
+    auto typeString = msg.GetTypeString();
+    return ProtobufEntry<T>{
+        ::nt::GetEntry(m_handle, NT_RAW, typeString, options), std::move(msg),
+        std::move(defaultValue)};
+  }
+};
+
+}  // namespace nt
diff --git a/ntcore/src/main/native/include/networktables/StructArrayTopic.h b/ntcore/src/main/native/include/networktables/StructArrayTopic.h
new file mode 100644
index 0000000..91f4721
--- /dev/null
+++ b/ntcore/src/main/native/include/networktables/StructArrayTopic.h
@@ -0,0 +1,593 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <stdint.h>
+
+#include <atomic>
+#include <ranges>
+#include <span>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include <wpi/SmallVector.h>
+#include <wpi/mutex.h>
+#include <wpi/struct/Struct.h>
+
+#include "networktables/NetworkTableInstance.h"
+#include "networktables/Topic.h"
+#include "ntcore_cpp.h"
+
+namespace wpi {
+class json;
+}  // namespace wpi
+
+namespace nt {
+
+template <wpi::StructSerializable T>
+class StructArrayTopic;
+
+/**
+ * NetworkTables struct-encoded value array subscriber.
+ */
+template <wpi::StructSerializable T>
+class StructArraySubscriber : public Subscriber {
+  using S = wpi::Struct<T>;
+
+ public:
+  using TopicType = StructArrayTopic<T>;
+  using ValueType = std::vector<T>;
+  using ParamType = std::span<const T>;
+  using TimestampedValueType = Timestamped<ValueType>;
+
+  StructArraySubscriber() = default;
+
+  /**
+   * Construct from a subscriber handle; recommended to use
+   * StructTopic::Subscribe() instead.
+   *
+   * @param handle Native handle
+   * @param defaultValue Default value
+   */
+  template <typename U>
+#if __cpp_lib_ranges >= 201911L
+    requires std::ranges::range<U> &&
+                 std::convertible_to<std::ranges::range_value_t<U>, T>
+#endif
+  StructArraySubscriber(NT_Subscriber handle, U&& defaultValue)
+      : Subscriber{handle},
+        m_defaultValue{defaultValue.begin(), defaultValue.end()} {
+  }
+
+  /**
+   * Get the last published value.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * stored default value.
+   *
+   * @return value
+   */
+  ValueType Get() const { return Get(m_defaultValue); }
+
+  /**
+   * Get the last published value.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * passed defaultValue.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return value
+   */
+  template <typename U>
+#if __cpp_lib_ranges >= 201911L
+    requires std::ranges::range<U> &&
+             std::convertible_to<std::ranges::range_value_t<U>, T>
+#endif
+  ValueType Get(U&& defaultValue) const {
+    return GetAtomic(std::forward<U>(defaultValue)).value;
+  }
+
+  /**
+   * Get the last published value.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * passed defaultValue.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return value
+   */
+  ValueType Get(std::span<const T> defaultValue) const {
+    return GetAtomic(defaultValue).value;
+  }
+
+  /**
+   * Get the last published value along with its timestamp
+   * If no value has been published or the value cannot be unpacked, returns the
+   * stored default value and a timestamp of 0.
+   *
+   * @return timestamped value
+   */
+  TimestampedValueType GetAtomic() const { return GetAtomic(m_defaultValue); }
+
+  /**
+   * Get the last published value along with its timestamp.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * passed defaultValue and a timestamp of 0.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return timestamped value
+   */
+  template <typename U>
+#if __cpp_lib_ranges >= 201911L
+    requires std::ranges::range<U> &&
+             std::convertible_to<std::ranges::range_value_t<U>, T>
+#endif
+  TimestampedValueType GetAtomic(U&& defaultValue) const {
+    wpi::SmallVector<uint8_t, 128> buf;
+    TimestampedRawView view = ::nt::GetAtomicRaw(m_subHandle, buf, {});
+    if (view.value.size() == 0 || (view.value.size() % S::kSize) != 0) {
+      return {0, 0, std::forward<U>(defaultValue)};
+    }
+    TimestampedValueType rv{view.time, view.serverTime, {}};
+    rv.value.reserve(view.value.size() / S::kSize);
+    for (auto in = view.value.begin(), end = view.value.end(); in != end;
+         in += S::kSize) {
+      rv.value.emplace_back(
+          S::Unpack(std::span<const uint8_t, S::kSize>{in, in + S::kSize}));
+    }
+    return rv;
+  }
+
+  /**
+   * Get the last published value along with its timestamp.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * passed defaultValue and a timestamp of 0.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return timestamped value
+   */
+  TimestampedValueType GetAtomic(std::span<const T> defaultValue) const {
+    wpi::SmallVector<uint8_t, 128> buf;
+    TimestampedRawView view = ::nt::GetAtomicRaw(m_subHandle, buf, {});
+    if (view.value.size() == 0 || (view.value.size() % S::kSize) != 0) {
+      return {0, 0, {defaultValue.begin(), defaultValue.end()}};
+    }
+    TimestampedValueType rv{view.time, view.serverTime, {}};
+    rv.value.reserve(view.value.size() / S::kSize);
+    for (auto in = view.value.begin(), end = view.value.end(); in != end;
+         in += S::kSize) {
+      rv.value.emplace_back(
+          S::Unpack(std::span<const uint8_t, S::kSize>{in, in + S::kSize}));
+    }
+    return rv;
+  }
+
+  /**
+   * Get an array of all valid value changes since the last call to ReadQueue.
+   * Also provides a timestamp for each value. Values that cannot be unpacked
+   * are dropped.
+   *
+   * @note The "poll storage" subscribe option can be used to set the queue
+   *     depth.
+   *
+   * @return Array of timestamped values; empty array if no valid new changes
+   *     have been published since the previous call.
+   */
+  std::vector<TimestampedValueType> ReadQueue() {
+    auto raw = ::nt::ReadQueueRaw(m_subHandle);
+    std::vector<TimestampedValueType> rv;
+    rv.reserve(raw.size());
+    for (auto&& r : raw) {
+      if (r.value.size() == 0 || (r.value.size() % S::kSize) != 0) {
+        continue;
+      }
+      std::vector<T> values;
+      values.reserve(r.value.size() / S::kSize);
+      for (auto in = r.value.begin(), end = r.value.end(); in != end;
+           in += S::kSize) {
+        values.emplace_back(
+            S::Unpack(std::span<const uint8_t, S::kSize>{in, in + S::kSize}));
+      }
+      rv.emplace_back(r.time, r.serverTime, std::move(values));
+    }
+    return rv;
+  }
+
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  TopicType GetTopic() const {
+    return StructArrayTopic<T>{::nt::GetTopicFromHandle(m_subHandle)};
+  }
+
+ private:
+  ValueType m_defaultValue;
+};
+
+/**
+ * NetworkTables struct-encoded value array publisher.
+ */
+template <wpi::StructSerializable T>
+class StructArrayPublisher : public Publisher {
+  using S = wpi::Struct<T>;
+
+ public:
+  using TopicType = StructArrayTopic<T>;
+  using ValueType = std::vector<T>;
+  using ParamType = std::span<const T>;
+
+  using TimestampedValueType = Timestamped<ValueType>;
+
+  StructArrayPublisher() = default;
+
+  /**
+   * Construct from a publisher handle; recommended to use
+   * StructTopic::Publish() instead.
+   *
+   * @param handle Native handle
+   */
+  explicit StructArrayPublisher(NT_Publisher handle) : Publisher{handle} {}
+
+  StructArrayPublisher(const StructArrayPublisher&) = delete;
+  StructArrayPublisher& operator=(const StructArrayPublisher&) = delete;
+
+  StructArrayPublisher(StructArrayPublisher&& rhs)
+      : Publisher{std::move(rhs)},
+        m_buf{std::move(rhs.m_buf)},
+        m_schemaPublished{rhs.m_schemaPublished} {}
+
+  StructArrayPublisher& operator=(StructArrayPublisher&& rhs) {
+    Publisher::operator=(std::move(rhs));
+    m_buf = std::move(rhs.m_buf);
+    m_schemaPublished.clear();
+    if (rhs.m_schemaPublished.test()) {
+      m_schemaPublished.test_and_set();
+    }
+    return *this;
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  template <typename U>
+#if __cpp_lib_ranges >= 201911L
+    requires std::ranges::range<U> &&
+             std::convertible_to<std::ranges::range_value_t<U>, T>
+#endif
+  void Set(U&& value, int64_t time = 0) {
+    if (!m_schemaPublished.test_and_set()) {
+      GetTopic().GetInstance().template AddStructSchema<T>();
+    }
+    m_buf.Write(std::forward<U>(value),
+                [&](auto bytes) { ::nt::SetRaw(m_pubHandle, bytes, time); });
+  }
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  void Set(std::span<const T> value, int64_t time = 0) {
+    m_buf.Write(value,
+                [&](auto bytes) { ::nt::SetRaw(m_pubHandle, bytes, time); });
+  }
+
+  /**
+   * Publish a default value.
+   * On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   */
+  template <typename U>
+#if __cpp_lib_ranges >= 201911L
+    requires std::ranges::range<U> &&
+             std::convertible_to<std::ranges::range_value_t<U>, T>
+#endif
+  void SetDefault(U&& value) {
+    if (!m_schemaPublished.test_and_set()) {
+      GetTopic().GetInstance().template AddStructSchema<T>();
+    }
+    m_buf.Write(std::forward<U>(value),
+                [&](auto bytes) { ::nt::SetDefaultRaw(m_pubHandle, bytes); });
+  }
+
+  /**
+   * Publish a default value.
+   * On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   */
+  void SetDefault(std::span<const T> value) {
+    m_buf.Write(value,
+                [&](auto bytes) { ::nt::SetDefaultRaw(m_pubHandle, bytes); });
+  }
+
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  TopicType GetTopic() const {
+    return StructArrayTopic<T>{::nt::GetTopicFromHandle(m_pubHandle)};
+  }
+
+ private:
+  wpi::StructArrayBuffer<T> m_buf;
+  std::atomic_flag m_schemaPublished = ATOMIC_FLAG_INIT;
+};
+
+/**
+ * NetworkTables struct-encoded value array entry.
+ *
+ * @note Unlike NetworkTableEntry, the entry goes away when this is destroyed.
+ */
+template <wpi::StructSerializable T>
+class StructArrayEntry final : public StructArraySubscriber<T>,
+                               public StructArrayPublisher<T> {
+ public:
+  using SubscriberType = StructArraySubscriber<T>;
+  using PublisherType = StructArrayPublisher<T>;
+  using TopicType = StructArrayTopic<T>;
+  using ValueType = std::vector<T>;
+  using ParamType = std::span<const T>;
+
+  using TimestampedValueType = Timestamped<ValueType>;
+
+  StructArrayEntry() = default;
+
+  /**
+   * Construct from an entry handle; recommended to use
+   * StructTopic::GetEntry() instead.
+   *
+   * @param handle Native handle
+   * @param defaultValue Default value
+   */
+  template <typename U>
+#if __cpp_lib_ranges >= 201911L
+    requires std::ranges::range<U> &&
+                 std::convertible_to<std::ranges::range_value_t<U>, T>
+#endif
+  StructArrayEntry(NT_Entry handle, U&& defaultValue)
+      : StructArraySubscriber<T>{handle, defaultValue},
+        StructArrayPublisher<T>{handle} {
+  }
+
+  /**
+   * Determines if the native handle is valid.
+   *
+   * @return True if the native handle is valid, false otherwise.
+   */
+  explicit operator bool() const { return this->m_subHandle != 0; }
+
+  /**
+   * Gets the native handle for the entry.
+   *
+   * @return Native handle
+   */
+  NT_Entry GetHandle() const { return this->m_subHandle; }
+
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  TopicType GetTopic() const {
+    return StructArrayTopic<T>{::nt::GetTopicFromHandle(this->m_subHandle)};
+  }
+
+  /**
+   * Stops publishing the entry if it's published.
+   */
+  void Unpublish() { ::nt::Unpublish(this->m_pubHandle); }
+};
+
+/**
+ * NetworkTables struct-encoded value array topic.
+ */
+template <wpi::StructSerializable T>
+class StructArrayTopic final : public Topic {
+ public:
+  using SubscriberType = StructArraySubscriber<T>;
+  using PublisherType = StructArrayPublisher<T>;
+  using EntryType = StructArrayEntry<T>;
+  using ValueType = std::vector<T>;
+  using ParamType = std::span<const T>;
+  using TimestampedValueType = Timestamped<ValueType>;
+
+  StructArrayTopic() = default;
+
+  /**
+   * Construct from a topic handle; recommended to use
+   * NetworkTableInstance::GetStructTopic() instead.
+   *
+   * @param handle Native handle
+   */
+  explicit StructArrayTopic(NT_Topic handle) : Topic{handle} {}
+
+  /**
+   * Construct from a generic topic.
+   *
+   * @param topic Topic
+   */
+  explicit StructArrayTopic(Topic topic) : Topic{topic} {}
+
+  /**
+   * Create a new subscriber to the topic.
+   *
+   * <p>The subscriber is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note Subscribers that do not match the published data type do not return
+   *     any values. To determine if the data type matches, use the appropriate
+   *     Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a
+   *        getter function
+   * @param options subscribe options
+   * @return subscriber
+   */
+  template <typename U>
+#if __cpp_lib_ranges >= 201911L
+    requires std::ranges::range<U> &&
+             std::convertible_to<std::ranges::range_value_t<U>, T>
+#endif
+  [[nodiscard]]
+  SubscriberType Subscribe(
+      U&& defaultValue, const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructArraySubscriber<T>{
+        ::nt::Subscribe(
+            m_handle, NT_RAW,
+            wpi::MakeStructArrayTypeString<T, std::dynamic_extent>(), options),
+        defaultValue};
+  }
+
+  /**
+   * Create a new subscriber to the topic.
+   *
+   * <p>The subscriber is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note Subscribers that do not match the published data type do not return
+   *     any values. To determine if the data type matches, use the appropriate
+   *     Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a
+   *        getter function
+   * @param options subscribe options
+   * @return subscriber
+   */
+  [[nodiscard]]
+  SubscriberType Subscribe(
+      std::span<const T> defaultValue,
+      const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructArraySubscriber<T>{
+        ::nt::Subscribe(
+            m_handle, NT_RAW,
+            wpi::MakeStructArrayTypeString<T, std::dynamic_extent>(), options),
+        defaultValue};
+  }
+
+  /**
+   * Create a new publisher to the topic.
+   *
+   * The publisher is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note It is not possible to publish two different data types to the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored). To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param options publish options
+   * @return publisher
+   */
+  [[nodiscard]]
+  PublisherType Publish(const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructArrayPublisher<T>{::nt::Publish(
+        m_handle, NT_RAW,
+        wpi::MakeStructArrayTypeString<T, std::dynamic_extent>(), options)};
+  }
+
+  /**
+   * Create a new publisher to the topic, with type string and initial
+   * properties.
+   *
+   * The publisher is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note It is not possible to publish two different data types to the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored). To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param properties JSON properties
+   * @param options publish options
+   * @return publisher
+   */
+  [[nodiscard]]
+  PublisherType PublishEx(
+      const wpi::json& properties,
+      const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructArrayPublisher<T>{::nt::PublishEx(
+        m_handle, NT_RAW,
+        wpi::MakeStructArrayTypeString<T, std::dynamic_extent>(), properties,
+        options)};
+  }
+
+  /**
+   * Create a new entry for the topic.
+   *
+   * Entries act as a combination of a subscriber and a weak publisher. The
+   * subscriber is active as long as the entry is not destroyed. The publisher
+   * is created when the entry is first written to, and remains active until
+   * either Unpublish() is called or the entry is destroyed.
+   *
+   * @note It is not possible to use two different data types with the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored), and the entry
+   *     will show no new values if the data type does not match. To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a
+   *        getter function
+   * @param options publish and/or subscribe options
+   * @return entry
+   */
+  template <typename U>
+#if __cpp_lib_ranges >= 201911L
+    requires std::ranges::range<U> &&
+             std::convertible_to<std::ranges::range_value_t<U>, T>
+#endif
+  [[nodiscard]]
+  EntryType GetEntry(U&& defaultValue,
+                     const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructArrayEntry<T>{
+        ::nt::GetEntry(m_handle, NT_RAW,
+                       wpi::MakeStructArrayTypeString<T, std::dynamic_extent>(),
+                       options),
+        defaultValue};
+  }
+
+  /**
+   * Create a new entry for the topic.
+   *
+   * Entries act as a combination of a subscriber and a weak publisher. The
+   * subscriber is active as long as the entry is not destroyed. The publisher
+   * is created when the entry is first written to, and remains active until
+   * either Unpublish() is called or the entry is destroyed.
+   *
+   * @note It is not possible to use two different data types with the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored), and the entry
+   *     will show no new values if the data type does not match. To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a
+   *        getter function
+   * @param options publish and/or subscribe options
+   * @return entry
+   */
+  [[nodiscard]]
+  EntryType GetEntry(std::span<const T> defaultValue,
+                     const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructArrayEntry<T>{
+        ::nt::GetEntry(m_handle, NT_RAW,
+                       wpi::MakeStructArrayTypeString<T, std::dynamic_extent>(),
+                       options),
+        defaultValue};
+  }
+};
+
+}  // namespace nt
diff --git a/ntcore/src/main/native/include/networktables/StructTopic.h b/ntcore/src/main/native/include/networktables/StructTopic.h
new file mode 100644
index 0000000..88da9f3
--- /dev/null
+++ b/ntcore/src/main/native/include/networktables/StructTopic.h
@@ -0,0 +1,438 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <stdint.h>
+
+#include <atomic>
+#include <concepts>
+#include <span>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include <wpi/SmallVector.h>
+#include <wpi/struct/Struct.h>
+
+#include "networktables/NetworkTableInstance.h"
+#include "networktables/Topic.h"
+#include "ntcore_cpp.h"
+
+namespace wpi {
+class json;
+}  // namespace wpi
+
+namespace nt {
+
+template <wpi::StructSerializable T>
+class StructTopic;
+
+/**
+ * NetworkTables struct-encoded value subscriber.
+ */
+template <wpi::StructSerializable T>
+class StructSubscriber : public Subscriber {
+  using S = wpi::Struct<T>;
+
+ public:
+  using TopicType = StructTopic<T>;
+  using ValueType = T;
+  using ParamType = const T&;
+  using TimestampedValueType = Timestamped<T>;
+
+  StructSubscriber() = default;
+
+  /**
+   * Construct from a subscriber handle; recommended to use
+   * StructTopic::Subscribe() instead.
+   *
+   * @param handle Native handle
+   * @param defaultValue Default value
+   */
+  StructSubscriber(NT_Subscriber handle, T defaultValue)
+      : Subscriber{handle}, m_defaultValue{std::move(defaultValue)} {}
+
+  /**
+   * Get the last published value.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * stored default value.
+   *
+   * @return value
+   */
+  ValueType Get() const { return Get(m_defaultValue); }
+
+  /**
+   * Get the last published value.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * passed defaultValue.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return value
+   */
+  ValueType Get(const T& defaultValue) const {
+    return GetAtomic(defaultValue).value;
+  }
+
+  /**
+   * Get the last published value, replacing the contents in place of an
+   * existing object. If no value has been published or the value cannot be
+   * unpacked, does not replace the contents and returns false.
+   *
+   * @param[out] out object to replace contents of
+   * @return true if successful
+   */
+  bool GetInto(T* out) {
+    wpi::SmallVector<uint8_t, S::kSize> buf;
+    TimestampedRawView view = ::nt::GetAtomicRaw(m_subHandle, buf, {});
+    if (view.value.size() < S::kSize) {
+      return false;
+    } else {
+      wpi::UnpackStructInto(out, view.value.subspan<0, S::kSize>());
+      return true;
+    }
+  }
+
+  /**
+   * Get the last published value along with its timestamp
+   * If no value has been published or the value cannot be unpacked, returns the
+   * stored default value and a timestamp of 0.
+   *
+   * @return timestamped value
+   */
+  TimestampedValueType GetAtomic() const { return GetAtomic(m_defaultValue); }
+
+  /**
+   * Get the last published value along with its timestamp.
+   * If no value has been published or the value cannot be unpacked, returns the
+   * passed defaultValue and a timestamp of 0.
+   *
+   * @param defaultValue default value to return if no value has been published
+   * @return timestamped value
+   */
+  TimestampedValueType GetAtomic(const T& defaultValue) const {
+    wpi::SmallVector<uint8_t, S::kSize> buf;
+    TimestampedRawView view = ::nt::GetAtomicRaw(m_subHandle, buf, {});
+    if (view.value.size() < S::kSize) {
+      return {0, 0, defaultValue};
+    } else {
+      return {view.time, view.serverTime,
+              S::Unpack(view.value.subspan<0, S::kSize>())};
+    }
+  }
+
+  /**
+   * Get an array of all valid value changes since the last call to ReadQueue.
+   * Also provides a timestamp for each value. Values that cannot be unpacked
+   * are dropped.
+   *
+   * @note The "poll storage" subscribe option can be used to set the queue
+   *     depth.
+   *
+   * @return Array of timestamped values; empty array if no valid new changes
+   *     have been published since the previous call.
+   */
+  std::vector<TimestampedValueType> ReadQueue() {
+    auto raw = ::nt::ReadQueueRaw(m_subHandle);
+    std::vector<TimestampedValueType> rv;
+    rv.reserve(raw.size());
+    for (auto&& r : raw) {
+      if (r.value.size() < S::kSize) {
+        continue;
+      } else {
+        rv.emplace_back(
+            r.time, r.serverTime,
+            S::Unpack(
+                std::span<const uint8_t>(r.value).subspan<0, S::kSize>()));
+      }
+    }
+    return rv;
+  }
+
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  TopicType GetTopic() const {
+    return StructTopic<T>{::nt::GetTopicFromHandle(m_subHandle)};
+  }
+
+ private:
+  ValueType m_defaultValue;
+};
+
+/**
+ * NetworkTables struct-encoded value publisher.
+ */
+template <wpi::StructSerializable T>
+class StructPublisher : public Publisher {
+  using S = wpi::Struct<T>;
+
+ public:
+  using TopicType = StructTopic<T>;
+  using ValueType = T;
+  using ParamType = const T&;
+
+  using TimestampedValueType = Timestamped<T>;
+
+  StructPublisher() = default;
+
+  StructPublisher(const StructPublisher&) = delete;
+  StructPublisher& operator=(const StructPublisher&) = delete;
+
+  StructPublisher(StructPublisher&& rhs)
+      : Publisher{std::move(rhs)}, m_schemaPublished{rhs.m_schemaPublished} {}
+
+  StructPublisher& operator=(StructPublisher&& rhs) {
+    Publisher::operator=(std::move(rhs));
+    m_schemaPublished.clear();
+    if (rhs.m_schemaPublished.test()) {
+      m_schemaPublished.test_and_set();
+    }
+    return *this;
+  }
+
+  /**
+   * Construct from a publisher handle; recommended to use
+   * StructTopic::Publish() instead.
+   *
+   * @param handle Native handle
+   */
+  explicit StructPublisher(NT_Publisher handle) : Publisher{handle} {}
+
+  /**
+   * Publish a new value.
+   *
+   * @param value value to publish
+   * @param time timestamp; 0 indicates current NT time should be used
+   */
+  void Set(const T& value, int64_t time = 0) {
+    if (!m_schemaPublished.test_and_set()) {
+      GetTopic().GetInstance().template AddStructSchema<T>();
+    }
+    uint8_t buf[S::kSize];
+    S::Pack(buf, value);
+    ::nt::SetRaw(m_pubHandle, buf, time);
+  }
+
+  /**
+   * Publish a default value.
+   * On reconnect, a default value will never be used in preference to a
+   * published value.
+   *
+   * @param value value
+   */
+  void SetDefault(const T& value) {
+    if (!m_schemaPublished.test_and_set()) {
+      GetTopic().GetInstance().template AddStructSchema<T>();
+    }
+    uint8_t buf[S::kSize];
+    S::Pack(buf, value);
+    ::nt::SetDefaultRaw(m_pubHandle, buf);
+  }
+
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  TopicType GetTopic() const {
+    return StructTopic<T>{::nt::GetTopicFromHandle(m_pubHandle)};
+  }
+
+ private:
+  std::atomic_flag m_schemaPublished = ATOMIC_FLAG_INIT;
+};
+
+/**
+ * NetworkTables struct-encoded value entry.
+ *
+ * @note Unlike NetworkTableEntry, the entry goes away when this is destroyed.
+ */
+template <wpi::StructSerializable T>
+class StructEntry final : public StructSubscriber<T>,
+                          public StructPublisher<T> {
+ public:
+  using SubscriberType = StructSubscriber<T>;
+  using PublisherType = StructPublisher<T>;
+  using TopicType = StructTopic<T>;
+  using ValueType = T;
+  using ParamType = const T&;
+
+  using TimestampedValueType = Timestamped<T>;
+
+  StructEntry() = default;
+
+  /**
+   * Construct from an entry handle; recommended to use
+   * StructTopic::GetEntry() instead.
+   *
+   * @param handle Native handle
+   * @param defaultValue Default value
+   */
+  StructEntry(NT_Entry handle, T defaultValue)
+      : StructSubscriber<T>{handle, std::move(defaultValue)},
+        StructPublisher<T>{handle} {}
+
+  /**
+   * Determines if the native handle is valid.
+   *
+   * @return True if the native handle is valid, false otherwise.
+   */
+  explicit operator bool() const { return this->m_subHandle != 0; }
+
+  /**
+   * Gets the native handle for the entry.
+   *
+   * @return Native handle
+   */
+  NT_Entry GetHandle() const { return this->m_subHandle; }
+
+  /**
+   * Get the corresponding topic.
+   *
+   * @return Topic
+   */
+  TopicType GetTopic() const {
+    return StructTopic<T>{::nt::GetTopicFromHandle(this->m_subHandle)};
+  }
+
+  /**
+   * Stops publishing the entry if it's published.
+   */
+  void Unpublish() { ::nt::Unpublish(this->m_pubHandle); }
+};
+
+/**
+ * NetworkTables struct-encoded value topic.
+ */
+template <wpi::StructSerializable T>
+class StructTopic final : public Topic {
+ public:
+  using SubscriberType = StructSubscriber<T>;
+  using PublisherType = StructPublisher<T>;
+  using EntryType = StructEntry<T>;
+  using ValueType = T;
+  using ParamType = const T&;
+  using TimestampedValueType = Timestamped<T>;
+
+  StructTopic() = default;
+
+  /**
+   * Construct from a topic handle; recommended to use
+   * NetworkTableInstance::GetStructTopic() instead.
+   *
+   * @param handle Native handle
+   */
+  explicit StructTopic(NT_Topic handle) : Topic{handle} {}
+
+  /**
+   * Construct from a generic topic.
+   *
+   * @param topic Topic
+   */
+  explicit StructTopic(Topic topic) : Topic{topic} {}
+
+  /**
+   * Create a new subscriber to the topic.
+   *
+   * <p>The subscriber is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note Subscribers that do not match the published data type do not return
+   *     any values. To determine if the data type matches, use the appropriate
+   *     Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a
+   *        getter function
+   * @param options subscribe options
+   * @return subscriber
+   */
+  [[nodiscard]]
+  SubscriberType Subscribe(
+      T defaultValue, const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructSubscriber<T>{
+        ::nt::Subscribe(m_handle, NT_RAW, wpi::GetStructTypeString<T>(),
+                        options),
+        std::move(defaultValue)};
+  }
+
+  /**
+   * Create a new publisher to the topic.
+   *
+   * The publisher is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note It is not possible to publish two different data types to the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored). To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param options publish options
+   * @return publisher
+   */
+  [[nodiscard]]
+  PublisherType Publish(const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructPublisher<T>{::nt::Publish(
+        m_handle, NT_RAW, wpi::GetStructTypeString<T>(), options)};
+  }
+
+  /**
+   * Create a new publisher to the topic, with type string and initial
+   * properties.
+   *
+   * The publisher is only active as long as the returned object
+   * is not destroyed.
+   *
+   * @note It is not possible to publish two different data types to the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored). To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param properties JSON properties
+   * @param options publish options
+   * @return publisher
+   */
+  [[nodiscard]]
+  PublisherType PublishEx(
+      const wpi::json& properties,
+      const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructPublisher<T>{::nt::PublishEx(
+        m_handle, NT_RAW, wpi::GetStructTypeString<T>(), properties, options)};
+  }
+
+  /**
+   * Create a new entry for the topic.
+   *
+   * Entries act as a combination of a subscriber and a weak publisher. The
+   * subscriber is active as long as the entry is not destroyed. The publisher
+   * is created when the entry is first written to, and remains active until
+   * either Unpublish() is called or the entry is destroyed.
+   *
+   * @note It is not possible to use two different data types with the same
+   *     topic. Conflicts between publishers are typically resolved by the
+   *     server on a first-come, first-served basis. Any published values that
+   *     do not match the topic's data type are dropped (ignored), and the entry
+   *     will show no new values if the data type does not match. To determine
+   *     if the data type matches, use the appropriate Topic functions.
+   *
+   * @param defaultValue default value used when a default is not provided to a
+   *        getter function
+   * @param options publish and/or subscribe options
+   * @return entry
+   */
+  [[nodiscard]]
+  EntryType GetEntry(T defaultValue,
+                     const PubSubOptions& options = kDefaultPubSubOptions) {
+    return StructEntry<T>{
+        ::nt::GetEntry(m_handle, NT_RAW, wpi::GetStructTypeString<T>(),
+                       options),
+        std::move(defaultValue)};
+  }
+};
+
+}  // namespace nt
diff --git a/ntcore/src/main/native/include/networktables/Topic.h b/ntcore/src/main/native/include/networktables/Topic.h
index e2a8a5a..d623fbd 100644
--- a/ntcore/src/main/native/include/networktables/Topic.h
+++ b/ntcore/src/main/native/include/networktables/Topic.h
@@ -11,14 +11,12 @@
 #include <utility>
 #include <vector>
 
+#include <wpi/json_fwd.h>
+
 #include "networktables/NetworkTableType.h"
 #include "ntcore_c.h"
 #include "ntcore_cpp.h"
 
-namespace wpi {
-class json;
-}  // namespace wpi
-
 namespace nt {
 
 class GenericEntry;
@@ -169,7 +167,8 @@
    * @param options subscribe options
    * @return subscriber
    */
-  [[nodiscard]] GenericSubscriber GenericSubscribe(
+  [[nodiscard]]
+  GenericSubscriber GenericSubscribe(
       const PubSubOptions& options = kDefaultPubSubOptions);
 
   /**
@@ -186,7 +185,8 @@
    * @param options subscribe options
    * @return subscriber
    */
-  [[nodiscard]] GenericSubscriber GenericSubscribe(
+  [[nodiscard]]
+  GenericSubscriber GenericSubscribe(
       std::string_view typeString,
       const PubSubOptions& options = kDefaultPubSubOptions);
 
@@ -206,7 +206,8 @@
    * @param options publish options
    * @return publisher
    */
-  [[nodiscard]] GenericPublisher GenericPublish(
+  [[nodiscard]]
+  GenericPublisher GenericPublish(
       std::string_view typeString,
       const PubSubOptions& options = kDefaultPubSubOptions);
 
@@ -228,7 +229,8 @@
    * @param options publish options
    * @return publisher
    */
-  [[nodiscard]] GenericPublisher GenericPublishEx(
+  [[nodiscard]]
+  GenericPublisher GenericPublishEx(
       std::string_view typeString, const wpi::json& properties,
       const PubSubOptions& options = kDefaultPubSubOptions);
 
@@ -250,7 +252,8 @@
    * @param options publish and/or subscribe options
    * @return entry
    */
-  [[nodiscard]] GenericEntry GetGenericEntry(
+  [[nodiscard]]
+  GenericEntry GetGenericEntry(
       const PubSubOptions& options = kDefaultPubSubOptions);
 
   /**
@@ -272,7 +275,8 @@
    * @param options publish and/or subscribe options
    * @return entry
    */
-  [[nodiscard]] GenericEntry GetGenericEntry(
+  [[nodiscard]]
+  GenericEntry GetGenericEntry(
       std::string_view typeString,
       const PubSubOptions& options = kDefaultPubSubOptions);
 
diff --git a/ntcore/src/main/native/include/networktables/Topic.inc b/ntcore/src/main/native/include/networktables/Topic.inc
index 642e49e..166dc6d 100644
--- a/ntcore/src/main/native/include/networktables/Topic.inc
+++ b/ntcore/src/main/native/include/networktables/Topic.inc
@@ -6,7 +6,6 @@
 
 #include <string>
 
-#include "networktables/NetworkTableInstance.h"
 #include "networktables/NetworkTableType.h"
 #include "networktables/Topic.h"
 #include "ntcore_c.h"
@@ -14,10 +13,6 @@
 
 namespace nt {
 
-inline NetworkTableInstance Topic::GetInstance() const {
-  return NetworkTableInstance{GetInstanceFromHandle(m_handle)};
-}
-
 inline std::string Topic::GetName() const {
   return ::nt::GetTopicName(m_handle);
 }
diff --git a/ntcore/src/main/native/include/networktables/UnitTopic.h b/ntcore/src/main/native/include/networktables/UnitTopic.h
index cac9501..eb6ad6d 100644
--- a/ntcore/src/main/native/include/networktables/UnitTopic.h
+++ b/ntcore/src/main/native/include/networktables/UnitTopic.h
@@ -10,13 +10,11 @@
 #include <string_view>
 #include <vector>
 
+#include <wpi/json_fwd.h>
+
 #include "networktables/Topic.h"
 #include "ntcore_cpp.h"
 
-namespace wpi {
-class json;
-}  // namespace wpi
-
 namespace nt {
 
 template <typename T>
@@ -295,7 +293,8 @@
    * @param options subscribe options
    * @return subscriber
    */
-  [[nodiscard]] SubscriberType Subscribe(
+  [[nodiscard]]
+  SubscriberType Subscribe(
       ParamType defaultValue,
       const PubSubOptions& options = kDefaultPubSubOptions);
 
@@ -315,7 +314,8 @@
    * @param options subscribe options
    * @return subscriber
    */
-  [[nodiscard]] SubscriberType SubscribeEx(
+  [[nodiscard]]
+  SubscriberType SubscribeEx(
       std::string_view typeString, ParamType defaultValue,
       const PubSubOptions& options = kDefaultPubSubOptions);
 
@@ -334,8 +334,8 @@
    * @param options publish options
    * @return publisher
    */
-  [[nodiscard]] PublisherType Publish(
-      const PubSubOptions& options = kDefaultPubSubOptions);
+  [[nodiscard]]
+  PublisherType Publish(const PubSubOptions& options = kDefaultPubSubOptions);
 
   /**
    * Create a new publisher to the topic, with type string and initial
@@ -355,9 +355,10 @@
    * @param options publish options
    * @return publisher
    */
-  [[nodiscard]] PublisherType PublishEx(
-      std::string_view typeString, const wpi::json& properties,
-      const PubSubOptions& options = kDefaultPubSubOptions);
+  [[nodiscard]]
+  PublisherType PublishEx(std::string_view typeString,
+                          const wpi::json& properties,
+                          const PubSubOptions& options = kDefaultPubSubOptions);
 
   /**
    * Create a new entry for the topic.
@@ -379,9 +380,9 @@
    * @param options publish and/or subscribe options
    * @return entry
    */
-  [[nodiscard]] EntryType GetEntry(
-      ParamType defaultValue,
-      const PubSubOptions& options = kDefaultPubSubOptions);
+  [[nodiscard]]
+  EntryType GetEntry(ParamType defaultValue,
+                     const PubSubOptions& options = kDefaultPubSubOptions);
 
   /**
    * Create a new entry for the topic, with specific type string.
@@ -404,9 +405,9 @@
    * @param options publish and/or subscribe options
    * @return entry
    */
-  [[nodiscard]] EntryType GetEntryEx(
-      std::string_view typeString, ParamType defaultValue,
-      const PubSubOptions& options = kDefaultPubSubOptions);
+  [[nodiscard]]
+  EntryType GetEntryEx(std::string_view typeString, ParamType defaultValue,
+                       const PubSubOptions& options = kDefaultPubSubOptions);
 };
 
 }  // namespace nt
diff --git a/ntcore/src/main/native/include/ntcore_c.h b/ntcore/src/main/native/include/ntcore_c.h
index e9e9f81..1af0e66 100644
--- a/ntcore/src/main/native/include/ntcore_c.h
+++ b/ntcore/src/main/native/include/ntcore_c.h
@@ -16,6 +16,8 @@
 extern "C" {
 #endif
 
+struct WPI_DataLog;
+
 /**
  * @defgroup ntcore_c_api ntcore C API
  *
@@ -1148,6 +1150,14 @@
 void NT_SetServerTeam(NT_Inst inst, unsigned int team, unsigned int port);
 
 /**
+ * Disconnects the client if it's running and connected. This will automatically
+ * start reconnection attempts to the current server list.
+ *
+ * @param inst instance handle
+ */
+void NT_Disconnect(NT_Inst inst);
+
+/**
  * Starts requesting server address from Driver Station.
  * This connects to the Driver Station running on localhost to obtain the
  * server IP address.
@@ -1340,6 +1350,54 @@
 /** @} */
 
 /**
+ * @defgroup ntcore_data_logger_cfunc Data Logger Functions
+ * @{
+ */
+
+/**
+ * Starts logging entry changes to a DataLog.
+ *
+ * @param inst instance handle
+ * @param log data log object; lifetime must extend until StopEntryDataLog is
+ *            called or the instance is destroyed
+ * @param prefix only store entries with names that start with this prefix;
+ *               the prefix is not included in the data log entry name
+ * @param logPrefix prefix to add to data log entry names
+ * @return Data logger handle
+ */
+NT_DataLogger NT_StartEntryDataLog(NT_Inst inst, struct WPI_DataLog* log,
+                                   const char* prefix, const char* logPrefix);
+
+/**
+ * Stops logging entry changes to a DataLog.
+ *
+ * @param logger data logger handle
+ */
+void NT_StopEntryDataLog(NT_DataLogger logger);
+
+/**
+ * Starts logging connection changes to a DataLog.
+ *
+ * @param inst instance handle
+ * @param log data log object; lifetime must extend until StopConnectionDataLog
+ *            is called or the instance is destroyed
+ * @param name data log entry name
+ * @return Data logger handle
+ */
+NT_ConnectionDataLogger NT_StartConnectionDataLog(NT_Inst inst,
+                                                  struct WPI_DataLog* log,
+                                                  const char* name);
+
+/**
+ * Stops logging connection changes to a DataLog.
+ *
+ * @param logger data logger handle
+ */
+void NT_StopConnectionDataLog(NT_ConnectionDataLogger logger);
+
+/** @} */
+
+/**
  * @defgroup ntcore_logger_cfunc Logger Functions
  * @{
  */
@@ -1378,6 +1436,44 @@
 /** @} */
 
 /**
+ * @defgroup ntcore_schema_cfunc Schema Functions
+ * @{
+ */
+
+/**
+ * Returns whether there is a data schema already registered with the given
+ * name. This does NOT perform a check as to whether the schema has already
+ * been published by another node on the network.
+ *
+ * @param inst instance
+ * @param name Name (the string passed as the data type for topics using this
+ *             schema)
+ * @return True if schema already registered
+ */
+NT_Bool NT_HasSchema(NT_Inst inst, const char* name);
+
+/**
+ * Registers a data schema.  Data schemas provide information for how a
+ * certain data type string can be decoded.  The type string of a data schema
+ * indicates the type of the schema itself (e.g. "protobuf" for protobuf
+ * schemas, "struct" for struct schemas, etc). In NetworkTables, schemas are
+ * published just like normal topics, with the name being generated from the
+ * provided name: "/.schema/<name>".  Duplicate calls to this function with
+ * the same name are silently ignored.
+ *
+ * @param inst instance
+ * @param name Name (the string passed as the data type for topics using this
+ *             schema)
+ * @param type Type of schema (e.g. "protobuf", "struct", etc)
+ * @param schema Schema data
+ * @param schemaSize Size of schema data
+ */
+void NT_AddSchema(NT_Inst inst, const char* name, const char* type,
+                  const uint8_t* schema, size_t schemaSize);
+
+/** @} */
+
+/**
  * @defgroup ntcore_interop_cfunc Interop Utility Functions
  * @{
  */
diff --git a/ntcore/src/main/native/include/ntcore_cpp.h b/ntcore/src/main/native/include/ntcore_cpp.h
index cbb541b..482d1e3 100644
--- a/ntcore/src/main/native/include/ntcore_cpp.h
+++ b/ntcore/src/main/native/include/ntcore_cpp.h
@@ -17,6 +17,8 @@
 #include <variant>
 #include <vector>
 
+#include <wpi/json_fwd.h>
+
 #include "networktables/NetworkTableValue.h"
 #include "ntcore_c.h"
 #include "ntcore_cpp_types.h"
@@ -24,7 +26,6 @@
 namespace wpi {
 template <typename T>
 class SmallVectorImpl;
-class json;
 }  // namespace wpi
 
 namespace wpi::log {
@@ -1088,6 +1089,14 @@
 void SetServerTeam(NT_Inst inst, unsigned int team, unsigned int port);
 
 /**
+ * Disconnects the client if it's running and connected. This will automatically
+ * start reconnection attempts to the current server list.
+ *
+ * @param inst instance handle
+ */
+void Disconnect(NT_Inst inst);
+
+/**
  * Starts requesting server address from Driver Station.
  * This connects to the Driver Station running on localhost to obtain the
  * server IP address.
@@ -1292,6 +1301,66 @@
                             unsigned int max_level);
 
 /** @} */
+
+/**
+ * @defgroup ntcore_schema_func Schema Functions
+ * @{
+ */
+
+/**
+ * Returns whether there is a data schema already registered with the given
+ * name. This does NOT perform a check as to whether the schema has already
+ * been published by another node on the network.
+ *
+ * @param inst instance
+ * @param name Name (the string passed as the data type for topics using this
+ *             schema)
+ * @return True if schema already registered
+ */
+bool HasSchema(NT_Inst inst, std::string_view name);
+
+/**
+ * Registers a data schema.  Data schemas provide information for how a
+ * certain data type string can be decoded.  The type string of a data schema
+ * indicates the type of the schema itself (e.g. "protobuf" for protobuf
+ * schemas, "struct" for struct schemas, etc). In NetworkTables, schemas are
+ * published just like normal topics, with the name being generated from the
+ * provided name: "/.schema/<name>".  Duplicate calls to this function with
+ * the same name are silently ignored.
+ *
+ * @param inst instance
+ * @param name Name (the string passed as the data type for topics using this
+ *             schema)
+ * @param type Type of schema (e.g. "protobuf", "struct", etc)
+ * @param schema Schema data
+ */
+void AddSchema(NT_Inst inst, std::string_view name, std::string_view type,
+               std::span<const uint8_t> schema);
+
+/**
+ * Registers a data schema.  Data schemas provide information for how a
+ * certain data type string can be decoded.  The type string of a data schema
+ * indicates the type of the schema itself (e.g. "protobuf" for protobuf
+ * schemas, "struct" for struct schemas, etc). In NetworkTables, schemas are
+ * published just like normal topics, with the name being generated from the
+ * provided name: "/.schema/<name>".  Duplicate calls to this function with
+ * the same name are silently ignored.
+ *
+ * @param inst instance
+ * @param name Name (the string passed as the data type for topics using this
+ *             schema)
+ * @param type Type of schema (e.g. "protobuf", "struct", etc)
+ * @param schema Schema data
+ */
+inline void AddSchema(NT_Inst inst, std::string_view name,
+                      std::string_view type, std::string_view schema) {
+  AddSchema(
+      inst, name, type,
+      std::span<const uint8_t>{reinterpret_cast<const uint8_t*>(schema.data()),
+                               schema.size()});
+}
+
+/** @} */
 /** @} */
 
 /**
diff --git a/ntcore/src/test/java/edu/wpi/first/networktables/RawTest.java b/ntcore/src/test/java/edu/wpi/first/networktables/RawTest.java
new file mode 100644
index 0000000..73d5efb
--- /dev/null
+++ b/ntcore/src/test/java/edu/wpi/first/networktables/RawTest.java
@@ -0,0 +1,137 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+package edu.wpi.first.networktables;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("PMD.SimplifiableTestAssertion")
+class RawTest {
+  private NetworkTableInstance m_inst;
+
+  @BeforeEach
+  void setUp() {
+    m_inst = NetworkTableInstance.create();
+  }
+
+  @AfterEach
+  void tearDown() {
+    m_inst.close();
+  }
+
+  @Test
+  void testGenericByteArray() {
+    GenericEntry entry = m_inst.getTopic("test").getGenericEntry("raw");
+    entry.setRaw(new byte[] {5}, 10);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {5}));
+    entry.setRaw(new byte[] {5, 6, 7}, 1, 2, 15);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {6, 7}));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.setRaw(new byte[] {5}, -1, 2, 20));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.setRaw(new byte[] {5}, 1, -2, 20));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.setRaw(new byte[] {5}, 1, 1, 20));
+  }
+
+  @Test
+  void testRawByteArray() {
+    RawEntry entry = m_inst.getRawTopic("test").getEntry("raw", new byte[] {});
+    entry.set(new byte[] {5}, 10);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {5}));
+    entry.set(new byte[] {5, 6, 7}, 1, 2, 15);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {6, 7}));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.set(new byte[] {5}, -1, 1, 20));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.set(new byte[] {5}, 1, -1, 20));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.set(new byte[] {5}, 1, 1, 20));
+  }
+
+  @Test
+  void testGenericByteBuffer() {
+    GenericEntry entry = m_inst.getTopic("test").getGenericEntry("raw");
+    entry.setRaw(ByteBuffer.wrap(new byte[] {5}), 10);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {5}));
+    entry.setRaw(ByteBuffer.wrap(new byte[] {5, 6, 7}).position(1), 15);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {6, 7}));
+    entry.setRaw(ByteBuffer.wrap(new byte[] {5, 6, 7}).position(1).limit(2), 16);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {6}));
+    entry.setRaw(ByteBuffer.wrap(new byte[] {8, 9, 0}), 1, 2, 20);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {9, 0}));
+    entry.setRaw(ByteBuffer.wrap(new byte[] {1, 2, 3}).position(2), 0, 2, 25);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {1, 2}));
+    assertThrows(
+        IndexOutOfBoundsException.class,
+        () -> entry.setRaw(ByteBuffer.wrap(new byte[] {5}), -1, 1, 30));
+    assertThrows(
+        IndexOutOfBoundsException.class,
+        () -> entry.setRaw(ByteBuffer.wrap(new byte[] {5}), 1, -1, 30));
+    assertThrows(
+        IndexOutOfBoundsException.class,
+        () -> entry.setRaw(ByteBuffer.wrap(new byte[] {5}), 1, 1, 30));
+  }
+
+  @Test
+  void testRawByteBuffer() {
+    RawEntry entry = m_inst.getRawTopic("test").getEntry("raw", new byte[] {});
+    entry.set(ByteBuffer.wrap(new byte[] {5}), 10);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {5}));
+    entry.set(ByteBuffer.wrap(new byte[] {5, 6, 7}).position(1), 15);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {6, 7}));
+    entry.set(ByteBuffer.wrap(new byte[] {5, 6, 7}).position(1).limit(2), 16);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {6}));
+    entry.set(ByteBuffer.wrap(new byte[] {8, 9, 0}), 1, 2, 20);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {9, 0}));
+    entry.set(ByteBuffer.wrap(new byte[] {1, 2, 3}).position(2), 0, 2, 25);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {1, 2}));
+    assertThrows(
+        IndexOutOfBoundsException.class,
+        () -> entry.set(ByteBuffer.wrap(new byte[] {5}), -1, 1, 30));
+    assertThrows(
+        IndexOutOfBoundsException.class,
+        () -> entry.set(ByteBuffer.wrap(new byte[] {5}), 1, -1, 30));
+    assertThrows(
+        IndexOutOfBoundsException.class,
+        () -> entry.set(ByteBuffer.wrap(new byte[] {5}), 1, 1, 30));
+  }
+
+  @Test
+  void testGenericNativeByteBuffer() {
+    GenericEntry entry = m_inst.getTopic("test").getGenericEntry("raw");
+    ByteBuffer bb = ByteBuffer.allocateDirect(3);
+    bb.put(new byte[] {5, 6, 7});
+    entry.setRaw(bb.position(1), 15);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {6, 7}));
+    entry.setRaw(bb.limit(2), 16);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {6}));
+    bb.clear();
+    bb.put(new byte[] {8, 9, 0});
+    entry.setRaw(bb, 1, 2, 20);
+    assertTrue(Arrays.equals(entry.getRaw(new byte[] {}), new byte[] {9, 0}));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.setRaw(bb, -1, 1, 25));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.setRaw(bb, 1, -1, 25));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.setRaw(bb, 2, 2, 25));
+  }
+
+  @Test
+  void testRawNativeByteBuffer() {
+    RawEntry entry = m_inst.getRawTopic("test").getEntry("raw", new byte[] {});
+    ByteBuffer bb = ByteBuffer.allocateDirect(3);
+    bb.put(new byte[] {5, 6, 7});
+    entry.set(bb.position(1), 15);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {6, 7}));
+    entry.set(bb.limit(2), 16);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {6}));
+    bb.clear();
+    bb.put(new byte[] {8, 9, 0});
+    entry.set(bb, 1, 2, 20);
+    assertTrue(Arrays.equals(entry.get(new byte[] {}), new byte[] {9, 0}));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.set(bb, -1, 1, 25));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.set(bb, 1, -1, 25));
+    assertThrows(IndexOutOfBoundsException.class, () -> entry.set(bb, 2, 2, 25));
+  }
+}
diff --git a/ntcore/src/test/java/edu/wpi/first/networktables/TimeSyncTest.java b/ntcore/src/test/java/edu/wpi/first/networktables/TimeSyncTest.java
index c539d20..9048ef4 100644
--- a/ntcore/src/test/java/edu/wpi/first/networktables/TimeSyncTest.java
+++ b/ntcore/src/test/java/edu/wpi/first/networktables/TimeSyncTest.java
@@ -34,29 +34,30 @@
 
   @Test
   void testServer() {
-    var poller = new NetworkTableListenerPoller(m_inst);
-    poller.addTimeSyncListener(false);
+    try (var poller = new NetworkTableListenerPoller(m_inst)) {
+      poller.addTimeSyncListener(false);
 
-    m_inst.startServer("timesynctest.json", "127.0.0.1", 0, 10030);
-    var offset = m_inst.getServerTimeOffset();
-    assertTrue(offset.isPresent());
-    assertEquals(0L, offset.getAsLong());
+      m_inst.startServer("timesynctest.json", "127.0.0.1", 0, 10030);
+      var offset = m_inst.getServerTimeOffset();
+      assertTrue(offset.isPresent());
+      assertEquals(0L, offset.getAsLong());
 
-    NetworkTableEvent[] events = poller.readQueue();
-    assertEquals(1, events.length);
-    assertNotNull(events[0].timeSyncData);
-    assertTrue(events[0].timeSyncData.valid);
-    assertEquals(0L, events[0].timeSyncData.serverTimeOffset);
-    assertEquals(0L, events[0].timeSyncData.rtt2);
+      NetworkTableEvent[] events = poller.readQueue();
+      assertEquals(1, events.length);
+      assertNotNull(events[0].timeSyncData);
+      assertTrue(events[0].timeSyncData.valid);
+      assertEquals(0L, events[0].timeSyncData.serverTimeOffset);
+      assertEquals(0L, events[0].timeSyncData.rtt2);
 
-    m_inst.stopServer();
-    offset = m_inst.getServerTimeOffset();
-    assertFalse(offset.isPresent());
+      m_inst.stopServer();
+      offset = m_inst.getServerTimeOffset();
+      assertFalse(offset.isPresent());
 
-    events = poller.readQueue();
-    assertEquals(1, events.length);
-    assertNotNull(events[0].timeSyncData);
-    assertFalse(events[0].timeSyncData.valid);
+      events = poller.readQueue();
+      assertEquals(1, events.length);
+      assertNotNull(events[0].timeSyncData);
+      assertFalse(events[0].timeSyncData.valid);
+    }
   }
 
   @Test
diff --git a/ntcore/src/test/native/cpp/ConnectionListenerTest.cpp b/ntcore/src/test/native/cpp/ConnectionListenerTest.cpp
index 1561277..3e4dd24 100644
--- a/ntcore/src/test/native/cpp/ConnectionListenerTest.cpp
+++ b/ntcore/src/test/native/cpp/ConnectionListenerTest.cpp
@@ -5,11 +5,11 @@
 #include <chrono>
 #include <thread>
 
+#include <gtest/gtest.h>
 #include <wpi/Synchronization.h>
 #include <wpi/mutex.h>
 
 #include "TestPrinters.h"
-#include "gtest/gtest.h"
 #include "ntcore_cpp.h"
 
 class ConnectionListenerTest : public ::testing::Test {
diff --git a/ntcore/src/test/native/cpp/LocalStorageTest.cpp b/ntcore/src/test/native/cpp/LocalStorageTest.cpp
index 5734284..eb57913 100644
--- a/ntcore/src/test/native/cpp/LocalStorageTest.cpp
+++ b/ntcore/src/test/native/cpp/LocalStorageTest.cpp
@@ -2,15 +2,16 @@
 // Open Source Software; you can modify and/or share it under the terms of
 // the WPILib BSD license file in the root directory of this project.
 
+#include <gtest/gtest.h>
+#include <wpi/SpanMatcher.h>
+
 #include "LocalStorage.h"
 #include "MockListenerStorage.h"
 #include "MockLogger.h"
 #include "PubSubOptionsMatcher.h"
-#include "SpanMatcher.h"
 #include "TestPrinters.h"
 #include "ValueMatcher.h"
 #include "gmock/gmock.h"
-#include "gtest/gtest.h"
 #include "net/MockNetworkInterface.h"
 #include "ntcore_c.h"
 #include "ntcore_cpp.h"
@@ -162,7 +163,7 @@
   EXPECT_EQ(value.GetBoolean(), true);
   EXPECT_EQ(value.time(), 5);
 
-  auto vals = storage.ReadQueueBoolean(sub);
+  auto vals = storage.ReadQueue<bool>(sub);
   ASSERT_EQ(vals.size(), 1u);
   EXPECT_EQ(vals[0].value, true);
   EXPECT_EQ(vals[0].time, 5);
@@ -171,7 +172,7 @@
   EXPECT_CALL(network, SetValue(pub, val));
   storage.SetEntryValue(pub, val);
 
-  auto vals2 = storage.ReadQueueInteger(sub);  // mismatched type
+  auto vals2 = storage.ReadQueue<int64_t>(sub);  // mismatched type
   ASSERT_TRUE(vals2.empty());
 }
 
@@ -197,9 +198,6 @@
   ASSERT_TRUE(value.IsBoolean());
   EXPECT_EQ(value.GetBoolean(), true);
   EXPECT_EQ(value.time(), 5);
-
-  auto vals = storage.ReadQueueValue(sub);  // read queue won't get anything
-  ASSERT_TRUE(vals.empty());
 }
 
 TEST_F(LocalStorageTest, EntryNoTypeLocalSet) {
@@ -224,7 +222,7 @@
   EXPECT_EQ(value.GetBoolean(), true);
   EXPECT_EQ(value.time(), 5);
 
-  auto vals = storage.ReadQueueBoolean(entry);
+  auto vals = storage.ReadQueue<bool>(entry);
   ASSERT_EQ(vals.size(), 1u);
   EXPECT_EQ(vals[0].value, true);
   EXPECT_EQ(vals[0].time, 5);
@@ -234,7 +232,7 @@
   EXPECT_CALL(network, SetValue(_, val));
   EXPECT_TRUE(storage.SetEntryValue(entry, val));
 
-  auto vals2 = storage.ReadQueueInteger(entry);  // mismatched type
+  auto vals2 = storage.ReadQueue<int64_t>(entry);  // mismatched type
   ASSERT_TRUE(vals2.empty());
 
   // cannot change type; won't generate network message
@@ -244,7 +242,7 @@
   EXPECT_EQ(storage.GetTopicType(fooTopic), NT_BOOLEAN);
   EXPECT_EQ(storage.GetTopicTypeString(fooTopic), "boolean");
 
-  auto vals3 = storage.ReadQueueInteger(entry);  // mismatched type
+  auto vals3 = storage.ReadQueue<int64_t>(entry);  // mismatched type
   ASSERT_TRUE(vals3.empty());
 }
 
@@ -256,9 +254,11 @@
   EXPECT_CALL(network, Publish(_, fooTopic, std::string_view{"foo"},
                                std::string_view{"boolean"}, wpi::json::object(),
                                IsDefaultPubSubOptions()));
-  EXPECT_CALL(logger, Call(NT_LOG_INFO, _, _,
-                           "local subscribe to 'foo' disabled due to type "
-                           "mismatch (wanted 'int', published as 'boolean')"));
+  EXPECT_CALL(logger,
+              Call(NT_LOG_INFO, _, _,
+                   std::string_view{
+                       "local subscribe to 'foo' disabled due to type "
+                       "mismatch (wanted 'int', published as 'boolean')"}));
   auto pub = storage.Publish(fooTopic, NT_BOOLEAN, "boolean", {}, {});
 
   auto val = Value::MakeBoolean(true, 5);
@@ -269,7 +269,7 @@
   EXPECT_EQ(storage.GetTopicTypeString(fooTopic), "boolean");
   EXPECT_TRUE(storage.GetTopicExists(fooTopic));
 
-  EXPECT_TRUE(storage.ReadQueueInteger(sub).empty());
+  EXPECT_TRUE(storage.ReadQueue<int64_t>(sub).empty());
 
   EXPECT_CALL(network, Unpublish(pub, fooTopic));
   storage.Unpublish(pub);
@@ -291,7 +291,7 @@
   EXPECT_EQ(storage.GetTopicTypeString(fooTopic), "int");
   EXPECT_TRUE(storage.GetTopicExists(fooTopic));
 
-  EXPECT_EQ(storage.ReadQueueInteger(sub).size(), 1u);
+  EXPECT_EQ(storage.ReadQueue<int64_t>(sub).size(), 1u);
 }
 
 TEST_F(LocalStorageTest, LocalPubConflict) {
@@ -300,9 +300,11 @@
                                IsDefaultPubSubOptions()));
   auto pub1 = storage.Publish(fooTopic, NT_BOOLEAN, "boolean", {}, {});
 
-  EXPECT_CALL(logger, Call(NT_LOG_INFO, _, _,
-                           "local publish to 'foo' disabled due to type "
-                           "mismatch (wanted 'int', currently 'boolean')"));
+  EXPECT_CALL(
+      logger,
+      Call(NT_LOG_INFO, _, _,
+           std::string_view{"local publish to 'foo' disabled due to type "
+                            "mismatch (wanted 'int', currently 'boolean')"}));
   auto pub2 = storage.Publish(fooTopic, NT_INTEGER, "int", {}, {});
 
   EXPECT_EQ(storage.GetTopicType(fooTopic), NT_BOOLEAN);
@@ -339,9 +341,11 @@
 
   EXPECT_CALL(network, Subscribe(_, wpi::SpanEq({std::string{"foo"}}),
                                  IsDefaultPubSubOptions()));
-  EXPECT_CALL(logger, Call(NT_LOG_INFO, _, _,
-                           "local subscribe to 'foo' disabled due to type "
-                           "mismatch (wanted 'int', published as 'boolean')"));
+  EXPECT_CALL(logger,
+              Call(NT_LOG_INFO, _, _,
+                   std::string_view{
+                       "local subscribe to 'foo' disabled due to type "
+                       "mismatch (wanted 'int', published as 'boolean')"}));
   storage.Subscribe(fooTopic, NT_INTEGER, "int", {});
 }
 
@@ -352,9 +356,11 @@
 
   storage.Publish(fooTopic, NT_BOOLEAN, "boolean", {}, {});
 
-  EXPECT_CALL(logger, Call(NT_LOG_INFO, _, _,
-                           "network announce of 'foo' overriding local publish "
-                           "(was 'boolean', now 'int')"));
+  EXPECT_CALL(logger,
+              Call(NT_LOG_INFO, _, _,
+                   std::string_view{
+                       "network announce of 'foo' overriding local publish "
+                       "(was 'boolean', now 'int')"}));
 
   storage.NetworkAnnounce("foo", "int", wpi::json::object(), {});
 
@@ -478,11 +484,10 @@
 }
 
 TEST_F(LocalStorageTest, PublishUntyped) {
-  EXPECT_CALL(
-      logger,
-      Call(
-          NT_LOG_ERROR, _, _,
-          "cannot publish 'foo' with an unassigned type or empty type string"));
+  EXPECT_CALL(logger,
+              Call(NT_LOG_ERROR, _, _,
+                   std::string_view{"cannot publish 'foo' with an unassigned "
+                                    "type or empty type string"}));
 
   EXPECT_EQ(storage.Publish(fooTopic, NT_UNASSIGNED, "", {}, {}), 0u);
 }
@@ -494,7 +499,7 @@
 class LocalStorageDuplicatesTest : public LocalStorageTest {
  public:
   void SetupPubSub(bool keepPub, bool keepSub);
-  void SetValues();
+  void SetValues(bool expectDuplicates);
 
   NT_Publisher pub;
   NT_Subscriber sub;
@@ -521,11 +526,12 @@
                           {.pollStorage = 10, .keepDuplicates = keepSub});
 }
 
-void LocalStorageDuplicatesTest::SetValues() {
+void LocalStorageDuplicatesTest::SetValues(bool expectDuplicates) {
   storage.SetEntryValue(pub, val1);
   storage.SetEntryValue(pub, val2);
-  // verify the timestamp was updated
-  EXPECT_EQ(storage.GetEntryLastChange(sub), val2.time());
+  // verify the timestamp was updated (or not)
+  EXPECT_EQ(storage.GetEntryLastChange(sub),
+            expectDuplicates ? val2.time() : val1.time());
   storage.SetEntryValue(pub, val3);
 }
 
@@ -534,10 +540,10 @@
 
   EXPECT_CALL(network, SetValue(pub, val1));
   EXPECT_CALL(network, SetValue(pub, val3));
-  SetValues();
+  SetValues(false);
 
   // verify 2nd update was dropped locally
-  auto values = storage.ReadQueueDouble(sub);
+  auto values = storage.ReadQueue<double>(sub);
   ASSERT_EQ(values.size(), 2u);
   ASSERT_EQ(values[0].value, val1.GetDouble());
   ASSERT_EQ(values[0].time, val1.time());
@@ -551,11 +557,11 @@
   EXPECT_CALL(network, SetValue(pub, val1)).Times(2);
   // EXPECT_CALL(network, SetValue(pub, val2));
   EXPECT_CALL(network, SetValue(pub, val3));
-  SetValues();
+  SetValues(true);
 
-  // verify all 3 updates were received locally
-  auto values = storage.ReadQueueDouble(sub);
-  ASSERT_EQ(values.size(), 3u);
+  // verify only 2 updates were received locally
+  auto values = storage.ReadQueue<double>(sub);
+  ASSERT_EQ(values.size(), 2u);
 }
 
 TEST_F(LocalStorageDuplicatesTest, KeepSub) {
@@ -564,14 +570,28 @@
   // second update should NOT go to the network
   EXPECT_CALL(network, SetValue(pub, val1));
   EXPECT_CALL(network, SetValue(pub, val3));
-  SetValues();
+  SetValues(false);
+
+  // verify 2 updates were received locally
+  auto values = storage.ReadQueue<double>(sub);
+  ASSERT_EQ(values.size(), 2u);
+}
+
+TEST_F(LocalStorageDuplicatesTest, KeepPubSub) {
+  SetupPubSub(true, true);
+
+  // second update SHOULD go to the network
+  EXPECT_CALL(network, SetValue(pub, val1)).Times(2);
+  // EXPECT_CALL(network, SetValue(pub, val2));
+  EXPECT_CALL(network, SetValue(pub, val3));
+  SetValues(true);
 
   // verify all 3 updates were received locally
-  auto values = storage.ReadQueueDouble(sub);
+  auto values = storage.ReadQueue<double>(sub);
   ASSERT_EQ(values.size(), 3u);
 }
 
-TEST_F(LocalStorageDuplicatesTest, FromNetwork) {
+TEST_F(LocalStorageDuplicatesTest, FromNetworkDefault) {
   SetupPubSub(false, false);
 
   // incoming from the network are treated like a normal local publish
@@ -582,8 +602,8 @@
   EXPECT_EQ(storage.GetEntryLastChange(sub), val2.time());
   storage.NetworkSetValue(topic, val3);
 
-  // verify 2nd update was dropped locally
-  auto values = storage.ReadQueueDouble(sub);
+  // verify 2nd update was dropped for local subscriber
+  auto values = storage.ReadQueue<double>(sub);
   ASSERT_EQ(values.size(), 2u);
   ASSERT_EQ(values[0].value, val1.GetDouble());
   ASSERT_EQ(values[0].time, val1.time());
@@ -591,6 +611,69 @@
   ASSERT_EQ(values[1].time, val3.time());
 }
 
+TEST_F(LocalStorageDuplicatesTest, FromNetworkKeepPub) {
+  SetupPubSub(true, false);
+
+  // incoming from the network are treated like a normal local publish
+  auto topic = storage.NetworkAnnounce("foo", "double", {{}}, 0);
+  storage.NetworkSetValue(topic, val1);
+  storage.NetworkSetValue(topic, val2);
+  // verify the timestamp was updated
+  EXPECT_EQ(storage.GetEntryLastChange(sub), val2.time());
+  storage.NetworkSetValue(topic, val3);
+
+  // verify 2nd update was dropped for local subscriber
+  auto values = storage.ReadQueue<double>(sub);
+  ASSERT_EQ(values.size(), 2u);
+  ASSERT_EQ(values[0].value, val1.GetDouble());
+  ASSERT_EQ(values[0].time, val1.time());
+  ASSERT_EQ(values[1].value, val3.GetDouble());
+  ASSERT_EQ(values[1].time, val3.time());
+}
+TEST_F(LocalStorageDuplicatesTest, FromNetworkKeepSub) {
+  SetupPubSub(false, true);
+
+  // incoming from the network are treated like a normal local publish
+  auto topic = storage.NetworkAnnounce("foo", "double", {{}}, 0);
+  storage.NetworkSetValue(topic, val1);
+  storage.NetworkSetValue(topic, val2);
+  // verify the timestamp was updated
+  EXPECT_EQ(storage.GetEntryLastChange(sub), val2.time());
+  storage.NetworkSetValue(topic, val3);
+
+  // verify 2nd update was received by local subscriber
+  auto values = storage.ReadQueue<double>(sub);
+  ASSERT_EQ(values.size(), 3u);
+  ASSERT_EQ(values[0].value, val1.GetDouble());
+  ASSERT_EQ(values[0].time, val1.time());
+  ASSERT_EQ(values[1].value, val2.GetDouble());
+  ASSERT_EQ(values[1].time, val2.time());
+  ASSERT_EQ(values[2].value, val3.GetDouble());
+  ASSERT_EQ(values[2].time, val3.time());
+}
+
+TEST_F(LocalStorageDuplicatesTest, FromNetworkKeepPubSub) {
+  SetupPubSub(true, true);
+
+  // incoming from the network are treated like a normal local publish
+  auto topic = storage.NetworkAnnounce("foo", "double", {{}}, 0);
+  storage.NetworkSetValue(topic, val1);
+  storage.NetworkSetValue(topic, val2);
+  // verify the timestamp was updated
+  EXPECT_EQ(storage.GetEntryLastChange(sub), val2.time());
+  storage.NetworkSetValue(topic, val3);
+
+  // verify 2nd update was received by local subscriber
+  auto values = storage.ReadQueue<double>(sub);
+  ASSERT_EQ(values.size(), 3u);
+  ASSERT_EQ(values[0].value, val1.GetDouble());
+  ASSERT_EQ(values[0].time, val1.time());
+  ASSERT_EQ(values[1].value, val2.GetDouble());
+  ASSERT_EQ(values[1].time, val2.time());
+  ASSERT_EQ(values[2].value, val3.GetDouble());
+  ASSERT_EQ(values[2].time, val3.time());
+}
+
 class LocalStorageNumberVariantsTest : public LocalStorageTest {
  public:
   void CreateSubscriber(NT_Handle* handle, std::string_view name, NT_Type type,
@@ -621,8 +704,9 @@
 void LocalStorageNumberVariantsTest::CreateSubscribers() {
   EXPECT_CALL(logger,
               Call(NT_LOG_INFO, _, _,
-                   "local subscribe to 'foo' disabled due to type "
-                   "mismatch (wanted 'boolean', published as 'double')"));
+                   std::string_view{
+                       "local subscribe to 'foo' disabled due to type "
+                       "mismatch (wanted 'boolean', published as 'double')"}));
   CreateSubscriber(&sub1, "subDouble", NT_DOUBLE, "double");
   CreateSubscriber(&sub2, "subInteger", NT_INTEGER, "int");
   CreateSubscriber(&sub3, "subFloat", NT_FLOAT, "float");
@@ -632,10 +716,12 @@
 }
 
 void LocalStorageNumberVariantsTest::CreateSubscribersArray() {
-  EXPECT_CALL(logger,
-              Call(NT_LOG_INFO, _, _,
-                   "local subscribe to 'foo' disabled due to type "
-                   "mismatch (wanted 'boolean[]', published as 'double[]')"));
+  EXPECT_CALL(
+      logger,
+      Call(NT_LOG_INFO, _, _,
+           std::string_view{
+               "local subscribe to 'foo' disabled due to type "
+               "mismatch (wanted 'boolean[]', published as 'double[]')"}));
   CreateSubscriber(&sub1, "subDouble", NT_DOUBLE_ARRAY, "double[]");
   CreateSubscriber(&sub2, "subInteger", NT_INTEGER_ARRAY, "int[]");
   CreateSubscriber(&sub3, "subFloat", NT_FLOAT_ARRAY, "float[]");
@@ -703,13 +789,13 @@
 
   for (auto&& subentry : subentries) {
     SCOPED_TRACE(subentry.name);
-    EXPECT_THAT(storage.GetAtomicDouble(subentry.subentry, 0),
+    EXPECT_THAT(storage.GetAtomic<double>(subentry.subentry, 0),
                 TSEq<TimestampedDouble>(1.0, 50));
-    EXPECT_THAT(storage.GetAtomicInteger(subentry.subentry, 0),
+    EXPECT_THAT(storage.GetAtomic<int64_t>(subentry.subentry, 0),
                 TSEq<TimestampedInteger>(1, 50));
-    EXPECT_THAT(storage.GetAtomicFloat(subentry.subentry, 0),
+    EXPECT_THAT(storage.GetAtomic<float>(subentry.subentry, 0),
                 TSEq<TimestampedFloat>(1.0, 50));
-    EXPECT_THAT(storage.GetAtomicBoolean(subentry.subentry, false),
+    EXPECT_THAT(storage.GetAtomic<bool>(subentry.subentry, false),
                 TSEq<TimestampedBoolean>(false, 0));
   }
 }
@@ -732,15 +818,15 @@
   for (auto&& subentry : subentries) {
     SCOPED_TRACE(subentry.name);
     double doubleVal = 1.0;
-    EXPECT_THAT(storage.GetAtomicDoubleArray(subentry.subentry, {}),
+    EXPECT_THAT(storage.GetAtomic<double[]>(subentry.subentry, {}),
                 TSSpanEq<TimestampedDoubleArray>(std::span{&doubleVal, 1}, 50));
     int64_t intVal = 1;
-    EXPECT_THAT(storage.GetAtomicIntegerArray(subentry.subentry, {}),
+    EXPECT_THAT(storage.GetAtomic<int64_t[]>(subentry.subentry, {}),
                 TSSpanEq<TimestampedIntegerArray>(std::span{&intVal, 1}, 50));
     float floatVal = 1.0;
-    EXPECT_THAT(storage.GetAtomicFloatArray(subentry.subentry, {}),
+    EXPECT_THAT(storage.GetAtomic<float[]>(subentry.subentry, {}),
                 TSSpanEq<TimestampedFloatArray>(std::span{&floatVal, 1}, 50));
-    EXPECT_THAT(storage.GetAtomicBooleanArray(subentry.subentry, {}),
+    EXPECT_THAT(storage.GetAtomic<bool[]>(subentry.subentry, {}),
                 TSSpanEq<TimestampedBooleanArray>(std::span<int>{}, 0));
   }
 }
@@ -756,9 +842,9 @@
   for (auto&& subentry : subentries) {
     SCOPED_TRACE(subentry.name);
     if (subentry.type == NT_BOOLEAN) {
-      EXPECT_THAT(storage.ReadQueueDouble(subentry.subentry), IsEmpty());
+      EXPECT_THAT(storage.ReadQueue<double>(subentry.subentry), IsEmpty());
     } else {
-      EXPECT_THAT(storage.ReadQueueDouble(subentry.subentry),
+      EXPECT_THAT(storage.ReadQueue<double>(subentry.subentry),
                   ElementsAre(TSEq<TimestampedDouble>(1.0, 50)));
     }
   }
@@ -767,9 +853,9 @@
   for (auto&& subentry : subentries) {
     SCOPED_TRACE(subentry.name);
     if (subentry.type == NT_BOOLEAN) {
-      EXPECT_THAT(storage.ReadQueueInteger(subentry.subentry), IsEmpty());
+      EXPECT_THAT(storage.ReadQueue<int64_t>(subentry.subentry), IsEmpty());
     } else {
-      EXPECT_THAT(storage.ReadQueueInteger(subentry.subentry),
+      EXPECT_THAT(storage.ReadQueue<int64_t>(subentry.subentry),
                   ElementsAre(TSEq<TimestampedInteger>(2, 50)));
     }
   }
@@ -778,9 +864,9 @@
   for (auto&& subentry : subentries) {
     SCOPED_TRACE(subentry.name);
     if (subentry.type == NT_BOOLEAN) {
-      EXPECT_THAT(storage.ReadQueueFloat(subentry.subentry), IsEmpty());
+      EXPECT_THAT(storage.ReadQueue<float>(subentry.subentry), IsEmpty());
     } else {
-      EXPECT_THAT(storage.ReadQueueFloat(subentry.subentry),
+      EXPECT_THAT(storage.ReadQueue<float>(subentry.subentry),
                   ElementsAre(TSEq<TimestampedFloat>(3.0, 50)));
     }
   }
@@ -788,7 +874,7 @@
   storage.SetEntryValue(pub, Value::MakeDouble(4.0, 50));
   for (auto&& subentry : subentries) {
     SCOPED_TRACE(subentry.name);
-    EXPECT_THAT(storage.ReadQueueBoolean(subentry.subentry), IsEmpty());
+    EXPECT_THAT(storage.ReadQueue<bool>(subentry.subentry), IsEmpty());
   }
 }
 
@@ -855,19 +941,19 @@
   // local set
   EXPECT_CALL(network, SetValue(_, _));
   storage.SetEntryValue(pub, Value::MakeDouble(1.0, 50));
-  EXPECT_THAT(storage.ReadQueueDouble(subBoth),
+  EXPECT_THAT(storage.ReadQueue<double>(subBoth),
               ElementsAre(TSEq<TimestampedDouble>(1.0, 50)));
-  EXPECT_THAT(storage.ReadQueueDouble(subLocal),
+  EXPECT_THAT(storage.ReadQueue<double>(subLocal),
               ElementsAre(TSEq<TimestampedDouble>(1.0, 50)));
-  EXPECT_THAT(storage.ReadQueueDouble(subRemote), IsEmpty());
+  EXPECT_THAT(storage.ReadQueue<double>(subRemote), IsEmpty());
 
   // network set
   storage.NetworkSetValue(remoteTopic, Value::MakeDouble(2.0, 60));
-  EXPECT_THAT(storage.ReadQueueDouble(subBoth),
+  EXPECT_THAT(storage.ReadQueue<double>(subBoth),
               ElementsAre(TSEq<TimestampedDouble>(2.0, 60)));
-  EXPECT_THAT(storage.ReadQueueDouble(subRemote),
+  EXPECT_THAT(storage.ReadQueue<double>(subRemote),
               ElementsAre(TSEq<TimestampedDouble>(2.0, 60)));
-  EXPECT_THAT(storage.ReadQueueDouble(subLocal), IsEmpty());
+  EXPECT_THAT(storage.ReadQueue<double>(subLocal), IsEmpty());
 }
 
 TEST_F(LocalStorageTest, SubExcludePub) {
@@ -884,15 +970,15 @@
   // local set
   EXPECT_CALL(network, SetValue(_, _));
   storage.SetEntryValue(pub, Value::MakeDouble(1.0, 50));
-  EXPECT_THAT(storage.ReadQueueDouble(subActive),
+  EXPECT_THAT(storage.ReadQueue<double>(subActive),
               ElementsAre(TSEq<TimestampedDouble>(1.0, 50)));
-  EXPECT_THAT(storage.ReadQueueDouble(subExclude), IsEmpty());
+  EXPECT_THAT(storage.ReadQueue<double>(subExclude), IsEmpty());
 
   // network set
   storage.NetworkSetValue(remoteTopic, Value::MakeDouble(2.0, 60));
-  EXPECT_THAT(storage.ReadQueueDouble(subActive),
+  EXPECT_THAT(storage.ReadQueue<double>(subActive),
               ElementsAre(TSEq<TimestampedDouble>(2.0, 60)));
-  EXPECT_THAT(storage.ReadQueueDouble(subExclude),
+  EXPECT_THAT(storage.ReadQueue<double>(subExclude),
               ElementsAre(TSEq<TimestampedDouble>(2.0, 60)));
 }
 
@@ -908,12 +994,56 @@
   EXPECT_CALL(network, Publish(_, _, _, _, _, _));
   EXPECT_CALL(network, SetValue(_, _));
   storage.SetEntryValue(entry, Value::MakeDouble(1.0, 50));
-  EXPECT_THAT(storage.ReadQueueDouble(entry), IsEmpty());
+  EXPECT_THAT(storage.ReadQueue<double>(entry), IsEmpty());
 
   // network set
   storage.NetworkSetValue(remoteTopic, Value::MakeDouble(2.0, 60));
-  EXPECT_THAT(storage.ReadQueueDouble(entry),
+  EXPECT_THAT(storage.ReadQueue<double>(entry),
               ElementsAre(TSEq<TimestampedDouble>(2.0, 60)));
 }
 
+TEST_F(LocalStorageTest, ReadQueueInitialLocal) {
+  EXPECT_CALL(network, Publish(_, _, _, _, _, _));
+  EXPECT_CALL(network, SetValue(_, _));
+  EXPECT_CALL(network, Subscribe(_, _, _)).Times(3);
+
+  auto pub = storage.Publish(fooTopic, NT_DOUBLE, "double", {}, {});
+  storage.SetEntryValue(pub, Value::MakeDouble(1.0, 50));
+
+  auto subBoth =
+      storage.Subscribe(fooTopic, NT_DOUBLE, "double", kDefaultPubSubOptions);
+  auto subLocal =
+      storage.Subscribe(fooTopic, NT_DOUBLE, "double", {.disableRemote = true});
+  auto subRemote =
+      storage.Subscribe(fooTopic, NT_DOUBLE, "double", {.disableLocal = true});
+
+  EXPECT_THAT(storage.ReadQueue<double>(subBoth),
+              ElementsAre(TSEq<TimestampedDouble>(1.0, 50)));
+  EXPECT_THAT(storage.ReadQueue<double>(subLocal),
+              ElementsAre(TSEq<TimestampedDouble>(1.0, 50)));
+  EXPECT_THAT(storage.ReadQueue<double>(subRemote), IsEmpty());
+}
+
+TEST_F(LocalStorageTest, ReadQueueInitialRemote) {
+  EXPECT_CALL(network, Subscribe(_, _, _)).Times(3);
+
+  auto remoteTopic =
+      storage.NetworkAnnounce("foo", "double", wpi::json::object(), 0);
+  storage.NetworkSetValue(remoteTopic, Value::MakeDouble(2.0, 60));
+
+  auto subBoth =
+      storage.Subscribe(fooTopic, NT_DOUBLE, "double", kDefaultPubSubOptions);
+  auto subLocal =
+      storage.Subscribe(fooTopic, NT_DOUBLE, "double", {.disableRemote = true});
+  auto subRemote =
+      storage.Subscribe(fooTopic, NT_DOUBLE, "double", {.disableLocal = true});
+
+  // network set
+  EXPECT_THAT(storage.ReadQueue<double>(subBoth),
+              ElementsAre(TSEq<TimestampedDouble>(2.0, 60)));
+  EXPECT_THAT(storage.ReadQueue<double>(subRemote),
+              ElementsAre(TSEq<TimestampedDouble>(2.0, 60)));
+  EXPECT_THAT(storage.ReadQueue<double>(subLocal), IsEmpty());
+}
+
 }  // namespace nt
diff --git a/ntcore/src/test/native/cpp/LoggerTest.cpp b/ntcore/src/test/native/cpp/LoggerTest.cpp
index a9f499c..9e974c3 100644
--- a/ntcore/src/test/native/cpp/LoggerTest.cpp
+++ b/ntcore/src/test/native/cpp/LoggerTest.cpp
@@ -2,11 +2,11 @@
 // Open Source Software; you can modify and/or share it under the terms of
 // the WPILib BSD license file in the root directory of this project.
 
+#include <gtest/gtest.h>
 #include <wpi/Synchronization.h>
 
 #include "Handle.h"
 #include "TestPrinters.h"
-#include "gtest/gtest.h"
 #include "ntcore_cpp.h"
 
 class LoggerTest : public ::testing::Test {
diff --git a/ntcore/src/test/native/cpp/NetworkTableTest.cpp b/ntcore/src/test/native/cpp/NetworkTableTest.cpp
index aa28afd..4429c89 100644
--- a/ntcore/src/test/native/cpp/NetworkTableTest.cpp
+++ b/ntcore/src/test/native/cpp/NetworkTableTest.cpp
@@ -2,8 +2,9 @@
 // Open Source Software; you can modify and/or share it under the terms of
 // the WPILib BSD license file in the root directory of this project.
 
+#include <gtest/gtest.h>
+
 #include "TestPrinters.h"
-#include "gtest/gtest.h"
 #include "networktables/NetworkTable.h"
 #include "networktables/NetworkTableInstance.h"
 
diff --git a/ntcore/src/test/native/cpp/SpanMatcher.h b/ntcore/src/test/native/cpp/SpanMatcher.h
deleted file mode 100644
index 9973c03..0000000
--- a/ntcore/src/test/native/cpp/SpanMatcher.h
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#pragma once
-
-#include <algorithm>
-#include <initializer_list>
-#include <memory>
-#include <ostream>
-#include <span>
-#include <type_traits>
-#include <utility>
-#include <vector>
-
-#include "TestPrinters.h"
-#include "gmock/gmock.h"
-
-namespace wpi {
-
-template <typename T>
-class SpanMatcher : public ::testing::MatcherInterface<std::span<T>> {
- public:
-  explicit SpanMatcher(std::span<T> good_) : good{good_.begin(), good_.end()} {}
-
-  bool MatchAndExplain(std::span<T> val,
-                       ::testing::MatchResultListener* listener) const override;
-  void DescribeTo(::std::ostream* os) const override;
-  void DescribeNegationTo(::std::ostream* os) const override;
-
- private:
-  std::vector<std::remove_cv_t<T>> good;
-};
-
-template <typename T>
-inline ::testing::Matcher<std::span<const T>> SpanEq(std::span<const T> good) {
-  return ::testing::MakeMatcher(new SpanMatcher(good));
-}
-
-template <typename T>
-inline ::testing::Matcher<std::span<const T>> SpanEq(
-    std::initializer_list<const T> good) {
-  return ::testing::MakeMatcher(
-      new SpanMatcher<const T>({good.begin(), good.end()}));
-}
-
-template <typename T>
-bool SpanMatcher<T>::MatchAndExplain(
-    std::span<T> val, ::testing::MatchResultListener* listener) const {
-  if (val.size() != good.size() ||
-      !std::equal(val.begin(), val.end(), good.begin())) {
-    return false;
-  }
-  return true;
-}
-
-template <typename T>
-void SpanMatcher<T>::DescribeTo(::std::ostream* os) const {
-  PrintTo(std::span<T>{good}, os);
-}
-
-template <typename T>
-void SpanMatcher<T>::DescribeNegationTo(::std::ostream* os) const {
-  *os << "is not equal to ";
-  PrintTo(std::span<T>{good}, os);
-}
-
-}  // namespace wpi
-
-inline std::span<const uint8_t> operator"" _us(const char* str, size_t len) {
-  return {reinterpret_cast<const uint8_t*>(str), len};
-}
diff --git a/ntcore/src/test/native/cpp/TableListenerTest.cpp b/ntcore/src/test/native/cpp/TableListenerTest.cpp
index 7fb1947..797c505 100644
--- a/ntcore/src/test/native/cpp/TableListenerTest.cpp
+++ b/ntcore/src/test/native/cpp/TableListenerTest.cpp
@@ -4,9 +4,10 @@
 
 #include <memory>
 
+#include <gtest/gtest.h>
+
 #include "TestPrinters.h"
 #include "gmock/gmock.h"
-#include "gtest/gtest.h"
 #include "networktables/DoubleTopic.h"
 #include "networktables/NetworkTableInstance.h"
 #include "ntcore_cpp.h"
@@ -45,7 +46,7 @@
   MockTableEventListener listener;
   table->AddListener(NT_EVENT_TOPIC | NT_EVENT_IMMEDIATE,
                      listener.AsStdFunction());
-  EXPECT_CALL(listener, Call(table.get(), "foovalue", _));
+  EXPECT_CALL(listener, Call(table.get(), std::string_view{"foovalue"}, _));
   PublishTopics();
   EXPECT_TRUE(m_inst.WaitForListenerQueue(1.0));
 }
@@ -54,7 +55,7 @@
   auto table = m_inst.GetTable("/foo");
   MockSubTableListener listener;
   table->AddSubTableListener(listener.AsStdFunction());
-  EXPECT_CALL(listener, Call(table.get(), "bar", _));
+  EXPECT_CALL(listener, Call(table.get(), std::string_view{"bar"}, _));
   PublishTopics();
   EXPECT_TRUE(m_inst.WaitForListenerQueue(1.0));
 }
diff --git a/ntcore/src/test/native/cpp/TestPrinters.cpp b/ntcore/src/test/native/cpp/TestPrinters.cpp
index 66afe4e..708573f 100644
--- a/ntcore/src/test/native/cpp/TestPrinters.cpp
+++ b/ntcore/src/test/native/cpp/TestPrinters.cpp
@@ -4,8 +4,6 @@
 
 #include "TestPrinters.h"
 
-#include <wpi/json.h>
-
 #include "Handle.h"
 #include "PubSubOptions.h"
 #include "net/Message.h"
@@ -13,12 +11,6 @@
 #include "networktables/NetworkTableValue.h"
 #include "ntcore_cpp.h"
 
-namespace wpi {
-void PrintTo(const json& val, ::std::ostream* os) {
-  *os << val.dump();
-}
-}  // namespace wpi
-
 namespace nt {
 
 void PrintTo(const Event& event, std::ostream* os) {
diff --git a/ntcore/src/test/native/cpp/TestPrinters.h b/ntcore/src/test/native/cpp/TestPrinters.h
index 2b8f1da..3481e76 100644
--- a/ntcore/src/test/native/cpp/TestPrinters.h
+++ b/ntcore/src/test/native/cpp/TestPrinters.h
@@ -9,34 +9,8 @@
 #include <string>
 #include <string_view>
 
-#include "gtest/gtest.h"
-
-namespace wpi {
-
-class json;
-
-inline void PrintTo(std::string_view str, ::std::ostream* os) {
-  ::testing::internal::PrintStringTo(std::string{str}, os);
-}
-
-template <typename T>
-void PrintTo(std::span<T> val, ::std::ostream* os) {
-  *os << '{';
-  bool first = true;
-  for (auto v : val) {
-    if (first) {
-      first = false;
-    } else {
-      *os << ", ";
-    }
-    *os << ::testing::PrintToString(v);
-  }
-  *os << '}';
-}
-
-void PrintTo(const json& val, ::std::ostream* os);
-
-}  // namespace wpi
+#include <gtest/gtest.h>
+#include <wpi/TestPrinters.h>
 
 namespace nt {
 
diff --git a/ntcore/src/test/native/cpp/TimeSyncTest.cpp b/ntcore/src/test/native/cpp/TimeSyncTest.cpp
index 54e1f7c..1820b37 100644
--- a/ntcore/src/test/native/cpp/TimeSyncTest.cpp
+++ b/ntcore/src/test/native/cpp/TimeSyncTest.cpp
@@ -2,7 +2,8 @@
 // Open Source Software; you can modify and/or share it under the terms of
 // the WPILib BSD license file in the root directory of this project.
 
-#include "gtest/gtest.h"
+#include <gtest/gtest.h>
+
 #include "networktables/NetworkTableInstance.h"
 #include "networktables/NetworkTableListener.h"
 
diff --git a/ntcore/src/test/native/cpp/TopicListenerTest.cpp b/ntcore/src/test/native/cpp/TopicListenerTest.cpp
index f0a002a..8f46742 100644
--- a/ntcore/src/test/native/cpp/TopicListenerTest.cpp
+++ b/ntcore/src/test/native/cpp/TopicListenerTest.cpp
@@ -5,12 +5,12 @@
 #include <chrono>
 #include <thread>
 
+#include <gtest/gtest.h>
 #include <wpi/Synchronization.h>
 #include <wpi/json.h>
 
 #include "TestPrinters.h"
 #include "ValueMatcher.h"
-#include "gtest/gtest.h"
 #include "ntcore_c.h"
 #include "ntcore_cpp.h"
 
diff --git a/ntcore/src/test/native/cpp/ValueListenerTest.cpp b/ntcore/src/test/native/cpp/ValueListenerTest.cpp
index dbe3201..83ef6d8 100644
--- a/ntcore/src/test/native/cpp/ValueListenerTest.cpp
+++ b/ntcore/src/test/native/cpp/ValueListenerTest.cpp
@@ -2,12 +2,12 @@
 // Open Source Software; you can modify and/or share it under the terms of
 // the WPILib BSD license file in the root directory of this project.
 
+#include <gtest/gtest.h>
 #include <wpi/StringExtras.h>
 #include <wpi/Synchronization.h>
 
 #include "TestPrinters.h"
 #include "ValueMatcher.h"
-#include "gtest/gtest.h"
 #include "ntcore_c.h"
 #include "ntcore_cpp.h"
 
diff --git a/ntcore/src/test/native/cpp/ValueTest.cpp b/ntcore/src/test/native/cpp/ValueTest.cpp
index d49be44..ba99782 100644
--- a/ntcore/src/test/native/cpp/ValueTest.cpp
+++ b/ntcore/src/test/native/cpp/ValueTest.cpp
@@ -5,14 +5,15 @@
 #include <algorithm>
 #include <string_view>
 
+#include <gtest/gtest.h>
+
 #include "TestPrinters.h"
 #include "Value_internal.h"
-#include "gtest/gtest.h"
 #include "networktables/NetworkTableValue.h"
 
 using namespace std::string_view_literals;
 
-namespace std {  // NOLINT (clang-tidy.cert-dcl58-cpp)
+namespace std {  // NOLINT(clang-tidy.cert-dcl58-cpp)
 template <typename T, typename U>
 inline bool operator==(std::span<T> lhs, std::span<U> rhs) {
   if (lhs.size() != rhs.size()) {
@@ -336,6 +337,58 @@
   vec = {1, 0};
   v2 = Value::MakeBooleanArray(vec);
   ASSERT_NE(v1, v2);
+
+  // empty
+  vec = {};
+  v1 = Value::MakeBooleanArray(vec);
+  v2 = Value::MakeBooleanArray(vec);
+  ASSERT_EQ(v1, v2);
+}
+
+TEST_F(ValueTest, IntegerArrayComparison) {
+  std::vector<int64_t> vec{-42, 0, 1};
+  auto v1 = Value::MakeIntegerArray(vec);
+  auto v2 = Value::MakeIntegerArray(vec);
+  ASSERT_EQ(v1, v2);
+
+  // different contents
+  vec = {-42, 1, 1};
+  v2 = Value::MakeIntegerArray(vec);
+  ASSERT_NE(v1, v2);
+
+  // different size
+  vec = {-42, 0};
+  v2 = Value::MakeIntegerArray(vec);
+  ASSERT_NE(v1, v2);
+
+  // empty
+  vec = {};
+  v1 = Value::MakeIntegerArray(vec);
+  v2 = Value::MakeIntegerArray(vec);
+  ASSERT_EQ(v1, v2);
+}
+
+TEST_F(ValueTest, FloatArrayComparison) {
+  std::vector<float> vec{0.5, 0.25, 0.5};
+  auto v1 = Value::MakeFloatArray(vec);
+  auto v2 = Value::MakeFloatArray(vec);
+  ASSERT_EQ(v1, v2);
+
+  // different contents
+  vec = {0.5, 0.5, 0.5};
+  v2 = Value::MakeFloatArray(vec);
+  ASSERT_NE(v1, v2);
+
+  // different size
+  vec = {0.5, 0.25};
+  v2 = Value::MakeFloatArray(vec);
+  ASSERT_NE(v1, v2);
+
+  // empty
+  vec = {};
+  v1 = Value::MakeFloatArray(vec);
+  v2 = Value::MakeFloatArray(vec);
+  ASSERT_EQ(v1, v2);
 }
 
 TEST_F(ValueTest, DoubleArrayComparison) {
@@ -353,6 +406,12 @@
   vec = {0.5, 0.25};
   v2 = Value::MakeDoubleArray(vec);
   ASSERT_NE(v1, v2);
+
+  // empty
+  vec = {};
+  v1 = Value::MakeDoubleArray(vec);
+  v2 = Value::MakeDoubleArray(vec);
+  ASSERT_EQ(v1, v2);
 }
 
 TEST_F(ValueTest, StringArrayComparison) {
@@ -390,6 +449,12 @@
   vec.push_back("goodbye");
   v2 = Value::MakeStringArray(std::move(vec));
   ASSERT_NE(v1, v2);
+
+  // empty
+  vec.clear();
+  v1 = Value::MakeStringArray(vec);
+  v2 = Value::MakeStringArray(std::move(vec));
+  ASSERT_EQ(v1, v2);
 }
 
 }  // namespace nt
diff --git a/ntcore/src/test/native/cpp/main.cpp b/ntcore/src/test/native/cpp/main.cpp
index 6d37027..0f060b0 100644
--- a/ntcore/src/test/native/cpp/main.cpp
+++ b/ntcore/src/test/native/cpp/main.cpp
@@ -4,10 +4,13 @@
 
 #include <climits>
 
+#include <wpi/timestamp.h>
+
 #include "gmock/gmock.h"
 #include "ntcore.h"
 
 int main(int argc, char** argv) {
+  wpi::impl::SetupNowRio();
   nt::AddLogger(nt::GetDefaultInstance(), 0, UINT_MAX, [](auto& event) {
     if (auto msg = event.GetLogMessage()) {
       std::fputs(msg->message.c_str(), stderr);
diff --git a/ntcore/src/test/native/cpp/net/MockWireConnection.cpp b/ntcore/src/test/native/cpp/net/MockWireConnection.cpp
deleted file mode 100644
index a5ddb1f..0000000
--- a/ntcore/src/test/native/cpp/net/MockWireConnection.cpp
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#include "MockWireConnection.h"
-
-using namespace nt::net;
-
-void MockWireConnection::StartSendText() {
-  if (m_in_text) {
-    m_text_os << ',';
-  } else {
-    m_text_os << '[';
-    m_in_text = true;
-  }
-}
-
-void MockWireConnection::FinishSendText() {
-  if (m_in_text) {
-    m_text_os << ']';
-    m_in_text = false;
-  }
-  m_text_os.flush();
-  Text(m_text);
-  m_text.clear();
-}
diff --git a/ntcore/src/test/native/cpp/net/MockWireConnection.h b/ntcore/src/test/native/cpp/net/MockWireConnection.h
index 3909cab..797ca25 100644
--- a/ntcore/src/test/native/cpp/net/MockWireConnection.h
+++ b/ntcore/src/test/native/cpp/net/MockWireConnection.h
@@ -20,35 +20,52 @@
 
 class MockWireConnection : public WireConnection {
  public:
-  MockWireConnection() : m_text_os{m_text}, m_binary_os{m_binary} {}
+  MOCK_METHOD(unsigned int, GetVersion, (), (const, override));
+
+  MOCK_METHOD(void, SendPing, (uint64_t time), (override));
 
   MOCK_METHOD(bool, Ready, (), (const, override));
 
-  TextWriter SendText() override { return {m_text_os, *this}; }
-  BinaryWriter SendBinary() override { return {m_binary_os, *this}; }
-
-  MOCK_METHOD(void, Text, (std::string_view contents));
-  MOCK_METHOD(void, Binary, (std::span<const uint8_t> contents));
-
-  MOCK_METHOD(void, Flush, (), (override));
-
-  MOCK_METHOD(void, Disconnect, (std::string_view reason), (override));
-
- protected:
-  void StartSendText() override;
-  void FinishSendText() override;
-  void StartSendBinary() override {}
-  void FinishSendBinary() override {
-    Binary(m_binary);
-    m_binary.resize(0);
+  int WriteText(wpi::function_ref<void(wpi::raw_ostream& os)> writer) override {
+    std::string text;
+    wpi::raw_string_ostream os{text};
+    writer(os);
+    return DoWriteText(text);
+  }
+  int WriteBinary(
+      wpi::function_ref<void(wpi::raw_ostream& os)> writer) override {
+    std::vector<uint8_t> binary;
+    wpi::raw_uvector_ostream os{binary};
+    writer(os);
+    return DoWriteBinary(binary);
   }
 
- private:
-  std::string m_text;
-  wpi::raw_string_ostream m_text_os;
-  std::vector<uint8_t> m_binary;
-  wpi::raw_uvector_ostream m_binary_os;
-  bool m_in_text{false};
+  void SendText(wpi::function_ref<void(wpi::raw_ostream& os)> writer) override {
+    std::string text;
+    wpi::raw_string_ostream os{text};
+    writer(os);
+    DoSendText(text);
+  }
+  void SendBinary(
+      wpi::function_ref<void(wpi::raw_ostream& os)> writer) override {
+    std::vector<uint8_t> binary;
+    wpi::raw_uvector_ostream os{binary};
+    writer(os);
+    DoSendBinary(binary);
+  }
+
+  MOCK_METHOD(int, DoWriteText, (std::string_view contents));
+  MOCK_METHOD(int, DoWriteBinary, (std::span<const uint8_t> contents));
+
+  MOCK_METHOD(void, DoSendText, (std::string_view contents));
+  MOCK_METHOD(void, DoSendBinary, (std::span<const uint8_t> contents));
+
+  MOCK_METHOD(int, Flush, (), (override));
+
+  MOCK_METHOD(uint64_t, GetLastFlushTime, (), (const, override));
+  MOCK_METHOD(uint64_t, GetLastPingResponse, (), (const, override));
+
+  MOCK_METHOD(void, Disconnect, (std::string_view reason), (override));
 };
 
 }  // namespace nt::net
diff --git a/ntcore/src/test/native/cpp/net/ServerImplTest.cpp b/ntcore/src/test/native/cpp/net/ServerImplTest.cpp
new file mode 100644
index 0000000..6423811
--- /dev/null
+++ b/ntcore/src/test/native/cpp/net/ServerImplTest.cpp
@@ -0,0 +1,364 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include <stdint.h>
+
+#include <concepts>
+#include <span>
+#include <string_view>
+#include <vector>
+
+#include <gtest/gtest.h>
+#include <wpi/SpanMatcher.h>
+
+#include "../MockLogger.h"
+#include "../PubSubOptionsMatcher.h"
+#include "../TestPrinters.h"
+#include "../ValueMatcher.h"
+#include "Handle.h"
+#include "MockNetworkInterface.h"
+#include "MockWireConnection.h"
+#include "gmock/gmock.h"
+#include "net/Message.h"
+#include "net/ServerImpl.h"
+#include "net/WireEncoder.h"
+#include "ntcore_c.h"
+#include "ntcore_cpp.h"
+
+using ::testing::_;
+using ::testing::AllOf;
+using ::testing::ElementsAre;
+using ::testing::Field;
+using ::testing::IsEmpty;
+using ::testing::Property;
+using ::testing::Return;
+using ::testing::StrEq;
+
+using MockSetPeriodicFunc = ::testing::MockFunction<void(uint32_t repeatMs)>;
+using MockConnected3Func =
+    ::testing::MockFunction<void(std::string_view name, uint16_t proto)>;
+
+namespace nt {
+
+class ServerImplTest : public ::testing::Test {
+ public:
+  ::testing::StrictMock<net::MockLocalInterface> local;
+  wpi::MockLogger logger;
+  net::ServerImpl server{logger};
+};
+
+TEST_F(ServerImplTest, AddClient) {
+  ::testing::StrictMock<net::MockWireConnection> wire;
+  // EXPECT_CALL(wire, Flush());
+  MockSetPeriodicFunc setPeriodic;
+  auto [name, id] = server.AddClient("test", "connInfo", false, wire,
+                                     setPeriodic.AsStdFunction());
+  EXPECT_EQ(name, "test@1");
+  EXPECT_NE(id, -1);
+}
+
+TEST_F(ServerImplTest, AddDuplicateClient) {
+  ::testing::StrictMock<net::MockWireConnection> wire1;
+  ::testing::StrictMock<net::MockWireConnection> wire2;
+  MockSetPeriodicFunc setPeriodic1;
+  MockSetPeriodicFunc setPeriodic2;
+  // EXPECT_CALL(wire1, Flush());
+  // EXPECT_CALL(wire2, Flush());
+
+  auto [name1, id1] = server.AddClient("test", "connInfo", false, wire1,
+                                       setPeriodic1.AsStdFunction());
+  auto [name2, id2] = server.AddClient("test", "connInfo2", false, wire2,
+                                       setPeriodic2.AsStdFunction());
+  EXPECT_EQ(name1, "test@1");
+  EXPECT_NE(id1, -1);
+  EXPECT_EQ(name2, "test@2");
+  EXPECT_NE(id1, id2);
+  EXPECT_NE(id2, -1);
+}
+
+TEST_F(ServerImplTest, AddClient3) {}
+
+template <typename T>
+static std::string EncodeText1(const T& msg) {
+  std::string data;
+  wpi::raw_string_ostream os{data};
+  net::WireEncodeText(os, msg);
+  return data;
+}
+
+template <typename T>
+static std::string EncodeText(const T& msgs) {
+  std::string data;
+  wpi::raw_string_ostream os{data};
+  bool first = true;
+  for (auto&& msg : msgs) {
+    if (first) {
+      os << '[';
+      first = false;
+    } else {
+      os << ',';
+    }
+    net::WireEncodeText(os, msg);
+  }
+  os << ']';
+  return data;
+}
+
+template <typename T>
+static std::vector<uint8_t> EncodeServerBinary1(const T& msg) {
+  std::vector<uint8_t> data;
+  wpi::raw_uvector_ostream os{data};
+  if constexpr (std::same_as<T, net::ServerMessage>) {
+    if (auto m = std::get_if<net::ServerValueMsg>(&msg.contents)) {
+      net::WireEncodeBinary(os, m->topic, m->value.time(), m->value);
+    }
+  } else if constexpr (std::same_as<T, net::ClientMessage>) {
+    if (auto m = std::get_if<net::ClientValueMsg>(&msg.contents)) {
+      net::WireEncodeBinary(os, Handle{m->pubHandle}.GetIndex(),
+                            m->value.time(), m->value);
+    }
+  }
+  return data;
+}
+
+template <typename T>
+static std::vector<uint8_t> EncodeServerBinary(const T& msgs) {
+  std::vector<uint8_t> data;
+  wpi::raw_uvector_ostream os{data};
+  for (auto&& msg : msgs) {
+    if constexpr (std::same_as<typename T::value_type, net::ServerMessage>) {
+      if (auto m = std::get_if<net::ServerValueMsg>(&msg.contents)) {
+        net::WireEncodeBinary(os, m->topic, m->value.time(), m->value);
+      }
+    } else if constexpr (std::same_as<typename T::value_type,
+                                      net::ClientMessage>) {
+      if (auto m = std::get_if<net::ClientValueMsg>(&msg.contents)) {
+        net::WireEncodeBinary(os, Handle{m->pubHandle}.GetIndex(),
+                              m->value.time(), m->value);
+      }
+    }
+  }
+  return data;
+}
+
+TEST_F(ServerImplTest, PublishLocal) {
+  // publish before client connect
+  server.SetLocal(&local);
+  NT_Publisher pubHandle = nt::Handle{0, 1, nt::Handle::kPublisher};
+  NT_Topic topicHandle = nt::Handle{0, 1, nt::Handle::kTopic};
+  NT_Publisher pubHandle2 = nt::Handle{0, 2, nt::Handle::kPublisher};
+  NT_Topic topicHandle2 = nt::Handle{0, 2, nt::Handle::kTopic};
+  NT_Publisher pubHandle3 = nt::Handle{0, 3, nt::Handle::kPublisher};
+  NT_Topic topicHandle3 = nt::Handle{0, 3, nt::Handle::kTopic};
+  {
+    ::testing::InSequence seq;
+    EXPECT_CALL(local, NetworkAnnounce(std::string_view{"test"},
+                                       std::string_view{"double"},
+                                       wpi::json::object(), pubHandle));
+    EXPECT_CALL(local, NetworkAnnounce(std::string_view{"test2"},
+                                       std::string_view{"double"},
+                                       wpi::json::object(), pubHandle2));
+    EXPECT_CALL(local, NetworkAnnounce(std::string_view{"test3"},
+                                       std::string_view{"double"},
+                                       wpi::json::object(), pubHandle3));
+  }
+
+  {
+    std::vector<net::ClientMessage> msgs;
+    msgs.emplace_back(net::ClientMessage{net::PublishMsg{
+        pubHandle, topicHandle, "test", "double", wpi::json::object(), {}}});
+    server.HandleLocal(msgs);
+  }
+
+  // client connect; it should get already-published topic as soon as it
+  // subscribes
+  ::testing::StrictMock<net::MockWireConnection> wire;
+  MockSetPeriodicFunc setPeriodic;
+  EXPECT_CALL(wire, GetVersion()).WillRepeatedly(Return(0x0401));
+  {
+    ::testing::InSequence seq;
+    // EXPECT_CALL(wire, Flush()).WillOnce(Return(0));     // AddClient()
+    EXPECT_CALL(setPeriodic, Call(100));  // ClientSubscribe()
+    // EXPECT_CALL(wire, Flush()).WillOnce(Return(0));     // ClientSubscribe()
+    EXPECT_CALL(wire, GetLastPingResponse()).WillOnce(Return(0));
+    EXPECT_CALL(wire, SendPing(100));
+    EXPECT_CALL(wire, Ready()).WillOnce(Return(true));  // SendControl()
+    EXPECT_CALL(
+        wire, DoWriteText(StrEq(EncodeText1(net::ServerMessage{net::AnnounceMsg{
+                  "test", 3, "double", std::nullopt, wpi::json::object()}}))))
+        .WillOnce(Return(0));
+    EXPECT_CALL(
+        wire, DoWriteText(StrEq(EncodeText1(net::ServerMessage{net::AnnounceMsg{
+                  "test2", 8, "double", std::nullopt, wpi::json::object()}}))))
+        .WillOnce(Return(0));
+    EXPECT_CALL(wire, Flush()).WillOnce(Return(0));     // SendControl()
+    EXPECT_CALL(wire, Ready()).WillOnce(Return(true));  // SendControl()
+    EXPECT_CALL(
+        wire, DoWriteText(StrEq(EncodeText1(net::ServerMessage{net::AnnounceMsg{
+                  "test3", 11, "double", std::nullopt, wpi::json::object()}}))))
+        .WillOnce(Return(0));
+    EXPECT_CALL(wire, Flush()).WillOnce(Return(0));  // SendControl()
+  }
+  auto [name, id] = server.AddClient("test", "connInfo", false, wire,
+                                     setPeriodic.AsStdFunction());
+
+  {
+    NT_Subscriber subHandle = nt::Handle{0, 1, nt::Handle::kSubscriber};
+    std::vector<net::ClientMessage> msgs;
+    msgs.emplace_back(net::ClientMessage{net::SubscribeMsg{
+        subHandle, {{""}}, PubSubOptions{.prefixMatch = true}}});
+    server.ProcessIncomingText(id, EncodeText(msgs));
+  }
+
+  // publish before send control
+  {
+    std::vector<net::ClientMessage> msgs;
+    msgs.emplace_back(net::ClientMessage{net::PublishMsg{
+        pubHandle2, topicHandle2, "test2", "double", wpi::json::object(), {}}});
+    server.HandleLocal(msgs);
+  }
+
+  server.SendAllOutgoing(100, false);
+
+  // publish after send control
+  {
+    std::vector<net::ClientMessage> msgs;
+    msgs.emplace_back(net::ClientMessage{net::PublishMsg{
+        pubHandle3, topicHandle3, "test3", "double", wpi::json::object(), {}}});
+    server.HandleLocal(msgs);
+  }
+
+  server.SendAllOutgoing(200, false);
+}
+
+TEST_F(ServerImplTest, ClientSubTopicOnlyThenValue) {
+  // publish before client connect
+  server.SetLocal(&local);
+  NT_Publisher pubHandle = nt::Handle{0, 1, nt::Handle::kPublisher};
+  NT_Topic topicHandle = nt::Handle{0, 1, nt::Handle::kTopic};
+  EXPECT_CALL(local, NetworkAnnounce(std::string_view{"test"},
+                                     std::string_view{"double"},
+                                     wpi::json::object(), pubHandle));
+
+  {
+    std::vector<net::ClientMessage> msgs;
+    msgs.emplace_back(net::ClientMessage{net::PublishMsg{
+        pubHandle, topicHandle, "test", "double", wpi::json::object(), {}}});
+    msgs.emplace_back(net::ClientMessage{
+        net::ClientValueMsg{pubHandle, Value::MakeDouble(1.0, 10)}});
+    server.HandleLocal(msgs);
+  }
+
+  ::testing::StrictMock<net::MockWireConnection> wire;
+  EXPECT_CALL(wire, GetVersion()).WillRepeatedly(Return(0x0401));
+  MockSetPeriodicFunc setPeriodic;
+  {
+    ::testing::InSequence seq;
+    // EXPECT_CALL(wire, Flush()).WillOnce(Return(0));     // AddClient()
+    EXPECT_CALL(setPeriodic, Call(100));  // ClientSubscribe()
+    // EXPECT_CALL(wire, Flush()).WillOnce(Return(0));     // ClientSubscribe()
+    EXPECT_CALL(wire, GetLastPingResponse()).WillOnce(Return(0));
+    EXPECT_CALL(wire, SendPing(100));
+    EXPECT_CALL(wire, Ready()).WillOnce(Return(true));  // SendValues()
+    EXPECT_CALL(
+        wire, DoWriteText(StrEq(EncodeText1(net::ServerMessage{net::AnnounceMsg{
+                  "test", 3, "double", std::nullopt, wpi::json::object()}}))))
+        .WillOnce(Return(0));
+    EXPECT_CALL(wire, Flush()).WillOnce(Return(0));  // SendValues()
+    EXPECT_CALL(setPeriodic, Call(100));             // ClientSubscribe()
+    // EXPECT_CALL(wire, Flush()).WillOnce(Return(0));     // ClientSubscribe()
+    EXPECT_CALL(wire, Ready()).WillOnce(Return(true));  // SendValues()
+    EXPECT_CALL(
+        wire, DoWriteBinary(wpi::SpanEq(EncodeServerBinary1(net::ServerMessage{
+                  net::ServerValueMsg{3, Value::MakeDouble(1.0, 10)}}))))
+        .WillOnce(Return(0));
+    EXPECT_CALL(wire, Flush());  // SendValues()
+  }
+
+  // connect client
+  auto [name, id] = server.AddClient("test", "connInfo", false, wire,
+                                     setPeriodic.AsStdFunction());
+
+  // subscribe topics only; will not send value
+  {
+    NT_Subscriber subHandle = nt::Handle{0, 1, nt::Handle::kSubscriber};
+    std::vector<net::ClientMessage> msgs;
+    msgs.emplace_back(net::ClientMessage{net::SubscribeMsg{
+        subHandle,
+        {{""}},
+        PubSubOptions{.topicsOnly = true, .prefixMatch = true}}});
+    server.ProcessIncomingText(id, EncodeText(msgs));
+  }
+
+  server.SendOutgoing(id, 100);
+
+  // subscribe normal; will not resend announcement, but will send value
+  {
+    NT_Subscriber subHandle = nt::Handle{0, 2, nt::Handle::kSubscriber};
+    std::vector<net::ClientMessage> msgs;
+    msgs.emplace_back(net::ClientMessage{
+        net::SubscribeMsg{subHandle, {{"test"}}, PubSubOptions{}}});
+    server.ProcessIncomingText(id, EncodeText(msgs));
+  }
+
+  server.SendOutgoing(id, 200);
+}
+
+TEST_F(ServerImplTest, ZeroTimestampNegativeTime) {
+  // publish before client connect
+  server.SetLocal(&local);
+  NT_Publisher pubHandle = nt::Handle{0, 1, nt::Handle::kPublisher};
+  NT_Topic topicHandle = nt::Handle{0, 1, nt::Handle::kTopic};
+  NT_Subscriber subHandle = nt::Handle{0, 1, nt::Handle::kSubscriber};
+  Value defaultValue = Value::MakeDouble(1.0, 10);
+  defaultValue.SetTime(0);
+  defaultValue.SetServerTime(0);
+  Value value = Value::MakeDouble(5, -10);
+  {
+    ::testing::InSequence seq;
+    EXPECT_CALL(local, NetworkAnnounce(std::string_view{"test"},
+                                       std::string_view{"double"},
+                                       wpi::json::object(), pubHandle))
+        .WillOnce(Return(topicHandle));
+    EXPECT_CALL(local, NetworkSetValue(topicHandle, defaultValue));
+    EXPECT_CALL(local, NetworkSetValue(topicHandle, value));
+  }
+
+  {
+    std::vector<net::ClientMessage> msgs;
+    msgs.emplace_back(net::ClientMessage{net::PublishMsg{
+        pubHandle, topicHandle, "test", "double", wpi::json::object(), {}}});
+    msgs.emplace_back(
+        net::ClientMessage{net::ClientValueMsg{pubHandle, defaultValue}});
+    msgs.emplace_back(
+        net::ClientMessage{net::SubscribeMsg{subHandle, {"test"}, {}}});
+    server.HandleLocal(msgs);
+  }
+
+  // client connect; it should get already-published topic as soon as it
+  // subscribes
+  ::testing::StrictMock<net::MockWireConnection> wire;
+  MockSetPeriodicFunc setPeriodic;
+  {
+    ::testing::InSequence seq;
+    // EXPECT_CALL(wire, Flush());  // AddClient()
+  }
+  auto [name, id] = server.AddClient("test", "connInfo", false, wire,
+                                     setPeriodic.AsStdFunction());
+
+  // publish and send non-default value with negative time offset
+  {
+    NT_Subscriber pubHandle2 = nt::Handle{0, 2, nt::Handle::kPublisher};
+    std::vector<net::ClientMessage> msgs;
+    msgs.emplace_back(net::ClientMessage{net::PublishMsg{
+        pubHandle2, topicHandle, "test", "double", wpi::json::object(), {}}});
+    server.ProcessIncomingText(id, EncodeText(msgs));
+    msgs.clear();
+    msgs.emplace_back(
+        net::ClientMessage{net::ClientValueMsg{pubHandle2, value}});
+    server.ProcessIncomingBinary(id, EncodeServerBinary(msgs));
+  }
+}
+
+}  // namespace nt
diff --git a/ntcore/src/test/native/cpp/net/WireDecoderTest.cpp b/ntcore/src/test/native/cpp/net/WireDecoderTest.cpp
index f893302..891d693 100644
--- a/ntcore/src/test/native/cpp/net/WireDecoderTest.cpp
+++ b/ntcore/src/test/native/cpp/net/WireDecoderTest.cpp
@@ -2,6 +2,7 @@
 // Open Source Software; you can modify and/or share it under the terms of
 // the WPILib BSD license file in the root directory of this project.
 
+#include <gtest/gtest.h>
 #include <wpi/SmallString.h>
 #include <wpi/raw_ostream.h>
 
@@ -9,7 +10,6 @@
 #include "../TestPrinters.h"
 #include "Handle.h"
 #include "gmock/gmock.h"
-#include "gtest/gtest.h"
 #include "net/Message.h"
 #include "net/WireDecoder.h"
 #include "networktables/NetworkTableValue.h"
@@ -67,8 +67,8 @@
       logger,
       Call(_, _, _,
            "could not decode JSON message: [json.exception.parse_error.101] "
-           "parse error at 1: syntax error - "
-           "unexpected end of input; expected '[', '{', or a literal"sv));
+           "parse error at line 1, column 1: syntax error while parsing value "
+           "- unexpected end of input; expected '[', '{', or a literal"sv));
   net::WireDecodeText("", handler, logger);
 }
 
@@ -77,8 +77,8 @@
       logger,
       Call(_, _, _,
            "could not decode JSON message: [json.exception.parse_error.101] "
-           "parse error at 2: syntax error - "
-           "unexpected end of input; expected '[', '{', or a literal"sv));
+           "parse error at line 1, column 2: syntax error while parsing value "
+           "- unexpected end of input; expected '[', '{', or a literal"sv));
   net::WireDecodeText("[", handler, logger);
 }
 
@@ -87,8 +87,8 @@
       logger,
       Call(_, _, _,
            "could not decode JSON message: [json.exception.parse_error.101] "
-           "parse error at 3: syntax error - "
-           "unexpected end of input; expected string literal"sv));
+           "parse error at line 1, column 3: syntax error while parsing object "
+           "key - unexpected end of input; expected string literal"sv));
   net::WireDecodeText("[{", handler, logger);
 }
 
diff --git a/ntcore/src/test/native/cpp/net/WireEncoderTest.cpp b/ntcore/src/test/native/cpp/net/WireEncoderTest.cpp
index 9cc3187..222a5b7 100644
--- a/ntcore/src/test/native/cpp/net/WireEncoderTest.cpp
+++ b/ntcore/src/test/native/cpp/net/WireEncoderTest.cpp
@@ -7,15 +7,15 @@
 #include <string_view>
 #include <vector>
 
+#include <gtest/gtest.h>
+#include <wpi/SpanMatcher.h>
 #include <wpi/json.h>
 #include <wpi/raw_ostream.h>
 
-#include "../SpanMatcher.h"
 #include "../TestPrinters.h"
 #include "Handle.h"
 #include "PubSubOptions.h"
 #include "gmock/gmock-matchers.h"
-#include "gtest/gtest.h"
 #include "net/Message.h"
 #include "net/WireEncoder.h"
 #include "networktables/NetworkTableValue.h"
@@ -172,14 +172,15 @@
   ASSERT_TRUE(net::WireEncodeText(os, msg));
   ASSERT_EQ(os.str(),
             "{\"method\":\"subscribe\",\"params\":{"
-            "\"options\":{},\"topics\":[\"a\",\"b\"],\"subuid\":5}}");
+            "\"options\":{},\"topics\":[\"a\",\"b\"],\"subuid\":402653189}}");
 }
 
 TEST_F(WireEncoderTextTest, MessageUnsubscribe) {
   net::ClientMessage msg{
       net::UnsubscribeMsg{Handle{0, 5, Handle::kSubscriber}}};
   ASSERT_TRUE(net::WireEncodeText(os, msg));
-  ASSERT_EQ(os.str(), "{\"method\":\"unsubscribe\",\"params\":{\"subuid\":5}}");
+  ASSERT_EQ(os.str(),
+            "{\"method\":\"unsubscribe\",\"params\":{\"subuid\":402653189}}");
 }
 
 TEST_F(WireEncoderTextTest, MessageAnnounce) {
diff --git a/ntcore/src/test/native/cpp/net3/MockWireConnection3.h b/ntcore/src/test/native/cpp/net3/MockWireConnection3.h
index b7c785f..50fc80c 100644
--- a/ntcore/src/test/native/cpp/net3/MockWireConnection3.h
+++ b/ntcore/src/test/native/cpp/net3/MockWireConnection3.h
@@ -28,6 +28,8 @@
 
   MOCK_METHOD(void, Flush, (), (override));
 
+  MOCK_METHOD(uint64_t, GetLastFlushTime, (), (const, override));
+
   MOCK_METHOD(void, Disconnect, (std::string_view reason), (override));
 
  protected:
diff --git a/ntcore/src/test/native/cpp/net3/WireDecoder3Test.cpp b/ntcore/src/test/native/cpp/net3/WireDecoder3Test.cpp
index 1cd6ecb..74f0d54 100644
--- a/ntcore/src/test/native/cpp/net3/WireDecoder3Test.cpp
+++ b/ntcore/src/test/native/cpp/net3/WireDecoder3Test.cpp
@@ -9,11 +9,12 @@
 #include <string>
 #include <string_view>
 
-#include "../SpanMatcher.h"
+#include <gtest/gtest.h>
+#include <wpi/SpanMatcher.h>
+
 #include "../TestPrinters.h"
 #include "../ValueMatcher.h"
 #include "gmock/gmock.h"
-#include "gtest/gtest.h"
 #include "net3/WireDecoder3.h"
 #include "networktables/NetworkTableValue.h"
 
diff --git a/ntcore/src/test/native/cpp/net3/WireEncoder3Test.cpp b/ntcore/src/test/native/cpp/net3/WireEncoder3Test.cpp
index bb4bfd2..de05934 100644
--- a/ntcore/src/test/native/cpp/net3/WireEncoder3Test.cpp
+++ b/ntcore/src/test/native/cpp/net3/WireEncoder3Test.cpp
@@ -8,11 +8,11 @@
 #include <string_view>
 #include <vector>
 
+#include <gtest/gtest.h>
+#include <wpi/SpanMatcher.h>
 #include <wpi/raw_ostream.h>
 
-#include "../SpanMatcher.h"
 #include "../TestPrinters.h"
-#include "gtest/gtest.h"
 #include "net3/Message3.h"
 #include "net3/WireEncoder3.h"
 #include "networktables/NetworkTableValue.h"