An input/output controller for virtual pinball machines, with plunger position tracking, accelerometer-based nudge sensing, button input encoding, and feedback device control.

Dependencies:   USBDevice mbed FastAnalogIn FastIO FastPWM SimpleDMA

/media/uploads/mjr/pinscape_no_background_small_L7Miwr6.jpg

The Pinscape Controller is a special-purpose software project that I wrote for my virtual pinball machine.

New version: V2 is now available! The information below is for version 1, which will continue to be available for people who prefer the original setup.

What exactly is a virtual pinball machine? It's basically a video-game pinball emulator built to look like a real pinball machine. (The picture at right is the one I built.) You start with a standard pinball cabinet, either built from scratch or salvaged from a real machine. Inside, you install a PC motherboard to run the software, and install TVs in place of the playfield and backglass. Several Windows pinball programs can take advantage of this setup, including the open-source project Visual Pinball, which has hundreds of tables available. Building one of these makes a great DIY project, and it's a good way to add to your skills at woodworking, computers, and electronics. Check out the Cabinet Builders' Forum on vpforums.org for lots of examples and advice.

This controller project is a key piece in my setup that helps integrate the video game into the pinball cabinet. It handles several input/output tasks that are unique to virtual pinball machines. First, it lets you connect a mechanical plunger to the software, so you can launch the ball like on a real machine. Second, it sends "nudge" data to the software, based on readings from an accelerometer. This lets you interact with the game physically, which makes the playing experience more realistic and immersive. Third, the software can handle button input (for wiring flipper buttons and other cabinet buttons), and fourth, it can control output devices (for tactile feedback, button lights, flashers, and other special effects).

Documentation

The Hardware Build Guide (PDF) has detailed instructions on how to set up a Pinscape Controller for your own virtual pinball cabinet.

Update notes

December 2015 version: This version fully supports the new Expansion Board project, but it'll also run without it. The default configuration settings haven't changed, so existing setups should continue to work as before.

August 2015 version: Be sure to get the latest version of the Config Tool for windows if you're upgrading from an older version of the firmware. This update adds support for TSL1412R sensors (a version of the 1410 sensor with a slightly larger pixel array), and a config option to set the mounting orientation of the board in the firmware rather than in VP (for better support for FP and other pinball programs that don't have VP's flexibility for setting the rotation).

Feb/March 2015 software versions: If you have a CCD plunger that you've been using with the older versions, and the plunger stops working (or doesn't work as well) after you update to the latest version, you might need to increase the brightness of your light source slightly. Check the CCD exposure with the Windows config tool to see if it looks too dark. The new software reads the CCD much more quickly than the old versions did. This makes the "shutter speed" faster, which might require a little more light to get the same readings. The CCD is actually really tolerant of varying light levels, so you probably won't have to change anything for the update - I didn't. But if you do have any trouble, have a look at the exposure meter and try a slightly brighter light source if the exposure looks too dark.

Downloads

  • Config tool for Windows (.exe and C# source): this is a Windows program that lets you view the raw pixel data from the CCD sensor, trigger plunger calibration mode, and configure some of the software options on the controller.
  • 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 9.9.1 and VP 10 releases, so you don't need my custom builds if you're using 9.9.1 or 10 or later. I don't think there's any reason to use my 9.9 instead of the official 9.9.1, but I'm leaving it here just in case. In the official VP releases, look for the checkbox "Enable Nudge Filter" in the Keys preferences dialog. (There's no checkbox in my custom builds, though; the filter is simply always on in those.)
  • Output circuit shopping list: This is a saved shopping cart at mouser.com with the parts needed for each output driver, if you want to use the LedWiz emulator feature. Note that quantities in the cart are for one output channel, so multiply everything by the number of channels you plan to use, except that you only need one of the ULN2803 transistor array chips for each eight output circuits.
  • 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.

Features

  • Plunger position sensing, using a TAOS TSL 1410R CCD linear array sensor. This sensor is a 1280 x 1 pixel array at 400 dpi, which makes it about 3" long - almost exactly the travel distance of a standard pinball plunger. The idea is that you install the sensor just above (within a few mm of) the shooter rod on the inside of the cabinet, with the CCD window facing down, aligned with and centered on the long axis of the shooter rod, and positioned so that the rest position of the tip is about 1/2" from one end of the window. As you pull back the plunger, the tip will travel down the length of the window, and the maximum retraction point will put the tip just about at the far end of the window. Put a light source below, facing the sensor - I'm using two typical 20 mA blue LEDs about 8" away (near the floor of the cabinet) with good results. The principle of operation is that the shooter rod casts a shadow on the CCD, so pixels behind the rod will register lower brightness than pixels that aren't in the shadow. We scan down the length of the sensor for the edge between darker and brighter, and this tells us how far back the rod has been pulled. We can read the CCD at about 25-30 ms intervals, so we can get rapid updates. We pass the readings reports to VP via our USB joystick reports.

    The hardware build guide includes schematics showing how to wire the CCD to the KL25Z. It's pretty straightforward - five wires between the two devices, no external components needed. Two GPIO ports are used as outputs to send signals to the device and one is used as an ADC in to read the pixel brightness inputs. The config tool has a feature that lets you display the raw pixel readings across the array, so you can test that the CCD is working and adjust the light source to get the right exposure level.

    Alternatively, you can use a slide potentiometer as the plunger sensor. This is a cheaper and somewhat simpler option that seems to work quite nicely, as you can see in Lemming77's video of this setup in action. This option is also explained more fully in the build guide.
  • Nudge sensing via the KL25Z's on-board accelerometer. Mounting the board in your cabinet makes it feel the same accelerations the cabinet experiences when you nudge it. Visual Pinball already knows how to interpret accelerometer input as nudging, so we simply feed the acceleration readings to VP via the joystick interface.
  • Cabinet button wiring. Up to 24 pushbuttons and switches can be wired to the controller for input controls (for example, flipper buttons, the Start button, the tilt bob, coin slot switches, and service door buttons). These appear to Windows as joystick buttons. VP can map joystick buttons to pinball inputs via its keyboard preferences dialog. (You can raise the 24-button limit by editing the source code, but since all of the GPIO pins are allocated, you'll have to reassign pins currently used for other functions.)
  • LedWiz emulation (limited). In addition to emulating a joystick, the device emulates the LedWiz USB interface, so controllers on the PC side such as DirectOutput Framework can recognize it and send it commands to control lights, solenoids, and other feedback devices. 22 GPIO ports are assigned by default as feedback device outputs. This feature has some limitations. The big one is that the KL25Z hardware only has 10 PWM channels, which isn't enough for a fully decked-out cabinet. You also need to build some external power driver circuitry to use this feature, because of the paltry 4mA output capacity of the KL25Z GPIO ports. The build guide includes instructions for a simple and robust output circuit, including part numbers for the exact components you need. It's not hard if you know your way around a soldering iron, but just be aware that it'll take a little work.

Warning: This is not replacement software for the VirtuaPin plunger kit. If you bought the VirtuaPin kit, please don't try to install this software. The VP kit happens to use the same microcontroller board, but the rest of its hardware is incompatible. The VP kit uses a different type of sensor for its plunger and has completely different button wiring, so the Pinscape software won't work properly with it.

Files at this revision

API Documentation at this revision

Comitter:
mjr
Date:
Sun Feb 07 03:07:11 2016 +0000
Parent:
43:7a6364d82a41
Child:
45:c42166b2878c
Commit message:
Work in progress on CCD speed-ups

Changed in this revision

ccdSensor.h Show annotated file Show diff for this revision Revisions of this file
config.h Show annotated file Show diff for this revision Revisions of this file
main.cpp Show annotated file Show diff for this revision Revisions of this file
nullSensor.h Show annotated file Show diff for this revision Revisions of this file
plunger.h Show annotated file Show diff for this revision Revisions of this file
potSensor.h Show annotated file Show diff for this revision Revisions of this file
--- a/ccdSensor.h	Sat Feb 06 20:21:48 2016 +0000
+++ b/ccdSensor.h	Sun Feb 07 03:07:11 2016 +0000
@@ -28,9 +28,14 @@
 class PlungerSensorCCD: public PlungerSensor
 {
 public:
-    PlungerSensorCCD(int nativePix, PinName si, PinName clock, PinName ao1, PinName ao2) 
+    PlungerSensorCCD(
+        int nativePix, int highResPix, int lowResPix, 
+        PinName si, PinName clock, PinName ao1, PinName ao2) 
         : ccd(nativePix, si, clock, ao1, ao2)
     {
+        this->highResPix = highResPix;
+        this->lowResPix = lowResPix;
+        this->pix = new uint16_t[highResPix];
     }
     
     // initialize
@@ -42,32 +47,42 @@
     }
     
     // Perform a low-res scan of the sensor.  
-    virtual bool lowResScan(int &pos)
+    virtual bool lowResScan(float &pos)
     {
+        // If we haven't sensed the direction yet, do a high-res scan
+        // first, so that we can get accurate direction data.  Return
+        // the result of the high-res scan if successful, since it 
+        // provides even better data than the caller requested from us.
+        // This will take longer than the caller wanted, but it should
+        // only be necessary to do this once after the system stabilizes
+        // after startup, so the timing difference won't affect normal
+        // operation.
+        if (dir == 0)
+            return highResScan(pos);
+        
         // read the pixels at low resolution
-        uint16_t pix[nlpix];
-        ccd.read(pix, nlpix);
-    
-        // determine which end is brighter
-        uint16_t p1 = pix[0];
-        uint16_t p2 = pix[nlpix-1];
-        int si = 0, di = 1;
-        if (p1 < p2)
-            si = nlpix - 1, di = -1;
+        ccd.read(pix, lowResPix);
         
-        // figure the shadow edge threshold - just use the midpoint 
-        // of the levels at the bright and dark ends
-        uint16_t shadow = uint16_t((long(p1) + long(p2))/2);
+        // set the loop variables for the sensor orientation
+        int si = 0;
+        if (dir < 0)
+            si = lowResPix - 1;
+            
+        // Figure the shadow edge threshold.  Use the midpoint between
+        // the average levels of a few pixels at each end.
+        uint16_t shadow = uint16_t(
+            (long(pix[0]) + long(pix[1]) + long(pix[2])
+             + long(pix[lowResPix-1]) + long(pix[lowResPix-2]) + long(pix[lowResPix-3])
+            )/6);
         
         // find the current tip position
-        for (int n = 0 ; n < nlpix ; ++n, si += di)
+        for (int n = 0 ; n < lowResPix ; ++n, si += dir)
         {
             // check to see if we found the shadow
             if (pix[si] <= shadow)
             {
-                // got it - normalize it to normal 'npix' resolution and
-                // return the result
-                pos = n*npix/nlpix;
+                // got it - normalize to the 0.0..1.0 range and return success
+                pos = float(n)/lowResPix;
                 return true;
             }
         }
@@ -77,24 +92,32 @@
     }
 
     // Perform a high-res scan of the sensor.
-    virtual bool highResScan(int &pos)
+    virtual bool highResScan(float &pos)
     {
         // read the array
-        ccd.read(pix, npix);
+        ccd.read(pix, highResPix);
 
-        // get the brightness at each end of the sensor
-        long b1 = pix[0];
-        long b2 = pix[npix-1];
-        
+        // Sense the orientation of the sensor if we haven't already.  If 
+        // that fails, we must not have enough contrast to find a shadow edge 
+        // in the image, so there's no point in looking any further - just
+        // return failure.
+        if (dir == 0 && !senseOrientation(highResPix))
+            return false;
+            
+        // Get the average brightness for a few pixels at each end.
+        long b1 = (long(pix[0]) + long(pix[1]) + long(pix[2]) + long(pix[3]) + long(pix[4])) / 5;
+        long b2 = (long(pix[highResPix-1]) + long(pix[highResPix-2]) + long(pix[highResPix-3])
+            + long(pix[highResPix-4]) + long(pix[highResPix-5])) / 5;
+
         // Work from the bright end to the dark end.  VP interprets the
         // Z axis value as the amount the plunger is pulled: zero is the
         // rest position, and the axis maximum is fully pulled.  So we 
         // essentially want to report how much of the sensor is lit,
         // since this increases as the plunger is pulled back.
-        int si = 0, di = 1;
+        int si = 0;
         long hi = b1;
-        if (b1 < b2)
-            si = npix - 1, di = -1, hi = b2;
+        if (dir < 0)
+            si = highResPix - 1, hi = b2;
 
         // Figure the shadow threshold.  In practice, the portion of the
         // sensor that's not in shadow has all pixels consistently near
@@ -124,13 +147,13 @@
         if (labs(b1 - b2) > 0x1000)
         {
             uint16_t *pixp = pix + si;           
-            for (int n = 0 ; n < npix ; ++n, pixp += di)
+            for (int n = 0 ; n < highResPix ; ++n, pixp += dir)
             {
                 // if we've crossed the midpoint, report this position
                 if (long(*pixp) < midpt)
                 {
-                    // note the new position
-                    pos = n;
+                    // normalize to the 0.0..1.0 range and return success
+                    pos = float(n)/highResPix;
                     return true;
                 }
             }
@@ -140,6 +163,43 @@
         return false;
     }
     
+    // Infer the sensor orientation from the image data.  This determines 
+    // which end of the array has the brighter pixels.  In some cases it 
+    // might not be possible to tell: if the light source is turned off,
+    // or if the plunger is all the way to one extreme so that the entire
+    // pixel array is in shadow or in full light.  To sense the direction
+    // we need to have a sufficient difference in brightness between the
+    // two ends of the array to be confident that one end is in shadow
+    // and the other isn't.  On success, sets member variable 'dir' and
+    // returns true; on failure (i.e., we don't have sufficient contrast
+    // to sense the orientation), returns false and leaves 'dir' unset.
+    bool senseOrientation(int n)
+    {
+        // get the total brightness for the first few pixels at
+        // each end of the array (a proxy for the average - just
+        // save time by skipping the divide-by-N)
+        long a = long(pix[0]) + long(pix[1]) + long(pix[2]) 
+            + long(pix[3]) + long(pix[4]);
+        long b = long(pix[n-1]) + long(pix[n-2]) + long(pix[n-3])
+            + long(pix[n-4]) + long(pix[n-5]);
+            
+        // if the difference is too small, we can't tell
+        const long minPct = 10;
+        const long minDiff = 65535*5*minPct/100;
+        if (labs(a - b) < minDiff)
+            return false;
+            
+        // we now know the orientation - set the 'dir' member variable
+        // for future use
+        if (a > b)
+            dir = 1;
+        else
+            dir = -1;
+            
+        // success
+        return true;
+    }
+    
     // send an exposure report to the joystick interface
     virtual void sendExposureReport(USBJoystick &js)
     {
@@ -147,13 +207,13 @@
         // gives us the shortest possible exposure for the sample we report,
         // which helps ensure that the user inspecting the data sees something
         // close to what we see when we calculate the plunger position.
-        ccd.read(pix, npix);
-        ccd.read(pix, npix);        
+        ccd.read(pix, highResPix);
+        ccd.read(pix, highResPix);
         
         // send reports for all pixels
         int idx = 0;
-        while (idx < npix)
-            js.updateExposure(idx, npix, pix);
+        while (idx < highResPix)
+            js.updateExposure(idx, highResPix, pix);
             
         // The pixel dump requires many USB reports, since each report
         // can only send a few pixel values.  An integration cycle has
@@ -162,20 +222,27 @@
         // the integration won't be comparable to a normal cycle.  Throw
         // this one away by doing a read now, and throwing it away - that 
         // will get the timing of the *next* cycle roughly back to normal.
-        ccd.read(pix, npix);
+        ccd.read(pix, highResPix);
     }
     
 protected:
-    // pixel buffer - concrete subclasses must set to a buffer of the
-    // appropriate size
+    // pixel buffer - we allocate this to be big enough for a high-res scan
     uint16_t *pix;
+
+    // number of pixels in a high-res scan
+    int highResPix;
+    
+    // number of pixels in a low-res scan
+    int lowResPix;
     
-    // number of pixels in low-res scan - concrete subclasses must set
-    // this to a value that evenly divides the native sensor size
-    int nlpix;
+    // Sensor orientation.  +1 means that the "tip" end - which is always
+    // the brighter end in our images - is at the 0th pixel in the array.
+    // -1 means that the tip is at the nth pixel in the array.  0 means
+    // that we haven't figured it out yet.
+    int dir;
     
+public:
     // the low-level interface to the CCD hardware
-public://$$$
     TSL1410R ccd;
 };
 
@@ -185,21 +252,9 @@
 {
 public:
     PlungerSensorTSL1410R(PinName si, PinName clock, PinName ao1, PinName ao2) 
-        : PlungerSensorCCD(1280, si, clock, ao1, ao2)
+        : PlungerSensorCCD(1280, 320, 64, si, clock, ao1, ao2)
     {
-        // This sensor is 1x1280 pixels at 400dpi.  Sample every 8th
-        // pixel -> 160 pixels at 50dpi == 0.5mm spatial resolution.
-        npix = 320;
-        
-        // for the low-res scan, sample every 40th pixel -> 32 pixels
-        // at 10dpi == 2.54mm spatial resolution.
-        nlpix = 32;
-        
-        // set the pixel buffer
-        pix = pixbuf;
     }
-    
-    uint16_t pixbuf[320];
 };
 
 // TSL1412R
@@ -207,20 +262,7 @@
 {
 public:
     PlungerSensorTSL1412R(PinName si, PinName clock, PinName ao1, PinName ao2)
-        : PlungerSensorCCD(1536, si, clock, ao1, ao2)
+        : PlungerSensorCCD(1536, 384, 64, si, clock, ao1, ao2)
     {
-        // This sensor is 1x1536 pixels at 400dpi.  Sample every 8th
-        // pixel -> 192 pixels at 50dpi == 0.5mm spatial resolution.
-        npix = 192;
-        
-        // for the low-res scan, sample every 48 pixels -> 32 pixels
-        // at 8.34dpi = 3.05mm spatial resolution
-        nlpix = 32;
-        
-        // set the pixel buffer
-        pix = pixbuf;
     }
-    
-    uint16_t pixbuf[192];
 };
-
--- a/config.h	Sat Feb 06 20:21:48 2016 +0000
+++ b/config.h	Sun Feb 07 03:07:11 2016 +0000
@@ -154,8 +154,8 @@
         plunger.cal.btn = NC;
         plunger.cal.led = NC;
         
-        // clear the plunger calibration
-        plunger.cal.reset(4096);
+        // set the default plunger calibration
+        plunger.cal.setDefaults();
         
         // disable the ZB Launch Ball by default
         plunger.zbLaunchBall.port = 0;
@@ -409,17 +409,29 @@
             // compressed by the user pushing on the plunger or by the momentum
             // of a release motion.  The minimum is the maximum forward point where
             // the barrel spring can't be compressed any further.
-            int min;
-            int zero;
-            int max;
+            float min;
+            float zero;
+            float max;
     
-            // reset the plunger calibration
-            void reset(int npix)
+            // Reset the plunger calibration
+            void setDefaults()
             {
-                calibrated = 0;          // not calibrated
-                min = 0;                 // assume we can go all the way forward...
-                max = npix;              // ...and all the way back
-                zero = npix/6;           // the rest position is usually around 1/2" back = 1/6 of total travel
+                calibrated = false;       // not calibrated
+                min = 0.0f;               // assume we can go all the way forward...
+                max = 1.0f;               // ...and all the way back
+                zero = 1.0/6.0f;          // the rest position is usually around 1/2" back = 1/6 of total travel
+            }
+            
+            // Begin calibration.  This sets each limit to the worst
+            // case point - for example, we set the retracted position
+            // to all the way forward.  Each actual reading that comes
+            // in is then checked against the current limit, and if it's
+            // outside of the limit, we reset the limit to the new reading.
+            void begin()
+            {
+                min = 0.0f;               // we don't calibrate the maximum forward position, so keep this at zero
+                zero = 1.0f;              // set the zero position all the way back
+                max = 0.0f;               // set the retracted position all the way forward
             }
 
         } cal;
--- a/main.cpp	Sat Feb 06 20:21:48 2016 +0000
+++ b/main.cpp	Sun Feb 07 03:07:11 2016 +0000
@@ -2503,7 +2503,7 @@
             // enter calibration mode
             calBtnState = 3;
             calBtnTimer.reset();
-            cfg.plunger.cal.reset(plungerSensor->npix);
+            cfg.plunger.cal.begin();
             break;
             
         case 3:
@@ -2765,8 +2765,8 @@
     // acceleration via the joystick x & y axes, per the VP convention)
     int x = 0, y = 0;
     
-    // last plunger report position, in 'npix' normalized pixel units
-    int pos = 0;
+    // last plunger report position, on the 0.0..1.0 normalized scale
+    float pos = 0;
     
     // last plunger report, in joystick units (we report the plunger as the
     // "z" axis of the joystick, per the VP convention)
@@ -2904,8 +2904,8 @@
                     calBtnState = 3;
                     calBtnTimer.reset();
                     
-                    // reset the plunger calibration limits
-                    cfg.plunger.cal.reset(plungerSensor->npix);
+                    // begin the plunger calibration limits
+                    cfg.plunger.cal.begin();
                 }
                 break;
                 
@@ -2978,32 +2978,35 @@
             }
         }
  
-        // If the plunger is enabled, and we're not already in a firing event,
-        // and the last plunger reading had the plunger pulled back at least
-        // a bit, watch for plunger release events until it's time for our next
-        // USB report.
-        if (!firing && cfg.plunger.enabled && z >= JOYMAX/6)
+        // If the plunger is enabled, and we're not in calibration mode, and 
+        // we're not already in a firing event, and the last plunger reading had 
+        // the plunger pulled back at least a bit, watch for plunger release 
+        // events until it's time for our next USB report.
+        if (!firing && calBtnState != 3 && cfg.plunger.enabled && z >= JOYMAX/6)
         {
             // monitor the plunger until it's time for our next report
-            while (jsReportTimer.read_ms() < 15)
+            for (int i = 0 ; i < 20 && jsReportTimer.read_ms() < 12 ; ++i)
             {
                 // do a fast low-res scan; if it's at or past the zero point,
                 // start a firing event
-                int pos0;
+                float pos0;
                 if (plungerSensor->lowResScan(pos0) && pos0 <= cfg.plunger.cal.zero)
+                {
                     firing = 1;
+                    break;
+                }
             }
         }
 
-        // read the plunger sensor, if it's enabled
-        if (cfg.plunger.enabled)
+        // read the plunger sensor, if it's enabled and we're not in firing mode
+        if (cfg.plunger.enabled && !firing)
         {
             // start with the previous reading, in case we don't have a
             // clear result on this frame
             int znew = z;
             if (plungerSensor->highResScan(pos))
             {
-                // We got a new reading.  If we're in calibration mode, use it
+                // We have a new reading.  If we're in calibration mode, use it
                 // to figure the new calibration, otherwise adjust the new reading
                 // for the established calibration.
                 if (calBtnState == 3)
@@ -3018,7 +3021,7 @@
                         cfg.plunger.cal.max = pos;
                         
                     // normalize to the full physical range while calibrating
-                    znew = int(round(float(pos)/plungerSensor->npix * JOYMAX));
+                    znew = int(round(pos * JOYMAX));
                 }
                 else
                 {
@@ -3032,8 +3035,10 @@
                     // values represent travel in the push direction.
                     if (pos > cfg.plunger.cal.max)
                         pos = cfg.plunger.cal.max;
-                    znew = int(round(float(pos - cfg.plunger.cal.zero)
-                        / (cfg.plunger.cal.max - cfg.plunger.cal.zero + 1) * JOYMAX));
+                    znew = int(round(
+                        (pos - cfg.plunger.cal.zero)
+                        / (cfg.plunger.cal.max - cfg.plunger.cal.zero) 
+                        * JOYMAX));
                 }
             }
 
@@ -3049,7 +3054,7 @@
                 // The plunger has moved forward since the previous report.
                 // Watch it for a few more ms to see if we can get a stable
                 // new position.
-                int pos0;
+                float pos0;
                 if (plungerSensor->lowResScan(pos0))
                 {
                     int pos1 = pos0;
@@ -3058,16 +3063,15 @@
                     while (tw.read_ms() < 6)
                     {
                         // read the new position
-                        int pos2;
+                        float pos2;
                         if (plungerSensor->lowResScan(pos2))
                         {
                             // If it's stable over consecutive readings, stop looping.
-                            // (Count it as stable if the position is within about 1/8".
-                            // pos1 and pos2 are reported in pixels, so they range from
-                            // 0 to npix.  The overall travel of a standard plunger is
-                            // about 3.2", so we have (npix/3.2) pixels per inch, hence
-                            // 1/8" is (npix/3.2)*(1/8) pixels.)
-                            if (abs(pos2 - pos1) < int(plungerSensor->npix/(3.2*8)))
+                            // Count it as stable if the position is within about 1/8".
+                            // The overall travel of a standard plunger is about 3.2", 
+                            // so on our normalized 0.0..1.0 scale, 1.0 equals 3.2",
+                            // thus 1" = .3125 and 1/8" = .0391.
+                            if (fabs(pos2 - pos1) < .0391f)
                                 break;
         
                             // If we've crossed the rest position, and we've moved by
@@ -3083,8 +3087,7 @@
                             // to the first reading of the loop - we don't require the
                             // threshold motion over consecutive readings, but any time
                             // over the stability wait loop.
-                            if (pos1 < cfg.plunger.cal.zero
-                                && abs(pos2 - pos0) > int(plungerSensor->npix/(3.2*8)))
+                            if (pos1 < cfg.plunger.cal.zero && fabs(pos2 - pos0) > .0391f)
                             {
                                 firing = 1;
                                 break;
--- a/nullSensor.h	Sat Feb 06 20:21:48 2016 +0000
+++ b/nullSensor.h	Sun Feb 07 03:07:11 2016 +0000
@@ -14,8 +14,8 @@
     PlungerSensorNull() { }
     
     virtual void init() { }
-    virtual bool lowResScan(int &pos) { return false; }
-    virtual bool highResScan(int &pos) { return false; }
+    virtual bool lowResScan(float &pos) { return false; }
+    virtual bool highResScan(float &pos) { return false; }
 };
 
 #endif /* NULLSENSOR_H */
--- a/plunger.h	Sat Feb 06 20:21:48 2016 +0000
+++ b/plunger.h	Sun Feb 07 03:07:11 2016 +0000
@@ -15,48 +15,23 @@
     PlungerSensor() { }
     virtual ~PlungerSensor() { }
     
-    // Number of "pixels" in the sensor's range.  We use the term "pixels"
-    // here for historical reasons, namely that the first sensor we implemented
-    // was an imaging sensor that physically sensed the plunger position as
-    // a pixel coordinate in the image.  But it's no longer the right word,
-    // since we support sensor types that have nothing to do with imaging.
-    // Even so, the function this serves is still applicable.  Abstractly,
-    // it represents the physical resolution of the sensor in terms of
-    // the number of quanta over the full range of travel of the plunger.
-    // For sensors that inherently quantize the position reading at the 
-    // physical level, such as imaging sensors and quadrature sensors, 
-    // this should be set to the total number of position steps over the 
-    // range of travel.  For devices with physically analog outputs, such 
-    // as potentiometers or LVDTs, the reading still has to be digitized 
-    // for us to be able to work with it, which means it has to be turned
-    // into a value that's fundamentally an integer.  But this happens in
-    // the ADC, so the quantization scale is hidden in the mbed libraries.
-    // The normal KL25Z ADC configuration is 16-bit quantization, so the
-    // quantization factor is usually 65535.  But you might prefer to set
-    // this to the joystick maximum so that there are no more rounding
-    // errors in scale conversions after the point of initial conversion.
-    //
-    // IMPORTANT!  This value MUST be initialized in the constructor for
-    // each concrete subclass.
-    int npix;
-         
     // Initialize the physical sensor device.  This is called at startup
     // to set up the device for first use.
     virtual void init() = 0;
 
     // Take a high-resolution reading.  Sets pos to the current position,
-    // on a scale from 0 to npix:  0 is the maximum forward plunger position,
-    // and npix is the maximum retracted position, in terms of the sensor's
+    // on a scale from 0.0 to 1.0:  0.0 is the maximum forward plunger position,
+    // and 1.0 is the maximum retracted position, in terms of the sensor's
     // extremes.  This is a raw reading in terms of the sensor range; the
     // caller is responsible for applying calibration data and scaling the
     // result to the the joystick report range.
     //
     // Returns true on success, false on failure.  Return false if it wasn't
     // possible to take a good reading for any reason.
-    virtual bool highResScan(int &pos) = 0;
+    virtual bool highResScan(float &pos) = 0;
 
     // Take a low-resolution reading.  This reports the result on the same
-    // 0..npix scale as highResScan().  Returns true on success, false on
+    // 0.0 to 1.0 scale as highResScan().  Returns true on success, false on
     // failure.
     //
     // The difference between the high-res and low-res scans is the amount 
@@ -67,7 +42,7 @@
     // The distinction is for the benefit of sensors that need significantly
     // longer to read at higher resolutions, such as image sensors that have
     // to sample pixels serially.
-    virtual bool lowResScan(int &pos) = 0;
+    virtual bool lowResScan(float &pos) = 0;
         
     // Send an exposure report to the joystick interface.  This is specifically
     // for image sensors, and should be omitted by other sensor types.  For
--- a/potSensor.h	Sat Feb 06 20:21:48 2016 +0000
+++ b/potSensor.h	Sun Feb 07 03:07:11 2016 +0000
@@ -23,20 +23,9 @@
     
     virtual void init() 
     {
-        // The potentiometer doesn't have pixels, but we still need an
-        // integer range for normalizing our digitized voltage level values.
-        // The number here is fairly arbitrary; the higher it is, the finer
-        // the digitized steps.  A 40" 1080p HDTV has about 55 pixels per inch
-        // on its physical display, so if the on-screen plunger is displayed
-        // at roughly the true physical size, it's about 3" on screen or about
-        // 165 pixels.  So the minimum quantization size here should be about
-        // the same.  For the pot sensor, this is just a scaling factor, 
-        // so higher values don't cost us anything (unlike the CCD, where the
-        // read time is proportional to the number of pixels we sample).
-        npix = 4096;
     }
     
-    virtual bool highResScan(int &pos)
+    virtual bool highResScan(float &pos)
     {
         // Take a few readings and use the average, to reduce the effect
         // of analog voltage fluctuations.  The voltage range on the ADC
@@ -47,21 +36,21 @@
         // So 1.5% noise is big enough to be visible in the joystick
         // reports.  Averaging several readings should help smooth out
         // random noise in the readings.
-        pos = int((pot.read() + pot.read() + pot.read())/3.0 * npix);
+        //
+        // Readings through the standard AnalogIn class take about 30us
+        // each, so 5 readings is about 150us.  This is plenty fast enough
+        // for even a low-res scan.
+        pos = (pot.read() + pot.read() + pot.read() + pot.read() + pot.read())/5.0;
         return true;
     }
     
-    virtual bool lowResScan(int &pos)
+    virtual bool lowResScan(float &pos)
     {
-        // Use an average of several readings.  Note that even though this
-        // is nominally a "low res" scan, we can still afford to take an
-        // average.  The point of the low res interface is to speed things
-        // up for the image sensor types, which have a large number of
-        // analog samples to read.  In our case, we only have the one
-        // input to sample, so our normal scan is already so fast that
-        // there's no need to do anything different here.
-        pos = int((pot.read() + pot.read() + pot.read())/3.0 * npix);
-        return true;
+        // Since we have only one analog input to sample, our read time is
+        // very fast compared to the image sensor alternatives, so there's no
+        // need to do anything different for a faster low-res scan.  Simply
+        // take a normal high-res reading.
+        return highResScan(pos);
     }
         
 private: