diff --git a/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BlockCacheBukkit.java b/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BlockCacheBukkit.java index ce9730eacc..89deee2e8f 100644 --- a/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BlockCacheBukkit.java +++ b/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BlockCacheBukkit.java @@ -14,6 +14,10 @@ */ package fr.neatmonster.nocheatplus.compat.bukkit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; @@ -21,6 +25,8 @@ import org.bukkit.entity.EntityType; import fr.neatmonster.nocheatplus.compat.Bridge1_13; +import fr.neatmonster.nocheatplus.compat.SchedulerHelper; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; import fr.neatmonster.nocheatplus.utilities.map.BlockCache; import fr.neatmonster.nocheatplus.utilities.map.BlockFlags; import fr.neatmonster.nocheatplus.utilities.map.BlockProperties; @@ -28,6 +34,12 @@ public class BlockCacheBukkit extends BlockCache { + private static final long FOLIA_FALLBACK_LOG_INTERVAL_MS = 10000L; + private static final int FOLIA_FALLBACK_CONSOLE_LOG_MIN_COUNT = 2; + private static final AtomicInteger foliaFallbackCount = new AtomicInteger(); + private static final AtomicInteger foliaFallbackResolvedCount = new AtomicInteger(); + private static final AtomicLong nextFoliaFallbackLog = new AtomicLong(); + protected World world; /** Temporary use. Use LocUtil.clone before passing on. Call setWorld(null) after use. */ @@ -50,16 +62,92 @@ public BlockCache setAccess(World world) { @Override public Material fetchTypeId(final int x, final int y, final int z) { // TODO: consider setting type id and data at once. - return world.getBlockAt(x, y, z).getType(); + if (world == null) { + return Material.AIR; + } + if (SchedulerHelper.isOwnedByCurrentRegion(world, x, z)) { + return world.getBlockAt(x, y, z).getType(); + } + final Material fallbackType = fetchTypeIdWithLocationFallback(x, y, z); + return fallbackType == null ? Material.AIR : fallbackType; } @SuppressWarnings("deprecation") @Override public int fetchData(final int x, final int y, final int z) { // TODO: consider setting type id and data at once. - return Bridge1_13.hasIsSwimming() ? 0 : world.getBlockAt(x, y, z).getData(); + if (world == null) { + return 0; + } + if (SchedulerHelper.isOwnedByCurrentRegion(world, x, z)) { + return Bridge1_13.hasIsSwimming() ? 0 : world.getBlockAt(x, y, z).getData(); + } + final Integer fallbackData = fetchDataWithLocationFallback(x, y, z); + return fallbackData == null ? 0 : fallbackData.intValue(); + } + + private Material fetchTypeIdWithLocationFallback(final int x, final int y, final int z) { + // Folia compatibility: if the chunk-level ownership check cannot prove safety, retry the exact location. + // If that also fails, keep AIR as the safe final fallback instead of touching another region's block state. + boolean resolved = false; + try { + if (SchedulerHelper.isOwnedByCurrentRegion(new Location(world, x, y, z))) { + resolved = true; + return world.getBlockAt(x, y, z).getType(); + } + } + catch (Throwable t) { + // Keep the final AIR fallback for servers that throw while checking ownership or block state. + } + finally { + logFoliaBlockFallback("type", resolved); + } + return null; + } + + @SuppressWarnings("deprecation") + private Integer fetchDataWithLocationFallback(final int x, final int y, final int z) { + // Folia compatibility: retry exact-location ownership before returning legacy data 0 as the final fallback. + boolean resolved = false; + try { + if (SchedulerHelper.isOwnedByCurrentRegion(new Location(world, x, y, z))) { + resolved = true; + return Integer.valueOf(Bridge1_13.hasIsSwimming() ? 0 : world.getBlockAt(x, y, z).getData()); + } + } + catch (Throwable t) { + // Keep the final data 0 fallback for servers that throw while checking ownership or block state. + } + finally { + logFoliaBlockFallback("data", resolved); + } + return null; } + private static void logFoliaBlockFallback(final String kind, final boolean resolved) { + if (!CheckUtils.shouldLogDebugToConsole()) { + return; + } + final int count = foliaFallbackCount.incrementAndGet(); + if (resolved) { + foliaFallbackResolvedCount.incrementAndGet(); + } + final long now = System.currentTimeMillis(); + final long next = nextFoliaFallbackLog.get(); + if (now < next || !nextFoliaFallbackLog.compareAndSet(next, now + FOLIA_FALLBACK_LOG_INTERVAL_MS)) { + return; + } + final int resolvedCount = foliaFallbackResolvedCount.getAndSet(0); + final int periodCount = foliaFallbackCount.getAndSet(0); + // Folia diagnostic: a single safe fallback during teleport/chunk handoff is expected and not useful console noise. + if (periodCount < FOLIA_FALLBACK_CONSOLE_LOG_MIN_COUNT) { + return; + } + Bukkit.getLogger().info("[NCP][Folia][BlockCache] safe " + kind + " block fallback used " + + periodCount + " times in the last 10s, resolved=" + resolvedCount + + ", finalSafeFallback=" + (periodCount - resolvedCount) + "."); + } + @Override public double[] fetchBounds(final int x, final int y, final int z){ Material mat = getType(x, y, z); @@ -74,11 +162,17 @@ public double[] fetchBounds(final int x, final int y, final int z){ @Override public boolean standsOnEntity(final Entity entity, final double minX, final double minY, final double minZ, final double maxX, final double maxY, final double maxZ){ + if (!SchedulerHelper.isOwnedByCurrentRegion(entity)) { + return false; + } try{ // TODO: Probably check other ids too before doing this ? for (final Entity other : entity.getNearbyEntities(2.0, 2.0, 2.0)){ + if (!SchedulerHelper.isOwnedByCurrentRegion(other)) { + continue; + } final EntityType type = other.getType(); - if (!MaterialUtil.isBoat(type) && type != EntityType.SHULKER){ // && !(other instanceof Minecart)) + if (!MaterialUtil.isBoat(type) && type != EntityType.SHULKER){ // && !(other instanceof Minecart)) continue; } final double locY = entity.getLocation(useLoc).getY(); diff --git a/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BlockCacheBukkitModern.java b/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BlockCacheBukkitModern.java index 5e4ad162fd..da249a1133 100644 --- a/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BlockCacheBukkitModern.java +++ b/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BlockCacheBukkitModern.java @@ -16,6 +16,7 @@ import java.util.Map; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.data.BlockData; @@ -23,6 +24,7 @@ import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; +import fr.neatmonster.nocheatplus.compat.SchedulerHelper; import fr.neatmonster.nocheatplus.compat.bukkit.model.BukkitShapeModel; import fr.neatmonster.nocheatplus.compat.versions.ClientVersion; import fr.neatmonster.nocheatplus.players.IPlayerData; @@ -61,6 +63,37 @@ public int fetchData(int x, int y, int z) { } return super.fetchData(x, y, z); } + + @Override + public long fetchExtendedData(final int x, final int y, final int z) { + if (world == null) { + return 0L; + } + // Folia support: only query live Bukkit block data from the owning region thread. + if (SchedulerHelper.isOwnedByCurrentRegion(world, x, z)) { + return fetchWaterloggedFlags(x, y, z); + } + try { + if (SchedulerHelper.isOwnedByCurrentRegion(new Location(world, x, y, z))) { + return fetchWaterloggedFlags(x, y, z); + } + } catch (Throwable ignored) { + // Keep the cache fallback conservative if the scheduler API is unavailable. + } + return 0L; + } + + private long fetchWaterloggedFlags(final int x, final int y, final int z) { + final IPlayerData pData = getPlayerData(); + if (pData != null && pData.getClientVersion().isLowerThan(ClientVersion.V_1_13)) { + return 0L; + } + final BlockData data = world.getBlockAt(x, y, z).getBlockData(); + if (data instanceof Waterlogged && ((Waterlogged) data).isWaterlogged()) { + return BlockFlags.F_WATER | BlockFlags.F_LIQUID | BlockFlags.F_WATERLOGGED; + } + return 0L; + } @Override public double[] fetchBounds(int x, int y, int z) { @@ -96,9 +129,15 @@ public boolean isCollisionSameVisual(int x, int y, int z) { @Override public boolean standsOnEntity(final Entity entity, final double minX, final double minY, final double minZ, final double maxX, final double maxY, final double maxZ) { + if (!SchedulerHelper.isOwnedByCurrentRegion(entity)) { + return false; + } try { // TODO: Probably check vehicle ids too before doing this ? for (final Entity vehicle : entity.getNearbyEntities(0.1, 2.0, 0.1)) { + if (!SchedulerHelper.isOwnedByCurrentRegion(vehicle)) { + continue; + } final EntityType type = vehicle.getType(); if (!MaterialUtil.isBoat(type) && type != EntityType.SHULKER) { // && !(vehicle instanceof Minecart)) continue; @@ -114,17 +153,4 @@ public boolean standsOnEntity(final Entity entity, final double minX, final doub } return false; } - - @Override - public long fetchExtendedData(int x, int y, int z) { - BlockData bd = world.getBlockAt(x, y, z).getBlockData(); - if (bd instanceof Waterlogged && ((Waterlogged)bd).isWaterlogged()) { - final IPlayerData pData = getPlayerData(); - if (pData != null) { - if (pData.getClientVersion().isLowerThan(ClientVersion.V_1_13)) return 0; - } - return BlockFlags.F_WATER | BlockFlags.F_LIQUID | BlockFlags.F_WATERLOGGED; - } - return 0; - } -} \ No newline at end of file +} diff --git a/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BukkitAttributeAccess.java b/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BukkitAttributeAccess.java index 16e15da7e6..face72b1b6 100644 --- a/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BukkitAttributeAccess.java +++ b/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BukkitAttributeAccess.java @@ -67,6 +67,7 @@ private int operationToInt(final Operation operation) { * @param id * @return */ + @SuppressWarnings({"deprecation", "removal"}) private AttributeModifier getModifier(final AttributeInstance attrInst, final UUID id) { for (final AttributeModifier mod : attrInst.getModifiers()) { if (id.equals(mod.getUniqueId())) { diff --git a/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/model/BukkitSnow.java b/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/model/BukkitSnow.java index 224bea9fe7..122f6c8172 100644 --- a/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/model/BukkitSnow.java +++ b/NCPCompatBukkit/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/model/BukkitSnow.java @@ -21,11 +21,13 @@ import org.bukkit.block.data.type.Snow; import fr.neatmonster.nocheatplus.utilities.map.BlockCache; +import fr.neatmonster.nocheatplus.utilities.map.BlockCache.IBlockCacheNode; public class BukkitSnow implements BukkitShapeModel { - private static final double[][] SNOW_LAYERS = { - null, + private static final int MAX_COLLISION_LAYER = 7; + private static final double[][] SNOW_COLLISION_LAYERS = { + {0.0, 0.0, 0.0, 1.0, 0.0, 1.0}, {0.0, 0.0, 0.0, 1.0, 0.125, 1.0}, {0.0, 0.0, 0.0, 1.0, 0.250, 1.0}, {0.0, 0.0, 0.0, 1.0, 0.375, 1.0}, @@ -38,14 +40,8 @@ public class BukkitSnow implements BukkitShapeModel { @Override public double[] getShape(BlockCache blockCache, World world, int x, int y, int z) { - // TODO: Backward Handling - final Block block = world.getBlockAt(x, y, z); - final BlockState state = block.getState(); - final BlockData blockData = state.getBlockData(); - if (blockData instanceof Snow) { - return SNOW_LAYERS[((Snow)blockData).getLayers() - 1]; - } - return new double[] {0.0, 0.0, 0.0, 1.0, 1.0, 1.0}; + // Snow model: collision height is one layer lower than visual height; one-layer snow has no collision. + return SNOW_COLLISION_LAYERS[getCollisionLayer(blockCache, world, x, y, z)]; } @Override @@ -60,12 +56,33 @@ public boolean isCollisionSameVisual(BlockCache blockCache, World world, int x, @Override public int getFakeData(BlockCache blockCache, World world, int x, int y, int z) { - final Block block = world.getBlockAt(x, y, z); - final BlockState state = block.getState(); - final BlockData blockData = state.getBlockData(); - if (blockData instanceof Snow) { - return ((Snow)blockData).getLayers() - 1; + return getCollisionLayer(blockCache, world, x, y, z); + } + + private int getCollisionLayer(final BlockCache blockCache, final World world, final int x, final int y, final int z) { + if (world != null) { + try { + final Block block = world.getBlockAt(x, y, z); + final BlockState state = block.getState(); + final BlockData blockData = state.getBlockData(); + if (blockData instanceof Snow) { + return clampCollisionLayer(((Snow) blockData).getLayers() - 1); + } + } + catch (Throwable ignored) { + // Folia/async fallback: use already cached legacy data if it exists, otherwise use no collision. + } + } + if (blockCache != null) { + final IBlockCacheNode node = blockCache.getBlockCacheNode(x, y, z); + if (node != null && node.isDataFetched()) { + return clampCollisionLayer(node.getData()); + } } return 0; } -} \ No newline at end of file + + private int clampCollisionLayer(final int layer) { + return Math.max(0, Math.min(MAX_COLLISION_LAYER, layer)); + } +} diff --git a/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/KeepAliveAdapter.java b/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/KeepAliveAdapter.java index 07289b321c..b1807a355e 100644 --- a/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/KeepAliveAdapter.java +++ b/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/KeepAliveAdapter.java @@ -44,7 +44,7 @@ public class KeepAliveAdapter extends BaseAdapter { private final KeepAliveFrequency frequencyCheck = new KeepAliveFrequency(); public KeepAliveAdapter(Plugin plugin) { - super(plugin, ListenerPriority.LOW, PacketType.Play.Client.KEEP_ALIVE); + super(plugin, ListenerPriority.LOW, PacketType.Play.Client.KEEP_ALIVE, PacketType.Play.Server.KEEP_ALIVE); this.checkType = CheckType.NET_KEEPALIVEFREQUENCY; // Add feature tags for checks. if (NCPAPIProvider.getNoCheatPlusAPI().getWorldDataManager().isActiveAnywhere(CheckType.NET_KEEPALIVEFREQUENCY)) { @@ -70,11 +70,12 @@ public void onPacketReceiving(final PacketEvent event) { final IPlayerData pData = DataManager.getPlayerDataSafe(player); if (pData == null) return; final NetData data = pData.getGenericInstance(NetData.class); + recordKeepAlivePacketDetails(event, data, time); data.lastKeepAliveTime = time; final NetConfig cc = pData.getGenericInstance(NetConfig.class); // Run check(s). - // TODO: Match vs. outgoing keep alive requests. + // KeepAlive model: outgoing server ids are recorded in onPacketSending; matching replies are expected. // TODO: Better modeling of actual packet sequences (flying vs. keep alive vs. request/ping). // TODO: Better integration with god-mode check / trigger reset ndt. if (frequencyCheck.isEnabled(player, pData) @@ -83,8 +84,25 @@ public void onPacketReceiving(final PacketEvent event) { } } + private void recordKeepAlivePacketDetails(final PacketEvent event, final NetData data, final long time) { + final KeepAlivePacketInfo info = KeepAlivePacketInfo.read(event.getPacket()); + data.recordKeepAlivePacket(time, info.idAvailable, info.id, info.idType, info.longCount, info.intCount, + event.isAsync(), Thread.currentThread().getName()); + } + @Override public void onPacketSending(PacketEvent event) { - // TODO: Maybe detect if keep alive wasn't asked for + allow cancel. + final Player player = event.getPlayer(); + if (player == null) { + return; + } + final IPlayerData pData = DataManager.getPlayerDataSafe(player); + if (pData == null) { + return; + } + final KeepAlivePacketInfo info = KeepAlivePacketInfo.read(event.getPacket()); + pData.getGenericInstance(NetData.class).recordOutgoingKeepAlivePacket(System.currentTimeMillis(), + info.idAvailable, info.id, info.idType, info.longCount, info.intCount, + event.isAsync(), Thread.currentThread().getName()); } } diff --git a/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/KeepAlivePacketInfo.java b/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/KeepAlivePacketInfo.java new file mode 100644 index 0000000000..112bd7a070 --- /dev/null +++ b/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/KeepAlivePacketInfo.java @@ -0,0 +1,74 @@ +/* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package fr.neatmonster.nocheatplus.checks.net.protocollib; + +import com.comphenix.protocol.events.PacketContainer; +import com.comphenix.protocol.reflect.StructureModifier; + +/** + * ProtocolLib packet-shape extraction for keepalive request/response ids. + */ +final class KeepAlivePacketInfo { + + final boolean idAvailable; + final long id; + final String idType; + final int longCount; + final int intCount; + + private KeepAlivePacketInfo(final boolean idAvailable, final long id, final String idType, + final int longCount, final int intCount) { + this.idAvailable = idAvailable; + this.id = id; + this.idType = idType; + this.longCount = longCount; + this.intCount = intCount; + } + + static KeepAlivePacketInfo read(final PacketContainer packet) { + boolean idAvailable = false; + long id = 0L; + String idType = "none"; + int longCount = -1; + int intCount = -1; + try { + final StructureModifier longs = packet.getLongs(); + longCount = longs.size(); + if (longCount > 0) { + final Long value = longs.read(0); + if (value != null) { + idAvailable = true; + id = value.longValue(); + idType = "long"; + } + } + } + catch (Throwable ignored) {} + try { + final StructureModifier integers = packet.getIntegers(); + intCount = integers.size(); + if (!idAvailable && intCount > 0) { + final Integer value = integers.read(0); + if (value != null) { + idAvailable = true; + id = value.longValue(); + idType = "int"; + } + } + } + catch (Throwable ignored) {} + return new KeepAlivePacketInfo(idAvailable, id, idType, longCount, intCount); + } +} diff --git a/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/OutgoingPosition.java b/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/OutgoingPosition.java index bcd26d4907..7dab81e18a 100644 --- a/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/OutgoingPosition.java +++ b/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/OutgoingPosition.java @@ -117,6 +117,7 @@ private void interpretPacket(final Player player, final PacketContainer packet, } final CountableLocation packetData = data.teleportQueue.onOutgoingTeleport(x, y, z, yaw, pitch, teleportId); + data.recordOutgoingPosition(x, y, z, yaw, pitch, teleportId, packetData != null, time); if (packetData == null) { // Add counter for untracked (by Bukkit API) outgoing teleport. // TODO: There may be other cases which are indicated by Bukkit API events. diff --git a/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/UseEntityAdapter.java b/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/UseEntityAdapter.java index 1e6d5f64c8..d1e9dc1367 100644 --- a/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/UseEntityAdapter.java +++ b/NCPCompatProtocolLib/src/main/java/fr/neatmonster/nocheatplus/checks/net/protocollib/UseEntityAdapter.java @@ -33,6 +33,7 @@ import fr.neatmonster.nocheatplus.checks.net.NetConfig; import fr.neatmonster.nocheatplus.checks.net.NetData; import fr.neatmonster.nocheatplus.compat.versions.ServerVersion; +import fr.neatmonster.nocheatplus.logging.StaticLog; import fr.neatmonster.nocheatplus.players.DataManager; import fr.neatmonster.nocheatplus.players.IPlayerData; import fr.neatmonster.nocheatplus.utilities.ReflectionUtil; @@ -104,6 +105,10 @@ String getActionFromNMSPacket(Object handle) { private final LegacyReflectionSet legacySet; + private volatile boolean protocolLibEntityUseActionBroken = false; + + private volatile boolean protocolLibEntityUseActionWarningLogged = false; + public UseEntityAdapter(Plugin plugin) { super(plugin, PacketType.Play.Client.USE_ENTITY); this.checkType = CheckType.NET_ATTACKFREQUENCY; @@ -169,17 +174,20 @@ public void onPacketReceiving(final PacketEvent event) { if (!packetInterpreted) { // Handle as if latest. try { - final StructureModifier actions = packet.getEntityUseActions(); - // TODO: Not sure about version! - if (isServerAtLeast1_13) { - final StructureModifier enumActions = packet.getEnumEntityUseActions(); - if (enumActions.size() == 1 && enumActions.read(0).equals(WrappedEnumEntityUseAction.attack())) { + if (!protocolLibEntityUseActionBroken) { + final StructureModifier actions = packet.getEntityUseActions(); + if (actions.size() == 1) { packetInterpreted = true; - isAttack = true; + isAttack = actions.read(0) == EntityUseAction.ATTACK; + } + // ProtocolLib compatibility: modern snapshots can throw while initializing enum wrappers. + if (!packetInterpreted && isServerAtLeast1_13) { + final StructureModifier enumActions = packet.getEnumEntityUseActions(); + if (enumActions.size() == 1 && enumActions.read(0).equals(WrappedEnumEntityUseAction.attack())) { + packetInterpreted = true; + isAttack = true; + } } - } else if (actions.size() == 1 && actions.read(0) == EntityUseAction.ATTACK) { - packetInterpreted = true; - isAttack = true; } } catch (NullPointerException e) { @@ -188,6 +196,16 @@ public void onPacketReceiving(final PacketEvent event) { * why doesn't the LegacyReflectionSet work here? */ } + catch (LinkageError | RuntimeException e) { + protocolLibEntityUseActionBroken = true; + if (!protocolLibEntityUseActionWarningLogged) { + protocolLibEntityUseActionWarningLogged = true; + // Diagnostic logging: name the failing branch so attack/combat packet issues are clear in console. + StaticLog.logWarning("ProtocolLib could not expose USE_ENTITY action data (branch=enumEntityUseActions, packet=" + packet.getType() + "). Skipping AttackFrequency interpretation for USE_ENTITY packets until restart."); + StaticLog.logWarning("ProtocolLib USE_ENTITY action failure: " + e.getClass().getName() + ": " + e.getMessage()); + } + return; + } } if (!packetInterpreted) { // StaticLog.logWarning("Attack packet couldn't be interpreted. Skipping AttackFrequency."); @@ -218,4 +236,4 @@ private int getAction_legacy(final PacketContainer packetContainer) { return actionName == null ? 0 : (INTERPRETED | ("ATTACK".equals(actionName) ? ATTACK : 0)); } -} \ No newline at end of file +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/actions/types/CommandAction.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/actions/types/CommandAction.java index 5654599a2a..4c4e217312 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/actions/types/CommandAction.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/actions/types/CommandAction.java @@ -14,10 +14,11 @@ */ package fr.neatmonster.nocheatplus.actions.types; -import java.util.logging.Level; - -import org.bukkit.Bukkit; -import org.bukkit.plugin.Plugin; +import java.util.logging.Level; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; import fr.neatmonster.nocheatplus.actions.AbstractActionList; import fr.neatmonster.nocheatplus.actions.ParameterHolder; @@ -60,22 +61,35 @@ public CommandAction(final String name, final int delay, final int repeat, final /* (non-Javadoc) * @see fr.neatmonster.nocheatplus.actions.Action#execute(fr.neatmonster.nocheatplus.checks.ViolationData) */ - @Override - public void execute(final D violationData) { - final String command = getMessage(violationData); - SchedulerHelper.runSyncTask(plugin, (arg) -> { - try { - Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), command); - if (logDebug) { - debug(violationData, command); - } + @Override + public void execute(final D violationData) { + final String command = getMessage(violationData); + final Runnable runCommand = () -> { + try { + Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), command); + if (logDebug) { + debug(violationData, command); + } } catch (final Exception e) { - StaticLog.logOnce(Level.WARNING, "Failed to execute the command '" + command + "': " + e.getMessage() - + ", please check if everything is setup correct.", StringUtil.throwableToString(e)); - } - }); - } + StaticLog.logOnce(Level.WARNING, "Failed to execute the command '" + command + "': " + e.getMessage() + + ", please check if everything is setup correct.", StringUtil.throwableToString(e)); + } + }; + if (plugin == null) { + runCommand.run(); + return; + } + if (SchedulerHelper.isFoliaServer() && violationData instanceof ViolationData) { + final Player player = ((ViolationData) violationData).player; + if (player != null) { + SchedulerHelper.runSyncTaskForEntity(player, plugin, (arg) -> runCommand.run(), + () -> SchedulerHelper.runSyncTask(plugin, (arg) -> runCommand.run())); + return; + } + } + SchedulerHelper.runSyncTask(plugin, (arg) -> runCommand.run()); + } private void debug(final D violationData, String command) { final String prefix; diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/blockbreak/FastBreak.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/blockbreak/FastBreak.java index 99fa44c9a0..c81a34430b 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/blockbreak/FastBreak.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/blockbreak/FastBreak.java @@ -25,12 +25,14 @@ import fr.neatmonster.nocheatplus.checks.ViolationData; import fr.neatmonster.nocheatplus.compat.AlmostBoolean; import fr.neatmonster.nocheatplus.compat.Bridge1_9; -import fr.neatmonster.nocheatplus.compat.bukkit.BridgePotionEffect; -import fr.neatmonster.nocheatplus.permissions.Permissions; -import fr.neatmonster.nocheatplus.players.IPlayerData; -import fr.neatmonster.nocheatplus.utilities.TickTask; -import fr.neatmonster.nocheatplus.utilities.entity.PotionUtil; -import fr.neatmonster.nocheatplus.utilities.map.BlockProperties; +import fr.neatmonster.nocheatplus.compat.bukkit.BridgePotionEffect; +import fr.neatmonster.nocheatplus.permissions.Permissions; +import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; +import fr.neatmonster.nocheatplus.utilities.StringUtil; +import fr.neatmonster.nocheatplus.utilities.TickTask; +import fr.neatmonster.nocheatplus.utilities.entity.PotionUtil; +import fr.neatmonster.nocheatplus.utilities.map.BlockProperties; /** * A check used to verify if the player is breaking blocks faster than possible. @@ -62,7 +64,12 @@ public boolean check(final Player player, final Block block, final AlmostBoolean // Counting break...break. : (data.fastBreakBreakTime > now) ? 0 : now - data.fastBreakBreakTime; - // Check if the time spent is lower than expected. + /* + * FastBreak compatibility: keep the grace threshold configurable. + * The safer modern default belongs in config, while this check only + * compares measured block/tool timing against that configured budget. + */ + // Check if the time spent is lower than expected. if (isInstaBreak.decideOptimistically()) { // Ignore these for now. // TODO: Find out why this was commented out long ago a) did not fix mcMMO b) exploits. @@ -77,21 +84,34 @@ else if (elapsedTime + cc.fastBreakDelay < expectedBreakingTime) { final float serverLagFactor = pData.getCurrentWorldDataSafe().shouldAdjustToLag(type) ? TickTask.getLag(expectedBreakingTime, true) : 1f; final long missingTime = expectedBreakingTime - (long)(serverLagFactor * elapsedTime); if (missingTime > 0) { - // Add as penalty - data.fastBreakPenalties.add(now, (float) missingTime); - // Only raise a violation, if the total penalty score exceeds the contention duration (for lag, delay). - if (data.fastBreakPenalties.score(cc.fastBreakBucketFactor) > cc.fastBreakGrace) { - // TODO: maybe add one absolute penalty time for big amounts to stop breaking until then - final double violation = (double) missingTime / 1000.0; - data.fastBreakVL += violation; - final ViolationData vd = new ViolationData(this, player, data.fastBreakVL, violation, cc.fastBreakActions); - if (vd.needsParameters()) { - vd.setParameter(ParameterName.BLOCK_TYPE, blockType.toString()); - } - cancel = executeActions(vd).willCancel(); - } - // else: still within contention limits. - } + // Add as penalty + data.fastBreakPenalties.add(now, (float) missingTime); + // Only raise a violation, if the total penalty score exceeds the contention duration (for lag, delay). + final float penaltyScore = data.fastBreakPenalties.score(cc.fastBreakBucketFactor); + if (penaltyScore > cc.fastBreakGrace) { + // TODO: maybe add one absolute penalty time for big amounts to stop breaking until then + final double violation = (double) missingTime / 1000.0; + data.fastBreakVL += violation; + final ItemStack stack = Bridge1_9.getItemInMainHand(player); + final Material toolType = stack == null ? Material.AIR : stack.getType(); + final boolean isValidTool = BlockProperties.isValidTool(blockType, stack); + final double haste = PotionUtil.getPotionEffectAmplifier(player, BridgePotionEffect.HASTE); + // Diagnostic info: tag the timing branch so false FastBreak reports show the block/tool context. + final String tags = getFastBreakTags(isInstaBreak, cc, isValidTool, haste, elapsedTime, missingTime); + final ViolationData vd = new ViolationData(this, player, data.fastBreakVL, violation, cc.fastBreakActions); + if (vd.needsParameters()) { + vd.setParameter(ParameterName.BLOCK_TYPE, blockType.toString()); + vd.setParameter(ParameterName.TAGS, tags); + } + if (CheckUtils.shouldLogDebugToConsole()) { + logFastBreakDetail(player, blockType, toolType, isInstaBreak, isValidTool, haste, + elapsedTime, expectedBreakingTime, missingTime, serverLagFactor, + penaltyScore, data.fastBreakVL, violation, cc, tags); + } + cancel = executeActions(vd).willCancel(); + } + // else: still within contention limits. + } } else if (expectedBreakingTime > cc.fastBreakDelay) { // Fast breaking does not decrease violation level. @@ -129,7 +149,72 @@ private void detailDebugStats(final Player player, final AlmostBoolean isInstaBr // if (mcItem != null) { // double x = mcItem.getDestroySpeed(((CraftItemStack) stack).getHandle(), net.minecraft.server.Block.byId[blockId]); // player.sendMessage("mc speed: " + x); - // } - } - } -} + // } + } + } + + private String getFastBreakTags(final AlmostBoolean isInstaBreak, final BlockBreakConfig cc, + final boolean isValidTool, final double haste, + final long elapsedTime, final long missingTime) { + // Diagnostic info: these tags describe why the umbrella FastBreak check added VL. + final StringBuilder builder = new StringBuilder(120); + builder.append("subcheck_fastbreak_timing") + .append("+branch_expected_duration") + .append(cc.fastBreakStrict ? "+strict_interact_break" : "+break_break") + .append("+insta_").append(isInstaBreak.name().toLowerCase()) + .append(isValidTool ? "+valid_tool" : "+invalid_tool"); + if (Double.isInfinite(haste)) { + builder.append("+no_haste"); + } + else { + builder.append("+haste_").append((int) haste + 1); + } + if (elapsedTime == 0L) { + builder.append("+zero_elapsed"); + } + if (missingTime >= 1000L) { + builder.append("+large_missing_time"); + } + return builder.toString(); + } + + private void logFastBreakDetail(final Player player, final Material blockType, final Material toolType, + final AlmostBoolean isInstaBreak, final boolean isValidTool, + final double haste, final long elapsedTime, + final long expectedBreakingTime, final long missingTime, + final float serverLagFactor, final float penaltyScore, + final double totalVL, final double addedVL, + final BlockBreakConfig cc, final String tags) { + try { + // Diagnostic info: console-only detail for tuning FastBreak grace without changing check behavior. + player.getServer().getLogger().info(new StringBuilder(380) + .append("[NCP][FastBreak][detail] player=").append(player.getName()) + .append(" uuid=").append(player.getUniqueId()) + .append(" subcheck=FASTBREAK_TIMING") + .append(" summary=block_timing{missingMs=").append(missingTime) + .append(",graceMs=").append(StringUtil.fdec3.format(cc.fastBreakGrace)) + .append(",tool=").append(isValidTool ? "valid" : "invalid") + .append(",insta=").append(isInstaBreak.name().toLowerCase()) + .append('}') + .append(" block=").append(blockType) + .append(" tool=").append(toolType) + .append(" validTool=").append(isValidTool) + .append(" insta=").append(isInstaBreak.name()) + .append(" strict=").append(cc.fastBreakStrict) + .append(" elapsedMs=").append(elapsedTime) + .append(" expectedMs=").append(expectedBreakingTime) + .append(" missingMs=").append(missingTime) + .append(" lagFactor=").append(StringUtil.fdec3.format(serverLagFactor)) + .append(" penaltyScore=").append(StringUtil.fdec3.format(penaltyScore)) + .append(" graceMs=").append(StringUtil.fdec3.format(cc.fastBreakGrace)) + .append(" delayMs=").append(cc.fastBreakDelay) + .append(" modSurvival=").append(cc.fastBreakModSurvival) + .append(" haste=").append(Double.isInfinite(haste) ? "none" : Integer.toString((int) haste + 1)) + .append(" addVL=").append(StringUtil.fdec3.format(addedVL)) + .append(" totalVL=").append(StringUtil.fdec3.format(totalVL)) + .append(" tags=").append(tags) + .toString()); + } + catch (Throwable ignored) {} + } +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/chat/ChatListener.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/chat/ChatListener.java index b883c315e2..26fdcacdeb 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/chat/ChatListener.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/chat/ChatListener.java @@ -27,11 +27,12 @@ import org.bukkit.event.player.PlayerLoginEvent; import org.bukkit.event.player.PlayerLoginEvent.Result; -import fr.neatmonster.nocheatplus.NCPAPIProvider; -import fr.neatmonster.nocheatplus.checks.CheckListener; -import fr.neatmonster.nocheatplus.checks.CheckType; -import fr.neatmonster.nocheatplus.checks.moving.MovingConfig; -import fr.neatmonster.nocheatplus.command.CommandUtil; +import fr.neatmonster.nocheatplus.NCPAPIProvider; +import fr.neatmonster.nocheatplus.checks.CheckListener; +import fr.neatmonster.nocheatplus.checks.CheckType; +import fr.neatmonster.nocheatplus.checks.moving.MovingConfig; +import fr.neatmonster.nocheatplus.checks.net.NetData; +import fr.neatmonster.nocheatplus.command.CommandUtil; import fr.neatmonster.nocheatplus.compat.BridgeMisc; import fr.neatmonster.nocheatplus.compat.SchedulerHelper; import fr.neatmonster.nocheatplus.components.NoCheatPlusAPI; @@ -160,11 +161,21 @@ public void onPlayerLogin(final PlayerLoginEvent event) { else if (logins.isEnabled(player, pData) && logins.check(player, cc, data)) { event.disallow(Result.KICK_OTHER, cc.loginsKickMessage); } - } - - /** We listen to PlayerCommandPreprocess events because commands can be used for spamming too. */ - @EventHandler(priority = EventPriority.LOWEST) - public void onPlayerCommandPreprocess(final PlayerCommandPreprocessEvent event) { + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerTeleportCommandMonitor(final PlayerCommandPreprocessEvent event) { + final String command = getTeleportLikeCommand(event.getMessage()); + if (command != null) { + final Player player = event.getPlayer(); + DataManager.getPlayerData(player).getGenericInstance(NetData.class) + .recordTeleportCommand(command, player.getLocation(), System.currentTimeMillis()); + } + } + + /** We listen to PlayerCommandPreprocess events because commands can be used for spamming too. */ + @EventHandler(priority = EventPriority.LOWEST) + public void onPlayerCommandPreprocess(final PlayerCommandPreprocessEvent event) { final Player player = event.getPlayer(); // Tell TickTask to update cached permissions. final IPlayerData pData = DataManager.getPlayerData(player); @@ -223,8 +234,47 @@ else if (!commandExclusions.hasAnyPrefixWords(messageVars)) { } } } - } - } + } + } + + private String getTeleportLikeCommand(final String message) { + if (message == null) { + return null; + } + final String lcMessage = StringUtil.leftTrim(message).toLowerCase(); + if (lcMessage.length() < 2 || lcMessage.charAt(0) != '/') { + return null; + } + final String[] split = lcMessage.split(" ", 2); + String command = split[0].substring(1); + final int namespaceIndex = command.indexOf(':'); + if (namespaceIndex >= 0 && namespaceIndex + 1 < command.length()) { + command = command.substring(namespaceIndex + 1); + } + return isTeleportLikeCommand(command) ? command : null; + } + + private boolean isTeleportLikeCommand(final String command) { + return command.equals("rtp") + || command.equals("randomtp") + || command.equals("randomteleport") + || command.equals("wild") + || command.equals("wilderness") + || command.equals("spawn") + || command.equals("hub") + || command.equals("lobby") + || command.equals("home") + || command.equals("homes") + || command.equals("warp") + || command.equals("warps") + || command.equals("back") + || command.equals("tpa") + || command.equals("tpaccept") + || command.equals("tpahere") + || command.equals("tp") + || command.equals("teleport") + || command.equals("tphere"); + } private boolean checkUntrackedLocation(final Player player, final String message, final MovingConfig mcc, final IPlayerData pData) { diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Angle.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Angle.java index bec587be75..558552e288 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Angle.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Angle.java @@ -27,12 +27,13 @@ import fr.neatmonster.nocheatplus.actions.ParameterName; import fr.neatmonster.nocheatplus.checks.Check; import fr.neatmonster.nocheatplus.checks.CheckType; -import fr.neatmonster.nocheatplus.checks.ViolationData; -import fr.neatmonster.nocheatplus.permissions.Permissions; -import fr.neatmonster.nocheatplus.players.IPlayerData; -import fr.neatmonster.nocheatplus.utilities.StringUtil; -import fr.neatmonster.nocheatplus.utilities.TickTask; -import fr.neatmonster.nocheatplus.utilities.math.TrigUtil; +import fr.neatmonster.nocheatplus.checks.ViolationData; +import fr.neatmonster.nocheatplus.permissions.Permissions; +import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; +import fr.neatmonster.nocheatplus.utilities.StringUtil; +import fr.neatmonster.nocheatplus.utilities.TickTask; +import fr.neatmonster.nocheatplus.utilities.math.TrigUtil; /** * A check used to verify if the player isn't using a forcefield in order to attack multiple entities at the same time. @@ -111,10 +112,11 @@ public boolean check(final Player player, final Location loc, data.angleHits.clear(); } - boolean cancel = false; - tags.clear(); - - // Quick check for expiration of all entries. + boolean cancel = false; + tags.clear(); + // Diagnostic info: keep stable subcheck tags so combat false positives show which Angle heuristic fired. + + // Quick check for expiration of all entries. final long time = System.currentTimeMillis(); AttackLocation lastLoc = data.angleHits.isEmpty() ? null : data.angleHits.getLast(); if (lastLoc != null && time - lastLoc.time > maxTimeDiff) { @@ -217,39 +219,97 @@ public boolean check(final Player player, final Location loc, } } - - if (violationMove > cc.angleMove) { - violation = violationMove; - data.angleVL += violation; - final ViolationData vd = new ViolationData(this, player, data.angleVL, violation, cc.angleActions); - if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); - cancel = executeActions(vd).willCancel(); - } - else if (violationTime > cc.angleTime) { - violation = violationTime; - data.angleVL += violation; - final ViolationData vd = new ViolationData(this, player, data.angleVL, violation, cc.angleActions); - if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); - cancel = executeActions(vd).willCancel(); - } - else if (violationYaw > cc.angleYaw) { - violation = violationYaw; - data.angleVL += violation; - final ViolationData vd = new ViolationData(this, player, data.angleVL, violation, cc.angleActions); - if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); - cancel = executeActions(vd).willCancel(); - } - else if (violationSwitchSpeed > cc.angleSwitch) { - violation = violationSwitchSpeed; - data.angleVL += violation; - final ViolationData vd = new ViolationData(this, player, data.angleVL, violation, cc.angleActions); - if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); - cancel = executeActions(vd).willCancel(); - } - else { - // Reward the player by lowering their violation level. - data.angleVL *= 0.98D; - } - return cancel; - } -} + + if (violationMove > cc.angleMove) { + violation = violationMove; + data.angleVL += violation; + // Diagnostic info: identify which FightAngle branch crossed its threshold. + tags.add(0, "subcheck_angle_move"); + final ViolationData vd = new ViolationData(this, player, data.angleVL, violation, cc.angleActions); + if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); + if (CheckUtils.shouldLogDebugToConsole()) logAngleDetail(player, loc, damagedEntity, "ANGLE_MOVE", + averageMove, averageTime, averageYaw, averageSwitching, data.angleVL, violation); + cancel = executeActions(vd).willCancel(); + } + else if (violationTime > cc.angleTime) { + violation = violationTime; + data.angleVL += violation; + // Diagnostic info: identify which FightAngle branch crossed its threshold. + tags.add(0, "subcheck_angle_time"); + final ViolationData vd = new ViolationData(this, player, data.angleVL, violation, cc.angleActions); + if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); + if (CheckUtils.shouldLogDebugToConsole()) logAngleDetail(player, loc, damagedEntity, "ANGLE_TIME", + averageMove, averageTime, averageYaw, averageSwitching, data.angleVL, violation); + cancel = executeActions(vd).willCancel(); + } + else if (violationYaw > cc.angleYaw) { + violation = violationYaw; + data.angleVL += violation; + // Diagnostic info: identify which FightAngle branch crossed its threshold. + tags.add(0, "subcheck_angle_yaw"); + final ViolationData vd = new ViolationData(this, player, data.angleVL, violation, cc.angleActions); + if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); + if (CheckUtils.shouldLogDebugToConsole()) logAngleDetail(player, loc, damagedEntity, "ANGLE_YAW", + averageMove, averageTime, averageYaw, averageSwitching, data.angleVL, violation); + cancel = executeActions(vd).willCancel(); + } + else if (violationSwitchSpeed > cc.angleSwitch) { + violation = violationSwitchSpeed; + data.angleVL += violation; + // Diagnostic info: identify which FightAngle branch crossed its threshold. + tags.add(0, "subcheck_angle_switch"); + final ViolationData vd = new ViolationData(this, player, data.angleVL, violation, cc.angleActions); + if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); + if (CheckUtils.shouldLogDebugToConsole()) logAngleDetail(player, loc, damagedEntity, "ANGLE_SWITCH", + averageMove, averageTime, averageYaw, averageSwitching, data.angleVL, violation); + cancel = executeActions(vd).willCancel(); + } + else { + // Reward the player by lowering their violation level. + data.angleVL *= 0.98D; + } + return cancel; + } + + private void logAngleDetail(final Player player, final Location loc, final Entity damagedEntity, + final String subCheck, final double averageMove, + final double averageTime, final double averageYaw, + final double averageSwitching, final double totalVL, + final double addedVL) { + try { + // Diagnostic info: console-only combat angle context for separating aim, timing, and target switching flags. + player.getServer().getLogger().info(new StringBuilder(360) + .append("[NCP][FightAngle][detail] player=").append(player.getName()) + .append(" uuid=").append(player.getUniqueId()) + .append(" subcheck=").append(subCheck) + .append(" summary=").append(subCheck.toLowerCase()).append("{avgMove=") + .append(StringUtil.fdec3.format(averageMove)) + .append(",avgTime=").append(StringUtil.fdec3.format(averageTime)) + .append(",avgYaw=").append(StringUtil.fdec3.format(averageYaw)) + .append(",switch=").append(StringUtil.fdec3.format(averageSwitching)) + .append('}') + .append(" target=").append(damagedEntity.getType()) + .append(" targetUuid=").append(damagedEntity.getUniqueId()) + .append(" avgMove=").append(StringUtil.fdec3.format(averageMove)) + .append(" avgTime=").append(StringUtil.fdec3.format(averageTime)) + .append(" avgYaw=").append(StringUtil.fdec3.format(averageYaw)) + .append(" avgSwitch=").append(StringUtil.fdec3.format(averageSwitching)) + .append(" addVL=").append(StringUtil.fdec3.format(addedVL)) + .append(" totalVL=").append(StringUtil.fdec3.format(totalVL)) + .append(" loc=").append(formatLocation(loc)) + .append(" tags=").append(StringUtil.join(tags, "+")) + .toString()); + } + catch (Throwable ignored) {} + } + + private String formatLocation(final Location location) { + return location == null ? "null" + : (location.getWorld() == null ? "null" : location.getWorld().getName()) + + "@" + StringUtil.fdec3.format(location.getX()) + + "," + StringUtil.fdec3.format(location.getY()) + + "," + StringUtil.fdec3.format(location.getZ()) + + "/" + StringUtil.fdec3.format(location.getYaw()) + + "," + StringUtil.fdec3.format(location.getPitch()); + } +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Critical.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Critical.java index 8fe1b4f75c..47136f2cd2 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Critical.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Critical.java @@ -23,29 +23,34 @@ import fr.neatmonster.nocheatplus.NCPAPIProvider; import fr.neatmonster.nocheatplus.actions.ParameterName; -import fr.neatmonster.nocheatplus.checks.Check; -import fr.neatmonster.nocheatplus.checks.CheckType; -import fr.neatmonster.nocheatplus.checks.ViolationData; -import fr.neatmonster.nocheatplus.checks.moving.MovingConfig; -import fr.neatmonster.nocheatplus.checks.moving.MovingData; -import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveData; -import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveInfo; -import fr.neatmonster.nocheatplus.checks.moving.velocity.VelocityFlags; -import fr.neatmonster.nocheatplus.compat.Bridge1_13; -import fr.neatmonster.nocheatplus.penalties.IPenaltyList; -import fr.neatmonster.nocheatplus.players.IPlayerData; -import fr.neatmonster.nocheatplus.utilities.StringUtil; -import fr.neatmonster.nocheatplus.utilities.map.BlockFlags; -import fr.neatmonster.nocheatplus.utilities.moving.AuxMoving; -import fr.neatmonster.nocheatplus.utilities.moving.MovingUtil; +import fr.neatmonster.nocheatplus.checks.Check; +import fr.neatmonster.nocheatplus.checks.CheckType; +import fr.neatmonster.nocheatplus.checks.ViolationData; +import fr.neatmonster.nocheatplus.checks.moving.MovingConfig; +import fr.neatmonster.nocheatplus.checks.moving.MovingData; +import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveData; +import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveInfo; +import fr.neatmonster.nocheatplus.checks.moving.velocity.VelocityFlags; +import fr.neatmonster.nocheatplus.compat.Bridge1_13; +import fr.neatmonster.nocheatplus.compat.versions.ClientVersion; +import fr.neatmonster.nocheatplus.penalties.IPenaltyList; +import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; +import fr.neatmonster.nocheatplus.utilities.StringUtil; +import fr.neatmonster.nocheatplus.utilities.map.BlockFlags; +import fr.neatmonster.nocheatplus.utilities.moving.AuxMoving; +import fr.neatmonster.nocheatplus.utilities.moving.MovingUtil; /** * A check used to verify that critical hits done by players are legit. */ -public class Critical extends Check { - - private final AuxMoving auxMoving = NCPAPIProvider.getNoCheatPlusAPI().getGenericInstance(AuxMoving.class); - +public class Critical extends Check { + + // Modern-client model: tiny fall-distance desync can happen during valid low-fall attacks. + private static final double MODERN_JUMP_PHASE_CRITICAL_FALL_GRACE = 0.90D; + + private final AuxMoving auxMoving = NCPAPIProvider.getNoCheatPlusAPI().getGenericInstance(AuxMoving.class); + /** * Instantiates a new critical check. */ @@ -100,34 +105,164 @@ public boolean check(final Player player, final Location loc, final FightData da return false; } - boolean isIllegal = - // 0: Don't allow players to perform critical hits in blocks where the game would reset fall distance (water, powder snow, bushes, webs, climbables) - moveInfo.from.isResetCond() - // 0: Same as above. The game resets fall distance with slowfall - || !Double.isInfinite(Bridge1_13.getSlowfallingAmplifier(player)) - // 0: A full jump from ground requires more than 6 phases/events. - || mData.sfJumpPhase > 0 && mData.sfJumpPhase <= mData.liftOffEnvelope.getMaxJumpPhase(mData.jumpAmplifier) - && !moveInfo.from.seekCollisionAbove(0.2) - && (lastMove.verVelUsed.isEmpty() || !lastMove.verVelUsed.get(0).hasFlag(VelocityFlags.ORIGIN_BLOCK_BOUNCE)) - // 0: Always invalidate critical hits if we judge the player to be on ground (given enough fall distance) - || Math.abs(ncpFallDistance - mcFallDistance) > 1e-5 && (moveInfo.from.isOnGround() || lastMove.touchedGroundWorkaround) - // (Let SurvivalFly catch low-jumps). - ; - - // Handle violations - if (isIllegal) { - data.criticalVL += 1.0; - // Execute whatever actions are associated with this check and - // the violation level and find out if we should cancel the event. - final ViolationData vd = new ViolationData(this, player, data.criticalVL, 1.0, cc.criticalActions); - if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); - cancel = executeActions(vd).willCancel(); - // TODO: Introduce penalty instead of cancel. - } + final boolean resetCond = moveInfo.from.isResetCond(); + final boolean slowFalling = !Double.isInfinite(Bridge1_13.getSlowfallingAmplifier(player)); + final boolean jumpPhaseCritical = mData.sfJumpPhase > 0 + && mData.sfJumpPhase <= mData.liftOffEnvelope.getMaxJumpPhase(mData.jumpAmplifier) + && !moveInfo.from.seekCollisionAbove(0.2) + && (lastMove.verVelUsed.isEmpty() || !lastMove.verVelUsed.get(0).hasFlag(VelocityFlags.ORIGIN_BLOCK_BOUNCE)) + && !isModernJumpPhaseCriticalGrace(player, pData, mcFallDistance, realisticFallDistance, + mData, thisMove, lastMove, moveInfo); + final boolean groundMismatch = Math.abs(ncpFallDistance - mcFallDistance) > 1e-5 + && (moveInfo.from.isOnGround() || lastMove.touchedGroundWorkaround); + boolean isIllegal = + // 0: Don't allow players to perform critical hits in blocks where the game would reset fall distance (water, powder snow, bushes, webs, climbables) + resetCond + // 0: Same as above. The game resets fall distance with slowfall + || slowFalling + // 0: A full jump from ground requires more than 6 phases/events. + || jumpPhaseCritical + // 0: Always invalidate critical hits if we judge the player to be on ground (given enough fall distance) + || groundMismatch + // (Let SurvivalFly catch low-jumps). + ; + + // Handle violations + if (isIllegal) { + addCriticalTags(tags, resetCond, slowFalling, jumpPhaseCritical, groundMismatch, lastMove, moveInfo); + data.criticalVL += 1.0; + // Execute whatever actions are associated with this check and + // the violation level and find out if we should cancel the event. + final ViolationData vd = new ViolationData(this, player, data.criticalVL, 1.0, cc.criticalActions); + if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); + if (CheckUtils.shouldLogDebugToConsole()) { + logCriticalDetail(player, loc, data.criticalVL, tags, mcFallDistance, ncpFallDistance, + realisticFallDistance, mData, thisMove, lastMove, moveInfo); + } + cancel = executeActions(vd).willCancel(); + // TODO: Introduce penalty instead of cancel. + } // Crit was legit, reward the player. else data.criticalVL *= 0.96D; } - auxMoving.returnPlayerMoveInfo(moveInfo); - return cancel; - } -} \ No newline at end of file + auxMoving.returnPlayerMoveInfo(moveInfo); + return cancel; + } + + private void addCriticalTags(final List tags, final boolean resetCond, final boolean slowFalling, + final boolean jumpPhaseCritical, final boolean groundMismatch, + final PlayerMoveData lastMove, final PlayerMoveInfo moveInfo) { + // Diagnostic info: name the exact Critical branch instead of only reporting FIGHT_CRITICAL. + tags.add("subcheck_" + getCriticalSubCheck(resetCond, slowFalling, jumpPhaseCritical, groundMismatch).toLowerCase()); + if (resetCond) { + tags.add("branch_resetcond"); + } + if (slowFalling) { + tags.add("branch_slowfall"); + } + if (jumpPhaseCritical) { + tags.add("branch_jump_phase"); + } + if (groundMismatch) { + tags.add("branch_ground_mismatch"); + } + if (lastMove.touchedGroundWorkaround) { + tags.add("branch_lostground"); + } + if (!lastMove.verVelUsed.isEmpty()) { + tags.add("branch_velocity"); + } + if (moveInfo.from.isInLiquid() || moveInfo.to.isInLiquid()) { + tags.add("branch_liquid"); + } + } + + private String getCriticalSubCheck(final boolean resetCond, final boolean slowFalling, + final boolean jumpPhaseCritical, final boolean groundMismatch) { + if (resetCond) { + return "CRITICAL_RESETCOND"; + } + if (slowFalling) { + return "CRITICAL_SLOWFALL"; + } + if (jumpPhaseCritical) { + return "CRITICAL_JUMP_PHASE"; + } + if (groundMismatch) { + return "CRITICAL_GROUND_MISMATCH"; + } + return "CRITICAL_UNKNOWN"; + } + + private boolean isModernJumpPhaseCriticalGrace(final Player player, final IPlayerData pData, + final double mcFallDistance, final double realisticFallDistance, + final MovingData mData, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final PlayerMoveInfo moveInfo) { + // False-positive model: 1.21+ clients can report valid low-fall attacks inside the old jump-phase window. + // This stays a bounded desync model because Bukkit fall distance and NCP movement history update separately. + if (pData.getClientVersion() != ClientVersion.HIGHER_THAN_KNOWN_VERSIONS + && !pData.getClientVersion().isAtLeast(ClientVersion.V_1_21)) { + return false; + } + if (mcFallDistance > MODERN_JUMP_PHASE_CRITICAL_FALL_GRACE + || moveInfo.from.isResetCond() + || moveInfo.to.isResetCond() + || !lastMove.verVelUsed.isEmpty() && lastMove.verVelUsed.get(0).hasFlag(VelocityFlags.ORIGIN_BLOCK_BOUNCE)) { + return false; + } + final boolean lowFallDesync = player.isOnGround() + || thisMove.from.onGround + || thisMove.to.onGround + || realisticFallDistance > 0.0D + || thisMove.yDistance < 0.0D + || lastMove.yDistance < 0.0D; + return lowFallDesync && mData.sfJumpPhase > 0 + && mData.sfJumpPhase <= mData.liftOffEnvelope.getMaxJumpPhase(mData.jumpAmplifier); + } + + private void logCriticalDetail(final Player player, final Location loc, final double totalVL, + final List tags, final double mcFallDistance, + final double ncpFallDistance, final double realisticFallDistance, + final MovingData mData, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, final PlayerMoveInfo moveInfo) { + try { + // Diagnostic info: console-only Critical context for fall distance, ground mismatch, and liquid/reset false flags. + player.getServer().getLogger().info(new StringBuilder(420) + .append("[NCP][FightCritical][detail] player=").append(player.getName()) + .append(" uuid=").append(player.getUniqueId()) + .append(" subcheck=").append(tags.isEmpty() ? "CRITICAL_UNKNOWN" : tags.get(0).substring("subcheck_".length()).toUpperCase()) + .append(" summary=").append(tags.isEmpty() ? "critical_unknown" : tags.get(0).substring("subcheck_".length())) + .append("{mcFall=").append(StringUtil.fdec3.format(mcFallDistance)) + .append(",ncpFall=").append(StringUtil.fdec3.format(ncpFallDistance)) + .append(",realistic=").append(StringUtil.fdec3.format(realisticFallDistance)) + .append(",jumpPhase=").append(mData.sfJumpPhase) + .append('}') + .append(" totalVL=").append(StringUtil.fdec3.format(totalVL)) + .append(" mcFall=").append(StringUtil.fdec3.format(mcFallDistance)) + .append(" ncpFall=").append(StringUtil.fdec3.format(ncpFallDistance)) + .append(" realisticFall=").append(StringUtil.fdec3.format(realisticFallDistance)) + .append(" jumpPhase=").append(mData.sfJumpPhase) + .append(" liftOff=").append(mData.liftOffEnvelope.name()) + .append(" playerGround=").append(player.isOnGround()) + .append(" moveGround=").append(thisMove.from.onGround).append("->").append(thisMove.to.onGround) + .append(" infoGround=").append(moveInfo.from.isOnGround()).append("->").append(moveInfo.to.isOnGround()) + .append(" reset=").append(moveInfo.from.isResetCond()).append("->").append(moveInfo.to.isResetCond()) + .append(" lastTouchedGround=").append(lastMove.touchedGroundWorkaround) + .append(" vVelUsed=").append(lastMove.verVelUsed) + .append(" loc=").append(formatLocation(loc)) + .append(" tags=").append(StringUtil.join(tags, "+")) + .toString()); + } + catch (Throwable ignored) {} + } + + private String formatLocation(final Location location) { + return location == null ? "none" + : StringUtil.fdec3.format(location.getX()) + "," + + StringUtil.fdec3.format(location.getY()) + "," + + StringUtil.fdec3.format(location.getZ()) + "/" + + StringUtil.fdec3.format(location.getYaw()) + "," + + StringUtil.fdec3.format(location.getPitch()); + } +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Direction.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Direction.java index 20518fc51e..337c2f2e4a 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Direction.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/fight/Direction.java @@ -18,14 +18,18 @@ import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; -import org.bukkit.util.Vector; - -import fr.neatmonster.nocheatplus.checks.Check; -import fr.neatmonster.nocheatplus.checks.CheckType; -import fr.neatmonster.nocheatplus.checks.moving.location.tracking.LocationTrace.ITraceEntry; -import fr.neatmonster.nocheatplus.compat.MCAccess; -import fr.neatmonster.nocheatplus.utilities.collision.CollisionUtil; -import fr.neatmonster.nocheatplus.utilities.math.TrigUtil; +import org.bukkit.util.Vector; + +import fr.neatmonster.nocheatplus.actions.ParameterName; +import fr.neatmonster.nocheatplus.checks.Check; +import fr.neatmonster.nocheatplus.checks.CheckType; +import fr.neatmonster.nocheatplus.checks.ViolationData; +import fr.neatmonster.nocheatplus.checks.moving.location.tracking.LocationTrace.ITraceEntry; +import fr.neatmonster.nocheatplus.compat.MCAccess; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; +import fr.neatmonster.nocheatplus.utilities.StringUtil; +import fr.neatmonster.nocheatplus.utilities.collision.CollisionUtil; +import fr.neatmonster.nocheatplus.utilities.math.TrigUtil; /** * The Direction check will find out if a player tried to interact with something that's not in their field of view. @@ -90,12 +94,13 @@ public boolean check(final Player player, final Location loc, final double distance = blockEyes.crossProduct(direction).length() / direction.length(); // Add the overall violation level of the check. - data.directionVL += distance; - // Execute whatever actions are associated with this check and the violation level and find out if we should - // cancel the event. - cancel = executeActions(player, data.directionVL, distance, cc.directionActions).willCancel(); - - if (cancel) { + data.directionVL += distance; + // Execute whatever actions are associated with this check and the violation level and find out if we should + // cancel the event. + cancel = executeDirectionViolation(player, loc, damaged, damagedIsFake, dLoc, "classic", + off, distance, data, cc, false); + + if (cancel) { // Deal an attack penalty time. data.attackPenalty.applyPenalty(cc.directionPenalty); } @@ -219,13 +224,14 @@ public boolean loopFinish(final Player player, final Location loc, final Entity } else if (off > 0.1) { // Add the overall violation level of the check. - data.directionVL += off; - - // Execute whatever actions are associated with this check and the violation level and find out if we should - // cancel the event. - cancel = executeActions(player, data.directionVL, off, cc.directionActions).willCancel(); - - if (cancel) { + data.directionVL += off; + + // Execute whatever actions are associated with this check and the violation level and find out if we should + // cancel the event. + cancel = executeDirectionViolation(player, loc, damaged, false, damaged.getLocation(), "trace_loop", + context.minResult, off, data, cc, forceViolation); + + if (cancel) { // Deal an attack penalty time. data.attackPenalty.applyPenalty(cc.directionPenalty); } @@ -233,7 +239,63 @@ else if (off > 0.1) { else { // Reward the player by lowering their violation level. data.directionVL *= 0.8D; - } - return cancel; - } -} \ No newline at end of file + } + return cancel; + } + + private boolean executeDirectionViolation(final Player player, final Location loc, final Entity damaged, + final boolean damagedIsFake, final Location damagedLoc, + final String branch, final double off, final double addedVL, + final FightData data, final FightConfig cc, + final boolean forceViolation) { + final ViolationData vd = new ViolationData(this, player, data.directionVL, addedVL, cc.directionActions); + if (vd.needsParameters()) { + vd.setParameter(ParameterName.TAGS, "branch_" + branch + + "+target_" + damaged.getType() + + (damagedIsFake ? "+fake_target" : "") + + (cc.directionStrict ? "+strict" : "+classic_precision") + + (forceViolation ? "+forced" : "")); + } + if (CheckUtils.shouldLogDebugToConsole()) { + logDirectionDetail(player, loc, damaged, damagedIsFake, damagedLoc, branch, off, addedVL, data.directionVL, cc, forceViolation); + } + return executeActions(vd).willCancel(); + } + + private void logDirectionDetail(final Player player, final Location loc, final Entity damaged, + final boolean damagedIsFake, final Location damagedLoc, + final String branch, final double off, final double addedVL, + final double totalVL, final FightConfig cc, + final boolean forceViolation) { + try { + player.getServer().getLogger().info(new StringBuilder(360) + .append("[NCP][FightDirection][detail] player=").append(player.getName()) + .append(" uuid=").append(player.getUniqueId()) + .append(" branch=").append(branch) + .append(" target=").append(damaged.getType()) + .append(" targetUuid=").append(damagedIsFake ? "fake" : damaged.getUniqueId()) + .append(" fake=").append(damagedIsFake) + .append(" strict=").append(cc.directionStrict) + .append(" force=").append(forceViolation) + .append(" off=").append(StringUtil.fdec3.format(off)) + .append(" addVL=").append(StringUtil.fdec3.format(addedVL)) + .append(" totalVL=").append(StringUtil.fdec3.format(totalVL)) + .append(" playerLoc=").append(formatLocation(loc)) + .append(" targetLoc=").append(formatLocation(damagedLoc)) + .toString()); + } + catch (Throwable ignored) {} + } + + private String formatLocation(final Location location) { + if (location == null) { + return "null"; + } + return (location.getWorld() == null ? "null" : location.getWorld().getName()) + + "@" + StringUtil.fdec3.format(location.getX()) + + "," + StringUtil.fdec3.format(location.getY()) + + "," + StringUtil.fdec3.format(location.getZ()) + + "/" + StringUtil.fdec3.format(location.getYaw()) + + "," + StringUtil.fdec3.format(location.getPitch()); + } +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/generic/block/AbstractBlockDirectionCheck.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/generic/block/AbstractBlockDirectionCheck.java index d15639e42d..f555c1d5fb 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/generic/block/AbstractBlockDirectionCheck.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/generic/block/AbstractBlockDirectionCheck.java @@ -23,15 +23,19 @@ import org.bukkit.entity.Player; import org.bukkit.util.Vector; +import fr.neatmonster.nocheatplus.actions.ParameterName; import fr.neatmonster.nocheatplus.actions.ActionList; import fr.neatmonster.nocheatplus.checks.Check; import fr.neatmonster.nocheatplus.checks.CheckType; +import fr.neatmonster.nocheatplus.checks.ViolationData; import fr.neatmonster.nocheatplus.checks.net.FlyingQueueHandle; import fr.neatmonster.nocheatplus.checks.net.FlyingQueueLookBlockChecker; import fr.neatmonster.nocheatplus.compat.Bridge1_13; import fr.neatmonster.nocheatplus.components.config.ICheckConfig; import fr.neatmonster.nocheatplus.components.data.ICheckData; import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; +import fr.neatmonster.nocheatplus.utilities.StringUtil; import fr.neatmonster.nocheatplus.utilities.collision.tracing.ray.CollideRayVsAABB; import fr.neatmonster.nocheatplus.utilities.collision.tracing.ray.ICollideRayVsAABB; import fr.neatmonster.nocheatplus.utilities.location.LocUtil; @@ -148,6 +152,7 @@ public boolean check(final Player player, final Location loc, final double eyeHe final int blockZ = block.getZ(); // The distance is squared initially. double distance; + String branch = "ray_miss"; if (checker.checkFlyingQueue(x, y, z, loc.getYaw(), loc.getPitch(), blockX, blockY, blockZ, flyingHandle)) { distance = Double.MAX_VALUE; @@ -156,7 +161,11 @@ public boolean check(final Player player, final Location loc, final double eyeHe distance = checker.getMinDistance(); } - if (face != null && !isInteractable(loc, Location.locToBlock(y), block, face)) distance = 1.0; + // Diagnostic branch: ray misses and unreachable block faces are different root causes. + if (face != null && !isInteractable(loc, Location.locToBlock(y), block, face)) { + distance = 1.0; + branch = "face_unreachable"; + } // TODO: Consider a protected field with a tolerance value. if (distance != Double.MAX_VALUE) { @@ -172,7 +181,16 @@ public boolean check(final Player player, final Location loc, final double eyeHe // Execute whatever actions are associated with this check and the violation level and find out if we should // cancel the event. - cancel = executeActions(player, vl, distance, getActions(cc)).willCancel(); + // Diagnostic info: preserve the concrete block-direction branch behind interact/place/break direction checks. + final String tags = getDiagnosticTags(block, face, branch); + final ViolationData vd = new ViolationData(this, player, vl, distance, getActions(cc)); + if (vd.needsParameters()) { + vd.setParameter(ParameterName.TAGS, tags); + } + if (CheckUtils.shouldLogDebugToConsole()) { + logDirectionDetail(player, loc, block, face, branch, distance, vl, tags); + } + cancel = executeActions(vd).willCancel(); } else { // Player did likely nothing wrong, reduce violation counter to reward them. cooldown(player, data, cc); @@ -221,6 +239,60 @@ private void outputDebugFail(Player player, ICollideRayVsAABB boulder, double di debug(player, "Failed: collides: " + boulder.collides() + " , dist: " + distance + " , pos: " + LocUtil.simpleFormat(boulder)); } + private String getDiagnosticTags(final Block block, final BlockFace face, final String branch) { + // Diagnostic info: distinguish ray misses from unreachable-face failures in umbrella direction logs. + return "subcheck_block_direction" + + "+branch_" + branch + + "+check_" + type.name().toLowerCase() + + "+block_" + block.getType().name().toLowerCase() + + (face == null ? "" : "+face_" + face.name().toLowerCase()); + } + + private void logDirectionDetail(final Player player, final Location loc, final Block block, + final BlockFace face, final String branch, final double distance, + final double totalVL, final String tags) { + try { + // Diagnostic info: console-only block direction context for false positives around stairs, boats, and odd block faces. + player.getServer().getLogger().info(new StringBuilder(360) + .append("[NCP][BlockDirection][detail] check=").append(type.name()) + .append(" player=").append(player.getName()) + .append(" uuid=").append(player.getUniqueId()) + .append(" subcheck=BLOCK_DIRECTION") + .append(" summary=block_direction{branch=").append(branch) + .append(",block=").append(block.getType()) + .append(",face=").append(face == null ? "none" : face.name().toLowerCase()) + .append(",distance=").append(StringUtil.fdec3.format(distance)) + .append('}') + .append(" branch=").append(branch) + .append(" block=").append(block.getType()) + .append(" face=").append(face == null ? "none" : face.name()) + .append(" distance=").append(StringUtil.fdec3.format(distance)) + .append(" totalVL=").append(StringUtil.fdec3.format(totalVL)) + .append(" playerLoc=").append(formatLocation(loc)) + .append(" blockLoc=").append(formatBlockLocation(block)) + .append(" yaw=").append(StringUtil.fdec3.format(loc.getYaw())) + .append(" pitch=").append(StringUtil.fdec3.format(loc.getPitch())) + .append(" tags=").append(tags) + .toString()); + } + catch (Throwable ignored) {} + } + + private String formatLocation(final Location location) { + return location == null ? "null" + : (location.getWorld() == null ? "null" : location.getWorld().getName()) + + "@" + StringUtil.fdec3.format(location.getX()) + + "," + StringUtil.fdec3.format(location.getY()) + + "," + StringUtil.fdec3.format(location.getZ()); + } + + private String formatBlockLocation(final Block block) { + return (block.getWorld() == null ? "null" : block.getWorld().getName()) + + "@" + block.getX() + + "," + block.getY() + + "," + block.getZ(); + } + /** * Check if interact with right direction block facing. * diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingConfig.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingConfig.java index 2ba6575efd..c3f01830c3 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingConfig.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingConfig.java @@ -102,15 +102,17 @@ public class MovingConfig extends ACheckConfig { // Leniency settings. public final long survivalFlyVLFreezeCount; public final boolean survivalFlyVLFreezeInAir; - // Set back policy. - public final boolean sfSetBackPolicyFallDamage; - public final ActionList survivalFlyActions; - - public final boolean sfHoverCheck; // TODO: Sub check ? - public final int sfHoverTicks; - public final int sfHoverLoginTicks; - public final boolean sfHoverFallDamage; - public final double sfHoverViolation; + // Set back policy. + public final boolean sfSetBackPolicyFallDamage; + public final ActionList survivalFlyActions; + public final boolean sfElytraEnforce; + + public final boolean sfHoverCheck; // TODO: Sub check ? + public final boolean sfHoverElytraEnforce; + public final int sfHoverTicks; + public final int sfHoverLoginTicks; + public final boolean sfHoverFallDamage; + public final double sfHoverViolation; // Special tolerance values: /** @@ -218,14 +220,16 @@ public MovingConfig(final IWorldData worldData) { this.sfStepHeight = ServerVersion.select(ref, 0.5, 0.6, 0.6, 0.5).doubleValue(); } else this.sfStepHeight = sfStepHeight; - survivalFlyVLFreezeCount = config.getInt(ConfPaths.MOVING_SURVIVALFLY_LENIENCY_FREEZECOUNT); - survivalFlyVLFreezeInAir = config.getBoolean(ConfPaths.MOVING_SURVIVALFLY_LENIENCY_FREEZEINAIR); - survivalFlyActions = config.getOptimizedActionList(ConfPaths.MOVING_SURVIVALFLY_ACTIONS, Permissions.MOVING_SURVIVALFLY); - - sfHoverCheck = config.getBoolean(ConfPaths.MOVING_SURVIVALFLY_HOVER_CHECK); - sfHoverTicks = config.getInt(ConfPaths.MOVING_SURVIVALFLY_HOVER_TICKS); - sfHoverLoginTicks = Math.max(0, config.getInt(ConfPaths.MOVING_SURVIVALFLY_HOVER_LOGINTICKS)); - sfHoverFallDamage = config.getBoolean(ConfPaths.MOVING_SURVIVALFLY_HOVER_FALLDAMAGE); + survivalFlyVLFreezeCount = config.getInt(ConfPaths.MOVING_SURVIVALFLY_LENIENCY_FREEZECOUNT); + survivalFlyVLFreezeInAir = config.getBoolean(ConfPaths.MOVING_SURVIVALFLY_LENIENCY_FREEZEINAIR); + survivalFlyActions = config.getOptimizedActionList(ConfPaths.MOVING_SURVIVALFLY_ACTIONS, Permissions.MOVING_SURVIVALFLY); + sfElytraEnforce = config.getBoolean(ConfPaths.MOVING_SURVIVALFLY_ELYTRA_ENFORCE); + + sfHoverCheck = config.getBoolean(ConfPaths.MOVING_SURVIVALFLY_HOVER_CHECK); + sfHoverElytraEnforce = config.getBoolean(ConfPaths.MOVING_SURVIVALFLY_HOVER_ELYTRA_ENFORCE); + sfHoverTicks = config.getInt(ConfPaths.MOVING_SURVIVALFLY_HOVER_TICKS); + sfHoverLoginTicks = Math.max(0, config.getInt(ConfPaths.MOVING_SURVIVALFLY_HOVER_LOGINTICKS)); + sfHoverFallDamage = config.getBoolean(ConfPaths.MOVING_SURVIVALFLY_HOVER_FALLDAMAGE); sfHoverViolation = config.getDouble(ConfPaths.MOVING_SURVIVALFLY_HOVER_SFVIOLATION); velocityActivationCounter = config.getInt(ConfPaths.MOVING_VELOCITY_ACTIVATIONCOUNTER); diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingData.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingData.java index 7e0047f036..5e61d35bfc 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingData.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingData.java @@ -233,6 +233,8 @@ public VehicleMoveData call() throws Exception { public double noFallMaxY = 0; /** Indicate that NoFall is not to use next damage event for checking on-ground properties. */ public boolean noFallSkipAirCheck = false; + /** Elytra NoFall model: last time glide movement reset fall-distance accounting. */ + public long noFallElytraResetTime = 0L; /** Last coordinate from when the player was affected wind charge explosion */ public Location noFallCurrentLocOnWindChargeHit = null; @@ -249,6 +251,30 @@ public VehicleMoveData call() throws Exception { public int sfHoverTicks = -1; /** First count these down before incrementing sfHoverTicks. Set on join, if configured so. */ public int sfHoverLoginTicks = 0; + /** Hover model: airborne ticks included in the accumulated descent budget. */ + public int hoverAirTicks = 0; + /** Hover model: total downward movement expected from gravity/glide prediction, plus unexplained ascent debt. */ + public double hoverExpectedDrop = 0.0; + /** Hover model: total downward movement actually sent by the client. */ + public double hoverActualDrop = 0.0; + /** Hover model: last predicted vertical velocity used for budget continuity. */ + public double hoverLastYVelocity = 0.0; + /** Elytra model: consecutive no-firework upward glide packets outside the energy envelope. */ + public int elytraNoFireworkAscentTicks = 0; + /** Elytra model: accumulated no-firework ascent above speed/dive-derived lift. */ + public double elytraNoFireworkAscentDebt = 0.0; + /** Elytra model diagnostics: allowed upward movement from decayed/dive/speed lift for the last no-firework glide packet. */ + public double elytraNoFireworkAscentBudget = 0.0; + /** Elytra model diagnostics: upward movement above the last no-firework ascent budget. */ + public double elytraNoFireworkAscentExcess = 0.0; + /** Elytra model diagnostics: horizontal speed needed if speed lift alone were explaining the observed ascent. */ + public double elytraNoFireworkNeededH = Double.NaN; + /** Elytra model: session energy bank created by actual no-firework descent. */ + public double elytraNoFireworkDescentCredit = 0.0; + /** Elytra model diagnostics: descent credit used by the last accepted no-firework ascent packet. */ + public double elytraNoFireworkDescentCreditUsed = 0.0; + /** Elytra model: first location of the current no-firework glide session, used as the illegal-ascent correction anchor. */ + public Location elytraNoFireworkStart = null; /** Fake in air flag: set with any violation, reset once on ground. */ public boolean sfVLInAir = false; /** Workarounds (AirWorkarounds,LiquidWorkarounds). */ @@ -349,6 +375,7 @@ public void clearFlyData() { removeAllPlayerSpeedModifiers(); clearWindChargeImpulse(); sfHoverTicks = sfHoverLoginTicks = -1; + resetHoverAirBudget(); liftOffEnvelope = defaultLiftOffEnvelope; vehicleConsistency = MoveConsistency.INCONSISTENT; verticalBounce = null; @@ -378,6 +405,7 @@ public void onSetBack(final PlayerLocation setBack, final Location loc, final Mo // Keep jump amplifier // keep jump phase. sfHoverTicks = -1; // 0 ? + resetHoverAirBudget(); liftOffEnvelope = defaultLiftOffEnvelope; removeAllPlayerSpeedModifiers(); vehicleConsistency = MoveConsistency.INCONSISTENT; // Not entirely sure here. @@ -409,7 +437,24 @@ public void prepareSetBack(final Location loc) { verticalBounce = null; // Remember where we send the player to. setTeleported(loc); - // TODO: sfHoverTicks ? + sfHoverTicks = -1; + resetHoverAirBudget(); + } + + + public void resetHoverAirBudget() { + hoverAirTicks = 0; + hoverExpectedDrop = 0.0; + hoverActualDrop = 0.0; + hoverLastYVelocity = 0.0; + elytraNoFireworkAscentTicks = 0; + elytraNoFireworkAscentDebt = 0.0; + elytraNoFireworkAscentBudget = 0.0; + elytraNoFireworkAscentExcess = 0.0; + elytraNoFireworkNeededH = Double.NaN; + elytraNoFireworkDescentCredit = 0.0; + elytraNoFireworkDescentCreditUsed = 0.0; + elytraNoFireworkStart = null; } @@ -550,6 +595,25 @@ public void clearPlayerMorePacketsData() { } + public void onExternalTeleportResync(final Location loc) { + // Teleport/Folia support: NET_MOVING accepted a stale pre-teleport packet, so old move history must not + // remain available as a future setback target. + playerMoves.invalidate(); + clearPlayerMorePacketsData(); + sfJumpPhase = 0; + sfHoverTicks = -1; + resetHoverAirBudget(); + verticalBounce = null; + timeSinceSetBack = 0; + if (loc != null && loc.getWorld() != null) { + // Folia/teleport safety: packet models can carry coordinate-only targets, but set backs need a real world. + setSetBack(loc); + } + resetTeleported(); + joinOrRespawn = false; + } + + /** * Reduce the morepackets frequency counters by the given amount, capped at * a minimum of 0. @@ -835,14 +899,14 @@ private void removeAllPlayerSpeedModifiers() { * Set on {@link org.bukkit.event.player.PlayerRiptideEvent} to "MAYBE", as we don't yet know whether the riptide push is applied with or without the 1.2 vertical move from ground. */ public void setTridentReleaseEvent(AlmostBoolean isReleased) { - tridentRelease = isReleased; + tridentRelease = isReleased == null ? AlmostBoolean.NO : isReleased; } /** * Set when pass to PlayerMoveData, also reset state */ public AlmostBoolean consumeTridentReleaseEvent() { - final AlmostBoolean result = tridentRelease; + final AlmostBoolean result = tridentRelease == null ? AlmostBoolean.NO : tridentRelease; tridentRelease = AlmostBoolean.NO; return result; } @@ -1023,6 +1087,10 @@ public List useHorizontalVelocity(final double x, final double z) { return available; } + public List useHorizontalVelocityCovering(final double x, final double z, final int maxActCount) { + return horVel.useCovering(x, z, 1, maxActCount, 0.001); + } + /** * Get the xz-axis velocity tracker. Rather for testing purposes. @@ -1448,4 +1516,4 @@ public boolean dataOnReload(final IGetGenericInstance dataAccess) { trace.adjustSettings(cc.traceMaxAge, cc.traceMaxSize, TickTask.getTick()); return false; } -} \ No newline at end of file +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingListener.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingListener.java index fffb8d93cb..ccd82f9c8e 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingListener.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/MovingListener.java @@ -47,6 +47,7 @@ import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause; import org.bukkit.event.player.PlayerToggleFlightEvent; import org.bukkit.event.player.PlayerVelocityEvent; +import org.bukkit.plugin.Plugin; import org.bukkit.util.Vector; import fr.neatmonster.nocheatplus.NCPAPIProvider; @@ -161,6 +162,32 @@ public class MovingListener extends CheckListener implements TickListener, IRemo /** Player names to check hover for, case-insensitive. */ private final Set hoverTicks = ConcurrentHashMap.newKeySet(30); + private static final double HOVER_VERTICAL_PROGRESS = 0.04D; + private static final double HOVER_AIR_DROP_DEFICIT = 0.60D; + /* + * Elytra hover model: normal no-firework glides from the tuning logs stayed + * below roughly 0.42 accumulated drop debt, while flat hover packets crossed + * 0.54 without ever paying real descent. Keep the boundary model-based and + * below the packet-shaped hover plateau instead of waiting for the legacy + * broad hover window. + */ + private static final double HOVER_ELYTRA_DROP_DEFICIT = 0.52D; + /* + * Elytra flat-hover model: cheat clients can stay just below the broad drop + * deficit by sending repeated tiny/flat glide packets. The latest 1x70 trace + * crossed ~0.27 drop debt with essentially zero descent, while legitimate + * traces that built similar short-term debt paid real descent, so require + * near-zero accumulated descent before using this tighter boundary. + */ + private static final double HOVER_ELYTRA_FLAT_DROP_DEFICIT = 0.25D; + private static final double HOVER_ELYTRA_FLAT_MAX_ACTUAL_DROP = 0.02D; + private static final double HOVER_ELYTRA_MIN_DESCENT = 0.006D; + private static final int HOVER_AIR_BUDGET_MIN_TICKS = 8; + private static final int HOVER_ELYTRA_BUDGET_MIN_TICKS = 45; + private static final int HOVER_AIR_BUDGET_WINDOW = 80; + private static final int HOVER_RECENT_SETBACK_TICKS = 20; + private static final long ELYTRA_NOFALL_LANDING_HANDOFF_MS = 1000L; + /** Player names to check enforcing the location for in onTick, case-insensitive. */ private final Set playersEnforce = ConcurrentHashMap.newKeySet(30); @@ -297,9 +324,8 @@ private boolean standsOnEntity(final Entity entity, final double minY) { if (!MaterialUtil.isBoat(type)) { continue; } - final Material m = other.getLocation().getBlock().getType(); final double locY = other.getLocation().getY(); - return Math.abs(locY - minY) < 0.7 && BlockProperties.isLiquid(m); + return Math.abs(locY - minY) < 1.3; } return false; } @@ -418,7 +444,8 @@ public void onPlayerDeath(final PlayerDeathEvent event) { final IPlayerData pData = DataManager.getPlayerData(player); final MovingData data = pData.getGenericInstance(MovingData.class); data.clearMostMovingCheckData(); - data.setSetBack(player.getLocation(useDeathLoc)); + // False-positive tuning: do not keep the death location as a valid movement setback for respawn packets. + data.resetSetBack(); if (pData.isDebugActive(checkType)) debug(player, "Death: " + player.getLocation(useDeathLoc)); useDeathLoc.setWorld(null); } @@ -564,6 +591,7 @@ else if (to == null) { return; } final MovingConfig cc = pData.getGenericInstance(MovingConfig.class); + pData.getGenericInstance(NetData.class).recordTeleportEvent(to, event.getCause().name()); // Detect our own player set backs. if (data.hasTeleported() && onPlayerTeleportMonitorHasTeleported(player, event, to, data, cc, pData)) { data.clearWindChargeImpulse(); // Always clear this data. @@ -573,8 +601,9 @@ else if (to == null) { boolean skipExtras = false; // Skip extra data adjustments during special teleport, e.g. vehicle set back. // Detect our own vehicle set backs (...). if (data.isVehicleSetBack) { - // Uncertain if this is vehicle leave or vehicle enter. - if (event.getCause() != BridgeMisc.TELEPORT_CAUSE_CORRECTION_OF_POSITION) { + // Folia/false-positive tuning: vehicle corrections can surface as DISMOUNT teleports on modern servers. + if (event.getCause() != BridgeMisc.TELEPORT_CAUSE_CORRECTION_OF_POSITION + && event.getCause() != TeleportCause.DISMOUNT) { // TODO: Unexpected, what now? NCPAPIProvider.getNoCheatPlusAPI().getLogManager().warning(Streams.STATUS, CheckUtils.getLogMessagePrefix(player, CheckType.MOVING_VEHICLE) + "Unexpected teleport cause on vehicle set back: " + event.getCause()); } @@ -714,16 +743,53 @@ public void onPlayerMoveMonitor(final PlayerMoveEvent event) { data.lastMoveTime = now; mData.mcFallDistance = player.getFallDistance(); final Location from = event.getFrom(); + final Location to = event.getTo(); + if (!isFoliaMoveRegionSafe(from, to)) { + if (pData.isDebugActive(checkType)) { + debugUnsafeFoliaMove(player, "Skip PlayerMoveEvent monitor", from, to); + } + return; + } // Feed yawrate and reset moving data positions if necessary. final int tick = TickTask.getTick(); final MovingConfig mCc = pData.getGenericInstance(MovingConfig.class); if (!event.isCancelled()) { final Location pLoc = player.getLocation(useLoc); - onMoveMonitorNotCancelled(player, TrigUtil.isSamePosAndLook(pLoc, from) ? from : pLoc, event.getTo(), now, tick, data, mData, mCc, pData); + onMoveMonitorNotCancelled(player, TrigUtil.isSamePosAndLook(pLoc, from) ? from : pLoc, to, now, tick, data, mData, mCc, pData); useLoc.setWorld(null); } else onCancelledMove(player, from, tick, now, mData, mCc, data, pData); } + + private boolean isFoliaMoveRegionSafe(final Location from, final Location to) { + return !SchedulerHelper.isFoliaServer() + || SchedulerHelper.isOwnedByCurrentRegion(from, 1) + && SchedulerHelper.isOwnedByCurrentRegion(to, 1); + } + + private String formatFoliaMoveLocation(final Location location) { + return location == null ? "null" : LocUtil.simpleFormat(location); + } + + private void debugUnsafeFoliaMove(final Player player, final String prefix, final Location from, final Location to) { + debug(player, prefix + ": movement touches a non-owned Folia region. from: " + + formatFoliaMoveLocation(from) + " , to: " + formatFoliaMoveLocation(to)); + } + + private void skipUnsafeFoliaMove(final Player player, final Location from, final Location to, final MovingData data, final boolean debug) { + final Location ref = to != null ? to : from; + data.clearMostMovingCheckData(); + if (ref != null) { + data.setSetBack(ref); + data.lastY = ref.getY(); + } + data.joinOrRespawn = false; + data.lastMoveNoMove = false; + processingEvents.remove(player.getName()); + if (debug) { + debugUnsafeFoliaMove(player, "Skip PlayerMoveEvent checks", from, to); + } + } /** LOWEST level PlayerMoveEvent: this is the level where checks are executed and most moving data is set */ @@ -743,6 +809,14 @@ public void onPlayerMove(final PlayerMoveEvent event) { /** New "to" location where to set back the player to. Requested by vehicle checks */ Location newTo = null; data.increasePlayerMoveCount(); + if (!isFoliaMoveRegionSafe(from, to)) { + skipUnsafeFoliaMove(player, from, to, data, debug); + return; + } + if (SchedulerHelper.isFoliaServer() && player.isInsideVehicle() && !SchedulerHelper.isOwnedByCurrentRegion(player.getVehicle())) { + skipUnsafeFoliaMove(player, from, to, data, debug); + return; + } //////////////////////////////////////////////////// // Early return tests (no full processing). // @@ -757,6 +831,14 @@ public void onPlayerMove(final PlayerMoveEvent event) { earlyReturn = true; token = "vehicle"; } + else if (standsOnEntity(player, Math.min(from.getY(), to.getY()))) { + // Standing on boats produces player moves from the support/passenger + // handoff that do not follow the normal walking/jumping envelope. + data.setSetBack(to); + data.survivalFlyVL *= 0.95; + earlyReturn = true; + token = "boat-support"; + } else if (data.lastVehicleType == EntityType.MINECART && ServerIsAtLeast1_19_4 // The setback comes from VehicleChecks#onPlayerVehicleLeave // Don't be confuse with data.getSetBack(from) here, the location "from" is used when the stored setback is null @@ -1106,6 +1188,10 @@ else if (fromIndex == -1 && TrigUtil.isSamePos(from.getX(), from.getY(), from.ge currentToIndex = count >= maxSplit ? -1 : i; /* The 'to' location skipped/lost by Bukkit in the flying queue. Use Bukkit's "to" if the maximum split was reached */ Location packetTo = count >= maxSplit ? to : new Location(from.getWorld(), filteredFlyingQueue[i].getX(), filteredFlyingQueue[i].getY(), filteredFlyingQueue[i].getZ(), currentYaw, currentPitch); + if (!isFoliaMoveRegionSafe(packet, packetTo)) { + skipUnsafeFoliaMove(player, packet, packetTo, data, debug); + break; + } // Finally, set the moving data to be used by checks. moveInfo.set(player, packet, packetTo, cc.yOnGround); // Finally, remap the input for this move. @@ -1160,6 +1246,10 @@ else if (fromIndex == -1 && TrigUtil.isSamePos(from.getX(), from.getY(), from.ge */ private void bukkitSplitMove(final Player player, final PlayerMoveInfo moveInfo, final Location from, final Location loc, final Location to, final boolean debug, final MovingData data, final MovingConfig cc, final IPlayerData pData, final PlayerMoveEvent event) { + if (!isFoliaMoveRegionSafe(from, loc) || !isFoliaMoveRegionSafe(loc, to)) { + skipUnsafeFoliaMove(player, from, to, data, debug); + return; + } // 1: Use player#getLocation() as the "to" location (from->loc). moveInfo.set(player, from, loc, cc.yOnGround); if (debug) { @@ -1265,6 +1355,13 @@ private boolean checkPlayerMove(final Player player, final Location from, final final PlayerLocation pFrom, pTo; pFrom = moveInfo.from; pTo = moveInfo.to; + + // Teleport/Folia model: NET_MOVING can detect a server-side position jump before Bukkit teleport + // monitor data is visible, so retire stale movement history from the safe move-event thread too. + if (pData.getGenericInstance(NetData.class).applyPendingTeleportResync(player, data, from, to, time) + && debug) { + debug(player, "Applied pending NET_MOVING teleport resync before movement checks."); + } //////////////////////////////////// @@ -1293,6 +1390,9 @@ private boolean checkPlayerMove(final Player player, final Location from, final thisMove.setBackYDistance = pTo.getY() - data.getSetBackY(); thisMove.isGliding = Bridge1_9.isGliding(player); thisMove.tridentRelease = data.consumeTridentReleaseEvent(); + if (isElytraNoFallResetMove(player, thisMove)) { + resetNoFallForElytraGlide(player, pTo, data, time); + } //////////////////////////// // Potion effect "Jump". // @@ -1501,17 +1601,14 @@ else if (!Double.isInfinite(Bridge1_9.getLevitationAmplifier(player))) { // 1.4: Only check NoFall, if not already vetoed. if (checkNf) { checkNf = noFall.isEnabled(player, pData); + if (checkNf && isRecentElytraNoFallReset(data, time)) { + checkNf = false; + } } // 1.5: Hover subcheck. if (newTo == null) { - // TODO: Could reset for from-on-ground as well, for not too big moves. - if (cc.sfHoverCheck && !(lastMove.toIsValid && lastMove.to.extraPropertiesValid && lastMove.to.onGroundOrResetCond) && !pTo.isOnGround()) { - // Start counting ticks. - hoverTicks.add(playerName); - data.sfHoverTicks = 0; - } - else data.sfHoverTicks = -1; + updateHoverTracking(player, playerName, pFrom, pTo, thisMove, lastMove, data, cc, debug); // Still check for NoFall. if (checkNf) { @@ -1638,6 +1735,220 @@ else if (checkCf) { } } + private boolean isElytraNoFallResetMove(final Player player, final PlayerMoveData thisMove) { + return thisMove.isGliding || Bridge1_9.isGlidingWithElytra(player); + } + + private boolean isRecentElytraNoFallReset(final MovingData data, final long now) { + return data.noFallElytraResetTime > 0L + && now >= data.noFallElytraResetTime + && now - data.noFallElytraResetTime <= ELYTRA_NOFALL_LANDING_HANDOFF_MS; + } + + private void resetNoFallForElytraGlide(final Player player, final PlayerLocation to, + final MovingData data, final long now) { + /* + * Elytra NoFall model: while the server accepts gliding, fall damage is + * governed by the elytra glide/collision model, not by ordinary vertical + * drop accumulation. Keep a short handoff for the landing damage event. + */ + data.clearNoFallData(); + data.noFallMaxY = to.getY(); + data.noFallElytraResetTime = now; + if (player.getFallDistance() > 0f) { + player.setFallDistance(0f); + } + } + + private void updateHoverTracking(final Player player, final String playerName, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, final MovingData data, final MovingConfig cc, + final boolean debug) { + if (!shouldTrackHover(player, from, to, lastMove, cc)) { + data.sfHoverTicks = -1; + data.resetHoverAirBudget(); + return; + } + hoverTicks.add(playerName); + if (isHoverMovementExempt(player, from, to, thisMove, data)) { + data.sfHoverTicks = 0; + data.resetHoverAirBudget(); + return; + } + final boolean gliding = thisMove.isGliding || Bridge1_9.isGlidingWithElytra(player); + final boolean descentBudgetDeficit = hasHoverDescentBudgetDeficit(player, thisMove, data); + if (gliding && !isElytraHoverEnforced(cc)) { + /* + * Elytra hover data collection: keep the descent budget and flightTrace + * diagnostics updated, but do not schedule a hover correction until the + * model is explicitly enabled in config. + */ + data.sfHoverTicks = -1; + hoverTicks.remove(playerName); + if (debug && descentBudgetDeficit) { + debug(player, "Elytra hover data-only: descent budget deficit" + + " yDist=" + StringUtil.fdec6.format(thisMove.yDistance) + + " hDist=" + StringUtil.fdec6.format(thisMove.hDistance) + + " airTicks=" + data.hoverAirTicks + + " expectedDrop=" + StringUtil.fdec6.format(data.hoverExpectedDrop) + + " actualDrop=" + StringUtil.fdec6.format(data.hoverActualDrop) + + " lastYVelocity=" + StringUtil.fdec6.format(data.hoverLastYVelocity)); + } + return; + } + if (!descentBudgetDeficit) { + // Hover model: real descent progress keeps the accumulated budget healthy. + data.sfHoverTicks = 0; + return; + } + if (gliding) { + /* + * Elytra hover model: once the descent budget model rejects the glide, + * do not wait for the legacy hover timer. The legacy timer was letting + * packet-shaped hover toggle below the threshold before correction. + */ + data.sfHoverTicks = Math.max(data.sfHoverTicks, cc.sfHoverTicks + hoverTicksStep); + return; + } + if (data.sfHoverTicks < 0) { + data.sfHoverTicks = 0; + if (debug) { + debug(player, "Start hover tracking: descent budget deficit" + + " yDist=" + StringUtil.fdec6.format(thisMove.yDistance) + + " hDist=" + StringUtil.fdec6.format(thisMove.hDistance) + + " gliding=" + thisMove.isGliding + + " airTicks=" + data.hoverAirTicks + + " expectedDrop=" + StringUtil.fdec6.format(data.hoverExpectedDrop) + + " actualDrop=" + StringUtil.fdec6.format(data.hoverActualDrop)); + } + } + } + + + private boolean isElytraHoverEnforced(final MovingConfig cc) { + /* + * Elytra hover is part of the active elytra movement model. Keep the + * dedicated hover switch for staged testing, but also enforce it when + * the broader SurvivalFly elytra model is enabled. + */ + return cc.sfHoverElytraEnforce || cc.sfElytraEnforce; + } + + + private boolean shouldTrackHover(final Player player, final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData lastMove, final MovingConfig cc) { + if (!cc.sfHoverCheck || player.isDead() || player.isSleeping() || player.isInsideVehicle() + || player.isFlying() || player.getAllowFlight()) { + return false; + } + if (to.isOnGroundOrResetCond() || from.isOnGroundOrResetCond()) { + return false; + } + return !(lastMove.toIsValid && lastMove.to.extraPropertiesValid && lastMove.to.onGroundOrResetCond); + } + + + private boolean isHoverMovementExempt(final Player player, final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final MovingData data) { + if (data.timeSinceSetBack < HOVER_RECENT_SETBACK_TICKS || data.hasTeleported() + || data.joinOrRespawn || data.timeRiptiding + 1500 > System.currentTimeMillis()) { + return true; + } + if (from.isResetCond() || to.isResetCond() || from.isInLiquid() || to.isInLiquid() + || from.isOnClimbable() || to.isOnClimbable() || from.isInWeb() || to.isInWeb() + || from.isInPowderSnow() || to.isInPowderSnow()) { + return true; + } + if (!Double.isInfinite(Bridge1_13.getSlowfallingAmplifier(player))) { + return true; + } + final boolean gliding = thisMove.isGliding || Bridge1_9.isGlidingWithElytra(player); + if (data.hasQueuedVerVel() || data.getHorizontalVelocityTracker().hasQueued()) { + return true; + } + if (gliding) { + /* + * Elytra anti-hover model: firework/riptide/velocity cases are handled + * elsewhere. Plain gliding is allowed to climb briefly, but sustained + * no-firework hover has to pay the accumulated descent budget. + */ + return data.fireworksBoostDuration > 0; + } + return player.getVelocity().getY() > HOVER_VERTICAL_PROGRESS; + } + + + private boolean hasHoverDescentBudgetDeficit(final Player player, final PlayerMoveData thisMove, + final MovingData data) { + final boolean gliding = thisMove.isGliding || Bridge1_9.isGlidingWithElytra(player); + final double expectedY = getHoverExpectedY(player, thisMove, data, gliding); + final int minTicks = gliding ? HOVER_ELYTRA_BUDGET_MIN_TICKS : HOVER_AIR_BUDGET_MIN_TICKS; + final double allowedDeficit = gliding ? HOVER_ELYTRA_DROP_DEFICIT : HOVER_AIR_DROP_DEFICIT; + data.hoverAirTicks++; + data.hoverExpectedDrop += Math.max(0.0D, -expectedY) + getHoverUnexpectedAscentDebt(thisMove, gliding); + data.hoverActualDrop += Math.max(0.0D, -thisMove.yDistance); + if (data.hoverActualDrop > data.hoverExpectedDrop + allowedDeficit) { + // Hover model: cap old descent credit so a past dive cannot bankroll future hovering. + data.hoverActualDrop = data.hoverExpectedDrop + allowedDeficit; + } + data.hoverLastYVelocity = expectedY; + trimHoverAirBudget(data); + + final double deficit = data.hoverExpectedDrop - data.hoverActualDrop; + final boolean flatNoDropGlide = gliding + && data.hoverAirTicks >= minTicks + && data.hoverActualDrop <= HOVER_ELYTRA_FLAT_MAX_ACTUAL_DROP + && deficit > HOVER_ELYTRA_FLAT_DROP_DEFICIT; + return data.hoverAirTicks >= minTicks && (deficit > allowedDeficit || flatNoDropGlide) + && (gliding || Math.abs(thisMove.yDistance) < HOVER_VERTICAL_PROGRESS + || thisMove.yDistance > expectedY + Magic.PREDICTION_EPSILON); + } + + + private double getHoverUnexpectedAscentDebt(final PlayerMoveData thisMove, final boolean gliding) { + if (!gliding || thisMove.yDistance <= 0.0D) { + return 0.0D; + } + /* + * Elytra anti-hover model: climbing without a firework is worse than simply + * missing descent, so count movement above the vanilla glide envelope as + * vertical debt in the same accumulated budget. + */ + final double modelUpper = Math.max(0.0D, thisMove.yAllowedDistance); + return Math.max(0.0D, thisMove.yDistance - modelUpper); + } + + + private double getHoverExpectedY(final Player player, final PlayerMoveData thisMove, + final MovingData data, final boolean gliding) { + double expectedY = thisMove.yAllowedDistance; + if (Double.isNaN(expectedY) || Double.isInfinite(expectedY)) { + expectedY = 0.0D; + } + if (gliding) { + /* + * Elytra hover model: gliding may trade speed for lift, but without a + * firework or queued server velocity it still needs a downward budget + * over time. Horizontal progress alone must not reset hover. + */ + return Math.min(expectedY, -HOVER_ELYTRA_MIN_DESCENT); + } + if (expectedY > -Magic.PREDICTION_EPSILON) { + expectedY = (data.hoverLastYVelocity - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR; + } + return expectedY; + } + + + private void trimHoverAirBudget(final MovingData data) { + if (data.hoverAirTicks <= HOVER_AIR_BUDGET_WINDOW) { + return; + } + data.hoverAirTicks = HOVER_AIR_BUDGET_WINDOW / 2; + data.hoverExpectedDrop *= 0.5D; + data.hoverActualDrop *= 0.5D; + } + private void prepareCreativeFlyCheck(final Player player, final Location from, final Location to, final PlayerMoveInfo moveInfo, final PlayerMoveData thisMove, final int multiMoveCount, @@ -1981,13 +2292,27 @@ private void onCancelledMove(final Player player, final Location from, final int private void onMoveMonitorNotCancelled(final Player player, final Location from, final Location to, final long now, final long tick, final CombinedData data, final MovingData mData, final MovingConfig mCc, final IPlayerData pData) { - final String toWorldName = to.getWorld().getName(); final boolean debug = pData.isDebugActive(checkType); + if (!isFoliaMoveRegionSafe(from, to)) { + if (debug) { + debugUnsafeFoliaMove(player, "Skip PlayerMoveEvent monitor follow-up", from, to); + } + return; + } + final String toWorldName = to.getWorld().getName(); Combined.feedYawRate(player, to.getYaw(), now, toWorldName, data, pData); // TODO: maybe even not count vehicles at all ? if (player.isInsideVehicle()) { // TODO: refine (!). - final Location ref = player.getVehicle().getLocation(useLoc); + final Entity vehicle = player.getVehicle(); + if (!SchedulerHelper.isOwnedByCurrentRegion(vehicle)) { + if (debug) { + debug(player, "Skip PlayerMoveEvent vehicle monitor follow-up: vehicle is not owned by the current Folia region."); + } + mData.clearVehicleData(); + return; + } + final Location ref = vehicle.getLocation(useLoc); aux.resetPositionsAndMediumProperties(player, ref, mData, mCc); // TODO: Consider using to and intercept cheat attempts in another way. useLoc.setWorld(null); mData.updateTrace(player, to, tick, mcAccess.getHandle()); // TODO: Can you become invincible by sending special moves? @@ -2155,6 +2480,18 @@ private void debugTeleportMessage(final Player player, final PlayerTeleportEvent private void checkFallDamageEvent(final Player player, final EntityDamageEvent event) { final IPlayerData pData = DataManager.getPlayerData(player); final MovingData data = pData.getGenericInstance(MovingData.class); + final long now = System.currentTimeMillis(); + if (Bridge1_9.isGlidingWithElytra(player) || isRecentElytraNoFallReset(data, now)) { + /* + * Elytra NoFall model: valid glide descent should not become stored + * ordinary fall distance on the landing packet or the following fall + * damage event. SurvivalFly handles invalid gliding separately. + */ + event.setCancelled(true); + data.clearNoFallData(); + player.setFallDistance(0f); + return; + } if (player.isInsideVehicle()) { // Ignore vehicles (noFallFallDistance will be inaccurate anyway). data.clearNoFallData(); @@ -2453,18 +2790,33 @@ public void onTick(final int tick, final long timeLast) { // TODO: Change to per world checking (as long as configs are per world). // Legacy: enforcing location consistency. if (!playersEnforce.isEmpty()) { - checkOnTickPlayersEnforce(); + if (SchedulerHelper.isFoliaServer()) { + checkOnTickPlayersEnforceFolia(); + } + else { + checkOnTickPlayersEnforce(); + } } // Hover check (SurvivalFly). if (tick % hoverTicksStep == 0 && !hoverTicks.isEmpty()) { // Only check every so and so ticks. - checkOnTickHover(); + if (SchedulerHelper.isFoliaServer()) { + checkOnTickHoverFolia(); + } + else { + checkOnTickHover(); + } } // Cleanup. useTickLoc.setWorld(null); } + private Plugin getPlugin() { + return Bukkit.getPluginManager().getPlugin("NoCheatPlus"); + } + + /** * Check for hovering.
* NOTE: Makes use of useLoc, without resetting it. @@ -2484,7 +2836,7 @@ private void checkOnTickHover() { final MovingData data = pData.getGenericInstance(MovingData.class); if (player.isDead() || player.isSleeping() || player.isInsideVehicle()) { data.sfHoverTicks = -1; - // (Removed below.) + data.resetHoverAirBudget(); } if (data.sfHoverTicks < 0) { data.sfHoverLoginTicks = 0; @@ -2501,6 +2853,7 @@ else if (data.sfHoverLoginTicks > 0) { if (!cc.sfHoverCheck) { rem.add(playerName); data.sfHoverTicks = -1; + data.resetHoverAirBudget(); continue; } // Increase ticks here. @@ -2518,6 +2871,82 @@ else if (data.sfHoverLoginTicks > 0) { } + /** + * Folia requires block/entity reads to happen on the owning entity's + * scheduler. The global tick task only dispatches the hover work. + */ + private void checkOnTickHoverFolia() { + final Plugin plugin = getPlugin(); + if (plugin == null) { + return; + } + for (final String playerName : hoverTicks) { + final Player player = DataManager.getPlayerExact(playerName); + if (player == null) { + hoverTicks.remove(playerName); + continue; + } + SchedulerHelper.runSyncTaskForEntity(player, plugin, (arg) -> { + final PlayerMoveInfo info = aux.usePlayerMoveInfo(); + try { + final List rem = new ArrayList(1); + synchronized (this) { + checkOnTickHover(playerName, info, rem); + } + if (!rem.isEmpty()) { + hoverTicks.removeAll(rem); + } + } + finally { + aux.returnPlayerMoveInfo(info); + } + }, () -> hoverTicks.remove(playerName)); + } + } + + + private void checkOnTickHover(final String playerName, final PlayerMoveInfo info, final List rem) { + final Player player = DataManager.getPlayerExact(playerName); + if (player == null || !player.isOnline()) { + rem.add(playerName); + return; + } + final IPlayerData pData = DataManager.getPlayerData(player); + final MovingData data = pData.getGenericInstance(MovingData.class); + if (player.isDead() || player.isSleeping() || player.isInsideVehicle()) { + data.sfHoverTicks = -1; + data.resetHoverAirBudget(); + } + if (data.sfHoverTicks < 0) { + data.sfHoverLoginTicks = 0; + rem.add(playerName); + return; + } + else if (data.sfHoverLoginTicks > 0) { + // Additional "grace period". + data.sfHoverLoginTicks --; + return; + } + final MovingConfig cc = pData.getGenericInstance(MovingConfig.class); + // Check if enabled at all. + if (!cc.sfHoverCheck) { + rem.add(playerName); + data.sfHoverTicks = -1; + data.resetHoverAirBudget(); + return; + } + // Increase ticks here. + data.sfHoverTicks += hoverTicksStep; + if (data.sfHoverTicks < cc.sfHoverTicks) { + // Don't do the heavier checking here, let moving checks reset these. + return; + } + if (checkHover(player, data, cc, pData, info)) { + rem.add(playerName); + } + } + + /** * Legacy check: Enforce location of players, in case of inconsistencies. * First move exploit / possibly vehicle leave.
@@ -2548,6 +2977,49 @@ else if (player.isDead() || player.isSleeping() || player.isInsideVehicle()) { } + /** + * Folia requires player location reads and corrective teleports to happen + * on the player's entity scheduler. + */ + private void checkOnTickPlayersEnforceFolia() { + final Plugin plugin = getPlugin(); + if (plugin == null) { + return; + } + for (final String playerName : playersEnforce) { + final Player player = DataManager.getPlayerExact(playerName); + if (player == null) { + playersEnforce.remove(playerName); + continue; + } + SchedulerHelper.runSyncTaskForEntity(player, plugin, (arg) -> { + synchronized (this) { + checkOnTickPlayerEnforce(playerName); + } + }, () -> playersEnforce.remove(playerName)); + } + } + + + private void checkOnTickPlayerEnforce(final String playerName) { + final Player player = DataManager.getPlayerExact(playerName); + if (player == null || !player.isOnline()) { + playersEnforce.remove(playerName); + return; + } + else if (player.isDead() || player.isSleeping() || player.isInsideVehicle()) { + // Don't remove but also don't check [subject to change]. + return; + } + final MovingData data = DataManager.getGenericInstance(player, MovingData.class); + final Location newTo = enforceLocation(player, player.getLocation(useTickLoc), data); + if (newTo != null) { + data.prepareSetBack(newTo); + SchedulerHelper.teleportEntity(player, newTo, BridgeMisc.TELEPORT_CAUSE_CORRECTION_OF_POSITION); + } + } + + private Location enforceLocation(final Player player, final Location loc, final MovingData data) { final PlayerMoveData lastMove = data.playerMoves.getFirstPastMove(); if (lastMove.toIsValid && TrigUtil.distanceSquared(lastMove.to.getX(), lastMove.to.getY(), lastMove.to.getZ(), loc.getX(), loc.getY(), loc.getZ()) > 1.0 / 256.0) { @@ -2580,6 +3052,7 @@ private boolean checkHover(final Player player, final MovingData data, final Mov if (info.from.isOnGroundOrResetCond() || info.from.isAboveLadder()) { res = true; data.sfHoverTicks = 0; + data.resetHoverAirBudget(); } else { if (data.sfHoverTicks > cc.sfHoverTicks) { @@ -2609,10 +3082,21 @@ private boolean checkHover(final Player player, final MovingData data, final Mov private void handleHoverViolation(final Player player, final PlayerLocation loc, final MovingConfig cc, final MovingData data, final IPlayerData pData) { // Check nofall damage (!). - if (cc.sfHoverFallDamage && noFall.isEnabled(player, pData)) { + final long now = System.currentTimeMillis(); + if (cc.sfHoverFallDamage && noFall.isEnabled(player, pData) + && !Bridge1_9.isGlidingWithElytra(player) + && !isRecentElytraNoFallReset(data, now)) { // Consider adding 3/3.5 to fall distance if fall distance > 0? noFall.checkDamage(player, loc.getY(), data, pData); } + if (pData.isDebugActive(checkType)) { + final double deficit = data.hoverExpectedDrop - data.hoverActualDrop; + debug(player, "Hover descent budget violation: hoverAirTicks=" + data.hoverAirTicks + + " hoverExpectedDrop=" + StringUtil.fdec6.format(data.hoverExpectedDrop) + + " hoverActualDrop=" + StringUtil.fdec6.format(data.hoverActualDrop) + + " hoverLastYVelocity=" + StringUtil.fdec6.format(data.hoverLastYVelocity) + + " hoverDropDeficit=" + StringUtil.fdec6.format(deficit)); + } // Delegate violation handling. survivalFly.handleHoverViolation(player, loc, cc, data); } @@ -2755,4 +3239,4 @@ private void outputMoveDebug(final Player player, final PlayerLocation from, fin NCPAPIProvider.getNoCheatPlusAPI().getLogManager().debug(Streams.TRACE_FILE, builder.toString()); } } -} \ No newline at end of file +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/location/tracking/LocationTrace.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/location/tracking/LocationTrace.java index 53c9163103..ec3569f754 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/location/tracking/LocationTrace.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/location/tracking/LocationTrace.java @@ -137,16 +137,26 @@ public boolean isInside(final double x, final double y, final double z) { } - public static final class TraceEntryPool extends AbstractPool { - - public TraceEntryPool(int maxPoolSize) { - super(maxPoolSize); - } - - @Override - protected TraceEntry newInstance() { - return new TraceEntry(); - } + public static final class TraceEntryPool extends AbstractPool { + + public TraceEntryPool(int maxPoolSize) { + super(maxPoolSize); + } + + @Override + public synchronized TraceEntry getInstance() { + return super.getInstance(); + } + + @Override + public synchronized void returnInstance(final TraceEntry instance) { + super.returnInstance(instance); + } + + @Override + protected TraceEntry newInstance() { + return new TraceEntry(); + } } @@ -254,11 +264,14 @@ public final void addEntry(final long time, final double x, final double y, fina // (No update of time. firstEntry ... now always counts.) return; } - } - // Add a new entry. - final TraceEntry newEntry = pool.getInstance(); - newEntry.set(time, x, y, z, boxMarginHorizontal, boxMarginVertical); - setFirst(newEntry); + } + // Add a new entry. + final TraceEntry newEntry = pool == null ? new TraceEntry() : pool.getInstance(); + if (newEntry == null) { + return; + } + newEntry.set(time, x, y, z, boxMarginHorizontal, boxMarginVertical); + setFirst(newEntry); // Remove the last entry, if maxSize is exceeded. if (size > maxSize) { returnToPool(lastEntry); diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/model/PlayerMoveData.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/model/PlayerMoveData.java index dc00130fa9..a00894e7f5 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/model/PlayerMoveData.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/model/PlayerMoveData.java @@ -76,7 +76,7 @@ public class PlayerMoveData extends MoveData { * riptide action itself. * */ - public AlmostBoolean tridentRelease; + public AlmostBoolean tridentRelease = AlmostBoolean.NO; /** * The distance covered by a move from the setback point to the to.getY() point. @@ -271,7 +271,7 @@ public class PlayerMoveData extends MoveData { * *

For clients supporting impulse event-sending, this will only return YES or NO, as input data is always available.

*/ - public AlmostBoolean hasImpulse; + public AlmostBoolean hasImpulse = AlmostBoolean.NO; /** * Indicates the strafing direction (LEFT, RIGHT, or NONE). @@ -279,7 +279,7 @@ public class PlayerMoveData extends MoveData { *

This value is set even if horizontal movement could not be accurately predicted, so it may be unreliable, unless the client sends impulse events, in which case it is dependable. * Check {@link PlayerMoveData#hasImpulse} for its reliability prior to version 1.21.2

*/ - public PlayerKeyboardInput.StrafeDirection strafeImpulse; + public PlayerKeyboardInput.StrafeDirection strafeImpulse = PlayerKeyboardInput.StrafeDirection.NONE; /** * Indicates the forward movement direction (FORWARD, BACKWARD, or NONE). @@ -287,7 +287,7 @@ public class PlayerMoveData extends MoveData { *

This value is set even if horizontal movement could not be accurately predicted, so it may be unreliable, unless the client sends impulse events, in which case it is dependable. * Check {@link PlayerMoveData#hasImpulse} for its reliability prior to version 1.21.2

*/ - public PlayerKeyboardInput.ForwardDirection forwardImpulse; + public PlayerKeyboardInput.ForwardDirection forwardImpulse = PlayerKeyboardInput.ForwardDirection.NONE; /** * Judge if this horizontal collision ({@link PlayerMoveData#collideX} or {@link PlayerMoveData#collideZ}) is to be considered as minor. diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/HiddenMotionReconstructor.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/HiddenMotionReconstructor.java index 0567590043..bf4e3a2cd8 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/HiddenMotionReconstructor.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/HiddenMotionReconstructor.java @@ -197,8 +197,8 @@ private static double[] reconstructHiddenTicksRecursive(final double yDistance, } // *----------tryCheckInsideBlocks()----------* // Bubble columns are checked in the tryCheckInsideBlocks method, so it comes after updateEntityAfterFallOn()... - //Vector bubbleVector = from.tryApplyBubbleColumnMotion(new Vector(0.0, baseY, 0.0)); - //baseY = bubbleVector.getY(); + Vector bubbleVector = from.tryApplyBubbleColumnMotion(new Vector(0.0, baseY, 0.0)); + baseY = bubbleVector.getY(); // Honey block sliding mechanic... if (from.isSlidingDown()) { // Speed is static in this case @@ -275,7 +275,7 @@ else if (lastMove.from.inLava) { // This condition is the same for both lava and water, and is always done at the end of the travel() function. if (lastMove.from.inLiquid && lastMove.collidesHorizontally // TODO: Somewhat work. Incorrect horizontal move. Require this function call at the time BOTH horizontal and vertical calculating at the same time. Which is not possible with current infrastructure - && from.isUnobstructed(0.0)) { + && from.isUnobstructed()) { baseY = 0.3; } diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/MorePackets.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/MorePackets.java index d4e91b099a..94bbd7e9a4 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/MorePackets.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/MorePackets.java @@ -25,10 +25,11 @@ import fr.neatmonster.nocheatplus.checks.CheckType; import fr.neatmonster.nocheatplus.checks.ViolationData; import fr.neatmonster.nocheatplus.checks.moving.MovingConfig; -import fr.neatmonster.nocheatplus.checks.moving.MovingData; -import fr.neatmonster.nocheatplus.checks.net.NetStatic; -import fr.neatmonster.nocheatplus.players.IPlayerData; -import fr.neatmonster.nocheatplus.utilities.StringUtil; +import fr.neatmonster.nocheatplus.checks.moving.MovingData; +import fr.neatmonster.nocheatplus.checks.net.NetStatic; +import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; +import fr.neatmonster.nocheatplus.utilities.StringUtil; import fr.neatmonster.nocheatplus.utilities.location.PlayerLocation; /** @@ -86,22 +87,28 @@ public Location check(final Player player, final PlayerLocation from, final Play } } - // Check for a violation of the set limits. - tags.clear(); - final double violation = NetStatic.morePacketsCheck(data.morePacketsFreq, time, 1f, cc.morePacketsEPSMax, cc.morePacketsEPSIdeal, data.morePacketsBurstFreq, cc.morePacketsBurstPackets, cc.morePacketsBurstDirect, cc.morePacketsBurstEPM, tags); + // Check for a violation of the set limits. + tags.clear(); + // Diagnostic info: packet-rate tags are separate from SurvivalFly movement-model tags. + final double violation = NetStatic.morePacketsCheck(data.morePacketsFreq, time, 1f, cc.morePacketsEPSMax, cc.morePacketsEPSIdeal, data.morePacketsBurstFreq, cc.morePacketsBurstPackets, cc.morePacketsBurstDirect, cc.morePacketsBurstEPM, tags); // Process violation result. - if (violation > 0.0) { - // Increment violation level. - data.morePacketsVL = violation; // TODO: Accumulate somehow [e.g. always += 1, decrease with continuous moving without violation]? - - // Violation handling. - final ViolationData vd = new ViolationData(this, player, data.morePacketsVL, violation, cc.morePacketsActions); - if (debug || vd.needsParameters()) { - vd.setParameter(ParameterName.PACKETS, Integer.toString(new Double(violation).intValue())); - vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); - } - if (executeActions(vd).willCancel()) { + if (violation > 0.0) { + // Increment violation level. + data.morePacketsVL = violation; // TODO: Accumulate somehow [e.g. always += 1, decrease with continuous moving without violation]? + // Diagnostic info: expose packet-rate violations as their own subcheck under MOVING_MOREPACKETS. + tags.add(0, "subcheck_morepackets_frequency"); + + // Violation handling. + final ViolationData vd = new ViolationData(this, player, data.morePacketsVL, violation, cc.morePacketsActions); + if (debug || vd.needsParameters()) { + vd.setParameter(ParameterName.PACKETS, Integer.toString(new Double(violation).intValue())); + vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); + } + if (CheckUtils.shouldLogDebugToConsole()) { + logConsoleDetails(violation, player, from, to, allowSetSetBack, data, cc, pData, time); + } + if (executeActions(vd).willCancel()) { // Set to cancel the move. /* * TODO: Harmonize with MovingUtil.getApplicableSetBackLocation @@ -122,6 +129,92 @@ else if (allowSetSetBack && data.getMorePacketsSetBackAge() > cc.morePacketsSetB // No set back. return null; - } - -} + } + + private void logConsoleDetails(final double violation, final Player player, + final PlayerLocation from, final PlayerLocation to, + final boolean allowSetSetBack, + final MovingData data, final MovingConfig cc, + final IPlayerData pData, final long time) { + try { + // Diagnostic info: console-only packet-rate context for distinguishing packet spam from movement model issues. + final StringBuilder builder = new StringBuilder(620); + builder.append("[NCP][MorePackets][detail] player=").append(player.getName()) + .append(" bedrock=").append(pData.isBedrockPlayer() || player.getName().startsWith(".") + || player.getUniqueId().toString().startsWith("00000000-0000-0000-0009-")) + .append(" bedrockData=").append(pData.isBedrockPlayer()) + .append(" uuid=").append(player.getUniqueId()) + .append(" client=").append(pData.getClientVersion()) + .append(" time=").append(time) + .append(" subcheck=MOREPACKETS_FREQUENCY") + .append(" summary=packet_rate{violation=").append(StringUtil.fdec3.format(violation)) + .append(",epsMax=").append(cc.morePacketsEPSMax) + .append(",burst=").append(cc.morePacketsBurstPackets) + .append(",setback=").append(data.hasMorePacketsSetBack()) + .append('}') + .append(" violation=").append(StringUtil.fdec3.format(violation)) + .append(" totalVL=").append(StringUtil.fdec3.format(data.morePacketsVL)) + .append(" moveCount=").append(data.getPlayerMoveCount()) + .append(" allowSetBack=").append(allowSetSetBack) + .append(" morePacketsSetBack=").append(data.hasMorePacketsSetBack()) + .append(" morePacketsSetBackAge=").append(data.getMorePacketsSetBackAge()) + .append(" from=").append(formatLocation(from)) + .append(" to=").append(formatLocation(to)) + .append(" move=").append(formatMove(from, to)) + .append(" fromEnv=").append(formatEnvironment(from)) + .append(" toEnv=").append(formatEnvironment(to)) + .append(" playerState=sprint:").append(player.isSprinting()) + .append(",sneak:").append(player.isSneaking()) + .append(",fly:").append(player.isFlying()) + .append(",allowFlight:").append(player.getAllowFlight()) + .append(",vehicle:").append(player.isInsideVehicle()) + .append(",velocity=").append(StringUtil.fdec3.format(player.getVelocity().getX())) + .append('/').append(StringUtil.fdec3.format(player.getVelocity().getY())) + .append('/').append(StringUtil.fdec3.format(player.getVelocity().getZ())) + .append(" config=epsIdeal:").append(cc.morePacketsEPSIdeal) + .append(",epsMax:").append(cc.morePacketsEPSMax) + .append(",burstPackets:").append(cc.morePacketsBurstPackets) + .append(",burstDirect:").append(cc.morePacketsBurstDirect) + .append(",burstEPM:").append(cc.morePacketsBurstEPM) + .append(" tags=").append(StringUtil.join(tags, "+")); + player.getServer().getLogger().info(builder.toString()); + } + catch (Throwable t) { + player.getServer().getLogger().info("[NCP][MorePackets][detail] diagnostic logging failed for player=" + + player.getName() + " reason=" + t.getClass().getSimpleName()); + } + } + + private String formatMove(final PlayerLocation from, final PlayerLocation to) { + final double x = to.getX() - from.getX(); + final double y = to.getY() - from.getY(); + final double z = to.getZ() - from.getZ(); + final double h = Math.sqrt(x * x + z * z); + return "x:" + StringUtil.fdec6.format(x) + + ",y:" + StringUtil.fdec6.format(y) + + ",z:" + StringUtil.fdec6.format(z) + + ",h:" + StringUtil.fdec6.format(h); + } + + private String formatLocation(final PlayerLocation location) { + return StringUtil.fdec3.format(location.getX()) + "," + + StringUtil.fdec3.format(location.getY()) + "," + + StringUtil.fdec3.format(location.getZ()); + } + + private String formatEnvironment(final PlayerLocation location) { + return "{block:" + location.getBlockType() + + ",below:" + location.getBlockTypeBelow() + + ",above:" + location.getBlockTypeAbove() + + ",ground:" + location.isOnGround() + + ",reset:" + location.isResetCond() + + ",water:" + location.isInWater() + + ",lava:" + location.isInLava() + + ",waterlogged:" + location.isInWaterLogged() + + ",climb:" + location.isOnClimbable() + + ",web:" + location.isInWeb() + + ",passable:" + location.isPassable() + + "}"; + } + +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/NoFall.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/NoFall.java index 36ef30004a..1a75026b52 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/NoFall.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/NoFall.java @@ -429,18 +429,18 @@ public void onTick(int tick, long timeLast) { if (player.getNoDamageTicks() > 0) { return; } - player.setLastDamageCause(event); - mcAccess.getHandle().dealFallDamage(player, BridgeHealth.getRawDamage(event)); - TickTask.removeTickListener(this); - } - }; - TickTask.addTickListener(damagePlayer); - } - else { - player.setLastDamageCause(event); - mcAccess.getHandle().dealFallDamage(player, BridgeHealth.getRawDamage(event)); - } - } + BridgeHealth.setLastDamageCause(player, event); + mcAccess.getHandle().dealFallDamage(player, BridgeHealth.getRawDamage(event)); + TickTask.removeTickListener(this); + } + }; + TickTask.addTickListener(damagePlayer); + } + else { + BridgeHealth.setLastDamageCause(player, event); + mcAccess.getHandle().dealFallDamage(player, BridgeHealth.getRawDamage(event)); + } + } } // Currently resetting is done from within the damage event handler. @@ -629,4 +629,4 @@ public void checkDamage(final Player player, final double y, final MovingData d // Deal damage. handleOnGround(player, y, data.hasSetBack() ? data.getSetBackY() : Double.NEGATIVE_INFINITY, false, data, cc, pData); } -} \ No newline at end of file +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/Passable.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/Passable.java index efdf96eefa..d694255a78 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/Passable.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/Passable.java @@ -26,12 +26,14 @@ import fr.neatmonster.nocheatplus.checks.CheckType; import fr.neatmonster.nocheatplus.checks.ViolationData; import fr.neatmonster.nocheatplus.checks.moving.MovingConfig; -import fr.neatmonster.nocheatplus.checks.moving.MovingData; -import fr.neatmonster.nocheatplus.compat.blocks.changetracker.BlockChangeTracker; -import fr.neatmonster.nocheatplus.players.IPlayerData; -import fr.neatmonster.nocheatplus.utilities.collision.Axis; -import fr.neatmonster.nocheatplus.utilities.collision.tracing.axis.ICollidePassable; -import fr.neatmonster.nocheatplus.utilities.collision.tracing.axis.PassableAxisTracing; +import fr.neatmonster.nocheatplus.checks.moving.MovingData; +import fr.neatmonster.nocheatplus.compat.blocks.changetracker.BlockChangeTracker; +import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; +import fr.neatmonster.nocheatplus.utilities.StringUtil; +import fr.neatmonster.nocheatplus.utilities.collision.Axis; +import fr.neatmonster.nocheatplus.utilities.collision.tracing.axis.ICollidePassable; +import fr.neatmonster.nocheatplus.utilities.collision.tracing.axis.PassableAxisTracing; import fr.neatmonster.nocheatplus.utilities.location.LocUtil; import fr.neatmonster.nocheatplus.utilities.location.PlayerLocation; import fr.neatmonster.nocheatplus.utilities.map.BlockProperties; @@ -87,11 +89,12 @@ private Location checkActual(final Player player, final PlayerLocation from, fin final int manhattan = from.manhattan(to); // Check default order first, then others. - rayTracing.setAxisOrder(Axis.AXIS_ORDER_YXZ); - String newTag = checkRayTracing(player, from, to, manhattan, data, cc, debug, tick, useBlockChangeTracker); - if (newTag != null) { - newTag = checkRayTracingAlernateOrder(player, from, to, manhattan, debug, data, cc, tick, useBlockChangeTracker, newTag); - } + rayTracing.setAxisOrder(Axis.AXIS_ORDER_YXZ); + String newTag = checkRayTracing(player, from, to, manhattan, data, cc, debug, tick, useBlockChangeTracker); + if (newTag != null) { + // Diagnostic info: alternate axis orders identify which collision path caused a passable flag. + newTag = checkRayTracingAlernateOrder(player, from, to, manhattan, debug, data, cc, tick, useBlockChangeTracker, newTag); + } // Finally handle violations. if (newTag == null) { // (Might consider if vl>=1: only decrease if from and loc are passable too, though micro...) @@ -212,20 +215,25 @@ private Location actualViolation(final Player player, } } - // Return the reset position. - data.passableVL += 1d; - final ViolationData vd = new ViolationData(this, player, data.passableVL, 1, cc.passableActions); - if (debug || vd.needsParameters()) { - vd.setParameter(ParameterName.LOCATION_FROM, String.format(Locale.US, "%.2f, %.2f, %.2f", from.getX(), from.getY(), from.getZ())); - vd.setParameter(ParameterName.LOCATION_TO, String.format(Locale.US, "%.2f, %.2f, %.2f", to.getX(), to.getY(), to.getZ())); - vd.setParameter(ParameterName.DISTANCE, String.format(Locale.US, "%.2f", TrigUtil.distance(from, to))); - if (!tags.isEmpty()) { - vd.setParameter(ParameterName.TAGS, tags); - } - } - if (executeActions(vd).willCancel()) { - // TODO: Consider another set back position for this, also keeping track of players moving around in blocks. - final Location newTo; + // Return the reset position. + data.passableVL += 1d; + final ViolationData vd = new ViolationData(this, player, data.passableVL, 1, cc.passableActions); + // Diagnostic info: keep raytrace branch/block context on MOVING_PASSABLE violations. + final String diagnosticTags = getPassableTags(tags, from, to); + if (debug || vd.needsParameters()) { + vd.setParameter(ParameterName.LOCATION_FROM, String.format(Locale.US, "%.2f, %.2f, %.2f", from.getX(), from.getY(), from.getZ())); + vd.setParameter(ParameterName.LOCATION_TO, String.format(Locale.US, "%.2f, %.2f, %.2f", to.getX(), to.getY(), to.getZ())); + vd.setParameter(ParameterName.DISTANCE, String.format(Locale.US, "%.2f", TrigUtil.distance(from, to))); + if (!diagnosticTags.isEmpty()) { + vd.setParameter(ParameterName.TAGS, diagnosticTags); + } + } + if (CheckUtils.shouldLogDebugToConsole()) { + logPassableDetail(player, from, to, data.passableVL, diagnosticTags); + } + if (executeActions(vd).willCancel()) { + // TODO: Consider another set back position for this, also keeping track of players moving around in blocks. + final Location newTo; if (setBackLoc != null) { // Ensure the given location is cloned. newTo = LocUtil.clone(setBackLoc); @@ -241,13 +249,57 @@ private Location actualViolation(final Player player, } else{ // No cancel action set. - return null; - } - } - - /** - * Debug only if colliding. - * + return null; + } + } + + private String getPassableTags(final String tags, final PlayerLocation from, final PlayerLocation to) { + // Diagnostic info: show which passable raytrace branch failed and which blocks were involved. + final String branch = tags == null || tags.isEmpty() ? "unknown" : tags.replaceAll("_+$", ""); + return "subcheck_passable_raytrace" + + "+branch_" + branch.toLowerCase(Locale.ROOT) + + "+from_block_" + from.getBlockType().name().toLowerCase(Locale.ROOT) + + "+to_block_" + to.getBlockType().name().toLowerCase(Locale.ROOT); + } + + private void logPassableDetail(final Player player, final PlayerLocation from, final PlayerLocation to, + final double totalVL, final String tags) { + try { + // Diagnostic info: console-only passable context for stuck-on-block and collision false positives. + player.getServer().getLogger().info(new StringBuilder(420) + .append("[NCP][Passable][detail] player=").append(player.getName()) + .append(" uuid=").append(player.getUniqueId()) + .append(" subcheck=PASSABLE_RAYTRACE") + .append(" summary=passable_raytrace{branch=").append(tags == null ? "unknown" : tags) + .append(",from=").append(from.getBlockType()) + .append(",to=").append(to.getBlockType()) + .append(",vl=").append(StringUtil.fdec3.format(totalVL)) + .append('}') + .append(" totalVL=").append(StringUtil.fdec3.format(totalVL)) + .append(" distance=").append(StringUtil.fdec3.format(TrigUtil.distance(from, to))) + .append(" from=").append(formatLocation(from)) + .append(" to=").append(formatLocation(to)) + .append(" fromBlock=").append(from.getBlockType()) + .append(" toBlock=").append(to.getBlockType()) + .append(" fromPassable=").append(from.isPassable()) + .append(" toPassable=").append(to.isPassable()) + .append(" tags=").append(tags) + .toString()); + } + catch (Throwable ignored) {} + } + + private String formatLocation(final PlayerLocation location) { + return StringUtil.fdec3.format(location.getX()) + + "," + StringUtil.fdec3.format(location.getY()) + + "," + StringUtil.fdec3.format(location.getZ()) + + "/" + StringUtil.fdec3.format(location.getYaw()) + + "," + StringUtil.fdec3.format(location.getPitch()); + } + + /** + * Debug only if colliding. + * * @param player * @param rayTracing * @param tag diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFly.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFly.java index a24af95ef3..982fd85e36 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFly.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFly.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Locale; import org.bukkit.ChatColor; @@ -42,12 +43,15 @@ import fr.neatmonster.nocheatplus.checks.moving.model.PlayerKeyboardInput.StrafeDirection; import fr.neatmonster.nocheatplus.checks.moving.model.LiftOffEnvelope; import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveData; +import fr.neatmonster.nocheatplus.checks.moving.velocity.PairEntry; +import fr.neatmonster.nocheatplus.checks.net.NetData; import fr.neatmonster.nocheatplus.checks.workaround.WRPT; import fr.neatmonster.nocheatplus.compat.AlmostBoolean; import fr.neatmonster.nocheatplus.compat.Bridge1_13; import fr.neatmonster.nocheatplus.compat.Bridge1_9; import fr.neatmonster.nocheatplus.compat.BridgeMisc; import fr.neatmonster.nocheatplus.compat.SchedulerHelper; +import fr.neatmonster.nocheatplus.compat.bukkit.BridgeMaterial; import fr.neatmonster.nocheatplus.compat.blocks.changetracker.BlockChangeTracker; import fr.neatmonster.nocheatplus.compat.blocks.changetracker.BlockChangeTracker.Direction; import fr.neatmonster.nocheatplus.compat.versions.ClientVersion; @@ -63,6 +67,7 @@ import fr.neatmonster.nocheatplus.utilities.location.PlayerLocation; import fr.neatmonster.nocheatplus.utilities.map.BlockFlags; import fr.neatmonster.nocheatplus.utilities.map.BlockProperties; +import fr.neatmonster.nocheatplus.utilities.map.MaterialUtil; import fr.neatmonster.nocheatplus.utilities.math.MathUtil; import fr.neatmonster.nocheatplus.utilities.math.TrigUtil; import fr.neatmonster.nocheatplus.utilities.moving.Magic; @@ -78,6 +83,405 @@ public class SurvivalFly extends Check { /** To join some tags with moving check violations. */ private final ArrayList tags = new ArrayList<>(15); + + /* + * Bedrock/Geyser clients can disagree with the Java movement model around + * combat knockback and half-block geometry. These values are only used by + * Bedrock-gated helper methods so they do not widen Java client movement. + */ + private static final double BEDROCK_HORIZONTAL_PREDICTION_EPSILON = 0.08D; + private static final double BEDROCK_GROUNDED_COMBAT_HORIZONTAL_OVER_GRACE = 0.75D; + private static final double BEDROCK_GROUNDED_COMBAT_MOVE_GRACE = 0.75D; + private static final double BEDROCK_GROUNDED_COMBAT_VERTICAL_MOVE_GRACE = 0.45D; + private static final double BEDROCK_GROUNDED_COMBAT_VERTICAL_OVER_GRACE = 0.45D; + private static final double BEDROCK_GROUNDED_COMBAT_VERTICAL_VELOCITY_GRACE = 0.45D; + private static final double BEDROCK_GROUNDED_COMBAT_VERTICAL_LAUNCH_HORIZONTAL_RESIDUAL = 0.05D; + private static final double BEDROCK_GROUND_VERTICAL_QUANTUM = 0.0625D; + private static final double BEDROCK_GROUND_VERTICAL_QUANTUM_EPSILON = 0.006D; + private static final double BEDROCK_PACKET_VERTICAL_PRECISION = 0.01D; + private static final double BEDROCK_AIR_GRAVITY_RESET_EPSILON = 0.015D; + private static final double BEDROCK_HALF_STEP_VERTICAL_MOVE = 0.50D; + private static final double BEDROCK_HALF_STEP_VERTICAL_EPSILON = 0.015D; + private static final double BEDROCK_STEP_VERTICAL_UNDERSHOOT_MOVE_GRACE = 0.05D; + private static final double BEDROCK_STEP_VERTICAL_MODEL_GRACE = 0.02D; + private static final double BEDROCK_STEP_VERTICAL_UNDERSHOOT_MIN_MODEL = 0.30D; + /* + * Server-applied velocity can arrive one movement packet apart from the + * player's position update. These graces let legitimate boosts/knockback + * explain a small horizontal miss before SurvivalFly adds VL. + */ + private static final double GROUNDED_VERTICAL_VELOCITY_HORIZONTAL_OVER_GRACE = 0.35D; + private static final double GROUNDED_VERTICAL_VELOCITY_MOVE_GRACE = 0.80D; + private static final double GROUNDED_VERTICAL_VELOCITY_MOVE_Y_GRACE = 0.45D; + private static final double SERVER_VERTICAL_VELOCITY_HORIZONTAL_OVER_GRACE = 0.50D; + private static final double SERVER_VERTICAL_VELOCITY_HORIZONTAL_MOVE_GRACE = 0.65D; + private static final double SERVER_VERTICAL_VELOCITY_ASCEND_GRACE = 1.20D; + private static final double GROUNDED_ITEM_RESYNC_HORIZONTAL_OVER_GRACE = 0.12D; + private static final double GROUNDED_ITEM_RESYNC_MOVE_GRACE = 0.25D; + /* + * Lanterns, trapdoors, carpets, and layered snow have thin support/collision + * shapes. The old full-block model can see these as air and repeatedly set + * players back. + */ + private static final double THIN_SUPPORT_HORIZONTAL_OVER_GRACE = 0.38D; + private static final double THIN_SUPPORT_HORIZONTAL_MOVE_GRACE = 0.80D; + private static final double THIN_SUPPORT_VERTICAL_OVER_GRACE = 0.62D; + private static final double THIN_SUPPORT_VERTICAL_MOVE_GRACE = 0.60D; + private static final double PARTIAL_SUPPORT_STEP_HEIGHT_MODEL = 0.60D; + private static final double PARTIAL_SUPPORT_HORIZONTAL_MODEL_EPSILON = 0.08D; + private static final double PARTIAL_SUPPORT_VERTICAL_MODEL_EPSILON = 0.10D; + private static final double PARTIAL_SUPPORT_VERTICAL_CLAMP_UNIT = 0.0625D; + private static final double PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON = 0.025D; + private static final double PARTIAL_SUPPORT_VERTICAL_CLAMP_MAX_DESCEND = 0.3125D; + private static final double SNOW_SUPPORT_LAYER_HEIGHT = 0.125D; + private static final double SNOW_SUPPORT_MAX_COLLISION_HEIGHT = 0.875D; + private static final double NEWER_CLIENT_HORIZONTAL_OVER_GRACE = 0.04D; + private static final double NEWER_CLIENT_HORIZONTAL_MOVE_GRACE = 0.34D; + private static final double GROUNDED_MICRO_OVER_GRACE = 0.12D; + private static final double GROUNDED_MICRO_MOVE_GRACE = 0.70D; + private static final double GROUNDED_JUMP_HORIZONTAL_OVER_GRACE = 0.05D; + private static final double GROUNDED_JUMP_HORIZONTAL_MOVE_GRACE = 0.75D; + private static final double GROUNDED_JUMP_VERTICAL_MOVE_GRACE = 0.46D; + private static final double GROUNDED_STEP_HORIZONTAL_OVER_GRACE = 0.05D; + private static final double GROUNDED_STEP_HORIZONTAL_MOVE_GRACE = 0.75D; + private static final double GROUNDED_STEP_VERTICAL_MOVE_GRACE = 0.52D; + private static final double GROUNDED_SETBACK_HORIZONTAL_GRACE = 0.20D; + private static final double GROUNDED_SETBACK_MOVE_GRACE = 0.32D; + private static final double GROUNDED_MICRO_HORIZONTAL_GRACE = 0.01D; + private static final double WATER_HORIZONTAL_MODEL_EPSILON = 0.06D; + private static final double WATER_QUEUED_VELOCITY_HORIZONTAL_RESIDUAL = 0.11D; + private static final double WATER_CURRENT_VELOCITY_HORIZONTAL_RESIDUAL = 0.12D; + private static final double WATER_CURRENT_VELOCITY_HORIZONTAL_CAP = 0.45D; + private static final double WATER_IMPLICIT_SWIM_HORIZONTAL_CAP = 0.16D; + private static final double WATER_IMPLICIT_SWIM_HORIZONTAL_RESIDUAL = 0.10D; + private static final double WATER_VERTICAL_MODEL_EPSILON = 0.06D; + private static final double WATER_SURFACE_ASCEND_MODEL = 0.34D; + private static final double WATER_SURFACE_EXIT_ASCEND_MODEL = 0.30D; + private static final double WATER_EXIT_DESCEND_MODEL = Magic.DEFAULT_GRAVITY * Magic.FRICTION_MEDIUM_AIR; + private static final double WATER_DOLPHIN_HORIZONTAL_OVER_GRACE = 0.02D; + private static final double WATER_DOLPHIN_HORIZONTAL_MOVE_GRACE = 0.45D; + private static final double LAVA_VERTICAL_OVER_GRACE = 0.90D; + private static final double LAVA_ASCEND_MOVE_GRACE = 0.95D; + private static final double LAVA_TAG_VERTICAL_OVER_GRACE = 0.10D; + private static final double LAVA_HORIZONTAL_OVER_GRACE = 0.08D; + private static final double LAVA_HORIZONTAL_MOVE_GRACE = 0.35D; + private static final double LAVA_VELOCITY_HORIZONTAL_RESIDUAL = 0.14D; + private static final double LAVA_VELOCITY_HORIZONTAL_PERPENDICULAR_RESIDUAL = 0.08D; + private static final double LAVA_CURRENT_VERTICAL_RESIDUAL = 0.10D; + private static final double CLIMBABLE_HORIZONTAL_OVER_GRACE = 0.03D; + private static final double CLIMBABLE_HORIZONTAL_MOVE_GRACE = 0.13D; + private static final double CLIMBABLE_ASCEND_GRACE = 0.22D; + private static final double CLIMBABLE_DESCEND_GRACE = 0.30D; + private static final double CLIMBABLE_DESCEND_OVER_GRACE = 0.30D; + private static final double CLIMBABLE_VERTICAL_PRECISION_GRACE = 0.005D; + private static final double CLIMBABLE_JUMP_CARRY_HORIZONTAL_EPSILON = 0.04D; + private static final double CLIMBABLE_ENTRY_HORIZONTAL_CARRY = 0.24D; + private static final double CLIMBABLE_DIAGONAL_AXIS_CAP = Magic.CLIMBABLE_MAX_SPEED * Math.sqrt(2.0D); + private static final double LEVITATION_STALL_VERTICAL_GRACE = 0.12D; + private static final double LEVITATION_STALL_OVER_GRACE = 0.12D; + private static final double LEVITATION_HORIZONTAL_OVER_GRACE = 0.08D; + private static final double GLIDING_VERTICAL_PRECISION_GRACE = 0.005D; + private static final double GLIDING_HORIZONTAL_PRECISION_GRACE = 0.06D; + private static final double GLIDING_VELOCITY_VERTICAL_OVER_GRACE = 0.70D; + private static final double GLIDING_VELOCITY_HORIZONTAL_OVER_GRACE = 0.04D; + private static final double GLIDING_VELOCITY_MIN_HORIZONTAL_MOVE = 0.75D; + private static final double GLIDING_VELOCITY_MAX_VERTICAL_MOVE = 0.75D; + private static final double GLIDING_STALL_VERTICAL_OVER_GRACE = 0.18D; + private static final double GLIDING_STALL_HORIZONTAL_OVER_GRACE = 0.04D; + private static final double GLIDING_STALL_VERTICAL_MOVE_GRACE = 0.18D; + private static final double GLIDING_STALL_HORIZONTAL_MOVE_GRACE = 0.20D; + private static final double GLIDING_CURRENT_VELOCITY_VERTICAL_OVER_GRACE = 1.00D; + private static final double GLIDING_CURRENT_VELOCITY_VERTICAL_MATCH_GRACE = 0.20D; + private static final double GLIDING_CURRENT_VELOCITY_BETTER_MODEL_GRACE = 0.08D; + private static final double GLIDING_CURRENT_VELOCITY_VERTICAL_MODEL_DIFF_GRACE = 0.55D; + private static final double GLIDING_CURRENT_VELOCITY_HORIZONTAL_MODEL_DIFF_GRACE = 0.55D; + private static final double GLIDING_CURRENT_VELOCITY_HORIZONTAL_AMOUNT_GRACE = 0.35D; + private static final double GLIDING_CURRENT_VELOCITY_HORIZONTAL_PERPENDICULAR_GRACE = 0.12D; + private static final double GLIDING_CURRENT_VELOCITY_TURN_YAW_EXTRA = 6.0D; + private static final double GLIDING_CURRENT_VELOCITY_HORIZONTAL_MOVE_GRACE = 0.45D; + private static final double GLIDING_NO_FIREWORK_ASCENT_RESIDUAL = 0.06D; + private static final double GLIDING_NO_FIREWORK_ASCENT_SPEED_START = 0.45D; + private static final double GLIDING_NO_FIREWORK_ASCENT_SPEED_FACTOR = 0.18D; + private static final double GLIDING_NO_FIREWORK_ASCENT_SPEED_CAP = 0.42D; + private static final double GLIDING_NO_FIREWORK_ASCENT_DIVE_FACTOR = 0.90D; + private static final double GLIDING_NO_FIREWORK_ASCENT_TRADE_FACTOR = 0.55D; + private static final double GLIDING_NO_FIREWORK_CURRENT_VELOCITY_MIN_HORIZONTAL = 0.45D; + private static final double GLIDING_NO_FIREWORK_DESCENT_CREDIT_FACTOR = 0.70D; + private static final double GLIDING_NO_FIREWORK_DESCENT_CREDIT_CAP = 8.0D; + private static final double GLIDING_NO_FIREWORK_DESCENT_CREDIT_DECAY = 0.006D; + private static final double GLIDING_NO_FIREWORK_DESCENT_CREDIT_MIN_H = 0.45D; + private static final double GLIDING_NO_FIREWORK_DESCENT_CREDIT_FULL_H = 1.40D; + private static final double GLIDING_NO_FIREWORK_DESCENT_CREDIT_TICK_CAP = 0.34D; + private static final double GLIDING_NO_FIREWORK_SETBACK_MIN_DROP = 1.25D; + private static final double GLIDING_NO_FIREWORK_SETBACK_MAX_DROP = 5.0D; + private static final double GLIDING_NO_FIREWORK_SETBACK_DEFICIT_FACTOR = 2.0D; + private static final double GLIDING_NO_FIREWORK_SETBACK_DEBT_FACTOR = 0.20D; + private static final double GLIDING_NO_FIREWORK_SETBACK_EXCESS_FACTOR = 4.0D; + private static final double GLIDING_NO_FIREWORK_START_SETBACK_MIN_GAIN = 0.40D; + private static final double GLIDING_NO_FIREWORK_START_SETBACK_MAX_HORIZONTAL = 80.0D; + private static final double GLIDING_NO_FIREWORK_DOWNWARD_VELOCITY_Y = -0.075D; + private static final double GLIDING_NO_FIREWORK_DOWNWARD_VELOCITY_MAX_DEBT = 0.20D; + private static final double GLIDING_NO_FIREWORK_ASCENT_DEBT_LIMIT = 0.32D; + private static final int GLIDING_NO_FIREWORK_ASCENT_TICK_LIMIT = 5; + private static final double GLIDING_NO_FIREWORK_DESCENT_DROP_DEFICIT = 0.52D; + private static final double GLIDING_NO_FIREWORK_FLAT_DROP_DEFICIT = 0.25D; + private static final double GLIDING_NO_FIREWORK_FLAT_MAX_ACTUAL_DROP = 0.02D; + private static final int GLIDING_NO_FIREWORK_DESCENT_BUDGET_MIN_TICKS = 45; + private static final double GLIDING_STEEP_DIVE_ENERGY_EPSILON = 0.04D; + private static final double GLIDING_VERTICAL_BELOW_MODEL_GRACE = 1.10D; + private static final double GLIDING_VERTICAL_BELOW_MODEL_FIREWORK_GRACE = 1.50D; + private static final double GLIDING_VERTICAL_SMALL_MISS_GRACE = 0.25D; + private static final double GLIDING_VERTICAL_SMALL_MISS_MOVE_GRACE = 1.85D; + // Model cleanup: firework residuals are empirical boundaries around vanilla boost vectors, not post-failure grace windows. + private static final double GLIDING_FIREWORK_PACKET_ORDER_HORIZONTAL_RESIDUAL = 0.20D; + private static final double GLIDING_FIREWORK_PACKET_ORDER_VERTICAL_RESIDUAL = 0.24D; + private static final double GLIDING_FIREWORK_SKIPPED_BOOST_HORIZONTAL_RESIDUAL = 0.08D; + private static final double GLIDING_FIREWORK_SKIPPED_BOOST_VERTICAL_RESIDUAL = 0.08D; + private static final double GLIDING_FIREWORK_TURN_YAW_EXTRA = 10.0D; + private static final double GLIDING_FIREWORK_PERPENDICULAR_RESIDUAL = 0.16D; + private static final double GLIDING_FIREWORK_GROUND_PROXIMITY_VERTICAL_RESIDUAL = 0.08D; + private static final double GLIDING_FIREWORK_GRAVITY_VERTICAL_MATCH = 0.015D; + private static final double GLIDING_FIREWORK_VERTICAL_PRECISION = 0.03D; + private static final double GLIDING_FIREWORK_PARTIAL_VERTICAL_RESIDUAL = 0.18D; + /* + * False-positive tuning: wearing an elytra is not the same as actively + * gliding. These handle launch and velocity transition packets before Bukkit + * reports normal gliding state. + */ + private static final double ELYTRA_EQUIPPED_VERTICAL_VELOCITY_MOVE_GRACE = 1.10D; + private static final double ELYTRA_EQUIPPED_VERTICAL_VELOCITY_Y_GRACE = 1.05D; + private static final double ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL = 0.20D; + private static final double ELYTRA_EQUIPPED_VELOCITY_VERTICAL_RESIDUAL = 0.28D; + private static final double ELYTRA_EQUIPPED_VELOCITY_FOLLOWUP_RESIDUAL = 0.30D; + private static final double ELYTRA_EQUIPPED_FIREWORK_HORIZONTAL_RESIDUAL = 0.22D; + private static final double ELYTRA_EQUIPPED_FIREWORK_VERTICAL_RESIDUAL = 0.26D; + private static final double ELYTRA_EQUIPPED_QUEUED_VELOCITY_MOVE_GRACE = 1.80D; + private static final double ELYTRA_EQUIPPED_QUEUED_VELOCITY_Y_GRACE = 1.05D; + private static final double ELYTRA_EQUIPPED_GROUND_HORIZONTAL_OVER_GRACE = 0.25D; + private static final double ELYTRA_EQUIPPED_GROUND_MOVE_GRACE = 0.80D; + private static final double ELYTRA_EQUIPPED_DESCEND_VERTICAL_OVER_GRACE = 0.10D; + private static final double ELYTRA_EQUIPPED_DESCEND_HORIZONTAL_OVER_GRACE = 0.08D; + private static final double ELYTRA_EQUIPPED_DESCEND_MOVE_GRACE = 0.60D; + private static final double ELYTRA_EQUIPPED_STALE_ASCEND_VERTICAL_OVER_GRACE = 0.45D; + private static final double ELYTRA_EQUIPPED_STALE_ASCEND_HORIZONTAL_OVER_GRACE = 0.10D; + private static final double ELYTRA_EQUIPPED_STALE_ASCEND_MOVE_GRACE = 0.65D; + private static final double ELYTRA_EQUIPPED_GROUND_STEP_VERTICAL_GRACE = 0.52D; + private static final double ELYTRA_EQUIPPED_GLIDE_EXIT_VERTICAL_MATCH = 0.02D; + private static final double ELYTRA_LIFTOFF_VERTICAL_OVER_GRACE = 0.095D; + private static final double ELYTRA_LIFTOFF_MAX_ASCEND = 0.44D; + private static final double ELYTRA_LIFTOFF_LAST_Y_GRACE = 0.02D; + private static final double ELYTRA_LIFTOFF_HORIZONTAL_OVER_GRACE = 0.04D; + private static final double ELYTRA_LIFTOFF_HORIZONTAL_MOVE_GRACE = 0.60D; + private static final double ELYTRA_GEOMETRY_STALL_VERTICAL_OVER_GRACE = LiftOffEnvelope.NORMAL.getJumpGain(0.0) + Magic.PREDICTION_EPSILON; + private static final double ELYTRA_GEOMETRY_STALL_MAX_VERTICAL_MOVE = 0.05D; + private static final double ELYTRA_GEOMETRY_STALL_LAST_ASCEND = 0.40D; + private static final double ELYTRA_GEOMETRY_STALL_HORIZONTAL_OVER_GRACE = 0.15D; + private static final double ELYTRA_GEOMETRY_STALL_HORIZONTAL_MOVE_GRACE = 0.30D; + private static final double ELYTRA_LANDING_INERTIA_HORIZONTAL_RESIDUAL = 0.18D; + private static final double ELYTRA_LANDING_INERTIA_PERPENDICULAR_RESIDUAL = 0.16D; + private static final double ELYTRA_LANDING_INERTIA_GENERAL_SUPPORT_CARRY = 0.42D; + private static final double ELYTRA_LANDING_INERTIA_MIN_LAST_HORIZONTAL = 0.75D; + private static final double ELYTRA_LANDING_INERTIA_MAX_VERTICAL_MOVE = 0.65D; + private static final double ELYTRA_LANDING_INERTIA_MAX_HORIZONTAL_MOVE = 1.95D; + private static final double SETBACK_GRAVITY_VERTICAL_GRACE = 0.18D; + private static final double SETBACK_GRAVITY_OVER_GRACE = 0.18D; + private static final double SETBACK_GRAVITY_SETBACK_Y_GRACE = 0.04D; + private static final double CURRENT_SERVER_VELOCITY_VERTICAL_OVER_GRACE = 2.00D; + private static final double CURRENT_SERVER_VELOCITY_VERTICAL_MATCH_GRACE = 0.25D; + private static final double CURRENT_SERVER_VELOCITY_HORIZONTAL_OVER_GRACE = 0.05D; + private static final double WATER_TAG_VERTICAL_OVER_GRACE = 0.16D; + private static final double WATER_TAG_HORIZONTAL_OVER_GRACE = 0.06D; + private static final double QUEUED_VELOCITY_HORIZONTAL_OVER_GRACE = 0.35D; + private static final double QUEUED_VELOCITY_HORIZONTAL_MOVE_GRACE = 0.45D; + private static final double QUEUED_VELOCITY_VERTICAL_MOVE_GRACE = 0.50D; + private static final double QUEUED_VELOCITY_HORIZONTAL_RESIDUAL = 0.05D; + private static final double GROUND_JUMP_TINY_HORIZONTAL_OVER_GRACE = 0.03D; + private static final double GROUND_JUMP_TINY_HORIZONTAL_MOVE_GRACE = 0.50D; + private static final double MODERN_VERTICAL_IMPULSE_Y_GRACE = 1.45D; + private static final double MODERN_VERTICAL_IMPULSE_MOVE_GRACE = 1.25D; + private static final double MODERN_VERTICAL_IMPULSE_MIN_SERVER_VELOCITY = 0.15D; + private static final double MODERN_VERTICAL_IMPULSE_HORIZONTAL_VELOCITY_GRACE = 0.45D; + private static final double MODERN_VERTICAL_IMPULSE_VERTICAL_RESIDUAL = 0.30D; + private static final double ELYTRA_EQUIPPED_NEUTRAL_VERTICAL_OVER_GRACE = 0.14D; + private static final double ELYTRA_EQUIPPED_NEUTRAL_HORIZONTAL_OVER_GRACE = 0.35D; + private static final double ELYTRA_EQUIPPED_NEUTRAL_MOVE_GRACE = 0.45D; + private static final double ELYTRA_EQUIPPED_SMALL_VERTICAL_OVER_GRACE = 0.18D; + private static final double ELYTRA_EQUIPPED_SMALL_VERTICAL_MOVE_GRACE = 0.10D; + private static final double ELYTRA_EQUIPPED_SMALL_VERTICAL_HORIZONTAL_OVER_GRACE = 0.35D; + private static final double ELYTRA_EQUIPPED_SMALL_VERTICAL_HORIZONTAL_MOVE_GRACE = 0.45D; + private static final double ELYTRA_EQUIPPED_LAST_INVALID_ASCEND_VERTICAL_RESIDUAL = 0.08D; + private static final double ELYTRA_EQUIPPED_LAST_INVALID_ASCEND_MAX_MOVE = 0.24D; + private static final double ELYTRA_EQUIPPED_GLIDE_COAST_HORIZONTAL_RESIDUAL = 0.12D; + private static final double ELYTRA_EQUIPPED_GLIDE_COAST_PERPENDICULAR_RESIDUAL = 0.12D; + private static final double ELYTRA_EQUIPPED_GLIDE_COAST_VERTICAL_RESIDUAL = 0.09D; + private static final double ELYTRA_EQUIPPED_GLIDE_COAST_MIN_LAST_HORIZONTAL = 0.30D; + private static final double COLLISION_VERTICAL_CORRECTION_OVER_GRACE = 0.18D; + private static final double COLLISION_VERTICAL_CORRECTION_HORIZONTAL_OVER_GRACE = 0.35D; + private static final double COLLISION_VERTICAL_CORRECTION_MOVE_GRACE = 0.35D; + private static final double COLLISION_VERTICAL_CORRECTION_Y_MOVE_GRACE = 0.20D; + private static final double COLLISION_HORIZONTAL_SLIDE_INPUT_CARRY = 0.08D; + private static final double COLLISION_HORIZONTAL_SLIDE_RESIDUAL = 0.02D; + private static final double COLLISION_HORIZONTAL_SLIDE_MOVE_CAP = 0.40D; + private static final double COLLISION_VERTICAL_TRUNCATION_MAX = 0.12D; + private static final double COLLISION_VERTICAL_TRUNCATION_HORIZONTAL_OVER = 0.08D; + private static final double LAST_INVALID_RESYNC_VERTICAL_MATCH = 0.035D; + private static final double LAST_INVALID_RESYNC_HORIZONTAL_INPUT_CARRY = 0.12D; + private static final double LAST_INVALID_RESYNC_HORIZONTAL_RESIDUAL = 0.08D; + private static final double LAST_INVALID_RESYNC_MAX_HORIZONTAL_MOVE = 0.45D; + private static final double LAST_INVALID_RESYNC_MAX_VERTICAL_MOVE = 0.35D; + private static final double LAST_INVALID_STANDSTILL_HORIZONTAL_MOVE = 0.04D; + private static final double LAST_INVALID_STANDSTILL_VERTICAL_OVER = 0.09D; + private static final double LAST_INVALID_GROUND_INPUT_HORIZONTAL_RESIDUAL = 0.04D; + private static final double LAST_INVALID_GROUND_INPUT_HORIZONTAL_CAP = 0.35D; + private static final double LAST_INVALID_VELOCITY_HANDOFF_VERTICAL_MATCH = 0.04D; + private static final double LAST_INVALID_VELOCITY_HANDOFF_MAX_VERTICAL_MOVE = 0.65D; + private static final double LAST_INVALID_AIR_STALL_VERTICAL_MOVE = 0.01D; + private static final double LAST_INVALID_AIR_STALL_SERVER_Y = 0.10D; + private static final double LAST_INVALID_AIR_STALL_VERTICAL_OVER = 0.09D; + private static final double MODERN_HALF_STEP_HORIZONTAL_INPUT_CARRY = 0.16D; + private static final double MODERN_HALF_STEP_HORIZONTAL_RESIDUAL = 0.035D; + private static final double MODERN_HALF_STEP_HORIZONTAL_CAP = 0.55D; + private static final double MODERN_HALF_STEP_PLATEAU_EPSILON = 0.015D; + private static final double LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_INPUT_CARRY = 0.30D; + private static final double LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_RESIDUAL = 0.04D; + private static final double LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_CAP = 0.38D; + private static final double LAST_INVALID_JUMP_CONTINUATION_VERTICAL_EPSILON = 0.015D; + private static final double LAST_INVALID_FIRST_JUMP_VERTICAL_EPSILON = 0.02D; + private static final double LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_MIN = 0.18D; + private static final double LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_MAX = 0.28D; + private static final double LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_EPSILON = 0.025D; + private static final double ELYTRA_EQUIPPED_VELOCITY_HANDOFF_HORIZONTAL_INPUT_CARRY = 0.30D; + private static final double ELYTRA_EQUIPPED_VELOCITY_HANDOFF_HORIZONTAL_RESIDUAL = 0.04D; + private static final double ELYTRA_EQUIPPED_VELOCITY_HANDOFF_VERTICAL_MATCH = 0.04D; + private static final double AIR_INERTIA_HORIZONTAL_EPSILON = 0.02D; + private static final double AIR_INERTIA_VERTICAL_EPSILON = 0.015D; + private static final double AIR_INERTIA_MAX_HORIZONTAL_MOVE = 0.36D; + private static final double GROUND_VELOCITY_CARRY_HORIZONTAL_RESIDUAL = 0.025D; + private static final double GROUND_VELOCITY_CARRY_MAX_MOVE = 0.55D; + private static final double GROUND_LANDING_CARRY_INPUT_MULTIPLIER = 1.45D; + private static final double GROUND_LANDING_CARRY_HORIZONTAL_RESIDUAL = 0.025D; + private static final double GROUND_LANDING_CARRY_MAX_MOVE = 0.62D; + private static final double GROUND_PASSABLE_PLANT_INPUT_CARRY = 0.45D; + private static final double GROUND_PASSABLE_PLANT_HORIZONTAL_RESIDUAL = 0.025D; + private static final double GROUND_PASSABLE_PLANT_MAX_MOVE = 0.48D; + private static final double JUMP_CARRY_HORIZONTAL_RESIDUAL = 0.04D; + private static final double JUMP_CARRY_MAX_MOVE = 0.55D; + private static final double LOW_JUMP_CARRY_VERTICAL_MIN = 0.18D; + private static final double LOW_JUMP_CARRY_VERTICAL_MAX = 0.28D; + private static final double LOW_JUMP_CARRY_VERTICAL_EPSILON = 0.025D; + private static final double AIR_CURRENT_VELOCITY_HORIZONTAL_RESIDUAL = 0.025D; + private static final double AIR_CURRENT_VELOCITY_VERTICAL_MATCH = 0.004D; + private static final double AIR_CURRENT_VELOCITY_MAX_HORIZONTAL_MOVE = 0.34D; + private static final double QUEUED_VELOCITY_VERTICAL_PACKET_ORDER_OVER = 0.12D; + private static final double QUEUED_VELOCITY_VERTICAL_PACKET_ORDER_MOVE = 0.05D; + private static final double QUEUED_VELOCITY_VERTICAL_INERTIA_RESIDUAL = 0.025D; + private static final double QUEUED_VELOCITY_VERTICAL_INERTIA_MIN_H = 0.75D; + private static final long SERVER_POSITION_JUMP_SURVIVALFLY_GRACE_MS = 2500L; + private static final double SERVER_POSITION_JUMP_HORIZONTAL_OVER_GRACE = 0.55D; + private static final double SERVER_POSITION_JUMP_HORIZONTAL_MOVE_GRACE = 0.65D; + private static final double SERVER_POSITION_JUMP_AIR_HORIZONTAL_RESIDUAL = 0.035D; + private static final double SERVER_POSITION_JUMP_AIR_VERTICAL_RESIDUAL = 0.04D; + private static final double SERVER_POSITION_JUMP_AIR_VERTICAL_MOVE_MODEL = 1.20D; + private static final double PORTAL_TRANSITION_HORIZONTAL_OVER_GRACE = 0.12D; + private static final double PORTAL_TRANSITION_HORIZONTAL_MOVE_GRACE = 0.16D; + private static final double PORTAL_TRANSITION_VERTICAL_OVER_GRACE = 1.10D; + private static final double PORTAL_TRANSITION_VERTICAL_MOVE_GRACE = 1.25D; + private static final double CURRENT_VELOCITY_PERPENDICULAR_GRACE = 0.05D; + private static final double CURRENT_VELOCITY_AMOUNT_GRACE = 0.04D; + + /* + * Documentation note: several constants still end in GRACE because they + * started as false-positive tolerances. In the model paths below, use them + * as bounded residuals inside a selected movement state, not as a separate + * "normal model failed, forgive it anyway" branch. + * + * This commit moves the most common false-positive fixes away from loose + * one-axis exemptions and into named movement model branches. Each branch + * still has tight bounds, but horizontal and vertical decisions now share + * the same state label in logs and diagnostics. + */ + private enum MovementModelBranch { + NONE("none"), + ELYTRA_GLIDING("elytra_gliding"), + ELYTRA_FIREWORK("elytra_firework"), + ELYTRA_EQUIPPED_FIREWORK("elytra_equipped_firework"), + ELYTRA_EQUIPPED_TRANSITION("elytra_equipped_transition"), + PORTAL_TRANSITION("portal_transition"), + SERVER_POSITION_JUMP_RESYNC("server_position_jump_resync"), + WATER("water"), + WATER_DOLPHIN("water_dolphin"), + LAVA("lava"), + CLIMBABLE("climbable"), + PARTIAL_SUPPORT("partial_support"), + BEDROCK_GROUNDED_COMBAT("bedrock_grounded_combat"), + BEDROCK_STEP("bedrock_step"), + BEDROCK_PACKET_PRECISION("bedrock_packet_precision"), + MODERN_CLIENT_GROUND("modern_client_ground"), + MODERN_VERTICAL_IMPULSE("modern_vertical_impulse"), + QUEUED_VELOCITY("queued_velocity"), + GROUNDED_VERTICAL_VELOCITY("grounded_vertical_velocity"), + SERVER_VERTICAL_VELOCITY("server_vertical_velocity"), + COLLISION("collision"), + LAST_INVALID_RESYNC("last_invalid_resync"), + MODERN_HALF_STEP("modern_half_step"), + GROUNDED_RECOVERY("grounded_recovery"), + SETBACK_GRAVITY("setback_gravity"), + GROUND_JUMP_TINY("ground_jump_tiny"), + GROUNDED_ITEM_RESYNC("grounded_item_resync"), + CURRENT_SERVER_VELOCITY("current_server_velocity"), + LEVITATION("levitation"), + ITEM_RESYNC("item_resync"), + AIR_INERTIA("air_inertia"), + GROUND_VELOCITY_CARRY("ground_velocity_carry"), + GROUND_LANDING_CARRY("ground_landing_carry"), + GROUND_PASSABLE_PLANT("ground_passable_plant"), + JUMP_CARRY("jump_carry"), + AIR_CURRENT_VELOCITY("air_current_velocity"); + + private final String tag; + + private MovementModelBranch(final String tag) { + this.tag = tag; + } + } + + /* + * Climbable model: keep separate tags for vines and scaffolding even though + * their current limits match. Logs stay readable if one surface needs + * different Bedrock or modern-client tuning later. + */ + private enum ClimbableSurfaceModel { + VINES("vines", CLIMBABLE_HORIZONTAL_MOVE_GRACE, CLIMBABLE_HORIZONTAL_OVER_GRACE, + CLIMBABLE_ASCEND_GRACE, CLIMBABLE_DESCEND_GRACE, CLIMBABLE_DESCEND_OVER_GRACE, + CLIMBABLE_VERTICAL_PRECISION_GRACE), + SCAFFOLDING("scaffolding", CLIMBABLE_HORIZONTAL_MOVE_GRACE, CLIMBABLE_HORIZONTAL_OVER_GRACE, + CLIMBABLE_ASCEND_GRACE, CLIMBABLE_DESCEND_GRACE, CLIMBABLE_DESCEND_OVER_GRACE, + CLIMBABLE_VERTICAL_PRECISION_GRACE), + GENERIC("climbable", CLIMBABLE_HORIZONTAL_MOVE_GRACE, CLIMBABLE_HORIZONTAL_OVER_GRACE, + CLIMBABLE_ASCEND_GRACE, CLIMBABLE_DESCEND_GRACE, CLIMBABLE_DESCEND_OVER_GRACE, + CLIMBABLE_VERTICAL_PRECISION_GRACE); + + private final String tag; + private final double horizontalMoveCap; + private final double horizontalResidual; + private final double ascendLimit; + private final double descendLimit; + private final double descendOverLimit; + private final double verticalPrecision; + + private ClimbableSurfaceModel(final String tag, final double horizontalMoveCap, + final double horizontalResidual, final double ascendLimit, + final double descendLimit, final double descendOverLimit, + final double verticalPrecision) { + this.tag = tag; + this.horizontalMoveCap = horizontalMoveCap; + this.horizontalResidual = horizontalResidual; + this.ascendLimit = ascendLimit; + this.descendLimit = descendLimit; + this.descendOverLimit = descendOverLimit; + this.verticalPrecision = verticalPrecision; + } + } private final ArrayList justUsedWorkarounds = new ArrayList<>(); @@ -195,8 +599,6 @@ public Location check(final Player player, final PlayerLocation from, final Play //data.clearActiveHorVel(); hFreedom = 0.0; } - - ///////////////////////////////////// // Vertical move /// ///////////////////////////////////// @@ -212,6 +614,31 @@ public Location check(final Player player, final PlayerLocation from, final Play yAllowedDistance = res[0]; yDistanceAboveLimit = res[1]; } + // Model pass: select the movement state first, then compare H/Y against that state-derived envelope. + final double[] modelRes = applyExplicitMovementModel(player, pData, data, from, to, thisMove, lastMove, + hDistanceAboveLimit, yDistanceAboveLimit, resetFrom, resetTo); + hDistanceAboveLimit = modelRes[0]; + yDistanceAboveLimit = modelRes[1]; + hAllowedDistance = thisMove.hAllowedDistance; + yAllowedDistance = thisMove.yAllowedDistance; + // Legacy fallback pass: keep after the model pass so diagnostics show which model did or did not cover it. + hDistanceAboveLimit = applyEnvironmentalHorizontalLeniency(player, pData, data, from, to, thisMove, lastMove, hDistanceAboveLimit); + hAllowedDistance = thisMove.hAllowedDistance; + yDistanceAboveLimit = applyEnvironmentalVerticalLeniency(player, pData, data, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit, resetFrom, resetTo); + yAllowedDistance = thisMove.yAllowedDistance; + final double dataOnlyResult = (Math.max(hDistanceAboveLimit, 0.0) + Math.max(yDistanceAboveLimit, 0.0)) * 100D; + if (dataOnlyResult > 0.0 && isElytraModelDataOnly(player, cc)) { + /* + * Elytra model collection is data-only when enforcement is disabled. + * Keep the model passive without dumping every glide miss to console; + * violation details below still include elytra diagnostics when the + * check is actively enforced. + */ + addTag(SurvivalFlyTags.ELYTRA_MODEL_DATA_ONLY); + hDistanceAboveLimit = 0.0D; + yDistanceAboveLimit = 0.0D; + } //////////////////////////// @@ -233,6 +660,17 @@ public Location check(final Player player, final PlayerLocation from, final Play final double result = (Math.max(hDistanceAboveLimit, 0.0) + Math.max(yDistanceAboveLimit, 0.0)) * 100D; if (result > 0.0) { final Location vLoc = handleViolation(result, player, from, to, data, cc); + if (CheckUtils.shouldLogDebugToConsole()) { + try { + logConsoleDetails(result, player, from, to, vLoc, data, pData, thisMove, lastMove, hAllowedDistance, + hDistanceAboveLimit, yAllowedDistance, yDistanceAboveLimit, fromOnGround, resetFrom, + toOnGround, resetTo, tick, now, multiMoveCount, isNormalOrPacketSplitMove); + } + catch (Throwable t) { + player.getServer().getLogger().info("[NCP][SurvivalFly][detail] diagnostic logging failed for player=" + + player.getName() + " reason=" + t.getClass().getSimpleName()); + } + } if (inAir) { data.sfVLInAir = true; } @@ -288,7 +726,7 @@ else if (resetFrom || thisMove.touchedGround) { } // Apply reset conditions. - if (resetTo) { + if (resetTo || isBedrockLanternCollisionMove(player, pData, from, to, thisMove)) { // Reset data. data.setSetBack(to); data.sfJumpPhase = 0; @@ -463,6 +901,11 @@ private double[] processGliding(final PlayerLocation from, final PlayerLocation // No Gliding, no deal return new double[] {thisMove.hDistance, 0.0, thisMove.yDistance, 0.0}; } + addTag(data.fireworksBoostDuration > 0 + ? SurvivalFlyTags.MODE_ELYTRA_FIREWORK : SurvivalFlyTags.MODE_ELYTRA_GLIDING); + if (data.fireworksBoostDuration > 0) { + addTag(SurvivalFlyTags.GLIDE_FIREWORK_ACTIVE); + } // Note: WASD key presses, as well as sneaking and item-use are irrelevant when gliding. // Initialize speed. thisMove.xAllowedDistance = lastMove.toIsValid ? lastMove.xDistance : 0.0; @@ -481,7 +924,7 @@ private double[] processGliding(final PlayerLocation from, final PlayerLocation } // Reset speed if judged to be negligible. checkNegligibleMomentum(pData, thisMove); - checkNegligibleMomentumVertical(pData, thisMove, null); + checkNegligibleMomentumVertical(pData, thisMove); // Yes, players can glide and riptide at the same time, increasing speed at a faster rate than chunks can load... // Surely a questionable decision on Mojang's part. // NOTE: For the elytra, this has to be done before applying gravity and other motion changes. @@ -495,6 +938,7 @@ private double[] processGliding(final PlayerLocation from, final PlayerLocation // TODO: Reduce verbosity (at least, make it easier to look at) Vector viewVector = TrigUtil.getLookingDirection(to, player); float radianPitch = to.getPitch() * TrigUtil.toRadians; + final ClientVersion clientVersion = getMovementClientVersion(pData); // Horizontal length of the look direction double viewVecHorizontalLength = MathUtil.dist(viewVector.getX(), viewVector.getZ()); // Horizontal length of the movement @@ -502,7 +946,7 @@ private double[] processGliding(final PlayerLocation from, final PlayerLocation // Overall length of the look direction. double viewVectorLength = viewVector.length(); // Mojang switched from their own cosine function to the standard Math.cos() one in 1.18.2 - double cosPitch = pData.getClientVersion().isAtMost(ClientVersion.V_1_18_2) ? TrigUtil.cos((double)radianPitch) : Math.cos((double)radianPitch); + double cosPitch = clientVersion.isAtMost(ClientVersion.V_1_18_2) ? TrigUtil.cos((double)radianPitch) : Math.cos((double)radianPitch); cosPitch = cosPitch * cosPitch * Math.min(1.0, viewVectorLength / 0.4); // Base gravity when gliding. thisMove.yAllowedDistance += (cData.wasSlowFalling && lastMove.yDistance <= 0.0 ? Magic.SLOW_FALL_GRAVITY : Magic.DEFAULT_GRAVITY) * (-1.0 + cosPitch * 0.75); @@ -560,6 +1004,7 @@ private double[] processGliding(final PlayerLocation from, final PlayerLocation if (MagicWorkarounds.checkPostPredictWorkaround(data, fromOnGround, toOnGround, from, to, thisMove.yAllowedDistance, player, isNormalOrPacketSplitMove)) { thisMove.yAllowedDistance = thisMove.yDistance; } + final boolean noFireworkAscentEnergyViolation = updateNoFireworkGlidingAscentEnergy(player, data, from, thisMove, lastMove); //////////////////////////// /// Calculate offests // @@ -569,29 +1014,95 @@ private double[] processGliding(final PlayerLocation from, final PlayerLocation if (Math.abs(offsetV) < Magic.PREDICTION_EPSILON) { // Accuracy margin. } + else if (currentGlidingVerticalVelocityMatches(player, thisMove, thisMove.yAllowedDistance, Math.abs(offsetV))) { + thisMove.yAllowedDistance = thisMove.yDistance; + addTag("glide_current_velocity_vertical_model"); + } + else if (currentGlidingVerticalVelocityEnvelopeCovers(player, thisMove, + thisMove.yAllowedDistance, Math.abs(offsetV))) { + /* + * Elytra current velocity model: after firework handoff/reload/packet + * ordering, Bukkit can still expose upward glide velocity while the + * vanilla tick prediction has lost that energy source. Accept only + * packets inside the server velocity envelope. + */ + thisMove.yAllowedDistance = thisMove.yDistance; + addTag("glide_current_velocity_vertical_envelope_model"); + } else { // If velocity can be used for compensation, use it. if (data.getOrUseVerticalVelocity(thisMove.yDistance).isEmpty()) { yDistanceAboveLimit = Math.max(yDistanceAboveLimit, Math.abs(offsetV)); - tags.add("vdistrel"); + addGlidingVerticalPredictionTags(offsetV); + addTag("vdistrel"); } } - + if (noFireworkAscentEnergyViolation) { + /* + * Elytra no-firework energy model: the model above tracks sustained + * climb debt separately from the one-packet vanilla prediction. If + * there is no firework, no queued vertical velocity, no riptide, and + * no descent/speed budget to pay for the climb, this must become an + * active setback immediately. Do not let the generic current-velocity + * vertical branch hide a no-energy ascent; legitimate handoff velocity + * is accepted inside updateNoFireworkGlidingAscentEnergy instead. + */ + yDistanceAboveLimit = Math.max(yDistanceAboveLimit, + Math.max(GLIDING_VERTICAL_PRECISION_GRACE, data.elytraNoFireworkAscentExcess)); + addTag("glide_no_firework_ascent_energy_enforced"); + addTag("vdistrel"); + } + final double noFireworkDescentBudgetOver = getNoFireworkGlidingDescentBudgetOver(player, data, thisMove); + if (noFireworkDescentBudgetOver > 0.0D) { + /* + * Elytra no-firework descent budget model: packet-shaped hover can + * stay under the one-packet ascent limit by repeatedly sending tiny + * positive/flat Y. Once the accumulated glide budget says descent is + * missing and the helper has ruled out firework/current upward velocity, + * surface that as an active SurvivalFly model miss instead of waiting + * only for the legacy hover tick action path. + */ + yDistanceAboveLimit = Math.max(yDistanceAboveLimit, + Math.max(GLIDING_VERTICAL_PRECISION_GRACE, noFireworkDescentBudgetOver)); + addTag("glide_no_firework_descent_budget_enforced"); + addTag("vdistrel"); + } thisMove.hAllowedDistance = MathUtil.dist(thisMove.xAllowedDistance, thisMove.zAllowedDistance); final double offsetH = thisMove.hDistance - thisMove.hAllowedDistance; if (offsetH < Magic.PREDICTION_EPSILON) { // Accuracy margin. } + else if (currentGlidingHorizontalVelocityCovers(player, thisMove, + thisMove.xAllowedDistance, thisMove.zAllowedDistance, + thisMove.xDistance - thisMove.xAllowedDistance, + thisMove.zDistance - thisMove.zAllowedDistance)) { + thisMove.xAllowedDistance = thisMove.xDistance; + thisMove.zAllowedDistance = thisMove.zDistance; + thisMove.hAllowedDistance = thisMove.hDistance; + addTag("glide_current_velocity_horizontal_model"); + } + else if (offsetH <= GLIDING_HORIZONTAL_PRECISION_GRACE) { + thisMove.xAllowedDistance = thisMove.xDistance; + thisMove.zAllowedDistance = thisMove.zDistance; + thisMove.hAllowedDistance = thisMove.hDistance; + addTag("glide_horizontal_precision_model"); + } else { hDistanceAboveLimit = Math.max(hDistanceAboveLimit, offsetH); - tags.add("hdistrel"); + addGlidingHorizontalPredictionTags(offsetH); + addTag("hdistrel"); } + addGlidingLookAndFireworkTags(data, to, thisMove, offsetV, offsetH); if (debug) { player.sendMessage(ChatColor.RED + "[SurvivalFly] vdistrel: predict=" + StringUtil.fdec6.format(thisMove.yAllowedDistance) + ", actual=" + StringUtil.fdec6.format(thisMove.yDistance) + ", offset=" + StringUtil.fdec6.format(offsetV)); player.sendMessage(ChatColor.YELLOW + "[SurvivalFly] hdistrel: predict=" + StringUtil.fdec6.format(thisMove.hAllowedDistance) + ", actual=" + StringUtil.fdec6.format(thisMove.hDistance) + ", offset=" + StringUtil.fdec6.format(offsetH)); } return new double[]{thisMove.hAllowedDistance, hDistanceAboveLimit, thisMove.yAllowedDistance, yDistanceAboveLimit}; } + + private boolean isElytraModelDataOnly(final Player player, final MovingConfig cc) { + return !cc.sfElytraEnforce && Bridge1_9.isGliding(player); + } /** * Reset the given speed upon wall collision. @@ -616,7 +1127,8 @@ private void doWallCollision(PlayerMoveData lastMove, PlayerMoveData thisMove) { * @param thisMove */ private void checkNegligibleMomentum(IPlayerData pData, PlayerMoveData thisMove) { - if (pData.getClientVersion().isAtLeast(ClientVersion.V_1_21_5)) { + final ClientVersion clientVersion = getMovementClientVersion(pData); + if (clientVersion.isAtLeast(ClientVersion.V_1_21_5)) { // This condition was added on 1.21.5. If the horizontal distance squared is below 0.000009 and the entity is a player, both horizontal momenta are set to 0.0. // This means that both x/z momenta can be reset even if one of them is above the threshold, as long as the overall horizontal momentum is below the threshold. // EntityLiving.java -> aiStep @@ -626,7 +1138,7 @@ private void checkNegligibleMomentum(IPlayerData pData, PlayerMoveData thisMove) thisMove.zAllowedDistance = 0.0; } } - else if (pData.getClientVersion().isAtLeast(ClientVersion.V_1_9)) { + else if (clientVersion.isAtLeast(ClientVersion.V_1_9)) { if (Math.abs(thisMove.xAllowedDistance) < Magic.NEGLIGIBLE_SPEED_THRESHOLD) { thisMove.xAllowedDistance = 0.0; } @@ -651,21 +1163,10 @@ else if (pData.getClientVersion().isAtLeast(ClientVersion.V_1_9)) { * * @param pData * @param thisMove - * @param yTheoreticalDistance */ - private void checkNegligibleMomentumVertical(IPlayerData pData, PlayerMoveData thisMove, double[] yTheoreticalDistance) { - final double threshold = pData.getClientVersion().isAtLeast(ClientVersion.V_1_9) ? Magic.NEGLIGIBLE_SPEED_THRESHOLD : Magic.NEGLIGIBLE_SPEED_THRESHOLD_LEGACY; - final boolean direct = yTheoreticalDistance == null; - if (direct) { - if (Math.abs(thisMove.yAllowedDistance) < threshold) { - thisMove.yAllowedDistance = 0.0; - } - } else { - for (int i = 0; i < yTheoreticalDistance.length; i++) { - if (Math.abs(yTheoreticalDistance[i]) < threshold) { - yTheoreticalDistance[i] = 0.0; - } - } + private void checkNegligibleMomentumVertical(IPlayerData pData, PlayerMoveData thisMove) { + if (Math.abs(thisMove.yAllowedDistance) < (getMovementClientVersion(pData).isAtLeast(ClientVersion.V_1_9) ? Magic.NEGLIGIBLE_SPEED_THRESHOLD : Magic.NEGLIGIBLE_SPEED_THRESHOLD_LEGACY)) { + thisMove.yAllowedDistance = 0.0; } } @@ -683,6 +1184,7 @@ private double[] prepareSpeedEstimation(final PlayerLocation from, final PlayerL final boolean fromOnGround, final boolean toOnGround, final boolean debug, final boolean isNormalOrPacketSplitMove, boolean forceSetOnGround, boolean forceSetOffGround) { double hDistanceAboveLimit; + final ClientVersion clientVersion = getMovementClientVersion(pData); ////////////////////////// // Early return(s) // ////////////////////////// @@ -715,9 +1217,9 @@ private double[] prepareSpeedEstimation(final PlayerLocation from, final PlayerL } if (StriderLevel > 0.0) { // (Less speed conservation (or in other words, more friction)) - data.nextInertia += (0.54600006f - data.nextInertia) * StriderLevel / (pData.getClientVersion().isAtMost(ClientVersion.V_1_20_6) ? 3.0f : 1.0f); // Mojang removed this / by 3 in 1.21 and switched to the WATER_MOVEMENT_EFFICIENCY attribute + data.nextInertia += (0.54600006f - data.nextInertia) * StriderLevel / (clientVersion.isAtMost(ClientVersion.V_1_20_6) ? 3.0f : 1.0f); // Mojang removed this / by 3 in 1.21 and switched to the WATER_MOVEMENT_EFFICIENCY attribute // (More per-tick speed gain) - acceleration += (data.walkSpeed - acceleration) * StriderLevel / (pData.getClientVersion().isAtMost(ClientVersion.V_1_20_6) ? 3.0f : 1.0f); + acceleration += (data.walkSpeed - acceleration) * StriderLevel / (clientVersion.isAtMost(ClientVersion.V_1_20_6) ? 3.0f : 1.0f); } if (!Double.isInfinite(Bridge1_13.getDolphinGraceAmplifier(player))) { // (Much more speed conservation (or in other words, much less friction)) @@ -734,8 +1236,8 @@ else if (from.isInLava()) { else { data.nextInertia = onGround ? data.nextFrictionHorizontal * Magic.AIR_HORIZONTAL_INERTIA : Magic.AIR_HORIZONTAL_INERTIA; // 1.12 (and below) clients will use cubed inertia, not cubed friction here. The difference isn't significant except for blocking speed and bunnyhopping on soul sand, which are both slower on 1.8 - float frictionMediumFactor = pData.getClientVersion().isAtLeast(ClientVersion.V_1_13) ? data.nextFrictionHorizontal : data.nextFrictionHorizontal * Magic.AIR_HORIZONTAL_INERTIA; - float acceleration = onGround ? data.walkSpeed * ((pData.getClientVersion().isAtLeast(ClientVersion.V_1_13) ? Magic.DEFAULT_FRICTION_CUBED : Magic.DEFAULT_FRICTION_MULTIPLIED_BY_091_CUBED) / (frictionMediumFactor * frictionMediumFactor * frictionMediumFactor)) : Magic.AIR_ACCELERATION; + float frictionMediumFactor = clientVersion.isAtLeast(ClientVersion.V_1_13) ? data.nextFrictionHorizontal : data.nextFrictionHorizontal * Magic.AIR_HORIZONTAL_INERTIA; + float acceleration = onGround ? data.walkSpeed * ((clientVersion.isAtLeast(ClientVersion.V_1_13) ? Magic.DEFAULT_FRICTION_CUBED : Magic.DEFAULT_FRICTION_MULTIPLIED_BY_091_CUBED) / (frictionMediumFactor * frictionMediumFactor * frictionMediumFactor)) : Magic.AIR_ACCELERATION; if (pData.isSprinting()) { // (We don't use the attribute here due to desync issues, just detect when the player is sprinting and apply the multiplier manually) acceleration += acceleration * 0.3f; // 0.3 is the effective sprinting speed (EntityLiving). @@ -764,193 +1266,5577 @@ else if (from.isInLava()) { } return new double[]{thisMove.hAllowedDistance, hDistanceAboveLimit}; } - - - /** - * Estimates the player's horizontal speed based on the given data and Minecraft's movement logic.
- * Order of operations is essential. Do not shuffle things around unless you know what you're doing. - *
- *

Order of client-movement operations (as per MCP tool): - *

    - *
  • {@code EntityLiving.tick()} - *
  • {@code [Entity].tick()} - *
      - *
    • {@code baseTick()} - *
    • {@code updateInWaterStateAndDoFluidPushing()} - *
    - *
  • {@code EntityLiving.aiStep()} - *
      - *
    • Decrease the jump delay counter if it is active ({@code this.noJumpDelay > 0}) - *
    • Negligible speed reset (0.003) - *
    • Apply liquid motion if the player is pressing the space bar (vertical axis only) - *
    • Multiply the input vector (= the vector containing the player's WASD impulse) by 0.98
    • - *
    • {@code jumpFromGround()} is called if the player is on ground and has pressed the space bar. - *
    - *
  • Begin executing {@code EntityLiving.travel()} ({@code In EntityLiving.aiStep()})

    Note: from 1.21.2 and onwards, Mojang split the travel function into different helper methods to better - * distinguish between media (we now have {@code travelInAir()}, {@code travelInFluid()} and {@code travelFallFlying()})


    - *
      - *
    • Invoke {@code [Entity].moveRelative()} (WASD inputs are transformed to acceleration vectors, call {@code getInputVector()}) - *
    • If not in liquid or gliding, limit motion when on climbable via {@code handleRelativeFrictionAndCalculateMovement()} - *
    • Invoke {@code [Entity].move()} - *
        - *
      • Apply stuck speed multiplier - *
      • Invoke {@code EntityHuman.maybeBackOffFromEdge()} - *
      • Handle wall collisions via {@code Entity.collide()} (speed is cut-off and the collision flag is set)
        - * After {@code [Entity].collide()} is called, the next movement is prepared. Every subsequent operation - * applies to the next move for the client. - *
      • Set the supporting block data; call {@code setGroundWithMovement()}
      • - *
      • Handle horizontal collisions (speed is now reset to 0 on the colliding axis); NCP STARTS ESTIMATING FROM HERE ON (!) - *
      • Invoke {@code checkFallDamage()} (apply fluid pushing if not previously in water) - *
      • Invoke {@code [Block].updateEntityAfterFallOn()} (for slime bouncing) - *
      • Invoke {@code [Block].stepOn()} (for slime blocks only, currently) - *
      • Invoke {@code tryCheckInsideBlocks()} (for honey blocks slide-down and bubble columns) - *
      • Invoke {@code [Entity].getBlockSpeedFactor()} (soul sand, honey blocks) - *
      - *
    - *
  • Complete executing {@code EntityLiving.travel()} - *
      - *
    • {@code handleRelativeFrictionAndCalculateMovement()} (for snow climbing speed) - *
    • Apply gravity. - *
    • Apply friction/inertia. - *
    • Handle fluid falling function if in liquid (vertical axis only) - *
    • Handle jumping out of liquids (vertical axis only) - *
    • Handle entity pushing - *
    - *
  • Complete {@code EntityLiving.aiStep()} - *
  • Complete {@code EntityLiving.tick()} - *
  • Finally, send movement to the server. - *
- *
- * The logic is split into different sections: - *
  • Firstly, we perform some preliminary checks to quickly catch specific ways of cheating.
  • - *
  • If no blatant cheating is detected, the movement speed estimate is calculated starting from the horizontal collision reset, calculating the client’s actions on the next move and then processing the actions performed prior.
  • - *
  • If needed, the player's impulse (acceleration) is brute-forced (see {@link BridgeMisc#isWASDImpulseKnown(Player)}
  • - * @return {@code true}, if the move has been deemed to be predictable. {@code false} otherwise. - */ - private boolean estimateNextSpeed(final Player player, float movementSpeed, final IPlayerData pData, final Collection tags, - final PlayerLocation to, final PlayerLocation from, final boolean debug, - final boolean fromOnGround, final boolean toOnGround, final boolean onGround, boolean forceSetOffGround) { + + private ClientVersion getMovementClientVersion(final IPlayerData pData) { + final ClientVersion clientVersion = pData.getClientVersion(); + return clientVersion == ClientVersion.UNKNOWN ? ClientVersion.getLatest() : clientVersion; + } + + // Movement client models: Bedrock and newer clients can round or packetize movement differently than legacy Java clients. + private boolean isBedrockPacketPrecisionContext(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return isBedrockPlayer(player, pData) + && !Bridge1_9.isGliding(player) + && Math.abs(thisMove.yDistance) <= GROUNDED_JUMP_VERTICAL_MOVE_GRACE + && thisMove.hDistance <= thisMove.hAllowedDistance + BEDROCK_HORIZONTAL_PREDICTION_EPSILON + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsBedrockPacketPrecisionHorizontalModel(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isBedrockPacketPrecisionContext(player, pData, from, to, thisMove)) { + return false; + } + final double horizontalLimit = thisMove.hAllowedDistance + BEDROCK_HORIZONTAL_PREDICTION_EPSILON; + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, BEDROCK_HORIZONTAL_PREDICTION_EPSILON)) { + tags.add("bedrock_packet_precision_horizontal_model"); + return true; + } + tags.add("bedrock_packet_precision_horizontal_model_miss"); + return false; + } + + private boolean acceptsBedrockPacketPrecisionVerticalModel(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isBedrockPacketPrecisionContext(player, pData, from, to, thisMove) + || hDistanceAboveLimit > BEDROCK_HORIZONTAL_PREDICTION_EPSILON) { + return false; + } /* - * TODO: This is a mess, clean-up pending / needed. Get rid of code duplication + * Bedrock model: Floodgate/Geyser packets can expose a tiny Y snap around + * ground/partial-block transitions. This is packet precision, not a fall + * bypass, so keep it under one-centimeter movement and one-centimeter over. */ - final MovingData data = pData.getGenericInstance(MovingData.class); - final CombinedData cData = pData.getGenericInstance(CombinedData.class); - final PlayerMoveData thisMove = data.playerMoves.getCurrentMove(); - final PlayerMoveData lastMove = data.playerMoves.getFirstPastMove(); - - // Reference commit of this piece of code: https://github.com/NoCheatPlus/NoCheatPlus/commit/1c024c072c9f6ebe5371c113916c6a2414e635a6 - ///////////////////////////////////////////////////// - // Horizontal push/pull is put on top priority // - ///////////////////////////////////////////////////// - // With the current implementation, the prediction will run for the axis even if a push/pull is detected on it. - // We'll have to somehow skip predicting that specfic axis, but it requires some refactoring to do it. - // This is not ideal, but it's better than flagging players for being pushed by pistons. - final MovingConfig cc = pData.getGenericInstance(MovingConfig.class); - boolean xPush = false; - boolean zPush = false; - // TODO: Get rid of this config option. Why would someone want to disable piston push detection and cause false positives? - if (cc.trackBlockMove) { - if (from.matchBlockChange(blockChangeTracker, data.blockChangeRef, thisMove.xDistance < 0.0 ? Direction.X_NEG : Direction.X_POS, 0.05)) { - tags.add("blkmv_x"); - xPush = true; - } - if (from.matchBlockChange(blockChangeTracker, data.blockChangeRef, thisMove.zDistance < 0.0 ? Direction.Z_NEG : Direction.Z_POS, 0.05)) { - tags.add("blkmv_z"); - zPush = true; - } - if (xPush && zPush) { - thisMove.xAllowedDistance = thisMove.xDistance; - thisMove.zAllowedDistance = thisMove.zDistance; - // A push/pull happened on both axes, no need to continue the prediction. - return true; - } - } - - //////////////////////////////////////////////////////// - // Test for specific cheat implementation types first // - //////////////////////////////////////////////////////// - // These checks don't need specific data from the prediction, so they can be performed ex-ante and save some performance. - if (cData.isHackingRI) { - tags.add("noslowpacket"); - cData.isHackingRI = false; - Improbable.check(player, (float) thisMove.hDistance, System.currentTimeMillis(), "moving.survivalfly.noslow", pData); - data.resetHorizontalData(); + if (Math.abs(thisMove.yDistance) <= BEDROCK_PACKET_VERTICAL_PRECISION + && yDistanceAboveLimit <= BEDROCK_PACKET_VERTICAL_PRECISION) { + tags.add("bedrock_packet_precision_vertical_model"); return true; } - // If impulses don't need to be inferred from the prediction, illegal sprinting checks can be performed here. - if (BridgeMisc.isWASDImpulseKnown(player) && pData.isSprinting() - && (data.input.getForwardDir() != ForwardDirection.FORWARD && data.input.getStrafeDir() != StrafeDirection.NONE && data.input.getForwardDir() != ForwardDirection.NONE - || player.getFoodLevel() <= 5) // must be checked here as well (besides on toggle sprinting) because players will immediately lose the ability to sprint if food level drops below 5 - ) { - // || inputs[i].getForward() < 0.8 // hasEnoughImpulseToStartSprinting, in LocalPlayer,java -> aiStep() - tags.add("illegalsprint"); - Improbable.check(player, (float) thisMove.hDistance, System.currentTimeMillis(), "moving.survivalfly.illegalsprint", pData); - data.resetHorizontalData(); + tags.add("bedrock_packet_precision_vertical_model_miss"); + return false; + } + + private boolean isNewerClientGroundPredictionContext(final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return pData.getClientVersion() == ClientVersion.HIGHER_THAN_KNOWN_VERSIONS + && thisMove.hDistance > thisMove.hAllowedDistance + Magic.PREDICTION_EPSILON + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid + && (isNewerClientGroundedPredictionState(from, to, thisMove) + || isNewerClientStepPredictionState(from, to, thisMove) + || isNewerClientJumpPredictionState(from, to, thisMove) + || isNewerClientPrecisionPredictionState(thisMove)) + && thisMove.hDistance <= getNewerClientGroundHorizontalModelLimit(from, to, thisMove); + } + + private boolean acceptsNewerClientGroundHorizontalModel(final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isNewerClientGroundPredictionContext(pData, from, to, thisMove)) { + return false; + } + final double horizontalLimit = getNewerClientGroundHorizontalModelLimit(from, to, thisMove); + final double residual = getNewerClientGroundHorizontalResidual(from, to, thisMove); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, horizontalLimit, residual)) { + tags.add(getNewerClientGroundModelTag(from, to, thisMove)); return true; } - - - ////////////////////////////////////////// - // Setup theoretical inputs, if needed // - ////////////////////////////////////////// - PlayerKeyboardInput input = null; // Precise input - PlayerKeyboardInput[] theorInputs = null; // All brute-forced inputs. - /* Index for accessing speed combinations. If you need to perform an operation for/with each speed, set it to 0 and loop until it 8 */ - int i = 0; - if (BridgeMisc.isWASDImpulseKnown(player)) { - // Clone for safety as this data is consumed. - input = data.input.clone(); - // In EntityLiving.java -> aiStep() the game multiplies input values by 0.98 before dispatching them to the travel() function. - input.operationToInt(0.98f, 0.98f, 1); - // From KeyboardInput.java and LocalPlayer.java (MC-Reborn tool) - // Sneaking and item-use aren't directly applied to the player's motion. The game reduces the force of the input instead. - if (pData.isInCrouchingPose()) { - // Note that this is determined by player poses, not shift key presses. - input.operationToInt(attributeAccess.getHandle().getPlayerSneakingFactor(player), attributeAccess.getHandle().getPlayerSneakingFactor(player), 1); - tags.add("crouching"); - } - // From LocalPlayer.java.aiStep() - if (BridgeMisc.isSlowedDownByUsingAnItem(player)) { - input.operationToInt(Magic.USING_ITEM_MULTIPLIER, Magic.USING_ITEM_MULTIPLIER, 1); - tags.add("usingitem"); - } - } - else { - // The input's matrix is: NONE, LEFT, RIGHT, FORWARD, FORWARD_LEFT, FORWARD_RIGHT, BACKWARD, BACKWARD_LEFT, BACKWARD_RIGHT. - theorInputs = new PlayerKeyboardInput[9]; - // Loop through all combinations otherwise. - for (int strafe = -1; strafe <= 1; strafe++) { - for (int forward = -1; forward <= 1; forward++) { - // Multiply all - theorInputs[i] = new PlayerKeyboardInput(strafe * 0.98f, forward * 0.98f); - i++; - } - } - if (pData.isInCrouchingPose()) { - tags.add("crouching"); - for (i = 0; i < 9; i++) { - // Multiply all combinations - theorInputs[i].operationToInt(attributeAccess.getHandle().getPlayerSneakingFactor(player), attributeAccess.getHandle().getPlayerSneakingFactor(player), 1); - } - } - // From LocalPlayer.java.aiStep() - if (BridgeMisc.isSlowedDownByUsingAnItem(player)) { - tags.add("usingitem"); - for (i = 0; i < 9; i++) { - theorInputs[i].operationToInt(Magic.USING_ITEM_MULTIPLIER, Magic.USING_ITEM_MULTIPLIER, 1); - } - } + tags.add("modern_client_ground_horizontal_model_miss"); + return false; + } + + private boolean acceptsModernClientGroundVerticalModel(final IPlayerData pData, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isNewerClientGroundPredictionContext(pData, from, to, thisMove) + || hDistanceAboveLimit > GROUNDED_MICRO_HORIZONTAL_GRACE + || !isGroundishStepMove(from, to, thisMove) + || !to.isOnGroundOrResetCond() + || thisMove.yDistance >= -Magic.PREDICTION_EPSILON) { + return false; + } + final double landingDistance = to.getBlockY() - from.getY(); + final double descend = -thisMove.yDistance; + final double quantized = Math.round(descend / PARTIAL_SUPPORT_VERTICAL_CLAMP_UNIT) + * PARTIAL_SUPPORT_VERTICAL_CLAMP_UNIT; + final boolean quantizedLanding = Math.abs(thisMove.yDistance - landingDistance) + <= PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON + || Math.abs(descend - quantized) <= PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON; + if (quantizedLanding + && descend <= PARTIAL_SUPPORT_VERTICAL_CLAMP_UNIT + PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - thisMove.yDistance) + + PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON) { + tags.add("modern_client_ground_quantized_landing_vertical_model"); + return true; } + tags.add("modern_client_ground_quantized_landing_vertical_model_miss"); + return false; + } + private boolean isNewerClientGroundedPredictionState(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + // Model cleanup: modern clients can miss the ground model by tiny amounts while sprinting/turning. + return isGroundishStepMove(from, to, thisMove) + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && thisMove.hDistance <= GROUNDED_MICRO_MOVE_GRACE; + } - ////////////////////////////////////// - // Next move for the client // - ////////////////////////////////////// - /* - All moves are assumed to be predictable, unless we explicitly state otherwise. - A move is considered to be predictable if there aren't any particular client-side issues/limitations that prevent it. - */ - boolean isPredictable = true; + private boolean isNewerClientStepPredictionState(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + // Model cleanup: stair/slab step packets use the step-height envelope instead of a post-failure H grace. + return isGroundishStepMove(from, to, thisMove) + && isStepBlockNear(from, to) + && thisMove.yDistance >= -Magic.PREDICTION_EPSILON + && thisMove.yDistance <= GROUNDED_STEP_VERTICAL_MOVE_GRACE + && thisMove.hDistance <= GROUNDED_STEP_HORIZONTAL_MOVE_GRACE; + } + + private boolean isNewerClientJumpPredictionState(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + // Model cleanup: jump-start packets keep one tick of sprint carry rather than accepting actual H afterward. + return (tags.contains("jump_env") || tags.contains("bunnyhop")) + && isGroundishStepMove(from, to, thisMove) + && thisMove.yDistance >= -Magic.PREDICTION_EPSILON + && thisMove.yDistance <= GROUNDED_JUMP_VERTICAL_MOVE_GRACE + && thisMove.hDistance <= GROUNDED_JUMP_HORIZONTAL_MOVE_GRACE; + } + + private boolean isNewerClientPrecisionPredictionState(final PlayerMoveData thisMove) { + // Model cleanup: preserve a tiny client-version precision envelope for unknown newer clients. + return thisMove.hDistance <= NEWER_CLIENT_HORIZONTAL_MOVE_GRACE + && Math.abs(thisMove.yDistance) <= GROUNDED_JUMP_VERTICAL_MOVE_GRACE; + } + + private double getNewerClientGroundHorizontalModelLimit(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + if (isNewerClientStepPredictionState(from, to, thisMove)) { + return Math.min(GROUNDED_STEP_HORIZONTAL_MOVE_GRACE, + Math.max(playerStepHorizontalModel(thisMove), + thisMove.hAllowedDistance + GROUNDED_STEP_HORIZONTAL_OVER_GRACE)); + } + if (isNewerClientJumpPredictionState(from, to, thisMove)) { + return Math.min(GROUNDED_JUMP_HORIZONTAL_MOVE_GRACE, + Math.max(playerStepHorizontalModel(thisMove), + thisMove.hAllowedDistance + GROUNDED_JUMP_HORIZONTAL_OVER_GRACE)); + } + if (isNewerClientGroundedPredictionState(from, to, thisMove)) { + return Math.min(GROUNDED_MICRO_MOVE_GRACE, + thisMove.hAllowedDistance + GROUNDED_MICRO_OVER_GRACE); + } + return Math.min(NEWER_CLIENT_HORIZONTAL_MOVE_GRACE, + thisMove.hAllowedDistance + NEWER_CLIENT_HORIZONTAL_OVER_GRACE); + } + + private double getNewerClientGroundHorizontalResidual(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + if (isNewerClientStepPredictionState(from, to, thisMove)) { + return GROUNDED_STEP_HORIZONTAL_OVER_GRACE; + } + if (isNewerClientJumpPredictionState(from, to, thisMove)) { + return GROUNDED_JUMP_HORIZONTAL_OVER_GRACE; + } + if (isNewerClientGroundedPredictionState(from, to, thisMove)) { + return GROUNDED_MICRO_OVER_GRACE; + } + return NEWER_CLIENT_HORIZONTAL_OVER_GRACE; + } + + private String getNewerClientGroundModelTag(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + if (isNewerClientStepPredictionState(from, to, thisMove)) { + return "modern_client_step_horizontal_model"; + } + if (isNewerClientJumpPredictionState(from, to, thisMove)) { + return "modern_client_jump_horizontal_model"; + } + if (isNewerClientGroundedPredictionState(from, to, thisMove)) { + return "modern_client_ground_horizontal_model"; + } + return "modern_client_precision_horizontal_model"; + } + + private boolean isGroundedRecoveryContext(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + return tags.contains("onground_env") + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && (from.isOnGroundOrResetCond() || thisMove.from.onGroundOrResetCond) + && (to.isOnGroundOrResetCond() || thisMove.to.onGroundOrResetCond) + && (!lastMove.toIsValid || thisMove.multiMoveCount > 0 + || thisMove.hDistance <= GROUNDED_SETBACK_MOVE_GRACE) + && thisMove.hDistance <= getGroundedRecoveryHorizontalModelLimit(thisMove, lastMove) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsGroundedRecoveryHorizontalModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isGroundedRecoveryContext(from, to, thisMove, lastMove)) { + return false; + } + final double horizontalLimit = getGroundedRecoveryHorizontalModelLimit(thisMove, lastMove); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, GROUNDED_MICRO_HORIZONTAL_GRACE)) { + tags.add("grounded_recovery_horizontal_model"); + return true; + } + tags.add("grounded_recovery_horizontal_model_miss"); + return false; + } + + private double getGroundedRecoveryHorizontalModelLimit(final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double historyCarry = lastMove.toIsValid ? lastMove.hDistance : thisMove.hAllowedDistance; + // Model cleanup: grounded recovery uses prior movement plus current input, capped by the old empirical recovery window. + return Math.min(GROUNDED_SETBACK_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, historyCarry + playerInputHorizontalCarry(thisMove) + + GROUNDED_MICRO_HORIZONTAL_GRACE)); + } + + // Explicit model dispatch: select the movement state first, then compare against that state's envelope. + private double[] applyExplicitMovementModel(final Player player, final IPlayerData pData, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + double hDistanceAboveLimit, double yDistanceAboveLimit, + final boolean resetFrom, final boolean resetTo) { + if (hDistanceAboveLimit <= 0.0 && yDistanceAboveLimit <= 0.0) { + return new double[]{hDistanceAboveLimit, yDistanceAboveLimit}; + } + final MovementModelBranch branch = selectExplicitMovementModel(player, pData, data, from, to, thisMove, lastMove); + if (branch == MovementModelBranch.NONE) { + return new double[]{hDistanceAboveLimit, yDistanceAboveLimit}; + } + + // Model pass: evaluate the chosen state before the old one-axis fallbacks run. + boolean acceptedH = hDistanceAboveLimit > 0.0 + && acceptsExplicitHorizontalModel(branch, player, pData, data, from, to, thisMove, lastMove, hDistanceAboveLimit); + final double hDistanceForVertical = acceptedH ? 0.0D : hDistanceAboveLimit; + boolean acceptedY = yDistanceAboveLimit > 0.0 + && acceptsExplicitVerticalModel(branch, player, pData, data, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceForVertical, resetFrom, resetTo); + + if (!acceptedH && !acceptedY) { + tags.add("model_" + branch.tag + "_miss"); + return new double[]{hDistanceAboveLimit, yDistanceAboveLimit}; + } + tags.add("model_" + branch.tag); + if (acceptedH) { + applyHorizontalModelAllowance(thisMove, branch); + hDistanceAboveLimit = 0.0; + } + if (acceptedY) { + applyVerticalModelAllowance(thisMove, branch); + yDistanceAboveLimit = 0.0; + } + return new double[]{hDistanceAboveLimit, yDistanceAboveLimit}; + } + + private MovementModelBranch selectExplicitMovementModel(final Player player, final IPlayerData pData, + final MovingData data, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + // Order matters: choose the most concrete state first so logs point at the real model, not a broader fallback. + if (Bridge1_9.isGliding(player)) { + return data.fireworksBoostDuration > 0 + ? MovementModelBranch.ELYTRA_FIREWORK : MovementModelBranch.ELYTRA_GLIDING; + } + if (isPortalNear(from, to) && (!lastMove.toIsValid || data.liftOffEnvelope == LiftOffEnvelope.UNKNOWN)) { + return MovementModelBranch.PORTAL_TRANSITION; + } + if (isServerPositionJumpResyncContext(pData, from, to, thisMove, lastMove)) { + return MovementModelBranch.SERVER_POSITION_JUMP_RESYNC; + } + if (isWaterMovementContext(from, to, thisMove)) { + return !Double.isInfinite(Bridge1_13.getDolphinGraceAmplifier(player)) + ? MovementModelBranch.WATER_DOLPHIN : MovementModelBranch.WATER; + } + if (isLavaMovementContext(from, to, thisMove)) { + return MovementModelBranch.LAVA; + } + if (isClimbableMovementContext(from, to, thisMove)) { + return MovementModelBranch.CLIMBABLE; + } + if (Bridge1_9.isWearingElytra(player) && data.fireworksBoostDuration > 0) { + return MovementModelBranch.ELYTRA_EQUIPPED_FIREWORK; + } + if (isBedrockStepContext(player, pData, from, to, thisMove, lastMove)) { + return MovementModelBranch.BEDROCK_STEP; + } + if (isElytraEquippedTransitionContext(player, data, from, to, thisMove, lastMove)) { + return MovementModelBranch.ELYTRA_EQUIPPED_TRANSITION; + } + if (isPartialSupportMovementContext(from, to, thisMove, lastMove)) { + return MovementModelBranch.PARTIAL_SUPPORT; + } + if (isLastInvalidResyncContext(player, from, to, thisMove, lastMove)) { + return MovementModelBranch.LAST_INVALID_RESYNC; + } + if (isModernHalfStepContext(player, pData, from, to, thisMove, lastMove)) { + return MovementModelBranch.MODERN_HALF_STEP; + } + if (isNewerClientGroundPredictionContext(pData, from, to, thisMove)) { + return MovementModelBranch.MODERN_CLIENT_GROUND; + } + if (isBedrockGroundedCombatContext(player, pData, from, to, thisMove)) { + return MovementModelBranch.BEDROCK_GROUNDED_COMBAT; + } + if (isBedrockPacketPrecisionContext(player, pData, from, to, thisMove)) { + return MovementModelBranch.BEDROCK_PACKET_PRECISION; + } + if (isJumpCarryContext(player, from, to, thisMove, lastMove)) { + return MovementModelBranch.JUMP_CARRY; + } + if (isGroundPassablePlantContext(player, from, to, thisMove, lastMove)) { + return MovementModelBranch.GROUND_PASSABLE_PLANT; + } + if (isGroundedRecoveryContext(from, to, thisMove, lastMove)) { + return MovementModelBranch.GROUNDED_RECOVERY; + } + if (isQueuedVelocityContext(data, thisMove)) { + return MovementModelBranch.QUEUED_VELOCITY; + } + if (isGroundJumpTinyContext(thisMove)) { + return MovementModelBranch.GROUND_JUMP_TINY; + } + if (isGroundLandingCarryContext(player, from, to, thisMove, lastMove)) { + return MovementModelBranch.GROUND_LANDING_CARRY; + } + if (isGroundVelocityCarryContext(player, from, to, thisMove)) { + return MovementModelBranch.GROUND_VELOCITY_CARRY; + } + if (isGroundedVerticalVelocityContext(player, from, to, thisMove)) { + return MovementModelBranch.GROUNDED_VERTICAL_VELOCITY; + } + if (isServerVerticalVelocityContext(player, data, from, to, thisMove)) { + return MovementModelBranch.SERVER_VERTICAL_VELOCITY; + } + if (isGroundedItemResyncContext(thisMove)) { + return MovementModelBranch.GROUNDED_ITEM_RESYNC; + } + if (isItemResyncMovementContext(player, from, to, thisMove)) { + return MovementModelBranch.ITEM_RESYNC; + } + if (isAirCurrentVelocityContext(player, from, to, thisMove)) { + return MovementModelBranch.AIR_CURRENT_VELOCITY; + } + if (isAirInertiaMovementContext(player, from, to, thisMove, lastMove)) { + return MovementModelBranch.AIR_INERTIA; + } + if (isCollisionMovementContext(player, from, to, thisMove, lastMove)) { + return MovementModelBranch.COLLISION; + } + if (isModernVerticalImpulseContext(player, pData, from, to, thisMove)) { + return MovementModelBranch.MODERN_VERTICAL_IMPULSE; + } + if (isCurrentServerVelocityVerticalContext(player, from, to, thisMove)) { + return MovementModelBranch.CURRENT_SERVER_VELOCITY; + } + if (isSetbackGravityRecoveryContext(data, from, to, thisMove, lastMove)) { + return MovementModelBranch.SETBACK_GRAVITY; + } + if (isLevitationMovementContext(player, thisMove)) { + return MovementModelBranch.LEVITATION; + } + return MovementModelBranch.NONE; + } + + private boolean acceptsExplicitHorizontalModel(final MovementModelBranch branch, final Player player, + final IPlayerData pData, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + switch (branch) { + case ELYTRA_GLIDING: + return acceptsGlidingHorizontalVelocityModel(player, data, from, to, thisMove, hDistanceAboveLimit) + || acceptsElytraLandingInertiaHorizontalModel(player, data, from, to, thisMove, lastMove, + hDistanceAboveLimit, true, false, "elytra_glide_landing_inertia_horizontal_model") + || acceptsGlidingSteepDiveEnergyModel(player, thisMove, hDistanceAboveLimit); + case ELYTRA_FIREWORK: + return acceptsGlidingHorizontalVelocityModel(player, data, from, to, thisMove, hDistanceAboveLimit) + || acceptsElytraLandingInertiaHorizontalModel(player, data, from, to, thisMove, lastMove, + hDistanceAboveLimit, true, true, "elytra_firework_landing_inertia_horizontal_model") + || acceptsGlidingFireworkHorizontalModel(player, pData, data, from, to, thisMove, lastMove, + hDistanceAboveLimit); + case ELYTRA_EQUIPPED_FIREWORK: + return acceptsElytraLandingInertiaHorizontalModel(player, data, from, to, thisMove, lastMove, + hDistanceAboveLimit, false, true, "elytra_equipped_firework_landing_inertia_horizontal_model") + || acceptsElytraEquippedFireworkHorizontalModel(player, data, from, to, thisMove, lastMove, + hDistanceAboveLimit); + case ELYTRA_EQUIPPED_TRANSITION: + return acceptsElytraEquippedGlideCoastHorizontalModel(player, pData, data, from, to, + thisMove, lastMove, hDistanceAboveLimit) + || acceptsElytraEquippedQueuedVelocityHorizontalModel(player, data, from, to, thisMove, hDistanceAboveLimit) + || acceptsElytraEquippedVelocityHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit) + || acceptsElytraEquippedGroundHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit) + || acceptsElytraEquippedVelocityHandoffHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit) + || acceptsElytraEquippedPartialSupportHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit) + || acceptsJumpCarryHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit) + || acceptsElytraLandingInertiaHorizontalModel(player, data, from, to, thisMove, lastMove, + hDistanceAboveLimit, false, false, "elytra_equipped_landing_inertia_horizontal_model") + || acceptsElytraEquippedDescendHorizontalModel(player, thisMove, hDistanceAboveLimit) + || acceptsElytraGeometryStallHorizontalModel(player, thisMove, lastMove, hDistanceAboveLimit); + case PORTAL_TRANSITION: + return isPortalTransitionHorizontalGrace(from, to, data, thisMove, lastMove, hDistanceAboveLimit); + case SERVER_POSITION_JUMP_RESYNC: + return acceptsServerPositionJumpResyncHorizontalModel(pData, from, to, thisMove, lastMove, + hDistanceAboveLimit); + case WATER_DOLPHIN: + return acceptsWaterHorizontalModel(player, data, from, to, thisMove, hDistanceAboveLimit, true); + case WATER: + return acceptsWaterHorizontalModel(player, data, from, to, thisMove, hDistanceAboveLimit, false); + case LAVA: + return acceptsLavaHorizontalModel(player, data, from, to, thisMove, hDistanceAboveLimit); + case CLIMBABLE: + return acceptsClimbableHorizontalModel(from, to, thisMove, lastMove, hDistanceAboveLimit); + case BEDROCK_STEP: + return acceptsBedrockStepHorizontalModel(player, pData, from, to, thisMove, lastMove, hDistanceAboveLimit); + case BEDROCK_PACKET_PRECISION: + return acceptsBedrockPacketPrecisionHorizontalModel(player, pData, from, to, thisMove, hDistanceAboveLimit); + case BEDROCK_GROUNDED_COMBAT: + return acceptsBedrockGroundedCombatHorizontalModel(player, pData, from, to, thisMove, hDistanceAboveLimit); + case MODERN_HALF_STEP: + return acceptsModernHalfStepHorizontalModel(player, pData, from, to, thisMove, lastMove, hDistanceAboveLimit); + case MODERN_CLIENT_GROUND: + return acceptsNewerClientGroundHorizontalModel(pData, from, to, thisMove, hDistanceAboveLimit); + case JUMP_CARRY: + return acceptsJumpCarryHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit); + case GROUND_JUMP_TINY: + return acceptsGroundJumpTinyHorizontalModel(thisMove, hDistanceAboveLimit); + case GROUND_LANDING_CARRY: + return acceptsGroundLandingCarryHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit); + case GROUND_VELOCITY_CARRY: + return acceptsGroundVelocityCarryHorizontalModel(player, from, to, thisMove, hDistanceAboveLimit); + case GROUND_PASSABLE_PLANT: + return acceptsGroundPassablePlantHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit); + case GROUNDED_RECOVERY: + return acceptsGroundedRecoveryHorizontalModel(from, to, thisMove, lastMove, hDistanceAboveLimit); + case GROUNDED_VERTICAL_VELOCITY: + return acceptsGroundedVerticalVelocityHorizontalModel(player, from, to, thisMove, hDistanceAboveLimit); + case SERVER_VERTICAL_VELOCITY: + return acceptsServerVerticalVelocityHorizontalModel(player, data, from, to, thisMove, hDistanceAboveLimit); + case GROUNDED_ITEM_RESYNC: + return acceptsGroundedItemResyncHorizontalModel(thisMove, hDistanceAboveLimit); + case ITEM_RESYNC: + return acceptsItemResyncHorizontalModel(player, from, to, thisMove, hDistanceAboveLimit); + case PARTIAL_SUPPORT: + return acceptsPartialSupportHorizontalModel(from, to, thisMove, lastMove, hDistanceAboveLimit); + case AIR_CURRENT_VELOCITY: + return acceptsAirCurrentVelocityHorizontalModel(player, from, to, thisMove, hDistanceAboveLimit); + case AIR_INERTIA: + return acceptsAirInertiaHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit); + case MODERN_VERTICAL_IMPULSE: + return isModernVerticalImpulseHorizontalGrace(player, pData, from, to, thisMove, hDistanceAboveLimit); + case QUEUED_VELOCITY: + return isQueuedVelocityHorizontalGrace(player, data, from, to, thisMove, hDistanceAboveLimit); + case CURRENT_SERVER_VELOCITY: + return acceptsCurrentServerVelocityHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit); + case LEVITATION: + case SETBACK_GRAVITY: + return false; + case COLLISION: + return acceptsCollisionHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit); + case LAST_INVALID_RESYNC: + return acceptsLastInvalidResyncHorizontalModel(player, from, to, thisMove, lastMove, hDistanceAboveLimit); + default: + return false; + } + } + + private boolean acceptsExplicitVerticalModel(final MovementModelBranch branch, final Player player, + final IPlayerData pData, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, final double hDistanceAboveLimit, + final boolean resetFrom, final boolean resetTo) { + switch (branch) { + case ELYTRA_GLIDING: + return acceptsGlidingBelowVerticalModel(player, data, thisMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsGlidingSmallVerticalPredictionModel(player, thisMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsGlidingVerticalPrecisionModel(player, yDistanceAboveLimit) + || acceptsGlidingStallVerticalModel(player, data, thisMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsGlidingVelocityVerticalModel(player, thisMove, yDistanceAboveLimit, hDistanceAboveLimit); + case ELYTRA_FIREWORK: + return acceptsGlidingFireworkVerticalModel(player, pData, data, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case ELYTRA_EQUIPPED_FIREWORK: + return acceptsElytraEquippedFireworkVerticalModel(player, data, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedPartialSupportVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case ELYTRA_EQUIPPED_TRANSITION: + return isElytraEquippedHalfStepVerticalModel(player, from, to, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedGlideCoastVerticalModel(player, pData, data, from, to, + thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedPartialSupportVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedVelocityVerticalModel(player, from, to, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedLastInvalidAscendVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedStaleAscendVerticalModel(player, from, to, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedVelocityHandoffVerticalModel(player, from, to, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedGlideExitVerticalModel(player, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedDescendVerticalModel(player, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedNeutralVerticalModel(player, thisMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraEquippedSmallVerticalResyncModel(player, from, to, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraLiftOffVerticalModel(player, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsElytraGeometryStallVerticalModel(player, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit); + case PORTAL_TRANSITION: + return isPortalTransitionVerticalGrace(from, to, data, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit); + case SERVER_POSITION_JUMP_RESYNC: + return acceptsServerPositionJumpResyncVerticalModel(pData, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case WATER_DOLPHIN: + case WATER: + return isWaterVerticalGrace(player, from, to, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit, resetFrom, resetTo) + || isWaterTagVerticalGrace(yDistanceAboveLimit, hDistanceAboveLimit); + case LAVA: + return isLavaVerticalGrace(player, from, to, thisMove, yDistanceAboveLimit, hDistanceAboveLimit); + case CLIMBABLE: + return acceptsClimbableVerticalModel(from, to, thisMove, yDistanceAboveLimit, hDistanceAboveLimit); + case BEDROCK_STEP: + return acceptsBedrockStepVerticalModel(player, pData, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case BEDROCK_PACKET_PRECISION: + return acceptsBedrockPacketPrecisionVerticalModel(player, pData, from, to, thisMove, + yDistanceAboveLimit, hDistanceAboveLimit) + || acceptsModernClientGroundVerticalModel(pData, from, to, thisMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case MODERN_CLIENT_GROUND: + return acceptsModernClientGroundVerticalModel(pData, from, to, thisMove, yDistanceAboveLimit, + hDistanceAboveLimit); + case GROUNDED_RECOVERY: + return false; + case BEDROCK_GROUNDED_COMBAT: + return acceptsBedrockGroundedCombatVerticalModel(player, pData, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case MODERN_HALF_STEP: + return acceptsModernHalfStepVerticalModel(player, pData, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case JUMP_CARRY: + return acceptsJumpCarryVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case GROUND_JUMP_TINY: + case GROUNDED_ITEM_RESYNC: + case SERVER_VERTICAL_VELOCITY: + return false; + case GROUNDED_VERTICAL_VELOCITY: + return acceptsGroundedVerticalVelocityVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case PARTIAL_SUPPORT: + return acceptsPartialSupportVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case AIR_INERTIA: + return acceptsAirInertiaVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case MODERN_VERTICAL_IMPULSE: + return isModernVerticalImpulseVerticalGrace(player, pData, from, to, thisMove, yDistanceAboveLimit, hDistanceAboveLimit); + case CURRENT_SERVER_VELOCITY: + return acceptsCurrentServerVelocityVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case SETBACK_GRAVITY: + return acceptsSetbackGravityVerticalModel(data, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case LEVITATION: + return acceptsLevitationVerticalModel(player, thisMove, yDistanceAboveLimit, hDistanceAboveLimit); + case QUEUED_VELOCITY: + return isCurrentServerVelocityVerticalGrace(player, thisMove, yDistanceAboveLimit, hDistanceAboveLimit) + || isQueuedVelocityVerticalInertiaHandoffModel(player, data, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit) + || isQueuedVelocityVerticalPacketOrderModel(player, data, from, to, thisMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case COLLISION: + return acceptsCollisionVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + case LAST_INVALID_RESYNC: + return acceptsLastInvalidResyncVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit); + default: + return false; + } + } + + private void applyHorizontalModelAllowance(final PlayerMoveData thisMove, final MovementModelBranch branch) { + // The allowed vector is only aligned to the actual move after a model has already accepted this envelope. + thisMove.xAllowedDistance = thisMove.xDistance; + thisMove.zAllowedDistance = thisMove.zDistance; + thisMove.hAllowedDistance = thisMove.hDistance; + tags.add("model_" + branch.tag + "_h"); + } + + private void applyVerticalModelAllowance(final PlayerMoveData thisMove, final MovementModelBranch branch) { + // The allowed Y is only aligned to the actual move after a model has already accepted this envelope. + thisMove.yAllowedDistance = thisMove.yDistance; + tags.add("model_" + branch.tag + "_y"); + } + + private boolean isWaterMovementContext(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return from.isInWater() || to.isInWater() + || thisMove.from.inWater || thisMove.to.inWater || tags.contains("v_water"); + } + + private boolean isLavaMovementContext(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return from.isInLava() || to.isInLava() + || thisMove.from.inLava || thisMove.to.inLava || tags.contains("v_lava"); + } + + private boolean isClimbableMovementContext(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return from.isOnClimbable() || to.isOnClimbable() + || thisMove.from.onClimbable || thisMove.to.onClimbable || tags.contains("v_climbable"); + } + + private boolean isServerPositionJumpResyncContext(final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final NetData netData = pData.getGenericInstance(NetData.class); + final long age = netData.getServerPositionJumpGraceAge(System.currentTimeMillis()); + // Teleport model: async/server-position jumps can leave one or two air packets attached to old movement history. + final double horizontalLimit = getServerPositionJumpResyncHorizontalModelLimit(thisMove, lastMove); + return age >= 0L + && age <= SERVER_POSITION_JUMP_SURVIVALFLY_GRACE_MS + && thisMove.hDistance <= horizontalLimit + && Math.abs(thisMove.yDistance) <= SERVER_POSITION_JUMP_AIR_VERTICAL_MOVE_MODEL + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isBedrockGroundedCombatContext(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return isBedrockPlayer(player, pData) + && !Bridge1_9.isGliding(player) + && thisMove.hDistance <= BEDROCK_GROUNDED_COMBAT_MOVE_GRACE + && Math.abs(thisMove.yDistance) <= BEDROCK_GROUNDED_COMBAT_VERTICAL_MOVE_GRACE + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid + && isGroundedCombatMove(player, from, to, thisMove); + } + + private boolean isBedrockStepContext(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + final double horizontalLimit = getBedrockStepHorizontalModelLimit(from, to, thisMove, lastMove); + return isBedrockPlayer(player, pData) + && !Bridge1_9.isGliding(player) + && isGroundishStepMove(from, to, thisMove) + && isStepBlockNear(from, to) + && thisMove.hDistance <= horizontalLimit + && (Math.abs(thisMove.yDistance - BEDROCK_HALF_STEP_VERTICAL_MOVE) <= BEDROCK_HALF_STEP_VERTICAL_EPSILON + || Math.abs(thisMove.yDistance) <= BEDROCK_STEP_VERTICAL_UNDERSHOOT_MOVE_GRACE) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isGroundedVerticalVelocityContext(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + if (Bridge1_9.isGliding(player) + || thisMove.hDistance > GROUNDED_VERTICAL_VELOCITY_MOVE_GRACE + || Math.abs(thisMove.yDistance) > GROUNDED_VERTICAL_VELOCITY_MOVE_Y_GRACE + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final boolean groundish = isGroundishStepMove(from, to, thisMove); + final double velocityY = player.getVelocity().getY(); + return groundish + && velocityY > 0.30D && velocityY <= GROUNDED_VERTICAL_VELOCITY_MOVE_Y_GRACE + && (thisMove.collidesHorizontally || thisMove.collideY || tags.contains("v_air")); + } + + private boolean isServerVerticalVelocityContext(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return !Bridge1_9.isGliding(player) + && !thisMove.verVelUsed.isEmpty() + && data.getHorizontalVelocityTracker().hasQueued() + && thisMove.yDistance > 0.0D + && thisMove.yDistance <= SERVER_VERTICAL_VELOCITY_ASCEND_GRACE + && thisMove.hDistance <= SERVER_VERTICAL_VELOCITY_HORIZONTAL_MOVE_GRACE + && isGroundishStepMove(from, to, thisMove) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isGroundJumpTinyContext(final PlayerMoveData thisMove) { + return (tags.contains("jump_env") || tags.contains("bunnyhop")) + && thisMove.yDistance > 0.0D + && thisMove.yDistance <= GROUNDED_JUMP_VERTICAL_MOVE_GRACE + && thisMove.hDistance <= GROUND_JUMP_TINY_HORIZONTAL_MOVE_GRACE; + } + + private boolean isGroundedItemResyncContext(final PlayerMoveData thisMove) { + return tags.contains("itemresync") + && tags.contains("usingitem") + && tags.contains("onground_env") + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && thisMove.hDistance <= GROUNDED_ITEM_RESYNC_MOVE_GRACE; + } + + private boolean isCurrentServerVelocityVerticalContext(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return !Bridge1_9.isGliding(player) + && Math.abs(thisMove.yDistance) > Magic.PREDICTION_EPSILON + && Math.abs(thisMove.yDistance - player.getVelocity().getY()) <= CURRENT_SERVER_VELOCITY_VERTICAL_MATCH_GRACE + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isLevitationMovementContext(final Player player, final PlayerMoveData thisMove) { + return !Double.isInfinite(Bridge1_9.getLevitationAmplifier(player)) + && Math.abs(thisMove.yDistance) <= LEVITATION_STALL_VERTICAL_GRACE; + } + + private boolean isElytraEquippedTransitionContext(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + return Bridge1_9.isWearingElytra(player) + && !Bridge1_9.isGliding(player) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid + && (isQueuedVelocityContext(data, thisMove) + || player.getVelocity().getY() > 0.10D + || lastMove.toIsValid && lastMove.yDistance > 0.25D + || isGroundishStepMove(from, to, thisMove)); + } + + private boolean isModernVerticalImpulseContext(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return isModernMovementClient(pData) + && !Bridge1_9.isGliding(player) + && thisMove.yDistance > Magic.PREDICTION_EPSILON + && hasModernVerticalImpulseSource(player, thisMove) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isQueuedVelocityContext(final MovingData data, final PlayerMoveData thisMove) { + return data.getHorizontalVelocityTracker().hasQueued() + || !thisMove.verVelUsed.isEmpty() + || tags.contains("hvel_current") || tags.contains("hvel"); + } + + private boolean isCollisionMovementContext(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return hasCollisionSignal(thisMove) + && !Bridge1_9.isGliding(player) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid + && (isCollisionHorizontalSlideCandidate(thisMove) + || isCollisionLandingHorizontalCandidate(thisMove, lastMove) + || isCollisionQuantizedStepHorizontalCandidate(thisMove) + || isCollisionVerticalTruncationCandidate(thisMove) + || isCollisionVerticalCorrectionCandidate(thisMove)); + } + + private boolean hasCollisionSignal(final PlayerMoveData thisMove) { + return thisMove.collideX || thisMove.collideY || thisMove.collideZ + || thisMove.collidesHorizontally || thisMove.negligibleHorizontalCollision; + } + + private boolean isCollisionHorizontalSlideCandidate(final PlayerMoveData thisMove) { + return Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && thisMove.hDistance <= COLLISION_HORIZONTAL_SLIDE_MOVE_CAP; + } + + private boolean isCollisionVerticalTruncationCandidate(final PlayerMoveData thisMove) { + final double truncatedY = thisMove.yAllowedDistance - thisMove.yDistance; + return thisMove.yDistance >= -Magic.PREDICTION_EPSILON + && truncatedY >= -Magic.PREDICTION_EPSILON + && truncatedY <= COLLISION_VERTICAL_TRUNCATION_MAX + && thisMove.hDistance <= COLLISION_HORIZONTAL_SLIDE_MOVE_CAP; + } + + private boolean isCollisionVerticalCorrectionCandidate(final PlayerMoveData thisMove) { + return Math.abs(thisMove.yDistance) <= COLLISION_VERTICAL_CORRECTION_Y_MOVE_GRACE + && thisMove.hDistance <= COLLISION_VERTICAL_CORRECTION_MOVE_GRACE; + } + + private boolean isCollisionLandingHorizontalCandidate(final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + if (!lastMove.toIsValid || !thisMove.collideY + || thisMove.yDistance >= -Magic.PREDICTION_EPSILON + || thisMove.hDistance > COLLISION_HORIZONTAL_SLIDE_MOVE_CAP) { + return false; + } + final double gravityModel = getAirInertiaVerticalModel(lastMove); + // Model cleanup: landing collision truncates the vertical fall tick but preserves normal air H inertia. + return thisMove.yDistance >= gravityModel - COLLISION_VERTICAL_TRUNCATION_MAX; + } + + private boolean isCollisionQuantizedStepHorizontalCandidate(final PlayerMoveData thisMove) { + if (!thisMove.collideY + || thisMove.yDistance <= Magic.PREDICTION_EPSILON + || thisMove.yDistance > PARTIAL_SUPPORT_STEP_HEIGHT_MODEL + || thisMove.hDistance > COLLISION_HORIZONTAL_SLIDE_MOVE_CAP) { + return false; + } + final double units = thisMove.yDistance / PARTIAL_SUPPORT_VERTICAL_CLAMP_UNIT; + // Model cleanup: modern collision can report a 1/16th-height Y correction while preserving ground H carry. + return Math.abs(units - Math.round(units)) <= PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON; + } + + // Collision and invalid-history models: recover from collision-shape snaps without treating them as open-air movement. + private boolean acceptsCollisionHorizontalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isCollisionMovementContext(player, from, to, thisMove, lastMove)) { + return false; + } + if (isCollisionLandingHorizontalCandidate(thisMove, lastMove)) { + final double limit = getAirInertiaHorizontalModel(thisMove, lastMove) + COLLISION_HORIZONTAL_SLIDE_RESIDUAL; + if (thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, COLLISION_HORIZONTAL_SLIDE_RESIDUAL)) { + tags.add("collision_landing_horizontal_model"); + return true; + } + } + if (isCollisionQuantizedStepHorizontalCandidate(thisMove)) { + final double limit = getCollisionHorizontalSlideModelLimit(thisMove); + if (thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, COLLISION_HORIZONTAL_SLIDE_RESIDUAL)) { + tags.add("collision_quantized_step_horizontal_model"); + return true; + } + } + if (!isCollisionHorizontalSlideCandidate(thisMove)) { + return false; + } + // False-flag model: a collision slide trims one axis while normal input still carries the other axis. + final double limit = getCollisionHorizontalSlideModelLimit(thisMove); + if (thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, COLLISION_HORIZONTAL_SLIDE_RESIDUAL)) { + tags.add("collision_horizontal_slide_model"); + return true; + } + tags.add("collision_horizontal_slide_model_miss"); + return false; + } + + private double getCollisionHorizontalSlideModelLimit(final PlayerMoveData thisMove) { + final double inputCarry = COLLISION_HORIZONTAL_SLIDE_INPUT_CARRY * getHorizontalInputScale(thisMove); + return Math.min(COLLISION_HORIZONTAL_SLIDE_MOVE_CAP, + Math.max(thisMove.hAllowedDistance, thisMove.hAllowedDistance + inputCarry + + COLLISION_HORIZONTAL_SLIDE_RESIDUAL)); + } + + private boolean acceptsCollisionVerticalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isCollisionMovementContext(player, from, to, thisMove, lastMove) + || hDistanceAboveLimit > COLLISION_VERTICAL_CORRECTION_HORIZONTAL_OVER_GRACE) { + return false; + } + // False-flag model: block collision can truncate upward/step Y below the vanilla predicted Y. + if (isCollisionVerticalTruncationCandidate(thisMove) + && hDistanceAboveLimit <= COLLISION_VERTICAL_TRUNCATION_HORIZONTAL_OVER) { + final double modelDelta = Math.abs(thisMove.yAllowedDistance - thisMove.yDistance); + if (yDistanceAboveLimit <= modelDelta + Magic.PREDICTION_EPSILON) { + tags.add("collision_vertical_truncation_model"); + return true; + } + } + if (acceptsCollisionVerticalCorrectionModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit)) { + tags.add("collision_vertical_correction_model"); + return true; + } + tags.add("collision_vertical_truncation_model_miss"); + return false; + } + + private boolean acceptsCollisionVerticalCorrectionModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isCollisionVerticalCorrectionCandidate(thisMove) + || hDistanceAboveLimit > COLLISION_VERTICAL_CORRECTION_HORIZONTAL_OVER_GRACE + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final boolean yMatchesVelocity = Math.abs(thisMove.yDistance - player.getVelocity().getY()) + <= COLLISION_VERTICAL_CORRECTION_Y_MOVE_GRACE; + if (lastMove.toIsValid && !yMatchesVelocity) { + return false; + } + return yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + player.getVelocity().getY(), COLLISION_VERTICAL_CORRECTION_Y_MOVE_GRACE); + } + + private boolean isLastInvalidResyncContext(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return !lastMove.toIsValid + && !Bridge1_9.isGliding(player) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid + && (isLastInvalidStandstillResyncModel(thisMove) + || isLastInvalidFirstJumpCandidate(thisMove) + || isLastInvalidVelocityResyncCandidate(player, thisMove) + || isLastInvalidGroundInputCandidate(thisMove) + || isLastInvalidVelocityHandoffCandidate(player, thisMove) + || isLastInvalidAirStallCandidate(player, thisMove) + || isLastInvalidJumpContinuationCandidate(thisMove)); + } + + private boolean isLastInvalidStandstillResyncModel(final PlayerMoveData thisMove) { + return thisMove.multiMoveCount > 0 + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && thisMove.hDistance <= LAST_INVALID_STANDSTILL_HORIZONTAL_MOVE; + } + + private boolean isLastInvalidFirstJumpCandidate(final PlayerMoveData thisMove) { + // Model cleanup: after an invalid history gap, the next valid packet can be the vanilla first jump tick. + return (tags.contains("jump_env") || thisMove.isJump) + && Math.abs(thisMove.yDistance - LiftOffEnvelope.NORMAL.getJumpGain(0.0D)) + <= LAST_INVALID_FIRST_JUMP_VERTICAL_EPSILON + && thisMove.hDistance <= getLastInvalidJumpContinuationHorizontalModelLimit(thisMove) + && getHorizontalInputScale(thisMove) > 0.0D; + } + + private boolean isLastInvalidVelocityResyncCandidate(final Player player, final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + return Math.abs(thisMove.yDistance - velocity.getY()) <= LAST_INVALID_RESYNC_VERTICAL_MATCH + && Math.abs(thisMove.yDistance) <= LAST_INVALID_RESYNC_MAX_VERTICAL_MOVE + && thisMove.hDistance <= getLastInvalidResyncHorizontalModelLimit(player, thisMove); + } + + private boolean isLastInvalidVelocityHandoffCandidate(final Player player, final PlayerMoveData thisMove) { + final double expectedY = getLastInvalidVelocityHandoffVerticalModel(player); + final double velocityY = player.getVelocity().getY(); + return Math.abs(thisMove.yDistance) <= LAST_INVALID_VELOCITY_HANDOFF_MAX_VERTICAL_MOVE + && (Math.abs(thisMove.yDistance - expectedY) <= LAST_INVALID_VELOCITY_HANDOFF_VERTICAL_MATCH + || Math.abs(thisMove.yDistance - velocityY) <= LAST_INVALID_VELOCITY_HANDOFF_VERTICAL_MATCH) + && thisMove.hDistance <= getLastInvalidResyncHorizontalModelLimit(player, thisMove); + } + + private boolean isLastInvalidAirStallCandidate(final Player player, final PlayerMoveData thisMove) { + // Model cleanup: invalid-history air packets can arrive between gravity ticks with near-zero Y motion. + return Math.abs(thisMove.yDistance) <= LAST_INVALID_AIR_STALL_VERTICAL_MOVE + && Math.abs(player.getVelocity().getY()) <= LAST_INVALID_AIR_STALL_SERVER_Y + && thisMove.hDistance <= getLastInvalidResyncHorizontalModelLimit(player, thisMove); + } + + private boolean isLastInvalidJumpContinuationCandidate(final PlayerMoveData thisMove) { + return isLastInvalidNormalJumpContinuationCandidate(thisMove) + || isLastInvalidLowJumpContinuationCandidate(thisMove); + } + + private boolean isLastInvalidNormalJumpContinuationCandidate(final PlayerMoveData thisMove) { + final double expectedY = getLastInvalidJumpContinuationVerticalModel(); + return thisMove.yDistance > Magic.PREDICTION_EPSILON + && Math.abs(thisMove.yDistance - expectedY) <= LAST_INVALID_JUMP_CONTINUATION_VERTICAL_EPSILON + && thisMove.hDistance <= getLastInvalidJumpContinuationHorizontalModelLimit(thisMove) + && getHorizontalInputScale(thisMove) > 0.0D; + } + + private boolean isLastInvalidLowJumpContinuationCandidate(final PlayerMoveData thisMove) { + // Model cleanup: invalid-history short-hop packets keep jump input carry but cap Y to the low-jump envelope. + return thisMove.yDistance >= LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_MIN + && thisMove.yDistance <= LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_MAX + && thisMove.hDistance <= getLastInvalidJumpContinuationHorizontalModelLimit(thisMove) + && getHorizontalInputScale(thisMove) > 0.0D; + } + + private boolean isLastInvalidGroundInputCandidate(final PlayerMoveData thisMove) { + return Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && getHorizontalInputScale(thisMove) > 0.0D + && thisMove.hDistance <= getLastInvalidGroundInputHorizontalModelLimit(thisMove); + } + + private boolean acceptsLastInvalidResyncHorizontalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isLastInvalidResyncContext(player, from, to, thisMove, lastMove)) { + return false; + } + if (isLastInvalidStandstillResyncModel(thisMove)) { + tags.add("last_invalid_standstill_resync_horizontal_model"); + return true; + } + if (isLastInvalidFirstJumpCandidate(thisMove)) { + final double limit = getLastInvalidJumpContinuationHorizontalModelLimit(thisMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_RESIDUAL)) { + tags.add("last_invalid_first_jump_horizontal_model"); + return true; + } + } + if (isLastInvalidJumpContinuationCandidate(thisMove)) { + final double limit = getLastInvalidJumpContinuationHorizontalModelLimit(thisMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_RESIDUAL)) { + tags.add("last_invalid_jump_continuation_horizontal_model"); + return true; + } + } + if (isLastInvalidGroundInputCandidate(thisMove)) { + final double limit = getLastInvalidGroundInputHorizontalModelLimit(thisMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, LAST_INVALID_GROUND_INPUT_HORIZONTAL_RESIDUAL)) { + tags.add("last_invalid_ground_input_horizontal_model"); + return true; + } + } + final double limit = getLastInvalidResyncHorizontalModelLimit(player, thisMove); + if ((isLastInvalidVelocityResyncCandidate(player, thisMove) + || isLastInvalidVelocityHandoffCandidate(player, thisMove) + || isLastInvalidAirStallCandidate(player, thisMove)) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, LAST_INVALID_RESYNC_HORIZONTAL_RESIDUAL)) { + tags.add("last_invalid_velocity_resync_horizontal_model"); + return true; + } + tags.add("last_invalid_resync_horizontal_model_miss"); + return false; + } + + private boolean acceptsLastInvalidResyncVerticalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isLastInvalidResyncContext(player, from, to, thisMove, lastMove)) { + return false; + } + if (isLastInvalidStandstillResyncModel(thisMove) + && yDistanceAboveLimit <= LAST_INVALID_STANDSTILL_VERTICAL_OVER) { + tags.add("last_invalid_standstill_resync_vertical_model"); + return true; + } + if (isLastInvalidFirstJumpCandidate(thisMove)) { + final double horizontalLimit = getLastInvalidJumpContinuationHorizontalModelLimit(thisMove); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_RESIDUAL) + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + LiftOffEnvelope.NORMAL.getJumpGain(0.0D), LAST_INVALID_FIRST_JUMP_VERTICAL_EPSILON)) { + tags.add("last_invalid_first_jump_vertical_model"); + return true; + } + } + if (isLastInvalidJumpContinuationCandidate(thisMove)) { + final double horizontalLimit = getLastInvalidJumpContinuationHorizontalModelLimit(thisMove); + final double verticalModel = getLastInvalidJumpContinuationVerticalModel(thisMove); + final double verticalEpsilon = isLastInvalidLowJumpContinuationCandidate(thisMove) + ? LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_EPSILON : LAST_INVALID_JUMP_CONTINUATION_VERTICAL_EPSILON; + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_RESIDUAL) + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + verticalModel, verticalEpsilon)) { + tags.add("last_invalid_jump_continuation_vertical_model"); + return true; + } + } + if (isLastInvalidGroundInputCandidate(thisMove)) { + final double groundInputLimit = getLastInvalidGroundInputHorizontalModelLimit(thisMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + groundInputLimit, LAST_INVALID_GROUND_INPUT_HORIZONTAL_RESIDUAL) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance) + LAST_INVALID_AIR_STALL_VERTICAL_OVER) { + tags.add("last_invalid_ground_input_vertical_stall_model"); + return true; + } + } + final Vector velocity = player.getVelocity(); + final double horizontalLimit = getLastInvalidResyncHorizontalModelLimit(player, thisMove); + final double verticalDelta = Math.abs(thisMove.yAllowedDistance - velocity.getY()); + if (Math.abs(thisMove.yDistance - velocity.getY()) <= LAST_INVALID_RESYNC_VERTICAL_MATCH + && Math.abs(thisMove.yDistance) <= LAST_INVALID_RESYNC_MAX_VERTICAL_MOVE + && thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, LAST_INVALID_RESYNC_HORIZONTAL_RESIDUAL) + && yDistanceAboveLimit <= verticalDelta + LAST_INVALID_RESYNC_VERTICAL_MATCH) { + tags.add("last_invalid_velocity_resync_vertical_model"); + return true; + } + if (isLastInvalidVelocityHandoffCandidate(player, thisMove) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, LAST_INVALID_RESYNC_HORIZONTAL_RESIDUAL) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance + - getLastInvalidVelocityHandoffVerticalModel(player, thisMove)) + + LAST_INVALID_VELOCITY_HANDOFF_VERTICAL_MATCH) { + tags.add("last_invalid_velocity_handoff_vertical_model"); + return true; + } + if (isLastInvalidAirStallCandidate(player, thisMove) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, LAST_INVALID_RESYNC_HORIZONTAL_RESIDUAL) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance) + LAST_INVALID_AIR_STALL_VERTICAL_MOVE) { + tags.add("last_invalid_air_stall_vertical_model"); + return true; + } + tags.add("last_invalid_resync_vertical_model_miss"); + return false; + } + + private double getLastInvalidResyncHorizontalModelLimit(final Player player, final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double inputCarry = Math.max(LAST_INVALID_RESYNC_HORIZONTAL_INPUT_CARRY * getHorizontalInputScale(thisMove), + playerInputHorizontalCarry(thisMove)); + return Math.min(LAST_INVALID_RESYNC_MAX_HORIZONTAL_MOVE, + Math.max(thisMove.hAllowedDistance, + velocityH + inputCarry + LAST_INVALID_RESYNC_HORIZONTAL_RESIDUAL)); + } + + private double getLastInvalidJumpContinuationVerticalModel() { + return (LiftOffEnvelope.NORMAL.getJumpGain(0.0D) - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR; + } + + private double getLastInvalidJumpContinuationVerticalModel(final PlayerMoveData thisMove) { + return isLastInvalidLowJumpContinuationCandidate(thisMove) + ? LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_MAX : getLastInvalidJumpContinuationVerticalModel(); + } + + private double getLastInvalidVelocityHandoffVerticalModel(final Player player) { + final double velocityY = player.getVelocity().getY(); + final double previousGravityTick = (velocityY + Magic.DEFAULT_GRAVITY) / Magic.FRICTION_MEDIUM_AIR; + final double nextGravityTick = (velocityY - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR; + return Math.abs(previousGravityTick) < Math.abs(nextGravityTick) ? previousGravityTick : nextGravityTick; + } + + private double getLastInvalidVelocityHandoffVerticalModel(final Player player, + final PlayerMoveData thisMove) { + final double velocityY = player.getVelocity().getY(); + final double gravityModel = getLastInvalidVelocityHandoffVerticalModel(player); + return Math.abs(thisMove.yDistance - velocityY) <= Math.abs(thisMove.yDistance - gravityModel) + ? velocityY : gravityModel; + } + + private double getLastInvalidJumpContinuationHorizontalModelLimit(final PlayerMoveData thisMove) { + final double inputCarry = LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_INPUT_CARRY * getHorizontalInputScale(thisMove); + return Math.min(LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_CAP, + thisMove.hAllowedDistance + inputCarry + LAST_INVALID_JUMP_CONTINUATION_HORIZONTAL_RESIDUAL); + } + + private double getLastInvalidGroundInputHorizontalModelLimit(final PlayerMoveData thisMove) { + // Model cleanup: after an invalid history gap, one grounded input packet can retain normal walk carry. + return Math.min(LAST_INVALID_GROUND_INPUT_HORIZONTAL_CAP, + playerStepHorizontalModel(thisMove) + LAST_INVALID_GROUND_INPUT_HORIZONTAL_RESIDUAL); + } + + // Elytra and firework models: compare gliding packets to velocity, look vector, and launch-energy envelopes. + private boolean acceptsGlidingHorizontalVelocityModel(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isGliding(player) + || !tags.contains(SurvivalFlyTags.GLIDE_HORIZONTAL_PREDICTION_MISS) + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double limit = getGlidingHorizontalVelocityModelLimit(player, data, thisMove); + if (thisMove.hDistance > limit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + limit, GLIDING_HORIZONTAL_PRECISION_GRACE)) { + tags.add("glide_horizontal_velocity_model_miss"); + return false; + } + if (currentGlidingActualVelocityMatches(player, thisMove)) { + tags.add("glide_current_velocity_horizontal_model"); + return true; + } + if (turningCurrentGlidingVelocityMatches(player, from, to, thisMove)) { + tags.add("glide_current_velocity_turn_horizontal_model"); + return true; + } + if (splitCurrentGlidingVelocityMagnitudeCovers(player, thisMove)) { + tags.add("glide_split_velocity_horizontal_model"); + return true; + } + if (queuedGlidingVelocityMatches(data, thisMove)) { + tags.add("glide_queued_velocity_horizontal_model"); + return true; + } + if (turningGlidingVelocityMatches(player, data, from, to, thisMove)) { + tags.add("glide_turning_velocity_horizontal_model"); + return true; + } + tags.add("glide_horizontal_velocity_vector_miss"); + return false; + } + + private boolean acceptsGlidingSteepDiveEnergyModel(final Player player, final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isGliding(player) + || !tags.contains("glide_pitch_down_steep") + || !tags.contains(SurvivalFlyTags.GLIDE_HORIZONTAL_PREDICTION_MISS) + || !tags.contains(SurvivalFlyTags.GLIDE_VERTICAL_ACTUAL_ABOVE_MODEL) + || thisMove.yDistance >= -Magic.GRAVITY_MAX + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + // Elytra model: steep dives can preserve total speed while redistributing too much into H for the axis model. + final double modeledEnergy = MathUtil.dist(thisMove.hAllowedDistance, thisMove.yAllowedDistance); + final double horizontalLimitSq = modeledEnergy * modeledEnergy - thisMove.yDistance * thisMove.yDistance; + if (horizontalLimitSq <= 0.0D) { + return false; + } + final double horizontalLimit = Math.sqrt(horizontalLimitSq) + GLIDING_STEEP_DIVE_ENERGY_EPSILON; + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, GLIDING_HORIZONTAL_PRECISION_GRACE)) { + tags.add("glide_steep_dive_energy_model"); + return true; + } + tags.add("glide_steep_dive_energy_model_miss"); + return false; + } + + private double getGlidingHorizontalVelocityModelLimit(final Player player, final MovingData data, + final PlayerMoveData thisMove) { + // Elytra model: current/queued velocity can be one packet ahead of the vanilla glide prediction. + // The residual remains because packet ordering for velocity handoff is not perfectly modelable on Folia. + final Vector velocity = player.getVelocity(); + double limit = Math.max(thisMove.hAllowedDistance, MathUtil.dist(velocity.getX(), velocity.getZ())); + final double[] queued = getQueuedGlidingVelocity(data, thisMove); + if (queued != null) { + limit = Math.max(limit, MathUtil.dist(queued[0], queued[1])); + // Elytra model: Folia can expose the prior current velocity and the next queued boost in the same move. + limit = Math.max(limit, MathUtil.dist(velocity.getX() + queued[0], velocity.getZ() + queued[1]) + + GLIDING_CURRENT_VELOCITY_HORIZONTAL_AMOUNT_GRACE); + } + return limit + GLIDING_CURRENT_VELOCITY_HORIZONTAL_PERPENDICULAR_GRACE; + } + + private boolean currentGlidingActualVelocityMatches(final Player player, final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + return horizontalVelocityVectorMatches(velocity.getX(), velocity.getZ(), thisMove.xDistance, thisMove.zDistance, + GLIDING_CURRENT_VELOCITY_HORIZONTAL_AMOUNT_GRACE, + GLIDING_CURRENT_VELOCITY_HORIZONTAL_PERPENDICULAR_GRACE); + } + + private boolean queuedGlidingVelocityMatches(final MovingData data, final PlayerMoveData thisMove) { + final double[] queued = getQueuedGlidingVelocity(data, thisMove); + if (queued == null) { + return false; + } + if (!horizontalVelocityVectorMatches(queued[0], queued[1], thisMove.xDistance, thisMove.zDistance, + GLIDING_CURRENT_VELOCITY_HORIZONTAL_AMOUNT_GRACE, + GLIDING_CURRENT_VELOCITY_HORIZONTAL_PERPENDICULAR_GRACE)) { + return false; + } + data.getHorizontalVelocityTracker().use((int) queued[2]); + return true; + } + + private boolean turningGlidingVelocityMatches(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + final double[] queued = getQueuedGlidingVelocity(data, thisMove); + if (queued == null) { + return false; + } + final Vector velocity = player.getVelocity(); + final double modelX = velocity.getX() + queued[0]; + final double modelZ = velocity.getZ() + queued[1]; + final double yawTurn = Math.min(90.0D, + Math.abs(getYawDelta(from.getYaw(), to.getYaw())) + GLIDING_CURRENT_VELOCITY_TURN_YAW_EXTRA); + final double turnPerpendicular = MathUtil.dist(modelX, modelZ) * Math.sin(yawTurn * TrigUtil.toRadians) + + GLIDING_CURRENT_VELOCITY_HORIZONTAL_PERPENDICULAR_GRACE; + if (!horizontalVelocityVectorMatches(modelX, modelZ, thisMove.xDistance, thisMove.zDistance, + GLIDING_CURRENT_VELOCITY_HORIZONTAL_AMOUNT_GRACE, turnPerpendicular)) { + return false; + } + data.getHorizontalVelocityTracker().use((int) queued[2]); + return true; + } + + private boolean turningCurrentGlidingVelocityMatches(final Player player, final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + if (velocityH <= Magic.PREDICTION_EPSILON) { + return false; + } + final double yawTurn = Math.min(90.0D, + Math.abs(getYawDelta(from.getYaw(), to.getYaw())) + GLIDING_CURRENT_VELOCITY_TURN_YAW_EXTRA); + final double turnPerpendicular = velocityH * Math.sin(yawTurn * TrigUtil.toRadians) + + GLIDING_CURRENT_VELOCITY_HORIZONTAL_PERPENDICULAR_GRACE; + return horizontalVelocityVectorMatches(velocity.getX(), velocity.getZ(), + thisMove.xDistance, thisMove.zDistance, + GLIDING_CURRENT_VELOCITY_HORIZONTAL_AMOUNT_GRACE, turnPerpendicular); + } + + private boolean splitCurrentGlidingVelocityMagnitudeCovers(final Player player, + final PlayerMoveData thisMove) { + if (thisMove.multiMoveCount <= 0) { + return false; + } + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + return velocityH > Magic.PREDICTION_EPSILON + && thisMove.hDistance <= velocityH + GLIDING_CURRENT_VELOCITY_HORIZONTAL_AMOUNT_GRACE; + } + + private double[] getQueuedGlidingVelocity(final MovingData data, final PlayerMoveData thisMove) { + final List queued = data.getHorizontalVelocityTracker().peekCovering(thisMove.xDistance, thisMove.zDistance, + 1, Integer.MAX_VALUE, GLIDING_CURRENT_VELOCITY_HORIZONTAL_PERPENDICULAR_GRACE); + if (queued.isEmpty()) { + return null; + } + double queuedX = 0.0D; + double queuedZ = 0.0D; + final int tick = queued.get(0).tick; + for (final PairEntry entry : queued) { + if (entry.tick == tick) { + queuedX += entry.x; + queuedZ += entry.z; + } + } + return new double[] { queuedX, queuedZ, tick }; + } + + private boolean horizontalVelocityVectorMatches(final double velocityX, final double velocityZ, + final double actualX, final double actualZ, + final double amountResidual, + final double perpendicularResidual) { + final double velocitySq = velocityX * velocityX + velocityZ * velocityZ; + if (velocitySq <= Magic.PREDICTION_EPSILON * Magic.PREDICTION_EPSILON) { + return false; + } + final double actualSq = actualX * actualX + actualZ * actualZ; + if (actualSq <= Magic.PREDICTION_EPSILON * Magic.PREDICTION_EPSILON) { + return false; + } + final double dot = velocityX * actualX + velocityZ * actualZ; + if (dot < -Magic.PREDICTION_EPSILON) { + return false; + } + final double velocityAmount = Math.sqrt(velocitySq); + final double actualAmount = Math.sqrt(actualSq); + if (actualAmount > velocityAmount + amountResidual) { + return false; + } + final double perpendicular = Math.abs(velocityX * actualZ - velocityZ * actualX) / velocityAmount; + return perpendicular <= perpendicularResidual; + } + + private boolean acceptsElytraLandingInertiaHorizontalModel(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit, + final boolean requireGliding, + final boolean requireFirework, + final String acceptedTag) { + if (!isElytraLandingInertiaContext(player, data, from, to, thisMove, lastMove, + requireGliding, requireFirework)) { + return false; + } + final double horizontalLimit = getElytraLandingInertiaHorizontalModelLimit(player, data, from, to, + thisMove, lastMove); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_LANDING_INERTIA_HORIZONTAL_RESIDUAL)) { + tags.add(acceptedTag + "_limit_miss"); + return false; + } + if (horizontalLookDirectionMatches(player, to, thisMove, horizontalLimit, + ELYTRA_LANDING_INERTIA_HORIZONTAL_RESIDUAL, + ELYTRA_LANDING_INERTIA_PERPENDICULAR_RESIDUAL)) { + tags.add(acceptedTag + "_look_vector"); + tags.add(acceptedTag); + return true; + } + if (horizontalLandingInertiaVectorMatches(player, data, thisMove, lastMove, acceptedTag)) { + tags.add(acceptedTag); + return true; + } + tags.add(acceptedTag + "_vector_miss"); + return false; + } + + private boolean horizontalLandingInertiaVectorMatches(final Player player, final MovingData data, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final String acceptedTag) { + /* + * Elytra landing model: contact with ground, lanterns, carpets, slabs, or + * similar support shapes keeps the incoming glide vector for a packet. + * It should not be forced to match the player's current look direction. + */ + final Vector velocity = player.getVelocity(); + if (horizontalVelocityVectorMatches(velocity.getX(), velocity.getZ(), thisMove.xDistance, thisMove.zDistance, + ELYTRA_LANDING_INERTIA_HORIZONTAL_RESIDUAL, + ELYTRA_LANDING_INERTIA_PERPENDICULAR_RESIDUAL)) { + tags.add(acceptedTag + "_current_velocity_vector"); + return true; + } + if (lastMove.toIsValid + && horizontalVelocityVectorMatches(lastMove.xDistance, lastMove.zDistance, + thisMove.xDistance, thisMove.zDistance, + ELYTRA_LANDING_INERTIA_HORIZONTAL_RESIDUAL, + ELYTRA_LANDING_INERTIA_PERPENDICULAR_RESIDUAL)) { + tags.add(acceptedTag + "_last_move_vector"); + return true; + } + if (queuedGlidingVelocityMatches(data, thisMove)) { + tags.add(acceptedTag + "_queued_velocity_vector"); + return true; + } + return false; + } + + private boolean isElytraLandingInertiaContext(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final boolean requireGliding, + final boolean requireFirework) { + final boolean gliding = Bridge1_9.isGliding(player); + if (requireGliding != gliding + || !gliding && !Bridge1_9.isWearingElytra(player) + || requireFirework && data.fireworksBoostDuration <= 0 + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid + || Math.abs(thisMove.yDistance) > ELYTRA_LANDING_INERTIA_MAX_VERTICAL_MOVE) { + return false; + } + final boolean partialSupport = isPartialSupportNear(from, to) || isPartialSupportLandingBlock(to); + final boolean landingContact = isGroundishStepMove(from, to, thisMove) + || thisMove.collideY + || partialSupport && getPartialSupportVerticalClampModel(from, to, thisMove.yDistance) > 0.0D + || partialSupport && getPartialSupportLandingClampFraction(to) >= 0.0D; + if (!landingContact && !partialSupport) { + return false; + } + final Vector look = TrigUtil.getLookingDirection(to, player); + if (MathUtil.dist(look.getX(), look.getZ()) <= Magic.PREDICTION_EPSILON) { + return false; + } + final double velocityH = MathUtil.dist(player.getVelocity().getX(), player.getVelocity().getZ()); + final boolean knownInertiaSource = gliding + || data.fireworksBoostDuration > 0 + || lastMove.toIsValid && lastMove.hDistance >= ELYTRA_LANDING_INERTIA_MIN_LAST_HORIZONTAL + || !lastMove.toIsValid && partialSupport + && (data.getHorizontalVelocityTracker().hasQueued() + || velocityH >= ELYTRA_EQUIPPED_VELOCITY_HANDOFF_HORIZONTAL_INPUT_CARRY + || thisMove.hDistance >= ELYTRA_LANDING_INERTIA_MIN_LAST_HORIZONTAL); + return knownInertiaSource && getHorizontalInputScale(thisMove) > 0.0D; + } + + private double getElytraLandingInertiaHorizontalModelLimit(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final Vector look = TrigUtil.getLookingDirection(to, player); + final double lookH = MathUtil.dist(look.getX(), look.getZ()); + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double lastH = lastMove.toIsValid ? lastMove.hDistance : 0.0D; + final double supportCarry = isPartialSupportNear(from, to) || isPartialSupportLandingBlock(to) + ? Math.max(PARTIAL_SUPPORT_STEP_HEIGHT_MODEL, getPartialSupportStepHeightModel(from, to)) + : ELYTRA_LANDING_INERTIA_GENERAL_SUPPORT_CARRY; + double limit = Math.max(thisMove.hAllowedDistance, Math.max(velocityH, lastH)) + + playerInputHorizontalCarry(thisMove) + ELYTRA_LANDING_INERTIA_HORIZONTAL_RESIDUAL; + // Elytra landing model: when glide/firework motion hits a support shape, preserve look-directed inertia through the contact packet. + limit = Math.max(limit, lookH + supportCarry + playerInputHorizontalCarry(thisMove) + + ELYTRA_LANDING_INERTIA_HORIZONTAL_RESIDUAL); + if (data.fireworksBoostDuration > 0) { + final double[] fireworkModel = Bridge1_9.isGliding(player) + ? getFireworkPacketOrderModelVector(player, data, to, thisMove, 1) + : getElytraEquippedFireworkModelVector(player, data, to, thisMove, lastMove); + limit = Math.max(limit, MathUtil.dist(fireworkModel[0], fireworkModel[2]) + + supportCarry + ELYTRA_LANDING_INERTIA_HORIZONTAL_RESIDUAL); + } + return Math.min(ELYTRA_LANDING_INERTIA_MAX_HORIZONTAL_MOVE, limit); + } + + private boolean acceptsElytraEquippedPartialSupportVerticalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) || Bridge1_9.isGliding(player)) { + return false; + } + // Elytra model: wearing elytra changes inertia, not the lantern/carpet/slab collision shape. + if (acceptsPartialSupportVerticalModel(player, from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit)) { + tags.add("elytra_equipped_partial_support_vertical_model"); + return true; + } + return false; + } + + private boolean acceptsElytraEquippedPartialSupportHorizontalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) || Bridge1_9.isGliding(player)) { + return false; + } + // Elytra model: thin-support geometry is shared with normal movement; only the inertia source changes. + if (acceptsPartialSupportHorizontalModel(from, to, thisMove, lastMove, hDistanceAboveLimit)) { + tags.add("elytra_equipped_partial_support_horizontal_model"); + return true; + } + return false; + } + + private boolean horizontalLookDirectionMatches(final Player player, final PlayerLocation to, + final PlayerMoveData thisMove, final double horizontalLimit, + final double amountResidual, + final double perpendicularResidual) { + final Vector look = TrigUtil.getLookingDirection(to, player); + final double lookH = MathUtil.dist(look.getX(), look.getZ()); + if (lookH <= Magic.PREDICTION_EPSILON) { + return false; + } + final double modelX = look.getX() / lookH * horizontalLimit; + final double modelZ = look.getZ() / lookH * horizontalLimit; + return horizontalVelocityVectorMatches(modelX, modelZ, thisMove.xDistance, thisMove.zDistance, + amountResidual, perpendicularResidual); + } + + // Liquid models: swimming and lava movement use liquid input acceleration plus current/queued server velocity. + private boolean acceptsWaterHorizontalModel(final Player player, final MovingData data, + final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final double hDistanceAboveLimit, + final boolean requireDolphinGrace) { + if (!isWaterMovementContext(from, to, thisMove)) { + return false; + } + if (requireDolphinGrace) { + if (Double.isInfinite(Bridge1_13.getDolphinGraceAmplifier(player))) { + return false; + } + final double limit = getWaterHorizontalModelLimit(player, thisMove, true); + if (thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, limit, WATER_HORIZONTAL_MODEL_EPSILON)) { + tags.add("water_dolphin_horizontal_model"); + return true; + } + return acceptsWaterQueuedVelocityHorizontalModel(data, thisMove, hDistanceAboveLimit); + } + final double limit = getWaterHorizontalModelLimit(player, thisMove, false); + if (thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, limit, WATER_HORIZONTAL_MODEL_EPSILON)) { + tags.add("water_current_horizontal_model"); + return true; + } + if (acceptsWaterCurrentVelocityHorizontalModel(player, thisMove, hDistanceAboveLimit)) { + tags.add("water_current_velocity_horizontal_model"); + return true; + } + if (acceptsWaterImplicitSwimHorizontalModel(thisMove, hDistanceAboveLimit)) { + tags.add("water_implicit_swim_horizontal_model"); + return true; + } + return acceptsWaterQueuedVelocityHorizontalModel(data, thisMove, hDistanceAboveLimit); + } + + private boolean acceptsWaterCurrentVelocityHorizontalModel(final Player player, final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + // False-flag model: arrow/combat knockback in water is current velocity plus swim input drag. + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + if (velocityH <= Magic.PREDICTION_EPSILON + || velocity.getX() * thisMove.xDistance + velocity.getZ() * thisMove.zDistance < -Magic.PREDICTION_EPSILON) { + return false; + } + final double inputCarry = Magic.LIQUID_ACCELERATION * getHorizontalInputScale(thisMove); + final double limit = Math.min(WATER_CURRENT_VELOCITY_HORIZONTAL_CAP, + Math.max(thisMove.hAllowedDistance, + velocityH + inputCarry + WATER_CURRENT_VELOCITY_HORIZONTAL_RESIDUAL)); + return thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, WATER_CURRENT_VELOCITY_HORIZONTAL_RESIDUAL); + } + + private boolean acceptsWaterImplicitSwimHorizontalModel(final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + /* + * Water model: Bedrock and Java can send small swim/body pushes even when + * Bukkit exposes no useful current velocity. Keep this bounded to normal + * submerged swim speed instead of setting allowed movement to actual. + */ + final double verticalEnvelope = WATER_SURFACE_ASCEND_MODEL + WATER_VERTICAL_MODEL_EPSILON; + final double limit = Math.max(thisMove.hAllowedDistance, WATER_IMPLICIT_SWIM_HORIZONTAL_CAP); + return thisMove.hDistance <= limit + && Math.abs(thisMove.yDistance) <= verticalEnvelope + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, WATER_IMPLICIT_SWIM_HORIZONTAL_RESIDUAL); + } + + private boolean acceptsLavaHorizontalModel(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isLavaMovementContext(from, to, thisMove)) { + return false; + } + final double horizontalLimit = getLavaHorizontalModelLimit(player, data, thisMove); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, LAVA_VELOCITY_HORIZONTAL_RESIDUAL)) { + tags.add("lava_velocity_horizontal_model_limit_miss"); + return false; + } + final List queued = data.getHorizontalVelocityTracker().peekCovering(thisMove.xDistance, + thisMove.zDistance, 1, Integer.MAX_VALUE, LAVA_VELOCITY_HORIZONTAL_RESIDUAL); + if (!queued.isEmpty()) { + data.getHorizontalVelocityTracker().use(queued.get(0).tick); + tags.add("lava_queued_velocity_horizontal_model"); + return true; + } + final Vector velocity = player.getVelocity(); + if (horizontalVelocityVectorMatches(velocity.getX(), velocity.getZ(), thisMove.xDistance, + thisMove.zDistance, LAVA_VELOCITY_HORIZONTAL_RESIDUAL, + LAVA_VELOCITY_HORIZONTAL_PERPENDICULAR_RESIDUAL)) { + tags.add("lava_current_velocity_horizontal_model"); + return true; + } + tags.add("lava_velocity_horizontal_vector_miss"); + return false; + } + + private double getLavaHorizontalModelLimit(final Player player, final MovingData data, + final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final List queued = data.getHorizontalVelocityTracker().peekCovering(thisMove.xDistance, + thisMove.zDistance, 1, Integer.MAX_VALUE, LAVA_VELOCITY_HORIZONTAL_RESIDUAL); + final double queuedH = queued.isEmpty() ? 0.0D : getHorizontalVelocityAmount(queued); + // Lava model: liquid flow and server velocity can apply one packet apart, but the vector must still match. + return Math.min(LAVA_HORIZONTAL_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, + Math.max(velocityH, queuedH) + Magic.LIQUID_ACCELERATION * getHorizontalInputScale(thisMove) + + LAVA_VELOCITY_HORIZONTAL_RESIDUAL)); + } + + // Step and partial-support models: half blocks, lanterns, carpet, fences, and similar shapes have their own support envelope. + private boolean isModernHalfStepContext(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + return isModernMovementClient(pData) + && !isBedrockPlayer(player, pData) + && !Bridge1_9.isGliding(player) + && isGroundishStepMove(from, to, thisMove) + && (isModernHalfStepRiseModel(from, to, thisMove) || isModernHalfStepPlateauModel(thisMove, lastMove)) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsModernHalfStepHorizontalModel(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isModernHalfStepContext(player, pData, from, to, thisMove, lastMove)) { + return false; + } + // False-flag model: modern clients can report the half-step rise before the sampled blocks expose support. + final double limit = getModernHalfStepHorizontalModelLimit(thisMove, lastMove); + if (thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, MODERN_HALF_STEP_HORIZONTAL_RESIDUAL)) { + tags.add("modern_half_step_horizontal_model"); + return true; + } + tags.add("modern_half_step_horizontal_model_miss"); + return false; + } + + private boolean acceptsModernHalfStepVerticalModel(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isModernHalfStepContext(player, pData, from, to, thisMove, lastMove)) { + return false; + } + final double horizontalLimit = getModernHalfStepHorizontalModelLimit(thisMove, lastMove); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, MODERN_HALF_STEP_HORIZONTAL_RESIDUAL)) { + tags.add("modern_half_step_vertical_h_miss"); + return false; + } + if (isModernHalfStepRiseModel(from, to, thisMove) + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + getModernHalfStepRiseVerticalModel(from, to, thisMove), + MODERN_HALF_STEP_PLATEAU_EPSILON)) { + tags.add("modern_half_step_rise_vertical_model"); + return true; + } + if (isModernHalfStepPlateauModel(thisMove, lastMove) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance) + + MODERN_HALF_STEP_PLATEAU_EPSILON) { + tags.add("modern_half_step_plateau_vertical_model"); + return true; + } + tags.add("modern_half_step_vertical_model_miss"); + return false; + } + + private boolean isModernHalfStepRiseModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return Math.abs(thisMove.yDistance - BEDROCK_HALF_STEP_VERTICAL_MOVE) <= BEDROCK_HALF_STEP_VERTICAL_EPSILON + || isModernHalfStepLandingModel(from, to, thisMove); + } + + private boolean isModernHalfStepLandingModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + if (!to.isOnGroundOrResetCond() || !thisMove.collideY || thisMove.yDistance <= Magic.PREDICTION_EPSILON) { + return false; + } + final double landingDistance = to.getBlockY() + BEDROCK_HALF_STEP_VERTICAL_MOVE - from.getY(); + return Math.abs(thisMove.yDistance - landingDistance) <= MODERN_HALF_STEP_PLATEAU_EPSILON; + } + + private double getModernHalfStepRiseVerticalModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + if (isModernHalfStepLandingModel(from, to, thisMove)) { + return to.getBlockY() + BEDROCK_HALF_STEP_VERTICAL_MOVE - from.getY(); + } + return BEDROCK_HALF_STEP_VERTICAL_MOVE; + } + + private boolean isModernHalfStepPlateauModel(final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + return lastMove.toIsValid + && Math.abs(lastMove.yDistance - BEDROCK_HALF_STEP_VERTICAL_MOVE) <= BEDROCK_HALF_STEP_VERTICAL_EPSILON + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && Math.abs(thisMove.yAllowedDistance - getModernHalfStepFollowupVerticalModel()) + <= MODERN_HALF_STEP_PLATEAU_EPSILON; + } + + private double getModernHalfStepFollowupVerticalModel() { + return (BEDROCK_HALF_STEP_VERTICAL_MOVE - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR; + } + + private double getModernHalfStepHorizontalModelLimit(final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + final double lastCarry = lastMove.toIsValid ? lastMove.hDistance : thisMove.hAllowedDistance; + final double inputCarry = MODERN_HALF_STEP_HORIZONTAL_INPUT_CARRY * getHorizontalInputScale(thisMove); + return Math.min(MODERN_HALF_STEP_HORIZONTAL_CAP, + Math.max(thisMove.hAllowedDistance, + Math.max(lastCarry, thisMove.hAllowedDistance) + inputCarry + + MODERN_HALF_STEP_HORIZONTAL_RESIDUAL)); + } + + private boolean isJumpCarryContext(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return lastMove.toIsValid + && !Bridge1_9.isGliding(player) + && (isNormalJumpCarryContext(thisMove, lastMove) || isLowJumpCarryContext(thisMove, lastMove)) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isNormalJumpCarryContext(final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + return (tags.contains("jump_env") || tags.contains("bunnyhop")) + && Math.abs(thisMove.yDistance - LiftOffEnvelope.NORMAL.getJumpGain(0.0D)) <= Magic.PREDICTION_EPSILON + && Math.abs(thisMove.yAllowedDistance - thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && thisMove.hDistance <= getJumpCarryHorizontalModelLimit(thisMove, lastMove); + } + + private boolean isLowJumpCarryContext(final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + // Model cleanup: head-obstructed short hops carry the previous ground H plus one normal input step. + return getHorizontalInputScale(thisMove) > 0.0D + && (lastMove.touchedGround || lastMove.from.onGroundOrResetCond || lastMove.to.onGroundOrResetCond) + && thisMove.yDistance >= LOW_JUMP_CARRY_VERTICAL_MIN + && thisMove.yDistance <= LOW_JUMP_CARRY_VERTICAL_MAX + && thisMove.hDistance <= getJumpCarryHorizontalModelLimit(thisMove, lastMove); + } + + private boolean acceptsJumpCarryHorizontalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isJumpCarryContext(player, from, to, thisMove, lastMove)) { + return false; + } + // False-flag model: bound jump carry to previous H plus one current input step, not the actual move. + final double limit = getJumpCarryHorizontalModelLimit(thisMove, lastMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, JUMP_CARRY_HORIZONTAL_RESIDUAL)) { + tags.add(isLowJumpCarryContext(thisMove, lastMove) + ? "low_jump_carry_horizontal_model" : "jump_carry_horizontal_model"); + return true; + } + tags.add("jump_carry_horizontal_model_miss"); + return false; + } + + private boolean acceptsJumpCarryVerticalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isJumpCarryContext(player, from, to, thisMove, lastMove)) { + return false; + } + final double horizontalLimit = getJumpCarryHorizontalModelLimit(thisMove, lastMove); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, JUMP_CARRY_HORIZONTAL_RESIDUAL)) { + tags.add("jump_carry_vertical_h_miss"); + return false; + } + if (isLowJumpCarryContext(thisMove, lastMove) + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + LOW_JUMP_CARRY_VERTICAL_MAX, LOW_JUMP_CARRY_VERTICAL_EPSILON)) { + tags.add("low_jump_carry_vertical_model"); + return true; + } + if (isNormalJumpCarryContext(thisMove, lastMove) + && yDistanceAboveLimit <= Magic.PREDICTION_EPSILON) { + tags.add("jump_carry_vertical_model"); + return true; + } + tags.add("jump_carry_vertical_model_miss"); + return false; + } + + private double getJumpCarryHorizontalModelLimit(final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + final double lastCarry = lastMove.toIsValid ? lastMove.hDistance : thisMove.hAllowedDistance; + return Math.min(JUMP_CARRY_MAX_MOVE, + Math.max(thisMove.hAllowedDistance, lastCarry + playerInputHorizontalCarry(thisMove)) + + JUMP_CARRY_HORIZONTAL_RESIDUAL); + } + + private boolean isGroundLandingCarryContext(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return lastMove.toIsValid + && !Bridge1_9.isGliding(player) + && isGroundishStepMove(from, to, thisMove) + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && lastMove.yDistance < -Magic.PREDICTION_EPSILON + && thisMove.hDistance <= getGroundLandingCarryHorizontalModelLimit(player, thisMove, lastMove) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsGroundLandingCarryHorizontalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isGroundLandingCarryContext(player, from, to, thisMove, lastMove)) { + return false; + } + // False-flag model: the landing packet keeps the previous air speed plus the next ground input step. + final double limit = getGroundLandingCarryHorizontalModelLimit(player, thisMove, lastMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, GROUND_LANDING_CARRY_HORIZONTAL_RESIDUAL)) { + tags.add("ground_landing_carry_horizontal_model"); + return true; + } + tags.add("ground_landing_carry_horizontal_model_miss"); + return false; + } + + private double getGroundLandingCarryHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double inputCarry = player.getWalkSpeed() * getHorizontalInputScale(thisMove) + * GROUND_LANDING_CARRY_INPUT_MULTIPLIER; + return Math.min(GROUND_LANDING_CARRY_MAX_MOVE, + Math.max(thisMove.hAllowedDistance, + lastMove.hDistance + inputCarry + GROUND_LANDING_CARRY_HORIZONTAL_RESIDUAL)); + } + + private boolean isGroundPassablePlantContext(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return lastMove.toIsValid + && !Bridge1_9.isGliding(player) + && isGroundishStepMove(from, to, thisMove) + && isPassablePlantMove(from, to) + && getHorizontalInputScale(thisMove) > 0.0D + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && thisMove.hDistance > thisMove.hAllowedDistance + Magic.PREDICTION_EPSILON + && thisMove.hDistance <= getGroundPassablePlantHorizontalModelLimit(thisMove, lastMove) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsGroundPassablePlantHorizontalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isGroundPassablePlantContext(player, from, to, thisMove, lastMove)) { + return false; + } + // Model cleanup: instant plants are passable, but can leave one grounded carry packet above the block predictor. + final double limit = getGroundPassablePlantHorizontalModelLimit(thisMove, lastMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, GROUND_PASSABLE_PLANT_HORIZONTAL_RESIDUAL)) { + tags.add("ground_passable_plant_horizontal_model"); + return true; + } + tags.add("ground_passable_plant_horizontal_model_miss"); + return false; + } + + private double getGroundPassablePlantHorizontalModelLimit(final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double inputCarry = playerInputHorizontalCarry(thisMove) * GROUND_PASSABLE_PLANT_INPUT_CARRY; + return Math.min(GROUND_PASSABLE_PLANT_MAX_MOVE, + Math.max(thisMove.hAllowedDistance, lastMove.hDistance + inputCarry + + GROUND_PASSABLE_PLANT_HORIZONTAL_RESIDUAL)); + } + + private boolean isPassablePlantMove(final PlayerLocation from, final PlayerLocation to) { + return MaterialUtil.INSTANT_PLANTS.contains(from.getBlockType()) + || MaterialUtil.INSTANT_PLANTS.contains(to.getBlockType()); + } + + private boolean isGroundVelocityCarryContext(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove) { + return !Bridge1_9.isGliding(player) + && isGroundishStepMove(from, to, thisMove) + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && thisMove.hDistance <= getGroundVelocityCarryHorizontalModelLimit(player, thisMove) + && groundVelocityCarryDirectionMatches(player, thisMove) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsGroundVelocityCarryHorizontalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isGroundVelocityCarryContext(player, from, to, thisMove)) { + return false; + } + // False-flag model: landing/ground packets can add current server velocity to the walk prediction. + final double limit = getGroundVelocityCarryHorizontalModelLimit(player, thisMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, GROUND_VELOCITY_CARRY_HORIZONTAL_RESIDUAL)) { + tags.add("ground_velocity_carry_horizontal_model"); + return true; + } + tags.add("ground_velocity_carry_horizontal_model_miss"); + return false; + } + + private double getGroundVelocityCarryHorizontalModelLimit(final Player player, final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + return Math.min(GROUND_VELOCITY_CARRY_MAX_MOVE, + thisMove.hAllowedDistance + velocityH + GROUND_VELOCITY_CARRY_HORIZONTAL_RESIDUAL); + } + + private boolean groundVelocityCarryDirectionMatches(final Player player, final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + if (velocityH <= Magic.PREDICTION_EPSILON) { + return false; + } + final double overX = thisMove.xDistance - thisMove.xAllowedDistance; + final double overZ = thisMove.zDistance - thisMove.zAllowedDistance; + return velocity.getX() * overX + velocity.getZ() * overZ >= -Magic.PREDICTION_EPSILON; + } + + private boolean isItemResyncMovementContext(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove) { + return tags.contains("itemresync") + && tags.contains("usingitem") + && !Bridge1_9.isGliding(player) + && thisMove.hDistance > thisMove.hAllowedDistance + Magic.PREDICTION_EPSILON + && Math.abs(thisMove.yDistance - thisMove.yAllowedDistance) <= Magic.PREDICTION_EPSILON + && thisMove.hDistance <= getItemResyncHorizontalModelLimit(player, thisMove) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsItemResyncHorizontalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isItemResyncMovementContext(player, from, to, thisMove)) { + return false; + } + // False-flag model: item-use state can resync one packet late, so use the normal walk envelope. + final double limit = getItemResyncHorizontalModelLimit(player, thisMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, GROUNDED_ITEM_RESYNC_HORIZONTAL_OVER_GRACE)) { + tags.add("itemresync_horizontal_model"); + return true; + } + tags.add("itemresync_horizontal_model_miss"); + return false; + } + + private double getItemResyncHorizontalModelLimit(final Player player, final PlayerMoveData thisMove) { + final double inputScale = Math.max(1.0D, getHorizontalInputScale(thisMove)); + final double normalWalkEnvelope = player.getWalkSpeed() * (inputScale > 1.0D ? 1.25D : 1.15D); + final double velocityEnvelope = MathUtil.dist(player.getVelocity().getX(), player.getVelocity().getZ()) + + AIR_CURRENT_VELOCITY_HORIZONTAL_RESIDUAL; + return Math.min(GROUNDED_ITEM_RESYNC_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, Math.max(normalWalkEnvelope, velocityEnvelope))); + } + + private boolean isAirCurrentVelocityContext(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove) { + return !Bridge1_9.isGliding(player) + && Math.abs(thisMove.yDistance - player.getVelocity().getY()) <= AIR_CURRENT_VELOCITY_VERTICAL_MATCH + && thisMove.hDistance <= getAirCurrentVelocityHorizontalModelLimit(player, thisMove) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsAirCurrentVelocityHorizontalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isAirCurrentVelocityContext(player, from, to, thisMove)) { + return false; + } + // False-flag model: server velocity can be exact for Y while X/Z still needs the air input carry. + final double limit = getAirCurrentVelocityHorizontalModelLimit(player, thisMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, AIR_CURRENT_VELOCITY_HORIZONTAL_RESIDUAL)) { + tags.add("air_current_velocity_horizontal_model"); + return true; + } + tags.add("air_current_velocity_horizontal_model_miss"); + return false; + } + + private double getAirCurrentVelocityHorizontalModelLimit(final Player player, final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + return Math.min(AIR_CURRENT_VELOCITY_MAX_HORIZONTAL_MOVE, + velocityH + Magic.AIR_ACCELERATION * getHorizontalInputScale(thisMove) + + AIR_CURRENT_VELOCITY_HORIZONTAL_RESIDUAL); + } + + private boolean isAirInertiaMovementContext(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return lastMove.toIsValid + && !Bridge1_9.isGliding(player) + && thisMove.hDistance <= AIR_INERTIA_MAX_HORIZONTAL_MOVE + && lastMove.hDistance <= AIR_INERTIA_MAX_HORIZONTAL_MOVE + && thisMove.hDistance <= getAirInertiaHorizontalModel(thisMove, lastMove) + + AIR_INERTIA_HORIZONTAL_EPSILON + && Math.abs(thisMove.yDistance - getAirInertiaVerticalModel(lastMove)) + <= AIR_INERTIA_VERTICAL_EPSILON + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsAirInertiaHorizontalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isAirInertiaMovementContext(player, from, to, thisMove, lastMove)) { + return false; + } + // False-flag model: ordinary air carry is last horizontal motion times vanilla air friction. + final double limit = getAirInertiaHorizontalModel(thisMove, lastMove) + AIR_INERTIA_HORIZONTAL_EPSILON; + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, AIR_INERTIA_HORIZONTAL_EPSILON)) { + tags.add("air_inertia_horizontal_model"); + return true; + } + tags.add("air_inertia_horizontal_model_miss"); + return false; + } + + private boolean acceptsAirInertiaVerticalModel(final Player player, final PlayerLocation from, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isAirInertiaMovementContext(player, from, to, thisMove, lastMove) + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + getAirInertiaHorizontalModel(thisMove, lastMove) + AIR_INERTIA_HORIZONTAL_EPSILON, + AIR_INERTIA_HORIZONTAL_EPSILON)) { + return false; + } + final double expectedY = getAirInertiaVerticalModel(lastMove); + if (yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - expectedY) + + AIR_INERTIA_VERTICAL_EPSILON) { + tags.add(lastMove.yDistance <= Magic.PREDICTION_EPSILON + ? "air_inertia_first_gravity_vertical_model" : "air_inertia_continued_gravity_vertical_model"); + return true; + } + tags.add("air_inertia_vertical_model_miss"); + return false; + } + + private double getAirInertiaHorizontalModel(final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + // Model cleanup: horizontal air carry uses vanilla's 0.91 inertia, not vertical air friction. + return lastMove.hDistance * Magic.AIR_HORIZONTAL_INERTIA + + Magic.AIR_ACCELERATION * getHorizontalInputScale(thisMove); + } + + private double getAirInertiaFirstGravityModel() { + return -Magic.DEFAULT_GRAVITY * Magic.FRICTION_MEDIUM_AIR; + } + + private double getAirInertiaVerticalModel(final PlayerMoveData lastMove) { + // Model cleanup: use vanilla gravity continuation once the first air tick is already known. + return lastMove.toIsValid + ? (lastMove.yDistance - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR + : getAirInertiaFirstGravityModel(); + } + + private boolean isPartialSupportMovementContext(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + if (!isPartialSupportNear(from, to) + || !isGroundishStepMove(from, to, thisMove) + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double horizontalLimit = getPartialSupportHorizontalModelLimit(from, to, thisMove, lastMove); + final double verticalLimit = getPartialSupportVerticalModelLimit(from, to, thisMove); + // Model cleanup: only select partial-support when the collision-shape envelope can plausibly own this move. + return thisMove.hDistance <= horizontalLimit + && (thisMove.yDistance <= verticalLimit + || getPartialSupportVerticalClampModel(from, to, thisMove.yDistance) > 0.0D + || getPartialSupportLandingClampFraction(to) >= 0.0D); + } + + private boolean acceptsPartialSupportHorizontalModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + // False-flag model: partial support blocks use the player's normal step height instead of a fixed horizontal grace. + final double limit = getPartialSupportHorizontalModelLimit(from, to, thisMove, lastMove); + final boolean accepted = isPartialSupportNear(from, to) + && isGroundishStepMove(from, to, thisMove) + && thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, limit, PARTIAL_SUPPORT_HORIZONTAL_MODEL_EPSILON) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + if (accepted) { + addPartialSupportTypeTag(from, to, "horizontal_model"); + } + return accepted; + } + + private boolean acceptsPartialSupportVerticalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-flag model: partial support uses collision-shape height, not a post-failure vertical grace. + final double verticalLimit = getPartialSupportVerticalModelLimit(from, to, thisMove); + final double horizontalLimit = getPartialSupportHorizontalModelLimit(from, to, thisMove, lastMove); + if (!isPartialSupportNear(from, to) + || !isGroundishStepMove(from, to, thisMove) + || thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, horizontalLimit, PARTIAL_SUPPORT_HORIZONTAL_MODEL_EPSILON) + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + if (thisMove.yDistance >= -Magic.PREDICTION_EPSILON + && thisMove.yDistance <= verticalLimit + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, verticalLimit, PARTIAL_SUPPORT_VERTICAL_MODEL_EPSILON)) { + tags.add("partial_support_vertical_model"); + addPartialSupportTypeTag(from, to, "vertical_model"); + return true; + } + if (acceptsPartialSupportVerticalClampModel(from, to, thisMove, yDistanceAboveLimit)) { + tags.add("partial_support_vertical_clamp_model"); + addPartialSupportTypeTag(from, to, "vertical_clamp_model"); + return true; + } + if (acceptsPartialSupportLastInvalidVelocityModel(player, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit)) { + tags.add("partial_support_last_invalid_velocity_model"); + addPartialSupportTypeTag(from, to, "last_invalid_velocity_model"); + return true; + } + if (acceptsPartialSupportLastInvalidGravityModel(player, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit)) { + tags.add("partial_support_last_invalid_gravity_model"); + addPartialSupportTypeTag(from, to, "last_invalid_gravity_model"); + return true; + } + if (thisMove.yDistance < -Magic.PREDICTION_EPSILON + && yDistanceAboveLimit > Magic.PREDICTION_EPSILON) { + tags.add("partial_support_vertical_clamp_model_miss"); + addPartialSupportTypeTag(from, to, "vertical_model_miss"); + } + return false; + } + + private boolean acceptsPartialSupportLastInvalidVelocityModel(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-flag model: partial support can invalidate the previous packet while the server fall velocity is exact. + if (lastMove.toIsValid + || hDistanceAboveLimit > LAST_INVALID_RESYNC_HORIZONTAL_RESIDUAL + || !isLastInvalidVelocityResyncCandidate(player, thisMove)) { + return false; + } + final double velocityY = player.getVelocity().getY(); + return yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - velocityY) + + LAST_INVALID_RESYNC_VERTICAL_MATCH; + } + + private boolean acceptsPartialSupportLastInvalidGravityModel(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-flag model: one lost support packet can apply the next vanilla gravity step after current velocity. + if (lastMove.toIsValid + || hDistanceAboveLimit > LAST_INVALID_RESYNC_HORIZONTAL_RESIDUAL) { + return false; + } + final double expectedY = (player.getVelocity().getY() - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR; + return Math.abs(thisMove.yDistance - expectedY) <= LAST_INVALID_RESYNC_VERTICAL_MATCH + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - expectedY) + + LAST_INVALID_RESYNC_VERTICAL_MATCH; + } + + private boolean acceptsPartialSupportVerticalClampModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit) { + final double verticalClamp = getPartialSupportVerticalClampModel(from, to, thisMove.yDistance); + if (verticalClamp > 0.0D && Math.abs(thisMove.yDistance + verticalClamp) <= PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON) { + if (thisMove.yDistance > thisMove.yAllowedDistance + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + -verticalClamp, PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON)) { + return true; + } + if (thisMove.yDistance < thisMove.yAllowedDistance + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance + verticalClamp) + + PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON) { + tags.add("partial_support_vertical_clamp_below_model"); + return true; + } + } + if (acceptsPartialSupportLandingClampModel(from, to, thisMove, yDistanceAboveLimit)) { + tags.add("partial_support_landing_clamp_model"); + return true; + } + return false; + } + + private boolean acceptsPartialSupportLandingClampModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit) { + // False-flag model: thin supports can be sampled as air, but the Y packet still lands on their quantized shape. + if (thisMove.yDistance >= -Magic.PREDICTION_EPSILON + || !isPartialSupportLandingBlock(to)) { + return false; + } + final double supportFraction = getPartialSupportLandingClampFraction(to); + if (supportFraction < 0.0D) { + return false; + } + final double landingDistance = to.getBlockY() + supportFraction - from.getY(); + return Math.abs(thisMove.yDistance - landingDistance) <= PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - landingDistance) + + PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON; + } + + private double getPartialSupportLandingClampFraction(final PlayerLocation to) { + final double fraction = to.getY() - to.getBlockY(); + final double clamp = Math.round(fraction / PARTIAL_SUPPORT_VERTICAL_CLAMP_UNIT) + * PARTIAL_SUPPORT_VERTICAL_CLAMP_UNIT; + return Math.abs(fraction - clamp) <= PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON ? clamp : -1.0D; + } + + private boolean isPartialSupportLandingBlock(final PlayerLocation to) { + return isPartialSupportBlock(to.getBlockTypeBelow()) || isPartialSupportBlock(to.getBlockType()); + } + + private double getPartialSupportVerticalClampModel(final PlayerLocation from, final PlayerLocation to, + final double yDistance) { + final double descend = -yDistance; + final boolean snowSupport = from != null && to != null && isSnowSupportNear(from, to); + final double maxDescend = snowSupport + ? SNOW_SUPPORT_MAX_COLLISION_HEIGHT : PARTIAL_SUPPORT_VERTICAL_CLAMP_MAX_DESCEND; + final double unit = snowSupport ? SNOW_SUPPORT_LAYER_HEIGHT : PARTIAL_SUPPORT_VERTICAL_CLAMP_UNIT; + if (descend <= Magic.PREDICTION_EPSILON + || descend > maxDescend) { + return 0.0D; + } + final double clamp = Math.round(descend / unit) * unit; + if (clamp <= 0.0D + || Math.abs(descend - clamp) > PARTIAL_SUPPORT_VERTICAL_CLAMP_EPSILON) { + return 0.0D; + } + return clamp; + } + + private double getWaterHorizontalModelLimit(final Player player, final PlayerMoveData thisMove, + final boolean dolphinGrace) { + // False-flag model: water H is driven by current server velocity plus liquid input acceleration. + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double inputAcceleration = Magic.LIQUID_ACCELERATION * getHorizontalInputScale(thisMove); + final double velocityModel = velocityH > Magic.PREDICTION_EPSILON + ? velocityH + inputAcceleration + WATER_HORIZONTAL_MODEL_EPSILON : 0.0D; + final double allowedModel = thisMove.hAllowedDistance + inputAcceleration + WATER_HORIZONTAL_MODEL_EPSILON; + if (!dolphinGrace) { + return Math.max(thisMove.hAllowedDistance, Math.max(velocityModel, allowedModel)); + } + final double dolphinAmplifier = Math.max(0.0D, Bridge1_13.getDolphinGraceAmplifier(player) + 1.0D); + final double dolphinModel = allowedModel + dolphinAmplifier * Magic.LIQUID_ACCELERATION * 4.0D; + return Math.max(dolphinModel, Math.max(velocityModel, thisMove.hAllowedDistance)); + } + + private boolean acceptsWaterQueuedVelocityHorizontalModel(final MovingData data, final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + final List queued = data.getHorizontalVelocityTracker().peekCovering(thisMove.xDistance, thisMove.zDistance, + 1, Integer.MAX_VALUE, WATER_QUEUED_VELOCITY_HORIZONTAL_RESIDUAL); + if (queued.isEmpty()) { + tags.add("water_queued_velocity_horizontal_model_miss"); + return false; + } + final double queuedLimit = getHorizontalVelocityAmount(queued) + + Magic.LIQUID_ACCELERATION * getHorizontalInputScale(thisMove) + + WATER_QUEUED_VELOCITY_HORIZONTAL_RESIDUAL; + if (thisMove.hDistance > queuedLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + queuedLimit, WATER_QUEUED_VELOCITY_HORIZONTAL_RESIDUAL)) { + tags.add("water_queued_velocity_horizontal_limit_miss"); + return false; + } + // Water model: knockback/current packets can apply through liquid before the normal swim predictor catches up. + data.getHorizontalVelocityTracker().use(queued.get(0).tick); + tags.add("water_queued_velocity_horizontal_model"); + return true; + } + + private double getHorizontalVelocityAmount(final List queued) { + double x = 0.0D; + double z = 0.0D; + final int tick = queued.get(0).tick; + for (final PairEntry entry : queued) { + if (entry.tick == tick) { + x += entry.x; + z += entry.z; + } + } + return MathUtil.dist(x, z); + } + + private double getPartialSupportHorizontalModelLimit(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + final double stepHeight = getPartialSupportStepHeightModel(from, to); + final double lastCarry = lastMove.toIsValid ? lastMove.hDistance : thisMove.hAllowedDistance; + final double inputCarry = thisMove.hasImpulse.decideOptimistically() + ? stepHeight * (isDiagonalImpulse(thisMove) ? 0.9D : 0.75D) : stepHeight * 0.35D; + return Math.max(thisMove.hAllowedDistance + stepHeight, lastCarry + inputCarry); + } + + private double getPartialSupportVerticalModelLimit(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + final double stepHeight = getPartialSupportStepHeightModel(from, to); + return Math.max(stepHeight, thisMove.yAllowedDistance + stepHeight); + } + + private double getBedrockStepHorizontalModelLimit(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + final double lastCarry = lastMove.toIsValid ? lastMove.hDistance : thisMove.hAllowedDistance; + final double inputCarry = BEDROCK_HALF_STEP_VERTICAL_MOVE + * (isDiagonalImpulse(thisMove) ? 0.9D : 0.75D); + return Math.max(getPartialSupportHorizontalModelLimit(from, to, thisMove, lastMove), + lastCarry + inputCarry + BEDROCK_HORIZONTAL_PREDICTION_EPSILON); + } + + private double getPartialSupportStepHeightModel(final PlayerLocation from, final PlayerLocation to) { + if (isSnowSupportNear(from, to)) { + // Snow model: layer collision is quantized in 1/8-block steps and can rise above a half block. + return Math.max(SNOW_SUPPORT_LAYER_HEIGHT, getSnowSupportHeightModel(from, to)); + } + return isPartialSupportNear(from, to) ? PARTIAL_SUPPORT_STEP_HEIGHT_MODEL : 0.0D; + } + + private double getHorizontalInputScale(final PlayerMoveData thisMove) { + if (!thisMove.hasImpulse.decideOptimistically() + && thisMove.forwardImpulse == ForwardDirection.NONE + && thisMove.strafeImpulse == StrafeDirection.NONE) { + return 0.0D; + } + return isDiagonalImpulse(thisMove) ? Math.sqrt(2.0D) : 1.0D; + } + + private boolean isDiagonalImpulse(final PlayerMoveData thisMove) { + return thisMove.forwardImpulse != ForwardDirection.NONE + && thisMove.strafeImpulse != StrafeDirection.NONE; + } + + private double getModelOverLimit(final double allowedDistance, final double modelLimit, final double epsilon) { + return Math.max(0.0D, modelLimit - allowedDistance) + epsilon; + } + + // Teleport/server-position models: clear stale movement history around async teleports, portals, respawn, and RTP-style jumps. + private boolean acceptsServerPositionJumpResyncHorizontalModel(final IPlayerData pData, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isServerPositionJumpResyncContext(pData, from, to, thisMove, lastMove)) { + return false; + } + final double limit = getServerPositionJumpResyncHorizontalModelLimit(thisMove, lastMove); + if (hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, SERVER_POSITION_JUMP_AIR_HORIZONTAL_RESIDUAL)) { + tags.add(isGroundishStepMove(from, to, thisMove) + ? "server_position_jump_ground_resync_horizontal_model" + : "server_position_jump_air_resync_horizontal_model"); + return true; + } + tags.add("server_position_jump_resync_horizontal_model_miss"); + return false; + } + + private double getServerPositionJumpResyncHorizontalModelLimit(final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double historyCarry = lastMove.toIsValid ? lastMove.hDistance : thisMove.hAllowedDistance; + final double inputCarry = playerInputHorizontalCarry(thisMove); + // Teleport model: keep post-teleport H to one normal input/history packet plus a small packet-order residual. + return Math.min(SERVER_POSITION_JUMP_HORIZONTAL_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance + SERVER_POSITION_JUMP_AIR_HORIZONTAL_RESIDUAL, + historyCarry + inputCarry + SERVER_POSITION_JUMP_AIR_HORIZONTAL_RESIDUAL)); + } + + private boolean acceptsServerPositionJumpResyncVerticalModel(final IPlayerData pData, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isServerPositionJumpResyncContext(pData, from, to, thisMove, lastMove)) { + return false; + } + final double horizontalLimit = getServerPositionJumpResyncHorizontalModelLimit(thisMove, lastMove); + if (hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, SERVER_POSITION_JUMP_AIR_HORIZONTAL_RESIDUAL)) { + tags.add("server_position_jump_resync_vertical_h_miss"); + return false; + } + final boolean firstAirPacketAfterResync = isFirstAirPacketAfterServerPositionJumpResync(from, to, thisMove, lastMove); + final double verticalModel = getServerPositionJumpResyncVerticalModel(thisMove, lastMove, firstAirPacketAfterResync); + if (matchesVerticalModel(thisMove.yDistance, verticalModel, SERVER_POSITION_JUMP_AIR_VERTICAL_RESIDUAL) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - verticalModel) + + SERVER_POSITION_JUMP_AIR_VERTICAL_RESIDUAL) { + if (firstAirPacketAfterResync) { + tags.add("server_position_jump_air_resync_first_gravity_model"); + } + tags.add("server_position_jump_air_resync_vertical_model"); + return true; + } + tags.add("server_position_jump_resync_vertical_model_miss"); + return false; + } + + private double getServerPositionJumpResyncVerticalModel(final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final boolean firstAirPacketAfterResync) { + if (!lastMove.toIsValid) { + if (firstAirPacketAfterResync) { + // Teleport model: the first air packet after stale movement history starts vanilla gravity from rest. + return -Magic.DEFAULT_GRAVITY * Magic.FRICTION_MEDIUM_AIR; + } + return thisMove.yAllowedDistance; + } + // Teleport model: after an async position jump, the next air packet should continue vanilla gravity from history. + return (lastMove.yDistance - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR; + } + + private boolean isFirstAirPacketAfterServerPositionJumpResync(final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return !lastMove.toIsValid + && thisMove.yDistance < 0.0D + && !from.isOnGroundOrResetCond() && !to.isOnGroundOrResetCond() + && !thisMove.from.onGroundOrResetCond && !thisMove.to.onGroundOrResetCond; + } + + // Bedrock movement models: keep Bedrock packet behavior separate unless it shares the exact same shape envelope as Java. + private boolean acceptsBedrockGroundedCombatHorizontalModel(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isBedrockGroundedCombatContext(player, pData, from, to, thisMove)) { + return false; + } + // Model cleanup: Bedrock combat is bounded by ground input plus the current server knockback/launch vector. + final double limit = Math.min(BEDROCK_GROUNDED_COMBAT_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, getGroundCombatHorizontalModelLimit(player, thisMove))); + return hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, BEDROCK_HORIZONTAL_PREDICTION_EPSILON); + } + + private boolean acceptsBedrockGroundedCombatVerticalModel(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isBedrockGroundedCombatContext(player, pData, from, to, thisMove) + || hDistanceAboveLimit > BEDROCK_GROUNDED_COMBAT_HORIZONTAL_OVER_GRACE) { + return false; + } + if (acceptsBedrockGroundVerticalSnapModel(thisMove, yDistanceAboveLimit)) { + tags.add("bedrock_grounded_combat_vertical_snap_model"); + return true; + } + if (acceptsBedrockAirGravityResetModel(thisMove, lastMove, yDistanceAboveLimit)) { + tags.add("bedrock_grounded_combat_air_gravity_reset_model"); + return true; + } + if (acceptsBedrockGroundVerticalQuantumModel(thisMove, yDistanceAboveLimit)) { + tags.add("bedrock_grounded_combat_vertical_quantum_model"); + return true; + } + final double verticalLimit = Math.min(BEDROCK_GROUNDED_COMBAT_VERTICAL_MOVE_GRACE, + Math.max(thisMove.yAllowedDistance, Math.abs(player.getVelocity().getY()) + + Magic.PREDICTION_EPSILON)); + return Math.abs(thisMove.yDistance) <= verticalLimit + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + verticalLimit, Magic.PREDICTION_EPSILON); + } + + private boolean acceptsBedrockGroundVerticalSnapModel(final PlayerMoveData thisMove, + final double yDistanceAboveLimit) { + /* + * Bedrock model: after combat/ground packets, Geyser can report the + * player as vertically snapped to the floor while Java prediction still + * carries the launch Y. Bound by the predicted Y difference itself. + */ + return Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && thisMove.yAllowedDistance > Magic.PREDICTION_EPSILON + && thisMove.yAllowedDistance <= BEDROCK_GROUNDED_COMBAT_VERTICAL_MOVE_GRACE + && yDistanceAboveLimit <= thisMove.yAllowedDistance + Magic.PREDICTION_EPSILON; + } + + private boolean acceptsBedrockAirGravityResetModel(final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit) { + /* + * Bedrock model: after a jump/step ascent, Geyser can expose the first + * air-gravity tick while Java prediction still carries the ascent. + */ + if (!lastMove.toIsValid + || lastMove.yDistance <= Magic.PREDICTION_EPSILON + || thisMove.yAllowedDistance <= Magic.PREDICTION_EPSILON) { + return false; + } + final double firstAirGravity = getAirInertiaFirstGravityModel(); + return matchesVerticalModel(thisMove.yDistance, firstAirGravity, + BEDROCK_AIR_GRAVITY_RESET_EPSILON) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - firstAirGravity) + + BEDROCK_AIR_GRAVITY_RESET_EPSILON; + } + + private boolean acceptsBedrockGroundVerticalQuantumModel(final PlayerMoveData thisMove, + final double yDistanceAboveLimit) { + /* + * Bedrock model: partial-block support can surface one 1/16th-block + * vertical quantum while Java's ground model predicts zero or slight + * descent. This is deliberately limited to that quantized step. + */ + final double quantum = Math.abs(thisMove.yDistance); + return thisMove.yAllowedDistance <= Magic.PREDICTION_EPSILON + && Math.abs(quantum - BEDROCK_GROUND_VERTICAL_QUANTUM) <= BEDROCK_GROUND_VERTICAL_QUANTUM_EPSILON + && yDistanceAboveLimit <= BEDROCK_GROUND_VERTICAL_QUANTUM + + BEDROCK_GROUND_VERTICAL_QUANTUM_EPSILON; + } + + private boolean acceptsBedrockStepHorizontalModel(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isBedrockStepContext(player, pData, from, to, thisMove, lastMove)) { + return false; + } + final double limit = getBedrockStepHorizontalModelLimit(from, to, thisMove, lastMove); + return hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, PARTIAL_SUPPORT_HORIZONTAL_MODEL_EPSILON); + } + + private boolean acceptsBedrockStepVerticalModel(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isBedrockStepContext(player, pData, from, to, thisMove, lastMove)) { + return false; + } + final double horizontalLimit = getBedrockStepHorizontalModelLimit(from, to, thisMove, lastMove); + if (hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, PARTIAL_SUPPORT_HORIZONTAL_MODEL_EPSILON)) { + return false; + } + if (isBedrockStepVerticalUndershootModel(thisMove) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance) + + PARTIAL_SUPPORT_VERTICAL_MODEL_EPSILON) { + tags.add("bedrock_step_vertical_undershoot_model"); + return true; + } + final double verticalModel = getBedrockStepVerticalModel(thisMove); + return yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + verticalModel, PARTIAL_SUPPORT_VERTICAL_MODEL_EPSILON); + } + + private boolean acceptsGroundedVerticalVelocityHorizontalModel(final Player player, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isGroundedVerticalVelocityContext(player, from, to, thisMove)) { + return false; + } + final double limit = getGroundedVerticalVelocityHorizontalModelLimit(player, thisMove); + return hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, BEDROCK_HORIZONTAL_PREDICTION_EPSILON); + } + + private boolean acceptsGroundedVerticalVelocityVerticalModel(final Player player, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isGroundedVerticalVelocityContext(player, from, to, thisMove)) { + return false; + } + final double horizontalLimit = getGroundedVerticalVelocityHorizontalModelLimit(player, thisMove); + if (hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, BEDROCK_HORIZONTAL_PREDICTION_EPSILON)) { + return false; + } + final double velocityY = player.getVelocity().getY(); + if (Math.abs(thisMove.yDistance - velocityY) <= CURRENT_SERVER_VELOCITY_VERTICAL_MATCH_GRACE + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - velocityY) + + CURRENT_SERVER_VELOCITY_VERTICAL_MATCH_GRACE) { + tags.add("grounded_vertical_velocity_y_model"); + return true; + } + if (acceptsGroundedVerticalVelocityGravityContinuationModel(thisMove, lastMove, yDistanceAboveLimit)) { + tags.add("grounded_vertical_velocity_gravity_continuation_model"); + return true; + } + if (acceptsGroundedBouncyVerticalVelocityModel(player, from, to, thisMove, yDistanceAboveLimit)) { + tags.add("grounded_bouncy_vertical_velocity_model"); + return true; + } + tags.add("grounded_vertical_velocity_y_model_miss"); + return false; + } + + private boolean acceptsGroundedVerticalVelocityGravityContinuationModel(final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit) { + /* + * Velocity/collision model: when vertical server velocity is truncated by + * collision, the next packet can match vanilla gravity continuation from + * the previous move better than the live Bukkit velocity vector. + */ + if (!lastMove.toIsValid) { + return false; + } + final double gravityModel = getAirInertiaVerticalModel(lastMove); + return matchesVerticalModel(thisMove.yDistance, gravityModel, AIR_INERTIA_VERTICAL_EPSILON) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - gravityModel) + + AIR_INERTIA_VERTICAL_EPSILON; + } + + private boolean acceptsGroundedBouncyVerticalVelocityModel(final Player player, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit) { + final double velocityY = player.getVelocity().getY(); + // Model cleanup: beds/slime can expose a positive server velocity while collision clips the actual Y packet. + return (from.isOnBouncyBlock() || to.isOnBouncyBlock() + || thisMove.from.onBouncyBlock || thisMove.to.onBouncyBlock) + && thisMove.collideY + && velocityY > Magic.PREDICTION_EPSILON + && thisMove.yDistance > Magic.PREDICTION_EPSILON + && thisMove.yDistance <= velocityY + CURRENT_SERVER_VELOCITY_VERTICAL_MATCH_GRACE + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - velocityY) + + CURRENT_SERVER_VELOCITY_VERTICAL_MATCH_GRACE; + } + + private boolean acceptsServerVerticalVelocityHorizontalModel(final Player player, final MovingData data, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isServerVerticalVelocityContext(player, data, from, to, thisMove)) { + return false; + } + final double limit = getServerVerticalVelocityHorizontalModelLimit(player, data, thisMove); + return hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, BEDROCK_HORIZONTAL_PREDICTION_EPSILON); + } + + private boolean acceptsGroundJumpTinyHorizontalModel(final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isGroundJumpTinyContext(thisMove)) { + return false; + } + final double limit = Math.min(GROUND_JUMP_TINY_HORIZONTAL_MOVE_GRACE, + thisMove.hAllowedDistance + GROUND_JUMP_TINY_HORIZONTAL_OVER_GRACE); + return hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, Magic.PREDICTION_EPSILON); + } + + private boolean acceptsGroundedItemResyncHorizontalModel(final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!isGroundedItemResyncContext(thisMove)) { + return false; + } + final double limit = Math.min(GROUNDED_ITEM_RESYNC_MOVE_GRACE, + thisMove.hAllowedDistance + GROUNDED_ITEM_RESYNC_HORIZONTAL_OVER_GRACE); + return hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, Magic.PREDICTION_EPSILON); + } + + private boolean acceptsCurrentServerVelocityVerticalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isCurrentServerVelocityVerticalContext(player, from, to, thisMove)) { + return false; + } + final double horizontalLimit = getCurrentServerVelocityHorizontalModelLimit(player, thisMove, lastMove); + // Model cleanup: the Y packet must match the current server velocity vector; only a tiny H miss is tolerated. + return yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + player.getVelocity().getY(), CURRENT_SERVER_VELOCITY_VERTICAL_MATCH_GRACE) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, CURRENT_SERVER_VELOCITY_HORIZONTAL_OVER_GRACE); + } + + private boolean acceptsCurrentServerVelocityHorizontalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isCurrentServerVelocityVerticalContext(player, from, to, thisMove)) { + return false; + } + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + if (velocityH > Magic.PREDICTION_EPSILON + && velocity.getX() * thisMove.xDistance + velocity.getZ() * thisMove.zDistance < -Magic.PREDICTION_EPSILON) { + return false; + } + // Model cleanup: pair the current server Y velocity with its H vector plus one normal input carry. + final double horizontalLimit = getCurrentServerVelocityHorizontalModelLimit(player, thisMove, lastMove); + return hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, CURRENT_SERVER_VELOCITY_HORIZONTAL_OVER_GRACE); + } + + private double getCurrentServerVelocityHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double inertiaH = lastMove.toIsValid ? getAirInertiaHorizontalModel(thisMove, lastMove) : 0.0D; + final double inputCarry = !lastMove.toIsValid + ? Math.max(playerInputHorizontalCarry(thisMove), LAST_INVALID_RESYNC_HORIZONTAL_INPUT_CARRY) + : playerInputHorizontalCarry(thisMove); + // Model cleanup: current server Y velocity can pair with either current H velocity or ordinary air H carry. + return Math.max(thisMove.hAllowedDistance, + Math.max(velocityH + inputCarry, inertiaH) + + CURRENT_SERVER_VELOCITY_HORIZONTAL_OVER_GRACE); + } + + private boolean acceptsLevitationVerticalModel(final Player player, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + return isLevitationMovementContext(player, thisMove) + && hDistanceAboveLimit <= LEVITATION_HORIZONTAL_OVER_GRACE + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + 0.0D, LEVITATION_STALL_OVER_GRACE); + } + + private double playerStepHorizontalModel(final PlayerMoveData thisMove) { + return thisMove.hAllowedDistance + playerInputHorizontalCarry(thisMove); + } + + private double getGroundCombatHorizontalModelLimit(final Player player, final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + double limit = velocityH + playerInputHorizontalCarry(thisMove) + BEDROCK_HORIZONTAL_PREDICTION_EPSILON; + final double verticalLaunch = Math.max(thisMove.yDistance, velocity.getY()); + if (verticalLaunch >= BEDROCK_STEP_VERTICAL_UNDERSHOOT_MIN_MODEL + && verticalLaunch <= BEDROCK_GROUNDED_COMBAT_VERTICAL_MOVE_GRACE) { + limit = Math.max(limit, verticalLaunch + BEDROCK_GROUNDED_COMBAT_VERTICAL_LAUNCH_HORIZONTAL_RESIDUAL); + } + return limit; + } + + private double getBedrockStepVerticalModel(final PlayerMoveData thisMove) { + if (Math.abs(thisMove.yDistance - BEDROCK_HALF_STEP_VERTICAL_MOVE) <= BEDROCK_HALF_STEP_VERTICAL_EPSILON) { + return BEDROCK_HALF_STEP_VERTICAL_MOVE; + } + if (isBedrockStepVerticalUndershootModel(thisMove)) { + // Bedrock step model: client may send the horizontal step packet before the Java Y rise is visible. + return 0.0D; + } + return thisMove.yAllowedDistance; + } + + private boolean isBedrockStepVerticalUndershootModel(final PlayerMoveData thisMove) { + return Math.abs(thisMove.yDistance) <= BEDROCK_STEP_VERTICAL_UNDERSHOOT_MOVE_GRACE + && thisMove.yAllowedDistance >= BEDROCK_STEP_VERTICAL_UNDERSHOOT_MIN_MODEL; + } + + private double getGroundedVerticalVelocityHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + return Math.min(GROUNDED_VERTICAL_VELOCITY_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, velocityH + playerInputHorizontalCarry(thisMove) + + BEDROCK_HORIZONTAL_PREDICTION_EPSILON)); + } + + private double getServerVerticalVelocityHorizontalModelLimit(final Player player, final MovingData data, + final PlayerMoveData thisMove) { + double velocityH = MathUtil.dist(player.getVelocity().getX(), player.getVelocity().getZ()); + final List queued = data.getHorizontalVelocityTracker().peekCovering(thisMove.xDistance, thisMove.zDistance, + 1, Integer.MAX_VALUE, SERVER_VERTICAL_VELOCITY_HORIZONTAL_OVER_GRACE); + if (!queued.isEmpty()) { + velocityH = Math.max(velocityH, getHorizontalVelocityAmount(queued)); + } + return Math.min(SERVER_VERTICAL_VELOCITY_HORIZONTAL_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, velocityH + playerInputHorizontalCarry(thisMove) + + BEDROCK_HORIZONTAL_PREDICTION_EPSILON)); + } + + private double playerInputHorizontalCarry(final PlayerMoveData thisMove) { + return 0.20D * getHorizontalInputScale(thisMove); + } + + private double applyEnvironmentalHorizontalLeniency(final Player player, final IPlayerData pData, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (hDistanceAboveLimit <= 0.0) { + return hDistanceAboveLimit; + } + if (selectExplicitMovementModel(player, pData, data, from, to, thisMove, lastMove) != MovementModelBranch.NONE) { + return hDistanceAboveLimit; + } + // Model cleanup: the former environmental H graces now enter through selectExplicitMovementModel(). + return hDistanceAboveLimit; + } + + private boolean acceptsGlidingFireworkHorizontalModel(final Player player, final IPlayerData pData, + final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isGlidingFireworkModelContext(player, data, from, to, thisMove) + || !tags.contains(SurvivalFlyTags.GLIDE_HORIZONTAL_PREDICTION_MISS)) { + return false; + } + if (acceptsGlidingFireworkSkippedBoostHorizontalModel(player, pData, data, from, to, thisMove, lastMove, + hDistanceAboveLimit)) { + return true; + } + final double[] packetOrderModel = getFireworkPacketOrderModelVector(player, data, to, thisMove, 1); + final double horizontalLimit = getFireworkHorizontalModelLimit(player, thisMove, packetOrderModel, + GLIDING_FIREWORK_PACKET_ORDER_HORIZONTAL_RESIDUAL); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, GLIDING_HORIZONTAL_PRECISION_GRACE)) { + tags.add("elytra_firework_horizontal_model_limit_miss"); + return false; + } + if (horizontalFireworkVectorMatches(from, to, thisMove, packetOrderModel, + GLIDING_FIREWORK_PACKET_ORDER_HORIZONTAL_RESIDUAL, + GLIDING_FIREWORK_PERPENDICULAR_RESIDUAL)) { + tags.add("elytra_firework_packet_order_horizontal_model"); + return true; + } + final Vector velocity = player.getVelocity(); + if (horizontalFireworkVectorMatches(from, to, thisMove, + new double[] { velocity.getX(), velocity.getY(), velocity.getZ() }, + GLIDING_FIREWORK_PACKET_ORDER_HORIZONTAL_RESIDUAL, + GLIDING_FIREWORK_PERPENDICULAR_RESIDUAL)) { + tags.add("elytra_firework_current_velocity_horizontal_model"); + return true; + } + final Vector look = TrigUtil.getLookingDirection(to, player); + if (horizontalFireworkVectorMatches(from, to, thisMove, + new double[] { look.getX() * 1.5D, look.getY() * 1.5D, look.getZ() * 1.5D }, + GLIDING_FIREWORK_PACKET_ORDER_HORIZONTAL_RESIDUAL, + GLIDING_FIREWORK_PERPENDICULAR_RESIDUAL)) { + tags.add("elytra_firework_look_horizontal_model"); + return true; + } + tags.add("elytra_firework_horizontal_vector_miss"); + return false; + } + + private boolean acceptsGlidingFireworkSkippedBoostHorizontalModel(final Player player, final IPlayerData pData, + final MovingData data, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isGlidingFireworkModelContext(player, data, from, to, thisMove) + || !lastMove.toIsValid + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double[] skippedBoostModel = getGlidingNoFireworkModelVector(player, pData, data, to, lastMove); + final double horizontalLimit = Math.max(thisMove.hAllowedDistance, + MathUtil.dist(skippedBoostModel[0], skippedBoostModel[2]) + + GLIDING_FIREWORK_SKIPPED_BOOST_HORIZONTAL_RESIDUAL); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, GLIDING_FIREWORK_SKIPPED_BOOST_HORIZONTAL_RESIDUAL)) { + tags.add("elytra_firework_skipped_boost_horizontal_limit_miss"); + return false; + } + if (horizontalFireworkVectorMatches(from, to, thisMove, skippedBoostModel, + GLIDING_FIREWORK_SKIPPED_BOOST_HORIZONTAL_RESIDUAL, + GLIDING_FIREWORK_PERPENDICULAR_RESIDUAL)) { + tags.add("elytra_firework_skipped_boost_horizontal_model"); + return true; + } + tags.add("elytra_firework_skipped_boost_horizontal_vector_miss"); + return false; + } + + private boolean acceptsElytraEquippedFireworkHorizontalModel(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isElytraEquippedFireworkModelContext(player, data, from, to, thisMove)) { + return false; + } + final double[] packetOrderModel = getElytraEquippedFireworkModelVector(player, data, to, thisMove, lastMove); + final double horizontalLimit = getElytraEquippedFireworkHorizontalModelLimit(player, from, to, + thisMove, packetOrderModel); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_FIREWORK_HORIZONTAL_RESIDUAL)) { + tags.add("elytra_equipped_firework_horizontal_model_limit_miss"); + return false; + } + if (horizontalFireworkVectorMatches(from, to, thisMove, packetOrderModel, + ELYTRA_EQUIPPED_FIREWORK_HORIZONTAL_RESIDUAL, + GLIDING_FIREWORK_PERPENDICULAR_RESIDUAL)) { + tags.add("elytra_equipped_firework_packet_order_horizontal_model"); + return true; + } + final Vector velocity = player.getVelocity(); + if (horizontalFireworkVectorMatches(from, to, thisMove, + new double[] { velocity.getX(), velocity.getY(), velocity.getZ() }, + ELYTRA_EQUIPPED_FIREWORK_HORIZONTAL_RESIDUAL, + GLIDING_FIREWORK_PERPENDICULAR_RESIDUAL)) { + tags.add("elytra_equipped_firework_current_velocity_horizontal_model"); + return true; + } + tags.add("elytra_equipped_firework_horizontal_vector_miss"); + return false; + } + + private double getElytraEquippedFireworkHorizontalModelLimit(final Player player, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final double[] model) { + double limit = getFireworkHorizontalModelLimit(player, thisMove, model, + ELYTRA_EQUIPPED_FIREWORK_HORIZONTAL_RESIDUAL); + if (isGroundishStepMove(from, to, thisMove)) { + /* + * Elytra model: if Bukkit still reports "wearing elytra on ground" + * while a rocket boost is active, the packet can contain both the + * boost vector and one normal ground-input carry. + */ + final double modelH = MathUtil.dist(model[0], model[2]); + limit = Math.max(limit, Math.max(thisMove.hAllowedDistance, + modelH + playerInputHorizontalCarry(thisMove)) + + ELYTRA_EQUIPPED_FIREWORK_HORIZONTAL_RESIDUAL); + } + return limit; + } + + private boolean isGlidingFireworkModelContext(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + // Model cleanup: firework boost prediction is only valid while the client is actively gliding in open space. + return Bridge1_9.isGliding(player) + && data.fireworksBoostDuration > 0 + && tags.contains(SurvivalFlyTags.GLIDE_FIREWORK_ACTIVE) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isElytraEquippedFireworkModelContext(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + // Bedrock/modern client model: rockets can arrive while elytra is equipped before Bukkit reports gliding. + return Bridge1_9.isWearingElytra(player) + && !Bridge1_9.isGliding(player) + && data.fireworksBoostDuration > 0 + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private double[] getFireworkPacketOrderModelVector(final Player player, final MovingData data, + final PlayerLocation to, final PlayerMoveData thisMove, + final int extraTicks) { + double x = thisMove.xAllowedDistance; + double y = thisMove.yAllowedDistance; + double z = thisMove.zAllowedDistance; + for (int i = 0; i < extraTicks; i++) { + final double[] next = applyFireworkBoostTick(player, data, to, x, y, z); + x = next[0]; + y = next[1]; + z = next[2]; + } + return new double[] { x, y, z }; + } + + private double[] getElytraEquippedFireworkModelVector(final Player player, final MovingData data, + final PlayerLocation to, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final Vector velocity = player.getVelocity(); + final double baseX = lastMove.toIsValid ? lastMove.xDistance : velocity.getX(); + final double baseY = lastMove.toIsValid ? lastMove.yDistance : velocity.getY(); + final double baseZ = lastMove.toIsValid ? lastMove.zDistance : velocity.getZ(); + return applyFireworkBoostTick(player, data, to, baseX, baseY, baseZ); + } + + private double[] getFireworkInertialLookModelVector(final Player player, final MovingData data, + final PlayerLocation to, final PlayerMoveData lastMove) { + final Vector velocity = player.getVelocity(); + final double baseX = lastMove.toIsValid ? lastMove.xDistance : velocity.getX(); + final double baseY = lastMove.toIsValid ? lastMove.yDistance : velocity.getY(); + final double baseZ = lastMove.toIsValid ? lastMove.zDistance : velocity.getZ(); + return applyFireworkBoostTick(player, data, to, baseX, baseY, baseZ); + } + + private double[] applyFireworkBoostTick(final Player player, final MovingData data, final PlayerLocation to, + final double baseX, final double baseY, final double baseZ) { + // Model cleanup: mirror the vanilla firework boost step instead of accepting the actual packet distance. + final Vector look = TrigUtil.getLookingDirection(to, player); + double x = baseX + look.getX() * 0.1D + (look.getX() * 1.5D - baseX) * 0.5D; + double y = baseY + look.getY() * 0.1D + (look.getY() * 1.5D - baseY) * 0.5D; + double z = baseZ + look.getZ() * 0.1D + (look.getZ() * 1.5D - baseZ) * 0.5D; + x *= 0.99D; + y *= data.lastFrictionVertical; + z *= 0.99D; + return new double[] { x, y, z }; + } + + private double getFireworkHorizontalModelLimit(final Player player, final PlayerMoveData thisMove, + final double[] model, final double residual) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double modelH = MathUtil.dist(model[0], model[2]); + return Math.max(thisMove.hAllowedDistance, Math.max(velocityH, modelH)) + residual; + } + + private boolean horizontalFireworkVectorMatches(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final double[] model, + final double amountResidual, + final double perpendicularResidual) { + final double modelH = MathUtil.dist(model[0], model[2]); + if (modelH <= Magic.PREDICTION_EPSILON) { + return thisMove.hDistance <= amountResidual; + } + final double yawTurn = Math.min(90.0D, + Math.abs(getYawDelta(from.getYaw(), to.getYaw())) + GLIDING_FIREWORK_TURN_YAW_EXTRA); + final double turnPerpendicular = modelH * Math.sin(yawTurn * TrigUtil.toRadians) + perpendicularResidual; + return horizontalVelocityVectorMatches(model[0], model[2], thisMove.xDistance, thisMove.zDistance, + amountResidual, turnPerpendicular); + } + + private boolean verticalFireworkModelMatches(final Player player, final PlayerMoveData thisMove, + final double modelY, final double residual, + final double yDistanceAboveLimit) { + if (Math.abs(thisMove.yDistance - modelY) <= residual) { + return true; + } + final double velocityY = player.getVelocity().getY(); + final double verticalLimit = Math.max(Math.abs(modelY), Math.abs(velocityY)) + residual; + return Math.abs(thisMove.yDistance) <= verticalLimit + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - modelY) + residual; + } + + private boolean isPortalTransitionHorizontalGrace(final PlayerLocation from, final PlayerLocation to, + final MovingData data, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + // False-positive tuning: portal transitions can invalidate the previous move before the client finishes drifting. + return isPortalNear(from, to) + && (!lastMove.toIsValid || data.liftOffEnvelope == LiftOffEnvelope.UNKNOWN) + && hDistanceAboveLimit <= PORTAL_TRANSITION_HORIZONTAL_OVER_GRACE + && thisMove.hDistance <= PORTAL_TRANSITION_HORIZONTAL_MOVE_GRACE; + } + + private boolean isServerPositionJumpGroundResyncHorizontalGrace(final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + // Folia/teleport compatibility: NET_MOVING may grace a server-side position jump one packet before SurvivalFly catches up. + final NetData netData = pData.getGenericInstance(NetData.class); + final long age = netData.getServerPositionJumpGraceAge(System.currentTimeMillis()); + return age >= 0L + && age <= SERVER_POSITION_JUMP_SURVIVALFLY_GRACE_MS + && isGroundishStepMove(from, to, thisMove) + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && hDistanceAboveLimit <= SERVER_POSITION_JUMP_HORIZONTAL_OVER_GRACE + && thisMove.hDistance <= SERVER_POSITION_JUMP_HORIZONTAL_MOVE_GRACE + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean acceptsElytraEquippedQueuedVelocityHorizontalModel(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || !(data.getHorizontalVelocityTracker().hasQueued() || !thisMove.verVelUsed.isEmpty()) + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final List queued = data.getHorizontalVelocityTracker().peekCovering(thisMove.xDistance, + thisMove.zDistance, 1, Integer.MAX_VALUE, ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL); + final double horizontalLimit = getElytraEquippedQueuedVelocityHorizontalModelLimit(player, thisMove, queued); + final double verticalLimit = getElytraEquippedQueuedVelocityVerticalModelLimit(player, thisMove); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL)) { + tags.add("elytra_equipped_queued_velocity_horizontal_limit_miss"); + return false; + } + if (Math.abs(thisMove.yDistance) > verticalLimit) { + tags.add("elytra_equipped_queued_velocity_vertical_limit_miss"); + return false; + } + if (!queued.isEmpty()) { + data.getHorizontalVelocityTracker().use(queued.get(0).tick); + } + tags.add("elytra_equipped_queued_velocity_horizontal_model"); + return true; + } + + private double getElytraEquippedQueuedVelocityHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove, + final List queued) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double queuedH = queued.isEmpty() ? 0.0D : getHorizontalVelocityAmount(queued); + final double inputCarry = playerInputHorizontalCarry(thisMove); + // Model cleanup: queued elytra velocity is capped by the old empirical window, but derived from actual velocity vectors. + return Math.min(ELYTRA_EQUIPPED_QUEUED_VELOCITY_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, Math.max(velocityH, queuedH) + inputCarry + + ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL)); + } + + private double getElytraEquippedQueuedVelocityVerticalModelLimit(final Player player, + final PlayerMoveData thisMove) { + final double velocityY = Math.abs(player.getVelocity().getY()); + final double allowedY = Math.abs(thisMove.yAllowedDistance); + return Math.min(ELYTRA_EQUIPPED_QUEUED_VELOCITY_Y_GRACE, + Math.max(velocityY, allowedY) + ELYTRA_EQUIPPED_VELOCITY_VERTICAL_RESIDUAL); + } + + private boolean isBedrockGroundedCombatHorizontalGrace(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + // Bedrock compatibility: combat knockback packets can look like short grounded speed spikes. + if (!isBedrockPlayer(player, pData) + || Bridge1_9.isGliding(player) + || hDistanceAboveLimit > BEDROCK_GROUNDED_COMBAT_HORIZONTAL_OVER_GRACE + || thisMove.hDistance > BEDROCK_GROUNDED_COMBAT_MOVE_GRACE + || Math.abs(thisMove.yDistance) > BEDROCK_GROUNDED_COMBAT_VERTICAL_MOVE_GRACE + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + return isGroundedCombatMove(player, from, to, thisMove); + } + + private boolean isGroundedCombatMove(final Player player, final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + final boolean groundish = from.isOnGroundOrResetCond() || to.isOnGroundOrResetCond() + || thisMove.from.onGroundOrResetCond || thisMove.to.onGroundOrResetCond + || thisMove.touchedGround || thisMove.touchedGroundWorkaround + || tags.contains("onground_env") || tags.contains("v_air"); + if (!groundish) { + return false; + } + final double velocityY = Math.abs(player.getVelocity().getY()); + return !thisMove.hasImpulse.decideOptimistically() + || thisMove.hasAttackSlowDown + || velocityY > Magic.PREDICTION_EPSILON + && velocityY <= BEDROCK_GROUNDED_COMBAT_VERTICAL_VELOCITY_GRACE; + } + + private boolean isGroundedVerticalVelocityHorizontalGrace(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (Bridge1_9.isGliding(player) + || hDistanceAboveLimit > GROUNDED_VERTICAL_VELOCITY_HORIZONTAL_OVER_GRACE + || thisMove.hDistance > GROUNDED_VERTICAL_VELOCITY_MOVE_GRACE + || Math.abs(thisMove.yDistance) > GROUNDED_VERTICAL_VELOCITY_MOVE_Y_GRACE + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final boolean groundish = from.isOnGroundOrResetCond() || to.isOnGroundOrResetCond() + || thisMove.from.onGroundOrResetCond || thisMove.to.onGroundOrResetCond + || thisMove.touchedGround || thisMove.touchedGroundWorkaround + || tags.contains("onground_env") || tags.contains("v_air"); + final double velocityY = player.getVelocity().getY(); + return groundish + && velocityY > 0.30D && velocityY <= GROUNDED_VERTICAL_VELOCITY_MOVE_Y_GRACE + && (thisMove.collidesHorizontally || thisMove.collideY || tags.contains("v_air")); + } + + private boolean isBedrockStepHorizontalGrace(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + // Bedrock model: step packets can report the 0.5 rise before Java's horizontal prediction catches the carry. + final double limit = getBedrockStepHorizontalModelLimit(from, to, thisMove, lastMove); + if (!isBedrockPlayer(player, pData) + || Bridge1_9.isGliding(player) + || Math.abs(thisMove.yDistance - BEDROCK_HALF_STEP_VERTICAL_MOVE) > BEDROCK_HALF_STEP_VERTICAL_EPSILON + || !isGroundishStepMove(from, to, thisMove) + || !isStepBlockNear(from, to) + || thisMove.hDistance > limit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, limit, PARTIAL_SUPPORT_HORIZONTAL_MODEL_EPSILON)) { + return false; + } + return !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isServerVerticalVelocityHorizontalGrace(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (thisMove.verVelUsed.isEmpty() + || !data.getHorizontalVelocityTracker().hasQueued() + || hDistanceAboveLimit > SERVER_VERTICAL_VELOCITY_HORIZONTAL_OVER_GRACE + || thisMove.hDistance > SERVER_VERTICAL_VELOCITY_HORIZONTAL_MOVE_GRACE + || thisMove.yDistance <= 0.0D + || thisMove.yDistance > SERVER_VERTICAL_VELOCITY_ASCEND_GRACE + || Bridge1_9.isGliding(player)) { + return false; + } + final boolean groundish = from.isOnGroundOrResetCond() || to.isOnGroundOrResetCond() + || thisMove.from.onGroundOrResetCond || thisMove.to.onGroundOrResetCond + || thisMove.touchedGround || thisMove.touchedGroundWorkaround + || tags.contains("onground_env") || tags.contains("v_air"); + return groundish + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isQueuedVelocityHorizontalGrace(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (Bridge1_9.isGliding(player) + || !(data.getHorizontalVelocityTracker().hasQueued() || !thisMove.verVelUsed.isEmpty()) + || Math.abs(thisMove.yDistance) > QUEUED_VELOCITY_VERTICAL_MOVE_GRACE + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double limit = getQueuedVelocityHorizontalModelLimit(player, data, thisMove); + // Model cleanup: queued velocity uses the pending vector plus one input packet, not a flat H grace. + return thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, QUEUED_VELOCITY_HORIZONTAL_RESIDUAL); + } + + private double getQueuedVelocityHorizontalModelLimit(final Player player, final MovingData data, + final PlayerMoveData thisMove) { + final List queued = data.getHorizontalVelocityTracker().peekCovering(thisMove.xDistance, thisMove.zDistance, + 1, Integer.MAX_VALUE, QUEUED_VELOCITY_HORIZONTAL_RESIDUAL); + final Vector velocity = player.getVelocity(); + final double queuedH = queued.isEmpty() ? 0.0D : getHorizontalVelocityAmount(queued); + final double serverH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double model = Math.max(queuedH, serverH) + playerInputHorizontalCarry(thisMove) + + QUEUED_VELOCITY_HORIZONTAL_RESIDUAL; + return Math.min(QUEUED_VELOCITY_HORIZONTAL_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, model)); + } + + private boolean isModernVerticalImpulseHorizontalGrace(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + // False-flag model: 1.21+ impulse packets are accepted only if they match the server velocity vector. + final double horizontalLimit = getModernVerticalImpulseHorizontalModelLimit(player, thisMove); + final double verticalLimit = getModernVerticalImpulseVerticalModelLimit(player, thisMove); + return isModernMovementClient(pData) + && !Bridge1_9.isGliding(player) + && thisMove.yDistance > Magic.PREDICTION_EPSILON + && thisMove.yDistance <= verticalLimit + && thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, MODERN_VERTICAL_IMPULSE_HORIZONTAL_VELOCITY_GRACE) + && hasModernVerticalImpulseSource(player, thisMove) + && currentHorizontalVelocityMatches(player, thisMove, MODERN_VERTICAL_IMPULSE_HORIZONTAL_VELOCITY_GRACE) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isGroundJumpTinyHorizontalGrace(final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + // False-positive tuning: ordinary jump packets can miss X/Z by a centimeter or two on newer clients. + return (tags.contains("jump_env") || tags.contains("bunnyhop")) + && thisMove.yDistance > 0.0D + && thisMove.yDistance <= GROUNDED_JUMP_VERTICAL_MOVE_GRACE + && hDistanceAboveLimit <= GROUND_JUMP_TINY_HORIZONTAL_OVER_GRACE + && thisMove.hDistance <= GROUND_JUMP_TINY_HORIZONTAL_MOVE_GRACE; + } + + private boolean isGroundedItemResyncHorizontalGrace(final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + return tags.contains("itemresync") + && tags.contains("usingitem") + && tags.contains("onground_env") + && Math.abs(thisMove.yDistance) <= Magic.PREDICTION_EPSILON + && hDistanceAboveLimit <= GROUNDED_ITEM_RESYNC_HORIZONTAL_OVER_GRACE + && thisMove.hDistance <= GROUNDED_ITEM_RESYNC_MOVE_GRACE; + } + + private boolean isThinSupportHorizontalGrace(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + // False-positive tuning: thin supports can be valid while the surrounding movement model still looks like air. + return !Bridge1_9.isGliding(player) + && isThinSupportNear(from, to) + && isGroundishStepMove(from, to, thisMove) + && hDistanceAboveLimit <= THIN_SUPPORT_HORIZONTAL_OVER_GRACE + && thisMove.hDistance <= THIN_SUPPORT_HORIZONTAL_MOVE_GRACE + && thisMove.yDistance >= -Magic.PREDICTION_EPSILON + && thisMove.yDistance <= THIN_SUPPORT_VERTICAL_MOVE_GRACE + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isWaterHorizontalGrace(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + // False-flag model: reuse the liquid velocity model in legacy fallback paths. + final boolean inWater = from.isInWater() || to.isInWater() + || thisMove.from.inWater || thisMove.to.inWater; + if (!inWater) { + return false; + } + if (tags.contains("v_water") && acceptsWaterHorizontalModel(player, data, from, to, thisMove, hDistanceAboveLimit, true)) { + tags.add("water_dolphin_horizontal_model"); + return true; + } + return acceptsWaterHorizontalModel(player, data, from, to, thisMove, hDistanceAboveLimit, false); + } + + // Climbable models: vines, ladders, and scaffolding share climbable physics with surface-specific caps. + private ClimbableSurfaceModel getClimbableSurfaceModel(final PlayerLocation from, final PlayerLocation to) { + if (isScaffoldingNear(from, to)) { + return ClimbableSurfaceModel.SCAFFOLDING; + } + if (isVineClimbableNear(from, to)) { + return ClimbableSurfaceModel.VINES; + } + return ClimbableSurfaceModel.GENERIC; + } + + private double getClimbableHorizontalModelLimit(final ClimbableSurfaceModel model, + final PlayerMoveData thisMove) { + // Model cleanup: vanilla clamps climbable X/Z per axis, so diagonal vine movement can exceed the old scalar cap. + final double axisClampCap = Math.max(model.horizontalMoveCap, CLIMBABLE_DIAGONAL_AXIS_CAP); + final double climbableLimit = Math.min(axisClampCap, + thisMove.hAllowedDistance + playerInputHorizontalCarry(thisMove) + model.horizontalResidual); + return Math.max(thisMove.hAllowedDistance + model.horizontalResidual, climbableLimit); + } + + private boolean isScaffoldingNear(final PlayerLocation from, final PlayerLocation to) { + return isScaffoldingBlock(from.getBlockType()) + || isScaffoldingBlock(from.getBlockTypeBelow()) + || isScaffoldingBlock(to.getBlockType()) + || isScaffoldingBlock(to.getBlockTypeBelow()); + } + + private boolean isScaffoldingBlock(final Material material) { + return material != null && material == BridgeMaterial.SCAFFOLDING; + } + + private boolean isVineClimbableNear(final PlayerLocation from, final PlayerLocation to) { + return isVineClimbableBlock(from.getBlockType()) + || isVineClimbableBlock(from.getBlockTypeBelow()) + || isVineClimbableBlock(to.getBlockType()) + || isVineClimbableBlock(to.getBlockTypeBelow()); + } + + private boolean isVineClimbableBlock(final Material material) { + return material != null && material.name().contains("VINE"); + } + + private boolean acceptsBaseClimbableHorizontalModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + // Model cleanup: vines, ladders, and scaffolding use climbable drag instead of the open-air H envelope. + final boolean climbable = from.isOnClimbable() || to.isOnClimbable() + || thisMove.from.onClimbable || thisMove.to.onClimbable; + final ClimbableSurfaceModel model = getClimbableSurfaceModel(from, to); + final double horizontalLimit = getClimbableHorizontalModelLimit(model, thisMove); + final double ascendLimit = getClimbableAscendModelLimit(model, thisMove); + final double descendLimit = getClimbableDescendModelLimit(model, thisMove); + final boolean accepted = climbable + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid + && thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, model.horizontalResidual) + && thisMove.yDistance >= -descendLimit + && thisMove.yDistance <= ascendLimit; + if (accepted) { + tags.add(model.tag + "_horizontal_model"); + if (ascendLimit > model.ascendLimit || descendLimit > model.descendLimit) { + tags.add(model.tag + "_vertical_envelope_horizontal_model"); + } + } + return accepted; + } + + private double getClimbableAscendModelLimit(final ClimbableSurfaceModel model, + final PlayerMoveData thisMove) { + // Model cleanup: entering vines can preserve a server-predicted upward packet; use that Y envelope instead of a flat grace. + final double predictedAscend = Math.max(thisMove.yAllowedDistance, 0.0D) + + model.verticalPrecision; + return Math.max(model.ascendLimit, predictedAscend); + } + + private double getClimbableDescendModelLimit(final ClimbableSurfaceModel model, + final PlayerMoveData thisMove) { + // Model cleanup: falling into vines can keep the normal gravity packet while horizontal motion is already climbable-clamped. + final double predictedDescend = Math.max(-thisMove.yAllowedDistance, 0.0D) + + model.verticalPrecision; + return Math.max(model.descendLimit, predictedDescend); + } + + private boolean acceptsClimbableHorizontalModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (acceptsBaseClimbableHorizontalModel(from, to, thisMove, hDistanceAboveLimit)) { + return true; + } + if (acceptsClimbableEntryCarryHorizontalModel(from, to, thisMove, hDistanceAboveLimit)) { + return true; + } + return acceptsClimbableJumpCarryHorizontalModel(from, to, thisMove, lastMove, hDistanceAboveLimit); + } + + private boolean acceptsClimbableEntryCarryHorizontalModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + final boolean climbable = from.isOnClimbable() || to.isOnClimbable() + || thisMove.from.onClimbable || thisMove.to.onClimbable; + if (!climbable + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid + || Math.abs(thisMove.yDistance) > CLIMBABLE_VERTICAL_PRECISION_GRACE + || !(from.isOnGroundOrResetCond() || to.isOnGroundOrResetCond() + || thisMove.from.onGroundOrResetCond || thisMove.to.onGroundOrResetCond)) { + return false; + } + /* + * Climbable model: entering vines/ladders can keep one ground-input + * horizontal packet while vertical motion is clamped to zero. + */ + final ClimbableSurfaceModel model = getClimbableSurfaceModel(from, to); + final double limit = Math.max(getClimbableHorizontalModelLimit(model, thisMove), + CLIMBABLE_ENTRY_HORIZONTAL_CARRY); + if (thisMove.hDistance <= limit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + limit, model.horizontalResidual)) { + tags.add(model.tag + "_entry_horizontal_model"); + return true; + } + tags.add(model.tag + "_entry_horizontal_model_miss"); + return false; + } + + private boolean acceptsClimbableJumpCarryHorizontalModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + final boolean climbable = from.isOnClimbable() || to.isOnClimbable() + || thisMove.from.onClimbable || thisMove.to.onClimbable; + final ClimbableSurfaceModel model = getClimbableSurfaceModel(from, to); + if (!climbable + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid + || !lastMove.toIsValid + || !(tags.contains("jump_env") || thisMove.isJump) + || thisMove.yDistance < model.ascendLimit + || thisMove.yDistance > GROUNDED_JUMP_VERTICAL_MOVE_GRACE) { + return false; + } + // Climbable model: scaffolding/ladder jumps keep last-tick horizontal carry even when current input is zero. + final double horizontalLimit = Math.max(model.horizontalMoveCap, + lastMove.hDistance + CLIMBABLE_JUMP_CARRY_HORIZONTAL_EPSILON); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, CLIMBABLE_JUMP_CARRY_HORIZONTAL_EPSILON)) { + tags.add(model.tag + "_jump_carry_horizontal_model"); + return true; + } + tags.add(model.tag + "_jump_carry_horizontal_model_miss"); + return false; + } + + private boolean acceptsElytraEquippedVelocityHorizontalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + return acceptsElytraEquippedVelocityMoveModel(player, from, to, thisMove, lastMove, 0.0D, hDistanceAboveLimit, false); + } + + private boolean acceptsElytraEquippedGroundHorizontalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || !isGroundishStepMove(from, to, thisMove) + || Math.abs(thisMove.yDistance) > ELYTRA_EQUIPPED_GROUND_STEP_VERTICAL_GRACE + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double horizontalLimit = getElytraEquippedGroundHorizontalModelLimit(player, thisMove, lastMove); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_GROUND_HORIZONTAL_OVER_GRACE)) { + tags.add("elytra_equipped_ground_horizontal_model"); + return true; + } + tags.add("elytra_equipped_ground_horizontal_model_miss"); + return false; + } + + private double getElytraEquippedGroundHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double lastCarry = lastMove.toIsValid ? lastMove.hDistance : thisMove.hAllowedDistance; + final double landingInertia = lastMove.toIsValid ? lastMove.hDistance * Magic.AIR_HORIZONTAL_INERTIA : 0.0D; + // Model cleanup: pre-glide ground packets use normal step input, server velocity, or one landing-inertia tick. + return Math.min(ELYTRA_EQUIPPED_GROUND_MOVE_GRACE, + Math.max(Math.max(playerStepHorizontalModel(thisMove), + Math.max(lastCarry, landingInertia) + ELYTRA_EQUIPPED_GROUND_HORIZONTAL_OVER_GRACE), + velocityH + playerInputHorizontalCarry(thisMove) + + ELYTRA_EQUIPPED_GROUND_HORIZONTAL_OVER_GRACE)); + } + + private boolean acceptsElytraEquippedGlideCoastHorizontalModel(final Player player, + final IPlayerData pData, + final MovingData data, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + final double[] model = getElytraEquippedGlideCoastModel(player, pData, data, from, to, thisMove, lastMove); + if (model == null) { + return false; + } + final double horizontalLimit = Math.max(thisMove.hAllowedDistance, + MathUtil.dist(model[0], model[2]) + ELYTRA_EQUIPPED_GLIDE_COAST_HORIZONTAL_RESIDUAL); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_GLIDE_COAST_HORIZONTAL_RESIDUAL) + && horizontalVelocityVectorMatches(model[0], model[2], thisMove.xDistance, thisMove.zDistance, + ELYTRA_EQUIPPED_GLIDE_COAST_HORIZONTAL_RESIDUAL, + ELYTRA_EQUIPPED_GLIDE_COAST_PERPENDICULAR_RESIDUAL)) { + tags.add("elytra_equipped_glide_coast_horizontal_model"); + return true; + } + tags.add("elytra_equipped_glide_coast_horizontal_model_miss"); + return false; + } + + private boolean acceptsElytraEquippedGlideCoastVerticalModel(final Player player, + final IPlayerData pData, + final MovingData data, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + final double[] model = getElytraEquippedGlideCoastModel(player, pData, data, from, to, thisMove, lastMove); + if (model == null) { + return false; + } + final double horizontalLimit = Math.max(thisMove.hAllowedDistance, + MathUtil.dist(model[0], model[2]) + ELYTRA_EQUIPPED_GLIDE_COAST_HORIZONTAL_RESIDUAL); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_GLIDE_COAST_HORIZONTAL_RESIDUAL) + && horizontalVelocityVectorMatches(model[0], model[2], thisMove.xDistance, thisMove.zDistance, + ELYTRA_EQUIPPED_GLIDE_COAST_HORIZONTAL_RESIDUAL, + ELYTRA_EQUIPPED_GLIDE_COAST_PERPENDICULAR_RESIDUAL) + && matchesVerticalModel(thisMove.yDistance, model[1], ELYTRA_EQUIPPED_GLIDE_COAST_VERTICAL_RESIDUAL) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - model[1]) + + ELYTRA_EQUIPPED_GLIDE_COAST_VERTICAL_RESIDUAL) { + tags.add("elytra_equipped_glide_coast_vertical_model"); + return true; + } + tags.add("elytra_equipped_glide_coast_vertical_model_miss"); + return false; + } + + private double[] getElytraEquippedGlideCoastModel(final Player player, + final IPlayerData pData, + final MovingData data, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + /* + * Elytra transition model: Folia/Bukkit can clear the gliding flag before + * the client stops applying vanilla glide physics. In that case the next + * packet should match the normal no-firework glide tick, not walking or + * plain air gravity. + */ + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || data.fireworksBoostDuration > 0 + || !lastMove.toIsValid + || lastMove.hDistance < ELYTRA_EQUIPPED_GLIDE_COAST_MIN_LAST_HORIZONTAL + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return null; + } + return getGlidingNoFireworkModelVector(player, pData, data, to, lastMove); + } + + private boolean acceptsElytraEquippedDescendHorizontalModel(final Player player, + final PlayerMoveData thisMove, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || thisMove.yDistance >= 0.0D + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double horizontalLimit = getElytraEquippedDescendHorizontalModelLimit(player, thisMove); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_DESCEND_HORIZONTAL_OVER_GRACE)) { + tags.add("elytra_equipped_descend_horizontal_model"); + return true; + } + tags.add("elytra_equipped_descend_horizontal_model_miss"); + return false; + } + + private double getElytraEquippedDescendHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + // Model cleanup: descending pre-glide packets may carry current velocity plus one normal air-input tick. + return Math.min(ELYTRA_EQUIPPED_DESCEND_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, velocityH + Magic.AIR_ACCELERATION * getHorizontalInputScale(thisMove) + + ELYTRA_EQUIPPED_DESCEND_HORIZONTAL_OVER_GRACE)); + } + + private boolean acceptsElytraEquippedVelocityHandoffHorizontalModel(final Player player, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isElytraEquippedVelocityHandoffContext(player, from, to, thisMove, lastMove)) { + return false; + } + final double horizontalLimit = getElytraEquippedVelocityHandoffHorizontalModelLimit(player, thisMove); + return hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_VELOCITY_HANDOFF_HORIZONTAL_RESIDUAL); + } + + private boolean acceptsElytraGeometryStallHorizontalModel(final Player player, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hDistanceAboveLimit) { + if (!isElytraGeometryStallContext(player, thisMove, lastMove)) { + return false; + } + final double horizontalLimit = getElytraGeometryStallHorizontalModelLimit(thisMove, lastMove); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_GEOMETRY_STALL_HORIZONTAL_OVER_GRACE)) { + tags.add("elytra_geometry_stall_horizontal_model"); + return true; + } + tags.add("elytra_geometry_stall_horizontal_model_miss"); + return false; + } + + private double applyEnvironmentalVerticalLeniency(final Player player, final IPlayerData pData, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit, + final boolean resetFrom, final boolean resetTo) { + if (yDistanceAboveLimit <= 0.0) { + return yDistanceAboveLimit; + } + if (selectExplicitMovementModel(player, pData, data, from, to, thisMove, lastMove) != MovementModelBranch.NONE) { + return yDistanceAboveLimit; + } + // Model cleanup: the former environmental Y graces now enter through selectExplicitMovementModel(). + return yDistanceAboveLimit; + } + + private boolean isSetbackGravityRecoveryContext(final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove) { + return data.hasSetBack() + && data.timeSinceSetBack <= 5 + && (!lastMove.toIsValid || thisMove.multiMoveCount == 0) + && thisMove.hDistance <= Magic.NEGLIGIBLE_SPEED_THRESHOLD + && thisMove.yDistance < -Magic.PREDICTION_EPSILON + && thisMove.yDistance >= -SETBACK_GRAVITY_VERTICAL_GRACE + && !from.isOnGroundOrResetCond() && !to.isOnGroundOrResetCond() + && !thisMove.from.onGroundOrResetCond && !thisMove.to.onGroundOrResetCond + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid + && Math.abs(from.getY() - data.getSetBackY()) <= SETBACK_GRAVITY_SETBACK_Y_GRACE; + } + + private boolean acceptsSetbackGravityVerticalModel(final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isSetbackGravityRecoveryContext(data, from, to, thisMove, lastMove) + || hDistanceAboveLimit > GROUNDED_MICRO_HORIZONTAL_GRACE) { + return false; + } + final double verticalModel = getSetbackGravityVerticalModel(thisMove, lastMove); + if (thisMove.yDistance >= verticalModel - SETBACK_GRAVITY_OVER_GRACE + && thisMove.yDistance <= Magic.PREDICTION_EPSILON + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + verticalModel, SETBACK_GRAVITY_OVER_GRACE)) { + tags.add("setback_gravity_vertical_model"); + return true; + } + tags.add("setback_gravity_vertical_model_miss"); + return false; + } + + private double getSetbackGravityVerticalModel(final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + // Model cleanup: after a setback, the next air packet should be one gravity tick below the reset speed. + final double lastY = lastMove.toIsValid && lastMove.yDistance < 0.0D ? lastMove.yDistance : 0.0D; + return Math.max(-SETBACK_GRAVITY_VERTICAL_GRACE, + (lastY - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR); + } + + private boolean isLevitationVerticalGrace(final Player player, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + return !Double.isInfinite(Bridge1_9.getLevitationAmplifier(player)) + && hDistanceAboveLimit <= LEVITATION_HORIZONTAL_OVER_GRACE + && Math.abs(thisMove.yDistance) <= LEVITATION_STALL_VERTICAL_GRACE + && yDistanceAboveLimit <= LEVITATION_STALL_OVER_GRACE; + } + + private boolean acceptsGlidingVerticalPrecisionModel(final Player player, + final double yDistanceAboveLimit) { + final boolean accepted = Bridge1_9.isGliding(player) + && yDistanceAboveLimit <= GLIDING_VERTICAL_PRECISION_GRACE; + if (accepted) { + tags.add("glide_vertical_precision_model"); + } + return accepted; + } + + private boolean acceptsGlidingVelocityVerticalModel(final Player player, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + final boolean accepted = Bridge1_9.isGliding(player) + && yDistanceAboveLimit <= GLIDING_VELOCITY_VERTICAL_OVER_GRACE + && hDistanceAboveLimit <= GLIDING_VELOCITY_HORIZONTAL_OVER_GRACE + && thisMove.hDistance >= GLIDING_VELOCITY_MIN_HORIZONTAL_MOVE + && Math.abs(thisMove.yDistance) <= GLIDING_VELOCITY_MAX_VERTICAL_MOVE + && (tags.contains("hvel_current") || tags.contains("hvel")); + if (accepted) { + tags.add("glide_velocity_vertical_model"); + } + return accepted; + } + + private boolean isCurrentServerVelocityVerticalGrace(final Player player, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-positive tuning: normal falls/knockback can be modeled late but still match the server velocity. + return yDistanceAboveLimit <= CURRENT_SERVER_VELOCITY_VERTICAL_OVER_GRACE + && hDistanceAboveLimit <= CURRENT_SERVER_VELOCITY_HORIZONTAL_OVER_GRACE + && Math.abs(thisMove.yDistance - player.getVelocity().getY()) <= CURRENT_SERVER_VELOCITY_VERTICAL_MATCH_GRACE; + } + + private boolean isQueuedVelocityVerticalInertiaHandoffModel(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + /* + * Velocity/firework handoff model: the horizontal velocity packet can be + * visible one tick before the vertical prediction decays. In that case Y + * should remain inside the envelope between previous Y and next vanilla + * gravity Y, not inside a flat post-failure grace. + */ + if (Bridge1_9.isGliding(player) + || !lastMove.toIsValid + || thisMove.verVelUsed.size() > 0 + || hDistanceAboveLimit > Magic.PREDICTION_EPSILON + || thisMove.hDistance < QUEUED_VELOCITY_VERTICAL_INERTIA_MIN_H + || !(data.getHorizontalVelocityTracker().hasQueued() + || tags.contains("hvel_current") || tags.contains("hvel")) + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double gravityY = getAirInertiaVerticalModel(lastMove); + final double lower = Math.min(gravityY, lastMove.yDistance) - QUEUED_VELOCITY_VERTICAL_INERTIA_RESIDUAL; + final double upper = Math.max(gravityY, lastMove.yDistance) + QUEUED_VELOCITY_VERTICAL_INERTIA_RESIDUAL; + final double overLimit = Math.abs(thisMove.yAllowedDistance - upper) + + QUEUED_VELOCITY_VERTICAL_INERTIA_RESIDUAL; + final boolean accepted = thisMove.yDistance >= lower + && thisMove.yDistance <= upper + && yDistanceAboveLimit <= overLimit; + if (accepted) { + tags.add("queued_velocity_vertical_inertia_handoff_model"); + } + return accepted; + } + + private boolean isQueuedVelocityVerticalPacketOrderModel(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // Packet-order tolerance: horizontal velocity can be consumed while the tiny Y correction arrives one packet later. + return data.getHorizontalVelocityTracker().hasQueued() + && !Bridge1_9.isGliding(player) + && thisMove.verVelUsed.isEmpty() + && hDistanceAboveLimit <= Magic.PREDICTION_EPSILON + && thisMove.yDistance >= -Magic.PREDICTION_EPSILON + && thisMove.yDistance <= QUEUED_VELOCITY_VERTICAL_PACKET_ORDER_MOVE + && yDistanceAboveLimit <= QUEUED_VELOCITY_VERTICAL_PACKET_ORDER_OVER + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isCollisionVerticalCorrectionGrace(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-positive tuning: collision resolution can report one small Y correction after a recovery/edge packet. + final boolean collision = thisMove.collideX || thisMove.collideY || thisMove.collideZ + || thisMove.collidesHorizontally || thisMove.negligibleHorizontalCollision; + return collision + && !Bridge1_9.isGliding(player) + && (!lastMove.toIsValid || Math.abs(thisMove.yDistance - player.getVelocity().getY()) <= COLLISION_VERTICAL_CORRECTION_Y_MOVE_GRACE) + && yDistanceAboveLimit <= COLLISION_VERTICAL_CORRECTION_OVER_GRACE + && hDistanceAboveLimit <= COLLISION_VERTICAL_CORRECTION_HORIZONTAL_OVER_GRACE + && thisMove.hDistance <= COLLISION_VERTICAL_CORRECTION_MOVE_GRACE + && Math.abs(thisMove.yDistance) <= COLLISION_VERTICAL_CORRECTION_Y_MOVE_GRACE + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isWaterTagVerticalGrace(final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-positive tuning: v_water tags can persist one packet after the sampled locations read as air. + return tags.contains("v_water") + && yDistanceAboveLimit <= WATER_TAG_VERTICAL_OVER_GRACE + && hDistanceAboveLimit <= WATER_TAG_HORIZONTAL_OVER_GRACE; + } + + private boolean isLavaVerticalGrace(final Player player, final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-positive tuning: lava surface/exiting packets use liquid motion even when one sampled point reads as air. + if (Bridge1_9.isGliding(player) + || hDistanceAboveLimit > LAVA_HORIZONTAL_OVER_GRACE) { + return false; + } + final boolean inLava = from.isInLava() || to.isInLava() + || thisMove.from.inLava || thisMove.to.inLava || tags.contains("v_lava"); + if (!inLava) { + return false; + } + if (thisMove.yDistance > 0.0D + && thisMove.yDistance <= LAVA_ASCEND_MOVE_GRACE + && yDistanceAboveLimit <= LAVA_VERTICAL_OVER_GRACE) { + tags.add("lava_ascend_vertical_model"); + return true; + } + if (acceptsLavaCurrentVelocityVerticalModel(player, thisMove, yDistanceAboveLimit)) { + tags.add("lava_current_velocity_vertical_model"); + return true; + } + if (tags.contains("v_lava") + && yDistanceAboveLimit <= LAVA_TAG_VERTICAL_OVER_GRACE) { + tags.add("lava_tag_vertical_model"); + return true; + } + return false; + } + + private boolean acceptsLavaCurrentVelocityVerticalModel(final Player player, final PlayerMoveData thisMove, + final double yDistanceAboveLimit) { + final double velocityY = player.getVelocity().getY(); + if (!tags.contains("v_lava") + || velocityY >= -Magic.PREDICTION_EPSILON + || thisMove.yDistance >= -Magic.PREDICTION_EPSILON) { + return false; + } + // Lava model: a downward server velocity can be exposed before the next lava-friction/gravity packet is sampled. + final double verticalModel = velocityY + velocityY * Magic.LAVA_VERTICAL_INERTIA + - Magic.DEFAULT_GRAVITY / 4.0D; + return Math.abs(thisMove.yDistance - verticalModel) <= LAVA_CURRENT_VERTICAL_RESIDUAL + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - verticalModel) + + LAVA_CURRENT_VERTICAL_RESIDUAL; + } + + private boolean isPortalTransitionVerticalGrace(final PlayerLocation from, final PlayerLocation to, + final MovingData data, final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-positive tuning: portal collision is passable, so the first move after dimension sync can lack history. + return isPortalNear(from, to) + && (!lastMove.toIsValid || data.liftOffEnvelope == LiftOffEnvelope.UNKNOWN) + && yDistanceAboveLimit <= PORTAL_TRANSITION_VERTICAL_OVER_GRACE + && hDistanceAboveLimit <= PORTAL_TRANSITION_HORIZONTAL_OVER_GRACE + && Math.abs(thisMove.yDistance) <= PORTAL_TRANSITION_VERTICAL_MOVE_GRACE + && thisMove.hDistance <= PORTAL_TRANSITION_HORIZONTAL_MOVE_GRACE; + } + + private boolean acceptsElytraEquippedFireworkVerticalModel(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isElytraEquippedFireworkModelContext(player, data, from, to, thisMove)) { + return false; + } + final double[] packetOrderModel = getElytraEquippedFireworkModelVector(player, data, to, thisMove, lastMove); + final double horizontalLimit = getElytraEquippedFireworkHorizontalModelLimit(player, from, to, + thisMove, packetOrderModel); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_FIREWORK_HORIZONTAL_RESIDUAL)) { + tags.add("elytra_equipped_firework_vertical_h_miss"); + return false; + } + if (verticalFireworkModelMatches(player, thisMove, packetOrderModel[1], + ELYTRA_EQUIPPED_FIREWORK_VERTICAL_RESIDUAL, yDistanceAboveLimit)) { + tags.add("elytra_equipped_firework_packet_order_vertical_model"); + return true; + } + if (verticalFireworkModelMatches(player, thisMove, player.getVelocity().getY(), + ELYTRA_EQUIPPED_FIREWORK_VERTICAL_RESIDUAL, yDistanceAboveLimit)) { + tags.add("elytra_equipped_firework_current_velocity_vertical_model"); + return true; + } + tags.add("elytra_equipped_firework_vertical_model_miss"); + return false; + } + + private boolean acceptsGlidingFireworkVerticalModel(final Player player, final IPlayerData pData, + final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isGlidingFireworkModelContext(player, data, from, to, thisMove) + || !tags.contains(SurvivalFlyTags.GLIDE_VERTICAL_PREDICTION_MISS)) { + return false; + } + if (acceptsGlidingFireworkSkippedBoostVerticalModel(player, pData, data, from, to, + thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit)) { + return true; + } + if (acceptsGlidingFireworkGravityContinuationVerticalModel(player, data, from, to, + thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit)) { + return true; + } + final double[] packetOrderModel = getFireworkPacketOrderModelVector(player, data, to, thisMove, 1); + final double horizontalLimit = getFireworkHorizontalModelLimit(player, thisMove, packetOrderModel, + GLIDING_FIREWORK_PACKET_ORDER_HORIZONTAL_RESIDUAL); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, GLIDING_FIREWORK_PACKET_ORDER_HORIZONTAL_RESIDUAL)) { + tags.add("elytra_firework_vertical_h_miss"); + return false; + } + if (yDistanceAboveLimit <= GLIDING_FIREWORK_VERTICAL_PRECISION) { + tags.add("elytra_firework_vertical_precision_model"); + return true; + } + if (verticalFireworkModelMatches(player, thisMove, packetOrderModel[1], + GLIDING_FIREWORK_PACKET_ORDER_VERTICAL_RESIDUAL, yDistanceAboveLimit)) { + tags.add("elytra_firework_packet_order_vertical_model"); + return true; + } + if (verticalFireworkModelMatches(player, thisMove, player.getVelocity().getY(), + GLIDING_FIREWORK_PACKET_ORDER_VERTICAL_RESIDUAL, yDistanceAboveLimit)) { + tags.add("elytra_firework_current_velocity_vertical_model"); + return true; + } + final double[] inertialModel = getFireworkInertialLookModelVector(player, data, to, lastMove); + if (acceptsGlidingFireworkPartialVerticalEnvelopeModel(player, thisMove, lastMove, packetOrderModel, + inertialModel, yDistanceAboveLimit, hDistanceAboveLimit)) { + return true; + } + if (verticalFireworkModelMatches(player, thisMove, inertialModel[1], + GLIDING_FIREWORK_PACKET_ORDER_VERTICAL_RESIDUAL, yDistanceAboveLimit)) { + tags.add("elytra_firework_inertial_vertical_model"); + return true; + } + if (acceptsGlidingFireworkGroundProximityVerticalModel(from, to, thisMove, lastMove, + yDistanceAboveLimit, hDistanceAboveLimit)) { + return true; + } + tags.add("elytra_firework_vertical_model_miss"); + return false; + } + + private boolean acceptsGlidingFireworkPartialVerticalEnvelopeModel(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double[] packetOrderModel, + final double[] inertialModel, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!lastMove.toIsValid + || hDistanceAboveLimit > GLIDING_HORIZONTAL_PRECISION_GRACE + || !tags.contains(SurvivalFlyTags.GLIDE_VERTICAL_ACTUAL_BELOW_MODEL)) { + return false; + } + /* + * Elytra firework model: Folia/packet order can expose only part of the + * rocket Y for one packet. The packet must still sit inside the vanilla + * interval between gravity continuation and the computed boost vectors. + */ + final double gravityModel = getAirInertiaVerticalModel(lastMove); + final double velocityY = player.getVelocity().getY(); + final double boostModel = Math.max(Math.max(packetOrderModel[1], inertialModel[1]), velocityY); + final double lower = Math.min(gravityModel, boostModel) - GLIDING_FIREWORK_PARTIAL_VERTICAL_RESIDUAL; + final double upper = Math.max(gravityModel, boostModel) + GLIDING_FIREWORK_PARTIAL_VERTICAL_RESIDUAL; + if (thisMove.yDistance < lower || thisMove.yDistance > upper) { + tags.add("elytra_firework_partial_vertical_envelope_miss"); + return false; + } + final double nearestModel = Math.abs(thisMove.yDistance - gravityModel) <= Math.abs(thisMove.yDistance - boostModel) + ? gravityModel : boostModel; + if (yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - nearestModel) + + GLIDING_FIREWORK_PARTIAL_VERTICAL_RESIDUAL) { + tags.add("elytra_firework_partial_vertical_envelope_model"); + return true; + } + tags.add("elytra_firework_partial_vertical_over_miss"); + return false; + } + + private boolean acceptsGlidingFireworkSkippedBoostVerticalModel(final Player player, final IPlayerData pData, + final MovingData data, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isGlidingFireworkModelContext(player, data, from, to, thisMove) + || !tags.contains(SurvivalFlyTags.GLIDE_VERTICAL_PREDICTION_MISS) + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double[] skippedBoostModel = lastMove.toIsValid + ? getGlidingNoFireworkModelVector(player, pData, data, to, lastMove) + : new double[] { 0.0D, getAirInertiaFirstGravityModel(), 0.0D }; + final double horizontalLimit = Math.max(thisMove.hAllowedDistance, + MathUtil.dist(skippedBoostModel[0], skippedBoostModel[2]) + + GLIDING_FIREWORK_SKIPPED_BOOST_HORIZONTAL_RESIDUAL); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, GLIDING_FIREWORK_SKIPPED_BOOST_HORIZONTAL_RESIDUAL)) { + tags.add("elytra_firework_skipped_boost_horizontal_miss"); + return false; + } + if (matchesVerticalModel(thisMove.yDistance, skippedBoostModel[1], + GLIDING_FIREWORK_SKIPPED_BOOST_VERTICAL_RESIDUAL) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - skippedBoostModel[1]) + + GLIDING_FIREWORK_SKIPPED_BOOST_VERTICAL_RESIDUAL) { + tags.add(lastMove.toIsValid + ? "elytra_firework_skipped_boost_vertical_model" + : "elytra_firework_last_invalid_gravity_vertical_model"); + return true; + } + tags.add("elytra_firework_skipped_boost_vertical_miss"); + return false; + } + + private boolean acceptsGlidingFireworkGravityContinuationVerticalModel(final Player player, + final MovingData data, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isGlidingFireworkModelContext(player, data, from, to, thisMove) + || !tags.contains(SurvivalFlyTags.GLIDE_VERTICAL_PREDICTION_MISS) + || !lastMove.toIsValid + || hDistanceAboveLimit > GLIDING_HORIZONTAL_PRECISION_GRACE) { + return false; + } + final double gravityModel = getAirInertiaVerticalModel(lastMove); + if (matchesVerticalModel(thisMove.yDistance, gravityModel, + GLIDING_FIREWORK_GRAVITY_VERTICAL_MATCH) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - gravityModel) + + GLIDING_FIREWORK_GRAVITY_VERTICAL_MATCH) { + /* + * Elytra firework model: some rocket packet-order cases skip the + * boost Y for one packet but still continue vanilla gravity exactly. + */ + tags.add("elytra_firework_gravity_continuation_vertical_model"); + return true; + } + tags.add("elytra_firework_gravity_continuation_vertical_model_miss"); + return false; + } + + private double[] getGlidingNoFireworkModelVector(final Player player, final IPlayerData pData, + final MovingData data, final PlayerLocation to, + final PlayerMoveData lastMove) { + // Model cleanup: firework packet order can skip the boost while still applying the normal glide tick. + final CombinedData cData = pData.getGenericInstance(CombinedData.class); + double x = lastMove.toIsValid ? lastMove.xDistance : 0.0D; + double y = lastMove.toIsValid ? lastMove.yDistance : 0.0D; + double z = lastMove.toIsValid ? lastMove.zDistance : 0.0D; + final Vector viewVector = TrigUtil.getLookingDirection(to, player); + final float radianPitch = to.getPitch() * TrigUtil.toRadians; + final double viewVecHorizontalLength = MathUtil.dist(viewVector.getX(), viewVector.getZ()); + final double thisMoveHDistance = MathUtil.dist(x, z); + final double viewVectorLength = viewVector.length(); + final ClientVersion clientVersion = getMovementClientVersion(pData); + double cosPitch = clientVersion.isAtMost(ClientVersion.V_1_18_2) + ? TrigUtil.cos((double) radianPitch) : Math.cos((double) radianPitch); + cosPitch = cosPitch * cosPitch * Math.min(1.0D, viewVectorLength / 0.4D); + y += (cData.wasSlowFalling && lastMove.yDistance <= 0.0D ? Magic.SLOW_FALL_GRAVITY : Magic.DEFAULT_GRAVITY) + * (-1.0D + cosPitch * 0.75D); + double baseSpeed; + if (y < 0.0D && viewVecHorizontalLength > 0.0D) { + baseSpeed = y * -0.1D * cosPitch; + x += viewVector.getX() * baseSpeed / viewVecHorizontalLength; + y += baseSpeed; + z += viewVector.getZ() * baseSpeed / viewVecHorizontalLength; + } + if (radianPitch < 0.0D && viewVecHorizontalLength > 0.0D) { + baseSpeed = thisMoveHDistance * (double) (-TrigUtil.sin(radianPitch)) * 0.04D; + x += -viewVector.getX() * baseSpeed / viewVecHorizontalLength; + y += baseSpeed * 3.2D; + z += -viewVector.getZ() * baseSpeed / viewVecHorizontalLength; + } + if (viewVecHorizontalLength > 0.0D) { + x += (viewVector.getX() / viewVecHorizontalLength * thisMoveHDistance - x) * 0.1D; + z += (viewVector.getZ() / viewVecHorizontalLength * thisMoveHDistance - z) * 0.1D; + } + x *= 0.99D; + y *= data.lastFrictionVertical; + z *= 0.99D; + return new double[] { x, y, z }; + } + + private boolean acceptsGlidingFireworkGroundProximityVerticalModel(final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!lastMove.toIsValid + || !tags.contains(SurvivalFlyTags.GLIDE_VERTICAL_ACTUAL_BELOW_MODEL) + || thisMove.yDistance >= -Magic.PREDICTION_EPSILON + || hDistanceAboveLimit > GLIDING_HORIZONTAL_PRECISION_GRACE + || thisMove.hDistance > thisMove.hAllowedDistance + GLIDING_HORIZONTAL_PRECISION_GRACE + || !isGroundBlockBelow(from, to)) { + return false; + } + // Model cleanup: a rocket packet near ground can keep the normal fall tick instead of applying upward boost Y. + final double gravityModel = getAirInertiaVerticalModel(lastMove); + if (matchesVerticalModel(thisMove.yDistance, gravityModel, + GLIDING_FIREWORK_GROUND_PROXIMITY_VERTICAL_RESIDUAL) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - gravityModel) + + GLIDING_FIREWORK_GROUND_PROXIMITY_VERTICAL_RESIDUAL) { + tags.add("elytra_firework_ground_proximity_gravity_vertical_model"); + return true; + } + return false; + } + + private boolean isGroundBlockBelow(final PlayerLocation from, final PlayerLocation to) { + final Material fromBelow = from.getBlockTypeBelow(); + final Material toBelow = to.getBlockTypeBelow(); + return fromBelow != null && BlockProperties.isGround(fromBelow) + || toBelow != null && BlockProperties.isGround(toBelow); + } + + private boolean acceptsGlidingSmallVerticalPredictionModel(final Player player, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // Model cleanup: normal elytra glides can miss the vertical curve by a few centimeters only. + final boolean accepted = Bridge1_9.isGliding(player) + && tags.contains(SurvivalFlyTags.GLIDE_VERTICAL_PREDICTION_MISS) + && yDistanceAboveLimit <= GLIDING_VERTICAL_SMALL_MISS_GRACE + && thisMove.hDistance <= GLIDING_VERTICAL_SMALL_MISS_MOVE_GRACE + && (hDistanceAboveLimit <= GLIDING_HORIZONTAL_PRECISION_GRACE + || tags.contains("glide_current_velocity_horizontal_model")); + if (accepted) { + tags.add("glide_small_vertical_prediction_model"); + } + return accepted; + } + + private boolean acceptsGlidingBelowVerticalModel(final Player player, final MovingData data, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // Model cleanup: bound "below predicted" gliding by server velocity/rocket packet order instead of a flat fallback. + if (!Bridge1_9.isGliding(player) + || !tags.contains(SurvivalFlyTags.GLIDE_VERTICAL_ACTUAL_BELOW_MODEL) + || thisMove.yDistance > thisMove.yAllowedDistance + || hDistanceAboveLimit > GLIDING_HORIZONTAL_PRECISION_GRACE + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double velocityY = player.getVelocity().getY(); + if (currentGlidingFullVelocityMatches(player, thisMove, GLIDING_CURRENT_VELOCITY_VERTICAL_MATCH_GRACE) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - velocityY) + + GLIDING_CURRENT_VELOCITY_VERTICAL_MATCH_GRACE) { + /* + * Elytra model: steep dives can have an invalid previous move after + * resync, but the current server velocity vector still exactly owns + * the packet. Do not cap those by the generic below-model window. + */ + tags.add("glide_below_current_velocity_vector_model"); + return true; + } + final double lowerModel = Math.min(thisMove.yAllowedDistance, velocityY); + final double residual = data.fireworksBoostDuration > 0 + ? GLIDING_FIREWORK_PACKET_ORDER_VERTICAL_RESIDUAL + : GLIDING_VERTICAL_SMALL_MISS_GRACE; + final double empiricalCap = data.fireworksBoostDuration > 0 + ? GLIDING_VERTICAL_BELOW_MODEL_FIREWORK_GRACE + : GLIDING_VERTICAL_BELOW_MODEL_GRACE; + final boolean accepted = thisMove.yDistance >= lowerModel - residual + && yDistanceAboveLimit <= Math.min(empiricalCap, + Math.abs(thisMove.yAllowedDistance - lowerModel) + residual); + if (accepted) { + tags.add(data.fireworksBoostDuration > 0 + ? "glide_firework_below_velocity_vertical_model" + : "glide_below_velocity_vertical_model"); + } + return accepted; + } + + private boolean currentGlidingFullVelocityMatches(final Player player, final PlayerMoveData thisMove, + final double verticalResidual) { + return currentGlidingActualVelocityMatches(player, thisMove) + && Math.abs(thisMove.yDistance - player.getVelocity().getY()) <= verticalResidual; + } + + private boolean acceptsElytraEquippedDescendVerticalModel(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || thisMove.yDistance >= 0.0D + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double horizontalLimit = getElytraEquippedDescendHorizontalModelLimit(player, thisMove); + final double verticalModel = getElytraEquippedDescendVerticalModel(player, thisMove, lastMove); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_DESCEND_HORIZONTAL_OVER_GRACE) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - verticalModel) + + ELYTRA_EQUIPPED_DESCEND_VERTICAL_OVER_GRACE) { + tags.add("elytra_equipped_descend_vertical_model"); + return true; + } + tags.add("elytra_equipped_descend_vertical_model_miss"); + return false; + } + + private double getElytraEquippedDescendVerticalModel(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double velocityY = player.getVelocity().getY(); + double model = thisMove.yAllowedDistance; + if (velocityY < -Magic.PREDICTION_EPSILON && velocityY > model) { + /* + * Elytra-equipped transition model: when Bukkit has already exposed + * a slower downward velocity than the vanilla air prediction, use + * that server velocity as the upper fall boundary. This covers legit + * soft glide-exit/landing packets without widening true hover. + */ + model = velocityY; + } + if (lastMove.toIsValid && lastMove.yDistance < -Magic.PREDICTION_EPSILON + && (hasCollisionSignal(thisMove) || tags.contains("hvel") || tags.contains("hvel_current"))) { + // Model cleanup: a collision/velocity handoff can repeat the previous descending packet for one tick. + model = Math.max(model, lastMove.yDistance); + } + return model; + } + + private boolean acceptsElytraEquippedGlideExitVerticalModel(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + /* + * Elytra model: Bukkit can clear gliding one packet before the client + * applies normal air gravity. Keep the last glide Y for exactly this + * transition instead of treating it as a generic vertical grace. + */ + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || !lastMove.toIsValid + || lastMove.yDistance >= -Magic.PREDICTION_EPSILON + || hDistanceAboveLimit > GLIDING_HORIZONTAL_PRECISION_GRACE + || !(tags.contains("hvel_current") || tags.contains("hvel")) + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + if (matchesVerticalModel(thisMove.yDistance, lastMove.yDistance, + ELYTRA_EQUIPPED_GLIDE_EXIT_VERTICAL_MATCH) + && yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - lastMove.yDistance) + + ELYTRA_EQUIPPED_GLIDE_EXIT_VERTICAL_MATCH) { + tags.add("elytra_equipped_glide_exit_vertical_model"); + return true; + } + tags.add("elytra_equipped_glide_exit_vertical_model_miss"); + return false; + } + + private boolean acceptsElytraEquippedVelocityHandoffVerticalModel(final Player player, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isElytraEquippedVelocityHandoffContext(player, from, to, thisMove, lastMove)) { + return false; + } + final double horizontalLimit = getElytraEquippedVelocityHandoffHorizontalModelLimit(player, thisMove); + if (hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_VELOCITY_HANDOFF_HORIZONTAL_RESIDUAL)) { + return false; + } + // Elytra model: descending handoff packets must match current server Y velocity, not a wide hover grace. + return yDistanceAboveLimit <= Math.abs(thisMove.yAllowedDistance - player.getVelocity().getY()) + + ELYTRA_EQUIPPED_VELOCITY_HANDOFF_VERTICAL_MATCH; + } + + private boolean acceptsElytraEquippedNeutralVerticalModel(final Player player, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || Math.abs(thisMove.yDistance) > Magic.PREDICTION_EPSILON + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double horizontalLimit = Math.min(ELYTRA_EQUIPPED_NEUTRAL_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, MathUtil.dist(player.getVelocity().getX(), player.getVelocity().getZ()) + + ELYTRA_EQUIPPED_NEUTRAL_HORIZONTAL_OVER_GRACE)); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_NEUTRAL_HORIZONTAL_OVER_GRACE) + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + 0.0D, ELYTRA_EQUIPPED_NEUTRAL_VERTICAL_OVER_GRACE)) { + tags.add("elytra_equipped_neutral_vertical_model"); + return true; + } + tags.add("elytra_equipped_neutral_vertical_model_miss"); + return false; + } + + private boolean acceptsElytraEquippedLastInvalidAscendVerticalModel(final Player player, + final PlayerLocation from, + final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || lastMove.toIsValid + || thisMove.yDistance <= Magic.PREDICTION_EPSILON + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final boolean lowJumpContinuation = isElytraEquippedLastInvalidLowJumpContinuation(thisMove); + if (!lowJumpContinuation && thisMove.yDistance > ELYTRA_EQUIPPED_LAST_INVALID_ASCEND_MAX_MOVE) { + return false; + } + final double horizontalLimit = Math.max(thisMove.hAllowedDistance, + Math.max(MathUtil.dist(player.getVelocity().getX(), player.getVelocity().getZ()) + + playerInputHorizontalCarry(thisMove) + + ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL, + lowJumpContinuation ? getElytraEquippedGroundHorizontalModelLimit(player, thisMove, lastMove) : 0.0D)); + if (thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL)) { + tags.add("elytra_equipped_last_invalid_ascend_horizontal_miss"); + return false; + } + final double verticalModel = lowJumpContinuation + ? LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_MAX : Math.max(0.0D, player.getVelocity().getY()); + final double verticalResidual = lowJumpContinuation + ? LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_EPSILON + : ELYTRA_EQUIPPED_LAST_INVALID_ASCEND_VERTICAL_RESIDUAL; + if (thisMove.yDistance <= verticalModel + verticalResidual + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + verticalModel, verticalResidual)) { + /* + * Elytra model: after stale/invalid movement history, a low pre-glide launch + * packet can expose current server Y velocity, or one vanilla low-jump + * continuation while the player is still only wearing an elytra. + */ + tags.add(lowJumpContinuation ? "elytra_equipped_last_invalid_low_jump_vertical_model" + : "elytra_equipped_last_invalid_ascend_vertical_model"); + return true; + } + tags.add("elytra_equipped_last_invalid_ascend_vertical_model_miss"); + return false; + } + + private boolean isElytraEquippedLastInvalidLowJumpContinuation(final PlayerMoveData thisMove) { + return thisMove.yDistance >= LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_MIN + && thisMove.yDistance <= LAST_INVALID_LOW_JUMP_CONTINUATION_VERTICAL_MAX + && getHorizontalInputScale(thisMove) > 0.0D; + } + + private boolean acceptsElytraEquippedSmallVerticalResyncModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // Packet-order model: tiny non-gliding Y resync packets happen after item-use, collision, or velocity handoff while elytra is worn. + final boolean resyncContext = !lastMove.toIsValid + || tags.contains("itemresync") + || tags.contains("hvel") || tags.contains("hvel_current") + || thisMove.collideX || thisMove.collideY || thisMove.collideZ + || isGroundishStepMove(from, to, thisMove); + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || !resyncContext + || thisMove.yDistance < -Magic.PREDICTION_EPSILON + || thisMove.yDistance > ELYTRA_EQUIPPED_SMALL_VERTICAL_MOVE_GRACE + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double horizontalLimit = Math.min(ELYTRA_EQUIPPED_SMALL_VERTICAL_HORIZONTAL_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, MathUtil.dist(player.getVelocity().getX(), player.getVelocity().getZ()) + + playerInputHorizontalCarry(thisMove) + + ELYTRA_EQUIPPED_SMALL_VERTICAL_HORIZONTAL_OVER_GRACE)); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_SMALL_VERTICAL_HORIZONTAL_OVER_GRACE) + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + getElytraEquippedSmallResyncVerticalModel(player, thisMove, lastMove), + ELYTRA_EQUIPPED_SMALL_VERTICAL_OVER_GRACE)) { + tags.add("elytra_equipped_small_resync_vertical_model"); + return true; + } + tags.add("elytra_equipped_small_resync_vertical_model_miss"); + return false; + } + + private double getElytraEquippedSmallResyncVerticalModel(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double velocityY = Math.max(0.0D, player.getVelocity().getY()); + final double followUpY = lastMove.toIsValid + ? Math.max(0.0D, (lastMove.yDistance - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR) + : 0.0D; + return Math.min(ELYTRA_EQUIPPED_SMALL_VERTICAL_MOVE_GRACE, + Math.max(thisMove.yAllowedDistance, Math.max(velocityY, followUpY))); + } + + private boolean acceptsElytraEquippedVelocityVerticalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + return acceptsElytraEquippedVelocityMoveModel(player, from, to, thisMove, lastMove, yDistanceAboveLimit, hDistanceAboveLimit, true); + } + + private boolean isElytraEquippedHalfStepVerticalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // Elytra model: wearing an elytra must still allow the normal 0.5-block slab/stair step shape before gliding starts. + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || !isGroundishStepMove(from, to, thisMove) + || Math.abs(thisMove.yDistance - BEDROCK_HALF_STEP_VERTICAL_MOVE) > BEDROCK_HALF_STEP_VERTICAL_EPSILON + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double horizontalLimit = getBedrockStepHorizontalModelLimit(from, to, thisMove, lastMove); + if (thisMove.hDistance <= horizontalLimit + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + BEDROCK_HALF_STEP_VERTICAL_MOVE, PARTIAL_SUPPORT_VERTICAL_MODEL_EPSILON) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, PARTIAL_SUPPORT_HORIZONTAL_MODEL_EPSILON)) { + tags.add("elytra_equipped_half_step_vertical_model"); + return true; + } + tags.add("elytra_equipped_half_step_vertical_model_miss"); + return false; + } + + private boolean acceptsElytraEquippedStaleAscendVerticalModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || !lastMove.toIsValid + || lastMove.yDistance <= 0.25D + || thisMove.yDistance > Magic.PREDICTION_EPSILON + || player.getVelocity().getY() <= 0.10D + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double verticalModel = getElytraEquippedStaleAscendVerticalModel(player, lastMove); + final double horizontalLimit = getElytraEquippedStaleAscendHorizontalModelLimit(player, thisMove, lastMove); + if (thisMove.hDistance <= horizontalLimit + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_STALE_ASCEND_HORIZONTAL_OVER_GRACE) + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + verticalModel, ELYTRA_EQUIPPED_STALE_ASCEND_VERTICAL_OVER_GRACE)) { + tags.add("elytra_equipped_stale_ascend_vertical_model"); + return true; + } + tags.add("elytra_equipped_stale_ascend_vertical_model_miss"); + return false; + } + + private double getElytraEquippedStaleAscendVerticalModel(final Player player, + final PlayerMoveData lastMove) { + final double followUpY = (lastMove.yDistance - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR; + return Math.max(0.0D, Math.max(player.getVelocity().getY(), followUpY)); + } + + private double getElytraEquippedStaleAscendHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double velocityH = MathUtil.dist(player.getVelocity().getX(), player.getVelocity().getZ()); + return Math.min(ELYTRA_EQUIPPED_STALE_ASCEND_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, Math.max(velocityH, lastMove.hDistance) + + ELYTRA_EQUIPPED_STALE_ASCEND_HORIZONTAL_OVER_GRACE)); + } + + private boolean acceptsElytraEquippedVelocityMoveModel(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit, + final boolean checkVerticalLimit) { + // Elytra model: match the pre-glide launch packet to the server velocity handoff instead of a wide Y grace. + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || thisMove.yDistance <= 0.0D + || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid) { + return false; + } + final double verticalLimit = getElytraEquippedVelocityVerticalModelLimit(player, thisMove, lastMove); + final double horizontalLimit = getElytraEquippedVelocityHorizontalModelLimit(player, thisMove, lastMove); + if (thisMove.yDistance > verticalLimit + || thisMove.hDistance > horizontalLimit + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL) + || checkVerticalLimit && yDistanceAboveLimit > getModelOverLimit(thisMove.yAllowedDistance, + verticalLimit, ELYTRA_EQUIPPED_VELOCITY_VERTICAL_RESIDUAL)) { + return false; + } + final boolean groundish = from.isOnGroundOrResetCond() || to.isOnGroundOrResetCond() + || thisMove.from.onGroundOrResetCond || thisMove.to.onGroundOrResetCond + || thisMove.touchedGround || thisMove.touchedGroundWorkaround + || tags.contains("onground_env") || tags.contains("v_air"); + final boolean currentVelocity = currentMotionVectorMatches(player, thisMove, + ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL, ELYTRA_EQUIPPED_VELOCITY_VERTICAL_RESIDUAL); + final boolean followUp = matchesElytraVelocityFollowupModel(player, thisMove, lastMove); + if (currentVelocity) { + tags.add("elytra_equipped_current_velocity_model"); + return true; + } + if (followUp) { + tags.add("elytra_equipped_velocity_followup_model"); + return true; + } + if (groundish && player.getVelocity().getY() > 0.20D) { + tags.add("elytra_equipped_grounded_velocity_model"); + return true; + } + tags.add("elytra_equipped_velocity_model_miss"); + return false; + } + + private boolean isElytraEquippedVelocityHandoffContext(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return Bridge1_9.isWearingElytra(player) + && !Bridge1_9.isGliding(player) + && !lastMove.toIsValid + && thisMove.yDistance < -Magic.PREDICTION_EPSILON + && Math.abs(thisMove.yDistance - player.getVelocity().getY()) + <= ELYTRA_EQUIPPED_VELOCITY_HANDOFF_VERTICAL_MATCH + && thisMove.hDistance <= getElytraEquippedVelocityHandoffHorizontalModelLimit(player, thisMove) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private double getElytraEquippedVelocityHandoffHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double inputCarry = ELYTRA_EQUIPPED_VELOCITY_HANDOFF_HORIZONTAL_INPUT_CARRY + * getHorizontalInputScale(thisMove); + // Elytra model: after invalid history, combine the current server vector with one air-input packet. + return Math.min(ELYTRA_EQUIPPED_DESCEND_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, velocityH + inputCarry + + ELYTRA_EQUIPPED_VELOCITY_HANDOFF_HORIZONTAL_RESIDUAL)); + } + + private double getElytraEquippedVelocityVerticalModelLimit(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double velocityY = player.getVelocity().getY(); + final double followUpY = lastMove.toIsValid + ? Math.max(0.0D, (lastMove.yDistance - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR) + : 0.0D; + final double model = Math.max(velocityY, followUpY); + return Math.min(ELYTRA_EQUIPPED_VERTICAL_VELOCITY_Y_GRACE, + Math.max(model + ELYTRA_EQUIPPED_VELOCITY_VERTICAL_RESIDUAL, thisMove.yAllowedDistance)); + } + + private double getElytraEquippedVelocityHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double lastCarry = lastMove.toIsValid ? lastMove.hDistance : thisMove.hAllowedDistance; + final double model = Math.max(velocityH, lastCarry); + return Math.min(ELYTRA_EQUIPPED_VERTICAL_VELOCITY_MOVE_GRACE, + Math.max(model + ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL, thisMove.hAllowedDistance)); + } + + private boolean matchesElytraVelocityFollowupModel(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + if (!lastMove.toIsValid || lastMove.yDistance <= 0.30D) { + return false; + } + final double expectedY = Math.max(player.getVelocity().getY(), + (lastMove.yDistance - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR); + final double expectedH = Math.max(MathUtil.dist(player.getVelocity().getX(), player.getVelocity().getZ()), + lastMove.hDistance); + return Math.abs(thisMove.yDistance - expectedY) <= ELYTRA_EQUIPPED_VELOCITY_FOLLOWUP_RESIDUAL + && thisMove.hDistance <= expectedH + ELYTRA_EQUIPPED_VELOCITY_HORIZONTAL_RESIDUAL; + } + + private boolean isBedrockGroundedCombatVerticalGrace(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // Bedrock compatibility: combat knockback can also show up as a small vertical model miss. + return isBedrockPlayer(player, pData) + && !Bridge1_9.isGliding(player) + && yDistanceAboveLimit <= BEDROCK_GROUNDED_COMBAT_VERTICAL_OVER_GRACE + && hDistanceAboveLimit <= BEDROCK_GROUNDED_COMBAT_HORIZONTAL_OVER_GRACE + && thisMove.hDistance <= BEDROCK_GROUNDED_COMBAT_MOVE_GRACE + && Math.abs(thisMove.yDistance) <= BEDROCK_GROUNDED_COMBAT_VERTICAL_MOVE_GRACE + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid + && isGroundedCombatMove(player, from, to, thisMove); + } + + private boolean isModernVerticalImpulseVerticalGrace(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-flag model: vertical impulse must fit the current server velocity vector. + final double horizontalLimit = getModernVerticalImpulseHorizontalModelLimit(player, thisMove); + final double verticalLimit = getModernVerticalImpulseVerticalModelLimit(player, thisMove); + return isModernMovementClient(pData) + && !Bridge1_9.isGliding(player) + && thisMove.yDistance > Magic.PREDICTION_EPSILON + && thisMove.yDistance <= verticalLimit + && thisMove.hDistance <= horizontalLimit + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + verticalLimit, MODERN_VERTICAL_IMPULSE_VERTICAL_RESIDUAL) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, MODERN_VERTICAL_IMPULSE_HORIZONTAL_VELOCITY_GRACE) + && hasModernVerticalImpulseSource(player, thisMove) + && currentHorizontalVelocityMatches(player, thisMove, MODERN_VERTICAL_IMPULSE_HORIZONTAL_VELOCITY_GRACE) + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private double getModernVerticalImpulseHorizontalModelLimit(final Player player, + final PlayerMoveData thisMove) { + final double velocityH = MathUtil.dist(player.getVelocity().getX(), player.getVelocity().getZ()); + return Math.min(MODERN_VERTICAL_IMPULSE_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, velocityH + MODERN_VERTICAL_IMPULSE_HORIZONTAL_VELOCITY_GRACE)); + } + + private double getModernVerticalImpulseVerticalModelLimit(final Player player, + final PlayerMoveData thisMove) { + final double velocityY = player.getVelocity().getY(); + final double usedVelocityY = thisMove.verVelUsed.isEmpty() ? 0.0D : thisMove.verVelUsed.get(0).value; + return Math.min(MODERN_VERTICAL_IMPULSE_Y_GRACE, + Math.max(thisMove.yAllowedDistance, Math.max(velocityY, usedVelocityY) + MODERN_VERTICAL_IMPULSE_VERTICAL_RESIDUAL)); + } + + private boolean isModernMovementClient(final IPlayerData pData) { + return pData.getClientVersion() == ClientVersion.HIGHER_THAN_KNOWN_VERSIONS + || pData.getClientVersion().isAtLeast(ClientVersion.V_1_21); + } + + private boolean hasModernVerticalImpulseSource(final Player player, final PlayerMoveData thisMove) { + return player.getVelocity().getY() >= MODERN_VERTICAL_IMPULSE_MIN_SERVER_VELOCITY + || !thisMove.verVelUsed.isEmpty() + || tags.contains("hvel_current") + || tags.contains("hvel"); + } + + private boolean currentHorizontalVelocityMatches(final Player player, final PlayerMoveData thisMove, + final double grace) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + if (velocityH <= Magic.PREDICTION_EPSILON) { + return thisMove.hDistance <= grace; + } + final double actualH = thisMove.hDistance; + final double dot = velocity.getX() * thisMove.xDistance + velocity.getZ() * thisMove.zDistance; + return dot >= -Magic.PREDICTION_EPSILON + && actualH <= velocityH + grace; + } + + private boolean currentMotionVectorMatches(final Player player, final PlayerMoveData thisMove, + final double horizontalResidual, + final double verticalResidual) { + final Vector velocity = player.getVelocity(); + final boolean verticalMatch = velocity.getY() > 0.0D + && Math.abs(thisMove.yDistance - velocity.getY()) <= verticalResidual; + return verticalMatch && currentHorizontalVelocityMatches(player, thisMove, horizontalResidual); + } + + private boolean isBedrockHalfStepVerticalGrace(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // Bedrock model: mirror the 0.5 block step geometry for the vertical violation path. + final boolean groundish = from.isOnGroundOrResetCond() || to.isOnGroundOrResetCond() + || thisMove.from.onGroundOrResetCond || thisMove.to.onGroundOrResetCond + || thisMove.touchedGround || thisMove.touchedGroundWorkaround + || tags.contains("onground_env") || tags.contains("v_air"); + final double horizontalLimit = getBedrockStepHorizontalModelLimit(from, to, thisMove, lastMove); + return isBedrockPlayer(player, pData) + && !Bridge1_9.isGliding(player) + && groundish + && Math.abs(thisMove.yDistance - BEDROCK_HALF_STEP_VERTICAL_MOVE) <= BEDROCK_HALF_STEP_VERTICAL_EPSILON + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + BEDROCK_HALF_STEP_VERTICAL_MOVE, PARTIAL_SUPPORT_VERTICAL_MODEL_EPSILON) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, PARTIAL_SUPPORT_HORIZONTAL_MODEL_EPSILON) + && thisMove.hDistance <= horizontalLimit + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isBedrockStepVerticalUndershootGrace(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // Bedrock model: allow the horizontal half-step packet when the matching Y correction is one packet late. + final double horizontalLimit = getBedrockStepHorizontalModelLimit(from, to, thisMove, lastMove); + return isBedrockPlayer(player, pData) + && !Bridge1_9.isGliding(player) + && isGroundishStepMove(from, to, thisMove) + && isStepBlockNear(from, to) + && Math.abs(thisMove.yDistance) <= BEDROCK_STEP_VERTICAL_UNDERSHOOT_MOVE_GRACE + && Math.abs(thisMove.yAllowedDistance - BEDROCK_HALF_STEP_VERTICAL_MOVE) <= BEDROCK_STEP_VERTICAL_MODEL_GRACE + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + BEDROCK_HALF_STEP_VERTICAL_MOVE, PARTIAL_SUPPORT_VERTICAL_MODEL_EPSILON) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, PARTIAL_SUPPORT_HORIZONTAL_MODEL_EPSILON) + && thisMove.hDistance <= horizontalLimit + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isGroundishStepMove(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return from.isOnGroundOrResetCond() || to.isOnGroundOrResetCond() + || thisMove.from.onGroundOrResetCond || thisMove.to.onGroundOrResetCond + || thisMove.touchedGround || thisMove.touchedGroundWorkaround + || tags.contains("onground_env") || tags.contains("v_air"); + } + + private boolean isStepBlockNear(final PlayerLocation from, final PlayerLocation to) { + return isStepBlock(from.getBlockType()) + || isStepBlock(from.getBlockTypeBelow()) + || isStepBlock(to.getBlockType()) + || isStepBlock(to.getBlockTypeBelow()); + } + + private boolean isStepBlock(final Material material) { + return material != null + && (BlockProperties.isStairs(material) || MaterialUtil.SLABS.contains(material)); + } + + private boolean isThinSupportVerticalGrace(final Player player, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // False-positive tuning: prevent lantern/trapdoor/carpet support from becoming a vertical setback loop. + return !Bridge1_9.isGliding(player) + && isThinSupportNear(from, to) + && isGroundishStepMove(from, to, thisMove) + && yDistanceAboveLimit <= THIN_SUPPORT_VERTICAL_OVER_GRACE + && hDistanceAboveLimit <= THIN_SUPPORT_HORIZONTAL_OVER_GRACE + && thisMove.hDistance <= THIN_SUPPORT_HORIZONTAL_MOVE_GRACE + && thisMove.yDistance >= -Magic.PREDICTION_EPSILON + && thisMove.yDistance <= THIN_SUPPORT_VERTICAL_MOVE_GRACE + && !from.isInLiquid() && !to.isInLiquid() + && !thisMove.from.inLiquid && !thisMove.to.inLiquid; + } + + private boolean isThinSupportNear(final PlayerLocation from, final PlayerLocation to) { + return isThinSupportBlock(from.getBlockType()) + || isThinSupportBlock(from.getBlockTypeBelow()) + || isThinSupportBlock(to.getBlockType()) + || isThinSupportBlock(to.getBlockTypeBelow()); + } + + private void addPartialSupportTypeTag(final PlayerLocation from, final PlayerLocation to, + final String suffix) { + tags.add(getPartialSupportTypeTag(from, to) + '_' + suffix); + } + + private String getPartialSupportTypeTag(final PlayerLocation from, final PlayerLocation to) { + if (isLanternSupportNear(from, to)) { + return "lantern_partial_support"; + } + if (isCarpetSupportNear(from, to)) { + return "carpet_partial_support"; + } + if (isStepBlockNear(from, to)) { + return "step_partial_support"; + } + if (isFenceLikeSupportNear(from, to)) { + return "fence_partial_support"; + } + if (isLeafLitterNear(from, to)) { + return "leaf_litter_partial_support"; + } + if (isSnowSupportNear(from, to)) { + return "snow_partial_support"; + } + return "partial_support"; + } + + private boolean isLanternSupportNear(final PlayerLocation from, final PlayerLocation to) { + return isLanternSupportBlock(from.getBlockType()) + || isLanternSupportBlock(from.getBlockTypeBelow()) + || isLanternSupportBlock(to.getBlockType()) + || isLanternSupportBlock(to.getBlockTypeBelow()); + } + + private boolean isCarpetSupportNear(final PlayerLocation from, final PlayerLocation to) { + return isCarpetSupportBlock(from.getBlockType()) + || isCarpetSupportBlock(from.getBlockTypeBelow()) + || isCarpetSupportBlock(to.getBlockType()) + || isCarpetSupportBlock(to.getBlockTypeBelow()); + } + + private boolean isFenceLikeSupportNear(final PlayerLocation from, final PlayerLocation to) { + return isFenceLikeSupport(from.getBlockType()) + || isFenceLikeSupport(from.getBlockTypeBelow()) + || isFenceLikeSupport(to.getBlockType()) + || isFenceLikeSupport(to.getBlockTypeBelow()); + } + + private boolean isLeafLitterNear(final PlayerLocation from, final PlayerLocation to) { + return isLeafLitter(from.getBlockType()) + || isLeafLitter(from.getBlockTypeBelow()) + || isLeafLitter(to.getBlockType()) + || isLeafLitter(to.getBlockTypeBelow()); + } + + private boolean isSnowSupportNear(final PlayerLocation from, final PlayerLocation to) { + return isSnowSupportBlock(from.getBlockType()) + || isSnowSupportBlock(from.getBlockTypeBelow()) + || isSnowSupportBlock(to.getBlockType()) + || isSnowSupportBlock(to.getBlockTypeBelow()); + } + + private boolean isPartialSupportNear(final PlayerLocation from, final PlayerLocation to) { + return isPartialSupportBlock(from.getBlockType()) + || isPartialSupportBlock(from.getBlockTypeBelow()) + || isPartialSupportBlock(to.getBlockType()) + || isPartialSupportBlock(to.getBlockTypeBelow()); + } + + private boolean isPartialSupportBlock(final Material material) { + return isThinSupportBlock(material) + || isStepBlock(material) + || isFenceLikeSupport(material) + || isLeafLitter(material) + || isSnowSupportBlock(material); + } + + private boolean isThinSupportBlock(final Material material) { + return material != null + && (isLanternSupportBlock(material) + || MaterialUtil.ALL_TRAP_DOORS.contains(material) + || isCarpetSupportBlock(material)); + } + + private boolean isLanternSupportBlock(final Material material) { + return material != null + && (MaterialUtil.LANTERNS.contains(material) + || MaterialUtil.COPPER_LANTERNS.contains(material)); + } + + private boolean isCarpetSupportBlock(final Material material) { + return material != null && MaterialUtil.CARPETS.contains(material); + } + + private boolean isFenceLikeSupport(final Material material) { + return material != null + && (MaterialUtil.WOODEN_FENCES.contains(material) + || MaterialUtil.WOODEN_FENCE_GATES.contains(material) + || MaterialUtil.ALL_WALLS.contains(material)); + } + + private boolean isLeafLitter(final Material material) { + return material != null && material.name().endsWith("LEAF_LITTER"); + } + + private boolean isSnowSupportBlock(final Material material) { + return material == Material.SNOW; + } + + private double getSnowSupportHeightModel(final PlayerLocation from, final PlayerLocation to) { + return Math.max(Math.max(getSnowSupportHeight(from, false), getSnowSupportHeight(from, true)), + Math.max(getSnowSupportHeight(to, false), getSnowSupportHeight(to, true))); + } + + private double getSnowSupportHeight(final PlayerLocation location, final boolean below) { + final int blockY = location.getBlockY() - (below ? 1 : 0); + final Material material = below ? location.getBlockTypeBelow() : location.getBlockType(); + if (!isSnowSupportBlock(material)) { + return 0.0D; + } + final double boundsHeight = getSnowSupportBoundsHeight(location, blockY); + if (boundsHeight > 0.0D) { + return boundsHeight; + } + final int data = location.getData(location.getBlockX(), blockY, location.getBlockZ()) & 0xF; + return Math.min(SNOW_SUPPORT_MAX_COLLISION_HEIGHT, Math.max(0.0D, data * SNOW_SUPPORT_LAYER_HEIGHT)); + } + + private double getSnowSupportBoundsHeight(final PlayerLocation location, final int blockY) { + final double[] bounds = location.getBlockCache().getBounds(location.getBlockX(), blockY, location.getBlockZ()); + if (bounds == null || bounds.length < 5) { + return 0.0D; + } + double height = bounds[4]; + for (int i = 10; i < bounds.length; i += 6) { + height = Math.max(height, bounds[i]); + } + return Math.min(SNOW_SUPPORT_MAX_COLLISION_HEIGHT, Math.max(0.0D, height)); + } + + private boolean isPortalNear(final PlayerLocation from, final PlayerLocation to) { + return isPortalBlock(from.getBlockType()) + || isPortalBlock(from.getBlockTypeBelow()) + || isPortalBlock(from.getBlockTypeAbove()) + || isPortalBlock(to.getBlockType()) + || isPortalBlock(to.getBlockTypeBelow()) + || isPortalBlock(to.getBlockTypeAbove()); + } + + private boolean isPortalBlock(final Material material) { + return material != null + && (material == BridgeMaterial.NETHER_PORTAL || material == BridgeMaterial.END_PORTAL); + } + + private boolean acceptsGlidingStallVerticalModel(final Player player, final MovingData data, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + final boolean accepted = Bridge1_9.isGliding(player) + && data.hasSetBack() + && yDistanceAboveLimit <= GLIDING_STALL_VERTICAL_OVER_GRACE + && hDistanceAboveLimit <= GLIDING_STALL_HORIZONTAL_OVER_GRACE + && Math.abs(thisMove.yDistance) <= GLIDING_STALL_VERTICAL_MOVE_GRACE + && thisMove.hDistance <= GLIDING_STALL_HORIZONTAL_MOVE_GRACE; + if (accepted) { + tags.add("glide_setback_stall_vertical_model"); + } + return accepted; + } + + private boolean acceptsElytraLiftOffVerticalModel(final Player player, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!Bridge1_9.isWearingElytra(player) + || Bridge1_9.isGliding(player) + || !lastMove.toIsValid + || lastMove.yDistance <= 0.0D + || thisMove.yDistance <= 0.0D) { + return false; + } + final double verticalModel = getElytraLiftOffVerticalModel(thisMove, lastMove); + final double horizontalLimit = getElytraLiftOffHorizontalModelLimit(thisMove, lastMove); + if (thisMove.yDistance <= verticalModel + && thisMove.hDistance <= horizontalLimit + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + verticalModel, ELYTRA_LIFTOFF_VERTICAL_OVER_GRACE) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_LIFTOFF_HORIZONTAL_OVER_GRACE)) { + tags.add("elytra_liftoff_vertical_model"); + return true; + } + tags.add("elytra_liftoff_vertical_model_miss"); + return false; + } + + private double getElytraLiftOffVerticalModel(final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return Math.min(ELYTRA_LIFTOFF_MAX_ASCEND, lastMove.yDistance + ELYTRA_LIFTOFF_LAST_Y_GRACE); + } + + private double getElytraLiftOffHorizontalModelLimit(final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return Math.min(ELYTRA_LIFTOFF_HORIZONTAL_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, lastMove.hDistance + ELYTRA_LIFTOFF_HORIZONTAL_OVER_GRACE)); + } + + private boolean acceptsElytraGeometryStallVerticalModel(final Player player, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + if (!isElytraGeometryStallContext(player, thisMove, lastMove)) { + return false; + } + final double horizontalLimit = getElytraGeometryStallHorizontalModelLimit(thisMove, lastMove); + final double verticalModel = getElytraGeometryStallVerticalModel(thisMove); + if (thisMove.hDistance <= horizontalLimit + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, + verticalModel, ELYTRA_GEOMETRY_STALL_VERTICAL_OVER_GRACE) + && hDistanceAboveLimit <= getModelOverLimit(thisMove.hAllowedDistance, + horizontalLimit, ELYTRA_GEOMETRY_STALL_HORIZONTAL_OVER_GRACE)) { + tags.add("elytra_geometry_stall_vertical_model"); + return true; + } + tags.add("elytra_geometry_stall_vertical_model_miss"); + return false; + } + + private boolean isElytraGeometryStallContext(final Player player, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return Bridge1_9.isWearingElytra(player) + && !Bridge1_9.isGliding(player) + && lastMove.toIsValid + && lastMove.yDistance >= ELYTRA_GEOMETRY_STALL_LAST_ASCEND + && Math.abs(thisMove.yDistance) <= ELYTRA_GEOMETRY_STALL_MAX_VERTICAL_MOVE; + } + + private double getElytraGeometryStallHorizontalModelLimit(final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + return Math.min(ELYTRA_GEOMETRY_STALL_HORIZONTAL_MOVE_GRACE, + Math.max(thisMove.hAllowedDistance, lastMove.hDistance + + ELYTRA_GEOMETRY_STALL_HORIZONTAL_OVER_GRACE)); + } + + private double getElytraGeometryStallVerticalModel(final PlayerMoveData thisMove) { + return thisMove.yDistance >= 0.0D ? ELYTRA_GEOMETRY_STALL_MAX_VERTICAL_MOVE + : -ELYTRA_GEOMETRY_STALL_MAX_VERTICAL_MOVE; + } + + private boolean acceptsClimbableVerticalModel(final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit) { + // Model cleanup: climbing blocks use their own vertical envelope from open air. + final boolean climbable = from.isOnClimbable() || to.isOnClimbable() + || thisMove.from.onClimbable || thisMove.to.onClimbable; + final ClimbableSurfaceModel model = getClimbableSurfaceModel(from, to); + if (!climbable || from.isInLiquid() || to.isInLiquid() + || thisMove.from.inLiquid || thisMove.to.inLiquid + || hDistanceAboveLimit > model.horizontalResidual) { + return false; + } + final double descendLimit = getClimbableDescendModelLimit(model, thisMove); + final double descendOverLimit = Math.max(model.descendOverLimit, + Math.max(0.0D, thisMove.yAllowedDistance + descendLimit) + + model.verticalPrecision); + final boolean accepted = yDistanceAboveLimit <= model.verticalPrecision + || thisMove.yDistance >= -Magic.PREDICTION_EPSILON + && thisMove.yDistance <= model.ascendLimit + || thisMove.yDistance >= -descendLimit + && thisMove.yDistance <= Magic.PREDICTION_EPSILON + && yDistanceAboveLimit <= descendOverLimit; + if (accepted) { + tags.add(model.tag + "_vertical_model"); + if (descendOverLimit > model.descendOverLimit) { + tags.add(model.tag + "_descend_clamp_vertical_model"); + } + } + return accepted; + } + + private boolean isWaterVerticalGrace(final Player player, final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double yDistanceAboveLimit, + final double hDistanceAboveLimit, + final boolean resetFrom, final boolean resetTo) { + // False-flag model: water Y is matched against buoyancy, liquid exit, or surface-swim physics. + final boolean inWater = from.isInWater() || to.isInWater() + || thisMove.from.inWater || thisMove.to.inWater; + if (!inWater + || hDistanceAboveLimit > getModelOverLimit(thisMove.hAllowedDistance, + getWaterHorizontalModelLimit(player, thisMove, false), WATER_HORIZONTAL_MODEL_EPSILON)) { + return false; + } + if (thisMove.yDistance < -Magic.PREDICTION_EPSILON + && matchesWaterDescendModel(player, thisMove, lastMove)) { + return true; + } + final double buoyancyModel = getWaterBuoyancyModel(player, thisMove, lastMove); + if (thisMove.yDistance <= buoyancyModel + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, buoyancyModel, WATER_VERTICAL_MODEL_EPSILON)) { + return true; + } + final boolean setbackLike = !lastMove.toIsValid + && (resetFrom || thisMove.from.resetCond) + && (resetTo || thisMove.to.resetCond); + final boolean resetLike = (resetFrom || thisMove.from.resetCond) + && (resetTo || thisMove.to.resetCond); + final boolean surfaceLike = resetLike || tags.contains("v_exiting_liquid") + || thisMove.collideX || thisMove.collideY || thisMove.collideZ; + final double surfaceModel = getWaterSurfaceVerticalModel(player, to, thisMove); + if (surfaceLike && thisMove.yDistance >= -WATER_VERTICAL_MODEL_EPSILON + && thisMove.yDistance <= surfaceModel + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, surfaceModel, WATER_VERTICAL_MODEL_EPSILON)) { + tags.add("water_surface_vertical_model"); + return true; + } + if (surfaceLike + && thisMove.yAllowedDistance > Magic.PREDICTION_EPSILON + && thisMove.yAllowedDistance <= surfaceModel + && Math.abs(thisMove.yDistance) <= WATER_VERTICAL_MODEL_EPSILON + && yDistanceAboveLimit <= thisMove.yAllowedDistance + WATER_VERTICAL_MODEL_EPSILON) { + /* + * Water model: surface/reset collisions can clamp the vertical packet + * to zero while buoyancy predicted an upward swim step. + */ + tags.add("water_surface_still_vertical_model"); + return true; + } + if (setbackLike && thisMove.yDistance <= surfaceModel + && yDistanceAboveLimit <= getModelOverLimit(thisMove.yAllowedDistance, surfaceModel, WATER_VERTICAL_MODEL_EPSILON)) { + return true; + } + return resetLike && matchesVerticalModel(thisMove.yDistance, thisMove.yAllowedDistance, WATER_VERTICAL_MODEL_EPSILON); + } + + private boolean matchesWaterDescendModel(final Player player, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double expectedExitFall = -WATER_EXIT_DESCEND_MODEL; + if (tags.contains("v_exiting_liquid") + && matchesVerticalModel(thisMove.yDistance, expectedExitFall, WATER_VERTICAL_MODEL_EPSILON)) { + tags.add("water_exit_first_gravity_vertical_model"); + return true; + } + final double velocityY = player.getVelocity().getY(); + if (matchesVerticalModel(thisMove.yDistance, velocityY, WATER_VERTICAL_MODEL_EPSILON)) { + tags.add("water_current_velocity_vertical_model"); + return true; + } + if (!lastMove.toIsValid) { + return false; + } + if (tags.contains("v_exiting_liquid") + && matchesVerticalModel(thisMove.yDistance, getWaterExitAirGravityModel(lastMove), + WATER_VERTICAL_MODEL_EPSILON)) { + // Model cleanup: Bedrock and modern clients can switch to air gravity while still sampled as water. + tags.add("water_exit_air_gravity_vertical_model"); + return true; + } + final double liquidDescend = (lastMove.yDistance - Magic.LEGACY_LIQUID_GRAVITY) * Magic.WATER_VERTICAL_INERTIA; + if (matchesVerticalModel(thisMove.yDistance, liquidDescend, WATER_VERTICAL_MODEL_EPSILON)) { + tags.add("water_liquid_descend_vertical_model"); + return true; + } + return false; + } + + private double getWaterExitAirGravityModel(final PlayerMoveData lastMove) { + return (lastMove.yDistance - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR; + } + + private double getWaterBuoyancyModel(final Player player, final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + final double velocityY = player.getVelocity().getY(); + final double lastBuoyancy = lastMove.toIsValid + ? (lastMove.yDistance - Magic.LEGACY_LIQUID_GRAVITY) * Magic.WATER_VERTICAL_INERTIA + + Magic.LIQUID_SPEED_GAIN + : Magic.LIQUID_SPEED_GAIN; + return Math.max(thisMove.yAllowedDistance, Math.max(velocityY, lastBuoyancy)) + WATER_VERTICAL_MODEL_EPSILON; + } + + private double getWaterSurfaceVerticalModel(final Player player, final PlayerLocation to, + final PlayerMoveData thisMove) { + final double velocityY = player.getVelocity().getY(); + final boolean exitingWater = tags.contains("v_exiting_liquid") || !to.isInWater() && !thisMove.to.inWater; + final double surfaceModel = exitingWater ? WATER_SURFACE_EXIT_ASCEND_MODEL : WATER_SURFACE_ASCEND_MODEL; + return Math.max(surfaceModel, velocityY > 0.0D ? velocityY : 0.0D) + WATER_VERTICAL_MODEL_EPSILON; + } + + private boolean matchesVerticalModel(final double actual, final double expected, final double epsilon) { + return Math.abs(actual - expected) <= epsilon; + } + + private boolean isBedrockPlayer(final Player player, final IPlayerData pData) { + // Prefer stored player data, then fall back to common Floodgate/Geyser identifiers used during login/session setup. + return pData.isBedrockPlayer() || player.getName().startsWith(".") || isFloodgateUuid(player) || isFloodgatePlayer(player) || isGeyserPlayer(player); + } + + private boolean isFloodgateUuid(final Player player) { + return player.getUniqueId().toString().startsWith("00000000-0000-0000-0009-"); + } + + private boolean isFloodgatePlayer(final Player player) { + try { + final Class apiClass = Class.forName("org.geysermc.floodgate.api.FloodgateApi"); + final Object api = apiClass.getMethod("getInstance").invoke(null); + final Object result = apiClass.getMethod("isFloodgatePlayer", java.util.UUID.class).invoke(api, player.getUniqueId()); + return Boolean.TRUE.equals(result); + } + catch (ClassNotFoundException | NoSuchMethodException ignored) { + return false; + } + catch (Throwable ignored) { + return false; + } + } + + private boolean isGeyserPlayer(final Player player) { + try { + final Class apiClass = Class.forName("org.geysermc.geyser.api.GeyserApi"); + final Object api = apiClass.getMethod("api").invoke(null); + return api != null && apiClass.getMethod("connectionByUuid", java.util.UUID.class).invoke(api, player.getUniqueId()) != null; + } + catch (ClassNotFoundException | NoSuchMethodException ignored) { + return false; + } + catch (Throwable ignored) { + return false; + } + } + + + /** + * Estimates the player's horizontal speed based on the given data and Minecraft's movement logic.
    + * Order of operations is essential. Do not shuffle things around unless you know what you're doing. + *
    + *

    Order of client-movement operations (as per MCP tool): + *

      + *
    • {@code EntityLiving.tick()} + *
    • {@code [Entity].tick()} + *
        + *
      • {@code baseTick()} + *
      • {@code updateInWaterStateAndDoFluidPushing()} + *
      + *
    • {@code EntityLiving.aiStep()} + *
        + *
      • Decrease the jump delay counter if it is active ({@code this.noJumpDelay > 0}) + *
      • Negligible speed reset (0.003) + *
      • Apply liquid motion if the player is pressing the space bar (vertical axis only) + *
      • Multiply the input vector (= the vector containing the player's WASD impulse) by 0.98
      • + *
      • {@code jumpFromGround()} is called if the player is on ground and has pressed the space bar. + *
      + *
    • Begin executing {@code EntityLiving.travel()} ({@code In EntityLiving.aiStep()})

      Note: from 1.21.2 and onwards, Mojang split the travel function into different helper methods to better + * distinguish between media (we now have {@code travelInAir()}, {@code travelInFluid()} and {@code travelFallFlying()})


      + *
        + *
      • Invoke {@code [Entity].moveRelative()} (WASD inputs are transformed to acceleration vectors, call {@code getInputVector()}) + *
      • If not in liquid or gliding, limit motion when on climbable via {@code handleRelativeFrictionAndCalculateMovement()} + *
      • Invoke {@code [Entity].move()} + *
          + *
        • Apply stuck speed multiplier + *
        • Invoke {@code EntityHuman.maybeBackOffFromEdge()} + *
        • Handle wall collisions via {@code Entity.collide()} (speed is cut-off and the collision flag is set)
          + * After {@code [Entity].collide()} is called, the next movement is prepared. Every subsequent operation + * applies to the next move for the client. + *
        • Set the supporting block data; call {@code setGroundWithMovement()}
        • + *
        • Handle horizontal collisions (speed is now reset to 0 on the colliding axis); NCP STARTS ESTIMATING FROM HERE ON (!) + *
        • Invoke {@code checkFallDamage()} (apply fluid pushing if not previously in water) + *
        • Invoke {@code [Block].updateEntityAfterFallOn()} (for slime bouncing) + *
        • Invoke {@code [Block].stepOn()} (for slime blocks only, currently) + *
        • Invoke {@code tryCheckInsideBlocks()} (for honey blocks slide-down and bubble columns) + *
        • Invoke {@code [Entity].getBlockSpeedFactor()} (soul sand, honey blocks) + *
        + *
      + *
    • Complete executing {@code EntityLiving.travel()} + *
        + *
      • {@code handleRelativeFrictionAndCalculateMovement()} (for snow climbing speed) + *
      • Apply gravity. + *
      • Apply friction/inertia. + *
      • Handle fluid falling function if in liquid (vertical axis only) + *
      • Handle jumping out of liquids (vertical axis only) + *
      • Handle entity pushing + *
      + *
    • Complete {@code EntityLiving.aiStep()} + *
    • Complete {@code EntityLiving.tick()} + *
    • Finally, send movement to the server. + *
    + *
    + * The logic is split into different sections: + *
  • Firstly, we perform some preliminary checks to quickly catch specific ways of cheating.
  • + *
  • If no blatant cheating is detected, the movement speed estimate is calculated starting from the horizontal collision reset, calculating the client’s actions on the next move and then processing the actions performed prior.
  • + *
  • If needed, the player's impulse (acceleration) is brute-forced (see {@link BridgeMisc#isWASDImpulseKnown(Player)}
  • + * @return {@code true}, if the move has been deemed to be predictable. {@code false} otherwise. + */ + private boolean estimateNextSpeed(final Player player, float movementSpeed, final IPlayerData pData, final Collection tags, + final PlayerLocation to, final PlayerLocation from, final boolean debug, + final boolean fromOnGround, final boolean toOnGround, final boolean onGround, boolean forceSetOffGround) { + /* + * TODO: This is a mess, clean-up pending / needed. Get rid of code duplication + */ + final MovingData data = pData.getGenericInstance(MovingData.class); + final CombinedData cData = pData.getGenericInstance(CombinedData.class); + final PlayerMoveData thisMove = data.playerMoves.getCurrentMove(); + final PlayerMoveData lastMove = data.playerMoves.getFirstPastMove(); + final ClientVersion clientVersion = getMovementClientVersion(pData); + + // Reference commit of this piece of code: https://github.com/NoCheatPlus/NoCheatPlus/commit/1c024c072c9f6ebe5371c113916c6a2414e635a6 + ///////////////////////////////////////////////////// + // Horizontal push/pull is put on top priority // + ///////////////////////////////////////////////////// + // With the current implementation, the prediction will run for the axis even if a push/pull is detected on it. + // We'll have to somehow skip predicting that specfic axis, but it requires some refactoring to do it. + // This is not ideal, but it's better than flagging players for being pushed by pistons. + final MovingConfig cc = pData.getGenericInstance(MovingConfig.class); + boolean xPush = false; + boolean zPush = false; + // TODO: Get rid of this config option. Why would someone want to disable piston push detection and cause false positives? + if (cc.trackBlockMove) { + if (from.matchBlockChange(blockChangeTracker, data.blockChangeRef, thisMove.xDistance < 0.0 ? Direction.X_NEG : Direction.X_POS, 0.05)) { + tags.add("blkmv_x"); + xPush = true; + } + if (from.matchBlockChange(blockChangeTracker, data.blockChangeRef, thisMove.zDistance < 0.0 ? Direction.Z_NEG : Direction.Z_POS, 0.05)) { + tags.add("blkmv_z"); + zPush = true; + } + if (xPush && zPush) { + thisMove.xAllowedDistance = thisMove.xDistance; + thisMove.zAllowedDistance = thisMove.zDistance; + // A push/pull happened on both axes, no need to continue the prediction. + return true; + } + } + + //////////////////////////////////////////////////////// + // Test for specific cheat implementation types first // + //////////////////////////////////////////////////////// + // These checks don't need specific data from the prediction, so they can be performed ex-ante and save some performance. + if (cData.isHackingRI) { + tags.add("noslowpacket"); + cData.isHackingRI = false; + Improbable.check(player, (float) thisMove.hDistance, System.currentTimeMillis(), "moving.survivalfly.noslow", pData); + data.resetHorizontalData(); + return true; + } + // If impulses don't need to be inferred from the prediction, illegal sprinting checks can be performed here. + if (BridgeMisc.isWASDImpulseKnown(player) && pData.isSprinting() + && (data.input.getForwardDir() != ForwardDirection.FORWARD && data.input.getStrafeDir() != StrafeDirection.NONE && data.input.getForwardDir() != ForwardDirection.NONE + || player.getFoodLevel() <= 5) // must be checked here as well (besides on toggle sprinting) because players will immediately lose the ability to sprint if food level drops below 5 + ) { + // || inputs[i].getForward() < 0.8 // hasEnoughImpulseToStartSprinting, in LocalPlayer,java -> aiStep() + tags.add("illegalsprint"); + Improbable.check(player, (float) thisMove.hDistance, System.currentTimeMillis(), "moving.survivalfly.illegalsprint", pData); + data.resetHorizontalData(); + return true; + } + + + ////////////////////////////////////////// + // Setup theoretical inputs, if needed // + ////////////////////////////////////////// + PlayerKeyboardInput input = null; // Precise input + PlayerKeyboardInput[] theorInputs = null; // All brute-forced inputs. + /* Index for accessing speed combinations. If you need to perform an operation for/with each speed, set it to 0 and loop until it 8 */ + int i = 0; + if (BridgeMisc.isWASDImpulseKnown(player)) { + // Clone for safety as this data is consumed. + input = data.input.clone(); + // In EntityLiving.java -> aiStep() the game multiplies input values by 0.98 before dispatching them to the travel() function. + input.operationToInt(0.98f, 0.98f, 1); + // From KeyboardInput.java and LocalPlayer.java (MC-Reborn tool) + // Sneaking and item-use aren't directly applied to the player's motion. The game reduces the force of the input instead. + if (pData.isInCrouchingPose()) { + // Note that this is determined by player poses, not shift key presses. + input.operationToInt(attributeAccess.getHandle().getPlayerSneakingFactor(player), attributeAccess.getHandle().getPlayerSneakingFactor(player), 1); + tags.add("crouching"); + } + // From LocalPlayer.java.aiStep() + if (BridgeMisc.isSlowedDownByUsingAnItem(player)) { + input.operationToInt(Magic.USING_ITEM_MULTIPLIER, Magic.USING_ITEM_MULTIPLIER, 1); + tags.add("usingitem"); + } + } + else { + // The input's matrix is: NONE, LEFT, RIGHT, FORWARD, FORWARD_LEFT, FORWARD_RIGHT, BACKWARD, BACKWARD_LEFT, BACKWARD_RIGHT. + theorInputs = new PlayerKeyboardInput[9]; + // Loop through all combinations otherwise. + for (int strafe = -1; strafe <= 1; strafe++) { + for (int forward = -1; forward <= 1; forward++) { + // Multiply all + theorInputs[i] = new PlayerKeyboardInput(strafe * 0.98f, forward * 0.98f); + i++; + } + } + if (pData.isInCrouchingPose()) { + tags.add("crouching"); + for (i = 0; i < 9; i++) { + // Multiply all combinations + theorInputs[i].operationToInt(attributeAccess.getHandle().getPlayerSneakingFactor(player), attributeAccess.getHandle().getPlayerSneakingFactor(player), 1); + } + } + // From LocalPlayer.java.aiStep() + if (BridgeMisc.isSlowedDownByUsingAnItem(player)) { + tags.add("usingitem"); + for (i = 0; i < 9; i++) { + theorInputs[i].operationToInt(Magic.USING_ITEM_MULTIPLIER, Magic.USING_ITEM_MULTIPLIER, 1); + } + } + } + + + ////////////////////////////////////// + // Next move for the client // + ////////////////////////////////////// + /* + All moves are assumed to be predictable, unless we explicitly state otherwise. + A move is considered to be predictable if there aren't any particular client-side issues/limitations that prevent it. + */ + boolean isPredictable = true; // Initialize the allowed distance(s) with the previous speed. (Only if we have end-point coordinates) // This essentially represents the momentum of the player. thisMove.xAllowedDistance = lastMove.toIsValid ? lastMove.xDistance : 0.0; @@ -1092,28 +6978,13 @@ private boolean estimateNextSpeed(final Player player, float movementSpeed, fina * TODO: Unify predictions somehow. */ // Current yDistance before calculation for supporting block ground state. Copy paste from vDistrel - double yDistanceBeforeCollide = lastMove.toIsValid ? lastMove.yDistance : 0.0; - if (lastMove.yCorrectedDistancePre != 0.0) yDistanceBeforeCollide = lastMove.yCorrectedDistancePre; - if (TrigUtil.lengthSquared(data.lastStuckInBlockHorizontal, data.lastStuckInBlockVertical, data.lastStuckInBlockHorizontal) > 1.0E-7) { - if (data.lastStuckInBlockVertical != 1.0) { - yDistanceBeforeCollide = 0.0; - } - } - if (pData.isShiftKeyPressed() && lastMove.collideY) { - if (yDistanceBeforeCollide < 0.0) { // NOTE: Must be the allowed distance, not the actual one (exploit) - if (lastMove.to.onBouncyBlock) { - // The effect works by inverting the distance. - // Beds have a weaker bounce effect (BedBlock.java). - yDistanceBeforeCollide = lastMove.to.onSlimeBlock ? -yDistanceBeforeCollide : -yDistanceBeforeCollide * 0.66; - } - } - } + double yDistanceBeforeCollide = lastMove.toIsValid ? lastMove.yDistance : 0.0; if (lastMove.from.inWater) { yDistanceBeforeCollide *= data.lastFrictionVertical; if (BridgeMisc.hasGravity(player)) { // Legacy: clients older than 1.13 have some kind of gravity effect applied to them even in liquids, if they don't press the space bar. // On 1.13 and above, only friction gets applied, resulting in a much slower descending speed when not pressing the space bar pressed. - if (pData.getClientVersion().isLowerThan(ClientVersion.V_1_13)) { + if (clientVersion.isLowerThan(ClientVersion.V_1_13)) { yDistanceBeforeCollide -= Magic.LEGACY_LIQUID_GRAVITY; } else { @@ -1127,7 +6998,7 @@ else if (lastMove.from.inLava) { if (data.lastFrictionVertical != Magic.LAVA_VERTICAL_INERTIA) { yDistanceBeforeCollide *= data.lastFrictionVertical; if (BridgeMisc.hasGravity(player)) { - if (pData.getClientVersion().isLowerThan(ClientVersion.V_1_13)) { + if (clientVersion.isLowerThan(ClientVersion.V_1_13)) { yDistanceBeforeCollide -= Magic.LEGACY_LIQUID_GRAVITY; } else { @@ -1156,23 +7027,6 @@ else if (lastMove.from.inLava) { // Liquid vertical push component calculated in hdistrel. yDistanceBeforeCollide += verticalLiquidPushComponent; } - if (from.isInWater() && pData.getClientVersion().isHigherThan(ClientVersion.V_1_12_2)) { - // *----------LocalPlayer.aiStep(), goDownInWater()----------* - if (pData.isShiftKeyPressed()) { - yDistanceBeforeCollide -= Magic.LIQUID_SPEED_GAIN; - } - } - if (yDistanceBeforeCollide < (pData.getClientVersion().isHigherThan(ClientVersion.V_1_12_2) ? 0.003 : 0.005)) yDistanceBeforeCollide = 0.0; - if (!from.isInLiquid() && from.isOnClimbable() && from.canClimbUp(data.liftOffEnvelope.getMaxJumpHeight(data.jumpAmplifier))) { - // Should replicate the condition: !this.getInBlockState().is(Blocks.SCAFFOLDING) - final Material typeId = from.getBlockType(); - final long theseFlags = BlockFlags.getBlockFlags(typeId); - yDistanceBeforeCollide = Math.max(yDistanceBeforeCollide, -Magic.CLIMBABLE_MAX_SPEED); - if (yDistanceBeforeCollide < 0.0 && pData.isShiftKeyPressed() && from.getEntity() instanceof Player - && (theseFlags & BlockFlags.F_SCAFFOLDING) == 0 && pData.getClientVersion().isAtLeast(ClientVersion.V_1_14)) { - yDistanceBeforeCollide = 0.0; - } - } //End of yDistanceBeforeCollide getter // *--------------------------------------------------------------------------------------------------------------------* @@ -1237,7 +7091,7 @@ else if (lastMove.from.inLava) { // TODO: Perhaps after this use collisionVector to store onGround? Also can not restore minecraft ground state with step and jump movement(like stairs)! Vector collisionVector = from.collide(new Vector(thisMove.xAllowedDistance, yDistanceBeforeCollide, thisMove.zAllowedDistance), onGround, from.getBoundingBox()); // Set the supporting block data. - if (pData.getClientVersion().isAtLeast(ClientVersion.V_1_20)) { + if (clientVersion.isAtLeast(ClientVersion.V_1_20)) { // This is called with setOnGroundWithMovement at the same time of setting the ground flag but before setting the horizontal collision flags. // NOTE: here the bounding box of the TO location must be used. pData.setSupportingBlockData(SupportingBlockUtils.checkSupportingBlock(to.getBlockCache(), player, pData.getSupportingBlockData(), new Vector(thisMove.xAllowedDistance, thisMove.yDistance, thisMove.zAllowedDistance), to.getBoundingBox(), yDistanceBeforeCollide < 0.0 && yDistanceBeforeCollide != collisionVector.getY())); @@ -1389,7 +7243,7 @@ True, if the offset between predicted and actual speed is smaller than the accur Otherwise, only the combined horizontal distance will be checked against the offset. */ boolean strict = cc.survivalFlyStrictHorizontal; - final double hiddenThreshold = pData.getClientVersion().isLowerThan(ClientVersion.V_1_18_2) ? Magic.Minecraft_minMoveSqDistance_legacy : Magic.Minecraft_minMoveSqDist_modern; + final double hiddenThreshold = clientVersion.isLowerThan(ClientVersion.V_1_18_2) ? Magic.Minecraft_minMoveSqDistance_legacy : Magic.Minecraft_minMoveSqDist_modern; for (i = 0; i < 9; i++) { // If this theoretical candidate results in a post-collision horizontal // displacement smaller than the client's packet-suppression threshold, @@ -1438,7 +7292,7 @@ True, if the offset between predicted and actual speed is smaller than the accur if (!forceViolation) { // Found a candidate to set in this move; these collisions are valid. // Also set the supporting block. - if (pData.getClientVersion().isAtLeast(ClientVersion.V_1_20)) { + if (clientVersion.isAtLeast(ClientVersion.V_1_20)) { pData.setSupportingBlockData(SupportingBlockUtils.checkSupportingBlock(to.getBlockCache(), player, pData.getSupportingBlockData(), new Vector(xTheoreticalDistance[i], thisMove.yDistance, zTheoreticalDistance[i]), to.getBoundingBox(), collideY[i] && yDistanceBeforeCollide < 0.0)); } thisMove.collideX = collideX[i]; @@ -1578,64 +7432,7 @@ private double handlePredictableMove(final PlayerMoveData thisMove, boolean stri } return hDistanceAboveLimit; } - - private enum Operation { - SET, - ADD, - SUBTRACT, - MULTIPLY, - DIVIDE - } - private void configVerticalAllowedValue(PlayerMoveData thisMove, double[] yTheoreticalDistance, double value, Operation operation) { - final boolean direct = yTheoreticalDistance == null; - if (direct) { - switch (operation) { - case ADD: - thisMove.yAllowedDistance += value; - break; - case DIVIDE: - thisMove.yAllowedDistance /= value; - break; - case MULTIPLY: - thisMove.yAllowedDistance *= value; - break; - case SET: - thisMove.yAllowedDistance = value; - break; - case SUBTRACT: - thisMove.yAllowedDistance -= value; - break; - } - } else { - switch (operation) { - case ADD: - for (int i = 0; i < yTheoreticalDistance.length; i++) { - yTheoreticalDistance[i] += value; - } - break; - case DIVIDE: - for (int i = 0; i < yTheoreticalDistance.length; i++) { - yTheoreticalDistance[i] /= value; - } - break; - case MULTIPLY: - for (int i = 0; i < yTheoreticalDistance.length; i++) { - yTheoreticalDistance[i] *= value; - } - break; - case SET: - for (int i = 0; i < yTheoreticalDistance.length; i++) { - yTheoreticalDistance[i] = value; - } - break; - case SUBTRACT: - for (int i = 0; i < yTheoreticalDistance.length; i++) { - yTheoreticalDistance[i] -= value; - } - break; - } - } - } + /** * Relative (to workarounds) vertical distance checking. @@ -1656,6 +7453,7 @@ private double[] vDistRel(final Player player, final PlayerLocation from, /* Not on ground, not on climbable, not in liquids, not in stuck-speed, no lostground (...) */ final boolean fullyInAir = !thisMove.touchedGroundWorkaround && !resetFrom && !resetTo; final CombinedData cData = pData.getGenericInstance(CombinedData.class); + final ClientVersion clientVersion = getMovementClientVersion(pData); final boolean onGround = from.isOnGround() || lastMove.toIsValid && lastMove.yDistance <= 0.0 && lastMove.from.onGround; /* * 1: Simulate the reset of speed that the client should have sent to the server. @@ -1693,6 +7491,13 @@ private double[] vDistRel(final Player player, final PlayerLocation from, tags.add("onground_env"); return new double[]{thisMove.yAllowedDistance, yDistanceAboveLimit}; } + if (isBedrockLanternCollisionMove(player, pData, from, to, thisMove)) { + // Bedrock clients can send small 1/16-ish vertical corrections while moving through lantern collision. + thisMove.yAllowedDistance = thisMove.yDistance; + yDistanceAboveLimit = 0.0; + tags.add("bedrock_lantern"); + return new double[]{thisMove.yAllowedDistance, yDistanceAboveLimit}; + } if (PhysicsEnvelope.isJumpMotion(from, to, player, fromOnGround, toOnGround)) { // After stepping, jumping comes second. // Players can jump anywhere through air, so this must be checked before the actual prediction. @@ -1736,99 +7541,68 @@ private double[] vDistRel(final Player player, final PlayerLocation from, double[] yTheoreticalDistance = null; boolean[] collideLiquidY = null; // Initialize with momentum (or lack thereof) + // TODO: Not sure block.updateEntityAfterFallOn (lastMove.yDistance < 0.0 && fromOnGround) put here is correct? thisMove.yAllowedDistance = forceResetMomentum || touchDownIsLost || (lastMove.yDistance < 0.0 && (fromOnGround || thisMove.fromLostGround)) || !lastMove.toIsValid ? 0.0 : lastMove.yDistance; if (lastMove.yCorrectedDistancePre != 0.0) thisMove.yAllowedDistance = lastMove.yCorrectedDistancePre; - // *----------stuck-speed-momentum-reset----------* - if (TrigUtil.lengthSquared(data.lastStuckInBlockHorizontal, data.lastStuckInBlockVertical, data.lastStuckInBlockHorizontal) > 1.0E-7) { - if (data.lastStuckInBlockVertical != 1.0) { - thisMove.yAllowedDistance = 0.0; - } - } - // TODO: Optimize to only split when need detect jump - if (!BridgeMisc.isSpaceBarImpulseKnown(player)) { - // *----------------------------------* - // *--- Loop the space bar impulse ---* - // *----------------------------------* - // Initialize with the momentum that has hitherto been calculated. - yTheoreticalDistance = new double[3]; - collideLiquidY = new boolean[3]; - // With space bar pressed - yTheoreticalDistance[0] = thisMove.yAllowedDistance; - // With space bar not pressed - yTheoreticalDistance[1] = thisMove.yAllowedDistance; - // With swimming speed not applied - yTheoreticalDistance[2] = thisMove.yAllowedDistance; - } + ////////////////////////////////////// + // Next client-tick/move // + ////////////////////////////////////// // *----------updateEntityAfterFallOn()----------* // NOTE: pressing space bar on a bouncy block will override the bounce (in that case, vdistrel will fall back to the jump check above). // updateEntityAfterFallOn(), this function is called on the next move - if (pData.isShiftKeyPressed() && lastMove.collideY) { - if (yTheoreticalDistance != null) { - for (int i = 0; i < yTheoreticalDistance.length; i++) { - if (yTheoreticalDistance[i] < 0.0) { // NOTE: Must be the allowed distance, not the actual one (exploit) - if (lastMove.to.onBouncyBlock) { - // The effect works by inverting the distance. - // Beds have a weaker bounce effect (BedBlock.java). - yTheoreticalDistance[i] = lastMove.to.onSlimeBlock ? -yTheoreticalDistance[i] : -yTheoreticalDistance[i] * 0.66; - } - } - } - tags.add("bounceup"); - } else { - if (thisMove.yAllowedDistance < 0.0) { // NOTE: Must be the allowed distance, not the actual one (exploit) - if (lastMove.to.onBouncyBlock) { - // The effect works by inverting the distance. - // Beds have a weaker bounce effect (BedBlock.java). - thisMove.yAllowedDistance = lastMove.to.onSlimeBlock ? -thisMove.yAllowedDistance : -thisMove.yAllowedDistance * 0.66; - } + if (pData.isShiftKeyPressed() && lastMove.collideY) { + if (thisMove.yAllowedDistance < 0.0) { // NOTE: Must be the allowed distance, not the actual one (exploit) + if (lastMove.to.onBouncyBlock) { + // The effect works by inverting the distance. + // Beds have a weaker bounce effect (BedBlock.java). + thisMove.yAllowedDistance = lastMove.to.onSlimeBlock ? -thisMove.yAllowedDistance : -thisMove.yAllowedDistance * 0.66; + tags.add("bounceup"); } - tags.add("bounceup"); } } // *----------tryCheckInsideBlocks()----------* // Bubble columns are checked in the tryCheckInsideBlocks method, so it comes after updateEntityAfterFallOn()... - from.tryApplyBubbleColumnMotion(thisMove, yTheoreticalDistance); + Vector bubbleVector = from.tryApplyBubbleColumnMotion(new Vector(0.0, thisMove.yAllowedDistance, 0.0)); + thisMove.yAllowedDistance = bubbleVector.getY(); // Honey block sliding mechanic... if (from.isSlidingDown()) { // Speed is static in this case - configVerticalAllowedValue(thisMove, yTheoreticalDistance, -Magic.SLIDE_SPEED_THROTTLE, Operation.SET); + thisMove.yAllowedDistance = -Magic.SLIDE_SPEED_THROTTLE; + } + // *----------stuck-speed-momentum-reset----------* + if (TrigUtil.lengthSquared(data.lastStuckInBlockHorizontal, data.lastStuckInBlockVertical, data.lastStuckInBlockHorizontal) > 1.0E-7) { + if (data.lastStuckInBlockVertical != 1.0) { + thisMove.yAllowedDistance = 0.0; + } } // *----------Finalization of handleRelativeFrictionAndCalculateMovement; this check/condition is called after having called the move() function. The former method is called only when the player is traveling in air, thus the liquid and gliding checks ----------* - if (!lastMove.from.inLiquid) { + if (!lastMove.from.inLiquid && !lastMove.isGliding) { // TODO: We have to loop the jumping state for 1.21.1 and below... No other way to put it unfortunately. This will make the code an ugly mess than it already is. final boolean jumpedOrCollided = lastMove.collidesHorizontally || data.input.wasSpaceBarPressed() && BridgeMisc.isSpaceBarImpulseKnown(player); - if (thisMove.from.onClimbable || thisMove.from.touchedPowderSnow && BridgeMisc.canStandOnPowderSnow(player)) { - if (jumpedOrCollided) { - configVerticalAllowedValue(thisMove, yTheoreticalDistance, 0.2, Operation.SET); - } else if (yTheoreticalDistance != null) { - yTheoreticalDistance[0] = 0.2; - } + if (jumpedOrCollided && (lastMove.from.onClimbable || lastMove.from.touchedPowderSnow && BridgeMisc.canStandOnPowderSnow(player))) { + thisMove.yAllowedDistance = 0.2; } } // *----------Gravity, friction and other medium-dependent modifiers in LivingEntity.travel() (water first, then lava and finally air)----------* data.nextGravity = attributeAccess.getHandle().getGravity(player); if (lastMove.from.inWater) { - if (lastMove.collidesHorizontally && lastMove.from.onClimbable && pData.getClientVersion().isAtLeast(ClientVersion.V_1_14)) { - configVerticalAllowedValue(thisMove, yTheoreticalDistance, 0.2, Operation.SET); + if (lastMove.collidesHorizontally && lastMove.from.onClimbable && clientVersion.isAtLeast(ClientVersion.V_1_14)) { + thisMove.yAllowedDistance = 0.2; } // Water applies friction before calling the fluidFalling function. - configVerticalAllowedValue(thisMove, yTheoreticalDistance, data.lastFrictionVertical, Operation.MULTIPLY); + thisMove.yAllowedDistance *= data.lastFrictionVertical; if (BridgeMisc.hasGravity(player)) { // Legacy: clients older than 1.13 have some kind of gravity effect applied to them even in liquids, if they don't press the space bar. // On 1.13 and above, only friction gets applied, resulting in a much slower descending speed when not pressing the space bar pressed. - if (pData.getClientVersion().isLowerThan(ClientVersion.V_1_13)) { - configVerticalAllowedValue(thisMove, yTheoreticalDistance, Magic.LEGACY_LIQUID_GRAVITY, Operation.SUBTRACT); + if (clientVersion.isLowerThan(ClientVersion.V_1_13)) { + thisMove.yAllowedDistance -= Magic.LEGACY_LIQUID_GRAVITY; } else { // In 1.13 the gravity effect in liquids was removed and this function got added. - if (yTheoreticalDistance != null) { - for (int i =0; i < yTheoreticalDistance.length; i++) { - Vector fluidFallingAdjustMovement = from.getFluidFallingAdjustedMovement(data.lastGravity, yTheoreticalDistance[i] <= 0.0, new Vector(0.0, yTheoreticalDistance[i], 0.0), cData.wasSprinting); - yTheoreticalDistance[i] = fluidFallingAdjustMovement.getY(); - } - } + Vector fluidFallingAdjustMovement = from.getFluidFallingAdjustedMovement(data.lastGravity, thisMove.yAllowedDistance <= 0.0, new Vector(0.0, thisMove.yAllowedDistance, 0.0), cData.wasSprinting); + thisMove.yAllowedDistance = fluidFallingAdjustMovement.getY(); } } tags.add("v_water"); @@ -1836,28 +7610,24 @@ private double[] vDistRel(final Player player, final PlayerLocation from, else if (lastMove.from.inLava) { // Lava friction is quite odd. Depending on specified thresholds, it can be 0.5 or 0.8 if (data.lastFrictionVertical != Magic.LAVA_VERTICAL_INERTIA) { // Note that this condition is not vanilla. It's just a shortcut to avoid replicating the condition contained in BlockProperties.getBlockFrictionFactor. - configVerticalAllowedValue(thisMove, yTheoreticalDistance, data.lastFrictionVertical, Operation.MULTIPLY); + thisMove.yAllowedDistance *= data.lastFrictionVertical; if (BridgeMisc.hasGravity(player)) { - if (pData.getClientVersion().isLowerThan(ClientVersion.V_1_13)) { - configVerticalAllowedValue(thisMove, yTheoreticalDistance, Magic.LEGACY_LIQUID_GRAVITY, Operation.SUBTRACT); + if (clientVersion.isLowerThan(ClientVersion.V_1_13)) { + thisMove.yAllowedDistance -= Magic.LEGACY_LIQUID_GRAVITY; } else { // getFluidFallingAdjustedMovement is only applied if friction is 0.8. - if (yTheoreticalDistance != null) { - for (int i =0; i < yTheoreticalDistance.length; i++) { - Vector fluidFallingAdjustMovement = from.getFluidFallingAdjustedMovement(data.lastGravity, yTheoreticalDistance[i] <= 0.0, new Vector(0.0, yTheoreticalDistance[i], 0.0), cData.wasSprinting); - yTheoreticalDistance[i] = fluidFallingAdjustMovement.getY(); - } - } + Vector fluidFallingAdjustMovement = from.getFluidFallingAdjustedMovement(data.lastGravity, thisMove.yAllowedDistance <= 0.0, new Vector(0.0, thisMove.yAllowedDistance, 0.0), cData.wasSprinting); + thisMove.yAllowedDistance = fluidFallingAdjustMovement.getY(); } } } else { // Otherwise, 0.5 - configVerticalAllowedValue(thisMove, yTheoreticalDistance, data.lastFrictionVertical, Operation.MULTIPLY); + thisMove.yAllowedDistance *= data.lastFrictionVertical; } if (data.lastGravity != 0.0) { - configVerticalAllowedValue(thisMove, yTheoreticalDistance, -data.lastGravity / 4.0, Operation.ADD); + thisMove.yAllowedDistance += -data.lastGravity / 4.0; } tags.add("v_lava"); } @@ -1865,70 +7635,58 @@ else if (lastMove.from.inLava) { // Air motion if (cData.wasLevitating) { // Levitation forces players to ascend and does not work in liquids, so thankfully we don't have to account for that, other than stuck-speed. - configVerticalAllowedValue(thisMove, yTheoreticalDistance, (0.05 * data.lastLevitationLevel - lastMove.yAllowedDistance) * 0.2, Operation.ADD); + thisMove.yAllowedDistance += (0.05 * data.lastLevitationLevel - lastMove.yAllowedDistance) * 0.2; } - else configVerticalAllowedValue(thisMove, yTheoreticalDistance, data.lastGravity, Operation.SUBTRACT); - configVerticalAllowedValue(thisMove, yTheoreticalDistance, data.lastFrictionVertical, Operation.MULTIPLY); + else thisMove.yAllowedDistance -= data.lastGravity; + thisMove.yAllowedDistance *= data.lastFrictionVertical; tags.add("v_air"); } // *----------Finalize LivingEntity.travel; isFree() check----------* // Try making the player jump out of the liquid... // This condition is the same for both lava and water, and is always done at the end of the travel() function. - //TODO: Still broken as thisMove.collidesHorizontally not working properly! - if (from.isInLiquid() && lastMove.collidesHorizontally) { - if (yTheoreticalDistance != null) { - for (int i =0; i < yTheoreticalDistance.length; i++) { - if (from.isUnobstructed(yTheoreticalDistance[i])) yTheoreticalDistance[i] = 0.3; - } - } else if (from.isUnobstructed(thisMove.yAllowedDistance)) thisMove.yAllowedDistance = 0.3; + if (lastMove.from.inLiquid && lastMove.collidesHorizontally + // TODO: Somewhat work. Incorrect horizontal move. Require this function call at the time BOTH horizontal and vertical calculating at the same time. Which is not possible with current infrastructure + && from.isUnobstructed()) { + thisMove.yAllowedDistance = 0.3; tags.add("v_exiting_liquid"); } ////////////////////////////////// // Last client-tick/move // - ////////////////////////////////// - if (from.isInLiquid() && verticalLiquidPushComponent != 0.0) { - // Liquid vertical push component calculated in hdistrel. - configVerticalAllowedValue(thisMove, yTheoreticalDistance, verticalLiquidPushComponent, Operation.ADD); - } - // *----------TridentItem.releaseUsing(), apply trident motion----------* - if (thisMove.tridentRelease.decideOptimistically()) { - thisMove.tridentRelease = AlmostBoolean.YES; - // Riptide works by propelling the player in air after releasing the trident (the effect only pushes the player, unless is on ground) - final Vector riptideVelocity = to.getRiptideVelocity(onGround); - final double fric = getLastYGroundRipTide(from, lastMove, data, cData); - if (fric != 0.0) { - configVerticalAllowedValue(thisMove, yTheoreticalDistance, riptideVelocity.getY() + fric, Operation.SET); - } - else { - configVerticalAllowedValue(thisMove, yTheoreticalDistance, riptideVelocity.getY(), Operation.ADD); - } - } - // Special case for riptide at the start of the movement tick (when the riptide move is "unified" and not split into two updates; friction of the next move is used here) - if (lastMove.tridentRelease.decide() && lastMove.toIsValid) { - final PlayerMoveData secondLastMove = data.playerMoves.getSecondPastMove(); - if (lastMove.from.onGround || (secondLastMove.toIsValid && secondLastMove.yDistance <= 0.0 && (secondLastMove.from.onGround || secondLastMove.fromLostGround))) { - configVerticalAllowedValue(thisMove, yTheoreticalDistance, Magic.RIPTIDE_ON_GROUND_MOVE * data.lastFrictionVertical, Operation.SUBTRACT); - } + ////////////////////////////////// + if (from.isInLiquid() && verticalLiquidPushComponent != 0.0) { + // Liquid vertical push component calculated in hdistrel. + thisMove.yAllowedDistance += verticalLiquidPushComponent; } - if (from.isInWater() && pData.getClientVersion().isHigherThan(ClientVersion.V_1_12_2)) { - // *----------LocalPlayer.aiStep(), goDownInWater()----------* - if (pData.isShiftKeyPressed()) { - configVerticalAllowedValue(thisMove, yTheoreticalDistance, Magic.LIQUID_SPEED_GAIN, Operation.SUBTRACT); + // *----------LivingEntity.aiStep(), negligible speed----------* + checkNegligibleMomentumVertical(pData, thisMove); + // *----------LivingEntity.travel(), handleRelativeFrictionAndCalculateMovement() -> handleOnClimbable()----------* + // TODO: Is it correct to put here? + if (!from.isInLiquid() && from.isOnClimbable() && from.canClimbUp(data.liftOffEnvelope.getMaxJumpHeight(data.jumpAmplifier))) { + thisMove.yAllowedDistance = Math.max(thisMove.yAllowedDistance, -Magic.CLIMBABLE_MAX_SPEED); + // Should replicate the condition: !this.getInBlockState().is(Blocks.SCAFFOLDING) + final Material typeId = from.getBlockType(); + final long theseFlags = BlockFlags.getBlockFlags(typeId); + if (thisMove.yAllowedDistance < 0.0 && pData.isShiftKeyPressed() && from.getEntity() instanceof Player + && (theseFlags & BlockFlags.F_SCAFFOLDING) == 0 && clientVersion.isAtLeast(ClientVersion.V_1_14)) { + thisMove.yAllowedDistance = 0.0; } + tags.add("v_climbable"); } - // *----------LivingEntity.aiStep(), negligible speed----------* - checkNegligibleMomentumVertical(pData, thisMove, yTheoreticalDistance); // *----------EntityLiving.aiStep(), apply liquid motion----------* if (from.isInLiquid()) { + // *----------LocalPlayer.aiStep(), goDownInWater()----------* + if (pData.isShiftKeyPressed() && from.isInWater()) { + thisMove.yAllowedDistance -= Magic.LIQUID_SPEED_GAIN; + } // *----------------------------------------------------------------------------------------------------------------------------* // *----- When in liquid, the game doesn't care about players being on ground, only if they press the space bar. -------------* // *----- When they do press it, the game sets the jumping field to true. ----------------------------------------------------* // *----- However, up until MC 1.21.2 we couldn't know this, because the player used to not send anything about it -------------* // *----- Solution: if the client/server does not support input reading/sending, loop the space bar impulse ------------------* // *----------------------------------------------------------------------------------------------------------------------------* - if (yTheoreticalDistance == null) { + if (BridgeMisc.isSpaceBarImpulseKnown(player)) { // From: EntityLiving.java -> aiStep() and KeyboardInput.java. if (data.input.isSpaceBarPressed()) { boolean isSubmergedInWater = from.isInWater() && thisMove.submergedWaterHeight > 0.0; @@ -1962,6 +7720,18 @@ else if ((onGround || isSubmergedInWater && thisMove.submergedWaterHeight <= flu } } else { + // *----------------------------------* + // *--- Loop the space bar impulse ---* + // *----------------------------------* + // Initialize with the momentum that has hitherto been calculated. + yTheoreticalDistance = new double[3]; + collideLiquidY = new boolean[3]; + // With space bar pressed + yTheoreticalDistance[0] = thisMove.yAllowedDistance; + // With space bar not pressed + yTheoreticalDistance[1] = thisMove.yAllowedDistance; + // With swimming speed not applied + yTheoreticalDistance[2] = thisMove.yAllowedDistance; boolean isSubmergedInWater = from.isInWater() && thisMove.submergedWaterHeight > 0.0; double fluidJumpThreshold = from.getEyeHeight() < 0.4D ? 0.0D : 0.4D; if (isSubmergedInWater && (!onGround || thisMove.submergedWaterHeight > fluidJumpThreshold)) { @@ -1988,32 +7758,50 @@ else if ((onGround || isSubmergedInWater && thisMove.submergedWaterHeight <= flu } } } - // *----------LivingEntity.travel(), handleRelativeFrictionAndCalculateMovement() -> handleOnClimbable()----------* - if (!from.isInLiquid() && from.isOnClimbable() && from.canClimbUp(data.liftOffEnvelope.getMaxJumpHeight(data.jumpAmplifier))) { - // Should replicate the condition: !this.getInBlockState().is(Blocks.SCAFFOLDING) - final Material typeId = from.getBlockType(); - final long theseFlags = BlockFlags.getBlockFlags(typeId); + // *----------Beginning of EntityLiving.travel(); call Entity.move(); apply stuck speed multipliers----------* + if (TrigUtil.lengthSquared(data.nextStuckInBlockHorizontal, data.nextStuckInBlockVertical, data.nextStuckInBlockHorizontal) > 1.0E-7) { + // If we looped the space bar impulse, all later modifiers are applied to each speed. if (yTheoreticalDistance != null) { for (int i = 0; i < yTheoreticalDistance.length; i++) { - yTheoreticalDistance[i] = Math.max(yTheoreticalDistance[i], -Magic.CLIMBABLE_MAX_SPEED); - if (yTheoreticalDistance[i] < 0.0 && pData.isShiftKeyPressed() && from.getEntity() instanceof Player - && (theseFlags & BlockFlags.F_SCAFFOLDING) == 0 && pData.getClientVersion().isAtLeast(ClientVersion.V_1_14)) { - yTheoreticalDistance[i] = 0.0; + yTheoreticalDistance[i] *= data.nextStuckInBlockVertical; + } + } + else thisMove.yAllowedDistance *= data.nextStuckInBlockVertical; + } + // *----------TridentItem.releaseUsing(), apply trident motion----------* + if (thisMove.tridentRelease.decideOptimistically()) { + thisMove.tridentRelease = AlmostBoolean.YES; + // Riptide works by propelling the player in air after releasing the trident (the effect only pushes the player, unless is on ground) + final Vector riptideVelocity = to.getRiptideVelocity(onGround); + final double fric = getLastYGroundRipTide(from, lastMove, data, cData); + if (fric != 0.0) { + if (yTheoreticalDistance != null) { + for (int i = 0; i < yTheoreticalDistance.length; i++) { + yTheoreticalDistance[i] = riptideVelocity.getY() + fric; } } - } else { - thisMove.yAllowedDistance = Math.max(thisMove.yAllowedDistance, -Magic.CLIMBABLE_MAX_SPEED); - if (thisMove.yAllowedDistance < 0.0 && pData.isShiftKeyPressed() && from.getEntity() instanceof Player - && (theseFlags & BlockFlags.F_SCAFFOLDING) == 0 && pData.getClientVersion().isAtLeast(ClientVersion.V_1_14)) { - thisMove.yAllowedDistance = 0.0; + else thisMove.yAllowedDistance = riptideVelocity.getY() + fric; + } + else { + if (yTheoreticalDistance != null) { + for (int i = 0; i < yTheoreticalDistance.length; i++) { + yTheoreticalDistance[i] += riptideVelocity.getY(); + } } + else thisMove.yAllowedDistance += riptideVelocity.getY(); } - tags.add("v_climbable"); } - // *----------Beginning call of Entity.move(); apply stuck speed multipliers----------* - if (TrigUtil.lengthSquared(data.nextStuckInBlockHorizontal, data.nextStuckInBlockVertical, data.nextStuckInBlockHorizontal) > 1.0E-7) { - // If we looped the space bar impulse, all later modifiers are applied to each speed. - configVerticalAllowedValue(thisMove, yTheoreticalDistance, data.nextStuckInBlockVertical, Operation.MULTIPLY); + // Special case for riptide at the start of the movement tick (when the riptide move is "unified" and not split into two updates; friction of the next move is used here) + if (lastMove.tridentRelease.decide() && lastMove.toIsValid) { + final PlayerMoveData secondLastMove = data.playerMoves.getSecondPastMove(); + if (lastMove.from.onGround || (secondLastMove.toIsValid && secondLastMove.yDistance <= 0.0 && (secondLastMove.from.onGround || secondLastMove.fromLostGround))) { + if (yTheoreticalDistance != null) { + for (int i = 0; i < yTheoreticalDistance.length; i++) { + yTheoreticalDistance[i] -= Magic.RIPTIDE_ON_GROUND_MOVE * data.lastFrictionVertical; + } + } + else thisMove.yAllowedDistance -= Magic.RIPTIDE_ON_GROUND_MOVE * data.lastFrictionVertical; + } } // *----------Entity.move(), call the collide() function----------* // Include horizontal motion to account for stepping: there are cases where NCP's isStep definition fails to catch it. @@ -2054,7 +7842,7 @@ else if ((onGround || isSubmergedInWater && thisMove.submergedWaterHeight <= flu // Calculate the offset: check for velocity and workarounds on violations // //////////////////////////////////////////////////////////////////////////// if (yTheoreticalDistance != null) { - final double hiddenThreshold = pData.getClientVersion().isLowerThan(ClientVersion.V_1_18_2) ? Magic.Minecraft_minMoveSqDistance_legacy : Magic.Minecraft_minMoveSqDist_modern; + final double hiddenThreshold = clientVersion.isLowerThan(ClientVersion.V_1_18_2) ? Magic.Minecraft_minMoveSqDistance_legacy : Magic.Minecraft_minMoveSqDist_modern; if (thisMove.hDistance < hiddenThreshold) { for (int i = 0; i < yTheoreticalDistance.length; i++) { if (yTheoreticalDistance[i] < hiddenThreshold && !collideLiquidY[i]) { @@ -2110,6 +7898,17 @@ else if (data.getOrUseVerticalVelocity(yDistance).isEmpty()) { return new double[]{thisMove.yAllowedDistance, yDistanceAboveLimit}; } + private boolean isBedrockLanternCollisionMove(final Player player, final IPlayerData pData, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData thisMove) { + return isBedrockPlayer(player, pData) + && thisMove.hDistance <= 0.35 + && thisMove.yDistance >= -0.125 + && thisMove.yDistance <= 0.625 + && (MaterialUtil.LANTERNS.contains(from.getBlockType()) + || MaterialUtil.LANTERNS.contains(to.getBlockType())); + } + private double getLastYGroundRipTide(PlayerLocation from, PlayerMoveData lastMove, MovingData data, CombinedData cData) { double tmp = 0.0; if (lastMove.tridentRelease == AlmostBoolean.MAYBE) { @@ -2205,21 +8004,511 @@ private double[] hDistAfterFailure(final Player player, */ // TODO: Implement Asofold's fix to prevent too easy abuse: // See: https://github.com/NoCheatPlus/Issues/issues/374#issuecomment-296172316 - //double hFreedom = 0.0; // Horizontal velocity used. - //if (hDistanceAboveLimit > 0.0) { - // hFreedom = data.getHorizontalFreedom(); - // if (hFreedom < hDistanceAboveLimit) { - // // Distance above limit is still greater. Try using queued velocity if possible. - // hFreedom += data.useHorizontalVelocity(hDistanceAboveLimit - hFreedom); - // } - // if (hFreedom > 0.0) { - // tags.add("hvel"); - // hDistanceAboveLimit = Math.max(0.0, hDistanceAboveLimit - hFreedom); - // } - //} - return new double[]{hAllowedDistance, hDistanceAboveLimit, 0.0}; + double hFreedom = 0.0; // Horizontal velocity used. + if (hDistanceAboveLimit > 0.0) { + final double xDemand = thisMove.xDistance - thisMove.xAllowedDistance; + final double zDemand = thisMove.zDistance - thisMove.zAllowedDistance; + if (!data.useHorizontalVelocityCovering(xDemand, zDemand, cc.velocityActivationCounter).isEmpty()) { + hFreedom = MathUtil.dist(xDemand, zDemand); + thisMove.xAllowedDistance = thisMove.xDistance; + thisMove.zAllowedDistance = thisMove.zDistance; + thisMove.hAllowedDistance = thisMove.hDistance; + tags.add("hvel"); + hDistanceAboveLimit = 0.0; + hAllowedDistance = thisMove.hAllowedDistance; + } + else if (currentHorizontalVelocityCovers(player, xDemand, zDemand)) { + hFreedom = MathUtil.dist(xDemand, zDemand); + thisMove.xAllowedDistance = thisMove.xDistance; + thisMove.zAllowedDistance = thisMove.zDistance; + thisMove.hAllowedDistance = thisMove.hDistance; + tags.add("hvel_current"); + hDistanceAboveLimit = 0.0; + hAllowedDistance = thisMove.hAllowedDistance; + } + } + return new double[]{hAllowedDistance, hDistanceAboveLimit, hFreedom}; } - + + private boolean currentHorizontalVelocityCovers(final Player player, final double xDemand, final double zDemand) { + final double demandSq = xDemand * xDemand + zDemand * zDemand; + if (demandSq <= Magic.PREDICTION_EPSILON * Magic.PREDICTION_EPSILON) { + return false; + } + final Vector velocity = player.getVelocity(); + final double vx = velocity.getX(); + final double vz = velocity.getZ(); + final double velocitySq = vx * vx + vz * vz; + if (velocitySq <= Magic.PREDICTION_EPSILON * Magic.PREDICTION_EPSILON) { + return false; + } + final double dot = vx * xDemand + vz * zDemand; + if (dot < -Magic.PREDICTION_EPSILON) { + return false; + } + final double demand = Math.sqrt(demandSq); + final double velocityAmount = Math.sqrt(velocitySq); + if (demand > velocityAmount + CURRENT_VELOCITY_AMOUNT_GRACE) { + return false; + } + final double perpendicular = Math.abs(vx * zDemand - vz * xDemand) / velocityAmount; + return perpendicular <= CURRENT_VELOCITY_PERPENDICULAR_GRACE; + } + + private boolean currentGlidingVerticalVelocityMatches(final Player player, final PlayerMoveData thisMove, + final double yAllowedDistance, + final double yDistanceAboveLimit) { + if (yDistanceAboveLimit > GLIDING_CURRENT_VELOCITY_VERTICAL_OVER_GRACE) { + return false; + } + final Vector velocity = player.getVelocity(); + final double actualVelocityDiff = Math.abs(thisMove.yDistance - velocity.getY()); + if (actualVelocityDiff <= GLIDING_CURRENT_VELOCITY_VERTICAL_MATCH_GRACE) { + return true; + } + final double modelVelocityDiff = Math.abs(yAllowedDistance - velocity.getY()); + return actualVelocityDiff <= GLIDING_CURRENT_VELOCITY_VERTICAL_MODEL_DIFF_GRACE + && actualVelocityDiff + GLIDING_CURRENT_VELOCITY_BETTER_MODEL_GRACE < modelVelocityDiff; + } + + private boolean currentGlidingVerticalVelocityEnvelopeCovers(final Player player, + final PlayerMoveData thisMove, + final double yAllowedDistance, + final double yDistanceAboveLimit) { + if (yDistanceAboveLimit > GLIDING_CURRENT_VELOCITY_VERTICAL_OVER_GRACE + || thisMove.yDistance + Magic.PREDICTION_EPSILON < yAllowedDistance) { + return false; + } + final double velocityY = player.getVelocity().getY(); + return velocityY > yAllowedDistance + Magic.PREDICTION_EPSILON + && thisMove.yDistance <= velocityY + GLIDING_CURRENT_VELOCITY_VERTICAL_MATCH_GRACE; + } + + private boolean currentVelocityBudgetsNoFireworkAscent(final Player player, + final PlayerMoveData thisMove, + final double energyLimit) { + final Vector velocity = player.getVelocity(); + final double velocityY = velocity.getY(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + return velocityY > energyLimit + Magic.PREDICTION_EPSILON + && thisMove.yDistance <= velocityY + GLIDING_CURRENT_VELOCITY_VERTICAL_MATCH_GRACE + && Math.max(thisMove.hDistance, velocityH) >= GLIDING_NO_FIREWORK_CURRENT_VELOCITY_MIN_HORIZONTAL; + } + + private double getNoFireworkDownwardVelocityAscentDebt(final Player player, + final PlayerMoveData thisMove, + final double energyLimit) { + final double velocityY = player.getVelocity().getY(); + final boolean lowEnergyAscent = thisMove.yDistance > Magic.PREDICTION_EPSILON + && velocityY <= GLIDING_NO_FIREWORK_DOWNWARD_VELOCITY_Y + && (thisMove.hDistance < GLIDING_NO_FIREWORK_ASCENT_SPEED_START + || energyLimit <= GLIDING_NO_FIREWORK_ASCENT_RESIDUAL + GLIDING_VERTICAL_PRECISION_GRACE); + if (!lowEnergyAscent) { + return 0.0D; + } + /* + * Elytra no-firework energy model: a real glide cannot climb while the + * server velocity says the player is already moving downward, unless + * speed/dive energy pays for it. This catches packet-shaped vertical fly + * before the broader hover timer is the only thing responding. + */ + return Math.min(GLIDING_NO_FIREWORK_DOWNWARD_VELOCITY_MAX_DEBT, + Math.max(0.0D, thisMove.yDistance - velocityY - GLIDING_NO_FIREWORK_ASCENT_RESIDUAL)); + } + + private double getNoFireworkGlidingDescentBudgetOver(final Player player, + final MovingData data, + final PlayerMoveData thisMove) { + if (data.fireworksBoostDuration > 0 + || player.getVelocity().getY() > Magic.PREDICTION_EPSILON + || data.hoverAirTicks < GLIDING_NO_FIREWORK_DESCENT_BUDGET_MIN_TICKS) { + return 0.0D; + } + final double deficit = data.hoverExpectedDrop - data.hoverActualDrop; + final boolean flatHover = data.hoverActualDrop <= GLIDING_NO_FIREWORK_FLAT_MAX_ACTUAL_DROP + && deficit > GLIDING_NO_FIREWORK_FLAT_DROP_DEFICIT; + final boolean broadBudgetMiss = deficit > GLIDING_NO_FIREWORK_DESCENT_DROP_DEFICIT; + if (!flatHover && !broadBudgetMiss) { + return 0.0D; + } + addTag(flatHover + ? "glide_no_firework_flat_hover_model_miss" + : "glide_no_firework_descent_budget_model_miss"); + return deficit - (flatHover ? GLIDING_NO_FIREWORK_FLAT_DROP_DEFICIT + : GLIDING_NO_FIREWORK_DESCENT_DROP_DEFICIT); + } + + private boolean updateNoFireworkGlidingAscentEnergy(final Player player, final MovingData data, + final PlayerLocation from, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + if (!isNoFireworkGlidingAscentTracked(player, data, thisMove)) { + final boolean resetStart = shouldResetNoFireworkGlidingStart(player, data); + resetNoFireworkGlidingAscentEnergy(data, resetStart); + if (!resetStart && data.elytraNoFireworkStart != null) { + /* + * Elytra no-firework model: correction teleports and packet + * resync can briefly surface as queued velocity. Preserve the + * original glide-start anchor through that internal noise, so + * a vertical-fly client cannot slowly refresh the anchor upward. + */ + addTag("glide_no_firework_start_anchor_preserved"); + } + return false; + } + recordNoFireworkGlidingStart(data, from); + updateNoFireworkGlidingDescentCredit(data, thisMove); + if (thisMove.yDistance <= Magic.PREDICTION_EPSILON) { + data.elytraNoFireworkAscentTicks = 0; + data.elytraNoFireworkAscentDebt = Math.max(0.0D, + data.elytraNoFireworkAscentDebt - Math.max(0.02D, -thisMove.yDistance)); + return false; + } + final double energyLimit = getNoFireworkGlidingAscentEnergyLimit(data, thisMove, lastMove); + final double velocityContradictionDebt = getNoFireworkDownwardVelocityAscentDebt(player, thisMove, energyLimit); + final double modeledExcess = thisMove.yDistance - energyLimit; + final double excess = Math.max(modeledExcess, velocityContradictionDebt); + data.elytraNoFireworkAscentExcess = Math.max(0.0D, excess); + if (excess <= 0.0D) { + data.elytraNoFireworkAscentTicks = 0; + data.elytraNoFireworkAscentDebt = Math.max(0.0D, data.elytraNoFireworkAscentDebt - 0.02D); + consumeNoFireworkGlidingDescentCredit(data); + addTag("glide_no_firework_ascent_energy_model"); + return false; + } + if (currentVelocityBudgetsNoFireworkAscent(player, thisMove, energyLimit)) { + /* + * Elytra no-firework energy model: do not treat ascent as client-created + * if the packet is still inside Bukkit's current velocity envelope and + * there is enough horizontal glide speed to plausibly carry that energy. + */ + data.elytraNoFireworkAscentTicks = 0; + data.elytraNoFireworkAscentDebt = Math.max(0.0D, + data.elytraNoFireworkAscentDebt - Math.max(0.04D, excess * 0.5D)); + addTag("glide_no_firework_ascent_velocity_budget_model"); + return false; + } + if (velocityContradictionDebt > modeledExcess) { + addTag("glide_no_firework_downward_velocity_ascent_debt"); + } + data.elytraNoFireworkAscentTicks++; + data.elytraNoFireworkAscentDebt += excess; + final boolean violation = data.elytraNoFireworkAscentTicks > GLIDING_NO_FIREWORK_ASCENT_TICK_LIMIT + || data.elytraNoFireworkAscentDebt > GLIDING_NO_FIREWORK_ASCENT_DEBT_LIMIT; + addTag(violation ? "glide_no_firework_ascent_energy_miss" : "glide_no_firework_ascent_energy_debt"); + return violation; + } + + private void recordNoFireworkGlidingStart(final MovingData data, final PlayerLocation from) { + final Location start = data.elytraNoFireworkStart; + if (start != null && start.getWorld().equals(from.getWorld())) { + return; + } + /* + * Elytra no-firework anchor: remember where this glide began. If the + * player later climbs above this without a firework/velocity/dive budget, + * the correction can use the launch point instead of a moving air setback. + */ + data.elytraNoFireworkStart = from.getLocation(); + addTag("glide_no_firework_start_anchor"); + } + + private void updateNoFireworkGlidingDescentCredit(final MovingData data, + final PlayerMoveData thisMove) { + data.elytraNoFireworkDescentCreditUsed = 0.0D; + if (thisMove.yDistance < -Magic.PREDICTION_EPSILON) { + /* + * Elytra no-firework energy model: a real glide can trade altitude + * lost during the current glide session back into a later climb. Store + * actual descent as energy, then spend it only through the horizontal + * speed-gated ascent envelope below. + */ + data.elytraNoFireworkDescentCredit = Math.min(GLIDING_NO_FIREWORK_DESCENT_CREDIT_CAP, + data.elytraNoFireworkDescentCredit + + (-thisMove.yDistance * GLIDING_NO_FIREWORK_DESCENT_CREDIT_FACTOR)); + return; + } + data.elytraNoFireworkDescentCredit = Math.max(0.0D, + data.elytraNoFireworkDescentCredit - GLIDING_NO_FIREWORK_DESCENT_CREDIT_DECAY); + } + + private boolean isNoFireworkGlidingAscentTracked(final Player player, final MovingData data, + final PlayerMoveData thisMove) { + return Bridge1_9.isGliding(player) + && data.fireworksBoostDuration <= 0 + && Double.isInfinite(Bridge1_9.getLevitationAmplifier(player)) + && data.timeRiptiding + 1500 <= System.currentTimeMillis() + && !data.hasQueuedVerVel() + && thisMove.verVelUsed.isEmpty(); + } + + private boolean shouldResetNoFireworkGlidingStart(final Player player, final MovingData data) { + return !Bridge1_9.isGliding(player) + || data.fireworksBoostDuration > 0 + || !Double.isInfinite(Bridge1_9.getLevitationAmplifier(player)) + || data.timeRiptiding + 1500 > System.currentTimeMillis(); + } + + private double getNoFireworkGlidingAscentEnergyLimit(final MovingData data, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + if (!lastMove.toIsValid) { + return recordNoFireworkGlidingAscentBudget(data, thisMove, GLIDING_NO_FIREWORK_ASCENT_RESIDUAL, 0.0D); + } + final double decayedAscent = lastMove.yDistance > 0.0D + ? Math.max(0.0D, (lastMove.yDistance - Magic.DEFAULT_GRAVITY) * Magic.FRICTION_MEDIUM_AIR) : 0.0D; + final double diveLift = Math.max(0.0D, -lastMove.yDistance) * GLIDING_NO_FIREWORK_ASCENT_DIVE_FACTOR; + final double tradedSpeedLift = Math.max(0.0D, lastMove.hDistance - thisMove.hDistance) + * GLIDING_NO_FIREWORK_ASCENT_TRADE_FACTOR; + final double descentCreditLift = getNoFireworkGlidingDescentCreditLift(data, thisMove, lastMove); + final double rawSpeedLift = Math.min(GLIDING_NO_FIREWORK_ASCENT_SPEED_CAP, + Math.max(0.0D, lastMove.hDistance - GLIDING_NO_FIREWORK_ASCENT_SPEED_START) + * GLIDING_NO_FIREWORK_ASCENT_SPEED_FACTOR); + final boolean hasEarnedLiftSource = diveLift > Magic.PREDICTION_EPSILON + || tradedSpeedLift > Magic.PREDICTION_EPSILON + || descentCreditLift > Magic.PREDICTION_EPSILON; + final double speedLift = hasEarnedLiftSource ? rawSpeedLift : 0.0D; + if (rawSpeedLift > 0.0D && speedLift <= 0.0D && thisMove.yDistance > GLIDING_NO_FIREWORK_ASCENT_RESIDUAL) { + addTag("glide_no_firework_unearned_speed_lift"); + } + /* + * Elytra no-firework energy model: climbing must be paid for by decaying + * previous upward momentum, a prior dive, horizontal speed actually traded + * away, or descent credit earned earlier in the same glide. Horizontal + * speed alone is not an energy source; otherwise a flat no-firework hover + * can create altitude just by maintaining speed. + */ + final double energyLimit = Math.max(decayedAscent, + Math.max(diveLift, speedLift + tradedSpeedLift + descentCreditLift)) + + GLIDING_NO_FIREWORK_ASCENT_RESIDUAL; + return recordNoFireworkGlidingAscentBudget(data, thisMove, energyLimit, + tradedSpeedLift + descentCreditLift); + } + + private double getNoFireworkGlidingDescentCreditLift(final MovingData data, + final PlayerMoveData thisMove, + final PlayerMoveData lastMove) { + data.elytraNoFireworkDescentCreditUsed = 0.0D; + if (data.elytraNoFireworkDescentCredit <= 0.0D) { + return 0.0D; + } + final double hDistance = Math.max(thisMove.hDistance, lastMove.toIsValid ? lastMove.hDistance : 0.0D); + if (hDistance < GLIDING_NO_FIREWORK_DESCENT_CREDIT_MIN_H) { + return 0.0D; + } + final double speedScale = Math.min(1.0D, Math.max(0.0D, + (hDistance - GLIDING_NO_FIREWORK_DESCENT_CREDIT_MIN_H) + / (GLIDING_NO_FIREWORK_DESCENT_CREDIT_FULL_H + - GLIDING_NO_FIREWORK_DESCENT_CREDIT_MIN_H))); + final double lift = Math.min(data.elytraNoFireworkDescentCredit, + GLIDING_NO_FIREWORK_DESCENT_CREDIT_TICK_CAP * speedScale); + data.elytraNoFireworkDescentCreditUsed = lift; + if (lift > 0.0D) { + addTag("glide_no_firework_descent_credit_model"); + } + return lift; + } + + private void consumeNoFireworkGlidingDescentCredit(final MovingData data) { + if (data.elytraNoFireworkDescentCreditUsed <= 0.0D) { + return; + } + data.elytraNoFireworkDescentCredit = Math.max(0.0D, + data.elytraNoFireworkDescentCredit - data.elytraNoFireworkDescentCreditUsed); + } + + private double recordNoFireworkGlidingAscentBudget(final MovingData data, final PlayerMoveData thisMove, + final double energyLimit, final double tradedSpeedLift) { + data.elytraNoFireworkAscentBudget = energyLimit; + data.elytraNoFireworkAscentExcess = Math.max(0.0D, thisMove.yDistance - energyLimit); + data.elytraNoFireworkNeededH = getNoFireworkHorizontalSpeedNeededForAscent(thisMove.yDistance, tradedSpeedLift); + return energyLimit; + } + + private double getNoFireworkHorizontalSpeedNeededForAscent(final double yDistance, final double tradedSpeedLift) { + final double ascent = Math.max(0.0D, yDistance); + final double speedLiftNeeded = Math.max(0.0D, + ascent - GLIDING_NO_FIREWORK_ASCENT_RESIDUAL - Math.max(0.0D, tradedSpeedLift)); + if (speedLiftNeeded <= 0.0D) { + return 0.0D; + } + if (speedLiftNeeded > GLIDING_NO_FIREWORK_ASCENT_SPEED_CAP) { + return Double.POSITIVE_INFINITY; + } + return GLIDING_NO_FIREWORK_ASCENT_SPEED_START + + speedLiftNeeded / GLIDING_NO_FIREWORK_ASCENT_SPEED_FACTOR; + } + + private void resetNoFireworkGlidingAscentEnergy(final MovingData data, final boolean resetStart) { + data.elytraNoFireworkAscentTicks = 0; + data.elytraNoFireworkAscentDebt = 0.0D; + data.elytraNoFireworkAscentBudget = 0.0D; + data.elytraNoFireworkAscentExcess = 0.0D; + data.elytraNoFireworkNeededH = Double.NaN; + data.elytraNoFireworkDescentCredit = 0.0D; + data.elytraNoFireworkDescentCreditUsed = 0.0D; + if (resetStart) { + data.elytraNoFireworkStart = null; + } + } + + private boolean currentGlidingHorizontalVelocityCovers(final Player player, final PlayerMoveData thisMove, + final double xAllowedDistance, final double zAllowedDistance, + final double xDemand, final double zDemand) { + final double demandSq = xDemand * xDemand + zDemand * zDemand; + if (demandSq <= Magic.PREDICTION_EPSILON * Magic.PREDICTION_EPSILON) { + return false; + } + final Vector velocity = player.getVelocity(); + final double vx = velocity.getX(); + final double vz = velocity.getZ(); + final double velocitySq = vx * vx + vz * vz; + if (velocitySq <= Magic.PREDICTION_EPSILON * Magic.PREDICTION_EPSILON) { + return false; + } + final double actualVelocityDiff = MathUtil.dist(thisMove.xDistance - vx, thisMove.zDistance - vz); + final double modelVelocityDiff = MathUtil.dist(xAllowedDistance - vx, zAllowedDistance - vz); + if (actualVelocityDiff <= GLIDING_CURRENT_VELOCITY_HORIZONTAL_MODEL_DIFF_GRACE + && actualVelocityDiff + GLIDING_CURRENT_VELOCITY_BETTER_MODEL_GRACE < modelVelocityDiff) { + return true; + } + final double dot = vx * xDemand + vz * zDemand; + if (dot < -Magic.PREDICTION_EPSILON) { + return false; + } + final double demand = Math.sqrt(demandSq); + final double velocityAmount = Math.sqrt(velocitySq); + if (demand > velocityAmount + GLIDING_CURRENT_VELOCITY_HORIZONTAL_AMOUNT_GRACE + || thisMove.hDistance > velocityAmount + GLIDING_CURRENT_VELOCITY_HORIZONTAL_MOVE_GRACE) { + return false; + } + final double perpendicular = Math.abs(vx * zDemand - vz * xDemand) / velocityAmount; + return perpendicular <= GLIDING_CURRENT_VELOCITY_HORIZONTAL_PERPENDICULAR_GRACE; + } + + private void addGlidingVerticalPredictionTags(final double offsetV) { + addTag(SurvivalFlyTags.GLIDE_VERTICAL_PREDICTION_MISS); + addTag(offsetV > 0.0 + ? SurvivalFlyTags.GLIDE_VERTICAL_ACTUAL_ABOVE_MODEL + : SurvivalFlyTags.GLIDE_VERTICAL_ACTUAL_BELOW_MODEL); + } + + private void addGlidingHorizontalPredictionTags(final double offsetH) { + addTag(SurvivalFlyTags.GLIDE_HORIZONTAL_PREDICTION_MISS); + addTag(offsetH > 0.0 + ? SurvivalFlyTags.GLIDE_HORIZONTAL_ACTUAL_ABOVE_MODEL + : SurvivalFlyTags.GLIDE_HORIZONTAL_ACTUAL_BELOW_MODEL); + } + + private void addGlidingLookAndFireworkTags(final MovingData data, final PlayerLocation to, + final PlayerMoveData move, + final double offsetV, final double offsetH) { + final String pitchBand = getElytraPitchBand(to.getPitch()); + addTag("glide_pitch_" + pitchBand.toLowerCase(Locale.ROOT)); + if (data.fireworksBoostDuration <= 0) { + return; + } + addTag("glide_firework_pitch_" + pitchBand.toLowerCase(Locale.ROOT)); + if (offsetV < -0.05D) { + addTag("glide_firework_vertical_model_high"); + } + else if (offsetV > 0.05D) { + addTag("glide_firework_vertical_model_low"); + } + if (offsetH < -0.05D || move.hDistance + 0.05D < move.hAllowedDistance) { + addTag("glide_firework_horizontal_model_fast"); + } + else if (offsetH > 0.05D) { + addTag("glide_firework_horizontal_model_slow"); + } + } + + private String getElytraPitchBand(final float pitch) { + if (pitch <= -15.0f) { + return "UP_STEEP"; + } + if (pitch <= -5.0f) { + return "UP"; + } + if (pitch < 5.0f) { + return "LEVEL"; + } + if (pitch < 15.0f) { + return "DOWN"; + } + return "DOWN_STEEP"; + } + + private void addTag(final String tag) { + if (!tags.contains(tag)) { + tags.add(tag); + } + } + + private void logConsoleDetails(final double result, + final Player player, final PlayerLocation from, final PlayerLocation to, + final Location setback, + final MovingData data, final IPlayerData pData, + final PlayerMoveData thisMove, final PlayerMoveData lastMove, + final double hAllowedDistance, final double hDistanceAboveLimit, + final double yAllowedDistance, final double yDistanceAboveLimit, + final boolean fromOnGround, final boolean resetFrom, + final boolean toOnGround, final boolean resetTo, + final int tick, final long now, final int multiMoveCount, + final boolean isNormalOrPacketSplitMove) { + final MovementModelBranch modelBranch = selectExplicitMovementModel(player, pData, data, from, to, thisMove, lastMove); + final String movementMode = SurvivalFlyDiagnostics.formatMovementMode(player, data, from, to, thisMove, + fromOnGround, resetFrom, toOnGround, resetTo); + final String subcheck = getViolationSubCheck(player, from, to, data); + final String axis = getViolationAxis(thisMove); + final boolean partialSupport = isPartialSupportNear(from, to); + final double partialHorizontalLimit = partialSupport ? getPartialSupportHorizontalModelLimit(from, to, thisMove, lastMove) : 0.0D; + final double partialVerticalLimit = partialSupport ? getPartialSupportVerticalModelLimit(from, to, thisMove) : 0.0D; + final double partialVerticalClamp = partialSupport ? getPartialSupportVerticalClampModel(from, to, thisMove.yDistance) : 0.0D; + final boolean jumpProbe = isJumpDiagnosticProbe(data, thisMove); + player.getServer().getLogger().info(SurvivalFlyDiagnostics.formatDetail(player, from, to, setback, + data, pData, thisMove, lastMove, isBedrockPlayer(player, pData), + String.valueOf(getMovementClientVersion(pData)), + movementMode, + subcheck, + SurvivalFlyDiagnostics.formatReadableDebugSummary(subcheck, movementMode, modelBranch.tag, axis, + hDistanceAboveLimit, yDistanceAboveLimit, tags), + result, hAllowedDistance, hDistanceAboveLimit, yAllowedDistance, yDistanceAboveLimit, + fromOnGround, resetFrom, toOnGround, resetTo, tick, now, multiMoveCount, + isNormalOrPacketSplitMove, + SurvivalFlyDiagnostics.formatCompactModelProbe(modelBranch.tag, axis, + hDistanceAboveLimit, yDistanceAboveLimit, lastMove.toIsValid, player.getVelocity(), + data.getHorizontalVelocityTracker().hasQueued(), !thisMove.verVelUsed.isEmpty(), + partialSupport, partialSupport ? getPartialSupportTypeTag(from, to) : "none", + partialHorizontalLimit, partialVerticalLimit, partialVerticalClamp, + jumpProbe, thisMove.isJump, thisMove.isStepUp, thisMove.couldStepUp, data.sfJumpPhase), + SurvivalFlyDiagnostics.formatElytraModel(player, from, to, data, thisMove, + hAllowedDistance, yAllowedDistance, getElytraPitchBand(to.getPitch()), + getYawDelta(from.getYaw(), to.getYaw())), + StringUtil.join(tags, "+"))); + } + + private boolean isJumpDiagnosticProbe(final MovingData data, final PlayerMoveData move) { + return move.isJump || move.couldStepUp || move.isStepUp || data.sfJumpPhase > 0 || tags.contains("jump_env"); + } + + private boolean isVelocityDiagnosticProbe(final MovingData data, final PlayerMoveData move) { + return data.getHorizontalVelocityTracker().hasQueued() + || !move.verVelUsed.isEmpty() + || tags.contains("hvel_current") || tags.contains("hvel"); + } + + private double getYawDelta(final float fromYaw, final float toYaw) { + double delta = toYaw - fromYaw; + while (delta > 180.0D) { + delta -= 360.0D; + } + while (delta < -180.0D) { + delta += 360.0D; + } + return delta; + } + /** * Handles a violation for Survivalfly. * @@ -2229,6 +8518,13 @@ private Location handleViolation(final double result, final Player player, final PlayerLocation from, final PlayerLocation to, final MovingData data, final MovingConfig cc) { // Increment violation level. + addViolationModeTag(player, from, to, data.playerMoves.getCurrentMove(), data); + addViolationDiagnosticTags(player, from, to, data); + if (shouldUseNoFireworkElytraDownwardSetBack(player, data)) { + addTag(shouldUseNoFireworkElytraStartSetBack(to, data) + ? "glide_no_firework_start_setback" + : "glide_no_firework_downward_setback"); + } data.survivalFlyVL += result; data.sfVLMoveCount = data.getPlayerMoveCount(); final ViolationData vd = new ViolationData(this, player, data.survivalFlyVL, result, cc.survivalFlyActions); @@ -2241,7 +8537,8 @@ private Location handleViolation(final double result, // Some resetting is done in MovingListener. if (executeActions(vd).willCancel()) { // Set back + view direction of to (more smooth). - return MovingUtil.getApplicableSetBackLocation(player, to.getYaw(), to.getPitch(), to, data, cc); + final Location fallback = MovingUtil.getApplicableSetBackLocation(player, to.getYaw(), to.getPitch(), to, data, cc); + return getNoFireworkElytraSetBackLocation(player, to, to.getYaw(), to.getPitch(), data, fallback); } else { data.sfJumpPhase = 0; @@ -2250,6 +8547,364 @@ private Location handleViolation(final double result, } } + private Location getNoFireworkElytraSetBackLocation(final Player player, final PlayerLocation ref, + final float refYaw, final float refPitch, + final MovingData data, final Location fallback) { + if (!shouldUseNoFireworkElytraDownwardSetBack(player, data)) { + return fallback; + } + final Location startCorrection = getNoFireworkElytraStartSetBackLocation(ref, refYaw, refPitch, data); + if (startCorrection != null) { + return startCorrection; + } + final double drop = getNoFireworkElytraSetBackDrop(data); + final Vector allowedDrop = ref.collide(new Vector(0.0D, -drop, 0.0D), false, ref.getBoundingBox()); + if (allowedDrop.getY() >= -Magic.PREDICTION_EPSILON) { + return fallback; + } + final Location correction = ref.getLocation(); + correction.setYaw(refYaw); + correction.setPitch(refPitch); + correction.setY(ref.getY() + allowedDrop.getY()); + if (fallback != null && fallback.getWorld().equals(correction.getWorld()) + && fallback.getY() < correction.getY()) { + return fallback; + } + /* + * Elytra no-firework correction model: ordinary SurvivalFly setbacks are + * updated during accepted gliding, so a hover cheat can otherwise be + * cancelled back to an air checkpoint. For no-energy ascent/hover, choose + * a collision-aware downward correction so repeated violations force real + * descent instead of preserving the illegal altitude. + */ + addTag("glide_no_firework_downward_setback"); + return correction; + } + + private Location getNoFireworkElytraStartSetBackLocation(final PlayerLocation ref, + final float refYaw, final float refPitch, + final MovingData data) { + if (!shouldUseNoFireworkElytraStartSetBack(ref, data)) { + return null; + } + final Location start = data.elytraNoFireworkStart; + /* + * Elytra no-firework correction model: if the player climbed above the + * point where the no-firework glide started, snap back to that anchor. + * Hovering at roughly the same height still uses the downward correction + * below, because the start anchor would not create descent. + */ + final Location correction = start.clone(); + correction.setYaw(refYaw); + correction.setPitch(refPitch); + addTag("glide_no_firework_start_setback"); + return correction; + } + + private boolean shouldUseNoFireworkElytraStartSetBack(final PlayerLocation ref, final MovingData data) { + final Location start = data.elytraNoFireworkStart; + if (start == null || !start.getWorld().equals(ref.getWorld())) { + return false; + } + final double yGain = ref.getY() - start.getY(); + if (yGain < GLIDING_NO_FIREWORK_START_SETBACK_MIN_GAIN) { + return false; + } + final double dx = ref.getX() - start.getX(); + final double dz = ref.getZ() - start.getZ(); + return dx * dx + dz * dz <= GLIDING_NO_FIREWORK_START_SETBACK_MAX_HORIZONTAL + * GLIDING_NO_FIREWORK_START_SETBACK_MAX_HORIZONTAL; + } + + private boolean shouldUseNoFireworkElytraDownwardSetBack(final Player player, final MovingData data) { + if (!Bridge1_9.isGliding(player) || data.fireworksBoostDuration > 0) { + return false; + } + final double deficit = data.hoverExpectedDrop - data.hoverActualDrop; + return tags.contains("glide_no_firework_ascent_energy_enforced") + || tags.contains("glide_no_firework_descent_budget_enforced") + || tags.contains("glide_no_firework_flat_hover_model_miss") + || tags.contains("glide_no_firework_descent_budget_model_miss") + || deficit > GLIDING_NO_FIREWORK_FLAT_DROP_DEFICIT; + } + + private double getNoFireworkElytraSetBackDrop(final MovingData data) { + final double deficit = Math.max(0.0D, data.hoverExpectedDrop - data.hoverActualDrop); + final double modelDrop = deficit * GLIDING_NO_FIREWORK_SETBACK_DEFICIT_FACTOR + + Math.max(0.0D, data.elytraNoFireworkAscentDebt) * GLIDING_NO_FIREWORK_SETBACK_DEBT_FACTOR + + Math.max(0.0D, data.elytraNoFireworkAscentExcess) * GLIDING_NO_FIREWORK_SETBACK_EXCESS_FACTOR; + return Math.min(GLIDING_NO_FIREWORK_SETBACK_MAX_DROP, + Math.max(GLIDING_NO_FIREWORK_SETBACK_MIN_DROP, modelDrop)); + } + + private void addViolationModeTag(final Player player, final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData move, final MovingData data) { + final String mode = SurvivalFlyDiagnostics.formatMovementMode(player, data, from, to, move, + from.isOnGround(), from.isResetCond(), to.isOnGround(), to.isResetCond()); + addTag("mode_" + mode.toLowerCase(Locale.ROOT)); + } + + private void addViolationDiagnosticTags(final Player player, final PlayerLocation from, final PlayerLocation to, + final MovingData data) { + // Diagnostic info: label the concrete SurvivalFly branch so false flags are not hidden by the umbrella check name. + final PlayerMoveData move = data.playerMoves.getCurrentMove(); + final PlayerMoveData lastMove = data.playerMoves.getFirstPastMove(); + final double hOverRaw = move.hDistance - move.hAllowedDistance; + final double yOverRaw = move.yDistance - move.yAllowedDistance; + if (hOverRaw > Magic.PREDICTION_EPSILON && Math.abs(yOverRaw) > Magic.PREDICTION_EPSILON) { + addTag("axis_hy"); + } + else if (hOverRaw > Magic.PREDICTION_EPSILON) { + addTag("axis_h"); + } + else if (Math.abs(yOverRaw) > Magic.PREDICTION_EPSILON) { + addTag("axis_y"); + } + if (yOverRaw > Magic.PREDICTION_EPSILON) { + addTag("y_above_model"); + } + else if (yOverRaw < -Magic.PREDICTION_EPSILON) { + addTag("y_below_model"); + } + if (tags.contains("onground_env")) { + addTag("branch_ground_env"); + } + if (tags.contains("jump_env") || move.touchedGround || move.from.onGroundOrResetCond && !move.to.onGroundOrResetCond) { + addTag("branch_jump_or_step"); + } + if (tags.contains("v_air") || (!move.from.onGroundOrResetCond && !move.to.onGroundOrResetCond)) { + addTag("branch_air_model"); + } + if (from.isInLiquid() || to.isInLiquid() || move.from.inLiquid || move.to.inLiquid || tags.contains("v_water")) { + addTag("branch_liquid"); + } + if (move.collideX || move.collideY || move.collideZ || move.collidesHorizontally || move.negligibleHorizontalCollision) { + addTag("branch_collision"); + } + if (tags.contains("hvel") || tags.contains("hvel_current") || !move.verVelUsed.isEmpty() + || data.getHorizontalVelocityTracker().hasQueued()) { + addTag("branch_velocity"); + } + if (!lastMove.toIsValid) { + addTag("branch_last_invalid"); + } + if (data.timeSinceSetBack < 20) { + addTag("branch_recent_setback"); + } + if (Bridge1_9.isGliding(player) || Bridge1_9.isWearingElytra(player) || data.fireworksBoostDuration > 0) { + addTag("branch_elytra_state"); + } + final MovementModelBranch modelBranch = selectExplicitMovementModel(player, DataManager.getPlayerData(player), + data, from, to, move, lastMove); + if (modelBranch != MovementModelBranch.NONE) { + addTag("branch_model_" + modelBranch.tag); + } + addModelProbeDiagnosticTags(player, from, to, data, move, lastMove, modelBranch, hOverRaw, yOverRaw); + if (!from.isPassable() || !to.isPassable()) { + addTag("branch_inside_block"); + } + addTag("subcheck_" + getViolationSubCheck(player, from, to, data).toLowerCase(Locale.ROOT)); + } + + private void addModelProbeDiagnosticTags(final Player player, final PlayerLocation from, final PlayerLocation to, + final MovingData data, final PlayerMoveData move, + final PlayerMoveData lastMove, final MovementModelBranch modelBranch, + final double hOverRaw, final double yOverRaw) { + // Diagnostic info: these tags do not grant movement; they mark the next model boundary to refine. + if (modelBranch != MovementModelBranch.NONE) { + addTag("diag_probe_" + modelBranch.tag); + } + if (!lastMove.toIsValid) { + addTag("diag_last_invalid_probe"); + if (Bridge1_9.isWearingElytra(player) || Bridge1_9.isGliding(player)) { + addTag("diag_last_invalid_elytra"); + } + if (hOverRaw > Magic.PREDICTION_EPSILON && Math.abs(yOverRaw) > Magic.PREDICTION_EPSILON) { + addTag("diag_last_invalid_hy"); + } + if (isLastInvalidStandstillResyncModel(move)) { + addTag("diag_last_invalid_standstill_resync_candidate"); + } + if (isLastInvalidVelocityResyncCandidate(player, move)) { + addTag("diag_last_invalid_velocity_resync_candidate"); + } + if (isLastInvalidGroundInputCandidate(move)) { + addTag("diag_last_invalid_ground_input_candidate"); + } + if (isLastInvalidVelocityHandoffCandidate(player, move)) { + addTag("diag_last_invalid_velocity_handoff_candidate"); + } + if (isLastInvalidAirStallCandidate(player, move)) { + addTag("diag_last_invalid_air_stall_candidate"); + } + if (isLastInvalidJumpContinuationCandidate(move)) { + addTag("diag_last_invalid_jump_continuation_candidate"); + if (isLastInvalidLowJumpContinuationCandidate(move)) { + addTag("diag_last_invalid_low_jump_continuation_candidate"); + } + } + } + if (hasCollisionSignal(move)) { + addTag("diag_collision_probe"); + if (isCollisionHorizontalSlideCandidate(move)) { + addTag("diag_collision_horizontal_slide_candidate"); + } + if (isCollisionVerticalTruncationCandidate(move)) { + addTag("diag_collision_vertical_truncation_candidate"); + } + } + if (isItemResyncMovementContext(player, from, to, move)) { + addTag("diag_itemresync_model_candidate"); + } + if (isAirInertiaMovementContext(player, from, to, move, lastMove)) { + addTag("diag_air_inertia_candidate"); + } + if (isAirCurrentVelocityContext(player, from, to, move)) { + addTag("diag_air_current_velocity_candidate"); + } + if (isGroundLandingCarryContext(player, from, to, move, lastMove)) { + addTag("diag_ground_landing_carry_candidate"); + } + if (isGroundVelocityCarryContext(player, from, to, move)) { + addTag("diag_ground_velocity_carry_candidate"); + } + if (Bridge1_9.isWearingElytra(player) && !Bridge1_9.isGliding(player)) { + addTag("diag_elytra_equipped_probe"); + if (isElytraEquippedTransitionContext(player, data, from, to, move, lastMove)) { + addTag("diag_elytra_equipped_transition_probe"); + } + } + if (isPartialSupportNear(from, to)) { + addTag("diag_partial_support_probe"); + final double horizontalLimit = getPartialSupportHorizontalModelLimit(from, to, move, lastMove); + if (move.hDistance > horizontalLimit) { + addTag("diag_partial_support_h_limit_miss"); + } + if (move.yDistance < -Magic.PREDICTION_EPSILON) { + final double verticalClamp = getPartialSupportVerticalClampModel(from, to, move.yDistance); + if (verticalClamp > 0.0D) { + addTag("diag_partial_support_clamp_candidate"); + } + else { + addTag("diag_partial_support_clamp_unit_miss"); + } + if (getPartialSupportLandingClampFraction(to) >= 0.0D + && (to.isOnGroundOrResetCond() || move.to.onGroundOrResetCond + || move.touchedGround || move.touchedGroundWorkaround)) { + addTag("diag_partial_support_landing_clamp_candidate"); + } + } + if (isJumpDiagnosticProbe(data, move)) { + addTag("diag_partial_support_jump_probe"); + } + } + if (isJumpDiagnosticProbe(data, move)) { + addTag("diag_jump_probe"); + if (Math.abs(move.yDistance - BEDROCK_HALF_STEP_VERTICAL_MOVE) <= BEDROCK_HALF_STEP_VERTICAL_EPSILON) { + addTag("diag_jump_half_block_candidate"); + } + if (isModernHalfStepContext(player, DataManager.getPlayerData(player), from, to, move, lastMove)) { + addTag("diag_modern_half_step_candidate"); + } + if (isJumpCarryContext(player, from, to, move, lastMove)) { + addTag("diag_jump_carry_candidate"); + if (isLowJumpCarryContext(move, lastMove)) { + addTag("diag_low_jump_carry_candidate"); + } + } + if (yOverRaw > Magic.PREDICTION_EPSILON) { + addTag("diag_jump_y_above_model"); + } + } + if (isVelocityDiagnosticProbe(data, move)) { + addTag("diag_velocity_probe"); + } + } + + private String getViolationSubCheck(final Player player, final PlayerLocation from, final PlayerLocation to, + final MovingData data) { + // Diagnostic info: choose a human-readable subcheck for console output and action tags. + final PlayerMoveData move = data.playerMoves.getCurrentMove(); + final String axis = getViolationAxis(move); + final double yOverRaw = move.yDistance - move.yAllowedDistance; + if (Bridge1_9.isGliding(player)) { + if (data.fireworksBoostDuration > 0) { + return "ELYTRA_FIREWORK_" + axis; + } + if (yOverRaw > Magic.PREDICTION_EPSILON) { + return "ELYTRA_GLIDE_Y_ABOVE"; + } + if (yOverRaw < -Magic.PREDICTION_EPSILON) { + return "ELYTRA_GLIDE_Y_BELOW"; + } + return "ELYTRA_GLIDE_" + axis; + } + if (from.isInWater() || to.isInWater() || move.from.inWater || move.to.inWater || tags.contains("v_water")) { + return "WATER_" + axis; + } + if (from.isInLava() || to.isInLava() || move.from.inLava || move.to.inLava) { + return "LAVA_" + axis; + } + if (from.isOnClimbable() || to.isOnClimbable() || move.from.onClimbable || move.to.onClimbable) { + return "CLIMBABLE_" + axis; + } + if (Bridge1_9.isWearingElytra(player)) { + if (data.fireworksBoostDuration > 0) { + return "ELYTRA_EQUIPPED_FIREWORK_" + axis; + } + return "ELYTRA_EQUIPPED_" + axis; + } + if (from.isInWeb() || to.isInWeb() || move.from.inWeb || move.to.inWeb) { + return "WEB_" + axis; + } + if (from.isInBerryBush() || to.isInBerryBush() || move.from.inBerryBush || move.to.inBerryBush) { + return "BERRY_BUSH_" + axis; + } + if (from.isInPowderSnow() || to.isInPowderSnow() || move.from.inPowderSnow || move.to.inPowderSnow) { + return "POWDER_SNOW_" + axis; + } + if (!from.isPassable() || !to.isPassable()) { + return "INSIDE_BLOCK_" + axis; + } + if (move.collideX || move.collideY || move.collideZ || move.collidesHorizontally || move.negligibleHorizontalCollision) { + return "COLLISION_" + axis; + } + if (tags.contains("hvel") || tags.contains("hvel_current") || !move.verVelUsed.isEmpty() + || data.getHorizontalVelocityTracker().hasQueued()) { + return "VELOCITY_" + axis; + } + if (data.timeSinceSetBack < 20) { + return "SETBACK_RECOVERY_" + axis; + } + if (move.isStepUp || move.couldStepUp) { + return "GROUND_STEP_" + axis; + } + if (move.isJump || data.sfJumpPhase > 0 || tags.contains("jump_env")) { + return "GROUND_JUMP_" + axis; + } + if (from.isOnGround() || to.isOnGround() || from.isResetCond() || to.isResetCond() + || move.from.onGroundOrResetCond || move.to.onGroundOrResetCond || tags.contains("onground_env")) { + return "GROUND_" + axis; + } + return "AIR_" + axis; + } + + private String getViolationAxis(final PlayerMoveData move) { + // Diagnostic info: split movement failures by horizontal, vertical, or combined model mismatch. + final boolean hOver = move.hDistance - move.hAllowedDistance > Magic.PREDICTION_EPSILON; + final boolean yOver = Math.abs(move.yDistance - move.yAllowedDistance) > Magic.PREDICTION_EPSILON; + if (hOver && yOver) { + return "HY"; + } + if (hOver) { + return "H"; + } + if (yOver) { + return "Y"; + } + return "MODEL"; + } + /** * Hover violations have to be handled in this check, because they are handled as SurvivalFly violations (needs executeActions). @@ -2264,11 +8919,22 @@ public final void handleHoverViolation(final Player player, final PlayerLocation vd.setParameter(ParameterName.LOCATION_FROM, String.format(Locale.US, "%.2f, %.2f, %.2f", loc.getX(), loc.getY(), loc.getZ())); vd.setParameter(ParameterName.LOCATION_TO, "(HOVER)"); vd.setParameter(ParameterName.DISTANCE, "0.0(HOVER)"); - vd.setParameter(ParameterName.TAGS, "hover"); + // Diagnostic info: hover is a SurvivalFly action path, so tag it as its own subcheck. + final boolean elytraHover = Bridge1_9.isGliding(player); + final String hoverTags = elytraHover + ? "subcheck_hover+branch_elytra_model+hover+hover_elytra_budget_model+hover_descent_budget_model" + + (shouldUseNoFireworkElytraDownwardSetBack(player, data) + ? (shouldUseNoFireworkElytraStartSetBack(loc, data) + ? "+glide_no_firework_start_setback" + : "+glide_no_firework_downward_setback") + : "") + : "subcheck_hover+branch_air_model+hover+hover_air_stall_model+hover_descent_budget_model"; + vd.setParameter(ParameterName.TAGS, hoverTags); } if (executeActions(vd).willCancel()) { // Set back or kick. - final Location newTo = MovingUtil.getApplicableSetBackLocation(player, loc.getYaw(), loc.getPitch(), loc, data, cc); + final Location fallback = MovingUtil.getApplicableSetBackLocation(player, loc.getYaw(), loc.getPitch(), loc, data, cc); + final Location newTo = getNoFireworkElytraSetBackLocation(player, loc, loc.getYaw(), loc.getPitch(), data, fallback); if (newTo != null) { data.prepareSetBack(newTo); SchedulerHelper.teleportEntity(player, newTo, BridgeMisc.TELEPORT_CAUSE_CORRECTION_OF_POSITION); @@ -2342,4 +9008,4 @@ private void outputDebug(final Player player, final PlayerLocation to, final Pla private void logPostViolationTags(final Player player) { debug(player, "SurvivalFly Post violation handling tag update:\n" + StringUtil.join(tags, "+")); } -} \ No newline at end of file +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFlyDiagnostics.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFlyDiagnostics.java new file mode 100644 index 0000000000..967ce42ddb --- /dev/null +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFlyDiagnostics.java @@ -0,0 +1,463 @@ +/* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package fr.neatmonster.nocheatplus.checks.moving.player; + +import java.util.Locale; + +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; + +import fr.neatmonster.nocheatplus.checks.moving.MovingData; +import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveData; +import fr.neatmonster.nocheatplus.compat.Bridge1_9; +import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.StringUtil; +import fr.neatmonster.nocheatplus.utilities.location.PlayerLocation; +import fr.neatmonster.nocheatplus.utilities.math.MathUtil; +import fr.neatmonster.nocheatplus.utilities.math.TrigUtil; +import fr.neatmonster.nocheatplus.utilities.moving.Magic; + +/** + * Formatting-only helpers for SurvivalFly compatibility diagnostics. + * Keep model math in SurvivalFly; this class only turns already-computed state + * into readable console output. + */ +final class SurvivalFlyDiagnostics { + + private SurvivalFlyDiagnostics() {} + + static String formatReadableDebugSummary(final String subcheck, final String movementMode, + final String modelBranch, final String axis, + final double hDistanceAboveLimit, + final double yDistanceAboveLimit, + final Iterable tags) { + return subcheck.toLowerCase(Locale.ROOT) + + "{mode=" + movementMode.toLowerCase(Locale.ROOT) + + ",model=" + modelBranch + + ",axis=" + axis.toLowerCase(Locale.ROOT) + + ",hOver=" + StringUtil.fdec6.format(Math.max(hDistanceAboveLimit, 0.0D)) + + ",yOver=" + StringUtil.fdec6.format(Math.max(yDistanceAboveLimit, 0.0D)) + + ",tags=" + formatPrimaryReadableTags(tags) + + "}"; + } + + private static String formatPrimaryReadableTags(final Iterable tags) { + final StringBuilder builder = new StringBuilder(120); + int count = 0; + for (final String tag : tags) { + if (tag.startsWith("subcheck_") || tag.startsWith("branch_") + || tag.startsWith("model_") || tag.startsWith("diag_")) { + if (count > 0) { + builder.append('+'); + } + builder.append(tag); + if (++count == 5) { + break; + } + } + } + return builder.length() == 0 ? "none" : builder.toString(); + } + + static String formatMovementMode(final Player player, final MovingData data, + final PlayerLocation from, final PlayerLocation to, + final PlayerMoveData move, + final boolean fromOnGround, final boolean resetFrom, + final boolean toOnGround, final boolean resetTo) { + if (player.isInsideVehicle()) { + return player.getVehicle() == null ? "VEHICLE" : "VEHICLE_" + player.getVehicle().getType().name(); + } + if (Bridge1_9.isGliding(player)) { + return data.fireworksBoostDuration > 0 ? "ELYTRA_FIREWORK" : "ELYTRA_GLIDING"; + } + if (from.isInWater() || to.isInWater() || move.from.inWater || move.to.inWater) { + return "WATER"; + } + if (from.isInLava() || to.isInLava() || move.from.inLava || move.to.inLava) { + return "LAVA"; + } + if (from.isOnClimbable() || to.isOnClimbable() || move.from.onClimbable || move.to.onClimbable) { + return "CLIMBABLE"; + } + if (Bridge1_9.isWearingElytra(player)) { + return fromOnGround || resetFrom || toOnGround || resetTo ? "ELYTRA_EQUIPPED_GROUND" : "ELYTRA_EQUIPPED_AIR"; + } + if (from.isInWeb() || to.isInWeb() || move.from.inWeb || move.to.inWeb) { + return "WEB"; + } + if (from.isInBerryBush() || to.isInBerryBush() || move.from.inBerryBush || move.to.inBerryBush) { + return "BERRY_BUSH"; + } + if (from.isInPowderSnow() || to.isInPowderSnow() || move.from.inPowderSnow || move.to.inPowderSnow) { + return "POWDER_SNOW"; + } + if (fromOnGround || resetFrom || toOnGround || resetTo || move.from.onGroundOrResetCond || move.to.onGroundOrResetCond) { + return "GROUND"; + } + return "AIR"; + } + + static String formatCompactModelProbe(final String branchTag, final String axis, + final double hDistanceAboveLimit, + final double yDistanceAboveLimit, + final boolean lastMoveValid, + final Vector velocity, + final boolean queuedHorizontalVelocity, + final boolean usedVerticalVelocity, + final boolean partialSupport, + final String partialSupportTag, + final double partialHorizontalLimit, + final double partialVerticalLimit, + final double partialVerticalClamp, + final boolean jumpProbe, + final boolean isJump, + final boolean isStepUp, + final boolean couldStepUp, + final int jumpPhase) { + // Diagnostic info: compact branch context for future false-positive reports without the old full environment dump. + final StringBuilder builder = new StringBuilder(260); + builder.append("branch=").append(branchTag) + .append(",axis=").append(axis) + .append(",hOver=").append(StringUtil.fdec6.format(Math.max(hDistanceAboveLimit, 0.0D))) + .append(",yOver=").append(StringUtil.fdec6.format(Math.max(yDistanceAboveLimit, 0.0D))) + .append(",last=").append(lastMoveValid ? "valid" : "invalid") + .append(",vel=").append(formatVector(velocity)) + .append(",queuedH=").append(queuedHorizontalVelocity) + .append(",usedY=").append(usedVerticalVelocity); + if (partialSupport) { + builder.append(",partial=").append(partialSupportTag) + .append(':').append(StringUtil.fdec6.format(partialHorizontalLimit)) + .append('/').append(StringUtil.fdec6.format(partialVerticalLimit)) + .append("/clamp=").append(StringUtil.fdec6.format(partialVerticalClamp)); + } + if (jumpProbe) { + builder.append(",jump=").append(isJump) + .append('/').append(isStepUp) + .append('/').append(couldStepUp) + .append(",phase=").append(jumpPhase); + } + if (!lastMoveValid) { + builder.append(",resync=last-invalid"); + } + return builder.toString(); + } + + static String formatDetail(final Player player, final PlayerLocation from, final PlayerLocation to, + final Location setback, final MovingData data, + final IPlayerData pData, final PlayerMoveData move, final PlayerMoveData lastMove, + final boolean bedrock, final String movementClient, final String movementMode, + final String subcheck, final String summary, final double result, + final double hAllowedDistance, final double hDistanceAboveLimit, + final double yAllowedDistance, final double yDistanceAboveLimit, final boolean fromOnGround, + final boolean resetFrom, final boolean toOnGround, final boolean resetTo, + final int tick, final long now, final int multiMoveCount, + final boolean packetSplit, final String modelProbe, + final String elytraModel, final String tags) { + final StringBuilder builder = new StringBuilder(1000); + builder.append("[NCP][SurvivalFly][detail] player=").append(player.getName()) + .append(" bedrock=").append(bedrock) + .append(" bedrockData=").append(pData.isBedrockPlayer()) + .append(" uuid=").append(player.getUniqueId()) + .append(" client=").append(pData.getClientVersion()) + .append(" movementClient=").append(movementClient) + .append(" tick=").append(tick) + .append(" now=").append(now) + .append(" moveCount=").append(data.getPlayerMoveCount()) + .append(" multiMove=").append(multiMoveCount) + .append(" packetSplit=").append(packetSplit) + .append(" movementMode=").append(movementMode) + .append(" subcheck=").append(subcheck) + .append(" summary=").append(summary) + .append(tags.contains(SurvivalFlyTags.ELYTRA_MODEL_DATA_ONLY) ? " dataOnlyVL=" : " addVL=") + .append(StringUtil.fdec3.format(result)) + .append(" totalVL=").append(StringUtil.fdec3.format(data.survivalFlyVL)) + .append(" setback=").append(formatBukkitLocation(setback)) + .append(" from=").append(formatLocation(from)) + .append(" to=").append(formatLocation(to)) + .append(" ground=").append(fromOnGround ? "on" : resetFrom ? "reset" : "air") + .append("->").append(toOnGround ? "on" : resetTo ? "reset" : "air") + .append(" playerState=sprint:").append(player.isSprinting()) + .append(",sneak:").append(player.isSneaking()) + .append(",fly:").append(player.isFlying()) + .append(",allowFlight:").append(player.getAllowFlight()) + .append(",vehicle:").append(player.isInsideVehicle()) + .append(",gliding:").append(Bridge1_9.isGliding(player)) + .append(",elytra:").append(Bridge1_9.isWearingElytra(player)) + .append(",walkSpeed:").append(StringUtil.fdec3.format(player.getWalkSpeed())) + .append(" medium=water:").append(StringUtil.fdec3.format(move.submergedWaterHeight)) + .append(",lava:").append(StringUtil.fdec3.format(move.submergedLavaHeight)) + .append(",friction:").append(StringUtil.fdec3.format(data.lastFrictionHorizontal)).append("->").append(StringUtil.fdec3.format(data.nextFrictionHorizontal)) + .append(",stuckH:").append(StringUtil.fdec3.format(data.lastStuckInBlockHorizontal)).append("->").append(StringUtil.fdec3.format(data.nextStuckInBlockHorizontal)) + .append(" speedData=walk:").append(StringUtil.fdec3.format(data.walkSpeed)).append("->").append(StringUtil.fdec3.format(data.nextWalkSpeed)) + .append(",speedTick:").append(data.speedTick) + .append(",jumpDelay:").append(data.jumpDelay) + .append(",fireworkBoost:").append(data.fireworksBoostDuration) + .append(",setbackAge:").append(data.timeSinceSetBack) + .append(" jumpPhase=").append(data.sfJumpPhase) + .append(" liftOff=").append(data.liftOffEnvelope.name()) + .append(" lastValid=").append(lastMove.toIsValid) + .append(" movementModel=").append(formatMovementModel(move, hAllowedDistance, hDistanceAboveLimit, yAllowedDistance, yDistanceAboveLimit)) + .append(" physicsModel=").append(formatPhysicsModel(player, data, move, lastMove, hAllowedDistance, yAllowedDistance)) + .append(" flightTrace=").append(formatFlightTrace(player, data, move, lastMove, movementMode, from, to)) + .append(" modelProbe=").append(modelProbe) + .append(" elytraModel=").append(elytraModel) + .append(" tags=").append(tags); + return builder.toString(); + } + + static String formatMovementModel(final PlayerMoveData move, + final double hAllowedDistance, final double hDistanceAboveLimit, + final double yAllowedDistance, final double yDistanceAboveLimit) { + final double xOver = move.xDistance - move.xAllowedDistance; + final double yOver = move.yDistance - yAllowedDistance; + final double zOver = move.zDistance - move.zAllowedDistance; + final double hOverRaw = move.hDistance - hAllowedDistance; + final StringBuilder builder = new StringBuilder(320); + builder.append("actual=").append(formatVector(move.xDistance, move.yDistance, move.zDistance)) + .append(",allowed=").append(formatVector(move.xAllowedDistance, yAllowedDistance, move.zAllowedDistance)) + .append(",overVector=").append(formatVector(xOver, yOver, zOver)) + .append(",hOverRaw=").append(StringUtil.fdec6.format(hOverRaw)) + .append(",hOverApplied=").append(StringUtil.fdec6.format(Math.max(hDistanceAboveLimit, 0.0))) + .append(",yOverRaw=").append(StringUtil.fdec6.format(yOver)) + .append(",yOverApplied=").append(StringUtil.fdec6.format(Math.max(yDistanceAboveLimit, 0.0))) + .append(",ratio=h:").append(formatRatio(move.hDistance, hAllowedDistance)) + .append(",x:").append(formatRatio(Math.abs(move.xDistance), Math.abs(move.xAllowedDistance))) + .append(",y:").append(formatRatio(Math.abs(move.yDistance), Math.abs(yAllowedDistance))) + .append(",z:").append(formatRatio(Math.abs(move.zDistance), Math.abs(move.zAllowedDistance))) + .append(",distSq=").append(StringUtil.fdec6.format(move.distanceSquared)) + .append(",modelFlying=").append(move.modelFlying == null ? "none" : move.modelFlying.getId()) + .append(",flyCheck=").append(move.flyCheck == null ? "none" : move.flyCheck.name()); + return builder.toString(); + } + + static String formatPhysicsModel(final Player player, final MovingData data, + final PlayerMoveData move, final PlayerMoveData lastMove, + final double hAllowedDistance, final double yAllowedDistance) { + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double actualMinusVelocityH = MathUtil.dist(move.xDistance - velocity.getX(), move.zDistance - velocity.getZ()); + final double allowedMinusVelocityH = MathUtil.dist(move.xAllowedDistance - velocity.getX(), move.zAllowedDistance - velocity.getZ()); + final boolean air = !move.from.onGroundOrResetCond && !move.to.onGroundOrResetCond; + final boolean liquid = move.from.inLiquid || move.to.inLiquid || move.submergedWaterHeight > 0.0D || move.submergedLavaHeight > 0.0D; + final boolean climbable = move.from.onClimbable || move.to.onClimbable; + final double gravity = data.lastGravity > 0.0D ? data.lastGravity : Magic.DEFAULT_GRAVITY; + final double frictionY = data.lastFrictionVertical != 0.0D ? data.lastFrictionVertical : Magic.FRICTION_MEDIUM_AIR; + final double gravityNextY = lastMove.toIsValid ? (lastMove.yDistance - gravity) * frictionY : Double.NaN; + final double actualAccelY = lastMove.toIsValid ? move.yDistance - lastMove.yDistance : Double.NaN; + final double gravityAccelY = lastMove.toIsValid ? gravityNextY - lastMove.yDistance : Double.NaN; + final double actualMinusGravityY = lastMove.toIsValid ? move.yDistance - gravityNextY : Double.NaN; + final StringBuilder builder = new StringBuilder(420); + builder.append("state=").append(liquid ? "liquid" : climbable ? "climbable" : air ? "air" : "ground") + .append(",gravity=").append(StringUtil.fdec6.format(gravity)) + .append(",frictionY=").append(StringUtil.fdec6.format(frictionY)) + .append(",lastY=").append(formatNumber(lastMove.toIsValid ? lastMove.yDistance : Double.NaN)) + .append(",gravityNextY=").append(formatNumber(gravityNextY)) + .append(",actualAccelY=").append(formatNumber(actualAccelY)) + .append(",gravityAccelY=").append(formatNumber(gravityAccelY)) + .append(",actualMinusGravityY=").append(formatNumber(actualMinusGravityY)) + .append(",velocityH=").append(StringUtil.fdec6.format(velocityH)) + .append(",actualHOverVelocity=").append(formatRatio(move.hDistance, velocityH)) + .append(",allowedHOverVelocity=").append(formatRatio(hAllowedDistance, velocityH)) + .append(",actualMinusVelocityH=").append(StringUtil.fdec6.format(actualMinusVelocityH)) + .append(",allowedMinusVelocityH=").append(StringUtil.fdec6.format(allowedMinusVelocityH)) + .append(",actualYMinusVelocity=").append(StringUtil.fdec6.format(move.yDistance - velocity.getY())) + .append(",allowedYMinusVelocity=").append(StringUtil.fdec6.format(yAllowedDistance - velocity.getY())) + .append(",glideRatio=actual:").append(formatRatio(move.hDistance, Math.abs(move.yDistance))) + .append(",allowed:").append(formatRatio(hAllowedDistance, Math.abs(yAllowedDistance))); + return builder.toString(); + } + + static String formatFlightTrace(final Player player, final MovingData data, + final PlayerMoveData move, final PlayerMoveData lastMove, + final String movementMode, final PlayerLocation from, + final PlayerLocation to) { + /* + * Diagnostic info: this compact trace is meant for comparing labeled + * legit/hacked test windows in the server log without adding more model + * logic to SurvivalFly itself. + */ + final Vector velocity = player.getVelocity(); + final double velocityH = MathUtil.dist(velocity.getX(), velocity.getZ()); + final double packetVelocityDx = move.xDistance - velocity.getX(); + final double packetVelocityDy = move.yDistance - velocity.getY(); + final double packetVelocityDz = move.zDistance - velocity.getZ(); + final double lastY = lastMove.toIsValid ? lastMove.yDistance : Double.NaN; + final double lastH = lastMove.toIsValid ? lastMove.hDistance : Double.NaN; + final double gravity = data.lastGravity > 0.0D ? data.lastGravity : Magic.DEFAULT_GRAVITY; + final double frictionY = data.lastFrictionVertical != 0.0D ? data.lastFrictionVertical : Magic.FRICTION_MEDIUM_AIR; + final double gravityNextY = lastMove.toIsValid ? (lastMove.yDistance - gravity) * frictionY : Double.NaN; + final double yAccel = lastMove.toIsValid ? move.yDistance - lastMove.yDistance : Double.NaN; + final double hAccel = lastMove.toIsValid ? move.hDistance - lastMove.hDistance : Double.NaN; + final double gravityMiss = lastMove.toIsValid ? move.yDistance - gravityNextY : Double.NaN; + final double liftExtraY = lastMove.toIsValid ? Math.max(0.0D, gravityMiss) : Double.NaN; + final double hoverDropDeficit = Math.max(0.0D, data.hoverExpectedDrop - data.hoverActualDrop); + final Vector look = TrigUtil.getLookingDirection(to, player); + final double yawDelta = getYawDelta(from.getYaw(), to.getYaw()); + final StringBuilder builder = new StringBuilder(420); + builder.append("mode=").append(movementMode) + .append(",trend=").append(formatTrend(move.yDistance)) + .append(",lastTrend=").append(formatTrend(lastY)) + .append(",pitch=").append(StringUtil.fdec3.format(from.getPitch())).append("->").append(StringUtil.fdec3.format(to.getPitch())) + .append(",pitchDelta=").append(StringUtil.fdec3.format(to.getPitch() - from.getPitch())) + .append(",pitchBand=").append(formatPitchBand(to.getPitch())) + .append(",yaw=").append(StringUtil.fdec3.format(from.getYaw())).append("->").append(StringUtil.fdec3.format(to.getYaw())) + .append(",yawDelta=").append(StringUtil.fdec3.format(yawDelta)) + .append(",lookVector=").append(formatVector(look)) + .append(",lookH=").append(StringUtil.fdec6.format(MathUtil.dist(look.getX(), look.getZ()))) + .append(",lookY=").append(StringUtil.fdec6.format(look.getY())) + .append(",h=").append(StringUtil.fdec6.format(move.hDistance)) + .append(",lastH=").append(formatNumber(lastH)) + .append(",hDelta=").append(formatNumber(hAccel)) + .append(",y=").append(StringUtil.fdec6.format(move.yDistance)) + .append(",lastY=").append(formatNumber(lastY)) + .append(",yDelta=").append(formatNumber(yAccel)) + .append(",gravityNextY=").append(formatNumber(gravityNextY)) + .append(",gravityMiss=").append(formatNumber(gravityMiss)) + .append(",velocity=").append(formatVector(velocity)) + .append(",velocityH=").append(StringUtil.fdec6.format(velocityH)) + .append(",packetMinusVelocity=").append(formatVector(packetVelocityDx, packetVelocityDy, packetVelocityDz)) + .append(",lift=extraY:").append(formatNumber(liftExtraY)) + .append(",perVelocityH:").append(formatRatio(liftExtraY, velocityH)) + .append(",glideRatio=").append(formatRatio(move.hDistance, Math.abs(move.yDistance))) + .append(",firework=").append(data.fireworksBoostDuration) + .append(",noFireworkAscent=ticks:").append(data.elytraNoFireworkAscentTicks) + .append(",debt:").append(StringUtil.fdec6.format(data.elytraNoFireworkAscentDebt)) + .append(",budget:").append(formatNumber(data.elytraNoFireworkAscentBudget)) + .append(",excess:").append(formatNumber(data.elytraNoFireworkAscentExcess)) + .append(",neededH:").append(formatNumber(data.elytraNoFireworkNeededH)) + .append(",startY:").append(data.elytraNoFireworkStart == null + ? "na" : formatNumber(data.elytraNoFireworkStart.getY())) + .append(",descentCredit:").append(formatNumber(data.elytraNoFireworkDescentCredit)) + .append(",creditUsed:").append(formatNumber(data.elytraNoFireworkDescentCreditUsed)) + .append(",hover=ticks:").append(data.hoverAirTicks) + .append(",expectedDrop:").append(StringUtil.fdec6.format(data.hoverExpectedDrop)) + .append(",actualDrop:").append(StringUtil.fdec6.format(data.hoverActualDrop)) + .append(",dropDeficit:").append(StringUtil.fdec6.format(hoverDropDeficit)) + .append(",lastYVel:").append(StringUtil.fdec6.format(data.hoverLastYVelocity)); + return builder.toString(); + } + + static String formatElytraModel(final Player player, final PlayerLocation from, final PlayerLocation to, + final MovingData data, final PlayerMoveData move, + final double hAllowedDistance, final double yAllowedDistance, + final String pitchBand, final double yawDelta) { + if (!Bridge1_9.isGliding(player) && !Bridge1_9.isWearingElytra(player)) { + return "none"; + } + final Vector look = TrigUtil.getLookingDirection(to, player); + final Vector velocity = player.getVelocity(); + final double actualVelocityDx = move.xDistance - velocity.getX(); + final double actualVelocityDy = move.yDistance - velocity.getY(); + final double actualVelocityDz = move.zDistance - velocity.getZ(); + final double allowedVelocityDx = move.xAllowedDistance - velocity.getX(); + final double allowedVelocityDy = yAllowedDistance - velocity.getY(); + final double allowedVelocityDz = move.zAllowedDistance - velocity.getZ(); + final StringBuilder builder = new StringBuilder(420); + builder.append("pitch=").append(StringUtil.fdec3.format(from.getPitch())).append("->").append(StringUtil.fdec3.format(to.getPitch())) + .append(",pitchDelta=").append(StringUtil.fdec3.format(to.getPitch() - from.getPitch())) + .append(",pitchBand=").append(pitchBand) + .append(",yaw=").append(StringUtil.fdec3.format(from.getYaw())).append("->").append(StringUtil.fdec3.format(to.getYaw())) + .append(",yawDelta=").append(StringUtil.fdec3.format(yawDelta)) + .append(",look=").append(formatVector(look)) + .append(",lookH=").append(StringUtil.fdec6.format(MathUtil.dist(look.getX(), look.getZ()))) + .append(",lookY=").append(StringUtil.fdec6.format(look.getY())) + .append(",firework=").append(data.fireworksBoostDuration) + .append(",actualH=").append(StringUtil.fdec6.format(move.hDistance)) + .append(",allowedH=").append(StringUtil.fdec6.format(hAllowedDistance)) + .append(",actualY=").append(StringUtil.fdec6.format(move.yDistance)) + .append(",allowedY=").append(StringUtil.fdec6.format(yAllowedDistance)) + .append(",velocity=").append(formatVector(velocity)) + .append(",actualMinusVelocity=").append(formatVector(actualVelocityDx, actualVelocityDy, actualVelocityDz)) + .append(",allowedMinusVelocity=").append(formatVector(allowedVelocityDx, allowedVelocityDy, allowedVelocityDz)); + return builder.toString(); + } + + static String formatRatio(final double actual, final double allowed) { + if (Math.abs(allowed) < 1.0E-9) { + return Math.abs(actual) < 1.0E-9 ? "1.000" : "inf"; + } + return StringUtil.fdec3.format(actual / allowed); + } + + private static String formatNumber(final double value) { + if (Double.isNaN(value)) { + return "na"; + } + return Double.isInfinite(value) ? "inf" : StringUtil.fdec6.format(value); + } + + private static String formatTrend(final double yDistance) { + if (Double.isNaN(yDistance)) { + return "na"; + } + if (yDistance > Magic.PREDICTION_EPSILON) { + return "ascend"; + } + if (yDistance < -Magic.PREDICTION_EPSILON) { + return "descend"; + } + return "level"; + } + + private static String formatPitchBand(final float pitch) { + if (pitch <= -15.0f) { + return "UP_STEEP"; + } + if (pitch <= -5.0f) { + return "UP"; + } + if (pitch < 5.0f) { + return "LEVEL"; + } + if (pitch < 30.0f) { + return "DOWN"; + } + return "DOWN_STEEP"; + } + + private static double getYawDelta(final float fromYaw, final float toYaw) { + double delta = toYaw - fromYaw; + while (delta > 180.0D) { + delta -= 360.0D; + } + while (delta < -180.0D) { + delta += 360.0D; + } + return delta; + } + + static String formatVector(final Vector vector) { + return formatVector(vector.getX(), vector.getY(), vector.getZ()); + } + + static String formatVector(final double x, final double y, final double z) { + return StringUtil.fdec6.format(x) + "," + StringUtil.fdec6.format(y) + "," + StringUtil.fdec6.format(z); + } + + static String formatLocation(final PlayerLocation location) { + return String.format(Locale.US, "%.3f,%.3f,%.3f", location.getX(), location.getY(), location.getZ()); + } + + static String formatBukkitLocation(final Location location) { + if (location == null) { + return "none"; + } + return String.format(Locale.US, "%s@%.3f,%.3f,%.3f/%.3f,%.3f", + location.getWorld() == null ? "null" : location.getWorld().getName(), + location.getX(), location.getY(), location.getZ(), location.getYaw(), location.getPitch()); + } + +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFlyTags.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFlyTags.java new file mode 100644 index 0000000000..3ef071208a --- /dev/null +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/player/SurvivalFlyTags.java @@ -0,0 +1,26 @@ +package fr.neatmonster.nocheatplus.checks.moving.player; + +/** + * Diagnostic tag names shared by SurvivalFly model logic and console formatting. + */ +final class SurvivalFlyTags { + + private SurvivalFlyTags() { + } + + // Elytra model collection: marks active-glide misses that are logged without applying VL/setback. + static final String ELYTRA_MODEL_DATA_ONLY = "elytra_model_data_only"; + + // Elytra state tags: identify which glide model is active before branch-specific diagnostics run. + static final String MODE_ELYTRA_FIREWORK = "mode_elytra_firework"; + static final String MODE_ELYTRA_GLIDING = "mode_elytra_gliding"; + static final String GLIDE_FIREWORK_ACTIVE = "glide_firework_active"; + + // Elytra prediction tags: classify which side of the model missed for later log triage. + static final String GLIDE_VERTICAL_PREDICTION_MISS = "glide_vertical_prediction_miss"; + static final String GLIDE_VERTICAL_ACTUAL_ABOVE_MODEL = "glide_vertical_actual_above_model"; + static final String GLIDE_VERTICAL_ACTUAL_BELOW_MODEL = "glide_vertical_actual_below_model"; + static final String GLIDE_HORIZONTAL_PREDICTION_MISS = "glide_horizontal_prediction_miss"; + static final String GLIDE_HORIZONTAL_ACTUAL_ABOVE_MODEL = "glide_horizontal_actual_above_model"; + static final String GLIDE_HORIZONTAL_ACTUAL_BELOW_MODEL = "glide_horizontal_actual_below_model"; +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/vehicle/VehicleEnvelope.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/vehicle/VehicleEnvelope.java index ac67815453..d56cbc166f 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/vehicle/VehicleEnvelope.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/vehicle/VehicleEnvelope.java @@ -25,6 +25,7 @@ import org.bukkit.entity.Pig; import org.bukkit.entity.Player; import org.bukkit.potion.PotionEffectType; +import org.bukkit.util.Vector; import fr.neatmonster.nocheatplus.actions.ParameterName; import fr.neatmonster.nocheatplus.checks.Check; @@ -35,6 +36,7 @@ import fr.neatmonster.nocheatplus.checks.moving.envelope.workaround.LostGroundVehicle; import fr.neatmonster.nocheatplus.checks.moving.envelope.workaround.VehicleWorkarounds; import fr.neatmonster.nocheatplus.checks.moving.location.setback.SetBackEntry; +import fr.neatmonster.nocheatplus.checks.moving.model.LocationData; import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveData; import fr.neatmonster.nocheatplus.checks.moving.model.VehicleMoveData; import fr.neatmonster.nocheatplus.checks.moving.model.VehicleMoveInfo; @@ -42,6 +44,7 @@ import fr.neatmonster.nocheatplus.compat.Bridge1_13; import fr.neatmonster.nocheatplus.compat.Bridge1_9; import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; import fr.neatmonster.nocheatplus.utilities.ReflectionUtil; import fr.neatmonster.nocheatplus.utilities.StringUtil; import fr.neatmonster.nocheatplus.utilities.entity.PotionUtil; @@ -159,6 +162,9 @@ public SetBackEntry check(final Player player, final Entity vehicle, if (violation) { data.vehicleEnvelopeVL += 1.0; // Add up one for now. + if (CheckUtils.shouldLogDebugToConsole()) { + logConsoleDetails(player, vehicle, thisMove, isFake, data, cc, moveInfo); + } final ViolationData vd = new ViolationData(this, player, data.vehicleEnvelopeVL, 1, cc.vehicleEnvelopeActions); vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); if (executeActions(vd).willCancel()) { @@ -172,6 +178,91 @@ public SetBackEntry check(final Player player, final Entity vehicle, return null; } + private void logConsoleDetails(final Player player, final Entity vehicle, + final VehicleMoveData thisMove, final boolean isFake, + final MovingData data, final MovingConfig cc, + final VehicleMoveInfo moveInfo) { + final StringBuilder builder = new StringBuilder(800); + builder.append("[NCP][VehicleEnvelope][detail]") + .append(" player=").append(player.getName()) + .append(" uuid=").append(player.getUniqueId()) + .append(" vehicle=").append(vehicle.getType()) + .append(" vehicleId=").append(vehicle.getUniqueId()) + .append(" fake=").append(isFake) + .append(" addVL=1") + .append(" totalVL=").append(StringUtil.fdec3.format(data.vehicleEnvelopeVL)) + .append(" tags=").append(StringUtil.join(tags, "+")) + .append(" move=h:").append(StringUtil.fdec6.format(thisMove.hDistance)) + .append(",x:").append(StringUtil.fdec6.format(thisMove.xDistance)) + .append(",y:").append(StringUtil.fdec6.format(thisMove.yDistance)) + .append(",z:").append(StringUtil.fdec6.format(thisMove.zDistance)) + .append(",distSq:").append(StringUtil.fdec6.format(thisMove.distanceSquared)) + .append(" from=").append(formatLocation(thisMove.from)) + .append(" to=").append(formatLocation(thisMove.to)) + .append(" fromEnv=").append(formatEnvironment(thisMove.from)) + .append(" toEnv=").append(formatEnvironment(thisMove.to)) + .append(" details=simplified:").append(checkDetails.simplifiedType) + .append(",inAir:").append(checkDetails.inAir) + .append(",fromSafe:").append(checkDetails.fromIsSafeMedium) + .append(",toSafe:").append(checkDetails.toIsSafeMedium) + .append(",canClimb:").append(checkDetails.canClimb) + .append(",canRails:").append(checkDetails.canRails) + .append(",canJump:").append(checkDetails.canJump) + .append(",canStep:").append(checkDetails.canStepUpBlock) + .append(",maxAscend:").append(StringUtil.fdec6.format(checkDetails.maxAscend)) + .append(",gravityTarget:").append(StringUtil.fdec6.format(checkDetails.gravityTargetSpeed)) + .append(",checkAscend:").append(checkDetails.checkAscendMuch) + .append(",checkDescend:").append(checkDetails.checkDescendMuch) + .append(" playerState=vehicle:").append(player.isInsideVehicle()) + .append(",sneak:").append(player.isSneaking()) + .append(",sprint:").append(player.isSprinting()) + .append(",fly:").append(player.isFlying()) + .append(",allowFlight:").append(player.getAllowFlight()) + .append(" velocity=player:").append(formatVector(player.getVelocity())) + .append(",vehicle:").append(formatVector(vehicle.getVelocity())) + .append(" vehicleState=valid:").append(vehicle.isValid()) + .append(",dead:").append(vehicle.isDead()) + .append(",onGround:").append(vehicle.isOnGround()) + .append(" moveInfo=fromFlags:").append(moveInfo.from.getBlockFlags()) + .append(",toFlags:").append(moveInfo.to.getBlockFlags()) + .append(" config=vehicleEnvelopeVL:").append(StringUtil.fdec3.format(data.vehicleEnvelopeVL)) + .append(",boatIceTicks:").append(data.boatIceVelocityTicks) + .append(",actions:").append(cc.vehicleEnvelopeActions); + // Diagnostic logging: mirror vehicle details to console so boat false positives can be matched to exact terrain. + player.getServer().getLogger().info(builder.toString()); + } + + private String formatLocation(final LocationData location) { + return location.getWorldName() + + "@" + StringUtil.fdec3.format(location.getX()) + + "," + StringUtil.fdec3.format(location.getY()) + + "," + StringUtil.fdec3.format(location.getZ()); + } + + private String formatEnvironment(final LocationData location) { + return "{ground:" + location.onGround + + ",reset:" + location.resetCond + + ",water:" + location.inWater + + ",lava:" + location.inLava + + ",liquid:" + location.inLiquid + + ",climb:" + location.onClimbable + + ",web:" + location.inWeb + + ",ice:" + location.onIce + + ",blueIce:" + location.onBlueIce + + ",slime:" + location.onSlimeBlock + + ",honey:" + location.onHoneyBlock + + ",bubble:" + location.inBubbleStream + + ",soulSand:" + location.onSoulSand + + ",valid:" + location.extraPropertiesValid + + "}"; + } + + private String formatVector(final Vector vector) { + return StringUtil.fdec6.format(vector.getX()) + + "," + StringUtil.fdec6.format(vector.getY()) + + "," + StringUtil.fdec6.format(vector.getZ()); + } + /** * @param player @@ -258,7 +349,8 @@ else if (lastMove.from.onIce && !thisMove.from.onIce){ if ((thisMove.from.onGround && !thisMove.from.inWater) || thisMove.to.onGround && !thisMove.to.inWater) { - return multiplier * 0.4; + // False-positive tuning: boats crossing shore/ground blocks on Folia can exceed the old 0.4 cap. + return multiplier * 0.75; } if (thisMove.from.inWater || thisMove.to.inWater) { @@ -662,7 +754,8 @@ private boolean checkInAir(final VehicleMoveData thisMove, final MovingData data if (data.sfJumpPhase > (checkDetails.canJump ? MagicVehicle.maxJumpPhaseAscend : 1) && thisMove.yDistance > Math.max(minDescend, -checkDetails.gravityTargetSpeed)) { - boolean noViolation = collidesWithHoneyBlock(from) + boolean noViolation = collidesWithHoneyBlock(from) + || isBoatDownhillMove(thisMove) || (vehicle instanceof LivingEntity && !Double.isInfinite(Bridge1_13.getSlowfallingAmplifier((LivingEntity)vehicle))) || !vehicle.hasGravity(); // TODO: What is this? Vehicle slide on honey block? @@ -693,6 +786,15 @@ else if (data.sfJumpPhase > 1 && thisMove.yDistance < maxDescend) { return violation; } + private boolean isBoatDownhillMove(final VehicleMoveData thisMove) { + return MaterialUtil.isBoat(checkDetails.simplifiedType) + && thisMove.yDistance <= 0.0 + && thisMove.yDistance >= -0.8 + && thisMove.hDistance <= 0.8 + && !thisMove.from.inWater + && !thisMove.to.inWater; + } + /** * @param thisMove diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/velocity/PairAxisVelocity.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/velocity/PairAxisVelocity.java index 07bbd22639..a035db17ba 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/velocity/PairAxisVelocity.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/moving/velocity/PairAxisVelocity.java @@ -163,6 +163,88 @@ public List use(final int tick) { return result; } + /** + * Use the next velocity entry that can cover the demanded x/z displacement. + * This is meant for knockback-style compensation, where the player may only + * consume part of a queued velocity vector during one move. + */ + public List useCovering(final double x, final double z, final int minActCount, + final int maxActCount, final double tolerance) { + final List available = peekCovering(x, z, minActCount, maxActCount, tolerance); + if (available.isEmpty()) { + return available; + } + return use(available.get(0).tick); + } + + public List peekCovering(final double x, final double z, final int minActCount, + final int maxActCount, final double tolerance) { + final List result = new LinkedList<>(); + double totalAmountX = 0; + double totalAmountZ = 0; + int lastTick = 0; + boolean hasVel = false; + final Iterator it = queued.iterator(); + while (it.hasNext()) { + final PairEntry entry = it.next(); + if (entry.actCount < minActCount || entry.actCount > maxActCount) { + continue; + } + if ((entry.flags & VelocityFlags.ADDITIVE) != 0 && lastTick == entry.tick) { + totalAmountX += entry.x; + totalAmountZ += entry.z; + result.add(entry); + hasVel = true; + } + else { + if (hasVel && coversVector(totalAmountX, totalAmountZ, x, z, tolerance)) { + return result; + } + result.clear(); + totalAmountX = entry.x; + totalAmountZ = entry.z; + lastTick = entry.tick; + result.add(entry); + hasVel = true; + } + } + if (hasVel && coversVector(totalAmountX, totalAmountZ, x, z, tolerance)) { + return result; + } + result.clear(); + return result; + } + + private boolean covers(final double velocity, final double amount, final double tolerance) { + return Math.abs(amount) <= tolerance + || Math.signum(velocity) == Math.signum(amount) + && Math.abs(amount) <= Math.abs(velocity) + tolerance; + } + + private boolean coversVector(final double velocityX, final double velocityZ, + final double amountX, final double amountZ, + final double tolerance) { + final double amountSq = amountX * amountX + amountZ * amountZ; + if (amountSq <= tolerance * tolerance) { + return true; + } + final double velocitySq = velocityX * velocityX + velocityZ * velocityZ; + if (velocitySq <= tolerance * tolerance) { + return false; + } + final double dot = velocityX * amountX + velocityZ * amountZ; + if (dot < -tolerance) { + return false; + } + final double amount = Math.sqrt(amountSq); + final double velocity = Math.sqrt(velocitySq); + if (amount > velocity + tolerance) { + return false; + } + final double perpendicular = Math.abs(velocityX * amountZ - velocityZ * amountX) / velocity; + return perpendicular <= Math.max(tolerance, 0.02); + } + /** * Without checking for invalidation, test if there is a matching entry with * same or less the activation count. diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/KeepAliveDiagnostics.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/KeepAliveDiagnostics.java new file mode 100644 index 0000000000..aafb2ef089 --- /dev/null +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/KeepAliveDiagnostics.java @@ -0,0 +1,81 @@ +/* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package fr.neatmonster.nocheatplus.checks.net; + +import org.bukkit.entity.Player; + +import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.StringUtil; +import fr.neatmonster.nocheatplus.utilities.TickTask; + +/** + * Console-only formatting for KeepAliveFrequency diagnostics. + */ +final class KeepAliveDiagnostics { + + private KeepAliveDiagnostics() {} + + static String formatDetail(final Player player, final long packetTime, final long now, final long joinTime, + final NetData data, final NetConfig cc, final IPlayerData pData, + final float first, final float fullScore, final double vl, + final boolean cancel, final String model, final float firstLimit) { + final long bucketDuration = data.keepAliveFreq.bucketDuration(); + final int buckets = data.keepAliveFreq.numberOfBuckets(); + return new StringBuilder(900) + .append("[NCP][KeepAliveFrequency][detail] player=").append(player.getName()) + .append(" uuid=").append(player.getUniqueId()) + .append(" client=").append(pData.getClientVersion()) + .append(" summary=keepalive_bucket{first=").append(StringUtil.fdec3.format(first)) + .append(",full=").append(StringUtil.fdec3.format(fullScore)) + .append(",expected=").append(data.keepAliveFreq.numberOfBuckets()) + .append(",cancel=").append(cancel) + .append('}') + .append(" packetTime=").append(packetTime) + .append(" now=").append(now) + .append(" joinAge=").append(joinTime <= 0L ? -1L : now - joinTime) + .append(" startupDelay=").append(cc.keepAliveFrequencyStartupDelay) + .append(" vl=").append(StringUtil.fdec3.format(vl)) + .append(" cancel=").append(cancel) + .append(" model=").append(model) + .append(" firstLimit=").append(StringUtil.fdec3.format(firstLimit)) + .append(" firstBucket=").append(StringUtil.fdec3.format(first)) + .append(" fullScore=").append(StringUtil.fdec3.format(fullScore)) + .append(" expectedFull=").append(buckets) + .append(" bucketDuration=").append(bucketDuration) + .append(" bucketAge=").append(now - data.keepAliveFreq.lastAccess()) + .append(" lastUpdateAge=").append(now - data.keepAliveFreq.lastUpdate()) + .append(" lag1s=").append(StringUtil.fdec3.format(TickTask.getLag(bucketDuration, true))) + .append(" lagWindow=").append(StringUtil.fdec3.format(TickTask.getLag(bucketDuration * buckets, true))) + .append(" buckets=").append(formatBuckets(data, Math.min(6, buckets))) + .append(" state=").append(data.describeKeepAliveState(now)) + .toString(); + } + + private static String formatBuckets(final NetData data, final int limit) { + final StringBuilder builder = new StringBuilder(80); + builder.append('['); + for (int i = 0; i < limit; i++) { + if (i > 0) { + builder.append(','); + } + builder.append(StringUtil.fdec3.format(data.keepAliveFreq.bucketScore(i))); + } + if (limit < data.keepAliveFreq.numberOfBuckets()) { + builder.append(",..."); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/KeepAliveFrequency.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/KeepAliveFrequency.java index aacb04b18d..80e4ba70fe 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/KeepAliveFrequency.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/KeepAliveFrequency.java @@ -19,6 +19,7 @@ import fr.neatmonster.nocheatplus.checks.Check; import fr.neatmonster.nocheatplus.checks.CheckType; import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.utilities.CheckUtils; public class KeepAliveFrequency extends Check { @@ -35,21 +36,95 @@ public KeepAliveFrequency() { * @return If to cancel. */ public boolean check(final Player player, final long time, final NetData data, final NetConfig cc, final IPlayerData pData) { - data.keepAliveFreq.add(time, 1f); - final float first = data.keepAliveFreq.bucketScore(0); final long now = System.currentTimeMillis(); - - if (now > pData.getLastJoinTime() && pData.getLastJoinTime() + cc.keepAliveFrequencyStartupDelay * 1000 < now) { + final long joinTime = pData.getLastJoinTime(); + if (joinTime > 0L && now < joinTime + cc.keepAliveFrequencyStartupDelay) { + return false; + } + if (data.keepAliveExpectedResponse && !data.keepAliveDuplicateId) { return false; } + data.keepAliveFreq.add(time, 1f); + final float first = data.keepAliveFreq.bucketScore(0); if (first > 1f) { // Trigger a violation. - final double vl = Math.max(first - 1f, data.keepAliveFreq.score(1f) - data.keepAliveFreq.numberOfBuckets()); - if (executeActions(player, vl, 1.0, cc.keepAliveFrequencyActions).willCancel()) { - return true; + final float fullScore = data.keepAliveFreq.score(1f); + if (isModeledBoundaryBurst(data, fullScore, first)) { + return false; } + final double vl = Math.max(first - getFirstBucketViolationLimit(data), fullScore - data.keepAliveFreq.numberOfBuckets()); + final boolean cancel = executeActions(player, vl, 1.0, cc.keepAliveFrequencyActions).willCancel(); + if (CheckUtils.shouldLogDebugToConsole()) { + // Diagnostic info: bucket details separate duplicate packets from normal boundary timing. + logConsoleDetails(player, time, now, joinTime, data, cc, pData, first, fullScore, vl, + cancel, describeKeepAliveModel(data, fullScore, first), getFirstBucketViolationLimit(data)); + } + return cancel; } return false; } + + private boolean isModeledBoundaryBurst(final NetData data, final float fullScore, final float first) { + // KeepAlive model: if outgoing tracking is unavailable, only treat small monotonic id bursts as timing leftovers. + if (data.keepAliveDuplicateId || fullScore > data.keepAliveFreq.numberOfBuckets() + 2f) { + return false; + } + // Folia/netty can timestamp two adjacent valid replies in the same millisecond; do not cancel a single pair. + return first <= 2f || isUntrackedMonotonicBurst(data, fullScore, first); + } + + private boolean isUntrackedMonotonicBurst(final NetData data, final float fullScore, final float first) { + if (data.keepAliveOutgoingSeen || fullScore > data.keepAliveFreq.numberOfBuckets()) { + return false; + } + if (!data.keepAlivePacketIdAvailable || !data.keepAlivePreviousPacketIdAvailable) { + return false; + } + if (data.keepAlivePacketId <= data.keepAlivePreviousPacketId) { + return false; + } + return first <= getFallbackFirstBucketLimit(data); + } + + private float getFirstBucketViolationLimit(final NetData data) { + if (data.keepAliveDuplicateId) { + return 1f; + } + if (!data.keepAliveOutgoingSeen + && data.keepAlivePacketIdAvailable + && data.keepAlivePreviousPacketIdAvailable + && data.keepAlivePacketId > data.keepAlivePreviousPacketId) { + return getFallbackFirstBucketLimit(data); + } + return 2f; + } + + private float getFallbackFirstBucketLimit(final NetData data) { + return Math.max(3f, Math.min(6f, data.keepAliveFreq.numberOfBuckets() / 4f)); + } + + private String describeKeepAliveModel(final NetData data, final float fullScore, final float first) { + if (data.keepAliveDuplicateId) { + return "duplicate-id"; + } + if (data.keepAliveOutgoingSeen) { + return "unmatched-outgoing"; + } + if (isUntrackedMonotonicBurst(data, fullScore, first)) { + return "untracked-monotonic-burst"; + } + return "bucket-frequency"; + } + + private void logConsoleDetails(final Player player, final long packetTime, final long now, final long joinTime, + final NetData data, final NetConfig cc, final IPlayerData pData, + final float first, final float fullScore, final double vl, + final boolean cancel, final String model, final float firstLimit) { + try { + player.getServer().getLogger().info(KeepAliveDiagnostics.formatDetail(player, packetTime, now, joinTime, + data, cc, pData, first, fullScore, vl, cancel, model, firstLimit)); + } + catch (Throwable ignored) {} + } } diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/Moving.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/Moving.java index 603d935ba1..d85f847aac 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/Moving.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/Moving.java @@ -20,6 +20,7 @@ import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; +import org.bukkit.util.Vector; import fr.neatmonster.nocheatplus.NCPAPIProvider; import fr.neatmonster.nocheatplus.actions.ParameterName; @@ -27,7 +28,11 @@ import fr.neatmonster.nocheatplus.checks.CheckType; import fr.neatmonster.nocheatplus.checks.ViolationData; import fr.neatmonster.nocheatplus.checks.moving.MovingData; +import fr.neatmonster.nocheatplus.checks.moving.model.LocationData; +import fr.neatmonster.nocheatplus.checks.moving.model.PlayerKeyboardInput; +import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveData; import fr.neatmonster.nocheatplus.checks.net.model.DataPacketFlying; +import fr.neatmonster.nocheatplus.checks.net.model.DataPacketInput; import fr.neatmonster.nocheatplus.logging.Streams; import fr.neatmonster.nocheatplus.players.IPlayerData; import fr.neatmonster.nocheatplus.utilities.CheckUtils; @@ -41,10 +46,6 @@ */ public class Moving extends Check { - /** For temporary use: LocUtil.clone before passing deeply, call setWorld(null) after use. */ - final Location useLoc = new Location(null, 0, 0, 0); - final List tags = new ArrayList(); - public Moving() { super(CheckType.NET_MOVING); } @@ -67,27 +68,114 @@ public boolean check(final Player player, final DataPacketFlying packetData, fin boolean cancel = false; final long now = System.currentTimeMillis(); final boolean debug = pData.isDebugActive(CheckType.NET_MOVING); - tags.clear(); + final List tags = new ArrayList(); if (now > pData.getLastJoinTime() && pData.getLastJoinTime() + 10000 > now) { tags.add("login_grace"); return false; } + if (player.isDead()) { + tags.add("dead_grace"); + data.movingVL *= 0.5; + return false; + } if (packetData != null && packetData.hasPos) { final MovingData mData = pData.getGenericInstance(MovingData.class); /** Actual Location on the server */ - final Location knownLocation = player.getLocation(useLoc); + final Location useLoc = new Location(null, 0, 0, 0); + final Location rawKnownLocation = player.getLocation(useLoc); + if (rawKnownLocation == null || rawKnownLocation.getWorld() == null) { + // Folia compatibility: a disconnecting player can leave the temporary Location without a world. + tags.add("known_world_null_grace"); + useLoc.setWorld(null); + return false; + } + // Folia/packet safety: keep packet-thread teleport models away from mutable temporary Location state. + final Location knownLocation = LocUtil.clone(rawKnownLocation); + useLoc.setWorld(null); + data.recordMovingKnownLocation(knownLocation, now); /** Claimed Location sent by the client */ final Location packetLocation = new Location(null, packetData.getX(), packetData.getY(), packetData.getZ()); //final double distanceSq = TrigUtil.distanceSquared(knownLocation, packetLocation); final double yDistance = Math.abs(knownLocation.getY() - packetLocation.getY()); final double hDistance = TrigUtil.xzDistance(knownLocation, packetLocation); final double distance = TrigUtil.distance(knownLocation, packetLocation); + final boolean extremeMove = yDistance > 100.0 || distance > 100.0; + + final boolean teleportGrace = data.isWithinMovingTeleportGrace(now); + final boolean outgoingPositionGrace = data.isWithinOutgoingPositionGrace(now, knownLocation); + final boolean serverPositionJumpModel = data.isWithinServerPositionJumpStalePacketModel(now, + knownLocation, packetLocation, packetData); + final boolean teleportCommandModel = data.isWithinTeleportCommandStalePacketModel(now, + knownLocation, packetLocation, packetData); + final boolean teleportResyncStalePacketDropModel = (serverPositionJumpModel || teleportCommandModel) + && data.isWithinTeleportResyncStalePacketDropModel(now, knownLocation, packetLocation, packetData); + final boolean expectedOutgoingPositionGrace = extremeMove && data.consumeExpectedOutgoingPosition(packetData); + final boolean serverPositionJumpRecoveryGrace = extremeMove + && !serverPositionJumpModel + && !teleportCommandModel + && isServerPositionJumpRecoveryContext(mData) + && data.consumeServerPositionJumpRecoveryGrace(now, knownLocation); + // NetMoving compatibility: match extreme packets against teleport/server-position history before flagging. + if (extremeMove && (teleportGrace || outgoingPositionGrace || expectedOutgoingPositionGrace + || serverPositionJumpModel || serverPositionJumpRecoveryGrace || teleportCommandModel)) { + if (teleportGrace) { + tags.add("teleport_grace"); + } + if (outgoingPositionGrace) { + tags.add("outgoing_position_grace"); + } + if (expectedOutgoingPositionGrace) { + tags.add("expected_outgoing_position_grace"); + } + if (serverPositionJumpModel) { + tags.add("server_position_jump_stale_packet_model"); + } + if (serverPositionJumpRecoveryGrace) { + tags.add("server_position_jump_recovery_grace"); + } + if (teleportCommandModel) { + tags.add("teleport_command_stale_packet_model"); + } + if (serverPositionJumpModel || teleportCommandModel) { + tags.add("teleport_resync_history_reset"); + } + if (teleportResyncStalePacketDropModel) { + tags.add("teleport_resync_stale_packet_drop"); + } + if (CheckUtils.shouldLogDebugToConsole() + && !(serverPositionJumpModel || teleportCommandModel)) { + final String reason = serverPositionJumpModel || teleportCommandModel ? "model" : "grace"; + logConsoleDetails(reason, tags, player, packetData, knownLocation, packetLocation, hDistance, yDistance, + distance, data, mData, pData, now); + } + if (serverPositionJumpModel || teleportCommandModel) { + final String reason = serverPositionJumpModel ? "server_position_jump_stale_packet_model" + : "teleport_command_stale_packet_model"; + // Teleport model: accepted stale packets are deleted from packet history, not kept as movement data. + if (teleportResyncStalePacketDropModel) { + data.acceptMovingTeleportStalePacketContinuation(player, knownLocation, packetLocation, + packetData, now, reason); + } + else { + data.acceptMovingTeleportResync(player, plugin, mData, knownLocation, packetLocation, + packetData, now, reason); + } + } + data.movingVL *= 0.98; + return false; + } // 100 it's the minimum [Math.max(100, config distance)]distance for the 'moved too quickly' check to fire // See PlayerConnection.java - if (yDistance > 100.0 || distance > 100.0/*distanceSq > 100.0 || hDistance > 100.0*/) { + if (extremeMove/*distanceSq > 100.0 || hDistance > 100.0*/) { data.movingVL++ ; tags.add("invalid_pos"); + // Diagnostic info: separate net extreme-move flags from teleport/grace branches. + tags.add(0, "subcheck_netmoving_extreme_move"); + if (CheckUtils.shouldLogDebugToConsole()) { + logConsoleDetails("violation", tags, player, packetData, knownLocation, packetLocation, hDistance, yDistance, + distance, data, mData, pData, now); + } final ViolationData vd = new ViolationData(this, player, data.movingVL, 1.0, cc.movingActions); if (vd.needsParameters()) vd.setParameter(ParameterName.TAGS, StringUtil.join(tags, "+")); cancel = executeActions(vd).willCancel(); @@ -98,20 +186,341 @@ public boolean check(final Player player, final DataPacketFlying packetData, fin } if (debug) { - final Location packetLocation = new Location(null, packetData.getX(), packetData.getY(), packetData.getZ()); final StringBuilder builder = new StringBuilder(500); - if (packetData.hasPos) { + if (packetData != null && packetData.hasPos) { + final Location packetLocation = new Location(null, packetData.getX(), packetData.getY(), packetData.getZ()); + final Location serverLocation = player.getLocation(); builder.append(CheckUtils.getLogMessagePrefix(player, type)); builder.append("\nPacket location: " + LocUtil.simpleFormat(packetLocation)); - builder.append("\nServer location: " + LocUtil.simpleFormat(player.getLocation(useLoc))); - builder.append("\nDeltas: h= " + TrigUtil.distance(player.getLocation(useLoc), packetLocation) + ", y= " + Math.abs(player.getLocation(useLoc).getY() - packetLocation.getY())); + builder.append("\nServer location: " + LocUtil.simpleFormat(serverLocation)); + builder.append("\nDeltas: h= " + TrigUtil.distance(serverLocation, packetLocation) + ", y= " + Math.abs(serverLocation.getY() - packetLocation.getY())); } else { builder.append("Empty packet (no position)"); } NCPAPIProvider.getNoCheatPlusAPI().getLogManager().debug(Streams.TRACE_FILE, builder.toString()); } - useLoc.setWorld(null); return cancel; } -} \ No newline at end of file + + private boolean isServerPositionJumpRecoveryContext(final MovingData data) { + // False-positive tuning: death/respawn and portal/server jumps can leave move history invalid for one packet. + final PlayerMoveData currentMove = data.playerMoves.getCurrentMove(); + final PlayerMoveData lastMove = data.playerMoves.getFirstPastMove(); + return data.joinOrRespawn + || !data.hasSetBack() + || !currentMove.toIsValid + || !lastMove.toIsValid; + } + + private void logConsoleDetails(final String reason, final List tags, + final Player player, final DataPacketFlying packetData, + final Location knownLocation, final Location packetLocation, + final double hDistance, final double yDistance, final double distance, + final NetData data, final MovingData mData, final IPlayerData pData, + final long now) { + try { + final String joinedTags = StringUtil.join(tags, "+"); + player.getServer().getLogger().info(new StringBuilder(700) + .append("[NCP][NetMoving][detail] player=").append(player.getName()) + .append(" reason=").append(reason) + .append(" subcheck=").append("violation".equals(reason) ? "NETMOVING_EXTREME_MOVE" + : "model".equals(reason) ? "NETMOVING_TELEPORT_RESYNC_MODEL" : "NETMOVING_GRACE") + .append(" summary=net_moving{reason=").append(reason) + .append(",h=").append(StringUtil.fdec3.format(hDistance)) + .append(",y=").append(StringUtil.fdec3.format(yDistance)) + .append(",total=").append(StringUtil.fdec3.format(distance)) + .append(",tags=").append(joinedTags.isEmpty() ? "none" : joinedTags) + .append('}') + .append(" uuid=").append(player.getUniqueId()) + .append(" client=").append(pData.getClientVersion()) + .append(" now=").append(now) + .append(" joinAge=").append(now - pData.getLastJoinTime()) + .append(" keepAliveAge=").append(data.lastKeepAliveTime <= 0L ? -1L : now - data.lastKeepAliveTime) + .append(" movingVL=").append(StringUtil.fdec3.format(data.movingVL)) + .append(" teleportGraceAge=").append(data.getMovingTeleportGraceAge(now)) + .append(" outgoingGraceAge=").append(data.getOutgoingPositionGraceAge(now)) + .append(" known=").append(LocUtil.simpleFormat(knownLocation)) + .append(" packet=").append(LocUtil.simpleFormat(packetLocation)) + .append(" hDist=").append(StringUtil.fdec3.format(hDistance)) + .append(" yDist=").append(StringUtil.fdec3.format(yDistance)) + .append(" distance=").append(StringUtil.fdec3.format(distance)) + .append(" vector=known-packet(").append(formatVector(knownLocation.getX() - packetLocation.getX(), + knownLocation.getY() - packetLocation.getY(), knownLocation.getZ() - packetLocation.getZ())).append(')') + .append(" packetMeta=").append(formatPacket(packetData, now)) + .append(" tags=").append(joinedTags) + .toString()); + player.getServer().getLogger().info(new StringBuilder(900) + .append("[NCP][NetMoving][teleport] player=").append(player.getName()) + .append(" reason=").append(reason) + .append(" ").append(data.describeMovingTeleportState(now, knownLocation, packetLocation, packetData)) + .toString()); + player.getServer().getLogger().info(new StringBuilder(1100) + .append("[NCP][NetMoving][model] player=").append(player.getName()) + .append(" reason=").append(reason) + .append(" playerState=").append(formatPlayerState(player)) + .append(" movingData=").append(formatMovingData(mData, knownLocation)) + .append(" input=").append(formatKeyboardInput(mData.input)) + .append(" packetModel=").append(formatPacketModel(data, packetData, knownLocation, packetLocation, now)) + .toString()); + } + catch (Throwable ignored) {} + } + + private String formatPlayerState(final Player player) { + final Vector velocity = player.getVelocity(); + return new StringBuilder(240) + .append("world=").append(player.getWorld() == null ? "null" : player.getWorld().getName()) + .append(",gameMode=").append(player.getGameMode()) + .append(",dead=").append(player.isDead()) + .append(",health=").append(StringUtil.fdec3.format(player.getHealth())) + .append(",food=").append(player.getFoodLevel()) + .append(",sprint=").append(player.isSprinting()) + .append(",sneak=").append(player.isSneaking()) + .append(",fly=").append(player.isFlying()) + .append(",allowFlight=").append(player.getAllowFlight()) + .append(",vehicle=").append(player.isInsideVehicle()) + .append(",walkSpeed=").append(StringUtil.fdec3.format(player.getWalkSpeed())) + .append(",fall=").append(StringUtil.fdec3.format(player.getFallDistance())) + .append(",velocity=").append(formatVector(velocity.getX(), velocity.getY(), velocity.getZ())) + .toString(); + } + + private String formatMovingData(final MovingData data, final Location ref) { + final StringBuilder builder = new StringBuilder(700); + builder.append("{moveCount=").append(data.getPlayerMoveCount()) + .append(",sfVL=").append(StringUtil.fdec3.format(data.survivalFlyVL)) + .append(",sfJumpPhase=").append(data.sfJumpPhase) + .append(",liftOff=").append(data.liftOffEnvelope) + .append(",joinOrRespawn=").append(data.joinOrRespawn) + .append(",timeSinceSetBack=").append(data.timeSinceSetBack) + .append(",setBack=").append(data.hasSetBack() ? formatBukkitLocation(data.getSetBack(ref)) : "none") + .append(",morePacketsSetBack=").append(data.hasMorePacketsSetBack() ? formatBukkitLocation(data.getMorePacketsSetBack()) : "none") + .append(",teleported=").append(data.hasTeleported() ? formatBukkitLocation(data.getTeleported()) : "none") + .append(",speed=walk:").append(StringUtil.fdec3.format(data.walkSpeed)) + .append("->").append(StringUtil.fdec3.format(data.nextWalkSpeed)) + .append("/tick:").append(data.speedTick) + .append(",frictionH=").append(StringUtil.fdec3.format(data.lastFrictionHorizontal)) + .append("->").append(StringUtil.fdec3.format(data.nextFrictionHorizontal)) + .append(",stuckH=").append(StringUtil.fdec3.format(data.lastStuckInBlockHorizontal)) + .append("->").append(StringUtil.fdec3.format(data.nextStuckInBlockHorizontal)) + .append(",lastGravity=").append(StringUtil.fdec3.format(data.lastGravity)) + .append(",nextGravity=").append(StringUtil.fdec3.format(data.nextGravity)) + .append(",currentMove=").append(formatPlayerMove(data.playerMoves.getCurrentMove())) + .append(",lastMove=").append(formatPlayerMove(data.playerMoves.getFirstPastMove())) + .append('}'); + return builder.toString(); + } + + private String formatPlayerMove(final PlayerMoveData move) { + final StringBuilder builder = new StringBuilder(500); + builder.append("{valid=").append(move.valid) + .append(",toValid=").append(move.toIsValid) + .append(",from=").append(formatLocationData(move.from)); + if (move.toIsValid) { + builder.append(",to=").append(formatLocationData(move.to)) + .append(",actual=").append(formatVector(move.xDistance, move.yDistance, move.zDistance)) + .append(",h=").append(StringUtil.fdec3.format(move.hDistance)) + .append(",distSq=").append(StringUtil.fdec3.format(move.distanceSquared)) + .append(",allowed=").append(formatVector(move.xAllowedDistance, move.yAllowedDistance, move.zAllowedDistance)) + .append(",hAllowed=").append(StringUtil.fdec3.format(move.hAllowedDistance)) + .append(",over=").append(formatVector(move.xDistance - move.xAllowedDistance, + move.yDistance - move.yAllowedDistance, move.zDistance - move.zAllowedDistance)); + } + builder.append(",flyCheck=").append(move.flyCheck) + .append(",modelFlying=").append(move.modelFlying == null ? "none" : move.modelFlying.getClass().getSimpleName()) + .append(",impulse=").append(move.hasImpulse).append('/').append(move.forwardImpulse).append('/').append(move.strafeImpulse) + .append(",collide=").append(move.collideX).append('/').append(move.collideY).append('/').append(move.collideZ) + .append(",hCollide=").append(move.collidesHorizontally) + .append(",minorHCollide=").append(move.negligibleHorizontalCollision) + .append(",touchedGround=").append(move.touchedGround) + .append(",lostGround=").append(move.fromLostGround).append('/').append(move.toLostGround) + .append(",jump=").append(move.isJump) + .append(",step=").append(move.isStepUp) + .append(",multi=").append(move.multiMoveCount) + .append(",hidden=").append(move.hiddenDistanceIndex).append('/').append(move.hiddenYDistanceIndex) + .append(",correctedPre=").append(formatVector(move.xCorrectedDistancePre, move.yCorrectedDistancePre, move.zCorrectedDistancePre)) + .append(",correctedPost=").append(formatVector(move.xCorrectedDistancePost, 0.0, move.zCorrectedDistancePost)) + .append(",verVelUsed=").append(move.verVelUsed) + .append('}'); + return builder.toString(); + } + + private String formatPacketModel(final NetData data, final DataPacketFlying packetData, + final Location knownLocation, final Location packetLocation, + final long now) { + final DataPacketFlying[] flyingQueue = data.copyFlyingQueue(); + final DataPacketInput[] inputQueue = data.copyInputQueue(); + return new StringBuilder(900) + .append("{queueSize=").append(flyingQueue.length) + .append(",inputQueueSize=").append(inputQueue.length) + .append(",lastMatchedMoveToSeq=").append(data.getLastMatchedMoveToSequence()) + .append(",currentPacketSeq=").append(packetData == null ? -1L : packetData.getSequence()) + .append(",clientMotion=").append(formatClientMotion(flyingQueue)) + .append(",knownFromPreviousPacket=").append(formatKnownFromPreviousPacket(flyingQueue, knownLocation)) + .append(",packetFromKnown=").append(formatLocationDelta(knownLocation, packetLocation)) + .append(",recentPackets=").append(formatFlyingQueue(flyingQueue, now)) + .append(",recentInputs=").append(formatInputQueue(inputQueue)) + .append('}') + .toString(); + } + + private String formatClientMotion(final DataPacketFlying[] queue) { + if (queue.length < 2 || !queue[0].hasPos || !queue[1].hasPos) { + return "none"; + } + return formatPacketDelta(queue[1], queue[0]); + } + + private String formatKnownFromPreviousPacket(final DataPacketFlying[] queue, final Location knownLocation) { + if (queue.length < 2 || !queue[1].hasPos) { + return "none"; + } + return formatPositionDelta(queue[1].getX(), queue[1].getY(), queue[1].getZ(), + knownLocation.getX(), knownLocation.getY(), knownLocation.getZ()); + } + + private String formatFlyingQueue(final DataPacketFlying[] queue, final long now) { + if (queue.length == 0) { + return "[]"; + } + final StringBuilder builder = new StringBuilder(500); + builder.append('['); + final int max = Math.min(queue.length, 6); + for (int i = 0; i < max; i++) { + if (i > 0) { + builder.append(';'); + } + builder.append(formatPacket(queue[i], now)); + if (i + 1 < queue.length && queue[i].hasPos && queue[i + 1].hasPos) { + builder.append(",dPrev=").append(formatPacketDelta(queue[i + 1], queue[i])); + } + } + if (queue.length > max) { + builder.append(";..."); + } + builder.append(']'); + return builder.toString(); + } + + private String formatInputQueue(final DataPacketInput[] queue) { + if (queue.length == 0) { + return "[]"; + } + final StringBuilder builder = new StringBuilder(220); + builder.append('['); + final int max = Math.min(queue.length, 6); + for (int i = 0; i < max; i++) { + if (i > 0) { + builder.append(';'); + } + builder.append(formatInput(queue[i])); + } + if (queue.length > max) { + builder.append(";..."); + } + builder.append(']'); + return builder.toString(); + } + + private String formatPacket(final DataPacketFlying packetData, final long now) { + if (packetData == null) { + return "null"; + } + final StringBuilder builder = new StringBuilder(220); + builder.append("{seq=").append(packetData.getSequence()) + .append(",age=").append(now >= packetData.time ? now - packetData.time : -(packetData.time - now)) + .append(",type=").append(packetData.getSimplifiedContentType()) + .append(",tracked=").append(packetData.isTracked()) + .append(",ground=").append(packetData.onGround) + .append(",hCollision=").append(packetData.horizontalCollision); + if (packetData.hasPos) { + builder.append(",pos=").append(formatVector(packetData.getX(), packetData.getY(), packetData.getZ())); + } + if (packetData.hasLook) { + builder.append(",look=").append(StringUtil.fdec3.format(packetData.getYaw())) + .append('/').append(StringUtil.fdec3.format(packetData.getPitch())); + } + builder.append('}'); + return builder.toString(); + } + + private String formatKeyboardInput(final PlayerKeyboardInput input) { + return new StringBuilder(120) + .append("current=").append(input.getForwardDir()).append('/').append(input.getStrafeDir()) + .append('(').append(StringUtil.fdec3.format(input.getForward())).append('/') + .append(StringUtil.fdec3.format(input.getStrafe())).append(')') + .append(",last=(").append(StringUtil.fdec3.format(input.getLastForward())).append('/') + .append(StringUtil.fdec3.format(input.getLastStrafe())).append(')') + .append(",keys=jump:").append(input.isSpaceBarPressed()).append("->").append(input.wasSpaceBarPressed()) + .append(",sneak:").append(input.isShift()).append("->").append(input.wasShifting()) + .append(",sprint:").append(input.isSprintingKeyPressed()).append("->").append(input.wasSprintingKeyPressed()) + .toString(); + } + + private String formatInput(final DataPacketInput input) { + if (input == null) { + return "none"; + } + return new StringBuilder(80) + .append("f:").append(input.forward) + .append(",b:").append(input.backward) + .append(",l:").append(input.left) + .append(",r:").append(input.right) + .append(",j:").append(input.jump) + .append(",shift:").append(input.shift) + .append(",sprint:").append(input.sprint) + .toString(); + } + + private String formatPacketDelta(final DataPacketFlying from, final DataPacketFlying to) { + return formatPositionDelta(from.getX(), from.getY(), from.getZ(), to.getX(), to.getY(), to.getZ()); + } + + private String formatLocationDelta(final Location from, final Location to) { + return formatPositionDelta(from.getX(), from.getY(), from.getZ(), to.getX(), to.getY(), to.getZ()); + } + + private String formatPositionDelta(final double fromX, final double fromY, final double fromZ, + final double toX, final double toY, final double toZ) { + final double xDistance = toX - fromX; + final double yDistance = toY - fromY; + final double zDistance = toZ - fromZ; + final double hDistance = Math.sqrt(xDistance * xDistance + zDistance * zDistance); + final double total = Math.sqrt(hDistance * hDistance + yDistance * yDistance); + return new StringBuilder(120) + .append("x=").append(StringUtil.fdec3.format(xDistance)) + .append(",y=").append(StringUtil.fdec3.format(yDistance)) + .append(",z=").append(StringUtil.fdec3.format(zDistance)) + .append(",h=").append(StringUtil.fdec3.format(hDistance)) + .append(",total=").append(StringUtil.fdec3.format(total)) + .toString(); + } + + private String formatLocationData(final LocationData loc) { + final StringBuilder builder = new StringBuilder(140); + builder.append(loc.getWorldName()).append('@').append(LocUtil.simpleFormat(loc)); + if (loc.extraPropertiesValid) { + builder.append(",ground=").append(loc.onGround) + .append(",reset=").append(loc.resetCond) + .append(",liquid=").append(loc.inLiquid) + .append(",web=").append(loc.inWeb) + .append(",powder=").append(loc.inPowderSnow) + .append(",ice=").append(loc.onIce || loc.onBlueIce) + .append(",slime=").append(loc.onSlimeBlock) + .append(",honey=").append(loc.onHoneyBlock); + } + else { + builder.append(",flags=unknown"); + } + return builder.toString(); + } + + private String formatBukkitLocation(final Location loc) { + return loc == null ? "null" : (loc.getWorld() == null ? "null" : loc.getWorld().getName()) + '@' + LocUtil.simpleFormat(loc); + } + + private String formatVector(final double x, final double y, final double z) { + return StringUtil.fdec3.format(x) + "," + StringUtil.fdec3.format(y) + "," + StringUtil.fdec3.format(z); + } +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/NetData.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/NetData.java index 76487699b9..9984d3f814 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/NetData.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/NetData.java @@ -15,6 +15,7 @@ package fr.neatmonster.nocheatplus.checks.net; import java.util.LinkedList; +import java.util.Iterator; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -49,6 +50,25 @@ */ public class NetData extends ACheckData { + private static final long MOVING_TELEPORT_GRACE_MS = 4000L; + private static final long MOVING_SERVER_JUMP_RECOVERY_GRACE_MS = 1500L; + private static final double MOVING_POSITION_MATCH_EPSILON = 0.03125D; + private static final double MOVING_SERVER_JUMP_DISTANCE = 100.0D; + private static final double MOVING_SERVER_JUMP_PACKET_DISTANCE = 64.0D; + private static final long MOVING_SERVER_JUMP_STALE_PACKET_MODEL_MS = 4000L; + private static final double MOVING_SERVER_JUMP_TARGET_MODEL_DISTANCE = 8.0D; + private static final double MOVING_STALE_PACKET_HORIZONTAL_BASE = 1.25D; + private static final double MOVING_STALE_PACKET_VERTICAL_BASE = 0.75D; + private static final double MOVING_STALE_PACKET_VERTICAL_PER_TICK = 0.30D; + private static final double MOVING_STALE_PACKET_VERTICAL_MAX = 4.0D; + private static final double MOVING_STALE_PACKET_MOTION_MULTIPLIER = 3.0D; + private static final long MOVING_TELEPORT_COMMAND_PENDING_MS = 15000L; + private static final long MOVING_TELEPORT_RESYNC_PENDING_MS = 4000L; + private static final double MOVING_TELEPORT_RESYNC_TARGET_DISTANCE = 8.0D; + private static final int MOVING_TELEPORT_RESYNC_NORMAL_STALE_PACKET_LIMIT = 2; + private static final long KEEP_ALIVE_REQUEST_MAX_AGE_MS = 30000L; + private static final int KEEP_ALIVE_REQUEST_MAX_PENDING = 40; + // Reentrant lock. private final Lock lock = new ReentrantLock(); private final Lock locki = new ReentrantLock(); @@ -62,6 +82,65 @@ public class NetData extends ACheckData { // Moving public double movingVL = 0; + // NetMoving diagnostics/model state: remember teleport and server-position packets for packet-order false flags. + private long lastMovingTeleportTime = 0L; + private String lastMovingTeleportWorld = null; + private String lastMovingTeleportCause = "none"; + private double lastMovingTeleportX = 0.0; + private double lastMovingTeleportY = 0.0; + private double lastMovingTeleportZ = 0.0; + private float lastMovingTeleportYaw = 0.0f; + private float lastMovingTeleportPitch = 0.0f; + private long lastOutgoingPositionTime = 0L; + private boolean lastOutgoingPositionTracked = false; + private int lastOutgoingPositionTeleportId = Integer.MIN_VALUE; + private double lastOutgoingPositionX = 0.0; + private double lastOutgoingPositionY = 0.0; + private double lastOutgoingPositionZ = 0.0; + private float lastOutgoingPositionYaw = 0.0f; + private float lastOutgoingPositionPitch = 0.0f; + private long lastMovingKnownLocationTime = 0L; + private String lastMovingKnownLocationWorld = null; + private double lastMovingKnownLocationX = 0.0; + private double lastMovingKnownLocationY = 0.0; + private double lastMovingKnownLocationZ = 0.0; + private float lastMovingKnownLocationYaw = 0.0f; + private float lastMovingKnownLocationPitch = 0.0f; + private long lastServerPositionJumpTime = 0L; + private String lastServerPositionJumpFromWorld = null; + private String lastServerPositionJumpToWorld = null; + private double lastServerPositionJumpFromX = 0.0; + private double lastServerPositionJumpFromY = 0.0; + private double lastServerPositionJumpFromZ = 0.0; + private float lastServerPositionJumpFromYaw = 0.0f; + private float lastServerPositionJumpFromPitch = 0.0f; + private double lastServerPositionJumpToX = 0.0; + private double lastServerPositionJumpToY = 0.0; + private double lastServerPositionJumpToZ = 0.0; + private float lastServerPositionJumpToYaw = 0.0f; + private float lastServerPositionJumpToPitch = 0.0f; + private long lastServerPositionJumpRecoveryUsedTime = 0L; + private long lastTeleportCommandTime = 0L; + private String lastTeleportCommand = "none"; + private String lastTeleportCommandWorld = null; + private double lastTeleportCommandX = 0.0; + private double lastTeleportCommandY = 0.0; + private double lastTeleportCommandZ = 0.0; + private float lastTeleportCommandYaw = 0.0f; + private float lastTeleportCommandPitch = 0.0f; + private long lastTeleportResyncMovingDataKey = 0L; + private long lastTeleportResyncRequestTime = 0L; + private long lastTeleportResyncAppliedKey = 0L; + private String lastTeleportResyncReason = "none"; + private String lastTeleportResyncWorld = null; + private double lastTeleportResyncX = 0.0; + private double lastTeleportResyncY = 0.0; + private double lastTeleportResyncZ = 0.0; + private float lastTeleportResyncYaw = 0.0f; + private float lastTeleportResyncPitch = 0.0f; + private long lastTeleportResyncDroppedPacketLogTime = 0L; + private int lastTeleportResyncDroppedPacketCount = 0; + private int lastTeleportResyncPendingDropLogCount = 0; // KeepAliveFrequency /** @@ -69,6 +148,34 @@ public class NetData extends ACheckData { * time of the last event. System.currentTimeMillis() is used. */ public ActionFrequency keepAliveFreq = new ActionFrequency(20, 1000); + public long keepAlivePacketTime = 0L; + public long keepAlivePreviousPacketTime = 0L; + public long keepAlivePacketDelta = -1L; + public long keepAlivePacketId = 0L; + public long keepAlivePreviousPacketId = 0L; + public boolean keepAlivePacketIdAvailable = false; + public boolean keepAlivePreviousPacketIdAvailable = false; + public boolean keepAliveDuplicateId = false; + // KeepAlive diagnostics: record packet shape/thread so async Folia timing is visible in console logs. + public String keepAlivePacketIdType = "none"; + public int keepAlivePacketLongCount = -1; + public int keepAlivePacketIntCount = -1; + public boolean keepAlivePacketAsync = false; + public String keepAlivePacketThread = "unknown"; + // KeepAlive model: expected client replies should match a recent outgoing server keepalive id. + private final LinkedList keepAliveRequests = new LinkedList(); + public boolean keepAliveOutgoingSeen = false; + public long keepAliveOutgoingPacketTime = 0L; + public long keepAliveOutgoingPacketId = 0L; + public boolean keepAliveOutgoingPacketIdAvailable = false; + public String keepAliveOutgoingPacketIdType = "none"; + public int keepAliveOutgoingPacketLongCount = -1; + public int keepAliveOutgoingPacketIntCount = -1; + public boolean keepAliveOutgoingPacketAsync = false; + public String keepAliveOutgoingPacketThread = "unknown"; + public boolean keepAliveExpectedResponse = false; + public long keepAliveExpectedResponseAge = -1L; + public int keepAlivePendingRequests = 0; // Wrong Turn public double wrongTurnVL = 0; @@ -123,13 +230,736 @@ public NetData(final NetConfig config) { } public void onJoin(final Player player) { + resetMovingTeleportDiagnostics(); teleportQueue.clear(); clearFlyingQueue(); + clearKeepAliveTracking(); } public void onLeave(Player player) { + resetMovingTeleportDiagnostics(); teleportQueue.clear(); clearFlyingQueue(); + clearKeepAliveTracking(); + } + + /** + * Register a Bukkit teleport for packet-level moving checks. + */ + public void recordTeleportEvent(final Location loc) { + recordTeleportEvent(loc, "unknown"); + } + + /** + * Register a Bukkit teleport for packet-level moving checks. + */ + public void recordTeleportEvent(final Location loc, final String cause) { + lastMovingTeleportTime = System.currentTimeMillis(); + lastMovingTeleportCause = cause == null ? "unknown" : cause; + movingVL *= 0.5; + clearFlyingQueue(); + if (loc != null) { + lastMovingTeleportWorld = loc.getWorld() == null ? "null" : loc.getWorld().getName(); + lastMovingTeleportX = loc.getX(); + lastMovingTeleportY = loc.getY(); + lastMovingTeleportZ = loc.getZ(); + lastMovingTeleportYaw = loc.getYaw(); + lastMovingTeleportPitch = loc.getPitch(); + teleportQueue.onTeleportEvent(loc.getX(), loc.getY(), loc.getZ(), loc.getYaw(), loc.getPitch()); + } + } + + /** + * Register every outgoing server position packet, including ones not matched + * to a Bukkit PlayerTeleportEvent. This catches packet/event ordering races. + */ + public void recordOutgoingPosition(final double x, final double y, final double z, + final float yaw, final float pitch, + final int teleportId, final boolean tracked, + final long time) { + lastOutgoingPositionTime = time; + lastOutgoingPositionTracked = tracked; + lastOutgoingPositionTeleportId = teleportId; + lastOutgoingPositionX = x; + lastOutgoingPositionY = y; + lastOutgoingPositionZ = z; + lastOutgoingPositionYaw = yaw; + lastOutgoingPositionPitch = pitch; + } + + public void recordKeepAlivePacket(final long time, final boolean idAvailable, final long id, + final String idType, final int longCount, final int intCount, + final boolean async, final String threadName) { + keepAlivePreviousPacketTime = keepAlivePacketTime; + keepAlivePreviousPacketId = keepAlivePacketId; + keepAlivePreviousPacketIdAvailable = keepAlivePacketIdAvailable; + keepAlivePacketDelta = keepAlivePreviousPacketTime <= 0L ? -1L : time - keepAlivePreviousPacketTime; + keepAlivePacketTime = time; + keepAlivePacketIdAvailable = idAvailable; + keepAlivePacketId = id; + keepAlivePacketIdType = idType == null ? "none" : idType; + keepAlivePacketLongCount = longCount; + keepAlivePacketIntCount = intCount; + keepAlivePacketAsync = async; + keepAlivePacketThread = threadName == null ? "unknown" : threadName; + keepAliveDuplicateId = idAvailable && keepAlivePreviousPacketIdAvailable && id == keepAlivePreviousPacketId; + keepAliveExpectedResponse = false; + keepAliveExpectedResponseAge = -1L; + synchronized (keepAliveRequests) { + pruneKeepAliveRequests(time); + if (idAvailable) { + final Iterator iterator = keepAliveRequests.iterator(); + while (iterator.hasNext()) { + final KeepAliveRequest request = iterator.next(); + if (request.id == id) { + keepAliveExpectedResponse = true; + keepAliveExpectedResponseAge = Math.max(0L, time - request.time); + iterator.remove(); + break; + } + } + } + keepAlivePendingRequests = keepAliveRequests.size(); + } + } + + public void recordOutgoingKeepAlivePacket(final long time, final boolean idAvailable, final long id, + final String idType, final int longCount, final int intCount, + final boolean async, final String threadName) { + keepAliveOutgoingSeen = true; + keepAliveOutgoingPacketTime = time; + keepAliveOutgoingPacketIdAvailable = idAvailable; + keepAliveOutgoingPacketId = id; + keepAliveOutgoingPacketIdType = idType == null ? "none" : idType; + keepAliveOutgoingPacketLongCount = longCount; + keepAliveOutgoingPacketIntCount = intCount; + keepAliveOutgoingPacketAsync = async; + keepAliveOutgoingPacketThread = threadName == null ? "unknown" : threadName; + synchronized (keepAliveRequests) { + pruneKeepAliveRequests(time); + if (idAvailable) { + keepAliveRequests.addFirst(new KeepAliveRequest(time, id)); + while (keepAliveRequests.size() > KEEP_ALIVE_REQUEST_MAX_PENDING) { + keepAliveRequests.removeLast(); + } + } + keepAlivePendingRequests = keepAliveRequests.size(); + } + } + + private void pruneKeepAliveRequests(final long time) { + while (!keepAliveRequests.isEmpty() + && time - keepAliveRequests.getLast().time > KEEP_ALIVE_REQUEST_MAX_AGE_MS) { + keepAliveRequests.removeLast(); + } + } + + private void clearKeepAliveTracking() { + synchronized (keepAliveRequests) { + keepAliveRequests.clear(); + keepAlivePendingRequests = 0; + } + keepAliveOutgoingSeen = false; + keepAliveOutgoingPacketTime = 0L; + keepAliveOutgoingPacketId = 0L; + keepAliveOutgoingPacketIdAvailable = false; + keepAliveOutgoingPacketIdType = "none"; + keepAliveOutgoingPacketLongCount = -1; + keepAliveOutgoingPacketIntCount = -1; + keepAliveOutgoingPacketAsync = false; + keepAliveOutgoingPacketThread = "unknown"; + keepAliveExpectedResponse = false; + keepAliveExpectedResponseAge = -1L; + } + + /** + * Track the server-side player location seen by NET_MOVING. If it suddenly + * jumps far away, stale client packets near the previous server location are + * very likely plugin-teleport leftovers. + */ + public void recordMovingKnownLocation(final Location loc, final long now) { + if (loc == null) { + return; + } + final String world = loc.getWorld() == null ? "null" : loc.getWorld().getName(); + if (lastMovingKnownLocationTime > 0L) { + final boolean worldChanged = lastMovingKnownLocationWorld == null + || !lastMovingKnownLocationWorld.equals(world); + final double distance = distance(lastMovingKnownLocationX, lastMovingKnownLocationY, lastMovingKnownLocationZ, + loc.getX(), loc.getY(), loc.getZ()); + if (worldChanged || distance > MOVING_SERVER_JUMP_DISTANCE) { + lastServerPositionJumpTime = now; + lastServerPositionJumpFromWorld = lastMovingKnownLocationWorld; + lastServerPositionJumpFromX = lastMovingKnownLocationX; + lastServerPositionJumpFromY = lastMovingKnownLocationY; + lastServerPositionJumpFromZ = lastMovingKnownLocationZ; + lastServerPositionJumpFromYaw = lastMovingKnownLocationYaw; + lastServerPositionJumpFromPitch = lastMovingKnownLocationPitch; + lastServerPositionJumpToWorld = world; + lastServerPositionJumpToX = loc.getX(); + lastServerPositionJumpToY = loc.getY(); + lastServerPositionJumpToZ = loc.getZ(); + lastServerPositionJumpToYaw = loc.getYaw(); + lastServerPositionJumpToPitch = loc.getPitch(); + } + } + lastMovingKnownLocationTime = now; + lastMovingKnownLocationWorld = world; + lastMovingKnownLocationX = loc.getX(); + lastMovingKnownLocationY = loc.getY(); + lastMovingKnownLocationZ = loc.getZ(); + lastMovingKnownLocationYaw = loc.getYaw(); + lastMovingKnownLocationPitch = loc.getPitch(); + } + + /** + * Mark that a command likely to teleport the player has been issued. + */ + public void recordTeleportCommand(final String command, final Location loc, final long now) { + lastTeleportCommandTime = now; + lastTeleportCommand = command == null ? "unknown" : command; + if (loc != null) { + lastTeleportCommandWorld = loc.getWorld() == null ? "null" : loc.getWorld().getName(); + lastTeleportCommandX = loc.getX(); + lastTeleportCommandY = loc.getY(); + lastTeleportCommandZ = loc.getZ(); + lastTeleportCommandYaw = loc.getYaw(); + lastTeleportCommandPitch = loc.getPitch(); + } + } + + public boolean isWithinMovingTeleportGrace(final long now) { + return lastMovingTeleportTime > 0L + && now >= lastMovingTeleportTime + && now - lastMovingTeleportTime <= MOVING_TELEPORT_GRACE_MS; + } + + public boolean isWithinOutgoingPositionGrace(final long now, final Location knownLocation) { + return lastOutgoingPositionTime > 0L + && now >= lastOutgoingPositionTime + && now - lastOutgoingPositionTime <= MOVING_TELEPORT_GRACE_MS + && matchesPosition(lastOutgoingPositionX, lastOutgoingPositionY, lastOutgoingPositionZ, knownLocation); + } + + public boolean consumeExpectedOutgoingPosition(final DataPacketFlying packetData) { + // Folia/respawn compatibility: the move can arrive before the outgoing teleport packet is recorded. + return teleportQueue.consumeExpectedOutgoingPosition(packetData); + } + + public boolean isWithinServerPositionJumpGrace(final long now, final Location knownLocation, final Location packetLocation) { + return lastServerPositionJumpTime > 0L + && now >= lastServerPositionJumpTime + && now - lastServerPositionJumpTime <= MOVING_TELEPORT_GRACE_MS + && distanceToStored(lastServerPositionJumpToX, lastServerPositionJumpToY, lastServerPositionJumpToZ, knownLocation) <= MOVING_SERVER_JUMP_PACKET_DISTANCE + && distanceToStored(lastServerPositionJumpFromX, lastServerPositionJumpFromY, lastServerPositionJumpFromZ, packetLocation) <= MOVING_SERVER_JUMP_PACKET_DISTANCE; + } + + public boolean isWithinServerPositionJumpStalePacketModel(final long now, + final Location knownLocation, + final Location packetLocation, + final DataPacketFlying packetData) { + // Teleport model: Folia/async teleports can leave pre-teleport packets in flight after the server moved. + return lastServerPositionJumpTime > 0L + && now >= lastServerPositionJumpTime + && now - lastServerPositionJumpTime <= MOVING_SERVER_JUMP_STALE_PACKET_MODEL_MS + && distanceToStored(lastServerPositionJumpToX, lastServerPositionJumpToY, lastServerPositionJumpToZ, + knownLocation) <= MOVING_SERVER_JUMP_TARGET_MODEL_DISTANCE + && isStalePacketNearStoredPositionModel(packetLocation, packetData, + lastServerPositionJumpFromX, lastServerPositionJumpFromY, lastServerPositionJumpFromZ, + now - lastServerPositionJumpTime); + } + + public boolean consumeServerPositionJumpRecoveryGrace(final long now, final Location knownLocation) { + // Folia/death compatibility: allow one stale packet after a server-side jump even if it matches neither endpoint. + if (lastServerPositionJumpTime <= 0L + || now < lastServerPositionJumpTime + || now - lastServerPositionJumpTime > MOVING_SERVER_JUMP_RECOVERY_GRACE_MS + || lastServerPositionJumpRecoveryUsedTime == lastServerPositionJumpTime + || distanceToStored(lastServerPositionJumpToX, lastServerPositionJumpToY, lastServerPositionJumpToZ, knownLocation) > MOVING_SERVER_JUMP_PACKET_DISTANCE) { + return false; + } + lastServerPositionJumpRecoveryUsedTime = lastServerPositionJumpTime; + return true; + } + + public boolean isWithinTeleportCommandGrace(final long now, final Location knownLocation, final Location packetLocation) { + return lastTeleportCommandTime > 0L + && now >= lastTeleportCommandTime + && now - lastTeleportCommandTime <= MOVING_TELEPORT_COMMAND_PENDING_MS + && distanceToStored(lastTeleportCommandX, lastTeleportCommandY, lastTeleportCommandZ, knownLocation) > MOVING_SERVER_JUMP_DISTANCE + && distanceToStored(lastTeleportCommandX, lastTeleportCommandY, lastTeleportCommandZ, packetLocation) <= MOVING_SERVER_JUMP_PACKET_DISTANCE; + } + + public boolean isWithinTeleportCommandStalePacketModel(final long now, + final Location knownLocation, + final Location packetLocation, + final DataPacketFlying packetData) { + // Teleport command model: command plugins can async-teleport before Bukkit's event/order data is visible here. + return lastTeleportCommandTime > 0L + && now >= lastTeleportCommandTime + && now - lastTeleportCommandTime <= MOVING_TELEPORT_COMMAND_PENDING_MS + && distanceToStored(lastTeleportCommandX, lastTeleportCommandY, lastTeleportCommandZ, + knownLocation) > MOVING_SERVER_JUMP_DISTANCE + && isStalePacketNearStoredPositionModel(packetLocation, packetData, + lastTeleportCommandX, lastTeleportCommandY, lastTeleportCommandZ, + now - lastTeleportCommandTime); + } + + public void acceptMovingTeleportResync(final Player player, final Plugin plugin, final MovingData mData, + final Location knownLocation, final Location packetLocation, + final DataPacketFlying packetData, final long now, final String reason) { + // Teleport model: accepted stale packets are packet-order leftovers, not valid post-teleport history. + movingVL *= 0.5; + final int queueSizeBeforeClear = getFlyingQueueSize(); + clearFlyingQueue(); + if (player == null || plugin == null || mData == null || knownLocation == null + || knownLocation.getWorld() == null) { + return; + } + final Location modelLocation = LocUtil.clone(knownLocation); + final long resyncKey = Math.max(lastServerPositionJumpTime, lastTeleportCommandTime); + if (resyncKey <= 0L || lastTeleportResyncMovingDataKey == resyncKey) { + return; + } + lastTeleportResyncMovingDataKey = resyncKey; + recordPendingTeleportResync(resyncKey, modelLocation, now, reason); + logTeleportResyncStalePacketDrop(player, now, modelLocation, packetLocation, packetData, + reason, "opened", queueSizeBeforeClear, true); + final Object task = SchedulerHelper.runSyncTaskForEntity(player, plugin, (arg) -> { + if (lastTeleportResyncAppliedKey == resyncKey) { + return; + } + Location syncLocation = null; + try { + syncLocation = player.isOnline() ? player.getLocation() : null; + } + catch (Throwable ignored) {} + // Folia model sync: refresh MovingData only on the player's owning region thread. + mData.onExternalTeleportResync(syncLocation != null && syncLocation.getWorld() != null + ? syncLocation : modelLocation); + markTeleportResyncApplied(resyncKey); + if (shouldLogTeleportResyncSuccessToConsole()) { + player.getServer().getLogger().info(NetDiagnostics.formatTeleportResyncApplied(player.getName(), + "applied", reason, lastTeleportResyncX, lastTeleportResyncY, lastTeleportResyncZ, + lastTeleportResyncYaw, lastTeleportResyncPitch)); + } + }, null); + if (!SchedulerHelper.isTaskScheduled(task)) { + StaticLog.logWarning("Failed to schedule teleport resync moving-data refresh for player: " + + player.getName() + " (" + (reason == null ? "unknown" : reason) + ")"); + } + } + + public boolean isWithinTeleportResyncStalePacketDropModel(final long now, + final Location knownLocation, + final Location packetLocation, + final DataPacketFlying packetData) { + if (lastTeleportResyncMovingDataKey <= 0L + || lastTeleportResyncRequestTime <= 0L + || now < lastTeleportResyncRequestTime + || now - lastTeleportResyncRequestTime > MOVING_TELEPORT_RESYNC_PENDING_MS + || distanceToStored(lastTeleportResyncX, lastTeleportResyncY, lastTeleportResyncZ, + knownLocation) > MOVING_TELEPORT_RESYNC_TARGET_DISTANCE) { + return false; + } + final long serverJumpAge = lastServerPositionJumpTime <= 0L ? Long.MAX_VALUE : now - lastServerPositionJumpTime; + if (lastServerPositionJumpTime > 0L + && serverJumpAge >= 0L + && serverJumpAge <= MOVING_SERVER_JUMP_STALE_PACKET_MODEL_MS + && isStalePacketNearStoredPositionModel(packetLocation, packetData, + lastServerPositionJumpFromX, lastServerPositionJumpFromY, + lastServerPositionJumpFromZ, serverJumpAge)) { + return true; + } + final long commandAge = lastTeleportCommandTime <= 0L ? Long.MAX_VALUE : now - lastTeleportCommandTime; + return lastTeleportCommandTime > 0L + && commandAge >= 0L + && commandAge <= MOVING_TELEPORT_COMMAND_PENDING_MS + && isStalePacketNearStoredPositionModel(packetLocation, packetData, + lastTeleportCommandX, lastTeleportCommandY, lastTeleportCommandZ, commandAge); + } + + public void acceptMovingTeleportStalePacketContinuation(final Player player, + final Location knownLocation, + final Location packetLocation, + final DataPacketFlying packetData, + final long now, + final String reason) { + // Teleport model: additional old-location packets after resync are deleted from packet history. + movingVL *= 0.5; + final int queueSizeBeforeClear = getFlyingQueueSize(); + clearFlyingQueue(); + logTeleportResyncStalePacketDrop(player, now, knownLocation, packetLocation, packetData, + reason, "continuation", queueSizeBeforeClear, false); + } + + /** + * Apply a pending teleport resync from the player move path, where MovingData + * is owned by the correct Folia region. This prevents stale pre-teleport + * set backs from surviving until the next scheduled entity task. + */ + public boolean applyPendingTeleportResync(final Player player, final MovingData mData, + final Location eventFrom, final Location eventTo, + final long now) { + if (player == null || mData == null || lastTeleportResyncMovingDataKey <= 0L + || lastTeleportResyncAppliedKey == lastTeleportResyncMovingDataKey + || lastTeleportResyncRequestTime <= 0L + || now < lastTeleportResyncRequestTime + || now - lastTeleportResyncRequestTime > MOVING_TELEPORT_RESYNC_PENDING_MS) { + return false; + } + final Location modelTarget = getPendingTeleportResyncTarget(); + if (modelTarget == null) { + return false; + } + Location current = null; + try { + current = player.isOnline() ? player.getLocation() : null; + } + catch (Throwable ignored) {} + final Location appliedTarget = isWorldBackedPendingTeleportTarget(current) ? current + : isWorldBackedPendingTeleportTarget(eventTo) ? eventTo + : isWorldBackedPendingTeleportTarget(eventFrom) ? eventFrom + : null; + if (appliedTarget == null) { + return false; + } + // Teleport/Folia model: move-event code is region-safe, so clear old movement history immediately. + mData.onExternalTeleportResync(appliedTarget); + markTeleportResyncApplied(lastTeleportResyncMovingDataKey); + if (shouldLogTeleportResyncSuccessToConsole()) { + player.getServer().getLogger().info(NetDiagnostics.formatTeleportResyncApplied(player.getName(), + "applied_move_event", lastTeleportResyncReason, + lastTeleportResyncX, lastTeleportResyncY, lastTeleportResyncZ, + lastTeleportResyncYaw, lastTeleportResyncPitch)); + } + return true; + } + + private void recordPendingTeleportResync(final long resyncKey, final Location loc, + final long now, final String reason) { + lastTeleportResyncRequestTime = now; + lastTeleportResyncReason = reason == null ? "unknown" : reason; + lastTeleportResyncWorld = loc.getWorld() == null ? "null" : loc.getWorld().getName(); + lastTeleportResyncX = loc.getX(); + lastTeleportResyncY = loc.getY(); + lastTeleportResyncZ = loc.getZ(); + lastTeleportResyncYaw = loc.getYaw(); + lastTeleportResyncPitch = loc.getPitch(); + if (lastTeleportResyncAppliedKey != resyncKey) { + lastTeleportResyncAppliedKey = 0L; + } + lastTeleportResyncDroppedPacketLogTime = 0L; + lastTeleportResyncDroppedPacketCount = 0; + lastTeleportResyncPendingDropLogCount = 0; + } + + private void markTeleportResyncApplied(final long resyncKey) { + if (resyncKey > 0L) { + lastTeleportResyncAppliedKey = resyncKey; + } + } + + private Location getPendingTeleportResyncTarget() { + if (lastTeleportResyncMovingDataKey <= 0L || lastTeleportResyncWorld == null) { + return null; + } + return new Location(null, lastTeleportResyncX, lastTeleportResyncY, lastTeleportResyncZ, + lastTeleportResyncYaw, lastTeleportResyncPitch); + } + + private boolean isNearPendingTeleportTarget(final Location loc) { + if (loc == null) { + return false; + } + if (loc.getWorld() != null && lastTeleportResyncWorld != null + && !"null".equals(lastTeleportResyncWorld) + && !lastTeleportResyncWorld.equals(loc.getWorld().getName())) { + return false; + } + return distanceToStored(lastTeleportResyncX, lastTeleportResyncY, lastTeleportResyncZ, loc) + <= MOVING_TELEPORT_RESYNC_TARGET_DISTANCE; + } + + private boolean isWorldBackedPendingTeleportTarget(final Location loc) { + // Folia packet safety: only real Bukkit-world locations may be stored as MovingData set backs. + return loc != null && loc.getWorld() != null && isNearPendingTeleportTarget(loc); + } + + private boolean isStalePacketNearStoredPositionModel(final Location packetLocation, + final DataPacketFlying packetData, + final double storedX, + final double storedY, + final double storedZ, + final long age) { + if (packetLocation == null || packetData == null || !packetData.hasPos) { + return false; + } + final double horizontal = horizontalDistance(storedX, storedZ, packetLocation.getX(), packetLocation.getZ()); + final double vertical = Math.abs(packetLocation.getY() - storedY); + return horizontal <= getStalePacketHorizontalModel(packetData) + && vertical <= getStalePacketVerticalModel(packetData, age); + } + + private double getStalePacketHorizontalModel(final DataPacketFlying packetData) { + final DataPacketFlying previous = getPreviousPositionPacket(packetData); + if (previous == null) { + return MOVING_STALE_PACKET_HORIZONTAL_BASE; + } + final double motion = horizontalDistance(previous.getX(), previous.getZ(), packetData.getX(), packetData.getZ()); + return Math.max(MOVING_STALE_PACKET_HORIZONTAL_BASE, + motion * MOVING_STALE_PACKET_MOTION_MULTIPLIER + MOVING_POSITION_MATCH_EPSILON); + } + + private double getStalePacketVerticalModel(final DataPacketFlying packetData, final long age) { + final double ageTicks = Math.max(1.0D, Math.ceil(Math.max(0L, age) / 50.0D) + 1.0D); + final double ageEnvelope = MOVING_STALE_PACKET_VERTICAL_BASE + + ageTicks * MOVING_STALE_PACKET_VERTICAL_PER_TICK; + final DataPacketFlying previous = getPreviousPositionPacket(packetData); + if (previous == null) { + return Math.min(MOVING_STALE_PACKET_VERTICAL_MAX, ageEnvelope); + } + final double motion = Math.abs(packetData.getY() - previous.getY()); + return Math.min(MOVING_STALE_PACKET_VERTICAL_MAX, + Math.max(ageEnvelope, motion * MOVING_STALE_PACKET_MOTION_MULTIPLIER + + MOVING_STALE_PACKET_VERTICAL_BASE)); + } + + private DataPacketFlying getPreviousPositionPacket(final DataPacketFlying packetData) { + if (packetData == null) { + return null; + } + lock.lock(); + try { + boolean foundCurrent = false; + for (final DataPacketFlying packet : flyingQueue) { + if (packet == null || !packet.hasPos) { + continue; + } + if (foundCurrent) { + return packet; + } + if (packet == packetData || packet.getSequence() == packetData.getSequence()) { + foundCurrent = true; + } + } + return null; + } + finally { + lock.unlock(); + } + } + + public long getMovingTeleportGraceAge(final long now) { + return lastMovingTeleportTime <= 0L ? -1L : now - lastMovingTeleportTime; + } + + public long getOutgoingPositionGraceAge(final long now) { + return lastOutgoingPositionTime <= 0L ? -1L : now - lastOutgoingPositionTime; + } + + public long getServerPositionJumpGraceAge(final long now) { + return lastServerPositionJumpTime <= 0L ? -1L : now - lastServerPositionJumpTime; + } + + public long getTeleportCommandGraceAge(final long now) { + return lastTeleportCommandTime <= 0L ? -1L : now - lastTeleportCommandTime; + } + + public String describeMovingTeleportState(final long now, final Location knownLocation, + final Location packetLocation, + final DataPacketFlying packetData) { + return new StringBuilder(500) + .append("eventAge=").append(getMovingTeleportGraceAge(now)) + .append(",eventWithinGrace=").append(isWithinMovingTeleportGrace(now)) + .append(",eventCause=").append(lastMovingTeleportCause) + .append(",eventLoc=").append(formatTeleportEventLocation()) + .append(",eventToKnown=").append(NetDiagnostics.formatStoredDistance(lastMovingTeleportTime, lastMovingTeleportX, lastMovingTeleportY, lastMovingTeleportZ, knownLocation)) + .append(",eventToPacket=").append(NetDiagnostics.formatStoredDistance(lastMovingTeleportTime, lastMovingTeleportX, lastMovingTeleportY, lastMovingTeleportZ, packetLocation)) + .append(",outgoingAge=").append(getOutgoingPositionGraceAge(now)) + .append(",outgoingWithinGrace=").append(isWithinOutgoingPositionGrace(now, knownLocation)) + .append(",outgoingTracked=").append(lastOutgoingPositionTracked) + .append(",outgoingTeleportId=").append(lastOutgoingPositionTeleportId) + .append(",outgoingLoc=").append(formatOutgoingPositionLocation()) + .append(",outgoingToKnown=").append(NetDiagnostics.formatStoredDistance(lastOutgoingPositionTime, lastOutgoingPositionX, lastOutgoingPositionY, lastOutgoingPositionZ, knownLocation)) + .append(",outgoingToPacket=").append(NetDiagnostics.formatStoredDistance(lastOutgoingPositionTime, lastOutgoingPositionX, lastOutgoingPositionY, lastOutgoingPositionZ, packetLocation)) + .append(",serverJumpAge=").append(getServerPositionJumpGraceAge(now)) + .append(",serverJumpWithinGrace=").append(isWithinServerPositionJumpGrace(now, knownLocation, packetLocation)) + .append(",serverJumpStalePacketModel=").append(isWithinServerPositionJumpStalePacketModel(now, + knownLocation, packetLocation, packetData)) + .append(",serverJumpFrom=").append(formatServerJumpFrom()) + .append(",serverJumpTo=").append(formatServerJumpTo()) + .append(",serverJumpToKnown=").append(NetDiagnostics.formatStoredDistance(lastServerPositionJumpTime, lastServerPositionJumpToX, lastServerPositionJumpToY, lastServerPositionJumpToZ, knownLocation)) + .append(",serverJumpFromPacket=").append(NetDiagnostics.formatStoredDistance(lastServerPositionJumpTime, lastServerPositionJumpFromX, lastServerPositionJumpFromY, lastServerPositionJumpFromZ, packetLocation)) + .append(",commandAge=").append(getTeleportCommandGraceAge(now)) + .append(",commandWithinGrace=").append(isWithinTeleportCommandGrace(now, knownLocation, packetLocation)) + .append(",commandStalePacketModel=").append(isWithinTeleportCommandStalePacketModel(now, + knownLocation, packetLocation, packetData)) + .append(",command=").append(lastTeleportCommand) + .append(",commandLoc=").append(formatTeleportCommandLocation()) + .append(",commandToKnown=").append(NetDiagnostics.formatStoredDistance(lastTeleportCommandTime, lastTeleportCommandX, lastTeleportCommandY, lastTeleportCommandZ, knownLocation)) + .append(",commandToPacket=").append(NetDiagnostics.formatStoredDistance(lastTeleportCommandTime, lastTeleportCommandX, lastTeleportCommandY, lastTeleportCommandZ, packetLocation)) + .append(",resync=").append(describeTeleportResyncState(now, knownLocation)) + .append(",queue=").append(teleportQueue.getDebugState(now)) + .toString(); + } + + public String describeKeepAliveState(final long now) { + return NetDiagnostics.formatKeepAliveState(this, now); + } + + private static final class KeepAliveRequest { + private final long time; + private final long id; + + private KeepAliveRequest(final long time, final long id) { + this.time = time; + this.id = id; + } + } + + private void resetMovingTeleportDiagnostics() { + lastMovingTeleportTime = 0L; + lastMovingTeleportWorld = null; + lastMovingTeleportCause = "none"; + lastOutgoingPositionTime = 0L; + lastOutgoingPositionTracked = false; + lastOutgoingPositionTeleportId = Integer.MIN_VALUE; + lastMovingKnownLocationTime = 0L; + lastMovingKnownLocationWorld = null; + lastServerPositionJumpTime = 0L; + lastServerPositionJumpFromWorld = null; + lastServerPositionJumpToWorld = null; + lastServerPositionJumpRecoveryUsedTime = 0L; + lastTeleportCommandTime = 0L; + lastTeleportCommand = "none"; + lastTeleportCommandWorld = null; + lastTeleportResyncMovingDataKey = 0L; + lastTeleportResyncRequestTime = 0L; + lastTeleportResyncAppliedKey = 0L; + lastTeleportResyncReason = "none"; + lastTeleportResyncWorld = null; + lastTeleportResyncDroppedPacketLogTime = 0L; + lastTeleportResyncDroppedPacketCount = 0; + lastTeleportResyncPendingDropLogCount = 0; + } + + private String describeTeleportResyncState(final long now, final Location knownLocation) { + return NetDiagnostics.formatTeleportResyncState(lastTeleportResyncMovingDataKey, + lastTeleportResyncRequestTime, lastTeleportResyncAppliedKey, lastTeleportResyncReason, + lastTeleportResyncX, lastTeleportResyncY, lastTeleportResyncZ, + lastTeleportResyncYaw, lastTeleportResyncPitch, + lastTeleportResyncDroppedPacketCount, knownLocation, now); + } + + private void logTeleportResyncStalePacketDrop(final Player player, final long now, + final Location knownLocation, + final Location packetLocation, + final DataPacketFlying packetData, + final String reason, + final String action, + final int queueSizeBeforeClear, + final boolean forceLog) { + lastTeleportResyncDroppedPacketCount++; + lastTeleportResyncPendingDropLogCount++; + if (player == null || !CheckUtils.shouldLogDebugToConsole()) { + return; + } + // Teleport model diagnostic: Folia/ProtocolLib can deliver a short pair of old-location packets after teleport. + if (lastTeleportResyncDroppedPacketCount <= MOVING_TELEPORT_RESYNC_NORMAL_STALE_PACKET_LIMIT) { + return; + } + if (!forceLog && lastTeleportResyncDroppedPacketLogTime > 0L + && now - lastTeleportResyncDroppedPacketLogTime < 10000L) { + return; + } + final int loggedCount = lastTeleportResyncPendingDropLogCount; + lastTeleportResyncPendingDropLogCount = 0; + lastTeleportResyncDroppedPacketLogTime = now; + player.getServer().getLogger().info(NetDiagnostics.formatTeleportStaleDrop(player.getName(), + action, reason, lastTeleportResyncDroppedPacketCount, loggedCount, queueSizeBeforeClear, + lastTeleportResyncX, lastTeleportResyncY, lastTeleportResyncZ, + lastTeleportResyncYaw, lastTeleportResyncPitch, lastTeleportResyncRequestTime, + knownLocation, lastServerPositionJumpTime, lastServerPositionJumpFromX, + lastServerPositionJumpFromY, lastServerPositionJumpFromZ, lastTeleportCommandTime, + lastTeleportCommandX, lastTeleportCommandY, lastTeleportCommandZ, + packetLocation, packetData, now)); + } + + private boolean shouldLogTeleportResyncSuccessToConsole() { + return CheckUtils.shouldLogDebugToConsole() + && lastTeleportResyncDroppedPacketCount > MOVING_TELEPORT_RESYNC_NORMAL_STALE_PACKET_LIMIT; + } + + private int getFlyingQueueSize() { + lock.lock(); + try { + return flyingQueue.size(); + } + finally { + lock.unlock(); + } + } + + private boolean matchesPosition(final double x, final double y, final double z, final Location loc) { + return loc != null + && Math.abs(x - loc.getX()) <= MOVING_POSITION_MATCH_EPSILON + && Math.abs(y - loc.getY()) <= MOVING_POSITION_MATCH_EPSILON + && Math.abs(z - loc.getZ()) <= MOVING_POSITION_MATCH_EPSILON; + } + + private String formatTeleportEventLocation() { + return lastMovingTeleportTime <= 0L ? "none" + : new StringBuilder(100).append(lastMovingTeleportWorld).append('@') + .append(NetDiagnostics.formatStoredLocation(lastMovingTeleportX, lastMovingTeleportY, lastMovingTeleportZ, lastMovingTeleportYaw, lastMovingTeleportPitch)) + .toString(); + } + + private String formatOutgoingPositionLocation() { + return lastOutgoingPositionTime <= 0L ? "none" + : NetDiagnostics.formatStoredLocation(lastOutgoingPositionX, lastOutgoingPositionY, lastOutgoingPositionZ, lastOutgoingPositionYaw, lastOutgoingPositionPitch); + } + + private String formatServerJumpFrom() { + return lastServerPositionJumpTime <= 0L ? "none" + : new StringBuilder(100).append(lastServerPositionJumpFromWorld).append('@') + .append(NetDiagnostics.formatStoredLocation(lastServerPositionJumpFromX, lastServerPositionJumpFromY, lastServerPositionJumpFromZ, + lastServerPositionJumpFromYaw, lastServerPositionJumpFromPitch)) + .toString(); + } + + private String formatServerJumpTo() { + return lastServerPositionJumpTime <= 0L ? "none" + : new StringBuilder(100).append(lastServerPositionJumpToWorld).append('@') + .append(NetDiagnostics.formatStoredLocation(lastServerPositionJumpToX, lastServerPositionJumpToY, lastServerPositionJumpToZ, + lastServerPositionJumpToYaw, lastServerPositionJumpToPitch)) + .toString(); + } + + private String formatTeleportCommandLocation() { + return lastTeleportCommandTime <= 0L ? "none" + : new StringBuilder(100).append(lastTeleportCommandWorld).append('@') + .append(NetDiagnostics.formatStoredLocation(lastTeleportCommandX, lastTeleportCommandY, lastTeleportCommandZ, + lastTeleportCommandYaw, lastTeleportCommandPitch)) + .toString(); + } + + private double distanceToStored(final double x, final double y, final double z, final Location loc) { + return loc == null ? Double.MAX_VALUE : distance(x, y, z, loc.getX(), loc.getY(), loc.getZ()); + } + + private double horizontalDistance(final double x1, final double z1, final double x2, final double z2) { + final double xDiff = x2 - x1; + final double zDiff = z2 - z1; + return Math.sqrt(xDiff * xDiff + zDiff * zDiff); + } + + private double distance(final double x1, final double y1, final double z1, + final double x2, final double y2, final double z2) { + final double xDiff = x2 - x1; + final double yDiff = y2 - y1; + final double zDiff = z2 - z1; + return Math.sqrt(xDiff * xDiff + yDiff * yDiff + zDiff * zDiff); } /** @@ -146,6 +976,7 @@ public void requestSetBack(final Player player, final IDebugPlayer idp, final Pl final Location knownLocation = player.getLocation(); final MovingData mData = pData.getGenericInstance(MovingData.class); Object task = null; + // Folia compatibility: packet-level set backs must run on the player's owning region. task = SchedulerHelper.runSyncTaskForEntity(player, plugin, (arg) -> { /** Get the first set-back location that might be available */ final Location newTo = mData.hasSetBack() ? mData.getSetBack(knownLocation) : diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/NetDiagnostics.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/NetDiagnostics.java new file mode 100644 index 0000000000..9c37bd39c0 --- /dev/null +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/NetDiagnostics.java @@ -0,0 +1,178 @@ +/* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package fr.neatmonster.nocheatplus.checks.net; + +import org.bukkit.Location; + +import fr.neatmonster.nocheatplus.checks.net.model.DataPacketFlying; +import fr.neatmonster.nocheatplus.utilities.StringUtil; + +/** + * Console-only formatting for net packet diagnostics. + */ +final class NetDiagnostics { + + private NetDiagnostics() {} + + static String formatKeepAliveState(final NetData data, final long now) { + final DataPacketFlying latest = data.getCurrentFlyingPacket(); + return new StringBuilder(500) + .append("packetTime=").append(data.keepAlivePacketTime) + .append(",packetAge=").append(data.keepAlivePacketTime <= 0L ? -1L : now - data.keepAlivePacketTime) + .append(",previousPacketAge=").append(data.keepAlivePreviousPacketTime <= 0L ? -1L : now - data.keepAlivePreviousPacketTime) + .append(",delta=").append(data.keepAlivePacketDelta) + .append(",idAvailable=").append(data.keepAlivePacketIdAvailable) + .append(",idType=").append(data.keepAlivePacketIdType) + .append(",id=").append(data.keepAlivePacketIdAvailable ? Long.toString(data.keepAlivePacketId) : "none") + .append(",previousId=").append(data.keepAlivePreviousPacketIdAvailable ? Long.toString(data.keepAlivePreviousPacketId) : "none") + .append(",duplicateId=").append(data.keepAliveDuplicateId) + .append(",expectedResponse=").append(data.keepAliveExpectedResponse) + .append(",expectedAge=").append(data.keepAliveExpectedResponseAge) + .append(",outgoingSeen=").append(data.keepAliveOutgoingSeen) + .append(",outgoingAge=").append(data.keepAliveOutgoingPacketTime <= 0L ? -1L : now - data.keepAliveOutgoingPacketTime) + .append(",outgoingId=").append(data.keepAliveOutgoingPacketIdAvailable ? Long.toString(data.keepAliveOutgoingPacketId) : "none") + .append(",pendingOutgoing=").append(data.keepAlivePendingRequests) + .append(",fieldCounts=long:").append(data.keepAlivePacketLongCount).append("/int:").append(data.keepAlivePacketIntCount) + .append(",outgoingFieldCounts=long:").append(data.keepAliveOutgoingPacketLongCount).append("/int:").append(data.keepAliveOutgoingPacketIntCount) + .append(",async=").append(data.keepAlivePacketAsync) + .append(",thread=").append(data.keepAlivePacketThread) + .append(",outgoingAsync=").append(data.keepAliveOutgoingPacketAsync) + .append(",outgoingThread=").append(data.keepAliveOutgoingPacketThread) + .append(",lastSharedKeepAliveAge=").append(data.lastKeepAliveTime <= 0L ? -1L : now - data.lastKeepAliveTime) + .append(",teleportAges=event:").append(data.getMovingTeleportGraceAge(now)) + .append(",outgoing:").append(data.getOutgoingPositionGraceAge(now)) + .append(",serverJump:").append(data.getServerPositionJumpGraceAge(now)) + .append(",command:").append(data.getTeleportCommandGraceAge(now)) + .append(",latestFlying=").append(formatLatestFlying(latest, now)) + .append(",matchedMoveSeq=").append(data.getLastMatchedMoveToSequence()) + .append(",teleportQueue=").append(data.teleportQueue.getDebugState(now)) + .toString(); + } + + static String formatTeleportResyncState(final long movingDataKey, final long requestTime, + final long appliedKey, final String reason, + final double x, final double y, final double z, + final float yaw, final float pitch, + final int droppedPackets, final Location knownLocation, + final long now) { + if (movingDataKey <= 0L) { + return "none"; + } + return new StringBuilder(150) + .append("{key=").append(movingDataKey) + .append(",age=").append(requestTime <= 0L ? -1L : now - requestTime) + .append(",applied=").append(appliedKey == movingDataKey) + .append(",reason=").append(reason) + .append(",target=").append(formatStoredLocation(x, y, z, yaw, pitch)) + .append(",targetToKnown=").append(formatStoredDistance(requestTime, x, y, z, knownLocation)) + .append(",droppedPackets=").append(droppedPackets) + .append('}') + .toString(); + } + + static String formatTeleportResyncApplied(final String playerName, final String action, final String reason, + final double x, final double y, final double z, + final float yaw, final float pitch) { + return new StringBuilder(160) + .append("[NCP][NetMoving][resync] player=").append(playerName) + .append(" action=").append(action) + .append(" reason=").append(reason == null ? "unknown" : reason) + .append(" target=").append(formatStoredLocation(x, y, z, yaw, pitch)) + .toString(); + } + + static String formatTeleportStaleDrop(final String playerName, final String action, final String reason, + final int droppedPackets, final int loggedCount, + final int queueSizeBeforeClear, + final double targetX, final double targetY, final double targetZ, + final float targetYaw, final float targetPitch, + final long targetTime, final Location knownLocation, + final long serverJumpTime, final double serverJumpFromX, + final double serverJumpFromY, final double serverJumpFromZ, + final long commandTime, final double commandX, + final double commandY, final double commandZ, + final Location packetLocation, final DataPacketFlying packetData, + final long now) { + return new StringBuilder(420) + .append("[NCP][NetMoving][stale-drop] player=").append(playerName) + .append(" action=").append(action) + .append(" reason=").append(reason == null ? "unknown" : reason) + .append(" droppedPackets=").append(droppedPackets) + .append(" loggedCount=").append(loggedCount) + .append(" queueSizeBeforeClear=").append(queueSizeBeforeClear) + .append(" target=").append(formatStoredLocation(targetX, targetY, targetZ, targetYaw, targetPitch)) + .append(" targetToKnown=").append(formatStoredDistance(targetTime, targetX, targetY, targetZ, knownLocation)) + .append(" packetToServerJumpFrom=").append(formatStoredDistance(serverJumpTime, + serverJumpFromX, serverJumpFromY, serverJumpFromZ, packetLocation)) + .append(" packetToCommand=").append(formatStoredDistance(commandTime, commandX, commandY, commandZ, packetLocation)) + .append(" packet=").append(formatPacket(packetData, now)) + .toString(); + } + + static String formatStoredLocation(final double x, final double y, final double z, final float yaw, final float pitch) { + return new StringBuilder(90) + .append("x=").append(StringUtil.fdec3.format(x)) + .append(",y=").append(StringUtil.fdec3.format(y)) + .append(",z=").append(StringUtil.fdec3.format(z)) + .append(",yaw=").append(StringUtil.fdec3.format(yaw)) + .append(",pitch=").append(StringUtil.fdec3.format(pitch)) + .toString(); + } + + static String formatStoredDistance(final long storedTime, final double x, final double y, final double z, final Location loc) { + if (storedTime <= 0L || loc == null) { + return "none"; + } + final double xDiff = loc.getX() - x; + final double yDiff = loc.getY() - y; + final double zDiff = loc.getZ() - z; + return new StringBuilder(80) + .append("h=").append(StringUtil.fdec3.format(Math.sqrt(xDiff * xDiff + zDiff * zDiff))) + .append("/y=").append(StringUtil.fdec3.format(Math.abs(yDiff))) + .append("/total=").append(StringUtil.fdec3.format(Math.sqrt(xDiff * xDiff + yDiff * yDiff + zDiff * zDiff))) + .toString(); + } + + static String formatPacket(final DataPacketFlying packetData, final long now) { + if (packetData == null) { + return "none"; + } + return new StringBuilder(120) + .append("seq=").append(packetData.getSequence()) + .append(",age=").append(now - packetData.time) + .append(",type=").append(packetData.getSimplifiedContentType()) + .append(",tracked=").append(packetData.isTracked()) + .append(",ground=").append(packetData.onGround) + .append(",hCollision=").append(packetData.horizontalCollision) + .append(",hasPos=").append(packetData.hasPos) + .toString(); + } + + private static String formatLatestFlying(final DataPacketFlying latest, final long now) { + if (latest == null) { + return "none"; + } + return new StringBuilder(160) + .append("seq=").append(latest.getSequence()) + .append(",age=").append(now - latest.time) + .append(",tracked=").append(latest.isTracked()) + .append(",type=").append(latest.getSimplifiedContentType()) + .append(",ground=").append(latest.onGround) + .append(",hcollide=").append(latest.horizontalCollision) + .append(",hasPos=").append(latest.hasPos) + .append(",hasLook=").append(latest.hasLook) + .toString(); + } +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/model/TeleportQueue.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/model/TeleportQueue.java index c75462762d..fd6c19ea40 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/model/TeleportQueue.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/checks/net/model/TeleportQueue.java @@ -102,6 +102,34 @@ public AckReference getLastAckReference() { return lastAckReference; } + /** + * Compact diagnostic snapshot for NET_MOVING false-positive analysis. + */ + public String getDebugState(final long now) { + final StringBuilder builder = new StringBuilder(300); + lock.lock(); + builder.append("expectOutgoing=").append(expectOutgoing == null ? "none" : expectOutgoing.toString()); + builder.append(",expectIncomingSize=").append(expectIncoming.size()); + if (!expectIncoming.isEmpty()) { + builder.append(",incomingFirst=").append(formatCountable(expectIncoming.getFirst(), now)); + builder.append(",incomingLast=").append(formatCountable(expectIncoming.getLast(), now)); + } + builder.append(",lastAck=").append(lastAck == null ? "none" : formatCountable(lastAck, now)); + builder.append(",ackRef=lastOutgoing:").append(lastAckReference.lastOutgoingId) + .append("/maxConfirmed:").append(lastAckReference.maxConfirmedId); + builder.append(",maxAge=").append(maxAge); + lock.unlock(); + return builder.toString(); + } + + private String formatCountable(final CountableLocation ref, final long now) { + final StringBuilder builder = new StringBuilder(160); + builder.append(ref.toString()); + builder.append(",age="); + builder.append(now >= ref.time ? now - ref.time : -(ref.time - now)); + return builder.toString(); + } + /** * Call for Bukkit events (expect this packet to be sent).
    * TODO: The method name is misleading, as this also should be called with @@ -245,6 +273,23 @@ public AlmostBoolean processAck(final int teleportId) { return ackState; } + /** + * Accept a movement packet that matches the teleport location from a Bukkit + * event before ProtocolLib has observed the matching outgoing position. + */ + public boolean consumeExpectedOutgoingPosition(final DataPacketFlying packetData) { + if (packetData == null || !packetData.hasPos) { + return false; + } + lock.lock(); + final boolean match = expectOutgoing != null && expectOutgoing.isSamePos(packetData); + if (match) { + expectOutgoing = null; + } + lock.unlock(); + return match; + } + /** * Test if the move is an ACK move (or no ACK is expected), adjust internals * if necessary. diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/compat/SchedulerHelper.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/compat/SchedulerHelper.java index bdb2c197b7..512b5233e8 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/compat/SchedulerHelper.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/compat/SchedulerHelper.java @@ -22,6 +22,7 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Server; +import org.bukkit.World; import org.bukkit.entity.Entity; import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause; import org.bukkit.plugin.Plugin; @@ -31,6 +32,7 @@ /** * Utility class to provide compatibility with Paper's regionized, multi-threaded server implementation (a.k.a.: Folia), using reflection. * If the server is not running Folia, use Bukkit's scheduler. + * Keep Folia-specific calls here so the rest of NCP can stay source-compatible with regular Bukkit/Paper APIs. */ public class SchedulerHelper { @@ -39,6 +41,11 @@ public class SchedulerHelper { private static final Class GlobalRegionScheduler = ReflectionUtil.getClass("io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler"); private static final Class EntityScheduler = ReflectionUtil.getClass("io.papermc.paper.threadedregions.scheduler.EntityScheduler"); private static final boolean isFoliaServer = RegionizedServer && GlobalRegionScheduler != null && EntityScheduler != null; // && AsyncScheduler != null + private static final Method Bukkit_isOwnedByCurrentRegionLocation = ReflectionUtil.getMethod(Bukkit.class, "isOwnedByCurrentRegion", Location.class); + private static final Method Bukkit_isOwnedByCurrentRegionLocationRadius = ReflectionUtil.getMethod(Bukkit.class, "isOwnedByCurrentRegion", Location.class, int.class); + private static final Method Bukkit_isOwnedByCurrentRegionWorldChunk = ReflectionUtil.getMethod(Bukkit.class, "isOwnedByCurrentRegion", World.class, int.class, int.class); + private static final Method Bukkit_isOwnedByCurrentRegionWorldChunkRadius = ReflectionUtil.getMethod(Bukkit.class, "isOwnedByCurrentRegion", World.class, int.class, int.class, int.class); + private static final Method Bukkit_isOwnedByCurrentRegionEntity = ReflectionUtil.getMethod(Bukkit.class, "isOwnedByCurrentRegion", Entity.class); /** * @return Whether the server is running Folia @@ -46,6 +53,107 @@ public class SchedulerHelper { public static boolean isFoliaServer() { return isFoliaServer; } + + /** + * Check whether the current Folia region owns a Bukkit location before + * touching world/block state. Non-Folia servers keep the old behavior. + * + * @param location The block/world location to check. + * @return True if the location is safe to access on the current thread. + */ + public static boolean isOwnedByCurrentRegion(final Location location) { + return isOwnedByCurrentRegion(location, 0); + } + + /** + * Check whether the current Folia region owns a location and a square chunk + * radius around it. Use this before code that scans neighboring blocks. + * + * @param location The block/world location to check. + * @param squareRadiusChunks Radius in chunks, not squared distance. + * @return True if the area is safe to access on the current thread. + */ + public static boolean isOwnedByCurrentRegion(final Location location, final int squareRadiusChunks) { + if (!isFoliaServer) { + return true; + } + if (location == null || location.getWorld() == null) { + return false; + } + if (squareRadiusChunks > 0 && Bukkit_isOwnedByCurrentRegionLocationRadius != null) { + return invokeOwnershipCheck(Bukkit_isOwnedByCurrentRegionLocationRadius, location, squareRadiusChunks); + } + if (squareRadiusChunks == 0 && Bukkit_isOwnedByCurrentRegionLocation != null) { + return invokeOwnershipCheck(Bukkit_isOwnedByCurrentRegionLocation, location); + } + return isOwnedByCurrentRegion(location.getWorld(), location.getBlockX(), location.getBlockZ(), squareRadiusChunks); + } + + /** + * Check whether the current Folia region owns the chunk containing a block. + * + * @param world The world to check. + * @param blockX The block x coordinate. + * @param blockZ The block z coordinate. + * @return True if the block is safe to access on the current thread. + */ + public static boolean isOwnedByCurrentRegion(final World world, final int blockX, final int blockZ) { + return isOwnedByCurrentRegion(world, blockX, blockZ, 0); + } + + /** + * Check whether the current Folia region owns the chunk containing a block, + * plus an optional square chunk radius. + * + * @param world The world to check. + * @param blockX The block x coordinate. + * @param blockZ The block z coordinate. + * @param squareRadiusChunks Radius in chunks, not squared distance. + * @return True if the block area is safe to access on the current thread. + */ + public static boolean isOwnedByCurrentRegion(final World world, final int blockX, final int blockZ, final int squareRadiusChunks) { + if (!isFoliaServer) { + return true; + } + if (world == null) { + return false; + } + final int chunkX = blockX >> 4; + final int chunkZ = blockZ >> 4; + if (squareRadiusChunks > 0 && Bukkit_isOwnedByCurrentRegionWorldChunkRadius != null) { + return invokeOwnershipCheck(Bukkit_isOwnedByCurrentRegionWorldChunkRadius, world, chunkX, chunkZ, squareRadiusChunks); + } + if (Bukkit_isOwnedByCurrentRegionWorldChunk != null) { + return invokeOwnershipCheck(Bukkit_isOwnedByCurrentRegionWorldChunk, world, chunkX, chunkZ); + } + return false; + } + + /** + * Check whether the current Folia region owns an entity before reading + * entity state or nearby entities. + * + * @param entity The entity to check. + * @return True if the entity is safe to access on the current thread. + */ + public static boolean isOwnedByCurrentRegion(final Entity entity) { + if (!isFoliaServer) { + return true; + } + if (entity == null || Bukkit_isOwnedByCurrentRegionEntity == null) { + return false; + } + return invokeOwnershipCheck(Bukkit_isOwnedByCurrentRegionEntity, entity); + } + + private static boolean invokeOwnershipCheck(final Method method, final Object... arguments) { + try { + return Boolean.TRUE.equals(method.invoke(null, arguments)); + } + catch (Throwable t) { + return false; + } + } /** * Executes an asynchronous task, either using Bukkit's scheduler or Java's thread execution if the server is running Folia. @@ -58,6 +166,7 @@ public static Object runTaskAsync(Plugin plugin, Consumer run) { if (!isFoliaServer) { return Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> run.accept(null)).getTaskId(); } + // Folia async scheduler signatures have moved across builds; these callers already do async-safe work. //try { // Method getSchedulerMethod = ReflectionUtil.getMethodNoArgs(Server.class, "getAsyncScheduler", AsyncScheduler); // Object asyncScheduler = getSchedulerMethod.invoke(Bukkit.getServer()); @@ -71,8 +180,7 @@ public static Object runTaskAsync(Plugin plugin, Consumer run) { //catch (Exception e) { // Second attempt, should be happening during onDisable calling from BukkitLogNodeDispatcher Thread thread = Executors.defaultThreadFactory().newThread(() -> run.accept(null)); - if (thread == null) return null; - thread.run(); + thread.start(); return thread; //} } diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BridgeHealth.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BridgeHealth.java index 4c276ebdfa..15b3a81e4b 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BridgeHealth.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/compat/bukkit/BridgeHealth.java @@ -41,8 +41,8 @@ * @author asofold * */ -@SuppressWarnings("deprecation") -public class BridgeHealth { +@SuppressWarnings({"deprecation", "removal"}) +public class BridgeHealth { // TODO: Move to (smaller?) IGenericInstanceHandle instances. @@ -326,11 +326,11 @@ public static void damage(final LivingEntity entity, } } - public static EntityDamageEvent getEntityDamageEvent(final Entity entity, - final DamageCause damageCause, final double damage) { - try{ - return new EntityDamageEvent(entity, damageCause, damage); - } + public static EntityDamageEvent getEntityDamageEvent(final Entity entity, + final DamageCause damageCause, final double damage) { + try{ + return new EntityDamageEvent(entity, damageCause, damage); + } catch(IncompatibleClassChangeError e) { Object o = ReflectionUtil.newInstance( ReflectionUtil.getConstructor(EntityDamageEvent.class, Entity.class, DamageCause.class, int.class), @@ -339,11 +339,15 @@ public static EntityDamageEvent getEntityDamageEvent(final Entity entity, if (o instanceof EntityDamageEvent) { return (EntityDamageEvent)o; } - return null; - } - } - - /** + return null; + } + } + + public static void setLastDamageCause(final Entity entity, final EntityDamageEvent event) { + entity.setLastDamageCause(event); + } + + /** * Intended for faster compatibility methods for defined scenarios. * Transforms any exception to a RuntimeException. * diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/components/registry/DefaultGenericInstanceRegistry.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/components/registry/DefaultGenericInstanceRegistry.java index b0f00113f0..dacce10647 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/components/registry/DefaultGenericInstanceRegistry.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/components/registry/DefaultGenericInstanceRegistry.java @@ -242,31 +242,36 @@ private Registration getRegistration(Class registeredFor, boolean crea @SuppressWarnings("unchecked") private Registration createEmptyRegistration(Class registeredFor) { lock.lock(); - Registration registration = (Registration) registrations.get(registeredFor); // Re-check. - if (registration == null) { - try { + try { + Registration registration = (Registration) registrations.get(registeredFor); // Re-check. + if (registration == null) { // TODO: Consider individual locks / configuration for it. registration = new Registration(registeredFor, null, this, this, lock); this.registrations.put(registeredFor, registration); } - catch (Throwable t) { - lock.unlock(); - throw new IllegalArgumentException(t); // Might document. - } + return registration; + } + catch (Throwable t) { + throw new IllegalArgumentException(t); // Might document. + } + finally { + lock.unlock(); } - lock.unlock(); - return registration; } @Override public void unregisterGenericInstanceRegistryListener(Class registeredFor, IGenericInstanceRegistryListener listener) { lock.lock(); - // (Include getRegistration, as no object creation is involved.) - Registration registration = getRegistration(registeredFor, false); - if (registration != null) { - registration.unregisterListener(listener); + try { + // (Include getRegistration, as no object creation is involved.) + Registration registration = getRegistration(registeredFor, false); + if (registration != null) { + registration.unregisterListener(listener); + } + } + finally { + lock.unlock(); } - lock.lock(); } @SuppressWarnings("unchecked") diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/config/ConfPaths.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/config/ConfPaths.java index c6ec1421c0..666a62a0aa 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/config/ConfPaths.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/config/ConfPaths.java @@ -80,10 +80,13 @@ public abstract class ConfPaths { @GlobalConfig private static final String LOGGING = "logging."; - public static final String LOGGING_ACTIVE = LOGGING + SUB_ACTIVE; - public static final String LOGGING_MAXQUEUESIZE = LOGGING + "max-queue-size"; - - private static final String LOGGING_BACKEND = LOGGING + "backend."; + public static final String LOGGING_ACTIVE = LOGGING + SUB_ACTIVE; + public static final String LOGGING_MAXQUEUESIZE = LOGGING + "max-queue-size"; + private static final String LOGGING_DEBUG_SECTION = LOGGING + "debug."; + /** Toggle for custom verbose diagnostic lines such as [NCP][SurvivalFly][detail]. */ + public static final String LOGGING_DEBUG_TO_CONSOLE = LOGGING_DEBUG_SECTION + "to-console"; + + private static final String LOGGING_BACKEND = LOGGING + "backend."; private static final String LOGGING_BACKEND_CONSOLE = LOGGING_BACKEND + "console."; public static final String LOGGING_BACKEND_CONSOLE_ACTIVE = LOGGING_BACKEND_CONSOLE + SUB_ACTIVE; public static final String LOGGING_BACKEND_CONSOLE_ASYNCHRONOUS = LOGGING_BACKEND_CONSOLE + "asynchronous"; @@ -625,11 +628,13 @@ public abstract class ConfPaths { private static final String MOVING_SURVIVALFLY = MOVING + "survivalfly."; public static final String MOVING_SURVIVALFLY_CHECK = MOVING_SURVIVALFLY + SUB_ACTIVE; - public static final String MOVING_SURVIVALFLY_STEPHEIGHT = MOVING_SURVIVALFLY + "stepheight"; - private static final String MOVING_SURVIVALFLY_EXTENDED = MOVING_SURVIVALFLY + "extended."; - public static final String MOVING_SURVIVALFLY_EXTENDED_RESETITEM = MOVING_SURVIVALFLY_EXTENDED + "reset-activeitem"; - public static final String MOVING_SURVIVALFLY_EXTENDED_STRICT_HORIZONTAL_PREDICTION = MOVING_SURVIVALFLY_EXTENDED + "strict-speed-prediction"; - private static final String MOVING_SURVIVALFLY_LENIENCY = MOVING_SURVIVALFLY + "leniency."; + public static final String MOVING_SURVIVALFLY_STEPHEIGHT = MOVING_SURVIVALFLY + "stepheight"; + private static final String MOVING_SURVIVALFLY_EXTENDED = MOVING_SURVIVALFLY + "extended."; + public static final String MOVING_SURVIVALFLY_EXTENDED_RESETITEM = MOVING_SURVIVALFLY_EXTENDED + "reset-activeitem"; + public static final String MOVING_SURVIVALFLY_EXTENDED_STRICT_HORIZONTAL_PREDICTION = MOVING_SURVIVALFLY_EXTENDED + "strict-speed-prediction"; + private static final String MOVING_SURVIVALFLY_ELYTRA = MOVING_SURVIVALFLY + "elytra."; + public static final String MOVING_SURVIVALFLY_ELYTRA_ENFORCE = MOVING_SURVIVALFLY_ELYTRA + "enforce"; + private static final String MOVING_SURVIVALFLY_LENIENCY = MOVING_SURVIVALFLY + "leniency."; public static final String MOVING_SURVIVALFLY_LENIENCY_FREEZECOUNT = MOVING_SURVIVALFLY_LENIENCY + "freeze-count"; public static final String MOVING_SURVIVALFLY_LENIENCY_FREEZEINAIR = MOVING_SURVIVALFLY_LENIENCY + "freeze-inair"; private static final String MOVING_SURVIVALFLY_SETBACKPOLICY = MOVING_SURVIVALFLY + "setback-policy."; @@ -643,10 +648,11 @@ public abstract class ConfPaths { public static final String MOVING_SURVIVALFLY_VLFREQUENCY_LASTVIOLATEDMOVECOUNT = MOVING_SURVIVALFLY_VLFREQUENCY + "last-violated-move-count"; public static final String MOVING_SURVIVALFLY_VLFREQUENCY_AMOUNTTOADD = MOVING_SURVIVALFLY_VLFREQUENCY + "amount-to-add"; - @GlobalConfig - public static final String MOVING_SURVIVALFLY_HOVER = MOVING_SURVIVALFLY + "hover."; - public static final String MOVING_SURVIVALFLY_HOVER_CHECK = MOVING_SURVIVALFLY_HOVER + SUB_ACTIVE; - public static final String MOVING_SURVIVALFLY_HOVER_STEP = MOVING_SURVIVALFLY_HOVER + "step"; + @GlobalConfig + public static final String MOVING_SURVIVALFLY_HOVER = MOVING_SURVIVALFLY + "hover."; + public static final String MOVING_SURVIVALFLY_HOVER_CHECK = MOVING_SURVIVALFLY_HOVER + SUB_ACTIVE; + public static final String MOVING_SURVIVALFLY_HOVER_ELYTRA_ENFORCE = MOVING_SURVIVALFLY_HOVER + "elytra-enforce"; + public static final String MOVING_SURVIVALFLY_HOVER_STEP = MOVING_SURVIVALFLY_HOVER + "step"; public static final String MOVING_SURVIVALFLY_HOVER_TICKS = MOVING_SURVIVALFLY_HOVER + "ticks"; public static final String MOVING_SURVIVALFLY_HOVER_LOGINTICKS = MOVING_SURVIVALFLY_HOVER + "login-ticks"; public static final String MOVING_SURVIVALFLY_HOVER_FALLDAMAGE = MOVING_SURVIVALFLY_HOVER + "fall-damage"; @@ -1059,4 +1065,4 @@ public static Collection getExtraMovedPaths() { return entries; } -} \ No newline at end of file +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/config/DefaultConfig.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/config/DefaultConfig.java index 28510ab35f..01958c51a6 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/config/DefaultConfig.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/config/DefaultConfig.java @@ -50,13 +50,16 @@ public DefaultConfig() { // not set(ConfPaths.CONFIGVERSION_SAVED, -1); set(ConfPaths.LOGGING_ACTIVE, true, 154); set(ConfPaths.LOGGING_MAXQUEUESIZE, 5000, 154); + // Compatibility diagnostics are opt-in because the extra movement/Folia details are too noisy for normal servers. + set(ConfPaths.LOGGING_DEBUG_TO_CONSOLE, false, 154); set(ConfPaths.LOGGING_EXTENDED_STATUS, false, 154); set(ConfPaths.LOGGING_EXTENDED_COMMANDS_ACTIONS, false, 1090); set(ConfPaths.LOGGING_EXTENDED_ALLVIOLATIONS_DEBUG, true, 154); set(ConfPaths.LOGGING_EXTENDED_ALLVIOLATIONS_DEBUGONLY, false, 154); set(ConfPaths.LOGGING_EXTENDED_ALLVIOLATIONS_BACKEND_TRACE, false, 154); set(ConfPaths.LOGGING_EXTENDED_ALLVIOLATIONS_BACKEND_NOTIFY, false, 154); - set(ConfPaths.LOGGING_BACKEND_CONSOLE_ACTIVE, true, 154); + // Default console backend off: server owners can opt in to console logging when debugging false positives. + set(ConfPaths.LOGGING_BACKEND_CONSOLE_ACTIVE, false, 154); set(ConfPaths.LOGGING_BACKEND_CONSOLE_ASYNCHRONOUS, true, 154); set(ConfPaths.LOGGING_BACKEND_FILE_ACTIVE, true, 154); set(ConfPaths.LOGGING_BACKEND_FILE_PREFIX, "", 154); @@ -121,7 +124,8 @@ public DefaultConfig() { set(ConfPaths.BLOCKBREAK_FASTBREAK_STRICT, true, 154); set(ConfPaths.BLOCKBREAK_FASTBREAK_DELAY, 10, 154); set(ConfPaths.BLOCKBREAK_FASTBREAK_MOD_SURVIVAL, 100, 154); - set(ConfPaths.BLOCKBREAK_FASTBREAK_GRACE, 500, 154); + // False-positive tuning: modern/Folia block-dig timing can need a larger grace, but keep it configurable. + set(ConfPaths.BLOCKBREAK_FASTBREAK_GRACE, 2000, 154); set(ConfPaths.BLOCKBREAK_FASTBREAK_ACTIONS, "cancel vl>5 cancel log:fastbreak:4:2:i vl>50 cancel log:fastbreak:0:2:if cmdc:kickfastbreak:2:5", 154); // Frequency set(ConfPaths.BLOCKBREAK_FREQUENCY_CHECK, "default", 154); @@ -476,10 +480,12 @@ public DefaultConfig() { set(ConfPaths.MOVING_PASSABLE_UNTRACKED_CMD_PREFIXES, Arrays.asList("sethome", "home set", "setwarp", "warp set", "setback", "set back", "back set", "warp", "home", "tp", "tpa", "tpask", "tpyes", "tphere"), 154); // SurvivalFly set(ConfPaths.MOVING_SURVIVALFLY_CHECK, "default", 154); - set(ConfPaths.MOVING_SURVIVALFLY_STEPHEIGHT, "default", 154); - set(ConfPaths.MOVING_SURVIVALFLY_EXTENDED_RESETITEM, true, 154); - set(ConfPaths.MOVING_SURVIVALFLY_EXTENDED_STRICT_HORIZONTAL_PREDICTION, true, 154); - // SurvivalFly - ViolationFrequencyHook + set(ConfPaths.MOVING_SURVIVALFLY_STEPHEIGHT, "default", 154); + set(ConfPaths.MOVING_SURVIVALFLY_EXTENDED_RESETITEM, true, 154); + set(ConfPaths.MOVING_SURVIVALFLY_EXTENDED_STRICT_HORIZONTAL_PREDICTION, true, 154); + // Elytra model collection: keep false while tuning from flightTrace/detail logs to avoid setback deaths. + set(ConfPaths.MOVING_SURVIVALFLY_ELYTRA_ENFORCE, false, 154); + // SurvivalFly - ViolationFrequencyHook set(ConfPaths.MOVING_SURVIVALFLY_VLFREQUENCY_ACTIVE, true, 154); set(ConfPaths.MOVING_SURVIVALFLY_VLFREQUENCY_DEBUG, false, 154); set(ConfPaths.MOVING_SURVIVALFLY_VLFREQUENCY_MAXTHRESHOLDVL, 35, 154); @@ -490,15 +496,17 @@ public DefaultConfig() { set(ConfPaths.MOVING_SURVIVALFLY_LENIENCY_FREEZECOUNT, 40, 1144); set(ConfPaths.MOVING_SURVIVALFLY_LENIENCY_FREEZEINAIR, true, 1143); set(ConfPaths.MOVING_SURVIVALFLY_SETBACKPOLICY_FALLDAMAGE, true, 154); - set(ConfPaths.MOVING_SURVIVALFLY_ACTIONS, "cancel log:flyfile:6:15:f" - + " vl>100 cancel log:survivalfly:10:11:i log:flyfile:6:15:f" - + " vl>700 cancel log:survivalfly:8:5:i log:flyfile:1:3:f" - + " vl>2100 cancel log:survivalflyhighvl:0:4:icf cmdc:kickfly:0:15", 154); - // SurvivalFly - Hover Subcheck - set(ConfPaths.MOVING_SURVIVALFLY_HOVER_CHECK, true, 154); // Not a check type yet. - set(ConfPaths.MOVING_SURVIVALFLY_HOVER_STEP, 5, 154); - set(ConfPaths.MOVING_SURVIVALFLY_HOVER_TICKS, 85, 154); - set(ConfPaths.MOVING_SURVIVALFLY_HOVER_LOGINTICKS, 60, 154); + set(ConfPaths.MOVING_SURVIVALFLY_ACTIONS, "cancel log:flyfile:6:15:f" + + " vl>100 cancel log:survivalfly:10:11:i log:flyfile:6:15:f" + + " vl>700 cancel log:survivalfly:8:5:i log:flyfile:1:3:f" + + " vl>2100 cancel log:survivalflyhighvl:0:4:icf cmdc:kickfly:0:15", 154); + // SurvivalFly - Hover Subcheck + set(ConfPaths.MOVING_SURVIVALFLY_HOVER_CHECK, true, 154); // Not a check type yet. + // Elytra hover model telemetry stays data-only by default until server owners opt into enforcement. + set(ConfPaths.MOVING_SURVIVALFLY_HOVER_ELYTRA_ENFORCE, false, 154); + set(ConfPaths.MOVING_SURVIVALFLY_HOVER_STEP, 5, 154); + set(ConfPaths.MOVING_SURVIVALFLY_HOVER_TICKS, 85, 154); + set(ConfPaths.MOVING_SURVIVALFLY_HOVER_LOGINTICKS, 60, 154); set(ConfPaths.MOVING_SURVIVALFLY_HOVER_FALLDAMAGE, true, 154); set(ConfPaths.MOVING_SURVIVALFLY_HOVER_SFVIOLATION, 200, 154); // Moving Trace - Lag compensator diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/permissions/Permissions.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/permissions/Permissions.java index 236e0c52cb..59923736ad 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/permissions/Permissions.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/permissions/Permissions.java @@ -58,6 +58,7 @@ private static final RegisteredPermission add(String stringRepresentation) { // Notifications (in-game). public static final RegisteredPermission NOTIFY = add(NOCHEATPLUS + ".notify"); + public static final RegisteredPermission COMPAT_BEDROCK = add(NOCHEATPLUS + ".compat.bedrock"); // Command permissions. public static final RegisteredPermission COMMAND = add(NOCHEATPLUS + ".command"); @@ -184,4 +185,4 @@ public static List getPermissions() { return new ArrayList(permissions.values()); } -} \ No newline at end of file +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/CheckUtils.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/CheckUtils.java index 6b6d636c28..107f9038d3 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/CheckUtils.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/CheckUtils.java @@ -26,11 +26,13 @@ import fr.neatmonster.nocheatplus.checks.combined.CombinedData; import fr.neatmonster.nocheatplus.checks.fight.FightData; import fr.neatmonster.nocheatplus.checks.inventory.InventoryData; -import fr.neatmonster.nocheatplus.checks.moving.MovingConfig; -import fr.neatmonster.nocheatplus.components.concurrent.IPrimaryThreadContextTester; -import fr.neatmonster.nocheatplus.logging.StaticLog; -import fr.neatmonster.nocheatplus.logging.Streams; -import fr.neatmonster.nocheatplus.players.IPlayerData; +import fr.neatmonster.nocheatplus.checks.moving.MovingConfig; +import fr.neatmonster.nocheatplus.components.concurrent.IPrimaryThreadContextTester; +import fr.neatmonster.nocheatplus.config.ConfigManager; +import fr.neatmonster.nocheatplus.config.ConfPaths; +import fr.neatmonster.nocheatplus.logging.StaticLog; +import fr.neatmonster.nocheatplus.logging.Streams; +import fr.neatmonster.nocheatplus.players.IPlayerData; /** @@ -38,9 +40,9 @@ * to get moved to other classes. All that is in here should be set up with * checks and not be related to early setup stages of the plugin. */ -public class CheckUtils { - - public static final IPrimaryThreadContextTester primaryServerThreadContextTester = new IPrimaryThreadContextTester() { +public class CheckUtils { + + public static final IPrimaryThreadContextTester primaryServerThreadContextTester = new IPrimaryThreadContextTester() { @Override public boolean isPrimaryThread() { @@ -68,17 +70,22 @@ public static void improperAsynchronousAPIAccess(final CheckType checkType) { * @param checkType * @return True if this is the primary thread. */ - public static boolean demandPrimaryThread(final CheckType checkType) { - if (Bukkit.isPrimaryThread()) { - return true; + public static boolean demandPrimaryThread(final CheckType checkType) { + if (Bukkit.isPrimaryThread()) { + return true; } else { improperAsynchronousAPIAccess(checkType); - return false; - } - } - - /** + return false; + } + } + + public static boolean shouldLogDebugToConsole() { + // This gates the extra diagnostic console spam added for compatibility tuning, not the normal NCP backend loggers. + return ConfigManager.getConfigFile().getBoolean(ConfPaths.LOGGING_DEBUG_TO_CONSOLE, false); + } + + /** * Kick and log. * * @param player diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/collision/CollisionUtil.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/collision/CollisionUtil.java index c4a1aaa72b..b4111b08b0 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/collision/CollisionUtil.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/collision/CollisionUtil.java @@ -1012,14 +1012,14 @@ public static boolean getCollisionBoxes(BlockCache blockCache, Entity entity, do continue; } // how many of the current block’s coordinates (x, y, z) lie on the edges of the search region defined by the entity’s AABB - //int edgeCount = ((x == minBlockX || x == maxBlockX) ? 1 : 0) + - // ((y == minBlockY || y == maxBlockY) ? 1 : 0) + - // ((z == minBlockZ || z == maxBlockZ) ? 1 : 0); - //if (edgeCount != 3 && (edgeCount != 1 || (BlockFlags.getBlockFlags(mat) & BlockFlags.F_HEIGHT150) != 0) - // && (edgeCount != 2 || mat == BridgeMaterial.MOVING_PISTON)) { + int edgeCount = ((x == minBlockX || x == maxBlockX) ? 1 : 0) + + ((y == minBlockY || y == maxBlockY) ? 1 : 0) + + ((z == minBlockZ || z == maxBlockZ) ? 1 : 0); + if (edgeCount != 3 && (edgeCount != 1 || (BlockFlags.getBlockFlags(mat) & BlockFlags.F_HEIGHT150) != 0) + && (edgeCount != 2 || mat == BridgeMaterial.MOVING_PISTON)) { // Don't add to a list if we only care if the player intersects with the block if (!onlyCheckCollide) { - final double[] originAABB = blockCache.getBounds(x, y, z); + final double[] originAABB = blockCache.fetchBounds(x, y, z); if (originAABB != null) { final double[] multiAABB = AxisAlignedBBUtils.move(originAABB, x, y, z); collisionBoxes.addAll(AxisAlignedBBUtils.splitIntoSingle(multiAABB)); @@ -1028,7 +1028,7 @@ public static boolean getCollisionBoxes(BlockCache blockCache, Entity entity, do else if (AxisAlignedBBUtils.isCollided(blockCache.getBounds(x, y, z), x, y, z, entityAABB, true)) { return true; } - //} + } } } } diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/PlayerLocation.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/PlayerLocation.java index 5400d66d8b..6d448c1ec3 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/PlayerLocation.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/PlayerLocation.java @@ -202,29 +202,28 @@ else if (zDistance > 0.0) { } } else { - final double signumX = Math.signum(xDistance) * 0.05D; - final double signumZ = Math.signum(zDistance) * 0.05D; - double[] offsetAABB_X = new double[]{minX + xDistance, minY + yBelow - 1.0E-5F, minZ, maxX + xDistance, minY, maxZ}; // Skip using maxY, as we do not care of the top of the box in this case. - while (xDistance != 0.0D && CollisionUtil.isEmpty(blockCache, player, offsetAABB_X)) { + double signumX = Math.signum(xDistance) * 0.05D; + double signumZ; + double[] offsetAABB_X = new double[]{minX + xDistance, minY + yBelow - 9.999999747378752E-6D, minZ, maxX + xDistance, minY, maxZ}; // Skip using maxY, as we do not care of the top of the box in this case. + for (signumZ = Math.signum(zDistance) * 0.05D; xDistance != 0.0D && CollisionUtil.isEmpty(blockCache, player, offsetAABB_X); xDistance -= signumX) { if (Math.abs(xDistance) <= 0.05D) { xDistance = 0.0D; break; } - xDistance -= signumX; - offsetAABB_X = new double[]{minX + xDistance, minY + yBelow - 1.0E-5F, minZ, maxX + xDistance, minY, maxZ}; + offsetAABB_X = new double[]{minX + xDistance, minY + yBelow - 9.999999747378752E-6D, minZ, maxX + xDistance, minY, maxZ}; } - double[] offsetAABB_Z = new double[]{minX, minY + yBelow - 1.0E-5F, minZ + zDistance, maxX, minY, maxZ + zDistance}; + double[] offsetAABB_Z = new double[]{minX, minY + yBelow - 9.999999747378752E-6D, minZ + zDistance, maxX, minY, maxZ + zDistance}; while (zDistance != 0.0D && CollisionUtil.isEmpty(blockCache, player, offsetAABB_Z)) { if (Math.abs(zDistance) <= 0.05D) { zDistance = 0.0D; break; } zDistance -= signumZ; - offsetAABB_Z = new double[]{minX, minY + yBelow - 1.0E-5F, minZ + zDistance, maxX, minY, maxZ + zDistance}; + offsetAABB_Z = new double[]{minX, minY + yBelow - 9.999999747378752E-6D, minZ + zDistance, maxX, minY, maxZ + zDistance}; } - double[] offsetAABB_XZ = new double[]{minX + xDistance, minY + yBelow - 1.0E-5F, minZ + zDistance, maxX + xDistance, minY, maxZ + zDistance}; + double[] offsetAABB_XZ = new double[]{minX + xDistance, minY + yBelow - 9.999999747378752E-6D, minZ + zDistance, maxX + xDistance, minY, maxZ + zDistance}; while (xDistance != 0.0D && zDistance != 0.0D && CollisionUtil.isEmpty(blockCache, player, offsetAABB_XZ)) { if (Math.abs(xDistance) <= 0.05D) { xDistance = 0.0D; @@ -235,7 +234,7 @@ else if (zDistance > 0.0) { zDistance = 0.0D; } else zDistance -= signumZ; - offsetAABB_XZ = new double[]{minX + xDistance, minY + yBelow - 1.0E-5F, minZ + zDistance, maxX + xDistance, minY, maxZ + zDistance}; + offsetAABB_XZ = new double[]{minX + xDistance, minY + yBelow - 9.999999747378752E-6D, minZ + zDistance, maxX + xDistance, minY, maxZ + zDistance}; } } vector = new Vector(xDistance, 0.0, zDistance); diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/RichBoundsLocation.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/RichBoundsLocation.java index f350f38f5c..522aea4359 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/RichBoundsLocation.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/RichBoundsLocation.java @@ -25,7 +25,7 @@ import org.bukkit.entity.Entity; import org.bukkit.util.Vector; -import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveData; +import fr.neatmonster.nocheatplus.compat.SchedulerHelper; import fr.neatmonster.nocheatplus.compat.blocks.changetracker.BlockChangeReference; import fr.neatmonster.nocheatplus.compat.blocks.changetracker.BlockChangeTracker; import fr.neatmonster.nocheatplus.compat.blocks.changetracker.BlockChangeTracker.BlockChangeEntry; @@ -114,6 +114,9 @@ public class RichBoundsLocation implements IGetBukkitLocation, IGetBlockPosition /** Is the player in water?. */ Boolean inWater = null; + /** Is the player in water logged block?. */ + Boolean inWaterLogged = null; + /** Is the player in web?. */ Boolean inWeb = null; @@ -615,74 +618,47 @@ public boolean isInLava() { *
    * The server applies bubble column motion on calling the tryCheckInsideBlocks() method in Entity.java * + * @param vector The current motion Vector of the entity. + * @return The modified motion Vector with adjusted vertical motion due to bubble column effects. */ - public void tryApplyBubbleColumnMotion(PlayerMoveData thisMove, double[]yTheoreticalDistance) { + public Vector tryApplyBubbleColumnMotion(Vector vector) { if (!isInBubbleStream()) { // Client-version checking is already contained in isInBubbleStream. - return; + return vector; } - final boolean direct = yTheoreticalDistance == null; - final int iMinX = MathUtil.floor(minX + 0.001); - final int iMaxX = MathUtil.ceil(maxX - 0.001); - final int iMinY = MathUtil.floor(minY + 0.001); - final int iMaxY = MathUtil.ceil(maxY - 0.001); - final int iMinZ = MathUtil.floor(minZ + 0.001); - final int iMaxZ = MathUtil.ceil(maxZ - 0.001); - for (int x = iMinX; x < iMaxX; x++) { - for (int y = iMinY; y < iMaxY; y++) { - for (int z = iMinZ; z < iMaxZ; z++) { - // Determine whether the block above the column is effectively "air". - final IBlockCacheNode nodeAbove = blockCache.getOrCreateBlockCacheNode(x, y + 1, z, false); - final boolean airAbove = BlockProperties.isAir(nodeAbove.getType()); - // TODO: Cache the data? - final BlockData data = world.getBlockAt(x, y, z).getBlockData(); - if (data instanceof BubbleColumn) { - final boolean isDrag = ((BubbleColumn) data).isDrag(); - if (direct) { - if (airAbove) { - // Above the column - if (isDrag) { - thisMove.yAllowedDistance = Math.max(-0.9D, thisMove.yAllowedDistance - 0.03D); - } - else { - thisMove.yAllowedDistance = Math.min(1.8D, thisMove.yAllowedDistance + 0.1D); - } - } - else { - // Fully inside - if (isDrag) { - thisMove.yAllowedDistance = Math.max(-0.3D, thisMove.yAllowedDistance - 0.03D); - } - else { - thisMove.yAllowedDistance = Math.min(0.7D, thisMove.yAllowedDistance + 0.06D); - } - } - } else { - for (int i = 0; i < yTheoreticalDistance.length; i++) { - if (airAbove) { - // Above the column - if (isDrag) { - yTheoreticalDistance[i] = Math.max(-0.9D, yTheoreticalDistance[i] - 0.03D); - } - else { - yTheoreticalDistance[i] = Math.min(1.8D, yTheoreticalDistance[i] + 0.1D); - } - } - else { - // Fully inside - if (isDrag) { - yTheoreticalDistance[i] = Math.max(-0.3D, yTheoreticalDistance[i] - 0.03D); - } - else { - yTheoreticalDistance[i] = Math.min(0.7D, yTheoreticalDistance[i] + 0.06D); - } - } - } - } - } - } + if (!SchedulerHelper.isOwnedByCurrentRegion(world, this.blockX, this.blockZ, 1)) { + return vector; + } + // Determine whether the block above the column is effectively "air". + IBlockCacheNode nodeAbove = blockCache.getBlockCacheNode(this.blockX, this.blockY + 1, this.blockZ); + boolean airAbove = (nodeAbove == null) ? BlockProperties.isAir(world.getBlockAt(this.blockX, this.blockY + 1, this.blockZ).getType()) + : BlockProperties.isAir(nodeAbove.getType()); + boolean isDrag = false; + BlockData data = world.getBlockAt(this.blockX, this.blockY, this.blockZ).getBlockData(); + if (data instanceof BubbleColumn) { + isDrag = ((BubbleColumn) data).isDrag(); + } + + double yMotion; + if (airAbove) { + // Above the column + if (isDrag) { + yMotion = Math.max(-0.9D, vector.getY() - 0.03D); + } + else { + yMotion = Math.min(1.8D, vector.getY() + 0.1D); } } + else { + // Fully inside + if (isDrag) { + yMotion = Math.max(-0.3D, vector.getY() - 0.03D); + } + else { + yMotion = Math.min(0.7D, vector.getY() + 0.06D); + } + } + return new Vector(vector.getX(), yMotion, vector.getZ()); } /** @@ -693,10 +669,14 @@ public void tryApplyBubbleColumnMotion(PlayerMoveData thisMove, double[]yTheoret */ public boolean isInWater() { if (inWater == null) { - inWater = false; - if (blockFlags != null && (blockFlags & BlockFlags.F_WATER) == 0) { + if (!isInWaterLogged() && blockFlags != null && (blockFlags & BlockFlags.F_WATER) == 0) { + inWater = false; return false; } + inWater = isInWaterLogged(); + if (inWater) { + return true; + } final int iMinX = MathUtil.floor(minX + 0.001); final int iMaxX = MathUtil.ceil(maxX - 0.001); final int iMinY = MathUtil.floor(minY + 0.001); @@ -720,12 +700,22 @@ public boolean isInWater() { } return inWater; } + + /** + * @return true, if is in a water logged block. Applies to 1.13+ clients + */ + public boolean isInWaterLogged() { + if (inWaterLogged == null) { + inWaterLogged = BlockProperties.isWaterlogged(world, blockCache, minX, minY, minZ, maxX, maxY, maxZ); + } + return inWaterLogged; + } /** * @return true, if is in liquid */ public boolean isInLiquid() { - if (blockFlags != null && (blockFlags & BlockFlags.F_LIQUID) == 0) { + if (!isInWaterLogged() && blockFlags != null && (blockFlags & BlockFlags.F_LIQUID) == 0) { return false; } return isInWater() || isInLava(); @@ -963,7 +953,7 @@ public boolean isInBubbleStream() { if (blockFlags != null && (blockFlags & BlockFlags.F_BUBBLE_COLUMN) == 0) { inBubbleStream = false; } - else inBubbleStream = isInsideIgnoreBounds(BlockFlags.F_BUBBLE_COLUMN); + else inBubbleStream = isInside(BlockFlags.F_BUBBLE_COLUMN); } return inBubbleStream; @@ -1517,6 +1507,7 @@ public void prepare(final RichBoundsLocation other) { this.nodeBelow = other.nodeBelow; this.onGround = other.isOnGround(); this.inWater = other.isInWater(); + this.inWaterLogged = other.isInWaterLogged(); this.inLava = other.isInLava(); this.inWeb = other.isInWeb(); this.inBerryBush = other.isInBerryBush(); @@ -1570,7 +1561,8 @@ protected void doSet(final Location location, final double fullWidth, final doub pitch = location.getPitch(); // Set bounding box. - final double dxz = fullWidth / 2D; // Don't round thing up, otherwise from#collide will fail randomly + // final double dxz = Math.round(fullWidth * 500.0) / 1000.0; // this.width / 2; // 0.3; + final double dxz = Math.round(fullWidth * 500.0) / 1000.0; // fullWidth / 2f; <---- This -for some reasons- yields thisMove.headObstructed = true when moving against walls! minX = x - dxz; minY = y; minZ = z - dxz; @@ -1590,7 +1582,7 @@ protected void doSet(final Location location, final double fullWidth, final doub // Reset cached values. node = nodeBelow = null; - inLava = inWater = inWeb = onIce = onBlueIce = inSoulSand = onHoneyBlock + inLava = inWater = inWaterLogged = inWeb = onIce = onBlueIce = inSoulSand = onHoneyBlock = onSlimeBlock = inBerryBush = inPowderSnow = touchedPowderSnow = onGround = onClimbable = onBouncyBlock = passable = passableBox = inBubbleStream = null; onGroundMinY = Double.MAX_VALUE; diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/RichEntityLocation.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/RichEntityLocation.java index 7bd284e343..f75f1fda0f 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/RichEntityLocation.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/location/RichEntityLocation.java @@ -279,6 +279,21 @@ public boolean isOnBlueIce() { return super.isOnBlueIce(); } + /** + * @return Always false for 1.12 and below. + */ + public boolean isInWaterLogged() { + if (inWaterLogged != null) { + return inWaterLogged; + } + if (GenericVersion.isLowerThan(entity, "1.13")) { + // Waterlogged blocks don't exist for older clients. + inWaterLogged = false; + return inWaterLogged; + } + return super.isInWaterLogged(); + } + /** * @return Always false for 1.12 and below. */ @@ -553,7 +568,7 @@ public boolean isUnobstructed(double xOffset, double yOffset, double zOffset, lo * * @return True, if the moved bounding box is free from obstructions, otherwise false. */ - public boolean isUnobstructed(double yDistance) { + public boolean isUnobstructed() { final IPlayerData pData = DataManager.getPlayerDataForEntity(entity, passengerUtil); final MovingData data = pData.getGenericInstance(MovingData.class); final PlayerMoveData thisMove = data.playerMoves.getCurrentMove(); @@ -561,7 +576,7 @@ public boolean isUnobstructed(double yDistance) { // Un-comment this once x/y/zAllowedDistances is shared with vehicles too in MoveData, and we have a prediction for vehicles. // final VehicleMoveData vehicleMove = data.vehicleMoves.getCurrentMove(); // final VehicleMoveData lastVehicleMove = data.vehicleMoves.getFirstPastMove(); - return isUnobstructed(thisMove.xAllowedDistance, yDistance+0.6-lastMove.to.getY()+lastMove.from.getY(), thisMove.zAllowedDistance, isInWater() ? BlockFlags.F_WATER : BlockFlags.F_LAVA); + return isUnobstructed(thisMove.xAllowedDistance, thisMove.yAllowedDistance+0.6-lastMove.to.getY()+lastMove.from.getY(), thisMove.zAllowedDistance, isInWater() ? BlockFlags.F_WATER : BlockFlags.F_LAVA); } /** diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/map/BlockProperties.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/map/BlockProperties.java index 663656085f..5047d0edce 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/map/BlockProperties.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/map/BlockProperties.java @@ -3759,6 +3759,66 @@ public static boolean containsAnyLiquid(final BlockCache access, double[] AABB, return false; } + /** + * Check if the given bounding box collides with waterlogged blocks. + * + *

    Folia support: waterlogged state now comes through {@link BlockCache} + * extended data, so callers keep the old API without directly querying + * Bukkit block data from a movement check.

    + * + * @param world + * the world + * @param access + * the access + * @param minX + * the min x + * @param minY + * the min y + * @param minZ + * the min z + * @param maxX + * the max x + * @param maxY + * the max y + * @param maxZ + * the max z + * @return true, if successful + */ + public static final boolean isWaterlogged(final World world, final BlockCache access, + final double minX, final double minY, final double minZ, + final double maxX, final double maxY, final double maxZ) { + if (!Bridge1_13.hasIsSwimming()) { + return false; + } + final int iMinX = Location.locToBlock(minX); + final int iMaxX = Location.locToBlock(maxX); + final int iMinY = Location.locToBlock(minY); + final int iMaxY = Math.min(Location.locToBlock(maxY), access.getMaxBlockY()); + final int iMinZ = Location.locToBlock(minZ); + final int iMaxZ = Location.locToBlock(maxZ); + + for (int x = iMinX; x <= iMaxX; x++) { + for (int z = iMinZ; z <= iMaxZ; z++) { + for (int y = iMaxY; y >= iMinY; y--) { + if ((access.getExtendedData(x, y, z) & BlockFlags.F_WATERLOGGED) == 0) { + continue; + } + // Waterlogged blocks expose the water volume inside the block cache. + if (minX > 1.0 + x || maxX < 0.0 + x + || minY > LIQUID_HEIGHT_LOWERED + y || maxY < 0.0 + y + || minZ > 1.0 + z || maxZ < 0.0 + z) { + continue; + } + if (minX == 1.0 + x || minY == LIQUID_HEIGHT_LOWERED + y || minZ == 1.0 + z) { + continue; + } + return true; + } + } + } + return false; + } + /** * Test if the box collide with any block that matches the flags somehow. * Convenience method. @@ -4875,4 +4935,4 @@ public static void setSpecialCaseTrapDoorAboveLadder(boolean specialCaseTrapDoor public static int getMinWorldY() { return minWorldY; } -} \ No newline at end of file +} diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/moving/AuxMoving.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/moving/AuxMoving.java index 21144513e6..c99c414abb 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/moving/AuxMoving.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/moving/AuxMoving.java @@ -27,6 +27,7 @@ import fr.neatmonster.nocheatplus.checks.moving.model.PlayerMoveInfo; import fr.neatmonster.nocheatplus.checks.moving.model.VehicleMoveInfo; import fr.neatmonster.nocheatplus.compat.MCAccess; +import fr.neatmonster.nocheatplus.compat.SchedulerHelper; import fr.neatmonster.nocheatplus.components.registry.event.IGenericInstanceHandle; import fr.neatmonster.nocheatplus.components.registry.feature.IRegisterAsGenericInstance; @@ -111,6 +112,11 @@ public synchronized final double getJumpAmplifier(final Player player) { * @param cc */ public synchronized void resetPositionsAndMediumProperties(final Player player, final Location loc, final MovingData data, final MovingConfig cc) { + if (!SchedulerHelper.isOwnedByCurrentRegion(loc, 1)) { + data.clearMostMovingCheckData(); + data.setSetBack(loc); + return; + } final PlayerMoveInfo moveInfo = usePlayerMoveInfo(); moveInfo.set(player, loc, null, cc.yOnGround); data.resetPlayerPositions(moveInfo.from); @@ -130,6 +136,11 @@ public synchronized void resetPositionsAndMediumProperties(final Player player, * @param cc */ public synchronized void resetVehiclePositions(final Entity vehicle, final Location vehicleLocation, final MovingData data, final MovingConfig cc) { + if (!SchedulerHelper.isOwnedByCurrentRegion(vehicle) || !SchedulerHelper.isOwnedByCurrentRegion(vehicleLocation, 1)) { + data.clearVehicleData(); + data.clearVehicleMorePacketsData(); + return; + } final VehicleMoveInfo vMoveInfo = useVehicleMoveInfo(); vMoveInfo.set(vehicle, vehicleLocation, null, cc.yOnGround); data.resetVehiclePositions(vMoveInfo.from); diff --git a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/moving/MovingUtil.java b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/moving/MovingUtil.java index 6cbfc46ab9..cdb86d5439 100644 --- a/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/moving/MovingUtil.java +++ b/NCPCore/src/main/java/fr/neatmonster/nocheatplus/utilities/moving/MovingUtil.java @@ -364,8 +364,20 @@ public static double getJumpAmplifier(final Player player, final MCAccess mcAcce return 1.0 + amplifier; } + private static boolean canCollectFullCheckContext(final RichBoundsLocation from, final RichBoundsLocation to) { + if (!SchedulerHelper.isFoliaServer()) { + return true; + } + return SchedulerHelper.isOwnedByCurrentRegion(from.getWorld(), from.getBlockX(), from.getBlockZ(), 1) + && SchedulerHelper.isOwnedByCurrentRegion(to.getWorld(), to.getBlockX(), to.getBlockZ(), 1); + } + public static void prepareFullCheck(final RichBoundsLocation from, final RichBoundsLocation to, final MoveData thisMove, final double yOnGround) { + if (!canCollectFullCheckContext(from, to)) { + thisMove.set(from, to); + return; + } // Collect block flags. from.collectBlockFlags(yOnGround); if (from.isSamePos(to)) { diff --git a/NCPPlugin/src/main/java/fr/neatmonster/nocheatplus/NoCheatPlus.java b/NCPPlugin/src/main/java/fr/neatmonster/nocheatplus/NoCheatPlus.java index 4e83c44c85..0f37a5389c 100644 --- a/NCPPlugin/src/main/java/fr/neatmonster/nocheatplus/NoCheatPlus.java +++ b/NCPPlugin/src/main/java/fr/neatmonster/nocheatplus/NoCheatPlus.java @@ -130,9 +130,9 @@ import fr.neatmonster.nocheatplus.players.IPlayerDataManager; import fr.neatmonster.nocheatplus.players.PlayerDataManager; import fr.neatmonster.nocheatplus.players.PlayerMessageSender; -import fr.neatmonster.nocheatplus.stats.Counters; -import fr.neatmonster.nocheatplus.utilities.ColorUtil; -import fr.neatmonster.nocheatplus.utilities.Misc; +import fr.neatmonster.nocheatplus.stats.Counters; +import fr.neatmonster.nocheatplus.utilities.ColorUtil; +import fr.neatmonster.nocheatplus.utilities.Misc; import fr.neatmonster.nocheatplus.utilities.OnDemandTickListener; import fr.neatmonster.nocheatplus.utilities.ReflectionUtil; import fr.neatmonster.nocheatplus.utilities.StringUtil; @@ -824,11 +824,11 @@ private void setupCommandProtection() { /* (non-Javadoc) * @see org.bukkit.plugin.java.JavaPlugin#onLoad() */ - @Override - public void onLoad() { - Bukkit.getLogger().info("[NoCheatPlus] onLoad: Early set up of static API, configuration, logging."); // Bukkit logger. - setupBasics(); - } + @Override + public void onLoad() { + Bukkit.getLogger().info("[NoCheatPlus] onLoad: Early set up of static API, configuration, logging."); // Bukkit logger. + setupBasics(); + } /** * Lazy initialization of basics (static API, configuration, logging). @@ -1054,11 +1054,11 @@ public void onEnable() { // Mid-term cleanup (seconds range). SchedulerHelper.runSyncRepeatingTask(this, (arg) -> midTermCleanup(), 83, 83); - // Set StaticLog to more efficient output. - StaticLog.setStreamID(Streams.STATUS); - // Tell the server administrator that we finished loading NoCheatPlus now. - logManager.info(Streams.INIT, "Version " + getDescription().getVersion() + " is enabled."); - } + // Set StaticLog to more efficient output. + StaticLog.setStreamID(Streams.STATUS); + // Tell the server administrator that we finished loading NoCheatPlus now. + logManager.info(Streams.INIT, "Version " + getDescription().getVersion() + " is enabled."); + } /** * Log other notes once on enabling. @@ -1309,6 +1309,10 @@ private void onWorldPresent(final World world) { private void onJoinLow(final Player player) { final String playerName = player.getName(); final IPlayerData data = DataManager.getPlayerData(player); + data.setBedrockPlayer(isBedrockPlayer(player, data)); + if (data.isBedrockPlayer()) { + logManager.info(Streams.STATUS, "Detected Bedrock player: " + playerName); + } if (data.hasPermission(Permissions.NOTIFY, player)) { // Updates the cache. // Login notifications... // // Update available. @@ -1336,6 +1340,32 @@ private void onJoinLow(final Player player) { } } + private boolean isBedrockPlayer(final Player player, final IPlayerData data) { + if (data.hasPermission(Permissions.COMPAT_BEDROCK, player)) { + return true; + } + try { + final Class apiClass = Class.forName("org.geysermc.floodgate.api.FloodgateApi"); + final Object api = apiClass.getMethod("getInstance").invoke(null); + return Boolean.TRUE.equals(apiClass.getMethod("isFloodgatePlayer", java.util.UUID.class).invoke(api, player.getUniqueId())); + } + catch (Throwable ignored) {} + try { + final Class apiClass = Class.forName("org.geysermc.geyser.api.GeyserApi"); + final Object api = apiClass.getMethod("api").invoke(null); + if (api != null) { + try { + return Boolean.TRUE.equals(apiClass.getMethod("isBedrockPlayer", java.util.UUID.class).invoke(api, player.getUniqueId())); + } + catch (NoSuchMethodException ignored) { + return apiClass.getMethod("connectionByUuid", java.util.UUID.class).invoke(api, player.getUniqueId()) != null; + } + } + } + catch (Throwable ignored) {} + return player.getName().startsWith("."); + } + private void onLeave(final Player player) { for (final JoinLeaveListener jlListener : joinLeaveListeners) { try{ diff --git a/NCPPlugin/src/test/java/fr/neatmonster/nocheatplus/test/TestInteractRayTracing.java b/NCPPlugin/src/test/java/fr/neatmonster/nocheatplus/test/TestInteractRayTracing.java index 8e5fe5dea6..76b36ef34d 100644 --- a/NCPPlugin/src/test/java/fr/neatmonster/nocheatplus/test/TestInteractRayTracing.java +++ b/NCPPlugin/src/test/java/fr/neatmonster/nocheatplus/test/TestInteractRayTracing.java @@ -15,6 +15,7 @@ package fr.neatmonster.nocheatplus.test; import org.bukkit.Material; +import org.junit.Before; import org.junit.Test; import fr.neatmonster.nocheatplus.compat.bukkit.BridgeMaterial; @@ -43,7 +44,8 @@ public void set(double x0, double y0, double z0, double x1, double y1, double z1 // TODO: Blunt copy and paste from TestPassableRayTracing, add something that makes sense. - public TestInteractRayTracing() { + @Before + public void initBlockProperties() { StaticLog.setUseLogManager(false); BlockTests.initBlockProperties(); StaticLog.setUseLogManager(true); diff --git a/NCPPlugin/src/test/java/fr/neatmonster/nocheatplus/test/TestPassableRayTracing.java b/NCPPlugin/src/test/java/fr/neatmonster/nocheatplus/test/TestPassableRayTracing.java index ac6feb3f22..b24f31d920 100644 --- a/NCPPlugin/src/test/java/fr/neatmonster/nocheatplus/test/TestPassableRayTracing.java +++ b/NCPPlugin/src/test/java/fr/neatmonster/nocheatplus/test/TestPassableRayTracing.java @@ -15,6 +15,7 @@ package fr.neatmonster.nocheatplus.test; import org.bukkit.Material; +import org.junit.Before; import org.junit.Test; import fr.neatmonster.nocheatplus.compat.bukkit.BridgeMaterial; @@ -36,7 +37,8 @@ public class TestPassableRayTracing extends MockServerBase { // TODO: From ground and onto ground moves, onto-edge moves (block before edge, into block, etc). // TODO: Randomized tests (Collide with inner sphere, not collide with outer sphere). - public TestPassableRayTracing() { + @Before + public void initBlockProperties() { StaticLog.setUseLogManager(false); BlockTests.initBlockProperties(); StaticLog.setUseLogManager(true); diff --git a/README.md b/README.md index e9d68300d2..134e3a72c9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Links ###### Support and Documentation * [Issues/Tickets](https://github.com/Updated-NoCheatPlus/NoCheatPlus/issues) * [Wiki](https://github.com/Updated-NoCheatPlus/Docs) +* [Compatibility debugging guide](docs/compat-debugging.md) * [Configuration](https://github.com/Updated-NoCheatPlus/Docs#configuration) * [Permissions](https://github.com/Updated-NoCheatPlus/Docs/blob/master/Settings/Permissions.md) * [Commands](https://github.com/Updated-NoCheatPlus/Docs/blob/master/Settings/Commands.md) diff --git a/docs/compat-debugging.md b/docs/compat-debugging.md new file mode 100644 index 0000000000..35b6d56e77 --- /dev/null +++ b/docs/compat-debugging.md @@ -0,0 +1,151 @@ +# Compatibility Debugging Guide + +This guide explains the extra compatibility diagnostics added for Bedrock, Folia, modern movement clients, and false-positive tuning. The goal is to make console logs easier to read without losing the detailed values needed to refine checks. + +## Enabling Console Detail + +The detailed compatibility lines are opt-in because they can be noisy on live servers. Enable them only while testing or collecting false-positive reports. + +Relevant config paths: + +```yaml +logging: + debug: + to-console: true + backend: + console: + active: true +``` + +The normal violation/debug settings still control whether a check has enough debug context to print. + +## Reading A Detail Line + +Most new detail lines keep the same shape: + +```text +[NCP][CheckName][detail] player=name subcheck=SPECIFIC_BRANCH summary=short_reason{...} ... tags=... +``` + +Important fields: + +| Field | Meaning | +| --- | --- | +| `subcheck` | The concrete branch behind the umbrella check name. Use this first when sorting logs. | +| `summary` | Short human-readable diagnosis for quick scanning. This is intentionally near the front of the line. | +| `movementMode` | SurvivalFly movement state such as `GROUND`, `WATER`, `CLIMBABLE`, `ELYTRA_GLIDING`, or `ELYTRA_FIREWORK`. | +| `model` | The selected SurvivalFly model branch, if one matched the movement context. | +| `hOver` | Remaining horizontal distance over the model boundary after model handling. | +| `yOver` | Remaining vertical distance over the model boundary after model handling. | +| `physicsModel` | Diagnostic-only gravity/velocity probe for SurvivalFly. It shows expected next fall speed, actual acceleration, server velocity differences, and horizontal-vs-vertical glide ratios. | +| `tags` | Full diagnostic markers for deeper analysis. | + +## SurvivalFly Tags + +SurvivalFly logs are the densest because one umbrella check covers many movement states. + +Common tag groups: + +| Tag prefix | Meaning | +| --- | --- | +| `subcheck_` | Final readable failure type, for example `subcheck_elytra_firework_y`. | +| `mode_` | Broad movement mode at the time of the violation. | +| `branch_` | Important branch/context that influenced diagnosis, such as liquid, collision, velocity, or recent setback. | +| `branch_model_` | The code selected a named movement model for this move. | +| `model_*` | A selected model accepted part of the movement. Suffix `_h` means horizontal, `_y` means vertical. | +| `model_*_miss` | A model was selected but did not accept the movement envelope. This is usually the next place to refine. | +| `diag_*` | Diagnostic probe only. It marks a likely area to inspect but does not mean movement was accepted. | + +Example: + +```text +summary=elytra_firework_y{mode=elytra_firework,model=elytra_firework,axis=y,hOver=0,yOver=0.035,tags=subcheck_elytra_firework_y+branch_elytra_state+branch_model_elytra_firework} +``` + +Practical reading: + +- The player was actively gliding with a firework. +- The selected model was `elytra_firework`. +- The remaining miss was vertical (`axis=y`). +- `yOver=0.035` is the amount still outside the accepted model. +- If this was false, refine the elytra firework vertical model, not generic SurvivalFly. + +## Teleport And Portal Sync + +Folia and modern teleport plugins can use `teleportAsync`, portal callbacks, or direct server position packets. In those cases, packet-level `NET_MOVING` may see the position jump before SurvivalFly has clean movement history for the new location. + +Useful tags: + +| Tag | Meaning | +| --- | --- | +| `server_position_jump_stale_packet_model` | `NET_MOVING` matched a stale pre-teleport packet to a recent server-side position jump. This is not elytra-specific. | +| `teleport_command_stale_packet_model` | A command such as `/home` or `/rtp` was recently issued, and the old packet still fit the command-location stale-packet model. | +| `server_position_jump_recovery_grace` | One last-resort stale packet was consumed after a server-side jump when movement history was invalid. | +| `server_position_jump_air_resync_horizontal_model` | SurvivalFly accepted a small post-teleport air/fall horizontal mismatch using the server-position resync model. | +| `server_position_jump_air_resync_vertical_model` | SurvivalFly accepted the next post-teleport vertical packet as a vanilla gravity continuation. | + +The final teleport sync model handles the common Folia/ProtocolLib packet order pattern where one or two old-location packets arrive after a command teleport, `/rtp`, `/spawn`, `/home`, respawn, or portal-style server-position jump. Those packets are deleted from packet history and do not represent player movement at the new location. Console diagnostics are intentionally quiet for that normal one-or-two-packet pattern; repeated stale packets beyond that still log so the packet ordering model can be refined. + +If teleport false positives remain, include the `NET_MOVING` detail, the matching `NET_MOVING][teleport]` line, and the next SurvivalFly detail line. The useful fields are `serverJumpAge`, `serverJumpFrom`, `serverJumpTo`, `serverJumpStalePacketModel`, `commandStalePacketModel`, `packetModel`, `movementMode`, `movementModel`, `modelProbe`, `summary`, and `tags`. + +## Folia Block Cache Fallback + +Folia region ownership can make direct block reads unsafe from packet or async paths. The Bukkit block cache now first checks normal region ownership, then retries with exact-location ownership before returning `AIR` or legacy data `0` as the final safe fallback. + +Single fallback events during teleport or chunk handoff are expected and are not printed to console. Repeated fallback events are rate-limited and logged as safe fallback diagnostics with resolved and final-fallback counts. + +## Other Check Summaries + +The other detailed checks now include short `summary` fields too: + +| Check | Summary shape | What it helps identify | +| --- | --- | --- | +| FastBreak | `summary=block_timing{...}` | Block/tool timing, missing break time, grace budget. | +| FightAngle | `summary=angle_*{...}` | Aim angle, timing, movement, or target-switch branch. | +| FightCritical | `summary=critical_*{...}` | Fall-distance mismatch, reset-condition criticals, jump-phase desync. | +| BlockDirection | `summary=block_direction{...}` | Ray miss vs unreachable block face. | +| MorePackets | `summary=packet_rate{...}` | Packet-rate or burst violations separate from movement model errors. | +| Passable | `summary=passable_raytrace{...}` | Ray-trace branch and block types involved in stuck/inside-block flags. | +| KeepAliveFrequency | `summary=keepalive_bucket{...}` | Bucket timing, duplicate/fast keep-alive replies, Folia async timing. | +| NetMoving | `summary=net_moving{...}` | Extreme packet movement vs teleport/server-position stale-packet models. | + +## What To Include In A Bug Report + +When reporting a false positive, include: + +1. The full `[NCP][...][detail]` line, not only the short `[NC+] [VL]` line. +2. What the player was doing in plain terms, such as "Bedrock player running up stairs" or "elytra firework into ground". +3. Whether the player was Bedrock or Java. +4. The block or environment involved, especially stairs, slabs, lanterns, carpet, vines, scaffolding, water, boats, or portals. +5. A few lines before and after the flag if teleport, respawn, knockback, firework, or combat happened. + +For SurvivalFly false positives, the most useful first fields are: + +```text +summary=... +movementMode=... +subcheck=... +movementModel=... +physicsModel=... +modelProbe=... +elytraModel=... +tags=... +``` + +## Model vs Grace + +Some constants still contain `GRACE` in their names for history. In the newer model paths, those values should be treated as empirical residual boundaries inside a selected movement state. + +Preferred design: + +```text +identify movement state -> select model -> calculate boundary -> compare actual movement +``` + +Avoid this design when changing future code: + +```text +normal model fails -> broad grace accepts actual movement afterward +``` + +If a tolerance remains because packet ordering, Folia timing, teleport sync, or floating-point precision cannot be modeled exactly, document that reason next to the code.