promotional bannermobile promotional banner

BS-AntiCheat

Movement, combat, world, inventory and packet-level cheat detection that's actually tuned not to flag your legit players. Install it, and your builders, miners and PvPers keep playing , while cheaters get caught.
Back to Files

BSAntiCheat-1.0.6.jar

File nameBSAntiCheat-1.0.6.jar
Uploaded
Sep 7, 2026
Downloads
44
Size
405.5 KB
File ID
8832378
Type
R
Release
Supported game versions
  • 26.3
  • 26.2
  • 26.1.2
  • 26.1.1
  • 26.1
  • 1.21.11
  • 1.21.10
  • 1.21.9
  • 1.21.8
  • 1.21.7
  • 1.21.6
  • 1.21.5
  • 1.21.4
  • 1.21.3
  • 1.21.2
  • 1.21.1
  • 1.21

What's new

[1.0.6] - 2026-09-07

Live alerts from two production servers, worked case by case, and then a review pass over what that work left behind. Every false-positive entry below started as an alert somebody looked at and said "that is not what happened" — and in most of them the check was not merely mistuned but measuring the wrong quantity. Three of the fixes here replace a model rather than a threshold, and two proposals were built, measured against the servers' own history, and then thrown away because the data did not support them.

Fixed — false positives

  • Water movement is judged by water physics. isSwimming() is only true in the horizontal swim pose. A player wading upright, surfacing, or carried by Dolphin's Grace is in water without ever entering it, fell through to WALKING, and was measured against the 0.4 dry-land cap — while the Depth Strider and Dolphin's Grace handling sat in a branch that was never reached. Live case: twelve SPEED alerts of 0.42–2.12 b/t, all labelled "walking", from a player with Depth Strider III and Dolphin's Grace.

    The fix applies the water cap as a floor on the on-foot cap, and deliberately does not reclassify the movement: the type gates far more than the speed limit — Step, Spider, GroundSpoof and the hover checks all require an on-foot type — so routing every in-water player to SWIMMING would have switched those off for anyone standing in a puddle. That was the first attempt, and it is the reason for the extra test that holds it shut.

  • Holding the drop key is not clicking. Every item dropped rides on a PLAYER_DIGGING packet and is accompanied by an arm swing, so holding Q emits one per tick and read as 26 CPS. Only the mining actions of that packet were excluded. The drop actions now suppress the swing too, through a deliberately short 200 ms window — mining's five-second safety cap would have turned one dropped item into a five-second free pass for a real clicker.

  • A thick vein counts as one find, not as its block count. The per-ore X-Ray threshold and the combined rare-ore count are block counts, and the hidden-ore test makes that worse rather than better: every block of a vein after the first is exposed by breaking its neighbour, so an entire deposit reads as hidden. The xray_min_veins gate was meant to prevent exactly this and could not, because the number it guarded stayed block-based. Live case: two fat deepslate veins produced 13 of 14 "hidden" diamonds and flagged an OP who had moved 3400 blocks of stone that hour. Each deposit now contributes at most xray_max_count_per_vein.

  • Strip mining is recognised by its shape. A new veto stands the counting checks down when the digging looks like a branch mine and the ore came out of the level it runs through. Calibrated against 4.5 months of this server's block log: honest strip miners hold one Y level to a standard deviation of 1.1–1.2, run 75–89 % of their digging as straight corridors, and take their ore from a 3.0–3.6 block band around them. The ore-band requirement is what keeps it from becoming a hiding place — without it a player could strip-mine honestly for ten minutes and have the veto cover the detours that were worth hiding.

  • The Timer check no longer punishes a bad connection. A link recovering from a stall delivers its backlog faster than one packet per tick but never all at once, so no single gap reaches PACKET_GAP_MS and the stall grace never engages while the balance climbs the whole way. Live case: a player at 1275–1444 ms round trips reached a balance of 779 ms and was flagged. Neither the height nor the duration of that excursion separates it from a hack — what does is that a catch-up drains and plateaus while a hack keeps gaining. The balance must now still be growing at the end of the excursion, and the required duration is extended by the measured round trip. The window is stretched, never the limit: a cheat that answers transaction pings late to fake latency then only delays its own flag instead of escaping it.

  • A ballistic arc is not hovering. The hover check counted any sample whose vertical speed stayed inside a ±0.08 band and never asked whether that speed was changing — but the apex of a slow arc sits inside the band for many samples in a row. Live case: dy ran +0.067 to −0.051 monotonically across all ten samples, with no potion, no gravity modifier and no teleport behind it (all three checked, not assumed). A hover holds its vertical speed; gravity keeps taking it away. checkSustainedAscent already judged motion this way — it asks whether a climb decays — and the hover check now does too.

  • Combat checks have a teleport and join grace. Every other check family had one: MovementChecker keeps recentTeleport and recentJoin, PacketChecker keeps graceUntil. Combat had none, so a hit landing while a teleport was still in flight was measured against a position no client involved had yet. It applies to both sides — being teleported yourself desyncs your view of everyone, and a target arriving next to you desyncs theirs.

  • The same swing is no longer counted several times. One hit does not always produce one event: item plugins that add their own damage fire EntityDamageByEntityEvent repeatedly with identical geometry. Measured here: of 327 hit groups, 83 arrived twice, 76 three times and 4 four times — same attacker, same victim, same aim angle to the degree. Every streak requirement in the combat checks was defeated by that, so a reach_violations of 3 was satisfied by a single over-reach hit. Deduplicated within one tick, before any streak is touched; weapon cooldowns put genuine hits hundreds of milliseconds apart.

  • Ranged item abilities are no longer judged as melee hits. This was the largest source of REACH alerts and it is not a threshold problem. MythicLib delivers ability damage as LivingEntity.damage(amount, player), which Bukkit reports as ENTITY_ATTACK with the player as damager — the exact shape of a melee hit, from wherever the ability reaches. Live case: a sword whose right-click fires a VOID_ZAPPER beam with length: 10.0; all 17 of that player's REACH alerts (4.14 to 9.37 blocks) fell inside that range, and the server had 21 such right-click abilities configured.

    A real melee hit is announced by the client with an INTERACT_ENTITY/ATTACK packet; ability damage has none, because the player never attacked anything. Damage without that packet is now skipped by all combat checks, not just reach: aim angle and multi-target are equally meaningless for a beam, which hits what it covers rather than what is aimed at, and which reaching three targets at once is exactly the "3 targets in 250 ms" that was being reported.

  • Reach latency is modelled as a distance, not a percentage. pingSlack scales a limit, which is right for a speed (blocks per tick × slack) and wrong for a distance: what latency costs here is however far the target travelled while the hit was in flight, and that is speed × time. Measured in a four-way PvP session at 373–1519 ms round trips — the old model allowed 4.7–5.5 blocks while a sprinting pair separates by 4.2 to 17.0 in one round trip, and the alerts ran to 14.61, a figure the multiplicative model cannot produce and ordinary play can. The allowance is now additive and capped, and above reach_max_ping_ms the check stands down rather than compensating further: past that the server cannot tell where either player was, and a hole that grows with latency is one a cheat can open by answering pings late.

  • Muted alert categories survive a disconnect. Persistent preferences are read from the config once, at startup, so anything dropped from memory on quit was gone for the rest of the server's uptime. cleanup() matched the stored entry with an exact-string test, which only ever recognised the "everything muted" form — a player who had muted individual categories counted as non-persistent and got those alerts back on rejoin.

  • The reach check's ping ceiling no longer switches off the rest of combat. Above reach_max_ping_ms reach stands down, which is right — but the exit was a return out of the event handler, so it took KillAura's aim angle and multi-target count with it. Neither becomes less answerable as latency rises: an aim 90 degrees off the target is off however long the packet took, and the multi-target count is a question about time, not distance. On a link like the one behind the timer case above (1275-1444 ms) that is KillAura switched off permanently, and a cheat can put itself there deliberately by answering transaction pings late — the same trick the timer check is explicitly hardened against.

  • A punishment tier no longer fires twice. The crossing test compared the level before and after the violation, and decay runs on every flag — so a VL sitting at exactly 1.0 is 0.9999 a moment later, floors back to 0, and the next violation "crosses" the same tier a second time. With a kick in the tier that is a duplicate; with a ban or tempban it is a second punishment for a violation already punished. Tiers are now tracked as served and re-arm when the level has decayed to zero.

  • A configured tier no longer fires one violation late. Levels were truncated, and decay runs on every flag and every read — so three violations a few seconds apart total 2.93 and floor made that a 2. Every threshold therefore acted one violation later than configured; for a kick tier of 25 that is the 26th alert. The same truncation made a fresh violation read as "no violations" in /bsac info a millisecond after it was counted, which is exactly when an admin looks after an alert. Levels are rounded now: the Nth violation reaches level N, while one that has genuinely decayed away still reads as gone. Verified live on mc-test — before the fix the notify tier needed three flags and the kick four, after it two and three.

  • The API event can no longer swallow a punishment. ViolationEvent was fired unguarded on the punishment's critical path, so anything that made the scheduling throw took the tier below it with it — violation counted, nobody punished, nothing in the log.

  • Punishment commands run on the target's own region thread. A kick, ban or tempban reaches into that player's state, and on Folia an entity may only be touched from the region that owns it; the global region is not that region. On Paper both are the main thread, so nothing changes there.

  • A rejected punishment command is reported. dispatchCommand's return value was discarded, so a typo in a tier, or a punishment plugin that had not loaded, looked exactly like a tier that never fired. Tiers that run are now logged, and an empty tiers section is called out the first time a level would have used it.

  • A hover run that once decayed can flag again. The drop is measured against the run's first sample and that reference never moved, so a run which exceeded fly_hover_max_drop once stayed silent for its whole length. Entering the still band from a rise (+0.06, then held at -0.01) keeps a player inside the ±0.08 band indefinitely with a permanent drop of 0.07: a hover descending 0.2 blocks a second that could never be flagged. Exceeding the drop now RESTARTS the run instead of only withholding the verdict, which costs the ballistic case nothing — it keeps falling out of the band anyway.

  • An edited punishment tier is no longer restored beside the admin's own. The reported case: "kick messages can't be edited, the plugin sets them back". The VL threshold is part of the KEY, so changing "40" to "10" looks to the config merge like key 40 was lost — and it put the shipped tier back, default kick message and all, next to the edited one, where it kept firing. Everyone changes that threshold, because 40 is out of reach for a per-check VL that decays every five minutes. Sections whose keys are the admin's data (punishments.tiers, xray_thresholds) are now left alone once they exist; a config that predates a section entirely still gets it in full.

Added

  • The plugin can punish without borrowing another plugin's commands. A tier entry of @kick [reason] is carried out directly, and @notify [text] tells the holders of bsanticheat.admin and nobody else. @kick keeps every colour format in its reason — a dispatched command hands its text to whichever plugin owns /kick, and vanilla's passes it through as plain text — needs no such plugin installed, and behaves the same on Paper and Folia because it runs on the player's own region. With no reason given it uses punishment.kick_default from the language file, so the message is translated rather than copied into every server's config. Anything without an @ is still run as a console command, so /tempban and friends are unaffected.
  • The shipped tiers are staggered: VL 12 notifies the admins, VL 25 kicks. Previously a single step at 40 — reachable only under sustained cheating, so switching punishments on appeared to do nothing at all. The low step costs nothing when an alert turns out to be a false positive, and nothing bans automatically; that decision stays with a human.
  • /bsac test <player> <CHECK> [count] raises a violation level by hand. There was no way to reach the punishment ladder without actually cheating — Speed and Fly need a modified client, Nuker and FastPlace need rates no hand achieves — so an admin who configured tiers could not find out whether they worked until somebody tripped them for real. It runs the real flag() path, so levels, decay, tier matching and the commands behave exactly as they will in earnest; which also means it really punishes, and logs who asked for it. Capped at 50 per call.
  • Every common colour format, mixable in one line. Only &0-&f was ever translated, so &#9863E7 and <#9863E7> reached the player as literal text. Messages now accept the classic codes, hex as &#RRGGBB and as &x&R&R&G&G&B&B, and MiniMessage (<#9863E7>, <red>, <bold>, <gradient:…>) — in any combination. The legacy rule that a COLOUR clears bold/italic is preserved, so existing language files render as before, and tags that are not colours are left alone so /bsac <reload|info|version> still reads correctly. One parser, in Messages, reached through LanguageManager — which is why no call site had to change.
  • PlaceholderAPI placeholders are resolved in the plugin's own messages. The expansion that PUBLISHES violation levels has existed since 1.0.0; nothing ever RESOLVED anybody else's placeholders. Alerts and punishment commands now do, against the player the message is about, and are unchanged on servers without PlaceholderAPI.

Changed

  • ChestStealer is off by default. The check judges the interval between container clicks, and vanilla shift-drag — hold shift, hold the button, sweep across slots — fires one InventoryClickEvent per slot crossed at 10–40 ms spacing. That is the exact band it watches, so one hand movement reads as a burst of inhuman clicking. Measured: a player sorting storage for two hours produced 96 alerts, and 88 % of their fast clicks landed on a slot adjacent to the previous one — a mouse path, not a click rate. Running total over the check's life: 97 alerts, 0 real findings.

    No threshold fixes that: widen the floor past 40 ms and the check is dead, keep it below and it keeps catching sweeps. A ChestStealer's actual signature is a container standing empty a fraction of a second after it was opened, which is a question about container state and open duration rather than click spacing. The code is kept for that rewrite. An alert type that is wrong every time teaches everyone to ignore it, and then a real one goes unnoticed.

  • /bsac reload now re-applies transaction_interval_ticks. The ping task's period is fixed when the task is created, so reloading the config alone left the old interval running.

  • Teleports now reset all movement streaks, not only speed and fly. Every one of them is built from deltas against a position the teleport just invalidated.

Fixed — correctness

  • DatabaseManager uses a bounded queue instead of a ConcurrentLinkedQueue guarded by a size check. That check ran on every logged violation and size() walks the whole queue — harmless while it is short, but it is only ever long during a database outage, i.e. exactly when every violation would pay for an O(n) scan.
  • AsyncConfigSaver no longer loses saves. The pending flag was checked while the saving flag was still set, so a request arriving in that gap found a save in progress, returned without scheduling anything, and was never picked up by anyone.
  • FallbackLogger drains into a batch before writing. Polling straight into the writer meant a failure part-way through — a full disk, a revoked permission, precisely the situations this file exists for — had already removed those entries from the queue.
  • CombatChecker uses a concurrent deque for its recent-target history. On Folia an EntityDamageByEntityEvent runs on the victim's region thread, so one attacker hitting entities in two regions reaches it from two threads at once.
  • WorldChecker no longer dereferences a rate window that a concurrent disconnect may have removed.
  • UpdateChecker's fallback applies the same guard as the loop above it, instead of dereferencing a missing field on exactly the malformed entry that loop skipped.
  • Exemptions.via is volatile — the packet checks read it from Netty threads, which share no happens-before edge with the enable that wrote it.
  • Merged config keys arrive with the comments that ship above them. mergeDefaults only ever copied the value, so an admin updating the plugin got every new switch as a bare line while a fresh install got the reasoning — worst for exactly the keys that were calibrated against live data.
  • A cheststealer_detection left ON is reported at startup. The merge only ADDS keys, so a changed default never reaches a server that already has the line; the value is the admin's decision, so it is named rather than overruled.
  • All 33 String.format calls use Locale.ROOT. Previously mixed, so number formatting depended on the host JVM's locale.

Diagnostics

  • [REACH-DEBUG] and [TIMER-DEBUG]: these two checks recorded nothing at all, which is why a 7.26-block reach alert and a 779 ms timer alert could not be taken apart afterwards. Reach logs from 80 % of its limit, not only on the flag.
  • [COMBAT-DEBUG] reports damage skipped as a plugin ability.
  • [HOVER-DEBUG] carries the absolute height and the measured drop.
  • The AutoClicker line carries the connection's current packet rate. A burst of arrivals and a fast hand both raise the click count, and on 2026-08-25 twelve AutoClicker alerts landed on a player who was mining continuously for 25 minutes — ten of them in the exact seconds Paper disconnected him with "You are sending too many packets". No threshold is drawn from this number yet; it exists so the next such case is decidable rather than arguable.
  • X-Ray logs the capped count and which veto fired.

Investigated, not shipped

Two proposals were built out, measured against 4.5 months of the server's own block log, and dropped. Both are recorded because the measurements are the useful part.

  • A longer spoil window for X-Ray. The false positive came from a player who dug for minutes and then spent one minute extracting, so the spoil that explained the ore had expired by the time the ore was counted. Widening the window fixes that case and does not separate anything: an X-Ray user tunnels straight from vein to vein and digs just as much, so a wider window hands them the searching exemption too. Reverted; the config keys remain with the reasoning attached, so the idea is not re-invented.

  • A long-horizon ore-to-stone ratio. The intuition was that honest mining has long dry stretches and X-Ray does not. Measured over 32 half-hour windows from four players, the honest maximum is 0.089 while a tunnelling X-Ray user computes to 0.04–0.13 — the two overlap, and a threshold high enough to avoid false positives (0.10) sits above where a typical cheat lands. A directedness proxy (what share of digging ends near ore) was tried next and separated no better: every honest player fell in a narrow 0.18–0.29 band, and an X-Ray tunnel produces the same, because only the tail of a corridor is near its ore.

    The exonerating side is calibratable and shipped (the strip-mining veto above). The incriminating side is not, on this data: there is no confirmed X-Ray case on either server to calibrate against, and any threshold picked without one is a guess wearing numbers.

Tests

96 → 182. New suites for the mining-shape veto, the drop key, the timer balance, the melee tracker, the combat grace, alert-preference persistence, the reach check's ping ceiling, the colour and placeholder handling, and the punishment path — which had none at all, because every scenario elsewhere replaces the ViolationManager with a counter to keep MockBukkit's missing region scheduler out of the way. That is how a tier could fire twice unnoticed.

Every behavioural fix was counter-checked by reverting it and confirming the new test fails. That caught four tests that passed for the wrong reason and proved nothing: two where a different guard was doing the work, one where the profile was one sample short of its threshold, and one whose "disabled" setting still satisfied the condition it was meant to break.

This mod has no related projects