Click here to Skip to main content
Click here to Skip to main content
Add your own
alternative version
Go to top

Undocumented Visual C++

, 17 Sep 2000
Spelunking in the Badlands of MSDEV
//*******************************************************************************
// COPYRIGHT NOTES
// ---------------
// You may use, compile or redistribute this source as part of your application 
// for free. You may not redistribute it as a part of a software development 
// library without the agreement of the author. If the sources are 
// distributed along with the application, you should leave the original 
// copyright notes in the source code without any changes.
// This code can be used WITHOUT ANY WARRANTIES on your own risk.
// 
// Nick Hodapp
// nhodapp@codeveloper.com
//
//*******************************************************************************

// DlgClasses.cpp : implementation file
//

#include "stdafx.h"
#include "DlgClasses.h"

#ifdef _DEBUG
#define new DEBUG_NEW
#undef THIS_FILE
static char THIS_FILE[] = __FILE__;
#endif

/////////////////////////////////////////////////////////////////////////////
// CDlgClasses dialog

CDlgClasses::CDlgClasses(CWnd* pParent /*=NULL*/)
:	CDialog(CDlgClasses::IDD, pParent)
{
	//{{AFX_DATA_INIT(CDlgClasses)
	m_iMode = 0;
	m_strItemCount = _T("");
	m_strCode = _T("");
	//}}AFX_DATA_INIT

	m_classView.m_pmapClasses = &m_mapClassHierarchy;
}


void CDlgClasses::DoDataExchange(CDataExchange* pDX)
{
	CDialog::DoDataExchange(pDX);
	//{{AFX_DATA_MAP(CDlgClasses)
	DDX_Control(pDX, IDC_ITEM_CT, m_staticCount);
	DDX_Control(pDX, IDC_EDIT_CODE, m_editCode);
	DDX_Radio(pDX, IDC_MODE_REPORT, m_iMode);
	DDX_Text(pDX, IDC_ITEM_CT, m_strItemCount);
	DDX_Text(pDX, IDC_EDIT_CODE, m_strCode);
	//}}AFX_DATA_MAP
}

BEGIN_MESSAGE_MAP(CDlgClasses, CDialog)
	//{{AFX_MSG_MAP(CDlgClasses)
	ON_BN_CLICKED(IDC_MODE_REPORT, OnModeChange)
	ON_WM_SIZE()
	ON_WM_CREATE()
	ON_NOTIFY_EX(GVN_SELCHANGED, IDC_GRID, OnSelChanged )
	ON_BN_CLICKED(IDC_MODE_GRAPH, OnModeChange)
	//}}AFX_MSG_MAP
END_MESSAGE_MAP()

void CDlgClasses::RecalcLayout()
{
	//
	// Quick & Dirty layout code.
	// Ugly, yes.. But at least it's functional.
	//
	if (!GetDlgItem(IDOK))
		return;

	CRect rclClient; GetClientRect(&rclClient);
	CRect rclBtn, rclCtl; 

	GetDlgItem(IDOK)->GetWindowRect(&rclBtn);
	::MapWindowPoints(NULL, m_hWnd, (POINT*)&rclBtn, 2);
	GetDlgItem(IDOK)->SetWindowPos(NULL, rclClient.Width()-(rclBtn.Width()+10), rclBtn.top, 0, 0, SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOSIZE);

	GetDlgItem(IDCANCEL)->GetWindowRect(&rclBtn);
	::MapWindowPoints(NULL, m_hWnd, (POINT*)&rclBtn, 2);
	GetDlgItem(IDCANCEL)->SetWindowPos(NULL, rclClient.Width()-(rclBtn.Width()+10), rclBtn.top, 0, 0, SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOSIZE);
	
	GetDlgItem(IDC_MODE_REPORT)->GetWindowRect(&rclBtn);
	::MapWindowPoints(NULL, m_hWnd, (POINT*)&rclBtn, 2);
	GetDlgItem(IDC_MODE_REPORT)->SetWindowPos(NULL, rclClient.Width()-(rclBtn.Width()+10), rclBtn.top, 0, 0, SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOSIZE);

	GetDlgItem(IDC_MODE_GRAPH)->GetWindowRect(&rclBtn);
	::MapWindowPoints(NULL, m_hWnd, (POINT*)&rclBtn, 2);
	GetDlgItem(IDC_MODE_GRAPH)->SetWindowPos(NULL, rclClient.Width()-(rclBtn.Width()+10), rclBtn.top, 0, 0, SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOSIZE);

	GetDlgItem(IDOK)->GetWindowRect(&rclBtn);
	::MapWindowPoints(NULL, m_hWnd, (POINT*)&rclBtn, 2);
	rclClient.right = rclBtn.left - 10;
	m_classView.SetWindowPos(NULL, 0, 0, rclClient.Width(), rclClient.Height(), SWP_NOZORDER | SWP_NOACTIVATE);

	GetDlgItem(IDOK)->GetWindowRect(&rclBtn);
	::MapWindowPoints(NULL, m_hWnd, (POINT*)&rclBtn, 2);
	rclClient.right = rclBtn.left - 10;
	m_grid.SetWindowPos(NULL, 10, 10, (rclClient.Width()/2)-20, rclClient.Height()-25, SWP_NOZORDER | SWP_NOACTIVATE);

	GetDlgItem(IDOK)->GetWindowRect(&rclBtn);
	::MapWindowPoints(NULL, m_hWnd, (POINT*)&rclBtn, 2);
	rclClient.right = rclBtn.left - 10;
	m_editCode.SetWindowPos(NULL, (rclClient.Width()/2), 10, (rclClient.Width()/2), rclClient.Height()-25, SWP_NOZORDER | SWP_NOACTIVATE);

	m_grid.GetWindowRect(&rclCtl);
	::MapWindowPoints(NULL, m_hWnd, (POINT*)&rclCtl, 2);
	m_staticCount.SetWindowPos(NULL, 10, rclCtl.bottom+2, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}

bool CDlgClasses::GetRuntimeInfo(void* pvObj, CRuntimeClass** pprc)
{
	CObject* pObj = (CObject*)pvObj;

	// We want to retrieve the MFC runtime type info pointer from this potential CObject pointer.
	//	The problem is, the pointer may not actually point to a CObject.
	//	If we just blindly cast and call pObj->GetRuntimeClass(), 
	//	we're likely to try executing some non-code, or perhaps we'll call some
	//	other virtual function like a destructor.
	//	Since this would be bad, we attempt to check the signature of the function, to 
	//	see if it "looks" like GetRuntimeClass(), which I've found to be simply a 
	//	mov instruction followed by a ret instruction.
	//
	//	I won't guarantee that this code will always work.  If it starts failing, you fix it.

	__try	// hope that SEH works, and hope we don't need it!
	{
		// validate address:
		if (FALSE == AfxIsValidAddress(pObj, sizeof(CObject), FALSE))
			return false;

		// check to make sure the VTable pointer is valid
		void** vfptr = (void**)*(void**)pObj;
		if (!AfxIsValidAddress(vfptr, sizeof(void*), FALSE))
			return false;

		// validate the first vtable entry
		void* pvtf0 = vfptr[0];
		if (IsBadCodePtr((FARPROC)pvtf0))
			return false;

		// look at the code for this function.  validate it is a mov and ret
		BYTE arrOpcodes[6];
		memcpy(arrOpcodes, pvtf0, 6);

		// prepare yourself, this gets ugly.  
		// if you don't understand whats going on then leave well enough alone.
		// don't get all upset about it either.  
		if (arrOpcodes[0] == 0xFF && arrOpcodes[1] == 0x25) // jmp
		{
			void** pvAddr = *(void***)&(arrOpcodes[2]);
			if (IsBadCodePtr((FARPROC)*pvAddr))
				return false;
			memcpy(arrOpcodes, *pvAddr, 6);
		}

		if (arrOpcodes[0] != 0xB8 || arrOpcodes[5] != 0xC3) // mov, ret
			return false;

		// ok, it looks like a likely candiate for a "real" GetRuntimeClass().
		// go ahead and call it.
		*pprc = pObj->GetRuntimeClass();
		ASSERT(AfxIsValidAddress((*pprc)->m_lpszClassName, sizeof(char*), FALSE));
		ASSERT((*pprc)->m_lpszClassName[0] == 'C'); // lame, but most classes will begin with 'C'
	}
	__except (1)
	{
		return false;
	}

	return true;
}

void CDlgClasses::Struct(DWORD dwUnique, CRuntimeClass* prc, DWORD dwOffset, const char* cpszType, bool bPointer, long lTypeSize, DWORD& dwUnknownCt)
{
	// determine where we are, derivation-wise:
	ASSERT(dwOffset < prc->m_nObjectSize);
	CRuntimeClass* prcParent = prc->m_pfnGetBaseClass();
	while (prcParent && dwOffset < prcParent->m_nObjectSize)
	{
		prc = prcParent;
		prcParent = prcParent->m_pfnGetBaseClass();
	}

	// find the class entry:
	M_Class::iterator it = m_mapClassInfo.find(CString(prc->m_lpszClassName));
	if (it == m_mapClassInfo.end()) return;
	SClass& cls = it->second;
	
	// only one 
	if (cls.m_dwUnique != 0 && cls.m_dwUnique != dwUnique)
	{
		dwUnknownCt = 0;
		return;
	}
	cls.m_dwUnique = dwUnique;

	// struct begin?
	if (dwOffset == 0 || (prcParent && dwOffset == prcParent->m_nObjectSize))
		StructBegin(cls);

	// unknown bytes?
	if (dwUnknownCt && cpszType)
	{
		StructMember(cls, "BYTE", false, dwUnknownCt);
		dwUnknownCt = 0;
	}

	// struct member:
	if (cpszType)
		StructMember(cls, cpszType, bPointer, 1);

	// struct end?
	if (dwOffset + lTypeSize >= prc->m_nObjectSize-1)
	{
		if (dwUnknownCt && NULL == cpszType)
		{
			StructMember(cls, "BYTE", false, dwUnknownCt);
			dwUnknownCt = 0;
		}

		StructEnd(cls);
	}
}

void CDlgClasses::StructBegin(SClass& cls)
{
	// done before?
	if (cls.m_strCode.GetLength())
		return;

	// get parent class name:
	CString strDerivation;
	if (cls.m_strParent.GetLength())
		strDerivation.Format(": public %s", cls.m_strParent);

	// format a struct:
	CString strObj;
	strObj = cls.m_strClass;
	if (strObj.GetAt(0) == 'C')
		strObj = strObj.Mid(1);
	cls.m_strCode.Format("typedef struct tag%s %s\r\n{\r\n", strObj, strDerivation);
}

void CDlgClasses::StructMember(SClass& cls, const char* cpszType, bool bPointer, long lCount)
{
	// format type name
	CString strType = cpszType;
	if (bPointer)
		strType += "*";

	// format variable name
	CString strVar = cpszType;
	if (strVar.GetAt(0) == 'C')
		strVar = strVar.Mid(1);
	if (bPointer)
		strVar.Insert(0, 'p');

	if (lCount > 1)
	{
		CString strArr; strArr.Format("[%ld]", lCount);
		strVar.Insert(0, "arr");
		strVar += strArr;
	}

	// format member:
	CString strMember;
	if (strType.GetLength() < 21)
		strMember.Format("   %-21.21sm_%s;\r\n\r\n", strType, strVar);
	else
		strMember.Format("   %s\r\n%24.24sm_%s;\r\n\r\n", strType, " ", strVar);

	// append:
	cls.m_strCode += strMember;
}

void CDlgClasses::StructEnd(SClass& cls)
{
	// close the struct
	CString strObj = cls.m_strClass;
	if (strObj.GetAt(0) == 'C')
		strObj = strObj.Mid(1);
	CString strCode;
	strCode.Format("} S%s;\r\n\t", strObj);
	cls.m_strCode += strCode;
}

void CDlgClasses::AnalyzeObject(CObject* pObj)
{
	// validate
	if (NULL == pObj || FALSE == AfxIsValidAddress(pObj, sizeof(CObject), FALSE))
		return;

	static DWORD dwUnique = 0;
	DWORD dwLocal = ++dwUnique;

	// look for sub-objects:
	CRuntimeClass* prc = pObj->GetRuntimeClass();
	BYTE *pbBegin	= reinterpret_cast<BYTE*>(pObj),
		  *pbCur		= reinterpret_cast<BYTE*>(pObj),
		  *pbEnd		= pbCur+prc->m_nObjectSize;

	DWORD dwMember(0),
			dwRunLen(0),
			dwInc(0);

	// "typedef struct tagObjectName : public ParentName {"
	while (pbCur < pbEnd)
	{
		CRuntimeClass* prcSub;
		if ((pbCur-pbBegin > 0) && GetRuntimeInfo(pbCur, &prcSub))	// embedded object
		{
			// document contained object:
			Struct(dwLocal, prc, pbCur-pbBegin, prcSub->m_lpszClassName, false, prcSub->m_nObjectSize, dwRunLen);

			// catalog this contained object:
			InsertClass(this, prcSub, (CObject*)pbCur);

			// skip past the contained object:
			dwInc = prcSub->m_nObjectSize;
		}
		else
		if (GetRuntimeInfo((void*)*(long*)pbCur, &prcSub)) // embedded pointer to object
		{
			// document contained object pointer:
			Struct(dwLocal, prc, pbCur-pbBegin, prcSub->m_lpszClassName, true, 4, dwRunLen);

			// catalog this contained object pointer:
			InsertClass(this, prcSub, (CObject*)*(DWORD*)pbCur);

			// skip past the contained pointer:
			dwInc = 4;
		}
		else
		{
			// continue searching:
			dwInc = 1;
			dwRunLen++;

			Struct(dwLocal, prc, pbCur-pbBegin, NULL, false, 0, dwRunLen);
		}

		pbCur += dwInc;
	}

	if (dwRunLen)
		Struct(dwLocal, prc, pbCur-pbBegin, NULL, false, 0, dwRunLen);
}

BOOL CDlgClasses::EnumClasses(HWND hwnd, LPARAM lParam)
{
	CDlgClasses* pThis = (CDlgClasses*)lParam;

	CWnd* pWnd = CWnd::FromHandle(hwnd);
	if (pWnd)
	{
		pThis->InsertClass(pThis, pWnd->GetRuntimeClass(), pWnd);

		EnumChildWindows(pWnd->GetSafeHwnd(), EnumClasses, lParam);
	}

	return TRUE;
}

void CDlgClasses::InsertClassRow(CRuntimeClass* prc, unsigned char ucLevel)
{
	// class
   int iRow = m_grid.InsertRow(prc->m_lpszClassName);
	m_vecLevels.push_back(ucLevel & 0x7F);

	// total byte count:
	CString strCell;
	strCell.Format("%ld", prc->m_nObjectSize);
	m_grid.GetCell(iRow, 1)->SetText(strCell);

	// non-derived byte count:
	CRuntimeClass* prcParent = (prc->m_pfnGetBaseClass)();
	if (prcParent)
	{
		strCell.Format("%ld", prc->m_nObjectSize - prcParent->m_nObjectSize);
		m_grid.GetCell(iRow, 2)->SetText(strCell);

		InsertClassRow(prcParent, ucLevel+1);
	}

	// schema
	strCell.Format("%ld", prc->m_wSchema);
	m_grid.GetCell(iRow, 3)->SetText(strCell);
}

void CDlgClasses::InsertClass(CDlgClasses* pThis, CRuntimeClass* pClass, CObject* pObj)
{
	// must have class!
	if (NULL == pClass) 
		return;

	// inserted yet?
	CString strClass = pClass->m_lpszClassName;
	M_Class::iterator it = m_mapClassInfo.find(strClass);
	if (it != m_mapClassInfo.end() && it->second.m_strCode != "") 
		return;
	SClass& cls = it->second;
	
	// parent name
	CString strParent;
	CRuntimeClass* pParentClass = (pClass->m_pfnGetBaseClass)();
	if (pParentClass) 
		strParent = pParentClass->m_lpszClassName;

	// store
	if (true == pThis->m_mapClassInfo.insert(M_Class::value_type(strClass, SClass(strClass, strParent, pClass))).second)
		pThis->m_mapClassHierarchy.insert(M_String::value_type(strParent, strClass));

	// insert parent
	InsertClass(pThis, (pClass->m_pfnGetBaseClass)());

	// analyze the object itself for contained data members:
	if (!cls.m_bAnalyzed && pObj)
	{
		cls.m_bAnalyzed = true;
		AnalyzeObject(pObj);
	}

	// traverse any CPtrLists
	if (pObj && pObj->IsKindOf(RUNTIME_CLASS(CPtrList)))
	{
		CPtrList* pList = (CPtrList*)pObj;
		for (POSITION pos = pList->GetHeadPosition() ; pos ; )
		{
			void* pvObj = pList->GetNext(pos);

			CRuntimeClass* prc;
			if (GetRuntimeInfo(pvObj, &prc))
				InsertClass(pThis, prc, (CObject*)pvObj);
		}
	}
	else

	// traverse any CObLists
	if (pObj && pObj->IsKindOf(RUNTIME_CLASS(CObList)))
	{
		CObList* pList = (CObList*)pObj;
		for (POSITION pos = pList->GetHeadPosition() ; pos ; )
		{
			CObject* pObj = pList->GetNext(pos);

			InsertClass(pThis, pObj->GetRuntimeClass(), pObj);
		}
	}
	else


	// traverse any CPtrArrays
	if (pObj && pObj->IsKindOf(RUNTIME_CLASS(CPtrArray)))
	{
		CPtrArray* pArr = (CPtrArray*)pObj;
		for (DWORD dw = 0 ; dw < pArr->GetSize() ; dw++)
		{
			void* pvObj = pArr->GetAt(dw);

			CRuntimeClass* prc;
			if (GetRuntimeInfo(pvObj, &prc))
				InsertClass(pThis, prc, (CObject*)pvObj);
		}
	}
	else

	// traverse any CObArrays
	if (pObj && pObj->IsKindOf(RUNTIME_CLASS(CObArray)))
	{
		CObArray* pArr = (CObArray*)pObj;
		for (DWORD dw = 0 ; dw < pArr->GetSize() ; dw++)
		{
			CObject* pObj = pArr->GetAt(dw);
			InsertClass(pThis, pObj->GetRuntimeClass(), pObj);
		}
	}
}

void CDlgClasses::Refresh()
{
	CWaitCursor wc;

	// clear out everything:
	m_mapClassInfo.clear();
	m_mapClassHierarchy.clear();
	m_grid.DeleteAllItems();
	m_grid.InsertColumn("Name", DT_LEFT|DT_VCENTER|DT_SINGLELINE);
	m_grid.InsertColumn("Total Bytes");
	m_grid.InsertColumn("Non-derived Bytes");
	m_grid.InsertColumn("Schema");
	m_grid.SetFixedColumnCount(1);
	m_grid.SetFixedRowCount(1);

	// walk through the simple list of registered classes
	AFX_MODULE_STATE* pModuleState = AfxGetModuleState();
	for (CDynLinkLibrary* pDLL = pModuleState->m_libraryList; pDLL != NULL;	pDLL = pDLL->m_pNextDLL)
	{
		for (CRuntimeClass* pClass = pDLL->m_classList.GetHead() ; pClass != NULL; pClass = pClass->m_pNextClass)
			InsertClass(this, pClass);
	}

	// enum the document-template classes:
	CWinApp* pApp = AfxGetApp();
	POSITION pos =	pApp->GetFirstDocTemplatePosition();
	while (pos)
	{
		CUnprotectedDocTemplate* pDocTemplate = (CUnprotectedDocTemplate*)pApp->GetNextDocTemplate(pos);
		pDocTemplate->InsertClasses(this);
	}

	// now pick up any stray window classes:
	EnumChildWindows(NULL, EnumClasses, (long)this);

	// add to grid:
	CString strCell;
	m_vecLevels.clear();
	for (M_Class::iterator it = m_mapClassInfo.begin() ; it != m_mapClassInfo.end() ; it++)
	{
		SClass& cls = it->second;

		InsertClassRow(cls.m_prcInfo, 1);
	}

	m_gridTreeCol.TreeSetup(&m_grid, // tree acts on a column in this grid
									0,       // which column has tree
									m_vecLevels.size(), // total number of rows if tree totally expanded
									1,    // Set fixed row count now, too
									&(m_vecLevels[0]),    // Tree Level data array --
															//  must have aiTotalRows of entries
									TRUE,               // T=show tree (not grid) lines; F=no tree lines
									FALSE);              // T=use 1st 3 images from already set image list
		
	//  to display folder graphics
	m_gridTreeCol.SetTreeLineColor( RGB( 0, 0, 0xFF) );
   m_gridTreeCol.TreeDisplayOutline( 1);

   m_grid.AutoSizeColumn(0, m_grid.GetAutoSizeStyle());
   m_grid.AutoSizeColumn(1, m_grid.GetAutoSizeStyle());
   m_grid.AutoSizeColumn(2, m_grid.GetAutoSizeStyle());
   m_grid.AutoSizeColumn(3, m_grid.GetAutoSizeStyle());
	m_grid.Refresh();

	m_strItemCount.Format("%ld Classes", m_mapClassInfo.size());
	UpdateData(FALSE);
}

/////////////////////////////////////////////////////////////////////////////
// CDlgClasses message handlers

void CDlgClasses::PostNcDestroy() 
{
	CDialog::PostNcDestroy();

	// modeless & allocated off the heap:
	delete this;
}

int CDlgClasses::OnCreate(LPCREATESTRUCT lpCreateStruct) 
{
	// graph create:
	CRect rcl(0,0,10,10);
	m_classView.Create(NULL, "", WS_CHILD | WS_HSCROLL | WS_VSCROLL, rcl, this, IDC_GRAPH);

	// grid create:
	m_grid.Create(rcl, this, IDC_GRID);

	if (CDialog::OnCreate(lpCreateStruct) == -1)
		return -1;

	return 0;
}

BOOL CDlgClasses::OnInitDialog() 
{
	CDialog::OnInitDialog();

	// graph init:
	m_classView.OnInitialUpdate();

	// grid init:
	m_grid.SetTextBkColor(RGB(0xFF, 0xFF, 0xE0));
	m_grid.SetRowResize(FALSE);
	m_grid.SetColumnResize(TRUE);
	m_grid.SetEditable(FALSE);

	// code font:
	m_courier.CreatePointFont(60, "Courier");
	GetDlgItem(IDC_EDIT_CODE)->SetFont(&m_courier);

	RecalcLayout();
	Refresh();

	return TRUE;
}

void CDlgClasses::OnOK() 
{
	Refresh();
}

void CDlgClasses::OnCancel() 
{
	// we're modeless:
	DestroyWindow();
}

void CDlgClasses::OnModeChange() 
{
	UpdateData(TRUE);	

	// show / hide controls:

	m_grid.EnableWindow(m_iMode == 0);
	m_grid.ShowWindow(m_iMode == 0 ? SW_SHOW : SW_HIDE);

	m_editCode.EnableWindow(m_iMode == 0);
	m_editCode.ShowWindow(m_iMode == 0 ? SW_SHOW : SW_HIDE);

	m_staticCount.EnableWindow(m_iMode == 0);
	m_staticCount.ShowWindow(m_iMode == 0 ? SW_SHOW : SW_HIDE);

	m_classView.EnableWindow(m_iMode == 1);
	m_classView.ShowWindow(m_iMode == 1 ? SW_SHOW : SW_HIDE);
}

void CDlgClasses::OnSize(UINT nType, int cx, int cy) 
{
	CDialog::OnSize(nType, cx, cy);

	RecalcLayout();
}

BOOL CDlgClasses::OnSelChanged( UINT id, NMHDR * pNotifyStruct, LRESULT * result )
{
	NM_GRIDVIEW* pnmgv = (NM_GRIDVIEW*)pNotifyStruct;

	// find and display "code" for selected item:
	CGridCellBase* pCell = m_grid.GetCell(pnmgv->iRow, 0);
	if (pCell)
	{
		CString strClass = pCell->GetText();

		M_Class::iterator it = m_mapClassInfo.find(strClass);
		if (it != m_mapClassInfo.end())
		{
			m_strCode = it->second.m_strCode;
			UpdateData(FALSE);
		}
	}

	return TRUE;
}

By viewing downloads associated with this article you agree to the Terms of Service and the article's licence.

If a file you wish to view isn't highlighted, and is a text file (not binary), please let us know and we'll add colourisation support for it.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here

Share

About the Author

Nick Hodapp
Web Developer
United States United States
No Biography provided

| Advertise | Privacy | Mobile
Web04 | 2.8.140926.1 | Last Updated 18 Sep 2000
Article Copyright 2000 by Nick Hodapp
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid