Something wrong with the Damage System.
What happened?
There is a problem with applying damage. Attacker -> Server -> ??
Attacker sends the request to the server -> Server successfully receives the request. But sometimes it has problems when applying the damage to the victim. Or does the server never send the damage to the victim? I don't know.
As you can see in the video, he shoot 5 bullets at the other player, but only 1 of them was applied to the player.
All damage requests are handled successfully in weaponDamageEvent. However, there is a problem when applying the damage to the target player. Some servers are waiting 50ms after the weaponDamageEvent is triggered and manually applying damage to the player if the damage has not been applied.
So far nothing has been done for PvP Servers. Please pay attention to this issue.
Example video:
https://github.com/user-attachments/assets/76b526eb-3fc7-4bd6-b04c-55cc2dc1178b
Expected result
Server should apply every damage to the player without any problem.
Reproduction steps
.
Importancy
Unknown
Area(s)
FiveM
Specific version(s)
FiveM/ Every Artifact
Additional information
No response
Pretty sure a lot of factors play into account on your issue. Factors like the dedicated server the server is on, the network up to the server side resmon / load.
To factor those out, you might want to create your own custom sync
Pretty sure a lot of factors play into account on your issue. Factors like the dedicated server the server is on, the network up to the server side resmon / load.
To factor those out, you might want to create your own custom sync
Dedicated Server. Ryzen 9 3900x CPU (+400 Average usage is %10) 10Gbit Network. (+400 Average usage is 50/100 Mbps.)
And if you read the issue details, the weaponDamageEvent triggers successfully no matter what and it's just a problem to apply the damage. So this is not a network or server related problem it's a Fivem / GTA issue.
The other client has to accept the damage event for it to be applied to their ped, you can try to use weaponDamageReply to see if they are actually accepting the damage event that the other client does.
This might be helpful in narrowing down the issue.
Do note that this can be inaccurate, there is no way to know in what order the client will reply to messages (if at all), so the caller/target could be wrong.
Here is an (over-engineered) example (note this isn't tested):
export interface WeaponDamageEvent {
actionResultId: number;
actionResultName: number;
damageFlags: number;
damageTime: Timestamp;
damageType: number;
f104: number;
f112: boolean;
f112_1: number;
f120: number;
f133: boolean;
hasActionResult: boolean;
hasImpactDir: boolean;
hasVehicleData: boolean;
hitComponent: number;
hitEntityWeapon: boolean;
hitGlobalId: number;
hitGlobalIds: number[];
hitWeaponAmmoAttachment: boolean;
impactDirX: number;
impactDirY: number;
impactDirZ: number;
isNetTargetPos: boolean;
localPosX: number;
localPosY: number;
localPosZ: number;
overrideDefaultDamage: boolean;
parentGlobalId: number;
silenced: boolean;
suspensionIndex: number;
tyreIndex: number;
weaponDamage: number;
weaponType: number;
willKill: boolean;
};
type Source = number;
type Timestamp = number;
interface PendingDamageData {
caller: Source,
health: number,
timestamp: Timestamp
}
class DamageHandler {
private static pendingDamages = new Map<Source, PendingDamageData[]>();
static get PendingDamageMap(): Map<Source, PendingDamageData[]> {
return this.pendingDamages;
}
static add(target: Source, damageData: PendingDamageData): void {
const pending = this.get(target);
if (pending) {
pending.push(damageData);
} else {
this.pendingDamages.set(target, [damageData])
}
}
static popFrontForTarget(target: Source): PendingDamageData | null {
const pending = this.get(target);
// don't know how we got here, but we have no pending response!
if (!pending) return null;
const data = pending.shift();
// cleanup data from the map if there are no more fields or if we just
// removed the last one.
if (!data || pending.length === 0) {
this.pendingDamages.delete(target);
}
// kep our return type consistent, null if it doesn't exist
if (!data) return null;
return data;
}
static get(target: Source): PendingDamageData[] | undefined {
return this.pendingDamages.get(target);
}
static delete(target: Source): void {
this.pendingDamages.delete(target);
}
}
on("weaponDamageEvent", (caller: string, event: WeaponDamageEvent) => {
const tgt = event.hitGlobalId;
if (tgt === 0) return;
const ent = NetworkGetEntityFromNetworkId(tgt);
if (!IsPedAPlayer(ent)) return;
const owner = NetworkGetEntityOwner(ent);
DamageHandler.add(owner, {
caller: parseInt(caller),
health: GetEntityHealth(ent),
timestamp: event.damageTime
})
})
interface WeaponDamageReply {
health: number;
time: Timestamp;
f131: boolean; // rejected?
}
on("weaponDamageReply", (caller: string, event: WeaponDamageReply) => {
const src = parseInt(caller);
const pending = DamageHandler.popFrontForTarget(src);
if (!pending) return console.log(`Got damage reply without a pending damage request`);
console.log(`Got damage reply, original health: ${pending.health}, new hp: ${event.health}, was rejected: ${event.f131}, timestamp diff: ${event.time - pending.timestamp}`);
})
setInterval(() => {
const gameTime = GetGameTimer();
for (const [target, callerData] of DamageHandler.PendingDamageMap) {
// if we haven't received a reply after a second then we likely aren't
// going to recieve one at all, clean it up and note that it didn't ever
// get a reply
const deleteForIndicies = [];
for (const [index, data] of callerData.entries()) {
if ((data.timestamp + 1000) < gameTime) {
console.log(`${target} didn't send a reply to ${data.caller} damage event`);
deleteForIndicies.push(index);
}
}
// the indicies should be in order so we'll just reverse it and delete
// from end -> begin so we don't remove the wrong indexs
const reversed = deleteForIndicies.reverse();
for (const index of reversed) {
callerData.splice(index, 1);
}
// if we removed all of the indicies from the array then we want to
// remove it from the damage map
if (callerData.length === 0) {
// i don't remember if this needs to be called when not inside an
// iterator
DamageHandler.delete(target);
}
}
}, 1000)
I ran several tests and came to the conclusion that the reason why the other client declines the weaponDamageEvent is due to a difference in how accuracySpread is calculated on both clients.
When using a weapon with high spread (which is default for automatic rifles), bullets can end up having slightly different trajectories on each client. I suspect this is because the spread is calculated independently on both ends. As a result, even if the shooter sees clear hits, the receiving client might reject them because from their perspective the bullets didn't actually hit.
https://github.com/user-attachments/assets/cd7921c7-25de-480a-91a8-817f68f7e1bf
To verify this behavior, I wrote a small test script that logs the last weapon impact positions of each player locally on their client:
local lastHit
local lastStart
while true do
local pedPool = GetGamePool('CPed')
for i, ped in ipairs(pedPool) do
if IsPedAPlayer(ped) then
local found, hit = GetPedLastWeaponImpactCoord(ped)
if found then
local player = NetworkGetPlayerIndexFromPed(ped)
local name = GetPlayerName(player)
print(name .. ' shot at ' .. hit.x .. ', ' .. hit.y .. ', ' .. hit.z)
end
end
end
Wait(0)
end
Output on client one: (shooting client)
Player1 shot at -1910.2446289062, 3301.6284179688, 33.40625
Player1 shot at -1910.2419433594, 3301.6333007812, 33.246509552002
Player1 shot at -1910.2160644531, 3301.6782226562, 33.189308166504
Player1 shot at -1910.2198486328, 3301.6716308594, 33.275722503662
Player1 shot at -1910.2111816406, 3301.6865234375, 33.197429656982
Output on client two:
Player1 shot at -1910.3804931641, 3301.3923339844, 33.184940338135
Player1 shot at -1910.1365966797, 3301.8161621094, 33.187858581543
Player1 shot at -1910.0836181641, 3301.9084472656, 33.195178985596
Player1 shot at -1910.0638427734, 3301.9428710938, 32.828342437744
Player1 shot at -1910.150390625, 3301.7922363281, 32.856979370117
As expected, the impact points differ noticeably even though the same bullets are being fired. This desync in spread calculations likely causes the weaponDamageEvent to be declined by the other client, since the bullet appears to miss on their side.
this issue is critical and should get a bit more attention given how big FiveM's PvP community has gotten.
The only thing I can think of is to allow a little margin of error so that the bullet dispersion is accepted as valid shots. But I don't know if it's a good idea.
"allow a little margin of error" honestly sounds really hard to implement. You would no longer be scanning a ray for a hit but some sort of cone.
Disabling the client side rejection weaponDamageEvent sounds easier but also prone to abuse. Maybe somehow sharing the randomness seed for the spread is a good balance of difficulty/utility? Might turn out to also be difficult though, depending on how the calculations are implemented 🤔