April 21, 2021

Reverse engineering Battlefield 2 to improve input resolution

Battlefield 2 is from the early 2000s. Back then monitors did not have many pixels, which means far objects in BF2 would usually be less than a pixel in size. As such, aiming at such objects (without zooming in) wasn’t something that really happened, and as such was not properly tested.

This led to an issue, small mouse movements would make the screen go up a pixel but then return back to its original position. This was a known issue for over a decade.

The mouse was moved all across the mousepad, but registered almost no movement

One day while working an unrelated issue and printing player input values received by the server, I noticed that when moving my mouse very slowly, the input the server receive did not change from 0. This led me to believe that this issue is caused by some kind of rounding error.

Some time later I decided to look into it and find out how the game serializes inputs. I loaded up the Linux server binaries (which were, probably accidentally, compiled with ELF symbols) in IDA and starting looking around. Starting from a game object’s handlePlayerInput function we can see that there’s a io::PlayerInput type that stored player inputs, and reading how it’s used inside the function we learn that it was full of floats.

PlayerInput has no constructor, but there is a PlayerAction type that interacts with it. What’s the difference between them?
Looking up PlayerAction’s constructor and checking who references it, we can see that it’s called only by ActionBuffer::pushBack which is called by PlayerActionManager::processRcvdPlayerActions.
Looking at the rest of PlayerAction’s functions we can see that it contains the inputs in a serialized state and not as floats. We can guess that player packets are first processed into PlayerAction class.
PlayerAction also has a function called PlayerAction::getAxis(io::PlayerInput&) So if there’s any rounding error, it is likely to be here.

The function is simple enough. Reads an int16 from PlayerAction, and unserializes it by converting it to float and divideing by a 100. Then storing in a PlayerInput. That 100 is the resolution the developers chose to network the float in, one tick of the integer means 0.01f.

I went and found the same functions (set/getAxis) in the client binaries and changed the resolution to something more reasonable (while still making sure that it doesn’t overflow signed int16) and went to test if the issue still exists.

Small movements are now properly registered
Written on April 21, 2021