diff --git a/src/main/java/me/cortex/voxy/common/config/VoxyServerConfig.java b/src/main/java/me/cortex/voxy/common/config/VoxyServerConfig.java index 72e74b85..52358560 100644 --- a/src/main/java/me/cortex/voxy/common/config/VoxyServerConfig.java +++ b/src/main/java/me/cortex/voxy/common/config/VoxyServerConfig.java @@ -3,6 +3,8 @@ package me.cortex.voxy.common.config; import com.google.gson.FieldNamingPolicy; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.annotations.SerializedName; +import com.google.gson.stream.JsonReader; import me.cortex.voxy.common.Logger; import me.cortex.voxy.commonImpl.VoxyCommon; import net.fabricmc.loader.api.FabricLoader; @@ -22,61 +24,76 @@ public class VoxyServerConfig { public static VoxyServerConfig CONFIG = loadOrCreate(); + @SerializedName(value = "view_distance", alternate = {"viewDistance"}) public int viewDistance = 32; - public int dirtyUpdateDelay = 20; // Ticks before sending dirty update + @SerializedName(value = "dirty_update_delay", alternate = {"dirtyUpdateDelay"}) + public int dirtyUpdateDelay = 20; + @SerializedName(value = "ingest_enabled", alternate = {"ingestEnabled"}) public boolean ingestEnabled = true; private static VoxyServerConfig loadOrCreate() { - if (VoxyCommon.isAvailable()) { - var path = getConfigPath(); + Path path = getConfigPath(); + try { if (Files.exists(path)) { try (FileReader reader = new FileReader(path.toFile())) { - var conf = GSON.fromJson(reader, VoxyServerConfig.class); + JsonReader jr = new JsonReader(reader); + jr.setLenient(true); + VoxyServerConfig conf = GSON.fromJson(jr, VoxyServerConfig.class); if (conf != null) { - // conf.save(); // Don't save immediately on load, just return it. This avoids overwriting. + Logger.info("Loaded voxy server config from " + path); return conf; } else { - Logger.error("Failed to load voxy server config (null), resetting"); + Logger.error("Failed to load voxy server config (null), using defaults"); + return new VoxyServerConfig(); } - } catch (Exception e) { // Catch all exceptions including syntax errors - Logger.error("Could not parse config, resetting to default", e); } + } else { + Logger.info("Config not found, creating default at " + path); + VoxyServerConfig config = new VoxyServerConfig(); + config.saveIfMissing(); + return config; } - Logger.info("Server config doesnt exist or failed to load, creating new"); - var config = new VoxyServerConfig(); - config.save(); - return config; - } else { + } catch (Exception e) { + Logger.error("Error loading voxy server config, using defaults", e); return new VoxyServerConfig(); } } public void save() { - if (!VoxyCommon.isAvailable()) { - return; - } - + Path path = getConfigPath(); try { - // Only save if file doesn't exist to prevent overwriting user changes? - // Actually, usually save() is called to persist changes. - // But if we call save() immediately after load(), it just re-formats. - // The issue user reported is "resetting to default". - // That happens if GSON.fromJson returns null or exception. - // OR if we create new instance and overwrite. - - // If we want to preserve comments or formatting, we shouldn't overwrite unless needed. - // But JSON doesn't support comments. - - // If the user says it "resets", maybe loadOrCreate is failing? - // loadOrCreate checks if file exists. - - // Let's modify save to be safe? - Files.writeString(getConfigPath(), GSON.toJson(this)); + Files.createDirectories(path.getParent()); + Files.writeString(path, GSON.toJson(this)); } catch (IOException e) { Logger.error("Failed to write config file", e); } } + public void saveIfMissing() { + Path path = getConfigPath(); + try { + Files.createDirectories(path.getParent()); + if (!Files.exists(path)) { + Files.writeString(path, GSON.toJson(this)); + Logger.info("Created voxy server config at " + path); + } + } catch (IOException e) { + Logger.error("Failed to write missing config file", e); + } + } + + public static VoxyServerConfig reload() { + try (FileReader reader = new FileReader(getConfigPath().toFile())) { + JsonReader jr = new JsonReader(reader); + jr.setLenient(true); + VoxyServerConfig conf = GSON.fromJson(jr, VoxyServerConfig.class); + return conf != null ? conf : new VoxyServerConfig(); + } catch (Exception e) { + Logger.error("Failed to reload config, using defaults", e); + return new VoxyServerConfig(); + } + } + private static Path getConfigPath() { return FabricLoader.getInstance() .getConfigDir() diff --git a/src/main/java/me/cortex/voxy/server/DirtyUpdateService.java b/src/main/java/me/cortex/voxy/server/DirtyUpdateService.java index a68a1c98..d35e3545 100644 --- a/src/main/java/me/cortex/voxy/server/DirtyUpdateService.java +++ b/src/main/java/me/cortex/voxy/server/DirtyUpdateService.java @@ -98,6 +98,7 @@ public class DirtyUpdateService { int voxyDistance = VoxyServerConfig.CONFIG.viewDistance * 16; int standardViewDist = server.getPlayerList().getViewDistance() * 16; int m = (1 << (lvl + 1)) - 1; + int perPlayerBudget = 25; for (int dx = 0; dx <= m; dx++) { for (int dz = 0; dz <= m; dz++) { double centerX = (((sx << (lvl + 1)) | dx) * sectionSize) + (sectionSize / 2.0); @@ -106,14 +107,19 @@ public class DirtyUpdateService { double dzp = player.getZ() - centerZ; double distSq = dxp * dxp + dzp * dzp; if (distSq > standardViewDist * standardViewDist && distSq < voxyDistance * voxyDistance) { - for (int dy = 0; dy <= m; dy++) { - int absX = (sx << (lvl + 1)) | dx; - int absY = (sy << (lvl + 1)) | dy; - int absZ = (sz << (lvl + 1)) | dz; + int absX = (sx << (lvl + 1)) | dx; + int absZ = (sz << (lvl + 1)) | dz; + int minAbsY = (sy << (lvl + 1)); + int maxAbsY = minAbsY + m; + int[] yList = VisibleBandUtil.computeVisibleBands(matchLevel, dirty.world, absX, absZ); + for (int i = 0; i < yList.length && perPlayerBudget > 0; i++) { + int absY = yList[i]; + if (absY < minAbsY || absY > maxAbsY) continue; var voxelized = WorldSectionToVoxelizedConverter.convert(dirty.section, lvl, absX, absY, absZ); if (voxelized.lvl0NonAirCount > 0) { var payload = VoxyNetwork.LodUpdatePayload.create(voxelized, dirty.world.getMapper()); ServerPlayNetworking.send(player, payload); + perPlayerBudget--; } } } diff --git a/src/main/java/me/cortex/voxy/server/PlayerLodTracker.java b/src/main/java/me/cortex/voxy/server/PlayerLodTracker.java index 3baa5878..dd48a079 100644 --- a/src/main/java/me/cortex/voxy/server/PlayerLodTracker.java +++ b/src/main/java/me/cortex/voxy/server/PlayerLodTracker.java @@ -27,6 +27,7 @@ public class PlayerLodTracker { final RingTracker tracker; int lastX = Integer.MIN_VALUE; int lastZ = Integer.MIN_VALUE; + final java.util.Map sentColumns = new java.util.concurrent.ConcurrentHashMap<>(); PlayerState(int radius, int x, int z) { this.tracker = new RingTracker(radius, x, z, true); @@ -87,7 +88,8 @@ public class PlayerLodTracker { // RingTracker is initialized with maxVoxyDist, so it won't generate events beyond that. // But we should double check here? - state.tracker.process(50, (x, z) -> { // Process up to 50 chunks per tick per player + java.util.concurrent.atomic.AtomicInteger budget = new java.util.concurrent.atomic.AtomicInteger(50); + state.tracker.process(20, (x, z) -> { // On Add (Load) double dx = x - state.lastX; double dz = z - state.lastZ; @@ -104,20 +106,36 @@ public class PlayerLodTracker { // 2. Check if inside Voxy Max Distance // This is implicitly handled by RingTracker radius, but good to be explicit. if (distSq > vanillaBoundary * vanillaBoundary && distSq <= maxVoxyDist * maxVoxyDist) { - sendLod(player, engine, x, z); + long key = (((long)x) << 32) ^ (z & 0xffffffffL); + var prog = state.sentColumns.computeIfAbsent(key, k -> new ColumnProgress()); + if (prog.yList == null) { + prog.yList = VisibleBandUtil.computeVisibleBands(level, engine, x, z); + prog.nextIdx = 0; + } + budget.addAndGet(-processVerticalList(player, engine, x, z, prog, budget.get())); } }, (x, z) -> { // On Remove (Unload) - // We don't explicit send unload packets for LODs usually, client manages its own cache/LRU? - // Or maybe we should? Voxy client has its own tracker. - // Sending unloads might be good to free memory. - // But currently there is no S2C_LodUnload packet. - // Client's RenderDistanceTracker handles unloading when it moves. - // So we don't need to do anything here. + long key = (((long)x) << 32) ^ (z & 0xffffffffL); + state.sentColumns.remove(key); }); + if (budget.get() > 0) { + for (var entry : state.sentColumns.entrySet()) { + if (budget.get() <= 0) break; + long key = entry.getKey(); + int x = (int)(key >> 32); + int z = (int)key; + var prog = entry.getValue(); + if (prog.yList == null) { + prog.yList = VisibleBandUtil.computeVisibleBands(level, engine, x, z); + prog.nextIdx = 0; + } + budget.addAndGet(-processVerticalList(player, engine, x, z, prog, budget.get())); + } + } } - private void sendLod(ServerPlayer player, WorldEngine engine, int x, int z) { + private void sendLodSection(ServerPlayer player, WorldEngine engine, int x, int z, int y) { // Decide LOD level based on distance? // For now, let's just send LOD 0 if it exists. // Optimization: Distant things can be higher LOD. @@ -155,28 +173,44 @@ public class PlayerLodTracker { this.instance.getThreadPool().execute(() -> { try { - int minSec = player.level().getMinSectionY(); - int maxSec = player.level().getMaxSectionY(); - - for (int y = minSec; y <= maxSec; y++) { - var sec = engine.acquire(lvl, x >> (lvl + 1), y >> (lvl + 1), z >> (lvl + 1)); - if (sec != null) { - try { - var voxelized = WorldSectionToVoxelizedConverter.convert(sec, lvl, x, y, z); - if (voxelized.lvl0NonAirCount > 0) { - var payload = VoxyNetwork.LodUpdatePayload.create(voxelized, engine.getMapper()); - ServerPlayNetworking.send(player, payload); - } - } finally { - sec.release(); + var sec = engine.acquire(lvl, x >> (lvl + 1), y >> (lvl + 1), z >> (lvl + 1)); + if (sec != null) { + try { + var voxelized = WorldSectionToVoxelizedConverter.convert(sec, lvl, x, y, z); + if (voxelized.lvl0NonAirCount > 0) { + var payload = VoxyNetwork.LodUpdatePayload.create(voxelized, engine.getMapper()); + ServerPlayNetworking.send(player, payload); } - } else { - //Logger.warn("Failed to acquire section " + x + ", " + y + ", " + z); + } finally { + sec.release(); } + } else { + //Logger.warn("Failed to acquire section " + x + ", " + y + ", " + z); } } catch (Exception e) { Logger.error("Error sending LOD to player", e); } }); } + + private int processVerticalList(ServerPlayer player, WorldEngine engine, int x, int z, ColumnProgress prog, int budget) { + if (budget <= 0) return 0; + if (prog.yList == null || prog.nextIdx >= prog.yList.length) return 0; + int step = Math.min(budget, 8); + int sent = 0; + while (sent < step && prog.nextIdx < prog.yList.length) { + int y = prog.yList[prog.nextIdx++]; + sendLodSection(player, engine, x, z, y); + sent++; + } + return sent; + } + + private static final class ColumnProgress { + int[] yList; + int nextIdx; + ColumnProgress() {} + } + + // Visible bands now provided by VisibleBandUtil } diff --git a/src/main/java/me/cortex/voxy/server/VisibleBandUtil.java b/src/main/java/me/cortex/voxy/server/VisibleBandUtil.java new file mode 100644 index 00000000..a92679f1 --- /dev/null +++ b/src/main/java/me/cortex/voxy/server/VisibleBandUtil.java @@ -0,0 +1,44 @@ +package me.cortex.voxy.server; + +import me.cortex.voxy.common.world.WorldEngine; +import net.minecraft.server.level.ServerLevel; + +final class VisibleBandUtil { + static int[] computeVisibleBands(ServerLevel level, WorldEngine engine, int chunkX, int chunkZ) { + int minSec = level.getMinSectionY(); + int maxSec = level.getMaxSectionY(); + int lvl = 0; + int surfaceY = Integer.MIN_VALUE; + for (int y = maxSec; y >= minSec; y--) { + var sec = engine.acquire(lvl, chunkX >> (lvl + 1), y >> (lvl + 1), chunkZ >> (lvl + 1)); + if (sec != null) { + int count = sec.getNonEmptyBlockCount(); + sec.release(); + if (count > 0) { + surfaceY = y; + break; + } + } + } + java.util.ArrayList ys = new java.util.ArrayList<>(); + if (surfaceY != Integer.MIN_VALUE) { + for (int dy = -2; dy <= 3; dy++) { + int yy = surfaceY + dy; + if (yy >= minSec && yy <= maxSec) ys.add(yy); + } + for (int dy = 4; dy <= 6; dy++) { + int yy = surfaceY + dy; + if (yy >= minSec && yy <= maxSec) ys.add(yy); + } + for (int dy = -3; dy <= -1; dy++) { + int yy = surfaceY + dy; + if (yy >= minSec && yy <= maxSec) ys.add(yy); + } + } else { + for (int y = minSec; y <= maxSec; y++) ys.add(y); + } + int[] out = new int[ys.size()]; + for (int i = 0; i < ys.size(); i++) out[i] = ys.get(i); + return out; + } +} diff --git a/src/main/java/me/cortex/voxy/server/VoxyServerInstance.java b/src/main/java/me/cortex/voxy/server/VoxyServerInstance.java index a6b281b4..6a54f027 100644 --- a/src/main/java/me/cortex/voxy/server/VoxyServerInstance.java +++ b/src/main/java/me/cortex/voxy/server/VoxyServerInstance.java @@ -25,7 +25,7 @@ public class VoxyServerInstance extends VoxyInstance { public VoxyServerInstance(MinecraftServer server) { super(); // Ensure config is loaded/created - // VoxyServerConfig.CONFIG.save(); // Don't force save, just accessing it will load it + VoxyServerConfig.CONFIG.saveIfMissing(); this.server = server; this.basePath = server.getWorldPath(LevelResource.ROOT).resolve("voxy");