Introduction
This article demonstrates how to access a .NET library from a COM client. It's different from other COM Interop articles in that it simulates connection points for consumption by a COM client implemented using MFC and ATL libraries. The .NET library that we use in this article makes heavy use of the System.Collections.Hashtable
class, so we'll also demonstrate a reusable unmanaged C++ wrapper for this class.
Background
At TrayGames, we needed to add support for non .NET game clients to the multi-player online game development SDK (TGSDK) provided to third party developers. Although we prefer the .NET environment because it offers rich capabilities and rapid development we didn't want to exclude developers who have not yet adopted .NET from writing fun games. Since these are turn-based games, our libraries make use of the .NET Framework delegate-based event system so we will go into detail on how to expose this to COM. On the client side we wrote a COM version of our Tic-Tac-Toe sample game (also available in C# and VB.NET) to demonstrate how you can go about accessing our .NET library, but this example can be applied to any .NET library with some small changes.
Using the code
The .NET Framework provides a delegate-based event system to connect an event sender (source) to an event receiver (sink). When the sink is a COM client however, the source must include additional elements to simulate connection points. This article will demonstrate these modifications, and then show how a COM client can register its event sink interface in the normal way by calling the IConnectionPoint::Advise
method (we actually use an ATL wrapper method). What also makes this COM client particularly interesting is the wrapper that it uses to access a .NET Hashtable
, we will explore that as well as the COM Interop in general.
Our .NET library code is proprietary so I will only provide code snippets showing the changes that need to be made to this (or any) .NET library to make it accessible via COM. The downloadable demo will include only the binaries for the VB.NET library under the "COM Demo" folder. A copy of the game server source code is included with the downloadable source however, under the "CPP Server" folder. On the client side full source code for the sample will be provided, which demonstrates how to access the .NET library. The COM client is implemented in C++ using the MFC and ATL libraries. Also included is the source code for the custom Hashtable project. Both of these projects can be found under the "COM Client" folder in the downloadable source archive.
In order to play the sample game you need to load the game daemon in the Daemon Harness server, "TGDaemonHarnes.exe". Run it from the "Com Demo" folder, click the "Load Game Daemon" button and navigate to the "TicTacDoeDaemon.dll". Select the file and open it. The Daemon Harness should report that the game daemon was started in Test Harness mode, and the number of players required. Now we need to get a couple of games connected to this daemon. We'll need to run two game clients, because it's a two player game. Run one instance of the �MFCTicTacToe.exe� game application, and then another. Upon running the second instance of the game the game daemon should realize it has enough players and proceed to start the game.
Exposing the managed library
We will be using a library called TGGameLib for this example. Understanding what this library does is not important for understanding how to expose it to COM, or how to simulate connection points. Suffice to say it's one of our core libraries for developing a multi-player game. The library is written in VB.NET, if you are using C# or another .NET language the modifications will be the same but the syntax will be slightly different. So let's dig in!
We will be exposing our classes using the ClassInterfaceAttribute
class. This class will automatically generate a class interface for an attributed class. A class interface will have the same name as the class itself, but the name will be prefixed with an underscore character. When exposed, the class interface contains all the public members of the managed class, including members inherited from its base class, if any. Although class interfaces eliminate the job of explicitly defining interfaces for each class, their use in production applications is not recommended. Dual class interfaces allow clients to bind to a specific interface layout that is subject to change as the class evolves. That said, this is a much faster way to expose your .NET library to COM and since the TGSDK is still in beta form this was not an issue for us. You can always explicitly define your interfaces at a later point (and have your class implement that interface) after you are up and running, and you know it won't be changing. Strong names are also recommended, but we aren't doing that here either.
We need to expose the various classes, interfaces, enum
s and events that make up the library. We'll start with the TGGameClass
class, just your everyday VB.NET class. Here's what it looks like before being exposed to COM:
Public Class TGGameClass
Implements IDisposable
Public Event GameEvent As TGGameEventHandler
Public Event AdminEvent As TGAdminEventHandler
Public Event DataEvent As EventHandler
Public Delegate Sub TGGameEventHandler(ByVal sender As Object, _
ByVal e As GameEventArgs)
Public Delegate Sub TGAdminEventHandler(ByVal sender As Object, _
ByVal e As AdminEventArgs)
Public Sub Start(ByVal gameQSize As Integer, _
ByVal retrieveMessagesManually As Boolean)
End Sub
Public Sub RetrieveMessages()
End Sub
Public Function IsConnected() As Boolean
End Function
Public Function SendToServer(ByVal data As Hashtable) _
As Boolean
End Function
End Class
In order to expose this class we need to add the interop namespace to the file. We must also add the Guid
, ClassInterface
, and ComVisible
attributes to the class. Also, because we want to connect an event sink interface to this class, we must add the ComSourceInterfaces
attribute.
The Guid
attribute lets you add a Globally Unique Identifier (GUID), which is its unique name, so that your class can be distinguished by COM. You must specify a GUID as a parameter to this attribute, and each type that you want to expose must have a different GUID. Use the "Create GUID" tool under the "Tools" menu in Visual Studio .NET to create a GUID. Paste the given GUID as a parameter to the Guid
attribute. Remove the braces from the GUID, and you should have something like this:
<Guid("F75C54A9-616F-4090-BC02-8B4795B6F499")>
The ClassInterface
attribute controls whether the Type Library Exporter (Tlbexp.exe) automatically generates a class interface for the attributed class. You can use the "Register for COM Interop" project option in Visual Studio .NET (under "Properties->Configuration Properties->Build"), Tlbexp.exe, or Regasm.exe to generate the type library. Regasm.exe is basically a superset of the functionality in Tlbexp.exe. The Visual Studio .NET option is only available if you have the full source. Class interfaces can be dual or dispatch-only interfaces, as stated earlier we are using dual interfaces to get up and run quickly:
<ClassInterface(ClassInterfaceType.AutoDual)>
The ComVisible
attribute controls the accessibility of an individual managed type or member, or of all the types within an assembly, to COM. You can apply this attribute to assemblies, interfaces, classes, structures, delegates, enumerations, fields, methods, or properties. Only public types can be made visible, furthermore this attribute is not needed to make public managed assemblies and types visible, they are visible to COM by default. We use it in our libraries because our assemblies set ComVisible
to False
. This is a recommendation from FxCop, a tool that we run against our code to ensure some level of compatibility with Microsoft's best practices:
<ComVisible(True)>
The ComSourceInterfaces
attribute defines event interfaces that will be exposed as COM connection points. We must pass the Type
of the source interface which we can get easily by using the GetType()
method. Note that the class event name and the outgoing interface method name must be the same. We will look at TGGameEvents
in a minute but basically we are connecting the event sink interface (outgoing), TGGameEvents
, to the TGGameClass
class by passing the namespace and event sink interface:
<ComSourceInterfaces(GetType(TGGameEvents))>
Putting all of these changes together and applying them to TGGameClass
will make it available to COM clients, here's what it looks like after applying the attributes:
Imports System.Runtime.InteropServices
<Guid("F75C54A9-616F-4090-BC02-8B4795B6F499"), _
ClassInterface(ClassInterfaceType.AutoDual), _
ComSourceInterfaces(GetType(TGGameEvents)), _
ComVisible(True)> _
Public Class TGGameClass
Implements IDisposable
Public Event GameEvent As TGGameEventHandler
Public Event AdminEvent As TGAdminEventHandler
Public Event DataEvent As EventHandler
Public Delegate Sub TGGameEventHandler(_
ByVal sender As Object, ByVal e As GameEventArgs)
Public Delegate Sub TGAdminEventHandler(_
ByVal sender As Object, ByVal e As AdminEventArgs)
Public Sub Start(ByVal gameQSize As Integer, _
ByVal retrieveMessagesManually As Boolean)
End Sub
Public Sub RetrieveMessages()
End Sub
Public Function IsConnected() As Boolean
End Function
Public Function SendToServer(_
ByVal data As Hashtable) As Boolean
End Function
End Class
Below we define the event sink interface TGGameEvents
to be implemented by the COM sink (discussed in the client section of this article). Unlike the other types we expose from this library, our event interface must be exposed explicitly as an IDispatch
interface; you use the InterfaceType
attribute to do this. This attribute will allow you to override the way the Type Library Exporter exposes a managed interface to COM by default (as a dual interface). InterfaceType
allows you to specify InterfaceIsDual
(the default), InterfaceIsIDispatch
(a dispinterface for late binding only), or InterfaceIsUnknown
(IUnknown
derived for early binding only).
In this interface, we expose the event methods by assigning arbitrary DispId
values. We've already seen how this event sink interface gets connected to TGGameClass
, and we've also defined events representing each of the delegates that we define for the methods in this outgoing interface. Later we will see how the unmanaged client creates an instance of TGGameClass
and implements the event sink interface:
<Guid("7D809BD7-13D2-4b47-9B74-0236E9F01A99"), _
InterfaceType(ComInterfaceType.InterfaceIsIDispatch), _
ComVisible(True)> _
Public Interface TGGameEvents
<DispId(1)> _
Sub DataEvent(ByVal sender As Object, _
ByVal e As EventArgs)
<DispId(2)> _
Sub GameEvent(ByVal sender As Object, _
ByVal e As GameEventArgs)
<DispId(3)> _
Sub AdminEvent(ByVal sender As Object, _
ByVal e As AdminEventArgs)
End Interface
The GameEventArgs
and the AdminEventArgs
classes referenced in the code snippet above are nothing special:
<Guid("8B7134E9-C428-42d4-AF84-2E803A6F2099"), _
ClassInterface(ClassInterfaceType.AutoDual), _
ComVisible(True)> _
Public Class GameEventArgs
Inherits EventArgs
Private mValue As Hashtable
Public Sub New(ByVal value As Hashtable)
mValue = value
End Sub
Public ReadOnly Property Value() As Hashtable
Get
Return mValue
End Get
End Property
End Class
<Guid("B5085EE9-0C98-47ff-AE2A-4B0F9D960B99"), _
ClassInterface(ClassInterfaceType.AutoDual), _
ComVisible(True)> _
Public Class AdminEventArgs
Inherits EventArgs
Private mType As AdminEventType
Private mData As Hashtable
Public Sub New(ByVal type As AdminEventType, _
ByVal data As Hashtable)
mType = type
mData = data
End Sub
Public ReadOnly Property Type() As AdminEventType
Get
Return mType
End Get
End Property
Public ReadOnly Property Data() As Hashtable
Get
Return mData
End Get
End Property
End Class
Finally, we have several enumerations that we want to expose to COM as well. We need to apply only the Guid
and ComVisible
attributes for enum
s:
<Guid("D5566638-49CD-4664-8DD2-BC49486A8799"), _
ComVisible(True)> _
Public Enum AdminEventType
Connected
Disconnected
Shutdown
Minimize
Restore
End Enum
<Guid("FAB7367B-EDA2-4447-B8C6-6869B2377699"), _
ComVisible(True)> _
Public Enum TGGameGetError
NoError
MissingSubtypeInGameMsg
MissingPlayerNumberInAdminConnectedMsg
MissingPlayerDataInAdminConnectedMsg
MissingDataInGameMsg
ExceptionDeserializingDataInGameMsg
End Enum
<Guid("97567966-C6ED-4249-BAA0-3BBDDCA3BB99"), _
ComVisible(True)> _
Public Enum TGGameSendError
NoError
NotConnected
ExceptionSerializingData
End Enum
An issue we ran into was in our TGDxGameLib library. This library attempts to expose a single interface, IGameClass
. Unfortunately, when we tried to register the project output for COM Interop we got the following error: "COM Interop registration failed. There are no registrable types in the built assembly". To solve this problem you can either create a class that implements the interface you are trying to expose, or simply add a dummy class to the project, which is what we chose to do:
<Guid("D93A0FE1-1B0E-481d-90F6-C61D56E36499"), _
ClassInterface(ClassInterfaceType.AutoDual), _
ComVisible(True)> _
Public Class DxGameEventArgs
Inherits EventArgs
Public Sub New()
End Sub
End Class
<Guid("F5133BF0-77DB-4f43-96A2-347D978A1699"), _
InterfaceType(ComInterfaceType.InterfaceIsDual), _
ComVisible(True)> _
Public Interface IGameClass
Sub Animate(ByVal time As Single)
Sub PreSkinPaint()
Sub Render(ByVal device As _
Microsoft.DirectX.Direct3D.Device)
Sub Init(ByVal gc As GraphicsClass)
Sub Dispose()
ReadOnly Property GameName() As String
ReadOnly Property GameSize() As Drawing.Size
ReadOnly Property DisplayFPS() As Boolean
ReadOnly Property MaxActiveFPS() As Integer
ReadOnly Property MaxInactiveFPS() As Integer
End Interface
Once you are finished, you can register the assemblies through RegAsm.exe to register them with COM and generate type libraries. Alternatively, you can use the "Register for COM Interop" project option in Visual Studio .NET to register if you have the full source. The type libraries can then be referenced in a client that will sink the events:
c:\Windows\Microsoft.NET\Framework\v1.1.4322\RegAsm.exe
TGGameLib.dll /codebase /tlb
Creating the unmanaged COM client
For our COM client we decided to do a version of our Tic-Tac-Toe sample game. To create the COM client that will use the .NET library and sink the event interface we created a new "MFC application" using the "New Project..." option in Visual Studio .NET and named it "MFCTicTacToe". After creating the application we chose the "Add Class..." project option and added "ATL Support to MFC". This gave us the boiler plate code we needed to proceed. We started with an MFC application to make doing GUI stuff easier in our sample since it's a game, but you don't necessarily have to. The file TGGameClass.h contains the CTGGameClassEventWrapper
class. We created this class as a sink for the outgoing event interface TGGameEvents
. Since the client side sink is the highlight of the article we'll look at it first. The basic steps for implementing a connection point sink in our client are:
- Import the type libraries for each external object using the
#import
directive.
- Declare the
IDispEventImpl
interfaces.
- Declare an event sink map.
- Advise and unadvise the connection points.
Let's look at these steps in more detail. At the top of the file we have to add the necessary import
statements. We must import the type library for every external object that we want to handle. This will define the events that can be handled and provide information that is used when declaring the event sink map (more on that later). Note that we use the rename
option to avoid some name conflicts:
#import "c:\\WINDOWS\\Microsoft.NET\\Framework\\v1.1.4322\\mscorlib.tlb"
rename("ReportEvent", "Report_Event")
#import "..\\..\\Com Demo\\TGGameLib.tlb" rename("GetType", "Get_Type")
Since we only want to supply implementations for the events that we are interested in handling (and not all of the IDispatch
stuff) we chose to use the IDispEventImpl
template class to provide support for a connection point sink in our ATL class. The sink will allow us to handle events fired from the TGGameClass
class. This template class works in conjunction with the event sink map in our CTGGameClassEventWrapper
class to route events to the appropriate handler methods. More on those methods later, for now let's look at the constructor call for this template class. The third parameter to the IDispEventImpl
constructor is the event dispinterface
that we want to implement. This dispinterface
must be described in the type library pointed to by the fourth parameter; this is where IDispEventImpl
gets its type information about the interface. This is why we had to run RegAsm.exe on the .NET library. Note that in the .NET library we assigned GUIDs to all of the types we wanted to expose to COM. On the COM side, we use the C++ __uuidof
operator to retrieve those GUIDs:
class ATL_NO_VTABLE CTGGameClassEventWrapper
: public IDispEventImplDbg<0, CTGGameClassEventWrapper,
&__uuidof(TGGameLib::TGGameEvents),
&__uuidof(TGGameLib::__TGGameLib),
1, 0>
We also declare a couple of data members that are specific to the logic of this sample:
HANDLE m_hDataEvent;
TGGameLib::_TGGameClassPtr TheGameClass;
In order for the event notifications from TGGameClass
to be handled by the proper methods, we must route each event to a handler. ATL provides macros, BEGIN_SINK_MAP
, END_SINK_MAP
, and SINK_ENTRY
, which make this mapping simple. We use these macros to create an event sink map for each event (on each object) that we want to handle as such:
BEGIN_SINK_MAP(CTGGameClassEventWrapper)
SINK_ENTRY_EX(0, __uuidof(TGGameLib::TGGameEvents),
1 , DataEvent)
SINK_ENTRY_EX(0, __uuidof(TGGameLib::TGGameEvents),
2 , GameEvent)
SINK_ENTRY_EX(0, __uuidof(TGGameLib::TGGameEvents),
3 , AdminEvent)
END_SINK_MAP()
Before CTGGameClassEventWrapper
becomes visible, each external dispinterface supported by CTGGameClassEventWrapper
is queried for outgoing interfaces. A connection is established and a reference to the outgoing interface is used to handle events from the source object, TGGameClass
in this case. This process is referred to as "advising". After we're finished with the external interfaces, the outgoing interfaces should be notified that they are no longer used by our class. This process is referred to as "unadvising." We call DispEventAdvise
to establish the connection between TGGameClass
and CTGGameClassEventWrapper
, and DispEventUnadvise
to break the connection. We do this in the constructor and destructor code below:
CTGGameClassEventWrapper(
TGGameLib::_TGGameClassPtr GameClass)
{
Advise(GameClass);
}
CTGGameClassEventWrapper()
: TheGameClass(__uuidof(TGGameLib::TGGameClass))
{
m_hDataEvent =
CreateEvent(NULL, FALSE, FALSE, NULL);
HRESULT _hr = Advise(TheGameClass);
if (FAILED(_hr)) _com_issue_error(_hr);
_hr = TheGameClass->Start(8000, VARIANT_TRUE);
if (_hr != S_OK)
{
ATLASSERT(false &&
"The Game Class could not be started.");
}
}
~CTGGameClassEventWrapper()
{
if (TheGameClass != NULL)
{
DispEventUnadvise(TheGameClass);
}
}
HRESULT Advise(
TGGameLib::_TGGameClassPtr GameClass)
{
HRESULT hr = E_FAIL;
TheGameClass = GameClass;
if (m_dwEventCookie == 0xFEFEFEFE)
{
hr = DispEventAdvise(TheGameClass);
}
return hr;
}
All that's missing to complete the event sink are the actual event handlers:
STDMETHOD_(void, DataEvent)(
VARIANT sender,
mscorlib::_EventArgs* e)
{
SetEvent(m_hDataEvent);
}
STDMETHOD_(void, GameEvent)(
VARIANT sender,
TGGameLib::_GameEventArgs* e) = 0;
STDMETHOD_(void, AdminEvent)(
VARIANT sender,
TGGameLib::_AdminEventArgs* e) = 0;
You may notice that two of these event handlers are pure virtual methods. These pure virtual methods are implemented in a derived class CMyTicTacToeGameClass
. This class and its event handlers are specific to the logic of this game sample so I'm not going to get into the details here. However, what is of general interest is the wrapper to the .NET System.Collections.Hashtable
class, so let's look at it now. When first developing this sample we had to write lots of COM code to pull out information from a .NET Hashtable
. This proved to be a pain even with a lot of the free COM code provided by ATL. First let's look at a typical Hashtable
as prepared by our server code, note the inner Hashtable
:
Dim PlayerData As New Hashtable
For Each Player As GamePerson In Group.Players.Values
Dim PlayerInfo As Hashtable = New Hashtable
PlayerInfo.Add("Nick", Player.Nick)
PlayerInfo.Add("Rank", Player.Rank)
PlayerInfo.Add("TimesPlayed", Player.TimesPlayed)
PlayerInfo.Add("HasBoughtGameLevel",
CType(Player.BoughtGameLevel, Integer))
PlayerData.Add(player.PlayerNumber, PlayerInfo)
Next
Msg.Add("PlayerData", PlayerData)
For Each player As GamePerson In Group.Players.Values
Msg("PlayerNum") = player.PlayerNumber
If IsTestHarnessMode Then
TestMsgToPlayer(Group.Id, player.Id, Msg)
Else
SendMsgToPlayer(Group.Id, player.id, Msg)
End If
Next
Using the .NET Hashtable
class a lot from a COM client can be repetitive and hard to maintain, especially when you are nesting this class as our library does. So, we wrote a handy reusable wrapper for the Hashtable
class to make this cleaner and easier. Here is an example of our COM code accessing this Hashtable
with our CustomHashtable
wrapper:
m_LocalPlayerIndex = pHash->Item["PlayerNum"];
ITGHashtablePtr pWrapPlayerData =
ITGHashtablePtr(__uuidof(TGHashtable));
pWrapPlayerData->InnerHashtable =
(mscorlib::_HashtablePtr)pHash->Item["PlayerData"];
mscorlib::IEnumeratorPtr playerDataEnum =
pWrapPlayerData->GetEnumerator();
if (playerDataEnum == NULL)
return;
mscorlib::IDictionaryEnumeratorPtr playerDataEnumDict =
playerDataEnum;
while(playerDataEnum->MoveNext())
{
ITGHashtablePtr pWrapPlayerInfo =
ITGHashtablePtr(__uuidof(TGHashtable));
pWrapPlayerInfo->InnerHashtable =
(mscorlib::_HashtablePtr)playerDataEnumDict->value;
}
The full source code for the reusable CustomHashtable
wrapper can be found in the downloadable source code. If we take a quick look at the InnerHashtable
methods we can see that they save us some work by setting the appropriate data members:
STDMETHODIMP CTGHashtable::
get_InnerHashtable(_Hashtable** pVal)
{
*pVal = m_pInnerHash;
if(*pVal)
{
(*pVal)->AddRef();
}
return S_OK;
}
STDMETHODIMP CTGHashtable::
put_InnerHashtable(_Hashtable* newVal)
{
m_pInnerHash = newVal;
m_pCollection = m_pInnerHash;
m_pDictionary = m_pInnerHash;
return S_OK;
}
So that we can wrap ICollection
and IDictionary
calls like this:
public:
STDMETHOD(CopyTo)(_Array * Array, long index)
{
return m_pCollection->CopyTo(Array,index);
}
STDMETHOD(get_SyncRoot)(VARIANT * pRetVal)
{
return m_pCollection->get_SyncRoot(pRetVal);
}
STDMETHOD(get_IsSynchronized)(VARIANT_BOOL * pRetVal)
{
return m_pCollection->get_IsSynchronized(pRetVal);
}
public:
STDMETHOD(get_Count)(long * pRetVal)
{
return m_pCollection->get_Count(pRetVal);
}
STDMETHOD(get_Item)(VARIANT key, VARIANT *pRetVal)
{
return m_pDictionary->get_Item(key,pRetVal);
}
STDMETHOD(putref_Item)(VARIANT key, VARIANT pRetVal)
{
return m_pDictionary->putref_Item(key,pRetVal);
}
STDMETHOD(get_Keys)(ICollection * * pRetVal)
{
return m_pDictionary->get_Keys(pRetVal);
}
STDMETHOD(get_Values)(ICollection * * pRetVal)
{
return m_pDictionary->get_Values(pRetVal);
}
STDMETHOD(Contains)(VARIANT key, VARIANT_BOOL * pRetVal)
{
return m_pDictionary->Contains(key,pRetVal);
}
STDMETHOD(Add)(VARIANT key, VARIANT value)
{
return m_pDictionary->Add(key,value);
}
STDMETHOD(Clear)()
{
return m_pDictionary->Clear();
}
STDMETHOD(get_IsReadOnly)(VARIANT_BOOL * pRetVal)
{
return m_pDictionary->get_IsReadOnly(pRetVal);
}
STDMETHOD(get_IsFixedSize)(VARIANT_BOOL * pRetVal)
{
return m_pDictionary->get_IsFixedSize(pRetVal);
}
STDMETHOD(GetEnumerator)(IDictionaryEnumerator * * pRetVal)
{
return m_pDictionary->GetEnumerator(pRetVal);
}
STDMETHOD(Remove)(VARIANT key)
{
return m_pDictionary->Remove(key);
}
Since CustomHashtable
is a COM library used by the main "MFCTicTacToe" project it will have to be registered on your computer. We've added a "Post Build Step" to the project in order to execute the required register command. If you are just running the demo you can do it manually by running the following command:
regsvr32 /s /c CustomHashtable.dll
An issue that came up for us when we first tried to compile this COM client was that we would get a "Class not registered" error. This is because in .NET 1.0/1.1 mscorlib isn't part of the Global Assembly Cache (GAC), from what I've read it will be in .NET 2.0. To solve the problem now you can run the following command:
c:\WINDOWS\Microsoft.NET\Framework\v1.1.4322\RegAsm.exe mscorlib.dll
Points of interest
This article is interesting if you would like to see a hands on approach to exposing your managed library to the world of COM. It's especially interesting as an example of how to simulate COM connection points and has a nifty wrapper for accessing the .NET Hashtable
class. If you are interested in checking out the full TGSDK for producing your own multi-player online games, you can get it at the TrayGames web site. I would like to emphasize that managed code is definitely the preference for writing games using the TGSDK. If you would like to see an article on how to use the TGSDK for writing games in managed code check out my Navy Battle article.
Acknowledgments
An extra special thanks to Ryan Schneider for putting in a considerable amount of his time in getting this project started.
Revision history
- 22nd August, 2005 - Initial revision.