Advertisement

object handles references etc

Started by January 12, 2005 07:56 AM
16 comments, last by WitchLord 19 years, 10 months ago
Hi, I now have a script like this:
// script
void update(Camera &in cam) {
     Vector pos = cam.getPosition();
}

But would like to update so that the function itself takes care of getting whatever data it needs, like:

// c++:
Camera *getCamera();

// script (would be..)
void update() {
    Camera *cam = getCamera();
    Vector pos = cam->getPosition();
}

Now, when pointers are no longer available in AngelScript, what would be the best way to implement such a solution? There is always the "bstr" way to do it, but I would rather not have to write proxy functions if possible. I tried to do something with object handles but it didn't work very well, probably because I don't understand how to use them.

// c++
Camera *getCamera();

//....
r = engine->RegisterObjectType("Camera", 0, asOBJ_CLASS); assert(r >= 0);
r = engine->RegisterObjectBehaviour("Camera", asBEHAVE_ADDREF, "void f()", asMETHOD(Camera, ref), asCALL_THISCALL);
r = engine->RegisterObjectBehaviour("Camera", asBEHAVE_RELEASE, "void f()", asMETHOD(Camera, unref), asCALL_THISCALL); assert(r >= 0);

// ...
r = engine->RegisterGlobalFunction("Camera @getCamera(int)", asFUNCTION(getCamera), asCALL_CDECL); assert(r >= 0);



// script
void update() {
    Camera @cam = getCamera();
    Vector pos = cam.getPosition();
}
This gives me:

main (1, 1) : Info    : Compiling void update()
main (2, 16) : Error   : Need to be a handle
main (2, 16) : Error   : Can't implicitly convert from 'Camera' to 'Camera@&'.
Help me out here, what is the correct syntax or do I have to do it the bstr way? Thanks
You were almost right. You just need to take the handle of the returned value from getCamera():

// scriptvoid update() {    Camera @cam = @getCamera();    Vector pos = cam.getPosition();}


I'm thinking of making the compiler able to implicitly take the handle of an object, but it's not a high priority right now.

Regards,
Andreas

PS. Is WIP 3 working all right on Linux and Dreamcast? I didn't miss some necessary changes?

AngelCode.com - game development and more - Reference DB - game developer references
AngelScript - free scripting library - BMFont - free bitmap font generator - Tower - free puzzle game

Advertisement
All tests WIP3 works fine for linux and dreamcast (except the ones with alignmentbugs).

Though I have found a bug in the linux-version and I'm still not sure of the syntax for object handles. Down here follows a new test:

#include "utils.h"namespace TestObjHandle2{#define TESTNAME "TestObjHandle2"static const char *script1 ="void TestObjHandle()                   \n""{                                      \n""   refclass@ b = @getRefClass();       \n""   Assert(b.id == 0xdeadc0de);         \n""}                                      \n""";class CRefClass{public:	CRefClass() 	{		id = 0xdeadc0de;//		asIScriptContext *ctx = asGetActiveContext(); //		printf("ln:%d ", ctx->GetCurrentLineNumber()); //		printf("Construct(%X)\n",this); 		refCount = 1;	}	~CRefClass() 	{//		asIScriptContext *ctx = asGetActiveContext(); //		printf("ln:%d ", ctx->GetCurrentLineNumber()); //		printf("Destruct(%X)\n",this);	}	int AddRef() 	{		asIScriptContext *ctx = asGetActiveContext(); 		printf("ln:%d ", ctx->GetCurrentLineNumber()); 		printf("AddRef(%X)\n",this); 		return ++refCount;	}	int Release() 	{		asIScriptContext *ctx = asGetActiveContext(); 		printf("ln:%d ", ctx->GetCurrentLineNumber()); 		printf("Release(%X)\n",this); 		int r = --refCount; 		if( refCount == 0 ) delete this; 		return r;	}	int refCount;	int id;};CRefClass c;CRefClass *getRefClass() {	asIScriptContext *ctx = asGetActiveContext(); 	printf("ln:%d ", ctx->GetCurrentLineNumber()); 	printf("getRefClass() = %X\n", &c); 	return &c;}static void Assert(bool expr){	if( !expr )	{		printf("Assert failed\n");		asIScriptContext *ctx = asGetActiveContext();		if( ctx )		{			asIScriptEngine *engine = ctx->GetEngine();			printf("func: %s\n", engine->GetFunctionDeclaration(ctx->GetCurrentFunction()));			printf("line: %d\n", ctx->GetCurrentLineNumber());			ctx->SetException("Assert failed");		}	}}bool Test(){	bool fail = false;	int r; 	asIScriptEngine *engine = asCreateScriptEngine(ANGELSCRIPT_VERSION);	RegisterStdString(engine);	r = engine->RegisterObjectType("refclass", sizeof(CRefClass), asOBJ_CLASS_CDA); assert(r >= 0);	r = engine->RegisterObjectProperty("refclass", "int id", offsetof(CRefClass, id));	r = engine->RegisterObjectBehaviour("refclass", asBEHAVE_ADDREF, "void f()", asMETHOD(CRefClass, AddRef), asCALL_THISCALL); assert(r >= 0);	r = engine->RegisterObjectBehaviour("refclass", asBEHAVE_RELEASE, "void f()", asMETHOD(CRefClass, Release), asCALL_THISCALL); assert(r >= 0);	r = engine->RegisterGlobalFunction("refclass @getRefClass()", asFUNCTION(getRefClass), asCALL_CDECL); assert( r >= 0 );	r = engine->RegisterGlobalFunction("void Assert(bool)", asFUNCTION(Assert), asCALL_CDECL); assert( r >= 0 );	COutStream out;	engine->AddScriptSection(0, TESTNAME, script1, strlen(script1), 0);	r = engine->Build(0, &out);	if( r < 0 )	{		fail = true;		printf("%s: Failed to compile the script\n", TESTNAME);	}	asIScriptContext *ctx;	r = engine->ExecuteString(0, "TestObjHandle()", 0, &ctx);	if( r != asEXECUTION_FINISHED )	{		fail = true;		printf("%s: Execution failed\n", TESTNAME);	}	if( ctx ) ctx->Release();	engine->Release();	// Success	return fail;}} // namespace


The linux version of this fails since we return a pointer from getRefClass() and AngelScript thinks it returns an object. Since CDECL_RETURN_SIMPLE_IN_MEMORY is defined, CallCDeclFunctionRetByRef_impl will be called. This is not good since the value is ofcourse returned in $eax... Any suggestions?

When running this test on dreamcast, I get the following output:
ln:3 getRefClass() = 8C10B6FCln:3 AddRef(8C16BE88)ln:3 Release(8C16BE88)Assert failedfunc: void TestObjHandle()line: 3ln:3 Release(8C16BE88)


8C10B6FC obviously isn't 8C16BE88.. Is it me still not getting the usage, something wrong with the dreamcast implementation or a bug in AS?

Andreas, btw, do you prefer email over forum messages?
No, you are right in the way you use the object handle. This is most likely a bug in AS. PrepareSystemFunction() should identify the return of an object handle the same way as a reference is returned, because to C/C++ it is the same thing.

It ought to work if you change the first if statement in the function to:

if( func->returnType.isReference || func->returnType.isExplicitHandle )


I'll try to verify this in some tests here as well.

It's good to hear that the Linux and Dreamcast version are working with the fixes that were made. I've already implemented the RD1 and RD2 instructions for WIP4 so the alignment problems ought to work as well when I release that version. I'll make sure this new bug is also fixed before the next release.

I read the forum and my e-mail with about the same frequency, so I don't really have a preference to either.




AngelCode.com - game development and more - Reference DB - game developer references
AngelScript - free scripting library - BMFont - free bitmap font generator - Tower - free puzzle game

Yes, that if-case seems to fix the bug in linux. (The test still does not pass ofcourse)

Also, should Release really be called twice when AddRef is only called once?
The the fix wasn't quite that simple. The CallSystemFunction() has to be changed as well. The engine was right in releasing the object twice, but CallSystemFunction() wasn't adding a reference as it should when storing the object handle in objectRegister. Also CallSystemFunction() was erronously allocating memory for a new object, thinking it would return the object by value to the VM.

as_callfunc_x86.cpp
int CallSystemFunction(int id, asCContext *context, void *objectPointer){	id = -id - 1;	asQWORD retTemp = 0;	asDWORD retDW = 0;	asQWORD retQW = 0;	asCScriptEngine *engine = context->engine;	asSSystemFunctionInterface *sysFunc = engine->systemFunctionInterfaces[id];	asCScriptFunction *descr = engine->systemFunctions[id];	int     callConv           = sysFunc->callConv;	void   *func               = (void*)sysFunc->func;	int     paramSize          = sysFunc->paramSize;			asDWORD *args              = context->stackPointer;	void    *retPointer = 0;	void    *obj = 0;	asDWORD *vftable;	int     popSize            = paramSize;	context->objectType = descr->returnType.objectType;	if( descr->returnType.IsObject() && !descr->returnType.isReference && !descr->returnType.isExplicitHandle )	{		// Allocate the memory for the object		retPointer = malloc(descr->returnType.GetSizeInMemoryBytes());		if( sysFunc->hostReturnInMemory )		{			// The return is made in memory			callConv++;		}	}		if( callConv >= ICC_THISCALL )	{		if( objectPointer )			obj = objectPointer;		else		{			// The object pointer should be popped from the context stack			popSize++;			// Check for null pointer			obj = (void*)*(args + paramSize);			if( obj == 0 )			{					context->SetInternalException(TXT_NULL_POINTER_ACCESS);				free(retPointer);				return 0;			}			// Add the base offset for multiple inheritance			obj = (void*)(int(obj) + sysFunc->baseOffset);		}	}	asDWORD paramBuffer[64];	if( sysFunc->takesObjByVal )	{		paramSize = 0;		int spos = 0;		int dpos = 1;		for( int n = 0; n < descr->parameterTypes.GetLength(); n++ )		{			if( descr->parameterTypes[n].IsObject() && !descr->parameterTypes[n].isReference )			{#ifdef COMPLEX_OBJS_PASSED_BY_REF				if( descr->parameterTypes[n].objectType->flags & COMPLEX_MASK )				{					paramBuffer[dpos++] = args[spos++];					paramSize++;				}				else#endif				{					// Copy the object's memory to the buffer					memcpy(¶mBuffer[dpos], *(void**)(args+spos), descr->parameterTypes[n].GetSizeInMemoryBytes());					// Delete the original memory					free(*(char**)(args+spos));					spos++;					dpos += descr->parameterTypes[n].GetSizeInMemoryDWords();					paramSize += descr->parameterTypes[n].GetSizeInMemoryDWords();				}			}			else			{				// Copy the value directly				paramBuffer[dpos++] = args[spos++];				if( descr->parameterTypes[n].GetSizeInMemoryDWords() > 1 )					paramBuffer[dpos++] = args[spos++];				paramSize += descr->parameterTypes[n].GetSizeInMemoryDWords();			}		}		// Keep a free location at the beginning		args = ¶mBuffer[1];	}	context->isCallingSystemFunction = true;	switch( callConv )	{	case ICC_CDECL:		retQW = CallCDeclFunctionQWord(args, paramSize<<2, (asDWORD)func);		break;			case ICC_CDECL_RETURNINMEM:		retQW = CallCDeclFunctionRetByRef(args, paramSize<<2, (asDWORD)func, retPointer);		break;	case ICC_STDCALL:		retQW = CallSTDCallFunctionQWord(args, paramSize<<2, (asDWORD)func);		break;	case ICC_STDCALL_RETURNINMEM:		// Push the return pointer on the stack		paramSize++;		args--;		*args = (asDWORD)retPointer;		retQW = CallSTDCallFunctionQWord(args, paramSize<<2, (asDWORD)func);		break;	case ICC_THISCALL:		retQW = CallThisCallFunctionQWord(obj, args, paramSize<<2, (asDWORD)func);		break;	case ICC_THISCALL_RETURNINMEM:		retQW = CallThisCallFunctionRetByRef(obj, args, paramSize<<2, (asDWORD)func, retPointer);		break;	case ICC_VIRTUAL_THISCALL:		// Get virtual function table from the object pointer		vftable = *(asDWORD**)obj;		retQW = CallThisCallFunctionQWord(obj, args, paramSize<<2, vftable[asDWORD(func)>>2]);		break;	case ICC_VIRTUAL_THISCALL_RETURNINMEM:		// Get virtual function table from the object pointer		vftable = *(asDWORD**)obj;		retQW = CallThisCallFunctionRetByRef(obj, args, paramSize<<2, vftable[asDWORD(func)>>2], retPointer);		break;	case ICC_CDECL_OBJLAST:		retQW = CallCDeclFunctionQWordObjLast(obj, args, paramSize<<2, (asDWORD)func);		break;	case ICC_CDECL_OBJLAST_RETURNINMEM:		// Call the system object method as a cdecl with the obj ref as the last parameter		retQW = CallCDeclFunctionRetByRefObjLast(obj, args, paramSize<<2, (asDWORD)func, retPointer);		break;    case ICC_CDECL_OBJFIRST:        // Call the system object method as a cdecl with the obj ref as the first parameter        retQW = CallCDeclFunctionQWordObjFirst(obj, args, paramSize<<2, (asDWORD)func);        break;    case ICC_CDECL_OBJFIRST_RETURNINMEM:        // Call the system object method as a cdecl with the obj ref as the first parameter		retQW = CallCDeclFunctionRetByRefObjFirst(obj, args, paramSize<<2, (asDWORD)func, retPointer);		break;	default:		context->SetInternalException(TXT_INVALID_CALLING_CONVENTION);	}	context->isCallingSystemFunction = false;#ifdef COMPLEX_OBJS_PASSED_BY_REF	if( sysFunc->takesObjByVal )	{		// Need to free the complex objects passed by value		args = context->stackPointer;		int spos = 0;		for( int n = 0; n < descr->parameterTypes.GetLength(); n++ )		{			if( descr->parameterTypes[n].IsObject() && 				!descr->parameterTypes[n].isReference &&				(descr->parameterTypes[n].objectType->flags & COMPLEX_MASK) )			{				void *obj = (void*)args[spos++];				asSTypeBehaviour *beh = &descr->parameterTypes[n].objectType->beh;				if( beh->destruct )					engine->CallObjectMethod(obj, beh->destruct);				free(obj);			}			else				spos += descr->parameterTypes[n].GetSizeInMemoryDWords();		}	}#endif	// Store the returned value in our stack	if( descr->returnType.IsObject() && !descr->returnType.isReference )	{		if( descr->returnType.isExplicitHandle )		{			context->objectRegister = (void*)retQW;                        // It's the responsibility of the application function to increase the reference			// engine->CallObjectMethod(context->objectRegister, descr->returnType.objectType->beh.addref);		}		else		{			if( !sysFunc->hostReturnInMemory )			{				// Copy the returned value to the pointer sent by the script engine				if( sysFunc->hostReturnSize == 1 )					*(asDWORD*)retPointer = (asDWORD)retQW;				else					*(asQWORD*)retPointer = retQW;			}			// Store the object in the register			context->objectRegister = retPointer;		}	}	else	{		// Store value in returnVal register		if( sysFunc->hostReturnFloat )		{			if( sysFunc->hostReturnSize == 1 )				*(asDWORD*)&context->returnVal = GetReturnedFloat();			else				context->returnVal = GetReturnedDouble();		}		else if( sysFunc->hostReturnSize == 1 )			*(asDWORD*)&context->returnVal = (asDWORD)retQW;		else			context->returnVal = retQW;	}	return popSize;}


as_callfunc_sh4.cpp
int CallSystemFunction(int id, asCContext *context, void *objectPointer) {	id = -id - 1;	asQWORD retQW = 0;	asCScriptEngine *engine = context->engine;	asSSystemFunctionInterface *sysFunc = engine->systemFunctionInterfaces[id];	asCScriptFunction *descr = engine->systemFunctions[id];	int     callConv           = sysFunc->callConv;	void   *func               = (void*)sysFunc->func;	int     paramSize          = sysFunc->paramSize;	asDWORD *args              = context->stackPointer;	void    *retPointer = 0;	void    *obj = 0;	asDWORD *vftable;	int     popSize            = paramSize;	context->objectType = descr->returnType.objectType;	if( descr->returnType.IsObject() && !descr->returnType.isReference && !descr->returnType.isExplicitHandle )	{		// Allocate the memory for the object		retPointer = malloc(descr->returnType.GetSizeInMemoryBytes());		sh4Args[AS_SH4_MAX_ARGS+1] = (asDWORD) retPointer;		if( sysFunc->hostReturnInMemory )		{			// The return is made in memory			callConv++;		}	}	if( callConv >= ICC_THISCALL )	{		if( objectPointer )			obj = objectPointer;		else		{			// The object pointer should be popped from the context stack			popSize++;			// Check for null pointer			obj = (void*)*(args + paramSize);			if( obj == 0 )			{					context->SetInternalException(TXT_NULL_POINTER_ACCESS);				free(retPointer);				return 0;			}			// Add the base offset for multiple inheritance			obj = (void*)(int(obj) + sysFunc->baseOffset);		}	}	assert(descr->parameterTypes.GetLength() <= 32);	// mark all float arguments	int argBit = 1;	int hostFlags = 0;	int intArgs = 0;	for( int a = 0; a < descr->parameterTypes.GetLength(); a++ ) {		if (descr->parameterTypes[a].IsFloatType()) {			hostFlags |= argBit;		} else intArgs++;		argBit <<= 1;	}	asDWORD paramBuffer[64];	if( sysFunc->takesObjByVal )	{		paramSize = 0;		int spos = 0;		int dpos = 1;		for( int n = 0; n < descr->parameterTypes.GetLength(); n++ )		{			if( descr->parameterTypes[n].IsObject() && !descr->parameterTypes[n].isReference )			{#ifdef COMPLEX_OBJS_PASSED_BY_REF				if( descr->parameterTypes[n].objectType->flags & COMPLEX_MASK )				{					paramBuffer[dpos++] = args[spos++];					paramSize++;				}				else#endif				{					// Copy the object's memory to the buffer					memcpy(¶mBuffer[dpos], *(void**)(args+spos), descr->parameterTypes[n].GetSizeInMemoryBytes());					// Delete the original memory					free(*(char**)(args+spos));					spos++;					dpos += descr->parameterTypes[n].GetSizeInMemoryDWords();					paramSize += descr->parameterTypes[n].GetSizeInMemoryDWords();				}			}			else			{				// Copy the value directly				paramBuffer[dpos++] = args[spos++];				if( descr->parameterTypes[n].GetSizeInMemoryDWords() > 1 )					paramBuffer[dpos++] = args[spos++];				paramSize += descr->parameterTypes[n].GetSizeInMemoryDWords();			}		}		// Keep a free location at the beginning		args = ¶mBuffer[1];	}	context->isCallingSystemFunction = true;	switch( callConv )	{	case ICC_CDECL:	case ICC_CDECL_RETURNINMEM:	case ICC_STDCALL:	case ICC_STDCALL_RETURNINMEM:		retQW = CallCDeclFunction(args, paramSize<<2, (asDWORD)func, hostFlags);		break;	case ICC_THISCALL:	case ICC_THISCALL_RETURNINMEM:		retQW = CallThisCallFunction(obj, args, paramSize<<2, (asDWORD)func, hostFlags);		break;	case ICC_VIRTUAL_THISCALL:	case ICC_VIRTUAL_THISCALL_RETURNINMEM:		// Get virtual function table from the object pointer		vftable = *(asDWORD**)obj;		retQW = CallThisCallFunction(obj, args, paramSize<<2, vftable[asDWORD(func)>>2], hostFlags);		break;	case ICC_CDECL_OBJLAST:	case ICC_CDECL_OBJLAST_RETURNINMEM:		retQW = CallThisCallFunction_objLast(obj, args, paramSize<<2, (asDWORD)func, hostFlags);		break;	case ICC_CDECL_OBJFIRST:	case ICC_CDECL_OBJFIRST_RETURNINMEM:		retQW = CallThisCallFunction(obj, args, paramSize<<2, (asDWORD)func, hostFlags);		break;	default:		context->SetInternalException(TXT_INVALID_CALLING_CONVENTION);	}	context->isCallingSystemFunction = false;	// Store the returned value in our stack	if( descr->returnType.IsObject() && !descr->returnType.isReference )	{		if( descr->returnType.isExplicitHandle )		{			context->objectRegister = (void*)retQW;                        // It's the responsibility of the application function to increase the reference			// engine->CallObjectMethod(context->objectRegister, descr->returnType.objectType->beh.addref);		}		else		{			if( !sysFunc->hostReturnInMemory )			{				// Copy the returned value to the pointer sent by the script engine				if( sysFunc->hostReturnSize == 1 )					*(asDWORD*)retPointer = (asDWORD)retQW;				else					*(asQWORD*)retPointer = retQW;			}						// Store the object in the register			context->objectRegister = retPointer;		}	}	else	{		// Store value in returnVal register		if( sysFunc->hostReturnFloat )		{			if( sysFunc->hostReturnSize == 1 )				*(asDWORD*)&context->returnVal = GetReturnedFloat();			else				context->returnVal = GetReturnedDouble();		}		else if( sysFunc->hostReturnSize == 1 )			*(asDWORD*)&context->returnVal = (asDWORD)retQW;		else			context->returnVal = retQW;	}	return popSize;}


[edit]Changed the way object handles were treated upon return from the function.[/edit]

I applied the same changes to CallSystemFunction() in the SH4 file, but I haven't tested them.

I also noticed a possible memory leak in the SH4 file. When the arguments are prepared you have the #ifdef COMPLEX_OBJS_PASSED_BY_REF, but these objects are never deleted after the function returns. Please, take a look at how it works in the X86 file, and verify if the objects has to be deleted or not upon return from the function.

[Edited by - WitchLord on January 13, 2005 4:46:25 PM]

AngelCode.com - game development and more - Reference DB - game developer references
AngelScript - free scripting library - BMFont - free bitmap font generator - Tower - free puzzle game

Advertisement
Cool. That fix almost does it ;)

If we extend the script in test_objhandle2.cpp posted above to this:
static const char *script1 ="void TestObjHandle()                   \n""{                                      \n""   refclass@ b = @getRefClass();       \n""   Assert(b.id == 0xdeadc0de);         \n""   refclass a = getRefClass();         \n""}                                      \n""";


We get the following output (watch the 2nd release from the bottom):
ln:3 getRefClass() = 80816BCln:3 AddRef(80816BC)ln:3 AddRef(80816BC)ln:3 Release(80816BC)ln:5 getRefClass() = 80816BCln:5 AddRef(80816BC)ln:5 Release(80816BC)ln:5 Release(8082E78)ln:5 Release(80816BC)


So the "@" is really just cosmetic? Since except that the 2nd release from the bottom is wrong, there does not seem to be much difference when using the "@" and when not using it.

You are right about the memory leak. I'll send you a new version of the file together with some other small fixes tomorrow. Now I have to go to bed, I have to get up for an exam in 7 hours...
Quote: Original post by quarn
Since except that the 2nd release from the bottom is wrong, there does not seem to be much difference when using the "@" and when not using it.


Ok, maybe it wasn't wrong. That release probably belongs to the local variable "a". Though, if when registering the type with a size of 0, should one really still be able to create local variables like that? From the documentation I get the impression that the size of the object has to be larger than 0 if one wants to be able to use that object type for a local variable.

Quote: If you specify a byteSize larger than 0, this means that structure can be declared as a local object in the script functions.


See, my intention with the first script posted here, is that the scripters shouldn't be able to create new cameras and such, but they should be able to get hold of existing ones and modify their attributes. Therefore, I register with a size of 0 and use object handles to get hold of an existing camera.
ln:3 getRefClass() = 80816BC   ln:3 AddRef(80816BC)           // temporary variable holding the refln:3 AddRef(80816BC)           // b is holding the refln:3 Release(80816BC)          // temporary variable is releasedln:5 getRefClass() = 80816BCln:5 AddRef(80816BC)           // temporary variable holding the ref                               // object is copied to aln:5 Release(80816BC)          // temporary variable is releasedln:5 Release(8082E78)          // a is releasedln:5 Release(80816BC)          // b is released


I guess you figured out the difference between using the @ and not using it. When doing an assignment without the @ the value is copied to the other object, when doing the assignment with the @ the two variables will hold a reference to the same object.

Yes, when registering an object with the size of 0 it shouldn't be possible to declare local variables of that type. There will also not be any default assignment operator available for the type. I'll write a test to verify this fact. I may have broken this funcionality while developing 2.0.0.

AngelCode.com - game development and more - Reference DB - game developer references
AngelScript - free scripting library - BMFont - free bitmap font generator - Tower - free puzzle game

Quote: Original post by WitchLord
I guess you figured out the difference between using the @ and not using it.


Indeed.

Quote:
Yes, when registering an object with the size of 0 it shouldn't be possible to declare local variables of that type. There will also not be any default assignment operator available for the type. I'll write a test to verify this fact. I may have broken this funcionality while developing 2.0.0.


How did it go with that?

This topic is closed to new replies.

Advertisement