An I/O controller for virtual pinball machines: accelerometer nudge sensing, analog plunger input, button input encoding, LedWiz compatible output controls, and more.

Dependencies:   mbed FastIO FastPWM USBDevice

Fork of Pinscape_Controller by Mike R

/media/uploads/mjr/pinscape_no_background_small_L7Miwr6.jpg

This is Version 2 of the Pinscape Controller, an I/O controller for virtual pinball machines. (You can find the old version 1 software here.) Pinscape is software for the KL25Z that turns the board into a full-featured I/O controller for virtual pinball, with support for accelerometer-based nudging, a mechanical plunger, button inputs, and feedback device control.

In case you haven't heard of the idea before, a "virtual pinball machine" is basically a video pinball simulator that's built into a real pinball machine body. A TV monitor goes in place of the pinball playfield, and a second TV goes in the backbox to show the backglass artwork. Some cabs also include a third monitor to simulate the DMD (Dot Matrix Display) used for scoring on 1990s machines, or even an original plasma DMD. A computer (usually a Windows PC) is hidden inside the cabinet, running pinball emulation software that displays a life-sized playfield on the main TV. The cabinet has all of the usual buttons, too, so it not only looks like the real thing, but plays like it too. That's a picture of my own machine to the right. On the outside, it's built exactly like a real arcade pinball machine, with the same overall dimensions and all of the standard pinball cabinet trim hardware.

It's possible to buy a pre-built virtual pinball machine, but it also makes a great DIY project. If you have some basic wood-working skills and know your way around PCs, you can build one from scratch. The computer part is just an ordinary Windows PC, and all of the pinball emulation can be built out of free, open-source software. In that spirit, the Pinscape Controller is an open-source software/hardware project that offers a no-compromises, all-in-one control center for all of the unique input/output needs of a virtual pinball cabinet. If you've been thinking about building one of these, but you're not sure how to connect a plunger, flipper buttons, lights, nudge sensor, and whatever else you can think of, this project might be just what you're looking for.

You can find much more information about DIY Pin Cab building in general in the Virtual Cabinet Forum on vpforums.org. Also visit my Pinscape Resources page for more about this project and other virtual pinball projects I'm working on.

Downloads

  • Pinscape Release Builds: This page has download links for all of the Pinscape software. To get started, install and run the Pinscape Config Tool on your Windows computer. It will lead you through the steps for installing the Pinscape firmware on the KL25Z.
  • Config Tool Source Code. The complete C# source code for the config tool. You don't need this to run the tool, but it's available if you want to customize anything or see how it works inside.

Documentation

The new Version 2 Build Guide is now complete! This new version aims to be a complete guide to building a virtual pinball machine, including not only the Pinscape elements but all of the basics, from sourcing parts to building all of the hardware.

You can also refer to the original Hardware Build Guide (PDF), but that's out of date now, since it refers to the old version 1 software, which was rather different (especially when it comes to configuration).

System Requirements

The new Config Tool requires a fairly up-to-date Microsoft .NET installation. If you use Windows Update to keep your system current, you should be fine. A modern version of Internet Explorer (IE) is required, even if you don't use it as your main browser, because the Config Tool uses some system components that Microsoft packages into the IE install set. I test with IE11, so that's known to work. IE8 doesn't work. IE9 and 10 are unknown at this point.

The Windows requirements are only for the config tool. The firmware doesn't care about anything on the Windows side, so if you can make do without the config tool, you can use almost any Windows setup.

Main Features

Plunger: The Pinscape Controller started out as a "mechanical plunger" controller: a device for attaching a real pinball plunger to the video game software so that you could launch the ball the natural way. This is still, of course, a central feature of the project. The software supports several types of sensors: a high-resolution optical sensor (which works by essentially taking pictures of the plunger as it moves); a slide potentiometer (which determines the position via the changing electrical resistance in the pot); a quadrature sensor (which counts bars printed on a special guide rail that it moves along); and an IR distance sensor (which determines the position by sending pulses of light at the plunger and measuring the round-trip travel time). The Build Guide explains how to set up each type of sensor.

Nudging: The KL25Z (the little microcontroller that the software runs on) has a built-in accelerometer. The Pinscape software uses it to sense when you nudge the cabinet, and feeds the acceleration data to the pinball software on the PC. This turns physical nudges into virtual English on the ball. The accelerometer is quite sensitive and accurate, so we can measure the difference between little bumps and hard shoves, and everything in between. The result is natural and immersive.

Buttons: You can wire real pinball buttons to the KL25Z, and the software will translate the buttons into PC input. You have the option to map each button to a keyboard key or joystick button. You can wire up your flipper buttons, Magna Save buttons, Start button, coin slots, operator buttons, and whatever else you need.

Feedback devices: You can also attach "feedback devices" to the KL25Z. Feedback devices are things that create tactile, sound, and lighting effects in sync with the game action. The most popular PC pinball emulators know how to address a wide variety of these devices, and know how to match them to on-screen action in each virtual table. You just need an I/O controller that translates commands from the PC into electrical signals that turn the devices on and off. The Pinscape Controller can do that for you.

Expansion Boards

There are two main ways to run the Pinscape Controller: standalone, or using the "expansion boards".

In the basic standalone setup, you just need the KL25Z, plus whatever buttons, sensors, and feedback devices you want to attach to it. This mode lets you take advantage of everything the software can do, but for some features, you'll have to build some ad hoc external circuitry to interface external devices with the KL25Z. The Build Guide has detailed plans for exactly what you need to build.

The other option is the Pinscape Expansion Boards. The expansion boards are a companion project, which is also totally free and open-source, that provides Printed Circuit Board (PCB) layouts that are designed specifically to work with the Pinscape software. The PCB designs are in the widely used EAGLE format, which many PCB manufacturers can turn directly into physical boards for you. The expansion boards organize all of the external connections more neatly than on the standalone KL25Z, and they add all of the interface circuitry needed for all of the advanced software functions. The big thing they bring to the table is lots of high-power outputs. The boards provide a modular system that lets you add boards to add more outputs. If you opt for the basic core setup, you'll have enough outputs for all of the toys in a really well-equipped cabinet. If your ambitions go beyond merely well-equipped and run to the ridiculously extravagant, just add an extra board or two. The modular design also means that you can add to the system over time.

Expansion Board project page

Update notes

If you have a Pinscape V1 setup already installed, you should be able to switch to the new version pretty seamlessly. There are just a couple of things to be aware of.

First, the "configuration" procedure is completely different in the new version. Way better and way easier, but it's not what you're used to from V1. In V1, you had to edit the project source code and compile your own custom version of the program. No more! With V2, you simply install the standard, pre-compiled .bin file, and select options using the Pinscape Config Tool on Windows.

Second, if you're using the TSL1410R optical sensor for your plunger, there's a chance you'll need to boost your light source's brightness a little bit. The "shutter speed" is faster in this version, which means that it doesn't spend as much time collecting light per frame as before. The software actually does "auto exposure" adaptation on every frame, so the increased shutter speed really shouldn't bother it, but it does require a certain minimum level of contrast, which requires a certain minimal level of lighting. Check the plunger viewer in the setup tool if you have any problems; if the image looks totally dark, try increasing the light level to see if that helps.

New Features

V2 has numerous new features. Here are some of the highlights...

Dynamic configuration: as explained above, configuration is now handled through the Config Tool on Windows. It's no longer necessary to edit the source code or compile your own modified binary.

Improved plunger sensing: the software now reads the TSL1410R optical sensor about 15x faster than it did before. This allows reading the sensor at full resolution (400dpi), about 400 times per second. The faster frame rate makes a big difference in how accurately we can read the plunger position during the fast motion of a release, which allows for more precise position sensing and faster response. The differences aren't dramatic, since the sensing was already pretty good even with the slower V1 scan rate, but you might notice a little better precision in tricky skill shots.

Keyboard keys: button inputs can now be mapped to keyboard keys. The joystick button option is still available as well, of course. Keyboard keys have the advantage of being closer to universal for PC pinball software: some pinball software can be set up to take joystick input, but nearly all PC pinball emulators can take keyboard input, and nearly all of them use the same key mappings.

Local shift button: one physical button can be designed as the local shift button. This works like a Shift button on a keyboard, but with cabinet buttons. It allows each physical button on the cabinet to have two PC keys assigned, one normal and one shifted. Hold down the local shift button, then press another key, and the other key's shifted key mapping is sent to the PC. The shift button can have a regular key mapping of its own as well, so it can do double duty. The shift feature lets you access more functions without cluttering your cabinet with extra buttons. It's especially nice for less frequently used functions like adjusting the volume or activating night mode.

Night mode: the output controller has a new "night mode" option, which lets you turn off all of your noisy devices with a single button, switch, or PC command. You can designate individual ports as noisy or not. Night mode only disables the noisemakers, so you still get the benefit of your flashers, button lights, and other quiet devices. This lets you play late into the night without disturbing your housemates or neighbors.

Gamma correction: you can designate individual output ports for gamma correction. This adjusts the intensity level of an output to make it match the way the human eye perceives brightness, so that fades and color mixes look more natural in lighting devices. You can apply this to individual ports, so that it only affects ports that actually have lights of some kind attached.

IR Remote Control: the controller software can transmit and/or receive IR remote control commands if you attach appropriate parts (an IR LED to send, an IR sensor chip to receive). This can be used to turn on your TV(s) when the system powers on, if they don't turn on automatically, and for any other functions you can think of requiring IR send/receive capabilities. You can assign IR commands to cabinet buttons, so that pressing a button on your cabinet sends a remote control command from the attached IR LED, and you can have the controller generate virtual key presses on your PC in response to received IR commands. If you have the IR sensor attached, the system can use it to learn commands from your existing remotes.

Yet more USB fixes: I've been gradually finding and fixing USB bugs in the mbed library for months now. This version has all of the fixes of the last couple of releases, of course, plus some new ones. It also has a new "last resort" feature, since there always seems to be "just one more" USB bug. The last resort is that you can tell the device to automatically reboot itself if it loses the USB connection and can't restore it within a given time limit.

More Downloads

  • Custom VP builds: I created modified versions of Visual Pinball 9.9 and Physmod5 that you might want to use in combination with this controller. The modified versions have special handling for plunger calibration specific to the Pinscape Controller, as well as some enhancements to the nudge physics. If you're not using the plunger, you might still want it for the nudge improvements. The modified version also works with any other input controller, so you can get the enhanced nudging effects even if you're using a different plunger/nudge kit. The big change in the modified versions is a "filter" for accelerometer input that's designed to make the response to cabinet nudges more realistic. It also makes the response more subdued than in the standard VP, so it's not to everyone's taste. The downloads include both the updated executables and the source code changes, in case you want to merge the changes into your own custom version(s).

    Note! These features are now standard in the official VP releases, so you don't need my custom builds if you're using 9.9.1 or later and/or VP 10. I don't think there's any reason to use my versions instead of the latest official ones, and in fact I'd encourage you to use the official releases since they're more up to date, but I'm leaving my builds available just in case. In the official versions, look for the checkbox "Enable Nudge Filter" in the Keys preferences dialog. My custom versions don't include that checkbox; they just enable the filter unconditionally.
  • Output circuit shopping list: This is a saved shopping cart at mouser.com with the parts needed to build one copy of the high-power output circuit for the LedWiz emulator feature, for use with the standalone KL25Z (that is, without the expansion boards). The quantities in the cart are for one output channel, so if you want N outputs, simply multiply the quantities by the N, with one exception: you only need one ULN2803 transistor array chip for each eight output circuits. If you're using the expansion boards, you won't need any of this, since the boards provide their own high-power outputs.
  • Cary Owens' optical sensor housing: A 3D-printable design for a housing/mounting bracket for the optical plunger sensor, designed by Cary Owens. This makes it easy to mount the sensor.
  • Lemming77's potentiometer mounting bracket and shooter rod connecter: Sketchup designs for 3D-printable parts for mounting a slide potentiometer as the plunger sensor. These were designed for a particular slide potentiometer that used to be available from an Aliexpress.com seller but is no longer listed. You can probably use this design as a starting point for other similar devices; just check the dimensions before committing the design to plastic.

Copyright and License

The Pinscape firmware is copyright 2014, 2021 by Michael J Roberts. It's released under an MIT open-source license. See License.

Warning to VirtuaPin Kit Owners

This software isn't designed as a replacement for the VirtuaPin plunger kit's firmware. If you bought the VirtuaPin kit, I recommend that you don't install this software. The KL25Z can only run one firmware program at a time, so if you install the Pinscape firmware on your KL25Z, it will replace and erase your existing VirtuaPin proprietary firmware. If you do this, the only way to restore your VirtuaPin firmware is to physically ship the KL25Z back to VirtuaPin and ask them to re-flash it. They don't allow you to do this at home, and they don't even allow you to back up your firmware, since they want to protect their proprietary software from copying. For all of these reasons, if you want to run the Pinscape software, I strongly recommend that you buy a "blank" retail KL25Z to use with Pinscape. They only cost about $15 and are available at several online retailers, including Amazon, Mouser, and eBay. The blank retail boards don't come with any proprietary firmware pre-installed, so installing Pinscape won't delete anything that you paid extra for.

With those warnings in mind, if you're absolutely sure that you don't mind permanently erasing your VirtuaPin firmware, it is at least possible to use Pinscape as a replacement for the VirtuaPin firmware. Pinscape uses the same button wiring conventions as the VirtuaPin setup, so you can keep your buttons (although you'll have to update the GPIO pin mappings in the Config Tool to match your physical wiring). As of the June, 2021 firmware, the Vishay VCNL4010 plunger sensor that comes with the VirtuaPin v3 plunger kit is supported, so you can also keep your plunger, if you have that chip. (You should check to be sure that's the sensor chip you have before committing to this route, if keeping the plunger sensor is important to you. The older VirtuaPin plunger kits came with different IR sensors that the Pinscape software doesn't handle.)

Revision:
48:058ace2aed1d
Parent:
47:df7a88cd249c
Child:
49:37bd97eb7688
diff -r df7a88cd249c -r 058ace2aed1d main.cpp
--- a/main.cpp	Thu Feb 18 07:32:20 2016 +0000
+++ b/main.cpp	Fri Feb 26 18:42:03 2016 +0000
@@ -10,7 +10,7 @@
 * substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
-* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILIT Y, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
 * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -20,12 +20,12 @@
 // The Pinscape Controller
 // A comprehensive input/output controller for virtual pinball machines
 //
-// This project implements an I/O controller for virtual pinball cabinets.  Its
-// function is to connect Windows pinball software, such as Visual Pinball, with
-// physical devices in the cabinet: buttons, sensors, and feedback devices that
-// create visual or mechanical effects during play.  
+// This project implements an I/O controller for virtual pinball cabinets.  The
+// controller's function is to connect Visual Pinball (and other Windows pinball 
+// emulators) with physical devices in the cabinet:  buttons, sensors, and 
+// feedback devices that create visual or mechanical effects during play.  
 //
-// The software can perform several different functions, which can be used 
+// The controller can perform several different functions, which can be used 
 // individually or in any combination:
 //
 //  - Nudge sensing.  This uses the KL25Z's on-board accelerometer to sense the
@@ -153,19 +153,19 @@
 // STATUS LIGHTS:  The on-board LED on the KL25Z flashes to indicate the current 
 // device status.  The flash patterns are:
 //
-//    two short red flashes = the device is powered but hasn't successfully
-//        connected to the host via USB (either it's not physically connected
-//        to the USB port, or there was a problem with the software handshake
-//        with the USB device driver on the computer)
+//    short yellow flash = waiting to connect
 //
-//    short red flash = the host computer is in sleep/suspend mode
+//    short red flash = the connection is suspended (the host is in sleep
+//        or suspend mode, the USB cable is unplugged after a connection
+//        has been established)
+//
+//    two short red flashes = connection lost (the device should immediately
+//        go back to short-yellow "waiting to reconnect" mode when a connection
+//        is lost, so this display shouldn't normally appear)
 //
 //    long red/yellow = USB connection problem.  The device still has a USB
-//        connection to the host, but data transmissions are failing.  This
-//        condition shouldn't ever occur; if it does, it probably indicates
-//        a bug in the device's USB software.  This display is provided to
-//        flag any occurrences for investigation.  You'll probably need to
-//        manually reset the device if this occurs.
+//        connection to the host (or so it appears to the device), but data 
+//        transmissions are failing.
 //
 //    long yellow/green = everything's working, but the plunger hasn't
 //        been calibrated.  Follow the calibration procedure described in
@@ -175,13 +175,27 @@
 //    alternating blue/green = everything's working normally, and plunger
 //        calibration has been completed (or there's no plunger attached)
 //
+//    fast red/purple = out of memory.  The controller halts and displays
+//        this diagnostic code until you manually reset it.  If this happens,
+//        it's probably because the configuration is too complex, in which
+//        case the same error will occur after the reset.  If it's stuck
+//        in this cycle, you'll have to restore the default configuration
+//        by re-installing the controller software (the Pinscape .bin file).
 //
-// USB PROTOCOL:  please refer to USBProtocol.h for details on the USB
-// message protocol.
+//
+// USB PROTOCOL:  Most of our USB messaging is through standard USB HID
+// classes (joystick, keyboard).  We also accept control messages on our
+// primary HID interface "OUT endpoint" using a custom protocol that's
+// not defined in any USB standards (we do have to provide a USB HID
+// Report Descriptor for it, but this just describes the protocol as
+// opaque vendor-defined bytes).  The control protocol incorporates the 
+// LedWiz protocol as a subset, and adds our own private extensions.
+// For full details, see USBProtocol.h.
 
 
 #include "mbed.h"
 #include "math.h"
+#include "pinscape.h"
 #include "USBJoystick.h"
 #include "MMA8451Q.h"
 #include "tsl1410r.h"
@@ -194,10 +208,37 @@
 #include "ccdSensor.h"
 #include "potSensor.h"
 #include "nullSensor.h"
+#include "TinyDigitalIn.h"
 
 #define DECL_EXTERNS
 #include "config.h"
 
+// --------------------------------------------------------------------------
+//
+// Custom memory allocator.  We use our own version of malloc() to provide
+// diagnostics if we run out of heap.
+//
+void *xmalloc(size_t siz)
+{
+    // allocate through the normal library malloc; if that succeeds,
+    // simply return the pointer we got from malloc
+    void *ptr = malloc(siz);
+    if (ptr != 0)
+        return ptr;
+
+    // failed - display diagnostics
+    for (;;)
+    {
+        diagLED(1, 0, 0);
+        wait(.2);
+        diagLED(1, 0, 1);
+        wait(.2);
+    }
+}
+
+// overload operator new to call our custom malloc
+void *operator new(size_t siz) { return xmalloc(siz); }
+void *operator new[](size_t siz) { return xmalloc(siz); }
 
 // ---------------------------------------------------------------------------
 //
@@ -209,9 +250,6 @@
 // ---------------------------------------------------------------------------
 // utilities
 
-// number of elements in an array
-#define countof(x) (sizeof(x)/sizeof((x)[0]))
-
 // floating point square of a number
 inline float square(float x) { return x*x; }
 
@@ -762,13 +800,19 @@
     switch (typ)
     {
     case PortTypeGPIOPWM:
-        // PWM GPIO port
-        lwp = new LwPwmOut(wirePinName(pin), activeLow ? 255 : 0);
+        // PWM GPIO port - assign if we have a valid pin
+        if (pin != 0)
+            lwp = new LwPwmOut(wirePinName(pin), activeLow ? 255 : 0);
+        else
+            lwp = new LwVirtualOut();
         break;
     
     case PortTypeGPIODig:
         // Digital GPIO port
-        lwp = new LwDigOut(wirePinName(pin), activeLow ? 255 : 0);
+        if (pin != 0)
+            lwp = new LwDigOut(wirePinName(pin), activeLow ? 255 : 0);
+        else
+            lwp = new LwVirtualOut();
         break;
     
     case PortTypeTLC5940:
@@ -1125,17 +1169,17 @@
     }
     
     // DigitalIn for the button
-    DigitalIn *di;
+    TinyDigitalIn *di;
     
     // current PHYSICAL on/off state, after debouncing
-    uint8_t on;
+    uint8_t on : 1;
     
     // current LOGICAL on/off state as reported to the host.
-    uint8_t pressed;
+    uint8_t pressed : 1;
 
     // previous logical on/off state, when keys were last processed for USB 
     // reports and local effects
-    uint8_t prev;
+    uint8_t prev : 1;
     
     // Debounce history.  On each scan, we shift in a 1 bit to the lsb if
     // the physical key is reporting ON, and shift in a 0 bit if the physical
@@ -1188,7 +1232,7 @@
     uint8_t pulseState;
     float pulseTime;
     
-} buttonState[MAX_BUTTONS];
+} __attribute__((packed)) buttonState[MAX_BUTTONS];
 
 
 // Button data
@@ -1200,7 +1244,7 @@
 struct
 {
     bool changed;       // flag: changed since last report sent
-    int nkeys;          // number of active keys in the list
+    uint8_t nkeys;      // number of active keys in the list
     uint8_t data[8];    // key state, in USB report format: byte 0 is the modifier key mask,
                         // byte 1 is reserved, and bytes 2-7 are the currently pressed key codes
 } kbState = { false, 0, { 0, 0, 0, 0, 0, 0, 0, 0 } };
@@ -1266,7 +1310,7 @@
         if (pin != NC)
         {
             // set up the GPIO input pin for this button
-            bs->di = new DigitalIn(pin);
+            bs->di = new TinyDigitalIn(pin);
             
             // if it's a pulse mode button, set the initial pulse state to Off
             if (cfg.button[i].flags & BtnFlagPulse)
@@ -1641,7 +1685,7 @@
          p->addAvg(ax, ay);
 
          // check for auto-centering every so often
-         if (tCenter_.read_ms() > 1000)
+         if (tCenter_.read_us() > 1000000)
          {
              // add the latest raw sample to the history list
              AccHist *prv = p;
@@ -1694,7 +1738,6 @@
              // of making the system more fault-tolerant.
              if (tInt_.read() > 1.0f)
              {
-                 printf("unwedging the accelerometer\r\n");
                 float x, y, z;
                 mma_.getAccXYZ(x, y, z);
              }
@@ -2265,6 +2308,786 @@
     }
 }
 
+// Plunger reader
+class PlungerReader
+{
+public:
+    PlungerReader()
+    {
+        // not in a firing event yet
+        firing = 0;
+
+        // no history yet
+        histIdx = 0;
+        
+        // not in calibration mode
+        cal = false;
+    }
+
+    // Collect a reading from the plunger sensor.  The main loop calls
+    // this frequently to read the current raw position data from the
+    // sensor.  We analyze the raw data to produce the calibrated
+    // position that we report to the PC via the joystick interface.
+    void read()
+    {
+        // Read a sample from the sensor
+        PlungerReading r;
+        if (plungerSensor->read(r))
+        {
+            // if in calibration mode, apply it to the calibration
+            if (cal)
+            {
+                // if it's outside of the current calibration bounds,
+                // expand the bounds
+                if (r.pos < cfg.plunger.cal.min)
+                    cfg.plunger.cal.min = r.pos;
+                if (r.pos < cfg.plunger.cal.zero)
+                    cfg.plunger.cal.zero = r.pos;
+                if (r.pos > cfg.plunger.cal.max)
+                    cfg.plunger.cal.max = r.pos;
+                    
+                // As long as we're in calibration mode, return the raw
+                // sensor position as the joystick value, adjusted to the
+                // JOYMAX scale.
+                z = int16_t((long(r.pos) * JOYMAX)/65535);
+                return;
+            }
+            
+            // If the new reading is within 2ms of the previous reading,
+            // ignore it.  We require a minimum time between samples to
+            // ensure that we have a usable amount of precision in the
+            // denominator (the time interval) for calculating the plunger
+            // velocity.  (The CCD sensor can't take readings faster than
+            // this anyway, but other sensor types, such as potentiometers,
+            // can, so we have to throttle the rate artifically in case
+            // we're using a fast sensor like that.)
+            if (uint32_t(r.t - prv.t) < 2000UL)
+                return;
+                
+            // bounds-check the calibration data
+            checkCalBounds(r.pos);
+
+            // calibrate and rescale the value
+            int pos = int(
+                (long(r.pos - cfg.plunger.cal.zero) * JOYMAX)
+                / (cfg.plunger.cal.max - cfg.plunger.cal.zero));
+
+            // Calculate the velocity from the previous reading to here,
+            // in joystick distance units per microsecond.
+            //
+            // For reference, the physical plunger velocity ranges up
+            // to about 100,000 joystick distance units/sec.  This is 
+            // based on empirical measurements.  The typical time for 
+            // a real plunger to travel the full distance when released 
+            // from full retraction is about 85ms, so the average velocity 
+            // covering this distance is about 56,000 units/sec.  The 
+            // peak is probably about twice that.  In real-world units, 
+            // this translates to an average speed of about .75 m/s and 
+            // a peak of about 1.5 m/s.
+            //
+            // Note that we actually calculate the value here in units
+            // per *microsecond* - the discussion above is in terms of
+            // units/sec because that's more on a human scale.  Our
+            // choice of internal units here really isn't important,
+            // since we only use the velocity for comparison purposes,
+            // to detect acceleration trends.  We therefore save ourselves
+            // a little CPU time by using the natural units of our inputs.
+            float v = float(pos - prv.pos)/float(r.t - prv.t);
+                
+            // presume we'll just report the latest reading
+            z = pos;
+            vz = v;
+            
+            // Check firing events
+            switch (firing)
+            {
+            case 0:
+                // Default state - not in a firing event.  
+                
+                // Check for a recent high water mark.  Keep the high point
+                // within a small window
+                
+                // If we have forward motion from a position that's retracted 
+                // beyond a threshold, enter phase 1.
+                if (v < 0 && pos > JOYMAX/6)
+                {
+                    // enter phase 1
+                    firingMode(1);
+                    
+                    // we don't have a freeze position yet, but note the start time
+                    f1.pos = 0;
+                    f1.t = r.t;
+                    
+                    // Figure the fake "bounce" position in case we complete the
+                    // firing event.  This is the barrel spring compression proportional
+                    // to the starting position.  The barrel spring is about 1/6 the
+                    // length of the main spring, so figure it compresses by 1/6 the
+                    // distance.
+                    f2.pos = -pos/6;
+                }
+                break;
+                
+            case 1:
+                // Phase 1 - acceleration.  If we cross the zero point, trigger
+                // the firing event.  Otherwise, continue monitoring as long as we
+                // see acceleration in the forward direction.
+                if (pos <= 0)
+                {
+                    // switch to the synthetic firing mode
+                    firingMode(2);
+                    
+                    // note the start time for the firing phase
+                    f2.t = r.t;
+                }
+                else if (v < vprv)
+                {
+                    // We're still accelerating, and we haven't crossed the zero
+                    // point yet - stay in phase 1.  (Note that forward motion is
+                    // negative velocity, so accelerating means that the new 
+                    // velocity is more negative than the previous one, which
+                    // is to say numerically less than - that's why the test
+                    // for acceleration is the seemingly backwards 'v < vprv'.)
+                }
+                else if (uint32_t(r.t - prv.t) < 5000UL)
+                {
+                    // We're not accelerating relative to the previous reading,
+                    // but we're within 5ms of it.  Throw out this reading to
+                    // collect more data.
+                    pos = prv.pos;
+                    r.t = prv.t;
+                    v = vprv;
+                }
+                else
+                {
+                    // We're not accelerating.  Cancel the firing event.
+                    firingMode(0);
+                }
+                
+                // If we've been in phase 1 for at least 25ms, we're probably
+                // really doing a release.  Jump back to the recent local
+                // maximum where the release *really* started.  This is always
+                // a bit before we started seeing sustained accleration, because
+                // the plunger motion for the first few milliseconds is too slow
+                // for our sensor precision to reliably detect acceleration.
+                if (firing == 1)
+                {
+                    if (f1.pos != 0)
+                    {
+                        // we have a reset point - freeze there
+                        z = f1.pos;
+                    }
+                    else if (uint32_t(r.t - f1.t) >= 25000UL)
+                    {
+                        // it's been long enough - set a reset point.
+                        f1.pos = z = histLocalMax(r.t, 50000UL);
+                    }
+                }
+                break;
+                
+            case 2:
+                // Phase 2 - start of synthetic firing event.  Report the fake
+                // bounce for 25ms.  VP polls the joystick about every 10ms, so 
+                // this should be enough time to guarantee that VP sees this
+                // report at least once.
+                if (uint32_t(r.t - f2.t) < 25000UL)
+                {
+                    // report the bounce position
+                    z = f2.pos;
+                }
+                else
+                {
+                    // it's been long enough - switch to phase 3, where we
+                    // report the park position until the real plunger comes
+                    // to rest
+                    firingMode(3);
+                    z = 0;
+                    
+                    // set the start of the "stability window" to the rest position
+                    f3s.t = r.t;
+                    f3s.pos = 0;
+                    
+                    // set the start of the "retraction window" to the actual position
+                    f3r = r;
+                }
+                break;
+                
+            case 3:
+                // Phase 3 - in synthetic firing event.  Report the park position
+                // until the plunger position stabilizes.  Left to its own devices, 
+                // the plunger will usualy bounce off the barrel spring several 
+                // times before coming to rest, so we'll see oscillating motion
+                // for a second or two.  In the simplest case, we can aimply wait
+                // for the plunger to stop moving for a short time.  However, the
+                // player might intervene by pulling the plunger back again, so
+                // watch for that motion as well.  If we're just bouncing freely,
+                // we'll see the direction change frequently.  If the player is
+                // moving the plunger manually, the direction will be constant
+                // for longer.
+                if (v >= 0)
+                {
+                    // We're moving back (or standing still).  If this has been
+                    // going on for a while, the user must have taken control.
+                    if (uint32_t(r.t - f3r.t) > 65000UL)
+                    {
+                        // user has taken control - cancel firing mode
+                        firingMode(0);
+                        break;
+                    }
+                }
+                else
+                {
+                    // forward motion - reset retraction window
+                    f3r.t = r.t;
+                }
+
+                // check if we've come to rest, or close enough
+                if (abs(r.pos - f3s.pos) < 200)
+                {
+                    // It's within an eighth inch of the last starting point. 
+                    // If it's been here for 30ms, consider it stable.
+                    if (uint32_t(r.t - f3s.t) > 30000UL)
+                    {
+                        // we're done with the firing event
+                        firingMode(0);
+                    }
+                    else
+                    {
+                        // it's close to the last position but hasn't been
+                        // here long enough; stay in firing mode and continue
+                        // to report the park position
+                        z = 0;
+                    }
+                }
+                else
+                {
+                    // It's not close enough to the last starting point, so use
+                    // this as a new starting point, and stay in firing mode.
+                    f3s = r;
+                    z = 0;
+                }
+                break;
+            }
+            
+            // save the new reading for next time
+            prv.pos = pos;
+            prv.t = r.t;
+            vprv = v;
+            
+            // add it to the circular history buffer as well
+            hist[histIdx++] = prv;
+            histIdx %= countof(hist);
+        }
+    }
+    
+    // Get the current value to report through the joystick interface
+    int16_t getPosition() const { return z; }
+    
+    // Get the current velocity (joystick distance units per microsecond)
+    float getVelocity() const { return vz; }
+    
+    // get the timestamp of the current joystick report (microseconds)
+    uint32_t getTimestamp() const { return prv.t; }
+
+    // Set calibration mode on or off
+    void calMode(bool f) 
+    {
+        // if entering calibration mode, reset the saved calibration data
+        if (f && !cal)
+            cfg.plunger.cal.begin();
+
+        // remember the new mode
+        cal = f; 
+    }
+    
+    // is a firing event in progress?
+    bool isFiring() { return firing > 3; }
+
+private:
+    // set a firing mode
+    inline void firingMode(int m) 
+    {
+        firing = m;
+    
+        // $$$
+        lwPin[3]->set(0);
+        lwPin[4]->set(0);
+        lwPin[5]->set(0);
+        switch (m)
+        {
+        case 1: lwPin[3]->set(255); break;       // red
+        case 2: lwPin[4]->set(255); break;       // green
+        case 3: lwPin[5]->set(255); break;       // blue
+        case 4: lwPin[3]->set(255); lwPin[5]->set(255); break;   // purple
+        }
+        //$$$
+    }
+    
+    // Find the most recent local maximum in the history data, up to
+    // the given time limit.
+    int histLocalMax(uint32_t tcur, uint32_t dt)
+    {
+        // start with the prior entry
+        int idx = (histIdx == 0 ? countof(hist) : histIdx) - 1;
+        int hi = hist[idx].pos;
+        
+        // scan backwards for a local maximum
+        for (int n = countof(hist) - 1 ; n > 0 ; idx = (idx == 0 ? countof(hist) : idx) - 1)
+        {
+            // if this isn't within the time window, stop
+            if (uint32_t(tcur - hist[idx].t) > dt)
+                break;
+                
+            // if this isn't above the current hith, stop
+            if (hist[idx].pos < hi)
+                break;
+                
+            // this is the new high
+            hi = hist[idx].pos;
+        }
+        
+        // return the local maximum
+        return hi;
+    }
+
+    // Adjust the calibration bounds for a new reading.  This is used
+    // while NOT in calibration mode to ensure that a reading doesn't
+    // violate the calibration limits.  If it does, we'll readjust the
+    // limits to incorporate the new value.
+    void checkCalBounds(int pos)
+    {
+        // If the value is beyond the calibration maximum, increase the
+        // calibration point.  This ensures that our joystick reading
+        // is always within the valid joystick field range.
+        if (pos > cfg.plunger.cal.max)
+            cfg.plunger.cal.max = pos;
+            
+        // make sure we don't overflow in the opposite direction
+        if (pos < cfg.plunger.cal.zero
+            && cfg.plunger.cal.zero - pos > cfg.plunger.cal.max)
+        {
+            // we need to raise 'max' by this much to keep things in range
+            int adj = cfg.plunger.cal.zero - pos - cfg.plunger.cal.max;
+            
+            // we can raise 'max' at most this much before overflowing
+            int lim = 0xffff - cfg.plunger.cal.max;
+            
+            // if we have headroom to raise 'max' by 'adj', do so, otherwise
+            // raise it as much as we can and apply the excess to lowering the
+            // zero point
+            if (adj > lim)
+            {
+                cfg.plunger.cal.zero -= adj - lim;
+                adj = lim;
+            }
+            cfg.plunger.cal.max += adj;
+        }
+            
+        // If the calibration max isn't higher than the calibration
+        // zero, we have a negative or zero scale range, which isn't
+        // physically meaningful.  Fix it by forcing the max above
+        // the zero point (or the zero point below the max, if they're
+        // both pegged at the datatype maximum).
+        if (cfg.plunger.cal.max <= cfg.plunger.cal.zero)
+        {
+            if (cfg.plunger.cal.zero != 0xFFFF)
+                cfg.plunger.cal.max = cfg.plunger.cal.zero + 1;
+            else
+                cfg.plunger.cal.zero -= 1;
+        }
+    }
+    
+    // Previous reading
+    PlungerReading prv;
+    
+    // velocity at previous reading
+    float vprv;
+    
+    // Circular buffer of recent readings.  We keep a short history
+    // of readings to analyze during firing events.  We can only identify
+    // a firing event once it's somewhat under way, so we need a little
+    // retrospective information to accurately determine after the fact
+    // exactly when it started.  We throttle our readings to no more
+    // than one every 2ms, so we have at least N*2ms of history in this
+    // array.
+    PlungerReading hist[25];
+    int histIdx;
+
+    // Firing event state.
+    //
+    // A "firing event" happens when we detect that the physical plunger
+    // is moving forward fast enough that it was probably released.  When
+    // we detect a firing event, we momentarily disconnect the joystick
+    // readings from the physical sensor, and instead feed in a series of
+    // synthesized readings that simulate an idealized release motion.
+    //
+    // The reason we create these synthetic readings is that they give us
+    // better results in VP and other PC pinball players.  The joystick
+    // interface only lets us report the instantaneous plunger position.
+    // VP only reads the position at certain intervals, so it picks up
+    // a series of snapshots of the position, which it uses to infer the
+    // plunger velocity.  But the plunger release motion is so fast that
+    // VP's sampling rate creates a classic digital "aliasing" problem.
+    //
+    // Our synthesized report structure is designed to overcome the
+    // aliasing problem by removing the intermediate position reports 
+    // and only reporting the starting and ending positions.  This
+    // allows the PC side to reliably read the extremes of the travel
+    // and work entirely in the simulation domain to simulate a plunger
+    // release of the detected distance.  This produces more realistic
+    // results than feeding VP the real data, ironically.
+    //
+    // DETECTING A RELEASE MOTION
+    //
+    // How do we tell when the plunger is being released?  The basic
+    // idea is to monitor the sensor data and look for a series of
+    // readings that match the profile of a release motion.  For an
+    // idealized, mathematical model of a plunger, a release causes
+    // the plunger to start accelerating under the spring force.
+    //
+    // The real system has a couple of complications.  First, there
+    // are some mechanical effects that make the motion less than
+    // ideal (in the sense of matching the mathematical model),
+    // like friction and wobble.  This seems to be especially
+    // significant for the first 10-20ms of the release, probably
+    // because friction is a bigger factor at slow speeds, and
+    // also because of the uneven forces as the user lets go.
+    // Second, our sensor doesn't have infinite precision, and
+    // our clock doesn't either, and these error bars compound
+    // when we combine position and time to compute velocity.
+    //
+    // To deal with these real-world complications, we have a couple
+    // of strategies.  First, we tolerate a little bit of non-uniformity
+    // in the acceleration, by waiting a little longer if we get a
+    // reading that doesn't appear to be accelerating.  We still
+    // insist on continuous acceleration, but we basically double-check
+    // a reading by extending the time window when necessary.  Second,
+    // when we detect a series of accelerating readings, we go back
+    // to prior readings from before the sustained acceleration
+    // began to find out when the motion really began.
+    //
+    // PROCESSING A RELEASE MOTION
+    //
+    // We continuously monitor the sensor data.  When we see the position
+    // moving forward, toward the zero point, we start watching for
+    // sustained acceleration .  If we see acceleration for more than a 
+    // minimum threshold time (about 20ms), we freeze the reported 
+    // position at the recent local maximum (from the recent history of 
+    // readings) and wait for the acceleration to stop or for the plunger
+    // to cross the zero position.  If it crosses the zero position
+    // while still accelerating, we initiate a firing event.  Otherwise
+    // we return to instantaneous reporting of the actual position.
+    //
+    // HOW THIS LOOKS TO THE USER
+    // 
+    // The typical timing to reach the zero point during a release
+    // is about 60-80ms.  This is essentially the longest that we can
+    // stay in phase 1, so it's the longest that the readings will be
+    // frozen while we try to decide about a firing event.  This is
+    // fast enough that it should be barely perceptible to the user.
+    // The synthetic firing event should trigger almost immediately
+    // upon releasing the plunger, from the user's perspective.
+    //
+    // The big danger with this approach is "false positives":
+    // mistaking manual motion under the user's control for a possible 
+    // firing event.  A false positive would produce a highly visible 
+    // artifact, namely the on-screen plunger freezing in place while 
+    // the player moves the real plunger.  The strategy we use makes it 
+    // almost impossible for this to happen long enough to be 
+    // perceptible.  To fool the system, you have to accelerate the 
+    // plunger very steadily - with about 5ms granularity.  It's
+    // really hard to do this, and especially unlikely that a user
+    // would do so accidentally.
+    //
+    // FIRING STATE VARIABLE
+    //
+    // The firing states are:
+    //
+    //   0 - Default state.  We report the real instantaneous plunger 
+    //       position to the joystick interface.
+    //
+    //   1 - Phase 1 - acceleration
+    //
+    //   2 - Firing event started.  We report the "bounce" position for
+    //       a minimum time.
+    //
+    //   3 - Firing event hold.  We report the rest position for a
+    //       minimum interval, or until the real plunger comes to rest
+    //       somewhere, whichever comes first.
+    //
+    int firing;
+    
+    // Position/timestamp at start of firing phase 1.  We freeze the
+    // joystick reports at this position until we decide whether or not 
+    // we're actually in a firing event.  This isn't set until we're
+    // confident that we've been in the accleration phase for long
+    // enough; pos is non-zero when this is valid.
+    PlungerReading f1;
+    
+    // Position/timestamp at start of firing phase 2.  The position is
+    // the fake "bounce" position we report during this phase, and the
+    // timestamp tells us when the phase began so that we can end it
+    // after enough time elapses.
+    PlungerReading f2;
+    
+    // Position/timestamp of start of stability window during phase 3.
+    // We use this to determine when the plunger comes to rest.  We set
+    // this at the beginning of phase 4, and then reset it when the 
+    // plunger moves too far from the last position.
+    PlungerReading f3s;
+    
+    // Position/timestamp of start of retraction window during phase 3.
+    // We use this to determine if the user is drawing the plunger back.
+    // If we see retraction motion for more than about 65ms, we assume
+    // that the user has taken over, because we should see forward
+    // motion within this timeframe if the plunger is just bouncing
+    // freely.
+    PlungerReading f3r;
+    
+    // flag: we're in calibration mode
+    bool cal;
+    
+    // next Z value to report to the joystick interface (in joystick 
+    // distance units)
+    int z;
+    
+    // velocity of this reading (joystick distance units per microsecond)
+    float vz;
+};
+
+// plunger reader singleton
+PlungerReader plungerReader;
+
+// ---------------------------------------------------------------------------
+//
+// Handle the ZB Launch Ball feature.
+//
+// The ZB Launch Ball feature, if enabled, lets the mechanical plunger
+// serve as a substitute for a physical Launch Ball button.  When a table
+// is loaded in VP, and the table has the ZB Launch Ball LedWiz port
+// turned on, we'll disable mechanical plunger reports through the
+// joystick interface and instead use the plunger only to simulate the
+// Launch Ball button.  When the mode is active, pulling back and 
+// releasing the plunger causes a brief simulated press of the Launch
+// button, and pushing the plunger forward of the rest position presses
+// the Launch button as long as the plunger is pressed forward.
+//
+// This feature has two configuration components:
+//
+//   - An LedWiz port number.  This port is a "virtual" port that doesn't
+//     have to be attached to any actual output.  DOF uses it to signal 
+//     that the current table uses a Launch button instead of a plunger.
+//     DOF simply turns the port on when such a table is loaded and turns
+//     it off at all other times.  We use it to enable and disable the
+//     plunger/launch button connection.
+//
+//   - A joystick button ID.  We simulate pressing this button when the
+//     launch feature is activated via the LedWiz port and the plunger is
+//     either pulled back and releasd, or pushed forward past the rest
+//     position.
+//
+class ZBLaunchBall
+{
+public:
+    ZBLaunchBall()
+    {
+        // start in the default state
+        lbState = 0;
+        
+        // get the button bit for the ZB Launch Ball button
+        lbButtonBit = (1 << (cfg.plunger.zbLaunchBall.btn - 1));
+        
+        // start the state transition timer
+        lbTimer.start();
+    }
+
+    // Update state.  This checks the current plunger position and
+    // the timers to see if the plunger is in a position that simulates
+    // a Launch Ball button press via the ZB Launch Ball feature.
+    // Updates the simulated button vector according to the current
+    // launch ball state.  The main loop calls this before each 
+    // joystick update to figure the new simulated button state.
+    void update(uint32_t &simButtons)
+    {
+        // Check for a simulated Launch Ball button press, if enabled
+        if (cfg.plunger.zbLaunchBall.port != 0)
+        {                
+            int znew = plungerReader.getPosition();
+            const int cockThreshold = JOYMAX/3;
+            const uint16_t pushThreshold = uint16_t(-JOYMAX/3.0 * cfg.plunger.zbLaunchBall.pushDistance/1000.0 * 65535.0);
+            int newState = lbState;
+            switch (lbState)
+            {
+            case 0:
+                // Base state.  If the plunger is pulled back by an inch
+                // or more, go to "cocked" state.  If the plunger is pushed
+                // forward by 1/4" or more, go to "pressed" state.
+                if (znew >= cockThreshold)
+                    newState = 1;
+                else if (znew <= pushThreshold)
+                    newState = 5;
+                break;
+                
+            case 1:
+                // Cocked state.  If a firing event is now in progress,
+                // go to "launch" state.  Otherwise, if the plunger is less
+                // than 1" retracted, go to "uncocked" state - the player
+                // might be slowly returning the plunger to rest so as not
+                // to trigger a launch.
+                if (plungerReader.isFiring() || znew <= 0)
+                    newState = 3;
+                else if (znew < cockThreshold)
+                    newState = 2;
+                break;
+                
+            case 2:
+                // Uncocked state.  If the plunger is more than an inch
+                // retracted, return to cocked state.  If we've been in
+                // the uncocked state for more than half a second, return
+                // to the base state.  This allows the user to return the
+                // plunger to rest without triggering a launch, by moving
+                // it at manual speed to the rest position rather than
+                // releasing it.
+                if (znew >= cockThreshold)
+                    newState = 1;
+                else if (lbTimer.read_us() > 500000)
+                    newState = 0;
+                break;
+                
+            case 3:
+                // Launch state.  If the plunger is no longer pushed
+                // forward, switch to launch rest state.
+                if (znew >= 0)
+                    newState = 4;
+                break;    
+                
+            case 4:
+                // Launch rest state.  If the plunger is pushed forward
+                // again, switch back to launch state.  If not, and we've
+                // been in this state for at least 200ms, return to the
+                // default state.
+                if (znew <= pushThreshold)
+                    newState = 3;
+                else if (lbTimer.read_us() > 200000)
+                    newState = 0;                    
+                break;
+                
+            case 5:
+                // Press-and-Hold state.  If the plunger is no longer pushed
+                // forward, AND it's been at least 50ms since we generated
+                // the simulated Launch Ball button press, return to the base 
+                // state.  The minimum time is to ensure that VP has a chance
+                // to see the button press and to avoid transient key bounce
+                // effects when the plunger position is right on the threshold.
+                if (znew > pushThreshold && lbTimer.read_us() > 50000)
+                    newState = 0;
+                break;
+            }
+            
+            // change states if desired
+            if (newState != lbState)
+            {
+                // If we're entering Launch state OR we're entering the
+                // Press-and-Hold state, AND the ZB Launch Ball LedWiz signal 
+                // is turned on, simulate a Launch Ball button press.
+                if (((newState == 3 && lbState != 4) || newState == 5)
+                    && wizOn[cfg.plunger.zbLaunchBall.port-1])
+                {
+                    lbBtnTimer.reset();
+                    lbBtnTimer.start();
+                    simButtons |= lbButtonBit;
+                }
+                
+                // if we're switching to state 0, release the button
+                if (newState == 0)
+                    simButtons &= ~(1 << (cfg.plunger.zbLaunchBall.btn - 1));
+                
+                // switch to the new state
+                lbState = newState;
+                
+                // start timing in the new state
+                lbTimer.reset();
+            }
+            
+            // If the Launch Ball button press is in effect, but the
+            // ZB Launch Ball LedWiz signal is no longer turned on, turn
+            // off the button.
+            //
+            // If we're in one of the Launch states (state #3 or #4),
+            // and the button has been on for long enough, turn it off.
+            // The Launch mode is triggered by a pull-and-release gesture.
+            // From the user's perspective, this is just a single gesture
+            // that should trigger just one momentary press on the Launch
+            // Ball button.  Physically, though, the plunger usually
+            // bounces back and forth for 500ms or so before coming to
+            // rest after this gesture.  That's what the whole state
+            // #3-#4 business is all about - we stay in this pair of
+            // states until the plunger comes to rest.  As long as we're
+            // in these states, we won't send duplicate button presses.
+            // But we also don't want the one button press to continue 
+            // the whole time, so we'll time it out now.
+            //
+            // (This could be written as one big 'if' condition, but
+            // I'm breaking it out verbosely like this to make it easier
+            // for human readers such as myself to comprehend the logic.)
+            if ((simButtons & lbButtonBit) != 0)
+            {
+                int turnOff = false;
+                
+                // turn it off if the ZB Launch Ball signal is off
+                if (!wizOn[cfg.plunger.zbLaunchBall.port-1])
+                    turnOff = true;
+                    
+                // also turn it off if we're in state 3 or 4 ("Launch"),
+                // and the button has been on long enough
+                if ((lbState == 3 || lbState == 4) && lbBtnTimer.read_us() > 250000)
+                    turnOff = true;
+                    
+                // if we decided to turn off the button, do so
+                if (turnOff)
+                {
+                    lbBtnTimer.stop();
+                    simButtons &= ~lbButtonBit;
+                }
+            }
+        }
+    }
+  
+private:
+    // Simulated Launch Ball button state.  If a "ZB Launch Ball" port is
+    // defined for our LedWiz port mapping, any time that port is turned ON,
+    // we'll simulate pushing the Launch Ball button if the player pulls 
+    // back and releases the plunger, or simply pushes on the plunger from
+    // the rest position.  This allows the plunger to be used in lieu of a
+    // physical Launch Ball button for tables that don't have plungers.
+    //
+    // States:
+    //   0 = default
+    //   1 = cocked (plunger has been pulled back about 1" from state 0)
+    //   2 = uncocked (plunger is pulled back less than 1" from state 1)
+    //   3 = launching, plunger is forward beyond park position
+    //   4 = launching, plunger is behind park position
+    //   5 = pressed and holding (plunger has been pressed forward beyond 
+    //       the park position from state 0)
+    int lbState;
+    
+    // button bit for ZB launch ball button
+    uint32_t lbButtonBit;
+    
+    // Time since last lbState transition.  Some of the states are time-
+    // sensitive.  In the "uncocked" state, we'll return to state 0 if
+    // we remain in this state for more than a few milliseconds, since
+    // it indicates that the plunger is being slowly returned to rest
+    // rather than released.  In the "launching" state, we need to release 
+    // the Launch Ball button after a moment, and we need to wait for 
+    // the plunger to come to rest before returning to state 0.
+    Timer lbTimer;
+    
+    // Launch Ball simulated push timer.  We start this when we simulate
+    // the button push, and turn off the simulated button when enough time
+    // has elapsed.
+    Timer lbBtnTimer;
+};
+
 // ---------------------------------------------------------------------------
 //
 // Reboot - resets the microcontroller
@@ -2329,9 +3152,8 @@
 // Pixel dump mode - the host requested a dump of image sensor pixels
 // (helpful for installing and setting up the sensor and light source)
 bool reportPix = false;
-uint8_t reportPixMode;  // pixel report mode bits: 
-                        // 0x01 -> raw pixels (0) / processed (1)
-                        // 0x02 -> high res scan (0) / low res (1)
+uint8_t reportPixFlags;    // pixel report flag bits (see ccdSensor.h)
+uint8_t reportPixVisMode;  // pixel report visualization mode (see ccdSensor.h)
 
 
 // ---------------------------------------------------------------------------
@@ -2384,7 +3206,7 @@
 // Handle an input report from the USB host.  Input reports use our extended
 // LedWiz protocol.
 //
-void handleInputMsg(LedWizMsg &lwm, USBJoystick &js, int &z)
+void handleInputMsg(LedWizMsg &lwm, USBJoystick &js)
 {
     // LedWiz commands come in two varieties:  SBA and PBA.  An
     // SBA is marked by the first byte having value 64 (0x40).  In
@@ -2487,10 +3309,6 @@
                 // update the status flags
                 statusFlags = (statusFlags & ~0x01) | (data[3] & 0x01);
                 
-                // if the plunger is no longer enabled, use 0 for z reports
-                if (!cfg.plunger.enabled)
-                    z = 0;
-                
                 // save the configuration
                 saveConfigToFlash();
                 
@@ -2506,17 +3324,17 @@
             
             // enter calibration mode
             calBtnState = 3;
+            plungerReader.calMode(true);
             calBtnTimer.reset();
-            cfg.plunger.cal.begin();
             break;
             
         case 3:
             // 3 = pixel dump
-            //     data[2] = mode bits:
-            //               0x01  -> return processed pixels (default is raw pixels)
-            //               0x02  -> low res scan (default is high res scan)
+            //     data[2] = flag bits
+            //     data[3] = visualization mode
             reportPix = true;
-            reportPixMode = data[2];
+            reportPixFlags = data[2];
+            reportPixVisMode = data[3];
             
             // show purple until we finish sending the report
             diagLED(1, 0, 1);
@@ -2660,7 +3478,7 @@
 //
 void preConnectFlasher()
 {
-    diagLED(1, 0, 0);
+    diagLED(1, 1, 0);
     wait(0.05);
     diagLED(0, 0, 0);
 }
@@ -2712,14 +3530,14 @@
     // controllers will need to address their respective controller objects,
     // which don't exit until we initialize those subsystems.
     initLwOut(cfg);
-    
+
     // start the TLC5940 clock
     if (tlc5940 != 0)
         tlc5940->start();
         
     // start the TV timer, if applicable
     startTVTimer(cfg);
-    
+
     // initialize the button input ports
     bool kbKeys = false;
     initButtons(cfg, kbKeys);
@@ -2737,6 +3555,8 @@
     Timer jsReportTimer;
     jsReportTimer.start();
     
+    Timer plungerIntervalTimer; plungerIntervalTimer.start(); // $$$
+
     // Time since we successfully sent a USB report.  This is a hacky workaround
     // for sporadic problems in the USB stack that I haven't been able to figure
     // out.  If we go too long without successfully sending a USB report, we'll
@@ -2767,63 +3587,11 @@
     
     // create the accelerometer object
     Accel accel(MMA8451_SCL_PIN, MMA8451_SDA_PIN, MMA8451_I2C_ADDRESS, MMA8451_INT_PIN);
-    
+   
     // last accelerometer report, in joystick units (we report the nudge
     // acceleration via the joystick x & y axes, per the VP convention)
     int x = 0, y = 0;
     
-    // last plunger report position, on the 0.0..1.0 normalized scale
-    float pos = 0;
-    
-    // last plunger report, in joystick units (we report the plunger as the
-    // "z" axis of the joystick, per the VP convention)
-    int z = 0;
-    
-    // most recent prior plunger readings, for tracking release events(z0 is
-    // reading just before the last one we reported, z1 is the one before that, 
-    // z2 the next before that)
-    int z0 = 0, z1 = 0, z2 = 0;
-    
-    // Simulated "bounce" position when firing.  We model the bounce off of
-    // the barrel spring when the plunger is released as proportional to the
-    // distance it was retracted just before being released.
-    int zBounce = 0;
-    
-    // Simulated Launch Ball button state.  If a "ZB Launch Ball" port is
-    // defined for our LedWiz port mapping, any time that port is turned ON,
-    // we'll simulate pushing the Launch Ball button if the player pulls 
-    // back and releases the plunger, or simply pushes on the plunger from
-    // the rest position.  This allows the plunger to be used in lieu of a
-    // physical Launch Ball button for tables that don't have plungers.
-    //
-    // States:
-    //   0 = default
-    //   1 = cocked (plunger has been pulled back about 1" from state 0)
-    //   2 = uncocked (plunger is pulled back less than 1" from state 1)
-    //   3 = launching, plunger is forward beyond park position
-    //   4 = launching, plunger is behind park position
-    //   5 = pressed and holding (plunger has been pressed forward beyond 
-    //       the park position from state 0)
-    int lbState = 0;
-    
-    // button bit for ZB launch ball button
-    const uint32_t lbButtonBit = (1 << (cfg.plunger.zbLaunchBall.btn - 1));
-    
-    // Time since last lbState transition.  Some of the states are time-
-    // sensitive.  In the "uncocked" state, we'll return to state 0 if
-    // we remain in this state for more than a few milliseconds, since
-    // it indicates that the plunger is being slowly returned to rest
-    // rather than released.  In the "launching" state, we need to release 
-    // the Launch Ball button after a moment, and we need to wait for 
-    // the plunger to come to rest before returning to state 0.
-    Timer lbTimer;
-    lbTimer.start();
-    
-    // Launch Ball simulated push timer.  We start this when we simulate
-    // the button push, and turn off the simulated button when enough time
-    // has elapsed.
-    Timer lbBtnTimer;
-    
     // Simulated button states.  This is a vector of button states
     // for the simulated buttons.  We combine this with the physical
     // button states on each USB joystick report, so we will report
@@ -2832,56 +3600,27 @@
     // simulated Launch Ball button.
     uint32_t simButtons = 0;
     
-    // Firing in progress: we set this when we detect the start of rapid 
-    // plunger movement from a retracted position towards the rest position.
-    //
-    // When we detect a firing event, we send VP a series of synthetic
-    // reports simulating the idealized plunger motion.  The actual physical
-    // motion is much too fast to report to VP; in the time between two USB
-    // reports, the plunger can shoot all the way forward, rebound off of
-    // the barrel spring, bounce back part way, and bounce forward again,
-    // or even do all of this more than once.  This means that sampling the 
-    // physical motion at the USB report rate would create a misleading 
-    // picture of the plunger motion, since our samples would catch the 
-    // plunger at random points in this oscillating motion.  From the 
-    // user's perspective, the physical action that occurred is simply that 
-    // the plunger was released from a particular distance, so it's this 
-    // high-level event that we want to convey to VP.  To do this, we
-    // synthesize a series of reports to convey an idealized version of
-    // the release motion that's perfectly synchronized to the VP reports.  
-    // Essentially we pretend that our USB position samples are exactly 
-    // aligned in time with (1) the point of retraction just before the 
-    // user released the plunger, (2) the point of maximum forward motion 
-    // just after the user released the plunger (the point of maximum 
-    // compression as the plunger bounces off of the barrel spring), and 
-    // (3) the plunger coming to rest at the park position.  This series
-    // of reports is synthetic in the sense that it's not what we actually
-    // see on the CCD at the times of these reports - the true plunger
-    // position is oscillating at high speed during this period.  But at
-    // the same time it conveys a more faithful picture of the true physical
-    // motion to VP, and allows VP to reproduce the true physical motion 
-    // more faithfully in its simulation model, by correcting for the
-    // relatively low sampling rate in the communication path between the
-    // real plunger and VP's model plunger.
-    //
-    // If 'firing' is non-zero, it's the index of our current report in
-    // the synthetic firing report series.
-    int firing = 0;
-
-    // start the first CCD integration cycle
+    // initialize the plunger sensor
     plungerSensor->init();
     
+    // set up the ZB Launch Ball monitor
+    ZBLaunchBall zbLaunchBall;
+    
     Timer dbgTimer; dbgTimer.start(); // $$$  plunger debug report timer
     
     // we're all set up - now just loop, processing sensor reports and 
     // host requests
     for (;;)
     {
-        // Process incoming reports on the joystick interface.  This channel
-        // is used for LedWiz commands are our extended protocol commands.
+        // Process incoming reports on the joystick interface.  The joystick
+        // "out" (receive) endpoint is used for LedWiz commands and our 
+        // extended protocol commands.  Limit processing time to 5ms to
+        // ensure we don't starve the input side.
         LedWizMsg lwm;
-        while (js.readLedWizMsg(lwm))
-            handleInputMsg(lwm, js, z);
+        Timer lwt;
+        lwt.start();
+        while (js.readLedWizMsg(lwm) && lwt.read_us() < 5000)
+            handleInputMsg(lwm, js);
        
         // check for plunger calibration
         if (calBtn != 0 && !calBtn->read())
@@ -2898,21 +3637,21 @@
             case 1:
                 // pushed, not yet debounced - if the debounce time has
                 // passed, start the hold period
-                if (calBtnTimer.read_ms() > 50)
+                if (calBtnTimer.read_us() > 50000)
                     calBtnState = 2;
                 break;
                 
             case 2:
                 // in the hold period - if the button has been held down
                 // for the entire hold period, move to calibration mode
-                if (calBtnTimer.read_ms() > 2050)
+                if (calBtnTimer.read_us() > 2050000)
                 {
                     // enter calibration mode
                     calBtnState = 3;
                     calBtnTimer.reset();
                     
                     // begin the plunger calibration limits
-                    cfg.plunger.cal.begin();
+                    plungerReader.calMode(true);
                 }
                 break;
                 
@@ -2932,10 +3671,11 @@
             // Otherwise, return to the base state without saving anything.
             // If the button is released before we make it to calibration
             // mode, it simply cancels the attempt.
-            if (calBtnState == 3 && calBtnTimer.read_ms() > 15000)
+            if (calBtnState == 3 && calBtnTimer.read_us() > 15000000)
             {
                 // exit calibration mode
                 calBtnState = 0;
+                plungerReader.calMode(false);
                 
                 // save the updated configuration
                 cfg.plunger.cal.calibrated = 1;
@@ -2954,7 +3694,7 @@
         {
         case 2:
             // in the hold period - flash the light
-            newCalBtnLit = ((calBtnTimer.read_ms()/250) & 1);
+            newCalBtnLit = ((calBtnTimer.read_us()/250000) & 1);
             break;
             
         case 3:
@@ -2984,357 +3724,16 @@
                 diagLED(0, 0, 0);       // off
             }
         }
- 
-        // If the plunger is enabled, and we're not in calibration mode, and 
-        // we're not already in a firing event, and the last plunger reading had 
-        // the plunger pulled back at least a bit, watch for plunger release 
-        // events until it's time for our next USB report.
-        if (!firing && calBtnState != 3 && cfg.plunger.enabled && z >= JOYMAX/6)
-        {
-            // monitor the plunger until it's time for our next report
-            for (int i = 0 ; i < 20 && jsReportTimer.read_ms() < 12 ; ++i)
-            {
-                // do a fast low-res scan; if it's at or past the zero point,
-                // start a firing event
-                float pos0;
-                if (plungerSensor->lowResScan(pos0) && pos0 <= cfg.plunger.cal.zero)
-                {
-                    firing = 1;
-                    break;
-                }
-            }
-        }
-
-        // read the plunger sensor, if it's enabled and we're not in firing mode
-        if (cfg.plunger.enabled && !firing)
-        {
-            // start with the previous reading, in case we don't have a
-            // clear result on this frame
-            int znew = z;
-            if (plungerSensor->highResScan(pos))
-            {
-                // We have a new reading.  If we're in calibration mode, use it
-                // to figure the new calibration, otherwise adjust the new reading
-                // for the established calibration.
-                if (calBtnState == 3)
-                {
-                    // Calibration mode.  If this reading is outside of the current
-                    // calibration bounds, expand the bounds.
-                    if (pos < cfg.plunger.cal.min)
-                        cfg.plunger.cal.min = pos;
-                    if (pos < cfg.plunger.cal.zero)
-                        cfg.plunger.cal.zero = pos;
-                    if (pos > cfg.plunger.cal.max)
-                        cfg.plunger.cal.max = pos;
-                        
-                    // normalize to the full physical range while calibrating
-                    znew = int(round(pos * JOYMAX));
-                }
-                else
-                {
-                    // Not in calibration mode, so normalize the new reading to the 
-                    // established calibration range.  
-                    //
-                    // Note that negative values are allowed.  Zero represents the
-                    // "park" position, where the plunger sits when at rest.  A mechanical 
-                    // plunger has a small amount of travel in the "push" direction,
-                    // since the barrel spring can be compressed slightly.  Negative
-                    // values represent travel in the push direction.
-                    if (pos > cfg.plunger.cal.max)
-                        pos = cfg.plunger.cal.max;
-                    znew = int(round(
-                        (pos - cfg.plunger.cal.zero)
-                        / (cfg.plunger.cal.max - cfg.plunger.cal.zero) 
-                        * JOYMAX));
-                }
-            }
-
-            // If we're not already in a firing event, check to see if the
-            // new position is forward of the last report.  If it is, a firing
-            // event might have started during the high-res scan.  This might
-            // seem unlikely given that the scan only takes about 5ms, but that
-            // 5ms represents about 25-30% of our total time between reports,
-            // there's about a 1 in 4 chance that a release starts during a
-            // scan.  
-            if (!firing && z0 > 0 && znew < z0)
-            {
-                // The plunger has moved forward since the previous report.
-                // Watch it for a few more ms to see if we can get a stable
-                // new position.
-                float pos0;
-                if (plungerSensor->lowResScan(pos0))
-                {
-                    int pos1 = pos0;
-                    Timer tw;
-                    tw.start();
-                    while (tw.read_ms() < 6)
-                    {
-                        // read the new position
-                        float pos2;
-                        if (plungerSensor->lowResScan(pos2))
-                        {
-                            // If it's stable over consecutive readings, stop looping.
-                            // Count it as stable if the position is within about 1/8".
-                            // The overall travel of a standard plunger is about 3.2", 
-                            // so on our normalized 0.0..1.0 scale, 1.0 equals 3.2",
-                            // thus 1" = .3125 and 1/8" = .0391.
-                            if (fabs(pos2 - pos1) < .0391f)
-                                break;
         
-                            // If we've crossed the rest position, and we've moved by
-                            // a minimum distance from where we starting this loop, begin
-                            // a firing event.  (We require a minimum distance to prevent
-                            // spurious firing from random analog noise in the readings
-                            // when the plunger is actually just sitting still at the 
-                            // rest position.  If it's at rest, it's normal to see small
-                            // random fluctuations in the analog reading +/- 1% or so
-                            // from the 0 point, especially with a sensor like a
-                            // potentionemeter that reports the position as a single 
-                            // analog voltage.)  Note that we compare the latest reading
-                            // to the first reading of the loop - we don't require the
-                            // threshold motion over consecutive readings, but any time
-                            // over the stability wait loop.
-                            if (pos1 < cfg.plunger.cal.zero && fabs(pos2 - pos0) > .0391f)
-                            {
-                                firing = 1;
-                                break;
-                            }
-                                                    
-                            // the new reading is now the prior reading
-                            pos1 = pos2;
-                        }
-                    }
-                }
-            }
-            
-            // Check for a simulated Launch Ball button press, if enabled
-            if (cfg.plunger.zbLaunchBall.port != 0)
-            {
-                const int cockThreshold = JOYMAX/3;
-                const int pushThreshold = int(-JOYMAX/3.0 * cfg.plunger.zbLaunchBall.pushDistance/1000.0);
-                int newState = lbState;
-                switch (lbState)
-                {
-                case 0:
-                    // Base state.  If the plunger is pulled back by an inch
-                    // or more, go to "cocked" state.  If the plunger is pushed
-                    // forward by 1/4" or more, go to "pressed" state.
-                    if (znew >= cockThreshold)
-                        newState = 1;
-                    else if (znew <= pushThreshold)
-                        newState = 5;
-                    break;
-                    
-                case 1:
-                    // Cocked state.  If a firing event is now in progress,
-                    // go to "launch" state.  Otherwise, if the plunger is less
-                    // than 1" retracted, go to "uncocked" state - the player
-                    // might be slowly returning the plunger to rest so as not
-                    // to trigger a launch.
-                    if (firing || znew <= 0)
-                        newState = 3;
-                    else if (znew < cockThreshold)
-                        newState = 2;
-                    break;
-                    
-                case 2:
-                    // Uncocked state.  If the plunger is more than an inch
-                    // retracted, return to cocked state.  If we've been in
-                    // the uncocked state for more than half a second, return
-                    // to the base state.  This allows the user to return the
-                    // plunger to rest without triggering a launch, by moving
-                    // it at manual speed to the rest position rather than
-                    // releasing it.
-                    if (znew >= cockThreshold)
-                        newState = 1;
-                    else if (lbTimer.read_ms() > 500)
-                        newState = 0;
-                    break;
-                    
-                case 3:
-                    // Launch state.  If the plunger is no longer pushed
-                    // forward, switch to launch rest state.
-                    if (znew >= 0)
-                        newState = 4;
-                    break;    
-                    
-                case 4:
-                    // Launch rest state.  If the plunger is pushed forward
-                    // again, switch back to launch state.  If not, and we've
-                    // been in this state for at least 200ms, return to the
-                    // default state.
-                    if (znew <= pushThreshold)
-                        newState = 3;
-                    else if (lbTimer.read_ms() > 200)
-                        newState = 0;                    
-                    break;
-                    
-                case 5:
-                    // Press-and-Hold state.  If the plunger is no longer pushed
-                    // forward, AND it's been at least 50ms since we generated
-                    // the simulated Launch Ball button press, return to the base 
-                    // state.  The minimum time is to ensure that VP has a chance
-                    // to see the button press and to avoid transient key bounce
-                    // effects when the plunger position is right on the threshold.
-                    if (znew > pushThreshold && lbTimer.read_ms() > 50)
-                        newState = 0;
-                    break;
-                }
-                
-                // change states if desired
-                if (newState != lbState)
-                {
-                    // If we're entering Launch state OR we're entering the
-                    // Press-and-Hold state, AND the ZB Launch Ball LedWiz signal 
-                    // is turned on, simulate a Launch Ball button press.
-                    if (((newState == 3 && lbState != 4) || newState == 5)
-                        && wizOn[cfg.plunger.zbLaunchBall.port-1])
-                    {
-                        lbBtnTimer.reset();
-                        lbBtnTimer.start();
-                        simButtons |= lbButtonBit;
-                    }
-                    
-                    // if we're switching to state 0, release the button
-                    if (newState == 0)
-                        simButtons &= ~(1 << (cfg.plunger.zbLaunchBall.btn - 1));
-                    
-                    // switch to the new state
-                    lbState = newState;
-                    
-                    // start timing in the new state
-                    lbTimer.reset();
-                }
-                
-                // If the Launch Ball button press is in effect, but the
-                // ZB Launch Ball LedWiz signal is no longer turned on, turn
-                // off the button.
-                //
-                // If we're in one of the Launch states (state #3 or #4),
-                // and the button has been on for long enough, turn it off.
-                // The Launch mode is triggered by a pull-and-release gesture.
-                // From the user's perspective, this is just a single gesture
-                // that should trigger just one momentary press on the Launch
-                // Ball button.  Physically, though, the plunger usually
-                // bounces back and forth for 500ms or so before coming to
-                // rest after this gesture.  That's what the whole state
-                // #3-#4 business is all about - we stay in this pair of
-                // states until the plunger comes to rest.  As long as we're
-                // in these states, we won't send duplicate button presses.
-                // But we also don't want the one button press to continue 
-                // the whole time, so we'll time it out now.
-                //
-                // (This could be written as one big 'if' condition, but
-                // I'm breaking it out verbosely like this to make it easier
-                // for human readers such as myself to comprehend the logic.)
-                if ((simButtons & lbButtonBit) != 0)
-                {
-                    int turnOff = false;
-                    
-                    // turn it off if the ZB Launch Ball signal is off
-                    if (!wizOn[cfg.plunger.zbLaunchBall.port-1])
-                        turnOff = true;
-                        
-                    // also turn it off if we're in state 3 or 4 ("Launch"),
-                    // and the button has been on long enough
-                    if ((lbState == 3 || lbState == 4) && lbBtnTimer.read_ms() > 250)
-                        turnOff = true;
-                        
-                    // if we decided to turn off the button, do so
-                    if (turnOff)
-                    {
-                        lbBtnTimer.stop();
-                        simButtons &= ~lbButtonBit;
-                    }
-                }
-            }
-                
-            // If a firing event is in progress, generate synthetic reports to 
-            // describe an idealized version of the plunger motion to VP rather 
-            // than reporting the actual physical plunger position.
-            //
-            // We use the synthetic reports during a release event because the
-            // physical plunger motion when released is too fast for VP to track.
-            // VP only syncs its internal physics model with the outside world 
-            // about every 10ms.  In that amount of time, the plunger moves
-            // fast enough when released that it can shoot all the way forward,
-            // bounce off of the barrel spring, and rebound part of the way
-            // back.  The result is the classic analog-to-digital problem of
-            // sample aliasing.  If we happen to time our sample during the
-            // release motion so that we catch the plunger at the peak of a
-            // bounce, the digital signal incorrectly looks like the plunger
-            // is moving slowly forward - VP thinks we went from fully
-            // retracted to half retracted in the sample interval, whereas
-            // we actually traveled all the way forward and half way back,
-            // so the speed VP infers is about 1/3 of the actual speed.
-            //
-            // To correct this, we take advantage of our ability to sample 
-            // the CCD image several times in the course of a VP report.  If
-            // we catch the plunger near the origin after we've seen it
-            // retracted, we go into Release Event mode.  During this mode,
-            // we stop reporting the true physical plunger position, and
-            // instead report an idealized pattern: we report the plunger
-            // immediately shooting forward to a position in front of the
-            // park position that's in proportion to how far back the plunger
-            // was just before the release, and we then report it stationary
-            // at the park position.  We continue to report the stationary
-            // park position until the actual physical plunger motion has
-            // stabilized on a new position.  We then exit Release Event
-            // mode and return to reporting the true physical position.
-            if (firing)
-            {
-                // Firing in progress.  Keep reporting the park position
-                // until the physical plunger position comes to rest.
-                const int restTol = JOYMAX/24;
-                if (firing == 1)
-                {
-                    // For the first couple of frames, show the plunger shooting
-                    // forward past the zero point, to simulate the momentum carrying
-                    // it forward to bounce off of the barrel spring.  Show the 
-                    // bounce as proportional to the distance it was retracted
-                    // in the prior report.
-                    z = zBounce = -z0/6;
-                    ++firing;
-                }
-                else if (firing == 2)
-                {
-                    // second frame - keep the bounce a little longer
-                    z = zBounce;
-                    ++firing;
-                }
-                else if (firing > 4
-                    && abs(znew - z0) < restTol
-                    && abs(znew - z1) < restTol 
-                    && abs(znew - z2) < restTol)
-                {
-                    // The physical plunger has come to rest.  Exit firing
-                    // mode and resume reporting the actual position.
-                    firing = false;
-                    z = znew;
-                }
-                else
-                {
-                    // until the physical plunger comes to rest, simply 
-                    // report the park position
-                    z = 0;
-                    ++firing;
-                }
-            }
-            else
-            {
-                // not in firing mode - report the true physical position
-                z = znew;
-            }
-
-            // shift the new reading into the recent history buffer
-            z2 = z1;
-            z1 = z0;
-            z0 = znew;
-        }
-
+        // read the plunger sensor
+        plungerReader.read();
+        
         // process button updates
         processButtons();
         
+        // handle the ZB Launch Ball feature
+        zbLaunchBall.update(simButtons);
+
         // send a keyboard report if we have new data
         if (kbState.changed)
         {
@@ -3360,7 +3759,7 @@
         // VP only wants to sync with the real world in 10ms intervals,
         // so reporting more frequently creates I/O overhead without 
         // doing anything to improve the simulation.
-        if (cfg.joystickEnabled && jsReportTimer.read_ms() > 10)
+        if (cfg.joystickEnabled /* $$$ && jsReportTimer.read_us() > 10000 */)
         {
             // read the accelerometer
             int xa, ya;
@@ -3376,16 +3775,28 @@
             x = xa;
             y = ya;
             
-            // Report the current plunger position UNLESS the ZB Launch Ball 
-            // signal is on, in which case just report a constant 0 value.  
-            // ZB Launch Ball turns off the plunger position because it
+            // Report the current plunger position unless the plunger is
+            // disabled, or the ZB Launch Ball signal is on.  In either of
+            // those cases, just report a constant 0 value.  ZB Launch Ball 
+            // temporarily disables mechanical plunger reporting because it 
             // tells us that the table has a Launch Ball button instead of
-            // a traditional plunger.
-            int zrep = (cfg.plunger.zbLaunchBall.port != 0 && wizOn[cfg.plunger.zbLaunchBall.port-1] ? 0 : z);
+            // a traditional plunger, so we don't want to confuse VP with
+            // regular plunger inputs.
+            int z = plungerReader.getPosition();
+            int zrep = (!cfg.plunger.enabled ? 0 :
+                        cfg.plunger.zbLaunchBall.port != 0 
+                          && wizOn[cfg.plunger.zbLaunchBall.port-1] ? 0 :
+                        z);
             
             // rotate X and Y according to the device orientation in the cabinet
             accelRotate(x, y);
 
+#if 0
+            // $$$ report velocity in x axis and timestamp in y axis
+            x = int(plungerReader.getVelocity() * 1.0 * JOYMAX);
+            y = (plungerReader.getTimestamp() / 1000) % JOYMAX;
+#endif
+
             // send the joystick report
             jsOK = js.update(x, y, zrep, jsButtons | simButtons, statusFlags);
             
@@ -3397,7 +3808,7 @@
         if (reportPix)
         {
             // send the report            
-            plungerSensor->sendExposureReport(js, reportPixMode);
+            plungerSensor->sendExposureReport(js, reportPixFlags, reportPixVisMode);
 
             // we have satisfied this request
             reportPix = false;
@@ -3405,7 +3816,7 @@
         
         // If joystick reports are turned off, send a generic status report
         // periodically for the sake of the Windows config tool.
-        if (!cfg.joystickEnabled && jsReportTimer.read_ms() > 200)
+        if (!cfg.joystickEnabled && jsReportTimer.read_us() > 200000)
         {
             jsOK = js.updateStatus(0);
             jsReportTimer.reset();
@@ -3490,23 +3901,39 @@
                 }
             }
         }
+        
+        // if we're disconnected, initiate a new connection
+        if (!connected && !js.isConnected())
+        {
+            // show connect-wait diagnostics
+            diagLED(0, 0, 0);
+            preConnectTicker.attach(preConnectFlasher, 3);
+            
+            // wait for the connection
+            js.connect(true);
+            
+            // remove the connection diagnostic ticker
+            preConnectTicker.detach();
+        }
 
     // $$$
+#if 0
         if (dbgTimer.read() > 10) {
             dbgTimer.reset();
             if (plungerSensor != 0 && (cfg.plunger.sensorType == PlungerType_TSL1410RS || cfg.plunger.sensorType == PlungerType_TSL1410RP))
             {
                 PlungerSensorTSL1410R *ps = (PlungerSensorTSL1410R *)plungerSensor;
                 uint32_t nRuns;
-                float totalTime;
+                uint64_t totalTime;
                 ps->ccd.getTimingStats(totalTime, nRuns);
-                printf("average plunger read time: %f ms (total=%f, n=%d)\r\n", totalTime*1000.0/nRuns, totalTime, nRuns);
+                printf("average plunger read time: %f ms (total=%f, n=%d)\r\n", totalTime / 1000.0f / nRuns, totalTime, nRuns);
             }
         }
+#endif
     // end $$$
         
         // provide a visual status indication on the on-board LED
-        if (calBtnState < 2 && hbTimer.read_ms() > 1000) 
+        if (calBtnState < 2 && hbTimer.read_us() > 1000000) 
         {
             if (!newConnected)
             {