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 real plunger, button inputs, and feedback device control.

In case you haven't heard of the concept 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 serve as the "backglass" display. A third smaller monitor can serve as the "DMD" (the Dot Matrix Display used for scoring on newer machines), or you can even install a real pinball plasma DMD. A computer 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 hardware.

A few small companies build and sell complete, finished virtual pinball machines, but I think it's more fun as a 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 potentionmeter (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 VirtuaPin kit uses the same KL25Z microcontroller that Pinscape uses, but the rest of its hardware is different and incompatible. In particular, the Pinscape firmware doesn't include support for the IR proximity sensor used in the VirtuaPin plunger kit, so you won't be able to use your plunger device with the Pinscape firmware. In addition, the VirtuaPin setup uses a different set of GPIO pins for the button inputs from the Pinscape defaults, so if you do install the Pinscape firmware, you'll have to go into the Config Tool and reassign all of the buttons to match the VirtuaPin wiring.

Revision:
53:9b2611964afc
Parent:
52:8298b2a73eb2
Child:
54:fd77a6b2f76c
--- a/main.cpp	Sat Mar 05 00:16:52 2016 +0000
+++ b/main.cpp	Fri Apr 22 17:58:35 2016 +0000
@@ -82,57 +82,53 @@
 //    to the PC as a joystick button or as a keyboard key (you can select which key
 //    is used for each button).
 //
-//  - LedWiz emulation.  The KL25Z can appear to the PC as an LedWiz device, and will
-//    accept and process LedWiz commands from the host.  The software can turn digital
-//    output ports on and off, and can set varying PWM intensitiy levels on a subset
-//    of ports.  The KL25Z hardware is limited to 10 PWM ports.  Ports beyond the
-//    10 PWM ports are simple digital on/off ports.  Intensity level settings on 
-//    digital ports is ignored, so such ports can only be used for devices such as 
-//    contactors and solenoids that don't need differeing intensities.
+//  - LedWiz emulation.  The KL25Z can pretend to be an LedWiz device.  This lets
+//    you connect feedback devices (lights, solenoids, motors) to GPIO ports on the 
+//    KL25Z, and lets PC software (such as Visual Pinball) control them during game 
+//    play to create a more immersive playing experience.  The Pinscape software
+//    presents itself to the host as an LedWiz device and accepts the full LedWiz
+//    command set, so software on the PC designed for real LedWiz'es can control
+//    attached devices without any modifications.
 //
-//    Note that the KL25Z can only supply or sink 4mA on its output ports, so external 
-//    amplifier hardware is required to use the LedWiz emulation.  Many different 
-//    hardware designs are possible, but there's a simple reference design in the 
-//    documentation that uses a Darlington array IC to increase the output from 
-//    each port to 500mA (the same level as the LedWiz), plus an extended design 
-//    that adds an optocoupler and MOSFET to provide very high power handling, up 
-//    to about 45A or 150W, with voltages up to 100V.  That will handle just about 
-//    any DC device directly (wtihout relays or other amplifiers), and switches fast 
-//    enough to support PWM devices.  For example, you can use it to drive a motor at
-//    different speeds via the PWM intensity.
-//
-//    The Controller device can report any desired LedWiz unit number to the host, 
-//    which makes it possible for one or more Pinscape Controller units to coexist
-//    with one more more real LedWiz units in the same machine.  The LedWiz design 
-//    allows for up to 16 units to be installed in one machine.  Each device needs
-//    to have a distinct LedWiz Unit Number, which allows software on the PC to
-//    address each device independently.
-//
-//    The LedWiz emulation features are of course optional.  There's no need to 
-//    build any of the external port hardware (or attach anything to the output 
-//    ports at all) if the LedWiz features aren't needed.
+//    Even though the software provides a very thorough LedWiz emulation, the KL25Z
+//    GPIO hardware design imposes some serious limitations.  The big one is that
+//    the KL25Z only has 10 PWM channels, meaning that only 10 ports can have
+//    varying-intensity outputs (e.g., for controlling the brightness level of an
+//    LED or the speed or a motor).  You can control more than 10 output ports, but
+//    only 10 can have PWM control; the rest are simple "digital" ports that can only
+//    be switched fully on or fully off.  The second limitation is that the KL25Z
+//    just doesn't have that many GPIO ports overall.  There are enough to populate
+//    all 32 button inputs OR all 32 LedWiz outputs, but not both.  The default is
+//    to assign 24 buttons and 22 LedWiz ports; you can change this balance to trade
+//    off more outputs for fewer inputs, or vice versa.  The third limitation is that
+//    the KL25Z GPIO pins have *very* tiny amperage limits - just 4mA, which isn't
+//    even enough to control a small LED.  So in order to connect any kind of feedback
+//    device to an output, you *must* build some external circuitry to boost the
+//    current handing.  The Build Guide has a reference circuit design for this
+//    purpose that's simple and inexpensive to build.
 //
 //  - Enhanced LedWiz emulation with TLC5940 PWM controller chips.  You can attach
 //    external PWM controller chips for controlling device outputs, instead of using
-//    the limited LedWiz emulation through the on-board GPIO ports as described above. 
-//    The software can control a set of daisy-chained TLC5940 chips, which provide
-//    16 PWM outputs per chip.  Two of these chips give you the full complement
-//    of 32 output ports of an actual LedWiz, and four give you 64 ports, which
-//    should be plenty for nearly any virtual pinball project.  A private, extended
-//    version of the LedWiz protocol lets the host control the extra outputs, up to
-//    128 outputs per KL25Z (8 TLC5940s).  To take advantage of the extra outputs
-//    on the PC side, you need software that knows about the protocol extensions,
-//    which means you need the latest version of DirectOutput Framework (DOF).  VP
-//    uses DOF for its output, so VP will be able to use the added ports without any
-//    extra work on your part.  Older software (e.g., Future Pinball) that doesn't
-//    use DOF will still be able to use the LedWiz-compatible protocol, so it'll be
-//    able to control your first 32 ports (numbered 1-32 in the LedWiz scheme), but
-//    older software won't be able to address higher-numbered ports.  That shouldn't
-//    be a problem because older software wouldn't know what to do with the extra
-//    devices anyway - FP, for example, is limited to a pre-defined set of outputs.
-//    As long as you put the most common devices on the first 32 outputs, and use
-//    higher numbered ports for the less common devices that older software can't
-//    use anyway, you'll get maximum functionality out of software new and old.
+//    the on-board GPIO ports as described above.  The software can control a set of 
+//    daisy-chained TLC5940 chips.  Each chip provides 16 PWM outputs, so you just
+//    need two of them to get the full complement of 32 output ports of a real LedWiz.
+//    You can hook up even more, though.  Four chips gives you 64 ports, which should
+//    be plenty for nearly any virtual pinball project.  To accommodate the larger
+//    supply of ports possible with the PWM chips, the controller software provides
+//    a custom, extended version of the LedWiz protocol that can handle up to 128
+//    ports.  PC software designed only for the real LedWiz obviously won't know
+//    about the extended protocol and won't be able to take advantage of its extra
+//    capabilities, but the latest version of DOF (DirectOutput Framework) *does* 
+//    know the new language and can take full advantage.  Older software will still
+//    work, though - the new extensions are all backward compatible, so old software
+//    that only knows about the original LedWiz protocol will still work, with the
+//    obvious limitation that it can only access the first 32 ports.
+//
+//    The Pinscape Expansion Board project (which appeared in early 2016) provides
+//    a reference hardware design, with EAGLE circuit board layouts, that takes full
+//    advantage of the TLC5940 capability.  It lets you create a customized set of
+//    outputs with full PWM control and power handling for high-current devices
+//    built in to the boards.  
 //
 //  - Night Mode control for output devices.  You can connect a switch or button
 //    to the controller to activate "Night Mode", which disables feedback devices
@@ -213,6 +209,71 @@
 #define DECL_EXTERNS
 #include "config.h"
 
+
+// --------------------------------------------------------------------------
+// 
+// OpenSDA module identifier.  This is for the benefit of the Windows
+// configuration tool.  When the config tool installs a .bin file onto
+// the KL25Z, it will first find the sentinel string within the .bin file,
+// and patch the "\0" bytes that follow the sentinel string with the 
+// OpenSDA module ID data.  This allows us to report the OpenSDA 
+// identifiers back to the host system via USB, which in turn allows the 
+// config tool to figure out which OpenSDA MSD (mass storage device - a 
+// virtual disk drive) correlates to which Pinscape controller USB 
+// interface.  
+// 
+// This is only important if multiple Pinscape devices are attached to 
+// the same host.  There doesn't seem to be any other way to figure out 
+// which OpenSDA MSD corresponds to which KL25Z USB interface; the OpenSDA 
+// MSD doesn't report the KL25Z CPU ID anywhere, and the KL25Z doesn't
+// have any way to learn about the OpenSDA module it's connected to.  The
+// only way to pass this information to the KL25Z side that I can come up 
+// with is to have the Windows host embed it in the .bin file before 
+// downloading it to the OpenSDA MSD.
+//
+// We initialize the const data buffer (the part after the sentinel string)
+// with all "\0" bytes, so that's what will be in the executable image that
+// comes out of the mbed compiler.  If you manually install the resulting
+// .bin file onto the KL25Z (via the Windows desktop, say), the "\0" bytes
+// will stay this way and read as all 0's at run-time.  Since a real TUID
+// would never be all 0's, that tells us that we were never patched and
+// thus don't have any information on the OpenSDA module.
+//
+const char *getOpenSDAID()
+{
+    #define OPENSDA_PREFIX "///Pinscape.OpenSDA.TUID///"
+    static const char OpenSDA[] = OPENSDA_PREFIX "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0///";
+    const size_t OpenSDA_prefix_length = sizeof(OPENSDA_PREFIX) - 1;
+    
+    return OpenSDA + OpenSDA_prefix_length;
+}
+
+// --------------------------------------------------------------------------
+//
+// Build ID.  We use the date and time of compiling the program as a build
+// identifier.  It would be a little nicer to use a simple serial number
+// instead, but the mbed platform doesn't have a way to automate that.  The
+// timestamp is a pretty good proxy for a serial number in that it will
+// naturally increase on each new build, which is the primary property we
+// want from this.
+//
+// As with the embedded OpenSDA ID, we store the build timestamp with a
+// sentinel string prefix, to allow automated tools to find the static data
+// in the .bin file by searching for the sentinel string.  In contrast to 
+// the OpenSDA ID, the value we store here is for tools to extract rather 
+// than store, since we automatically populate it via the preprocessor 
+// macros.
+//
+const char *getBuildID()
+{
+    #define BUILDID_PREFIX "///Pinscape.Build.ID///"
+    static const char BuildID[] = BUILDID_PREFIX __DATE__ " " __TIME__ "///";
+    const size_t BuildID_prefix_length = sizeof(BUILDID_PREFIX) - 1;
+    
+    return BuildID + BuildID_prefix_length;
+}
+
+
 // --------------------------------------------------------------------------
 //
 // Custom memory allocator.  We use our own version of malloc() to provide
@@ -276,6 +337,14 @@
     bool running;
 };
 
+
+// --------------------------------------------------------------------------
+//
+// Reboot timer.  When we have a deferred reboot operation pending, we
+// set the target time and start the timer.
+Timer2 rebootTimer;
+long rebootTime_us;
+
 // --------------------------------------------------------------------------
 // 
 // USB product version number
@@ -332,29 +401,36 @@
     return (int32_t)wireUI32(b);
 }
 
-static const PinName pinNameMap[] =  {
-    NC,    PTA1,  PTA2,  PTA4,  PTA5,  PTA12, PTA13, PTA16, PTA17, PTB0,    // 0-9
-    PTB1,  PTB2,  PTB3,  PTB8,  PTB9,  PTB10, PTB11, PTB18, PTB19, PTC0,    // 10-19
-    PTC1,  PTC2,  PTC3,  PTC4,  PTC5,  PTC6,  PTC7,  PTC8,  PTC9,  PTC10,   // 20-29
-    PTC11, PTC12, PTC13, PTC16, PTC17, PTD0,  PTD1,  PTD2,  PTD3,  PTD4,    // 30-39
-    PTD5,  PTD6,  PTD7,  PTE0,  PTE1,  PTE2,  PTE3,  PTE4,  PTE5,  PTE20,   // 40-49
-    PTE21, PTE22, PTE23, PTE29, PTE30, PTE31                                // 50-55
-};
-inline PinName wirePinName(int c)
+// Convert "wire" (USB) pin codes to/from PinName values.
+// 
+// The internal mbed PinName format is 
+//
+//   ((port) << PORT_SHIFT) | (pin << 2)    // MBED FORMAT
+//
+// where 'port' is 0-4 for Port A to Port E, and 'pin' is
+// 0 to 31.  E.g., E31 is (4 << PORT_SHIFT) | (31<<2).
+//
+// We remap this to our more compact wire format where each
+// pin name fits in 8 bits:
+//
+//   ((port) << 5) | pin)                   // WIRE FORMAT
+//
+// E.g., E31 is (4 << 5) | 31.
+//
+// Wire code FF corresponds to PinName NC (not connected).
+//
+inline PinName wirePinName(uint8_t c)
 {
-    return (c < countof(pinNameMap) ? pinNameMap[c] : NC);
+    if (c == 0xFF)
+        return NC;                                  // 0xFF -> NC
+    else 
+        return PinName(
+            (int(c & 0xE0) << (PORT_SHIFT - 5))      // top three bits are the port
+            | (int(c & 0x1F) << 2));                // bottom five bits are pin
 }
 inline void pinNameWire(uint8_t *b, PinName n)
 {
-    b[0] = 0; // presume invalid -> NC
-    for (int i = 0 ; i < countof(pinNameMap) ; ++i)
-    {
-        if (pinNameMap[i] == n)
-        {
-            b[0] = i;
-            return;
-        }
-    }
+    *b = PINNAME_TO_WIRE(n);
 }
 
 
@@ -420,10 +496,6 @@
     for (int i = 0 ; i < MAX_OUT_PORTS && cfg.outPort[i].typ != PortTypeDisabled ; ++i)
         l.check(cfg.outPort[i]);
     
-    // check the special ports
-    for (int i = 0 ; i < countof(cfg.specialPort) ; ++i)
-        l.check(cfg.specialPort[i]);
-    
     // We now know which segments are taken for LedWiz use and which
     // are free.  Create diagnostic ports for the ones not claimed for
     // LedWiz use.
@@ -495,9 +567,34 @@
     virtual void set(uint8_t val) { out->set(255 - val); }
     
 private:
+    // underlying physical output
     LwOut *out;
 };
 
+// Global ZB Launch Ball state
+bool zbLaunchOn = false;
+
+// ZB Launch Ball output.  This is layered on a port (physical or virtual)
+// to track the ZB Launch Ball signal.
+class LwZbLaunchOut: public LwOut
+{
+public:
+    LwZbLaunchOut(LwOut *o) : out(o) { }
+    virtual void set(uint8_t val)
+    {
+        // update the global ZB Launch Ball state
+        zbLaunchOn = (val != 0);
+        
+        // pass it along to the underlying port, in case it's a physical output
+        out->set(val);
+    }
+        
+private:
+    // underlying physical or virtual output
+    LwOut *out;
+};
+
+
 // Gamma correction table for 8-bit input values
 static const uint8_t gamma[] = {
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0, 
@@ -531,6 +628,9 @@
     LwOut *out;
 };
 
+// global night mode flag
+static bool nightMode = false;
+
 // Noisy output.  This is a filter object that we layer on top of
 // a physical pin output.  This filter disables the port when night
 // mode is engaged.
@@ -540,15 +640,28 @@
     LwNoisyOut(LwOut *o) : out(o) { }
     virtual void set(uint8_t val) { out->set(nightMode ? 0 : val); }
     
-    static bool nightMode;
+private:
+    LwOut *out;
+};
+
+// Night Mode indicator output.  This is a filter object that we
+// layer on top of a physical pin output.  This filter ignores the
+// host value and simply shows the night mode status.
+class LwNightModeIndicatorOut: public LwOut
+{
+public:
+    LwNightModeIndicatorOut(LwOut *o) : out(o) { }
+    virtual void set(uint8_t) 
+    {
+        // ignore the host value and simply show the current 
+        // night mode setting
+        out->set(nightMode ? 255 : 0);
+    }
 
 private:
     LwOut *out;
 };
 
-// global night mode flag
-bool LwNoisyOut::nightMode = false;
-
 
 //
 // The TLC5940 interface object.  We'll set this up with the port 
@@ -559,8 +672,13 @@
 {
     if (cfg.tlc5940.nchips != 0)
     {
-        tlc5940 = new TLC5940(cfg.tlc5940.sclk, cfg.tlc5940.sin, cfg.tlc5940.gsclk,
-            cfg.tlc5940.blank, cfg.tlc5940.xlat, cfg.tlc5940.nchips);
+        tlc5940 = new TLC5940(
+            wirePinName(cfg.tlc5940.sclk), 
+            wirePinName(cfg.tlc5940.sin),
+            wirePinName(cfg.tlc5940.gsclk),
+            wirePinName(cfg.tlc5940.blank), 
+            wirePinName(cfg.tlc5940.xlat), 
+            cfg.tlc5940.nchips);
     }
 }
 
@@ -656,7 +774,12 @@
 {
     if (cfg.hc595.nchips != 0)
     {
-        hc595 = new HC595(cfg.hc595.nchips, cfg.hc595.sin, cfg.hc595.sclk, cfg.hc595.latch, cfg.hc595.ena);
+        hc595 = new HC595(
+            wirePinName(cfg.hc595.nchips), 
+            wirePinName(cfg.hc595.sin), 
+            wirePinName(cfg.hc595.sclk), 
+            wirePinName(cfg.hc595.latch), 
+            wirePinName(cfg.hc595.ena));
         hc595->init();
         hc595->update();
     }
@@ -763,13 +886,6 @@
 static int numOutputs;
 static LwOut **lwPin;
 
-// Special output ports:
-//
-//    [0] = Night Mode indicator light
-//
-static LwOut *specialPin[1];
-const int SPECIAL_PIN_NIGHTMODE = 0;
-
 
 // Number of LedWiz emulation outputs.  This is the number of ports
 // accessible through the standard (non-extended) LedWiz protocol
@@ -785,7 +901,7 @@
 static uint8_t *outLevel;
 
 // create a single output pin
-LwOut *createLwPin(LedWizPortCfg &pc, Config &cfg)
+LwOut *createLwPin(int portno, LedWizPortCfg &pc, Config &cfg)
 {
     // get this item's values
     int typ = pc.typ;
@@ -883,6 +999,16 @@
     // If it's gamma-corrected, layer on a gamma corrector
     if (gamma)
         lwp = new LwGammaOut(lwp);
+        
+    // If this is the ZB Launch Ball port, layer a monitor object.  Note
+    // that the nominal port numbering in the cofnig starts at 1, but we're
+    // using an array index, so test against portno+1.
+    if (portno + 1 == cfg.plunger.zbLaunchBall.port)
+        lwp = new LwZbLaunchOut(lwp);
+        
+    // If this is the Night Mode indicator port, layer a night mode object.
+    if (portno + 1 == cfg.nightMode.port)
+        lwp = new LwNightModeIndicatorOut(lwp);
 
     // turn it off initially      
     lwp->set(0);
@@ -923,11 +1049,7 @@
     
     // create the pin interface object for each port
     for (i = 0 ; i < numOutputs ; ++i)
-        lwPin[i] = createLwPin(cfg.outPort[i], cfg);
-        
-    // create the pin interface for each special port
-    for (i = 0 ; i < countof(cfg.specialPort) ; ++i)
-        specialPin[i] = createLwPin(cfg.specialPort[i], cfg);
+        lwPin[i] = createLwPin(i, cfg.outPort[i], cfg);
 }
 
 // LedWiz output states.
@@ -1157,49 +1279,57 @@
     ButtonState()
     {
         di = NULL;
-        on = 0;
-        pressed = prev = 0;
-        dbstate = 0;
-        js = 0;
-        keymod = 0;
-        keycode = 0;
-        special = 0;
+        physState = logState = prevLogState = 0;
+        virtState = 0;
+        dbState = 0;
         pulseState = 0;
-        pulseTime = 0.0f;
+        pulseTime = 0;
     }
     
-    // DigitalIn for the button
+    // "Virtually" press or un-press the button.  This can be used to
+    // control the button state via a software (virtual) source, such as
+    // the ZB Launch Ball feature.
+    //
+    // To allow sharing of one button by multiple virtual sources, each
+    // virtual source must keep track of its own state internally, and 
+    // only call this routine to CHANGE the state.  This is because calls
+    // to this routine are additive: turning the button ON twice will
+    // require turning it OFF twice before it actually turns off.
+    void virtPress(bool on)
+    {
+        // Increment or decrement the current state
+        virtState += on ? 1 : -1;
+    }
+    
+    // DigitalIn for the button, if connected to a physical input
     TinyDigitalIn *di;
     
     // current PHYSICAL on/off state, after debouncing
-    uint8_t on : 1;
+    uint8_t physState : 1;
     
     // current LOGICAL on/off state as reported to the host.
-    uint8_t pressed : 1;
+    uint8_t logState : 1;
 
     // previous logical on/off state, when keys were last processed for USB 
     // reports and local effects
-    uint8_t prev : 1;
+    uint8_t prevLogState : 1;
+    
+    // Virtual press state.  This is used to simulate pressing the button via
+    // software inputs rather than physical inputs.  To allow one button to be
+    // controlled by mulitple software sources, each source should keep track
+    // of its own virtual state for the button independently, and then INCREMENT
+    // this variable when the source's state transitions from off to on, and
+    // DECREMENT it when the source's state transitions from on to off.  That
+    // will make the button's pressed state the logical OR of all of the virtual
+    // and physical source states.
+    uint8_t virtState;
     
     // 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
     // key is reporting OFF.  We consider the key to have a new stable state
     // if we have N consecutive 0's or 1's in the low N bits (where N is
     // a parameter that determines how long we wait for transients to settle).
-    uint8_t dbstate;
-    
-    // joystick button mask for the button, if mapped as a joystick button
-    uint32_t js;
-    
-    // keyboard modifier bits and scan code for the button, if mapped as a keyboard key
-    uint8_t keymod;
-    uint8_t keycode;
-    
-    // media control key code
-    uint8_t mediakey;
-    
-    // special key code
-    uint8_t special;
+    uint8_t dbState;
     
     // Pulse mode: a button in pulse mode transmits a brief logical button press and
     // release each time the attached physical switch changes state.  This is useful
@@ -1220,17 +1350,17 @@
     //
     // Each state change sticks for a minimum period; when the timer expires,
     // if the underlying physical switch is in a different state, we switch
-    // to the next state and restart the timer.  pulseTime is the amount of
-    // time remaining before we can make another state transition.  The state
-    // transitions require a complete cycle, 1 -> 2 -> 3 -> 4 -> 1...; this
-    // guarantees that the parity of the pulse count always matches the 
+    // to the next state and restart the timer.  pulseTime is the time remaining
+    // remaining before we can make another state transition, in microseconds.
+    // The state transitions require a complete cycle, 1 -> 2 -> 3 -> 4 -> 1...; 
+    // this guarantees that the parity of the pulse count always matches the 
     // current physical switch state when the latter is stable, which makes
     // it impossible to "trick" the host by rapidly toggling the switch state.
     // (On my original Pinscape cabinet, I had a hardware pulse generator
     // for coin door, and that *was* possible to trick by rapid toggling.
     // This software system can't be fooled that way.)
     uint8_t pulseState;
-    float pulseTime;
+    uint32_t pulseTime;
     
 } __attribute__((packed)) buttonState[MAX_BUTTONS];
 
@@ -1267,7 +1397,8 @@
     ButtonState *bs = buttonState;
     for (int i = 0 ; i < MAX_BUTTONS ; ++i, ++bs)
     {
-        // if it's connected, check its physical state
+        // if this logical button is connected to a physical input, check 
+        // the GPIO pin state
         if (bs->di != NULL)
         {
             // Shift the new state into the debounce history.  Note that
@@ -1275,10 +1406,10 @@
             // the reading by XOR'ing the low bit with 1.  And of course we
             // only want the low bit (since the history is effectively a bit
             // vector), so mask the whole thing with 0x01 as well.
-            uint8_t db = bs->dbstate;
+            uint8_t db = bs->dbState;
             db <<= 1;
             db |= (bs->di->read() & 0x01) ^ 0x01;
-            bs->dbstate = db;
+            bs->dbState = db;
             
             // if we have all 0's or 1's in the history for the required
             // debounce period, the key state is stable - check for a change
@@ -1286,7 +1417,7 @@
             const uint8_t stable = 0x1F;   // 00011111b -> 5 stable readings
             db &= stable;
             if (db == 0 || db == stable)
-                bs->on = db;
+                bs->physState = db & 1;
         }
     }
 }
@@ -1302,6 +1433,17 @@
     // presume we'll find no keyboard keys
     kbKeys = false;
     
+    // Configure the virtual buttons.  These are buttons controlled via
+    // software triggers rather than physical GPIO inputs.  The virtual
+    // buttons have the same control structures as regular buttons, but
+    // they get their configuration data from other config variables.
+    
+    // ZB Launch Ball button
+    cfg.button[ZBL_BUTTON].set(
+        PINNAME_TO_WIRE(NC),
+        cfg.plunger.zbLaunchBall.keytype,
+        cfg.plunger.zbLaunchBall.keycode);
+    
     // create the digital inputs
     ButtonState *bs = buttonState;
     for (int i = 0 ; i < MAX_BUTTONS ; ++i, ++bs)
@@ -1316,41 +1458,29 @@
             if (cfg.button[i].flags & BtnFlagPulse)
                 bs->pulseState = 1;
             
-            // note if it's a keyboard key of some kind (including media keys)
-            uint8_t val = cfg.button[i].val;
+            // Note if it's a keyboard key of some kind.  If we find any keyboard
+            // mappings, we'll declare a keyboard interface when we send our HID 
+            // configuration to the host during USB connection setup.
             switch (cfg.button[i].typ)
             {
-            case BtnTypeJoystick:
-                // joystick button - get the button bit mask
-                bs->js = 1 << val;
-                break;
-                
             case BtnTypeKey:
-                // regular keyboard key - note the scan code
-                bs->keycode = val;
+                // note that we have at least one keyboard key
                 kbKeys = true;
                 break;
                 
-            case BtnTypeModKey:
-                // keyboard mod key - note the modifier mask
-                bs->keymod = val;
-                kbKeys = true;
-                break;
-                
-            case BtnTypeMedia:
-                // media key - note the code
-                bs->mediakey = val;
-                kbKeys = true;
-                break;
-                
-            case BtnTypeSpecial:
-                // special key
-                bs->special = val;
+            default:
+                // not a keyboard key
                 break;
             }
         }
     }
     
+    // If the ZB Launch Ball feature is enabled, and it uses a keyboard
+    // key, this requires setting up a USB keyboard interface.
+    if (cfg.plunger.zbLaunchBall.port != 0 
+        && cfg.plunger.zbLaunchBall.keytype == BtnTypeKey)
+        kbKeys = true;
+    
     // start the button scan thread
     buttonTicker.attach_us(scanButtons, 1000);
 
@@ -1362,7 +1492,7 @@
 // media control descriptors with the current state of keys mapped to
 // those HID interfaces, and executes the local effects for any keys 
 // mapped to special device functions (e.g., Night Mode).
-void processButtons()
+void processButtons(Config &cfg)
 {
     // start with an empty list of USB key codes
     uint8_t modkeys = 0;
@@ -1376,33 +1506,36 @@
     uint8_t mediakeys = 0;
     
     // calculate the time since the last run
-    float dt = buttonTimer.read();
+    uint32_t dt = buttonTimer.read_us();
     buttonTimer.reset();
 
     // scan the button list
     ButtonState *bs = buttonState;
-    for (int i = 0 ; i < MAX_BUTTONS ; ++i, ++bs)
+    ButtonCfg *bc = cfg.button;
+    for (int i = 0 ; i < MAX_BUTTONS ; ++i, ++bs, ++bc)
     {
         // if it's a pulse-mode switch, get the virtual pressed state
         if (bs->pulseState != 0)
         {
-            // deduct the time to the next state change
-            bs->pulseTime -= dt;
-            if (bs->pulseTime < 0)
-                bs->pulseTime = 0;
-                
             // if the timer has expired, check for state changes
-            if (bs->pulseTime == 0)
+            if (bs->pulseTime > dt)
             {
-                const float pulseLength = 0.2;
+                // not expired yet - deduct the last interval
+                bs->pulseTime -= dt;
+            }
+            else
+            {
+                // pulse time expired - check for a state change
+                const uint32_t pulseLength = 200000UL;  // 200 milliseconds
                 switch (bs->pulseState)
                 {
                 case 1:
                     // off - if the physical switch is now on, start a button pulse
-                    if (bs->on) {
+                    if (bs->physState) 
+                    {
                         bs->pulseTime = pulseLength;
                         bs->pulseState = 2;
-                        bs->pressed = 1;
+                        bs->logState = 1;
                     }
                     break;
                     
@@ -1412,15 +1545,16 @@
                     // change in state in the logical button
                     bs->pulseState = 3;
                     bs->pulseTime = pulseLength;
-                    bs->pressed = 0;
+                    bs->logState = 0;
                     break;
                     
                 case 3:
                     // on - if the physical switch is now off, start a button pulse
-                    if (!bs->on) {
+                    if (!bs->physState) 
+                    {
                         bs->pulseTime = pulseLength;
                         bs->pulseState = 4;
-                        bs->pressed = 1;
+                        bs->logState = 1;
                     }
                     break;
                     
@@ -1428,7 +1562,7 @@
                     // transitioning on to off - end the pulse, and start a gap
                     bs->pulseState = 1;
                     bs->pulseTime = pulseLength;
-                    bs->pressed = 0;
+                    bs->logState = 0;
                     break;
                 }
             }
@@ -1436,44 +1570,102 @@
         else
         {
             // not a pulse switch - the logical state is the same as the physical state
-            bs->pressed = bs->on;
+            bs->logState = bs->physState;
         }
 
         // carry out any edge effects from buttons changing states
-        if (bs->pressed != bs->prev)
+        if (bs->logState != bs->prevLogState)
         {
             // check for special key transitions
-            switch (bs->special)
+            if (cfg.nightMode.btn == i + 1)
             {
-            case 1:
-                // night mode momentary switch - when the button transitions from
-                // OFF to ON, invert night mode
-                if (bs->pressed)
-                    toggleNightMode();
-                break;
-                
-            case 2:
-                // night mode toggle switch - when the button changes state, change
-                // night mode to match the new state
-                setNightMode(bs->pressed);
-                break;
+                // Check the switch type in the config flags.  If flag 0x01 is set,
+                // it's a persistent on/off switch, so the night mode state simply
+                // follows the current state of the switch.  Otherwise, it's a 
+                // momentary button, so each button push (i.e., each transition from
+                // logical state OFF to ON) toggles the current night mode state.
+                if (cfg.nightMode.flags & 0x01)
+                {
+                    // toggle switch - when the button changes state, change
+                    // night mode to match the new state
+                    setNightMode(bs->logState);
+                }
+                else
+                {
+                    // momentary switch - toggle the night mode state when the
+                    // physical button is pushed (i.e., when its logical state
+                    // transitions from OFF to ON)
+                    if (bs->logState)
+                        toggleNightMode();
+                }
             }
             
             // remember the new state for comparison on the next run
-            bs->prev = bs->pressed;
+            bs->prevLogState = bs->logState;
         }
 
-        // if it's pressed, add it to the appropriate key state list
-        if (bs->pressed)
+        // if it's pressed, physically or virtually, add it to the appropriate 
+        // key state list
+        if (bs->logState || bs->virtState)
         {
             // OR in the joystick button bit, mod key bits, and media key bits
-            newjs |= bs->js;
-            modkeys |= bs->keymod;
-            mediakeys |= bs->mediakey;
-            
-            // if it has a keyboard key, add the scan code to the active list
-            if (bs->keycode != 0 && nkeys < 7)
-                keys[nkeys++] = bs->keycode;
+            uint8_t val = bc->val;
+            switch (bc->typ)
+            {
+            case BtnTypeJoystick:
+                // joystick button
+                newjs |= (1 << (val - 1));
+                break;
+                
+            case BtnTypeKey:
+                // Keyboard key.  This could be a modifier key (shift, control,
+                // alt, GUI), a media key (mute, volume up, volume down), or a
+                // regular key.  Check which one.
+                if (val >= 0x7F && val <= 0x81)
+                {
+                    // It's a media key.  OR the key into the media key mask.
+                    // The media mask bits are mapped in the HID report descriptor
+                    // in USBJoystick.cpp.  For simplicity, we arrange the mask so
+                    // that the ones with regular keyboard equivalents that we catch
+                    // here are in the same order as the key scan codes:
+                    //
+                    //   Mute     = scan 0x7F = mask bit 0x01
+                    //   Vol Up   = scan 0x80 = mask bit 0x02
+                    //   Vol Down = scan 0x81 = mask bit 0x04
+                    //
+                    // So we can translate from scan code to bit mask with some
+                    // simple bit shifting:
+                    mediakeys |= (1 << (val - 0x7f));
+                }
+                else if (val >= 0xE0 && val <= 0xE7)
+                {
+                    // It's a modifier key.  Like the media keys, these are represented
+                    // in the USB reports with a bit mask, and like the media keys, we
+                    // arrange the mask bits in the same order as the scan codes.  This
+                    // makes figuring the mask a simple bit shift:
+                    modkeys |= (1 << (val - 0xE0));
+                }
+                else
+                {
+                    // It's a regular key.  Make sure it's not already in the list, and
+                    // that the list isn't full.  If neither of these apply, add the key.
+                    if (nkeys < 7)
+                    {
+                        bool found;
+                        for (int j = 0 ; j < nkeys ; ++j)
+                        {
+                            if (keys[j] == val)
+                            {
+                                found = true;
+                                break;
+                            }
+                        }
+                        if (!found)
+                            keys[nkeys++] = val;
+                    }
+                }
+                break;
+            }
         }
     }
 
@@ -2139,11 +2331,13 @@
 void startTVTimer(Config &cfg)
 {
     // only start the timer if the status sense circuit pins are configured
-    if (cfg.TVON.statusPin != NC && cfg.TVON.latchPin != NC && cfg.TVON.relayPin != NC)
+    if (cfg.TVON.statusPin != 0xFF 
+        && cfg.TVON.latchPin != 0xFF 
+        && cfg.TVON.relayPin != 0xFF)
     {
-        psu2_status_sense = new DigitalIn(cfg.TVON.statusPin);
-        psu2_status_set = new DigitalOut(cfg.TVON.latchPin);
-        tv_relay = new DigitalOut(cfg.TVON.relayPin);
+        psu2_status_sense = new DigitalIn(wirePinName(cfg.TVON.statusPin));
+        psu2_status_set = new DigitalOut(wirePinName(cfg.TVON.latchPin));
+        tv_relay = new DigitalOut(wirePinName(cfg.TVON.relayPin));
         tv_delay_time = cfg.TVON.delayTime/100.0;
     
         // Set up our time routine to run every 1/4 second.  
@@ -2245,10 +2439,12 @@
 static void setNightMode(bool on)
 {
     // set the new night mode flag in the noisy output class
-    LwNoisyOut::nightMode = on;
+    nightMode = on;
 
     // update the special output pin that shows the night mode state
-    specialPin[SPECIAL_PIN_NIGHTMODE]->set(on ? 255 : 0);
+    int port = int(cfg.nightMode.port) - 1;
+    if (port >= 0 && port < numOutputs)
+        lwPin[port]->set(nightMode ? 255 : 0);
 
     // update all outputs for the mode change
     updateAllOuts();
@@ -2257,7 +2453,7 @@
 // Toggle night mode
 static void toggleNightMode()
 {
-    setNightMode(!LwNoisyOut::nightMode);
+    setNightMode(!nightMode);
 }
 
 
@@ -2278,27 +2474,44 @@
     {
     case PlungerType_TSL1410RS:
         // pins are: SI, CLOCK, AO
-        plungerSensor = new PlungerSensorTSL1410R(cfg.plunger.sensorPin[0], cfg.plunger.sensorPin[1], cfg.plunger.sensorPin[2], NC);
+        plungerSensor = new PlungerSensorTSL1410R(
+            wirePinName(cfg.plunger.sensorPin[0]), 
+            wirePinName(cfg.plunger.sensorPin[1]),
+            wirePinName(cfg.plunger.sensorPin[2]),
+            NC);
         break;
         
     case PlungerType_TSL1410RP:
         // pins are: SI, CLOCK, AO1, AO2
-        plungerSensor = new PlungerSensorTSL1410R(cfg.plunger.sensorPin[0], cfg.plunger.sensorPin[1], cfg.plunger.sensorPin[2], cfg.plunger.sensorPin[3]);
+        plungerSensor = new PlungerSensorTSL1410R(
+            wirePinName(cfg.plunger.sensorPin[0]), 
+            wirePinName(cfg.plunger.sensorPin[1]),
+            wirePinName(cfg.plunger.sensorPin[2]), 
+            wirePinName(cfg.plunger.sensorPin[3]));
         break;
         
     case PlungerType_TSL1412RS:
         // pins are: SI, CLOCK, AO1, AO2
-        plungerSensor = new PlungerSensorTSL1412R(cfg.plunger.sensorPin[0], cfg.plunger.sensorPin[1], cfg.plunger.sensorPin[2], NC);
+        plungerSensor = new PlungerSensorTSL1412R(
+            wirePinName(cfg.plunger.sensorPin[0]),
+            wirePinName(cfg.plunger.sensorPin[1]), 
+            wirePinName(cfg.plunger.sensorPin[2]), 
+            NC);
         break;
     
     case PlungerType_TSL1412RP:
         // pins are: SI, CLOCK, AO1, AO2
-        plungerSensor = new PlungerSensorTSL1412R(cfg.plunger.sensorPin[0], cfg.plunger.sensorPin[1], cfg.plunger.sensorPin[2], cfg.plunger.sensorPin[3]);
+        plungerSensor = new PlungerSensorTSL1412R(
+            wirePinName(cfg.plunger.sensorPin[0]), 
+            wirePinName(cfg.plunger.sensorPin[1]), 
+            wirePinName(cfg.plunger.sensorPin[2]), 
+            wirePinName(cfg.plunger.sensorPin[3]));
         break;
     
     case PlungerType_Pot:
         // pins are: AO
-        plungerSensor = new PlungerSensorPot(cfg.plunger.sensorPin[0]);
+        plungerSensor = new PlungerSensorPot(
+            wirePinName(cfg.plunger.sensorPin[0]));
         break;
     
     case PlungerType_None:
@@ -2383,13 +2596,6 @@
         PlungerReading r;
         if (plungerSensor->read(r))
         {
-            // if in calibration mode, apply it to the calibration
-            if (plungerCalMode)
-            {
-                readForCal(r);
-                return;
-            }
-            
             // Pull the previous reading from the history
             const PlungerReading &prv = nthHist(0);
             
@@ -2403,14 +2609,68 @@
             // 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);
+
+            // check for calibration mode
+            if (plungerCalMode)
+            {
+                // Calibration mode.  Adjust the calibration bounds to fit
+                // the value.  If this value is beyond the current min or max,
+                // expand the envelope to include this new value.
+                if (r.pos > cfg.plunger.cal.max)
+                    cfg.plunger.cal.max = r.pos;
+                if (r.pos < cfg.plunger.cal.min)
+                    cfg.plunger.cal.min = r.pos;
 
-            // Apply the calibration and rescale to the joystick range.
-            r.pos = int(
-                (long(r.pos - cfg.plunger.cal.zero) * JOYMAX)
-                / (cfg.plunger.cal.max - cfg.plunger.cal.zero));
+                // If we're in calibration state 0, we're waiting for the
+                // plunger to come to rest at the park position so that we
+                // can take a sample of the park position.  Check to see if
+                // we've been at rest for a minimum interval.
+                if (calState == 0)
+                {
+                    if (abs(r.pos - calZeroStart.pos) < 65535/3/50)
+                    {
+                        // we're close enough - make sure we've been here long enough
+                        if (uint32_t(r.t - calZeroStart.t) > 100000UL)
+                        {
+                            // we've been at rest long enough - count it
+                            calZeroPosSum += r.pos;
+                            calZeroPosN += 1;
+                            
+                            // update the zero position from the new average
+                            cfg.plunger.cal.zero = uint16_t(calZeroPosSum / calZeroPosN);
+                            
+                            // switch to calibration state 1 - at rest
+                            calState = 1;
+                        }
+                    }
+                    else
+                    {
+                        // we're not close to the last position - start again here
+                        calZeroStart = r;
+                    }
+                }
+                
+                // Rescale to the joystick range, and adjust for the current
+                // park position, but don't calibrate.  We don't know the maximum
+                // point yet, so we can't calibrate the range.
+                r.pos = int(
+                    (long(r.pos - cfg.plunger.cal.zero) * JOYMAX)
+                    / (65535 - cfg.plunger.cal.zero));
+            }
+            else
+            {
+                // Not in calibration mode.  Apply the existing calibration and 
+                // rescale to the joystick range.
+                r.pos = int(
+                    (long(r.pos - cfg.plunger.cal.zero) * JOYMAX)
+                    / (cfg.plunger.cal.max - cfg.plunger.cal.zero));
+                    
+                // limit the result to the valid joystick range
+                if (r.pos > JOYMAX)
+                    r.pos = JOYMAX;
+                else if (r.pos < -JOYMAX)
+                    r.pos = -JOYMAX;
+            }
 
             // Calculate the velocity from the second-to-last reading
             // to here, in joystick distance units per microsecond.
@@ -2456,9 +2716,14 @@
                 // be strong enough to require the synthetic firing treatment.
                 if (v < 0 && r.pos > JOYMAX/6)
                 {
-                    // enter phase 1
+                    // enter firing phase 1
                     firingMode(1);
                     
+                    // if in calibration state 1 (at rest), switch to state 2 (not 
+                    // at rest)
+                    if (calState == 1)
+                        calState = 2;
+                    
                     // we don't have a freeze position yet, but note the start time
                     f1.pos = 0;
                     f1.t = r.t;
@@ -2470,7 +2735,7 @@
                     // linearly proportional to the starting retraction distance.  
                     // The barrel spring is about 1/6 the length of the main spring, 
                     // so figure it compresses by 1/6 the distance.  (This is overly
-                    // simplistic and inaccurate, but it seems to give perfectly good
+                    // simplistic and not very accurate, but it seems to give good 
                     // visual results, and that's all it's for.)
                     f2.pos = -r.pos/6;
                 }
@@ -2488,6 +2753,25 @@
                     
                     // note the start time for the firing phase
                     f2.t = r.t;
+                    
+                    // if in calibration mode, and we're in state 2 (moving), 
+                    // collect firing statistics for calibration purposes
+                    if (plungerCalMode && calState == 2)
+                    {
+                        // collect a new zero point for the average when we 
+                        // come to rest
+                        calState = 0;
+                        
+                        // collect average firing time statistics in millseconds, if 
+                        // it's in range (20 to 255 ms)
+                        int dt = uint32_t(r.t - f1.t)/1000UL;
+                        if (dt >= 20 && dt <= 255)
+                        {
+                            calRlsTimeSum += dt;
+                            calRlsTimeN += 1;
+                            cfg.plunger.cal.tRelease = uint8_t(calRlsTimeSum / calRlsTimeN);
+                        }
+                    }
                 }
                 else if (v < vprv2)
                 {
@@ -2519,6 +2803,7 @@
                 {
                     // We're not accelerating.  Cancel the firing event.
                     firingMode(0);
+                    calState = 1;
                 }
                 break;
                 
@@ -2578,11 +2863,14 @@
                     f3r.t = r.t;
                 }
 
-                // check if we've come to rest, or close enough
-                if (abs(r.pos - f3s.pos) < 200)
+                // Check if we're close to the last starting point.  The joystick
+                // positive axis range (0..4096) covers the retraction distance of 
+                // about 2.5", so 1" is about 1638 joystick units, hence 1/16" is
+                // about 100 units.
+                if (abs(r.pos - f3s.pos) < 100)
                 {
-                    // It's within an eighth inch of the last starting point. 
-                    // If it's been here for 30ms, consider it stable.
+                    // It's at roughly the same position as the starting point.
+                    // Consider it stable if this has been true for 300ms.
                     if (uint32_t(r.t - f3s.t) > 30000UL)
                     {
                         // we're done with the firing event
@@ -2649,7 +2937,7 @@
                 cfg.plunger.cal.zero = r.pos;
                 
                 // use it as the starting point for the settling watch
-                f1 = r;
+                calZeroStart = r;
             }
             else
             {
@@ -2657,8 +2945,21 @@
                 cfg.plunger.cal.zero = 0xffff/6;
                 
                 // we don't have a starting point for the setting watch
-                f1.pos = -65535;
-                f1.t = 0;
+                calZeroStart.pos = -65535;
+                calZeroStart.t = 0;
+            }
+        }
+        else if (!f && plungerCalMode)
+        {
+            // Leaving calibration mode.  Make sure the max is past the
+            // zero point - if it's not, we'd have a zero or negative
+            // denominator for the scaling calculation, which would be
+            // physically meaningless.
+            if (cfg.plunger.cal.max <= cfg.plunger.cal.zero)
+            {
+                // bad settings - reset to defaults
+                cfg.plunger.cal.max = 0xffff;
+                cfg.plunger.cal.zero = 0xffff/6;
             }
         }
             
@@ -2667,127 +2968,9 @@
     }
     
     // is a firing event in progress?
-    bool isFiring() { return firing > 3; }
+    bool isFiring() { return firing == 3; }
 
 private:
-    // Read the sensor in calibration mode
-    void readForCal(PlungerReading r)
-    {
-        // 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.max)
-            cfg.plunger.cal.max = r.pos;
-                    
-        // While we're in calibration mode, report the raw sensor
-        // position as the joystick value, adjusted to the JOYMAX scale.
-        z = int16_t((long(r.pos) * JOYMAX)/65535);
-        
-        // for the release monitoring, take readings at least 2ms apart
-        if (uint32_t(r.t - f2.t) < 2000UL)
-            return;
-        
-        // Check our state
-        switch (calState)
-        {
-        case 0:
-            // We're waiting for the position to settle.  Check to see if
-            // we've been at the recent settling position long enough.
-            // Consider 1/50" (about 0.5mm) close enough to count as stable, 
-            // to allow for some slight sensor noise from reading to reading.
-            if (abs(r.pos - f1.pos) > 65535/3/50)
-            {
-                // too far away - set the new starting point
-                f1 = r;
-            }
-            else if (uint32_t(r.t - f1.t) > 100000)
-            {
-                // We've been stationary long enough to count as settled.
-                // Wwitch to "at rest" state.
-                calState = 1;
-                
-                // collect the new zero point for our average
-                calZeroPosSum += r.pos;
-                calZeroPosN += 1;
-                
-                // use the new average as the zero point
-                cfg.plunger.cal.zero = uint16_t(calZeroPosSum / calZeroPosN);
-                
-                // remember the current position in f1 to detect when we start
-                // moving again
-                f1 = r;
-            }
-            break;
-            
-        case 1:
-            // At rest.  We remain in this state until we see the plunger
-            // retract more than about 1/2".
-            if (r.pos - f1.pos > 65535/6)
-            {
-                // switch to state 2 - retracting
-                calState = 2;
-                
-                // use f1 as the max so far
-                f1 = r;
-            }
-            break;
-            
-        case 2:
-            // Away from rest position.  Note the maximum point so far in f1,
-            // and monitor for release motions.
-            if (r.pos >= f1.pos)
-            {
-                // moving back - note the new max point on this run
-                f1 = r;
-            }
-            else
-            {
-                // moving forward - switch to possible release mode
-                calState = 3;
-            }
-            break;
-            
-        case 3:
-            // Possible release.  We have to move forward on each new
-            // reading, relative to two readings ago, to stay in release 
-            // mode.
-            if (r.pos >= f3r.pos)
-            {
-                // not moving forward - switch back to retract mode
-                calState = 2;
-                f1 = r;
-            }
-            else if (r.pos <= cfg.plunger.cal.zero)
-            {
-                // Crossed the zero point.  Figure the release time.  If
-                // it's within a reasonable range, add it to the average.
-                // We'll ignore outliers on the assumption that they
-                // don't reflect actual release motions.  
-                int dt = uint32_t(r.t - f1.t)/1000;
-                if (dt < 250 && dt > 25)
-                {
-                    // count it in the average
-                    calRlsTimeSum += dt;
-                    calRlsTimeN += 1;
-                    
-                    // store the new average in the configuration
-                    cfg.plunger.cal.tRelease = uint8_t(calRlsTimeSum / calRlsTimeN);
-                    cfg.plunger.cal.tRelease = dt; // $$$
-                }
-                
-                // release done - switch to "waiting to settle" mode
-                calState = 0;
-                f1 = r;
-            }
-            break;
-        }
-        
-        // f2 is always the immediately previous reading in cal mode, 
-        // and f3r is the one before that
-        f3r = f2;
-        f2 = r;
-    }
 
     // Calibration state.  During calibration mode, we watch for release
     // events, to measure the time it takes to complete the release
@@ -2807,6 +2990,7 @@
     // itself doesn't come to rest at exactly the same spot every time, 
     // largely due to friction in the mechanism.  To calculate the average,
     // we keep a sum of the readings and a count of samples.
+    PlungerReading calZeroStart;
     long calZeroPosSum;
     int calZeroPosN;
     
@@ -2860,53 +3044,6 @@
         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;
-        }
-    }
-    
     // velocity at previous reading, and the one before that
     float vprv, vprv2;
     
@@ -2939,16 +3076,12 @@
     //   0 - Default state.  We report the real instantaneous plunger 
     //       position to the joystick interface.
     //
-    //   1 - Possible release in progress.  We enter this state when
-    //       we see the plunger start to move forward, and stay in this
-    //       state as long as we see *accelerating* forward motion.
+    //   1 - Moving forward
     //
-    //   2 - Firing event started.  We report the "bounce" position for
-    //       a minimum time.
+    //   2 - Accelerating
     //
-    //   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.
+    //   3 - Firing.  We report the rest position for a minimum interval,
+    //       or until the real plunger comes to rest somewhere.
     //
     int firing;
     
@@ -3026,12 +3159,7 @@
     {
         // 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();
+        btnState = false;
     }
 
     // Update state.  This checks the current plunger position and
@@ -3040,152 +3168,96 @@
     // 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)
+    void update()
     {
-        // Check for a simulated Launch Ball button press, if enabled
-        if (cfg.plunger.zbLaunchBall.port != 0)
+        // If the ZB Launch Ball led wiz output is ON, check for a 
+        // plunger firing event
+        if (zbLaunchOn)
         {                
+            // note the new position
             int znew = plungerReader.getPosition();
-            const int cockThreshold = JOYMAX/3;
+            
+            // figure the push threshold from the configuration data
             const int pushThreshold = int(-JOYMAX/3.0 * cfg.plunger.zbLaunchBall.pushDistance/1000.0);
-            int newState = lbState;
+
+            // check the state
             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;
+                // Default state.  If a launch event has been detected on
+                // the plunger, activate a timed pulse and switch to state 1.
+                // If the plunger is pushed forward of the threshold, push
+                // the button.
+                if (plungerReader.isFiring())
+                {
+                    // firing event - start a timed Launch button pulse
+                    lbTimer.reset();
+                    lbTimer.start();
+                    setButton(true);
+                    
+                    // switch to state 1
+                    lbState = 1;
+                }
                 else if (znew <= pushThreshold)
-                    newState = 5;
+                {
+                    // pushed forward without a firing event - hold the
+                    // button as long as we're pushed forward
+                    setButton(true);
+                }
+                else
+                {
+                    // not pushed forward - turn off the Launch button
+                    setButton(false);
+                }
                 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;
+                // State 1: Timed Launch button pulse in progress after a
+                // firing event.  Wait for the timer to expire.
+                if (lbTimer.read_us() > 200000UL)
+                {
+                    // timer expired - turn off the button
+                    setButton(false);
+                    
+                    // switch to state 2
+                    lbState = 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;
+                // State 2: Timed Launch button pulse done.  Wait for the
+                // plunger launch event to end.
+                if (!plungerReader.isFiring())
+                {
+                    // firing event done - return to default state
+                    lbState = 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;
+        }
+        else
+        {
+            // ZB Launch Ball disabled - turn off the button if it was on
+            setButton(false);
                 
-                // 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;
-                }
-            }
+            // return to the default state
+            lbState = 0;
         }
     }
-  
+    
+    // Set the button state
+    void setButton(bool on)
+    {
+        if (btnState != on)
+        {
+            // remember the new state
+            btnState = on;
+            
+            // update the virtual button state
+            buttonState[ZBL_BUTTON].virtPress(on);
+        }
+    }
+    
 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,
@@ -3196,16 +3268,13 @@
     //
     // 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;
+    //   1 = firing (firing event has activated a Launch button pulse)
+    //   2 = firing done (Launch button pulse ended, waiting for plunger
+    //       firing event to end)
+    uint8_t lbState;
     
-    // button bit for ZB launch ball button
-    uint32_t lbButtonBit;
+    // button state
+    bool btnState;
     
     // Time since last lbState transition.  Some of the states are time-
     // sensitive.  In the "uncocked" state, we'll return to state 0 if
@@ -3215,11 +3284,6 @@
     // 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;
 };
 
 // ---------------------------------------------------------------------------
@@ -3286,8 +3350,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 reportPlungerStat = false;
-uint8_t reportStatFlags;    // pixel report flag bits (see ccdSensor.h)
-uint8_t reportStatVisMode;  // pixel report visualization mode (not currently used)
+uint8_t reportPlungerStatFlags; // plunger pixel report flag bits (see ccdSensor.h)
+uint8_t reportPlungerStatTime;  // extra exposure time for plunger pixel report
 
 
 // ---------------------------------------------------------------------------
@@ -3313,9 +3377,10 @@
 
 // Handle SET messages - write configuration variables from USB message data
 #define if_msg_valid(test)  if (test)
-#define v_byte(var, ofs)   cfg.var = data[ofs]
-#define v_ui16(var, ofs)   cfg.var = wireUI16(data+ofs)
-#define v_pin(var, ofs)    cfg.var = wirePinName(data[ofs])
+#define v_byte(var, ofs)    cfg.var = data[ofs]
+#define v_ui16(var, ofs)    cfg.var = wireUI16(data+(ofs))
+#define v_pin(var, ofs)     cfg.var = wirePinName(data[ofs])
+#define v_byte_ro(val, ofs) // ignore read-only variables on SET
 #define v_func configVarSet
 #include "cfgVarMsgMap.h"
 
@@ -3324,13 +3389,15 @@
 #undef v_byte
 #undef v_ui16
 #undef v_pin
+#undef v_byte_ro
 #undef v_func
 
 // Handle GET messages - read variable values and return in USB message daa
 #define if_msg_valid(test)
-#define v_byte(var, ofs)   data[ofs] = cfg.var
-#define v_ui16(var, ofs)   ui16Wire(data+ofs, cfg.var)
-#define v_pin(var, ofs)    pinNameWire(data+ofs, cfg.var)
+#define v_byte(var, ofs)    data[ofs] = cfg.var
+#define v_ui16(var, ofs)    ui16Wire(data+(ofs), cfg.var)
+#define v_pin(var, ofs)     pinNameWire(data+(ofs), cfg.var)
+#define v_byte_ro(val, ofs) data[ofs] = val
 #define v_func  configVarGet
 #include "cfgVarMsgMap.h"
 
@@ -3465,10 +3532,10 @@
         case 3:
             // 3 = plunger sensor status report
             //     data[2] = flag bits
-            //     data[3] = visualization mode
+            //     data[3] = extra exposure time, 100us (.1ms) increments
             reportPlungerStat = true;
-            reportStatFlags = data[2];
-            reportStatVisMode = data[3];
+            reportPlungerStatFlags = data[2];
+            reportPlungerStatTime = data[3];
             
             // show purple until we finish sending the report
             diagLED(1, 0, 1);
@@ -3493,17 +3560,16 @@
             // 6 = Save configuration to flash.
             saveConfigToFlash();
             
-            // Reboot the microcontroller.  Nearly all config changes
-            // require a reset, and a reset only takes a few seconds, 
-            // so we don't bother tracking whether or not a reboot is
-            // really needed.
-            reboot(js);
+            // before disconnecting, pause for the delay time specified in
+            // the parameter byte (in seconds)
+            rebootTime_us = data[2] * 1000000L;
+            rebootTimer.start();
             break;
             
         case 7:
             // 7 = Device ID report
-            // (No parameters)
-            js.reportID();
+            //     data[2] = ID index: 1=CPU ID, 2=OpenSDA TUID
+            js.reportID(data[2]);
             break;
             
         case 8:
@@ -3517,10 +3583,12 @@
             //     data[2] = config var ID
             //     data[3] = array index (for array vars: button assignments, output ports)
             {
-                // set up the reply buffer with the variable ID data
+                // set up the reply buffer with the variable ID data, and zero out
+                // the rest of the buffer
                 uint8_t reply[8];
                 reply[1] = data[2];
                 reply[2] = data[3];
+                memset(reply+3, 0, sizeof(reply)-3);
                 
                 // query the value
                 configVarGet(reply);
@@ -3529,6 +3597,11 @@
                 js.reportConfigVar(reply + 1);
             }
             break;
+            
+        case 10:
+            // 10 = Build ID query.
+            js.reportBuildInfo(getBuildID());
+            break;
         }
     }
     else if (data[0] == 66)
@@ -3721,8 +3794,10 @@
     statusFlags = (cfg.plunger.enabled ? 0x01 : 0x00);
 
     // initialize the calibration buttons, if present
-    DigitalIn *calBtn = (cfg.plunger.cal.btn == NC ? 0 : new DigitalIn(cfg.plunger.cal.btn));
-    DigitalOut *calBtnLed = (cfg.plunger.cal.led == NC ? 0 : new DigitalOut(cfg.plunger.cal.led));
+    DigitalIn *calBtn = (cfg.plunger.cal.btn == 0xFF ? 0 : 
+        new DigitalIn(wirePinName(cfg.plunger.cal.btn)));
+    DigitalOut *calBtnLed = (cfg.plunger.cal.led == 0xFF ? 0 :
+        new DigitalOut(wirePinName(cfg.plunger.cal.led)));
 
     // initialize the calibration button 
     calBtnTimer.start();
@@ -3745,14 +3820,6 @@
     // acceleration via the joystick x & y axes, per the VP convention)
     int x = 0, y = 0;
     
-    // 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
-    // a button as pressed if either the physical button is being pressed
-    // or we're simulating a press on the button.  This is used for the
-    // simulated Launch Ball button.
-    uint32_t simButtons = 0;
-    
     // initialize the plunger sensor
     plungerSensor->init();
     
@@ -3881,12 +3948,12 @@
         // read the plunger sensor
         plungerReader.read();
         
-        // process button updates
-        processButtons();
+        // update the ZB Launch Ball status
+        zbLaunchBall.update();
         
-        // handle the ZB Launch Ball feature
-        zbLaunchBall.update(simButtons);
-
+        // process button updates
+        processButtons(cfg);
+        
         // send a keyboard report if we have new data
         if (kbState.changed)
         {
@@ -3934,10 +4001,7 @@
             // 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);
+            int zrep = (!cfg.plunger.enabled || zbLaunchOn ? 0 : z);
             
             // rotate X and Y according to the device orientation in the cabinet
             accelRotate(x, y);
@@ -3949,7 +4013,7 @@
 #endif
 
             // send the joystick report
-            jsOK = js.update(x, y, zrep, jsButtons | simButtons, statusFlags);
+            jsOK = js.update(x, y, zrep, jsButtons, statusFlags);
             
             // we've just started a new report interval, so reset the timer
             jsReportTimer.reset();
@@ -3959,7 +4023,7 @@
         if (reportPlungerStat)
         {
             // send the report            
-            plungerSensor->sendStatusReport(js, reportStatFlags, reportStatVisMode);
+            plungerSensor->sendStatusReport(js, reportPlungerStatFlags, reportPlungerStatTime);
 
             // we have satisfied this request
             reportPlungerStat = false;
@@ -4053,6 +4117,10 @@
             }
         }
         
+        // if we have a reboot timer pending, check for completion
+        if (rebootTimer.isRunning() && rebootTimer.read_us() > rebootTime_us)
+            reboot(js);
+        
         // if we're disconnected, initiate a new connection
         if (!connected)
         {