Introduction

When a person decides to learn how to create Thief (or System Shock) missions, he will inevitably be faced with three questions. The first of these is encountered at the very beginning: “What is Dromed and how do I use it?” I won’t be answering that question here. The second question that anyone learning Dromed eventually faces is: “Why is this damn thing not working?” This, I’m afraid, is more of a philosophical matter. But the third question, and one which I am most confident in answering, is: “What are scripts and how do I make my own?”

In Thief, a script is a small program that runs in an environment with limited, but ample access to game features. The important part of this description is that scripts are limited. You are likely driven to scripting by the desire to create some complicated effect in the game. But if what you want is too complicated, or is outside the abilities of the game itself, a script isn’t going to help you. A script can’t do anything the game wasn’t designed to do. You can, however, do normal, mundane things, but in a different order, or with extra logic that makes it seem new.

It is also important to note that a script is a computer program in the form of compiled machine code. This presents certain prerequisites for creating new scripts. You must have a full-featured compiler, and you must know a programming language; C++ in particular. While in other contexts, the term “script” refers to something that is light-weight and simple. I’m afraid that scripting the Dark Engine is neither.

So if you have a compiler, are comfortable working with C++, and aren’t already discouraged, we can begin exploring the basic structure of a script. You probably already know that scripts are stored in OSM files. An OSM, which stands for Object Script Module, is just a DLL. (Or more generally, a Win32 PE object file for x86.) Each OSM can contain code for any number of scripts. Thief loads the OSM as a library, then calls an exported initialization function. This function provides an interface which Thief uses to load and execute the individual scripts. The scripts themselves can communicate with the game using an interface that was provided to the initialization function.

On the other hand, if you're not so familiar with C++, then I you can find a decent enough guide at http://www.silicontao.com/ProgrammingGuide/index.html. There are more in-depth articles, including many covering COM, at http://www.codeguru.com/Cpp/.

Script Module

The script module interface, and all interfaces used by scripts (and the scripts themselves, for that matter), are implemented as C++ objects with methods borrowed from COM. They are not actually COM objects, however; they just look like it. In particular, all objects implement the IUnknown interface, and interfaces are identified using GUIDs. To create scripts, you need header files with declarations of the public interfaces, and definitions of the appropriate GUIDs. There are also some classes that you must implement yourself. If you download one of my script modules, the headers and source code are provided in the lg Library. You may use and distribute this library with your own scripts.

The initialization function your OSM must export is declared as:

extern "C" __declspec(dllexport) __stdcall
int ScriptModuleInit (const char* pszName,
                      IScriptMan* pScriptManager, 
                      MPrintfProc pfnMPrintf,
                      IMalloc* pMalloc,
                      IScriptModule** pOutInterface);

The function is called once when your module is loaded. Since an OSM is a regular DLL, you can also have a DllMain function, but it isn’t necessary.

The first argument to the initialization function is the name of the script module. What you do with this isn’t too important. For now, we’ll save a copy of the string. The next argument is a pointer to the script manager. You must save this pointer somewhere where your scripts can access it. The easiest way is with a global variable. Although the script manager, like all public interfaces used in Thief, implements IUnknown, it is not necessary to increment the reference count. You can still increment it, just be sure to release it when your script module class is deleted. This argument should never be NULL.

I’ll skip over the third argument for now, and describe the fourth: an allocator object. This is also an important interface that we’ll save in a global variable. The IMalloc interface is defined by COM, and Thief provides two implementations of it that scripts can use. The pointer that is passed to the initialization function is a standard allocator, and you can simply copy the pointer if it’s all you need. But there is also a debugging allocator, with extra checks for memory leaks and double-frees. You can get this version by calling QueryInterface with the GUID IID_IDebugMalloc.

The last argument is where you store a pointer to your script module interface. The interface is an implementation of IScriptModule, which isn’t very complicated. You indicate the success or failure of the function with the return value. If you return 1, then initialization was a success, and pOutInterface points to a valid object. Otherwise, return 0. pOutInterface will be ignored in in this case.

The argument I skipped over, pfnMPrintf, is a pointer to a function. The actual declaration of the function is:

int __cdecl MPrintf(const char*, ...);

As you can see, the function takes a variable number of arguments. It should be apparent that the function takes arguments in the style of printf. And you may also have guessed that the formatted text is printed to the monolog. While use of this function is optional, it certainly makes debugging easier. However, Thief uses a fixed-length buffer of 1000 bytes when formatting the string, so you need to be careful not to exceed this limit. (The other method of writing to the monolog, using DebugScriptService, is also limited by this buffer. So really, there’s no reason not to use the provided function.)

Now we can write the actual initialization function:

typedef int (__cdecl *MPrintfProc)(const char*, ...);

MPrintfProc g_pfnMPrintf = NULL;
IMalloc *g_pMalloc = NULL;
IScriptMan *g_pScriptManager = NULL;
cScriptModule g_ScriptModule;

extern "C" __declspec(dllexport) __stdcall
int ScriptModuleInit (const char* pszName, IScriptMan* pScriptManager, void (*pfnMPrintf)(), 
                      IMalloc* pMalloc, IScriptModule** pOutInterface)
{
    *pOutInterface = NULL;
    g_pScriptManager = pScriptMan;
#ifdef _DEBUG
    pMalloc->QueryInterface(IID_IDebugMalloc, reinterpret_cast<void**>(&g_pMalloc))
    if (!g_pMalloc)
        g_pMalloc = pMalloc;
#else
    g_pMalloc = pMalloc;
#endif
    g_pfnMPrintf = reinterpret_cast<MPrintfProc>(pfnMPrintf);
    if (!g_pScriptManager || !g_pMalloc)
        return 0;
    g_ScriptModule.SetName(pszName);
    g_ScriptModule.QueryInterface(IID_IScriptModule, reinterpret_cast<void**>(pOutInterface));
    return 1;
}

Most of the function involves copying the provided pointers to our global variables. There is a conditionally compiled block that attempts to retrieve an instance of the debugging allocator. It may not always be available, however, so we have to check for that and use the default allocator instead. We also use the QueryInterface method of our script module class to initialize pOutInterface. You may notice that the script module is a static global variable. This is unusual for most COM classes, but this isn’t really COM. Since there’s no need for more than one instance of the script module class, most OSMs simply use a static instance. There’s no requirement to do it this way, and you’re free to allocate your class dynamically if you prefer it.

Let’s look at the script module class now:

class cScriptModule : public IScriptModule
{
public:
    // IUnknown
    STDMETHOD(QueryInterface)(REFIID,void**);
    STDMETHOD_(ULONG,AddRef)(void);
    STDMETHOD_(ULONG,Release)(void);
    // IScriptModule
    STDMETHOD_(const char*,GetName)(void);
    STDMETHOD_(const sScrClassDesc*,GetFirstClass)(tScrIter*);
    STDMETHOD_(const sScrClassDesc*,GetNextClass)(tScrIter*);
    STDMETHOD_(void,EndClassIter)(tScrIter*);

private:
    int m_iRef;

public:
    virtual ~cScriptModule();
    cScriptModule();
    cScriptModule(const char* pszName);
    void SetName(const char* pszName);

private:
    const sScrClassDesc* GetScript(unsigned int i);
    char* m_pszName;
    static const sScrClassDesc sm_ScriptsArray[];
    static const unsigned int sm_ScriptsArraySize;
};

You can probably guess at what most of this class does. The first three methods are the familiar IUnknown methods, along with the variable m_iRef. In this declaration, I used the STDMETHOD macros; they save typing and the code is clearer. You can get these macros from the standard header objbase.h. Including that should also give you interface declarations, the GUID type, and an overloaded == operator for it.

Further down is the constructor and destructor for the class, and the method SetName. You would be correct in assuming that SetName copies the argument to m_pszName. The second constructor, the one that accepts an argument, simply calls SetName. The variable m_pszName is returned when the interface method GetName is called. Thief doesn’t particularly care, but I consider it polite to return from GetName the same string that was originally passed to my initialization function.

The other three interface methods, and the two static variables at the end, work together to tell Thief what scripts this module can provide. The script information is stored in a sScrClassDesc structure. It looks like this:

typedef IScript* (__cdecl *ScriptFactoryProc)(const char*,int);
struct sScrClassDesc
{
    char* pszModule;
    char* pszClass;
    char* pszBaseClass;
    ScriptFactoryProc pfnFactory;
};

Each structure stores the name of the module (pszModule), the name of the script (pszClass), the name of the script it is derived from (pszBaseClass), and a pointer to the function that will return new instances of the script. All of this can be written ahead of time and stored statically in the module.

The base class of a script is the name of another script from which this one derives its behavior. This relationship, however, is pretty much irrelevant. Thief makes no special consideration of inheritance for scripts. If you want it, you have to do all the sub-classing in C++. In fact, pszBaseClass doesn’t even have to be a valid string. What you decide is the “base class” of your scripts is entirely arbitrary.

Since we haven’t created any scripts yet, we’ll leave the scripts array undefined. Let’s fill in the parts of the class we’ve covered so far.

cScriptModule::~cScriptModule()
{
    if (m_pszName)
        delete[] m_pszName;
}

cScriptModule::cScriptModule()
        : m_iRef(1), m_pszName(NULL)
{
}

cScriptModule::cScriptModule(const char* pszName)
        : m_iRef(1), m_pszName(NULL)
{
    SetName(pszName);
}

HRESULT cScriptModule::QueryInterface(REFIID riid, void** ppout)
{
    if (riid == IID_IUnknown)
        *ppout = static_cast<IUnknown*>(this);
    else if (riid == IID_IScriptModule)
        *ppout = static_cast<IScriptModule*>(this);
    else
        return E_NOINTERFACE;
    reinterpret_cast<IUnknown*>(*ppout)->AddRef();
    return S_OK;
}

ULONG cScriptModule::AddRef(void) 
{
    return ++m_iRef;
}

ULONG cScriptModule::Release(void)
{
    // This object is static, so we don't delete it.
    if (m_iRef)
        m_iRef--;
    return m_iRef;
}

void cScriptModule::SetName(const char* pszName)
{
    if (m_pszName)
        delete[] m_pszName;
    if (pszName)
    {
        m_pszName = new char[strlen(pszName)];
        strcpy(m_pszName, pszName);
    }
    else
        m_pszName = NULL;
}

const char* cScriptModule::GetName(void)
{
    return m_pszName;
}

const sScrClassDesc* GetScript(unsigned int i)
{
    if (i < sm_scriptsArraySize)
        return &sm_ScriptsArray[i];
    else
        return NULL;
}

If you’ve worked with COM before, then most of this should be familiar. Even if you haven’t, it’s pretty obvious what’s going on. The one unusual function is Release. I’m going to be using this class for a static object, so I don’t want to delete it even if the reference count reaches zero. If you were going to use this class dynamically, you’d want the function to look like this:

ULONG cScriptModule::Release(void)
{
    if (m_iRef)
        if (--m_iRef == 0)
            delete this;
    return m_iRef;
}

I should also point out that QueryInterface uses the == operator to compare GUIDs. This operator needs to be overloaded, which should be done by your platform headers. If your headers are old or incomplete, then this won’t work.

Now we get to the real work of the script module class. Whenever Thief needs to find out what scripts are available, it goes to each module and asks for a list of the scripts it provides. This begins with a call to GetFirstClass. Then GetNextClass is called repeatedly until all scripts have been found. The last function, EndClassIter, is called when the application wants to stop the iteration before all the scripts have been retrieved; it is not called when the iteration ends because of a call to GetNextClass. (Thief rarely does this, so EndClassIter usually goes unused.) The return value from these functions is either the pointer to a sScrClassDesc structure, or it is NULL to indicate that there are no more scripts. The argument is a pointer to a handle. A handle is a pointer-sized value of unspecified purpose. You get to use this handle for whatever you want, as long as it fits into 32-bits. (And if you need more space, you’re supposed to allocate a block of memory and use the pointer as the handle value.) The only thing we need to keep track of is the index into the scripts array; it can be stored directly into the handle. If you were using a pointer, then you would need to free the memory once the iteration had completed. This would be in EndClassIter, obviously, but also when GetNextClass returns NULL.

Now we can finish our class. The static script array will be defined once we have a script to put in it.

const sScrClassDesc* cScriptModule::GetFirstClass(tScrIter* pIterParam)
{
    *reinterpret_cast<unsigned int*>(pIterParam) = 0;
    return GetScript(0);
}

const sScrClassDesc* cScriptModule::GetNextClass(tScrIter* pIterParam) 
{
    const sScrClassDesc *pRet;
    register unsigned int index = *reinterpret_cast<unsigned int*>(pIterParam);
    pRet = GetScript(++index);
    *reinterpret_cast<unsigned int*>(pIterParam) = index;
    return pRet;
}

void cScriptModule::EndClassIter(tScrIter* pIterParam)
{
    // Nothing to do here
}

And that’s a script module. If you were to initialize the script array to zero, then you would be able to compile this and load it in Dromed. It wouldn’t do anything, of course, since no scripts are defined. But this is all that is necessary for a complete OSM.

In fact, let’s build the OSM now, before moving on to creating actual scripts. Because the details of building a project are different for each programming environment, I can’t provide step-by-step instructions. When possible, I’ll offer specific details for a particular compiler.

As was mentioned previously, an OSM is simply a DLL. If you’re using an IDE, there should be a template for a standard Win32 DLL project. There’s no need for any special type library or foundation class. The target DLL will have 32-bit words and ANSI (8-bit) characters. An OSM does not usually have any use of Windows functions, so you can save compilation time by defining WIN32_LEAN_AND_MEAN; I also like to define WINVER=0x400 and WIN32_WINNT=0x400. If you are compiling your module for debugging, you’ll want to also define DEBUG, and define NDEBUG otherwise. Finally, make sure that your OSM won’t depend on any unusual libraries. In the Cygwin environment, for example, you’ll want to specify the option -mno-cygwin to use the Mingw32 compiler, which links against MSVCRT.DLL.

The most difficult part of compiling an OSM will be getting the lg headers to work right. The script interfaces weren’t originally written with portability in mind. If you’re using my version of the headers, you can use the file config.h to adjust for compiler inconsistencies. If you need to modify a header file, you should protect your changes using an #ifdef appropriate for your compiler. Right now, we’re only interested in the IScriptModule interface. But to use that, you also need to have sScrClassDesc and IScript. Including these will pull in other declarations. It’s usually sufficient to include lg/types.h then any headers with interfaces that you will be using specifically. (It is safe to include a header more than once.)

Once you’ve ironed out any wrinkles with the lg headers, you can compile the object file. The next step is to link the OSM. There is only one function that needs to be exported, and we decorated it with __declspec(dllexport). The decoration is enough to tell most linkers that we want the function exported. Still, some linkers want to see a DEF file. This will do for most cases:

EXPORTS
    _ScriptModuleInit@20 =ScriptModuleInit

If you’re using GCC, you should use this one:

EXPORTS
    _ScriptModuleInit@20 =ScriptModuleInit@20

The name of the initialization function has been decorated with @20 because it is a __stdcall function. And it begins with an underscore, which most C/C++ functions have but is not normally shown. There is no ordinal number because we want the function to be exported by name. In any case, whether you use a DEF file or just the implicit declaration, the end result should be a DLL with an export table that looks something like this:

RVA      Ord. Hint Name
-------- ---- ---- ----
000014E0    1 0000 _ScriptModuleInit@20

It’s okay if there are other symbols being exported. (Borland likes to make ___CPPdebugHook public.) The important part is that Dromed will be able to find this particular symbol in your OSM.

When linking your OSM, the only libraries that are usually necessary (other than the lg library) is a C/C++ runtime, Kernel32, possibly User32, and Uuid which provides the GUIDs of standard interfaces such as IUnknown. There are many other libraries, such as OLE2 and ComDlg32, which aren’t going to be used by any of your scripts. (Unless you’re doing something really unusual. In which case, good luck!) If you’re using an IDE project, then some of these extra libraries may have been included by default; removing them can save time when building the module.

These are the basic requirements for compiling a script module. There are other options to consider, of course. The level of optimization, how much debugging information to write, etc. You should avoid unusual options, and keep in mind that the baseline for compatibility is MSVC++ 6, which is what Dromed was originally compiled with.

Scripts

You have now, hopefully, compiled an empty script module. To add scripts to this module you need to do two things: create the script classes, and create the script factories. The script factory function was mentioned briefly when we described the sScrClassDesc structure. Since we need to have a class defined before we can create an instance of it, the factory function will again have to wait.

Each script that we create is a class that implements the IScript interface. Here is the declaration of a typical script class:

class cScript : public IScript
{
public:
    // IUnknown
    STDMETHOD(QueryInterface)(REFIID,void**);
    STDMETHOD_(ULONG,AddRef)(void);
    STDMETHOD_(ULONG,Release)(void);
    // IScript
    STDMETHOD_(const char*,GetClassName)(void);
    STDMETHOD_(long,ReceiveMessage)(sScrMsg*,sMultiParm*,eScrTraceAction);

private:
    int m_iRef;

public:
    virtual ~cScript();
    cScript(const char* pszName, int iHostObjId);

protected:
    const char* m_szName;
    int m_iObjId;

private:
    long ScriptPtrQuery(sPtrQueryMsg* pMsg);

};

The astute C++ programmer will notice that much of this class could be shared among all scripts. And that’s exactly what we’ll do, by deriving all our scripts from this class, which will be our base class.

The two methods specific to scripts are GetClassName and ReceiveMessage. The first should be obvious, it returns the name of the script. The name is originally passed to the constructor, we simply store the pointer in a member variable. The constructor also receives the object ID that this particular instance of the script is associated with. This is also saved, and will get used a lot in the scripts we make.

The other method is ReceiveMessage. This is the real workhorse of the script. A script’s entire operation is based on responding to messages. These messages are generated by events in the game engine, actions taken by the player, and even by other scripts. Understanding scripts is all about understanding messages. For now, it’s enough to know that ReceiveMessage is called whenever a message is sent to this object.

Let’s implement the obvious parts of this class now.

cScript::~cScript()
{
}

cScript::cScript(const char* pszName, int iHostObjId)
        : m_iRef(1), m_szName(pszName), m_iObjId(iHostObjId)
{
}

HRESULT cScript::QueryInterface(REFIID riid, void** ppout)
{
    if (riid == IID_IUnknown)
        *ppout = static_cast<IUnknown*>(this);
    else if (riid == IID_IScript)
        *ppout = static_cast<IScript*>(this);
    else
        return E_NOINTERFACE;
    static_cast<IUnknown*>(*ppout)->AddRef();
    return S_OK;
}

ULONG cScript::AddRef(void)
{
    return ++m_iRef;
}

ULONG cScript::Release(void)
{
    if (m_iRef)
    {
        if (--m_iRef == 0)
            delete this;
    }
    return m_iRef;
}

const char* cScript::GetClassName(void)
{
    // Name MUST match the one in the list.
    // Still, we don't want to toss a NULL pointer around, do we?
    return (m_szName) ? m_szName : "cScript";
}

There’s nothing particularly unusual here. The m_szName variable is initialized with a simple pointer copy. Because our scripts will be created by our own factory functions, I’m taking advantage of this to always pass a static string to the constructor. If this weren’t the case, then you’d want to allocate a copy of the string for each instance.

Also, none of these functions need to be defined in a derived class. The only thing we’ll have to write for that is a constructor, destructor, and ReceiveMessage. It’s only what happens there that differentiates your scripts. Let’s take care of that now, as well as the mysterious ScriptPtrQuery you’ve been wondering about.

long cScript::ReceiveMessage(sScrMsg* pMsg, sMultiParm* pReply, eScrTraceAction eTrace)
{
    long iRet = 0;
    if (!stricmp(pMsg->message, "ScriptPtrQuery"))
    {
        iRet = ScriptPtrQuery(static_cast<sPtrQueryMsg*>(pMsg));
    }
    return iRet;
}


long cScript::ScriptPtrQuery(sPtrQueryMsg* pMsg)
{
    // Check class name 
    if (!stricmp(pMsg->pszDestClass, GetClassName()))
    {
        *(pMsg->pScriptReceptacle) = reinterpret_cast<void*>(this);
        return 0;
    }
    return 1;
}

When this script receives a message, it compares the name of the message to the string "ScriptPtrQuery". If it matches, then the function ScriptPtrQuery is called with message data. Notice that the we’re using stricmp, which compares strings case-insensitively. Most functions in Thief ignore the case of strings; you should do the same.

The three arguments to ReceiveMessage are the message structure, a reply, and a trace flag. The trace flag is used for debugging, and advises the script to either print what it’s doing to the monolog, to cause a debugger interrupt, or to operate normally. Feel free to ignore this argument; I’ve actually never used it.

The second argument, pReply, allows the script to reply to the message with a specific value. The return value of the function is a long integer, and only signifies whether the message was handled successfully or not. If there are no errors, you should return 0, and non-zero (usually 1) otherwise. But in some cases, a message expects a response from your script, this is supplied in the pReply argument. For example, when you send a script message from a pseudo-script, Thief will check the reply and will abort the pseudo-script if the reply is zero or “False”.

The type of pReply is sMultiParm. That, along with the related cMultiParm, is like the Variant type of COM. A multi-param can be one of five types: integer, float, string, vector, or boolean. In the case of strings and vectors, the value stored in the multi-parm is a pointer to the data. The vector type, as you can guess, is a structure that consists of three floats, labeled x, y, and z. The pointer used for a string or a vector must be allocated using the IMalloc interface. And if you modify a multi-param, you have to check if the old value was a string or vector and free the memory it was using. The cMultiParm class provides methods for doing all this. You don’t have to be too concerned with getting the data type right. When Thief reads a multi-param, it will convert it to whatever type it wants. Of course, you shouldn’t pass a boolean when it wants a vector, but most other conversions can be reasonably performed; including parsing of strings into numeric values. The implementation of cMultiParm in my version of the lg library can also perform these conversions.

Message data is passed to your script in a sScrMsg structure. This is actually a base-class with the information common to all messages. Many messages derive a class with more specific data in it. The sScrMsg class looks like this:

struct sScrMsg : public sScrMsgBase, public sPersistent
{
    int   from;
    int   to;
    const char* message;
    int   time;
    int   flags;
    cMultiParm data;
    cMultiParm data2;
    cMultiParm data3;

    sScrMsg();
    virtual ~sScrMsg();

    virtual int __thiscall Persistence();
    virtual const char* __thiscall GetName() const {
        return "sScrMsg";
    }
};

Actually, I’m simplifying quite a bit. There are a number of peculiarities about this class. You can see here that the class inherits from both sScrMsgBase (which just provides the IUnknown interface) and sPersistent. Unfortunately, multiple-inheritance isn’t very portable. The methods of the class also use __thiscall instead of the usual __stdcall or __cdecl. This is something unique to MSVC++. So anyone not using that compiler will have difficulty creating instances of sScrMsg. Fortunately, most scripts won’t have to worry about it. As you’ll see later, there is a script interface for sending messages that doesn’t require you to use the sScrMsg class.

The important part of the message structure is the data members. You have access to the object ID that the message is from, the object that the message was sent to (usually your own), the name of the message, the time at which the message was sent, some flags that can be ignored (I haven’t even figured out what they do, anyway!), and three multi-params with arbitrary data. These data fields are actually used pretty rarely. Where you will use them is when a message is sent from a pseudo-script.

Time in Thief is measured relative to the simulation, or “Sim” for short. It tries to average 1 tick of sim-time for every millisecond, but this depends on the speed of the computer. There are also times when the sim is suspended, such as when the player is reading a book. Whenever Thief needs to measure time, it does so using sim-time. (This keeps the game pretty well synchronized. But when something needs to take place in real-time, such as playing a sound schema, there is no guarantee that sim-time can keep up.)

For many messages, you’ll need to cast the pointer to a more specific class. In this case, the message we’re handling is ScriptPtrQuery. This is the relevant class:

struct sPtrQueryMsg : sScrMsg
{
    const char* pszDestClass;
    void** pScriptReceptacle;

    virtual const char* __thiscall GetName() const { return "sPtrQueryMsg"; }
};

Two extra fields have been added. The GetName method has also been overloaded. As you can see, the method simply returns the name of the class. You can use this to respond to messages without having to know the actual names. For example, the messages caused by an act/react stimulus can have many different names, but they will all be instances of sStimMsg.

The ScriptPtrQuery message is sent to ask for the pointer to a script instance. We first check if we are the script being requested by comparing the pszDestClass member to what we think our name is. If it matches, then we cast our instance pointer to void* and copy it to the pScriptReceptacle member. As you recall, cScript will be used as a base class for the other scripts we create. By handling this message here, all of our scripts will be able to respond to a ScriptPtrQuery message.

Let’s look at another message: “Sim”. This message is simpler, and much more common.

struct sSimMsg : sScrMsg {
    BOOL fStarting;

    virtual const char* __thiscall GetName() const { return "sSimMsg"; }
};

This message adds only one extra field. The Sim message is sent only twice during any mission: once when the mission starts, and once when it ends. The fStarting member will be TRUE for the first one, and FALSE for the second. Scripts are active all the time, even in Dromed’s editor mode. So it makes sense to only respond to certain messages while the simulation is active. If you have a script that is triggered when an object is unlocked, it would be inconvenient for it to activate every time you changed the “Locked” property in Dromed. Many scripts store a m_bSim variable with the value from the last Sim message. You may even want to add it to your base class.

There are some cases when a script can be started while the simulation is currently running, such as by adding a meta-property to an object. The script won’t receive the first Sim message in this case. So you can’t rely on it all the time. This is why I said that understanding scripts means understanding messages.

This is a complete script which could be used in the game. But since we’re only using it as a base class, there’s no point in telling our script module about it. The scripts that will be available for use will derive from this class. Let’s start with a script that simply prints the name of all messages it receives. The simplicity of this class demonstrates the advantage of inheritance.

class cScr_Echo : public cScript
{
public:
    virtual ~cScr_Echo() { }
    cScr_Echo(const char* pszName, int iHostObjId)
            : cScript(pszName,iHostObjId)
    { }

    STDMETHOD_(long,ReceiveMessage)(sScrMsg*,sMultiParm*,eScrTraceAction);

public:
    static IScript* __cdecl ScriptFactory(const char* pszName, int iHostObjId);
};

The derived script only needs to override RecieveMessage. We also are implementing the constructor and destructor inline. And we’ve finally gotten around to the ScriptFactory function. The factory function is declared the same way as the typedef used in sScrClassDesc. It is also declared as a static member function. This allows us to call the function without a class instance. The factory could also be a global function, but for the sake of organization, it’s convenient to have the factory associated with the script class it creates.

When our script receives a message, we want it to print the name of the message on-screen. This means calling a function that is part of the game engine. Dromed provides a number of interfaces specifically designed for use by scripts: the script services. The function that displays text on-screen is in the DarkUI script service. In the lg headers, it looks like this:

extern  const GUID  IID_IDarkUIScriptService;
interface IDarkUISrv : IScriptServiceBase
{
    STDMETHOD_(long,TextMessage)(const char *,int,int) PURE;
    •
    •
    •
};

The other methods of this interface have been omitted for brevity. The interface name is IDarkUISrv, and it is identified with the GUID IID_IDarkUIScriptService. To access the service, you call the GetService method of the script manager. We received a pointer to the script manager when our module was initialized. The GetService method accepts the GUID of a script service and returns its instance pointer, or NULL if the service can’t be found. GetService can only be used to retrieve registered script service interfaces. There are other global interfaces that aren’t script services; to get one of these you must use the QueryInterface method of the script manager.

The TextMessage method has three arguments. The first is the string to display. The second is the color of the text. The color format is the same as the COLORREF type used in the Windows GDI. If you specify 0 for the color, then Thief uses a default color, which is usually white. The last argument is how long the text will be displayed on the screen, specified as milliseconds. This also has a default value of 5 seconds; it’s used if you pass -1001 for the time.

In this case, the arguments of the function make sense, and you might’ve been able to figure it out on your own. However, some interface functions have non-obvious arguments. You should be cautious when using a function that you don’t know all the arguments to. Nearly all of the script services are known, and you can do quite a lot with only services. Other interfaces can be used, and many have similar functions to the script services, but they’re not as well documented which makes it more likely that you’ll have problems. (Script services are often just wrappers around other, lower-level interfaces. You might think it’s faster to use the other interface directly, but it’s really not.)

We can now implement the rest of the script.

long cScr_Echo::ReceiveMessage(sScrMsg* pMsg, sMultiParm* pReply, eScrTraceAction eTrace)
{
    long iRet = cScript::ReceiveMessage(pMsg, pReply, eTrace);

    try
    {
        string sOutput = pMsg->message;
        sOutput += "(";
        sOutput += static_cast<const char*>(pMsg->data);
        sOutput += ",";
        sOutput += static_cast<const char*>(pMsg->data2);
        sOutput += ",";
        sOutput += static_cast<const char*>(pMsg->data2);
        sOutput += ")";

        IDarkUISrv* pUI = static_cast<IDarkUISrv*>(
                              g_pScriptManager->GetService(IID_IDarkUIScriptService));
        if (pUI)
        {
            pUI->TextMessage(sOutput.c_str(), 0, -1001);
            pUI->Release();
        }
    }
    catch (exception& err)
    {
        // Don't pass exceptions out of the module.
        if (g_pfnMPrintf)
            g_pfnMPrintf("Error! %s\n", err.what());
    }

    return iRet;
}

The first thing our script does is call the ReceiveMessage method of the super-class. Whether you do this before or after the rest of your script depends on how you want it to behave. It doesn’t make much difference for this script, so we do it first. We then create a string with the name of the message, and also the value of the three data parameters. I chose to use the standard string class since it’s so easy to use. This means there’s a chance an exception will be raised during our script. If the exception were to be passed out of our script and back to Thief, it would cause the game to unexpectedly quit; it might even crash if your C++ runtime isn’t compatible with MSVC++. So we guard this section of code with a try-catch block. When an exception occurs, we will print a message in the monolog.

Once we’ve formatted the string, we call the GetService method of the script manager to get an instance of IDarkUISrv. Before using the interface, we make sure that the pointer is valid. Then we can call TextMessage using default values for color and time. Finally, the IDarkUISrv instance is released. You always have to release an interface when you no longer need it; just be sure you don’t release it too early, or more than once.

That’s all of our script. All we need to do now is make it available to the script module. This is done with the script factory and the sScrClassDesc structure.

IScript* cScr_Echo::ScriptFactory(const char* pszName, int iHostObjId)
{
    if (stricmp(pszName,"Echo") != 0)
        return NULL;

    // Use a static string, so I don't have to make a copy.
    cScr_Echo* pscrRet = new(nothrow) cScr_Echo("Echo", iHostObjId);
    return static_cast<IScript*>(pscrRet);
}

const sScrClassDesc cScriptModule::sm_ScriptsArray[] = {
    { "Demo", "Echo", "CustomScript", cScr_Echo::ScriptFactory }
};
const unsigned int cScriptModule::sm_ScriptsArraySize = 
                sizeof(sm_ScriptsArray)/sizeof(sm_ScriptsArray[0]);

This factory function will be virtually identical for each script you create. For this script, I’m passing a static string to the script constructor. This let’s me get away with not copying the name in each instance. And while it’s highly unlikely that the factory will be called with a different name, since I use a separate factory function for each script class, I check the name anyway. You could pass the name directly from the factory function to the script instance, but you should make a copy of the string in that case, since we can’t be sure that the string the game uses is constant.

When creating the script instance, I use the new operator with the nothrow specifier. That way, if there’s an error, new will return a NULL pointer rather than throw an exception. The script factory also returns NULL on error, so this behavior is preferred.

We’ve also created the static variables sm_ScriptsArray and sm_ScriptsArraySize for use by the script module class. For the size, we use a shortcut that calculates the number of elements in the array dynamically. The total size of an array is the number of elements in the array multiplied by the size of a single element. So the number of elements can be calculated by dividing the size of the array by the size of a single element in the array. Your compiler will recognize that this is a constant number that can be calculated ahead of time and write the computed value in the variable. So when we add scripts to the array, we don’t have to modify the size variable.

The new script module can now be compiled. There’s a bit more code, and you’ve probably organized the classes into separate source files. (If you haven’t, then you should.) But building the OSM is essentially the same as was previously covered. You’ll need to make sure you’ve included the appropriate headers, both from the lg headers, and also your C++ runtime. The filename of the script module should be “Demo.osm” to match the name that we’ve written in the script description.

Try out the module and, hopefully, it will work as expected. If you haven’t familiarized yourself with the various script messages yet, you can use this script to see some of the messages that are caused by events in the game.

I don’t like the message being displayed for 5 seconds, though. To make the script more flexible, let’s have the time be read from a property on the object. Dromed doesn’t allow us to define new property types, but there are many built-in properties that can be used by scripts. For storing a time value, the property ScriptTiming makes the most sense.

When editing a property in Dromed, you select the name from a menu. You may go to the sub-menu named “Script” then select the item named “Timing”. But internally, that property is identified with the name “ScriptTiming”. As a more extreme example, the property that is “Renderer\Dynamic Light” in Dromed is named “SelfLit” internally. It’s the internal name that scripts must use. You can get a list of all the properties by typing the command “list_props” in Dromed; the information will be displayed in the monolog.

To access an object’s properties from a script, you use the IPropertySrv interface. It is defined as:

extern  const GUID  IID_IPropertyScriptService;
interface IPropertySrv : IScriptServiceBase
{
    STDMETHOD_(cMultiParm*,Get)(cMultiParm &,object,const char *,const char *) PURE;
    STDMETHOD_(long,Set)(object,const char *,const char *,const cMultiParm &) PURE;
    STDMETHOD_(long,Set)(object,const char *,const cMultiParm &) PURE;
#if (_DARKGAME == 2) || (_DARKGAME == 3)
    STDMETHOD_(long,SetLocal)(object,const char *,const char *,const cMultiParm &) PURE;
#endif
    STDMETHOD_(long,Add)(object,const char *) PURE;
    STDMETHOD_(long,Remove)(object,const char *) PURE;
    STDMETHOD_(long,CopyFrom)(object,const char *,object) PURE;
    STDMETHOD_(int,Possessed)(object,const char *) PURE;
};

The SetLocal method in this interface is guarded by a conditional preprocessor statement. While the method of creating scripts is the same for all Dark Engine games, the actual interfaces are not all the same. Up until now, we’ve used interfaces that are command to all the games. But IPropertySrv is different in Thief 1/Gold than it is in Thief 2 and System Shock 2. To accommodate these differences, the preprocessor symbol _DARKGAME is set to the version of the game that is being compiled for. Set it to 1 for Thief 1/Gold, 2 for Thief 2, and 3 for System Shock 2. This also means that, unless you can somehow avoid using any incompatible interfaces, your script module will only be usable with one particular game.

You will also notice that there are two Set methods, where the first takes an additional string argument. The multi-param data type only represents one of five simple types, but properties can be more complex. To handle complex properties, you reference the data by both the property name and the field name. The field names for a property are exactly the labels used for the dialog box in Dromed. (Field names often have spaces and non-alphanumeric characters in them.) Remember that the vector data type will appear as three boxes labelled X, Y, and Z. If a property is just a simple type, or only has one field, then you set the field name to NULL.

For this script we only need to use two methods: Possessed and Get. The first tells us whether a property exists or not, and the second retrieves it. The first two arguments to these functions are the object ID we’re working with, and the name of the property. For Get, these are actually the second and third arguments because the first argument is the return value. (There’s another pesky MSVC incompatibility involved here. The interface is written this way because it works with all compilers.) The last argument to Get is the name of the field. The ScriptTiming property is just an integer, so we set this to NULL.

Our ReceiveMessage function now looks like this:

long cScr_Echo::ReceiveMessage(sScrMsg* pMsg, sMultiParm* pReply, eScrTraceAction eTrace)
{
    long iRet = cScript::ReceiveMessage(pMsg, pReply, eTrace);

    try
    {
        string sOutput = pMsg->message;
        sOutput += "(";
        sOutput += static_cast<const char*>(pMsg->data);
        sOutput += ",";
        sOutput += static_cast<const char*>(pMsg->data2);
        sOutput += ",";
        sOutput += static_cast<const char*>(pMsg->data2);
        sOutput += ")";

        int iTime = -1001;
        IPropertySrv* pProps = static_cast<IPropertySrv*>(
                                   g_pScriptManager->GetService(IID_IPropertyScriptService));
        if (pProps)
        {
            if (pProps->Possessed(m_iObjId, "ScriptTiming"))
            {
                cMultiParm mpTiming;
                pProps->Get(mpTiming, m_iObjId, "ScriptTiming", NULL);
                if (static_cast<int>(mpTiming) > 0)
                {
                    iTime = mpTiming;
                }
            }
            pProps->Release();
        }

        IDarkUISrv* pUI = static_cast<IDarkUISrv*>(
                              g_pScriptManager->GetService(IID_IDarkUIScriptService));
        if (pUI)
        {
            pUI->TextMessage(sOutput.c_str(), 0, iTime);
            pUI->Release();
        }
    }
    catch (exception& err)
    {
        // Don't pass exceptions out of the module.
        if (g_pfnMPrintf)
            g_pfnMPrintf("Error! %s\n", err.what());
    }

    return iRet;
}

The changes are the added variable iTime, and the use of IPropertySrv. Notice how the way we use IPropertySrv is very similar to how IDarkUISrv is used.

The script acts the same as before, but now you can add the ScriptTiming property to the object to adjust how long the text will stay on the screen. Another way you could modify the script is to make it also print the message to the monolog. Try it out, but instead of calling the g_pfnMPrintf pointer (too easy), use the IDebugScrSrv interface.

Script Manager

One of the more ubiquitous types of script is the relay. The purpose of a relay is to receive a message from a trigger, manipulate the message in some way, then re-send the message to a trap. Relay networks usually work with the messages “TurnOn” and “TurnOff”, and are connected using ControlDevice links. (System Shock 2 uses the SwitchLink flavor instead of ControlDevice. I will continue to use ControlDevice in this document, but the same things apply to SwitchLink links.)

Handling the incoming messages has already been covered. Sending messages is accomplished with the script manager interface. The script manager, which is likely the only interface you’ll use that isn’t a script service, has many methods related to the operation of scripts. Not all of these are meant to be used by scripts themselves. The methods that you do want to use are for sending messages, setting timers, and storing persistent data. All three of these are used in the next script: a relay that pauses between messages. The script will pass TurnOn messages, but will wait at least 1 second before sending another message. We do this by counting the number of TurnOn messages that are received; when the timer we set expires, a message is relayed and the counter decremented. We’ll respond to TurnOff messages by decrementing the counter, and cancelling the timer if it reaches 0.

The script class is, as expected, mostly the same as the previous scripts.

class cScr_SlowRelay : public cScript
{
public:
    virtual ~cScr_SlowRelay() { }
    cScr_SlowRelay(const char* pszName, int iHostObjId)
        : cScript(pszName,iHostObjId)
    { }

    STDMETHOD_(long,ReceiveMessage)(sScrMsg*,sMultiParm*,eScrTraceAction);

public:
    static IScript* __cdecl ScriptFactory(const char* pszName, int iHostObjId);

private:
    void SetTimer(void);
};

The script factory and entry in the scripts array should be obvious to you now.

The ReceiveMessage method needs to respond to three messages: TurnOn, TurnOff, and Timer.

long cScr_SlowRelay::ReceiveMessage(sScrMsg* pMsg, sMultiParm* pReply, eScrTraceAction eTrace)
{
    long iRet = 0;
    cMultiParm mpData;
    sScrDatumTag dataInfo;
    dataInfo.objId = m_iObjId;
    dataInfo.pszClass = m_szName;

    if (!stricmp(pMsg->message, "Timer"))
    {
        if (!stricmp(static_cast<sScrTimerMsg*>(pMsg)->name, "Delay"))
        {
            •
            •
            •
        }
    }
    else if (!stricmp(pMsg->message, "TurnOn"))
    {
        •
        •
        •
    }
    else if (!stricmp(pMsg->message, "TurnOff"))
    {
        •
        •
        •
    }
    else
        iRet = cScript::ReceiveMessage(pMsg, pReply, eTrace);

    return iRet;
}

Timers are named so you can have multiple timers active. You also don’t want to interfere with the timers set by other scripts that the object is using. (Although, to be honest, “Delay” is too common a name.) There is no extra data for TurnOn and TurnOff messages.

The task of sending the messages is carried-out when the timer expires.

        ILinkSrv* pLS = static_cast<ILinkSrv*>(
                                g_pScriptManager->GetService(IID_ILinkScriptService));
        ILinkToolsSrv* pLTS = static_cast<ILinkToolsSrv*>(
                                g_pScriptManager->GetService(IID_ILinkToolsScriptService));
        if (pLS && pLTS)
        {
            linkset lsLinks;
            pLS->GetAll(lsLinks, pLTS->LinkKindNamed("ControlDevice"), m_iObjId, 0);
            for (true_bool __p; lsLinks.AnyLinksLeft(__p), __p; lsLinks.NextLink())
            {
                sLink slInfo;
                lsLinks.query->Link(&slInfo);
                g_pScriptManager->PostMessage2(m_iObjId, slInfo.dest, "TurnOn", 
                                                  cMultiParm(), cMultiParm(), cMultiParm());
            }
            pLTS->Release();
            pLS->Release();
        }

The link service is used to iterate over all the ControlDevice links that originate from this object. Most interfaces will interpret an ID of 0 as meaning any link or object. When an interface returns ID 0, that means the link or object doesn’t exist, or there was some error.

We send the message using the PostMessage2 method of the script manager. There are four methods for sending messages: SendMessage, PostMessage, SendMessage2, and PostMessage2. The first two use a sScrMsg instance that you provide yourself. This is not a very efficient way to send messages, however. (There are also some compatibility issues.) But that’s the only way to send a message with extra data. If you don’t need more than the basic message features, which you usually won’t, then the second two methods will do the work of constructing the message structure using values you supply.

The difference between sending and posting a message is that a post will only prepare the message then return without waiting for the message to be delivered. You don’t get to see the reply from a posted message, but your script will run quicker. You would send a message if you need to receive a response, or want to wait until the message is delivered and processed before continuing.

That said, there’s actually a much simpler way of sending messages. I just showed you this to demonstrate the use of links and the PostMessage2 method. But in the link service is a method called BroadcastOnAllLinks. It does just what the above code does with a single call.

            pLS->BroadcastOnAllLinks(m_iObjId, "TurnOn", pLTS->LinkKindNamed("ControlDevice"));

There is another version of the method that accepts an extra multi-param argument. That version will compare the data associated with each link to the argument and will only relay the message if they match. This is most often used with ScriptParams links. The data is compared case-sensitively, though, so you may opt to do it manually as I previously described. Also, the messages that are sent will have the first data multi-param set to the comparison argument.

Going back to the beginning of the ReceiveMessage method, you’ll see that a sScrDatumTag structure is created and initialized. This is used when we retrieve and set the value of persistent script data. The Dark Engine doesn’t provide a way for scripts to marshal data the way that COM objects can. Instead, the script manager lets you manually store multi-param data in a database that will be saved with the mission. Each item is identified with an object ID, a class (script) name, and the name of the data.

Once we’ve sent a round of messages, we need to decide whether there are more messages we need to relay.

        dataInfo.pszName = "Timer";
        g_pScriptManager->ClearScriptData(&dataInfo, &mpData);

        dataInfo.pszName = "Count";
        if (g_pScriptManager->IsScriptDataSet(&dataInfo))
        {
            g_pScriptManager->GetScriptData(&dataInfo, &mpData);
            int iCount = static_cast<int>(mpData) - 1;
            if (iCount > 0)
            {
                mpData = iCount;
                g_pScriptManager->SetScriptData(&dataInfo, &mpData);
                SetTimer();
            }
            else
            {
                g_pScriptManager->ClearScriptData(&dataInfo, &mpData);
            }
        }

All we’re doing is subtracting 1 from the “Count” variable. The variable will be saved and another round of the timer set if the counter is greater than zero, otherwise we just erase the variable. This is basically the opposite of what happens when TurnOn is received. That looks like this:

        int iCount = 0;
        dataInfo.pszName = "Count";
        if (g_pScriptManager->IsScriptDataSet(&dataInfo))
        {
            g_pScriptManager->GetScriptData(&dataInfo, &mpData);
            iCount = static_cast<int>(mpData);
        }
        mpData = iCount + 1;
        g_pScriptManager->SetScriptData(&dataInfo, &mpData);

        dataInfo.pszName = "Timer";
        if (!g_pScriptManager->IsScriptDataSet(&dataInfo))
            SetTimer();

After incrementing the counter, we need to set the delay timer, but not if it’s already active. The “Timer” data will be set if a timer is active. Setting the timer is carried out in our own SetTimer method.

void cScr_SlowRelay::SetTimer(void)
{
    unsigned long iTime = 1000;
    IPropertySrv* pProps = static_cast<IPropertySrv*>(
                               g_pScriptManager->GetService(IID_IPropertyScriptService));
    if (pProps)
    {
        if (pProps->Possessed(m_iObjId, "ScriptTiming"))
        {
            cMultiParm mpTime;
            pProps->Get(mpTime, m_iObjId, "ScriptTiming", NULL);
            iTime = (static_cast<int>(mpTime) > 100) ? static_cast<int>(mpTime) : 100;
        }
        pProps->Release();
    }
    
    tScrTimer hDelay = g_pScriptManager->SetTimedMessage2(m_iObjId, "Delay", iTime, 
                                                             kSTM_OneShot, cMultiParm());
    cMultiParm mpDelay = reinterpret_cast<int>(hDelay);
    sScrDatumTag dataInfo;
    dataInfo.objId = m_iObjId;
    dataInfo.pszClass = m_szName;
    dataInfo.pszName = "Timer";
    g_pScriptManager->SetScriptData(&dataInfo, &mpDelay);
}

The duration of the timer is read from a property for flexibility. The timer is created by the method SetTimedMessage2 of the script manager. It creates a Timer message with a specified name. There is also a SetTimedMessage method that lets you use any message type, but it doesn’t return a timer handle which we want. A timer handle is a non-zero value that identifies an active timer. If you have the handle to a timer then you can abort it using the KillTimedMessage method. The value of a handle doesn’t mean anything, but you can save it as script data (by casting to int) and it will still be valid when the mission is restarted. It also lets you know whether or not a timer is active.

A timed message can either be one-shot or periodic. A one-shot timer will be killed when it expires, while a periodic timer automatically restarts itself until it is explicitly stopped using KillTimedMessage.

The last part of our script is the TurnOff handler. We will decrement the counter much like was done for the Timer message, but we also have to consider whether we need to stop the active timer.

        dataInfo.pszName = "Count";
        if (g_pScriptManager->IsScriptDataSet(&dataInfo))
        {
            g_pScriptManager->GetScriptData(&dataInfo, &mpData);
            int iCount = static_cast<int>(mpData) - 1;
            if (iCount > 0)
            {
                mpData = iCount;
                g_pScriptManager->SetScriptData(&dataInfo, &mpData);
            }
            else
            {
                g_pScriptManager->ClearScriptData(&dataInfo, &mpData);
                dataInfo.pszName = "Timer";
                if (g_pScriptManager->IsScriptDataSet(&dataInfo))
                {
                    g_pScriptManager->GetScriptData(&dataInfo, &mpData);
                    tScrTimer hDelay = reinterpret_cast<tScrTimer>(static_cast<int>(mpData));
                    g_pScriptManager->KillTimedMessage(hDelay);
                    g_pScriptManager->ClearScriptData(&dataInfo, &mpData);
                }
            }
        }

While there is no harm from trying to kill a timer that doesn’t exist, there is the possibility that a handle value may be assigned to another timer after a previous one is stopped. You should clear the script data, or set the handle to 0 after a timer is stopped.

Because of the use of script data, the source code of this script appears quite complex. But the basic logic of the script is not difficult to understand. And, unlike the previous scripts we wrote, this one can be a useful part of an actual mission.

Advanced Techniques

All of the steps necessary to use script data can be cumbersome, however. You will almost certainly want to use some sort of wrapper-class that makes using persistent variables transparent. One implementation is provided in the file scriptvars.h included with this document.

The scriptvars.h file contains class definitions for using a script data variable as an integer, a float, string, vector, or a handle such as for a timer.

Declaring a script data variable is straightforward, except for the handle class which needs to know the specific handle type.

    script_int m_integer;
    script_float m_float;
    script_str m_string;
    script_vec m_vector;
    script_handle<tScrTimer> m_timerhandle;

The internal names used for the script data are specified when you initialize the member variable.

    cScr_MyScript::cScr_MyScript(const char* pszName, int iHostObjId)
        : cScript(pszName, iHostObjId), m_integer("cScr_MyScript","m_integer",iHostObjId)
    { }

I’ve only shown the integer initialization, but the same can be used for any data type. You can also use a macro to simplify initialization.

#define SCRIPT_VAROBJ(className,varName,objId)  varName(#className, #varName, objId)

    cScr_MyScript::cScr_MyScript(const char* pszName, int iHostObjId)
        : cScript(pszName, iHostObjId), SCRIPT_VAROBJ(cScr_MyScript,m_integer,iHostObjId)
    { }

Finally, each script data variable needs to be set to an initial value. You should do this when handling the BeginScript message. By default, the Init method sets the data to 0 or an empty string. You can pass a value that should be used instead, except for handles which are always initialized to NULL. The vector type passes values using a pointer to a mxs_vector struct.

long cScr_MyScript::OnBeginScript(sScrMsg* pMsg)
{
    m_integer.Init();
    m_float.Init(1.0);
    m_string.Init("Foo");
    mxs_vector v = { 1.0, 1.0, 0.0 };
    m_vector.Init(&v);
    m_timerhandle.Init();
    return 0;
}

Now the variables can be used as if they were normal types. If you want to erase the data explicitly, the Clear method is provided. The pointer returned from a string variable will invalidated the next time the variable is read or when it goes out-of-scope. Clearing the script data is safe.

Another helpful wrapper-class is for the script services. The included version of the lg headers has a template class in interface.h that automatically releases an interface when it goes out-of-scope.

Two classes are defined: one for script services, and another for all other interfaces. They are both used similarly.

    SService<IObjectSrv> pObjSrv(g_pScriptManager);
    SInterface<ILinkManager> pLinkMan(g_pScriptManager);
    SInterface<IRelation> pRel;
    pRel = pLinkMan->GetRelationNamed("ControlDevice");
    if (pRel)
    {
    }

The first two lines show the most common use of the classes. As you can see, you must already have a pointer to the script manager interface. The SService class uses GetService to retrieve the interface, while SInterface uses the QueryInterface method. You can also use SInterface to wrap pointers that are returned from other interface methods. Note that if you initialize the class with a pointer, as opposed to simply assigning to it, then the AddRef method of the interface will be called.

Using the wrapped interface is the same as if you had a direct pointer. If cast to bool, then it returns whether the interface pointer is NULL. The Release method of the interface will be called once the variable goes out-of-scope. This means that you can break out of a loop, return, or throw an exception without worrying about which interfaces you’re still holding.

If you use the wrappers, then you should use a try-catch statement because an exception will be thrown if the interface can’t be found by GetService or QueryInterface.

Another candidate for improving both the performance of your scripts, as well as the ease of writing new scripts, is how you determine which script messages to respond to. We’ve been using simple string comparison functions so far, but this can be very slow, and cumbersome when dealing with a large number of messages. A better way to handle messages is to use a data structure that can compare strings using a hash function. This is how the original scripts work, and also how I write scripts in the modules that I release. (I do things a little differently than the original scripts, however.) Using a hash table is much too complicated for this document. If you’d like to explore this feature, you can download my public scripts and examine the source file Script.cc, which uses the map class from the standard template library.

Conclusion

With these techniques, and demonstrated classes, you have all the tools you need to create script modules for Thief, Thief 2, and System Shock 2. But you are still only halfway toward making useful scripts. There is still the myriad interface functions that you need to learn so your scripts can interact usefully, and effectively with the game engine. You can learn this by examining the header files, script source code that has been made public, and by talking with other script writers. But the best way is probably to experiment on your own. (By the time you put your own scripts into a mission, you’ll likely be very familiar with the Dromed crash dump.)

If you are writing scripts for System Shock 2, however, then you must be extra mindful of the differences between it and Thief. There are differences in all three games, but Thief 1/G and Thief 2 are so closely related that they can be treated almost identically. But System Shock 2 added many things that aren’t found in the other games, or that are unused even if present. You can still share scripting information with other programmers; you just may not be able to apply it directly.

Addendum

The following paragraphs are quite technical and may be ignored if you would rather not read a bunch of jargon. None of it is vital for understanding scripts or script modules.

Something that you won’t encounter when building a regular application, and not always when building a DLL either, is the base-address of the module. Every executable module that Windows loads for an application needs to sit at a unique and non-overlapping base-address. When creating a DLL, a default address is written as a reference. When it’s loaded, Windows first tries to use the default address, and if it isn’t available then it will be loaded someplace else. But loading at a different base-address means the references in the file are wrong, so Windows has to rewrite the addresses of those references. This can take a small, but not insignificant amount of time. So when you load two DLLs that both use 0x10000000 as a default base-address (which most compilers use), then one of them is going to be rewritten. If you instead used two different addresses for the DLLs, with enough difference to avoid overlapping, then there’s a better chance that the modules can be loaded without rewriting.

Choosing a base-address isn’t an exact science, however. You should pick a fairly large, and round number; a multiple of 0x100000 higher than 0x50000000 is a good choice. And you should try avoid overlapping other modules that might be loaded. A good list of how modules are arranged in memory can be found in the crash dump that Dromed makes. Looking at one right now, I can see that there’s a large chunk of unused space between 0x50000000 and 0x5AD00000. We just need to tell our linker that we want to use a custom base-address. For Borland, the option flag is -b:0x51000000. MSVC uses the option /base:"0x51000000". And with GCC it’s -Wl,--image-base=0x51000000, or with dllwrap, you can just use --image-base 0x51000000. GCC also has a feature designed for this situation; specify the option -Wl,--enable-auto-image-base and the base-address will be set to a semi-unique value based on the name of the DLL.

Copyright © 2004 Tom N Harris (telliamed@whoopdedo.cjb.net);
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON- INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.