State checks: what a player's character is doing
Bastrel reads, every 1.5 seconds per player, the state of the character as the server sees it, and compares it with what your server knows. A menu that makes a player invincible, invisible, strongly armoured or much stronger shows up here. This page covers seven codes.
Status: journal only, for now
Section titled “Status: journal only, for now”All seven codes ship as unverified. FiveM does not document whether the value a server native returns follows what a cheating client sets, and Bastrel never took that on faith. Until a test in game shows that the server sees the value, these codes are held in observe whatever your configuration says:
- they appear in the log and in the detections table, with
would_haveempty; - no alert, no Discord message, no score, no kick, no ban;
- they never cancel anything.
They are lifted one at a time, in a later release, after a capture on a real server. You can already read the journal to learn what your own scripts trigger, and declare them (below) so that the day a code goes live it is quiet on your server.
| Code | What it sees | Severity | How certain |
|---|---|---|---|
BAS-ST-001 |
A weapon in hand that is not the one your inventory (ox_inventory) says is equipped | critical | deterministic |
BAS-ST-010 |
A weapon damage, defense or melee modifier that differs from the expected value (state.expectedModifiers) |
critical | deterministic |
BAS-ST-020 |
Invincible | critical | deterministic |
BAS-ST-021 |
More health or armour than your configured caps | high | deterministic |
BAS-ST-022 |
Several impacts from different players, no loss of health or armour | low | clue only |
BAS-ST-030 |
Invisible character | medium | clue only |
BAS-ST-040 |
Camera focus far from the character | medium | clue only |
How a finding is made
Section titled “How a finding is made”- A player is read once per interval (
sampler.intervalMs, 1.5 s). A finding needs two consecutive samples that agree (state.confirmSamples), so a state seen once is never reported. A sample that does not agree, a native that answers nothing, or a grace, starts the count again. - The evidence of a finding carries the values read.
BAS-ST-030carriesvisible(false, the value the server read) besidessamples;BAS-ST-010carries the three modifiers read, the expected ones,epsilonandrelEpsilon. - A finding is reported once per player and code every 10 seconds (
state.reportMs), however long the state lasts. - Nothing is judged while the player loads (
graces.joinMs, 15 s) nor just after a change of routing bucket (graces.bucketChangeMs, 3 s). With QBX, the 15 s start when the character is loaded: a player on the character screen is never judged, however long it lasts, and a logout or a character change starts the wait again. Without that signal (another framework,qbx_corestopped, a load event missed because Bastrel restarted while the player was choosing), the 15 s start when the player joins; a load event Bastrel misses leaves that player unjudged until the next load (never a false report). There is no automatic grace for a respawn: the reanimation scripts of your server callallowTeleport. BAS-ST-020does not judge a character the framework says is dead or in last stand (with QBX:isdeadorinlaststand), because qbx_medical makes the ped invincible then. The framework’s death state can be written by the client (qbx_medical copies a state bag), so the exemption holds only while the server agrees: the character stays withinstate.deadMoveMeters(5 m) of where it was first seen dead, unless it is carried (attached) or a passenger in a vehicle that another ped drives (a character driving away, or in a vehicle with no readable driver, is not excused), or a teleport your scripts declared withallowTeleportlands it withinmovement.teleportGraceRadius(25 m) of the declared destination (the reference point then follows it), and it sends no weapon damage and throws no grenade, rocket or Molotov. Explosions and vehicle weapons are not counted yet. At moststate.reviveGraceMax(3) such episodes are excused perstate.reviveGraceWindowMs(10 minutes); past that, nothing is excused. After a revive, a character still invincible is excused forstate.reviveGraceMs(10 s), thenBAS-ST-020comes back. A finding within the window after a revive carriesafterRevive: truefor the review. If the framework answer is unknown, the check judges as before. For the owner: qbx_medical can leave the invincibility on after a revive (seen in game, 2026-10-24:GetPlayerInvinciblestays 1 at full health untilSetPlayerInvincible(false)); the grace does not cover that case, so Bastrel reports it once the grace ends. Report it to qbx_medical or clear the invincibility in your revive flow.- The limits of
BAS-ST-021are yours (state.healthCap,state.armourCap), never the maximum the game client reports: a client that raises its own maximum would raise the limit with it. Known gap: in one test (one client; maximum health raised first withSetPedMaxHealth, then health set to 300) the server read 200, soBAS-ST-021reported excess armour but not excess health. That test does not establish that the server never sees health above 200. Armour is seen (150 against a cap of 100). BAS-ST-040sees the focus when the player renders a scripted camera and keeps setting the streaming focus at the same point (tested: a focus 300 m from the character is reported). A streaming focus set on its own does not move the position the server reads; a rendered camera on its own was not tested.BAS-ST-022is a clue declared by other players’ clients, so it asks for at leaststate.noEffect.impacts(5) impacts of damage above zero instate.noEffect.windowMs(10 s), from at leaststate.noEffect.minAttackers(2, never less) different players, none cancelled (by Bastrel, or by another resource whose handler ran before Bastrel’s: a handler that runs after is not seen), with no loss of health or armour between the sample before the first impact and the samples after. The check waitsstate.noEffect.settleMs(3 s) after the last impact and never judges at the first sample that follows the impacts, because the health the server reads can lag behind a shot. A heal or regeneration that takes health plus armour above the previous sample cancels the judgement of that window (a known miss, never an accusation). A heal that exactly cancels the damage within one sample interval (for example a return to the cap) looks the same as godmode and stays a known false positive of this clue (low, clue only,unverified). A shot counts only when its list of victims holds exactly one player other than the shooter, so one forged event cannot spread impacts over several players. A lineshooter.not_loadedis logged once per session of a shooter that the framework reports as not loaded while declaring an impact (the impact still counts). Bastrel warns at startup whenstate.noEffect.windowMsminussettleMsis belowsampler.intervalMs, because the check can then hardly judge. A victim that the framework reports as dead or downed is excused only while the server corroborates it, as forBAS-ST-020(the body stays where it was first seen dead, or is carried, or followed a teleport your server declared, and it fires nothing): the state of the framework can be forged by a client, so a “dead” victim who walks away or shoots is judged. A player under aninvinciblegrace and a victim shot only by one player are never reported. A shot at a ped that the shooter owns does not reach the server at all, so the check only sees shots at players.
BAS-ST-001: a weapon outside the inventory
Section titled “BAS-ST-001: a weapon outside the inventory”Each sample, Bastrel compares the weapon the server reads on the character (GetSelectedPedWeapon) with the weapon ox_inventory says is equipped. It reports when a weapon is in hand and the inventory shows no weapon, or another one, for two consecutive samples (about 3 seconds). This is what a menu that gives weapons produces.
It never judges, and starts the count again, in these cases:
- the inventory status is unknown (ox_inventory stopped, inventory not loaded yet, an export that fails): “unknown” is never read as “no weapon”;
- the weapon in hand is a fist, or in
state.weaponIgnore; the inventory is then not even read; - the character is in a vehicle, or the server cannot tell;
- a weapon grace covers the weapon in hand (a grace for the equipped weapon alone does not). Bastrel grants it by itself (
state.weaponGraceMs, 2 s) when ox_inventory moves, buys or uses a weapon, and when the equipped weapon appears or disappears between two samples (a server-sideRemoveItemhas no hook, so this covers it). Your scripts can add one withexports.bastrel:allowState(src, 'weapon', 10000, { hash = <weapon hash> }), only ifgrants.allowedResourceslists them.
Limits. The inventory status comes in part from the player’s own client (ox_inventory records what the client answers), so it is never a proof on its own. A grace can be missing when a player holds many weapons (grants.maxWeaponHashes): the absence of a grace is never a proof of cheating either. The name of the parachute is not listed in the FiveM weapon reference, so it is not excluded by default: a player holding one shows as a finding (journal only) until you add its name or hash to state.weaponIgnore. A player with fists, no readable weapon or in a vehicle costs no export; an armed player costs one GetCurrentWeapon per sample, plus GetInventory when nothing is equipped. The cost is measured before the code is lifted.
ox_inventory already disarms. With its default setting (inventory:weaponmismatch true) the ox_inventory client takes away, every 200 ms, a weapon that is in hand while no weapon is equipped in the inventory. A weapon given by a menu is therefore usually removed before the second sample, and BAS-ST-001 does not report it (seen on 2026-10-20). The code stays useful against a client that defeats that disarming. To test it, run setr inventory:weaponmismatch false then restart ox_inventory, and put the setting back to true (and restart) afterwards.
Telling Bastrel your scripts are legitimate
Section titled “Telling Bastrel your scripts are legitimate”Use the grace exports (see grants.md) from your server scripts:
| Your script | Call |
|---|---|
| Cutscene, character creation, invincible by design | exports.bastrel:allowState(src, 'invincible', 10000) |
| Admin invisibility, character creation | exports.bastrel:allowState(src, 'invisible', 0), then revokeState |
| Your script sets a weapon modifier | exports.bastrel:allowState(src, 'weaponModifier', 10000) |
| Your script gives more than 200 health | exports.bastrel:allowState(src, 'health', 10000), or raise state.healthCap |
| Your script gives a weapon outside ox_inventory | exports.bastrel:allowState(src, 'weapon', 10000, { hash = GetHashKey('WEAPON_PISTOL') }) |
| Staff spectate, scripted camera | exports.bastrel:allowState(src, 'camera', 10000) |
weapon, weaponModifier, health and camera are refused until grants.allowedResources lists the resources allowed to grant them.
Settings (config/modules.lua)
Section titled “Settings (config/modules.lua)”| Setting | Default | Meaning |
|---|---|---|
state.confirmSamples |
2 |
Consecutive samples before a finding (2 to 10). |
state.modifierEpsilon |
0.01 |
Gap from the expected value above which a finding is raised (strictly greater) (above 0, at most 1). The tolerance of one getter is the larger of this value and state.modifierRelEpsilon times the expected value. The game hands the server a coarsely rounded modifier: 0.99607849 for 1.0 at rest and 4.9951124 for a set of 5.0 (seen in game, 19 October 2026: gaps of 0.0039 and 0.0049, and the gap grows with the value). Bastrel warns at startup when the tolerance of a getter is below 0.0049, because every player would then be flagged. A gap under the tolerance is not seen: 1.005 and 1.009 pass for an expected 1.0. |
state.modifierRelEpsilon |
0.01 |
Relative part of the tolerance (above 0, at most 1): 1 % of the expected value, so a server that expects 10.0 tolerates 0.1. |
state.expectedModifiers |
{ damage = 1.0, defense = 1.0, melee = 1.0 } |
The modifier your server expects for each weapon getter (numbers above 0). Change one only if a script of yours sets it for everybody. |
state.healthCap |
200 |
Health above this is a finding (1 to 1000). |
state.armourCap |
100 |
Armour above this is a finding (1 to 1000). |
state.focusMaxDistance |
150.0 |
Metres between the camera focus and the character. |
state.reportMs |
10000 |
One report per player and code in this window (1000 to 3600000). |
state.noEffect.impacts |
5 |
Impacts for BAS-ST-022 (2 to 100). |
state.noEffect.windowMs |
10000 |
Window of those impacts (1000 to 600000). |
state.noEffect.minAttackers |
2 |
Different players needed (2 to 16). |
state.noEffect.settleMs |
3000 |
Wait after the last impact before judging (0 to 60000, below windowMs). |
state.weaponGraceMs |
2000 |
Grace after an inventory change, used by the weapon check (500 to 10000). |
state.reviveGraceMs |
10000 |
Grace after a revive for BAS-ST-020: a character still invincible is excused this long (1000 to 60000). |
state.deadMoveMeters |
5.0 |
Distance from where a character was first seen dead beyond which BAS-ST-020 no longer excuses it (above 0, at most 100; not applied when carried or a passenger in a vehicle that another ped drives). |
state.reviveGraceMax |
3 |
Dead episodes excused per window for BAS-ST-020 (1 to 20). |
state.reviveGraceWindowMs |
600000 |
Sliding window of state.reviveGraceMax (60000 to 3600000). |
state.weaponIgnore |
{ 'WEAPON_UNARMED' } |
Weapons never compared with the inventory by BAS-ST-001 and BAS-GE-060: names or integer hashes, at most 64. Fists are always excluded, whatever this list says. If Bastrel cannot compute the hash of one of the names (or of the fists), BAS-ST-001 and BAS-GE-060 both judge nothing (a warning is printed once). Add the parachute once you know its name on your build. |
graces.joinMs |
15000 |
No check while a player loads, counted from the load of the character with QBX (0 to 600000). |
graces.bucketChangeMs |
3000 |
No check after a routing bucket change (0 to 600000). |
scoring.weights['BAS-ST-…'] |
40, 40, 40, 30, 5, 10, 10 | Weight of each code in the score once it is lifted. |
A missing setting takes its default silently. An invalid value takes its default and a warning is printed at startup; Bastrel never stops for it.
The checks run inside the scheduler (sampler), spread over the interval, with 12 native reads per sample, plus one or two ox_inventory exports for an armed player on foot, and a few more reads (position, vehicle, driver seat, attachment of the character) while the framework reports a player as dead and invincible, which is the episode of BAS-ST-020. The cost is measured on a development server before any code is lifted.