Custom objects - X-Hax/SADXModdingGuide GitHub Wiki

Note: this tutorial uses the old names, it will be updated soon enough.

This tutorial covers the creation of custom objects, you will need a DLL mod to achieve this.

Table of contents:

  1. Loading or replacing an object definition
  2. Creating the object definition
    1. General information
    2. The exec Function
    3. The Display Function
      1. Drawing an object
      2. Textures
      3. Object
    4. The Init Function
      1. Shape collisions
      2. Dynamic collisions

Loading or replacing an object definition

SADX (and SA2) use .SET files to store position, rotation and parameters of level objects. Each entry of a set file contains an index, a position, a rotation, and 3 parameters (and a clip level for SADX). To edit SET files, you can either use SADXLVL2 in SA Tools or Set Adventure.

struct SETEntry
{
	int16_t ObjectType; // (contains the ClipLevel with some bit cheating)
	Rotation3 Rotation;
	NJS_VECTOR Position;
	NJS_VECTOR Properties;
};

When the game reads the set file, it picks the object from the level's ObjectList at the set object index. The ObjectList is a list in the game's code that contains the object function, its clip distance and some other things. Editing object lists requires code if you want to add custom objects, if you just want to port existing SADX objects to other levels, you can do that with SA Tools by editing ObjectList and ObjectTextures config files.

struct ObjectListEntry
{
	LoadObj Flags; // This is the amount of data that is going to be allocated, use LoadObj_Data1 for now.
	char ObjectListIndex; // The list in which to add the object. There are 8 lists, 0 is system stuff, 1 is character stuff, 2 is usually common objects, 3 is usually level objects, 4 is usually enemy objects, 5 is usually effect objects, 7 is child objects...
	short UseDistance; // If 0, the default distance will be used, if 1 Distance will be used, if 2 it will load instantly only once.
	float Distance; // Distance, but squared
	int field_8; // Unknown
	ObjectFuncPtr LoadSub; // The function it is going to call to initialize the object
	const char *Name; // Probably for debug, unused
};

struct ObjectList
{
	int Count;
	ObjectListEntry *List;
};

So in order to load a custom object, we need to modify the object function in the object list. For example if we want to replace rings (index 0) in EmeraldCoast by a custom object, we would do ObjLists[LevelIDs_EmeraldCoast * 8 /* + ACT */]->List[0 /* ID */].LoadSub = CustomFunction; (there are 8 acts per level so we need to multiply by 8.) If we do this, the game will run our function for every object 0, which is ring in Emerald Coast.

We can also edit the ObjectList entirely:

ObjectListEntry CustomObjectList_Entries[] = {
	{ LoadObj_Data1, 2, 1, 1600000, 0, CustomFunction, "RING"},
	{ LoadObj_Data1, 2, 1, 1600000, 0, Spring_Main, "SPRING"},
	{ ... }
}

ObjectList CustomObjectList = { arraylengthandptr(CustomObjectList_Entries) };

ObjLists[LevelIDs_EmeraldCoast * 8 /* + ACT */] = CustomObjectList;

Or we can load a custom object ourselves by doing: CreateElementalTask(LoadObj_Data1, 3, CustomFunction);

Creating the object definition

General information

Once an object is loaded, a task is created, it contains the data of the object.

struct task (previously known as "ObjectMaster")
{
	task* next;            // Reserved, next object in the list
	task* last;            // Reserved, previous object in the list
	task* ptp;             // Reserved, parent object (if loaded with LoadChildObject)
	task* ctp;             // Reserved, child object (if a child was loaded with LoadChildObject)
	TaskFuncPtr exec;      // The function that gets called every unpaused frame
	TaskFuncPtr disp;      // The function that gets called every paused frame
	TaskFuncPtr dest;      // The function that gets called when the object is automatically/manually deleted
	OBJ_CONDITION* ocp;    // Reserved, contains the data from the set file
	taskwk* twp;           // Reserved, necessary for SET objects: contains position, rotation, scale, collision data
	motionwk* mwp;         // Reserved, loaded with LoadObj_Data2
	forcewk* fwp;          // Reserved, loaded with LoadObj_UnknownA
	anywk* awp;            // Reserved, laoded with LoadObj_UnknownB
	void* thp;             // Reserved, unique identifier	
};

The exec will be the function you set in the ObjectList / in the LoadObject function, if you change it, it will call the new one instead every other frame.

Object functions (exec, disp and dest) have for argument their task, it is unique to every object. In other words, the function is going to get called by every object, but the data it contains will be different.

void __cdecl CustomObject_Delete(task* obj) {
	// Remove stuff that the game doesn't automatically
	// (ie. only needed if you allocate stuff manually: new/delete operators, malloc/free, etc.)
}

void __cdecl CustomObject_Display(task* obj) {
	if (!MissedFrames) {
		// Handle display
	}
}

void __cdecl CustomObject_Main(task* obj) {
	obj->disp(obj); // Call the display function since it's only called automatically on PAUSED frames
}

void __cdecl CustomObject_Init(task* obj) {
	// Do init stuff

	obj->exec = CustomObject_Main;
	obj->disp = CustomObject_Display;
	obj->dest = CustomObject_Delete;
}

If your object is a SET object, it will automatically have an taskwk, with some informations already filled with its SET data. If your object was loaded manually with the LoadObj_Data1 flag, it will have an empty twp.

struct taskwk
{
	char mode;
	char smode;
	char id;
	char btimer;
	__int16 flag;
	unsigned __int16 wtimer;
	taskwk_counter counter;
	taskwk_timer timer;
	taskwk_timer value;
	Angle3 ang; // Contains the SET rotation
	NJS_POINT3 pos; // Contains the SET position
	NJS_POINT3 scl; // Contains the SET parameters
	colliwk* cwp; // Reserved, collision data allocation
	eventwk* ewp; // Reserved, unknown data allocation
};

You can edit all the variables as you want, except the reserved ones.

The exec Function

The exec function should contain the code that affects the object state. For example if you make your object move up every frame, this code should be put in the main function. Since the main function isn't called on paused frames, the object will automatically stop moving.

void __cdecl CustomObject_Main(task* obj) {
	taskwk* twp = obj->twp;

	twp->pos.y += 1; // Move 1 unit up every frame

	obj->disp(obj);
}

If your object was loaded from the set file, it won't handle the removal itself. SADX provides a function to delete objects that are too far : ClipSetObject. If the object comes from a set file it will delete itself when beyond the limit set in the ObjectList.

Here is a standard Main function for a set object:

void __cdecl CustomObject_Main(task* obj) {
	if (!ClipSetObject(obj)) {
		taskwk* twp = obj->twp;

		obj->disp(obj);
	}
}

List of useful functions:

  • CheckCollisionP(vector, radius); // Returns player id + 1 if it's within the sphere boundaries (previously known as IsPlayerInsideSphere)
  • CheckRangeOut(obj) // Deletes the object if it's beyond its draw distance (default draw distance if not a set object)

The disp Function

Drawing an object

This should contain the drawing code of the object (the only thing that happens when you pause the game).

Here is a standard drawing function:

void __cdecl CustomObject_Display(task* obj) {
	// Only draw if the frame is not skipped for performance
	if (!MissedFrames) { 
		taskwk* twp = obj->twp;

		njSetTexture(texlist);
		njPushMatrixEx(); // Pushes a normal matrix on the stack
		njTranslateV(0, &twp->Position); // Translate (move) to the object position
		njRotateXYZ(0, twp->ang.x, twp->ang.y, twp->ang.z); // Rotate to the object rotation
		DrawModel(model); // Draw there
		njPopMatrixEx(); // Pop the matrix from the stack
	}
}

A 3D matrix is some kind of table that contains positions, rotation and scale in some very clever way to make 3D space easier to work with. Matrices are on a "stack" which means you can push (add a matrix to the stack) and pop (remove that matrix from the stack). New matrices retain information from the previous matrices on the stack, so be sure to pop any matrix you push.

Note that translation, rotation and scale are relative to the current matrix state, so if you rotate an objet down, translating forward will make the object move down.

List of useful functions:

  • njSetTexture(texlist); // Set the list of texture it is going to use
  • njPushMatrixEx(); // Pushes one matrix
  • njPushMatrix(type); // 0 for normal matrix, _nj_unit_matrix_ for a unit matrix (not relative to the camera)
  • njTranslateEx(vector) // Translates a vector on the current matrix
  • njTranslateV(matrix, vector) // Translates a vector on a matrix (0 is current matrix)
  • njTranslate(matrix, x, y, z); // Translates individual floats on a matrix (0 is current matrix)
  • njRotateXYZ(matrix, x, y, z) // Rotates Z, then X, then Y to a matrix (0 is current matrix)
  • njRotateZYX(matrix, x, y, z) // Rotates X, then Y, then Z to a matrix (0 is current matrix)
  • njScale(matrix, x, y, z) // Scales individual floats to a matrix (0 is current matrix)
  • njScaleV(matrix, vector) // Scales a vector to a matrix (0 is current matrix)
  • DrawObject // Draws a basic object with hierarchy
  • DrawModel // Draws one model
  • DrawModel_Queue // Queue the model to be drawn later (use the depth bias variable to control their priority)
  • njPopMatrix(count) // Pops a number of matrices
  • njPopMatrixEx() // Pops one matrix

Textures

The texlist is the texture information that the object is going to use. If your object uses the object textures of Emerald Coast, use OBJ_BEACH_TEXLIST. The texture IDs that the model use are set in the model's data, you have to set them in SAMDL.

If you are using a custom texture (a pvm/pvmx/prs file in your mod's system folder) you should create your own texture list. If you don't know how to import custom textures, follow this guide.

NJS_TEXNAME CustomTexnames[TEXTURECOUNT];
NJS_TEXLIST CustomTexlist = { arrayptrandlength(CustomTexnames) };

Then you should either:

  • add this texlist to the level's texture list: TexLists_Obj[LevelIDs_EmeraldCoast][index] = CustomTexlist;
  • load the pvm manually with LoadPVM("name", CustomTexlist);

Finally, you can use njSetTexture(&CustomTexlist);

Object

This is the object that is going to be drawn. You can generate an NJS_OBJECT with SAMDL from SA Tools and use it with DrawObject(&CustomObject);.

If you don't have an object right now, you can draw an object from the game like DrawObject(&Sphere_Model);

The Init Function

What I call the init function is just the original main function that gets change to something else. Not all objects in SADX use an init function, but you should for tidiness. Just do the init stuff in that, then change the MainSub to your main function with obj->MainSub = function_Main. If you don't have anything to initialize, there's no need to have an init function.

Here are common stuff you can initialize:

Shape collisions

Shape collision is the most basic collision system of sadx, it does some simple 3D maths to check if the player is within a specific shape. An object can have several shape collisions. Shape collision also come with specific flags that can make them solid, damaging, pickable, etc.

The structure for creating a collision is:

struct CCL_INFO
{
	char kind; // An ID used to differentiate collisions
	char form; // The Collision Shape
	char push; // The solid flag
	char damage; // The interaction flag
	unsigned int attr; // Bunch of other flags
	NJS_POINT3 center; // Position offset from twp->pos
	float a;  // Parameters, change depending on the collision shape
	float b; // //
	float c; // //
	float d; // //
	int angx; offset rotation local to twp->ang
	int angy;
	int angz;
};

Here is the list of collision shapes, along with the parameter used:

enum CollisionShapes {
	CollisionShape_Sphere,		// a (radius)
	CollisionShape_Cylinder,	// a, b (radius, height)
	CollisionShape_Cylinder2,	// a, b (radius, height)
	CollisionShape_Cube,		// a, b, c (XYZ scale)
	CollisionShape_Cube2,		// a, b, c (XYZ scale, support YZ rotation)
	CollisionShape_Capsule = 6,	// a, b (radius, height; support XYZ rotation; cylinder with rounded ends that can't be walked on),
	CollisionShape_PushWall = 9	// a, b, c (width, height, power; a wall that pushes the player; support Y rotation)
};

Here is the list of solid flags:

  • 0x07 is solid
  • 0x70 is not pushable
  • 0x77 is solid, not pushable
  • 0xF0 is not solid, not pushable

When an object is pushable, it means it can be pushed by any other solid collision.

Here is the list of interaction flags:

  • 0x0 nothing
  • 0x2F damaging

List of useful functions:

  • CCL_Init(object, collisiondata, count, collision list);
  • EntryColliList(twp); // runs the collision for the current object
  • CCL_IsHitPlayer(twp); // Check if a player collides with twp, returns the player's taskwk or 0
  • CCL_IsHitBullet,(twp); // Check if a projectile collides with twp, returns the projectile's taskwk or 0
  • SET_COLLI_RANGE(twp->cwp, radius); // set the radius at which the collision starts, usually not needed

The collision lists you want to use are:

  • 2 = Targetable 1
  • 3 = Targetable 2
  • 4 = Normal

So here's an example of shape collision:

CollisionData CustomObject_Col[] = {
	{ 0, CollisionShape_Cube, 0x77, 0x2F, 0,{ 0, 40, 0 },{ 100, 120, 30 }, 0, 0 },
	{ ... }
};

void __cdecl CustomObject_Main(task* obj) {
	taskwk* twp = obj->twp;

	...

	EntryColliList(twp); // Add the collision for the current frame
};

void __cdecl CustomObject_Init(task* obj) {
	CCL_Init(obj, arrayptrandlength(CustomObject), 4); // Initialize the collision in list 4 (normal collision)

	obj->exec = CustomObject_Main;
	...
};

Note: when you initialize a collision, it is copied into data1->CollisionInfo. So you can modify the collision all you like by going into twp->cwp->info[index].

Dynamic collisions

These are collisions made out of models, it uses the same system as the level collision but in a different list.

Dynamic collisions use a list of NJS_OBJECTs, to get an empty object from this list do: NJS_OBJECT* object = ObjectArray_GetFreeObject();. Your dyncol is going to use the information on that NJS_OBJECT to work.

  • object->pos = position
  • object->rot = rotation
  • object->scl = scale
  • object->basicdxmodel = the collision model in BASIC format.

Once your dyncol object is set up, you need to save it somewhere in your game object for example in obj->Data1->Object so that we can either update it or remove it later.

Final step is initializing the dynamic collision: DynamicCOL_Add(colflag, entity, object);

  1. colflag = the surface flag that your object will have, you most likely want to use ColFlags_Solid. You can combine colflags using |. For example if you want a collision to be solid and hurting the player, put (ColFlags)(ColFlags_Solid | ColFlags_Hurt)
  2. entity = the pointer to the task entity (or "ObjectMaster")
  3. object = the dyncol object

You can the list of ColFlags by looking for the enum in the Mod Loader headers.

Finally, you will need to release the collision when your game object is deleted otherwise the game will likely crash. It can be done by adding DynamicCOL_Remove(GameObjectPtr, DyncolObjectPtr); in the delete sub of your game object.

Code example of dyncols: DynamicCollision.cpp, MovingDynamicCollision.cpp.