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/.
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.
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.
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.
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.
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.
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.