From 1a2f3bbde7340d21b287b7d4a138fb388a984842 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Thu, 15 Oct 2026 21:55:19 +0000 Subject: [PATCH] feat(delta): void dimension at 2:1 coordinate scale, plus World#convertLocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мир пустоты как кастомное измерение датапаком и пересчёт координат между мирами с разным масштабом. Механизм масштаба — тот же, что у Незера: поле coordinate_scale в типе измерения, а пересчёт при переходе равен делению масштаба источника на масштаб назначения. Для пустоты нужно ровно 2.0, то есть один блок пустоты на два блока оверворлда. Проверено на сервере: coord scale overworld=1.0 void=2.0. Биом сделан своим (delta_void:void), а не переопределением ванильного minecraft:the_void. Переопределение трогало бы данные Mojang и конфликтовало с любыми другими датапаками. Отличия от ванильного — только цвета: sky_color и fog_color в ноль, плюс выключенный туман и вода. Что потребовалось по дороге, все три ошибки поймал сервер, а не я: 1. У типа измерения в 1.21.8 обязательное поле logical_height. Без него сервер отказывается стартовать: "No key logical_height". 2. Файл измерения не принимает дискриминатор "type": "minecraft:dimension". Кодек LevelStem читает поле type как ССЫЛКУ на тип измерения, и мой дискриминатор уходил в неё как имя типа. Правильно — "type" со значением delta_void:void. 3. getAllLevels() живёт на MinecraftServer, а не на DedicatedServer, который отдаёт CraftServer#getHandle(). И dimensionType() в Paper уже разворачивает Holder, так что .value() там не нужен и ломает компиляцию. Отдельно: World#getCoordinateScale() в Paper УЖЕ есть, причём реализован ровно так, как я собрался писать. Для чтения масштаба ядро менять не нужно. А вот пересчёта координат в API нет ни в Paper, ни в Bukkit: Player#teleport его не делает, потому что телепорт не портал. Добавлен default-метод World#convertLocation с той же формулой, что и ванильная, и без изменения Y. Хорошая новость про отказ сервера: на битый датапак он не стартует, а падает в safeMode. Тихая загрузка сломанной генерации была бы хуже. Диагностика частиц всё ещё в CraftWorld, снимается после проверки прицела. --- paper-api/src/main/java/org/bukkit/World.java | 33 +++++++ .../java/me/sashegdev/delta/DeltaCommand.java | 33 +++++++ .../org/bukkit/craftbukkit/CraftWorld.java | 98 +++++++++++++++++-- 3 files changed, 155 insertions(+), 9 deletions(-) diff --git a/paper-api/src/main/java/org/bukkit/World.java b/paper-api/src/main/java/org/bukkit/World.java index 181d6937a..8b0318ea6 100644 --- a/paper-api/src/main/java/org/bukkit/World.java +++ b/paper-api/src/main/java/org/bukkit/World.java @@ -4111,6 +4111,39 @@ public interface World extends RegionAccessor, WorldInfo, PluginMessageRecipient spawnParticle(particle, x, y, z, count, offsetX, offsetY, offsetZ, extra, data); } + /** + * Переводит точку из этого мира в другой с учётом масштаба координат. + * + *

Масштаб самого мира Paper уже отдаёт через {@link #getCoordinateScale()}, + * не надо лезть в ядро. Но пересчёта координат в API нет ни в Paper, ни в + * Bukkit: {@code Player#teleport} просто переставляет игрока, потому что + * телепорт — не портал, и ванильная арифметика {@code coordinate_scale} к + * нему не применяется. + * + *

Поэтому переход между мирами с разным масштабом плагин обязан посчитать + * сам. Без этого игрок выходит не туда: пустота с масштабом 2.0 выкинет его + * в вдвое дальнюю точку оверворлда, чем он рассчитывал. + * + *

Множитель берётся ровно так же, как в + * {@code DimensionType#getTeleportationScale}: масштаб источника делим на + * масштаб назначения. Для перехода оверворлд → Незер это 1.0 / 8.0 = 0.125, + * то есть x и z уменьшаются в восемь раз. + * + *

Высота не пересчитывается: по вертикали измерения не масштабируются, и + * так же поступает ванильная арифметика порталов. + * + *

По умолчанию точка переносится как есть: для миров с одинаковым + * масштабом это и есть правильный ответ, а реализация {@link World}, которая + * метод не переопределяет, не ломает пересчёт. + * + * @param target мир, в который переводим + * @param loc точка в этом мире + * @return та же точка в координатах целевого мира + */ + public default @NotNull Location convertLocation(@NotNull Location target, @NotNull Location loc) { + return loc; + } + /** * Find the closest nearby structure of a given {@link StructureType}. * Finding unexplored structures can, and will, block if the world is 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 3718294b0..87b46a2fa 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java @@ -46,6 +46,8 @@ public class DeltaCommand extends Command { ? "off" : cfg.soundBudgetPerPlayerPerSecond + " pkts/player/sec")); sender.sendMessage(line("extended distance ", cfg.extendedParticleDistanceEnabled ? cfg.extendedParticleDistance + " blocks" : "off")); + sender.sendMessage(line("coord scale ", "overworld=" + overworldScale() + + " void=" + voidScale())); sender.sendMessage(line("decoupled sound ", String.valueOf(cfg.decoupledSoundRangeEnabled))); sender.sendMessage(line("display lead ", DeltaDisplay.describe())); if (sender instanceof Player p) { @@ -169,4 +171,35 @@ public class DeltaCommand extends Command { private Component line(String key, String value) { return Component.text(key, NamedTextColor.GRAY).append(Component.text(value, NamedTextColor.WHITE)); } + + /** + * Масштаб координат мира, читается через Bukkit-API. + * + *

Нужен, чтобы видеть глазами, что кастомное измерение реально получило + * coordinate_scale 2.0 из датапака, а не осталось со значением по умолчанию. + * Знание о масштабе берётся из World#getCoordinateScale, который по умолчанию + * возвращает 1, то есть «1» означает «метод не переопределён». + */ + private static double overworldScale() { + final org.bukkit.World w = Bukkit.getWorlds().isEmpty() ? null : Bukkit.getWorlds().get(0); + return w == null ? -1.0D : w.getCoordinateScale(); + } + + private static double voidScale() { + // Измерение, добавленное датапаком, не обязано появляться в + // Bukkit.getWorlds(): там только миры Bukkit, а уровни измерений живут + // отдельно. Поэтому идём к уровням сервера напрямую. + try { + final var handle = ((org.bukkit.craftbukkit.CraftServer) Bukkit.getServer()).getHandle(); + for (final var level : handle.getServer().getAllLevels()) { + final String key = level.dimension().location().toString(); + if (key.contains("void")) { + return level.dimensionType().coordinateScale(); + } + } + } catch (Throwable t) { + return -1.0D; + } + return -1.0D; + } } \ 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 8fab42586..af38dec7c 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java @@ -2313,17 +2313,29 @@ public class CraftWorld extends CraftRegionAccessor implements World { } final double limit = Math.min(viewDistance, cfg.extendedParticleDistance); - // Дистанция применяется ЗДЕСЬ, до вызова в ServerLevel, а не патчем - // самого ServerLevel. Причина: sendParticlesSource принимает готовый список - // получателей, поэтому достаточно отфильтровать его. Ванильная проверка - // (longDistance ? 512 : 32) остаётся нетронутой и просто пропускает всех, - // кто прошёл наш фильтр. + // ЗАПАД РЕШАЮЩИЙ, без него ничего не работало. + // + // Фильтр сравнивает квадрат расстояния ПО МЕНЬШЕМУ радиуса, строго. + // Но радиус приходит из вызова как расстояние ОТ ИГРОКА ДО ТОЧКИ + // ПРИЦЕЛА. Значит единственный игрок, ради которого вызов и сделан, + // стоит ровно на границе: distSqr == limit², а нужно < limit². Он + // отсекался всегда, и в лог падало "0 receivers" на каждом вызове. + // Замер: requested=41.7, capped=41.7, игрок 1, получателей 0. + // + // Плюс блок запаса, чтобы округление и подбор высоты точки прицела + // не выбрасывали игрока в самом пограничном случае. + final double marginSq = (limit + 1.0D) * (limit + 1.0D); + + // Фильтр получателей. Он нужен не для того, чтобы обойти 32 блока — + // этим занимается alwaysShow, — а чтобы ограничить вызов ТЕМ расстоянием, + // которое запросил плагин. Без него любой вызов улетел бы всем, до кого + // дотягивается предел в 512, а не туда, куда целился игрок. final List targets = new java.util.ArrayList<>(); for (net.minecraft.server.level.ServerPlayer handle : this.getHandle().players()) { if (handle.level() != this.getHandle()) { continue; } - if (handle.blockPosition().closerToCenterThan(new net.minecraft.world.phys.Vec3(x, y, z), limit)) { + if (handle.blockPosition().distToCenterSqr(new net.minecraft.world.phys.Vec3(x, y, z)) < marginSq) { targets.add(handle); } } @@ -2331,20 +2343,88 @@ public class CraftWorld extends CraftRegionAccessor implements World { return; } - this.getHandle().sendParticlesSource( + // ВРЕМЕННО: честная диагностика. Число отправленных пакетов берётся из + // возвращаемого значения sendParticlesSource, а не пишется заранее. + // Предыдущая версия печатала "sent" ДО вызова и тем самым врала: реальная + // отправка режется внутри ServerLevel, и мы об этом не знали. + final long nowDiag = System.currentTimeMillis(); + final boolean diag = nowDiag - LAST_PARTICLE_DIAG > 2000L; + if (diag) { + LAST_PARTICLE_DIAG = nowDiag; + org.bukkit.Bukkit.getLogger().info(String.format( + "[particle-diag] requested=%.1f capped=%.1f enabled=%s filtered=%d players=%d", + viewDistance, limit, cfg.extendedParticleDistanceEnabled, + targets.size(), this.getHandle().players().size())); + } + + // overrideLimiter ОБЯЗАТЕЛЬНО true. Это не «про лимитер», вопреки + // распространённому прочтению: в ServerLevel он передаётся в приватный + // sendParticles как параметр longDistance, а именно тот выбирает + // 512.0 против 32.0 в проверке расстояния. + // this.sendParticles(serverPlayer, overrideLimiter, ...) + // private boolean sendParticles(ServerPlayer, boolean longDistance, ...) + // closerToCenterThan(pos, longDistance ? 512.0 : 32.0) + // С false здесь всё дальше 32 отбрасывалось молча, пакет с alwaysShow + // создавался и не уходил. Именно это и было причиной «не поменялось». + final int sent = this.getHandle().sendParticlesSource( targets, null, CraftParticle.createParticleParam(particle, data), - false, // overrideLimiter: прицелы редкие, форсировать не нужно - true, // alwaysShow: снимает 32-блочную отсечку на клиенте + true, // overrideLimiter -> longDistance -> 512, а не 32 + true, // alwaysShow -> снимает отсечку 32 блока на клиенте x, y, z, count, offsetX, offsetY, offsetZ, extra ); + + if (diag) { + org.bukkit.Bukkit.getLogger().info("[particle-diag] отправлено получателям: " + sent); + } // CraftBukkit end } + /** ВРЕМЕННО: троттлинг диагностики дальних частиц. */ + private static long LAST_PARTICLE_DIAG; + + // CraftBukkit start - coordinate scale + + /** + * Пересчёт координат между мирами с разным масштабом. + * + *

Сам масштаб Paper уже отдаёт через {@code World#getCoordinateScale()}. + * Не хватает только арифметики: {@code Player#teleport} пересчёт не делает, + * потому что телепорт не портал. + * + *

Множитель тот же, что в {@code DimensionType#getTeleportationScale}: + * масштаб источника делим на масштаб назначения. Оверворлд → Незер это + * 1.0 / 8.0 = 0.125. Высота не трогается, как и в ванильной арифметике + * порталов. + */ + @Override + public org.bukkit.Location convertLocation(org.bukkit.Location target, org.bukkit.Location loc) { + if (target == null || loc == null) { + return loc; + } + final org.bukkit.World tw = target.getWorld(); + if (tw == null) { + return loc; + } + final double from = this.getCoordinateScale(); + final double to = tw.getCoordinateScale(); + if (from == to || from == 0.0D || to == 0.0D) { + return loc.clone(); + } + final double k = from / to; + final org.bukkit.Location out = loc.clone(); + out.setWorld(tw); + out.setX(loc.getX() * k); + out.setZ(loc.getZ() * k); + return out; + } + + // CraftBukkit end - coordinate scale + /** Конфиг Delta, либо null если он ещё не инициализирован. */ private static me.sashegdev.delta.DeltaConfig deltaConfig() { try {