chore(history): squash 59 commit(s) from 2026-09-23

- refactor(hud/weather/render): Scoreboard 931→240 + Rain 903→159 + UiMeshGeometry 956→99 (8.5.2)
- refactor(chams): Chams 985 → 129 + 4×≤195 (8.5.2)
- refactor(module): Module 1031 → 139 + 4×≤134 (8.5.2)
- refactor(tooltips): BetterTooltips 960 → 181 + 4×≤199 (8.5.2)
- refactor(clickgui): ClickGuiPickerState 970→166 + ModulesMenuScreen 966→145 (8.5.2)
- refactor(clickgui): CombatProtocolHeuristicsEditorState 932→79 + 4×≤192 (8.5.2)
- refactor(hud): CompactHudStatModel 894 → 125 + 4×≤175 (8.5.2)
- refactor(iris): ShaderPatchCompiler 843→66 + 4×≤174 (8.5.2)
- refactor(tab): CustomTabList 834→136 + 4×≤166 (8.5.2)
- refactor(relations): OnlineRelationPlayerPickerComponent 837→74 + 4×≤192 (8.5.2)
- refactor(hits): HitEffect 770→120 + 4×≤176 (8.5.2)
- refactor(predict): Predictions 815→150 + 4×≤195 (8.5.2)
- refactor(config): ConfigProfilesComponent 799→125 + 4×≤195 (8.5.2)
- refactor(sim): PlayerMovementSimulation 757→157 + 4×≤194 (8.5.2)
- refactor(predict): ProjectilePuncher 747→104 + 3×≤134 (8.5.2)
- refactor(crosshair): Crosshair 743→143 + 4×≤167 (8.5.2)
- refactor(mediaplayer): MediaPlayer 743→174 + 4×≤196 (8.5.2)
- refactor(hud): Itemizer 699→193 + Cooldowns 707→151 (8.5.2)
- refactor(hud): Potions 617→176 + 4×≤166 (8.5.2)
- refactor(hud): Admins 702→118 + 4×≤138 (8.5.2)
- refactor(hud): Keybinds 611→157 + 4×≤160 (8.5.2)
- refactor(hud): ModuleList 499→181 + 3×≤150 (8.5.2)
- refactor(hud): Radar 392→161 + 2×≤124 (8.5.2)
- refactor(hud): Fps 309→168 + 3×≤107 (8.5.2)
- refactor(hud): Memory 304→151 + 3×≤144 (8.5.2)
- refactor(hud): Tps 339→154 + 3×≤143 (8.5.2)
- refactor(hud): Ping 309→145 + 3×≤125 (8.5.2)
- refactor(hud): SpeedBps 334→156 + 3×≤142 (8.5.2)
- refactor(hud): GameTime 314→150 + 3×≤127 (8.5.2)
- refactor(hud): SystemTime 300→136 + 3×≤130 (8.5.2)
- refactor(hud): Coordinates 337→174 + 2×≤172 (8.5.2)
- refactor(hud): Armor 324→162 + 2×≤148 (8.5.2)
- refactor(hud): Inventory 476→153 + 3×≤181 (8.5.2)
- docs(todo): обновлён 8.5.2 — отмечены готовые гиганты (BetterButtons/InventorySwap/Svg/Темы/WorldParticles/Module/Chams и хвост)
- docs(api): папка combatant-client-26.2/docs + addons.md (API можно ломать при рефакторе)
- refactor(visuals): BlockHighlight 692→271 + 4×≤200 (8.5.2)
- refactor(mixins): GameRendererMixin 830→344 (хуки) + 4 хелпера (8.5.2)
- refactor(visuals): WorldParticles 821→260 + 6×≤200, режимы добиты (8.5.2)
- docs(todo): 8.5.2 — WorldParticles/GameRendererMixin/BlockHighlight отмечены ГОТОВО
- refactor(render): MeshBuilder 738→447 (фасад write-API) + 3 хелпера, FrameStats снесён (8.5.2)
- docs(todo): Фаза 10 — дизайн платформы (аккаунты/конфиги/аватарки), backlog RPC-чата
- docs(todo): Подсистема 3 — Аддоны 2.0 (скрипты/версии) + витрина аддонов + админка
- docs(todo): Подсистема 2 — RPC-чат/друзья/виджеты-телеметрия + рейт-лимиты, доп. правки Подсистемы 3
- chore: переименование combatant-client-26.2 -> mod, добавлены backend/ и frontend/
- docs(backend): implementation plan for accounts-service (Подсистема 1, часть 1)
- chore: пересоздать backend/frontend через cargo new и bun create vite + tailwind; убрать таблицу компонентов из TODO.md
- docs(backend): edition 2024 в плане вместо 2021
- chore: обновить версии до реально актуальных (проверено компиляцией)
- docs: правила платформы (лимит 250 строк, антипаттерны backend/frontend по итогам ресёрча) + frontend/ARCHITECTURE.md
- docs: добавить правило ≤4 файла на папку в правила платформы (backend/frontend)
- docs: commit messages in English from now on
- chore: gitignore .superpowers/ scratch directory
- docs: commit messages in English from now on (mod)
- feat(backend): scaffold accounts-service with health check
- chore(backend): remove target/ build artifacts from git, add gitignore
- chore: track docs/ in git (was accidentally gitignored, never committed)
- chore(backend): convert to Cargo workspace, plan full microservice layout
- feat(backend): accounts/avatars/device_links schema + migration
- feat(backend): argon2id password hashing
This commit is contained in:
loki5512344 2026-09-23 22:38:08 +02:00
parent ec10395511
commit 9d08fa910a
2239 changed files with 35265 additions and 28110 deletions

View file

@ -0,0 +1,75 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util;
import lombok.Getter;
public enum FastFps {
;
private static final double NANOS_PER_SEC = 1_000_000_000.0;
private static final long FPS_SMOOTHING_TAU_NANOS = 150_000_000L;
private static final long MIN_FRAME_DISCONTINUITY_NANOS = 250_000_000L;
private static final long INITIAL_FRAME_DISCONTINUITY_NANOS = 5_000_000_000L;
private static final double FRAME_DISCONTINUITY_MULTIPLIER = 4.0;
private static final float DEFAULT_DELTA_SECONDS = 1.0f / 60.0f;
private static long lastFrameNanos;
private static double smoothedFrameNanos;
private static float deltaSeconds = DEFAULT_DELTA_SECONDS;
@Getter
private static int fps;
public static void onFrame() {
long now = System.nanoTime();
if (lastFrameNanos == 0L) {
lastFrameNanos = now;
return;
}
long frameNanos = now - lastFrameNanos;
lastFrameNanos = now;
if (frameNanos <= 0L) {
return;
}
// A minimized window, debugger stop or resource reload is not a rendered frame.
// Feeding that gap into the FPS EMA poisons it for several seconds after resume.
long discontinuityNanos = smoothedFrameNanos > 0.0
? Math.max(MIN_FRAME_DISCONTINUITY_NANOS, (long) (smoothedFrameNanos * FRAME_DISCONTINUITY_MULTIPLIER))
: INITIAL_FRAME_DISCONTINUITY_NANOS;
if (frameNanos >= discontinuityNanos) {
smoothedFrameNanos = 0.0;
deltaSeconds = DEFAULT_DELTA_SECONDS;
return;
}
deltaSeconds = (float) (frameNanos / NANOS_PER_SEC);
if (smoothedFrameNanos == 0.0) {
smoothedFrameNanos = frameNanos;
} else {
double alpha = 1.0 - Math.exp(-frameNanos / (double) FPS_SMOOTHING_TAU_NANOS);
smoothedFrameNanos += (frameNanos - smoothedFrameNanos) * alpha;
}
fps = (int) Math.round(NANOS_PER_SEC / smoothedFrameNanos);
}
public static float getDeltaSeconds() {
return deltaSeconds;
}
public static void reset() {
lastFrameNanos = 0L;
smoothedFrameNanos = 0.0;
deltaSeconds = DEFAULT_DELTA_SECONDS;
fps = 0;
}
}

View file

@ -0,0 +1,29 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
public enum LegacyHudNotifier {
;
public static void show(String message) {
Minecraft client = Minecraft.getInstance();
if (client.gui != null) {
client.gui.hud.setOverlayMessage(Component.nullToEmpty(message), false);
}
}
public static void show(Component text) {
Minecraft client = Minecraft.getInstance();
if (client.gui != null) {
client.gui.hud.setOverlayMessage(text, false);
}
}
}

View file

@ -0,0 +1,48 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.NarratorStatus;
import net.minecraft.client.Options;
import dev.loki.lovisual.config.i18n.MainConfig;
//Да ну нахуй эту залупу блять
public enum NarratorBlocker {
;
private static boolean applying;
public static boolean isBlocked() {
return MainConfig.get().isNarratorDisabled();
}
public static void enforce(Minecraft client) {
if (!isBlocked() || client == null) return;
enforce(client.options);
try {
client.getNarrator().clear();
} catch (Throwable ignored) {
}
}
public static void enforce(Options options) {
if (!isBlocked() || options == null || applying) return;
applying = true;
try {
if (options.narrator().get() != NarratorStatus.OFF) {
options.narrator().set(NarratorStatus.OFF);
}
if (options.narratorHotkey().get()) {
options.narratorHotkey().set(false);
}
} finally {
applying = false;
}
}
}

View file

@ -0,0 +1,60 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.item.*;
import net.minecraft.world.item.enchantment.Enchantments;
import net.minecraft.world.item.enchantment.ItemEnchantments;
public enum OmniItemUtils {
;
public static boolean isProjectileWeaponInHand(LocalPlayer player) {
if (player == null) return false;
ItemStack chest = player.getItemBySlot(EquipmentSlot.CHEST);
if (chest != null && chest.getItem() == Items.ELYTRA) {
return true; // считаем как "есть снарядное оружие" → отключить omnisprint
}
for (ItemStack stack : new ItemStack[]{
player.getMainHandItem(),
player.getOffhandItem()
}) {
if (stack.isEmpty()) continue;
Item item = stack.getItem();
// ==== Bow ====
if (item instanceof BowItem) return true;
// ==== Crossbow ====
if (item instanceof CrossbowItem) return true;
// ==== Trident ====
if (item == Items.TRIDENT) {
ItemEnchantments ench =
stack.getOrDefault(DataComponents.ENCHANTMENTS, ItemEnchantments.EMPTY);
boolean hasRiptide = ench.keySet().stream().anyMatch(entry ->
entry.unwrapKey().isPresent() &&
entry.unwrapKey().get().equals(Enchantments.RIPTIDE)
);
if (!hasRiptide) return true;
}
// ==== Throwable ====
if (item == Items.ENDER_PEARL //||
//item == Items.SNOWBALL ||
/*item == Items.EGG*/) return true;
}
return false;
}
}

View file

@ -0,0 +1,109 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming;
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.raycast.RaycastUtil;
import java.util.ArrayList;
import java.util.List;
/**
* Finds a good aim point/rotation inside a target box.
*/
public enum AimPointUtil {
;
public static RotationResult findBestRotation(Vec3 eyes,
AABB box,
float baseYaw,
float basePitch,
double range,
double wallsRange,
boolean allowThroughWalls) {
if (eyes == null || box == null) return null;
List<Vec3> points = collectPoints(box);
RotationResult bestVisible = null;
RotationResult bestInvisible = null;
double rangeSq = range * range;
double wallsSq = wallsRange * wallsRange;
for (Vec3 point : points) {
double distSq = eyes.distanceToSqr(point);
if (distSq > rangeSq && distSq > wallsSq) continue;
boolean visible = distSq <= wallsSq || RaycastUtil.hasLineOfSightPoint(eyes, point);
if (!visible && !allowThroughWalls) continue;
float[] rot = RotationUtil.getRotations(eyes, point);
float score = rotationScore(baseYaw, basePitch, rot[0], rot[1]);
RotationResult candidate = new RotationResult(rot[0], rot[1], point, visible);
if (visible) {
if (bestVisible == null || score < rotationScore(baseYaw, basePitch, bestVisible.yaw, bestVisible.pitch)) {
bestVisible = candidate;
}
} else {
if (bestInvisible == null || score < rotationScore(baseYaw, basePitch, bestInvisible.yaw, bestInvisible.pitch)) {
bestInvisible = candidate;
}
}
}
return bestVisible != null ? bestVisible : bestInvisible;
}
private static float rotationScore(float baseYaw, float basePitch, float yaw, float pitch) {
float yawDiff = Math.abs(RotationUtil.angleDifference(baseYaw, yaw));
float pitchDiff = Math.abs(RotationUtil.angleDifference(basePitch, pitch));
return yawDiff + pitchDiff;
}
private static List<Vec3> collectPoints(AABB box) {
List<Vec3> out = new ArrayList<>(14);
double minX = box.minX;
double minY = box.minY;
double minZ = box.minZ;
double maxX = box.maxX;
double maxY = box.maxY;
double maxZ = box.maxZ;
double midX = (minX + maxX) * 0.5;
double midY = (minY + maxY) * 0.5;
double midZ = (minZ + maxZ) * 0.5;
// center + face centers
out.add(new Vec3(midX, midY, midZ));
out.add(new Vec3(midX, minY, midZ));
out.add(new Vec3(midX, maxY, midZ));
out.add(new Vec3(minX, midY, midZ));
out.add(new Vec3(maxX, midY, midZ));
out.add(new Vec3(midX, midY, minZ));
out.add(new Vec3(midX, midY, maxZ));
// corners
out.add(new Vec3(minX, minY, minZ));
out.add(new Vec3(minX, minY, maxZ));
out.add(new Vec3(minX, maxY, minZ));
out.add(new Vec3(minX, maxY, maxZ));
out.add(new Vec3(maxX, minY, minZ));
out.add(new Vec3(maxX, minY, maxZ));
out.add(new Vec3(maxX, maxY, minZ));
out.add(new Vec3(maxX, maxY, maxZ));
return out;
}
public record RotationResult(float yaw, float pitch, Vec3 point, boolean visible) {
}
}

View file

@ -0,0 +1,89 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming;
import dev.loki.lovisual.features.module.core.Module;
import java.util.Comparator;
import java.util.concurrent.PriorityBlockingQueue;
public final class RequestHandler<T> {
private final PriorityBlockingQueue<Request<T>> activeRequests =
new PriorityBlockingQueue<>(11, Comparator.comparingInt((Request<T> req) -> req.priority).reversed());
private int currentTick = 0;
private static boolean isProviderRunning(Object provider) {
return !(provider instanceof Module module) || module.isEnabled();
}
public void tick() {
currentTick++;
}
public void request(Request<T> request) {
if (request == null) return;
activeRequests.removeIf(existing -> existing.provider == request.provider);
request.expiresIn += currentTick;
activeRequests.add(request);
}
public Request<T> getActiveRequest() {
Request<T> top = activeRequests.peek();
while (top != null && (top.expiresIn <= currentTick || !isProviderRunning(top.provider))) {
activeRequests.poll();
top = activeRequests.peek();
}
return top;
}
public T getActiveRequestValue() {
Request<T> top = getActiveRequest();
return top != null ? top.value : null;
}
public Object getActiveRequestProvider() {
Request<T> top = getActiveRequest();
return top != null ? top.provider : null;
}
public void clear() {
activeRequests.clear();
currentTick = 0;
}
public boolean clear(Object provider) {
if (provider == null) {
return false;
}
return activeRequests.removeIf(existing -> existing.provider == provider);
}
public static final class Request<T> {
private final int priority;
private final Object provider;
private final T value;
private int expiresIn;
public Request(int expiresIn, int priority, Object provider, T value) {
this.expiresIn = expiresIn;
this.priority = priority;
this.provider = provider;
this.value = value;
}
public Object provider() {
return provider;
}
public T value() {
return value;
}
}
}

View file

@ -0,0 +1,26 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming;
public final class RestrictedSingleUseAction {
private final Runnable action;
private boolean used;
public RestrictedSingleUseAction(Runnable action) {
this.action = action;
}
public void invoke() {
if (used) return;
used = true;
if (action != null) {
action.run();
}
}
}

View file

@ -0,0 +1,131 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.data;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
public record Rotation(float yaw, float pitch, boolean isNormalized) {
public static final Rotation ZERO = new Rotation(0.0f, 0.0f, false);
public Rotation(float yaw, float pitch) {
this(yaw, pitch, false);
}
public static Rotation lookingAt(Vec3 point, Vec3 from) {
return fromRotationVec(point.subtract(from));
}
public static Rotation fromRotationVec(Vec3 lookVec) {
return fromRotationVec(lookVec.x, lookVec.y, lookVec.z);
}
public static Rotation fromRotationVec(double diffX, double diffY, double diffZ) {
float yaw = Mth.wrapDegrees((float) Math.toDegrees(Math.atan2(diffZ, diffX)) - 90.0f);
float pitch = Mth.wrapDegrees((float) -Math.toDegrees(Math.atan2(diffY, Math.hypot(diffX, diffZ))));
return new Rotation(yaw, pitch, false);
}
private static float clamp(float v, float low, float high) {
if (v < low) return low;
if (v > high) return high;
return v;
}
private static float quantizeDelta(float rawDelta, float gcd) {
if (gcd <= 0.0f) {
return rawDelta;
}
return Math.round(rawDelta / gcd) * gcd;
}
public Vec3 directionVector() {
return Vec3.directionFromRotation(pitch, yaw);
}
public float xRot() {
return pitch;
}
public float yRot() {
return yaw;
}
/**
* Fixes GCD and modulo 360 at yaw.
*/
public Rotation normalize() {
if (isNormalized) return this;
double gcd = RotationUtil.gcd();
if (gcd <= 0.0) {
float ny = yaw;
float np = pitch;
return new Rotation(ny, Mth.clamp(np, -90.0f, 90.0f), true);
}
Rotation base = RotationManager.INSTANCE.getServerRotation();
if (base == null || (!base.isNormalized() && base == Rotation.ZERO)) {
Minecraft mc = Minecraft.getInstance();
if (mc != null && mc.player != null) {
base = new Rotation(mc.player.getYRot(), mc.player.getXRot(), true);
} else {
base = Rotation.ZERO;
}
}
RotationDelta diff = base.rotationDeltaTo(this);
float g1 = quantizeDelta(diff.deltaYaw(), (float) gcd);
float g2 = quantizeDelta(diff.deltaPitch(), (float) gcd);
float ny = base.yaw + g1;
float np = base.pitch + g2;
return new Rotation(ny, Mth.clamp(np, -90.0f, 90.0f), true);
}
public float angleTo(Rotation other) {
return Math.min(rotationDeltaTo(other).length(), 180.0f);
}
public RotationDelta rotationDeltaTo(Rotation other) {
return new RotationDelta(
RotationUtil.angleDifference(other.yaw, this.yaw),
RotationUtil.angleDifference(other.pitch, this.pitch)
);
}
public Rotation towardsLinear(Rotation other, float horizontalFactor, float verticalFactor) {
RotationDelta diff = rotationDeltaTo(other);
float rotationDifference = diff.length();
if (rotationDifference <= 0.0f) {
return this;
}
float straightLineYaw = Math.abs(diff.deltaYaw() / rotationDifference) * horizontalFactor;
float straightLinePitch = Math.abs(diff.deltaPitch() / rotationDifference) * verticalFactor;
return new Rotation(
this.yaw + clamp(diff.deltaYaw(), -straightLineYaw, straightLineYaw),
this.pitch + clamp(diff.deltaPitch(), -straightLinePitch, straightLinePitch)
);
}
public boolean approximatelyEquals(Rotation other) {
return approximatelyEquals(other, 2.0f);
}
public boolean approximatelyEquals(Rotation other, float tolerance) {
if (other == null) return false;
return angleTo(other) <= tolerance;
}
}

View file

@ -0,0 +1,19 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.data;
public record RotationDelta(float deltaYaw, float deltaPitch) {
public float length() {
return (float) Math.hypot(deltaYaw, deltaPitch);
}
public net.minecraft.world.phys.Vec2 toVec2f() {
return new net.minecraft.world.phys.Vec2(deltaYaw, deltaPitch);
}
}

View file

@ -0,0 +1,13 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.data;
import net.minecraft.world.phys.Vec3;
public record RotationWithVector(Rotation rotation, Vec3 vec) {
}

View file

@ -0,0 +1,27 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features;
import dev.loki.lovisual.config.values.mode.EnumValue;
public enum MovementCorrection implements EnumValue.IdProvider {
OFF("Off"),
STRICT("Strict"),
SILENT("Silent");
private final String id;
MovementCorrection(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}

View file

@ -0,0 +1,220 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors;
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.events.impl.render.GameTickEvent;
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.BooleanSupplier;
public final class FailRotationProcessor implements RotationProcessor {
private static final int MIN_TRANSITION_TICKS = 8;
private static final int UNSAFE_COOLDOWN_TICKS = 4;
private static final float MAX_SHIFT_STEP = 1.15f;
private static final Rotation ZERO_SHIFT = new Rotation(0f, 0f);
public final BooleanValue enabled;
private final NumberValue<Integer> failRate;
private final NumberValue<Float> failFactor;
private final NumberValue<Float> strengthHorizontalMin;
private final NumberValue<Float> strengthHorizontalMax;
private final NumberValue<Float> strengthVerticalMin;
private final NumberValue<Float> strengthVerticalMax;
private final NumberValue<Integer> transitionMin;
private final NumberValue<Integer> transitionMax;
private final BooleanSupplier unsafeWindow;
private int ticksElapsed = Integer.MAX_VALUE;
private int currentTransitionDuration = 1;
private int cooldownTicks;
private Rotation shiftRotation = ZERO_SHIFT;
private Rotation appliedShift = ZERO_SHIFT;
public FailRotationProcessor(BooleanValue enabled,
NumberValue<Integer> failRate,
NumberValue<Float> failFactor,
NumberValue<Float> strengthHorizontalMin,
NumberValue<Float> strengthHorizontalMax,
NumberValue<Float> strengthVerticalMin,
NumberValue<Float> strengthVerticalMax,
NumberValue<Integer> transitionMin,
NumberValue<Integer> transitionMax) {
this(enabled, failRate, failFactor, strengthHorizontalMin, strengthHorizontalMax,
strengthVerticalMin, strengthVerticalMax, transitionMin, transitionMax, () -> false);
}
public FailRotationProcessor(BooleanValue enabled,
NumberValue<Integer> failRate,
NumberValue<Float> failFactor,
NumberValue<Float> strengthHorizontalMin,
NumberValue<Float> strengthHorizontalMax,
NumberValue<Float> strengthVerticalMin,
NumberValue<Float> strengthVerticalMax,
NumberValue<Integer> transitionMin,
NumberValue<Integer> transitionMax,
BooleanSupplier unsafeWindow) {
this.enabled = enabled;
this.failRate = failRate;
this.failFactor = failFactor;
this.strengthHorizontalMin = strengthHorizontalMin;
this.strengthHorizontalMax = strengthHorizontalMax;
this.strengthVerticalMin = strengthVerticalMin;
this.strengthVerticalMax = strengthVerticalMax;
this.transitionMin = transitionMin;
this.transitionMax = transitionMax;
this.unsafeWindow = unsafeWindow != null ? unsafeWindow : () -> false;
}
private static Rotation stepTowards(Rotation current, Rotation target, float maxStep) {
return new Rotation(
step(current.yaw(), target.yaw(), maxStep),
step(current.pitch(), target.pitch(), maxStep)
);
}
private static float step(float current, float target, float maxStep) {
float diff = target - current;
if (Math.abs(diff) <= maxStep) {
return target;
}
return current + Math.copySign(maxStep, diff);
}
private static boolean isZero(Rotation rotation) {
return Math.abs(rotation.yaw()) < 0.001f && Math.abs(rotation.pitch()) < 0.001f;
}
private static int random(int min, int max) {
if (max <= min) return min;
return ThreadLocalRandom.current().nextInt(min, max + 1);
}
private static float randomRange(float a, float b) {
float min = Math.min(a, b);
float max = Math.max(a, b);
if (max <= min) return min;
return (float) ThreadLocalRandom.current().nextDouble(min, max);
}
public void tick() {
if (!enabled.get()) {
resetState();
return;
}
if (cooldownTicks > 0) {
cooldownTicks--;
}
boolean unsafe = unsafeWindow.getAsBoolean();
if (unsafe) {
cooldownTicks = Math.max(cooldownTicks, UNSAFE_COOLDOWN_TICKS);
if (isInFailState() && ticksElapsed < currentTransitionDuration / 2) {
ticksElapsed = currentTransitionDuration / 2;
}
}
if (isInFailState()) {
ticksElapsed++;
if (!isInFailState()) {
cooldownTicks = Math.max(cooldownTicks, UNSAFE_COOLDOWN_TICKS);
}
return;
}
if (unsafe || cooldownTicks > 0) {
return;
}
int chance = ThreadLocalRandom.current().nextInt(0, 101);
if (failRate.get() > chance) {
currentTransitionDuration = Math.max(MIN_TRANSITION_TICKS, random(transitionMin.get(), transitionMax.get()) * 2);
float yawShift = randomRange(strengthHorizontalMin.get(), strengthHorizontalMax.get());
if (ThreadLocalRandom.current().nextBoolean()) yawShift = -yawShift;
float pitchShift = randomRange(strengthVerticalMin.get(), strengthVerticalMax.get());
if (ThreadLocalRandom.current().nextBoolean()) pitchShift = -pitchShift;
shiftRotation = new Rotation(yawShift, pitchShift);
ticksElapsed = 0;
}
}
public boolean isInFailState() {
return enabled.get() && ticksElapsed < currentTransitionDuration;
}
@EventHandler(priority = 1000)
public void onGameTick(GameTickEvent event) {
tick();
}
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
if (!enabled.get() && isZero(appliedShift)) {
return targetRotation;
}
Rotation desiredShift = ZERO_SHIFT;
if (enabled.get()
&& isInFailState()
&& !unsafeWindow.getAsBoolean()
&& currentRotation.angleTo(targetRotation) <= 8.0f) {
Rotation prevRotation = RotationManager.INSTANCE.getPreviousRotation();
if (prevRotation != null) {
Rotation serverRotation = RotationManager.INSTANCE.getServerRotation();
float deltaYaw = (prevRotation.yaw() - serverRotation.yaw()) * failFactor.get();
float deltaPitch = (prevRotation.pitch() - serverRotation.pitch()) * failFactor.get();
float pulse = pulse();
desiredShift = new Rotation(
(deltaYaw + shiftRotation.yaw()) * pulse,
(deltaPitch + shiftRotation.pitch()) * pulse
);
}
}
appliedShift = stepTowards(appliedShift, desiredShift, MAX_SHIFT_STEP);
if (isZero(appliedShift)) {
return targetRotation;
}
return new Rotation(
targetRotation.yaw() + appliedShift.yaw(),
targetRotation.pitch() + appliedShift.pitch()
);
}
private float pulse() {
if (currentTransitionDuration <= 0) {
return 0.0f;
}
float progress = Math.min(1.0f, Math.max(0.0f, (ticksElapsed + 1.0f) / currentTransitionDuration));
float sine = (float) Math.sin(progress * Math.PI);
return sine * sine;
}
private void resetState() {
ticksElapsed = Integer.MAX_VALUE;
cooldownTicks = 0;
shiftRotation = ZERO_SHIFT;
appliedShift = ZERO_SHIFT;
}
}

View file

@ -0,0 +1,16 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
public interface RotationProcessor {
Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation);
}

View file

@ -0,0 +1,30 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.anglesmooth;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
public abstract class AngleSmooth implements RotationProcessor {
private final String name;
protected AngleSmooth(String name) {
this.name = name;
}
public String name() {
return name;
}
public abstract int calculateTicks(Rotation currentRotation, Rotation targetRotation);
@Override
public abstract Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation);
}

View file

@ -0,0 +1,48 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.anglesmooth;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
public abstract class FactorAngleSmooth extends AngleSmooth implements RotationProcessor {
protected FactorAngleSmooth(String name) {
super(name);
}
protected abstract float[] calculateFactors(RotationTarget rotationTarget,
Rotation currentRotation,
Rotation targetRotation);
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
float[] factors = calculateFactors(rotationTarget, currentRotation, targetRotation);
Rotation stepped = currentRotation.towardsLinear(targetRotation, factors[0], factors[1]);
float gcd = (float) RotationUtil.gcd();
float snapTolerance = Math.max(gcd * 2.0f, 0.5f);
if (stepped.angleTo(targetRotation) <= snapTolerance) {
return targetRotation;
}
return stepped;
}
@Override
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
Rotation rot = currentRotation;
int ticks = -1;
do {
float[] factors = calculateFactors(null, rot, targetRotation);
rot = rot.towardsLinear(targetRotation, factors[0], factors[1]);
ticks++;
} while (!rot.approximatelyEquals(targetRotation) && ticks < 80);
return ticks;
}
}

View file

@ -0,0 +1,28 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.anglesmooth;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
public final class NoneAngleSmooth extends AngleSmooth {
public NoneAngleSmooth() {
super("None");
}
@Override
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
return 0;
}
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
return currentRotation;
}
}

View file

@ -0,0 +1,234 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.anglesmooth.impl;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.data.RotationDelta;
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.AngleSmooth;
import dev.loki.lovisual.util.raycast.RaycastUtil;
import java.util.concurrent.ThreadLocalRandom;
public final class AccelerationAngleSmooth extends AngleSmooth {
// Dynamic acceleration
public final BooleanValue dynamicAccelEnabled;
public final NumberValue<Float> coefDistance;
// Acceleration error
public final BooleanValue accelErrorEnabled;
// Constant error
public final BooleanValue constantErrorEnabled;
// Sigmoid deceleration
public final BooleanValue sigmoidDecelEnabled;
private final NumberValue<Float> yawAccelerationMin;
private final NumberValue<Float> yawAccelerationMax;
private final NumberValue<Float> pitchAccelerationMin;
private final NumberValue<Float> pitchAccelerationMax;
private final NumberValue<Float> yawCrosshairMin;
private final NumberValue<Float> yawCrosshairMax;
private final NumberValue<Float> pitchCrosshairMin;
private final NumberValue<Float> pitchCrosshairMax;
private final NumberValue<Float> yawAccelError;
private final NumberValue<Float> pitchAccelError;
private final NumberValue<Float> yawConstantError;
private final NumberValue<Float> pitchConstantError;
private final NumberValue<Float> sigmoidSteepness;
private final NumberValue<Float> sigmoidMidpoint;
public AccelerationAngleSmooth(
NumberValue<Float> yawAccelerationMin,
NumberValue<Float> yawAccelerationMax,
NumberValue<Float> pitchAccelerationMin,
NumberValue<Float> pitchAccelerationMax,
BooleanValue dynamicAccelEnabled,
NumberValue<Float> coefDistance,
NumberValue<Float> yawCrosshairMin,
NumberValue<Float> yawCrosshairMax,
NumberValue<Float> pitchCrosshairMin,
NumberValue<Float> pitchCrosshairMax,
BooleanValue accelErrorEnabled,
NumberValue<Float> yawAccelError,
NumberValue<Float> pitchAccelError,
BooleanValue constantErrorEnabled,
NumberValue<Float> yawConstantError,
NumberValue<Float> pitchConstantError,
BooleanValue sigmoidDecelEnabled,
NumberValue<Float> sigmoidSteepness,
NumberValue<Float> sigmoidMidpoint
) {
super("Acceleration");
this.yawAccelerationMin = yawAccelerationMin;
this.yawAccelerationMax = yawAccelerationMax;
this.pitchAccelerationMin = pitchAccelerationMin;
this.pitchAccelerationMax = pitchAccelerationMax;
this.dynamicAccelEnabled = dynamicAccelEnabled;
this.coefDistance = coefDistance;
this.yawCrosshairMin = yawCrosshairMin;
this.yawCrosshairMax = yawCrosshairMax;
this.pitchCrosshairMin = pitchCrosshairMin;
this.pitchCrosshairMax = pitchCrosshairMax;
this.accelErrorEnabled = accelErrorEnabled;
this.yawAccelError = yawAccelError;
this.pitchAccelError = pitchAccelError;
this.constantErrorEnabled = constantErrorEnabled;
this.yawConstantError = yawConstantError;
this.pitchConstantError = pitchConstantError;
this.sigmoidDecelEnabled = sigmoidDecelEnabled;
this.sigmoidSteepness = sigmoidSteepness;
this.sigmoidMidpoint = sigmoidMidpoint;
}
private static float calculateAcceleration(float diff, float prevDiff,
float low, float high,
float decel) {
float delta = RotationUtil.angleDifference(diff, prevDiff);
float clamped = clamp(delta, low, high);
return clamped * decel;
}
private static float clamp(float v, float low, float high) {
if (v < low) return low;
if (v > high) return high;
return v;
}
private static float randomRange(float a, float b) {
float min = Math.min(a, b);
float max = Math.max(a, b);
if (max <= min) return min;
return (float) ThreadLocalRandom.current().nextDouble(min, max);
}
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
Rotation prevRotation = RotationManager.INSTANCE.getPreviousRotation();
if (prevRotation == null) {
prevRotation = RotationManager.getPlayerLastRotation();
}
RotationDelta prevDiff = prevRotation.rotationDeltaTo(currentRotation);
RotationDelta diff = currentRotation.rotationDeltaTo(targetRotation);
double distance = 0.0;
boolean crosshair = false;
if (rotationTarget != null && rotationTarget.entity != null) {
distance = RotationManager.boxedDistanceToPlayer(rotationTarget.entity);
crosshair = RaycastUtil.isLookingAtEntity(
RotationManager.player(),
rotationTarget.entity,
currentRotation.yaw(),
currentRotation.pitch(),
Math.max(3.0, distance),
0.0
) != null;
}
float[] newDiff = computeTurnSpeed(prevDiff, diff, crosshair, distance);
return new Rotation(currentRotation.yaw() + newDiff[0], currentRotation.pitch() + newDiff[1]);
}
@Override
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
Rotation prevRotation = RotationManager.INSTANCE.getPreviousRotation();
if (prevRotation == null) {
prevRotation = RotationManager.getPlayerLastRotation();
}
RotationDelta prevDiff = prevRotation.rotationDeltaTo(currentRotation);
RotationDelta diff = currentRotation.rotationDeltaTo(targetRotation);
if (diff.deltaYaw() == 0f && diff.deltaPitch() == 0f) {
return 0;
}
float[] newDiff = computeTurnSpeed(prevDiff, diff, false, 0.0);
if ((newDiff[0] == 0f && newDiff[1] == 0f) ||
(Math.abs(diff.deltaYaw()) < Math.abs(newDiff[0]) &&
Math.abs(diff.deltaPitch()) < Math.abs(newDiff[1]))) {
return 0;
}
double ticksH = Math.floor(Math.abs(diff.deltaYaw()) / Math.abs(newDiff[0]));
double ticksV = Math.floor(Math.abs(diff.deltaPitch()) / Math.abs(newDiff[1]));
if (Double.isNaN(ticksH) || Double.isNaN(ticksV)) return 0;
return (int) Math.max(ticksH, ticksV);
}
private float[] computeTurnSpeed(RotationDelta prevDiff,
RotationDelta diff,
boolean crosshair,
double distance) {
float decelerationFactor = 1.0f;
if (sigmoidDecelEnabled.get()) {
decelerationFactor = computeDecelerationFactor(diff.length());
}
boolean crosshairCheck = dynamicAccelEnabled.get() && crosshair;
float distanceFactor = (coefDistance.get() * (float) distance);
float yawAccel = randomRange(yawAccelerationMin.get(), yawAccelerationMax.get());
float pitchAccel = randomRange(pitchAccelerationMin.get(), pitchAccelerationMax.get());
if (crosshairCheck) {
yawAccel = randomRange(yawCrosshairMin.get(), yawCrosshairMax.get());
pitchAccel = randomRange(pitchCrosshairMin.get(), pitchCrosshairMax.get());
}
float yawLow = -yawAccel + distanceFactor;
float yawHigh = yawAccel + distanceFactor;
float pitchLow = -pitchAccel + distanceFactor;
float pitchHigh = pitchAccel + distanceFactor;
float yawA = calculateAcceleration(diff.deltaYaw(), prevDiff.deltaYaw(),
yawLow, yawHigh, decelerationFactor);
float pitchA = calculateAcceleration(diff.deltaPitch(), prevDiff.deltaPitch(),
pitchLow, pitchHigh, decelerationFactor);
float[] errors = getErrors(yawA, pitchA);
return new float[]{
prevDiff.deltaYaw() + yawA + errors[0],
prevDiff.deltaPitch() + pitchA + errors[1]
};
}
private float computeDecelerationFactor(float rotationDifference) {
float scaled = rotationDifference / 120f;
double sigmoid = 1.0 / (1.0 + Math.exp(-(sigmoidSteepness.get() * (scaled - sigmoidMidpoint.get()))));
float out = (float) sigmoid;
if (out < 0f) out = 0f;
if (out > 180f) out = 180f;
return out;
}
private float[] getErrors(float yawAccel, float pitchAccel) {
float yawErr = 0f;
float pitchErr = 0f;
if (accelErrorEnabled.get()) {
float yawAcc = yawAccelError.get();
float pitchAcc = pitchAccelError.get();
yawErr += yawAccel * randomRange(-yawAcc, yawAcc);
pitchErr += pitchAccel * randomRange(-pitchAcc, pitchAcc);
}
if (constantErrorEnabled.get()) {
float yawConst = yawConstantError.get();
float pitchConst = pitchConstantError.get();
yawErr += randomRange(-yawConst, yawConst);
pitchErr += randomRange(-pitchConst, pitchConst);
}
return new float[]{yawErr, pitchErr};
}
}

View file

@ -0,0 +1,104 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.anglesmooth.impl;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.FactorAngleSmooth;
import java.util.concurrent.ThreadLocalRandom;
public final class InterpolationAngleSmooth extends FactorAngleSmooth {
private final NumberValue<Integer> horizontalMin;
private final NumberValue<Integer> horizontalMax;
private final NumberValue<Integer> verticalMin;
private final NumberValue<Integer> verticalMax;
private final NumberValue<Integer> directionChangeMin;
private final NumberValue<Integer> directionChangeMax;
private final NumberValue<Float> midpoint;
public InterpolationAngleSmooth(NumberValue<Integer> horizontalMin,
NumberValue<Integer> horizontalMax,
NumberValue<Integer> verticalMin,
NumberValue<Integer> verticalMax,
NumberValue<Integer> directionChangeMin,
NumberValue<Integer> directionChangeMax,
NumberValue<Float> midpoint) {
super("Interpolation");
this.horizontalMin = horizontalMin;
this.horizontalMax = horizontalMax;
this.verticalMin = verticalMin;
this.verticalMax = verticalMax;
this.directionChangeMin = directionChangeMin;
this.directionChangeMax = directionChangeMax;
this.midpoint = midpoint;
}
private static float calculateFactor(float rotationDifference, float turnSpeed, float directionChange,
float midpoint) {
float t = clamp01(rotationDifference / 180f);
float bezierSpeed = bezierTransform(0.05f, 1f, 1f - t);
float sigmoidSpeed = sigmoidTransform(t);
if (t > midpoint) {
return bezierSpeed * turnSpeed;
}
return sigmoidSpeed * clamp01(turnSpeed + directionChange);
}
private static float sigmoidTransform(float t) {
return (float) (1f / (1f + Math.exp(-0.5f * (t - 0.3f))));
}
private static float bezierTransform(float start, float end, float t) {
return (1f - t) * (1f - t) * start + 2f * (1f - t) * t * 1f + t * t * end;
}
private static float clamp01(float v) {
return Math.max(0f, Math.min(1f, v));
}
private static float random(int min, int max) {
if (max <= min) return min;
return (float) ThreadLocalRandom.current().nextInt(min, max + 1);
}
@Override
protected float[] calculateFactors(RotationTarget rotationTarget,
Rotation currentRotation,
Rotation targetRotation) {
float yawDiff = RotationUtil.wrapDegrees(targetRotation.yaw() - currentRotation.yaw());
float pitchDiff = RotationUtil.wrapDegrees(targetRotation.pitch() - currentRotation.pitch());
float directionChange = 0f;
RotationTarget prev = RotationManager.INSTANCE.getPreviousRotationTarget();
if (rotationTarget != null && prev != null) {
int dMin = Math.min(directionChangeMin.get(), directionChangeMax.get());
int dMax = Math.max(directionChangeMin.get(), directionChangeMax.get());
float factor = random(dMin, dMax) / 100.0f;
float diff = prev.rotation.angleTo(targetRotation);
directionChange = clamp01(diff) * factor;
}
int hMin = Math.min(horizontalMin.get(), horizontalMax.get());
int hMax = Math.max(horizontalMin.get(), horizontalMax.get());
int vMin = Math.min(verticalMin.get(), verticalMax.get());
int vMax = Math.max(verticalMin.get(), verticalMax.get());
float horizontalSpeed = (rotationTarget != null ? random(hMin, hMax) : hMin) / 100.0f;
float verticalSpeed = (rotationTarget != null ? random(vMin, vMax) : vMin) / 100.0f;
float hFactor = calculateFactor(Math.abs(yawDiff), clamp01(horizontalSpeed), directionChange, midpoint.get());
float vFactor = calculateFactor(Math.abs(pitchDiff), clamp01(verticalSpeed), directionChange, midpoint.get());
return new float[]{hFactor * Math.abs(yawDiff), vFactor * Math.abs(pitchDiff)};
}
}

View file

@ -0,0 +1,107 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.anglesmooth.impl;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.FactorAngleSmooth;
import java.util.concurrent.ThreadLocalRandom;
public final class LinearAngleSmooth extends FactorAngleSmooth {
private final NumberValue<Float> horizontalMin;
private final NumberValue<Float> horizontalMax;
private final NumberValue<Float> verticalMin;
private final NumberValue<Float> verticalMax;
private float currentHorizontalFactor = -1.0f;
private float currentVerticalFactor = -1.0f;
private float targetHorizontalFactor = -1.0f;
private float targetVerticalFactor = -1.0f;
private int trackedEntityId = Integer.MIN_VALUE;
public LinearAngleSmooth(NumberValue<Float> horizontalMin,
NumberValue<Float> horizontalMax,
NumberValue<Float> verticalMin,
NumberValue<Float> verticalMax) {
super("Linear");
this.horizontalMin = horizontalMin;
this.horizontalMax = horizontalMax;
this.verticalMin = verticalMin;
this.verticalMax = verticalMax;
}
private static float pickFactor(float min, float max, float angle) {
if (max <= min) return min;
float normalized = Math.max(0.0f, Math.min(1.0f, angle / 90.0f));
float center = lerp(min, max, normalized);
float spread = Math.max(1.0f, (max - min) * 0.12f);
return clamp(random(center - spread, center + spread), min, max);
}
private static float lerp(float from, float to, float factor) {
return from + (to - from) * factor;
}
private static float clamp(float value, float min, float max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
private static float random(float min, float max) {
if (max <= min) return min;
return (float) (ThreadLocalRandom.current().nextDouble(min, max));
}
@Override
protected float[] calculateFactors(RotationTarget rotationTarget,
Rotation currentRotation,
Rotation targetRotation) {
float hMin = Math.min(horizontalMin.get(), horizontalMax.get());
float hMax = Math.max(horizontalMin.get(), horizontalMax.get());
float vMin = Math.min(verticalMin.get(), verticalMax.get());
float vMax = Math.max(verticalMin.get(), verticalMax.get());
if (rotationTarget == null) {
resetState();
return new float[]{hMin, vMin};
}
int entityId = rotationTarget.entity != null ? rotationTarget.entity.getId() : Integer.MIN_VALUE;
if (trackedEntityId != entityId) {
trackedEntityId = entityId;
currentHorizontalFactor = random(hMin, hMax);
currentVerticalFactor = random(vMin, vMax);
targetHorizontalFactor = currentHorizontalFactor;
targetVerticalFactor = currentVerticalFactor;
}
float angle = currentRotation.angleTo(targetRotation);
if (Math.abs(currentHorizontalFactor - targetHorizontalFactor) < 0.5f) {
targetHorizontalFactor = pickFactor(hMin, hMax, angle);
}
if (Math.abs(currentVerticalFactor - targetVerticalFactor) < 0.5f) {
targetVerticalFactor = pickFactor(vMin, vMax, angle);
}
float blend = angle > 45.0f ? 0.35f : angle > 12.0f ? 0.22f : 0.14f;
currentHorizontalFactor = lerp(currentHorizontalFactor, targetHorizontalFactor, blend);
currentVerticalFactor = lerp(currentVerticalFactor, targetVerticalFactor, blend);
return new float[]{currentHorizontalFactor, currentVerticalFactor};
}
private void resetState() {
trackedEntityId = Integer.MIN_VALUE;
currentHorizontalFactor = -1.0f;
currentVerticalFactor = -1.0f;
targetHorizontalFactor = -1.0f;
targetVerticalFactor = -1.0f;
}
}

View file

@ -0,0 +1,73 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.anglesmooth.impl.curves;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.FactorAngleSmooth;
import java.util.concurrent.ThreadLocalRandom;
public final class SigmoidAngleSmooth extends FactorAngleSmooth {
private final NumberValue<Float> horizontalMin;
private final NumberValue<Float> horizontalMax;
private final NumberValue<Float> verticalMin;
private final NumberValue<Float> verticalMax;
private final NumberValue<Float> steepness;
private final NumberValue<Float> midpoint;
public SigmoidAngleSmooth(NumberValue<Float> horizontalMin,
NumberValue<Float> horizontalMax,
NumberValue<Float> verticalMin,
NumberValue<Float> verticalMax,
NumberValue<Float> steepness,
NumberValue<Float> midpoint) {
super("Sigmoid");
this.horizontalMin = horizontalMin;
this.horizontalMax = horizontalMax;
this.verticalMin = verticalMin;
this.verticalMax = verticalMax;
this.steepness = steepness;
this.midpoint = midpoint;
}
private static float random(float min, float max) {
if (max <= min) return min;
return (float) (ThreadLocalRandom.current().nextDouble(min, max));
}
@Override
protected float[] calculateFactors(RotationTarget rotationTarget,
Rotation currentRotation,
Rotation targetRotation) {
float rotationDifference = currentRotation.angleTo(targetRotation);
float hMin = Math.min(horizontalMin.get(), horizontalMax.get());
float hMax = Math.max(horizontalMin.get(), horizontalMax.get());
float vMin = Math.min(verticalMin.get(), verticalMax.get());
float vMax = Math.max(verticalMin.get(), verticalMax.get());
float hSpeed = rotationTarget != null ? random(hMin, hMax) : hMin;
float vSpeed = rotationTarget != null ? random(vMin, vMax) : vMin;
float hFactor = computeFactor(rotationDifference, hSpeed);
float vFactor = computeFactor(rotationDifference, vSpeed);
return new float[]{hFactor, vFactor};
}
private float computeFactor(float rotationDifference, float turnSpeed) {
float scaled = rotationDifference / 120f;
double sigmoid = 1.0 / (1.0 + Math.exp(-steepness.get() * (scaled - midpoint.get())));
float interpolated = (float) (sigmoid * turnSpeed);
if (interpolated < 0f) interpolated = 0f;
if (interpolated > 180f) interpolated = 180f;
return interpolated;
}
}

View file

@ -0,0 +1,172 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.anglesmooth.impl.curves;
import net.minecraft.util.Mth;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.AngleSmooth;
import java.util.concurrent.ThreadLocalRandom;
/**
* Practical hybrid smooth with distance-aware speed and optional micro-jitter.
* Designed to reach target reliably while keeping motion less robotic.
*/
public final class SmartAngleSmooth extends AngleSmooth {
private final NumberValue<Float> yawMin;
private final NumberValue<Float> yawMax;
private final NumberValue<Float> pitchMin;
private final NumberValue<Float> pitchMax;
private final NumberValue<Float> snapThreshold;
private final NumberValue<Float> jitterYaw;
private final NumberValue<Float> jitterPitch;
private final BooleanValue decelerateEnabled;
private final NumberValue<Float> decelerateAngle;
private final NumberValue<Float> decelerateMinFactor;
private int trackedEntityId = Integer.MIN_VALUE;
private Rotation virtualRotation;
private boolean wasResetting;
private int resetTicks;
public SmartAngleSmooth(NumberValue<Float> yawMin,
NumberValue<Float> yawMax,
NumberValue<Float> pitchMin,
NumberValue<Float> pitchMax,
NumberValue<Float> snapThreshold,
NumberValue<Float> jitterYaw,
NumberValue<Float> jitterPitch,
BooleanValue decelerateEnabled,
NumberValue<Float> decelerateAngle,
NumberValue<Float> decelerateMinFactor) {
super("Smart");
this.yawMin = yawMin;
this.yawMax = yawMax;
this.pitchMin = pitchMin;
this.pitchMax = pitchMax;
this.snapThreshold = snapThreshold;
this.jitterYaw = jitterYaw;
this.jitterPitch = jitterPitch;
this.decelerateEnabled = decelerateEnabled;
this.decelerateAngle = decelerateAngle;
this.decelerateMinFactor = decelerateMinFactor;
}
private static float smoothStep(float t) {
return t * t * (3.0f - 2.0f * t);
}
private static float randomInRange(float a, float b) {
float min = Math.min(a, b);
float max = Math.max(a, b);
if (max <= min) {
return min;
}
return (float) ThreadLocalRandom.current().nextDouble(min, max);
}
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
boolean resetting = rotationTarget == null || rotationTarget.entity == null;
int entityId = rotationTarget != null && rotationTarget.entity != null
? rotationTarget.entity.getId()
: Integer.MIN_VALUE;
if (entityId != trackedEntityId || virtualRotation == null || resetting != wasResetting) {
trackedEntityId = entityId;
virtualRotation = currentRotation;
resetTicks = 0;
}
wasResetting = resetting;
Rotation baseRotation = virtualRotation != null ? virtualRotation : currentRotation;
float angle = baseRotation.angleTo(targetRotation);
float finishThreshold = resetting
? Mth.clamp(snapThreshold.get() * 0.28f, 0.12f, 0.45f)
: Math.max(0.05f, snapThreshold.get());
if (angle <= finishThreshold) {
virtualRotation = targetRotation;
return targetRotation;
}
float yawFactor = randomInRange(yawMin.get(), yawMax.get());
float pitchFactor = randomInRange(pitchMin.get(), pitchMax.get());
float distanceScale = 1.0f;
if (rotationTarget != null && rotationTarget.entity != null) {
double distance = RotationManager.boxedDistanceToPlayer(rotationTarget.entity);
distanceScale = Mth.clamp((float) (0.80 + distance * 0.15), 0.80f, 1.55f);
}
float motionScale = resetting ? computeResetScale(angle) : computeDecelerateScale(angle);
Rotation stepped = baseRotation.towardsLinear(
targetRotation,
yawFactor * distanceScale * motionScale,
pitchFactor * distanceScale * motionScale
);
float jitterScale = Mth.clamp(angle / 30.0f, 0.0f, 1.0f);
if (resetting) {
jitterScale *= Mth.clamp(angle / 18.0f, 0.0f, 0.35f);
resetTicks++;
}
if (jitterScale > 0.0f) {
float phase = (float) (System.currentTimeMillis() * 0.015);
int jitterEntityId = rotationTarget != null && rotationTarget.entity != null ? rotationTarget.entity.getId() : 0;
float seed = jitterEntityId * 0.137f;
float yawOffset = (float) Math.sin(phase + seed) * jitterYaw.get() * jitterScale;
float pitchOffset = (float) Math.cos(phase * 1.11f + seed) * jitterPitch.get() * jitterScale;
Rotation output = new Rotation(
stepped.yaw() + yawOffset,
Mth.clamp(stepped.pitch() + pitchOffset, -90.0f, 90.0f),
false
);
virtualRotation = output;
return output;
}
virtualRotation = stepped;
return stepped;
}
@Override
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
Rotation rot = currentRotation;
int ticks = 0;
while (!rot.approximatelyEquals(targetRotation) && ticks < 80) {
float yawFactor = (Math.min(yawMin.get(), yawMax.get()) + Math.max(yawMin.get(), yawMax.get())) * 0.5f;
float pitchFactor = (Math.min(pitchMin.get(), pitchMax.get()) + Math.max(pitchMin.get(), pitchMax.get())) * 0.5f;
float angle = rot.angleTo(targetRotation);
float decelerateScale = computeDecelerateScale(angle);
rot = rot.towardsLinear(targetRotation, yawFactor * decelerateScale, pitchFactor * decelerateScale);
ticks++;
}
return ticks;
}
private float computeResetScale(float angle) {
float ramp = Mth.clamp((resetTicks + 1.0f) / 4.0f, 0.0f, 1.0f);
float accel = 0.38f + 0.62f * smoothStep(ramp);
return Mth.clamp(computeDecelerateScale(angle) * accel, 0.08f, 1.0f);
}
private float computeDecelerateScale(float angle) {
if (!decelerateEnabled.get()) {
return 1.0f;
}
float start = Math.max(0.05f, decelerateAngle.get());
float min = Mth.clamp(decelerateMinFactor.get(), 0.05f, 1.0f);
float t = Mth.clamp(angle / start, 0.0f, 1.0f);
float smooth = smoothStep(t);
return min + (1.0f - min) * smooth;
}
}

View file

@ -0,0 +1,205 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.anglesmooth.impl.curves;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.AngleSmooth;
import dev.loki.lovisual.util.click.AttackPressing;
import dev.loki.lovisual.util.raycast.RaycastUtil;
import java.util.concurrent.ThreadLocalRandom;
public final class SpookyAngleSmooth extends AngleSmooth {
private float currentJitterYaw = 0.0f;
private float currentJitterPitch = 0.0f;
private float targetJitterYaw = 0.0f;
private float targetJitterPitch = 0.0f;
private float circlePhase = 0.0f;
private float circleRadius = 0.0f;
private float targetCircleRadius = 0.0f;
private float currentSpeed = 0.0f;
private int trackedEntityId = Integer.MIN_VALUE;
private Rotation virtualRotation;
public SpookyAngleSmooth() {
super("Spooky");
}
private static boolean isAttackWindow() {
Minecraft mc = Minecraft.getInstance();
return mc.player != null && mc.player.getAttackStrengthScale(0.0f) >= 0.95f;
}
private static Vec3 hitbox(Entity entity, float xDiv, float yDiv, float zDiv, float widthDiv) {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) {
return entity.getBoundingBox().getCenter();
}
double wHalf = entity.getBbWidth() / Math.max(1.0f, widthDiv);
double yExpand = Mth.clamp(entity.getEyeY() - entity.getY(), 0.0, entity.getBbHeight());
double xExpand = Mth.clamp(mc.player.getX() - entity.getX(), -wHalf, wHalf);
double zExpand = Mth.clamp(mc.player.getZ() - entity.getZ(), -wHalf, wHalf);
return new Vec3(
entity.getX() + xExpand / Math.max(0.001f, xDiv),
entity.getY() + yExpand / Math.max(0.001f, yDiv),
entity.getZ() + zExpand / Math.max(0.001f, zDiv)
);
}
private static boolean isLookingAtHitbox(Entity entity, Rotation currentRotation) {
if (entity == null) {
return false;
}
return RaycastUtil.isLookingAtEntity(
RotationManager.player(),
entity,
currentRotation.yaw(),
currentRotation.pitch(),
4.0,
0.0
) != null;
}
private static float random(float a, float b) {
float min = Math.min(a, b);
float max = Math.max(a, b);
if (max <= min) {
return min;
}
return (float) ThreadLocalRandom.current().nextDouble(min, max);
}
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
Entity entity = rotationTarget != null ? rotationTarget.entity : null;
Minecraft mc = Minecraft.getInstance();
int entityId = entity != null ? entity.getId() : Integer.MIN_VALUE;
if (entityId != trackedEntityId || virtualRotation == null) {
trackedEntityId = entityId;
virtualRotation = currentRotation;
}
Rotation baseRotation = virtualRotation != null ? virtualRotation : currentRotation;
boolean canAttack = entity != null && isAttackWindow();
boolean lookingAtHitbox = !canAttack && isLookingAtHitbox(entity, baseRotation);
if (mc.player != null && entity != null && canAttack) {
Vec3 aimPoint = hitbox(entity, 1.0f, entity.onGround() ? 1.0f : 1.256f, 1.0f, 2.0f);
targetRotation = Rotation.lookingAt(aimPoint, mc.player.getEyePosition());
}
float yawDelta = Mth.wrapDegrees(targetRotation.yaw() - baseRotation.yaw());
float pitchDelta = Mth.wrapDegrees(targetRotation.pitch() - baseRotation.pitch());
float rotationDifference = (float) Math.hypot(Math.abs(yawDelta), Math.abs(pitchDelta));
if (rotationDifference < 0.01f) {
rotationDifference = 1.0f;
}
circlePhase += 0.75f * random(7.5f, 12.5f);
if (circlePhase > Math.PI * 2) {
circlePhase -= (float) (Math.PI * 2);
}
if (canAttack) {
targetCircleRadius = random(0.5f, 4.5f);
} else if (lookingAtHitbox) {
targetCircleRadius = 12.0f;
} else {
targetCircleRadius = random(8.0f, 12.0f);
}
circleRadius += (targetCircleRadius - circleRadius) * 0.18f;
float circleYaw = (float) (Math.cos(circlePhase) * circleRadius);
float circlePitch = (float) (Math.sin(circlePhase * 11.3f) * circleRadius * 0.4f);
long elapsedMs = AttackPressing.INSTANCE.lastClickPassed();
int count = AttackPressing.INSTANCE.clickCount();
float timeRandom = elapsedMs / 100.0f + (count % 5);
int pattern = count % 4;
float sinA = (float) Math.sin(timeRandom);
float cosA = (float) Math.cos(timeRandom);
float sinB = (float) Math.sin(timeRandom * 2.2f);
float cosB = (float) Math.cos(timeRandom * 0.6f);
float sinC = (float) Math.sin(timeRandom * 2.1f);
float cosC = (float) Math.cos(timeRandom * 0.5f);
float patternYaw = switch (pattern) {
case 0 -> cosA;
case 1 -> sinB;
case 2 -> sinA;
default -> -cosC;
};
float patternPitch = switch (pattern) {
case 0 -> sinA;
case 1 -> cosB;
case 2 -> -cosA;
default -> sinC;
};
float jitterMultiplier = canAttack ? 0.5f : (lookingAtHitbox ? 0.6f : 1.0f);
targetJitterYaw = random(32.0f, 35.0f) * patternYaw * jitterMultiplier;
targetJitterPitch = random(2.0f, 5.0f) * patternPitch * jitterMultiplier;
currentJitterYaw += (targetJitterYaw - currentJitterYaw) * 0.15f;
currentJitterPitch += (targetJitterPitch - currentJitterPitch) * 0.15f;
float targetSpeed;
if (canAttack) {
targetSpeed = 1.0f;
} else if (lookingAtHitbox) {
targetSpeed = random(0.15f, 0.35f);
} else if (entity != null) {
float distanceFactor = Mth.clamp(rotationDifference / 30.0f, 0.1f, 1.0f);
targetSpeed = random(0.25f, 0.45f) * distanceFactor;
} else {
targetSpeed = AttackPressing.INSTANCE.lastClickPassed() > 600L ? random(0.2f, 0.35f) : 0.53f;
}
currentSpeed += (targetSpeed - currentSpeed) * 0.65f;
float lineYaw = Math.abs(yawDelta / rotationDifference) * 180.0f;
float linePitch = Math.abs(pitchDelta / rotationDifference) * 90.0f;
float moveYaw = Mth.clamp(yawDelta, -lineYaw, lineYaw);
float movePitch = Mth.clamp(pitchDelta, -linePitch, linePitch);
float totalJitterYaw = currentJitterYaw + circleYaw;
float totalJitterPitch = currentJitterPitch + circlePitch;
if (entity == null && AttackPressing.INSTANCE.lastClickPassed() > 800L) {
totalJitterYaw *= 0.3f;
totalJitterPitch *= 0.3f;
}
float newYaw = Mth.lerp(currentSpeed, baseRotation.yaw(), baseRotation.yaw() + moveYaw) + totalJitterYaw;
float newPitch = Mth.lerp(currentSpeed, baseRotation.pitch(), baseRotation.pitch() + movePitch) + totalJitterPitch;
Rotation output = new Rotation(newYaw, Mth.clamp(newPitch, -90.0f, 90.0f), false);
virtualRotation = output;
return output;
}
@Override
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
Rotation rot = currentRotation;
int ticks = 0;
while (!rot.approximatelyEquals(targetRotation) && ticks < 80) {
rot = rot.towardsLinear(targetRotation, 90.0f, 65.0f);
ticks++;
}
return ticks;
}
}

View file

@ -0,0 +1,36 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.impl;
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
public final class LazyAimProcessor implements RotationProcessor {
private final NumberValue<Float> threshold;
private final NumberValue<Float> followFactor;
public LazyAimProcessor(NumberValue<Float> threshold, NumberValue<Float> followFactor) {
this.threshold = threshold;
this.followFactor = followFactor;
}
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
float angle = currentRotation.angleTo(targetRotation);
if (angle <= threshold.get()) {
return currentRotation;
}
float factor = Math.max(0.1f, followFactor.get()) * Math.max(angle, 1.0f);
return currentRotation.towardsLinear(targetRotation, factor, factor);
}
}

View file

@ -0,0 +1,54 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.impl;
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
import dev.loki.lovisual.util.aiming.data.Rotation;
/**
* Adds conservative per-tick rotation limits while moving to avoid sharp
* silent-yaw snaps that anti-cheats can flag more easily than pure input mismatch.
*/
public final class MovingRotationProcessor implements RotationProcessor {
private static final double MIN_HORIZONTAL_SPEED = 0.045;
private static final float MAX_MOVING_YAW_STEP = 7.5f;
private static final float MAX_MOVING_PITCH_STEP = 10.0f;
private static final float MAX_MOVEMENT_YAW_OFFSET = 82.0f;
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc == null ? null : mc.player;
if (player == null || currentRotation == null || targetRotation == null) {
return targetRotation;
}
Vec3 velocity = player.getDeltaMovement();
double horizontalSpeedSq = velocity.x * velocity.x + velocity.z * velocity.z;
if (horizontalSpeedSq < MIN_HORIZONTAL_SPEED * MIN_HORIZONTAL_SPEED) {
return targetRotation;
}
float movementYaw = (float) Math.toDegrees(Math.atan2(velocity.z, velocity.x)) - 90.0f;
movementYaw = Mth.wrapDegrees(movementYaw);
float movementDelta = RotationUtil.angleDifference(targetRotation.yaw(), movementYaw);
float clampedYaw = movementYaw + Mth.clamp(movementDelta, -MAX_MOVEMENT_YAW_OFFSET, MAX_MOVEMENT_YAW_OFFSET);
Rotation softenedTarget = new Rotation(clampedYaw, targetRotation.pitch(), false);
return currentRotation.towardsLinear(softenedTarget, MAX_MOVING_YAW_STEP, MAX_MOVING_PITCH_STEP);
}
}

View file

@ -0,0 +1,46 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.impl;
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
import net.minecraft.client.Minecraft;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
public final class ShakeAimProcessor implements RotationProcessor {
private final NumberValue<Float> yawAmplitude;
private final NumberValue<Float> pitchAmplitude;
private final NumberValue<Float> speed;
public ShakeAimProcessor(NumberValue<Float> yawAmplitude,
NumberValue<Float> pitchAmplitude,
NumberValue<Float> speed) {
this.yawAmplitude = yawAmplitude;
this.pitchAmplitude = pitchAmplitude;
this.speed = speed;
}
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) {
return targetRotation;
}
float phase = mc.player.tickCount * Math.max(0.01f, speed.get());
int entityId = rotationTarget != null && rotationTarget.entity != null ? rotationTarget.entity.getId() : 0;
float seed = entityId * 0.173f;
float yawOffset = (float) Math.sin(phase + seed) * yawAmplitude.get();
float pitchOffset = (float) Math.cos(phase * 1.17f + seed) * pitchAmplitude.get();
return new Rotation(targetRotation.yaw() + yawOffset, targetRotation.pitch() + pitchOffset);
}
}

View file

@ -0,0 +1,41 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.features.processors.impl;
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
import dev.loki.lovisual.util.aiming.data.Rotation;
public final class SnapAssistAimProcessor implements RotationProcessor {
private final NumberValue<Float> snapThreshold;
private final NumberValue<Float> snapFactor;
public SnapAssistAimProcessor(NumberValue<Float> snapThreshold, NumberValue<Float> snapFactor) {
this.snapThreshold = snapThreshold;
this.snapFactor = snapFactor;
}
@Override
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
float angle = currentRotation.angleTo(targetRotation);
if (angle <= snapThreshold.get()) {
return targetRotation;
}
float rampWindow = Math.max(snapThreshold.get() * 2.0f, snapThreshold.get() + 1.0f);
if (angle > rampWindow) {
return targetRotation;
}
float factor = Math.max(1.0f, snapFactor.get()) * angle;
return currentRotation.towardsLinear(targetRotation, factor, factor);
}
}

View file

@ -0,0 +1,27 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.point;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
public record PointInsideBox(Vec3 pos, AABB box) {
public PointInsideBox {
pos = clampToBox(pos, box);
}
private static Vec3 clampToBox(Vec3 pos, AABB box) {
return new Vec3(
Mth.clamp(pos.x, box.minX, box.maxX),
Mth.clamp(pos.y, box.minY, box.maxY),
Mth.clamp(pos.z, box.minZ, box.maxZ)
);
}
}

View file

@ -0,0 +1,582 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.point;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.entity.simulation.PositionExtrapolation;
import dev.loki.lovisual.util.raycast.RaycastUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
public final class PointTracker {
private int trackedEntityId = -1;
private PointInsideBox delayedPoint;
private int currentDelay = randomInt(2, 4);
private PointInsideBox lazyPoint;
private double currentLazyThreshold;
private Vec3 currentOffset = Vec3.ZERO;
private Vec3 targetOffset = Vec3.ZERO;
private Vec3 smoothedPoint;
private Vec3 lastSelectedPoint;
private Vec3 lastEyes;
private Vec3 lastAimDirection;
private Vec3 instabilityOffset = Vec3.ZERO;
private Vec3 targetInstabilityOffset = Vec3.ZERO;
private int instabilityRetargetTicks;
private boolean delayEnabled = true;
private int delayMin = 2;
private int delayMax = 4;
private boolean lazyEnabled = true;
private double lazyMin = 0.10;
private double lazyMax = 0.20;
private boolean gaussianEnabled;
private double gaussianYawFactor;
private double gaussianPitchFactor;
private int gaussianChance = 100;
private double gaussianSpeed = 0.15;
private static float scoreCandidate(Vec3 eyes,
Rotation initialRotation,
Rotation candidateRotation,
Vec3 point,
Vec3 center,
AABB box,
Vec3 aimDir,
Vec3 movement,
double movementStrength) {
float rotationScore = initialRotation.angleTo(candidateRotation) * 1.15f;
double halfX = Math.max(1.0E-4, box.getXsize() * 0.5);
double halfY = Math.max(1.0E-4, box.getYsize() * 0.5);
double halfZ = Math.max(1.0E-4, box.getZsize() * 0.5);
double nx = Math.abs(point.x - center.x) / halfX;
double ny = Math.abs(point.y - center.y) / halfY;
double nz = Math.abs(point.z - center.z) / halfZ;
double edgeFactor = Math.max(Math.max(nx, nz), ny * 0.65);
double centerPenalty = (1.0 - Mth.clamp(edgeFactor, 0.0, 1.0))
* (movementStrength > 0.035 ? 6.0 : 4.0);
double rayDistance = distanceToRay(eyes, aimDir, point);
double rayScale = Math.max(0.08, Math.max(box.getXsize(), Math.max(box.getYsize(), box.getZsize())) * 0.5);
double rayScore = (rayDistance / rayScale) * (movementStrength > 0.035 ? 5.5 : 4.0);
double movementBenefit = movingPointBenefit(point, center, box, movement, movementStrength);
return rotationScore + (float) rayScore + (float) centerPenalty - (float) movementBenefit;
}
private static List<Candidate> filterDiverse(List<Candidate> candidates, Vec3 reference, AABB box, boolean movingTarget) {
List<Candidate> out = new ArrayList<>();
if (reference == null) {
return out;
}
double minAxis = Math.min(box.getXsize(), Math.min(box.getYsize(), box.getZsize()));
double minDistance = Math.max(movingTarget ? 0.085 : 0.055, minAxis * (movingTarget ? 0.26 : 0.18));
double minDistanceSq = minDistance * minDistance;
for (Candidate candidate : candidates) {
if (candidate.point().distanceToSqr(reference) >= minDistanceSq) {
out.add(candidate);
}
}
return out;
}
private static Candidate selectWeighted(List<Candidate> candidates, float bestScore) {
if (candidates.size() == 1) {
return candidates.get(0);
}
double total = 0.0;
double[] weights = new double[candidates.size()];
for (int i = 0; i < candidates.size(); i++) {
double delta = Math.max(0.0, candidates.get(i).score() - bestScore);
double weight = 1.0 / (1.0 + delta * delta * 0.08);
weights[i] = weight;
total += weight;
}
double pick = ThreadLocalRandom.current().nextDouble(total);
for (int i = 0; i < candidates.size(); i++) {
pick -= weights[i];
if (pick <= 0.0) {
return candidates.get(i);
}
}
return candidates.get(candidates.size() - 1);
}
private static double movingPointBenefit(Vec3 point, Vec3 center, AABB box, Vec3 movement, double movementStrength) {
if (movementStrength <= 0.035) {
return 0.0;
}
double halfX = Math.max(1.0E-4, box.getXsize() * 0.5);
double halfZ = Math.max(1.0E-4, box.getZsize() * 0.5);
double dirX = movement.x / movementStrength;
double dirZ = movement.z / movementStrength;
double offX = Mth.clamp((point.x - center.x) / halfX, -1.0, 1.0);
double offZ = Mth.clamp((point.z - center.z) / halfZ, -1.0, 1.0);
double lead = Mth.clamp(offX * dirX + offZ * dirZ, -1.0, 1.0);
double sideStrength = Math.min(1.0, Math.hypot(offX, offZ));
return Math.max(0.0, lead) * 4.0 + sideStrength * 1.4;
}
private static double distanceToRay(Vec3 origin, Vec3 direction, Vec3 point) {
Vec3 diff = point.subtract(origin);
double t = Math.max(0.0, diff.dot(direction));
Vec3 closest = origin.add(direction.scale(t));
return point.distanceTo(closest);
}
private static Vec3 findRayHitPoint(Vec3 eyes, Vec3 aimDir, AABB box, double maxDistance) {
double range = maxDistance > 0.0 && maxDistance < Double.MAX_VALUE ? maxDistance : 6.0;
return box.clip(eyes, eyes.add(aimDir.scale(range))).orElse(null);
}
private static double horizontalLength(Vec3 vec) {
if (vec == null) {
return 0.0;
}
return Math.hypot(vec.x, vec.z);
}
private static boolean isValidPoint(Vec3 startPoint,
Vec3 endPoint,
double maxDistance,
boolean ignoreWalls) {
if (startPoint == null || endPoint == null) {
return false;
}
if (maxDistance > 0.0 && startPoint.distanceToSqr(endPoint) > maxDistance * maxDistance) {
return false;
}
return ignoreWalls || RaycastUtil.hasLineOfSightPoint(startPoint, endPoint);
}
private static Vec3 randomInstabilityOffset(AABB box, Vec3 entityVelocity, Vec3 aimDirection, double intensity) {
double halfX = Math.max(0.001, box.getXsize() * 0.5);
double halfY = Math.max(0.001, box.getYsize() * 0.5);
double halfZ = Math.max(0.001, box.getZsize() * 0.5);
double speed = horizontalLength(entityVelocity);
double sideX;
double sideZ;
if (speed > 0.035) {
sideX = entityVelocity.x / speed;
sideZ = entityVelocity.z / speed;
} else {
Vec3 horizontalAim = new Vec3(aimDirection.x, 0.0, aimDirection.z);
double aimLen = Math.max(1.0E-4, horizontalLength(horizontalAim));
sideX = -horizontalAim.z / aimLen;
sideZ = horizontalAim.x / aimLen;
}
double lateral = randomDouble(-1.0, 1.0) * (0.14 + 0.22 * intensity);
double lead = speed > 0.035 ? randomDouble(0.05, 0.24) * intensity : 0.0;
double vertical = randomDouble(-0.16, 0.14) * intensity;
return new Vec3(
sideX * halfX * lateral + sideX * halfX * lead,
halfY * vertical,
sideZ * halfZ * lateral + sideZ * halfZ * lead
);
}
private static Vec3 clampToBox(Vec3 point, AABB box) {
return new Vec3(
Mth.clamp(point.x, box.minX, box.maxX),
Mth.clamp(point.y, box.minY, box.maxY),
Mth.clamp(point.z, box.minZ, box.maxZ)
);
}
private static Vec3 getNearestPoint(AABB box, Vec3 eyes) {
return new Vec3(
Mth.clamp(eyes.x, box.minX, box.maxX),
Mth.clamp(eyes.y, box.minY, box.maxY),
Mth.clamp(eyes.z, box.minZ, box.maxZ)
);
}
private static List<Vec3> projectPointsOnBox(Vec3 eyes, AABB box) {
List<Vec3> points = new ArrayList<>();
double[] steps = proportions();
if (!box.contains(eyes)) {
for (double y : steps) {
for (double z : steps) {
points.add(new Vec3(box.minX, box.minY + box.getYsize() * y, box.minZ + box.getZsize() * z));
points.add(new Vec3(box.maxX, box.minY + box.getYsize() * y, box.minZ + box.getZsize() * z));
}
}
for (double x : steps) {
for (double z : steps) {
points.add(new Vec3(box.minX + box.getXsize() * x, box.minY, box.minZ + box.getZsize() * z));
points.add(new Vec3(box.minX + box.getXsize() * x, box.maxY, box.minZ + box.getZsize() * z));
}
}
for (double x : steps) {
for (double y : steps) {
points.add(new Vec3(box.minX + box.getXsize() * x, box.minY + box.getYsize() * y, box.minZ));
points.add(new Vec3(box.minX + box.getXsize() * x, box.minY + box.getYsize() * y, box.maxZ));
}
}
return points;
}
for (double x : steps) {
for (double y : steps) {
for (double z : steps) {
points.add(new Vec3(
box.minX + box.getXsize() * x,
box.minY + box.getYsize() * y,
box.minZ + box.getZsize() * z
));
}
}
}
return points;
}
private static int randomInt(int min, int max) {
if (max <= min) return min;
return ThreadLocalRandom.current().nextInt(min, max + 1);
}
private static double randomDouble(double min, double max) {
if (max <= min) return min;
return ThreadLocalRandom.current().nextDouble(min, max);
}
private static double[] proportions() {
double[] steps = new double[13];
for (int i = 0; i < steps.length; i++) {
steps[i] = (i + 0.5) / steps.length;
}
return steps;
}
public void setOptions(boolean delayEnabled,
int delayMin,
int delayMax,
boolean lazyEnabled,
double lazyMin,
double lazyMax,
boolean gaussianEnabled,
double gaussianYawFactor,
double gaussianPitchFactor,
int gaussianChance,
double gaussianSpeed) {
this.delayEnabled = delayEnabled;
this.delayMin = Math.max(0, Math.min(delayMin, delayMax));
this.delayMax = Math.max(this.delayMin, delayMax);
this.lazyEnabled = lazyEnabled;
this.lazyMin = Math.max(0.0, Math.min(lazyMin, lazyMax));
this.lazyMax = Math.max(this.lazyMin, lazyMax);
this.gaussianEnabled = gaussianEnabled;
this.gaussianYawFactor = Math.max(0.0, gaussianYawFactor);
this.gaussianPitchFactor = Math.max(0.0, gaussianPitchFactor);
this.gaussianChance = Math.max(0, Math.min(100, gaussianChance));
this.gaussianSpeed = Math.max(0.01, Math.min(1.0, gaussianSpeed));
}
public PointInsideBox findPoint(Vec3 eyes,
Entity entity,
int ticks,
Rotation initialRotation,
double maxDistance,
boolean ignoreWalls) {
AABB box = PositionExtrapolation.getBestForEntity(entity).getBoxInTicks(ticks);
List<Vec3> points = projectPointsOnBox(eyes, box);
if (trackedEntityId != entity.getId()) {
resetProcessors();
trackedEntityId = entity.getId();
}
Vec3 bestPoint = selectMultiPoint(eyes, entity, box, initialRotation, maxDistance, ignoreWalls);
if (bestPoint == null) {
Vec3 anchor = smoothedPoint != null ? clampToBox(smoothedPoint, box) : eyes;
bestPoint = points.isEmpty() ? getNearestPoint(box, anchor) : points.get(0);
for (Vec3 point : points) {
if (point.distanceToSqr(anchor) < bestPoint.distanceToSqr(anchor)) {
bestPoint = point;
}
}
}
PointInsideBox point = new PointInsideBox(clampToBox(bestPoint, box), box);
point = applyDelay(point);
point = applyLazy(point);
point = applyGaussian(point);
point = applyMotionInstability(point, eyes, entity, initialRotation);
point = applyContinuity(point);
smoothedPoint = point.pos();
return new PointInsideBox(clampToBox(point.pos(), box), box);
}
public PointInsideBox findPoint(Vec3 eyes, Entity entity, int ticks, Rotation initialRotation) {
return findPoint(eyes, entity, ticks, initialRotation, Double.MAX_VALUE, true);
}
private Vec3 selectMultiPoint(Vec3 eyes,
Entity entity,
AABB box,
Rotation initialRotation,
double maxDistance,
boolean ignoreWalls) {
if (eyes == null || box == null || initialRotation == null) {
return null;
}
List<Candidate> candidates = new ArrayList<>();
float bestScore = Float.MAX_VALUE;
Vec3 center = box.getCenter();
Vec3 aimDir = initialRotation.directionVector().normalize();
Vec3 movement = entity != null ? entity.getDeltaMovement() : Vec3.ZERO;
double movementStrength = horizontalLength(movement);
Vec3 rayHit = findRayHitPoint(eyes, aimDir, box, maxDistance);
if (rayHit != null && isValidPoint(eyes, rayHit, maxDistance, ignoreWalls)) {
Rotation rayRotation = Rotation.lookingAt(rayHit, eyes).normalize();
float score = scoreCandidate(eyes, initialRotation, rayRotation, rayHit, center, box,
aimDir, movement, movementStrength) - 2.0f;
candidates.add(new Candidate(rayHit, score));
bestScore = score;
}
for (Vec3 point : projectPointsOnBox(eyes, box)) {
if (!isValidPoint(eyes, point, maxDistance, ignoreWalls)) {
continue;
}
Rotation candidateRotation = Rotation.lookingAt(point, eyes).normalize();
float score = scoreCandidate(eyes, initialRotation, candidateRotation, point, center, box,
aimDir, movement, movementStrength);
candidates.add(new Candidate(point, score));
if (score < bestScore) {
bestScore = score;
}
}
if (candidates.isEmpty()) {
return null;
}
boolean movingTarget = movementStrength > 0.035 || Math.abs(movement.y) > 0.03;
float threshold = movingTarget
? Math.max(7.0f, Math.min(28.0f, bestScore * 0.55f + 6.0f))
: Math.max(4.0f, Math.min(18.0f, bestScore * 0.35f + 3.0f));
List<Candidate> pool = new ArrayList<>();
for (Candidate candidate : candidates) {
if (candidate.score() <= bestScore + threshold) {
pool.add(candidate);
}
}
if (pool.isEmpty()) {
pool = candidates;
}
Vec3 reference = lastSelectedPoint != null ? lastSelectedPoint : smoothedPoint;
List<Candidate> diversePool = filterDiverse(pool, reference, box, movingTarget);
if (!diversePool.isEmpty()) {
pool = diversePool;
}
Candidate selected = selectWeighted(pool, bestScore);
lastSelectedPoint = selected.point();
return selected.point();
}
public void reset() {
trackedEntityId = -1;
resetProcessors();
}
private void resetProcessors() {
delayedPoint = null;
currentDelay = randomInt(delayMin, delayMax);
lazyPoint = null;
currentLazyThreshold = 0.0;
currentOffset = Vec3.ZERO;
targetOffset = Vec3.ZERO;
smoothedPoint = null;
lastSelectedPoint = null;
lastEyes = null;
lastAimDirection = null;
instabilityOffset = Vec3.ZERO;
targetInstabilityOffset = Vec3.ZERO;
instabilityRetargetTicks = 0;
}
private PointInsideBox applyDelay(PointInsideBox point) {
if (!delayEnabled) {
delayedPoint = point;
currentDelay = 0;
return point;
}
if (delayedPoint != null) {
delayedPoint = new PointInsideBox(clampToBox(delayedPoint.pos(), point.box()), point.box());
}
if (point.equals(delayedPoint)) {
return point;
}
if (delayedPoint == null) {
delayedPoint = point;
currentDelay = randomInt(delayMin, delayMax);
return point;
}
PointInsideBox currentPoint = delayedPoint;
currentDelay--;
if (currentDelay > 0) {
return currentPoint;
}
delayedPoint = point;
currentDelay = randomInt(delayMin, delayMax);
return currentPoint;
}
private PointInsideBox applyLazy(PointInsideBox point) {
if (!lazyEnabled) {
lazyPoint = point;
currentLazyThreshold = 0.0;
return point;
}
if (lazyPoint != null) {
lazyPoint = new PointInsideBox(clampToBox(lazyPoint.pos(), point.box()), point.box());
}
if (lazyPoint == null) {
lazyPoint = point;
currentLazyThreshold = randomDouble(lazyMin, lazyMax);
return point;
}
PointInsideBox currentPoint = lazyPoint;
double thresholdSq = currentLazyThreshold * currentLazyThreshold;
if (point.pos().distanceToSqr(currentPoint.pos()) < thresholdSq) {
return currentPoint;
}
lazyPoint = point;
currentLazyThreshold = randomDouble(lazyMin, lazyMax);
return currentPoint;
}
private PointInsideBox applyGaussian(PointInsideBox point) {
if (!gaussianEnabled || (gaussianYawFactor <= 0.0 && gaussianPitchFactor <= 0.0)) {
currentOffset = Vec3.ZERO;
targetOffset = Vec3.ZERO;
return point;
}
if (hasReachedOffsetTarget()) {
if (ThreadLocalRandom.current().nextInt(100) < gaussianChance) {
targetOffset = new Vec3(
ThreadLocalRandom.current().nextGaussian() * gaussianYawFactor,
ThreadLocalRandom.current().nextGaussian() * gaussianPitchFactor,
ThreadLocalRandom.current().nextGaussian() * gaussianYawFactor
);
} else {
targetOffset = Vec3.ZERO;
}
} else {
currentOffset = new Vec3(
Mth.lerp(gaussianSpeed, currentOffset.x, targetOffset.x),
Mth.lerp(gaussianSpeed, currentOffset.y, targetOffset.y),
Mth.lerp(gaussianSpeed, currentOffset.z, targetOffset.z)
);
}
return new PointInsideBox(point.pos().add(currentOffset), point.box());
}
private PointInsideBox applyMotionInstability(PointInsideBox point,
Vec3 eyes,
Entity entity,
Rotation initialRotation) {
Vec3 aimDirection = initialRotation.directionVector().normalize();
double angleChange = lastAimDirection != null
? Math.toDegrees(Math.acos(Mth.clamp(lastAimDirection.dot(aimDirection), -1.0, 1.0)))
: 0.0;
double playerMove = lastEyes != null ? eyes.distanceTo(lastEyes) : 0.0;
Vec3 entityVelocity = entity != null ? entity.getDeltaMovement() : Vec3.ZERO;
double entityMove = horizontalLength(entityVelocity) + Math.abs(entityVelocity.y) * 0.45;
lastEyes = eyes;
lastAimDirection = aimDirection;
double intensity = Mth.clamp(angleChange / 14.0 + playerMove / 0.14 + entityMove / 0.11, 0.0, 1.0);
if (intensity < 0.12) {
instabilityOffset = instabilityOffset.scale(0.72);
targetInstabilityOffset = targetInstabilityOffset.scale(0.72);
return new PointInsideBox(clampToBox(point.pos().add(instabilityOffset), point.box()), point.box());
}
instabilityRetargetTicks--;
if (instabilityRetargetTicks <= 0 || instabilityOffset.distanceToSqr(targetInstabilityOffset) < 1.0E-4) {
targetInstabilityOffset = randomInstabilityOffset(point.box(), entityVelocity, aimDirection, intensity);
instabilityRetargetTicks = randomInt(2, intensity > 0.65 ? 4 : 6);
}
double blend = 0.14 + 0.18 * intensity;
instabilityOffset = new Vec3(
Mth.lerp(blend, instabilityOffset.x, targetInstabilityOffset.x),
Mth.lerp(blend, instabilityOffset.y, targetInstabilityOffset.y),
Mth.lerp(blend, instabilityOffset.z, targetInstabilityOffset.z)
);
return new PointInsideBox(clampToBox(point.pos().add(instabilityOffset), point.box()), point.box());
}
private PointInsideBox applyContinuity(PointInsideBox point) {
if (smoothedPoint == null) {
return point;
}
Vec3 current = clampToBox(smoothedPoint, point.box());
Vec3 target = clampToBox(point.pos(), point.box());
double distance = current.distanceTo(target);
if (distance <= 1.0E-4) {
return new PointInsideBox(current, point.box());
}
double blend = distance > 0.35 ? 0.42 : distance > 0.15 ? 0.28 : 0.18;
Vec3 blended = new Vec3(
Mth.lerp(blend, current.x, target.x),
Mth.lerp(blend, current.y, target.y),
Mth.lerp(blend, current.z, target.z)
);
return new PointInsideBox(blended, point.box());
}
private boolean hasReachedOffsetTarget() {
return Math.abs(currentOffset.x - targetOffset.x) < 0.01
&& Math.abs(currentOffset.y - targetOffset.y) < 0.01
&& Math.abs(currentOffset.z - targetOffset.z) < 0.01;
}
private record Candidate(Vec3 point, float score) {
}
}

View file

@ -0,0 +1,76 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.preference;
import net.minecraft.client.Minecraft;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
import dev.loki.lovisual.util.aiming.data.Rotation;
import java.util.Objects;
import java.util.function.Supplier;
public final class LeastDifferencePreference implements RotationPreference {
public static final LeastDifferencePreference LEAST_DISTANCE_TO_CURRENT_ROTATION =
new LeastDifferencePreference(LeastDifferencePreference::currentRotation);
private final Supplier<Rotation> baseSupplier;
private final Vec3 basePoint;
public LeastDifferencePreference(Rotation baseRotation) {
this(() -> baseRotation, null);
}
private LeastDifferencePreference(Supplier<Rotation> supplier) {
this(supplier, null);
}
private LeastDifferencePreference(Supplier<Rotation> supplier, Vec3 basePoint) {
this.baseSupplier = Objects.requireNonNull(supplier, "supplier");
this.basePoint = basePoint;
}
public static RotationPreference leastDifferenceToLastPoint(Vec3 eyes, Vec3 lastPoint) {
if (eyes == null || lastPoint == null) {
return LEAST_DISTANCE_TO_CURRENT_ROTATION;
}
return new LeastDifferencePreference(() -> Rotation.lookingAt(lastPoint, eyes), lastPoint);
}
private static Rotation currentRotation() {
Rotation current = RotationManager.INSTANCE.getCurrentRotation();
if (current != null) {
return current;
}
Minecraft mc = Minecraft.getInstance();
if (mc != null && mc.player != null) {
return new Rotation(mc.player.getYRot(), mc.player.getXRot(), false).normalize();
}
return Rotation.ZERO;
}
@Override
public int compare(Rotation a, Rotation b) {
Rotation base = baseSupplier.get();
return Float.compare(base.angleTo(a), base.angleTo(b));
}
@Override
public Vec3 getPreferredSpotOnBox(AABB box, Vec3 eyes, double range) {
if (basePoint != null) {
return basePoint;
}
Rotation base = baseSupplier.get();
Vec3 dir = RotationUtil.getRotationVector(base.pitch(), base.yaw()).normalize();
return eyes.add(dir.scale(range));
}
}

View file

@ -0,0 +1,19 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.preference;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.aiming.data.Rotation;
import java.util.Comparator;
public interface RotationPreference extends Comparator<Rotation> {
Vec3 getPreferredSpotOnBox(AABB box, Vec3 eyes, double range);
}

View file

@ -0,0 +1,225 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.raytrace;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.data.RotationWithVector;
import dev.loki.lovisual.util.aiming.preference.LeastDifferencePreference;
import dev.loki.lovisual.util.aiming.preference.RotationPreference;
import dev.loki.lovisual.util.raycast.RaycastUtil;
import java.util.Optional;
import java.util.function.Consumer;
public enum RotationRaytrace {
;
public static final VisibilityPredicate OUTLINE_VISIBILITY =
(eyes, target) -> RaycastUtil.hasLineOfSightPoint(eyes, target);
public static RotationWithVector raytraceBox(
Vec3 eyes,
AABB box,
double range,
double wallsRange
) {
return raytraceBox(eyes, box, range, wallsRange, OUTLINE_VISIBILITY,
LeastDifferencePreference.LEAST_DISTANCE_TO_CURRENT_ROTATION, true);
}
public static RotationWithVector raytraceBox(
Vec3 eyes,
AABB box,
double range,
double wallsRange,
VisibilityPredicate visibilityPredicate,
RotationPreference rotationPreference,
boolean prioritizeVisible
) {
if (eyes == null || box == null) return null;
final VisibilityPredicate vis =
visibilityPredicate == null ? OUTLINE_VISIBILITY : visibilityPredicate;
final RotationPreference pref =
rotationPreference == null ? LeastDifferencePreference.LEAST_DISTANCE_TO_CURRENT_ROTATION : rotationPreference;
double rangeSq = range * range;
double wallsRangeSq = wallsRange * wallsRange;
Vec3 preferredSpot = pref.getPreferredSpotOnBox(box, eyes, range);
Vec3 preferredSpotOnBox = firstHit(box, eyes, preferredSpot);
if (preferredSpotOnBox != null) {
double preferredDist = eyes.distanceToSqr(preferredSpotOnBox);
boolean visible = vis.isVisible(eyes, preferredSpotOnBox);
if (preferredDist < wallsRangeSq || (visible && preferredDist < rangeSq)) {
Rotation rot = Rotation.lookingAt(preferredSpot, eyes);
return new RotationWithVector(rot, preferredSpot);
}
}
BestRotationTracker tracker = new BestRotationTracker(pref, !prioritizeVisible);
Vec3 nearestSpot = getNearestPoint(box, eyes);
tracker.considerSpot(preferredSpot, box, eyes, vis, rangeSq, wallsRangeSq, nearestSpot);
scanBoxPoints(eyes, box, spot -> tracker.considerSpot(
spot, box, eyes, vis, rangeSq, wallsRangeSq, spot
));
return tracker.bestVisible != null ? tracker.bestVisible : tracker.bestInvisible;
}
public static boolean canSeeBox(Vec3 eyes, AABB box, double range, double wallsRange) {
if (eyes == null || box == null) return false;
if (box.contains(eyes)) return true;
double rangeSq = range * range;
double wallsRangeSq = wallsRange * wallsRange;
final boolean[] ok = {false};
scanBoxPoints(eyes, box, pos -> {
double dist = eyes.distanceToSqr(pos);
if (dist > rangeSq) return;
boolean visible = RaycastUtil.hasLineOfSightPoint(eyes, pos);
if (!visible && dist > wallsRangeSq) return;
ok[0] = true;
});
return ok[0];
}
private static Vec3 firstHit(AABB box, Vec3 from, Vec3 to) {
Optional<Vec3> hit = box.clip(from, to);
return hit.orElse(null);
}
private static Vec3 getNearestPoint(AABB box, Vec3 eyes) {
double x = Mth.clamp(eyes.x, box.minX, box.maxX);
double y = Mth.clamp(eyes.y, box.minY, box.maxY);
double z = Mth.clamp(eyes.z, box.minZ, box.maxZ);
return new Vec3(x, y, z);
}
private static void scanBoxPoints(Vec3 eyes, AABB box, Consumer<Vec3> fn) {
boolean outside = projectPointsOnBox(eyes, box, 256, fn);
if (!outside) {
double[] steps = proportions();
for (double x : steps) {
for (double y : steps) {
for (double z : steps) {
Vec3 vec3 = new Vec3(
box.minX + box.getXsize() * x,
box.minY + box.getYsize() * y,
box.minZ + box.getZsize() * z
);
fn.accept(vec3);
}
}
}
}
}
private static boolean projectPointsOnBox(Vec3 eyes, AABB box, int maxPoints, Consumer<Vec3> fn) {
boolean outside = !box.contains(eyes);
if (!outside) return false;
double[] steps = proportions();
int count = 0;
// Faces: X-min/max
for (double y : steps) {
for (double z : steps) {
fn.accept(new Vec3(box.minX, box.minY + box.getYsize() * y, box.minZ + box.getZsize() * z));
if (++count >= maxPoints) return true;
fn.accept(new Vec3(box.maxX, box.minY + box.getYsize() * y, box.minZ + box.getZsize() * z));
if (++count >= maxPoints) return true;
}
}
// Faces: Y-min/max
for (double x : steps) {
for (double z : steps) {
fn.accept(new Vec3(box.minX + box.getXsize() * x, box.minY, box.minZ + box.getZsize() * z));
if (++count >= maxPoints) return true;
fn.accept(new Vec3(box.minX + box.getXsize() * x, box.maxY, box.minZ + box.getZsize() * z));
if (++count >= maxPoints) return true;
}
}
// Faces: Z-min/max
for (double x : steps) {
for (double y : steps) {
fn.accept(new Vec3(box.minX + box.getXsize() * x, box.minY + box.getYsize() * y, box.minZ));
if (++count >= maxPoints) return true;
fn.accept(new Vec3(box.minX + box.getXsize() * x, box.minY + box.getYsize() * y, box.maxZ));
if (++count >= maxPoints) return true;
}
}
return true;
}
private static double[] proportions() {
double[] steps = new double[10];
double v = 0.05;
for (int i = 0; i < steps.length; i++) {
steps[i] = v;
v += 0.1;
}
return steps;
}
public interface VisibilityPredicate {
boolean isVisible(Vec3 eyesPos, Vec3 targetSpot);
}
private static class BestRotationTracker {
private final RotationPreference comparator;
private final boolean ignoreVisibility;
private RotationWithVector bestVisible;
private RotationWithVector bestInvisible;
private BestRotationTracker(RotationPreference comparator, boolean ignoreVisibility) {
this.comparator = comparator;
this.ignoreVisibility = ignoreVisibility;
}
private void considerRotation(RotationWithVector rotation, boolean visible) {
if (visible || ignoreVisibility) {
if (bestVisible == null || comparator.compare(bestVisible.rotation(), rotation.rotation()) > 0) {
bestVisible = rotation;
}
} else {
if (bestInvisible == null || comparator.compare(bestInvisible.rotation(), rotation.rotation()) > 0) {
bestInvisible = rotation;
}
}
}
private void considerSpot(
Vec3 preferredSpot,
AABB box,
Vec3 eyes,
VisibilityPredicate visibilityPredicate,
double rangeSq,
double wallsRangeSq,
Vec3 spot
) {
Vec3 raycastTarget = preferredSpot.subtract(eyes).scale(2.0).add(eyes);
Vec3 spotOnBox = firstHit(box, eyes, raycastTarget);
if (spotOnBox == null) return;
double distSq = eyes.distanceToSqr(spotOnBox);
boolean visible = visibilityPredicate.isVisible(eyes, spotOnBox);
if ((!visible || distSq >= rangeSq) && distSq >= wallsRangeSq) return;
Rotation rotation = Rotation.lookingAt(spot, eyes);
considerRotation(new RotationWithVector(rotation, spot), visible);
}
}
}

View file

@ -0,0 +1,398 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.rotation;
import dev.loki.lovisual.util.aiming.RequestHandler;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
import net.minecraft.network.protocol.game.ServerboundUseItemPacket;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.events.Events;
import dev.loki.lovisual.events.impl.network.EventSync;
import dev.loki.lovisual.events.impl.network.PacketEvent;
import dev.loki.lovisual.events.impl.world.PlayerVelocityStrafe;
import dev.loki.lovisual.events.impl.input.RotationUpdateEvent;
import dev.loki.lovisual.mixininterface.entity.ILocalPlayer;
import dev.loki.lovisual.mixins.accessors.entity.EntityInvoker;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.features.MovementCorrection;
public final class RotationManager {
public static final RotationManager INSTANCE = new RotationManager();
private final RequestHandler<RotationTarget> rotationTargetHandler = new RequestHandler<>();
private RotationTarget previousRotationTarget;
private Object previousRotationProvider;
private Rotation currentRotation;
private Rotation previousRotation;
private Object currentRotationProvider;
private boolean smoothReturnActive;
private int smoothReturnTicks;
private Rotation actualServerRotation = Rotation.ZERO;
private Rotation theoreticalServerRotation = Rotation.ZERO;
private int lastLifecycleAge = Integer.MIN_VALUE;
private RotationManager() {
}
private static float computeRotationDifference(Rotation a, Rotation b) {
if (a == null || b == null) {
return Float.MAX_VALUE;
}
float yawDiff = Math.abs(RotationUtil.angleDifference(a.yaw(), b.yaw()));
float pitchDiff = Math.abs(a.pitch() - b.pitch());
return (float) Math.hypot(yawDiff, pitchDiff);
}
public static LocalPlayer player() {
return Minecraft.getInstance().player;
}
public static Rotation getPlayerLastRotation() {
LocalPlayer player = player();
if (player instanceof ILocalPlayer access) {
return new Rotation(access.lovisual$getLastYaw(), access.lovisual$getLastPitch(), true);
}
if (player != null) {
return new Rotation(player.getYRot(), player.getXRot(), true);
}
return Rotation.ZERO;
}
public static double boxedDistanceToPlayer(Entity entity) {
LocalPlayer player = player();
if (player == null || entity == null) return 0.0;
Vec3 eyes = player.getEyePosition();
var box = entity.getBoundingBox();
double dx = 0.0;
if (eyes.x < box.minX) dx = box.minX - eyes.x;
else if (eyes.x > box.maxX) dx = eyes.x - box.maxX;
double dy = 0.0;
if (eyes.y < box.minY) dy = box.minY - eyes.y;
else if (eyes.y > box.maxY) dy = eyes.y - box.maxY;
double dz = 0.0;
if (eyes.z < box.minZ) dz = box.minZ - eyes.z;
else if (eyes.z > box.maxZ) dz = eyes.z - box.maxZ;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
public RotationTarget getActiveRotationTarget() {
RotationTarget active = rotationTargetHandler.getActiveRequestValue();
return active != null ? active : previousRotationTarget;
}
public RotationTarget getPreviousRotationTarget() {
return previousRotationTarget;
}
public Rotation getCurrentRotation() {
return currentRotation;
}
private void setCurrentRotation(Rotation rotation) {
setCurrentRotation(rotation, currentRotationProvider);
}
private void setCurrentRotation(Rotation rotation, Object provider) {
if (rotation == null) {
if (currentRotation != null) {
previousRotation = currentRotation;
}
currentRotation = null;
currentRotationProvider = null;
return;
}
previousRotation = currentRotation;
currentRotation = rotation;
currentRotationProvider = provider;
}
public Rotation getPreviousRotation() {
return previousRotation;
}
public Rotation getServerRotation() {
return actualServerRotation;
}
public Rotation getMovementRotation() {
LocalPlayer player = player();
Rotation fallback = player != null
? new Rotation(player.getYRot(), player.getXRot(), true)
: (currentRotation != null ? currentRotation : Rotation.ZERO);
RotationTarget active = getActiveRotationTarget();
if (currentRotation == null || active == null || active.movementCorrection == MovementCorrection.OFF) {
return fallback;
}
return currentRotation;
}
public void setRotationTarget(RotationTarget plan, int priority, Object provider) {
if (plan == null) return;
rotationTargetHandler.request(new RequestHandler.Request<>(plan.ticksUntilReset, priority, provider, plan));
smoothReturnActive = false;
smoothReturnTicks = 0;
}
public void setRotationTarget(RotationTarget plan, int priority) {
setRotationTarget(plan, priority, null);
}
private boolean isRotatingAllowed(RotationTarget target) {
if (!target.considerInventory) return true;
Minecraft mc = Minecraft.getInstance();
if (mc == null) return true;
return !(ClientScreen.current() instanceof AbstractContainerScreen<?>);
}
/**
* Update current rotation to a new rotation step.
*/
public void update() {
LocalPlayer player = player();
if (player == null) {
clear();
return;
}
Rotation playerRotation = new Rotation(player.getYRot(), player.getXRot(), true);
RequestHandler.Request<RotationTarget> activeRequest = rotationTargetHandler.getActiveRequest();
RotationTarget activeRotationTarget = activeRequest != null ? activeRequest.value() : previousRotationTarget;
Object activeProvider = activeRequest != null ? activeRequest.provider() : previousRotationProvider;
if (activeRotationTarget == null) {
if (currentRotation == null || !smoothReturnActive) {
return;
}
float diffToPlayer = computeRotationDifference(currentRotation, playerRotation);
if (diffToPlayer <= 0.5f) {
finishSmoothReturn();
return;
}
float speed = 0.25f + 0.4f * Math.min(1.0f, diffToPlayer / 30.0f);
float yawDiff = RotationUtil.angleDifference(playerRotation.yaw(), currentRotation.yaw());
float newYaw = currentRotation.yaw() + yawDiff * speed;
float newPitch = Mth.lerp(speed, currentRotation.pitch(), playerRotation.pitch());
setCurrentRotation(new Rotation(newYaw, newPitch, false).normalize(), currentRotationProvider);
return;
}
if (isRotatingAllowed(activeRotationTarget)) {
Rotation fromRotation = resolveBaseRotation(playerRotation);
if (activeRequest == null) {
if (currentRotation == null) {
finishSmoothReturn();
return;
}
if (!smoothReturnActive) {
smoothReturnActive = true;
smoothReturnTicks = 0;
}
Rotation resetRotation = activeRotationTarget.towards(fromRotation, true).normalize();
setCurrentRotation(resetRotation, activeProvider);
previousRotationTarget = activeRotationTarget;
previousRotationProvider = activeProvider;
smoothReturnTicks++;
float diffToPlayer = computeRotationDifference(resetRotation, playerRotation);
int maxTicks = Math.max(1, activeRotationTarget.ticksUntilReset);
float threshold = Math.max(0.05f, activeRotationTarget.resetThreshold);
if (diffToPlayer <= threshold || smoothReturnTicks >= maxTicks) {
finishSmoothReturn();
rotationTargetHandler.tick();
return;
}
} else {
Rotation rotation = activeRotationTarget.towards(fromRotation, false).normalize();
setCurrentRotation(rotation, activeProvider);
previousRotationTarget = activeRotationTarget;
previousRotationProvider = activeProvider;
smoothReturnActive = false;
smoothReturnTicks = 0;
if (activeRotationTarget.whenReached != null) {
activeRotationTarget.whenReached.invoke();
}
}
}
rotationTargetHandler.tick();
}
private void finishSmoothReturn() {
setCurrentRotation(null);
previousRotation = null;
previousRotationTarget = null;
previousRotationProvider = null;
smoothReturnActive = false;
smoothReturnTicks = 0;
}
private Rotation resolveBaseRotation(Rotation playerRotation) {
if (currentRotation != null) {
return currentRotation;
}
if (actualServerRotation != null && actualServerRotation != Rotation.ZERO) {
return actualServerRotation;
}
return playerRotation;
}
public void clear() {
rotationTargetHandler.clear();
previousRotationTarget = null;
previousRotationProvider = null;
currentRotation = null;
currentRotationProvider = null;
previousRotation = null;
smoothReturnActive = false;
smoothReturnTicks = 0;
}
public void clear(Object provider) {
if (provider == null) return;
boolean removed = rotationTargetHandler.clear(provider);
boolean ownsVisualState = provider == currentRotationProvider || provider == previousRotationProvider;
if (!removed && !ownsVisualState) {
return;
}
if (currentRotation != null && ownsVisualState) {
smoothReturnActive = true;
smoothReturnTicks = 0;
return;
}
if (ownsVisualState) {
previousRotationTarget = null;
previousRotationProvider = null;
previousRotation = null;
}
smoothReturnActive = false;
smoothReturnTicks = 0;
}
/**
* Stops accepting new rotations from provider, but keeps current/previous state
* so update() can return camera smoothly instead of snapping instantly.
*/
public void release(Object provider) {
release(provider, true);
}
public void release(Object provider, boolean smoothReturn) {
if (provider == null) return;
boolean removed = rotationTargetHandler.clear(provider);
boolean ownsVisualState = provider == currentRotationProvider || provider == previousRotationProvider;
if (!removed && !ownsVisualState) {
return;
}
if (!smoothReturn) {
if (ownsVisualState) {
setCurrentRotation(null);
previousRotationTarget = null;
previousRotationProvider = null;
previousRotation = null;
}
smoothReturnActive = false;
smoothReturnTicks = 0;
return;
}
smoothReturnActive = ownsVisualState && currentRotation != null;
smoothReturnTicks = 0;
}
@EventHandler
public void onSync(EventSync e) {
RotationTarget activeRotationTarget = getActiveRotationTarget();
if (activeRotationTarget == null || currentRotation == null) return;
e.setRotation(currentRotation.yaw(), currentRotation.pitch(), true);
}
@EventHandler(priority = 100)
public void onVelocityStrafe(PlayerVelocityStrafe event) {
RotationTarget active = getActiveRotationTarget();
if (active == null) return;
if (active.movementCorrection == MovementCorrection.OFF) {
return;
}
Rotation rotation = getMovementRotation();
if (rotation == null) return;
Vec3 velocity = EntityInvoker.lovisual$movementInputToVelocity(
event.getMovementInput(),
event.getSpeed(),
rotation.yaw()
);
event.setVelocity(velocity);
}
@EventHandler(priority = -100)
public void onPacketSend(PacketEvent.Send e) {
if (e.isCancelled()) return;
var packet = e.getPacket();
Rotation rot = null;
if (packet instanceof ServerboundMovePlayerPacket move) {
if (!move.hasRotation()) return;
rot = new Rotation(move.getYRot(0.0f), move.getXRot(0.0f), true);
} else if (packet instanceof ServerboundUseItemPacket use) {
rot = new Rotation(use.getYRot(), use.getXRot(), true);
}
if (rot != null) {
actualServerRotation = rot;
theoreticalServerRotation = rot;
}
}
@EventHandler(priority = -100)
public void onPacketReceive(PacketEvent.Receive e) {
if (!(e.getPacket() instanceof net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket packet))
return;
Rotation rot = new Rotation(packet.change().yRot(), packet.change().xRot(), true);
theoreticalServerRotation = rot;
actualServerRotation = rot;
}
public void runTickLifecycle() {
LocalPlayer player = player();
if (player == null) {
lastLifecycleAge = Integer.MIN_VALUE;
return;
}
if (player.tickCount == lastLifecycleAge) {
return;
}
lastLifecycleAge = player.tickCount;
Events.BUS.post(new RotationUpdateEvent(RotationUpdateEvent.Type.PRE));
update();
Events.BUS.post(new RotationUpdateEvent(RotationUpdateEvent.Type.POST));
}
}

View file

@ -0,0 +1,87 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.rotation;
import dev.loki.lovisual.util.aiming.RestrictedSingleUseAction;
import net.minecraft.world.entity.Entity;
import dev.loki.lovisual.util.aiming.data.Rotation;
import dev.loki.lovisual.util.aiming.features.MovementCorrection;
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
import java.util.List;
public final class RotationTarget {
public final Rotation rotation;
public final List<RotationProcessor> processors;
public final int ticksUntilReset;
public final float resetThreshold;
public final boolean considerInventory;
public final MovementCorrection movementCorrection;
public final boolean freeCorrection;
public final RestrictedSingleUseAction whenReached;
public Entity entity;
public RotationTarget(Rotation rotation,
Entity entity,
List<RotationProcessor> processors,
int ticksUntilReset,
float resetThreshold,
boolean considerInventory,
MovementCorrection movementCorrection,
RestrictedSingleUseAction whenReached) {
this(rotation, entity, processors, ticksUntilReset, resetThreshold, considerInventory,
movementCorrection, movementCorrection == MovementCorrection.SILENT, whenReached);
}
public RotationTarget(Rotation rotation,
Entity entity,
List<RotationProcessor> processors,
int ticksUntilReset,
float resetThreshold,
boolean considerInventory,
MovementCorrection movementCorrection,
boolean freeCorrection,
RestrictedSingleUseAction whenReached) {
this.rotation = rotation;
this.entity = entity;
this.processors = processors != null ? processors : List.of();
this.ticksUntilReset = ticksUntilReset;
this.resetThreshold = resetThreshold;
this.considerInventory = considerInventory;
this.movementCorrection = movementCorrection != null ? movementCorrection : MovementCorrection.SILENT;
this.freeCorrection = freeCorrection && this.movementCorrection == MovementCorrection.SILENT;
this.whenReached = whenReached;
}
public Rotation towards(Rotation currentRotation, boolean isResetting) {
if (isResetting) {
this.entity = null;
var player = RotationManager.player();
Rotation base = player != null ? new Rotation(player.getYRot(), player.getXRot(), false) : Rotation.ZERO;
return process(currentRotation, base);
}
return process(currentRotation, rotation);
}
private Rotation process(Rotation currentRotation, Rotation targetRotation) {
if (processors.isEmpty()) {
return targetRotation;
}
Rotation out = targetRotation;
Rotation base = currentRotation;
for (RotationProcessor processor : processors) {
out = processor.process(this, base, out);
base = out;
}
return out;
}
}

View file

@ -0,0 +1,142 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.aiming.rotation;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Input;
import net.minecraft.world.phys.Vec2;
import net.minecraft.world.phys.Vec3;
/**
* Small rotation helpers (yaw/pitch).
*/
public enum RotationUtil {
;
public static double gcd() {
Minecraft mc = Minecraft.getInstance();
if (mc == null) return 0.0;
double sens = mc.options.sensitivity().get();
double f = sens * 0.6 + 0.2;
return f * f * f * 8.0 * 0.15;
}
public static float[] getRotations(Vec3 from, Vec3 to) {
double dx = to.x - from.x;
double dy = to.y - from.y;
double dz = to.z - from.z;
double distXZ = Math.sqrt(dx * dx + dz * dz);
float yaw = (float) (Math.toDegrees(Math.atan2(dz, dx)) - 90.0);
float pitch = (float) (-Math.toDegrees(Math.atan2(dy, distXZ)));
return new float[]{wrapDegrees(yaw), Mth.clamp(wrapDegrees(pitch), -90.0f, 90.0f)};
}
public static float[] getRotationsToEntity(Entity from, Entity target) {
Vec3 src = from.getEyePosition();
Vec3 dst = target.position().add(0.0, target.getBbHeight() * 0.5, 0.0);
if (target instanceof LivingEntity living) {
dst = living.getEyePosition();
}
return getRotations(src, dst);
}
public static float wrapDegrees(float degrees) {
return Mth.wrapDegrees(degrees);
}
public static float angleDifference(float a, float b) {
return wrapDegrees(a - b);
}
public static float directionAngleTo(Entity from, Entity target) {
float[] rot = getRotationsToEntity(from, target);
return Math.abs(angleDifference(from.getYRot(), rot[0]));
}
public static int estimateTicksToReach(float currentYaw, float currentPitch,
float targetYaw, float targetPitch,
float maxTurnPerTick) {
if (maxTurnPerTick <= 0.0f) return 1;
float yawDiff = Math.abs(angleDifference(currentYaw, targetYaw));
float pitchDiff = Math.abs(angleDifference(currentPitch, targetPitch));
float maxDiff = Math.max(yawDiff, pitchDiff);
return Math.max(1, (int) Math.ceil(maxDiff / maxTurnPerTick));
}
public static Vec3 getRotationVector(float pitch, float yaw) {
return Vec3.directionFromRotation(pitch, yaw);
}
public static float withFixedYaw(float currentYaw, float targetYaw) {
return targetYaw + angleDifference(currentYaw, targetYaw);
}
/**
* Rotate movement input from one yaw-space to another (used for silent rotations).
*/
public static Vec2 rotateMovementInput(Vec2 input, float fromYaw, float toYaw) {
Vec2 world = inputToWorld(input, fromYaw);
return worldToInput(world, toYaw);
}
public static Input correctMovementInput(Input input, float fromYaw, float toYaw) {
if (input == null) return null;
float forward = impulse(input.forward(), input.backward());
float sideways = impulse(input.left(), input.right());
if (forward == 0.0f && sideways == 0.0f) {
return input;
}
float deltaYaw = fromYaw - toYaw;
float radians = deltaYaw * Mth.DEG_TO_RAD;
float newX = sideways * Mth.cos(radians) - forward * Mth.sin(radians);
float newZ = forward * Mth.cos(radians) + sideways * Mth.sin(radians);
int movementSideways = Math.round(newX);
int movementForward = Math.round(newZ);
return new Input(
movementForward > 0,
movementForward < 0,
movementSideways > 0,
movementSideways < 0,
input.jump(),
input.shift(),
input.sprint()
);
}
private static float impulse(boolean positive, boolean negative) {
if (positive == negative) return 0.0f;
return positive ? 1.0f : -1.0f;
}
private static Vec2 inputToWorld(Vec2 input, float yaw) {
double rad = Math.toRadians(yaw);
double sin = Math.sin(rad);
double cos = Math.cos(rad);
float worldX = (float) (input.x * cos - input.y * sin);
float worldZ = (float) (input.y * cos + input.x * sin);
return new Vec2(worldX, worldZ);
}
private static Vec2 worldToInput(Vec2 world, float yaw) {
double rad = Math.toRadians(yaw);
double sin = Math.sin(rad);
double cos = Math.cos(rad);
float inputX = (float) (world.x * cos + world.y * sin);
float inputY = (float) (world.y * cos - world.x * sin);
return new Vec2(inputX, inputY);
}
}

View file

@ -0,0 +1,36 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
/**
* Кривые анимации для equip-движений руки. Чистая математика без Minecraft.
*/
public enum EaseCurves {
;
/**
* Ease-out-back: перелетает чуть дальше 1.0 перед установкой, давая движению вес.
*
* @param c1 сила «отдачи» (оригинал — 1.70158)
*/
public static float easeOutBack(float x, float c1) {
float c3 = c1 + 1.0f;
float t = x - 1.0f;
return 1.0f + c3 * t * t * t + c1 * t * t;
}
/**
* Перекладывает линейный [0,1] equip-прогресс через ease-out-back,
* затем подмешивает по intensity: 0 — ванильная линейность, 1 — полная кривая.
*/
public static float shapeEquipProgress(float linear, float c1, float intensity) {
float eased = easeOutBack(linear, c1);
float amount = Math.max(0.0f, Math.min(1.0f, intensity));
return linear + (eased - linear) * amount;
}
}

View file

@ -0,0 +1,34 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
import dev.loki.lovisual.features.module.modules.visuals.camera.ViewModel;
import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems;
/**
* Единая цепочка обработки equip-прогресса руки: сначала кривая HoldMyItems
* (перелёт через ease-out-back на полном диапазоне 0..1), затем занижение
* амплитуды от ViewModel. Порядок важен: кривая должна получать полный
* диапазон, а ViewModel — масштабировать уже готовое движение, иначе
* конечная позиция предмета уезжает.
*/
public enum EquipProgressComposer {
;
public static float compose(float linear,
boolean curveEnabled, float curveC1, float curveIntensity,
boolean lowerEnabled, float lowering) {
float progress = linear;
if (curveEnabled) {
progress = EaseCurves.shapeEquipProgress(progress, curveC1, curveIntensity);
}
if (lowerEnabled) {
progress *= lowering;
}
return progress;
}
}

View file

@ -0,0 +1,119 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
/**
* Frame-rate independent smooth 0..1 transition value driven by a boolean state.
* <p>
* The contract is intentionally strict so animations can never fight each other:
* the owner computes the target once per frame with {@link #setTarget(boolean)}
* and advances the value exactly once per frame with {@link #update(float)}.
* Renderers only read {@link #value()}. The value approaches the target with
* exponential easing {@code value += (target - value) * (1 - exp(-speed * delta))},
* which is monotonic, symmetric for appear/disappear, and identical in wall-clock
* time regardless of frame rate.
* <p>
* This class is pure Java: no Minecraft types, safe to unit test.
*/
public final class SmoothValue {
/**
* When the remaining distance to the target drops below this, the value snaps
* exactly onto the target so it converges in finite time instead of forever
* trailing a microscopic difference.
*/
private static final float CONVERGENCE_EPSILON = 1.0e-4f;
private final float speed;
private float value;
private float target;
/**
* @param speed approach rate in 1/delta units; higher is faster.
* With delta measured in ticks (20 per second), a speed of
* {@code s} needs roughly {@code ln(100) / s} ticks to close 99% of a transition.
*/
public SmoothValue(float speed) {
this(speed, 0.0f);
}
public SmoothValue(float speed, float initialValue) {
if (!isFinite(speed) || speed <= 0.0f) {
throw new IllegalArgumentException("speed must be finite and positive: " + speed);
}
this.speed = speed;
this.value = clamp01(initialValue);
this.target = this.value;
}
/**
* Sets the goal for the next {@link #update(float)} calls: 1 when the animated
* state is active, 0 when it is not. Does not move the value by itself.
*/
public void setTarget(boolean active) {
target = active ? 1.0f : 0.0f;
}
/**
* Advances the value one step toward the target. Call exactly once per frame.
*
* @param delta elapsed time since the previous update, in the same units the
* speed was configured for (ticks in this client). Non-positive
* or broken deltas are ignored.
*/
public void update(float delta) {
if (!isFinite(delta) || delta <= 0.0f) {
return;
}
float factor = 1.0f - (float) Math.exp(-speed * delta);
if (factor <= 0.0f) {
return;
}
value += (target - value) * factor;
value = clamp01(value);
if (Math.abs(target - value) <= CONVERGENCE_EPSILON) {
value = target;
}
}
/**
* Teleports both value and target: no transition, the next frame renders settled.
*/
public void setInstant(boolean active) {
target = active ? 1.0f : 0.0f;
value = target;
}
/** Current smoothed position in 0..1. */
public float value() {
return value;
}
/** Current goal in 0..1 as last set by {@link #setTarget(boolean)}. */
public float target() {
return target;
}
/** True once the value has fully reached the target. */
public boolean isSettled() {
return value == target;
}
private static float clamp01(float v) {
if (v < 0.0f) return 0.0f;
if (v > 1.0f) return 1.0f;
return v;
}
private static boolean isFinite(float v) {
return !Float.isNaN(v) && !Float.isInfinite(v);
}
}

View file

@ -0,0 +1,62 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
/**
* Чистая математика трёхфазной анимации (появление/жизнь/исчезновение) —
* идея из Soup HitBubbles (ThreeStageAnimation), без библиотеки именованных
* интерполяций референса: appear — ease-out (быстрый старт), disappear —
* ease-in (медленный старт затухания).
*/
public enum ThreeStageProgress {
;
public enum Stage { APPEAR, EXIST, DISAPPEAR, FINISHED }
public static Stage stage(double elapsedMs, double appearMs, double existMs, double disappearMs) {
if (elapsedMs < appearMs) return Stage.APPEAR;
if (elapsedMs < appearMs + existMs) return Stage.EXIST;
if (elapsedMs < appearMs + existMs + disappearMs) return Stage.DISAPPEAR;
return Stage.FINISHED;
}
public static boolean isFinished(double elapsedMs, double appearMs, double existMs, double disappearMs) {
return stage(elapsedMs, appearMs, existMs, disappearMs) == Stage.FINISHED;
}
/** @return общая "видимость" 0..1: растёт на appear, держится 1 на exist, падает на disappear. */
public static float visibility(double elapsedMs, double appearMs, double existMs, double disappearMs) {
Stage stage = stage(elapsedMs, appearMs, existMs, disappearMs);
return switch (stage) {
case APPEAR -> easeOut(clamp01(safeDiv(elapsedMs, appearMs)));
case EXIST -> 1.0f;
case DISAPPEAR -> 1.0f - easeIn(clamp01(safeDiv(elapsedMs - appearMs - existMs, disappearMs)));
case FINISHED -> 0.0f;
};
}
private static float easeOut(float t) {
float inv = 1.0f - t;
return 1.0f - inv * inv;
}
private static float easeIn(float t) {
return t * t;
}
private static double safeDiv(double value, double divisor) {
return divisor <= 0.0 ? 1.0 : value / divisor;
}
private static float clamp01(double v) {
if (Double.isNaN(v)) return 0.0f;
if (v < 0.0) return 0.0f;
if (v > 1.0) return 1.0f;
return (float) v;
}
}

View file

@ -0,0 +1,60 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
/**
* Чистая математика синусоидального покачивания предмета при ходьбе/беге.
* <p>
* Фаза берётся из накопителя шагов игрока ({@code walkAnimation.position()}),
* амплитуда — из нормализованной скорости ({@code walkAnimation.speed()},
* 0..1). Один цикл латерали соответствует двум шагам, вертикаль проседает
* дважды за цикл с лёгким сдвигом фазы (как у ванильного боба камеры).
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
*/
public enum BobMath {
;
/** Радиан на единицу пройденной фазы (та же частота шагов, что у конечностей). */
public static final float STEP_RATE = 0.6662f;
/** Сдвиг фазы вертикали, чтобы проседание не совпадало с крайними точками латерали. */
static final float VERTICAL_PHASE_OFFSET = 0.2f;
/**
* Амплитуда боба по нормализованной скорости: 0 — стоим, 1 — бег/полная ходьба.
* Некорректные входы (NaN/Inf) дают 0.
*/
public static float amplitude(float speed01) {
if (Float.isNaN(speed01) || Float.isInfinite(speed01)) {
return 0.0f;
}
if (speed01 <= 0.0f) {
return 0.0f;
}
return Math.min(1.0f, speed01);
}
/** Латеральная компонента, -1..1, период — два шага. */
public static float lateral(float phase) {
return (float) Math.sin(phase * STEP_RATE);
}
/** Вертикальная компонента, -1..0: проседание дважды за цикл. */
public static float vertical(float phase) {
float c = (float) Math.cos(phase * STEP_RATE - VERTICAL_PHASE_OFFSET);
return -Math.abs(c);
}
/**
* Точка фазы, где латеральная компонента достигает максимума:
* полезна тестам для проверки симметрии цикла.
*/
public static float lateralPeakPhase() {
return (float) (Math.PI / 2.0 / STEP_RATE);
}
}

View file

@ -0,0 +1,100 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
import dev.loki.lovisual.util.animation.hand.state.HandAnimState;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm;
import net.minecraft.world.item.ItemStack;
import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems;
import dev.loki.lovisual.render.helpers.util.TickDelta;
/**
* Клей между рендером руки и чистой математикой динамики: собирает ввод
* (дельты взгляда, скорость, использование предмета), собирает позу и
* применяет её к матрице вокруг ванильных трансформов. Состояние не хранит —
* оно живёт в {@link HandAnimState}, обновляемом раз за кадр.
*/
public final class HandDynamics {
/** Смещения/углы слабее этого считаются нулевыми и не применяются. */
private static final float SIGNIFICANT_EPSILON = 1.0e-4f;
private HandDynamics() {
}
/** Один шаг динамики за кадр; вызывается из начала submitHandsWithItems. */
public static void beginFrame(HandAnimState state, AbstractClientPlayer player, float tickDelta) {
state.beginFrame(
player,
Minecraft.getInstance().level,
player.getViewYRot(tickDelta),
player.getViewXRot(tickDelta),
player.isUsingItem() && player.getUsedItemHand() == InteractionHand.MAIN_HAND,
player.isUsingItem() && player.getUsedItemHand() == InteractionHand.OFF_HAND,
TickDelta.fixedDeltaTicks()
);
}
/**
* Применяет позу динамики к матрице руки: pushPose, затем смещение и
* повороты вокруг точки взгляда до ванильных трансформов. Вызывающий
* обязан сделать popPose, если вернулось true.
*
* @return true, если был pushPose и нужен popPose
*/
public static boolean apply(
HoldMyItems module, HandAnimState state, AbstractClientPlayer player,
float tickDelta, InteractionHand hand, ItemStack item, PoseStack matrices
) {
boolean mainHand = hand == InteractionHand.MAIN_HAND;
float bobStrength = module.walkBob.get() ? module.bobStrength.get() : 0.0f;
float driftStrength = module.useDrift.get() ? module.driftStrength.get() : 0.0f;
boolean usingThisHand = player.isUsingItem() && player.getUsedItemHand() == hand;
float drift = usingThisHand ? state.drift(mainHand) : 0.0f;
boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand;
HandPoseMath.Pose pose = HandPoseMath.sway(
state.sway().yawLag(), state.sway().pitchLag(), module.swayIntensity.get());
if (bobStrength > 0.0f) {
pose = HandPoseMath.add(pose, HandPoseMath.bob(
player.walkAnimation.position(tickDelta),
BobMath.amplitude(player.walkAnimation.speed(tickDelta)),
bobStrength));
}
if (driftStrength > 0.0f && drift > 0.0f) {
pose = HandPoseMath.add(pose, HandPoseMath.drift(drift, driftStrength, rightArm));
}
if (module.joinAppear.get()) {
pose = HandPoseMath.add(pose, HandPoseMath.joinDrop(state.joinProgress()));
}
if (module.gripEnabled.get() && !usingThisHand && HeldAlongFist.test(item)) {
float side = rightArm ? 1.0f : -1.0f;
pose = HandPoseMath.add(pose, HandPoseMath.grip(
module.gripExtend.get(), module.gripSlide.get(), module.gripOut.get(),
module.gripForward.get(), module.gripTilt.get(), module.gripTurn.get(),
module.gripRoll.get(), side));
}
if (!pose.isSignificant(SIGNIFICANT_EPSILON)) {
return false;
}
matrices.pushPose();
matrices.translate(pose.dx(), pose.dy(), pose.dz());
if (pose.rx() != 0.0f) matrices.mulPose(Axis.XP.rotationDegrees(pose.rx()));
if (pose.ry() != 0.0f) matrices.mulPose(Axis.YP.rotationDegrees(pose.ry()));
if (pose.rz() != 0.0f) matrices.mulPose(Axis.ZP.rotationDegrees(pose.rz()));
return true;
}
}

View file

@ -0,0 +1,178 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
/**
* Сборка итоговой позы руки из отдельных эффектов: sway от обзора, боб от
* ходьбы, дрейф к центру при использовании предмета и «въезд» при входе в мир.
* <p>
* Конвенции знаков соответствуют пространству ванильного рендера руки:
* +X — влево на экране (правая рука стоит при X&lt;0), +Y — вверх, +Z — к
* зрителю; углы в градусах. Смещения — в условных блоках.
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
*/
public enum HandPoseMath {
;
/** Нулевая поза — применять трансформы не нужно. */
public static final Pose ZERO = new Pose(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
private static final float SWAY_POS_PER_DEG = 0.010f;
private static final float SWAY_ROT_Y_PER_DEG = 1.4f;
private static final float SWAY_ROT_X_PER_DEG = 1.1f;
private static final float SWAY_ROT_Z_PER_DEG = 0.5f;
private static final float BOB_POS_X = 0.045f;
private static final float BOB_POS_Y = 0.035f;
private static final float BOB_ROT_Z_DEG = 2.5f;
private static final float BOB_ROT_X_DEG = 3.0f;
private static final float DRIFT_POS_X = 0.10f;
private static final float DRIFT_POS_Z = 0.05f;
private static final float DRIFT_ROT_Y_DEG = 10.0f;
private static final float DRIFT_ROT_Z_DEG = 9.0f;
private static final float JOIN_DROP = 0.85f;
private static final float JOIN_TILT_DEG = 18.0f;
/**
* Отставание от поворота камеры: камера повернула вправо (yaw+) — предмет
* сместился влево на экране (+X) и довернулся назад (ry-), взгляд вниз
* (pitch+) — предмет приподнялся (+Y) и задрался (rx-).
*/
public static Pose sway(float yawLagDeg, float pitchLagDeg, float strength) {
float s = scale(strength);
if (s <= 0.0f) {
return ZERO;
}
return new Pose(
yawLagDeg * SWAY_POS_PER_DEG * s,
pitchLagDeg * SWAY_POS_PER_DEG * s,
0.0f,
-pitchLagDeg * SWAY_ROT_X_PER_DEG * s,
-yawLagDeg * SWAY_ROT_Y_PER_DEG * s,
yawLagDeg * SWAY_ROT_Z_PER_DEG * s
);
}
/** Покачивание при ходьбе: {@code phase} — позиция шага, {@code amplitude01} — 0..1 от скорости. */
public static Pose bob(float phase, float amplitude01, float strength) {
float a = clamp01(amplitude01) * scale(strength);
if (a <= 0.0f) {
return ZERO;
}
float lateral = BobMath.lateral(phase);
float vertical = BobMath.vertical(phase);
return new Pose(
-lateral * BOB_POS_X * a,
vertical * BOB_POS_Y * a,
0.0f,
vertical * BOB_ROT_X_DEG * a,
0.0f,
-lateral * BOB_ROT_Z_DEG * a
);
}
/**
* Дрейф используемого предмета (еда/зелье/щит) к центру экрана.
* Правая рука тянется влево (+X), левая — вправо (-X), плюс лёгкий
* наклон внутрь.
*/
public static Pose drift(float progress01, float strength, boolean rightArm) {
float s = clamp01(progress01) * scale(strength);
if (s <= 0.0f) {
return ZERO;
}
float side = rightArm ? 1.0f : -1.0f;
return new Pose(
side * DRIFT_POS_X * s,
0.0f,
-DRIFT_POS_Z * s,
0.0f,
-side * DRIFT_ROT_Y_DEG * s,
side * DRIFT_ROT_Z_DEG * s
);
}
/**
* "Хват вдоль кулака" (grip) для предметов с рукоятью (см. {@link HeldAlongFist}):
* лёгкий сдвиг ближе к камере/в сторону и поворот — имитирует держание меча/палки
* не прямо по-вертикали ванильного equip, а слегка наискось в кулаке.
* {@code side}: +1 — правая рука, -1 — левая.
*/
public static Pose grip(float extend, float slide, float out, float forward,
float tilt, float turn, float roll, float side) {
return new Pose(
0.32f * side * extend + out * side,
slide,
-0.95f * extend - forward,
tilt,
turn * side,
roll * side
);
}
/** Появление при входе в мир: предмет въезжает снизу и распрямляется. */
public static Pose joinDrop(float progress01) {
float p = clamp01(progress01);
float remain = 1.0f - p;
if (remain <= 0.0f) {
return ZERO;
}
float square = remain * remain;
return new Pose(0.0f, -JOIN_DROP * square, 0.0f, JOIN_TILT_DEG * square, 0.0f, 0.0f);
}
/** Поэлементная сумма поз. */
public static Pose add(Pose a, Pose b) {
if (a == ZERO) return b;
if (b == ZERO) return a;
return new Pose(
a.dx() + b.dx(),
a.dy() + b.dy(),
a.dz() + b.dz(),
a.rx() + b.rx(),
a.ry() + b.ry(),
a.rz() + b.rz()
);
}
static float clamp01(float v) {
if (Float.isNaN(v)) return 0.0f;
if (v < 0.0f) return 0.0f;
if (v > 1.0f) return 1.0f;
return v;
}
/**
* Множитель силы настройки: линейный, без потолка — диапазон настроек 0..2
* должен именно усиливать эффект, а не обрезаться. Отрицательные и
* сломанные значения дают ноль.
*/
static float scale(float strength) {
if (Float.isNaN(strength) || Float.isInfinite(strength)) return 0.0f;
return Math.max(0.0f, strength);
}
/**
* Итоговая поза руки: смещение (dx, dy, dz) и поворот (rx, ry, rz в градусах),
* применяемые вокруг точки взгляда до ванильных трансформов руки.
*/
public record Pose(float dx, float dy, float dz, float rx, float ry, float rz) {
/** Все компоненты по модулю не больше epsilon — трансформ можно не применять. */
public boolean isSignificant(float epsilon) {
return Math.abs(dx) > epsilon
|| Math.abs(dy) > epsilon
|| Math.abs(dz) > epsilon
|| Math.abs(rx) > epsilon
|| Math.abs(ry) > epsilon
|| Math.abs(rz) > epsilon;
}
}
}

View file

@ -0,0 +1,32 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
import net.minecraft.tags.ItemTags;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.ItemStack;
/**
* Предметы с рукоятью, которые выглядят естественно при "хвате вдоль кулака"
* (grip): мечи/инструменты/палки. Портировано из идеи Delta HMI
* (heldAlongTheFist), список расширен по нашему набору ванильных предметов.
*/
public enum HeldAlongFist {
;
public static boolean test(ItemStack item) {
if (item == null || item.isEmpty()) return false;
return item.is(ItemTags.SWORDS) || item.is(ItemTags.AXES) || item.is(ItemTags.PICKAXES)
|| item.is(ItemTags.SHOVELS) || item.is(ItemTags.HOES)
|| item.is(Items.MACE) || item.is(Items.TRIDENT)
|| item.is(Items.FISHING_ROD) || item.is(Items.CARROT_ON_A_STICK)
|| item.is(Items.WARPED_FUNGUS_ON_A_STICK) || item.is(Items.STICK)
|| item.is(Items.BLAZE_ROD) || item.is(Items.BREEZE_ROD)
|| item.is(Items.BONE) || item.is(Items.DEBUG_STICK);
}
}

View file

@ -0,0 +1,125 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand.state;
import dev.loki.lovisual.util.animation.SmoothValue;
/**
* Покадровое состояние динамики рук: сводит sway, дрейф использования и
* появление при входе в мир. Обновляется ровно один раз за кадр
* ({@link #beginFrame}), рендер только читает значения — это исключает
* двойные шаги, когда кадр рендерит обе руки.
* <p>
* Player/level передаются как Object-идентичности: смена игрока или мира
* перезапускает анимацию появления и гасит ложные дельты взгляда.
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
*/
public final class HandAnimState {
/** Резкая смена взгляда больше этих пределов считается телепортом, а не движением мыши. */
static final float TELEPORT_YAW_DEG = 45.0f;
static final float TELEPORT_PITCH_DEG = 25.0f;
static final float MAX_FRAME_DELTA_TICKS = 5.0f;
/** Скорость дрейфа использования (99% за ~ln(100)/speed тиков). */
static final float DRIFT_SPEED = 1.8f;
/** Скорость появления при входе: ~10 тиков до почти полной посадки. */
static final float JOIN_SPEED = 0.45f;
private final SwayState sway = new SwayState();
private final SmoothValue driftMain = new SmoothValue(DRIFT_SPEED);
private final SmoothValue driftOff = new SmoothValue(DRIFT_SPEED);
private final SmoothValue join = new SmoothValue(JOIN_SPEED);
private float lastYaw = Float.NaN;
private float lastPitch = Float.NaN;
private Object lastPlayer;
private Object lastLevel;
/**
* Один шаг на кадр.
*
* @param player идентичность игрока (для смены мира/респавна)
* @param level идентичность мира
* @param yaw текущий yaw взгляда, градусы (любой диапазон)
* @param pitch текущий pitch взгляда, градусы
* @param usingMain предмет используется в главной руке
* @param usingOff предмет используется во второй руке
* @param deltaTicks реальное время кадра в тиках
*/
public void beginFrame(Object player, Object level, float yaw, float pitch,
boolean usingMain, boolean usingOff, float deltaTicks) {
if (player == null) {
reset();
return;
}
if (player != lastPlayer || level != lastLevel) {
lastPlayer = player;
lastLevel = level;
join.setInstant(false);
lastYaw = Float.NaN;
lastPitch = Float.NaN;
}
float dt = SwayState.sanitize(deltaTicks);
if (Float.isNaN(lastYaw)) {
lastYaw = yaw;
lastPitch = pitch;
return;
}
float deltaYaw = SwayState.wrapDegrees(yaw - lastYaw);
float deltaPitch = pitch - lastPitch;
lastYaw = yaw;
lastPitch = pitch;
boolean lookJump = Math.abs(deltaYaw) > TELEPORT_YAW_DEG
|| Math.abs(deltaPitch) > TELEPORT_PITCH_DEG;
if (dt > 0.0f) {
if (lookJump) {
sway.reset();
} else {
sway.update(deltaYaw, deltaPitch, Math.min(dt, MAX_FRAME_DELTA_TICKS));
}
driftMain.setTarget(usingMain);
driftMain.update(dt);
driftOff.setTarget(usingOff);
driftOff.update(dt);
join.setTarget(true);
join.update(dt);
}
}
/** Полный сброс: следующий кадр считается первым после входа в мир. */
public void reset() {
sway.reset();
driftMain.setInstant(false);
driftOff.setInstant(false);
join.setInstant(false);
lastYaw = Float.NaN;
lastPitch = Float.NaN;
lastPlayer = null;
lastLevel = null;
}
public SwayState sway() {
return sway;
}
/** Сглаженный прогресс дрейфа для указанной руки, 0..1. */
public float drift(boolean mainHand) {
return (mainHand ? driftMain : driftOff).value();
}
/** Прогресс появления при входе в мир, 0..1 (1 — предмет встал на место). */
public float joinProgress() {
return join.value();
}
}

View file

@ -0,0 +1,117 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand.state;
/**
* Накопитель «отставания» предмета от поворота камеры (view sway).
* <p>
* Модель: дельты взгляда за кадр (в градусах) накапливаются в отставание
* {@code lag}, ограниченное сверху, и экспоненциально затухают к нулю
* (RELAX). Отображаемое значение догоняет накопленное отставание
* экспонентой (RESPONSE). Всё в градусах, время — в тиках, поэтому
* поведение не зависит от частоты кадров.
* <p>
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
*/
public final class SwayState {
public static final float MAX_YAW_LAG_DEG = 6.0f;
public static final float MAX_PITCH_LAG_DEG = 4.0f;
/** Скорость догоняния отображаемого отставания, 1/тик. */
static final float RESPONSE_PER_TICK = 6.0f;
/** Скорость возврата накопленного отставания к нулю, 1/тик. */
static final float RELAX_PER_TICK = 0.25f;
/** Верхняя граница шага, чтобы пауза/лаг не давали рывок. */
static final float MAX_DELTA_TICKS = 5.0f;
/** Ниже этого всё состояние защёлкивается в точный ноль — конечное время затухания. */
private static final float SETTLE_EPSILON = 1.0e-4f;
private float yawLag; // накопленное отставание по yaw, градусы
private float pitchLag; // накопленное отставание по pitch, градусы
private float yawSmooth; // отображаемое отставание по yaw, градусы
private float pitchSmooth;
/**
* Продвигает состояние на один кадр.
*
* @param deltaYawDeg изменение yaw камеры за кадр (может быть любым по знаку)
* @param deltaPitchDeg изменение pitch камеры за кадр
* @param deltaTicks реальное время кадра в тиках; неположительное пропускается
*/
public void update(float deltaYawDeg, float deltaPitchDeg, float deltaTicks) {
float dt = sanitize(deltaTicks);
if (dt <= 0.0f) {
return;
}
yawLag = clamp(yawLag + sanitize(deltaYawDeg), -MAX_YAW_LAG_DEG, MAX_YAW_LAG_DEG);
pitchLag = clamp(pitchLag + sanitize(deltaPitchDeg), -MAX_PITCH_LAG_DEG, MAX_PITCH_LAG_DEG);
float follow = 1.0f - (float) Math.exp(-RESPONSE_PER_TICK * dt);
yawSmooth += (yawLag - yawSmooth) * follow;
pitchSmooth += (pitchLag - pitchSmooth) * follow;
float relax = (float) Math.exp(-RELAX_PER_TICK * dt);
yawLag *= relax;
pitchLag *= relax;
if (Math.abs(yawLag) <= SETTLE_EPSILON && Math.abs(pitchLag) <= SETTLE_EPSILON
&& Math.abs(yawSmooth) <= SETTLE_EPSILON && Math.abs(pitchSmooth) <= SETTLE_EPSILON) {
reset();
}
}
/** Мгновенный сброс: предмет стоит ровно, без хвостов. */
public void reset() {
yawLag = 0.0f;
pitchLag = 0.0f;
yawSmooth = 0.0f;
pitchSmooth = 0.0f;
}
/** Сглаженное отставание по yaw в градусах. */
public float yawLag() {
return yawSmooth;
}
/** Сглаженное отставание по pitch в градусах. */
public float pitchLag() {
return pitchSmooth;
}
/** Обе оси в нуле — считать можно не применять трансформ вовсе. */
public boolean isSettled() {
return yawLag == 0.0f && pitchLag == 0.0f && yawSmooth == 0.0f && pitchSmooth == 0.0f;
}
/**
* Приводит разницу углов к диапазону [-180, 180): переход через
* границу мира не даёт ложного рывка на 360 градусов.
*/
public static float wrapDegrees(float degrees) {
float d = sanitize(degrees) % 360.0f;
if (d >= 180.0f) {
d -= 360.0f;
}
if (d < -180.0f) {
d += 360.0f;
}
return d;
}
static float sanitize(float v) {
return (Float.isNaN(v) || Float.isInfinite(v)) ? 0.0f : v;
}
static float clamp(float v, float min, float max) {
if (v < min) return min;
if (v > max) return max;
return v;
}
}

View file

@ -0,0 +1,134 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.bed;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.block.BedBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.block.scan.BlockSearchUtil;
import java.util.*;
import java.util.function.BiPredicate;
public enum BedBlockUtil {
;
public static boolean isBed(BlockState state) {
return state != null && state.getBlock() instanceof BedBlock;
}
public static Direction anotherBedPartDirection(BlockState state) {
if (!isBed(state)) {
return null;
}
return BedBlock.getConnectedDirection(state);
}
public static BlockPos canonicalBedPos(BlockPos pos, BlockState state) {
if (pos == null) {
return null;
}
Direction anotherPartDirection = anotherBedPartDirection(state);
if (anotherPartDirection == null) {
return pos.immutable();
}
BlockPos otherPart = pos.relative(anotherPartDirection);
return pos.asLong() <= otherPart.asLong() ? pos.immutable() : otherPart.immutable();
}
public static List<LayeredBlockPos> searchBedLayer(BlockPos pos, BlockState state, int layers) {
if (pos == null || !isBed(state) || layers <= 0) {
return List.of();
}
Direction anotherPartDirection = anotherBedPartDirection(state);
if (anotherPartDirection == null) {
return List.of();
}
Direction bedDirection = anotherPartDirection.getOpposite();
Direction left;
Direction right;
if (bedDirection.getAxis() == Direction.Axis.X) {
left = Direction.SOUTH;
right = Direction.NORTH;
} else {
left = Direction.WEST;
right = Direction.EAST;
}
List<LayeredBlockPos> results = new ArrayList<>();
results.addAll(searchLayer(pos, layers, bedDirection, Direction.UP, left, right));
results.addAll(searchLayer(pos.relative(anotherPartDirection), layers, anotherPartDirection, Direction.UP, left, right));
return results;
}
public static List<LayeredBlockPos> searchLayer(BlockPos origin, int layers, Direction... directions) {
if (origin == null || layers <= 0 || directions == null || directions.length == 0) {
return List.of();
}
List<LayeredBlockPos> results = new ArrayList<>();
ArrayDeque<LayeredBlockPos> queue = new ArrayDeque<>();
Set<BlockPos> visited = new HashSet<>();
BlockPos start = origin.immutable();
queue.add(new LayeredBlockPos(0, start));
visited.add(start);
while (!queue.isEmpty()) {
LayeredBlockPos next = queue.removeFirst();
if (next.layer() > 0) {
results.add(next);
}
if (next.layer() >= layers) {
continue;
}
for (Direction direction : directions) {
BlockPos candidate = next.pos().relative(direction);
if (!visited.add(candidate)) {
continue;
}
queue.addLast(new LayeredBlockPos(next.layer() + 1, candidate.immutable()));
}
}
return results;
}
public static BedSearchEntry findClosestBed(
BlockGetter world,
Vec3 center,
double range,
BiPredicate<BlockPos, BlockState> filter
) {
if (world == null || center == null || range < 0.0) {
return null;
}
return BlockSearchUtil.searchBlocksInCuboid(world, center, range, (pos, state) ->
isBed(state) && (filter == null || filter.test(pos, state)))
.stream()
.min(Comparator.comparingDouble(entry -> Vec3.atCenterOf(entry.pos()).distanceToSqr(center)))
.map(entry -> new BedSearchEntry(entry.pos(), entry.state()))
.orElse(null);
}
public record LayeredBlockPos(int layer, BlockPos pos) {
}
public record BedSearchEntry(BlockPos pos, BlockState state) {
}
}

View file

@ -0,0 +1,202 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.bed;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.DyedItemColor;
import net.minecraft.world.level.block.BedBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.scores.PlayerTeam;
public enum BedwarsTeamColorUtil {
;
public static Integer getPlayerTeamRgb(Player player) {
if (player == null) {
return null;
}
for (EquipmentSlot slot : EquipmentSlot.values()) {
if (!slot.isArmor()) {
continue;
}
ItemStack stack = player.getItemBySlot(slot);
if (stack.isEmpty()) {
continue;
}
int color = DyedItemColor.getOrDefault(stack, -1);
if (color != -1) {
return color & 0x00FFFFFF;
}
}
return null;
}
public static Integer getPlayerTeamRgb(String name) {
if (name == null || name.isBlank()) {
return null;
}
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.level == null) {
return null;
}
for (Player player : mc.level.players()) {
String playerName = player.getGameProfile().name();
if (playerName != null && playerName.equalsIgnoreCase(name)) {
return getPlayerTeamRgb(player);
}
}
return null;
}
public static Integer getPlayerTabRgb(Player player) {
if (player == null) {
return null;
}
Integer scoreboardRgb = getScoreboardTeamRgb(player.getTeam());
if (scoreboardRgb != null) {
return scoreboardRgb;
}
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.getConnection() == null) {
return null;
}
PlayerInfo entry = mc.getConnection().getPlayerInfo(player.getUUID());
return entry == null ? null : extractTextRgb(entry.getTabListDisplayName());
}
public static Integer getPlayerTabRgb(String name) {
if (name == null || name.isBlank()) {
return null;
}
Minecraft mc = Minecraft.getInstance();
if (mc == null) {
return null;
}
if (mc.level != null) {
for (Player player : mc.level.players()) {
String playerName = player.getGameProfile().name();
if (playerName != null && playerName.equalsIgnoreCase(name)) {
Integer scoreboardRgb = getScoreboardTeamRgb(player.getTeam());
if (scoreboardRgb != null) {
return scoreboardRgb;
}
break;
}
}
}
if (mc == null || mc.getConnection() == null) {
return null;
}
for (PlayerInfo entry : mc.getConnection().getOnlinePlayers()) {
if (entry == null || entry.getProfile() == null) {
continue;
}
String entryName = entry.getProfile().name();
if (entryName != null && entryName.equalsIgnoreCase(name)) {
return extractTextRgb(entry.getTabListDisplayName());
}
}
return null;
}
public static TeamRelation determineRelation(String name) {
if (name == null || name.isBlank()) {
return TeamRelation.NONE;
}
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null) {
return TeamRelation.NONE;
}
String selfName = mc.player.getGameProfile().name();
if (selfName != null && selfName.equalsIgnoreCase(name)) {
return TeamRelation.SELF;
}
Integer selfTab = getPlayerTabRgb(mc.player);
Integer otherTab = getPlayerTabRgb(name);
if (selfTab != null && otherTab != null) {
return selfTab.equals(otherTab) ? TeamRelation.SELF : TeamRelation.ENEMY;
}
Integer selfArmor = getPlayerTeamRgb(mc.player);
Integer otherArmor = getPlayerTeamRgb(name);
if (selfArmor != null && otherArmor != null) {
return selfArmor.equals(otherArmor) ? TeamRelation.SELF : TeamRelation.ENEMY;
}
return TeamRelation.NONE;
}
public static Integer getBedTeamRgb(BlockState state) {
if (state == null || !(state.getBlock() instanceof BedBlock bedBlock)) {
return null;
}
return bedBlock.getColor().getTextureDiffuseColor() & 0x00FFFFFF;
}
public static int replaceRgb(int argb, int rgb) {
return (argb & 0xFF000000) | (rgb & 0x00FFFFFF);
}
private static Integer extractTextRgb(Component text) {
if (text == null) {
return null;
}
if (text.getStyle() != null && text.getStyle().getColor() != null) {
return text.getStyle().getColor().getValue() & 0x00FFFFFF;
}
for (Component sibling : text.getSiblings()) {
Integer siblingRgb = extractTextRgb(sibling);
if (siblingRgb != null) {
return siblingRgb;
}
}
return null;
}
private static Integer getScoreboardTeamRgb(PlayerTeam team) {
if (team == null || team.getColor().isEmpty()) {
return null;
}
return team.getColor().map(net.minecraft.world.scores.TeamColor::rgb).orElse(null);
}
public enum TeamRelation {
SELF,
ENEMY,
NONE
}
}

View file

@ -0,0 +1,28 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.bed;
import dev.loki.lovisual.config.values.mode.EnumValue;
public enum SelfBedMode implements EnumValue.IdProvider {
NONE("none"),
COLOR("color"),
SPAWN_LOCATION("spawn_location"),
MANUAL("manual");
private final String id;
SelfBedMode(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}

View file

@ -0,0 +1,210 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.bed;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.DyedItemColor;
import net.minecraft.world.level.block.BedBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.events.impl.render.GameTickEvent;
import dev.loki.lovisual.events.impl.network.PacketEvent;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.HudNotifier;
import dev.loki.lovisual.util.block.bed.BedBlockUtil;
import dev.loki.lovisual.util.block.bed.SelfBedMode;
public final class SelfBedTracker {
public static final SelfBedTracker INSTANCE = new SelfBedTracker();
private static final double DEFAULT_MANUAL_TRACK_RANGE = 16.0;
private static final double MIN_SPAWN_PACKET_DISTANCE_SQ = 16.0 * 16.0;
private Vec3 trackedSpawnLocation;
private BlockPos trackedManualBed;
private ClientLevel lastWorld;
private SelfBedTracker() {
}
public boolean shouldDefend(
SelfBedMode mode,
BlockState state,
BlockPos pos,
LocalPlayer player,
double spawnBedDistance
) {
return mode == SelfBedMode.NONE || isSelfBed(mode, state, pos, player, spawnBedDistance);
}
public boolean isSelfBed(
SelfBedMode mode,
BlockState state,
BlockPos pos,
LocalPlayer player,
double spawnBedDistance
) {
if (mode == null || state == null || pos == null || !(state.getBlock() instanceof BedBlock bedBlock)) {
return false;
}
return switch (mode) {
case NONE -> false;
case COLOR -> matchesArmorColor(player, bedBlock);
case SPAWN_LOCATION -> isNearTrackedSpawn(pos, spawnBedDistance);
case MANUAL -> matchesManualBed(state, pos);
};
}
public void trackNearestBed(LocalPlayer player) {
trackNearestBed(player, DEFAULT_MANUAL_TRACK_RANGE);
}
public void trackNearestBed(LocalPlayer player, double range) {
if (player == null || player.level() == null) {
return;
}
BedBlockUtil.BedSearchEntry bed = BedBlockUtil.findClosestBed(
player.level(),
player.getEyePosition(),
range,
(pos, state) -> true
);
if (bed == null) {
HudNotifier.pushMessage("SelfBed: cannot find any bed around you. Get closer to your bed.", HudNotifier.NotifyType.NO);
return;
}
trackedManualBed = bed.pos().immutable();
lastWorld = Minecraft.getInstance().level;
HudNotifier.pushMessage(
"SelfBed: tracked bed position " + trackedManualBed.getX() + " " + trackedManualBed.getY() + " " + trackedManualBed.getZ(),
HudNotifier.NotifyType.YES
);
}
public void untrackManualBed() {
if (trackedManualBed == null) {
return;
}
BlockPos pos = trackedManualBed;
trackedManualBed = null;
HudNotifier.pushMessage(
"SelfBed: bed position " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " has been untracked.",
HudNotifier.NotifyType.INFO
);
}
public void reset() {
trackedSpawnLocation = null;
trackedManualBed = null;
lastWorld = null;
}
public Vec3 getTrackedSpawnLocation() {
return trackedSpawnLocation;
}
public BlockPos getTrackedManualBed() {
return trackedManualBed;
}
@EventHandler(priority = 1000)
private void onPacketReceive(PacketEvent.Receive event) {
if (!(event.getPacket() instanceof ClientboundPlayerPositionPacket packet)) {
return;
}
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null) {
return;
}
Vec3 position = packet.change().position();
if (player.position().distanceToSqr(position) > MIN_SPAWN_PACKET_DISTANCE_SQ) {
trackedSpawnLocation = position;
}
}
@EventHandler
private void onGameTick(GameTickEvent event) {
Minecraft mc = Minecraft.getInstance();
ClientLevel world = mc.level;
if (world != lastWorld) {
if (trackedManualBed != null && lastWorld != null) {
BlockPos pos = trackedManualBed;
trackedManualBed = null;
HudNotifier.pushMessage(
"SelfBed: bed position " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " has been untracked due to world change.",
HudNotifier.NotifyType.INFO
);
}
lastWorld = world;
}
}
private boolean matchesArmorColor(LocalPlayer player, BedBlock bedBlock) {
Integer armorColor = getArmorColor(player);
return armorColor != null && armorColor == bedBlock.getColor().getTextureDiffuseColor();
}
private boolean isNearTrackedSpawn(BlockPos pos, double distance) {
if (trackedSpawnLocation == null || distance < 0.0) {
return false;
}
return trackedSpawnLocation.distanceToSqr(pos.getX(), pos.getY(), pos.getZ()) <= distance * distance;
}
private boolean matchesManualBed(BlockState state, BlockPos pos) {
if (trackedManualBed == null) {
return false;
}
if (trackedManualBed.equals(pos)) {
return true;
}
Direction direction = BedBlockUtil.anotherBedPartDirection(state);
return direction != null && pos.relative(direction).equals(trackedManualBed);
}
private Integer getArmorColor(LocalPlayer player) {
if (player == null) {
return null;
}
for (EquipmentSlot slot : EquipmentSlot.values()) {
if (!slot.isArmor()) {
continue;
}
ItemStack stack = player.getItemBySlot(slot);
if (stack.isEmpty()) {
continue;
}
int color = DyedItemColor.getOrDefault(stack, -1);
if (color != -1) {
return color;
}
}
return null;
}
}

View file

@ -0,0 +1,119 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.render;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.util.block.render.BlockOutlineShapeMode;
public enum BlockOutlineRenderer {
;
public static void render(Renderer3D renderer,
ClientLevel world,
BlockPos pos,
BlockState state,
BlockOutlineShapeMode mode,
int argb) {
if (renderer == null || world == null || pos == null || state == null || state.isAir()) {
return;
}
if (mode == BlockOutlineShapeMode.VOXEL_SHAPE) {
renderVoxelShape(renderer, world, pos, state, argb);
} else {
renderBox(renderer, new AABB(pos), argb);
}
}
public static void renderBox(Renderer3D renderer, AABB box, int argb) {
if (renderer == null || box == null) {
return;
}
int a = alpha(argb);
int r = red(argb);
int g = green(argb);
int b = blue(argb);
double minX = box.minX;
double minY = box.minY;
double minZ = box.minZ;
double maxX = box.maxX;
double maxY = box.maxY;
double maxZ = box.maxZ;
line(renderer, minX, minY, minZ, maxX, minY, minZ, r, g, b, a);
line(renderer, maxX, minY, minZ, maxX, minY, maxZ, r, g, b, a);
line(renderer, maxX, minY, maxZ, minX, minY, maxZ, r, g, b, a);
line(renderer, minX, minY, maxZ, minX, minY, minZ, r, g, b, a);
line(renderer, minX, maxY, minZ, maxX, maxY, minZ, r, g, b, a);
line(renderer, maxX, maxY, minZ, maxX, maxY, maxZ, r, g, b, a);
line(renderer, maxX, maxY, maxZ, minX, maxY, maxZ, r, g, b, a);
line(renderer, minX, maxY, maxZ, minX, maxY, minZ, r, g, b, a);
line(renderer, minX, minY, minZ, minX, maxY, minZ, r, g, b, a);
line(renderer, maxX, minY, minZ, maxX, maxY, minZ, r, g, b, a);
line(renderer, maxX, minY, maxZ, maxX, maxY, maxZ, r, g, b, a);
line(renderer, minX, minY, maxZ, minX, maxY, maxZ, r, g, b, a);
}
private static void renderVoxelShape(Renderer3D renderer,
ClientLevel world,
BlockPos pos,
BlockState state,
int argb) {
Minecraft mc = Minecraft.getInstance();
Entity cameraEntity = mc != null ? mc.getCameraEntity() : null;
CollisionContext context = cameraEntity != null ? CollisionContext.of(cameraEntity) : CollisionContext.empty();
VoxelShape shape = state.getShape(world, pos, context);
if (shape.isEmpty()) {
renderBox(renderer, new AABB(pos), argb);
return;
}
int a = alpha(argb);
int r = red(argb);
int g = green(argb);
int b = blue(argb);
shape.move(pos.getX(), pos.getY(), pos.getZ()).forAllEdges((x1, y1, z1, x2, y2, z2) ->
line(renderer, x1, y1, z1, x2, y2, z2, r, g, b, a)
);
}
private static void line(Renderer3D renderer,
double x1, double y1, double z1,
double x2, double y2, double z2,
int r, int g, int b, int a) {
renderer.line(x1, y1, z1, x2, y2, z2, r, g, b, a);
}
private static int red(int argb) {
return (argb >>> 16) & 255;
}
private static int green(int argb) {
return (argb >>> 8) & 255;
}
private static int blue(int argb) {
return argb & 255;
}
private static int alpha(int argb) {
return (argb >>> 24) & 255;
}
}

View file

@ -0,0 +1,26 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.render;
import dev.loki.lovisual.config.values.mode.EnumValue;
public enum BlockOutlineShapeMode implements EnumValue.IdProvider {
FULL_BLOCK("full_block"),
VOXEL_SHAPE("voxel_shape");
private final String id;
BlockOutlineShapeMode(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}

View file

@ -0,0 +1,42 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.render;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;
import java.util.concurrent.ConcurrentHashMap;
public enum RenderSectionBlockScanner {
;
private static final ConcurrentHashMap<Long, BlockState> BLOCKS = new ConcurrentHashMap<>();
public static void recordBlock(BlockPos pos, BlockState state) {
if (pos == null || state == null) {
return;
}
long key = pos.asLong();
if (state.isAir()) {
BLOCKS.remove(key);
} else {
BLOCKS.put(key, state);
}
}
public static void clear() {
BLOCKS.clear();
}
public static BlockState getCachedState(BlockPos pos) {
return pos == null ? null : BLOCKS.get(pos.asLong());
}
public record ScanResult(BlockPos pos, BlockState state, boolean visible) {
}
}

View file

@ -0,0 +1,196 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.render;
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import java.util.ArrayDeque;
import java.util.function.Predicate;
/**
* Main-thread, budgeted scanner for already loaded client world chunk sections.
*
* <p>This is intentionally not a worker thread and does not request chunk rebuilds.
* Sodium/renderer observations can still feed caches opportunistically, while this
* scanner provides deterministic catch-up for enable, setting changes and newly
* reachable sections.</p>
*/
public final class WorldSectionBlockScanner {
private final ArrayDeque<Long> queue = new ArrayDeque<>();
private final LongOpenHashSet queuedSections = new LongOpenHashSet();
private final LongOpenHashSet scannedSections = new LongOpenHashSet();
private static boolean scanSection(
ClientLevel world,
long sectionKey,
Predicate<BlockState> targetPredicate,
ResultConsumer consumer
) {
int sectionX = SectionPos.x(sectionKey);
int sectionY = SectionPos.y(sectionKey);
int sectionZ = SectionPos.z(sectionKey);
ChunkAccess chunk = world.getChunk(sectionX, sectionZ, ChunkStatus.FULL, false);
if (chunk == null) {
return false;
}
int sectionIndex = world.getSectionIndexFromSectionY(sectionY);
LevelChunkSection[] sections = chunk.getSections();
if (sectionIndex < 0 || sectionIndex >= sections.length) {
return true;
}
LevelChunkSection section = sections[sectionIndex];
if (section == null || section.hasOnlyAir() || !section.maybeHas(targetPredicate)) {
return true;
}
int baseX = SectionPos.sectionToBlockCoord(sectionX);
int baseY = SectionPos.sectionToBlockCoord(sectionY);
int baseZ = SectionPos.sectionToBlockCoord(sectionZ);
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (int localY = 0; localY < 16; localY++) {
int y = baseY + localY;
if (!world.isInsideBuildHeight(y)) {
continue;
}
for (int localZ = 0; localZ < 16; localZ++) {
int z = baseZ + localZ;
for (int localX = 0; localX < 16; localX++) {
BlockState state = section.getBlockState(localX, localY, localZ);
if (!targetPredicate.test(state)) {
continue;
}
mutable.set(baseX + localX, y, z);
consumer.accept(mutable.immutable(), state);
}
}
}
return true;
}
public void reset() {
queue.clear();
queuedSections.clear();
scannedSections.clear();
}
public void forget(BlockPos pos) {
if (pos == null) {
return;
}
scannedSections.remove(SectionPos.asLong(pos));
}
public void enqueueAround(Minecraft client, int horizontalBlockRadius, int minY, int maxY) {
if (client == null || client.level == null || client.player == null) {
return;
}
ClientLevel world = client.level;
int radius = Math.max(1, horizontalBlockRadius);
int chunkRadius = Math.max(1, (radius + 15) >> 4);
int centerChunkX = SectionPos.blockToSectionCoord(client.player.getBlockX());
int centerChunkZ = SectionPos.blockToSectionCoord(client.player.getBlockZ());
int clampedMinY = Math.max(world.getMinY(), Math.min(minY, maxY));
int clampedMaxY = Math.min(world.getMaxY(), Math.max(minY, maxY));
if (clampedMinY > clampedMaxY) {
return;
}
int minSectionY = SectionPos.blockToSectionCoord(clampedMinY);
int maxSectionY = SectionPos.blockToSectionCoord(clampedMaxY);
int centerSectionY = SectionPos.blockToSectionCoord(client.player.getBlockY());
for (int ring = 0; ring <= chunkRadius; ring++) {
for (int dx = -ring; dx <= ring; dx++) {
enqueueColumnEdge(centerChunkX + dx, centerChunkZ - ring, minSectionY, maxSectionY, centerSectionY);
if (ring != 0) {
enqueueColumnEdge(centerChunkX + dx, centerChunkZ + ring, minSectionY, maxSectionY, centerSectionY);
}
}
for (int dz = -ring + 1; dz <= ring - 1; dz++) {
enqueueColumnEdge(centerChunkX - ring, centerChunkZ + dz, minSectionY, maxSectionY, centerSectionY);
if (ring != 0) {
enqueueColumnEdge(centerChunkX + ring, centerChunkZ + dz, minSectionY, maxSectionY, centerSectionY);
}
}
}
}
public int drain(
Minecraft client,
int maxSections,
Predicate<BlockState> targetPredicate,
ResultConsumer consumer
) {
if (client == null || client.level == null || maxSections <= 0 || targetPredicate == null || consumer == null) {
return 0;
}
int processed = 0;
int attempted = 0;
int maxAttempts = Math.max(maxSections, maxSections * 8);
while (processed < maxSections && attempted < maxAttempts && !queue.isEmpty()) {
attempted++;
long sectionKey = queue.removeFirst();
queuedSections.remove(sectionKey);
if (scanSection(client.level, sectionKey, targetPredicate, consumer)) {
scannedSections.add(sectionKey);
processed++;
}
}
return processed;
}
private void enqueueColumnEdge(int chunkX, int chunkZ, int minSectionY, int maxSectionY, int centerSectionY) {
int center = Math.max(minSectionY, Math.min(maxSectionY, centerSectionY));
enqueueSection(chunkX, center, chunkZ);
int maxOffset = Math.max(center - minSectionY, maxSectionY - center);
for (int offset = 1; offset <= maxOffset; offset++) {
int up = center + offset;
if (up <= maxSectionY) {
enqueueSection(chunkX, up, chunkZ);
}
int down = center - offset;
if (down >= minSectionY) {
enqueueSection(chunkX, down, chunkZ);
}
}
}
private void enqueueSection(int sectionX, int sectionY, int sectionZ) {
long key = SectionPos.asLong(sectionX, sectionY, sectionZ);
if (scannedSections.contains(key) || !queuedSections.add(key)) {
return;
}
queue.addLast(key);
}
@FunctionalInterface
public interface ResultConsumer {
void accept(BlockPos pos, BlockState state);
}
}

View file

@ -0,0 +1,229 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.scan;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import net.minecraft.network.protocol.game.ServerboundSwingPacket;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Predicate;
import dev.loki.lovisual.util.block.render.RenderSectionBlockScanner;
public final class BlockDeobfuscationScanner {
private final ArrayList<BlockPos> queue = new ArrayList<>();
private final ArrayList<CheckedBlock> checked = new ArrayList<>();
private final Set<Long> discovered = new HashSet<>();
private final Map<Long, BlockState> positiveStates = new HashMap<>();
private AABB area;
private int delayTicks;
private int done;
private int all;
private BlockPos displayBlock;
private static AABB areaOf(Minecraft client, int radius, int up, int down) {
int r = Math.max(1, radius);
int u = Math.max(1, up);
int d = Math.max(1, down);
return new AABB(
client.player.getX() - r,
client.player.getY() - d,
client.player.getZ() - r,
client.player.getX() + r,
client.player.getY() + u,
client.player.getZ() + r
);
}
public void reset() {
queue.clear();
checked.clear();
discovered.clear();
positiveStates.clear();
area = null;
delayTicks = 0;
done = 0;
all = 0;
displayBlock = null;
}
public void tick(
Minecraft client,
int delay,
int radius,
int up,
int down,
boolean fast
) {
if (client == null || client.level == null || client.player == null || client.gameMode == null) {
reset();
return;
}
AABB currentArea = areaOf(client, radius, up, down);
if (area == null || !area.intersects(currentArea)) {
rebuildQueue(client, currentArea, radius, up, down, fast);
}
checked.removeIf(block -> {
if (block.tickAndIsReady()) {
discovered.add(block.pos().asLong());
return true;
}
return false;
});
if (queue.isEmpty()) {
return;
}
if (++delayTicks < Math.max(1, delay)) {
return;
}
delayTicks = 0;
int index = queue.size() <= 1 ? 0 : ThreadLocalRandom.current().nextInt(queue.size());
BlockPos pos = queue.remove(index);
displayBlock = pos;
client.gameMode.startDestroyBlock(pos, client.player.getDirection());
client.gameMode.stopDestroyBlock();
if (client.getConnection() != null) {
client.getConnection().send(new ServerboundSwingPacket(InteractionHand.MAIN_HAND));
}
checked.add(new CheckedBlock(pos));
done++;
}
public void acceptBlockUpdate(BlockPos pos, BlockState state, Predicate<BlockState> targetPredicate) {
if (pos == null || state == null || targetPredicate == null) {
return;
}
if (targetPredicate.test(state)) {
long key = pos.asLong();
discovered.add(key);
positiveStates.put(key, state);
RenderSectionBlockScanner.recordBlock(pos, state);
}
}
public boolean hasPositiveState(BlockPos pos) {
return pos != null && positiveStates.containsKey(pos.asLong());
}
public BlockState getPositiveState(BlockPos pos) {
return pos == null ? null : positiveStates.get(pos.asLong());
}
public List<RenderSectionBlockScanner.ScanResult> snapshot(
Minecraft client,
Predicate<BlockState> targetPredicate,
Predicate<BlockPos> visibilityPredicate,
int maxTargets
) {
if (client == null || client.level == null || maxTargets <= 0 || targetPredicate == null) {
return List.of();
}
List<RenderSectionBlockScanner.ScanResult> results = new ArrayList<>(Math.min(maxTargets, discovered.size()));
for (long key : discovered) {
if (results.size() >= maxTargets) {
break;
}
BlockState state = positiveStates.get(key);
BlockPos pos = BlockPos.of(key);
if (state == null) {
state = RenderSectionBlockScanner.getCachedState(pos);
}
if (state == null) {
state = client.level.getBlockState(pos);
}
if (!targetPredicate.test(state)) {
continue;
}
boolean visible = visibilityPredicate != null && visibilityPredicate.test(pos);
results.add(new RenderSectionBlockScanner.ScanResult(pos, state, visible));
}
return results;
}
public int done() {
return done;
}
public int all() {
return all;
}
public BlockPos displayBlock() {
return displayBlock;
}
private void rebuildQueue(Minecraft client, AABB newArea, int radius, int up, int down, boolean fast) {
queue.clear();
checked.clear();
area = newArea;
done = 0;
displayBlock = null;
int clampedRadius = Math.max(1, radius);
int clampedUp = Math.max(1, up);
int clampedDown = Math.max(1, down);
int px = client.player.getBlockX();
int py = client.player.getBlockY();
int pz = client.player.getBlockZ();
int minY = client.level.getMinY();
int maxY = client.level.getMinY() + client.level.getHeight() - 1;
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (int x = px - clampedRadius; x <= px + clampedRadius; x++) {
for (int y = Math.max(minY, py - clampedDown); y <= Math.min(maxY, py + clampedUp); y++) {
for (int z = pz - clampedRadius; z <= pz + clampedRadius; z++) {
if (fast && (x % 2 == 0 || y % 2 == 0 || z % 2 == 0)) {
continue;
}
mutable.set(x, y, z);
BlockState state = client.level.getBlockState(mutable);
if (!state.isAir()) {
queue.add(mutable.immutable());
}
}
}
}
all = queue.size();
}
private static final class CheckedBlock {
private final BlockPos pos;
private int age;
private CheckedBlock(BlockPos pos) {
this.pos = pos;
}
private BlockPos pos() {
return pos;
}
private boolean tickAndIsReady() {
age++;
return age > 10;
}
}
}

View file

@ -0,0 +1,102 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.scan;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.config.values.collection.ItemIdSetValue;
import java.util.HashSet;
import java.util.Set;
public final class BlockScanContext {
public static final Direction[] DIRECTIONS = new Direction[]{
Direction.UP,
Direction.DOWN,
Direction.NORTH,
Direction.SOUTH,
Direction.EAST,
Direction.WEST
};
private final Minecraft client;
private final ItemIdSetValue targetBlocks;
private volatile Set<Identifier> targetIdCache = Set.of();
private volatile Set<Block> targetBlockCache = Set.of();
private volatile int targetIdCacheHash;
public BlockScanContext(Minecraft client, ItemIdSetValue targetBlocks) {
this.client = client;
this.targetBlocks = targetBlocks;
}
private static Set<Identifier> parseIds(Set<String> rawIds) {
HashSet<Identifier> ids = new HashSet<>(rawIds.size());
for (String raw : rawIds) {
Identifier id = Identifier.tryParse(raw);
if (id != null) {
ids.add(id);
}
}
return ids;
}
private static Set<Block> parseBlocks(Set<Identifier> ids) {
HashSet<Block> blocks = new HashSet<>(ids.size());
for (Identifier id : ids) {
blocks.add(BuiltInRegistries.BLOCK.getValue(id));
}
return blocks;
}
public void refresh() {
if (targetBlocks != null) {
Set<String> rawTargets = targetBlocks.get();
int targetHash = rawTargets.hashCode();
if (targetHash != targetIdCacheHash) {
targetIdCache = parseIds(rawTargets);
targetBlockCache = parseBlocks(targetIdCache);
targetIdCacheHash = targetHash;
}
}
}
public boolean isConfiguredTarget(BlockState state) {
if (state == null || state.isAir() || targetBlocks == null) {
return false;
}
return targetBlockCache.contains(state.getBlock());
}
public Set<Block> targetBlocksSnapshot() {
return targetBlockCache;
}
public boolean isPassable(BlockPos pos) {
if (pos == null || client.level == null) {
return false;
}
BlockState state = client.level.getBlockState(pos);
if (state.isAir()) {
return true;
}
return state.getCollisionShape(client.level, pos).isEmpty();
}
}

View file

@ -0,0 +1,35 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.scan;
import dev.loki.lovisual.config.values.mode.EnumValue;
import java.util.List;
public enum BlockScanMode implements EnumValue.IdProvider, EnumValue.AliasProvider {
LOS("los", "los_unlocked"),
UNRESTRICTED("unrestricted", "aggressive");
private final String id;
private final List<String> aliases;
BlockScanMode(String id, String... aliases) {
this.id = id;
this.aliases = List.of(aliases);
}
@Override
public String id() {
return id;
}
@Override
public List<String> aliases() {
return aliases;
}
}

View file

@ -0,0 +1,72 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.block.scan;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiPredicate;
public enum BlockSearchUtil {
;
public static List<BlockSearchEntry> searchBlocksInCuboid(
BlockGetter world,
Vec3 center,
double range,
BiPredicate<BlockPos, BlockState> filter
) {
return searchBlocksInCuboid(world, center, range, range, filter);
}
public static List<BlockSearchEntry> searchBlocksInCuboid(
BlockGetter world,
Vec3 center,
double rangeXZ,
double rangeY,
BiPredicate<BlockPos, BlockState> filter
) {
if (world == null || center == null || rangeXZ < 0.0 || rangeY < 0.0) {
return List.of();
}
int minX = Mth.floor(center.x - rangeXZ);
int minY = Mth.floor(center.y - rangeY);
int minZ = Mth.floor(center.z - rangeXZ);
int maxX = Mth.floor(center.x + rangeXZ);
int maxY = Mth.floor(center.y + rangeY);
int maxZ = Mth.floor(center.z + rangeXZ);
List<BlockSearchEntry> results = new ArrayList<>();
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (int x = minX; x <= maxX; x++) {
for (int y = minY; y <= maxY; y++) {
for (int z = minZ; z <= maxZ; z++) {
mutable.set(x, y, z);
BlockPos pos = mutable.immutable();
BlockState state = world.getBlockState(pos);
if (filter != null && !filter.test(pos, state)) {
continue;
}
results.add(new BlockSearchEntry(pos, state));
}
}
}
return results;
}
public record BlockSearchEntry(BlockPos pos, BlockState state) {
}
}

View file

@ -0,0 +1,88 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.click;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket;
import net.minecraft.network.protocol.game.ServerboundSwingPacket;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.Items;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.events.impl.network.PacketEvent;
import dev.loki.lovisual.util.combat.rotation.LethalStrikeCalculator;
/**
* Shared modern attack gate logic.
* Tracks outgoing swing/slot packets and exposes a single readiness check.
*/
public final class AttackPressing {
public static final AttackPressing INSTANCE = new AttackPressing();
private static final float READY_COOLDOWN_PROGRESS = 0.9F;
private static final long MINIMUM_COOLDOWN_MS = 500L;
private long lastClickTime = System.currentTimeMillis();
private int clickCount = 0;
private AttackPressing() {
}
public boolean isCooldownComplete(LocalPlayer player, boolean legacyProtocol, int ticks) {
return isCooldownComplete(player, null, legacyProtocol, ticks);
}
public boolean isCooldownComplete(LocalPlayer player, LivingEntity target, boolean legacyProtocol, int ticks) {
if (player == null) return false;
if (legacyProtocol) return true;
int safeTicks = Math.max(0, ticks);
boolean isMace = player.getMainHandItem().is(Items.MACE);
boolean cooldownReady = isMace
|| player.getAttackStrengthScale(safeTicks) > READY_COOLDOWN_PROGRESS
|| LethalStrikeCalculator.canKillNow(player, target, safeTicks);
boolean minimumDelayPassed = lastClickPassed() >= MINIMUM_COOLDOWN_MS;
return cooldownReady && minimumDelayPassed;
}
public boolean willCooldownComplete(LocalPlayer player, boolean legacyProtocol, int ticks) {
return willCooldownComplete(player, null, legacyProtocol, ticks);
}
public boolean willCooldownComplete(LocalPlayer player, LivingEntity target, boolean legacyProtocol, int ticks) {
if (player == null) return false;
if (legacyProtocol) return true;
int safeTicks = Math.max(0, ticks);
boolean isMace = player.getMainHandItem().is(Items.MACE);
boolean cooldownReady = isMace
|| player.getAttackStrengthScale(safeTicks) > READY_COOLDOWN_PROGRESS
|| LethalStrikeCalculator.canKillNow(player, target, safeTicks);
boolean minimumDelayPassed = lastClickPassed() + safeTicks * 50L >= MINIMUM_COOLDOWN_MS;
return cooldownReady && minimumDelayPassed;
}
public long lastClickPassed() {
return System.currentTimeMillis() - lastClickTime;
}
public int clickCount() {
return clickCount;
}
public void recalculate() {
lastClickTime = System.currentTimeMillis();
clickCount++;
}
@EventHandler
private void onPacketSend(PacketEvent.Send event) {
if (event.getPacket() instanceof ServerboundSwingPacket
|| event.getPacket() instanceof ServerboundSetCarriedItemPacket) {
recalculate();
}
}
}

View file

@ -0,0 +1,97 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.click;
import dev.loki.lovisual.config.values.primitive.BooleanMapValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import java.util.concurrent.ThreadLocalRandom;
/**
* Human-like click behavior helper (misses / pauses).
*/
public final class ClickHumanizer {
private final BooleanMapValue toggles;
private final NumberValue<Integer> missRate;
private final NumberValue<Integer> swingRate;
private final NumberValue<Integer> pauseRate;
private final NumberValue<Integer> pauseMin;
private final NumberValue<Integer> pauseMax;
private final String toggleMiss;
private final String toggleMissSwing;
private final String togglePause;
private int pauseTicks = 0;
public ClickHumanizer(BooleanMapValue toggles,
NumberValue<Integer> missRate,
NumberValue<Integer> swingRate,
NumberValue<Integer> pauseRate,
NumberValue<Integer> pauseMin,
NumberValue<Integer> pauseMax,
String toggleMiss,
String toggleMissSwing,
String togglePause) {
this.toggles = toggles;
this.missRate = missRate;
this.swingRate = swingRate;
this.pauseRate = pauseRate;
this.pauseMin = pauseMin;
this.pauseMax = pauseMax;
this.toggleMiss = toggleMiss;
this.toggleMissSwing = toggleMissSwing;
this.togglePause = togglePause;
}
private static boolean roll(int rate) {
if (rate <= 0) return false;
if (rate >= 100) return true;
return ThreadLocalRandom.current().nextInt(1, 101) <= rate;
}
private static int rand(int min, int max) {
if (max < min) max = min;
return ThreadLocalRandom.current().nextInt(min, max + 1);
}
public void reset() {
pauseTicks = 0;
}
public Decision decide() {
if (pauseTicks > 0) {
pauseTicks--;
return Decision.PAUSE;
}
if (toggles.get(togglePause) && roll(pauseRate.get())) {
int min = pauseMin.get();
int max = pauseMax.get();
if (max < min) max = min;
pauseTicks = rand(min, max);
return Decision.PAUSE;
}
if (toggles.get(toggleMissSwing) && roll(swingRate.get())) {
return Decision.MISS_SWING;
}
if (toggles.get(toggleMiss) && roll(missRate.get())) {
return Decision.MISS;
}
return Decision.CLICK;
}
public enum Decision {
CLICK,
MISS,
MISS_SWING,
PAUSE
}
}

View file

@ -0,0 +1,150 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.click;
import java.util.Arrays;
import java.util.concurrent.ThreadLocalRandom;
public final class ClickScheduler {
private static final int CYCLE_LENGTH = 20;
private static final int ITERATIONS = 2;
private final RollingClickArray clickArray = new RollingClickArray(CYCLE_LENGTH, ITERATIONS);
private int minCps;
private int maxCps;
public ClickScheduler(int minCps, int maxCps) {
setCps(minCps, maxCps);
fill();
}
public void setCps(int minCps, int maxCps) {
this.minCps = Math.max(1, minCps);
this.maxCps = Math.max(this.minCps, maxCps);
fill();
}
public void reset() {
fill();
}
public void tick() {
if (clickArray.advance()) {
clickArray.push(generateCycle());
}
}
public boolean shouldClick() {
return willClickAt(0);
}
public int getClicksAt(int tick) {
return getClickAmount(Math.max(0, tick));
}
public boolean willClickAt(int tick) {
return getClicksAt(tick) > 0;
}
public boolean willClickInTicks(int ticks) {
int clamped = Math.max(0, ticks);
for (int i = 0; i <= clamped; i++) {
if (willClickAt(i)) {
return true;
}
}
return false;
}
private int getClickAmount(int tick) {
return clickArray.get(tick);
}
private void fill() {
clickArray.clear();
int[] cycleArray = new int[CYCLE_LENGTH];
for (int i = 0; i < ITERATIONS; i++) {
Arrays.fill(cycleArray, 0);
fillStabilizedPattern(cycleArray);
clickArray.push(cycleArray);
clickArray.advance(CYCLE_LENGTH);
}
}
private int[] generateCycle() {
int[] cycleArray = new int[CYCLE_LENGTH];
fillStabilizedPattern(cycleArray);
return cycleArray;
}
private void fillStabilizedPattern(int[] cycleArray) {
int clicks = randomCps();
int interval = clicks > 0 ? cycleArray.length / clicks : 0;
int remainder = clicks > 0 ? cycleArray.length % clicks : 0;
int currentIndex = 0;
for (int i = 0; i < clicks; i++) {
cycleArray[currentIndex % cycleArray.length]++;
currentIndex += Math.max(interval, 1);
if (remainder > 0) {
currentIndex++;
remainder--;
}
}
}
private int randomCps() {
if (minCps >= maxCps) return minCps;
return ThreadLocalRandom.current().nextInt(minCps, maxCps + 1);
}
private static final class RollingClickArray {
private final int cycleLength;
private final int[] array;
private int head;
private RollingClickArray(int cycleLength, int iterations) {
this.cycleLength = cycleLength;
this.array = new int[cycleLength * iterations];
}
private int get(int relativeIndex) {
int actualIndex = (head + relativeIndex) % array.length;
return array[actualIndex];
}
private boolean advance() {
head = (head + 1) % array.length;
return head % cycleLength == 0;
}
private void advance(int amount) {
head = (head + amount) % array.length;
}
private void clear() {
Arrays.fill(array, 0);
head = 0;
}
private void push(int[] cycleArray) {
if (cycleArray.length != cycleLength) {
throw new IllegalArgumentException("Array size must match cycle length");
}
if (head == 0) {
System.arraycopy(cycleArray, 0, array, cycleLength, cycleLength);
} else if (head == cycleLength) {
System.arraycopy(cycleArray, 0, array, 0, cycleLength);
} else {
throw new IllegalStateException("Head must be at 0 or cycle length");
}
}
}
}

View file

@ -0,0 +1,221 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.attack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.network.protocol.game.ServerboundAttackPacket;
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
import net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.Items;
import dev.loki.lovisual.events.Events;
import dev.loki.lovisual.events.impl.entity.AttackEntityEvent;
import dev.loki.lovisual.features.gui.hud.draggable.impl.targethud.TargetHud;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.combat.pvp.RubberHand;
import dev.loki.lovisual.features.module.modules.misc.sound.HitSounds;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.HitBubbles;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.HitEffect;
import dev.loki.lovisual.mixins.accessors.entity.LocalPlayerAccessor;
import dev.loki.lovisual.mixins.accessors.misc.MultiPlayerGameModeAccessor;
import dev.loki.lovisual.util.item.food.FoodUtil;
import dev.loki.lovisual.util.player.interaction.ResetAttackCooldown;
import dev.loki.lovisual.util.target.TargetManager;
import dev.loki.lovisual.util.combat.rotation.SprintController;
public enum AttackUtil {
;
public static boolean attack(Minecraft mc, LivingEntity target) {
return attack(mc, target, null, null, false, true, false, false);
}
public static boolean attack(Minecraft mc, LivingEntity target, Float yaw, Float pitch, boolean silent) {
return attack(mc, target, yaw, pitch, silent, true, false, false);
}
public static boolean attack(Minecraft mc,
LivingEntity target,
Float yaw,
Float pitch,
boolean silent,
boolean sendRotationPacket) {
return attack(mc, target, yaw, pitch, silent, sendRotationPacket, false);
}
public static boolean attack(Minecraft mc,
LivingEntity target,
Float yaw,
Float pitch,
boolean silent,
boolean sendRotationPacket,
boolean keepSprint) {
if (mc == null || mc.player == null || mc.gameMode == null || target == null) {
return false;
}
LocalPlayer player = mc.player;
boolean usingItem = player.isUsingItem();
if (usingItem) {
RubberHand rh = Modules.get(RubberHand.class);
boolean rhEnabled = rh != null && rh.isEnabled();
boolean usingShield =
player.getUseItem().is(Items.SHIELD) ||
player.getOffhandItem().is(Items.SHIELD);
boolean usingFood = FoodUtil.isFood(player.getUseItem());
if (usingFood) {
if (!rhEnabled || !rh.canAttackWhileEating()) {
return false;
}
return performAttack(mc, player, target, true);
}
if (usingShield) {
if (!rhEnabled || !rh.canAttackWhileShield()) {
return false;
}
boolean usePressed = mc.options.keyUse.isDown();
player.releaseUsingItem();
mc.options.keyUse.setDown(false);
boolean result = performAttack(mc, player, target, keepSprint);
if (usePressed) {
mc.options.keyUse.setDown(true);
}
return result;
}
return false;
}
boolean attacked = performAttack(mc, player, target, keepSprint);
return attacked;
}
public static boolean attack(Minecraft mc,
LivingEntity target,
Float yaw,
Float pitch,
boolean silent,
boolean sendRotationPacket,
boolean resetSprint,
boolean resumeSprint) {
return attack(mc, target, yaw, pitch, silent, sendRotationPacket, false);
}
private static boolean performAttack(Minecraft mc,
LocalPlayer player,
LivingEntity target,
boolean keepSprint) {
if (keepSprint) {
if (!performKeepSprintAttack(mc, player, target)) {
return false;
}
} else {
mc.gameMode.attack(player, target);
}
player.swing(InteractionHand.MAIN_HAND);
return true;
}
public static boolean attackCurrentItem(Minecraft mc, LivingEntity target, boolean keepSprint) {
if (mc == null || mc.player == null || mc.gameMode == null || target == null) {
return false;
}
return performAttack(mc, mc.player, target, keepSprint);
}
private static boolean performKeepSprintAttack(Minecraft mc,
LocalPlayer player,
LivingEntity target) {
if (mc.getConnection() == null) return false;
if (mc.gameMode instanceof MultiPlayerGameModeAccessor accessor) {
accessor.lovisual$syncSelectedSlot();
}
notifyLocalAttack(target);
mc.getConnection().send(new ServerboundAttackPacket(target.getId()));
player.attack(target);
player.resetAttackStrengthTicker();
ResetAttackCooldown.resetAttackCooldown(player);
return true;
}
private static void notifyLocalAttack(LivingEntity target) {
LocalPlayer player = Minecraft.getInstance().player;
if (player != null && Events.BUS.hasListeners(AttackEntityEvent.class)) {
Events.BUS.post(new AttackEntityEvent(player, target));
}
HitSounds hitSounds = Modules.get(HitSounds.class);
if (hitSounds != null) {
hitSounds.handleHit(target);
}
HitEffect hitEffect = Modules.get(HitEffect.class);
if (hitEffect != null) {
hitEffect.handleHit(target);
}
HitBubbles hitBubbles = Modules.get(HitBubbles.class);
if (hitBubbles != null) {
hitBubbles.handleHit(target);
}
TargetHud.notifyHit(target);
TargetManager.onAttack(target);
}
public static void sendRotationPacket(Minecraft mc,
LocalPlayer player,
float yaw,
float pitch) {
if (mc.getConnection() == null) return;
mc.getConnection().send(new ServerboundMovePlayerPacket.PosRot(
player.getX(),
player.getY(),
player.getZ(),
yaw,
pitch,
player.onGround(),
player.horizontalCollision
));
}
public static void setSprinting(Minecraft mc, LocalPlayer player, boolean sprinting) {
if (player == null) return;
if (player.isSprinting() == sprinting) return;
player.setSprinting(sprinting);
if (mc.getConnection() == null) return;
mc.getConnection().send(new ServerboundPlayerCommandPacket(
player,
sprinting ? ServerboundPlayerCommandPacket.Action.START_SPRINTING
: ServerboundPlayerCommandPacket.Action.STOP_SPRINTING
));
}
public static void sendStopSprinting(Minecraft mc, LocalPlayer player) {
sendStopSprinting(mc, player, false);
}
public static void sendStopSprinting(Minecraft mc, LocalPlayer player, boolean force) {
if (player == null || mc.getConnection() == null) return;
if (!force && !player.isSprinting() && !SprintController.INSTANCE.isServerSprinting(player)) return;
mc.getConnection().send(new ServerboundPlayerCommandPacket(
player,
ServerboundPlayerCommandPacket.Action.STOP_SPRINTING
));
if (player instanceof LocalPlayerAccessor accessor) {
accessor.lovisual$setLastSprinting(false);
}
}
}

View file

@ -0,0 +1,206 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.attack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket;
import net.minecraft.world.entity.LivingEntity;
import dev.loki.lovisual.util.click.AttackPressing;
import dev.loki.lovisual.util.click.ClickScheduler;
import java.util.function.BooleanSupplier;
import dev.loki.lovisual.util.combat.attack.AttackUtil;
import dev.loki.lovisual.util.combat.attack.ProtocolAttackExecutor;
import dev.loki.lovisual.util.combat.rotation.SprintController;
/**
* Shared combat strike orchestration layer.
* Keeps sprint reset, pressing gate and attack execution out of KillAura itself.
*/
public final class CombatStrikeController {
public static final CombatStrikeController INSTANCE = new CombatStrikeController();
private CombatStrikeController() {
}
public boolean isAttackReady(LocalPlayer player,
LivingEntity target,
boolean legacyProtocol,
BooleanSupplier criticalGate) {
if (player == null || target == null) {
return false;
}
if (criticalGate != null && !criticalGate.getAsBoolean()) {
return false;
}
int cooldownTicks = legacyProtocol ? 0 : 1;
return AttackPressing.INSTANCE.isCooldownComplete(player, target, legacyProtocol, cooldownTicks);
}
public boolean tryAttack(Minecraft mc,
LivingEntity target,
boolean legacyProtocol,
int clickCount,
SprintResetMode sprintMode,
boolean allowSprintReset,
BooleanSupplier criticalGate) {
if (mc == null || mc.player == null || target == null || clickCount <= 0) {
return false;
}
int cooldownTicks = legacyProtocol ? 0 : 1;
if (!AttackPressing.INSTANCE.isCooldownComplete(mc.player, target, legacyProtocol, cooldownTicks)) {
return false;
}
SprintResetMode resolvedMode = resolveSprintResetMode(sprintMode);
boolean shouldReset = allowSprintReset
&& resolvedMode != SprintResetMode.NONE
&& shouldResetSprintForAttack(mc);
if (shouldReset && !prepareSprintForAttack(mc, target, resolvedMode)) {
return false;
}
if (criticalGate != null && !criticalGate.getAsBoolean()) {
return false;
}
boolean attacked = executeAttacksInternal(mc, target, clickCount, legacyProtocol, false);
if (shouldReset) {
restoreSprintAfterAttack(mc, resolvedMode, true);
}
return attacked;
}
public int resolveClickCount(ClickScheduler clicker, boolean legacyProtocol) {
if (!legacyProtocol) {
return 1;
}
if (clicker == null) {
return 0;
}
int clickCount = clicker.getClicksAt(0);
if (clickCount <= 0) {
return 0;
}
return clickCount;
}
public boolean prepareSprintForAttack(Minecraft mc,
LivingEntity target,
SprintResetMode mode) {
if (mc == null || mc.player == null || target == null) {
return true;
}
if (!shouldResetSprintForAttack(mc)) {
return true;
}
return switch (mode) {
case DEFAULT -> true;
case LEGIT -> SprintController.INSTANCE.prepareLegitAttack(mc, mc.player, 2);
case PACKET -> {
AttackUtil.sendStopSprinting(mc, mc.player);
yield true;
}
case NONE -> true;
};
}
public boolean shouldResetSprintForAttack(Minecraft mc) {
if (mc == null || mc.player == null) {
return false;
}
if (mc.player.isInWater() || mc.player.isUnderWater() || mc.player.isSwimming()) {
return false;
}
if (mc.player.isFallFlying()) {
return false;
}
return SprintController.INSTANCE.isServerSprinting(mc.player) || mc.player.isSprinting();
}
public void restoreSprintAfterAttack(Minecraft mc, SprintResetMode mode, boolean shouldReset) {
if (!shouldReset || mc == null || mc.player == null) {
return;
}
switch (mode) {
case DEFAULT, NONE -> {
}
case LEGIT -> {
// Legit reset: no forced instant sprint restore.
// Sprint comes back naturally from input/autosprint after short block window.
}
case PACKET -> {
if (mc.getConnection() != null) {
mc.getConnection().send(
new ServerboundPlayerCommandPacket(mc.player, ServerboundPlayerCommandPacket.Action.START_SPRINTING)
);
}
}
}
}
public SprintResetMode resolveSprintResetMode(SprintResetMode mode) {
if (mode != SprintResetMode.DEFAULT) {
return mode;
}
// Sprint module removed; default to legit reset.
return SprintResetMode.LEGIT;
}
public void executeAttacks(Minecraft mc, LivingEntity target, int clickCount, boolean legacyProtocol) {
executeAttacksInternal(mc, target, clickCount, legacyProtocol, false);
}
public void executeAttacks(Minecraft mc,
LivingEntity target,
int clickCount,
boolean legacyProtocol,
boolean keepSprint) {
executeAttacksInternal(mc, target, clickCount, legacyProtocol, keepSprint);
}
private boolean executeAttacksInternal(Minecraft mc,
LivingEntity target,
int clickCount,
boolean legacyProtocol,
boolean keepSprint) {
if (mc == null || mc.player == null || target == null || clickCount <= 0) {
return false;
}
boolean attacked = false;
for (int i = 0; i < clickCount; i++) {
if (!target.isAlive() || target.isRemoved()) {
break;
}
attacked |= ProtocolAttackExecutor.attackOnce(mc, mc.player, target, legacyProtocol, keepSprint);
}
return attacked;
}
public enum SprintResetMode {
DEFAULT,
LEGIT,
PACKET,
NONE
}
}

View file

@ -0,0 +1,65 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.attack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.entity.LivingEntity;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.combat.pvp.RubberHand;
import dev.loki.lovisual.util.item.food.FoodUtil;
import dev.loki.lovisual.util.player.interaction.ResetAttackCooldown;
import dev.loki.lovisual.util.combat.attack.AttackUtil;
/**
* Shared protocol-aware attack execution.
* 1.9+ relies on vanilla cooldown reset from attackEntity.
* 1.8 path keeps explicit reset for legacy behavior.
*/
public enum ProtocolAttackExecutor {
;
public static boolean attackOnce(Minecraft mc, LocalPlayer player, LivingEntity target, boolean legacyProtocol) {
return attackOnce(mc, player, target, legacyProtocol, false);
}
public static boolean attackOnce(Minecraft mc,
LocalPlayer player,
LivingEntity target,
boolean legacyProtocol,
boolean keepSprint) {
if (mc == null || player == null || target == null) {
return false;
}
RubberHand rubberHand = Modules.get(RubberHand.class);
boolean rubberFoodAttack = player.isUsingItem()
&& FoodUtil.isFood(player.getUseItem())
&& rubberHand != null
&& rubberHand.isEnabled()
&& rubberHand.canAttackWhileEating();
boolean attacked = rubberFoodAttack
? AttackUtil.attackCurrentItem(mc, target, keepSprint)
: AttackUtil.attack(
mc,
target,
null,
null,
false,
false,
keepSprint
);
if (attacked && legacyProtocol) {
ResetAttackCooldown.resetAttackCooldown(player);
}
return attacked;
}
}

View file

@ -0,0 +1,58 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.hit;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.projectile.ProjectileUtil;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.EntityHitResult;
import net.minecraft.world.phys.Vec3;
/**
* Точная точка удара по хитбоксу цели — трасса луча от камеры вдоль последнего
* известного взгляда игрока. Общая для эффектов, которым важна не позиция цели
* целиком, а конкретное место попадания (HitEffect, HitBubbles).
*/
public enum AttackHitPoint {
;
/** @return точка попадания на хитбоксе {@code target} или null, если трасса не пересекла его. */
public static Vec3 resolve(Entity target, float lastYaw, float lastPitch) {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) return null;
double distance = mc.player.distanceTo(target) + 1.0;
Vec3 start = mc.player.position().add(0, mc.player.getEyeHeight(mc.player.getPose()), 0);
Vec3 rot = rotationVector(lastPitch, lastYaw);
Vec3 end = start.add(rot.scale(distance));
AABB box = mc.player.getBoundingBox().expandTowards(rot.scale(distance)).inflate(1.0, 1.0, 1.0);
EntityHitResult hit = ProjectileUtil.getEntityHitResult(
mc.player,
start,
end,
box,
e -> !e.isSpectator() && e.isPickable() && e == target,
distance * distance
);
return hit != null ? hit.getLocation() : null;
}
private static Vec3 rotationVector(float pitch, float yaw) {
float yawRad = -yaw * ((float) Math.PI / 180F);
float pitchRad = pitch * ((float) Math.PI / 180F);
float cosYaw = Mth.cos(yawRad);
float sinYaw = Mth.sin(yawRad);
float cosPitch = Mth.cos(pitchRad);
float sinPitch = Mth.sin(pitchRad);
return new Vec3(sinYaw * cosPitch, -sinPitch, cosYaw * cosPitch);
}
}

View file

@ -0,0 +1,14 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.protocol;
/**
* Simple protocol version holder (client-side).
*/
public record ClientProtocolVersion(String name, int version) {
}

View file

@ -0,0 +1,170 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.protocol;
import com.viaversion.viafabricplus.ViaFabricPlus;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.SharedConstants;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import dev.loki.lovisual.util.logging.DebugLog;
import dev.loki.lovisual.util.combat.protocol.vfp.VfpCompatibility;
import dev.loki.lovisual.util.combat.protocol.vfp.VfpCompatibility1_8;
import java.util.Map;
import dev.loki.lovisual.util.combat.protocol.ClientProtocolVersion;
import dev.loki.lovisual.util.combat.protocol.heuristics.CombatProtocolHeuristics;
/**
* Protocol helpers with optional ViaFabricPlus support.
*/
public enum ProtocolUtil {
;
public static final ClientProtocolVersion DEFAULT_PROTOCOL_VERSION =
new ClientProtocolVersion(SharedConstants.getCurrentVersion().name(), SharedConstants.getProtocolVersion());
public static final boolean USES_VIAFABRICPLUS = detectViaFabricPlus();
private static boolean detectViaFabricPlus() {
boolean loaded = FabricLoader.getInstance().isModLoaded("viafabricplus");
if (!loaded) {
return false;
}
try {
ViaFabricPlus.getImpl().registerOnChangeProtocolVersionCallback((oldVer, newVer) -> {
// Обновляем заголовок окна лениво — только когда сработает колбэк
Minecraft client = Minecraft.getInstance();
client.execute(client::updateTitle);
});
} catch (Throwable t) {
DebugLog.error("Failed to register ViaFabricPlus protocol callback", t);
}
return true;
}
public static ClientProtocolVersion getProtocolVersion() {
if (USES_VIAFABRICPLUS) {
try {
ClientProtocolVersion version = VfpCompatibility.INSTANCE.unsafeGetProtocolVersion();
return version != null ? version : DEFAULT_PROTOCOL_VERSION;
} catch (Throwable t) {
DebugLog.error("Failed to get protocol version", t);
}
}
return DEFAULT_PROTOCOL_VERSION;
}
public static ClientProtocolVersion[] getProtocolVersions() {
if (USES_VIAFABRICPLUS) {
try {
return VfpCompatibility.INSTANCE.unsafeGetProtocolVersions();
} catch (Throwable t) {
DebugLog.error("Failed to get protocol versions", t);
}
}
return new ClientProtocolVersion[]{DEFAULT_PROTOCOL_VERSION};
}
public static boolean isEqual1_8() {
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isEqual1_8,
"Failed to check if protocol is 1.8");
}
public static boolean isOlderThanOrEqual1_8() {
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isOlderThanOrEqual1_8,
"Failed to check if protocol is <= 1.8");
}
public static boolean isLegacyAttackProtocol(Map<String, Boolean> heuristicSources) {
return CombatProtocolHeuristics.INSTANCE.resolveLegacyOrDefault(
heuristicSources,
isOlderThanOrEqual1_8()
);
}
public static boolean isOlderThanOrEquals1_7_10() {
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isOlderThanOrEqual1_7_10,
"Failed to check if protocol is <= 1.7.10");
}
public static boolean isNewerThanOrEquals1_16() {
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isNewerThanOrEqual1_16,
"Failed to check if protocol is >= 1.16");
}
public static boolean isNewerThanOrEquals1_21_5() {
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isNewerThanOrEqual1_21_5,
"Failed to check if protocol is >= 1.21.5");
}
public static boolean isNewerThanOrEquals1_21_6() {
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isNewerThanOrEqual1_21_6,
"Failed to check if protocol is >= 1.21.6");
}
public static boolean isNewerThanOrEquals1_21_9() {
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isNewerThanOrEqual1_21_9,
"Failed to check if protocol is >= 1.21.9");
}
public static boolean isOlderThanOrEqual1_11_1() {
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isOlderThanOrEqual1_11_1,
"Failed to check if protocol is <= 1.11.1");
}
public static void selectProtocolVersion(int protocolId) {
if (!USES_VIAFABRICPLUS) {
throw new IllegalStateException("ViaFabricPlus is not loaded");
}
VfpCompatibility.INSTANCE.unsafeSelectProtocolVersion(protocolId);
}
public static void openVfpProtocolSelection() {
if (!USES_VIAFABRICPLUS) {
DebugLog.error("ViaFabricPlus is not loaded");
return;
}
VfpCompatibility.INSTANCE.unsafeOpenVfpProtocolSelection();
}
public static void send1_8SignUpdate(BlockPos blockPos, String[] lines) {
if (!USES_VIAFABRICPLUS) {
throw new IllegalStateException("ViaFabricPlus is missing");
}
if (!isEqual1_8()) {
throw new IllegalStateException("Not 1.8 protocol");
}
VfpCompatibility1_8.INSTANCE.sendSignUpdate(blockPos, lines);
}
public static void send1_8PlayerInput(float sideways, float forward, boolean jumping, boolean sneaking) {
if (!USES_VIAFABRICPLUS) {
throw new IllegalStateException("ViaFabricPlus is missing");
}
if (!isEqual1_8()) {
throw new IllegalStateException("Not 1.8 protocol");
}
VfpCompatibility1_8.INSTANCE.sendPlayerInput(sideways, forward, jumping, sneaking);
}
private static boolean safeBoolean(BooleanSupplierCall call, String error) {
try {
return call.getAsBoolean();
} catch (Throwable t) {
DebugLog.error(error, t);
return false;
}
}
@FunctionalInterface
private interface BooleanSupplierCall {
boolean getAsBoolean();
}
}

View file

@ -0,0 +1,24 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.protocol.heuristics;
public enum CombatProtocolHeuristicSource {
MESSAGES("messages"),
OVERLAY("overlay"),
BOSSBAR("bossbar");
private final String key;
CombatProtocolHeuristicSource(String key) {
this.key = key;
}
public String key() {
return key;
}
}

View file

@ -0,0 +1,166 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.protocol.heuristics;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.events.impl.pvp.CombatProtocolBossbarEvent;
import dev.loki.lovisual.events.impl.pvp.PvpChatEvent;
import dev.loki.lovisual.events.impl.pvp.PvpOverlayEvent;
import dev.loki.lovisual.util.logging.DebugLog;
import dev.loki.lovisual.util.text.format.LegacyTextUtil;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import dev.loki.lovisual.util.combat.protocol.heuristics.CombatProtocolHeuristicSource;
import dev.loki.lovisual.util.combat.protocol.heuristics.CombatProtocolHeuristicsConfig;
public final class CombatProtocolHeuristics {
public static final CombatProtocolHeuristics INSTANCE = new CombatProtocolHeuristics();
private static final long SIGNAL_TTL_MS = 300_000L;
private final Object lock = new Object();
private final EnumMap<CombatProtocolHeuristicSource, Signal> signals =
new EnumMap<>(CombatProtocolHeuristicSource.class);
private CombatProtocolHeuristics() {
}
public static LinkedHashMap<String, Boolean> defaultSourceToggles() {
LinkedHashMap<String, Boolean> defaults = new LinkedHashMap<>();
for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) {
defaults.put(source.key(), source == CombatProtocolHeuristicSource.BOSSBAR);
}
return defaults;
}
public Optional<Boolean> resolveLegacy(Map<String, Boolean> sourceToggles) {
long now = System.currentTimeMillis();
synchronized (lock) {
Signal best = null;
for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) {
if (!sourceEnabled(sourceToggles, source)) continue;
Signal signal = signals.get(source);
if (signal == null || now - signal.timeMs > SIGNAL_TTL_MS) continue;
if (best == null || signal.timeMs > best.timeMs) {
best = signal;
}
}
return best == null ? Optional.empty() : Optional.of(best.legacy);
}
}
public boolean resolveLegacyOrDefault(Map<String, Boolean> sourceToggles, boolean fallbackLegacy) {
return resolveLegacy(sourceToggles).orElse(fallbackLegacy);
}
@EventHandler
public void onOverlay(PvpOverlayEvent event) {
if (event == null || event.message == null) return;
accept(CombatProtocolHeuristicSource.OVERLAY, event.message.getString(), event.timeMs);
}
@EventHandler
public void onChat(PvpChatEvent event) {
if (event == null || event.message == null) return;
accept(CombatProtocolHeuristicSource.MESSAGES, event.message, event.timeMs);
}
@EventHandler
public void onBossbar(CombatProtocolBossbarEvent event) {
if (event == null || event.names == null || event.names.isEmpty()) return;
for (String name : event.names) {
accept(CombatProtocolHeuristicSource.BOSSBAR, name, event.timeMs);
}
}
public void accept(CombatProtocolHeuristicSource source, String raw, long timeMs) {
if (source == null || raw == null || raw.isBlank()) return;
Boolean legacy = detectLegacy(source, raw);
if (legacy == null) return;
synchronized (lock) {
signals.put(source, new Signal(legacy, timeMs, raw));
}
if (DebugLog.serverOnly()) {
DebugLog.server("Combat protocol heuristic: source=%s legacy=%s raw=\"%s\"",
source.key(), legacy, raw);
}
}
public static Boolean detectLegacy(String raw) {
return detectLegacy(CombatProtocolHeuristicSource.BOSSBAR, raw);
}
public static Boolean detectLegacy(CombatProtocolHeuristicSource source, String raw) {
String text = normalize(raw);
if (text.isBlank()) return null;
CombatProtocolHeuristicSource resolvedSource = source == null
? CombatProtocolHeuristicSource.BOSSBAR
: source;
CombatProtocolHeuristicsConfig config = CombatProtocolHeuristicsConfig.get();
boolean legacy = matchesAny(text, compile(config.patterns(
resolvedSource,
CombatProtocolHeuristicsConfig.ProtocolFamily.LEGACY
)));
boolean modern = matchesAny(text, compile(config.patterns(
resolvedSource,
CombatProtocolHeuristicsConfig.ProtocolFamily.MODERN
)));
if (legacy == modern) return null;
return legacy;
}
private static boolean sourceEnabled(Map<String, Boolean> toggles, CombatProtocolHeuristicSource source) {
return toggles != null && Boolean.TRUE.equals(toggles.get(source.key()));
}
private static String normalize(String raw) {
String text = LegacyTextUtil.stripLegacy(raw);
if (text == null) return "";
return text.trim().toLowerCase(Locale.ROOT);
}
private static boolean matchesAny(String text, Pattern[] patterns) {
if (text == null || text.isBlank() || patterns == null) return false;
for (Pattern pattern : patterns) {
if (pattern != null && pattern.matcher(text).find()) return true;
}
return false;
}
private static Pattern[] compile(Iterable<String> patterns) {
if (patterns == null) return new Pattern[0];
List<String> values = new java.util.ArrayList<>();
for (String pattern : patterns) {
if (pattern != null && !pattern.isBlank()) values.add(pattern);
}
return values.stream()
.map(CombatProtocolHeuristics::compileOne)
.filter(pattern -> pattern != null)
.toArray(Pattern[]::new);
}
private static Pattern compileOne(String pattern) {
try {
return Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
} catch (PatternSyntaxException ignored) {
return null;
}
}
private record Signal(boolean legacy, long timeMs, String raw) {
}
}

View file

@ -0,0 +1,110 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.protocol.heuristics;
import dev.loki.lovisual.config.definition.ConfigNameProvider;
import dev.loki.lovisual.config.definition.ConfigObject;
import dev.loki.lovisual.config.io.ConfigSerializer;
import dev.loki.lovisual.config.values.collection.ConfigValue;
import dev.loki.lovisual.config.values.collection.SetValue;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import dev.loki.lovisual.util.combat.protocol.heuristics.CombatProtocolHeuristicSource;
public final class CombatProtocolHeuristicsConfig implements ConfigObject, ConfigNameProvider {
public static final CombatProtocolHeuristicsConfig INSTANCE = new CombatProtocolHeuristicsConfig();
public static final String DEFAULT_LEGACY_PATTERN = "\\b1[._\\s-]+8(?:\\b|\\+)";
public static final String DEFAULT_MODERN_PATTERN = "\\b1[._\\s-]+(?:9|[1-9][0-9])(?:[._\\s-]+[0-9]+)*(?:\\b|\\+)";
private final EnumMap<CombatProtocolHeuristicSource, SetValue> legacyPatterns =
new EnumMap<>(CombatProtocolHeuristicSource.class);
private final EnumMap<CombatProtocolHeuristicSource, SetValue> modernPatterns =
new EnumMap<>(CombatProtocolHeuristicSource.class);
private CombatProtocolHeuristicsConfig() {
for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) {
legacyPatterns.put(source, new SetValue(source.key() + "_legacy_patterns", defaultLegacyPatterns()));
modernPatterns.put(source, new SetValue(source.key() + "_modern_patterns", defaultModernPatterns()));
}
ConfigSerializer.load(this);
}
public static CombatProtocolHeuristicsConfig get() {
return INSTANCE;
}
public Set<String> patterns(CombatProtocolHeuristicSource source, ProtocolFamily family) {
SetValue value = value(source, family);
return value == null ? Set.of() : new LinkedHashSet<>(value.get());
}
public void setPatterns(CombatProtocolHeuristicSource source, ProtocolFamily family, Iterable<String> patterns) {
SetValue value = value(source, family);
if (value == null) return;
LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (patterns != null) {
for (String pattern : patterns) {
if (pattern == null) continue;
String clean = pattern.trim();
if (!clean.isEmpty()) normalized.add(clean);
}
}
value.set(normalized);
save();
}
public void reset(CombatProtocolHeuristicSource source) {
if (source == null) return;
legacyPatterns.get(source).set(defaultLegacyPatterns());
modernPatterns.get(source).set(defaultModernPatterns());
save();
}
public void save() {
ConfigSerializer.requestSave(this);
}
@Override
public String getConfigName() {
return "combatprotocolheuristics";
}
@Override
public List<ConfigValue<?>> getConfigValues() {
List<ConfigValue<?>> values = new ArrayList<>();
for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) {
values.add(legacyPatterns.get(source));
values.add(modernPatterns.get(source));
}
return values;
}
private SetValue value(CombatProtocolHeuristicSource source, ProtocolFamily family) {
if (source == null || family == null) return null;
return family == ProtocolFamily.LEGACY ? legacyPatterns.get(source) : modernPatterns.get(source);
}
private static Set<String> defaultLegacyPatterns() {
return Set.of(DEFAULT_LEGACY_PATTERN);
}
private static Set<String> defaultModernPatterns() {
return Set.of(DEFAULT_MODERN_PATTERN);
}
public enum ProtocolFamily {
LEGACY,
MODERN
}
}

View file

@ -0,0 +1,161 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.protocol.vfp;
import com.viaversion.viafabricplus.ViaFabricPlus;
import com.viaversion.viaversion.api.protocol.version.ProtocolVersion;
import com.viaversion.viaversion.api.protocol.version.VersionType;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.TitleScreen;
import dev.loki.lovisual.util.logging.DebugLog;
import dev.loki.lovisual.util.combat.protocol.ClientProtocolVersion;
/**
* Compatibility layer for ViaFabricPlus.
* <p>
* DO NOT CALL ANY OF THESE METHODS WITHOUT CHECKING IF VIAFABRICPLUS IS LOADED.
*/
public enum VfpCompatibility {
INSTANCE;
public ClientProtocolVersion unsafeGetProtocolVersion() {
try {
ProtocolVersion version = ViaFabricPlus.getImpl().getTargetVersion();
return new ClientProtocolVersion(version.getName(), version.getVersion());
} catch (Throwable t) {
DebugLog.error("Failed to get protocol version", t);
return null;
}
}
public ClientProtocolVersion[] unsafeGetProtocolVersions() {
try {
var protocols = ProtocolVersion.getProtocols()
.stream()
.filter(version -> version.getVersionType() == VersionType.RELEASE)
.map(version -> new ClientProtocolVersion(version.getName(), version.getVersion()))
.toArray(ClientProtocolVersion[]::new);
for (int left = 0, right = protocols.length - 1; left < right; left++, right--) {
var tmp = protocols[left];
protocols[left] = protocols[right];
protocols[right] = tmp;
}
return protocols;
} catch (Throwable t) {
DebugLog.error("Failed to get protocol versions", t);
return new ClientProtocolVersion[0];
}
}
public void unsafeOpenVfpProtocolSelection() {
try {
var currentScreen = ClientScreen.current(Minecraft.getInstance());
if (currentScreen == null) {
currentScreen = new TitleScreen();
}
ViaFabricPlus.getImpl().openProtocolSelectionScreen(currentScreen);
} catch (Throwable t) {
DebugLog.error("Failed to open ViaFabricPlus screen", t);
}
}
public void unsafeSelectProtocolVersion(int protocolId) {
try {
if (!ProtocolVersion.isRegistered(protocolId)) {
throw new IllegalArgumentException("Protocol version is not registered");
}
ProtocolVersion version = ProtocolVersion.getProtocol(protocolId);
ViaFabricPlus.getImpl().setTargetVersion(version);
} catch (Throwable t) {
DebugLog.error("Failed to select protocol version", t);
}
}
public boolean isEqual1_8() {
try {
var version = ViaFabricPlus.getImpl().getTargetVersion();
return version.equalTo(ProtocolVersion.v1_8);
} catch (Throwable t) {
DebugLog.error("Failed to check if protocol is 1.8", t);
return false;
}
}
public boolean isOlderThanOrEqual1_8() {
try {
var version = ViaFabricPlus.getImpl().getTargetVersion();
return version.olderThanOrEqualTo(ProtocolVersion.v1_8);
} catch (Throwable t) {
DebugLog.error("Failed to check if protocol is <= 1.8", t);
return false;
}
}
public boolean isOlderThanOrEqual1_7_10() {
try {
var version = ViaFabricPlus.getImpl().getTargetVersion();
return version.olderThanOrEqualTo(ProtocolVersion.v1_7_6);
} catch (Throwable t) {
DebugLog.error("Failed to check if protocol is <= 1.7.10", t);
return false;
}
}
public boolean isNewerThanOrEqual1_16() {
try {
var version = ViaFabricPlus.getImpl().getTargetVersion();
return version.newerThanOrEqualTo(ProtocolVersion.v1_16);
} catch (Throwable t) {
DebugLog.error("Failed to check if protocol is >= 1.16", t);
return false;
}
}
public boolean isNewerThanOrEqual1_21_5() {
try {
var version = ViaFabricPlus.getImpl().getTargetVersion();
return version.newerThanOrEqualTo(ProtocolVersion.v1_21_5);
} catch (Throwable t) {
DebugLog.error("Failed to check if protocol is >= 1.21.5", t);
return false;
}
}
public boolean isNewerThanOrEqual1_21_6() {
try {
var version = ViaFabricPlus.getImpl().getTargetVersion();
return version.newerThanOrEqualTo(ProtocolVersion.v1_21_6);
} catch (Throwable t) {
DebugLog.error("Failed to check if protocol is >= 1.21.6", t);
return false;
}
}
public boolean isNewerThanOrEqual1_21_9() {
try {
var version = ViaFabricPlus.getImpl().getTargetVersion();
return version.newerThanOrEqualTo(ProtocolVersion.v1_21_9);
} catch (Throwable t) {
DebugLog.error("Failed to check if protocol is >= 1.21.9", t);
return false;
}
}
public boolean isOlderThanOrEqual1_11_1() {
try {
var version = ViaFabricPlus.getImpl().getTargetVersion();
return version.olderThanOrEqualTo(ProtocolVersion.v1_11_1);
} catch (Throwable t) {
DebugLog.error("Failed to check if protocol is <= 1.11.1", t);
return false;
}
}
}

View file

@ -0,0 +1,67 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.protocol.vfp;
import com.viaversion.viafabricplus.ViaFabricPlus;
import com.viaversion.viaversion.api.minecraft.BlockPosition;
import com.viaversion.viaversion.api.protocol.packet.PacketWrapper;
import com.viaversion.viaversion.api.protocol.packet.ServerboundPacketType;
import com.viaversion.viaversion.api.type.Types;
import com.viaversion.viaversion.protocols.v1_8to1_9.packet.ServerboundPackets1_8;
import net.minecraft.core.BlockPos;
import java.util.function.Consumer;
import dev.loki.lovisual.util.combat.protocol.vfp.VfpCompatibility;
/**
* Compatibility layer for ViaFabricPlus on protocol 1.8.
* <p>
* DO NOT CALL ANY OF THESE METHODS WITHOUT CHECKING IF VIAFABRICPLUS IS LOADED AND YOU ARE ON 1.8 PROTOCOL.
*/
public enum VfpCompatibility1_8 {
INSTANCE;
public void sendSignUpdate(final BlockPos blockPos, final String[] lines) throws IllegalArgumentException {
if (lines.length != 4) {
throw new IllegalArgumentException("Lines length does not match 4");
}
writePacket(ServerboundPackets1_8.SIGN_UPDATE, packet -> {
packet.write(Types.BLOCK_POSITION1_8, new BlockPosition(blockPos.getX(), blockPos.getY(), blockPos.getZ()));
for (String line : lines) {
packet.write(Types.STRING, line);
}
});
}
public void sendPlayerInput(float sideways, float forwards, boolean jumping, boolean sneaking) {
writePacket(ServerboundPackets1_8.PLAYER_INPUT, packet -> {
packet.write(Types.FLOAT, sideways);
packet.write(Types.FLOAT, forwards);
byte b = 0;
if (jumping) {
b = (byte) (b | 1);
}
if (sneaking) {
b = (byte) (b | 2);
}
packet.write(Types.BYTE, b);
});
}
private void writePacket(ServerboundPacketType packetType, Consumer<PacketWrapper> writer) {
if (!VfpCompatibility.INSTANCE.isEqual1_8()) {
throw new IllegalStateException("Not on 1.8 protocol");
}
var packet = PacketWrapper.create(packetType, ViaFabricPlus.getImpl().getPlayNetworkUserConnection());
writer.accept(packet);
packet.sendToServerRaw();
}
}

View file

@ -0,0 +1,101 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.rotation;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.util.raycast.RaycastUtil;
import dev.loki.lovisual.util.target.TargetingUtil;
/**
* Shared combat rotation-mode helpers for modules that can operate with or without
* synthetic rotation control.
*/
public enum CombatRotationModeUtil {
;
public static final String MODE_ROTATIONS = "ROTATIONS";
public static final String MODE_NO_ROTATIONS = "NO_ROTATIONS";
public static boolean usesRotations(ModeValue modeValue) {
return modeValue != null && MODE_ROTATIONS.equalsIgnoreCase(modeValue.get());
}
public static boolean isNoRotations(ModeValue modeValue) {
return modeValue != null && MODE_NO_ROTATIONS.equalsIgnoreCase(modeValue.get());
}
public static boolean canAttackWithoutRotations(Minecraft mc, LivingEntity target, double range) {
return canAttackWithoutRotations(mc, target, range, false);
}
public static boolean canAttackWithoutRotations(Minecraft mc,
LivingEntity target,
double range,
boolean requireLineOfSight) {
if (mc == null || mc.player == null || target == null || !target.isAlive()) {
return false;
}
double safeRange = Math.max(0.0, range);
Vec3 eyes = mc.player.getEyePosition();
double distSq = TargetingUtil.distanceToEntityBoxSq(eyes, target);
if (distSq > safeRange * safeRange) {
return false;
}
return !requireLineOfSight || hasLineOfSightToEntityBox(eyes, target.getBoundingBox(), safeRange);
}
private static boolean hasLineOfSightToEntityBox(Vec3 eyes, AABB box, double range) {
if (eyes == null || box == null) {
return false;
}
if (box.contains(eyes)) {
return true;
}
double maxDistanceSq = range * range + 1.0E-6;
double centerX = (box.minX + box.maxX) * 0.5;
double centerY = (box.minY + box.maxY) * 0.5;
double centerZ = (box.minZ + box.maxZ) * 0.5;
double lowerY = box.minY + Math.min(0.25, Math.max(0.0, (box.maxY - box.minY) * 0.25));
double upperY = box.maxY - Math.min(0.25, Math.max(0.0, (box.maxY - box.minY) * 0.25));
return isVisiblePointInRange(eyes, clampToBox(eyes, box), maxDistanceSq)
|| isVisiblePointInRange(eyes, new Vec3(centerX, centerY, centerZ), maxDistanceSq)
|| isVisiblePointInRange(eyes, new Vec3(centerX, lowerY, centerZ), maxDistanceSq)
|| isVisiblePointInRange(eyes, new Vec3(centerX, upperY, centerZ), maxDistanceSq)
|| isVisiblePointInRange(eyes, new Vec3(box.minX, centerY, centerZ), maxDistanceSq)
|| isVisiblePointInRange(eyes, new Vec3(box.maxX, centerY, centerZ), maxDistanceSq)
|| isVisiblePointInRange(eyes, new Vec3(centerX, centerY, box.minZ), maxDistanceSq)
|| isVisiblePointInRange(eyes, new Vec3(centerX, centerY, box.maxZ), maxDistanceSq);
}
private static Vec3 clampToBox(Vec3 point, AABB box) {
return new Vec3(
clamp(point.x, box.minX, box.maxX),
clamp(point.y, box.minY, box.maxY),
clamp(point.z, box.minZ, box.maxZ)
);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
private static boolean isVisiblePointInRange(Vec3 eyes, Vec3 point, double maxDistanceSq) {
return point != null
&& eyes.distanceToSqr(point) <= maxDistanceSq
&& RaycastUtil.hasLineOfSightPoint(eyes, point);
}
}

View file

@ -0,0 +1,267 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.rotation;
import net.minecraft.core.Holder;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.tags.EntityTypeTags;
import net.minecraft.util.Mth;
import net.minecraft.world.damagesource.CombatRules;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.EquipmentSlotGroup;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.ai.attributes.AttributeModifier;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.MaceItem;
import net.minecraft.world.item.enchantment.Enchantment;
import net.minecraft.world.item.enchantment.EnchantmentHelper;
import net.minecraft.world.item.enchantment.Enchantments;
import dev.loki.lovisual.util.player.health.PlayerHealthResolver;
import dev.loki.lovisual.util.player.effect.heuristics.StatusEffectInference;
import dev.loki.lovisual.util.player.effect.tracker.StatusEffectTracker;
public enum LethalStrikeCalculator {
;
private static final float FULL_STRENGTH_THRESHOLD = 0.9f;
private static final float LETHAL_MARGIN = 1.25f;
public static boolean canKillNow(Player attacker, LivingEntity target, int cooldownTicks) {
if (attacker == null || target == null || !target.isAlive() || target.isRemoved()) {
return false;
}
if (!target.isAttackable() || target.isInvulnerable()) {
return false;
}
if (target instanceof Player player && hasUnstableDefensiveState(player)) {
return false;
}
float damage = estimateFinalDamage(attacker, target, Math.max(0, cooldownTicks));
float effectiveHealth = PlayerHealthResolver.resolve(target).totalHealth();
return damage >= effectiveHealth + LETHAL_MARGIN;
}
private static float estimateFinalDamage(Player attacker, LivingEntity target, int cooldownTicks) {
ItemStack weapon = attacker.getMainHandItem();
DamageSource source = getDamageSource(attacker, weapon);
float cooldown = attacker.getAttackStrengthScale(cooldownTicks + 0.5f);
float cooldownScale = 0.2f + cooldown * cooldown * 0.8f;
float baseDamage = (float) attacker.getAttributeValue(Attributes.ATTACK_DAMAGE);
float handBase = (float) attacker.getAttributeBaseValue(Attributes.ATTACK_DAMAGE);
float weaponBonus = getMainHandAttackDamageBonus(weapon);
if (weaponBonus > 0.0f && Math.abs(baseDamage - handBase) < 0.0001f) {
baseDamage += weaponBonus;
}
float enchantDamage = getEnchantmentDamage(attacker, target, weapon, source, baseDamage) - baseDamage;
if (enchantDamage < 0.0f) {
enchantDamage = 0.0f;
}
float damage = baseDamage * cooldownScale + enchantDamage * cooldown;
damage += weapon.getItem().getAttackDamageBonus(target, damage, source);
if (!(attacker.level() instanceof ServerLevel)) {
damage += getClientMaceDensityBonus(attacker, target, weapon, source);
}
if (cooldown > FULL_STRENGTH_THRESHOLD && isCriticalHit(attacker, target)) {
damage *= 1.5f;
}
if (damage <= 0.0f) {
return 0.0f;
}
damage = applyArmor(attacker, target, weapon, source, damage);
damage = applyResistance(target, damage);
damage = applyProtection(target, source, damage);
return Math.max(damage, 0.0f);
}
private static DamageSource getDamageSource(Player attacker, ItemStack weapon) {
if (attacker.level() != null) {
return weapon.getDamageSource(attacker);
}
return attacker.damageSources().playerAttack(attacker);
}
private static float getEnchantmentDamage(Player attacker,
Entity target,
ItemStack weapon,
DamageSource source,
float baseDamage) {
if (attacker.level() instanceof ServerLevel serverWorld) {
return EnchantmentHelper.modifyDamage(serverWorld, weapon, target, source, baseDamage);
}
float damage = baseDamage;
int sharpness = getEnchantmentLevel(attacker, Enchantments.SHARPNESS, weapon);
if (sharpness > 0) {
damage += 1.0f + (sharpness - 1) * 0.5f;
}
int smite = getEnchantmentLevel(attacker, Enchantments.SMITE, weapon);
if (smite > 0 && target.getType().builtInRegistryHolder().is(EntityTypeTags.SENSITIVE_TO_SMITE)) {
damage += smite * 2.5f;
}
int bane = getEnchantmentLevel(attacker, Enchantments.BANE_OF_ARTHROPODS, weapon);
if (bane > 0 && target.getType().builtInRegistryHolder().is(EntityTypeTags.SENSITIVE_TO_BANE_OF_ARTHROPODS)) {
damage += bane * 2.5f;
}
return damage;
}
private static float applyArmor(Player attacker,
LivingEntity target,
ItemStack weapon,
DamageSource source,
float damage) {
float armor = target.getArmorValue();
float toughness = (float) target.getAttributeValue(Attributes.ARMOR_TOUGHNESS);
if (attacker.level() instanceof ServerLevel) {
return CombatRules.getDamageAfterAbsorb(target, damage, source, armor, toughness);
}
float f = 2.0f + toughness / 4.0f;
float g = Mth.clamp(armor - damage / f, armor * 0.2f, 20.0f);
float armorEffectiveness = getArmorEffectiveness(attacker, weapon, g / 25.0f);
armorEffectiveness = Mth.clamp(armorEffectiveness, 0.0f, 1.0f);
return damage * (1.0f - armorEffectiveness);
}
private static float getArmorEffectiveness(Player attacker, ItemStack weapon, float baseArmorEffectiveness) {
int breach = getEnchantmentLevel(attacker, Enchantments.BREACH, weapon);
if (breach <= 0) {
return baseArmorEffectiveness;
}
return baseArmorEffectiveness - 0.15f * breach;
}
private static float getClientMaceDensityBonus(Player attacker,
Entity target,
ItemStack weapon,
DamageSource source) {
if (!(weapon.getItem() instanceof MaceItem) || !MaceItem.canSmashAttack(attacker)) {
return 0.0f;
}
int density = getEnchantmentLevel(attacker, Enchantments.DENSITY, weapon);
return density > 0 ? (float) (density * 0.5f * attacker.fallDistance) : 0.0f;
}
private static float applyResistance(LivingEntity target, float damage) {
MobEffectInstance resistance = target.getEffect(MobEffects.RESISTANCE);
if (resistance == null) {
return damage;
}
int reduced = 25 - (resistance.getAmplifier() + 1) * 5;
return Math.max(damage * reduced / 25.0f, 0.0f);
}
private static float applyProtection(LivingEntity target, DamageSource source, float damage) {
float protection = getProtectionAmount(target);
if (protection <= 0.0f || source.is(net.minecraft.tags.DamageTypeTags.BYPASSES_INVULNERABILITY)) {
return damage;
}
return CombatRules.getDamageAfterMagicAbsorb(damage, protection);
}
private static float getProtectionAmount(LivingEntity target) {
float total = 0.0f;
total += getEnchantmentLevel(target, Enchantments.PROTECTION, target.getItemBySlot(EquipmentSlot.HEAD));
total += getEnchantmentLevel(target, Enchantments.PROTECTION, target.getItemBySlot(EquipmentSlot.CHEST));
total += getEnchantmentLevel(target, Enchantments.PROTECTION, target.getItemBySlot(EquipmentSlot.LEGS));
total += getEnchantmentLevel(target, Enchantments.PROTECTION, target.getItemBySlot(EquipmentSlot.FEET));
return total;
}
private static float getMainHandAttackDamageBonus(ItemStack stack) {
if (stack == null || stack.isEmpty()) {
return 0.0f;
}
final double[] add = {0.0};
final double[] mulBase = {0.0};
final double[] mulTotal = {1.0};
stack.forEachModifier(EquipmentSlotGroup.MAINHAND, (attribute, modifier, display) -> {
if (!attribute.equals(Attributes.ATTACK_DAMAGE)) {
return;
}
AttributeModifier.Operation op = modifier.operation();
double amount = modifier.amount();
if (op == AttributeModifier.Operation.ADD_VALUE) {
add[0] += amount;
} else if (op == AttributeModifier.Operation.ADD_MULTIPLIED_BASE) {
mulBase[0] += amount;
} else if (op == AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL) {
mulTotal[0] *= 1.0 + amount;
}
});
return (float) ((add[0] + mulBase[0]) * mulTotal[0]);
}
private static boolean isCriticalHit(Player attacker, Entity target) {
return attacker.fallDistance > 0.0f
&& !attacker.onGround()
&& !attacker.onClimbable()
&& !attacker.isInWater()
&& !attacker.isMobilityRestricted()
&& !attacker.isPassenger()
&& target instanceof LivingEntity
&& !attacker.isSprinting();
}
private static boolean hasUnstableDefensiveState(Player player) {
if (StatusEffectInference.isDefensiveConsumeLikely(player)
|| StatusEffectInference.hasPendingDefensiveEffect(player)) {
return true;
}
int entityId = player.getId();
for (MobEffectInstance effect : StatusEffectTracker.get(entityId)) {
Holder<MobEffect> type = effect.getEffect();
if (StatusEffectInference.isDefensiveHealthEffect(type)
&& StatusEffectTracker.isHeuristic(entityId, type)) {
return true;
}
}
return false;
}
private static int getEnchantmentLevel(LivingEntity entity, ResourceKey<Enchantment> key, ItemStack stack) {
Holder<Enchantment> entry = getEnchantmentEntry(entity, key);
return entry != null ? EnchantmentHelper.getItemEnchantmentLevel(entry, stack) : 0;
}
private static Holder<Enchantment> getEnchantmentEntry(LivingEntity entity, ResourceKey<Enchantment> key) {
if (entity == null || entity.level() == null) {
return null;
}
Registry<Enchantment> registry = entity.level().registryAccess().lookupOrThrow(Registries.ENCHANTMENT);
Enchantment enchantment = registry.getValue(key);
return enchantment != null ? registry.wrapAsHolder(enchantment) : null;
}
}

View file

@ -0,0 +1,89 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.rotation;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.combat.pvp.RubberHand;
import dev.loki.lovisual.util.item.food.FoodUtil;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.item.Items;
import java.util.function.BooleanSupplier;
public final class RubberHandUseUtil {
private RubberHandUseUtil() {
}
/**
* Executes a block-use action while RubberHand allows bypassing the currently active item.
*
* The helper mirrors RubberHand's release/restore pattern for module block interactions, so
* AutoAnchor/AutoBed can place and detonate while shield/food active-use bypass is enabled
* without permanently dropping the user's held use key.
*/
public static boolean runBlockUse(Minecraft mc, BooleanSupplier action) {
if (action == null) return false;
if (mc == null || mc.player == null) return action.getAsBoolean();
LocalPlayer player = mc.player;
RubberHand rubberHand = Modules.get(RubberHand.class);
if (!canBypassCurrentUse(mc, player, rubberHand)) {
return action.getAsBoolean();
}
boolean restoreUseKey = mc.options != null && mc.options.keyUse.isDown();
player.releaseUsingItem();
if (mc.options != null) {
mc.options.keyUse.setDown(false);
}
try {
return action.getAsBoolean();
} finally {
if (restoreUseKey && mc.options != null) {
mc.options.keyUse.setDown(true);
}
}
}
public static boolean canBypassCurrentUse(Minecraft mc) {
if (mc == null || mc.player == null) return false;
return canBypassCurrentUse(mc, mc.player, Modules.get(RubberHand.class));
}
public static boolean isUsingShield(LocalPlayer player) {
if (player == null || !player.isUsingItem()) return false;
return player.getUseItem().is(Items.SHIELD) || player.getOffhandItem().is(Items.SHIELD);
}
public static boolean isUsingFood(LocalPlayer player) {
return player != null && player.isUsingItem() && FoodUtil.isFood(player.getUseItem());
}
private static boolean canBypassCurrentUse(Minecraft mc, LocalPlayer player, RubberHand rubberHand) {
if (mc == null
|| mc.options == null
|| player == null
|| rubberHand == null
|| !rubberHand.isEnabled()
|| !player.isUsingItem()) {
return false;
}
if (isUsingShield(player)) {
return rubberHand.canBypassShieldUse();
}
if (isUsingFood(player)) {
return rubberHand.canBypassFoodUse();
}
return false;
}
}

View file

@ -0,0 +1,289 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.rotation;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.events.impl.render.GameTickEvent;
import dev.loki.lovisual.events.impl.network.PacketEvent;
import dev.loki.lovisual.events.impl.input.SprintControlEvent;
import dev.loki.lovisual.mixins.accessors.entity.LocalPlayerAccessor;
import dev.loki.lovisual.util.combat.attack.AttackUtil;
/**
* Shared sprint state/controller layer.
* <p>
* AutoSprint is applied only as PlayerInput.sprint through Source.INPUT.
* Movement/network hooks may only force sprint false when vanilla would stop it
* or when legit attack reset blocks sprint for a few ticks.
*/
public final class SprintController {
public static final SprintController INSTANCE = new SprintController();
private static final int MAX_LEGIT_BLOCK_TICKS = 3;
private boolean serverSprinting;
private int blockSprintUntilAge = Integer.MIN_VALUE;
private int requestedSprintInputUntilAge = Integer.MIN_VALUE;
private SprintController() {
}
public void requestLegitStop(Minecraft mc, LocalPlayer player, int blockTicks) {
if (mc == null || player == null) {
return;
}
int safeBlockTicks = Math.max(1, Math.min(MAX_LEGIT_BLOCK_TICKS, blockTicks));
serverSprinting = false;
blockSprintUntilAge = Math.max(blockSprintUntilAge, player.tickCount + safeBlockTicks);
requestedSprintInputUntilAge = Integer.MIN_VALUE;
player.setSprinting(false);
AttackUtil.sendStopSprinting(mc, player, true);
}
public boolean prepareLegitAttack(Minecraft mc, LocalPlayer player, int blockTicks) {
if (player == null) {
return true;
}
boolean localSprinting = player.isSprinting();
boolean remoteSprinting = isServerSprinting(player);
if (!localSprinting && !remoteSprinting) {
return true;
}
requestLegitStop(mc, player, blockTicks);
return false;
}
public boolean isServerSprinting(LocalPlayer player) {
return player != null
&& serverSprinting
&& !player.isFallFlying()
&& !player.isInWater();
}
public void clearSprintBlock() {
blockSprintUntilAge = Integer.MIN_VALUE;
}
public boolean isSprintBlocked() {
return shouldBlockSprint();
}
public boolean canStartSprinting(LocalPlayer player) {
if (player == null) {
return false;
}
if (shouldBlockSprint()) {
return false;
}
if (shouldStopSprintingForControl(player)) {
return false;
}
return ((LocalPlayerAccessor) player).lovisual$canStartSprinting();
}
/**
* Compatibility for old callers.
* Does not call setSprinting(true). It requests sprint input instead.
*/
public boolean requestStartSprinting(Minecraft mc, LocalPlayer player, boolean hasForwardMotion) {
if (mc == null || player == null || !hasForwardMotion) {
return false;
}
if (shouldBlockSprint()) {
return false;
}
if (shouldStopSprintingForControl(player)) {
return false;
}
if (player.input == null || !player.input.hasForwardImpulse()) {
return false;
}
requestedSprintInputUntilAge = Math.max(requestedSprintInputUntilAge, player.tickCount + 1);
return true;
}
public boolean requestStartSprinting(Minecraft mc,
LocalPlayer player,
boolean hasForwardMotion,
int ticks) {
if (mc == null || player == null || !hasForwardMotion) {
return false;
}
if (shouldBlockSprint() || shouldStopSprintingForControl(player)) {
return false;
}
if (player.input == null || !player.input.hasForwardImpulse()) {
return false;
}
int safeTicks = Math.max(1, ticks);
requestedSprintInputUntilAge = Math.max(requestedSprintInputUntilAge, player.tickCount + safeTicks);
return true;
}
@EventHandler
private void onSprintControl(SprintControlEvent event) {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null || player.input == null) {
return;
}
if (shouldBlockSprint() || shouldStopSprintingForControl(player)) {
event.setSprint(false);
return;
}
if (event.getSource() == SprintControlEvent.Source.INPUT) {
if (shouldPressSprintInput(player, event.shouldSprint())) {
event.setSprint(true);
}
}
}
private boolean shouldPressSprintInput(LocalPlayer player, boolean original) {
if (player == null || player.input == null) {
return original;
}
if (isSprintInputRequested(player) && player.input.hasForwardImpulse()) {
return true;
}
return original;
}
private boolean isSprintInputRequested(LocalPlayer player) {
if (player == null) {
requestedSprintInputUntilAge = Integer.MIN_VALUE;
return false;
}
if (requestedSprintInputUntilAge == Integer.MIN_VALUE) {
return false;
}
if (player.tickCount > requestedSprintInputUntilAge) {
requestedSprintInputUntilAge = Integer.MIN_VALUE;
return false;
}
return true;
}
private boolean shouldVanillaStopSprinting(LocalPlayer player) {
LocalPlayerAccessor accessor = (LocalPlayerAccessor) player;
if (player.isSwimming()) {
return accessor.lovisual$shouldStopSwimSprinting();
}
return accessor.lovisual$shouldStopSprinting();
}
public boolean shouldStopSprintingForControl(LocalPlayer player) {
if (player == null) {
return false;
}
if (shouldBlockSprint()) {
return true;
}
if (!shouldVanillaStopSprinting(player)) {
return false;
}
return !player.horizontalCollision || shouldStopAtHorizontalObstacle();
}
private boolean shouldStopAtHorizontalObstacle() {
// Sprint module removed; always stop sprinting at horizontal obstacles.
return true;
}
@EventHandler(priority = 3000)
private void onPacketSend(PacketEvent.Send event) {
if (!(event.getPacket() instanceof ServerboundPlayerCommandPacket command)) {
return;
}
if (command.getAction() == ServerboundPlayerCommandPacket.Action.START_SPRINTING && shouldBlockSprint()) {
event.cancel();
}
}
@EventHandler
private void onPacketSendPost(PacketEvent.SendPost event) {
if (!(event.getPacket() instanceof ServerboundPlayerCommandPacket command)) {
return;
}
switch (command.getAction()) {
case START_SPRINTING -> {
serverSprinting = true;
clearSprintBlock();
}
case STOP_SPRINTING -> serverSprinting = false;
default -> {
}
}
}
@EventHandler
private void onGameTick(GameTickEvent event) {
Minecraft mc = Minecraft.getInstance();
if (mc.player != null && mc.level != null) {
return;
}
serverSprinting = false;
clearSprintBlock();
requestedSprintInputUntilAge = Integer.MIN_VALUE;
}
private boolean shouldBlockSprint() {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null) {
clearSprintBlock();
return false;
}
if (blockSprintUntilAge == Integer.MIN_VALUE) {
return false;
}
if (player.tickCount >= blockSprintUntilAge) {
clearSprintBlock();
return false;
}
return true;
}
}

View file

@ -0,0 +1,306 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.tracking;
import com.mojang.authlib.GameProfile;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.equipment.Equippable;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.events.impl.render.GameTickEvent;
import dev.loki.lovisual.events.impl.network.PacketEvent;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public final class AntiBotTracker {
public static final AntiBotTracker INSTANCE = new AntiBotTracker();
private static final long SUSPECT_TTL_MS = 8_000L;
private static final long TAB_TTL_MS = 30_000L;
private static final int BOT_SCORE = 4;
private static final EquipmentSlot[] ARMOR_SLOTS = {
EquipmentSlot.HEAD,
EquipmentSlot.CHEST,
EquipmentSlot.LEGS,
EquipmentSlot.FEET
};
private final ConcurrentHashMap<UUID, TabInfo> tab = new ConcurrentHashMap<>();
private final ConcurrentHashMap<UUID, SuspectInfo> suspects = new ConcurrentHashMap<>();
private final ConcurrentHashMap.KeySetView<UUID, Boolean> bots = ConcurrentHashMap.newKeySet();
private AntiBotTracker() {
}
@EventHandler
private void onPacketReceivePost(PacketEvent.ReceivePost event) {
if (event.getPacket() instanceof ClientboundPlayerInfoUpdatePacket packet) {
handlePlayerList(packet);
} else if (event.getPacket() instanceof ClientboundPlayerInfoRemovePacket packet) {
handlePlayerRemove(packet);
}
}
@EventHandler
private void onGameTick(GameTickEvent event) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null || mc.player == null) {
reset();
return;
}
long now = System.currentTimeMillis();
suspects.entrySet().removeIf(entry -> now - entry.getValue().seenAtMs > SUSPECT_TTL_MS);
tab.entrySet().removeIf(entry -> {
UUID uuid = entry.getKey();
return now - entry.getValue().updatedAtMs > TAB_TTL_MS
&& mc.getConnection() != null
&& mc.getConnection().getPlayerInfo(uuid) == null
&& mc.level.getPlayerByUUID(uuid) == null;
});
bots.removeIf(uuid -> mc.level.getPlayerByUUID(uuid) == null && !isKnownInTab(uuid));
for (UUID uuid : suspects.keySet()) {
Player player = mc.level.getPlayerByUUID(uuid);
if (player != null && assess(player).score() >= BOT_SCORE) {
bots.add(uuid);
}
}
}
public boolean isBot(Player player) {
Minecraft mc = Minecraft.getInstance();
if (player == null || mc.player == null || player == mc.player) {
return false;
}
UUID uuid = player.getUUID();
if (bots.contains(uuid)) {
return true;
}
BotAssessment assessment = assess(player);
if (assessment.score() >= BOT_SCORE) {
bots.add(uuid);
return true;
}
return false;
}
public void reset() {
tab.clear();
suspects.clear();
bots.clear();
}
private void handlePlayerList(ClientboundPlayerInfoUpdatePacket packet) {
long now = System.currentTimeMillis();
boolean add = packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER);
boolean listedAffects = add || packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED);
boolean latencyAffects = add || packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LATENCY);
for (ClientboundPlayerInfoUpdatePacket.Entry entry : packet.entries()) {
UUID uuid = entry.profileId();
TabInfo info = tab.computeIfAbsent(uuid, TabInfo::new);
info.updatedAtMs = now;
GameProfile profile = entry.profile();
if (profile != null) {
info.name = profile.name();
info.propertiesMissing = profile.properties() == null || profile.properties().isEmpty();
}
if (listedAffects) {
info.listed = entry.listed();
info.listedKnown = true;
}
if (latencyAffects) {
info.latency = entry.latency();
info.latencyKnown = true;
}
if (add && profile != null) {
if (hasDuplicateProfile(profile.name(), profile.id())) {
bots.add(uuid);
} else if (isSuspiciousAddition(entry, profile)) {
suspects.put(uuid, new SuspectInfo(now));
}
}
}
}
private void handlePlayerRemove(ClientboundPlayerInfoRemovePacket packet) {
for (UUID uuid : packet.profileIds()) {
tab.remove(uuid);
suspects.remove(uuid);
bots.remove(uuid);
}
}
private BotAssessment assess(Player player) {
Minecraft mc = Minecraft.getInstance();
String name = player.getGameProfile().name();
UUID uuid = player.getUUID();
TabInfo info = tab.get(uuid);
PlayerInfo networkEntry = mc.getConnection() != null
? mc.getConnection().getPlayerInfo(uuid)
: null;
boolean knownInTab = info != null || networkEntry != null;
boolean listed = info == null || !info.listedKnown || info.listed;
boolean suspect = suspects.containsKey(uuid);
boolean profilePropertiesMissing = info != null && info.propertiesMissing;
boolean npcName = looksLikeNpcName(name);
int score = 0;
if (isMatrixNameBot(name)) {
score += 4;
}
if (hasDuplicateProfile(name, uuid)) {
score += 4;
}
if (!knownInTab && player.tickCount > 20) {
score += 4;
} else if (!listed && player.tickCount > 40) {
score += 3;
}
if (suspect) {
score += 2;
}
if (profilePropertiesMissing && suspect) {
score += 1;
}
if (player.isInvisible() && (!knownInTab || !listed) && !npcName) {
score += 3;
}
if (suspect && isFullyCleanArmored(player)) {
score += 2;
}
if (isNonOfflineUuid(player) && player.isInvisible() && !npcName && (!knownInTab || !listed)) {
score += 2;
}
return new BotAssessment(score);
}
private boolean isSuspiciousAddition(ClientboundPlayerInfoUpdatePacket.Entry entry, GameProfile profile) {
if (profile == null || profile.name() == null || profile.name().isBlank()) {
return true;
}
if (looksLikeNpcName(profile.name())) {
return false;
}
boolean propertiesMissing = profile.properties() == null || profile.properties().isEmpty();
return propertiesMissing && entry.latency() >= 2;
}
private boolean hasDuplicateProfile(String name, UUID self) {
Minecraft mc = Minecraft.getInstance();
if (name == null || name.isBlank() || self == null) {
return false;
}
String normalized = name.toLowerCase(Locale.ROOT);
for (TabInfo info : tab.values()) {
if (info.name != null
&& info.name.toLowerCase(Locale.ROOT).equals(normalized)
&& !self.equals(info.uuid)) {
return true;
}
}
if (mc.getConnection() == null) {
return false;
}
for (PlayerInfo entry : mc.getConnection().getOnlinePlayers()) {
GameProfile profile = entry.getProfile();
if (profile != null
&& profile.name() != null
&& profile.name().equalsIgnoreCase(name)
&& !self.equals(profile.id())) {
return true;
}
}
return false;
}
private boolean isKnownInTab(UUID uuid) {
Minecraft mc = Minecraft.getInstance();
if (uuid == null) {
return false;
}
if (tab.containsKey(uuid)) {
return true;
}
return mc.getConnection() != null && mc.getConnection().getPlayerInfo(uuid) != null;
}
private boolean isFullyCleanArmored(Player player) {
for (EquipmentSlot slot : ARMOR_SLOTS) {
ItemStack stack = player.getItemBySlot(slot);
Equippable equippable = stack.get(DataComponents.EQUIPPABLE);
if (stack.isEmpty() || equippable == null || equippable.slot() != slot || stack.isEnchanted()) {
return false;
}
}
return true;
}
private boolean isMatrixNameBot(String name) {
return name != null
&& name.startsWith("CIT-")
&& !name.contains("NPC")
&& !name.contains("[ZNPC]");
}
private boolean looksLikeNpcName(String name) {
if (name == null) {
return false;
}
String upper = name.toUpperCase(Locale.ROOT);
return upper.contains("NPC") || upper.startsWith("[ZNPC]");
}
private boolean isNonOfflineUuid(Player player) {
String name = player.getGameProfile().name();
if (name == null || name.isBlank()) {
return false;
}
UUID offline = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
return !player.getUUID().equals(offline);
}
private static final class TabInfo {
private final UUID uuid;
private String name;
private boolean listed = true;
private boolean listedKnown;
private int latency;
private boolean latencyKnown;
private boolean propertiesMissing;
private long updatedAtMs;
private TabInfo(UUID uuid) {
this.uuid = uuid;
}
}
private record SuspectInfo(long seenAtMs) {
}
private record BotAssessment(int score) {
}
}

View file

@ -0,0 +1,83 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat.tracking;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import java.util.Locale;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.overlay.ReachDisplay;
/**
* Tracks the reach (eye -> target hitbox distance) of the player's last attack
* for the ReachDisplay HUD widget. Measurement math is pure and unit-tested;
* state is a volatile snapshot written on the attack event.
*/
public final class ReachMeter {
/** Vanilla survival attack range, m. Distances above are tinted as overreach. */
public static final double VANILLA_REACH = 3.0;
private static volatile double lastDistance = -1.0;
private static volatile double sessionMax = -1.0;
private static volatile long lastAttackAtMs;
private ReachMeter() {
}
public static void record(double distance, long nowMs) {
if (Double.isNaN(distance) || distance < 0.0) return;
lastDistance = distance;
lastAttackAtMs = nowMs;
if (distance > sessionMax) {
sessionMax = distance;
}
}
public static double lastDistance() {
return lastDistance;
}
public static double sessionMax() {
return sessionMax;
}
public static long lastAttackAtMs() {
return lastAttackAtMs;
}
public static void reset() {
lastDistance = -1.0;
sessionMax = -1.0;
lastAttackAtMs = 0L;
}
/**
* Distance from a point to the closest point of an AABB (per-axis clamp).
* Returns 0 when the point is inside the box.
*/
public static double distanceToBox(Vec3 point, AABB box) {
if (point == null || box == null) return -1.0;
double dx = axisDelta(point.x, box.minX, box.maxX);
double dy = axisDelta(point.y, box.minY, box.maxY);
double dz = axisDelta(point.z, box.minZ, box.maxZ);
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
private static double axisDelta(double value, double min, double max) {
if (value < min) return min - value;
if (value > max) return value - max;
return 0.0;
}
/** Formats meters for display, e.g. "3.12 m"; "-" when no measurement yet. */
public static String format(double distance) {
if (distance < 0.0) return "-";
return String.format(Locale.ROOT, "%.2f m", distance);
}
}

View file

@ -0,0 +1,371 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.entity;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.events.impl.movement.MovementInputEvent;
import dev.loki.lovisual.render.helpers.util.TickDelta;
public enum EagleUtil {
;
private static final double STEP_HEIGHT = 0.5;
private static final double MIN_INPUT_LENGTH_SQUARED = 1.0E-6;
private static final double MIN_AXIS_COMPONENT = 0.18;
private static final double MAX_RESCUE_LOOKAHEAD = 0.42;
private static final double MIN_TICK_DELTA_LEAD = 0.25;
private static final double CENTER_DEAD_ANGLE_COS = 0.34;
private static final int DIAGONAL_RESCUE_TICKS = 2;
public static EdgeCheck checkEdge(LocalPlayer player, MovementInputEvent event, double edgeDistance) {
return checkEdge(player, event, edgeDistance, TickDelta.get());
}
public static EdgeCheck checkEdge(LocalPlayer player, MovementInputEvent event, double edgeDistance, double tickDelta) {
if (player == null || event == null) {
return EdgeCheck.empty();
}
Vec3 inputDirection = movementInputToWorld(player, event);
if (inputDirection.lengthSqr() < MIN_INPUT_LENGTH_SQUARED) {
return EdgeCheck.empty();
}
Vec3 movementDirection = inputDirection.normalize();
Vec3 horizontalVelocity = new Vec3(player.getDeltaMovement().x, 0.0, player.getDeltaMovement().z);
double velocityLength = horizontalVelocity.horizontalDistance();
double distance = Math.max(0.0, edgeDistance);
double lookAhead = Math.min(MAX_RESCUE_LOOKAHEAD, distance + velocityLength);
double firstLead = Math.max(MIN_TICK_DELTA_LEAD, clamp01(tickDelta));
Vec3 partialPos = player.position().add(horizontalVelocity.scale(firstLead));
Vec3 nextPartialPos = player.position().add(horizontalVelocity.scale(firstLead + 0.5));
boolean closeToEdge = wouldFallAlong(player, player.position(), movementDirection, distance)
|| wouldFallAlong(player, partialPos, movementDirection, distance)
|| wouldFallAlong(player, nextPartialPos, movementDirection, distance);
boolean diagonalRescue = isDiagonal(event)
&& wouldDiagonalFall(player, movementDirection, horizontalVelocity, Math.max(distance, lookAhead), firstLead);
return new EdgeCheck(closeToEdge || diagonalRescue, diagonalRescue, movementDirection);
}
public static boolean isCloseToEdge(LocalPlayer player, MovementInputEvent event, double edgeDistance) {
return checkEdge(player, event, edgeDistance).closeToEdge();
}
public static boolean shouldDiagonalRescue(LocalPlayer player, MovementInputEvent event, double edgeDistance) {
return checkEdge(player, event, edgeDistance).diagonalRescue();
}
public static double horizontalSpeed(LocalPlayer player) {
if (player == null) {
return 0.0;
}
Vec3 velocity = player.getDeltaMovement();
return Math.hypot(velocity.x, velocity.z);
}
public static Vec3 movementInputToWorld(LocalPlayer player, MovementInputEvent event) {
if (player == null || event == null) {
return Vec3.ZERO;
}
float forward = impulse(event.isForward(), event.isBackward());
float sideways = impulse(event.isRight(), event.isLeft());
if (forward == 0.0f && sideways == 0.0f) {
return Vec3.ZERO;
}
double rad = Math.toRadians(player.getYRot());
double sin = Math.sin(rad);
double cos = Math.cos(rad);
double x = sideways * cos - forward * sin;
double z = forward * cos + sideways * sin;
return new Vec3(x, 0.0, z);
}
public static boolean wouldBeCloseToFallOff(LocalPlayer player, Vec3 position) {
if (player == null || position == null) {
return false;
}
EntityDimensions dimensions = player.getDimensions(player.getPose());
AABB hitbox = dimensions.makeBoundingBox(position)
.inflate(-0.05, 0.0, -0.05)
.move(0.0, player.fallDistance - STEP_HEIGHT, 0.0);
return player.level().noCollision(player, hitbox);
}
private static boolean wouldDiagonalFall(
LocalPlayer player,
Vec3 movementDirection,
Vec3 horizontalVelocity,
double distance,
double firstLead
) {
Vec3 base = player.position().add(horizontalVelocity.scale(firstLead));
Vec3 predicted = player.position().add(horizontalVelocity.scale(firstLead + 0.5));
Vec3 predictedSecond = player.position().add(horizontalVelocity.scale(firstLead + 1.0));
if (wouldFallAlong(player, base, movementDirection, distance)
|| wouldFallAlong(player, predicted, movementDirection, distance)
|| wouldFallAlong(player, predictedSecond, movementDirection, distance)) {
return true;
}
Vec3 xAxis = Math.abs(movementDirection.x) > MIN_AXIS_COMPONENT
? new Vec3(Math.signum(movementDirection.x), 0.0, 0.0)
: Vec3.ZERO;
Vec3 zAxis = Math.abs(movementDirection.z) > MIN_AXIS_COMPONENT
? new Vec3(0.0, 0.0, Math.signum(movementDirection.z))
: Vec3.ZERO;
return (xAxis != Vec3.ZERO && wouldFallAlong(player, predicted, xAxis, distance))
|| (zAxis != Vec3.ZERO && wouldFallAlong(player, predicted, zAxis, distance))
|| (xAxis != Vec3.ZERO && zAxis != Vec3.ZERO
&& wouldFallAlong(player, predicted, xAxis.add(zAxis).normalize(), distance));
}
private static boolean wouldFallAlong(LocalPlayer player, Vec3 base, Vec3 direction, double distance) {
if (player == null || base == null || direction == null || direction.lengthSqr() < MIN_INPUT_LENGTH_SQUARED) {
return false;
}
Vec3 normalized = direction.normalize();
double clampedDistance = Math.max(0.0, distance);
if (clampedDistance <= 1.0E-7) {
return wouldBeCloseToFallOff(player, base);
}
for (int i = 1; i <= 3; i++) {
double scale = clampedDistance * i / 3.0;
Vec3 checkedPos = base.add(normalized.x * scale, 0.0, normalized.z * scale);
if (wouldBeCloseToFallOff(player, checkedPos)) {
return true;
}
}
return false;
}
private static boolean isDiagonal(MovementInputEvent event) {
return event != null
&& event.isForward() != event.isBackward()
&& event.isLeft() != event.isRight();
}
private static float impulse(boolean positive, boolean negative) {
if (positive == negative) return 0.0f;
return positive ? 1.0f : -1.0f;
}
private static double clamp01(double value) {
if (Double.isNaN(value)) return 0.0;
return Math.max(0.0, Math.min(1.0, value));
}
public enum RecoveryMode {
STOP,
INVERT,
CENTER
}
public static final class EdgeRecovery {
private Vec3 center;
private int overwriteTicks;
private int sneakTicks;
private static Vec3 blockBottomCenter(LocalPlayer player) {
return new Vec3(
player.blockPosition().getX() + 0.5,
player.blockPosition().getY(),
player.blockPosition().getZ() + 0.5
);
}
private static double horizontalDistanceSquared(Vec3 a, Vec3 b) {
double x = a.x - b.x;
double z = a.z - b.z;
return x * x + z * z;
}
public boolean handleDiagonalRescue(LocalPlayer player, MovementInputEvent event, double edgeDistance) {
if (player == null || event == null) {
return false;
}
updateSafeCenter(player);
EdgeCheck edgeCheck = checkEdge(player, event, edgeDistance);
if (edgeCheck.diagonalRescue() && shouldOverwriteInput(player)) {
overwriteTicks = Math.max(overwriteTicks, DIAGONAL_RESCUE_TICKS);
}
if (overwriteTicks <= 0) {
return false;
}
overwriteTicks--;
applyInputTowardsCenter(player, event);
event.setJump(false);
event.setSneak(true);
event.setSprint(false);
updateSafeCenter(player);
return true;
}
public boolean handleOnEdge(
LocalPlayer player,
MovementInputEvent event,
double edgeDistance,
RecoveryMode mode,
int keepTicks,
int requestedSneakTicks,
boolean jump
) {
if (player == null || event == null) {
return false;
}
updateSafeCenter(player);
double distance = Math.min(horizontalSpeed(player), Math.max(0.0, edgeDistance));
EdgeCheck edgeCheck = checkEdge(player, event, distance);
if (edgeCheck.closeToEdge() && shouldOverwriteInput(player)) {
overwriteTicks = Math.max(overwriteTicks, Math.max(1, keepTicks));
sneakTicks = Math.max(sneakTicks, Math.max(0, requestedSneakTicks));
}
boolean applied = false;
if (overwriteTicks > 0) {
overwriteTicks--;
applyRecoveryInput(player, event, mode);
event.setJump(jump);
event.setSprint(false);
applied = true;
}
if (sneakTicks > 0) {
sneakTicks--;
event.setSneak(true);
applied = true;
}
updateSafeCenter(player);
return applied;
}
public void reset() {
center = null;
overwriteTicks = 0;
sneakTicks = 0;
}
private boolean shouldOverwriteInput(LocalPlayer player) {
Vec3 center = this.center;
if (center == null) {
return true;
}
Vec3 horizontalVelocity = new Vec3(player.getDeltaMovement().x, 0.0, player.getDeltaMovement().z);
if (horizontalVelocity.horizontalDistanceSqr() <= 0.02 * 0.02) {
return true;
}
double currentDistance = horizontalDistanceSquared(center, player.position());
double nextDistance = horizontalDistanceSquared(center, player.position().add(horizontalVelocity));
return nextDistance > currentDistance;
}
private void applyRecoveryInput(LocalPlayer player, MovementInputEvent event, RecoveryMode mode) {
RecoveryMode resolvedMode = mode != null ? mode : RecoveryMode.CENTER;
if (resolvedMode == RecoveryMode.INVERT) {
boolean forward = event.isForward();
boolean backward = event.isBackward();
boolean left = event.isLeft();
boolean right = event.isRight();
event.setForward(backward);
event.setBackward(forward);
event.setLeft(right);
event.setRight(left);
event.setJump(false);
return;
}
if (resolvedMode == RecoveryMode.STOP && horizontalSpeed(player) <= 0.05) {
event.setForward(false);
event.setBackward(false);
event.setLeft(false);
event.setRight(false);
event.setJump(false);
return;
}
applyInputTowardsCenter(player, event);
}
private void applyInputTowardsCenter(LocalPlayer player, MovementInputEvent event) {
Vec3 center = this.center != null ? this.center : blockBottomCenter(player);
Vec3 toCenter = center.subtract(player.position());
Vec3 horizontal = new Vec3(toCenter.x, 0.0, toCenter.z);
if (horizontal.lengthSqr() < 0.015 * 0.015) {
event.setForward(false);
event.setBackward(false);
event.setLeft(false);
event.setRight(false);
return;
}
Vec3 direction = horizontal.normalize();
double rad = Math.toRadians(player.getYRot());
double sin = Math.sin(rad);
double cos = Math.cos(rad);
double localForward = direction.z * cos - direction.x * sin;
double localSideways = direction.x * cos + direction.z * sin;
boolean forward = localForward > CENTER_DEAD_ANGLE_COS;
boolean backward = localForward < -CENTER_DEAD_ANGLE_COS;
boolean left = localSideways < -CENTER_DEAD_ANGLE_COS;
boolean right = localSideways > CENTER_DEAD_ANGLE_COS;
if (!forward && !backward && !left && !right) {
if (Math.abs(localForward) >= Math.abs(localSideways)) {
forward = localForward > 0.0;
backward = localForward < 0.0;
} else {
left = localSideways < 0.0;
right = localSideways > 0.0;
}
}
event.setForward(forward);
event.setBackward(backward);
event.setLeft(left);
event.setRight(right);
}
private void updateSafeCenter(LocalPlayer player) {
Vec3 blockCenter = blockBottomCenter(player);
if (!wouldBeCloseToFallOff(player, blockCenter)) {
center = blockCenter;
}
}
}
public record EdgeCheck(boolean closeToEdge, boolean diagonalRescue, Vec3 movementDirection) {
private static EdgeCheck empty() {
return new EdgeCheck(false, false, Vec3.ZERO);
}
}
}

View file

@ -0,0 +1,51 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.entity;
import com.mojang.authlib.GameProfile;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.player.RemotePlayer;
import java.util.UUID;
public class FreecamEntity extends RemotePlayer {
private static final int FREECAM_ID_BASE = -1_100_000;
private static final int FREECAM_ID_RANGE = 10_000;
private static int nextFreecamEntityId = FREECAM_ID_BASE;
public FreecamEntity(ClientLevel world) {
super(world, new GameProfile(UUID.randomUUID(), "freecam"));
this.setId(allocateFreecamEntityId(world));
this.noPhysics = true;
this.setInvisible(true);
this.setInvulnerable(true);
}
private static int allocateFreecamEntityId(ClientLevel world) {
for (int i = 0; i < FREECAM_ID_RANGE; i++) {
int candidate = nextFreecamEntityId--;
if (nextFreecamEntityId < FREECAM_ID_BASE - FREECAM_ID_RANGE) {
nextFreecamEntityId = FREECAM_ID_BASE;
}
if (world.getEntity(candidate) == null) {
return candidate;
}
}
return FREECAM_ID_BASE;
}
@Override
public boolean shouldShowName() {
return false;
}
@Override
public boolean shouldRenderAtSqrDistance(double distance) {
return false; // якорь: в мире может существовать, но не рисуется
}
}

View file

@ -0,0 +1,311 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.entity.simulation;
import net.minecraft.core.BlockPos;
import net.minecraft.tags.FluidTags;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
import net.minecraft.world.level.block.LilyPadBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.BooleanOp;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.events.impl.render.GameTickEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
public final class BoatSimulationCache {
public static final BoatSimulationCache INSTANCE = new BoatSimulationCache();
private static final double BOAT_GRAVITY = 0.04;
private final Map<AbstractBoat, SimulatedBoatCache> cache = new WeakHashMap<>();
private BoatSimulationCache() {
}
public static SimulatedBoatCache getSimulation(AbstractBoat boat) {
return INSTANCE.cache.computeIfAbsent(boat, SimulatedBoatCache::new);
}
@EventHandler
private void onGameTick(GameTickEvent event) {
cache.clear();
}
private enum Location {
IN_WATER,
UNDER_WATER,
UNDER_FLOWING_WATER,
ON_LAND,
IN_AIR
}
public static final class SimulatedBoatCache {
private final SimulatedBoat simulator;
private final List<BoatSnapshot> snapshots = new ArrayList<>();
private int simulatedTicks;
private SimulatedBoatCache(AbstractBoat boat) {
this.simulator = new SimulatedBoat(boat);
this.snapshots.add(new BoatSnapshot(simulator.pos, simulator.yaw));
}
public BoatSnapshot getSnapshotAt(int ticks) {
while (simulatedTicks < ticks) {
simulator.tick();
snapshots.add(new BoatSnapshot(simulator.pos, simulator.yaw));
simulatedTicks++;
}
return snapshots.get(ticks);
}
}
public record BoatSnapshot(Vec3 pos, float yaw) {
}
private static final class SimulatedBoat {
private final AbstractBoat boat;
private final float yaw;
private Vec3 pos;
private Vec3 velocity;
private AABB boundingBox;
private float yawVelocity;
private double waterLevel;
private float nearbySlipperiness;
private Location location;
private Location lastLocation;
private double fallVelocity;
private SimulatedBoat(AbstractBoat boat) {
this.boat = boat;
this.pos = boat.position();
this.velocity = boat.getDeltaMovement();
this.boundingBox = boat.getBoundingBox();
this.yaw = boat.getYRot();
this.yawVelocity = 0.0f;
this.location = checkLocation();
this.lastLocation = location;
}
private void tick() {
lastLocation = location;
location = checkLocation();
updateVelocity();
move();
}
private void move() {
Vec3 adjusted = Entity.collideBoundingBox(boat, velocity, boundingBox, boat.level(), List.of());
pos = pos.add(adjusted);
boundingBox = boat.getBoundingBox().move(pos.subtract(boat.position()));
velocity = adjusted;
}
private Location checkLocation() {
Location underWater = getUnderWaterLocation();
if (underWater != null) {
waterLevel = boundingBox.maxY;
return underWater;
}
if (checkBoatInWater()) {
return Location.IN_WATER;
}
float slipperiness = getNearbySlipperiness();
if (slipperiness > 0.0f) {
nearbySlipperiness = slipperiness;
return Location.ON_LAND;
}
return Location.IN_AIR;
}
private void updateVelocity() {
double gravity = -boat.getGravity();
double buoyancy = 0.0;
float drag = 0.05f;
if (lastLocation == Location.IN_AIR && location != Location.IN_AIR && location != Location.ON_LAND) {
waterLevel = pos.y + boat.getBbHeight();
double waterHeightBelow = getWaterHeightBelow() - boat.getBbHeight() + 0.101;
AABB movedBox = boundingBox.move(0.0, waterHeightBelow - pos.y, 0.0);
if (boat.level().noCollision(boat, movedBox)) {
pos = new Vec3(pos.x, waterHeightBelow, pos.z);
boundingBox = movedBox;
velocity = new Vec3(velocity.x, 0.0, velocity.z);
fallVelocity = 0.0;
}
location = Location.IN_WATER;
} else {
if (location == Location.IN_WATER) {
buoyancy = (waterLevel - pos.y) / boat.getBbHeight();
drag = 0.9f;
} else if (location == Location.UNDER_FLOWING_WATER) {
gravity = -7.0E-4;
drag = 0.9f;
} else if (location == Location.UNDER_WATER) {
buoyancy = 0.01f;
drag = 0.45f;
} else if (location == Location.IN_AIR) {
drag = 0.9f;
} else if (location == Location.ON_LAND) {
drag = nearbySlipperiness;
if (boat.getControllingPassenger() instanceof Player) {
nearbySlipperiness /= 2.0f;
}
}
velocity = new Vec3(velocity.x * drag, velocity.y + gravity, velocity.z * drag);
yawVelocity *= drag;
if (buoyancy > 0.0) {
velocity = new Vec3(
velocity.x,
(velocity.y + buoyancy * (BOAT_GRAVITY / 0.65)) * 0.75,
velocity.z
);
}
}
}
private float getWaterHeightBelow() {
AABB box = boundingBox;
int minX = Mth.floor(box.minX);
int maxX = Mth.ceil(box.maxX);
int minY = Mth.floor(box.maxY);
int maxY = Mth.ceil(box.maxY - fallVelocity);
int minZ = Mth.floor(box.minZ);
int maxZ = Mth.ceil(box.maxZ);
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (int y = minY; y < maxY; y++) {
float fluidHeight = 0.0f;
for (int x = minX; x < maxX; x++) {
for (int z = minZ; z < maxZ; z++) {
mutable.set(x, y, z);
FluidState fluidState = boat.level().getFluidState(mutable);
if (fluidState.is(FluidTags.WATER)) {
fluidHeight = Math.max(fluidHeight, fluidState.getHeight(boat.level(), mutable));
}
if (fluidHeight >= 1.0f) {
break;
}
}
}
if (fluidHeight < 1.0f) {
return mutable.getY() + fluidHeight;
}
}
return maxY + 1.0f;
}
private float getNearbySlipperiness() {
AABB box = boundingBox;
AABB sample = new AABB(box.minX, box.minY - 0.001, box.minZ, box.maxX, box.minY, box.maxZ);
int minX = Mth.floor(sample.minX) - 1;
int maxX = Mth.ceil(sample.maxX) + 1;
int minY = Mth.floor(sample.minY) - 1;
int maxY = Mth.ceil(sample.maxY) + 1;
int minZ = Mth.floor(sample.minZ) - 1;
int maxZ = Mth.ceil(sample.maxZ) + 1;
VoxelShape shape = Shapes.create(sample);
float slipperiness = 0.0f;
int count = 0;
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (int x = minX; x < maxX; x++) {
for (int z = minZ; z < maxZ; z++) {
int edge = (x != minX && x != maxX - 1 ? 0 : 1) + (z != minZ && z != maxZ - 1 ? 0 : 1);
if (edge == 2) {
continue;
}
for (int y = minY; y < maxY; y++) {
if (edge <= 0 || y != minY && y != maxY - 1) {
mutable.set(x, y, z);
BlockState state = boat.level().getBlockState(mutable);
if (!(state.getBlock() instanceof LilyPadBlock)
&& Shapes.joinIsNotEmpty(state.getCollisionShape(boat.level(), mutable).move(mutable), shape, BooleanOp.AND)) {
slipperiness += state.getBlock().getFriction();
count++;
}
}
}
}
}
return count == 0 ? 0.0f : slipperiness / count;
}
private boolean checkBoatInWater() {
AABB box = boundingBox;
int minX = Mth.floor(box.minX);
int maxX = Mth.ceil(box.maxX);
int minY = Mth.floor(box.minY);
int maxY = Mth.ceil(box.minY + 0.001);
int minZ = Mth.floor(box.minZ);
int maxZ = Mth.ceil(box.maxZ);
boolean inWater = false;
waterLevel = -Double.MAX_VALUE;
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (int x = minX; x < maxX; x++) {
for (int y = minY; y < maxY; y++) {
for (int z = minZ; z < maxZ; z++) {
mutable.set(x, y, z);
FluidState fluidState = boat.level().getFluidState(mutable);
if (fluidState.is(FluidTags.WATER)) {
float height = y + fluidState.getHeight(boat.level(), mutable);
waterLevel = Math.max(height, waterLevel);
inWater |= box.minY < height;
}
}
}
}
return inWater;
}
private Location getUnderWaterLocation() {
AABB box = boundingBox;
double top = box.maxY + 0.001;
int minX = Mth.floor(box.minX);
int maxX = Mth.ceil(box.maxX);
int minY = Mth.floor(box.maxY);
int maxY = Mth.ceil(top);
int minZ = Mth.floor(box.minZ);
int maxZ = Mth.ceil(box.maxZ);
boolean still = false;
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
for (int x = minX; x < maxX; x++) {
for (int y = minY; y < maxY; y++) {
for (int z = minZ; z < maxZ; z++) {
mutable.set(x, y, z);
FluidState fluidState = boat.level().getFluidState(mutable);
if (fluidState.is(FluidTags.WATER) && top < mutable.getY() + fluidState.getHeight(boat.level(), mutable)) {
if (!fluidState.isSource()) {
return Location.UNDER_FLOWING_WATER;
}
still = true;
}
}
}
}
return still ? Location.UNDER_WATER : null;
}
}
}

View file

@ -0,0 +1,79 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.entity.simulation;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.player.simulation.PlayerSimulationCache;
/**
* Predicts passengers by following the predicted vehicle position and rotating
* the current passenger offset by the vehicle yaw delta.
*/
public enum MountedEntityPrediction {
;
public static Vec3 predictMountedPosition(Entity entity, double ticks) {
if (entity == null) {
return Vec3.ZERO;
}
Entity vehicle = entity.getVehicle();
if (vehicle == null) {
return entity.position();
}
Vec3 predictedVehiclePos = predictVehiclePosition(vehicle, ticks);
float currentVehicleYaw = vehicle.getYRot();
float predictedVehicleYaw = predictVehicleYaw(vehicle, ticks);
Vec3 currentOffset = entity.position().subtract(vehicle.position());
Vec3 localOffset = rotateY(currentOffset, -currentVehicleYaw);
Vec3 rotatedOffset = rotateY(localOffset, predictedVehicleYaw);
return predictedVehiclePos.add(rotatedOffset);
}
private static Vec3 predictVehiclePosition(Entity vehicle, double ticks) {
if (vehicle.isPassenger()) {
return predictMountedPosition(vehicle, ticks);
}
if (vehicle instanceof AbstractBoat boat) {
return BoatSimulationCache.getSimulation(boat).getSnapshotAt((int) Math.ceil(ticks)).pos();
}
if (vehicle instanceof Player player) {
return PlayerSimulationCache.getSimulationForOtherPlayers(player).getSnapshotAt((int) Math.ceil(ticks)).pos();
}
return PositionPredictor.predictEntityPos(vehicle, ticks);
}
private static float predictVehicleYaw(Entity vehicle, double ticks) {
if (vehicle instanceof AbstractBoat boat) {
return BoatSimulationCache.getSimulation(boat).getSnapshotAt((int) Math.ceil(ticks)).yaw();
}
float yaw = vehicle.getYRot();
float yawDelta = Mth.wrapDegrees(yaw - vehicle.yRotO);
return yaw + yawDelta * (float) ticks;
}
private static Vec3 rotateY(Vec3 vec, float yawDegrees) {
double rad = Math.toRadians(yawDegrees);
double sin = Math.sin(rad);
double cos = Math.cos(rad);
double x = vec.x * cos - vec.z * sin;
double z = vec.x * sin + vec.z * cos;
return new Vec3(x, vec.y, z);
}
}

View file

@ -0,0 +1,44 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.entity.simulation;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
public enum PositionExtrapolation {
;
public static Extrapolation getBestForEntity(Entity entity) {
return new Extrapolation(entity);
}
public static final class Extrapolation {
private final Entity entity;
private Extrapolation(Entity entity) {
this.entity = entity;
}
public Vec3 getPositionInTicks(double ticks) {
return PositionPredictor.predictEntityPos(entity, ticks);
}
public Vec3 getEyePositionInTicks(double ticks) {
if (entity instanceof LivingEntity living) {
return PositionPredictor.predictEyePos(living, ticks);
}
return getPositionInTicks(ticks);
}
public AABB getBoxInTicks(double ticks) {
return PositionPredictor.predictBox(entity, ticks);
}
}
}

View file

@ -0,0 +1,100 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.entity.simulation;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import java.lang.ref.WeakReference;
import java.util.Map;
import java.util.WeakHashMap;
/**
* Simple position extrapolation based on current velocity.
*/
public enum PositionPredictor {
;
private static final Map<Entity, State> STATES = new WeakHashMap<>();
public static Vec3 predictEntityPos(Entity entity, double ticks) {
if (entity == null || ticks <= 0) {
return entity != null ? entity.position() : Vec3.ZERO;
}
State state = updateState(entity);
Vec3 base = pickBaseMovement(entity, state);
Vec3 accel = state.prevMovement != null
? state.lastMovement.subtract(state.prevMovement)
: Vec3.ZERO;
double t = ticks;
Vec3 predicted = state.lastPos.add(base.scale(t));
if (state.prevMovement != null) {
predicted = predicted.add(accel.scale(0.5 * t * t));
}
return predicted;
}
public static Vec3 predictEyePos(LivingEntity entity, double ticks) {
if (entity == null) return Vec3.ZERO;
Vec3 pos = predictEntityPos(entity, ticks);
Vec3 eyeOffset = entity.getEyePosition().subtract(entity.position());
return pos.add(eyeOffset);
}
public static AABB predictBox(Entity entity, double ticks) {
if (entity == null) return new AABB(0, 0, 0, 0, 0, 0);
Vec3 predicted = predictEntityPos(entity, ticks);
Vec3 current = entity.position();
Vec3 delta = predicted.subtract(current);
return entity.getBoundingBox().move(delta);
}
private static State updateState(Entity entity) {
State state = STATES.get(entity);
if (state == null) {
state = new State();
state.ref = new WeakReference<>(entity);
STATES.put(entity, state);
}
int age = entity.tickCount;
if (state.lastAge != age) {
state.lastAge = age;
state.lastPos = entity.position();
state.prevMovement = state.lastMovement;
state.lastMovement = entity.getKnownMovement();
state.lastVelocity = entity.getDeltaMovement();
}
return state;
}
private static Vec3 pickBaseMovement(Entity entity, State state) {
Vec3 movement = state.lastMovement != null ? state.lastMovement : entity.getKnownMovement();
Vec3 velocity = state.lastVelocity != null ? state.lastVelocity : entity.getDeltaMovement();
double moveLen = movement.lengthSqr();
double velLen = velocity.lengthSqr();
if (moveLen < 1.0e-6 && velLen > moveLen) {
return velocity;
}
return movement;
}
private static final class State {
private WeakReference<Entity> ref;
private int lastAge = Integer.MIN_VALUE;
private Vec3 lastPos = Vec3.ZERO;
private Vec3 lastMovement = Vec3.ZERO;
private Vec3 prevMovement;
private Vec3 lastVelocity = Vec3.ZERO;
}
}

View file

@ -0,0 +1,244 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.input;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import org.lwjgl.glfw.GLFW;
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
import dev.loki.lovisual.features.gui.clickgui.tabs.ClickGuiSearch;
import dev.loki.lovisual.util.logging.DebugLog;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import dev.loki.lovisual.events.Event;
public enum KeyManager {
;
/**
* func -> combos
*/
private static final Map<String, Set<Set<Integer>>> comboBinds = new HashMap<>();
/**
* combo -> func(s); allow multiple modules to share the same combo
*/
private static final Map<Set<Integer>, Set<String>> reverseLookup = new HashMap<>();
/**
* per-function debounce state for wasPressed
*/
private static final Map<String, Boolean> prevFuncState = new HashMap<>();
/**
* Event-backed state for mouse buttons and scancode-only keys.
*/
private static final Map<Integer, Boolean> liveInputState = new HashMap<>();
// ============================================================
// Registration / removal
// ============================================================
public static void registerCombo(String func, String comboString) {
Set<Integer> combo = KeyUtil.stringToCombo(comboString);
if (combo.isEmpty()) return;
comboBinds.computeIfAbsent(func, k -> new HashSet<>()).add(combo);
reverseLookup.computeIfAbsent(combo, k -> new HashSet<>()).add(func);
DebugLog.info("[KeyManager] Registered %s = %s", func, comboString);
}
public static void unregisterCombo(String func, String comboString) {
Set<Integer> combo = KeyUtil.stringToCombo(comboString);
if (combo.isEmpty()) return;
if (!comboBinds.containsKey(func)) return;
comboBinds.get(func).remove(combo);
Set<String> funcs = reverseLookup.get(combo);
if (funcs != null) {
funcs.remove(func);
if (funcs.isEmpty()) reverseLookup.remove(combo);
}
if (comboBinds.get(func).isEmpty()) {
comboBinds.remove(func);
}
DebugLog.info("[KeyManager] Unregistered %s = %s", func, comboString);
}
/**
* Remove all combos bound to a function.
*/
public static void unregisterAll(String func) {
if (!comboBinds.containsKey(func)) return;
for (Set<Integer> combo : comboBinds.get(func)) {
Set<String> funcs = reverseLookup.get(combo);
if (funcs != null) {
funcs.remove(func);
if (funcs.isEmpty()) reverseLookup.remove(combo);
}
}
comboBinds.remove(func);
prevFuncState.remove(func);
DebugLog.info("[KeyManager] Unregistered ALL combos for %s", func);
}
// ============================================================
// Event state bridge
// ============================================================
public static void handleKeyEvent(int key, int scancode, int action) {
boolean pressed = action != GLFW.GLFW_RELEASE;
if (key != GLFW.GLFW_KEY_UNKNOWN) {
liveInputState.put(key, pressed);
}
if (scancode >= 0) {
liveInputState.put(KeyUtil.scancodeToCode(scancode), pressed);
}
}
public static void handleMouseButtonEvent(int button, int action) {
if (button < GLFW.GLFW_MOUSE_BUTTON_1 || button > GLFW.GLFW_MOUSE_BUTTON_LAST) return;
liveInputState.put(KeyUtil.mouseButtonToCode(button), action != GLFW.GLFW_RELEASE);
}
// ============================================================
// Query
// ============================================================
/**
* True once when combo transitions from not-pressed to pressed for this func.
*/
public static boolean wasPressed(String func) {
if (ClickGuiSearch.isActive()) return false;
if (!comboBinds.containsKey(func)) return false;
boolean anyPressed = false;
for (Set<Integer> combo : comboBinds.get(func)) {
boolean allPressed = true;
for (int key : combo) {
if (!isKeyPressed(key, false)) {
allPressed = false;
break;
}
}
if (allPressed) {
anyPressed = true;
break;
}
}
boolean prev = prevFuncState.getOrDefault(func, false);
if (anyPressed && !prev) {
prevFuncState.put(func, true);
return true;
}
if (!anyPressed) {
prevFuncState.put(func, false);
}
return false;
}
/**
* True while any combo for the function is held down.
*/
public static boolean isHeld(String func) {
return isHeldInternal(func, false);
}
/**
* True while any combo for the function is held down, even when a screen is open.
*/
public static boolean isHeldAllowScreen(String func) {
return isHeldInternal(func, true);
}
/**
* True while the given combo is held down, even when a screen is open.
*/
public static boolean isComboHeldAllowScreen(String comboString) {
if (comboString == null || comboString.isEmpty() || comboString.equalsIgnoreCase("NONE")) return false;
Set<Integer> combo = KeyUtil.stringToCombo(comboString);
if (combo.isEmpty()) return false;
for (int key : combo) {
if (!isKeyPressed(key, true)) {
return false;
}
}
return true;
}
// ============================================================
// Low-level input helpers
// ============================================================
private static boolean isKeyPressed(int key, boolean allowScreen) {
if (key == KeyUtil.NONE) return false;
if (ClickGuiRenderer.waitingForKey || ClickGuiRenderer.isTextEditorActive()) return false;
Minecraft client = Minecraft.getInstance();
if (!allowScreen && ClientScreen.current(client) != null) return false;
if (KeyUtil.isScancodeCode(key)) {
return liveInputState.getOrDefault(key, false);
}
long win = client.getWindow().handle();
if (KeyUtil.isMouseCode(key)) {
int button = KeyUtil.codeToMouseButton(key);
if (button < GLFW.GLFW_MOUSE_BUTTON_1 || button > GLFW.GLFW_MOUSE_BUTTON_LAST) return false;
return GLFW.glfwGetMouseButton(win, button) == GLFW.GLFW_PRESS
|| liveInputState.getOrDefault(key, false);
}
return GLFW.glfwGetKey(win, key) == GLFW.GLFW_PRESS
|| liveInputState.getOrDefault(key, false);
}
private static boolean isHeldInternal(String func, boolean allowScreen) {
if (ClickGuiSearch.isActive()) return false;
if (!comboBinds.containsKey(func)) return false;
for (Set<Integer> combo : comboBinds.get(func)) {
boolean allPressed = true;
for (int key : combo) {
if (!isKeyPressed(key, allowScreen)) {
allPressed = false;
break;
}
}
if (allPressed) return true;
}
return false;
}
// ============================================================
// Debug / helpers
// ============================================================
public static boolean isComboUsed(String comboString) {
Set<Integer> combo = KeyUtil.stringToCombo(comboString);
Set<String> funcs = reverseLookup.get(combo);
return funcs != null && !funcs.isEmpty();
}
}

View file

@ -0,0 +1,372 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.input;
import org.lwjgl.glfw.GLFW;
import java.util.*;
public enum KeyUtil {
;
public static final int NONE = GLFW.GLFW_KEY_UNKNOWN;
private static final int MOUSE_CODE_BASE = -4096;
private static final int SCANCODE_CODE_BASE = -1_048_576;
private static final Map<String, Integer> NAME_TO_KEY = new HashMap<>();
private static final Map<Integer, String> KEY_TO_NAME = new HashMap<>();
static {
for (char c = 'A'; c <= 'Z'; c++) {
put(String.valueOf(c), GLFW.GLFW_KEY_A + (c - 'A'));
}
for (char c = '0'; c <= '9'; c++) {
put(String.valueOf(c), GLFW.GLFW_KEY_0 + (c - '0'));
}
for (int i = 1; i <= 25; i++) {
put("F" + i, GLFW.GLFW_KEY_F1 + (i - 1));
}
put("SPACE", GLFW.GLFW_KEY_SPACE);
put("TAB", GLFW.GLFW_KEY_TAB);
put("ENTER", GLFW.GLFW_KEY_ENTER);
putAlias("RETURN", GLFW.GLFW_KEY_ENTER);
put("ESCAPE", GLFW.GLFW_KEY_ESCAPE);
putAlias("ESC", GLFW.GLFW_KEY_ESCAPE);
put("BACKSPACE", GLFW.GLFW_KEY_BACKSPACE);
put("DELETE", GLFW.GLFW_KEY_DELETE);
put("INSERT", GLFW.GLFW_KEY_INSERT);
put("HOME", GLFW.GLFW_KEY_HOME);
put("END", GLFW.GLFW_KEY_END);
put("PAGE_UP", GLFW.GLFW_KEY_PAGE_UP);
putAlias("PGUP", GLFW.GLFW_KEY_PAGE_UP);
put("PAGE_DOWN", GLFW.GLFW_KEY_PAGE_DOWN);
putAlias("PGDN", GLFW.GLFW_KEY_PAGE_DOWN);
put("UP", GLFW.GLFW_KEY_UP);
put("DOWN", GLFW.GLFW_KEY_DOWN);
put("LEFT", GLFW.GLFW_KEY_LEFT);
put("RIGHT", GLFW.GLFW_KEY_RIGHT);
put("LEFT_SHIFT", GLFW.GLFW_KEY_LEFT_SHIFT);
put("RIGHT_SHIFT", GLFW.GLFW_KEY_RIGHT_SHIFT);
putAlias("LSHIFT", GLFW.GLFW_KEY_LEFT_SHIFT);
putAlias("RSHIFT", GLFW.GLFW_KEY_RIGHT_SHIFT);
putAlias("SHIFT", GLFW.GLFW_KEY_LEFT_SHIFT);
put("LEFT_CTRL", GLFW.GLFW_KEY_LEFT_CONTROL);
put("RIGHT_CTRL", GLFW.GLFW_KEY_RIGHT_CONTROL);
putAlias("LEFT_CONTROL", GLFW.GLFW_KEY_LEFT_CONTROL);
putAlias("RIGHT_CONTROL", GLFW.GLFW_KEY_RIGHT_CONTROL);
putAlias("LCTRL", GLFW.GLFW_KEY_LEFT_CONTROL);
putAlias("RCTRL", GLFW.GLFW_KEY_RIGHT_CONTROL);
putAlias("CTRL", GLFW.GLFW_KEY_LEFT_CONTROL);
putAlias("CONTROL", GLFW.GLFW_KEY_LEFT_CONTROL);
put("LEFT_ALT", GLFW.GLFW_KEY_LEFT_ALT);
put("RIGHT_ALT", GLFW.GLFW_KEY_RIGHT_ALT);
putAlias("LALT", GLFW.GLFW_KEY_LEFT_ALT);
putAlias("RALT", GLFW.GLFW_KEY_RIGHT_ALT);
putAlias("ALT", GLFW.GLFW_KEY_LEFT_ALT);
put("LEFT_SUPER", GLFW.GLFW_KEY_LEFT_SUPER);
put("RIGHT_SUPER", GLFW.GLFW_KEY_RIGHT_SUPER);
putAlias("LEFT_WIN", GLFW.GLFW_KEY_LEFT_SUPER);
putAlias("RIGHT_WIN", GLFW.GLFW_KEY_RIGHT_SUPER);
putAlias("LWIN", GLFW.GLFW_KEY_LEFT_SUPER);
putAlias("RWIN", GLFW.GLFW_KEY_RIGHT_SUPER);
putAlias("SUPER", GLFW.GLFW_KEY_LEFT_SUPER);
putAlias("WIN", GLFW.GLFW_KEY_LEFT_SUPER);
put("CAPS_LOCK", GLFW.GLFW_KEY_CAPS_LOCK);
put("SCROLL_LOCK", GLFW.GLFW_KEY_SCROLL_LOCK);
put("NUM_LOCK", GLFW.GLFW_KEY_NUM_LOCK);
put("PRINT_SCREEN", GLFW.GLFW_KEY_PRINT_SCREEN);
put("PAUSE", GLFW.GLFW_KEY_PAUSE);
put("MENU", GLFW.GLFW_KEY_MENU);
put("GRAVE_ACCENT", GLFW.GLFW_KEY_GRAVE_ACCENT);
putAlias("GRAVE", GLFW.GLFW_KEY_GRAVE_ACCENT);
putAlias("BACKTICK", GLFW.GLFW_KEY_GRAVE_ACCENT);
put("APOSTROPHE", GLFW.GLFW_KEY_APOSTROPHE);
put("BACKSLASH", GLFW.GLFW_KEY_BACKSLASH);
put("COMMA", GLFW.GLFW_KEY_COMMA);
put("EQUAL", GLFW.GLFW_KEY_EQUAL);
put("LEFT_BRACKET", GLFW.GLFW_KEY_LEFT_BRACKET);
put("RIGHT_BRACKET", GLFW.GLFW_KEY_RIGHT_BRACKET);
put("MINUS", GLFW.GLFW_KEY_MINUS);
put("PERIOD", GLFW.GLFW_KEY_PERIOD);
put("SEMICOLON", GLFW.GLFW_KEY_SEMICOLON);
put("SLASH", GLFW.GLFW_KEY_SLASH);
put("WORLD_1", GLFW.GLFW_KEY_WORLD_1);
put("WORLD_2", GLFW.GLFW_KEY_WORLD_2);
put("KP_0", GLFW.GLFW_KEY_KP_0);
put("KP_1", GLFW.GLFW_KEY_KP_1);
put("KP_2", GLFW.GLFW_KEY_KP_2);
put("KP_3", GLFW.GLFW_KEY_KP_3);
put("KP_4", GLFW.GLFW_KEY_KP_4);
put("KP_5", GLFW.GLFW_KEY_KP_5);
put("KP_6", GLFW.GLFW_KEY_KP_6);
put("KP_7", GLFW.GLFW_KEY_KP_7);
put("KP_8", GLFW.GLFW_KEY_KP_8);
put("KP_9", GLFW.GLFW_KEY_KP_9);
put("KP_DECIMAL", GLFW.GLFW_KEY_KP_DECIMAL);
put("KP_DIVIDE", GLFW.GLFW_KEY_KP_DIVIDE);
put("KP_MULTIPLY", GLFW.GLFW_KEY_KP_MULTIPLY);
put("KP_SUBTRACT", GLFW.GLFW_KEY_KP_SUBTRACT);
put("KP_ADD", GLFW.GLFW_KEY_KP_ADD);
put("KP_ENTER", GLFW.GLFW_KEY_KP_ENTER);
put("KP_EQUAL", GLFW.GLFW_KEY_KP_EQUAL);
putMouseAliases("MOUSE_LEFT", GLFW.GLFW_MOUSE_BUTTON_LEFT);
putMouseAliases("MOUSE_RIGHT", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
putMouseAliases("MOUSE_MIDDLE", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
putMouseAlias("MOUSE1", GLFW.GLFW_MOUSE_BUTTON_LEFT);
putMouseAlias("MOUSE_1", GLFW.GLFW_MOUSE_BUTTON_LEFT);
putMouseAlias("MOUSE_BUTTON_1", GLFW.GLFW_MOUSE_BUTTON_LEFT);
putMouseAlias("MOUSE2", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
putMouseAlias("MOUSE_2", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
putMouseAlias("MOUSE_BUTTON_2", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
putMouseAlias("MOUSE3", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
putMouseAlias("MOUSE_3", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
putMouseAlias("MOUSE_BUTTON_3", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
putMouseAlias("MB1", GLFW.GLFW_MOUSE_BUTTON_LEFT);
putMouseAlias("MB2", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
putMouseAlias("MB3", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
for (int userButton = 4; userButton <= 8; userButton++) {
int glfwButton = userButton - 1;
putMouseAlias("MOUSE" + userButton, glfwButton);
putMouseAlias("MOUSE_" + userButton, glfwButton);
putMouseAlias("MOUSE_BUTTON_" + userButton, glfwButton);
putMouseAlias("MB" + userButton, glfwButton);
}
putTranslationAliases();
}
private static void put(String name, int code) {
String normalized = normalizeName(name);
NAME_TO_KEY.put(normalized, code);
KEY_TO_NAME.putIfAbsent(code, normalized);
}
private static void putAlias(String name, int code) {
NAME_TO_KEY.put(normalizeName(name), code);
}
private static void putMouseAliases(String name, int button) {
putMouseAlias(name, button);
}
private static void putMouseAlias(String name, int button) {
NAME_TO_KEY.put(normalizeName(name), mouseButtonToCode(button));
}
private static void putTranslationAliases() {
for (Map.Entry<Integer, String> entry : KEY_TO_NAME.entrySet()) {
int code = entry.getKey();
String name = entry.getValue().toLowerCase(Locale.ROOT).replace('_', '.');
NAME_TO_KEY.put("KEY.KEYBOARD." + name.toUpperCase(Locale.ROOT), code);
}
putAlias("KEY.KEYBOARD.LEFT.CONTROL", GLFW.GLFW_KEY_LEFT_CONTROL);
putAlias("KEY.KEYBOARD.RIGHT.CONTROL", GLFW.GLFW_KEY_RIGHT_CONTROL);
putAlias("KEY.KEYBOARD.LEFT.WIN", GLFW.GLFW_KEY_LEFT_SUPER);
putAlias("KEY.KEYBOARD.RIGHT.WIN", GLFW.GLFW_KEY_RIGHT_SUPER);
putAlias("KEY.KEYBOARD.KEYPAD.0", GLFW.GLFW_KEY_KP_0);
putAlias("KEY.KEYBOARD.KEYPAD.1", GLFW.GLFW_KEY_KP_1);
putAlias("KEY.KEYBOARD.KEYPAD.2", GLFW.GLFW_KEY_KP_2);
putAlias("KEY.KEYBOARD.KEYPAD.3", GLFW.GLFW_KEY_KP_3);
putAlias("KEY.KEYBOARD.KEYPAD.4", GLFW.GLFW_KEY_KP_4);
putAlias("KEY.KEYBOARD.KEYPAD.5", GLFW.GLFW_KEY_KP_5);
putAlias("KEY.KEYBOARD.KEYPAD.6", GLFW.GLFW_KEY_KP_6);
putAlias("KEY.KEYBOARD.KEYPAD.7", GLFW.GLFW_KEY_KP_7);
putAlias("KEY.KEYBOARD.KEYPAD.8", GLFW.GLFW_KEY_KP_8);
putAlias("KEY.KEYBOARD.KEYPAD.9", GLFW.GLFW_KEY_KP_9);
putAlias("KEY.KEYBOARD.KEYPAD.ADD", GLFW.GLFW_KEY_KP_ADD);
putAlias("KEY.KEYBOARD.KEYPAD.DECIMAL", GLFW.GLFW_KEY_KP_DECIMAL);
putAlias("KEY.KEYBOARD.KEYPAD.DIVIDE", GLFW.GLFW_KEY_KP_DIVIDE);
putAlias("KEY.KEYBOARD.KEYPAD.ENTER", GLFW.GLFW_KEY_KP_ENTER);
putAlias("KEY.KEYBOARD.KEYPAD.EQUAL", GLFW.GLFW_KEY_KP_EQUAL);
putAlias("KEY.KEYBOARD.KEYPAD.MULTIPLY", GLFW.GLFW_KEY_KP_MULTIPLY);
putAlias("KEY.KEYBOARD.KEYPAD.SUBTRACT", GLFW.GLFW_KEY_KP_SUBTRACT);
putMouseAlias("KEY.MOUSE.LEFT", GLFW.GLFW_MOUSE_BUTTON_LEFT);
putMouseAlias("KEY.MOUSE.RIGHT", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
putMouseAlias("KEY.MOUSE.MIDDLE", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
for (int userButton = 4; userButton <= 8; userButton++) {
putMouseAlias("KEY.MOUSE." + userButton, userButton - 1);
}
}
public static int mouseButtonToCode(int button) {
return MOUSE_CODE_BASE - Math.max(0, button);
}
public static boolean isMouseCode(int code) {
return code <= MOUSE_CODE_BASE && code > SCANCODE_CODE_BASE;
}
public static int codeToMouseButton(int code) {
return MOUSE_CODE_BASE - code;
}
public static int scancodeToCode(int scancode) {
return SCANCODE_CODE_BASE - Math.max(0, scancode);
}
public static boolean isScancodeCode(int code) {
return code <= SCANCODE_CODE_BASE;
}
public static int codeToScancode(int code) {
return SCANCODE_CODE_BASE - code;
}
public static int fromKeyInput(int key, int scancode) {
if (key != GLFW.GLFW_KEY_UNKNOWN) return key;
return scancode >= 0 ? scancodeToCode(scancode) : NONE;
}
public static int nameToKey(String name) {
if (name == null) return NONE;
String normalized = normalizeName(name);
if (normalized.isEmpty() || normalized.equals("NONE")) return NONE;
Integer mapped = NAME_TO_KEY.get(normalized);
if (mapped != null) return mapped;
if (normalized.startsWith("KEY_") && normalized.length() > 4) {
Integer parsed = parseInteger(normalized.substring(4));
return parsed != null ? parsed : NONE;
}
if (normalized.startsWith("SCANCODE_") && normalized.length() > 9) {
Integer parsed = parseInteger(normalized.substring(9));
return parsed != null ? scancodeToCode(parsed) : NONE;
}
Integer mouse = parseMouseName(normalized);
if (mouse != null) return mouseButtonToCode(mouse);
return NONE;
}
public static String keyToName(int key) {
if (key == NONE) return "NONE";
if (isMouseCode(key)) {
int button = codeToMouseButton(key);
return switch (button) {
case GLFW.GLFW_MOUSE_BUTTON_LEFT -> "MOUSE_LEFT";
case GLFW.GLFW_MOUSE_BUTTON_RIGHT -> "MOUSE_RIGHT";
case GLFW.GLFW_MOUSE_BUTTON_MIDDLE -> "MOUSE_MIDDLE";
default -> "MOUSE_" + (button + 1);
};
}
if (isScancodeCode(key)) {
return "SCANCODE_" + codeToScancode(key);
}
String known = KEY_TO_NAME.get(key);
if (known != null) return known;
return "KEY_" + key;
}
public static Set<Integer> stringToCombo(String str) {
Set<Integer> set = new HashSet<>();
if (str == null || str.isBlank()) return set;
for (String part : str.split("\\+")) {
int code = nameToKey(part.trim());
if (code != NONE) set.add(code);
}
return set;
}
public static String comboToString(Set<Integer> keys) {
if (keys == null || keys.isEmpty()) return "NONE";
List<Integer> sorted = new ArrayList<>(keys);
sorted.sort(KeyUtil::compareKeysForDisplay);
List<String> names = new ArrayList<>(sorted.size());
for (int key : sorted) {
names.add(keyToName(key));
}
return String.join("+", names);
}
public static boolean isModifierCode(int code) {
return code == GLFW.GLFW_KEY_LEFT_SHIFT
|| code == GLFW.GLFW_KEY_RIGHT_SHIFT
|| code == GLFW.GLFW_KEY_LEFT_CONTROL
|| code == GLFW.GLFW_KEY_RIGHT_CONTROL
|| code == GLFW.GLFW_KEY_LEFT_ALT
|| code == GLFW.GLFW_KEY_RIGHT_ALT
|| code == GLFW.GLFW_KEY_LEFT_SUPER
|| code == GLFW.GLFW_KEY_RIGHT_SUPER;
}
private static int compareKeysForDisplay(int a, int b) {
int ar = sortRank(a);
int br = sortRank(b);
if (ar != br) return Integer.compare(ar, br);
return keyToName(a).compareTo(keyToName(b));
}
private static int sortRank(int code) {
if (isModifierCode(code)) return 0;
if (isMouseCode(code)) return 2;
if (isScancodeCode(code)) return 3;
return 1;
}
private static Integer parseMouseName(String normalized) {
String s = normalized;
if (s.startsWith("MOUSE_BUTTON_")) s = s.substring("MOUSE_BUTTON_".length());
else if (s.startsWith("MOUSE_")) s = s.substring("MOUSE_".length());
else if (s.startsWith("MOUSE")) s = s.substring("MOUSE".length());
else if (s.startsWith("MB")) s = s.substring(2);
else return null;
return switch (s) {
case "LEFT" -> GLFW.GLFW_MOUSE_BUTTON_LEFT;
case "RIGHT" -> GLFW.GLFW_MOUSE_BUTTON_RIGHT;
case "MIDDLE" -> GLFW.GLFW_MOUSE_BUTTON_MIDDLE;
default -> {
Integer parsed = parseInteger(s);
if (parsed == null || parsed < 1 || parsed > 8) yield null;
yield parsed - 1;
}
};
}
private static Integer parseInteger(String s) {
try {
return Integer.parseInt(s);
} catch (NumberFormatException ignored) {
return null;
}
}
private static String normalizeName(String name) {
return name.trim()
.toUpperCase(Locale.ROOT)
.replace('-', '_')
.replace(' ', '_');
}
}

View file

@ -0,0 +1,80 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.item.enchant;
import net.minecraft.client.resources.language.I18n;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public record EnchantMeta(
String key, // path: "lunge", "sharpness"
String category // "armor", "melee", "other"
) {
public String translationKey() {
return "enchantment.minecraft." + key;
}
public String localizedName() {
String translationKey = translationKey();
String translated = I18n.get(translationKey);
if (translated != null && !translated.isBlank() && !translationKey.equals(translated)) {
return translated.trim();
}
return fallbackName();
}
public String shortName() {
return abbreviate(localizedName());
}
static String abbreviate(String name) {
if (name == null || name.isBlank()) return "";
String[] rawWords = name.trim().split("[^\\p{L}\\p{N}]+");
List<String> words = new ArrayList<>(rawWords.length);
for (String word : rawWords) {
if (!word.isBlank()) words.add(word);
}
if (words.isEmpty()) return "";
if (words.size() == 1) return codePointPrefix(words.getFirst(), 3);
// Connector words do not carry meaning in a compact label:
// "Bane of Arthropods" -> "BA", "Защита от снарядов" -> "ЗС".
List<String> meaningful = words.stream()
.filter(word -> word.codePointCount(0, word.length()) > 2)
.toList();
if (meaningful.isEmpty()) meaningful = words;
StringBuilder result = new StringBuilder(Math.min(meaningful.size(), 3));
for (String word : meaningful) {
if (result.codePointCount(0, result.length()) >= 3) break;
result.appendCodePoint(Character.toUpperCase(word.codePointAt(0)));
}
return result.toString();
}
private String fallbackName() {
String[] words = key.toLowerCase(Locale.ROOT).split("_");
StringBuilder result = new StringBuilder(key.length() + 4);
for (String word : words) {
if (word.isBlank()) continue;
if (!result.isEmpty()) result.append(' ');
int first = word.codePointAt(0);
result.appendCodePoint(Character.toUpperCase(first));
result.append(word.substring(Character.charCount(first)));
}
return result.toString();
}
private static String codePointPrefix(String value, int length) {
int count = Math.min(length, value.codePointCount(0, value.length()));
return value.substring(0, value.offsetByCodePoints(0, count));
}
}

View file

@ -0,0 +1,83 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.item.enchant;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.enchantment.Enchantment;
import net.minecraft.world.item.enchantment.Enchantments;
import java.util.Map;
import dev.loki.lovisual.util.item.enchant.EnchantMeta;
public enum EnchantRegistry {
;
public static final Map<ResourceKey<Enchantment>, EnchantMeta> REGISTRY = Map.ofEntries(
/* ================= ARMOR ================= */
Map.entry(Enchantments.PROTECTION, new EnchantMeta("protection", "armor")),
Map.entry(Enchantments.FIRE_PROTECTION, new EnchantMeta("fire_protection", "armor")),
Map.entry(Enchantments.FEATHER_FALLING, new EnchantMeta("feather_falling", "armor")),
Map.entry(Enchantments.BLAST_PROTECTION, new EnchantMeta("blast_protection", "armor")),
Map.entry(Enchantments.PROJECTILE_PROTECTION, new EnchantMeta("projectile_protection", "armor")),
Map.entry(Enchantments.RESPIRATION, new EnchantMeta("respiration", "armor")),
Map.entry(Enchantments.AQUA_AFFINITY, new EnchantMeta("aqua_affinity", "armor")),
Map.entry(Enchantments.THORNS, new EnchantMeta("thorns", "armor")),
Map.entry(Enchantments.DEPTH_STRIDER, new EnchantMeta("depth_strider", "armor")),
Map.entry(Enchantments.FROST_WALKER, new EnchantMeta("frost_walker", "armor")),
Map.entry(Enchantments.SOUL_SPEED, new EnchantMeta("soul_speed", "armor")),
Map.entry(Enchantments.SWIFT_SNEAK, new EnchantMeta("swift_sneak", "armor")),
Map.entry(Enchantments.BINDING_CURSE, new EnchantMeta("binding_curse", "armor")),
/* ================= MELEE ================= */
Map.entry(Enchantments.SHARPNESS, new EnchantMeta("sharpness", "melee")),
Map.entry(Enchantments.SMITE, new EnchantMeta("smite", "melee")),
Map.entry(Enchantments.BANE_OF_ARTHROPODS, new EnchantMeta("bane_of_arthropods", "melee")),
Map.entry(Enchantments.KNOCKBACK, new EnchantMeta("knockback", "melee")),
Map.entry(Enchantments.FIRE_ASPECT, new EnchantMeta("fire_aspect", "melee")),
Map.entry(Enchantments.LOOTING, new EnchantMeta("looting", "melee")),
Map.entry(Enchantments.SWEEPING_EDGE, new EnchantMeta("sweeping_edge", "melee")),
/* ================= TOOLS ================= */
Map.entry(Enchantments.EFFICIENCY, new EnchantMeta("efficiency", "tools")),
Map.entry(Enchantments.SILK_TOUCH, new EnchantMeta("silk_touch", "tools")),
Map.entry(Enchantments.FORTUNE, new EnchantMeta("fortune", "tools")),
/* ================= BOW ================= */
Map.entry(Enchantments.POWER, new EnchantMeta("power", "bow")),
Map.entry(Enchantments.PUNCH, new EnchantMeta("punch", "bow")),
Map.entry(Enchantments.FLAME, new EnchantMeta("flame", "bow")),
Map.entry(Enchantments.INFINITY, new EnchantMeta("infinity", "bow")),
/* ================= FISHING ================= */
Map.entry(Enchantments.LUCK_OF_THE_SEA, new EnchantMeta("luck_of_the_sea", "fishing")),
Map.entry(Enchantments.LURE, new EnchantMeta("lure", "fishing")),
/* ================= TRIDENT ================= */
Map.entry(Enchantments.LOYALTY, new EnchantMeta("loyalty", "trident")),
Map.entry(Enchantments.IMPALING, new EnchantMeta("impaling", "trident")),
Map.entry(Enchantments.RIPTIDE, new EnchantMeta("riptide", "trident")),
Map.entry(Enchantments.CHANNELING, new EnchantMeta("channeling", "trident")),
/* ================= CROSSBOW ================= */
Map.entry(Enchantments.MULTISHOT, new EnchantMeta("multishot", "crossbow")),
Map.entry(Enchantments.QUICK_CHARGE, new EnchantMeta("quick_charge", "crossbow")),
Map.entry(Enchantments.PIERCING, new EnchantMeta("piercing", "crossbow")),
/* ================= MACE ================= */
Map.entry(Enchantments.DENSITY, new EnchantMeta("density", "mace")),
Map.entry(Enchantments.BREACH, new EnchantMeta("breach", "mace")),
Map.entry(Enchantments.WIND_BURST, new EnchantMeta("wind_burst", "mace")),
/* ================= OTHER ================= */
Map.entry(Enchantments.LUNGE, new EnchantMeta("lunge", "other")),
Map.entry(Enchantments.MENDING, new EnchantMeta("mending", "other")),
Map.entry(Enchantments.UNBREAKING, new EnchantMeta("unbreaking", "other")),
Map.entry(Enchantments.VANISHING_CURSE, new EnchantMeta("vanishing_curse", "other"))
);
}

View file

@ -0,0 +1,28 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.item.enchant;
import net.minecraft.core.Holder;
import net.minecraft.world.item.enchantment.Enchantment;
/**
* Shared helpers for enchantment validation/formatting.
*/
public enum EnchantUtil {
;
/**
* Checks if the provided enchantment level exceeds its max level.
*/
public static boolean isInvalidLevel(Holder<Enchantment> enchant, int level) {
if (enchant == null || level <= 0) return false;
Enchantment value = enchant.value();
int max = value.getMaxLevel();
return level > max;
}
}

View file

@ -0,0 +1,111 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.item.enchant;
import it.unimi.dsi.fastutil.objects.Object2IntMap;
import net.minecraft.core.Holder;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.enchantment.Enchantment;
import net.minecraft.world.item.enchantment.EnchantmentHelper;
import net.minecraft.world.item.enchantment.ItemEnchantments;
import dev.loki.lovisual.config.values.collection.ItemIdSetValue;
import dev.loki.lovisual.config.values.color.RGBColorValue;
import dev.loki.lovisual.features.module.modules.visuals.esp.overlay.DropESP;
import dev.loki.lovisual.features.module.modules.visuals.nametags.NameTags;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.function.BiConsumer;
import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat;
import dev.loki.lovisual.util.item.food.IllegalItemUtil;
/**
* Shared helper for highlighting top (max-level) legal enchants used by DropESP/NameTags/BetterChat.
* Illegal enchantments always take priority (see IllegalItemUtil).
*/
public enum TopEnchantUtil {
;
private static final RGBColorValue TOP_COLOR = new RGBColorValue("top_enchant_color", "#B84DFF");
private static final ItemIdSetValue IGNORE_TOP = new ItemIdSetValue("top_enchant_ignore", buildDefaultIgnore());
public static RGBColorValue topColorValue() {
return TOP_COLOR;
}
public static ItemIdSetValue ignoreValue() {
return IGNORE_TOP;
}
public static int topColor() {
return TOP_COLOR.getArgb();
}
/**
* @return true if the stack has at least one enchant at its vanilla max level,
* while not being illegal and not in the ignore list.
*/
public static boolean hasTopEnchant(ItemStack stack) {
if (stack == null || stack.isEmpty()) return false;
if (IllegalItemUtil.isIllegal(stack)) return false; // illegal has higher priority
ItemEnchantments enchComp = EnchantmentHelper.getEnchantmentsForCrafting(stack);
Set<String> ignore = IGNORE_TOP.get();
for (Object2IntMap.Entry<Holder<Enchantment>> entry : enchComp.entrySet()) {
Holder<Enchantment> ref = entry.getKey();
String id = safeId(ref);
if (ignore.contains(id)) continue;
int level = entry.getIntValue();
if (level <= 0) continue;
Enchantment ench = ref.value();
if (level == ench.getMaxLevel()) return true;
}
return false;
}
/**
* Collects (label, isTop) pairs, skipping ignore-list entries.
*/
public static void collectTopEnchants(ItemStack stack, BiConsumer<String, Boolean> consumer) {
if (stack == null || stack.isEmpty() || consumer == null) return;
Set<String> ignore = IGNORE_TOP.get();
ItemEnchantments enchComp = EnchantmentHelper.getEnchantmentsForCrafting(stack);
for (Object2IntMap.Entry<Holder<Enchantment>> entry : enchComp.entrySet()) {
Holder<Enchantment> ref = entry.getKey();
String id = safeId(ref);
if (ignore.contains(id)) continue;
int level = entry.getIntValue();
if (level <= 0) continue;
Enchantment ench = ref.value();
boolean isTop = level == ench.getMaxLevel();
String label = Enchantment.getFullname(ref, level).getString();
consumer.accept(label, isTop);
}
}
private static String safeId(Holder<Enchantment> ref) {
return ref.unwrapKey().map(ResourceKey::identifier).map(Object::toString).orElse("");
}
private static Set<String> buildDefaultIgnore() {
Set<String> result = new LinkedHashSet<>();
// Known single-level enchants
result.add("minecraft:binding_curse");
result.add("minecraft:vanishing_curse");
result.add("minecraft:mending");
result.add("minecraft:infinity");
result.add("minecraft:silk_touch");
result.add("minecraft:aqua_affinity");
result.add("minecraft:flame");
result.add("minecraft:channeling");
result.add("minecraft:multishot");
// Explicitly exclude Unbreaking as top (lvl 3)
result.add("minecraft:unbreaking");
return result;
}
}

View file

@ -0,0 +1,202 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.item.food;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectCategory;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.food.FoodData;
import net.minecraft.world.food.FoodProperties;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.Consumable;
import net.minecraft.world.item.consume_effects.ApplyStatusEffectsConsumeEffect;
import net.minecraft.world.item.consume_effects.ConsumeEffect;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
public enum FoodUtil {
;
public static boolean isFood(ItemStack stack) {
if (stack == null || stack.isEmpty()) return false;
return stack.get(DataComponents.FOOD) != null;
}
public static int getFoodLevel(Player player) {
if (player == null) return 0;
FoodData hungerManager = player.getFoodData();
if (hungerManager == null) return 0;
return hungerManager.getFoodLevel();
}
public static float getSaturationLevel(Player player) {
if (player == null) return 0f;
FoodData hungerManager = player.getFoodData();
if (hungerManager == null) return 0f;
return hungerManager.getSaturationLevel();
}
public static int getNutrition(ItemStack stack) {
Object food = getFoodComponent(stack);
if (food instanceof FoodProperties fc) {
return fc.nutrition();
}
return food == null ? 0 : readInt(food, "nutrition", "getNutrition");
}
public static float getSaturation(ItemStack stack) {
Object food = getFoodComponent(stack);
if (food instanceof FoodProperties fc) {
return fc.saturation();
}
return food == null ? 0f : readFloat(food, "saturation", "getSaturation", "getSaturationModifier", "saturationModifier");
}
public static boolean canAlwaysEat(ItemStack stack) {
Object food = getFoodComponent(stack);
if (food instanceof FoodProperties fc) {
return readBoolean(fc, "canAlwaysEat", "isAlwaysEdible");
}
return food != null && readBoolean(food, "canAlwaysEat", "isAlwaysEdible");
}
public static float scoreFood(ItemStack stack) {
if (!isFood(stack)) return Float.NEGATIVE_INFINITY;
int nutrition = getNutrition(stack);
float saturation = getSaturation(stack);
float score = nutrition * 10.0f + saturation * 20.0f;
score += scoreEffects(stack);
return score;
}
public static List<MobEffectInstance> getFoodEffects(ItemStack stack) {
if (stack == null || stack.isEmpty()) return Collections.emptyList();
List<MobEffectInstance> out = new java.util.ArrayList<>();
Consumable consumable = stack.get(DataComponents.CONSUMABLE);
if (consumable == null) return Collections.emptyList();
for (ConsumeEffect effect : consumable.onConsumeEffects()) {
if (effect instanceof ApplyStatusEffectsConsumeEffect apply) {
for (MobEffectInstance inst : apply.effects()) {
out.add(new MobEffectInstance(inst));
}
}
}
return out;
}
private static float scoreEffects(ItemStack stack) {
Object food = getFoodComponent(stack);
if (food == null) return 0f;
List<?> effects = readList(food, "effects", "getEffects", "getStatusEffects");
if (effects.isEmpty()) return 0f;
float score = 0f;
for (Object entry : effects) {
if (entry == null) continue;
float chance = readFloat(entry, "probability", "getProbability", "getChance");
if (chance <= 0f) chance = 1f;
MobEffect effect = extractStatusEffect(entry);
if (effect == null) {
score += 2.0f * chance;
continue;
}
MobEffectCategory cat = effect.getCategory();
float add = switch (cat) {
case BENEFICIAL -> 6.0f;
case HARMFUL -> -6.0f;
default -> 2.0f;
};
score += add * chance;
}
return score;
}
private static MobEffect extractStatusEffect(Object effectEntry) {
Object obj = readObject(effectEntry, "effect", "getEffect", "getFirst", "getLeft");
if (obj instanceof MobEffectInstance inst) {
Object type = inst.getEffect();
if (type instanceof MobEffect effect) return effect;
if (type instanceof net.minecraft.core.Holder<?> regEntry) {
Object v = regEntry.value();
if (v instanceof MobEffect effect) return effect;
}
}
if (obj instanceof MobEffect effect) {
return effect;
}
if (obj instanceof net.minecraft.core.Holder<?> regEntry) {
Object v = regEntry.value();
if (v instanceof MobEffect effect) {
return effect;
}
}
Object fromInstance = readObject(obj, "getEffectType", "getEffect");
if (fromInstance instanceof MobEffect effect) {
return effect;
}
Object value = readObject(obj, "value");
if (value instanceof MobEffect effect) {
return effect;
}
return null;
}
private static Object getFoodComponent(ItemStack stack) {
if (stack == null || stack.isEmpty()) return null;
return stack.get(DataComponents.FOOD);
}
private static int readInt(Object target, String... methods) {
Number n = readNumber(target, methods);
return n == null ? 0 : n.intValue();
}
private static float readFloat(Object target, String... methods) {
Number n = readNumber(target, methods);
return n == null ? 0f : n.floatValue();
}
private static boolean readBoolean(Object target, String... methods) {
Object v = readObject(target, methods);
return v instanceof Boolean b && b;
}
private static Number readNumber(Object target, String... methods) {
Object v = readObject(target, methods);
return v instanceof Number ? (Number) v : null;
}
private static List<?> readList(Object target, String... methods) {
Object v = readObject(target, methods);
if (v instanceof List<?> list) return list;
return Collections.emptyList();
}
private static Object readObject(Object target, String... methods) {
if (target == null) return null;
for (String name : methods) {
try {
Method m = target.getClass().getMethod(name);
return m.invoke(target);
} catch (ReflectiveOperationException ignored) {
}
}
return null;
}
}

View file

@ -0,0 +1,67 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.item.food;
import it.unimi.dsi.fastutil.objects.Object2IntMap;
import net.minecraft.core.Holder;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.enchantment.Enchantment;
import net.minecraft.world.item.enchantment.EnchantmentHelper;
import net.minecraft.world.item.enchantment.ItemEnchantments;
import dev.loki.lovisual.config.values.color.RGBColorValue;
import java.util.function.BiConsumer;
import dev.loki.lovisual.util.item.enchant.EnchantUtil;
/**
* Общая утилита проверки нелегальных предметов и цвета их подсветки.
*/
public enum IllegalItemUtil {
;
private static final RGBColorValue ILLEGAL_COLOR = new RGBColorValue("illegal_item_color", "#FF5555");
public static RGBColorValue illegalColorValue() {
return ILLEGAL_COLOR;
}
public static int illegalColor() {
return ILLEGAL_COLOR.getArgb();
}
/**
* true, если стек содержит энчанты выше разрешенного уровня.
*/
public static boolean isIllegal(ItemStack stack) {
if (stack == null || stack.isEmpty()) return false;
ItemEnchantments enchComp = EnchantmentHelper.getEnchantmentsForCrafting(stack);
for (Object2IntMap.Entry<Holder<Enchantment>> entry : enchComp.entrySet()) {
Holder<Enchantment> ref = entry.getKey();
int level = entry.getIntValue();
if (level <= 0) continue;
if (EnchantUtil.isInvalidLevel(ref, level)) return true;
}
return false;
}
/**
* Обход энчантов с признаком нелегальности.
*/
public static void collectEnchants(ItemStack stack, BiConsumer<String, Boolean> consumer) {
if (stack == null || stack.isEmpty() || consumer == null) return;
ItemEnchantments enchComp = EnchantmentHelper.getEnchantmentsForCrafting(stack);
for (Object2IntMap.Entry<Holder<Enchantment>> entry : enchComp.entrySet()) {
Holder<Enchantment> ref = entry.getKey();
int level = entry.getIntValue();
if (level <= 0) continue;
boolean overMax = EnchantUtil.isInvalidLevel(ref, level);
String label = Enchantment.getFullname(ref, level).getString();
consumer.accept(label, overMax);
}
}
}

View file

@ -0,0 +1,25 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.item.rarity;
import net.minecraft.world.item.ItemStack;
/**
* Интерфейс для классов, которые могут извлекать цвет предмета
* на основе его редкости или других свойств.
*/
public interface ItemRarityColorProvider {
/**
* Возвращает RGBA цвет (0..1) для данного предмета.
*
* @param stack предмет
* @return массив float[4] {r,g,b,a}
*/
float[] getRarityColor(ItemStack stack);
}

View file

@ -0,0 +1,76 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.item.rarity;
import net.minecraft.world.item.Rarity;
import dev.loki.lovisual.config.definition.ConfigNameProvider;
import dev.loki.lovisual.config.definition.ConfigObject;
import dev.loki.lovisual.config.io.ConfigSerializer;
import dev.loki.lovisual.config.values.collection.ConfigValue;
import dev.loki.lovisual.config.values.color.RGBColorValue;
import dev.loki.lovisual.features.module.modules.visuals.esp.overlay.DropESP;
import java.util.ArrayList;
import java.util.List;
/**
* Editable rarity colors (RGB) for tooltips/DropESP/etc.
*/
public final class RarityColorConfig implements ConfigObject, ConfigNameProvider {
public static final RarityColorConfig INSTANCE = new RarityColorConfig();
private final RGBColorValue common = new RGBColorValue("rarity_common", "#FFFFFF");
private final RGBColorValue uncommon = new RGBColorValue("rarity_uncommon", "#55FF55");
private final RGBColorValue rare = new RGBColorValue("rarity_rare", "#5555FF");
private final RGBColorValue epic = new RGBColorValue("rarity_epic", "#FF55FF");
private RarityColorConfig() {
ConfigSerializer.load(this);
}
@Override
public String getConfigName() {
return "raritycolorconfig";
}
public int getColor(Rarity rarity) {
if (rarity == null) return common.getArgb();
return switch (rarity) {
case UNCOMMON -> uncommon.getArgb();
case RARE -> rare.getArgb();
case EPIC -> epic.getArgb();
default -> common.getArgb();
};
}
public RGBColorValue commonValue() {
return common;
}
public RGBColorValue uncommonValue() {
return uncommon;
}
public RGBColorValue rareValue() {
return rare;
}
public RGBColorValue epicValue() {
return epic;
}
@Override
public List<ConfigValue<?>> getConfigValues() {
List<ConfigValue<?>> list = new ArrayList<>();
list.add(common);
list.add(uncommon);
list.add(rare);
list.add(epic);
return list;
}
}

View file

@ -0,0 +1,56 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.item.rarity;
import dev.loki.lovisual.features.module.modules.visuals.esp.overlay.DropESP;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Rarity;
import dev.loki.lovisual.util.item.rarity.ItemRarityColorProvider;
import dev.loki.lovisual.util.item.rarity.RarityColorConfig;
/**
* Rarity -> RGBA for DropESP. Зачарованные вещи имеют минимум RARE.
*/
public final class RarityColorUtil implements ItemRarityColorProvider {
public static final RarityColorUtil INSTANCE = new RarityColorUtil();
private RarityColorUtil() {
}
@Override
public float[] getRarityColor(ItemStack stack) {
if (stack == null || stack.isEmpty()) {
return white();
}
Rarity rarity = stack.get(DataComponents.RARITY);
if (rarity == null) rarity = Rarity.COMMON;
if (stack.isEnchanted()) {
rarity = forceAtLeastRarity(rarity, Rarity.RARE);
}
int argb = RarityColorConfig.INSTANCE.getColor(rarity);
int rgb = argb & 0x00FFFFFF;
float r = (rgb >> 16 & 0xFF) / 255f;
float g = (rgb >> 8 & 0xFF) / 255f;
float b = (rgb & 0xFF) / 255f;
return new float[]{r, g, b, 1f};
}
private Rarity forceAtLeastRarity(Rarity current, Rarity min) {
return current.ordinal() < min.ordinal() ? min : current;
}
private float[] white() {
return new float[]{1f, 1f, 1f, 1f};
}
}

View file

@ -0,0 +1,207 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.logging;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public enum DebugLog {
;
private static final Logger LOGGER = LoggerFactory.getLogger("LoVisual");
private static final Set<String> ONCE_KEYS = ConcurrentHashMap.newKeySet();
private static final ConcurrentMap<String, String> LAST_STATES = new ConcurrentHashMap<>();
private static volatile DebugMode mode = DebugMode.OFF;
public static void setMode(DebugMode mode) {
DebugLog.mode = mode == null ? DebugMode.OFF : mode;
}
public static DebugMode mode() {
return mode;
}
public static boolean isEnabled() {
return mode != DebugMode.OFF;
}
public static boolean serverOnly() {
return mode == DebugMode.SERVERDEBUG;
}
public static boolean isStencilDebugEnabled() {
return branchEnabled(Branch.STENCIL);
}
public static boolean isRenderThreadDebugEnabled() {
return branchEnabled(Branch.RENDER_THREAD);
}
public static boolean isConfigDebugEnabled() {
return branchEnabled(Branch.CONFIG);
}
public static void info(String message, Object... args) {
logInfo(Branch.GENERAL, "", message, args);
}
public static void config(String message, Object... args) {
logInfo(Branch.CONFIG, "[CONFIG] ", message, args);
}
public static void renderThread(String message, Object... args) {
logInfo(Branch.RENDER_THREAD, "[RENDER] ", message, args);
}
public static void stencil(String message, Object... args) {
logInfo(Branch.STENCIL, "[STENCIL] ", message, args);
}
public static void server(String message, Object... args) {
logInfo(Branch.SERVER, "[SERVER] ", message, args);
}
public static void warn(String message, Object... args) {
if (!warnEnabled()) return;
log(LogLevel.WARN, "[WARN] ", message, args);
}
public static void infoOnce(String key, String message, Object... args) {
if (once(key)) info(message, args);
}
public static void configOnce(String key, String message, Object... args) {
if (once(key)) config(message, args);
}
public static void renderThreadOnce(String key, String message, Object... args) {
if (once(key)) renderThread(message, args);
}
public static void stencilOnce(String key, String message, Object... args) {
if (once(key)) stencil(message, args);
}
public static void warnOnce(String key, String message, Object... args) {
if (once(key)) warn(message, args);
}
public static void errorOnce(String key, String message, Object... args) {
if (once(key)) error(message, null, args);
}
public static void infoOnChange(String key, Object state, String message, Object... args) {
if (changed(key, state)) info(message, args);
}
public static void configOnChange(String key, Object state, String message, Object... args) {
if (changed(key, state)) config(message, args);
}
public static void renderThreadOnChange(String key, Object state, String message, Object... args) {
if (changed(key, state)) renderThread(message, args);
}
public static void stencilOnChange(String key, Object state, String message, Object... args) {
if (changed(key, state)) stencil(message, args);
}
public static void warnOnChange(String key, Object state, String message, Object... args) {
if (changed(key, state)) warn(message, args);
}
public static void errorOnChange(String key, Object state, String message, Object... args) {
if (changed(key, state)) error(message, null, args);
}
public static void error(String message) {
error(message, null);
}
public static void error(String message, Throwable t) {
log(LogLevel.ERROR, "[ERROR] ", message);
if (isEnabled() && t != null) {
LOGGER.error("[LoVisual][Debug] exception", t);
}
}
public static void error(String message, Throwable t, Object... args) {
log(LogLevel.ERROR, "[ERROR] ", message, args);
if (isEnabled() && t != null) {
LOGGER.error("[LoVisual][Debug] exception", t);
}
}
private static void logInfo(Branch branch, String prefix, String message, Object... args) {
if (!branchEnabled(branch)) return;
log(LogLevel.INFO, prefix, message, args);
}
private static boolean warnEnabled() {
return mode != DebugMode.OFF && mode != DebugMode.ERROR_ONLY;
}
private static boolean branchEnabled(Branch branch) {
return switch (mode) {
case OFF, ERROR_ONLY, ERROR_AND_WARNINGS -> false;
case INFO -> branch == Branch.GENERAL;
case CONFIG -> branch == Branch.CONFIG;
case RENDER_THREAD -> branch == Branch.RENDER_THREAD;
case STENCIL -> branch == Branch.STENCIL;
case SERVERDEBUG -> branch == Branch.SERVER;
case ALL -> true;
};
}
private static boolean once(String key) {
String safeKey = key == null ? "<null>" : key;
return ONCE_KEYS.add(safeKey);
}
private static boolean changed(String key, Object state) {
String safeKey = key == null ? "<null>" : key;
String next = String.valueOf(state);
String previous = LAST_STATES.put(safeKey, next);
return !next.equals(previous);
}
private static void log(LogLevel level, String prefix, String message, Object... args) {
if (!isEnabled()) return;
Objects.requireNonNull(message, "message");
String formatted = (args == null || args.length == 0)
? message
: String.format(message, args);
String out = "[LoVisual][Debug] " + prefix + formatted;
switch (level) {
case INFO -> LOGGER.info(out);
case WARN -> LOGGER.warn(out);
case ERROR -> LOGGER.error(out);
}
}
private enum Branch {
GENERAL,
CONFIG,
RENDER_THREAD,
STENCIL,
SERVER
}
private enum LogLevel {
INFO,
WARN,
ERROR
}
}

View file

@ -0,0 +1,20 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.logging;
public enum DebugMode {
OFF,
ERROR_ONLY,
ERROR_AND_WARNINGS,
INFO,
CONFIG,
RENDER_THREAD,
STENCIL,
SERVERDEBUG,
ALL
}

View file

@ -0,0 +1,82 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.logging;
import net.minecraft.network.chat.Component;
import net.minecraft.world.scores.Objective;
import net.minecraft.world.scores.PlayerScoreEntry;
import net.minecraft.world.scores.PlayerTeam;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public enum ServerDumpUtil {
;
private static final Map<String, String> LAST_SIGNATURES = new ConcurrentHashMap<>();
public static void dumpTabText(String section, Component text) {
if (text == null || !DebugLog.serverOnly()) return;
String raw = text.getString();
if (raw.isBlank()) return;
String key = "tab:" + section;
if (!markChanged(key, raw)) return;
DebugLog.server("==== TAB %s ====", section);
for (String line : raw.split("\\R")) {
DebugLog.server("[TAB] %s", line);
}
DebugLog.server("========================");
}
public static void dumpScoreboardSidebar(Objective objective,
List<PlayerScoreEntry> entries,
List<String> renderedLabels,
List<String> renderedValues) {
if (objective == null || !DebugLog.serverOnly()) return;
StringBuilder signature = new StringBuilder();
signature.append(objective.getName()).append('|')
.append(objective.getDisplayName().getString()).append('|');
for (int i = 0; i < entries.size(); i++) {
PlayerScoreEntry entry = entries.get(i);
signature.append(i).append(':')
.append(entry.value()).append(':')
.append(entry.owner()).append(':')
.append(entry.ownerName().getString()).append('\n');
}
if (!markChanged("scoreboard:sidebar", signature.toString())) return;
DebugLog.server("[ScoreboardDump] objectiveName=%s display=\"%s\" entries=%d",
objective.getName(), objective.getDisplayName().getString(), entries.size());
for (int i = 0; i < entries.size(); i++) {
PlayerScoreEntry entry = entries.get(i);
PlayerTeam team = objective.getScoreboard().getPlayersTeam(entry.owner());
Component decorated = PlayerTeam.formatNameForTeam(team, entry.ownerName());
String renderedLabel = i < renderedLabels.size() ? renderedLabels.get(i) : "<skipped>";
String renderedValue = i < renderedValues.size() ? renderedValues.get(i) : "<skipped>";
DebugLog.server("[ScoreboardDump] [%d] score=%d owner=\"%s\" rawName=\"%s\" decorated=\"%s\" label=\"%s\" value=\"%s\"",
i,
entry.value(),
entry.owner(),
entry.ownerName().getString(),
decorated.getString(),
renderedLabel,
renderedValue);
}
}
private static boolean markChanged(String key, String signature) {
String previous = LAST_SIGNATURES.put(key, signature);
return !signature.equals(previous);
}
}

View file

@ -0,0 +1,199 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ServerData;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.bars.CustomBar;
import dev.loki.lovisual.util.logging.DebugLog;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
public enum MapPlayersCache {
;
private static final long REFRESH_MS = 10_000L;
private static final AtomicBoolean FETCHING = new AtomicBoolean(false);
private static volatile Set<UUID> cachedPlayers = Collections.emptySet();
private static volatile long lastFetchMs = 0L;
private static volatile String lastUrl = null;
public static Set<UUID> getPlayers(Minecraft mc, CustomBar bm) {
if (mc == null || bm == null) return Collections.emptySet();
if (!bm.isLocatorMapFilterEnabled()) return Collections.emptySet();
String serverHost = bm.getLocatorMapServer();
if (serverHost == null || serverHost.isBlank()) return Collections.emptySet();
if (!isOnServer(mc, serverHost)) return Collections.emptySet();
String baseUrl = bm.getLocatorMapUrl();
if (baseUrl == null || baseUrl.isBlank()) return Collections.emptySet();
String playersUrl = normalizePlayersUrl(baseUrl);
if (playersUrl == null || playersUrl.isBlank()) return Collections.emptySet();
long now = System.currentTimeMillis();
if (!playersUrl.equals(lastUrl)) {
lastUrl = playersUrl;
cachedPlayers = Collections.emptySet();
lastFetchMs = 0L;
}
if (now - lastFetchMs >= REFRESH_MS && FETCHING.compareAndSet(false, true)) {
DebugLog.server("Locator map fetch start: %s", playersUrl);
CompletableFuture.runAsync(() -> fetch(playersUrl));
}
return cachedPlayers;
}
private static void fetch(String playersUrl) {
try {
String json = httpGet(playersUrl);
if (json == null || json.isBlank()) {
DebugLog.server("Locator map fetch empty: %s", playersUrl);
return;
}
Set<UUID> next = parsePlayers(json);
cachedPlayers = next;
DebugLog.server("Locator map fetch ok: %d player(s)", next.size());
} catch (Exception ignored) {
DebugLog.server("Locator map fetch failed: %s", playersUrl);
} finally {
lastFetchMs = System.currentTimeMillis();
FETCHING.set(false);
}
}
private static String httpGet(String urlText) throws Exception {
URL url = URI.create(urlText).toURL();
HttpURLConnection request = (HttpURLConnection) url.openConnection();
request.setRequestMethod("GET");
request.setRequestProperty("Content-Type", "application/json");
request.setInstanceFollowRedirects(true);
request.setConnectTimeout(10_000);
request.setReadTimeout(10_000);
StringBuilder response = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(request.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
return response.toString();
}
private static Set<UUID> parsePlayers(String json) {
try {
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
JsonArray players = root.has("players") ? root.getAsJsonArray("players") : new JsonArray();
if (players == null || players.isEmpty()) return Collections.emptySet();
Set<UUID> ids = new HashSet<>();
for (JsonElement el : players) {
if (!el.isJsonObject()) continue;
JsonObject p = el.getAsJsonObject();
if (!p.has("uuid")) continue;
String raw = p.get("uuid").getAsString();
UUID uuid = parseUuid(raw);
if (uuid != null) ids.add(uuid);
}
return ids;
} catch (Exception ignored) {
return Collections.emptySet();
}
}
private static UUID parseUuid(String raw) {
if (raw == null) return null;
String s = raw.trim();
if (s.isEmpty()) return null;
try {
if (s.indexOf('-') >= 0) {
return UUID.fromString(s);
}
if (s.length() == 32) {
String dashed = s.substring(0, 8) + "-" + s.substring(8, 12) + "-" + s.substring(12, 16)
+ "-" + s.substring(16, 20) + "-" + s.substring(20);
return UUID.fromString(dashed);
}
} catch (IllegalArgumentException ignored) {
}
return null;
}
private static String normalizePlayersUrl(String baseUrl) {
String b = baseUrl.trim();
if (b.isEmpty()) return "";
if (b.endsWith("/")) b = b.substring(0, b.length() - 1);
if (b.endsWith("players.json")) {
return b;
}
if (b.endsWith("/tiles")) {
return b + "/players.json";
}
return b + "/tiles/players.json";
}
private static boolean isOnServer(Minecraft mc, String serverHost) {
if (mc == null || mc.hasSingleplayerServer()) return false;
ServerData info = mc.getCurrentServer();
if (info == null || info.ip == null) return false;
String current = stripPort(info.ip);
return current.equalsIgnoreCase(serverHost.trim());
}
private static String stripPort(String address) {
if (address == null) return "";
String s = address.trim();
if (s.isEmpty()) return "";
if (s.startsWith("[")) {
int end = s.indexOf(']');
if (end > 1) {
return s.substring(1, end);
}
return s;
}
int lastColon = s.lastIndexOf(':');
if (lastColon > 0 && lastColon + 1 < s.length()) {
String tail = s.substring(lastColon + 1);
boolean digits = true;
for (int i = 0; i < tail.length(); i++) {
char c = tail.charAt(i);
if (c < '0' || c > '9') {
digits = false;
break;
}
}
if (digits) {
return s.substring(0, lastColon);
}
}
return s;
}
}

View file

@ -0,0 +1,124 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.mctiers;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Асинхронный кэш профилей mctiers.com (публичный REST API, без ключа):
* {@code GET https://mctiers.com/api/search_profile/<username>} возвращает
* {@code rankings.<mode>.{tier,retired}} на игровой режим (vanilla/sword/
* uhc/pot/neth_pot/smp/axe/mace). Паттерн кэша и HTTP-вызова — как у
* {@link dev.loki.lovisual.features.account.SkinManager} (та же ниша:
* не блокировать рендер, тихо ронять сеть).
*/
public enum MCTiersCache {
;
private static final String API_URL = "https://mctiers.com/api/search_profile/%s";
private static final long TTL_MS = 5 * 60_000L;
private static final Map<String, CachedProfile> CACHE = new ConcurrentHashMap<>();
private static final Map<String, Boolean> LOADING = new ConcurrentHashMap<>();
private static final Executor EXECUTOR = Executors.newFixedThreadPool(2, runnable -> {
Thread thread = new Thread(runnable, "LoVisual-MCTiersLookup");
thread.setDaemon(true);
return thread;
});
/** @return тир для режима, либо null если ещё не загружен/нет ранга — запрос уходит в фон сам. */
public static MCTiersTier getTier(String playerName, String apiMode) {
String key = normalize(playerName);
if (key.isEmpty()) return null;
CachedProfile cached = CACHE.get(key);
if (cached != null && System.currentTimeMillis() - cached.fetchedAtMs < TTL_MS) {
return cached.rankings.get(apiMode);
}
if (LOADING.putIfAbsent(key, Boolean.TRUE) == null) {
CompletableFuture.runAsync(() -> load(playerName, key), EXECUTOR);
}
return cached != null ? cached.rankings.get(apiMode) : null;
}
public static void clearCache() {
CACHE.clear();
LOADING.clear();
}
private static void load(String playerName, String key) {
try {
JsonObject profile = fetchProfile(playerName);
if (profile == null) return;
Map<String, MCTiersTier> rankings = new ConcurrentHashMap<>();
if (profile.has("rankings") && profile.get("rankings").isJsonObject()) {
JsonObject rankingsJson = profile.getAsJsonObject("rankings");
for (String mode : rankingsJson.keySet()) {
JsonObject ranking = rankingsJson.getAsJsonObject(mode);
if (ranking == null || !ranking.has("tier")) continue;
int tier = ranking.get("tier").getAsInt();
boolean retired = ranking.has("retired") && ranking.get("retired").getAsBoolean();
rankings.put(mode, new MCTiersTier(tier, retired));
}
}
CACHE.put(key, new CachedProfile(rankings, System.currentTimeMillis()));
} catch (Throwable ignored) {
} finally {
LOADING.remove(key);
}
}
private static JsonObject fetchProfile(String playerName) {
try {
HttpURLConnection connection = (HttpURLConnection) URI
.create(String.format(API_URL, playerName))
.toURL()
.openConnection();
connection.setRequestMethod("GET");
connection.setInstanceFollowRedirects(true);
connection.setConnectTimeout(5000);
connection.setReadTimeout(5000);
connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("User-Agent", "LoVisual");
if (connection.getResponseCode() != 200) {
connection.disconnect();
return null;
}
try (InputStreamReader reader = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) {
return JsonParser.parseReader(reader).getAsJsonObject();
} finally {
connection.disconnect();
}
} catch (Throwable ignored) {
return null;
}
}
private static String normalize(String name) {
return name == null ? "" : name.trim().toLowerCase(Locale.ROOT);
}
private record CachedProfile(Map<String, MCTiersTier> rankings, long fetchedAtMs) {
}
}

View file

@ -0,0 +1,56 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.mctiers;
/**
* Чистое форматирование одной строки рейтинга mctiers.com (тир 1..5,
* retired-флаг) в текст/цвет — вынесено из парсинга JSON, чтобы тестить
* без сети. Соответствие тиров цветам/подписям взято из публичного формата
* mctiers.com (HT1/LT2/LT3/LT4, T5+ как запасной вариант).
*/
public record MCTiersTier(int tier, boolean retired) {
public String label() {
return switch (tier) {
case 1 -> "HT1";
case 2 -> "LT2";
case 3 -> "LT3";
case 4 -> "LT4";
default -> "T" + tier;
};
}
/** ARGB, непрозрачный. */
public int colorArgb() {
return switch (tier) {
case 1 -> 0xFFFF5555;
case 2 -> 0xFFFF55FF;
case 3 -> 0xFF5555FF;
case 4 -> 0xFF55FF55;
default -> 0xFFAAAAAA;
};
}
public static String modeApiKey(String fixType) {
if (fixType == null) return "vanilla";
return switch (fixType) {
case "UHC" -> "uhc";
case "Pot" -> "pot";
case "NethOP" -> "neth_pot";
case "SMP" -> "smp";
case "Sword" -> "sword";
case "Axe" -> "axe";
case "Mace" -> "mace";
default -> "vanilla";
};
}
public static String modeDisplayName(String fixType) {
return fixType == null ? "Vanilla" : fixType;
}
}

View file

@ -0,0 +1,25 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.media.impl;
import dev.loki.lovisual.util.media.session.IMediaSession;
import dev.loki.lovisual.util.media.info.MediaPlayerInfo;
import java.util.List;
public final class DummyMediaPlayerInfo implements MediaPlayerInfo {
public static final DummyMediaPlayerInfo INSTANCE = new DummyMediaPlayerInfo();
private DummyMediaPlayerInfo() {
}
@Override
public List<IMediaSession> getMediaSessions() {
return List.of();
}
}

Some files were not shown because too many files have changed in this diff Show more