Click here to Skip to main content
Click here to Skip to main content

A Basic iButton Interface

, 7 Mar 2003
Rate this:
Please Sign up or sign in to vote.
An Example Of How To Interface To Dallas Semiconductor's iButton And 1-Wire Network

Table Of Contents

Introduction
What Is An iButton
How Is It Used Here
iButton Code Encryption
NIC Address Encryption
License Time Encryption

System Requirements
Polling
Notification
Contention

The Button Interface Class
Driver Initialization
Session Startup And Termination
Obtaining A List Of 1-Wire Devices
Initiating The Worker Thread
Pausing And Resuming The Worker Thread
Handling Notification Messages In The Application
Device Selection
NVRAM Read/Write Functions
NVRAM I/O During Device Monitoring
The Worker Thread
CDialog Notification Handler

Conclusion

Sample Image - IButtonInterface.jpg

Introduction

First off, hopefully no one is offended with the content of this article! The intention of this article is to demonstrate the use of an interesting technology in an even more interesting, and legal (in most states), industry. In many ways, this industry is no more and no less corrupt than any other form of commerce (think of Enron). Because it is so closely monitored by the government and special interest groups, it may be even more accountable than most.

In the adult entertainment industry, tracking cash transactions is a dubious process. My client has resolved this problem by, in part, using an interesting little device known as an "iButton", manufactured by Dallas Semiconductor (the other half of the equation involves custom hardware distributed throughout the club utilizing a proprietary communication protocol over Ethernet). Using this device, a computer system can track dances, money deposited into bill acceptors, authorize door access, etc. Installed in dance clubs across the country, the system has, in many cases, doubled the revenue of the club because dance income is now accurately tracked. To see a system in action near you...

As mentioned, cash transaction monitoring is complicated. There are two cash transaction models that are supported--"debit account" and "credit account". The following diagrams the difference in these two models. Depending on the club requirements, one or the other or both methods are used.

Credit Account:

Debit Account:

In both cases, this method works only when a method exists for activating the booth, window, or other device that initiates a timed interaction between the entertainer and the customer. In cases where there is direct contact between the entertainer and the customer, a booth is used with an activation light. This still requires checkers, which are people that roam around the club all the time making sure that an entertainer is not in a booth with a customer without activation. There is also extensive video surveillance in most clubs that is constantly being monitored and taped. Interestingly, VHS tape is still used instead of fancy digital recorders. The reason, as explained to me, is that the police will take only the tape for evidence, whereas if you have a digital recorder, they'll take the whole box.

Additional features of the system include being able to charge different amounts based on the entertainer (as identified by the iButton) and the activation point (booth, type of booth, shower, etc). This is all possible because the of unique ID of the entertainer's iButton.

What Is An iButton?

An iButton is like a snowflake. No two are alike. A unique 8 byte (64 bit) code is written into a Read Only Memory (ROM) of each device at the time of manufacture. Besides the ROM, there are several flavors of iButtons, many of which include non-volatile random-access-memory (NVRAM), timer functions, and real-time clocks. Interesting devices include a decoder ring, Java interpreter, and temperature sensor. The devices are addressed using a "1-wire" network, which is a network protocol designed by Dallas Semiconductor to support iButton interfacing.

For more information on iButtons and to download the iButton SDK, go here: www.ibutton.com

In this article, I will illustrate:

  • reading the ROM code
  • reading and writing NVRAM pages
  • 1-wire polling via a worker thread
  • custom dialog notifications
  • iButton debouncing

This code is applicable for all iButtons supporting NVRAM, such as the DS1994.

How Is It Used Here?

In the software utilized by my client, an iButton with NVRAM is used as a copy protection mechanism and license renewal service. The encryption algorithm used is based on the public domain "Blowfish" algorithm. There is a CP article on the subject here. The code that validates the encryption is itself encrypted, and because it is written in a proprietary script language (ooh, opportunity to plug the AAL), it is difficult to determine the exact locations where the validation tests are made, since this is actually generic code used throughout the application. (Go ahead Bill, give it a try).

iButton Code Encryption

First, an encrypted version of the iButton code itself is written into NVRAM. This protects the system from someone with moderate ability from copying the authorization from one iButton to another. Because iButtons are unique, the decrypted iButton code of one button will not be equal to the code of the other button.

NIC Address Encryption

Secondly, the computer's NIC address (not the IP address) is encrypted and stored in the iButton. This prevents the software from operating on a computer for which it is not registered to operate. Besides preventing illegal copies, it also ensures that there is some control over the computer system that is operating the software. In almost all cases, a turnkey system is sold to the customer.

License Time Encryption

The software is typically licensed to a club for a certain number of hours of operation. This ensures a continuing source of revenue, and in some cases where the customer doesn't pay on time, it ensures that the system is shut down after a certain amount of time. Yes, in this industry people like to hold on to their cash, and methods like this need to be used to ensure that people pay for their systems. The licensed hours of operation is encrypted and stored in the iButton. During software operation, this value is decremented frequently. This approach was used instead of the iButton's timer features to ensure that time is decremented only while the software is actually in operation.

System Requirements

In addition to identifying transactions taking place in the club, the iButton is used to quickly select the entertainer from list boxes and combo boxes during the cash out and income statement reports (among other places). To support this capability, several challenges exist, some of which have nothing to do with iButtons.

Polling

The 1-wire network is a polled network. It does not alert the system when devices are added or removed from the network. Therefore, a thread is utilized to monitor the devices on the network. There are two complications that exist. One is that the 1-wire drivers consume a considerable amount of CPU resources. Polling a network with two devices every 100ms on a 1.6Ghz P4 results in a CPU utilization approaching 50%. This is unacceptable for continuous operation, and therefore requires a mechanism to activate the polling only when required.

Secondly, because a person actually connects the iButton to the network using a simple "reader" (seeing picture), the iButton can appear and disappear several times within a short (500ms or less) period of time. One of the artifacts of this is that the ordering of the devices on the network can change. This caused problems in the initial implementation of this system because I was expecting the licensing iButton (which is physically mounted in a holder that attaches to the parallel port--the holder has it's own unique address, by the way) to always appear first in the device list. Alas, this is not necessarily the case. To compensate for this problem, a simple debounce system is implemented. This mechanism provides an immediate notification when an iButton is added to the network, but holds off on the "iButton removed" from network notification for 500ms. This prevents multiple "new button" notifications from being issued to the user interface.

Notification

The next problem is how to get notification of an event to a dialog that contains the list box or combo box. In the example provided here, a custom message notification scheme is used and the PreTranslateMessage method of CDialog must be overridden. In the actual system in use, a message is placed in an internal queue which is processed during the application's OnIdle() function. Because this function is not active when a modal dialog is present, the dialog implements a timer which issues a message and activates the same OnIdle processing. For those interested, the reason for this implementation is that is also serves other purposes, such as enabling processing of other messages coming in from the club's network. Because Access is used as the database, all database transactions must occur in the application thread. There. That's the technical reason.

Contention

Contention between the polling routine worker thread and any application thread I/O is resolved using semaphores.

The Button Interface Class

This class is a framework for adding additional functionality that the iButton family supports. It currently reads the ROM code, reads and writes NVRAM pages, and provides a polling mechanism for notification of device changes on the network. The class definition is:

class ButtonInterface
{
private:

    static UINT ButtonInterface::MonitorThreadStartup(void* v);

public:

    ButtonInterface(void);
    virtual ~ButtonInterface(void);

    bool StartSession(void);
    void EndSession(void);

    void StartMonitor(int msSleep=100, int debounceTime=500);
    void EndMonitor(void) {endMonitor=true;};

    void PauseMonitor(void) {pauseMonitor=true;};
    void ResumeMonitor(void) {pauseMonitor=false;};

    void BeginIO(void);
    void EndIO(void);

    bool SelectDevice(CString romCode);
    bool SelectDeviceStrong(CString romCode);

    bool ReadPage(int page, unsigned char* data, int len);
    bool WritePage(int page, unsigned char* data, int len);

    bool IsMonitorActive(void) {return monitorActive;};

    UINT GetNotificationAdded(void) {return notificationAdded;};
    UINT GetNotificationRemoved(void) {return notificationRemoved;};

    int GetButtonList(CString* codes);

protected:

    void Monitor(void);
    void PostNotification(UINT msg, CString code);

    struct ButtonInfo
    {
        ButtonInfo(void)
        {
            touched=false;
            newButton=true;
        }
        LARGE_INTEGER currentSampleTime;
        bool touched;
        bool newButton;
    };

protected:

    // button code to info mapping
    std::map<CString, ButtonInfo> buttonInfo;        
    // notification message data

    std::map<CString, CString*> msgData;
    HINSTANCE hInst;          // library instance
    long hSess;               // session instance
    bool endMonitor;          // end monitor thread flag
    bool pauseMonitor;        // pause monitor thread.
    bool monitorActive;       // worker thread is running
    int sleepTime;            // sample rate in ms
    int debounceTime;         // debounce time, in ms
    CRITICAL_SECTION cs;      // thread blocking
    BYTE stateBuffer[15360];  // internal for TMX interface
    UINT notificationAdded;   // button added notification message
    UINT notificationRemoved; // button removed notification message
    LARGE_INTEGER freq;       // counts per second

private:

    // function pointers to TMX DLL
    short (far pascal* TMReadDefaultPort)
          (short far* portNum, short far* portType);
    long (far pascal* TMExtendedStartSession)
         (short portNum, short portType, void far* enhSessOpt);
    short (far pascal* TMSetup)(long hSess);
    short (far pascal* TMEndSession)(long hSess);

    short (far pascal* TMFirst)(long hSess, void far* stateBuff);
    short (far pascal* TMNext)(long hSess, void far* stateBuff);
    short (far pascal* TMRom)
          (long hSess, void far* stateBuff, short far* ROM);
    short (far pascal* TMAccess)
          (long hSess, void far* stateBuff);
    short (far pascal* TMStrongAccess)
          (long hSess, void far* stateBuff);
    short (far pascal* TMWritePacket)
          (long hSess, void far* stateBuff, short page,
           unsigned char far* data, short len);
    short (far pascal* TMReadPacket)
          (long hSess, void far* stateBuff, short page,
          unsigned char far* data, short len);
    short (far pascal* TMTouchByte)(long hSess, short byte);
    short (far pascal* TMBlockStream)
          (long hSess, unsigned char far* data, short len);
};

Driver Initialization

The driver can be initialized by including a LIB in the application, or by loading a DLL at runtime and determining the interface points programmatically. I have chosen the latter method, as it accommodates updates in the driver without having to recompile because of the dependent LIB. This simplifies updating the clubs with new iButton driver software--in many cases, they can do it themselves. The following code illustrates loading the DLL and acquiring the entry points to the functions required in this implementation of the ButtonInterface object.

ButtonInterface::ButtonInterface(void) :
      hSess(0), endMonitor(false), pauseMonitor(false), monitorActive(false)
{
    InitializeCriticalSection(&cs);
    notificationAdded=RegisterWindowMessage(
          "iButtonNotification_ButtonAdded");
    notificationRemoved=RegisterWindowMessage(
          "iButtonNotification_ButtonRemoved");
    QueryPerformanceFrequency(&freq);

    hInst = LoadLibrary("IBFS32.DLL");

    if (hInst != NULL)
    {
        TMExtendedStartSession=(long (far pascal *)
            (short,short,void far *))
             GetProcAddress(hInst, "TMExtendedStartSession");
        TMReadDefaultPort=(short (far pascal *)
            (short far*, short far*))
            GetProcAddress(hInst, "TMReadDefaultPort");
        TMSetup=(short (far pascal *)(long))
            GetProcAddress(hInst, "TMSetup");
        TMEndSession=(short (far pascal *)(long))
            GetProcAddress(hInst, "TMEndSession");

        TMFirst=(short (far pascal *)(long, void far*))
            GetProcAddress(hInst, "TMFirst");
        TMNext=(short (far pascal *)(long, void far*))
            GetProcAddress(hInst, "TMNext");

        TMRom=(short (far pascal *)(long, void far*, short far*))
            GetProcAddress(hInst, "TMRom");
        TMAccess=(short (far pascal *)(long, void far*))
            GetProcAddress(hInst, "TMAccess");
        TMStrongAccess=(short (far pascal *)(long, void far*))
            GetProcAddress(hInst, "TMStrongAccess");

        TMTouchByte=(short (far pascal *)(long, short))
            GetProcAddress(hInst, "TMTouchByte");
        TMBlockStream=(short (far pascal *)
            (long, unsigned char far*, short))
            GetProcAddress(hInst, "TMBlockStream");

        // nvram devices only
        TMWritePacket=(short (far pascal *)
            (long, void far*, short, unsigned char far*, short))
            GetProcAddress(hInst, "TMWritePacket");
        TMReadPacket=(short (far pascal *)
            (long, void far*, short, unsigned char far*, short))
            GetProcAddress(hInst, "TMReadPacket");
    }
}

ButtonInterface::~ButtonInterface(void)
{
    endMonitor=true;
    while (monitorActive) {};
    FreeLibrary(hInst);
    DeleteCriticalSection(&cs);
}

Session Startup And Termination

All interfacing to the 1-wire network must be performed within a session. The session mechanism locks out other applications from accessing the 1-wire network while the session is active. Dallas Semiconductor recommends that you keep the session open for as short a period as possible so that other applications are not locked out from accessing the network. Because this is a proprietary turnkey system, this is not an issue and the session is maintained for the lifetime of the ButtonInterface object. The following code illustrates initiating and terminating a session, which also automatically terminates the worker thread.

bool ButtonInterface::StartSession(void)
{
    if (hInst != NULL)
    {
        short portNum;
        short portType;
        TMReadDefaultPort(&portNum, &portType);
        hSess=TMExtendedStartSession(portNum, portType, NULL);
        if (hSess != 0)
        {
            // must be called before any non-session functions
            // can be called
            TMSetup(hSess);
        }
    }
    return hSess != 0;
}

 
void ButtonInterface::EndSession(void)
{
    if (hSess > 0)
    {
        endMonitor=true;
        while (monitorActive) {};
        TMEndSession(hSess);
        hSess=NULL;
    }
}

Obtaining A List Of 1-Wire Devices

A list of all 1-wire devices can be obtained in the application thread at any time by calling the GetButtonList method. Note that this method does not affect the flags maintained internally by the ButtonInterface object for determining additions and removals of 1-wire devices. The following code illustrates how the devices on the 1-wire are acquired. This object currently handles a maximum of 32 devices. The ROM codes for each device is returned in an array of CStrings, as opposed to hex values. Note that the ROM code is read out in reverse order from the way it is displayed on the iButton. This corrects the fact that the ROM code is returned from the driver in reverse order.

int ButtonInterface::GetButtonList(CString* codes)
{
    short ret=TMFirst(hSess, stateBuffer);
    int n=0;
    while ( (ret==1) && (n<32) )
    {
        // if MSB of ROM[0] != 0, then write, else read
        short ROM[8]={0, 0, 0, 0, 0, 0, 0, 0};
        TMRom(hSess, stateBuffer, ROM);

        // convert the ROM code into a string
        char s[3]="\0\0";
        CString romCode="";
        for (int i=7; i>=0; i--)
        {
            sprintf(s, "%02X", ROM[i]);
            romCode+=s;
        }
        codes[n]=romCode;
        ++n;
        ret=TMNext(hSess, stateBuffer);
    }
    return n;
}

This code acquires the devices on the network through a straight forward "First" and "Next" iteration. Each byte is converted into an ASCII representation of the hexidecimal value of the byte.

Initiating The Worker Thread

The polling worker thread is initiated with a default 100ms polling interval and a 500ms debounce circuit. Termination sets a flag that causes the thread to exit the next time polling is initiated. Because the fairly inaccurate Sleep function is used, these timing values are approximate only.

...
    void StartMonitor(int msSleep=100, int debounceTime=500);
    void EndMonitor(void) {endMonitor=true;};
...    
void ButtonInterface::StartMonitor(int msSleep, int debounce)
{
    if (hSess > 0)
    {
        sleepTime=msSleep;
        debounceTime=debounce;
        AfxBeginThread(MonitorThreadStartup, this);
    }
}

UINT ButtonInterface::MonitorThreadStartup(void* v)
{
    ButtonInterface* btn=(ButtonInterface*)v;
    btn->Monitor();
    return 0;
}

The worker thread utilizes the same method as the application thread network identification. In addition, it sets and clears various flags to identify new devices, devices that have been removed from the network, and handles device debounce.


Pausing And Resuming The Worker Thread

When paused, the worker thread wakes up at the polling interval and checks only if the thread is to be terminated. It does not perform polling when paused. This is useful for when you simply want to stop polling for performance or other reasons, without going through the process of destroying and recreating the object which will result in reloading the DLL.

...
    void PauseMonitor(void) {pauseMonitor=true;};
    void ResumeMonitor(void) {pauseMonitor=false;};
...

Handling Notification Messages In The Application

The ButtonInterface object establishes its own unique "button added" and "button removed" notifications. These can be determined using two methods defined in the class header file:

...
    UINT GetNotificationAdded(void) {return notificationAdded;};
    UINT GetNotificationRemoved(void) {return notificationRemoved;};
...

Device Selection

Before reading and writing to NVRAM, the specific device must be selected on the 1-wire. To ensure correct selection, the TMAccessStrong driver function must be called with the ROM code of the desired device. If the TMAccess is used, you will not be guaranteed that the desired device is actually on the network (this has been impirically verified). The ButtonInterface class provides two methods for selecting a device. The "weaker" SelectDevice method can be used once the device has been verified to exist on the network using the SelectDeviceStrong method. For further information, read the TMAccess and TMAccessStrong documentation in the iButton SDK.

bool ButtonInterface::SelectDevice(CString romCode)
{
    short ROM[9];
        for (int i=7; i>=0; i--)
    {
        CString hex=romCode.Mid(i*2, 2);
        sscanf(hex, "%02X", &ROM[7-i]);
    }
    TMRom(hSess, stateBuffer, ROM);
    int n=TMAccess(hSess, stateBuffer);
    return n==1;
}
bool ButtonInterface::SelectDeviceStrong(CString romCode)
{
    short ROM[9];
        for (int i=7; i>=0; i--)
    {
        CString hex=romCode.Mid(i*2, 2);
        sscanf(hex, "%02X", &ROM[7-i]);
    }
    TMRom(hSess, stateBuffer, ROM);
    int n=TMStrongAccess(hSess, stateBuffer);
    return n==1;
}

NVRAM Read/Write Functions

The NVRAM page read/write functions are closely tied to the hardware implementation, reading and writing a maximum of 32 bytes per page. This implementation does not support intelligent cross-page boundary detection. While the drivers provide "TMReadPacket" and "TMWritePacket" functions, these functions only support NVRAM devices such as the DS1993. Of course, this information isn't mentioned in the "TMReadPacket" function, only the "TMWritePacket" function, and caused me to spend about 4 hours trying to figure out why I couldn't read or write to a DS1994 device.

The ReadPage method issues a command over the 1-wire network requesting the specified page and then reads out the desired number of bytes, up to the page size, which is 32 bytes.

bool ButtonInterface::ReadPage(int page, unsigned char* data, int len)
{
    if (len > 32) return false;
    TMAccess(hSess, stateBuffer);
    TMTouchByte(hSess, 0xF0);
    TMTouchByte(hSess, (short)((page*32)&0xFF));
    TMTouchByte(hSess, (short)((page*32)>>8));

Similarly, the WritePage sends up to one page size (32 bytes) to the specified page:

bool ButtonInterface::WritePage(int page, unsigned char* data, int len)
{
    if (len > 32) return false;
    // write to scratchpad
    TMAccess(hSess, stateBuffer);
    TMTouchByte(hSess, 0x0F);
    TMTouchByte(hSess, (short)((page*32)&0xFF));
    TMTouchByte(hSess, (short)((page*32)>>8));
    TMBlockStream(hSess, data, (short)len);

    // get target address and ending offset/data status byte
    TMAccess(hSess, stateBuffer);
    TMTouchByte(hSess, 0xAA);
    unsigned char auth[3];
    for (int i=0; i<3; i++)
    {
        auth[i]=(unsigned char)TMTouchByte(hSess, 0xFF);
    }

    // copy scratchpad to memory
    TMAccess(hSess, stateBuffer);
    TMTouchByte(hSess, 0x55);
    for (int i=0; i<3; i++)
    {
        TMTouchByte(hSess, auth[i]);
    }
    return true;
}

Note that the 1-wire network always returns a byte when a byte is placed onto the network. In the case of reading a byte, an 0xFF is placed onto the network. In the case of writing a byte, a non-0xFF byte is placed on the network. Therefore, true binary cannot be stored in NVRAM. Also note that in the write process, the target beginning and ending address is read off of the network after data transfer and must be written back to the network in order to actually commit the data to memory. This can be used to ensure data integrity.

NVRAM I/O During Device Monitoring

In order to avoid conflicts with the device monitoring thread, the application must initiate and terminate an I/O session using the following methods:

void ButtonInterface::BeginIO(void)
{
    EnterCriticalSection(&cs);
}
void ButtonInterface::EndIO(void)
{
    LeaveCriticalSection(&cs);
}

This is not necessary when the monitoring thread is not used.

The Worker Thread

This thread is fairly self explanitory. The debounce feature is the most interesting, and involves timing how long a device has remained "untouched" in the list. Once the device has been "untouched" for the specified time, a notification message is posted to the application thread and the device is removed from the list. This also terminates testing for removed devices, therefore if more than one device is simultaneously removed the notifications will be delayed by the polling rate.

void ButtonInterface::Monitor(void)
{
    CString* codes=new CString[32];
    monitorActive=true;
    while (!endMonitor)
    {
        Sleep(sleepTime);
        if (pauseMonitor)
        {
            continue;
        }
        EnterCriticalSection(&cs);

        // untouch all buttons in list
        std::map<CString, ButtonInfo>::iterator iter=buttonInfo.begin();
        while (iter != buttonInfo.end())
        {
            (*iter).second.touched=false;
            ++iter;
        }

        int numButtons=GetButtonList(codes);

        // touch all found devices
        for (int i=0; i<numButtons; i++)
        {
            CString romCode=codes[i];
            iter=buttonInfo.find(romCode);
            if (iter != buttonInfo.end())
            {
                (*iter).second.touched=true;
                QueryPerformanceCounter(&(*iter).second.currentSampleTime);
            }
            else
            {
                buttonInfo[romCode]=ButtonInfo();
            }
        }

        // Debounce all activity
        // 1.  If a button is new, then issue an add notification immediately
        // 2.  If a button hasn't been "touched" for the debounce time,
        //     then issue a remove notification
        LARGE_INTEGER sampleTime;
        QueryPerformanceCounter(&sampleTime);
        iter=buttonInfo.begin();
        while (iter != buttonInfo.end())
        {
            CString code=(*iter).first;
            ButtonInfo& bi=(*iter).second;
            if (bi.newButton)
            {
                PostNotification(notificationAdded, (*iter).first);
                bi.newButton=false;
            }
            else
            if (bi.touched==false)
            {
                if (sampleTime.QuadPart-bi.currentSampleTime.QuadPart > 
                    freq.QuadPart*debounceTime/1000)
                {
                    PostNotification(notificationRemoved, (*iter).first);
                    buttonInfo.erase(iter);
                    break;
                }
            }
            ++iter;
        }

        LeaveCriticalSection(&cs);
    }
    endMonitor=false;
    delete[] codes;
    monitorActive=false;
}

CDialog Notification Handler

The following code illustrates how the monitor thread notifications are handled in a CDialog derived class. In this example, the button code is either added to a list control list or removed. Note that the ROM code CString* is deallocated by the message handler.

BOOL CButtonTestDlg::PreTranslateMessage(MSG* msg)
{
    BOOL ret;
    if (msg->message==btn->GetNotificationAdded())
    {
        CString* code=(CString*)msg->lParam;
        buttonList.ButtonPresent(code, true);
        delete code;
        ret=true;
    }
    else
    if (msg->message==btn->GetNotificationRemoved())
    {
        CString* code=(CString*)msg->lParam;
        buttonList.ButtonPresent(code, false);
        delete code;
        ret=true;
    }
    else
    {
        ret=CDialog::PreTranslateMessage(msg);
    }
    return ret;
}

Conclusion

In conclusion, the iButton is an excellent solution for tracking monetary transactions. In the particular case of my client's system, all funds are maintained in the server that monitors club transactions. In other applications, the funds can be maintained directly in iButton NVRAM, providing autonomous support for authorization requests and funds crediting/debiting. There are numerous other interesting applications as well!

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

About the Author

Marc Clifton

United States United States
Marc is the creator of two open source projets, MyXaml, a declarative (XML) instantiation engine and the Advanced Unit Testing framework, and Interacx, a commercial n-tier RAD application suite.  Visit his website, www.marcclifton.com, where you will find many of his articles and his blog.
 
Marc lives in Philmont, NY.

Comments and Discussions

 
GeneralHelp! PinmemberMarkBrock1-May-08 0:08 
GeneralRe: Help! PinprotectorMarc Clifton1-May-08 0:51 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web02 | 2.8.140718.1 | Last Updated 8 Mar 2003
Article Copyright 2003 by Marc Clifton
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid