April 29, 2021

Extending BF2 Python API Part 4 - Ragdoll control (dragging)

Dragging has been a feature people requested for years. With dragging, you can pull teammates who were shot peeking a corner back past that corner, without risking yourself or them to fire when reviving. If you’ve read the previous parts then you’d think we already can control a wounded “Soldier” object. We already have the SetVelocity API we created in the previous steps, and could use it to push a Soldier towards the player that’s dragging him. However, this doesn’t work. Wounded soldiers ignore all SetPosition() from the default API, and our SetVelocity doesn’t do anything. The reason for this is that they go into a special Ragdoll mode.

Skeleton and Ragdoll

The Soldier model is split into “bones”, creating a “skeleton”. For alive Soldiers the shape of the skeleton depends on animations developers create. For wounded Soldiers, the control of the skeleton goes to the Ragdoll system, which tries to make the unconcscious Soldier look like it’s still obeying the laws of physics.

Ragdoll in Battlefield 2

The Ragdoll system of Battlefield 2 is very moddable. You can define in plain text which bones of the skeleton to create physics “Particles” for. Creating a particle for each bone would probably be too heavy, the developers of Battlefield 2 decided 13 particles is good enough.

rem ragDoll.addParticle <boneIndex> <mass> 
ragDoll.addParticle 1 8
ragDoll.addParticle 2 8
ragDoll.addParticle 4 8
...

These particles are then assigned constraints for these particles so they move like human are expected to move. These constarints control how these bones can rotate, what other particles they are connected to, distances from eachother, and so on. The hardcoded system doesn’t care what type of animal its simulating, the “human” definitions are loaded into the engine later from these files. You can find all these definitions in these files. Good luck figuring out what they mean, I haven’t seen a Battlefield 2 mod that messed with them, and the constraints aren’t relevant for this. objects\soldiers\Common\Animations\ragDollInit.con objects\soldiers\Common\Animations\ragDollConstraints.inc

Reversing the Ragdoll

From experience playing Battlefield 2, we can tell that the ragdolls are networked. Something pushing a wounded Soldier will push it to the same position for all players, or parts of the ragdoll getting stuck inside objects (which is a somewhat common issue in Battlefield 2) will be seen the same for everyone. If it’s networked, the physics are likely done by the server, and if it’s done on the server we can control it from there.
Loading up the linux server binaries again and searching for functions with “ragdoll” in them I find some interesting classes:

  • RagDoll
  • RagDollTemplate
  • RagDoll::Particle.

I also note some interesting functions:

  • RagDoll::writeCurrentState(dice::hfe::io::BitStream *) which is called by SoldierNetworkable::getNetUpdate. Any function writing to a bitstream is networking related, especially when its called by something that handles networkables. This class gets serialized and sent to players.
  • std::vector<RagDoll::Particle>. We have a STL vector storing Particles. A vector::fill function for it (which inserts N copies of the same value to the vector) is called by RagDoll::reset
  • RagDoll::wakeUp, another phyiscs optimization. Ragdoll goes to sleep when it stops moving. The function sets a bool and resets a float, we likely have to set it in Python as well if the RagDoll is already sleeping.
  • Constructor for RagDoll, called by RagDollTemplate::createInstance, which is called by Soldier::enableRagDoll. The result, a pointer to the constructed Ragdoll, is then stored in the Soldier struct. Soldier inherits the from the class we reversed in the last parts, this is a place we already know how to get to in Python. From testing, We see that the pointer is null when the Soldier is healthy or dead. But when wounded, it becomes a pointer. (When a Soldier goes full dead, his ragdoll becomes client-side, and the Soldier object is deleted from the server).

From this we know that wounded Soldier objects contain a pointer to a Ragdoll object, which contains a vector of Particles. The particles, likely contain the physics state (position/velocity) we want to be able to control. We also know that the Ragdoll is networked, so modifying it on the server should be enough for it to move on clients as well.
After finding the offsets for all of these in the windows binaries (estimating the offset compared to linux, then some trial and error), I check how the Particles look in memory, record them and seeing which offsets could store the velocity. Couple of failed experiments later, I got everything I needed.

Failed experiments. I was trying to "animate" the particles and set their position, but since changing their position caused them to be too far from eachother the constraints were impossible to fulfil and the IK system just gave up. Its always better to adjust velocity and let the physics engine deal with the result of that.

Once again, define the structs:

# sizeOf = 0x58
class RagdollParticle(Structure):
    _fields_ = [
        ("x", c_float),  # 0
        ("y", c_float),  # 4
        ("z", c_float),  # 8

        ("unknown", c_int8 * 0x18),  # C

        ("vx", c_float),  # 24
        ("vy", c_float),  # 28
        ("vz", c_float),  # 2C

        ("unknown", c_int8 * 0x28),  # 30
    ]
class Ragdoll(Structure):
    _fields_ = [
        ("unknown", c_int8 * 0x08),  # 0
        # std::Vector<Ragdoll::Particle>, treat as pointer to array and just select first

        # POINTER(RagdollParticle * 13) defines a a pointer to an array of 13 RagdollParticles.
        #(13 is bad hardcoding that will break if we change particle count)
        ("particles", POINTER(RagdollParticle * 13)),  # 0x08 
        ("unknown", c_int8 * 0x11),  # 0x0C
        ("isAwake", c_bool),  # 0x1D
    ]


class CSoldier(Structure):
    _fields_ = [
        ...
        ("ragdoll", POINTER(Ragdoll)),  # 254
    ]

Now we can take a small break from reversing and play with the new toys, testing that nothing else breaks

Testing velocity
Adding velocity to the ragdoll towards a point infront of me, and testing that this doesn't break revives. I was actually impressed by the AI's ability to follow the ragdoll
Adding velocity to a sleeping Ragdoll, and then touching it to release it from its sleep

Writing the API

From here its just normal Python (as much as ctypes can be called normal python)

def _getSoldierRagdollParticles(soldier, wakeUpRagdoll = False):
    csoldier = _getCObjectCasted(soldier, CSoldier)
    if not csoldier.ragdoll: return None # <---- bool() on a ctypes.POINTER does a null check
    ragdoll = csoldier.ragdoll.contents


    if wakeUpRagdoll:
        ragdoll.isAwake = True
        ragdoll.sleepTimer = 1.0

    return ragdoll.particles.contents

Make sure no other pythoner is allowed to mess with memory operations. And no writes to position as they upset the IK system if constraints are impossible to fulfill. Only write to velocity.

def getNearestRagdollParticle(position, team, maxDistance=1.5):
    '''
    :param position: Position to search around
    :param team: team of the soldiers to filter to
    :param maxDistance: ignore particles this far away
    :return: None if none are near, a tuple of closest (player, particleindex) if found.
    This tuple should be saved and used with the other functions
    '''
    ....

def getRagdollParticlePosition(PlayerParticleTuple):
     '''
    :param PlayerParticleTuple: The tuple from getNearestRagdollParticle
    :return: Position of the ragdoll particles
    '''
    ....

def pushRagdollParticle(PlayerParticleTuple, deltaV, maxSpeed=None):
    '''
    :param PlayerParticleTuple: The tuple from getNearestRagdollParticle
    :param deltaV: Vector to add to the speed vector
    :return: None
    '''

    ....

From here its just utilizing the normal API to check if a player has changed to the “dragging weapon” we created which has a special animation and applying velocity to the nearest particle which the player “grabbed”.

Final product
Written on April 29, 2021