fix(delta): make the audio and config patches actually compile, add DeltaDisplay
Build Delta / Build paperclip jar (push) Successful in 7m49s

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<SoundEvent> 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.
This commit is contained in:
SashegDev
2026-09-30 12:41:35 +00:00
parent 0817a57065
commit dac80386b5
6 changed files with 186 additions and 24 deletions
@@ -25,9 +25,23 @@ public final class DeltaBuild {
private DeltaBuild() {
}
/** Зарегистрированные кастомные возможности: имя -> краткое описание состояния. */
/** Зарегистрированные кастомные возможности: имя -> краткое описание. */
private static final Map<String, Supplier<String>> 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;
@@ -18,7 +18,7 @@ import java.util.Locale;
*/
public class DeltaCommand extends Command {
private static final List<String> SUBS = Arrays.asList("status", "displayrange", "clear", "help");
private static final List<String> 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 <blocks|0> | /delta clear", NamedTextColor.YELLOW));
"/delta status | /delta displayrange <blocks|0> | /delta displaylead <ticks|0> | /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(
@@ -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 {
/**
* Скорость распространения в блоках за тик.
@@ -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-сущностей.
*
* <p>Display-сущности двигаются пакетами позиции, и клиент по умолчанию
* интерполирует их у себя, из-за чего на реальном пинге визуал отстаёт и
* «дёргается» в упор. Лечится это не пакетами чаще, а одной предсказательной
* телепортацией: сервер ставит сущность сразу в то место, где она окажется
* через {@code N} тиков, и говорит клиенту интерполировать на эти же
* {@code N} тиков. Клиент никогда не отстаёт, а полоса нагрузки не растёт —
* пакетов ровно столько же, сколько было.
*
* <p>Почему так, а не «слать позицию каждый тик» или «сдвигать по
* transformation-offset»: подтики в пакетах Bukkit не умеет, а смещение
* относительно начала координат ломает отсечение и трекинг — сущность
* выпадает из чужого экрана и появляется с задержкой.
*
* <p>Ограничение, о котором стоит знать честно: {@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 extends Entity> 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 extends Display> 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";
}
}
@@ -35,7 +35,7 @@ public final class DeltaSound {
}
private static final List<Pending> 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) {
/**
* Насос задержанных пакетов. Живёт только пока есть что доставлять, и снимает
* себя сам — постоянный тиковый таск ради пустого списка не нужен.
*
* <p>Идёт через {@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;
}
}
}
}
@@ -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()