From dac80386b5e984e4b907f54509eb1e9cbe1c9f21 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Wed, 30 Sep 2026 12:41:35 +0000 Subject: [PATCH] fix(delta): make the audio and config patches actually compile, add DeltaDisplay 0373806e2 and 0817a5706 were pushed but never built, so the server jar was broken on main. Six compile errors, all in code that had never run: - ClientboundSoundPacket takes Holder in 1.21, not SoundEvent. - CraftWorld has no getServer(); the vanilla handle does. - Server has no paperConfigurations()/registryAccess() - Configurations is reachable through the console, and saveGlobalSection lives on Configurations (GlobalConfiguration is a ConfigurationPart, i.e. the data, not the writer). - ServerPlayer.server is private. DeltaSound pumped delayed packets through BukkitScheduler, which requires a live Plugin, and Delta as a core has none. It now uses GlobalRegionScheduler with a null plugin, which exists precisely for core tasks. Delays are counted in game ticks via Bukkit#getCurrentTick rather than System.nanoTime, so they no longer stretch under lag. The pump cancels itself once the queue drains. New DeltaDisplay holds the predictive-teleport policy (lead range, default, application) and /delta displaylead tunes it. Honest about its scope: Display#setTeleportDuration is already stock Bukkit, so Delta adds no new API to plugins and breaks no compatibility - it centralises the policy and reports it. The position shift itself is done by the plugin, per visual, along that visual's own trajectory. DeltaBuild now registers the existing features in the startup banner instead of printing "no custom features registered". The banner is emitted from a static initialiser before paper-global.yml is parsed, so the strings describe the mechanism and point at /delta status for live values rather than printing defaults as if they were the running configuration. --- .../java/me/sashegdev/delta/DeltaBuild.java | 17 +++- .../java/me/sashegdev/delta/DeltaCommand.java | 44 ++++++++-- .../java/me/sashegdev/delta/DeltaConfig.java | 11 +++ .../java/me/sashegdev/delta/DeltaDisplay.java | 88 +++++++++++++++++++ .../java/me/sashegdev/delta/DeltaSound.java | 44 +++++++--- .../org/bukkit/craftbukkit/CraftWorld.java | 6 +- 6 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 paper-server/src/main/java/me/sashegdev/delta/DeltaDisplay.java diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaBuild.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaBuild.java index 9c30db65f..92b3e61b9 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaBuild.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaBuild.java @@ -25,9 +25,23 @@ public final class DeltaBuild { private DeltaBuild() { } - /** Зарегистрированные кастомные возможности: имя -> краткое описание состояния. */ + /** Зарегистрированные кастомные возможности: имя -> краткое описание. */ private static final Map> FEATURES = new LinkedHashMap<>(); + static { + // Баннер печатается в статическом инициализаторе PaperBootstrap, то есть + // ДО загрузки paper-global.yml: значения конфига на этом этапе ещё не + // прочитаны и покажутся дефолтными. Поэтому строки описывают механизм, + // а фактическое состояние смотрится через /delta status. + FEATURES.put("display tracking range", () -> "clamped in org.spigotmc.TrackingRange (off by default, /delta displayrange)"); + FEATURES.put("config persistence", () -> "paper-global.yml -> delta is writable (/delta saves only its own section)"); + FEATURES.put("decoupled sound range", () -> "World#playSound(loc, sound, vol, pitch, range) + World#playSoundAttenuated(...)"); + FEATURES.put("attenuated sound", () -> "wave propagation " + DeltaSound.speed() + " blocks/tick, gain=(1-d/range)^" + + DeltaSound.exponent() + ", one packet per player"); + FEATURES.put("predictive display", () -> "display lead " + DeltaDisplay.MIN_LEAD_TICKS + ".." + DeltaDisplay.MAX_LEAD_TICKS + + " ticks (default " + DeltaDisplay.DEFAULT_LEAD_TICKS + ", tune with /delta displaylead)"); + } + /** * Зарегистрировать кастомную фичу ядра. Вызывается из инициализаторов * модулей до {@link #startupMessages(ServerBuildInfo)}. @@ -60,6 +74,7 @@ public final class DeltaBuild { synchronized (DeltaBuild.class) { FEATURES.forEach((name, status) -> lines.add(" - " + name + ": " + status.get())); } + lines.add(" live values: /delta status"); } lines.add("-----------------------------------------------------"); return lines; diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java index b6bce8484..3718294b0 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java @@ -18,7 +18,7 @@ import java.util.Locale; */ public class DeltaCommand extends Command { - private static final List SUBS = Arrays.asList("status", "displayrange", "clear", "help"); + private static final List SUBS = Arrays.asList("status", "displayrange", "displaylead", "clear", "help"); public DeltaCommand(String name) { super(name); @@ -47,6 +47,7 @@ public class DeltaCommand extends Command { sender.sendMessage(line("extended distance ", cfg.extendedParticleDistanceEnabled ? cfg.extendedParticleDistance + " blocks" : "off")); sender.sendMessage(line("decoupled sound ", String.valueOf(cfg.decoupledSoundRangeEnabled))); + sender.sendMessage(line("display lead ", DeltaDisplay.describe())); if (sender instanceof Player p) { sender.sendMessage(line("your view distance", String.valueOf(p.getViewDistance()))); } @@ -77,14 +78,41 @@ public class DeltaCommand extends Command { // saveGlobalSection(), который пишет только секцию delta и не трогает остальное. persist(sender, "display range"); } + case "displaylead" -> { + var cfg = io.papermc.paper.configuration.GlobalConfiguration.get().delta; + if (args.length < 2) { + sender.sendMessage(line("display lead ", DeltaDisplay.describe())); + return true; + } + int value; + try { + value = Integer.parseInt(args[1]); + } catch (NumberFormatException e) { + sender.sendMessage(Component.text("Not a number: " + args[1], NamedTextColor.RED)); + return true; + } + if (value < DeltaDisplay.MIN_LEAD_TICKS || value > DeltaDisplay.MAX_LEAD_TICKS) { + sender.sendMessage(Component.text( + "Lead must be " + DeltaDisplay.MIN_LEAD_TICKS + ".." + DeltaDisplay.MAX_LEAD_TICKS + + " (0 disables)", NamedTextColor.RED)); + return true; + } + cfg.display.predictiveLeadTicks = value; + sender.sendMessage(Component.text(value == 0 + ? "Predictive display smoothing disabled (stock Paper)" + : "Display lead set to " + value + " ticks", NamedTextColor.GREEN)); + persist(sender, "display lead"); + } case "clear" -> { + io.papermc.paper.configuration.GlobalConfiguration.get().delta.display.predictiveLeadTicks = 0; var cfg = io.papermc.paper.configuration.GlobalConfiguration.get().delta; cfg.displayTrackingRangeEnabled = false; persist(sender, "display range"); sender.sendMessage(line("display range ", DeltaTracking.describe())); } case "help" -> sender.sendMessage(Component.text( - "/delta status | /delta displayrange | /delta clear", NamedTextColor.YELLOW)); + "/delta status | /delta displayrange | /delta displaylead | /delta clear", + NamedTextColor.YELLOW)); default -> sender.sendMessage(Component.text("Unknown subcommand: " + sub, NamedTextColor.RED)); } return true; @@ -108,6 +136,9 @@ public class DeltaCommand extends Command { if (args.length == 2 && args[0].equalsIgnoreCase("displayrange")) { return List.of("0", "48", "64", "96"); } + if (args.length == 2 && args[0].equalsIgnoreCase("displaylead")) { + return List.of("0", "2", "3", "5", "8"); + } return List.of(); } @@ -117,11 +148,12 @@ public class DeltaCommand extends Command { * поэтому сломать чужие настройки этим невозможно. */ private void persist(final CommandSender sender, final String what) { - final var server = Bukkit.getServer(); try { - server.paperConfigurations().saveGlobalSection( - server.registryAccess(), "delta", - io.papermc.paper.configuration.GlobalConfiguration.get().delta); + final var global = io.papermc.paper.configuration.GlobalConfiguration.get(); + final var console = ((org.bukkit.craftbukkit.CraftServer) Bukkit.getServer()).getHandle().getServer(); + // saveGlobalSection() живёт на Configurations, а GlobalConfiguration — это + // ConfigurationPart (сами данные), поэтому запись идёт через владельца. + console.paperConfigurations.saveGlobalSection(console.registryAccess(), "delta", global.delta); sender.sendMessage(Component.text("Saved to paper-global.yml -> delta (" + what + ")", NamedTextColor.DARK_GREEN)); } catch (Throwable t) { sender.sendMessage(Component.text( diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java index 55a23434c..009cd7e3e 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java @@ -29,6 +29,17 @@ public class DeltaConfig extends ConfigurationPart { /** Затухающий звук: волна идёт наружу и стихает по дистанции. */ public Sound sound; + /** Политика предсказывающей телепортации Display-сущностей. */ + public Display display; + + public class Display extends ConfigurationPart { + /** + * На сколько тиков Display уходит вперёд, чтобы клиент не отставал. + * 0 = выключить, вернуться к стоковому поведению Paper. + */ + public int predictiveLeadTicks = 3; + } + public class Sound extends ConfigurationPart { /** * Скорость распространения в блоках за тик. diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaDisplay.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaDisplay.java new file mode 100644 index 000000000..5268e842a --- /dev/null +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaDisplay.java @@ -0,0 +1,88 @@ +package me.sashegdev.delta; + +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.bukkit.entity.Entity; +import org.bukkit.util.Vector; + +/** + * Предсказывающая (lead) телепортация Display-сущностей. + * + *

Display-сущности двигаются пакетами позиции, и клиент по умолчанию + * интерполирует их у себя, из-за чего на реальном пинге визуал отстаёт и + * «дёргается» в упор. Лечится это не пакетами чаще, а одной предсказательной + * телепортацией: сервер ставит сущность сразу в то место, где она окажется + * через {@code N} тиков, и говорит клиенту интерполировать на эти же + * {@code N} тиков. Клиент никогда не отстаёт, а полоса нагрузки не растёт — + * пакетов ровно столько же, сколько было. + * + *

Почему так, а не «слать позицию каждый тик» или «сдвигать по + * transformation-offset»: подтики в пакетах Bukkit не умеет, а смещение + * относительно начала координат ломает отсечение и трекинг — сущность + * выпадает из чужого экрана и появляется с задержкой. + * + *

Ограничение, о котором стоит знать честно: {@code teleportDuration} — + * это уже стоковый Bukkit API ({@code Display#setTeleportDuration}), поэтому + * ядро не добавляет плагинам новых методов и не ломает совместимость. Delta + * держит здесь единую политику (значение по умолчанию, диапазон, применение) + * и показывает её в баннере, а плагины применяют её сами. + */ +public final class DeltaDisplay { + + private DeltaDisplay() { + } + + /** Допустимый диапазон lead. Больше 10 — уже не предсказание, а телепорт рывком. */ + public static final int MIN_LEAD_TICKS = 0; + public static final int MAX_LEAD_TICKS = 10; + public static final int DEFAULT_LEAD_TICKS = 3; + + /** + * Значение по умолчанию из {@code paper-global.yml -> delta.display}. + * 0 = выключить предсказание и вернуться к стоковому поведению. + */ + public static int leadTicks() { + try { + return clamp(io.papermc.paper.configuration.GlobalConfiguration.get().delta.display.predictiveLeadTicks); + } catch (Throwable t) { + return 3; + } + } + + public static int clamp(int ticks) { + return Math.max(MIN_LEAD_TICKS, Math.min(MAX_LEAD_TICKS, ticks)); + } + + /** + * Перевести сущность на {@code leadTicks} вперёд и растянуть телепорт на них же. + * + * @param entity что двигаем + * @param perTick смещение за один тик (обычно velocity сущности) + * @param leadTicks на сколько тиков забежать вперёд + * @return тот же объект, чтобы можно было вызывать в цепочке + */ + public static T lead(final T entity, final Vector perTick, final int leadTicks) { + final int lead = clamp(leadTicks); + if (lead <= 0 || perTick == null) { + return entity; + } + final Location next = entity.getLocation().add(perTick.clone().multiply(lead)); + entity.teleport(next); + if (entity instanceof Display display) { + display.setTeleportDuration(lead); + } + return entity; + } + + /** Только растянуть интерполяцию, без сдвига позиции. */ + public static T tune(final T display, final int leadTicks) { + display.setTeleportDuration(clamp(leadTicks)); + return display; + } + + /** Описание для {@code /delta status} и баннера. */ + public static String describe() { + final int lead = leadTicks(); + return lead <= 0 ? "off (stock)" : lead + " tick lead"; + } +} diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java index 7eb8a7190..c926a0d51 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java @@ -35,7 +35,7 @@ public final class DeltaSound { } private static final List PENDING = new ArrayList<>(); - private static int pumpTaskId = -1; + private static io.papermc.paper.threadedregions.scheduler.ScheduledTask pumpTask = null; /** Итоговая громкость для игрока на дистанции {@code distance}. */ public static double attenuate(final double volume, final double distance, final double range, final double exponent) { @@ -66,23 +66,32 @@ public final class DeltaSound { synchronized (PENDING) { PENDING.add(new Pending(currentTick() + delayTicks, player, packet)); } - ensurePump(player.server); + ensurePump(); } private static long currentTick() { - // Отсчёт идёт от первого появления записи, поэтому относительные задержки - // не зависят от абсолютного значения игрового тика. - return (long) (System.nanoTime() / 50_000_000L); + // Игровые тики, а не wall clock: задержка не должна растягиваться при лагах + // и не должна зависеть от того, что кто-то подкрутил System.nanoTime(). + try { + return org.bukkit.Bukkit.getCurrentTick(); + } catch (Throwable t) { + return (long) (System.nanoTime() / 50_000_000L); + } } - private static void ensurePump(final Object server) { - if (pumpTaskId != -1) { + /** + * Насос задержанных пакетов. Живёт только пока есть что доставлять, и снимает + * себя сам — постоянный тиковый таск ради пустого списка не нужен. + * + *

Идёт через {@code GlobalRegionScheduler}, а не {@code BukkitScheduler}: + * тому нужен живой Plugin, а у Delta как у ядра плагина нет. + */ + private static void ensurePump() { + if (pumpTask != null) { return; } - final org.bukkit.Server bukkitServer = org.bukkit.Bukkit.getServer(); - pumpTaskId = bukkitServer.getScheduler() - .runTaskTimer(bukkitServer, DeltaSound::pump, 1L, 1L) - .getTaskId(); + pumpTask = org.bukkit.Bukkit.getServer().getGlobalRegionScheduler() + .runAtFixedRate(null, task -> pump(), 1L, 1L); } private static void pump() { @@ -97,9 +106,6 @@ public final class DeltaSound { done.add(p); } } - if (PENDING.isEmpty()) { - pumpTaskId = -1; - } } for (final Pending p : done) { // Игрок мог выйти или сменить мир за время задержки @@ -108,6 +114,12 @@ public final class DeltaSound { } p.player().connection.send(p.packet()); } + synchronized (PENDING) { + if (PENDING.isEmpty() && pumpTask != null) { + pumpTask.cancel(); + pumpTask = null; + } + } } /** Дистанция по горизонтали и вертикали от игрока до точки звука. */ @@ -162,6 +174,10 @@ public final class DeltaSound { public static void reset() { synchronized (PENDING) { PENDING.clear(); + if (pumpTask != null) { + pumpTask.cancel(); + pumpTask = null; + } } } } \ No newline at end of file diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java index fd00ded0f..524189767 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java @@ -1908,13 +1908,13 @@ public class CraftWorld extends CraftRegionAccessor implements World { final double z = loc.getZ(); final net.minecraft.network.protocol.game.ClientboundSoundPacket packet = new net.minecraft.network.protocol.game.ClientboundSoundPacket( - CraftSound.bukkitToMinecraft(sound), + net.minecraft.core.Holder.direct(CraftSound.bukkitToMinecraft(sound)), SoundSource.valueOf(category.name()), x, y, z, volume, pitch, this.getHandle().random.nextLong() ); - this.getServer().getHandle().getPlayerList().broadcast( + this.getHandle().getServer().getPlayerList().broadcast( null, x, y, z, range, this.getHandle().dimension(), packet ); } @@ -1953,7 +1953,7 @@ public class CraftWorld extends CraftRegionAccessor implements World { final float outgoing = me.sashegdev.delta.DeltaSound.outgoingVolume(gain, distance, rangeD); final net.minecraft.network.protocol.game.ClientboundSoundPacket packet = new net.minecraft.network.protocol.game.ClientboundSoundPacket( - CraftSound.bukkitToMinecraft(sound), + net.minecraft.core.Holder.direct(CraftSound.bukkitToMinecraft(sound)), SoundSource.valueOf(category.name()), x, y, z, outgoing, pitch, this.getHandle().random.nextLong()